junyeokk
Blog
react-internals·2024. 09. 26

함수 컴포넌트 렌더링 파이프라인

React에서 컴포넌트를 작성하는 가장 기본적인 방식은 함수 컴포넌트다. JSX로 <App /> 같은 코드를 작성하면 내부적으로 어떤 과정을 거쳐 실제 DOM이 만들어질까? 겉으로는 단순해 보이지만, 함수 호출 → VNode 반환 → DOM 생성이라는 명확한 파이프라인이 존재한다.

이 글에서는 함수 컴포넌트가 어떻게 Virtual DOM 트리의 일부로 통합되고, 최종적으로 브라우저에 렌더링되는지 그 내부 동작을 살펴본다.


클래스 컴포넌트와의 차이

React 초기에는 클래스 컴포넌트가 유일한 컴포넌트 작성 방식이었다. render() 메서드를 통해 VNode를 반환하고, 생명주기 메서드(componentDidMount, shouldComponentUpdate 등)로 상태와 사이드이펙트를 관리했다.

javascript
class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  render() {
    return createElement("div", null, this.state.count);
  }
}

함수 컴포넌트는 이보다 훨씬 단순하다. 그냥 함수다. props를 인자로 받아서 VNode를 반환하기만 하면 된다.

javascript
function Counter({ count }) {
  return createElement("div", null, count);
}

클래스 컴포넌트에서는 new Counter(props)로 인스턴스를 생성한 뒤 instance.render()를 호출해야 했다면, 함수 컴포넌트는 Counter(props)로 바로 호출하면 끝이다. 인스턴스 생성 과정이 없기 때문에 메모리 오버헤드가 적고, 코드도 간결해진다.

하지만 이 "단순함"이 렌더링 엔진 입장에서는 하나의 문제를 만든다. createElementtype에 문자열("div")이 올 수도 있고 함수(Counter)가 올 수도 있는데, 이 두 가지를 어떻게 구분해서 처리할 것인가?


JSX에서 함수 컴포넌트까지

JSX는 문법 설탕이다. 빌드 타임에 createElement 호출로 변환된다. 여기서 중요한 점은 HTML 태그와 컴포넌트를 구분하는 규칙이다.

jsx
// JSX
<div className="wrapper">
  <Header title="Hello" />
</div>

// 변환 결과
createElement("div", { className: "wrapper" },
  createElement(Header, { title: "Hello" })
);

소문자로 시작하면 문자열("div")로, 대문자로 시작하면 함수 참조(Header)로 변환된다. 이것이 React에서 컴포넌트 이름을 반드시 대문자로 시작해야 하는 이유다. 소문자로 쓰면 빌드 도구가 HTML 태그로 인식해서 문자열로 변환해버린다.

createElement가 반환하는 객체의 구조를 보면:

javascript
// HTML 태그의 경우
{ type: "div", props: { className: "wrapper" }, key: null }

// 함수 컴포넌트의 경우
{ type: Header, props: { title: "Hello" }, key: null }

type 필드에 문자열이 들어있으면 네이티브 DOM 요소, 함수가 들어있으면 컴포넌트다. createElement 자체는 이 둘을 구분하지 않고 동일한 형태의 객체를 만든다. 실제 분기는 이 객체를 DOM으로 변환하는 시점에서 발생한다.


렌더링 파이프라인의 핵심: type 분기

VNode를 실제 DOM 노드로 변환하는 함수에서 가장 중요한 분기점은 typeof type === "function" 검사다. 전체 흐름을 단순화하면 이렇다:

javascript
function createDomNode(vnode) {
  // 1. 텍스트 노드 처리
  if (typeof vnode === "string" || typeof vnode === "number") {
    return document.createTextNode(String(vnode));
  }

  // 2. ReactElement인지 확인
  if (!isReactElement(vnode)) {
    return null;
  }

  // 3. 함수 컴포넌트인 경우
  if (typeof vnode.type === "function") {
    const componentVNode = vnode.type(vnode.props);
    return createDomNode(componentVNode);
  }

  // 4. 네이티브 HTML 태그인 경우
  const domElement = document.createElement(vnode.type);
  // ... 속성 설정, 자식 렌더링
  return domElement;
}

3번이 핵심이다. vnode.type이 함수라면, 그 함수를 vnode.props를 인자로 호출한다. 함수 컴포넌트는 항상 VNode를 반환하므로, 그 결과를 다시 createDomNode에 재귀적으로 넘긴다.

이 재귀가 의미하는 바를 구체적으로 살펴보자:

javascript
function Header({ title }) {
  return createElement("h1", { className: "header" }, title);
}

// createElement(Header, { title: "Hello" })의 결과:
// { type: Header, props: { title: "Hello" }, key: null }

createDomNode가 이 VNode를 받으면:

  1. typeof Header === "function" → true
  2. Header({ title: "Hello" })를 호출
  3. 반환값: { type: "h1", props: { className: "header", children: "Hello" }, key: null }
  4. 이 VNode를 다시 createDomNode에 전달
  5. 이번엔 typeof "h1" === "function" → false → document.createElement("h1") 실행

함수 컴포넌트는 VNode 트리에서 중간 레이어로 동작한다. 최종적으로는 네이티브 태그("div", "h1" 등)에 도달할 때까지 재귀적으로 풀려나간다.


타입 가드: ReactElement 판별

렌더링 파이프라인에서 VNode를 처리하기 전에, 해당 값이 유효한 ReactElement인지 먼저 확인해야 한다. ReactNode 타입은 다양한 값을 포함하기 때문이다:

typescript
type ReactNode = ReactElement | string | number | boolean | null | undefined | ReactNode[];

문자열, 숫자, null, undefined, 배열 등이 모두 ReactNode로 올 수 있다. 이 중에서 typeprops 속성을 가진 객체만이 ReactElement다.

typescript
function isReactElement(node: ReactNode): node is ReactElement {
  return (
    node !== null &&
    typeof node === "object" &&
    "type" in node &&
    "props" in node &&
    "key" in node
  );
}

TypeScript의 node is ReactElement 타입 가드 문법을 사용하면, 이 함수가 true를 반환한 이후의 코드에서 nodeReactElement 타입으로 안전하게 사용할 수 있다. 런타임 안전성과 타입 안전성을 동시에 확보하는 패턴이다.

이 타입 가드가 없으면 렌더링 함수 곳곳에서 (node as ReactElement).type 같은 타입 단언을 써야 하고, 잘못된 값이 들어왔을 때 런타임 에러가 발생한다. 타입 가드를 분기 조건으로 사용하면 잘못된 값은 자연스럽게 걸러진다.


컴포넌트 중첩과 재귀적 해소

실제 애플리케이션에서 컴포넌트는 여러 단계로 중첩된다. 최상위 App 컴포넌트가 TodoList를 렌더링하고, TodoListTodoItem을 렌더링하는 식이다.

javascript
function TodoItem({ task, completed, onToggle }) {
  return createElement(
    "li",
    { className: completed ? "completed" : "" },
    createElement("input", {
      type: "checkbox",
      checked: completed,
    }),
    createElement("span", null, task)
  );
}

function TodoList({ items, onToggle }) {
  return createElement(
    "ul",
    { id: "todo-list" },
    ...items.map(item =>
      createElement(TodoItem, {
        key: item.id,
        task: item.task,
        completed: item.completed,
        onToggle: onToggle,
      })
    )
  );
}

function App() {
  const items = [
    { id: 1, task: "Buy milk", completed: false },
    { id: 2, task: "Write code", completed: true },
  ];

  return createElement(
    "div",
    { id: "app" },
    createElement("h2", null, "Todo List"),
    createElement(TodoList, { items, onToggle: () => {} })
  );
}

createDomNodeApp의 VNode를 처리하는 과정:

  1. App은 함수 → App({})을 호출 → { type: "div", ... } 반환
  2. "div"는 문자열 → document.createElement("div") 생성
  3. 자식 중 { type: "h2", ... }document.createElement("h2") 생성
  4. 자식 중 { type: TodoList, ... }TodoList(props) 호출
  5. TodoList{ type: "ul", ... } 반환 → document.createElement("ul") 생성
  6. ul의 자식 중 { type: TodoItem, ... }TodoItem(props) 호출
  7. TodoItem{ type: "li", ... } 반환 → document.createElement("li") 생성

이처럼 함수 컴포넌트는 호출될 때마다 한 단계씩 "풀려서" 결국 네이티브 태그만 남는 VNode 트리가 된다. 이 과정을 컴포넌트 해소(resolution)라고 부른다.


ElementType 설계

타입 시스템에서 type 필드가 받을 수 있는 값의 범위를 정의하는 것도 중요하다:

typescript
type ElementType = keyof JSX.IntrinsicElements | Function;

keyof JSX.IntrinsicElements"div", "span", "input" 같은 모든 HTML 태그의 유니온 타입이다. 여기에 Function을 유니온으로 추가하면 함수 컴포넌트도 type에 들어갈 수 있게 된다.

더 엄격한 타이핑을 원한다면 Function 대신 구체적인 시그니처를 사용할 수 있다:

typescript
type FunctionComponent<P = {}> = (props: P) => ReactNode;
type ElementType = keyof JSX.IntrinsicElements | FunctionComponent<any>;

이렇게 하면 type에 아무 함수나 들어가는 것을 방지하고, "props를 받아서 ReactNode를 반환하는 함수"만 허용할 수 있다. 실무에서는 이 방식이 컴파일 타임에 잘못된 컴포넌트 사용을 잡아주므로 더 안전하다.


children 처리의 미묘함

createElement에서 children을 처리하는 방식은 겉보기보다 복잡하다:

javascript
function createElement(type, props, ...children) {
  const finalProps = props || {};

  if (children.length > 0) {
    finalProps.children = children.length === 1 ? children[0] : children;
  }

  return { type, props: { ...finalProps }, key: finalProps.key || null };
}

children이 하나일 때와 여러 개일 때 다르게 처리하는 이유가 있다. 단일 자식인 경우:

javascript
createElement("h1", null, "Hello")
// → { type: "h1", props: { children: "Hello" }, key: null }

여러 자식인 경우:

javascript
createElement("div", null, "Hello", "World")
// → { type: "div", props: { children: ["Hello", "World"] }, key: null }

단일 자식을 배열로 감싸지 않는 이유는 성능과 편의성 때문이다. 컴포넌트 내부에서 props.children이 문자열인지 배열인지에 따라 분기해야 하지만, 대부분의 경우 단일 자식(텍스트 콘텐츠)이 더 흔하기 때문에 불필요한 배열 래핑을 피한다.

렌더링 함수에서는 이 차이를 처리해야 한다:

javascript
const children = vnode.props.children || [];
const flatChildren = Array.isArray(children) ? children : [children];

flatChildren.forEach(child => {
  const childNode = createDomNode(child);
  if (childNode) {
    domElement.appendChild(childNode);
  }
});

children이 배열이 아닐 수 있으므로 항상 Array.isArray() 검사 후 정규화하는 패턴이 필수다. 이 정규화를 빠뜨리면 단일 자식을 가진 컴포넌트에서 forEach is not a function 에러가 발생한다.


diff 알고리즘과의 연동

함수 컴포넌트는 최초 렌더링뿐 아니라 업데이트 시에도 중요한 역할을 한다. 상태가 변경되면 render 함수가 다시 호출되고, 새로운 VNode 트리와 이전 VNode 트리를 비교하는 diff 과정이 시작된다.

javascript
function render(newVNode, container) {
  const oldVNode = container._vnode || null;
  diff(newVNode, oldVNode, container);
  container._vnode = newVNode;
}

diff 함수는 두 VNode를 비교할 때 type이 같은지 먼저 확인한다. 함수 컴포넌트의 경우 type은 함수 참조이므로, 같은 컴포넌트인지는 함수 참조의 동일성으로 판단한다.

javascript
function diff(newVNode, oldVNode, container) {
  // type이 다르면 완전히 교체
  if (!isReactElement(oldVNode) || newVNode.type !== oldVNode.type) {
    const newDomNode = createDomNode(newVNode);
    if (newDomNode) {
      container.replaceWith(newDomNode);
    }
  } else {
    // type이 같으면 속성만 업데이트
    updateProps(container, oldVNode.props, newVNode.props);
    diffChildren(newVNode, oldVNode, container);
  }
}

여기서 주의할 점: 함수 컴포넌트의 type 비교는 === 연산자로 수행된다. 즉, 렌더링할 때마다 새로운 함수를 생성하면 항상 다른 컴포넌트로 인식되어 불필요한 DOM 교체가 발생한다.

javascript
// ❌ 이러면 매 렌더링마다 새 함수가 생성됨
function App() {
  const Item = ({ text }) => createElement("span", null, text);
  return createElement(Item, { text: "hello" });
}

// ✅ 컴포넌트를 외부에 정의
function Item({ text }) {
  return createElement("span", null, text);
}

function App() {
  return createElement(Item, { text: "hello" });
}

컴포넌트 정의를 렌더링 함수 안에 넣으면 매 호출마다 새로운 함수 객체가 만들어지고, diff에서 oldType !== newType이 되어 전체 서브트리를 다시 생성하게 된다. 이것은 실제 React에서도 동일한 문제이며, 성능 최적화의 기본 중 하나다.


실제 React와의 차이

위에서 설명한 방식은 함수 컴포넌트의 기본 원리를 보여주지만, 실제 React의 구현과는 몇 가지 중요한 차이가 있다.

Fiber 아키텍처

실제 React는 VNode를 바로 DOM으로 변환하지 않는다. 먼저 Fiber 트리를 구성한다. 각 Fiber 노드는 컴포넌트 하나에 대응하며, 상태, 이펙트, 부모/자식/형제 링크 등의 정보를 담고 있다. 렌더링은 두 단계로 나뉜다:

  1. Render Phase: Fiber 트리를 순회하며 변경 사항을 계산 (중단 가능)
  2. Commit Phase: 계산된 변경 사항을 실제 DOM에 적용 (중단 불가)

이 분리 덕분에 React는 렌더링 도중 우선순위가 높은 업데이트를 먼저 처리하는 Concurrent Mode를 구현할 수 있다.

Hook 시스템

단순 구현에서는 함수 컴포넌트를 호출만 하면 끝이지만, 실제 React는 호출 전에 "현재 컴포넌트"를 내부적으로 설정하고, Hook(useState, useEffect 등)이 이 컨텍스트를 참조한다.

javascript
// 개념적 구조
let currentFiber = null;

function renderFunctionComponent(fiber) {
  currentFiber = fiber;  // Hook이 참조할 컨텍스트 설정
  const children = fiber.type(fiber.props);  // 컴포넌트 호출
  currentFiber = null;
  return children;
}

이것이 Hook을 조건문이나 반복문 안에서 호출하면 안 되는 이유다. Hook은 호출 순서에 의존하여 상태를 매칭하는데, 조건에 따라 호출 순서가 바뀌면 잘못된 상태가 반환된다.

배치 업데이트

단순 구현에서는 setState가 호출될 때마다 즉시 리렌더링이 발생한다. 실제 React는 같은 이벤트 핸들러 내의 여러 setState를 하나로 모아서 한 번만 리렌더링한다. React 18부터는 이벤트 핸들러뿐 아니라 setTimeout, Promise 콜백 등에서도 자동 배칭이 적용된다.


정리

함수 컴포넌트의 렌더링은 결국 "함수를 호출해서 VNode를 얻고, 네이티브 태그가 나올 때까지 반복"이라는 단순한 재귀로 설명된다. 핵심 포인트를 정리하면:

단계동작
JSX 변환<Component />createElement(Component, props)
VNode 생성{ type: Component, props, key } 객체 생성
type 분기typeof type === "function"이면 컴포넌트, 아니면 네이티브 태그
컴포넌트 해소type(props)를 호출하여 반환된 VNode를 재귀 처리
DOM 생성네이티브 태그에 도달하면 document.createElement 실행

이 파이프라인을 이해하면 React가 왜 컴포넌트 이름을 대문자로 요구하는지, 왜 컴포넌트를 렌더 함수 안에서 정의하면 안 되는지, 그리고 Hook이 왜 호출 순서에 의존하는지가 자연스럽게 이해된다.