
Next.js는 어떻게 React Compiler를 돌리는가 SWC와 Babel의 현명한 공존
React Compiler가 드디어 안정화 후보로 공개되면서 개발자 커뮤니티가 술렁였는데요.
Next.js는 v15에서 이를 실험적으로 지원하기 시작했고, 많은 분들이 바로 이런 질문을 던졌죠.
Next.js는 SWC로 갈아탄 지 오래인데, Babel 플러그인인 React Compiler는 대체 어떻게 끼워 넣는 거예요.
표면만 보면 모순처럼 보이지만, 속살을 보면 꽤 단정한 해법이 숨어있습니다.
이번 글은 2025년 8월 22일 시점의 Next.js Canary 소스 코드를 기준으로, React Compiler가 Next.js에서 어떤 경로로 실행되는지 코드 레벨로 풀어보려는 거예요.
결론부터 말하면 'SWC와 Babel의 하이브리드 전략'이 핵심이고, 그 사이를 이어주는 얇고 빠른 러스트 판별기가 주인공이죠.
결론부터 정리하는 전체 그림
Next.js는 먼저 SWC로 소스를 파싱해 AST를 만들고, '이 파일이 React Compiler를 적용해야 하는지'를 빠르게 판별하는 전처리를 수행해요.
판별이 참일 때만 Babel 로더를 통해 React Compiler의 Babel 플러그인을 주입하고, 나머지는 SWC 파이프라인을 그대로 탑니다.
즉, 모든 파일을 무지성으로 Babel에 밀어 넣지 않고, '필요한 파일만' Babel을 경유하도록 가려내는 구조죠.
SWC가 가진 속도를 해치지 않으면서, Babel 플러그인으로만 제공되는 React Compiler를 무리 없이 끼워 넣는 방식입니다.
왜 이 전략이 필요한가
Next.js는 SWC 기반 트랜스파일과 최적화로 빌드 시간이 크게 줄었는데요.
여기에 Babel 단계가 넓게 끼어들면 속도가 다시 역주행할 위험이 있죠.
React Compiler가 아직 Babel 플러그인 형태라면, '진입 면적'을 최소화하는 설계가 절대적으로 필요해집니다.
그 해답이 바로 러스트로 구현된 '후보 판별기'인데요.
AST를 얕게 스캔해 후보를 좁히고, 그때만 Babel을 통과시키는 전술이 체감 성능을 견고하게 지켜줘요.
코드를 따라가 보기
Next.js는 SWC의 커스텀 트랜스폼으로, 한 파일이 React Compiler 대상인지 빠르게 판단합니다.
대문자로 시작하는 함수명처럼 전형적인 컴포넌트 네이밍, 'use'로 시작하는 훅스 스타일 함수, default export된 함수, 그리고 무엇보다 파일 내부의 JSX 사용 여부를 근거로 삼는 흐름이죠.
이 판별은 '거짓 부정'을 최대한 줄이는 방향으로 설계되어 있더라고요.
즉, 애매하면 일단 후보로 보고 Babel을 태우는 쪽으로 기운다는 얘기죠.
SWC에서의 후보 판별 러스트 스케치
실제 코드는 프로젝트 사정과 시점에 따라 변동될 수 있는데요.
핵심 아이디어만 러스트로 옮겨보면 대략 이런 형태로 이해할 수 있어요.
use swc_core::ecma::ast::*;
use swc_core::ecma::visit::{Visit, visit_program};
struct Detector {
has_jsx: bool,
has_reacty_fn: bool,
has_default_export_fn: bool,
}
impl Detector {
fn new() -> Self {
Self { has_jsx: false, has_reacty_fn: false, has_default_export_fn: false }
}
fn result(&self) -> bool {
self.has_jsx && (self.has_reacty_fn || self.has_default_export_fn)
}
}
impl Visit for Detector {
fn visit_jsx_element(&mut self, _n: &JSXElement) {
self.has_jsx = true;
}
fn visit_jsx_fragment(&mut self, _n: &JSXFragment) {
self.has_jsx = true;
}
fn visit_fn_decl(&mut self, n: &FnDecl) {
if let Some(ident) = &n.ident.as_ref().into() {
let name = ident.sym.as_ref();
let looks_component = name.chars().next().map(|c| c.is_uppercase()).unwrap_or(false);
let looks_hook = name.starts_with("use");
if looks_component || looks_hook {
self.has_reacty_fn = true;
}
}
}
fn visit_export_default_decl(&mut self, n: &ExportDefaultDecl) {
if matches!(n.decl, DefaultDecl::Fn(_)) {
self.has_default_export_fn = true;
}
}
}
pub fn should_compile_for_react_compiler(module: &Program) -> bool {
let mut d = Detector::new();
visit_program(&mut d, module);
d.result()
}
여기서 중요 포인트는 최종 반환값이 불리언 하나라는 점이에요.
AST 전체를 JS로 역직렬화해 넘기지 않고, 러스트가 판단만 끝내서 '해야 한다' 혹은 '안 해도 된다'의 신호만 건넨다는 거죠.
러스트 판별 로직을 TS에서 부르기
Next.js는 napi 기반 바인딩을 통해 위 같은 러스트 함수를 TS에서 호출합니다.
호출 시 파일 내용과 경로 정도만 건네고, 결과는 true 또는 false로만 받는 가벼운 경로가 유지돼요.
// pseudo binding
import { shouldCompileForReactCompiler } from '@next/swc-native';
export function decideReactCompiler(filename: string, src: string): boolean {
return shouldCompileForReactCompiler(src, filename);
}
중간에 AST를 주고받지 않기 때문에 프로세스 경계를 넘는 비용이 극히 작습니다.
이 덕분에 대규모 코드베이스에서도 판별 단계가 병목이 되지 않죠.
Babel 로더에 React Compiler를 동적으로 꽂기
판별 결과가 참이면 그때서야 Babel 로더 설정에 React Compiler 플러그인을 주입합니다.
거짓이라면 SWC만 태우고 완료되는 구조라서, Babel이 전체 빌드를 느리게 만들 여지가 확 줄어들죠.
// pseudo webpack rule builder inside Next.js
function buildJsTsRule(ctx) {
return {
test: /\.(js|jsx|ts|tsx)$/,
issuer: { not: [/node_modules/] },
use: [
{
loader: 'next-swc-loader',
options: { /* swc options */ },
},
{
loader: 'babel-loader',
options: (source, filename) => {
const plugins = [];
const should = decideReactCompiler(filename, source);
if (should) {
plugins.push(require.resolve('babel-plugin-react-compiler'));
}
return { presets: [require.resolve('next/babel')], plugins };
},
},
],
};
}
여기서도 자주 쓰이는 최적화가 보이는데요.
node_modules는 애초에 제외하고, 내부 앱 소스만 후보로 삼는 점이 대표적이죠.
또 하나, 프로젝트가 독자적인 .babelrc를 두고 있다면 Next.js는 그 설정을 우선하므로 React Compiler 플러그인은 직접 추가해야 합니다.
실제 설정은 어떻게 켜나
Next.js의 실험 플래그에서 React Compiler를 켜는 설정은 다음처럼 단출합니다.
버전과 채널에 따라 속성명이 바뀔 수 있으니, 사용 중인 Next.js 문서를 한 번 더 확인하는 습관이 좋아요.
// next.config.js
module.exports = {
experimental: {
reactCompiler: true,
},
};
만약 .babelrc를 이미 사용 중이라면 플러그인을 직접 넣어야 하는데요.
Next.js가 사용자 설정을 존중하기 때문에, 이렇게 해야 Next의 동적 주입과 충돌하지 않아요.
{
"presets": ["next/babel"],
"plugins": [
["babel-plugin-react-compiler", { "target": "18" }]
]
}
여기서 옵션 스킴은 플러그인 버전에 따라 달라질 수 있습니다.
실제 프로젝트에서는 패키지 릴리스 노트를 확인하고, 팀 내 바벨 사용 범위와 충돌이 없는지 검증하는 편이 안전하죠.
대상 파일의 실제 변환은 어떻게 보이나
구체적인 트랜스폼 산출물은 버전에 민감한 영역인데요.
큰 흐름만 잡아보면, 컴포넌트 내부에서 불필요하게 재생성되는 객체 리터럴이나 핸들러가 안정적으로 재사용되도록 코드를 재배치하거나, 의존성 경계를 정밀하게 추적해 불필요한 리렌더를 건너뛰도록 만드는 쪽으로 최적화가 이뤄집니다.
예를 들어 다음처럼 인라인 핸들러와 객체를 매번 새로 만드는 코드는, 컴파일러 통과 후에 참조 안정성이 생기도록 재작성될 수 있어요.
핵심은 개발자가 일일이 useMemo나 useCallback을 둘러치지 않아도 된다는 점이죠.
// before
export default function Button({ onClick, label }: { onClick: () => void; label: string }) {
const style = { padding: 8, borderRadius: 6 };
return <button style={style} onClick={() => onClick()}>{label}</button>;
}
// after (sketch)
export default function Button({ onClick, label }: { onClick: () => void; label: string }) {
const style = /* hoisted or memoized by compiler */ { padding: 8, borderRadius: 6 };
const handle = /* memoized closure */ onClick;
return <button style={style} onClick={handle}>{label}</button>;
}
이 스케치는 개념을 보여주기 위한 것이고, 실제 출력물은 플러그인 버전과 실험 플래그에 따라 달라질 수 있습니다.
파일이 후보로만 판정되고 결과적으로 변환이 거의 일어나지 않는 경우도 있으니, 팀 레벨에서는 '관측 기반'으로 체크하는 게 좋아요.
성능 관점에서의 이점과 함정
하이브리드 전략의 첫 장점은 'Babel 경유 면적'의 축소인데요.
전체 파일을 몰아서 Babel에 태우지 않고, 러스트로 빠르게 좁힌 후보만 통과시키므로 빌드 시간이 안정적으로 유지됩니다.
판별 결과가 불리언 하나라는 것도 중요해요.
AST를 직렬화해 옮기지 않기에 IPC 비용이 사실상 무시할 수 있는 수준으로 떨어지죠.
다만 .babelrc를 이미 쓰는 프로젝트라면, Next.js가 동적으로 플러그인을 끼우는 경로가 비활성화될 수 있어요.
이때는 플러그인을 명시적으로 추가하고, 캐시 디렉터리나 플러그인 버전을 빌드 캐시 키에 포함시켜 흔들림을 줄이는 운영 팁이 필요합니다.
개발 모드에서는 변환의 일관성과 HMR 상호작용을 함께 보셔야 해요.
특히 플러그인 업데이트 시 캐시가 엇갈리면 애매한 재빌드가 발생할 수 있는데, 이럴 땐 일시적으로 캐시를 비우고 기준을 재정렬하는 편이 빠르게 치유되죠.
엣지 케이스는 어떻게 다루나
대문자로 시작하지만 사실은 일반 함수인 경우처럼 경계가 모호한 케이스가 있겠죠.
이 판별기는 보통 '의심되면 후보로 포함'하는 쪽으로 기울어 있으며, 실제 변환 단계에서 안전하게 아무 일도 하지 않고 지나가는 경로가 존재합니다.
JSX를 전혀 쓰지 않는 유틸 파일은 대개 후보에서 제외돼요.
또한 node_modules는 원칙적으로 제외 대상이라, 서드파티 의존성까지 전부 컴파일러 범위에 넣는 실수를 피할 수 있죠.
앞으로의 로드맵을 가늠해보기
Babel과 SWC가 나란히 달리는 이 체계는 '과도기 해법'에 가깝습니다.
자연스럽게 떠오르는 결말은 'SWC 네이티브 React Compiler'의 등장이죠.
공식 일정이 박혀 있진 않지만, 보통은 Babel 플러그인의 일반 제공과 채택 확대, 사양의 사실상 표준화, 그리고 네이티브 SWC 플러그인의 본격 개발로 이어지는 길을 밟게 마련이에요.
그때까지는 오늘 살펴본 하이브리드 전략이 가장 현실적인 선택지가 됩니다.
실제 코드를 더 보고 싶다면
이번 변화의 대부분은 다음 PR에서 추가된 것으로 확인할 수 있어요.
https://github.com/vercel/next.js/pull/75605.
버전과 채널에 따라 파일 경로나 함수명이 달라질 수 있으니, 같은 개념을 찾는다는 마음으로 읽어보면 길을 잃지 않죠.
러스트 쪽의 얕은 AST 스캔과 TS 바인딩, Babel 로더 주입의 세 박자를 기억해두면 구조가 한눈에 들어옵니다.
마무리
정리하면 이렇습니다.
Next.js는 SWC를 전면에 세우고, 러스트로 빠르게 후보를 가려낸 다음, 정말 필요한 파일에만 Babel 기반 React Compiler를 꽂아 넣는 하이브리드 전략을 씁니다.
덕분에 빌드 속도를 지키면서도 최신 최적화를 누릴 수 있는데요.
프로젝트에 .babelrc가 있다면 플러그인 추가를 직접 챙기고, 캐시 키와 버전 관리를 분명히 해두면 실무에서의 마찰이 크게 줄어들죠.
SWC 네이티브 구현이 오기 전까지, 이 방식이 가장 합리적인 다리 역할을 해줄 거예요.
소스 코드의 관점에서 이해해두면, 변동이 와도 대응이 쉬워진다는 점도 큰 수확이죠.
부록 실행 흐름을 요약하는 미니 스니펫
읽으며 떠올린 핵심 조각을 아주 간단한 스케치로 묶어보면 다음과 같아요.
실제 Next.js 내부는 훨씬 많은 가지치기와 오류 처리가 들어가지만, 큰 흐름은 이 윤곽을 따릅니다.
// 1) 러스트 바인딩으로 후보 판별
const should = decideReactCompiler(filename, source);
// 2) SWC는 항상 돈다
const swcOut = swcTransform(source, swcOptions);
// 3) 후보일 때만 Babel로 React Compiler 적용
const final = should
? babelTransform(swcOut.code, {
presets: ['next/babel'],
plugins: [require('babel-plugin-react-compiler')],
})
: swcOut;
이 스니펫이 보여주듯, 흐름의 요체는 '빠른 판별'과 '선택적 주입'이에요.
두 단어만 기억해도 전체 전략이 머릿속에 또렷이 남습니다.
'Javascript' 카테고리의 다른 글
| ES2016 이후 모던 JavaScript 완벽 가이드 한번에 끝내기 (3) | 2025.08.31 |
|---|---|
| nuqs 2.5.0 업데이트 완전 정리 타입 안전 URL 상태 관리의 다음 단계 (4) | 2025.08.24 |
| ECMAScript 2025 최종 승인 무엇이 달라졌나? (0) | 2025.07.13 |
| 암호 해독 가이드 더 이상 두렵지 않은 자바스크립트 정규표현식 (0) | 2025.07.13 |
| 더 안전한 타입스크립트 Map과 배열 다루기 고급 패턴 탐구 (0) | 2025.07.13 |