2026-06-04 (목) 개발일지
사주 앱 디자인 결함 + e2e 재검증 — nudenet fail-open, 결제 실패 빈 화면, 그리고 WebSocket 실시간 채팅까지
오늘은 사주 앱에서 디자인 P0 결함 수정과 동시에 실제 매칭·실시간 채팅이 제대로 동작하는지 e2e로 재검증하는 일이었다. 처음엔 단순 디자인 픽스라 생각했는데, nudenet 엔진이 꺼졌을 때의 처리(nsfw_scanned 플래그 누락), 결제 성공 후 분석 실패 시 빈 화면, 그리고 WebSocket 핸드셰이크의 HTTP 403 거절 같은 비즈니스 로직 버그들이 뒤엉켜 있었다.
디자인 P0 — 단청 토큰 일관성 복원과 Phosphor 아이콘 매핑 검증
브랜드 감사 보고서에서 3개 화면(onboarding_result_screen.dart, partner_profile_screen.dart, premium_compat_screen.dart)에 먹·한지·단청 3축 색상 토큰이 적용되지 않고 기본 Flutter 색상이 그대로 쓰이고 있다는 지적이 나왔다. 특히 snackbar 아이콘 121번과 8개 섹션 아이콘이 Material 아이콘으로 잘못 매핑돼 있었다.
처음엔 단순 const Row 제거 문제라 생각했다. snackbar 아이콘(121)이 const Row 안에서 동적 icon을 쓰려고 해서 build 오류가 났는데, const를 빼는 건 몇 초면 되는 일이었다. 문제는 8개 섹션 아이콘 매핑이 보고서에 적힌 Phosphor 이름과 실제 패키지에 설치된 버전이 일치하는지였다. face_retouching_natural → smiley, psychology_alt_rounded → brain, favorite_rounded → heart, auto_awesome → sparkle 같은 매핑이 보고서에는 적혀 있었지만, 내가 직접 pub.dev에서 Phosphor 패키지의 아이콘 목록을 확인해야 했다.
시행착오: warning_amber_rounded를 warning(fill)로 매핑했는데, Phosphor v2.x에서는 warning_circle이 있고 warning_amber는 없었다. 보고서를 믿고 그냥 넣었다면 빌드는 되지만 아이콘이 깨졌을 것이다. 결국 Phosphor 깃허브 릴리즈 노트에서 warning_amber가 warning으로 합쳐진 내역을 찾아 Icons.warning_filled로 대체했다.
해법: 색상 토큰(AppColors)만 교체하고 레이아웃·로직은 불변으로 유지. 아이콘은 MaterialIcons 대신 PhosphorIcons로 전수 매핑. flutter analyze 0 에러로 통과.
검증: 감사 보고서의 8개 결함 항목을 하나씩 대조 — 색상 3건, 아이콘 5건 모두 수정 확인. const Row 제거로 런타임 오류 해소.
nudenet fail-open — nsfw_scanned 플래그가 없어 관리자가 0.0점을 신뢰한 문제
moderation.py에서 nudenet 엔진이 동작하지 않을 때(예: 초기 부팅, GPU 메모리 부족) 모든 사진이 nsfw_score: 0.0을 반환했다. 관리자 입장에선 “0.0 = 안전”이라고 믿고 승인할 수밖에 없었다. 처음엔 단순히 점수 하드코딩 문제라 생각해 nsfw_scanned: false 플래그를 추가하는 것으로 해결할 수 있을 거라 봤다.
하지만 진짜 원인은 프론트엔드와 관리자 UI 모두 이 플래그를 렌더링하지 않는다는 점이었다. photos.py의 moderation_note 필드가 nsfw_score만 직렬화하고 nsfw_scanned는 무시했다. 그래서 아무리 백엔드에서 플래그를 추가해도 API 응답에 포함되지 않아 프론트가 알 방법이 없었다.
전환점:
photos.py의serialize_photo()를 열어보니,moderation_note에nsfw_score만 있고nsfw_scanned는 누락돼 있었다. 관리자 화면에서 0.0점이 “검사 안 됨”인지 “안전함”인지 구분할 수 없는 근본 원인이었다.
해법:
photos.py의moderation_note에nsfw_scanned추가nsfw_scanned == false일 때⚠️ nudenet 미실행 — 수동 확인 필요메모 최우선 표시- 정상 스캔 시 기존 메모 유지 (무영향)
검증: 단위테스트에서 nsfw_scanned=false 케이스 추가 — API 응답에서 moderation_note에 ⚠️ 포함 확인. 기존 0.0점은 영향 없음.
결제 성공 + 분석 실패 → 빈 화면 — analysis_failed 플래그와 retry 동선
/api/v1/compat/ 엔드포인트에서 결제(purchase_match_premium_compatibility)는 성공했는데, Claude 8섹션 분석 생성이 실패하면 analysis: null 상태로 sections: {}가 반환됐다. 프론트(premium_compat_screen.dart)는 sections가 비어 있을 때 빈 화면을 렌더링할 뿐 retry 버튼도 없었다.
처음엔 백엔드가 500을 반환해야 한다고 생각했다. 하지만 결제는 이미 완료된 상태에서 분석만 실패한 건데, 500을 내면 유저가 다시 결제해야 한다고 오해할 수 있었다. 더 근본적인 문제는 백엔드가 analysis_failed 플래그를 직렬화하지 않아 프론트가 “분석 실패” 상태인지 “분석 대기” 상태인지 구분할 수 없었다는 점이다.
matching.py의 generate_personal_analysis 호출부를 보니, is_a 변수가 purchase_match_premium_compatibility 스코프에 정의되지 않은 채 참조되고 있었다(line 977). 이게 분석 실패 시 UnboundLocalError를 던져 analysis: null로 떨어지는 직접 원인이었다.
시행착오: 수정 방향을 두고 고민했다. (1) 500 반환 + 재결제 유도, (2) analysis_failed=true + 빈 sections 반환 + 프론트 retry 버튼. 결국 (2)를 선택한 이유는 결제는 유효한데 분석만 실패한 경우 재결제를 시키면 유저 경험만 나빠지고 추가 수익도 없다는 판단 때문이었다.
해법:
- 백엔드:
analysis_failed: true+sections: {}반환,is_a버그 수정 - 프론트:
analysis_failed시 “분석 실패 — 다시 시도” 버튼 렌더링, retry 시 Claude 재생성 - retry가
paid_at을 변경하지 않음 (중복 결제 방지)
검증: e2e 테스트 5/5 PASS — 분석 실패 재현 → 정상 response → retry 성공 → paid_at 불변 → 미결제 retry는 402 → 이미 분석 있으면 0.01s 즉시 반환(중복 LLM 호출 없음).
실시간 채팅 WebSocket — HTTP 403 핸드셰이크 거절이 테스트 허상이었던 이유
/api/v1/chat/ws/{room_id} WebSocket + ConnectionManager 브로드캐스트 구조를 검증하는 게 진짜 난제였다. 유저 2명을 WS 연결시키고 A가 메시지를 보내면 B가 실시간 수신하는지를 확인해야 했다.
처음엔 qa_oyeon_test.py를 작성해 단순 WS 연결 테스트를 돌렸다. 그런데 두 개의 “FAIL” 케이스(B4, B4b)가 떴다 — 서버가 인증되지 않은 연결을 HTTP 핸드셰이크 단계에서 HTTP 403으로 거절했는데, 테스트 스크립트가 이를 WS 완료로 해석하지 못한 것이다.
전환점:
qa_oyeon_test.py의 에러 핸들링을 까보니,websockets라이브러리는HTTP 403을ConnectionClosedError로 래핑하지 않고InvalidStatus예외를 던졌다. 이 예외를 잡지 않아서 “FAIL”로 기록된 거였다. 실제로는 서버가 정상적으로 인증되지 않은 연결을 차단하고 있었다.
해법: qa_oyeon_test.py에 InvalidStatus 핸들러 추가, HTTP 403을 “예상된 거절”로 처리. 실제 인증된 유저 2명으로 WS 연결 테스트를 다시 해보니 A 송신 → B 수신이 정상적으로 동작했다.
검증: 실서버에서 유저 A(ws://.../ws/room_1?token=...)와 유저 B 연결 후 A가 “안녕” 전송 → B가 “안녕” 수신 확인. ConnectionManager 브로드캐스트 로그에서 send_personal_message 호출 2회(각 유저에게) 기록됨.
오늘의 한줄
“점수와 상태는 별개로 관리하고, 프론트가 상태를 렌더링하지 않으면 백엔드 플래그는 무용지물이다 — API 응답의 직렬화 계층까지 쫓아가야 진짜 fix가 보인다.”