도감 목록에 가상화를 붙이고 프로덕션 빌드로 확인했는데, 카드가 한 장도 안 보였습니다.
개발 서버에선 멀쩡했어요.
원인 찾는 데 꽤 걸렸고, 중간에 한 번은 고쳤다고 착각까지 했습니다. 기록으로 남겨둡니다.
일단 상황부터
도감 장비 목록이 2,800종쯤 됩니다. 처음엔
content-visibility로 버티다가 모바일에서 DOM 노드가 너무 많아져서 @tanstack/react-virtual로 갈아탔어요.그리고 이 프로젝트엔 React Compiler가 켜져 있습니다(
reactCompiler: true). 그래서 useMemo나 useCallback을 손으로 안 씁니다. 이게 이번 사건의 전제입니다.카운트는 1,926인데 카드가 0장입니다
작업 끝내고
next build && next start로 확인했더니 이랬습니다.
필터는 정상이었어요. 직업을 "전사"로 바꾸면 숫자가 456종으로 제대로 바뀝니다. 그런데 카드는 여전히 0장.

콘솔에서 확인한 값입니다.
getVirtualItems() → [] getTotalSize() → 0 document height → 뷰포트와 동일 (스크롤 자체가 안 됨) aria-hidden 스페이서 2개는 렌더됨, 단 height: 0
그리고 dev 서버에선 아무 문제가 없었습니다. 이게 제일 당황스러웠어요.
데이터는 무죄였습니다
- 총 개수 표시 정상 (1,926 → 456으로 필터 반응)
- 네트워크 응답 정상
- 스페이서 div는 DOM에 존재 → 컴포넌트 자체는 렌더되고 있음
데이터는 왔고, 컴포넌트도 돌았는데, virtualizer만 "그릴 게 없다"고 말하는 상황이었습니다. 그럼 count나 측정값을 못 받고 있다는 뜻이죠.
그리고 dev와 prod가 다르다 → 빌드 타임에 코드를 변환하는 무언가가 범인입니다. 후보는 하나뿐이었어요.
React Compiler가 라이브러리 전제를 깼습니다
@tanstack/react-virtual의 React 어댑터를 열어봤습니다.// @tanstack/react-virtual/dist/esm/index.js (요지) instance.setOptions(resolvedOptions); // 매 렌더마다 옵션(count 등) 주입 useIsomorphicLayoutEffect(() => instance._willUpdate()); // 매 렌더마다 측정값 반영
렌더 함수가 실행될 때마다 인스턴스를 직접 변형합니다. interior mutability 패턴이고, "매 렌더 이 코드가 돈다"가 라이브러리의 전제예요.
React Compiler는 정반대를 가정합니다. "이 컴포넌트는 순수하니까 입력이 같으면 렌더를 건너뛰어도 된다."
그래서 컴파일러가 메모이즈하면 이렇게 됩니다.
setOptions/_willUpdate호출이 스킵됨
- virtual-core가
outerSize와measurements를 못 받음
calculateRange()가range = null반환
getVirtualItems()가 빈 배열 → DOM에 행 0개
카운트가 멀쩡했던 건 그 숫자가 virtualizer를 안 거치기 때문이었습니다. dev에서 안 터진 건 컴파일러 최적화가 프로덕션 빌드에서 온전히 걸려서고요.
중간에 한 번 속았습니다
원인을 좁히려고 virtualizer 내부 값을 콘솔에 찍는 디버그 코드를 넣었습니다. 그랬더니 그리드가 정상적으로 그려지더군요.
"고쳐졌나?" 했는데, 디버그 코드를 빼니까 다시 0행이었습니다.
virtualizer의 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); // ... }
훅에만 넣으면 안 됩니다.
useVirtualizer를 호출하는 훅과, 그 출력(getVirtualItems())을 렌더하는 컴포넌트 양쪽 다 필요해요. 이것도 한 번 헤맨 지점입니다.적용한 파일은 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-19-modernization.md에 컴파일러 옵트아웃이 필요한 라이브러리 목록을 만들었습니다. 원래는 react-hook-form의 watch만 적혀 있어서, react-virtual이 같은 부류라는 걸 놓쳤거든요.그리고 가상화 검증 게이트를 프로덕션 빌드 기준으로 바꿨습니다.
next build && next start로 실제 행이 그려지는지 확인하고, dev 통과는 게이트로 인정하지 않기로 했어요.남은 생각
이 버그가 불편했던 건 아무도 잘못하지 않았다는 점입니다.
react-virtual은 매 렌더 실행을 전제하고, React Compiler는 컴포넌트가 순수하다고 전제합니다. 둘 다 자기 세계에선 맞는 말인데, 겹치는 순간 조용히 깨져요. 에러도 경고도 안 뜨고 그냥 화면이 비어 있습니다.
라이브러리 생태계가 컴파일러를 아직 다 따라오지 못한 과도기라, 당분간은 프로덕션 빌드로 눈으로 확인하는 것 말고 더 나은 방법을 못 찾았습니다.
혹시 React Compiler 켜고 가상화 쓰시는 분이라면, dev에서 잘 된다고 넘기지 마시고 한 번은 prod 빌드로 확인해보세요. 빌드 타임 변환이 끼는 순간 dev와 prod는 다른 프로그램입니다. 🙂
