JavaScript를 사용하면서 헷갈렸던 것을 고르라고 하면 고민 없이 비동기 처리를 가장 먼저 말할 것이다. 그중에서도 Promise가 무엇인지 이해가 잘되지 않았다. 시간이 지나면서 경험적으로 알게 되었지만, 여전히 명확하게 말하기는 어려운 것 같다. 또 Promise와 함께 빼놓을 수 없는 것이 async/await 문법이다. 멋모르고 사용할 때는 잘 사용하고 있는지 파악하기도 힘들었고, 동작하지 않으면 어떤 걸 잘못해서 동작하지 않는지도 몰랐다.
그래서 이번 기회에 애매하게 알고 있던 Promise 및 비동기 문법을 정리 해보고자 한다.
JavaScript를 사용하는 사람들이라면 누구나 한 번쯤은 ‘콜백 지옥’이라는 단어를 들어봤을 것이다. (콜백함수에 류가 아도겐하는 짤과 함께…) 비동기 처리 결과를 해당 비동기 처리 함수의 외부에서 바로 사용할 수 없기 때문에 비동기 작업 이후 실행할 로직을 내부로 넘겨주는 과정에서 콜백 지옥이 생긴다. 비동기 결과를 가지고 다시 비동기 작업을 해야 할 경우 콜백 지옥은 더 깊어지고 코드를 파악하기도 어려워진다. 이뿐만 아니라 콜백 함수로 비동기를 처리하는 방법에서는 에러 처리가 힘들다는 문제도 있다.(더 자세한 내용은 모던 자바스크립트 Deep Dive 참고)
이러한 전통적인 비동기 처리의 문제를 해결하고자 ES6에서 Promise가 등장했다.
그렇다면 Promise 객체는 무엇일까? MDN애서는 다음과 같이 나와있다.
*Promise
객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타냅니다.
…
미래의 어떤 시점에 결과를 제공하겠다는 '약속'(프로미스)을 반환합니다.*
설명된 것처럼 Promise는 당장 사용할 수 있는 값이 아니라, 비동기 작업 이후 사용할 수 있는 결과를 반환할 것임을 나타내는 객체이다. Promise 생성자는 resolve
와 reject
인수를 전달할 실행 함수를 매개변수로 받는다.
const promise = new Promise((resolve, reject) => {});
console.log(promise);
위 코드를 실행해보면 다음과 같이 나온다.
이를 통해 알 수 있듯이 Promise 객체는 상태와 결과를 가진다. 위에서는 아무 것도 resolve 하거나 reject하지 않았기 때문에 결과 값이 undefined
이며 pending
상태이다.
실행 함수 내부에서 resolve하면 상태는 연산이 성공적으로 완료되었다는 fulfilled
가 되고, reject하면 연산이 실패했다는 rejected
가 된다.
Promise
에 결과 값이 존재하기는 하지만 이를 위처럼 단순히 변수로 받아서 사용할 수는 없다. 그러면 결과 값을 사용하려면 어떻게 해야 할까?
바로 then
메서드를 사용하면 된다.
then
은 두 개의 매개변수를 받을 수 있다. 처음 매개변수는 Promise
가 resolve되었을 때 호출되는 핸들러 함수이며, 두 번째 매개변수는 Promise
가 reject 되었을 때 호출되는 핸들러 함수이다.
new Promise((resolve, reject) => {
resolve("resolve 결과");
reject("reject 결과");
}).then(
(resolvedResult) => {
console.log(resolvedResult + " 이행됨");
},
(rejectedResult) => {
console.log(rejectedResult + " 거부됨");
}
);
// 실행 결과: resolve 결과 이행됨
// resolve() 부분을 주석처리하고 실행하면 'reject 결과 거부됨'이 찍힌다.
이처럼 then
메서드를 통해 이행 및 거부 결과를 받아서 처리할 수 있다. 또 then
에서 반환되는 값은 Promise
객체이기 때문에 then
을 체이닝할 수도 있다.
핸들러 함수에서의 return 값은 then
에서 반환되는 Promise
의 결과값이 된다. 만약 반환하는 값이 없다면 undefined
가 결과 값이 된다.
Promise
가 거부되었을 때 이행할 핸들러 함수를 등록하는 메서드이다.
then
메서드를 통해 이행 및 거부 시 행할 동작을 모두 처리할 수 있기 때문에 사실 catch
를 사용하지 않아도 된다. 하지만 일반적으로는 catch
를 이용해서 에러가 발생하거나, Promise
가 거부되었을 때 작업을 수행한다.
catch
역시 then
처럼 반환값은 Promise
객체이다. 그런데 이 때 Promise
는 rejected 될 Promise
가 아니라 resolved 될 Promise
이다. reject하는 새로운 Promise
를 만들어서 반환하거나, 내부 로직에서 에러가 발생하지 않는 이상 반환값은 곧 이행할 Promise
이다.
Promise.reject("거부")
.catch((result) => result + " 첫 catch에서 반환")
.then((result) => console.log(result + " then에서 받음"))
.catch((result) => console.log(result + " 두 번째 catch에서 받음"));
위 코드의 실행 결과는 거부 첫 catch에서 반환 then에서 받음 이다. 첫 번째 catch
에서 값을 반환했을 때 그 다음 catch
로 이어지는 것이 아니라 then
에서 받은 것을 확인할 수 있으며 이를 통해 반환된 Promise
가 이행 상태임을 알 수 있다.
이 이외에도 promise.finally
를 통해서 이행, 거부 상관 없이 항상 실행할 작업을 등록할 수 있다.
then
과 catch
의 동작을 정리해보면
둘 다 Promise
가 이행 또는 거부되었을 때 호출될 핸들러 함수를 등록하는 역할을 한다. Promise
가 web APIs의 도움을 받아 비동기적으로 실행되고 결과 값이 셋팅되면 등록된 핸들러 함수가 마이크로 태스크 큐에서 호출되기를 기다린다.
이제 정말 async / await에 대한 얘기를 해보자.
Promise
로 인해 비동기 처리의 흐름을 파악하기 쉬워졌지만 여전히 콜백 함수를 메서드의 매개변수로 전달해야 한다. 그렇지만 async / await을 이용하면 콜백 함수 전달 없이 비동기를 동기처럼 처리할 수 있다.
async 함수는 비동기로 동작하는 함수이며 항상 Promise
를 반환한다.
async function myAsync() {
return 1;
}
const value = myAsync();
console.log(value);
위의 코드를 실행하면 다음과 같이 fulfilled
상태의 결과 값이 1인 Promise
가 반환 되는 것을 확인할 수 있다. myAsync()
에서 반환하는 값이 Promise
가 아닌데 Promise
로 감싸져서 반환되는 것이다.
await
은 async
함수 내부에서만 사용할 수 있는 구문이다. MDN에 따르면 async
함수 내부에서 await
을 만나면 해당 async
함수의 실행이 정지가 된다. 이후 await
이 기다리는 Promise
가 이행되면 해당 결과 값을 반환하고, 거부된다면 해당 결과 값을 throw
한다. 만약 await
이 기다리는 값이 Promise
가 아니라면 이행된 Promise
로 변환하고 결과 값을 반환한다. 따라서 await
으로 기다려서 가져오는 값은 Promise.then()
으로 넘겨지는 핸들러 함수에서 인자로 받는 값과 같다고 할 수 있다.
또 async
함수는 첫 번째 await
까지는 동기적으로 실행되며 나머지 부분은 마이크로 태스크로 옮겨져 실행된다.
글로만 읽어서는 잘 모르겠어서 코드를 작성해봤다.
async function get1() {
console.log("start getting one");
const one = await new Promise((resolve) => {
console.log("got one");
resolve(1);
});
console.log("end getting one");
return one;
}
function get2() {
console.log("get two");
return 2;
}
async function get3() {
console.log("get three");
return 3;
}
async function getNumbers() {
console.log("start getting numbers");
const one = await get1();
console.log(one);
const two = await get2();
const three = get3();
console.log(three);
return [one, two, three];
}
getNumbers();
console.log("got all numbers");
위 코드를 실행시키면 다음과 같이 출력 된다.
start getting numbers
start getting one
got one
got all numbers
end getting one
1
get two
get three
Promise { 3 }
getNumbers
를 호출하면 "start getting numbers"가 출력 되고, 첫 번째 await
을 만나 get1
함수를 호출한다.get1
이 호출되었으므로 get1
의 실행 문맥으로 옮겨가 내부에서 "start getting one"을 출력하고 다시 await
을 만나 Promise
가 이행되기를 기다린다.Promise
내에서 "got one"이 출력 되고 1을 결과 값으로 해 Promise
가 이행된다.async
함수는 첫 번째 await
까지만 동기적으로 실행되기 때문에 get1
함수의 나머지 부분과 getNumbers
함수의 나머지 부분은 마이크로 태스크로 옮겨가고, 전역 실행 문맥으로 돌아가 "got all numbers”가 출력 된다.await
은 비동기 함수 내부에서의 동기적인 실행을 보장하므로 getNumbers
의 다음 코드들은 get1
이 이행되기 전까지 실행되지 않는다. 따라서 get1
에서 "end getting one"이 출력되고 1을 반환한다.one
에는 get1
에서 이행된 값인 1이 담기고 “1”이 출력된다.get2
가 호출되어 내부에서 "get two”이 출력되고 2를 반환한다. get2
는 비동기 함수가 아니지만 await
으로 인해 이행된 Promise
로 변환되고, 그 결과 값인 2가 two
에 담긴다.get3
가 호출되거 내부에서 "get three”가 출력되고 3을 반환한다. await
키워드가 없기 때문에 three
에는 결과 값을 3으로 하는, 이행된 Promise
객체가 담긴다.async
함수는 Promise
를 반환한다.async
함수는 첫 번째 await
까지만 동기적으로 실행되고, 나머지는 마이크로 태스크로서 비동기적으로 실행된다.await
은 async
함수 내부에서의 동기적인 실행을 보장하며, Promise
에서 이행된 결과 값을 기다리고 가져온다. 기다리는 것이 Promise
가 아니라면 Promise
로 감싸서 해당 값을 이행한다.중간에 마이크로 태스크라는 것이 나왔는데, 이는 이벤트 루프로 관리되는 태스크 중 하나이다. 마이크로 태스크는 마이크로 태스크 큐에서 대기하며, 일반 태스크보다 우선순위가 높아서 먼저 콜스택에 추가된다.
setTimeout(() => {
console.log("timeout이 나중에");
}, 0);
new Promise(() => {
console.log("promise가 먼저");
});
// 출력:
// promise가 먼저
// timeout이 나중에
간단한 예제를 보면 setTimeout
이 먼저 호출되었음에도 Promise
내부가 먼저 실행되는 것을 확인할 수 있다.
(태스크와 마이크로태스크의 차이를 더 자세하게 알고 싶으면 JavaScript의 queueMicrotask()와 함께 마이크로태스크 사용하기를 참고)