React JSX Transform 실전 가이드

jsxbabeltypescriptesbuildswcvitewebpack

Introduce

// 모든 빌드 도구가 처리해야 하는 JSX
function App() {
  return <div>Hello World</div>;
}

npx create-next-app이나 npm create vite 같은 CLI 명령어 하나면, Babel, SWC, TypeScript의 복잡한 설정이 우리도 모르는 새에 완료된다. JSX Transform 설정 역시 마찬가지이다.

"이걸 굳이 내가 직접 알 필요가 있을까?"

물론 그렇게 생각할 수 있겠지만, 현실은 그리 녹록치 않다. 자동 설정이라는 편안한 조수석에서 내려, 직접 운전대를 잡고 문제를 해결해야 하는 순간은 반드시 찾아온다는 것이다.

예를 들어 이런 상황들.

  • 문제 해결 능력 (디버깅) : 원인 불명의 빌드 에러가 터미널에 _jsx is not defined나 Cannot resolve 'react/jsx-runtime' 같은 메시지를 뿜어낼 때, 어디부터 확인해야 할까?
  • 자유로운 확장성 : 프로젝트에 Emotion이나 Relay처럼 특별한 Babel 플러그인이 필요한 라이브러리를 새로 도입해야 한다면?
  • 최적화 : CRA(Create React App)를 eject 하거나 Webpack 설정을 직접 수정하여 빌드 속도를 극한까지 끌어올려야 하는 상황이라면?
  • 하위 호환성 : 아직 Classic Runtime을 사용하는 오래된 사내 라이브러리나 레거시 프로젝트를 갑자기 유지보수하게 되었다면?

CLI는 우리를 목적지까지 빠르고 편안하게 데려다주는 자율주행 자동차와 같다. 하지만 엔진 소리가 이상하거나, 예상치 못한 경고등이 켜졌을 때 보닛을 열어 문제를 진단할 수 있는 엔지니어와 그렇지 못한 엔지니어의 차이는 명확하지 않을까?

⚠️ 주의: 이 글은 2024년 11월 기준으로 작성되었습니다.
웹 개발 생태계, 특히 빌드 도구는 매우 빠르게 변화합니다. 이 글을 참고하시되, 실제 프로젝트에 적용하기 전에는 반드시 각 도구의 최신 공식 문서를 함께 확인하시는 것을 권장합니다.

트랜스파일러(Transpiler)와 번들러(Bundler)

이 글에 등장하는 도구들은 크게 두 종류로 나뉜다. 대부분의 개발 환경은 번들러(Webpack, Vite)를 사용하며, 이 번들러가 내부적으로 트랜스파일러(Babel, SWC, esbuild)를 호출하여 JSX 변환 같은 작업을 처리한다. 이 구조를 이해하면 각 설정이 어떤 역할을 하는지 파악하기 쉽다.

1. Babel: 가장 유연한 선택

Babel은 최신 JavaScript 문법을 구형 브라우저에서도 동작하도록 변환해주는, 가장 성숙하고 널리 쓰이는 트랜스파일러이다. 방대한 플러그인 생태계를 통해 JSX 변환 외에도 무궁무진한 코드 변환이 가능하다.

Babel 공식 사이트

기본 설정

// .babelrc.json
{
  "presets": [
    ["@babel/preset-react", {
      "runtime": "automatic",
      "importSource": "react"
    }]
  ]
}

상세 옵션 분석

{
  "presets": [
    ["@babel/preset-react", {
      // "automatic" | "classic"
      "runtime": "automatic",
      
      // JSX runtime을 import할 패키지 (기본값: "react")
      "importSource": "react", 
      
      // development 모드 설정 (true 시 __source, __self prop 추가)
      "development": process.env.NODE_ENV === "development",
      
      // React.createElement 함수명 (classic 모드 전용)
      "pragma": "React.createElement",
      
      // React.Fragment 함수명 (classic 모드 전용)  
      "pragmaFrag": "React.Fragment",
      
      // XML 네임스페이스 문법 사용 시 에러 발생 (React 미지원)
      "throwIfNamespace": true
    }]
  ]
}

파일별 설정 오버라이드

/** @jsxRuntime classic */ 또는 /** @jsxImportSource @emotion/react */ 같은 주석을 파일 상단에 추가하여 전역 설정을 덮어쓸 수 있다.

2. TypeScript: 타입 안전성과 함께

TypeScript는 JavaScript에 정적 타입 시스템을 추가한 슈퍼셋(Superset) 언어이다. 자체 컴파일러(tsc)를 통해 타입 검사와 코드 변환(JSX 포함)을 동시에 수행할 수 있다.
TypeScript는 tsconfig.json 파일을 통해 JSX 처리 방식을 정교하게 제어한다.

TypeScript 공식 사이트

tsconfig.json의 주요 jsx 옵션

{
  "compilerOptions": {
    // "preserve": JSX를 변환하지 않고 그대로 둡니다.
    //             Babel, SWC 등 다른 트랜스파일러가 JSX를 처리할 때 사용합니다.
    //             가장 흔하고 권장되는 설정 중 하나입니다.
    "jsx": "preserve",
 
    // "react-jsx": Automatic Runtime을 사용하여 JSX를 변환합니다. (프로덕션용)
    // "react-jsxdev": Automatic Runtime을 사용하며, 디버깅을 위한
    //                 __source, __self prop을 추가합니다. (개발용)
    "jsx": "react-jsxdev",
    
    // Automatic Runtime 사용 시, JSX 함수를 가져올 소스를 지정합니다.
    // (예: "preact", "@emotion/react" 등)
    "jsxImportSource": "react"
  }
}

실전에서의 선택: preserve vs react-jsx

  • "jsx": "preserve" : 현대적인 프레임워크(Next.js, Vite 등)에서 가장 흔하게 볼 수 있는 설정이다. TypeScript는 JSX의 타입 검사만 수행하고 실제 변환은 더 빠른 SWC나 esbuild에게 맡기는 방식이다. 이를 통해 타입 안전성과 빌드 속도를 모두 잡을 수 있다.

  • "jsx": "react-jsx" / "react-jsxdev" : 오직 tsc(TypeScript 컴파일러)만을 사용하여 코드를 변환하는 간단한 환경에서 유용하다.

대부분의 경우, 어플리케이션 개발에서는 프레임워크가 제공하는 기본 설정(preserve)을 그대로 따르는 것을 추천한다.

3. esbuild: 극한의 속도

esbuild는 Go 언어로 작성되어 압도적으로 빠른 속도를 자랑하는 차세대 트랜스파일러 겸 번들러이다. 복잡한 설정 없이도 매우 빠른 성능을 보여주어 Vite 등 현대적인 도구의 핵심 엔진으로 사용된다.

esbuild 공식 사이트

CLI 옵션

# automatic runtime
esbuild app.jsx --jsx=automatic --jsx-import-source=react
 
# classic runtime  
esbuild app.jsx --jsx=transform --jsx-factory=React.createElement

API 설정

// build.js
require('esbuild').build({
  entryPoints: ['app.jsx'],
  bundle: true,
  outfile: 'out.js',
  jsx: 'automatic',
  jsxImportSource: 'react',
});

5. SWC: Rust 기반 차세대 도구

SWC (Speedy Web Compiler)는 Rust 언어로 작성되어 Babel을 대체할 수 있는 빠른 속도를 목표로 하는 트랜스파일러이다. Next.js의 기본 트랜스파일러로 채택되면서 널리 알려지게 되었다.

esbuild 공식 사이트

.swcrc 설정

{
  "jsc": {
    "parser": { "syntax": "typescript", "jsx": true },
    "transform": {
      "react": {
        "runtime": "automatic",
        "importSource": "react",
        // true 시 React Refresh 자동 활성화 및 개발 정보 주입
        "refresh": process.env.NODE_ENV === "development"
      }
    }
  }
}

Next.js에서 SWC 커스터마이징

Next.js는 내장된 SWC 최적화를 compiler 옵션을 통해 제어하는 것을 권장한다.

// next.config.js
module.exports = {
  compiler: {
    // Emotion.js 최적화를 위한 내장 기능 (권장)
    emotion: {
      sourceMap: true,
      autoLabel: 'dev-only',
      importSource: '@emotion/react'
    }
  },
  // 그 외 커스텀 WASM 플러그인 사용 시 (실험적 기능)
  experimental: {
    swcPlugins: [
      ['my-custom-swc-plugin', { /* plugin options */ }]
    ]
  }
};

6. Vite: 현대적인 개발 경험

Vite는 네이티브 ESM을 기반으로 한 매우 빠른 개발 서버와 Rollup 기반의 최적화된 프로덕션 빌드를 제공하는 차세대 프론트엔드 빌드 도구이다. 개발 시에는 esbuild를 사용하여 즉각적인 HMR을 구현한다.
JSX 설정은 @vitejs/plugin-react를 통해 통합 관리된다.

Vite 공식 사이트

vite.config.js 설정

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
 
export default defineConfig({
  plugins: [
    react({
      jsxRuntime: 'automatic', // 'classic'
      jsxImportSource: 'react', // 기본값
      // 필요 시 Babel 플러그인 체인 추가 가능
      babel: {
        plugins: ['@emotion/babel-plugin']
      }
    })
  ]
});

7. Webpack: 엔터프라이즈 표준

Webpack은 유연한 모듈 번들러이다. 방대한 로더와 플러그인 생태계를 통해 거의 모든 종류의 웹 애플리케이션 빌드 파이프라인을 구축할 수 있으며, 오랜 기간 표준으로 자리 잡아왔다.
Webpack은 로더(loader)를 통해 트랜스파일러를 선택적으로 사용할 수 있다.

Webpack 공식 사이트

로더 선택 가이드

  • babel-loader : 가장 성숙하고 방대한 플러그인 생태계를 활용해야 할 때 최적의 선택이다. 유연성이 가장 높다.
  • swc-loader / esbuild-loader : 기존 babel-loader 대비 월등히 빠른 빌드 속도를 원할 때 사용한다. Babel 만큼의 플러그인 호환성은 없지만, 속도 향상 효과가 매우 크다.

webpack.config.js with Babel

module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'swc-loader',
          options: {
            jsc: {
              transform: {
                react: {
                  runtime: 'automatic',
                  importSource: 'react'
                }
              }
            }
          }
        }
      }
    ]
  }
};

8. 실전 마이그레이션 시나리오

시나리오 1: CRA 프로젝트 마이그레이션

# 1. React 버전 업그레이드
npm update react react-dom
 
# 2. react-scripts 업데이트 (5.0+는 automatic 기본)
npm update react-scripts
 
# 3. ESLint 설정 수정
// .eslintrc.json
{
  "extends": ["react-app"],
  "rules": {
    "react/react-in-jsx-scope": "off"
  }
}

시나리오 2: 레거시 프로젝트 점진적 마이그레이션

// 1단계: 빌드 설정은 classic 유지
{
  "presets": [
    ["@babel/preset-react", {
      "runtime": "classic"
    }]
  ]
}
 
// 2단계: 파일별로 automatic 전환
/** @jsxRuntime automatic */
// 이 파일만 automatic 사용
 
// 3단계: 전체 전환
{
  "runtime": "automatic"
}

시나리오 3: 모노레포 환경

// packages/shared/.babelrc.json
{
  "presets": [
    ["@babel/preset-react", {
      "runtime": "automatic",
      "importSource": "@emotion/react" // 공통 스타일링
    }]
  ]
}
 
// packages/app-a/.babelrc.json
{
  "extends": "../shared/.babelrc.json",
  "presets": [
    ["@babel/preset-react", {
      "runtime": "automatic",
      "importSource": "react" // 기본 React
    }]
  ]
}

9. 트러블슈팅 가이드

1. Mixed Runtime Error

Error: Automatic JSX runtime requires importing 'jsx' from 'react/jsx-runtime'
but 'React' is already in scope

해결책 :

// 잘못된 코드
import React from 'react'; // automatic에서는 불필요
 
// 올바른 코드
// import 제거하거나
import { useState } from 'react'; // 필요한 것만 import

2. JSX Import Source Not Found

Module not found: Can't resolve 'react/jsx-runtime'

해결책 :

# React 버전 확인 (17+ 필요)
npm list react
 
# 업데이트
npm update react react-dom

문제 3: TypeScript 타입 에러

// JSX element type 'Element' is not a constructor function for JSX elements

해결책 :

// tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "types": ["react", "react-dom"]
  }
}

10. 성능 측정과 최적화

빌드 시간 비교

// 측정 스크립트
const { execSync } = require('child_process');
 
const configs = [
  { name: 'Babel Classic', cmd: 'babel src --jsx-runtime classic' },
  { name: 'Babel Automatic', cmd: 'babel src --jsx-runtime automatic' },
  { name: 'esbuild', cmd: 'esbuild src/**/*.jsx --jsx=automatic' },
  { name: 'SWC', cmd: 'swc src -d dist' }
];
 
configs.forEach(({ name, cmd }) => {
  console.time(name);
  execSync(cmd);
  console.timeEnd(name);
});
  • 실제 측정 결과 (1000개 컴포넌트):
    • Babel Classic: 3.24s
    • Babel Automatic: 3.18s
    • esbuild: 0.12s
    • SWC: 0.31s

번들 사이즈 최적화

// webpack-bundle-analyzer로 분석
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html'
    })
  ]
};

11. 결론: 도구는 다르지만 목표는 하나

// 모든 도구가 지향하는 미래
function App() {
  return <div>Simple, Clean, Efficient</div>;
}

빌드 도구마다 설정도 다르고 기준도 다르지만, 모두 같은 목표를 향하고 있다.
더 작은 번들 사이즈, 더 빠른 빌드 속도, 그리고 더 나은 개발 경험.

도구속도유연성/생태계주 사용처
Babel느림최상높은 커스터마이징이 필요하거나, 레거시 지원이 중요한 환경
SWC빠름좋음Next.js, Webpack/Vite 등에서 속도와 유연성의 균형을 잡을 때
esbuild가장 빠름보통Vite 개발 서버, 빠른 프로토타이핑, 번들링 속도가 최우선일 때

결국 가장 중요한 것은 "어떤 도구가 최고인가"가 아니라, "우리 프로젝트의 요구사항에 가장 잘 맞는 도구와 설정은 무엇인가"를 고민하는 것이다.