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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho
- **네트워크 진단**: `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`가 "없다고 답했다"다.
- **장소 검색(`GET /locations`)의 `lat`/`lon`은 필수이고, `(0,0)`은 결과를 0건으로 만든다**(2026-09-25 실측). 좌표는 결과 집합이 아니라 **거리 표기·정렬**에만 쓰인다 — 부산 좌표로 '강남역'을 검색해도 같은 20건이 나오고 `radius`만 316km로 바뀐다. 그래서 위치를 모를 때는 `Coordinate.serviceRegionCenter`(서울시청)로 폴백한다(결과 손실 없음). 파라미터 생략은 `REQ_005`. **레거시의 "미지정 시 0.0" 규약을 되살리지 말 것** — 위치를 얻기 전의 모든 검색이 통째로 빈다.
- **경로 조회(`GET /routes/last-routes`)의 정상 상태 에러 코드**(2026-09-25 실측): `TRS_011` = 출발지·도착지가 너무 가까움(→ `.noRoute`), `TRS_012` = 서비스 지역 밖(→ `.outOfServiceRegion`). `SearchLastRoutesUseCase.normalizedResult`가 매핑하며, 매핑 전에는 둘 다 throw되어 화면에 "검색에 실패했어요"만 떴다. 막차 종료 전용 코드는 아직 미관측.
- **응답 캐시는 실패뿐 아니라 빈 결과도 캐시하지 않는다**(`CachingPlaceRepository`). 빈 배열도 성공 응답이라 그냥 담으면 원인이 사라져도 TTL 내내 화면이 회복되지 않는다.
- 위치가 안 잡히는 증상은 원인이 여럿이라(권한 미결정 대기 / 거부 / 전역 OFF / 스트림 조기 종료) 밖에서는 전부 "출발지가 비어 있다"로 보인다. `CoreLocationServiceAdapter`가 DEBUG에서 `com.atcha.iOS.v2` 서브시스템 `Location` 카테고리에 업데이트별 플래그를 남긴다(좌표는 민감 정보라 미기록).
- 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
21 changes: 21 additions & 0 deletions Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CoreLocation
import Domain
import os

/// CoreLocation → Domain `LocationService` 어댑터. CoreLocation을 아는 곳은 여기뿐.
///
Expand All @@ -8,6 +9,13 @@ import Domain
/// App 모듈 기본 격리가 MainActor라 이 클래스는 암시적 Sendable — 프로토콜의
/// nonisolated async 요구사항은 격리 witness로 충족된다.
final class CoreLocationServiceAdapter: LocationService {
/// **이 로그가 있는 이유.** 위치가 안 잡히는 증상은 원인이 여럿인데(권한 미결정 대기 /
/// 거부 / 전역 OFF / 스트림 조기 종료) 밖에서는 전부 "출발지가 비어 있다"로만 보인다.
/// 자동 검수 규약도 이 상황을 *"증상이 '위치가 조용히 안 잡힘'으로 보여 원인 추적이
/// 어렵다"* 고 적어 뒀다. 업데이트 한 건마다 플래그를 남기면 그 구분이 로그에서 끝난다.
/// 좌표는 남기지 않는다 — 위치는 민감 정보다.
private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "Location")

/// CLLocationUpdate의 거부 플래그 3종 → LocationError 매핑(Phase 17). nil = 진행 중.
/// 우선순위: restricted > 전역 OFF > 앱 권한 거부 — 더 좁은 회복 경로가 이긴다
/// (restricted는 설정으로 못 풀고, 전역 OFF는 앱 권한 상태를 무의미하게 만든다).
Expand All @@ -21,6 +29,14 @@ final class CoreLocationServiceAdapter: LocationService {
func currentLocation() async throws -> Coordinate {
do {
for try await update in CLLocationUpdate.liveUpdates() {
Self.logger.debug(
"""
update: location=\(update.location != nil, privacy: .public) \
denied=\(update.authorizationDenied, privacy: .public) \
global=\(update.authorizationDeniedGlobally, privacy: .public) \
restricted=\(update.authorizationRestricted, privacy: .public)
"""
)
if let error = Self.classify(
denied: update.authorizationDenied,
deniedGlobally: update.authorizationDeniedGlobally,
Expand All @@ -37,10 +53,15 @@ final class CoreLocationServiceAdapter: LocationService {
// 권한 요청 진행 중 / 일시적 위치 불가 → 다음 업데이트를 기다린다.
}
} catch let error as LocationError {
Self.logger.debug("거부·제약으로 종료: \(String(describing: error), privacy: .public)")
throw error
} catch {
Self.logger.debug("스트림 오류로 종료")
throw LocationError.unavailable
}
// 스트림이 위치 없이 끝났다 — 자동 검수 규약이 기록한 "Allow Once로 오염된 상태"가
// 이 경로다. 어느 쪽인지는 위 업데이트 로그가 말해 준다.
Self.logger.debug("스트림이 위치 없이 종료")
throw LocationError.unavailable
}
}
14 changes: 9 additions & 5 deletions Projects/Data/Sources/Network/PlaceEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ enum PlaceEndpoint: Endpoint {
var queryItems: [URLQueryItem] {
switch self {
case let .search(keyword, near):
// 좌표 미지정 시 0.0 전송은 레거시 실측 규약.
[
// 서버는 lat/lon을 **필수**로 받고(생략하면 REQ_005), 좌표는 결과 집합이 아니라
// 거리 표기·정렬에만 쓴다. 단 (0,0)은 유효한 좌표로 취급되어 **0건**이 온다 —
// 레거시의 "미지정 시 0.0" 규약을 그대로 따르면 위치를 얻기 전의 모든 검색이
// 통째로 빈다. 그래서 모를 때는 서비스 지역 중심을 보낸다(실측 근거는 Coordinate).
let bias = near ?? .serviceRegionCenter
return [
URLQueryItem(name: "keyword", value: keyword),
URLQueryItem(name: "lat", value: String(near?.latitude ?? 0.0)),
URLQueryItem(name: "lon", value: String(near?.longitude ?? 0.0)),
URLQueryItem(name: "lat", value: String(bias.latitude)),
URLQueryItem(name: "lon", value: String(bias.longitude)),
]
case let .reverseGeocode(coordinate), let .serviceRegion(coordinate):
[
return [
URLQueryItem(name: "lat", value: String(coordinate.latitude)),
URLQueryItem(name: "lon", value: String(coordinate.longitude)),
]
Expand Down
12 changes: 9 additions & 3 deletions Projects/Data/Sources/Repositories/CachingPlaceRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import Foundation
/// | 장소 검색 | 새로 생긴 가게가 안 보인다 | 캐시. POI는 생기고 없어지므로 1시간 |
/// | 서비스 지역 판정 | **서비스가 되는데 안 된다고 막는다** | **캐시하지 않음** — 지역 확장이 즉시 반영돼야 하고, 호출 빈도도 집 주소 저장 시 1회뿐이라 아낄 게 없다 |
///
/// 실패는 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도 계속
/// 실패로 답하는 게 더 나쁘다.
/// 실패도, **빈 결과도** 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도
/// 계속 실패로 답하는 게 더 나쁘고, 빈 결과를 TTL 내내 박아 두면 원인이 사라져도 화면이
/// 회복되지 않는다.
public struct CachingPlaceRepository: PlaceRepository {
private let upstream: any PlaceRepository
private let geocodeCache: ExpiringCache<PlaceRecordDTO>
Expand Down Expand Up @@ -46,7 +47,12 @@ public struct CachingPlaceRepository: PlaceRepository {
return cached.map { $0.toEntity() }
}
let places = try await upstream.searchPlaces(keyword: keyword, near: coordinate)
try? await searchCache.setValue(places.map(PlaceRecordDTO.init), forKey: key)
// **빈 결과는 캐시하지 않는다.** 빈 배열도 성공 응답이라 그냥 담으면, 한 번 비어서
// 온 키워드가 원인이 사라진 뒤에도 TTL 내내 비어 보인다 — 실패를 캐시하지 않는
// 이유와 같은 이유이고, 아낄 왕복 하나보다 피해가 크다.
if !places.isEmpty {
try? await searchCache.setValue(places.map(PlaceRecordDTO.init), forKey: key)
}
return places
}

Expand Down
24 changes: 24 additions & 0 deletions Projects/Data/Tests/CachingPlaceRepositoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ private final class SpyPlaceRepository: PlaceRepository, @unchecked Sendable {
private let searchCalls = Mutex(0)
private let serviceRegionCalls = Mutex(0)
private let shouldFail = Mutex(false)
private let emptyResult = Mutex(false)

var geocodeCount: Int { geocodeCalls.withLock { $0 } }
var searchCount: Int { searchCalls.withLock { $0 } }
Expand All @@ -29,10 +30,15 @@ private final class SpyPlaceRepository: PlaceRepository, @unchecked Sendable {
get { shouldFail.withLock { $0 } }
set { shouldFail.withLock { $0 = newValue } }
}
var returnsEmpty: Bool {
get { emptyResult.withLock { $0 } }
set { emptyResult.withLock { $0 = newValue } }
}

func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] {
searchCalls.withLock { $0 += 1 }
if fails { throw UpstreamError() }
if returnsEmpty { return [] }
return [makePlace("\(keyword) 결과")]
}

Expand Down Expand Up @@ -163,6 +169,24 @@ struct CachingPlaceRepositoryTests {
#expect(upstream.searchCount == 2)
}

/// **빈 결과는 캐시하지 않는다.** 빈 배열도 성공 응답이라 그냥 담으면, 한 번 비어서 온
/// 키워드가 원인이 사라진 뒤에도 TTL 내내 비어 보인다 — 화면이 스스로 회복하지 못한다.
@Test
func searchPlaces_emptyResult_isNotCached() async throws {
let sut = makeSUT()
upstream.returnsEmpty = true

let first = try await sut.searchPlaces(keyword: "강남", near: nil)
#expect(first.isEmpty)

// 상류가 회복되면 같은 키워드가 **즉시** 결과를 돌려줘야 한다.
upstream.returnsEmpty = false
let second = try await sut.searchPlaces(keyword: "강남", near: nil)

#expect(upstream.searchCount == 2)
#expect(!second.isEmpty)
}

// MARK: - 캐시하지 않는 것

/// 실패는 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도 계속
Expand Down
11 changes: 8 additions & 3 deletions Projects/Data/Tests/PlaceEndpointTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ struct PlaceEndpointTests {
])
}

/// **좌표를 모를 때 (0,0)을 보내면 안 된다.** 서버는 그걸 유효한 좌표로 취급해
/// 결과를 0건으로 돌려준다(2026-09-25 실측) — 위치를 얻기 전의 모든 검색이 통째로
/// 비는 원인이었다. lat/lon은 필수라 생략도 못 한다(REQ_005).
@Test
func search_withoutCoordinate_sendsZeroesLikeLegacy() {
func search_withoutCoordinate_fallsBackToServiceRegionCenterNotZero() {
let endpoint = PlaceEndpoint.search(keyword: "홍대입구", near: nil)

#expect(endpoint.queryItems == [
URLQueryItem(name: "keyword", value: "홍대입구"),
URLQueryItem(name: "lat", value: "0.0"),
URLQueryItem(name: "lon", value: "0.0"),
URLQueryItem(name: "lat", value: String(Coordinate.serviceRegionCenter.latitude)),
URLQueryItem(name: "lon", value: String(Coordinate.serviceRegionCenter.longitude)),
])
#expect(endpoint.queryItems.first { $0.name == "lat" }?.value != "0.0")
}

@Test
Expand Down
10 changes: 10 additions & 0 deletions Projects/Domain/Sources/Entities/Coordinate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,14 @@ public struct Coordinate: Equatable, Sendable {
self.latitude = latitude
self.longitude = longitude
}

/// 서비스 지역(서울·경기·인천)의 중심으로 쓰는 좌표 — 서울시청.
///
/// **위치를 모를 때 쓰는 폴백이다.** 장소 검색 API가 `lat`/`lon`을 필수로 받는데
/// (생략하면 `REQ_005`), 좌표는 결과 집합이 아니라 **거리 표기·정렬**에만 쓰인다
/// (실측 2026-09-25: 부산 좌표로 '강남역'을 검색해도 같은 20건이 나오고 `radius`만
/// 316km로 바뀐다). 그래서 이 폴백은 결과를 잃지 않는다.
///
/// 0,0을 보내면 안 된다 — 유효한 좌표로 취급되어 **결과가 0건이 된다**(같은 실측).
public static let serviceRegionCenter = Coordinate(latitude: 37.5665, longitude: 126.9780)
}
5 changes: 4 additions & 1 deletion Projects/Domain/Sources/Entities/LastRouteSearchResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ public enum LastRouteSearchResult: Sendable, Equatable {
case available([LastRoute])
/// 오늘 막차 종료
case serviceEnded
/// 경로 없음 (도보권 등)
/// 경로 없음 (도보권 등) — 서버 `TRS_011`("출발지와 도착지 간 거리가 너무 가깝습니다").
case noRoute
/// 서비스 지역(서울·경기·인천) 밖 — 서버 `TRS_012`. `noRoute`와 회복 경로가 다르다:
/// 다른 경로를 찾아 줄 수 없고, 사용자가 목적지 자체를 바꿔야 한다.
case outOfServiceRegion
}
14 changes: 11 additions & 3 deletions Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,23 @@ public struct DefaultSearchLastRoutesUseCase: SearchLastRoutesUseCase {
}
throw error
}
// TODO: [미확정 #3] 서버의 "막차 종료" 표현 실측 전까지 빈 목록을 종료로 간주한다.
// TODO: [미확정 #3] 막차 종료의 전용 responseCode는 아직 관측되지 않았다
// (2026-09-25 실측 시각이 막차 전이라 재현 못 함). 그때까지 빈 목록을 종료로 간주한다.
guard !routes.isEmpty else { return .serviceEnded }
return .available(routes)
}

// TODO: [미확정 #3] "막차 종료"/"경로 없음"의 responseCode 실측값이 확정되면 이 매핑에만 추가한다.
// 레거시 단서(의미 미확인): URT_001, LRT_001, LRT_003, REQ_004
/// 서버가 **정상 상태를 에러 코드로 알리는** 경우를 결과로 되돌린다. 매핑이 비어 있던
/// 동안 이 둘은 그대로 throw되어 화면에 "검색에 실패했어요"만 떴다 — 사용자는 무엇을
/// 바꿔야 하는지 알 수 없었다.
///
/// 2026-09-25 실측:
/// - `TRS_011` "출발지와 도착지 간 거리가 너무 가깝습니다." (같은 지점으로 조회)
/// - `TRS_012` "서비스 지역이 아닙니다: (129.0415, 35.1151)" (부산으로 조회)
private static func normalizedResult(code: String) -> LastRouteSearchResult? {
switch code {
case "TRS_011": .noRoute
case "TRS_012": .outOfServiceRegion
default: nil
}
}
Expand Down
24 changes: 24 additions & 0 deletions Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ struct DefaultSearchLastRoutesUseCaseTests {
#expect(result == .serviceEnded)
}

/// 2026-09-25 실측: 같은 지점으로 조회하면 서버가 `TRS_011`
/// ("출발지와 도착지 간 거리가 너무 가깝습니다")를 준다. **정상 상태를 에러 코드로**
/// 알리는 경우라, 매핑이 비어 있던 동안에는 그대로 throw되어 화면에
/// "검색에 실패했어요"만 떴다 — 사용자는 무엇을 바꿔야 하는지 알 수 없었다.
@Test
func execute_tooCloseServerCode_returnsNoRoute() async throws {
let sut = DefaultSearchLastRoutesUseCase(
repository: StubLastRouteRepository(routes: [], error: ServerError(code: "TRS_011"))
)

#expect(try await sut.execute(start: start, end: end) == .noRoute)
}

/// 2026-09-25 실측: 권역 밖(부산) 조회 → `TRS_012` "서비스 지역이 아닙니다".
/// `noRoute`와 회복 경로가 다르다 — 다른 경로를 찾아 줄 수 없고 목적지를 바꿔야 한다.
@Test
func execute_outOfServiceRegionServerCode_returnsOutOfServiceRegion() async throws {
let sut = DefaultSearchLastRoutesUseCase(
repository: StubLastRouteRepository(routes: [], error: ServerError(code: "TRS_012"))
)

#expect(try await sut.execute(start: start, end: end) == .outOfServiceRegion)
}

@Test
func execute_unknownServerError_rethrows() async {
let sut = DefaultSearchLastRoutesUseCase(
Expand Down
2 changes: 2 additions & 0 deletions Projects/Feature/Home/Sources/HomeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ final class HomeViewController: UIViewController {
DSToast.show("오늘 막차가 끊겼어요", in: toastHost)
case .chipNoRoute:
DSToast.show("대중교통 경로를 찾지 못했어요", in: toastHost)
case .chipOutOfServiceRegion:
DSToast.show("앗차는 현재 서울, 경기, 인천에서만 이용 가능해요", in: toastHost)
case .alarmPermissionNeeded:
DSToast.show(
"알람 권한이 꺼져 있어요",
Expand Down
4 changes: 4 additions & 0 deletions Projects/Feature/Home/Sources/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ final class HomeViewModel {
case chipServiceEnded
/// 칩 재검색 결과 경로 없음(Phase 18).
case chipNoRoute
/// 칩 재검색 결과 서비스 지역 밖 — 서버 TRS_012.
case chipOutOfServiceRegion
case alarmPermissionNeeded
case alarmRegisterFailed
case alarmCancelFailed
Expand Down Expand Up @@ -318,6 +320,8 @@ final class HomeViewModel {
self.onToast?(.chipServiceEnded)
case .noRoute:
self.onToast?(.chipNoRoute)
case .outOfServiceRegion:
self.onToast?(.chipOutOfServiceRegion)
}
} catch {
guard !Task.isCancelled else { return }
Expand Down
Loading
Loading