아래 면접 질문을 공부해봅시다.
자바스크립트는 싱글 스레드 언어인데 비동기 처리는 어떻게 가능한가요? 이벤트 루프, 콜 스택, 태스크 큐(Microtask 포함) 중심으로 설명해주세요.
위키피디아의 정의를 살펴볼게요:
스레드(thread)는 어떠한 프로그램 내에서 실행되는 흐름의 단위를 말한다.
‘흐름의 단위’란 말이 굉장히 추상적이니 코드로 살펴봅시다. 안타깝게도 자바스크립트는 스레드 생성을 제한적으로 지원하니 파이썬 코드로 봅시다:
import threading
import time
def worker(name):
for _ in range(10):
print(name, end="")
time.sleep(0.01)
# 메인 프로그램(프로세스)에서 두 개의 스레드 생성
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
t1.start()
t2.start()
# 메인 스레드는 두 스레드가 끝날 때까지 대기
t1.join()
t2.join()
# 예시 결과: ABBABABABAABABBAABBA
# 결과값은 매번 다름
메인 스레드에서 두 개의 스레드를 만들고, 두 개의 스레드에서 메인 스레드와는 독립된 작업을 하는게 보이시나요? 보통의 프로그램은 어떤 코드를 실행중인지 손가락 하나로 짚을 수 있는데, 스레드가 생성되면 손가락 여러개로 짚어야하죠.
그럼 프론트엔드 개발할 때 스레드는 언제 만날 수 있을까요? mdn의 메인 스레드 (Main thread) 글을 참고해봅시다:
메인 스레드(main thread)는 브라우저가 사용자 이벤트와 페인트를 처리하는 곳입니다. 기본적으로, 브라우저는 단일 스레드를 사용하여 페이지의 모든 JavaScript를 실행하고 레이아웃, 리플로우 및 가비지 컬렉션을 수행합니다. 오래 실행되는 JavaScript 함수가 스레드를 차단하여 페이지가 응답하지 않고, 사용자 경험이 저하될 수 있다는 것을 의미합니다.
브라우저가 단일 스레드로 자바스크립트를 실행한다는걸 직접 확인해봅시다.
브라우저가 잘 렌더링되고 있음을 보여주는 시계 컴포넌트를 준비했습니다. 위 ‘메인 스레드’의 정의에서 확인한 것처럼 시계 컴포넌트는 메인 스레드에서 타이머 이벤트를 받고 업데이트되어 페인팅되고있습니다.
00:00:00아래 자바스크립트 코드는 3초 동안 실행됩니다.
const now = Date.now();
while (Date.now() - now < 3000) {
// 3초 동안 대기
}
이제 아래 버튼을 눌러 3초동안 자바스크립트를 실행해 메인 스레드를 바쁘게 만들어봅시다.
시계가 멈춘 걸 확인할 수 있으신가요? 시계뿐만 아니라 텍스트 선택, 버튼 클릭같은 기능도 멈춥니다.
신기하게도 스크롤은 동작하는데 그 이유는 이 stackoverflow 답변을 참고하세요.
Over the years, browser vendors have recognized that offloading work to background threads can yield enormous improvements to smoothness and responsiveness. Scrolling, being so important to the core user experience of every browser, was quickly identified as a ripe target for such optimizations. Nowadays, every major browser engine (Blink, EdgeHTML, Gecko, WebKit) supports off-main-thread scrolling to one degree or another (with Firefox being the most recent member of the club, as of Firefox 46).
비동기는 컴퓨터 프로그래밍에서 메인 프로그램의 흐름과 독립적으로 발생하는 사건과 그러한 사건을 처리하는 방법을 의미합니다.
웹 환경에서는 네트워크 요청, 타이머, 사용자 입력 등이 대표적인 비동기 작업입니다. 이들은 메인 스레드를 막지 않고 백그라운드에서 처리됩니다. 대표적으로 setTimeout
이 있습니다.
비동기 작업을 보여주는 버튼입니다. 버튼을 누르면 시계가 멈추지 않고 3초 뒤에 버튼이 활성화됩니다. 메인 스레드와 별개로 3초 기다리는 작업이 이루어짐을 확인해보세요.
그럼 프론트엔드 개발에서 동기 관련 함수는 뭐가 있을까요? window.alert
가 있습니다. 얼럿이 떠있는 동안 타이머가 멈추는게 보이시나요?
참고: 브라우저 정책에 따라 탭 전환 등 특정 상황에서는 타이머가 움직일 수 있습니다.
자바스크립트가 싱글 스레드로 작동한다면 setTimeout을 호출한 뒤에 다른 코드를 실행할 수 없어야할 것 같습니다. setTimeout이 ‘3초를 기다린다’라는 작업을 하는 동안에는 다른 작업을 할 수 없어야한다는 말이지요. 하지만 위에서 비동기버튼 예제에서 봤듯이 3초 기다리는 동안 시계 컴포넌트가 잘 업데이트됩니다.
아래가 함정입니다.
자바스크립트 엔진은 싱글 스레드지만, 브라우저는 멀티 스레드입니다.
갑자기 헷갈리니 자바스크립트 엔진은 뭔지, 브라우저와 자바스크립트 엔진의 관계를 살펴봅시다.
자바스크립트 엔진(예: V8, SpiderMonkey)은 자바스크립트 코드를 실행하는 역할을 담당하며, 흔히 자바스크립트가 싱글 스레드라고 하는 것은 바로 이 엔진이 한 번에 하나의 작업만 처리할 수 있기 때문입니다.
반면, 브라우저 구현은 메인 스레드 외에 컴포지터, 래스터 등 여러 스레드/프로세스로 나뉘며(특히 크로미움 계열), 네트워크·타이머·이벤트 등은 JS 엔진 밖의 Web API들이 관리합니다. 자바스크립트 엔진 자체는 비동기 함수를 구현하고 있지 않습니다. setTimeout
이나 fetch
와 같은 비동기 함수는 브라우저가 제공하는 Web API에 정의되어 있으며, 자바스크립트 엔진 외부에 구현되어 있습니다. 즉, 자바스크립트 엔진은 단순히 코드를 실행하는 역할만 맡고, 비동기 작업 자체는 브라우저의 다른 스레드에서 처리되는 것입니다.
그렇다면 브라우저의 멀티 스레드 작업이 어떻게 싱글 스레드인 자바스크립트 엔진과 소통할까요? 이 둘을 연결해주는 것이 바로 이벤트 루프입니다. 이벤트 루프는 브라우저에 구현되어 있으며, 브라우저의 백그라운드에서 비동기 작업이 완료되면, 그 결과로 실행되어야 하는 콜백 함수를 자바스크립트 엔진의 콜 스택으로 옮겨주는 역할을 합니다.
이벤트 루프를 제대로 이해하기 위해서는 먼저 인식의 전환이 필요합니다.
보통 우리가 학교에서 배우는 과제나 숙제로 하는 프로그램들은 ‘시작 → 작업 → 종료’라는 명확한 순서를 따릅니다. 스크립트가 실행되고, 모든 작업을 마친 후 프로그램이 끝나는 것이 일반적이지요.
하지만 브라우저 환경의 자바스크립트는 느낌이 다릅니다. 스크립트가 한 번 실행되고 끝나더라도, 이후에 클릭과 스크롤같이 사용자가 만드는 이벤트, 네트워크 이벤트, 타이머 이벤트 등이 계속해서 발생하고, 이에 등록해둔 콜백이 실행됩니다. 이러한 패러다임을 이벤트 기반 프로그래밍이라고 합니다.
그렇다면 이런 콜백 함수들은 언제 실행될까요? 바로 콜 스택이 비어있을 때입니다.
콜 스택은 자바스크립트 코드가 실행되는 공간으로, 함수의 호출 순서를 기록하는 스택 구조입니다. 함수가 호출될 때 스택에 쌓이고, 함수가 실행을 마치면 스택에서 제거됩니다.
실제로 코드가 실행될 때 콜 스택이 어떻게 변하는지 확인해볼까요? Error.stack을 사용하면 현재 콜 스택의 호출 정보를 확인할 수 있습니다.
function third() {
console.log(new Error().stack);
}
function second() {
third();
}
function first() {
second();
}
first();
// Error
// at third (<anonymous>:2:15)
// at second (<anonymous>:6:3)
// at first (<anonymous>:10:3)
// at <anonymous>:13:1
위 코드를 실행하면 first 함수가 second를, second 함수가 third를 호출하므로 콜 스택에는 first → second → third 순서로 함수가 쌓입니다. third 함수가 실행될 때 Error.stack을 출력하면 현재 콜 스택의 호출 순서를 볼 수 있습니다.
콜 스택이 비어 있다는 것은 현재 실행 중인 자바스크립트 코드가 없다는 뜻입니다. 이벤트 루프는 콜 스택이 비어있는지 계속해서 확인하며, 이것이 비동기 작업을 처리할 수 있는 중요한 신호가 됩니다.
앞서 이벤트 루프가 콜 스택이 비어있을 때만 새로운 콜백을 실행한다고 했습니다. 이것이 의미하는 바가 무엇일까요? 바로 자바스크립트의 Run-to-completion 모델과 연결됩니다.
Run-to-completion은 현재 실행 중인 함수가 완전히 끝날 때까지는 다른 코드로 제어권이 넘어가지 않는다는 의미입니다. 즉, 한 번 콜 스택에 올라간 함수는 중간에 멈추지 않고 끝까지 실행된다는 것이죠.
C나 Java 같은 멀티스레드 언어에서는 실행 중간에 다른 스레드가 개입하여 변수나 객체의 상태를 변경할 수 있습니다. 다음 C 코드를 보면:
#include <pthread.h>
#include <stdio.h>
int shared_counter = 0;
void* increment_function(void* arg) {
for (int i = 0; i < 100000; i++) {
shared_counter++; // 다른 스레드가 중간에 개입 가능!
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, increment_function, NULL);
pthread_create(&thread2, NULL, increment_function, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("최종 카운터: %d\n", shared_counter);
// 예상: 200000, 실제: 불확실 (예: 156789)
return 0;
}
위 코드에서 shared_counter++
는 실제로 “메모리에서 값 읽기 → 1 증가 → 메모리에 쓰기”라는 3단계로 이루어지는데, 이 과정 중간에 다른 스레드가 끼어들어 예상과 다른 결과가 나올 수 있습니다.
하지만 자바스크립트는 현재 함수가 끝나기 전까지는 다른 콜백이 실행되지 않습니다. 이 특성 덕분에 다음과 같은 코드가 문제없이 동작합니다:
document.body.appendChild(element);
element.style.display = 'none';
만약 멀티스레드 환경이었다면 appendChild
실행 후 렌더링 스레드가 즉시 개입해서 사용자에게 요소가 잠깐 보였다가 사라질 수도 있습니다. 하지만 자바스크립트는 이 두 줄이 모두 실행된 후에야 렌더링이 일어나기 때문에, 사용자는 요소를 전혀 볼 수 없습니다. 이런 특성은 DOM 조작의 예측 가능성을 높여줍니다.
이제 이벤트 루프가 어떻게 콜백들을 관리하는지 살펴봅시다. 이벤트 루프는 여러 개의 큐를 가지고 있으며, 각각 다른 우선순위와 역할을 가지고 있습니다. 그 중 가장 기본적인 것이 태스크 큐(매크로태스크 큐라고도 합니다)입니다.
이벤트 기반 프로그래밍에서 비동기 작업의 결과로 실행될 콜백들은 태스크 큐에 저장됩니다. 태스크 큐는 setTimeout
, setInterval
, DOM 이벤트 콜백 등이 들어가는 큐입니다.
간단한 예제로 태스크 큐의 동작을 살펴봅시다.
console.log('시작');
setTimeout(() => {
console.log('첫 번째 타이머');
}, 0);
setTimeout(() => {
console.log('두 번째 타이머');
}, 0);
console.log('끝');
// 시작
// 끝
// 첫 번째 타이머
// 두 번째 타이머
왜 이런 순서로 실행될까요? 먼저 console.log('시작')
과 console.log('끝')
이 콜 스택에서 즉시 실행되고, setTimeout의 콜백들은 태스크 큐에 추가됩니다. ‘시작’과 ‘끝’이 프린트된뒤 콜 스택이 완전히 비워진 후에야 이벤트 루프가 태스크 큐에서 콜백을 하나씩 가져와 실행합니다. 콜백이 끝나고나면 필요시 렌더링을 진행한 뒤 다시 콜백이 있는지 확인합니다.
이것이 암시하는 바는 setTimeout
이 정확히 지정된 시간 후에 실행된다는 보장이 없다는 것입니다. 다음 예제를 보면:
console.log('시작');
setTimeout(() => {
console.log('100ms 타이머');
}, 100);
// 5초 동안 메인 스레드를 바쁘게 만듦
const start = Date.now();
while (Date.now() - start < 5000) {
// 5초간 대기
}
console.log('끝');
// 실행 결과:
// 시작
// (약 5초 후) 끝
// (약 5초 후, 100ms가 아님!) 100ms 타이머
위 예제에서 setTimeout은 100ms 후에 실행되어야 하지만, while문이 5초 동안 콜 스택을 점유하고 있기 때문에 실제로는 5초가 지난 후에 실행됩니다. 이는 setTimeout이 “최소 지연 시간”을 보장할 뿐, “정확한 실행 시간”을 보장하지는 않음을 보여줍니다.
초기 자바스크립트에는 매크로태스크 큐만 존재했습니다. 하지만 ES6에서 Promise가 도입되고, DOM 변경을 감지하는 Mutation Observer 같은 기능들이 필요해지면서 문제가 생겼습니다.
이런 기능들의 공통점은 작업이 끝나자마자 바로 실행되어야 한다는 것입니다. 예를 들어 Mutation Observer는 DOM이 변경되는 순간을 감지해야 하는데, 만약 콜백을 매크로태스크 큐에 넣으면 다음 작업으로 넘어가기 전에 렌더링이 먼저 일어날 수 있습니다. 그렇게 되면 DOM이 바뀌고 화면에 반영된 뒤에야 콜백이 실행되기 때문에 원래 의도했던 “변경 직후 감지”가 불가능해집니다.
그래서 새로운 큐인 마이크로태스크 큐가 생겼습니다. 마이크로태스크 큐에 들어간 작업은 콜 스택이 비워지고 화면을 그리기 전에 반드시 실행됩니다. 덕분에 DOM 변경이나 Promise 결과 같은 것들을 “지연 없이” 처리할 수 있게 되었습니다.
마이크로태스크 큐에는 Promise.then()
, Promise.catch()
, Promise.finally()
, async/await
, queueMicrotask()
등의 콜백들이 들어갑니다. 각 태스크가 끝난 직후 브라우저는 마이크로태스크 큐를 전부 비우고, 그다음 렌더링/다음 태스크로 진행합니다.
console.log('시작');
setTimeout(() => {
console.log('setTimeout (태스크 큐)');
}, 0);
Promise.resolve().then(() => {
console.log('Promise.then (마이크로태스크 큐)');
});
console.log('끝');
// 실행 결과:
// 시작
// 끝
// Promise.then (마이크로태스크 큐)
// setTimeout (태스크 큐)
둘 다 0ms 지연이지만, Promise가 먼저 실행됩니다! 이는 이벤트 루프가 콜 스택이 비워진 후 마이크로태스크 큐를 먼저 확인하고, 마이크로태스크 큐에 있는 모든 콜백을 처리한 다음에야 다른 단계(렌더링, 태스크 큐 등)로 넘어가기 때문입니다.
마이크로태스크 큐의 특별한 점은 큐가 완전히 비워질 때까지는 이벤트 루프의 다음 단계로 넘어가지 않는다는 것입니다. 이는 Promise 체인이나 연속된 마이크로태스크들이 렌더링이나 다른 작업들보다 항상 우선하여 실행됨을 의미합니다.
마이크로태스크와 태스크 큐의 차이를 이해하는 중요한 예제를 살펴봅시다.
// 마이크로태스크 무한 루프
function createMicrotasks() {
queueMicrotask(() => {
console.log('마이크로태스크 실행');
createMicrotasks(); // 새로운 마이크로태스크를 계속 생성
});
}
createMicrotasks();
// 결과: 마이크로태스크 큐가 절대 비워지지 않아 렌더링 완전 중단
반면 setTimeout을 사용한 무한 루프는 각 태스크 사이에 렌더링이 일어날 수 있습니다:
// 태스크 큐 무한 루프
function createTasks() {
setTimeout(() => {
console.log('태스크 실행');
createTasks(); // 새로운 태스크를 계속 생성
}, 0);
}
createTasks();
// 결과: 각 태스크 사이마다 렌더링이 일어남
이 차이는 이벤트 루프가 마이크로태스크 큐를 완전히 비운 후에만 렌더링으로 넘어가기 때문입니다.
애니메이션을 부드럽게 만들기 위해 사용하는 requestAnimationFrame은 태스크 큐도 마이크로태스크 큐도 아닌 특별한 위치에서 실행됩니다.
이벤트 루프는 다음 순서로 작업을 처리합니다:
중요한 점은 requestAnimationFrame이 실제 DOM 렌더링 직전에 실행된다는 것입니다. 이는 브라우저가 화면을 다시 그리기로 결정했을 때만 호출되므로, 불필요한 계산을 방지하고 화면 업데이트와 완벽하게 동기화됩니다.
모니터는 초당 일정한 횟수(보통 60번)로 화면을 새로 그립니다. 브라우저는 이 타이밍에 맞춰 화면을 업데이트하는데, setTimeout은 이 타이밍을 전혀 모르기 때문에 문제가 생깁니다.
화면 업데이트 주기와의 불일치가 가장 큰 문제입니다. 위에서 살펴본 것처럼 setTimeout은 명시한 delay가 지난 뒤에 실행될거라는 보장이 없으며, 이러 인해 콜백이 조금씩 밀리다보면 일부 프레임은 건너뛰어지고 애니메이션이 끊어져 보입니다.
// setTimeout을 사용한 애니메이션 (권장하지 않음)
function animate() {
element.style.left = position + 'px';
position += 1;
setTimeout(animate, 16); // 60fps를 목표로 16ms마다 실행
}
반면 requestAnimationFrame은 브라우저의 화면 업데이트 주기와 완벽하게 동기화됩니다:
// requestAnimationFrame을 사용한 애니메이션 (권장)
let lastTime = 0;
function smoothAnimation(currentTime) {
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
// 프레임 간 시간 차이를 고려한 일정한 속도
position += (deltaTime / 16) * 2; // 60fps 기준으로 정규화
element.style.transform = `translateX(${position}px)`;
if (position < 300) {
requestAnimationFrame(smoothAnimation);
}
}
requestAnimationFrame(smoothAnimation);
requestAnimationFrame은 백그라운드 탭에서 자동으로 일시정지되어 시스템 리소스를 절약하기도 합니다.
아래는 setTimeout(move, 1000/60)
과 requestAnimationFrame(move)
의 차이를 보여주는 예제입니다.
렉 시뮬레이션을 활성화시켜 어디가 더 매끄러운지 확인해보세요. 60fps가 아닌 화면으로 보면 속도 차이도 나겠지요.
더 자세한 렌더링 성능 최적화에 대해서는 Chrome Developers의 렌더링 성능 가이드를 참고하세요.
스터디 과정에서 작성한 글입니다. 오셔서 다른 글과 자료들도 구경해보세요 🙌
댓글을 남기려면 로그인이 필요합니다.