Repository navigation
[Work 45] 장소 검색/주변 장소/역지오코딩을 카카오 로컬 API로 연동했습니다. #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
5ccb6ae
CHORE: 카카오 REST API 키 Info.plist 항목 추가
snughnu 832b470
FEAT: Info.plist 기반 API 키 조회 타입 추가
snughnu 53ee76d
FEAT: 네트워크 에러 타입 및 AppError 매핑 추가
snughnu 9ac3266
FEAT: 네트워크 요청 Endpoint 프로토콜 추가
snughnu 78d566e
FEAT: Repository 요청 취소용 CancellableRequest 프로토콜 추가
snughnu 9bec219
FEAT: URLSession 기반 APIClient 구현
snughnu 691e4b1
REFACTOR: 장소 Repository가 요청 취소 핸들을 반환하도록 변경
snughnu 05c5946
REFACTOR: 장소 Repository·UseCase·ViewModel을 async throws/Task 기반으로 전환
snughnu 630b7a7
FIX: 빈 키워드로 장소 검색 요청이 발생하지 않도록 수정
snughnu aa8b459
FIX: 장소 검색 실패 시 이전 결과를 비우고 실패 문구 표시
snughnu 74c1cac
FEAT: 카카오 로컬 API Endpoint 정의
snughnu 7a349f0
FEAT: 카카오 장소 검색 응답 DTO 및 Place 매핑 추가
snughnu 4899d68
FEAT: 카카오 좌표-주소 변환 응답 DTO 및 Place 매핑 추가
snughnu 6d25825
FEAT: 카카오 로컬 API 기반 ReverseGeocodingRepository 구현
snughnu 74d8827
REFACTOR: ReverseGeocodingRepository를 카카오 구현으로 교체
snughnu b33eef7
FEAT: 카카오 카테고리 검색 기반 NearbyPlaceRepository 구현
snughnu 4b5b6fb
REFACTOR: NearbyPlaceRepository를 카카오 구현으로 교체
snughnu 5b455d6
FEAT: 카카오 키워드 검색 기반 PlaceSearchRepository 구현
snughnu 0cbc54a
REFACTOR: PlaceSearchRepository를 카카오 구현으로 교체
snughnu 1ea0bbe
FEAT: 채팅 내 위치 공유 확인창에 현재 위치의 장소명 표시
snughnu 855aa7a
FEAT: 경로 검색 현재 위치 출발지에 실제 장소명 표시
snughnu d8a8521
REFACTOR: KakaoAddressResponse의 AppError 직접 생성을 NetworkError 경유로 변경
snughnu 0213044
REFACTOR: KakaoNearbyPlaceRepository가 카테고리 요청 실패와 결과 없음을 구분하도록 변경
snughnu 19f7c98
DOCS: KakaoNearbyPlaceRepository 주석에 주변 장소 조회 목적 명시
snughnu 0d4bf1f
REFACTOR: 현재 위치 조회와 근처 장소 조회를 GetCurrentLocationPlaceUseCase로 통합
snughnu 8b58573
REFACTOR: 장소 검색 카드의 결과 비우기를 applyFilter 대신 filteredPlaces 직접 초기화로 변경
snughnu 49d4c5b
Merge branch 'develop' into WORK-45
snughnu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
102 changes: 102 additions & 0 deletions
102
WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| // | ||
| // KakaoNearbyPlaceRepository.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 김성훈 on 9/29/26. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// 좌표 반경 내 가장 가까운 장소 1개 조회 (지도 장소 선택, 현재 위치 표시용, 키워드 검색과 무관) | ||
| /// 카테고리 검색이 요청당 카테고리 1개만 지원해, 서비스가 다루는 카테고리를 병렬 요청 후 가장 가까운 결과 선택 | ||
| /// 지도 이동 1회당 카테고리 수만큼 호출 발생 — PlaceSelectionViewModel의 디바운스/최소 이동 거리로 호출량 제한 | ||
| final class KakaoNearbyPlaceRepository: NearbyPlaceRepository { | ||
|
|
||
| /// 카카오 API가 허용하는 최대 반경(m) | ||
| private static let maximumRadiusMeters = 20000 | ||
|
|
||
| /// 조회 대상 카테고리 — 검색 화면 필터와 무관, PlaceType 매핑 대상만 포함 | ||
| private static let categoryCodes: [KakaoCategoryGroupCode] = [ | ||
| .subwayStation, .restaurant, .cafe, .hospital, .largeMart, .convenienceStore | ||
| ] | ||
|
|
||
| private let apiClient: APIClient | ||
|
|
||
| init(apiClient: APIClient) { | ||
| self.apiClient = apiClient | ||
| } | ||
|
|
||
| /// 카테고리 하나 조회 결과 — 결과 없음(정상)과 요청 실패를 구분해 상위에서 정책을 결정할 수 있게 함 | ||
| private enum CategoryResult { | ||
| case found(Place, distanceMeters: Double) | ||
| case notFound | ||
| case failed(Error) | ||
| } | ||
|
|
||
| func fetchNearbyPlace(coordinate: Coordinate, radiusKm: Double) async throws -> Place? { | ||
| let radiusMeters = min(Int(radiusKm * 1000), Self.maximumRadiusMeters) | ||
|
|
||
| let results = try await withThrowingTaskGroup(of: CategoryResult.self) { group in | ||
| for code in Self.categoryCodes { | ||
| group.addTask { | ||
| try await self.nearestDocument(code: code, coordinate: coordinate, radiusMeters: radiusMeters) | ||
| } | ||
| } | ||
| var collected: [CategoryResult] = [] | ||
| for try await result in group { | ||
| collected.append(result) | ||
| } | ||
| return collected | ||
| } | ||
|
|
||
| // 카테고리 중 하나라도 성공(결과 유무 무관)했으면 그 결과로 판단. | ||
| // 전부 요청 실패였을 때만 대표로 첫 실패를 throw — 상위(GetNearbyPlaceUseCase)가 역지오코딩 폴백 여부를 판단할 신호로 사용 | ||
| var best: (place: Place, distance: Double)? | ||
| var firstFailure: Error? | ||
| var hasSucceeded = false | ||
|
|
||
| for result in results { | ||
| switch result { | ||
| case .found(let place, let distance): | ||
| hasSucceeded = true | ||
| if best == nil || distance < best!.distance { | ||
| best = (place, distance) | ||
| } | ||
| case .notFound: | ||
| hasSucceeded = true | ||
| case .failed(let error): | ||
| if firstFailure == nil { firstFailure = error } | ||
| } | ||
| } | ||
|
|
||
| if !hasSucceeded, let firstFailure { | ||
| throw firstFailure | ||
| } | ||
| return best?.place | ||
| } | ||
|
|
||
| // MARK: - Private | ||
|
|
||
| /// 카테고리 하나를 조회해 결과 유무/실패를 구분해 반환. 취소는 흡수하지 않고 그대로 다시 throw | ||
| private func nearestDocument( | ||
| code: KakaoCategoryGroupCode, | ||
| coordinate: Coordinate, | ||
| radiusMeters: Int | ||
| ) async throws -> CategoryResult { | ||
| do { | ||
| let response: KakaoPlaceResponse = try await apiClient.request( | ||
| KakaoLocalEndpoint.category(code: code, coordinate: coordinate, radiusMeters: radiusMeters) | ||
| ) | ||
| guard let document = response.documents.first, | ||
| let place = document.toPlace(), | ||
| let distance = document.distanceMeters else { | ||
| return .notFound | ||
| } | ||
| return .found(place, distanceMeters: distance) | ||
| } catch is CancellationError { | ||
| throw CancellationError() | ||
| } catch { | ||
|
snughnu marked this conversation as resolved.
|
||
| return .failed(error) | ||
| } | ||
| } | ||
| } | ||
25 changes: 25 additions & 0 deletions
25
WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // | ||
| // KakaoPlaceSearchRepository.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 김성훈 on 9/29/26. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// 카카오 로컬 API 기반 키워드 장소 검색 | ||
| final class KakaoPlaceSearchRepository: PlaceSearchRepository { | ||
|
|
||
| private let apiClient: APIClient | ||
|
|
||
| init(apiClient: APIClient) { | ||
| self.apiClient = apiClient | ||
| } | ||
|
|
||
| func searchPlaces(keyword: String) async throws -> [Place] { | ||
| let response: KakaoPlaceResponse = try await apiClient.request( | ||
| KakaoLocalEndpoint.keyword(query: keyword) | ||
| ) | ||
| return response.documents.compactMap { $0.toPlace() } | ||
| } | ||
| } |
29 changes: 29 additions & 0 deletions
29
WhereAreYou/WhereAreYou/Data/KakaoReverseGeocodingRepository.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| // | ||
| // KakaoReverseGeocodingRepository.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 김성훈 on 9/29/26. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// 카카오 로컬 API 기반 역지오코딩 | ||
| final class KakaoReverseGeocodingRepository: ReverseGeocodingRepository { | ||
|
|
||
| private let apiClient: APIClient | ||
|
|
||
| init(apiClient: APIClient) { | ||
| self.apiClient = apiClient | ||
| } | ||
|
|
||
| func reverseGeocode(coordinate: Coordinate) async throws -> Place { | ||
| do { | ||
| let response: KakaoAddressResponse = try await apiClient.request( | ||
| KakaoLocalEndpoint.coord2address(coordinate: coordinate) | ||
| ) | ||
| return try response.toPlace(coordinate: coordinate) | ||
| } catch let error as NetworkError { | ||
| throw error.appError | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| // | ||
| // APIClient.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 김성훈 on 9/29/26. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// Endpoint 기반 네트워크 요청 실행기 | ||
| protocol APIClient { | ||
| func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T | ||
| } | ||
|
|
||
| /// URLSession 기반 APIClient 구현 | ||
| final class URLSessionAPIClient: APIClient { | ||
|
|
||
| private let session: URLSession | ||
| private let decoder: JSONDecoder | ||
|
|
||
| init(session: URLSession = .shared, decoder: JSONDecoder = JSONDecoder()) { | ||
| self.session = session | ||
| self.decoder = decoder | ||
| } | ||
|
|
||
| func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T { | ||
| let urlRequest: URLRequest | ||
| do { | ||
| urlRequest = try endpoint.makeURLRequest() | ||
| } catch { | ||
| throw (error as? NetworkError ?? .invalidURL).appError | ||
| } | ||
|
|
||
| let data: Data | ||
| let response: URLResponse | ||
| do { | ||
| (data, response) = try await session.data(for: urlRequest) | ||
| } catch { | ||
| throw NetworkError.transport(error).appError | ||
| } | ||
|
|
||
| return try decode(data: data, response: response) | ||
| } | ||
|
|
||
| // MARK: - Private | ||
|
|
||
| /// 응답 검증 및 디코딩 | ||
| private func decode<T: Decodable>(data: Data, response: URLResponse) throws -> T { | ||
| guard let httpResponse = response as? HTTPURLResponse else { | ||
| throw NetworkError.invalidResponse.appError | ||
| } | ||
| guard (200..<300).contains(httpResponse.statusCode) else { | ||
| throw NetworkError.httpStatus(httpResponse.statusCode).appError | ||
| } | ||
| do { | ||
| return try decoder.decode(T.self, from: data) | ||
| } catch { | ||
| throw NetworkError.decoding(error).appError | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| // | ||
| // APIKey.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 김성훈 on 9/29/26. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// Info.plist에 등록된 외부 API 키를 한 곳에서 읽어오기 위한 타입 | ||
| enum APIKey { | ||
|
|
||
| /// 카카오 로컬 API 호출에 사용하는 REST API 키 | ||
| static var kakaoRestAPIKey: String { | ||
| value(forInfoDictionaryKey: "KakaoRestAPIKey") | ||
| } | ||
|
|
||
| // MARK: - Private | ||
|
|
||
| /// Info.plist에서 키 값을 조회 | ||
| /// Config.xcconfig에 키가 설정되지 않았을 때 assertionFailure로 알림 | ||
| private static func value(forInfoDictionaryKey key: String) -> String { | ||
| guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String, | ||
| !value.isEmpty, | ||
| !value.hasPrefix("$(") else { | ||
| assertionFailure("Info.plist에 \(key) 값이 설정되어 있지 않습니다. Config.xcconfig를 확인하세요.") | ||
| return "" | ||
| } | ||
| return value | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.