전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

한 코드베이스에 가상화 라이브러리 3개 + 자체 구현이 공존하는 이유

가상화 라이브러리 세 개에 자체 구현까지, 정리 안 된 게 아니라 정리한 결과예요. 요구가 다르면 답도 다르거든요. 상용 그리드를 밀어낸 벤치마크와 리렌더 0회 스크롤까지.

2026. 07. 14. 18:12:078분 읽기프로젝트/데이터 플랫폼
#가상화#성능 최적화#React
조회 35
리스트 가상화 썸네일

저희 코드베이스에는 가상화 라이브러리가 세 개 들어 있어요. react-virtuoso, TanStack react-virtual, react-window. 그리고 자체 구현이 하나 더 있습니다.

정리를 안 한 게 아니냐고요? 아니요. 정리한 결과가 이겁니다. 데이터 분석 플랫폼에서 가상화는 기본기인데, 요구가 다르면 답도 달라야 하거든요.

(네, 번들에 가상화가 네 종류 들어 있습니다. 편안합니다.)

이 시리즈 이름은 "직접 만든 엔진들"이에요. 라이브러리로 안 되는 지점에서 바퀴를 다시 깎은 기록들이죠. 바퀴의 재발명이 왜 죄가 아닌지, 세 편에 걸쳐 변호해 보겠습니다.

먼저, 왜 하나로 통일이 안 되나

"가상화 라이브러리 하나만 쓰면 되지 않나"가 당연한 질문이에요. 저도 그러고 싶었습니다.

그런데 가상화라는 말 아래에 서로 다른 문제 네 개가 있었어요.

요구특징필요한 것
긴 목록행 높이가 대체로 균일간단한 창 계산
채팅형 목록높이가 제각각, 나중에 확정동적 측정과 보정
표 (행+열)가로세로 동시 가상화2차원 인덱싱
분석용 대형 표수십만 행, 열 고정·병합전용 구현

한 라이브러리로 넷을 다 하려면 그 라이브러리가 넷을 다 잘해야 해요. 그런데 잘하는 라이브러리들은 대개 하나를 아주 잘합니다.

넷을 다 하는 라이브러리를 고르면 어떻게 되냐면, 네 자리 모두에서 조금씩 아쉬워요. 그리고 아쉬운 부분을 우회하는 코드가 붙기 시작합니다.

그래서 기준을 이렇게 잡았어요

세 번째 줄이 이 결정을 가능하게 만든 조건이에요. 화면 코드가 라이브러리를 직접 부르지 않고 우리 컴포넌트를 부릅니다.

그래서 나중에 갈아탈 때 그 자리의 래퍼만 바꾸면 돼요. 실제로 상용 그리드를 걷어낼 때 이 구조가 값을 했습니다.

(래퍼는 도입할 때가 아니라 뺄 때 진가가 나와요. 이 시리즈에서 여러 번 반복되는 이야기죠.)

래퍼 뒤에 둔다는 게 실제로 어떤 모양이냐면요.

// 화면 코드가 보는 것 — 라이브러리 이름이 없습니다
<VirtualList
  items={rows}
  estimateSize={() => 36}
  renderItem={(row) => <Row data={row} />}
/>

// 래퍼 안에서만 라이브러리를 부릅니다
// packages/ui/virtual-list/index.tsx
import { useVirtualizer } from "<선택된 라이브러리>";
래퍼 없이 직접 사용래퍼 뒤에 둘 때
갈아탈 때 사용처를 전부 수정래퍼 파일 하나만 수정
라이브러리 API가 화면 코드에 노출우리 언어로 통일됨
자리마다 사용법이 조금씩 달라짐자리가 달라도 부르는 법은 같음
라이브러리가 셋이면 사용법도 셋사용법은 하나

마지막 줄이 핵심이에요. 라이브러리를 세 개 쓴다고 개발자가 세 개를 배워야 하는 건 아닙니다. 래퍼가 있으면 배울 건 하나예요.

그리고 이게 "세 개나 쓴다"는 결정을 감당 가능하게 만든 조건이었어요. 래퍼가 없었으면 저도 통일을 택했을 겁니다.

네 번째는 왜 직접 만들었나

셋은 라이브러리를 썼는데 하나는 직접 만들었어요. 그 이유를 적을게요.

분석용 대형 표는 요구가 특이했습니다. 수십만 행에 열 고정, 셀 병합, 그룹 헤더가 동시에 필요했거든요. 그리고 스크롤이 부드러워야 했고요.

라이브러리들을 벤치마크했는데, 이 조합에서 성능이 요구에 못 미쳤어요. 그래서 만들었습니다.

만들 때 기준이 하나 있었어요. 직접 만들 자격은 "라이브러리가 없어서"가 아니라 "요구를 수치로 말할 수 있어서" 생깁니다.

숫자 없이 만들기 시작하면 그건 취향이에요. 벤치마크를 먼저 돌린 이유가 그겁니다.

벤치마크를 어떻게 설계했나

"성능이 못 미쳤다"는 말은 근거가 없으면 의견이에요. 그래서 재는 방법을 먼저 정했습니다.

항목조건왜 이 조건인가
행 수1천 · 1만 · 10만 · 100만 · 1000만어디서 꺾이는지를 봐야 해서
초기 렌더데이터 주입 후 첫 페인트까지사용자가 "느리다"고 느끼는 순간
메모리렌더 직후 힙 스냅샷탭을 여러 개 열어 두는 사용 패턴
스크롤 품질고정 속도 10초, 16ms 초과 프레임 수평균이 아니라 최악값이 체감을 만듦
기능 충족열 고정 · 셀 병합 · 그룹 헤더 동시 사용이 조합이 우리 요구

마지막 줄이 실제 판정 기준이었어요. 앞의 넷은 잘 나오는데 마지막에서 막히는 라이브러리가 여럿이었거든요. 각각은 되는데 셋을 동시에 켜면 안 되는 식으로요.

결과는 100만 행 기준 초기 렌더 약 3.7배, 메모리 약 2.4배 우위였습니다. 숫자가 쌓이니 회의가 짧아졌어요. 반대할 근거를 같은 표 위에서 대야 했으니까요.

직접 만들 자격은 "라이브러리가 없어서"가 아니라 "요구를 수치로 말할 수 있어서" 생깁니다. 숫자 없이 만들기 시작하면 그건 취향이에요.

그래서 번들이 무거워지지 않나

네 종류가 들어가면 당연히 걱정되는 부분이죠. 그런데 실제로는 안 그랬어요.

각 라이브러리가 쓰이는 자리가 다르고, 그 자리들이 서로 다른 화면에 있거든요. 코드 분할이 제대로 돼 있으면 한 화면이 네 개를 다 받지 않습니다.

한 화면에서 두 개 이상이 필요한 경우가 있는지도 확인했는데, 없었어요. 애초에 요구가 달라서 나눈 거라 같은 화면에 겹칠 이유가 없었던 겁니다.

그리고 이 확인은 감이 아니라 검사로 합니다. 브라우저가 실제로 뭘 받아가는지 CI가 보고 있거든요.

용도별로 이렇게 골랐어요

용도선택이유
무한스크롤 테이블react-virtuosoendReached 무한 스크롤, 스켈레톤, window 스크롤러. 동적 콘텐츠 목록에서 손이 제일 덜 갑니다
동적 높이 트리·콤보박스·사이드바TanStack react-virtual측정 기반 재배치가 필요한 곳. 훅 API라 커스텀 레이아웃에 붙이기 쉬워요
고정 그리드(아이콘 피커 등)react-window항목이 균일하면 가장 가벼운 도구면 충분합니다
2축 피벗 테이블자체 구현행·열 동시 가상화 + 고정 영역 4개 동기화는 범용 라이브러리가 감당 못 했어요

기준은 하나예요. 라이브러리가 요구의 90%를 덮으면 라이브러리, 요구가 라이브러리의 가정을 깨면 직접. 도구 통일이라는 미적 만족을 위해 요구를 깎지 않습니다.

상용 그리드를 밀어낸 벤치마크

1편에서 말한 상용 그리드 롤백의 근거가 여기 있어요. 자체 가상화 테이블과 상용 그리드를 같은 조건(1,000~10,000,000행)에서 계측했더니 — 100만 행 기준 초기 렌더 약 3.7배, 메모리 약 2.4배 우위. 1,000만 행에서는 격차가 9배 이상으로 벌어졌어요. 번들 크기는 MB 단위에서 수십 KB로 줄었고요.

감이 아니라 표가 결정을 만들었습니다.

자체 구현의 디테일 둘

이중 가상화 전략

피벗 테이블의 자체 가상화는 조건부예요. 셀 크기가 전부 기본값이면 인덱스 산술만으로 가시 범위를 계산하는 O(1) 고정 사이즈 경로를 타고, 사용자가 셀을 리사이즈해서 크기가 제각각이 되면 TanStack 가상화로 전환합니다.

// 고정 사이즈 경로 — 측정이 필요 없다
startIndex = floor(scrollOffset / cellSize) - overscan
endIndex   = startIndex + ceil(viewport / cellSize) + overscan * 2

스크롤 갱신도 두 갈래예요. 새 가시 범위가 이전 범위와 겹치면(연속 스크롤) requestAnimationFrame으로 지연 렌더하고, 스크롤바 점프처럼 범위가 완전히 끊기면 flushSync로 동기 렌더해서 빈 화면 플래시를 막아요. 부드러움과 정확함 중 하나를 고르지 않고, 상황이 고르게 합니다.

이중 경로가 왜 필요했는지는 계산을 보면 바로 나와요.

// 모든 셀이 기본 크기일 때 — 측정이 필요 없습니다
startIndex = Math.floor(scrollTop / rowHeight);
endIndex   = Math.ceil((scrollTop + viewportHeight) / rowHeight);
// O(1)

// 사용자가 일부를 리사이즈한 뒤 — 누적 높이를 알아야 합니다
// 각 행의 실제 높이를 재고 오프셋을 누적 관리
// → 이 시점부터 범용 가상화로 넘어갑니다
상태경로비용
전부 기본 크기인덱스 산술O(1) · 측정 없음
일부 리사이즈됨범용 가상화측정 + 오프셋 관리
판정 자체리사이즈 여부 플래그 하나

실제 사용 패턴을 보니 대부분의 세션에서 셀 크기를 아예 안 바꾸더라고요. 그러니까 흔한 경우를 가장 싸게 만들고, 드문 경우만 비싼 경로로 보낸 거예요.

스크롤 갱신도 두 갈래예요. 가시 범위가 이전과 겹치면 다음 프레임에 그리고, 스크롤바 점프처럼 범위가 완전히 끊기면 동기적으로 그립니다. 앞쪽은 부드러움이 중요하고, 뒤쪽은 빈 화면이 안 보이는 게 중요하거든요.

스크롤 리렌더 0회

피벗은 본문 스크롤에 헤더·행헤더·합계 3개 고정 pane이 따라와야 해요. 이걸 React 상태로 하면 스크롤마다 리렌더가 나죠. 대신 CSS 변수를 썼습니다.

// 스크롤 핸들러 — React를 거치지 않는다
container.style.setProperty('--scroll-x', scrollLeft + 'px');

/* 고정 pane은 변수를 따라 움직인다 */.pivot-header { transform: translateX(calc(var(--scroll-x) * -1)); }

결과: 스크롤 중 React 리렌더 0회. 역방향 동기화(합계 pane을 직접 스크롤하는 경우)는 ignore 플래그로 루프를 차단해요. 상태 관리 원칙(외부 시스템은 React 밖에서)이 성능 최적화와 만나는 지점입니다.

CSS 변수로 고정 pane을 따라오게 하는 방법이 이거예요.

// 스크롤 핸들러 — React 를 거치지 않습니다
function onScroll(e) {
  const { scrollLeft, scrollTop } = e.currentTarget;
  container.style.setProperty("--scroll-x", `${scrollLeft}px`);
  container.style.setProperty("--scroll-y", `${scrollTop}px`);
}
/* 고정 pane 들은 변수만 보고 따라옵니다 */
.column-header { transform: translateX(calc(-1 * var(--scroll-x))); }
.row-header    { transform: translateY(calc(-1 * var(--scroll-y))); }
.total-pane    { transform: translateX(calc(-1 * var(--scroll-x))); }
방식스크롤 1회당 리렌더
스크롤 위치를 React 상태로전체 트리 리렌더
ref + 각 pane 을 직접 조작pane 개수만큼 DOM 쓰기
CSS 변수 하나DOM 쓰기 1회 · 리렌더 0회

세 번째가 가능한 이유는 CSS 변수가 상속되기 때문이에요. 컨테이너에 한 번 쓰면 자식들이 알아서 따라옵니다. pane이 셋이든 다섯이든 쓰기는 한 번이에요.

역방향 동기화 — 합계 pane을 직접 스크롤하는 경우 — 는 무한 루프가 될 수 있어서 무시 플래그로 끊었습니다. A가 B를 움직이고 B가 다시 A를 움직이는 고전적인 문제죠.

상태 관리 원칙이 성능 최적화와 만나는 지점이에요. 외부 시스템은 React 밖에서 다룬다는 규칙이, 여기서는 그대로 "리렌더 0회"가 됐습니다.


다음 편은 이 가상화 위에 올라간 피벗테이블 엔진 자체예요. 단일 패스 집계와 셀 병합 — 3,100줄 순수 함수의 설계 노트입니다.

시리즈 이어읽기

직접 만든 엔진들

1 / 3

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.