From b43349bda84fec9872735639e7a34c9999b302ce Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Fri, 25 Sep 2026 01:22:22 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9C=84=EC=B9=98=20=ED=99=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A0=84=20=EC=9E=A5=EC=86=8C=20=EA=B2=80=EC=83=89=EC=9D=B4?= =?UTF-8?q?=200=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EB=8F=8C=EC=95=84=EC=98=A4?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20+=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 목적지 검색이 비는 이유 PlaceEndpoint.search가 좌표를 모를 때 lat=0.0&lon=0.0을 보냈다("레거시 실측 규약"). 서버는 이걸 유효한 좌표로 취급해 **결과를 0건으로 돌려준다.** 2026-09-25 실측 — 키워드 '강남역'을 바이어스 좌표만 바꿔가며: 서울시청 (37.5665,126.978) → 20건 (radius 8km) 강남역 옆 → 20건 (radius 7m) 부산 → 20건 (radius 316km) 제주 → 20건 (radius 446km) (0, 0) → 0건 ← 앱이 보내던 값 lat/lon 생략 → REQ_005 (필수 파라미터) 좌표는 결과 집합이 아니라 거리 표기·정렬에만 쓰인다. 그래서 모를 때는 서비스 지역 중심(서울시청)으로 폴백해도 결과를 잃지 않는다. 생략은 서버가 받지 않으므로 선택지가 아니다. near가 nil인 구간은 실재한다 — SearchViewModel.currentCoordinate는 prefillDepartureWithCurrentLocation()이 끝나야 채워지고, 검색 화면에 들어가자마자 타이핑하면 그 사이 요청이 전부 (0,0)으로 나간다. ## 빈 결과를 1시간 캐싱하던 문제 (#372에서 내가 넣은 결함) 빈 배열도 성공 응답이라 캐시에 박혔다. 한 번 비어서 온 키워드는 원인이 사라진 뒤에도 TTL 내내 비어 보인다 — 화면이 스스로 회복하지 못한다. 실패를 캐시하지 않는 이유와 같은 이유다. ## 경로 조회의 정상 상태가 에러로 흐르던 문제 2026-09-25 실측: TRS_011 출발지와 도착지 간 거리가 너무 가깝습니다 (도보권) TRS_012 서비스 지역이 아닙니다: (129.0415, 35.1151) normalizedResult가 빈 switch라 둘 다 throw되어 화면에 "검색에 실패했어요"만 떴다 — 사용자는 무엇을 바꿔야 하는지 알 수 없었다. TRS_011 → .noRoute, TRS_012 → 신설 .outOfServiceRegion(재검색 버튼 없이 "서울·경기·인천에서만 이용 가능" 안내. 같은 목적지로 다시 눌러도 결과가 같으므로). ## 위치 진단 로그 위치가 안 잡히는 증상은 원인이 여럿인데(권한 미결정 대기 / 거부 / 전역 OFF / 스트림 조기 종료) 밖에서는 전부 "출발지가 비어 있다"로 보인다. 업데이트별 플래그를 DEBUG 로그로 남긴다 — 이번 원인 규명이 이 로그로 끝났다. 좌표는 민감 정보라 남기지 않는다. 검증: 3구성 빌드 + Domain 119 · AtchaData 68 · 전 스킴 통과. 시뮬레이터 신규 설치 기행(XCUITest)에서 검색 20건 → 경로 → 등록(204) → 해제(204)까지 끝까지 이어짐. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 ++++ .../Adapters/CoreLocationServiceAdapter.swift | 21 ++++++++++++++++ .../Data/Sources/Network/PlaceEndpoint.swift | 14 +++++++---- .../Repositories/CachingPlaceRepository.swift | 12 +++++++--- .../Tests/CachingPlaceRepositoryTests.swift | 24 +++++++++++++++++++ Projects/Data/Tests/PlaceEndpointTests.swift | 11 ++++++--- .../Domain/Sources/Entities/Coordinate.swift | 10 ++++++++ .../Entities/LastRouteSearchResult.swift | 5 +++- .../UseCases/SearchLastRoutesUseCase.swift | 14 ++++++++--- .../DefaultSearchLastRoutesUseCaseTests.swift | 24 +++++++++++++++++++ .../Home/Sources/HomeViewController.swift | 2 ++ .../Feature/Home/Sources/HomeViewModel.swift | 4 ++++ .../Search/Sources/SearchViewController.swift | 7 ++++++ .../Search/Sources/SearchViewModel.swift | 9 ++++++- 14 files changed, 145 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 890adc86..17b91e76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift b/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift index a7611018..b895493d 100644 --- a/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift +++ b/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift @@ -1,5 +1,6 @@ import CoreLocation import Domain +import os /// CoreLocation → Domain `LocationService` 어댑터. CoreLocation을 아는 곳은 여기뿐. /// @@ -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는 앱 권한 상태를 무의미하게 만든다). @@ -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, @@ -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 } } diff --git a/Projects/Data/Sources/Network/PlaceEndpoint.swift b/Projects/Data/Sources/Network/PlaceEndpoint.swift index c74d2705..d1b69002 100644 --- a/Projects/Data/Sources/Network/PlaceEndpoint.swift +++ b/Projects/Data/Sources/Network/PlaceEndpoint.swift @@ -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)), ] diff --git a/Projects/Data/Sources/Repositories/CachingPlaceRepository.swift b/Projects/Data/Sources/Repositories/CachingPlaceRepository.swift index b73903a3..2ad96e22 100644 --- a/Projects/Data/Sources/Repositories/CachingPlaceRepository.swift +++ b/Projects/Data/Sources/Repositories/CachingPlaceRepository.swift @@ -13,8 +13,9 @@ import Foundation /// | 장소 검색 | 새로 생긴 가게가 안 보인다 | 캐시. POI는 생기고 없어지므로 1시간 | /// | 서비스 지역 판정 | **서비스가 되는데 안 된다고 막는다** | **캐시하지 않음** — 지역 확장이 즉시 반영돼야 하고, 호출 빈도도 집 주소 저장 시 1회뿐이라 아낄 게 없다 | /// -/// 실패는 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도 계속 -/// 실패로 답하는 게 더 나쁘다. +/// 실패도, **빈 결과도** 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도 +/// 계속 실패로 답하는 게 더 나쁘고, 빈 결과를 TTL 내내 박아 두면 원인이 사라져도 화면이 +/// 회복되지 않는다. public struct CachingPlaceRepository: PlaceRepository { private let upstream: any PlaceRepository private let geocodeCache: ExpiringCache @@ -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 } diff --git a/Projects/Data/Tests/CachingPlaceRepositoryTests.swift b/Projects/Data/Tests/CachingPlaceRepositoryTests.swift index 65583331..419d553d 100644 --- a/Projects/Data/Tests/CachingPlaceRepositoryTests.swift +++ b/Projects/Data/Tests/CachingPlaceRepositoryTests.swift @@ -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 } } @@ -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) 결과")] } @@ -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: - 캐시하지 않는 것 /// 실패는 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도 계속 diff --git a/Projects/Data/Tests/PlaceEndpointTests.swift b/Projects/Data/Tests/PlaceEndpointTests.swift index cf894105..6b57b711 100644 --- a/Projects/Data/Tests/PlaceEndpointTests.swift +++ b/Projects/Data/Tests/PlaceEndpointTests.swift @@ -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 diff --git a/Projects/Domain/Sources/Entities/Coordinate.swift b/Projects/Domain/Sources/Entities/Coordinate.swift index 352728b2..ea7db192 100644 --- a/Projects/Domain/Sources/Entities/Coordinate.swift +++ b/Projects/Domain/Sources/Entities/Coordinate.swift @@ -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) } diff --git a/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift b/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift index e1a67df6..0a60a91f 100644 --- a/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift +++ b/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift @@ -3,6 +3,9 @@ public enum LastRouteSearchResult: Sendable, Equatable { case available([LastRoute]) /// 오늘 막차 종료 case serviceEnded - /// 경로 없음 (도보권 등) + /// 경로 없음 (도보권 등) — 서버 `TRS_011`("출발지와 도착지 간 거리가 너무 가깝습니다"). case noRoute + /// 서비스 지역(서울·경기·인천) 밖 — 서버 `TRS_012`. `noRoute`와 회복 경로가 다르다: + /// 다른 경로를 찾아 줄 수 없고, 사용자가 목적지 자체를 바꿔야 한다. + case outOfServiceRegion } diff --git a/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift b/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift index f872c359..55ad1f01 100644 --- a/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift +++ b/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift @@ -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 } } diff --git a/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift b/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift index 1768fe53..5b07b2d2 100644 --- a/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift @@ -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( diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 7d461613..b12bcdc9 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -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( "알람 권한이 꺼져 있어요", diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index e6600bad..40558f7b 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -87,6 +87,8 @@ final class HomeViewModel { case chipServiceEnded /// 칩 재검색 결과 경로 없음(Phase 18). case chipNoRoute + /// 칩 재검색 결과 서비스 지역 밖 — 서버 TRS_012. + case chipOutOfServiceRegion case alarmPermissionNeeded case alarmRegisterFailed case alarmCancelFailed @@ -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 } diff --git a/Projects/Feature/Search/Sources/SearchViewController.swift b/Projects/Feature/Search/Sources/SearchViewController.swift index e15c33be..f43fe923 100644 --- a/Projects/Feature/Search/Sources/SearchViewController.swift +++ b/Projects/Feature/Search/Sources/SearchViewController.swift @@ -249,6 +249,13 @@ final class SearchViewController: UIViewController { message: "출발지나 도착지를 바꿔 보세요", actionTitle: "다시 검색하기" ) + case .outOfServiceRegion: + rows = [] + // 재검색 버튼을 두지 않는다 — 같은 목적지로 다시 눌러도 결과가 같다. + showEmptyState( + title: "아직 갈 수 없는 지역이에요", + message: "앗차는 현재 서울, 경기, 인천에서만 이용 가능해요" + ) case let .failed(message): rows = [] showEmptyState(title: "앗차!", message: message, actionTitle: "다시 시도") diff --git a/Projects/Feature/Search/Sources/SearchViewModel.swift b/Projects/Feature/Search/Sources/SearchViewModel.swift index 4669e7e3..5f9634c2 100644 --- a/Projects/Feature/Search/Sources/SearchViewModel.swift +++ b/Projects/Feature/Search/Sources/SearchViewModel.swift @@ -22,6 +22,8 @@ final class SearchViewModel { case routes(RouteResultsViewData) case serviceEnded case noRoute + /// 서비스 지역 밖 — 재검색을 권할 수 없고 목적지를 바꿔야 한다. + case outOfServiceRegion case failed(message: String) } @@ -215,7 +217,7 @@ final class SearchViewModel { switch state.content { case .failed where departure != nil && arrival != nil: searchRoutes() - case .serviceEnded, .noRoute: + case .serviceEnded, .noRoute, .outOfServiceRegion: // "다시 검색하기" 실동작(Phase 17) — 도착지 슬롯을 비우고 포커스를 넘겨 // 즉시 재검색이 가능하게 한다. 출발지는 유지(대개 현재 위치). arrival = nil @@ -284,6 +286,9 @@ final class SearchViewModel { } private func searchRoutes() { + // 두 슬롯이 다 찬 뒤에만 호출된다(`confirm`이 게이트) — 여기 가드는 방어용이다. + // 출발지가 없는 상태의 사용자 안내는 `confirm`의 else 분기가 이미 한다 + // (포커스를 출발지로 옮기고 최근 검색을 보여준다). guard let start = departure?.coordinate, let end = arrival?.coordinate else { return } searchTask?.cancel() recentTask?.cancel() @@ -309,6 +314,8 @@ final class SearchViewModel { self?.state.content = .serviceEnded case .noRoute: self?.state.content = .noRoute + case .outOfServiceRegion: + self?.state.content = .outOfServiceRegion } } catch { guard !Task.isCancelled else { return }