From d3cde5674442728602ed1e9b4d2953b63c08acd8 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 21:40:32 +0900 Subject: [PATCH 01/27] =?UTF-8?q?FIX:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=8B=9C=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=95=9E?= =?UTF-8?q?=EB=92=A4=20=EA=B3=B5=EB=B0=B1=EC=9D=84=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=B4=20=EC=9A=94=EC=B2=AD=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Component/SearchPlaceCard/SearchPlaceCardViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 260c75a83e6bea32d86e43ee1bfa7289d8bc1d35 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 21:43:42 +0900 Subject: [PATCH 02/27] =?UTF-8?q?FEAT:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=80=EC=83=89=20Endpoint?= =?UTF-8?q?=EC=97=90=20page=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/Kakao/KakaoLocalEndpoint.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift index 2e62af8..70ee537 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift @@ -10,7 +10,7 @@ import Foundation /// 카카오 로컬 API 요청 enum KakaoLocalEndpoint { /// 키워드로 장소 검색 - case keyword(query: String) + case keyword(query: String, page: Int = 1) /// 좌표 반경 내 카테고리 검색, 거리순 정렬 case category(code: KakaoCategoryGroupCode, coordinate: Coordinate, radiusMeters: Int) /// 좌표 -> 주소 변환 @@ -36,10 +36,11 @@ extension KakaoLocalEndpoint: Endpoint { var queryItems: [URLQueryItem] { switch self { - case .keyword(let query): + case .keyword(let query, let page): return [ URLQueryItem(name: "query", value: query), - URLQueryItem(name: "size", value: "15") + URLQueryItem(name: "size", value: "15"), + URLQueryItem(name: "page", value: "\(page)") ] case .category(let code, let coordinate, let radiusMeters): return [ From 540f34f137e61e12a06d39de7371ccbc34163362 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 21:45:41 +0900 Subject: [PATCH 03/27] =?UTF-8?q?FEAT:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=80=EC=83=89=EC=9D=84=202?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B3=91=EB=A0=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/KakaoPlaceSearchRepository.swift | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift index a27041e..7fcd845 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift @@ -17,9 +17,33 @@ final class KakaoPlaceSearchRepository: PlaceSearchRepository { } func searchPlaces(keyword: String) async throws -> [Place] { + async let firstPage = fetchDocuments(keyword: keyword, page: 1) + async let secondPage = fetchDocumentsIgnoringFailure(keyword: keyword, page: 2) + + let documents = try await firstPage + secondPage + + var seenIDs = Set() + return documents + .filter { seenIDs.insert($0.id).inserted } + .compactMap { $0.toPlace() } + } + + // MARK: - Private + + private func fetchDocuments(keyword: String, page: Int) async throws -> [KakaoPlaceDocument] { let response: KakaoPlaceResponse = try await apiClient.request( - KakaoLocalEndpoint.keyword(query: keyword) + KakaoLocalEndpoint.keyword(query: keyword, page: page) ) - return response.documents.compactMap { $0.toPlace() } + return response.documents + } + + /// 보조 페이지 조회 + private func fetchDocumentsIgnoringFailure(keyword: String, page: Int) async throws -> [KakaoPlaceDocument] { + do { + return try await fetchDocuments(keyword: keyword, page: page) + } catch { + try Task.checkCancellation() + return [] + } } } From 328f2d1a3d687843ef2b84d4b5977e576bd1ab8f Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 21:50:49 +0900 Subject: [PATCH 04/27] =?UTF-8?q?FEAT:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=B3=84=20=EB=8C=80=ED=91=9C=EC=84=B1=20?= =?UTF-8?q?=EA=B0=80=EC=A4=91=EC=B9=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Kakao/KakaoCategoryGroupCode.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift index 3ddae65..0ab994a 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift @@ -28,3 +28,21 @@ enum KakaoCategoryGroupCode: String { case hospital = "HP8" // 병원 case pharmacy = "PM9" // 약국 } + +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 + } + } +} From 68a1e228cde5e2e753d274a52973387866e3c664 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 21:55:09 +0900 Subject: [PATCH 05/27] =?UTF-8?q?FEAT:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=9E=AC=EC=A0=95=EB=A0=AC=EA=B8=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Kakao/KakaoCategoryGroupCode.swift | 2 +- .../Data/Network/Kakao/KakaoPlaceRanker.swift | 85 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift index 0ab994a..6d615ed 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift @@ -29,7 +29,7 @@ enum KakaoCategoryGroupCode: String { case pharmacy = "PM9" // 약국 } -extension KakaoCategoryGroupCode { +nonisolated extension KakaoCategoryGroupCode { /// 코드 정보가 없는 장소에 적용하는 기본 대표성 가중치 static let defaultRepresentativeness = 1.0 diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift new file mode 100644 index 0000000..980ae74 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift @@ -0,0 +1,85 @@ +// +// 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 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, + 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 } + 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 } + } +} From f5dc456be4f7cf294fc676d0ae4a49f17c575770 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 22:02:14 +0900 Subject: [PATCH 06/27] =?UTF-8?q?FEAT:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=97=90=20=EB=8C=80=ED=91=9C=EC=84=B1=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Data/KakaoPlaceSearchRepository.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift index 7fcd845..4392a44 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift @@ -23,8 +23,9 @@ final class KakaoPlaceSearchRepository: PlaceSearchRepository { let documents = try await firstPage + secondPage var seenIDs = Set() - return documents - .filter { seenIDs.insert($0.id).inserted } + let uniqueDocuments = documents.filter { seenIDs.insert($0.id).inserted } + + return KakaoPlaceRanker.rank(uniqueDocuments, keyword: keyword) .compactMap { $0.toPlace() } } From 0563744262ab86b34ad79afecafc548c9e35d3d5 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 22:03:58 +0900 Subject: [PATCH 07/27] =?UTF-8?q?REFACTOR:=20KakaoNearbyPlaceRepository?= =?UTF-8?q?=EA=B0=80=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=B3=84=20?= =?UTF-8?q?=EC=83=81=EC=9C=84=20=ED=9B=84=EB=B3=B4=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/KakaoNearbyPlaceRepository.swift | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift index a271cda..9580ab1 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift @@ -26,9 +26,19 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { self.apiClient = apiClient } + /// 카테고리당 수집하는 후보 수 + private static let candidatesPerCategory = 5 + + /// 선택 대상 후보 + private struct Candidate { + let place: Place + let distanceMeters: Double + let categoryCode: KakaoCategoryGroupCode + } + /// 카테고리 하나 조회 결과 — 결과 없음(정상)과 요청 실패를 구분해 상위에서 정책을 결정할 수 있게 함 private enum CategoryResult { - case found(Place, distanceMeters: Double) + case found([Candidate]) case notFound case failed(Error) } @@ -39,7 +49,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 +61,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 +80,13 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { if !hasSucceeded, let firstFailure { throw firstFailure } - return best?.place + return candidates.min { $0.distanceMeters < $1.distanceMeters }?.place } // MARK: - Private - /// 카테고리 하나를 조회해 결과 유무/실패를 구분해 반환. 취소는 흡수하지 않고 그대로 다시 throw - private func nearestDocument( + /// 카테고리 하나의 상위 후보를 조회해 결과 유무/실패를 구분해 반환 + private func fetchCandidates( code: KakaoCategoryGroupCode, coordinate: Coordinate, radiusMeters: Int @@ -87,12 +95,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 { From 8effcb646451695f5ce80c3a2e05bfd9f32451ce Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 22:05:33 +0900 Subject: [PATCH 08/27] =?UTF-8?q?FEAT:=20=EC=A3=BC=EB=B3=80=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=84=A0=ED=83=9D=EC=97=90=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=8C=80=ED=91=9C=EC=84=B1=20=EA=B0=80?= =?UTF-8?q?=EC=A4=91=EC=B9=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Data/KakaoNearbyPlaceRepository.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift index 9580ab1..fabc3e5 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift @@ -8,7 +8,7 @@ import Foundation /// 좌표 반경 내 가장 가까운 장소 1개 조회 (지도 장소 선택, 현재 위치 표시용, 키워드 검색과 무관) -/// 카테고리 검색이 요청당 카테고리 1개만 지원해, 서비스가 다루는 카테고리를 병렬 요청 후 가장 가까운 결과 선택 +/// 카테고리 검색이 요청당 카테고리 1개만 지원해, 서비스가 다루는 카테고리를 병렬 요청 후 거리와 카테고리 대표성을 함께 고려해 선택 /// 지도 이동 1회당 카테고리 수만큼 호출 발생 — PlaceSelectionViewModel의 디바운스/최소 이동 거리로 호출량 제한 final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { @@ -34,6 +34,11 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { let place: Place let distanceMeters: Double let categoryCode: KakaoCategoryGroupCode + + /// 선택 점수, 낮을수록 우선 + var score: Double { + distanceMeters / categoryCode.representativeness + } } /// 카테고리 하나 조회 결과 — 결과 없음(정상)과 요청 실패를 구분해 상위에서 정책을 결정할 수 있게 함 @@ -80,7 +85,7 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { if !hasSucceeded, let firstFailure { throw firstFailure } - return candidates.min { $0.distanceMeters < $1.distanceMeters }?.place + return candidates.min { $0.score < $1.score }?.place } // MARK: - Private From cd07e045d1549ccf5201e4d33a9e915bf70289cd Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 8 Oct 2026 22:07:04 +0900 Subject: [PATCH 09/27] =?UTF-8?q?FEAT:=20=EC=A3=BC=EB=B3=80=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=A1=B0=ED=9A=8C=20=EB=8C=80=EC=83=81=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=EC=97=90=20=EA=B3=B5=EA=B3=B5?= =?UTF-8?q?=EA=B8=B0=EA=B4=80=C2=B7=EA=B4=80=EA=B4=91=EB=AA=85=EC=86=8C?= =?UTF-8?q?=C2=B7=EB=AC=B8=ED=99=94=EC=8B=9C=EC=84=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Data/KakaoNearbyPlaceRepository.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift index fabc3e5..42f4155 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift @@ -15,9 +15,10 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { /// 카카오 API가 허용하는 최대 반경(m) private static let maximumRadiusMeters = 20000 - /// 조회 대상 카테고리 — 검색 화면 필터와 무관, PlaceType 매핑 대상만 포함 + /// 조회 대상 카테고리 private static let categoryCodes: [KakaoCategoryGroupCode] = [ - .subwayStation, .restaurant, .cafe, .hospital, .largeMart, .convenienceStore + .subwayStation, .restaurant, .cafe, .hospital, .largeMart, .convenienceStore, + .publicInstitution, .attraction, .culturalFacility ] private let apiClient: APIClient From deee084e528a82a59c86eeb4f8f7d05b6db8d0e5 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:10:27 +0900 Subject: [PATCH 10/27] =?UTF-8?q?FEAT:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=80=EC=83=89=20Endpoint?= =?UTF-8?q?=EC=97=90=20=EC=A2=8C=ED=91=9C=EC=99=80=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Kakao/KakaoLocalEndpoint.swift | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift index 70ee537..efcee32 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoLocalEndpoint.swift @@ -7,10 +7,18 @@ import Foundation +/// 카카오 키워드 검색 정렬 기준 +enum KakaoKeywordSort { + /// 정확도순 (카카오 기본값) + case accuracy + /// 기준 좌표와 가까운 순 + case distance +} + /// 카카오 로컬 API 요청 enum KakaoLocalEndpoint { - /// 키워드로 장소 검색 - case keyword(query: String, page: Int = 1) + /// 키워드로 장소 검색, 좌표를 넘기면 응답에 거리(m)가 채워짐 + case keyword(query: String, page: Int = 1, coordinate: Coordinate? = nil, sort: KakaoKeywordSort = .accuracy) /// 좌표 반경 내 카테고리 검색, 거리순 정렬 case category(code: KakaoCategoryGroupCode, coordinate: Coordinate, radiusMeters: Int) /// 좌표 -> 주소 변환 @@ -36,12 +44,20 @@ extension KakaoLocalEndpoint: Endpoint { var queryItems: [URLQueryItem] { switch self { - case .keyword(let query, let page): - 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: "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), From 63da8e02015d8013e5ce35ddea9788eaf886213a Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:11:56 +0900 Subject: [PATCH 11/27] =?UTF-8?q?FEAT:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=9E=AC=EC=A0=95=EB=A0=AC=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B1=B0=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/Kakao/KakaoPlaceRanker.swift | 14 ++++++++++++-- .../Data/Network/Kakao/KakaoPlaceResponse.swift | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift index 980ae74..5805332 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift @@ -27,6 +27,7 @@ nonisolated enum KakaoPlaceRanker { let originalIndex: Int let matchLevel: MatchLevel let representativeness: Double + let distanceMeters: Double? let lengthRatio: Double } @@ -34,8 +35,12 @@ nonisolated enum KakaoPlaceRanker { let normalizedKeyword = normalize(keyword) guard !normalizedKeyword.isEmpty else { return documents } + let usesDistance = documents.allSatisfy { $0.distanceMeters != nil } + return documents.enumerated() - .map { candidate(of: $1, at: $0, normalizedKeyword: normalizedKeyword) } + .map { + candidate(of: $1, at: $0, normalizedKeyword: normalizedKeyword, usesDistance: usesDistance) + } .sorted(by: isOrderedBefore) .map(\.document) } @@ -45,7 +50,8 @@ nonisolated enum KakaoPlaceRanker { private static func candidate( of document: KakaoPlaceDocument, at index: Int, - normalizedKeyword: String + normalizedKeyword: String, + usesDistance: Bool ) -> Candidate { let normalizedName = normalize(document.placeName) let representativeness = KakaoCategoryGroupCode(rawValue: document.categoryGroupCode)?.representativeness @@ -59,6 +65,7 @@ nonisolated enum KakaoPlaceRanker { originalIndex: index, matchLevel: matchLevel(name: normalizedName, keyword: normalizedKeyword), representativeness: representativeness, + distanceMeters: usesDistance ? document.distanceMeters : nil, lengthRatio: lengthRatio ) } @@ -74,6 +81,9 @@ nonisolated enum KakaoPlaceRanker { 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 } + if let lhsDistance = lhs.distanceMeters, let rhsDistance = rhs.distanceMeters, lhsDistance != rhsDistance { + return lhsDistance < rhsDistance + } if lhs.lengthRatio != rhs.lengthRatio { return lhs.lengthRatio > rhs.lengthRatio } return lhs.originalIndex < rhs.originalIndex } diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceResponse.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceResponse.swift index fc524ef..5f8e347 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceResponse.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceResponse.swift @@ -41,7 +41,7 @@ struct KakaoPlaceDocument: Decodable { extension KakaoPlaceDocument { /// 중심 좌표로부터의 거리(m), 파싱 실패 시 nil - var distanceMeters: Double? { + nonisolated var distanceMeters: Double? { distance.flatMap(Double.init) } From 764f8855a3b81a560992211d319c68146b4af3e6 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:13:47 +0900 Subject: [PATCH 12/27] =?UTF-8?q?REFACTOR:=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20Repository=C2=B7UseCase=EC=97=90=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A0=81=20=EA=B8=B0=EC=A4=80=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift | 2 +- WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift | 2 +- .../WhereAreYou/Domain/Repository/PlaceSearchRepository.swift | 2 +- .../WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift index 4392a44..0a81a30 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift @@ -16,7 +16,7 @@ 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) async let secondPage = fetchDocumentsIgnoringFailure(keyword: keyword, page: 2) diff --git a/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift index 54ecc32..c40d529 100644 --- a/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/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/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/UseCase/SearchPlacesUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift index 7a8c044..7178d34 100644 --- a/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift @@ -13,8 +13,8 @@ final class SearchPlacesUseCase { self.repository = repository } - func execute(keyword: String) async throws -> [Place] { - try await repository.searchPlaces(keyword: keyword) + func execute(keyword: String, near coordinate: Coordinate? = nil) async throws -> [Place] { + try await repository.searchPlaces(keyword: keyword, near: coordinate) } } From d22b6f39a4413a0f8a984fc3f84f2e65373b9c17 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:14:51 +0900 Subject: [PATCH 13/27] =?UTF-8?q?FEAT:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=80=EC=83=89=EC=97=90=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=EC=88=9C=20=ED=9B=84=EB=B3=B4=20=ED=92=80=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/KakaoPlaceSearchRepository.swift | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift index 0a81a30..221e1d4 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift @@ -17,10 +17,11 @@ final class KakaoPlaceSearchRepository: PlaceSearchRepository { } func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - async let firstPage = fetchDocuments(keyword: keyword, page: 1) - async let secondPage = fetchDocumentsIgnoringFailure(keyword: keyword, page: 2) + 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 + let documents = try await firstPage + secondPage + nearestPage var seenIDs = Set() let uniqueDocuments = documents.filter { seenIDs.insert($0.id).inserted } @@ -31,20 +32,41 @@ final class KakaoPlaceSearchRepository: PlaceSearchRepository { // MARK: - Private - private func fetchDocuments(keyword: String, page: Int) async throws -> [KakaoPlaceDocument] { + 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, page: page) + KakaoLocalEndpoint.keyword(query: keyword, page: page, coordinate: coordinate, sort: sort) ) return response.documents } /// 보조 페이지 조회 - private func fetchDocumentsIgnoringFailure(keyword: String, page: Int) async throws -> [KakaoPlaceDocument] { + private func fetchDocumentsIgnoringFailure( + keyword: String, + page: Int, + coordinate: Coordinate?, + sort: KakaoKeywordSort = .accuracy + ) async throws -> [KakaoPlaceDocument] { do { - return try await fetchDocuments(keyword: keyword, page: page) + 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 + ) + } } From 2719def424af847c6bfcaf3584d7060d33488b5f Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:16:26 +0900 Subject: [PATCH 14/27] =?UTF-8?q?FEAT:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=8B=9C=20=ED=98=84=EC=9E=AC=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=ED=95=B4=20=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScreenFactory+SearchPlaceCard.swift | 6 +++- .../Domain/UseCase/SearchPlacesUseCase.swift | 32 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+SearchPlaceCard.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+SearchPlaceCard.swift index a111bd0..132ab93 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+SearchPlaceCard.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/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/Domain/UseCase/SearchPlacesUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift index 7178d34..d370fa0 100644 --- a/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/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, near coordinate: Coordinate? = nil) async throws -> [Place] { - try await repository.searchPlaces(keyword: keyword, near: coordinate) + /// 현재 위치를 얻을 수 있으면 가까운 장소를 우대해 검색, 얻을 수 없으면 키워드만으로 검색 + func execute(keyword: String) async throws -> [Place] { + 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) } + } } } From e3f70ce5ee950c63f0bba26d2a037ad1bc429b3c Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:20:33 +0900 Subject: [PATCH 15/27] =?UTF-8?q?REFACTOR:=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20=EC=A3=BC=EB=B3=80=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EB=94=94=EB=B0=94?= =?UTF-8?q?=EC=9A=B4=EC=8A=A4=EC=99=80=20=EC=B5=9C=EC=86=8C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EA=B1=B0=EB=A6=AC=20=EC=83=81=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Map/PlaceSelection/PlaceSelectionViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift index 78d681d..1bfe690 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift @@ -30,7 +30,7 @@ final class PlaceSelectionViewModel { private var nearbyPlaceTask: Task? /// 이 거리(km) 미만으로 움직였을 때는 재조회하지 않음 - private static let minimumFetchDistanceKm = 0.02 + private static let minimumFetchDistanceKm = 0.03 /// 현재 위치 조회를 위임하는 UseCase private let getCurrentLocationUseCase: GetCurrentLocationUseCase @@ -72,7 +72,7 @@ final class PlaceSelectionViewModel { 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 From 33098291670cf3d235fc3951c8f812aaa0d299a8 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:22:07 +0900 Subject: [PATCH 16/27] =?UTF-8?q?FEAT:=20=EC=A3=BC=EB=B3=80=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=A1=B0=ED=9A=8C=20=EB=8C=80=EC=83=81=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EC=A0=84=EC=B2=B4=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Data/KakaoNearbyPlaceRepository.swift | 5 +---- .../Data/Network/Kakao/KakaoCategoryGroupCode.swift | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift index 42f4155..b8abbab 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift @@ -16,10 +16,7 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { private static let maximumRadiusMeters = 20000 /// 조회 대상 카테고리 - private static let categoryCodes: [KakaoCategoryGroupCode] = [ - .subwayStation, .restaurant, .cafe, .hospital, .largeMart, .convenienceStore, - .publicInstitution, .attraction, .culturalFacility - ] + private static let categoryCodes = KakaoCategoryGroupCode.allCases private let apiClient: APIClient diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift index 6d615ed..6594e1e 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift @@ -8,7 +8,7 @@ import Foundation /// 카카오 로컬 API 카테고리 그룹 코드 전체 -enum KakaoCategoryGroupCode: String { +enum KakaoCategoryGroupCode: String, CaseIterable { case largeMart = "MT1" // 대형마트 case convenienceStore = "CS2" // 편의점 case kindergarten = "PS3" // 어린이집, 유치원 From 0ec454cfd3aff6dc8a4bffa691860f4089129df5 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:42:20 +0900 Subject: [PATCH 17/27] =?UTF-8?q?REFACTOR:=20=EC=A3=BC=EB=B3=80=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=A1=B0=ED=9A=8C=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=2010=EC=A2=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Data/KakaoNearbyPlaceRepository.swift | 5 ++++- .../Data/Network/Kakao/KakaoCategoryGroupCode.swift | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift index b8abbab..1bc8f8e 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift @@ -16,7 +16,10 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { private static let maximumRadiusMeters = 20000 /// 조회 대상 카테고리 - private static let categoryCodes = KakaoCategoryGroupCode.allCases + private static let categoryCodes: [KakaoCategoryGroupCode] = [ + .subwayStation, .attraction, .culturalFacility, .hospital, .largeMart, + .restaurant, .cafe, .convenienceStore, .school, .parkingLot + ] private let apiClient: APIClient diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift index 6594e1e..6d615ed 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoCategoryGroupCode.swift @@ -8,7 +8,7 @@ import Foundation /// 카카오 로컬 API 카테고리 그룹 코드 전체 -enum KakaoCategoryGroupCode: String, CaseIterable { +enum KakaoCategoryGroupCode: String { case largeMart = "MT1" // 대형마트 case convenienceStore = "CS2" // 편의점 case kindergarten = "PS3" // 어린이집, 유치원 From ed71ab5dbe137028b26ac3c3d41ceab3f049832d Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:43:28 +0900 Subject: [PATCH 18/27] =?UTF-8?q?FEAT:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C-=EC=A3=BC=EC=86=8C=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=A7=80=EC=97=AD=EB=AA=85=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/Kakao/KakaoAddressResponse.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift index 442ccb3..1131a97 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift @@ -26,18 +26,30 @@ 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" } } From f994e1a646df20be050e85e2492eb8cf7e5a34bc Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:45:02 +0900 Subject: [PATCH 19/27] =?UTF-8?q?FEAT:=20=EC=97=AD=EC=A7=80=EC=98=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20Repository=EC=97=90=20=EC=8B=9C/=EB=8F=84?= =?UTF-8?q?=C2=B7=EC=8B=9C/=EA=B5=B0/=EA=B5=AC=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KakaoReverseGeocodingRepository.swift | 19 ++++++++-- .../Data/MockReverseGeocodingRepository.swift | 16 +++++++++ .../Network/Kakao/KakaoAddressResponse.swift | 35 +++++++++++++++++++ .../Domain/Entity/RegionLevel.swift | 16 +++++++++ .../ReverseGeocodingRepository.swift | 3 ++ 5 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 WhereAreYou/WhereAreYou/Domain/Entity/RegionLevel.swift diff --git a/WhereAreYou/WhereAreYou/Data/KakaoReverseGeocodingRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoReverseGeocodingRepository.swift index a34c87f..8925652 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoReverseGeocodingRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/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/MockReverseGeocodingRepository.swift b/WhereAreYou/WhereAreYou/Data/MockReverseGeocodingRepository.swift index 2ffb2a9..aa44b99 100644 --- a/WhereAreYou/WhereAreYou/Data/MockReverseGeocodingRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/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/KakaoAddressResponse.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift index 1131a97..aab2e03 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift @@ -55,6 +55,41 @@ struct KakaoAddress: Decodable { extension KakaoAddressResponse { + /// 지정한 행정구역 단위의 Place로 변환, 지역명이 없으면 emptyResult + func toRegionPlace(coordinate: Coordinate, level: RegionLevel) throws -> Place { + guard let document = documents.first else { + throw NetworkError.emptyResult + } + let province = document.address?.region1DepthName ?? document.roadAddress?.region1DepthName + let city = document.address?.region2DepthName ?? document.roadAddress?.region2DepthName + + guard let province, !province.isEmpty else { + throw NetworkError.emptyResult + } + let name: String + switch level { + case .province: + name = province + case .city: + guard let city, !city.isEmpty else { + throw NetworkError.emptyResult + } + name = city + } + 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/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/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 + } From d2f4641c2d5abaa25e141266ed7b073e0e7d6ea8 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:46:02 +0900 Subject: [PATCH 20/27] =?UTF-8?q?FEAT:=20=EC=A7=80=EC=97=AD=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=9E=A5=EC=86=8C=20=EC=A1=B0=ED=9A=8C=20GetRegion?= =?UTF-8?q?PlaceUseCase=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCase/GetRegionPlaceUseCase.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/UseCase/GetRegionPlaceUseCase.swift 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) + } + +} From f867875cf7ae86d6d93253fb5ddae141124ea78e Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:47:03 +0900 Subject: [PATCH 21/27] =?UTF-8?q?REFACTOR:=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=A7=80=EB=8F=84=EC=9D=98=20=EC=B9=B4?= =?UTF-8?q?=EB=A9=94=EB=9D=BC=20=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20=EC=A4=8C?= =?UTF-8?q?=20=EB=A0=88=EB=B2=A8=EC=9D=84=20ViewModel=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Map/PlaceSelection/PlaceSelectionMapView.swift | 5 +++-- .../Map/PlaceSelection/PlaceSelectionViewController.swift | 4 ++-- .../Map/PlaceSelection/PlaceSelectionViewModel.swift | 8 +++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift index 61e5062..7b98abe 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() @@ -69,7 +70,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 1bfe690..d73c498 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift @@ -20,6 +20,8 @@ final class PlaceSelectionViewModel { @Published private(set) var distanceText: String? private(set) var nearbyPlaceDomain: Place? + /// 지도의 마지막 줌 레벨 — 카메라 이동이 멈출 때 갱신 + private(set) var zoomLevel: Double? /// 지도 중앙 좌표가 바뀔 때마다 이벤트를 흘려보내는 파이프 private let centerCoordinateSubject = PassthroughSubject() /// Combine 구독을 유지하기 위한 저장소 @@ -66,7 +68,11 @@ 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) } From 822009e3011d301a6ce3b83470bcc2c6c059cc0d Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:48:49 +0900 Subject: [PATCH 22/27] =?UTF-8?q?FEAT:=20=EC=A4=8C=20=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=8B=9C=20=EC=A3=BC=EB=B3=80=20=EC=9E=A5=EC=86=8C=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=8B=9C/=EB=8F=84=C2=B7=EC=8B=9C/=EA=B5=B0/?= =?UTF-8?q?=EA=B5=AC=20=EC=A7=80=EC=97=AD=EB=AA=85=EC=9D=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScreenFactory+PlaceSelection.swift | 3 + .../PlaceSelectionViewModel.swift | 59 ++++++++++++++++--- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+PlaceSelection.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+PlaceSelection.swift index da0942e..bdd8061 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+PlaceSelection.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/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/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift index d73c498..5697caa 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift @@ -28,26 +28,57 @@ final class PlaceSelectionViewModel { private var cancellables = Set() /// 마지막으로 실제 조회를 실행했던 좌표 private var lastFetchedCoordinate: Coordinate? + /// 마지막으로 실제 조회를 실행했던 조회 방식 + private var lastFetchedMode: FetchMode? /// 진행 중인 근처 장소 조회 Task — 새 좌표가 들어오면 이전 Task를 취소해 응답 경쟁을 막음 private var nearbyPlaceTask: Task? - /// 이 거리(km) 미만으로 움직였을 때는 재조회하지 않음 - private static let minimumFetchDistanceKm = 0.03 + /// 줌 레벨에 따른 조회 방식 + 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 = 15.0 + /// 이 줌 레벨 이상(주변 장소 미만)이면 시/군/구, 미만이면 시/도 조회 + private static let cityMinimumZoomLevel = 12.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() } @@ -80,23 +111,35 @@ final class PlaceSelectionViewModel { centerCoordinateSubject .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 From 7583fe81e2600242154346a9f1f89a3664a851e9 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 00:59:49 +0900 Subject: [PATCH 23/27] =?UTF-8?q?FIX:=20=EC=A4=8C=20=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=EB=AA=85=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20?= =?UTF-8?q?=EC=A4=8C=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EC=A1=B0=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=8B=9C/=EB=8F=84=EB=AA=85=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EC=8B=9D=20=EB=AA=85=EC=B9=AD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Kakao/KakaoAddressResponse.swift | 27 +++++++++++++++++-- .../PlaceSelectionViewModel.swift | 4 +-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift index aab2e03..3e0e71b 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoAddressResponse.swift @@ -55,17 +55,40 @@ struct KakaoAddress: Decodable { 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 province = document.address?.region1DepthName ?? document.roadAddress?.region1DepthName + let rawProvince = document.address?.region1DepthName ?? document.roadAddress?.region1DepthName let city = document.address?.region2DepthName ?? document.roadAddress?.region2DepthName - guard let province, !province.isEmpty else { + guard let rawProvince, !rawProvince.isEmpty else { throw NetworkError.emptyResult } + let province = Self.provinceFullNames[rawProvince] ?? rawProvince let name: String switch level { case .province: diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift index 5697caa..fbe2437 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift @@ -49,9 +49,9 @@ final class PlaceSelectionViewModel { } /// 이 줌 레벨 이상이면 주변 장소 조회 - private static let nearbyPlaceMinimumZoomLevel = 15.0 + private static let nearbyPlaceMinimumZoomLevel = 14.0 /// 이 줌 레벨 이상(주변 장소 미만)이면 시/군/구, 미만이면 시/도 조회 - private static let cityMinimumZoomLevel = 12.0 + private static let cityMinimumZoomLevel = 9.0 /// 현재 줌 레벨에 해당하는 조회 방식, 줌 레벨을 모르면 주변 장소 private var currentFetchMode: FetchMode { From cc7a19ad78432f2984cc1da62e710f37aa0a79e2 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 9 Oct 2026 01:03:17 +0900 Subject: [PATCH 24/27] =?UTF-8?q?DOCS:=20=EC=A3=BC=EB=B3=80=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=A1=B0=ED=9A=8C=20=EA=B4=80=EB=A0=A8=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=9D=84=20=EC=A7=80=EC=97=AD=EB=AA=85=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=99=80=20=EA=B0=80=EC=A4=91=EC=B9=98=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Data/KakaoNearbyPlaceRepository.swift | 2 +- .../Map/PlaceSelection/PlaceSelectionViewModel.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift index 1bc8f8e..32083d9 100644 --- a/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift @@ -7,7 +7,7 @@ import Foundation -/// 좌표 반경 내 가장 가까운 장소 1개 조회 (지도 장소 선택, 현재 위치 표시용, 키워드 검색과 무관) +/// 좌표 반경 내 거리와 대표성을 고려한 장소 1개 조회 (지도 장소 선택, 현재 위치 표시용, 키워드 검색과 무관) /// 카테고리 검색이 요청당 카테고리 1개만 지원해, 서비스가 다루는 카테고리를 병렬 요청 후 거리와 카테고리 대표성을 함께 고려해 선택 /// 지도 이동 1회당 카테고리 수만큼 호출 발생 — PlaceSelectionViewModel의 디바운스/최소 이동 거리로 호출량 제한 final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift index fbe2437..4b021b2 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewModel.swift @@ -12,9 +12,9 @@ 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? @@ -30,7 +30,7 @@ final class PlaceSelectionViewModel { private var lastFetchedCoordinate: Coordinate? /// 마지막으로 실제 조회를 실행했던 조회 방식 private var lastFetchedMode: FetchMode? - /// 진행 중인 근처 장소 조회 Task — 새 좌표가 들어오면 이전 Task를 취소해 응답 경쟁을 막음 + /// 진행 중인 근처 장소·지역명 조회 Task — 새 좌표가 들어오면 이전 Task를 취소해 응답 경쟁을 막음 private var nearbyPlaceTask: Task? /// 줌 레벨에 따른 조회 방식 From 783581bbf796bc2dac1dfec9fbd71f797c78580e Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 16 Oct 2026 20:31:01 +0900 Subject: [PATCH 25/27] =?UTF-8?q?FIX:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=9E=AC=EC=A0=95=EB=A0=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B1=B0=EB=A6=AC=20=EC=A0=95=EB=B3=B4=EA=B0=80=20?= =?UTF-8?q?=EC=9D=BC=EB=B6=80=20=EC=97=86=EC=9D=84=20=EB=95=8C=EB=8F=84=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EA=B8=B0=EC=A4=80=EC=9D=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/Kakao/KakaoPlaceRanker.swift | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift index 5805332..6893f65 100644 --- a/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift +++ b/WhereAreYou/WhereAreYou/Data/Network/Kakao/KakaoPlaceRanker.swift @@ -35,12 +35,8 @@ nonisolated enum KakaoPlaceRanker { let normalizedKeyword = normalize(keyword) guard !normalizedKeyword.isEmpty else { return documents } - let usesDistance = documents.allSatisfy { $0.distanceMeters != nil } - return documents.enumerated() - .map { - candidate(of: $1, at: $0, normalizedKeyword: normalizedKeyword, usesDistance: usesDistance) - } + .map { candidate(of: $1, at: $0, normalizedKeyword: normalizedKeyword) } .sorted(by: isOrderedBefore) .map(\.document) } @@ -50,8 +46,7 @@ nonisolated enum KakaoPlaceRanker { private static func candidate( of document: KakaoPlaceDocument, at index: Int, - normalizedKeyword: String, - usesDistance: Bool + normalizedKeyword: String ) -> Candidate { let normalizedName = normalize(document.placeName) let representativeness = KakaoCategoryGroupCode(rawValue: document.categoryGroupCode)?.representativeness @@ -65,7 +60,7 @@ nonisolated enum KakaoPlaceRanker { originalIndex: index, matchLevel: matchLevel(name: normalizedName, keyword: normalizedKeyword), representativeness: representativeness, - distanceMeters: usesDistance ? document.distanceMeters : nil, + distanceMeters: document.distanceMeters, lengthRatio: lengthRatio ) } @@ -81,9 +76,10 @@ nonisolated enum KakaoPlaceRanker { 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 } - if let lhsDistance = lhs.distanceMeters, let rhsDistance = rhs.distanceMeters, lhsDistance != rhsDistance { - return lhsDistance < rhsDistance - } + // 거리를 모르는 문서는 가장 먼 것으로 취급 — 일부만 거리가 있어도 비교 순서가 일관됨 + 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 } From 7c7b455cdfd73529620dc2dfffe199807b9a3c1e Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 16 Oct 2026 20:35:42 +0900 Subject: [PATCH 26/27] =?UTF-8?q?FIX:=20=EC=8B=9C/=EA=B5=B0/=EA=B5=AC?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EB=8A=94=20=EC=A7=80=EC=97=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=8B=9C/=EA=B5=B0/=EA=B5=AC=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=8B=9C/=EB=8F=84=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Kakao/Response/KakaoAddressResponse.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoAddressResponse.swift b/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoAddressResponse.swift index 3e0e71b..4e957fc 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoAddressResponse.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/Response/KakaoAddressResponse.swift @@ -94,10 +94,12 @@ extension KakaoAddressResponse { case .province: name = province case .city: - guard let city, !city.isEmpty else { - throw NetworkError.emptyResult + // 세종처럼 시/군/구가 없는 지역은 시/도 이름으로 대체 + if let city, !city.isEmpty { + name = city + } else { + name = province } - name = city } let address = [province, city] .compactMap { $0 } From 27ed3ab6d139c279c844138d43b9a80888fa4c63 Mon Sep 17 00:00:00 2001 From: snughnu Date: Fri, 16 Oct 2026 20:38:45 +0900 Subject: [PATCH 27/27] =?UTF-8?q?REFACTOR:=20=EC=A3=BC=EB=B3=80=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=ED=9B=84=EB=B3=B4=EC=9D=98=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EA=B8=B0=EC=A4=80=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?score=EC=97=90=EC=84=9C=20rank=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift index 32083d9..f59c25c 100644 --- a/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/Kakao/Repository/KakaoNearbyPlaceRepository.swift @@ -36,8 +36,8 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { let distanceMeters: Double let categoryCode: KakaoCategoryGroupCode - /// 선택 점수, 낮을수록 우선 - var score: Double { + /// 선택 순위, 낮을수록 우선 + var rank: Double { distanceMeters / categoryCode.representativeness } } @@ -86,7 +86,7 @@ final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { if !hasSucceeded, let firstFailure { throw firstFailure } - return candidates.min { $0.score < $1.score }?.place + return candidates.min { $0.rank < $1.rank }?.place } // MARK: - Private