자바스크립트의 기본 동작을 설명해보라고 하면 명확히 설명할 수 없었다
이젠 그러지 않기로 다짐했으니 다시 기본기부터 차근차근 정리해보겠다.
JavaScript를 쓰다 보면 비동기라는 단어를 피할 수 없다. setTimeout 하나만 써도 "왜 이게 나중에 실행되지?"라는 의문이 생기고, API 호출을 하면 Promise와 async/await를 마주하게 된다.
이 글에서는 비동기 처리가 왜 필요하고, 어떻게 동작하며, 어떤 방식으로 발전해왔는지를 실행 흐름 중심으로 정리한다.
동기 vs 비동기, 뭐가 다른 건데
동기(Synchronous) 처리는 단순하다. 함수가 콜 스택에 쌓이고, 위에서부터 하나씩 실행된다. 선입후출(LIFO). 앞의 작업이 끝나야 다음 작업이 시작된다.
문제는 시간이 오래 걸리는 작업이 있을 때다. 서버에서 데이터를 받아오는 데 3초가 걸린다면, 그 3초 동안 화면은 멈춘다. 사용자가 버튼을 눌러도 반응이 없다.
비동기(Asynchronous) 처리는 이 문제를 해결한다. 시간이 걸리는 작업을 브라우저의 Web API에 맡기고, JavaScript는 다음 코드를 먼저 실행한다.
setTimeout으로 보는 비동기 실행 흐름
가장 기본적인 예시로 setTimeout의 실행 과정을 따라가보자.
console.log("시작");
setTimeout(() => {
console.log("타이머 완료");
}, 0);
console.log("끝");
출력 결과:
시작
끝
타이머 완료
0초로 설정했는데도 "타이머 완료"가 마지막에 출력된다. 이유는 다음 흐름 때문이다:
1. console.log("시작") → 콜 스택에서 즉시 실행
2. setTimeout 호출 → 콜 스택에 올라감 → Web API에 타이머 등록 → 콜 스택에서 제거
3. console.log("끝") → 콜 스택에서 즉시 실행
4. 타이머 완료 → 콜백이 태스크 큐(Task Queue)에 들어감
5. 이벤트 루프가 콜 스택이 비었음을 감지 → 태스크 큐에서 콜백을 꺼내 실행
핵심은 Web API → 태스크 큐 → 이벤트 루프 → 콜 스택 순서다. setTimeout이 0초여도 이 흐름을 반드시 거치기 때문에, 동기 코드보다 늦게 실행된다.
콜백 패턴: 비동기의 시작
비동기 처리의 가장 기본적인 방법은 콜백(Callback)이다. 함수를 인자로 넘겨서 "이 작업 끝나면 이걸 실행해줘"라고 알려주는 방식.
function loadData(callback) {
setTimeout(() => {
callback("데이터 로드 완료");
}, 1000);
}
loadData((message) => {
console.log(message);
});
간단한 경우엔 문제없다. 하지만 비동기 작업이 연달아 이어지면?
setTimeout(() => {
console.log("1단계");
setTimeout(() => {
console.log("2단계");
setTimeout(() => {
console.log("3단계");
}, 1000);
}, 1000);
}, 1000);
이른바 콜백 헬(Callback Hell). depth가 3개만 들어가도 읽기가 힘들어진다. 함수를 분리하면 들여쓰기는 줄일 수 있지만, 실행 흐름을 따라가려면 여러 함수를 오가야 하는 건 마찬가지다.
Promise: 체이닝으로 해결하기
Promise는 비동기 작업의 상태를 객체로 관리하는 방법이다.
function delay(ms) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`${ms}ms 대기 완료`);
}, ms);
});
}
delay(1000).then((message) => {
console.log(message);
});
콜백을 넘기는 대신, delay()가 Promise 객체를 반환하고 .then()으로 결과를 받는다.
Promise의 3가지 상태
┌─ resolve() ──→ Fulfilled ──→ .then()
Pending ──┤
└─ reject() ──→ Rejected ──→ .catch()
- Pending: 아직 결과가 정해지지 않은 초기 상태
- Fulfilled: 작업 성공 (resolve 호출)
- Rejected: 작업 실패 (reject 호출)
.then() 체이닝
.then()의 콜백이 값을 return하면 다음 .then()으로 전달된다.
delay(1000)
.then((message) => {
return `처리됨: ${message}`;
})
.then((result) => {
console.log(result);
});
콜백 헬의 깊은 중첩 없이, 순차적으로 읽을 수 있다.
주의할 점: .then() 안에서 return을 빼먹으면 다음 .then()에 undefined가 전달된다. 체이닝할 때는 반드시 return을 확인하자.
async/await: 동기 코드처럼 쓰기
Promise의 .then() 체이닝도 충분히 깔끔하지만, async/await를 쓰면 정말 동기 코드처럼 읽힌다.
// .then() 방식
function run() {
delay(1000)
.then((message) => {
return `처리됨: ${message}`;
})
.then((result) => {
console.log(result);
});
}
// async/await 방식
async function run() {
const message = await delay(1000);
console.log(`처리됨: ${message}`);
}
코드 양이 확 줄고, 위에서 아래로 자연스럽게 읽힌다.
await는 block이 아니라 suspend
여기서 중요한 점이 있다. await가 "기다린다"고 해서 콜 스택을 차단(block)하는 건 아니다.
내부적으로는:
await를 만나면 함수 실행을 일시 중단(suspend)- await 뒤의 값이 Promise가 아니면
Promise.resolve()로 감싸짐 - Promise가 settle되면 나머지 코드가 마이크로태스크로 예약되어 실행
즉, await 동안 다른 이벤트나 동기 코드는 정상적으로 처리된다. UI가 멈추지 않는다.
async 함수의 반환값
async 함수는 항상 Promise를 반환한다. 그래서 return값을 사용하려면 호출하는 쪽에서도 await가 필요하다.
async function getData() {
return "결과값";
}
// getData()는 Promise<string>
async function main() {
const result = await getData();
console.log(result); // "결과값"
}
이벤트 루프와 실행 순서
여기까지 왔다면 마지막 퍼즐은 이벤트 루프(Event Loop)다.
이벤트 루프는 콜 스택이 비었을 때 대기열에서 작업을 꺼내오는 역할을 한다. 이때 대기열은 두 종류다:
| 큐 | 들어가는 것 | 우선순위 |
|---|---|---|
| 마이크로태스크 큐 | Promise .then(), queueMicrotask() |
높음 (먼저 처리) |
| 태스크 큐 (매크로태스크) | setTimeout, setInterval, DOM 이벤트 |
낮음 |
실행 순서 규칙
- 콜 스택의 동기 코드를 모두 실행
- 마이크로태스크 큐를 완전히 비울 때까지 처리
- 태스크 큐에서 하나 꺼내 실행
- 다시 2번으로 돌아감
정리
JavaScript 비동기 처리의 핵심을 한 줄로 요약하면:
콜 스택에서 빠져나간 작업이 Web API → 큐 → 이벤트 루프를 거쳐 다시 콜 스택으로 돌아오는 흐름
그리고 이 비동기를 코드로 다루는 방법이 콜백 → Promise → async/await 순서로 발전해왔다. 각각의 장단점을 이해하고 상황에 맞게 쓰는 것이 중요하다.
'코딩딩 > Javascript' 카테고리의 다른 글
| 날것 - 데이터 타입 (0) | 2026.03.11 |
|---|---|
| 날것 - 표현식과 문 (0) | 2026.03.11 |
| JavaScript 변수의 동작 원리: 메모리, 호이스팅, var/let/const (0) | 2026.03.08 |
| 자바스크립트에서 this와 화살표 함수의 컨텍스트 유지 (1) | 2024.10.02 |
| Web (0) | 2023.08.28 |