Search

[자바스크립트 비동기 프로그래밍] 1편: Promise, Event Loop, Task Queue

Last update:

개요

자바스크립트 코드를 실행하는 자바스크립트 엔진은 단일 스레드에서 작동하는 라이브러리로, 한 번에 한 가지 작업만을 수행할 수 있다. 이런 단일 스레드 엔진을 활용해 자바스크립트가 어떻게 여러 작업을 동시에 처리할 수 있는지 살펴보자.

자바스크립트 런타임의 구성

자바스크립트 엔진 구현체는 우리가 흔히 알고 있는 V8(구글 크롬, Node.js, Electron), SpiderMonkey(파이어폭스), JavaScriptCore(애플 웹킷) 외에도 수 십 개가 있다. 위키백과에서 그 목록을 볼 수 있는데, C/C++, Java, Go 등 다양한 언어로 구현되어 있는 것을 확인할 수 있다.
자바스크립트 엔진 구현체 목록 (위키백과)
자바스크립트 엔진과 이를 가져다 쓰는 브라우저, Node.js 같은 호스트를 통틀어 자바스크립트 런타임이라고 부른다. 자바스크립트 엔진은 싱글 스레드이기 때문에 호스트와 힘을 합쳐야만 여러 작업들을 동시에 처리할 수 있다(웹 워커나 node.js의 worker_threads 등 엔진을 여러 개 돌리는 방법 제외).
아래는 대표적인 자바스크립트 런타임의 구성이다
호스트
자바스크립트 엔진
구글 크롬, Node.js, Electron
V8
파이어폭스
SpiderMonkey
애플 웹킷
JavaScriptCore
아래는 간단한 자바스크립트 런타임의 구성도다. 주황색은 자바스크립트 엔진이 관리하고, 파란색은 호스트가 관리한다. 각 요소들이 어떤 역할을 하는지 작동 예시와 함께 살펴보자.

예시와 함께 비동기 동작 이해하기

실제 자바스크립트 코드를 기반으로 내부 동작 과정을 살펴보자. 아래는 Promise 객체와 then 함수를 이용해 작성한 예시 코드다.
//test.js console.log('Global in'); a().then(() => { console.log('Global out'); }); function a() { console.log('a in'); return b().then(() => { console.log('a out'); }); } function b() { console.log('b in'); return sleep(3000).then(() => { console.log('b out'); }); } function sleep(ms) { return new Promise(res => setTimeout(res, ms)); }
JavaScript
복사
이 코드를 실행해보면 아래와 같은 결과를 얻는다. 어떻게 이런 결과가 나오는지 따라가보자.
$ node test.js Global in a in b in b out a out Global out
Shell
복사
이 예제는 모듈 형식(module type)의 자바스크립트 코드라고 가정하겠다. 우선 호스트(위 명령어에서는 Node.js)는 실행할 모듈을 콜스택에 올린다.
첫번째 명령어가 실행되어 콘솔에 Global in이 출력된다.
그 다음 a() 함수가 호출된다.
마찬가지로 a in 이 출력된다.
계속해서 b() 호출 후 b in이 출력되고,
sleep() 함수가 호출된다.
Promise 객체의 생성자가 호출되면서, 힙 메모리에 Promise 객체 인스턴스가 새로 생성된다. sleep() 함수 내에서 생성되었으니 sleepPromise라고 불러주자.
프라미스(Promise)
Promise 객체는 자바스크립트가 비동기 프로그래밍을 구현하기 위해 사용하는 객체다. 프라미스는 목표 작업(executor callback)이 완료되거나(fulfill, resolve) 거부됐을 때(reject), 약속(promise)된 다른 작업(reactions)들을 수행하기 위해 사용하는 객체다. 아래와 같은 주요 속성들을 가지고 있다.
state: 작업의 상태. fulfilled 또는 rejected 상태를 합쳐 settled 상태라고 부른다.
pending: 초기 상태
fulfilled: 작업이 완료됨
rejected: 작업이 실패함
value: 작업의 최종 결과값으로, 프라미스가 settled 되었을 때 저장되는 값이다. 초기에는 undefined이며, 작업이 완료되면 특정 값으로 채워진다. value의 타입에는 제한이 없어서 작업에 실패하더라도 꼭 에러 객체를 반환하지 않아도 된다.
fulfillReactions: 프라미스가 fulfilled 상태가 되었을 때 실행될 콜백 함수들의 배열
rejectReactions: 프라미스가 rejected 상태가 되었을 때 실행될 콜백 함수들의 배열
이런 작동 방식을 통해 프라미스 객체는 내부 상태 변화에 따라 연쇄적인 함수 호출을 일으켜서 비동기 작업의 실행 순서를 보장하고, 작업을 스케줄링하는 역할을 한다.
프라미스의 생성자로 전달한 Executor 콜백 함수(res => setTimeout(res, ms))가 실행된다.
Executor 콜백 함수 내의 setTimeout() 함수가 실행되면 호스트 내에 3초 후 res() 함수를 실행하는 타이머가 추가된다.
sleep() 함수의 실행이 끝나면 sleepPromise 객체가 b() 함수로 반환된다.
반환된 sleepPromise 객체의 then() 함수가 실행되면, 새로운 프라미스 객체가 생성된다. 이 객체를 bPromise라고 부르자. sleepPromise.then()에 넘겨진 콜백 함수는 sleepPromisefulfillReactions에 추가되고 isHandled 속성은 true로 변경된다.
프라미스의 isHandled 속성은 리액션 함수가 추가되기만 해도 true로 바뀐다. 리액션 함수의 실제 실행 여부를 나타내지 않는다.
sleepPromise.then() 함수는 이렇게 기존 프라미스(sleepPromise)의 상태를 변경한 후, 새로 생성한 프라미스(bPromise)를 반환한다. 새로 생성된 bPromise는 return문을 타고 다시 a() 함수로 반환된다.
똑같이 반환된 bPromisethen() 함수가 호출되면 프라미스가 새로 생성되고(aPromise), bPromise.fulfillReactions에 콜백이 추가되고, bPromise.isHandled 속성은 true로 변경된다.
마찬가지로 탑 레벨에 aPromise가 반환되고,
모듈 레벨로 반환된 aPromisethen() 함수가 호출되면 프라미스가 새로 생성되고(Promise), aPromise.fulfillReactions에 콜백이 추가되고, aPromise.isHandled 속성은 true로 변경된다.
이렇게 각 프라미스의 fulfillReactions에 등록된 콜백 함수들을 제외하고 모든 모듈의 코드가 실행되며 콜스택에서 내려가고, 호스트에서는 모듈 load 이벤트가 발생한다.
이윽고 3초가 경과하면 호스트의 타이머가 작동한다.
호스트는 타이머 콜백으로 전달되었던 res() 함수를 매크로태스크 큐에 넣는다. res() 함수는 sleepPromise가 생성될 때 Executor Callback의 파라미터로 전달되었던 함수이다.
매크로태스크 큐(Macrotask Queue)
매크로 태스크 큐는 아래처럼 이벤트 단위의 작업들이 들어간다.
setTimeout/setInterval
이벤트 핸들러
네트워크 I/O 콜백
UI 렌더링 등
호스트가 관리하는 이벤트 루프는 res() 함수를 매크로태스크 큐에서 꺼내 콜스택에 올려놓는다.
res() 함수가 실행되면 sleepPromise의 state가 fulfilled로 변경된다.
그리고 sleepPromisefulfillReactions에 등록된 () => {console.log('b out')} 콜백이 마이크로태스크 큐에 들어간다. 회색 화살표는 콜백 함수가 bPromise의 상태를 변화시킨다는 점을 표시한 것이다.
콜스택이 빈 것을 확인한 이벤트루프는 마이크로태스크 큐를 확인하고, 큐 안에 있는 콜백을 콜스택에 올린다.
() => {console.log('b out')} 함수가 실행되고, 콘솔에 b out이 출력된다.
sleepPromisefulfillReactions로 등록되었던 콜백이 실행 완료되면, sleepPromise.then()으로 생성되었던 bPromise의 상태가 fulfilled로 변경된다.
마찬가지로 bPromise.fulfillReactions에 등록된 콜백이 마이크로태스크 큐에 들어가고
실행되고
콘솔에 a out이 찍히고
aPromisefulfilled 되고
aPromise.fulfillReactions에 등록된 콜백이 마이크로태스크 큐에 들어가고
이벤트 루프를 타고 콜스택에 올라가 실행되고
Global out이 출력되고
마지막 프라미스가 fulfilled되며 모든 스크립트 실행이 마무리된다. 여기서 마지막 프라미스 객체(Promise)는 아무런 역할을 하지 않는다.

정리

위 예시를 기반으로 내용을 정리하면 아래와 같다.

자바스크립트의 실질적인 동시성은 호스트를 통해 구현된다.

자바스크립트 코드는 싱글 스레드로 실행되는 것을 확인했다. 실질적인 동시성은 호스트를 통해 구현된다는 것을 알 수 있었다.

작업들에는 실행 우선순위가 있다

자바스크립트는 아래와 같은 방식으로 작동한다.
1.
매크로태스크 큐에서 작업을 하나 꺼내 콜스택에서 끝까지 실행한다(run-to-completion).
2.
콜스택이 비면 마이크로태스크 큐가 빌 때까지 모두 실행한다.
3.
호스트가 필요할 경우 기타 호스트 작업을 실행한다(렌더링 등).
4.
1부터 반복
따라서 콜스택이나 마이크로태스크에 올라간 작업이 너무 오래 걸린다면 틈틈이 작업을 중단하고 setTimeout 등을 통해 나머지 작업을 매크로태스크로 보내야 렌더링처럼 다른 중요한 작업들이 방해받지 않는다.
자바스크립트는 위와 같은 작동 방식을 통해 여러 프로미스 객체의 콜백 함수 체인이 하나의 문맥(context) 안에서 안전하게 실행될 수 있도록 한다.
즉, 중요한 문맥을 구성하는 setTimeout, 이벤트 핸들러, 네트워크 I/O 콜백 등은 매크로태스크 큐로 들어가고,
같은 문맥 하에 연속적으로 처리되어야 하는 프라미스 콜백, MutationObserver 콜백 등은 마이크로태스크 큐로 들어간다.
이 예시에서는 setTimeout이라는 단일한 매크로태스크 하에 res, console.log('b out'), console.log('a out') 같은 마이크로태스크들이 하나의 문맥 안에서 실행될 수 있었다.

이어서

다음 글에서는 비동기 프로그래밍을 위한 일종의 문법적 설탕(syntactic sugar)인 async/await의 작동 방식을 알아보자.