로그인 안 된 첫 화면의 인증 리스너 무한 재구독 수리 — 원인 둘·게이트 두 겹 (2026-09-16)
- 기간: 2026-09-15 22:24 ~ 2026-09-16 00:15 KST (세션 2개 — 발견·분석·계획
c3abeeeb(디자인 개선 세션 5 #1678 도중, 오너 "따로 다루지 마 — 맨날 그래놓고 까먹어서 기술 부채 쌓인다"), 구현da1113da(오너 "#1680 계속해줘")) - 랜딩: PR #1685(Phase 1
12607393· Phase 2cee529eb· Phase 3665d2079· release 합침29972caa) → release/v0.19.4 병합de5800f0(큐 run 34984527198) — 마이그레이션·엣지 없음, staging·Production 미반영(승격 대기) - 설계서: 없음(수리 건) — 원인 분석·Phase 계획·예상 효과는 #1680 댓글과 계획 갱신
- 정본: 앱
src/react/controllers/remoteDataController.ts(인증 runtime 조립 effect,latestAuthRuntimeHelpersRef) ·src/react/app/ownerStoreSetHost.ts(같은 오너 전환은 묶음 유지) · 렌더 불변식 계약 §2 규칙 17 - 도구: host 격리 스크립트(tsx로 host+runtime만 띄움, 세션 scratchpad, 레포 밖) · Barbelic-preview
up --app(프리뷰 5302, staging DB) - 게이트: 앱
npm run check:renderR7(check:static→ 승격 ① precheck 레인) · QA2browser-login-listener-stability(QA2 PR #12, 병합·핀 갱신 뒤 승격 ② QA2 레인). QA2 집중 실행qa:ci --mode focused --risk auth: 결함e3789e745/6(새 계약 실패, 3초 유휴 창에 리스너 제거 4,232건) · 수정cee529eb6/6(0건). 마이그레이션 없음 — DB 재적용 기록 없음 - 버그리포트:
bug-report/bug-150-20260915.md— 이 건의 정본. 아래는 그 리포트를 잇는 짧은 기록 - 계약: 렌더 불변식 계약 §2 규칙 17(인증 리스너는 앱 마운트당 1회, R7), §4·§5·§6의 R1~R6 → R1~R7
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 인증 effect 마운트당 1회(도우미 최신값 ref) + PR 화면 콜백 안정화 | ✅ PR #1685 (12607393) |
| Phase 2 | 오너가 그대로인 전환(없음→없음)에서 오너 스토어 묶음 유지 | ✅ PR #1685 (cee529eb) |
| Phase 3 | 정적 게이트 R7 + QA2 로그인 화면 유휴 계약 | ✅ PR #1685 (665d2079) · QA2 PR #12 병합 대기 → 앱 핀 갱신 PR |
| Merge Check | release/v0.19.4 병합 | ✅ de5800f0 |
1. 배경
v0.19.3(2026-09-15 22:14 KST Production)은 #1610 렌더 불변식 개편과 #1638 홈 복구 합류를 실었다. 배포 직후 디자인 개선 세션 5의 프리뷰에서 오너가 심사용 로그인 입력 중 화면이 멎는 것을 보고했고, 같은 시각 Production·staging을 로그인 안 된 상태로 열면 콘솔에 인증 이벤트 구독·해제가 1~2ms 간격으로 끝없이 찍혔다(8초에 8만 건).
2. 문제 제기
인증 리스너를 붙이는 effect가 로그아웃 정리에 딸려 바뀌는 콜백에 의존했다
유저 A가 로그인 안 된 채 앱을 열면 부팅이 "로그인 안 됨"으로 끝나며 로그아웃 정리가 돈다. 그 정리가 오너 스토어 묶음을 바꾸면 PR 화면 콜백 → 데이터 초기화 → 계정 채택 함수가 줄줄이 새 함수가 되고, 인증 effect의 의존성 하나(adoptVerifiedAuthenticatedIdentity)가 바뀌어 React가 리스너를 떼고 다시 붙인다. 재구독은 다시 첫 세션 이벤트를 내어 같은 정리를 부른다. 이 사슬은 #1610 Phase 2-3a c7f82975(PR·리포트 화면 상태를 오너 스토어 prScreen으로)에서 이어졌다. 상세는 BUG-150 §2.
오너가 바뀌지 않았는데도 오너 스토어 묶음을 새로 만들었다
오너 스토어 host는 runtime의 모든 전환(없음→없음 포함)에 묶음 교체로 반응했다. 묶음은 오너 단위라는 계약과 어긋나며, 위 사슬이 없을 때는 낭비였을 뿐 무한 반복은 아니었다.
3. 해결 방안
원칙 (오너 지시, 2026-09-15)
- "따로 다루지 마. 맨날 그래놓고 까먹어서 기술 부채 쌓인거잖아" — 처음 계획의 "(B) 별도 이슈로 분리 가능"을 삭제하고 두 원인을 같은 트랙에서 고친다(전역 지침 §32 등재). 채택.
- D1(Production 즉시 롤백 여부)은 오너 손이 필요한 결정으로 남겨 두고 수정 릴리스 경로를 진행.
접근
콜백 하나만 ref로 되돌림(기각: 다른 콜백이 사슬에 들어오면 재발) / 인증 effect를 마운트당 1회로 고정 + 같은 오너 전환에서 묶음 유지 + 게이트 두 겹(채택). 비교표는 BUG-150 §3.
4. 적용한 내용
Phase 1 — 인증 effect 마운트당 1회 (#1685 12607393)
remoteDataController.ts의 인증 runtime 조립 effect는 controller 콜백 11개를 latestAuthRuntimeHelpersRef(렌더마다 최신 구현으로 갱신되는 ref)로 읽는 위임자로 넘기고 의존성을 비웠다 — 로더 안정 파사드와 같은 방식. prExerciseQueryBinding.ts의 setActivePrExerciseId/endPrExerciseVisit는 prScreenRef로 최신 스토어를 읽어 오너 묶음이 바뀌어도 같은 함수다(읽기·구독 콜백은 스토어가 바뀌면 다시 구독해야 하므로 그대로 prScreen에 의존). 검토된 ESLint 억제 목록의 문구를 갱신했다.
Phase 2 — 같은 오너 전환에서 묶음 유지 (#1685 cee529eb)
ownerStoreSetHost.ts는 전환의 이전·다음 오너가 같으면 묶음을 만들지 않고 보존·리셋 훅도 돌리지 않으며(리셋된 묶음을 계속 쓰면 폐기된 구독이 남으므로) 구독자에게도 알리지 않는다 — 다음 epoch에 다시 등록만. runtime의 epoch 증가(옛 응답 차단)는 그대로.
Phase 3 — 게이트 두 겹 (#1685 665d2079, QA2 PR #12)
check-render-rules.mjs R7: 인증 조립 effect의 의존성 배열이 비어 있고 effect 코드(주석 제외)가 latestAuthRuntimeHelpersRef를 쓴다. QA2 login-listener-stability.mjs: 로그인 버튼이 보인 뒤 3초 유휴 창에 제품의 인증 진단 콘솔에 리스너 제거·세션 이벤트가 0건, 심사용 로그인 대화상자가 열린다.
주요 결정과 그 근거
- 인증 effect를 "의존성 0"으로 고정: 어떤 도우미가 어떤 이유로 바뀌어도 리스너는 다시 붙지 않는다. 의존성 목록에 기대는 방식은 같은 종류의 사고를 다른 콜백에서 다시 낸다(원칙 4 구조적 원인 해결).
- host 수정은 runtime이 아니라 host에: runtime의 "비어 있던 상태에서 또 비워도 epoch가 오른다"는 옛 응답 차단 계약이라 유지하고, "묶음은 오너 단위"는 host의 책임이다.
- 단위 테스트 대신 격리 스크립트 + 두 게이트: QA1 퇴역(#1661)으로 앱에 단위 테스트 실행 경로가 없다. host 동작은 레포 밖 스크립트로 7개 단언(수정 전 host는 첫 단언 실패)을 남기고, 자동 경로의 검사는 R7(정적)과 QA2 계약(브라우저)에 뒀다.
작업 중 드러난 것
- 데스크톱 폭 브라우저에서 프리뷰를 열면 부팅 9초쯤 뒤 모바일 셸 → 데스크톱 셸로 앱 루트가 통째로 바뀌며 리스너 제거 2건이 더 찍힌다(무한 반복이 아니라 루트 재마운트). 개발 모드 StrictMode의 마운트-해제-재마운트도 1건을 남긴다. 판정은
?lgProfile=1(StrictMode 없음)로 0건을 확인했다. - R7의 첫 판은 ref 이름이 주석에만 있어도 통과했다 — 주석을 벗기고 검사하도록 고쳤고, 두 부정 사례(의존성 오염·ref 미사용)로 실제 실패를 확인했다.
- QA2
qa:ci는 포트 59320~59323을 독점하므로 이 PC에서 동시에 하나만 돈다. 결함·수정 리비전을 순서대로 돌려 revision 교정을 남겼다.
5. 적용 결과
| 항목 | 결과 |
|---|---|
로그인 안 된 부팅의 session:listener_removed(프리뷰, staging DB) | 18초 18,000+건 → StrictMode 없이 0건(StrictMode 1건) |
조립 루트 재실행(로그인 안 된 부팅, ?lgProfile=1) | 측정 불가(무한) → 4회 |
QA2 browser-login-listener-stability 3초 유휴 창 | 결함 e3789e74 리스너 제거 4,232·세션 이벤트 4,232(실패) → 수정 cee529eb 0·0(통과), 타이머 지연 6ms |
| host 격리 단언 7개 | 수정 전 첫 단언 실패 → 통과 |
npm run check(R1~R7) | 통과 |
| 미검증 | 로그인된 사용자 부팅·로그아웃→재로그인 경로(staging 계정 없음, 로직상 같은 고리 없음) · 네이티브 셸 · staging·Production 배포 뒤 실측(승격 세션) |
6. 이번 개선으로 향상된 것
로그인 안 된 사용자의 첫 화면이 즉시 조작된다
신규·로그아웃 사용자가 앱을 열면 인증 리스너가 한 번 붙고 끝난다. 로그인 버튼·심사용 로그인이 바로 반응한다.
구조적으로 남는 것
인증 리스너는 앱 마운트당 1회라는 계약을 코드 모양(R7, 커밋마다)과 실제 동작(QA2 계약, 승격 ②마다)이 지킨다. 오너 스토어 묶음은 오너가 바뀔 때만 바뀐다.
남은 것
- QA2 PR #12 병합 뒤 앱
qa2.lock.json핀 갱신 PR(같은 이슈 #1680). - 오너 결정 D1: Production 즉시 롤백 여부. staging·Production 반영은 릴리스 승격 세션이
[v0.19.4 스테이징]→[v0.19.4 반영완료]로 진행.