본문 바로가기
iOS

[iOS] MapKit 주소 자동검색 구현하기

by DuncanKim 2024. 1. 7.
728x90

[iOS] MapKit 주소 자동검색 구현하기

카뮤 프로젝트에서 iOS 앱으로 주소검색을 하고, 검색된 주소를 탭 하면 다음화면으로 넘어가서 그 위치를 맵에 찍어주는 기능을 개발해야 했다. 카뮤는 네이버 맵을 사용하기로 했었고, 네이버 맵을 활용 해서 자동 검색을 구현하려고 했었다. 하지만, 검색과 관련된 API를 제공하기는 하지만, REST API 방식이라 URLSession 등 기본 프레임워크로 하나씩 설정해주지 않는 한, Alamofire 등 새로운 라이브러리를 써야 하는 점, 개발 시간이 한정적인 점 등 환경적인 요소를 고려하여 일단은 MapKit에서 제공하는 MKSearchCompleter로 자동 검색을 구현하고, 거기에서 얻어지는 좌표를 가지고 네이버 맵에서 reverseGeocoding을 하는 방식으로 개발을 진행했다.

일단 이번에 알아볼 것은 MapKit을 활용한 자동검색 구현이다. Naver Map API의 reverseGeocoding을 하는 방법은 따로 알아보겠다.

 

 

1. MapKit을 활용한 자동검색 구현

구현해야 하는 것은 키보드에서 텍스트가 하나씩 쳐 질 때마다 현재까지 입력된 텍스트와 관련 있는 장소들을 불러와서 TableView에 표시를 해줘야 하는 기능이었다. Naver Open API에 이 기능이 지원되어 적절하게 변형해서 사용하면 가능하겠지만, 시간적 문제가 있었고, MapKit으로 구현해도 유사한 기능을 만들어낼 수 있고 안정적일 것이라는 생각에 MapKit을 사용했다.

 

1) 사용한 클래스들

상세하게는 MapKit의 MKLocalSearchCompleter, MKLocalSearch, Geocoder 등을 사용했다.

 

(1) MKLocalSearchCompleter

  • MKLocalSearchCompleter 클래스는 지리적 위치를 기반으로 자동 완성 및 검색 기능을 제공한다.
  • 사용자가 주소 또는 장소를 검색하면 관련 검색어 및 위치 정보를 추천한다.
  • 이 클래스를 사용하여 사용자에게 검색어 예측을 제공하고 이를 기반으로 추가적인 검색을 수행할 수 있다.

(2) MKLocalSearchCompletion

  • MKLocalSearchCompletionMKLocalSearchCompleter 클래스를 통해 생성된 검색어 또는 위치 정보를 나타내는 객체.
  • 사용자에게 검색 예측을 제공하거나 검색 결과를 선택할 때 사용된다.
  • 예측된 검색어와 위치 정보를 보유하고 있어, 사용자에게 검색 옵션을 제공하거나 선택한 위치를 가져오는 데 사용된다.

 

(3) MKLocalSearch

  • MKLocalSearch 클래스는 지역 검색을 수행하는 데 사용된다.
  • 사용자가 제공한 검색어 또는 위치 정보를 기반으로 주변의 지리적 위치를 검색하고 검색 결과를 반환한다.
  • 주소 또는 장소 검색을 처리하거나 지도 위에 결과를 표시하는 데 유용하다

 

(4) MKMapItem

  • MKMapItem 클래스는 지도에 표시되는 특정 위치를 나타낸다.
  • 위치의 이름, 주소 및 좌표 정보와 같이 지도 상의 개별 항목에 액세스 할 때 사용된다.
  • 주로 지도 애플리케이션에서 사용되며, 해당 위치를 설정하거나 자세한 정보를 얻는 데 활용된다.

 

(5) CLGeocoder

  • CLGeocoder 클래스는 지리적 위치 정보와 주소 정보를 변환하는 데 사용된다.
  • 주로 주소를 위도 및 경도로 변환하거나 반대로 위도와 경도를 주소로 변환할 때 활용된다.
  • 위치 정보를 지오코딩 또는 리버스지오코딩하려는 경우에 유용하다.

 

2) 구현 순서

  • 구현된 TextField의 문자열을 MKLocalSearchCompleter에게 넘긴다.
  • MKLocalSearchCompleter를 통해 검색된 결과(MKLocalSearchCompletion)를 저장한다.
  • 이 값을 tableView에 전달하고 reloadData()를 한다.
  • cell에서 표시한다.

MKLocalSearchCompleter를 통해 받아온 데이터에는 “title, subtitle”이 있다. 이 값들이 대표 장소 이름과 상세주소인 것이다.

여기에서 MKLocalSearchCompleter의 resultType을 .address, .pointOfInterest, .query 중 무엇을 사용하는지에 따라 받아오는 데이터의 값이 다른데 주소만 받아오고 싶다면 .address, 관심 장소와 관련을 지어야 하는 경우 .pointsOfInterest, Text와 관련된 모든 정보를 불러오고 싶다면 .query를 사용해야 한다.

주소만 필요한 것이 아니라, 상호명 등을 입력했을 때도 주소를 부르고 싶었기 때문에, 여기에서는 .query 타입의 결과를 받아오고, 보여주기로 했다.

 

(1) ViewController

final class SelectAddressViewController: UIViewController {

    private let selectAddressView = SelectAddressView()
    private let selectAddressModel = SelectAddressModel()
    private var isKeyboardActive = false
    private var searchCompleter: MKLocalSearchCompleter?
    private var completerResults: [MKLocalSearchCompletion]?
    private var places: MKMapItem? {
        didSet {
            selectAddressView.tableViewComponent.reloadData()
        }
    }
    private var localSearch: MKLocalSearch? {
        willSet {
            places = nil
            localSearch?.cancel()
        }
    }
    private let koreaBounds = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 36.34, longitude: 127.77),
        span: MKCoordinateSpan(latitudeDelta: 2, longitudeDelta: 2)
    )

    override func viewDidLoad() {
        super.viewDidLoad()

                //...

        self.searchCompleter = MKLocalSearchCompleter()
        self.searchCompleter?.delegate = self
        self.searchCompleter?.resultTypes = .query
        self.searchCompleter?.region = koreaBounds

        selectAddressView.friendSearchTextField.delegate = self
        selectAddressView.tableViewComponent.delegate = self
        selectAddressView.tableViewComponent.dataSource = self
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        searchCompleter = nil
    }
}

 

VC에는 searchCompleter와 델리게이트 관련 설정을 해준다.

여기에서 koreaBounds 변수는 searchCompleter의 검색 범위를 지정해주는 것이다. 범위를 설정해 주는 것으로, Delta 값은 1, 0.1, 0.01 등으로 설정할 수 있는데, 0.0001 수준으로 가면 너무 좁은 범위가 나오게 된다. 그렇기 때문에, 2 정도로 설정하여, 우리나라로 한정 지을 수 있다. center는 휴전선 이남의 한국 중앙 위치를 설정해 주었다.

 

단, 이렇게 해도, 만약 Completer에서 값을 찾지 못하면, 여전히 세계 기준으로 찾아준다. 검색 기록이 없는 경우, 다시 서치 하는 것이 아니라, 결과값이 없다는 셀을 보여주는 것으로 대체해야 한다.

 

 

(2) objc 메서드

// MARK: - @objc Method
extension SelectAddressViewController {

    // [검색] 버튼을 눌렀을 때 동작
    @objc private func performAddressSearch() {
        if isKeyboardActive {
            isKeyboardActive = false
            dismissTextField()
        }

        if let searchText = selectAddressView.friendSearchTextField.text, !searchText.isEmpty {
            // 사용자가 입력한 주소 또는 장소명
            let address = searchText

            // 주소를 이용하여 상세한 좌표를 비동기적으로 가져오기
            getCoordinates(for: address) { result in
                switch result {
                case .success(let (latitude, longitude)):
                    // 여기에서 얻은 latitude와 longitude를 사용하여 원하는 작업을 수행합니다.
                    print("Latitude: \(latitude), Longitude: \(longitude)")
                case .failure(let error):
                    // 좌표를 가져올 수 없는 경우에 대한 처리를 추가.
                    print("Failed to get coordinates for the address: \(address). Error: \(error)")
                }
            }
        }
    }

    @objc private func textFieldDidChange(_ textField: UITextField) {
        // 텍스트 필드 내용이 변경될 때마다 호출되는 메서드
        if let searchText = textField.text {
            if searchText == "" {
                completerResults?.removeAll()
                selectAddressView.tableViewComponent.reloadData()
            }
            searchCompleter?.queryFragment = searchText
            selectAddressView.tableViewComponent.reloadData()
        }
    }
}

 

@objc 메서드 중 지도와 관련된 것들이다. 검색에 searchBar를 쓰지 않고 그대로 TextField를 사용했는데, 기존에 만든 TextField를 재활용하기 위해 이렇게 사용하였다.

TextField의 변화가 감지되면, searchCompleter에 queryFragment를 전달해서 계속 tableView의 셀을 업데이트시켜준다. 이렇게 되면, 검색할 때, 글자 하나하나 마다 결과를 내보여줄 수 있는 자동완성 기능을 구현할 수 있다.

 

검색 버튼을 누를 때 나타나는 메서드는 아래에서 더 자세히 설명할 것인데, 이 부분의 경우, 현재 텍스트 필드에 입력된 String을 가지고 주소를 검색하고 Geocoding을 해와서 좌표값을 얻어오는 것이다.

 

 

(3) Custom Method

// MARK: - Custom Method
extension SelectAddressViewController {

    private func search(for suggestedCompletion: MKLocalSearchCompletion) {
        let searchRequest = MKLocalSearch.Request(completion: suggestedCompletion)
        search(using: searchRequest)
    }

    private func search(using searchRequest: MKLocalSearch.Request) {
        // 검색 지역 설정
        searchRequest.region = self.koreaBounds

        // 검색 유형 설정
        searchRequest.resultTypes = .pointOfInterest
        // MKLocalSearch 생성
        localSearch = MKLocalSearch(request: searchRequest)
        // 비동기로 검색 실행
        localSearch?.start { [unowned self] (response, error) in
            guard error == nil else {
                return
            }
            // 검색한 결과 : reponse의 mapItems 값을 가져온다.
            self.places = response?.mapItems[0]

            print(places?.placemark.coordinate as Any) // 위경도 가져옴
        }
    }

    private func getCoordinates(for address: String, completion: @escaping (Result<(Double, Double), Error>) -> Void) {
        if address == "" { return }

        let geocoder = CLGeocoder()
        geocoder.geocodeAddressString(address) { (placemarks, error) in
            if let error = error {
                completion(.failure(error))
                return
            }
            if let placemark = placemarks?.first, let location = placemark.location {
                let latitude = location.coordinate.latitude
                let longitude = location.coordinate.longitude
                // 검색 결과에서 위도와 경도를 비동기적으로 반환
                completion(.success((latitude, longitude)))
            } else {
                let noLocationError = NSError(
                    domain: "GeocodingError",
                    code: -1,
                    userInfo: [NSLocalizedDescriptionKey: "No location found for the address"]
                )
                completion(.failure(noLocationError))
            }
        }
    }

    private func removeCountryAndPostalCode(from subtitle: String) -> String {
        var modifiedSubtitle = subtitle

        modifiedSubtitle = modifiedSubtitle.replacingOccurrences(of: "대한민국", with: "")

        // ", #####" 패턴을 제거
        if let range = modifiedSubtitle.range(of: ", \\d{5}", options: .regularExpression) {
            modifiedSubtitle = modifiedSubtitle.replacingCharacters(in: range, with: "")
        }

        if let range = modifiedSubtitle.range(of: "\\d{5}", options: .regularExpression) {
            modifiedSubtitle = modifiedSubtitle.replacingCharacters(in: range, with: "")
        }

        return modifiedSubtitle.trimmingCharacters(in: .whitespaces)
    }
}

 

위치 검색과 좌표 추출, 그리고 상세 주소 표현에서 특정 키워드를 필터링하는 메서드이다.

좌표를 얻기 위한 메서드가 getCoordinates이다. 비동기적으로 현재 입력된 주소를 활용해서 현재의 좌표값을 반환한다.

 

 

(4) MKLocalSearchCompleterDelegate 메서드

// MARK: - MKLocalSearchCompleterDelegate Method
extension SelectAddressViewController: MKLocalSearchCompleterDelegate {

    // 자동완성 완료시 결과를 받는 method
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        self.completerResults = completer.results
        self.selectAddressView.tableViewComponent.reloadData()
    }

    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        if let error = error as NSError? {
            print("Error: \(error.localizedDescription).\n The query: \"\(completer.queryFragment)")
        }
    }
}

 

자동 완성 시 결과를 받는 메서드이다. VC의 completerResults에 결과를 저장하게 된다. 그러고 나서 tableView의 데이터를 reload 해준다.

위에서 이야기한 것처럼, 검색 시에 지역한정이 되지 않아, 아래와 같이 필터링을 통해 위도, 경도가 한국인 곳만 넣어줘보려고 했었다.

 

// 자동완성 완료시 결과를 받는 method
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
    let group = DispatchGroup()
    var filteredResults: [MKLocalSearchCompletion] = []

    for result1 in completer.results {
        let address = result1.subtitle
        group.enter()
        getCoordinates(for: address) { result2 in
            defer {
                group.leave()
            }

            switch result2 {
            case .success((let latitude, let longitude)):
                if (33.0...39.0).contains(latitude), (123.0...133.0).contains(longitude) {
                    filteredResults.append(result1)
                }
            case .failure(let error):
//                    filteredResults.append(result1)
                print("Error getting coordinates for address: \(address). Error: \(error)")
            }
        }
    }

    group.notify(queue: DispatchQueue.main) {
        self.completerResults = filteredResults
        self.selectAddressView.tableViewComponent.reloadData()
    }
}

 

그런데, 이 같은 경우에, 계속 텍스트를 입력할 때마다, getCoordinates를 해버렸다. 그럴 경우, API 통신이 분당 50회로 제한되어 있기 때문에, 버그가 생겼다. 그래서 기존의 코드로 진행하였다.

 

 

(5) TableViewDataSource 메서드

// MARK: - Tableview DataSource Method
extension SelectAddressViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if let results = completerResults, !results.isEmpty {
            return results.count
        } else {
            return 1
        }
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        if let results = completerResults, !results.isEmpty { // 검색 결과가 있는 경우
            guard let cell = tableView.dequeueReusableCell(
                withIdentifier: "selectAddressCell"
            ) as? SelectAddressTableViewCell else {
                return UITableViewCell()
            }

            if let suggestion = completerResults?[indexPath.row] {
                cell.buildingNameLabel.text = suggestion.title
                cell.detailAddressLabel.text = removeCountryAndPostalCode(from: suggestion.subtitle)
            }
            return cell
        } else { // 검색 결과가 없는 경우
            if let cell = tableView.dequeueReusableCell(
                withIdentifier: "defaultAddressCell",
                for: indexPath
            ) as? DefaultAddressTableViewCell {
                return cell
            }
        }
        return UITableViewCell()
    }
}

 

별거 없다. 셀에 라벨에 title, subtitle을 더해주는 것이다.

 

2. 완성물

 

 

 

이렇게 완성을 해보았다. 이게 완성본은 아니지만, 위의 코드에서 조금 더 UI 적으로 손을 보아 위의 결과물을 만들어냈다. 작동 방식은 동일하다. 이렇게 알아낸 좌표값을 토대로 다음 뷰에 값을 전달하여 Naver Map에서 검색한 위치를 바로 보여주었다.

 

다음 포스팅에서는 Naver Map API ReverseGeocoding을 iOS에서 사용하는 방법을 알아보겠다.

 

728x90

댓글