JavaScript는 싱글 스레드 언어입니다. 하나의 작업이 끝나야 다음 작업을 시작할 수 있습니다. 그런데 네트워크 요청처럼 시간이 오래 걸리는 작업을 동기적으로 처리하면 브라우저가 완전히 멈춰버립니다. 이 문제를 해결하기 위해 비동기 프로그래밍이 존재합니다.
1단계: 콜백(Callback)
가장 초기 방식은 콜백 함수입니다. 작업이 완료됐을 때 호출할 함수를 미리 넘기는 방식입니다.
function fetchUser(id, callback) {
setTimeout(() => {
callback(null, { id, name: '홍길동' });
}, 1000);
}
fetchUser(1, (err, user) => {
if (err) return console.error(err);
console.log(user.name);
});
문제는 중첩이 깊어질수록 "콜백 지옥"이 발생한다는 점입니다.
// 콜백 지옥 — 읽기도, 관리하기도 어렵습니다
getUser(1, (err, user) => {
getPosts(user.id, (err, posts) => {
getComments(posts[0].id, (err, comments) => {
getLikes(comments[0].id, (err, likes) => {
console.log(likes); // 4단계 중첩...
});
});
});
});
2단계: Promise
Promise는 비동기 작업의 미래 결과를 나타내는 객체입니다. pending(대기), fulfilled(이행), rejected(거부) 세 가지 상태를 가집니다.
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, name: '홍길동' });
} else {
reject(new Error('잘못된 ID'));
}
}, 1000);
});
}
fetchUser(1)
.then(user => console.log(user.name)) // 성공
.catch(err => console.error(err)) // 실패
.finally(() => console.log('완료')); // 항상 실행
Promise를 체이닝하면 중첩 없이 순차 처리가 가능합니다.
getUser(1)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => getLikes(comments[0].id))
.then(likes => console.log(likes))
.catch(err => console.error(err));
병렬 처리: Promise.all & Promise.allSettled
// 모두 성공해야 함 — 하나라도 실패하면 catch
const [user, posts] = await Promise.all([
fetchUser(1),
fetchPosts(1)
]);
// 각각의 성공/실패를 모두 받음
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(999) // 실패해도 OK
]);
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log(result.value);
} else {
console.error(result.reason);
}
});
3단계: async/await
ES2017에서 도입된 async/await는 Promise를 동기 코드처럼 읽기 좋게 작성할 수 있게 해줍니다.
async function loadUserDashboard(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
return { user, posts, comments };
} catch (err) {
console.error('데이터 로드 실패:', err);
throw err;
}
}
// 최상위에서 async 함수 호출
loadUserDashboard(1).then(dashboard => {
renderDashboard(dashboard);
});
실전 패턴: API 요청 유틸리티
async function api(url, options = {}) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
// 사용
const user = await api('/api/users/1');
const newPost = await api('/api/posts', {
method: 'POST',
body: JSON.stringify({ title: '새 글', content: '...' })
});
이벤트 루프와 마이크로태스크 큐
JavaScript의 비동기 처리가 실제로 어떻게 동작하는지 이해하면 실수를 줄일 수 있습니다. 브라우저(또는 Node.js)의 이벤트 루프는 크게 두 종류의 비동기 작업을 처리합니다.
- 마이크로태스크 큐 — Promise의
.then,async/await,queueMicrotask가 여기 들어갑니다. 현재 콜 스택이 비면 즉시 실행됩니다. - 매크로태스크 큐(태스크 큐) —
setTimeout,setInterval, 이벤트 핸들러가 여기 들어갑니다. 마이크로태스크 큐가 완전히 비워진 다음에 실행됩니다.
그래서 Promise.resolve().then()은 setTimeout(fn, 0)보다 항상 먼저 실행됩니다. 이 차이가 복잡한 비동기 버그의 원인이 되기도 합니다.
AbortController — 요청 취소
컴포넌트가 언마운트되거나 사용자가 페이지를 이동할 때, 진행 중인 fetch 요청을 취소하지 않으면 불필요한 상태 업데이트나 메모리 누수가 발생할 수 있습니다.
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('요청이 타임아웃되었습니다');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
async/await 성능 주의: 직렬 vs 병렬
가장 많이 하는 실수 중 하나는 병렬로 처리할 수 있는 작업을 불필요하게 직렬로 실행하는 것입니다.
// 나쁜 예 — 순서가 없는데 직렬로 기다림 (2초 소요)
const user = await fetchUser(id); // 1초
const config = await fetchConfig(); // 1초 (user와 무관)
// 좋은 예 — 병렬 처리 (1초 소요)
const [user, config] = await Promise.all([
fetchUser(id),
fetchConfig()
]);
두 요청이 서로 의존하지 않는다면 항상 Promise.all로 병렬 처리하세요. 데이터가 클수록 성능 차이가 커집니다.
콜백 → Promise → async/await 순서로 발전했습니다. 현대 JavaScript에서는
async/await를 기본으로 사용하고, 서로 의존 관계가 없는 요청은 Promise.all로 병렬 처리하세요. 에러 처리는 반드시 try/catch로 감싸고, 긴 요청은 AbortController로 타임아웃을 설정하세요.
Promise 조합 메서드 완전 정리
| 메서드 | 동작 | 사용 시점 |
|---|---|---|
Promise.all | 모두 성공해야 resolve, 하나라도 실패 시 즉시 reject | 모든 결과가 필요할 때 |
Promise.allSettled | 모두 완료될 때까지 대기, 성공/실패 구분해서 반환 | 실패해도 나머지 결과가 필요할 때 |
Promise.race | 가장 먼저 완료된 것의 결과 반환 | 타임아웃 구현 |
Promise.any | 가장 먼저 성공한 것 반환, 모두 실패 시 AggregateError | 여러 폴백 중 첫 성공 사용 |
// Promise.race로 타임아웃 구현
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${ms}ms 타임아웃`)), ms)
);
return Promise.race([promise, timeout]);
}
// Promise.any — 여러 미러 서버 중 가장 빠른 응답 사용
const response = await Promise.any([
fetch('https://cdn1.example.com/data.json'),
fetch('https://cdn2.example.com/data.json'),
fetch('https://cdn3.example.com/data.json'),
]);
async Generator — 대용량 데이터 스트리밍
페이지네이션된 API에서 모든 데이터를 메모리에 올리지 않고 스트리밍으로 처리할 때 async generator가 유용합니다.
// async generator로 페이지네이션 API 스트리밍
async function* fetchAllPages(baseUrl) {
let cursor = null;
do {
const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
const data = await fetch(url).then(r => r.json());
yield* data.items; // 배열의 각 항목을 하나씩 yield
cursor = data.nextCursor;
} while (cursor);
}
// for-await-of로 메모리 효율적으로 처리
for await (const item of fetchAllPages('/api/products')) {
await processItem(item); // 한 번에 하나씩 처리
}
전통적인 방식으로 모든 페이지를 한 번에 불러오면 수천 개 항목이 메모리에 동시에 올라갑니다. async generator를 사용하면 한 번에 한 페이지씩만 처리하므로 메모리 사용량이 대폭 줄어듭니다.
커스텀 에러 클래스로 에러 분류하기
API 에러를 세분화하면 클라이언트에서 상황에 맞는 처리(재시도, 로그아웃, 알림 등)를 할 수 있습니다.
class ApiError extends Error {
constructor(message, status, code) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
async function apiFetch(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new ApiError(
data.message || `HTTP ${res.status}`,
res.status,
data.code
);
}
return res.json();
}
// 에러 타입에 따른 처리
try {
await apiFetch('/api/protected');
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 401) redirectToLogin();
if (err.status === 429) showRateLimitMessage();
if (err.status >= 500) showServerErrorMessage();
}
}
비동기 코드에서 자주 발생하는 실수와 예방법
비동기 프로그래밍에서 가장 흔한 실수는 에러를 삼키는 것입니다. async 함수 안에서 발생한 예외를 try-catch로 잡지 않으면 호출부로 전파되지 않고 사라지는 것처럼 보입니다. 실제로는 거부된 Promise(rejected Promise)가 되어 처리되지 않은 채 남습니다. Node.js에서는 process.on('unhandledRejection'), 브라우저에서는 window.addEventListener('unhandledrejection')으로 전역 에러 핸들러를 등록해두면 숨어 있던 에러를 잡아낼 수 있습니다.
또 다른 흔한 실수는 루프 안에서 await를 사용할 때 발생합니다. for문 안에서 await를 쓰면 각 요청이 순차적으로 처리되어 성능이 크게 떨어집니다. 독립적인 요청들은 Promise.all()로 병렬 처리해야 전체 실행 시간을 최소화할 수 있습니다. 단, 요청 수가 수백 개 이상이라면 서버나 외부 API에 과부하를 줄 수 있으므로, 청크로 나눠 Promise.all()을 여러 번 호출하거나 동시 실행 수를 제한하는 큐를 사용하는 것이 안전합니다. 비동기 코드의 실행 순서와 에러 전파 경로를 항상 의식하면서 작성하는 것이 안정적인 비동기 애플리케이션의 핵심입니다.