📦 ClusterManager: Naver Map 기반 커스텀 클러스터링 구현

 

ClusterManager는 Naver Map 위에서 대량 주문 데이터를 클러스터링하여 가시성 있게 표현하기 위한 커스텀 유틸입니다. 줌 레벨에 따라 유동적으로 클러스터링 범위를 조정하고, 클릭 이벤트에 따라 사용자 액션을 트리거합니다.

 

 


 

🚩 주요 기능

 

  • Haversine 공식을 사용한 거리 기반 클러스터링
  • 줌 레벨에 따라 동적으로 threshold(거리 임계값) 조정
  • 마커 클릭 시 선택 상태 반영 및 카메라 이동
  • 단일 마커/클러스터 마커 각각에 대한 커스텀 뷰 렌더링

 

📌 주요 필드

private val maxDistance = 350.0
private val minDistance = 10.0
private var selectedId: Int? = null
private val markers = mutableListOf<Marker>()
private var items: List<ClusterItem> = mutableListOf()

 


📍 클러스터링 핵심 로직

private fun grouping(items: List<ClusterItem>): List<Pair<LatLng, List<ClusterItem>>> {
    val threshold = when (val zoom = naverMap.cameraPosition.zoom) {
        in 18.0..Float.MAX_VALUE -> 10.0
        in 17.0..18.0 -> 20.0
        in 16.0..17.0 -> 50.0
        in 15.0..16.0 -> 100.0
        in 14.0..15.0 -> 200.0
        // ... 생략 ...
        else -> 5000.0
    }

    val clusters = mutableListOf<Pair<LatLng, List<ClusterItem>>>()
    val visited = mutableSetOf<ClusterItem>()

    for (item in items) {
        if (item in visited) continue

        val group = items.filter { other ->
            !visited.contains(other) && calculateDistance(item, other) < threshold
        }

        visited.addAll(group)
        clusters.add(calculateClusterCenter(group) to group)
    }

    return clusters
}

 

threshold를 세분화해서 계산 했습니다.


 

🗺️ 마커 렌더링

fun update() {
    markers.forEach { it.map = null }
    markers.clear()

    val clusters = grouping(items)

    clusters.forEach { (position, clusterItems) ->
        val marker = Marker().apply {
            this.position = position
            this.tag = clusterItems
            this.icon = // 클러스터 여부에 따라 뷰 생성 생략
            setOnClickListener {
                // 선택 마커 ID 저장 및 콜백 처리
                true
            }
        }
        marker.map = naverMap
        markers.add(marker)
    }
}

 


 

🔄 선택 초기화

fun clearSelection() {
    selectedOrderId = null
    update()
}

 


 

✍️ 사용 예시

val clusterManager = ClusterManager(naverMap, context)
clusterManager.setItems(listOfClusterItems)

 


 

✅ 적용 효과

  • 마커 수가 많을 경우 시각적 복잡도 감소
  • 사용자의 선택과 클러스터 정보를 자연스럽게 연결
  • MapActivity와 연동하여 마커 선택 → 상세 정보 표시 가능

'Android' 카테고리의 다른 글

BackpressCallback  (0) 2024.05.29
네트워크 체크  (0) 2024.05.29

안드로이드 hilt 의존성 주입 라이브러리 적용 과정을 메모.

 

App 단의 Gradle에서

buildscript {
    dependencies {
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
    }

}

 

모듈단의 Gradle에서

apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'


dependencies {
    implementation "com.google.dagger:hilt-android:2.28-alpha"
    kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
}

kapt를 사용합니다.

 

Application을 상속받는 클래스를 하나 만들어서

@HiltAndroidApp 어노테이션을 추가합니다.

@HlitAndroidApp
class AppName : Application()

 

manifest >> application에 AppName을 설정해줍니다

    <application
        android:name="com.my.App"
        android:allowBackup="false"
        android:hardwareAccelerated="true"
        android:icon="@drawable/ic_app_icon"
        android:label="${appName}"
        android:largeHeap="true"
        android:enableOnBackInvokedCallback="true"
        android:requestLegacyExternalStorage="true"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyApp"
        android:usesCleartextTraffic="true"
        tools:replace="android:label,android:theme,android:allowBackup,android:icon">

 

기본적인 세팅은 끝이 났고, 사용하면됩니다.

 

예를들어 특정 모듈에 의존성을 주입하고싶다면.

 

NetworkModule.kt

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    fun provideGson(): Gson = GsonBuilder().create()

    @Provides
    fun provideGoodPharmOkHttpClient() : OkHttpClient =
        if(BuildConfig.DEBUG) { // for debug
            OkHttpClient.Builder()
                .callTimeout(30, TimeUnit.SECONDS)
                .connectTimeout(30, TimeUnit.SECONDS)
                .readTimeout(30, TimeUnit.SECONDS)
                .writeTimeout(30, TimeUnit.SECONDS)
                .addInterceptor(network.RequestInterceptor())
                .addInterceptor(network.ResponseInterceptor())
                .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
                .build()
        } else{
            OkHttpClient.Builder()
                .callTimeout(30, TimeUnit.SECONDS)
                .connectTimeout(30, TimeUnit.SECONDS)
                .readTimeout(30, TimeUnit.SECONDS)
                .writeTimeout(30, TimeUnit.SECONDS)
                .addInterceptor(network.RequestInterceptor())
                .addInterceptor(network.ResponseInterceptor())
                .build()
        }

    @Singleton
    @Provides
    @Named("scalar")
    fun provideStringRetrofit (
        okHttpClient: OkHttpClient
    ) : Retrofit =
        Retrofit.Builder()
            .baseUrl(BuildConfig.ServerUrl)
            .addConverterFactory(ScalarsConverterFactory.create())
            .client(okHttpClient)
            .build()

    @Singleton
    @Provides
    @Named("gson")
    fun provideGoodPharmGsonRetrofit (
        okHttpClient: OkHttpClient
    ) : Retrofit =
        Retrofit.Builder()
            .baseUrl(BuildConfig.ServerUrl)
            .addConverterFactory(nullOnEmptyConverterFactory)
            .addConverterFactory(ScalarsConverterFactory.create())
            .addConverterFactory(GsonConverterFactory.create())
            .client(okHttpClient)
            .build()

    /**
     * 응답값이 비어있는 경우 처리
     */
    private val nullOnEmptyConverterFactory = object : Converter.Factory() {
        fun converterFactory() = this
        override fun responseBodyConverter(type: Type, annotations: Array<out Annotation>, retrofit: Retrofit) = object : Converter<ResponseBody, Any?> {
            val nextResponseBodyConverter = retrofit.nextResponseBodyConverter<Any?>(converterFactory(), type, annotations)
            override fun convert(value: ResponseBody) = if (value.contentLength() != 0L) nextResponseBodyConverter.convert(value) else null
        }
    }
}

이런식으로 사용하면됩니다.

 

@Singleton 어노테이션을 사용하는 이유는 해당 모듈에 대한 싱글톤 패턴을 지키면서 인스턴스 중복생성을 막기 위함입니다.

 

 

 

Activity나 Fragment단에도 Hilt를 적용한다면.

@AndroidEntryPoint 어노테이션을 사용하면됩니다.

 

MyActivity.kt

@AndroidEntryPoint
class MyActivity : BaseActivity<ActivityMyBinding>(R.layout.activity_my) {
	
    private val myViewModel: MyViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding {
             ...
            }
	    }
}

Activity의 경우. Fragment도 동일합니다.

 

view model에 의존성을 주입하는 경우 @HiltViewModel이라는 어노테이션을 사용하면됩니다.

 

MyViewModel.kt

@HiltViewModel
class MyViewModel @Inject constructor(
    private val myUseCase: MyUseCase
): BaseViewModel() { // ViewModel 을 상속받는 베이스 클래스 입니다.

// sample live data
    private val _sampleLiveData = MutableLiveData<String>()
    val sampleLiveData: MutableLiveData<String> = _sampleLiveData

// sample code
    fun checkSample(){
        viewModelScope.launch {
            _sampleLiveData.value = myUseCase.checkSample()
        }
    }
}

생성자가 별도로 필요하지 않은 경우에도

@Inject constructor() 생성해줘야 합니다.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    Surface(modifier, color = MaterialTheme.colorScheme.background) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it person" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {
    OutlinedCard(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.primary
        ),
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        CardContent(name)
    }
}

@Composable
private fun CardContent(name: String) {
    var expanded by rememberSaveable { mutableStateOf(false) }

    Row(
        modifier = Modifier
            .padding(12.dp)
            .animateContentSize(
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioLowBouncy,
                    stiffness = Spring.StiffnessLow
                )
            )
    ) {
        Column(
            modifier = Modifier
                .weight(1f)
                .padding(12.dp)
        ) {
            Text(text = "Hello, ")
            Text(
                text = name, style = MaterialTheme.typography.headlineMedium.copy(
                    fontWeight = FontWeight.ExtraBold
                )
            )
            if (expanded) {
                Text(
                    text = ("Composem ipsum color sit lazy, " +
                            "padding theme elit, sed do bouncy. ").repeat(4),
                )
            }
        }
        IconButton(onClick = { expanded = !expanded }) {
            Icon(
                imageVector = if (expanded) Filled.ExpandLess else Filled.ExpandMore,
                contentDescription = if (expanded) {
                    stringResource(R.string.show_less)
                } else {
                    stringResource(R.string.show_more)
                }
            )
        }
    }
}

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "GreetingPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

 

Android CodeLab 예제 코드

Base 클래스에서 사용하는게 편했습니다.

 

BaseFragment클래스에서 사용한 예제입니다.

abstract class BaseFragment<T : ViewDataBinding> : Fragment() {
		var onBackPressedCallback: OnBackPressedCallback? = null

		...

		override fun onDestroyView() {
        super.onDestroyView()
        onBackPressedCallback?.remove()
    }
}

디스트로이에서 remove()해주었습니다.

 

onBackPressedCallback = object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
								// 처리할 내용
            }
        }
        requireActivity().onBackPressedDispatcher.addCallback(
            viewLifecycleOwner,
            onBackPressedCallback!!
        )

 

onViewCreated(...) 내부에서 추가해주었습니다.

'Android' 카테고리의 다른 글

네이버지도 마커 클러스터링(커스텀)  (0) 2025.04.22
네트워크 체크  (0) 2024.05.29

대부분의 네트워크 체킹

BroadcastReceiver에 등록하는 액션 이벤트가 Deprecated 되면서

네트워크 콜백을 사용

 

private fun checkNetworkStatus() {
        val connectivityManager =
            applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network : Network) {
                // has network connection
            }

            override fun onLost(network : Network) {
                // has no connection
            }
        })
    }

'Android' 카테고리의 다른 글

네이버지도 마커 클러스터링(커스텀)  (0) 2025.04.22
BackpressCallback  (0) 2024.05.29

개발하다가 서버로 파일 전송 API를 리스트 순서대로 동기 처리할 케이스가 생겨서 메모

private val failedFileList = mutableListOf<File>() // 실패 아이템 재전송 리스트
private var tryCount = 0 // 시도횟수
private var fileSendJob: Job? = null

fun sendMultipleFile(files: MutableList<File>) {
    fileSendJob?.cancel()
    fileSendJob = viewModelScope.launch {
        isLoadingLiveData.value = true // 로딩 시작
        for (eachFile in files) { // 동기로 동작시키므로 for문 처리
            val result = withContext(Dispatchers.IO) {
                runCatching {
                    fileSendUseCase.postPrescriptions(listOf(eachFile))
                }
            }

            result.onSuccess {
                    when(it) {
                        is CommonResponse.Success -> {
                            DLog.e("success response: ${it.data}")
                            if (failedFileList.contains(eachFile)) {
                                failedFileList.remove(eachFile)
                                tryCount--
                            }
                        }
                        is CommonResponse.Error -> {
                            DLog.e("error response: ${it.error.message}")
                            failedFileList.add(eachFile)
                        }
                    }

                }.onFailure {
                DLog.e("it: ${it.message}")
                failedFileList.add(eachFile)
            }
        }

        if (failedFileList.isNotEmpty() && tryCount < 3) {
            tryCount++
            sendMultipleFile(failedFileList)
        } else {
            if (failedFileList.isNotEmpty()) { // 최종적으로 실패한 아이템이 존재
                DLog.e("failedFileList: ${failedFileList.size}")
                successLiveData.value = Event(false)
                isLoadingLiveData.value = false // 로딩 종료
            } else { // 성공적으로 처리
                successLiveData.value = Event(true)
                isLoadingLiveData.value = false // 로딩 종료
            }
        }
    }

 

 

+ 비동기 처리

fun sendMultipleFile(files: MutableList<File>) {
    fileSendJob?.cancel()
    fileSendJob = viewModelScope.launch {
        isLoadingLiveData.value = true
        val deferredResults = files.map { eachFile ->
            async(Dispatchers.IO) {
                runCatching {
                    prescriptionUseCase.postPrescriptions(listOf(eachFile))                }
            }
        }

        val results = deferredResults.awaitAll()

        results.forEachIndexed { index, result ->
            val eachFile = files[index]
            result.onSuccess {
                when (it) {
                    is CommonResponse.Success -> {
                        DLog.e("success response: ${it.data}")
                        if (failedFileList.contains(eachFile)) {
                            failedFileList.remove(eachFile)
                            tryCount--
                        }
                    }
                    is CommonResponse.Error -> {
                        DLog.e("error response: ${it.error.message}")
                        failedFileList.add(eachFile)
                    }
                }
            }.onFailure {
                DLog.e("it: ${it.message}")
                failedFileList.add(eachFile)
            }
        }

        if (failedFileList.isNotEmpty() && tryCount < 3) {
            tryCount++
            sendMultipleFile(failedFileList)
        } else {
            if (failedFileList.isNotEmpty()) {
                DLog.e("failedFileList: ${failedFileList.size}")
                successLiveData.value = Event(false)
            } else {
                successLiveData.value = Event(true)
            }
            isLoadingLiveData.value = false
        }
    }
}


보완은 좀 필요하겠으나. 일단 마무리..

+ Recent posts