diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift index 8a65832..c586d95 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift @@ -54,10 +54,19 @@ extension DIContainer { // 채팅 register(ChatRepository.self) { MockChatRepository() } + // 네트워크 + register(APIClient.self, instance: URLSessionAPIClient()) + // 장소 검색/조회 - register(NearbyPlaceRepository.self) { MockNearbyPlaceRepository() } - register(PlaceSearchRepository.self) { MockPlaceSearchRepository() } - register(ReverseGeocodingRepository.self) { MockReverseGeocodingRepository() } + register(NearbyPlaceRepository.self) { + KakaoNearbyPlaceRepository(apiClient: self.resolve(APIClient.self)) + } + register(PlaceSearchRepository.self) { + KakaoPlaceSearchRepository(apiClient: self.resolve(APIClient.self)) + } + register(ReverseGeocodingRepository.self) { + KakaoReverseGeocodingRepository(apiClient: self.resolve(APIClient.self)) + } register(SharedPlaceRepository.self) { MockSharedPlaceRepository() } // 길찾기 diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+Chat.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+Chat.swift index bd8afb4..47a13bb 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+Chat.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+Chat.swift @@ -18,6 +18,13 @@ extension ScreenFactory { getCurrentLocationUseCase: GetCurrentLocationUseCase( repository: container.resolve(LocationRepository.self) ), + getCurrentLocationPlaceUseCase: GetCurrentLocationPlaceUseCase( + locationRepository: container.resolve(LocationRepository.self), + getNearbyPlaceUseCase: GetNearbyPlaceUseCase( + nearbyPlaceRepository: container.resolve(NearbyPlaceRepository.self), + reverseGeocodingRepository: container.resolve(ReverseGeocodingRepository.self) + ) + ), shareLocationUseCase: ShareLocationUseCase(repository: chatRepository), sharePlaceUseCase: SharePlaceUseCase( chatRepository: chatRepository, diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+RouteSearch.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+RouteSearch.swift index 3b8f30a..1a7b21d 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+RouteSearch.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+RouteSearch.swift @@ -12,8 +12,12 @@ extension ScreenFactory { searchRoutesUseCase: SearchRoutesUseCase( repository: container.resolve(RouteSearchRepository.self) ), - getCurrentLocationUseCase: GetCurrentLocationUseCase( - repository: container.resolve(LocationRepository.self) + getCurrentLocationPlaceUseCase: GetCurrentLocationPlaceUseCase( + locationRepository: container.resolve(LocationRepository.self), + getNearbyPlaceUseCase: GetNearbyPlaceUseCase( + nearbyPlaceRepository: container.resolve(NearbyPlaceRepository.self), + reverseGeocodingRepository: container.resolve(ReverseGeocodingRepository.self) + ) ) ) return RouteSearchViewController(viewModel: viewModel, departure: departure, destination: destination) diff --git a/WhereAreYou/WhereAreYou/Core/Info.plist b/WhereAreYou/WhereAreYou/Core/Info.plist index 68dd34a..293cb0c 100644 --- a/WhereAreYou/WhereAreYou/Core/Info.plist +++ b/WhereAreYou/WhereAreYou/Core/Info.plist @@ -4,6 +4,8 @@ NMFClientId $(NMF_CLIENT_ID) + KakaoRestAPIKey + $(KAKAO_REST_API_KEY) UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/WhereAreYou/WhereAreYou/Data/CoreLocationRepository.swift b/WhereAreYou/WhereAreYou/Data/CoreLocationRepository.swift index dab071d..2684979 100644 --- a/WhereAreYou/WhereAreYou/Data/CoreLocationRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/CoreLocationRepository.swift @@ -29,6 +29,8 @@ final class CoreLocationRepository: NSObject, LocationRepository { } func getCurrentLocation(completion: @escaping (Result) -> Void) { + // 이전 요청이 끝나기 전 재호출 시 이전 completion을 실패로 마무리 (async 변환 시 continuation 누수 방지) + self.completion?(.failure(LocationError.unableToFetch)) self.completion = completion switch locationManager.authorizationStatus { diff --git a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift new file mode 100644 index 0000000..a271cda --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift @@ -0,0 +1,102 @@ +// +// KakaoNearbyPlaceRepository.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// 좌표 반경 내 가장 가까운 장소 1개 조회 (지도 장소 선택, 현재 위치 표시용, 키워드 검색과 무관) +/// 카테고리 검색이 요청당 카테고리 1개만 지원해, 서비스가 다루는 카테고리를 병렬 요청 후 가장 가까운 결과 선택 +/// 지도 이동 1회당 카테고리 수만큼 호출 발생 — PlaceSelectionViewModel의 디바운스/최소 이동 거리로 호출량 제한 +final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { + + /// 카카오 API가 허용하는 최대 반경(m) + private static let maximumRadiusMeters = 20000 + + /// 조회 대상 카테고리 — 검색 화면 필터와 무관, PlaceType 매핑 대상만 포함 + private static let categoryCodes: [KakaoCategoryGroupCode] = [ + .subwayStation, .restaurant, .cafe, .hospital, .largeMart, .convenienceStore + ] + + private let apiClient: APIClient + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + /// 카테고리 하나 조회 결과 — 결과 없음(정상)과 요청 실패를 구분해 상위에서 정책을 결정할 수 있게 함 + private enum CategoryResult { + case found(Place, distanceMeters: Double) + case notFound + case failed(Error) + } + + func fetchNearbyPlace(coordinate: Coordinate, radiusKm: Double) async throws -> Place? { + let radiusMeters = min(Int(radiusKm * 1000), Self.maximumRadiusMeters) + + let results = try await withThrowingTaskGroup(of: CategoryResult.self) { group in + for code in Self.categoryCodes { + group.addTask { + try await self.nearestDocument(code: code, coordinate: coordinate, radiusMeters: radiusMeters) + } + } + var collected: [CategoryResult] = [] + for try await result in group { + collected.append(result) + } + return collected + } + + // 카테고리 중 하나라도 성공(결과 유무 무관)했으면 그 결과로 판단. + // 전부 요청 실패였을 때만 대표로 첫 실패를 throw — 상위(GetNearbyPlaceUseCase)가 역지오코딩 폴백 여부를 판단할 신호로 사용 + var best: (place: Place, distance: Double)? + var firstFailure: Error? + var hasSucceeded = false + + for result in results { + switch result { + case .found(let place, let distance): + hasSucceeded = true + if best == nil || distance < best!.distance { + best = (place, distance) + } + case .notFound: + hasSucceeded = true + case .failed(let error): + if firstFailure == nil { firstFailure = error } + } + } + + if !hasSucceeded, let firstFailure { + throw firstFailure + } + return best?.place + } + + // MARK: - Private + + /// 카테고리 하나를 조회해 결과 유무/실패를 구분해 반환. 취소는 흡수하지 않고 그대로 다시 throw + private func nearestDocument( + code: KakaoCategoryGroupCode, + coordinate: Coordinate, + radiusMeters: Int + ) async throws -> CategoryResult { + do { + let response: KakaoPlaceResponse = try await apiClient.request( + KakaoLocalEndpoint.category(code: code, coordinate: coordinate, radiusMeters: radiusMeters) + ) + guard let document = response.documents.first, + let place = document.toPlace(), + let distance = document.distanceMeters else { + return .notFound + } + return .found(place, distanceMeters: distance) + } catch is CancellationError { + throw CancellationError() + } catch { + return .failed(error) + } + } +} diff --git a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift new file mode 100644 index 0000000..a27041e --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift @@ -0,0 +1,25 @@ +// +// KakaoPlaceSearchRepository.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// 카카오 로컬 API 기반 키워드 장소 검색 +final class KakaoPlaceSearchRepository: PlaceSearchRepository { + + private let apiClient: APIClient + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func searchPlaces(keyword: String) async throws -> [Place] { + let response: KakaoPlaceResponse = try await apiClient.request( + KakaoLocalEndpoint.keyword(query: keyword) + ) + return response.documents.compactMap { $0.toPlace() } + } +} diff --git a/WhereAreYou/WhereAreYou/Data/KakaoReverseGeocodingRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoReverseGeocodingRepository.swift new file mode 100644 index 0000000..a34c87f --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/KakaoReverseGeocodingRepository.swift @@ -0,0 +1,29 @@ +// +// KakaoReverseGeocodingRepository.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// 카카오 로컬 API 기반 역지오코딩 +final class KakaoReverseGeocodingRepository: ReverseGeocodingRepository { + + private let apiClient: APIClient + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func reverseGeocode(coordinate: Coordinate) async throws -> Place { + do { + let response: KakaoAddressResponse = try await apiClient.request( + KakaoLocalEndpoint.coord2address(coordinate: coordinate) + ) + return try response.toPlace(coordinate: coordinate) + } catch let error as NetworkError { + throw error.appError + } + } +} diff --git a/WhereAreYou/WhereAreYou/Data/MockNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/MockNearbyPlaceRepository.swift index 28ab06e..b81ade9 100644 --- a/WhereAreYou/WhereAreYou/Data/MockNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockNearbyPlaceRepository.swift @@ -9,18 +9,13 @@ import Foundation final class MockNearbyPlaceRepository: NearbyPlaceRepository { - func fetchNearbyPlace( - coordinate: Coordinate, - radiusKm: Double, - completion: @escaping (Result) -> Void - ) { + func fetchNearbyPlace(coordinate: Coordinate, radiusKm: Double) async throws -> Place? { let nearest = Self.mockPlaces .filter { $0.coordinate.distance(to: coordinate) <= radiusKm } .min { $0.coordinate.distance(to: coordinate) < $1.coordinate.distance(to: coordinate) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - completion(.success(nearest)) - } + try await Task.sleep(for: .milliseconds(300)) + return nearest } private static let mockPlaces: [Place] = [ diff --git a/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift index cd4f755..54ecc32 100644 --- a/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift @@ -9,22 +9,10 @@ import Foundation final class MockPlaceSearchRepository: PlaceSearchRepository { - func searchPlaces( - keyword: String, - completion: @escaping (Result<[Place], Error>) -> Void - ) { + func searchPlaces(keyword: String) async throws -> [Place] { let trimmed = keyword.trimmingCharacters(in: .whitespaces) - let results: [Place] - - if trimmed.isEmpty { - results = [] - } else { - results = Self.mockPlaces - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - completion(.success(results)) - } + try await Task.sleep(for: .milliseconds(300)) + return trimmed.isEmpty ? [] : Self.mockPlaces } private static let mockPlaces: [Place] = [ diff --git a/WhereAreYou/WhereAreYou/Data/MockReverseGeocodingRepository.swift b/WhereAreYou/WhereAreYou/Data/MockReverseGeocodingRepository.swift index b36b9a3..2ffb2a9 100644 --- a/WhereAreYou/WhereAreYou/Data/MockReverseGeocodingRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockReverseGeocodingRepository.swift @@ -9,10 +9,7 @@ import Foundation final class MockReverseGeocodingRepository: ReverseGeocodingRepository { - func reverseGeocode( - coordinate: Coordinate, - completion: @escaping (Result) -> Void - ) { + func reverseGeocode(coordinate: Coordinate) async throws -> Place { let (roadName, fullAddress) = Self.mockAddress(near: coordinate) let place = Place( id: "address_\(coordinate.latitude)_\(coordinate.longitude)", @@ -22,9 +19,8 @@ final class MockReverseGeocodingRepository: ReverseGeocodingRepository { type: .other ) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - completion(.success(place)) - } + try await Task.sleep(for: .milliseconds(300)) + return place } /// 실제 API의 도로명주소 응답 형태를 흉내낸 더미 주소 생성 diff --git a/WhereAreYou/WhereAreYou/Data/Network/APIClient.swift b/WhereAreYou/WhereAreYou/Data/Network/APIClient.swift new file mode 100644 index 0000000..9c64bd4 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/APIClient.swift @@ -0,0 +1,61 @@ +// +// APIClient.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// Endpoint 기반 네트워크 요청 실행기 +protocol APIClient { + func request(_ endpoint: Endpoint) async throws -> T +} + +/// URLSession 기반 APIClient 구현 +final class URLSessionAPIClient: APIClient { + + private let session: URLSession + private let decoder: JSONDecoder + + init(session: URLSession = .shared, decoder: JSONDecoder = JSONDecoder()) { + self.session = session + self.decoder = decoder + } + + func request(_ endpoint: Endpoint) async throws -> T { + let urlRequest: URLRequest + do { + urlRequest = try endpoint.makeURLRequest() + } catch { + throw (error as? NetworkError ?? .invalidURL).appError + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: urlRequest) + } catch { + throw NetworkError.transport(error).appError + } + + return try decode(data: data, response: response) + } + + // MARK: - Private + + /// 응답 검증 및 디코딩 + private func decode(data: Data, response: URLResponse) throws -> T { + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse.appError + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw NetworkError.httpStatus(httpResponse.statusCode).appError + } + do { + return try decoder.decode(T.self, from: data) + } catch { + throw NetworkError.decoding(error).appError + } + } +} diff --git a/WhereAreYou/WhereAreYou/Data/Network/APIKey.swift b/WhereAreYou/WhereAreYou/Data/Network/APIKey.swift new file mode 100644 index 0000000..13df343 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/APIKey.swift @@ -0,0 +1,31 @@ +// +// APIKey.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// Info.plist에 등록된 외부 API 키를 한 곳에서 읽어오기 위한 타입 +enum APIKey { + + /// 카카오 로컬 API 호출에 사용하는 REST API 키 + static var kakaoRestAPIKey: String { + value(forInfoDictionaryKey: "KakaoRestAPIKey") + } + + // MARK: - Private + + /// Info.plist에서 키 값을 조회 + /// Config.xcconfig에 키가 설정되지 않았을 때 assertionFailure로 알림 + private static func value(forInfoDictionaryKey key: String) -> String { + guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String, + !value.isEmpty, + !value.hasPrefix("$(") else { + assertionFailure("Info.plist에 \(key) 값이 설정되어 있지 않습니다. Config.xcconfig를 확인하세요.") + return "" + } + return value + } +} diff --git a/WhereAreYou/WhereAreYou/Data/Network/Endpoint.swift b/WhereAreYou/WhereAreYou/Data/Network/Endpoint.swift new file mode 100644 index 0000000..7625c05 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/Endpoint.swift @@ -0,0 +1,38 @@ +// +// Endpoint.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// HTTP 메서드 +enum HTTPMethod: String { + case get = "GET" +} + +/// API 요청 하나를 표현하는 타입 +protocol Endpoint { + var baseURL: String { get } + var path: String { get } + var method: HTTPMethod { get } + var queryItems: [URLQueryItem] { get } + var headers: [String: String] { get } +} + +extension Endpoint { + + /// URLRequest 생성 + func makeURLRequest() throws -> URLRequest { + var components = URLComponents(string: baseURL + path) + components?.queryItems = queryItems.isEmpty ? nil : queryItems + guard let url = components?.url else { + throw NetworkError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } + return request + } +} diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift new file mode 100644 index 0000000..442ccb3 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift @@ -0,0 +1,62 @@ +// +// KakaoAddressResponse.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// 카카오 좌표 -> 주소 변환 응답 +struct KakaoAddressResponse: Decodable { + let documents: [KakaoAddressDocument] +} + +/// 카카오 좌표 -> 주소 변환 결과 문서 +struct KakaoAddressDocument: Decodable { + let roadAddress: KakaoRoadAddress? + let address: KakaoAddress? + + enum CodingKeys: String, CodingKey { + case roadAddress = "road_address" + case address + } +} + +/// 도로명주소 +struct KakaoRoadAddress: Decodable { + let addressName: String + + enum CodingKeys: String, CodingKey { + case addressName = "address_name" + } +} + +/// 지번주소 +struct KakaoAddress: Decodable { + let addressName: String + + enum CodingKeys: String, CodingKey { + case addressName = "address_name" + } +} + +extension KakaoAddressResponse { + + /// Place로 변환, 도로명 우선 없으면 지번, 결과 없으면 emptyResult + func toPlace(coordinate: Coordinate) throws -> Place { + guard let document = documents.first else { + throw NetworkError.emptyResult + } + guard let address = document.roadAddress?.addressName ?? document.address?.addressName else { + throw NetworkError.emptyResult + } + return Place( + id: "address_\(coordinate.latitude)_\(coordinate.longitude)", + name: address, + address: address, + coordinate: coordinate, + type: .other + ) + } +} diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift new file mode 100644 index 0000000..3ddae65 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift @@ -0,0 +1,30 @@ +// +// KakaoCategoryGroupCode.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// 카카오 로컬 API 카테고리 그룹 코드 전체 +enum KakaoCategoryGroupCode: String { + case largeMart = "MT1" // 대형마트 + case convenienceStore = "CS2" // 편의점 + case kindergarten = "PS3" // 어린이집, 유치원 + case school = "SC4" // 학교 + case academy = "AC5" // 학원 + case parkingLot = "PK6" // 주차장 + case gasStation = "OL7" // 주유소, 충전소 + case subwayStation = "SW8" // 지하철역 + case bank = "BK9" // 은행 + case culturalFacility = "CT1" // 문화시설 + case brokerage = "AG2" // 중개업소 + case publicInstitution = "PO3" // 공공기관 + case attraction = "AT4" // 관광명소 + case accommodation = "AD5" // 숙박 + case restaurant = "FD6" // 음식점 + case cafe = "CE7" // 카페 + case hospital = "HP8" // 병원 + case pharmacy = "PM9" // 약국 +} diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift new file mode 100644 index 0000000..2e62af8 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift @@ -0,0 +1,63 @@ +// +// KakaoLocalEndpoint.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// 카카오 로컬 API 요청 +enum KakaoLocalEndpoint { + /// 키워드로 장소 검색 + case keyword(query: String) + /// 좌표 반경 내 카테고리 검색, 거리순 정렬 + case category(code: KakaoCategoryGroupCode, coordinate: Coordinate, radiusMeters: Int) + /// 좌표 -> 주소 변환 + case coord2address(coordinate: Coordinate) +} + +extension KakaoLocalEndpoint: Endpoint { + + var baseURL: String { "https://dapi.kakao.com" } + + var path: String { + switch self { + case .keyword: + return "/v2/local/search/keyword.json" + case .category: + return "/v2/local/search/category.json" + case .coord2address: + return "/v2/local/geo/coord2address.json" + } + } + + var method: HTTPMethod { .get } + + var queryItems: [URLQueryItem] { + switch self { + case .keyword(let query): + return [ + URLQueryItem(name: "query", value: query), + URLQueryItem(name: "size", value: "15") + ] + case .category(let code, let coordinate, let radiusMeters): + return [ + URLQueryItem(name: "category_group_code", value: code.rawValue), + URLQueryItem(name: "x", value: "\(coordinate.longitude)"), + URLQueryItem(name: "y", value: "\(coordinate.latitude)"), + URLQueryItem(name: "radius", value: "\(radiusMeters)"), + URLQueryItem(name: "sort", value: "distance") + ] + case .coord2address(let coordinate): + return [ + URLQueryItem(name: "x", value: "\(coordinate.longitude)"), + URLQueryItem(name: "y", value: "\(coordinate.latitude)") + ] + } + } + + var headers: [String: String] { + ["Authorization": "KakaoAK \(APIKey.kakaoRestAPIKey)"] + } +} diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceResponse.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceResponse.swift new file mode 100644 index 0000000..fc524ef --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceResponse.swift @@ -0,0 +1,80 @@ +// +// KakaoPlaceResponse.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// 카카오 키워드/카테고리 검색 공통 응답 +struct KakaoPlaceResponse: Decodable { + let documents: [KakaoPlaceDocument] +} + +/// 카카오 검색 결과 문서 +struct KakaoPlaceDocument: Decodable { + let id: String + let placeName: String + let categoryGroupCode: String + let categoryName: String + let addressName: String + let roadAddressName: String + let x: String + let y: String + /// 중심 좌표로부터의 거리(m) — 카테고리 검색(x,y 파라미터 포함) 응답에만 값이 채워짐 + let distance: String? + + enum CodingKeys: String, CodingKey { + case id + case placeName = "place_name" + case categoryGroupCode = "category_group_code" + case categoryName = "category_name" + case addressName = "address_name" + case roadAddressName = "road_address_name" + case x + case y + case distance + } +} + +extension KakaoPlaceDocument { + + /// 중심 좌표로부터의 거리(m), 파싱 실패 시 nil + var distanceMeters: Double? { + distance.flatMap(Double.init) + } + + /// Place로 변환, 좌표 파싱 실패 시 nil + func toPlace() -> Place? { + guard let longitude = Double(x), let latitude = Double(y) else { + return nil + } + let address = roadAddressName.isEmpty ? addressName : roadAddressName + return Place( + id: id, + name: placeName, + address: address, + coordinate: Coordinate(latitude: latitude, longitude: longitude), + type: placeType + ) + } + + /// category_group_code, category_name 기반 PlaceType 매핑 + private var placeType: PlaceType { + switch KakaoCategoryGroupCode(rawValue: categoryGroupCode) { + case .subwayStation: + return .subway + case .restaurant: + return .restaurant + case .cafe: + return .cafe + case .hospital: + return .hospital + case .largeMart, .convenienceStore: + return .shop + default: + return categoryName.contains("기차역") ? .station : .other + } + } +} diff --git a/WhereAreYou/WhereAreYou/Data/Network/NetworkError.swift b/WhereAreYou/WhereAreYou/Data/Network/NetworkError.swift new file mode 100644 index 0000000..2c116f6 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/NetworkError.swift @@ -0,0 +1,50 @@ +// +// NetworkError.swift +// WhereAreYou +// +// Created by 김성훈 on 9/29/26. +// + +import Foundation + +/// URLSession 기반 네트워크 요청에서 발생하는 에러 +enum NetworkError: Error { + + /// Endpoint로부터 URLRequest 생성 실패 + case invalidURL + /// HTTPURLResponse가 아닌 응답 + case invalidResponse + /// 2xx가 아닌 상태 코드 + case httpStatus(Int) + /// 응답 바디 디코딩 실패 + case decoding(Error) + /// URLSession 자체의 전송 실패 + case transport(Error) + /// 응답 자체는 성공했으나 원하는 결과가 없음 + case emptyResult +} + +extension NetworkError { + + /// AppError로 변환 + var appError: AppError { + switch self { + case .invalidURL, .invalidResponse, .decoding: + return .unknown(self) + case .httpStatus(let code): + switch code { + case 401, 403: + return .permissionDenied + case 404: + return .notFound + default: + return .unknown(self) + } + case .transport(let error): + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain ? .network : .unknown(error) + case .emptyResult: + return .notFound + } + } +} diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/NearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/NearbyPlaceRepository.swift index 699d0a6..db2a5db 100644 --- a/WhereAreYou/WhereAreYou/Domain/Repository/NearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Domain/Repository/NearbyPlaceRepository.swift @@ -7,10 +7,10 @@ protocol NearbyPlaceRepository { - func fetchNearbyPlace( - coordinate: Coordinate, - radiusKm: Double, - completion: @escaping (Result) -> Void - ) + /// 좌표 반경 내 가장 가까운 장소 1개를 조회한다 + /// 조회 자체는 성공했으나 반경 내 장소가 없으면 nil을 반환한다 (에러 아님) + /// 내부적으로 여러 하위 조회로 나뉘는 구현이라면, 그중 일부만 실패했을 때 나머지 결과로 판단하고 + /// throw는 조회 자체가 전혀 성립하지 않았을 때(예: 전부 실패)로 제한해야 한다 + func fetchNearbyPlace(coordinate: Coordinate, radiusKm: Double) async throws -> Place? } diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift index 4830fac..21b2fba 100644 --- a/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift @@ -7,9 +7,6 @@ protocol PlaceSearchRepository { - func searchPlaces( - keyword: String, - completion: @escaping (Result<[Place], Error>) -> Void - ) + func searchPlaces(keyword: String) async throws -> [Place] } diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/ReverseGeocodingRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/ReverseGeocodingRepository.swift index 665d497..7f46e6e 100644 --- a/WhereAreYou/WhereAreYou/Domain/Repository/ReverseGeocodingRepository.swift +++ b/WhereAreYou/WhereAreYou/Domain/Repository/ReverseGeocodingRepository.swift @@ -7,9 +7,6 @@ protocol ReverseGeocodingRepository { - func reverseGeocode( - coordinate: Coordinate, - completion: @escaping (Result) -> Void - ) + func reverseGeocode(coordinate: Coordinate) async throws -> Place } diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/GetCurrentLocationPlaceUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/GetCurrentLocationPlaceUseCase.swift new file mode 100644 index 0000000..7e7a1b5 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/GetCurrentLocationPlaceUseCase.swift @@ -0,0 +1,45 @@ +// +// GetCurrentLocationPlaceUseCase.swift +// WhereAreYou +// +// Created by 김성훈 on 10/2/26. +// + +final class GetCurrentLocationPlaceUseCase { + + private let locationRepository: LocationRepository + private let getNearbyPlaceUseCase: GetNearbyPlaceUseCase + + init( + locationRepository: LocationRepository, + getNearbyPlaceUseCase: GetNearbyPlaceUseCase + ) { + self.locationRepository = locationRepository + self.getNearbyPlaceUseCase = getNearbyPlaceUseCase + } + + /// 현재 좌표 조회 후 근처 장소 조회 — 좌표 조회 실패는 throw, 장소 조회 실패는 nil로 대체 + func execute() async throws -> (coordinate: Coordinate, place: Place?) { + let coordinate = try await currentCoordinate() + + let place: Place? + do { + place = try await getNearbyPlaceUseCase.execute(coordinate: coordinate) + } catch is CancellationError { + throw CancellationError() + } catch { + place = nil + } + return (coordinate, place) + } + + // MARK: - Private + + /// completion 기반 현재 위치 조회를 async로 변환 + private func currentCoordinate() async throws -> Coordinate { + try await withCheckedThrowingContinuation { continuation in + locationRepository.getCurrentLocation { continuation.resume(with: $0) } + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/GetNearbyPlaceUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/GetNearbyPlaceUseCase.swift index 8c49b15..945f79d 100644 --- a/WhereAreYou/WhereAreYou/Domain/UseCase/GetNearbyPlaceUseCase.swift +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/GetNearbyPlaceUseCase.swift @@ -22,28 +22,22 @@ final class GetNearbyPlaceUseCase { } /// 좌표 기준 POI를 우선 조회하고, 없으면 역지오코딩 주소로 대체한다 - func execute( - coordinate: Coordinate, - completion: @escaping (Result) -> Void - ) { - nearbyPlaceRepository.fetchNearbyPlace( - coordinate: coordinate, - radiusKm: Self.maximumDistanceKm - ) { [weak self] result in - guard let self else { return } - switch result { - case .success(let place) where place != nil: - completion(.success(place)) - case .success: - self.reverseGeocodingRepository.reverseGeocode(coordinate: coordinate) { geocodeResult in - completion(geocodeResult.map { $0 }) - } - case .failure: - self.reverseGeocodingRepository.reverseGeocode(coordinate: coordinate) { geocodeResult in - completion(geocodeResult.map { $0 }) - } - } + func execute(coordinate: Coordinate) async throws -> Place? { + let nearbyPlace: Place? + do { + nearbyPlace = try await nearbyPlaceRepository.fetchNearbyPlace( + coordinate: coordinate, + radiusKm: Self.maximumDistanceKm + ) + } catch is CancellationError { + throw CancellationError() + } catch { + nearbyPlace = nil + } + if let nearbyPlace { + return nearbyPlace } + return try await reverseGeocodingRepository.reverseGeocode(coordinate: coordinate) } } diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift index 4f735cd..7a8c044 100644 --- a/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift @@ -13,11 +13,8 @@ final class SearchPlacesUseCase { self.repository = repository } - func execute( - keyword: String, - completion: @escaping (Result<[Place], Error>) -> Void - ) { - repository.searchPlaces(keyword: keyword, completion: completion) + func execute(keyword: String) async throws -> [Place] { + try await repository.searchPlaces(keyword: keyword) } } diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift index 0f58ad0..b5a8f8b 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift @@ -28,14 +28,17 @@ final class ChatViewModel { private let fetchMessagesUseCase: FetchMessagesUseCase private let sendMessageUseCase: SendMessageUseCase private let getCurrentLocationUseCase: GetCurrentLocationUseCase + private let getCurrentLocationPlaceUseCase: GetCurrentLocationPlaceUseCase private let shareLocationUseCase: ShareLocationUseCase private let sharePlaceUseCase: SharePlaceUseCase + private var fetchCurrentLocationTask: Task? init( appointmentInfo: AppointmentInfo, fetchMessagesUseCase: FetchMessagesUseCase, sendMessageUseCase: SendMessageUseCase, getCurrentLocationUseCase: GetCurrentLocationUseCase, + getCurrentLocationPlaceUseCase: GetCurrentLocationPlaceUseCase, shareLocationUseCase: ShareLocationUseCase, sharePlaceUseCase: SharePlaceUseCase ) { @@ -43,10 +46,15 @@ final class ChatViewModel { self.fetchMessagesUseCase = fetchMessagesUseCase self.sendMessageUseCase = sendMessageUseCase self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.getCurrentLocationPlaceUseCase = getCurrentLocationPlaceUseCase self.shareLocationUseCase = shareLocationUseCase self.sharePlaceUseCase = sharePlaceUseCase } + deinit { + fetchCurrentLocationTask?.cancel() + } + func fetchMessages() { fetchMessagesUseCase.execute(appointmentID: appointmentInfo.id) { [weak self] result in guard let self else { return } @@ -86,14 +94,11 @@ final class ChatViewModel { // MARK: - Location Share func fetchCurrentLocation(completion: @escaping (Coordinate, String) -> Void) { - getCurrentLocationUseCase.execute { result in - DispatchQueue.main.async { - if case .success(let coordinate) = result { - // TODO: CLGeocoder로 실제 주소 변환 - let address = "서울특별시 중구 세종대로 110" - completion(coordinate, address) - } - } + fetchCurrentLocationTask?.cancel() + fetchCurrentLocationTask = Task { @MainActor [weak self] in + guard let self, let result = try? await self.getCurrentLocationPlaceUseCase.execute() else { return } + guard !Task.isCancelled else { return } + completion(result.coordinate, result.place?.name ?? "현재 위치") } } diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift index 64938da..dbae9c4 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift @@ -164,8 +164,12 @@ final class SearchPlaceCard: UIView { } } - func updateEmptyResultMessage(hasSearched: Bool) { - emptyResultLabel.text = hasSearched ? "검색 결과가 존재하지 않습니다." : "장소를 검색해주세요." + func updateEmptyResultMessage(hasSearched: Bool, errorMessage: String?) { + if let errorMessage { + emptyResultLabel.text = errorMessage + } else { + emptyResultLabel.text = hasSearched ? "검색 결과가 존재하지 않습니다." : "장소를 검색해주세요." + } } // MARK: - Result Cells diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift index 105e937..13af971 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift @@ -203,9 +203,10 @@ final class SearchPlaceCardViewController: UIViewController { .store(in: &cancellables) viewModel.$hasSearched + .combineLatest(viewModel.$errorMessage) .receive(on: DispatchQueue.main) - .sink { [weak self] hasSearched in - self?.searchCard.updateEmptyResultMessage(hasSearched: hasSearched) + .sink { [weak self] hasSearched, errorMessage in + self?.searchCard.updateEmptyResultMessage(hasSearched: hasSearched, errorMessage: errorMessage) } .store(in: &cancellables) } diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift index 4472a95..d488dd5 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift @@ -14,25 +14,54 @@ final class SearchPlaceCardViewModel { @Published private(set) var selectedFilters: [PlaceType] = [] @Published private(set) var isSearching = false @Published private(set) var hasSearched = false + @Published private(set) var errorMessage: String? private var allPlaces: [Place] = [] private let searchPlacesUseCase: SearchPlacesUseCase + private var searchTask: Task? init(searchPlacesUseCase: SearchPlacesUseCase) { self.searchPlacesUseCase = searchPlacesUseCase } + deinit { + searchTask?.cancel() + } + func search(keyword: String) { + searchTask?.cancel() + + let trimmed = keyword.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + isSearching = false + hasSearched = false + errorMessage = nil + allPlaces = [] + filteredPlaces = [] + return + } + isSearching = true - searchPlacesUseCase.execute(keyword: keyword) { [weak self] result in + errorMessage = nil + searchTask = Task { @MainActor [weak self] in guard let self else { return } - DispatchQueue.main.async { + do { + let places = try await self.searchPlacesUseCase.execute(keyword: keyword) + guard !Task.isCancelled else { return } + self.isSearching = false + self.hasSearched = true + self.errorMessage = nil + self.allPlaces = places + self.applyFilter() + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } self.isSearching = false self.hasSearched = true - if case .success(let places) = result { - self.allPlaces = places - self.applyFilter() - } + self.errorMessage = (error as? AppError)?.errorDescription ?? "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + self.allPlaces = [] + self.filteredPlaces = [] } } } @@ -56,9 +85,11 @@ final class SearchPlaceCardViewModel { } func resetAll() { + searchTask?.cancel() filteredPlaces = [] selectedFilters = [] hasSearched = false + errorMessage = nil allPlaces = [] } diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift index d57dea8..78d681d 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift @@ -26,6 +26,8 @@ final class PlaceSelectionViewModel { private var cancellables = Set() /// 마지막으로 실제 조회를 실행했던 좌표 private var lastFetchedCoordinate: Coordinate? + /// 진행 중인 근처 장소 조회 Task — 새 좌표가 들어오면 이전 Task를 취소해 응답 경쟁을 막음 + private var nearbyPlaceTask: Task? /// 이 거리(km) 미만으로 움직였을 때는 재조회하지 않음 private static let minimumFetchDistanceKm = 0.02 @@ -48,6 +50,10 @@ final class PlaceSelectionViewModel { bindCenterCoordinate() } + deinit { + nearbyPlaceTask?.cancel() + } + func fetchCurrentLocation() { getCurrentLocationUseCase.execute { [weak self] result in guard let self else { return } @@ -79,21 +85,25 @@ final class PlaceSelectionViewModel { } private func fetchNearbyPlace(at coordinate: Coordinate) { + nearbyPlaceTask?.cancel() isFetchingNearbyPlace = true - getNearbyPlaceUseCase.execute(coordinate: coordinate) { [weak self] result in + nearbyPlaceTask = Task { @MainActor [weak self] in guard let self else { return } - DispatchQueue.main.async { + do { + let fetchedPlace = try await self.getNearbyPlaceUseCase.execute(coordinate: coordinate) + guard !Task.isCancelled else { return } self.isFetchingNearbyPlace = false - switch result { - case .success(let fetchedPlace): - self.nearbyPlaceDomain = fetchedPlace - self.nearbyPlace = fetchedPlace.map { PlaceInfo(place: $0) } - self.updateDistanceText(for: fetchedPlace) - case .failure: - self.nearbyPlaceDomain = nil - self.nearbyPlace = nil - self.distanceText = nil - } + self.nearbyPlaceDomain = fetchedPlace + self.nearbyPlace = fetchedPlace.map { PlaceInfo(place: $0) } + self.updateDistanceText(for: fetchedPlace) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + self.isFetchingNearbyPlace = false + self.nearbyPlaceDomain = nil + self.nearbyPlace = nil + self.distanceText = nil } } } diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift index 995b0b4..15d9f3a 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift @@ -23,14 +23,19 @@ final class RouteSearchViewModel { private var routesDomain: [Route] = [] private let searchRoutesUseCase: SearchRoutesUseCase - private let getCurrentLocationUseCase: GetCurrentLocationUseCase + private let getCurrentLocationPlaceUseCase: GetCurrentLocationPlaceUseCase + private var fetchCurrentLocationTask: Task? init( searchRoutesUseCase: SearchRoutesUseCase, - getCurrentLocationUseCase: GetCurrentLocationUseCase + getCurrentLocationPlaceUseCase: GetCurrentLocationPlaceUseCase ) { self.searchRoutesUseCase = searchRoutesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.getCurrentLocationPlaceUseCase = getCurrentLocationPlaceUseCase + } + + deinit { + fetchCurrentLocationTask?.cancel() } func setDeparture(_ place: Place?) { @@ -61,20 +66,18 @@ final class RouteSearchViewModel { } func fetchCurrentLocation() { - getCurrentLocationUseCase.execute { [weak self] result in - guard let self else { return } - DispatchQueue.main.async { - if case .success(let coordinate) = result { - let place = Place( - id: "current_location", - name: "현재 위치", - address: "", - coordinate: coordinate, - type: .other - ) - self.setDeparture(place) - } - } + fetchCurrentLocationTask?.cancel() + fetchCurrentLocationTask = Task { @MainActor [weak self] in + guard let self, let result = try? await self.getCurrentLocationPlaceUseCase.execute() else { return } + guard !Task.isCancelled else { return } + let place = Place( + id: "current_location", + name: result.place?.name ?? "현재 위치", + address: result.place?.address ?? "", + coordinate: result.coordinate, + type: .other + ) + self.setDeparture(place) } }