grep

Engineering

제휴점 목록/지도 통합기: 26배 폭증한 비용부터 아키텍처 최적화까지

김주엽(Groo) / Android Developer여기어때

2025년 12월 17일

원문에서 보기 ↗

제휴점 목록/지도 통합기: 26배 폭등한 비용부터 아키텍처 최적화까지

안녕하세요, 여기어때에서 안드로이드 앱 개발을 담당하는 소프트웨어 개발자 그루입니다.

지난 몇 개월간 진행했던 ‘제휴점 목록/지도 화면 통합 프로젝트’에 대한 이야기를 공유하려 합니다. 기존 저희 앱은 목록 화면과 지도 화면이 분리되어 있어, 목록 화면에서 발견한 제휴점을 지도 화면에서 처음부터 다시 찾아야 하거나 화면을 오가며 탐색 흐름이 끊기는 불편함이 있었습니다.

이를 해결하기 위해 저희는 ‘연속적인 탐색 경험’을 목표로 두 화면을 하나로 통합하고, 클러스터링과 편의성이 높은 여러 기능을 제공하기로 했습니다.

하지만 이상적인 기능을 구현하는 과정 뒤에는 비용 폭등, 아키텍처의 비대화 등 예상치 못한 기술적 난관들이 기다리고 있었습니다. 이 글에서는 그 문제를 해결해 나간 과정과 고민을 회고해 봅니다.

1. 지도 SDK 비용 26배 폭등을 막아라

가장 먼저 직면한 과제는 비용 효율성 문제였습니다. 사용자 경험(UX) 개선을 위해 목록 뷰와 지도 뷰를 단일 화면으로 통합하는 과정에서, 네이버 지도 SDK의 API 호출량이 기존 일평균 12.9만 건에서 약 340만 건으로 급증 할 것으로 예상되었습니다. 이는 곧 약 26배의 비용 증가를 의미하며, 아무리 뛰어난 UX라 할지라도 감당하기 어려운 수준의 재정적 부담이었습니다.

만약 MapView를 Global로 사용한다면?

비용 문제를 해결하기 위해 먼저 네이버 지도 SDK의 비용 청구 구조를 알아보았습니다. 그 결과 비용이 발생하는 시점은 MapView가 onCreate 되는 순간이라는 것을 알게 되었습니다.

여기서 저희는 한 가지 아이디어를 떠올렸습니다. “그렇다면 MapView를 앱 실행 시 Global Application Context로 딱 한 번만 생성하고, 모든 화면에서 이 인스턴스를 공유해서 쓰면 되지 않을까?”

이론적으로는 획기적인 비용 절감책이었습니다. 하지만 깊이 검토한 결과, 다음과 같은 치명적인 위험 요소들 때문에 이 방법을 포기했습니다.

  1. 안정성 리스크: 네이버 지도 SDK는 Application Context 기반의 싱글톤 사용을 공식적으로 권장하지 않습니다. View 시스템 상 동작은 하더라도, 배포 후 발생할 수 있는 잠재적인 이슈(Memory Leak, Context 불일치 등)를 감당하기엔 위험성이 있었습니다.
  2. 복잡한 상태 관리: 하나의 MapView를 돌려쓸 경우, 화면 스택(Stack) 관리가 매우 복잡해집니다. 예를 들어 A 화면 지도 위에 B 화면이 뜨면 지도를 B에 맞게 세팅해야 하고, 다시 뒤로 가기를 했을 때 A 화면의 지도 상태를 완벽하게 복원해야 합니다. 여러 화면이 중첩될수록 이 상태 관리는 기하급수적으로 어려워질 것이 뻔했습니다.

결국 저희는 “하나의 화면에는 하나의 MapView를 사용한다”는 기본 원칙을 유지하는 것이 장기적인 유지보수 관점에서 옳다고 판단했습니다.

지도가 필요한 시점에만 로드하자

그렇게 저희는 다시 한번 고민했고, 그 결과 도출된 것이 바로 Lazy Load(지연 로드) 전략입니다.

저희는 사용자의 행동 데이터를 분석해, PLP(Product List Page) 등에서는 여전히 ‘목록’ 탐색이 주가 된다는 점에 주목했습니다. 즉 PLP 같은 화면에서는 사용자가 지도 기능을 사용하지 않을 확률이 높으니, 목록에서 지도로 뷰 타입을 전환하는 시점에 지도를 Lazy Load 하는 것도 괜찮겠다고 판단했습니다.

물론, 이 방식에도 Trade-off는 존재합니다. 즉시 로딩보다 지도 렌더링이 미세하게 늦어질 수 있습니다. 하지만 테스트 결과 사용자 경험을 해칠 수준은 아니라고 판단했습니다. 대신 지연 시간 동안 빈 화면이 아닌 지도 이미지 PlaceHolder를 노출하여 체감 속도를 높였고, 추가로 목록 뷰 타입일 때는 서버의 핀 API 호출도 차단하여 사내 서버 비용까지 함께 절감하는 성과를 거두었습니다.

2. 비대해지는 ViewModel을 다이어트시켜라

기존에는 목록과 지도가 분리되어 있어 각각의 ViewModel이 명확한 역할을 수행했습니다. 하지만 두 화면이 물리적으로 통합되면서 상황이 급변했습니다.

단순히 두 화면의 코드를 합치다 보니, 하나의 ViewModel이 감당해야 할 역할이 너무 비대해졌습니다.

지도 제어, 목록 페이징, 필터링 로직 등이 모두 한곳에 뒤섞이면서 ViewModel 파일 하나가 3,000줄을 넘어가 함수 하나를 수정하려 해도 스크롤을 한참 내려야 하는 상황이 발생했습니다. 또 코드를 처음 접하는 동료는 어디서부터 어디까지가 지도의 상태이고, 어디가 목록의 로직인지 파악하는 데 어려움을 겪었습니다.

관심사 분리를 위한 StateHolder 패턴 도입

저희는 이 거대한 ViewModel을 쪼개기 위해 관심사 분리 원칙을 적용했습니다. 화면 전체를 통제하는 것이 아니라, ‘UI 컴포넌트 단위’로 상태와 로직을 분리하는 StateHolder 패턴을 도입했습니다.

구조는 다음과 같이 재편되었습니다.

이 구조를 적용하면서 얻은 이점은 아래와 같습니다.

  1. 캡슐화와 높은 응집도 확보: 상태와 기능 로직을 각자의 주인인 컴포넌트별 StateHolder 내부에 캡슐화하면서 역할과 책임이 같은 코드들이 한곳에 모여 응집도가 크게 향상되었습니다.
  2. 성능 최적화 (Recomposition 방지): Compose 컴포넌트와 StateHolder가 1:1로 매핑되는 구조는 성능 면에서도 큰 이점을 가져왔습니다. 예를 들어, MapStateHolder의 상태가 변해도 PlaceListBottomSheetStateHolder를 바라보는 목록 컴포넌트에는 영향을 주지 않습니다. 즉, 관련 없는 State 변경으로 인한 불필요한 리컴포지션을 구조적으로 차단할 수 있었습니다.
  3. 코드 파악 및 온보딩 용이성: 새로 프로젝트에 합류한 개발자가 “지도 기능”을 분석하고 싶다면, 기존 3,000줄짜리 코드에서 헤맬 필요 없이 MapStateHolder와 Map 컴포넌트만 보면 됩니다. 전체 구조를 다 파악하지 않아도, 해당 컴포넌트와 매칭된 StateHolder만 읽으면 기능의 동작 원리를 즉시 파악할 수 있어 코드 가독성과 생산성이 크게 향상되었습니다.

한계와 고민

하지만, 이 구조가 만능열쇠는 아니었습니다. 실무에 적용하면서 마주한 현실적인 고민과 Trade-off도 분명 존재했습니다.

결론적으로, 완벽한 아키텍처는 없지만 ‘유지보수성’과 ‘성능’이라는 두 마리 토끼를 잡기 위해 현재 상황에서 내린 제일 나은 선택이었다고 생각하며 앞으로 팀원들과 함께 더 나은 방향으로 개선해 나갈 예정입니다.

3. 동일 위치에 겹쳐버린 마커를 개선하라

지도 서비스를 개발하다 보면 “데이터의 정확성”과 “사용자의 시인성”이 충돌하는 지점을 마주하게 됩니다. 특히 관광지 인근이나 대형 레지던스 건물처럼 고밀도 지역이 문제였습니다.

데이터상으로는 서로 다른 4개의 제휴점이지만, 이들이 하나의 큰 건물(동일 주소)에 입점해 있다면 위·경도(lat, lon) 좌표가 소수점까지 완벽하게 일치하는 경우가 발생합니다. 이 경우 지도에는 4개의 마커가 정확히 같은 위치에 Z-Index만 다르게 겹쳐서 렌더링 됩니다.

사용자로서는 손가락 터치 영역 내에 너무 많은 핀이 밀집해 있으면 원하는 대상을 정확히 선택하는 것이 사실상 불가능해집니다. 저희는 이를 해결하기 위해 실제 거리 기반의 정밀한 그룹화가 필요했습니다.

Haversine 공식을 활용한 거리 기반 클러스터링

저희는 AI와 함께 직접 거리 계산 알고리즘을 구현하여 이 문제를 해결했습니다. 핵심은 ‘기준점으로부터 반경 X cm 이내의 점들을 하나의 그룹으로 묶는 것’입니다.

구현 과정에서 가장 중요하게 고려한 점은 정확도 와 연산 비용의 균형이었습니다.

1. 지구 곡면을 고려한 정밀 거리 계산 (Haversine Formula)

평면 좌표계가 아닌 구 형태의 지구 위에서 두 점 사이의 거리를 정확히 구하기 위해 하버 사인 공식을 적용했습니다. 삼각함수(sin, cos, atan2)를 활용하여 위·경도 좌표 간의 최단 거리를 미터(m) 단위로 환산하여 거리를 계산했습니다.

fun calculateDistanceBetweenPlaces(
    lat1: Double,
    lon1: Double,
    lat2: Double,
    lon2: Double,
): Double {
    val r = 6_371_000.0 // 지구 반지름 (m)

    val dLat = Math.toRadians(lat2 - lat1)
    val dLon = Math.toRadians(lon2 - lon1)
    val radLat1 = Math.toRadians(lat1)
    val radLat2 = Math.toRadians(lat2)

    val a = sin(dLat / 2).pow(2.0) +
            cos(radLat1) * cos(radLat2) *
            sin(dLon / 2).pow(2.0)
    val c = 2 * atan2(sqrt(a), sqrt(1 - a))
    val distanceMeters = r * c

    return distanceMeters
}

2. 연산 최적화 (Optimization)

하지만 모든 제휴점 쌍(Pair)에 대해 하버 사인 공식을 적용하는 것은 O(N^2)의 복잡도를 가지며, 특히 삼각함수 연산은 CPU 비용이 매우 많이 듭니다. 제휴점의 수가 많아질 경우 메인 스레드에 부하를 줄 수 있었습니다.

이를 방지하기 위해 저희는 ‘선행 필터링(Pre-filtering)’ 로직을 추가했습니다.

// 최적화 포인트: 값비싼 삼각함수 연산을 수행하기 전, 단순 좌표 차이로 1차 필터링
if (abs(p.lat - q.lat) > 0.00001 || abs(p.lon - q.lon) > 0.00001) {
    continue
}
// 1차 필터링을 통과한 근접 좌표에 대해서만 정밀 거리 계산(Haversine) 수행
val distance = calculateDistanceBetweenPlaces(p.lat, p.lon, q.lat, q.lon)

위 코드처럼 위·경도 차이가 0.00001도(약 1.1m 내외) 이상 차이가 날 때에는 아예 하버 사인 연산을 수행하지 않고 건너뛰도록 처리했습니다. 이를 통해 불필요한 연산을 획기적으로 줄일 수 있었습니다.

단순 줌 레벨 기반의 클러스터링 대신 실제 거리 측정 기반의 정밀한 알고리즘 을 도입한 것이 주효했습니다. 이 방법을 통해 ‘동일 건물 내 다수 제휴점’ 그룹화 라는 목표를 성공적으로 달성했고, 결과적으로 사용자에게 지도상에서 획기적으로 개선된 제휴점 선택 경험을 제공할 수 있었습니다.

4. Material의 한계를 넘어 바텀시트를 직접 구현하라

디자인 가이드를 받아본 순간, 예상치 못한 난관을 마주했습니다. 기획된 바텀시트는 뒷배경이 어두워지는(Dim) 일반적인 모달(Modal) 형태가 아니라, 지도와 상호작용이 가능한 Non-Modal 형태였습니다.

가장 큰 문제는 ‘상태(State)의 개수’였습니다. Material 3 라이브러리가 제공하는 표준 바텀시트는 Hidden, PartiallyExpanded, Expanded의 3가지 상태 만 지원합니다. 하지만 저희 서비스는 하단에 최소한의 정보만 보여주는 'Collapsed' 상태를 포함하여 총 4가지 상태가 필수적이었습니다.

표준 라이브러리의 구조상 기존에 이미 정의되어 있는 상태에 새로운 상태를 추가하는 것은 불가능하다고 판단했습니다. 결국 저희는 “라이브러리에 비즈니스를 맞추지 말고, 우리가 직접 만들자”라는 결론을 내렸습니다.

AnchoredDraggable과 Offset을 활용한 바텀시트 구현

저희는 Compose Foundation API인 AnchoredDraggable을 활용하여 바텀시트의 뼈대를 직접 구축했습니다.

핵심 원리는 Offset(위치) 제어 입니다. Expanded 상태(화면 꽉 채움)를 Offset 0으로 기준 잡고, 나머지 상태들은 양수(+) 방향으로 Offset을 밀어버리는 방식입니다. 이렇게 하면 기획에서 원하는 어떤 상태든 픽셀 단위로 정밀하게 위치를 지정할 수 있습니다.

  1. 4가지 상태 정의와 Anchor 설정

먼저 비즈니스 로직에 맞는 4가지 상태를 정의하고, 각 상태가 위치해야 할 Y축 Offset을 매핑했습니다.

@Immutable
enum class PlaceListBottomSheetValue {
    Expanded,          // 전체 노출
    PartiallyExpanded, // 스크린 높이 기준 30% 노출
    Collapsed,         // 스크린 높이 기준 15% 노출
    Hidden;            // 미노출
}
// 각 상태별로 정확한 픽셀 위치(Anchor)를 지정
val placeListBottomSheetState = rememberMapNonModalBottomSheetState(
    anchoredDraggableState = remember {
        AnchoredDraggableState(
            initialValue = PlaceListBottomSheetValue.Collapsed,
            anchors = DraggableAnchors {
                PlaceListBottomSheetValue.Expanded at 0f
                PlaceListBottomSheetValue.PartiallyExpanded at (screenHeight - (screenHeight * 0.3f))
                PlaceListBottomSheetValue.Collapsed at (screenHeight - (screenHeight * 0.15f))
                PlaceListBottomSheetValue.Hidden at screenHeight
            },
        )
    }
)

2. MapNonModalBottomSheet 구현

정의된 상태를 바탕으로 실제로 움직이는 UI를 구성했습니다. offset 수정자를 통해 위치를 잡고, anchoredDraggable을 통해 드래그 제스처를 연결했습니다.

@Composable
fun <T> MapNonModalBottomSheet(
    modifier: Modifier = Modifier,
    sheetState: MapNonModalBottomSheetState<T>,
    content: @Composable () -> Unit,
) {
    Box(
        modifier = modifier
            // 1. 계산된 Offset만큼 뷰를 이동시킴 (핵심 로직)
            .offset { IntOffset(0, sheetState.anchoredDraggableState.offset.roundToInt()) }
            // 2. 드래그 제스처 연결
            .anchoredDraggable(
                state = sheetState.anchoredDraggableState,
                orientation = Orientation.Vertical,
                flingBehavior = sheetState.flingBehavior,
            ),
    ) {
        content.invoke()
    }
}

NestedScroll 설정을 통해 LazyColumn 스크롤 충돌 해결

구현 과정에서 마주한 또 다른 기술적 난관은 ‘스크롤 충돌’이었습니다. 바텀시트 내부에는 LazyColumn이 존재하는데, 사용자가 화면을 밀었을 때 "리스트가 스크롤 되어야 하는지" 아니면 "바텀시트 자체가 움직여야 하는지" 뷰 시스템이 혼란스러워했기 때문입니다.

저희는 ‘Material3의 기본 BottomSheet는 LazyColumn을 넣어도 잘 동작한다’는 점에 착안하여 오픈 소스 코드를 분석(Deep Dive)했습니다. 그 결과 NestedScrollConnection을 통해 부모(Sheet)와 자식(List) 간의 스크롤 우선순위를 중재하는 로직을 발견했고, 이를 저희 코드에도 이식(Porting)했습니다.

@OptIn(ExperimentalFoundationApi::class)
fun <T> consumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
    anchoredDraggableState: AnchoredDraggableState<T>,
    orientation: Orientation,
    onFling: (velocity: Float) -> Unit
): NestedScrollConnection =
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            val delta = available.toFloat()
            return if (delta < 0 && source == NestedScrollSource.UserInput) {
                anchoredDraggableState.dispatchRawDelta(delta).toOffset()
            } else {
                Offset.Zero
            }
        }

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return if (source == NestedScrollSource.UserInput) {
                anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
            } else {
                Offset.Zero
            }
        }

        override suspend fun onPreFling(available: Velocity): Velocity {
            val toFling = available.toFloat()
            val currentOffset = anchoredDraggableState.requireOffset()
            val minAnchor = anchoredDraggableState.anchors.minPosition()
            return if (toFling < 0 && currentOffset > minAnchor) {
                onFling(toFling)
                // since we go to the anchor with tween settling, consume all for the best UX
                available
            } else {
                Velocity.Zero
            }
        }

        override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
            onFling(available.toFloat())
            return available
        }

        private fun Float.toOffset(): Offset =
            Offset(
                x = if (orientation == Orientation.Horizontal) this else 0f,
                y = if (orientation == Orientation.Vertical) this else 0f
            )

        @JvmName("velocityToFloat")
        private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y

        @JvmName("offsetToFloat")
        private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
    }

특히 가장 까다로웠던 ‘가속도(Fling)의 자연스러운 연결’은 아래와 같이 onFling 콜백 내에서 Scroll Scope를 생성하여 해결했습니다.

val nestedScrollConnection = consumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
    anchoredDraggableState = sheetState.anchoredDraggableState,
    orientation = Orientation.Vertical,
    onFling = { velocity ->
        coroutineScope.launch {
            // AnchoredDraggable의 드래그 스코프 내에서 Fling을 수행
            sheetState.anchoredDraggableState.anchoredDrag {
                val scrollFlingScope = object : ScrollScope {
                    override fun scrollBy(pixels: Float): Float {
                        // 리스트의 스크롤 힘을 시트의 Offset 이동으로 변환
                        dragTo(sheetState.anchoredDraggableState.offset + pixels)
                        return pixels
                    }
                }
                // 자연스러운 관성 애니메이션 수행
                with(sheetState.flingBehavior) { 
                    scrollFlingScope.performFling(velocity) 
                }
            }
        }
    },
)

이번 프로젝트의 목표는 목록과 지도를 통합하여 사용자에게 끊김이 없는 탐색 경험(Seamless Experience)을 제공하는 것이었습니다.

이를 위해 라이브러리의 제약에 기획을 맞추기보다, 내부 동작 원리를 깊이 있게 분석 하여 저희가 의도했던 사용성을 끝까지 구현하고자 노력했습니다. 또한, 네이티브 앱의 강점인 퍼포먼스를 극대화하여 지도 위에서도 부드러운 상호작용이 가능하도록 최적화했습니다.

이러한 고민 끝에 완성된 기능이 여러분의 제휴점 탐색을 조금 더 빠르고 편리하게 만드는 데 도움이 되기를 바랍니다. 감사합니다.

✔️ 글쓴이를 더 알고 싶다면?: 링크드인