diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory/ScreenFactory+PlaceSelection.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory/ScreenFactory+PlaceSelection.swift index da0942e..bdd8061 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory/ScreenFactory+PlaceSelection.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory/ScreenFactory+PlaceSelection.swift @@ -16,6 +16,9 @@ extension ScreenFactory { nearbyPlaceRepository: container.resolve(NearbyPlaceRepository.self), reverseGeocodingRepository: container.resolve(ReverseGeocodingRepository.self) ), + getRegionPlaceUseCase: GetRegionPlaceUseCase( + reverseGeocodingRepository: container.resolve(ReverseGeocodingRepository.self) + ), initialCoordinate: initialCoordinate ) return PlaceSelectionViewController(viewModel: viewModel) diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory/ScreenFactory+SearchPlaceCard.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory/ScreenFactory+SearchPlaceCard.swift index a111bd0..132ab93 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory/ScreenFactory+SearchPlaceCard.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory/ScreenFactory+SearchPlaceCard.swift @@ -12,7 +12,11 @@ extension ScreenFactory { style: SearchPlaceCardViewController.Style ) -> SearchPlaceCardViewController { let viewModel = SearchPlaceCardViewModel( - searchPlacesUseCase: SearchPlacesUseCase(repository: container.resolve(PlaceSearchRepository.self)) + searchPlacesUseCase: SearchPlacesUseCase( + repository: container.resolve(PlaceSearchRepository.self), + locationRepository: container.resolve(LocationRepository.self), + locationPermissionRepository: container.resolve(LocationPermissionRepository.self) + ) ) return SearchPlaceCardViewController( viewModel: viewModel, diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/KakaoCategoryGroupCode.swift b/WhereAreYou/WhereAreYou/Data/Kakao/KakaoCategoryGroupCode.swift index 3ddae65..6d615ed 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/KakaoCategoryGroupCode.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/KakaoCategoryGroupCode.swift @@ -28,3 +28,21 @@ enum KakaoCategoryGroupCode: String { case hospital = "HP8" // 병원 case pharmacy = "PM9" // 약국 } + +nonisolated extension KakaoCategoryGroupCode { + + /// 코드 정보가 없는 장소에 적용하는 기본 대표성 가중치 + static let defaultRepresentativeness = 1.0 + + /// 지명을 대표하는 정도 + var representativeness: Double { + switch self { + case .subwayStation, .publicInstitution, .attraction: + return 4.0 + case .culturalFacility, .hospital, .largeMart: + return 2.0 + default: + return Self.defaultRepresentativeness + } + } +} diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/KakaoLocalEndpoint.swift b/WhereAreYou/WhereAreYou/Data/Kakao/KakaoLocalEndpoint.swift index 2e62af8..efcee32 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/KakaoLocalEndpoint.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/KakaoLocalEndpoint.swift @@ -7,10 +7,18 @@ import Foundation +/// 카카오 키워드 검색 정렬 기준 +enum KakaoKeywordSort { + /// 정확도순 (카카오 기본값) + case accuracy + /// 기준 좌표와 가까운 순 + case distance +} + /// 카카오 로컬 API 요청 enum KakaoLocalEndpoint { - /// 키워드로 장소 검색 - case keyword(query: String) + /// 키워드로 장소 검색, 좌표를 넘기면 응답에 거리(m)가 채워짐 + case keyword(query: String, page: Int = 1, coordinate: Coordinate? = nil, sort: KakaoKeywordSort = .accuracy) /// 좌표 반경 내 카테고리 검색, 거리순 정렬 case category(code: KakaoCategoryGroupCode, coordinate: Coordinate, radiusMeters: Int) /// 좌표 -> 주소 변환 @@ -36,11 +44,20 @@ extension KakaoLocalEndpoint: Endpoint { var queryItems: [URLQueryItem] { switch self { - case .keyword(let query): - return [ + case .keyword(let query, let page, let coordinate, let sort): + var items = [ URLQueryItem(name: "query", value: query), - URLQueryItem(name: "size", value: "15") + URLQueryItem(name: "size", value: "15"), + URLQueryItem(name: "page", value: "\(page)") ] + if let coordinate { + items.append(URLQueryItem(name: "x", value: "\(coordinate.longitude)")) + items.append(URLQueryItem(name: "y", value: "\(coordinate.latitude)")) + if sort == .distance { + items.append(URLQueryItem(name: "sort", value: "distance")) + } + } + return items case .category(let code, let coordinate, let radiusMeters): return [ URLQueryItem(name: "category_group_code", value: code.rawValue), diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift index a271cda..f59c25c 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift @@ -7,17 +7,18 @@ import Foundation -/// 좌표 반경 내 가장 가까운 장소 1개 조회 (지도 장소 선택, 현재 위치 표시용, 키워드 검색과 무관) -/// 카테고리 검색이 요청당 카테고리 1개만 지원해, 서비스가 다루는 카테고리를 병렬 요청 후 가장 가까운 결과 선택 +/// 좌표 반경 내 거리와 대표성을 고려한 장소 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 + .subwayStation, .attraction, .culturalFacility, .hospital, .largeMart, + .restaurant, .cafe, .convenienceStore, .school, .parkingLot ] private let apiClient: APIClient @@ -26,9 +27,24 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { self.apiClient = apiClient } + /// 카테고리당 수집하는 후보 수 + private static let candidatesPerCategory = 5 + + /// 선택 대상 후보 + private struct Candidate { + let place: Place + let distanceMeters: Double + let categoryCode: KakaoCategoryGroupCode + + /// 선택 순위, 낮을수록 우선 + var rank: Double { + distanceMeters / categoryCode.representativeness + } + } + /// 카테고리 하나 조회 결과 — 결과 없음(정상)과 요청 실패를 구분해 상위에서 정책을 결정할 수 있게 함 private enum CategoryResult { - case found(Place, distanceMeters: Double) + case found([Candidate]) case notFound case failed(Error) } @@ -39,7 +55,7 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { 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) + try await self.fetchCandidates(code: code, coordinate: coordinate, radiusMeters: radiusMeters) } } var collected: [CategoryResult] = [] @@ -51,17 +67,15 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { // 카테고리 중 하나라도 성공(결과 유무 무관)했으면 그 결과로 판단. // 전부 요청 실패였을 때만 대표로 첫 실패를 throw — 상위(GetNearbyPlaceUseCase)가 역지오코딩 폴백 여부를 판단할 신호로 사용 - var best: (place: Place, distance: Double)? + var candidates: [Candidate] = [] var firstFailure: Error? var hasSucceeded = false for result in results { switch result { - case .found(let place, let distance): + case .found(let found): hasSucceeded = true - if best == nil || distance < best!.distance { - best = (place, distance) - } + candidates.append(contentsOf: found) case .notFound: hasSucceeded = true case .failed(let error): @@ -72,13 +86,13 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { if !hasSucceeded, let firstFailure { throw firstFailure } - return best?.place + return candidates.min { $0.rank < $1.rank }?.place } // MARK: - Private - /// 카테고리 하나를 조회해 결과 유무/실패를 구분해 반환. 취소는 흡수하지 않고 그대로 다시 throw - private func nearestDocument( + /// 카테고리 하나의 상위 후보를 조회해 결과 유무/실패를 구분해 반환 + private func fetchCandidates( code: KakaoCategoryGroupCode, coordinate: Coordinate, radiusMeters: Int @@ -87,12 +101,16 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { 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) + let candidates = response.documents + .prefix(Self.candidatesPerCategory) + .compactMap { document -> Candidate? in + guard let place = document.toPlace(), + let distance = document.distanceMeters else { + return nil + } + return Candidate(place: place, distanceMeters: distance, categoryCode: code) + } + return candidates.isEmpty ? .notFound : .found(candidates) } catch is CancellationError { throw CancellationError() } catch { diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoPlaceSearchRepository.swift index a27041e..221e1d4 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoPlaceSearchRepository.swift @@ -16,10 +16,57 @@ final class KakaoPlaceSearchRepository: PlaceSearchRepository { self.apiClient = apiClient } - func searchPlaces(keyword: String) async throws -> [Place] { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + async let firstPage = fetchDocuments(keyword: keyword, page: 1, coordinate: coordinate) + async let secondPage = fetchDocumentsIgnoringFailure(keyword: keyword, page: 2, coordinate: coordinate) + async let nearestPage = fetchNearestDocuments(keyword: keyword, coordinate: coordinate) + + let documents = try await firstPage + secondPage + nearestPage + + var seenIDs = Set() + let uniqueDocuments = documents.filter { seenIDs.insert($0.id).inserted } + + return KakaoPlaceRanker.rank(uniqueDocuments, keyword: keyword) + .compactMap { $0.toPlace() } + } + + // MARK: - Private + + private func fetchDocuments( + keyword: String, + page: Int, + coordinate: Coordinate?, + sort: KakaoKeywordSort = .accuracy + ) async throws -> [KakaoPlaceDocument] { let response: KakaoPlaceResponse = try await apiClient.request( - KakaoLocalEndpoint.keyword(query: keyword) + KakaoLocalEndpoint.keyword(query: keyword, page: page, coordinate: coordinate, sort: sort) + ) + return response.documents + } + + /// 보조 페이지 조회 + private func fetchDocumentsIgnoringFailure( + keyword: String, + page: Int, + coordinate: Coordinate?, + sort: KakaoKeywordSort = .accuracy + ) async throws -> [KakaoPlaceDocument] { + do { + return try await fetchDocuments(keyword: keyword, page: page, coordinate: coordinate, sort: sort) + } catch { + try Task.checkCancellation() + return [] + } + } + + /// 기준 좌표에서 가까운 순 후보 조회 + private func fetchNearestDocuments(keyword: String, coordinate: Coordinate?) async throws -> [KakaoPlaceDocument] { + guard let coordinate else { return [] } + return try await fetchDocumentsIgnoringFailure( + keyword: keyword, + page: 1, + coordinate: coordinate, + sort: .distance ) - return response.documents.compactMap { $0.toPlace() } } } diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoReverseGeocodingRepository.swift b/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoReverseGeocodingRepository.swift index a34c87f..8925652 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoReverseGeocodingRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoReverseGeocodingRepository.swift @@ -18,12 +18,25 @@ final class KakaoReverseGeocodingRepository: ReverseGeocodingRepository { func reverseGeocode(coordinate: Coordinate) async throws -> Place { do { - let response: KakaoAddressResponse = try await apiClient.request( - KakaoLocalEndpoint.coord2address(coordinate: coordinate) - ) + let response = try await fetchAddress(coordinate: coordinate) return try response.toPlace(coordinate: coordinate) } catch let error as NetworkError { throw error.appError } } + + func reverseGeocodeRegion(coordinate: Coordinate, level: RegionLevel) async throws -> Place { + do { + let response = try await fetchAddress(coordinate: coordinate) + return try response.toRegionPlace(coordinate: coordinate, level: level) + } catch let error as NetworkError { + throw error.appError + } + } + + // MARK: - Private + + private func fetchAddress(coordinate: Coordinate) async throws -> KakaoAddressResponse { + try await apiClient.request(KakaoLocalEndpoint.coord2address(coordinate: coordinate)) + } } diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoAddressResponse.swift b/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoAddressResponse.swift index 442ccb3..4e957fc 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoAddressResponse.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoAddressResponse.swift @@ -26,23 +26,95 @@ struct KakaoAddressDocument: Decodable { /// 도로명주소 struct KakaoRoadAddress: Decodable { let addressName: String + /// 시/도 + let region1DepthName: String? + /// 시/군/구 + let region2DepthName: String? enum CodingKeys: String, CodingKey { case addressName = "address_name" + case region1DepthName = "region_1depth_name" + case region2DepthName = "region_2depth_name" } } /// 지번주소 struct KakaoAddress: Decodable { let addressName: String + /// 시/도 + let region1DepthName: String? + /// 시/군/구 + let region2DepthName: String? enum CodingKeys: String, CodingKey { case addressName = "address_name" + case region1DepthName = "region_1depth_name" + case region2DepthName = "region_2depth_name" } } extension KakaoAddressResponse { + /// 카카오가 약칭으로 내려주는 시/도명을 정식 명칭으로 변환 + /// 이미 정식 명칭이거나 매핑이 없으면 그대로 사용 + private static let provinceFullNames: [String: String] = [ + "서울": "서울특별시", + "부산": "부산광역시", + "대구": "대구광역시", + "인천": "인천광역시", + "광주": "광주광역시", + "대전": "대전광역시", + "울산": "울산광역시", + "세종": "세종특별자치시", + "경기": "경기도", + "강원": "강원특별자치도", + "충북": "충청북도", + "충남": "충청남도", + "전북": "전북특별자치도", + "전남": "전라남도", + "경북": "경상북도", + "경남": "경상남도", + "제주": "제주특별자치도", + ] + + /// 지정한 행정구역 단위의 Place로 변환, 지역명이 없으면 emptyResult + func toRegionPlace(coordinate: Coordinate, level: RegionLevel) throws -> Place { + guard let document = documents.first else { + throw NetworkError.emptyResult + } + let rawProvince = document.address?.region1DepthName ?? document.roadAddress?.region1DepthName + let city = document.address?.region2DepthName ?? document.roadAddress?.region2DepthName + + guard let rawProvince, !rawProvince.isEmpty else { + throw NetworkError.emptyResult + } + let province = Self.provinceFullNames[rawProvince] ?? rawProvince + let name: String + switch level { + case .province: + name = province + case .city: + // 세종처럼 시/군/구가 없는 지역은 시/도 이름으로 대체 + if let city, !city.isEmpty { + name = city + } else { + name = province + } + } + let address = [province, city] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + + return Place( + id: "region_\(level.rawValue)_\(coordinate.latitude)_\(coordinate.longitude)", + name: name, + address: address, + coordinate: coordinate, + type: .other + ) + } + /// Place로 변환, 도로명 우선 없으면 지번, 결과 없으면 emptyResult func toPlace(coordinate: Coordinate) throws -> Place { guard let document = documents.first else { diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoPlaceResponse.swift b/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoPlaceResponse.swift index fc524ef..5f8e347 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoPlaceResponse.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoPlaceResponse.swift @@ -41,7 +41,7 @@ struct KakaoPlaceDocument: Decodable { extension KakaoPlaceDocument { /// 중심 좌표로부터의 거리(m), 파싱 실패 시 nil - var distanceMeters: Double? { + nonisolated var distanceMeters: Double? { distance.flatMap(Double.init) } diff --git a/WhereAreYou/WhereAreYou/Data/Mock/MockPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/Mock/MockPlaceSearchRepository.swift index 54ecc32..c40d529 100644 --- a/WhereAreYou/WhereAreYou/Data/Mock/MockPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/Mock/MockPlaceSearchRepository.swift @@ -9,7 +9,7 @@ import Foundation final class MockPlaceSearchRepository: PlaceSearchRepository { - func searchPlaces(keyword: String) async throws -> [Place] { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { let trimmed = keyword.trimmingCharacters(in: .whitespaces) try await Task.sleep(for: .milliseconds(300)) return trimmed.isEmpty ? [] : Self.mockPlaces diff --git a/WhereAreYou/WhereAreYou/Data/Mock/MockReverseGeocodingRepository.swift b/WhereAreYou/WhereAreYou/Data/Mock/MockReverseGeocodingRepository.swift index 2ffb2a9..aa44b99 100644 --- a/WhereAreYou/WhereAreYou/Data/Mock/MockReverseGeocodingRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/Mock/MockReverseGeocodingRepository.swift @@ -23,6 +23,22 @@ final class MockReverseGeocodingRepository: ReverseGeocodingRepository { return place } + func reverseGeocodeRegion(coordinate: Coordinate, level: RegionLevel) async throws -> Place { + let parts = Self.mockAddress(near: coordinate).roadName.split(separator: " ").map(String.init) + let province = parts.first ?? "알 수 없는 지역" + let city = parts.count > 1 ? parts[1] : province + let place = Place( + id: "region_\(level.rawValue)_\(coordinate.latitude)_\(coordinate.longitude)", + name: level == .province ? province : city, + address: province == city ? province : "\(province) \(city)", + coordinate: coordinate, + type: .other + ) + + try await Task.sleep(for: .milliseconds(300)) + return place + } + /// 실제 API의 도로명주소 응답 형태를 흉내낸 더미 주소 생성 /// 좌표와 가장 가까운 지역 기준점을 찾아, 도로명(요약)과 도로명+지번(상세) 주소를 함께 반환 private static func mockAddress(near coordinate: Coordinate) -> (roadName: String, fullAddress: String) { diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift new file mode 100644 index 0000000..6893f65 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift @@ -0,0 +1,91 @@ +// +// KakaoPlaceRanker.swift +// WhereAreYou +// +// Created by 김성훈 on 10/8/26. +// + +import Foundation + +/// 키워드 검색 결과 재정렬 +nonisolated enum KakaoPlaceRanker { + + /// 이름이 키워드와 일치하는 정도 + nonisolated private enum MatchLevel: Int, Comparable { + case none + case contains + case prefix + case exact + + static func < (lhs: MatchLevel, rhs: MatchLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + nonisolated private struct Candidate { + let document: KakaoPlaceDocument + let originalIndex: Int + let matchLevel: MatchLevel + let representativeness: Double + let distanceMeters: Double? + let lengthRatio: Double + } + + static func rank(_ documents: [KakaoPlaceDocument], keyword: String) -> [KakaoPlaceDocument] { + let normalizedKeyword = normalize(keyword) + guard !normalizedKeyword.isEmpty else { return documents } + + return documents.enumerated() + .map { candidate(of: $1, at: $0, normalizedKeyword: normalizedKeyword) } + .sorted(by: isOrderedBefore) + .map(\.document) + } + + // MARK: - Private + + private static func candidate( + of document: KakaoPlaceDocument, + at index: Int, + normalizedKeyword: String + ) -> Candidate { + let normalizedName = normalize(document.placeName) + let representativeness = KakaoCategoryGroupCode(rawValue: document.categoryGroupCode)?.representativeness + ?? KakaoCategoryGroupCode.defaultRepresentativeness + let lengthRatio = normalizedName.isEmpty + ? 0 + : Double(normalizedKeyword.count) / Double(normalizedName.count) + + return Candidate( + document: document, + originalIndex: index, + matchLevel: matchLevel(name: normalizedName, keyword: normalizedKeyword), + representativeness: representativeness, + distanceMeters: document.distanceMeters, + lengthRatio: lengthRatio + ) + } + + private static func matchLevel(name: String, keyword: String) -> MatchLevel { + if name == keyword { return .exact } + if name.hasPrefix(keyword) { return .prefix } + if name.contains(keyword) { return .contains } + return .none + } + + /// 앞 기준이 같을 때만 다음 기준으로 비교, 마지막은 원래 순서로 안정 정렬 보장 + private static func isOrderedBefore(_ lhs: Candidate, _ rhs: Candidate) -> Bool { + if lhs.matchLevel != rhs.matchLevel { return lhs.matchLevel > rhs.matchLevel } + if lhs.representativeness != rhs.representativeness { return lhs.representativeness > rhs.representativeness } + // 거리를 모르는 문서는 가장 먼 것으로 취급 — 일부만 거리가 있어도 비교 순서가 일관됨 + let lhsDistance = lhs.distanceMeters ?? .infinity + let rhsDistance = rhs.distanceMeters ?? .infinity + if lhsDistance != rhsDistance { return lhsDistance < rhsDistance } + if lhs.lengthRatio != rhs.lengthRatio { return lhs.lengthRatio > rhs.lengthRatio } + return lhs.originalIndex < rhs.originalIndex + } + + /// 소문자 변환, 공백 제거 + private static func normalize(_ text: String) -> String { + text.lowercased().filter { !$0.isWhitespace } + } +} diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/RegionLevel.swift b/WhereAreYou/WhereAreYou/Domain/Entity/RegionLevel.swift new file mode 100644 index 0000000..37ba001 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Entity/RegionLevel.swift @@ -0,0 +1,16 @@ +// +// RegionLevel.swift +// WhereAreYou +// +// Created by 김성훈 on 10/9/26. +// + +/// 행정구역 단위 +enum RegionLevel: String { + + /// 시/도 + case province + /// 시/군/구 + case city + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift index 21b2fba..df99502 100644 --- a/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift @@ -7,6 +7,6 @@ protocol PlaceSearchRepository { - func searchPlaces(keyword: String) async throws -> [Place] + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] } diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/ReverseGeocodingRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/ReverseGeocodingRepository.swift index 7f46e6e..490102f 100644 --- a/WhereAreYou/WhereAreYou/Domain/Repository/ReverseGeocodingRepository.swift +++ b/WhereAreYou/WhereAreYou/Domain/Repository/ReverseGeocodingRepository.swift @@ -9,4 +9,7 @@ protocol ReverseGeocodingRepository { func reverseGeocode(coordinate: Coordinate) async throws -> Place + /// 좌표가 속한 행정구역을 해당 단위의 장소로 반환 (이름은 지역명, 좌표는 입력 좌표) + func reverseGeocodeRegion(coordinate: Coordinate, level: RegionLevel) async throws -> Place + } diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/GetRegionPlaceUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/GetRegionPlaceUseCase.swift new file mode 100644 index 0000000..9470431 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/GetRegionPlaceUseCase.swift @@ -0,0 +1,21 @@ +// +// GetRegionPlaceUseCase.swift +// WhereAreYou +// +// Created by 김성훈 on 10/9/26. +// + +final class GetRegionPlaceUseCase { + + private let reverseGeocodingRepository: ReverseGeocodingRepository + + init(reverseGeocodingRepository: ReverseGeocodingRepository) { + self.reverseGeocodingRepository = reverseGeocodingRepository + } + + /// 좌표가 속한 행정구역을 해당 단위의 장소로 조회한다 + func execute(coordinate: Coordinate, level: RegionLevel) async throws -> Place { + try await reverseGeocodingRepository.reverseGeocodeRegion(coordinate: coordinate, level: level) + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/Place/SearchPlacesUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/Place/SearchPlacesUseCase.swift index 7a8c044..d370fa0 100644 --- a/WhereAreYou/WhereAreYou/Domain/UseCase/Place/SearchPlacesUseCase.swift +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/Place/SearchPlacesUseCase.swift @@ -8,13 +8,39 @@ final class SearchPlacesUseCase { private let repository: PlaceSearchRepository + private let locationRepository: LocationRepository + private let locationPermissionRepository: LocationPermissionRepository - init(repository: PlaceSearchRepository) { + init( + repository: PlaceSearchRepository, + locationRepository: LocationRepository, + locationPermissionRepository: LocationPermissionRepository + ) { self.repository = repository + self.locationRepository = locationRepository + self.locationPermissionRepository = locationPermissionRepository } + /// 현재 위치를 얻을 수 있으면 가까운 장소를 우대해 검색, 얻을 수 없으면 키워드만으로 검색 func execute(keyword: String) async throws -> [Place] { - try await repository.searchPlaces(keyword: keyword) + let coordinate = await currentCoordinate() + try Task.checkCancellation() + return try await repository.searchPlaces(keyword: keyword, near: coordinate) + } + + // MARK: - Private + + /// 권한이 이미 허용된 경우에만 조회(검색 중 권한 팝업 방지), 실패는 nil로 대체 + private func currentCoordinate() async -> Coordinate? { + switch locationPermissionRepository.authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: + break + case .notDetermined, .denied, .restricted: + return nil + } + return try? await withCheckedThrowingContinuation { continuation in + locationRepository.getCurrentLocation { continuation.resume(with: $0) } + } } } diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift index d488dd5..a8d3592 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift @@ -46,7 +46,7 @@ final class SearchPlaceCardViewModel { searchTask = Task { @MainActor [weak self] in guard let self else { return } do { - let places = try await self.searchPlacesUseCase.execute(keyword: keyword) + let places = try await self.searchPlacesUseCase.execute(keyword: trimmed) guard !Task.isCancelled else { return } self.isSearching = false self.hasSearched = true diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift index 27d0c01..ab2d1d0 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift @@ -10,7 +10,8 @@ import NMapsMap final class PlaceSelectionMapView: NMFNaverMapView { - var onCameraIdle: ((Coordinate) -> Void)? + /// 카메라 이동이 멈추면 핀이 가리키는 좌표와 현재 줌 레벨을 전달 + var onCameraIdle: ((Coordinate, Double) -> Void)? private let centerPinImageView: UIImageView = { let imageView = UIImageView() @@ -61,7 +62,7 @@ extension PlaceSelectionMapView: NMFMapViewCameraDelegate { to: mapView ) let coordinate = mapView.projection.latlng(from: pinPoint) - onCameraIdle?(Coordinate(latitude: coordinate.lat, longitude: coordinate.lng)) + onCameraIdle?(Coordinate(latitude: coordinate.lat, longitude: coordinate.lng), mapView.zoomLevel) } } diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewController.swift index bf2bb38..f09985e 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewController.swift @@ -154,8 +154,8 @@ final class PlaceSelectionViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .systemBackground setUpLayout() - naverMapView.onCameraIdle = { [weak self] coordinate in - self?.viewModel.setCenterCoordinate(coordinate) + naverMapView.onCameraIdle = { [weak self] coordinate, zoomLevel in + self?.viewModel.setCenterCoordinate(coordinate, zoomLevel: zoomLevel) } searchOtherPlaceButton.addAction( UIAction { [weak self] _ in self?.searchOtherPlaceTapped() }, diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift index 78d681d..4b021b2 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift @@ -12,40 +12,73 @@ final class PlaceSelectionViewModel { /// 사용자의 현재 GPS 좌표 @Published private(set) var currentLocation: Coordinate? - /// 지도 중앙 좌표 기준 근처 장소를 조회 중인지 여부 + /// 지도 중앙 좌표 기준 근처 장소 또는 지역명을 조회 중인지 여부 @Published private(set) var isFetchingNearbyPlace = false - /// 지도 중앙 좌표 기준으로 조회된 가장 가까운 장소 1개 + /// 지도 중앙 좌표 기준으로 조회된 근처 장소 또는 지역명 1개 (줌 레벨에 따라 결정) @Published private(set) var nearbyPlace: PlaceInfo? /// currentLocation과 nearbyPlace 사이 거리를 표시용 문자열로 계산해둔 값 @Published private(set) var distanceText: String? private(set) var nearbyPlaceDomain: Place? + /// 지도의 마지막 줌 레벨 — 카메라 이동이 멈출 때 갱신 + private(set) var zoomLevel: Double? /// 지도 중앙 좌표가 바뀔 때마다 이벤트를 흘려보내는 파이프 private let centerCoordinateSubject = PassthroughSubject() /// Combine 구독을 유지하기 위한 저장소 private var cancellables = Set() /// 마지막으로 실제 조회를 실행했던 좌표 private var lastFetchedCoordinate: Coordinate? - /// 진행 중인 근처 장소 조회 Task — 새 좌표가 들어오면 이전 Task를 취소해 응답 경쟁을 막음 + /// 마지막으로 실제 조회를 실행했던 조회 방식 + private var lastFetchedMode: FetchMode? + /// 진행 중인 근처 장소·지역명 조회 Task — 새 좌표가 들어오면 이전 Task를 취소해 응답 경쟁을 막음 private var nearbyPlaceTask: Task? - /// 이 거리(km) 미만으로 움직였을 때는 재조회하지 않음 - private static let minimumFetchDistanceKm = 0.02 + /// 줌 레벨에 따른 조회 방식 + private enum FetchMode: Equatable { + case nearbyPlace + case region(RegionLevel) + + /// 이 거리(km) 미만으로 움직였을 때는 재조회하지 않음 + var minimumFetchDistanceKm: Double { + switch self { + case .nearbyPlace: return 0.03 + case .region(.city): return 0.5 + case .region(.province): return 3 + } + } + } + + /// 이 줌 레벨 이상이면 주변 장소 조회 + private static let nearbyPlaceMinimumZoomLevel = 14.0 + /// 이 줌 레벨 이상(주변 장소 미만)이면 시/군/구, 미만이면 시/도 조회 + private static let cityMinimumZoomLevel = 9.0 + + /// 현재 줌 레벨에 해당하는 조회 방식, 줌 레벨을 모르면 주변 장소 + private var currentFetchMode: FetchMode { + guard let zoomLevel else { return .nearbyPlace } + if zoomLevel >= Self.nearbyPlaceMinimumZoomLevel { return .nearbyPlace } + if zoomLevel >= Self.cityMinimumZoomLevel { return .region(.city) } + return .region(.province) + } /// 현재 위치 조회를 위임하는 UseCase private let getCurrentLocationUseCase: GetCurrentLocationUseCase /// 좌표 기준 근처 장소 조회를 위임하는 UseCase private let getNearbyPlaceUseCase: GetNearbyPlaceUseCase + /// 좌표가 속한 지역명 조회를 위임하는 UseCase + private let getRegionPlaceUseCase: GetRegionPlaceUseCase /// 초기 지도 중심 좌표 (nil이면 현재 위치 사용) let initialCoordinate: Coordinate? init( getCurrentLocationUseCase: GetCurrentLocationUseCase, getNearbyPlaceUseCase: GetNearbyPlaceUseCase, + getRegionPlaceUseCase: GetRegionPlaceUseCase, initialCoordinate: Coordinate? = nil ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase self.getNearbyPlaceUseCase = getNearbyPlaceUseCase + self.getRegionPlaceUseCase = getRegionPlaceUseCase self.initialCoordinate = initialCoordinate bindCenterCoordinate() } @@ -66,31 +99,47 @@ final class PlaceSelectionViewModel { } } - func setCenterCoordinate(_ coordinate: Coordinate) { + /// zoomLevel이 nil이면 직전에 전달된 줌 레벨을 유지 (현재 위치·초기 좌표처럼 줌 정보가 없는 호출) + func setCenterCoordinate(_ coordinate: Coordinate, zoomLevel: Double? = nil) { + if let zoomLevel { + self.zoomLevel = zoomLevel + } centerCoordinateSubject.send(coordinate) } private func bindCenterCoordinate() { centerCoordinateSubject - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .filter { [weak self] coordinate in - guard let self, let lastFetchedCoordinate else { return true } - return lastFetchedCoordinate.distance(to: coordinate) >= Self.minimumFetchDistanceKm + guard let self, let lastFetchedCoordinate, let lastFetchedMode else { return true } + let mode = self.currentFetchMode + // 줌 레벨 변화로 조회 방식이 바뀌면 같은 위치여도 재조회 + if mode != lastFetchedMode { return true } + return lastFetchedCoordinate.distance(to: coordinate) >= mode.minimumFetchDistanceKm } .sink { [weak self] coordinate in - self?.lastFetchedCoordinate = coordinate - self?.fetchNearbyPlace(at: coordinate) + guard let self else { return } + let mode = self.currentFetchMode + self.lastFetchedCoordinate = coordinate + self.lastFetchedMode = mode + self.fetchPlace(at: coordinate, mode: mode) } .store(in: &cancellables) } - private func fetchNearbyPlace(at coordinate: Coordinate) { + private func fetchPlace(at coordinate: Coordinate, mode: FetchMode) { nearbyPlaceTask?.cancel() isFetchingNearbyPlace = true nearbyPlaceTask = Task { @MainActor [weak self] in guard let self else { return } do { - let fetchedPlace = try await self.getNearbyPlaceUseCase.execute(coordinate: coordinate) + let fetchedPlace: Place? + switch mode { + case .nearbyPlace: + fetchedPlace = try await self.getNearbyPlaceUseCase.execute(coordinate: coordinate) + case .region(let level): + fetchedPlace = try await self.getRegionPlaceUseCase.execute(coordinate: coordinate, level: level) + } guard !Task.isCancelled else { return } self.isFetchingNearbyPlace = false self.nearbyPlaceDomain = fetchedPlace