자바스크립트를 배우는 사람이 가장 크게 벽을 느끼는 지점이 비동기다. 문법은 대충 익혔는데 코드 실행 순서가 내 예상과 자꾸 어긋나는 순간이 온다. 분명히 위에 쓴 코드가 먼저 나올 줄 알았는데 아래 코드가 먼저 찍히고, 왜 이러는지 설명을 못 하니 답답하다. 나도 여기서 크게 헤맸고, 원인은 이벤트 루프(event loop, 사건 순환)라는 구조를 몰랐기 때문이었다.


이번 편은 그 벽의 정체를 뜯어본다. 자바스크립트가 한 번에 하나만 처리한다는 사실, 오래 걸리는 일을 어떻게 미뤄두는지, 그 미뤄둔 일을 이벤트 루프가 언제 다시 꺼내는지를 코드를 돌려 실제 출력 순서로 확인한다. 이 그림 하나만 손에 쥐면 어긋나 보이던 순서가 전부 설명된다.


자바스크립트는 한 번에 하나만 한다. 먼저 받아들여야 할 사실은 자바스크립트가 싱글 스레드(single thread, 단일 작업 흐름)라는 거다. 일을 처리하는 손이 딱 하나뿐이라, 두 가지를 동시에 못 한다. 실행 중인 함수는 콜 스택(call stack, 호출 더미)이라는 곳에 차곡차곡 쌓였다가 끝나면 빠진다. 이 스택이 하나뿐이니, 어떤 함수가 스택을 차지하고 안 끝나면 그동안 다른 아무 일도 못 한다.


오래 걸리는 일을 그 자리에서 기다리면 멈춰버린다. 문제는 시간이 걸리는 일이다. 서버에서 데이터를 받아오거나 몇 초 뒤에 뭘 해야 할 때, 손이 하나뿐인 자바스크립트가 그 자리에 서서 기다리면 어떻게 될까. 그동안 화면 클릭도, 스크롤도 아무것도 못 받는다. 브라우저가 통째로 얼어붙는다. 나는 무거운 반복 계산을 그냥 돌렸다가 페이지가 몇 초간 먹통이 되는 걸 보고 이 문제를 몸으로 알았다. 그래서 자바스크립트는 오래 걸리는 일을 그 자리에서 기다리지 않는 길을 택했다.


비동기는 나중에 부를 함수를 맡겨두는 것이다. 그 길이 바로 비동기다. 오래 걸리는 일은 브라우저나 시스템에 맡겨두고, 그게 끝났을 때 실행할 함수를 미리 건네둔다. 이렇게 나중에 불러달라고 맡겨두는 함수를 콜백(callback, 되부름 함수)이라 한다. 그러면 자바스크립트는 그 일이 끝나기를 서서 기다리지 않고 다음 줄로 곧장 넘어간다. 가장 단순한 예가 setTimeout이다.


console.log("1 시작");
setTimeout(() => console.log("2 타이머 콜백"), 0);
console.log("3 끝");


실행 결과다.


1 시작
3 끝
2 타이머 콜백


대기 시간을 0으로 줬는데도 23보다 늦게 나온다. 처음 보면 황당하다. 0초면 즉시 아니냐 싶다. 하지만 setTimeout은 콜백을 지금 실행하는 게 아니라, 지정한 시간이 지난 뒤에 실행하라고 맡겨두는 함수다. 맡겨둔 뒤 자바스크립트는 기다리지 않고 곧장 3번 줄로 간다. 맡긴 콜백은 지금 하던 일이 전부 끝난 다음에야 실행된다. 그 순서를 정하는 게 이벤트 루프다.


시간 재는 일은 브라우저가 대신 해준다. 그럼 자바스크립트가 기다리지 않는 사이 누가 시간을 재냐면, 브라우저다. 자바스크립트 엔진 옆에는 타이머, 네트워크 요청, 사용자 이벤트 같은 오래 걸리는 일을 대신 처리해주는 별도의 기능들이 붙어 있다. setTimeout을 부르면 엔진은 브라우저에게 시간 재는 일을 넘기고 곧장 다음 줄로 간다. 브라우저가 정해진 시간을 다 재면, 그제야 맡아둔 콜백을 대기줄에 넣는다. 그러니 손 하나로 못 하는 일을 브라우저가 옆에서 병렬로 처리해주는 셈이다. 자바스크립트 자체는 여전히 하나씩만 하지만, 기다리는 일은 밖으로 떠넘기니 화면이 안 멈춘다.


이벤트 루프가 언제 콜백을 꺼낼지 정한다. 브라우저가 대기줄에 넣은 콜백은 태스크 큐(task queue, 작업 대기줄)라는 줄에 가서 선다. 이벤트 루프가 하는 일은 딱 하나다. 콜 스택이 완전히 비었는지 계속 확인하다가, 비는 순간 대기줄의 맨 앞 콜백을 꺼내 스택에 올린다. 핵심은 지금 실행 중인 코드가 끝까지 다 돌아 스택이 텅 비어야 비로소 콜백 차례가 온다는 점이다. 그래서 대기 시간이 0이든 아니든, 지금 동기 코드가 남아 있으면 콜백은 무조건 그 뒤로 밀린다. 위 예제에서 23 뒤로 간 이유가 이거다. 13은 지금 당장 도는 동기 코드고, 2는 대기줄에서 스택이 빌 때까지 기다렸던 거다.


콜백이 여러 개면 순서가 더 헷갈린다. 하나 더 돌려보자.


console.log("A");
setTimeout(() => {
console.log("B");
setTimeout(() => console.log("C"), 0);
}, 0);
setTimeout(() => console.log("D"), 0);
console.log("E");


실행 결과다.


A
E
B
D
C


순서를 따라가 보면 규칙이 보인다. 먼저 동기 코드인 AE가 나온다. 그사이 B를 낼 콜백과 D를 낼 콜백이 대기줄에 순서대로 선다. 스택이 비자 이벤트 루프가 맨 앞의 B 콜백을 꺼내 실행한다. 그런데 B 콜백 안에서 또 setTimeout으로 C를 대기줄 맨 뒤에 새로 세운다. 이때 줄에는 이미 DC보다 앞에 서 있다. 그래서 D가 먼저, C가 나중에 나온다. 대기줄은 먼저 선 게 먼저 처리되는 선입선출이라, 이 순서가 어긋나지 않는다.


콜백이 겹치면 지옥이 시작된다. 비동기 일을 순서대로 이어야 할 때 콜백만 쓰면 문제가 생긴다. 첫 일이 끝난 콜백 안에서 둘째 일을 시작하고, 그 콜백 안에서 셋째를 시작하는 식으로 계속 안으로 파고든다. 코드가 오른쪽으로 계단처럼 밀려나는 이 모양을 콜백 지옥(callback hell)이라 부른다.


login(user, (token) => {
getProfile(token, (profile) => {
getPosts(profile.id, (posts) => {
render(posts);
});
});
});


일이 세 단계라 아직 봐줄 만하지만, 대여섯 단계로 늘면 괄호가 어디서 닫히는지도 안 보인다. 더 큰 문제는 에러 처리다. 각 단계마다 실패를 따로 확인해야 해서, 여기에 에러 처리까지 끼우면 코드가 걷잡을 수 없이 지저분해진다. 나는 실무에서 이 지옥을 만나 단계마다 실패 검사를 붙이다가, 정작 성공 흐름이 어디로 흐르는지 놓쳐서 며칠을 헤맸다. 순서를 지키려고 안으로 파고들수록 읽기가 불가능해지는 이 구조가 콜백 방식의 한계였다.


내가 이벤트 루프를 오해해서 날린 시간. 처음 비동기를 배울 때 나는 setTimeout의 시간이 정확한 약속인 줄 알았다. 1000이라 적으면 딱 1초 뒤에 실행되는 줄 알고, 그걸 믿고 타이밍을 맞춘 코드를 짰다. 그런데 앞에서 무거운 동기 작업이 스택을 오래 붙들고 있으면, 시간이 지나 콜백이 대기줄에 서 있어도 스택이 안 비어 실행이 못 된다. 그래서 1초라 적은 콜백이 2초, 3초 뒤에 튀어나오는 걸 보고 버그인 줄 알았다. 알고 보니 setTimeout의 시간은 최소한 이만큼은 기다린다는 하한이지, 정확한 실행 시각이 아니었다. 이벤트 루프는 스택이 빈 다음에야 콜백을 꺼내니, 앞이 밀리면 콜백도 밀린다. 이 하나를 이해하고 나서 타이밍에 의존하는 코드를 아예 안 짜게 됐다.


정리하면 자바스크립트는 손이 하나뿐이라 오래 걸리는 일을 그 자리에서 기다리지 않고, 끝났을 때 부를 콜백을 맡겨둔다. 맡긴 콜백은 대기줄에 서고, 이벤트 루프는 콜 스택이 빌 때마다 그 줄에서 하나씩 꺼내 실행한다. 그래서 지금 도는 동기 코드가 항상 먼저고, 콜백은 그 뒤로 밀린다. 이 순서 규칙만 확실히 잡으면 어긋나 보이던 출력이 전부 말이 된다. 다만 콜백을 겹쳐 쓰면 지옥에 빠지니, 이 문제를 깔끔하게 푸는 도구가 필요하다. 그 도구가 프로미스다.