728x90
SMALL

안녕하세요 꾸꾸입니다.

LLM과 함께 만든 일주일 운동 루틴 #1

체지방은 줄이고, 근육은 지키는 오전·저녁 분할 루틴

운동 계획을 세울 때마다 비슷한 고민을 하게 됩니다.

근력운동과 유산소를 어떻게 배치해야 할까?
체지방을 줄이면서 근육량도 유지할 수 있을까?
매일 운동하더라도 지루하지 않게 구성할 수 있을까?

그래서 이번에는 LLM을 활용해 일주일 운동 루틴을 만들어봤습니다. 앞으로도 매주 목표와 조건을 조금씩 바꾸면서 새로운 루틴을 만들고, 실제로 활용하기 좋은 형태로 정리해 공유하려고 합니다.

첫 번째 루틴의 주제는 체지방 감량과 근육량 유지·증가입니다. 오전에는 40~50분 동안 부위별 근력운동을 하고, 저녁에는 약 60분 동안 유산소나 코어 운동을 수행하도록 구성했습니다.

이 글은 일반적인 운동 정보와 루틴 예시를 제공합니다. 부상 이력, 질환, 통증이 있거나 운동 경험이 많지 않다면 전문가와 상담하고 운동량을 개인 상태에 맞게 조절하세요.


이번 주 운동 목표

구분 목표
기간 2026년 7월 20일(월) ~ 7월 26일(일)
핵심 목표 체지방 감량, 근육량 유지 및 증가
오전 운동 근력 중심, 40~50분
저녁 운동 유산소·코어·회복 중심, 약 60분
루틴 방향 부위별 분할과 서로 다른 유산소 자극 활용
회복 전략 일요일 회복 운동, 매일 스트레칭과 수면 관리

일주일 루틴 한눈에 보기

요일 오전 운동 저녁 운동 주요 자극
월요일 가슴 + 삼두 경사 걷기 50분 + 스트레칭 10분 상체 밀기, 저강도 유산소
화요일 등 + 이두 실내 자전거 60분 상체 당기기, 중강도 유산소
수요일 하체 코어 30분 + 빠르게 걷기 30분 하체 후면, 복부 안정성
목요일 어깨 걷기 2분·달리기 1분 × 20세트 어깨 전반, 인터벌
금요일 팔 + 복근 스텝밀 45분 + 스트레칭 15분 팔 집중, 계단 유산소
토요일 전신 서킷 야외 걷기 또는 가벼운 자전거 60분 전신 컨디셔닝
일요일 휴식 또는 요가 30분 가벼운 산책 60분 + 폼롤러 회복, 가동성

월요일: 가슴 + 삼두

한 주의 시작은 상체 밀기 운동입니다. 가슴 운동을 먼저 수행한 뒤 삼두를 마무리하고, 저녁에는 경사 걷기로 관절 부담을 낮추면서 유산소 시간을 확보합니다.

오전 운동 — 45분

순서 운동 세트 × 반복
워밍업 러닝머신 걷기 + 어깨 스트레칭 5분
1 벤치프레스 4세트 × 8~10회
2 인클라인 덤벨프레스 3세트 × 10회
3 체스트프레스 머신 3세트 × 12회
4 케이블 플라이 3세트 × 15회
5 케이블 푸시다운 3세트 × 12회

저녁 운동 — 60분

운동 설정 시간
러닝머신 경사 걷기 경사 10%, 속도 5.8~6km/h 50분
마무리 스트레칭 가슴·어깨·하체 중심 10분

화요일: 등 + 이두

월요일의 밀기 동작과 반대되는 당기기 운동으로 구성했습니다. 등 근육을 여러 각도에서 사용하고, 저녁에는 실내 자전거로 하체에 다른 형태의 유산소 자극을 줍니다.

오전 운동

순서 운동 세트
1 랫풀다운 4세트
2 시티드 로우 3세트
3 원암 덤벨 로우 3세트
4 스트레이트암 풀다운 3세트
5 바벨 컬 3세트
6 해머 컬 2세트

저녁 운동

운동 강도 시간
실내 자전거 대화가 가능한 중간 강도 60분

수요일: 하체 + 코어

스쿼트와 루마니안 데드리프트를 중심으로 하체 전반을 훈련합니다. 저녁에는 무거운 하체 운동을 반복하지 않고 코어와 빠른 걷기로 구성합니다.

오전 운동

순서 운동 세트
1 스쿼트 4세트
2 루마니안 데드리프트 3세트
3 레그프레스 3세트
4 레그 컬 3세트
5 카프레이즈 4세트

저녁 운동

구분 구성 시간
코어 플랭크, 사이드 플랭크, 데드버그, 레그레이즈 30분
유산소 빠르게 걷기 30분

목요일: 어깨 + 인터벌 러닝

어깨의 전면·측면·후면을 고르게 자극하고 승모근 운동으로 마무리합니다. 저녁 인터벌은 이번 주 유산소 가운데 강도가 가장 높으므로 컨디션을 확인하면서 진행합니다.

오전 운동

순서 운동 목표
1 밀리터리 프레스 어깨 전반
2 덤벨 숄더프레스 전면·측면 삼각근
3 레터럴 레이즈 측면 삼각근
4 리어델트 플라이 후면 삼각근
5 슈러그 승모근
합계 5개 종목 총 18세트

저녁 운동

구간 시간 반복
걷기 2분 20회
달리기 1분 20회
합계 1세트당 3분 총 60분

인터벌 20세트가 부담스럽다면 812세트부터 시작해 매주 12세트씩 늘리는 방식이 안전합니다. 무릎이나 발목에 불편함이 있다면 달리기를 빠른 걷기 또는 자전거 인터벌로 바꿔도 좋습니다.


금요일: 팔 + 복근

가슴과 등 운동에서 보조적으로 사용한 이두와 삼두를 직접 훈련하는 날입니다. 저녁에는 스텝밀을 활용해 심폐 지구력과 하체 근지구력을 함께 자극합니다.

오전 운동

부위 운동
이두 EZ바 컬, 인클라인 컬, 해머 컬
삼두 케이블 푸시다운, 오버헤드 익스텐션, 딥스
복근 크런치, 케이블 크런치

저녁 운동

운동 시간
계단 오르기 또는 스텝밀 45분
스트레칭 15분

토요일: 전신 서킷

토요일 오전에는 웨이트 트레이닝보다 역동적인 전신 서킷을 진행합니다. 여러 동작을 연속으로 수행하기 때문에 짧은 시간에도 심박수를 높이고 전신 근육을 활용할 수 있습니다.

오전 운동

순서 운동 구성
1 스쿼트 서킷
2 푸시업 서킷
3 버피 서킷
4 케틀벨 스윙 서킷
5 마운틴 클라이머 서킷
합계 5개 종목 연속 수행 4라운드

운동 사이에는 가능한 한 짧게 쉬고, 라운드가 끝나면 1~2분 정도 회복합니다. 자세가 무너지기 시작하면 속도보다 정확한 동작을 우선합니다.

저녁 운동

선택 운동 강도 시간
야외 걷기 가벼운 강도 60분
가벼운 자전거 회복 가능한 강도 60분

일요일: 회복

일요일은 다음 주를 위한 회복일입니다. 완전히 쉬어도 괜찮고, 몸이 뻣뻣하다면 가벼운 요가와 산책으로 혈액순환을 돕습니다.

시간대 운동 시간
오전 휴식 또는 요가 30분
저녁 가벼운 산책 60분
마무리 폼롤러 + 스트레칭 컨디션에 맞게

점진적 과부하 목표

근육량을 유지하거나 늘리려면 매주 같은 중량과 반복 횟수에 머무르지 않는 것이 중요합니다. 이번 주에는 각 운동에서 다음 두 가지 중 하나를 목표로 합니다.

  • 지난주보다 같은 중량으로 1~2회 더 수행하기
  • 자세를 유지할 수 있다면 2.5kg 증량하기

두 가지를 동시에 달성할 필요는 없습니다. 반복 횟수와 자세가 안정된 뒤 중량을 올리는 방식으로 진행합니다. 모든 세트를 실패 지점까지 수행하기보다는 대부분의 세트에서 1~3회 정도 더 할 수 있는 여유를 남기는 편이 회복에 유리합니다.


식단과 생활 습관

항목 이번 주 목표
단백질 하루 150g 이상
수분 하루 2.5L 이상
운동 후 영양 저녁 운동 후 단백질 보충
걸음 수 하루 8,000~10,000보
수면 하루 7시간 이상
체중 측정 월요일·토요일 아침

단백질 150g은 이 루틴에서 제시한 예시 목표입니다. 실제 필요량은 체중, 체지방률, 총섭취 열량과 운동량에 따라 달라질 수 있습니다. 일반적으로는 하루 총량을 여러 끼에 나누어 섭취하는 것이 실천하기 쉽습니다.


이번 주 체크리스트

  • 운동 12회 이상 완료
  • 하루 8,000~10,000보 걷기
  • 하루 단백질 목표 달성
  • 하루 2.5L 이상 물 마시기
  • 하루 7시간 이상 수면
  • 월요일·토요일 아침 체중 측정
  • 운동 중 통증과 피로도 기록

루틴을 진행할 때 확인할 점

이번 루틴은 하루 두 번 운동하는 날이 많고 유산소 목표도 높은 편입니다. 운동 경험이 적거나 최근 운동을 쉬었다면 전체 계획을 한 번에 수행하기보다 오전 운동 또는 저녁 운동 중 하나부터 시작하는 것이 좋습니다.

다음과 같은 신호가 이어진다면 운동량을 줄이거나 휴식일을 추가합니다.

  • 평소보다 수면의 질이 계속 떨어질 때
  • 안정 시 심박수가 눈에 띄게 높아질 때
  • 근육통과 관절 통증이 며칠 동안 사라지지 않을 때
  • 같은 중량이 갑자기 무겁게 느껴질 때
  • 운동 의욕과 집중력이 지속해서 떨어질 때

특히 하체 운동 다음 날의 인터벌 러닝과 금요일 스텝밀은 피로가 누적될 수 있습니다. 회복이 부족하다면 목요일 인터벌을 저강도 자전거로 바꾸거나 금요일 유산소 시간을 20~30분으로 줄여도 됩니다.


마치며

LLM을 활용하면 운동 목적, 가능한 시간, 선호 종목과 운동 장소 같은 조건을 반영해 일주일 계획의 초안을 빠르게 만들 수 있습니다. 다만 LLM이 만든 루틴이 모든 사람에게 그대로 맞는 것은 아닙니다. 운동 경력과 회복력, 통증 여부를 기준으로 세트 수와 강도를 조절하는 과정이 꼭 필요합니다.

이번 첫 번째 루틴은 부위별 근력운동과 다양한 유산소를 결합해 체지방 감량과 근육 유지에 초점을 맞춘 구성이었습니다. 4~6주 동안 운동 기록을 남기면서 반복 횟수나 중량을 점진적으로 높이고, 피로가 쌓이면 한 주 정도 전체 운동량을 낮춰 회복하는 방식으로 활용할 수 있습니다.

다음 글에서는 새로운 목표와 조건을 적용한 또 다른 일주일 루틴을 소개하겠습니다.

여러분이라면 다음 루틴에 어떤 목표를 넣고 싶으신가요?
운동 가능 시간, 장소, 사용 가능한 기구를 함께 남겨주시면 다음 LLM 운동 루틴의 조건으로 활용해보겠습니다.

728x90
LIST
728x90
SMALL

ESP32 Arduino 장시간 네트워크 테스트 로그 설계하기

안녕하세요. 꾸꾸입니다.

지난 글에서는 ESP32 Arduino 환경에서 TCP 통신 중 1~2시간마다 간헐적으로 리셋되는 문제를 정리했습니다.

Core Dump를 확인했을 때 lwIP 관련 함수가 보였고, 이때 중요한 것은 lwIP가 보였다는 사실만으로 바로 lwIP 자체 버그라고 단정하지 않는 것이라고 이야기했습니다.

이번 글에서는 그 다음 단계로, 이런 간헐적 리셋 문제를 추적하기 위해 어떤 로그를 남겨야 하는지 정리해보려고 합니다.

사실 장시간 테스트에서 발생하는 문제는 로그 없이는 잡기가 어렵습니다.

특히 ESP32처럼 WiFi, TCP, FreeRTOS, lwIP, 사용자 코드가 함께 얽혀 있는 환경에서는 리셋 직전 상태를 최대한 남겨야 원인을 좁힐 수 있습니다.

이번 글은 최종 해결책이라기보다는, 문제를 잡기 위한 로그 설계 기록에 가깝습니다.

왜 로그 설계가 중요할까?

짧은 테스트에서 바로 죽는 문제라면 원인을 찾기가 비교적 쉽습니다.

코드를 수정하고, 다시 실행하고, 바로 결과를 볼 수 있기 때문입니다.

하지만 1~2시간에 한 번씩 발생하는 문제는 다릅니다.

언제 죽을지 모른다.
항상 같은 위치에서 죽지 않는다.
시리얼 모니터를 계속 보고 있기 어렵다.
리셋되면 직전 상태가 사라진다.
Core Dump만으로는 원인이 부족할 수 있다.

이런 문제는 크래시 순간의 코드 위치뿐만 아니라, 크래시 직전까지 시스템이 어떤 상태였는지를 함께 봐야 합니다.

Core Dump가 "어디서 죽었는지"를 보여준다면, 로그는 "죽기 전까지 어떤 일이 있었는지"를 보여줍니다.

저는 이번 문제를 보면서 이런 생각이 들었습니다.

간헐적 리셋 문제를 잡으려면 코드를 바로 고치기 전에,
먼저 리셋 직전 단서를 남길 수 있는 로그 구조를 만들어야 한다.

로그로 확인해야 할 핵심 항목

ESP32 TCP 장시간 테스트에서 남겨야 할 로그는 크게 아래 항목으로 나눌 수 있습니다.

1. WiFi 상태
2. TCP 연결 상태
3. 송수신 성공/실패 카운트
4. 재연결 횟수
5. Heap / Min Heap
6. 마지막 정상 패킷 시간
7. 마지막 에러 시간
8. 리셋 원인
9. Core Dump 직전 상태

처음부터 너무 많은 로그를 남기면 오히려 분석하기 어렵습니다.

그래서 저는 로그를 세 가지 종류로 나눠서 생각하는 것이 좋다고 봅니다.

상태 로그: 주기적으로 현재 상태를 출력
이벤트 로그: 연결/해제/실패 같은 사건이 발생했을 때 출력
에러 로그: 비정상 상황이 발생했을 때 출력

이렇게 나누면 장시간 테스트 로그를 볼 때도 흐름이 조금 더 잘 보입니다.

1. 부팅 시 리셋 원인 남기기

ESP32가 리셋되었다면, 다음 부팅 시 가장 먼저 리셋 원인을 남기는 것이 좋습니다.

리셋 원인을 보면 전원 문제인지, Watchdog인지, Panic인지, Software Reset인지 대략적인 방향을 잡을 수 있습니다.

void printResetReason()
{
    esp_reset_reason_t reason = esp_reset_reason();

    Serial.printf("[BOOT] reset reason=%d\n", reason);
}

숫자만 출력하면 나중에 보기 불편하니 문자열로 변환해두면 더 좋습니다.

const char* resetReasonToString(esp_reset_reason_t reason)
{
    switch (reason) {
        case ESP_RST_POWERON:   return "POWERON";
        case ESP_RST_EXT:       return "EXT";
        case ESP_RST_SW:        return "SW";
        case ESP_RST_PANIC:     return "PANIC";
        case ESP_RST_INT_WDT:   return "INT_WDT";
        case ESP_RST_TASK_WDT:  return "TASK_WDT";
        case ESP_RST_WDT:       return "WDT";
        case ESP_RST_DEEPSLEEP: return "DEEPSLEEP";
        case ESP_RST_BROWNOUT:  return "BROWNOUT";
        case ESP_RST_SDIO:      return "SDIO";
        default:                return "UNKNOWN";
    }
}

void printResetReason()
{
    esp_reset_reason_t reason = esp_reset_reason();

    Serial.printf("[BOOT] reset reason=%d(%s)\n",
                  reason,
                  resetReasonToString(reason));
}

이 로그는 setup() 초반에 찍는 것이 좋습니다.

void setup()
{
    Serial.begin(115200);
    delay(1000);

    printResetReason();
}

리셋 원인이 PANIC, TASK_WDT, BROWNOUT 중 무엇인지에 따라 봐야 할 방향이 달라집니다.

2. WiFi 상태 로그

TCP 통신 문제를 분석할 때 WiFi 상태는 반드시 같이 봐야 합니다.

WiFi가 순간적으로 끊긴 뒤 TCP 코드가 비정상 상태로 들어가는 경우가 있을 수 있기 때문입니다.

주기적으로 아래 정보를 남기면 좋습니다.

WiFi.status()
IP 주소
RSSI
Gateway
DNS

예시 코드는 아래와 같습니다.

void printWiFiStatus()
{
    Serial.printf("[WIFI] status=%d, ip=%s, gateway=%s, dns=%s, rssi=%d\n",
                  WiFi.status(),
                  WiFi.localIP().toString().c_str(),
                  WiFi.gatewayIP().toString().c_str(),
                  WiFi.dnsIP().toString().c_str(),
                  WiFi.RSSI());
}

여기서 RSSI는 WiFi 신호 세기를 보는 데 도움이 됩니다.

장시간 테스트 중 RSSI가 계속 낮거나, 특정 시점에 급격히 나빠진다면 공유기 위치나 안테나 환경도 의심해야 합니다.

3. WiFi 이벤트 로그

주기적인 상태 로그도 중요하지만, WiFi 이벤트 로그는 더 중요할 수 있습니다.

WiFi가 끊겼는지, 다시 연결되었는지, IP를 새로 받았는지 확인할 수 있기 때문입니다.

void onWiFiEvent(WiFiEvent_t event)
{
    Serial.printf("[WIFI_EVENT] event=%d, millis=%lu\n",
                  event,
                  millis());
}

void setup()
{
    Serial.begin(115200);

    WiFi.onEvent(onWiFiEvent);
    WiFi.begin("ssid", "password");
}

다만 이벤트 콜백 안에서는 복잡한 처리를 하지 않는 것이 좋습니다.

WiFi 이벤트 콜백은 일반적인 loop() 흐름과 다른 Task에서 호출될 수 있습니다.

그래서 콜백 안에서는 상태 기록이나 플래그 변경 정도만 하고, 실제 재연결 처리는 별도 네트워크 Task나 loop()에서 처리하는 편이 안전합니다.

volatile bool wifiDisconnected = false;

void onWiFiEvent(WiFiEvent_t event)
{
    Serial.printf("[WIFI_EVENT] event=%d, millis=%lu\n",
                  event,
                  millis());

    // 실제 reconnect 처리는 여기서 바로 하지 않고,
    // loop 또는 network task에서 처리하는 방식으로 분리한다.
}

4. TCP 연결 상태 로그

WiFi가 연결되어 있다고 해서 TCP 연결이 정상이라는 뜻은 아닙니다.

그래서 TCP 상태도 별도로 남겨야 합니다.

void printTcpStatus(WiFiClient& client)
{
    Serial.printf("[TCP] connected=%d, available=%d\n",
                  client.connected(),
                  client.available());
}

TCP 연결 시도도 반드시 로그로 남기는 것이 좋습니다.

bool connectTcp(WiFiClient& client, const char* host, uint16_t port)
{
    Serial.printf("[TCP] connecting to %s:%u\n", host, port);

    bool ok = client.connect(host, port);

    Serial.printf("[TCP] connect result=%d\n", ok);

    return ok;
}

장시간 테스트에서는 연결 성공보다 연결 실패 로그가 더 중요할 때가 많습니다.

실패가 발생한 시각, 실패 직전 WiFi 상태, 실패 직전 Heap 상태를 함께 보면 원인을 좁히기 쉽습니다.

5. 송수신 성공/실패 카운트

단순히 write failed만 출력하면 전체 흐름을 보기 어렵습니다.

장시간 테스트에서는 성공 횟수와 실패 횟수를 카운트하는 것이 좋습니다.

struct NetStats {
    uint32_t txOk = 0;
    uint32_t txFail = 0;
    uint32_t rxOk = 0;
    uint32_t rxTimeout = 0;
    uint32_t reconnectCount = 0;
    uint32_t disconnectCount = 0;
};

NetStats gNetStats;

송신 함수에서는 성공/실패를 구분해서 카운트합니다.

bool sendTcpPacket(WiFiClient& client, const uint8_t* data, size_t length)
{
    if (!client.connected()) {
        gNetStats.txFail++;
        Serial.println("[TCP] write skipped. client disconnected.");
        return false;
    }

    size_t written = client.write(data, length);

    if (written != length) {
        gNetStats.txFail++;
        Serial.printf("[TCP] write failed. request=%u, written=%u\n",
                      length, written);
        return false;
    }

    gNetStats.txOk++;
    return true;
}

주기적으로 통계도 출력합니다.

void printNetStats()
{
    Serial.printf("[STAT] txOk=%u, txFail=%u, rxOk=%u, rxTimeout=%u, reconnect=%u, disconnect=%u\n",
                  gNetStats.txOk,
                  gNetStats.txFail,
                  gNetStats.rxOk,
                  gNetStats.rxTimeout,
                  gNetStats.reconnectCount,
                  gNetStats.disconnectCount);
}

이런 카운트가 있으면 크래시 직전까지 실패가 누적되었는지, 아니면 정상 통신 중 갑자기 죽었는지 구분할 수 있습니다.

6. Heap / Min Heap 로그

1~2시간 뒤에 발생하는 리셋은 메모리와도 관련이 있을 수 있습니다.

특히 Arduino 환경에서는 String 사용, 반복적인 동적 할당, 연결/해제 반복으로 인해 heap이 줄거나 단편화될 수 있습니다.

그래서 최소한 아래 두 가지는 주기적으로 봐야 합니다.

현재 free heap
테스트 시작 후 가장 낮았던 min free heap
void printMemoryStatus()
{
    Serial.printf("[MEM] freeHeap=%u, minFreeHeap=%u, maxAllocHeap=%u\n",
                  ESP.getFreeHeap(),
                  ESP.getMinFreeHeap(),
                  ESP.getMaxAllocHeap());
}

freeHeap이 일시적으로 회복되더라도 minFreeHeap이 계속 낮아진다면 장시간 안정성을 의심해야 합니다.

maxAllocHeap은 연속으로 할당 가능한 가장 큰 heap 블록을 보는 데 도움이 됩니다.

현재 free heap은 충분해 보여도 max alloc heap이 작다면 단편화가 진행되고 있을 수 있습니다.

7. 마지막 정상 패킷과 마지막 에러 시간

간헐적 리셋 문제에서는 "마지막으로 정상 동작한 시점"이 중요합니다.

그래서 마지막 정상 송신 시간, 마지막 정상 수신 시간, 마지막 에러 시간을 기록해두면 좋습니다.

uint32_t gLastTxOkMs = 0;
uint32_t gLastRxOkMs = 0;
uint32_t gLastErrorMs = 0;

void markTxOk()
{
    gLastTxOkMs = millis();
}

void markRxOk()
{
    gLastRxOkMs = millis();
}

void markError()
{
    gLastErrorMs = millis();
}

상태 출력에 함께 포함합니다.

void printLastEventTimes()
{
    Serial.printf("[TIME] lastTxOk=%lu, lastRxOk=%lu, lastError=%lu, now=%lu\n",
                  gLastTxOkMs,
                  gLastRxOkMs,
                  gLastErrorMs,
                  millis());
}

리셋 직전 로그에서 lastTxOk 이후 갑자기 멈췄는지, lastError가 먼저 찍혔는지 확인할 수 있습니다.

8. 주기 로그와 이벤트 로그를 분리하기

장시간 테스트에서는 로그가 너무 많아지면 오히려 보기 어렵습니다.

그래서 주기 로그와 이벤트 로그를 분리하는 것이 좋습니다.

주기 로그는 예를 들어 10초 또는 30초마다 한 번씩 출력합니다.

const uint32_t STATUS_LOG_INTERVAL_MS = 30000;
uint32_t gLastStatusLogMs = 0;

void printPeriodicStatus(WiFiClient& client)
{
    uint32_t now = millis();

    if (now - gLastStatusLogMs < STATUS_LOG_INTERVAL_MS) {
        return;
    }

    gLastStatusLogMs = now;

    Serial.println("----- [STATUS] -----");
    printWiFiStatus();
    printTcpStatus(client);
    printMemoryStatus();
    printNetStats();
    printLastEventTimes();
}

반면 이벤트 로그는 사건이 발생했을 때 즉시 출력합니다.

[TCP] connected
[TCP] disconnected
[TCP] write failed
[WIFI_EVENT] disconnected
[NET] reconnect start
[NET] reconnect success

이렇게 나누면 로그를 볼 때 시스템의 기본 상태와 갑작스러운 사건을 구분하기 쉽습니다.

9. 로그 포맷을 일정하게 만들기

로그는 나중에 사람이 읽기 쉬워야 합니다.

그래서 포맷을 일정하게 맞추는 것이 좋습니다.

저는 아래처럼 태그를 앞에 붙이는 방식을 선호합니다.

[BOOT] 부팅/리셋 관련
[WIFI] WiFi 현재 상태
[WIFI_EVENT] WiFi 이벤트
[TCP] TCP 연결/송수신
[MEM] 메모리 상태
[STAT] 통계
[ERR] 에러
[TIME] 마지막 이벤트 시간

예시 로그는 이런 식입니다.

[BOOT] reset reason=3(PANIC)
[WIFI] status=3, ip=192.168.1.50, gateway=192.168.1.1, dns=8.8.8.8, rssi=-55
[TCP] connected=1, available=0
[MEM] freeHeap=178432, minFreeHeap=143216, maxAllocHeap=102400
[STAT] txOk=12150, txFail=2, rxOk=12148, rxTimeout=1, reconnect=1, disconnect=1
[TIME] lastTxOk=7260000, lastRxOk=7259950, lastError=7120000, now=7270000

이 정도면 리셋 직전 상태를 비교하기 훨씬 쉬워집니다.

10. Core Dump와 직전 로그를 함께 보기

Core Dump만 보면 "어디서 죽었는지"는 알 수 있습니다.

하지만 왜 그 상태가 되었는지는 로그를 함께 봐야 합니다.

예를 들어 Core Dump에서 lwip_send 또는 WiFiClient::write 근처가 보였다고 가정해보겠습니다.

이때 직전 로그가 아래와 같다면 TCP 송신 실패 이후 상태 처리가 문제였을 수 있습니다.

[TCP] write failed. request=128, written=0
[TCP] connected=0, available=0
[NET] reconnect start

반대로 직전까지 상태가 안정적이었다면 다른 방향도 의심해야 합니다.

[MEM] freeHeap=42120, minFreeHeap=18000, maxAllocHeap=4096

이런 로그가 보이면 메모리 부족이나 heap fragmentation을 의심할 수 있습니다.

또는 WiFi 이벤트가 먼저 발생했을 수도 있습니다.

[WIFI_EVENT] event=DISCONNECTED
[TCP] write failed. request=128, written=0

이런 경우 WiFi 끊김 이후 TCP 송신 코드가 어떤 상태로 흘러갔는지 봐야 합니다.

결국 Core Dump와 로그는 따로 보는 것이 아니라 함께 봐야 합니다.

11. 리셋 직전 로그를 보존하는 방법

시리얼 모니터만 보고 있으면 리셋 직전 로그를 놓칠 수 있습니다.

그래서 장시간 테스트에서는 PC에서 시리얼 로그를 파일로 저장하는 것이 좋습니다.

예를 들어 PlatformIO를 사용한다면 시리얼 모니터 로그를 터미널에서 파일로 남기는 방식을 사용할 수 있습니다.

또는 별도 시리얼 터미널 프로그램에서 로그 저장 기능을 켜두는 것도 방법입니다.

중요한 것은 테스트가 끝난 뒤 아래 정보를 확인할 수 있어야 한다는 점입니다.

리셋 직전 마지막 100줄
리셋 직후 BOOT 로그
Core Dump / Backtrace
테스트 시작 후 경과 시간
WiFi 이벤트 발생 여부
Heap 변화 추이
TCP 실패 누적 여부

나중에는 중요 로그를 NVS나 파일 시스템에 일부 저장하는 방식도 생각해볼 수 있습니다.

다만 플래시에 너무 자주 쓰면 수명 문제가 생길 수 있으니, 처음에는 PC 쪽에 시리얼 로그를 저장하는 방식이 더 단순합니다.

12. 최소 테스트 코드 구조

전체 코드를 한 번에 바꾸기 어렵다면, 먼저 로그 함수만 분리해서 넣는 것도 좋습니다.

대략적인 구조는 아래와 같이 잡을 수 있습니다.

void setup()
{
    Serial.begin(115200);
    delay(1000);

    printResetReason();

    WiFi.onEvent(onWiFiEvent);
    WiFi.begin("ssid", "password");
}

void loop()
{
    handleNetworkState();
    printPeriodicStatus(client);
}

네트워크 처리와 로그 출력을 완전히 섞어버리면 나중에 보기 어려워집니다.

가능하면 아래처럼 역할을 분리하는 편이 좋습니다.

handleNetworkState(): WiFi/TCP 연결 상태 처리
sendTcpPacket(): TCP 송신
readTcpPacket(): TCP 수신
printPeriodicStatus(): 주기 상태 로그
onWiFiEvent(): WiFi 이벤트 기록

이렇게 나눠두면 나중에 상태 머신으로 확장하기도 좋습니다.

테스트할 때 보고 싶은 패턴

로그를 남기기 시작했다면 이제 패턴을 봐야 합니다.

아래 질문에 답할 수 있으면 원인을 좁히는 데 도움이 됩니다.

리셋 직전 WiFi 이벤트가 있었는가?
리셋 직전 TCP write 실패가 있었는가?
txFail이 꾸준히 증가하고 있었는가?
reconnectCount가 증가한 뒤 리셋되었는가?
freeHeap 또는 maxAllocHeap이 계속 줄어들었는가?
RSSI가 낮거나 불안정했는가?
리셋 원인이 PANIC인지 WDT인지 BROWNOUT인지?
Core Dump 위치와 마지막 로그가 연결되는가?

이 질문에 답할 수 있어야 다음 단계로 넘어갈 수 있습니다.

로그가 없다면 대부분 추측이 됩니다.

로그가 있으면 가설을 세울 수 있습니다.

마무리

ESP32 Arduino에서 TCP 통신 중 간헐적 리셋이 발생하면 바로 코드를 고치고 싶어집니다.

하지만 1~2시간에 한 번씩 발생하는 문제는 추측으로 수정하면 더 길어질 가능성이 큽니다.

먼저 리셋 직전 단서를 남길 수 있는 로그를 설계해야 합니다.

이번 글에서 정리한 핵심은 아래와 같습니다.

부팅 시 리셋 원인을 남긴다.
WiFi 상태와 TCP 상태를 분리해서 본다.
WiFi 이벤트를 기록한다.
송수신 성공/실패 카운트를 남긴다.
Heap과 Min Heap을 주기적으로 확인한다.
마지막 정상 패킷과 마지막 에러 시간을 기록한다.
주기 로그와 이벤트 로그를 분리한다.
Core Dump와 직전 로그를 함께 본다.

이번 로그 설계가 제대로 되면, 다음 단계에서는 TCP 재연결 구조를 더 안정적으로 만드는 방향으로 넘어갈 수 있을 것 같습니다.

다음 글에서는 ESP32 TCP 재연결 상태 머신 만들기에 대해 정리해보려고 합니다.

참고 자료

728x90
LIST
728x90
SMALL

ESP32 Arduino TCP 통신 중 간헐적 리셋 문제 분석

안녕하세요. 꾸꾸입니다.

이번 글은 ESP32 Arduino 환경에서 TCP 통신을 하던 중 겪은 간헐적 리셋 문제에 대한 정리입니다.

현재 ESP32에서 상위 장비 또는 서버와 TCP로 통신하는 구조를 테스트하고 있었습니다.

처음에는 정상적으로 연결되고, 데이터 송수신도 잘 되는 것처럼 보였습니다.

그런데 이상하게도 장시간 테스트를 돌려보면 1~2시간에 한 번씩 ESP32가 리셋되는 경우가 발생했습니다.

항상 같은 시점에 죽는 것도 아니고, 항상 같은 패킷에서 죽는 것도 아니었습니다.

짧게 테스트하면 잘 동작하는데, 오래 켜두면 간헐적으로 리셋되는 형태였습니다.

임베디드에서 이런 문제가 제일 피곤합니다.

재현은 되는데 바로 되지는 않고, 로그를 보고 있어도 한참 뒤에 죽고, 죽는 순간에 남는 정보가 부족하면 원인을 좁히기가 어렵습니다.

그래서 이번에는 ESP32 Core Dump를 확인해봤고, 그 안에서 lwIP 관련 흔적을 발견했습니다.

문제 상황

먼저 상황을 정리하면 이렇습니다.

보드: ESP32
개발 환경: Arduino ESP32
통신 방식: WiFi 기반 TCP 통신
증상: 1~2시간에 한 번씩 간헐적 리셋
특징: 짧은 테스트에서는 정상, 장시간 테스트에서 불규칙하게 발생
단서: Core Dump 확인 시 lwIP 관련 함수가 보임

처음에는 단순히 WiFi가 끊겼다가 다시 붙는 문제라고 생각했습니다.

하지만 WiFi 끊김만으로 보드가 리셋되는 것은 이상했습니다.

정상적인 코드라면 WiFi가 끊겨도 재연결을 시도하거나, TCP 연결을 닫고 다시 연결하면 됩니다.

그런데 실제로는 보드 자체가 리셋되었습니다.

이 시점부터는 단순 연결 문제가 아니라, 네트워크 처리 중 특정 조건에서 크래시가 발생하는 문제로 봐야 했습니다.

처음 의심했던 것들

ESP32가 장시간 동작 중 리셋될 때는 의심할 수 있는 원인이 많습니다.

처음에는 아래 항목들을 먼저 의심했습니다.

전원 불안정
WiFi 신호 약함
상위 서버 또는 장비 연결 끊김
Watchdog Reset
Heap 부족
메모리 단편화
TCP 소켓 리소스 누수
WiFiClient 객체 사용 문제
Arduino ESP32 Core 버전 문제

특히 TCP 통신 중 발생하는 리셋이라면 네트워크 코드만 볼 것이 아니라, 메모리와 Task 구조도 함께 봐야 합니다.

ESP32 Arduino는 겉으로는 Arduino 코드처럼 보이지만 내부적으로는 FreeRTOS, WiFi 드라이버, lwIP TCP/IP 스택이 함께 동작합니다.

그래서 loop() 안에서 단순히 client.write()를 호출하는 것처럼 보여도, 실제로는 아래쪽에서 꽤 복잡한 일이 일어납니다.

사용자 코드
-> WiFiClient
-> socket API
-> lwIP
-> WiFi driver
-> 무선 네트워크

이 중 어느 계층에서 문제가 발생했는지 구분하는 것이 중요합니다.

Core Dump에서 lwIP가 보였다

ESP32가 리셋되면 시리얼 로그에 Guru Meditation Error, Backtrace, Core Dump 같은 정보가 남을 수 있습니다.

이번 문제에서 중요한 단서는 Core Dump 분석 결과에 lwIP 관련 함수가 보였다는 점이었습니다.

예를 들면 상황에 따라 이런 계열의 함수가 backtrace에 나타날 수 있습니다.

lwip_send
lwip_recv
tcp_write
tcp_output
netconn_write
socket
WiFiClient::write
WiFiClient::read

물론 실제로 어떤 함수가 보였는지는 Core Dump 내용에 따라 다릅니다.

중요한 것은 lwIP가 보였다는 사실만으로 바로 "lwIP 버그다"라고 단정하면 안 된다는 점입니다.

lwIP가 원인일까, 결과일까?

처음에는 Core Dump에 lwIP가 보이면 lwIP 자체가 문제라고 생각하기 쉽습니다.

하지만 실제로는 사용자 코드의 잘못된 호출이 lwIP 내부에서 터지는 경우도 많습니다.

예를 들어 이런 상황을 생각해볼 수 있습니다.

TCP 연결이 끊기는 중인데 write를 호출함
WiFi 연결이 끊겼는데 기존 client 객체를 계속 사용함
여러 Task에서 같은 WiFiClient 객체에 동시에 접근함
client.stop() 이후에도 read/write를 호출함
재연결 과정에서 이전 소켓 리소스가 정리되지 않음
버퍼 범위를 넘어선 데이터를 write함
Heap이 부족한 상태에서 TCP 송수신을 반복함

이런 경우 Core Dump에서는 lwIP 내부 함수가 보일 수 있습니다.

하지만 실제 원인은 lwIP가 아니라, lwIP를 호출한 위쪽 코드의 상태 관리 문제일 수 있습니다.

이번 문제에서 제가 가장 중요하게 봐야 한다고 느낀 포인트는 이것입니다.

Core Dump에 lwIP가 보였다는 사실보다,
왜 lwIP 내부 함수가 잘못된 상태에서 호출되었는지를 추적해야 한다.

WiFi 연결 상태와 TCP 연결 상태는 다르다

ESP32 Arduino에서 네트워크 코드를 작성할 때 자주 하는 실수가 있습니다.

WiFi.status() == WL_CONNECTED이면 네트워크가 모두 정상이라고 생각하는 것입니다.

하지만 WiFi 연결 상태와 TCP 연결 상태는 다릅니다.

WiFi 연결됨
IP 할당됨
Gateway 접근 가능
DNS 정상
TCP 서버 연결됨
TCP 송수신 가능
HTTP 요청 정상

이 단계들은 모두 다릅니다.

WiFi는 연결되어 있어도 TCP 서버와 연결이 끊겼을 수 있습니다.

IP는 받아왔지만 Gateway나 DNS 문제가 있을 수도 있습니다.

TCP는 연결되어 있는 것처럼 보여도 실제 write 시점에 실패할 수도 있습니다.

따라서 장시간 네트워크 테스트에서는 WiFi 상태와 TCP 상태를 분리해서 로그로 남겨야 합니다.

의심해야 할 코드 패턴

이번 문제를 분석하면서 ESP32 Arduino TCP 코드에서 특히 조심해야 할 패턴을 정리해봤습니다.

1. 연결 상태 확인 없이 write 호출

TCP 송신 전에 최소한 현재 연결 상태를 확인해야 합니다.

if (client.connected()) {
    size_t written = client.write(buffer, length);
}

하지만 이것만으로 완벽하지는 않습니다.

connected()가 true였더라도 바로 다음 순간에 연결이 끊길 수 있습니다.

따라서 write()의 반환값도 반드시 확인해야 합니다.

size_t written = client.write(buffer, length);

if (written != length) {
    Serial.printf("[TCP] write failed. request=%u, written=%u\n",
                  length, written);
}

2. 여러 Task에서 같은 WiFiClient 사용

ESP32는 FreeRTOS 기반으로 동작합니다.

Arduino 코드에서도 직접 Task를 만들거나, 라이브러리 내부에서 별도 Task가 동작할 수 있습니다.

만약 여러 Task에서 같은 WiFiClient 객체를 동시에 read/write한다면 문제가 생길 수 있습니다.

예를 들어 한 Task에서는 데이터를 보내고, 다른 Task에서는 연결을 끊거나 재연결을 시도한다면 타이밍에 따라 위험해질 수 있습니다.

Task A: client.write()
Task B: client.stop()
Task C: WiFi reconnect 처리

이런 구조라면 TCP 객체 접근을 하나의 Task로 모으거나, 최소한 Mutex로 보호해야 합니다.

3. 끊김 처리 없이 기존 client를 계속 사용

TCP 연결은 언제든 끊길 수 있습니다.

상위 서버가 재시작될 수도 있고, 공유기 상태가 나빠질 수도 있고, WiFi가 잠시 불안정할 수도 있습니다.

이때 기존 WiFiClient 객체를 계속 사용하면 문제가 커질 수 있습니다.

실패가 감지되면 명확하게 정리하고 다시 연결하는 흐름이 필요합니다.

if (!client.connected()) {
    Serial.println("[TCP] disconnected. stop client.");
    client.stop();
    // reconnect 플래그 설정
}

4. timeout 없는 blocking read

네트워크 코드는 timeout이 중요합니다.

상대방이 응답하지 않는 상황에서 계속 기다리면 Task가 묶일 수 있습니다.

장시간 테스트에서는 이런 작은 대기가 누적되어 전체 상태를 꼬이게 만들 수도 있습니다.

client.setTimeout(1000);

timeout을 너무 짧게 잡으면 정상 패킷도 실패할 수 있고, 너무 길게 잡으면 장애 감지가 늦어집니다.

따라서 실제 통신 주기와 서버 응답 시간을 기준으로 적당한 값을 찾아야 합니다.

5. Heap 감소를 확인하지 않음

1~2시간 뒤에 리셋된다면 메모리도 꼭 봐야 합니다.

특히 Arduino 환경에서 String을 많이 사용하거나, 연결/해제를 반복하면서 객체를 계속 만들고 버리면 heap fragmentation이 생길 수 있습니다.

주기적으로 아래 값을 출력해보면 도움이 됩니다.

Serial.printf("[MEM] freeHeap=%u, minFreeHeap=%u\n",
              ESP.getFreeHeap(),
              ESP.getMinFreeHeap());

단순히 현재 free heap만 보면 순간적으로 회복된 것처럼 보일 수 있습니다.

minFreeHeap도 같이 봐야 장시간 테스트 중 얼마나 낮게 떨어졌는지 확인할 수 있습니다.

네트워크 테스트 로그 설계

이번 같은 문제는 로그 없이는 분석하기 어렵습니다.

특히 1~2시간에 한 번 발생하는 문제라면, 크래시 직전 상태를 최대한 남겨야 합니다.

제가 앞으로 남겨보려고 하는 로그는 아래와 같습니다.

WiFi 상태
IP 주소
RSSI
TCP 연결 상태
송신 시도 횟수
송신 실패 횟수
수신 timeout 횟수
재연결 횟수
freeHeap
minFreeHeap
마지막 정상 패킷 시간
마지막 에러 시간

예시 코드는 아래처럼 작성할 수 있습니다.

void printNetworkStatus(WiFiClient& client)
{
    Serial.printf("[NET] wifi=%d, ip=%s, rssi=%d\n",
                  WiFi.status(),
                  WiFi.localIP().toString().c_str(),
                  WiFi.RSSI());

    Serial.printf("[TCP] connected=%d, available=%d\n",
                  client.connected(),
                  client.available());

    Serial.printf("[MEM] freeHeap=%u, minFreeHeap=%u\n",
                  ESP.getFreeHeap(),
                  ESP.getMinFreeHeap());
}

송신 부분도 성공/실패를 구분해서 기록해야 합니다.

bool sendTcpPacket(WiFiClient& client, const uint8_t* data, size_t length)
{
    if (!client.connected()) {
        Serial.println("[TCP] write skipped. client disconnected.");
        return false;
    }

    size_t written = client.write(data, length);

    if (written != length) {
        Serial.printf("[TCP] write failed. request=%u, written=%u\n",
                      length, written);
        return false;
    }

    Serial.printf("[TCP] write ok. length=%u\n", written);
    return true;
}

이런 로그가 있어야 Core Dump와 실제 통신 흐름을 함께 볼 수 있습니다.

WiFi 이벤트도 같이 봐야 한다

Arduino ESP32에서는 WiFi.onEvent()를 사용해서 WiFi 이벤트를 받을 수 있습니다.

장시간 테스트에서는 WiFi가 순간적으로 끊겼는지, IP를 다시 받았는지, 연결이 재시도되었는지를 확인하는 것이 중요합니다.

void onWiFiEvent(WiFiEvent_t event)
{
    Serial.printf("[WIFI] event=%d\n", event);
}

void setup()
{
    Serial.begin(115200);
    WiFi.onEvent(onWiFiEvent);
    WiFi.begin("ssid", "password");
}

다만 이벤트 콜백 안에서 너무 많은 일을 하면 안 됩니다.

WiFi 이벤트는 일반적인 loop() 흐름과 다른 Task에서 호출될 수 있기 때문에, 콜백 안에서는 상태 플래그만 바꾸고 실제 처리는 메인 Task나 네트워크 Task에서 하는 방식이 더 안전합니다.

volatile bool needReconnect = false;

void onWiFiEvent(WiFiEvent_t event)
{
    Serial.printf("[WIFI] event=%d\n", event);

    // 실제 reconnect 처리는 별도 Task 또는 loop에서 수행
    // 여기서는 상태만 기록하는 식으로 단순하게 유지
}

해결 방향

아직 이 문제의 최종 원인을 단정할 수는 없습니다.

하지만 현재 상황에서 적용해볼 수 있는 해결 방향은 정리할 수 있습니다.

1. TCP 송수신을 하나의 Task에서만 처리한다.
2. 여러 Task에서 WiFiClient를 공유한다면 Mutex를 적용한다.
3. write/read 전후로 connected 상태와 반환값을 확인한다.
4. 실패 발생 시 client.stop()으로 명확히 정리한다.
5. 재연결은 상태 머신으로 관리한다.
6. WiFi 이벤트 로그를 남긴다.
7. freeHeap, minFreeHeap을 주기적으로 기록한다.
8. WiFi 절전 모드를 끄고 테스트한다.
9. Arduino ESP32 Core 버전을 변경해 비교 테스트한다.
10. 가능하면 ESP-IDF 예제 또는 socket API 기반 최소 재현 코드를 만든다.

WiFi 절전 모드가 의심된다면 테스트 차원에서 아래 설정도 비교해볼 수 있습니다.

WiFi.setSleep(false);

물론 이 설정이 모든 문제의 해결책은 아닙니다.

하지만 장시간 TCP 통신 안정성을 확인할 때 비교 조건으로는 의미가 있습니다.

상태 머신으로 TCP 연결 관리하기

간헐적 네트워크 문제를 줄이려면 TCP 연결 상태를 명확하게 나누는 것이 좋습니다.

예를 들어 아래처럼 상태를 나눌 수 있습니다.

WIFI_WAIT
TCP_CONNECTING
TCP_CONNECTED
TCP_ERROR
TCP_RECONNECT_DELAY

대략적인 흐름은 이렇습니다.

WiFi 연결 확인
-> TCP 연결 시도
-> 연결 성공 시 송수신
-> 실패 감지 시 client.stop()
-> 일정 시간 대기
-> 다시 연결 시도

중요한 것은 실패 상황을 애매하게 두지 않는 것입니다.

연결이 끊겼는데도 연결된 것처럼 계속 write를 시도하거나, stop과 reconnect가 여러 곳에서 동시에 일어나면 문제가 재현되기 쉬워집니다.

네트워크 코드는 성공 흐름보다 실패 흐름을 더 신경 써야 합니다.

앞으로 테스트해볼 것

이번 문제는 한 번에 해결하기보다는 단계별로 원인을 좁혀가야 할 것 같습니다.

앞으로는 아래 방식으로 테스트해보려고 합니다.

1. 10초 또는 30초 주기로 네트워크 상태 로그 출력
2. TCP 송수신 성공/실패 카운트 기록
3. WiFi 이벤트 발생 시각 기록
4. Heap 변화량 기록
5. 리셋 직전 마지막 로그 확인
6. Core Dump backtrace와 마지막 TCP 상태 비교
7. WiFiClient 접근 Task 단일화 후 비교
8. WiFi.setSleep(false) 적용 전후 비교
9. Arduino ESP32 Core 버전별 비교

이렇게 하면 최소한 문제가 아래 중 어디에 가까운지 구분할 수 있을 것 같습니다.

WiFi 연결 불안정 문제
TCP 재연결 처리 문제
메모리 누수 또는 단편화 문제
여러 Task의 동시 접근 문제
Arduino ESP32 Core/lwIP 버전 문제

마무리

이번 문제는 ESP32 Arduino에서 TCP 통신 중 1~2시간마다 간헐적으로 리셋되는 문제였습니다.

Core Dump를 확인했을 때 lwIP 관련 함수가 보였기 때문에 처음에는 lwIP 자체 문제처럼 보일 수 있었습니다.

하지만 lwIP는 TCP/IP 스택의 내부 계층이기 때문에, 실제 원인은 그 위에서 호출하는 사용자 코드의 상태 관리 문제일 수도 있습니다.

특히 장시간 TCP 통신에서는 아래 항목들을 꼭 확인해야 합니다.

WiFi 상태와 TCP 상태를 분리해서 보는가?
write/read 반환값을 확인하는가?
연결 실패 시 client.stop()으로 정리하는가?
재연결 흐름이 한 곳에서 관리되는가?
여러 Task에서 같은 client를 동시에 쓰지 않는가?
Heap이 장시간 안정적으로 유지되는가?
Core Dump와 직전 로그를 함께 보는가?

이번 글은 아직 최종 해결기라기보다는, 문제를 어떻게 바라보고 어떤 방향으로 테스트할지 정리한 글입니다.

다음에는 실제 테스트 로그를 더 쌓아서, 어떤 조건에서 리셋이 발생하는지와 어떤 수정이 효과가 있었는지 이어서 정리해보려고 합니다.

참고 자료

728x90
LIST
728x90
SMALL

ESP32에서 ISR 안에 float를 넣었다가 칩이 리셋된 이야기

안녕하세요. 꾸꾸입니다.

최근 ESP32 기반 펌웨어를 개발하면서 꽤 치명적인 문제를 만났습니다.

문제 자체는 단순해 보였습니다.

GPIO interrupt가 발생하면 그 순간 특정 값을 저장해야 했고, 저는 이 흐름을 LLM에게 설명했습니다.

LLM은 자연스럽게 interrupt handler 안에서 flag 처리와 값 저장을 같이 하는 코드를 만들어줬습니다.

빌드도 정상적으로 됐고, 업로드도 문제 없이 진행됐습니다.

처음에는 동작도 크게 이상해 보이지 않았습니다.

그런데 이후 ESP32 칩이 간헐적으로 리셋되는 문제가 발생했습니다.

이번 글은 그 문제를 추적하면서 알게 된 내용과, LLM을 사용해서 임베디드 개발을 할 때 조심해야 할 지점을 정리한 글입니다.

처음 만든 구조

처음에는 대략 이런 흐름이었습니다.

volatile bool interruptDetected = false;
volatile float capturedValue = 0.0f;

void IRAM_ATTR onGpioInterrupt() {
    interruptDetected = true;
    capturedValue = getCurrentFloatValue();
}

의도는 간단했습니다.

interrupt가 발생하면 flag를 세우고, 그 순간 필요한 값을 저장하는 것입니다.

일반적인 애플리케이션 코드만 생각하면 크게 이상해 보이지 않습니다.

하지만 ESP32의 ISR, FreeRTOS, FPU 제약을 생각하면 이 코드는 위험한 코드였습니다.

증상

처음에는 빌드와 업로드가 모두 성공했습니다.

그래서 코드 자체에는 큰 문제가 없다고 생각했습니다.

하지만 실제 보드에서 테스트하다 보니 ESP32가 간헐적으로 리셋됐습니다.

처음에는 이런 가능성을 의심했습니다.

  • 전원 불안정
  • watchdog timeout
  • stack overflow
  • 잘못된 포인터 접근
  • race condition
  • interrupt가 너무 자주 발생하는 문제

그런데 원인을 좁혀가다 보니 문제는 interrupt handler 안에서 float 계열 값을 다루는 부분이었습니다.

왜 문제가 됐을까?

ESP32의 FreeRTOS는 FPU register에 대해 lazy context switching을 사용합니다.

쉽게 말하면 task context에서는 FPU 상태를 항상 즉시 저장하고 복구하는 것이 아니라, 필요할 때만 처리하는 방식입니다.

이 방식은 성능 측면에서는 유리할 수 있습니다.

하지만 interrupt context에서는 이야기가 달라집니다.

interrupt handler는 특정 task의 일반적인 실행 흐름 안에서 동작하는 코드가 아닙니다. 그런데 FPU register 상태는 task와 연결되어 관리됩니다.

그래서 ESP-IDF 문서에서는 기본적으로 interrupt context에서 FPU 사용을 지원하지 않는다고 설명합니다.

공식 문서의 핵심 내용은 다음과 같습니다.

  • ESP-IDF FreeRTOS는 FPU register에 대해 lazy context switching을 사용한다.
  • task가 float를 사용하면 해당 task는 현재 실행 중인 core에 자동으로 pin 될 수 있다.
  • 기본 설정에서는 interrupt context 안에서 FPU 사용을 지원하지 않는다.
  • ISR에서 float 사용이 꼭 필요하면 CONFIG_FREERTOS_FPU_IN_ISR 설정을 참고해야 한다.

참고 문서: ESP-IDF FreeRTOS - Floating Point Usage

결국 제가 겪은 문제는 ISR 내부에서 float 값을 직접 저장하거나 계산하려고 하면서 FPU 상태 관리와 충돌한 사례였습니다.

컴파일러는 이 코드를 막아주지 않았습니다.

빌드도 됐고 업로드도 됐습니다.

하지만 런타임에서는 보드가 리셋됐습니다.

임베디드 개발에서 가장 무서운 종류의 버그입니다.

수정한 구조

해결 방법은 단순했습니다.

ISR에서는 실제 데이터 처리를 하지 않도록 바꿨습니다.

interrupt handler 안에서는 interrupt가 발생했다는 사실만 기록하고, float 값 업데이트는 별도의 task에서 처리하도록 변경했습니다.

volatile bool interruptDetected = false;

void IRAM_ATTR onGpioInterrupt() {
    interruptDetected = true;
}

void sensorTask(void* parameter) {
    while (true) {
        if (interruptDetected) {
            interruptDetected = false;

            // ISR 밖, task context에서 float 처리
            float currentValue = getCurrentFloatValue();
            updateSensorValue(currentValue);
        }

        vTaskDelay(pdMS_TO_TICKS(1));
    }
}

이렇게 구조를 바꾸고 나서는 리셋 문제가 사라졌습니다.

핵심은 ISR을 최대한 짧고 단순하게 유지하는 것입니다.

ISR에서는 flag만 세우고, 실제 계산과 상태 변경은 task context에서 처리합니다.

ISR 안에서 조심해야 할 것들

이번 사례는 float 때문에 발생했지만, 사실 ISR 안에서 조심해야 할 것은 많습니다.

ESP32뿐 아니라 대부분의 임베디드 환경에서 ISR은 최대한 짧아야 합니다.

ISR 안에서는 보통 이런 작업을 피하는 것이 좋습니다.

  • float 연산
  • 시간이 오래 걸리는 계산
  • blocking 함수 호출
  • delay 함수 호출
  • 동적 메모리 할당
  • 일반적인 log 출력
  • mutex lock
  • flash 접근 가능성이 있는 함수 호출
  • thread-safe가 보장되지 않은 API 호출

반대로 ISR 안에서는 이런 정도만 하는 것이 안전합니다.

  • flag 설정
  • counter 증가
  • queue로 이벤트 전달
  • semaphore notify
  • 최소한의 register read/write

물론 상황에 따라 예외는 있습니다.

하지만 기본 원칙은 명확합니다.

ISR에서는 이벤트만 기록하고, 무거운 처리는 task로 넘기는 것이 좋습니다.

LLM이 틀렸다고만 볼 수 있을까?

이번 문제에서 LLM이 만든 코드는 빌드 가능한 코드였습니다.

그리고 요구사항만 보면 그럴듯한 코드이기도 했습니다.

제가 "interrupt가 발생했을 때 값을 저장해야 한다"고 말했기 때문에, LLM은 interrupt handler 안에서 값을 저장하는 코드를 만든 것입니다.

문제는 그 코드가 ESP32의 런타임 제약까지 만족하는지는 별개의 문제였다는 점입니다.

LLM은 문법적으로 맞고 요구사항에 가까운 코드를 빠르게 만들어줍니다.

하지만 임베디드에서는 다음 질문이 훨씬 중요합니다.

  • 이 코드는 ISR 안에서 실행해도 안전한가?
  • 이 함수는 내부에서 blocking 될 가능성이 있는가?
  • 이 코드가 heap allocation을 일으키지는 않는가?
  • log 출력이나 flash 접근이 숨어 있지는 않은가?
  • float, double, FPU 사용이 들어가 있지는 않은가?
  • 이 API는 task context 전용인가, ISR에서도 호출 가능한가?
  • dual-core 환경에서 race condition이 생기지는 않는가?

LLM을 사용할수록 개발자는 단순히 코드를 작성하는 사람에서, 코드의 실행 맥락을 검증하는 사람으로 이동하게 됩니다.

특히 임베디드에서는 이 차이가 더 크게 드러납니다.

내가 얻은 교훈

이번 문제를 겪고 나서 LLM에게 임베디드 코드를 요청할 때 질문 방식을 바꾸게 됐습니다.

예전에는 이렇게 물었습니다.

ESP32에서 GPIO interrupt가 발생하면 값을 저장하는 코드를 만들어줘.

이제는 이렇게 묻는 편입니다.

ESP32에서 GPIO interrupt가 발생했을 때 값을 저장해야 한다.
ISR 안에서는 최소 작업만 하고, float 연산이나 blocking API는 사용하지 않는 구조로 만들어줘.
FreeRTOS task에서 후속 처리를 하도록 예제를 작성해줘.

또는 코드가 나온 뒤에 한 번 더 검증합니다.

이 코드에서 ISR 안에서 호출하면 위험한 함수나 연산이 있는지 검토해줘.
ESP32, FreeRTOS, FPU 제약 기준으로 봐줘.

LLM은 좋은 도구입니다.

하지만 LLM이 만든 코드가 하드웨어의 제약까지 자동으로 만족한다고 믿으면 안 됩니다.

컴파일 성공은 충분조건이 아닙니다.

업로드 성공도 충분조건이 아닙니다.

실제 장비에서 안정적으로 돌아가는지는 또 다른 문제입니다.

마무리

이번 버그의 원인은 ESP32 interrupt handler 안에서 float 계열 처리를 한 것이었습니다.

빌드와 업로드는 성공했지만, 런타임에서 FPU 사용 제약과 충돌하면서 칩 리셋으로 이어졌습니다.

해결은 ISR 안에서 float 처리를 제거하고, flag만 세운 뒤 task context에서 값을 업데이트하는 방식이었습니다.

이번 경험을 한 문장으로 정리하면 이렇습니다.

LLM이 만들어준 코드는 컴파일러가 통과시킬 수 있지만, 하드웨어가 받아들일지는 엔지니어가 검증해야 한다.

LLM 시대의 개발자는 코드를 더 적게 쓰게 될 수 있습니다.

하지만 대신 더 정확하게 읽고, 더 깊게 검증해야 합니다.

특히 임베디드 개발에서는 그 차이가 보드 리셋 하나로 바로 드러납니다.

728x90
LIST
728x90
SMALL

처음 보는 프로젝트 구조를 분석하는 방법

안녕하세요. 꾸꾸입니다.

지난 글에서는 LLM 시대에 개발자가 코드를 작성하는 능력만큼이나, 코드를 분석하는 능력이 중요해지고 있다는 이야기를 해봤습니다.

AI가 코드를 만들어주는 시대가 되면서 개발자는 단순히 코드를 많이 작성하는 사람보다는, 만들어진 코드를 읽고 판단하고 기존 시스템에 맞게 다듬는 사람이 되어야 한다고 생각했습니다.

그렇다면 실제로 처음 보는 프로젝트를 마주했을 때 우리는 어디부터 봐야 할까요?

저도 새로운 프로젝트나 오래된 코드를 볼 때 처음에는 막막했던 경험이 많습니다.

파일은 많고, 함수도 많고, 폴더 이름도 익숙하지 않고, 어디서부터 실행되는지도 잘 보이지 않을 때가 있습니다.

이럴 때 모든 코드를 처음부터 끝까지 읽으려고 하면 금방 지칩니다.

그래서 이번 글에서는 처음 보는 프로젝트 구조를 분석할 때 제가 앞으로 사용해보려고 하는 기준을 정리해보려고 합니다.

완벽한 정답이라기보다는, 프로젝트를 처음 마주했을 때 길을 잃지 않기 위한 체크리스트에 가깝습니다.

전체 코드를 다 읽으려 하면 실패한다

처음 보는 프로젝트를 분석할 때 가장 먼저 버려야 할 생각은 이것 같습니다.

전체 코드를 다 이해해야 한다.

물론 언젠가는 많은 부분을 이해해야 할 수도 있습니다.

하지만 처음부터 모든 파일과 모든 함수를 다 이해하려고 하면 오히려 전체 구조를 놓치기 쉽습니다.

처음에는 세부 구현보다 큰 흐름을 먼저 잡는 것이 중요합니다.

프로그램은 어디서 시작되는가?
주요 폴더는 어떤 역할을 하는가?
데이터는 어디서 들어와 어디로 나가는가?
외부 장치나 서버와 연결되는 지점은 어디인가?
로그와 에러는 어디서 처리되는가?

이런 질문에 먼저 답할 수 있어야 합니다.

코드를 읽는다는 것은 단순히 파일을 위에서 아래로 보는 일이 아닙니다.

흐름을 찾는 일에 더 가깝습니다.

1. 실행 시작점부터 찾기

프로젝트를 처음 볼 때 가장 먼저 찾아야 하는 것은 실행 시작점입니다.

프로그램이 어디서 시작되는지 알아야 이후 흐름을 따라갈 수 있습니다.

언어와 환경에 따라 시작점은 다릅니다.

C/C++       main()
Arduino     setup(), loop()
STM32       main.c, MX_* 초기화 함수
Node.js     package.json의 scripts, server.js, app.js
Python      if __name__ == "__main__"
Java        main(), Spring Boot Application 클래스
React       main.tsx, App.tsx, router 설정

임베디드 프로젝트라면 main() 함수만 보는 것으로 끝나지 않습니다.

초기화 순서도 함께 봐야 합니다.

Clock 초기화
GPIO 초기화
UART/CAN/SPI/I2C 초기화
RTOS Kernel 시작
Task 생성
Interrupt 설정

서버 프로젝트라면 어떤 포트로 서버가 열리는지, 어떤 라우터가 등록되는지, 어떤 미들웨어가 먼저 실행되는지 확인해야 합니다.

처음에는 세부 구현보다 실행 순서를 적어보는 것이 좋습니다.

예를 들어 이렇게 정리할 수 있습니다.

1. main() 진입
2. 하드웨어 초기화
3. 통신 모듈 초기화
4. FreeRTOS Task 생성
5. Scheduler 시작
6. 각 Task에서 Queue 또는 Event 처리

이 정도만 정리해도 프로젝트가 조금 덜 낯설게 보입니다.

2. 폴더 구조에서 역할을 추측하기

실행 시작점을 찾았다면 다음은 폴더 구조를 봐야 합니다.

폴더 구조는 프로젝트의 설계 의도가 드러나는 부분입니다.

물론 항상 깔끔한 것은 아닙니다.

오래된 프로젝트일수록 폴더 이름과 실제 역할이 맞지 않을 수도 있고, 임시로 만든 코드가 그대로 남아 있을 수도 있습니다.

그래도 처음 분석할 때 폴더 구조를 보는 것은 매우 중요합니다.

예를 들어 이런 식으로 역할을 추측해볼 수 있습니다.

src/          실제 소스 코드
include/      헤더 파일
drivers/      하드웨어 드라이버
hal/          하드웨어 추상화 계층
app/          애플리케이션 로직
core/         핵심 로직
services/     기능 단위 서비스
utils/        공통 유틸리티
config/       설정 파일
tests/        테스트 코드
docs/         문서

Node.js나 웹 프로젝트라면 이런 구조를 볼 수도 있습니다.

routes/       API 경로
controllers/  요청 처리
services/     비즈니스 로직
models/       데이터 모델
middlewares/  공통 처리
config/       환경 설정

중요한 것은 폴더 이름을 외우는 것이 아닙니다.

각 폴더가 어떤 책임을 가지고 있는지 추측하고, 실제 코드와 비교해보는 것입니다.

이때 LLM을 활용하면 꽤 도움이 됩니다.

이 프로젝트의 폴더 구조를 보고 각 디렉터리의 역할을 추정해줘.
각 폴더가 어떤 계층에 속하는지 표로 정리해줘.
이 구조에서 비즈니스 로직이 들어있을 가능성이 높은 폴더를 알려줘.

단, LLM의 답변은 추측일 뿐입니다.

반드시 실제 코드와 맞춰봐야 합니다.

3. 핵심 모듈과 의존성 방향 보기

폴더 구조를 봤다면 이제 핵심 모듈을 찾아야 합니다.

처음부터 모든 모듈을 다 볼 필요는 없습니다.

먼저 프로젝트에서 가장 중요한 기능이 어디에 있는지 찾아야 합니다.

예를 들어 임베디드 프로젝트라면 이런 모듈이 핵심일 수 있습니다.

통신 처리 모듈
센서 데이터 수집 모듈
상태 관리 모듈
제어 알고리즘 모듈
로그 출력 모듈
설정 저장 모듈

웹이나 서버 프로젝트라면 이런 모듈이 핵심일 수 있습니다.

인증 모듈
사용자 관리 모듈
결제 모듈
알림 모듈
데이터 저장 모듈
외부 API 연동 모듈

여기서 중요한 것은 의존성 방향입니다.

어떤 모듈이 어떤 모듈을 호출하는지 봐야 합니다.

좋은 구조에서는 보통 의존성 방향이 어느 정도 일정합니다.

예를 들어 서버 프로젝트라면 대략 이런 방향을 기대할 수 있습니다.

Router -> Controller -> Service -> Repository -> Database

임베디드 프로젝트라면 이런 흐름일 수 있습니다.

Application -> Service -> Driver -> Hardware

물론 실제 프로젝트는 항상 이렇게 깔끔하지 않습니다.

하지만 기준을 가지고 보면 이상한 부분이 보입니다.

예를 들어 상위 레벨 로직이 하드웨어 레지스터를 직접 만지고 있다면, 추후 변경에 약한 구조일 수 있습니다.

반대로 드라이버 코드가 애플리케이션 상태를 직접 바꾸고 있다면, 의존성 방향이 꼬여 있을 가능성이 있습니다.

처음 분석할 때는 이런 질문을 해보면 좋습니다.

이 모듈은 누구에게 호출되는가?
이 모듈은 누구를 호출하는가?
이 모듈을 수정하면 어디까지 영향이 갈까?
하위 계층이 상위 계층을 알고 있지는 않은가?

4. 데이터 흐름을 따라가기

프로젝트 구조를 이해하는 데 가장 중요한 것 중 하나는 데이터 흐름입니다.

데이터가 어디서 들어와서, 어떤 과정을 거쳐, 어디로 나가는지 따라가야 합니다.

임베디드 프로젝트라면 데이터 흐름이 이런 식일 수 있습니다.

센서 입력
-> 드라이버에서 Raw Data 수집
-> 보정 및 필터링
-> 상태 계산
-> 통신 패킷 생성
-> CAN/UART/RS485 전송

서버 프로젝트라면 이런 흐름일 수 있습니다.

HTTP 요청
-> Router
-> Controller
-> Service
-> Database 조회
-> 응답 데이터 생성
-> HTTP 응답

프론트엔드 프로젝트라면 이런 흐름일 수 있습니다.

사용자 입력
-> 이벤트 핸들러
-> 상태 변경
-> API 요청
-> 응답 데이터 저장
-> 화면 렌더링

데이터 흐름을 따라가면 프로젝트가 훨씬 잘 보입니다.

함수 이름이나 클래스 이름보다 중요한 것은 데이터가 이동하는 경로입니다.

특히 버그를 분석할 때는 데이터 흐름을 모르면 원인을 찾기 어렵습니다.

값이 잘못되었을 때 어느 지점에서 처음 잘못되었는지 찾아야 하기 때문입니다.

이때는 이런 질문을 사용할 수 있습니다.

이 값은 어디서 처음 만들어지는가?
중간에 어떤 함수들이 이 값을 변경하는가?
최종적으로 어디에서 사용되는가?
정상 값과 비정상 값이 갈라지는 지점은 어디인가?

LLM에게 물어볼 때는 이렇게 요청할 수 있습니다.

아래 코드에서 inputData가 생성되고 변경되고 사용되는 흐름을 단계별로 추적해줘.
각 단계에서 값이 바뀌는 조건을 함께 정리해줘.

데이터 흐름은 코드 분석의 중심이라고 생각합니다.

5. 외부 연결점을 찾기

프로젝트에서 외부와 연결되는 지점은 항상 중요합니다.

외부 연결점은 버그가 많이 발생하는 곳이기도 하고, 시스템의 경계이기도 합니다.

임베디드에서는 이런 것들이 외부 연결점입니다.

UART
CAN
RS485
SPI
I2C
GPIO
ADC
Timer
Interrupt

서버에서는 이런 것들이 외부 연결점입니다.

HTTP API
Database
Message Queue
Redis
File System
외부 Open API
인증 서버

프로젝트를 분석할 때는 외부 연결점을 먼저 표시해두면 좋습니다.

왜냐하면 외부 연결점은 시스템의 입력과 출력이기 때문입니다.

문제가 생겼을 때도 보통 이 경계에서 단서가 나옵니다.

패킷이 들어왔는가?
응답이 나갔는가?
DB에 저장되었는가?
인터럽트가 발생했는가?
타임아웃이 발생했는가?
ACK를 받았는가?

외부 연결점을 알면 로그를 어디에 남겨야 하는지도 보입니다.

6. 로그와 에러 처리 위치 찾기

처음 보는 프로젝트에서 꼭 확인해야 하는 것이 로그와 에러 처리 방식입니다.

로그는 문제가 생겼을 때 시스템이 남기는 흔적입니다.

에러 처리는 문제가 생겼을 때 시스템이 어떻게 반응하는지를 보여줍니다.

프로젝트를 처음 분석할 때는 이런 것들을 찾아보면 좋습니다.

로그 출력 함수는 무엇인가?
로그 레벨은 구분되어 있는가?
에러 코드는 어디에 정의되어 있는가?
예외 처리는 어디에서 하는가?
assert는 어디에서 발생할 수 있는가?
통신 실패나 타임아웃은 어떻게 처리하는가?
재시도 로직은 있는가?

임베디드 프로젝트라면 printf, UART 로그, LED 상태 표시, 에러 코드, assert handler 같은 것들이 단서가 됩니다.

서버 프로젝트라면 logger, middleware, exception handler, error response format, request id 같은 것들을 봐야 합니다.

로그와 에러 처리 위치를 찾으면 이후 디버깅이 훨씬 쉬워집니다.

특히 LLM에게 로그를 분석시키려면, 로그가 어떤 코드에서 나온 것인지 연결할 수 있어야 합니다.

로그만 던져주고 "왜 이래?"라고 물으면 좋은 답을 얻기 어렵습니다.

대신 이렇게 물어보는 것이 좋습니다.

아래 로그는 UART 수신 Task에서 출력된 로그야.
정상 동작일 때는 A -> B -> C 순서로 출력되고,
문제 상황에서는 A 이후 C가 나오지 않아.
관련 코드는 아래와 같아.
가능한 원인을 우선순위로 정리해줘.

로그 분석은 결국 코드 흐름과 함께 봐야 합니다.

7. 변경 영향도를 생각하기

프로젝트 구조를 분석하는 이유는 단순히 이해하기 위해서만은 아닙니다.

결국 우리는 코드를 수정해야 합니다.

코드를 수정하기 전에는 변경 영향도를 생각해야 합니다.

이 코드를 수정하면 어떤 기능이 영향을 받을까?
이 함수는 여러 곳에서 사용되고 있는가?
이 설정값은 다른 모듈에서도 공유하는가?
이 변경이 통신 프로토콜에 영향을 주는가?
이 변경이 데이터 저장 형식에 영향을 주는가?

처음 보는 프로젝트에서 가장 위험한 실수 중 하나는, 작은 수정이라고 생각하고 바꿨는데 실제로는 여러 기능에 영향을 주는 경우입니다.

LLM을 사용할 때도 마찬가지입니다.

LLM이 제안한 수정 코드가 현재 문제는 해결할 수 있어도, 다른 흐름을 깨뜨릴 수 있습니다.

그래서 코드 수정 전에 항상 영향 범위를 확인해야 합니다.

LLM에게는 이렇게 물어볼 수 있습니다.

이 함수를 수정하려고 한다.
호출하는 위치와 호출되는 흐름을 기준으로 변경 영향도를 정리해줘.
부작용이 생길 수 있는 부분을 우선순위로 알려줘.

AI가 코드를 빨리 만들어줄수록, 개발자는 더 천천히 영향도를 생각해야 합니다.

처음 보는 프로젝트 분석 체크리스트

마지막으로 처음 보는 프로젝트를 분석할 때 사용할 체크리스트를 정리해보겠습니다.

1. 실행 시작점은 어디인가?
2. 초기화 순서는 어떻게 되는가?
3. 주요 폴더는 어떤 책임을 가지고 있는가?
4. 핵심 모듈은 무엇인가?
5. 모듈 간 의존성 방향은 자연스러운가?
6. 데이터는 어디서 들어와 어디로 나가는가?
7. 외부 연결점은 어디인가?
8. 로그는 어디에서 남는가?
9. 에러는 어디에서 처리되는가?
10. 수정하려는 코드의 영향 범위는 어디까지인가?

이 체크리스트만 있어도 처음 보는 프로젝트 앞에서 조금 덜 막막할 것 같습니다.

LLM에게 프로젝트 분석을 요청할 때 좋은 질문

LLM을 활용할 때는 질문을 구체적으로 해야 합니다.

막연하게 이렇게 물어보면 답변도 막연해질 가능성이 큽니다.

이 프로젝트 분석해줘.

대신 이렇게 나눠서 물어보는 것이 좋습니다.

이 폴더 구조를 보고 각 디렉터리의 역할을 추정해줘.
이 프로젝트의 실행 시작점부터 주요 처리 흐름을 단계별로 정리해줘.
이 코드에서 데이터가 입력되어 처리되고 출력되는 흐름을 추적해줘.
로그와 에러 처리가 어느 계층에서 이루어지는지 찾아줘.
이 기능을 수정할 때 영향 받을 수 있는 파일과 함수를 정리해줘.
이 구조에서 의존성 방향이 이상해 보이는 부분이 있는지 알려줘.

LLM은 프로젝트를 대신 이해해주는 도구라기보다는, 내가 구조를 이해할 수 있도록 도와주는 분석 보조 도구에 가깝다고 생각합니다.

좋은 질문을 던지려면 개발자도 어느 정도 구조를 볼 줄 알아야 합니다.

마무리

처음 보는 프로젝트를 분석할 때는 모든 코드를 한 번에 이해하려고 하면 어렵습니다.

먼저 실행 흐름을 찾고, 폴더 구조를 보고, 핵심 모듈을 파악하고, 데이터 흐름과 외부 연결점, 로그 위치를 따라가야 합니다.

코드를 읽는다는 것은 단순히 문법을 해석하는 일이 아니라, 시스템이 어떻게 움직이는지 이해하는 일에 가깝습니다.

LLM 시대에는 코드가 더 빨리 만들어집니다.

그만큼 개발자는 만들어진 코드와 기존 시스템의 구조를 더 잘 읽어야 합니다.

저도 아직 이 부분을 공부하고 정리해가는 중입니다.

앞으로는 실제 코드나 예시 프로젝트를 기준으로, 프로젝트 구조를 어떻게 분석할 수 있는지 더 구체적으로 정리해보려고 합니다.

다음 글에서는 LLM에게 코드 분석을 시킬 때 좋은 질문과 나쁜 질문에 대해 정리해보겠습니다.

추천 태그

LLM
AI코딩
ChatGPT
코드분석
프로젝트분석
아키텍처분석
로그분석
레거시코드
개발자공부
개발자생존기
728x90
LIST
728x90
SMALL

LLM 시대에 개발자는 무엇을 공부해야 할까?

안녕하세요. 꾸꾸입니다.

요즘 개발을 하다 보면 예전과는 확실히 분위기가 많이 달라졌다는 생각이 듭니다.

예전에는 개발자가 코드를 얼마나 빠르게 작성하는지, 문법을 얼마나 잘 알고 있는지, 원하는 기능을 직접 구현할 수 있는지가 중요했습니다.

물론 지금도 그런 능력이 필요하지 않은 것은 아닙니다.

하지만 LLM, 즉 ChatGPT나 Claude, Gemini, Copilot 같은 도구들이 등장하면서 단순히 코드를 작성하는 일 자체는 점점 더 쉬워지고 있습니다.

이제는 예전처럼 빈 파일을 열고 처음부터 끝까지 혼자 코드를 작성하는 경우보다, AI가 만들어준 코드를 읽고, 고치고, 검증하고, 기존 시스템에 맞게 붙이는 일이 더 많아지고 있습니다.

그러다 보니 자연스럽게 이런 생각이 들었습니다.

앞으로 개발자에게 더 중요한 능력은 코드를 작성하는 능력보다, 코드를 분석하는 능력이 아닐까?

이번 글은 그 생각에 대한 첫 번째 정리입니다.

앞으로 이 블로그에서 다뤄보고 싶은 새로운 시리즈의 시작이기도 합니다.

AI가 코드를 작성해주는 시대

요즘은 간단한 기능을 만들 때 LLM에게 이렇게 물어볼 수 있습니다.

STM32에서 UART DMA 수신 예제 코드를 작성해줘.

또는 이렇게 물어볼 수도 있습니다.

Node.js에서 특정 시간마다 메시지를 보내는 스케줄러 코드를 만들어줘.

그러면 LLM은 꽤 그럴듯한 코드를 만들어줍니다.

예전 같으면 공식 문서를 찾아보고, 예제 코드를 검색하고, 블로그를 뒤지고, 직접 컴파일하면서 한 줄씩 맞춰가야 했던 작업들이 이제는 훨씬 빠르게 시작됩니다.

이건 분명히 엄청난 변화입니다.

하지만 문제는 여기서부터입니다.

AI가 만들어준 코드가 항상 맞는 것은 아닙니다.

컴파일은 되지만 구조가 이상할 수도 있고, 지금 프로젝트의 아키텍처와 맞지 않을 수도 있고, 예외 상황에서 터질 수도 있습니다. 임베디드 쪽이라면 더 심각합니다. 타이밍, 메모리, 인터럽트, 태스크 우선순위, 통신 상태 같은 요소가 얽히면 겉으로 보기에는 멀쩡한 코드도 실제 장비에서는 이상하게 동작할 수 있습니다.

결국 개발자는 AI가 만든 코드를 그냥 붙여넣는 사람이 아니라, 그 코드가 정말 맞는지 판단하는 사람이 되어야 합니다.

이제 중요한 질문은 "만들 수 있나?"가 아니다

예전에는 어떤 기능을 구현할 때 이런 질문을 많이 했습니다.

이 기능을 만들 수 있을까?

하지만 LLM 시대에는 질문이 조금 바뀌어야 한다고 생각합니다.

이 코드가 왜 이렇게 작성되었을까?
이 구조가 우리 프로젝트에 맞을까?
장애가 난다면 어디부터 확인해야 할까?
로그를 보면 어떤 흐름이 보일까?
이 코드를 운영 환경에 넣어도 괜찮을까?

코드를 만드는 것보다, 만들어진 코드를 이해하고 판단하는 능력이 더 중요해지고 있습니다.

왜냐하면 코드는 이제 더 많이, 더 빠르게 생성될 것이기 때문입니다.

코드가 많아질수록 중요한 것은 작성 속도가 아니라 분석 능력입니다.

코드 분석 능력

코드 분석은 단순히 코드를 읽는 것이 아닙니다.

제가 생각하는 코드 분석은 이런 것에 가깝습니다.

이 함수는 왜 존재하는가?
입력과 출력은 무엇인가?
이 코드가 의존하는 외부 상태는 무엇인가?
예외 상황은 어디서 발생할 수 있는가?
동시성 문제는 없는가?
메모리나 리소스는 안전하게 관리되는가?
수정했을 때 영향 범위는 어디까지인가?

AI가 코드를 만들어주면 우리는 먼저 코드를 읽어야 합니다.

그리고 질문해야 합니다.

"이 코드가 동작하는가?"보다 더 중요한 질문은 "이 코드가 이 시스템 안에서 안전한가?"입니다.

특히 기존 프로젝트에 새로운 코드를 붙일 때는 더 조심해야 합니다.

새로운 함수 하나를 추가하는 것처럼 보여도, 실제로는 기존 상태 관리 방식, 에러 처리 방식, 로그 정책, 통신 흐름, 메모리 사용 방식과 맞아야 합니다.

코드 분석 능력은 앞으로 개발자에게 가장 기본적인 생존 기술이 될 것 같습니다.

아키텍처 분석 능력

코드 한 줄 한 줄을 읽는 것도 중요하지만, 그보다 한 단계 위에서 전체 구조를 보는 능력도 중요합니다.

처음 보는 프로젝트를 받았을 때 저는 보통 이런 것부터 확인하려고 합니다.

프로그램의 시작점은 어디인가?
주요 모듈은 어떻게 나뉘어 있는가?
데이터는 어떤 방향으로 흐르는가?
외부 장치나 서버와 통신하는 경계는 어디인가?
상태는 어디에서 관리되는가?
에러는 어디에서 처리되는가?
로그는 어디에서 남는가?

이런 것들이 보이기 시작하면 코드가 조금씩 구조로 보입니다.

반대로 아키텍처가 보이지 않으면 코드는 그냥 수많은 파일과 함수의 묶음처럼 느껴집니다.

LLM을 사용할 때도 마찬가지입니다.

LLM에게 특정 함수 하나만 보여주면 그 함수만 보고 답합니다. 하지만 실제 문제는 그 함수 바깥에 있을 때가 많습니다.

예를 들어 UART 수신 코드에서 문제가 생겼다고 해서 항상 UART 코드만 문제인 것은 아닙니다. DMA 설정, 버퍼 크기, 인터럽트 우선순위, FreeRTOS 태스크 구조, 메시지 큐 처리 방식이 함께 얽혀 있을 수 있습니다.

그래서 앞으로는 "코드를 잘 짜는 법"만큼이나 "구조를 잘 읽는 법"을 공부해야 한다고 생각합니다.

로그 분석 능력

제가 요즘 더 중요하게 느끼는 또 하나의 영역은 로그 분석입니다.

장애가 발생했을 때 가장 먼저 남는 것은 보통 로그입니다.

임베디드라면 시리얼 로그, assert 위치, 에러 코드, 통신 상태, 태스크 상태 같은 정보가 남습니다. 서버나 애플리케이션에서는 요청 로그, 에러 로그, 스택 트레이스, 상태 코드, 타임스탬프가 남습니다.

로그를 잘 본다는 것은 단순히 에러 메시지를 읽는 것이 아닙니다.

언제부터 문제가 시작되었는가?
문제 직전에 어떤 이벤트가 있었는가?
정상 로그와 비정상 로그의 차이는 무엇인가?
반복되는 패턴이 있는가?
특정 조건에서만 발생하는가?
원인 로그와 결과 로그를 구분할 수 있는가?

이런 질문을 하면서 로그를 읽어야 합니다.

개인적으로는 로그 분석이야말로 개발자의 실전 감각이 가장 잘 드러나는 영역이라고 생각합니다.

왜냐하면 로그에는 정답이 바로 적혀 있지 않기 때문입니다.

로그는 단서입니다.

그 단서들을 시간 순서대로 연결하고, 코드 흐름과 비교하고, 시스템 구조와 맞춰보면서 원인을 좁혀가야 합니다.

LLM도 로그 분석을 도와줄 수 있습니다. 하지만 좋은 답을 얻으려면 개발자가 먼저 로그의 맥락을 정리해줘야 합니다.

이 로그는 어떤 환경에서 나왔는지
정상 동작일 때는 어떤 로그가 나오는지
문제 발생 직전 어떤 작업을 했는지
관련 코드는 어디인지
재현 조건은 무엇인지

결국 로그 분석도 개발자가 주도해야 합니다.

LLM을 잘 쓰려면 개발자가 더 많이 알아야 한다

가끔은 AI가 코드를 만들어주니 개발자가 공부할 필요가 줄어드는 것처럼 느껴질 수도 있습니다.

하지만 저는 반대라고 생각합니다.

LLM을 잘 쓰려면 오히려 개발자가 더 많이 알아야 합니다.

정확히는 모든 코드를 외워야 한다는 뜻이 아닙니다. 문법을 전부 암기하거나, 모든 API를 머릿속에 넣고 있어야 한다는 뜻도 아닙니다.

대신 이런 능력이 더 중요해진다고 생각합니다.

문제를 정확히 설명하는 능력
AI가 만든 코드를 검증하는 능력
프로젝트 구조에 맞게 수정하는 능력
위험한 부분을 의심하는 능력
로그와 증상을 보고 원인을 좁히는 능력
아키텍처 관점에서 변경 영향도를 보는 능력

AI가 운전 속도를 올려주는 도구라면, 개발자는 방향과 위험을 판단해야 합니다.

속도가 빨라질수록 판단 능력은 더 중요해집니다.

앞으로 이 블로그에서 다뤄보고 싶은 것

기존에는 이 블로그에 임베디드 개발 중 겪었던 문제, CAN 통신 디버깅, FreeRTOS assert, RTLS 공부, 개인적인 관심사들을 기록해왔습니다.

앞으로는 여기에 한 가지 축을 더 추가해보려고 합니다.

LLM 시대 개발자 생존기

이 시리즈에서는 단순히 AI 도구 사용법만 정리하지는 않으려고 합니다.

그보다 AI 시대에 개발자가 어떤 관점으로 코드를 읽어야 하는지, 아키텍처를 어떻게 분석해야 하는지, 로그를 어떻게 해석해야 하는지에 대해 정리해보려고 합니다.

앞으로 다뤄보고 싶은 주제는 이런 것들입니다.

처음 보는 프로젝트 구조 분석하는 방법
LLM에게 코드 분석을 시킬 때 좋은 질문과 나쁜 질문
AI가 만든 코드를 검증하는 체크리스트
로그를 보고 원인을 좁혀가는 방법
임베디드 개발에서 로그를 남기는 기준
FreeRTOS 문제를 분석할 때 봐야 하는 흐름
CAN 통신 장애를 로그와 상태값으로 추적하는 방법
레거시 코드를 읽을 때 먼저 봐야 할 것들

이 주제들은 기존에 작성하던 디버깅 글과도 잘 이어질 것 같습니다.

결국 디버깅이라는 것도 코드를 읽고, 구조를 이해하고, 로그를 해석하는 과정이기 때문입니다.

마무리

LLM 시대가 되면서 개발자의 일이 사라지는 것이 아니라, 개발자의 일이 조금씩 바뀌고 있다고 생각합니다.

코드를 작성하는 일의 비중은 줄어들 수 있습니다.

하지만 코드를 이해하고, 검증하고, 운영 가능한 형태로 다듬고, 문제가 생겼을 때 원인을 찾아내는 일은 여전히 개발자의 몫입니다.

어쩌면 앞으로의 개발자는 코드를 잘 쓰는 사람에서, 시스템을 잘 읽는 사람으로 조금씩 이동하게 될지도 모르겠습니다.

저도 아직 이 부분을 공부하고 정리해가는 중입니다.

그래서 앞으로 이 블로그에 제가 겪은 개발 삽질 기록뿐만 아니라, LLM 시대에 개발자가 어떤 식으로 코드를 읽고 분석해야 하는지에 대한 글도 하나씩 남겨보려고 합니다.

첫 글은 여기까지입니다.

다음 글에서는 처음 보는 프로젝트 구조를 분석하는 방법에 대해 정리해보겠습니다.

추천 태그

LLM
AI코딩
ChatGPT
개발자공부
코드분석
아키텍처분석
로그분석
디버깅
임베디드
개발자생존기
728x90
LIST

+ Recent posts