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

콘솔에서 확인한 값:
getVirtualItems() → [] getTotalSize() → 0 document height → 뷰포트와 동일 (스크롤 자체가 안 됨) aria-hidden 스페이서 2개는 렌더됨, 단 height: 0
그리고 dev 서버에선 아무 문제가 없었다. 이게 제일 당황스러웠다.
범위 좁히기
일단 데이터 계층은 무죄였다.
- 총 개수 표시 정상 (1,926 → 456으로 필터 반응)
- 네트워크 응답 정상
- 스페이서 div는 DOM에 존재 → 컴포넌트 자체는 렌더되고 있음
즉 데이터는 왔고, 컴포넌트도 돌았는데, virtualizer만 "그릴 게 없다"고 말하는 상황. 그럼 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개 파일만 최소 범위로 껐다.
수정 후, 모바일 375px에서 그리드가 정상적으로 그려진다.

재발 방지
rules/react-19-modernization.md에 컴파일러 옵트아웃 필수 라이브러리 목록을 만들었다. 기존엔 react-hook-form의 watch만 적혀 있어서 react-virtual이 같은 부류라는 걸 놓쳤다가상화 관련 검증 게이트를 프로덕션 빌드 기준으로 바꿨다.
next build && next start로 실제 행이 그려지는지 확인. dev 통과는 게이트로 인정하지 않는다새 가상화 컴포넌트에서 디렉티브 누락을 잡는 ESLint 규칙 (검토 중)
남은 생각
이 버그가 불편했던 건 아무도 잘못하지 않았다는 점이다.
react-virtual은 매 렌더 실행을 전제로 하고, React Compiler는 컴포넌트가 순수하다고 전제한다. 둘 다 자기 세계에선 맞는 말인데, 겹치는 순간 조용히 깨진다. 에러도 경고도 안 뜬다. 그냥 화면이 비어 있을 뿐이다.
라이브러리 생태계가 컴파일러를 아직 다 따라오지 못한 과도기라서, 당분간은 프로덕션 빌드로 눈으로 확인하는 것 말고 더 나은 방법을 못 찾았다.
그리고 하나 더 — dev에서 되니까 됐다고 생각했던 게 제일 큰 실수였다. 빌드 타임 변환이 끼는 순간 dev와 prod는 다른 프로그램이다.
