Skip to content

refactor: 알람 세션 단일 소유 + 트리거·표출 분리 (Phase B3c·C·D) - #368

Merged
YuGeonHui merged 2 commits into
env/devfrom
refactor/v2-phase-b3-session
Sep 24, 2026
Merged

YuGeonHui merged 2 commits into
env/devfrom
refactor/v2-phase-b3-session

Conversation

@YuGeonHui

Copy link
Copy Markdown
Collaborator

알람 세션 리팩토링의 2단계입니다. #367에서 만든 AlarmSession/Reconciler/Store 위에, 실제 사용처를 전부 옮기고 575줄 단일 타입을 분해했습니다.


숫자로 본 변화

지표 전 후
AlarmSyncService 575줄 / 프로토콜 3개 183줄 / 프로토콜 1개
세션 복제 지점 8곳 4곳 (서버·AlarmKit 레코드·AlarmKit·ActivityKit — 전부 OS·원격 소유)
세션 상태 필드 5개 + 딕셔너리 2개 0개 (Store가 소유)
만료 판정 주체 3곳 1곳 (순수 함수)
firstWalkSeconds 읽는 곳 4곳 1곳 (AlarmSession.fireDate)
HomeViewModel 665줄 634줄

타입당 프로토콜 1개가 됐습니다:

타입 줄 수 프로토콜 책임
AlarmSyncService 183 AlarmSyncRequesting 트리거 4경로 + inFlight 합류
AlarmChangePresenter 292 AlarmChangeEvents 판정 → LA/노티/인앱 3채널
AlarmSessionStore 210 AlarmSyncEvents + AlarmSessionStoring 세션 소유 + 시계 틱
AlarmSessionReconciler 105 (순수 함수) 만료·서버우선·메아리 판정

셋이 한 타입에 있던 이유는 "같은 세션을 필요로 해서" 였습니다. Store가 세션을 소유하게 되자 서로를 알 필요가 없어졌습니다.


B3c — AlarmSessionSnapshot 제거

sync()가 네 단계로 정리됐습니다:

Store.bootstrap() → refresh(current:) → Reconciler.reconcile() → Store.apply()

기존의 "시딩 게이트 → 선행 만료 후보 → 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 스텁이 필요 없습니다.


검증

모듈 테스트
Domain 119
AtchaV2 39
HomeFeature 66
Debug · Stage · Release 3구성 빌드

테스트 삭제 0개. 소유자가 바뀐 2개는 그에 맞춰 재작성했습니다:

기존 지금
bannerTimer_graceElapsed_transitionsToPastTrainState sessionEnded_transitionsToPastTrainState — 종료 이벤트로 화면 정리
bannerTimer_ticksRecomputeMinutes syncUpdate_recomputesBannerMinutes — 스트림 재방출로 분 재계산

AlarmSyncServiceTests는 스냅샷 스텁을 걷어내고 **실제 AlarmSessionStore**를 씁니다. 스텁으로 뒀다면 이번에 Store로 옮겨온 부트스트랩·톰스톤·replay 규칙이 검증에서 빠졌을 것입니다.


남은 단계

  • Phase E — 화면 상태 규약 (Home State 불법 조합 1,035 → 0)
  • Phase F — UseCase 21개 → 11개
  • 경로 캐시 (CollectionStore 기반, 기반은 #367에서 완료)

🤖 Generated with Claude Code

YuGeonHui and others added 2 commits September 24, 2026 20:59
`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>
@YuGeonHui
YuGeonHui merged commit 41455ae into env/dev Sep 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant