본문 바로가기

자바스크립트의 비동기 처리

동기 실행과 이벤트 기반 비동기 실행을 구분하고 콜백에서 Promise와 async·await로 발전한 제어 흐름을 익힙니다.

8장에서는 실제 웹 애플리케이션 개발에 꼭 필요한 비동기 처리(asynchronous processing)와 데이터 요청(data fetching)을 다룹니다. 웹 애플리케이션은 서버에서 데이터를 가져오는 API 호출이나, 실행 환경이 제공하는 파일 접근과 타이머처럼 기다려야 하는 작업을 자주 합니다. 이런 작업을 동기적으로 처리하면 애플리케이션이 멈춘 것처럼 응답하지 않게 될 수 있으므로, 기다리는 동안 다른 작업을 진행하려면 비동기 처리가 필요합니다. 다만 비동기 문법이 CPU 연산을 다른 스레드로 저절로 보내 주지는 않습니다. 오래 실행되는 자바스크립트 계산은 여전히 현재 스레드를 막으므로, 필요하다면 Web Worker처럼 실행 주체를 분명히 나눠야 합니다.

이 글에서는 자바스크립트 비동기 처리의 기본 동작과 핵심 도구인 콜백, Promise, async/await를 정리하고, 같은 실행 규칙을 React의 요청 화면과 잇습니다. 웹 입문 교재의 비동기 프로그래밍과 함수와 스코프에서 다룬 Promise와 콜백을 읽을 수 있다면 따라오기 쉽습니다. 여기서 task는 실행 환경이 하나씩 실행하는 작업의 단위이고, microtask는 현재 작업의 콜 스택이 비었을 때 이어서 처리하는 짧은 후속 작업입니다. 두 큐의 이름을 외우기보다, 무엇이 지금의 스택에서 실행되고 무엇이 나중으로 미뤄지는지를 표시해 보세요.

자바스크립트 task와 microtask, React scheduling 경계 — 상태 추적

현재 task의 일반 코드/executor 뒤 checkpoint reaction과 다음 timer task가 다르다. React 갱신 요청과 commit은 별도 scheduler 역할이며 timer와 commit의 상대 순서를 고정하지 않아야 한다.

현재 task의 동기 콜 스택, 호스트 API, Promise reaction microtask, timer task, React scheduler를 구분한 자바스크립트 비동기 실행 순서

Task → host → checkpoint → next task

현재 task, microtask와 React 업데이트의 경계

비동기 API를 호출해도 현재 자바스크립트는 콜 스택에서 먼저 끝까지 실행됩니다. 호스트가 완료 작업을 큐에 넣으면 Promise reaction은 microtask checkpoint에서, timer callback은 선택된 다음 task에서 실행되며 React 업데이트는 별도 scheduler 경계를 거칩니다.

현재 task에서 이행되는 Promise와 타이머의 실행 예일반 코드와 executor는 동기 실행됩니다. 현재 task에서 이행된 Promise의 reaction은 stack이 빈 뒤 microtask checkpoint에서 실행됩니다. React 업데이트는 scheduler에 요청하며 타이머 콜백은 별도로 선택된 task에서 실행됩니다. React commit과 타이머 준비 시점의 상대 순서를 고정하지 않습니다.현재 task콜 스택Host APItimer · I/OMicrotaskPromise 반응Task queue타이머 콜백ReactschedulersetTimeoutPromise 이행microtasksetState최소 지연 뒤다음 task 선택동기 실행일반 코드executorstack 비움reaction 실행업데이트 요청batch · priorityrender / commit시점은 schedulercommit 뒤 Effect타이머 콜백새 task 콜 스택반응 등록checkpoint실행 가능setState(update)는 갱신 요청입니다.타이머 준비와 React commit의 상대 시점은 고정하지 않습니다.
현재 task에서 이행되는 Promise와 타이머의 실행 예일반 코드와 executor는 동기 실행됩니다. 현재 task에서 이행된 Promise의 reaction은 stack이 빈 뒤 microtask checkpoint에서 실행됩니다. React 업데이트는 scheduler에 요청하며 타이머 콜백은 별도로 선택된 task에서 실행됩니다. React commit과 타이머 준비 시점의 상대 순서를 고정하지 않습니다. 모바일은 같은 다섯 참여자와 여섯 메시지를 좁은 lifeline에 재배치합니다. 간격은 실측 시간을 나타내지 않습니다.현재taskHostAPI반응큐후속taskReact등록이행반응갱신준비선택동기실행스택비움갱신요청timertask콜백동기 실행: 일반 코드 · executor등록: setTimeout이행: Promise reaction 등록반응: microtask checkpoint갱신: setState(update)준비: 최소 지연 뒤 실행 가능선택: 다음 task 선택React가 render·commit 예약commit 뒤 Effect타이머 준비와 React commit의상대 시점은 고정하지 않습니다.
  1. 현재 task를 동기 실행

    일반 코드와 new Promise(executor), async 함수의 첫 await 전 본문은 현재 콜 스택에서 실행됩니다.

  2. 기다림은 Host API에 등록

    타이머와 네트워크 작업은 실행 환경이 맡을 수 있지만, 긴 CPU 계산은 자동으로 백그라운드로 가지 않습니다.

  3. stack이 비면 microtask checkpoint

    Promise reaction과 await continuation을 처리하며, 새로 생긴 microtask도 checkpoint가 끝날 때까지 이어서 처리합니다.

  4. React 업데이트는 scheduler로

    setState는 업데이트를 요청합니다. React가 batching과 우선순위에 따라 render와 commit 시점을 정합니다.

  5. 타이머는 최소 지연 뒤 실행 가능

    지연 시간이 지났다고 즉시 실행되는 것이 아니라 timer callback task가 선택될 자격을 얻습니다.

  6. 다음 task를 선택

    이벤트 루프가 timer task를 선택하면 콜백이 새 콜 스택에서 실행됩니다.

현재 task에서 이행되는 Promise와 타이머를 함께 등록한 예입니다. 타이머 준비 시점과 React commit의 상대 순서는 이 그림으로 고정하지 않습니다.

CPU boundary

비동기 문법은 연산 스레드를 바꾸지 않는다

Promise나 async/await 안의 긴 계산도 메인 스레드를 막습니다. 계산을 분리하려면 Web Worker 같은 별도 실행 주체가 필요합니다.

React boundary

microtask와 화면 갱신은 일대일이 아니다

React는 여러 업데이트를 batch할 수 있고 scheduler가 render를 미루거나 다시 시작할 수 있습니다. DOM 반영은 commit에서, Effect의 외부 동기화는 commit 이후에 일어납니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명일반 코드와 new Promise(executor), async 함수의 첫 await 전 본문은 현재 콜 스택에서 실행됩니다.
원도표 조건·주석현재 task를 동기 실행: 일반 코드와 new Promise(executor), async 함수의 첫 await 전 본문은 현재 콜 스택에서 실행됩니다.
원도표 조건·주석기다림은 Host API에 등록: 타이머와 네트워크 작업은 실행 환경이 맡을 수 있지만, 긴 CPU 계산은 자동으로 백그라운드로 가지 않습니다.
원도표 조건·주석stack이 비면 microtask checkpoint: Promise reaction과 await continuation을 처리하며, 새로 생긴 microtask도 checkpoint가 끝날 때까지 이어서 처리합니다.
원도표 설명setState는 업데이트를 요청합니다. React가 batching과 우선순위에 따라 render와 commit 시점을 정합니다.
원도표 조건·주석React 업데이트는 scheduler로: setState는 업데이트를 요청합니다. React가 batching과 우선순위에 따라 render와 commit 시점을 정합니다.
원도표 조건·주석타이머는 최소 지연 뒤 실행 가능: 지연 시간이 지났다고 즉시 실행되는 것이 아니라 timer callback task가 선택될 자격을 얻습니다.
원도표 조건·주석다음 task를 선택: 이벤트 루프가 timer task를 선택하면 콜백이 새 콜 스택에서 실행됩니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
현재 taskstack: 동기 코드 / timer: 호스트 등록일반 코드와 executor를 실행하고 timer를 등록한다executor는 비동기 callback처럼 미뤄지지 않는다
checkpointmicrotask: reaction / React: 업데이트 요청이행한 Promise의 반응을 실행하고 setState를 scheduler에 요청한다setState가 즉시 DOM commit을 뜻하지는 않는다
다음에 선택된 tasktimer: 새 JS stack / commit: scheduler 시점timer callback은 새 task에서 실행하며 React commit과의 상대 시점은 고정하지 않는다모든 task를 하나의 전역 FIFO로 일반화하지 않는다

자바스크립트 task와 microtask, React scheduling 경계 — 상태 추적

현재 task의 일반 코드/executor 뒤 checkpoint reaction과 다음 timer task가 다르다. React 갱신 요청과 commit은 별도 scheduler 역할이며 timer와 commit의 상대 순서를 고정하지 않아야 한다.

현재 task의 동기 콜 스택, 호스트 API, Promise reaction microtask, timer task, React scheduler를 구분한 자바스크립트 비동기 실행 순서

Task → host → checkpoint → next task

현재 task, microtask와 React 업데이트의 경계

비동기 API를 호출해도 현재 자바스크립트는 콜 스택에서 먼저 끝까지 실행됩니다. 호스트가 완료 작업을 큐에 넣으면 Promise reaction은 microtask checkpoint에서, timer callback은 선택된 다음 task에서 실행되며 React 업데이트는 별도 scheduler 경계를 거칩니다.

현재 task에서 이행되는 Promise와 타이머의 실행 예일반 코드와 executor는 동기 실행됩니다. 현재 task에서 이행된 Promise의 reaction은 stack이 빈 뒤 microtask checkpoint에서 실행됩니다. React 업데이트는 scheduler에 요청하며 타이머 콜백은 별도로 선택된 task에서 실행됩니다. React commit과 타이머 준비 시점의 상대 순서를 고정하지 않습니다.현재 task콜 스택Host APItimer · I/OMicrotaskPromise 반응Task queue타이머 콜백ReactschedulersetTimeoutPromise 이행microtasksetState최소 지연 뒤다음 task 선택동기 실행일반 코드executorstack 비움reaction 실행업데이트 요청batch · priorityrender / commit시점은 schedulercommit 뒤 Effect타이머 콜백새 task 콜 스택반응 등록checkpoint실행 가능setState(update)는 갱신 요청입니다.타이머 준비와 React commit의 상대 시점은 고정하지 않습니다.
현재 task에서 이행되는 Promise와 타이머의 실행 예일반 코드와 executor는 동기 실행됩니다. 현재 task에서 이행된 Promise의 reaction은 stack이 빈 뒤 microtask checkpoint에서 실행됩니다. React 업데이트는 scheduler에 요청하며 타이머 콜백은 별도로 선택된 task에서 실행됩니다. React commit과 타이머 준비 시점의 상대 순서를 고정하지 않습니다. 모바일은 같은 다섯 참여자와 여섯 메시지를 좁은 lifeline에 재배치합니다. 간격은 실측 시간을 나타내지 않습니다.현재taskHostAPI반응큐후속taskReact등록이행반응갱신준비선택동기실행스택비움갱신요청timertask콜백동기 실행: 일반 코드 · executor등록: setTimeout이행: Promise reaction 등록반응: microtask checkpoint갱신: setState(update)준비: 최소 지연 뒤 실행 가능선택: 다음 task 선택React가 render·commit 예약commit 뒤 Effect타이머 준비와 React commit의상대 시점은 고정하지 않습니다.
  1. 현재 task를 동기 실행

    일반 코드와 new Promise(executor), async 함수의 첫 await 전 본문은 현재 콜 스택에서 실행됩니다.

  2. 기다림은 Host API에 등록

    타이머와 네트워크 작업은 실행 환경이 맡을 수 있지만, 긴 CPU 계산은 자동으로 백그라운드로 가지 않습니다.

  3. stack이 비면 microtask checkpoint

    Promise reaction과 await continuation을 처리하며, 새로 생긴 microtask도 checkpoint가 끝날 때까지 이어서 처리합니다.

  4. React 업데이트는 scheduler로

    setState는 업데이트를 요청합니다. React가 batching과 우선순위에 따라 render와 commit 시점을 정합니다.

  5. 타이머는 최소 지연 뒤 실행 가능

    지연 시간이 지났다고 즉시 실행되는 것이 아니라 timer callback task가 선택될 자격을 얻습니다.

  6. 다음 task를 선택

    이벤트 루프가 timer task를 선택하면 콜백이 새 콜 스택에서 실행됩니다.

현재 task에서 이행되는 Promise와 타이머를 함께 등록한 예입니다. 타이머 준비 시점과 React commit의 상대 순서는 이 그림으로 고정하지 않습니다.

CPU boundary

비동기 문법은 연산 스레드를 바꾸지 않는다

Promise나 async/await 안의 긴 계산도 메인 스레드를 막습니다. 계산을 분리하려면 Web Worker 같은 별도 실행 주체가 필요합니다.

React boundary

microtask와 화면 갱신은 일대일이 아니다

React는 여러 업데이트를 batch할 수 있고 scheduler가 render를 미루거나 다시 시작할 수 있습니다. DOM 반영은 commit에서, Effect의 외부 동기화는 commit 이후에 일어납니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명일반 코드와 new Promise(executor), async 함수의 첫 await 전 본문은 현재 콜 스택에서 실행됩니다.
원도표 조건·주석현재 task를 동기 실행: 일반 코드와 new Promise(executor), async 함수의 첫 await 전 본문은 현재 콜 스택에서 실행됩니다.
원도표 조건·주석기다림은 Host API에 등록: 타이머와 네트워크 작업은 실행 환경이 맡을 수 있지만, 긴 CPU 계산은 자동으로 백그라운드로 가지 않습니다.
원도표 조건·주석stack이 비면 microtask checkpoint: Promise reaction과 await continuation을 처리하며, 새로 생긴 microtask도 checkpoint가 끝날 때까지 이어서 처리합니다.
원도표 설명setState는 업데이트를 요청합니다. React가 batching과 우선순위에 따라 render와 commit 시점을 정합니다.
원도표 조건·주석React 업데이트는 scheduler로: setState는 업데이트를 요청합니다. React가 batching과 우선순위에 따라 render와 commit 시점을 정합니다.
원도표 조건·주석타이머는 최소 지연 뒤 실행 가능: 지연 시간이 지났다고 즉시 실행되는 것이 아니라 timer callback task가 선택될 자격을 얻습니다.
원도표 조건·주석다음 task를 선택: 이벤트 루프가 timer task를 선택하면 콜백이 새 콜 스택에서 실행됩니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
현재 taskstack: 동기 코드 / timer: 호스트 등록일반 코드와 executor를 실행하고 timer를 등록한다executor는 비동기 callback처럼 미뤄지지 않는다
checkpointmicrotask: reaction / React: 업데이트 요청이행한 Promise의 반응을 실행하고 setState를 scheduler에 요청한다setState가 즉시 DOM commit을 뜻하지는 않는다
다음에 선택된 tasktimer: 새 JS stack / commit: scheduler 시점timer callback은 새 task에서 실행하며 React commit과의 상대 시점은 고정하지 않는다모든 task를 하나의 전역 FIFO로 일반화하지 않는다

위 그림은 현재 task와 microtask, React의 갱신이 어디서 나뉘는지를 다섯 참여자로 보여 줍니다. 현재 task의 콜 스택에서는 일반 코드와 new Promise(executor)의 executor, async 함수의 첫 await 전까지의 본문이 동기적으로 실행되며, 그 안에서 setTimeout으로 타이머를 실행 환경(Host API)에 등록하고 Promise를 이행해 반응(reaction)을 등록합니다. 스택이 비면 microtask checkpoint에서 Promise의 반응과 await의 이어 실행(continuation)을 처리하고, 그 사이에 새로 생긴 microtask도 checkpoint가 끝날 때까지 이어서 처리합니다. 반응 안에서 setState(update)를 부르면 React의 scheduler에 갱신을 요청할 뿐이며, React가 묶음 처리(batching)와 우선순위에 따라 렌더링과 커밋의 시점을 정하고 커밋한 뒤에 Effect가 실행됩니다.

타이머는 최소 지연이 지난 뒤에 실행될 자격을 얻을 뿐, 그때 바로 실행되는 것이 아니라 이벤트 루프가 다음 task로 고를 때 새 콜 스택에서 실행됩니다. 그림의 설명처럼 타이머가 준비되는 시점과 React의 커밋 시점의 순서는 이 그림으로 고정할 수 없습니다. 그림 아래 두 칸이 덧붙이듯 Promise나 async/await 안의 긴 계산도 메인 스레드를 막으며, React는 여러 갱신을 묶거나 렌더링을 미루거나 다시 시작할 수 있으므로 microtask 하나와 화면 갱신 하나가 일대일로 대응하지 않습니다.


동기 vs 비동기

개념을 분명히 하기 위해 동기(synchronous) 처리와 비동기(asynchronous) 처리를 비교해 봅니다. 동기 처리에서는 코드가 위에서 아래로 차례로 실행되고, 한 작업이 끝날 때까지 다음 작업이 기다립니다. 코드의 흐름을 파악하기 쉽고 예측할 수 있다는 장점이 있지만, 시간이 오래 걸리는 작업이 있으면 앱 전체가 막혀(blocking) 사용자 인터페이스가 멈추거나 응답하지 않게 됩니다.

console.log("작업 1 시작");
// 매우 오래 걸리는 동기 작업이라고 가정
for (let i = 0; i < 1000000000; i++) {} // CPU를 많이 사용하는 작업
console.log("작업 2 완료"); // 작업 1이 끝나야 실행
console.log("작업 3 완료");

위 코드에서 “작업 2 완료”는 “작업 1 시작” 뒤의 반복문이 모두 실행된 다음에야 출력됩니다.

비동기 처리에서는 타이머나 네트워크 요청처럼 실행 환경에 맡길 수 있는 작업을 시작한 뒤, 결과를 기다리는 동안 다음 자바스크립트 코드를 실행할 수 있습니다. 실행 환경의 작업이 끝나면 콜백 task나 Promise 반응 microtask가 실행 가능한 큐에 들어가고, 이벤트 루프가 현재 콜 스택이 끝난 뒤 알맞은 때에 이를 실행합니다. 기다림이 많은 작업에서 메인 스레드를 계속 붙잡지 않아 사용자 경험을 부드럽게 유지할 수 있지만, CPU를 많이 쓰는 자바스크립트 자체는 이 방식만으로 막힘이 사라지지 않습니다. 또 코드의 흐름이 위에서 아래로 이어지지 않아 이해하거나 디버깅하기 어려울 때도 있습니다.

console.log("작업 A 시작");
setTimeout(() => { // 1초가 지난 뒤 실행 가능해질 콜백 task 등록
  console.log("작업 B 완료 (1초 지연)");
}, 1000);
console.log("작업 C 완료"); // 작업 B가 끝나기를 기다리지 않고 즉시 실행

이 코드의 출력 순서는 “작업 A 시작”, “작업 C 완료”, “작업 B 완료 (1초 지연)”입니다. 1000은 정확한 실행 시각이 아니라 최소 지연 시간입니다. 1초가 지난 뒤에도 현재 task나 먼저 처리할 microtask가 남아 있다면 콜백은 더 늦게 시작할 수 있습니다.


콜백(Callback) 함수

자바스크립트에서 비동기 처리를 구현하는 가장 기본적인 방법은 콜백 함수를 쓰는 것입니다. 콜백 함수는 다른 함수의 인자로 넘겨져, 그 함수가 정한 때에 호출되는 함수입니다. 콜백이 받는 인자와 호출되는 횟수, 결과와 오류를 넘기는 방식은 그 API가 정한 규칙에 따르며, 모든 콜백이 같은 오류 형식을 쓰지는 않습니다. setTimeout을 쓰는 예를 봅니다.

function greet(name, callback) {
  setTimeout(() => {
    const message = `Hello, ${name}!`;
    callback(message); // 작업 완료 후 콜백 함수 호출
  }, 1000);
}

function displayMessage(msg) {
  console.log(msg);
}

console.log("인사 시작...");
greet("Alice", displayMessage); // 콜백으로 displayMessage 함수 전달
console.log("인사 요청 완료.");
인사 시작...
인사 요청 완료.
Hello, Alice!

여기서 displayMessage가 greet 함수의 콜백으로 넘겨져, setTimeout 안의 비동기 작업이 끝난 뒤에 실행됩니다. 하지만 콜백이라는 이름이 곧 비동기를 뜻하지는 않습니다. 예를 들어 [1, 2].map(value => value * 2)의 함수는 map이 반환되기 전에 동기적으로 호출됩니다. 위 인사 예제가 나중에 호출되는 이유는 콜백을 넘겼기 때문이 아니라, greet가 타이머 안에서 호출하도록 구현했기 때문입니다.

여러 비동기 작업을 차례로 실행해야 할 때 콜백 함수가 겹겹이 중첩되어 코드를 읽기 어렵고 고치기 힘들어지는 현상을 콜백 지옥(callback hell)이나 파멸의 피라미드(pyramid of doom)라고 부릅니다. 아래의 getData, processData, displayResult는 구현을 생략한 흐름의 모형입니다.

// 콜백 헬 예시
getData(function(a) {
  processData(a, function(b) {
    displayResult(b, function(c) {
      // ... 계속 중첩
    });
  });
});

Promise (프로미스)

콜백 지옥의 단점을 극복하고 비동기 코드를 더 깔끔하게 쓰도록 ES2015(ES6)에서 Promise가 도입되었습니다. Promise는 비동기 작업의 최종 완료나 실패를 나타내는 객체입니다. new Promise(executor)의 executor는 Promise를 만드는 순간 동기적으로 실행되며, Promise가 이행(fulfilled)이나 거부(rejected)로 정해진 뒤에 등록된 반응이 실행되는 단계가 microtask입니다. executor 자체가 저절로 백그라운드에서 실행되는 것은 아닙니다.

Promise에는 세 가지 상태가 있습니다. 대기(pending)는 아직 결과가 정해지지 않은 처음 상태이고, 이행(fulfilled)은 작업이 성공적으로 끝난 상태이며, 거부(rejected)는 이유와 함께 작업이 실패한 상태입니다. 대기는 이행이나 거부 가운데 하나로 한 번만 바뀌며, 정해진 상태는 다시 바뀌지 않습니다.

Promise는 new Promise((resolve, reject) => { ... })로 만듭니다. executor는 동기적으로 호출되고, 그 안에서 타이머나 네트워크 작업을 등록할 수 있습니다. resolve는 값이나 thenable(Promise처럼 then() 메서드를 가진 객체)로 Promise를 해결(resolve)하는 함수인데, thenable을 넘기면 그 최종 상태를 따르게 되므로 반드시 이행만 뜻하지는 않습니다. 해결(resolved)은 결정(settled)과 같은 말이 아니라서, 아직 대기 중인 thenable로 해결하면 그 결과를 따르도록 고정되지만 그 thenable이 결정될 때까지 Promise 자체는 대기 상태일 수 있습니다. reject는 비동기 작업이 실패했을 때 호출하는 함수입니다.

Promise의 결과는 .then(), .catch(), .finally()로 이어 받습니다. .then(onFulfilled, onRejected)는 Promise가 이행되거나 거부되었을 때 실행할 콜백을 등록하며, 보통은 성공한 경우만 처리합니다. .catch(onRejected)는 거부되었을 때만 실행되는 콜백을 등록해 오류를 처리하며, .then(undefined, onRejected)의 줄임입니다. .finally()는 성공과 실패에 상관없이 정리하는 콜백을 등록하는데, 결과 값이나 실패의 이유를 인자로 받지 않고, 콜백이 오류를 던지거나 거부된 Promise를 돌려주지 않는 한 기존 결과를 그대로 통과시킵니다.

function fetchDataPromise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = true; // 가상으로 성공/실패 여부 결정
      if (success) {
        resolve("데이터를 성공적으로 가져왔습니다!"); // 성공 시 resolve 호출
      } else {
        reject("데이터를 가져오는 데 실패했습니다."); // 실패 시 reject 호출
      }
    }, 2000);
  });
}

console.log("데이터 요청 시작...");

fetchDataPromise()
  .then(response => { // 성공적으로 데이터를 가져왔을 때
    console.log("성공:", response);
    return "다음 작업으로 전달할 값"; // 다음 .then()으로 값 전달
  })
  .then(nextValue => { // 이전 .then()에서 반환된 값 받음
    console.log("다음 체인:", nextValue);
  })
  .catch(error => { // Promise가 실패했을 때
    console.error("실패:", error);
  })
  .finally(() => { // 성공/실패와 상관없이 항상 실행
    console.log("데이터 요청 작업 완료.");
  });

console.log("Promise 요청은 즉시 반환되었습니다.");
데이터 요청 시작...
Promise 요청은 즉시 반환되었습니다.
(2초 후)
성공: 데이터를 성공적으로 가져왔습니다!
다음 체인: 다음 작업으로 전달할 값
데이터 요청 작업 완료.

Promise는 콜백 지옥에서 벗어나 비동기의 결과와 오류를 체인으로 이어, 코드를 더 읽기 쉽게 만들어 줍니다. 각 .then()이 돌려준 값이나 Promise는 다음 단계가 이어받고, 처리 함수가 던진 오류는 다음 거부 경로로 전달됩니다.

다만 후속 작업의 Promise를 처리 함수에서 돌려줘야 바깥의 체인이 그 완료와 실패를 함께 기다립니다. then(() => { saveData(); })는 saveData의 결과를 돌려주지 않으므로 바깥 체인에는 undefined가 전달되고, 안쪽에서 시작한 작업의 실패도 바깥의 catch에 저절로 이어지지 않습니다. then(() => saveData())나 then(async () => { await saveData(); })처럼 작업을 이어야 합니다. 또 catch가 정상적인 값을 돌려주면 그다음 체인은 이행으로 이어지므로, 계속 실패로 전달하려면 오류를 다시 던져야 합니다. 이렇게 비동기 문법은 콜백에서 Promise, async/await로 발전해 왔지만, 풀려는 문제는 같습니다. 중첩을 줄이고, 성공과 실패의 흐름을 읽기 쉬운 모양으로 펼치는 것입니다.


async/await (어싱크/어웨이트)

ES2017에서 도입된 async/await 문법은 Promise를 바탕으로, 비동기 코드를 동기 코드처럼 보이게 작성하도록 도와 가독성을 크게 높여 줍니다. 함수 앞에 async를 붙이면 그 함수는 언제나 Promise를 돌려주며, 함수 안에서 await를 쓸 수 있게 됩니다. 함수 본문은 호출되면 첫 await를 만나 멈추거나 함수가 끝날 때까지 동기적으로 실행됩니다.

await는 Promise뿐 아니라 어떤 표현식에도 쓸 수 있습니다. 표현식의 결과는 Promise의 해결 규칙을 거치므로, 일반 값은 이행된 값처럼 다뤄지고 thenable은 그 상태를 따릅니다. 그 값이 이행될 때까지 현재의 async 함수만 멈추며, 이어 실행은 microtask에서 계속됩니다. 이행되면 결과 값을 돌려주고, 거부되면 그 await 지점에서 오류를 던집니다.

function fetchDataAsync() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve("비동기 데이터 획득!");
    }, 1500);
  });
}

async function processData() { // async 함수로 정의
  console.log("데이터 처리 시작 (async/await)...");
  try {
    // await은 Promise가 완료될 때까지 기다립니다.
    const data = await fetchDataAsync(); // fetchDataAsync Promise가 이행될 때까지 대기
    console.log("받은 데이터:", data);

    const processedData = data + " -> 처리 완료!";
    console.log("처리된 데이터:", processedData);

    // 다음 비동기 작업도 await으로 순차적으로 처리 가능
    const anotherResult = await new Promise(resolve => setTimeout(() => resolve("추가 작업 완료!"), 500));
    console.log(anotherResult);

  } catch (error) { // Promise가 rejected되면 catch 블록으로 이동
    console.error("오류 발생:", error);
  } finally {
    console.log("데이터 처리 종료.");
  }
}

// 첫 await 전의 본문은 동기적으로 실행되고, async 함수는 Promise를 반환합니다.
processData().then(() => {
  console.log("processData 함수 전체 실행 완료.");
});

console.log("async 함수 호출에서 Promise를 받았습니다.");
데이터 처리 시작 (async/await)...
async 함수 호출에서 Promise를 받았습니다.
(1.5초 후)
받은 데이터: 비동기 데이터 획득!
처리된 데이터: 비동기 데이터 획득! -> 처리 완료!
(0.5초 후)
추가 작업 완료!
데이터 처리 종료.
processData 함수 전체 실행 완료.

async/await는 비동기 코드의 가독성을 크게 높여 줍니다. 다만 위 processData는 catch에서 오류를 출력한 뒤 정상적으로 끝나므로, 안에서 실패를 잡았더라도 바깥 .then()의 “processData 함수 전체 실행 완료.”가 출력될 수 있으며, 그 문구가 성공을 보장하지는 않습니다. 호출하는 쪽이 실패를 판단해야 하는 함수라면 오류를 다시 던지거나, 성공과 실패를 구별할 수 있는 결과를 돌려줘야 합니다. finally는 정리를 위한 단계일 뿐 실패를 저절로 성공으로 바꾸지 않습니다.

여러 비동기 작업이 실제로 앞 단계의 결과에 기댄다면 Promise 체인보다 순서를 더 직관적으로 나타낼 수 있습니다. 반대로 await마다 서로 독립적인 작업을 새로 시작하면 시작하는 시점까지 차례로 늘어집니다. 함께 기다리려면 작업들을 먼저 시작한 뒤 await Promise.all(...)처럼 묶습니다. Promise.all([loadA(), loadB()])에서는 두 함수를 먼저 호출하고 돌려받은 Promise들을 묶으며, 결과 배열은 끝난 속도가 아니라 입력의 순서를 따릅니다. 하나가 거부되면 묶은 결과도 거부되지만 다른 작업을 저절로 취소하지는 않으므로, 모든 성공과 실패를 각각 확인해야 한다면 Promise.allSettled를 검토하고, 실제 취소는 그 API의 취소 수단으로 설계합니다. 둘 다 긴 CPU 계산을 다른 스레드로 옮기는 기능은 아닙니다.

콜백과 Promise, async/await는 결과와 오류가 전달되는 자리가 다릅니다. 아래 비교는 문법의 모양과 실행 규칙을 나눠 보여 줍니다.

Callback, Promise, async await의 시작과 반환, 결과와 오류 전달, 순차 실행 계약 및 Promise의 pending, fulfilled, rejected 상태 비교

Start · return · result · error

콜백·Promise·async/await의 반환과 오류 계약

세 형식은 작업을 더 빠르게 만드는 단계가 아니라 결과와 오류를 읽는 위치를 바꾸는 계약입니다. API가 콜백을 호출하거나 Promise가 결정되면 후속 로직이 이어지며, 의존 관계가 있는 작업과 서로 독립적인 작업의 시작 시점은 따로 설계합니다.

Callback · API가 호출 규약 소유

시작 · 반환

함수를 넘기면 API가 정한 시점과 횟수로 호출합니다. 동기 호출인지 나중 호출인지, 취소 handle을 반환하는지도 API 계약입니다.

결과

결과는 callback 인자로 받습니다. 브라우저의 event/value 형식과 Node에서 흔한 (error, value) 형식은 서로 다른 규약입니다.

오류 · 순서

나중 callback의 throw는 호출 바깥 try/catch가 잡지 못합니다. 의존 작업을 그대로 중첩하면 callback hell이 생길 수 있습니다.

Promise · 상태와 reaction 체인

시작 · 반환

new Promise(executor)의 executor는 동기 실행되고, 생성자가 반환한 Promise가 나중 결과를 나타냅니다.

결과

resolve는 값이나 thenable로 Promise를 해결합니다. pending thenable이면 그 결과를 따르도록 고정된 뒤에도 settlement 전까지 Pending일 수 있고, fulfillment reaction은 microtask에서 실행됩니다.

오류 · 순서

reject와 handler의 throw는 Rejected 경로로 이어지고 catch가 처리합니다. 체인은 앞 결과에 의존하는 순서를 표현합니다.

async · await · Promise를 평평하게 읽기

시작 · 반환

async 본문은 첫 await 전까지 동기 실행되고 호출 결과로 Promise를 반환합니다.

결과

await는 임의 표현식을 Promise resolution 규칙으로 처리합니다. 이행 값은 continuation에서 읽고, continuation은 microtask에서 이어집니다.

오류 · 순서

거부는 await 지점에서 throw되어 try/catch로 갑니다. 잡지 않은 throw는 async 함수의 Rejected 결과가 되며 각 await에서 작업을 시작하면 시작 시점도 직렬화됩니다.

Pending

결정 전

아직 fulfillment 값이나 rejection 이유가 정해지지 않은 초기 상태입니다.

Fulfilled

값으로 이행

Pending에서 한 번 전이한 최종 상태이며 등록된 fulfillment reaction이 실행 대상이 됩니다.

Rejected

이유와 함께 거부

Pending에서 한 번 전이한 최종 상태이며 rejection handler가 없으면 미처리 오류로 보고될 수 있습니다.

Promise state: Pending → Fulfilled 또는 Rejected로 한 번만 결정됩니다. finally는 결과 인자를 받지 않는 정리 단계이며, 스스로 throw하거나 Rejected Promise를 반환하지 않으면 기존 settlement를 통과시킵니다. 서로 독립적인 작업은 먼저 함께 시작한 뒤 await Promise.all(...)처럼 결합해야 불필요한 직렬화를 피할 수 있습니다. 구현 형식과 별개로 UI는 시작·결과·오류를 명시적 상태로 해석해야 합니다.

위 그림은 세 형식이 작업을 더 빠르게 만드는 단계가 아니라, 결과와 오류를 읽는 자리를 바꾸는 방식이라는 점을 비교합니다. 콜백에서는 함수를 넘기면 API가 정한 때와 횟수로 호출하며, 동기로 부르는지 나중에 부르는지, 취소 수단을 돌려주는지도 그 API가 정합니다. 결과는 콜백의 인자로 받는데, 브라우저의 이벤트나 값을 넘기는 형식과 Node에서 흔한 (error, value) 형식은 서로 다른 규칙입니다. 나중에 호출된 콜백이 던진 오류는 호출 바깥의 try/catch가 잡지 못하고, 기댄 작업을 그대로 중첩하면 콜백 지옥이 생깁니다.

Promise에서는 executor가 동기적으로 실행되고, 생성자가 돌려준 Promise가 나중의 결과를 나타냅니다. resolve는 값이나 thenable로 해결하고 이행의 반응은 microtask에서 실행되며, reject와 처리 함수의 오류는 거부 경로로 이어져 catch가 처리합니다. async/await에서는 첫 await 전까지의 본문이 동기적으로 실행되고 호출 결과로 Promise를 돌려주며, await가 이행된 값을 이어 실행에서 읽고 거부는 그 지점에서 던져 catch로 보냅니다. 잡지 않은 오류는 async 함수의 거부된 결과가 됩니다.

그림 아래쪽은 Promise의 세 상태를 정리합니다. 대기는 아직 이행 값이나 거부 이유가 정해지지 않은 처음 상태이고, 대기에서 한 번만 이행이나 거부로 바뀝니다. 거부를 처리하는 함수가 없으면 처리되지 않은 오류로 보고될 수 있습니다. 그림의 마지막 설명처럼 서로 독립된 작업은 먼저 함께 시작한 뒤 Promise.all(...)처럼 묶어야 불필요하게 순서대로 늘어지지 않으며, 어떤 형식으로 구현하든 UI는 시작과 결과, 오류를 분명한 상태로 해석해야 합니다.

비동기 문법을 UI 상태로 옮길 때는 성공의 경로만이 아니라 대기와 실패, 정리의 단계를 함께 나타내야 합니다.

비동기 요청 UI의 명시적 상태 전이 — 상태 추적

idle/loading/성공 data/성공 empty/error와 retry 사건의 실제 의미 상태가 바뀐다. 독립 JS 실행 예제를 UI 구현으로 주장하지 않고 최소 UI 모델 확장으로 명시한다.

비동기 UI가 idle에서 loading으로 전이한 뒤 fulfilled 결과의 유무에 따라 success 또는 empty가 되고 rejected 결과는 error가 되는 상태 머신

Idle → loading → one outcome

비동기 요청의 다섯 상태와 재시도

요청 상태를 한 번에 하나의 명시적 상태로 모델링하면 모순되는 조합을 막기 쉽습니다. 시작하면 Loading, 이행하면 데이터 유무에 따라 Success 또는 Empty, 거부되면 Error로 전이하며 재시도와 오래된 응답 처리도 상태 계약에 포함합니다.

비동기 요청 UI의 idle, loading, success, empty, error 상태Idle에서 요청을 시작하면 Loading이 되고, fulfilled 결과에 항목이 있으면 Success, 없으면 Empty가 됩니다. Rejected 결과는 Error가 되며 Error에서 재시도하면 Loading으로 돌아갑니다. Idle아직 요청 전Loading진행 중Success데이터 화면Empty빈 결과 안내Error실패 · 재시도요청항목 있음항목 없음rejected재시도
비동기 요청 UI의 idle, loading, success, empty, error 상태Idle에서 요청을 시작하면 Loading이 되고, fulfilled 결과에 항목이 있으면 Success, 없으면 Empty가 됩니다. Rejected 결과는 Error가 되며 Error에서 재시도하면 Loading으로 돌아갑니다.Idle아직 요청 전Loading진행 중Success데이터 화면Empty빈 결과 안내Error실패 · 재시도요청항목 있음항목 없음rejected재시도
Idle

아직 요청 전

초기 안내나 요청 버튼을 보여주며 데이터와 오류를 요청 결과처럼 가장하지 않습니다.

Loading

요청 진행 중

진행 피드백과 중복 요청 정책을 적용합니다. 이전 데이터 유지 여부도 제품 계약으로 정합니다.

Success

데이터가 있는 이행 결과

필요한 형태로 저장한 데이터를 화면에 표시합니다.

Empty

성공했지만 항목 없음

오류가 아닌 정상적인 빈 결과로 안내와 다음 행동을 제공합니다.

Error

거부 또는 처리 실패

실패 정보를 표시하고 재시도하면 Loading으로 돌아갑니다.

불가능한 조합을 만들지 않는다

status를 idle, loading, success, empty, error 중 하나로 모델링하면 loading과 error가 동시에 true인 모순을 막기 쉽습니다.

정리와 경쟁 상태는 별도 계약

finally는 자원 정리 지점이지 결과 상태를 고르는 대신이 아닙니다. AbortController는 요청을 취소하고, active/request ID gate는 이미 완료 중인 오래된 응답의 상태 쓰기까지 차단합니다.

Callback, Promise, async/await 중 무엇으로 구현해도 UI는 요청 시작, 이행 값의 유무, 거부 이유를 같은 상태 전이로 해석할 수 있어야 합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명초기 안내나 요청 버튼을 보여 주며 데이터와 오류를 요청 결과처럼 가장하지 않습니다.
원도표 설명status를 idle, loading, success, empty, error 중 하나로 모델링하면 loading과 error가 동시에 true인 모순을 막기 쉽습니다.
원도표 설명finally는 자원 정리 지점이지 결과 상태의 선택을 대신하지는 않습니다. AbortController는 요청을 취소하고, active/request ID gate는 이미 완료 중인 오래된 응답의 상태 쓰기까지 차단합니다.
원도표 설명Callback, Promise, async/await 중 무엇으로 구현해도 UI는 요청 시작, 이행 값의 유무, 거부 이유를 같은 상태 전이로 해석할 수 있어야 합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
idle과 시작이전: idle / 현재: loading아직 요청 전인 UI가 시작 사건으로 loading에 들어간다시작 사건이 상태 전이의 원인이다
한 결과fulfilled: data 또는 empty / rejected: error현재 결과에 따라 data·empty·error 중 하나를 고른다empty는 성공이며 오류가 아니다
retryevent: retry / status: loadingerror의 재시도는 새 loading 요청이다retry를 영구 화면 상태로 두지 않으며 UI 모델 확장임을 명시한다

비동기 요청 UI의 명시적 상태 전이 — 상태 추적

idle/loading/성공 data/성공 empty/error와 retry 사건의 실제 의미 상태가 바뀐다. 독립 JS 실행 예제를 UI 구현으로 주장하지 않고 최소 UI 모델 확장으로 명시한다.

비동기 UI가 idle에서 loading으로 전이한 뒤 fulfilled 결과의 유무에 따라 success 또는 empty가 되고 rejected 결과는 error가 되는 상태 머신

Idle → loading → one outcome

비동기 요청의 다섯 상태와 재시도

요청 상태를 한 번에 하나의 명시적 상태로 모델링하면 모순되는 조합을 막기 쉽습니다. 시작하면 Loading, 이행하면 데이터 유무에 따라 Success 또는 Empty, 거부되면 Error로 전이하며 재시도와 오래된 응답 처리도 상태 계약에 포함합니다.

비동기 요청 UI의 idle, loading, success, empty, error 상태Idle에서 요청을 시작하면 Loading이 되고, fulfilled 결과에 항목이 있으면 Success, 없으면 Empty가 됩니다. Rejected 결과는 Error가 되며 Error에서 재시도하면 Loading으로 돌아갑니다. Idle아직 요청 전Loading진행 중Success데이터 화면Empty빈 결과 안내Error실패 · 재시도요청항목 있음항목 없음rejected재시도
비동기 요청 UI의 idle, loading, success, empty, error 상태Idle에서 요청을 시작하면 Loading이 되고, fulfilled 결과에 항목이 있으면 Success, 없으면 Empty가 됩니다. Rejected 결과는 Error가 되며 Error에서 재시도하면 Loading으로 돌아갑니다.Idle아직 요청 전Loading진행 중Success데이터 화면Empty빈 결과 안내Error실패 · 재시도요청항목 있음항목 없음rejected재시도
Idle

아직 요청 전

초기 안내나 요청 버튼을 보여주며 데이터와 오류를 요청 결과처럼 가장하지 않습니다.

Loading

요청 진행 중

진행 피드백과 중복 요청 정책을 적용합니다. 이전 데이터 유지 여부도 제품 계약으로 정합니다.

Success

데이터가 있는 이행 결과

필요한 형태로 저장한 데이터를 화면에 표시합니다.

Empty

성공했지만 항목 없음

오류가 아닌 정상적인 빈 결과로 안내와 다음 행동을 제공합니다.

Error

거부 또는 처리 실패

실패 정보를 표시하고 재시도하면 Loading으로 돌아갑니다.

불가능한 조합을 만들지 않는다

status를 idle, loading, success, empty, error 중 하나로 모델링하면 loading과 error가 동시에 true인 모순을 막기 쉽습니다.

정리와 경쟁 상태는 별도 계약

finally는 자원 정리 지점이지 결과 상태를 고르는 대신이 아닙니다. AbortController는 요청을 취소하고, active/request ID gate는 이미 완료 중인 오래된 응답의 상태 쓰기까지 차단합니다.

Callback, Promise, async/await 중 무엇으로 구현해도 UI는 요청 시작, 이행 값의 유무, 거부 이유를 같은 상태 전이로 해석할 수 있어야 합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명초기 안내나 요청 버튼을 보여 주며 데이터와 오류를 요청 결과처럼 가장하지 않습니다.
원도표 설명status를 idle, loading, success, empty, error 중 하나로 모델링하면 loading과 error가 동시에 true인 모순을 막기 쉽습니다.
원도표 설명finally는 자원 정리 지점이지 결과 상태의 선택을 대신하지는 않습니다. AbortController는 요청을 취소하고, active/request ID gate는 이미 완료 중인 오래된 응답의 상태 쓰기까지 차단합니다.
원도표 설명Callback, Promise, async/await 중 무엇으로 구현해도 UI는 요청 시작, 이행 값의 유무, 거부 이유를 같은 상태 전이로 해석할 수 있어야 합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
idle과 시작이전: idle / 현재: loading아직 요청 전인 UI가 시작 사건으로 loading에 들어간다시작 사건이 상태 전이의 원인이다
한 결과fulfilled: data 또는 empty / rejected: error현재 결과에 따라 data·empty·error 중 하나를 고른다empty는 성공이며 오류가 아니다
retryevent: retry / status: loadingerror의 재시도는 새 loading 요청이다retry를 영구 화면 상태로 두지 않으며 UI 모델 확장임을 명시한다

위 그림은 비동기 요청 UI의 다섯 상태와 그 사이의 전이를 보여 줍니다. 아직 요청하기 전인 Idle에서 요청을 시작하면 Loading이 되고, 이행된 결과에 항목이 있으면 Success, 없으면 Empty가 되며, 거부되면 Error가 됩니다. Error에서 다시 시도하면 Loading으로 돌아갑니다. Idle에서는 처음 안내나 요청 버튼을 보여 줄 뿐 데이터나 오류를 요청의 결과처럼 꾸미지 않고, Loading에서는 진행을 알리는 표시와 중복 요청에 대한 규칙을 적용하며 이전 데이터를 유지할지도 제품의 규칙으로 정합니다. Empty는 오류가 아닌 정상적인 빈 결과이므로 안내와 다음 행동을 제공합니다.

그림 아래 칸이 정리하듯, 상태를 idle, loading, success, empty, error 가운데 하나로 나타내면 로딩과 오류가 동시에 참인 모순을 막기 쉽습니다. 또 finally는 자원을 정리하는 자리일 뿐 결과 상태를 고르는 곳이 아니며, AbortController는 요청을 취소하고 활성 상태나 요청 ID를 확인하는 장치는 이미 끝나 가는 오래된 응답이 상태를 쓰는 것까지 막습니다. 콜백과 Promise, async/await 가운데 무엇으로 구현하든 UI는 요청의 시작과 이행 값의 유무, 거부의 이유를 같은 상태 전이로 해석할 수 있어야 합니다.


지연 시간 없이 실행 순서 확인하기

새 파일 async-order.mjs에 아래 코드를 저장해 node async-order.mjs로 실행하거나, 브라우저 Console에 붙여 넣습니다. React 컴포넌트 안에서 되풀이해 실행하지 않는 독립된 예제입니다.

async-order.mjs
console.log('A');
new Promise(resolve => {
  console.log('executor');
  resolve();
}).then(() => console.log('P'));

async function work() {
  console.log('async-start');
  await 0;
  console.log('after-await');
}
work();
setTimeout(() => console.log('timer'), 0);
console.log('B');

예상되는 순서는 A, executor, async-start, B, P, after-await, timer입니다. Promise의 생성자와 async 함수의 첫 await 전까지는 현재의 스택에서 실행되므로 B보다 먼저 출력됩니다. 이미 해결된 Promise의 P 반응을 먼저 등록하고 그다음 await의 이어 실행을 등록했으므로, 같은 checkpoint에서 이 순서를 따릅니다. await 0도 일반 값이라고 바로 다음 줄을 실행하지 않습니다. 타이머는 최소 지연이 0이더라도 현재 작업과 microtask가 모두 끝난 뒤에 실행됩니다. 이 출력은 React의 커밋 시점을 측정하는 예제가 아닙니다.


비동기 처리의 주요 개념 요약

이벤트 루프(event loop)는 브라우저 같은 실행 환경이 task를 하나씩 실행하고, task가 끝나 콜 스택이 비면 microtask checkpoint에서 Promise의 반응과 await의 이어 실행을 처리한 뒤, 다음 task와 렌더링의 기회로 넘어가는 실행 모델입니다. 논블로킹 I/O(non-blocking I/O)는 네트워크처럼 실행 환경에 맡길 수 있는 입출력이 끝나기를 기다리는 동안 자바스크립트가 다른 작업을 진행하는 방식이며, 긴 CPU 계산을 저절로 나눠 준다는 뜻은 아닙니다.

React의 스케줄링도 구별해 둡니다. Promise나 타이머 콜백 안의 setState는 갱신을 요청할 뿐이고, React는 갱신을 묶고 우선순위에 따라 렌더링과 커밋을 예약하므로 microtask 하나와 화면 갱신 하나가 언제나 일대일로 대응하지 않습니다. Effect는 렌더링하는 동안이 아니라, 커밋한 뒤 외부 시스템과 맞추는 단계에서 실행됩니다. 특히 async/await는 지금의 React 애플리케이션에서 서버와 통신해 데이터를 가져올 때 가장 널리 쓰는 방식이므로, 이 개념을 확실히 익혀 두세요.


정리

  • 동기 처리는 한 작업이 끝날 때까지 다음 작업이 기다리고, 비동기 처리는 실행 환경에 맡긴 작업을 기다리는 동안 다른 코드를 실행하지만 긴 CPU 계산은 여전히 스레드를 막습니다.
  • 콜백은 다른 함수가 정한 때에 호출되는 함수이며, 콜백이라는 사실 자체가 비동기를 뜻하지는 않습니다.
  • Promise의 executor는 동기적으로 실행되고, 이행이나 거부가 정해진 뒤의 반응은 microtask에서 실행되며, 상태는 한 번만 바뀝니다.
  • .then()에서 후속 Promise를 돌려줘야 바깥 체인이 그 결과와 실패를 기다리며, catch가 값을 돌려주면 이후 체인은 이행으로 이어집니다.
  • async 함수는 언제나 Promise를 돌려주고, await는 현재 함수만 멈추며, 독립된 작업은 먼저 시작한 뒤 Promise.all로 함께 기다립니다.
  • 요청 UI는 idle, loading, success, empty, error 가운데 하나의 상태로 나타내 모순되는 조합을 막습니다.

연습 문제

1. 마이크로태스크의 순서

아래 코드는 한 작업에서 실행되고 다른 코드가 큐에 작업을 더하지 않는다고 가정합니다. 출력 순서를 먼저 적고, P2가 Q를 앞지를 수 있는지 이유를 설명해 보세요.

Promise.resolve().then(() => {
  console.log('P1');
  queueMicrotask(() => console.log('P2'));
});
queueMicrotask(() => console.log('Q'));
setTimeout(() => console.log('T'), 0);
console.log('S');
해설 보기

출력 순서는 S, P1, Q, P2, T입니다. 현재 스택의 S가 먼저 실행되고, checkpoint가 시작될 때 P1과 Q가 이 순서로 기다리고 있습니다. P1 안에서 새로 넣은 P2는 이미 기다리던 Q 뒤에 들어가므로 Q를 앞지르지 못합니다.

checkpoint에서는 그 사이에 더해진 microtask까지 모두 비운 뒤 다음 타이머 task를 처리하므로 T가 마지막입니다. P2를 더 일찍 실행하고 싶다면 등록하는 코드의 위치를 바꿔야 합니다.

2. 반환을 빠뜨린 Promise 체인

아래 예제는 네트워크와 React 렌더링 없이 Promise의 결과만 비교합니다. broken과 connected를 각각 await하면 무엇을 얻을까요? 두 값이 다른 이유를 반환의 관점에서 설명해 보세요.

function loadWord() {
  return Promise.resolve('react');
}

const broken = Promise.resolve().then(() => {
  loadWord().then(word => word.toUpperCase());
});
const connected = Promise.resolve().then(() =>
  loadWord().then(word => word.toUpperCase())
);
해설 보기

broken은 undefined로, connected는 "REACT"로 이행됩니다. 첫 처리 함수는 안쪽의 Promise를 시작했지만 돌려주지 않아, 바깥 체인이 그 결과를 받지 못합니다. 두 번째는 안쪽 체인을 돌려주므로 그 완료를 기다린 뒤 문자열을 전달합니다. 어느 로그가 먼저 찍히는지보다, 어떤 Promise가 어떤 작업의 결과를 이어받는지의 문제입니다.

3. catch에서 빈 배열 돌려주기

요청을 await한 함수가 catch에서 return []를 실행한다면, 호출하는 쪽은 성공과 실패 가운데 어느 Promise 경로를 보게 될까요? 데이터가 없는 정상적인 결과와 네트워크 실패를 모두 빈 배열로 바꿔도 될까요?

해설 보기

catch의 return []는 오류를 빈 배열이라는 정상적인 값으로 회복시키므로, 호출하는 쪽은 이행 경로를 봅니다. 이것이 의도한 기본값이라면 괜찮지만, 정상적인 빈 화면과 실패한 오류 화면을 구분해야 하는 요청이라면 두 결과를 합쳐서는 안 됩니다.

오류를 다시 던지면 바깥 호출자의 catch로 전달되고, 별도의 결과 객체를 돌려주면 호출하는 쪽이 그 상태를 판별할 수 있습니다. 시작한 두 작업을 Promise.all로 묶었다면, 하나의 실패가 다른 작업을 저절로 취소하지 않는다는 점도 함께 확인합니다.

다음 글 useEffect와 데이터 요청에서는 이 비동기 처리를 써서 React 컴포넌트 안에서 실제로 데이터를 가져오는 방법을 구체적으로 알아봅니다.