Repository navigation
refactor: 알람 세션 단일 소유 + 트리거·표출 분리 (Phase B3c·C·D) - #368
Merged
Merged
Conversation
`AlarmSessionSnapshot`을 완전히 걷어내고 `AlarmSession`으로 통일한다. 세션을
소유하는 곳이 하나가 되면서 만료 판정이 진짜로 한 곳에 모였다.
## 숫자
AlarmSyncService 575줄 → 449줄
세션 상태 필드 5개 + 딕셔너리 2개 → 1개 + 딕셔너리 1개
만료 판정 주체 3곳 → 1곳(순수 함수)
firstWalkSeconds 참조 4곳 → 1곳(AlarmSession.fireDate)
`sync()`가 네 단계로 정리됐다:
Store.bootstrap() → refresh(current:) → Reconciler.reconcile() → Store.apply()
기존의 "시딩 게이트 → 선행 만료 후보 → refresh → 3분기 서버 우선 판정 → 톰스톤
기록 → 스냅샷 병합 저장"이 한 번의 판정으로 모이면서, 코드가 스스로 붙여둔
"1차 방어 / 2차 방어" 주석이 사라졌다.
남은 `previousServerInfo` 하나는 세션이 아니라 **diff 한 번에만 쓰이는 지역
기억**이라 여기 있는 게 맞다.
## 회귀 1건을 만들었다가 잡았다
만료와 missed("막차가 지나갔어요")를 가르는 경계가 코드 어디에도 적혀 있지
않았다. `expireLocallyIfNeeded`가 "진입 시점 lastInfo만 본다"는 구현 세부가
사실상 그 규칙이었는데, Reconciler로 옮기며 병합된 최신 정보로 판정하도록
바꿔 버렸다.
결과는 **막차를 놓친 순간 알림이 나가지 않는 것** — 빌드도 되고 대부분의
테스트도 통과하는 종류의 회귀다. 기존 통합 테스트가 잡아줬다.
이제 그 규칙이 세 곳에 명시돼 있다: Reconciler 주석 / Reconciler 순수 함수
테스트 / 기존 통합 테스트.
## 스트림 소유권과 행(hang)
`AlarmSyncEvents`를 `AlarmSessionStore`가 직접 구현한다. 다른 타입이 중계하면
끝나지 않는 스트림을 구독하는 Task가 해제되지 않는다.
더 큰 문제는 **등록이 비동기였던 것**이다. `updates()`는 동기 메서드인데
`@MainActor` 상태를 만지려고 `Task`를 썼더니, 등록 전에 일어난 방출을 구독자가
통째로 놓치고 오지 않을 값을 영원히 기다렸다(테스트 25개가 실행조차 되지 않고
47분 행). `Mutex`로 등록과 replay를 동기화했다 — 같은 모듈 `KeychainStore`가
쓰는 패턴이다.
스트림이 둘로 정리됐다:
- `updates()` → AlarmSyncUpdate (홈 계약, **끝난 세션 제외**)
- `sessionUpdates()` → AlarmSession? (App 내부용)
## 테스트
스냅샷 스텁을 걷어내고 **실제 `AlarmSessionStore`**를 쓴다. 스텁으로 뒀다면
이번에 Store로 옮겨온 부트스트랩·톰스톤·replay 규칙이 검증에서 빠졌을 것이다.
`AlarmSessionTests`에 저장 포맷 계약을 고정하는 테스트를 추가했다 — `lifecycle`이
rawValue 문자열로 저장되므로 케이스 이름을 바꾸면 기존 기록을 못 읽는다.
검증: Domain 119 / AtchaData 53 / AtchaV2 36 / Home 66 / CoreStorage 33
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## Phase C — 화면이 세션을 끝내지 않게
이전에는 홈의 배너 타이머가 유예 경과를 감지해 `sessionExpired()`로 세션을
끝냈다. 만료 판정 주체가 하나 더 있던 셈이고, **홈이 떠 있지 않으면 감지되지도
않았다.**
틱과 판정을 세션 소유자(Store → Reconciler)로 옮겼다. 홈은 받은 값을 그리기만
한다. `bannerTickInterval` 주입이 프로젝트 전체에서 사라졌다.
부수적 개선: 도보 초를 스트림이 싣고 온다(`AlarmSyncUpdate.firstWalkSeconds`).
기존에는 홈이 화면에 떠 있는 경로에서 추측했고, **재실행 복원처럼 카드가 없으면
버퍼만 적용된 배너**를 그렸다. 세션이 유일한 출처이므로 이제 정확하다.
### 종료 경로를 구분해야 했다
`.sessionEnded`(서버 운행 종료)와 유예 경과 만료는 의미가 다르다 — 전자는 카드를
그대로 두고, 후자만 "지난 막차"로 바꾼다. 둘 다 같은 채널로 오게 되면서 홈이
출발 시각으로 구분하도록 했다. 지나간 것과 사라진 것은 다르고, 사라진 경로에
"지난 막차"를 붙이면 오정보다.
지난 막차일 때는 등록 버튼도 숨긴다 — 누르면 `tooLate`로 실패할 뿐이다.
## Phase D — 트리거와 표출 분리
AlarmSyncService 575줄 / 프로토콜 3개 → 183줄 / 1개
AlarmChangePresenter 신규 292줄 / 1개
셋이 한 타입에 있던 이유는 "같은 세션을 필요로 해서"였다. Store가 세션을
소유하게 되자 서로를 알 필요가 없어졌다.
`changeBadgeExpiry`와 `reset()`도 Presenter로 옮겼다 — **세션 상태가 아니라 표출
상태**다.
## 타입당 프로토콜 1개
AlarmSyncService 183줄 AlarmSyncRequesting 트리거 4경로 + inFlight
AlarmChangePresenter 292줄 AlarmChangeEvents 판정 → 3채널 표출
AlarmSessionStore 210줄 AlarmSyncEvents + 세션 소유 + 시계 틱
AlarmSessionStoring
AlarmSessionReconciler 105줄 (순수 함수) 만료·서버우선·메아리
## 테스트
삭제 0개. 소유자가 바뀐 2개는 그에 맞춰 재작성했다:
- `bannerTimer_graceElapsed_...` → `sessionEnded_transitionsToPastTrainState`
(틱 감지 → 종료 **이벤트**)
- `bannerTimer_ticksRecomputeMinutes` → `syncUpdate_recomputesBannerMinutes`
(홈 틱 → 스트림 **재방출**)
Store 틱 테스트 3개를 추가했다. 그중 "이미 끝난 세션을 다시 통지하지 않는지"가
중요하다 — 톰스톤을 걸러내지 않으면 매 틱마다 `.sessionEnded`가 방출되어 LA
종료·알람 취소가 반복된다.
검증: Domain 119 / AtchaV2 39 / HomeFeature 66 / Debug 빌드
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
알람 세션 리팩토링의 2단계입니다. #367에서 만든
AlarmSession/Reconciler/Store위에, 실제 사용처를 전부 옮기고 575줄 단일 타입을 분해했습니다.숫자로 본 변화
AlarmSyncServicefirstWalkSeconds읽는 곳AlarmSession.fireDate)HomeViewModel타입당 프로토콜 1개가 됐습니다:
AlarmSyncServiceAlarmSyncRequestingAlarmChangePresenterAlarmChangeEventsAlarmSessionStoreAlarmSyncEvents+AlarmSessionStoringAlarmSessionReconciler셋이 한 타입에 있던 이유는 "같은 세션을 필요로 해서" 였습니다. Store가 세션을 소유하게 되자 서로를 알 필요가 없어졌습니다.
B3c —
AlarmSessionSnapshot제거sync()가 네 단계로 정리됐습니다:기존의 "시딩 게이트 → 선행 만료 후보 → refresh → 3분기 서버 우선 판정 → 톰스톤 기록 → 스냅샷 병합 저장"이 한 번의 판정으로 모이면서, 코드가 스스로 붙여둔 "1차 방어 / 2차 방어" 주석이 사라졌습니다.
만료와
missed("막차가 지나갔어요")를 가르는 경계가 코드 어디에도 적혀 있지 않았습니다.expireLocallyIfNeeded가 "진입 시점lastInfo만 본다"는 구현 세부가 사실상 그 규칙이었는데, Reconciler로 옮기며 병합된 최신 정보로 판정하도록 바꿨습니다.결과는 막차를 놓친 순간 알림이 나가지 않는 것 — 빌드도 되고 대부분의 테스트도 통과하는 종류의 회귀입니다. 기존 통합 테스트가 잡아줬습니다.
이제 그 규칙이 세 곳에 명시돼 있습니다: Reconciler 주석 / Reconciler 순수 함수 테스트 / 기존 통합 테스트.
스트림 소유권과 47분 행(hang)
updates()는 동기 메서드인데@MainActor상태를 만지려고Task를 썼더니, 등록 전에 일어난 방출을 구독자가 통째로 놓치고 오지 않을 값을 영원히 기다렸습니다. 테스트 25개가 실행조차 되지 않고 47분간 멈췄습니다.Mutex로 등록과 replay를 동기화했습니다 — 같은 모듈KeychainStore가 쓰는 패턴입니다. 왜Task면 안 되는지 주석에 남겼습니다.스트림이 둘로 정리됐습니다:
updates()→AlarmSyncUpdate(홈 계약, 끝난 세션 제외)sessionUpdates()→AlarmSession?(App 내부용)Phase C — 화면이 세션을 끝내지 않게
이전에는 홈의 배너 타이머가 유예 경과를 감지해
sessionExpired()로 세션을 끝냈습니다. 만료 판정 주체가 하나 더 있던 셈이고, 홈이 떠 있지 않으면 감지되지도 않았습니다.이제 틱과 판정은 세션 소유자(Store → Reconciler)가 하고, 홈은 받은 값을 그리기만 합니다.
bannerTickInterval주입이 프로젝트 전체에서 사라졌습니다.부수적 개선: 도보 초를 스트림이 싣고 옵니다. 기존엔 홈이 화면에 떠 있는 경로(
selectedRoute)에서 도보 초를 추측했고, 재실행 복원처럼 카드가 없으면 버퍼만 적용된 배너를 그렸습니다. 세션이 유일한 출처이므로 이제 그런 상황에서도 정확합니다.틱 테스트 3개를 추가했는데, 그중 하나가 특히 중요합니다 — 이미 끝난 세션을 다시 통지하지 않는지. 톰스톤을 걸러내지 않으면 매 틱마다
.sessionEnded가 방출되어 LA 종료·알람 취소가 반복됩니다.Phase D — 트리거와 표출 분리
changeBadgeExpiry와reset()도 Presenter로 옮겼습니다 — 세션 상태가 아니라 표출 상태이기 때문입니다.테스트 하네스도 둘로 나눠 조립합니다. 이제 LA 4분기(포그라운드×도달가능) 검증에 refresh 스텁이 필요 없습니다.
검증
테스트 삭제 0개. 소유자가 바뀐 2개는 그에 맞춰 재작성했습니다:
bannerTimer_graceElapsed_transitionsToPastTrainStatesessionEnded_transitionsToPastTrainState— 종료 이벤트로 화면 정리bannerTimer_ticksRecomputeMinutessyncUpdate_recomputesBannerMinutes— 스트림 재방출로 분 재계산AlarmSyncServiceTests는 스냅샷 스텁을 걷어내고 **실제AlarmSessionStore**를 씁니다. 스텁으로 뒀다면 이번에 Store로 옮겨온 부트스트랩·톰스톤·replay 규칙이 검증에서 빠졌을 것입니다.남은 단계
CollectionStore기반, 기반은 #367에서 완료)🤖 Generated with Claude Code