SwiftUI에서의 지도 API 선택


현재 SwiftUI를 이용하여 IOS 앱을 개발하고 있다. 어플에서 지도를 사용할 일이 있어서 지도 API를 알아보았다. 지금 개발중인 앱은 국내에서만 서비스가 될 것이므로 국내 지도가 잘 되어 있는 API를 선정하여야 한다. 그렇게 선정한 것이 네이버 지도와 카카오 맵이다. 처음에는 카카오 맵을 사용하기로 마음을 먹었었다. 왜냐하면 카카오 API가 문서화가 상당히 잘 되어 있어서 웹 개발을 했었을 때 사용하기 상당히 편리했었던 기억이 있다.

스크린샷 2021-07-18 오후 10.58.17

그러나 카카오 맵은 2019년 11월이 마지막으로 업데이트 되었고 지금 대부분의 앱들은 Swift 언어를 이용해서 개발이 진행되고 있다. 그리고 카카오 맵은 앱에 적용하기 위해 자체적으로 SDK가 제공되고 있다. 그러나 이 SDK가 Swift가 아닌 오브젝티브 C로 만들어져 있다. 나는 아직 Swift를 입문한지 얼마 되지 않았고 오브젝티브 C를 모르기 때문에 카카오 맵은 사용하지 않기로 하였다. 그러면 남은 선택지는 네이버 지도 뿐이다..

다행히 네이버 지도는 Swift 언어로 개발되어 있어서 사용할 수 있었다. 하지만 API 문서가 나한테는 불친절하다고 느껴지므로.. 구글링을 통해 다른 사람들이 구현한 코드를 보고 따라하는 방식으로 진행하고 있다. 없는 부분에 대해서는 직접 API 문서를 보고 삽질을 해가며.. 구현해야겠지만.. SwiftUI 방식은 발표된지 그렇게 오래되지가 않아 한글화 정보가 상당히 적다는 것을 알고 있다..

이게 서론을 마치고 SwiftUI 방식으로 네이버 지도를 띄우는 방법에 대해서 적어보겠다.

0. 우리가 할일은?

21FA0DBB-7786-4E2D-8F0A-603AAE5BFA42_1_105_c

  • “폭탄전” 버튼을 클릭하면 네이버 지도가 나타난다.
  • 네이버 지도가 로드되면서 현재 위치를 기반으로 나타난다.
  • 네이버 지도에 터치 이벤트를 적용한다. - 다음 포스트

1. 네이버 지도 API 이용 신청

네이버 지도를 적용하기 전에 우선 이용 신청을 하여 키를 발급 받아야 한다.

네이버 클라우드 플랫폼 을 통해서 API 키를 발급받을 수 있다.

스크린샷 2021-07-18 오후 11.38.07

결제정보를 등록하고 애플리케이션 등록을 한다. 결제정보는 체크카드나 신용카드를 등록하면 된다. 어차피 현재 개발 상태이므로 과금이 될일이 절대 없다. 나는 IOS 앱에 적용을 할 것이기 때문에 Mobile Dynamic Map를 선택한다. 그리고 밑에 IOS Bundle ID를 입력한다. IOS Bundle ID는 Xcode로 프로젝트를 생성할 때 입력했던 것이다. 그떄 입력했던 번들 ID를 입력해준다.

스크린샷 2021-07-18 오후 11.39.37

등록하면 위와 같이 나타난다. 인증 정보를 클릭하면 발급된 API Key를 확인할 수 있다.

2. 네이버 지도 SDK 설치

네이버 지도 SDK는 cocoapods를 통해 배포 되고 대용량 파일이므로 이를 받기 위해선 git-lfs를 추가로 설치해야 한다. 설치 방법은 간단하다. homebrew가 설치되었다고 가정하고 아래 명령어를 입력해보자.

brew install cocoapods

터미널을 실행시켜서 위 명령어를 이용하여 코코아팟을 설치하자.

pod init

설치한 cocoapods를 초기화하자.

brew install git-lfs

대용량 SDK를 다운받기 위해 git-fls를 설치해주자. 홈페이지에서 직접 다운 받고 싶을 경우 여기로 들어가자.

그리고 내가 만든 프로젝트 파일의 폴더로 들어간 후 에디터 명령을 이용하여 Podfile 파일을 생성해준다. 나는 vi 명령어에 익숙해져 있으므로 vi 에디터를 사용하였다. Podfile에 들어갈 내용은 아래와 같다.

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'
pod 'NMapsMap'  # 네이버 지도를 여기에 추가해주면 된다.

target 'AirHelper' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for AirHelper

  target 'AirHelperTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'AirHelperUITests' do
    # Pods for testing
  end

end

위에 한줄만 추가해주면 된다. 저장을 해준 뒤 “pod install” 명령어를 입력하면 SDK가 자동으로 다운로드 된다. 다운로드가 완료되면 Pods, Podfile.lock 파일이 추가로 생길 것이다.

앞으로 추가된 라이브러리를 사용하기 위해서는 프로젝트명.xcworkspace으로 시작하는 파일로 xcode를 실행시켜서 개발을 진행하면 된다.

3. 프로젝트에 GPS 권한 부여 및 API KEY 등록

세번째로 프로젝트에 GPS 권한 알림창을 띄게하도록 설정할 것이고 첫 단계에서 발급받은 네이버 지도 API Key를 등록을 할 것이다.

Swift를 이용하여 진행중인 프로젝트라면 Info.plist라는 파일이 보일 것이다. 내 프로젝트의 현재 구성은 다음과 같다.

스크린샷 2021-07-19 오전 12.03.15

여기서 Info.plist라는 파일이 보일 것이다. 해당 파일을 열어보자.

파일을 열면 상단 분류란에 Key, Type, Value 순으로 구분이 되어 있을 것이다. 여기서 키-값 쌍을 추가를 해줄 것이다.

Key : Privacy - Location Always Usage Description
Type : String
Value : 사용자의 위치를 받습니다.

Key : NMFClientId
Type : String
Value : 발급받은 API Key

Key : Privacy - Location When In Use Usage Description
Type : String
Value : 앱이 사용중일 때만 가져오기

위와 같이 3줄을 추가해주면 된다.

4. 네이버 지도 띄우기

이제 화면에 네이버 지도를 띄워보겠다. 네이버 지도 SDK를 사용하기 위해서는 import NMapsMap를 필수적으로 입력해주어야 한다. 아쉽게도 네이버 지도는 SwiftUI 방식을 지원하지 않는다. 따라서 UIViewRepresentable를 이용하여 구현을 해주어야 한다. 참고한 방법은 Sweet_dev님 블로그를 참고하였다.

// MapView.swift
import SwiftUI
import KeyboardToolbar
import MapKit
import NMapsMap
import Combine

struct MapView: UIViewRepresentable {
    @ObservedObject var viewModel = MapSceneViewModel()
    @State var userLatitude: Double
    @State var userLongitude: Double
    
    func makeUIView(context: Context) -> NMFNaverMapView {
        let view = NMFNaverMapView()
        view.showZoomControls = false
        view.mapView.zoomLevel = 17
        view.mapView.mapType = .hybrid

        let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: userLatitude, lng: userLongitude))
        view.mapView.moveCamera(cameraUpdate)
        return view
    }
    
    func updateUIView(_ uiView: NMFNaverMapView, context: Context) {}
    
}

class MapSceneViewModel: ObservableObject {
    
}

저 코드를 그대로 따라 쓴 후, View의 바디 부분에 MapView()를 입력하면 지도가 출력된다.

//CreateView.swift
import SwiftUI
import KeyboardToolbar
import MapKit
import NMapsMap
import Combine


struct CreateView: View {
    @State var title: String = ""
    @State var password: String = ""
    @State var verboseLeft: String = ""
    @State var verboseRight: String = ""
    @State var minuties: String = ""
    
    let buttons = ["섬멸전", "폭탄전", "스파이전"]
    @State public var buttonSelected: Int?
    @State private var showingAlert = false
    
    @State var spyPercent : String = ""
    @State var spyMax : String = ""
    
    @StateObject private var keyboardHandler = KeyboardHandler()
    
    @StateObject var locationManager = LocationManager()
    var userLatitude: Double {
        return locationManager.lastLocation?.coordinate.latitude ?? 0
    }
    
    var userLongitude: Double {
        return locationManager.lastLocation?.coordinate.longitude ?? 0
    }
    
    //서울 좌표
    @State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 37.5666791, longitude: 126.9782914), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
    
    
    
    var body: some View {
        GeometryReader { gp in
            ScrollView(.vertical, showsIndicators: false){
                VStack(alignment:.center, spacing: 0) {
                    
                    TextField("방 제목", text: self.$title)
                        .padding()
                        .frame(width: gp.size.width * 0.9)
                    
                    Divider()
                        .frame(width: gp.size.width * 0.9)
                    
                    SecureField("비밀번호", text: self.$password)
                        .padding()
                        .frame(width: gp.size.width * 0.9)
                        .keyboardType(.numberPad)
                    
                    Divider()
                        .frame(width: gp.size.width * 0.9)
                    
                    HStack() {
                        TextField("5", text: self.$verboseLeft)
                            .keyboardType(.numberPad)
                            .padding()
                            .frame(width: gp.size.width * 0.2, alignment: .center)
                            .multilineTextAlignment(.center)
                        Text("VS")
                            .padding()
                        TextField("5", text: self.$verboseRight)
                            .keyboardType(.numberPad)
                            .padding()
                            .frame(width: gp.size.width * 0.2, alignment: .center)
                            .multilineTextAlignment(.center)
                    }
                    
                    Divider()
                        .frame(width: gp.size.width * 0.9)
                    
                    HStack(){
                        TextField("게임시간(분)", text: self.$minuties)
                            .keyboardType(.numberPad)
                            .padding()
                            .multilineTextAlignment(.trailing)
                            .frame(width: gp.size.width * 0.7)
                        
                        Text("분")
                            .padding()
                            .frame(width: gp.size.width * 0.2)
                    }
                    
                    Divider()
                        .frame(width: gp.size.width * 0.9)
                    
                    Group(){
                        HStack(){
                            Spacer()
                            Text("게임모드")
                                .padding()
                            Spacer()
                        }
                        HStack() {
                            ForEach(0..<buttons.count) { button in
                                Button(action: {
                                    self.buttonSelected = button
                                }) {
                                    VStack(){
                                        Image(self.buttonSelected == button ? self.buttons[button]+"-selected" : self.buttons[button])
                                            .resizable()
                                            .aspectRatio(contentMode: .fit)
                                            .frame(width: 80)
                                        Text("\(self.buttons[button])")
                                            .foregroundColor(self.buttonSelected == button ? Color.blue : Color.gray)
                                    }
                                    .frame(width: gp.size.width * 0.25, height: gp.size.width * 0.35)
                                    .clipShape(Rectangle())
                                    .border(self.buttonSelected == button ? Color.blue : Color.gray, width: 1)
                                }
                            }
                        }
                        
                        if self.buttonSelected == 1 {
                            Text("폭탄 설치지역 설정")
                                .padding(.vertical, 30)
                            
                            
                            MapView(userLatitude: self.userLatitude, userLongitude: self.userLongitude)
                                .frame(width: gp.size.width * 0.8, height: gp.size.height * 0.4)
                            
                           
                            
                        }
                        else if self.buttonSelected == 2 {
                            HStack() {
                                TextField("스파이 발생확률", text: self.$spyPercent)
                                    .keyboardType(.numberPad)
                                    .padding()
                                    .multilineTextAlignment(.trailing)
                                    .frame(width: gp.size.width * 0.7)
                                Text("%")
                            }
                            Divider()
                                .frame(width: gp.size.width * 0.9)
                            HStack() {
                                TextField("스파이 최대인원", text: self.$spyMax)
                                    .keyboardType(.numberPad)
                                    .padding()
                                    .multilineTextAlignment(.trailing)
                                    .frame(width: gp.size.width * 0.7)
                                Text("명")
                            }
                            Divider()
                                .frame(width: gp.size.width * 0.9)
                        }
                    }
                    
                    Button(action: {
                        print("Button action")
                        if self.create_validation() == false {
                            self.showingAlert = true
                        }
                    }) {
                        Text("생성하기")
                            .font(.title2)
                            .foregroundColor(Color.white)
                            .frame(width: gp.size.width * 0.6, height: gp.size.height * 0.1)
                            .background(Color.blue)
                            .cornerRadius(10)
                    }
                    .padding(.top, 30)
                    .padding(.bottom, 100)
                    .alert(isPresented: $showingAlert) {
                        Alert(title: Text("입력"), message: Text("모든 항목을 입력해주세요."), dismissButton: .default(Text("확인")))
                    }
                }
                .listRowInsets(EdgeInsets())
            }
            //        .onTapGesture(count: 1) { // 키보드밖 화면 터치시 키보드 사라짐
            //            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
            //        }
            //        .gesture(DragGesture().onChanged{_ in UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)}) // 키보드 밖 화면에서 스크롤시 키보드 사라짐
            .padding(.bottom, keyboardHandler.keyboardHeight)
            .animation(.default)
            .keyboardToolbar(toolbarItems)
        }
    }
    
    func create_validation() -> Bool {
        if self.title == "" {
            return false
        }
        else if self.password == "" {
            return false
        }
        else if self.verboseLeft == "" {
            return false
        }
        else if self.verboseRight == "" {
            return false
        }
        else if self.minuties == "" {
            return false
        }
        else if self.buttonSelected == nil {
            return false
        }
        
        if self.buttonSelected == 2 {
            if self.spyMax == "" {
                return false
            }
            else if self.spyPercent == "" {
                return false
            }
        }
        return true
    }
}


//LocationManager.swift
import Foundation
import CoreLocation
import Combine

class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {

    private let locationManager = CLLocationManager()
    @Published var locationStatus: CLAuthorizationStatus?
    @Published var lastLocation: CLLocation?

    override init() {
        super.init()
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
    }

   
    
    var statusString: String {
        guard let status = locationStatus else {
            return "unknown"
        }
        
        switch status {
        case .notDetermined: return "notDetermined"
        case .authorizedWhenInUse: return "authorizedWhenInUse"
        case .authorizedAlways: return "authorizedAlways"
        case .restricted: return "restricted"
        case .denied: return "denied"
        default: return "unknown"
        }
    }

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        locationStatus = status
        print(#function, statusString)
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        lastLocation = location
        print(#function, location)
    }
}

내가 원하는 방법은 지도가 나타남과 동시에 현재 위치를 기반으로 지도가 표시되는 것이었다. 위와 같이 작성하면 뷰가 나타날때 LocationManager를 통해 현재 위치(위도, 경도)를 받아서 MapView 매개변수로 보내서 네이버 지도 API중 카메라 위치를 옮기는 함수가 있다. 그 함수에 위도와 경도로 설정하면 내가 위치한 장소를 기반으로 지도가 표시되게 된다.

결과물

0C1BF262-0611-4A25-B43F-544E7D22D777

마치며..

구글 맵, 애플 맵도 원래 고려대상에 있었지만 국내 지도 구현이 미흡한 점이 많아서 제외시켰다. 다음 포스트에서는 위 지도에 터치이벤트를 적용해보도록 하겠다. 그리고 애플 맵을 띄우는 포스트도 올려보려고 한다.