ESP32를 처음 사용할 때는 대부분 모든 기능을 하나의 loop() 안에 넣어서 개발을 시작합니다.
처음에는 꽤 잘 동작합니다.
센서를 읽고,
LED를 켜고,
Wi-Fi에 연결하고,
서버로 데이터를 보내는 정도는 하나의 loop만으로도 충분히 가능합니다.
하지만 프로젝트가 조금만 커지기 시작하면 문제가 발생합니다.
예를 들어:
- BLE 통신
- Wi-Fi 연결
- HTTPS 업로드
- OLED 디스플레이
- 센서 polling
- Firebase Cloud Messaging(FCM)
- 버튼 입력 처리
같은 기능들이 동시에 들어가기 시작하면,
코드 구조가 급격히 복잡해집니다.
특히 문제가 되는 것은 “시간이 오래 걸리는 작업”입니다.
대표적으로:
- Wi-Fi 연결
- DNS lookup
- TLS handshake
- HTTP 요청
- 서버 응답 대기
같은 작업들은 상황에 따라 수 초 이상 걸릴 수도 있습니다.
만약 이런 작업을 loop() 안에서 직접 처리하면 어떻게 될까요?
그 시간 동안 다른 기능들이 모두 멈추게 됩니다.
예를 들어:
- BLE 응답이 끊기고
- OLED가 멈추고
- 센서 업데이트가 지연되고
- timing이 불안정해지고
- 심하면 watchdog reset까지 발생할 수 있습니다.
ESP32는 단순한 Arduino MCU와 다르게,
내부적으로 FreeRTOS 기반으로 동작합니다.
즉, 내부적으로:
- Wi-Fi stack
- BLE stack
- Idle task
- Timer task
같은 시스템 Task들도 같이 실행되어야 합니다.
그런데 사용자의 코드가 CPU를 너무 오래 점유하면,
다른 Task들이 실행 기회를 얻지 못하게 됩니다.
그래서 ESP32를 제품 수준으로 사용하기 시작하면,
자연스럽게 “멀티태스킹 구조”를 사용하게 됩니다.
핵심 아이디어는 단순합니다.
“시간이 오래 걸리는 작업을 별도의 Task로 분리한다.”
그리고:
“메인 코드가 직접 작업하지 않고, Task에게 요청만 한다.”
이번 글에서는 실제 제품 코드에서 사용하는 구조를 최대한 단순화해서 설명해보겠습니다.
아래 예제는:
- Queue
- Task
- Task Notification
을 이용해서,
FCM 전송 작업을 별도의 Task로 처리하는 구조입니다.
여기서 FCM이란 firebase cloud messaging 서비스의 약자로, FCM은 google cloud 서비스의 일종입니다. http요청을 통해서 사용자의 휴대폰에 어떠한 정보를 전달하는 메세징 서비스입니다.
제 경우, ESP32가 wifi에 접속을 한 상태로 FCM을 통해서 제 휴대폰에 센서 데이터를 전송하는 데 Task를 이용해 처리하고 있습니다. 왜냐하면 FCM이 시간이 많이 걸릴 수 있기 때문에 main loop에서 처리하지않고 Task에서 처리하기 때문입니다.
그럼 아래에 예제 코드를 보도록 하겠습니다:
struct FcmJob {
TaskHandle_t waiter;
};
QueueHandle_t fcmQueue;
TaskHandle_t fcmTask;
void FcmSenderTask(void* parameter) {
for (;;) {
FcmJob job;
if (xQueueReceive(fcmQueue, &job, portMAX_DELAY) == pdTRUE) {
bool success = sendFcmMessage();
if (job.waiter != nullptr) {
xTaskNotify(
job.waiter,
success ? 1 : 0,
eSetValueWithOverwrite
);
}
}
}
}
void setup() {
Serial.begin(115200);
fcmQueue = xQueueCreate(8, sizeof(FcmJob));
xTaskCreatePinnedToCore(
FcmSenderTask,
"FcmSender",
8192,
nullptr,
4,
&fcmTask,
1
);
}
void upload() {
FcmJob job;
job.waiter = xTaskGetCurrentTaskHandle();
xQueueSend(fcmQueue, &job, pdMS_TO_TICKS(200));
uint32_t result;
BaseType_t got = xTaskNotifyWait(
0,
0xFFFFFFFF,
&result,
pdMS_TO_TICKS(30000)
);
if (got == pdTRUE && result == 1) {
Serial.println("FCM success");
} else {
Serial.println("FCM failed or timeout");
}
}
void loop() {
if (WiFi.status() == WL_CONNECTED) {
upload();
}
delay(10000);
}
이 예제의 loop에서는 매 10000초 마다 ESP32가 WiFi에 연결되어 있는지 확인하고, 연결되어 있으면, upload함수를 통해서 FCM을 수행합니다.
이 때 upload함수는 job구조체를 fcmQueue에 실어서 core1에 멀티태스킹을 맡깁니다.
이 코드를 처음 보면 조금 복잡해 보일 수 있습니다.
핵심은:
“FCM 전송을 다른 Task에게 맡긴다”
는 것입니다.
먼저 아래 구조체를 보겠습니다.
struct FcmJob {
TaskHandle_t waiter;
};
여기서 waiter는:
“누가 이 작업을 요청했는가?”
를 저장하는 용도입니다.
나중에 작업이 끝났을 때,
정확히 그 Task에게 결과를 돌려주기 위해 필요합니다.
다음으로 Queue와 Task를 선언합니다.
QueueHandle_t fcmQueue;
TaskHandle_t fcmTask;
여기서:
- Queue는 작업 요청이 쌓이는 공간
- Task는 실제 작업을 수행하는 실행 주체
입니다.
그리고 핵심은 아래 Task입니다.
void FcmSenderTask(void* parameter)
이 Task는 FcmSenderTask 내에서 무한 루프를 돌면서 Queue를 감시합니다.
즉:
“계속 살아 있으면서 작업 요청을 기다리는 구조”
입니다.
그리고 Queue에 작업이 들어오면:
xQueueReceive(fcmQueue, &job, portMAX_DELAY)
요청을 받아옵니다.
여기서 portMAX_DELAY는:
“작업이 들어올 때까지 계속 기다려라”
라는 의미입니다.
즉 평소에는 CPU를 거의 사용하지 않고 sleeping 상태로 기다립니다.
작업이 들어오면 실제 작업을 수행합니다.
bool success = sendFcmMessage();
(sendFcmMessage함수에서 구체적인 http요청 등의 기능을 수행하나, 본글은 Task에 대한 설명이므로 이 함수에 대한 설명은 생략하도록 하겠습니다. 다만, 이 함수가 http요청을 통해서 정보를 cloud에 upload하고 사용자의 휴대폰으로 보낸다라고만 이해하면 됩니다.)
여기서 시간이 오래 걸릴 수 있습니다.
예를 들어:
- Wi-Fi 상태 불안정
- TLS handshake
- 서버 응답 지연
같은 상황이 발생할 수 있습니다.
하지만 중요한 점은:
이 작업이 별도의 Task에서 실행된다는 것입니다.
즉 메인 코드 전체가 멈추지 않습니다.
이제 가장 중요한 부분이 나옵니다.
xTaskNotify(
job.waiter,
success ? 1 : 0,
eSetValueWithOverwrite
);
많은 사람들이 여기서 궁금해합니다.
“result 변수는 어떻게 전달되는가?”
입니다.
처음 보면 마치 다른 Task가 직접 result 변수에 접근하는 것처럼 보입니다.
하지만 실제로는 그렇지 않습니다.
핵심은:
xTaskNotify()는 상대 Task 내부에 존재하는 “Notification 공간”에 값을 저장한다
는 것입니다.
FreeRTOS에서는 각 Task마다 작은 notification mailbox가 하나 존재한다고 생각하면 이해하기 쉽습니다.
즉 개념적으로는:
// 개념적 구조 (실제 구현 아님)
task->notificationValue = 1;
task->notificationPending = true;
같은 일이 일어난다고 생각하면 됩니다.
즉 xTaskNotify()는:
“상대 Task에게 작은 값 하나를 전달하는 기능”
입니다.
그리고 작업을 요청했던 쪽에서는:
uint32_t result;
BaseType_t got = xTaskNotifyWait(
0,
0xFFFFFFFF,
&result,
pdMS_TO_TICKS(30000)
);
를 호출합니다.
이 함수는:
- Notification이 올 때까지 기다리고
- 값이 도착하면
- 내부 Notification 값을 result 변수에 복사합니다.
즉 중요한 점은:
result 변수 자체를 다른 Task가 직접 수정하는 것이 아닙니다.
실제로는:
FcmSenderTask
↓
xTaskNotify()
↓
FreeRTOS 내부 Notification 저장
↓
xTaskNotifyWait()
↓
result 변수로 복사
라는 흐름입니다.
즉 result는 “복사된 값”입니다.
이 구조의 장점은 굉장히 많습니다.
예를 들어:
- global variable 불필요
- mutex 사용 감소
- race condition 감소
- 매우 빠름
- 메모리 사용량 감소
같은 장점이 있습니다.
그래서 FreeRTOS에서는:
- Queue
- Semaphore
- EventGroup
- Notification
중에서도,
“Task 하나에게 간단한 결과값 전달”
에는 xTaskNotify()가 매우 자주 사용됩니다.
이제 upload 함수를 다시 보면 구조가 명확해집니다.
FcmJob job;
job.waiter = xTaskGetCurrentTaskHandle();
현재 Task의 핸들을 저장합니다.
즉:
“작업 끝나면 나한테 알려줘”
라고 등록하는 것입니다.
그 다음 Queue에 작업 요청을 넣습니다.
xQueueSend(fcmQueue, &job, pdMS_TO_TICKS(200));
즉:
“FCM 전송 작업 부탁한다”
라고 요청하는 것입니다.
그리고 결과를 기다립니다.
xTaskNotifyWait(...)
이 구조의 핵심은:
메인 코드가 직접 오래 걸리는 작업을 하지 않는다는 것입니다.
대신:
- 작업은 별도 Task가 수행하고
- 메인 코드는 요청과 결과 처리만 담당합니다.
이것이 멀티태스킹 구조의 핵심입니다.
처음 ESP32를 사용할 때는 보통:
“코드를 어떻게 짜야 하지?”
를 고민합니다.
하지만 제품 수준으로 올라갈수록,
진짜 중요한 것은:
“어떤 작업을 분리해야 하는가?”
가 됩니다.
특히:
- Wi-Fi
- BLE
- HTTP
- Cloud Upload
- Display
- Sensor Polling
처럼 timing과 responsiveness가 중요한 기능들은,
하나의 loop에 모두 넣기보다 역할을 분리하는 것이 훨씬 안정적입니다.
결국 ESP32 멀티태스킹의 핵심은 단순히 “코어가 2개다”가 아닙니다.
진짜 핵심은:
“느린 작업이 시스템 전체를 멈추지 않게 만드는 것”
입니다.
그리고 바로 그 순간부터,
ESP32 코드는 단순한 Arduino 스타일 코드에서 벗어나,
작은 RTOS 기반 시스템처럼 동작하기 시작합니다.
'DIY Electronics' 카테고리의 다른 글
| ESP32 시뮬레이션 도구 (1) | 2026.05.13 |
|---|---|
| 마이크로 컨트롤러 커스텀 보드를 공부하게된 계기 (1) | 2026.05.13 |
| ESP32 멀티태스킹, 왜 꼭 써야 하는가 (FreeRTOS 기반 구조 이해) (1) | 2026.04.30 |
| ESPHome 입문 가이드 - 로컬 스마트홈 기기를 만드는 가장 쉬운 방법 (1) | 2026.04.15 |
| ESP32 Online Tools - IDE 없이 개발하는 방법 (링크 포함) (2) | 2026.04.14 |
