데이터 그리드 구축의 10단계
요약
대규모 데이터베이스 GUI를 위한 고성능 데이터 그리드 구축 과정을 다룹니다. 양축 가상화, 섀도 테이블, DOM 풀링 등 렌더링 최적화 기법을 통해 수만 개의 행과 열을 효율적으로 처리하는 방법을 설명합니다.
핵심 포인트
- 양축 가상화를 통해 화면에 보이는 요소만 렌더링하여 DOM 부하 최소화
- 섀도 테이블을 구축하여 원본 데이터와 별개로 표시 상태 및 경로 미리 계산
- DOM 풀링과 transform 속성을 활용해 스크롤 성능 및 메인 스레드 부하 개선
- Canvas 대신 DOM 기반 설계를 선택하여 텍스트 선택 및 접근성 유지
- MongoDB·PostgreSQL용 데이터베이스 GUI를 만들면서 BSON·JSONB 타입, 중첩 열, 검색, 편집, 고정, 드래그를 지원해야 했고, 이를 위해 약 1년간
양축 가상화와 상태 관리 구조를 최적화함 - 원본 문서와 별도로 표시 문자열·타입·평탄화 경로·열 순서·검색 결과를 미리 계산하는
섀도 테이블을 만들고, 화면에 보이는 행과 열만 일정한 크기의 DOM으로 렌더링함 - 스크롤 경로에는 수동적 이벤트 리스너,
requestAnimationFrame
, 버퍼·히스테리시스, 속도 추적을 적용하고, 레이아웃 속성 대신 transform
과 opacity
를 활용해 메인 스레드 작업을 줄임
- 셀별 아이콘을 공유 SVG 배경 이미지로 바꾸고 편집기는 필요할 때만 마운트했으며, 행과 열을 위치 기준으로 추적하는
DOM 풀링으로 스크롤 중 노드 생성을 없앰 - Canvas는 DOM보다 높은 60fps 성능 상한을 제공하지만 텍스트·선택·접근성·기능 확장에 불리해, 실제 텍스트 선택과 빠른 개발을 유지하는
DOM 기반 설계를 선택함
목표와 초기 제약
- 단순한 2차원 배열과 중첩 반복문으로 시작했지만, 약 1년에 걸친 간헐적 최적화 작업으로 이어짐
- 데이터베이스 GUI의 표는 표시 전용이 아니라 다양한 상태와 상호작용을 지원해야 했음
- MongoDB의 모든
BSON 타입과 PostgreSQL 등의 JSONB를 이해하고 타입별 색상 아이콘을 표시함 - 문자열
"123"
과 정수 123
처럼 쿼리 결과가 달라지는 타입을 구분함
-
중첩 문서를 실제 하위 열로 확장하고, 중첩 경로 전체를 검색해 셀 내부의 일치 부분을 강조함
-
열 재정렬·크기 변경·고정, 셀 내부 편집, 값·행·열을 시각적 쿼리 빌더로 드래그하는 기능이 필요했음
-
이런 기능의 상태는 원본 문서에 없으며 스크롤 이후에도 유지돼야 하므로 별도 렌더링 구조가 필요했음
1단계: 모든 항목을 직접 렌더링
-
행과 필드를 중첩 순회해 모든 셀을 만드는 방식은
100행에서는 동작하지만 대규모 데이터에서 무너짐 -
10,000행 × 30열은 약 300,000개의 DOM 노드를 만들며, 프레임워크의 변경 감지가 이들을 반복해서 순회함
-
DOM 노드 하나가 브라우저 내부 구조까지 약 1KB를 소비한다고 보면 실제 데이터 이전에 수백 MB가 필요함
-
60fps의 프레임 예산은
16.7ms이며 스타일·레이아웃·페인트도 이 시간을 공유함 -
실제 구현에서는 약 20열을 가진 1,000행을 렌더링하려다 실패함
-
일부만 렌더링하려면 현재 보이는 행과 열, 순서, 중첩 필드 확장, 검색 결과를 별도로 추적해야 함
2단계: 섀도 테이블로 표시 상태 분리
-
원본 문서는 중첩되고 타입이 제각각이어서 렌더링 입력으로 쓰기 어려움
-
MongoDB에는 ObjectId·Decimal128·타임스탬프·바이너리 같은 BSON 값이 있음
-
SQL 데이터에는 JSONB와 시간대가 포함된 타임스탬프가 있으며, 셀마다 형식 결정이 필요함
-
렌더링 루프에서 형식을 판단하면 매 프레임 같은 비용이 반복되며, 원본 데이터에는 열 순서·확장 상태·검색 결과 같은 표 상태도 없음
섀도 테이블은 원본 문서를 변경하지 않은 채 실제 표가 보여줄 상태의 기준점 역할을 함 -
로드 시 한 번 구축하고 상태가 바뀔 때 갱신하며, 스크롤 중에는 변경하지 않음
-
셀마다 잘린 표시 문자열, 확정된 타입, 평탄화된 경로를 미리 계산함
-
표시 문자열을 제한해 16MB 문서가 렌더링 상태에 16MB 문자열을 그대로 생성하지 않도록 함
-
타입은 아이콘·편집기·검색 방식을 결정함
"address.geo.lat"
같은 평탄화 경로를 키로 사용해 매번 트리를 순회하지 않음
- 중첩 객체를 확장하면 하위 경로가 실제 열로 승격되며, 정렬·검색 결과·열 순서·확장 상태도 같은 구조에 저장됨
- 이 단계에서는 DOM 수가 줄지 않지만 이후 열 순서와 너비를 빠르게 계산할 기반이 마련됨
3단계: 수직 가상화와 유령 스크롤 영역
- 수직 가상화는 뷰포트의 행과 소량의 버퍼만 렌더링하고 나머지 높이를 가짜 영역으로 만듦
유령 영역(phantom) 은rowCount × rowHeight
높이의 내부 컨테이너임
- 100만 행 × 40px이면 내용이 거의 없는 4,000만px 높이의
div
가 만들어짐
-
브라우저가 이 높이를 기준으로 네이티브 스크롤바와 스크롤 동작을 제공함
-
표시 범위는 다음 계산으로 구함
firstRow = floor(scrollTop / rowHeight)
lastRow = floor((scrollTop + viewportHeight) / rowHeight)
-
작은 이동마다 다시 렌더링하지 않도록 양쪽에 버퍼 행을 추가함
-
표시 행은
firstRow × rowHeight
위치의 슬랩(slab) 컨테이너에 배치하며, top
보다 transform
을 사용하는 편이 유리함
- 사용자는 100만 행을 보지만 DOM에는 약 40행만 존재함
- 그러나 300열이라면 40행만으로도 12,000개 셀이 생기므로 열도 가상화해야 함
4단계: 가변 너비를 처리하는 수평 가상화
- 문서 데이터베이스의 컬렉션에는 수백 개 필드가 생길 수 있어
열 가상화도 필요함 - 열 너비가 일정하지 않으므로 고정값 나눗셈 대신 누적 합과 이진 탐색을 사용함
position[n] = width[0] + ... + width[n-1]
형태의 누적 합으로 각 열의 x 좌표를 한 번의 배열 조회로 얻음
- 스크롤 오프셋
x
에 해당하는 열은 누적 합 배열을 이진 탐색해 찾음
-
열이 1,000개여도 탐색은 마이크로초 단위로 끝남
-
누적 합은 열 크기 변경·숨김·재정렬처럼 너비가 실제로 바뀔 때만 재구축하며, 스크롤 중에는 만들지 않음
-
좌우 약
200px 버퍼를 두어 다음 열이 화면에 나타나기 전에 미리 렌더링함 -
양축 가상화 후 렌더링 면적은 데이터 크기와 무관하게 약 40행 × 12열로 유지됨
-
500열 컬렉션에서도 한 번에 약 12열만 존재하므로 로드 시간이 늘지 않았음
-
렌더링 대상은 줄었지만 초당 수백 번 발생하는 스크롤 이벤트마다 범위를 다시 계산하는 문제가 남음
5단계: 스크롤 경로의 프레임 예산 관리
- 스크롤 처리는 스타일·레이아웃·페인트와
16.7ms 프레임 예산을 공유하므로 최소한의 작업만 수행해야 함 - 수동적 이벤트 리스너를 프레임워크 변경 감지 밖에서 등록함
preventDefault
를 호출하지 않는다고 브라우저에 알려 컴포지터가 JavaScript를 기다리지 않고 픽셀을 이동할 수 있음
-
스크롤 이벤트 자체가 프레임워크의 렌더링 검사를 일으키지 않음
-
여러 이벤트를 프레임당 한 번으로 합침
-
최신 스크롤 위치만 기록하고
requestAnimationFrame
콜백 하나를 예약함
-
한 프레임에 발생한 12개 이벤트도 한 번의 범위 계산으로 처리됨
-
Handsontable 소스에서 얻은
빠른 그리기 종료를 적용함 -
새 표시 범위가 기존 렌더링 버퍼 안에 있으면 정수 비교 두 번만 하고 즉시 반환함
-
버퍼 경계에는 히스테리시스를 적용함
-
표시 범위가 버퍼 가장자리 약 40px 이내로 접근하면 재구축함
-
약 200px 버퍼를 새 위치 중심으로 다시 배치해 빈 가장자리가 보이거나 경계에서 반복 재구축되는 일을 막음
-
이벤트 사이의 px/ms를 추적하는 속도 감지기를 추가함
-
구현에서는
10px/ms를 넘으면 빠른 플릭으로 판단해 렌더링을 중단함 -
네이티브 스크롤이 유령 영역 위를 이동하도록 두고 속도가 안정되면 슬랩을 다시 채움
-
JavaScript 작업을 줄인 뒤에도 프레임 저하가 남았으며, 스타일·얼룩무늬·아이콘을 포함한 레이아웃과 페인트가 다음 병목이 됨
6단계: 레이아웃 속성 비용 제거
- 브라우저의 메인 스레드는 스타일·레이아웃·페인트를 담당하고, 컴포지터는 이미 그려진 레이어를 GPU에서 이동함
- 애니메이션을 컴포지터에서 처리할 수 있는 속성은
transform
과 opacity
이며, top
·left
·width
·height
·background-color
등은 메인 스레드를 깨움
- 행 번호와 고정 열 패널의 스크롤 동기화에
top
을 갱신하자 초당 60번 강제 레이아웃이 발생함
- 이를
translate3d
로 교체해 동일한 화면을 유지하면서 메인 스레드 비용을 없앰
- 행별 배경으로 구현했던 얼룩무늬는 본문 전체의
repeating-linear-gradient
하나로 변경함
-
행 높이는 CSS 변수로 전달함
-
브라우저는 두 행 크기의 타일 하나만 래스터화하고 GPU 텍스처에서 반복 복사함
-
행별 클래스 바인딩이 사라지고 본문과 고정 패널이 같은 타일을 공유해 색상 불일치도 방지함
-
열 구분선은 4,000만px 높이의 유령 영역 전체가 아니라 현재 렌더링된 슬랩 높이에만 그림
-
정상 스크롤은 부드러워졌지만 새 셀을 만드는 윈도 전환 시점에는 셀마다 불필요한 DOM이 누적돼 비용이 남음
7단계: 아이콘과 편집기의 경량화
- 데이터베이스 그리드의 타입 아이콘은 장식이 아니라 ObjectId·문자열·정수·JSONB 같은 값의 의미를 구분하는 기능임
- 첫 구현은 셀마다 폰트 아이콘 요소를 추가했지만, 추가 DOM 노드와 글리프의 텍스트 렌더링 경로가 큰 비용을 만들었음
- 아이콘을 셀 자체의
background-image
로 옮기고 SVG 데이터 URI로 인코딩함
-
같은 타입의 모든 셀이 동일한 URI 문자열을 참조함
-
브라우저가 타입별 아이콘을 한 번만 래스터화하고 캐시된 GPU 텍스처에서 반복 복사함
-
별도 DOM 노드 없이 반복 장식을 표시할 수 있음
-
셀 편집에는 혼합 렌더링 방식을 적용함
-
평상시 셀은 일반 텍스트와
span
하나만 사용함
-
더블클릭할 때만 무거운 타입 인식 편집기 컴포넌트를 해당 셀 위에 포털처럼 마운트함
-
모든 셀에 프레임워크 컴포넌트를 사용하면 인스턴스 생성 비용이 누적되며, 그리드 라이브러리를 셀 렌더러 컴포넌트와 결합할 때 성능이 떨어질 수 있음
-
셀 자체는 가벼워졌지만 새 열 하나가 나타날 때 프레임워크가 내용이 동일한 나머지 셀까지 다시 만드는 문제가 남음
8단계: 위치 기반 DOM 재활용
- Angular의
trackBy
, React와 Vue의 key
같은 추적 기준은 윈도가 이동할 때 기존 요소를 재사용할지 파괴 후 생성할지 결정함
-
행이 새 데이터에 다시 바인딩되지 않고 매번 교체되면 컴포넌트·DOM 노드·이벤트 리스너를 계속 다시 만들어야 함
-
행과 열을 데이터 값이 아니라
화면상 위치로 추적함 -
같은 약 40개의 행 요소와 열 요소가 유지되고 내용만 새 값으로 교체됨
-
그리드는 객체 풀처럼 동작하며 스크롤 중 새 DOM을 할당하지 않음
-
이 단계에서 양축 스크롤과 윈도 재구축 비용은 해결됐지만, 열 드롭 시 한 프레임의 흔들림이나 행 번호의 반 픽셀 오차 같은 마감 문제가 남음
9단계: 세부 상호작용 마감
- 열 드래그 중에는
transform
으로 기존 열을 새 순서처럼 이동시키며, 드롭 시 **재정렬과 **transform
초기화를 같은 렌더링 패스에서 처리함
-
마지막 드래그 프레임과 첫 재정렬 프레임을 픽셀 단위로 같게 만들어 전환이 보이지 않게 함
-
툴팁은 셀마다 바인딩하지 않고 컨테이너의 위임된 hover 리스너 하나로 처리함
-
커서 아래 셀에 대해서만 툴팁을 계산함
-
SQL의 JSONB 값을 보기 좋게 출력하는 것처럼 비싼 문자열 생성도 실제 hover 시점으로 미룸
-
행 번호 열의 1px 테두리가 데이터 행과 텍스트 기준선을 반 픽셀 어긋나게 해 스크롤 중 흔들림을 만들었음
-
양쪽 열이 같은
박스 모델을 사용하도록 수정함 -
얼룩무늬는 재사용되는 DOM 위치가 아니라 절대 행 인덱스를 기준으로 정해 가상화 윈도가 바뀌어도 색상이 깜빡이지 않음
-
자체 그리드 구축을 정당화한 기능도 유지함
-
중첩 문서를 JSON 문자열로 두지 않고 실제 하위 열로 확장함
-
중첩 경로의 일치 문자열을 셀 내부에서 강조함
-
타입이 있는 값을 그리드에서 시각적 쿼리 빌더로 직접 드래그함
-
일반적인 그리드 라이브러리에 이런 기능을 추가하는 비용이 렌더러를 직접 소유하는 비용보다 컸음
10단계: 기존 고성능 그리드의 소스 읽기
-
프런트엔드 성능 학습에서는 다른 프로젝트의
소스 코드 읽기가 가장 가치 높은 습관이었으며, 문서화되지 않은 최적화가 공개 저장소에 남아 있었음 -
연구한 DOM 그리드인 AG-Grid에는 다음 기법이 적용돼 있었음
-
명시적인 프레임별 시간 예산과 우선순위 작업 큐로 DOM 작업을 시간 분할함
-
열 뷰포트를 해시해 변화 없는 스크롤을 문자열 비교 한 번으로 끝냄
-
스크롤 방향을 따라 행을 생성해 사용자가 향하는 쪽의 내용부터 표시함
-
새 셀을 먼저 생성하고 기존 셀의 파괴를 뒤로 미뤄 새 내용이 사라진 내용보다 먼저 그려지게 함
-
셀별 이벤트 리스너가 측정된 병목이어서 컨테이너 수준의 이벤트 위임을 사용함
-
Canvas 기반 그리드는 DOM을 건너뛰어 레이아웃과 스타일 재계산 없이 프레임마다 수백 개의 텍스트를 다시 그림
-
과도한 조작에서도 60fps를 유지할 수 있어 DOM 그리드보다 성능 상한이 높음
-
픽셀 격자 밖에서 래스터화된 텍스트가 흐릴 수 있음
-
선택이 셀 단위로만 동작하고, 말줄임표도 직접 그리지 않으면 나타나지 않음
-
새 셀 기능마다 그리기 코드와 적중 판정 코드를 추가해야 함
-
최종적으로 DOM을 선택해 선명한 텍스트, 실제 텍스트 선택, 접근성, 빠른 기능 개발을 유지함
-
Canvas 수준의 부드러움에는 도달하지 못하더라도 필요한 사용자 경험과 개발 비용을 고려한
명시적 절충임
댓글과 토론
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기