[번역] 과소평가된 리팩터링이 메모리 사용량을 90% 절감한 방법

Kevin Van Cott - 2026년 6월 22일


TanStack Table V9 - 메모리 성능

TanStack Table V9의 메모리 사용량이 V8에 비해 최대 90%나 줄어들었다고요? 네, 맞습니다! 적어도 대규모 테이블에서는 그렇습니다.
우리는 어떻게 이를 달성했을까요? 애초에 개선할 여지가 왜 그렇게 많았던 걸까요?
TanStack Table V9에는 더 나은 방향으로의 많은 아키텍처 변화가 있었습니다. 상태 관리 시스템의 전면 개편과 "사용하는 만큼만 비용을 지불하는(only pay for what you use)" 훨씬 작은 런타임 모델을 갖춘 새로운 기능/플러그인 시스템이 도입되었습니다.
하지만 Table V9 개발 과정에서 얻은 가장 큰 성능 이점 중 하나는 겉으로 잘 드러나지 않는 배후의 리팩터링에서 비롯되었습니다. 이 리팩터링 덕분에 TanStack Table V9은 페이지네이션(Pagination)이나 가상화(Virtualization)를 통해 수십만 또는 수백만 개의 행을 처리해야 하는 대규모 테이블에서 Table V8보다 메모리를 최대 약 90% 적게 사용하게 되었습니다.
이 개선 사항은 TanStack Table을 어떻게 사용하느냐에 따라 매우 중요한 문제일 수도 있고, 거의 상관없는 문제일 수도 있습니다. Table V8에서는 여러 요인에 따라 다르지만, 브라우저에서 메모리 문제(보통 4GB 제한)가 발생하기 전까지 약 100만 개(최대 150만 개)의 행만 처리할 수 있었습니다. 하지만 벤치마크 결과에 따르면, Table V9에서 4GB 메모리를 사용하기 전까지 처리할 수 있는 최대 행 수는 이제 1,000만~1,600만 개에 달합니다. 물론 웹 페이지의 복잡도에 따라 이 낙관적인 수치보다 낮은 지점에서 문제가 발생할 가능성이 높지만, 이는 TanStack Table 자체의 확장성이 최대 10배 향상되었음을 의미합니다.
브라우저 클라이언트 측에서 1,500만 개의 행을 가져오거나 처리하도록 TanStack Table에 요청해야 할까요? 보통은 그렇지 않겠지만, 우리는 그 정도로 "터무니없는" 수준은 아닌 실제 사용 사례들을 보아왔습니다. 😉
이 글에서는 벤치마크 결과를 살펴보고, 이러한 성능 향상을 어떻게 달성했는지 자세히 설명하겠습니다. 놀랍게도 이는 (단 하나의 파괴적 변경 사항을 제외하면) 단점이 거의 없는 간단한 리팩터링이었습니다. 아직 이 패턴을 사용하지 않는 많은 라이브러리도 동일한 방식을 통해 메모리 사용량을 개선할 수 있을 것입니다.
페이지네이션된 행, 가상화된 행, 가상화된 열 및 kitchen sink 예제에서 TanStack Table V8과 Table V9의 메모리 사용량을 보여주는 차트

위 차트는 TanStack Table이 처리(단순 렌더링뿐만 아니라)해야 하는 셀(행 x 열)의 수가 늘어날수록 Table V8과 Table V9의 메모리 사용량 차이가 훨씬 더 뚜렷해짐을 보여줍니다. 그래프 왼쪽의 차이는 미미하지만, 오른쪽으로 갈수록 100만 행 x 8열을 처리할 때 Table V9이 Table V8보다 유지되는 JS 힙(JS heap) 메모리를 2.4GB 이상 적게 사용합니다.
전체 벤치마크 결과는 다음과 같습니다.
벤치마크 예제
셀 개수 (행 x 열)
Table V8 사용 메모리
Table V9 사용 메모리
절감된 메모리
개선율
paginated rows
80 (10 x 8)
1.93 MB
1.91 MB
0.02 MB
1.0%
paginated rows
8,000 (1,000 x 8)
4.71 MB
2.22 MB
2.49 MB
52.9%
paginated rows
800,000 (100,000 x 8)
272.58 MB
27.28 MB
245.30 MB
90.0%
paginated rows
8,000,000 (1,000,000 x 8)
2710.06 MB
257.19 MB
2452.87 MB
90.5%
virtualized rows
80 (10 x 8)
2.13 MB
2.10 MB
0.03 MB
1.4%
virtualized rows
8,000 (1,000 x 8)
5.16 MB
2.68 MB
2.48 MB
48.1%
virtualized rows
800,000 (100,000 x 8)
273.42 MB
28.12 MB
245.30 MB
89.7%
virtualized rows
8,000,000 (1,000,000 x 8)
2714.32 MB
261.46 MB
2452.86 MB
90.4%
virtualized columns
100 (10 x 10)
2.24 MB
2.24 MB
0.00 MB
0.0%
virtualized columns
10,000 (100 x 100)
5.31 MB
3.83 MB
1.48 MB
27.9%
virtualized columns
100,000 (100 x 1,000)
25.82 MB
10.73 MB
15.09 MB
58.4%
virtualized columns
1,000,000 (100 x 10,000)
230.47 MB
80.24 MB
150.23 MB
65.2%
kitchen sink
80 (10 x 8)
2.18 MB
2.38 MB
-0.20 MB
-9.2%
kitchen sink
8,000 (1,000 x 8)
4.96 MB
2.79 MB
2.17 MB
43.8%
kitchen sink
800,000 (100,000 x 8)
272.83 MB
36.91 MB
235.92 MB
86.5%
kitchen sink
8,000,000 (1,000,000 x 8)
2710.31 MB
349.22 MB
2361.09 MB
87.1%
전반적으로 행의 수가 늘어남에 따라 메모리 사용량 절감 효과도 매우 일관되게 증가합니다.
이러한 예제 벤치마크의 대부분은 페이지네이션이나 정렬과 같은 한두 가지 기능만 구현한 최소한의 예제이지만, 'kitchen-sink' 예제는 TanStack Table이 제공하는 모든 기능을 사용하는 보다 현실적인 예제입니다.
한 가지 주목할 점은 행 10개 x 열 8개만 있는 kitchen sink 예제의 경우, Table V9이 Table V8보다 실제로 메모리를 약간 더 많이 사용한다는 것입니다. 이는 CPU 성능 향상을 위해 내부 메모이제이션(Memoization)을 추가했기 때문일 가능성이 큽니다. 하지만 이러한 개선으로 인한 메모리 사용량 증가는 리팩터링을 통해 얻은 이득에 비하면 매우 미미합니다. 본질적으로 우리는 이전에는 없었던 거대한 메모리 사용 예산을 확보했으며, 이 예산은 테이블에서 생성되는 객체의 수에 비례하여 커집니다. 앞으로는 약간의 메모리 사용량을 대가로 속도 최적화에 더 집중할 수 있는 여유가 생겼습니다.
이 벤치마크가 어떻게 실행되었는지 궁금하다면 여기에서 저장소를 확인할 수 있습니다.
벤치마크 러너는 Playwright와 Chrome DevTools Protocol을 사용합니다. 각 예제에 대해 다음 과정을 거칩니다.
  1. 프로덕션용 Vite 예제 빌드
  1. Vite preview 시작
  1. 새로운 Chromium 컨텍스트에서 페이지 열기
  1. 테이블이 준비되었다고 보고할 때까지 대기
  1. HeapProfiler.collectGarbage로 가비지 컬렉션 강제 실행
  1. 유지된 JS 힙 기록
  1. DOM 개수 및 렌더링된 행/셀 개수 기록
  1. 측정할 상호작용이 있는 예제의 경우 테이블을 스크롤하거나 페이지 이동
이 벤치마크는 TanStack Table이 생성하는 행, 열, 셀 및 헤더 객체의 수라는 변화된 핵심 요소를 테스트하도록 설계되었습니다.
이제 어떻게 이러한 성능 향상을 이루었는지 알아보겠습니다.
결론부터 말씀드리면, 우리는 공유 프로토타입(Shared prototypes)을 사용했습니다. 그게 전부입니다. 자세한 내용을 살펴보겠습니다.

Table V8의 방식

TanStack Table V8에서는 테이블, 행, 열, 셀, 헤더 등의 객체가 생성될 때, 해당 객체의 값과 메서드가 각 객체 인스턴스에 직접 할당되었습니다.
조금 단순화하자면, 새로운 행 객체를 생성하는 코드는 다음과 같았습니다.
const createRow = (id, data) => {
const row = {
id,
data,
getValue: () => { /* ... */ },
getUniqueValues: () => { /* ... */ },
// ... 수십 개의 다른 메서드들
}
return row
}
이것은 JavaScript를 작성하는 매우 자연스러운 방식입니다. 일반적인 애플리케이션 코드라면 다른 방식으로 작성할 이유가 없을 것입니다. 문제는 TanStack Table에서 이 코드가 수천 또는 수백만 개의 행을 생성하는 루프 내부에 있을 수 있다는 점입니다. 그리고 각 행은 다시 수십 또는 수백 개의 셀을 생성하는 자체 루프를 가집니다.
결과적으로 수백만 개의 객체 인스턴스가 모두 동일한 메서드의 복사본을 반복해서 가지게 되는 상황이 발생합니다. 수백만 개의 거의 동일한 셀과 행 객체, 그리고 수십 또는 수백 개의 거의 동일한 열과 헤더 객체가 생기는 것이죠.
비용은 단순히 중복된 함수 객체에만 국한되지 않습니다. 이런 방식에서는 모든 화살표 함수가 클로저(Closure) 스코프를 함께 가질 수 있습니다. 그 클로저는 객체를 생성한 팩토리 함수로부터 행, 테이블, 캐시, 옵션 또는 기타 로컬 값들을 캡처할 수 있습니다. 이러한 클로저 스코프 역시 인스턴스마다 유지되는데, 이것이 메모리 사용량이 급격히 늘어나는 큰 이유 중 하나입니다.

Table V9의 새로운 방식

TanStack Table V9 알파 개발 기간 동안, 우리는 모든 행, 열, 셀, 헤더 객체를 생성할 때 이 리팩터링을 도입했습니다.
const rowMethods = {
getValue() { /* ... */ },
getUniqueValues() { /* ... */ },
// ...
}

const createRow = (id, data) => {
const row = {
id,
data,
}
Object.setPrototypeOf(row, rowMethods)
return row
}
따라서 각 행 객체에 대해 row.getValue(), row.getUniqueValues() 등의 메서드를 수백만 번 생성하는 대신, 단 한 번만 생성하여 행 프로토타입에 할당하고, 새 행 객체에 해당 프로토타입을 지정하기만 하면 됩니다.
또한 이렇게 하면 인스턴스별 메서드 클로저가 제거됩니다. 공유된 프로토타입 메서드는 this를 통해 특정 행을 전달받으므로, 행 전용 상태는 모든 행에 대해 새로운 함수 스코프에 캡처되는 대신 행 객체 자체에 머물게 됩니다.
우리는 열, 셀, 헤더 객체에 대해서도 이 패턴을 반복했습니다. 다만 행 객체가 확장될 가능성이 가장 높고 메서드 수도 많기 때문에 행 객체에서 가장 큰 효과를 보았습니다. 테이블 객체는 이미 단 하나의 인스턴스만 존재하므로 프로토타입 메서드를 사용할 필요가 없었습니다.

왜 JavaScript 클래스를 사용하지 않았나요?

여기서 당연한 질문이 생길 수 있습니다. 왜 그냥 Row, Column, Cell, Header 클래스를 만들고 메서드를 거기에 두지 않았을까요?
일반적인 라이브러리라면 그것이 매우 합리적일 것입니다. JavaScript 클래스 메서드는 이미 클래스 프로토타입에 존재하므로, 클래스 필드나 생성자에서 할당된 화살표 함수를 메서드로 사용하지 않는 한 class Row { getValue() {} } 구현은 이 리팩터링과 동일한 메모리 이점을 얻을 수 있습니다.
문제는 TanStack Table V9의 API가 기능(Features)들로부터 동적으로 구성된다는 점입니다. 우리는 열 가시성(Column visibility) 기능이 등록된 경우에만 row.getVisibleCells()가 존재하기를 원합니다. 행 정렬(Row sorting) 기능이 등록된 경우에만 column.toggleSorting()이 존재하기를 원합니다. 동일한 개념이 행, 열, 셀, 헤더 및 커스텀 플러그인 API 전반에 적용됩니다.
이를 클래스로 모델링하려고 하면 금세 까다로워집니다. JavaScript 클래스는 단일 상속을 사용하지만, TanStack Table의 기능 시스템은 조건부 다중 상속에 가깝습니다. 어떤 테이블은 정렬, 필터링, 페이지네이션, 열 가시성, 행 선택, 고정(Pinning), 그룹화 및 커스텀 플러그인을 사용할 수 있습니다. 다른 테이블은 정렬만 사용할 수도 있고, 또 다른 테이블은 완전히 다른 조합을 사용할 수도 있습니다.
다음과 같이 믹스인(Mixin) 체인을 구축하는 것을 상상해 볼 수 있습니다.
class Row extends Sorting(Filtering(Pagination(BaseRow))) {}
하지만 그렇게 되면 모든 기능이 클래스 구성에 참여해야 하고, 순서가 취약해지며, 커스텀 플러그인은 적절한 시점에 올바른 클래스 형태를 확장해야 합니다. 가능은 하겠지만, 결코 더 간단하지는 않습니다.
수동 프로토타입 접근 방식은 기능 시스템을 동적으로 유지하면서도 클래스에서 실제로 원하는 부분인 '공유 프로토타입 메서드'를 제공합니다.
const createRowPrototype = (features) => {
const prototype = {}
features.forEach(feature => {
Object.assign(prototype, feature.createRowMethods())
})
return prototype
}
각 테이블은 등록된 기능에 대한 API만 정확히 포함하는 프로토타입을 갖게 됩니다. 이것이 트리 쉐이킹(Tree-shaking)이 가능한 런타임 모델에서 중요한 부분이며, 모든 가능한 기능 조합을 클래스 상속을 통해 강제하려는 것보다 훨씬 깔끔합니다.

왜 Table V8에서는 이렇게 하지 않았나요?

원래 Michael Leibman이 TanStack Table V8에 대해 이러한 종류의 리팩터링을 제안한 PR이 있었습니다. 하지만 우리는 이것이 기술적으로 미묘한 파괴적 변경을 초래한다는 사실을 발견했습니다. 그래서 Table V8 대신 Table V9에서 이를 구현하는 것이 덜 위험하다고 판단했습니다.
그 미묘한 파괴적 변경이란 무엇일까요? 주로 객체 메서드의 구조 분해 할당(Destructuring)이 더 이상 작동하지 않는다는 점입니다.
다음과 같은 코드는 작동하지 않게 됩니다.
const { getValue } = row // Table V9에서는 작동하지 않음
getValue()
대신 다음과 같이 메서드를 사용해야 합니다.
row.getValue() // 항상 작동함
Table V8에서는 getValue와 같은 메서드가 행 팩토리 내부에서 생성된 화살표 함수였기 때문에 구조 분해가 가능했습니다. 이 함수들은 행 객체를 클로저로 캡처하고 있었으므로 어떻게 호출되든 상관없었습니다.
Table V9에서 메서드는 행 프로토타입에서 공유되며, 자신이 어떤 행에서 작동하는지 알기 위해 this 컨텍스트를 사용합니다. 위와 같이 메서드를 구조 분해하면 함수는 얻을 수 있지만 원래의 수신자(Receiver)를 잃게 됩니다. 그러면 엄격 모드(Strict mode)에서 thisundefined가 되어 메서드가 행을 찾을 수 없게 됩니다. ES 모듈은 항상 엄격 모드이고 TanStack Table은 모듈로 제공되므로 이 동작이 자동으로 적용됩니다.
또한 메서드는 고유 속성(Own properties)으로 나타나지 않습니다(예: Object.keys(row), 객체 스프레드, JSON.stringify). 메서드는 프로토타입에 존재하지만, 콘솔의 [[Prototype]] 항목에서는 여전히 찾을 수 있습니다. 이는 { ...row }와 같은 얕은 복사(Shallow clone)를 하면 행 데이터는 복사되지만 객체의 메서드는 누락됨을 의미합니다.
하지만 메서드 호출 자체는 이전과 동일하게 할 수 있습니다. row.getValue()를 호출하면 JavaScript는 객체 자체에서 메서드를 찾지 못할 경우 자동으로 프로토타입에서 메서드를 찾기 때문에 여전히 잘 작동합니다.
이는 파괴적 변경이 허용되는 메이저 릴리스에서 충분히 감수할 만한 가치가 있는 트레이드오프이지만, Table V8에 이 변경 사항을 적용할 수는 없었습니다.


이것은 TanStack Table과 같은 라이브러리에 있어 확실히 가치 있는 리팩터링입니다. 확장성이 필요한 다른 라이브러리나 애플리케이션 중에서도 이와 동일한 최적화의 혜택을 볼 수 있는 곳이 얼마나 많을지 궁금합니다. 여러분에게 유용할 수 있다고 생각하여 이 내용을 공유하게 되었습니다.
TanStack Table 사용자로서 이것이 무엇을 의미하는지 궁금하시다면, 아마도 거의 눈치채지 못할 정도의 보이지 않는 개선 사항이 되기를 바랍니다. Table V8에서 V9으로의 마이그레이션 가이드에 이러한 작은 파괴적 변경 사항들을 문서화할 예정입니다.
0
2

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글