"대용량 데이터를 다뤄보신 적 있으신가요?"
면접 자리에서 이 질문을 몇 번이나 받았습니다. 그때마다 선뜻 "네"라고 답하기가 어려웠습니다. 제가 다뤄본 데이터는 수십만 건 단위의 배치 처리도, 실시간 스트리밍도 아니었기 때문입니다.
그런데 같은 질문을 반복해서 받다 보니, 면접관이 정말 궁금해하는 건 다뤄본 데이터의 '규모' 자체가 아니라는 걸 알게 됐습니다. 오히려 "그 데이터를 화면에 그리다가 어떤 문제를 만났는지, 그걸 어떻게 분석했고, 그래서 어떻게 풀었는지"를 묻고 있었습니다. 프론트엔드 입장에서 대용량 데이터가 진짜 문제가 되는 지점은 데이터의 크기 그 자체가 아니라, 그 데이터를 화면에 그리는 순간의 렌더링 부하였습니다.
이 글은 성격이 다른 두 화면에서 같은 종류의 문제를 만나 풀어간 이야기입니다. 하나는 세로로 무한히 길어지는 리스트였고, 다른 하나는 좌표 위에 겹쳐 쌓이는 지도 마커였습니다. 둘 다 "데이터가 늘어나는 만큼 DOM 노드가 그대로 늘어난다"는 같은 뿌리를 갖고 있었습니다. 비슷한 고민을 하고 계신 분들께 도움이 되길 바랍니다.
프론트엔드에서 대용량 데이터는 곧 렌더링 부하다
두 화면의 공통점은 이랬습니다. 화면에 그려야 할 항목이 무제한으로 늘어날 수 있는데, 그 항목 하나하나가 실제 DOM 노드였습니다. 리스트는 전체 목록을 .map()으로 순회해 보이지 않는 행까지 전부 노드로 만들었고, 지도는 데이터 수만큼 마커 노드를 얹었습니다. 데이터가 커지면 초기 렌더링 부하가 그대로 비례해서 커지는 구조였습니다.
여기에 각 화면마다 손쉬운 우회를 막는 제약이 하나씩 있었습니다.
- 리스트: 데이터를 내려주는 API가 페이지네이션 없이 전체 목록을 한 번에 응답했습니다. 백엔드를 바꾸지 않고 프론트 단독으로 풀어야 했습니다.
- 지도: 마커는 "화면 안에 실제로 존재하는 대상"을 표시하는 것이라, 리스트처럼 안 보이는 항목을 임의로 잘라낼 수 없었습니다.
그래서 세운 원칙은 하나였습니다. 화면에 실제로 그려지는 DOM 노드 수를, 데이터의 크기에서 떼어내자. 데이터가 아무리 커져도 실제로 그려지는 노드는 "지금 화면에 필요한 만큼"으로 유지하는 것이 목표였습니다.
이 원칙을 구현하는 방법은 크게 세 갈래였습니다.
| 방식 | 핵심 아이디어 | 성격 |
|---|---|---|
| 가상 스크롤 (windowing) | 뷰포트 근처 항목만 마운트하고 스크롤에 따라 노드를 교체 | 노드 수가 항상 일정하지만, 좌표를 수동 계산해야 해 구조가 복잡 |
| 점진적 렌더링 (chunking) | 처음 N개만 그리고, 끝에 도달하면 개수를 늘림 | 기존 렌더링 구조를 그대로 두고 얹을 수 있어 단순 |
| 클러스터링 (grouping) | 가까운 항목을 하나로 묶어 그룹으로 그림 | 좌표 기반 데이터에서 "밀집도"에 비례해 노드 수를 줄임 |
세로 리스트에는 세 번째가 맞지 않고, 좌표가 없는 리스트에는 세 번째가 성립하지 않습니다. 결국 리스트에는 첫째·둘째를, 지도에는 셋째를 적용하게 됐는데, 그 선택 과정에서 겪은 게 이 글의 본론입니다.
첫 번째 사례: 원인을 설명할 수 없는 버그 앞에서 내린 선택
무한히 길어지는 리스트에는 노드 수가 항상 일정하다는 점에서 가상 스크롤이 이론적으로 가장 우아한 해법이었습니다. 그래서 먼저 가상 스크롤을 적용했습니다. 그런데 행 사이 간격이 비정상적으로 벌어지는 버그가 나타났습니다.
원인을 특정하려고 할 수 있는 검증은 다 해봤습니다.
- 라이브러리 동작을 소스 수준에서 재현하며 추적
getComputedStyle로 실제 계산된 좌표값을 콘솔에서 확인 — 행 간격이 정확한 값으로 계산되고 있음을 확인- 스크린샷과 실제 화면을 나란히 대조
- 스크롤·리사이즈로 강제 리페인트를 유도해 재현 시도
그런데 DOM/CSS 값은 항상 정상인데 화면만 계속 어긋났습니다. 특정 환경에서만 나타나는 문제인지조차 끝내 가려내지 못한 채, 재현은 되지만 설명은 안 되는 상태로 남았습니다.
여기서 방향을 틀었습니다. 원인을 못 찾은 채로 좌표를 수동 계산하는 복잡한 구조를 계속 붙들고 있는 것 자체가 리스크라고 판단하고, 가상 스크롤을 걷어낸 뒤 훨씬 단순한 점진적 렌더링으로 전환했습니다. 처음 일정 개수만 그리고, 리스트 끝에 보이지 않는 감지용 요소를 두어 사용자가 끝에 닿으면 개수를 늘리는 방식입니다. 기존 렌더링 구조를 그대로 두고 그 위에 "몇 개까지 보여줄지"만 얹으면 되니, 좌표 계산에서 비롯된 버그가 끼어들 여지 자체가 없었습니다.
💡 두 방식의 트레이드오프
- 가상 스크롤: 노드 수가 항상 일정하지만, 좌표 수동 계산이 복잡성과 디버깅 리스크를 함께 끌고 온다.
- 점진적 렌더링: 스크롤을 끝까지 내리면 노드가 계속 쌓이지만, 구조가 단순해 새 버그가 생길 여지가 적다. 초기 렌더링 부하를 줄이는 게 핵심 목적이라면 충분하다.
다만 렌더링 방식을 바꾸면서 지켜야 할 기존 기능이 하나 있었습니다. 특정 항목으로 바로 이동하는 버튼이었는데, 대상이 아직 렌더링되지 않은 범위 밖이면 이동할 노드 자체가 없어 그대로는 동작하지 않았습니다. 이건 렌더링 방식을 바꾸기 전에 미리 확인해둔 제약이라, 처음부터 설계에 반영할 수 있었습니다.
💡 렌더 안 된 항목으로 이동하는 2단계 로직 1단계. 대상이 아직 안 그려진 범위면, 먼저 그 지점까지 렌더링 개수를 확장하고 이동할 위치를 예약해둔다. 2단계. 개수가 늘어나 대상이 실제로 화면에 마운트되는 시점을 감지해, 그제서야 스크롤 이동을 실행한다.
두 번째 사례: "붙이는 순간 끝"이 아니었던 클러스터링
두 번째 화면은 실시간으로 갱신되는 좌표 위에 마커가 쌓이는 지도였습니다. 데이터가 몇십 개만 되어도 줌아웃하면 마커가 서로 겹쳐 클릭도 식별도 안 됐고, 위치가 소켓으로 계속 갱신되니 그 부하가 한 번으로 끝나지 않고 반복됐습니다.
여기서는 가까운 마커를 하나로 묶는 클러스터링을 적용했습니다. 지도에 실제로 그려지는 노드 수가 데이터 수가 아니라 "밀집도"에 비례하게 바뀌는 것이 핵심이었습니다. 일정 반경 안의 마커는 하나의 그룹으로 묶고, 충분히 확대하면 그룹을 풀어 개별 마커를 노출합니다.
문제는 이게 라이브러리를 붙이는 순간 끝나는 작업이 아니었다는 점입니다. 그룹을 얼마나 촘촘하게 묶을지, 그룹 배지를 얼마나 크게 그릴지 같은 값은 정답이 없어서, 실제 데이터 밀집도를 눈으로 확인하며 반복해서 조정해야 했습니다.
- 묶는 반경: 처음엔 넓게 잡았다가, 너무 뭉쳐 보여서 더 촘촘하게 좁혔습니다.
- 그룹 배지 크기·폰트: 지도를 덜 가리도록 단계적으로 줄였습니다.
- 숫자 오버플로: 밀집 지역에서 묶인 개수가 세 자리, 네 자리로 늘어나며 배지를 뚫고 나가길래, 일정 수를 넘으면
99+처럼 캡을 씌웠습니다.
정량적으로 "렌더링이 몇 % 빨라졌다"고 말할 수 있는 수치는 남아있지 않습니다. 다만 이렇게 여러 라운드에 걸쳐 크기와 반경을 계속 손봤다는 것 자체가, 실제로 개선이 체감됐기에 다듬을 가치가 있었다는 방증에 가깝습니다.
흥미로웠던 건, 클러스터링을 얹으면서 오히려 손댈 필요가 없었던 부분입니다. 리스트에서 항목을 선택하면 해당 마커가 강조되는 기존 기능이 있었는데, 이 로직이 처음부터 리액트 상태를 거치지 않고 마커 인스턴스의 겉모습만 직접 바꾸는 방식으로 짜여 있었습니다. 덕분에 클러스터링 입장에서는 "묶여있는 마커 중 하나의 겉모습이 바뀐 것"일 뿐이라, 선택할 때마다 그룹 전체가 다시 계산되는 일이 없었습니다.
💡 상태로 바꿀지, 인스턴스를 직접 바꿀지
- 리액트가 이 변경을 알아야 하위 컴포넌트도 함께 다시 그려져야 한다면 → 상태로 관리
- 서드파티 라이브러리가 이미 들고 있는 객체의 겉모습만 바꾸는 거라면 → 인스턴스를 직접 변경
만약 이 선택 로직이 상태 기반이었다면, 클러스터링을 붙이는 시점에 "선택할 때마다 마커 배열 전체가 리렌더링되어 그룹도 매번 다시 계산되는" 문제를 새로 풀어야 했을 겁니다. 실시간 갱신 때마다 이전 그룹을 정리하는 코드도, 원래 있던 마커 정리 로직 옆에 한 줄 얹는 것으로 끝났습니다. 부가 레이어를 붙일 때 기존 정리 패턴을 그대로 따라간 게, 결과적으로 조용히 누적되는 사고를 처음부터 막아준 셈입니다.
종합 결과
두 화면 모두, 데이터가 늘어나도 실제로 그려지는 DOM 노드 수가 데이터 크기와 분리됐습니다.
- 리스트: 초기 렌더링이 일정 개수로 제한되어, 항목이 아무리 많아져도 초기 부하가 더는 비례해 커지지 않습니다. 렌더링 방식을 바꾸고도 특정 항목으로 바로 이동하는 기존 기능은 그대로 동작합니다.
- 지도: 그려지는 노드 수가 데이터 수가 아니라 밀집도에 비례해, 마커 겹침으로 인한 클릭·식별 문제가 해소되고 그룹 배지로 밀집 지역을 한눈에 파악할 수 있게 됐습니다.
정직하게 말하면, 두 작업 모두 "몇 ms에서 몇 ms로 줄었다" 같은 정량 지표는 남기지 못했습니다. 초기 렌더링 부하를 구조적으로 없앤 것과, 겹침·복잡성 같은 정성적 문제를 해소한 것이 실제 결과였습니다.
배운 점
- 프론트엔드에서 '대용량 데이터'의 실체는 렌더링 부하다. 데이터가 크다는 것 자체보다, 그 데이터를 화면에 그리는 순간 DOM 노드가 몇 개 생기느냐가 진짜 병목이었습니다. 해법의 방향도 결국 "그려지는 노드 수를 데이터 크기에서 떼어내는 것"으로 수렴했습니다.
- 제약을 먼저 파악하면, 해법이 좁혀지고 기존 기능도 지킬 수 있다. 1. 백엔드 API를 못 바꾼다는 제약, 2. 지도 마커는 잘라낼 수 없다는 제약, 3. 특정 항목으로 이동하는 기능을 깨면 안 된다는 제약 — 이 세 가지 제약을 구현 전에 확인해둔 덕분에, 방식 선택과 예외 처리를 처음부터 설계에 넣을 수 있었습니다.
- 더 우아한 해법이 항상 더 안전한 해법은 아니다. 노드 수가 일정한 가상 스크롤이 이론적으로는 더 나았지만, 좌표를 수동 계산하는 구조는 계산값을 다 확인하고도 원인을 설명할 수 없는 버그를 함께 데려왔습니다. 이럴 때는 원인을 끝까지 파고드는 것보다, 복잡성 자체를 걷어내 리스크를 없애는 쪽이 더 합리적일 수 있었습니다. 단순한 구조는 애초에 새 버그가 끼어들 여지 자체가 적었으니까요.
- 부가 레이어는 기존 설계의 성격에 크게 좌우된다. 마커 선택 로직이 처음부터 인스턴스 직접 변경 방식이었고 정리 로직이 이미 있었던 덕분에, 클러스터링을 나중에 얹고도 충돌 없이 넘어갈 수 있었습니다.
남은 이슈: 사라지지 않은 두 가지 숙제
두 작업 모두 실사용 데이터 기반의 정량 측정과 자동 검증이 빠져 있습니다.
- 점진적 렌더링은 진짜 가상 스크롤과 달리 스크롤을 끝까지 내리면 노드가 계속 누적되는 트레이드오프가 그대로 남습니다. 초기 부하 감소가 핵심 목적이라 허용한 선택이지만, 아주 긴 목록에서의 누적 지점은 실측으로 확인해봐야 합니다.
- 클러스터링의 반경·크기 값은 여전히 실 데이터를 눈으로 보며 맞춘 감(感)에 의존한 값입니다. 실제 밀집 지역 데이터를 기반으로 한 값 재산정과, 회귀를 자동으로 잡아낼 검증 장치 마련이 남은 과제입니다.
결국 이번 경험에서 제가 얻은 건, "대용량 데이터를 다룬 규모"가 아니라 데이터가 화면과 만나는 지점에서 무엇이 병목이 되고, 어떤 제약 아래서 그걸 어떻게 떼어냈는가라는 이야기였습니다. 여러분의 서비스도 데이터가 늘어날수록 화면이 무거워지고 있나요? 그렇다면 데이터를 줄이기 전에, 화면에 실제로 그려지는 노드 수부터 데이터 크기와 분리할 수 있는지 살펴보시길 권합니다.
한 가지 덧붙이자면, 개선안을 곧바로 적용하기 전에 데이터로 실측할 수 있는 판을 먼저 마련해두시길 권합니다. 이번 작업에서 제가 가장 아쉬웠던 부분이 바로 이 지점입니다. 렌더링 시간이나 노드 수를 측정할 수 있는 계측을 미리 붙여뒀다면, "체감상 빨라진 것 같다"에 기대지 않고 개선 전후를 숫자로 비교하고 튜닝 값도 감이 아니라 근거로 정할 수 있었을 겁니다. 최적화는 측정에서 시작해 측정으로 끝나야 한다는 걸, 측정이 빠진 채로 끝내고 나서야 절감했습니다.