JavaScript를 몇 달 이상 써봤다면 깊이 중첩된 콜백과 얽혀 있는 .then() 체인의 고통을 느껴보셨을 겁니다. ES2017에서 도입된 async/await는 그 모든 문제를 해결했습니다 — 그런데도 개발자들은 여전히 같은 서너 가지 함정에 빠집니다. 작동 방식, 오류 처리 방법, 그리고 성능에 실제로 중요한 병렬 실행 패턴까지 제대로 살펴보겠습니다.
기초 — async 함수와 await
async 함수는 항상 Promise를 반환합니다. 함수 내부에서 await는 기다리는 Promise가 완료될 때까지 실행을 일시 중지합니다. 이것이 전체 정신 모델입니다:
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 블록은 실제 버그를 숨기는 코드 냄새입니다:
// ❌ 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을 반환하거나 의도적인 오류를 던지는 패턴을 선호합니다. 피해야 할 것은 오류를 잡아 로그로 남긴 후, 호출자가 요청이 성공했다고 생각하게 만드는 값을 반환하는 것입니다.
[error, data] 튜플을 반환하는 await to(promise) 같은 헬퍼를 고려하세요.순차 vs 병렬 — 성능 함정
이것이 프로덕션 코드에서 가장 자주 보이는 실수입니다. 각 호출을 차례로 await하면 완전히 독립적인 경우에도 순차적으로 실행됩니다:
// ❌ 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()는 정확히 그 목적으로 만들어졌습니다:
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 작업이 완료되기 전에 끝납니다:
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 데이터에 의존하는 모듈 초기화에 큰 도움이 됩니다:
// 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;// 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 오류 처리, 데이터 변환, 그리고 실패 시 우아한 폴백:
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 코드가 빠르고 디버깅하기 쉬워질 것입니다.