Metro Bundler는 무엇을 어떻게 묶는가
React Native 환경에서 JavaScript는 어떻게 하나의 번들이 되는가
React Native 프로젝트를 진행하다 보면 Metro Bundler는 실행되고 있지만, 내부에서 어떤 일이 일어나는지 상대적으로 잘 드러나지 않습니다. Metro Bundler가 entry 파일부터 최종 index.bundle을 만들어내기까지의 전체 흐름을, 데이터의 형태가 어떻게 변하는지에 초점을 맞추어 정리해보겠습니다.
Metro Bundler란?
Metro는 React Native에서 기본으로 사용하는 JavaScript 번들러입니다. 역할 자체는 Webpack이나 Vite와 유사하지만, 모바일 환경에 특화된 설계를 가지고 있습니다.
Metro의 주요 책임은 다음과 같습니다.
- JavaScript / TypeScript 코드 번들링
- 이미지, SVG 등 asset 처리
- 플랫폼(iOS, Android)에 맞는 코드 변환
- 최종적으로 실행 가능한 단일 번들 생성
Metro Bundler는 크게 아래 3단계를 거쳐 동작합니다.
- Resolution – 어떤 파일을 읽어야 하는지 결정
- Transformation – 각 파일의 코드를 변환
- Serialization – 변환된 파일들을 하나로 묶음
각 단계마다 입력과 출력 데이터의 형태가 달라진다는 점을 중심으로 살펴보겠습니다.
1. Resolution(“어떤 파일들이 필요한가?”)
1-1. Entry File에서 시작
Metro는 하나의 entry file을 진입점으로 삼습니다. 보통은 index.js 혹은 index.ts가 됩니다.
index.js
├─ import App from './App'
│ ├─ import Button from './components/Button'
│ │ └─ import './Button.style'
│ └─ import logo from './assets/logo.png'
이 단계에서의 데이터는 파일 경로 정보입니다. (dependency graph 정도로 이해해도 될것 같습니다.)
1-2. Resolver
Resolver는 다음을 수행합니다.
./App 경로를 기준으로 .js, .jsx, .ts, .tsx 등 설정된 확장자를 순서대로 탐색하고 실제 존재하는 파일을 찾아냅니다.
확장자 인식 규칙은 metro.config.js에서 설정합니다.
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const config = {
resolver: {
sourceExts: ['jsx', 'js', 'ts', 'tsx', 'json'],
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
resolver는 파일(모듈)들의 의존성 그래프를 그리고, 이 의존성 그래프를 transformer로 넘겨줍니다.
2. Transformation
“각 파일의 코드를 실행 가능한 형태로 바꾸기”
Resolution이 끝나면 Metro는 이제 각 파일의 실제 내용을 읽기 시작합니다. 일반적으로 babel이 transform을 담당합니다. web에서의 역할과 크게 다르지 않습니다.
Babel Preset에 의한 AST 변환
React Native에서는 기본적으로 metro-react-native-babel-preset이 사용됩니다.
이 preset은 다음과 같은 작업을 수행합니다.
- JSX →
React.createElement - TypeScript → JavaScript
- 최신 JS 문법 → 타깃 엔진(Hermes, JSC 등)에 맞는 문법
예시
기본적으로 SVG는 이미지 asset으로 처리됩니다. 하지만 UI 컴포넌트로 사용하고 싶은 경우가 많습니다. 이를 가능하게 해주는 것이 react-native-svg-transformer입니다.
// metro.config.js - resolver에서 svg를 모듈로 사용
resolver: {
assetExts: assetExts.filter(ext => ext !== 'svg'),
sourceExts: [...sourceExts, 'svg'],
}
SVG를 asset이 아니라 source module로 인식하게 만듭니다.
아래는 react-native-svg-transformer 소스코드의 일부입니다.
//https://github.com/kristerkari/react-native-svg-transformer/blob/master/index.js
const { resolveConfig, transform } = require("@svgr/core");
const resolveConfigDir = require("path-dirname");
/**
* `metro-react-native-babel-transformer` has recently been migrated to the React Native
* repository and published under the `@react-native/metro-babel-transformer` name.
* The new package is default on `react-native` >= 0.73.0, so we need to conditionally load it.
*/
const getReactNativeTransformer = () => {
try {
return require("@react-native/metro-babel-transformer");
} catch (error) {
return require("metro-react-native-babel-transformer");
}
};
const getExpoTransformer = () => {
try {
return require("@expo/metro-config/babel-transformer");
} catch (error) {
try {
return require("expo/node_modules/@expo/metro-config/babel-transformer");
} catch (nestedError) {
return null;
}
}
};
const defaultSVGRConfig = {
native: true,
plugins: ["@svgr/plugin-svgo", "@svgr/plugin-jsx"],
svgoConfig: {
plugins: [
{
name: "preset-default",
params: {
overrides: {
inlineStyles: {
onlyMatchedOnce: false
},
removeViewBox: false,
removeUnknownsAndDefaults: false,
convertColors: false
}
}
}
]
}
};
function createTransformer(transformer) {
return async ({ src, filename, ...rest }) => {
// 리턴되는 부분에서 함수로 파일에 대한 인자를 받아서 처리한다.
if (filename.endsWith(".svg")) {
const config = await resolveConfig(resolveConfigDir(filename));
// config 유무에 따라 svgConfig 설정 변경
const svgrConfig = config
? { ...defaultSVGRConfig, ...config }
: defaultSVGRConfig;
// transformer.transform 함수에 인자는 넣어서 리턴)
return transformer.transform({
src: await transform(src, svgrConfig),
filename,
...rest
});
}
return transformer.transform({ src, filename, ...rest });
};
}
// 모듈로 노출한다.
module.exports = {
getReactNativeTransformer,
getExpoTransformer,
createTransformer,
transform: createTransformer(
/*
* Expo v50.0.0 has begun using @expo/metro-config/babel-transformer as its upstream transformer.
* To avoid breaking projects, we should prioritze that package if it is available.
**/
getExpoTransformer() || getReactNativeTransformer()
)
};
react-native-svg-transformer 내부에서는 다음과 같은 흐름이 발생합니다.
.svg 파일
↓
SVGR (@svgr/core)
↓
React Component 형태의 JS 코드
↓
Babel Transformer
핵심 코드를 간단히 요약하면 다음과 같습니다.
function createTransformer(transformer) {
return async ({ src, filename, ...rest }) => {
// 리턴되는 부분에서 함수로 파일에 대한 인자를 받아서 처리한다.
if (filename.endsWith(".svg")) {
const config = await resolveConfig(resolveConfigDir(filename));
// config 유무에 따라 svgConfig 설정 변경
const svgrConfig = config
? { ...defaultSVGRConfig, ...config }
: defaultSVGRConfig;
// transformer.transform 함수에 인자는 넣어서 리턴)
return transformer.transform({
src: await transform(src, svgrConfig),
filename,
...rest
});
}
return transformer.transform({ src, filename, ...rest });
};
}
- input: .SVG 파일인지 확인
- logic: React Component 코드 문자열
- return: 일반 JS 모듈과 동일한 변환 결과
SVG도 이 시점부터는 완전히 JS 모듈로 취급됩니다.
Serialization(“모든 모듈을 하나로 묶기”)
Transformation을 거친 결과는 다음과 같습니다.
- 수많은 JS 모듈
- 각각 독립적으로 실행 가능한 코드
- 의존성 정보는 Metro가 이미 알고 있음
Serialization 단계에서는 이를 다음과 같이 처리합니다.
[Module A]
[Module B]
[Module C]
↓
하나의 실행 그래프
↓
index.bundle
마무리
react native는 javascript를 이용해서 native app을 만들수 있게 해주는 매우 강력한 프레임워크입니다. react native는 web에서 사용되던 기술이 적용되어있는 모습을 볼수가 있습니다. metro는 webpack과 비슷한점이 많다는것을 알게 된 것 또한 수확이었습니다.
metro는 단순히 “파일을 합치는 도구”가 아니라, 모바일 환경에 최적화된 코드 생성 파이프라인이라고 이해하는 것이 맞을것 같습니다. Metro가 어떻게 동작하는지 이해하면커스텀 transformer 작성, SVG / asset 처리, 빌드 성능 이슈 분석 등 응용할수 있는 부분이 많아 질것 같네요.
참고


Member discussion