최적화가 오히려 느려질 때 — 뷰포트 컬링 A/B 측정
SVG로 수천 개의 도형을 그리는 뷰어가 있었다. 팬·줌 할 때마다 화면 밖 객체까지 전부 다시 훑는 구조라, 뷰포트 컬링을 넣기로 했다. 보이는 영역과 겹치는 것만 렌더 목록에 남기는 것이다.
const visible = items.filter((it) => hitsBox(viewRect, it.bbox))
당연히 빨라질 거라 생각했다. 절반은 맞았다.
확대 상태 — 기대한 대로
객체 3,500개(도형 2,000 · 선 500 · 점 500 · 문자 500)를 8000×6000 캔버스에 뿌리고, 줌 단계별로 실제 DOM 요소 수를 셌다.
| 줌 | DOM 요소 | 감소 |
|---|---|---|
| 전체 맞춤 | 11,002 | — |
| 확대 5단계 | 7,076 | 36% |
| 확대 10단계 | 2,386 | 78% |
| 확대 20단계 | 260 | 98% |
확대할수록 효과가 커진다. 실제 편집 작업은 대부분 확대한 상태에서 하니, 필요한 구간과 효과가 나는 구간이 일치한다.
그런데 드래그가 느껴졌다
“매 프레임 필터를 돌리고 DOM을 넣고 빼니 오히려 무거운 것 아닌가?” 하는 의심이 들었다. 구조만 보면 그럴듯했다.
추정으로 판단할 문제가 아니라 같은 조건에서 전후를 재기로 했다.
git stash push -u # 컬링 코드를 잠시 빼고
npm run build # 이전 버전 배포
# → 측정
git stash pop # 되돌리고
npm run build # 컬링 버전 배포
# → 같은 시나리오로 측정
측정은 브라우저 자동화로 60회 드래그를 재현하고, CDP의 Performance.getMetrics 로 그 구간의 스크립트·총 작업 시간을 뽑았다.
결과 — 추정이 틀렸다
드래그 60회 동안의 총 작업 시간이다.
| 조건 | 컬링 전 | 컬링 후 |
|---|---|---|
| 객체 2000 · 확대 10단계 | 1,588ms | 707ms |
| 객체 200 · 확대 10단계 | 427ms | 234ms |
| 객체 20 · 확대 10단계 | 259ms | 215ms |
| 객체 2000 · 전체 맞춤 | 1,227ms | 1,360ms ⚠️ |
드래그는 오히려 빨라졌다. 객체가 20개뿐일 때도 그랬다. DOM을 넣고 빼는 비용보다, 유지할 노드가 줄어드는 이득이 컸다.
대신 전혀 예상 못 한 곳에서 느려졌다. 전체 맞춤 상태, 즉 모든 객체가 화면에 보이는 상황이다.
왜 축소 상태에서 손해인가
당연한 결과였다. 걸러낼 것이 없는데 판정 비용만 든다.
매 프레임: 3,500개를 순회하며 "보이나?" 검사
결과: 전부 보임 → 하나도 안 걸러짐
선 객체는 경계 상자를 구하려고 좌표 배열을 새로 만들기까지 했다. 결과가 늘 “전부 통과”인데도.
우회 조건이 필요했다
전체가 보이면 컬링을 건너뛰게 했다. 그런데 처음 만든 조건이 작동하지 않았다.
// 판정 사각형이 도면 전체를 '정확히' 덮는가
const skip = r.x1 <= 0 && r.y1 <= 0 && r.x2 >= W && r.y2 >= H
맞춤 상태에서 조금만 팬하면 한쪽이 어긋나 거짓이 된다. 가장 흔한 초기 상태에서 최적화가 안 걸리고 비용만 내고 있었다.
겹치는 면적 비율로 바꿨다.
// 도면의 대부분이 보이면 건너뛴다
const w = Math.min(r.x2, W) - Math.max(r.x1, 0)
const h = Math.min(r.y2, H) - Math.max(r.y1, 0)
const skip = w > 0 && h > 0 && (w * h) / (W * H) >= 0.9
여유값도 과했다
경계에 걸친 객체가 잘리지 않도록 판정 영역을 20% 넓혀뒀는데, 이게 생각보다 비쌌다.
가로 1.4배 × 세로 1.4배 = 면적 약 2배
→ 필요한 것의 두 배를 그리고 있었다
여유가 실제로 담당하는 것은 경계 상자보다 크게 그려지는 부분(라벨·표식·선 두께)인데, 그것들은 줌 계수에 비례해 화면 폭의 2~3% 수준이었다. 6%로 줄이니 확대 상태의 요소 수가 2,392개에서 1,580개로 34% 더 줄었다.
여기서 한 가지 오해도 정리됐다. 처음엔 여유가 “팬 할 때 객체가 늦게 나타나는 것을 막아준다”고 생각했는데, 틀렸다. 컬링은 매 프레임 새 뷰에서 다시 계산되고 화면 이동과 같은 커밋에 반영된다. 한 번에 화면 몇 배를 건너뛰어도 빈 곳은 생기지 않는다. 실제로 한 프레임에 화면 폭의 62%를 이동시켜 확인했다.
정리
- 최적화에는 효과가 나는 구간과 손해인 구간이 있다. 둘 다 재야 한다
- 컬링은 확대할수록 이득, 축소 상태에서는 순손해였다. 우회 조건이 필요했다
- “이 변경이 X를 느리게 만들 것”이라는 추정은 A/B 측정에서 뒤집혔다. 구조를 보고 세운 가설과 실제가 달랐다
- 안전장치로 넣은 우회 조건이 정작 가장 흔한 상태에서 작동하지 않는 경우가 있다. 조건 자체도 확인 대상이다
측정 없이 넣었다면 “확대하면 빨라진다”만 보고 만족했을 것이고, 축소 상태의 손해는 몰랐을 것이다.
댓글