View
JS를 좀 다루다 보면 반드시 마주치는 것이 Promise와 async/await이다. 그런데 "둘이 결국 같은 것 아닌가?" 싶다가도, 면접에서 "차이가 무엇인가" 묻는 순간 말문이 막힌다. 구글에 promise async await 차이를 검색해도 대부분 2020년경의 글이라 최신 정보가 반영되어 있지 않다. 그래서 이 글 하나로 내부 동작, 에러 처리, 병렬 실행, 안티패턴, 2025년 기준 최신 문법까지 전부 정리한다.
타깃은 JS 문법은 어느 정도 알지만 마이크로태스크 큐 같은 런타임 레벨은 흐릿한 중급 개발자다. 완전 초보용 튜토리얼도 아니고, V8 소스코드를 파헤치는 딥다이브도 아니다. 딱 그 중간이다.

출처: freeCodeCamp.org
결론부터 말하면, 둘은 "같은 것"이지만 "다르게 동작한다"
한 줄 요약부터 박고 시작한다. async/await은 Promise 위에서 돌아가는 문법 설탕(syntactic sugar)이다. 즉 async 함수는 내부적으로 Promise를 반환하고, await은 그 Promise가 풀릴 때까지 함수 실행을 일시정지하는 키워드다.
다만 이 "설탕"이라는 말에 속으면 함정을 밟는다. 실제로 사용하다 보면 다음과 같은 차이가 드러난다.
- 가독성: async/await이 대체로 압승이지만 항상 그런 것은 아니다
- 에러 처리: try-catch와 .catch()는 각자 쓰임새가 다르다
- 병렬 처리: async/await만 쓰다가 코드가 2배 느려지는 경우가 있다
- 디버깅: V8 2018년 업데이트 이후 async/await의 스택 트레이스가 더 친절하다
- 마이크로태스크 스케줄링: 예전에는 async/await이 더 느렸으나 지금은 차이가 거의 없다
이 5가지를 아래에서 하나씩 팩트 체크한다. 먼저 Promise부터 빠르게 복습한다.
Promise가 무엇이었는지 먼저 복습한다
Promise는 "미래의 값"을 담는 객체이다
Promise는 비동기 작업의 아직 끝나지 않은 상태의 결과값을 감싸는 객체다. 상태는 다음 3가지뿐이다.
- pending: 아직 진행 중
- fulfilled: 성공해서 값이 정해짐
- rejected: 실패해서 에러가 정해짐
한 번 fulfilled나 rejected로 바뀌면 다시는 바뀌지 않는다. 이를 "settled"라고 부른다.
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve(42), 1000);
});
p.then(value => console.log(value)) // 1초 뒤 42
.catch(err => console.error(err))
.finally(() => console.log('끝남'));
Promise가 해결한 문제는 콜백 헬이다
Promise 이전 시대의 코드는 이렇게 생겼다.
getUser(id, (user) => {
getOrders(user.id, (orders) => {
getPayment(orders[0].id, (payment) => {
getShipping(payment.id, (shipping) => {
console.log(shipping);
}, errHandler);
}, errHandler);
}, errHandler);
}, errHandler);
들여쓰기 피라미드가 보이는가? 이것이 "콜백 헬(callback hell)"이다. Promise를 쓰면 다음과 같이 펴진다.
getUser(id)
.then(user => getOrders(user.id))
.then(orders => getPayment(orders[0].id))
.then(payment => getShipping(payment.id))
.then(shipping => console.log(shipping))
.catch(errHandler);
에러 처리도 마지막 .catch() 하나로 전부 잡힌다. 이 하나만으로도 JS 비동기 코드는 훨씬 쾌적해졌다.
Promise에도 한계는 있다, 여전히 읽기 어렵다
다만 조건 분기나 반복문이 들어가면 체인이 금방 난잡해진다.
getUser(id)
.then(user => {
if (user.premium) {
return getPremiumOrders(user.id)
.then(orders => orders.filter(o => o.active));
} else {
return getOrders(user.id);
}
})
.then(orders => {
// 여기부터 또 중첩 시작됨
});
중첩이 다시 생긴다. 그래서 ES2017에 들어온 것이 async/await이다. 자세한 스펙이나 동작 정의는 MDN async function 문서에서 확인할 수 있다.
async/await은 정확히 무엇을 바꾼 것인가
"동기 코드처럼 보이게" 쓸 수 있다
앞의 코드를 async/await으로 다시 쓰면 이렇게 된다.
async function getShippingInfo(id) {
const user = await getUser(id);
const orders = user.premium
? (await getPremiumOrders(user.id)).filter(o => o.active)
: await getOrders(user.id);
const payment = await getPayment(orders[0].id);
const shipping = await getShipping(payment.id);
return shipping;
}
동기 코드처럼 읽힌다. if/else, for, try/catch 같은 JS 제어문이 비동기 흐름에서도 자연스럽게 쓰인다. 이것이 async/await의 본질이다.
async 함수는 항상 Promise를 반환한다
이것을 놓치면 실수가 나온다. async 함수 안에서 return 42를 해도, 호출부에서는 Promise로 래핑되어 돌아온다.
async function foo() {
return 42;
}
console.log(foo()); // Promise { 42 } — 숫자 아님!
foo().then(v => console.log(v)); // 42
즉 async 함수의 호출부에서는 반드시 await이나 .then()으로 값을 꺼내야 한다. 이를 모르고 바로 값을 쓰려다 undefined가 찍히는 경험은 누구나 한 번쯤 해봤을 것이다.
await은 "함수 실행만" 일시정지한다
흔히 하는 오해가 있다. "await을 붙이면 이벤트 루프가 멈추는가?"
그렇지 않다. await은 현재 async 함수의 실행만 멈추는 것이다. 이벤트 루프는 계속 돌고, 다른 태스크도 정상적으로 처리된다. Promise가 풀리면 엔진이 해당 async 함수 컨텍스트를 복구하여 이어서 실행한다.
async function slow() {
console.log('A');
await new Promise(r => setTimeout(r, 1000));
console.log('B');
}
slow();
console.log('C');
// 출력: A → C → (1초 뒤) B
만약 await이 이벤트 루프 전체를 막았다면 C는 1초 뒤에 찍혔어야 한다. 그런데 A가 찍힌 뒤 바로 C가 찍힌다. 함수 실행만 멈춘 것이다.
진짜 차이 5가지, 팩트로 확인한다

출처: velog.io
차이 1: 가독성 — async/await 압승이지만 항상 그렇지는 않다
단순 순차 작업은 async/await이 넘사벽이다. 다만 병렬 처리나 경쟁 조건은 Promise API가 더 직관적이다.
// 병렬은 이게 훨씬 명확함
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
// 제일 빨리 오는 거 하나만
const fastest = await Promise.race([fetchA(), fetchB()]);
Promise.all, Promise.race, Promise.allSettled, Promise.any 같은 API는 async/await 시대에도 여전히 핵심 도구다. async가 이를 "대체"하는 것이 아니다.
차이 2: 에러 처리 — try-catch와 .catch()
Promise:
getUser(id)
.then(user => getOrders(user.id))
.then(orders => process(orders))
.catch(err => console.error(err));
async/await:
async function handle(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
return process(orders);
} catch (err) {
console.error(err);
}
}
차이점은 다음과 같다.
- try-catch는 동기/비동기 예외를 한 번에 잡는다. 같은 블록 안에 있는 JSON.parse() 같은 동기 에러도 동일한 catch가 잡아준다
- .catch()는 특정 Promise 체인에 붙어 핀포인트로 잡기 쉽다. 어느 단계에서 터졌는지 명확하다
안티패턴에 주의해야 한다. try 블록을 너무 넓게 감싸면 에러 지점이 애매해진다. 호출별로 세분화하거나 에러 종류별로 분기 처리해야 한다.
차이 3: 병렬 처리 — 잘못 쓰면 async/await이 더 느리다
이것이 가장 자주 하는 실수다. 다음 코드를 보고 무엇이 문제인지 바로 감이 와야 한다.
// 망하는 코드 — 순차 실행돼서 2배 느림
const user = await fetchUser();
const posts = await fetchPosts();
두 호출이 서로 의존성이 없는데 직렬로 실행된다. fetchUser 1초 + fetchPosts 1초 = 총 2초다. 올바른 패턴은 다음과 같다.
// 올바른 패턴 — Promise.all로 병렬 실행 (총 1초)
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts(),
]);
의존성이 있을 때만 await 체인으로 직렬을 쓰고, 나머지는 Promise.all로 묶는 것이 정석이다. 이를 모르고 "async/await은 느리다"라고 말하는 사람이 많은데, 잘못 쓴 것이지 문법 탓이 아니다.
차이 4: 디버깅 — async/await이 스택 트레이스에 유리하다
2018년 V8이 "zero-cost async stack traces"를 도입하기 전에는 Promise 체인 디버깅이 그야말로 지옥이었다. 에러가 터져도 스택 트레이스가 Promise.then 안쪽만 찍히고, 실제 호출한 코드는 드러나지 않았다.
지금은 async/await이 compile time에 더 친절한 스택을 만들어준다. 에러가 나면 async 함수 호출 경로가 찍힌다. 디버깅 편의성만 따지면 async/await이 한 수 위다.
차이 5: 마이크로태스크 스케줄링 — 예전엔 달랐으나 지금은 동일하다
스펙상 둘 다 마이크로태스크 큐(microtask queue)에 들어간다. 다만 2018년 이전에는 async 함수가 내부적으로 Promise를 두 번 래핑하여 마이크로태스크 2개를 쓰고 있었다. 그래서 p.then()보다 await p가 살짝 느렸다.
V8 2018년 업데이트(Faster async functions and promises) 이후로는 이 오버헤드가 사라졌다. 2025년 현재는 성능 차이가 거의 없다. 옛날 블로그에서 "async/await이 느리다"라고 주장한다면 그것은 v6.x 시절의 이야기다.
실전에서는 언제 무엇을 써야 하는가
async/await을 쓰는 것이 맞는 경우
- 순차적 비동기 작업 (A → B → C 의존성)
- 조건 분기가 있는 비동기 로직
- try-catch로 묶어 여러 호출을 한 번에 에러 핸들링하고 싶을 때
- 코드 리뷰어가 읽기 편하기를 바랄 때 (대부분의 비즈니스 로직)
Promise를 그대로 쓰는 것이 맞는 경우
- Promise.all, Promise.race, Promise.allSettled 같은 병렬 제어
- 짧은 변환 한 줄로 끝나는 경우: fetch(url).then(r => r.json())
- 라이브러리 API 설계 — 내부를 Promise 반환으로 두면 호출부의 유연성이 높다
- 이미 .then 체인으로 깔끔하게 정리된 기존 코드
섞어 쓰는 것이 정답인 경우가 많다
실무에서 가장 자주 등장하는 패턴은 다음과 같다.
async function fetchDashboard(userId) {
const [user, stats, notifications] = await Promise.all([
fetchUser(userId),
fetchStats(userId),
fetchNotifications(userId),
]);
return { user, stats, notifications };
}
async 함수 안에서 await Promise.all([...])을 쓴다. 둘은 대립이 아니라 조합이다.
자주 하는 실수 모음 (안티패턴)

출처: Programiz
forEach 안에서 await을 쓰는 실수
이것은 정말 자주 목격된다.
// 망함 — forEach는 async 콜백을 기다리지 않음
users.forEach(async (user) => {
await saveUser(user);
});
console.log('끝남'); // saveUser 안 끝났는데 먼저 찍힘
forEach는 반환값을 무시한다. async 함수가 반환하는 Promise가 허공에 날아간다. 해결 방법은 다음과 같다.
// 순차 실행이 필요하면
for (const user of users) {
await saveUser(user);
}
// 병렬로 돌려도 되면
await Promise.all(users.map(user => saveUser(user)));
try-catch를 너무 넓게 감싸는 실수
// 망함 — 어느 호출에서 터졌는지 모름
try {
const user = await getUser();
const orders = await getOrders(user.id);
const shipping = await getShipping(orders[0].id);
await notify(user.email);
} catch (err) {
console.error('뭔가 터졌음'); // 어디서 터졌는데?
}
호출마다 실패의 의미가 다르다면 세분화해야 한다. 예를 들어 notify 실패는 무시해도 되지만, getOrders 실패는 전체를 중단해야 하는 경우가 많다.
async를 붙이지 않고 await을 쓰는 실수
function foo() {
await bar(); // SyntaxError
}
await은 async 함수 안에서만 사용할 수 있다. 예외가 있다면 top-level await을 지원하는 ES 모듈이다.
Promise 반환 함수에서 await을 빼먹고 return하는 실수
async function good() {
return await fetchData(); // 에러 스택에 이 함수 찍힘
}
async function bad() {
return fetchData(); // 작동은 하는데 스택 트레이스에서 이 함수 사라짐
}
둘 다 호출부에서는 동일하게 동작한다. async 함수가 어차피 Promise를 풀어주기 때문이다. 다만 에러 스택 트레이스에서 차이가 난다. return await을 쓰면 해당 함수가 스택에 남고, 그냥 return하면 스택에서 사라져 디버깅이 어렵다.
Node.js v10+ 기준으로는 "return-await" ESLint 규칙도 존재하며, 팀 컨벤션에 따라 다르다. 일반적으로는 에러 핸들링이 필요한 구간에서는 return await을 쓰는 것이 안전하다.
2024~2025 최신 문법 체크
top-level await (ES2022)
ES 모듈 최상위에서 async 함수 없이 바로 await을 쓸 수 있다.
// app.mjs
const config = await fetch('/config.json').then(r => r.json());
export default config;
Node.js 14.8+ ESM과 모든 최신 브라우저에서 지원한다. CommonJS require에서는 사용할 수 없다. 모듈 로딩이 Promise가 풀릴 때까지 대기하므로 남발하면 초기화 지연이 발생한다.
Promise.try() (Stage 4 진입)
동기 예외와 비동기 에러를 한 번에 처리할 수 있게 해주는 문법이다. 2024년 TC39 proposal-promise-try에서 Stage 4에 근접했다.
// 예전 방식
Promise.resolve().then(() => riskyFunction())
.catch(err => handle(err));
// Promise.try()
Promise.try(() => riskyFunction())
.catch(err => handle(err));
riskyFunction이 동기 throw를 하든 rejected Promise를 반환하든 동일하게 catch가 잡아준다. 라이브러리 작성 시 유용하다.
AbortSignal로 async 함수 취소
fetch 취소를 async/await 흐름에서 자연스럽게 처리할 수 있다.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('/api', { signal: controller.signal });
const data = await res.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('5초 넘어서 취소됨');
}
}
타임아웃이나 사용자 취소를 구현할 때 이를 쓰지 않으면 손해다. Node.js 18+부터는 AbortSignal.timeout(ms) 헬퍼도 추가되어 한층 편리해졌다.
면접에서 물어보면 이렇게 답하면 된다
면접/코테 대비용 대답 템플릿을 박아둔다. 외워서 나가도 좋고, 키워드만 뽑아 써도 된다.
"Promise와 async/await의 차이가 무엇인가?"
"async/await은 Promise 위에 얹은 문법 설탕이다. async 함수는 내부적으로 Promise를 반환하고, await은 그 Promise가 resolve될 때까지 함수 실행을 일시정지한다. 이벤트 루프는 막지 않는다. 가독성 면에서는 순차 처리 시 async/await이 유리하고, 병렬 처리에는 여전히 Promise.all 같은 API가 필요하다. 즉 대체 관계가 아니라 조합하여 쓰는 도구다."
"async/await이 Promise보다 느리다는데 사실인가?"
"2018년 V8 업데이트 이전에는 async 함수가 마이크로태스크를 추가로 써서 살짝 느렸다. 다만 'Faster async functions and promises' 업데이트 이후로는 거의 동일하다. 오히려 async/await을 잘못 써서 순차 실행하면 느려지는 경우가 많다. Promise.all로 병렬로 묶으면 해결된다."
"에러 처리는 어떻게 하는가?"
"async/await은 try-catch로 동기/비동기 예외를 통합해 잡을 수 있다. Promise 체인은 .catch()를 마지막에 붙여 처리한다. 다만 try 블록을 너무 넓게 잡으면 어디서 터졌는지 알기 어려우니 구간을 나눠 처리하는 편이 낫다."
async/await은 "대체"가 아니라 "도구 세트"다
여기까지 읽었다면 이제 이 문장이 체감될 것이다. async/await은 Promise를 대체하는 것이 아니라, Promise 위에 얹어서 쓰는 문법이다.
기억해야 할 핵심 5가지를 다시 정리한다.
- async 함수는 무조건 Promise를 반환한다. 호출부에서 꺼내 쓰려면 await이나 .then이 필요하다
- await은 함수 실행만 일시정지한다. 이벤트 루프는 막지 않는다
- 병렬 처리는 Promise.all이 맞다 — await 체인으로 순차 실행하면 느려진다
- try-catch는 동기/비동기 통합 에러 처리에 강력하다
- 성능 차이는 이제 거의 없다 — 옛날 글의 "async가 느리다"는 주장은 무시해도 된다
async/await 시대에도 Promise API(.all, .race, .allSettled, .any)는 여전히 필수이고, 2025년에 추가된 top-level await과 Promise.try도 챙겨두면 좋다. 다음 단계로 공부할 주제를 추천하자면 이벤트 루프, 마이크로태스크 vs 매크로태스크, V8 엔진 최적화 쪽으로 파고들면 된다. 그쪽까지 이해하면 JS 비동기는 끝판왕을 찍는 셈이다.
헷갈리던 지점이 좀 풀렸는가? 댓글이나 북마크로 남겨두고 면접 전에 한 번씩 다시 봐주면 좋을 것이다.
'Frontend' 카테고리의 다른 글
| 표준화되는 Tailwind CSS, Bootstrap의 위상은 진짜 꺾였는가 (0) | 2026.04.24 |
|---|---|
| 자바스크립트, ECMAScript, DOM, BOM 차이 — 표준이 왜 따로 노는지 정리 (0) | 2026.04.23 |
| Chrome 효과적인 유저 디렉토리 설정 방법 (0) | 2022.11.18 |
| 프론트엔드 모노레포 전략 (0) | 2022.05.03 |
| fabricJS - 이미지에 오버레이 포커스 (0) | 2022.04.20 |
