async와 await을 사용할 때 주의해야할 점 - 42-Gang/project-wiki GitHub Wiki
서론
Node.js로 개발을 하다 보면 async
와 await
는 정말 자주 사용된다. 처음에는 비동기 코드를 더 깔끔하게 쓸 수 있어서 좋았지만, 프로젝트가 커질수록 "정말 이게 성능에 문제는 없을까?" 라는 의문이 들었다. 그래서 async/await
의 작동 방식과 함께, 성능적으로 주의할 점들을 정리해보았다.
async/await은 어떻게 작동할까?
async
함수는 항상 Promise를 반환한다. 그리고 await
는 Promise가 resolve될 때까지 기다리는 것처럼 보이지만, 실제로는 해당 이후 코드를 마이크로태스크 큐(Microtask Queue) 에 등록해두고, 현재 실행 중인 스택이 끝난 후 실행된다.
이 구조는 Node.js의 이벤트 루프(Event Loop) 와 깊은 관련이 있다. await
는 코드 흐름을 잠깐 멈춘 뒤, 나중에 다시 이어서 실행되도록 만든다. 덕분에 콜백 지옥을 피하면서도 직관적인 비동기 코드를 작성할 수 있다.
예시 코드:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
(async () => {
console.log('4');
await null;
console.log('5');
})();
console.log('6');
출력 순서: 1, 4, 6, 3, 5, 2
async/await을 사용할 때 주의할 점
1. 반복문 안에서 await 사용은 직렬 처리
// ❌ 비효율적인 예시
for (const item of items) {
await processItem(item); // 하나 끝나면 다음 실행 → 느림
}
// ✅ 병렬 처리 예시
await Promise.all(items.map(processItem));
await
는 해당 작업이 끝날 때까지 멈추므로, 반복문 안에서 사용하면 직렬 처리가 된다. 성능이 중요한 경우 Promise.all
로 묶어서 병렬 처리해야 한다.
시간 분석 예시
const items = Array.from({ length: 1000 }, (_, i) => i);
async function processItem(item) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`Processed item ${item}`);
}, 10); // 각 아이템 처리에 10ms 소요 가정
});
}
async function inefficientExample() {
console.time("Inefficient Example");
for (let i = 0; i < items.length; i++) {
await processItem(items[i]);
}
console.timeEnd("Inefficient Example");
}
async function parallelExample() {
console.time("Parallel Example");
await Promise.all(items.map(processItem));
console.timeEnd("Parallel Example");
}
async function testPerformance() {
await inefficientExample();
await parallelExample();
}
testPerformance();
/* 결과
Inefficient Example: 10.970s
Parallel Example: 11.923ms
*/
걸린 시간을 보면 엄청나게 차이가 날 수 있다. 11ms가 걸릴 작업을 10초가 걸릴 작업으로 처리한다는 것은 디도스나 다름없다.
2. await 남발은 이벤트 루프를 끊는다
await
가 많아지면 자주 마이크로태스크 큐를 만들고 실행을 나눠서, 컨텍스트 스위칭이 잦아지고 성능 저하가 발생할 수 있다. 가능한 한 묶어서 처리하거나 최소화하는 것이 좋다.
3. CPU 바운드 코드 앞뒤의 await은 주의
await fs.promises.readFile('bigfile.txt');
for (let i = 0; i < 1e9; i++) {
// 무거운 연산
}
I/O는 비동기로 넘겼지만, 바로 뒤에서 CPU 연산이 길게 이어지면 이벤트 루프가 막힘. 이 경우 Node.js의 구조적 장점이 사라진다. 워커 스레드나 별도 프로세스로 넘기는 것이 좋다.
4. Array.map, forEach에서는 await가 의도대로 작동하지 않는다
// ❌ 동작은 하지만 의도와 다를 수 있음
items.map(async (item) => {
await processItem(item);
});
// 또는 forEach도 마찬가지
items.forEach(async (item) => {
await processItem(item); // 이 await는 기다려지지 않음
});
map
과 forEach
는 내부에서 async
콜백을 호출하더라도 해당 Promise를 기다리지 않는다. 따라서 병렬 처리를 하려면 Promise.all()
과 함께 사용하거나, 반복문을 명시적으로 제어할 수 있는 for...of
를 사용하는 것이 더 안전하다.
// ✅ 병렬 처리 (정확히 기다림)
await Promise.all(items.map(async (item) => {
await processItem(item);
}));