02. ⏳ 자바스크립트 엔진과 비동기 이벤트 루프
📋 개요
실행 컨텍스트, 클로저, 그리고 이벤트 루프의 미세한 동작 원리를 통해 자바스크립트의 엔진 내부를 정복합니다.
📌 이 면접 항목의 목표
⏱️ 예상 읽기 시간: 25분 (핵심 요약: 12분)
🗺️ 이 챕터의 흐름
[개념 사전] → [질문 1: 이벤트 루프 & 큐] → [질문 2: 클로저 & 메모리] → [질문 3: 비동기 에러 핸들링]
🎯 이 챕터를 다 읽으면 할 수 있는 것
- 마이크로태스크와 태스크 큐의 실행 순서를 정확히 설명할 수 있습니다.
- 클로저로 인한 메모리 누수 패턴을 진단하고 해결할 수 있습니다.
async/await내부의 상태 관리 메커니즘을 논리적으로 설명합니다.
📚 핵심 개념 사전 (Concept Glossary)
1. 실행 컨텍스트 (Execution Context)
자바스크립트 코드가 실행되는 환경(Scope, Hoisting, this 등)을 추상화한 개념입니다. 콜 스택(Call Stack)은 이 컨텍스트들이 쌓이고 빠지는 물리적인 통로입니다.
2. 클로저 (Closure)
함수가 선언될 때의 렉시컬 환경(Lexical Environment)을 기억하여, 함수가 외부 스코프 밖에서 실행될 때도 그 환경에 접근할 수 있는 기술입니다. 캡슐화와 상태 유지의 핵심입니다.
3. 태스크 큐 (Task Queue) vs 마이크로태스크 큐 (Microtask Queue)
- 마이크로태스크:
Promise.then,MutationObserver,queueMicrotask. 현재 실행 중인 자바스크립트가 끝나면 다음 렌더링 기회 전에 먼저 비워집니다. - 태스크(매크로태스크):
setTimeout,setInterval, 사용자 입력, 네트워크 이벤트처럼 이벤트 루프의 다음 턴에서 처리되는 작업입니다. 브라우저는 태스크와 렌더링 기회를 번갈아 조율합니다.
🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'
- 🐣 영철 (초반): "영호 님!
setTimeout(..., 0)을 썼는데 왜 바로 실행이 안 되고 한참 뒤에 돌까요? 자바스크립트는 빠르다면서요." - 🦁 영호 (리드): "영철 님, 자바스크립트는 싱글 스레드입니다. 연산 속도가 아무리 빨라도 메인 스레드의 순서가 밀리면 사용자는 지연을 느껴요. 이벤트 루프의 큐를 따라가면서 어떤 작업이 먼저 실행되는지 확인해 봅시다."
면접 질문 1. 이벤트 루프 동작 방식과 마이크로/매크로태스크 큐의 차이를 설명해 보세요.
🎯 출제 의도
비동기 자바스크립트의 실행 순서를 정확히 예측할 수 있는지, 그리고 메인 스레드 차단(Blocking) 문제를 어떻게 진단하는지 확인합니다.
🐣 영철이의 Naive 구현 (Bad Case)
영철이는 게시판 검색 기능을 구현하면서, 무거운 필터링 작업을 아무런 비동기 처리 없이 메인 스레드에서 돌려버렸습니다.
// 🐣 영철: "데이터가 10만 개뿐인데 금방 돌겠죠?"
searchButton.addEventListener('click', () => {
showLoadingSpinner(); // ⚠️ 스피너가 안 돔! (메인 스레드 점유 때문)
// ⚠️ 10만 개 데이터 필터링 (메인 스레드 차단)
const results = expensiveFilter(allPosts, query);
renderResults(results);
hideLoadingSpinner();
});🦁 영호의 리뷰 포인트
"영철 님,showLoadingSpinner를 호출해도 브라우저가 화면을 그릴 '틈'(Render step)을 주지 않고 곧바로 무거운 연산을 시작하면, 사용자는 멈춘 화면만 보게 됩니다. 이벤트 루프는 메인 스레드가 완전히 비워질 때까지 렌더링을 시도조차 못 하거든요."
🦁 영호의 아키텍처 가이드 (Good Case)
영호 리드는 비동기 큐를 활용하여 브라우저에게 숨 쉴 틈을 주는 방식을 제안합니다.
// 🦁 영호: "메인 스레드를 잘게 쪼개어 브라우저에게 렌더링 기회를 주세요."
searchButton.addEventListener('click', async () => {
showLoadingSpinner();
// setTimeout은 다음 태스크로 넘겨 브라우저가 렌더링할 기회를 만든다.
// Promise.then 같은 마이크로태스크만 계속 쌓으면 오히려 페인트가 더 밀릴 수 있다.
await new Promise(resolve => setTimeout(resolve, 0));
// 무거운 작업을 Web Worker로 넘기거나, Task를 분할함
const results = await runInWorker(expensiveFilter, allPosts, query);
renderResults(results);
hideLoadingSpinner();
});여기서 핵심은 async 키워드가 CPU 작업을 자동으로 병렬화하지 않는다는 점입니다. 긴 계산은 Web Worker로 옮기거나 작은 태스크로 나누어, 브라우저가 입력과 렌더링을 처리할 시간을 확보해야 합니다.
📊 레벨별 답변 가이드 (Self-Check)
- Level 1 (Junior): "자바스크립트는 싱글 스레드라 한 번에 하나의 일만 합니다. 비동기 작업은 Web API로 넘겼다가 콜백 큐에 쌓이고, 콜 스택이 비면 이벤트 루프가 이를 하나씩 꺼내 실행합니다."
- Level 2 (Senior): "마이크로태스크 큐가 매크로태스크 큐보다 우선순위가 높음을 설명합니다. 하나의 매크로태스크가 실행된 후 브라우저는 렌더링 기회를 가지며, 그 직전에 마이크로태스크 큐가 완전히 비워져야 한다는 '렌더링 사이클'과의 관계를 설명합니다."
- Level 3 (Specialist): "이벤트 루프 기반 아키텍처에서 'Starvation(기아 상태)' 문제를 설명할 수 있습니다. 마이크로태스크가 계속해서 마이크로태스크를 생성할 경우 매크로태스크나 렌더링이 영원히 실행되지 않을 수 있음을 지적하고, 이를 해결하기 위한 리액트의
Scheduler나MessageChannel활용 사례를 예로 듭니다."
면접 질문 2. 실행 컨텍스트(Execution Context)와 클로저(Closure)의 개념을 설명하고, 이것이 상태 관리 라이브러리 내부에서 어떻게 활용되는지 유추해 보세요.
🎯 출제 의도
자바스크립트의 가장 강력하면서도 위험한 기능인 '클로저'의 동작 원리를 이해하고, 이를 이용해 어떻게 외부에서 접근할 수 없는 독립된 상태를 만드는지(캡슐화) 설계 능력을 확인합니다.
🐣 영철이의 Naive 구현 (Bad Case)
영철이는 게시판 필터링 상태를 전역 변수로 관리하다가, 여러 컴포넌트에서 값을 덮어쓰는 바람에 버그가 발생했습니다.
// 🐣 영철: "전역 변수에 담아두면 어디서든 쓰기 편하니까요!"
let globalFilter = { category: 'all' };
function updateFilter(newCategory) {
globalFilter.category = newCategory; // ⚠️ 외부에서 누구나 수정 가능 (예측 불가능)
}🦁 영호의 리뷰 포인트
"영철 님, 전역 변수는 접근하기 쉬운 만큼 변경 출처를 추적하기 어렵습니다. 클로저를 쓰면 특정 함수만 접근할 수 있는 '닫힌 방(Scope)'을 만들 수 있어요. 리액트 훅도 Fiber에 연결된 상태 저장소와 클로저를 함께 활용해 렌더링 사이의 값을 이어갑니다."
🦁 영호의 아키텍처 가이드 (Good Case)
영호 리드는 클로저를 활용해 상태를 캡슐화하는 '간이 Store' 구조를 보여줍니다.
// 🦁 영호: "접근 권한을 통제하세요. 이것이 상태 관리의 기본입니다."
function createStore(initialState) {
let state = initialState; // 🔒 외부에서 직접 접근 불가 (클로저 변수)
return {
getState: () => state,
setState: (newState) => {
state = { ...state, ...newState }; // 불변성 유지
console.log('상태 업데이트 완료!');
}
};
}
const filterStore = createStore({ category: 'all' });
filterStore.setState({ category: 'notice' });
console.log(filterStore.getState()); // { category: 'notice' }
// state 변수 자체에는 절대 직접 접근할 수 없음!📊 레벨별 답변 가이드 (Self-Check)
- Level 1 (Junior): "클로저는 함수가 선언된 환경을 기억하는 것입니다.
useState같은 훅에서 이전 값을 기억할 때 사용됩니다." - Level 2 (Senior): "실행 컨텍스트의
Lexical Environment와Outer Reference의 관계를 설명합니다. 상위 함수가 종료되어 호출 스택에서 사라져도, 하위 함수가 참조하고 있다면 메모리에서 유지되는 메커니즘을 설명합니다." - Level 3 (Specialist): "클로저로 인한 메모리 누수 사례(캡처된 변수가 의도치 않게 큰 객체를 참조할 때)를 사례로 들고,
useState내부 구현에서 클로저를 통해 어떻게 독립적인 상태 인덱스를 관리하는지(Fiber 노드 연동) 아키텍처 관점에서 설명합니다."
🔗 실전 변형 질문 (Related Variations)
면접 질문 4. 가비지 컬렉션(GC)의 동작 원리와 메모리 누수 사례를 설명해 보세요.
- 🎯 출제 의도: 자바스크립트 엔진의 자동 메모리 관리 메커니즘을 이해하고, 개발자의 부주의로 인해 메모리가 해제되지 않는 상황(누수)을 방어할 수 있는지 확인합니다.
- 💡 핵심 원리 & 답변: 자바스크립트는 'Reachability(도달 가능성)'라는 개념을 바탕으로 레퍼런스 카운팅이나 마크 앤 스윕(Mark-and-Sweep) 알고리즘을 사용해 더 이상 참조되지 않는 객체를 메모리에서 해제합니다. 하지만 전역 변수에 큰 객체를 할당해 두거나, 해제되지 않은 이벤트 리스너, 혹은 클로저 내부에서 의도치 않게 거대한 외부 변수를 계속 참조하고 있을 경우 GC가 이를 회수하지 못해 메모리 누수가 발생합니다. 특히 SPA 환경에서는 페이지 이동 시에도 메모리가 유지되므로, 컴포넌트 언마운트 시 클린업 로직을 철저히 관리하는 것이 시니어의 역량입니다.
면접 질문 6. 자바스크립트의 비동기 처리 방식(Promise, async/await)이 내부적으로 어떻게 상태를 관리하나요?
- 🎯 출제 의도: 비동기 문법의 설탕(Sugar) 너머에 있는 마이크로태스크 큐와 상태 머신(State Machine)의 동작 원리를 이해하고 있는지 확인합니다.
- 💡 핵심 원리 & 답변:
Promise는pending,fulfilled,rejected세 가지 상태를 가지는 객체이며,.then()콜백은 마이크로태스크 큐에 쌓여 현재 콜 스택이 비는 즉시 실행됩니다.async/await은 제너레이터(Generator)와 프로미스를 결합한 형태로,await키워드를 만나면 실행 컨텍스트를 일시 중단하고 비동기 작업이 완료될 때까지 제어권을 이벤트 루프에 넘깁니다. 이는 동기적인 코드 흐름을 유지하면서도 메인 스레드를 차단하지 않는 우아한 비동기 프로그래밍을 가능하게 합니다.
면접 질문 13. 같은 동기 코드 안에서 Promise.resolve().then()과 setTimeout(..., 0)을 등록하면 어떤 콜백이 먼저 실행되나요?
- 🎯 출제 의도: 태스크 큐 간의 명확한 우선순위 차이를 실무 수준에서 구분할 수 있는지 확인합니다.
- 💡 핵심 원리 & 답변: 같은 동기 코드에서 둘을 등록했다면 마이크로태스크 큐(
Promise.then)가 먼저 비워진 뒤 다음 태스크(setTimeout)가 실행됩니다. 이벤트 루프는 콜 스택이 비는 즉시 마이크로태스크 큐를 소진한 다음 렌더링이나 다음 태스크로 넘어갑니다. 만약 마이크로태스크 안에서 계속 새로운 마이크로태스크를 만들면setTimeout이나 화면 업데이트가 오래 밀리는 '기아 상태(Starvation)'에 빠질 수 있으므로 주의해야 합니다.
📝 마무리 퀴즈
Q1. setTimeout(..., 0)보다 Promise.then이 먼저 실행되는 이유를 면접에서 설명하려면 무엇을 말해야 하나요?
✅ 정답: 콜 스택이 비워진 뒤 마이크로태스크 큐를 먼저 모두 비우고, 그 다음 태스크 큐와 렌더링 단계로 넘어가는 이벤트 루프 규칙
💡 상세 해설:
- 원리 설명: 이벤트 루프 답변은 "비동기라서요"로 끝나면 부족합니다. 콜 스택, 마이크로태스크, 태스크 큐의 우선순위를 코드 실행 순서와 연결해야 합니다.
- 오답 피드백:
setTimeout의 지연 시간이 0ms라는 사실은 "즉시 실행"을 보장하지 않습니다. 현재 스택과 마이크로태스크가 먼저 끝나야 합니다. - 📌 핵심 기억법: 0ms는 "바로 지금"이 아니라 "다음 차례"입니다.
Q2. 영호가 검색 필터링을 Web Worker나 작업 분할로 옮기라고 한 이유는 무엇인가요?
✅ 정답: 무거운 동기 작업이 메인 스레드를 오래 점유하면 렌더링과 입력 응답이 밀리기 때문
💡 상세 해설:
- 원리 설명: 브라우저는 자바스크립트 실행, 렌더링, 사용자 입력 처리를 모두 메인 스레드에서 조율합니다. 긴 작업 하나가 자리를 차지하면 스피너도 못 돌고 클릭도 늦게 반응합니다.
- 오답 피드백:
async를 붙인다고 CPU 작업이 자동으로 분리되지는 않습니다. 실제로 일을 쪼개거나 Worker로 넘겨야 메인 스레드가 숨을 쉽니다. - 📌 핵심 기억법: 비동기 문법과 병렬 실행은 같은 말이 아닙니다.
Q3. 영철이의 테스트 타임: 클로저 기반 상태 저장 로직을 리뷰할 때 꼭 확인해야 할 위험은 무엇인가요?
✅ 정답: 외부에서 더 이상 필요 없는 큰 객체나 이벤트 리스너를 클로저가 계속 붙잡아 메모리 누수를 만들지 않는지 확인한다
💡 상세 해설:
- 원리 설명: 클로저는 상태 은닉에 강력하지만, 참조가 남아 있으면 GC가 객체를 회수하지 못합니다. SPA에서는 언마운트와 이벤트 해제가 특히 중요합니다.
- 오답 피드백: 클로저를 쓴다고 항상 메모리 누수가 나는 것은 아닙니다. 문제는 "의도치 않은 참조를 오래 붙잡는 설계"입니다.
- 📌 핵심 기억법: 클로저는 기억력이 좋다. 그래서 잊어야 할 것도 명시해야 한다.
🐣 영철이의 퇴근 일기
오늘은 "비동기니까 안 막히겠지"라는 내 말이 얼마나 위험한지 알았다. async/await로 감싸도 CPU를 오래 붙잡는 작업은 여전히 메인 스레드를 막고, 마이크로태스크를 계속 쌓으면 렌더링 차례 자체를 밀어낼 수 있었다.
💡 "이벤트 루프를 안다는 건 실행 순서를 외우는 게 아니라, 브라우저에게 언제 양보해야 하는지 아는 것이다."
내일부터는 면접에서 실행 순서를 맞히는 데서 멈추지 않고, 왜 사용자가 멈춘 화면을 보게 되는지까지 설명해야겠다. 그리고 클로저를 쓸 때는 "이 함수가 무엇을 계속 기억하고 있지?"를 코드 리뷰 체크리스트에 넣어야겠다.