티스토리 뷰
개발을 하게 되면 비동기 함수를 사용하게 되는 경우가 생긴다. 그리고 이를 효율적으로 처리하기 위한 방법을 예제와 함께 알아보자.
1. 한 개의 비동기 함수
1-1. 비동기 함수 실행 여부와 다른 코드가 상관이 없는 경우
일반적으로는 메인 함수의 어디에서 함수를 호출하던지 상관이 없다.
물론 특이한 케이스도 존재한다. 예를들어 만약 해당 함수가 오래걸리는 함수고 함수를 실행시키는 즉시 api를 호출시키고 싶다면 맨 위에서 호출해야된다.
function main1_1() {
foo();
delay(300);
bar();
}
1-2. 하나의 비동기 함수가 실행된 이후에 실행할 코드가 있는 경우
위와 달리 delay라는 함수가 실행된 이후에 bar가 실행되어야하는 경우가 있다.
웹에서는 api 호출 후 해당 데이터로 setState를 하는 코드,
백엔드에서는 서비스레이어에서 레포지토리레이어를 통해 db에 있는 데이터를 가져오는 코드를 생각하면 된다.
async function main1_2() {
foo();
await delay(300);
bar();
}
async function main1_2_1() {
const [state, setState] = useState();
useEffect(() => {
axios.get('/user').then((v) => setState(v.data));
}, []);
}
async function main1_2_2() {
const createdUser = await this.userRepository.createUser({ email, id });
const user = filter(createUser);
return user;
}
2. 두 개의 비동기 함수
2-1. 비동기 함수가 동기적으로 호출되어야하는 경우
웹에서는 A api 데이터를 이용해 B api를 호출하는 경우,
백엔드에서는 서비스레이어에서 User Repository에서 userId를 가져오고 userId로 post를 검색하는 경우를 생각하자.
async function main2_1() {
foo();
await delay(300);
await delay(400);
bar();
}
async function main2_1_1() {
const user = await axios.get('/user');
const posts = await axios.get(`/posts?userId=${user.data.id}`);
}
async function main2_1_2() {
const user = await this.userRepository.findOne({ where: { email } });
const posts = await this.postRepository.findOne({ where: { userId: user.id } });
return posts;
}
2-2. 비동기 함수가 동기적으로 호출되지 않아도 되는 경우
웹에서 두개의 컴포넌트를 렌더링하는데 전혀 다른 두개의 데이터를 보여준다면 A와 B컴포넌트에서 각각 api를 호출하면 된다.
백엔드에서 A와 B 두개의 db에 접근해서 데이터를 가져오고 두개를 결합해서 프론트엔드에서 처리하기 쉽게 내려주는 예시를 생각하자.
async function main2_2() {
foo();
await Promise.all([delay(300), delay(400)]);
bar();
}
3. 다섯개의 비동기 함수
두 개의 비동기 함수를 호출할 때와 크게 다른건없다. 비지니스 로직에 따라 코드가 복잡해질 수 도 있지만 개념은 동일하다.
async function main3_1() {
foo();
await Promise.all([delay(300), delay(400), delay(500), delay(600), delay(700)]);
bar();
}
4. n개의 비동기 함수(bacth)
지금부터는 조금 처리 방법이 특별하다. Promise.all을 사용하면 대부분의 환경에서 부하가 커지고 터지게 된다.
한번에 실행할 수 있는 비동기 함수 개수를 파악하고 이를 batch로 나눠서 처리해야한다.
(제너레이터, for of문을 이용하면 for문을 좀 더 개선할 수 있다.)
const mockApiCall = (id: number) => () =>
new Promise<string>((resolve) => setTimeout(() => resolve(`API ${id} 완료`), id % 2 === 0 ? 1000 : 500));
const tasks = Array.from({ length: 500 }, (_, i) => mockApiCall(i + 1));
const count = 50;
async function main4() {
console.time('main4 time');
const result: string[] = [];
for (let i = 0; i < tasks.length; i += count) {
const temp = await Promise.all(tasks.slice(i, i + count).map((fn) => fn()));
result.push(...temp);
}
console.log(`최종 결과:`, result);
console.timeEnd('main4 time');
}
// main4 time: 10.012s
4번 방식의 한계
이 방식도 나쁘지 않지만, 경우에 따라 성능이 저하될 수 있다. 예를 들어, API 호출 시 10%의 확률로 응답 시간이 2배가 된다고 가정해보자. 이 경우, 4번 방식에서는 1번부터 10번 API를 동시에 실행한 후, 평균 응답 시간보다 더 오래 걸리는 요청이 있으면 전체 배치가 대기하게 된다. 그리고 이후 API 호출은 블록되어 있다. 즉, 한 개의 API 응답이 지연되면 그 배치의 모든 요청이 완료될 때까지 다음 배치가 실행되지 않는다. 이를 해결하기 위해 pool 방식을 사용하면 성능을 더욱 최적화할 수 있다.
5. n개의 비동기 함수 성능 최적화(promise pool)
pool 방식을 사용하면 고정된 개수의 작업을 병렬로 실행할 수 있다. 즉, 특정 요청이 오래 걸려도 다음 요청들이 블록되지 않고 계속 실행될 수 있다.
예를들면 1번부터 10번 API 중 1번 API가 오래 걸려도, 2번~10번 API가 완료되면 즉시 11번부터 실행된다.
아래는 promise를 이용해 pool을 만들고 이를 이용해 최적화한 코드다.
async function promisePool<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]> {
const results: T[] = [];
const executing = new Set<Promise<void>>(); // 실행 중인 작업을 저장하는 Set
for (const task of tasks) {
const promise = task().then((result) => {
results.push(result);
executing.delete(promise); // 완료된 Promise를 Set에서 제거
});
executing.add(promise); // 실행 중인 Promise 추가
// 실행 중인 작업이 concurrency를 초과하면, 하나가 끝날 때까지 대기
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
// 모든 작업이 끝날 때까지 대기
await Promise.all(executing);
return results;
}
async function main5() {
console.time('main5 time');
const results = await promisePool<string>(tasks, count);
console.log(results);
console.timeEnd('main5 time');
}
// main5 time: 8.018s
라이브러리 추천
위는 간단한 예시라서 그냥 구현할 수 있었지만 실제 프로젝트에서는 요구사항에 따라 다양한 처리를 해줘야한다. 찾아보니 다음과 같은 라이브러리가 존재한다. 라이브러리를 사용해서 간편하게 비동기 함수를 최적하는 것도 좋은 방법이다.
https://github.com/supercharge/promise-pool
GitHub - supercharge/promise-pool: Map-like, concurrent promise processing
Map-like, concurrent promise processing. Contribute to supercharge/promise-pool development by creating an account on GitHub.
github.com
결론
1. 소수의 비동기 함수는 await 또는 Promise.all()을 사용하면 충분하다.
2. 대량의 비동기 함수 실행 시 Promise.all()을 직접 사용하면 메모리 이슈가 발생할 수 있다.
3. 일정 개수씩 나눠 실행하는 배치(batch) 방식이 필요하다.
4. pool 방식을 사용하면 일부 요청이 오래 걸려도 다음 요청들이 블로킹되지 않아서 성능이 향상된다.
5. 직접 pool 방식을 구현할 수도 있지만, 검증된 라이브러리를 사용하는걸 추천한다.
TIL/blog/비동기_함수_효율적으로_처리하기.ts at main · yoonminsang/TIL
Today I Learned. Contribute to yoonminsang/TIL development by creating an account on GitHub.
github.com
'기술' 카테고리의 다른 글
타사 제품 분석해보기(네이버 쇼츠, 유튜브 쇼츠) (0) | 2025.03.11 |
---|---|
useState, useEffect, useRef 만들기(feat. 클로저) (1) | 2025.02.27 |
3년차 프론트엔드 개발자가 보는 테스트코드(feat. 유의미한 테스트코드) (5) | 2024.09.12 |
컴포넌트를 잘 만드는 방법 2편(리액트) (2) | 2024.02.13 |
라이브러리없이 리액트 만들기(클래스형 컴포넌트) (0) | 2022.08.14 |