[번역] async/await 를 사용하기 전에 promise를 이해하기
(이 글은 원작자의 허가를 받고 번역하였습니다)
이제는 바벨이 async/await를 지원해서 곧바로 쓸 수도 있고 ES7도 이제 거의 다 왔기 때문에
비동기적인 코드를 쓸때나 동기적인 코드 구조를 사용할 때 이 패턴이 얼마나 좋은 패턴인지 점점 더 많은 사람들이 깨닫고 있습니다.
이건 좋은 현상입니다. 전반적인 코드 품질을 많이 향상시킬거에요.
하지만 많은 분들이 놓치는게 있습니다. async/await의 기반이 promise라는 사실입니다.
사실, 우리가 쓰는 모든 async 함수는 promise를 리턴하고, 모든 await 함수는 일반적으로 promise가 됩니다.
제가 이걸 왜 강조하는걸까요? 왜냐하면 오늘날 쓰여지는 거의 모든 javascript 코드가 callback 패턴을 사용하기 때문입니다.
즉, 많은 분들이 promise 를 안쓰신다는거죠. 그리고 그분들은 async/await 의 중요한 점을 놓치고 있습니다.
promise가 대체 뭘까요?
(다른 곳에서 자세하게 다루기 때문에 짧게 설명하겠습니다.)
promise는 오브젝트 안에 오브젝트를 포함하는 javascript 오브젝트의 특별한 형태입니다. 정수 17에 대한 promise가 있을 수 있고, “hello world” 라는 문자열에 대한 promise도 있을 수 있고 등등. 일반적으로 javascript 변수가 될 수 있는 값은 다 가능합니다.
그러면 어떻게 promise에 접근할 수 있을까요? `.then()`을 씁니다.
function getFirstUser() {
return getUsers().then(function(users) {
return users[0].name;
});
}
그럼 promise 체인에서 error를 어떻게 처리할 수 있을까요? `.catch()`를 사용합니다.
function getFirstUser() {
return getUsers().then(function(users) {
return users[0].name;
}).catch(function(err) {
return {
name: 'default user'
};
});
}
promise(약속)이 ‘미래’ 시점의 데이터를 위한 것이긴 하지만, 내가 무언가의 promise를 갖고 있기만 하면, 그 데이터가 미래 시점에 있을지 이미 받았는지는 상관이 없습니다. 어떤 경우에도 `then()`을 부르기만 하면 되는 것이죠.
promise는 일관된 비동기를 강제합니다. 이렇게 말하는거죠. ‘이건 비동기 함수가 될거야. 리턴값이 지금 사용 가능하든지, 아니든지 말이야.’
그렇군요… 그럼 async/await 는 어떻게 묶여있는거죠?
자, 위에 있는 코드를 봅시다. `getUsers()` 는 promise를 리턴합니다. ES2016의 promise 이기만 하면 우리는 기다릴(await) 수 있어요. 진짜 말 그대로 promise에서 `.then()`을 쓰는거랑 똑같은거에요. (콜백 함수를 요구하지 않는다는점은 다르지만요)
그래서 위에 코드는 이렇게 바뀔 수 있습니다.
async function getFirstUser() {
let users = await getUsers();
return users[0].name;
}
우리는 무엇이든 기다릴(await) 수 있습니다. 그게 결정되었든(resolved) 안되었든, 그게 생성되었든 아니든 말입니다.
await은 내 메소드의 실행을 일시중지시킵니다. promise의 값이 사용가능할 때까지요.
음.. 그러면 error 처리는 어떻게 할까요?
쉽습니다. 우리는 이제 동기식 스타일 코드를 쓸 수 있습니다. try/catch를 쓰던 때로 돌아갈 수 있어요.
async function getFirstUser() {
try {
let users = await getUsers();
return users[0].name;
} catch (err) {
return {
name: 'default user'
};
}
}
자, 이제 promise로 구현하는 법과 async/await로 구현하는 법이 있다는 걸 알았습니다. 그럼 왜 promise를 알아야 하는걸까요?
1. 기다리지(await) 않는 상황
만약 그냥 호출한다면,
let users = getFirstUser();
기다리지(await) 않았지만, 자동으로 error를 뿜지 않습니다!
사실, await를 써야하는 의무는 없어요. 단지 쓰지 않는다면, user는 resolved 값이 아니라 promise 객체를 가리킬거에요. 그리고 많은 것들을 할 수 없게 되겠죠.
javascript는 엄격한 타입선언을 하지 않기 때문에, user 변수로 무언가를 할때까지 드러나지 않을거고 아마 내가 원하는 곳에서 null 값을 줄거에요.
비동기 함수가 저절로 wait 하지 않는다는 사실을 잊지 마세요.
당신이 반드시 await 해야합니다. 하지 않는다면 예상한 값 대신에 promise 객체를 받게 될거에요.
물론 promise 객체를 받아오도록 의도한거라면 괜찮아요. 그러면 promise 객체로 더 많은 것을 컨트롤 할 수 있습니다. 예를 들면 memoizing promises 같은 것들이요.
2. 다수의 값을 await 하는 상황
await 의 장애물이 하나 있습니다.
평범하게 사용할 때, 한번에 한개만 기다릴(await) 수 있다는 거에요.
let foo = await getFoo();
let bar = await getBar();
두개를 동시에 받아올 수 있어야 하는데도 이 코드로는 foo와 bar를 순차적으로 가져온다는 거죠.
그걸 해결하는 하나의 대안이 제시되었었는데, ES2016에서 채택되지는 못했습니다.
let [foo, bar] = await* [getFoo(), getBar()];
이렇게 문제 해결을 위한 syntax를 갖고 있다는게 좋다고 생각하는데 안타깝습니다. 그대신 이렇게 가능합니다.
let [foo, bar] = await Promise.all([getFoor(), getBar()]);
좀 헷갈립니다. 우리가 async/await를 쓰고있었지 promise를 쓰는게 아녔잖아요?! 그런데도 이게 가능한 이유는 async/await 과 promise가 같은 맥락이기 때문입니다.
Promise.all이 뭘 뜻하는지 이해하면 좀더 이해하기 쉽습니다. 그러니까 promise의 기초로 돌아가봅시다.
기본적으로 Promise.all은 promise 들의 배열을 받습니다. 그리고 그걸 다 합쳐서 하나의 promise로 만듭니다.
그 하나의 promise는 배열 안에 있는 모든 구성원 promise 들이 resolved(결정)될 때 비로소 resolve 합니다.
우리는 위 예시에서 Promise.all로 생성한 ‘슈퍼 promise’ 를 기다렸(await)습니다. 이것은 async/await를 사용하기 전에 Promise.all 의 의미를 이해하는데 도움이 될겁니다.
여기서 흔한 오해 하나를 바로잡고 싶습니다.
Promise.all은 넘겨준 promise를 전달(dispatch)하거나 생성(create)하지는 않습니다.
배열을 만들 때
[getFoo(), getBar()]
이러한 동작이 이미 실행되고 있습니다. Promise.all이 하는 일은 그들을 그룹화해서 하나의 새로운 promise로 만들고 모두 종료될 때까지 기다리는 것입니다. Promise.all 은 “이것들을 다 해줘”가 아니라, “이것들을 기다려줘” 입니다. 이것은 async.parallel 과는 다릅니다. (async.parallel은 전달한 메소드를 call합니다)
흥미롭게도, async/await를 병렬적으로 할 수 있는 재밌는 방법이 있습니다. (제가 권장하는 방법은 아닙니다)
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;
이 코드에서 무슨 일이 벌어지고 있는지를 알기 위해서는 promise에 대한 이해가 필요합니다.
- 먼저 우리는 getFoo 와 getBar를 전달(dispatch)했습니다. 그리고 그들이 리턴한 fooPromise와 barPromise를 저장했습니다.
- 이 작업은 진행중입니다. 그것들을 막거나 딜레이시킬 수 없습니다.
- 각 promise들을 순서대로 기다립니다(await)
await를 하지 않으면 async 액션들이 순차적으로 일어날까요? 그렇지 않습니다!
이 경우를 보면 우리는 await를 하기 전에 async 액션을 둘다 dispatch시켰습니다. 그래서 그것들이 동시에 실행이 됐죠.
우리가 await를 하려고 할 시점에는 그들을 지연시키기엔 너무 늦은거죠.
이렇게는 하지 마세요. 이건 가독성이 좋지 않습니다. 하지만 이것 또한 async/await 판에서 promise를 어떻게 사용할 수 있을까에 대한 하나의 예라고 말할 수 있겠죠.
3. 스택 전체가 비동기화 되어야 하는 상황
만약 어딘가에 await를 쓰기 시작했다면, 전체 스택에 영향을 미치는 문제가 생깁니다.
하나의 async 함수를 사용하기 위해서는, 이상적으로, caller는 자기 자신이 async 함수여야 합니다.
이것은 내 스택 전체에 연쇄효과가 있습니다. 그리고 그 효과 때문에 callback에서 async/await로 점진적으로 바꾸기가 힘듭니다.
[참고: 만약 stack에서 이미 promise를 사용하고 있다면 꼭 그렇지만은 않습니다. 왜냐하면 async 함수는 promise를 리턴하고 await는 promise를 기다립니다. 그래서 90%의 호환성을 이미 가지고 있는 셈입니다 ]
만약 promise가 어떻게 작동하는지 이해한다면, 콜백을 받는 async 함수의 결과를 promise로 취급하여 해결할 수 있을 것입니다.
function getFirstUser(callback) {
return getUsers().then(function(users) {
return callback(null, users[0].name);
}).catch(function(err) {
return callback(err);
});
}
자, 엄청 큰 보일러플레이트 없이도 async 함수인 getUsers를 callback 응답으로 변환시켰습니다. 사실, 많은 promise 라이브러리들이 nodeify() 라는 이름의 함수로 이런걸 지원합니다. 다음 처럼요.
function getFirstUser(callback) {
return getUsers().then(function(users) {
return users[0].name;
}).nodeify(callback);
}
그럼 반대 경우는 어떨까요?
비동기함수(async)가 있고, 그 안에 있는 콜백함수를 불러야할 때는 어떻게 할까요?
다시 한번 강조하지만, 이건 promise를 이해하기 위한 과정입니다. 왜냐하면 그게 ‘callback 함수’를 ‘promise를 return하는 함수’로 만드는 유일한 방법이기 때문입니다. ES6에서는 callback을 promise로 바꾸는게 엄청 쉽습니다.
function callbackToPromise(method, ...args) {
return new Promise(function(resolve, reject) {
return method(...args, function(err, result) {
return err ? reject(err) : resolve(result);
});
});
}
다음과 같이 바꿀 수 있습니다.
async function getFirstUser() {
let users = await callbackToPromise(getUsers);
return users[0].name;
}
4. 잊으면 안 되는 에러 핸들링
promise의 오래된 문제이면서 async/await의 문제이기도 합니다.
에러 핸들링을 잊지 않고 해줘야 합니다. 그렇지 않으면 다른 부분에서 로직이 끊길 수 있습니다. (원문: 길을 잃을 수 있습니다)
다음 코드를 살펴봅시다.
myApp.endpoint('GET', '/api/firstUser', async function(req, res) {
let firstUser = await getFirstUser();
res.json(firstUser)
});
`myApp.endpoint` 가 promise/async가 아니라면, 그리고 내가 보낸 핸들러 함수에서 돌아온 것에 await, .catch()를 걸지 않는다면,
에러는 처리되지 않을 것입니다. (에러는 길을 잃을 것입니다.)
만약에 똑같은 코드를 promise로 쓴다면, 왜 그런지 바로 알게 될겁니다.
myApp.endpoint('GET', '/api/firstUser', function(req, res) {
return getFirstUser().then(function(firstUser) {
res.json(firstUser)
});
});
이 코드를 보면 우리는 getFirstUser 에 대한 성공 케이스를 처리하기 위해서 callback을 전달해줬습니다. 그렇지만 error 를 처리하기 위한 어떤 것도 전달해주지 않았습니다. 그리고 getFirstUser 가 비동기함수이기 때문에 이것이 error를 발생(throw)시켜도 myApp.endpoint 에서 자동으로 잡지(catch) 못합니다.
그래서 다음과 같이 에러를 처리할 수 있도록 코드 상위 레벨에서 try/catch로 묶어야 합니다.
myApp.registerEndpoint('GET', '/api/firstUser', async function(req, res) {
try {
let firstUser = await getFirstUser();
res.json(firstUser)
} catch (err) {
console.error(err);
res.status(500);
}
});
조만간 더 많은 프레임워크들이 async/await 를 알게 되어서 이런 걱정을 덜어줬으면 좋겠습니다.
이 글을 통해 얻게 되는게 뭘까요?
“promise를 이해하지 못하면 async/await를 사용하면서 진짜 진짜 이해하기 어려운 케이스와 버그를 만나게 된다”
그리고 당신이 바벨(babel)을 쓰는 것에 관심이 없더라도, 이젠 promise로 바꿔서 ES2016의 async/await 장점을 그대로 살린 코드를 사용할 수 있습니다!
paypal 개발자 Daniel Brain 으로부터 동의를 얻어 번역하였습니다.
원문은 아래에서 확인할 수 있습니다.
https://medium.com/@bluepnume/learn-about-promises-before-you-start-using-async-await-eb148164a9c8