React Compiler가 조용히 최적화를 포기할 때 — Bailout 원인과 탐지 전략 (React Compiler v1.0 기준)
React Compiler v1.0이 2025년 10월에 공식 안정화되면서 저도 팀에서 도입을 시작했습니다. "드디어 수동 useMemo 지옥에서 해방됐다"는 기대감으로요. 그런데 프로덕션에 실제로 붙여보니, 컴파일러가 아무 경고 없이 조용히 특정 컴포넌트 최적화를 통째로 건너뛰는 상황이 생각보다 많더라고요. 처음엔 "어차피 이전 상태로 돌아가는 거 아니야?"라고 넘겼는데, 어느 순간 useEffect가 예상치 못한 시점에 재실행되면서 — 컴파일러 도입 전엔 없던 버그였는데 — 단순한 성능 손실이 아니라는 걸 실감했습니다.
이 현상을 Bailout이라고 부릅니다. 컴파일러가 정적 분석 중 "이 컴포넌트는 안전하게 최적화할 수 없다"고 판단해서 최적화 자체를 포기하는 상황인데, 기본적으로 아무런 경고 없이 침묵하며 발생한다는 것이 핵심 문제입니다. React Compiler 성능 문제나 최적화 실패 원인을 찾고 있다면, Bailout을 탐지하는 체계를 갖추는 게 컴파일러를 켜는 것만큼이나 중요합니다.
핵심 개념
React Compiler가 최적화를 건너뛰는 이유
React Compiler는 컴포넌트와 훅을 빌드 타임에 정적으로 분석해서 useMemo, useCallback, memo를 자동으로 삽입합니다. 이 분석이 성립하려면 세 가지 전제 — 렌더 중 사이드 이펙트가 없을 것, 렌더 함수가 순수할 것, props와 state는 불변으로 취급할 것 — 가 지켜져야 합니다. 이걸 "Rules of React"라고 부릅니다.
코드를 정적으로만 봤을 때 이 전제가 지켜지는지 확인할 수 없는 상황이 생기면, 컴파일러는 "모르면 건드리지 않는다"는 원칙으로 해당 컴포넌트·훅의 최적화를 통째로 건너뜁니다.
Bailout이란? 컴파일러가 정적 분석 과정에서 최적화의 안전성을 보장할 수 없을 때, 해당 컴포넌트·훅의 최적화를 완전히 건너뛰는 상황. 기존 React 동작으로 폴백(fallback)되지만, 기본적으로 아무런 경고 없이 조용히 발생한다.
// 컴파일러가 최적화하는 이상적인 케이스
function GoodComponent({ items }) {
const sorted = [...items].sort(); // 새 배열 반환 → 안전
return <List data={sorted} />;
}
// 컴파일러가 bailout하는 케이스
function BadComponent({ items }) {
items.sort(); // 원본 배열 변형 → Rules of React 위반 → bailout
return <List data={items} />;
}Bailout의 세 가지 종류
| 종류 | 발생 시점 | 대표 사례 |
|---|---|---|
CannotPreserveMemoization |
컴파일러가 삽입하려는 메모이제이션의 안전성을 정적으로 보장할 수 없을 때 | 복잡한 의존성 체인, 기존 useMemo와의 충돌 |
UnsupportedSyntax |
흐름 추적이 불가능한 구문을 만났을 때 | try/catch 내 복잡한 조건 분기 |
IncompatibleLibrary |
컴파일러의 불변 참조 가정과 충돌하는 외부 라이브러리를 사용할 때 | MobX observer()¹, React Hook Form watch() |
¹
observer()는 MobX의 반응형 래퍼 함수로, observable 객체의 변형을 감지해 컴포넌트를 리렌더링시킵니다. 이 "직접 변형" 모델이 컴파일러의 불변 가정과 충돌합니다.
한 가지 오해를 짚고 넘어가고 싶은 게 있습니다. CannotPreserveMemoization이 "기존 useMemo를 이미 직접 쓴 경우에만 발생한다"고 생각하기 쉬운데, 실제로는 훨씬 넓은 개념입니다. 컴파일러가 자신이 삽입하려는 메모이제이션의 정확성을 정적으로 검증하지 못하는 상황 전반에서 발생하고, 기존 useMemo와의 충돌은 그 하위 사례 중 하나일 뿐입니다. "내가 useMemo를 안 쓰면 이 bailout은 없겠구나"라고 판단하면 놓치는 케이스가 생길 수 있습니다.
Silent Failure가 실제로 어떤 문제를 만드나
저도 처음엔 "어차피 이전 최적화 안 한 상태로 돌아가는 거 아니야?"라고 생각했습니다. 그런데 두 가지 이유에서 그렇게 단순하지 않습니다.
첫째, 컴파일러를 믿고 기존 useMemo/useCallback을 이미 걷어낸 뒤에 bailout이 발생하면, 이전보다 오히려 성능이 나빠집니다. 둘째, 더 심각한 건 정확성 버그입니다. 컴파일러가 이전과 다른 방식으로 메모이제이션을 구성하면, 참조 동일성에 의존하던 useEffect가 예상치 못한 시점에 재실행될 수 있습니다.
참조 동일성(Referential Equality)이란? JavaScript에서 객체나 배열은 내용이 같아도 다른 참조면
!==로 평가됩니다.useEffect의 의존성 배열은 참조 동일성으로 비교하기 때문에, 컴파일러가 메모이제이션 구조를 바꾸면 "같은 값"인데도 effect가 재실행될 수 있습니다.
실전 적용 — Bailout 패턴
예시 1: Props/State 직접 변형 — 가장 흔하게 마주치는 bailout
실무에서 가장 자주 맞닥뜨리는 상황입니다. 폼이나 편집 UI를 구현하면서 무심코 props 객체를 직접 수정하는 패턴이 종종 남아있습니다.
// ❌ bailout: props 객체 직접 변형
function BadForm({ user }) {
user.name = 'edited'; // props를 직접 변형 → Rules of React 위반 → bailout
return <input value={user.name} />;
}
// ✅ useState로 로컬 편집 상태를 별도 관리
function GoodForm({ user }) {
const [name, setName] = useState(user.name);
return <input value={name} onChange={e => setName(e.target.value)} />;
}| 포인트 | 내용 |
|---|---|
| 원인 | user.name = ...은 props 객체를 직접 변형 |
| 컴파일러 반응 | 해당 컴포넌트 전체 bailout |
| 해결 방향 | useState로 편집 값을 로컬 상태로 관리 |
예시 2: 렌더 중 ref.current 읽기
useRef는 렌더 사이클과 무관하게 변할 수 있어서, 컴파일러가 의존성을 추적할 방법이 없습니다. 이 케이스에서 짚어둘 점은, 외부에서 ref를 props로 받아 렌더 중 읽는 상황입니다. ref.current를 내부화하는 게 해결책이 아니라, 외부에서 내려주는 값 자체를 일반 prop이나 state로 바꾸는 게 맞습니다.
// ❌ bailout: props로 받은 ref를 렌더 중 읽기
function Counter({ countRef }) {
const doubled = countRef.current * 2; // 의존성 추적 불가 → bailout
return <div>{doubled}</div>;
}
// ✅ ref 대신 일반 값을 prop으로 전달
function Counter({ count }) {
const doubled = count * 2;
return <div>{doubled}</div>;
}ref 접근은 이벤트 핸들러나 useEffect 내부에서 이루어지도록 구조를 바꾸는 것이 일반적인 방향입니다.
예시 3: 렌더 중 setState 호출 — try/catch 안에서도 마찬가지
이건 bailout 원인이 두 가지로 구분되어야 하는 케이스입니다. 저도 처음에 "그냥 try/catch가 문제네"라고 뭉뚱그려 생각했는데, 실제론 두 가지 별개의 위반입니다.
// ❌ bailout 원인 A: 렌더 함수 내에서 직접 setState 호출
function DataFetcher({ cache }) {
let data = null;
try {
data = cache.get();
} catch (e) {
setError(e); // 렌더 중 state 변경 → Rules 위반
}
return <View data={data} />;
}
// ❌ bailout 원인 B: try/catch 내 복잡한 조건 분기
function DataFetcher({ cache }) {
let data = null;
try {
if (!cache.isValid() && cache.hasBackup()) {
// 복잡한 분기 → 컴파일러가 흐름 추적을 포기
data = cache.getBackup();
} else if (cache.isPending()) {
data = cache.lastKnown;
}
} catch (e) {
data = null;
}
return <View data={data} />;
}원인 A는 렌더 함수 자체의 순수성 위반이고, 원인 B는 분기 복잡도로 인한 정적 분석 포기입니다. 두 위반을 한 컴포넌트에서 동시에 만나면 어느 쪽이 bailout을 유발했는지 분리하기 어려우니, 하나씩 수정해가며 확인해보는 것을 권장합니다.
주의: try/catch silent bailout 버그 (2026년 1월 발견) 컴포넌트 본문에
try/catch/finally블록이 하나라도 있으면, 해당 컴포넌트의 ESLint 에러 보고 전체가 멈추는 버그가 현재 이슈(facebook/react #35644)로 등록되어 있습니다. ESLint를 믿을 수 없는 구간이 생긴다는 뜻이라,try/catch가 포함된 컴포넌트는 별도로 수동 감사하거나'use no memo'로 임시 제외해두는 것이 현실적입니다.
예시 4: MobX observer() — 구조적 비호환
MobX는 observable 객체를 직접 변형하는 방식으로 반응성을 구현합니다. React Compiler의 "불변 참조" 가정과 정면으로 충돌하는 구조입니다. 실제로 MobX를 쓰는 팀과 이 문제를 같이 들여다봤는데, MobX 메인테이너도 "fundamentally conflicting model"임을 인정한 상황이었고(공식 GitHub 이슈 #3874), 단기 해결책이 없다는 걸 납득할 수밖에 없었습니다.
observer()로 래핑된 컴포넌트는 컴파일러가 전체 최적화를 건너뜁니다. 현실적인 선택지는 두 가지입니다.
'use no memo'로 명시적으로 opt-out 처리 (단기)useSyncExternalStore² 기반으로 동작하는 Zustand로 점진적 마이그레이션 (장기)
²
useSyncExternalStore는 React 18에서 추가된 외부 스토어 구독 훅입니다. 불변 스냅샷 방식으로 상태를 읽기 때문에 컴파일러와 완전히 호환됩니다.
예시 5: React Hook Form watch() — incompatible-library 감지
watch() API는 내부적으로 가변 구독(mutable subscription) 방식을 사용합니다. incompatible-library³ lint 경고가 발생하고 bailout으로 이어집니다.
// ❌ 컴파일러가 incompatible-library로 감지
const { watch } = useForm();
const value = watch('fieldName'); // 내부 가변 구독 → bailout
// ✅ Controller 패턴으로 전환
<Controller
name="fieldName"
control={control}
render={({ field }) => <Input {...field} />}
/>³
incompatible-library는eslint-plugin-react-compiler가 제공하는 린트 규칙으로, 컴파일러와 구조적으로 호환되지 않는 외부 라이브러리 사용을 감지합니다.
예시 6: 'use no memo' — 임시 탈출구 활용법
버그 원인을 좁혀가는 과정에서 "컴파일러가 문제인지 코드가 문제인지"를 판별할 때 유용한 지시어입니다.
function DebugComponent() {
'use no memo'; // 이 컴포넌트만 컴파일러 최적화 완전 제외
return <ComplexView />;
}
'use no memo'는 임시 탈출구입니다."use client"처럼 영구 선언이 아니라, 버그 원인을 확인한 후 제거해야 하는 디버깅 도구입니다. 코드리뷰에서 이 지시어가 보이면 PR 설명에 제거 계획이 있는지 확인해보시면 좋습니다.
Bailout 탐지 전략
ESLint 설정 강화
탐지의 첫 번째 레이어는 ESLint입니다. eslint-plugin-react-compiler를 설치하고, 기본값이 warn인 규칙들을 error로 격상해야 빌드 차단이 가능합니다. 아래는 확인 시점(2025년 10월) 기준으로 권장되는 설정이며, 플러그인 버전에 따라 규칙 이름이 달라질 수 있으니 공식 ESLint 플러그인 문서에서 최신 규칙명을 확인하는 것을 권장합니다.
{
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
}숨겨진 bailout까지 보이게 하려면 __unstable_donotuse_reportAllBailouts: true 옵션을 추가할 수 있습니다.
주의:
__unstable_donotuse_reportAllBailouts는 이름 자체가 불안정 API임을 경고합니다. 개발 환경에서만 활성화하고 프로덕션 빌드에는 포함시키지 않는 것을 권장합니다.
CI에서 Bailout 회귀 탐지
ESLint만으론 모든 bailout을 잡을 수 없습니다. babel-plugin-react-compiler의 logger 옵션을 활용하면 CI 파이프라인에서 CompileSkip 이벤트를 추적할 수 있습니다.
// babel.config.js (Babel 기반 프로젝트)
const BabelPluginReactCompiler = require('babel-plugin-react-compiler');
module.exports = {
plugins: [[BabelPluginReactCompiler, {
logger: {
logEvent(filename, event) {
if (event.kind === 'CompileSkip') {
process.stderr.write(`BAILOUT: ${filename} — ${event.fnName}\n`);
}
}
}
}]]
};Vite 기반 프로젝트라면 @vitejs/plugin-react v6에서 동등한 설정을 적용할 수 있습니다.
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react({
babel: {
plugins: [['babel-plugin-react-compiler', {
logger: {
logEvent(filename, event) {
if (event.kind === 'CompileSkip') {
process.stderr.write(`BAILOUT: ${filename} — ${event.fnName}\n`);
}
}
}
}]]
}
})]
});한 가지 주의할 점은 event.kind === 'CompileSkip'이 내부 API 값이라는 겁니다. 버전 업그레이드 시 이 값이 변경되면 CI 탐지가 조용히 멈출 수 있으니, 컴파일러 버전을 올릴 때 로그 출력이 여전히 동작하는지 확인해보는 게 좋습니다.
장단점 분석
솔직히 장단점 표를 보면 단점 항목이 많아서 "이거 그냥 안 쓰는 게 낫겠다"는 생각이 들 수도 있습니다. 그런데 맥락이 중요합니다. 컴파일러를 도입할 때의 실질적인 질문은 "bailout이 없는가"가 아니라 "bailout을 탐지하고 관리하는 체계가 있는가"입니다.
장점
| 항목 | 내용 |
|---|---|
| 수동 작업 감소 | useMemo/useCallback/memo를 직접 작성하지 않아도 됨 |
| 점진적 적용 | 컴포넌트 단위로 opt-out 가능해 기존 코드베이스에 안전하게 도입 가능 |
| 누락 방지 | 사람이 놓친 최적화 지점도 컴파일러가 자동으로 커버 |
| 도구 생태계 | ESLint 플러그인, logger 옵션 등 탐지 도구가 빠르게 성숙 중 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Silent Bailout | 최적화 실패가 기본적으로 경고 없이 발생 | logger 옵션 + __unstable_donotuse_reportAllBailouts (개발 환경 한정) |
| ESLint 의존 | 탐지를 위해 eslint-plugin-react-compiler 필수 설정 |
규칙을 error로 격상해 빌드 차단 |
| try/catch 버그 | try/catch 블록이 있으면 ESLint 감지 자체가 비활성화됨 |
해당 컴포넌트 수동 감사 또는 'use no memo' 처리 |
| 라이브러리 호환성 | MobX, React Hook Form 등 다수 라이브러리가 비호환 | 호환 대안으로 마이그레이션 또는 명시적 opt-out |
| 정확성 버그 | 참조 동일성에 의존하던 useEffect가 예상 외로 실행될 수 있음 |
'use no memo'로 opt-out 후 근본 원인 수정 |
| 동적 프로퍼티 접근 | obj[dynamicKey] 패턴은 정적 분석 불가로 bailout |
가능하면 정적 키 접근으로 변경 |
실무에서 가장 흔한 실수
-
ESLint 경고를
warn으로 그냥 두는 것 — 기본값이warn이면 빌드가 통과되고 bailout이 조용히 쌓입니다.error로 격상해서 PR 단계에서 막는 것이 효과적입니다. -
'use no memo'를 임시가 아닌 영구 해결책으로 쓰는 것 — 원인 분석 없이 지시어만 붙이기 시작하면 컴파일러 도입 효과가 점점 희석됩니다. 붙였다면 근본 원인을 찾을 때까지 티켓을 열어두는 것을 권장합니다. -
라이브러리 호환성 확인 없이 전체 적용하는 것 — MobX, TanStack Query 일부 API 등 비호환 라이브러리가 포함된 컴포넌트는 사전 감사 없이 컴파일러를 켜면 조용한 bailout이 대량 발생할 수 있습니다. 먼저 ESLint로
incompatible-library경고를 수집하고 마이그레이션 우선순위를 정한 뒤 적용하는 순서가 안전합니다.
# incompatible-library 경고 목록 수집
npx eslint src --rule 'react-compiler/react-compiler: warn' 2>&1 | grep incompatible-library마치며
React Compiler의 효과를 실제로 누리려면 최적화를 켜는 것만큼, Bailout을 탐지하고 추적하는 체계를 함께 갖추는 것이 중요합니다.
저는 CI에 bailout 로그를 먼저 붙인 뒤 결과를 봤는데, 예상보다 훨씬 많은 컴포넌트가 CompileSkip 되고 있었습니다. 한동안 ESLint 설정과 코드 패턴을 들여다보며 하나씩 줄여가는 과정이 필요했는데, 그게 오히려 코드베이스의 숨겨진 패턴들을 들여다보는 좋은 기회가 됐습니다.
지금 바로 시작해보시면 좋을 3단계를 제안합니다.
-
ESLint 규칙을
error로 격상합니다..eslintrc에eslint-plugin-react-compiler를 추가하고 규칙을error수준으로 올리면, 새로운 bailout 패턴이 PR 단계에서 걸립니다. -
CI에 bailout 로그를 붙입니다. 위
babel.config.js(또는vite.config.js) 예시를 적용하면 어떤 파일의 어떤 함수가 최적화를 건너뛰는지 CI 로그에서 확인됩니다. 신규 PR에서 bailout 수가 늘어나는지 추적하는 것만으로도 회귀를 초기에 잡을 수 있습니다. -
현재 코드베이스의 호환성을 점검합니다. 위 ESLint 명령어로
incompatible-library경고를 수집하고, MobX나 React Hook Formwatch()등 비호환 라이브러리를 사용하는 컴포넌트 목록을 확인한 뒤 마이그레이션 우선순위를 정하면 됩니다.
참고 자료
- React Compiler 공식 문서 | react.dev
- React Compiler v1.0 릴리즈 노트 | react.dev
- 'use no memo' 지시어 레퍼런스 | react.dev
- incompatible-library ESLint 규칙 | react.dev
- React Compiler 디버깅 가이드 | react.dev
- React Compiler의 Silent Failure와 해결 방법 | acusti.ca
__unstable_donotuse_reportAllBailouts논의 | reactwg GitHub- React Hook Form과 React Compiler 비호환 이슈 분석 | zenn.dev
- MobX React Compiler 비호환 이슈 | GitHub #3874
- try/catch silent bailout 버그 | facebook/react #35644
- React Compiler Configuration 레퍼런스 | react.dev