React Compiler에서 useVirtualizer 를 분석

린트 경고가 사라지는 순간, 디버깅은 불가능해진다…?

React Compiler를 적용하면서 처음에는 단순히 “어떤 훅이 컴파일 최적화와 잘 맞지 않는다” 정도로 생각했습니다.

그런데 문제를 따라가다 보니, 진짜 불편했던 지점은 최적화가 건너뛰어지는 것 자체가 아니었습니다.

오히려 건너뛰어졌다는 사실을 알려주는 경고까지 사라지는 순간, 문제는 훨씬 더 찾기 어려워졌습니다.

처음에는 useVirtualizer 자체가 문제라고 단정하려 했습니다.

하지만 실제로 파고들수록 핵심은 라이브러리 하나를 비판하는 데 있지 않았고, React Compiler의 진단 신호가 특정 suppression에 의해 함께 사라지는 흐름에 더 가까웠습니다.

따라서 이번 글에서는, 제가 React Compiler를 적용하면서 useVirtualizer 관련 문제를 어떻게 해석했고, 왜 이걸 이슈로 리포팅했으며, 어떤 방향으로 PR을 제안했는지를 기술적으로 정리해보려 합니다.

그리고 마지막에는, merge되지 않은 PR에서도 충분히 남길 만한 기록이 무엇이었는지도 함께 이야기해보겠습니다.

0. 문제제기 useVirtualizer 를 훅에서 사용하는 경우 리스트가 나오지 않는다?

use no memo(메모라이징 스킵)가 있는 아래 사진과 다르게 use no memo가 없이 메모라이징 된 리스트에서는 아이템을 렌더링 하지 못하는 버그가 존재했습니다

useVirtualizer 훅을 래핑하지 않고 컴포넌트 레벨에서 훅을 선언하는 경우에는 문제가 없었습니다.

그렇다면 문제는 !! useVirtualizer 훅을 래핑해서 사용하는 케이스였습니다

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2025-11-10_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_3.05.51.png

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2025-11-10_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_3.05.38.png


1. 먼저 전제부터: incompatible-library는 “에러”가 아니라 “안전장치”다

react-hooks/incompatible-library는, 한마디로 **“이 API는 자동 memoization이랑 같이 쓰면 위험할 수 있어요”**를 알려주는 규칙입니다.

즉 “컴파일러가 실패했다”가 아니라, 안전하지 않으니 최적화를 일부러 건너뛰겠다에 더 가깝습니다.

저도 처음엔 “왜 memoization이 안 되지?”로 접근했는데, 문서를 다시 보니까 관점이 반대였습니다.

  • “이 케이스는 memoization을 하면 안 되기 때문에 skip하는 게 맞다”

React 쪽에서 @tanstack/react-virtual을 이렇게 지정해둔 것도 같은 맥락입니다.

case '@tanstack/react-virtual': {
  return {
    kind: 'object',
    properties: {
      /*
       * Many of the properties of `useVirtualizer()`'s return value are incompatible, so we mark the entire hook
       * as incompatible
       */
      useVirtualizer: {
        kind: 'hook',
        positionalParams: [],
        restParam: Effect.Read,
        returnType: {kind: 'type', name: 'Any'},
        knownIncompatible: `TanStack Virtual's \\`useVirtualizer()\\` API returns functions that cannot be memoized safely`,
      },
    },
  };
}

}


2. 왜 하필 useVirtualizer가 걸렸을까

useVirtualizer는 “React 상태를 순수하게 만들어내는 훅”이라기보단, 외부 인스턴스(virtualizer)를 React에 연결해주는 어댑터에 가깝습니다.

여기서 자주 생기는 구조가 이겁니다.

  • 겉의 참조(virtualizer 객체)는 안정적이고
  • 내부 상태는 계속 변하고
  • 그런데 컴파일러는 “참조가 안 바뀌네? 그럼 결과도 안 바뀌겠지?”로 추론하기 쉬움

TanStack Virtual 이슈 #736에서 말하는 “getVirtualItems()가 초기값처럼 캐시된다”가 딱 이 패턴입니다.

그리고 2026년 1월의 TanStack Virtual 이슈 #1119에서도 ESLint가 useVirtualizer()에 대해

Compilation Skipped: Use of incompatible library 경고를 찍는 사례가 공유됐습니다.

여기서 핵심은,

useVirtualizer가 “나쁜 훅”이라서가 아닙니다.

구조 자체가 React Compiler의 ‘참조 기반’ 추론과 긴장 관계를 만들기 쉬운 형태라는 점이 더 중요합니다.

그래서 React 입장에서는 “안전하게 최적화하지 말자”가 자연스러운 반응이 됩니다.


3. 제가 본 진짜 문제: skip 자체보다 더 위험한 ‘무경고 skip’

처음엔 저도 “useVirtualizer가 컴파일러에서 잘못 최적화되네?”라고 생각했습니다.

근데 파고들수록 더 위험했던 건 이쪽이었습니다.

skip이 되는 건 괜찮다.

문제는, skip이 됐다는 신호(경고)까지 같이 사라지는 순간입니다.

제가 올린 React 이슈 #35105의 포인트는 이겁니다.

  • useVirtualizer 같은 incompatible API를 쓰면 원래 react-hooks/incompatible-library 경고가 떠야 함
  • 그런데 같은 함수 안에 eslint-disable-next-line react-hooks/exhaustive-deps 같은 무관한 suppression이 있으면
  • 그 중요한 경고까지 같이 가려지는 케이스가 있었다

이게 왜 위험하냐면,

개발자 입장에서 lint가 조용하면 보통 “문제 없나 보다”로 받아들이기 쉽기 때문입니다.

근데 실제론 compiler가 그 훅을 최적화 대상에서 빼버렸고, 우리는 그 사실을 모르게 됩니다.

특히 로직이 custom hook 안에 감싸져 있을수록, “왜 여기만 최적화가 안 먹지?”를 추적하는 비용이 급격히 커집니다.


4. 재현 코드: useVirtualizer + suppression이 ‘같은 함수’에 있을 때 터진다

아래 코드는 이슈/PR fixture를 바탕으로 상황을 단순화한 버전입니다.

핵심은 incompatible API 사용 + unrelated eslint suppression 조합입니다.

import { useEffect, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'

function useRows(count: number) {
  const parentRef = useRef<HTMLDivElement | null>(null)

  const virtualizer = useVirtualizer({
    count,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
  })

  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(() => {
    // 어떤 동기화 로직
  }, [])

  return {
    parentRef,
    items: virtualizer.getVirtualItems(),
  }
}

여기서 포인트는 “exhaustive-deps를 끄는 게 잘못이다”가 아니라,

그 suppression이 전혀 다른 종류의 경고까지 같이 먹어버릴 수 있다는 점입니다.

정상이라면 useVirtualizer 사용 지점에서 incompatible-library 진단이 보여야 하고,

개발자는 “아, 이 훅은 compiler가 일부러 skip했구나”로 이해할 수 있어야 합니다.

근데 suppression 때문에 그 신호가 사라지면, 동작은 바뀌는데 이유는 안 보입니다.


5. 이슈 #35105에서 제가 정의한 문제

이슈 제목은 이렇게 잡았습니다.

[Compiler Bug]: eslint-disable incorrectly suppresses incompatible-library warning, causing silent memoization skip

이 이슈에서 제가 비교한 건 두 시나리오입니다.

  • 경고가 보여서 “디버깅 가능한 skip”인 상태
  • 경고가 가려져서 “조용히 skip”되는 상태

이걸 따로 기록해야겠다고 느낀 이유는, 증상이 너무 애매하기 때문입니다.

  • 앱이 바로 깨지진 않고
  • lint도 깨끗해 보이고
  • 특정 custom hook만 조용히 최적화에서 빠질 수 있음

즉, “큰 장애”라기보단 추적 비용이 높은 문제에 가깝습니다.

그래서 저는 이걸 단순 lint edge case가 아니라, DX 관점에서 중요한 진단 누락으로 봤습니다.


6. PR #35124: 해결 방향은 ‘분석을 멈추지 말자’

이슈를 올린 다음, React 저장소에 PR #35124도 같이 올렸습니다.

요지는 간단합니다.

  • suppression이 있다고 해서 함수 전체 분석을 바로 멈추지 말고
  • ESLint mode (noEmit: true)에선 일단 diagnostic으로 기록하고
  • 나머지 분석은 계속 진행하자

핵심 로직은 이런 느낌입니다.

if (suppressionsInFunction.length > 0) {
  if (!programContext.opts.noEmit) {
    return {
      kind: 'error',
      error: suppressionsToCompilerError(suppressionsInFunction),
    }
  }

  logError(
    suppressionsToCompilerError(suppressionsInFunction),
    programContext,
    fn.node.loc ?? null,
  )
}

// 이후에도 계속 분석

이 방식이 제가 보기엔 현실적이었습니다.

build mode의 동작을 크게 흔들지 않으면서도,

적어도 lint/diagnostics 레벨에서는 중요한 경고가 묻히지 않게 해주기 때문입니다.


7. 리뷰 과정에서 배운 점: ‘느낌’ 말고 ‘프로젝트가 믿는 방식’으로 증명하기

리뷰에서 인상적이었던 건 이 포인트였습니다.

“좋다. 그럼 테스트 체계 안에서 보여 달라.”

결국 저는 설명을 정리하고, 로컬에서 yarn snap으로 테스트를 돌린 뒤,

fixture를 업데이트해서 1796 tests passing 상태를 확인했습니다.

여기서 배운 건 단순합니다.

문제를 찾는 것과, 그걸 프로젝트가 받아들일 수 있는 형태로 증명하는 것은 완전히 다른 일입니다.

오픈소스에서 중요한 건 “내가 맞다”가 아니라, 저장소가 신뢰하는 검증 방식으로 말하는 능력이었습니다.


8. 그런데 왜 아직 merge가 안 됐을까

작성 시점(2026년 3월 24일) 기준으로 PR은 reject된 분위기는 아니지만, 그렇다고 merge가 된 것도 아닙니다.

이게 오픈소스의 현실이라고 느꼈습니다.

  • 문제를 잘 찾았다고 바로 반영되는 것도 아니고
  • 수정 방향이 합리적이라고 바로 merge되는 것도 아니고

결국 프로젝트 우선순위/설계 방향/리뷰 사이클 안에서 “타이밍이 맞을 때” 들어갑니다.

그래서 이번 글은 “merge 성공기”라기보단,

문제를 정의하고, 원인을 설명하고, 코드 레벨 수정안을 제시했던 과정 자체를 남기는 기록에 가깝습니다.


9. 이번 경험에서 남은 기술적 정리 (딱 3줄로)

  1. React Compiler의 skip은 실패가 아니라 안전장치

  2. stable reference + mutable internal state 조합은 컴파일러와 충돌하기 쉽다

  3. skip보다 더 위험한 건, skip 신호(경고)가 사라지는 경로


마무리

처음엔 “useVirtualizer가 문제다”라고 결론내리고 싶었습니다.

근데 끝까지 따라가 보니 더 본질적인 문제는 이거였습니다.

React Compiler가 ‘왜’ 최적화를 건너뛰었는지 알려주는 신호가, 특정 suppression 조합에서 같이 사라질 수 있다.

PR이 아직 merge되진 않았지만,

이번 경험 덕분에 React Compiler를 조금 더 정확히 이해했고,

“문제가 있다”는 감각을 “프로젝트가 검토할 수 있는 제안”으로 바꾸는 과정도 배웠습니다.

이 기록은 그 자체로 남길 가치가 있다고 생각합니다.


관련 링크