구성을 바꾸자 증상이 돌아왔다

3편 에서 초기 버전을 완료했다. 실제 공장 설치를 준비하며 컨트롤러와 패널 구성을 바꿨다.

항목초기 버전교체 후
컨트롤러ESP32 NodeMCU-32S + 빵판 배선 + 외장 승압 모듈Adafruit Matrix Portal S3 (PID 5778) — 74AHCT245 레벨 시프터 내장, HUB75 16핀 직결
패널64×32 × 2장64×32 1/8 스캔 × 4장 (논리 256×32 / 물리 512×16 체인)
배선점퍼선 12가닥리본 케이블 직결 (접촉 저항·유격 제거)

레벨 시프터가 내장된 보드로 바꾸고 배선도 리본으로 정리했으니 신호는 더 깨끗해졌을 텐데, 배경의 푸르스름한 잔상이 다시 나타났다.

Adafruit Matrix Portal S3 보드가 HUB75 패널 네 장에 리본 케이블로 연결된 모습
교체한 Adafruit Matrix Portal S3 와 HUB75 4장 체인 결착 상태.

이번에는 변수를 하나씩 되돌렸다

3편에서는 우연히 발견한 현상(가로 스크롤이 지나가면 사라진다)에서 바로 해결책으로 갔다. 이번에는 순서를 바꿨다. 원인 후보를 하나씩 원래 값으로 되돌려 재현되는지 확인했다.

단계mxconfig.driverGPIO 드라이브 강도결과
1SHIFTREGCAP_1배경 정상 (Pitch Black)
2ICN2038SCAP_1푸른기 즉시 재발
3SHIFTREGCAP_3재발 없음
확정 원인mxconfig.driver = ICN2038S 설정 단 한 줄. GPIO 드라이브 강도도, 전원도, 배선도 아니었다.

라이브러리 소스를 읽고 나서야 이해됐다

ESP32-HUB75-MatrixPanel-DMA 라이브러리는 driver != SHIFTREG(0) 이면 begin() 에서 shiftDriver() 를 호출한다. 그런데 ICN2038S, FM6124, FM6126A 세 값이 전부 동일한 fm6124init() 으로 분기한다.

이 초기화 루틴은 패널에 글로벌 전류 게인 레지스터(REG1)를 거의 최대값으로 기입한다. 그런데 내가 쓰는 패널의 실제 드라이버는 이 레지스터 포맷을 따르지 않는다. 기입된 값이 엉뚱하게 해석되면서 블랙 레벨 자체가 떠 버린 것이다.

// [원인] driver != SHIFTREG → shiftDriver() → fm6124init() → 전류 게인 레지스터 오기입
// mxconfig.driver = HUB75_I2S_CFG::ICN2038S;   // ❌ 패널 라벨이 ICN2038S여도 이 옵션과는 비호환

constexpr HUB75_I2S_CFG::shift_driver PANEL_DRIVER = HUB75_I2S_CFG::SHIFTREG;   // ✅ 확정 해결
mxconfig.driver           = PANEL_DRIVER;
mxconfig.i2sspeed         = HUB75_I2S_CFG::HZ_8M;   // 512px 체인 안전 속도 명시
mxconfig.min_refresh_rate = 60;
패널 라벨을 믿으면 안 됐다패널에 붙은 칩 라벨이 ICN2038S 라고 해서 라이브러리의 ICN2038S 옵션을 고르면 되는 게 아니었다. 그 옵션이 실제로 어떤 초기화를 하는지는 소스를 직접 읽어야 알 수 있었다. 문서에는 "드라이버 칩에 맞춰 설정하라"고만 적혀 있다.

왜 하필 파란색만 뜨는가

블랙 레벨이 떴다면 R, G, B가 고르게 떠야 할 것 같은데 유독 푸르스름했다. HUB75 리본 케이블의 핀 배치를 보면 이유가 보인다.

 1 R1  |  2 G1
 3 B1  |  4 GND    ← B1이 GND 바로 옆
 5 R2  |  6 G2
 7 B2  |  8 E      ← B2가 E 바로 옆 (1/8 스캔 패널은 내부 GND)
 9 A   | 10 B
11 C   | 12 D      ← 1/8 스캔 패널은 내부 GND
13 CLK | 14 LAT
15 OE  | 16 GND

Blue 두 라인(B1=3번, B2=7번)만 GND 계열 핀과 물리적으로 인접해 있다. 따라서 블랙 레벨이 조금만 떠도 R/G보다 B가 먼저 문턱값을 넘어 미세하게 점등되고, 결과적으로 "푸르스름한 배경"으로 보인다.

함께 정정한 오진 두 건

3편의 해결책은 무엇이었나"가로 스위핑 강제 방전"은 원인을 제거한 것이 아니라 증상을 덮은 우회책이었다. 화면이 한 번 훑고 지나가면서 프레임버퍼를 전부 덮어썼기 때문에 정상으로 보였을 뿐이다. 설정 한 줄이 원인이었다는 것은 변수를 하나씩 되돌리는 교차 검증을 하고 나서야 확정할 수 있었다.

그리고 화면이 굳었다

리팩터링을 하고 나니 새 증상이 생겼다. 와이파이가 연결된 직후 화면이 멈춘 채 굳었다. 증상은 하나였지만 원인은 두 개가 겹쳐 있었다.

원인 A — 스캔이 렌더링 루프를 잡고 있었다

WiFiMulti::run() 은 전 채널을 스캔하는 블로킹 호출이라 수 초간 CPU를 점유한다. 이걸 loop()(코어 1)에서 불렀기 때문에 스캔이 도는 동안 I2S 렌더링이 통째로 정지했다. 신호가 약할수록 스캔이 길어져 더 오래 멈췄다.

3편에서 HTTP 수신만 코어 0으로 뺐는데, 스캔과 연결도 블로킹이라는 걸 놓쳤다. 모든 블로킹 네트워크 작업을 코어 0 전담 NetTask 로 격리했다. loop()volatile 상태값만 읽고 절대 블로킹되지 않는다.

원인 B — 리팩터링이 드러낸 순서 의존성

// [BEFORE] 스크롤 좌표 갱신이 else 분기 안에만 있었다
if (!isOfflineMode) {
    drawOnlineScreen();        // ← 내부에서 getLocalTime() 실패 시 drawOfflineScreen()으로 폴백
} else {
    if (now - lastScrollTime >= scrollSpeedMs) { offlineScrollX--; ... }   // ← 여기에만 존재
    drawOfflineScreen();
}

configTime() 은 비동기라 호출 직후에는 시계가 아직 무효다. NetTask 가 연결에 성공해 isOfflineMode = false 가 되면 drawOnlineScreen() 이 호출되지만, 시계가 없어 내부에서 오프라인 화면으로 폴백한다.

이때 offlineScrollXelse 분기에 있어 절대 갱신되지 않으므로 같은 프레임이 계속 그려진다 = 정지. UDP 123이 막혀 있거나 전파가 약해 SNTP가 끝내 동기화되지 않으면 영구 정지한다.

전형적인 리팩터링 회귀 버그블로킹 NTP 대기를 제거하자, 그 대기에 암묵적으로 의존하던 렌더링 순서 문제가 표면화됐다. 예전 코드에서는 setup() 이 시계가 맞을 때까지 기다린 뒤 루프에 들어갔기 때문에 이 경로가 아예 발생하지 않았다.

같이 잡은 것들

버그영향조치
wifiMulti.addAP() 를 매 사이클 반복 호출내부 AP 리스트 무한 증식 → 힙 누수 + 스캔 시간 증가부팅 시 1회만 등록
WL_CONNECT_FAILED 1회로 인증 실패 확정 (3편의 해결책)전파가 약해도 이 코드가 뜨므로, 한 번 삐끗하면 영영 오프라인 고정3회 연속 기준으로 완화, 영구 잠금 제거
WIFI_PS_MIN_MODEM (3편의 판단)약전계에서 비콘 놓침 → 접속 직후 끊김WIFI_PS_NONE
WIFI_POWER_17dBm최대 출력이 아니었음 (ESP32-S3 최대는 19.5dBm)WIFI_POWER_19_5dBm, 연결 성공 시마다 재적용
재연결마다 날씨 API 강제 즉시 호출링크가 불안정하면 기상청 호출 한도 소진weatherIsStale() 판정 — 연결 유지가 최우선
setup() 의 블로킹 연결 + NTP 대기부팅 후 최대 20초 검은 화면전부 제거, 즉시 오프라인 화면부터 시작

3편에서 "해결"이라고 적었던 판단 중 세 개가 여기서 뒤집혔다. 모뎀 슬립, 인증 실패 영구 고정, 그리고 잔상 퍼지. 구성이 바뀌자 당시의 판단이 오히려 발목을 잡았다.

현장에 걸기 전에 — 전파를 화면으로 보기

공장 현장은 금속 케이스 내부 설치가 예정돼 있어 RF 차폐가 우려됐다. 그런데 설치 환경에서는 PC를 붙이기 어렵고, PSU 5V가 인가된 상태에서 USB를 꽂으면 역전류 위험이 있어 시리얼 모니터에 의존할 수 없었다.

그래서 전파 세기를 LED 화면 자체에 표시하도록 만들었다.

기능동작
연결 토스트 RSSI 표기WiFi OK -58dBm(초록) / RSSI WARN -78dBm(주황) / PASSWORD ERR!(빨강). 문자열 길이 기반 자동 중앙정렬
RSSI 워치독온라인 상태에서 −75dBm 미만이면 10분 주기로 경고 토스트
UP 버튼 (온보드 GPIO6)전체화면 RSSI 모니터 토글 — 큰 dBm 수치 + GOOD/FAIR/WEAK 등급 + 게이지 바(−95~−35dBm)
DOWN 버튼 (온보드 GPIO7)패널 하드 리셋 — 표시가 깨졌을 때 전원 재투입 없이 DMA 재초기화

버튼 극성은 부팅 시 실측해 자동 판별하므로 보드 리비전 차이를 코드 수정 없이 흡수한다.

실측해 보니 자세가 전부였다

LED 전광판 전체화면에 dBm 수치와 등급, 게이지 바가 표시된 RSSI 모니터 화면
UP 버튼으로 띄운 전체화면 RSSI 모니터. 설치 위치를 바꿔 가며 이 화면만 보고 판단했다.
환경배치RSSI등급
실내 (배면 목재벽)기본 자세−66 ~ −59FAIR~GOOD
실내LED 패널면이 AP를 향함−58 ~ −56GOOD
실내모듈 안테나부가 AP를 향함−49GOOD
옥외 (대리석 벽 밀착)안테나가 대리석을 향함순간 −89 → −78 ~ −76WEAK
옥외 (대리석 벽 밀착)안테나가 패널 쪽을 향함−74 ~ −68FAIR
옥외벽에서 떼어냄−66 ~ −61GOOD
여기서 나온 결론자세만 바꿔도 최대 17dB 차이가 났다. LED 패널 PCB의 통 구리 그라운드 플레인 자체가 이미 상당한 차폐체라는 실측 증거다. "전면이 플라스틱이니 안테나를 앞으로 향하면 된다"는 통념은 성립하지 않았다. 벽에서 떼기만 해도 17dB 회복되는 것은 재질 감쇠보다 근접장 디튜닝이 지배적이라는 뜻이다. 2.4GHz에서 λ/10 ≈ 12mm 이내에 유전체나 금속이 닿으면 안테나 임피던스가 틀어져 단순 감쇠보다 훨씬 크게 손해를 본다.
안전장치NetTask 도입 이후에는 와이파이가 끊겨도 화면이 멈추지 않는다. 오프라인 마키가 계속 구동되고 15초 주기로 백그라운드 재연결을 시도하므로, 전파가 나빠도 전광판 본연의 안전수칙 표시 기능은 정상 동작한다. 날씨만 갱신이 늦어질 뿐이다.

코드 정리

이 시리즈에서 남은 것

네 편에 걸쳐 쓴 것을 한 줄씩 줄이면 이렇게 된다.

이 유형의 실수(라이브러리 옵션이 실제로 무엇을 하는지 확인하지 않은 것, 핀 매핑을 가정으로 둔 것, 증상만 덮는 우회책)는 표로 정리해 프로젝트 문서에 남겼다. 다음에 같은 자리에서 시간을 쓰지 않기 위해서다.