JS와 프로미스
비동기의 꽃. Promise와 async / await

옛날에는 비동기 작업을 콜백 함수로 처리했습니다.
A라는 처리 결과에 대한...
B라는 처리 결과에 대한...
C라는 처리 결과에 대한...
D라는 처리 결과에 대해
코드를 작성하면...
콜백 지옥이...
탄생한다...
doSomething(function(result) {
doSomethingElse(result, function(newResult) {
doThirdThing(newResult, function(finalResult) {
console.log('Got the final result: ' + finalResult);
// 에러용 failureCallback 콜백 함수를 여러 차례 붙이는 모습.
}, failureCallback);
}, failureCallback);
}, failureCallback);
콜백 지옥의 문제점.
- 가독성 bad
- 실행 순서 보장 X
자원을 받아오는 시간이 3초, 자원을 활용하는 콜백 함수를 실행하는 코드까지 도달하는데 1초 걸린다고 하면? - 종속성
콜백 함수들이 얽히고설키면 하나 고칠 때 여럿 고친다. - 에러 처리 난해
에러 처리를 꼬박꼬박 붙여야 한다.
그러면 모두가 저렇게 괴로운 코드를 썼을까요? 일단, 여러 노력이 있었습니다.
document.querySelector('form').onsubmit = formSubmit
function formSubmit (submitEvent) {
var name = document.querySelector('input').value
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, postResponse)
}
function postResponse (err, response, body) {
var statusMessage = document.querySelector('.status')
if (err) return statusMessage.value = err
statusMessage.value = body
}
module.exports.submit = formSubmit
function formSubmit (submitEvent) {
var name = document.querySelector('input').value
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, postResponse)
}
function postResponse (err, response, body) {
var statusMessage = document.querySelector('.status')
if (err) return statusMessage.value = err
statusMessage.value = body
}
var fs = require('fs')
fs.readFile('/Does/not/exist', handleFile)
function handleFile (error, file) {
if (error) return console.error('Uhoh, there was an error', error)
// otherwise, continue on and use `file` in your code
}
그러나 이 노력들도 근본적인 해결책은 아닙니다. 코드량은 많아지고 신경 써야할 부분은 많았습니다. 그래서 나온 해결책이 프로미스입니다.
Promise
프로미스는 선언 당시 확정 못한 값에 대한 중개인으로서 성공, 실패에 대한 처리를 합니다. 일반 함수처럼 최종값을 즉시 반환하지 않고 나중에 주겠다는 약속을 반환합니다.
위 콜백 지옥 코드를 프로미스로 바꿔보겠습니다.
// before
doSomething(function(result) {
doSomethingElse(result, function(newResult) {
doThirdThing(newResult, function(finalResult) {
console.log('Got the final result: ' + finalResult);
}, failureCallback);
}, failureCallback);
}, failureCallback);
// after
doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);
- then의 콜백 함수는 이벤트 루프가 콜 스택 작업을 마치기 전까지는 절대 호출되지 않습니다.
- then을 여러 번 써서 콜백 함수를 계속 추가할 수 있습니다. 각 콜백 함수는 순서대로 실행됩니다.
상태와 운명
프로미스는 3가지 상태(state)와 2가지 운명(fate)를 가집니다. 번외로 확정(settled)이라는 표현도 있습니다.
대기중(pending)만 아니면 확정입니다. 확정은 상태(state)를 뜻하진 않고 편의상의 표현입니다.
- 상태
fulfilled : 성공 (then 콜백을 당장 작업 큐에 집어넣을 수 있는 상태)
rejected : 실패 (catch 콜백을 당장 작업 큐에 집어넣을 수 있는 상태)
pending : 대기 (성공도 실패도 아닌 상태) - 운명
resolved : 해결 (성공, 실패를 다루는 함수를 사용함)
unresolved : 미해결 (해결을 안 함)

- 코드를 실행하고 pending 프로미스를 초기화한다.
- 프로미스가 확정(성공 or 실패)된다.
- 성공: 결과를 캐시하고(나중에 재사용하기 위함) then 콜백을 실행.
실패: catch에서 에러 처리. - 새로운 pending 프로미스 객체를 초기화한다.
- 추가 작업시 위의 알고리즘을 반복한다.
미해결과 미확정
해결 안 된 프로미스는 계속 대기 상태입니다. 해결된 프로미스는 대기, 성공, 실패 중 하나입니다.
프로미스는 성공용 함수, 실패용 함수를 호출하면 해결(resolved)했다고 말합니다. 성공용, 실패용 함수에 또다른 프로미스를 전달해도 해결됩니다. 하지만 '아직은' 확정됐다고 말할 수 없습니다.
let p0 = new Promise((resolve, reject) => {
resolve(123);
});
let p1 = p0.then((result) => {
return result + 1;
});
console.log(p1); // pending
setTimeout(() => {
console.log(p1); // fulfilled 124
}, 10);
비동기 작업은 응답이 언제 올 지는 예측할 수 없습니다.
의도대로 안 나오는 결과
let p0 = new Promise((resolve, reject) => {
resolve(123);
});
let p1 = p0.then((result) => {
setTimeout(() => {
console.log("this callback returns undefined, not 404");
return 404;
}, 1000);
});
setTimeout(() => {
console.log(p1); // undefined
}, 10);
then은 콜백 함수 실행 직후 바로 프로미스를 반환합니다. 일반 함수처럼, return 문이 없으면 undefined (프로미스)를 반환합니다. 이는 성공됐다고 간주합니다.
위 코드는 setTimeout 내부에 return을 작성했으므로 의도하는 404 대신 undefined가 return됩니다.
부실한 에러 처리
promise1
.then(
(value) => {
console.log(value);
},
(reason) => {
console.log(reason);
}
)
.catch((reason) => {
console.log(reason);
});
then에는 두 개의 콜백을 작성할 수 있는데 첫 번째는 성공에 대한 처리, 두 번째는 실패에 대한 이유(reason)를 작성합니다. 하지만 보통은 catch문으로 처리하길 권합니다. 가독성도 더 나을 뿐더러, then(reason)은 then(value)에서 발생하는 에러를 처리 못하지만 catch는 그것까지 처리하기 때문입니다.
프로미스 지옥
loadSomething()
.then(something => {
loadAnotherthing()
.then(another => {
DoSomethingOnThem(something, another);
});
});
이런 코드는 가독성이 나쁘기에 보통은 Promise.all로 해결합니다. Promise.all은 모든 프로미스가 성공하면 배열을 반환합니다.
Promise.all([loadSomething, loadAnotherThing])
.then(values => {
DoSomethingOnThem(values);
});
function promised() {
return new Promise(resolve => {
getOtherPromise().then(result => {
getAnotherPromise(result).then(result2 => {
resolve(result2);
});
});
});
}
하지만 연쇄적으로 프로미스를 처리해야 하는 경우는 프로미스 지옥을 해소할 수 없습니다. 이 경우 async / await를 사용합니다.
async / await
async function promised() {
const result = await getOtherPromise();
const result2 = await getAnotherPromise(result);
return result2;
}
- 간결한 가독성
- then / catch 대신 try / catch 사용
- 에러가 발생한 위치를 구체적으로 알려줌 (편한 디버깅)
- 프로미스 지옥 해소
async & await를 쓸 땐 아래 특징을 염두합니다.
- async가 반환하는 것은 프로미스.
- await로 응답을 받으면 이후 코드는 then의 콜백 함수처럼 마이크로태스크로 처리된다.
async 함수
async는 promise보다 짧고 쉽습니다.
async function foo() {
return 1
}
function foo() {
return Promise.resolve(1)
}
단, async와 Promise는 소소한 차이가 있습니다.
const p = new Promise((res, rej) => {
res(1);
})
const asyncReturn = async () => p;
const basicReturn = () => Promise.resolve(p);
console.log(p === basicReturn()); // true
console.log(p === asyncReturn()); // false
await
function wait() {
return new Promise((res) => {
setTimeout(() => {
res(10);
}, 10);
});
}
function foo() {
return Promise.resolve(wait()).then((result) => result);
}
async function foo() {
const result = await wait();
return result;
}
위 코드와 이벤트 루프를 생각해서 아래 코드의 출력 순서를 맞혀봅시다.
const fetchData = async (url) => {
console.log("데이터 가져오는 중...");
const data = await fetch(url);
return data;
};
const doSomething = async () => {
console.log("데이터를 가져옵니다.");
const url = "https://jsonplaceholder.typicode.com/posts/1";
const data = await fetchData(url);
console.log("데이터를 가져왔습니다.");
return data;
};
queueMicrotask(() => {
console.log("microtask 1");
});
doSomething();
queueMicrotask(() => {
console.log("microtask 2");
});
console.log("stack");
미해결된 프로미스, timeout
미해결 프로미스는 안티 패턴 중 하나입니다.
async function createUnresolvedPromise() {
return new Promise((resolve, reject) => null);
}
async function notWorking() {
let p0 = await createUnresolvedPromise();
// 실행 안 됨
p0 = resolvedPromise;
console.log(`this promise is ${p0}`);
}
notWorking();
createUnresolvedPromise 함수는 성공도, 실패도 아닌 프로미스를 만듭니다. 아무 반응이 없기 때문에 계속 대기합니다. 일부러 저렇게 작성할 일은 없지만, 아래처럼 비슷한 케이스를 경험할 수는 있습니다.
const timeout = (ms = 6000) => {
return new Promise((reject) => {
setTimeout(
() => reject(new Error(`데이터 요청 후 ${time / 1000}초가 지났습니다.`)),
ms
);
});
};
const data = Promise.race([
fetch("https://jsonplaceholder.typicode.com/posts"),
timeout(1),
])
.then(console.log)
.catch((error) => {
console.error("데이터를 가져올 수 없습니다.", error);
});
const timeout = (ms = 6000) => {
return new Promise((reject) => {
setTimeout(
() => reject(new Error(`데이터 요청 후 ${time / 1000}초가 지났습니다.`)),
ms
);
});
};
const fetchData = async () => {
try {
const data = await Promise.race([
fetch("https://jsonplaceholder.typicode.com/posts"),
timeout(1),
]);
return data;
} catch (error) {
console.error("데이터를 가져올 수 없습니다.", error);
}
};
const data = await fetchData();
console.log(data);
데이터를 가져오는 시간이 너무 오래 걸리거나 무응답일 경우를 대비해 timeout을 처리할 필요가 있습니다.
- Promise.all : 모든 프로미스가 '성공'하면 배열 반환. 하나라도 실패하면 에러.
- Promise.allSettled : 모든 프로미스가 '확정'되면 배열 반환.
- Promise.race : 가장 빨리 '확정'된 프로미스 반환.
- Promise.any : 가장 빨리 '성공'한 프로미스 반환.
마무리
async / await는 프로미스보다 나중에 나왔지만 무조건 어느 쪽이 더 좋다고 할 순 없기에 상황에 따라 적절히 쓰면 좋습니다.
6. 참조
- Callback Hell
- Promise - JavaScript | MDN
- Using promises - JavaScript | MDN
- Promise() constructor - JavaScript | MDN
- Promise.all() - JavaScript | MDN
- Promise.race() - JavaScript | MDN
- promises-unwrapping
- Finding unresolved promises in JavaScript
- Promise | PoiemaWeb
- The JavaScript Promise Anti-patterns
- 자바스크립트의 Async/Await 가 Promises를 사라지게 만들 수 있는 6가지 이유
- Top-level await
- ECMA-262, 12th edition