이 글은 누구를 위한 것인가
- 목록과 지도를 한 화면에 놓는 탐색 UI를 만들고 있는 팀
- 항목이 늘어나면서 스크롤이 끊기고 지도가 멈추기 시작한 경우
- "일단 다 그리고 나중에 최적화하자"로 시작했다가 되돌아온 개발자
들어가며
장소 검색, 부동산, 배달, 여행 — 목록과 지도를 나란히 놓는 화면은 흔하다. 항목이 200개일 때는 아무 문제가 없다. 2,000개가 되면 스크롤이 미세하게 끊기고, 20,000개가 되면 지도를 움직이는 순간 앱이 멈춘다.
원인은 대부분 둘 중 하나다. 화면에 안 보이는 것까지 만들고 있거나, 보이는 영역 밖의 데이터까지 들고 있거나.
이 글은 그 둘을 각각 리스트와 지도 쪽에서 어떻게 끊는지, 그리고 두 뷰의 상태를 어떻게 어긋나지 않게 유지하는지를 다룬다.
1. 먼저 무엇이 느린지 나눈다
"목록이 느리다"는 진단이 아니다. 세 가지가 완전히 다른 문제다.
[증상별 원인]
스크롤 중 프레임 드랍
→ 셀 생성/레이아웃 비용. 가상화 문제.
화면 진입이 느림
→ 초기 데이터 로딩량. 페이징 문제.
시간이 지날수록 느려지고 앱이 종료됨
→ 메모리 누수. 재사용 안 되는 뷰나 해제 안 되는 이미지.
세 번째를 첫 번째로 오진하면 가상화를 아무리 잘해도 앱은 계속 죽는다. 먼저 프로파일러로 프레임 타임과 메모리 그래프를 각각 확인하고, 어느 쪽인지 확정한 뒤 손대야 한다. Android는 렌더링 성능 지표 문서에 느린 프레임의 판정 기준이 정의돼 있다.
2. 리스트 가상화 — 보이는 것만 만든다
가상화의 원리는 단순하다. 전체 높이만 확보해두고, 뷰포트에 들어오는 범위의 셀만 실제로 만든다.
네이티브 리스트 컴포넌트는 대부분 이미 이걸 한다 — iOS의 LazyVStack이 대표적이다. 문제는 셀 하나의 비용이 크면 가상화가 있어도 끊긴다는 점이다.
// SwiftUI — LazyVStack은 지연 생성을 하지만, 셀이 무거우면 소용없다
ScrollView {
LazyVStack(spacing: 0) {
ForEach(places) { place in
PlaceRow(place: place)
.frame(height: 88) // 고정 높이: 레이아웃 계산 제거
.id(place.id)
}
}
}
고정 높이를 주는 것이 가장 효과가 큰 단일 변경이다. 높이가 가변이면 스크롤바 위치를 계산하기 위해 보이지 않는 셀의 크기까지 추정해야 하고, 추정이 틀리면 스크롤이 튄다.
[셀 비용을 줄이는 순서]
1. 고정 높이 (또는 정확한 높이 추정치 제공)
2. 셀 안의 뷰 계층 깊이 줄이기 — 중첩 스택 3단계 이하
3. 이미지: 셀 크기에 맞춰 디코딩 (원본 4000px를 88px 셀에 넣지 않기)
4. 날짜 포맷·거리 계산 등은 셀 밖에서 미리 계산해 모델에 담기
5. 그림자·블러 등 오프스크린 렌더링 유발 효과 제거
4번이 자주 누락된다. 셀 렌더링 중에 DateFormatter를 만들거나 거리 계산을 하면, 그 비용이 스크롤할 때마다 반복된다. 뷰 모델을 만들 때 한 번 계산해서 문자열로 들고 있으면 된다.
Android Compose 쪽은 리스트 문서에 key 지정과 contentType 지정이 정리돼 있는데, 둘 다 재구성 비용에 직접 영향을 준다.
LazyColumn {
items(
items = places,
key = { it.id }, // 안정적인 키: 불필요한 재구성 방지
contentType = { it.layoutType }, // 타입별 뷰 재사용
) { place ->
PlaceRow(place, modifier = Modifier.height(88.dp))
}
}
3. 지도 — 마커를 다 찍지 않는다
지도 쪽 문제는 리스트보다 심각하다. 마커 2만 개를 그리면 지도 자체가 멈춘다.
마커 클러스터링은 가까운 마커를 하나의 묶음 마커로 합쳐 그리는 기법이다. 줌 레벨이 올라가면 묶음이 풀린다.
[줌 레벨별 마커 수]
줌 9 (전국) 20,000개 → 클러스터 18개
줌 12 (시/군) 20,000개 → 클러스터 140개
줌 15 (동) 1,200개 → 클러스터 90개 + 개별 40개
줌 18 (건물) 12개 → 개별 12개
화면에 실제로 그려지는 마커를 200개 이하로 유지하는 것이 목표
클러스터링을 클라이언트에서 할지 서버에서 할지가 첫 갈림길이다.
| 클라이언트 클러스터링 | 서버 클러스터링 | |
|---|---|---|
| 전체 데이터 전송 | 필요 (2만 건) | 불필요 |
| 줌/이동 반응 | 즉각 | 요청 왕복 필요 |
| 메모리 | 전체 보유 | 화면 분량만 |
| 적합 규모 | 수천 건 이하 | 수만 건 이상 |
수만 건이면 서버 클러스터링이 사실상 유일한 선택이다. 2만 건의 좌표를 모바일로 내려보내는 것 자체가 이미 문제다.
-- 서버: 뷰포트 + 줌 레벨을 받아 격자 단위로 집계
SELECT
floor(lng / :grid) AS gx,
floor(lat / :grid) AS gy,
count(*) AS cnt,
avg(lat) AS lat,
avg(lng) AS lng,
-- 개별 표시가 가능한 소수 군집이면 id도 함께
CASE WHEN count(*) <= 3 THEN array_agg(id) END AS ids
FROM places
WHERE published = true
AND lat BETWEEN :south AND :north
AND lng BETWEEN :west AND :east
GROUP BY gx, gy
HAVING count(*) > 0;
:grid는 줌 레벨에서 유도한다. 줌이 1 올라갈 때마다 격자를 절반으로 줄이는 식이다. 클라이언트 구현 형태는 Google Maps의 마커 클러스터링 문서에 개념 설명이 정리돼 있고, 격자 방식과 거리 기반 방식의 차이도 여기서 확인할 수 있다.
격자 방식의 함정 하나: 격자 경계에 걸친 두 마커가 실제로는 10m 거리인데 서로 다른 클러스터에 들어가는 경우가 생긴다. 사용자 눈에는 "왜 이 둘만 따로 있지?"로 보인다. 경계 근처에서는 인접 격자를 병합하는 후처리를 넣거나, 거리 기반 클러스터링으로 바꾼다.
4. 뷰포트 기반 로딩 — 이동할 때마다 부르지 않는다
지도를 드래그하면 이동 중에도 onCameraMove가 초당 수십 번 호출된다. 여기서 바로 요청을 보내면 요청 폭풍이 된다.
private val viewportFlow = MutableStateFlow<Viewport?>(null)
init {
viewModelScope.launch {
viewportFlow
.filterNotNull()
.debounce(300) // 이동이 멈춘 뒤 300ms
.distinctUntilChanged { old, new ->
// 이동량이 화면의 30% 미만이면 재요청하지 않음
old.overlapRatio(new) > 0.7
}
.mapLatest { vp -> // 이전 요청은 취소
repository.loadClusters(vp, currentZoom)
}
.collect { clusters -> _markers.value = clusters }
}
}
세 장치가 각각 다른 일을 한다.
debounce— 드래그 중에는 요청하지 않는다distinctUntilChanged— 조금 움직인 것으로는 재요청하지 않는다mapLatest— 새 요청이 오면 이전 요청을 취소한다
특히 세 번째가 없으면, 빠르게 여러 번 이동했을 때 응답이 도착 순서와 다르게 와서 지도에 옛날 데이터가 최종적으로 남는 버그가 생긴다. 재현이 어려워서 놓치기 쉬운 종류다.
여기에 더해 요청 범위를 화면보다 약간 넓게 잡으면 체감이 좋아진다. 화면 딱 맞게 요청하면 조금만 움직여도 빈 영역이 보인다.
요청 범위 = 현재 뷰포트 × 1.4
→ 약간의 이동은 이미 받아둔 데이터로 커버
→ 대신 데이터량이 약 2배. 클러스터 응답이라 감당 가능한 수준
5. 목록과 지도의 상태 동기화
두 뷰를 한 화면에 놓으면 반드시 나오는 요구사항이 있다.
지도에서 마커 탭 → 목록이 해당 항목으로 스크롤
목록에서 항목 탭 → 지도가 해당 위치로 이동
지도 이동 → 목록이 현재 화면 범위로 필터링
세 번째가 까다롭다. 지도를 움직일 때마다 목록을 갈아끼우면 사용자가 읽던 위치를 잃는다. 실무에서는 자동 갱신 대신 명시적 갱신을 쓰는 경우가 많다.
[패턴 A — 자동 갱신]
지도 이동 즉시 목록 교체
장점: 항상 일치
단점: 스크롤 위치 상실, 지도 조작 중 목록이 계속 흔들림
[패턴 B — 명시적 갱신 (권장)]
지도 이동 후 "이 지역에서 검색" 버튼 노출
사용자가 누를 때만 목록 갱신
장점: 사용자가 제어권을 가짐
단점: 한 번 더 탭
패턴 B는 지도 앱들이 널리 쓰는 방식인데, 이유는 성능이 아니라 사용자가 지도를 움직이는 목적이 항상 "다시 검색"은 아니기 때문이다. 주변을 둘러보는 중에 목록이 계속 바뀌면 방해가 된다.
지역·업종 축으로 목록과 지도를 함께 제공하는 플림의 장소 탐색 같은 화면을 보면, 필터 조건이 여러 축으로 걸리는 순간 "지금 보고 있는 결과가 무엇인지"를 사용자가 놓치기 쉬워진다는 걸 알 수 있다. 현재 적용된 조건을 상단에 칩으로 남겨두는 것이 사실상 필수다.
6. 메모리 — 시간이 지나면 느려지는 문제
가상화를 제대로 했는데도 오래 쓰면 느려진다면 대개 이미지다.
[이미지 메모리 계산]
88 × 88 셀에 원본 2000 × 1500 이미지를 넣으면
2000 × 1500 × 4바이트 = 약 12MB (디코딩된 비트맵)
화면에 8개 보이고 프리페치 20개면 → 약 336MB
셀 크기로 다운샘플링하면
176 × 176 × 4바이트 = 약 124KB
→ 28개여도 약 3.5MB
디코딩 크기를 지정하는 것이 핵심이다. 파일 크기가 작아도 디코딩된 비트맵은 픽셀 수에 비례한다. 이미지 라이브러리 대부분이 대상 크기 지정 옵션을 제공하니, 셀 크기(× 디바이스 배율)를 넘겨주면 된다.
[체크리스트]
□ 이미지 디코딩 크기를 셀 크기로 제한
□ 화면을 벗어난 셀의 진행 중 이미지 요청 취소
□ 메모리 캐시 상한 설정 (기기 메모리의 일정 비율)
□ 메모리 경고 시 캐시 비우기
□ 지도 마커 아이콘은 재사용 (마커마다 비트맵 생성 금지)
마지막 항목이 지도 쪽에서 자주 빠진다. 클러스터 마커의 숫자가 다르다고 매번 새 비트맵을 그리면, 지도를 조금만 움직여도 비트맵이 수백 개씩 생성된다. 숫자 구간별로 미리 만들어두고 재사용한다.
7. 정리
1. 프레임 드랍 / 초기 로딩 / 메모리 누수를 먼저 구분한다
2. 리스트: 고정 높이 → 뷰 계층 축소 → 셀 밖 사전 계산
3. 지도: 수만 건이면 서버 클러스터링. 화면 마커 200개 이하 유지
4. 뷰포트 로딩은 debounce + 최소 이동량 + 이전 요청 취소
5. 목록↔지도 동기화는 자동보다 명시적 갱신
6. 이미지는 디코딩 크기를 셀 크기로 제한
이 중 하나만 고른다면 2번의 고정 높이와 6번의 디코딩 크기 제한이다. 두 가지만 해도 대부분의 목록 성능 문제는 체감상 사라진다.