1. 왜 JavaScript는 비동기가 필요한가
단일 스레드라는 운명
JavaScript는 단일 스레드(single-threaded) 언어다. 모든 코드는 하나의 콜 스택(Call Stack)에서 순차적으로 실행된다. 한 작업이 끝나야만 그 다음 작업이 시작될 수 있다.
하지만 일상에서 브라우저를 사용하면서 크게 불편함을 느끼진 않는다. 유튜브 라이브 같이 잘 만들어진 웹 서비스를 경험할 때는 오히려 복잡한 컨텐츠들이 병렬적으로 로드되고 실행되는 동시에 사용자 인터랙션까지 무리없이 소화하는 것을 경험한다. 만약 JavaScript가 순수하게 동기적으로 동작한다면 이런 사용자 경험은 불가능할 것이다.
JavaScript는 단일 스레드라는 한계를 이벤트 루프(Event Loop)라는 우아한 메커니즘으로 해결한다.
이벤트 루프
JavaScript 런타임 환경은 크게 세 가지 구성 요소로 이루어진다.
콜 스택(Call Stack)
현재 실행 중인 함수들이 쌓이는 곳이다. 함수가 호출되면 스택에 쌓이고, 반환되면 사라진다.
Web API / Node.js API
브라우저 혹은 Node.js가 제공하는 기능들이다. setTimeout, fetch, DOM 이벤트 등이 여기에 속한다. 이 작업들은 JavaScript 엔진 바깥에서 실행된다.
태스크 큐(Task Queue) / 마이크로태스크 큐(Microtask Queue)
비동기 작업의 콜백들이 대기하는 곳이다.
이벤트 루프의 역할은 단순하다. 콜 스택이 비어 있으면, 큐에서 대기 중인 콜백을 꺼내 스택에 올린다.
이 단순한 메커니즘이 JavaScript의 논블로킹(non-blocking) 특성을 만들어낸다.
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// 출력: 1, 3, 2
setTimeout의 딜레이가 0ms라도, 콜백은 Web API를 거쳐 태스크 큐에 들어간다. 이벤트 루프는 현재 콜 스택이 완전히 비워진 뒤에야 큐에서 콜백을 꺼낸다. 그래서 3이 먼저 출력되고 2가 나중에 나온다.
마이크로태스크 큐는 특별하다
태스크 큐가 있다면 마이크로태스크 큐도 있다.
Promise의 .then(), queueMicrotask(), MutationObserver 콜백 등이 여기에 들어간다.
이벤트 루프의 규칙은 마이크로태스크 큐에 더 높은 우선순위를 준다. 마이크로태스크 큐를 완전히 비운 뒤에 태스크 큐로 넘어간다.
console.log('1');
setTimeout(() => console.log('setTimeout'), 0); // 태스크 큐
Promise.resolve().then(() => console.log('Promise')); // 마이크로태스크 큐
console.log('2');
// 출력: 1, 2, Promise, setTimeout
setTimeout이 먼저 등록되었어도, Promise의 .then()이 마이크로태스크 큐에 있기 때문에 먼저 실행된다.
이벤트 루프 예시
async function fetchData() {
console.log('A: 데이터 요청 시작');
const data = await fetch('/api/data');
console.log('C: 데이터 도착');
}
fetchData();
console.log('B: fetchData 밖의 코드');
1단계 — fetchData() 호출 콜 스택에 fetchData가 올라간다.
2단계 — console.log('A') 실행 A: 데이터 요청 시작 출력. 아직 평범한 동기 실행이다.
3단계 — await fetch('/api/data') 도달 fetch가 호출되면서 네트워크 요청이 브라우저 Web API 영역으로 넘어간다. 그 순간 fetchData 함수는 콜 스택에서 내려와 대기 상태가 된다. 콜 스택이 비워진다.
4단계 — console.log('B') 실행 콜 스택이 비어있으니 이벤트 루프가 다음 코드를 실행한다. B: fetchData 밖의 코드 출력.
5단계 — 콜 스택이 다시 비워짐 이제 실행할 동기 코드가 더 없다. 이벤트 루프는 계속 돌면서 큐를 감시한다.
6단계 — 네트워크 응답 도착 브라우저 Web API가 응답을 받고, fetchData의 나머지 실행(await 이후 부분)을 마이크로태스크 큐에 등록한다.
7단계 — fetchData 재개 이벤트 루프가 마이크로태스크 큐에서 꺼내 콜 스택에 올린다. await 다음 줄부터 이어서 실행된다.
8단계 — console.log('C') 실행 C: 데이터 도착 출력.
그림으로 요약하면 이렇다.
콜 스택 Web API 마이크로태스크 큐
───────── ───────── ────────────────
fetchData() →
log('A')
await fetch ──────→ 네트워크 요청
(대기중...)
log('B') ← 콜 스택이 비어서 실행됨
응답 도착 ──→ fetchData 재개 등록
fetchData() ←──────────────────────────────────
log('C')
2. 콜백(Callback)
콜백 패턴의 본질
콜백은 "나중에 실행할 함수를 인자로 넘기는" 패턴이다.
JavaScript에서 함수는 일급 객체이기 때문에 변수에 담거나 인자로 전달할 수 있다. 이 특성이 비동기 콜백의 근간이다.
function fetchUser(userId, onSuccess, onError) {
setTimeout(() => {
if (userId === 1) {
onSuccess({ id: 1, name: 'Alice' });
} else {
onError(new Error('User not found'));
}
}, 1000);
}
fetchUser(
1,
(user) => console.log('사용자:', user.name),
(err) => console.error('오류:', err.message)
);
단순한 경우에는 콜백이 충분히 직관적이다. 문제는 연쇄적으로 작업이 이어질 때 발생한다.
콜백 지옥
콜백 지옥은 단순히 코드가 못생겨지는 문제가 아니다. 더 근본적인 문제들이 있다.
getUserData(userId, function(user) {
getOrderHistory(user.id, function(orders) {
getProductDetails(orders[0].productId, function(product) {
getReviews(product.id, function(reviews) {
// 드디어 원하는 데이터가 여기에...
// 하지만 이미 화면 밖으로 벗어난 중괄호들
render(user, orders, product, reviews);
}, handleError);
}, handleError);
}, handleError);
}, handleError);
- 에러 처리가 파편화된다. 각 단계마다 에러 핸들러를 따로 달아야 하고, 한 곳에서 일관되게 처리하기 어렵다.
- 흐름을 읽기 어렵다. 코드는 왼쪽에서 오른쪽으로, 위에서 아래로 읽는 것이 자연스럽다. 콜백 중첩은 이 흐름을 비틀어 놓는다.
- 제어권이 역전된다(Inversion of Control). fetchData(callback)을 호출하는 순간, 내 콜백이 언제, 몇 번, 어떤 방식으로 호출될지의 제어권을 fetchData에 넘겨버린다. 서드파티 라이브러리라면 그 구현을 믿어야 한다. 콜백을 두 번 호출하거나 아예 호출하지 않아도 내가 막을 방법이 없다.
이 세 가지 문제, 특히 제어권 역전이 Promise가 등장한 핵심 이유다.
3. Promise
콜백은 "이 작업이 끝나면 이 함수를 실행해줘" 라고 상대방에게 부탁하는 방식이다.
내 함수를 상대방에게 넘기는 순간, 그걸 언제 실행할지는 상대방이 결정한다.
반면 Promise는 "작업이 끝나면 결과를 여기다 담아줘" 라고 말하는 방식이다.
상대방은 그냥 결과만 채워주면 되고, 그 결과를 가지고 무엇을 할지는 내가 결정할 수 있다.
Promise는 세 가지 상태를 가진다.
작업이 진행 중인 Pending,
성공적으로 완료된 Fulfilled,
실패한 Rejected다.
한번 Fulfilled나 Rejected 상태가 되면 다시는 변하지 않는다.
이 덕분에 콜백이 여러 번 호출되는 문제가 원천적으로 차단된다.
function fetchUser(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId === 1) {
resolve({ id: 1, name: 'Alice' });
} else {
reject(new Error('User not found'));
}
}, 1000);
});
}
fetchUser(1)
.then(user => console.log('사용자:', user.name))
.catch(err => console.error('오류:', err.message));
에러 처리가 한 곳으로 모였다. .catch()는 체인 어디에서든 발생한 에러를 잡아내기 때문이다.
체이닝: 비동기를 동기처럼 읽히게
Promise의 가장 강력한 특징은 체이닝이다. .then()은 항상 새로운 Promise를 반환한다. 그래서 다음과 같이 순차적인 비동기 로직을 마치 동기 코드처럼 읽히게 쓸 수 있다.
fetchUser(1)
.then(user => getOrderHistory(user.id)) // Promise 반환
.then(orders => getProductDetails(orders[0].productId)) // Promise 반환
.then(product => getReviews(product.id)) // Promise 반환
.then(reviews => render(reviews))
.catch(err => console.error('어디선가 에러 발생:', err));
콜백 지옥과 비교하면 가독성이 극적으로 향상된다. 인덴팅으로 인해 코드가 왼쪽으로 들어가지 않고, 에러는 단 하나의 .catch()에서 처리된다.
Promise.all과 동시성 제어
비동기의 진가는 여러 작업을 동시에 실행할 때 드러난다.
서로 의존성이 없는 세 개의 API를 동기적으로 호출하면 시간이 3배 걸린다.
Promise.all을 쓰면 세 개를 동시에 시작하고, 모두 완료되면 결과를 모아서 받는다.
// 나쁜 예: 순차 실행 (총 3초)
const user = await fetchUser(1); // 1초
const orders = await fetchOrders(1); // 1초
const settings = await fetchSettings(1); // 1초
// 좋은 예: 병렬 실행 (총 ~1초)
const [user, orders, settings] = await Promise.all([
fetchUser(1),
fetchOrders(1),
fetchSettings(1)
]);
Promise.allSettled는 모든 Promise가 이행되든 거부되든 끝날 때까지 기다리고, 각각의 결과(성공/실패)를 모두 돌려준다.
하나의 실패가 전체를 망치면 안 되는 상황에 유용하다.
Promise.race는 가장 빨리 완료되는 Promise의 결과를 반환한다. 타임아웃 구현에 자주 쓰인다.
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
);
return Promise.race([promise, timeout]);
}
Promise.any는 가장 먼저 성공하는 Promise를 반환한다.
여러 미러 서버 중 가장 빠른 응답을 쓰고 싶을 때 이상적이다.
4. async/await: 비동기 코드를 동기처럼
async/await은 Promise 위에 얹힌 문법 설탕(Syntactic Sugar)이다.
내부적으로는 여전히 Promise를 사용한다.
하지만 단순한 문법적 편의를 넘어, 비동기 코드를 읽고 추론하는 방식을 개선해준다.
async function loadDashboard(userId) {
try {
const user = await fetchUser(userId);
const [orders, settings] = await Promise.all([
fetchOrders(user.id),
fetchSettings(user.id)
]);
return { user, orders, settings };
} catch (err) {
console.error('대시보드 로딩 실패:', err);
throw err;
}
}
try/catch로 에러를 처리한다. 동기 코드와 완전히 동일한 방식이다.
await이 붙은 줄에서 실행이 일시 중지되고 Promise가 완료될 때까지 기다린다.
물론 이 "기다림"은 스레드를 블로킹하지 않는다.
이벤트 루프는 계속 돌아가고, 다른 작업들이 처리된다.
5. 코드를 잘 짜기 위한 원칙들
에러는 항상 명시적으로 처리하라
비동기 코드에서 에러를 삼켜버리는 것은 동기 코드에서보다 훨씬 위험하다. 어디서 실패했는지 추적이 어렵기 때문이다.
// 나쁜 예
async function doSomething() {
try {
await riskyOperation();
} catch (e) {
// 아무것도 안 함
}
}
// 좋은 예
async function doSomething() {
try {
await riskyOperation();
} catch (e) {
logger.error('riskyOperation 실패:', e);
throw e; // 또는 복구 로직
}
}
취소 가능한 비동기를 만들어라
React로 개발을 해봤다면 컴포넌트가 언마운트된 후 뒤늦게 반환된 API 응답이 상태 업데이트를 시도해 경고가 뜨는 것을 많이 경험해봤을 것이다. AbortController로 비동기 작업을 취소 가능하게 만들어야 한다.
function useUserData(userId) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort(); // 클린업 시 요청 취소
}, [userId]);
return user;
}
비동기 흐름을 명시적으로 모델링하라
복잡한 비동기 로직은 상태 머신(State Machine)으로 모델링하면 버그가 줄어든다. 로딩 중인지, 성공인지, 실패인지를 명확한 상태로 관리하면 "로딩 중인데 에러 메시지가 보인다"와 같은 불가능한 상태를 원천 차단할 수 있다.
// 불완전한 상태 관리 (isLoading과 error가 동시에 true일 수 있음)
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
// 명시적인 상태 (한번에 하나의 상태만 가능)
const [state, setState] = useState({ status: 'idle' });
// { status: 'loading' }
// { status: 'success', data: ... }
// { status: 'error', error: ... }
'Learning Log' 카테고리의 다른 글
| [멋사 클라우드 5기] Day 22 - SpringBoot 그리고 웹 서버 (0) | 2026.02.27 |
|---|---|
| [멋사 클라우드 5기] Day 21 - 제어의 역전(IoC)과 의존성 주입(DI) (0) | 2026.02.26 |
| [멋사 클라우드 5기] Day 19 - Object.assign과 structuredClone에 대하여 (0) | 2026.02.24 |
| [멋사 클라우드 5기] Day 18 - JavaScript 호이스팅 (0) | 2026.02.15 |
| [멋사 클라우드 5기] Day 17 - Flexbox (0) | 2026.02.13 |
