Babel 설정을 통한 React의 JSX Transform

들어가며

리액트가 어떻게 JSX 코드를 브라우저가 이해할 수 있는 JavaScript로 변환하는지 알아보겠습니다.

이를 위해서 Babel의 @babel/plugin-transform-react-jsx 플러그인을 통해 트랜스파일 과정을 살펴보고, 이 과정에서 React.createElement와 jsx 함수가 어떤 역할을 하는지 분석해 보겠습니다.

JSX Transform

우선 아래와 같이 플러그인을 설정하고 컴파일을 진행해 보겠습니다.

babel.config.json
{
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      {
        "runtime": "classic", 
      }
    ]
  ]
}

위 설정에서 runtime: "classic"은 React 17 이전 버전에서 사용하던 방식입니다.

17부터는 runtime: "automatic"을 사용합니다. 이와 관련해서는 추후 다시 살펴보겠습니다.

이제 간단한 React 컴포넌트를 살펴보겠습니다.

Before
export default function App() {
  return (
    <div id="app">
      <h1>hello</h1>
    </div>
  );
}

이 JSX 코드가 Babel을 통해 트랜스파일되면 다음과 같이 변환됩니다.

After
export default function App() {
  return /*#__PURE__*/ React.createElement(
    "div",
    {
      id: "app",
    },
    /*#__PURE__*/ React.createElement("h1", null, "hello")
  );
}

리액트의 공식문서에 따르면 createElement(type, props, ...children) 함수는 다음과 같이 ReactElement 객체를 만들어 Virtual DOM을 구성합니다.

ReactElement
// Slightly simplified
{
  type: "div",
  props: {
    id: 'app'
    children: {
      type: "h1",
      props: {
        children: "Hello"
      }
    }
  },
  key: null,
  ref: null,
}

그렇다면 runtime: "automatic" 옵션은 무엇일까요?

automatic compile
import { jsx as _jsx } from "react/jsx-runtime";
export default function App() {
  return /*#__PURE__*/_jsx("div", {
    id: "app",
    children: /*#__PURE__*/_jsx("h1", {
      children: "hello"
    })
  });
}

놀랍게도 React.createElement 대신 react/jsx-runtime에서 jsx 함수를 불러옵니다.

그러면 jsx 함수는 반환값이 다를까요? 잠시 살펴보겠습니다.

ReactJSXElement.js
 
export function jsxProd(type, config, maybeKey) {
  let key = null;
 
  /* ...생략....*/
 
  let props;
  if (!('key' in config)) {
    // If key was not spread in, we can reuse the original props object. This
    // only works for `jsx`, not `createElement`, because `jsx` is a compiler
    // target and the compiler always passes a new object. For `createElement`,
    // we can't assume a new object is passed every time because it can be
    // called manually.
    props = config;
  } 
 
  /* ...생략....*/
 
  return ReactElement(
    type,
    key,
    undefined,
    undefined,
    getOwner(),
    props,
    undefined,
    undefined,
  );
}
 

반환값은 똑같이 ReactElement를 반환합니다. 그러면 createElement와 무슨 차이가 있을까요?

리액트에서는 이 차이점을 주석 부분에 친절히 설명해주었습니다.

번역하자면 키를 기준으로 jsx 함수는 컴파일러를 대상으로 하기 때문에 props가 항상 새로운 객체임을 보장하지만, createElement는 수동으로 호출할 수 있기 때문에 새로운 객체임을 보장하지 않는다는 점입니다.

이를 통해 우리는 jsx 함수와 createElement가 나뉜 것은 새로운 객체를 보장하느냐 아니냐임을 알 수 있습니다.

그러면 새로운 객체를 보장하는 게 왜 중요할까요?

이는 리액트의 렌더링 최적화 및 함수형 컴포넌트의 동작 방식과 밀접한 관련이 있습니다.

함수형 컴포넌트는 클래스 컴포넌트와 달리 인스턴스를 생성하지 않고 매 렌더링마다 함수를 새로 호출하여 결과값을 반환합니다. 이때 함수 내부에서 선언된 모든 것들이 매번 새롭게 생성됩니다.

리액트는 컴포넌트의 리렌더링 여부를 결정할 때 props의 변화를 확인합니다.

새로운 객체가 매번 보장된다는 것은 리액트가 이전 props와 현재 props를 비교할 때 명확한 기준을 갖게 해줍니다.

반면, 참조가 일관되지 않으면 리액트의 비교 알고리즘이 불필요한 리렌더링을 발생시키거나, 반대로 필요한 업데이트를 놓칠 수 있습니다.

그러면 createElement는 새로운 객체를 어떻게 보장할까요?

createElement
export function createElement(type, config, children) {
  // Reserved names are extracted
  const props = {}; 
  // Remaining properties are added to a new props object
  for (propName in config) {
    if (
      hasOwnProperty.call(config, propName) &&
      // Skip over reserved prop names
      propName !== 'key' &&
      // Even though we don't use these anymore in the runtime, we don't want
      // them to appear as props, so in createElement we filter them out.
      // We don't have to do this in the jsx() runtime because the jsx()
      // transform never passed these as props; it used separate arguments.
      propName !== '__self' &&
      propName !== '__source'
    ) {
      props[propName] = config[propName];
    }
  }
}

두 함수 모두 결과적으로는 새로운 props 객체를 생성하지만, createElement의 경우 최적화와 관련된 몇 가지 문제를 안고 있었습니다.

(원문)

  • We don’t know if the passed in props is a user created object that can be mutated so we must always clone it once.
  • The transform uses React.createElement which is a dynamic property look up instead of a constant closed over module scope. This minimizes poorly and takes a little cost to run.

(번역)

  • 전달된 props가 변경될 수 있는 사용자 생성 객체인지 알 수 없으므로 항상 한 번 복제해야 합니다.
  • 이 변환은 모듈 스코프에서 클로즈된 상수 대신 동적 속성 조회인 React.createElement를 사용합니다. 이는 최소화가 잘 되지 않으며 실행하는 데 약간의 비용이 듭니다.

+) 더 많은 이슈는 이곳을 참고해주세요.

객체 복사는 위의 코드에서 차이점을 확인했습니다.

그러면 모듈 스코프에서 클로즈된 상수와 동적 속성 조회는 무엇을 의미할까요?

import

The transform uses React.createElement which is a dynamic property look up instead of a constant closed over module scope. This minimizes poorly and takes a little cost to run.

동적 속성 조회(dynamic property look up)

React.createElement와 같이 객체의 속성에 접근하는 방식입니다.

런타임에 React 객체를 찾고, 그 안에서 createElement 속성을 찾아야 합니다.

이는 자바스크립트 엔진이 매번 이 경로를 따라가서 함수를 찾아야 하므로 약간의 성능 비용이 발생합니다.

모듈 스코프에서 클로즈된 상수(constant closed over module scope)

모듈 스코프에서 직접 함수나 값을 가져와 사용하는 방식입니다.

automatic
import { jsx as _jsx } from "react/jsx-runtime";
export default function App() {
  return /*#__PURE__*/_jsx("div", {
    id: "app",
    children: /*#__PURE__*/_jsx("h1", {
      children: "hello"
    })
  });
}

Babel이 트랜스파일하는 과정에서 import { jsx as _jsx } from "react/jsx-runtime"를 주입해줍니다.

덕분에 이런 정적 import는 트리 쉐이킹과 같은 최적화 기법을 더 효과적으로 적용할 수 있으며, 매번 React 객체를 통해 ‘createElement’를 찾는 과정이 없어 미세한 성능 최적화 효과를 누릴 수 있습니다.

나가며

JSX가 어떻게 트랜스파일 되는지 확인해보면서 리액트가 어떻게 최적화를 하는지 살짝 엿볼 수 있었습니다.

여기서 한 가지 중요한 부분은 매 렌더링 시 함수형 컴포넌트가 새로운 객체를 반환한다면 상태는 어떻게 유지가 될까요?

바로 클로저와 파이버인데요.

이에 대해 더 알아보면 좋을 거 같습니다.