LED 시세 전광판 제작기 · 05

[IoT DIY 5편] 개인용 토이 프로젝트를 상용 배포 레벨로 업그레이드: Captive Portal, 웹 설정 UI, OTA 무선 업데이트 구현 노하우

[IoT DIY 5편] 개인용 토이 프로젝트를 상용 배포 레벨로 업그레이드: Captive Portal, 웹 설정 UI, OTA 무선 업데이트 구현 노하우

책상 위에서 혼자 사용하기 위해 만든 임베디드 장치는 코드가 조금 지저분해도, 와이파이 비밀번호가 하드코딩되어 있어도 전혀 문제가 되지 않습니다. 하지만 이 장치를 다른 사람에게 선물하거나 소규모로 제작해 분양하는 순간, 기기는 개인의 토이 프로젝트에서 완벽하게 독립적으로 동작해야 하는 제품의 영역으로 넘어가게 됩니다.

본 글에서는 단일 목적의 ESP32 LED 전광판을 다수의 사용자가 원격에서 스스로 설정하고 유지보수할 수 있는 상용 아키텍처로 업그레이드하면서 적용한 핵심 기술(Captive Portal, NVS, OTA)과 현장에서 겪은 기술적 트러블슈팅 과정을 공유합니다.

[구동 영상] Captive Portal 설정과 OTA 유지보수를 고려한 LED 시세 전광판 실사용 구동 테스트입니다.
[구동 영상] 나무 케이스를 적용한 시세 전광판과 네오픽셀 무드등 배치 테스트입니다.

1. 하드코딩된 WiFi 정보의 한계와 Captive Portal 도입

개발 단계에서는 소스 코드 내에 공유기의 SSID와 비밀번호를 문자열로 입력해 배포합니다. 하지만 기기가 다른 장소로 이동하면 공유기 정보가 바뀌므로 매번 소스 코드를 수정해 컴파일하고 USB로 업로드해야 합니다. 또한, 사용자의 개인 공유기 비밀번호를 개발자가 직접 전달받는 것 역시 보안상 큰 부담입니다.

이 문제를 해결하기 위해 캡티브 포털(Captive Portal) 아키텍처를 도입했습니다.

  • 기기 부팅 시 비휘발성 메모리(EEPROM/NVS)에 저장된 WiFi 정보로 접속을 시도합니다.
  • 접속 실패 시 ESP32가 자체 Wi-Fi Access Point(AP 모드)로 전환하며, ESP_LED_SETUP 등의 SSID를 방송합니다.
  • 사용자 접속 시 스마트폰으로 해당 AP에 접속하면, DNS 서버가 모든 포트 80 요청을 가로채 기기 내부의 웹 설정 페이지로 강제 리다이렉트합니다.
  • 설정 저장 시 사용자가 웹 UI에서 자신의 공유기 정보를 입력하면, 기기는 이를 NVS에 저장 후 스테이션(STA) 모드로 재부팅하여 인터넷에 연결합니다.

2. 사용자별 옵션 파편화와 내장 웹서버(Web Server) UI 설계

기기를 사용하는 사람이 늘어남에 따라 요구사항이 급격히 분화되기 시작했습니다. 누구는 암호화폐 시세를, 누구는 미국 주식 증시와 환율을, 또 다른 누구는 뉴스 헤드라인과 날씨만을 원했습니다. 이를 개발자가 일일이 맞춤형 펌웨어로 굽는 것은 불가능에 가깝습니다.

따라서 캡티브 포털의 웹서버 영역에 동적 구성 UI(Dynamic Configuration UI)를 통합하고, 설정값을 JSON 형태로 플래시 메모리에 영구 저장하는 구조를 구축했습니다.

설정 항목데이터 저장 키(Key)사용자 커스텀 제어 범위
뉴스 헤드라인cfg_news_enRSS 뉴스 피드 롤링 표시 여부(On/Off)
날씨 및 지역cfg_weather_locOpenWeatherMap API 기준 지역 코드(서울, 부산 등)
증시 및 환율cfg_stock_fx코스피, 미국 나스닥, 달러/원 환율 표시 순서
야간 모드cfg_night_mode야간 시간대 디스플레이 밝기(PWM) 자동 감쇄 여부

이처럼 하드웨어는 단일 표준 규격으로 통일하되, 기능의 분기를 소프트웨어의 런타임 변수로 제어함으로써 유지보수 리소스를 80% 이상 절감할 수 있었습니다.

3. 원격 유지보수를 위한 OTA(Over-The-Air) 무선 업데이트 아키텍처

기기 배포 후 가장 치명적인 문제는 치명적인 버그 수정이나 새로운 API 스펙 대응이 필요할 때 발생합니다. 이미 물리적으로 분리된 장치의 케이스를 열고 USB 케이블을 PC에 연결해 펌웨어를 다시 올리는 것은 불가능합니다.

이를 해결하기 위해 와이파이 네트워크를 통해 펌웨어를 직접 교체하는 OTA(Over-The-Air) 기술을 적용했습니다.

ESP32 메모리 파티션 설계 주의점

OTA를 구현하기 위해서는 ESP32의 플래시 메모리(기본 4MB)를 두 개의 펌웨어 영역(Factory/App1, App2)으로 나누는 Dual Bank Partitioning이 필수적입니다. 현재 동작 중인 펌웨어가 App1 영역에 있다면, 무선으로 전송받는 새 펌웨어는 비어있는 App2 영역에 기록되며, 무결성 검증(MD5 Checksum)이 끝나면 부팅 포인터를 App2로 변경하여 안전하게 재부팅합니다.

[실무 코드 블록] ESP32 WiFiManager 및 OTA 통합 보일러플레이트

다음은 캡티브 포털(WiFiManager)과 ArduinoOTA를 단 하나의 루프 안에서 안정적으로 구동하는 핵심 보일러플레이트 코드입니다.

ESP32 WiFiManager 및 OTA 통합 보일러플레이트
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <DNSServer.h>
#include <ArduinoOTA.h>

const byte DNS_PORT = 53;
DNSServer dnsServer;
AsyncWebServer server(80);

// ==============================================================================
// 1. 캡티브 포털 (AP 모드 및 DNS 리다이렉트) 설정
// ==============================================================================
void setupCaptivePortal() {
    WiFi.mode(WIFI_AP);
    WiFi.softAP("LED_DISPLAY_SETUP");

    // 모든 DNS 요청을 기기의 IP(192.168.4.1)로 리다이렉트
    dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());

    // 웹 설정 화면 랜더링
    server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
        request->send(200, "text/html", "<h1>WiFi Setup</h1><form action='/save'>...</form>");
    });

    server.onNotFound([](AsyncWebServerRequest *request){
        request->redirect("http://192.168.4.1/");
    });

    server.begin();
}

// ==============================================================================
// 2. OTA(무선 업데이트) 초기화 및 콜백 정의
// ==============================================================================
void setupOTA() {
    ArduinoOTA.setHostname("IoT-LED-Display");

    ArduinoOTA.onStart([]() {
        String type = (ArduinoOTA.getCommand() == U_FLASH) ? "sketch" : "filesystem";
        Serial.println("OTA Update Start: " + type);
    });

    ArduinoOTA.onEnd([]() {
        Serial.println("
OTA Update Complete! Rebooting...");
    });

    ArduinoOTA.onError([](ota_error_t error) {
        Serial.printf("OTA Error[%u]: ", error);
        if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
        else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
        else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
    });

    ArduinoOTA.begin();
}

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

    // NVS에서 WiFi 정보 읽기 시도 후 실패 시 설정 모드 진입
    if (!connectToWiFi()) {
        setupCaptivePortal();
    } else {
        setupOTA();
    }
}

void loop() {
    if (WiFi.getMode() == WIFI_AP) {
        dnsServer.processNextRequest();
    } else {
        ArduinoOTA.handle(); // STA 모드에서 상시 무선 업데이트 대기

        // 디스플레이 데이터 롤링 및 렌더링 로직 수행
    }
}

4. 하드웨어의 감성과 데이터 수집(Scraping)의 실무적 난관

기술적인 아키텍처 외에도 상용화 단계에서는 하드웨어 감성과 외부 데이터 파싱의 난관이 기다리고 있었습니다.

수제품 나무 케이스가 주는 E-E-A-T 가치

초기 아크릴 케이스에서 동네 문구점의 나무판을 직접 사포질하고 접착해 만든 소형 버전은 공장에서 찍어낸 기성품과는 다른 고유한 가치를 만들었습니다. 나무의 결, 마감의 차이에서 오는 아날로그적인 따뜻함이 입소문을 타면서 지인 네트워크를 통해 10개 가까운 기기가 실제 배포되는 계기가 되었습니다.

마이크로컨트롤러 환경에서의 데이터 크롤링 트러블슈팅

환율, 뉴스, 증시 데이터를 가져와 전광판에 뿌리는 작업은 겉보기엔 단순하지만, 마이크로컨트롤러의 메모리 한계(Heap Memory Limitation)로 인해 수많은 장애를 겪었습니다.

  • 문제점: 뉴스 포털이나 증권사 API는 대부분 HTTPS 통신을 요구하며, 전송되는 JSON 파이프라인의 크기가 수백 KB에 달합니다. ESP32가 직접 SSL 핸드셰이크를 맺고 대용량 JSON을 메모리에 올려 파싱하려고 하면 즉시 Out of Memory(OOM) 크래시와 함께 워치독(Watchdog Reset)이 발생합니다.
  • 해결책: 임베디드 장치가 직접 무거운 웹 생태계와 맞서게 해서는 안 됩니다. 이전 글에서 구축했던 Python 보조서버를 중간 프록시(Proxy)로 활용했습니다. 파이썬 서버가 고속으로 웹 크롤링 및 복잡한 JSON 가공을 전담하고, ESP32에게는 전광판 픽셀 배열에 즉시 매핑할 수 있는 가벼운 평문(Plain Text) 또는 압축 스트리밍 데이터만 MQTT/HTTP로 넘겨줌으로써 메모리 누수 문제를 해결했습니다.

5. 결론: Toy Project에서 Product로 가는 길

개인용 프로젝트를 타인이 사용할 수 있는 디바이스로 발전시키는 과정은 기술의 완성도를 한 단계 끌어올리는 훌륭한 계기가 됩니다.

WiFi 비밀번호를 하드코딩하지 않는 Captive Portal, 사용자의 취향을 비휘발성 메모리에 기록하는 웹 설정 UI, 그리고 물리적 접촉 없이 펌웨어를 패치하는 OTA 무선 업데이트는 실제 현장에서 동작하는 IoT 디바이스가 갖추어야 할 필수 엔지니어링 3요소입니다. 2022년의 코인 열풍은 지나갔지만, 이 아키텍처를 바탕으로 설계된 나무 전광판은 지금도 실시간 증시와 환율을 띄우며 묵묵히 책상 위를 지키고 있습니다.