dev.syw

render 함수, VNode, diff 알고리즘과 컴파일러 최적화까지 렌더링 흐름을 이해한다.

렌더링 파이프라인과 가상 DOM

입문편에서는 <template> 안에 HTML처럼 마크업을 작성하면 Vue가 알아서 화면을 그려 준다고 배웠습니다. 하지만 "알아서"라는 말 뒤에는 꽤 정교한 파이프라인이 숨어 있습니다. 템플릿은 빌드 타임에 JavaScript 함수로 변환되고, 그 함수가 실행되면 실제 DOM이 아닌 **가상 DOM(VNode 트리)**을 만들어 냅니다. 상태가 바뀌면 Vue는 이전 VNode 트리와 새 VNode 트리를 비교(diff)해 최소한의 DOM 조작만 수행합니다.

이 흐름을 이해하면 key가 왜 중요한지, 컴파일러가 왜 특정 코드를 더 빠르게 실행하는지, 그리고 언제 h() 함수나 JSX를 직접 써야 하는지가 자연스럽게 납득됩니다. 이번 레슨은 Vue 렌더링의 "뒷단"을 집중적으로 파고듭니다.

학습 목표

  • 템플릿 컴파일 과정을 이해하고 render 함수의 역할을 파악한다.
  • h() 함수VNode 구조를 직접 다룰 수 있다.
  • diff 알고리즘key가 내부적으로 어떻게 작동하는지 설명할 수 있다.
  • PatchFlags·정적 호이스팅·트리 플래트닝 등 컴파일러 최적화 기법을 이해한다.
  • JSX·render 함수·함수형 컴포넌트가 필요한 상황을 구분하고 적용할 수 있다.

템플릿이 render 함수로 컴파일되는 과정

Vue SFC의 <template> 블록은 @vue/compiler-dom이 빌드 타임에 JavaScript 함수로 변환합니다. 런타임에는 이 함수가 호출될 뿐이며, 원본 HTML 문자열은 번들에 포함되지 않습니다.

변환된 결과를 직접 확인하려면 Vue 공식 Template Explorer를 사용하거나 아래처럼 빌드 출력물을 확인하면 됩니다.

<!-- 입력 템플릿 -->
<template>
  <div class="box">
    <p>{{ message }}</p>
  </div>
</template>
// 컴파일러가 생성하는 render 함수 (단순화)
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", { class: "box" }, [
    _createElementVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ]))
}
JavaScript

_toDisplayString은 값을 문자열로 변환하고, 마지막 숫자 1PatchFlag입니다(뒤에서 자세히 다룹니다). 컴파일러는 이처럼 어떤 부분이 동적인지를 표시해 두어 런타임이 해당 노드만 재검사하게 만듭니다.

💡 TIP vite-plugin-inspect를 사용하면 Vite 빌드 과정에서 SFC의 각 변환 단계를 브라우저 UI로 확인할 수 있습니다.

h() 함수와 VNode 구조 직접 다루기

h() 함수는 hyperscript의 약자로, VNode를 생성하는 핵심 API입니다. 컴파일러가 내부적으로 호출하는 createElementVNode·createVNode의 공개 인터페이스입니다.

// 시그니처
h(type, props?, children?)
인자타입설명
typestring | Component | FunctionalComponent태그명 또는 컴포넌트
propsobject | null어트리뷰트, 이벤트, 클래스 등
childrenstring | VNode[] | object자식 노드 또는 슬롯
import { h, ref } from 'vue'

// 직접 render 함수를 작성한 컴포넌트
export default {
  setup() {
    const count = ref(0)
    return () =>
      h('div', { class: 'counter' }, [
        h('p', null, `현재 값: ${count.value}`),
        h('button', { onClick: () => count.value++ }, '증가'),
      ])
  },
}
JavaScript

VNode는 평범한 JavaScript 객체입니다. 주요 필드를 살펴보면 Vue가 어떤 정보를 들고 다니는지 이해할 수 있습니다.

// VNode 구조 (Vue 3 내부, 일부 발췌)
{
  __v_isVNode: true,
  type: 'div',           // 태그명 또는 컴포넌트
  props: { class: 'box' },
  key: null,             // diff 최적화용 키
  ref: null,
  children: [...],       // 자식 VNode 배열
  el: null,              // 마운트 후 실제 DOM 참조
  shapeFlag: 17,         // 노드 종류 비트플래그
  patchFlag: 1,          // 컴파일러가 붙인 동적 힌트
  dynamicChildren: [...] // 블록 내 동적 자식만 추려 낸 배열
}
JavaScript

⚠️ 주의 VNode는 재사용하지 마세요. 한 번 마운트된 VNode를 다른 위치에서 재사용하면 예측 불가능한 버그가 발생합니다. h()는 렌더 함수가 호출될 때마다 새 객체를 반환해야 합니다.

diff(patch) 알고리즘과 key의 실제 역할

동일 레벨 비교 원칙

Vue의 diff 알고리즘은 동일 레벨에서만 비교합니다. 다른 레벨의 노드를 비교하지 않아 O(n) 시간 복잡도를 유지합니다.

두 VNode를 비교할 때 가장 먼저 확인하는 것은 typekey입니다.

// 내부 비교 로직 (의사 코드)
function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key
}
JavaScript

typekey가 다르면 Vue는 기존 노드를 언마운트하고 새 노드를 마운트합니다. 같으면 **patch(재사용)**합니다.

key가 없을 때 발생하는 문제

key 없이 동적 리스트를 렌더링하면 Vue는 인덱스 기반으로 비교합니다. 중간에 아이템이 삽입·삭제되면 이후 모든 노드가 patch 대상이 되어 불필요한 DOM 조작이 발생합니다.

<script setup>
import { ref } from 'vue'
const items = ref([
  { id: 1, text: '사과' },
  { id: 2, text: '바나나' },
  { id: 3, text: '체리' },
])
const prepend = () => items.value.unshift({ id: 4, text: '딸기' })
</script>

<template>
  <!-- ❌ key 없음 — 0번 인덱스 변경 시 1, 2번도 모두 patch -->
  <li v-for="item in items">{{ item.text }}</li>

  <!-- ✅ id를 key로 — Vue가 기존 li를 재사용, DOM 이동만 수행 -->
  <li v-for="item in items" :key="item.id">{{ item.text }}</li>
</template>

최장 증가 부분 수열(LIS) 기반 이동 최적화

Vue 3의 keyed diff는 단순한 이중 포인터를 넘어 **최장 증가 부분 수열(Longest Increasing Subsequence)**을 활용합니다. 이미 올바른 상대 순서에 있는 노드는 이동하지 않고, 나머지만 최소 횟수로 이동시킵니다.

이전: [a, b, c, d, e]
이후: [a, c, e, b, d]

LIS([c, e, b, d]의 인덱스 기준) = [c, e] → 이 두 노드는 이동 안 함
b, d만 새 위치로 이동 (insertBefore 2회)

💡 TIP key는 전역 유일할 필요가 없으며, 같은 v-for 레벨 내에서만 유일하면 됩니다. 단, 배열 인덱스를 key로 쓰는 것은 순서 변경 시 효과가 없으므로 피해야 합니다.

컴파일러 최적화: PatchFlags·정적 호이스팅·트리 플래트닝

Vue 3 컴파일러는 단순한 코드 변환을 넘어 런타임 성능을 높이는 여러 최적화를 적용합니다.

PatchFlags — 동적 바인딩 힌트

컴파일러는 각 동적 노드에 숫자 플래그를 붙입니다. 런타임은 이 플래그만 보고 어떤 속성을 업데이트할지 결정하므로 전체 props 비교를 건너뜁니다.

// PatchFlags 상수 (Vue 3 내부)
export const enum PatchFlags {
  TEXT          = 1,       // 동적 텍스트
  CLASS         = 2,       // 동적 class
  STYLE         = 4,       // 동적 style
  PROPS         = 8,       // 동적 일반 props
  FULL_PROPS    = 16,      // prop 이름(key)을 정적으로 알 수 없는 props (예: v-bind="obj" 스프레드, 동적 인자 :[key]="v") — 전체 props 비교 필요
  HYDRATE_EVENTS = 32,     // SSR 하이드레이션용 이벤트 리스너
  STABLE_FRAGMENT = 64,
  KEYED_FRAGMENT = 128,
  UNKEYED_FRAGMENT = 256,
  NEED_PATCH    = 512,     // ref 등 side-effect
  DYNAMIC_SLOTS = 1024,
  DEV_ROOT_FRAGMENT = 2048,
  HOISTED       = -1,      // 정적 호이스팅된 노드 (재사용)
  BAIL          = -2,      // 최적화 포기 (동적 컴파일 등)
}
JavaScript
<!-- 템플릿 -->
<template>
  <div :class="cls" :style="sty">{{ text }}</div>
</template>
// 컴파일 결과: FLAG = CLASS | STYLE | TEXT = 2 | 4 | 1 = 7
_createElementVNode("div", { class: _ctx.cls, style: _ctx.sty },
  _toDisplayString(_ctx.text), 7 /* CLASS, STYLE, TEXT */)
JavaScript

런타임 patch 함수는 patchFlag & PatchFlags.TEXT처럼 비트 연산으로 해당 속성만 업데이트합니다.

정적 호이스팅(Static Hoisting)

변하지 않는 VNode는 render 함수 바깥으로 끌어올려 한 번만 생성하고 반복 재사용합니다.

// ✅ 컴파일러가 정적 노드를 모듈 스코프로 호이스팅
const _hoisted_1 = _createElementVNode("h1", null, "제목", -1 /* HOISTED */)
const _hoisted_2 = _createElementVNode("p", null, "고정 문단", -1)

function render(_ctx) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,   // 매 렌더마다 새 객체 생성 X
    _hoisted_2,
    _createElementVNode("span", null, _toDisplayString(_ctx.dynamic), 1),
  ]))
}
JavaScript

컴포넌트가 수백 번 리렌더되어도 _hoisted_1은 처음 한 번만 생성됩니다.

트리 플래트닝(Tree Flattening) — Block Tree

블록(Block) 내 동적 자식만 dynamicChildren 배열에 평탄하게 모읍니다. diff 시 이 배열만 순회하므로 중첩 깊이에 관계없이 O(동적 노드 수)의 비교가 가능합니다.

// 블록 생성 시 동적 자식이 추적됨
(_openBlock(), _createElementBlock("div", null, [
  _createElementVNode("p", null, "정적"),         // dynamicChildren에 미포함
  _createElementVNode("p", null, _ctx.a, 1 /*TEXT*/), // 포함
  _createElementVNode("p", null, _ctx.b, 1),          // 포함
]))
// dynamicChildren: [vnode_a, vnode_b]  ← 이 두 개만 diff 대상
JavaScript

⚠️ 주의 v-if·v-for는 새로운 블록을 생성해 동적 서브트리를 분리합니다. 블록 경계가 중요한 이유는 이 최적화 때문입니다. 직접 h()로 render 함수를 작성하면 컴파일러 최적화가 모두 적용되지 않으므로, 성능이 중요한 구간은 신중히 고려해야 합니다.

JSX/render 함수가 필요한 동적 컴포넌트 상황

템플릿으로 표현하기 어려운 동적 구조가 필요할 때 h() 또는 JSX를 직접 사용합니다.

동적 태그와 재귀 컴포넌트

<!-- TreeNode.vue — 재귀 렌더링 -->
<script setup>
import { h, defineProps } from 'vue'
import TreeNode from './TreeNode.vue'

const props = defineProps({
  node: Object,
})
</script>

<script>
export default {
  name: 'TreeNode',
  props: { node: Object },
  render() {
    const { node } = this
    if (!node.children?.length) {
      return h('li', node.label)
    }
    return h('li', [
      node.label,
      h('ul', node.children.map(child =>
        h(TreeNode, { node: child, key: child.id })
      )),
    ])
  },
}
</script>

헤딩 레벨을 props로 받는 컴포넌트

// HeadingLevel.js
import { h } from 'vue'

export default {
  props: {
    level: { type: Number, required: true },
  },
  render() {
    // ❌ 템플릿으로는 <h${level}>을 동적으로 만들기 곤란
    // ✅ h()로 태그명을 동적으로 지정
    return h(`h${this.level}`, this.$slots.default?.())
  },
}
JavaScript

JSX로 동일하게 작성

Vite + @vitejs/plugin-vue-jsx를 사용하면 JSX 문법을 쓸 수 있습니다.

// HeadingLevel.tsx
import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    level: { type: Number, required: true },
  },
  setup(props, { slots }) {
    return () => {
      const Tag = `h${props.level}` as keyof HTMLElementTagNameMap
      return <Tag>{slots.default?.()}</Tag>
    }
  },
})

💡 TIP JSX는 TypeScript와 함께 쓸 때 타입 추론이 자연스럽고, 복잡한 조건부 렌더링을 중첩 삼항 연산자 없이 표현하기 좋습니다. 단, 팀 전체가 JSX에 익숙하지 않다면 유지보수 비용을 고려해야 합니다.

함수형 컴포넌트와 동적 렌더링 패턴

함수형 컴포넌트

함수형 컴포넌트는 상태도, 라이프사이클도 없는 순수 함수 형태의 컴포넌트입니다. 컴포넌트 인스턴스 오버헤드가 없어 매우 가볍습니다. Vue 3에서는 일반 함수를 그대로 사용합니다.

// FunctionalBadge.js
import { h } from 'vue'

// ✅ 함수형 컴포넌트 — props와 context만 받음
const FunctionalBadge = (props, { slots }) => {
  const colorMap = { success: 'green', danger: 'red', info: 'blue' }
  return h(
    'span',
    {
      class: 'badge',
      style: { backgroundColor: colorMap[props.type] ?? 'gray' },
    },
    slots.default?.()
  )
}

// props 타입 선언
FunctionalBadge.props = ['type']

export default FunctionalBadge
JavaScript
<!-- 사용 예 -->
<FunctionalBadge type="success">완료</FunctionalBadge>

렌더 Props 패턴 (Scoped Slot 활용)

함수형 컴포넌트와 scoped slot을 조합하면 렌더 로직을 외부에서 주입받는 패턴을 구현할 수 있습니다.

<!-- DataFetcher.vue — 데이터 패칭 로직을 컴포넌트로 추상화 -->
<script setup>
import { ref, onMounted } from 'vue'

const props = defineProps({ url: String })
const data = ref(null)
const error = ref(null)
const loading = ref(true)

onMounted(async () => {
  try {
    const res = await fetch(props.url)
    data.value = await res.json()
  } catch (e) {
    error.value = e
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <slot :data="data" :error="error" :loading="loading" />
</template>
<!-- 사용처 — 렌더링 방식을 자유롭게 결정 -->
<DataFetcher url="/api/users" v-slot="{ data, loading, error }">
  <div v-if="loading">로딩 중…</div>
  <ul v-else-if="data">
    <li v-for="user in data" :key="user.id">{{ user.name }}</li>
  </ul>
  <p v-else>오류: {{ error.message }}</p>
</DataFetcher>

동적 컴포넌트와 resolveComponent

런타임에 컴포넌트 이름 문자열로 렌더링해야 하는 경우 resolveComponent를 사용합니다.

import { h, resolveComponent } from 'vue'

export default {
  props: { componentName: String },
  render() {
    // ✅ 전역 등록된 컴포넌트를 이름으로 해석
    const comp = resolveComponent(this.componentName)
    return h(comp, this.$attrs)
  },
}
JavaScript

⚠️ 주의 resolveComponent는 반드시 render 함수 또는 setup 내에서 호출해야 합니다. 모듈 스코프에서 호출하면 앱 인스턴스 컨텍스트가 없어 동작하지 않습니다.

요약

  • Vue 템플릿은 빌드 타임에 render 함수로 컴파일되며, 런타임에는 VNode 트리를 반환하는 JavaScript 함수만 남는다.
  • h() 함수로 VNode를 직접 생성할 수 있으며, VNode는 type·props·key·patchFlag·dynamicChildren 등의 필드를 가진 일반 객체다.
  • diff 알고리즘은 동일 레벨·동일 type+key 기준으로 비교하며, keyed 리스트에서는 LIS 기반 이동 최적화를 적용한다.
  • PatchFlags는 동적 바인딩 종류를 비트플래그로 표시해 런타임이 최소한의 속성만 재검사하도록 유도한다.
  • 정적 호이스팅은 불변 노드를 모듈 스코프로 끌어올려 리렌더 시 재생성 비용을 없애고, 블록 트리는 동적 자식만 추려 diff 대상을 줄인다.
  • 동적 태그·재귀 구조·조건부 슬롯 같은 복잡한 렌더링은 h() 또는 JSX로, 상태 없는 경량 UI는 함수형 컴포넌트로 처리한다.

연습문제

  1. 다음 템플릿을 h() 함수를 사용하는 render 함수(또는 setup에서 반환하는 함수)로 변환하세요. countref(0)이고, 버튼 클릭 시 1씩 증가해야 합니다.

    <template>
      <div>
        <p>{{ count }}</p>
        <button @click="count++">+1</button>
      </div>
    </template>
    

    힌트 h('div', null, [h('p', ...), h('button', { onClick: ... }, ...)]) 형태로 작성합니다.

  2. 아래 keyed 리스트에서 key가 배열 인덱스로 지정되어 있을 때와 item.id로 지정되어 있을 때, 맨 앞에 새 아이템을 추가하는 경우 diff 동작이 어떻게 달라지는지 설명하세요.

    const items = ref([
      { id: 10, text: 'A' },
      { id: 20, text: 'B' },
    ])
    // 앞에 추가: items.value.unshift({ id: 30, text: 'C' })
    
    JavaScript

    힌트 인덱스 key는 0, 1, 2 … 순서가 유지되므로 Vue가 어떤 노드를 같다고 판단하는지 생각해 보세요.

  3. PatchFlag 값이 9 (1 | 8, TEXT + PROPS)인 VNode는 런타임에서 어떤 두 가지 속성만 비교·업데이트하나요? 또한 PatchFlag가 -1인 노드는 왜 diff 대상에서 제외되는지 설명하세요.

    힌트 PatchFlags 상수 테이블의 비트 의미와 HOISTED = -1의 의미를 연결해 보세요.

  4. level prop(1~6)을 받아 해당 <h1>~<h6> 태그를 동적으로 렌더링하는 DynamicHeading 컴포넌트를 h() 함수를 사용해 구현하세요. slot의 기본 콘텐츠도 렌더링되어야 합니다.

    힌트 태그명을 동적으로 결정할 때 템플릿보다 h() 사용이 훨씬 자연스럽습니다.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Vue.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.