Skip to main content
Summary

delay()는 왜 문제가 될까

delay()는 지정한 시간 동안 프로그램을 멈춘다. LED를 1초 켜고 1초 끄는 예제에서는 편하다. 하지만 버튼, 센서, 화면, 모터가 함께 들어가면 문제가 보인다. 사람이 잠깐 문 앞에서 기다리는 동안 전화도 못 받고, 택배도 못 받고, 불도 못 끄는 상황과 비슷하다.

먼저 문제를 확인해 보자.

준비할 회로는 단순하다.

  • LED 긴 다리: 220Ω~330Ω 저항을 거쳐 D9

  • LED 짧은 다리: GND

  • 버튼 한쪽: D2

  • 버튼 반대쪽: GND

  • 버튼 입력 방식: INPUT_PULLUP

INPUT_PULLUP을 쓰면 버튼을 누르지 않았을 때 HIGH, 눌렀을 때 LOW로 읽힌다.

const int ledPin = 9;
const int buttonPin = 2;

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  digitalWrite(ledPin, HIGH);
  delay(1000);

  digitalWrite(ledPin, LOW);
  delay(1000);

  if (digitalRead(buttonPin) == LOW) {
    Serial.println("button pressed");
  }
}

이 코드는 LED를 깜빡이지만 버튼 반응은 좋지 않다. 버튼을 아주 짧게 누르면 시리얼 모니터에 아무것도 찍히지 않을 수 있다. 이유는 간단하다. 아두이노가 delay(1000) 안에 있는 동안에는 버튼을 읽는 줄까지 도착하지 못한다.

delay()가 항상 나쁜 것은 아니다. 전원을 켠 직후 센서가 안정될 때까지 잠깐 기다리거나, 아주 작은 테스트 코드를 만들 때는 유용하다. 문제는 프로젝트 전체의 시간 관리를 delay()에 맡기는 것이다.

delay를 넘어 안정적인 프로그램 만들기AI 생성 이미지

기다리지 말고 시간을 비교하기

millis()는 아두이노가 켜진 뒤 흐른 시간을 밀리초 단위로 알려 준다. 핵심은 "멈춰서 기다리기"가 아니라 "지금 시간이 지난번보다 충분히 지났는지 확인하기"이다.

LED를 1초마다 깜빡이는 코드를 millis() 방식으로 바꿔 보자.

const int ledPin = 9;

unsigned long previousBlinkTime = 0;
const unsigned long blinkInterval = 1000;

bool ledState = LOW;

void setup() {
  pinMode(ledPin, OUTPUT);
}

void loop() {
  unsigned long currentTime = millis();

  if (currentTime - previousBlinkTime >= blinkInterval) {
    previousBlinkTime = currentTime;
    ledState = !ledState;
    digitalWrite(ledPin, ledState);
  }
}

처음 보면 delay(1000)보다 복잡해 보인다. 하지만 이 구조의 장점은 loop()가 계속 빠르게 돈다는 데 있다. LED를 깜빡일 시간이 아니면 그냥 지나간다. 그 사이에 버튼을 읽고, 센서를 확인하고, 화면을 갱신할 수 있다.

이제 같은 코드에 버튼 입력을 함께 넣어 보자.

const int ledPin = 9;
const int buttonPin = 2;

unsigned long previousBlinkTime = 0;
const unsigned long blinkInterval = 1000;

bool ledState = LOW;

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  unsigned long currentTime = millis();

  if (currentTime - previousBlinkTime >= blinkInterval) {
    previousBlinkTime = currentTime;
    ledState = !ledState;
    digitalWrite(ledPin, ledState);
  }

  if (digitalRead(buttonPin) == LOW) {
    Serial.println("button pressed");
  }
}

이제 LED가 깜빡이는 중에도 버튼을 더 자주 확인한다. 버튼을 누르고 있는 동안 시리얼 메시지가 여러 번 찍힐 수 있는데, 이것은 코드가 멈추지 않고 계속 돌고 있다는 증거이기도 하다. 실제 프로젝트에서는 버튼을 누르는 순간 한 번만 처리하도록, 5장에서 배운 디바운싱과 상태 변화 감지를 함께 사용하면 된다.

여러 작업을 시간표처럼 배치하기

프로젝트에는 보통 여러 주기가 섞인다. LED는 0.5초마다 깜빡이고, 센서는 1초마다 읽고, 화면은 0.3초마다 갱신할 수 있다. 모든 작업을 같은 delay()로 묶으면 가장 느린 작업이 전체를 붙잡는다.

가변저항을 A0에 연결해 센서 역할을 시켜 보자.

  • 가변저항 한쪽: 5V

  • 가변저항 반대쪽: GND

  • 가변저항 가운데 단자: A0

  • LED: D9와 GND 사이, 저항 포함

  • 버튼: D2와 GND 사이

const int ledPin = 9;
const int sensorPin = A0;

unsigned long previousBlinkTime = 0;
unsigned long previousSensorTime = 0;

const unsigned long blinkInterval = 500;
const unsigned long sensorInterval = 1000;

bool ledState = LOW;
int sensorValue = 0;

void setup() {
  pinMode(ledPin, OUTPUT);
  Serial.begin(9600);
}

void loop() {
  unsigned long currentTime = millis();

  if (currentTime - previousBlinkTime >= blinkInterval) {
    previousBlinkTime = currentTime;
    ledState = !ledState;
    digitalWrite(ledPin, ledState);
  }

  if (currentTime - previousSensorTime >= sensorInterval) {
    previousSensorTime = currentTime;
    sensorValue = analogRead(sensorPin);
    Serial.print("sensor: ");
    Serial.println(sensorValue);
  }
}

이 코드는 두 작업을 동시에 실행하는 것처럼 보인다. 실제로 아두이노가 여러 코드를 완전히 동시에 실행하는 것은 아니다. 다만 각 작업을 아주 짧게 처리하고 빠르게 돌아오기 때문에 동시에 움직이는 것처럼 느껴진다. 이런 방식은 작은 보드에서 안정적인 프로그램을 만들 때 매우 중요하다.

점검 기준은 다음과 같다.

  • LED가 멈칫거리면 한 작업이 너무 오래 걸리고 있을 수 있다.

  • 시리얼 출력이 너무 많으면 전체 반응이 느려질 수 있다.

  • 센서값이 늦게 바뀌면 sensorInterval이 너무 길 수 있다.

  • 회로가 맞는데도 보드가 리셋되면 전원 부족이나 모터 노이즈도 함께 의심해야 한다.

상태 머신으로 동작 단계를 나누기

시간 관리가 익숙해지면 다음 문제는 "지금 장치가 어떤 단계에 있는가"이다. 예를 들어 자동 경고 장치를 생각해 보자.

  1. 대기 상태: 버튼을 기다린다.

  2. 측정 상태: 센서값을 읽는다.

  3. 경고 상태: 기준값을 넘으면 LED를 빠르게 깜빡인다.

  4. 복귀 상태: 잠시 쉬고 다시 대기로 돌아간다.

이처럼 동작 단계를 이름 붙여 나누는 방식을 상태 머신이라고 한다. 상태 머신은 복잡한 if문 덩어리를 줄이고, 프로그램의 현재 위치를 분명하게 만든다.

const int buttonPin = 2;
const int ledPin = 9;
const int sensorPin = A0;

const int warningLevel = 700;

enum State {
  WAITING,
  MEASURING,
  WARNING,
  RETURNING
};

State currentState = WAITING;

unsigned long stateStartTime = 0;
unsigned long previousBlinkTime = 0;
bool ledState = LOW;

void setup() {
  pinMode(buttonPin, INPUT_PULLUP);
  pinMode(ledPin, OUTPUT);
  Serial.begin(9600);
}

void loop() {
  unsigned long now = millis();

  if (currentState == WAITING) {
    digitalWrite(ledPin, LOW);
    ledState = LOW;

    if (digitalRead(buttonPin) == LOW) {
      currentState = MEASURING;
      stateStartTime = now;
    }
  }

  else if (currentState == MEASURING) {
    int value = analogRead(sensorPin);
    Serial.print("value: ");
    Serial.println(value);

    if (value >= warningLevel) {
      currentState = WARNING;
      stateStartTime = now;
      previousBlinkTime = now;
      ledState = LOW;
      digitalWrite(ledPin, ledState);
    } else {
      currentState = RETURNING;
      stateStartTime = now;
    }
  }

  else if (currentState == WARNING) {
    if (now - previousBlinkTime >= 100) {
      previousBlinkTime = now;
      ledState = !ledState;
      digitalWrite(ledPin, ledState);
    }

    if (now - stateStartTime >= 3000) {
      currentState = RETURNING;
      stateStartTime = now;
      ledState = LOW;
      digitalWrite(ledPin, ledState);
    }
  }

  else if (currentState == RETURNING) {
    if (now - stateStartTime >= 1000) {
      currentState = WAITING;
    }
  }
}

여기서 중요한 변수는 currentState이다. 이 값 하나가 장치의 현재 단계를 나타낸다. stateStartTime은 그 상태에 들어온 시간을 기억한다. 그래서 경고 상태에 들어온 뒤 3초가 지났는지, 복귀 상태에서 1초가 지났는지 계산할 수 있다.

상태 머신을 만들 때는 먼저 그림이나 표로 상태를 적어 보는 것이 좋다.

상태하는 일다음 상태로 넘어가는 조건
WAITING버튼을 기다림버튼이 눌림
MEASURING센서값 읽기기준 이상이면 경고, 아니면 복귀
WARNINGLED 빠른 깜빡임3초 경과
RETURNING잠시 정리1초 경과

프로그램이 이상하게 움직일 때는 "회로가 틀렸나?"만 보지 말고 "상태가 예상대로 바뀌고 있나?"를 시리얼 모니터로 출력해 확인해야 한다.

인터럽트는 언제 필요할까

millis()와 상태 머신만으로도 많은 프로젝트를 만들 수 있다. 그러나 아주 짧은 신호를 놓치면 안 되는 경우가 있다. 예를 들어 로터리 엔코더의 빠른 회전, 속도 센서의 펄스, 사용자가 아주 짧게 누르는 버튼을 정확히 세고 싶을 때다. 이때 인터럽트를 사용할 수 있다.

인터럽트는 "지금 하던 일을 잠깐 멈추고 이 신호부터 처리하라"는 요청이다. Arduino Uno에서는 보통 D2, D3 핀이 외부 인터럽트에 자주 쓰인다.

버튼이 눌린 횟수를 세는 가장 작은 예는 다음과 같다.

const int buttonPin = 2;

volatile unsigned long pressCount = 0;

unsigned long previousPrintTime = 0;
const unsigned long printInterval = 500;

void setup() {
  pinMode(buttonPin, INPUT_PULLUP);
  Serial.begin(9600);
  attachInterrupt(digitalPinToInterrupt(buttonPin), countPress, FALLING);
}

void loop() {
  unsigned long now = millis();

  if (now - previousPrintTime >= printInterval) {
    previousPrintTime = now;

    noInterrupts();
    unsigned long countCopy = pressCount;
    interrupts();

    Serial.print("count: ");
    Serial.println(countCopy);
  }
}

void countPress() {
  pressCount++;
}

volatile은 이 변수가 일반 코드가 아닌 인터럽트 함수 안에서도 바뀔 수 있음을 알려 주는 표시다. 인터럽트 함수인 countPress() 안에서는 일을 짧게 끝내야 한다. delay()를 넣거나, 오래 걸리는 계산을 하거나, 시리얼 출력을 많이 하는 것은 피해야 한다.

예제에서 pressCount를 바로 출력하지 않고 countCopy로 복사한 이유도 있다. Arduino Uno 같은 8비트 보드에서는 unsigned long처럼 여러 바이트로 된 값을 읽는 도중에 인터럽트가 끼어들 수 있다. 그래서 아주 짧은 순간만 noInterrupts()로 인터럽트를 잠깐 막고 값을 복사한 뒤, 바로 interrupts()로 다시 켠다. 오래 막아 두면 오히려 중요한 신호를 놓칠 수 있으니, 복사처럼 짧은 작업에만 사용해야 한다.

버튼 인터럽트에는 채터링도 생긴다. 한 번 눌렀는데 여러 번 눌린 것처럼 카운트될 수 있다. 따라서 인터럽트는 만능 해결책이 아니다. 느린 버튼 입력은 보통 millis() 기반 디바운싱으로 충분하다. 인터럽트는 정말 놓치면 안 되는 짧고 빠른 신호에 제한적으로 쓰는 것이 좋다.

오래 켜 두는 프로젝트에서 확인할 것

실습 코드는 몇 분만 켜 두는 경우가 많지만, 실제 장치는 몇 시간, 며칠, 몇 주 동안 켜져 있을 수 있다. 이때는 작은 습관이 안정성을 좌우한다.

첫째, millis() 값은 계속 커지다가 언젠가 다시 0 근처로 돌아간다. Arduino Uno 기준으로는 약 49.7일마다 이런 일이 생긴다. 그래서 시간 비교는 다음처럼 해야 한다.

if (now - previousTime >= interval) {
  previousTime = now;
}

반대로 다음처럼 미래 시각을 직접 비교하는 방식은 오버플로 상황에서 문제가 생길 수 있다.

// 피하는 것이 좋은 방식
if (millis() >= previousTime + interval) {
}

둘째, 시간 변수는 int가 아니라 unsigned long을 사용한다. millis()가 반환하는 값의 크기를 담기 위해서다.

셋째, 초기화 순서를 분명히 한다. 핀 모드를 정하기 전에 출력을 기대하지 말고, 외부 전원이 필요한 모터나 센서는 GND 공통 연결과 전원 안정성을 먼저 확인한다. 특히 모터가 켜질 때 보드가 리셋된다면 코드를 고치기 전에 전원 용량과 배선을 점검해야 한다.

넷째, 예외 상황을 코드에 넣는다. 센서값이 읽히지 않거나, 값이 말이 안 되게 튀거나, 통신이 실패할 수 있다. 그런 경우 마지막 정상값을 유지할지, 경고 상태로 들어갈지, 장치를 멈출지 미리 정해야 한다.

마지막으로, 한 번에 모든 기능을 합치지 않는다. LED 깜빡임, 버튼 입력, 센서 읽기, 상태 전환을 각각 확인한 뒤 합친다. 안정적인 프로그램은 긴 코드에서 시작하지 않는다. 작은 코드가 멈추지 않고 반복되는 구조에서 시작한다.

책 목록

아두이노 학습: 기초 회로부터 IoT 캡스톤까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기