LED 시세 전광판 제작기 · 02

[IoT DIY 2편] 마이크로컨트롤러와 API 서버의 아키텍처 분리: API 키 보안, 비동기 로테이션 및 네트워크 장애 트러블슈팅

[IoT DIY 2편] 마이크로컨트롤러와 API 서버의 아키텍처 분리: API 키 보안, 비동기 로테이션 및 네트워크 장애 트러블슈팅

임베디드 디바이스를 개발할 때 많은 엔지니어와 메이커들이 저지르는 실수 중 하나는, 모든 연산과 외부 API 통신을 마이크로컨트롤러(ESP8266/ESP32) 내부에서 직접 처리하려 하는 것입니다. 하지만 암호화폐 거래소나 증권사 API와 연동되는 시세 전광판의 경우, 이러한 설계는 치명적인 보안 취약점과 시스템 다운을 유발합니다.

본 글에서는 단일 하드웨어가 가진 연산 및 보안 한계를 극복하기 위해 ‘파이썬 보조서버(Broker) - ESP 뷰어(Client)’로 역할을 완벽히 분리(Decoupling)한 아키텍처 설계 배경을 설명하고, 실제 현장에서 적용한 비동기 데이터 로테이션 로직과 펌웨어 업데이트 후 겪었던 네트워크 프리즈(Freeze) 트러블슈팅 과정을 단계적으로 공개합니다.

[구동 영상] Python 보조서버가 정제한 시세 데이터를 ESP 전광판이 비동기 로테이션으로 출력하는 테스트입니다.

1. 서버-클라이언트 역할 분리(Decoupling)의 필수성과 보안

1) 펌웨어 덤프(Dump)로 인한 API 키 유출 위험

업비트(Upbit)나 바이낸스 같은 암호화폐 거래소 API를 통해 실시간 수익률을 계산하려면 액세스 키(Access Key)시크릿 키(Secret Key)가 필요합니다. 만약 이 키를 며칠 안 되는 편의를 위해 ESP8266/ESP32의 소스 코드에 직접 하드코딩하여 기기에 업로드한다면 어떻게 될까요?

  • 보안 취약점: 마이크로컨트롤러는 플래시 메모리 보호 기능이 완벽하지 않습니다. 제3자가 기기를 물리적으로 입수하여 플래시 메모리를 덤프(Dump)하고 평문 문자열을 추출할 경우, 사용자의 거래소 API 키가 그대로 노출됩니다. 이는 악의적인 공격자가 내 계좌의 자산을 무단으로 매매하거나 탈취할 수 있는 치명적인 결과를 초래합니다.
  • 아키텍처 해결책: “인증과 연산은 안전한 내부 서버(PC/라즈베리파이)에서, 전광판은 단순한 디스플레이 모니터로” 역할을 정의했습니다. 파이썬 기반의 보조서버가 API 인증, 매수 내역 대비 수익률 연산, 데이터 가공을 전담하고, 전광판은 내부 로컬 네트워크(LAN)를 통해 “지금 화면에 띄울 문자열이 무엇인가요?”라고 HTTP 요청(Polling)만 수행하도록 격리했습니다.

2) 비즈니스 로직과 뷰(View)의 완전한 분리

이러한 구조 분리는 유지보수성을 극대화합니다. API 규격이 바뀌거나 새로운 주식 지수를 추가하고 싶을 때, 전광판의 펌웨어를 매번 다시 컴파일하고 OTA나 USB로 업데이트할 필요가 없습니다. 파이썬 서버의 로직만 수정하면, 전광판은 다음 주기에 자동으로 새로운 스펙의 데이터를 띄우게 됩니다.

2. 동적 정보 로테이션과 ‘빈 데이터 패스(Skip-on-Empty)’ 규칙

화면 공간이 제한된 도트매트릭스(8x16 또는 2x20 배열) 전광판에 시간, 암호화폐 시세, 수익률, 환율, 날씨 등의 다채로운 정보를 담기 위해서는 순차적인 동적 로테이션 시스템이 필요합니다.

이때 사용자 경험(UX)을 결정짓는 핵심 설계 규칙이 바로 ‘빈 데이터 패스(Skip-on-Empty)’ 알고리즘입니다.

로테이션 항목데이터 소스(Python Server)Skip-on-Empty 조건 판단(예시)전광판 출력 동작
1. 실시간 시계NTP / 로컬 시스템 시간항상 유효함기본 화면으로 표시(HH:MM)
2. 코인/증시 시세거래소 REST API장 마감(주식) 또는 API 응답 지연화면에 오류를 띄우지 않고 즉시 다음 순서로 패스
3. 내 수익률계좌 조회 + 연산 결과보유 종목 없음 또는 키 인증 오류공백이나 ‘0%’를 띄우지 않고 조용히 건너뜀
4. 환율 및 날씨OpenWeather / 환율 API새벽 시간대 RSS 뉴스 미발행 등빈 텍스트 프레임을 무시하고 다음 유효 데이터 출력

많은 메이커들이 API 통신 실패 시 화면에 Error 404, No Data, Null 같은 텍스트를 띄우도록 설계합니다. 하지만 책상 위의 인테리어 기기가 에러 메시지를 뿜어내는 것은 완성도를 크게 떨어뜨립니다. 서버 단에서 데이터가 없거나 유효하지 않을 때 공백이나 명시적인 스킵 플래그("skip": true)를 반환하게 하고, 전광판 상태 머신(FSM)은 이를 감지하는 즉시 다음 유효 인덱스로 부드럽게 화면을 전환하도록 설계했습니다.

3. [실무 코드 블록 1] Python API 보조서버(Flask + 시세 연동)

다음은 거래소 API의 시크릿 키를 안전하게 보관하면서, 전광판이 요청할 때만 정제된 JSON 데이터를 반환하는 실무형 파이썬 서버 보일러플레이트 코드입니다.

Python API 보조서버(Flask + 시세 연동)
import os
import requests
from flask import Flask, jsonify

app = Flask(__name__)

# [보안 규칙] API 키는 절대 코드에 적지 않고 OS 환경 변수에서 로드합니다.
UPBIT_ACCESS_KEY = os.getenv("UPBIT_ACCESS_KEY")
UPBIT_SECRET_KEY = os.getenv("UPBIT_SECRET_KEY")

def get_crypto_ticker(market="KRW-BTC"):
    """
    거래소 API를 호출하여 현재 시세와 전일 대비 등락률을 계산합니다.
    """
    try:
        url = f"https://api.upbit.com/v1/ticker?markets={market}"
        response = requests.get(url, timeout=3.0)
        response.raise_for_status()
        data = response.json()[0]

        price = f"{data['trade_price']:,} KRW"
        change_rate = round(data['signed_change_rate'] * 100, 2)

        return {
            "valid": True,
            "display_text": f"BTC {price} ({change_rate:+}%)",
            "type": "CRYPTO"
        }
    except Exception as e:
        # API 호출 실패, 타임아웃, 네트워크 오류 시 에러를 던지지 않고 skip 플래그 반환
        print(f"[Error] 시세 조회 실패: {str(e)}")
        return {"valid": False, "skip": True}

@app.route('/api/display/current', methods=['GET'])
def get_display_data():
    """
    ESP 전광판이 주기적으로 폴링(Polling)하는 단일 엔드포인트입니다.
    현재 시간에 맞춰 보여줄 데이터를 동적으로 결정합니다.
    """
    # [예시] 시세 데이터를 가져오되, 실패 시 Skip-on-Empty 규칙 적용
    crypto_data = get_crypto_ticker("KRW-BTC")

    if crypto_data.get("valid"):
        return jsonify(crypto_data), 200
    else:
        # 유효하지 않은 경우 전광판이 다음 모환 순서로 넘어가도록 지시
        return jsonify({"valid": False, "skip": True, "fallback": "CLOCK"}), 200

if __name__ == '__main__':
    # 0.0.0.0으로 개방하여 동일 와이파이(LAN) 내의 ESP 보드 통신 허용
    app.run(host='0.0.0.0', port=5000, debug=False)

4. [실무 코드 블록 2] ESP8266/ESP32 논블로킹 HTTP 폴링 및 로테이션 렌더러

마이크로컨트롤러 단에서 HTTP 통신을 수행할 때 delay()를 사용하면 디스플레이의 픽셀 스캔이 멈춰 글자가 깜빡거리거나 깨지는 현상이 발생합니다. millis() 기반의 논블로킹(Non-blocking) 폴링과 JSON 파싱을 수행하는 C++ 보일러플레이트 코드입니다.

ESP8266/ESP32 논블로킹 HTTP 폴링 및 로테이션 렌더러
#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClient.h>
#include <ArduinoJson.h>

const char* ssid = "MY_WIFI_SSID";
const char* password = "MY_WIFI_PASSWORD";
const char* serverUrl = "http://192.168.1.100:5000/api/display/current";

unsigned long lastRequestTime = 0;
const unsigned long POLLING_INTERVAL = 5000; // 5초 주기 데이터 갱신
String currentDisplayText = "BOOTING...";

// ==============================================================================
// 1. 논블로킹 HTTP 폴링 및 Skip-on-Empty 처리
// ==============================================================================
void fetchDisplayData() {
    if (WiFi.status() != WL_CONNECTED) return;

    WiFiClient client;
    HTTPClient http;

    // [트러블슈팅 핵심] 네트워크 타임아웃을 1.5초로 제한하여 멈춤(Freeze) 방지
    http.setTimeout(1500);

    if (http.begin(client, serverUrl)) {
        int httpCode = http.GET();

        if (httpCode == HTTP_CODE_OK) {
            String payload = http.getString();

            // 힙 메모리 단편화 방지를 위한 정적 JSON 문서 생성
            StaticJsonDocument<256> doc;
            DeserializationError error = deserializeJson(doc, payload);

            if (!error) {
                bool isValid = doc["valid"] | false;
                bool shouldSkip = doc["skip"] | false;

                // [Skip-on-Empty 규칙 적용] 데이터가 없거나 skip 플래그가 있으면 렌더링 무시
                if (!isValid || shouldSkip) {
                    Serial.println("[System] 빈 데이터 패스(Skip) - 이전 화면 유지 또는 시계 전환");
                    // 필요 시 로테이션 인덱스를 즉시 다음 단계로 강제 전환하는 로직 호출
                } else {
                    currentDisplayText = doc["display_text"].as<String>();
                    Serial.println("[Display update] " + currentDisplayText);
                }
            }
        } else {
            Serial.printf("[HTTP] 통신 실패 코드: %d
", httpCode);
        }
        http.end();
    }
}

void setup() {
    Serial.begin(115200);
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
    }
}

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

    // delay() 없이 5초마다 비동기 HTTP 요청 실행
    if (currentMillis - lastRequestTime >= POLLING_INTERVAL) {
        lastRequestTime = currentMillis;
        fetchDisplayData();
    }

    // 디스플레이 렌더링 루프는 통신과 무관하게 초당 60프레임 이상 상시 작동
    // renderLedMatrix(currentDisplayText);
}

5. 현장 트러블슈팅: 펌웨어 업데이트 후 로테이션 멈춤 및 Wi-Fi 먹통 현상

로테이션 규칙을 완벽하게 짰음에도 불구하고, 실제 운영 과정에서 “펌웨어를 업데이트한 후 장시간 작동 시 화면 전체가 멈춰버리는 치명적인 증상”을 겪었습니다. 원인을 규명하기까지 며칠이 걸렸던 이 장애의 핵심 원인 2가지와 해결책입니다.

Case 1: HTTP 동기식 요청 블로킹(Blocking)으로 인한 메인 루프 중단

  • 증상: 와이파이 신호가 일시적으로 불안정해지거나 공유기가 채널 변경을 시도할 때, 전광판 글자가 흐르다 중간에 딱 멈추고 10~20초간 아무 반응이 없는 현상.
  • 원인: 마이크로컨트롤러의 기본 HTTP GET 요청은 응답이 올 때까지 메인 루프를 블로킹(Blocking)하는 동기식으로 동작합니다. 공유기와의 연결이 꼬여 패킷 드롭이 발생할 경우, 기본 TCP 타임아웃(보통 수십 초 ~ 1분) 동안 CPU가 대기 상태에 빠져 디스플레이 스캔과 애니메이션 루프가 완전히 멈추게 됩니다.
  • 해결책:
    1. 위 C++ 코드 블록과 같이 http.setTimeout(1500);을 명시하여, 1.5초 이내에 서버 응답이 없으면 즉시 소켓을 끊고 디스플레이 렌더링 루프로 복귀하도록 제한했습니다.
    2. 통신 시도 전 반드시 WiFi.status() == WL_CONNECTED 상태를 체크하고, 3회 이상 연속 통신 실패 시 와이파이 모듈만 조용히 재접속(Soft-reset)하는 복구 루틴을 추가했습니다.

Case 2: String 객체 누적에 따른 힙 메모리 단편화(Heap Fragmentation)

  • 증상: 부팅 후 첫 24시간은 매우 매끄럽게 로테이션되나, 2~3일이 지나면 펌웨어 업데이트 여부와 상관없이 기기가 돌연 재부팅되거나 굳어버리는 현상.
  • 원인: 파이썬 서버로부터 받은 JSON 텍스트를 파싱하고 조합하는 과정에서 아두이노의 String 객체를 무분별하게 덧붙이기(+)할 경우, RAM 용량이 극도로 작은 ESP8266(사용 가능 힙 메모리 약 40KB) 내부에서 메모리 단편화(Fragmentation)가 심화됩니다. 연속된 여유 메모리 공간이 고갈되면 JSON 할당 시 Out of Memory 크래시가 발생합니다.
  • 해결책:
    1. 동적할당을 유발하는 DynamicJsonDocument 대신 메모리 크기가 고정된 StaticJsonDocument<256>을 사용하여 스택 메모리 내에서 안전하게 파싱을 마친 후 즉시 반환하도록 수정했습니다.
    2. 전역 String 변수의 크기가 무한정 커지지 않도록 초기에 .reserve(64)로 버퍼를 미리 할당하여 가비지 컬렉션 부담을 원천 차단했습니다.

6. 결론 및 확장성: 2x20 대형 도트매트릭스로의 진화

서버가 모든 인증과 연산을 전담하고 디스플레이는 정제된 텍스트만 보여주는 ‘역할 분리 아키텍처’를 채택한 덕분에, 이후 프로젝트를 확장하는 과정은 경이로울 만큼 쉬웠습니다.

초기 소형 8x16 배열의 전광판을 시야각이 넓은 2x20 대형 도트매트릭스로 업그레이드할 때, 비즈니스 로직이나 통신 프로토콜을 단 한 줄도 뜯어고칠 필요가 없었습니다. 단순히 ESP 보드의 글자 렌더링 폭(Width) 파라미터만 늘려주자, 파이썬 서버가 보내주는 시세와 환율, 시계 데이터가 광활해진 매트릭스 위를 부드럽게 흐르기 시작했습니다.

AI 코딩 어시스턴트조차 흔치 않던 2022년, API 문서를 직접 뜯어보며 맨땅에 헤딩하듯 구축했던 이 ‘서버-클라이언트 분리 구조’는, 몇 년이 지난 지금까지도 디바이스가 단 한 번의 보안 사고나 크래시 없이 제 책상 위에서 우직하게 돌아가는 가장 든든한 기술적 뼈대가 되고 있습니다.

이 서버-클라이언트 분리 아키텍처를 바탕으로, 야간 LED 눈부심 문제를 도플러 레이더와 FSM 상태 머신으로 해결한 과정은 3편: 야간 시각적 피로도 해결을 위한 다중 센서 퓨전에서 이어집니다.