2026 . 오은

~~~~~~><>

React Compiler 에서 as 단언 사용 문제

2026.04.16

React Compiler에서 as 단언이 메모이제이션을 깨뜨린다?

TL;DR

reactCompiler: true 환경에서 (data ?? []) as 타입[] 처럼 assertion을 썼는데 TanStack Query 응답이 바뀌어도 화면이 갱신되지 않는 버그가 발생했다.

const 변수: 타입[] = data ?? [] 처럼 변수 타입 어노테이션으로 바꾸니 해결됐다. 원인은 단순하지 않고 세 가지 요인이 결합된 결과였다.


1. 문제를 만난 상황

Next.js + React Compiler 19.2.4 + TanStack Query + TanStack Table 조합으로 작업 중에 이상한 증상을 만났다.

const { data: response, isLoading } = useChannelsList(params, {
  placeholderData: keepPreviousData,
  enabled: !storeState.isOpen,
});

const channels = (response?.data ?? []) as Channel[];

증상은 이랬다:

  • ✅ Network 탭에서 fetch 요청은 정상적으로 날아감
  • ✅ TanStack Query devtools에서 response에 새 데이터가 들어온 것 확인
  • ✅ TanStack Table 컴포넌트 내부에서 console.log로 channels에 최신 데이터가 찍힘
  • ❌ 그런데 화면(DOM)은 이전 데이터 그대로

"데이터는 오고 있는데 렌더가 안 된다"는 가장 디버깅하기 싫은 상황이었다. React DevTools Profiler로 보면 컴포넌트 함수는 실행되는데 결과물은 stale한 상태였다.

2. 해결한 방법

우연히 타입 정비 작업을 하다가 발견했다. as 캐스트를 제거하고 변수 타입 어노테이션으로 바꾸니 정상 동작했다.

// ❌ Before: as 단언 — 화면이 갱신되지 않음
const channels = (response?.data ?? []) as Channel[];

// ✅ After: 타입 어노테이션 — 정상 동작
const channels: ChannelListItem[] = response?.data ?? [];

이 발견을 계기로 전체 코드베이스의 타입 체인을 정비했다:

  • types/apis: ChannelSearchData → ChannelListItem으로 이름 변경 (더 명확한 의미)
  • channel-table-columns: ColumnDef<Channel> → ColumnDef<ChannelListItem>
  • Table, TableBody: Channel 하드코딩 → 제네릭 <TRow extends { id: string }>로
  • TableBottomHeader: 제네릭화
  • cart-store: items 타입 Channel → ChannelListItem
  • cart-toggle-button, cart-select-all-button: ChannelListItem 타입 적용
  • creator-modal-store: channel: Channel → channelId: string (목록에서는 서머리만 가지고 있으므로, ID만 전달하고 모달에서 상세 조회)
  • creator-modal 하위 컴포넌트: channelId 기반으로 useChannelDetail 연동
  • mix 컴포넌트: 변경된 Table 제네릭에 맞춰 타입 적용

단순한 우회였지만, "왜 as가 문제를 만들었는가?"가 계속 궁금해서 Claude와 함께 근본 원인을 파헤쳐봤다.

3. 함께 연구하며 밝혀낸 문제 — 세 가지 요인의 결합

결론부터 말하면, 이건 단일 버그가 아니라 세 가지 요인이 결합된 결과였다.

요인 ①: as 단언은 AST에서 표현식을 감싸는 래퍼 노드다

as 단언과 타입 어노테이션은 의미상 비슷해 보이지만, Babel AST 구조가 완전히 다르다.

as 단언 패턴 (const channels = (response?.data ?? []) as Channel[]):

VariableDeclarator
├── id: Identifier("channels")
└── init: TSAsExpression                  ← 표현식을 감싸는 래퍼 노드
    ├── expression: LogicalExpression(??)  ← 실제 연산
    │   ├── left: OptionalMemberExpression(response?.data)
    │   └── right: ArrayExpression([])
    └── typeAnnotation: TSTypeReference("Channel[]")

타입 어노테이션 패턴 (const channels: ChannelListItem[] = response?.data ?? []):

VariableDeclarator
├── id: Identifier("channels")
│   └── typeAnnotation: TSTypeAnnotation  ← 변수 이름에 부착된 메타데이터
└── init: LogicalExpression(??)           ← 깨끗한 표현식, 래퍼 없음

핵심 차이: VariableDeclarator.init이

  • as 패턴에서는 TSAsExpression (실제 표현식을 감싼 래퍼)
  • 어노테이션 패턴에서는 바로 LogicalExpression (깨끗한 표현식)

as는 표현식 레벨에서 작동하는 래핑 노드이므로 컴파일러의 표현식 처리 파이프라인에 직접 개입하지만, 타입 어노테이션은 식별자의 메타데이터에 불과해서 표현식 분석에 관여하지 않는다.

요인 ②: React Compiler는 TypeScript 스트리핑보다 먼저 실행된다

이게 핵심적인 아키텍처 사실이다. React 공식 문서:

"React Compiler must run first in your Babel plugin pipeline."

Babel의 실행 순서 규칙상 플러그인이 프리셋보다 먼저 실행된다. 따라서 babel-plugin-react-compiler(플러그인)는 @babel/preset-typescript(프리셋)보다 먼저 돈다. Next.js의 reactCompiler: true도 마찬가지로, SWC가 컴파일러 적용 대상 파일을 식별한 뒤 Babel 플러그인을 돌리는데 이때 TypeScript 문법이 아직 제거되지 않은 상태다.

그 결과 React Compiler는 TSAsExpression, TSNonNullExpression, TSSatisfiesExpression 등 TypeScript 전용 AST 노드를 직접 처리해야 한다. 컴파일러 내부 파이프라인은 이렇다:

  1. BuildHIR — Babel AST를 HIR(제어 흐름 그래프 + SSA)로 변환
  2. SSA 변환 — 각 변수 할당에 고유 식별자 부여
  3. 타입 추론 — 컴파일러 자체 타입 시스템 (TS 타입 정보는 사용 안 함)
  4. 효과 분석 — Read/Store/Capture/Mutate/Freeze 효과 추론
  5. 리액티브 분석 — 렌더 간 변경 가능한 값 식별
  6. 스코프 발견 — 리액티브 스코프 그룹핑/병합
  7. 코드 생성 — useMemoCache 기반 최적화 코드 출력

TSAsExpression을 처리하긴 한다. BuildHIR의 lowerExpression에서 내부 표현식을 재귀적으로 내려간다.

case "TSAsExpression":
case "TSSatisfiesExpression": {
  let expr = exprPath as NodePath<t.TSAsExpression | t.TSSatisfiesExpression>;
  return lowerExpression(builder, expr.get("expression"));
}

그런데 이 과정에서 TypeCastExpression이라는 HIR 중간 명령어를 생성한다 (PR #32742 참고):

interface TypeCastExpression {
  kind: "TypeCastExpression";
  value: Place;
  typeAnnotation: t.FlowType | t.TSType;
  typeAnnotationKind: 'cast' | 'as' | 'satisfies';
}

이 TypeCastExpression 명령어는 HIR의 나머지 파이프라인을 모두 통과한다.

SSA 변환에서 새 식별자를 받고, 효과 분석에서 효과가 추론되고, 리액티브 분석에서 리액티브 여부가 판정되고, 스코프 발견에서 특정 스코프에 배치된다.

중간 명령어가 하나 더 존재한다는 사실이 리액티브 스코프 경계 계산에 영향을 줄 수 있다.

타입 어노테이션 패턴에서는 이 중간 단계 자체가 없다. init이 바로 LogicalExpression이니 HIR에서 데이터 흐름이 직접적이고 투명하다.

요인 ③: 독립 리액티브 스코프의 === 비교가 무력화된다

React Compiler가 생성하는 코드를 보면 "실행되지만 갱신 안 됨" 증상을 정확히 설명할 수 있다. 컴파일러는 각 리액티브 스코프를 독립 캐시 슬롯으로 변환한다:

const $ = c(6); // 6개 캐시 슬롯

// 스코프 1: channels 계산
let channels;
if ($[0] !== response) {
  channels = response?.data ?? [];
  $[0] = response;
  $[1] = channels;
} else {
  channels = $[1]; // 캐시 반환
}

// 스코프 2: JSX 렌더링
let t0;
if ($[2] !== channels) {  // ← 이 의존성 검사가 핵심
  t0 = <Table data={channels} />;
  $[2] = channels;
  $[3] = t0;
} else {
  t0 = $[3]; // 캐시된 JSX 반환 → DOM 갱신 안 됨!
}

각 스코프는 === 동등 비교로 의존성 변경을 감지한다. as 단언이 만든 TypeCastExpression 중간 명령어가 스코프 경계를 바꿔서, JSX 스코프의 의존성 목록에서 channels(또는 상위 리액티브 소스인 response)가 누락된다면, JSX 스코프는 항상 캐시된 결과를 반환한다.

이게 내가 본 증상과 정확히 일치한다:

관찰설명
Network fetch 정상useQuery는 정상 동작
response에 새 데이터 존재TanStack Query의 structural sharing 정상
console.log에 최신 데이터 출력컴포넌트 함수는 실행됨 (스코프 1은 정상)
DOM 미갱신JSX 스코프(스코프 2)가 의존성 변경을 감지 못함 → 캐시 반환

추가 요인: TanStack Table의 내부 가변성

설상가상으로 TanStack Table은 React Compiler와 공식 비호환이다. React 팀은 DefaultModuleTypeProvider.ts의 블록리스트에 TanStack Table을 추가했다 (PR #31820, #34027). 공식 문서도 "incompatible library"로 명시한다.

핵심 문제는 **내부 가변성(interior mutability)**이다. useReactTable()이 반환하는 테이블 인스턴스는 외부 참조는 동일하게 유지하면서 내부 상태만 변경한다. React Compiler의 === 비교로는 이 변경을 감지할 수 없다.

관련 이슈들:

  • facebook/react#33057 — "React Compiler breaks most functionality of TanStack Table"
  • TanStack/table#5567 — "Table doesn't re-render with new React Compiler + React 19"
  • TanStack/query#9571 — "Referential stability lost when using react-compiler"
  • facebook/react#34211 — "breaks referential stability in @tanstack/react-query"

내 상황에서는 두 문제가 결합되어 있었다. as 단언이 리액티브 스코프 추적을 교란했고, TanStack Table의 내부 가변성이 === 비교를 무력화했다. 타입 어노테이션으로 전환하면서 첫 번째 문제가 해소되어 컴파일러가 의존성을 올바르게 추적하게 됐고, 적어도 channels 배열의 참조 변경은 정상 감지된 것이다.

컴파일러의 TypeScript 처리에는 역사적 공백이 있다

이 문제는 React Compiler가 TypeScript AST 노드를 완벽하게 처리하지 못한다는 더 큰 패턴의 일부다:

  • TSSatisfiesExpression은 2025년 3월에야 추가됨. Issue #29754(2024년 6월)에서 satisfies 연산자가 BuildHIR에서 처리 안 되어 bailout 발생 보고 → PR #32742로 약 9개월 뒤 수정.
  • TSInstantiationExpression은 현재까지도 미처리 (Issue #34358, #31745). lowerReorderableExpression에서 "cannot be safely reordered" 에러.
  • reactwg/react-compiler Discussion #34: TSAsExpression이 ObjectExpression 키 위치에 사용될 때 bailout 발생 보고.

이 패턴의 배경은 React Compiler가 Meta 내부에서 Flow 타입 시스템 기반으로 개발되었다는 점이다. 공식 문서도 인정한다:

"While the compiler does not currently use type information from typed JavaScript languages like TypeScript or Flow, internally it has its own type system."

TypeScript 지원은 점진적으로 추가되고 있고, lowerExpression과 lowerReorderableExpression 등 서로 다른 코드 경로에서 동일한 TS 노드 타입이 일관되게 처리되지 않는 경우가 존재한다.

4. 결론 & 교훈

교훈

React Compiler 환경에서는 as 캐스트 대신 그냥 변수 타입 어노테이션을 사용하자. 이건 단순한 스타일 권장사항이 아니라 AST 구조적으로 컴파일러의 표현식 분석 파이프라인에 개입하지 않는 유일한 방법이다.

// ✅ 권장: 타입 어노테이션 (AST에서 init 표현식이 깨끗)
const channels: ChannelListItem[] = response?.data ?? [];

// ✅ 대안: 제네릭 타입 파라미터로 해결
const { data: response } = useChannelsList<ChannelListResponse>(params, options);
// response.data가 이미 올바른 타입을 가지므로 캐스트 불필요

// ⚠️ satisfies: TypeCastExpression 명령어를 동일하게 생성 → 같은 위험 존재
const channels = (response?.data ?? []) satisfies Channel[];

// ❌ 문제: as 단언 (TSAsExpression 래퍼 노드가 init을 감싸고, TypeCastExpression 생성)
const channels = (response?.data ?? []) as Channel[];

TanStack Table과 같이 쓰는 경우 'use no memo' 디렉티브로 해당 컴포넌트를 컴파일러 최적화에서 제외하는 것도 고려 대상이다.

'use no memo'; // 이 컴포넌트에 대해 React Compiler 비활성화

function ChannelTable({ params }: Props) {
  const { data: response } = useChannelsList(params, { ... });
  const channels: ChannelListItem[] = response?.data ?? [];
  const table = useReactTable({ data: channels, ... });
  // ...
}

이슈 제출 가능성

재현 가능한 최소 예제를 만들 수 있다면 facebook/react 레포지토리에 Component: React Compiler 태그로 이슈를 제출할 만한 내용이다. TypeScript 표현식 래퍼 노드의 처리는 역사적으로 점진적 개선이 이루어져 온 영역이고, 이 특정 상호작용(as 단언 + ?? + TanStack Query response)은 아직 공식 이슈로 보고되지 않은 것으로 보인다.

최소 재현 예제를 만든다면:

  1. Playground(https://playground.react.dev/)에서 두 패턴의 컴파일 결과 차이를 캡처 — 실제로 생성되는 캐시 슬롯과 의존성 배열의 차이를 직접 비교
  2. TanStack Query/Table을 배제한 순수 재현 — React state만 사용해서 as만으로 재현되는지 확인
  3. 최소 조건 좁히기 — optional chaining 없이도 재현되는지, nullish coalescing 없이도 재현되는지 하나씩 제거해보며 최소 트리거 조건 파악

만약 순수 재현이 안 되고 TanStack Query/Table과 결합해야만 재현된다면, 그 자체가 이슈 내용의 일부가 된다 ("세 요인의 결합 버그"). React 팀은 bailout 보고를 환영하는 분위기고, __unstable_donotuse_reportAllBailouts 같은 디버깅 도구도 제공하고 있다.

상위 레벨 교훈

  • React Compiler는 계속 개선 중이다. TypeScript 노드 처리의 완성도가 2025년 3월 이후로도 꾸준히 발전하고 있다.
  • TanStack Table은 React Compiler와 아직 함께 쓰기엔 조심해야 한다. 공식 비호환 라이브러리 목록에 있다.
  • 데이터는 오는데 화면이 안 바뀌면 컴파일러 레벨의 메모이제이션 버그를 의심하자. React DevTools Profiler의 "Why did this render?" 기능으로 진단 가능하다.
  • 타입 체계를 깨끗하게 유지하는 것(as 남용 피하기, 제네릭 활용)은 가독성/안전성뿐 아니라 컴파일러 최적화의 정확성에도 영향을 준다.

5. 참조 리스트

React Compiler 공식 문서

  • React Compiler Introduction
  • React Compiler Installation
  • React Compiler Debugging
  • React Compiler v1.0 Release Blog
  • incompatible-library ESLint rule
  • Next.js: reactCompiler config

내부 설계 문서

  • React Compiler DESIGN_GOALS.md
  • Introducing React Compiler (reactwg)
  • __unstable_donotuse_reportAllBailouts 논의

관련 GitHub 이슈 및 PR

TypeScript 노드 처리 관련:

  • PR #32742 — feat(babel-plugin-react-compiler): support satisfies operator
  • Issue #29754 — Handle TSSatisfiesExpression expressions
  • Issue #34358 — TSInstantiationExpression as default value in parameter list

TanStack Table / Query 호환성 관련:

  • facebook/react#33057 — React Compiler breaks most functionality of TanStack Table
  • facebook/react#34211 — breaks referencial stability in @tanstack/react-query
  • PR #31820 — add tanstack table and virtual to known incompat libraries
  • TanStack/table#5567 — Table doesn't re-render with new React Compiler + React 19
  • TanStack/table#6137 — React Compiler skips memoization for useReactTable
  • TanStack/query#9571 — Referencial stability lost when using react-compiler

도구

  • React Compiler Playground
  • Babel @babel/types (TSAsExpression 노드 정의)

참고한 분석 글

  • Yongseok Jang — React Compiler, How Does It Work? [3] HIR Transformation
  • Yongseok Jang — React Compiler, How Does It Work? [4] SSA Transformation
  • Yuri Shapkarin — The Mutability & Aliasing Model in React
  • Anita — React Compiler and why class objects can work against memoization
  • Lydia Hallie — React Compiler Internals (GitNation talk)