본문으로 건너뛰기
JavaScript Async/Await 가이드

# JavaScript Async/Await 완벽 가이드: 비동기 처리의 정석

Table of Contents

Async/await는 JavaScript에서 비동기 코드를 동기 코드처럼 직관적으로 작성할 수 있게 해주는 혁신적인 문법입니다. Promise를 기반으로 동작하지만, 콜백 지옥(Callback Hell)이나 복잡한 Promise 체이닝보다 훨씬 더 읽기 쉽고 유지보수하기 좋은 코드를 만들 수 있습니다.

Async/Await 기초

async 키워드는 함수를 비동기 함수로 선언하며, await 키워드는 Promise가 처리될 때까지 함수의 실행을 일시 중지합니다.

// 기본 async 함수 구조
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    console.log(data);
    return data;
  } catch (error) {
    console.error('데이터 가져오기 실패:', error);
    throw error;
  }
}

// async 함수는 항상 Promise를 반환합니다
fetchData().then((data) => console.log('성공:', data));

:::important 핵심 개념 async 함수는 항상 Promise를 반환합니다. 함수 내부에서 값을 반환하면 자동으로 Promise.resolve(value)로, 에러를 던지면 Promise.reject(error)로 감싸집니다. :::

Promise vs. Async/Await 비교

동일한 로직을 구현했을 때의 차이를 비교해 보겠습니다.

// [Promise 체이닝]
function getUserDataWithPromise(userId) {
  return fetch(`/api/users/${userId}`)
    .then((response) => {
      if (!response.ok) throw new Error('사용자를 찾을 수 없습니다.');
      return response.json();
    })
    .then((user) => {
      return fetch(`/api/posts?userId=${user.id}`);
    })
    .then((response) => response.json())
    .then((posts) => {
      return { user, posts };
    })
    .catch((error) => {
      console.error('에러 발생:', error);
      throw error;
    });
}

// [Async/Await] - 훨씬 더 직관적입니다
async function getUserDataWithAsync(userId) {
  try {
    const userResponse = await fetch(`/api/users/${userId}`);
    if (!userResponse.ok) throw new Error('사용자를 찾을 수 없습니다.');

    const user = await userResponse.json();
    const postsResponse = await fetch(`/api/posts?userId=${user.id}`);
    const posts = await postsResponse.json();

    return { user, posts };
  } catch (error) {
    console.error('에러 발생:', error);
    throw error;
  }
}

:::tip 가독성 Async/await를 사용하면 비동기 흐름이 위에서 아래로 흐르는 동기 코드처럼 보여, 코드의 의도를 파악하고 디버깅하기가 훨씬 수월해집니다. :::

에러 처리의 정석

Try-Catch 블록 활용

async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
      throw new Error(`HTTP 에러! 상태 코드: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    if (error.name === 'TypeError') {
      // 네트워크 연결 실패 등의 에러
      console.error('네트워크 에러:', error.message);
    } else {
      console.error('요청 에러:', error.message);
    }
    throw error; // 호출한 곳으로 에러 전파
  }
}

Finally 블록 활용

async function fetchWithLoading(url) {
  showLoadingSpinner(); // 로딩 시작

  try {
    const response = await fetch(url);
    return await response.json();
  } catch (error) {
    showErrorMessage(error.message);
    throw error;
  } finally {
    hideLoadingSpinner(); // 성공하든 실패하든 로딩 종료
  }
}

병렬 실행 최적화: Promise.all

여러 비동기 작업이 서로 의존하지 않는다면, Promise.all을 사용하여 병렬로 실행하는 것이 성능상 유리합니다.

// [순차 실행] - 느림 (총 시간 = A + B + C)
async function fetchSequential() {
  const user = await fetch('/api/user').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 { user, posts, comments };
}

// [병렬 실행] - 빠름 (총 시간 = Max(A, B, C))
async function fetchParallel() {
  const [user, posts, comments] = await Promise.all([
    fetch('/api/user').then((r) => r.json()),
    fetch('/api/posts').then((r) => r.json()),
    fetch('/api/comments').then((r) => r.json()),
  ]);

  return { user, posts, comments };
}

:::important 성능 팁 서로 결과가 필요 없는 독립적인 요청들은 반드시 Promise.all로 묶어서 실행하세요. 전체 응답 시간이 획기적으로 줄어듭니다. :::

실전 패턴: 재시도(Retry) 로직 구현

네트워크 요청은 언제든 실패할 수 있습니다. 지수 백오프(Exponential Backoff)를 적용한 재시도 로직은 필수 패턴입니다.

async function fetchWithRetry(url, options = {}, retries = 3, delay = 1000) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      return await response.json();
    } catch (error) {
      const isLastAttempt = i === retries - 1;
      if (isLastAttempt) {
        console.error(`${retries}회 시도 실패:`, error);
        throw error;
      }

      console.log(`${i + 1}번째 시도 실패, ${delay}ms 후 재시도...`);
      await new Promise((resolve) => setTimeout(resolve, delay));
      delay *= 2; // 대기 시간을 2배로 늘림 (지수 백오프)
    }
  }
}

흔한 실수와 해결책

1. forEach 루프에서 await 사용

forEach는 비동기 함수를 기다려주지 않습니다.

// [나쁜 예] - 의도대로 동작하지 않음
async function bad() {
  const items = [1, 2, 3];
  items.forEach(async (item) => {
    await processItem(item);
  });
  console.log('완료'); // processItem이 끝나기도 전에 출력됨
}

// [좋은 예] - for...of 루프 사용
async function good() {
  const items = [1, 2, 3];
  for (const item of items) {
    await processItem(item); // 하나씩 순차적으로 실행
  }
  console.log('완료'); // 모든 처리가 끝난 후 출력됨
}

// [좋은 예] - 병렬 처리 (Promise.all)
async function parallel() {
  const items = [1, 2, 3];
  await Promise.all(items.map((item) => processItem(item)));
  console.log('완료');
}

2. 불필요한 await

// [비효율]
async function returnData() {
  return await fetch('/api/data'); // 여기서 await는 불필요
}

// [효율]
async function returnData() {
  return fetch('/api/data'); // Promise를 그대로 반환해도 됨
}

단, try-catch 블록 내부에서는 에러를 잡기 위해 await가 필수입니다.

정리

Async/await는 모던 자바스크립트 비동기 처리의 표준입니다.

  1. 가독성: 동기 코드처럼 읽히는 직관적인 구조
  2. 에러 처리: try-catch로 표준화된 에러 핸들링
  3. 성능: Promise.all을 활용한 병렬 처리 최적화

이 패턴들을 숙지하고 실무에 적용한다면, 훨씬 더 견고하고 유지보수하기 쉬운 애플리케이션을 만들 수 있습니다.

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트