자바스크립트 이벤트 루프와 V8엔진 메모리 구조 분석
자바스크립트는 싱글 스레드 언어입니다. 즉, 한 번에 하나의 작업만 처리할 수 있습니다. 그런데 자바스크립트는 수많은 비동기 작업을 동시에 처리합니다.비법은 바로 이벤트 루프(Event Loop)입니다.
이 게시글에서 이벤트 루프에 대해 알아보고, JS 엔진에 대해 분석해 보겠습니다.
1. 콜 스택 (Call Stack)
콜 스택은 자바스크립트 엔진의 어떤 함수를 실행하고 있는지 추적하는 자료구조입니다. 함수를 호출하면 스택 프레임이 맨 위에 쌓이고, 함수가 끝나면 꺼내는 후입선출(LIFO) 구조입니다.
예제
function add(a, b) {
return a + b;
}
function calculate() {
const result = add(3, 5);
console.log(result);
}
calculate();위 코드를 실행하면 콜 스택은 다음과 같은 순서로 작업을 처리합니다.
자바스크립트는 스택 맨 위의 작업, 즉 현재 처리 중인 작업을 완전히 끝날 때까지 다른 작업을 처리하지 않습니다. 이것이 싱글 스레드의 핵심인 Run-to-Completion 원칙입니다.
2. 이벤트 루프 (Event Loop)
콜 스택이 setTimeout(() => ..., 2000)를 처리한다면 이 작업을 처리하기 위해 2초간 멈춰 버릴 것 입니다. 그러나 실제로는 그렇게 동작하지 않습니다. 자바스크립트는 이러한 비동기 작업을 처리하기 위해 이벤트 루프를 사용합니다.
이벤트 루프는 다음 세 가지 영역을 관리합니다.
Call Stack : 현재 처리 중인 작업
Microtask Queue :
Promise,queueMicrotask콜백이 대기하는 곳Task Queue :
setTimeout,setInterval. DOM 이벤트 콜백이 대기하는 곳
이벤트 루프의 역할은 콜 스택이 비어있는지 감시하다가, 비어있으면 다음 작업을 큐에서 꺼내 스택에 넣는 것입니다.
예제
console.log('A');
setTimeout(() => {
console.log('C');
}, 0);
Promise.resolve().then(() => {
console.log('B');
});
console.log('D');위 코드는 A -> D -> B -> C 순서로 실행됩니다. 실행 흐름은 다음과 같습니다.
Microtask Queue VS Task Queue
두 큐의 가장 중요한 차이는 한 번에 얼마나 처리하냐입니다.
구분 | 처리 단위 | API |
|---|---|---|
Microtask Queue | 큐가 완전히 빌 때까지 전부 처리 |
|
Task Queue | 한 번에 1개만 처리 |
|
이벤트 루프의 한 사이클(tick)은 다음 순서로 동작합니다.
한 가지 주의할 점은 큐에서 꺼낸 각각의 콜백은 Run-to-Completion 원칙 아래 동기적으로 실행됩니다.
브라우저는 Task와 Task 사이, Mictotask Drain이 완료된 직후 렌더링 기회(Rendering Opportunity)를 얻습니다. 60fps 기준으로 약 16ms마다 한 번씩 렌더링이 필요한 경우에 실행됩니다. 만약 Microtaks Queue에 작업이 무한히 작업을 추가하면 렌더링 자체가 차단될 수 있습니다.
Web APIs와 Queue의 관계
setTimeout, fetch, addEventListener같은 함수들이 바로 Webb APIs입니다. 자바스크립트 엔진 자체에는 포함되지 않고, 브라우저 별도로 제공하는 기능으로, 비동기 작업이 완료될 때까지의 대기를 브라우저 백그라운드에서 처리해 주는 역할을 합니다.
중요한 점은 대기(Pending)가 끝나지 않은 작업은 큐에 들어있지 않습니다.
비동기 작업이 완료되기 전과 후의 물리적 위치는 다음과 같이 완전히 다릅니다.
즉, 큐는 "대기 중인 작업"이 아니라 "이미 대기가 끝난 결과를 받아든 콜백들이 스택에 들어가기 전에 잠깐 대기하는 공간"입니다.
그리고 Web APIs는 두 개의 비동기 작업을 브라우저 백그라운드 스레드에서 병렬로 동시에 처리할 수 있습니다. 따라서 코드에 적은 순서와 무관하게, 실제 대기가 먼저 끝난 작업의 콜백이 큐에 먼저 들어오고 먼저 실행됩니다.
3. 비동기의 원리
여기까지 이해했다면 자바스크립트의 비동기에 대한 핵심 본질을 한 문장으로 정리할 수 있습니다.
자바스크립트의 비동기는 병렬 처리가 아니고 실행 순서를 뒤로 미룬 동기 실행입니다.
Promise.then 콜백이든 setTimeout 콜백이든, 큐에서 꺼 내어지는 순간부터 일반적인 동기 함수와 완전히 똑같이 콜 스택에서 Run-to-Completion 규칙 아래 처리됩니다. 차이는 실행 타이밍이 뒤로 밀렸다는 것 뿐입니다.
async/await 비동기 완료 순서를 개발자가 제어하는 방법
Web API는 두 비동기 작업을 병렬로 처리하므로, 대기 시간이 짧은 작업의 콜백이 큐에 먼저 들어와 먼저 실행합니다. 코드에 적은 순서 와 무관합니다.
// fetchData(1) = 5초, fetchData(2) = 1초 소요
fetchData(1).then(data => console.log("1번", data));
fetchData(2).then(data => console.log("2번", data));
// 출력: "2번" → "1번" (대기 완료 순서대로)만약 소요 시간과 무관하게 1번을 먼저 받은 뒤 2번을 실행하는 순서를 보장하고 싶다면 async/await를 사용합니다.
async function run() {
const data1 = await fetchData(1); // 5초 대기, 완료 전까지 다음 줄 실행 안 함
console.log("1번", data1);
const data2 = await fetchData(2); // 1초 대기
console.log("2번", data2);
}
run();
// 출력: "1번" → "2번" (순서 보장)전체를 멈추는 것이 아니라, async 함수의 실행만 일시 중단하고 콜 스택 점유를 반납합니다. 이러한 방식으로 await는 브라우저를 멈추지 않고 다음 실행을 막을 수 있습니다.
await를 만난 순간 run() 함수는 Heap에 자신의 실행 상태를 저장하고 콜 스택을 비웁니다. 덕분에 브라우저는 그 5초 동안 화면 렌더링이나 다른 이벤트를 자유롭게 처리할 수 있습니다. fetchData(1)이 완료되어 Microtask Queue를 통해 돌아오면, 이벤트 루프는 Heap에서 run() 함수의 중단 지점을 복원해 그 다음 줄부터 이어갑니다.
4. 스택 프레임과 클로저
스택 프레임에는 무엇이 들어갈까?
함수가 호출되면 스택에 올라가는 프레임(Frame)에는 단순히 실행 중인 함수라는 정보만 있는 것이 아닙니다. 정확히는 다음 내용이 들어갑니다.
원시 값 :
number,string,boolean등은 값 자체가 프레임 안에 직접 저장됩니다.참조값 : 객체, 배열, 함수는 덩치가 크기 때문에 Heap에 원본을 두고, 프레임에는 Heap 주소(Pointer)만 저장됩니다.
이 구조 덕분에 같은 객체를 여러 변수가 가리키면 하나를 수정했을 때 나머지에도 영향을 미칩니다.
클로저 변수는 스택에서 Heap으로 이동할까?
상위 함수가 끝나 스택 프레임이 Pop되면, 내부 함수가 참조하는 변수는 스택에서 힙으로 이동할까? 클로저를 처음 배울 때 흔히 드는 의문입니다.
정답은 클로저에 쓰일 변수는 처음부터 Heap에 할당됩니다.
V8 엔진은 파싱 단계에서 이미 이 사실을 알고 있습니다.
스택 프레임이 사라져도 Heap의 Context 객체는 내부 함수가 참조하는 동안 GC에 의해 수거되지 않습니다. 그리고 내부 함수는 Context 주소 + Offset 연산으로 클로저 변수에 즉시 접근합니다. 런타임에 변수 이름을 조회하는 무거운 탐색이 아니라, 기계 수준의 메모리 연산이라 매우 빠릅니다.
5. 전역 변수의 물리적 위치
var, let, const는 같은 전역 스코프에 선언되는 것처럼 보이지만, 물리적으로 저장되는 위치가 다릅니다. 세 키워드 모두 결국 Heap에 존재하지만, 어느 구조체에 위치하는지 차이가 있습니다.
var/function선언문 : 실행 전 전역 객체(window)의 프로퍼티로 등록되며,undefined(또는 함수 전체)로 미리 초기화됩니다. 이것이 호이스팅(Hoisting)의 실체입니다.let/const: 엔진 내부의 별도Script Context에 등록되지만, 해당 선언 라인에 도달하기 전까지는 TDZ(Temporal Dead Zone) 상태가 됩니다. 변수가 존재하는 건 알지만 접근은 차단해 두는 것으로, 접근 시ReferenceError가 발생합니다.
console.log(count); // undefined (var — 호이스팅)
console.log(name); // ReferenceError (let — TDZ)
var count = 10;
let name = 'Kim';TDZ는 var의 호이스팅이 만들어내는 혼란을 방지하기 위해 도입된 안전장치 입니다.
마무리
이번 글에서 살펴본 내용을 요약하면 다음과 같습니다.
자바 스크립트의 이벤트 루프와 Run-to-Completion 특성을 이해한다면, 복잡한 비동기 코드나 클로저를 작성할 때 "내 데이터가 어떻게 유지되고 언제 실행될 것인가"를 더 명확하게 추론할 수 있을 것입니다.