콘텐츠로 이동

Babel → ZNTC 이관 가이드

Metro 기반 babel.config.js를 ZNTC로 옮길 때 각 플러그인/프리셋의 대응을 정리합니다.

Babel 설정ZNTC 대응비고
@react-native/babel-presetplatform: "react-native"JSX/Flow/class props 자동
@babel/preset-env (문법 다운레벨)target: "es2020"engine 타겟도 가능 (chrome80 등)
@babel/preset-env { useBuiltIns: "usage", corejs }runtimePolyfills: "auto" (+ bun add core-js core-js-compat)런타임 폴리필 가이드useBuiltIns: "entry"{ mode: "entry" }
@babel/plugin-transform-flow-strip-typesflow: true 또는 RN 프리셋.js.flow/@flow pragma 자동
@babel/plugin-proposal-decorators { legacy }experimentalDecorators: trueStage 3도 별도 지원
@babel/plugin-transform-class-properties { loose }useDefineForClassFields: falsetsconfig와 동기화
@babel/plugin-transform-private-methods { loose }target 자동 다운레벨별도 옵션 불필요
@babel/plugin-proposal-optional-chainingtarget 자동 다운레벨ES2020 내장
babel-plugin-root-importalias: { "~/": "./src" }tsconfig paths로도 가능
react-native-worklets/plugin내장 worklet 플러그인platform: "react-native"로 자동
babel-plugin-lodashmoduleSpecifierMap: { lodash: "lodash/{name}" }cherry-pick 분해. alias: { lodash: "lodash-es" }도 가능 (ESM tree-shaking 활용)
babel-plugin-styled-componentscompiler.styledComponents1st-party transform (아래 섹션)
@emotion/babel-plugincompiler.emotion1st-party transform (아래 섹션)
transform-remove-consoledrop: ["console"]
transform-react-remove-prop-typespure: ["PropTypes.*"] + DCEReact 19+에선 불필요
커스텀 Babel 플러그인Babel bridge (아래 섹션)또는 ZNTC 플러그인 포팅
module.exports = {
presets: ["module:@react-native/babel-preset"],
plugins: [
["babel-plugin-root-import", { rootPathSuffix: "./src", rootPathPrefix: "~/" }],
"@babel/plugin-transform-flow-strip-types",
["@babel/plugin-proposal-decorators", { version: "legacy" }],
["@babel/plugin-transform-class-properties", { loose: true }],
["@babel/plugin-transform-private-methods", { loose: true }],
["react-native-worklets/plugin"],
],
env: {
production: {
plugins: ["transform-remove-console"],
},
},
};
import { defineConfig } from "@zntc/core";
export default defineConfig({
platform: "react-native",
target: "es2020",
alias: { "~/": "./src" },
experimentalDecorators: true,
useDefineForClassFields: false,
drop: process.env.NODE_ENV === "production" ? ["console"] : [],
});

플러그인 배열이 0줄로 줄어듭니다. RN 프리셋 + worklet + Flow는 platform: "react-native"에 전부 포함됩니다.

RegExp 매칭이 필요할 때 — alias array 형태

섹션 제목: “RegExp 매칭이 필요할 때 — alias array 형태”

기본 object 형태는 exact + prefix 매칭만 지원합니다. babel-plugin-module-resolverregExp 옵션처럼 정규식이 필요하면 array 형태를 사용합니다 (Vite resolve.alias 호환).

defineConfig({
alias: [
{ find: /^@\/(.*)$/, replacement: "./src/$1" },
{ find: /^~components\/(.*)$/, replacement: "./src/components/$1" },
],
});

매칭 순서대로 첫 번째만 적용. find 가 string 이면 prefix 매칭, RegExp 이면 host runtime 이 매칭 + replacement 로 치환합니다.

주의: array 형태는 build() (async) 만 지원합니다. buildSync() 는 host RegExp 위임이 plugin hook 기반이라 미지원 — Record<string, string> object 형태를 쓰거나 build() 로 전환하세요.

styled-components — compiler.styledComponents

섹션 제목: “styled-components — compiler.styledComponents”

babel-plugin-styled-components 의 ZNTC 1st-party 대응. plugin 등록 없이 옵션만 켜면 됩니다.

defineConfig({
compiler: {
styledComponents: {
displayName: true, // devtools 표시 (default: NODE_ENV !== "production")
ssr: true, // 결정론적 componentId hash (default: true)
fileName: true, // componentId 에 파일명 포함 (default: true)
minify: true, // CSS whitespace minify (default: true)
transpileTemplateLiterals: true, // 다운레벨된 템플릿 인식 (default: true)
pure: false, // styled.X 부수효과 없음 hint (default: false)
namespace: "my-app", // displayName/componentId namespace prefix
topLevelImportPaths: ["@my-org/styled"], // vendored fork 인식
cssProp: false, // `<div css={...}>` 를 module-level styled component 로 추출 (default: false)
},
},
});

compiler.styledComponents: true 로 default 옵션을 한 번에 켤 수도 있습니다.

@emotion/babel-plugin 의 ZNTC 1st-party 대응.

defineConfig({
compiler: {
emotion: {
autoLabel: "dev-only", // "always" | "dev-only" | "never" | boolean (default: "dev-only")
labelFormat: "[local]", // tokens: [local] / [filename] / [dirname] (default: "[local]")
sourceMap: true, // sourceMap 생성 (default: true)
importMap: { // fork / vendored emotion 사용 시 import alias
"@my-org/styled": {
styled: { canonicalImport: ["@emotion/styled", "default"] },
},
},
},
},
});

jsxImportSourceBuildOptions 의 동명 옵션으로 별도 지정합니다 (예: jsxImportSource: "@emotion/react").

Babel bridge — 커스텀 Babel 플러그인 재사용

섹션 제목: “Babel bridge — 커스텀 Babel 플러그인 재사용”

내장으로 대체할 수 없는 커스텀 Babel 플러그인(예: 사내 preset, testID 자동 주입, AppRegistry 래핑 등)은 Babel을 통째로 transform 훅에서 호출해 재사용할 수 있습니다.

Terminal window
bun add -D @babel/core
import { defineConfig } from "@zntc/core";
import * as babel from "@babel/core";
import mcpPreset from "@ohah/react-native-mcp-server/babel-preset";
export default defineConfig({
platform: "react-native",
plugins: [
{
name: "babel-bridge",
transform: {
filter: /\.(jsx?|tsx?)$/,
handler(code, id) {
const out = babel.transformSync(code, {
filename: id,
presets: [[mcpPreset, { renderHighlight: true }]],
plugins: [
// 여기에 그 외 커스텀 Babel 플러그인
],
babelrc: false,
configFile: false,
sourceMaps: true,
});
if (!out) return null;
return { code: out.code ?? code, map: out.map ?? undefined };
},
},
},
],
});

핵심 포인트:

  • babelrc: false, configFile: false — 프로젝트 babel.config.js를 재귀적으로 읽지 않도록 명시. ZNTC 설정과 이중 변환 방지
  • filter — 필요한 확장자만. node_modules 제외 원하면 filter 함수에 !/node_modules/.test(id) 추가
  • sourceMaps: true — 소스맵 체이닝. ZNTC가 이후 단계에서 병합
  • 반환 형식: { code, map? }. null 반환 시 ZNTC 기본 파이프라인으로 폴백

각 모듈마다 Babel을 한 번 돌리므로 개발 중 dev server 웜업은 느려집니다. 프로덕션 번들은 ZNTC 본체보다 @babel/core 호출 비용이 지배적. 다음 중 하나로 완화:

  1. filter를 좁혀 Babel이 꼭 필요한 파일만 통과 (예: src/**/*.tsx만)
  2. 자주 쓰는 플러그인은 ZNTC 플러그인으로 포팅 (아래 섹션)
  3. dev는 Babel bridge, prod는 ZNTC 네이티브로 분기

Babel bridge는 간편하지만 느립니다. 성능이 중요하거나 빌드 횟수가 많다면 ZNTC 플러그인 API로 재작성하는 게 정답입니다.

Rollup/Vite 스타일 훅(resolveId, load, transform)으로 커스텀 플러그인 직접 작성:

zntc.config.ts
import { defineConfig } from "@zntc/core";
export default defineConfig({
plugins: [
{
name: "inject-testid",
transform: {
filter: /\.tsx?$/,
handler(code, id) {
// JSX elements에 testID prop 주입 등
// 자세한 AST 훅은 플러그인 가이드 참조
return null;
},
},
},
],
});

자세한 플러그인 작성법: 플러그인 가이드, 플러그인 레시피.

Q. babel-plugin-lodash는 꼭 필요한가?

섹션 제목: “Q. babel-plugin-lodash는 꼭 필요한가?”

Metro에선 tree-shaking이 약해 import { debounce } from 'lodash' 시 lodash 전체(~70KB)가 번들됨 → 이 플러그인으로 cherry-pick 필수였음.

ZNTC는 ESM tree-shaking이 정상 동작하므로:

  • lodash-es 사용 → 자동 cherry-pick (최적)
  • lodash 유지 → alias: { lodash: "lodash-es" } 한 줄로 해결
  • lodash 유지 + path import 강제 → moduleSpecifierMap: { lodash: "lodash/{name}" } (named specifier 만, alias 없는 경우만 변환. 미충족 시 원본 import 유지)
  • 즉 플러그인 포팅 불필요

React 19+는 PropTypes API 자체를 제거. TypeScript 사용 중이면 PropTypes 자체가 없을 것.

남은 PropTypes 코드 제거가 필요하면:

pure: ["PropTypes.string", "PropTypes.number", /* ... */]
  • dead code elimination으로 상당 부분 제거. 완벽히는 커스텀 플러그인 필요.

ZNTC에선 NODE_ENV 기반 분기를 defineConfig 내부에서:

const isProd = process.env.NODE_ENV === "production";
export default defineConfig({
drop: isProd ? ["console", "debugger"] : [],
plugins: isProd ? [minifyPlugin] : [],
});

plugins[].transform.filter로 파일 패턴별 변환을 분리:

plugins: [
{ name: "a", transform: { filter: /\.tsx$/, handler: ... } },
{ name: "b", transform: { filter: /\/legacy\//, handler: ... } },
]

한 번에 Babel 전체를 걷어내지 않아도 됩니다:

  1. Stage 1platform: "react-native" + alias 기본 설정 + Babel bridge로 기존 플러그인 전부 유지
  2. Stage 2 — Babel 플러그인을 하나씩 내장 기능으로 치환하거나 ZNTC 플러그인으로 포팅 → bridge에서 제거
  3. Stage 3 — bridge 자체 제거. @babel/core 의존성 삭제

각 단계가 독립적으로 배포 가능합니다.

  • 런타임 폴리필 (core-js)useBuiltIns + core-js 이관
  • 플러그인 가이드
  • 플러그인 레시피
  • React Native 가이드
  • 마이그레이션 가이드 (esbuild/Vite/webpack)