도감 목록에 가상화를 붙이고 프로덕션 빌드로 확인했는데, 카드가 한 장도 안 보였습니다.
개발 서버에선 멀쩡했던 문제입니다.
일단 상황부터
도감 장비 목록이 2,800종쯤 됩니다. 처음엔 content-visibility로 버티다가 모바일에서 DOM 노드가 너무 많아져서 @tanstack/react-virtual로 갈아탔어요.
그리고 이 프로젝트엔 React Compiler가 켜져 있습니다(
reactCompiler: true). 결론부터 말하면 이번 문제는 이게 켜져 있어서 생긴 겁니다.React Compiler는 빌드할 때 코드를 훑어서 메모이제이션을 자동으로 넣어주는 도구입니다. 손으로 쓰던
useMemo, useCallback, React.memo를 컴파일러가 대신 판단해서 넣어줍니다. React 19와 묶여서 얘기되는 일이 많은데, 사실 React에 포함된 기능은 아니고 별도 빌드 도구라 React 17·18에서도 쓸 수 있어요.처음 켰을 때는 솔직히 좀 신기했습니다. 29개 파일에서 손으로 쓴 메모이제이션을 120곳쯤 지웠는데 렌더 횟수가 그대로였거든요. 그 뒤로는 새 코드에 아예 안 씁니다.
숫자는 1,926인데 화면이 비어 있습니다
작업을 끝내고 프로덕션 빌드로 확인했더니 이랬습니다. 총 개수는 1,926종으로 제대로 떠 있는데 카드가 0장이었어요.

필터도 정상이었습니다. 직업을 "전사"로 바꾸면 숫자가 456종으로 잘 바뀝니다. 그런데 카드는 여전히 0장.
콘솔에는 이렇게 찍혔습니다.

getVirtualItems() → [] getTotalSize() → 0 document height → 뷰포트와 동일 (스크롤 자체가 안 됨) aria-hidden 스페이서 2개는 렌더됨, 단 height: 0
그리고 dev 서버에선 아무 문제가 없었습니다. 이게 제일 당황스러웠어요.
데이터 쪽은 멀쩡했습니다
- 총 개수 표시 정상 (1,926 → 456으로 필터 반응)
- 네트워크 응답 정상
- 스페이서 div는 DOM에 존재 → 컴포넌트 자체는 렌더되고 있음
데이터도 왔고 컴포넌트도 렌더됐는데 virtualizer만 빈 배열을 뱉고 있었습니다. 그리고 dev와 prod가 다르다는 건 빌드 타임에 코드를 건드리는 뭔가가 있다는 뜻이고요. 이 프로젝트에서 그런 건 하나뿐이었어요.
높이는 우리가 못 정해줍니다
여기가 이번 일의 핵심이었습니다.
virtualizer한테 넘기는 값은 이게 전부입니다.
useVirtualizer({ count: rows.length, // 총 1,200개 getScrollElement: () => parentRef.current, // 스크롤되는 div estimateSize: () => 52, // 한 줄이 52px })
여기서 넘기는 52는 "한 줄"의 높이입니다. 그런데 몇 줄을 그릴지 정하려면 하나가 더 있어야 해요. 화면이 몇 px인지요.
560px(화면) ÷ 52px(한 줄) = 약 11줄 → "0번부터 10번까지 그려라"
그리고 이 560은 우리가 못 넘깁니다. virtualizer가 직접 재야 하는 값이거든요. CSS가 고정 px일 수도 있고 100%일 수도 있고 부모에 따라 달라질 수도 있으니, 브라우저가 실제로 그린 다음에야 알 수 있습니다.
그래서 순서가 이렇게 됩니다.
① 첫 렌더 총 1,200 ✅ 한 줄 52 ✅ 화면 ? ❌ → 0줄 ② 브라우저가 div를 화면에 붙임 ③ ResizeObserver가 잼: "이 div 560px이야" ④ 다시 렌더 총 1,200 ✅ 한 줄 52 ✅ 화면 560 ✅ → 11줄
첫 렌더가 0행인 건 정상입니다. 아직 화면을 못 쟀으니까요. 실제로 그려지는 건 ④, 두 번째 렌더입니다.
컴파일러가 두 번째 렌더를 박제했습니다
어댑터를 열어봤습니다. 50줄쯤 되는데 핵심은 세 줄이었어요.
// @tanstack/react-virtual@3.13.24 — dist/esm/index.js const [instance] = React.useState(() => new Virtualizer(resolvedOptions)); // 인스턴스는 최초 1회만 만들고 계속 재사용 instance.setOptions(resolvedOptions); // 훅이 아니라 렌더 함수 본문에 그냥 있는 줄 useIsomorphicLayoutEffect(() => { return instance._willUpdate(); }); // deps 배열이 없다 = 매 렌더 후 실행
인스턴스를 하나 만들어두고 매 렌더마다 고쳐 쓰는 구조입니다. 재본 화면 크기도 그 안에 들어갑니다. 값을 계산해서 반환하는 게 아니라 살아 있는 객체를 계속 고치는 방식이라, interior mutability라고 부릅니다.
React Compiler가 하는 일은 값을 캐싱하는 거고요. 입력이 그대로면 다시 계산하지 않고 저장해둔 값을 꺼내 씁니다.
④에서 부딪힙니다. 화면 크기는 들어오고 리렌더도 되는데, 컴파일러 눈에는 props도 state도 그대로예요. 바뀐 560은 virtualizer 객체 안쪽이라 안 보이거든요. 그래서 ①에 캐싱해둔 값을 그대로 꺼내 씁니다. 그게 빈 배열이고요.
카운트만 멀쩡했던 건 그 숫자가 props에서 바로 나와서입니다. 캐시가 계산 단위로 걸리다 보니 행 목록만 멈춰 있었어요. dev에서 안 터진 건 컴파일러 최적화가 프로덕션 빌드에서 온전히 걸려서고요.
중간에 한 번 속았습니다
원인을 좁히려고 virtualizer 내부 값을 콘솔에 찍는 디버그 코드를 넣었더니, 그리드가 정상적으로 그려지더군요. "고쳐졌나?" 했는데 디버그 코드를 빼니까 다시 0행이었습니다.
mutable 값을 읽는 코드가 들어가면서 컴파일러가 최적화를 포기(bailout)한 거였어요. 캐시를 안 쓰니 두 번째 렌더가 제대로 계산된 겁니다. 덕분에 원인을 두 번 잘못 짚었습니다.
해결은 디렉티브 한 줄
훅과 컴포넌트 함수 본문 첫 줄에
'use no memo'를 넣어 컴파일러를 옵트아웃했습니다. "이건 캐싱하지 마"라는 뜻입니다.export function VirtualItemGrid({ grouped, sections }: VirtualItemGridProps) { // React Compiler 옵트아웃 — 가상화 출력(virtualizer mutable)을 렌더하므로 메모이즈 시 0행. 'use no memo'; const { parentRef, virtualizer, rows, virtualRows } = useVirtualGrid(grouped, sections); // ... }
당시 적용한 파일은 4개입니다.
- database/_lib/use-virtual-grid.ts
- database/_features/item-list/ui/virtual-item-grid.tsx
- database/equips/_features/equip-list/ui/virtual-level-grid.tsx
- database/equips/_features/equip-list/ui/timeline-view.tsx
전역으로 컴파일러를 끄는 선택지도 있었지만, 나머지 200여 개 컴포넌트는 자동 메모이제이션 이득을 그대로 받고 있어서 4개만 최소 범위로 껐습니다.

재발 방지로 한 것
rules 문서에 컴파일러 옵트아웃이 필요한 라이브러리 목록을 만들었습니다. 원래는 react-hook-form의 watch만 적혀 있어서, react-virtual이 같은 부류라는 걸 놓쳤거든요.
그런데 이 글을 정리하다가, 정작 매물 테이블 컴포넌트에 옵트아웃이 빠져 있는 걸 발견했습니다. 도감 쪽 네 파일만 고치고 같은 훅을 쓰는 다섯 번째 파일을 놓친 거예요. 문서를 만들어놨는데도 전수로 확인을 안 했습니다.
처음엔 문서에 "새로 부를 때 같이 넣자"고 한 줄 적고 끝내려다가, 그게 이번에 안 먹혔다는 걸 방금 확인한 참이라 ESLint로 옮겼습니다.
no-restricted-syntax에 셀렉터 하나를 넣으면 강제됩니다.// packages/eslint-config/rules.mjs { selector: 'FunctionDeclaration:has(CallExpression[callee.name=/^use(Window)?Virtualizer$/])' + ':not(:has(ExpressionStatement > Literal[value="use no memo"]))', message: "❌ useVirtualizer 호출 함수엔 'use no memo' 필수 — 없으면 prod 빌드에서 0행", }
읽는 순서대로 풀면 이렇습니다.
:has()로 "안에서 useVirtualizer를 부르는 함수"를 고르고, :not(:has())로 "그런데 'use no memo'가 없는" 것만 남깁니다. 조건 두 개를 겹쳐서 "A가 있으면 B도 있어야 한다"를 만드는 거예요.테스트 파일 두 개로 확인했습니다. 디렉티브가 있는 쪽은 통과하고, 없는 쪽만 에러가 납니다. 기존 5개 파일은 전부 통과했고요.
이제 디렉티브를 빠뜨리면 저장하는 순간 에디터에 뜨고, pre-commit에서도 막힙니다. 문서를 안 읽어도 걸립니다.
다만 한계가 있습니다.
FunctionDeclaration만 잡아서, 화살표 함수로 훅을 감싸면 안 걸려요. 지금은 다섯 파일이 전부 function 선언이라 괜찮은데, 나중에 누가 화살표로 쓰면 구멍이 생깁니다. 그건 규칙 문서에 적어뒀습니다.그리고 가상화 검증 게이트를 프로덕션 빌드 기준으로 바꿨습니다. 실제로 행이 그려지는지 눈으로 확인하고, dev 통과는 게이트로 인정하지 않기로 했어요.

