동기 vs 비동기 차이, 콜백 함수의 역할과 한계, 이벤트 루프·콜 스택·태스크 큐의 원리, 콜백 지옥 문제까지 비동기 프로그래밍의 기초 개념을 완전히 이해합니다.
🎯 학습 목표
이 강을 마치면 다음을 할 수 있습니다.
- 동기(synchronous)와 비동기(asynchronous)의 차이를 명확히 설명할 수 있습니다.
- 콜 스택, 이벤트 루프, 태스크 큐가 어떻게 협력하는지 이해합니다.
- 콜백 함수로 비동기 결과를 처리하고, 콜백 지옥의 문제를 인식합니다.
- 왜 Promise와 async/await가 등장했는지 맥락을 이해합니다.
⏳ 동기 vs 비동기
이 섹션에서는 자바스크립트가 코드를 실행하는 두 가지 방식의 근본적인 차이를 살펴보겠습니다.
동기(Synchronous)는 코드가 작성된 순서대로 한 줄씩 실행되는 것입니다. 앞 작업이 끝나야 다음 작업이 시작됩니다. 비동기(Asynchronous)는 어떤 작업을 시작해두고 끝나기를 기다리지 않고 다음 코드를 즉시 실행하는 것입니다. 작업이 완료되면 나중에 결과를 처리합니다.
왜 비동기가 필요할까요? 자바스크립트는 단일 스레드(Single Thread)로 동작합니다. 한 번에 하나의 작업만 처리할 수 있습니다. 서버에서 데이터를 받거나 파일을 읽는 작업은 수십~수백 밀리초가 걸릴 수 있습니다. 이 동안 동기적으로 기다리면 화면이 완전히 멈춥니다. 비동기 방식은 기다리는 동안 다른 코드를 실행하고, 작업이 완료되면 결과를 처리합니다.
// 동기 코드 — 순서대로 실행
console.log('1번');
console.log('2번'); // 반드시 1번 다음
console.log('3번'); // 반드시 2번 다음
// 출력: '1번' → '2번' → '3번'
// 비동기 코드 — 순서가 달라짐
console.log('1번');
setTimeout(() => {
console.log('2번 (타이머)');
}, 1000);
console.log('3번');
// 출력: '1번' → '3번' → (1초 후) '2번 (타이머)'
// setTimeout은 1초 동안 기다리지 않고 즉시 다음 줄로 넘어감!
// 동기 코드가 비동기보다 항상 먼저 실행됨
setTimeout(() => {
console.log('타이머 (delay 0)');
}, 0);
for (let i = 0; i < 1000; i++) { // 오래 걸리는 동기 작업
// ...
}
console.log('동기 작업 완료'); // 이것이 먼저 출력됨
// 그 후 '타이머 (delay 0)'
이 코드의 동작이 처음에는 직관에 반해 보일 수 있습니다. setTimeout(fn, 0)은 ‘즉시’가 아닌 ‘현재 실행 중인 코드가 모두 끝난 후 가장 빠른 시점’에 실행됩니다. 이를 이해하려면 이벤트 루프를 알아야 합니다.
🔄 이벤트 루프 — 비동기의 비밀
이 섹션에서는 자바스크립트 엔진이 동기 코드와 비동기 콜백을 어떻게 관리하는지 내부 원리를 살펴보겠습니다.
자바스크립트 런타임은 세 가지 핵심 요소로 구성됩니다. 콜 스택(Call Stack)은 현재 실행 중인 함수들의 목록입니다. 함수가 호출되면 추가되고 반환되면 제거됩니다. Web APIs(브라우저 환경) 또는 Node.js APIs(서버 환경)는 setTimeout, fetch, 이벤트 리스너 등 시간이 걸리는 작업을 처리합니다. 태스크 큐(Task Queue)는 Web API 작업이 완료되면 콜백 함수가 여기에 대기합니다.
// 이벤트 루프 실행 순서 추적
console.log('[1] 시작');
setTimeout(() => {
console.log('[3] 타이머'); // 태스크 큐
}, 0);
Promise.resolve().then(() => {
console.log('[2] Promise'); // 마이크로태스크 큐 — setTimeout보다 우선!
});
console.log('[4] 끝');
// 실제 출력 순서:
// '[1] 시작'
// '[4] 끝'
// '[2] Promise' ← 마이크로태스크 큐
// '[3] 타이머' ← 태스크 큐
이벤트 루프 동작: 콜 스택이 비면 마이크로태스크 큐를 모두 처리한 후, 태스크 큐에서 하나를 처리합니다. Promise의 .then 콜백은 마이크로태스크 큐에, setTimeout 콜백은 태스크 큐에 들어갑니다. 그래서 Promise.resolve().then()이 setTimeout(fn, 0)보다 항상 먼저 실행됩니다.
📞 콜백 함수로 비동기 처리
이 섹션에서는 비동기 작업 완료 후 결과를 처리하는 가장 기본적인 방법인 콜백 패턴을 살펴보겠습니다.
비동기 작업이 완료됐을 때 할 일을 미리 지정하는 방법이 콜백(Callback)입니다. ‘작업 끝나면 이 함수를 불러줘’라고 등록하는 것입니다. 18강에서 배운 이벤트 리스너도 일종의 콜백 패턴입니다.
// 콜백의 기본 원리
function fetchUser(userId, callback) {
setTimeout(() => {
const user = { id: userId, name: '홍길동', age: 28 };
callback(user); // 완료 후 콜백 호출
}, 1000);
}
fetchUser(1, (user) => {
console.log('사용자: ' + user.name); // 1초 후 실행
});
console.log('사용자 로딩 중...'); // 이게 먼저 출력됨
// 오류 처리를 포함한 콜백 패턴 (Node.js 스타일)
function readData(id, callback) {
setTimeout(() => {
if (id <= 0) {
callback(new Error('잘못된 ID'), null);
} else {
callback(null, { id, value: '데이터' });
}
}, 500);
}
readData(1, (err, data) => {
if (err) {
console.error('오류:', err.message);
return;
}
console.log('데이터:', data);
});
😱 콜백 지옥과 그 문제
이 섹션에서는 콜백이 중첩될 때 발생하는 콜백 지옥 문제와 이것이 왜 현대 패턴(Promise, async/await)으로 해결됐는지 살펴보겠습니다.
콜백 패턴은 직관적이지만, 비동기 작업이 여러 단계로 이어질 때 심각한 문제가 생깁니다. 각 단계가 이전 단계의 콜백 안에 중첩되어야 하므로 코드가 오른쪽으로 점점 밀려나는 콜백 지옥(Callback Hell)이 됩니다.
// 콜백 지옥 — 사용자 조회 → 주문 조회 → 상품 조회
getUser(userId, (userErr, user) => {
if (userErr) return handleError(userErr);
getOrders(user.id, (orderErr, orders) => {
if (orderErr) return handleError(orderErr);
getProduct(orders[0].productId, (productErr, product) => {
if (productErr) return handleError(productErr);
getReviews(product.id, (reviewErr, reviews) => {
if (reviewErr) return handleError(reviewErr);
// 드디어 원하는 데이터!
console.log(user, orders, product, reviews);
// 들여쓰기가 깊어져 읽기 어려움
});
});
});
});
// 콜백 지옥의 문제점:
// 1. 가독성: 코드가 오른쪽으로 밀림 (pyramid of doom)
// 2. 오류 처리: 각 단계마다 개별 처리 필요
// 3. 흐름 제어: 병렬 처리, 조건 분기 등이 어려움
콜백 지옥이 만들어내는 피라미드 구조는 코드의 가독성을 심각하게 떨어뜨립니다. 오류 처리를 매 단계마다 반복해야 하고 관리하기 어렵습니다. 이 문제를 해결하기 위해 Promise가 ES6에서 도입되었고, 더 나아가 Promise를 동기 코드처럼 작성할 수 있는 async/await가 ES2017에서 등장했습니다.
🧵 단일 스레드와 이벤트 루프의 협력
이 섹션에서는 자바스크립트의 단일 스레드 특성이 어떤 한계를 만들고, 비동기가 어떻게 이를 보완하는지 살펴보겠습니다.
자바스크립트 엔진 자체는 단일 스레드이지만, 웹 브라우저는 멀티 스레드 환경입니다. 브라우저의 Web API(타이머, 네트워크, DOM 이벤트)는 별도 스레드에서 처리됩니다. 자바스크립트는 이들이 완료되면 이벤트 루프를 통해 결과를 받아 처리합니다. 그러므로 자바스크립트는 단일 스레드임에도 불구하고 네트워크 요청이나 타이머 등 시간이 걸리는 작업을 효율적으로 처리할 수 있습니다.
// 단일 스레드의 한계 — CPU 집약적 작업이 화면을 멈춤
const start = Date.now();
while (Date.now() - start < 3000) { // 3초 블로킹
// 이 동안 클릭, 스크롤 등 모두 멈춤!
}
console.log('계산 완료');
// 해결: 작업을 나눠서 처리 (이벤트 루프에 양보)
function processChunk(data, index = 0) {
if (index >= data.length) {
console.log('완료');
return;
}
process(data[index]);
setTimeout(() => processChunk(data, index + 1), 0);
// 매 항목 처리 후 이벤트 루프에 제어권 반환
}
대부분의 웹 앱에서 성능 문제는 CPU가 아닌 네트워크 지연이 원인이며, 이는 비동기 프로그래밍으로 해결합니다. 정말 CPU 집약적 연산(대규모 데이터 처리, 암호화, 이미지 처리 등)은 Web Worker로 별도 스레드에서 실행해야 합니다.
📝 핵심 요약
23강에서 배운 내용을 정리해 보겠습니다.
| 개념 | 설명 |
|---|---|
| 동기 | 코드를 순서대로 실행, 앞이 끝나야 다음 실행 |
| 비동기 | 기다리지 않고 다음 코드 실행, 완료 시 콜백 실행 |
| 콜 스택 | 실행 중인 함수 목록, LIFO 구조 |
| Web API | 타이머·네트워크 등 브라우저가 별도 처리 |
| 태스크 큐 | 완료된 콜백 대기 장소, 콜 스택 비면 실행 |
| 마이크로태스크 큐 | Promise 콜백, 태스크보다 우선 처리 |
| 콜백 지옥 | 중첩 콜백으로 가독성·유지보수성 저하 |
다음 강인 24강 — Promise에서는 콜백 지옥을 해결하는 ES6의 핵심 기능을 배웁니다. Promise는 비동기 작업의 결과를 나타내는 객체로, .then과 .catch로 체이닝하면 콜백 지옥 없이 깔끔하게 비동기 흐름을 제어할 수 있습니다!
관련 주제
- 동기 비동기
- 콜 스택
- 이벤트 루프
- 태스크 큐
- 콜백 지옥
- 단일 스레드
- 개발·프로그래밍
- 개발·프로그래밍 강의
- 자바스크립트 기초 30강 — 웹을 움직이는 언어
- 무료강의
- 무료 온라인 강의
- NUGUNA
- 누구나
📚 시리즈 전체 공유
자바스크립트 기초 30강 — 웹을 움직이는 언어
이 강의가 속한 시리즈는 총 30강, 모두 무료입니다. 처음부터 배우려는 동료에게 시리즈 전체를 알려 주세요.
댓글
불러오는 중...
