-
Notifications
You must be signed in to change notification settings - Fork 1
[Feat] 제보하기 화면 구현(2차), 역지오코딩 로직 구현 #66
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| // | ||
| // KakaoLocationResponseDTO.swift | ||
| // DataSource | ||
| // | ||
| // Created by 이동현 on 11/10/25. | ||
| // | ||
|
|
||
| import Domain | ||
| import Foundation | ||
|
|
||
| struct KakaoLocationResponseDTO: Codable { | ||
| let meta: Meta | ||
| let documents: [Document] | ||
|
|
||
| struct Meta: Codable { | ||
| let totalCount: Int | ||
| let pageableCount: Int? | ||
| let isEnd: Bool? | ||
|
|
||
| enum CodingKeys: String, CodingKey { | ||
| case totalCount = "total_count" | ||
| case pageableCount = "pageable_count" | ||
| case isEnd = "is_end" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Document | ||
| struct Document: Codable { | ||
| let roadAddress: KakaoRoadAddress? | ||
| let address: KakaoAddress? | ||
|
|
||
| let addressName: String? | ||
| let y: String? // 위도 (latitude) | ||
| let x: String? // 경도 (longitude) | ||
| let addressType: String? | ||
|
|
||
| enum CodingKeys: String, CodingKey { | ||
| case addressName = "address_name" | ||
| case y, x | ||
| case addressType = "address_type" | ||
| case address | ||
| case roadAddress = "road_address" | ||
| } | ||
|
|
||
| var latitude: Double? { | ||
| if let y, let v = Double(y) { return v } | ||
| if let v = address?.latitude { return v } | ||
| if let v = roadAddress?.latitude { return v } | ||
| return nil | ||
| } | ||
|
|
||
| var longitude: Double? { | ||
| if let x, let v = Double(x) { return v } | ||
| if let v = address?.longitude { return v } | ||
| if let v = roadAddress?.longitude { return v } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| // MARK: - KakaoRoadAddress (도로명 주소) | ||
| struct KakaoRoadAddress: Codable { | ||
| let addressName: String | ||
| let region1depthName: String | ||
| let region2depthName: String | ||
| let region3depthName: String | ||
| let roadName: String | ||
| let undergroundYn: String? | ||
| let mainBuildingNo: String? | ||
| let subBuildingNo: String? | ||
| let buildingName: String? | ||
| let zoneNo: String? | ||
| let y: String? | ||
| let x: String? | ||
|
|
||
| enum CodingKeys: String, CodingKey { | ||
| case addressName = "address_name" | ||
| case region1depthName = "region_1depth_name" | ||
| case region2depthName = "region_2depth_name" | ||
| case region3depthName = "region_3depth_name" | ||
| case roadName = "road_name" | ||
| case undergroundYn = "underground_yn" | ||
| case mainBuildingNo = "main_building_no" | ||
| case subBuildingNo = "sub_building_no" | ||
| case buildingName = "building_name" | ||
| case zoneNo = "zone_no" | ||
| case y, x | ||
| } | ||
|
|
||
| var latitude: Double? { y.flatMap(Double.init) } | ||
| var longitude: Double? { x.flatMap(Double.init) } | ||
| } | ||
|
|
||
| // MARK: - Address (지번 주소) | ||
| struct KakaoAddress: Codable { | ||
| let addressName: String | ||
| let region1depthName: String | ||
| let region2depthName: String | ||
| let region3depthName: String | ||
| let region3depthHName: String? | ||
| let hCode: String? | ||
| let bCode: String? | ||
| let mountainYn: String? | ||
| let mainAddressNo: String? | ||
| let subAddressNo: String? | ||
| let x: String? | ||
| let y: String? | ||
| let zipCode: String? | ||
|
|
||
| enum CodingKeys: String, CodingKey { | ||
| case addressName = "address_name" | ||
| case region1depthName = "region_1depth_name" | ||
| case region2depthName = "region_2depth_name" | ||
| case region3depthName = "region_3depth_name" | ||
| case region3depthHName = "region_3depth_h_name" | ||
| case hCode = "h_code" | ||
| case bCode = "b_code" | ||
| case mountainYn = "mountain_yn" | ||
| case mainAddressNo = "main_address_no" | ||
| case subAddressNo = "sub_address_no" | ||
| case x, y | ||
| case zipCode = "zip_code" | ||
| } | ||
|
|
||
| var latitude: Double? { y.flatMap(Double.init) } | ||
| var longitude: Double? { x.flatMap(Double.init) } | ||
| } | ||
|
|
||
| // MARK: - Mapping | ||
| extension KakaoLocationResponseDTO { | ||
| func toLocationEntity(fallbackLongitude: Double? = nil, fallbackLatitude: Double? = nil) -> LocationEntity? { | ||
| guard let doc = documents.first else { return nil } | ||
| let longitude = doc.longitude ?? fallbackLongitude | ||
| let latitude = doc.latitude ?? fallbackLatitude | ||
| let address = doc.roadAddress?.addressName ?? doc.address?.addressName | ||
|
|
||
| return LocationEntity(longitude: longitude ?? 0, latitude: latitude ?? 0, address: address) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| // | ||
| // LocationEndpoint.swift | ||
| // DataSource | ||
| // | ||
| // Created by 이동현 on 11/10/25. | ||
| // | ||
|
|
||
| enum LocationEndpoint { | ||
| case fetchAddress(longitude: Double, latitude: Double) | ||
| } | ||
|
|
||
| extension LocationEndpoint: Endpoint { | ||
| var baseURL: String { | ||
| switch self { | ||
| case .fetchAddress: | ||
| "https://dapi.kakao.com/v2" | ||
| } | ||
| } | ||
|
|
||
| var path: String { | ||
| switch self { | ||
| case .fetchAddress: | ||
| return baseURL + "/local/geo/coord2address.json" | ||
| } | ||
| } | ||
|
|
||
| var method: HTTPMethod { | ||
| switch self { | ||
| case .fetchAddress: | ||
| return .get | ||
| } | ||
| } | ||
|
|
||
| var headers: [String : String] { | ||
| let headers: [String: String] = [ | ||
| "Authorization": "KakaoAK \(AppProperties.kakaoApiKey)", | ||
| ] | ||
| return headers | ||
| } | ||
|
|
||
| var queryParameters: [String : String] { | ||
| switch self { | ||
| case .fetchAddress(let longitude, let latitude): | ||
| ["x": "\(longitude)", "y": "\(latitude)"] | ||
| } | ||
| } | ||
|
|
||
| var bodyParameters: [String : Any] { | ||
| return [:] | ||
| } | ||
|
|
||
| var isAuthorized: Bool { | ||
| return false | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,13 +81,18 @@ final class NetworkService { | |
| guard !data.isEmpty else { throw NetworkError.emptyData } | ||
|
|
||
| do { | ||
| let baseResponse = try decoder.decode(BaseResponse<T>.self, from: data) | ||
| let bitnagilResponse = try decoder.decode(BaseResponse<T>.self, from: data) | ||
|
|
||
| guard let responseDTO = baseResponse.data else { return nil } | ||
| guard let responseDTO = bitnagilResponse.data else { return nil } | ||
|
|
||
| return responseDTO | ||
| } catch { | ||
| throw NetworkError.decodingError | ||
| do { | ||
| let generalResponse = try decoder.decode(T.self, from: data) | ||
| return generalResponse | ||
|
Comment on lines
+91
to
+92
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 요 부분이 리뷰 노트에 말씀해주신 부분이시죵 !! 완전 이해 완 ~~~ |
||
| } catch { | ||
| throw NetworkError.decodingError | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| // | ||
| // LocationRepository.swift | ||
| // DataSource | ||
| // | ||
| // Created by 이동현 on 11/9/25. | ||
| // | ||
|
|
||
| import CoreLocation | ||
| import Domain | ||
|
|
||
| final class LocationRepository: NSObject, LocationRepositoryProtocol { | ||
| private let networkService = NetworkService.shared | ||
| private let locationManager = CLLocationManager() | ||
| private var continuation: CheckedContinuation<LocationEntity?, Never>? | ||
| private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>? | ||
|
|
||
| override init() { | ||
| super.init() | ||
|
|
||
| locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters | ||
| locationManager.delegate = self | ||
| } | ||
|
|
||
| func fetchCoordinate() async -> LocationEntity? { | ||
| guard CLLocationManager.locationServicesEnabled() else { return nil } | ||
|
|
||
| let currentStatus = await requestAuthorizationIfNeeded() | ||
|
|
||
| if currentStatus == .authorizedAlways || currentStatus == .authorizedWhenInUse { | ||
| return await withCheckedContinuation { continuation in | ||
| self.continuation = continuation | ||
| locationManager.requestLocation() | ||
| } | ||
| } else { | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| func fetchAddress(coordinate: LocationEntity) async throws -> LocationEntity? { | ||
| let endpoint = LocationEndpoint.fetchAddress(longitude: coordinate.longitude, latitude: coordinate.latitude) | ||
|
|
||
| guard let response = try await networkService.request(endpoint: endpoint, type: KakaoLocationResponseDTO.self) | ||
| else { return nil } | ||
|
|
||
| let location = response.toLocationEntity(fallbackLongitude: coordinate.longitude, fallbackLatitude: coordinate.latitude) | ||
| return location | ||
| } | ||
|
|
||
| private func requestAuthorizationIfNeeded() async -> CLAuthorizationStatus { | ||
| let currentStatus = locationManager.authorizationStatus | ||
|
|
||
| switch currentStatus { | ||
| case .authorizedAlways, .authorizedWhenInUse: | ||
| return currentStatus | ||
| case .denied, .restricted: | ||
| return currentStatus | ||
| case .notDetermined: | ||
| return await withCheckedContinuation { (continuation: CheckedContinuation<CLAuthorizationStatus, Never>) in | ||
| self.authContinuation?.resume(returning: currentStatus) | ||
| self.authContinuation = continuation | ||
| self.locationManager.requestWhenInUseAuthorization() | ||
| } | ||
| default: | ||
| return currentStatus | ||
| } | ||
| } | ||
|
Comment on lines
+49
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain위치 권한 요청용
그 결과:
보통은 extension LocationRepository: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
authContinuation?.resume(returning: status)
authContinuation = nil
}
// 기존 didUpdateLocations / didFailWithError 유지
}또한 self.authContinuation?.resume(returning: currentStatus)
self.authContinuation = continuation부분은 이전 continuation을 안전하게 정리하려는 의도라면, 실제로 Also applies to: 14-15, 69-81 Length of output: 3004 🏁 Script executed: #!/bin/bash
# Search for authContinuation usage and CLLocationManagerDelegate in the file
rg -n "authContinuation|locationManagerDidChangeAuthorization|CLLocationManagerDelegate" "Projects/DataSource/Sources/Repository/"Length of output: 535 🏁 Script executed: #!/bin/bash
# Check the full context around lines 14-15, 49-66, and 69-81
rg -n -B2 -A8 "requestAuthorizationIfNeeded|func locationManager" "Projects/DataSource/Sources/Repository/LocationRepository.swift"Length of output: 1514
검증 결과, 리뷰 의견이 정확합니다:
추가로 59-60줄의 로직도 문제가 있습니다: self.authContinuation?.resume(returning: currentStatus) // 이전 continuation 재개 시도
self.authContinuation = continuation // 새 continuation 설정첫 호출 시 해결 방법: CLLocationManagerDelegate 확장에 다음 메서드를 추가하세요: func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
authContinuation?.resume(returning: status)
authContinuation = nil
}또한 59-60줄을 정리하여 명확하게 변경하세요. 🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 래빗아 .. Continuation 넘 어렵다 ..................... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @choijungp 이해합니다! Continuation은 처음 접하면 정말 어려운 개념이에요. 쉽게 설명해드릴게요! 😊 🎫 Continuation을 식당 대기표로 생각해보세요현재 코드의 문제:
해결 방법 (간단 버전)// 1️⃣ 권한 요청할 때 대기표 받기
private func requestAuthorizationIfNeeded() async -> CLAuthorizationStatus {
// ... 기존 코드 ...
case .notDetermined:
return await withCheckedContinuation { continuation in
self.authContinuation = continuation // 대기표 보관
self.locationManager.requestWhenInUseAuthorization() // 권한 요청
}
}
// 2️⃣ 권한 변경될 때 호명하기 - 이게 없어서 문제였어요!
extension LocationRepository: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
// 권한이 변경되면 대기 중인 사람을 깨워줍니다
authContinuation?.resume(returning: manager.authorizationStatus)
authContinuation = nil // 대기표 처리 완료
}
// 기존 메서드들...
}이렇게 하면 사용자가 권한을 허용/거부하는 순간 천천히 이해하시면 됩니다. 궁금한 점 있으면 언제든 물어보세요! 💪 🧠 Learnings used |
||
| } | ||
|
|
||
| extension LocationRepository: CLLocationManagerDelegate { | ||
| func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { | ||
| let coordinate = locations.last?.coordinate | ||
| let entity = coordinate.map { LocationEntity(longitude: $0.longitude, latitude: $0.latitude, address: nil) } | ||
| continuation?.resume(returning: entity) | ||
| continuation = nil | ||
| } | ||
|
|
||
| func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { | ||
| continuation?.resume(returning: nil) | ||
| continuation = nil | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| // | ||
| // ReportRepository.swift | ||
| // DataSource | ||
| // | ||
| // Created by 이동현 on 11/9/25. | ||
| // | ||
|
|
||
| import Domain | ||
|
|
||
| final class ReportRepository: ReportRepositoryProtocol { | ||
| func report(reportEntity: Domain.ReportEntity) async { | ||
|
|
||
| } | ||
|
Comment on lines
+11
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 요기는 추후 서버 연동할 부분인 것이지요 !!?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네네 맞습니다요 |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| // | ||
| // LocationEntity.swift | ||
| // Domain | ||
| // | ||
| // Created by 이동현 on 11/9/25. | ||
| // | ||
|
|
||
| public struct LocationEntity { | ||
| public let longitude: Double | ||
| public let latitude: Double | ||
| public let address: String? | ||
|
|
||
| public init( | ||
| longitude: Double, | ||
| latitude: Double, | ||
| address: String? | ||
| ) { | ||
| self.longitude = longitude | ||
| self.latitude = latitude | ||
| self.address = address | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // | ||
| // ReportEntity.swift | ||
| // Domain | ||
| // | ||
| // Created by 이동현 on 11/9/25. | ||
| // | ||
|
|
||
| public struct ReportEntity { | ||
|
|
||
| } | ||
|
Comment on lines
+8
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ReportEntity 구조체 구현 필요 현재 빈 구조체로 정의되어 있습니다. 제보 기능을 완성하려면 필요한 속성들(예: 위치 정보, 카테고리, 설명, 이미지 등)을 정의해야 합니다. 제보 데이터 모델의 속성 정의를 도와드릴까요? 어떤 정보들을 포함해야 하는지 알려주시면 구조체 구현을 제안해드리겠습니다. 🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서버 연동할 때 채울 예정이야 ~~ 돈와리 래빗 ~ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @choijungp, 알겠습니다! 서버 연동 시점에 구현하시는 것이 합리적이네요. API 스펙이 확정되면 그에 맞춰 필드를 정의하시면 되겠습니다. 👍 그때 필요하시면 언제든 도움 요청해주세요! ˙˙˙˙˙ ʕ •ᴥ•ʔ 🧠 Learnings used |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좌표가 nil일 때 (0, 0) 반환을 재고하세요.
137번째 줄에서
longitude ?? 0와latitude ?? 0를 사용하면 좌표를 가져오지 못했을 때 (0, 0)이 반환됩니다. 이는 기니만(Gulf of Guinea)의 실제 위치이므로 잘못된 좌표로 오해될 수 있습니다.좌표가 없는 경우 nil을 반환하거나, LocationEntity를 Optional로 만드는 것을 고려하세요.
다음 중 하나를 선택하세요:
옵션 1: nil 반환 (권장)
func toLocationEntity(fallbackLongitude: Double? = nil, fallbackLatitude: Double? = nil) -> LocationEntity? { guard let doc = documents.first else { return nil } let longitude = doc.longitude ?? fallbackLongitude let latitude = doc.latitude ?? fallbackLatitude + + guard let longitude = longitude, let latitude = latitude else { return nil } let address = doc.roadAddress?.addressName ?? doc.address?.addressName - return LocationEntity(longitude: longitude ?? 0, latitude: latitude ?? 0, address: address) + return LocationEntity(longitude: longitude, latitude: latitude, address: address) }옵션 2: LocationEntity의 좌표를 Optional로 변경
(이 경우 Domain 계층의 LocationEntity 수정 필요)
🤖 Prompt for AI Agents