0. 들어가며…
- Vue에서 자식 컴포넌트의 스타일을 가져올 때
ref.value.$el.{스타일_속성}방식을 사용한다.
// 부모 컴포넌트
console.log(childRef.value.$el.style.clientHeight) // 300
- 여기서
$el은 “컴포넌트 인스턴스가 관리하는 루트 DOM 노드”를 가리킨다. - 그래서 이 방법을 사용하면
<div>같은 루트 요소의 스타일을 바로 읽을 수 있다. - 그런데 어느 날, 컴포넌트의 레이아웃 구조를 바꾸자
$el.{스타일_속성}이 undefined를 반환하기 시작했다.
💡문제 상황
- 부모 컴포넌트는 2개의 자식 컴포넌트를 가진다.
- 그리고 각 자식 컴포넌트의 높이 값을
$el.clientHeight방식으로 가져왔다.
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import Comp from './Comp.vue'
import Comp1 from './Comp1.vue'
const compRef = useTemplateRef('comp')
const comp1Ref = useTemplateRef('comp1')
onMounted(() => {
console.log('immediate:', compRef.value?.$el.clientHeight) // 300
console.log('immediate:', comp1Ref.value?.$el.clientHeight) // 🚨undefined
})
</script>
<template>
<Comp ref="comp" />
<Comp1 ref="comp1" />
</template>
- 각 자식 컴포넌트의 구성은 다음과 같다.
<!-- Comp.vue -->
<template>
<div style="height:300px">hello</div>
</template>
<!-- Comp1.vue -->
<template>
<div style="height:300px">hello1</div>
<div style="height:300px">hello2</div>
</template>
🔍 원인 분석
$el의 스타일 속성이undefined가 뜨는 이유가 뭘까?- 그 원인을 찾고자 각 컴포넌트의
$el을 콘솔로 출력했다.
onMounted(() => {
console.log('compRef의 $el:', compRef.value?.$el)
console.log('comp1Ref의 $el:', comp1Ref.value?.$el)
})
- 결과는 다음과 같았다.
// 콘솔 출력 결과
compRef의 $el: <div style="height:300px">hello</div>
comp1Ref의 $el: #text
- 루트 노드가 하나인 컴포넌트(
Comp.vue)에서는 정상적으로<div>를 가리키지만, - 루트 노드가 두 개인 컴포넌트(
Comp1.vue)에서는$el이#text노드를 가리켰다. - 즉,
$el.style.clientHeight에 접근하려해도 텍스트 노드를 가리키기에undefined가 뜨는 것이다.
📖 Vue 공식 문서의 설명
- Vue 공식 문서에서는 다중 루트 노드(Fragment) 에 대해 다음과 같이 설명하고 있다.

For components with multiple root nodes,
$elwill be the placeholder DOM node that Vue uses to keep track of the component's position in the DOM(a text node, or a comment node in SSR hydration mode).
- 즉, 루트가 여러 개인 컴포넌트는 내부적으로 Fragment로 렌더링되며,
- 이때
$el은 실제 DOM 엘리먼트가 아닌 “Fragment의 앵커 노드(anchor node)”, 즉 #text를 가리키게 된다. - 그렇다면 Vue는 Fragment를 렌더링할 때 어떤 DOM 구조를 만들기에 이런 현상이 발생하는 걸까?
- 이번 시간에는 Vue 내부의 DOM 생성 방식과
$el이 지정되는 방식을 살펴보며 이해해보겠다.
1. DOM 생성 방식 - Vue 내부 구조 분석하기
Vue 3는 모든 컴포넌트를 Virtual DOM(VNode) 트리 형태로 표현하고,
이를 실제 브라우저 DOM으로 패치(patch) 하여 렌더링한다.
렌더링 과정은renderer.ts의patch()함수를 중심으로 진행된다.
1) Vue의 렌더링 파이프라인 한눈에 보기
- 렌더러(
runtime-core/renderer.ts) 내부에서 모든 DOM 조작의 중심은patch()이다. patch()에서는 VNode의 타입에 따라processElement(),processComponent(),processFragment()등이 호출되며,
이 단계들이 합쳐져 하나의 화면을 완성한다.- 즉, Vue의 렌더러는 VNode의 타입에 따라 DOM 처리 방식을 선택한다.
(1) patch 함수 — DOM 생성의 중심 루프
출처: vuejs/core - renderer.ts#L374
const patch: PatchFn = (n1, n2, container, anchor, parentComponent) => {
const { type, ref, shapeFlag } = n2
switch (type) {
case Text: processText(...); break
case Comment: processCommentNode(...); break
case Static:
if (n1 == null) mountStaticNode(...)
else if (__DEV__) patchStaticNode(...)
break
case Fragment:
processFragment(...)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) processElement(...)
else if (shapeFlag & ShapeFlags.COMPONENT) processComponent(...)
}
}
patch()는 Vue 렌더러의 핵심으로, VNode 타입에 따라 어떤 DOM 생성 전략을 사용할지”결정한다.- 즉, Vue는 엘리먼트인지, 텍스트인지, Fragment인지에 따라 서로 다른 함수를 호출한다.
type |
호출 함수 | 설명 |
|---|---|---|
Text |
processText() |
텍스트 노드(#text) 생성/업데이트 |
Comment |
processCommentNode() |
주석 노드 생성 |
Static |
mountStaticNode() / patchStaticNode() |
정적 HTML 처리 |
Fragment |
processFragment() |
다중 루트(Fragment) 처리 — 루트 엘리먼트 없음 |
| (기본) | processElement() / processComponent() |
일반 DOM 엘리먼트 혹은 컴포넌트 렌더링 |
(2) processElement() — 단일 루트 렌더링의 흐름
출처: vuejs/core - processElement.ts#L595
const processElement = (...) => {
// (a) 네임스페이스(SVG/MathML 등) 구분
if (n2.type === 'svg') namespace = 'svg'
else if (n2.type === 'math') namespace = 'mathml'
// (b) 최초 렌더링
if (n1 == null) mountElement(...)
// (c) 업데이트 렌더링
else patchElement(...)
}
| 단계 | 설명 |
|---|---|
| (a) 네임스페이스 구분 | <svg>나 <math> 같은 특수 DOM은 별도 네임스페이스에서 생성된다. |
| (b) 최초 렌더링 | mountElement()가 <div>를 생성하고 n2.el에 저장한다. |
| (c) 업데이트 렌더링 | 기존 DOM과 새로운 VNode를 diff하여 필요한 부분만 최소 변경한다. |
(3) publicPropertiesMap - $el은 어떻게 연결되는가
출처: vuejs/core - componentPublicInstance.ts#L370
- Vue는
$el,$data,$props,$refs등의 인스턴스 속성을publicPropertiesMap이라는 매핑 테이블로 관리한다. - 이 매핑은 컴포넌트의
proxy객체에서 호출될 때 어떤 내부 필드로 접근할지를 정의한다. $el접근 시 내부적으로는instance.vnode.el을 반환한다.- 이 구조로 인해 Vue는 컴포넌트 외부에서 내부 DOM에 직접 접근할 수 있게 된다.
export const publicPropertiesMap: PublicPropertiesMap = extend(Object.create(null), {
// ...
$el: i => i.vnode.el, // ✅ $el 접근 시 vnode.el을 그대로 반환
$data: i => i.data,
$props: i => i.props,
$refs: i => i.refs,
// ...
})
- 렌더링 연결 구조는 다음과 같다:
mountElement() → vnode.el = <div>
↓
componentInstance.vnode.el = vnode.el
↓
publicPropertiesMap.$el = i => i.vnode.el
↓
instance.proxy.$el → <div>
- 결과적으로
$el은 컴포넌트의 루트 DOM 엘리먼트를 참조하게 된다. - 즉,
$el.style.clientHeight,$el.offsetWidth등의 접근이 가능하다.
(4) processFragment() — 다중 루트 렌더링의 구조
출처: vuejs/core - renderer.ts#L417
그럼 루트가 두 개라면? 즉, Fragment(다중 루트) 컴포넌트에서는 어떤 일이 벌어질까?
<!-- Comp1.vue -->
<template>
<div>hello1</div>
<div>hello2</div>
</template>
- Vue는 자동으로 이 다중루트를 Fragment VNode로 감싸서 렌더링한다.
VNode {
type: Fragment,
children: [
VNode({ type: 'div', children: 'hello1' }),
VNode({ type: 'div', children: 'hello2' })
]
}
- 따라서, 이제
patch()는processElement()대신processFragment()를 호출하게 된다. - 이 지점에서
$el의 구조가 완전히 달라진다.
const processFragment = (...) => {
// (a) Fragment용 시작/끝 앵커 노드(Text) 생성
const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!
// (b) 최초 렌더링
if (n1 == null) {
hostInsert(fragmentStartAnchor, container, anchor)
hostInsert(fragmentEndAnchor, container, anchor)
mountChildren((n2.children || []), container, fragmentEndAnchor, ...)
}
// (c) 업데이트 렌더링
else {
if (patchFlag > 0 && dynamicChildren) patchBlockChildren(...)
else patchChildren(...)
}
}
(a) Fragment의 가상 DOM 생성
- Fragment는 실제 엘리먼트를 생성하지 않는다.
- 대신 시작(
fragmentStartAnchor)과 끝(fragmentEndAnchor)을 나타내는 빈 텍스트 노드(#text) 두 개를 만들어 Fragment의 경계를 표시한다. n2.el은 시작 Text Node를 가리키며, 이 값이$el로 노출된다.
(b) 최초 렌더링
hostInsert()가 두 앵커 텍스트 노드를 container에 삽입한다.mountChildren()이 두 앵커 사이에 실제 children(div,div)을 렌더링한다.- 렌더링 결과,
$el은<div>가 아닌 Fragment의 시작 텍스트 노드를 참조한다.
#text("") ← fragmentStartAnchor (이게 $el)
<div>hello1</div>
<div>hello2</div>
#text("") ← fragmentEndAnchor
(c) 업데이트 렌더링
- Fragment 자체는 실제 엘리먼트가 아니므로 patch 대상이 아니다.
- 대신 내부 children만 비교(diff)하여 갱신한다.
$el은 최초에 만들어진#text노드를 계속 가리킨다.
2) $el이 다르게 나타나는 이유
- Fragment를 사용하는 순간,
$el은 더 이상 “DOM 엘리먼트”가 아니라 “Fragment의 시작 텍스트 노드”를 가리키게 된다.
| 상황 | $el 값 |
원인 |
|---|---|---|
단일 루트 (<div>...</div>) |
HTMLElement | processElement()가 실제 엘리먼트를 생성함 |
다중 루트 (<div/><div/>) |
#text 노드 |
processFragment()가 시작/끝 Text Node만 생성함 |
이 구조가 바로 Fragment 환경에서
$el.{스타일 속성}이 undefined로 나오는 이유다.
이를 해결하려면 루트 구조를 명시적으로 정의하거나, 다른 방식으로 ref를 노출해야 한다.
2. Fragment 환경에서의 $el 접근, 어떻게 해결할까?
- 앞서 살펴봤듯
$el이#text로 출력되는 이유는 루트가 Fragment로 렌더링되어 실제 DOM 엘리먼트가 존재하지 않기 때문이다. - 이 문제를 해결하려면 “루트의 구조를 명시적으로 정의하거나, DOM 접근의 목적을 명확히 구분하는 것”이 중요하다.
1) 대안 알아보기
(1) 루트 노드 추가하기 — 가장 단순하고 안정적인 해결책
- Fragment는 여러 루트를 허용하지만, DOM 조작이나 스타일 접근이 필요한 컴포넌트라면 하나의 루트를 두는 것이 안전하다.
<!-- 수정 전 예시 -->
<template>
<div>hello</div>
<div>world</div>
</template>
<!-- 개선된 예시 -->
<template>
<div class="root">
<div>hello</div>
<div>world</div>
</div>
</template>
- 이 방식을 사용하면,
$el이 항상 HTMLElement를 가리켜 스타일 접근이 가능하다. - 다만, 마크업 계층이 한 단계 깊어지는 단점이 있다.
(2) defineExpose()로 내부 엘리먼트 노출하기
- 루트를 Fragment로 유지해야 하지만, 그 내부의 특정 DOM 엘리먼트를 부모에서 제어해야 한다면?
defineExpose()를 활용할 수 있다.
<!-- Child.vue -->
<template>
<div ref="rootEl">hello</div>
<div>world</div>
</template>
<script setup>
import { ref, defineExpose } from 'vue'
const rootEl = ref(null)
defineExpose({ rootEl }) // 부모에서 접근 가능하게 노출
</script>
<!-- Parent.vue -->
<template>
<Child ref="child" />
</template>
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import Child from './Child.vue'
const child = useTemplateRef('child')
onMounted(() => {
console.log(child.value.rootEl.style) // ✅ 정상 접근
})
</script>
- Fragment 구조를 유지하면서도 부모가 실제 DOM에 접근 가능하다.
- 명시적으로 노출된
ref만 접근하므로, 의도치 않은 의존 관계 방지할 수 있다.
2) Fragment 사용시 주의할 점
$el.{스타일 속성}이 undefined로 나오는 현상은 Vue의 렌더링 구조에 따른 정상 동작이다.- Fragment는 “루트 없는 템플릿”을 가능하게 하지만,
$el이 항상 실제 DOM을 보장하지 않는다. - 또한, Fragment는 DOM 구조뿐 아니라 렌더링 타이밍과 부모 & 자식 관계에도 영향을 준다.
- 특히 다음과 같은 경우에 주의가 필요하다.
| 케이스 | 영향 |
|---|---|
| Transition / Teleport / KeepAlive | 내부적으로 “루트 엘리먼트”를 기준으로 작동하므로, 다중 루트일 경우 애니메이션이나 마운트 타이밍이 어긋날 수 있다. |
| 외부 UI 라이브러리 (Chart.js, Swiper 등) | $el이 실제 HTMLElement가 아니면 렌더링 실패 또는 초기화 오류가 발생할 수 있다. |
- 따라서 루트 구조 변경은 단순한 마크업 수정이 아니라, 렌더링 전반의 동작 흐름에 영향을 미치는 구조적 변경임을 유의해야 한다.
3. 마치며…
이번 시간에는 Vue 3의 Fragment 렌더링 구조와
$el참조 방식을 자세히 살펴보았다.왜 다중 루트 컴포넌트에서
$el이#text로 나타나는지, 그리고 이를 어떻게 안전하게 제어할 수 있는지를 단계별로 확인했다.Vue의 Fragment는 선언적 UI를 유연하게 구성하기 위한 추상화 계층이지만,
$el은 여전히 “물리적 DOM”에 기반한 속성이다.이 둘의 차이를 이해하지 못하면 예상치 못한 동작을 경험하게 된다.
결국 중요한 것은 “DOM을 직접 다뤄야 하는 이유”를 명확히 하는 것이다.
그리고 필요하다면,
ref,defineExpose, 혹은 Style API 등을 활용해 Vue의 렌더링 흐름과 충돌하지 않도록 안전하게 제어해야 한다.
'개발 기술 > 사소하지만 놓치기 쉬운 개발 지식' 카테고리의 다른 글
| 브라우저 렌더링 최적화를 위한 Virtual Scroll - 구현 코드 살펴보기 (1) | 2025.11.16 |
|---|---|
| 브라우저 렌더링 최적화를 위한 Virtual Scroll - 원리/성능 비교 (0) | 2025.11.16 |
| [JS] 모바일 웹뷰에서 가상 키보드 감지하는 법: visualViewport·디바운스·rAF (8) | 2025.08.17 |
| [JS] Array.map() vs Iterator Helper API: 어떤 방식이 더 빠를까? (2) | 2025.07.21 |
| [CSS] scrollIntoView를 사용하면 바운싱되는 이유(with. position의 차이) (0) | 2025.06.22 |
댓글