비동기 처리 패턴 완전 정리
음식 배달 앱의 진화
비동기 처리 패턴의 역사는 마치 음식 배달 서비스의 진화와 같습니다.
- 콜백 시대: 주문 후 전화를 계속 들고 기다림. 음식이 오면 전화로 알려주는 방식. 다른 일은 못 합니다.
- Promise 시대: 주문 후 진동벨을 받음. 진동벨을 들고 다른 일을 하다가 진동하면 픽업. 체인도 됩니다.
- async/await 시대: 주문 앱에서 주문하고, 알림을 기다리는 동안 다른 일 가능. 마치 동기적으로 작성한 것처럼 읽힙니다.
각 방식이 왜 등장했는지, 어떤 문제를 해결했는지를 이해하면 세 패턴 모두 자연스럽게 익힙니다.
1. 비동기가 필요한 이유
자바스크립트는 싱글 스레드입니다. 모든 작업을 동기로 처리하면 브라우저가 멈춥니다.
비유: 혼자 운영하는 편의점을 생각해보세요. 손님 A의 계산을 하다가 재고 확인(3초짜리 작업)을 기다리는 동안 뒤 손님들은 줄 서서 기다려야 합니다. 비동기는 “재고 확인은 창고 직원에게 맡기고, 그 사이에 다음 손님 계산을 합니다”입니다.
graph LR
subgraph "동기 처리 (나쁨)"
A1["코드 실행"] --> B1["네트워크 요청"]
B1 -->|"3초 대기"| C1["응답 받음"]
C1 --> D1["다음 코드"]
B1 -->|"블로킹"| FREEZE["UI 완전 멈춤"]
end
subgraph "비동기 처리 (좋음)"
A2["코드 실행"] --> B2["네트워크 요청 시작"]
B2 --> C2["다른 코드 실행"]
C2 --> D2["UI 응답 유지"]
B2 -->|"3초 후"| E2["응답 받아 처리"]
end
style FREEZE fill:#e74c3c,color:#fff
style D2 fill:#2ecc71,color:#fff
2. 콜백 패턴 — 가장 오래된 방식
콜백은 “나중에 실행할 함수를 미리 전달하는” 방식입니다. 단순하고 직관적이지만, 여러 작업을 순서대로 처리해야 할 때 심각한 문제가 생깁니다.
// 기본 콜백
function fetchUser(id, callback) {
setTimeout(() => {
if (id > 0) {
callback(null, { id, name: '홍길동' });
} else {
callback(new Error('유효하지 않은 ID'));
}
}, 1000);
}
fetchUser(1, (error, user) => {
if (error) {
console.error('오류:', error.message);
return;
}
console.log('유저:', user.name);
});
콜백 지옥 — 왜 이게 문제인가
여러 비동기 작업을 순서대로 처리해야 할 때 콜백을 중첩하면 이렇게 됩니다.
readFile('config.json', (err, config) => {
if (err) throw err;
connectDB(config.db, (err, db) => {
if (err) throw err;
db.query('SELECT * FROM users', (err, users) => {
if (err) throw err;
sendEmail(users[0].email, (err, result) => {
if (err) throw err;
logActivity(result, (err) => {
if (err) throw err;
console.log('완료!'); // 5단계 중첩
});
});
});
});
});
코드가 오른쪽으로 계속 밀려납니다. 이것이 콜백 지옥(Callback Hell)입니다. 문제점이 세 가지입니다.
- 가독성 저하: 코드가 피라미드 형태로 중첩됨
- 에러 처리 반복: 각 단계마다
if (err)체크 필요 - 흐름 제어 어려움: 중간에 작업을 건너뛰거나 반복하기 복잡
3. Promise — 콜백 지옥을 탈출하다
Promise는 “미래에 완료될 작업”을 나타내는 객체입니다. ES6에서 도입됐으며, 세 가지 상태를 가집니다.
stateDiagram-v2
[*] --> Pending: new Promise()
Pending --> Fulfilled: resolve(value)
Pending --> Rejected: reject(error)
Fulfilled --> [*]: .then()
Rejected --> [*]: .catch()
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);
return user;
})
.then(user => {
return fetchPosts(user.id); // 다른 비동기 작업 체이닝
})
.then(posts => {
console.log('게시물:', posts);
})
.catch(error => {
console.error('오류:', error.message); // 모든 에러를 한 곳에서 처리
})
.finally(() => {
console.log('항상 실행됨'); // 성공/실패 무관
});
콜백 지옥이 Promise 체인으로 어떻게 바뀌는지 보세요.
// 콜백 지옥 → Promise 체인으로 개선
readFile('config.json')
.then(config => connectDB(config.db))
.then(db => db.query('SELECT * FROM users'))
.then(users => sendEmail(users[0].email))
.then(result => logActivity(result))
.then(() => console.log('완료!'))
.catch(err => console.error('오류:', err)); // 에러 한 곳에서 처리
flowchart LR
RF["readFile()"] --> CB["connectDB()"]
CB --> Q["db.query()"]
Q --> SE["sendEmail()"]
SE --> LA["logActivity()"]
LA --> DONE["완료"]
RF -->|"에러 발생 시"| ERR["catch() — 한 곳에서 처리"]
CB --> ERR
Q --> ERR
SE --> ERR
style DONE fill:#2ecc71,color:#fff
style ERR fill:#e74c3c,color:#fff
4. Promise 고급 메서드 — 병렬 처리의 핵심
여러 비동기 작업을 어떻게 조합하느냐에 따라 실행 시간이 크게 달라집니다.
Promise.all() — 모두 병렬로, 하나라도 실패하면 전체 실패
const userPromise = fetchUser(1);
const postsPromise = fetchPosts(1);
const settingsPromise = fetchSettings(1);
// 세 개를 동시에 시작, 모두 완료되면 결과 반환
Promise.all([userPromise, postsPromise, settingsPromise])
.then(([user, posts, settings]) => {
console.log(user, posts, settings);
})
.catch(err => {
console.error('하나 이상 실패:', err);
});
Promise.allSettled() — 실패해도 모든 결과를 수집
// 실패해도 모든 결과 수집 (부분 성공 시 유용)
Promise.allSettled([userPromise, postsPromise, settingsPromise])
.then(results => {
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('성공:', result.value);
} else {
console.log('실패:', result.reason);
}
});
});
Promise.race() — 가장 먼저 완료된 것만
// 타임아웃 구현에 자주 사용
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('타임아웃')), 5000)
);
Promise.race([fetchData(), timeoutPromise])
.then(data => console.log(data))
.catch(err => console.error(err)); // 5초 안에 안 오면 타임아웃
Promise.any() — 하나라도 성공하면
// 여러 미러 서버 중 가장 빠른 것 사용
Promise.any([
fetchFromServer1(),
fetchFromServer2(),
fetchFromServer3()
])
.then(data => console.log('가장 빠른 서버의 데이터:', data))
.catch(err => console.error('모두 실패:', err));
graph TD
subgraph "1번 Promise.all — 모두 성공해야"
PA1["P1 성공"] --> PA_OK["성공"]
PA2["P2 성공"] --> PA_OK
PA3["P3 실패"] --> PA_FAIL["실패"]
end
subgraph "2번 Promise.allSettled — 모두 기다림"
PAS1["P1 성공"] --> PAS_ALL["모든 결과"]
PAS2["P2 실패"] --> PAS_ALL
PAS3["P3 성공"] --> PAS_ALL
end
subgraph "3번 Promise.race — 가장 빠른 것"
PR2["P2 (1초) — 먼저!"] --> PR_WIN["P2 결과"]
end
subgraph "4번 Promise.any — 첫 성공"
PAN2["P2 성공 — 첫 성공!"] --> PAN_WIN["P2 결과"]
end
style PA_FAIL fill:#e74c3c,color:#fff
style PR_WIN fill:#2ecc71,color:#fff
style PAN_WIN fill:#2ecc71,color:#fff
5. async/await — Promise를 동기처럼 작성하기
async/await는 새로운 비동기 메커니즘이 아닙니다. Promise 위에 얹은 문법적 설탕입니다. 훨씬 읽기 쉬운 코드를 작성할 수 있게 해줍니다.
비유: Promise 체인이 “1번 완료 후 2번 시작, 2번 완료 후 3번 시작”이라는 지시서라면, async/await는 그 지시서를 마치 동기 코드처럼 자연스럽게 읽히게 해주는 번역기입니다.
// Promise 체인
function loadUserData(userId) {
return fetchUser(userId)
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0].id))
.then(comments => ({ comments }));
}
// async/await — 같은 동작, 훨씬 읽기 쉬움
async function loadUserData(userId) {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
return { comments };
}
async 함수가 항상 Promise를 반환하는 이유
async function example() {
return 42; // 실제로는 Promise.resolve(42) 반환
}
example().then(console.log); // 42
async function failing() {
throw new Error('실패'); // Promise.reject(new Error('실패'))
}
failing().catch(console.error); // Error: 실패
async 함수 안에서 throw를 하면 자동으로 rejected Promise가 됩니다. 이 덕분에 try/catch로 비동기 에러를 처리할 수 있습니다.
에러 처리
async function fetchUserSafe(id) {
try {
const user = await fetchUser(id);
const posts = await fetchPosts(user.id);
return { user, posts };
} catch (error) {
console.error('데이터 로드 실패:', error.message);
return null;
} finally {
console.log('항상 실행');
}
}
// 개별 에러 처리 — 한 단계 실패해도 다음 단계 진행 가능
async function fetchWithPartialErrors(id) {
const user = await fetchUser(id).catch(err => {
console.error('유저 로드 실패:', err);
return null; // 기본값 반환
});
if (!user) return null;
const posts = await fetchPosts(user.id).catch(() => []); // 실패 시 빈 배열
return { user, posts };
}
6. 병렬 실행 최적화 — 순서가 중요하지 않으면 동시에 실행하세요
await를 남발하면 오히려 성능이 나빠집니다. 각 작업이 이전 결과에 의존하지 않는다면, 동시에 실행해야 합니다.
// 나쁜 예 — 순차 실행 (총 3초)
async function loadDataSequential() {
const user = await fetchUser(1); // 1초 대기
const posts = await fetchPosts(1); // 1초 대기
const settings = await fetchSettings(1); // 1초 대기
return { user, posts, settings };
}
// 좋은 예 — 병렬 실행 (총 1초, 가장 느린 작업 기준)
async function loadDataParallel() {
const [user, posts, settings] = await Promise.all([
fetchUser(1), // 동시 시작
fetchPosts(1), // 동시 시작
fetchSettings(1) // 동시 시작
]);
return { user, posts, settings };
}
gantt
title 순차 vs 병렬 실행
dateFormat X
axisFormat %Ls
section 순차 실행 (3초)
fetchUser :0, 1000
fetchPosts :1000, 2000
fetchSettings :2000, 3000
section 병렬 실행 (1초)
fetchUser :0, 1000
fetchPosts :0, 800
fetchSettings :0, 600
7. 실전 패턴 — 재시도 로직
네트워크는 불안정합니다. 한 번 실패했다고 바로 포기하지 말고, 몇 번 재시도하도록 만드세요.
async function fetchWithRetry(url, maxRetries = 3, delay = 1000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn(`시도 ${attempt}/${maxRetries} 실패:`, error.message);
if (attempt === maxRetries) {
throw new Error(`${maxRetries}번 모두 실패: ${error.message}`);
}
// 지수 백오프 — 1초, 2초, 4초... 점점 간격을 늘림
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, attempt - 1)));
}
}
}
지수 백오프(Exponential Backoff)를 쓰는 이유는, 서버가 과부하 상태일 때 짧은 간격으로 재시도를 계속하면 오히려 서버를 더 힘들게 만들기 때문입니다. 간격을 늘려가면 서버가 회복할 시간을 줍니다.
8. 실전 패턴 — 요청 취소
React에서 컴포넌트가 언마운트됐는데 비동기 작업이 완료되면 “언마운트된 컴포넌트에 setState”라는 경고가 납니다. AbortController로 해결합니다.
async function fetchWithCancel(url) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('요청이 취소됐습니다 (타임아웃)');
}
throw error;
}
}
// React에서 컴포넌트 언마운트 시 취소
function useAsync(asyncFn) {
useEffect(() => {
const controller = new AbortController();
asyncFn(controller.signal).catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort(); // 언마운트 시 자동 취소
}, []);
}
9. 실전 패턴 — 동시성 제어
100개의 API 요청을 동시에 보내면 서버가 과부하됩니다. 최대 N개씩 병렬로 실행하도록 제어합니다.
async function limitConcurrency(tasks, limit) {
const results = [];
const executing = [];
for (const task of tasks) {
const promise = task().then(result => {
executing.splice(executing.indexOf(promise), 1);
return result;
});
results.push(promise);
executing.push(promise);
if (executing.length >= limit) {
await Promise.race(executing); // 하나가 완료될 때까지 대기
}
}
return Promise.all(results);
}
// 100개 API 요청을 5개씩 병렬로
const tasks = urls.map(url => () => fetch(url).then(r => r.json()));
const results = await limitConcurrency(tasks, 5);
10. 에러 처리 전략 — 비동기 에러는 조용히 사라질 수 있다
비동기 코드에서 에러를 제대로 잡지 않으면 오류가 조용히 사라집니다. 항상 명시적으로 에러를 처리하세요.
// 에러 타입 분류
class ApiError extends Error {
constructor(message, status) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function apiRequest(url) {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(
`요청 실패: ${response.statusText}`,
response.status
);
}
return response.json();
}
// 에러 타입에 따른 다른 처리
async function handleRequest() {
try {
const data = await apiRequest('/api/data');
return data;
} catch (error) {
if (error instanceof ApiError) {
if (error.status === 401) {
redirectToLogin();
} else if (error.status === 404) {
return null; // 없는 것은 null 반환
}
}
throw error; // 기타 에러는 상위로 전파
}
}
정리
mindmap
root((비동기 처리))
콜백
가장 기본
콜백 지옥 문제
레거시 코드
Promise
then/catch 체인
all/race/allSettled/any
에러 처리 통합
async/await
동기처럼 작성
try/catch 사용
가장 권장
실전 패턴
재시도 로직
타임아웃/취소
동시성 제어
| 패턴 | 장점 | 단점 | 사용 시기 |
|---|---|---|---|
| 콜백 | 간단, 낮은 오버헤드 | 콜백 지옥, 에러 처리 어려움 | 이벤트 리스너, 레거시 |
| Promise | 체인 가능, 에러 통합 | 디버깅 스택 추적 어려움 | 여러 비동기 조합 |
| async/await | 가독성 최고, 디버깅 쉬움 | 최신 문법 필요 | 대부분의 경우 |
현대 자바스크립트에서는 async/await를 기본으로 사용하고, 병렬 처리가 필요한 경우 Promise.all과 조합하는 것이 가장 좋은 패턴입니다.
댓글