[바미] Promise와 async/await
안녕하세요. 회사에서 저에게 자바스크립트에서 async, await사용시 블락되는 구간에 대해 질문을 받게 되었습니다.
먼저 제 블로그에 그러한 글이 있는지 찾아보았지만 Promise와 async에 관련된 글이 포스팅 된 줄 알았으나 단 한 개도 없더라구요.
그래서 겸사겸사 Javascript에서 비동기 처리에 빠질 수 없는 Promise와 async/await에 대한 내용과 저에게 질문 받은 내용을 포스팅하게 되었습니다.
만으로 2년간 Node.js를 사용하고 있었지만 아직까지 모르는 게 많다는 걸 알게 되네요.
해당 글의 내용이 정확하지 않을 수 있기 때문에 혹시나 틀린 부분이 있으시다면 언제든 지적 부탁드립니다!
물론 Javascript에서 비동기 처리를 다룰 수 있는 방법에는 여러가지가 있지만 많이 사용되는 Promise, async/await에 대해 자세히 정리 해보려 합니다.
Promise
Promise는 자바스크립트에서 비동기 처리에 사용되는 객체입니다.
내용은 실행 되었지만 결과를 아직 반환하지 않은 객체라고 이해해도 좋습니다.
Promise 에는 3가지 상태가 있는데
- Pending (대기)
- Fulfilled (이행)
- Rejected (실패)
비동기 처리가 완료 되지 않았다면 Pending, 완료 되었다면 Fulfilled, 실패하거나 오류가 발생하였다면 Rejected 상태를 갖게 됩니다.
Promise 사용 예시
const condition = true;
const promise = new Promise((resolve, reject) => {
if (condition) {
resolve('resolved');
} else {
reject('rejected');
}
});
promise
.then((res) => {
console.log(res);
})
.catch((error) => {
console.error(error);
});
condition 값의 따라 promise의 반환 값이 결정 되고 있습니다.
값이 참이면 resolve 를 호출하고, 아닐시에는 reject 를 호출하는 형태이죠.
resolve 한 반환 값에 대해서는 then() 을 통해 결과 값을 반환 받을 수 있고, reject 의 반환 값에 대해서는 catch() 를 통해 반환 받게 됩니다.
then() 과 catch() 문의 체이닝을 통해 비동기 로직의 성공 여부에 따른 분기 처리가 가능합니다.
async / await
가장 최근의 나온 비동기 처리 문법으로 ES7에서는 비동기적 제어흐름을 동기적으로 제어 할 수 있도록 기존의 callback 이나 Promise 의 단점을 해소하고자 만들어졌습니다.
async 키워드는 함수 앞에 붙는 키워드로, 해당 함수가 비동기 함수임을 의미합니다.
async함수는 항상 Promise를 리턴하죠.
await 키워드는 async 함수 내부에서만 사용 할 수 있으며, Promise가 resolved 또는 reject될 때까지 기다립니다.
이를 통해 비동기적 흐름을 동기적인 흐름으로 제어할 수 있죠.
주의 할 점으로는 callback 이나 Promise 의 경우에 단점은 꼬리에 꼬리를 무는 코드가 나올 수도 있다는 점입니다.
이런 경우를 흔히 콜백 지옥, then() 지옥이라고 부르죠.
const variable = await promise;
// promise의 반환 값을 받아 variable.
하지만 async/await 를 사용하기 위해서는 선행되어야 하는 조건이 있는데, await 는 async 함수 안에서만 동작합니다.
사용 예시를 한번 보죠.
async / await 사용예시
(async () => {
const condition = true;
const promise = new Promise((resolve, reject) => {
if (condition) {
resolve('resolved');
} else {
reject('rejected');
}
});
try {
const result = await promise;
console.log(result);
} catch (err) {
console.error(err);
}
})();
위의 Promise 사용 코드를 async/await 를 사용하여 코드를 익명 함수 패턴을 활용하여 변경하였습니다.
async 함수 내의 await 를 통해 Promise 의 반환 값을 result 변수에 담아 콘솔에 출력하는 코드 입니다.
그리고 주의할 점은 async/await 은 Promise 와는 다르게 에러를 핸들링 할 수 있는 기능이 없습니다. 따라서 try-catch() 문을 활용하여 에러를 핸들링 하여 주어야 하죠.
차이점
- 에러 핸들링
- Promise 를 활용할 시에는 .catch() 문을 통해 에러 핸들링이 가능하지만, async/await 은 에러 핸들링 할 수 있는 기능이 없어 try-catch() 문을 활용해야 합니다.
- 코드 가독성
- Promise의 .then() 지옥의 가능성.
- 코드가 길어지면 길어질수록, async/await 를 활용한 코드가 가독성이 좋음.
- async/await 은 비동기 코드가 동기 코드처럼 읽히게 해준다.( 코드 흐름을 이해 하기 쉬움.)
Async/Await 사용 시 주의할 점
Node Js는 I/O 작업을 Non-Blocking 형태로 처리하기 때문에 순차적 코드 작성에 익숙한 개발자들이 Node Js의 I/O 처리에서 어려움을 겪는 경우가 흔하게 발생하기 때문에 Non-Blocking을 다루면서 실수하기 쉬운 포인트들을 정리해보겠습니다.
코드의 순서와 일치하지 않는 결과
다음과 같은 코드를 보죠.
let printNum = (number, delaySec) => {
setTimeout(() => console.log(number), delaySec); // i/o 작업을 대신한다.
};
let logPrintNum = (number, delaySec) => {
console.log(`Enter logPrintNum ${number}`);
printNum(number, delaySec);
console.log(`Exit logPrintNum ${number}`);
};
logPrintNum(1, 0);
printNum 함수는 delaySec 초 뒤에 number를 console에 출력하는 간단한 함수입니다.
logPrintNum은 printNum 함수를 실행하기 전 후에 logging을 추가적으로 출력하는 함수인데 위 코드를 실행한 결과는 아래와 같습니다.
Enter logPrintNum 1
Exit logPrintNum 1
1
출력결과는 코드의 실행순서와 일치하지 않습니다.
이 부분이 Non-Blocking I/O가 어렵게 느껴지는 가장 근본적인 이유죠.
왜 그런지 간략히 살펴보도록 하겠습니다. 위 코드의 대략적인 흐름은 다음과 같습니다.
- logPrintNum의 첫번째 console.log가 실행되어 Enter logPrintNum 1가 출력된다.
- 다음라인인 printNum이 호출된다.
2-1. printNumb의 setTimeOut이 호출된다.
2-2. setTimeOut은 타이머 이벤트를 브라우저 또는 Node API에 요청한다. - logPrintNum의 마지막 라인 console.log가 실행되고 Exit logPrintNum 1가 출력된다.
setTimeOut의 인자로 넘겨진 콜백함수는 곧바로 실행되지 않고, 2-2번에서 요청한 타이머 이벤트가 완료된 뒤에 Task Queue에 삽
입합니다.
이 후 Call Stack이 비었을 때 Event Loop가 하나씩 Task Queue에서 꺼내어 콜백함수가 실행됩니다.
이러한 비동기적 동작방식때문에 0초에 딜레이를 줬음에도 불구하고 코드 순서와 다르게 결과가 출력되는 것이죠.
Await은 Promise에 대해서만 유효합니다!
위 코드를 async,await 키워드를 이용해 코드의 순서와 출력이 일치하도록 고쳐보도록 하겠습니다.
let printNum = async (number, delaySec) => {
await setTimeout(() => console.log(number), delaySec); // i/o 작업을 대신한다.
};
let logPrintNum = async (number, delaySec) => {
console.log(`Enter logPrintNum ${number}`);
await printNum(number, delaySec);
console.log(`Exit logPrintNum ${number}`);
};
logPrintNum(1, 0);
위 코드는 과연 의도한대로 코드의 순서와 일치하는 결과가 출력이 될까요?
Enter logPrintNum 1
Exit logPrintNum 1
1
여전히 코드의 흐름과 맞지 않는 결과가 출력 되었습니다.
그 이유는 setTimeOut이 Promise를 리턴하고 하고 있지 않기 때문입니다.
Await은 오직 Promise에 대해서만 유효합니다. 우리가 의도한대로 순서대로 결과를 출력하기 위해서 아래와 같이 수정해보죠.
let printNum = (number, delaySec) => {
return new Promise((resolve) =>
setTimeout(() => {
console.log(number);
resolve();
}, delaySec));
};
let logPrintNum = async (number, delaySec) => {
console.log(`Enter logPrintNum ${number}`);
await printNum(number, delaySec);
console.log(`Exit logPrintNum ${number}`);
};
logPrintNum(1, 0);
Promise는 생성자로 콜백함수를 받게되는데, 해당 콜백함수의 첫번째 인자는 resolve()입니다.
resolve()를 호출함으로서 해당 Promise가 이행되었음을 알려줍니다.
실행결과는 다음과 같습니다.
Enter logPrintNum 1
1
Exit logPrintNum 1
map에서의 async/await
let printNum = (number, delaySec) => {
return new Promise((resolve) =>
setTimeout(() => {
console.log(number);
resolve();
}, delaySec));
};
let inner = async (number) => {
console.log(`Enter Inner ${number}`);
await printNum(number);
console.log(`Exit Inner ${number}`);
};
let outer = (numbers) => {
numbers.map(async printNum => {
console.log(`Enter Outer ${printNum}`);
await inner(printNum);
console.log(`Exit Outer ${printNum}`);
});
};
let data = [1,2];
outer(data);
위 코드의 실행결과는 다음과 같습니다.
Enter Outer 1
Enter Inner 1
Enter Outer 2
Enter Inner 2
1
Exit Inner 1
Exit Outer 1
2
Exit Inner 2
Exit Outer 2
실행 결과를 보게 되면 1에 대한 처리가 다 끝나지 않았음에도 2에 대한 처리가 시작되는 걸 볼 수 있죠?
그 이유는 map은 await를 기다리지 않기 때문이에요.
map은 파라미터로 받은 함수를 요소에 따라 순회하면서 실행하는데, 이 때 await에 대해서 block되지 않고 다음 요소에 대한 수행을 실행하게 됩니다.
물론 forEacth도 마찬가지죠.
이외에도 또 한가지 조심해야 될점은 map의 파라미터로 async 함수를 넣어주게 되면, Promise 객체가 리턴됩니다.
그렇다면 await은 정말 Non-Blocking일까요?
Node.js가 싱글 스레드 기반의 Non-Blocking/Asynchronous 라는 것은 Node.js를 사용해 본 사람이라면 들어봤을 것입니다.
하지만 실제로 Node.js가 구동되는 방식을 보면 Node.js는 과연 싱글 스레드가 맞는가? 라는 의문도 들게 되죠.
Node.js는 Javascript Core Engine과 V8과 libuv 사이를 바인딩해주는 인터페이스로 볼 수도 있고, 기존의 Non-Blocking이
가지고 있던 한계를 극복하기 위해 운영체제가 지원하는 Demultiplexer를 이용해 Event Demultiplexing을 구현하고,
Event Demultiplexer와 Event Queue, Event Loop를 이용해 Reactor 패턴을 사용하고 있죠.
Node.js의 async/await의 동작 방식은 await이 실행되는 시점에 async 내부의 실행을 멈춘다고 공식 문서에서 설명하고 있
습니다.
async/await는 분명히 코드를 동기적 보여지기 위해 사용하며, Non-Blocking/Asynchronous 이라고 알고 있기 때문에
await가 실행되는 시점에 async 내부의 다음 코드들의 실행을 멈추는 부분이 당연히 Blocking이 아닌가? 라는 생각이 들었습니다.
Node.js는 핸들러라는 개념을 통해 Callback 함수를 표현합니다. 그리고 async/await은 반드시 Promise를 반환받아야 하죠. 그렇기 때문에 async/await이 당연히 비동기로 동작한다는 것은 당연해보입니다.
하지만 await의 동작은 엄연히 Blocking이 아닌가? 라는 의문이 들었고, 해당 구글에서 찾아보았지만 구글에서도 글을 쓰는 사람들 마다의 의견이 다분했기 때문에 쉽게 결론이 나지 않았습니다.
그 중에 자료를 찾다보니 알게된 사실은 async/await은 반드시 Promise 객체를 반환 받아야 하기 때문에 await은 Promise의 반환값을 기다리는 함수라고 생각할 수 있습니다.
그리고 async 내부의 await이 실행되는 시점에는 해당 함수의 Promise 반환값을 받을 때 까지 async의 내부 코드들의 실행을 멈춥니다만, 실제로 await은 async 내부 코드들의 실행을 멈추지 않는다는 것입니다.
다만 보이지 않는 .then을 통해 다음 코드들을 담아둘 뿐이죠.
MDN 문서에서도 await 다음의 코드는 then 콜백에 포함되어 있다고 생각해도 된다 써 있습니다.
Code after each await expression can be thought of as existing in a `.then` callback. In this way a promise chain is progressively constructed with each reentrant step through the function. The return value forms the final link in the chain.
출처 : MDN
await의 실행 방식은 함수를 콜백 또는 프로미스 체인으로 작성하는 것과 같다고 보면 됩니다.
즉, async/await은 코드를 동기적으로 실행하는 것처럼 보이지만 실제로 인터프리터의 실행을 멈추는 것은 아니며,
await 또한 콜백으로 동작하는 것과 같기 때문에 여전히 다른 이벤트가 이벤트 핸들러를 실행할 수 있는 상태인 것이죠.
이 말은 곧 다른 이벤트들에 대해 이벤트 큐가 여전히 동작하고 있다는 것이고, await이 실행되는 순간 정말로 async 내부의 실행
이 Blocking 되는 것이 아니라 일단 해결되지 않은 Promise를 반환한 후 다른 것을 실행하다가 Promise 객체의 반환값을 받는 시점에 다음 코드를 실행할 수 있는 이벤트가 되는 것을 의미합니다.
Reference