서버에 데이터를 요청하고 응답이 올 때까지 화면이 멈춰버린 경험이 있으신가요? 또는 반대로, 요청을 보내고도 페이지가 자연스럽게 동작하는 것을 당연하게 쓰고 있지는 않으신가요? 이 두 경험의 차이가 바로 동기와 비동기입니다.
JavaScript는 한 번에 하나의 작업만 처리하는 싱글 스레드 언어입니다. 그런데도 네트워크 요청을 보내는 동안 사용자 클릭을 받고, 타이머가 만료되면 콜백을 실행합니다. 어떻게 가능한 걸까요?
답은 JavaScript 엔진이 혼자 모든 일을 처리하지 않기 때문입니다. 시간이 걸리는 작업은 브라우저나 Node.js의 외부 API에 위임하고, 엔진은 나머지 코드를 계속 실행합니다. 위임한 작업이 끝나면 콜백이 큐에 올라오고, 엔진이 한가해지면 그것을 처리합니다.
동기(Synchronous) 방식은 코드가 순서대로 실행되며, 앞 작업이 끝나야 다음 줄로 넘어갑니다.
console.log("1번");
const result = heavyComputation(); // 끝날 때까지 대기
console.log("2번"); // 1번이 완전히 끝난 후 실행
비동기(Asynchronous) 방식은 시간이 걸리는 작업을 위임하고, 결과를 기다리지 않고 다음 코드를 실행합니다.
console.log("1번");
setTimeout(() => console.log("타이머 완료"), 1000);
console.log("2번"); // 타이머를 기다리지 않고 바로 실행
// 출력 순서: 1번 → 2번 → 타이머 완료
┌─────────────────────────────────────────────┐
│ JavaScript Engine │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ Call Stack │ │ Heap (memory) │ │
│ │ │ │ │ │
│ │ [fn calls] │ │ [objects] │ │
│ └─────────────┘ └──────────────────┘ │
└───────────────┬─────────────────────────────┘
│ delegate
┌───────────────▼─────────────────────────────┐
│ Browser / Node.js APIs │
│ (fetch, fs, setTimeout, DOM Events ...) │
└───────────────┬─────────────────────────────┘
│ on complete → enqueue callback
┌───────────────▼─────────────────────────────┐
│ Callback Queue │
└───────────────┬─────────────────────────────┘
│ when Call Stack is empty
┌───────────────▼─────────────────────────────┐
│ Event Loop │
│ (moves callbacks from Queue to Stack) │
└─────────────────────────────────────────────┘
Call Stack이 비어있을 때만 Event Loop가 큐의 콜백을 실행합니다. JavaScript가 "한 번에 하나"를 지키면서도 비동기를 처리할 수 있는 핵심 구조입니다.
| 상황 | 예시 |
|---|---|
| 네트워크 요청 | REST API 호출, fetch |
| 파일 I/O | Node.js에서 파일 읽기/쓰기 |
| 타이머 | setTimeout, setInterval |
| 사용자 이벤트 | 클릭, 키보드 입력 |
이 중 네트워크 요청과 파일 I/O처럼 외부 자원에 의존하는 작업이 비동기 처리의 주요 대상입니다.
I/O: Input/Output의 약자. 파일 읽기, 네트워크 통신 등 외부와 데이터를 주고받는 작업.
콜백은 가장 오래된 비동기 패턴입니다. "작업이 끝나면 이 함수를 실행해줘"라고 함수를 인자로 전달합니다.
function fetchUser(id, callback) {
setTimeout(() => {
callback(null, { id, name: "김철수" });
}, 1000);
}
fetchUser(1, (err, user) => {
if (err) return console.error(err);
console.log(user.name); // 김철수
});
중첩이 깊어지면 이른바 콜백 지옥이 발생합니다.
콜백 지옥(Callback Hell): 콜백 함수가 중첩되어 코드가 오른쪽으로 계속 들여쓰기 되는 패턴. "Pyramid of Doom"이라고도 합니다.
fetchUser(1, (err, user) => {
if (err) return handleError(err);
fetchOrders(user.id, (err, orders) => {
if (err) return handleError(err);
fetchProducts(orders[0].id, (err, product) => {
if (err) return handleError(err);
console.log(product.name); // 가독성 최악
});
});
});
ES6에서 도입된 Promise는 콜백 지옥 문제를 해결합니다. 비동기 작업의 결과를 나타내는 객체로, pending → fulfilled / rejected 세 가지 상태를 가집니다.
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id, name: "김철수" });
}, 1000);
});
}
fetchUser(1)
.then((user) => fetchOrders(user.id))
.then((orders) => fetchProducts(orders[0].id))
.then((product) => console.log(product.name))
.catch((err) => console.error(err)); // 에러 한 곳에서 처리
중첩 대신 .then 체이닝으로 흐름이 수평적으로 펼쳐져 가독성이 크게 개선됩니다.
ES2017에서 도입된 async/await는 Promise를 기반으로 하되, 동기 코드처럼 읽히도록 문법을 제공합니다.
async function loadProduct(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
const product = await fetchProducts(orders[0].id);
console.log(product.name);
} catch (err) {
console.error(err);
}
}
loadProduct(1);
await는 Promise가 완료될 때까지 해당 함수의 실행을 일시 정지하지만, Call Stack 자체를 블로킹하지는 않습니다. 내부적으로는 .then과 동일하게 동작합니다.
사용자 정보를 가져와서 이름을 출력하는 동일한 작업입니다.
콜백 방식
getUser(1, (err, user) => {
if (err) return console.error("오류:", err.message);
console.log("이름:", user.name);
});
Promise 방식
getUser(1)
.then((user) => console.log("이름:", user.name))
.catch((err) => console.error("오류:", err.message));
async/await 방식
async function printUserName() {
try {
const user = await getUser(1);
console.log("이름:", user.name);
} catch (err) {
console.error("오류:", err.message);
}
}
printUserName();
각 방식마다 에러를 처리하는 위치와 방법이 다릅니다.
콜백 — 첫 번째 인자로 에러를 전달하는 Node.js 관례(Error-first callback)를 사용합니다.
fs.readFile("data.txt", "utf8", (err, data) => {
if (err) {
console.error("파일 읽기 실패:", err.message);
return;
}
console.log(data);
});
Promise — .catch로 체인 전체의 에러를 한 번에 처리합니다.
fetch("/api/user")
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => console.error("요청 실패:", err.message));
async/await — 일반적인 try/catch 구문을 그대로 사용합니다.
async function loadData() {
try {
const res = await fetch("/api/user");
const data = await res.json();
console.log(data);
} catch (err) {
console.error("요청 실패:", err.message);
}
}
| 방식 | 장점 | 단점 |
|---|---|---|
| 콜백 | 추가 문법 없이 바로 사용 가능 | 중첩 시 가독성 급감, 에러 처리 분산 |
| Promise | 체이닝으로 흐름 명확, 에러 한 곳 처리 | 복잡한 흐름에서 여전히 장황 |
| async/await | 동기 코드처럼 읽힘, 디버깅 쉬움 | async 함수 안에서만 사용 가능 |
JavaScript의 비동기 패턴은 콜백에서 Promise로, Promise에서 async/await로 진화했지만 세 방식 모두 Event Loop라는 동일한 메커니즘 위에서 동작합니다. async/await가 가장 읽기 좋지만, Promise의 동작 원리를 이해해야 예상치 못한 버그를 피할 수 있습니다.