2026.04.16
as 단언이 메모이제이션을 깨뜨린다?TL;DR
reactCompiler: true환경에서(data ?? []) as 타입[]처럼 assertion을 썼는데 TanStack Query 응답이 바뀌어도 화면이 갱신되지 않는 버그가 발생했다.
const 변수: 타입[] = data ?? []처럼 변수 타입 어노테이션으로 바꾸니 해결됐다. 원인은 단순하지 않고 세 가지 요인이 결합된 결과였다.
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[];
증상은 이랬다:
response에 새 데이터가 들어온 것 확인console.log로 channels에 최신 데이터가 찍힘"데이터는 오고 있는데 렌더가 안 된다"는 가장 디버깅하기 싫은 상황이었다. React DevTools Profiler로 보면 컴포넌트 함수는 실행되는데 결과물은 stale한 상태였다.
우연히 타입 정비 작업을 하다가 발견했다.
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 → ChannelListItemcart-toggle-button, cart-select-all-button: ChannelListItem 타입 적용creator-modal-store: channel: Channel → channelId: string
(목록에서는 서머리만 가지고 있으므로, ID만 전달하고 모달에서 상세 조회)creator-modal 하위 컴포넌트: channelId 기반으로 useChannelDetail 연동mix 컴포넌트: 변경된 Table 제네릭에 맞춰 타입 적용단순한 우회였지만, "왜 as가 문제를 만들었는가?"가
계속 궁금해서 Claude와 함께 근본 원인을 파헤쳐봤다.
결론부터 말하면, 이건 단일 버그가 아니라 세 가지 요인이 결합된 결과였다.
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 공식 문서:
"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 노드를 직접 처리해야 한다. 컴파일러 내부 파이프라인은 이렇다:
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은 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 배열의 참조 변경은 정상 감지된 것이다.
이 문제는 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" 에러.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 노드 타입이 일관되게 처리되지 않는 경우가 존재한다.
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)은 아직 공식 이슈로 보고되지 않은 것으로 보인다.
최소 재현 예제를 만든다면:
as만으로 재현되는지 확인만약 순수 재현이 안 되고 TanStack Query/Table과 결합해야만 재현된다면, 그 자체가 이슈 내용의 일부가 된다 ("세 요인의 결합 버그"). React 팀은 bailout 보고를 환영하는 분위기고, __unstable_donotuse_reportAllBailouts 같은 디버깅 도구도 제공하고 있다.
as 남용 피하기, 제네릭 활용)은 가독성/안전성뿐 아니라 컴파일러 최적화의 정확성에도 영향을 준다.__unstable_donotuse_reportAllBailouts 논의TypeScript 노드 처리 관련:
TanStack Table / Query 호환성 관련: