자바스크립트는 싱글 스레드인데 왜 안 멈추는가
콜 스택은 하나뿐이다
자바스크립트 엔진은 코드를 실행할 스택을 딱 하나만 가지고 있습니다. 함수가 함수를 부르고, 그 함수가 또 다른 함수를 부르는 식으로 쌓이는 그 스택 말입니다. 스택이 하나라는 것은 어느 순간이든 실행 중인 코드가 정확히 한 줄이라는 뜻입니다. 두 함수가 동시에 돌아가는 일은 절대 일어나지 않습니다.
이걸 직접 눈으로 확인하는 방법이 있습니다.
function heavyLoop() {
const start = Date.now();
while (Date.now() - start < 3000) {
// 3초 동안 아무것도 안 하고 그냥 돈다
}
console.log("루프 끝");
}
setTimeout(() => console.log("1초 뒤 실행되어야 할 코드"), 1000);
heavyLoop();setTimeout 은 분명히 1초 뒤에 실행해 달라고 예약했습니다. 하지만 실제로 콘솔에는 "루프 끝"이 먼저 찍히고, 그다음에야 "1초 뒤 실행되어야 할 코드"가 찍힙니다. 3초짜리 동기 코드가 끝날 때까지 타이머 콜백은 순서를 기다립니다. 브라우저 탭도 이 3초 동안 완전히 멈춥니다. 클릭도 안 먹고 스크롤도 안 됩니다.
이 실험이 이 챕터의 핵심입니다. 자바스크립트는 진짜로 한 번에 한 가지 일만 합니다. "비동기"라는 단어 때문에 여러 작업이 동시에 처리된다고 오해하기 쉬운데, 그런 일은 자바스크립트 엔진 안에서는 벌어지지 않습니다.
그런데 왜 안 멈추는 것처럼 보이는가
그럼 우리가 매일 쓰는 "동시에 여러 네트워크 요청을 보내고", "타이머 여러 개를 걸어두고", "사용자 입력을 기다리면서 애니메이션도 돌리는" 코드는 대체 뭘까요.
정답은 간단합니다. 그 일들을 자바스크립트 엔진이 하는 게 아닙니다. fetch 로 네트워크 요청을 보내면, 실제 통신은 브라우저(정확히는 브라우저를 구성하는 C++ 코드, 별도 스레드)가 담당합니다. setTimeout 의 타이머도 마찬가지로 브라우저가 관리합니다. 자바스크립트 엔진은 "이 작업 끝나면 나한테 알려줘" 하고 콜백만 등록해 놓고 손을 뗍니다. 일이 끝나면 브라우저가 그 콜백을 다시 자바스크립트 엔진에 돌려주는데, 이때도 콜 스택이 비어 있어야만 실행됩니다.
즉 "동시성"처럼 보이는 것의 실체는 이렇습니다.
- 무거운 I/O 작업(네트워크, 파일, 타이머)은 런타임(브라우저 또는 Node.js)이 별도로 처리한다
- 자바스크립트 엔진 자체는 여전히 한 번에 하나의 함수만 실행한다
- 작업이 끝나면 결과를 "나중에 실행할 함수" 형태로 큐에 넣고, 엔진이 한가할 때 꺼내 실행한다
이 세 가지를 뒷받침하는 실제 구조 — 콜 스택, Web API, 큐 — 를 다음 챕터부터 하나씩 뜯어봅니다. 지금은 딱 하나만 기억하면 됩니다. 자바스크립트 엔진 자체에는 스레드가 여러 개 없습니다. 있는 것처럼 보이게 해주는 건 엔진 바깥의 런타임입니다.
왜 하필 싱글 스레드로 설계했을까
자바스크립트가 처음 만들어진 이유를 생각하면 이 선택은 자연스럽습니다. 자바스크립트는 브라우저 안에서 DOM 을 조작하려고 태어났습니다. DOM 은 화면에 그려지는 트리 구조인데, 만약 여러 스레드가 동시에 같은 DOM 노드를 건드릴 수 있다면 어떤 순서로 반영될지 예측할 수 없는 코드가 쏟아졌을 겁니다. "버튼을 지우는 코드"와 "버튼 색을 바꾸는 코드"가 동시에 실행되면 어느 게 이길지는 운에 맡겨야 합니다.
싱글 스레드는 이 문제를 설계 단계에서 아예 없애 버립니다. 레이스 컨디션, 데드락, 뮤텍스 같은 멀티스레드 프로그래밍의 골칫거리들이 자바스크립트 코드 자체에는 존재하지 않습니다(Web Worker 로 진짜 스레드를 띄우는 경우는 예외인데, 이건 이 책의 범위 밖입니다). 대신 그 대가로 "무거운 동기 코드 한 줄이 전체를 멈춘다"는 특성을 얻었습니다. 이 트레이드오프를 이해하고 나면, 왜 자바스크립트 생태계가 그렇게까지 비동기·논블로킹 패턴에 집착하는지 납득이 갑니다. 스레드를 늘릴 수 없으니 스레드 하나를 최대한 안 막는 방향으로 설계할 수밖에 없었던 겁니다.