🟨 1-11. 비동기(Asynchronous)와 이벤트 루프(Event Loop) — 자바스크립트가 멈추지 않는 이유
1. 자바스크립트는 ‘단일 스레드(single-thread)’ 언어다
먼저 전제부터 잡고 가자.
자바스크립트는 기본적으로 한 번에 하나의 작업만 처리한다.
즉, CPU가 한 줄씩 순서대로 실행하는 “단일 스레드” 모델이다.
그렇다면 이런 의문이 생긴다.
“단일 스레드인데, 어떻게 동시에 여러 작업을 처리하지?”
예를 들어, 브라우저에서 이런 코드를 실행하면 어떻게 될까?
console.log("A");
setTimeout(() => console.log("B"), 1000);
console.log("C");
출력 결과는
A
C
B
가 된다.
즉, 1초를 기다리지 않고 다음 코드가 바로 실행된다.
이게 바로 비동기 처리(asynchronous execution) 의 핵심이다.
2. 동기(Synchronous) vs 비동기(Asynchronous)
- 동기: 코드가 위에서 아래로 차례대로 실행된다.
다음 줄은 이전 작업이 끝나야 실행된다. - 비동기: 어떤 작업은 나중에 완료되더라도, 일단 다른 코드가 계속 진행된다.
비유하자면,
- 동기: 편의점 계산대에서 한 사람씩 차례로 계산하는 구조
- 비동기: “계산 끝나면 문자 드릴게요” 하고 다음 손님을 받는 구조
즉, 자바스크립트는 작업을 기다리지 않고 다음 줄로 넘어간다.
3. 이벤트 루프(Event Loop)의 등장
이제 “어떻게 그게 가능한가?”를 보자.
자바스크립트는 실제로 다음과 같은 구성 요소로 작동한다.
- Call Stack (호출 스택): 현재 실행 중인 함수가 쌓이는 곳
- Web APIs (브라우저 제공 기능): setTimeout, fetch, DOM 이벤트 등
- Callback Queue (태스크 큐): 실행 대기 중인 콜백들이 모여 있는 곳
- Event Loop (이벤트 루프): 스택이 비면 큐에서 콜백을 하나씩 가져와 실행
즉, 자바스크립트 엔진 자체는 단일 스레드지만,
비동기 처리 기능은 브라우저나 Node.js의 백그라운드(Web APIs) 가 담당한다.
4. setTimeout이 실제로 동작하는 과정
아래 코드를 기준으로 단계별로 살펴보자.
console.log("1");
setTimeout(() => console.log("2"), 1000);
console.log("3");
실행 순서는 이렇게 된다.
1️⃣ console.log("1") → Call Stack 실행
2️⃣ setTimeout() 실행 → 타이머는 Web API 영역으로 보냄
3️⃣ Call Stack에는 남지 않고 바로 다음 코드 실행
4️⃣ 1초 뒤 콜백(() => console.log("2"))이 Callback Queue로 이동
5️⃣ Call Stack이 비면 Event Loop가 Queue의 콜백을 가져와 실행
결과:
1
3
2
즉, setTimeout은 자바스크립트가 아닌 브라우저가 담당한다.
자바스크립트는 타이머가 끝나면 그 콜백을 “나중에 실행할 목록”에만 넣어둔다.
5. 비동기 작업의 종류
브라우저나 Node.js가 제공하는 대표적인 비동기 API는 다음과 같다.
- setTimeout(), setInterval()
- fetch(), XMLHttpRequest()
- addEventListener() (이벤트 기반 콜백)
- Promise, async/await
이 중에서도 Promise 기반의 비동기 처리는 가장 현대적인 방식이다.
6. 콜백(callback) 지옥과 Promise의 등장
과거에는 비동기 작업이 콜백(callback)으로만 처리되었다.
setTimeout(() => {
console.log("1초 후 실행");
setTimeout(() => {
console.log("2초 후 실행");
setTimeout(() => {
console.log("3초 후 실행");
}, 1000);
}, 1000);
}, 1000);
결과는 의도대로 나오지만, 들여쓰기가 깊어지고 가독성이 나빠진다.
이걸 흔히 “콜백 지옥(callback hell)”이라고 부른다.
그래서 나온 해결책이 Promise 다.
7. Promise의 동작 원리
Promise는 “비동기 작업의 상태(state)”를 표현하는 객체다.
const promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("완료!"), 1000);
});
promise.then(result => console.log(result));
Promise는 세 가지 상태를 가진다.
- pending (대기 중)
- fulfilled (성공, resolve 호출됨)
- rejected (실패, reject 호출됨)
즉, Promise는 비동기 작업이 끝날 때까지 기다리면서,
성공 시 then, 실패 시 catch로 연결되는 체인을 만든다.
8. async / await — 비동기를 동기처럼
async/await는 Promise를 좀 더 읽기 쉬운 형태로 바꾼 문법이다.
async function fetchData() {
console.log("요청 시작");
const res = await fetch("https://jsonplaceholder.typicode.com/todos/1");
const data = await res.json();
console.log("데이터:", data);
console.log("요청 완료");
}
fetchData();
이 코드의 실행 순서는 겉보기엔 동기처럼 보이지만,
await 키워드가 Promise가 완료될 때까지 함수 내부 실행을 일시 중단시킨다.
이 덕분에 복잡한 .then().then() 체인 없이,
“읽히는 순서대로 동작하는 비동기 코드”를 작성할 수 있다.
9. 이벤트 루프의 우선순위 — 마이크로태스크 vs 매크로태스크
이제 좀 더 깊이 들어가 보자.
비동기 작업이 큐로 들어올 때는 두 가지 종류로 나뉜다.
- 매크로태스크(Macro Task): setTimeout, setInterval, setImmediate
- 마이크로태스크(Micro Task): Promise.then, queueMicrotask
이벤트 루프는 항상
“Call Stack이 비면 → 먼저 마이크로태스크 큐를 확인 → 그 다음 매크로태스크를 실행한다.”
즉, Promise는 setTimeout보다 우선 실행된다.
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
결과는 다음과 같다.
sync
promise
timeout
이 차이를 모르면,
“setTimeout(0)”을 써도 Promise가 먼저 실행되는 이유를 이해하기 어렵다.
10. 실무 예제 — API 호출 순서 제어
실제 프론트엔드에서는 API 응답을 기다리며 데이터를 그려야 하는 경우가 많다.
async function loadUser() {
const user = await fetch("/api/user").then(res => res.json());
const posts = await fetch(`/api/posts?user=${user.id}`).then(res => res.json());
console.log("유저와 게시글 로딩 완료:", { user, posts });
}
이렇게 await으로 순서를 제어하면,
의존성이 있는 비동기 요청을 깔끔하게 관리할 수 있다.
병렬 요청도 가능하다.
const [user, posts] = await Promise.all([
fetch("/api/user").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
]);
이 구조는 데이터 의존성이 없는 요청을 동시에 실행할 수 있어 성능상 이점이 크다.
11. 이벤트 루프를 시각적으로 이해하기
비동기 작업의 흐름을 간단히 그림으로 표현하면 다음과 같다.
┌──────────────────────────┐
│ Call Stack │ ← 현재 실행 중인 코드
└──────────▲───────────────┘
│
▼
┌──────────────────────────┐
│ Web APIs (Timer, Fetch) │ ← 비동기 작업 위탁
└──────────▲───────────────┘
│
▼
┌──────────────────────────┐
│ Callback / Microtask Queue│ ← 실행 대기 중 콜백
└──────────▲───────────────┘
│
▼
Event Loop (스택이 비면 큐에서 콜백 전달)
이 루프가 계속 돌면서 자바스크립트는 절대 멈추지 않는다.
12. 정리
개념 설명 비고
| 동기(Sync) | 한 줄씩 순서대로 실행 | 코드 직관적이지만 느림 |
| 비동기(Async) | 기다리지 않고 다음 코드 실행 | UI 렉 방지 |
| 이벤트 루프 | 스택과 큐를 이어주는 중개자 | 자바스크립트의 핵심 구조 |
| 매크로태스크 | setTimeout, setInterval 등 | 느린 작업 |
| 마이크로태스크 | Promise, async/await | 빠른 우선순위 |
| Promise | 비동기 상태 관리 객체 | then / catch |
| async/await | Promise 문법의 단순화 버전 | 가독성 최고 |
13. 마무리
비동기는 단순한 기능이 아니라,
자바스크립트가 UI를 멈추지 않고 작동하게 만드는 엔진의 핵심 원리다.
이 개념을 제대로 이해하면 다음과 같은 코드의 차이를 명확히 구분할 수 있다.
setTimeout(() => console.log("A"), 0);
Promise.resolve().then(() => console.log("B"));
console.log("C");
// 결과: C → B → A
이 한 줄 차이를 설명할 수 있다면,
당신은 자바스크립트 엔진을 이해하고 있는 것이다.
“비동기는 속도가 아니라 구조의 문제다.”
기다리지 않는 코드가 진짜 효율적인 코드다.
다음 편에서는
1-12. 모듈(Module) 시스템 — import/export로 구조적 자바스크립트 설계하기
를 통해 ES6 모듈의 내부 동작, CommonJS와의 차이, 그리고 실제 프로젝트 구조 설계까지 다뤄보자.