Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d3cde56
FIX: 장소 검색 시 키워드 앞뒤 공백을 제거해 요청하도록 수정
snughnu Oct 8, 2026
260c75a
FEAT: 카카오 키워드 검색 Endpoint에 page 파라미터 추가
snughnu Oct 8, 2026
540f34f
FEAT: 카카오 키워드 검색을 2페이지 병렬 조회로 확장
snughnu Oct 8, 2026
328f2d1
FEAT: 카카오 카테고리 그룹 코드별 대표성 가중치 추가
snughnu Oct 8, 2026
68a1e22
FEAT: 카카오 키워드 검색 결과 재정렬기 추가
snughnu Oct 8, 2026
f5dc456
FEAT: 카카오 키워드 검색 결과에 대표성 재정렬 적용
snughnu Oct 8, 2026
0563744
REFACTOR: KakaoNearbyPlaceRepository가 카테고리별 상위 후보를 수집하도록 변경
snughnu Oct 8, 2026
8effcb6
FEAT: 주변 장소 선택에 카테고리 대표성 가중치 반영
snughnu Oct 8, 2026
cd07e04
FEAT: 주변 장소 조회 대상 카테고리에 공공기관·관광명소·문화시설 추가
snughnu Oct 8, 2026
deee084
FEAT: 카카오 키워드 검색 Endpoint에 좌표와 정렬 옵션 추가
snughnu Oct 8, 2026
63da8e0
FEAT: 키워드 검색 재정렬에 사용자 거리 기준 반영
snughnu Oct 8, 2026
764f885
REFACTOR: 장소 검색 Repository·UseCase에 선택적 기준 좌표 추가
snughnu Oct 8, 2026
d22b6f3
FEAT: 카카오 키워드 검색에 거리순 후보 풀 병합 추가
snughnu Oct 8, 2026
2719def
FEAT: 장소 검색 시 현재 위치를 조회해 거리 기준으로 전달
snughnu Oct 8, 2026
e3f70ce
REFACTOR: 지도 이동 시 주변 장소 조회의 디바운스와 최소 이동 거리 상향
snughnu Oct 8, 2026
3309829
FEAT: 주변 장소 조회 대상 카테고리를 카카오 전체 카테고리로 확장
snughnu Oct 8, 2026
0ec454c
REFACTOR: 주변 장소 조회 대상 카테고리를 10종으로 축소
snughnu Oct 8, 2026
ed71ab5
FEAT: 카카오 좌표-주소 변환 응답에 지역명 필드 추가
snughnu Oct 8, 2026
f994e1a
FEAT: 역지오코딩 Repository에 시/도·시/군/구 단위 조회 추가
snughnu Oct 8, 2026
d2f4641
FEAT: 지역 단위 장소 조회 GetRegionPlaceUseCase 추가
snughnu Oct 8, 2026
f867875
REFACTOR: 장소 선택 지도의 카메라 이동 시 줌 레벨을 ViewModel로 전달
snughnu Oct 8, 2026
822009e
FEAT: 줌 아웃 시 주변 장소 대신 시/도·시/군/구 지역명을 조회하도록 변경
snughnu Oct 8, 2026
7583fe8
FIX: 줌 아웃 지역명 조회의 줌 경계를 조정하고 시/도명을 정식 명칭으로 표시
snughnu Oct 8, 2026
cc7a19a
DOCS: 주변 장소 조회 관련 주석을 지역명 조회와 가중치 선택에 맞게 수정
snughnu Oct 8, 2026
90516b4
Merge branch 'develop' into WORK-59
snughnu Oct 16, 2026
783581b
FIX: 키워드 검색 재정렬에서 거리 정보가 일부 없을 때도 거리 기준을 적용하도록 수정
snughnu Oct 16, 2026
7c7b455
FIX: 시/군/구가 없는 지역에서 시/군/구 단위 조회 시 시/도 이름으로 대체
snughnu Oct 16, 2026
27ed3ab
REFACTOR: 주변 장소 후보의 선택 기준 이름을 score에서 rank로 변경
snughnu Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions WhereAreYou/WhereAreYou/Data/Kakao/KakaoCategoryGroupCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
27 changes: 22 additions & 5 deletions WhereAreYou/WhereAreYou/Data/Kakao/KakaoLocalEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
/// 좌표 -> 주소 변환
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment thread
snughnu marked this conversation as resolved.
}
}

/// 카테고리 하나 조회 결과 — 결과 없음(정상)과 요청 실패를 구분해 상위에서 정책을 결정할 수 있게 함
private enum CategoryResult {
case found(Place, distanceMeters: Double)
case found([Candidate])
case notFound
case failed(Error)
}
Expand All @@ -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] = []
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
snughnu marked this conversation as resolved.

var seenIDs = Set<String>()
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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading