📱 플젝 개발일지

SwiftUI 프로젝트에서 네이버 지도 SDK, 카카오맵 API로 맵 커스텀 (추가 수정 예정)

dev_zoe 2025. 8. 5. 18:47
반응형

1. 현재 위치와 함께 MapView 표시하기

1️⃣ 위치 권한 요청 후, 현재 위치 받아오기

 

Info.plist

	<key>NSLocationAlwaysUsageDescription</key>
	<string>위치 기반 맛집 검색을 위해 위치 권한 허용이 필요합니다.</string>
	<key>NSLocationWhenInUseUsageDescription</key>
	<string>위치 기반 맛집 검색을 위해 위치 권한 허용이 필요합니다.</string>
	<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
	<string>위치 기반 맛집 검색을 위해 위치 권한 허용이 필요합니다.</string>

 

LocationService

- 위치 권한 여부를 체크하고, 허용받았을 때 사용자의 위치를 가져오는 Service Class이다.

import CoreLocation

class LocationService: NSObject {
    static let shared = LocationService()
    @Published var userLocation: (lat: Double, lng: Double) = (0.0, 0.0)
    var locationManager = CLLocationManager()
    
    func checkLocationAuthAndGetUserLocation() {
        switch locationManager.authorizationStatus {
        case .notDetermined:
            locationManager.requestWhenInUseAuthorization()
        case .restricted:
            // 위치 정보 접근 제한 - 관련 액션
        case .denied:
            // 위치 정보 접근 거부 - 관련 액션 (사용자에게 설정 창 안내해준다든지)
        case .authorizedAlways, .authorizedWhenInUse:

            fetchUserLocation()

         default:
            break
        }
    }

    
    func fetchUserLocation() {
        let lat = locationManager.location?.coordinate.latitude
        let lng = locationManager.location?.coordinate.longitude
        
        userLocation = (Double(lat ?? 0.0), Double(lng ?? 0.0))
    }
}

 

 

2️⃣ NaverMapView 가져오기 + 현재 사용자의 위치 오버레이 찍기

 

NaverMap은 UIKit 기반이기 때문에 SwiftUI에서 사용하려면 UIViewRepresentable 프로토콜을 채택해야한다.

- makeUIView: UIView 객체를 상성하여 반환

- coordinator: UIView에서 delegate 등 복잡한 이벤트 처리가 필요할 때, SwiftUI와 UIKit 간 연결을 도와주는 매개체 역할을 한다.

 

네이버 지도에 관한 세세한 내용은 공식문서를 첨부해두도록 하고, 간단하게 주석을 통해 설명을 달아두었다.

https://navermaps.github.io/ios-map-sdk/guide-ko/1.html

 

여기서는 크게 지도와 좌표, 위치 오버레이, 마커, 카메라에 대한 항목을 잘 따라가면서 구현하면 된다.

import SwiftUI
import NMapsMap
import Combine

struct MapView: UIViewRepresentable {
    func makeUIView(context: Context) -> NMFNaverMapView {
     context.coordinator.getNaverMapView()
    }
    
    func updateUIView(_ uiView: NMFNaverMapView, context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator.shared
    }
    
    class Coordinator: NSObject, ObservableObject,
                       NMFMapViewCameraDelegate,
                       NMFMapViewTouchDelegate {
        
        static let shared = Coordinator()

        let view = NMFNaverMapView(frame: .zero)
        
        private var cancellables = Set<AnyCancellable>()
        
        override init() {
            super.init()
            
            initMap()
            
            bindLocationUpdates()
        }
        
        private func initMap() {
            // 네이버 지도에 관한 기본 정보 세팅하기
            view.mapView.positionMode = .direction
            view.mapView.isNightModeEnabled = true
            view.mapView.zoomLevel = 15
            view.mapView.minZoomLevel = 1
            view.mapView.maxZoomLevel = 17
            
            view.showLocationButton = true
            view.showZoomControls = true
            view.showCompass = true
            view.showScaleBar = true
            
            // 카메라: 사용자가 지도를 바라보는 틀같은 친구
            view.mapView.addCameraDelegate(delegate: self)
            view.mapView.touchDelegate = self
        }
        
        private func bindLocationUpdates() {
        // 위에서 받아오는 userLocation에 바인딩하여, 값이 변경될때마다 특정행동을 할 수 있도록 한다.
            LocationService.shared.$userLocation
                .receive(on: RunLoop.main)
                .sink { [weak self] location in
                    guard let self = self else { return }
                    
                    self.updateMapLocation(lat: location.lat, lng: location.lng)
                }
                .store(in: &cancellables)
        }
        
        private func updateMapLocation(lat: Double, lng: Double) {
            let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: lat, lng: lng), zoomTo: 15)
            cameraUpdate.animationDuration = 1    // 카메라 이동을 몇초동안 할 것인가?
            
            // locationOverlay: 사용자의 현재 위치를 나타내는 오버레이
            // NMGLatLng으로 변환하여 사용자의 현재 위치를 지정해줍니다.
            view.mapView.locationOverlay.location = NMGLatLng(lat: lat, lng: lng)
            view.mapView.locationOverlay.hidden = false
            
            // 카메라 이동 -> 현재 사용자의 위치로 이동하기
            view.mapView.moveCamera(cameraUpdate)
        }

        func getNaverMapView() -> NMFNaverMapView {
            return view
        }

    }
}

 

3️⃣ 카카오맵 API를 이용하여 주변 음식점 마커 찍기

사이드 프로젝트에서 사용자의 현재 위치를 받아 그 주변의 음식점들을 받아와야했는데, API 레퍼런스를 찾아봤을 때 카카오맵 API가 적절하다고 생각했다.

하단 카테고리로 로컬 검색 API를 사용했다.

https://developers.kakao.com/docs/latest/ko/local/dev-guide#search-by-category

import SwiftUI
import NMapsMap
import Combine

struct MapView: UIViewRepresentable {

    // ... 중략
    
    class Coordinator: NSObject, ObservableObject,
                       NMFMapViewCameraDelegate,
                       NMFMapViewTouchDelegate {
        
        static let shared = Coordinator()
        @Published var places: [PlaceInfo] = []
        private var markers: [NMFMarker] = []    // 지도 위의 마커들을 담을 배열

        let infoWindow = NMFInfoWindow()     // 지도의 정보창 (음식점 이름 위에 띄울 창)을 컨트롤하는 클래스 객체
        let dataSource = NMFInfoWindowDefaultTextSource.data()    // 지도의 정보창의 datasource
        let view = NMFNaverMapView(frame: .zero)
        
        private var cancellables = Set<AnyCancellable>()
        
        override init() {
            super.init()
            
            // 생략
            
            bindLocationUpdates()
        }
        
        // 정보를 받아올때
        private func bindLocationUpdates() {
            LocationService.shared.$userLocation
                .receive(on: RunLoop.main)
                .sink { [weak self] location in
                    guard let self = self else { return }
                    
                    // 사용자의 위치 오버레이를 찍음과 동시에
                    self.updateMapLocation(lat: location.lat, lng: location.lng)
                    
                    // 위치 주변의 음식점들을 검색한다.
                    self.loadNearbyRestaurants(lat: location.lat, lng: location.lng)
                }
                .store(in: &cancellables)
        }
        
        private func loadNearbyRestaurants(lat: Double, lng: Double) {
            
            // 카카오로컬 API - 요청 및 응답 포맷은 문서 참고
            RestaurantSerachAPIService.shared.fetchNearbyRestaurants(lat: lat, lng: lng)
                .receive(on: RunLoop.main)
                .sink(
                    receiveCompletion: { completion in
                        if case let .failure(error) = completion {
                            print("🍽️ 식당 검색 실패: \(error.localizedDescription)")
                        }
                    },
                    receiveValue: { [weak self] places in
                        self?.addPlaceMarkers(places)
                    }
                )
                .store(in: &cancellables)
        }
        
        // ... 중략
        
        private func addPlaceMarkers(_ places: [PlaceInfo]) {
            // 위치가 바뀔때마다 지도 위 마커를 새롭게 갱신하기 위해, 이전 마커들을 제거한다.
            markers.forEach { $0.mapView = nil }
            markers.removeAll()
            
            // 마커 클릭 시 액션에 관한 handler
            let handler = { [weak self] (overlay: NMFOverlay) -> Bool in
                if let marker = overlay as? NMFMarker {
                    // 정보 창이 열린 마커의 tag를 텍스트로 노출하도록 반환
                    self?.dataSource.title = marker.userInfo["tag"] as! String
                    // 마커를 터치할 때 정보창을 엶
                    self?.infoWindow.open(with: marker)
                }
                return true
            };
            
            for place in places {
                guard let lat = Double(place.y), let lng = Double(place.x) else { continue }
                
                let marker = NMFMarker()
                marker.position = NMGLatLng(lat: lat, lng: lng)
                // 마커를 기본 내장된 마커로 지정할 수도 있고, 커스텀 이미지로 지정할 수도 있다.
                marker.iconImage = NMFOverlayImage(name: "chili-pepper")
                marker.width = 30
                marker.height = 40
                marker.mapView = view.mapView
                marker.userInfo = ["tag": place.placeName]   // 각각의 마커에 정보를 만들어준다.
                marker.touchHandler = handler      // 클릭 시 핸들러 달기
                
                markers.append(marker)
            }
        }
        
        // 지도를 탭할 때, 모든 정보창들을 없앨 수 있도록 한다.
    
        func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) {
            infoWindow.close()
        }
    }
}

 

여기까지 진행하면 아래와 같이 나타난다.  (오른쪽 주황색 버튼은 새로고침 버튼으로 필자가 커스텀한 버튼임)

 

 

반응형