이 글은 이벤트 루프와 마이크로태스크 작동 순서를 이해하고 보길 권한다. 필요하다면 이 포스팅을 참조하자.
옛날에는 비동기를 콜백 함수로 처리했다.
비동기 처리는 단순하지 않다.
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);
콜백 지옥의 문제점.
이런 문제를 해결하기 위해 Promise
가 도입됐다는 사실은 유명하다.
그러면 프로미스가 도입되기 전에는 다들 무조건 저런 괴로운 코드를 썼을까?
그렇진 않다. 코드가 피라미드 형태로 쌓이지 않도록 여러 노력을 했다.
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
}
콜백마다 단일 에러 처리를 붙이는 등의 노력을 했다.
코드는 작성자만 알면 안 된다. 다른 개발자 눈에도 흐름이 잘 들어와야 한다.
그래서 자세히 몰라도 되는 부분은 모듈화 시켜서 다른 데에 저장하고, 요점만 남기곤 했다.
이러한 방식은 클린 코드를 위해서라도 참고하면 좋다.
그러나 위의 노력도 근본적인 고뇌를 말끔히 해소시키진 못했는데, 어차피 코드량은 많아지고 신경 써야할 부분은 많았다.
프로미스는 선언 당시 확정 못한 값에 대한 중개인(proxy)으로서 성공, 실패에 대한 처리를 할 수 있습니다. 일반 메소드처럼 최종적인 값을 반환하진 않고 나중에 주겠다는 프로미스(약속)을 반환합니다.
위에서 failureCallback
을 계속 사용했던 코드를 then
으로 적용한 사례를 보자.
doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);
훨씬 나아졌다. 가독성도 좋아졌고, 에러 처리도 한 번만 해주면 된다.
MDN
에서 소개하는 프로미스의 이점.
A Promise object is created using the new keyword and its constructor. This constructor takes a function, called the "executor function", as its parameter.
프로미스 객체는 new 지정어와 생성자로 만듭니다. 생성자는 "실행자 함수"를 매개변수로 사용합니다.
기본적으론 new Promise()
문법으로 프로미스를 생성한다. 이 때 내부에 작성하는 게 실행자 함수다.
Promise(executor: (resolve: (value: any) => void, reject: (reason?: any) => void) => void): Promise
프로미스 초기화용 콜백 함수.
실행자 함수는 매개 변수를 두 개 가집니다
1. 결과값 or 다른 프로미스 객체를 return하는 resolve 메소드.
2. 실패 이유나 에러를 표시하는 reject 메소드.
새 프로미스를 생성합니다.
// 프로미스를 만드는 예시
const promise1 = new Promise((resolve, reject) => {
resolve('foo');
});
The Promise constructor is primarily used to wrap functions that do not already support promises.
프로미스 지원을 안 하는 함수를 감쌀 때 씁니다.
프로미스는 3가지 상태(state)와 2가지 운명(fate)를 가진다.
번외로 확정(settled)이라는 표현을 쓴다.
We say that a promise is settled if it is not pending, i.e. if it is either fulfilled or rejected. Being settled is not a state, just a linguistic convenience.
대기중(pending)만 아니면 확정입니다. 확정은 상태(state)를 뜻하는 건 아닙니다. 편의상 표현일 뿐입니다.
pending
프로미스를 초기화한다.then
에서 비동기 실행, 실패하면 catch
에서 에러 처리.pending
프로미스 객체를 초기화한다.An unresolved promise is always in the pending state. A resolved promise may be pending, fulfilled or rejected.
해결 안 된 프로미스는 '계속' 대기 상태입니다. 해결된 프로미스는 대기, 성공, 실패 중 하나입니다.
The promise object will become "resolved" when either of the functions resolutionFunc or rejectionFunc are invoked.
Note that if you call resolutionFunc or rejectionFunc and pass another Promise object as an argument, you can say that it is "resolved", but still cannot be said to be "settled".
프로미스는 성공용 함수, 실패용 함수를 호출하면 "해결(resolved)"됩니다.
성공용, 실패용 함수에 또다른 프로미스를 전달해도 "해결"됩니다. 하지만 '아직은' 확정됐다고 말할 수 없습니다.
let p0 = new Promise((resolve, reject) => resolve(101010));
let p1 = new Promise((resolve, reject) => resolve(p0));
let p0 = new Promise((resolve, reject) => {
resolve(123);
});
let p1 = p0.then((result) => {
return result + 1;
});
// pending
console.log(p1);
// fulfilled
setTimeout(() => {
console.log(p1);
}, 10);
예시 1, 예시 2에서 p1은 해결했어도 처음엔 pending
상태다.
예시 2의 실행 순서는 아래와 같다.
비동기 작업은 값이 언제 반환될지 정확하게 알 수 없다.
resolve
가 정상적으로 처리됐다 !== 곧바로 성공 상태로 전환했다.
성공 상태의 정의가 'then
을 바로 실행할 수 있는 상태'라는 점을 생각해보면
이라고 정리할 수 있다.
성공한 프로미스에 대한 설명. 프로미스가 대기중이면 성공에 대한 코드를 실행 후 프로미스 결과를 할당한다. 마지막으로 상태를 성공으로 전환한다.
성공으로 처리하는 함수가 꼭 resolve
만 있지는 않다.
new Promise
는 resolve
, reject
를 호출하면 해결되지만 then
은 웬만하면 해결된다.
let p0 = new Promise((resolve, reject) => {
resolve(123);
});
let p1 = p0
.then((result) => result + 100)
.then((result) => result - 23)
.then();
// pending
console.log(p1);
// fulfilled Promise { 200 }
setTimeout(() => {
console.log(p1);
}, 100);
let p0 = new Promise((resolve, reject) => {
resolve(123);
});
let p1 = new Promise((resolve, reject) => {
resolve(p0);
});
setTimeout(() => {
console.log("task queue");
}, 0);
p1.then((result) => {
console.log("then callback");
});
queueMicrotask(() => {
console.log("queueMicrotask");
});
console.log("call stack");
위 코드에서 p1은 이미 확정된 프로미스 p0을 할당받는다. p1도 바로 확정이 될 것 같고, p1.then의 콜백도 queueMicrotask
보다 먼저 실행될 것 같다.
하지만 미세한 차이로 queueMicrotask
가 먼저 실행이 되고, 그 다음에 p1이 받아지면 then
콜백이 실행하고 확정한다. 결과값으로 다른 프로미스를 넣으면 확정이 조금 늦어진다. 그 증거로 resolve(p0)를 resolve(1)로 바꾸면 실행 순서가 역전되는 모습을 볼 수 있다.
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);
});
console.log(p1);
setTimeout(() => {
console.log("Hi", p1);
}, 100);
setTimeout(() => {
console.log("Hello?", p1);
}, 3000);
위의 코드에서 p1은 setTimeout()
의 의도대로 404 프로미스를 return
하지 않고 undefined
로 해결된 프로미스를 return
한다.
then 콜백 함수는 실행을 했으면 프로미스를 return
한다. (then
콜백 내부에 중첩된 콜백 함수는 상관없다.) 마땅히 return
문이 없으면 undefined
를 결과로 가진 프로미스를 초기화하고 성공으로 전환한다.
애초에 then
은 '캐싱된 값을 순서대로, 빨리 이행하는' 컨셉이다.
fetch
든 axios.get
이든 new Promise
든 자원을 가져오는 시간은 걸릴 수밖에 없다.
하지만 then
은 가져온 자원으로 최대한 빨리 처리만 하면 되는 메소드다. then
에서 새 프로미스를 느긋하게 만들 이유는 없다. 의도대로 프로미스를 return
하고 싶으면 정상적으로 작성하자.
async / await
로도 프로미스를 다룰 수 있는데 특징은 다음과 같다.
then / catch
대신 try / catch
구문 사용개인적으로 async / await
의 가장 큰 장점은 프로미스 지옥의 해소 + 가독성 간결화라고 본다.
프로미스 지옥은 프로미스 안티 패턴 중 하나로서, 아래 안티 패턴 목차에서 소개하겠다.
async
는 Promise
를 생성하고 돌려주는 역할을 한다.
async function foo() {
return 1
}
function foo() {
return Promise.resolve(1)
}
둘은 매우 흡사하다. 둘다 성공한 프로미스(1)을 반환한다.
차이점이라면 동치는 아니다. 아래 코드가 그걸 보여준다.
const p = new Promise((res, rej) => {
res(1);
})
async function asyncReturn() {
return p;
}
function basicReturn() {
return Promise.resolve(p);
}
console.log(p === basicReturn()); // true
console.log(p === asyncReturn()); // false
이런 부분은 사실 잘 몰라도 된다.
async 함수 내부에선 여러 가지 일을 할 수 있지만 이러니저러니 해도 핵심은 해결된 프로미스 반환이다.
await
는 async
내부에서만 유효한 예약어(reserved word)로 async
함수가 아닌 곳에선 쓸 일이 없'었'다. 하지만 2019년 즈음부터 top-level await
개념이 도입됐다. 전역 await
는 자원 초기화, 동적 의존성 연결 등에 활용한다.
async function foo() {
await 1
}
function foo() {
return Promise.resolve(1).then(() => undefined)
}
async 내부에서 await를 쓰면 저렇게 처리된다.
조금만 더 보자.
let p0 = new Promise((resolve, reject) => {
resolve("promise");
});
async function createPromise() {
return "async";
}
async function runPromise() {
console.log("sync");
let p1 = await createPromise();
// 아래부턴 then의 콜백과 똑같이 microtask로 작동
console.log(p1);
}
queueMicrotask(() => {
console.log("microtask");
});
runPromise();
p0.then((result) => console.log(result));
PromiseResult
값이 할당된다. await
를 빼고 실행하면 프로미스 자체가 통째로 p1에 할당된다. result
값을 따로 저장해서 쓰기보단 then
으로 result
값을 전달받아서 처리한다.queueMicrotask(() => {
console.log("queueMicrotask");
});
async function callLog() {
console.log(2);
}
async function callLog2() {
console.log(3);
}
async function callLog3() {
console.log(4);
}
const func = async () => {
console.log(1);
await callLog();
// --- then 콜백처럼 적용 ---
await callLog2();
// --- then 콜백처럼 또 적용 ---
await callLog3();
};
console.log("stack");
func();
queueMicrotask(() => {
console.log("queueMicrotask2");
});
queueMicrotask(() => {
console.log("queueMicrotask");
});
async function callLog() {
console.log(2);
}
async function callLog2() {
console.log(3);
}
async function callLog3() {
console.log(4);
}
const func = async () => {
console.log(1);
await callLog();
// --- then 콜백 적용 ---
callLog2();
callLog3();
};
console.log("stack");
func();
queueMicrotask(() => {
console.log("queueMicrotask2");
});
await
를 여러 개 쓴다는 말은 프로미스로 치면 프로미스 체인을 많이 쓴다는 뜻이다.
위 코드 2개는 await callLog()를 통해 비동기 작업을 시작하고, 아래부터 then
으로 처리되도록 했다. callLog2, callLog3을 await
로 호출하느냐 마냐의 차이 뿐이다.
microtask queue
로 이동하길 반복해서 처리된다.queueMicrotask(() => {
console.log("queueMicrotask");
});
let p0 = new Promise((resolve, reject) => {
console.log("create new Promise");
resolve("promise");
});
p0.then((result) => {
console.log("chaining 1");
console.log("chaining 2");
});
queueMicrotask(() => {
console.log("queueMicrotask2");
});
queueMicrotask(() => {
console.log("queueMicrotask");
});
let p0 = new Promise((resolve, reject) => {
console.log("create new Promise");
resolve("promise");
});
p0.then((result) => {
console.log("chaining 1");
return result;
}).then((result) => {
console.log("chaining 2");
return result;
});
queueMicrotask(() => {
console.log("queueMicrotask2");
});
프로미스로 코드를 짠다면 위와 같은 상황이다.
console.log("call stack");
queueMicrotask(() => {
console.log("queueMicrotask");
});
let p0 = await fetch("https://velog.io/");
console.log("I'm in async!");
console.log(p0);
top-level await
를 쓰면 그 부분만 특별하게 비동기로 처리되는 건 아니고, 자원을 다 받아오면 async
처럼 아래가 전부 microtask
로 처리된다.
안티 패턴은 이름 그대로 '하지 말아야할 패턴'이다.
비동기는 언제, 어떤 게 반환될 지 대체로 기대할 뿐, 모든 게 확실하지는 않다.
데이터를 요청했는데 엉뚱한 데이터가 오거나, 깨진 데이터가 오거나, 뭔지 모를 게 오거나, 이상한 에러가 나거나...어떤 결과가 올 지는 누구도 모른다. 그렇기 때문에 비동기를 할 땐 코드를 잘 짜고, 에러 처리라던지, 마무리도 확실히 해야 한다.
const p0 = new Promise((resolve, reject) => null)
const p1 = Promise.resolve(17)
p0.then((result) => p1)
.then((value) => value + 1)
.then((value) => console.log(value))
일반적으로, resolve
나 reject
로 생성된 프로미스 값은 캐싱된다. then
은 그 캐시를 활용하는 메소드다. 위 코드는 언뜻 보면 잘 처리될 것처럼 보인다. 하지만 실행 자체가 안 된다.
then<TResult1 = T, TResult2 = never>(
onfulfilled?: ((value: T) =>
TResult1 | PromiseLike<TResult1>) | undefined | null,
onrejected?: ((reason: any) =>
TResult2 | PromiseLike<TResult2>) | undefined | null):
Promise<TResult1 | TResult2>;
catch
는 실패한 프로미스일 때 콜백 함수를 실행하고, then
은 확정된(성공, 실패가 완료된) 프로미스에 대해서만 콜백 함수를 실행한다.
미해결에 대한 처리는 아예 기술하지 않았다.
성공, 실패에 대한 리액션만 있다.
let p1 = new Promise((resolve, reject) => {
console.log("해결 안 된 프로미스");
return 123;
});
p1.then((result) => {
console.log(result);
})
.catch((err) => {
console.log(err);
})
.finally(() => {
console.log(123);
});
// 위 코드는 처음 생성 당시 콘솔로그만 찍고 어떤 것도 실행하지 않는다.
await
도 돌아가는 원리는 똑같기 때문에 같은 조건을 주면 같은 반응을 보인다.
const resolvedPromise = new Promise((resolve, reject) => resolve("good"));
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
함수는 '해결을 안 한 프로미스'를 return
했다. 이 코드를 바꾸지 않는 한 영원히 실행될 수 없다.
async function createPendingPromise() {
console.log(123);
}
async function logPromise() {
let p0 = await createPendingPromise();
console.log(p0);
}
logPromise(); // undefined
근데 너무 당연한 현상인 게, '데이터를 받아온 다음에 순서대로 처리하도록 짠 코드'인데 데이터가 안 받아진 상황에서 실행이 되면 프로미스 쓰는 의미가 하나도 없다. 그건 그냥 콜백이랑 다를 게 없다.
해결은 했지만 네트워크 처리가 오래 걸려서 미확정인 시간이 너무 길다던지, 코드를 잘못 짜서 프로미스가 미해결된 채로 있으면 이에 대한 처리를 해야 사용자 경험으로도 좋다. 이는 Promise.race
와 setTimeout
을 활용해서 타임아웃으로 처리하기도 한다.
// 타임아웃 처리 예시
let p0 = new Promise((resolve, reject) => {
console.log("p0 작업중...");
setTimeout(() => {
resolve(1);
}, 4000);
});
let p1 = new Promise((resolve, reject) => {
console.log("p1 작업중...");
setTimeout(() => {
resolve(2);
}, 3000);
});
let p2 = new Promise((resolve) => {
setTimeout(() => {
resolve("시간 초과");
}, 2000);
});
Promise.race([p0, p1, p2]).then((value) => {
if (value === "시간 초과") {
throw new Error("대기 시간이 너무 깁니다!");
}
});
then으로 에러 처리를 할 수는 있다. 하지만 권장하진 않는다.
then
에서는 두 개의 콜백을 작성할 수 있는데 첫 번째는 성공에 대한 코드, 두 번째 콜백은 실패에 대한 이유(reason)를 작성한다.
promise1
.then(
(value) => {
console.log(value);
},
(reason) => {
console.log(reason);
}
)
.catch((reason) => {
console.log(reason, 123123);
});
then
이 에러를 처리하고 catch
는 작동하지 않는다.
하지만 catch
로 에러 및 실패를 처리하는 게 좋은데, 크게 두 가지 이유가 있다.
Promise
에는 [[PromiseIsHandled]]
라는 필드가 있다.
성패 여부를 표시하는 boolean
인데 주로 처리가 안 된 실패를 추적할 때 쓴다고 한다.
이걸 이용해서 catch
가 에러 처리를 하지 않을까 싶다.
결론 : catch
로 에러 처리하자.
그럼 catch
만 쓰면 되는데 then
은 존재할 의의가 있을까?
있다. React
에선 catch
대신 then
으로 error
를 처리해야 할 때가 있다.
loadSomething()
.then(something => {
loadAnotherthing()
.then(another => {
DoSomethingOnThem(something, another);
});
});
콜백 지옥이 다가 아니라 프로미스 지옥도 있다.
위 코드의 경우 둘을 중첩시키지 말고 선형적으로 시행하는 게 낫다.
Promise.all
이 그걸 돕는다.
Promise.all([loadSomething(), loadAnotherThing()])
.then(values => {
DoSomethingOnThem(values...);
});
Promise
관련 메소드.
Promise.all
, Promise.allSettled
는 배열에 미해결된 프로미스가 하나라도 있으면 아무 실행도 안 한다. (race
와 any
는 성공, 확정된 프로미스 하나만 반환하면 되어서 상관없다.)
function promised() {
return new Promise(resolve => {
getOtherPromise().then(result => {
getAnotherPromise(result).then(result2 => {
resolve(result2);
});
});
});
}
위 코드는 프로미스를 시행할 때 다른 프로미스가 필요하기 때문에 중첩할 수밖에 없다.
이럴 때에는 async / await
가 유용하다.
async function promised() {
const result = await getOtherPromise();
const result2 = await getAnotherPromise(result);
return result2;
}
가독성이 훨씬 좋아졌다.