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을 활용하는 것입니다.