전체 검색

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

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

일반 테이블과 트리는 무료 라이브러리를 쓰고, 피벗은 직접 구현했어요. 반복되는 커스텀 공수·서버 연동·라이선스 비용을 놓고 선택한 이유와 직접 맡게 된 유지보수를 정리합니다.

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

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

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

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

이 시리즈 이름은 "직접 만든 엔진들"이에요. 제품 요구에 맞추는 비용을 따져 보고 직접 구현을 택한 기록들이죠. 바퀴의 재발명이 왜 죄가 아닌지, 세 편에 걸쳐 변호해 보겠습니다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

피벗을 직접 만든 이유는 커스텀 공수였어요

상용 피벗을 쓸 때도 필요한 커스텀은 가능했어요. 문제는 비슷한 기능을 추가할 때마다 라이브러리의 계약에 맞춰 동작을 수정해야 한다는 점이었습니다. 당시 적용 방식에서는 사내 Axios API client를 그대로 쓰기 어려웠고, 서버 계산 연동에도 별도 대응이 필요했어요.

무료 대안은 필요한 기능을 상당 부분 새로 만들어야 했고, 유료 대안도 라이선스 비용과 커스텀 공수를 함께 감수해야 했어요. 그래서 피벗의 초기 기본 기능과 유지보수를 직접 맡고, 사내 API client와 서버 endpoint를 사용하기로 했습니다.

일반 테이블과 트리는 기본 기능이 단순하고 무료 라이브러리로 커스텀하기 쉬웠어요. 피벗을 직접 만들었다고 나머지 UI까지 직접 만들 필요는 없었습니다.

성능 수치와 교체 판단을 구분했어요

2026년 9월 8일에 근거를 다시 정리하며, 이전 글의 상용 제품 대비 렌더·메모리 배수와 대용량 벤치마크 수치를 제외했어요. 비교 제품과 버전, 동일한 데이터·결과 크기, 실행 환경과 원본 결과를 다시 확보하기 전에는 성과 근거로 쓰지 않으려 합니다.

교체를 결정한 실제 이유는 반복되는 커스텀 공수와 라이선스 비용이었어요. 성능 프로파일링으로 병목을 찾아 교체했다고 설명하지 않습니다. 현재의 대용량 테스트 코드도 당시 비교 수치를 대신 증명하지는 못해요.

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

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

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

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

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

용도별로 이렇게 골랐어요

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

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

대체한 뒤에는 저희가 검증과 유지보수를 맡았어요

셀 합산·역산과 팝업 동작을 정의하고 AI 보조로 구현했어요. 작은 데이터의 계산 결과를 Excel과 대조하고, 서브에이전트와 계산·테스트 로직을 리뷰한 뒤 직접 스모크 테스트로 주요 동작을 확인했습니다.

실제 제품에서 상용 피벗을 대체해 해당 라이선스 비용은 없어졌어요. 대신 기본 기능과 이후 인터랙션 변경의 유지보수는 저희 책임으로 남았습니다. 라이선스 비용 제거를 총 개발 비용이 없어진 것으로 설명하지는 않아요.

자체 구현의 디테일 둘

이중 가상화 전략

피벗 테이블의 자체 가상화는 조건부예요. 셀 크기가 전부 기본값이면 인덱스 산술만으로 가시 범위를 계산하는 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) · 측정 없음
일부 리사이즈됨범용 가상화측정 + 오프셋 관리
—판정 자체리사이즈 여부 플래그 하나

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

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

고정 영역의 스크롤 위치 동기화

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

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

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

결과: 고정 영역의 위치 동기화를 React 상태와 분리. 역방향 동기화(합계 pane을 직접 스크롤하는 경우)는 ignore 플래그로 루프를 차단해요. 본문의 가시 범위 변경에는 렌더가 필요하므로, 스크롤 전체의 리렌더가 없다는 의미는 아니에요.

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))); }

CSS 변수는 상속되므로 고정 영역들이 같은 위치 값을 사용할 수 있어요. 다만 실제 DOM 쓰기 횟수와 렌더 횟수는 어느 경로를 측정하는지에 따라 달라집니다. 여기서는 위치 동기화와 가시 영역 렌더의 책임을 나눈 설계로 설명합니다.

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

스크롤 위치를 전달하는 일과 표시할 셀을 바꾸는 일을 구분해 둔 덕분에, 어느 경로를 조정해야 하는지 살피기 쉬워졌어요. 전체 화면의 성능은 별도 측정이 필요합니다.


다음 편은 이 가상화 위에 올라간 피벗테이블 엔진 자체예요. 단일 패스 집계와 셀 병합 — 계산 구조와 검증 방법을 정리한 기록입니다.

시리즈 이어읽기

직접 만든 엔진들

1 / 3회

이야기 나누기

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

댓글0
서로를 존중하는 대화를 남겨주세요.
아직 남겨진 대화가 없어요
첫 댓글을 남겨 대화를 시작해보세요.
새 글 구독
새 글이 공개되면 이메일로 알려드려요.