본문으로 건너뛰기

JSX 내부 동작과 트랜스파일 정리

·11 min read

JSX를 매일 쓰면서도 빌드 시점에 정확히 어떤 변환을 거치는지 흐릿했다. "JSX는 결국 함수 호출로 바뀐다" 정도만 알고 있었지, 그 사이에 어떤 단계가 끼어 있는지 한번 짚어보고 싶었다. 그래서 JSX가 어떤 목적을 가졌고, 자바스크립트로 어떻게 변환되며, 그 변환을 가능하게 하는 도구들이 무엇을 하는지 정리해뒀다.

JSX는 왜 쓰는가

리액트 컴포넌트를 쓰면서 반복·계산·인라인 실행 같은 자바스크립트의 표현력을 그대로 살리고 싶었다. 그러면서도 트리 구조의 UI를 HTML처럼 직관적으로 적고 싶다는 요구가 같이 있었다. JSX는 그 둘을 같이 해결하려고 만들어진 자바스크립트용 구문 확장(syntax extension)이다.

JSX는 별도의 언어가 아니다. 컴파일러나 트랜스파일러가 일반 자바스크립트 코드로 변환해주는 확장 구문이고, 브라우저에 전달되기 전에 바닐라 JS로 바뀐다. 가독성을 높이고 컴포넌트 코드를 간단히 적기 위한 표기일 뿐, 실행 단계에서는 결국 평범한 함수 호출이다.

HTML과 닮았지만 다른 부분

JSX는 HTML처럼 보이지만 몇 가지가 다르다. 이걸 헷갈리면 처음에 빌드 에러가 자주 났다.

  1. 중괄호({})로 자바스크립트 표현식을 삽입한다. HTML 같은 구조 안에서 변수와 식을 자연스럽게 끼워넣을 수 있게 한다.
  2. 대부분의 속성은 카멜케이스로 쓴다. tabindextabIndex로 적는다. 단, 자바스크립트 예약어와 충돌하는 classfor는 각각 className, htmlFor로 리네이밍된다. data-*, aria-* 속성은 하이픈을 유지한다.
  3. 사용자 정의 컴포넌트는 첫 글자를 대문자로 적는다. 소문자로 시작하는 태그는 React가 div, span 같은 HTML 내장 요소로 해석한다.

JSX를 함수 호출로 풀어보면

JSX로 쓴 컴포넌트는 빌드 단계에서 함수 호출 형태로 변환된다. React 17 이전 classic runtime에서는 React.createElement 호출로, React 17부터는 새 JSX 변환(automatic runtime)이 기본값이 되어 react/jsx-runtimejsx/jsxs 함수 호출로 변환된다.

JSX로 적은 컴포넌트 예시:

const MyComponent = () => {
  return (
    <section id="list-section">
      <h1>제목입니다.</h1>
      <ul id="list">
        {list.map((item) => (
          <li key={item.id}>{item.component}</li>
        ))}
      </ul>
    </section>
  )
}

같은 코드를 classic runtime 기준으로 직접 풀어쓰면 이런 모양이 된다. JSX가 사라지자 트리 구조가 중첩된 함수 호출로 바뀐 게 한눈에 보인다.

const MyComponent = () => {
  return React.createElement(
    "section",
    { id: "list-section" },
    React.createElement("h1", null, "제목입니다."),
    React.createElement(
      "ul",
      { id: "list" },
      list.map((item) =>
        React.createElement("li", { key: item.id }, item.component)
      )
    )
  )
}

JSX가 자바스크립트로 바뀌기까지 거치는 단계들

JSX가 함수 호출로 바뀌는 것까지는 알겠는데, 그 변환 자체가 어떤 흐름인지 더 들어가봤다. 자바스크립트 엔진이 소스 코드를 실행하는 일반적인 과정과 동일한 단계를 따른다.

토큰화: 문자열을 의미 단위로 자르기

토큰화는 문자열을 의미 있는 토큰으로 분해하는 과정이다. 이 단계에서 두 용어가 같이 등장하는데, 토크나이저는 문자열을 토큰 리스트로 잘라내는 단계이고, 렉서(lexical analyzer)는 각 토큰에 의미적 분류(키워드, 식별자, 연산자 등)와 타입 정보를 부여하는 단계다. 둘은 거의 동의어로도 쓰인다.

const x = 10을 예로 들어보면 다섯 조각으로 나뉜다.

  • const → 키워드(Token Type: Keyword)
  • x → 변수명(Token Type: Identifier)
  • = → 연산자(Token Type: Operator)
  • 10 → 숫자(Token Type: Number)
  • ; → 구분자(Token Type: Punctuation)

토크나이저는 단순히 문자열을 잘라낸 결과만 내놓는다.

["let", "y", "=", "x", "+", "5", ";"]

렉서는 거기에 타입까지 붙여준다.

[
  { "type": "Keyword", "value": "let" },
  { "type": "Identifier", "value": "y" },
  { "type": "Operator", "value": "=" },
  { "type": "Identifier", "value": "x" },
  { "type": "Operator", "value": "+" },
  { "type": "Number", "value": "5" },
  { "type": "Punctuation", "value": ";" }
]

구문 분석: 토큰을 트리로 묶기

토큰 리스트만 보면 평면적인 조각들이라 의미가 안 잡힌다. 구문 분석 단계가 이 토큰들을 가져와 구문 트리(AST, Abstract Syntax Tree)로 변환한다. AST는 코드의 구조를 나타내는 자료 구조이고, 구문 분석기에 의해 문자열이 JSON 형태의 트리 객체가 된다.

const a = 1; let b = 2;를 파싱하면 이런 트리가 만들어진다.

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": { "type": "Identifier", "name": "a" },
          "init": { "type": "Literal", "value": 1, "raw": "1" }
        }
      ],
      "kind": "const"
    },
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": { "type": "Identifier", "name": "b" },
          "init": { "type": "Literal", "value": 2, "raw": "2" }
        }
      ],
      "kind": "let"
    }
  ]
}

코드 생성: 트리에서 실행 가능한 코드로

AST가 만들어졌으면, 그걸 기반으로 실행 가능한 코드(바이트코드 또는 기계어)를 생성한다. 자바스크립트 엔진(V8 등)의 경우 AST를 먼저 바이트코드로 변환해 인터프리터(Ignition)가 실행하고, 자주 호출되는 부분은 JIT 컴파일러(TurboFan 등)가 기계어로 최적화한다.

런타임은 엔진이 아니다

여기서 한 가지 헷갈렸던 게 엔진과 런타임의 구분이다. 엔진은 자바스크립트 코드를 실행하는 핵심이고, 런타임은 엔진과 연동해 특정 환경에 맞는 컨텍스트 헬퍼와 기능을 추가로 제공한다. 브라우저 런타임은 window, document 객체 같은 컨텍스트를 제공한다.

Node.js는 런타임이 달라서 window 전역 객체가 없다. 대신 global 또는 표준화된 globalThis를 쓴다. 같은 V8 엔진을 쓰더라도 런타임이 다르면 사용할 수 있는 전역 객체가 달라진다는 뜻이다. 종류로는 크로미움 런타임(크롬), 사파리(사파리 브라우저), Node.js(서버) 등이 있다.

JSX를 엔진이 직접 못 읽는 이유

엔진은 표준 자바스크립트 문법만 이해한다. JSX 같은 확장 구문은 엔진 입장에서 알 수 없는 문자열이다. 그래서 엔진보다 앞 단계에서 JSX를 처리해주는 전처리기가 필요했다.

전처리기는 확장 언어로 작성된 코드 문자열을 이해할 수 있는 렉서와 구문 분석기를 갖춰야 한다. 그렇게 만들어진 구문 트리를 다시 자바스크립트 엔진이 이해할 수 있는 바닐라 JS로 변환해주는 게 Babel, TypeScript, SWC, esbuild 같은 도구가 하는 일이다.

이 전처리기 역할을 위해 빌드 단계가 필요하다. 빌드를 통해 브라우저에 전달하기 전에 코드를 변환·컴파일하여 배포용 번들에 포함시키는 과정이 트랜스파일이다.

트랜스파일과 컴파일의 차이

트랜스파일은 소스 대 소스 컴파일이다. 특정 언어로 작성된 소스 코드를 추상화 수준이 비슷한 다른 언어로 변환하는 과정이다. 고수준 언어인 타입스크립트를 트랜스파일하면 또 다른 고수준 언어인 자바스크립트가 만들어진다. 일반 컴파일이 고수준에서 저수준(기계어·바이트코드)으로 내려가는 것과는 결이 다르다.

JSX 한 줄을 적었을 때 그 줄이 함수 호출로 바뀌고, 토큰으로 잘리고, AST가 되고, 바이트코드로 변환되어 엔진이 실행하기까지의 흐름이 이렇게 이어져 있었다. 평소 npm run build만 돌리고 결과물만 봤던 단계 안쪽이 조금 더 또렷해졌다.