Vue.js에서 비동기 렌더링을 채택한 이유
Vue.js는 데이터 변경을 감지하면 DOM 업데이트를 즉시 수행하지 않고 비동기 방식으로 처리합니다. 데이터가 변경될 때마다 Vue는 큐(Queue)를 생성하여 동일한 이벤트 루프(Event Loop) 내에서 발생하는 모든 데이터 변경 사항을 버퍼링합니다. 만약 동일한 Watcher가 여러 번 트리거되더라도 큐에는 단 한 번만 푸시됩니다. 이러한 중복 제거 과정은 불필요한 계산과 DOM 조작을 방지하는 데 핵심적인 역할을 합니다. 이후 다음 이벤트 루프(tick)에서 Vue는 큐를 플러시(Flush)하고 실제 중복이 제거된 작업을 실행합니다. 이때 내부적으로 네이티브 Promise.then, MutationObserver, setImmediate를 우선적으로 사용하며, 환경을 지원하지 않을 경우 setTimeout(fn, 0)로 폴백(Fallback)합니다.
동기 업데이트 vs 비동기 업데이트
비동기 업데이트를 도입한 가장 큰 이유는 성능 최적화 때문입니다. 만약 동기적으로 업데이트가 이루어진다면, 데이터가 변경될 때마다 컴포넌트의 리렌더링이 즉시 발생합니다.
다음과 같이 하나의 메서드 내에서 연속해서 상태를 변경하는 상황을 가정해 보겠습니다.
this.counter = 10;
this.counter = 20;
this.counter = 30;
우리가 실제로 원하는 결과는 마지막 상태인 30뿐입니다. 즉, 앞선 두 번의 DOM 업데이트는 완전히 불필요한 연산입니다. 모든 상태 변경이 완료된 후 한 번만 렌더링을 수행하는 것이 성능 면에서 훨씬 유리합니다.
동기 큐를 사용할 경우:
this.counter = 10을 실행하면 값 업데이트 -> Setter 트리거 -> Watcher의 update 호출 -> render 재호출 -> 새로운 VDOM 생성 -> DOM Diff -> 실제 DOM 업데이트의 과정이 즉시 이루어집니다. 이후 this.counter = 20과 30을 실행할 때마다 동일한 과정이 반복됩니다. 최종적으로 DOM에는 30만 남게 되지만, 내부적인 VDOM 생성 및 Diff 연산은 3번 모두 수행되므로 엄청난 성능 낭비가 발생합니다.
비동기 큐를 사용할 경우:
this.counter = 10을 실행하면 즉시 렌더링을 수행하는 대신, 해당 데이터에 의존하는 Watcher를 비동기 큐에 저장합니다. 20과 30을 연속으로 변경하더라도 Vue 내부의 중복 제거 로직에 의해 동일한 Watcher는 큐에 한 번만 존재하게 됩니다. 현재 동기 코드가 모두 실행되어 이벤트 루프가 다음 단계로 넘어갈 때, 큐에 있는 Watcher들을 순회하며 단 한 번의 render -> VDOM Diff -> DOM 업데이트 과정을 거치게 됩니다. 이를 통해 Vue는 불필요한 내부 계산과 메모리 상의 DOM 트리 조작 비용을 획기적으로 절감합니다.
Virtual DOM과 배치 렌더링
컴포넌트는 Virtual DOM을 사용하여 렌더링을 수행하므로, 어떤 특정 상태가 변경되었는지 개별적으로 추적할 필요가 없습니다. 여러 개의 상태를 변경하더라도 VDOM은 단 한 번의 연산으로 전체 컴포넌트의 DOM을 최신 상태로 갱신할 수 있습니다.
this.count = 1;
this.age = 25;
this.userName = 'John';
위와 같이 세 가지 상태를 연속으로 변경하더라도 Vue는 단 한 번만 렌더링을 수행합니다. 이를 달성하기 위해 Vue는 렌더링 작업을 현재 이벤트 루프의 맨 마지막 또는 다음 이벤트 루프로 지연시킵니다. 상태 변경을 트리거하는 Setter가 호출될 때마다 렌더링 신호가 발생하지만, Vue는 큐에 동일한 작업이 이미 존재하는지 확인하여 중복을 방지합니다. 그 후 비동기 방식으로 큐의 모든 렌더링 작업을 지연 실행하고 큐를 비우게 됩니다.
데이터 변경 시 notify 메서드가 호출되어 의존성 있는 Watcher들의 update를 트리거합니다. 이때 Watcher는 즉시 실행되지 않고 queueWatcher를 통해 큐에 푸시됩니다. 큐에 중복된 Watcher가 없다면 추가되며, 이후 $nextTick의 실행 큐에 flushSchedulerQueue 함수가 등록됩니다. 이 함수는 버퍼링된 모든 Watcher의 콜백을 실행하며, 실행 전 beforeUpdate 훅이 호출되고, Watcher의 run 메서드가 실행되어 실제 렌더링이 이루어진 후 updated 훅이 호출됩니다.
DOM 업데이트 후 상태 확인: $nextTick
Vue의 DOM 업데이트는 비동기적으로 이루어지므로, 데이터를 변경한 직후에 동기적으로 DOM 요소에 접근하면 이전의 값을 가져오게 됩니다. 이 문제를 해결하기 위해 vm.$nextTick 메서드를 제공합니다. 이 메서드는 콜백을 다음 DOM 업데이트 사이클이 끝난 직후에 지연 실행하여, 데이터 변경 후 업데이트된 DOM에 안전하게 접근할 수 있게 해줍니다.
<!DOCTYPE html>
<html>
<head>
<title>NextTick Example</title>
</head>
<body>
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script>
new Vue({
el: '#root',
data: {
statusText: 'Initial'
},
template: `
<div>
<p ref="statusRef">{{ statusText }}</p>
<button @click="changeStatus">Change</button>
</div>
`,
methods: {
changeStatus() {
this.statusText = 'Updated';
// DOM이 아직 업데이트되지 않았으므로 이전 값 출력
console.log('Before Update:', this.$refs.statusRef.innerText);
this.$nextTick(() => {
// DOM 업데이트 완료 후 새로운 값 출력
console.log('After Update:', this.$refs.statusRef.innerText);
});
}
}
});
</script>
</body>
</html>
자바스크립트 이벤트 루프(Event Loop)
자바스크립트의 비동기 처리는 이벤트 루프를 기반으로 합니다. 브라우저 환경의 이벤트 루프는 크게 실행 스택(Execution Stack), 백그라운드 스레드, 매크로 큐(Macrotask Queue), 마이크로 큐(Microtask Queue)로 구성됩니다.
- 실행 스택: 동기 코드가 실행되는 공간으로, 함수 호출에 따른 프레임이 쌓이는 구조입니다.
- 백그라운드 스레드:
setTimeout,XMLHttpRequest등의 비동기 작업을 처리하는 스레드입니다. - 매크로 큐:
setTimeout,setInterval,setImmediate,requestAnimationFrame등의 콜백이 저장됩니다. - 마이크로 큐:
Promise.then,process.nextTick,MutationObserver등의 콜백이 저장됩니다.
이벤트 루프의 동작 순서는 다음과 같습니다:
- 실행 스택의 동기 코드를 실행하며 비동기 작업을 백그라운드 스레드로 위임합니다.
- 동기 코드 실행이 완료되어 스택이 비면 마이크로 큐를 스캔합니다.
- 마이크로 큐의 첫 번째 작업을 실행 스택으로 옮겨 실행하고, 이 과정에서 발생하는 새로운 마이크로 작업도 큐에 추가합니다.
- 마이크로 큐가 완전히 비어 있을 때까지 이 과정을 반복합니다.
- 마이크로 큐가 비면 매크로 큐에서 하나의 작업을 꺼내 실행 스택에서 처리합니다.
- 매크로 작업 실행 후 다시 마이크로 큐를 비우는 과정을 거치며 이 사이클을 반복합니다.
다음은 이벤트 루프의 실행 순서를 확인하는 예제입니다.
console.log('A');
setTimeout(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
}, 0);
new Promise((resolve) => {
console.log('D');
resolve();
}).then(() => {
console.log('E');
});
setTimeout(() => {
console.log('F');
}, 0);
console.log('G');
// 실행 결과: A -> D -> G -> E -> B -> C -> F
$nextTick의 내부 구현과 실행 순서 분석
데이터가 업데이트될 때 Vue는 렌더링을 위한 flushSchedulerQueue를 $nextTick의 큐에 먼저 푸시합니다. 그 후 사용자가 정의한 $nextTick 콜백이 큐에 추가됩니다. 비동기 큐가 실행될 때 flushSchedulerQueue가 먼저 실행되어 DOM 렌더링을 완료한 후, 사용자의 콜백이 실행되므로 업데이트된 DOM에 접근할 수 있는 것입니다.
흥미로운 점은 데이터 변경 여부에 따라 $nextTick과 Promise의 실행 순서가 달라질 수 있다는 것입니다.
<script>
new Vue({
el: '#root',
data: { text: 'Hello' },
template: `
<div>
<p ref="pRef">{{ text }}</p>
<button @click="withUpdate">With Update</button>
<button @click="withoutUpdate">Without Update</button>
</div>
`,
methods: {
withUpdate() {
this.text = 'World'; // 데이터 변경 발생
setTimeout(() => console.log(1));
Promise.resolve().then(() => console.log(2));
this.$nextTick(() => console.log(3));
},
withoutUpdate() {
// 데이터 변경 없음
setTimeout(() => console.log(1));
Promise.resolve().then(() => console.log(2));
this.$nextTick(() => console.log(3));
}
}
});
</script>
withUpdate를 클릭하면 3 -> 2 -> 1 순서로 출력되지만, withoutUpdate를 클릭하면 2 -> 3 -> 1 순서로 출력됩니다. 이는 Promise 객체에 비동기 작업이 먼저 마운트되느냐의 차이에서 발생합니다.
데이터가 변경되면 Vue 내부에서 먼저 nextTick(flushSchedulerQueue)가 호출되어 마이크로 큐에 등록됩니다. 그 후에 사용자가 작성한 Promise.resolve().then(...)이 등록되므로, $nextTick의 콜백(3)이 먼저 실행되고 그다음 사용자 Promise(2)가 실행됩니다.
반면 데이터 변경이 없는 경우, 사용자가 작성한 Promise가 먼저 마이크로 큐에 등록되고, 그 후에 $nextTick이 등록되므로 사용자 Promise(2)가 먼저 실행됩니다.
다음은 $nextTick의 동작 원리를 단순화하여 재구현한 예제입니다.
const createNextTick = (() => {
let isScheduled = false;
const taskQueue = [];
const microtask = Promise.resolve();
const flushTasks = () => {
taskQueue.forEach(task => task());
taskQueue.length = 0;
isScheduled = false;
};
const scheduleMicrotask = () => {
microtask.then(flushTasks);
};
return function customNextTick(callback) {
taskQueue.push(() => callback());
if (!isScheduled) {
isScheduled = true;
scheduleMicrotask();
}
};
})();
// 테스트
(function testExecutionOrder() {
customNextTick(() => console.log("DOM 렌더링 플러시"));
setTimeout(() => console.log(1));
Promise.resolve().then(() => console.log(2));
customNextTick(() => console.log(3));
})();