
해당 포스트는 멀티패러다임 프로그래밍 (유인동 저)을 읽고 개인적으로 정리한 글입니다.
값으로 다루는 비동기
Promise는 비동기 작업의 결과를 값으로 다룰 수 있게 하는 객체이자 규약이다. Promise를 반복자 패턴과 조합하면 매우 강력한 비동기 프로그래밍 모델을 만들 수 있다. 리스트 프로세싱과 함께 사용해 비동기 로직을 제어하는 방법을 알아보자.
Promise
Promise는 비동기 작업의 완료 여부와 상관없이 즉시 객체를 생성하여 값으로 다룰 수 있고, 결과가 필요한 시점에 꺼내보거나 에러를 처리할 수 있다.
대기(pending), 이행(fulfilled), 실패(reject) 3가지 상태를 가지며, 여러 Promise를 조합하여 순차적으로 실행하거나 동시에 실행할 수 있다.
Promise를 반환하는 delay 함수
function delay<T>(time: number, value: T): Promise<T> {
return new Promise((resolve) => setTimeout(resolve, time, value))
}
time만큼 대기한 후 value로 받은 값을 반환하는 함수이다.
new Promise()를 직접 사용해본 적 있는가
최근에는 Web API와 대부분의 라이브러리들이 이미 Promise 기반 인터페이스를 제공하기 때문에 new Promise()
를 직접 사용하는 일이 드물다.
하지만 기존에 제공되지 않는 고유한 방식의 병렬 실행 제어나 복잡한 비동기 로직을 구현해야 할 때는 new Promise()
를 통해 직접 생성하고 제어하는 로직이 필요하다.
new Promise()
를 능숙하게 다룬다는 것은 비동기 제어에 관한 깊이 있는 이해와 문제 해결 능력을 갖추고 있다는 의미다.
Promise.race
Promise.race
는 병렬로 실행된 여러 Promise 중 가장 먼저 완료된 Promise의 결과나 에러를 반환한다.
const promise1 = new Promise((resolve) => {
setTimeout(() => resolve('Promise 1 완료!'), 2000);
});
const promise2 = new Promise((resolve) => {
setTimeout(() => resolve('Promise 2 완료!'), 1000);
});
Promise.race([promise1, promise2])
.then((result) => {
console.log(result); // 'Promise 2 완료!'
});
Promise.race
를 실무에서는 어떤 상황에 사용할 수 있을까?
IO 작업에 타임아웃 설정하기
API요청시 응답이 지연되었을 때 에러 처리를 하고자 한다면 Promise.race
를 활용하면 좋다.
const result = await Promise.race([
fetch('/friends'),
delay(5000).then(() => { throw new Error('타임아웃!') })
]);
AbortController와 fetch를 함께 사용할 수도 있지만, Promise.race
를 활용한 방법은 fetch 이외의 다양한 상황에서도 비동기 작업을 효과적으로 처리할 수 있는 범용성을 제공한다.
응답 속도에 따라 다른 전략으로 UI 렌더링하기
채팅방에 '친구 초대하기' 버튼이 있고, 버튼을 누를 때 친구 목록을 가져오는 화면에서 응답 시간에 따라 다른 UI 전략을 사용할 수 있다. 친구 목록 API 응답이 100ms 내로 완료되면 즉시 렌더링하고, 그보다 오래 걸리면 로딩 표시를 띄우는 방식이다. Promise.race
를 활용하면 쉽게 구현할 수 있다.
Promise를 활용한 UI 렌더링 퀴즈
Promise.all
Promise.all
은 주어진 모든 Promise가 이행될 때까지 기다렸다가 모든 결과를 배열로 반환한다. 하나라도 거부되면 즉시 거부되고 그 이유를 반환한다. 여러 비동기 작업을 병렬로 실행하고 모든 작업을 기다릴 때 유용하다.
function getFile(name: string, size = 1000): Promise<File> {
return delay(size, { name, body: '...', size });
}
const files = await Promise.all([
getFile('img.png', 500),
getFile('book.pdf', 1000),
getFile('index.html', 1500)
]);
console.log(files);
// After about 1,500ms:
// [
// { name: 'img.png', body: '...', size: 500 },
// { name: 'book.pdf', body: '...', size: 1000 },
// { name: 'index.html', body: '...', size: 1500 }
// ]
병렬로 실행되기 때문에 가장 오래 걸리는 Promise인 1500ms 후에 결과가 완성된다.
만약 중간에 reject되는 Promise가 있다면 바로 종료된다. 따라서 아래 코드는 500ms 후에 catch 볼록 코드가 실행된다.
try {
const files = await Promise.all([
getFile('img.png'), // 기본 size: 1000, delay: 1000ms
getFile('book.pdf'),
getFile('index.html'),
delay(500, Promise.reject('File download failed'))
]);
console.log(files);
} catch (error) {
// After about 500ms:
console.error(error); // 'File download failed'
}
Promise.allSettled
Promise.allSettled
는 이행, 거부 여부와 상관없이 모든 Promise가 완료되기를 기다린 후 결과를 객체로 반환한다. ES11부터 등장한 함수로, 이전에는 다음과 같은 헬퍼 함수를 만들어 사용했다.
const settlePromise = <T>(promise: Promise<T>) =>
promise
.then(value => ({ status: 'fulfilled', value }))
.catch(reason => ({ status: 'rejected', reason }));
const files = await Promise.all([
getFile('img.png'),
getFile('book.pdf'),
getFile('index.html'),
Promise.reject('File download failed')
].map(settlePromise));
Promise.any
Promise.any
는 여러 Promise중 가장 먼저 이행(fulfiled)된 Promise의 값을 반환한다. 거부(reject)된 값이 있어도 가장 먼저 이행된 Promise의 결괏값을 반환한다.
const files = await Promise.any([
getFile('img.png', 1500),
getFile('book.pdf', 700),
getFile('index.html', 900),
delay(100, Promise.reject('File download failed'))
]);
console.log(files);
// After about 700ms
// { name: 'book.pdf', body: '...', size: 700 }
지연성으로 다루는 비동기
이번에는 비동기 상황을 다루는 재사용 가능한 함수를 만들며 Promise를 값으로 다루는 사고를 확장해보자.
Promise 실행을 지연하려면
Promise.all
은 모든 Promise를 병렬로 실행 후 모든 결과를 배열로 반환한다. 이런 상황에서 Promise마다 부하를 조절하고 싶으면 어떻게 해야할까? 예를 들어, 6개의 Promise를 전부 실행 시키는 것이 아니라 3개씩 2번으로 나눠서 실행시키고 싶다.
이러한 비동기 작업을 나누어 실행하는 함수를 만들어보자.
async function executeWithLimit<T>(
promises: Promise<T>[],
limit: number
): Promise<T[]> {
const result1 = await Promise.all([promises[0], promises[1], promises[2]]);
const result2 = await Promise.all([promises[3], promises[4], promises[5]]);
return [
...result1,
...result2
];
}
console.time("executeWithLimit");
await executeWithLimit(
[
delay(1000, "test"),
delay(500, "test"),
delay(300, "test"),
delay(1000, "test"),
delay(500, "test"),
delay(200, "test"),
],
3,
);
console.timeEnd("executeWithLimit"); // executeWithLimit: 1001.912841796875 ms
3개씩 나누어 각각 최대 1000ms씩 걸려 약 2000ms가 소요될 것으로 예상했지만, 실제로는 1000ms만 걸린다. 이는 Promise 객체가 생성 시 즉시 실행되기 때문이다.
Promise의 즉시 실행
Promise 객체는 생성시 즉시 실행된다. 즉, delay
함수가 호출되는 순간 이미 Promise가 시작된다. 따라서 3개씩 그룹화 하여 await
으로 대기하더라도 Promise는 모두 동시에 시작되는 것이다.
병렬 실행의 의미
Promise.all
은 이미 실행된 모든 Promise를 받아 모두 완료될 때까지 기다린 후 각 Promise를 풀어 배열로 반환하는 함수일 뿐, Promise의 시작 자체를 제어하는 함수는 아니다. 두 번의 Promise.all
호출이 있어도 각 Promise는 이미 시작된 상태기 때문에 전체 실행 시간에는 영향을 미치지 않는다.
결국 3개씩 그룹화하여 병렬 실행하는 것처럼 보여도 실제로는 모두 동시에 시작되는 것이다.
해결 방법
해결 방법은 간단하다. 각 Promise를 함수로 한 번 감싸주면 된다.
async function executeWithLimit<T>(
fs: (() => Promise<T>)[],
limit: number,
): Promise<T[]> {
const result1 = await Promise.all([fs[0](), fs[1](), fs[2]()]);
const result2 = await Promise.all([fs[3](), fs[4](), fs[5]()]);
return [...result1, ...result2];
}
(async () => {
console.time("executeWithLimit");
await executeWithLimit(
[
() => delay(1000, "test"),
() => delay(500, "test"),
() => delay(300, "test"),
() => delay(1000, "test"),
() => delay(500, "test"),
() => delay(200, "test"),
],
3,
);
console.timeEnd("executeWithLimit"); // executeWithLimit: 2003.93701171875 ms
})();
이렇게 하면 Promise를 즉시 실행하지 않고 필요할 때 실행되도록 지연시킬 수 있다.
Claude (Sonnet 4)가 명령형으로 구현한 동시성 핸들링 함수
Claude에게 executeWithLimit
함수의 구현을 부탁했다.
async function executeWithLimit<T>(
promiseFactories: (() => Promise<T>)[],
limit: number,
): Promise<T[]> {
const results: T[] = [];
// promiseFactories 배열을 limit 개씩 청크로 나누기
for (let i = 0; i < promiseFactories.length; i += limit) {
const chunk = promiseFactories.slice(i, i + limit);
// 현재 청크의 모든 promise factory들을 실행해서 promise 생성
const promises = chunk.map((factory) => factory());
// 생성된 promise들을 병렬로 실행
const chunkResults = await Promise.all(promises);
// 결과를 전체 결과 배열에 추가
results.push(...chunkResults);
}
return results;
}
함수형으로 구현한 동시성 핸들링 함수
executeWithLimit
를 리스트 프로세싱 관점으로 구현해보자.
chunk(size, iterable) 함수
우선 이터러블을 주어진 size만큼 그룹화하는 리스트 프로세싱함수가 있어야 한다.
function* chunk<T>(size: number, iterable: Iterable<T>): IterableIterator<T[]> {
const iterator = iterable[Symbol.iterator]();
while (true) {
const arr = [
...take(size, {
[Symbol.iterator]() {
return iterator;
},
}),
];
if (arr.length) yield arr;
if (arr.length < size) break;
}
}
class FxIterable<A> {
...
chunk(size: number) {
return fx(chunk(size, this));
}
}
console.log([...chunk(2, [1, 2, 3, 4, 5])]); // [[1, 2], [3, 4], [5]]
chunk로 시작하여 executeWithLimit 구현 완료하기
async function fromAsync<T>(
iterable: Iterable<Promise<T>> | AsyncIterable<T>
): Promise<T[]> {
const arr: T[] = [];
for await (const a of iterable) {
arr.push(a);
}
return arr;
}
const executeWithLimit = <T>(fs: (() => Promise<T>)[], limit: number): Promise<T[]> =>
fx(fs)
.chunk(limit) // limit 개씩 그룹화화
.map(fs => fs.map(f => f())) // 비동기 함수 실행
.map(ps => Promise.all(ps)) // limit 개씩 대기하도록
.to(fromAsync) // Promise.all의 결과 꺼내기
.then(arr => arr.flat()); // 1차원 배열로 평탄화
executeWithLimit
함수의 핵심은 지연성이다.
executeWithLimit
함수는 Promise 실행을 지연한 함수를 인자로 받는다..map(fs => fs.map(f => f()))
는 인자로 받은 모든 함수를 실행하는 것처럼 보이지만 Array의 map과 달리 직접 구현한 지연 평가되는 map이다.- 따라서
fromAsync
에서 하나의 요소를 꺼낼 때 해당 청크 안에 있는 함수들만 실행하고 그 다음map
에서Promise.all
로 감싼다. fromAsync
는 이 결과를for await...of
로 꺼내므로Promise.all
의 결과를 대기 한다.- 결과적으로 지연된 비동기 작업을 순차적으로 평가하여 배열에 담는다.
지연성을 더욱 잘 활용한 함수 합성으로 코드를 더 간결하게 만들기
const executeWithLimit = <T>(fs: (() => Promise<T>)[], limit: number): Promise<T[]> =>
fx(fs)
.map(f => f()) // 비동기 함수 지연 실행
.chunk(limit) // 그룹화
.map(ps => Promise.all(ps)) // limit개씩 대기하도록 Promise.all로 감싸기
.to(fromAsync)
.then(arr => arr.flat());
.to(fromAsync)
- 여기서 실제 실행!
// for await 루프가 순차적으로 실행:
// 1단계: 첫 번째 Promise.all 실행
await Promise.all([f1(), f2(), f3()])
// → API 1,2,3 동시 호출 → 모두 완료 기다림
// 결과: [response1, response2, response3]
// 2단계: 두 번째 Promise.all 실행
await Promise.all([f4(), f5(), f6()])
// → API 4,5,6 동시 호출 → 모두 완료 기다림
// 결과: [response4, response5, response6]
// 3단계: 세 번째 Promise.all 실행
await Promise.all([f7()])
// → API 7 호출 → 완료 기다림
// 결과: [response7]
// fromAsync 최종 결과
[
[response1, response2, response3],
[response4, response5, response6],
[response7]
]
지연 평가와 리스트 프로세싱을 사용해 Promise를 값으로 다루는 연습은 실제로 다양한 비동기 상황을 잘 핸들링할 수 있는 능력을 길러줄 것이다. (특히 백엔드 개발시)
지연 평가는 단순히 성능 개선이나 최적화를 위한 도구에 그치는 것이 아니라 이터레이터와 일급 함수를 원하는 시점에 평가하는 코드 패턴을 통해 재사용 가능한 로직을 만들 수 있다.
타입으로 다루는 비동기
AsyncIterator, AsyncIterable, AsyncGenerator
자바스크립트는 AsyncIterator, AsyncIterable, AsyncGenerator와 같은 프로토콜을 제공하여 비동기 작업의 순차적 처리를 지원한다.
AsyncIterator, AsyncIterable 인터페이스
다음은 AsyncIterator, AsyncIterable, AsyncGenerator의 구조를 타입스크립트 인터페이스 정의를 통해 실제보다 축악하여 표현한 코드이다.
// AsyncIteraotr가 아직 완료되지 않았음을 의미
interface IteratorYieldResult<T> {
done?: false;
value: T;
}
// AsyncIterator가 완료됨을 의미
interface IteratorReturnResult {
done: true;
value: undefined;
}
// Promise를 반환하는 next 메서드를 가진 인터페이스다.
interface AsyncIterator<T> {
next(): Promise<IteratorYieldResult<T> | IteratorReturnResult>;
}
interface AsyncIterable<T> {
[Symbol.asyncIterator](): AsyncIterator<T>;
}
interface AsyncIterableIterator<T> extends AsyncIterator<T> {
[Symbol.asyncIterator](): AsyncIterableIterator<T>;
}
Iterable을 for...of
구문으로 순회할 수 있던 것 처럼 AsyncIterable은 for await...of
구문을 사용하여 비동기적으로 반복할 수 있다.
AsyncGenerator 기본 문법
AsyncGenerator는 비동기적으로 값을 생성하고 순차적으로 처리하는 기능을 제공한다. 아래에서 예시로 보여주는 stringsAsyncTest
는 비동기적으로 문자열을 생성하는 AsyncGenerator이다.
async function* stringsAsyncTest(): AsyncIterableIterator<string> {
yield delay(1000, 'a');
const b = await delay(500, 'b') + 'c';
yield b;
}
async function test() {
const asyncIterator: AsyncIterableIterator<string> = stringsAsyncTest();
const result1 = await asyncIterator.next();
console.log(result1.value); // 약 1000ms 뒤 a
const result2 = await asyncIterator.next();
console.log(result2.value); // 약 500ms 뒤 bc
const { done } = await asyncIterator.next();
console.log(done); // true
}
await test();
}
toAsync 함수
toAsync
함수는 동기적인 Iterable 혹은 Promise가 포함된 Iterable을 받아 AsyncIterable로 변환한다. 두 가지 방식으로 구현해 보자.
-
AsyncIterator를 직접 구현하는 방식
typescriptfunction toAsync<T>(iterable: Iterable<T | Promise<T>>): AsyncIterable<Awaited<T>> { return { [Symbol.asyncIterator](): AsyncIterator<Awaited<T>> { const iterator = iterable[Symbol.iterator](); return { async next() { const { done, value } = iterator.next(); return done ? { done, value } : { done, value: await value }; } }; } }; }
-
AsyncGenerator를 사용하여 구현하는 방식
typescriptasync function* toAsync<T>( iterable: Iterable<T | Promise<T>> ): AsyncIterableIterator<Awaited<T>> { for await (const value of iterable) { yield value; } }
toAsync
의 경우 AsyncGenerator를 사용하는 방식이 더 간편하고 코드도 짧으며 직관적이다. 이 문제에서는 여러 패러다임 중 명령형이 가장 적합한 방법이라고 볼 수 있으며, 이처럼 각 문제에 적합한 패러다임을 선탤하는 것이 더 나은 코드 작성과 높은 유지보수성을 제공한다.
AsyncIterable을 다루는 고차 함수
map
의 AsyncIterable을 다루는 버전인 mapAsync
함수를 만들어보자.
제네레이터를 사용하면 아래처럼 간결하게 구현할 수 있다.
async function* mapAsync<A, B>(
f: (a: A) => B,
asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<Awaited<B>> {
for await (const value of asyncIterable) {
yield f(value);
}
}
이번에는 filter
의 AsyncIterable을 다루는 버전인 filterAsync
함수를 만들어보자.
async function* filterAsync<A>(
f: (a: A) => boolean | Promise<boolean>,
asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<A> {
for await (const value of asyncIterable) {
if (await f(value)) {
yield value;
}
}
}
동기와 비동기를 동시에 지원하는 함수로 만드는 규약 - toAsync
toAsync
함수는 일반 Iterable 혹은 Promise 값들로 구성된 Iterable을 AsyncIterable로 변환하는 함수이다.
동기와 비동기를 모두 지원하는 map 함수
기존 map은 Iterable<A>
를 인자로 받고, mapAsync는 AsyncIterable<A>
를 인자로 받는다.
타입스크립트에서는 함수 오버로드를 통해 하나의 함수로 두 가지 이상의 역할을 수행할 수 있다. 그럼 바로 적용해보자.
기존에 구현한 map은 mapSync로 이름을 바꾸고, mapSync와 mapAsync를 지원하는 함수를 map으로 교체한다.
function map<A, B>(
f: (a: A) => B,
iterable: Iterable<A>
): IterableIterator<B>;
function map<A, B>(
f: (a: A) => B,
asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<Awaited<B>>;
function map<A, B>(
f: (a: A) => B,
iterable: Iterable<A> | AsyncIterable<A>
): IterableIterator<B> | AsyncIterableIterator<Awaited<B>> {
return isIterable(iterable)
? mapSync(f, iterable) // [iterable: Iterable<A>]
: mapAsync(f, iterable); // [iterable: AsyncIterable<A>]
}
이 코드는 타입스크립트의 오버로드와 타입 추론을 적극 활용하고 있다.
async function test() {
// 동기적 배열 처리: mapSync
console.log([...map(a => a * 10, [1, 2])]);
// [10, 20]
// 비동기 이터러블 처리 mapAsync
for await (const a of map(a => delay(100, a * 10), toAsync([1, 2]))) {
console.log(a);
}
// 비동기 이터러블을 배열로 변환
console.log(
await fromAsync(map(a => delay(100, a * 10), toAsync([1, 2])))
);
// 동기 배열을 비동기적으로 처리
console.log(
await Promise.all(map(a => delay(100, a * 10), [1, 2]))
);
}
비동기 에러 핸들링
비동기 로직의 특성상 에러가 발생했을 때 어디서 실행되고 발생했는지 명확하게 파악하기 어려울 수 있다. 따라서 비동기 프로그래밍에서 에러를 효과적으로 처리하는 것은 필수적이다.
여러 이미지를 불러와서 높이 구하기
여러 이미지 URL을 받아 각 이미지의 높이를 계산 후 합을 구하는 기능을 만든다고 해보자. 아래는 이미지를 비동기적으로 불러오는 함수다.
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.src = url;
image.onload = function() {
resolve(image);
}
image.onerror = function() {
reject(new Error(`load error : ${url}`));
}
});
}
async function calcTotalHeight2(urls: string[]) {
try {
const totalHeight = await urls
.map(async (url) => {
const img = await loadImage(url);
return img.height;
})
.reduce(
async (a, b) => await a + await b,
Promise.resolve(0)
);
return totalHeight;
} catch (e) {
console.error('error: ', e);
}
}
URL이 담긴 배열을 받아 비동기적으로 로드 후 높이를 계산한 후 총합을 반환하는 함수다. 에러가 발생하면 catch 블록에서 처리한다. 마치 잘 동작하는 듯해 보이는 코드지만 아래와 같은 문제가 있다.
- 불필요한 부하: 에러가 발생해도 나머지 URL에 대해 이미지 다운로드를 모두 시도한다.
- 부수 효과: 위 상황은 단순히 GET 요청이지만 같은 방식으로 POST나 PUT 요청등을 한다면 불필요한 요청으로 부수 효과가 발생한다.
이는 Promise와 비동기 상황을 깊이 이해하지 않고 에러 처리를 충분히 고려하지 않아 불필요한 요청이나 작업 흐름이 진행될 가능성이 있는 코드다.
개선된 비동기 로직
에러가 발생하면 즉시 요청을 멈추고 추가적힌 부하를 방지하게 만들어보자.
async function calcTotalHeight(urls: string[]) {
try {
const totalHeight = await fx(urls)
.toAsync()
.map(loadImage)
.map(img => img.height)
.reduce((a, b) => a + b, 0);
return totalHeight;
} catch (e) {
console.error('error: ', e);
}
}
이 방식은 순차적으로 하나씩 처리하기 때문에 중간에 에러가나면 전파되어 나머지 요청을 멈추게 된다.
에러가 제대로 발생되도록 하는 것이 핵심
비동기 프로그래밍에서 중요한 것은 단순히 에러를 핸들링하는 것이 아니라 에러가 제대로 발생되도록 설계하는 것이다. 어떤 로직이 있을 때, 이 로직 내부에서 에러처리를 하도록 하지않고 대신에 호출하는 곳에서 에러를 감지하고 처리하게 해보자.
try {
const height = await getTotalHeight(urls);
// ...
} catch (e) {
console.error(e);
}
이러한 접근 방식은 순수 함수를 작성하는데 유리하며 부수 효과르 관리하기도 용이하다. 에러 핸들링은 에러가 발생하는 맥락에 가깝게 작성해야 효과적이다.
또한 에러를 호출하는 쪽에서 처리하게 하면 각 호출자가 자신에게 필요한 상황에 맞게 핸들링할 수 있어 유연성을 가질 수 있다.
에러를 감추는 것이 능사가 아니다. 에러를 꽁꽁 감추는 것은 문제의 원인을 파악하기 어렵게 하고 예기치 못한 동작을 유발할 가능성이 크다.