SWC 컴파일러 심화 가이드 — 고급 설정·플러그인·Next.js·Vite·모노레포
이 글의 핵심
SWC는 Rust로 작성된 JavaScript·TypeScript 컴파일러입니다. 이 글에서는 렉서·파서·AST·변환 파이프라인을 이해한 뒤, .swcrc 고급 설정, WASM 플러그인과 프로그래매틱 API, Next.js·Vite 통합, 타깃·캐시·병렬화 기반 성능 최적화, Babel과의 선택 기준, 모노레포에서의 설정 계층을 다룹니다.
이 글의 핵심
SWC(Speedy Web Compiler)는 Rust로 구현된 JavaScript·TypeScript 컴파일러로, Next.js의 기본 컴파일러이자 Vite의 React SWC 플러그인, Jest용 @swc/jest 등에서 쓰입니다. “Babel보다 빠르다”는 점은 잘 알려져 있지만, 실제로 설정을 튜닝하려면 처리 파이프라인과 설정 계층, 그리고 도구마다 SWC를 감싸는 방식이 어떻게 다른지를 알아야 합니다. 이 글은 아키텍처, .swcrc 옵션, 플러그인, Next.js·Vite 연동, 성능, 모노레포 순으로 정리합니다.
SWC의 처리 파이프라인
SWC는 소스 문자열을 렉서(lexer)로 토큰화하고, 파서(parser)로 AST(abstract syntax tree)를 만든 뒤, 변환(transform) 단계에서 TypeScript 타입 제거, JSX 변환, 최신 문법의 하위 버전 변환 등을 적용하고, 마지막으로 코드 생성(codegen)으로 다시 문자열과 소스맵을 출력합니다. 구조 자체는 Babel과 비슷하지만, 전 과정이 네이티브 코드로 실행되고 여러 파일을 스레드로 병렬 처리할 수 있어 처리량이 높습니다.
변환 레이어가 하는 일의 예를 들면, TypeScript 타입 어노테이션과 interface를 지우고, class 필드나 옵셔널 체이닝(?.)처럼 목표 런타임이 지원하지 않는 문법을 동등한 구형 코드로 바꾸고, JSX를 react/jsx-runtime의 jsx() 호출(자동 런타임)이나 React.createElement 호출(클래식 런타임)로 바꿉니다. SWC는 문법만 변환하며, Array.prototype.at 같은 런타임 API를 채우는 폴리필은 env.mode를 설정해 core-js import를 넣게 하지 않는 한 추가하지 않습니다.
타깃과 모듈 형식
출력 문법 수준은 jsc.target(예: es2022)으로 지정하거나, 최상위 env.targets에 browserslist 쿼리를 주어 지정합니다. 둘은 함께 쓸 수 없습니다. 타깃이 낮을수록 더 많은 변환이 들어가 출력이 커지고 느려지며, 최신 엔진만 지원하면 변환이 거의 필요 없습니다.
출력 모듈 형식은 module.type으로 es6, commonjs, umd, amd, nodenext 등에서 고릅니다. 라이브러리를 배포할 때는 이 선택이 소비자가 import와 require 중 무엇으로 읽을 수 있는지를 결정하므로, package.json의 exports·type 필드와 맞춰야 합니다.
.swcrc 고급 설정
.swcrc는 @swc/cli, @swc/core, @swc/jest처럼 SWC를 직접 쓰는 도구가 읽는 설정 파일입니다. Next.js나 Vite 플러그인은 이 파일을 읽지 않고 자체 옵션을 쓴다는 점을 먼저 기억해야 합니다. $schema를 지정하면 편집기 자동 완성을 받을 수 있습니다.
{
"$schema": "https://swc.rs/schema.json",
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": false
},
"transform": {
"react": {
"runtime": "automatic",
"development": false,
"refresh": false
}
},
"target": "es2022",
"loose": false,
"externalHelpers": false,
"keepClassNames": true
},
"module": {
"type": "es6"
},
"minify": false,
"sourceMaps": true,
"inlineSourcesContent": true
}
parser.syntax를 typescript로 두고 tsx를 켜면 TypeScript와 JSX를 함께 파싱합니다. .ts 파일에서 <T>value 형태의 타입 단언을 쓰는 코드가 있다면 tsx: true일 때 JSX로 오인되므로, 확장자별로 설정을 나누거나 as 단언으로 바꿔야 합니다. transform.react.runtime: "automatic"은 React 17 이후의 JSX 자동 런타임을 써서 파일마다 import React를 할 필요가 없게 합니다. keepClassNames는 변환이나 압축 과정에서 클래스 이름이 바뀌거나 사라지지 않게 하는 옵션으로, constructor.name에 의존하는 직렬화 코드나 로그가 있을 때 필요합니다.
loose 모드의 트레이드오프
jsc.loose: true는 클래스, 스프레드, 구조 분해 같은 문법을 하위 버전으로 변환할 때 명세를 완전히 따르는 대신 더 단순한 코드를 생성합니다. 출력이 작고 빨라지지만, 예를 들어 배열 스프레드가 이터레이터 프로토콜을 거치지 않거나 클래스 메서드가 열거 가능해지는 등 명세와 다른 동작이 생깁니다. 다른 사람이 소비하는 라이브러리라면 기본값 false를 유지하는 편이 안전합니다. 타깃이 충분히 높아 해당 변환이 일어나지 않는다면 loose는 결과에 영향을 주지 않습니다.
externalHelpers와 @swc/helpers
하위 버전 변환에는 _class_call_check 같은 헬퍼 함수가 필요합니다. 기본적으로 SWC는 이 헬퍼를 파일마다 인라인하므로 파일이 많으면 같은 코드가 반복됩니다. externalHelpers: true로 두면 헬퍼를 @swc/helpers 패키지에서 import하도록 바꿔 중복을 없앱니다. 이때 @swc/helpers는 런타임 의존성(dependencies)으로 설치해야 하며, 사용하는 @swc/core 버전과 호환되는 버전이어야 합니다.
소스맵과 인라인 소스
sourceMaps: true는 별도 .map 파일을, "inline"은 출력 파일 안에 소스맵을 넣습니다. inlineSourcesContent를 켜면 원본 소스가 맵 안에 포함되어 원본 파일이 없는 환경에서도 디버깅할 수 있지만, 맵 크기가 커지고 원본 코드가 노출됩니다. 프로덕션에서는 소스맵을 공개 서버에 올리지 않고 Sentry 같은 에러 추적 서비스에만 업로드하는 방식이 일반적입니다.
압축(minify) 옵션
최상위 minify: true는 출력 단계에서 압축을 켜고, 세부 동작은 jsc.minify.compress와 jsc.minify.mangle로 제어합니다. 둘 다 Terser와 거의 같은 옵션 이름을 씁니다. mangle.props로 객체 프로퍼티 이름까지 줄일 수 있지만, 외부에서 접근하는 프로퍼티 이름이 바뀌면 라이브러리 사용자 코드가 깨지므로 이 옵션은 내부 전용 이름에 한정된 정규식과 함께 아주 보수적으로 써야 합니다. 라이브러리는 압축하지 않고 배포해 최종 애플리케이션의 번들러가 압축하게 하는 것이 일반적입니다.
플러그인과 프로그래매틱 API
두 가지 확장 방식
SWC 위에서 변환을 추가하는 방법은 두 가지입니다. 하나는 Rust로 작성해 WASM으로 컴파일한 플러그인을 jsc.experimental.plugins에 등록하는 방식이고, 다른 하나는 Node에서 @swc/core의 transform/transformSync를 호출하면서 그 전후에 직접 처리를 넣는 방식입니다. 예전에 있던 JavaScript 플러그인 API(plugin 옵션)는 성능 문제로 사용이 권장되지 않습니다.
WASM 플러그인은 AST 구조에 직접 접근하므로 강력하지만, 플러그인이 빌드된 swc_core 버전과 실행하는 @swc/core(또는 Next.js에 내장된 SWC) 버전의 AST 직렬화 형식이 맞아야 합니다. Next.js나 @swc/core를 업그레이드한 뒤 플러그인이 로드되지 않거나 패닉이 나는 문제가 자주 보고되므로, 플러그인을 쓴다면 업그레이드할 때마다 호환 버전을 확인하는 절차가 필요합니다.
@swc/core로 변환 호출
// scripts/swc-run.mjs — 단일 파일 변환 예시
import fs from 'node:fs';
import { transformSync } from '@swc/core';
const src = fs.readFileSync('input.tsx', 'utf8');
const out = transformSync(src, {
filename: 'input.tsx',
jsc: {
parser: { syntax: 'typescript', tsx: true },
target: 'es2022',
transform: { react: { runtime: 'automatic' } },
},
module: { type: 'es6' },
sourceMaps: true,
});
fs.writeFileSync('output.js', out.code);
if (out.map) fs.writeFileSync('output.js.map', out.map);
filename을 넘겨야 소스맵의 원본 경로가 올바르게 기록되고, .swcrc 탐색 기준도 정해집니다. 설정 객체를 직접 넘길 때 프로젝트의 .swcrc까지 함께 적용되지 않게 하려면 swcrc: false를 지정합니다. 빌드 스크립트나 코드 생성처럼 파일이 몇 개 안 되면 transformSync가 간단하고, 파일이 많다면 비동기 transform을 여러 개 동시에 호출하면 네이티브 스레드 풀에서 병렬로 처리됩니다.
플러그인을 설계할 때의 원칙
변환은 가능한 한 국소적으로 유지하고 전역 상태에 의존하지 않는 것이 좋습니다. SWC는 파일을 병렬로 처리하므로 파일 간 상태를 공유하는 플러그인은 결과가 실행 순서에 따라 달라질 수 있습니다. 또 플러그인이 받는 AST는 파서 설정(TypeScript 여부, JSX, decorators)에 따라 달라지므로 어떤 설정을 전제로 하는지 문서화해 두어야 합니다. Next.js처럼 SWC를 감싼 도구는 자체 변환을 추가로 적용하므로, 같은 대상을 두 번 변환해 충돌하지 않는지도 확인합니다.
Next.js와의 통합
Next.js는 12 버전부터 SWC를 기본 컴파일러로 쓰며, next.config의 compiler 옵션으로 일부 동작을 제어합니다. removeConsole, reactRemoveProperties, styledComponents, emotion, relay 같은 옵션이 여기에 있습니다. Next.js는 .swcrc를 읽지 않으므로, SWC 공식 문서에 있는 옵션이라도 Next의 compiler 섹션에 없으면 설정할 수 없습니다. SWC WASM 플러그인은 experimental.swcPlugins로 등록합니다.
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
compiler: {
// 프로덕션에서 console.error는 남기고 나머지 console.* 제거
removeConsole:
process.env.NODE_ENV === 'production' ? { exclude: ['error'] } : false,
},
};
export default nextConfig;
removeConsole은 운영 환경의 로그 전략과 충돌하지 않는지 확인하고 써야 합니다. 서버 코드의 console.log로 로그를 수집하고 있다면 이 옵션이 그 로그까지 지웁니다.
프로젝트 루트에 .babelrc나 babel.config.js가 있으면 Next.js는 SWC 대신 Babel로 변환합니다. 오래된 프로젝트에서 빌드가 느린데 원인을 모르겠다면 이 파일이 남아 있는지부터 확인합니다. Babel 설정이 꼭 필요한 플러그인 때문에 남아 있다면, 그 기능이 compiler 옵션이나 SWC 플러그인으로 대체 가능한지 검토한 뒤 제거합니다.
Vite와의 통합
Vite는 기본적으로 esbuild로 TypeScript를 변환하지만, React 프로젝트에서 @vitejs/plugin-react-swc를 쓰면 개발 중 JSX·TypeScript 변환과 Fast Refresh를 SWC가 맡습니다. Babel 기반인 @vitejs/plugin-react보다 개발 서버의 변환 속도가 빠르고, 커스텀 Babel 플러그인이 필요 없는 프로젝트라면 이쪽을 쓰는 것이 일반적입니다. 프로덕션 빌드에서도 JSX 변환에 SWC를 쓰며, 번들링과 압축은 Vite의 번들러가 담당합니다.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [react()],
});
SWC는 esbuild와 마찬가지로 파일 단위로 타입을 지우기만 하고 타입 검사는 하지 않습니다. 따라서 tsc --noEmit을 CI나 별도 스크립트에서 돌려 타입 오류를 잡아야 합니다. 같은 이유로 const enum이나 다른 파일의 타입만 다시 내보내는(re-export) 코드처럼 파일 간 타입 정보가 필요한 기능은 제대로 변환되지 않을 수 있어, tsconfig의 isolatedModules: true로 이런 코드를 미리 금지하는 것이 좋습니다.
성능 최적화
타깃을 현실적으로 올리기
필요 이상으로 낮은 타깃은 변환량과 출력 크기를 늘립니다. 실제 지원 브라우저와 Node 버전을 사용 통계나 browserslist 설정으로 확정하고, 그에 맞는 가장 높은 타깃을 잡으면 SWC와 이후 번들러 모두 할 일이 줄어듭니다.
캐시
SWC를 직접 호출하는 커스텀 파이프라인이라면 입력 파일 내용, 설정, @swc/core 버전을 함께 키로 쓰는 캐시를 둘 수 있습니다. 키에서 @swc/core 버전을 빼면 업그레이드 후에도 이전 버전의 출력이 재사용되어, 로컬과 CI 결과가 달라지는 재현성 문제가 생깁니다.
병렬화
@swc/cli로 디렉터리를 변환하면 이미 여러 파일을 병렬로 처리합니다. 직접 API를 쓴다면 비동기 transform을 동시에 여러 개 호출하는 것만으로 병렬 처리가 되므로, 보통 JavaScript 쪽에 별도 워커 풀을 만들 필요는 없습니다. 다만 수만 개 파일을 한꺼번에 읽어 호출하면 메모리 사용량이 급증하므로 동시 호출 수에 상한을 두는 것이 좋습니다.
개발과 프로덕션 설정 분리
개발 모드에서는 소스맵과 Fast Refresh, react.development: true가 중요하고, 프로덕션에서는 압축과 불필요한 코드 제거가 중요합니다. .swcrc에는 NODE_ENV에 따라 값을 바꾸는 조건 분기가 없으므로(배열 형식과 test로 파일 종류별 설정을 나누는 기능은 있음), 환경별로 파일을 나누고(--config-file) 빌드 스크립트에서 골라 쓰는 방식이 명확합니다.
Babel과 SWC 비교
| 관점 | Babel | SWC |
|---|---|---|
| 구현 | JavaScript | Rust |
| 플러그인 생태계 | 매우 큼, JS로 작성 | 작음, Rust(WASM)로 작성 |
| 변환 속도 | 상대적으로 느림 | 빠름 |
| 타입스크립트 | 프리셋으로 타입 제거(검사 없음) | 내장 타입 제거(검사 없음) |
선택 기준은 속도만이 아닙니다. 기존 코드가 Babel 매크로나 사내 커스텀 플러그인에 의존한다면 그것을 Rust로 다시 작성하는 비용이 큽니다. 새 프로젝트이거나 Next.js·Vite처럼 SWC 경로를 기본 지원하는 도구를 쓴다면 SWC를 기본으로 두는 것이 합리적입니다.
모노레포 설정
단일 루트 설정의 한계
모노레포 루트에 공통 .swcrc 하나를 두면 관리가 쉽지만, 프런트엔드 앱은 브라우저 타깃, 서버 패키지는 Node 20 타깃처럼 요구가 갈라지면 한 설정으로는 어느 한쪽이 손해를 봅니다. @swc/cli와 @swc/core는 기본적으로 현재 작업 디렉터리의 .swcrc를 기준으로 동작하므로, 어느 디렉터리에서 명령을 실행하느냐에 따라 적용되는 설정이 달라질 수 있습니다. --config-file로 설정 파일을 명시하는 편이 디버깅에 유리합니다.
패키지별 설정 파일
각 패키지에 .swcrc를 두고 빌드 스크립트에서 --config-file로 경로를 명시합니다. .swcrc에는 다른 파일을 상속하는 기능이 없으므로, 공통 설정이 크다면 JS 빌드 스크립트에서 기본 객체와 패키지별 객체를 병합해 @swc/core에 넘기는 방법이 중복을 줄입니다.
{
"name": "root",
"private": true,
"scripts": {
"build:core": "swc packages/core/src -d packages/core/dist --strip-leading-paths --config-file packages/core/.swcrc",
"build:web": "pnpm --filter web build"
}
}
최근 버전의 @swc/cli는 출력 경로에 입력 디렉터리 이름이 포함되므로(dist/src/...), 예전처럼 dist/ 바로 아래에 파일을 두려면 --strip-leading-paths를 붙입니다. pnpm을 쓴다면 워크스페이스 목록은 package.json의 workspaces가 아니라 pnpm-workspace.yaml에 둡니다.
TypeScript 프로젝트 참조와 정렬
SWC는 .d.ts를 생성하지 않으므로, 라이브러리 패키지는 보통 SWC로 JavaScript를 만들고 tsc --emitDeclarationOnly로 타입 선언을 따로 만듭니다. 이때 SWC의 출력 디렉터리, tsconfig의 outDir·declarationDir, package.json의 exports·types가 같은 구조를 가리키지 않으면, 소비하는 쪽에서 타입은 찾는데 런타임 파일이 없거나 그 반대가 되는 문제가 생깁니다. 소스 디렉터리와 산출물 경로를 패키지 전체에서 같은 규칙으로 고정하는 것이 좋습니다.
문제 해결
| 증상 | 확인할 것 |
|---|---|
| 변환 결과가 기대와 다름 | jsc.parser의 syntax·tsx·decorators가 파일 확장자와 맞는지 |
| 데코레이터가 다르게 동작함 | decorators: true와 transform.legacyDecorator, decoratorMetadata 설정이 TypeScript 설정(experimentalDecorators)과 맞는지 |
| React Fast Refresh가 깨짐 | 컴포넌트 파일이 컴포넌트 외의 값도 export하는지, 익명 default export인지 |
| 프로덕션에서만 오류 | react.development, removeConsole, 압축 시 이름 변경(keepClassNames, keep_fnames) |
| WASM 플러그인 로드 실패 | 플러그인이 빌드된 swc_core 버전과 실행 중인 SWC 버전의 호환성 |