From fc55498cacfa5132c6f03ec9236428540839b88d Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 19:07:40 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20CoreStorage=EC=97=90=20File/Doc?= =?UTF-8?q?ument/Collection=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(Phase=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 경로 캐시(§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 --- .../Storage/Sources/CollectionStore.swift | 65 ++++++++ .../Core/Storage/Sources/DocumentStore.swift | 56 +++++++ .../Storage/Sources/FileKeyValueStore.swift | 97 ++++++++++++ .../Storage/Tests/CollectionStoreTests.swift | 129 ++++++++++++++++ .../Storage/Tests/DocumentStoreTests.swift | 144 ++++++++++++++++++ .../Tests/FileKeyValueStoreTests.swift | 136 +++++++++++++++++ 6 files changed, 627 insertions(+) create mode 100644 Projects/Core/Storage/Sources/CollectionStore.swift create mode 100644 Projects/Core/Storage/Sources/DocumentStore.swift create mode 100644 Projects/Core/Storage/Sources/FileKeyValueStore.swift create mode 100644 Projects/Core/Storage/Tests/CollectionStoreTests.swift create mode 100644 Projects/Core/Storage/Tests/DocumentStoreTests.swift create mode 100644 Projects/Core/Storage/Tests/FileKeyValueStoreTests.swift diff --git a/Projects/Core/Storage/Sources/CollectionStore.swift b/Projects/Core/Storage/Sources/CollectionStore.swift new file mode 100644 index 00000000..88fb060b --- /dev/null +++ b/Projects/Core/Storage/Sources/CollectionStore.swift @@ -0,0 +1,65 @@ +import Foundation + +/// 상한이 있는 최신순 목록 저장소. 최근 검색·경로 캐시처럼 "최근 N건만 남기는" 목록을 +/// 다룬다. +/// +/// `actor`인 이유가 `DocumentStore`보다 더 직접적이다. 목록 갱신은 전부 +/// read-modify-write이고, 값 타입 저장소로 이걸 하면 **동시 저장 시 나중 쓰기가 먼저 +/// 쓰기를 통째로 덮어써 항목이 조용히 사라진다.** `RecentSearchRepositoryImpl`에서 +/// 실제로 발생한 버그이고(단일 인스턴스에 4건 동시 저장 → 2건 유실), 그 수정이 이 +/// 타입의 존재 이유다. +/// +/// 격리가 **인스턴스 단위**임에 주의한다 — 같은 키를 여러 인스턴스가 쓰면 직렬화가 +/// 성립하지 않는다. 조합 루트에서 1회 생성해 공유해야 한다. +public actor CollectionStore { + private let store: any KeyValueStore + private let key: String + private let limit: Int + private var cached: [Element]? + + /// - Parameter limit: 최신순 상한. 초과분은 저장 시 잘린다. + public init(store: any KeyValueStore, key: String, limit: Int) { + self.store = store + self.key = key + self.limit = limit + } + + /// 디코딩 실패는 빈 목록으로 강등한다 — 일회성 캐시라 다음 쓰기가 덮어써 자가 치유한다. + public func all() -> [Element] { + if let cached { return cached } + let value = (try? store.value([Element].self, forKey: key)) ?? [] + cached = value + return value + } + + /// 맨 앞에 넣는다. `isDuplicate`로 기존 항목을 먼저 걷어내므로 재저장이 멱등이다 + /// (같은 항목을 다시 넣으면 순서만 최신으로 올라간다). + public func insert(_ element: Element, isDuplicate: (Element) -> Bool) throws { + var items = all() + items.removeAll(where: isDuplicate) + items.insert(element, at: 0) + try persist(Array(items.prefix(limit))) + } + + public func remove(where shouldRemove: (Element) -> Bool) throws { + var items = all() + items.removeAll(where: shouldRemove) + try persist(items) + } + + public func first(where predicate: (Element) -> Bool) -> Element? { + all().first(where: predicate) + } + + public func clear() throws { + try store.removeValue(forKey: key) + cached = [] + } + + /// 디스크와 캐시를 같은 지점에서 갱신한다 — 한쪽만 바꾸면 살아 있는 인스턴스가 + /// 낡은 값을 답한다. + private func persist(_ items: [Element]) throws { + try store.setValue(items, forKey: key) + cached = items + } +} diff --git a/Projects/Core/Storage/Sources/DocumentStore.swift b/Projects/Core/Storage/Sources/DocumentStore.swift new file mode 100644 index 00000000..dcee7374 --- /dev/null +++ b/Projects/Core/Storage/Sources/DocumentStore.swift @@ -0,0 +1,56 @@ +import Foundation + +/// 단일 Codable 문서를 읽고 쓰는 저장소. "한 개짜리 레코드"를 다루는 곳에서 +/// `KeyValueStore`를 직접 쓰는 대신 이걸 쓴다. +/// +/// `actor`인 이유: `KeyValueStore`가 `Data`만 다루므로 부분 갱신을 하려면 호출자가 +/// read-modify-write를 직접 해야 하고, 그게 lost update의 원인이 된다 +/// (`RecentSearchRepositoryImpl`에서 실제로 발생했다). 단일 writer를 타입으로 강제한다. +/// +/// 메모리 캐시를 함께 두는 이유: 같은 문서를 프레임마다 읽는 호출자가 있어도 디스크 +/// 왕복이 한 번뿐이어야 한다. 캐시와 디스크는 **항상 같은 트랜잭션에서** 갱신한다 — +/// 한쪽만 갱신하면 살아 있는 인스턴스가 낡은 값을 계속 답한다. +public actor DocumentStore { + private let store: any KeyValueStore + private let key: String + /// `.some(nil)` = 부재를 확인함, `.none` = 아직 디스크를 안 읽음. + /// 이 구분이 없으면 "값이 없는 문서"를 매번 디스크에서 다시 확인하게 된다. + private var cached: Document?? + + public init(store: any KeyValueStore, key: String) { + self.store = store + self.key = key + } + + /// 디코딩 실패를 부재로 강등한다 — 스키마가 바뀌었거나 파일이 깨진 경우, + /// 다음 저장이 덮어써 자가 치유하는 것이 이 계층의 규약이다(레거시 실측 정책). + public func load() -> Document? { + if let cached { return cached } + let value = try? store.value(Document.self, forKey: key) + cached = .some(value) + return value + } + + public func save(_ document: Document) throws { + try store.setValue(document, forKey: key) + cached = .some(document) + } + + /// 읽고-고쳐-쓰기를 actor 안에서 한 번에 끝낸다. 호출자가 load→save로 나눠 하면 + /// 그 사이에 다른 쓰기가 끼어들 수 있다. + @discardableResult + public func mutate(_ transform: (Document?) -> Document?) throws -> Document? { + let next = transform(load()) + if let next { + try save(next) + } else { + try clear() + } + return next + } + + public func clear() throws { + try store.removeValue(forKey: key) + cached = .some(nil) + } +} diff --git a/Projects/Core/Storage/Sources/FileKeyValueStore.swift b/Projects/Core/Storage/Sources/FileKeyValueStore.swift new file mode 100644 index 00000000..dd9c3268 --- /dev/null +++ b/Projects/Core/Storage/Sources/FileKeyValueStore.swift @@ -0,0 +1,97 @@ +import Foundation + +/// 파일 기반 `KeyValueStore`. 키 하나가 파일 하나다. +/// +/// `UserDefaultsKeyValueStore`와 나누는 기준은 **값의 크기**다. UserDefaults는 첫 접근에 +/// plist 전체를 메모리로 올리므로 큰 값을 담으면 앱 시작 비용이 된다. 판단선은 단일 값 +/// 4KB — 그 이상은 이쪽을 쓴다(경로 캐시 10건 ≈ 20KB가 여기 해당). +/// +/// 쓰기는 `.atomic`이다. 원자적 교체가 아니면 앱이 쓰는 중에 죽었을 때 반쯤 쓰인 JSON이 +/// 남고, 다음 실행의 디코딩이 실패한다. +// FileManager는 문서화된 thread-safe지만 SDK가 Sendable로 표기하지 않아 @unchecked가 +// 필요하다 — UserDefaultsKeyValueStore와 같은 이유·같은 처리다. +public struct FileKeyValueStore: KeyValueStore, @unchecked Sendable { + /// 저장 위치와 백업 정책을 함께 정한다 — 둘은 따로 정할 수 있는 값이 아니다. + public enum Namespace: Sendable { + /// 다시 만들 수 없는 값(세션 로컬 사실 등). 백업 대상. + case durable(String) + /// 서버에서 다시 받을 수 있는 값. **백업에서 제외**한다 — 사용자의 백업 용량을 + /// 쓰면서 저장 공간이 부족해도 정리되지 않는 최악의 조합을 피한다. + case cache(String) + + var directoryName: String { + switch self { + case let .durable(name), let .cache(name): name + } + } + + var isExcludedFromBackup: Bool { + switch self { + case .durable: false + case .cache: true + } + } + } + + private let namespace: Namespace + private let fileManager: FileManager + + public init(namespace: Namespace, fileManager: FileManager = .default) { + self.namespace = namespace + self.fileManager = fileManager + } + + public func data(forKey key: String) throws -> Data? { + let url = try fileURL(forKey: key) + // 부재는 오류가 아니다(프로토콜 규약: nil = 값 부재). + guard fileManager.fileExists(atPath: url.path) else { return nil } + return try Data(contentsOf: url) + } + + public func set(_ data: Data, forKey key: String) throws { + let url = try fileURL(forKey: key) + try data.write(to: url, options: .atomic) + } + + public func removeValue(forKey key: String) throws { + let url = try fileURL(forKey: key) + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + } + + // MARK: - 경로 + + private func fileURL(forKey key: String) throws -> URL { + try directoryURL().appendingPathComponent("\(sanitized(key)).json") + } + + /// 디렉터리는 접근 시점에 만든다(지연 생성) — init을 throwing으로 만들지 않기 위해서다. + /// 백업 제외 속성은 디렉터리 단위로 한 번만 붙인다. + private func directoryURL() throws -> URL { + let support = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + var directory = support.appendingPathComponent(namespace.directoryName, isDirectory: true) + guard !fileManager.fileExists(atPath: directory.path) else { return directory } + + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + if namespace.isExcludedFromBackup { + var values = URLResourceValues() + values.isExcludedFromBackup = true + // 실패해도 저장 자체는 계속한다 — 백업 제외는 최선 노력 항목이다. + try? directory.setResourceValues(values) + } + return directory + } + + /// 키가 파일명이 되므로 경로 구분자·상위 참조를 막는다. 키는 코드 상수라 충돌은 + /// 실질적으로 없지만, 파일 시스템에 닿는 값을 검증 없이 쓰지 않는다. + private func sanitized(_ key: String) -> String { + key.replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") + .replacingOccurrences(of: "..", with: "_") + } +} diff --git a/Projects/Core/Storage/Tests/CollectionStoreTests.swift b/Projects/Core/Storage/Tests/CollectionStoreTests.swift new file mode 100644 index 00000000..7c309e3d --- /dev/null +++ b/Projects/Core/Storage/Tests/CollectionStoreTests.swift @@ -0,0 +1,129 @@ +@testable import CoreStorage +import Foundation +import Synchronization +import Testing + +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { storage.withLock { $0[key] } } + func set(_ data: Data, forKey key: String) throws { storage.withLock { $0[key] = data } } + func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } } + func plant(_ raw: Data, forKey key: String) { storage.withLock { $0[key] = raw } } +} + +private struct Entry: Codable, Equatable { + var name: String +} + +struct CollectionStoreTests { + private let store = InMemoryKeyValueStore() + private func makeSUT(limit: Int = 10) -> CollectionStore { + CollectionStore(store: store, key: "recents", limit: limit) + } + + @Test + func all_emptyStore_returnsEmpty() async { + #expect(await makeSUT().all().isEmpty) + } + + @Test + func insert_putsNewestFirst() async throws { + let sut = makeSUT() + + try await sut.insert(Entry(name: "A")) { $0.name == "A" } + try await sut.insert(Entry(name: "B")) { $0.name == "B" } + + #expect(await sut.all().map(\.name) == ["B", "A"]) + } + + /// 같은 항목 재저장은 멱등이어야 한다 — 중복이 쌓이지 않고 순서만 최신으로 올라간다. + @Test + func insert_duplicate_movesToFrontWithoutGrowing() async throws { + let sut = makeSUT() + try await sut.insert(Entry(name: "A")) { $0.name == "A" } + try await sut.insert(Entry(name: "B")) { $0.name == "B" } + + try await sut.insert(Entry(name: "A")) { $0.name == "A" } + + #expect(await sut.all().map(\.name) == ["A", "B"]) + } + + @Test + func insert_beyondLimit_dropsOldest() async throws { + let sut = makeSUT(limit: 3) + + for name in ["A", "B", "C", "D"] { + try await sut.insert(Entry(name: name)) { $0.name == name } + } + + #expect(await sut.all().map(\.name) == ["D", "C", "B"]) + } + + /// 이 타입의 존재 이유 — 값 타입 저장소에서 동시 저장 시 나중 쓰기가 먼저 쓰기를 + /// 덮어써 항목이 사라지던 버그(`RecentSearchRepositoryImpl`)를 막는다. + /// **단일 인스턴스로 검증한다** — actor 격리는 인스턴스 단위다. + @Test + func insert_concurrentWrites_keepsAll() async throws { + let sut = makeSUT() + let names = ["A", "B", "C", "D", "E"] + + await withTaskGroup(of: Void.self) { group in + for name in names { + group.addTask { try? await sut.insert(Entry(name: name)) { $0.name == name } } + } + } + + let stored = await sut.all().map(\.name) + #expect(stored.count == names.count) + #expect(Set(stored) == Set(names)) + } + + @Test + func remove_deletesMatching() async throws { + let sut = makeSUT() + try await sut.insert(Entry(name: "A")) { $0.name == "A" } + try await sut.insert(Entry(name: "B")) { $0.name == "B" } + + try await sut.remove { $0.name == "A" } + + #expect(await sut.all().map(\.name) == ["B"]) + } + + @Test + func remove_absentElement_isNoop() async throws { + let sut = makeSUT() + try await sut.insert(Entry(name: "A")) { $0.name == "A" } + + try await sut.remove { $0.name == "없음" } + + #expect(await sut.all().map(\.name) == ["A"]) + } + + @Test + func first_findsMatching() async throws { + let sut = makeSUT() + try await sut.insert(Entry(name: "A")) { $0.name == "A" } + + #expect(await sut.first { $0.name == "A" } == Entry(name: "A")) + #expect(await sut.first { $0.name == "Z" } == nil) + } + + @Test + func clear_emptiesList() async throws { + let sut = makeSUT() + try await sut.insert(Entry(name: "A")) { $0.name == "A" } + + try await sut.clear() + + #expect(await sut.all().isEmpty) + } + + /// 파손 데이터는 빈 목록으로 강등 — 다음 쓰기가 덮어써 자가 치유한다. + @Test + func all_corruptData_returnsEmpty() async { + store.plant(Data("not json".utf8), forKey: "recents") + + #expect(await makeSUT().all().isEmpty) + } +} diff --git a/Projects/Core/Storage/Tests/DocumentStoreTests.swift b/Projects/Core/Storage/Tests/DocumentStoreTests.swift new file mode 100644 index 00000000..76534529 --- /dev/null +++ b/Projects/Core/Storage/Tests/DocumentStoreTests.swift @@ -0,0 +1,144 @@ +@testable import CoreStorage +import Foundation +import Synchronization +import Testing + +private final class CountingKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + private let reads = Mutex(0) + private let failOnSet: Bool + + init(failOnSet: Bool = false) { + self.failOnSet = failOnSet + } + + var readCount: Int { reads.withLock { $0 } } + + func data(forKey key: String) throws -> Data? { + reads.withLock { $0 += 1 } + return storage.withLock { $0[key] } + } + + func set(_ data: Data, forKey key: String) throws { + if failOnSet { throw StoreError.rejected } + storage.withLock { $0[key] = data } + } + + func removeValue(forKey key: String) throws { + storage.withLock { $0[key] = nil } + } + + /// 스키마 불일치·파손 재현용 — 프로토콜 밖에서 직접 심는다. + func plant(_ raw: Data, forKey key: String) { + storage.withLock { $0[key] = raw } + } +} + +private enum StoreError: Error { case rejected } + +private struct Session: Codable, Equatable { + var routeID: String + var walkSeconds: Int +} + +struct DocumentStoreTests { + private let store = CountingKeyValueStore() + private func makeSUT() -> DocumentStore { + DocumentStore(store: store, key: "session") + } + + @Test + func load_emptyStore_returnsNil() async { + #expect(await makeSUT().load() == nil) + } + + @Test + func save_thenLoad_roundTrips() async throws { + let sut = makeSUT() + let session = Session(routeID: "R1", walkSeconds: 300) + + try await sut.save(session) + + #expect(await sut.load() == session) + } + + /// 캐시가 부재도 기억해야 한다 — 없는 문서를 매번 디스크에서 다시 확인하면 + /// 프레임마다 읽는 호출자에게 비용이 된다. + @Test + func load_absentDocument_readsDiskOnce() async { + let sut = makeSUT() + + _ = await sut.load() + _ = await sut.load() + _ = await sut.load() + + #expect(store.readCount == 1) + } + + @Test + func save_thenLoad_doesNotTouchDisk() async throws { + let sut = makeSUT() + try await sut.save(Session(routeID: "R1", walkSeconds: 300)) + let before = store.readCount + + _ = await sut.load() + + #expect(store.readCount == before) + } + + /// 스키마가 바뀌었거나 파일이 깨지면 부재로 강등한다(자가 치유 규약). + @Test + func load_corruptData_returnsNil() async { + store.plant(Data("not json".utf8), forKey: "session") + + #expect(await makeSUT().load() == nil) + } + + @Test + func clear_removesDocument() async throws { + let sut = makeSUT() + try await sut.save(Session(routeID: "R1", walkSeconds: 300)) + + try await sut.clear() + + #expect(await sut.load() == nil) + } + + /// mutate는 읽고-고쳐-쓰기를 actor 안에서 한 번에 끝낸다. + @Test + func mutate_appliesTransformAndPersists() async throws { + let sut = makeSUT() + try await sut.save(Session(routeID: "R1", walkSeconds: 300)) + + try await sut.mutate { current in + guard var next = current else { return nil } + next.walkSeconds = 600 + return next + } + + #expect(await sut.load() == Session(routeID: "R1", walkSeconds: 600)) + } + + @Test + func mutate_returningNil_clearsDocument() async throws { + let sut = makeSUT() + try await sut.save(Session(routeID: "R1", walkSeconds: 300)) + + try await sut.mutate { _ in nil } + + #expect(await sut.load() == nil) + } + + /// 저장 실패 시 캐시가 갱신되면 살아 있는 인스턴스가 디스크에 없는 값을 답한다. + @Test + func save_storeFailure_propagatesAndKeepsCacheConsistent() async { + let failing = CountingKeyValueStore(failOnSet: true) + let sut = DocumentStore(store: failing, key: "session") + + await #expect(throws: StoreError.self) { + try await sut.save(Session(routeID: "R1", walkSeconds: 300)) + } + + #expect(await sut.load() == nil) + } +} diff --git a/Projects/Core/Storage/Tests/FileKeyValueStoreTests.swift b/Projects/Core/Storage/Tests/FileKeyValueStoreTests.swift new file mode 100644 index 00000000..16594f93 --- /dev/null +++ b/Projects/Core/Storage/Tests/FileKeyValueStoreTests.swift @@ -0,0 +1,136 @@ +@testable import CoreStorage +import Foundation +import Testing + +private struct Payload: Codable, Equatable { + var id: Int + var text: String +} + +/// 실제 파일 시스템을 쓴다 — 원자적 쓰기·백업 제외는 모킹하면 검증 의미가 없다. +/// 테스트마다 고유 네임스페이스를 쓰고 끝나면 지운다(swift-testing은 기본 병렬 실행). +struct FileKeyValueStoreTests { + private let namespace: String + private let sut: FileKeyValueStore + + init() { + namespace = "FileKeyValueStoreTests-\(UUID().uuidString)" + sut = FileKeyValueStore(namespace: .durable(namespace)) + } + + private func cleanUp() { + guard let support = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) else { return } + try? FileManager.default.removeItem( + at: support.appendingPathComponent(namespace, isDirectory: true) + ) + } + + @Test + func data_absentKey_returnsNil() throws { + defer { cleanUp() } + #expect(try sut.data(forKey: "missing") == nil) + } + + @Test + func set_thenData_roundTrips() throws { + defer { cleanUp() } + let raw = Data("hello".utf8) + + try sut.set(raw, forKey: "greeting") + + #expect(try sut.data(forKey: "greeting") == raw) + } + + @Test + func codableRoundTrip_viaKeyValueStoreExtension() throws { + defer { cleanUp() } + let payload = Payload(id: 7, text: "막차") + + try sut.setValue(payload, forKey: "payload") + + #expect(try sut.value(Payload.self, forKey: "payload") == payload) + } + + @Test + func set_overwritesExistingValue() throws { + defer { cleanUp() } + try sut.set(Data("first".utf8), forKey: "k") + + try sut.set(Data("second".utf8), forKey: "k") + + #expect(try sut.data(forKey: "k") == Data("second".utf8)) + } + + @Test + func removeValue_deletesFile() throws { + defer { cleanUp() } + try sut.set(Data("x".utf8), forKey: "k") + + try sut.removeValue(forKey: "k") + + #expect(try sut.data(forKey: "k") == nil) + } + + /// 부재 키 삭제는 오류가 아니다 — 멱등이어야 정리 경로가 단순해진다. + @Test + func removeValue_absentKey_isNoop() throws { + defer { cleanUp() } + try sut.removeValue(forKey: "missing") + } + + /// 키가 파일명이 되므로 경로 구분자가 디렉터리를 만들거나 상위로 탈출하면 안 된다. + @Test + func keysWithPathSeparators_stayInsideNamespace() throws { + defer { cleanUp() } + try sut.set(Data("a".utf8), forKey: "alarm/session") + try sut.set(Data("b".utf8), forKey: "../escape") + + #expect(try sut.data(forKey: "alarm/session") == Data("a".utf8)) + #expect(try sut.data(forKey: "../escape") == Data("b".utf8)) + } + + /// 캐시 네임스페이스는 백업에서 제외된다 — 다시 받을 수 있는 데이터가 사용자의 + /// 백업 용량을 쓰면서 저장 공간이 부족해도 정리되지 않는 조합을 피한다. + @Test + func cacheNamespace_isExcludedFromBackup() throws { + let cacheNamespace = "FileKeyValueStoreTests-cache-\(UUID().uuidString)" + let cache = FileKeyValueStore(namespace: .cache(cacheNamespace)) + let support = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let directory = support.appendingPathComponent(cacheNamespace, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + try cache.set(Data("x".utf8), forKey: "k") + + let excluded = try directory.resourceValues(forKeys: [.isExcludedFromBackupKey]) + .isExcludedFromBackup + #expect(excluded == true) + } + + @Test + func durableNamespace_isNotExcludedFromBackup() throws { + defer { cleanUp() } + let support = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let directory = support.appendingPathComponent(namespace, isDirectory: true) + + try sut.set(Data("x".utf8), forKey: "k") + + let excluded = try directory.resourceValues(forKeys: [.isExcludedFromBackupKey]) + .isExcludedFromBackup + #expect(excluded != true) + } +} From df638fab3bad9989fd43b9542d4769ffaf356e34 Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 19:21:49 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20AlarmSession=20+=20Reconciler?= =?UTF-8?q?=20=EC=8B=A0=EC=84=A4=20=E2=80=94=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20=EC=88=9C=EC=88=98=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=201=EA=B3=B3=EC=9C=BC=EB=A1=9C=20(Phase=20B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 코드는 건드리지 않는 순수 추가다(테스트 영향 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 --- .../Sources/AlarmSessionReconciler.swift | 93 +++++ .../Sources/Entities/AlarmSession.swift | 140 ++++++++ .../Tests/AlarmSessionReconcilerTests.swift | 334 ++++++++++++++++++ 3 files changed, 567 insertions(+) create mode 100644 Projects/Domain/Sources/AlarmSessionReconciler.swift create mode 100644 Projects/Domain/Sources/Entities/AlarmSession.swift create mode 100644 Projects/Domain/Tests/AlarmSessionReconcilerTests.swift diff --git a/Projects/Domain/Sources/AlarmSessionReconciler.swift b/Projects/Domain/Sources/AlarmSessionReconciler.swift new file mode 100644 index 00000000..45faa86b --- /dev/null +++ b/Projects/Domain/Sources/AlarmSessionReconciler.swift @@ -0,0 +1,93 @@ +import Foundation + +/// 서버 refresh 결과와 현재 세션을 대조해 **다음 세션 상태를 하나 정한다.** +/// +/// 존재 이유는 만료 판정이 세 주체에 흩어져 있었다는 것이다 — 홈 ViewModel의 미래시각 +/// 가드, 동기화 서비스의 로컬 만료 확정, 스냅샷 톰스톤. 판정 *함수*는 이미 +/// `AlarmTiming.isSessionExpired` 하나였는데 그걸 *호출해 결정하는 주체*가 셋이라 +/// "1차 방어 / 2차 방어" 같은 주석이 붙었다. +/// +/// I/O가 없는 순수 함수라, 이전에는 네트워크·스토어·LA·노티 스텁을 다 조립해야 +/// 검증할 수 있던 조합을 표 하나로 확인할 수 있다. +public enum AlarmSessionReconciler { + public enum Outcome: Sendable, Equatable { + /// 세션을 이 값으로 갱신한다. + case refreshed(AlarmSession) + /// 세션이 죽었다 — 톰스톤으로 남긴다(지우지 않는다). + case expired(AlarmSession) + /// 이미 끝난 세션의 메아리 — 아무것도 하지 않는다. + /// 지운 세션을 refresh가 되살리는 것을 막는 지점이다. + case ignoredStaleEcho + /// 서버가 세션 없음을 알렸다 — 로컬 기록까지 정리한다. + case ended + } + + /// - Parameters: + /// - current: 로컬이 아는 세션(없으면 nil). + /// - server: refresh 결과. 실패·오프라인이면 nil을 넘긴다 — + /// 그때도 로컬 만료 판정은 돌아야 한다. + /// - now: 주입된 현재 시각(실 `Date()` 의존 금지 규약). + public static func reconcile( + current: AlarmSession?, + server: AlarmInfo?, + now: Date + ) -> Outcome { + switch (current, server) { + case (nil, nil): + return .ended + + // 로컬에 기록이 없고 서버에 세션이 있다 — 재실행 후 발견이거나 첫 sync다. + // 로컬 사실을 모르므로 `.empty`로 시작하고, 다음 등록이 채운다. + case let (nil, .some(info)): + let discovered = AlarmSession(server: info, local: .empty, syncedAt: now) + return expiryOutcome(for: discovered, now: now) ?? .refreshed(discovered) + + // 서버 응답이 없다(실패·오프라인). 세션을 버리지 않고 **로컬 만료만** 판정한다 — + // 네트워크 실패가 "데이터가 조금 오래됨"에 그쳐야 하고 "세션 소멸"이 되면 안 된다. + case let (.some(session), nil): + if session.isEnded { return .ignoredStaleEcho } + return expiryOutcome(for: session, now: now) ?? .refreshed(session) + + case let (.some(session), .some(info)): + return reconcile(session: session, server: info, now: now) + } + } + + // MARK: - + + private static func reconcile( + session: AlarmSession, + server info: AlarmInfo, + now: Date + ) -> Outcome { + let merged = session.merging(server: info, syncedAt: now) + + // 톰스톤 처리. 같은 경로가 미래 출발 시각을 들고 오면 **서버가 이기므로** + // 만료를 해제하고 되살린다(서버 우선 규약). 과거 시각이면 메아리로 무시한다. + if session.isEnded { + guard info.lastRouteId != session.server.lastRouteId else { + return merged.hasPassedDeparture(now: now) + ? .ignoredStaleEcho + : .refreshed(merged.with(lifecycle: .active)) + } + // 다른 경로 = 새 세션이다. 톰스톤과 무관하게 채택한다. + let fresh = AlarmSession(server: info, local: .empty, syncedAt: now) + return expiryOutcome(for: fresh, now: now) ?? .refreshed(fresh) + } + + // 서버가 출발 시각을 안 줬다 — 세션이 서버에서 사라진 것으로 본다. + guard info.departureTime != nil else { return .ended } + + return expiryOutcome(for: merged, now: now) ?? .refreshed(merged) + } + + /// 만료 판정이 일어나는 **유일한 지점**. 만료면 톰스톤 세션을 담은 outcome을, + /// 아니면 nil을 돌려준다(호출자가 정상 경로를 계속한다). + /// + /// `syncedAt`은 보존한다 — 만료 전환이 "마지막으로 서버에 확인한 시각"을 바꾸지는 + /// 않기 때문이다. 신선도 스탬프가 만료 순간으로 튀면 사용자에게 거짓말이 된다. + private static func expiryOutcome(for session: AlarmSession, now: Date) -> Outcome? { + guard session.hasPassedDeparture(now: now) else { return nil } + return .expired(session.with(lifecycle: .ended)) + } +} diff --git a/Projects/Domain/Sources/Entities/AlarmSession.swift b/Projects/Domain/Sources/Entities/AlarmSession.swift new file mode 100644 index 00000000..c02f126c --- /dev/null +++ b/Projects/Domain/Sources/Entities/AlarmSession.swift @@ -0,0 +1,140 @@ +import Foundation + +/// 알람 세션의 **단일 진실 원천**. 앱 안에 한 개만 존재한다. +/// +/// `AlarmSessionSnapshot`이 서버 사실과 로컬 사실을 한 겹에 담아 "서버의 복제본"처럼 +/// 보였던 것을, 성질이 다른 세 덩이로 나눈다. 이 분리가 중요한 이유는 각각의 **권위가 +/// 다르기** 때문이다 — 충돌 시 서버가 이기는 값, 서버가 절대 주지 않는 값, 로컬만 아는 +/// 수명이 섞여 있으면 "누가 이기는가"를 필드마다 따로 기억해야 한다. +public struct AlarmSession: Sendable, Equatable, Codable { + /// 시각의 정본. refresh 결과와 충돌하면 **항상 이쪽이 이긴다**. + public let server: AlarmInfo + /// 서버가 주지 않는 값 — 로컬이 유일 원천이라 잃으면 복구할 수 없다. + public let local: LocalFacts + public let lifecycle: Lifecycle + /// 마지막으로 서버에 확인된 시각. 신선도 스탬프("HH:mm 확인 기준")의 출처이고, + /// 오프라인·재실행에서도 낡은 시각을 정직하게 보여주는 근거다. + public let syncedAt: Date? + + /// refresh 응답에 없는 값들. `firstWalkSeconds`가 특히 중요하다 — 알람 발화 시각 + /// 계산에 필수인데 **등록 시점 경로에서만** 얻을 수 있다. + public struct LocalFacts: Sendable, Equatable, Codable { + /// 등록 시점 경로의 첫 도보 구간(초). + public let firstWalkSeconds: Int? + /// LA·카드 복원용 표시명 (예: "6411번 버스"). + public let routeDisplayName: String + /// LA 재시작 시 아이콘 분기용. + public let transportMode: TransportMode? + + public init( + firstWalkSeconds: Int?, + routeDisplayName: String, + transportMode: TransportMode? + ) { + self.firstWalkSeconds = firstWalkSeconds + self.routeDisplayName = routeDisplayName + self.transportMode = transportMode + } + } + + /// 세션 수명. 이전에는 `acknowledged`·`expired` 2개 Bool이었고 `(true, true)` 조합이 + /// 표현 가능했는데 그 의미가 정의돼 있지 않았다. + /// + /// 하나로 합쳐도 동작이 보존되는 근거: `acknowledged`를 읽는 프로덕션 코드는 LA + /// 재시작 게이트 한 곳뿐이고, 거기서 `expired`와 **함께** 검사해 둘 중 하나라도 + /// 참이면 결과가 "재시작 금지"로 같다. 따라서 `acknowledged → ended` 전이에서 + /// 잃는 정보가 없다. + public enum Lifecycle: String, Sendable, Equatable, Codable { + /// 살아 있는 세션. LA 재시작 대상. + case active + /// stopIntent("확인") 수신 — departed 소멸 예약이 이미 잡혀 있어 재시작 금지. + case acknowledged + /// 죽은 세션 톰스톤. **지우지 않고 남기는 이유**: 지워 버리면 다음 실행에서 + /// 같은 과거 세션의 refresh가 배너·재부착을 되살린다(2차 방어의 영속화). + case ended + } + + public init( + server: AlarmInfo, + local: LocalFacts, + lifecycle: Lifecycle = .active, + syncedAt: Date? = nil + ) { + self.server = server + self.local = local + self.lifecycle = lifecycle + self.syncedAt = syncedAt + } + + // MARK: - 파생 (저장 금지) + + /// 알람 발화 시각. **`firstWalkSeconds`를 읽는 유일한 지점**이다. + /// + /// 이전에는 스냅샷 → RefreshAlarmUseCase → AlarmSyncService → HomeViewModel 4곳이 + /// 각자 도보 초를 들고 `alarmFireDate`를 호출했다. 출처가 하나가 되면 + /// `AlarmTiming.alarmFireDate`의 "콜사이트가 도보 초 출처를 명시하게 강제한다"는 + /// 방어 장치가 타입으로 달성된다. + public var fireDate: Date? { + guard let departure = server.departureTime else { return nil } + return AlarmTiming.alarmFireDate( + departureTime: departure, + firstWalkSeconds: local.firstWalkSeconds + ) + } + + /// LA 재시작 대상인가. `active`만 해당한다. + public var allowsActivityRestart: Bool { lifecycle == .active } + + public var isEnded: Bool { lifecycle == .ended } + + /// 출발 시각 + 유예를 지났는가. 판정 자체는 `AlarmTiming`의 순수 함수가 한다 — + /// 여기서 다시 계산하지 않는다. + public func hasPassedDeparture(now: Date) -> Bool { + guard let departure = server.departureTime else { return false } + return AlarmTiming.isSessionExpired(departureTime: departure, now: now) + } + + // MARK: - 전이 + + /// 서버 사실만 갈아끼운다. **같은 경로일 때만 로컬 사실을 보존**한다 — 다른 경로면 + /// 도보 초·표시명이 그 경로의 것이 아니므로 들고 가면 알람이 틀린 시각에 울린다. + /// + /// 이 규칙은 이전에 두 곳(`AlarmSyncService`의 스냅샷 병합, `RefreshAlarmUseCase`의 + /// routeId 비교)이 각자 판단하고 있었다. + public func merging(server newServer: AlarmInfo, syncedAt: Date?) -> AlarmSession { + let sameRoute = newServer.lastRouteId == server.lastRouteId + return AlarmSession( + server: newServer, + local: sameRoute ? local : .empty, + lifecycle: lifecycle, + syncedAt: syncedAt ?? self.syncedAt + ) + } + + public func with(lifecycle newLifecycle: Lifecycle) -> AlarmSession { + AlarmSession( + server: server, + local: local, + lifecycle: newLifecycle, + syncedAt: syncedAt + ) + } + + public func with(syncedAt newSyncedAt: Date?) -> AlarmSession { + AlarmSession( + server: server, + local: local, + lifecycle: lifecycle, + syncedAt: newSyncedAt ?? syncedAt + ) + } +} + +public extension AlarmSession.LocalFacts { + /// 로컬 사실을 모르는 세션 — 서버 refresh로만 발견한 경우(재실행 후 등록 기록이 + /// 없거나 다른 경로로 바뀐 경우). 도보 초가 없으면 알람은 버퍼만 적용된 시각에 + /// 울리므로, 다음 등록이 이 값을 채워야 한다. + static var empty: Self { + .init(firstWalkSeconds: nil, routeDisplayName: "", transportMode: nil) + } +} diff --git a/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift b/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift new file mode 100644 index 00000000..50b7a157 --- /dev/null +++ b/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift @@ -0,0 +1,334 @@ +@testable import Domain +import Foundation +import Testing + +/// 이전에는 만료 판정이 세 주체(홈 VM 가드 / 동기화 서비스 / 스냅샷 톰스톤)에 흩어져 +/// 있어서, 이 조합들을 검증하려면 네트워크·스토어·LA·노티 스텁을 다 조립해야 했다. +/// 순수 함수가 되면 **스텁 0개**로 같은 판정을 표로 확인할 수 있다. +struct AlarmSessionReconcilerTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func info( + route: String = "R1", + departureOffset: TimeInterval?, + isReal: Bool = true + ) -> AlarmInfo { + AlarmInfo( + lastRouteId: route, + departureTime: departureOffset.map { now.addingTimeInterval($0) }, + updatedAt: now, + isReal: isReal + ) + } + + private func session( + route: String = "R1", + departureOffset: TimeInterval?, + walkSeconds: Int? = 300, + lifecycle: AlarmSession.Lifecycle = .active, + syncedAt: Date? = nil + ) -> AlarmSession { + AlarmSession( + server: info(route: route, departureOffset: departureOffset), + local: .init( + firstWalkSeconds: walkSeconds, + routeDisplayName: "6411번 버스", + transportMode: .bus + ), + lifecycle: lifecycle, + syncedAt: syncedAt + ) + } + + // MARK: - 발견 / 정상 갱신 + + @Test + func noCurrent_futureDeparture_refreshes() { + let outcome = AlarmSessionReconciler.reconcile( + current: nil, + server: info(departureOffset: 600), + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + #expect(session.server.lastRouteId == "R1") + // 로컬 사실을 모르는 상태로 시작한다 — 다음 등록이 채운다. + #expect(session.local.firstWalkSeconds == nil) + #expect(session.syncedAt == now) + } + + /// 재실행 직후 발견한 세션이 이미 지난 막차면 곧장 톰스톤이어야 한다 — + /// 되살아나 배너를 그리면 안 된다. + @Test + func noCurrent_pastDeparture_expiresImmediately() { + let outcome = AlarmSessionReconciler.reconcile( + current: nil, + server: info(departureOffset: -120), + now: now + ) + + guard case let .expired(session) = outcome else { + Issue.record("expired 기대, 실제 \(outcome)"); return + } + #expect(session.lifecycle == .ended) + } + + @Test + func noCurrentNoServer_isEnded() { + #expect( + AlarmSessionReconciler.reconcile(current: nil, server: nil, now: now) == .ended + ) + } + + // MARK: - 서버 우선 + + /// 로컬은 만료라고 보지만 서버가 미래 출발을 주면 **서버가 이긴다**. + @Test + func pastDeparture_serverGivesFuture_refreshesNotExpires() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -120), + server: info(departureOffset: 900), + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + #expect(session.lifecycle == .active) + } + + @Test + func pastDeparture_serverConfirmsSamePast_expires() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -120), + server: info(departureOffset: -120), + now: now + ) + + guard case .expired = outcome else { + Issue.record("expired 기대, 실제 \(outcome)"); return + } + } + + /// 톰스톤이어도 같은 경로가 미래 시각을 들고 오면 되살린다(서버 우선). + @Test + func endedSession_serverGivesFuture_revives() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -600, lifecycle: .ended), + server: info(departureOffset: 900), + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + #expect(session.lifecycle == .active) + } + + /// 톰스톤 + 같은 경로 + 과거 시각 = 메아리. 지운 세션이 되살아나면 안 된다. + @Test + func endedSession_serverEchoesSamePast_isIgnored() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -600, lifecycle: .ended), + server: info(departureOffset: -600), + now: now + ) + + #expect(outcome == .ignoredStaleEcho) + } + + /// 톰스톤이어도 **다른 경로**는 새 세션이므로 채택한다. + @Test + func endedSession_differentRoute_acceptsAsNewSession() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(route: "R1", departureOffset: -600, lifecycle: .ended), + server: info(route: "R2", departureOffset: 900), + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + #expect(session.server.lastRouteId == "R2") + #expect(session.lifecycle == .active) + } + + // MARK: - 로컬 사실 보존 규칙 + + /// 같은 경로면 도보 초·표시명을 보존한다 — 서버가 주지 않는 값이라 잃으면 복구 불가다. + @Test + func sameRoute_preservesLocalFacts() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(route: "R1", departureOffset: 600, walkSeconds: 300), + server: info(route: "R1", departureOffset: 900), + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + #expect(session.local.firstWalkSeconds == 300) + #expect(session.local.routeDisplayName == "6411번 버스") + } + + /// 다른 경로면 **버려야** 한다. 들고 가면 그 경로의 것이 아닌 도보 초로 + /// 알람이 틀린 시각에 울린다. + @Test + func differentRoute_dropsLocalFacts() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(route: "R1", departureOffset: 600, walkSeconds: 300), + server: info(route: "R2", departureOffset: 900), + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + #expect(session.local.firstWalkSeconds == nil) + #expect(session.local.routeDisplayName.isEmpty) + } + + // MARK: - 수명 보존 + + @Test + func acknowledgedSession_refresh_keepsLifecycle() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: 600, lifecycle: .acknowledged), + server: info(departureOffset: 900), + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + #expect(session.lifecycle == .acknowledged) + } + + /// 확인된 세션도 출발 시각이 지나면 톰스톤이 된다(acknowledged → ended 전이). + @Test + func acknowledgedSession_pastDeparture_expires() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -120, lifecycle: .acknowledged), + server: nil, + now: now + ) + + guard case let .expired(session) = outcome else { + Issue.record("expired 기대, 실제 \(outcome)"); return + } + #expect(session.lifecycle == .ended) + } + + // MARK: - 서버 응답 없음 (오프라인·실패) + + /// 네트워크 실패가 "세션 소멸"이 되면 안 된다 — 미래 세션은 그대로 유지한다. + @Test + func serverUnavailable_futureSession_keptIntact() { + let existing = session(departureOffset: 600, syncedAt: now.addingTimeInterval(-300)) + let outcome = AlarmSessionReconciler.reconcile( + current: existing, + server: nil, + now: now + ) + + guard case let .refreshed(session) = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + // 확인 시각을 갱신하지 않는다 — 서버에 닿지 못했으므로 낡은 시각이 정직하다. + #expect(session.syncedAt == now.addingTimeInterval(-300)) + } + + @Test + func serverUnavailable_endedSession_isIgnored() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -600, lifecycle: .ended), + server: nil, + now: now + ) + + #expect(outcome == .ignoredStaleEcho) + } + + // MARK: - 서버가 세션 없음을 알림 + + @Test + func serverDropsDepartureTime_isEnded() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: 600), + server: info(departureOffset: nil), + now: now + ) + + #expect(outcome == .ended) + } + + // MARK: - 경계 + + /// 만료 경계는 출발 + 유예(60초)다. 정확히 그 시점은 만료로 본다. + @Test + func expiryBoundary_exactlyAtGrace_expires() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -AlarmTiming.expiryGraceSeconds), + server: nil, + now: now + ) + + guard case .expired = outcome else { + Issue.record("expired 기대, 실제 \(outcome)"); return + } + } + + @Test + func expiryBoundary_oneSecondBeforeGrace_survives() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -(AlarmTiming.expiryGraceSeconds - 1)), + server: nil, + now: now + ) + + guard case .refreshed = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + } + + // MARK: - fireDate (도보 초를 읽는 유일한 지점) + + @Test + func fireDate_subtractsWalkAndBuffer() { + let session = session(departureOffset: 1800, walkSeconds: 300) + + #expect( + session.fireDate == now.addingTimeInterval(1800 - 300 - AlarmTiming.bufferSeconds) + ) + } + + /// 도보 초를 모르면 버퍼만 적용된다 — 알람이 도보 시간만큼 늦게 울리므로, + /// 등록 경로가 이 값을 반드시 채워야 한다는 근거다. + @Test + func fireDate_withoutWalkSeconds_appliesBufferOnly() { + let session = session(departureOffset: 1800, walkSeconds: nil) + + #expect(session.fireDate == now.addingTimeInterval(1800 - AlarmTiming.bufferSeconds)) + } + + @Test + func fireDate_withoutDeparture_isNil() { + let session = AlarmSession( + server: info(departureOffset: nil), + local: .empty + ) + + #expect(session.fireDate == nil) + } + + // MARK: - LA 재시작 게이트 + + @Test + func allowsActivityRestart_onlyWhenActive() { + #expect(session(departureOffset: 600, lifecycle: .active).allowsActivityRestart) + #expect(!session(departureOffset: 600, lifecycle: .acknowledged).allowsActivityRestart) + #expect(!session(departureOffset: 600, lifecycle: .ended).allowsActivityRestart) + } +} From febac761630533ef540e665df320d1cb297ba322 Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 19:25:32 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20AlarmSessionStore=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20=E2=80=94=20=EC=84=B8=EC=85=98=20=EC=86=8C=EC=9C=A0?= =?UTF-8?q?=EB=A5=BC=20=ED=95=9C=20=EA=B3=B3=EC=9C=BC=EB=A1=9C=20(Phase=20?= =?UTF-8?q?B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 코드는 아직 건드리지 않는다(테스트 영향 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 --- Projects/App/Sources/AlarmSessionStore.swift | 136 +++++++++++ .../App/Tests/AlarmSessionStoreTests.swift | 226 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 Projects/App/Sources/AlarmSessionStore.swift create mode 100644 Projects/App/Tests/AlarmSessionStoreTests.swift diff --git a/Projects/App/Sources/AlarmSessionStore.swift b/Projects/App/Sources/AlarmSessionStore.swift new file mode 100644 index 00000000..142c10b1 --- /dev/null +++ b/Projects/App/Sources/AlarmSessionStore.swift @@ -0,0 +1,136 @@ +import CoreStorage +import Domain +import Foundation +import os + +/// 알람 세션을 소유하는 **유일한 지점**. 읽기도 쓰기도 여기를 통한다. +/// +/// 이전에는 하나의 세션이 8군데에 복제돼 있었다 — 서버, `lastInfo`(replay 버퍼 겸 +/// diff 이전 값 겸 만료 판정 대상), 파생 메모리 5종, 스냅샷, AlarmKit 레코드, +/// AlarmKit, ActivityKit, 그리고 홈 ViewModel 사본. 이 중 OS·원격이 소유한 +/// 4개(서버·AlarmKit 레코드·AlarmKit·ActivityKit)는 본질적 미러라 없앨 수 없지만, +/// **앱이 소유한 나머지는 하나로 모을 수 있다.** 이 타입이 그 하나다. +/// +/// `@MainActor` 클래스인 이유: `actor`로 만들면 `current`가 `async`가 되어 홈 렌더 +/// 경로마다 `await`가 붙는다. 세션 쓰기는 동기화 트리거 4경로에서만 일어나고 그 +/// 경로가 이미 `inFlight` 합류로 직렬화되므로, 메인 액터 격리만으로 단일 writer가 +/// 보장된다. +@MainActor +final class AlarmSessionStore { + private static let logger = Logger(subsystem: "com.atcha.alarm", category: "session") + + /// 새 키다 — 기존 `alarm.sessionSnapshot`과 스키마가 다르다. V2가 미출시이므로 + /// 마이그레이션을 두지 않고, 구 키는 전환이 끝난 뒤 정리한다. + static let storageKey = "alarm.session" + + private let document: DocumentStore + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + + /// 메모리 캐시 = 동기 읽기의 근거. 디스크 로드는 `bootstrap()`에서 1회만 한다. + private(set) var current: AlarmSession? + private var didBootstrap = false + + init(store: any KeyValueStore) { + document = DocumentStore(store: store, key: Self.storageKey) + } + + /// 앱 시작 직후 1회. **sync보다 먼저 불러야 한다** — 그래야 오프라인 콜드스타트에서도 + /// 홈이 배너·카드를 즉시 그린다. 이전 구조는 시딩 게이트가 `sync()` 안에 있어서 + /// sync가 돌지 않으면 복원도 되지 않았다. + func bootstrap() async { + guard !didBootstrap else { return } + didBootstrap = true + current = await document.load() + if let current { + Self.logger.info( + """ + 세션 복원: route=\(current.server.lastRouteId, privacy: .public) \ + lifecycle=\(current.lifecycle.rawValue, privacy: .public) + """ + ) + } + // 톰스톤이어도 방출한다 — 홈이 "지난 막차" 카드를 그릴 근거이고, + // 무엇보다 구독자가 "아직 로드 전"과 "세션 없음"을 구분할 수 있어야 한다. + broadcast() + } + + /// Reconciler 판정을 그대로 반영한다. **세션 상태가 바뀌는 유일한 경로**다. + func apply(_ outcome: AlarmSessionReconciler.Outcome) async { + switch outcome { + case let .refreshed(session): + await persist(session) + + case let .expired(session): + // clear가 아니라 톰스톤을 남긴다 — 지워 버리면 다음 실행에서 같은 과거 + // 세션의 refresh가 배너·재부착을 되살린다. + Self.logger.info( + "세션 만료 확정: route=\(session.server.lastRouteId, privacy: .public)" + ) + await persist(session) + + case .ended: + await clear() + + case .ignoredStaleEcho: + // 아무것도 하지 않는다. 방출도 하지 않아야 한다 — 같은 값을 다시 흘리면 + // 구독자가 불필요하게 다시 렌더한다. + break + } + } + + /// stopIntent("확인") 수신 — LA 재시작 금지 상태로 전이한다. + func acknowledge() async { + guard let session = current, session.lifecycle == .active else { return } + await persist(session.with(lifecycle: .acknowledged)) + } + + /// 등록 성공 직후. 서버가 주지 않는 로컬 사실(도보 초·표시명·수단)의 유일한 기록 + /// 시점이라, **서버 등록 직후·로컬 스케줄 전에** 불러야 한다. 스케줄이 실패해 + /// 앱이 죽어도 다음 sync가 도보 초를 복원할 수 있다(그러지 않으면 알람이 도보 + /// 시간만큼 늦게 울린다). + func register(session: AlarmSession) async { + await persist(session) + } + + func clear() async { + try? await document.clear() + current = nil + broadcast() + } + + /// 로그아웃·탈퇴 — 로컬 기록을 비우고 구독자에게도 알린다. + func reset() async { + await clear() + } + + // MARK: - 구독 + + /// 구독자마다 독립 스트림. **replay-1** — 구독 전에 끝난 부트스트랩·동기화를 + /// 놓치지 않아야 한다. (변경 *사건*은 replay하지 않는 별도 채널이 맡는다.) + func updates() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + subscribers[id] = continuation + continuation.yield(current) + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in self?.subscribers[id] = nil } + } + } + } + + // MARK: - + + private func persist(_ session: AlarmSession) async { + // 저장 실패를 흡수하는 이유: 정본은 서버다. 디스크 기록은 재실행 브리지이므로 + // 실패해도 이번 실행의 동작을 막아서는 안 된다(다음 sync가 자가치유). + try? await document.save(session) + current = session + broadcast() + } + + private func broadcast() { + for continuation in subscribers.values { + continuation.yield(current) + } + } +} diff --git a/Projects/App/Tests/AlarmSessionStoreTests.swift b/Projects/App/Tests/AlarmSessionStoreTests.swift new file mode 100644 index 00000000..89b77d5b --- /dev/null +++ b/Projects/App/Tests/AlarmSessionStoreTests.swift @@ -0,0 +1,226 @@ +@testable import AtchaV2 +import CoreStorage +import Domain +import Foundation +import Synchronization +import Testing + +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { storage.withLock { $0[key] } } + func set(_ data: Data, forKey key: String) throws { storage.withLock { $0[key] = data } } + func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } } +} + +@MainActor +struct AlarmSessionStoreTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let store = InMemoryKeyValueStore() + + private func makeSUT() -> AlarmSessionStore { + AlarmSessionStore(store: store) + } + + private func session( + route: String = "R1", + departureOffset: TimeInterval = 900, + walkSeconds: Int? = 300, + lifecycle: AlarmSession.Lifecycle = .active + ) -> AlarmSession { + AlarmSession( + server: AlarmInfo( + lastRouteId: route, + departureTime: now.addingTimeInterval(departureOffset), + updatedAt: now, + isReal: true + ), + local: .init( + firstWalkSeconds: walkSeconds, + routeDisplayName: "6411번 버스", + transportMode: .bus + ), + lifecycle: lifecycle, + syncedAt: now + ) + } + + // MARK: - 부트스트랩 + + @Test + func bootstrap_emptyStore_currentIsNil() async { + let sut = makeSUT() + + await sut.bootstrap() + + #expect(sut.current == nil) + } + + /// 앱 시작 직후 디스크에서 복원해야 한다 — 이전 구조는 시딩이 `sync()` 안에 있어서 + /// sync가 돌지 않으면(오프라인 콜드스타트) 복원도 되지 않았다. + @Test + func bootstrap_restoresPersistedSession() async { + let saved = session() + try? store.setValue(saved, forKey: AlarmSessionStore.storageKey) + let sut = makeSUT() + + await sut.bootstrap() + + #expect(sut.current == saved) + } + + @Test + func bootstrap_calledTwice_loadsOnce() async { + try? store.setValue(session(), forKey: AlarmSessionStore.storageKey) + let sut = makeSUT() + await sut.bootstrap() + + // 두 번째 호출 전에 디스크를 비워도 캐시가 유지돼야 한다(재로드 금지). + try? store.removeValue(forKey: AlarmSessionStore.storageKey) + await sut.bootstrap() + + #expect(sut.current != nil) + } + + // MARK: - Reconciler outcome 반영 + + @Test + func apply_refreshed_persistsAndExposes() async { + let sut = makeSUT() + await sut.bootstrap() + let next = session() + + await sut.apply(.refreshed(next)) + + #expect(sut.current == next) + #expect((try? store.value(AlarmSession.self, forKey: AlarmSessionStore.storageKey)) == next) + } + + /// 만료는 **지우지 않고 톰스톤을 남긴다** — 지우면 다음 실행에서 같은 과거 세션의 + /// refresh가 배너를 되살린다. + @Test + func apply_expired_keepsTombstoneOnDisk() async { + let sut = makeSUT() + await sut.bootstrap() + let dead = session(lifecycle: .ended) + + await sut.apply(.expired(dead)) + + #expect(sut.current?.lifecycle == .ended) + let persisted = try? store.value(AlarmSession.self, forKey: AlarmSessionStore.storageKey) + #expect(persisted?.lifecycle == .ended) + } + + @Test + func apply_ended_clearsEverything() async { + let sut = makeSUT() + await sut.register(session: session()) + + await sut.apply(.ended) + + #expect(sut.current == nil) + let persisted = try? store.value(AlarmSession.self, forKey: AlarmSessionStore.storageKey) + #expect(persisted == nil) + } + + /// 메아리는 상태를 바꾸지 않아야 한다. + @Test + func apply_ignoredStaleEcho_leavesStateUntouched() async { + let sut = makeSUT() + let existing = session() + await sut.register(session: existing) + + await sut.apply(.ignoredStaleEcho) + + #expect(sut.current == existing) + } + + // MARK: - 전이 + + @Test + func acknowledge_activeSession_transitionsToAcknowledged() async { + let sut = makeSUT() + await sut.register(session: session(lifecycle: .active)) + + await sut.acknowledge() + + #expect(sut.current?.lifecycle == .acknowledged) + } + + /// 이미 끝난 세션은 확인으로 되살아나면 안 된다. + @Test + func acknowledge_endedSession_isNoop() async { + let sut = makeSUT() + await sut.register(session: session(lifecycle: .ended)) + + await sut.acknowledge() + + #expect(sut.current?.lifecycle == .ended) + } + + /// 등록은 로컬 사실(도보 초)의 유일한 기록 시점이다. + @Test + func register_recordsLocalFacts() async { + let sut = makeSUT() + + await sut.register(session: session(walkSeconds: 420)) + + #expect(sut.current?.local.firstWalkSeconds == 420) + // fireDate가 도보 초를 반영해야 한다 — 이게 없으면 알람이 늦게 울린다. + #expect( + sut.current?.fireDate + == now.addingTimeInterval(900 - 420 - AlarmTiming.bufferSeconds) + ) + } + + @Test + func reset_clearsSession() async { + let sut = makeSUT() + await sut.register(session: session()) + + await sut.reset() + + #expect(sut.current == nil) + } + + // MARK: - 구독 (replay-1) + + /// 구독 전에 끝난 부트스트랩을 놓치지 않아야 한다. + @Test + func updates_replaysCurrentValueOnSubscribe() async { + let sut = makeSUT() + let existing = session() + await sut.register(session: existing) + + var iterator = sut.updates().makeAsyncIterator() + let first = await iterator.next() + + #expect(first == existing) + } + + @Test + func updates_emitsOnApply() async { + let sut = makeSUT() + await sut.bootstrap() + var iterator = sut.updates().makeAsyncIterator() + _ = await iterator.next() // replay(nil) + + let next = session() + await sut.apply(.refreshed(next)) + + #expect(await iterator.next() == next) + } + + @Test + func updates_emitsNilOnClear() async { + let sut = makeSUT() + await sut.register(session: session()) + var iterator = sut.updates().makeAsyncIterator() + _ = await iterator.next() // replay(session) + + await sut.clear() + + let received = await iterator.next() + #expect(received == AlarmSession?.none) + } +} From d76a3a49ee70c63b4400bbd720c68de6b375ebd1 Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 19:27:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EB=93=B1=EB=A1=9D=20=EC=A4=91=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EC=95=8C=EB=9E=8C=EC=9D=B4=20=EB=8A=A6?= =?UTF-8?q?=EA=B2=8C=20=EC=9A=B8=EB=A6=AC=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?(Phase=20B3a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 증상 알람 등록에서 서버 등록은 성공했는데 로컬 스케줄(`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 --- .../UseCases/RegisterAlarmUseCase.swift | 20 ++++++---- .../DefaultRegisterAlarmUseCaseTests.swift | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift index c9b09e4f..af0aa192 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -56,20 +56,18 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { try? await repository.cancel(lastRouteId: existing.lastRouteId) } try await repository.register(lastRouteId: route.id) - // 단일 알람 정책: 서버 등록이 성공한 뒤에만 로컬 알람을 교체한다. - try await scheduler.replaceAlarm( - id: route.id, - fireDate: fireDate, - title: AlarmSchedulingDefaults.title - ) let session = AlarmInfo( lastRouteId: route.id, departureTime: route.departureTime, updatedAt: nil, // 등록 직후라 서버 재계산 값이 아직 없다 — refresh가 갱신한다. isReal: true ) - // 등록 성공 = 스냅샷 저장 시점(Phase 14) — 재실행 시 diff 기준·LA 재부착·카드 - // 복원의 재료. 도보 초·표시명·수단은 등록 시점 경로에서만 얻을 수 있다. + // 스냅샷을 **로컬 스케줄보다 먼저** 저장한다. 도보 초·표시명·수단은 등록 시점 + // 경로에서만 얻을 수 있는데, 아래 replaceAlarm이 throw하면(AlarmKit 거부 등) + // 이 저장이 건너뛰어져 서버에는 세션이 있고 로컬에는 도보 초가 없는 상태가 된다. + // 그러면 다음 refresh가 세션을 발견해도 버퍼만 적용된 시각으로 복구되어 + // **알람이 도보 시간만큼 늦게 울린다**(도보 3분이면 3분 늦음). + // 저장은 throws가 아니라 실패를 흡수하므로 이 순서가 등록을 막지 않는다. // syncedAt = 등록 시각(Phase 16) — 서버가 방금 이 값을 받아들였으므로 확인이다. await snapshotStore?.save(AlarmSessionSnapshot( info: session, @@ -80,6 +78,12 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { expired: false, syncedAt: now() )) + // 단일 알람 정책: 서버 등록이 성공한 뒤에만 로컬 알람을 교체한다. + try await scheduler.replaceAlarm( + id: route.id, + fireDate: fireDate, + title: AlarmSchedulingDefaults.title + ) // 수명 정책: 알람 등록(서버+로컬)이 전부 성공한 뒤에만 LA를 시작한다. // start는 throws가 아니므로 LA 실패가 알람 등록을 실패시킬 수 없다. if let activityPort { diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift index 1ca0fd93..c5ede2a8 100644 --- a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -33,6 +33,8 @@ private struct SpyAlarmRepository: AlarmRepository { private struct SpyAlarmScheduler: AlarmScheduler { let log: CallLog var authorizationGranted = true + /// 로컬 스케줄 실패 주입 — AlarmKit이 과거 fixed date를 거부하는 등의 상황. + var replaceError: (any Error)? func requestAuthorization() async -> Bool { await log.append("auth:\(authorizationGranted)") @@ -41,6 +43,7 @@ private struct SpyAlarmScheduler: AlarmScheduler { func replaceAlarm(id: String, fireDate: Date, title: String) async throws { await log.append("replaceAlarm:\(id)@\(Int(fireDate.timeIntervalSince1970))") + if let replaceError { throw replaceError } } func cancelAlarm() async { @@ -302,4 +305,38 @@ struct DefaultRegisterAlarmUseCaseTests { } #expect(await store.saved.isEmpty) } + + /// 회귀: 서버 등록은 성공했는데 로컬 스케줄이 실패하는 경우. + /// + /// 스냅샷 저장이 `replaceAlarm` **뒤에** 있던 시절에는 이 경로에서 저장이 건너뛰어져 + /// 서버에는 세션이 있고 로컬에는 도보 초가 없는 상태가 됐다. 그러면 다음 refresh가 + /// 세션을 발견해도 버퍼만 적용된 시각으로 복구되어 **알람이 도보 시간만큼 늦게** + /// 울린다(도보 5분이면 5분 늦음). 저장을 서버 등록 직후로 옮겨 막는다. + @Test + func execute_localScheduleFails_stillPersistsLocalFacts() async { + let log = CallLog() + let store = SpySnapshotStore() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log, replaceError: StubError()), + snapshotStore: store, + now: fixedNow + ) + + let route = LastRoute.fixture( + id: "new", legs: [walkLeg(sectionTime: 300), busLeg(routeName: "간선:6411")] + ) + + await #expect(throws: StubError.self) { + try await sut.execute(route: route) + } + + let saved = await store.saved + #expect(saved.count == 1) + // 도보 초가 보존돼야 한다 — 이게 없으면 복구 시 알람이 늦는다. + #expect(saved.first?.firstWalkSeconds == 300) + #expect(saved.first?.info.lastRouteId == "new") + // 로컬 스케줄은 실제로 시도됐고 실패했다(저장이 스케줄을 건너뛴 게 아니다). + #expect(await log.events.contains { $0.hasPrefix("replaceAlarm:new") }) + } }