본문으로 건너뛰기

I/O · Event Loop

쇼핑몰에서 상품 목록을 불러오는 상황을 생각해 보자.

서버에서 상품 정보를 가져오는 데 3초가 걸리더라도, 우리는 그동안 메뉴를 누르거나 검색창에 글자를 입력할 수 있다.

그런데 JavaScript는 한 실행 흐름에서 코드를 하나씩 실행한다고 한다. 상품 정보를 기다리고 있는데 어떻게 다른 일을 할 수 있을까?

핵심은 “응답을 기다리는 일”과 “응답을 받아 코드를 실행하는 일”을 나누는 것이다.

이 글에서는 브라우저의 페이지 메인 스레드를 기준으로 I/O, Blocking·Non-blocking, Task·Microtask, Event Loop를 살펴본다. Node.js의 이벤트 루프 단계와 process.nextTick()은 범위에 포함하지 않는다.


1. I/O와 비동기 처리​

**I/O(Input/Output)**는 프로그램이 외부와 데이터를 주고받는 입출력 작업이다. 서버에 주문을 보내거나 상품 목록을 받는 네트워크 통신, 파일 읽기·쓰기가 해당한다. 배열 정렬 같은 CPU 계산과 구분하며, 이 글은 브라우저의 네트워크 I/O를 예로 든다.

작업 요청과 완료 처리의 분리​

카페 직원이 주문을 받은 뒤 음료가 완성될 때까지 계산대에서 기다린다고 생각해 보자.

첫 번째 손님의 음료가 완성되기 전까지는 다음 손님의 주문을 받을 수 없다.

반대로 다음처럼 일을 나누면 어떨까?

  1. 계산대 직원이 주문을 받는다.
  2. 음료 제조 담당자에게 주문을 전달한다.
  3. 계산대 직원은 다음 손님의 주문을 받는다.
  4. 음료가 완성되면 준비된 음료를 손님에게 전달한다.

브라우저의 비동기 처리도 이와 비슷하다.

JavaScript가 서버에 요청을 시작한 뒤, 응답이 올 때까지 같은 자리를 차지하며 기다릴 필요는 없다.

다만 카페 비유가 브라우저 내부 구조와 정확히 같지는 않다. 여기서는 요청과 결과 처리를 나눈다는 점에 집중하면 된다.

용어 설명: 동기와 비동기

동기는 호출한 작업의 결과를 현재 실행 흐름에서 받아 다음 처리를 이어가는 방식이다. 계산 함수가 합계를 반환하면 그 값으로 다음 줄을 실행하는 경우가 그렇다.

비동기는 작업을 요청한 뒤, 완료되었을 때 처리할 코드를 별도로 이어가는 방식이다. fetch()로 상품 목록을 요청하면 응답을 기다리는 동안 아래의 다른 코드를 실행할 수 있다.

비동기는 **“작업이 빨라진다”가 아니라 “완료를 기다리는 동안 다른 일을 진행할 수 있다”**는 의미로 이해하면 좋다.

fetch와 Promise 기반 응답 처리​

fetch()는 브라우저의 네트워크 요청 API이며, 나중에 받을 결과를 표현하는 Promise를 반환한다. Promise 자체가 별도 스레드에서 계산하거나 네트워크 요청을 수행하는 것은 아니다.

Promise 상태의미
Pending결과가 아직 정해지지 않음
Fulfilled값으로 이행됨
Rejected이유와 함께 거부됨

Fulfilled와 Rejected를 합쳐 **settled(완료된 상태)**라고 하며, 한 번 settled 상태가 되면 다시 바뀌지 않는다. .then()은 성공 후속 처리를, .catch()는 실패 후속 처리를 연결할 때 사용한다. (MDN — Promise)

상품 목록을 요청한 뒤 응답이 늦게 오는 상황을 생각해 보자. 목록은 아직 표시할 수 없지만, 사용자가 검색어를 입력하는 일까지 기다릴 필요는 없다.

아래에서 상품 불러오기 → 검색창에 ‘마우스’ 입력 → 상품 데이터 응답 보내기 순서로 조작해 보자. 가상 서버의 응답을 직접 보내기 전까지는 로딩 상태가 유지된다.

응답을 기다리는 동안에도 입력할 수 있을까?

상품을 불러온 뒤, 응답을 보내기 전에 검색창에 ‘마우스’를 입력해 보세요.

브라우저 · 상품 목록준비
현재 검색어: (없음)

상품 불러오기를 눌러 시작하세요.

가상 서버 · 응답 제어

요청 후 아래 버튼을 누를 때까지 응답을 보류합니다. 기다리는 시간을 직접 늘려 볼 수 있습니다.

실제 서버 통신 없이 Promise로 재현합니다. 버튼은 학습용 응답 제어 장치입니다.
① 상품을 요청하고 ② 기다리는 동안 검색어를 입력한 뒤 ③ 서버 응답을 보내 보세요.
실행 기록
  1. 아직 요청하지 않았습니다.
화면 동작을 코드로 연결하기

실제 API가 있을 때의 React 코드입니다. 위 체험은 fetch 대신 제어 가능한 Promise를 사용합니다. 강조된 부분은 마지막 실행 구간입니다.

// “상품 불러오기”를 누르면 실행하는 코드
setStatus("loading");

fetch("/api/products")
.then((response) => response.json())
.then((products) => {
setProducts(products);
setStatus("success");
})
.catch(() => setStatus("error"));

// 응답을 기다리지 않고 바로 실행됩니다.
console.log("요청 등록 완료");

검색 입력은 별도의 onChange 이벤트로 처리합니다. setStatus는 로딩·완료·오류 표시를, setProducts는 상품 목록을 갱신합니다.

실행 기록을 보면 요청 직후의 코드와 검색 입력 처리가 응답보다 먼저 진행된다. 응답을 기다리는 동안 JavaScript 실행 흐름을 붙잡아 두지 않기 때문이다. 상품 데이터가 준비되면 .then()의 후속 처리로 목록이 표시되고, 미리 입력한 검색어로 필터링된다.

처음부터 다시 시작해 네트워크 오류도 발생시켜 보자. 이번에는 .then()의 상품 표시 대신 .catch()의 오류 처리가 실행된다.

fetch()의 Promise는 응답 헤더를 받으면 Response로 이행되고, response.json()은 본문을 읽어 결과를 Promise로 반환한다. 위 체험은 두 단계를 한 번의 응답 버튼으로 진행한다. (MDN — fetch)

네트워크 오류와 HTTP 오류의 구분​

위 시뮬레이션의 실패 버튼은 네트워크 오류를 재현한다. 실제 fetch()에서는 서버가 404나 500을 반환했다고 해서 Promise가 자동으로 거부되지는 않는다. HTTP 응답을 받는 데 성공했으므로 Response를 받은 뒤 상태를 확인해야 한다.

function loadProducts() {
return fetch("/api/products").then((response) => {
if (!response.ok) {
throw new Error(`HTTP 오류: ${response.status}`);
}
return response.json();
});
}

loadProducts()
.then((products) => console.log("상품:", products))
.catch((error) => console.error("상품 조회 실패:", error.message));

이 .catch()는 네트워크 실패, 직접 던진 HTTP 오류, JSON 해석 실패를 처리할 수 있다. 요청 실패와 오류 상태의 응답은 다르다는 점이 핵심이다. (MDN — Fetch 사용과 응답 상태 확인)


2. Blocking과 Non-blocking​

두 용어는 다음 질문으로 구분하면 쉽다.

작업을 호출한 쪽이 기다리느라 다음 일을 못 하는가?

구분쉬운 설명
Blocking호출한 쪽이 결과를 기다리느라 다음 일을 진행하지 못한다.
Non-blocking호출한 쪽을 기다리게 붙잡아 두지 않아 다음 일을 진행할 수 있다.

동기·비동기와 Blocking·Non-blocking의 구분​

비슷하게 느껴지지만 구분하는 기준이 다르다.

  • 동기·비동기: 완료와 결과를 어떤 흐름으로 처리하는가?
  • Blocking·Non-blocking: 호출한 쪽이 기다리느라 멈추는가?

다만 모든 환경에서 두 쌍의 용어가 같은 뜻으로 쓰이지는 않는다. 예를 들어 어떤 Non-blocking I/O는 “지금은 데이터가 없다”고 즉시 반환하고, 호출자가 나중에 다시 확인해야 한다.

프론트엔드에서는 이를 사용자 경험과 연결해 설명하는 것이 중요하다. 비동기 요청을 보내는 코드는 빨리 반환하더라도, 응답 콜백에서 큰 데이터를 정렬하면 그 시간에는 메인 스레드가 점유된다. API가 Non-blocking이라는 사실과, 애플리케이션 전체가 반응성을 유지한다는 사실은 별개다.


3. JavaScript 실행 환경과 브라우저의 역할​

카페에서 계산 담당자와 제조 담당자의 역할이 나뉘듯, JavaScript 코드 실행과 브라우저의 기능도 구분할 수 있다.

역할하는 일
JavaScript 코드 실행숫자를 계산하고, 함수를 실행하고, DOM을 변경한다.
브라우저의 네트워크 기능서버와 데이터를 주고받는다.
브라우저의 타이머 기능지정한 시간이 지났는지 관리한다.

이 때문에 JavaScript가 한 번에 하나의 실행 흐름으로 동작한다고 해서 브라우저 전체가 하나의 일만 할 수 있는 것은 아니다. (MDN — JavaScript 실행 모델)

용어 설명: 스레드와 메인 스레드

스레드는 프로그램 안에서 명령을 실행하는 하나의 흐름이다.

한 명의 담당자가 자신에게 주어진 일을 순서대로 처리한다고 생각하면 된다.

메인 스레드는 웹 페이지의 JavaScript 실행, 사용자 입력 처리, 화면 업데이트에 필요한 여러 작업을 담당하는 주된 실행 흐름이다.

현재 실행의 완료와 후속 처리​

상품 정보를 기다리는 동안 JavaScript가 다른 함수를 실행 중이라고 생각해 보자.

그때 서버 응답이 도착해도 현재 함수를 임의로 끊고 응답 처리 코드를 실행하지는 않는다.

후속 처리는 정해진 실행 규칙에 따라 차례를 기다린다. 이 규칙을 이해할 때 등장하는 것이 Call Stack, Task, Microtask, Event Loop다.

이처럼 현재 실행 중인 동기 작업이 끝날 때까지 다른 JavaScript 작업이 끼어들지 않는 성질을 run-to-completion이라고 한다. 같은 실행 흐름의 상태를 다루기 쉬워지지만, 긴 반복문도 끝날 때까지 입력 처리를 막을 수 있다. “싱글 스레드라서 비동기가 불가능하다”가 아니라, 대기는 브라우저 기능에 맡기고 준비된 콜백은 같은 실행 흐름에서 순서대로 처리한다고 설명해야 한다. Worker는 별도의 실행 흐름을 가지므로 이 설명의 범위는 페이지 메인 스레드다. (MDN — 실행 모델)

용어 설명: Call Stack

Call Stack은 현재 어떤 함수를 실행 중인지 기록하는 공간이다. showPrice()가 calculatePrice()를 호출하면 두 함수의 실행 기록이 차례로 쌓인다. calculatePrice()가 끝나면 그 기록이 먼저 빠지고 showPrice()로 돌아온다. 쌓아 둔 접시에서 맨 위 접시를 먼저 꺼내는 것과 비슷하다.

용어 설명: 콜백

콜백은 다른 함수에 전달하여 그쪽에서 호출하도록 맡긴 함수다. 타이머에는 나중에 실행할 함수를 전달한다. 다만 콜백이 전부 비동기인 것은 아니다. 배열의 map()에 전달한 함수는 map()을 실행하는 동안 동기적으로 호출된다.


4. Event Loop와 Task·Microtask​

**Event Loop(이벤트 루프)**는 실행 가능한 Task 선택, Microtask 체크포인트, 렌더링 기회에 따른 처리를 조율하는 실행 흐름이다. Call Stack은 지금 실행 중인 호출을 기록하고, 큐는 나중에 실행할 작업을 보관한다. 이벤트 루프가 네트워크 데이터를 직접 가져오거나 JavaScript 콜백을 병렬로 실행하는 것은 아니다.

Task와 Microtask의 구분​

Task는 브라우저가 실행할 작업 단위이고, Microtask는 정해진 체크포인트에서 다음 Task보다 먼저 처리할 후속 작업이다. 두 종류의 차이는 이름이나 작업 크기가 아니라 언제 실행하고 어디까지 처리하는가에 있다.

구분TaskMicrotask
대표적인 예타이머 콜백, 브라우저가 전달하는 사용자 입력 이벤트Promise 후속 처리, await 이후 재개, queueMicrotask(), MutationObserver 알림
처리 방식실행 가능한 Task를 선택해 실행체크포인트에서 큐가 빌 때까지 실행
실행 중 추가된 작업뒤의 Task 실행 차례를 기다림같은 체크포인트에서 이어서 처리 가능
주의할 점오래 실행하면 입력과 렌더링을 지연시킴짧아 보여도 연쇄적으로 쌓이면 입력과 렌더링을 지연시킴

Task Queue는 실제로 여러 개일 수 있다. 따라서 타이머·네트워크·입력 이벤트를 모두 한 줄에 세우고 전역적인 FIFO 순서로 처리한다고 생각하면 부정확하다. 반면 Microtask Queue는 먼저 들어간 Microtask부터 꺼내 처리한다. 자료에서 Macrotask라는 이름을 볼 수 있지만, 이 글은 HTML 표준의 Task라는 표현을 사용한다. (WHATWG — Event loops)

더 보기: MutationObserver도 Microtask를 사용할까?

DOM 변경을 관찰하는 MutationObserver는 알림에 Microtask를 사용한다. DOM을 바꾼 바로 그 줄에서 관찰 콜백이 동기 실행되는 것은 아니다. 모든 Observer API가 같은 규칙을 따르지는 않으므로 API별로 확인해야 한다. (MDN — Microtask 가이드)

Microtask의 역할과 체크포인트​

Promise 결과를 처리할 때마다 다음 Task까지 기다린다면, 관련된 후속 처리가 다른 입력이나 작업 사이에 흩어질 수 있다. Microtask는 현재 동기 실행이 끝난 뒤 결과를 정리하되, 다음 Task로 넘어가기 전에 처리할 기회를 제공한다.

이 작업들을 처리하는 시점을 Microtask 체크포인트라고 한다. 기본적인 스크립트 예제는 다음 흐름으로 읽으면 된다.

  1. 현재 Task의 동기 코드를 실행한다.
  2. 체크포인트에서 Microtask Queue의 맨 앞 작업을 꺼내 실행한다.
  3. 큐에 작업이 남아 있다면 반복한다. 실행 도중 추가된 Microtask도 포함한다.
  4. 체크포인트가 끝나면 이벤트 루프의 다음 처리를 진행한다. 렌더링 기회가 있을 때 화면 업데이트가 이루어지고, 이후 다른 Task를 처리할 수 있다.

“Microtask의 우선순위가 높다”는 말은 현재 코드를 중단한다는 뜻이 아니다. 동기 함수가 아직 실행 중인데 Promise 콜백이 중간에 끼어들지는 않는다. 또한 모든 함수가 반환할 때마다 Microtask를 실행하는 것도 아니다. 호출자 코드가 남아 있다면 그 실행을 계속한다.

Task 종료는 대표적인 체크포인트다. 스크립트·콜백 실행 뒤의 정리 과정 등에도 체크포인트가 있으므로, “Task당 정확히 한 번”이라고 외우지 않는 것이 좋다. “Call Stack이 비었다”는 설명도 브라우저가 정한 체크포인트에서 처리한다는 조건과 함께 이해해야 한다. (WHATWG — Microtask 체크포인트)

Promise 콜백 등록과 실행 예약의 차이​

.then()을 썼다는 사실만으로 콜백이 즉시 Microtask Queue에 들어가는 것은 아니다. 대상 Promise의 결과가 준비되었는지를 먼저 확인해야 한다.

Promise 상태.then(onFulfilled, onRejected)를 호출하면
Pending후속 처리를 등록한다. 아직 성공 콜백을 실행할 수 없다.
Fulfilled성공 후속 처리를 Microtask로 예약한다.
Rejected등록된 실패 후속 처리를 Microtask로 예약한다.

위 표는 해당 콜백이 전달된 경우다. 콜백을 생략하면 값이나 거부 이유가 다음 Promise로 전달된다. Pending 상태에서 등록한 후속 처리는 Promise가 이행되거나 거부될 때 해당 결과에 맞춰 예약된다. .catch()도 Promise의 실패 후속 처리이며, .finally()도 Promise 체인의 후속 처리다. Promise 객체 자체나 네트워크 요청 전체가 큐에 들어가는 것은 아니다. (MDN — Promise.then)

new Promise()에 전달한 executor는 생성 순간 동기적으로 실행된다. 아래 코드에서는 executor가 resolve를 finish에 저장한다. .then()은 Pending Promise에 후속 처리를 등록하며, 결과가 정해질 때까지 실행을 기다린다.

다음 시뮬레이션에서 .then() 등록, finish() 호출, Microtask 실행을 차례로 확인해 보자.

Promise 등록과 실행0 / 6
  1. let finish;
  2. const result = new Promise((resolve) => {
  3. finish = resolve;
  4. });
  5. result.then(() => console.log("결과 처리"));
  6. console.log("동기 코드 A");
  7. finish();
  8. console.log("동기 코드 B");
실행 전

시작을 눌러 Promise 상태와 로그를 따라가세요.

대기 중비어 있음
누적 출력없음

코드의 실행 순서를 보여 주는 단계별 모형입니다. 버튼을 누를 때 실제 타이머나 Promise를 실행하지 않으며, 단계 사이의 화면 업데이트도 보장하지 않습니다.

finish()가 Promise를 이행해 후속 처리를 예약하지만, 현재 코드의 "동기 코드 B"를 먼저 출력한다.

더 보기: resolve를 호출하면 항상 fulfilled가 될까?

resolve("아이스 아메리카노")는 문자열로 이행시키지만, resolve(otherPromise)는 다른 Promise의 결과를 따르도록 한다. 따라서 다른 Promise가 Pending이면 resolve()를 호출했어도 결과를 계속 기다릴 수 있다. (MDN — Promise 상태)

따라서 “Promise는 타이머보다 무조건 먼저 실행된다”도 잘못된 일반화다. 아직 응답이 없는 fetch().then(...)과 준비된 타이머를 비교하면, 타이머가 먼저 실행될 수 있다. 우선 처리되는 것은 체크포인트에 이미 준비되어 있는 Microtask다.

중첩 Microtask와 큐의 변화​

실행 순서 문제는 코드의 위아래 위치보다 큐에 들어가는 시점을 추적하면 풀기 쉽다.

큐를 한 단계씩 따라가기0 / 5
실행 예제 · 현재 단계의 코드 강조
console.log("A");

setTimeout(() => console.log("T"), 0);

Promise.resolve().then(() => {
console.log("B");
queueMicrotask(() => console.log("D"));
});

queueMicrotask(() => console.log("C"));
console.log("E");
실행 전
지금 실행없음
Microtask Queue · 앞 → 뒤비어 있음
타이머 Task타이머 등록 전

시작을 눌러 왼쪽 코드의 실행 순서를 따라가세요.

누적 출력아직 없음

D는 B 안에서 만들어졌지만, 이미 기다리던 C를 앞지르지 않는다. 새 Microtask는 큐 뒤에 추가되며, 다음 Task로 넘어가기 전에 함께 처리된다. 시뮬레이션은 출력 순서에 집중한 것으로, 단계 사이에 화면이 그려진다는 뜻은 아니다. (MDN — Microtask 가이드)

Task 사이의 Microtask 체크포인트​

Microtask를 처음 한 번 비운 뒤 모든 Task를 연달아 실행하는 것은 아니다. 각 Task 뒤에도 체크포인트가 있다. 첫 번째 타이머 콜백이 Microtask를 등록하고 종료하면, 두 번째 타이머 Task를 시작하기 전에 그 Microtask를 처리한다. 단, 서로 다른 Task 출처까지 하나의 FIFO 줄로 묶는다는 뜻은 아니다.

Promise 체인의 실행 순서​

같은 원리로 연속된 .then()도 한꺼번에 실행되지 않는다.

Promise 체인의 실행 순서0 / 5
  1. Promise.resolve()
  2. .then(() => console.log("A"))
  3. .then(() => console.log("C"));
  4. queueMicrotask(() => console.log("B"));
  5. console.log("S");
실행 전

첫 번째 .then()과 B의 예약 시점을 비교해 보세요.

대기 중비어 있음
누적 출력없음

코드의 실행 순서를 보여 주는 단계별 모형입니다. 버튼을 누를 때 실제 타이머나 Promise를 실행하지 않으며, 단계 사이의 화면 업데이트도 보장하지 않습니다.

출력은 S → A → B → C다. 첫 .then()이 끝나야 두 번째 .then()의 C가 예약된다. 그때 B가 이미 큐에서 기다리고 있으므로 B를 먼저 실행한다.

이 예제의 콜백은 console.log()의 반환값인 undefined를 반환한다. 콜백이 다른 Promise나 thenable을 반환하면 그 결과를 받아들이는 추가 과정이 생길 수 있으므로, 모든 Promise 체인을 고정된 칸 수로 외우기보다 각 Promise가 언제 완료되는지를 추적해야 한다. (MDN — Promise.then의 반환값)

더 보기: queueMicrotask의 활용과 Promise.then과의 차이

queueMicrotask(fn)은 Promise 결과가 없어도 후속 작업을 예약할 때 사용한다. 같은 동기 실행에서 여러 번 바뀐 상태를 한 번에 정리할 수 있지만, 서로 다른 입력 Task의 변경까지 자동으로 합치지는 않는다.

Promise.resolve().then(fn)도 Microtask를 예약하지만 반환값과 오류 처리 방식이 다르다.

구분queueMicrotask(fn)Promise.resolve().then(fn)
호출 결과undefined새 Promise
fn이 예외를 던지면일반 예외로 보고됨반환된 Promise가 거부됨
후속 연결반환값으로 .catch() 연결 불가.then()·.catch() 연결 가능

둘 다 무거운 계산을 다른 스레드로 옮겨 주지는 않는다. queueMicrotask()는 짧은 후속 정리에 쓰고, 긴 계산은 6장에서 설명하는 작업 분할이나 Worker를 검토한다. (MDN — queueMicrotask, MDN — Microtask 사용)


5. 비동기 코드의 실행 순서​

타이머 Task와 Promise Microtask의 실행 순서​

상품 가격을 표시하면서 할인 가격과 안내 메시지를 처리한다고 가정해 보자.

타이머를 먼저 등록하고, 이미 준비된 Promise의 후속 처리를 나중에 등록해 보자. 두 콜백의 실행 순서를 직접 확인할 수 있다.

타이머 Task와 Promise Microtask0 / 5
  1. console.log("1. 상품 가격 표시");
  2. setTimeout(() => console.log("4. 장바구니 안내"), 0);
  3. Promise.resolve(8000).then((price) => {
  4. console.log(`3. 할인 가격 ${price}원 표시`);
  5. });
  6. console.log("2. 구매 버튼 준비");
실행 전

타이머를 먼저 등록해도 실행 순서는 달라질 수 있습니다.

대기 중비어 있음
누적 출력없음

코드의 실행 순서를 보여 주는 단계별 모형입니다. 버튼을 누를 때 실제 타이머나 Promise를 실행하지 않으며, 단계 사이의 화면 업데이트도 보장하지 않습니다.

여기서 Promise.resolve(8000)은 서버에 할인 가격을 요청하는 코드가 아니다. 이미 알고 있는 값 8000으로 성공 상태의 Promise를 만드는 코드다.

용어 설명: setTimeout의 0은 왜 즉시 실행이 아닐까?

setTimeout(callback, 0)은 “현재 실행을 멈추고 당장 콜백을 실행해 줘”라는 뜻이 아니다.

타이머 조건이 충족된 후, 해당 Task를 실행할 차례가 되었을 때 콜백이 실행된다.

그전에 현재 코드와 처리할 Microtask가 남아 있다면 기다려야 한다. 브라우저의 타이머 제한 때문에 추가로 늦어질 수도 있다.

따라서 0ms는 정확한 실행 시각을 보장하지 않는다. (MDN — setTimeout)

async/await의 실행 중단과 재개​

async 함수는 항상 Promise를 반환한다. 일반 값을 반환하면 그 값으로 이행되고, 처리하지 않은 예외를 던지면 반환한 Promise가 거부된다. await는 결과를 기다리도록 해당 함수의 나머지 실행을 미루고, 준비된 결과로 실행을 재개한다. 아래 예제는 async 함수 내부의 await를 다룬다.

서버 대신 타이머로 상품 정보가 약 1초 뒤에 준비되는 상황을 만든다. await 앞의 코드, 호출자의 코드, 타이머 콜백, 함수 재개를 차례로 살펴보자.

await에서 멈추고 재개하기0 / 5
  1. function getProduct() {
  2. return new Promise((resolve) => {
  3. setTimeout(() => resolve({ name: "무선 마우스", price: 25000 }), 1000);
  4. });
  5. }
  6. async function showProduct() {
  7. console.log("1. 상품 로딩 시작");
  8. const product = await getProduct();
  9. console.log(`3. 상품 표시: ${product.name}`);
  10. console.log(`4. 가격 표시: ${product.price}원`);
  11. }
  12. showProduct();
  13. console.log("2. 메뉴 준비");
실행 전

await가 함수의 어느 부분을 미루는지 살펴보세요.

대기 중비어 있음
누적 출력없음

코드의 실행 순서를 보여 주는 단계별 모형입니다. 버튼을 누를 때 실제 타이머나 Promise를 실행하지 않으며, 단계 사이의 화면 업데이트도 보장하지 않습니다.

상품을 기다리는 것은 showProduct()의 나머지 코드다. 메인 스레드 전체가 기다리는 것은 아니다. (MDN — await)

이미 이행된 Promise를 await해도 뒤의 코드는 Microtask로 재개된다. 또한 await getProduct()는 먼저 getProduct()를 호출해 반환값을 평가한다. 그 함수가 반환 전에 긴 계산을 한다면 await를 썼어도 그 계산은 동기적으로 메인 스레드를 점유한다. (MDN — await와 제어 흐름)

Promise의 반환값과 오류 전파​

.then()은 새 Promise를 반환한다. 콜백에서 일반 값을 반환하면 다음 .then()이 그 값을 받고, Promise를 반환하면 그 결과를 기다린다. 아무것도 반환하지 않으면 undefined를 전달한다. 따라서 response.json() 앞의 return을 빠뜨리면 다음 단계가 JSON 결과를 기다리지 못한다.

콜백이 예외를 던지면 반환된 Promise가 거부되어 뒤의 실패 처리로 전달된다. .catch()에서 정상 값을 반환하면 오류를 복구한 것으로 취급되어 다음 성공 처리로 이어진다.

Promise 오류와 복구0 / 3
  1. Promise.resolve()
  2. .then(() => { throw new Error("조회 실패"); })
  3. .catch(() => "대체 데이터")
  4. .then((value) => console.log(value));
실행 전

성공 상태의 Promise에서 후속 처리를 시작합니다.

대기 중비어 있음
누적 출력없음

코드의 실행 순서를 보여 주는 단계별 모형입니다. 버튼을 누를 때 실제 타이머나 Promise를 실행하지 않으며, 단계 사이의 화면 업데이트도 보장하지 않습니다.

복구할 수 없는 오류라면 .catch()에서 다시 던져 호출자가 실패를 알 수 있도록 해야 한다. .finally()는 성공·실패와 무관한 정리에 쓰며, 정상 종료하면 기존 결과를 유지한다. 다만 그 안에서 예외를 던지거나 거부된 Promise를 반환하면 체인이 실패할 수 있다. (MDN — Promise.then)

await한 Promise가 거부되면 해당 위치에서 예외가 발생하므로 그 await를 try/catch로 감싸 처리할 수 있다. await를 빼면 Promise 객체만 받으므로, 나중의 거부를 같은 동기 try/catch가 잡지 못한다. 타이머 콜백에서 나중에 던진 예외도 타이머 등록 코드를 감싼 try/catch로 잡을 수 없다. 오류가 발생하는 비동기 흐름에 처리기를 연결해야 한다. (MDN — async function)

독립적인 비동기 작업의 시작 시점​

두 요청이 서로의 결과를 필요로 하지 않는다면, 첫 번째 결과를 받은 뒤 두 번째를 시작할 필요는 없다.

// 순차 실행: 첫 요청이 끝나야 두 번째 요청을 시작한다.
const products = await loadProducts();
const categories = await loadCategories();
// 두 함수를 먼저 호출해 요청을 시작하고, 두 결과를 함께 기다린다.
const [products, categories] = await Promise.all([
loadProducts(),
loadCategories(),
]);

두 코드 조각은 각각 async 함수 안에서 사용하며, loadCategories()도 카테고리 요청의 Promise를 반환한다고 가정한다. 아래 방식은 대기 시간을 겹칠 수 있지만, Promise.all()이 JavaScript 계산을 여러 스레드에서 실행시키는 것은 아니다. 요청을 시작하는 것은 배열 안의 함수 호출이고, Promise.all()은 결과를 모은다.

결과 배열은 완료 순서가 아니라 입력 순서를 따른다. 하나가 거부되면 결합된 Promise도 거부되지만, 이미 진행 중인 나머지 요청을 자동으로 취소하지는 않는다. 따라서 작업 시작, 결과 대기, 실패 처리, 취소는 각각 구분해서 설계해야 한다. (MDN — Promise.all)


6. 메인 스레드 점유와 렌더링 지연​

I/O 대기와 CPU 연산의 차이​

다음 두 상황을 비교해 보자.

상황메인 스레드가 하는 일
서버 응답을 기다리는 중기다리는 동안 다른 작업을 처리할 수 있다.
아주 큰 배열을 정렬하는 중정렬 코드를 실행하느라 다른 작업이 지연될 수 있다.

async 함수 안에서 배열을 정렬해도 자동으로 다른 스레드에서 실행되지 않는다. 배열이 매우 커서 정렬에 오래 걸린다면, 그동안 클릭 처리나 화면 업데이트가 늦어질 수 있다.

DOM 변경과 렌더링 시점​

예를 들어 다음 코드를 작성했다고 하자.

아래에서 단계를 넘기며 코드가 바꾼 DOM 값과 마지막으로 그려진 화면을 비교해 보자.

DOM 값과 보이는 화면 비교0 / 4
실행 코드 · 현재 줄 강조
  1. loadingText.textContent = "분석 중입니다...";
  2. const result = analyzeLargeData(data);
  3. loadingText.textContent = "분석 완료!";
실행 전
코드가 바꾼 DOM 값시작 전
화면 모형 · 마지막으로 그려진 문구시작 전
메인 스레드: 대기

시작을 눌러 한 번의 동기 코드 실행을 따라가세요.

개념을 보여 주는 단계별 모형입니다. 버튼을 누를 때 실제 긴 계산을 실행하거나 브라우저의 페인트를 제어하지 않습니다. 실제 렌더링은 매 기회마다 보장되지 않습니다.

DOM은 HTML 문서를 코드에서 다루는 구조이고, 렌더링은 그 상태를 바탕으로 화면을 만드는 과정이다. DOM의 문구를 변경해도 사용자가 보는 화면이 그 순간 즉시 바뀌지는 않는다.

analyzeLargeData()가 메인 스레드를 오래 차지하는 동안에는 중간 문구를 그릴 기회가 없다. 계산 직후 DOM을 "분석 완료!"로 덮어쓰면, 사용자는 "분석 중입니다..."를 보지 못한 채 화면이 멈춘 것처럼 느낄 수 있다.

Microtask 연쇄 실행과 기아 상태​

Microtask는 실행 중 새 Microtask를 추가할 수 있다. 큐가 계속 채워지면 체크포인트가 끝나지 않아 다른 작업이 실행 기회를 얻지 못한다. 이를 **기아 상태(starvation)**라고 한다.

다음 예제는 반복 횟수를 세 번으로 제한했다. 매번 새 Microtask가 추가될 때 타이머 Task의 차례가 어떻게 밀리는지 확인해 보자.

Microtask가 계속 추가되면0 / 5
  1. let count = 0;
  2. function repeat() {
  3. count += 1;
  4. console.log(`microtask ${count}`);
  5. if (count < 3) queueMicrotask(repeat);
  6. }
  7. setTimeout(() => console.log("timer"), 0);
  8. queueMicrotask(repeat);
  9. console.log("sync");
실행 전

예제는 세 번만 반복하도록 제한했습니다.

대기 중비어 있음
누적 출력없음

코드의 실행 순서를 보여 주는 단계별 모형입니다. 버튼을 누를 때 실제 타이머나 Promise를 실행하지 않으며, 단계 사이의 화면 업데이트도 보장하지 않습니다.

종료 조건이 없거나 각 Microtask에 무거운 계산을 넣으면, 입력 처리와 렌더링도 지연될 수 있다.

따라서 await Promise.resolve()를 반복문에 넣는 것만으로는 화면을 갱신할 기회를 보장하지 못한다. 다음 반복이 또 Microtask로 재개되기 때문이다. async 함수의 실행을 잠시 미루는 것과, 브라우저가 다음 Task나 렌더링으로 넘어가도록 하는 것은 다르다. (MDN — Task와 Microtask의 처리 과정)

더 보기: requestAnimationFrame과 화면 업데이트

requestAnimationFrame()은 다음 리페인트 전에 실행할 콜백을 요청하는 API다. 화면에 맞춰 애니메이션 상태를 갱신할 때 유용하지만, Promise Microtask나 일반 타이머 Task와 같은 줄에 넣어 고정된 순서를 외우면 안 된다.

예를 들어 요소의 위치 변경을 렌더링 주기에 맞춰 요청할 수 있다. 콜백은 화면을 그린 뒤가 아니라 그리기 전에 실행되므로, 그 안에서 무거운 계산을 하면 해당 프레임의 표시도 늦어진다. 백그라운드 탭에서는 호출이 중단될 수 있어 일반적인 작업 완료 타이머로도 적합하지 않다. (MDN — requestAnimationFrame)

면접에서 setTimeout(..., 0)과 requestAnimationFrame() 중 무엇이 먼저인지 물으면, 실행 문맥과 렌더링 기회 없이 항상 같은 순서라고 단정하지 않는 것이 좋다. 타이머는 지연 조건 이후 Task 실행 기회를 기다리고, rAF는 렌더링 주기에 맞춰 실행한다.

작업 분할과 Web Worker 활용​

실무에서는 한 번의 계산량을 줄이는 것부터 시작한다. 큰 작업을 작은 덩어리로 나누고 타이머 등으로 다음 Task에 이어서 실행하면 다른 작업이 끼어들 기회가 생긴다. 다만 각 덩어리가 여전히 길거나, 타이머를 지나치게 자주 만들면 효과가 줄어든다. setTimeout(..., 0)도 매번 화면이 그려진다고 보장하지 않는다.

Web Worker는 메인 스레드와 별도 실행 흐름에서 JavaScript를 실행한다. DOM을 직접 다룰 수 없으므로 데이터 계산을 맡기고 메시지로 결과를 받아 메인 스레드에서 화면을 갱신한다. 이때 데이터 전달 비용도 고려해야 한다. “무조건 비동기로 감싸기”보다 I/O 대기인지, CPU 계산인지, 화면에 맞춰 실행할 작업인지를 먼저 구분해야 한다. (MDN — JavaScript 런타임 심화)


7. 프론트엔드 면접 대비 객관식 5문제​

브라우저 메인 스레드에서 실행한다고 가정한다. 각 문제를 먼저 풀고 정답과 해설을 펼쳐 확인해 보자. 특정 기업의 실제 기출로 확인된 문제가 아닌 학습용 예상 문제다.

문제 1. Task와 Microtask의 실행 순서​

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

출력 순서로 맞는 것은?

  • ① A → B → C → D
  • ② A → D → B → C
  • ③ A → D → C → B
  • ④ A → C → D → B
정답과 해설

③ A → D → C → B. A와 D는 현재 스크립트의 동기 코드다. 준비된 Promise의 후속 처리가 Microtask로 C를 출력한 뒤, 타이머 Task가 B를 출력한다. 0은 타이머 콜백을 즉시 실행하라는 뜻이 아니다. (4~5장)

문제 2. Promise 생성과 후속 처리​

console.log("A");
new Promise((resolve) => {
console.log("B");
resolve();
}).then(() => console.log("C"));
console.log("D");

출력 순서로 맞는 것은?

  • ① A → B → C → D
  • ② A → B → D → C
  • ③ A → D → B → C
  • ④ B → A → D → C
정답과 해설

② A → B → D → C. new Promise()에 전달한 executor는 생성 중 동기 실행되어 B를 출력한다. resolve()는 .then() 콜백을 현재 코드 사이에 끼워 넣지 않는다. D까지 실행한 뒤 Microtask에서 C를 출력한다. (4장)

문제 3. await가 미루는 실행​

async function show() {
console.log("A");
await Promise.resolve();
console.log("B");
}
show();
console.log("C");

출력 순서로 맞는 것은?

  • ① A → B → C
  • ② A → C → B
  • ③ C → A → B
  • ④ C → B → A
정답과 해설

② A → C → B. show()는 await 앞의 A를 동기적으로 출력한다. 이미 완료된 Promise라도 await 뒤의 B는 현재 실행과 분리되어 재개된다. 호출자의 C가 먼저 출력된다. await가 메인 스레드 전체를 멈추는 것은 아니다. (5장)

문제 4. fetch와 HTTP 오류​

fetch("/api/products")
.then((response) => response.json())
.catch(() => console.log("요청 실패"));

서버가 유효한 JSON 본문과 함께 HTTP 500 응답을 보냈다. 이 코드의 동작으로 맞는 것은?

  • ① fetch()가 자동으로 거부되어 .catch()가 실행된다.
  • ② response.json()이 성공하면 .catch()는 실행되지 않을 수 있다.
  • ③ 500 응답은 브라우저가 자동으로 다시 요청한다.
  • ④ response.json()은 HTTP 상태를 검사해 예외를 던진다.
정답과 해설

②. HTTP 오류 상태의 응답을 받았다는 이유만으로 fetch()의 Promise가 거부되지는 않는다. 본문도 정상 JSON이라면 해석이 성공한다. 서버 오류를 실패로 처리하려면 response.ok나 status를 확인해 직접 예외를 던져야 한다. (1장)

문제 5. DOM 변경과 렌더링​

loadingText.textContent = "분석 중";
await Promise.resolve();
const result = analyzeLargeData(data);
loadingText.textContent = "분석 완료";

위 코드는 async 함수 안에 있다. analyzeLargeData()가 오래 걸리는 동기 계산일 때 맞는 설명은?

  • ① await가 있으므로 “분석 중” 문구가 반드시 화면에 그려진다.
  • ② Promise.resolve()가 계산을 Worker로 옮긴다.
  • ③ await 이후는 Microtask로 재개될 수 있어, 중간 문구의 렌더링은 보장되지 않는다.
  • ④ DOM을 수정하면 브라우저가 그 줄에서 즉시 화면을 그린다.
정답과 해설

③. 이미 준비된 Promise를 기다린 뒤에도 함수의 나머지 실행은 Microtask로 재개된다. 그 뒤 긴 동기 계산이 메인 스레드를 차지하면 브라우저가 중간 문구를 그릴 기회를 얻지 못할 수 있다. await만 추가해 화면 표시를 보장할 수는 없다. (6장)


참고​