목록으로
JavaScript

JavaScript 비동기 처리 완벽 이해하기

콜백부터 Promise, async/await까지. 비동기 처리의 원리와 실전 활용법을 깊이 있게 다룹니다.

비동기 처리는 JavaScript의 핵심입니다. 네트워크 요청, 파일 읽기, 타이머 등 시간이 걸리는 작업을 효율적으로 처리하려면 비동기 프로그래밍을 제대로 이해해야 합니다.

동기 vs 비동기

동기 처리는 코드가 위에서 아래로 순차적으로 실행됩니다. 한 작업이 끝나야 다음 작업이 시작됩니다.

console.log('1');
console.log('2');
console.log('3');
// 출력: 1, 2, 3 (순서대로)

비동기 처리는 작업이 완료되기를 기다리지 않고 다음 코드를 실행합니다. 작업이 완료되면 나중에 결과를 처리합니다.

console.log('1');
setTimeout(() => console.log('2'), 1000);
console.log('3');
// 출력: 1, 3, 2 (2는 1초 후에)

콜백 함수의 문제점

초기 JavaScript에서는 콜백 함수로 비동기를 처리했습니다. 하지만 콜백이 중첩되면 코드가 복잡해집니다. 이를 "콜백 지옥"이라고 부릅니다.

getUser(userId, (user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0].id, (details) => {
      getProduct(details.productId, (product) => {
        console.log(product);
        // 점점 깊어지는 들여쓰기...
      });
    });
  });
});

이런 코드는 읽기 어렵고, 에러 처리도 복잡합니다. 각 단계마다 에러를 처리해야 하기 때문입니다.

Promise의 등장

Promise는 ES6에서 도입되었습니다. 비동기 작업의 결과를 나타내는 객체입니다.

const promise = new Promise((resolve, reject) => {
  // 비동기 작업 수행
  const success = true;

  if (success) {
    resolve('성공!');
  } else {
    reject(new Error('실패!'));
  }
});

promise
  .then(result => console.log(result))
  .catch(error => console.error(error));

Promise는 세 가지 상태를 가집니다. pending(대기), fulfilled(이행), rejected(거부)입니다. 한번 상태가 변경되면 다시 변경되지 않습니다.

Promise 체이닝

then 메서드는 새로운 Promise를 반환하므로 체이닝이 가능합니다.

fetch('/api/user')
  .then(response => response.json())
  .then(user => fetch(`/api/orders/${user.id}`))
  .then(response => response.json())
  .then(orders => console.log(orders))
  .catch(error => console.error('에러 발생:', error));

콜백 지옥보다 훨씬 깔끔합니다. 에러 처리도 마지막 catch에서 한 번에 할 수 있습니다.

유용한 Promise 메서드

// Promise.all - 모든 Promise가 완료될 때까지 대기
const [users, products] = await Promise.all([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/products').then(r => r.json())
]);

// Promise.race - 가장 먼저 완료되는 Promise 반환
const result = await Promise.race([
  fetch('/api/fast'),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error('타임아웃')), 5000)
  )
]);

// Promise.allSettled - 모든 결과를 배열로 반환 (성공/실패 모두)
const results = await Promise.allSettled([
  fetch('/api/a'),
  fetch('/api/b'),
  fetch('/api/c')
]);
// [{ status: 'fulfilled', value: ... }, { status: 'rejected', reason: ... }, ...]

async/await 문법

ES2017에서 도입된 async/await는 Promise를 더 간결하게 사용할 수 있게 해줍니다.

async function fetchUserData(userId) {
  try {
    const userResponse = await fetch(`/api/users/${userId}`);
    const user = await userResponse.json();

    const ordersResponse = await fetch(`/api/orders/${user.id}`);
    const orders = await ordersResponse.json();

    return { user, orders };
  } catch (error) {
    console.error('데이터 로딩 실패:', error);
    throw error;
  }
}

async 함수는 항상 Promise를 반환합니다. await는 Promise가 처리될 때까지 실행을 일시 중지합니다.

병렬 처리 주의사항

await를 순차적으로 사용하면 불필요하게 느려질 수 있습니다.

// 느림 - 순차 실행 (총 2초)
async function slow() {
  const a = await delay(1000);
  const b = await delay(1000);
  return [a, b];
}

// 빠름 - 병렬 실행 (총 1초)
async function fast() {
  const [a, b] = await Promise.all([
    delay(1000),
    delay(1000)
  ]);
  return [a, b];
}

서로 의존성이 없는 작업은 Promise.all로 병렬 처리하는 것이 좋습니다.

실전 패턴

재시도 로직

async function fetchWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error('요청 실패');
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await delay(1000 * (i + 1)); // 점진적 대기
    }
  }
}

타임아웃 처리

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 });
    return await response.json();
  } finally {
    clearTimeout(timeoutId);
  }
}

마무리

비동기 처리는 JavaScript 개발에서 피할 수 없는 부분입니다. Promise와 async/await를 제대로 이해하면 복잡한 비동기 로직도 깔끔하게 작성할 수 있습니다.

핵심은 에러 처리를 빠뜨리지 않는 것과, 병렬로 처리할 수 있는 작업은 Promise.all을 활용하는 것입니다.