JavaScript 비동기 — 콜백 지옥에서 async-await까지
서로 의존성이 없는 세 개의 API 요청을 순차적으로
await했더니 응답 시간이 3배가 됐습니다. 비동기 코드인데 왜 느려진 걸까요?
자바스크립트는 싱글 스레드이지만 논블로킹 I/O 모델로 동시에 수천 개의 연결을 처리할 수 있습니다. 핵심은 비동기 패턴을 올바르게 사용하는 것입니다.
왜 비동기가 필요한가
자바스크립트는 ** 싱글 스레드** 언어입니다. 콜 스택이 하나뿐이라 한 번에 하나의 작업만 실행할 수 있어요.
그런데 네트워크 요청이나 파일 읽기 같은 I/O 작업은 수백 밀리초에서 수 초까지 걸립니다. 이걸 동기로 처리하면? 그 시간 동안 브라우저 UI가 통째로 멈춰요. 버튼 클릭도 안 되고, 스크롤도 안 되고, 렌더링도 멈추는 최악의 UX가 됩니다.
그래서 자바스크립트 런타임(브라우저, Node.js)은 ** 논블로킹 I/O** 모델을 채택했습니다. 시간이 걸리는 작업을 런타임(Web API, libuv 등)에 위임하고, 콜 스택은 다음 코드를 바로 실행해요. 작업이 끝나면 콜백 함수를 태스크 큐에 넣어 이벤트 루프가 콜 스택이 비었을 때 꺼내 실행하는 구조입니다.
[콜 스택] → 비동기 작업 발생 → [Web API / libuv에 위임]
↓ 완료
[태스크 큐에 콜백 등록]
↓ 콜 스택 비면
[이벤트 루프가 콜백 실행]
이 구조 덕분에 싱글 스레드로도 수천 개의 동시 연결을 처리할 수 있습니다. Node.js가 I/O 집약적인 서버에서 강한 이유가 바로 이거예요.
콜백 패턴
가장 원시적인 비동기 처리 방식입니다. 함수의 인자로 "끝나면 실행해줘"라는 함수를 넘기는 거예요.
function getUser(id, callback) {
setTimeout(() => {
callback(null, { id, name: '김개발' });
}, 1000);
}
getUser(1, (err, user) => {
if (err) {
console.error(err);
return;
}
console.log(user);
});
단순한 경우에는 괜찮습니다. 문제는 비동기 작업이 연쇄적으로 이어질 때 발생해요.
콜백 지옥 (Callback Hell)
getUser(1, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetail(orders[0].id, (err, detail) => {
if (err) return handleError(err);
getProduct(detail.productId, (err, product) => {
if (err) return handleError(err);
console.log(product);
});
});
});
});
들여쓰기가 깊어지는 게 본질적인 문제가 아닙니다. 진짜 문제는 이래요.
- ** 에러 처리가 중복됩니다** — 매 콜백마다
if (err)체크를 반복해야 해요. - ** 흐름 제어가 힘듭니다** — 분기, 반복, 병렬 실행 같은 제어 흐름을 콜백으로 표현하면 코드가 금세 난해해져요.
- ** 에러가 삼켜집니다** — 콜백 안에서 던진 예외는
try/catch로 잡을 수 없어요. 비동기 콜백은 콜 스택이 이미 풀린 뒤에 실행되기 때문입니다.
try {
setTimeout(() => {
throw new Error('이 에러는 catch에서 안 잡힘');
}, 0);
} catch (e) {
// 여기 도달하지 않는다
}
Promise
ES2015(ES6)에서 도입된 Promise는 콜백의 문제를 구조적으로 해결합니다. "미래에 완료될 비동기 작업의 결과"를 객체로 표현하는 패턴이에요.
3가지 상태
| 상태 | 설명 |
|---|---|
| pending | 초기 상태. 아직 이행도 거부도 아닌 상태 |
| fulfilled | 작업이 성공적으로 완료됨 (resolve 호출) |
| rejected | 작업이 실패함 (reject 호출) |
한 번 fulfilled나 rejected가 되면 다시 바뀌지 않습니다. 이걸 settled 상태라고 해요.
const promise = new Promise((resolve, reject) => {
const data = fetchSomething();
if (data) {
resolve(data); // → fulfilled
} else {
reject(new Error('데이터 없음')); // → rejected
}
});
then / catch / finally 체이닝
Promise의 핵심은 체이닝입니다. then은 새로운 Promise를 반환하기 때문에 연속 호출이 가능해요.
getUser(1)
.then(user => getOrders(user.id))
.then(orders => getOrderDetail(orders[0].id))
.then(detail => getProduct(detail.productId))
.then(product => console.log(product))
.catch(err => console.error('어디서든 발생한 에러:', err))
.finally(() => console.log('무조건 실행'));
아까 콜백 지옥이었던 코드가 플랫하게 바뀌었습니다. catch 하나로 체인 전체의 에러를 잡을 수 있고, finally는 성공/실패와 무관하게 항상 실행돼요. 로딩 스피너를 끄는 로직 같은 걸 여기에 넣으면 됩니다.
** 주의할 점 **: then 안에서 Promise를 반환하지 않으면 체이닝이 끊깁니다.
// 잘못된 예 — getOrders의 결과가 다음 then으로 전달되지 않음
getUser(1)
.then(user => {
getOrders(user.id); // return 빠짐!
})
.then(orders => {
console.log(orders); // undefined
});
Promise 정적 메서드
Promise.all, allSettled, race, any는 어떻게 다른 걸까요? 핵심은 "실패 하나가 전체에 어떤 영향을 주느냐"입니다.
Promise.all
모든 Promise가 fulfilled되면 결과 배열을 반환합니다. ** 하나라도 rejected되면 즉시 rejected**돼요.
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
세 요청을 동시에 보내고, 전부 성공해야만 다음으로 넘어갑니다. 하나라도 실패하면 나머지 결과를 버려요(취소하는 건 아니고, 결과를 무시합니다).
Promise.allSettled
모든 Promise가 settled(fulfilled 또는 rejected)될 때까지 기다립니다. ** 절대 rejected되지 않아요.**
const results = await Promise.allSettled([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log(result.value);
} else {
console.error(result.reason);
}
});
"일부 실패해도 나머지 결과는 써야 할 때" 사용합니다. 대시보드에서 여러 위젯 데이터를 동시에 로드하는 상황이 전형적인 예시예요.
Promise.race
가장 먼저 settled된 Promise의 결과를 반환합니다. 나머지는 무시해요.
const result = await Promise.race([
fetch('/api/data'),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('타임아웃')), 5000)
),
]);
타임아웃 구현에 자주 쓰입니다. 위 예시에서 fetch가 5초 안에 응답하지 않으면 타임아웃 에러가 발생해요.
Promise.any
가장 먼저 fulfilled 된 Promise의 결과를 반환합니다. 모두 rejected되면 AggregateError를 던져요.
const fastest = await Promise.any([
fetch('https://cdn1.example.com/data'),
fetch('https://cdn2.example.com/data'),
fetch('https://cdn3.example.com/data'),
]);
여러 미러 서버 중 가장 빠른 응답을 사용할 때 유용합니다. race와 달리 rejected는 무시하고 fulfilled만 기다린다는 게 차이점이에요.
| 메서드 | 성공 조건 | 실패 조건 |
|---|---|---|
all | 전부 성공 | 하나라도 실패 |
allSettled | 항상 성공 | 없음 |
race | 첫 번째 settled | 첫 번째가 실패면 실패 |
any | 첫 번째 성공 | 전부 실패 |
async / await
ES2017에서 도입된 async/await은 Promise를 기반으로 동작하지만, 동기 코드처럼 읽히게 만들어줍니다. 문법적 설탕(syntactic sugar)이라고 부르지만, 가독성 개선 효과는 설탕 이상이에요.
async function getUserProduct(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const detail = await getOrderDetail(orders[0].id);
const product = await getProduct(detail.productId);
return product;
}
async 함수는 항상 Promise를 반환합니다. await은 Promise가 settled될 때까지 함수 실행을 일시 중단하고, fulfilled되면 값을 꺼내줘요.
try / catch로 에러 처리
async function getUserProduct(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
return orders;
} catch (err) {
// getUser 또는 getOrders에서 발생한 에러를 여기서 잡음
console.error('유저 정보 로드 실패:', err.message);
throw err; // 필요하면 다시 던져서 호출자에게 전파
}
}
콜백에서는 불가능했던 try/catch 에러 핸들링이 비동기 코드에서도 자연스럽게 동작합니다. 이게 async/await의 가장 큰 장점 중 하나예요.
병렬 실행 vs 순차 실행
이건 실수하기 정말 쉬운 부분입니다.
순차 실행 (느림)
async function loadData() {
const users = await fetchUsers(); // 1초
const posts = await fetchPosts(); // 1초
const comments = await fetchComments(); // 1초
// 총 3초
}
각 await이 이전 작업이 끝날 때까지 기다립니다. 세 요청 사이에 의존성이 없는데도 순차적으로 실행되니까 시간이 낭비돼요.
병렬 실행 (빠름)
async function loadData() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
// 총 1초 (가장 느린 요청 기준)
}
Promise.all로 감싸면 세 요청이 동시에 출발합니다. 결과는 전부 끝났을 때 한꺼번에 받아요.
흔한 실수 — Promise를 먼저 만들어두기
async function loadData() {
// Promise 객체를 먼저 생성 → 이 시점에 이미 요청이 시작됨
const usersPromise = fetchUsers();
const postsPromise = fetchPosts();
// await은 결과를 기다릴 뿐
const users = await usersPromise;
const posts = await postsPromise;
}
이 패턴도 사실상 병렬입니다. Promise는 생성 시점에 즉시 실행을 시작하기 때문이에요. 하지만 Promise.all을 쓰는 게 의도가 더 명확하고, 에러 처리도 깔끔합니다.
에러 처리 전략
개별 에러 처리 vs 전체 에러 처리
// 전체를 한 번에 — 간단하지만 어디서 터졌는지 모름
async function loadPage() {
try {
const user = await getUser();
const posts = await getPosts(user.id);
render(user, posts);
} catch (err) {
showErrorPage(err);
}
}
// 개별로 — 각 실패에 다른 대응 가능
async function loadPage() {
let user;
try {
user = await getUser();
} catch (err) {
return showLoginPrompt();
}
let posts;
try {
posts = await getPosts(user.id);
} catch (err) {
posts = []; // 포스트 실패해도 페이지는 보여주자
}
render(user, posts);
}
전역 에러 핸들링 — unhandledrejection
처리되지 않은 Promise 거부를 전역에서 잡을 수 있습니다.
// 브라우저
window.addEventListener('unhandledrejection', event => {
console.error('처리되지 않은 Promise 거부:', event.reason);
event.preventDefault(); // 기본 에러 로그 방지
// Sentry 같은 에러 트래킹 서비스로 전송
});
// Node.js
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
});
catch를 안 달아놓은 Promise가 rejected되면 이 이벤트가 발생합니다. 프로덕션에서는 이걸 에러 리포팅 서비스와 연결해두는 게 기본이에요.
fetch API
XMLHttpRequest를 대체하는 모던 HTTP 클라이언트입니다. Promise 기반이라 async/await과 궁합이 좋아요.
기본 사용법
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
// fetch는 네트워크 에러만 reject한다
// 404, 500 같은 HTTP 에러는 reject되지 않음!
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json(); // 이것도 Promise를 반환
}
**핵심 포인트 **: fetch는 네트워크 에러(서버 연결 불가 등)만 reject합니다. 404나 500 응답은 정상 응답으로 취급되므로 response.ok를 직접 체크해야 해요. axios 같은 라이브러리는 이걸 자동으로 해준다는 차이가 있습니다.
AbortController — 요청 취소
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('요청이 취소됨');
}
});
// 필요한 시점에 취소
controller.abort();
React에서 컴포넌트 언마운트 시 진행 중인 요청을 정리(cleanup)할 때 필수적입니다.
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
});
return () => controller.abort(); // cleanup
}, []);
타임아웃 구현
AbortSignal.timeout()을 쓰면 깔끔합니다. (비교적 최신 API)
// 모던 방식 (AbortSignal.timeout)
const response = await fetch('/api/data', {
signal: AbortSignal.timeout(5000), // 5초 타임아웃
});
// 호환성을 고려한 방식
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
return response;
} finally {
clearTimeout(id);
}
}
모듈 시스템 — CommonJS vs ESM
비동기 개념과 직접 연관되는 건 동적 import()인데, 그 전에 두 모듈 시스템의 차이를 짚고 넘어갈게요.
CommonJS (CJS)
// 내보내기
module.exports = { getUser, getOrders };
// 또는
exports.getUser = getUser;
// 가져오기
const { getUser, getOrders } = require('./user');
- Node.js의 기본 모듈 시스템입니다.
require는 ** 동기적으로** 동작합니다. 파일을 읽고 파싱하고 실행까지 끝나야 다음 줄로 넘어가요.- 런타임에 조건부 로드가 가능합니다 (
if (조건) require(...))
ESM (ES Modules)
// 내보내기
export function getUser(id) { /* ... */ }
export default class UserService { /* ... */ }
// 가져오기
import { getUser } from './user.js';
import UserService from './user.js';
- ES2015 표준 모듈 시스템입니다.
import/export는 정적 선언 이에요. 파일 최상단에만 쓸 수 있고, 빌드 타임에 의존성 그래프를 분석할 수 있어서 트리 셰이킹(tree shaking)이 가능합니다.- 브라우저에서
<script type="module">로 직접 사용 가능해요.
동적 import()
// 조건부 모듈 로드
const module = await import('./heavy-module.js');
module.doSomething();
// React에서 lazy loading
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
import()는 Promise를 반환합니다. 코드 스플리팅(code splitting)의 핵심이며, 초기 번들 크기를 줄이는 데 쓰여요. React의 React.lazy도 내부적으로 이걸 사용합니다.
| 구분 | CommonJS | ESM |
|---|---|---|
| 문법 | require / module.exports | import / export |
| 로딩 | 동기 | 비동기 (동적 import()) |
| 분석 | 런타임 | 정적 분석 가능 |
| 트리 셰이킹 | 불가 | 가능 |
| 브라우저 | 직접 불가 (번들러 필요) | 네이티브 지원 |
마이크로태스크 vs 매크로태스크
이벤트 루프는 태스크를 두 종류의 큐에서 가져옵니다.
- 매크로태스크(macrotask):
setTimeout,setInterval,setImmediate, I/O 콜백 - ** 마이크로태스크(microtask)**:
Promise.then/catch/finally,queueMicrotask,MutationObserver
** 실행 순서 : 콜 스택이 비면, 마이크로태스크 큐를 ** 전부 비울 때까지 실행합니다. 그 후 매크로태스크를 ** 하나** 실행해요. 그리고 다시 마이크로태스크 큐를 전부 비웁니다. 이걸 반복해요.
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 출력 순서: 1 → 4 → 3 → 2
왜 이렇게 되는지 단계별로 보면요:
console.log('1')— 동기, 바로 실행. ** 출력: 1**setTimeout— 콜백을 매크로태스크 큐에 등록Promise.resolve().then(...)— 콜백을 마이크로태스크 큐에 등록console.log('4')— 동기, 바로 실행. ** 출력: 4**- 콜 스택 비었음 → 마이크로태스크 큐 확인 →
console.log('3')실행. ** 출력: 3** - 마이크로태스크 큐 비었음 → 매크로태스크 큐에서 하나 꺼냄 →
console.log('2')실행. ** 출력: 2**
// 더 복잡한 예시
setTimeout(() => console.log('A'), 0);
Promise.resolve()
.then(() => {
console.log('B');
return Promise.resolve();
})
.then(() => console.log('C'));
Promise.resolve().then(() => console.log('D'));
console.log('E');
// 출력: E → B → D → C → A
이 예시에서 B가 D보다 먼저인 이유는 같은 마이크로태스크 큐에서 먼저 등록된 순서대로 실행되기 때문이고, C가 D보다 나중인 이유는 B의 then이 새로운 Promise를 반환하면서 C의 콜백이 한 턴 뒤에 등록되기 때문입니다.
Top-level await
ES2022부터 모듈의 최상위 스코프에서 await을 쓸 수 있습니다. 예전에는 async 함수 안에서만 가능했어요.
// config.js (ESM)
const response = await fetch('/api/config');
export const config = await response.json();
// main.js
import { config } from './config.js';
// config.js의 await이 끝나야 이 줄이 실행됨
console.log(config);
주의할 점이 있습니다. Top-level await은 해당 모듈을 import하는 모든 모듈의 실행을 블로킹해요. 남용하면 앱 초기 로딩이 느려질 수 있으니 설정 파일이나 초기화 코드 정도에만 제한적으로 쓰는 게 좋습니다.
for await...of
비동기 이터러블을 순회할 때 사용합니다. 스트리밍 데이터나 페이지네이션 처리에 유용해요.
async function* fetchPages(url) {
let nextUrl = url;
while (nextUrl) {
const response = await fetch(nextUrl);
const data = await response.json();
nextUrl = data.nextPage;
yield data.items;
}
}
// 사용
for await (const items of fetchPages('/api/posts?page=1')) {
items.forEach(item => console.log(item.title));
}
async function*은 비동기 제너레이터로, yield할 때마다 호출자가 값을 받아 처리할 수 있어요. for await...of는 각 yield된 Promise가 resolve될 때까지 기다렸다가 다음 반복으로 넘어갑니다.
Node.js에서 스트림을 읽을 때도 자주 써요.
import { createReadStream } from 'fs';
const stream = createReadStream('big-file.txt', { encoding: 'utf8' });
for await (const chunk of stream) {
process.stdout.write(chunk);
}
파생 개념
이 글에서 다룬 비동기 패턴은 여러 주제와 연결됩니다.
- 이벤트 루프 — 마이크로태스크/매크로태스크의 실행 주체입니다. 콜 스택, 태스크 큐, 렌더링 사이클까지 깊이 들어가면 별도의 글이 필요해요.
- React 상태 관리 —
useEffect의 cleanup에서AbortController로 요청을 취소하거나,Suspense와React.lazy로 코드 스플리팅을 구현할 때 비동기 패턴이 기반이 됩니다. - Node.js — 이벤트 루프 기반의 논블로킹 I/O 모델 자체가 비동기 패턴 위에 서있어요. 스트림, Worker Threads,
fs.promisesAPI 등 비동기 처리가 서버사이드에서도 핵심입니다.