Joyuno's Dev Blog

2026-06-21 (일) 개발일지

dev-blog

오늘의 트러블슈팅: HA 웹 데드락, 몽글목장 타입 안전성, 그리고 CRLF 파싱의 함정

오늘은 Home Assistant Voice PE 환경에서 HA 웹이 죽는 데드락, 몽글목장 개체 등급 시스템 구현 중 GDScript 타입 추론 문제, 그리고 study_game_godot YAML 팩의 CRLF 줄바꿈 파싱 버그까지 세 가지 이슈를 잡았다. 셋 다 원인이 전혀 다른 곳에 있었고 — 하나는 맥 Docker의 근본적 한계, 하나는 언어의 타입 시스템 허점, 하나는 이식성의 함정이었다.


Home Assistant Voice PE — 맥 Docker에서 HA 웹이 죽고, mDNS 자동발견이 깨진 이유

증상: docker compose up으로 HA를 재시작한 직후, 웹 대시보드(port 8123)에 접속이 안 됐다. 브라우저는 빈 화면 또는 auth_callback 500 에러를 반환. 앱에서도 homeassistant.local:8123으로 자동발견이 되지 않아 연결할 수 없었다.

첫 가설: OAuth 콜백 경로가 꼬였거나, 로그인 토큰이 만료된 줄 알았다. .storage/auth 파일을 지우고 재시작해봤지만 증상이 동일했다. 그제서야 docker logs를 찍어봤다.

단서 수집: 로그는 깔끔했다 — 특별한 오류 없이 정상 기동. 그런데 앱에서 homeassistant.local을 찾지 못한다는 건 mDNS가 동작하지 않는 구조적 문제였다.

원인 추적 — 사고의 진화: 맥에서 Docker로 HA를 돌릴 때 homeassistant.local mDNS 자동발견은 기본적으로 깨진다는 걸 알게 됐다. 맥 Docker는 Linux 컨테이너 위에서 가상 네트워크 스택을 쓰는데, mDNS(multicast DNS) 패킷이 컨테이너 브리지를 제대로 통과하지 못한다. 그래서 homeassistant.local이라는 이름으로는 절대 찾을 수 없고, LAN IP를 직접 입력해야만 연결된다.

그런데 웹이 죽은 건 별개였다. 컨테이너 자체는 살아 있고, curl localhost:8123은 정상 응답. 문제는 외부 접속이었다. .env에 HOST 변수가 0.0.0.0이 아니라 127.0.0.1로 고정돼 있어서, 컨테이너 내부 바인딩은 되지만 호스트 머신의 LAN 인터페이스로는 포워딩이 안 됐다.

해결: .env의 HOST를 실제 LAN IP로 변경하고 HA를 재시작했다 (docker compose down && docker compose up -d). 그 후 앱에서 수동 주소 등록으로 http://<INTERNAL_IP>:8123 입력하자 정상 연결됐다. homeassistant.local은 포기하고 앱 설정에서 수동 주소 등록을 기본으로 쓰기로 했다.

항목 before after
HA 바인딩 주소 127.0.0.1:8123 <LAN_IP>:8123
앱 연결 방식 자동발견(mDNS) → 실패 수동 주소 등록 → 성공
docker logs 정상, 에러 없음 동일

검증: HA 웹 대시보드 정상 접속 확인. edge_tts와 claude_code_bridge 커스텀 컴포넌트가 “not tested by HA” 경고를 띄우는데 이는 모든 서드파티 커스텀 컴포넌트에 항상 뜨는 정상 메시지였다.

남은 과제: 맥 Docker의 mDNS 제한은 해결 방법이 없음. 만약 Voice PE와의 음성 명령 연결이 LAN IP로 안 잡히면, 별도 DNS/호스트 파일에 nabu 같은 이름을 수동 등록해야 할 수도 있다.


몽글목장 개체 등급 시스템 — GDScript := 타입 추론으로 인한 hue-rotation 오차

증상: study_game_v2 프로젝트(Godot 4.6.2 GDScript)에 개체 등급(★1~5)과 변종(반짝) 경제 시스템을 구현하면서, character_sprite.gd에 tint shader(색조 회전)를 적용했더니 일부 개체에서 hue-rotation 값이 의도와 다르게 적용됐다.

처음엔 shader 파라미터 이름을 헷갈린 줄 알았다. tint.gdshader에는 shader_parameter("hue_shift")로 선언돼 있었고, GDScript에서 shader_material.set_shader_parameter("hue_shift", float(tint) / 360.0)로 전달했다. 변수명은 정확했다.

단서: 디버그 출력으로 tint 값을 확인했더니, 0이 들어가야 할 일반 개체가 0.0 대신 1.0을 넘기거나, 180이 들어가야 할 개체가 180.0 대신 0.0이 전달되는 케이스가 있었다.

원인 추적: 문제는 코드 컨벤션에 숨어 있었다. 기존 study_game_v2 코드베이스는 := 연산자로 타입을 추론하는 스타일이었고, character_sprite.gd도 그 스타일을 따라 var tint := character_data.get("tint", 0) 같은 식으로 변수를 선언했다. 그런데 character_data.get(...)은 Variant 타입을 반환하고, :=는 이를 Variant로 추론한다. 이후 float(tint) / 360.0에서 암시적 변환은 되지만, tint가 int인지 float인지가 불확실해져 shader에 전달될 때 값이 튀는 현상이 발생했다.

결정적 발견: var tint := ...가 Variant로 추론되면서, bool(true) 같은 엉뚱한 값이 tint에 할당돼도 컴파일 에러가 안 났다.

해결: :=를 포기하고 명시적 타입 선언으로 전환했다.

var tint: int = int(character_data.get("tint", 0))

또한 _build_nodes()에서 shader를 부착할 때도 타입을 구체적으로 명시했다:

var mat := ShaderMaterial.new()  # 이건 문제 없음 — ShaderMaterial은 구체 타입
mat.shader = load("res://assets/shaders/tint.gdshader")
mat.set_shader_parameter("hue_shift", float(tint) / 360.0)

변경 후 tint > 0일 때만 shader를 부착하고, tint == 0일 때는 아예 shader를 설정하지 않도록 조건을 추가했다 — 불필요한 기본값(0.0) 전달을 막기 위해서다.

검증: 시장에서 ★2 솜솜 980 골드 vs ★1 솜솜 667 골드 — 같은 종인데도 등급에 따라 가격 차등이 정확히 반영됨. 변종(반짝) 개체는 tint 값이 120~240 범위로 들어가 hue-rotation이 눈에 띄게 적용됐다.

조건 before (오동작) after (정상)
일반 개체 tint 0 0.0 전달 → shader 적용 안 됨(의도) 동일
★4/5 반짝 개체 tint 180 0.0 또는 1.0 전달 → 엉뚱 색 180.0 전달 → 정확한 보색 회전
타입 안전성 := → Variant 추론 → 디버깅 어려움 명시적 int(), float() → 컴파일 타임 체크

남은 과제: collection.gd와 gacha.gd도 동일한 := 패턴을 쓰고 있어, 나중에 확장하다 비슷한 버그가 터질 수 있다. 지금 당장 고칠 필요는 없지만, 추후 리팩토링 대상으로 기록해뒀다.


JLPT 팩 파싱 실패 — CRLF 줄바꿈이 YAMLPackParser를 막은 이유

증상: 5/17 ~ 5/18 작업으로 study_game_godot에 JLPT N2 접속사(104문항)와 문장 순서(102문항) 팩을 추가했다. 그런데 기존에 잘 동작하던 reading.yml 팩이 갑자기 게임에서 열리지 않았다. debug_jlpt_load.gd 테스트 스크립트에서 “YAML parse error”가 떴다.

첫 가설: 새로 추가한 팩의 YAML 문법에 오류가 있거나, 기존 reading.yml에 충돌하는 키가 있다고 생각했다. Node.js로 작성된 data-conv.mjs에서 생성한 YAML을 출력해 문법 검증기를 통과시켰다 — 문법은 정상이었다.

단서: 문제는 reading.yml 하나만 깨지는 게 아니라, 모든 팩이 순서대로 깨지는 패턴이었다. 테스트 스크립트가 팩을 읽는 순서가 고정돼 있었는데, reading.yml이 3번째 팩이었고 거기서 멈췄다. 1, 2번 팩은 정상.

원인 추적: YAMLPackParser.gd를 열어 line-by-line 파싱 로직을 살펴봤다. 핵심은 파일을 \n(LF) 기준으로 split()한 뒤 각 줄을 처리하는 구조였다. 그런데 reading.yml은 Windows 환경에서 git clone할 때 CRLF로 변환된 상태였다. \r\n을 LF로 쪼개면 각 줄 끝에 \r이 붙어서, string.begins_with(" "), string.strip_edges() 같은 조건이 의도대로 동작하지 않았다.

파일: reading.yml (CRLF)
실제 내용: "  - question_id: 21\r\n  - options:\r\n"
파서 split("\n") 결과: ["  - question_id: 21\r", "  - options:\r"]
→ `\r` 때문에 `strip_edges()`가 공백 제거 실패, 키-값 매칭이 깨짐

해결: 두 군데를 고쳤다.

  1. YAMLPackParser.gd: 라인을 읽은 직후 replace("\r", "")로 CR를 제거. 핵심 함수인 _parse_lines() 시작 부분에 추가했다.
# before
var lines := content.split("\n")

# after
content = content.replace("\r", "")
var lines := content.split("\n")
  1. test_runner.gd: 테스트 스크립트가 실패한 팩에서 멈추지 않고 계속 진행하도록 break 대신 continue로 변경. 이건 디버깅 편의 개선.

검증: test_runner.gd를 headless로 실행 — reading.yml 포함 모든 팩이 정상 파싱됐다. git push 후 원격 저장소에서도 git config core.autocrlf input으로 설정해 다시는 CRLF 변환이 생기지 않도록 방어했다.

팩 before after
vocab.yml 정상 정상
grammar.yml 정상 정상
reading.yml CRLF → 파싱 실패 replace("\r","") → 정상
conj.yml (신규) 정상 (LF) 정상
order.yml (신규) 정상 (LF) 정상

남은 과제: YAMLPackParser 외에 다른 파서(CSVPackParser 등)도 CRLF 공격을 받을 가능성이 있다. 이번에 발견된 취약점이지만, 현재 모든 팩이 YAML 포맷이라 replace("\r","") 한 줄로 전체를 방어할 수 있어 더 이상 확장은 안 했다.


오늘의 한줄

타입 시스템의 빈틈(:= 추론)과 이식성의 함정(CRLF) 둘 다, 유닛 테스트나 로그만으로는 절대 못 잡는다 — 실제 파이프라인을 통과한 데이터로 검증해야만 드러난다.