TanStack Table v9 전환기 - use no memo 7개 제거
React Compiler와 TanStack Table v8의 호환성 문제를 정리한 글을 쓴 게 두 달 전이다.
그 글의 결론 중 하나가 "v9로 올리면 "use no memo"를 들고 가지 말 것"이었고, 할 일로 적어 두기만 하던 작업을 오늘 했다.
결과부터 적으면 이렇다.
@tanstack/react-table8.21.3 → 9.3.1(legacy 호환 레이어)"use no memo"7개 → 0개- 컴포넌트 테스트 환경을 React Compiler를 켠 채로 처음 세웠다
- 그 테스트가 운영에 두 달 넘게 살아 있던 버그를 하나 찾았다
마지막 줄이 이 글을 쓰는 이유다. 전편에서 "아직 안 터진다"고 적어 둔 경로가 실은 이미 터져 있었다.
프로젝트별 수치나 파일 목록은 빼고, 전편처럼 다른 프로젝트에서도 남을 것만 적는다.
1. 첫 번째 결정 - 테스트부터, 그것도 컴파일러를 켠 채로
프로젝트에 컴포넌트 테스트가 하나도 없었다. 단위 테스트는 수백 개 있었는데 전부 node 환경의 .test.ts였다.
라이브러리 교체에서 테스트는 새 동작이 아니라 회귀 고정이 목적이다. 그런데 이 부류의 회귀는 "에러 없이 화면만 옛 값"이다. 그래서 테스트를 세우기 전에 한 가지를 먼저 정해야 했다.
컴파일러 없이 돌리는 컴포넌트 테스트는 이 버그를 원리적으로 못 본다.
캐시를 만드는 게 컴파일러인데 테스트에서 컴파일러가 빠지면, 테스트는 늘 "갱신된다"고 말한다. 통과해도 아무 의미가 없다.
Vite 8(rolldown) + vitest 4 조합에서는 이렇게 붙는다.
// vitest.config.mts
import babel from "@rolldown/plugin-babel";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
plugins: [react(), babel({ presets: [reactCompilerPreset({ target: "19" })] })],
함정이 하나 있었다. pnpm이 @babel/core를 8.x로 깔았는데, babel-plugin-react-compiler@1.0.0은 @babel/types ^7에 묶여 있다. 7.x로 고정했다.
가드 테스트 - 테스트 환경 자체를 테스트한다
설정이 맞게 들어갔는지 확인할 방법이 필요했다.
누가 나중에 설정에서 프리셋을 빼면 모든 컴포넌트 테스트가 조용히 무력해지기 때문이다.
그래서 컴파일러가 있으면 낡은 값을 보여야 하는 테스트를 하나 넣었다.
function Reader({ source }: { source: { read: () => string } }) {
return <p>{source.read()}</p>;
}
it("참조가 같은 prop이면 컴파일러가 캐시한 결과를 재사용한다", () => {
let value = "처음";
const source = { read: () => value }; // 참조 고정 + 내부 값만 변함 = v8 table과 같은 성질
const { rerender } = render(<Reader source={source} />);
value = "바뀜";
rerender(<Reader source={source} />);
expect(screen.getByText("처음")).toBeInTheDocument(); // 컴파일러가 없으면 "바뀜"
});
버그를 재현하는 걸 성공 조건으로 둔 테스트다. 프리셋을 빼고 돌려서 실패하는 것까지 확인했다. 이게 없었으면 이후의 모든 초록불을 믿을 근거가 없었다.
2. legacy 경유 - 그리고 전편이 예고한 일
테이블 훅 호출부가 여러 곳이고 공용 테이블 컴포넌트 파일도 많아서, 신 API(useTable + tableFeatures)로 한 번에 가지 않고 v9가 동봉한 v8 호환 훅 useLegacyTable을 먼저 거치기로 했다.
설치하자마자 전편에 적어 둔 문장이 현실이 됐다.
v9 코드에는 컴파일러 경고가 매칭될 수 없다. 경고가 사라진 것과 안전해진 것은 다르다.
컴파일러 레지스트리는 @tanstack/react-table 모듈의 useReactTable이라는 이름 하나만 안다. useLegacyTable로 바꾸는 순간 호출부의 자동 스킵이 전부 사라진다. v8 시절 "디렉티브 없이도 안전했던" 파일이 하나 있었는데, 그 안전은 디렉티브가 아니라 자동 스킵 덕이었다. 이제 그 파일도 컴파일된다.
다행히 legacy 훅을 뜯어보니 반환값이 useMemo(() => ({...table, getState, setState}), [table, …])이고, 안쪽 useTable은 옵션·상태가 바뀌면 새 참조를 준다. 입력이 바뀌면 참조도 바뀐다 — 컴파일러가 원하는 전제를 라이브러리가 맞춰 준 구조다. 자동 스킵을 잃어도 판정 자체가 정상으로 돈다.
경험칙: 라이브러리 메이저 업그레이드 후 컴파일러 경고가 사라지면, 먼저 레지스트리 키가 아직 코드에 존재하는지 본다. 경고가 사라진 이유가 "고쳐져서"인지 "이름이 바뀌어서"인지는 다르다.
3. 숨은 버그 - 가중치를 바꿔도 점수 열이 그대로
문제의 화면은 사용자가 가중치(URL 쿼리 파라미터)를 바꾸면 각 행의 종합 점수 열이 다시 계산되는 목록 화면이다. 가중치를 프리셋 하나로 바꾸는 테스트를 썼다. 실패했다.
URL 값은 바뀌었다(25,25,25,25 → 40,30,15,15). 셀만 25에 머물렀다.
처음엔 마이그레이션 회귀라고 생각했다. 그래서 v8 시절 커밋을 worktree로 띄워서 같은 테스트를 돌렸다.
| 조건 | 결과 |
|---|---|
| v8 + 컴파일러 켬 | 실패 |
| v8 + 컴파일러 끔 | 통과 |
| v9 legacy + 수정 전 | 실패 |
| v9 legacy + 수정 후 | 통과 |
v8에서도 실패했다. 원래 있던 버그였다.
원인 - 전편이 "아직 안 터진다"고 했던 바로 그 자리
공용 행 컴포넌트가 이렇게 생겼었다.
function TableBodyRow({ row, onRowClick }) {
return (
<tr onClick={() => onRowClick?.(row.original)}>
{row.getVisibleCells().map((cell) => <td>{flexRender(…)}</td>)}
</tr>
);
}
컴파일 결과의 셀 블록 메모 조건은 row 하나였다. 그리고 row는 data가 같으면 같은 참조다. 가중치 변경은 columns만 바꾸고 data는 그대로 둔다. 그러니 컴파일러 입장에서는 아무것도 안 바뀌었다.
이건 v9 동봉 문서가 HIGH로 꼽은 "중첩 인스턴스(row·cell·column·header)는 여전히 안정 참조"의 실례다. 수정은 전편의 판단 기준을 그대로 적용한 것이다 - 인스턴스를 넘기지 말고, 이미 읽어낸 값을 넘긴다.
// 부모 테이블 컴포넌트가 읽어서 넘긴다. 컬럼이 바뀌면 배열이 새로 온다.
<TableBodyRow row={row} cells={row.getVisibleCells()} onRowClick={onRowClick} />
그런데 내 문서가 반대로 말하고 있었다
내가 남긴 문서 두 곳이 이 결과와 어긋났다.
- 전편을 쓰며 함께 정리한 문서에는 "호출부가 자동 스킵되고 인라인
onRowClick이 매 렌더 새 참조라 자식의 메모 조건을 무효화해서 아직 안 터진다"고 적혀 있었다. 틀렸다.onRowClick이 바뀌면onClick핸들러 블록만 다시 만들어지고, 셀 블록은row만 보므로 그대로 재사용된다. 메모 조건이 블록마다 따로라는 걸 놓친 추론이었다. - 몇 달 전 다른 기록에는 "가중치를 바꾸면 셀 점수는 즉시 갱신된다"는 관찰이 남아 있었다.
2번이 특히 신경 쓰였다. 테스트는 "안 바뀐다", 사람의 관찰은 "바뀐다".
테스트 환경(babel)과 운영 빌드(Next SWC)가 다르게 동작할 가능성도 있었다. 그래서 운영에서 직접 확인하기로 했다.
처음 확인했을 때 "가중치 바꾸니까 평균 점수가 바뀐다"고 답했다. 그런데 그건 표 위의 요약 카드였다. 카드는 표를 거치지 않고 스토어에서 바로 계산한다. 표의 점수 열을 다시 보니 - 그대로였다.
예전 관찰도 아마 같은 착각이었을 것이다. 화면 한쪽 숫자가 바뀌니까 다 바뀐 줄 알았다.
같은 화면에서 숫자 하나가 바뀌었다고 다른 숫자도 바뀐 게 아니다. 같은 입력을 받아도 표를 거치는 값과 안 거치는 값은 서로 다른 캐시 경로를 탄다. 확인할 땐 문제의 그 셀을 본다.
덤으로 얻은 것도 있다. 컴파일러를 켠 vitest가 재현한 버그가 운영 빌드에서도 그대로 나왔다. 테스트 환경이 운영과 같은 판정을 낸다는 근거가 생긴 셈이다.
4. 테스트가 정말 회귀를 잡는지 - 통과를 믿지 않는 법
회귀 고정 테스트는 처음부터 초록불이다.
이미 동작하는 코드에 붙이니까. TDD의 RED 단계가 없다. 그러면 이 테스트가 잡아야 할 때 실제로 잡는지는 어떻게 아나.
이번에 쓴 방법은 과거 커밋을 worktree로 띄워 같은 테스트를 돌리는 것이었다.
git worktree add ../wt-v8 <v8 시절 커밋>
cp <새 테스트들> ../wt-v8/src/__tests__/…
# 디렉티브를 뺀 상태로 돌려 본다
행 선택 테스트는 v8에서 디렉티브를 빼자 정확히 실패했다. 예전에 고쳤던 버그("체크박스를 눌러도 선택 표시가 안 바뀜")의 증상 그대로다. 이 테스트는 두 달 전 그 버그를 다시 잡을 수 있다는 증명이 붙은 셈이다.
그 다음에야 "use no memo"를 하나씩 뺐다. 파일 하나 뺄 때마다 테스트를 돌렸고, 7개를 다 뺀 상태의 컴파일 결과도 뽑아서 메모 조건에 table(입력이 바뀌면 새 참조)이나 원시값만 남은 걸 확인했다.
할 일 메모에는 디렉티브가 8개라고 적혀 있었는데 실제로는 7개였다. 하나는 주석 안의 언급을 grep이 잡은 것이었다. 숫자도 실측한다.
5. 유통기한 있는 지식 vs 남을 원리 (전편 이어서)
| 유통기한 있는 지식 | 오래 남을 원리 |
|---|---|
legacy 훅 이름(useLegacyTable)과 import 경로 |
레지스트리는 이름으로 매칭한다 — 이름이 바뀌면 보호도 경고도 사라진다 |
@babel/core 7 고정, reactCompilerPreset 설정 문법 |
컴파일러를 켠 채로 컴포넌트를 테스트한다 |
cells prop이라는 구체적 해법 |
data는 그대로, columns만 바뀌는 화면이 안정 참조 row의 사각지대다 |
| 이 프로젝트의 디렉티브 7개 현황 | 테스트가 회귀를 잡는지는 과거 커밋에서 실패하는지로 확인한다 |
| 예전 기록의 틀린 관찰 | 확인할 땐 문제의 그 값을 본다. 옆의 값이 바뀐 건 증거가 아니다 |
6. 다음 - 다른 프로젝트들
이 작업을 문서로 남긴 이유 중 하나는 다른 프로젝트도 같은 길을 가야 하기 때문이다. 훑어보니 갈래가 뚜렷했다.
| 프로젝트 유형 | react-table | 컴파일러 | 해야 할 일 |
|---|---|---|---|
| 이번 프로젝트 | 9.x legacy | 켜짐 | 2단계 — 화면별로 신 API |
| 관리자용 프로젝트 | 8.x | 꺼짐 | 순수 API 이전. 이 글의 함정 대부분이 해당 없음 |
| 최근에 새로 만든 프로젝트 | 9.x | 켜짐 | 이미 신 API |
| 의존성만 남은 프로젝트 | 8.x | 꺼짐 | 사용처 0 → 의존성 제거 |
관리자용 프로젝트가 컴파일러를 안 쓴다는 게 의외였다. 같은 v8 → v9 작업인데 위험의 성격이 완전히 다르다.
컴파일러가 없으면 "화면만 조용히 옛 값"인 부류가 원리적으로 생기지 않으니, 남는 건 타입·이름 이전뿐이다.
정리
- 컴포넌트 테스트는 앱과 같은 컴파일러 조건에서 돌아야 이 부류의 버그를 본다. 테스트 환경 자체를 지키는 가드 테스트를 같이 둔다.
- v9로 올리면 레지스트리가 모르는 이름이 되어 자동 스킵이 사라진다. v9 훅은 입력이 바뀌면 새 참조를 주므로 판정은 정상이지만, 경고 부재를 안전 신호로 읽지 않는다.
row·cell같은 중첩 인스턴스는 v9에서도 안정 참조다. 특히data는 그대로이고columns만 바뀌는 화면에서 셀이 얼어붙는다. 부모가 읽은 값을 넘긴다.- 메모 조건은 블록마다 따로다. 한 prop이 매 렌더 바뀐다고 같은 컴포넌트의 다른 블록까지 무효화되지는 않는다.
- 회귀 고정 테스트는 과거 커밋에서 실패하는 것을 보고 나서 믿는다.
- 운영 확인은 문제의 그 값을 본다. 같은 화면의 다른 숫자가 바뀐 것은 증거가 아니다 — 이번에 두 번(예전 기록, 오늘의 첫 확인) 같은 착각이 나왔다.
- 전편의 교훈이 또 맞았다. 그럴듯한 설명(
onRowClick이 메모를 무효화한다는 추론)을 적어 두면 다음 사람이 믿는다. 이번에 그걸 깬 건 추론이 아니라 실패하는 테스트 하나였다.
'TIL' 카테고리의 다른 글
| [261015 TIL] 우리들의 추억 3편 - 기억은 있는데 왜 꺼내질 못하니 (0) | 2026.10.16 |
|---|---|
| [261004 TIL] 우리들의 추억 2편 - 체크포인트 1 (1) | 2026.10.04 |
| [260930 TIL] vgpu로 블랙홀 세 번째 시도 (0) | 2026.09.30 |
| [260928 TIL] terraform provider 공유 캐시 사용 (0) | 2026.09.30 |
| [260917 TIL] ANALYZE는 왜 한 시간이 걸렸나 (0) | 2026.09.17 |