1. 서론
Vue3에서 개발을 하다 보면 nextTick
함수를 자주 사용하게 됩니다. 이 함수는 데이터 변경 후 DOM이 업데이트될 때까지 기다렸다가 특정 작업을 수행하게 해줍니다.
"브라우저는 정확히 언제, 어떤 순서로 HTML을 화면에 렌더링할까?", "왜 JavaScript는 DOM 업데이트를 기다려야 할까?" 이 질문들의 답을 다시 정리해 보고 싶었습니다.
2. 본론
🌳 브라우저의 렌더링 과정
브라우저가 웹 페이지를 로드하고 렌더링하는 과정은 크게 5단계로 이루어집니다.
1) HTML 파싱과 DOM 트리 생성
브라우저는 HTML 문서를 읽고 파싱하여 DOM(Document Object Model) 트리를 구축합니다. 이 과정은 HTML 태그를 위에서 아래로 순차적으로 처리하며, 중첩된 요소들은 트리 구조로 표현됩니다. 페이지의 구조를 표현하는 이 트리는 JavaScript가 문서를 조작할 수 있는 기반이 됩니다.
2) CSS 파싱과 CSSOM 생성
브라우저는 CSS 파일을 다운로드하고 파싱하여 CSSOM(CSS Object Model)을 만듭니다. CSSOM도 DOM과 유사한 트리 구조를 가지며, 각 노드에는 해당 요소에 적용될 모든 스타일 정보가 포함됩니다.
3) 렌더 트리 구축
DOM과 CSSOM을 결합하여 렌더 트리를 만듭니다. 렌더 트리는 실제로 화면에 표시될 요소들만 포함합니다. 예를 들어, display: none
이 적용된 요소는 렌더 트리에 포함되지 않습니다. 반면 visibility: hidden
이 적용된 요소는 공간을 차지하므로 렌더 트리에 포함됩니다.
4) 레이아웃(Reflow)
렌더 트리가 완성되면, 브라우저는 각 노드의 정확한 위치와 크기를 계산합니다. 이 과정을 레이아웃 또는 리플로우라고 합니다. 레이아웃 과정은 DOM의 구조와 스타일 속성에 의해 영향을 받습니다. 또한, 디바이스 회전이나 브라우저 사이즈 조정과 같이 화면 요소의 위치가 변경되면 이 과정이 다시 수행됩니다.
5) 페인팅
마지막으로, 계산된 레이아웃을 바탕으로 실제 픽셀을 화면에 그립니다.
⚒️ JavaScript와 브라우저 렌더링의 상호작용
웹 페이지가 로드되는 동안 브라우저는 <script>
태그를 만나면 HTML 파싱을 일시 중지하고 JavaScript를 실행합니다. 이때 JavaScript는 이미 생성된 DOM을 수정할 수 있습니다. 예를 들어, 요소를 추가하거나 삭제하고, 속성을 변경하거나 스타일을 조작하는 등의 작업을 수행할 수 있습니다.
이러한 JavaScript로 DOM과 CSSOM이 변경되면 레이아웃이 다시 그려지는 리플로우 또는 리페인팅이 일어나게 됩니다.
🔄 JavaScript와 렌더링의 관계: 마이크로태스크와 렌더링 큐
브라우저 성능을 최적화하기 위해 모든 JavaScript 작업을 즉시 DOM에 반영하지 않습니다. 대신, 변경사항을 일괄 처리하여 불필요한 렌더링 작업을 최소화합니다. JavaScript 엔진(V8)은 매크로 태스크 큐와 마이크로 태스크 큐라는 두 가지 주요 큐를 사용합니다.
큐(queue) : 선입선출(First In First Out—FIFO) 자료구조
- 매크로 태스크 큐: 외부 스크립트(
<script>
태그), 마우스 이벤트 핸들러, setTimeout, setInterval - 마이크로태스크 큐 : Promise의 .then()/.catch()/.finally(), queueMicrotask(), async/await 콜백
현재 실행 중인 스크립트가 완료된 후, 브라우저는 렌더링을 수행하기 전에 마이크로태스크 큐의 모든 작업을 처리합니다. 그런 다음, 매크로 태스크 큐에서 다음 작업을 가져와 실행합니다.
console.log("script start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
Promise.resolve()
.then(() => {
console.log("promise1");
})
.then(() => {
console.log("promise2");
});
console.log("script end");
// 출력: script start, script end, promise1, promise2, setTimeout
📚 Vue와 React의 DOM 업데이트
Vue
-
상태가 변경되면, Vue는 이를 감지합니다.
-
변경사항을 즉시 DOM에 반영하지 않고, 마이크로 태스트 큐에 쌓아둡니다.
-
다음 "tick"에 변경사항을 일괄적으로 Virtual DOM에 적용합니다.
-
이전 Virtual DOM과 새 Virtual DOM을 비교하여 변경이 필요한 DOM 업데이트를 수행합니다.
<script setup>
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
// DOM 업데이트 전
console.log(document.getElementById('counter').textContent) // 이전 값 (0)
await nextTick()
// DOM 업데이트 후
console.log(document.getElementById('counter').textContent) // 업데이트된 값 (1)
}
</script>
<template>
<button id="counter" @click="increment">{{ count }}</button>
</template>
마이크로태스크 큐에 등록된 nextTick
의 콜백은, 현재 실행 중인 코드가 완료된 후 실행됩니다. 콜백 함수 실행전 Vue가 DOM 업데이트를 수행하므로, 콜백 내부에서는 업데이트된 DOM에 접근할 수 있게 됩니다.
React
-
상태가 변경되면(
setState
호출 등), 새로운 Virtual DOM 트리를 생성합니다. -
이전 Virtual DOM과 비교 후 필요한 부분만 업데이트합니다.
-
useEffect
와 같은 메서드로 업데이트된 값에 접근할 수 있습니다.
function Counter() {
const [count, setCount] = useState(0);
const counterRef = useRef(null);
useEffect(() => {
// DOM 업데이트 후
console.log(counterRef.current.textContent); // 업데이트된 값 (1)
}, [count]);
function increment() {
setCount(count + 1);
// DOM 업데이트 전
console.log(counterRef.current.textContent); // 이전 값 (0)
}
return (
<button ref={counterRef} onClick={increment}>
{count}
</button>
);
}
Vue와 React의 차이점
- 반응성 시스템
- Vue: 양방향 바인딩이 가능하며,
ref
,reactive
등을 제공합니다. - React: 명시적으로
setState
를 호출해야 변경이 감지되며, 상태 변경은 컴포넌트 단위에서만 이루어집니다.
- Vue: 양방향 바인딩이 가능하며,
3. 결론
Vue의 nextTick을 시작으로 DOM 생성과 업데이트 과정, JavaScript의 동작 구조, 그리고 Virtual DOM을 활용한 프레임워크들의 동작 방식까지 살펴봤습니다. 이번 글을 통해 브라우저, javascript, 프레임워크의 각 개념들을 유기적으로 연결할 수 있었습니다.
🙇♂️ 참고문헌
웹페이지를 표시한다는 것: 브라우저는 어떻게 동작하는가