[iOS] NaverMap - Clustering(클러스터링)

클러스터링이란?

클러스터링

 

클러스터링이란 주어진 개체들을 여러 개의 그룹으로 묶는 일련의 작업을 말한다.

Map에서는 마커가 많아지는 경우, 모든 마커를 맵에 다 표현하는 것이 성능적으로나 UX적으로나 좋지 않다.

그래서 이런 경우 근처에 있는 여러 개의 마커를 하나로 표현하는 클러스터링을 사용한다. 

 

생각보다 클러스터링 구현하는 방법은 쉽다.

그러나 이와 관련된 글을 내가 찾기엔 하나도 없어서 이번 글에서 설명해보려 한다.

 

  

 

 

 

코드

    class ItemKey: NSObject, NMCClusteringKey {
        let identifier: Int
        let position: NMGLatLng

        init(identifier: Int, position: NMGLatLng) {
            self.identifier = identifier
            self.position = position
        }

        static func markerKey(withIdentifier identifier: Int, position: NMGLatLng) -> ItemKey {
            return ItemKey(identifier: identifier, position: position)
        }

        override func isEqual(_ o: Any?) -> Bool {
            guard let o = o as? ItemKey else {
                return false
            }
            if self === o {
                return true
            }

            return o.identifier == self.identifier
        }

        override var hash: Int {
            return self.identifier
        }

        func copy(with zone: NSZone? = nil) -> Any {
            return ItemKey(identifier: self.identifier, position: self.position)
        }
    }

 

 클러스터링을 하기 위해서 먼저 데이터의 키를 의미하는 NMCClusteringKey 인터페이스를 구현한 클래스를 정의해야 한다.

위의 코드는 도큐먼트에 있던 클래스를 가져온 것이다.

간단하게 이에 대해서 알아보자면 NSObject는 모든 Objective-C 클래스의 루트 클래스이며, 

NMCClusteringKey클러스터링을 위한 키 역할을 한다.

도큐먼트에 따르면 isEqualhash 및 copyWithZone도 구현하는 것이 권장한다고 한다.

 

 

import UIKit
import NMapsMap

class ClusteringViewController: UIViewController {
	
    //클러스터러 객체 
    var clusterer: NMCClusterer<ItemKey>?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        let mapView = NMFNaverMapView(frame: view.frame)
        view.addSubview(mapView)
        
        //클러스터러 객체 초기화
        let builder = NMCBuilder<ItemKey>()
        clusterer = builder.build()
        
        //좌표 데이터 추가
        let keyTagMap = [
            ItemKey(identifier: 1, position: NMGLatLng(lat: 37.5133, lng: 127.1026)): NSNull(),
            ItemKey(identifier: 2, position: NMGLatLng(lat: 37.5194, lng: 126.9404)): NSNull(),
            ItemKey(identifier: 3, position: NMGLatLng(lat: 37.5789, lng: 126.9770)): NSNull(),
            ItemKey(identifier: 4, position: NMGLatLng(lat: 37.5512, lng: 126.9882)): NSNull(),
            ItemKey(identifier: 5, position: NMGLatLng(lat: 37.5823, lng: 126.9831)): NSNull(),
            ItemKey(identifier: 6, position: NMGLatLng(lat: 37.5665, lng: 127.0090)): NSNull(),
            ItemKey(identifier: 7, position: NMGLatLng(lat: 37.5704, lng: 126.9780)): NSNull(),
            ItemKey(identifier: 8, position: NMGLatLng(lat: 37.5740, lng: 126.9855)): NSNull(),
            ItemKey(identifier: 9, position: NMGLatLng(lat: 37.5568, lng: 126.9237)): NSNull(),
            ItemKey(identifier: 10, position: NMGLatLng(lat: 37.5711, lng: 126.9768)): NSNull(),
            ItemKey(identifier: 11, position: NMGLatLng(lat: 37.5304, lng: 126.9877)): NSNull(),
            ItemKey(identifier: 12, position: NMGLatLng(lat: 37.5126, lng: 127.0590)): NSNull(),
            ItemKey(identifier: 13, position: NMGLatLng(lat: 37.5096, lng: 127.0630)): NSNull(),
            ItemKey(identifier: 14, position: NMGLatLng(lat: 37.5271, lng: 126.9341)): NSNull(),
            ItemKey(identifier: 15, position: NMGLatLng(lat: 37.5345, lng: 126.9931)): NSNull(),
            ItemKey(identifier: 16, position: NMGLatLng(lat: 37.5658, lng: 126.9753)): NSNull(),
            ItemKey(identifier: 17, position: NMGLatLng(lat: 37.5794, lng: 126.9910)): NSNull(),
            ItemKey(identifier: 18, position: NMGLatLng(lat: 37.5637, lng: 126.9850)): NSNull(),
            ItemKey(identifier: 19, position: NMGLatLng(lat: 37.5702, lng: 126.9699)): NSNull(),
            ItemKey(identifier: 20, position: NMGLatLng(lat: 37.5795, lng: 127.0075)): NSNull()
        ]
        clusterer?.addAll(keyTagMap)
        
        //지도 객체 지정
        clusterer?.mapView = mapView.mapView
    }

}

 

그 후엔 실제 클러스터링 동작을 하는 NMCClusterer 객체를 만들어주고,

좌표값을 clusterer에 추가한 뒤 클러스터러에 지도 객체를 지정해 주면 된다. (참.. 쉽죠?)

 

 

전체 코드 보기

더보기
import UIKit
import NMapsMap

class ClusteringViewController: UIViewController {
    
    var clusterer: NMCClusterer<ItemKey>?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        let mapView = NMFNaverMapView(frame: view.frame)
        view.addSubview(mapView)
        
        let builder = NMCBuilder<ItemKey>()
        clusterer = builder.build()
        
        let keyTagMap = [
            ItemKey(identifier: 1, position: NMGLatLng(lat: 37.5133, lng: 127.1026)): NSNull(),
            ItemKey(identifier: 2, position: NMGLatLng(lat: 37.5194, lng: 126.9404)): NSNull(),
            ItemKey(identifier: 3, position: NMGLatLng(lat: 37.5789, lng: 126.9770)): NSNull(),
            ItemKey(identifier: 4, position: NMGLatLng(lat: 37.5512, lng: 126.9882)): NSNull(),
            ItemKey(identifier: 5, position: NMGLatLng(lat: 37.5823, lng: 126.9831)): NSNull(),
            ItemKey(identifier: 6, position: NMGLatLng(lat: 37.5665, lng: 127.0090)): NSNull(),
            ItemKey(identifier: 7, position: NMGLatLng(lat: 37.5704, lng: 126.9780)): NSNull(),
            ItemKey(identifier: 8, position: NMGLatLng(lat: 37.5740, lng: 126.9855)): NSNull(),
            ItemKey(identifier: 9, position: NMGLatLng(lat: 37.5568, lng: 126.9237)): NSNull(),
            ItemKey(identifier: 10, position: NMGLatLng(lat: 37.5711, lng: 126.9768)): NSNull(),
            ItemKey(identifier: 11, position: NMGLatLng(lat: 37.5304, lng: 126.9877)): NSNull(),
            ItemKey(identifier: 12, position: NMGLatLng(lat: 37.5126, lng: 127.0590)): NSNull(),
            ItemKey(identifier: 13, position: NMGLatLng(lat: 37.5096, lng: 127.0630)): NSNull(),
            ItemKey(identifier: 14, position: NMGLatLng(lat: 37.5271, lng: 126.9341)): NSNull(),
            ItemKey(identifier: 15, position: NMGLatLng(lat: 37.5345, lng: 126.9931)): NSNull(),
            ItemKey(identifier: 16, position: NMGLatLng(lat: 37.5658, lng: 126.9753)): NSNull(),
            ItemKey(identifier: 17, position: NMGLatLng(lat: 37.5794, lng: 126.9910)): NSNull(),
            ItemKey(identifier: 18, position: NMGLatLng(lat: 37.5637, lng: 126.9850)): NSNull(),
            ItemKey(identifier: 19, position: NMGLatLng(lat: 37.5702, lng: 126.9699)): NSNull(),
            ItemKey(identifier: 20, position: NMGLatLng(lat: 37.5795, lng: 127.0075)): NSNull()
        ]
        clusterer?.addAll(keyTagMap)
        
        clusterer?.mapView = mapView.mapView
    }

}


class ItemKey: NSObject, NMCClusteringKey {
    let identifier: Int
    let position: NMGLatLng

    init(identifier: Int, position: NMGLatLng) {
        self.identifier = identifier
        self.position = position
    }

    static func markerKey(withIdentifier identifier: Int, position: NMGLatLng) -> ItemKey {
        return ItemKey(identifier: identifier, position: position)
    }

    override func isEqual(_ o: Any?) -> Bool {
        guard let o = o as? ItemKey else {
            return false
        }
        if self === o {
            return true
        }

        return o.identifier == self.identifier
    }

    override var hash: Int {
        return self.identifier
    }

    func copy(with zone: NSZone? = nil) -> Any {
        return ItemKey(identifier: self.identifier, position: self.position)
    }
}

 

 

 그런데 클러스터링을 구현했을 때, 단일 마커를 내가 원하는 이미지로 설정하고 싶을 수도 있다.

이럴 때 마커 커스텀을 하기 위해 사용하는 것이 leafMarkerUpdater 이다.

 

 

 위와 같이 마커의 아이콘 이미지도 변경이 가능하며 caption 역시 추가해 줄 수 있다.

 

이 부분은 LeafMarkerUpdater라는 객체를 사용해야 한다. 

extension ClusteringViewController {
    class LeafMarkerUpdater: NMCDefaultLeafMarkerUpdater {
        var clusterer: NMCClusterer<ItemKey>?
        //데이터 - 뷰컨에서 전달
        var list: [Spot] = []
        
        override func updateLeafMarker(_ info: NMCLeafMarkerInfo, _ marker: NMFMarker) {
            super.updateLeafMarker(info, marker)
            if let key = info.key as? ItemKey {
            //커스텀 마커 이미지 설정
                marker.iconImage = NMFOverlayImage(image: UIImage(systemName: "arrowshape.down.fill")!)
                marker.width = 24
                marker.height = 24
                marker.iconTintColor = .black
                //마커 켑션 설정
                if let spot = list.first(where: { $0.lat == key.position.lat } ) {
                    marker.captionText = spot.placeName
                }
            }
        }
    }
}

 

LeafMarkerUpdater는 지도에 표시되는 개별 마커(leaf marker)를 업데이트하는 역할을 한다.

이 클래스는 NMCDefaultLeafMarkerUpdater를 상속하며,

마커 업데이트 기능을 커스터마이즈 할 수 있게 해 준다.

 

이때 key = info.key as? ItemKey 코드에서는 클러스터링을 구현하면서

미리 만들어둔 NMCClusteringKey 클래스 활용해 다운캐스팅을 하고,

marker 이미지를 설정해 주면 된다. 

 

그리고 spot = list.first(where: { $0.lat == key.position.lat } ) 코드에서는

좌표값을 비교해서 Spot 객체를 찾고,

그 객체의 placeName을 captionText로 나오게 설정해 준 방법이다.

 

 

        let builder = NMCBuilder<ItemKey>()
        //기존 클러스터링 코드 사이에 leafMarkerUpdater 추가
        let leafMarkerUpdater = LeafMarkerUpdater()
        //leafMarkerUpdater에 데이터 전달
        leafMarkerUpdater.list = list
        //빌더 객체의 leafMarkerUpdater 속성에 생성한 LeafMarkerUpdater 인스턴스를 할당
        //이 인스턴스를 클러스터러를 생성하기 전에 설정해줘야 한다
        builder.leafMarkerUpdater = leafMarkerUpdater
        //LeafMarkerUpdater와 클러스터러 연결
        leafMarkerUpdater.clusterer = self.clusterer
        //클러스터러 생성
        clusterer = builder.build()

 

기존 클러스터 코드에서 leafMarkerUpdater 코드를 추가해야 하는데

중요한 부분은 builder.leafMarkerUpdater = leafMarkerUpdater 코드를

 clusterer = builder.build() 코드 앞에 써줘야 한다.

그렇지 않으면 생성한 leafMarkerUpdater 인스턴스가 할당되지 않아

커스텀한 마커가 적용되지 않는다.

 

그리고 데이터를 전달해 주기 위해 Spot이라는 struct를 만들었고, 이를 담는 배열을 만들었다.

struct Spot {
    let placeName: String
    let lat: Double
    let lng: Double
}

    var list: [Spot] = [
        Spot(placeName: "롯데타워", lat: 37.5133, lng: 127.1026),
        Spot(placeName: "63빌딩", lat: 37.5194, lng: 126.9404),
        Spot(placeName: "경복궁", lat: 37.5789, lng: 126.9770),
        Spot(placeName: "남산서울타워", lat: 37.5512, lng: 126.9882),
        Spot(placeName: "북촌한옥마을", lat: 37.5823, lng: 126.9831),
        Spot(placeName: "동대문디자인플라자", lat: 37.5665, lng: 127.0090),
        Spot(placeName: "청계천", lat: 37.5704, lng: 126.9780),
        Spot(placeName: "인사동", lat: 37.5740, lng: 126.9855),
        Spot(placeName: "홍대거리", lat: 37.5568, lng: 126.9237),
        Spot(placeName: "광화문광장", lat: 37.5711, lng: 126.9768),
        Spot(placeName: "한강공원", lat: 37.5304, lng: 126.9877),
        Spot(placeName: "코엑스", lat: 37.5126, lng: 127.0590),
        Spot(placeName: "여의도한강공원", lat: 37.5271, lng: 126.9341),
        Spot(placeName: "이태원", lat: 37.5345, lng: 126.9931),
        Spot(placeName: "덕수궁", lat: 37.5658, lng: 126.9753),
        Spot(placeName: "창덕궁", lat: 37.5794, lng: 126.9910),
        Spot(placeName: "명동", lat: 37.5637, lng: 126.9850),
        Spot(placeName: "경희궁", lat: 37.5702, lng: 126.9699),
        Spot(placeName: "낙산공원", lat: 37.5795, lng: 127.0075),
        Spot(placeName: "네이버", lat: 37.35957, lng: 127.10539)
    ]

 

 

결과물

 

 

전체코드 보기

더보기
import UIKit
import NMapsMap

struct Spot {
    let placeName: String
    let lat: Double
    let lng: Double
}

class ClusteringViewController: UIViewController {
    
    var list: [Spot] = [
        Spot(placeName: "롯데타워", lat: 37.5133, lng: 127.1026),
        Spot(placeName: "63빌딩", lat: 37.5194, lng: 126.9404),
        Spot(placeName: "경복궁", lat: 37.5789, lng: 126.9770),
        Spot(placeName: "남산서울타워", lat: 37.5512, lng: 126.9882),
        Spot(placeName: "북촌한옥마을", lat: 37.5823, lng: 126.9831),
        Spot(placeName: "동대문디자인플라자", lat: 37.5665, lng: 127.0090),
        Spot(placeName: "청계천", lat: 37.5704, lng: 126.9780),
        Spot(placeName: "인사동", lat: 37.5740, lng: 126.9855),
        Spot(placeName: "홍대거리", lat: 37.5568, lng: 126.9237),
        Spot(placeName: "광화문광장", lat: 37.5711, lng: 126.9768),
        Spot(placeName: "한강공원", lat: 37.5304, lng: 126.9877),
        Spot(placeName: "코엑스", lat: 37.5126, lng: 127.0590),
        Spot(placeName: "여의도한강공원", lat: 37.5271, lng: 126.9341),
        Spot(placeName: "이태원", lat: 37.5345, lng: 126.9931),
        Spot(placeName: "덕수궁", lat: 37.5658, lng: 126.9753),
        Spot(placeName: "창덕궁", lat: 37.5794, lng: 126.9910),
        Spot(placeName: "명동", lat: 37.5637, lng: 126.9850),
        Spot(placeName: "경희궁", lat: 37.5702, lng: 126.9699),
        Spot(placeName: "낙산공원", lat: 37.5795, lng: 127.0075),
        Spot(placeName: "네이버", lat: 37.35957, lng: 127.10539)
    ]
    
    var clusterer: NMCClusterer<ItemKey>?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        let mapView = NMFNaverMapView(frame: view.frame)
        view.addSubview(mapView)
        
        let builder = NMCBuilder<ItemKey>()
        let leafMarkerUpdater = LeafMarkerUpdater()
        builder.leafMarkerUpdater = leafMarkerUpdater
        clusterer = builder.build()
        leafMarkerUpdater.clusterer = self.clusterer
        leafMarkerUpdater.list = list
        
        var keyTagMap: [ItemKey: NSNull] = [:]
        var index = 0
        
        for spot in list {
            index += 1
            let newItem = ItemKey(identifier: index, position: NMGLatLng(lat: spot.lat, lng: spot.lng))
            keyTagMap[newItem] = NSNull()
        }
        
        clusterer?.addAll(keyTagMap)
        
        clusterer?.mapView = mapView.mapView
    }

}

extension ClusteringViewController {
    class LeafMarkerUpdater: NMCDefaultLeafMarkerUpdater {
        var clusterer: NMCClusterer<ItemKey>?
        var list: [Spot] = []
        
        override func updateLeafMarker(_ info: NMCLeafMarkerInfo, _ marker: NMFMarker) {
            super.updateLeafMarker(info, marker)
            if let key = info.key as? ItemKey {
                marker.iconImage = NMFOverlayImage(image: UIImage(systemName: "arrowshape.down.fill")!)
                marker.width = 24
                marker.height = 24
                marker.iconTintColor = .black
                
                if let spot = list.first(where: { $0.lat == key.position.lat } ) {
                    marker.captionText = spot.placeName
                }
            }
        }
    }
}


class ItemKey: NSObject, NMCClusteringKey {
    let identifier: Int
    let position: NMGLatLng

    init(identifier: Int, position: NMGLatLng) {
        self.identifier = identifier
        self.position = position
    }

    static func markerKey(withIdentifier identifier: Int, position: NMGLatLng) -> ItemKey {
        return ItemKey(identifier: identifier, position: position)
    }

    override func isEqual(_ o: Any?) -> Bool {
        guard let o = o as? ItemKey else {
            return false
        }
        if self === o {
            return true
        }

        return o.identifier == self.identifier
    }

    override var hash: Int {
        return self.identifier
    }

    func copy(with zone: NSZone? = nil) -> Any {
        return ItemKey(identifier: self.identifier, position: self.position)
    }
}

 

 

 

사실 처음 구현할 땐 생각보다 시간이 오래 걸렸지만, 예제 코드를 참고하면서 해보니 많은 도움이 되었다.

만약 네이버맵에서 구현에 막히는 부분이 있다면 

네이버맵 깃헙을 참고하면 도움이 될 것이다.