싱글 스레드지만, 순차 실행은 아니다
자바스크립트는 하나의 스레드에서 돌아간다. 콜 스택도 하나뿐이고, 어느 순간이든 정확히 하나의 함수만 실행된다. 같은 realm 안에서 여러분의 코드 두 줄이 동시에 실행되는 일은 절대 없다.
이것만 보면 제약처럼 느껴지지만, 자바스크립트가 실제로 하는 일을 떠올려 보자. 데이터를 가져오고, 사용자의 클릭을 기다리고, 파일을 읽는다. 대부분의 "작업"은 사실 기다림이다. 자바스크립트 이벤트 루프는 바로 이 기다림을 값싸게 만들어 주는 장치다. 여러분의 코드가 브라우저나 Node에게 작업을 넘기고 다른 일을 계속하다가, 결과가 준비되면 알림을 받는 구조다.
첫 번째와 두 번째는 순서대로 찍히는데, 세 번째는 타임아웃이 0인데도 한참 뒤에 찍힌다. 이 미묘한 간격이 바로 자바스크립트 이벤트 루프가 일하는 흔적이고, 왜 이런 일이 벌어지는지 파헤쳐 보는 게 이 글의 핵심이다.
콜 스택(Call Stack)
함수를 호출할 때마다 프레임 하나가 콜 스택에 쌓인다. 함수가 리턴되면 그 프레임은 스택에서 빠진다. 말 그대로 스택 자료구조라, 나중에 들어간 게 먼저 나오는 LIFO 구조다.
outer()를 실행하면 Node는 먼저 outer를 스택에 올리고, 이어서 inner를 올립니다. inner가 "완료"을 반환하면 스택에서 빠지고, 그다음 outer도 빠지면서 스택이 다시 비게 되죠. 바로 이 "스택이 비는 순간"을 이벤트 루프가 노리고 있습니다.
동기 코드는 콜 스택 위에서 처음부터 끝까지 한 번에 실행됩니다. 중간에 비동기 작업이 끼어들 수 없어요. 만약 while (true) 같은 무한 루프를 돌린다면, 스택이 절대 비지 않으니 화면은 그대로 얼어붙습니다. 클릭도, 타이머도, 프라미스 콜백도 전부 먹통이 되죠. 이벤트 루프에게 차례가 돌아오지 않으니 아무 일도 할 수 없는 겁니다.
비동기 작업은 실제로 어디에서 처리될까
사실 자바스크립트 엔진 자체는 네트워크 요청을 보내거나 100밀리초를 기다리는 방법을 모릅니다. 이런 API들은 호스트 환경, 즉 브라우저나 Node가 제공하는 거예요. setTimeout(fn, 100)을 호출했을 때 내부에서 벌어지는 일을 살펴보면 이렇습니다.
- 타이머가 호스트 환경에 등록됩니다.
setTimeout은 곧바로 반환되고, 콜 스택은 계속 실행을 이어갑니다.- 100ms가 지나면 호스트가
fn을 큐에 집어넣습니다. - 콜 스택이 비는 순간, 이벤트 루프가 큐에서
fn을 꺼내 실행합니다.
타이머 콜백은 for 루프와 console.log("끝")가 끝나야 실행됩니다. 스택이 비기 전까지는 대기할 수밖에 없죠. 타이머의 시간 값은 최소 지연 시간일 뿐, 정확히 그때 실행된다는 보장이 아닙니다.
태스크 큐와 마이크로태스크 큐
이벤트 루프가 참조하는 큐는 하나가 아니라 두 개입니다. 이 둘의 차이를 이해하면 이벤트 루프에서 마주치는 대부분의 의아한 동작이 설명됩니다.
- 태스크 큐 (매크로태스크 큐라고도 부릅니다):
setTimeout,setInterval, I/O 콜백, UI 이벤트가 여기에 들어갑니다. - 마이크로태스크 큐: 프로미스 콜백(
.then,.catch,.finally),await뒤의 이어지는 코드, 그리고queueMicrotask로 예약한 작업이 쌓입니다.
이벤트 루프가 따르는 규칙은 이렇습니다:
- 태스크 큐에서 태스크 하나를 꺼내 실행한다.
- 마이크로태스크 큐를 전부 비운다. 비우는 도중에 새로 추가된 마이크로태스크까지 포함해서 남김없이 처리한다.
- 필요하면 렌더링을 수행한다(브라우저 환경).
- 다시 1번으로 돌아간다.
즉, 마이크로태스크는 언제나 다음 태스크보다 먼저 실행됩니다. 그래서 아래와 같은 코드가 처음 보면 의외로 느껴집니다:
실행 순서는 동기 1, 동기 2, 프로미스, 타임아웃입니다. 먼저 동기 코드가 실행되고, 콜 스택이 비워집니다. 그다음 이벤트 루프가 마이크로태스크 큐를 전부 비우면서 프로미스를 출력하죠. 그 후에야 비로소 타이머 태스크인 타임아웃이 처리됩니다.
마이크로태스크가 태스크를 굶길 수 있다
마이크로태스크 큐는 다음 태스크로 넘어가기 전에 완전히 비워지기 때문에, 마이크로태스크가 계속해서 또 다른 마이크로태스크를 예약하면 태스크 큐는 영원히 차례가 오지 않습니다:
타이머는 영영 실행되지 않습니다. 매 마이크로태스크가 또 다른 마이크로태스크를 큐에 넣으니 큐가 비워질 틈이 없기 때문이죠. 프로미스 체인은 각 .then이 연속 작업을 하나씩만 예약하기 때문에 괜찮지만, 직접 손으로 만든 마이크로태스크 루프는 알아둘 만한 함정입니다.
await는 마이크로태스크를 예약하는 문법 설탕일 뿐
프로미스를 await하면 함수는 거기서 멈추고, 나머지 부분은 해당 프로미스가 settle되는 순간 실행될 마이크로태스크로 예약됩니다. 특별한 마법은 없습니다. 내부적으로는 그냥 .then이 호출될 뿐이에요.
출력은 A, C, B 순서입니다. await가 제어권을 호출자에게 돌려주기 때문이죠. 그 결과 현재 스택에 있던 console.log("C")가 먼저 실행되고, 이후 마이크로태스크 큐가 비워지면서 demo 함수의 나머지 부분이 이어서 실행돼 B가 찍힙니다.
비동기 코드를 읽을 땐 이 점을 꼭 기억해 두세요. await는 블로킹이 아니라 양보(yield) 입니다.
종합 예제: 실행 순서 전체 살펴보기
지금까지 배운 조각들을 한데 모아 보겠습니다.
실행 순서:
1: 동기— 콜 스택에서 바로 실행됩니다.6: 동기— 아직 스택에 남아 있는 동기 코드입니다.- 스택이 비워지면 마이크로태스크 큐가 모두 처리됩니다:
3: 프로미스,5: 마이크로태스크, 그리고4: 중첩된 마이크로태스크(큐를 비우는 도중에 추가됐지만 이것도 같이 처리됩니다). - 그다음으로 태스크가 실행됩니다:
2: 타임아웃.
최종 출력은 1, 6, 3, 5, 4, 2. 이 흐름을 머릿속에서 따라갈 수 있다면 이벤트 루프를 제대로 이해한 겁니다.
Node.js의 이벤트 루프는 단계가 더 많다
Node의 이벤트 루프는 브라우저 모델을 확장한 형태입니다. 타이머, 대기 중인 I/O 콜백, 폴(poll), 체크(check), 종료(close) 같은 개별 페이즈(phase) 로 나뉘어 있고, 각 페이즈 사이마다 마이크로태스크 큐가 전부 비워집니다. setImmediate는 체크 페이즈에서 실행되고, process.nextTick은 일반 마이크로태스크 보다 먼저 실행됩니다(별도의 더 높은 우선순위 큐를 가지고 있습니다).
첫날부터 이 페이즈 구조를 외울 필요는 없습니다. 핵심은 브라우저와 똑같습니다. 동기 코드가 끝까지 실행되고, 마이크로태스크가 전부 비워진 다음, 루프가 대기 중인 다음 콜백을 집어 드는 순서입니다.
왜 중요한가
이 모델이 한번 이해되면 그동안 수수께끼 같던 비동기 코드들이 훨씬 명확하게 보입니다:
- 긴
for루프가 UI를 멈추게 만드는 이유는 이벤트 루프가 돌 차례를 얻지 못하기 때문입니다. setTimeout(fn, 0)은 현재 태스크와 마이크로태스크가 끝난 뒤로 작업을 미루는 방법입니다.- 이미 resolve된 프로미스에 붙은
.then콜백이 "즉시" 실행되는 것처럼 보여도, 실제로는 현재 동기 코드가 끝날 때까지 기다립니다. - 루프 안에서
await를 쓰면 작업이 순차 처리되는데, 매 반복마다 마이크로태스크 큐로 제어권을 넘긴 뒤에야 다음으로 넘어가기 때문입니다.
비동기 코드를 디버깅할 때는 대부분 "지금 콜 스택에는 뭐가 있고, 큐에는 뭐가 쌓여 있나?"만 물어보면 됩니다. 그 답이 바로 이벤트 루프입니다.
다음 주제: 콜백
프로미스와 async/await가 등장하기 전, 자바스크립트에서 비동기 작업을 다루는 유일한 수단은 콜백이었습니다. 나중에 호출해 달라고 API에 넘겨주는 함수죠. 콜백은 지금도 여기저기서 쓰이고 있고(이벤트 리스너, Node의 코어 API 등), 이 챕터의 나머지 내용을 이해하기 위한 가장 기본적인 토대가 됩니다.
자주 묻는 질문
자바스크립트의 이벤트 루프란 무엇인가요?
싱글 스레드인 자바스크립트가 블로킹 없이 비동기 작업을 처리할 수 있게 해주는 메커니즘입니다. 이벤트 루프는 콜 스택을 계속 감시하다가, 스택이 비는 순간 큐에 쌓여 있던 콜백을 하나 꺼내서 실행시킵니다. 타이머, I/O, 프로미스의 후속 처리 같은 것들이 전부 큐에 들어오고, 이벤트 루프가 한 번에 하나씩 꺼내 돌리는 구조예요.
자바스크립트는 왜 싱글 스레드인가요?
언어 스펙 자체가 하나의 realm 안에 콜 스택을 딱 하나만 두도록 정의하고 있어서, 여러분이 작성한 코드는 단일 스레드에서 실행됩니다. 동시성은 호스트 환경(브라우저나 Node)이 타이머, 네트워크, 파일 I/O 같은 작업을 백그라운드 API로 떠넘기고, 끝나면 콜백을 큐에 넣어주는 방식으로 구현됩니다. 같은 컨텍스트 안에서 JS 코드 두 개가 동시에 실행되는 일은 결코 없죠.
마이크로태스크와 매크로태스크의 차이는 뭔가요?
마이크로태스크는 프로미스(.then, await)와 queueMicrotask에서 나오는 작업이고, 매크로태스크는 setTimeout, setInterval, I/O, UI 이벤트 같은 것들입니다. 핵심은 순서인데, 매크로태스크 하나가 끝날 때마다 이벤트 루프는 마이크로태스크 큐를 전부 비운 뒤에야 다음 매크로태스크로 넘어갑니다. 그래서 같은 시점에 예약된 Promise.resolve().then(...)은 항상 setTimeout(..., 0)보다 먼저 실행되는 거예요.
setTimeout을 0ms로 지정했는데 왜 바로 실행되지 않나요?
setTimeout(fn, 0)은 '지금 당장 실행'이 아니라 '매크로태스크 큐에 등록하되, 빨라야 0ms 뒤에 실행'이라는 뜻입니다. 현재 실행 중인 동기 코드가 전부 끝나고, 마이크로태스크 큐까지 싹 비워져야 비로소 이벤트 루프가 타이머 콜백을 꺼내갑니다. 즉, 0은 최소 지연 시간일 뿐 즉시 실행 보장이 아닙니다.