async/await란?
JavaScript는 싱글 스레드 언어입니다. 네트워크 요청, 파일 읽기 같은 시간이 걸리는 작업을 동기적으로 처리하면 화면이 멈추게 됩니다. 이 문제를 해결하는 것이 비동기 프로그래밍이고, async/await는 그 핵심 문법입니다.
async/await는 Promise 기반 비동기 코드를 동기 코드처럼 읽기 쉽게 작성할 수 있게 해줍니다. 이 글에서는 기본 사용법, 에러 핸들링, 병렬 실행, 그리고 실전에서 자주 만나는 패턴까지 다룹니다.
Promise에서 async/await로
async/await를 이해하려면 먼저 Promise를 알아야 합니다. async/await는 Promise의 **문법적 설탕(syntactic sugar)**으로, 내부적으로는 Promise와 동일하게 동작합니다.
// Promise 체이닝 방식
function fetchUserPromise(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(user => {
console.log(user.name); // "Alice"
return user;
});
}
// async/await 방식 (동일한 동작)
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
console.log(user.name); // "Alice"
return user;
}
Promise 체이닝은 .then()이 중첩될수록 코드 흐름을 파악하기 어렵습니다. async/await는 위에서 아래로 순차적으로 읽히므로 가독성이 크게 향상됩니다.
| 항목 | Promise 체이닝 | async/await |
|---|---|---|
| 가독성 | .then() 중첩으로 복잡해짐 | 순차적, 동기 코드와 유사 |
| 에러 처리 | .catch() 체이닝 | try/catch 블록 |
| 디버깅 | 스택 트레이스 불명확 | 일반 함수와 동일한 스택 트레이스 |
| 조건 분기 | .then() 안에서 분기 어려움 | if/else 자연스럽게 사용 |
기본 사용법
async 키워드를 함수 앞에 붙이면 해당 함수는 항상 Promise를 반환합니다. await는 async 함수 내부에서만 사용할 수 있으며, Promise가 처리(resolve)될 때까지 실행을 일시 중단합니다.
// async 함수는 항상 Promise를 반환
async function getMessage() {
return "안녕하세요"; // Promise.resolve("안녕하세요")와 동일
}
getMessage().then(msg => console.log(msg)); // "안녕하세요"
// await는 Promise의 결과값을 꺼내줌
async function loadData() {
console.log("요청 시작");
const response = await fetch("/api/data"); // 응답 올 때까지 대기
const data = await response.json(); // JSON 파싱까지 대기
console.log("데이터 수신 완료:", data.length, "건");
return data;
}
await는 해당 async 함수의 실행만 일시 중단할 뿐, 브라우저의 다른 작업(렌더링, 이벤트 처리)은 정상적으로 계속됩니다. 이것이 동기 코드와의 핵심적인 차이입니다.
에러 핸들링
비동기 코드에서 에러 처리는 가장 자주 실수하는 부분입니다. async/await는 try/catch를 사용하여 동기 코드와 동일한 패턴으로 에러를 처리할 수 있습니다.
// try/catch로 에러 처리
async function fetchData(url) {
try {
const response = await fetch(url);
// HTTP 에러 상태 확인 (fetch는 네트워크 에러만 reject함)
if (!response.ok) {
throw new Error(`HTTP 에러: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
// 네트워크 에러 + HTTP 에러 + JSON 파싱 에러 모두 처리
console.error("데이터 요청 실패:", error.message);
throw error; // 호출자에게 에러 전파
}
}
// 호출 시 에러 처리
try {
const data = await fetchData("https://api.example.com/users");
console.log("사용자 수:", data.length); // 사용자 수: 42
} catch (error) {
console.error("최종 에러:", error.message);
}
fetch()는 네트워크 에러(서버 미응답, DNS 실패 등)만 reject하고, 404나 500 같은 HTTP 에러 상태에서는 reject하지 않습니다. 따라서 response.ok를 반드시 확인해야 합니다.
병렬 실행: Promise.all과 Promise.allSettled
독립적인 비동기 작업 여러 개를 순차적으로 await하면 불필요하게 느려집니다. Promise.all()을 사용하면 모든 작업을 동시에 시작하고, 전부 완료될 때까지 기다릴 수 있습니다.
// 순차 실행: 총 2초 소요 (1초 + 1초)
async function sequential() {
const user = await fetchUser(1); // 1초 대기
const posts = await fetchPosts(1); // 1초 대기
return { user, posts };
}
// 병렬 실행: 총 1초 소요 (동시 시작)
async function parallel() {
const [user, posts] = await Promise.all([
fetchUser(1), // 동시 시작
fetchPosts(1), // 동시 시작
]);
return { user, posts };
}
Promise.all()은 하나라도 실패하면 즉시 reject됩니다. 일부 실패를 허용해야 한다면 Promise.allSettled()를 사용합니다.
// 일부 실패를 허용하는 병렬 실행
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(999), // 존재하지 않는 사용자 (실패 가능)
fetchUser(2),
]);
// 성공/실패를 개별적으로 처리
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`#${index} 성공:`, result.value.name);
} else {
console.log(`#${index} 실패:`, result.reason.message);
}
});
// #0 성공: Alice
// #1 실패: User not found
// #2 성공: Charlie
| 메서드 | 실패 시 동작 | 반환값 | 사용 시점 |
|---|---|---|---|
Promise.all | 하나라도 실패하면 즉시 reject | 결과 배열 | 모두 성공해야 의미 있을 때 |
Promise.allSettled | 모두 완료될 때까지 대기 | {status, value/reason} 배열 | 일부 실패를 허용할 때 |
Promise.race | 가장 먼저 완료된 것으로 결정 | 첫 번째 결과 | 타임아웃 구현 시 |
자주 하는 실수
반복문 안에서 await 사용: forEach 안에서 await를 쓰면 의도와 다르게 병렬로 실행되고, 완료를 기다리지 않습니다. 순차 실행이 필요하면 for...of를, 병렬이 필요하면 Promise.all + map을 사용합니다.
// 잘못됨: forEach는 await를 기다리지 않음
userIds.forEach(async (id) => {
const user = await fetchUser(id); // 완료를 보장하지 않음
});
// 순차 실행이 필요할 때: for...of
for (const id of userIds) {
const user = await fetchUser(id); // 하나씩 순서대로 처리
console.log(user.name);
}
// 병렬 실행이 필요할 때: Promise.all + map
const users = await Promise.all(
userIds.map(id => fetchUser(id)) // 모두 동시에 시작
);
이 실수는 실무에서 매우 흔하며, forEach가 async 콜백의 반환값(Promise)을 무시하기 때문에 발생합니다.
정리
async/await는 JavaScript 비동기 프로그래밍의 표준 패턴입니다. 핵심 포인트를 정리하면 다음과 같습니다.
async함수는 항상 Promise를 반환하고, 내부에서await사용 가능- 에러 처리는
try/catch로 동기 코드와 동일하게 처리 - 독립적인 작업은
Promise.all()로 병렬 실행하여 성능 개선 - 일부 실패 허용 시
Promise.allSettled()사용 - 반복문에서는
forEach대신for...of또는Promise.all+map사용 fetch()의 HTTP 에러는 자동 reject되지 않으므로response.ok확인 필수