프로그래밍에서 시간이 걸리는 함수를 기다리지 않고 다음 함수를 실행하는 것을 비동기 프로그래밍이라고 합니다. Javascript 영역에서는 Promise를 통해 시간이 오래걸리는 함수를 기다리지 않는 것처럼 프로그래밍 할 수 있습니다. 이를 통해 개발자들은 더 나은 가독성과 생산성을 챙길 수 있게 되었습니다.
JavaScript 개발자라면 반드시 알아야하는 Promise에 대한 기본적인 개념을 정리해보았습니다.
설명은 MDN 공식문서에 나온 것을 조금 정리하였습니다.
Promise는 아직 결정되지 않은 값을 위한 클래스로 비동기 연산이 종료된 이후에 결과 값을 반환하고 에러를 처리할 수 있는 함수를 넘길 수 있습니다. 프로미스를 사용하면 비동기 메서드에서 마치 동기 메서드처럼 값을 반환합니다.
Promise 클래스는 이행 또는 거부될 프로미스를 만들어주는 정적 메서드를 제공하고 있습니다.
Promise.reject(reason)
주어진 사유로 거부하는 Promise 객체를 반환합니다.
Promise.resolve()
주어진 값으로 이행하는 Promise 객체를 반환합니다. 주어진 값이 Promise일 경우 이를 타고 모든 Promise를 이행한 값을 최종적으로 반환합니다.
앞서 말했듯 프로미스는 마치 동기 메서드처럼 값을 반환하는데 이것을 그냥 달성할 수는 없고 then 메서드를 사용해서 값을 가져오게 됩니다. 또한 에러를 처리할 수 있는 함수는 catch를 통해 넘겨줄 수 있습니다.
이를 프로미스 객체를 만들어주는 정적함수인 resolve, reject를 활용한 예시를 통해 보겠습니다.
const promise = Promise.resolve(4);
promise.then(value => {
console.log(value) // 4
});
const promise = Promise.reject('error');
promise.catch(error => {
console.log(error) // 'error'
});
Promise 클래스는 비동기 작업 동시성을 용이하게 하기 위해 네 가지 정적 메서드를 제공합니다.
간단한 예시와 함께 설명하도록 하겠습니다.
const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'foo');
});
Promise.all([promise1, promise2, promise3]).then((values) => {
console.log(values); // Array [3, 42, "foo"]
});
const promise1 = Promise.resolve(3);
const promise2 = new Promise((resolve, reject) =>
setTimeout(reject, 100, 'foo'),
);
const promises = [promise1, promise2];
Promise.allSettled(promises).then((results) =>
results.forEach((result) => console.log(result.status)),
);
// Expected output:
// "fulfilled"
// "rejected"
const promise1 = Promise.reject(0);
const promise2 = new Promise((resolve) => setTimeout(resolve, 100, 'quick'));
const promise3 = new Promise((resolve) => setTimeout(resolve, 500, 'slow'));
const promises = [promise1, promise2, promise3];
Promise.any(promises).then((value) => console.log(value));
// Expected output: "quick"
const promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 500, 'one');
});
const promise2 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'two');
});
Promise.race([promise1, promise2]).then((value) => {
console.log(value);
// Both resolve, but promise2 is faster
});
// Expected output: "two"
이 모든 정적 메서드들은 프로미스의 then 메서드를 해결하고 새로운 프로미스를 반환합니다.
JavaScript는 본질적으로 싱글스레드이므로 특정 순간에는 하나의 작업만 실행되지만, 제어권이 다른 프로미스 간에 이동하여 프로미스가 동시에 실행되는 것처럼 보이게 할 수 있습니다. JavaScript에서 병렬 실행은 worker threads를 통해서만 가능합니다.
async키워드를 사용하면 비동기 Promise 기반 코드로 작업할 때 더 유용합니다. async함수 시작 부분에 추가하면 비동기 함수가 됩니다.
비동기 함수 내에서 await으로 promise를 반환하는 함수를 호출하기 전에 키워드를 사용할 수 있습니다. 이렇게 하면 코드는 약속이 확정될 때까지 그 시점에서 기다리게 되며, 이 시점에서 약속의 이행된 값은 반환 값으로 처리되거나 거부된 값이 던져집니다.
아래와 같이 특정 시간 이후 랜덤수를 반환하는 wait 함수가 있다고 하면
export function wait(ms: number) {
return new Promise<string>((resolve) => {
const random = Math.random();
setTimeout(() => resolve(random.toString()), ms);
});
}
await을 통해 다음과 같이 동기처럼 보이는 코드를 작성할 수 있습니다.
import { wait } from "./util";
async function asyncAwait() {
const a: string = await wait(1000);
console.log(a);
const b: string = await wait(1000);
console.log(b);
const c: string = await wait(1000);
console.log(c);
return "async await ended!";
}
asyncAwait().then((res) => console.log(res));
/**
output:
0.1234...
0.4234...
0.0698...
async await ended
**/
이제까지는 promise에 대해 기본적인 설명과 async & await 키워드를 간략하게 알아보았습니다. 사실 여태까지는 제가 하고 싶은 것을 하기 위한 보조 작업일 뿐이었습니다. async와 await는 어떤식으로 구성되어 있는지 궁금하지 않으신가요?(안 궁금하실 수도 있다고 생각합니다...!) 대략 유추해보건데 generator를 사용하여 특정 시점에 멈춰있는 형태로 구성되어 있을 것입니다. 아래와 같이 말이죠.
import { resolver, wait } from "./util";
function* generator() {
const a: string = yield wait(1000);
console.log(a);
const b: string = yield wait(2000);
console.log(b);
const c: string = yield wait(1000);
console.log(c);
return "generator ended!";
}
resolver(generator(), (res) => console.log(res));
엄청 유사한 모양의 코드가 나왔습니다. 차이점은 제너레이터와 이행 함수를 인자로 넘겨주는 resolver라는 또 다른 함수를 사용한다는 것입니다. 이 resolver의 구현은 다음과 같습니다.
type Gen<T> = Generator<Promise<T>, T>;
export function resolver<T>(generator: Gen<T>, callback?: (value: T) => void) {
return onFulfilled(generator, undefined, callback);
}
function onFulfilled<T>(gen: Gen<T>, res?: T, callback?: (value: T) => void) {
const next = gen.next(res);
return handler(gen, next, callback);
}
function onRejected<T>(gen: Gen<T>, err: unknown) {
const next = gen.throw(err);
return handler(gen, next);
}
function handler<T>(
iter: Gen<T>,
next: IteratorResult<Promise<T>, T>,
callback?: (value: T) => void
) {
const value = Promise.resolve(next.value);
if (next.done) {
value.then((res) => callback?.(res));
return value;
}
value.then(
(res) => onFulfilled(iter, res, callback),
(err) => onRejected(iter, err)
);
}
동작은 코드를 보면 제너레이터의 done 값이 true가 될 때까지 재귀적으로 promise를 해결해 나가는 구조로 되어 있습니다. 위에서 설명했던 promise 해결의 과정을 내부적으로 제너레이터를 사용하여 구현하고 있는 모습입니다.
더 궁금하신 사항이 있으시다면 예시코드를 직접 받으셔서 여러가지 고쳐보셔도 좋을 것 같습니다.
Promise와 Async & Await에 대하여 MDN의 공식 문서를 간략하게 정리해보면서 알아보았습니다. 저의 욕심으로 짤막하게 제너레이터도 끼워넣었는데 직접 구현하면서 비교해보니 해당 키워드들이 얼마나 개발자의 생산성을 올려주었는지 체감하게 되었습니다. 정리하면서 생각보다 Promise의 정적 메서드들을 활용하지 않는 경우가 많은 것 같은데 동시성을 위해서 정적 메서드들을 본격적으로 활용해보는 것이 좋을 것 같습니다.
그리고 혹시 이 글을 읽으시면서 설명 없이 넘어간 주요한 몇개의 키워들이 있다는 것을 눈치채셨나요? 제대로 설명하기엔 너무 큰 주제라 별도의 글로 정리하려고합니다.
이벤트 루프(우당탕탕 4주차 주제)
사실 간단하게 넘어갔지만 비동기 함수(async function)의 동작을 정확히 이해하기 위해서는 이벤트 루프에 대한 개념이 필수입니다.
제너레이터
흔하게 쓰이는 개념은 아닙니다만 라이브러리 개발이나 알고리즘 등에 충분히 활용될 수 있는 주제입니다.
Worker threads
웹 브라우져 상에서 싱글 스레드가 아닌 멀티 스레드로 동작할 수 있는 기능입니다.
다음 글도 기대해주시기를 바라면서 이만 글 줄이도록 하겠습니다.