[iOS] 지도 마커 클러스터링 적용

민경준·2025년 3월 26일
1
post-thumbnail

📌 겹치는 마커 해결: 그리드 클러스터링 도입기

사용자로부터 지도 마커가 겹치는 문제에 대한 문의를 받으면서, 보다 명확하고 직관적인 지도 서비스를 제공하기 위한 해법을 고민하게 되었습니다. 여러 클러스터링 알고리즘 중에서도, 데이터의 분포와 처리 효율성을 고려하여 그리드 기반 알고리즘을 선택하게 되었고, 이를 통해 사용자 경험을 획기적으로 개선할 수 있었습니다. 이 글은 마커 클러스터링 도입 결정부터 실제 구현까지의 과정을 담은 기록으로, 여러분과 그 여정을 공유하고자 합니다.

✨ 문제 인식 및 배경

마커 겹침 문제는 사용자의 문의가 있기 전부터 인지하고 있었습니다. 같은 건물에 여러 약국이 위치할 수 있고, 지도의 확대 배율이 줄어들면 겹침으로 인해 선택이 어려워지는 문제를 직접 경험했습니다. 이에 모바일 파트장에게 건의하여 디자인과 기획을 논의한 적도 있었지만, 회사 내부 사정으로 인해 중단된 채 서비스가 진행되었습니다. 이번에 사용자의 문의가 생기면서 다시 작업에 들어가게 되었습니다.

✨ 기존 접근법의 한계

기존 방식은 단순히 현재 화면에 보이는 위도/경도 범위를 기준으로 서버에서 약국 리스트를 받아와 각 약국에 대응하는 핀을 개별적으로 지도에 뿌려주는 구조였습니다. 이러한 접근법은 각각의 마커가 독립적으로 렌더링되기 때문에, 위치가 근접한 약국들이 서로 겹쳐서 표시되는 문제가 발생했습니다. 특히 지도를 축소했을 때 다수의 마커가 한 화면에 몰리게 되어, 사용자가 특정 약국을 선택하거나 정보를 확인하기 어려워졌습니다. 또한, 화면에 보이는 범위만 고려하다 보니 마커 간의 상호 관계를 고려하지 않아, 단순한 정보 나열 방식으로는 겹침 문제를 근본적으로 해결할 수 없었습니다.

✨ 클러스터링 도입 배경

여러 클러스터링 알고리즘 중 그리드 기반 방식을 선택한 이유는 작업 일정이 촉박해 복잡하지 않은 로직을 빠르게 적용해야 했기 때문입니다. 이 방식은 화면상에 분포된 핀들을 하나로 표현할 수 있어 효과적이었습니다. 다만, 각 그리드 셀에 따라 핀들이 모이기 때문에, 셀 경계에 가까운 핀들이 하나로 묶이지 않는 한계가 있었습니다. 이후 작업 기간이 넉넉해진다면 다른 알고리즘도 고려해보고 싶은 욕심이 있습니다.


🚀 그리드 기반 클러스터링 로직 구현 및 적용

✨ 리스트의 그룹화

리스트를 그룹화하기 위해 각 그룹을 나타내는 Cell 구조체를 구현했습니다. 각 그룹(Cell)은 해당 그룹에 속하는 아이템 리스트를 관리하기 위해 key-value 형태인 Dictionary로 저장했습니다. 이때, dictionary의 key로 Cell을 사용하기 위해서는 고유하게 식별되어야 하며, 이를 위해 Identifiable 프로토콜을 상속받았습니다.

구조체는 셀의 위치를 나타내는 rowcol 값을 포함하며, 고유 식별자로는 "\(row)-\(col)" 형식의 문자열을 사용해 각 셀이 유일한 키 값을 가지도록 구성했습니다.

struct GridCell: Identifiable, Hashable {
    let row: Int
    let col: Int
    var id: String { "\(row)-\(col)" }
}

🔥 데이터 배열을 Dictionary로 매핑하기.

데이터 배열을 Dictionary로 매핑하기 위해, 각 데이터가 갖고 있는 위도/경도 값을 이용해 해당 데이터가 어떤 GridCell에 속하는지 결정하는 것이 핵심입니다. 먼저, 지도를 가로와 세로로 몇 등분할지 결정하여 각 셀의 크기를 산출하고, 이 크기와 데이터의 위도/경도 값을 사용해 해당 셀의 rowcol을 계산하여 Cell을 리턴합니다. 이제 데이터를 groups[cell] 배열에 추가하며 클러스터링 마무리.

또한, 계산 과정 중 인덱스 범위를 초과할 가능성에 대비해 결과를 clamp하는 로직을 추가했습니다. 이 과정을 통해 안정적으로 데이터를 각 GridCell에 매핑할 수 있습니다.

typealias GridGroup = [GridCell: [MetaData]]

/// gridCount: 가로, 세로 몇 등분할지 결정 (예: 3이면 3x3 그리드)
private func steps(gridCount: Int) -> (lngStep: Double, latStep: Double) {
    let lngStep = (maxLng - minLng) / Double(gridCount)
    let latStep = (maxLat - minLat) / Double(gridCount)
    return (lngStep, latStep)
}

/// 주어진 Map 좌표가 속하는 GridCell을 반환합니다.
func cell(for data: MetaData, gridCount: Int) -> GridCell? {
    guard let lng = data.lng, let lat = data.lat else {
        return nil
    }
    
    // 지도 영역 밖인 경우 nil 반환
    guard lng >= minLng, lng <= maxLng,
          lat >= minLat, lat <= maxLat else {
        return nil
    }
    
    let (lngStep, latStep) = steps(gridCount: gridCount)
    let col = Int((lng - minLng) / lngStep)
    let row = Int((lat - minLat) / latStep)
    
    // 계산 결과가 경계값을 초과하지 않도록 clamp
    let clampedCol = min(max(col, 0), gridCount - 1)
    let clampedRow = min(max(row, 0), gridCount - 1)
    
    return GridCell(row: clampedRow, col: clampedCol)
}

func groupMetaData(_ mataDataList: [MetaData], gridCount: Int) -> GridGroup {
    var groups: GridGroup = [:]
    for metaData in mataDataList {
        if let cell = cell(for: metaData, gridCount: gridCount) {
            groups[cell, default: []].append(metaData)
        }
    }
    
    return groups
}

🔥 매핑한 데이터 화면 UI에 적용하기.

화면에서 가장 두드러지게 변화한 부분은 Pin의 위치입니다. 기존에는 각 데이터마다 개별 Pin을 생성하여 mapView에 추가했지만, 이제는 그룹화된 데이터의 평균 위치를 기준으로 Pin을 생성하여 mapView에 표시하도록 개선했습니다.

private func reloadPins() {
	for (grid, dataList) in gridGroup {
    	guard let pin = average(from: dataList) else { continue }
        
        let touchHandler: (NMFOverlay) -> Void = { overlay in
        	/* pin을 선택 했을 때 동작할 로직 */
            openActionSheet()
            
            let target = self.makeTarget(from: pin)
            mapView.cameraUpdate(scrollTo: target)
        }
        
        mapView.insertPin(pin, touchHandler: touchHandler)
    }
}


func average(from metaDataList: [MetaData]) -> Coordinate? {
	guard !metaDataList.isEmpty else { return nil }

	let total = metaDataList.reduce((lat: 0.0, lng: 0.0)) { (result, metaData) in
        (lat: result.lat + metaData.lat, lng: result.lng + metaData.lng)
    }

    let count = Double(metaDataList.count)
    return .init(latitude: total.lat / count, longitude: total.lng / count)
}

또한, Pin을 선택했을 때 직접 구현한 actionSheet가 표시되어 해당 약국의 상세 정보를 보여주도록 개선했습니다.
기존에는 여러 약국 정보를 동시에 표시할 수 있는 UI가 없었지만, 이번에 actionSheet를 별도로 분리하여 제작하고 기존에 사용하던 DataView와 UITableView를 활용함으로써, 데이터의 개수에 따라 유동적으로 UI를 구성할 수 있도록 했습니다.

final class ActionSheet: UIView {
	private let baseView: UIStackView = .init()
    private let contentView: DataView = .init()
    private let tableView: UITableView = .init()
   
    override init(frame: CGRect) {
        super.init(frame: frame)
        
    	self.addSubview(self.baseView)
        self.baseView.addArrangedSubview(self.contentView)
        self.baseView.addArrangedSubview(self.tableView)
        
        self.baseView.snp.makeConstraints { make in
        	make.edges.equalToSuperview()
        }
        self.tableView.snp.makeConstraints { make in 
        	make.width.equalToSuperview()
            make.height.equalTo(272)
        }
        
        /* DataView는 내부에 UIStackView로 구성하여 자동으로 높이 조절 */
    }
    
    
    func setData(_ metaData: MetaData) {
    	self.tableView.isHidden = (itemList.count < 2)
        self.contentView.isHidden = (itemList.count > 1)
    	
        /* 각 view에 맞게 data setting */
    }
}

✨ 지도 카메라 이동 시키기

이렇게 데이터를 클러스터링하고 UI에 적용하는 작업은 완료되었지만, 한 가지 숙제가 남아 있었습니다. actionSheet에 리스트 형식을 추가하면서 그 높이가 증가해, 해당 pin을 가리게 되는 문제가 발생했습니다. 기존에는 선택한 pin의 위치를 카메라 정중앙으로 업데이트해 보여주었으나, actionSheet의 높이가 중앙보다 높아짐에 따라 pin이 가려지는 문제가 생겼습니다. 이에 카메라 위치 업데이트 과정을 재조정하는 작업에 대한 기록을 공유하고자 합니다.

🔥 첫 번째 아이디어.

Pin을 중앙보다 약간 더 높은 위치에 표시하기 위해, 카메라의 위치를 기존보다 낮추어야 했습니다. 이를 위해 화면 상의 1px이 실제 위도에서 어느 정도의 거리에 해당하는지를 계산하고, 이 값을 바탕으로 원하는 오프셋을 적용하여 pin의 위도에서 빼주는 방식을 도입했습니다. 이로써 카메라가 실제보다 아래쪽으로 이동하도록 하여, actionSheet가 높아진 상황에서도 pin이 가려지지 않도록 조정할 수 있었습니다.

func makeTarget(from coordinate: Coordinate) -> Coordinate {
	// 1. 화면상의 1픽셀당 위도 변화량 계산
	let distance = region.minLat.distance(to: region.maxLat)
    let screenSize: CGFloat = self.bounds.height
        
    // 2. 최종 새로운 좌표 계산
    let pixel = distance / screenSize
    let latitude = coordinate.lat - (60.0 * pixel)
    return .init(latitude: latitude, longitude: coordinate.lng)
}

🔥 두 번째 아이디어.

그러나 이 방법은 지도가 회전하지 않았을 때만 유효한 계산이었습니다. 예를 들어, 지도를 회전시켜 동쪽이 위쪽으로 오게 설정하면, 기존 방식대로 계산했을 경우 실제 지도에서는 아래쪽으로 이동하는 것이 아니라 왼쪽으로 이동하는 문제가 발생했습니다.

이를 해결하기 위해, 원하는 이동 거리를 회전 공식을 적용하여 실제 회전된 화면에서 이동할 때, 단순히 y값만 이동하는 것이 아니라 x값도 함께 보정되어야 함을 인지했습니다. 따라서 위도와 경도 모두에 대해 적절한 계산을 수행해, 지도의 회전에 상관없이 정확한 위치로 카메라를 이동시키는 방식을 도입했습니다.

func makeTarget(from coordinate: Coordinate) -> Coordinate {
	// 1. 화면의 오프셋 (예: 60픽셀 아래쪽)
    let screenOffset = CGPoint(x: 0, y: 60)

    // 2. 지도 회전 각도 (라디안 단위)
    let rotationAngle = self.mapView.rotationAngle
       
    // 3. 회전 행렬 적용하여 지도 좌표계 오프셋으로 변환
    let cos = cos(rotationAngle)
    let sin = sin(rotationAngle)
     
    let dx = (screenOffset.x * cos) - (screenOffset.y * sin)
    let dy = (screenOffset.x * sin) + (screenOffset.y * cos)
       
    // 4. 화면상의 1픽셀당 위도/경도 변화량 계산
    let xPoint: CGPoint = .init(x: self.center.x + 1, y: self.center.y)
    let yPoint: CGPoint = .init(x: self.center.x, y: self.center.y + 1)
        
    // 화면 좌표를 지도 좌표로 변환
    let centerCoordinate = self.mapView.coordinate(from: self.center)
    let xCoordinates = self.mapView.coordinate(from: xPoint)
    let yCoordinates = self.mapView.coordinate(from: yPoint)
        
    // 1픽셀당 위도 변화량 계산
    let xPixel = xCoordinates.lng - centerCoordinate.lng
    let yPixel = centerCoordinate.lat - yCoordinates.lat
    
    // 5. 계산된 오프셋을 지도 좌표의 변화량으로 변환
    let latitude = dy * yPixel
    let longitude = dx * xPixel
        
    return .init(
    	lat: coordinate.lat - latitude,
        lng: coordinate.lng + longitude
    )
}

⚡️ 회전 공식의 문제.

그러나 기존 회전 공식에는 문제가 있었습니다. 일반적인 회전 공식은 동쪽을 0도로 두고 반시계 방향으로 각도가 증가하지만, NaverMap의 cameraPositionheading 값은 북쪽이 0도이며 시계 방향으로 값이 증가합니다. 이 차이로 인해 기존 공식을 그대로 적용하면 카메라 업데이트 위치가 크게 왜곡되는 문제가 발생했습니다.

이 문제를 인지하고, 기존 공식을 수정하여 아래와 같이 적용하였습니다.

---
let dx = (screenOffset.x * cos) - (screenOffset.y * sin)
let dy = (screenOffset.x * sin) + (screenOffset.y * cos)

===>

let dx = (screenOffset.x * cos) - (screenOffset.y * sin)
let dy = (-screenOffset.x * sin) + (screenOffset.y * cos)
---

🔥 세 번째 아이디어.

하지만 이 로직에는 여전히 여러 문제가 존재했습니다. 첫째, 삼각함수(cos, sin)의 기본 입력 단위가 라디안임에도 불구하고, 지도 회전 각도를 라디안으로 변환하지 않아 정확한 위도/경도 계산이 이루어지지 않았습니다. 둘째, 평면 좌표계에서는 단순히 x, y 좌표의 차이로 거리를 계산할 수 있으나, 지구와 같이 구형인 표면에서는 이 방식이 정확하지 않습니다. 따라서 지구의 곡률까지 고려하여 오차를 보정해야 위도와 경도 사이의 실제 거리 차이를 계산할 수 있습니다.

그래서 저는 이런 복잡한 공식을 사용하기 보다는 NaverMap 라이브러리에서 제공하는 메서드를 사용하기로 하였습니다. 현재 선택된 pin의 좌표를 화면상의 CGPoint로 변환한 후, 해당 CGPoint에서 이동하고 싶은 거리 만큼 yOffset값을 더한 뒤 다시 지도상의 좌표로 변환하여, 그 위치로 카메라를 이동시키는 방식을 채택하였습니다.

그리고 드디어, 지도의 회전 여부와 상관없이 원하는 위치로 카메라를 정확하게 이동시킬 수 있게 되었으며, actionSheet로 인해 pin이 가려지는 문제까지 해결하는 로직을 완성할 수 있었습니다.

let touchHandler: (NMFOverlay) -> Void = { overlay in
	/* pin을 선택 했을 때 동작할 로직 */
    openActionSheet()

	let target = self.makeTarget(from: overlay)
    mapView.cameraUpdate(scrollTo: target)
}

func makeTarget(from overlay: NMFOverlay, offset yOffset: Double = 100.0) -> Coordinate {
	guard let marker = overaly as? NMFMarker else { return .zero }
    
    let point = mapView.point(from: marker.position)
    let target: CGPoint = .init(x: point.x, y: point.y + offset)
    
    return mapView.coordinate(from: target)
}

📝 글을 마치며..

그리드 방식의 클러스터링은 기본적으로 구역을 나누고, 각 구역에 속하는 데이터를 분류하는 단순한 작업이었습니다. 처음에는 화면 전체에 보이지 않는 Grid Cell을 생성하고, 각 Cell을 순회하면서 dataList에서 해당 Cell에 위치하는 데이터를 선별하여 Dictionary에 저장하는 방식(즉, 2중 for문)을 사용하려 했습니다. 이 경우, 데이터의 개수와 Grid 영역의 수가 늘어날수록 전체 연산 횟수가 기하급수적으로 증가하여 시간 복잡도가 O(N * M)이 되었습니다.

그러나 데이터를 처리할 때 이미 각 데이터의 위도/경도를 이용해 해당 Cell을 반환하고 있었으므로, 굳이 전체 Cell을 미리 생성할 필요가 없다는 결론에 도달했습니다. 대신, dataList를 순회하면서 각 데이터가 속하는 Cell을 바로 계산하고, 이를 Dictionary에 저장하는 방식으로 변경함으로써, 시간 복잡도를 O(N)으로 줄일 수 있었습니다. 이 방식은 Grid의 영역 개수에 영향을 받지 않아 효율적이었습니다.

이번 개발은 1주일 내에 구현, 테스트, 배포를 완료해야 했기 때문에 복잡한 클러스터링 알고리즘 적용은 어려웠지만, 추후 기회가 된다면 그리드 방식이 아닌 밀도 기반으로 특정 반경 내 데이터 포인트의 개수를 활용하는 보다 세밀한 클러스터링 방식을 적용해보고 싶습니다.

또한, 지도 카메라 이동 로직을 직접 구현해보려 했으나, 수학적으로 정확하고 까다로운 계산이 요구되었기에 결국 API에서 제공하는 메서드를 활용하는 방법으로 해결했습니다. 이미 만들어진 메서드를 잘 활용하는 것이 얼마나 중요한지 다시금 깨닫게 되었습니다. 만약 직접 로직을 구현하기 전에 API 활용 아이디어를 먼저 떠올렸다면 구현 시간이 훨씬 단축되었을 것입니다. 과거의 결정은 아쉽지만, 이번 경험을 반면교사 삼아 다음에는 더욱 빠르고 효율적으로 개발할 수 있는 개발자가 되고자 합니다.

profile
iOS Developer 💻

0개의 댓글