Search

[자바스크립트 비동기 프로그래밍] 2편: async/await

Last update:

개요

지난 글에서 자바스크립트의 비동기 프로그래밍 작동 원리를 살펴보았다. 이번 글에서는 비동기 프로그래밍을 위한 async/await 문법을 알아보자.
async/await은 기존 Promise의 then/catch/finally 문법처럼 함수에 콜백을 전달하는 방식이 아니라 동기적 프로그래밍 방식으로 비동기 코드를 작성하는 편의성을 제공한다. 내부적으로 프라미스를 사용한다는 점은 똑같고 문법적인 편의성만 제공하기 때문에 문법적 설탕(syntactic sugar)의 일종으로 볼 수 있다.

예시와 함께 async/await 이해하기

1편에서 썼던 예제는 아래와 같다.
//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
복사
이 예제를 아래와 같이 async/await을 사용하여 등가 코드로 변경했다.
console.log('Global in'); await a(); console.log('Global out'); async function a() { console.log('a in'); await b(); console.log('a out'); } async function b() { console.log('b in'); await sleep(3000); console.log('b out'); } function sleep(ms) { return new Promise(res => setTimeout(res, ms)); }
JavaScript
복사
이 예제는 top-level await를 사용하기 때문에 모듈 형식(module type)으로만 작동한다. 우선 호스트(위 명령어에서는 Node.js)는 실행할 모듈을 콜스택에 올리면서 모듈 레벨의 프라미스 객체를 생성한다. 이 객체를 modulePromise라고 부르자.
modulePromise에는 모듈이 로드되거나 에러가 발생했을 때 실행될 호스트의 콜백들이 이미 등록되어 있고, isHandled 속성도 이미 true 상태이다.
그 다음 Global in이 콘솔에 출력되고,
a() 함수가 실행된다.
a() 함수는 async 키워드가 붙어있어 실행과 동시에 새로운 프로미스 객체를 생성한다.
그 다음 a in 콘솔에 출력되고
b() 함수가 실행된다.
마찬가지로 async 키워드로 인해 새로운 프라미스 객체가 생성된다.
b in이 출력되고
sleep() 함수가 호출된다.
sleep() 함수 내에서 프라미스 생성자 호출로 인해 새로운 프라미스 객체가 생성된다.
프라미스 생성자로 전달된 Executor Callback 함수가 실행되고,
Executor Callback 함수 내부의 setTimeout 함수가 실행되어 호스트에 타이머가 등록된다.
sleep() 함수가 종료되고, 생성된 sleepPromise 객체가 b() 함수로 반환된다.
반환된 sleepPromise 객체가 await을 만나고, awaitpending 상태인 것을 확인한다.
awaitb() 함수의 실행을 중단하고, 이어서 실행할 실행 컨텍스트를 sleepPromisefulfillReactions 목록에 추가한다.
제어권이 a() 함수로 넘어가며 bPromise가 반환된다.
마찬가지로 a() 함수의 awaitbPromisepending 상태임을 확인한다.
awaita() 함수의 실행을 중단하고, 이어서 실행할 실행 컨텍스트를 bPromisefulfillReactions 목록에 추가한다.
제어권이 모듈 레벨로 넘어가며 aPromise가 반환된다.
모듈 레벨의 awaitaPromisepending 상태임을 확인한다.
마찬가지로 await은 모듈 실행을 중단하고, 이어서 실행할 실행 컨텍스트를 aPromisefulfillReactions 목록에 추가한다.
콜스택이 비고, 3초 후 타이머가 만료된다.
호스트 시스템은 타이머 콜백으로 전달되었던 res() 함수를 매크로태스크 큐에 넣는다. res() 함수는 sleepPromise가 생성될 때 Executor Callback의 파라미터로 전달되었던 함수이다.
호스트 시스템이 관리하는 이벤트 루프는 res() 함수를 매크로태스크 큐에서 꺼내 콜스택에 올려놓는다.
res() 함수가 실행되면 sleepPromisefulfilled 상태가 된다.
이어서 b() 함수의 중단된 실행 지점이 다시 마이크로태스크 큐와 이벤트 루프를 거쳐 콜스택에 들어가고
중단됐던 지점부터 코드가 다시 실행되며 b out이 출력된다.
b() 함수의 실행이 종료되면 bPromise의 상태가 fulfilled로 변경된다.
이어서 a() 함수의 중단된 실행 지점이 다시 마이크로태스크 큐와 이벤트 루프를 거쳐 콜스택에 들어가고
중단됐던 지점부터 코드가 다시 실행되며 a out이 출력된다.
a() 함수의 실행이 종료되면 aPromise의 상태가 fulfilled로 변경된다.
이어서 모듈 내 중단된 실행 지점이 다시 마이크로태스크 큐를 거쳐 콜스택에 들어가고
중단됐던 지점부터 코드가 다시 실행되며 Global out이 출력된다.
모듈 내 모든 코드의 실행이 완료되면 modulePromise의 상태가 fulfilled로 변경되고
등록되어 있던 콜백들이 실행된다.
이 때 호스트가 등록해 두었던 모듈 load 이벤트가 발생하고, 이 모듈의 로딩을 기다리며 블로킹 되어있던 다른 모듈들로 로딩이 재개되는 등의 일이 일어난다.
즉, top-level await가 존재하는 모듈의 경우 해당 모듈을 import하는 다른 모듈의 로드도 블로킹을 하게 된다. 따라서 모듈 초기화 시 비동기 작업 결과가 반드시 필요하다면 블로킹을 위해 top-level await를 사용하거나, 직접 Promise를 export해서 클라이언트 모듈에서 직접 핸들링해야 한다.

정리

async/await은 내부적으로 프라미스를 이용해 작동되는 것을 알 수 있었다. 모듈 로드 시의 top-level await 부분만 제외하면 사실상 동작의 차이는 없다고 보면 된다. 헷갈릴 때는 아래처럼 생각하면 쉽다.
await 아래 부분의 코드가 then()의 콜백 함수가 되고(실행 보류),
try-catch문의 catch 블럭으로 감싼 부분이 catch()의 콜백함수가 되며,
try-catch-finally문의 finally 블럭으로 감싼 부분이 finally()의 콜백 함수가 된다.

References

관련 문서