Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho
- `AppEnvironment.apiBaseURL`은 **세 환경 모두 `https://atcha.kro.kr/api` 단일값**(2026-09-24 확정). 이전 호스트 둘은 모두 죽었다 — `atcha.online`은 NXDOMAIN, `atcha.p-e.kr`은 443 연결 불가. **`/api` 접두어는 base URL에서만 붙인다** — 각 `Endpoint.path`는 접두어를 모르므로(`/auth/guest`, `/routes/user-routes` …) 서버가 접두어 체계를 바꾸면 이 프로퍼티 한 줄만 고친다. 환경별 호스트가 다시 생기면 `switch`로 되돌리면 된다.
- **네트워크 진단**: `URLSessionNetworkClient`가 DEBUG에서 `com.atcha.network` 서브시스템에 `메서드 · URL · 상태코드/에러 · 소요시간`을 남긴다(헤더·본문은 토큰이 실리므로 절대 로깅 금지). `NetworkError.debugDescription`이 URLError 코드를 노출해 타임아웃(-1001)·연결 불가(-1004)·TLS 실패(-1200)를 가른다 — 이전에는 전부 `.transport`로 뭉개져 원인 구분이 불가능했다. 부트스트랩 실패는 `BootstrapFailureMessage`를 거쳐 스플래시의 `showRetry`로 표면화된다(게스트 전환으로 부트스트랩이 비동기가 되면서 비로소 쓰이는 경로).
- `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰은 로그인/가입 파라미터로만 서버에 가고, 갱신 전달은 `SyncPushTokenUseCase`까지 배선됐지만 서버 API 미확정이라 `UnconfirmedPushTokenRepository`(no-op)가 주입돼 있다. 그래서 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐.
- **서버의 "등록된 알람 없음"은 `404` + `responseCode: URT_001`**(2026-09-25 실측: `"id(1010) 유저가 등록한 경로를 찾을 수 없습니다."`). `AlarmRepositoryImpl.refresh()`가 이것만 `AlarmRefreshOutcome.notRegistered`로 매핑하고, 나머지 에러 코드는 그대로 throw한다. **이 셋을 절대 뭉개지 말 것** — `.registered`(병합·재스케줄) / `.notRegistered`(**로컬 기록까지 정리**) / `throw`(**세션을 지킨다** — 네트워크 실패가 세션 소멸이 되면 지하철에서 앱을 여는 것만으로 알람이 사라진다). 판정은 `AlarmSessionReconciler.reconcile(current:server:now:)` 한 곳이고, 거기서 `server: nil`이 "못 물어봤다", `.notRegistered`가 "없다고 답했다"다.
- AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료.
- **막차 경로(`/routes/last-routes`) 응답 캐시는 의도적으로 미도입**이다. 장소·역지오코딩과 달리 틀렸을 때의 피해가 "막차를 놓친다"라서, 도입하려면 두 가지가 함께 와야 한다: ① 화면에 **"HH:mm 기준" 스탬프 필수**(스탬프 없이 표시 금지), ② **알람 등록 근거로는 캐시 사용 금지** — 지금 `RegisterAlarmUseCase`는 사용자가 고른 `LastRoute`를 그대로 받으므로, 이 규칙을 강제하려면 등록 시점 재조회가 필요해 시그니처가 바뀐다. TTL은 쓰지 않는다(막차의 유효 시간은 벽시계가 아니라 `departureTime` 자체다 — `RouteCardViewData.asPastTrain`이 이미 그 전환을 한다).
- Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`.
Expand Down
16 changes: 9 additions & 7 deletions Projects/App/Sources/Adapters/DevChangeSimulator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,19 @@ struct DevChangeSimulatingAlarmRepository: AlarmRepository {
try await base.cancel(lastRouteId: lastRouteId)
}

func refresh() async throws -> AlarmInfo {
func refresh() async throws -> AlarmRefreshOutcome {
// 보류 중 변경 주입이 있으면 서버 대신 시뮬레이터가 응답한다(1회 소비).
if let injected = await DevChangeSimulator.shared.consumeInjectedInfo() {
print("⚠️ [DEV 변경 시뮬레이터] 변형 AlarmInfo 반환: departure=\(String(describing: injected.departureTime))")
return injected
return .registered(injected)
}
let info = try await base.refresh()
await DevChangeSimulator.shared.noteKnownSession(
routeId: info.lastRouteId, departureTime: info.departureTime
)
return info
let outcome = try await base.refresh()
if case let .registered(info) = outcome {
await DevChangeSimulator.shared.noteKnownSession(
routeId: info.lastRouteId, departureTime: info.departureTime
)
}
return outcome
}
}

Expand Down
20 changes: 12 additions & 8 deletions Projects/App/Sources/AlarmSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ final class AlarmSyncService: AlarmSyncRequesting {
/// 세션은 Store가 소유하고, 이건 diff 한 번에만 쓰이는 지역 기억이다.
private var previousServerInfo: AlarmInfo?
/// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다.
private var inFlight: Task<AlarmInfo?, Never>?
private var inFlight: Task<AlarmRefreshOutcome?, Never>?
private var foregroundObserver: (any NSObjectProtocol)?

// 앱 수명 객체(조합 루트 소유) — 해제 경로가 없어 관찰 해지/태스크 취소 정리가 없다.
Expand Down Expand Up @@ -120,7 +120,7 @@ final class AlarmSyncService: AlarmSyncRequesting {
@discardableResult
private func sync() async -> AlarmInfo? {
if let inFlight {
return await inFlight.value
return await inFlight.value?.info
}
Self.logger.info("알람 동기화 시작")
// 재실행 브리지 복원 — 세션을 디스크에서 되살린다. Store가 1회만 수행한다.
Expand All @@ -129,7 +129,10 @@ final class AlarmSyncService: AlarmSyncRequesting {
// diff의 "이전 값"은 이번 갱신 **전**의 서버 값이다.
previousServerInfo = current?.server

let task = Task { [refreshAlarmUseCase, current] () -> AlarmInfo? in
// nil = 조회 실패(세션 유지). `.notRegistered` = 서버가 없다고 확정(정리).
// 이 구분이 없던 시절엔 서버의 "등록된 알람 없음"(404 URT_001)이 실패로 흘러,
// 세션이 사라져도 앱이 계속 붙들고 매 실행마다 에러 로그가 남았다.
let task = Task { [refreshAlarmUseCase, current] () -> AlarmRefreshOutcome? in
do {
return try await refreshAlarmUseCase.execute(current: current)
} catch {
Expand All @@ -138,18 +141,19 @@ final class AlarmSyncService: AlarmSyncRequesting {
}
}
inFlight = task
let info = await task.value
let outcome = await task.value
inFlight = nil
let info = outcome?.info

// 만료·서버 우선·톰스톤 메아리 판정이 전부 여기 한 번에 일어난다.
// 이전에는 이 판단이 선행 만료 후보 → refresh → 3분기 서버 우선 → 톰스톤 기록으로
// 흩어져 있었다(그래서 "1차 방어 / 2차 방어" 주석이 붙었다).
let outcome = AlarmSessionReconciler.reconcile(
current: current, server: info, now: now()
let reconciled = AlarmSessionReconciler.reconcile(
current: current, server: outcome, now: now()
)
await sessionStore.apply(outcome)
await sessionStore.apply(reconciled)

switch outcome {
switch reconciled {
case let .refreshed(session):
Self.logger.info(
"알람 동기화 성공: route=\(session.server.lastRouteId, privacy: .public)"
Expand Down
57 changes: 52 additions & 5 deletions Projects/App/Tests/AlarmSyncServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,33 @@ private nonisolated final class ValueBox<Value: Sendable>: @unchecked Sendable {
}
}

/// 큐는 `AlarmInfo` 그대로 받는다 — 기존 케이스 대부분이 "서버에 세션이 있다"라서
/// 매 호출처에 `.registered(...)`를 쓰게 하면 읽기만 나빠진다. 서버가 "없다"고 확정한
/// 경우는 `enqueueNotRegistered()`로 명시한다(실패 = `.failure`와 구분되어야 한다).
private final class RefreshStub: RefreshAlarmUseCase, @unchecked Sendable {
private let lock = NSLock()
private var queue: [Result<AlarmInfo, any Error>]
init(_ results: [Result<AlarmInfo, any Error>]) { queue = results }
private var queue: [Result<AlarmRefreshOutcome, any Error>]
init(_ results: [Result<AlarmInfo, any Error>]) {
queue = results.map { $0.map { AlarmRefreshOutcome.registered($0) } }
}
func enqueue(_ result: Result<AlarmInfo, any Error>) {
lock.lock()
queue.append(result)
queue.append(result.map { AlarmRefreshOutcome.registered($0) })
lock.unlock()
}
/// 서버가 "등록된 알람 없음"(URT_001)을 확정한 응답.
func enqueueNotRegistered() {
lock.lock()
queue.append(.success(.notRegistered))
lock.unlock()
}
// NSLock은 async 컨텍스트에서 직접 못 쓴다 — 동기 헬퍼로 분리.
private func dequeue() -> Result<AlarmInfo, any Error>? {
private func dequeue() -> Result<AlarmRefreshOutcome, any Error>? {
lock.lock()
defer { lock.unlock() }
return queue.isEmpty ? nil : queue.removeFirst()
}
func execute(current: AlarmSession?) async throws -> AlarmInfo {
func execute(current: AlarmSession?) async throws -> AlarmRefreshOutcome {
guard let next = dequeue() else { throw StubError() }
return try next.get()
}
Expand Down Expand Up @@ -201,6 +212,42 @@ private func makeHarness(
struct AlarmSyncServiceTests {
// MARK: - 신선도 스탬프 (Phase 16)

// MARK: - 서버가 "등록된 알람 없음"을 확정 (404 URT_001)

/// 서버가 세션 없음을 확정하면 로컬 기록도 정리된다. 이전에는 이 응답이 throw로
/// 흘러 **조회 실패와 같은 취급**을 받아, 서버에서 세션이 사라져도 앱은 낡은 세션을
/// 계속 붙들었다 — 사용자는 울리지 않을 알람을 믿게 된다.
@Test
func serverSaysNotRegistered_clearsLocalSession() async {
let harness = makeHarness(seeded: AlarmSession(
server: info(route: "r1", departure: fixedNow.addingTimeInterval(3600)),
local: .empty,
syncedAt: fixedNow.addingTimeInterval(-600)
))
harness.refresh.enqueueNotRegistered()

await harness.sut.syncNow()

#expect(harness.store.current == nil)
}

/// 같은 "세션 없음" 결과라도 **조회 실패는 세션을 지킨다.** 둘이 구분되지 않으면
/// 지하철에서 앱을 여는 것만으로 알람이 사라진다.
@Test
func syncFailure_keepsLocalSession() async {
let seeded = AlarmSession(
server: info(route: "r1", departure: fixedNow.addingTimeInterval(3600)),
local: .empty,
syncedAt: fixedNow.addingTimeInterval(-600)
)
let harness = makeHarness(seeded: seeded)
harness.refresh.enqueue(.failure(StubError()))

await harness.sut.syncNow()

#expect(harness.store.current?.server.lastRouteId == "r1")
}

@Test
func syncSuccess_publishesCheckedAtAndPersistsSyncedAt() async {
let harness = makeHarness()
Expand Down
23 changes: 18 additions & 5 deletions Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,24 @@ public struct AlarmRepositoryImpl: AlarmRepository {
)
}

public func refresh() async throws -> AlarmInfo {
let dto: AlarmRefreshResponseDTO = try await networkClient.requestEnveloped(AlarmEndpoint.refresh)
guard let info = dto.toEntity() else {
throw NetworkError.decoding(underlying: MissingResultError())
/// 서버는 "등록된 알람 없음"을 **404 + `URT_001`** 로 알린다(2026-09-25 실측:
/// `{"responseCode":"URT_001","message":"id(1010) 유저가 등록한 경로를 찾을 수 없습니다."}`).
/// 이걸 throw로 흘리면 조회 실패와 구분되지 않아, 서버가 세션을 잃어도 앱이 낡은
/// 세션을 계속 붙들고 평상시엔 매 실행마다 에러 로그가 남는다.
public func refresh() async throws -> AlarmRefreshOutcome {
do {
let dto: AlarmRefreshResponseDTO = try await networkClient.requestEnveloped(
AlarmEndpoint.refresh
)
guard let info = dto.toEntity() else {
throw NetworkError.decoding(underlying: MissingResultError())
}
return .registered(info)
} catch let error as ServerError where error.code == Self.notRegisteredCode {
return .notRegistered
}
return info
}

/// 서버가 "이 유저에게 등록된 경로가 없다"를 나타내는 responseCode.
private static let notRegisteredCode = "URT_001"
}
61 changes: 61 additions & 0 deletions Projects/Data/Tests/AlarmRepositoryImplTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@testable import AtchaData
import CoreNetwork
import Domain
import Foundation
import Testing

private struct StubNetworkClient: NetworkClient {
let result: Result<Data, NetworkError>

func data(for endpoint: any Endpoint) async throws -> Data {
try result.get()
}

func request<Response: Decodable & Sendable>(
_ endpoint: any Endpoint,
as _: Response.Type
) async throws -> Response {
try JSONDecoder().decode(Response.self, from: result.get())
}
}

/// 2026-09-25 실측으로 확인한 서버 응답을 그대로 고정한다.
struct AlarmRepositoryImplTests {
/// 서버가 "등록된 알람 없음"을 알리는 실제 응답:
/// `404 {"responseCode":"URT_001","message":"id(1010) 유저가 등록한 경로를 찾을 수 없습니다."}`
///
/// 이걸 throw로 흘리면 조회 실패와 구분되지 않아, 상류(Reconciler)가 "못 물어봤다"로
/// 해석해 세션을 계속 붙든다.
@Test
func refresh_urt001_isNotRegisteredRatherThanFailure() async throws {
let body = Data(#"{"responseCode":"URT_001","message":"id(1010) 유저가 등록한 경로를 찾을 수 없습니다."}"#.utf8)
let sut = AlarmRepositoryImpl(
networkClient: StubNetworkClient(result: .failure(.unacceptableStatus(code: 404, data: body)))
)

#expect(try await sut.refresh() == .notRegistered)
}

@Test
func refresh_success_returnsRegisteredInfo() async throws {
let body = Data(#"{"responseCode":"SUCCESS","result":{"lastRouteId":"route-1","isReal":"true"}}"#.utf8)
let sut = AlarmRepositoryImpl(
networkClient: StubNetworkClient(result: .success(body))
)

#expect(try await sut.refresh().info?.lastRouteId == "route-1")
}

/// 다른 에러 코드는 **여전히 throw여야 한다** — URT_001만 정상 상태다.
@Test
func refresh_otherServerError_stillThrows() async {
let body = Data(#"{"responseCode":"REQ_002","message":"Authorization 헤더가 없습니다"}"#.utf8)
let sut = AlarmRepositoryImpl(
networkClient: StubNetworkClient(result: .failure(.unacceptableStatus(code: 401, data: body)))
)

await #expect(throws: ServerError.self) {
_ = try await sut.refresh()
}
}
}
20 changes: 14 additions & 6 deletions Projects/Domain/Sources/AlarmSessionReconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,23 @@ public enum AlarmSessionReconciler {

/// - Parameters:
/// - current: 로컬이 아는 세션(없으면 nil).
/// - server: refresh 결과. 실패·오프라인이면 nil을 넘긴다 —
/// 그때도 로컬 만료 판정은 돌아야 한다.
/// - server: refresh 결과. **조회를 못 했으면(실패·오프라인) nil**을 넘긴다 —
/// 그때도 로컬 만료 판정은 돌아야 한다. 서버가 "없다"고 **답한** 것은
/// nil이 아니라 `.notRegistered`다. 둘을 같은 값으로 넘기면 네트워크
/// 실패가 세션을 지워 버린다.
/// - now: 주입된 현재 시각(실 `Date()` 의존 금지 규약).
public static func reconcile(
current: AlarmSession?,
server: AlarmInfo?,
server: AlarmRefreshOutcome?,
now: Date
) -> Outcome {
switch (current, server) {
case (nil, nil):
case (nil, nil), (nil, .notRegistered):
return .ended

// 로컬에 기록이 없고 서버에 세션이 있다 — 재실행 후 발견이거나 첫 sync다.
// 로컬 사실을 모르므로 `.empty`로 시작하고, 다음 등록이 채운다.
case let (nil, .some(info)):
case let (nil, .registered(info)):
let discovered = AlarmSession(server: info, local: .empty, syncedAt: now)
return expiryOutcome(for: discovered, now: now) ?? .refreshed(discovered)

Expand All @@ -48,7 +50,13 @@ public enum AlarmSessionReconciler {
if session.isEnded { return .ignoredStaleEcho }
return expiryOutcome(for: session, now: now) ?? .refreshed(session)

case let (.some(session), .some(info)):
// 서버가 **없다고 확정했다**(URT_001). 로컬 기록까지 정리한다 — 다른 기기나
// 서버 쪽 정리로 세션이 사라졌는데 이 기기만 붙들고 있으면, 울리지 않을 알람을
// 믿고 자게 된다. 이미 끝난 세션이면 지울 것도 없다.
case let (.some(session), .notRegistered):
return session.isEnded ? .ignoredStaleEcho : .ended

case let (.some(session), .registered(info)):
return reconcile(session: session, server: info, now: now)
}
}
Expand Down
22 changes: 22 additions & 0 deletions Projects/Domain/Sources/Entities/AlarmRefreshOutcome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// `refresh()`가 돌려줄 수 있는 답. **`AlarmInfo?` 하나로 뭉개면 안 된다** —
/// "서버가 없다고 답했다"와 "못 물어봤다"가 구분되지 않기 때문이다.
///
/// | 답 | 의미 | 세션 처리 |
/// |---|---|---|
/// | `.registered` | 서버에 세션이 있다 | 병합·재스케줄 |
/// | `.notRegistered` | **서버가 없다고 확정했다** | 로컬 기록까지 정리 |
/// | `throw` | 못 물어봤다(실패·오프라인) | **지킨다** — 네트워크 실패가 세션 소멸이 되면 안 된다 |
///
/// 가운데 칸이 이 타입이 생긴 이유다. 서버는 이 상태를 404 + `URT_001`로 알리는데,
/// 그게 throw로 흘러 "실패"와 같은 취급을 받고 있었다 — 서버가 세션을 잃어도 앱은
/// 낡은 세션을 계속 붙들고, 등록된 알람이 없는 평상시엔 매 실행마다 에러 로그가 찍혔다.
public enum AlarmRefreshOutcome: Sendable, Equatable {
case registered(AlarmInfo)
case notRegistered

/// 값이 필요한 호출처용 — 부재와 실패를 구분할 필요가 없는 자리에서만 쓴다.
public var info: AlarmInfo? {
if case let .registered(info) = self { return info }
return nil
}
}
3 changes: 2 additions & 1 deletion Projects/Domain/Sources/Interfaces/AlarmRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
public protocol AlarmRepository: Sendable {
func register(lastRouteId: String) async throws
func cancel(lastRouteId: String) async throws
func refresh() async throws -> AlarmInfo
/// throw = 조회 실패(세션을 지킨다). `.notRegistered` = 서버가 없다고 확정(정리한다).
func refresh() async throws -> AlarmRefreshOutcome
}
Loading
Loading