RSS

Leaflet 지도가 느려지는 3가지 원인: React key, SVG 렌더러, 가드 위치

지도 위에 H3 육각 격자를 얹은 화면을 만들었습니다. 셀을 누르면 인접한 여섯 셀이 함께 칠해지는, 영업 구역을 나눠 보는 화면입니다.

만들고 나서 확대·축소를 해보니 눈에 띄게 버벅였습니다. 원인을 세 번 찾았고, 세 번째가 가장 컸습니다.

처음 의심한 곳은 격자 계산이었습니다

화면을 움직일 때마다 뷰포트를 덮는 셀을 다시 구합니다. polygonToCells 로 셀 목록을 얻고, 셀마다 cellToBoundary 로 경계 좌표를 구합니다. 해상도를 올리면 셀이 수천 개가 되니 여기가 무거울 것 같았습니다.

재봤습니다. 기흥 일대가 보이는 축척이었습니다.

res 8   셀 116개     polygonToCells  9.2ms  · cellToBoundary 1.6ms
res 9   셀 812개     polygonToCells  7.3ms  · cellToBoundary 4.1ms
res 10  셀 5,684개   polygonToCells 16.4ms · cellToBoundary 4.4ms

가장 무거운 경우도 20ms 남짓이었습니다. 이동이 끝날 때 한 번만 도는 계산이라 체감 지연의 원인이 될 수 없다고 판단했습니다.

이 판단은 절반만 맞았습니다. 왜 틀렸는지는 뒤에 나옵니다.

원인 1: React 가 지도를 매번 버리고 있었습니다

지도 제공자(OpenStreetMap·네이버)를 화면에서 바꿀 수 있게 만들면서 컴포넌트에 key 를 줬습니다. 제공자가 바뀔 때 이전 지도의 DOM 이 남아 있으면 새 SDK 가 같은 컨테이너를 다시 초기화하려다 엉키기 때문입니다.

const mapKey = `${provider}-${view.center[0].toFixed(4)}`;

<OsmMap key={mapKey} center={view.center} zoom={view.zoom} />

view.center 를 왜 넣었을까요. 당시에는 "제공자를 바꿔도 보던 위치를 이어야 하니 좌표도 key 에 반영하자"고 생각했던 것 같습니다.

그런데 그 값은 지도를 움직일 때마다 갱신됩니다.

React 에서 key 가 바뀌면 같은 자리의 컴포넌트라도 다른 컴포넌트로 취급합니다. 이전 것을 언마운트하고 새로 마운트합니다. 확대 한 번에 이런 일이 벌어지고 있었습니다.

  1. Leaflet 인스턴스 파괴 (map.remove())
  2. 새 인스턴스 생성
  3. 타일 레이어 재생성, 화면의 타일을 전부 다시 요청

지도가 느린 게 아니라 지도를 새로 띄우고 있었습니다.

toFixed(4) 가 상황을 더 헷갈리게 만들었습니다. 위도 소수점 넷째 자리는 약 11m 입니다. 아주 조금 움직이면 값이 그대로라 멀쩡하고, 조금 크게 움직이면 값이 바뀌어 느려집니다. 그래서 원인이 "그려야 할 양이 많아서"처럼 보였습니다.

고치는 것은 한 줄이었습니다.

const mapKey = provider;

위치를 잇는 일은 centerzoom prop 이 이미 하고 있었습니다. key 에 넣을 이유가 처음부터 없었습니다.

key 는 정체성이지 상태가 아닙니다

key 는 "이게 아까 그것과 같은 것이냐"에 답하는 값입니다. 자주 바뀌는 상태를 섞으면 매번 "다른 것"이라고 답하게 되고, React 는 시키는 대로 파괴하고 다시 만듭니다.

무거운 자원을 들고 있는 컴포넌트에서 이 실수는 특히 비쌉니다. 지도, 에디터, 비디오 플레이어, 웹소켓을 쥔 컴포넌트가 그렇습니다. 가벼운 컴포넌트였다면 눈치채지도 못했을 겁니다.

반대로 일부러 재마운트하려고 쓰는 것도 같은 성질입니다. 폼을 초기화하려고 key={userId} 를 주는 패턴이 그것입니다. 도구가 잘못된 게 아니라, 제가 정체성 자리에 상태를 넣었을 뿐입니다.

원인 2: 셀 하나당 SVG 노드 하나

Leaflet 의 기본 벡터 렌더러는 SVG 입니다. L.polygon 을 하나 만들면 <path> 엘리먼트가 하나 생깁니다.

화면에 셀이 800개면 <path> 800개, 2,000개면 2,000개입니다. 이동할 때마다 전부 지우고 다시 만들었으니 DOM 을 생성하고 파괴하는 비용이 그대로 들었습니다.

Leaflet 은 캔버스 렌더러를 옵션으로 제공합니다.

const map = L.map(el, { center, zoom, preferCanvas: true });

캔버스는 도형 수와 무관하게 한 장에 칠합니다. 도형이 늘어도 DOM 노드는 늘지 않습니다.

같이 정리한 것이 하나 더 있습니다. 셀마다 클릭 핸들러를 달고 있었습니다.

poly.on("click", () => onSelect(cell.id));

지도 클릭에서 좌표를 받아 latLngToCell 로 계산하면 같은 결과가 나옵니다. 중복이었고, 도형 수천 개에 히트 테스트를 등록하는 비용이 있었습니다. 핸들러를 떼고 interactive: false 를 줬습니다.

여기까지 고치고 "이제 됐다"고 생각했습니다.

스크린샷 한 장에서 세 번째 원인이 나왔습니다

여전히 느리다는 얘기와 함께 화면 사진을 받았습니다. 전국이 보이도록 축소한 상태였고, 제가 만든 안내 문구가 이렇게 떠 있었습니다.

화면 셀   978,717개 · 너무 많아 그리지 않았습니다

저는 이 숫자를 "안 그렸으니 괜찮다"로 읽고 있었습니다. 그런데 저 숫자를 화면에 띄우려면 셀을 이미 다 세어 봤어야 합니다.

코드는 이랬습니다.

const ids = polygonToCells(bbox, resolution);   // 여기서 다 만든다
if (ids.length > MAX_CELLS) return;             // 그러고 나서 "너무 많네"

가드가 비싼 호출 뒤에 있었습니다. 그리지 않기로 한 판단은 맞았는데, 그 판단을 내리려고 이미 값을 다 치른 뒤였습니다.

전국 축척에서 재봤습니다.

전국 축척 res 9   셀 4,754,353개 · polygonToCells 11,242ms

11초였습니다.

처음 벤치마크가 왜 무죄 판결을 내렸나

앞에서 20ms 를 재고 "격자 계산은 범인이 아니다"라고 했습니다. 그 측정 자체는 정확했습니다. 문제는 입력이었습니다. 저는 기흥 일대가 보이는 축척으로만 재봤습니다. 제가 개발하면서 보던 화면이 거기였으니까요.

사용자는 전국까지 축소했습니다. 같은 함수에 240배 넓은 범위를 넣으면 다른 물건이 됩니다. 벤치마크는 자기가 받은 입력에 대해서만 정직합니다.

세는 대신 어림잡습니다

뷰포트 넓이를 그 해상도의 평균 셀 면적으로 나누면 개수를 즉시 알 수 있습니다. h3-js 의 getHexagonAreaAvg 가 해상도별 평균 면적을 줍니다.

const EARTH_RADIUS_KM = 6371;
const toRad = (deg) => (deg * Math.PI) / 180;

// 위도가 높을수록 경도 1도가 짧아지는 것을 sin 차이로 반영합니다.
function bboxAreaKm2(b) {
  return (
    Math.abs(toRad(b.east - b.west)) *
    EARTH_RADIUS_KM * EARTH_RADIUS_KM *
    Math.abs(Math.sin(toRad(b.north)) - Math.sin(toRad(b.south)))
  );
}

const estimate = bboxAreaKm2(bounds) / getHexagonAreaAvg(resolution, "km2");
if (estimate > MAX_CELLS) return;   // polygonToCells 를 아예 부르지 않습니다

전국 축척에서 어림값은 3,464,105개가 나왔습니다. 실제 4,754,353개의 0.73배입니다. 걸린 시간은 0.02ms 였습니다.

정확할 필요가 없습니다. "상한을 한참 넘는가"만 가리면 되고, 경계 근처의 오차는 뒤에 남겨둔 정확한 검사가 잡습니다. 어림값이 실제보다 작게 나오는 쪽이라는 것도 안전한 방향입니다. 여기서 걸리면 실제로는 더 많다는 뜻이니까요.

전국 축척 res 9   11,242ms  →  0.02ms

화면 문구도 바꿨습니다. 어림값일 때는 "약 340만 개"처럼 앞에 "약"을 붙입니다. 세어 본 값이 아닌데 정확한 수처럼 보이면, 나중에 누군가 그 숫자를 근거로 판단하게 됩니다.

증상은 하나, 원인은 셋

성격 원인 고침
설계 실수 key 에 좌표를 섞어 매번 재마운트 key 를 provider 로
기본값 함정 SVG 렌더러 · 셀당 클릭 핸들러 preferCanvas, interactive: false
가드 위치 다 세어 본 뒤에 "너무 많다" 판단 면적으로 먼저 어림잡기

셋 다 "느리다"는 같은 증상으로 나타났고, 하나만 고쳤다면 "좀 나아졌는데 여전히 느리네"로 끝났을 겁니다. 실제로 두 개를 고치고 그렇게 끝낼 뻔했습니다.

배운 것을 하나만 꼽으면 세 번째입니다. 가드는 비싼 호출 앞에 둬야 합니다. 뒤에 두면 방어가 아니라 장식입니다. 결과를 안 쓰기로 결정하는 것과 결과를 안 만드는 것은 다릅니다.

그리고 벤치마크는 자기가 받은 입력만큼만 정직합니다. 20ms 라는 숫자는 틀리지 않았습니다. 제가 그 숫자에 없는 범위까지 무죄를 준 것뿐입니다.