Owner·Auth·Connectivity 수명주기 계약 (이슈 #1335, A07)
한 문장 — 계정(owner)이 바뀌거나 떠날 때 "누가 세대를 세고, 어떤 순서로 정리하고, 늦은 응답을 누가 거르는가"를 runtime 세 개와 계약 하나로 고정한다. 유저 A 에서 시작한 결과는 B 에게도, A 의 새 로그인에도 적용되지 않는다.
- 이슈: #1335 (계획 ID A07, Phase 2-2). 선행 A01 §5(취소 신호)·B01 §5(보존 정책)·§6(오류 분류)·S08 "Lifecycle and Ordering"(초안 보존 순서). 계약 타입 = G03 §8
OwnerScope{userId, epoch}. - 코드:
src/react/app/ownerRuntime.ts(정본) ·app/ownerStoreSetHost.ts·app/authSessionRuntime.ts·app/connectivityRuntimeHost.ts· 변환 어댑터controllers/authSessionRevision.ts· 조립appController.tsx·controllers/remoteDataController.ts. - 검사:
tests/react/ownerRuntime·ownerStoreSetHost·authSessionRuntime·ownerSwitchFixture·ownerStores·authSessionFailurePolicy·authDegradedBootstrap·authConnectivityCoordination·connectivityRuntime. 픽스처tests/support/ownerSwitch.mjs·authSessionRuntime.mjs.
1. 유저 A 의 로그아웃 한 건
A 가 홈에서 PR 대시보드를 불러오는 중에 로그아웃하고, 같은 폰에서 B 가 로그인한다.
- 로그아웃 순간 owner runtime 이 떠나기를 돈다: ① A 세트의 작성 중 초안을 기기에 저장하기 시작하고(S08) ② A 에 등록된 자원(요청 취소 신호·연결 runtime·구독)을 등록 역순으로 정리하고 ③ 세대(epoch)를 올리며 A 세대의 취소 신호를 발화하고 ④ 구독자에게 알린다 — store 세트 host 가 여기서 빈 세트를 만든다. 전부 동기이고 React 렌더보다 먼저다.
- A 의 PR 대시보드 요청은 ③ 에서 transport 까지 끊긴다. 끊기기 전에 이미 서버가 답을 보냈어도, 답을 받는 쪽은 "시작할 때 잡아 둔 scope 가 아직 현재인가"(
isCurrent)를 보고 버린다. - B 가 로그인하면 다시 ①~④ 가 돈다(비어 있던 owner 에서 B 로). B 의 세트는 초기 상태 리터럴이다 — A 의 토스트·초안·조회 결과가 남을 자리가 없다.
- A 가 곧바로 다시 로그인하면 세대가 또 오른다(로그아웃 1, 로그인 1). 로그아웃 전 비행 결과는 A 의 새 세대에도 적용되지 않는다. A 의 초안 복원은 ① 의 저장이 끝난 뒤에 읽는다(
awaitLeave(A), S08 보존 꼬리).
2. runtime 세 개와 책임
| runtime | 파일 | 책임 | 하지 않는 것 |
|---|---|---|---|
| OwnerRuntime | app/ownerRuntime.ts | 세대(epoch)의 유일한 카운터 · 떠나는 순서(보존 → 폐기 → 무효화 → 알림) · owner 별 AbortSignal · 자원 등록/폐기 · awaitLeave | 인증 증거를 모른다. 기기 저장물을 지우지 않는다 |
| AuthSessionRuntime | app/authSessionRuntime.ts | 부팅(기기 세션 흔적 → 로컬 채택 → 서버 확인 → 셸 적재) · SDK 이벤트(SIGNED_IN/SIGNED_OUT/TOKEN_REFRESHED/USER_UPDATED) · degraded 재시도 타이머 · 세 사건에 이유 부여 | feature 조회 성공/실패를 인증 상태로 합치지 않는다. 데이터 5xx 는 degraded(연결)이지 로그아웃이 아니다 |
| ConnectivityRuntime host | app/connectivityRuntimeHost.ts (본체 controllers/connectivityRuntime.ts 불변) | owner scope 하나에 묶어 leader/follower·probe·rehydrate 조립 · offline listener · 폐기 반복 안전 | 인증 상태를 바꾸지 않는다. 로그아웃 정리(snapshot purge)와 분리 |
| store 세트 host | app/ownerStoreSetHost.ts | owner 전환 때 세트 교체를 runtime 순서 안에서(① preserveOwnerStores ② resetOwnerStores ④ 새 세트) | React 를 모른다. 조립 루트는 current 를 읽기만 |
세 runtime 은 앱 셸(appController)이 인스턴스당 하나씩 만들어 주입한다 — 전역 mutable singleton 이 아니다.
3. 세대(epoch)와 변환 경계
- 정본은
OwnerRuntime.capture()의OwnerScope{userId, epoch}. 명령·조회·캐시는 시작할 때 scope 를 잡고, 결과를 적용하기 전에isCurrent(scope)로 비교한다. 같은 사용자라도 epoch 가 다르면 다른 scope 다. - 변환 어댑터
controllers/authSessionRevision.ts: 기존 호출부가 쓰는AuthRequestScope{revision, userId}는revision = epoch인 이름 바꾸기다. 숫자·의미가 같아 두 값을 섞어 비교해도 된다.ownerScopeController의remoteUserRevisionRef도 runtime epoch 를 읽는다(렌더 중 카운팅 삭제). - 제거 조건:
authSessionRevision.ts를 import 하는 파일이 0 이 되면 삭제한다. 그 전까지 새 카운터를 만들지 않는다. 지금 남은 것:remoteDataController·connectivityController(state.scope)·connectivityRuntime·authBootstrapPolicy·screenLoadPolicyController등이AuthRequestScope타입을 쓴다 — A08(resource cache)이OwnerScope로 키를 바꾸면서 줄인다. - 남은 별도 카운터 둘은 세대가 아니다:
authBootstrapRevisionRef(부팅 비행 번호 — 같은 세대 안에서 재시도를 구분)·authSessionLifecycleRevision(인증 이벤트 순번 — PR 대시보드 조회가 이벤트 사이 재진입을 막는 용도). 둘 다 owner 가 바뀌면 함께 오르지만 owner 판정에는 쓰지 않는다.
4. 떠나는 순서 계약
OwnerRuntime.adopt(next) / leave(reason) 안에서 동기로:
| 단계 | 누가 | 무엇 |
|---|---|---|
| ① preserve | register(hook, {phase:"preserve"}) 등록 순서대로 | 이전 세트 초안 보존 시작(prepareWorkoutDraftForOwnerChange). 돌려준 Promise 는 awaitLeave(previousUserId) 에 이어진다. 실패해도 전환은 막지 않는다(마지막으로 끝난 기기 저장물이 복원 기준) |
| ② dispose | register(hook) 등록 역순 | 연결 runtime host 폐기·세트 리셋(resetOwnerStores)·기타 등록 자원. 돌고 나면 자동 해제 |
| ③ 무효화 | runtime | epoch +1, 이전 세대 AbortSignal 발화, 새 signal 발급 |
| ④ 알림 | subscribe 리스너 | store 세트 host 가 새 세트 생성. 조립 루트는 다음 렌더에서 읽기만 |
React 쪽(appController layoutEffect)은 자기 소유 React 상태만 리셋하고 hydrateAppShellSnapshotForAdoptedOwner 를 시작한다. 첫 로그인('' → A)도 세트를 바꾼다(로그인 전 화면 상태가 첫 owner 로 새지 않게).
5. 취소 신호 규칙
OwnerRuntime.signal()은 현재 세대의AbortSignal이다. 셸 hydration(loadAppShellData)·PR 대시보드·볼륨·세션 상세 요청은 이 신호를 transport 까지 전달한다. 리스너는 요청이 끝나면 해제한다(세대 동안 누적 금지).- 취소는 서버 미커밋의 증거가 아니다. 늦은 write receipt 의 서버 확정 여부와 화면 적용 권한은 다른 질문이다: durable 행(기기 대기열)은
userId기준으로 다음 세션까지 남고(isSameOwnerUser), 화면·cache·toast 에 적용할 권한만 epoch 로 제한한다. - 구독 해제·자원 해제·
stop()·dispose()는 몇 번 불러도 안전하다(테스트가 잠근다).
6. 세 사건과 보존 정책 (B01 §5 표를 소비)
| 사건 | 진입 | OwnerLeaveReason | 초안·대기열 | 화면·기기 사본 |
|---|---|---|---|---|
| 명시 로그아웃 | SIGNED_OUT 이벤트 / profileController.runSignOut | signed_out | 보존 | purgeAuthenticatedOwnerData(profileController) |
| 세션 만료 | 종료 오류(isAuthSessionError: 폐기 refresh token 등) | expired (만료 문구와 함께) | 보존 | 셸 전환만 |
| 계정 삭제 | profileController.deleteAccount — 서버 200/410 뒤 | deleted | 삭제(초안·보관함·대기열, controller 가 아니라 profileController 가 성공 확인 뒤) | 정리 |
| 계정 전환 | 다른 사용자 채택 | switched | 보존 | 세트 교체 |
runtime 은 어느 경우에도 기기 저장물을 지우지 않는다. 인증 오류 분류는 B01 services/auth/authErrors.ts 를 API port 로 소비한다 — provider 별 인증 구현을 복제하지 않는다.
7. 데이터 오류 ≠ 인증 오류
- 셸·프로필 서버의 5xx·조회 실패 →
dataStatus/연결 runtime(degraded, 재시도)로. 인증 상태·owner 는 그대로. - 401 →
resolveScopedUnauthorizedSession이 같은 scope 의 세션을 다시 확인한 뒤에만 로그아웃(한 번의 빈 세션 읽기는 토큰 회전 유예 350ms 뒤 재확인). - 행동 테스트:
authSessionRuntime.test.mjs("5xx 는 degraded 이지 로그아웃 아님")·authSessionFailurePolicy.test.mjs·authDegradedBootstrap.test.mjs.
8. 남은 손 리셋과 A08 인계
remoteDataController.resetRemoteUserData는 아직 feature 조회 ref 약 50개를 손으로 비운다(PR 상세·피드·검색·카탈로그·세션 상세 캐시). 연결 runtime 폐기는 owner 자원 등록이 정본이고, 이 함수 안의disposeConnectivityRuntime()은 안전망(이미 비어 있어 no-op)이다. A08 이 이 ref 들을ResourceCachePort.invalidateOwner(owner)로 흡수하면서 목록을 지운다 — 새 화면 조회는 이 목록에 줄을 더하지 말고 owner 자원으로 등록하거나 resource cache 를 쓴다.- A08 이 가져갈 지점:
- scope 캡처/비교:
ownerRuntime.capture()→isCurrent(scope)(또는isSameOwnerScope). 요청 시작과 결과 채택 두 곳. - owner 종료 신호:
ownerRuntime.signal()(transport 취소) ·subscribe(transition)(cache 의invalidateOwner(previous.userId)) ·transition.reason(deleted면 durable 까지, 그 밖은 화면 cache 만). - 자원 등록/해제:
ownerRuntime.register(dispose)→ 해제 함수. 세대가 끝나면 자동으로 돈다. 반복 해제 안전. - 픽스처:
tests/support/ownerSwitch.mjs(harness.runtime·deferred().scope/signal·isStale) 가 제품 runtime 으로 세대를 센다.
- scope 캡처/비교:
- 이 이슈에서 옮기지 않은 것: 피드·검색·카탈로그·연도 활동·PR 상세 조회는 scope 비교만 하고 취소 신호는 받지 않는다(A08 resource 화 대상).
screenLoadPolicyController의(ownerId, ownerRevision)쌍은 어댑터 경유로 동작한다. - A08 반영(2026-09-08, 이슈 #1338):
resources/resourceStore.ts가 위 세 지점(scope 캡처/비교·전환 구독·요청 취소)을 store 하나로 흡수했다 — Owner 범위 조회 캐시 계약. 첫 소비자 = 완료 세션 상세:resetRemoteUserData의 세션 상세 줄 5개가 사라졌고, 남은 ref 목록은 그 문서 §10 표가 화면 담당(A09~A11)별로 든다.