Owner·Auth·Connectivity 수명주기 분리 — 세대 정본 하나·떠나는 순서 계약·인증/연결 runtime 을 controller 밖으로 — v0.18.0 A07 (2026-09-07)
- 기간: 2026-09-07 (세션 2개 —
d161e4b2Phase 0~4·Phase 5 문서 초안, 세션 한도로 중단 →3140ad75이어받아 리베이스·장부·ci:local·PR·머지. 오너 지시 "#1335 진행해주고, 혹시 선행작업 안끝났으면 끝나고 진행해줘" — 선행 A01947413d1·B01ba0f4035반영 확인 뒤 착수). 계획 ID A07 / Phase 2 Step 2-2. 총괄 A07로 돌아가기. - 랜딩: PR #1365 → main
0775e6b2(2026-09-08 00:5x KST squash, CI verify 범위 초록) · staging Deploy run 34137394668 성공(resolve·database·functions·frontend·smoke 전부 초록, release tag 는 Production 전용이라 skip) · 앞수리 PR #13624f2dc691(ci:local) · 이 기록의 마무리는 docs PR(아래 등록 뒤 머지). (Phase 1~4 커밋3fd2e9ba·d508dc6a·7fdcd364·834f0a0e— origin/main632ccab3(S03 #1363 뒤) 위로 리베이스한 해시. 리베이스 전2d1add5d·40ecf2e4·7aa11550·90a2fdd8. Phase 59cb01bd1·0fff8aee·1bbdfc92·da87fa3d+기록 갱신) — 마이그레이션·엣지 함수 없음, 랜딩 큐 불필요(일반 절차). 앱 controller 변경이라 main 머지 = staging 배포, Production 은 v0.18.0 릴리스. 머지 해시·staging run 은 위에 적었다. - 설계서: 이슈 #1335 착수 댓글(연결도·해결 방안·Phase 5개·예상 효과·개선사항 표).
- 정본: Owner·Auth·Connectivity 수명주기 계약 ·
src/react/app/{ownerRuntime,ownerStoreSetHost,authSessionRuntime,connectivityRuntimeHost}.ts· 변환 어댑터controllers/authSessionRevision.ts. - 도구: 없음(레포 안 스크립트 변경 없음). 이동은 세션 scratchpad 의 추출 스크립트(레포 밖)로 자리만 옮겼다.
- 게이트: 새 테스트 4파일 22건(
tests/react/ownerRuntime7·ownerStoreSetHost5·authSessionRuntime5 + 픽스처 경유ownerSwitchFixture·authSessionFailurePolicy행동 전환) · 픽스처tests/support/ownerSwitch.mjs(제품 runtime 으로 세대)·authSessionRuntime.mjs·npm run check(Phase 마다) ·npm run ci:local --full(Phase 5, 검증 줄은 PR 본문) · 명부 신고 5건(pending-changes.json: appContainer·backgroundTokens·controllerBoundaries·logTableMonthCache 이어 적기, authSessionFailurePolicy 새 항목) · 경계 검사 2곳·계층 허용표에src/react/app/**등재. - 버그리포트: 없음(오너 보고 결함이 아니라 계획된 구조 개선).
- 계약: 새 문서
docs/architecture/owner-auth-connectivity-lifecycle.md§1~§8.v0-18-0-layer-dependency-table.md에 앱 runtime 행 추가.types/controllers.tsAuthStateResetter에 reason 인자.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 분석·연결도(이슈 댓글) | ✅ |
| Phase 1 | OwnerRuntime — 세대·취소 신호·자원 등록/폐기·떠나는 순서의 정본 한 곳 | ✅ 3fd2e9ba(구 2d1add5d) |
| Phase 2 | owner 전환 순서 계약 — 세트 교체를 runtime 전환 안으로, owner 취소 신호를 transport 까지 | ✅ d508dc6a(구 40ecf2e4) |
| Phase 3 | AuthSessionRuntime — 인증 부팅·복원·이벤트·재시도를 controller 밖으로, 세 사건에 이름 | ✅ 7fdcd364(구 7aa11550) |
| Phase 4 | ConnectivityRuntime host — 연결 조립을 controller 밖으로, 폐기를 owner 자원으로 | ✅ 834f0a0e(구 90a2fdd8) |
| Phase 5 | 계약 문서·G05 장부·작업 기록·ci:local·PR·CI·머지·staging 확인 | ✅ 9cb01bd1 0fff8aee 1bbdfc92 da87fa3d c4de8de8 → 머지 0775e6b2 · staging run 34137394668 성공 |
1. 배경
v0.18.0 Phase 2 는 "안전한 저장 경로와 첫 수직 통합" 이다. 그중 A07 은 계정(owner)·인증·연결의 수명주기를 feature 조회에서 떼어 내, 다음 스텝 A08(resource cache)이 "이 응답을 지금 화면에 적용해도 되는가" 를 owner 세대 하나로 판정할 수 있게 하는 자리다. A01 이 transport 에 취소 신호 자리를 만들어 두었고("A07 가 owner 떠날 때 abort 할 signal 을 제공"), B01 이 인증 오류 분류와 보존 정책 표를 A07 인계로 남겼으며, S08 이 초안 보존 순서(prepareWorkoutDraftForOwnerChange·보존 꼬리)를 완성해 두었다.
2. 문제 제기
같은 뜻의 번호표 4개가 각자 셌다
유저 A 가 조회 중 로그아웃하고 B 가 로그인하면 "이 응답은 지금 주인 것인가" 를 검사해 버려야 한다. 그 기준값이 authSessionRevision(controller 내부)·ownerScopeController.remoteUserRevisionRef(렌더 중 증가, 14개 파일이 (userId, revision) 쌍을 손으로 들고 다님)·authSessionLifecycleRevision·authBootstrapRevision 네 곳에 있었다. G03 OwnerScope{userId, epoch} 는 이름만 있고 구현이 없었다.
떠나는 순서가 두 곳에 나뉘어 뒤바뀌어 있었다
adoptAuthenticatedIdentity 가 scope 무효화와 ref 리셋 60여 줄을 먼저 돌리고, React 가 B 화면을 그린 뒤 layoutEffect 의 adoptOwnerStores 가 A 의 초안 보존을 돌렸다 — 무효화가 보존보다 먼저였다. 보존의 완료/실패를 기다릴 port 는 S08 의 모듈 꼬리뿐이었다.
자원 정리가 등록/폐기가 아니라 목록이었다
resetRemoteUserData 가 ref 를 하나씩 비우고, 연결 runtime 폐기는 effect cleanup 과 이 함수 두 곳, 부팅 재시도 타이머 취소는 세 함수가 각각 했다. 새 화면을 추가할 때마다 목록에 줄을 더해야 했고, 빠뜨리면 B 화면에 A 데이터가 남는다.
인증·연결 조립이 4,091줄 controller 의 effect 두 개(약 700줄)에 갇혀 있었다
React 없이 검사할 수 없어 테스트 2건이 소스 문자열 앵커로 검사했다. 세 사건(만료·명시 로그아웃·계정 삭제)은 보존 정책은 지켜지고 있었지만 runtime 에 "왜 떠났는가" 라는 이름이 없었다.
3. 해결 방안
원칙 (오너 결정)
이번 트랙에 오너 결정은 없다. 전제: "#1335 진행해줘" 를 착수 승인으로 봤다(이슈 범위는 총괄·HQ 승인 완료, 일상 착수 재승인 불필요). 보존 정책은 B01 계약 §5 표 그대로, 제품 정책 값 변경 0.
접근
| 안 | 내용 | 판정 |
|---|---|---|
| 리셋 목록 보강·검사 위치 추가 | 빠진 ref 를 찾아 목록에 넣고 조회마다 scope 검사 추가 | 기각 — 다음 화면이 또 빠뜨린다(전역 지침 §22) |
| 전역 singleton 하나에 인증·연결·owner 전부 | 한 store 가 모든 상태 소유 | 기각 — 이슈·총괄이 금지(feature 성공/실패를 인증 상태로 합치지 않는다, 전역 mutable singleton 회귀 금지) |
| 작은 runtime 3개 + owner 전환 계약 (채택) | OwnerRuntime(세대·자원·순서), AuthSessionRuntime(인증 증거·세 사건), ConnectivityRuntime host(기존 runtime 을 owner 자원으로). 기존 store 세트·연결 runtime 본체는 그대로 두고 누가 언제 부르는지만 한 곳으로 | 채택 |
4. 적용한 내용
Phase 1 — OwnerRuntime (2d1add5d)
src/react/app/ownerRuntime.ts:OwnerScope{userId, epoch}구현.adopt/leave(reason)/capture/isCurrent/signal/register(preserve|dispose)/subscribe/awaitLeave/inspect. 떠날 때 보존 → 폐기(등록 역순) → epoch+1·abort → 알림. 같은 A 재로그인 = 새 세대.authSessionRevision.ts는 runtime 위의 변환 어댑터(revision = epoch), 제거 조건 명시.ownerScopeController는 렌더 중 카운팅을 버리고 runtime epoch 를 읽는다.appController가 셸 인스턴스당 runtime 하나를 만들어 주입.- 경계 검사 2곳(
frontendImportBoundaries·contractsImportBoundaries)과 계층 허용표에src/react/app/**를 controllers 와 같은 규칙으로 등재.
Phase 2 — owner 전환 순서 계약 (40ecf2e4)
src/react/app/ownerStoreSetHost.ts: store 세트 슬롯을 runtime 에 붙인다 — ①preserveOwnerStores(S08 보존 꼬리를 결과로) ②resetOwnerStores③ epoch·abort ④ 새 세트.appControllerlayoutEffect 는 세트를 읽기만.ownerStores.ts:leaveOwnerStores를 preserve/reset 두 단계로 나눔(종전 진입점·adoptOwnerStores유지).- 셸 hydration·PR 대시보드·볼륨·세션 상세 요청에 owner 취소 신호 연결(리스너는 요청 끝에 해제).
Phase 3 — AuthSessionRuntime (7aa11550)
src/react/app/authSessionRuntime.ts: controller 의 인증 effect 408줄을 자리만 옮겨start/stop/retryBootstrap으로(host = 참조·콜백·API 표면 주입). 상수 4개 정본 이동.- 세 사건: SIGNED_OUT →
transitionToSignedOut("", "signed_out"), 만료 문구 →expired,profileController.deleteAccount가 서버 성공 뒤deleted.AuthStateResetter에 reason. B01 분류기를 API port 로 소비. - 하네스
tests/support/authSessionRuntime.mjs+ 행동 테스트 5건.authSessionFailurePolicy앵커 1건 → 행동 검사.
Phase 4 — ConnectivityRuntime host (90a2fdd8)
src/react/app/connectivityRuntimeHost.ts: 연결 effect 278줄을 자리만 옮겨 owner scope 하나에 묶인 host(runtime + dispose 반복 안전). 본체controllers/connectivityRuntime.ts불변.- controller effect 는 host 를 만들고
ownerRuntime.register(() => host.dispose()). 고아 import 16개 정리.
Phase 5 — 문서·장부·랜딩
- 계약 문서
docs/architecture/owner-auth-connectivity-lifecycle.md(§1 유저 이야기·§2 runtime 3개·§3 세대와 변환 경계·§4 순서·§5 취소 규칙·§6 세 사건·§7 데이터≠인증·§8 A08 인계), G05 장부(auth controller/route·shell controller/binding 행), 이 기록, 사이드바·README 등록. npm run ci:local --full→ PR → CI 1회 → 머지 → staging 확인(결과는 PR 본문·이 문서 §5).
주요 결정과 그 근거
- 세대는 runtime 하나가 세고, 기존
{revision, userId}는 이름 바꾸기 어댑터로 남긴다: 14개 파일을 한 PR 에서 옮기면 A08 과 같은 파일을 동시에 고치게 된다. 숫자·의미가 같으므로 섞어 비교해도 안전하고, 제거 조건("import 0")을 문서에 적어 두 카운터가 독립적으로 자라는 것을 막는다. - 보존을 렌더 전(동기)으로 당긴다: 초안 env 는 이전 세트에 렌더마다 붙어 있으므로 auth 이벤트 시점에 보존해도 최신 스냅샷이다. 보존 실패는 전환을 막지 않는다(마지막 기기 저장물이 복원 기준, S08).
- 첫 로그인('' → A)도 세트를 바꾼다: 종전 지휘자 동작과 같다 — 로그인 전 화면 상태(토스트·내비)가 첫 owner 로 새지 않게.
resetRemoteUserData의disposeConnectivityRuntime()은 남긴다: owner 자원 폐기가 먼저 돌아 no-op 이지만 안전망. 목록 자체는 A08 이 resource cache 로 흡수한다.- "자리만 이동": 인증·연결 effect 는 free variable 을 host 주입으로 바꾼 것 외에 로직을 바꾸지 않았다(기존
any도 그대로, boundary gate 허용 목록에 사유 등재). 행동 변경은 세 사건 reason 과 취소 신호 연결뿐.
작업 중 드러난 것
- 소스 앵커 테스트가 많다: 세트 교체 위치 변경에 3파일, 인증 effect 이동에 5파일, 연결 effect 이동에 2파일이 걸렸다.
sourceSliceAnchors.test.mjs(앵커 레지스트리)가 "어느 앵커가 어느 파일에서 사라졌는가" 를 정확히 알려 줘 재조준이 기계적이었다. 명부 등재분은pending-changes.json기존 항목에 이어 적었다(새 항목은 중복 거부 — B01 교훈). - 부팅 순서기의 재시도 간격(250ms·500ms)은 실제 타이머라 "5xx 는 degraded" 행동 테스트는 실제 시간을 900ms 기다린다. 테스트마다
t.after(() => runtime.stop())이 없으면 재시도 타이머가 프로세스를 붙들어 테스트 러너가 멈춘다(처음 120초 타임아웃으로 발견). - 큰 python 히어독은 Bash 툴에서 따옴표가 깨진다 — 추출·재조준 스크립트는 파일로 쓰고
python file.py. appContainer의 부팅 채택 기록: 기기 흔적 채택 1 + 순서기의 local·verified 채택 2 =adopt3회 뒤adoptVerified— 종전과 같은 횟수(행동 불변).- 사전 검증 누락 1건(세션 1 → 2 인계 뒤 발견): 브라우저 회귀 픽스처
tests/react/authResume.browser.mjs가 controller 를 옛 인자(owner runtime 없이)로 불러window.authResume이 만들어지지 않았다 —ci:localpersistence 묶음 3건 시간 초과. Phase 마다npm run check(단위)만 돌리고 브라우저 묶음은 Phase 5 로 미뤘기 때문에 Phase 1 에서 잡을 수 있었던 것을 Phase 5 에서 잡았다. 조립 루트와 같이createOwnerRuntime()주입으로 수리(c8b19681). 교훈: controller hook 의 필수 인자를 바꾸면grep useRemoteDataController(로 픽스처까지 전수 확인. - main 앞수리 1건: B02(#1356) 뒤
ci:local이 verify 묶음 전체에BARBELIC_TARGET=local을 줘 관리자 셸 빌드까지 local 로 새겨지고check:artifacts가 항상 실패했다(CI 는build단계에만 준다). 별도 PR #13624f2dc691로build단계에만 주게 고쳤다(CI 생략 — 로컬 전용 스크립트, 계약 테스트 27건 로컬 통과). - G05 장부 JSON 을
json.dump로 다시 쓰면 안 된다: 규칙 파일이 펼친 객체·한 줄 객체가 섞인 모양이라 통째로 직렬화하면 2,240줄 diff 가 난다(세션 1 이 그렇게 남김). HEAD 로 되돌린 뒤 문자열 치환으로 행 4개·규칙 3줄만 고치고--render(미분류 0). - Bash 히어독은 백슬래시를 먹는다(정규식
\\.md) — JSON 규칙 한 줄도 Edit 도구로 써야 했다(메모리 bash-heredoc-escape-mangling 재확인). - 포트 4173 경합: 다른 세션의 미리보기가 4173 을 잡고 있어
ci:local --full이 browser 단계에서 코드 3.--only verify,db,local,empty,cardio,persistence로 먼저 돌리고, 같은 빌드를 4179 미리보기(샌드박스E2E_SUPABASE_*를 준 프로세스)에 띄워CI=true E2E_APP_URL로 browser·viewport 를 따로 돌렸다. - CASE-004 자정 걸침 플레이크: browser 묶음이 23:55 KST 에 시작해 자정을 넘겼고 CASE-004(하루 요약 RPC 대기)만 1회 실패 뒤 재시도 통과 — 메모리의 8/31 기록(23:56 시작 → 자정 넘김)과 같은 모양. 자정 뒤(00:10 KST) 같은 미리보기에서 재시도 0 으로 3회 반복 → 3/3 통과(38초). 코드 원인 아님.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 같은 뜻의 세대 카운터 | 4 → 1(OwnerRuntime.epoch; authSessionRevision·remoteUserRevisionRef 는 이 값을 읽는 어댑터). 부팅 비행 번호·인증 이벤트 순번은 세대가 아니라 남김(계약 §3) |
| 떠나는 순서 | 무효화 → (렌더) → 보존 → 새 세트 → 보존 → 폐기 → 무효화 → 새 세트(동기, 렌더 전). 보존 완료/실패 port 0 → 1(awaitLeave) |
| A 의 늦은 성공/실패가 B 화면·cache·toast·초안에 적용 | 행동 테스트 0건 적용(ownerRuntime·ownerStoreSetHost·ownerSwitchFixture), A→A 재로그인 새 세대에서 옛 결과 0건 채택 |
| owner 취소 신호 → transport | 없음(A01 §5 미구현) → 셸 hydration·PR·볼륨·세션 상세 4경로. 나머지 조회는 scope 비교 유지(A08 대상) |
| 연결 runtime 폐기 지점 | effect cleanup + resetRemoteUserData 2 → owner 자원 등록 1(+ 안전망 no-op) |
| 세 사건 | reason 없음 → signed_out/expired/deleted/switched, B01 §5 표와 1:1 |
| 데이터 5xx → 인증 상태 변화 | (검사 없음) → 0건(행동 테스트) |
remoteDataController.ts | 4,091 → 3,503줄(인증 408·연결 278 이동, 고아 import 정리). src/react/app/** 4파일 1,217줄 신설 |
| 소스 앵커 검사 → 행동 검사 | 2 → 1건 전환(authSessionFailurePolicy SIGNED_OUT 경계). 늦은 401 hydration 앵커는 controller 에 남아 유지 |
| 전체 게이트 | 단위 3,070건 통과(Phase 4) · tsc·lint·unused·boundary gate·test-manifest·앵커 레지스트리 통과 · 검증: ci:local full · verify 통과 (8단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 117파일/2036 assert · 동시 저장 세대·영수증 통과 · e2e-local 통과 11/11 · e2e-empty 통과 7/7 · e2e-cardio 통과 6/6 · e2e-persistence 통과 11/11 · 4분 42초 · browser 37/38 통과 + CASE-004 1회 재시도 통과(자정 걸침) · viewport 14/14 |
| 실기기·브라우저 | ci:local --full 의 브라우저 묶음(로그인·계정 전환 여정 포함)까지 자동 검증. 시각·감각적 품질은 사람 눈 몫(정보) |
| main/staging | PR #1365 → main 0775e6b2 · staging Deploy run 34137394668 성공(database·functions·frontend·smoke) · Production 은 v0.18.0 릴리스 대기 |
6. 이번 개선으로 향상된 것
공용 기기에서 계정을 바꿔도 이전 사람의 화면이 새지 않는다
A 의 늦은 응답은 B 의 화면·cache·toast·초안 어디에도 닿지 않고, 같은 A 가 다시 로그인해도 로그아웃 전 결과는 버려진다. 서버 요청 자체도 owner 가 떠나면 끊긴다.
초안 보존이 무효화보다 먼저다
A 가 기록 중 로그아웃해도 마지막 편집이 먼저 기기에 저장되기 시작하고, 곧바로 다시 로그인하면 그 저장이 끝난 뒤 복원한다.
새 화면·새 조회가 시작할 자리가 있다
세대 캡처/비교(capture·isCurrent), owner 종료 신호(signal·subscribe·reason), 자원 등록/해제(register) — 리셋 목록에 줄을 더하는 대신 이 셋을 쓴다. A08 은 이 위에 resource cache 를 올린다.
구조적으로 남는 것
OwnerScope 구현과 변환 어댑터의 제거 조건, 떠나는 순서 계약과 그 테스트, 세 사건 reason, React 없이 도는 인증·연결 runtime 하네스, 계약 문서 §8 의 A08 인계.
남은 것
- A08(#1338):
resetRemoteUserData의 손 리셋 약 50 ref 를ResourceCachePort.invalidateOwner로 흡수, 피드·검색·카탈로그·연도 활동·PR 상세 조회에 취소 신호·OwnerScope키 적용,AuthRequestScope소비자 축소(어댑터 제거 조건). - 릴리스 v0.18.0 뒤
[v0.18.0 반영완료]+ 이슈 닫기.