9P by GN⁺ | ★ favorite | 댓글과 토론
  • MongoDB·PostgreSQL용 데이터베이스 GUI를 만들면서 BSON·JSONB 타입, 중첩 열, 검색, 편집, 고정, 드래그를 지원해야 했고, 이를 위해 약 1년간 양축 가상화와 상태 관리 구조를 최적화함
  • 원본 문서와 별도로 표시 문자열·타입·평탄화 경로·열 순서·검색 결과를 미리 계산하는 섀도 테이블을 만들고, 화면에 보이는 행과 열만 일정한 크기의 DOM으로 렌더링함
  • 스크롤 경로에는 수동적 이벤트 리스너, requestAnimationFrame, 버퍼·히스테리시스, 속도 추적을 적용하고, 레이아웃 속성 대신 transformopacity 를 활용해 메인 스레드 작업을 줄임
  • 셀별 아이콘을 공유 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에서 이동함
  • 애니메이션을 컴포지터에서 처리할 수 있는 속성은 transformopacity 이며, 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 수준의 부드러움에는 도달하지 못하더라도 필요한 사용자 경험과 개발 비용을 고려한 명시적 절충

댓글과 토론