배포와 인프라8 분 읽기

지도를 축소했더니 남의 영토가 사라졌다

지도를 축소하면 다른 유저의 영토가 안 보이고 버벅였습니다. 로딩이 느린 것처럼 보였는데, 기다려도 안 채워졌습니다. 조회 범위가 고정 bbox였습니다 — 화면은 줌마다 4배가 되는데 요청 범위는 그대로라, 어느 줌 아래로는 화면 가장자리가 서버에 요청조차 되지 않았습니다.

#android#supabase#postgis#gotchas
개념 도식: 화면은 줌아웃마다 4배로 커지는데 조회 박스는 ±0.05°로 고정. z11에선 박스가 화면 안쪽에 완전히 갇힌다. 박스 밖은 '아직 안 옴'이 아니라 '요청조차 안 됨'.
로딩 지연이 아니라 영구 누락이었다. 기다려도 안 온다 — 요청을 안 했으니까.

지도 기반 앱에서 "축소하면 다른 유저의 영토가 안 보인다, 그리고 버벅인다"는 리포트가 들어왔습니다. 로딩이 느린 것처럼 보였습니다. 기다리면 채워질 것 같았는데 안 채워졌습니다.

원인 셋, 전부 클라이언트 버그

서버는 처음부터 정상이었습니다.

① 줌과 무관한 고정 bbox. 카메라가 움직일 때 조회 범위를 delta = 0.05로 하드코딩해서 중심 ±0.05°만 요청하고 있었습니다. 서울 기준 약 8.8 × 11 km입니다. 화면 면적은 줌 한 단계 축소마다 4배가 되는데 조회 범위는 고정이니, 대략 z13 이하에서 화면 가장자리는 서버에 요청조차 되지 않았습니다. 세로 화면이라 세로 방향은 z13에서 이미 잘렸습니다.

로딩 지연이 아니라 영구 누락입니다. 기다려도 안 옵니다. 요청을 안 했으니까요.

② 디바운스 없음. 같은 이펙트의 key가 카메라 position이라 제스처 프레임마다 재실행됐습니다. 취소되지 않는 RPC가 폭주했습니다. iOS 쪽은 처음부터 400ms 디바운스 + 이전 Task 취소로 짜여 있었습니다. 같은 기능인데 한쪽만 빠져 있었습니다.

③ 캐시 전체 렌더. 로컬 DB의 observeAll()은 로그아웃 전까지 누적되는 전체 테이블인데, 화면이 그걸 다 그리고 있었습니다. 화면 밖 영토까지 폴리곤과 마커 비트맵을 만들었습니다. 앱을 오래 쓸수록 무거워지는 구조입니다.

진단이 헷갈렸던 이유가 여기서 나옵니다. 캐시가 누적되므로 과거에 우연히 고정 박스 안에 들어왔던 영토는 계속 보입니다. 나머지는 영영 안 보입니다. 이 뒤섞임이 "일부는 보이고 일부는 아직 안 왔다" — 즉 로딩이 덜 된 것처럼 읽혔습니다. 부분적으로 맞는 화면이 완전히 틀린 화면보다 진단하기 어렵습니다.

제일 빨랐던 증거 — anon key로 박스별 건수 비교

이 진단에서 제일 빨랐던 건 anon key로 RPC를 직접 호출해 박스별 건수를 비교한 것입니다.

구 박스(시청 ±0.05°)  →   0건
실제 z11 뷰포트       →  60건
전국                  → 180건

0건 대 60건이 나오는 순간 "요청 범위가 문제"라는 게 확정됩니다. 클라이언트를 붙잡고 로그를 파는 것보다 훨씬 빠릅니다. 전국이 180건이므로 LIMIT 500은 아직 안 걸린다는 것도 같이 확인됩니다.

코드

MapViewport.kt (신규)
  cameraPositionState.projection?.visibleRegion?.latLngBounds 우선
  projection null(레이아웃 전)이면 Web-Mercator 줌 수식 폴백
  양쪽 다 20% 패딩

여기 함정이 둘 있습니다.

  • 위도는 Mercator y 공간에서 계산해야 합니다. 안 그러면 고위도에서 중심이 밀립니다.
  • 자정선 교차(neLng < swLng)는 -180..180으로 확대합니다. 그냥 넘기면 ST_MakeEnvelope가 빈 envelope를 만듭니다.
MapViewModel.loadVisibleArea(MapBBox)
  단일 fetchJob 취소 / loadedBBox·inFlightBBox 로 중복 스킵

⚠️ refreshBBox의 catch에서 CancellationException은 반드시 rethrow할 것. 안 하면 취소가 errorMessage로 새어 스피너가 남습니다. 렌더는 뷰포트 교차분으로 제한하고, 폴리곤 JSON 파싱은 row id + json hash로 메모이즈합니다.

수정 반영: 0.11.2 / vc42. 등장 앱: Plotta (Android) · iOS. 이 앱은 R8이 route 필드를 지운 그 앱이기도 합니다 — iOS/Android가 같은 기능을 다르게 구현하다 생긴 격차가 두 버그의 공통 배경입니다.

Plotta 지도 화면

정직하게

  • 수정한 건 클라이언트 버그 셋이고, 넓은 줌의 근본 문제는 그대로 남아 있습니다. 한 화면 조회에 LIMIT 500이 걸려 있고, 지금은 프로덕션 전체가 180건이라 안 닿을 뿐입니다. 데이터가 늘면 다시 터집니다.
  • 그 상한을 완화하는 마이그레이션(ORDER BY area_m2 DESC, id)을 작성해두고 적용하지 않았습니다. 하는 일이 "500개가 잘릴 때 어느 500개가 잘릴지 결정론적으로 만드는 것" 하나뿐이라서입니다. 500 미만에서는 동작이 완전히 동일합니다. 체감 변화 0에 프로덕션 DB 터치 1회를 지금 쓸 이유가 없다고 판단했습니다.
  • 진짜 근본 해결은 아직 안 했습니다. 우선순위는 정해뒀습니다 — 줌 기반 최소 면적 필터(z9에서 200m² 폴리곤은 1px도 안 되니 애초에 안 보내면 됨), 그 다음 geohash 집계, 타일 캐싱은 수만 건 규모에서. 첫 번째가 변경량 대비 효과가 가장 큽니다.
  • iOS와 Android가 같은 기능을 다르게 구현하고 있었다는 게 이 버그의 배경입니다. 디바운스가 한쪽에만 있었던 것처럼, 다른 격차가 더 있는지는 별도로 훑어야 합니다.

"버벅이고 안 보인다"를 로딩 문제로 읽으면 영원히 클라이언트만 팝니다. 지금 당신 앱에서 "느린 것 같은" 화면 하나를 떠올려 보세요. 그거, 정말 느린 겁니까, 아니면 요청조차 안 한 겁니까? anon key로 건수 한 번만 찍어보면 갈립니다.

관련 글