Repository navigation
refactor: 알람 세션 단일 진실 원천 기반 + 저장소 계층 (Phase A·B1·B2) + 알람 지연 버그 수정 - #367
Merged
Merged
Conversation
경로 캐시(§5)와 알람 세션 단일 레코드(Phase B)의 기반을 먼저 깐다. `KeyValueStore` 프로토콜은 건드리지 않았으므로 기존 호출처 영향은 0이다. ## FileKeyValueStore Application Support 기반, 키 하나가 파일 하나. 쓰기는 `.atomic` — 원자적 교체가 아니면 쓰는 중 앱이 죽었을 때 반쯤 쓰인 JSON이 남아 다음 실행의 디코딩이 깨진다. `UserDefaultsKeyValueStore`와 나누는 기준은 **값의 크기**다. UserDefaults는 첫 접근에 plist 전체를 메모리로 올리므로 큰 값은 앱 시작 비용이 된다. 판단선은 단일 값 4KB(경로 캐시 10건 ≈ 20KB가 이쪽 대상). `Namespace`가 저장 위치와 백업 정책을 함께 정한다 — 둘은 따로 정할 수 있는 값이 아니다. `.cache`는 백업에서 제외해, 다시 받을 수 있는 데이터가 사용자의 백업 용량을 쓰면서 저장 공간이 부족해도 정리되지 않는 조합을 피한다. ## DocumentStore / CollectionStore 둘 다 `actor`다. `KeyValueStore`가 `Data`만 다루므로 부분 갱신을 하려면 호출자가 read-modify-write를 직접 해야 하고, 그게 lost update의 원인이다 — 직전 커밋에서 고친 최근 검색 유실(4건 동시 저장 → 2건 유실)이 정확히 그 사례다. 단일 writer를 타입으로 강제해 재발을 막는다. `DocumentStore`의 캐시는 `Document??`로 **부재도 기억**한다. 없는 문서를 매번 디스크에서 다시 확인하면 프레임마다 읽는 호출자에게 비용이 된다. `CollectionStore`의 동시 삽입 테스트 주석에 "단일 인스턴스로 검증해야 한다"를 명시했다 — actor 격리는 인스턴스 단위라, 매번 새 인스턴스를 만드는 computed property로 검증하면 수정 후에도 실패한다(실제로 그렇게 헛짚었다). ## 하지 않은 것 계획에 "UserDefaults 직접 접근 7곳 → 0곳"으로 적었으나 실측 결과 과장이었다. `ScheduledAlarmRecord`는 이미 `AlarmRecordStoring` 경계가 있고(Core/Alarm 무의존 규칙 때문에 자체 추상화), 두 어댑터는 `userDefaults`를 주입받는 구조이며, `.standard` 하드코딩은 `DevChangeSimulator`·`SceneDelegate`의 `#if DEV` 전용 코드뿐이었다. 두 어댑터를 `KeyValueStore`로 바꾸는 것도 검토했으나 하지 않는다 — 저장값이 Bool 하나씩이라 위 4KB 판단선상 UserDefaults가 맞는 선택이고, 이미 주입 가능해 이득이 없다. 검증: CoreStorage 33 테스트 통과 (기존 5 + 신규 28) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
기존 코드는 건드리지 않는 순수 추가다(테스트 영향 0). 다음 단계에서
`AlarmSyncService`가 이 타입들로 갈아탄다.
## AlarmSession — 권위 기준으로 나눈 단일 진실 원천
`AlarmSessionSnapshot`은 성질이 다른 값들을 한 겹에 담아 "서버의 복제본"처럼
보였다. 실제로는 **권위가 다른 세 덩이**다:
server: AlarmInfo 충돌 시 항상 서버가 이긴다
local: LocalFacts 서버가 절대 주지 않는 값 — 잃으면 복구 불가
lifecycle + syncedAt 로컬만 아는 수명
섞여 있으면 "누가 이기는가"를 필드마다 따로 기억해야 한다.
`fireDate`를 계산 프로퍼티로 만들어 **`firstWalkSeconds`를 읽는 유일한 지점**이
되게 했다. 기존에는 스냅샷·RefreshAlarmUseCase·AlarmSyncService·HomeViewModel
4곳이 각자 도보 초를 들고 `alarmFireDate`를 호출했다. 출처가 하나가 되면
`AlarmTiming`의 "콜사이트가 도보 초 출처를 명시하게 강제한다"는 방어가 타입으로
달성된다.
`acknowledged`·`expired` 2개 Bool → `Lifecycle` enum. 기존에 `(true, true)`
조합이 표현 가능했는데 의미가 정의돼 있지 않았고, 실제로 발생했다
(`finalizeLocalExpiry`가 톰스톤을 쓸 때 `acknowledged`를 보존).
합쳐도 동작이 보존되는 근거: `acknowledged`의 프로덕션 읽기처가 LA 재시작 게이트
(`LastTrainLiveActivityAdapter:170-176`) **한 곳뿐**이고, 거기서 `expired`와 함께
검사해 둘 중 하나라도 참이면 결과가 "재시작 금지"로 같다.
## AlarmSessionReconciler — 만료 판정의 유일한 장소
판정 *함수*는 이미 `AlarmTiming.isSessionExpired` 하나였는데, 그걸 *호출해
결정하는 주체*가 셋이라("1차 방어 / 2차 방어" 주석의 출처) 규칙이 흩어져 있었다.
I/O 없는 순수 함수로 모으니 기존에 네트워크·스토어·LA·노티 스텁을 다 조립해야
검증할 수 있던 조합을 **스텁 0개**로 표 검증한다. 특히 흩어져 있던 규칙들:
- 로컬은 만료인데 서버가 미래 시각 → 서버가 이겨서 되살림
- 톰스톤 + 같은 경로 + 과거 시각 → 메아리로 무시(지운 세션 부활 방지)
- 톰스톤 + 다른 경로 → 새 세션이므로 채택
- **같은 경로면 로컬 사실 보존, 다른 경로면 폐기** — 들고 가면 그 경로의 것이
아닌 도보 초로 알람이 틀린 시각에 울린다. 기존에는 `AlarmSyncService`의 스냅샷
병합과 `RefreshAlarmUseCase`의 routeId 비교가 같은 판단을 따로 하고 있었다
- 서버 응답 없음(오프라인)은 세션을 버리지 않고 로컬 만료만 판정 — 네트워크
실패가 "데이터가 조금 오래됨"에 그쳐야 하고 "세션 소멸"이 되면 안 된다
- 만료 전환 시 `syncedAt` 보존 — 신선도 스탬프가 만료 순간으로 튀면 거짓말이 된다
검증: Domain 113 테스트 통과 (기존 92 + 신규 21)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
기존 코드는 아직 건드리지 않는다(테스트 영향 0). B3에서 `AlarmSyncService`가 이 Store로 갈아탄다. ## 왜 @mainactor 클래스인가 (actor가 아니라) `actor`로 만들면 `current`가 `async`가 되어 홈 렌더 경로마다 `await`가 붙는다. 세션 쓰기는 동기화 트리거 4경로에서만 일어나고 그 경로가 이미 `inFlight` 합류로 직렬화되므로, 메인 액터 격리만으로 단일 writer가 보장된다. 동기 읽기를 지키는 쪽이 이득이 크다. ## bootstrap()을 sync에서 떼어냈다 기존에는 스냅샷 시딩 게이트가 `sync()` 안에 있어서, **오프라인 콜드스타트에서 sync가 실패하면 배너·카드 복원도 되지 않았다.** 앱 시작 직후 디스크에서 먼저 복원하면 네트워크와 무관하게 홈이 즉시 그려진다. 톰스톤도 방출한다 — 홈이 "지난 막차" 카드를 그릴 근거이고, 구독자가 "아직 로드 전"과 "세션 없음"을 구분할 수 있어야 한다. ## 만료는 clear가 아니라 톰스톤 `.expired` outcome을 받으면 지우지 않고 `lifecycle: .ended`로 남긴다. 지워 버리면 다음 실행에서 같은 과거 세션의 refresh가 배너·재부착을 되살린다. `.ignoredStaleEcho`는 방출조차 하지 않는다 — 같은 값을 다시 흘리면 구독자가 불필요하게 다시 렌더한다. ## register()의 호출 시점이 중요하다 로컬 사실(도보 초·표시명·수단)은 서버가 주지 않아 등록 시점이 유일한 기록 기회다. **서버 등록 직후·로컬 스케줄 전에** 불러야 한다 — 스케줄이 실패해 앱이 죽어도 다음 sync가 도보 초를 복원할 수 있다. 그러지 않으면 버퍼만 적용된 시각으로 복구되어 알람이 도보 시간만큼 늦게 울린다(도보 3분이면 3분 늦음). ## 저장 키 새 키 `alarm.session`을 쓴다 — 기존 `alarm.sessionSnapshot`과 스키마가 다르다. V2 미출시이므로 마이그레이션을 두지 않고, 구 키 정리는 B3에서 한다. 검증: AtchaV2 34 테스트 통과 (기존 20 + 신규 14) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 증상
알람 등록에서 서버 등록은 성공했는데 로컬 스케줄(`scheduler.replaceAlarm`)이
실패하면, **다음 복구 시 알람이 도보 시간만큼 늦게 울린다.** 도보 5분 경로면
5분 늦는다.
## 원인
저장 순서가 이랬다:
서버 등록 → 로컬 스케줄 → 스냅샷 저장
↑ throw하면 아래가 실행되지 않는다
도보 초·표시명·수단은 **등록 시점 경로에서만** 얻을 수 있는 값이라 서버가 주지
않는다. 스케줄이 실패해 저장이 건너뛰어지면 서버에는 세션이 있고 로컬에는 도보
초가 없는 상태가 되고, 다음 refresh가 세션을 발견해도 `firstWalkSeconds`가 nil이라
버퍼(180초)만 적용된 시각으로 복구된다.
AlarmKit이 과거 fixed date를 거부하는 등 실제로 throw하는 경로가 있다.
## 수정
스냅샷 저장을 **서버 등록 직후·로컬 스케줄 전**으로 옮긴다. 저장은 throws가
아니라 실패를 흡수하므로 이 순서가 등록을 막지 않는다.
서버 등록 → 스냅샷 저장 → 로컬 스케줄
## 회귀 테스트
`SpyAlarmScheduler`에 실패 주입(`replaceError`)을 추가했다 — 기존 스텁에는
로컬 스케줄 실패를 재현할 수단이 없었다.
새 테스트는 스케줄 실패 시에도 도보 초 300초가 보존되고 `replaceAlarm`이 실제로
시도됐음을 확인한다(저장이 스케줄을 건너뛴 게 아님을 함께 검증).
기존 순서 검증 테스트(`log.events`)는 영향받지 않는다 — 스냅샷 저장은 CallLog에
기록되지 않기 때문이다.
검증: Domain 114 테스트 통과
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.
아키텍처 리팩토링의 1단계입니다. 설계 근거는
mobile-app-architecture결정표를 따라 판단선과 계산으로 정당화했고, 각 결정에 재검토 조건을 달았습니다.커밋 4개가 각각 독립적이고, 뒤 3개는 기존 코드를 건드리지 않는 순수 추가입니다.
🐛 실제 버그 수정: 알람이 도보 시간만큼 늦게 울림 (
d76a3a4)증상. 알람 등록에서 서버 등록은 성공했는데 로컬 스케줄(
scheduler.replaceAlarm)이 실패하면, 다음 복구 시 알람이 도보 시간만큼 늦게 울립니다. 도보 5분 경로면 5분 늦습니다.원인. 저장 순서가 이랬습니다:
도보 초·표시명·수단은 등록 시점 경로에서만 얻을 수 있어 서버가 주지 않습니다. 스케줄 실패로 저장이 건너뛰어지면 서버엔 세션이 있고 로컬엔 도보 초가 없는 상태가 되고, 다음 refresh가 세션을 발견해도
firstWalkSeconds가 nil이라 버퍼(180초)만 적용된 시각으로 복구됩니다.AlarmKit이 과거 fixed date를 거부하는 등 실제로 throw하는 경로가 있습니다.
수정. 저장을 서버 등록 직후·로컬 스케줄 전으로 옮겼습니다. 저장은 throws가 아니라 실패를 흡수하므로 이 순서가 등록을 막지 않습니다.
회귀 테스트.
SpyAlarmScheduler에 실패 주입을 추가했습니다 — 기존 스텁에는 로컬 스케줄 실패를 재현할 수단이 없었습니다.📦 CoreStorage 저장소 3종 (
fc55498)경로 캐시와 알람 세션 단일 레코드의 기반입니다.
KeyValueStore프로토콜은 건드리지 않았으므로 기존 호출처 영향 0.FileKeyValueStore.atomic).Namespace가 저장 위치와 백업 정책을 함께 정합니다 — 따로 정할 수 있는 값이 아닙니다..cache는 백업 제외DocumentStore<T>Document??로 부재까지 기억해 없는 문서를 반복 조회하지 않습니다CollectionStore<T>UserDefaults와의 경계는 값의 크기입니다. UserDefaults는 첫 접근에 plist 전체를 메모리로 올리므로 큰 값이 앱 시작 비용이 됩니다. 판단선은 단일 값 4KB (경로 캐시 10건 ≈ 20KB가 파일 대상).
CollectionStore가 actor인 이유는 직전 PR(#366)에서 고친 최근 검색 유실입니다 — 값 타입 저장소의 read-modify-write가 lost update를 만들었고, 그 재발을 타입으로 막습니다.🎯 AlarmSession + Reconciler (
df638fa)권위 기준으로 나눈 단일 진실 원천
AlarmSessionSnapshot은 성질이 다른 값들을 한 겹에 담아 "서버의 복제본"처럼 보였습니다. 실제로는 권위가 다른 세 덩이입니다:섞여 있으면 "누가 이기는가"를 필드마다 따로 기억해야 합니다.
fireDate를 계산 프로퍼티로 만들어firstWalkSeconds를 읽는 유일한 지점이 됐습니다. 기존엔 스냅샷·RefreshAlarmUseCase·AlarmSyncService·HomeViewModel 4곳이 각자 도보 초를 들고 계산했습니다.acknowledged+expired→Lifecycleenum기존에
(true, true)조합이 표현 가능했는데 의미가 정의돼 있지 않았고, 실제로 발생했습니다 (finalizeLocalExpiry가 톰스톤을 쓸 때acknowledged를 보존).합쳐도 동작이 보존되는 근거를 코드 주석에 남겼습니다:
acknowledged의 프로덕션 읽기처가 LA 재시작 게이트(LastTrainLiveActivityAdapter:170-176) 한 곳뿐이고, 거기서expired와 함께 검사해 둘 중 하나라도 참이면 결과가 "재시작 금지"로 같습니다.만료 판정을 순수 함수 1곳으로
판정 함수는 이미
AlarmTiming.isSessionExpired하나였는데, 그걸 호출해 결정하는 주체가 셋이라("1차 방어 / 2차 방어" 주석의 출처) 규칙이 흩어져 있었습니다.I/O 없는 순수 함수로 모으니 기존엔 네트워크·스토어·LA·노티 스텁을 다 조립해야 검증할 수 있던 조합을 스텁 0개로 표 검증합니다. 흩어져 있던 규칙들:
AlarmSyncService의 스냅샷 병합과RefreshAlarmUseCase의 routeId 비교가 같은 판단을 따로 했습니다syncedAt보존 — 신선도 스탬프가 만료 순간으로 튀면 사용자에게 거짓말이 됩니다🗄 AlarmSessionStore (
febac76)@MainActor클래스인 이유 (actor가 아니라)actor로 만들면current가async가 되어 홈 렌더 경로마다await가 붙습니다. 세션 쓰기는 동기화 트리거 4경로에서만 일어나고 그 경로가 이미inFlight합류로 직렬화되므로, 메인 액터 격리만으로 단일 writer가 보장됩니다.bootstrap()을 sync에서 떼어냈습니다기존엔 스냅샷 시딩 게이트가
sync()안에 있어서, 오프라인 콜드스타트에서 sync가 실패하면 배너·카드 복원도 되지 않았습니다. 앱 시작 직후 디스크에서 먼저 복원하면 네트워크와 무관하게 홈이 즉시 그려집니다.만료는 clear가 아니라 톰스톤
지워 버리면 다음 실행에서 같은 과거 세션의 refresh가 배너·재부착을 되살립니다.
.ignoredStaleEcho는 방출조차 하지 않습니다 — 같은 값을 다시 흘리면 구독자가 불필요하게 다시 렌더합니다.검증
기존 테스트 삭제 0개, 수정 0개 — 뒤 3개 커밋이 순수 추가이기 때문입니다.
다음 단계 (이 PR 범위 밖)
AlarmSessionSnapshot이 10개 파일에서 쓰이고 있어,AlarmSession으로의 전면 교체는 Domain·App·테스트를 한 커밋에 바꿔야 합니다. 그 전환(Phase B3c)에서 얻을 것:AlarmSyncService575줄 → 4타입, 각 프로토콜 1개이후 Phase C(홈 세션 소유 해제) · D(Presenter/Coordinator 분리) · E(화면 상태 규약) · F(UseCase 21→11)가 남아 있습니다.
이 PR에서 하지 않은 것 (계획 정정)
계획에 "UserDefaults 직접 접근 7곳 → 0곳"으로 적었으나 실측 결과 과장이었습니다.
ScheduledAlarmRecord는 이미AlarmRecordStoring경계가 있고(Core/Alarm무의존 규칙 때문에 자체 추상화), 두 어댑터는userDefaults를 주입받는 구조이며,.standard하드코딩은#if DEV코드 2곳뿐이었습니다.두 어댑터를
KeyValueStore로 바꾸는 것도 검토했으나 하지 않았습니다 — 저장값이 Bool 하나씩이라 위 4KB 판단선상 UserDefaults가 맞는 선택이고, 이미 주입 가능해 이득이 없습니다.🤖 Generated with Claude Code