Skip to content
Merged
Show file tree
Hide file tree
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 Sep 28, 2026
832b470
FEAT: Info.plist 기반 API 키 조회 타입 추가
snughnu Sep 28, 2026
53ee76d
FEAT: 네트워크 에러 타입 및 AppError 매핑 추가
snughnu Sep 28, 2026
9ac3266
FEAT: 네트워크 요청 Endpoint 프로토콜 추가
snughnu Sep 28, 2026
78d566e
FEAT: Repository 요청 취소용 CancellableRequest 프로토콜 추가
snughnu Sep 28, 2026
9bec219
FEAT: URLSession 기반 APIClient 구현
snughnu Sep 28, 2026
691e4b1
REFACTOR: 장소 Repository가 요청 취소 핸들을 반환하도록 변경
snughnu Sep 28, 2026
05c5946
REFACTOR: 장소 Repository·UseCase·ViewModel을 async throws/Task 기반으로 전환
snughnu Sep 28, 2026
630b7a7
FIX: 빈 키워드로 장소 검색 요청이 발생하지 않도록 수정
snughnu Sep 28, 2026
aa8b459
FIX: 장소 검색 실패 시 이전 결과를 비우고 실패 문구 표시
snughnu Sep 28, 2026
74c1cac
FEAT: 카카오 로컬 API Endpoint 정의
snughnu Sep 28, 2026
7a349f0
FEAT: 카카오 장소 검색 응답 DTO 및 Place 매핑 추가
snughnu Sep 28, 2026
4899d68
FEAT: 카카오 좌표-주소 변환 응답 DTO 및 Place 매핑 추가
snughnu Sep 28, 2026
6d25825
FEAT: 카카오 로컬 API 기반 ReverseGeocodingRepository 구현
snughnu Sep 28, 2026
74d8827
REFACTOR: ReverseGeocodingRepository를 카카오 구현으로 교체
snughnu Sep 28, 2026
b33eef7
FEAT: 카카오 카테고리 검색 기반 NearbyPlaceRepository 구현
snughnu Sep 28, 2026
4b5b6fb
REFACTOR: NearbyPlaceRepository를 카카오 구현으로 교체
snughnu Sep 28, 2026
5b455d6
FEAT: 카카오 키워드 검색 기반 PlaceSearchRepository 구현
snughnu Sep 28, 2026
0cbc54a
REFACTOR: PlaceSearchRepository를 카카오 구현으로 교체
snughnu Sep 28, 2026
1ea0bbe
FEAT: 채팅 내 위치 공유 확인창에 현재 위치의 장소명 표시
snughnu Sep 28, 2026
855aa7a
FEAT: 경로 검색 현재 위치 출발지에 실제 장소명 표시
snughnu Sep 28, 2026
d8a8521
REFACTOR: KakaoAddressResponse의 AppError 직접 생성을 NetworkError 경유로 변경
snughnu Sep 28, 2026
0213044
REFACTOR: KakaoNearbyPlaceRepository가 카테고리 요청 실패와 결과 없음을 구분하도록 변경
snughnu Sep 28, 2026
19f7c98
DOCS: KakaoNearbyPlaceRepository 주석에 주변 장소 조회 목적 명시
snughnu Oct 2, 2026
0d4bf1f
REFACTOR: 현재 위치 조회와 근처 장소 조회를 GetCurrentLocationPlaceUseCase로 통합
snughnu Oct 2, 2026
8b58573
REFACTOR: 장소 검색 카드의 결과 비우기를 applyFilter 대신 filteredPlaces 직접 초기화로 변경
snughnu Oct 2, 2026
49d4c5b
Merge branch 'develop' into WORK-45
snughnu Oct 2, 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 @@ -54,10 +54,19 @@ extension DIContainer {
// 채팅
register(ChatRepository.self) { MockChatRepository() }

// 네트워크
register(APIClient.self, instance: URLSessionAPIClient())

// 장소 검색/조회
register(NearbyPlaceRepository.self) { MockNearbyPlaceRepository() }
register(PlaceSearchRepository.self) { MockPlaceSearchRepository() }
register(ReverseGeocodingRepository.self) { MockReverseGeocodingRepository() }
register(NearbyPlaceRepository.self) {
KakaoNearbyPlaceRepository(apiClient: self.resolve(APIClient.self))
}
register(PlaceSearchRepository.self) {
KakaoPlaceSearchRepository(apiClient: self.resolve(APIClient.self))
}
register(ReverseGeocodingRepository.self) {
KakaoReverseGeocodingRepository(apiClient: self.resolve(APIClient.self))
}
register(SharedPlaceRepository.self) { MockSharedPlaceRepository() }

// 길찾기
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ extension ScreenFactory {
getCurrentLocationUseCase: GetCurrentLocationUseCase(
repository: container.resolve(LocationRepository.self)
),
getCurrentLocationPlaceUseCase: GetCurrentLocationPlaceUseCase(
locationRepository: container.resolve(LocationRepository.self),
getNearbyPlaceUseCase: GetNearbyPlaceUseCase(
nearbyPlaceRepository: container.resolve(NearbyPlaceRepository.self),
reverseGeocodingRepository: container.resolve(ReverseGeocodingRepository.self)
)
),
shareLocationUseCase: ShareLocationUseCase(repository: chatRepository),
sharePlaceUseCase: SharePlaceUseCase(
chatRepository: chatRepository,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ extension ScreenFactory {
searchRoutesUseCase: SearchRoutesUseCase(
repository: container.resolve(RouteSearchRepository.self)
),
getCurrentLocationUseCase: GetCurrentLocationUseCase(
repository: container.resolve(LocationRepository.self)
getCurrentLocationPlaceUseCase: GetCurrentLocationPlaceUseCase(
locationRepository: container.resolve(LocationRepository.self),
getNearbyPlaceUseCase: GetNearbyPlaceUseCase(
nearbyPlaceRepository: container.resolve(NearbyPlaceRepository.self),
reverseGeocodingRepository: container.resolve(ReverseGeocodingRepository.self)
)
)
)
return RouteSearchViewController(viewModel: viewModel, departure: departure, destination: destination)
Expand Down
2 changes: 2 additions & 0 deletions WhereAreYou/WhereAreYou/Core/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>NMFClientId</key>
<string>$(NMF_CLIENT_ID)</string>
<key>KakaoRestAPIKey</key>
<string>$(KAKAO_REST_API_KEY)</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
2 changes: 2 additions & 0 deletions WhereAreYou/WhereAreYou/Data/CoreLocationRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ final class CoreLocationRepository: NSObject, LocationRepository {
}

func getCurrentLocation(completion: @escaping (Result<Coordinate, Error>) -> Void) {
// 이전 요청이 끝나기 전 재호출 시 이전 completion을 실패로 마무리 (async 변환 시 continuation 누수 방지)
self.completion?(.failure(LocationError.unableToFetch))
self.completion = completion

switch locationManager.authorizationStatus {
Expand Down
102 changes: 102 additions & 0 deletions WhereAreYou/WhereAreYou/Data/KakaoNearbyPlaceRepository.swift
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 {
Comment thread
sangYuLv marked this conversation as resolved.

/// 카카오 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 {
Comment thread
snughnu marked this conversation as resolved.
return .failed(error)
}
}
}
25 changes: 25 additions & 0 deletions WhereAreYou/WhereAreYou/Data/KakaoPlaceSearchRepository.swift
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 WhereAreYou/WhereAreYou/Data/KakaoReverseGeocodingRepository.swift
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
}
}
}
11 changes: 3 additions & 8 deletions WhereAreYou/WhereAreYou/Data/MockNearbyPlaceRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,13 @@ import Foundation

final class MockNearbyPlaceRepository: NearbyPlaceRepository {

func fetchNearbyPlace(
coordinate: Coordinate,
radiusKm: Double,
completion: @escaping (Result<Place?, Error>) -> Void
) {
func fetchNearbyPlace(coordinate: Coordinate, radiusKm: Double) async throws -> Place? {
let nearest = Self.mockPlaces
.filter { $0.coordinate.distance(to: coordinate) <= radiusKm }
.min { $0.coordinate.distance(to: coordinate) < $1.coordinate.distance(to: coordinate) }

DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
completion(.success(nearest))
}
try await Task.sleep(for: .milliseconds(300))
return nearest
}

private static let mockPlaces: [Place] = [
Expand Down
18 changes: 3 additions & 15 deletions WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,10 @@ import Foundation

final class MockPlaceSearchRepository: PlaceSearchRepository {

func searchPlaces(
keyword: String,
completion: @escaping (Result<[Place], Error>) -> Void
) {
func searchPlaces(keyword: String) async throws -> [Place] {
let trimmed = keyword.trimmingCharacters(in: .whitespaces)
let results: [Place]

if trimmed.isEmpty {
results = []
} else {
results = Self.mockPlaces
}

DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
completion(.success(results))
}
try await Task.sleep(for: .milliseconds(300))
return trimmed.isEmpty ? [] : Self.mockPlaces
}

private static let mockPlaces: [Place] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ import Foundation

final class MockReverseGeocodingRepository: ReverseGeocodingRepository {

func reverseGeocode(
coordinate: Coordinate,
completion: @escaping (Result<Place, Error>) -> Void
) {
func reverseGeocode(coordinate: Coordinate) async throws -> Place {
let (roadName, fullAddress) = Self.mockAddress(near: coordinate)
let place = Place(
id: "address_\(coordinate.latitude)_\(coordinate.longitude)",
Expand All @@ -22,9 +19,8 @@ final class MockReverseGeocodingRepository: ReverseGeocodingRepository {
type: .other
)

DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
completion(.success(place))
}
try await Task.sleep(for: .milliseconds(300))
return place
}

/// 실제 API의 도로명주소 응답 형태를 흉내낸 더미 주소 생성
Expand Down
61 changes: 61 additions & 0 deletions WhereAreYou/WhereAreYou/Data/Network/APIClient.swift
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
}
}
}
31 changes: 31 additions & 0 deletions WhereAreYou/WhereAreYou/Data/Network/APIKey.swift
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
}
}
Loading
Loading