JavaScript를 몇 달 이상 써봤다면 깊이 중첩된 콜백과 얽혀 있는 .then() 체인의 고통을 느껴보셨을 겁니다. ES2017에서 도입된 async/await는 그 모든 문제를 해결했습니다 — 그런데도 개발자들은 여전히 같은 서너 가지 함정에 빠집니다. 작동 방식, 오류 처리 방법, 그리고 성능에 실제로 중요한 병렬 실행 패턴까지 제대로 살펴보겠습니다.

기초 — async 함수와 await

async 함수는 항상 Promise를 반환합니다. 함수 내부에서 await는 기다리는 Promise가 완료될 때까지 실행을 일시 중지합니다. 이것이 전체 정신 모델입니다:

js
async function fetchUserProfile(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const profile = await response.json();
  return profile; // wrapped in a Promise automatically
}

// Calling an async function gives you a Promise
const profilePromise = fetchUserProfile(42);
profilePromise.then(profile => console.log(profile.name));

// Or use await at the call site
const profile = await fetchUserProfile(42);
console.log(profile.name);

await 키워드는 async 함수 내부에서만 사용할 수 있습니다 — 또는 ES 모듈의 최상위 레벨에서(이에 대해서는 나중에 다룹니다). 그 외의 곳에서 사용하면 구문 오류가 발생합니다.

오류 처리 — 올바른 방법과 잘못된 방법

여기서 대부분의 튜토리얼이 길을 잃습니다. 본능적으로 모든 것을 try/catch로 감싸고 끝냈다고 생각합니다. 동작은 하지만, 빈 catch 블록은 실제 버그를 숨기는 코드 냄새입니다:

js
// ❌ Don't do this — silent failure, impossible to debug
async function loadConfig() {
  try {
    const res = await fetch('/api/config');
    return await res.json();
  } catch (err) {
    // swallowed — you'll never know what broke
  }
}

// ✅ Do this — handle errors explicitly, return something meaningful
async function loadConfig() {
  try {
    const res = await fetch('/api/config');
    if (!res.ok) {
      throw new Error(`Config fetch failed: ${res.status} ${res.statusText}`);
    }
    return await res.json();
  } catch (err) {
    console.error('loadConfig error:', err.message);
    return null; // caller can check for null
  }
}

저는 async 함수가 실패 시 null을 반환하거나 의도적인 오류를 던지는 패턴을 선호합니다. 피해야 할 것은 오류를 잡아 로그로 남긴 후, 호출자가 요청이 성공했다고 생각하게 만드는 값을 반환하는 것입니다.

팁: 하나의 try 블록에 여러 await가 있는 경우, 단일 catch가 모든 것을 처리합니다 — 하지만 어느 것이 실패했는지에 대한 맥락을 잃게 됩니다. 복잡한 흐름에서는 별도의 try/catch 블록을 사용하거나 [error, data] 튜플을 반환하는 await to(promise) 같은 헬퍼를 고려하세요.

순차 vs 병렬 — 성능 함정

이것이 프로덕션 코드에서 가장 자주 보이는 실수입니다. 각 호출을 차례로 await하면 완전히 독립적인 경우에도 순차적으로 실행됩니다:

js
// ❌ Sequential — takes ~900ms total (300 + 300 + 300)
async function loadDashboard(userId) {
  const user     = await fetchUser(userId);       // 300ms
  const orders   = await fetchOrders(userId);     // 300ms
  const settings = await fetchSettings(userId);  // 300ms
  return { user, orders, settings };
}

// ✅ Parallel with Promise.all — takes ~300ms total
async function loadDashboard(userId) {
  const [user, orders, settings] = await Promise.all([
    fetchUser(userId),
    fetchOrders(userId),
    fetchSettings(userId)
  ]);
  return { user, orders, settings };
}

Promise.all()은 세 요청을 동시에 실행하고 모두 완료될 때까지 기다립니다. 하나라도 거부되면 전체가 거부됩니다. 모든 데이터가 필요한 대시보드 스타일 로딩에서는 대개 원하는 동작입니다.

Promise.allSettled — 부분 실패가 허용될 때

여러 요청을 실행하고 일부가 실패해도 성공한 것만 사용하고 싶을 때가 있습니다. Promise.allSettled()는 정확히 그 목적으로 만들어졌습니다:

js
async function loadWidgets(widgetIds) {
  const results = await Promise.allSettled(
    widgetIds.map(id => fetchWidget(id))
  );

  const widgets = [];
  const errors  = [];

  for (const result of results) {
    if (result.status === 'fulfilled') {
      widgets.push(result.value);
    } else {
      errors.push(result.reason.message);
    }
  }

  if (errors.length > 0) {
    console.warn('Some widgets failed to load:', errors);
  }

  return widgets; // return whatever succeeded
}

이 패턴은 비핵심 UI 요소에 탁월합니다 — 여러 독립적인 섹션이 있는 사이드바 같은 경우. 하나가 실패해도 전체 페이지를 빈 화면으로 만들지 않고 나머지를 보여줍니다.

루프에서의 async — forEach 함정

이건 한 번쯤은 모두가 겪는 함정입니다. Array.forEach()는 async 콜백을 await하지 않습니다 — 실행하고 즉시 다음으로 넘어갑니다. 루프가 async 작업이 완료되기 전에 끝납니다:

js
const orderIds = [101, 102, 103, 104];

// ❌ forEach ignores async — all requests fire in parallel uncontrolled,
// and code after the forEach runs before any complete
orderIds.forEach(async (id) => {
  await processOrder(id); // NOT awaited by forEach
});
console.log('done?'); // prints before any order is processed

// ✅ for...of — sequential, fully awaited
for (const id of orderIds) {
  await processOrder(id);
}
console.log('done'); // prints after all orders are processed

// ✅ Parallel but controlled — all fire at once, await all completions
await Promise.all(orderIds.map(id => processOrder(id)));
console.log('done'); // prints after all orders are processed

순서가 중요하거나 요청을 조절해야 할 때(한 번에 하나씩 처리)는 for...of를 사용하세요. 최대 병렬성을 원하고 순차적 보장이 필요 없을 때는 Promise.all(map(...))을 사용하세요.

ES 모듈의 최상위 await

ES2022부터 ES 모듈의 최상위 레벨에서 await를 사용할 수 있습니다 — 래퍼 함수가 필요 없습니다. async 데이터에 의존하는 모듈 초기화에 큰 도움이 됩니다:

js
// config.js (ES module)
const response = await fetch('/api/runtime-config');
const config   = await response.json();

export const API_BASE_URL  = config.apiBaseUrl;
export const FEATURE_FLAGS = config.featureFlags;
js
// main.js — imports wait for config.js to fully resolve
import { API_BASE_URL, FEATURE_FLAGS } from './config.js';

console.log(API_BASE_URL); // guaranteed to be loaded

최상위 await는 package.json에 "type": "module"을 설정한 Node.js 14.8+와 네이티브 ES 모듈을 지원하는 모든 최신 브라우저에서 동작합니다. 가져오는 모듈의 실행은 await된 모듈이 완전히 해결될 때까지 일시 중지됩니다 — 바로 필요한 보장입니다.

실제 파이프라인 — 가져오기, 파싱, 변환

다음은 모든 것을 결합한 현실적인 async 파이프라인입니다: API에서 가져오기, HTTP 오류 처리, 데이터 변환, 그리고 실패 시 우아한 폴백:

js
async function getProductCatalog(categoryId) {
  // Step 1: fetch raw data
  const response = await fetch(
    `https://api.shop.example.com/categories/${categoryId}/products`,
    { headers: { Authorization: `Bearer ${getAuthToken()}` } }
  );

  if (!response.ok) {
    throw new Error(`Catalog fetch failed: ${response.status}`);
  }

  // Step 2: parse JSON
  const raw = await response.json();

  // Step 3: transform into the shape your UI needs
  const products = raw.items.map(item => ({
    id:       item.product_id,
    name:     item.display_name,
    price:    (item.price_cents / 100).toFixed(2),
    inStock:  item.inventory_count > 0,
    imageUrl: item.media?.[0]?.url ?? '/images/placeholder.png'
  }));

  // Step 4: filter out anything that's been discontinued
  return products.filter(p => !p.discontinued);
}

// Usage
try {
  const catalog = await getProductCatalog('electronics');
  renderProductGrid(catalog);
} catch (err) {
  showErrorBanner(`Could not load products: ${err.message}`);
}

유용한 도구

JSON 페이로드를 처리하는 async 코드를 디버깅할 때 이 도구들이 도움이 됩니다: JSON 포맷터로 API 응답을 검사하고, JSON 유효성 검사기로 코드에 도달하기 전에 잘못된 페이로드를 잡고, JS 포맷터로 async 함수 코드를 정리하세요. 전체 async/await 명세는 MDN async 가이드가 가장 철저한 참고 자료이며, TC39 명세에서 정확한 의미론을 확인할 수 있습니다.

마무리

async/await는 비동기 JavaScript를 읽기 쉽게 만들어 줍니다 — 하지만 함정은 실재합니다. 파싱 전에 항상 response.ok를 확인하고, catch 블록이 오류를 조용히 삼키지 않게 하고, 독립적인 병렬 호출에는 Promise.all()을 사용하고, async 콜백과 함께 forEach는 피하세요. 이 습관들을 몸에 익히면 async 코드가 빠르고 디버깅하기 쉬워질 것입니다.