[Android] NaverMap Compose 클러스터링 구현

easyhooon·2025년 5월 6일
3
post-thumbnail

새로 업데이트된 gtx 노선때매 반갈죽 당해버린ww

서론

NaverMap Compose 를 통해 클러스터링을 구현하는 방식에 대해, github repository 내에 샘플 예제를 제외하면 마땅한 레퍼런스가 존재하지 않아, 이를 구현하는 방법과 구현하면서 겪었던 문제를 해결하는 방법을 공유해보고자 한다.

NaverMap 의 경우 공식 문서가 아직 XML 기반으로 서술되어있고, Compose 버전의 NaverMap 을 정식으로 지원하지 않는다.
fornewid 님께서 NaverMap 을 Compose 환경에서도 사용할 수 있도록 오픈소스 라이브러리로 개발하여 운영중이시다.
KakaoMap 도 마찬가지로 Compose 버전을 정식으로 지원하지 않는다.

NaverMap 에서 클러스터링을 정식으로 지원하지 않았을 때는, Ted Park 님께서 개발하신 TedNaverClustering 를 사용하면 되었는데, NaverMap 에서 정식으로 지원하는 클러스터링이 어느정도 안정화가 되었으므로, 이제는 정식버전을 사용하면 될듯하다.

본론

초기 설정 및 구현 자체는 NaverMap 공식 문서의 클러스터링 파트 내용을 한번 숙지한 후에, github repository 내에 클러스터링 예제 코드들을 참고하여 구현(요구사항에 맞춰 설정 값들을 변경)하면 되기에 큰 어려움은 없었다.

기존 XML 기반의 NaverMap github repository 에 있던 예제 코드들을 전부 Compose 로 포팅 해주셔서, 구현하는데 정말 큰 도움이 되었다.
감사합니다. fornewid 님(_ _)

직접 구현한 코드는 다음과 같고, 각각의 람다함수와 개별 속성들에 대해 주석을 달아보았다.

@OptIn(ExperimentalNaverMapApi::class)
@Composable
internal fun MapContent(
    uiState: MapUiState, // 지도 UI 상태를 관리하는 객체
    cameraPositionState: CameraPositionState, // 카메라 위치 상태를 관리
    rotationState: Float, // 지도 회전 상태
    onMapUiAction: (MapUiAction) -> Unit, // 지도 UI 액션 처리를 위한 콜백
    isClusteringEnabled: Boolean, // 클러스터링 활성화 여부 (마커 그룹화)
    // ...
) {
    Box {
        NaverMap(
        	// 카메라 위치 설정
            cameraPositionState = cameraPositionState, 
            properties = MapProperties(
            	// 위치 추적 모드 설정 (위치를 자동으로 따라가지 않음)
                locationTrackingMode = LocationTrackingMode.NoFollow, 
                // 시스템 다크 모드에 따라 지도 테마 설정
                isNightModeEnabled = isSystemInDarkTheme(), 
            ),
            uiSettings = MapUiSettings(
            	// 줌 컨트롤 버튼 활성화 
                isZoomControlEnabled = true, 
                // 축척 표시 비활성화
                isScaleBarEnabled = false, 
                // Naver 로고 클릭 비활성화
                isLogoClickEnabled = false, 
                // 현재 위치 버튼 활성화
                isLocationButtonEnabled = true, 
            ),
            // 위치 정보 소스 설정
            locationSource = rememberFusedLocationSource(), 
        ) {
            // 학교 경계선 표시 - 다각형 오버레이로 구현
            PolygonOverlay(
            	// 외부 경계선 좌표
                coords = uiState.outerPolygon, 
                // 반투명 회색으로 채우기
                color = Color.Gray.copy(alpha = 0.3f), 
                // 경계선 색상
                outlineColor = Color.Gray, 
                // 경계선 두께
                outlineWidth = 1.dp, 
                // 내부 PolyLines 정의
                holes = uiState.innerPolyLines, 
            )

            // 클러스터링 활성화 여부에 따른 마커 표시 방식 분기
            if (isClusteringEnabled) {
            	// 클러스터러 생성 및 상태 관리 
                var clusterManager by remember { mutableStateOf<Clusterer<BoothMapModel>?>(null) } 
                DisposableMapEffect(uiState.filteredBoothList) { map ->
                    if (clusterManager == null) {
                        clusterManager = Clusterer.ComplexBuilder<BoothMapModel>()
                        	// 최소 클러스터링 줌 레벨 설정 (9 이하에서는 모두 클러스터링)
                            .minClusteringZoom(9) 
                          	// 최대 클러스터링 줌 레벨 설정 (16 이상에서는 클러스터링 해제)
                            .maxClusteringZoom(16) 
                            // 클러스터링할 마커간 최대 화면 거리
                            .maxScreenDistance(200.0) 
                            .thresholdStrategy { zoom ->
                                if (zoom <= 11) {
                                    0.0 // 줌 레벨 11 이하에서는 더 공격적으로 클러스터링 (모든 마커를 클러스터링)
                                } else {
                                    70.0 // 줌 레벨 11 초과에서는 70 픽셀 거리 내에서만 클러스터링
                                }
                            }
                            // 거리별 클러스터링 전략 설정
                            .distanceStrategy(
                                object : DistanceStrategy {
                                	// 기본 거리 전략 사용
                                    private val defaultDistanceStrategy = DefaultDistanceStrategy() 

                                    override fun getDistance(zoom: Int, node1: Node, node2: Node): Double {
                                        return if (zoom <= 9) {
                                            -1.0 // 줌 레벨 9 이하에서는 무조건 클러스터링 (-1은 항상 클러스터링)
                                        } else if ((node1.tag as ItemData).category == (node2.tag as ItemData).category) {
                                            if (zoom <= 11) {
                                                -1.0 // 줌 레벨 11 이하에서 같은 카테고리면 무조건 클러스터링
                                            } else {
                                                defaultDistanceStrategy.getDistance(zoom, node1, node2) // 기본 거리 전략 사용
                                            }
                                        } else {
                                            10000.0 // 다른 카테고리는 클러스터링하지 않음 (큰 값으로 설정)
                                        }
                                    }
                                },
                            )
                            // 마커 tag 병합 전략 설정 
                            .tagMergeStrategy { cluster ->
                                if (cluster.maxZoom <= 9) {
                                    null // 줌 레벨 9 이하에서는 태그 정보 없음
                                } else {
                                    ItemData("", (cluster.children.first().tag as ItemData).category) // 첫 번째 자식의 카테고리 사용
                                }
                            }
                            // 마커 옵션 설정 
                            .markerManager(
                                object : DefaultMarkerManager() {
                                    override fun createMarker() = super.createMarker().apply {
                                    	// 부제목 텍스트 크기
                                        subCaptionTextSize = 10f 
                                        // 부제목 색상
                                        subCaptionColor = android.graphics.Color.WHITE 
                                        // 부제목 테두리 색상
                                        subCaptionHaloColor = android.graphics.Color.TRANSPARENT 
                                    }
                                },
                            )
                            // 클러스터링 마커 설정 
                            .clusterMarkerUpdater { info, marker ->
                                marker.apply {
                                	// 클러스터 아이콘 설정
                                    icon = OverlayImage.fromResource(designR.drawable.ic_cluster) 
                                    // 클러스터 내 마커 개수 표시
                                    captionText = info.size.toString()    
                                    // 캡션 가운데 정렬
                                    setCaptionAligns(Align.Center) 
                                    // 캡션 색상
                                    captionColor = android.graphics.Color.WHITE 
                                    // 캡션 테두리 색상
                                    captionHaloColor = android.graphics.Color.TRANSPARENT 
                                    // 클러스터 클릭 리스너 설정
                                    onClickListener = DefaultClusterOnClickListener(info) 
                                }
                            }
                            // 리프 마커(각각의 마커) 설정
                            .leafMarkerUpdater { info, marker ->
                                marker.apply {
                                	// 카테고리와 선택 상태에 따른 아이콘
                                    icon = MarkerCategory.fromString((info.key as BoothMapModel).category)
                                        .getMarkerIcon((info.key as BoothMapModel).isSelected) 
                                    // 캡션 텍스트 빈 문자열로 설정(초기화, 이후 언급)
                                    // captionText = "" 
                                    // 부제목 텍스트 빈 문자열로 설정(초기화, 이후 언급)
                                    // subCaptionText = "" 
                                    // 마커 클릭 이벤트 처리
                                    onClickListener = Overlay.OnClickListener {
                                        onMapUiAction(MapUiAction.OnBoothMarkerClick(listOf(info.key as BoothMapModel))) 
                                        true
                                    }
                                }
                            }
                            .build()
                            // 클러스터러를 현재 지도에 적용
                            .apply { this.map = map } 
                    }
                    // 부스 리스트를 ItemData와 매핑
                    val boothListMap = uiState.filteredBoothList.associateWith { booth -> ItemData(booth.name, booth.category) } 
                    // 클러스터러에 모든 부스 추가
                    clusterManager?.addAll(boothListMap) 
                    onDispose {
                    	// 컴포저블이 제거될 때 클러스터러 초기화
                        clusterManager?.clear() 
                    }
                }
            } else {
                // 클러스터링이 비활성화된 경우 개별 마커로 표시
                uiState.filteredBoothList.forEach { booth ->
                    ComposeMarker(
                        state = rememberMarkerState(position = LatLng(booth.latitude, booth.longitude)), // 마커 위치 설정
                        icon = MarkerCategory.fromString(booth.category).getMarkerIcon(booth.isSelected), // 카테고리와 선택 상태에 맞는 아이콘
                        onClick = {
                            onMapUiAction(MapUiAction.OnSingleBoothMarkerClick(booth)) // 단일 마커 클릭 이벤트 처리
                            true
                        },
                    )
                }
            }
        }
        // 그밖에 MapScreen 구성요소들...
    }
}

// Marker 에 대한 UiModel 
data class BoothMapModel(
    val id: Long = 0L,
    val name: String = "",
    val category: String = "",
    val description: String = "",
    val thumbnail: String = "",
    val location: String = "",
    val latitude: Double = 0.toDouble(),
    val longitude: Double = 0.toDouble(),
    val isSelected: Boolean = false,
) : ClusteringKey {
    override fun getPosition(): LatLng {
        return LatLng(latitude, longitude)
    }
}

학교라는 좁은 면적내에 위치하는 축제 부스 마커들이 전부 하나로 병합되지 않도록, 클러스터링 조건을 상당히 타이트하게 설정하였고, 설정화면 내에서 클러스터링 활성화 여부를 토글로 관리할 수 있도록 지원 하였다.

문제 발생 1

클러스터링 마커가 분리될때, captionText 가 그대로 유지되는 문제

zoom 을 확대하거나 클러스터링 마커를 클릭하는 경우에, 각 클러스터링 마커는 리프(leaf) 마커들로 분리가 되는데, 이때 기존 클러스터링 마커 자리에 위치하는 리프 마커에 captionText(병합된 리프 마커의 개수)가 표기 되는 문제가 발생하였다.

문제 해결 1

https://navermaps.github.io/android-map-sdk/guide-ko/5-8.html

NaverMap 에서 Marker 객체가 클러스터링이 해제되고, 다시 적용될 때마다 추가, 삭제가 반복된다면 리소스 낭비가 발생할 수 있다. 따라서 NaverMap 측에서는 마커가 재사용되도록 내부적으로 구현하고 있다.

때문에 문서에 언급된 것 처럼, leafMarkerUpdater 람다 함수내에서 명시적으로 마커내에 captionText 를 빈 문자열로 덮어씌우는(초기화 하는) 코드를 추가해줘야 문제를 해결할 수 있다.

.leafMarkerUpdater { info, marker ->
	marker.icon = MarkerCategory.fromString((info.key as BoothMapModel).category)
    	.getMarkerIcon((info.key as BoothMapModel).isSelected)
        // captionText 명시적 초기화
        marker.captionText = ""
        marker.subCaptionText = ""
    	marker.onClickListener = Overlay.OnClickListener {
    	onMapUiAction(MapUiAction.OnBoothMarkerClick(listOf(info.key as BoothMapModel)))
        true
    }
}

문제 발생 2

클러스터링 마커 Custom Click Event 지원

ClursterMarker 에 대한 Click Event 지원
기존의 기획은 클러스터링된 부스를 클릭시, 선택된 부스 목록을 지도 화면 하단에 horizontalPager 로 띄워주는 방식이었다.

하지만, clusterMarkerUpdater 람다 함수의 매개변수인 info, marker 의 경우

.clusterMarkerUpdater { info, marker ->
	marker.apply {
    	// ...
        // 클러스터 내 마커 개수 표시
        captionText = info.size.toString()    
 		//...
        // 클러스터 클릭 리스너 설정
        onClickListener = DefaultClusterOnClickListener(info) 
    }
}

ClusterMarkerInfo.class

@b
@AnyThread
public class ClusterMarkerInfo extends MarkerInfo {
    private final int a;

    ClusterMarkerInfo(long id, @Nullable Object tag, @NonNull WebMercatorCoord coord, @NonNull LatLng position, int minZoom, int maxZoom, int size) {
        super(id, tag, coord, position, minZoom, maxZoom);
        this.a = size;
    }

    @Nullable
    public Object getTag() {
        return super.getTag();
    }

    @NonNull
    public LatLng getPosition() {
        return super.getPosition();
    }

    public int getSize() {
        return this.a;
    }

    public boolean equals(Object o) {
        if (this == o) {
            return true;
        } else if (o != null && this.getClass() == o.getClass()) {
            if (!super.equals(o)) {
                return false;
            } else {
                ClusterMarkerInfo var2 = (ClusterMarkerInfo)o;
                return this.a == var2.a;
            }
        } else {
            return false;
        }
    }

    public int hashCode() {
        int var1 = super.hashCode();
        var1 = 31 * var1 + this.a;
        return var1;
    }
}

Marker.class

public final class Marker extends Overlay {
    //...
    @b
    public void setMap(@Nullable NaverMap map) {
        super.setMap(map);
    }

    @b
    public int getGlobalZIndex() {
        return super.getGlobalZIndex();
    }

    @b
    public void setGlobalZIndex(int globalZIndex) {
        super.setGlobalZIndex(globalZIndex);
    }

    @Keep
    @NonNull
    @b
    @UiThread
    public LatLng getPosition() {
        this.e();
        return this.nativeGetPosition();
    }

	@Keep
    @b
    @UiThread
    public void setPosition(@NonNull LatLng position) {
        this.e();
        a("position", position);
        this.nativeSetPosition(position.latitude, position.longitude);
    }

    @Keep
    @NonNull
    @b
    @UiThread
    public OverlayImage getIcon() {
        this.e();
        return this.a;
    }

    @Keep
    @b
    @UiThread
    public void setIcon(@NonNull OverlayImage icon) {
        this.e();
        if (!ObjectsCompat.equals(this.a, icon)) {
            this.a = icon;
            if (this.isAdded()) {
                this.nativeSetIcon(icon);
            }

        }
    }

    @ColorInt
    @Keep
    @b
    @UiThread
    public int getIconTintColor() {
        this.e();
        return this.nativeGetIconTintColor();
    }

    @Keep
    @b
    @UiThread
    public void setIconTintColor(@ColorInt int color) {
        this.e();
        this.nativeSetIconTintColor(color);
    }
    // ... 너무 길어서 생략
}

요약하자면 매개변수인 info 내에서 현재 병합된 리프 마커들의 객체 목록을 가져올 수 없기 때문에, 구현이 불가능하였다.

따라서, 클러스터링 마커의 기본 클릭 이벤트를 지원하는 DefaultCusterOnClickListener 를 적용하여, 클릭시 클러스터링 마커가 각각의 리프 마커로 분리되도록 구현하였다.

네이버 클라우드에 문의한 결과, tagMergeStrategy 를 이용해 자식 리프 노드의 데이터를 리스트로 병합하여 클러스터의 tag 로 지정해 접근하면 된다고 하는데,
tagMergyStategy 람다함수 내에 return 값을 clusterMarkerUpdater 람다 함수에서 사용할 방법을 찾지 못하였다...

문제 해결 2

문의 답변 처럼, tagMergeStrategy 람다함수를 통해 클러스터링 마커에 병합된 리프 노드들의 tag 를 List 로 묶어 반환하면, 이를 clusterMarkerUpdater 람다 함수내에 매개변수인 info 를 통해 info.tag 로 접근할 수 있었다!

따라서 이 info.tag 를 원하는 타입으로 형변환 후(info.tag 의 기본 타입은 Object) 클릭 이벤트내에 전달해주면 된다.

.tagMergeStrategy { cluster ->
	cluster.children.flatMap { child ->
    	when (val tag = child.tag) {
        	is BoothMapModel -> listOf(tag)
            is List<*> -> tag.filterIsInstance<BoothMapModel>()
            else -> emptyList()
        }
    }
}
// ...다른 람다 함수
.clusterMarkerUpdater { info, marker ->
	marker.apply {
    	icon = OverlayImage.fromResource(designR.drawable.ic_cluster)
        captionText = info.size.toString()
        //... 다른 속성들 
        val booths = (info.tag as? List<BoothMapModel>) ?: emptyList()
        onClickListener = Overlay.OnClickListener {
        	onMapUiAction(MapUiAction.OnBoothMarkerClick(booths))
            true
        }
    }
}

한계

클러스터링 마커 Custom Design

Clustering Marker 에 대한 Icon Custom Design 지원

이슈 링크를 타고 들어가면 각각의 한계에 대한 자세한 설명을 확인 할 수 있다.

기존의 앱의 기획은 위의 이미지 처럼, 같은 카테고리의 마커들만을 클러스터링으로 묶고, 몇개의 마커가 병합되었는지에 대한 개수를 마커에 오른쪽 상단에 표기하도록 구현하려고 하였다.

하지만, 마커의 icon 과 captionText 등은 어떤 값을 넣을지 설정만 가능하지(타입도 제한됨), 기획에서 처럼 captionText 의 위치와 나타내는 방식 자체를 커스텀할 순 없었다.내가 못 찾은 것일 수도 있다.

코드를 보면 알 수 있듯이, 빌더 패턴을 통한 명령형 프로그램의 방식으로 각각의 개별 속성들을 정의하는 스타일로 클러스터링을 구현하게 되는데, 이는 NaverMap Compose 가 기존의 XML 기반의 Android NaverMap 을 Compose 로 Wrapping 한 라이브러리이기 때문이다.

구현할 방법이 아예 없진 않는데, 모든 경우에 수(ex. 13개의 마커가 병합된 카테고리가 Bar 인 마커, 25개의 마커가 병합된 카테고리가 Food 인 마커 등등...)에 대한 리소스 파일을 앱에 넣어놓은 뒤에, 이를 각 상황에 맞게 분기처리하여 모두 적용해주면 된다.

하지만 이는 너무나 많은 리소스를 앱에 추가해야 하는 관계로, 앱의 용량이 많이 늘어날 수 있기 때문에 현명한 방식이 아니라고 판단하여, 절충하여 현재 썸네일의 형태로 구현하게 되었다.

위의 문제 2의 해결방식을 응용해서, clusterMarkerUpdater 내에 클러스터링 마커내에 병합된 마커 객체들을 모두 가져오는 방식으로 필요한 정보를 모두 가져올 순 있는 것을 확인할 수 있었다.
이후 클러스터링 전략 변경(거리로만 판단하는 것이 아닌, 같은 카테고리의 마커들만 병합)과 아이콘 변경 및 텍스트 추가를 위한 Custom AndroidView 구현을 통해 어떻게든 구현은 할 수 있을 것 같은데, 조금 더 고민을 해봐야할 것 같다.

각각의 설정을 위한 람다함수 내부에서는 Composable 함수를 사용할 수 없기 때문에, 마커에 대한 커스텀 디자인은 아직 Custom AndroidView 로 구현해야하는 점이 개인적으로 아쉬운 부분이다.

...그러면 마커를 Compose 스럽게 구현할 방법은 없나요?

그건 아니다. 클러스터링 마커가 아닌, 기본 마커의 경우 Composable 함수의 형태로 구현할 수 있도록 지원하고 있다.

지원을 부탁드렸더니 직접 만들어주셨다 ㅎㅎ (_ _)
내부 구현 코드는 여기서 확인할 수 있다.

// 사용 예시
uiState.filteredBoothList.forEach { booth ->
	ComposeMarker(
    	state = rememberMarkerState(position = LatLng(booth.latitude, booth.longitude)),
        icon = MarkerCategory.fromString(booth.category).getMarkerIcon(booth.isSelected),
        onClick = {
        	onMapUiAction(MapUiAction.OnSingleBoothMarkerClick(booth))
            true
        },
    )
}

// MarkerCategory.kt
import com.naver.maps.map.overlay.OverlayImage

enum class MarkerCategory(val value: String) {
    BAR("BAR"),
    FOOD("FOOD"),
    EVENT("EVENT"),
    NORMAL("NORMAL"),
    MEDICAL("MEDICAL"),
    TOILET("TOILET"),
    ;

    companion object {
        fun fromString(value: String): MarkerCategory {
            return entries.find { it.value == value } ?: NORMAL
        }
    }

    fun getMarkerIcon(isSelected: Boolean): OverlayImage {
        if (isSelected) {
            return when (this) {
                BAR -> OverlayImage.fromResource(R.drawable.ic_marker_bar_selected)
                FOOD -> OverlayImage.fromResource(R.drawable.ic_marker_food_selected)
                EVENT -> OverlayImage.fromResource(R.drawable.ic_marker_event_selected)
                NORMAL -> OverlayImage.fromResource(R.drawable.ic_marker_normal_selected)
                MEDICAL -> OverlayImage.fromResource(R.drawable.ic_marker_medical_selected)
                TOILET -> OverlayImage.fromResource(R.drawable.ic_marker_toilet_selected)
            }
        } else {
            return when (this) {
                BAR -> OverlayImage.fromResource(R.drawable.ic_marker_bar)
                FOOD -> OverlayImage.fromResource(R.drawable.ic_marker_food)
                EVENT -> OverlayImage.fromResource(R.drawable.ic_marker_event)
                NORMAL -> OverlayImage.fromResource(R.drawable.ic_marker_normal)
                MEDICAL -> OverlayImage.fromResource(R.drawable.ic_marker_medical)
                TOILET -> OverlayImage.fromResource(R.drawable.ic_marker_toilet)
            }
        }
    }
}

덕분에 원하는 리소스 파일을 통해 Composable 함수의 선언형의 방식으로 마커를 디자인 할 수 있었다.

결론

NaverMap Compose 를 통해 클러스터링을 구현하는 방식과, 직접 구현하면서 발생했던 문제에 대한 해결 방법, 그밖에 아직은 지원하지 않는 부분들을 알아볼 수 있었다.

전체 코드는 하단 링크를 통해 확인할 수 있습니다.
https://github.com/Project-Unifest/unifest-android

reference)
https://github.com/fornewid/naver-map-compose/tree/main/app/src/main/java/com/naver/maps/map/compose/demo/clustering
https://navermaps.github.io/android-map-sdk/guide-ko/5-8.html

profile
실력은 고통의 총합이다. Android Developer

1개의 댓글

comment-user-thumbnail
2025년 6월 17일

헉 이렇게 좋은 것이 있었다니...다음에 또 저희 학교 축제 해주시면 홍보해드리겠습니다!

답글 달기