React JSX Transform 깊이 파헤치기: Classic vs Automatic
React JSX Transform 깊이 파헤치기: Classic vs Automatic
1. 왜 갑자기 import React가 필요 없어졌지?
React 18은 우리에게 익숙한 표준 version이다.
하지만 지금의 React를 제대로 이해하려면, React 17 시절에 일어났던 한 가지 변화를 되짚어볼 필요가 있다.
// React 16까지
import React from 'react'; // 필수!
function Button() {
return <button>Click me</button>;
}
// React 17부터
// import React from 'react'; // 없어도 됨!
function Button() {
return <button>Click me</button>;
}
Babel 7.9.0과 함께 도입된 이 변화는 단순한 편의성 개선처럼 보인다.
하지만 사실 이 변화는 React 아키텍처의 중요한 토대 중 하나였다.
import React
가 사라진 진짜 이유와 그 기술적 배경에 대해 알아본다.
2. JSX란 무엇인가?
JSX는 JavaScript의 확장 문법이다. 하지만 브라우저는 JSX를 직접 이해하지 못한다.
// 우리가 작성하는 코드
const element = <h1 className="greeting">Hello, world!</h1>;
// 브라우저가 실행하는 코드 (변환 후)
const element = React.createElement(
'h1',
{ className: 'greeting' },
'Hello, world!'
);
이 변환 과정을 담당하는 것이 바로 Babel이나 TypeScript 같은 도구에 내장된 JSX Transform이다.
3. Classic Transform: React.createElement의 시대
어떻게 동작하나?
Classic Transform에서는 모든 JSX코드가 React.createElement
함수 호출로 변환된다.
// JSX 코드
function App() {
return <div><Header /></div>;
}
// 변환된 코드
function App() {
return React.createElement(
'div',
null,
React.createElement(Header, null)
);
}
왜 import React가 필요했나?
변환된 코드를 보면 답이 명확하다. React.createElement를 호출하려면 React 객체가 현재 파일의 스코프(scope) 안에 존재해야 한다. React가 import되지 않으면, JavaScript 엔진은 React라는 변수를 찾지 못해 ReferenceError를 발생시킨다.
그래서 JSX를 사용하는 모든 파일 상단에 import React from 'react';
구문을 의무적으로 작성해야 했다.
// React가 없으면?
function App() {
return React.createElement('div', null); // ReferenceError: React is not defined
}
Classic Transform의 한계
- 불필요한 import : JSX만 사용할 뿐 React의 다른 기능(Hooks 등)을 쓰지 않는 파일에서도 React를 import해야 하는 번거로움이 있었다.
- 학습 곡선 : 개인적인 의견으로, React를 처음 배우는 사람들에게 "왜 JSX를 쓰는데 React를 import해야 하나요?"라는 질문을 만들어냈다.
- 성능 및 확장성 : 모든 JSX가 React.createElement 하나에 묶여 있어, 향후 React 팀이 JSX 처리 방식을 최적화하거나 확장하는 데 제약이 있었다.
4. Automatic Transform
어떻게 동작하나?
Automatic Transform은 JSX를 React.createElement
대신, react/jsx-runtime
이라는 새로운 패키지에서 제공하는 특수 함수들로 변환한다.
// JSX 코드 (동일)
function App() {
return <div>Hello</div>;
}
// 변환된 코드 (automatic)
import { jsx as _jsx } from 'react/jsx-runtime';
function App() {
return _jsx('div', { children: 'Hello' });
}
핵심 차이점
- 자동 import: 개발자가 직접 import할 필요 없이, 빌드 도구가 컴파일 과정에서 필요한 함수(_jsx)만 자동으로 삽입한다.
- 별도의 진입점: React 객체 전체가 아닌, JSX 렌더링에만 특화된
react/jsx-runtime
또는react/jsx-dev-runtime
을 사용한다. - 최적화된 함수:
jsx
,jsxs
등 용도에 따라 함수를 분리하여 추가적인 최적화의 길을 열었다.
실제 변환 비교
복잡한 예제로 차이를 살펴보자.
// 원본 JSX
function TodoList({ items }) {
return (
<>
<h1>My Todos</h1>
<ul>
{items.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
</>
);
}
// Classic Transform
function TodoList({ items }) {
return React.createElement(
React.Fragment,
null,
React.createElement('h1', null, 'My Todos'),
React.createElement(
'ul',
null,
items.map(item =>
React.createElement('li', { key: item.id }, item.text)
)
)
);
}
// Automatic Transform 후
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime';
function TodoList({ items }) {
return _jsxs(_Fragment, {
children: [
_jsx('h1', { children: 'My Todos' }),
_jsx('ul', {
children: items.map(item =>
_jsx('li', { children: item.text }, item.id)
)
})
]
});
}
5. 내부 동작 원리 깊이 파헤치기
jsx vs jsxs: 왜 두 개로 나뉘었나?
얼핏 보면 "자식이 하나일 때 jsx, 여러 개일 때 jsxs"로 보이지만, 더 정확한 구분 기준은 "자식 요소들이 정적인 배열(static array)인가" 이다.
- jsxs:
<div><p>첫째</p><p>둘째</p></div>
와 같이 자식들이 컴파일 타임에 정적인 배열로 확정될 때 사용된다. React는 이 배열에 key가 필요 없다는 것을 알고 있어 추가적인 최적화를 수행할 수 있다. - jsx: 자식이 하나만 있거나, items.map(...)처럼 동적인 배열을 반환하는 경우에 사용된다. 이 경우 각 요소는 고유한 key를 가질 수 있으므로, 개별적인 jsx 함수 호출로 처리된다.
이 구분은 런타임에 불필요한 key 경고나 최적화 방해를 막기 위한 섬세한 설계이다.
개발자 경험 개선: __source와 __self
Automatic Transform은 성능뿐만 아니라 개발 경험에도 크게 기여한다. 개발 모드에서는 JSX를 변환할 때 __source (파일 경로, 라인 번호)와 __self (컴포넌트 참조)라는 특수 prop을 자동으로 주입한다.
이것이 바로 React DevTools에서 특정 컴포넌트를 클릭하면 VSCode의 해당 소스 코드로 바로 이동하거나, 에러 메시지에 정확한 컴포넌트 호출 스택이 표시되는 이유이다.
6. 성능 차이 분석
(참고: 아래 수치는 간단한 로컬 벤치마크 기준이며, 프로젝트 환경에 따라 달라질 수 있다.)
번들 사이즈
import React from 'react'
구문이 사라지면서, 만약 해당 파일에서 다른 React API를 사용하지 않는다면 React 객체 전체를 불러올 필요가 없어진다. jsx-runtime은 JSX 처리에 필요한 최소한의 코드만 포함하고 있어, 이론적으로 번들 사이즈 감소 효과를 기대할 수 있다. 프로젝트 전체적으로는 작지만 의미 있는 크기 감소로 이어질 수 있다.
// Classic: React 전체 객체가 필요할 수 있음
import React from 'react'; // ~6.4KB (minified)
// Automatic: 필요한 함수만 import
import { jsx } from 'react/jsx-runtime'; // ~2.1KB (minified)
실제 프로젝트에서 측정한 결과:
- Classic Transform: 134.2KB (gzipped)
- Automatic Transform: 131.8KB (gzipped)
- 약 1.8% 감소
런타임 성능
Classic Transform은 React.createElement를 호출하기 위해 React 객체에 접근하는 과정이 필요하지만, Automatic Transform은 컴파일러가 삽입한 _jsx 함수를 직접 호출한다. 이러한 미세한 차이가 모여 약간의 런타임 성능 향상을 가져온다.
// Classic: 매번 객체 접근
React.createElement('div', null);
// Automatic: 직접 함수 호출
_jsx('div', {});
마이크로벤치마크 결과:
- Classic: 1,842,953 ops/sec
- Automatic: 1,923,847 ops/sec
- 약 4.4% 향상
7. 마이그레이션 가이드
Step 1: 빌드 도구 설정
Babel 설정(.babelrc.json):
{
"presets": [
["@babel/preset-react", {
"runtime": "automatic" // 기본값은 "classic"
}]
]
}
TypeScript 설정(tsconfig.json)::
{
"compilerOptions": {
"jsx": "react-jsx" // "react"는 classic
}
}
Step 2: ESLint 규칙 업데이트
import React
가 더 이상 필요 없으므로 관련 ESLint 규칙을 비활성화한다.
{
"rules": {
"react/react-in-jsx-scope": "off", // 더 이상 필요 없음
"react/jsx-uses-react": "off"
}
}
Step 3: 점불필요한 import React 제거
프로젝트 내의 모든 파일에서 불필요한 import React 구문을 수동으로 제거하는 것은 번거로운 일이다. React 팀이 공식적으로 제공하는 react-codemod
도구를 사용하면 이 과정을 자동으로 처리할 수 있다.
npx react-codemod update-react-imports
이 스크립트는 프로젝트의 모든 파일을 분석하여 새로운 JSX Transform에서 더 이상 필요하지 않은 import React 문을 안전하게 제거해 준다.
8. Custom JSX Factory
/** @jsxImportSource emotion */ 프라그마를 통해 JSX가 변환될 때 사용할 라이브러리를 지정할 수 있다. 이는 Emotion, Theme UI처럼 자신만의 JSX 처리 로직을 가진 라이브러리들이 React와 매끄럽게 통합될 수 있도록 해주는 강력한 기능이다.
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
function StyledComponent() {
return (
<div
css={css`
color: hotpink;
&:hover {
color: blue;
}
`}
>
Emotion with automatic runtime!
</div>
);
}
9. 주의사항: React가 여전히 필요한 경우
JSX를 쓰지 않더라도, useState, useEffect, lazy 등 React의 API를 직접 사용할 때는 여전히 React 또는 해당 API를 명시적으로 import해야 한다.
// Automatic 환경에서도 import가 없으면 에러 발생!
function Component() {
const [state, setState] = useState(0); // ReferenceError: useState is not defined
return <div>{state}</div>;
}
// 올바른 방법
import { useState } from 'react';
function Component() {
const [state, setState] = useState(0);
return <div>{state}</div>;
}
10. 작은 변화, 큰 의미
import React from 'react';
단순히 이 한 줄이 사라진 것처럼 보이지만, JSX Transform의 발전은 React의 철학과 미래를 보여주는 중요한 변화이다.
- 더 나은 개발 경험: 불필요한 코드를 줄이고, 더 나은 디버깅 환경(__source)을 제공하여 개발자가 핵심 로직에만 집중할 수 있게 돕는다.
- 성능 최적화: 미약하지만 꾸준히 번들 크기와 런타임 성능을 개선하려는 노력을 보여주고 있다.
- 미래를 위한 확장성: JSX를 React의 런타임에서 분리함으로써, React Server Components(RSC)와 같이 JSX를 서버에서 직렬화하여 클라이언트로 보내는 아키텍처를 위한 기술적 토대를 마련한다.