비동기 처리는 JavaScript 개발에서 피할 수 없는 숙제다. 다만 대부분의 개발자들은 async/await 문법이 내부에서 어떻게 동작하는지 정확히 모른 채 단순히 "동기처럼 코드를 쓸 수 있다"는 장점만 알고 있다. 결국 복잡한 비동기 흐름을 다룰 때 예상과 다른 동작에 당황하거나, 성능 최적화를 못 하는 상황이 벌어진다. 이번에는 async/await가 정확히 뭔지, Promise와 무엇이 다른지, 그리고 실전에서 어떻게 활용하는지 완벽하게 정리해서 소개하겠다.
async/await는 ECMAScript 2017에서 추가된 문법으로, 근본적으로는 Promise의 "문법 설탕(Syntactic Sugar)"이다. 즉, Promise로 할 수 있는 모든 것을 더 읽기 쉽게 표현한 것일 뿐, 내부 동작은 결국 Promise와 동일하다.
async 함수를 선언하면 자동으로 Promise를 반환한다. 함수 내부의 await 키워드는 Promise가 해결(resolve)될 때까지 코드 실행을 일시 중단했다가 결과값을 받아온다. 이 과정에서 JavaScript의 이벤트 루프와 마이크로태스크 큐가 개입한다.
// ✗ 잘못된 이해: 일반 동기 함수처럼 동작한다고 생각하기
function fetchUserData() {
const response = await fetch('/api/user');
return response.json();
}
// ✓ 올바른 이해: 실제로는 Promise를 반환한다
async function fetchUserData() {
const response = await fetch('/api/user');
return response.json();
}
// 위 코드는 아래와 동일하다
function fetchUserData() {
return fetch('/api/user')
.then(response => response.json());
}
// 둘 다 Promise를 반환하므로 호출할 때도 같은 방식이어야 한다
fetchUserData().then(data => console.log(data));
async 함수 내부에서 throw로 예외를 던지면, 반환된 Promise는 rejected 상태가 된다. try/catch 블록으로 처리하는 것도 Promise의 .catch()와 동일한 효과다.
같은 동작을 두 방식으로 구현했을 때 차이를 직접 살펴보자. 사용자 정보를 조회하고, 그 사용자의 포스트 목록을 가져온 다음, 각 포스트마다 댓글 수를 집계하는 시나리오다.
function getUserPostsWithComments(userId) {
return fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(user => {
return fetch(`/api/users/${userId}/posts`)
.then(response => response.json())
.then(posts => {
return Promise.all(
posts.map(post =>
fetch(`/api/posts/${post.id}/comments`)
.then(r => r.json())
.then(comments => ({
...post,
commentCount: comments.length
}))
)
);
})
.then(postsWithCounts => ({
user,
posts: postsWithCounts
}));
})
.catch(error => console.error('에러:', error));
}
getUserPostsWithComments(123).then(data => console.log(data));
async function getUserPostsWithComments(userId) {
try {
const userResponse = await fetch(`/api/users/${userId}`);
const user = await userResponse.json();
const postsResponse = await fetch(`/api/users/${userId}/posts`);
const posts = await postsResponse.json();
const postsWithCounts = await Promise.all(
posts.map(async (post) => {
const commentsResponse = await fetch(`/api/posts/${post.id}/comments`);
const comments = await commentsResponse.json();
return {
...post,
commentCount: comments.length
};
})
);
return {
user,
posts: postsWithCounts
};
} catch (error) {
console.error('에러:', error);
}
}
const data = await getUserPostsWithComments(123);
console.log(data);
async/await 방식이 훨씬 읽기 쉽다. 들여쓰기가 줄어들고, 변수명도 명확하게 지을 수 있다. 특히 에러 처리가 일관되게 try/catch로 통일되므로 로직 흐름을 따라가기 편하다.
// ✗ 틀린 코드: 병렬 처리할 수 있는데 순차 처리함
async function fetchMultipleEndpoints() {
const users = await fetch('/api/users').then(r => r.json());
const posts = await fetch('/api/posts').then(r => r.json());
const comments = await fetch('/api/comments').then(r => r.json());
return { users, posts, comments };
}
// 총 시간 = API 1시간 + API 2시간 + API 3시간 (예: 3초)
// ✓ 올바른 코드: Promise.all로 병렬 요청
async function fetchMultipleEndpoints() {
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
return { users, posts, comments };
}
// 총 시간 = 가장 느린 API 시간 (예: 1초)
// ✗ 틀린 코드: 각 배열 요소마다 순차 대기
const userIds = [1, 2, 3, 4, 5];
async function fetchAllUsers() {
const users = [];
for (const id of userIds) {
const user = await fetch(`/api/users/${id}`).then(r => r.json());
users.push(user);
}
return users;
}
// 5개 API 호출을 순차적으로 대기 (느림)
// ✓ 올바른 코드: Promise.all로 병렬 처리
async function fetchAllUsers() {
return Promise.all(
userIds.map(id =>
fetch(`/api/users/${id}`).then(r => r.json())
)
);
}
// 5개 API를 동시에 요청해서 대기 (빠름)
// ✗ 틀린 코드: 에러를 처리하지 않아 unhandled rejection 발생
async function riskyFetch() {
const response = await fetch('/api/data');
return response.json();
}
riskyFetch(); // 에러가 발생해도 잡지 않음 → 콘솔 경고
// ✓ 올바른 코드: 반드시 에러 처리
async function riskyFetch() {
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
} catch (error) {
console.error('데이터 로드 실패:', error);
throw error; // 호출처에서 다시 처리할 수 있게
}
}
try {
await riskyFetch();
} catch (error) {
// 적절한 폴백 처리
}
| 항목 | Promise 체이닝 | async/await |
|---|---|---|
| 가독성 | 콜백 지옥, 들여쓰기 깊음 | 동기 코드처럼 읽기 쉬움 |
| 에러 처리 | .catch() 메서드 사용 | try/catch 블록 사용 |
| 디버깅 | 스택 트레이스 추적 어려움 | 스택 트레이스가 명확함 |
| 반환값 | Promise 객체 | Promise 객체 (동일) |
| 성능 | 동일 | 동일 (내부 구현이 같음) |
실제 서비스에서는 단순 await만으로는 부족하다. API 호출이 너무 오래 걸리거나 실패했을 때 재시도하는 로직이 필요하다.
// 타임아웃 기능이 있는 fetch 래퍼
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
// 재시도 로직이 있는 fetch
async function fetchWithRetry(url, maxRetries = 3, delay = 1000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetchWithTimeout(url);
return await response.json();
} catch (error) {
if (attempt === maxRetries) throw error;
console.warn(`시도 ${attempt} 실패, ${delay}ms 후 재시도...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// 사용 예
try {
const data = await fetchWithRetry('/api/important-data');
console.log('데이터 로드 성공:', data);
} catch (error) {
console.error('모든 재시도 실패:', error);
}
async/await와 Promise를 섞어서 써도 문제없다. 둘 다 결국 Promise이기 때문이다. 레거시 코드가 Promise 체이닝을 쓰고 있다면, 새로운 기능은 async/await로 작성한 다음 Promise의 then()으로 연결하면 된다. 다만 async 함수 내부에서 await를 쓸 때는 불필요한 순차 대기를 피하고, 병렬 처리할 수 있는 부분은 Promise.all()을 활용해야 한다는 점을 기억하자. 이 작은 최적화가 모여서 사용자 경험을 크게 개선하는 차이를 만든다.