비동기 처리는 JavaScript 개발에서 피할 수 없는 숙제다. 다만 대부분의 개발자들은 async/await 문법이 내부에서 어떻게 동작하는지 정확히 모른 채 단순히 "동기처럼 코드를 쓸 수 있다"는 장점만 알고 있다. 결국 복잡한 비동기 흐름을 다룰 때 예상과 다른 동작에 당황하거나, 성능 최적화를 못 하는 상황이 벌어진다. 이번에는 async/await가 정확히 뭔지, Promise와 무엇이 다른지, 그리고 실전에서 어떻게 활용하는지 완벽하게 정리해서 소개하겠다.

 

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()와 동일한 효과다.

 

실전: Promise 체이닝 vs async/await 코드 비교

같은 동작을 두 방식으로 구현했을 때 차이를 직접 살펴보자. 사용자 정보를 조회하고, 그 사용자의 포스트 목록을 가져온 다음, 각 포스트마다 댓글 수를 집계하는 시나리오다.

Promise 체이닝 방식
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/await 방식
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로 통일되므로 로직 흐름을 따라가기 편하다.

 

주의: await를 쓸 때 흔한 실수들
❌ 실수 1: 불필요한 순차 대기로 성능 저하
// ✗ 틀린 코드: 병렬 처리할 수 있는데 순차 처리함
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초)
❌ 실수 2: 반복문에서 await 남용
// ✗ 틀린 코드: 각 배열 요소마다 순차 대기
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를 동시에 요청해서 대기 (빠름)
❌ 실수 3: try/catch에서 catch 블록 누락
// ✗ 틀린 코드: 에러를 처리하지 않아 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) {
  // 적절한 폴백 처리
}

 

async/await vs Promise 동작 비교 표
항목 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와 100% 호환이다

async/await와 Promise를 섞어서 써도 문제없다. 둘 다 결국 Promise이기 때문이다. 레거시 코드가 Promise 체이닝을 쓰고 있다면, 새로운 기능은 async/await로 작성한 다음 Promise의 then()으로 연결하면 된다. 다만 async 함수 내부에서 await를 쓸 때는 불필요한 순차 대기를 피하고, 병렬 처리할 수 있는 부분은 Promise.all()을 활용해야 한다는 점을 기억하자. 이 작은 최적화가 모여서 사용자 경험을 크게 개선하는 차이를 만든다.