비동기 자바스크립트
"JavaScript is a single-threaded non-blocking asynchronous concurrent programming language."
— 자바스크립트 비동기 아키텍처의 핵심을 관통하는 공식적인 학계 및 업계 정의.
화면이 뚝뚝 끊기는 현상을 막기 위해 똑똑한 비동기 문법을 끊임없이 발명했으나, 정작 개발자의 뇌가 비동기 흐름을 쫓아가지 못해 싱글 스레드로 정지해 버리는 기현상을 낳았다. 콜백 체인을 한 10단계쯤 짜놓으면 내가 짠 코드인데도 내 뇌에서는 동기식으로도 해석이 거부된다
1. 개요
비동기 자바스크립트는 자바스크립트 엔진이 단 하나의 호출 스택(Call Stack)만을 가진 싱글 스레드(Single-threaded) 환경임에도 불구하고, 네트워크 요청이나 타이머 같은 무거운 작업을 멈춤(Non-blocking) 없이 동시에 처리할 수 있도록 돕는 일련의 패러다임과 제어 기법이다.
만약 자바스크립트가 모든 작업을 동기식(Synchronous)으로 처리했다면, 웹페이지에서 기가바이트급 이미지를 다운로드하는 5초 동안 브라우저는 완전히 얼어붙어 마우스 클릭조차 먹히지 않는 쓰레기 앱이 되었을 것이다. 이를 막기 위해 자바스크립트는 엔진 자체의 단일 스레드를 유지하되, 시간이 걸리는 무거운 일은 브라우저(또는 NodeJS 런타임) 백그라운드에 슬쩍 미뤄두고 자신은 가볍게 다음 코드를 즉시 실행하는 기막힌 비동기 협업 모델을 정립했다.(...)
2. 비동기를 실현하는 삼총사: 콜 스택, Web API, 이벤트 루프
2.1. 엔진은 혼자 일하지만, 런타임은 군대다
많은 초보 개발자들이 오해하는 점이 바로 V8 엔진 같은 자바스크립트 엔진 내부에서 모든 일이 비동기로 벌어진다고 생각하는 것이다. 그러나 엔진 안에는 오직 일을 순서대로 해치우는 Call Stack(콜 스택) 단 하나뿐이다. 비동기의 진짜 마법은 엔진 바깥을 감싸고 있는 브라우저(또는 NodeJS 런타임)에서 일어난다.
- Call Stack: 자바스크립트 코드가 실행되는 단 하나의 외길. 함수가 실행되면 스택에 쌓이고, 끝나면 빠져나간다.
- Web API: 브라우저가 제공하는 멀티 스레드 영역이다.
setTimeout,fetch, DOM 이벤트 리스너 등이 실행을 요청하면 브라우저는 엔진 대신 타이머를 세거나 네트워크 트래픽을 처리해 준다. - Task Queue(Callback Queue): Web API가 비동기 연산을 마친 뒤, 나중에 실행해달라며 콜백 함수들을 줄 세워두는 대기실이다.
- Event Loop(이벤트 루프): 콜 스택이 텅 빈 상태가 될 때까지 하염없이 째려보고 있다가, 스택이 완전히 비는 순간 대기실(Task Queue)에 있는 콜백 함수를 하나씩 꺼내 콜 스택에 사뿐히 얹어 실행시켜 주는 감시자다.1
3. 비동기 문법의 3대 연대기
3.1. 콜백 함수 (Callback)와 아뵤(?) 지옥
비동기 처리가 끝난 후 실행할 로직을 인자값으로 함수 자체를 넘겨주는 고전적 방식이다. 단순하지만 비동기 연산이 연속으로 여러 번 겹칠 경우, 코드의 오른쪽 끝이 장풍을 맞은 듯 사정없이 밀려나며 화면을 뒤덮는 '콜백 지옥 (Callback Hell)'을 연출한다. 에러 처리가 거의 불가능에 가깝고 가독성은 사망한다.
3.2. 프로미스 (Promise)의 구원
ES6(2015)에서 도입된 구세주다. 지금 당장은 없지만 '미래에 올 데이터 혹은 에러'를 약속하는 객체다. 콜백 지옥을 대괄호 체인인 .then(), .catch(), .finally()를 통해 평평한 수평 구조로 변형시켜 준다. 그러나 여전히 꼬리에 꼬리를 무는 프로미스 체이닝은 복잡도가 상승하면 또 하나의 새로운 세련된 지옥을 만들어낼 뿐이었다.
3.3. async / await: 자바스크립트 문법의 축복
ES2017에서 표준으로 안착한 비동기 제어의 최종 진화형이다. Promise 기반 위에 얹어진 기막힌 Syntactic Sugar로, 비동기 코드를 무려 '동기식 코드처럼' 위에서 아래로 정직하게 읽히게 만들어 준다. function 앞에 async를 붙이고 비동기 작업에 await를 걸면 끝이다. 에러 처리도 전통적인 try-catch 문을 그대로 재활용할 수 있어 개발자들의 눈시울을 붉히게 만들었다.(...)2
4. 새치기의 대가, 마이크로태스크 큐 (Microtask Queue)
4.1. 대기실에도 금수저와 흙수저가 있다
자바스크립트 런타임 내부의 대기실(Queue)은 단 하나가 아니다. 크게 일반 태스크 큐(Task Queue / Macrotask Queue)와 마이크로태스크 큐 (Microtask Queue)로 쪼개진다.
- 일반 태스크 큐:
setTimeout,setInterval,setImmediate등의 콜백이 들어간다. - 마이크로태스크 큐:
Promise의.then(),async/await,MutationObserver같은 초법적인 녀석들이 들어간다.
이벤트 루프는 두 대기실에 일감이 다 차 있어도, 마이크로태스크 큐가 완전히 텅 빌 때까지 일반 태스크 큐의 콜백은 쳐다보지도 않는다. 즉, 프로미스는 일반 타이머보다 무조건 새치기해서 먼저 실행되는 초법적인 특권을 누린다. 면접 단골 수수께끼로 자주 출제되니 뼈에 새겨두자.
5. 관련 밈 및 드립
5.1. setTimeout(fn, 0)의 간절함
분명 타이머에 시간 '0'을 주었으니 당장 실행되어야 할 것 같지만, 자바스크립트 뇌구조상 이 콜백 함수는 콜 스택에 직접 올라가지 못하고 Web API를 거쳐 일반 태스크 큐로 쫓겨난다. 즉, 현재 실행 중인 무거운 연산이 다 끝나고 콜 스택이 텅 비어야 비로소 '0초 타이머' 콜백이 실행된다. 실무에서 기괴한 렌더링 타이밍 꼬임 현상이 일어났을 때, 원인은 모르겠지만 일단 돌아가게 만들려고 setTimeout(() => { ... }, 0)을 소스코드 군데군데 땜질식으로 도배하는 주니어 개발자들의 기도가 담긴 임시방편 전용 밈이다.(...)
6. 여담
- Uncaught (in promise)의 경고: 프로미스를 쓸 때
.catch()나try-catch로 예외 처리를 해주지 않고 그냥 넘어가면 브라우저 콘솔창이 새빨간 에러로 도배된다. 프로그램이 죽지는 않지만 로그 분석기의 경보를 실시간으로 울리게 만드는 주범이다. - async는 무조건 Promise를 리턴한다:
async키워드가 붙은 함수 내부에서 설령 문자열이나 숫자를 직접 리턴하더라도, 자바스크립트는 이를 자동으로Promise.resolve(값)으로 포장해서 반환한다. 그러므로 받은 쪽에서는 좋든 싫든 꺼내기 위해await를 걸거나.then()을 붙여야 한다. - 비동기 루프 안의 await의 덫:
Array.prototype.forEach내부 콜백에async/await를 걸면, 배열 요소가 차례대로 비동기로 도는 것이 아니라 그냥 모든 요소가 동시에 한 번에 비동기로 실행되어 서버 트래픽 폭탄을 때리는 대형 참사가 발생한다. 순서대로 돌리려면 정직하게for...of문을 써야 한다.