대부분의 개발자는 async/await를 하루 이틀이면 익힌다. 그리고 그 주 안에 프로덕션 코드에 쓴다. 그때부터 버그가 시작된다.
분명히 맞게 짠 것 같은데 값이 undefined로 돌아온다. 에러도 없이 함수가 조용히 실패한다. 앱이 이유 없이 느려진다. 열에 아홉은 같은 실수가 원인이다. async/await를 배운 직후 누구나 한 번씩 빠지는 그 함정들이다.
이 글에서는 그 실수 5가지가 정확히 무엇인지, 왜 발생하는지, 어떻게 고치는지를 코드 예시와 함께 설명한다. 지금 당장 코드베이스를 점검하고 싶다면 마지막 섹션의 감사 체크리스트를 참고하면 된다.
실수 1: await를 빠뜨려서 Promise 객체를 값으로 착각
이게 async 코드에서 가장 빈번한 조용한 실패 원인이다. 함수 호출처럼 보이는데, 실제로는 Promise 객체를 받아온다.
어떻게 생겼나
async function getUserData() {
const user = fetchUser(123); // await 없음!
console.log(user.name); // undefined -- user는 값이 아니라 Promise 객체
}
에러는 하나도 안 난다. user는 Promise { <pending> } 객체다. user.name에 접근하면 undefined가 나오고, 데이터가 왜 없는지 30분을 헤매게 된다.
왜 발생하나
JavaScript는 await를 강제하지 않는다. async 함수를 일반 함수처럼 호출해도 아무 경고 없이 Promise만 반환한다. 조용히 실패한다.
해결법
async function getUserData() {
const user = await fetchUser(123); // 올바른 코드
console.log(user.name); // "Alice"
}
Pro tip: TypeScript나 ESLint를 쓴다면 no-floating-promises 규칙을 활성화하자. await 없는 Promise를 린팅 단계에서 잡아준다. async/await 전체 동작 방식은 MDN의 async function 문서에 잘 정리되어 있다.
실제 사례가 있다. 핀테크 스타트업 백엔드 개발자 지훈은 결제 확인 이메일이 전송되지 않는 버그를 이틀 동안 추적했다. 결제는 정상 처리됐는데 확인 이메일만 안 갔다. 원인은 이메일 서비스 호출 앞에 await 하나가 빠진 것이었다. 결제는 완료됐고, 이메일 발송 Promise는 생성됐지만 await 없이 함수가 return됐다. 수정은 단어 하나를 추가하는 것이었다.
코드베이스 점검 팁: async 함수 안에서
= fetch또는= get으로 시작하는 호출을 검색해서 앞에await가 있는지 확인하라.
실수 2: try/catch 없이 에러를 방치
unhandled promise rejection은 Node.js 프로덕션 크래시의 주요 원인 중 하나다. 그런데 많은 개발자가 async 함수에 에러 처리를 전혀 안 한다.
어떻게 생겼나
async function loadUserProfile(id) {
const user = await db.findUser(id); // DB가 다운되면?
const posts = await db.getPosts(id); // 여기서 throw가 나면?
return { user, posts };
}
db.findUser에서 에러가 나면 unhandled rejection으로 전파된다. Node.js 14 이하에서는 조용히 삼켜지고, 15 이상에서는 프로세스가 죽는다. Node.js 공식 Promises 문서에서도 unhandled rejection을 명시적으로 처리할 것을 권장한다.
왜 발생하나
async 함수는 동기 코드처럼 보이기 때문에 await마다 throw 가능성이 있다는 걸 잊기 쉽다. 동기 코드라면 당연히 try/catch로 감쌌을 부분이다.
해결법
async function loadUserProfile(id) {
try {
const user = await db.findUser(id);
const posts = await db.getPosts(id);
return { user, posts };
} catch (error) {
console.error('프로필 로드 실패:', error.message);
throw new Error(`사용자 ${id} 프로필 로드 실패: ${error.message}`);
}
}
원본 에러를 그냥 rethrow하지 않고 컨텍스트를 추가해서 rethrow하면 스택 트레이스를 보존하면서 디버깅 정보도 남길 수 있다.
각 단계를 개별적으로 처리하고 싶을 때:
async function loadUserProfile(id) {
const [userError, user] = await fetchUser(id).then(
data => [null, data],
err => [err, null]
);
if (userError) {
return { error: '사용자를 찾을 수 없음' };
}
// user 데이터로 계속 진행...
}
Go 스타일의 에러 처리 패턴이다. 중첩된 try/catch 없이 각 비동기 작업의 에러를 독립적으로 처리할 수 있다.
실수 3: 병렬로 처리할 수 있는 요청을 직렬로 실행
이건 코드를 망가뜨리지는 않는다. 대신 느리게 만든다. 경우에 따라 5~10배.
어떻게 생겼나
async function getDashboardData(userId) {
const user = await getUser(userId); // 200ms
const orders = await getOrders(userId); // 300ms
const reviews = await getReviews(userId); // 150ms
// 합계: 650ms
return { user, orders, reviews };
}
각 요청이 이전 것이 끝날 때까지 기다린다. 그런데 이 세 요청은 완전히 독립적이다. 서로의 데이터가 필요하지 않다. 인위적으로 직렬화하고 있는 것이다.
해결법: Promise.all 사용
async function getDashboardData(userId) {
const [user, orders, reviews] = await Promise.all([
getUser(userId),
getOrders(userId),
getReviews(userId),
]);
// 합계: ~300ms (가장 느린 요청 기준)
return { user, orders, reviews };
}
결과는 같다. 시간은 절반 이하다. Promise.all은 세 요청을 동시에 시작하고 모두 완료될 때까지 기다린다.
언제 직렬, 언제 병렬을 쓸까
직렬 await를 쓸 때:
- B 요청이 A 요청의 결과를 필요로 할 때
- 순서가 보장되어야 할 때 (예: 유저 생성 후 프로필 생성)
- 앞 단계 실패 시 즉시 중단해야 할 때
Promise.all을 쓸 때:
- 요청이 서로 독립적일 때
- 모든 결과가 다 필요할 때
- 성능이 중요할 때
주의: Promise.all은 하나라도 reject되면 전체가 reject된다. 개별 실패와 무관하게 모두 완료되길 원하면 Promise.allSettled를 쓴다.
const results = await Promise.allSettled([
getUser(userId),
getOrders(userId),
getReviews(userId),
]);
const successful = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
한 이커머스 팀이 상품 페이지 로딩 시간을 1.2초에서 410ms로 줄였다. 백엔드 로직은 하나도 바꾸지 않았다. 프론트엔드에서 직렬로 하던 API 호출 세 개를 Promise.all로 바꾼 것이 전부였다.
실수 4: async 콜백을 forEach에 넣고 정상 동작을 기대
경험 많은 개발자도 종종 걸리는 함정이다. forEach 안에서 async 코드를 쓰는데, 실행은 되는 것 같은데 순서가 틀리거나, 함수가 작업 완료 전에 반환되거나, 결과가 뒤죽박죽이 된다.
어떻게 생겼나
async function processOrders(orders) {
orders.forEach(async (order) => {
await saveOrder(order); // 실행은 되지만...
await sendConfirmation(order); // 완료 순서를 보장할 수 없음
});
// 이 줄은 위 작업들이 완료되기 전에 실행된다!
console.log('모든 주문 처리 완료'); // 거짓말
}
무슨 일이 일어나냐면, forEach는 각 항목마다 async 콜백을 호출한다. 하지만 forEach는 콜백의 반환값(Promise)을 무시한다. 콜백을 전부 실행하고 바로 다음으로 넘어간다. “모든 주문 처리 완료” 로그는 첫 번째 saveOrder가 완료되기도 전에 찍힌다.
왜 발생하나
forEach는 Promise가 존재하기 전에 설계됐다. 콜백 함수를 받지만 반환값은 무시한다. async 함수는 Promise를 반환하는데, forEach가 그걸 버리는 것이다. 참고로 map과 forEach의 차이를 정리한 글도 있으니 두 메서드의 동작 방식이 궁금하다면 함께 읽어보면 좋다.
해결법
옵션 1: 순차 처리가 필요하면 for...of
async function processOrders(orders) {
for (const order of orders) {
await saveOrder(order);
await sendConfirmation(order);
}
console.log('모든 주문 처리 완료'); // 이제 정확하다
}
for...of는 각 await에서 제대로 멈추고 한 번에 하나씩 처리한다.
옵션 2: 병렬 처리가 필요하면 Promise.all + map
async function processOrders(orders) {
await Promise.all(
orders.map(async (order) => {
await saveOrder(order);
await sendConfirmation(order);
})
);
console.log('모든 주문 처리 완료'); // 정확하고 더 빠르다
}
map은 Promise 배열을 반환하고, Promise.all이 전부 완료될 때까지 기다린다.
어떤 걸 선택할까:
- 순서가 중요하거나, 다운스트림 서비스 부하 조절이 필요하면
for...of - 요청이 독립적이고 속도가 중요하면
Promise.all + map
실수 5: 에러를 다시 던질 때 스택 트레이스를 잃어버림
이 실수는 동작을 망가뜨리지 않는다. 대신 디버깅을 극도로 어렵게 만든다. 에러가 async 코드를 통해 잘못 전파되면 어디서, 왜 실패했는지의 맥락을 잃는다.
어떻게 생겼나
async function createUser(data) {
try {
await validateData(data);
await saveToDatabase(data);
await sendWelcomeEmail(data.email);
} catch (error) {
throw new Error('사용자 생성 실패'); // 원본 에러가 사라졌다!
}
}
error를 catch해서 새 Error를 throw하면 원래 메시지, 스택 트레이스, 원본 에러의 프로퍼티가 전부 사라진다. saveToDatabase에서 발생한 구체적인 DB 에러 정보가 없어진다.
해결법: 원본 에러 컨텍스트 보존
async function createUser(data) {
try {
await validateData(data);
await saveToDatabase(data);
await sendWelcomeEmail(data.email);
} catch (error) {
// 방법 1: 컨텍스트 추가 + 원본 보존
const wrappedError = new Error(`사용자 생성 실패: ${error.message}`);
wrappedError.cause = error; // Node.js 16.9+ / 최신 브라우저
throw wrappedError;
// 방법 2: 원본 그대로 rethrow
// throw error;
}
}
error.cause 프로퍼티(Node.js 16.9+, 최신 브라우저 지원)는 에러를 체이닝하는 현대적인 방법이다. 컨텍스트를 추가하면서 원본 에러를 그대로 보존한다.
rethrow 전에 로깅하기:
} catch (error) {
logger.error('사용자 생성 실패', {
userId: data.id,
step: 'createUser',
error: error.message,
stack: error.stack,
});
throw error; // 원본 그대로 rethrow
}
디버깅에 필요한 세부 정보를 로깅하고, 원본 에러를 rethrow해서 상위 호출자가 전체 맥락을 볼 수 있게 한다.
빠른 참조: async/await 실수와 해결법
| 실수 | 증상 | 해결법 |
|---|---|---|
await 누락 | undefined 값, 조용한 실패 | await 추가; ESLint no-floating-promises 사용 |
| 에러 처리 없음 | 앱 크래시, 조용한 실패 | try/catch 래핑, 컨텍스트 포함 rethrow |
| 독립 요청 직렬화 | 느린 성능 | Promise.all로 병렬 실행 |
forEach에 async | 너무 일찍 완료, 순서 뒤바뀜 | for...of 또는 Promise.all + map |
| 빈 Error rethrow | 스택 트레이스 소실, 디버깅 어려움 | error.cause로 원본 에러 보존 |
코드베이스 감사 체크리스트
forEach+async검색:grep -rn "forEach.*async" src/—for...of또는Promise.all + map으로 교체- 연속된 독립
await탐색: 서로 의존하지 않는await3개 이상이 연속되면Promise.all후보 - 모든 catch 블록 확인: 컨텍스트 포함 rethrow를 하고 있나? rethrow 전 로깅은?
no-floating-promisesESLint 규칙 추가: await 없는 Promise를 자동으로 감지- async 함수 반환값 검토: await나
.then()없이 async 함수를 호출하고 있으면 floating promise
마치며
async/await는 비동기 JavaScript를 읽기 좋게 만들어주지만, 그에 따른 함정도 있다. 이 글에서 다룬 다섯 가지 실수인 await 누락, 에러 처리 생략, 불필요한 직렬화, async forEach, 스택 트레이스 소실은 실제 프로젝트에서 마주치는 비동기 버그의 대부분을 차지한다.
좋은 소식은 다섯 가지 모두 간단하게 고칠 수 있다는 것이다. 패턴을 알고 나면 자신의 코드와 코드 리뷰에서 즉시 눈에 띄기 시작한다.
가장 중요한 async 함수부터 시작하자. 에러 처리를 추가하고, 독립 요청은 Promise.all로 묶고, forEach 콜백은 for...of 또는 Promise.all + map으로 바꾸자. 코드는 더 안정적이 되고, 동료들도 고마워할 것이다. 비슷한 실수 패턴이 궁금하다면 Debugging 카테고리의 다른 글도 참고하자.
지금 바로 클린 async 코드를 작성하고 싶다면? 코드베이스에서 함수 하나를 골라 이 가이드의 패턴을 적용해보자. 작은 개선이 쌓이면 훨씬 신뢰할 수 있는 소프트웨어가 된다.