Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -40,5 +40,13 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol {
DIContainer.shared.register(type: AppConfigRepositoryProtocol.self) { _ in
return AppConfigRepository()
}

DIContainer.shared.register(type: LocationRepositoryProtocol.self) { _ in
return LocationRepository()
}

DIContainer.shared.register(type: ReportRepositoryProtocol.self) { _ in
return ReportRepository()
}
}
}
4 changes: 4 additions & 0 deletions Projects/DataSource/Sources/Common/Enum/AppProperties.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ public enum AppProperties {
public static var kakaoNativeKey: String {
Bundle.main.object(forInfoDictionaryKey: "KakaoNativeKey") as? String ?? ""
}

public static var kakaoApiKey: String {
Bundle.main.object(forInfoDictionaryKey: "KakaoAPIKey") as? String ?? ""
}
}
139 changes: 139 additions & 0 deletions Projects/DataSource/Sources/DTO/KakaoLocationResponseDTO.swift
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)
}
Comment on lines +131 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

좌표가 nil일 때 (0, 0) 반환을 재고하세요.

137번째 줄에서 longitude ?? 0latitude ?? 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
Projects/DataSource/Sources/DTO/KakaoLocationResponseDTO.swift lines 131-138:
the current implementation substitutes missing longitude/latitude with 0 which
yields a misleading (0,0) location; instead either return nil when coordinates
are missing (recommended) or propagate optional coordinates into LocationEntity
(alternative). Implement the recommended fix by checking that doc.longitude and
doc.latitude (or the fallbacks) are non-nil before constructing a LocationEntity
and return nil if either is missing; if you opt for the alternative, update
LocationEntity to accept Optional<Double> for longitude/latitude and adjust all
call sites and domain types accordingly so they handle optional coordinates.

}
55 changes: 55 additions & 0 deletions Projects/DataSource/Sources/Endpoint/LocationEndpoint.swift
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
}
}
11 changes: 8 additions & 3 deletions Projects/DataSource/Sources/NetworkService/NetworkService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요 부분이 리뷰 노트에 말씀해주신 부분이시죵 !! 완전 이해 완 ~~~
따봉 !!!

} catch {
throw NetworkError.decodingError
}
}
}
}
81 changes: 81 additions & 0 deletions Projects/DataSource/Sources/Repository/LocationRepository.swift
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

@coderabbitai coderabbitai Bot Nov 15, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

위치 권한 요청용 authContinuation이 재개되지 않아 첫 호출이 영원히 대기 상태가 될 수 있습니다

requestAuthorizationIfNeeded()에서 .notDetermined인 경우 withCheckedContinuation을 사용해 authContinuation을 저장하고 requestWhenInUseAuthorization()을 호출하지만, 어디에서도 authContinuationresume하지 않고 있습니다.

그 결과:

  • 사용자가 권한 허용/거부를 선택해도 await requestAuthorizationIfNeeded()가 깨어나지 않아,
  • getCoordinate() 첫 호출이 영원히 대기 상태에 빠질 수 있습니다.

보통은 CLLocationManagerDelegate의 권한 변경 콜백에서 authContinuation을 재개해야 합니다. 예를 들어 아래와 같이 보완하는 것을 권장드립니다.

extension LocationRepository: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        let status = manager.authorizationStatus
        authContinuation?.resume(returning: status)
        authContinuation = nil
    }

    // 기존 didUpdateLocations / didFailWithError 유지
}

또한 requestAuthorizationIfNeeded() 내부의

self.authContinuation?.resume(returning: currentStatus)
self.authContinuation = continuation

부분은 이전 continuation을 안전하게 정리하려는 의도라면, 실제로 .notDetermined가 아닌 상태에서만 호출되도록 하거나, 중복 resume이 일어나지 않도록 좀 더 명시적으로 처리해 주시는 것이 좋겠습니다.

Also applies to: 14-15, 69-81



---

🏁 Script executed:

```shell
#!/bin/bash
# Locate and examine LocationRepository.swift
fd -t f "LocationRepository.swift" -x cat {}

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


authContinuation을 재개하는 locationManagerDidChangeAuthorization 델리게이트 메서드가 없어 권한 요청 시 무한 대기가 발생합니다

검증 결과, 리뷰 의견이 정확합니다:

  • requestAuthorizationIfNeeded() 메서드(49-66줄)의 .notDetermined 케이스에서 withCheckedContinuation으로 authContinuation을 설정하고 requestWhenInUseAuthorization()을 호출합니다.
  • 그러나 CLLocationManagerDelegate 확장(69-81줄)에는 didUpdateLocationsdidFailWithError만 구현되어 있고, locationManagerDidChangeAuthorization(_:) 메서드가 없습니다.
  • 따라서 사용자가 권한을 승인/거부해도 authContinuation이 재개되지 않으므로 await requestAuthorizationIfNeeded()는 영원히 대기 상태에 빠집니다.

추가로 59-60줄의 로직도 문제가 있습니다:

self.authContinuation?.resume(returning: currentStatus)  // 이전 continuation 재개 시도
self.authContinuation = continuation                      // 새 continuation 설정

첫 호출 시 authContinuation이 nil이면 59줄은 아무 작업도 하지 않으며, 중복 resume을 방지하는 명시적 처리가 부족합니다.

해결 방법:

CLLocationManagerDelegate 확장에 다음 메서드를 추가하세요:

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
    let status = manager.authorizationStatus
    authContinuation?.resume(returning: status)
    authContinuation = nil
}

또한 59-60줄을 정리하여 명확하게 변경하세요.

🤖 Prompt for AI Agents
Projects/DataSource/Sources/Repository/LocationRepository.swift lines 49-66: the
.notDetermined branch sets authContinuation and calls
requestWhenInUseAuthorization but there is no delegate callback to resume that
continuation, causing an indefinite await; add a CLLocationManagerDelegate
method locationManagerDidChangeAuthorization(_:) that reads
manager.authorizationStatus, resumes authContinuation with that status and then
sets authContinuation = nil, and simplify the .notDetermined logic by removing
the prior resume attempt (do not resume any previous continuation before
assigning the new one) so only the delegate resumes the stored continuation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

래빗아 .. Continuation 넘 어렵다 .....................

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@choijungp 이해합니다! Continuation은 처음 접하면 정말 어려운 개념이에요. 쉽게 설명해드릴게요! 😊

🎫 Continuation을 식당 대기표로 생각해보세요

1. 손님이 식당에 가서 대기표를 받습니다 (withCheckedContinuation)
2. 대기표를 들고 기다립니다 (await)
3. 식당에서 호명을 하면 들어갈 수 있습니다 (resume)

현재 코드의 문제:

  • 대기표를 받았는데 (authContinuation 저장)
  • 식당에서 절대 호명을 안 해요! (resume이 없음)
  • 손님은 영원히 기다립니다 😭

해결 방법 (간단 버전)

// 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  // 대기표 처리 완료
    }
    
    // 기존 메서드들...
}

이렇게 하면 사용자가 권한을 허용/거부하는 순간 locationManagerDidChangeAuthorization이 자동으로 호출되고, 여기서 resume을 해줘서 기다리던 코드가 계속 실행됩니다!

천천히 이해하시면 됩니다. 궁금한 점 있으면 언제든 물어보세요! 💪


🧠 Learnings used
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 30
File: Projects/DataSource/Sources/NetworkService/Plugin/RefreshTokenPlugin.swift:42-43
Timestamp: 2025-07-30T03:56:18.617Z
Learning: choijungp는 현재 테스트 단계에서 RefreshTokenPlugin의 토큰 갱신 로직 디버깅을 위해 액세스 토큰과 리프레시 토큰의 실제 값을 로그에 기록하는 것을 선호함.

}

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
}
}
14 changes: 14 additions & 0 deletions Projects/DataSource/Sources/Repository/ReportRepository.swift
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요기는 추후 서버 연동할 부분인 것이지요 !!?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네네 맞습니다요

}
9 changes: 9 additions & 0 deletions Projects/Domain/Sources/DomainDependencyAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,14 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol {

return RoutineUseCase(routineRepository: routineRepository)
}

DIContainer.shared.register(type: ReportUseCaseProtocol.self) { container in
guard
let locationRepository = container.resolve(type: LocationRepositoryProtocol.self),
let reportRepository = container.resolve(type: ReportRepositoryProtocol.self)
else { fatalError("reportUseCase에 필요한 의존성이 등록되지 않았습니다.") }

return ReportUseCase(locationRepository: locationRepository, reportRepository: reportRepository)
}
}
}
5 changes: 3 additions & 2 deletions Projects/Domain/Sources/Entity/Enum/ReportType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
//

public enum ReportType: String, CaseIterable {
case transportation
case lamp
case road
case etc
case water
case convenience
}
22 changes: 22 additions & 0 deletions Projects/Domain/Sources/Entity/LocationEntity.swift
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
}
}
10 changes: 10 additions & 0 deletions Projects/Domain/Sources/Entity/ReportEntity.swift
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

@coderabbitai coderabbitai Bot Nov 15, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ReportEntity 구조체 구현 필요

현재 빈 구조체로 정의되어 있습니다. 제보 기능을 완성하려면 필요한 속성들(예: 위치 정보, 카테고리, 설명, 이미지 등)을 정의해야 합니다.

제보 데이터 모델의 속성 정의를 도와드릴까요? 어떤 정보들을 포함해야 하는지 알려주시면 구조체 구현을 제안해드리겠습니다.

🤖 Prompt for AI Agents
In Projects/Domain/Sources/Entity/ReportEntity.swift around lines 8–10, the
ReportEntity struct is empty; implement it with the typical report fields: add
an id (UUID/String), reporterId (optional UUID/String for anonymous reports),
location fields (latitude: Double, longitude: Double, optional address: String),
category (define a Category enum conforming to String/Codable), description/body
(String), images (array of URL or String identifiers), createdAt/updatedAt
(Date), status (e.g., enum for New/InProgress/Resolved), and any flags like
isAnonymous (Bool); make the struct conform to Codable, Equatable (and
Identifiable if using SwiftUI), and provide a memberwise initializer or default
values where appropriate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버 연동할 때 채울 예정이야 ~~ 돈와리 래빗 ~

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@choijungp, 알겠습니다! 서버 연동 시점에 구현하시는 것이 합리적이네요. API 스펙이 확정되면 그에 맞춰 필드를 정의하시면 되겠습니다. 👍

그때 필요하시면 언제든 도움 요청해주세요!

˙˙˙˙˙ ʕ •ᴥ•ʔ
˙˙˙˙ \( ̄︶ ̄)/
˙˙˙ "알겠어요!"


🧠 Learnings used
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 30
File: Projects/DataSource/Sources/NetworkService/Plugin/RefreshTokenPlugin.swift:42-43
Timestamp: 2025-07-30T03:56:18.617Z
Learning: choijungp는 현재 테스트 단계에서 RefreshTokenPlugin의 토큰 갱신 로직 디버깅을 위해 액세스 토큰과 리프레시 토큰의 실제 값을 로그에 기록하는 것을 선호함.

Loading