본문 바로가기

비동기 프로그래밍

Promise의 상태와 체이닝, 병렬 조합을 익히고 async·await와 try·catch로 비동기 성공과 실패를 처리합니다.

6장에서는 실행 컨텍스트, this, 프로토타입처럼 자바스크립트의 내부 동작을 살펴봤고, 마지막 글 고차 함수와 콜백에서는 비동기 작업을 콜백으로 이으면 코드가 깊게 중첩되는 콜백 헬을 확인했습니다. 7장 자바스크립트 심화 II는 이 문제를 푸는 Promise와 async/await로 시작합니다.

웹 애플리케이션은 서버와 통신하거나 타이머를 기다리는 것처럼 오래 걸리는 작업을 화면을 멈추지 않고 처리해야 합니다. 브라우저의 메인 실행 흐름은 한 번에 하나의 호출만 실행하므로, 이 흐름을 오래 붙잡는 코드는 입력 처리와 화면 갱신을 늦춥니다. 그래서 네트워크 대기처럼 브라우저가 대신 진행할 수 있는 작업은 맡겨 두고, 현재 코드는 계속 실행하다가 결과가 나오면 그때 처리하는 비동기 방식을 씁니다.

다만 코드를 Promise로 감싼다고 계산이 다른 스레드로 옮겨 가지는 않습니다. new Promise()에 넘긴 함수와 결과를 처리하는 함수도 실행되는 동안에는 같은 실행 흐름을 차지합니다. 오래 걸리는 계산을 나누거나 Web Worker로 옮기는 일은 따로 설계해야 합니다.


Promise

Promise는 비동기 작업이 나중에 성공해서 값을 내놓을지, 실패해서 오류를 내놓을지를 나타내는 객체입니다. 아직 정해지지 않은 미래의 결과를 값처럼 주고받을 수 있게 해 주며, 콜백 헬 대신 위에서 아래로 읽히는 비동기 코드를 쓰기 위해 도입되었습니다.

Promise의 세 가지 상태

Promise는 다음 세 상태 가운데 하나입니다. 처음에는 작업이 끝나지 않은 대기(pending) 상태입니다. 작업이 성공하면 결과 값을 가진 이행(fulfilled) 상태가 되고, 실패하면 오류를 가진 거부(rejected) 상태가 됩니다.

이행이나 거부로 한 번 결정(settled)되면 Promise의 상태와 값은 다시 바뀌지 않습니다. 그래서 결과를 나중에 여러 번 읽어도 항상 같은 값을 얻습니다.

Promise 만들기

Promise는 new Promise() 생성자로 만들며, 생성자에는 resolve와 reject를 인수로 받는 실행 함수(executor)를 넘깁니다. 작업이 성공하면 resolve(value)를 호출해 그 값으로 이행시키고, 실패하면 reject(error)를 호출해 그 오류로 거부시킵니다.

resolve()에 일반 값 대신 다른 Promise를 넘기면 그 Promise의 결과를 따라갑니다. 그래서 resolve()를 호출한 직후에도 여전히 대기 중이거나 나중에 거부될 수 있으며, 이 때문에 “resolve했다”와 “이행되었다”를 항상 같은 뜻으로 보지는 않습니다.

function fetchData() {
    return new Promise((resolve, reject) => {
        // 비동기 작업 시뮬레이션 (예: 서버에서 데이터 가져오기)
        const success = Math.random() > 0.5; // 50% 확률로 성공/실패

        setTimeout(() => {
            if (success) {
                resolve("데이터를 성공적으로 가져왔습니다!"); // 성공 시 resolve 호출
            } else {
                reject(new Error("데이터 가져오기 실패!")); // 실패 시 Error 객체로 reject 호출
            }
        }, 1000); // 최소 1초 뒤에 결과 결정
    });
}

// Promise 사용 예시
fetchData()
    .then(message => { // 이행되면 실행됩니다.
        console.log("성공:", message);
    })
    .catch(error => { // 거부되면 실행됩니다.
        console.error("오류:", error.message);
    })
    .finally(() => { // 이행이든 거부든 결정되면 실행됩니다.
        console.log("작업 완료!");
    });

console.log("Promise 작업 시작 (비동기)"); // 이 메시지가 먼저 출력됩니다.

.then()에는 이행했을 때 실행할 함수를, .catch()에는 거부했을 때 실행할 함수를, .finally()에는 결과와 관계없이 마지막에 실행할 함수를 넘깁니다. Math.random() 때문에 실행할 때마다 결과가 달라지며, 먼저 “Promise 작업 시작 (비동기)”가 출력된 뒤 성공이면 “성공: 데이터를 성공적으로 가져왔습니다!”, 실패면 “오류: 데이터 가져오기 실패!”가 나오고 마지막에 “작업 완료!”가 출력됩니다.

reject()에는 문자열보다 Error 객체를 넘기는 편이 좋습니다. Error 객체는 message와 함께 오류가 생긴 위치를 담은 스택 정보를 가지므로 문제를 찾기 쉽습니다. 또 finally()는 Promise가 결정되어야 실행되므로, 영원히 대기하는 Promise라면 finally()도 실행되지 않습니다.

실행 순서

Promise의 결과를 처리하는 함수는 언제 실행될까요? 다음 코드의 출력 순서를 먼저 예상해 보세요.

console.log("A 시작");
const orderForPromise = new Promise(resolve => {
    console.log("B executor");
    resolve(7);
});
orderForPromise.then(value => console.log("D then", value));
setTimeout(() => console.log("E 타이머"), 0);
console.log("C 현재 코드 끝");

출력은 “A 시작”, “B executor”, “C 현재 코드 끝”, “D then 7”, “E 타이머” 순서입니다. 실행 함수는 new Promise() 안에서 곧바로 동기적으로 호출되므로 B가 A 다음에 나옵니다. Promise가 이미 이행되었더라도 .then()의 함수는 지금 실행 중인 코드가 끝난 다음에 실행되며, 지연 시간이 0인 타이머보다도 먼저 실행됩니다.

task와 Promise microtask 실행 순서 — 상태 추적

동기 코드/executor·then microtask·타이머 task에서 스택·대기 큐·출력 순서가 바뀐다. 타이머 준비/paint 조건을 명시한 큐 snapshot이 핵심이다.

task와 Promise microtask 실행 순서

SEQUENCE

task와 Promise microtask 실행 순서

현재 작업과 Promise executor는 동기로 실행된다. 예약된 Promise 반응은 다음 task보다 먼저 microtask checkpoint에서 처리된다. 브라우저의 그리기는 별도의 기회다.

현재 코드·Promise 반응·타이머 태스크현재 코드와 Promise executor는 동기로 실행한다. 타이머 등록은 API로, then 반응은 마이크로태스크 큐로 향한다. 현재 코드가 끝난 뒤 반응 큐를 처리한다. 타이머 준비 시점은 가변이며 준비된 작업은 태스크로 실행 기회를 얻는다. 모바일은 타이머 API와 태스크 예약 역할을 한 lifeline으로 묶어 같은 메시지 방향을 유지한다. 그리기는 반드시 실행되는 메시지가 아니라 별도 기회다. 현재 JS동기 호출타이머 API등록 · 대기MicrotaskPromise 반응Task queue타이머 작업executor 동기 실행타이머 등록then 반응 예약현재 코드 끝마이크로태스크 모두 처리타이머 준비 → 작업 예약실행 기회를 얻은 다음 태스크타이머 예약 시점은 가변 · 그리기는 별도 기회
현재 코드·Promise 반응·타이머 태스크현재 코드와 Promise executor는 동기로 실행한다. 타이머 등록은 API로, then 반응은 마이크로태스크 큐로 향한다. 현재 코드가 끝난 뒤 반응 큐를 처리한다. 타이머 준비 시점은 가변이며 준비된 작업은 태스크로 실행 기회를 얻는다. 모바일은 타이머 API와 태스크 예약 역할을 한 lifeline으로 묶어 같은 메시지 방향을 유지한다. 그리기는 반드시 실행되는 메시지가 아니라 별도 기회다.JS동기Promise반응 큐타이머API/taskexecutor는 동기타이머 등록then 반응 예약현재 코드 끝반응 큐 처리타이머 작업 준비다음 태스크 실행준비 시점은 가변그리기는 별도 기회API·태스크 역할 병합
  1. Current task

    script와 Promise executor가 call stack에서 동기로 실행된다.

  2. Host 등록

    setTimeout은 Host API에 timer를 등록하고 현재 task를 막지 않는다.

  3. Microtask 예약

    Promise가 fulfilled/rejected로 결정되면 해당 then 반응이 microtask queue에 예약된다. 이미 결정된 Promise에 then을 등록해도 동기 호출되지 않는다.

  4. Microtask checkpoint

    현재 task가 끝나면 queue가 비워질 때까지 microtask를 모두 실행한다.

  5. Render opportunity

    microtask drain 뒤 브라우저가 필요하면 layout·paint frame을 만든다.

  6. Next task

    Host completion은 task queue로 돌아오고 그 뒤 다음 task로 실행된다.

  • task 실행·전달
  • host·queue 예약
  • microtask checkpoint

Microtask는 다음 task 전에 처리

현재 작업이 끝난 뒤 microtask queue가 비워질 때까지 실행한다. 실행 중 추가된 microtask도 처리하므로, 이를 계속 추가하면 다음 task와 그리기가 지연될 수 있다.

타이머 완료의 큐 등록 시점과 그리기 여부는 이 예제의 순서로 항상 고정되지 않는다. timer 시간이 먼저 끝났더라도 현재 task와 microtask checkpoint를 끼어들어 실행하지 않는다. completion은 task queue에서 다음 차례를 기다린다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석Current task: script와 Promise executor가 call stack에서 동기로 실행된다.
원도표 조건·주석Host 등록: setTimeout은 Host API에 timer를 등록하고 현재 task를 막지 않는다.
원도표 조건·주석Microtask 예약: Promise가 fulfilled/rejected로 결정되면 해당 then 반응이 microtask queue에 예약된다. 이미 결정된 Promise에 then을 등록해도 동기 호출되지 않는다.
원도표 조건·주석Microtask checkpoint: 현재 task가 끝나면 queue가 비워질 때까지 microtask를 모두 실행한다.
원도표 조건·주석Render opportunity: microtask drain 뒤 브라우저가 필요하면 layout·paint frame을 만든다.
원도표 조건·주석Next task: Host completion은 task queue로 돌아오고 그 뒤 다음 task로 실행된다.
원도표 설명타이머 완료의 큐 등록 시점과 그리기 여부는 이 예제의 순서로 항상 고정되지 않는다. timer 시간이 먼저 끝났더라도 현재 task와 microtask checkpoint를 중단하고 실행하지 않는다. completion은 task queue에서 다음 차례를 기다린다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
현재 동기 taskstack: 현재 동기 실행 / reaction: 실행 전일반 코드와 Promise executor를 실행하고 timer를 등록한다executor는 생성 중 바로 실행된다
microtask checkpointmicrotask: 현재 reaction / timer: 후속 task 준비 가능stack 뒤 Promise reaction을 실행한다반응은 executor와 별도 작업이다
timer task현재 task: timer callback / paint: 고정 순서 보장 아님호스트가 선택한 timer callback이 새 stack에서 실행된다0ms 요청은 즉시 동기 호출을 뜻하지 않는다

task와 Promise microtask 실행 순서 — 상태 추적

동기 코드/executor·then microtask·타이머 task에서 스택·대기 큐·출력 순서가 바뀐다. 타이머 준비/paint 조건을 명시한 큐 snapshot이 핵심이다.

task와 Promise microtask 실행 순서

SEQUENCE

task와 Promise microtask 실행 순서

현재 작업과 Promise executor는 동기로 실행된다. 예약된 Promise 반응은 다음 task보다 먼저 microtask checkpoint에서 처리된다. 브라우저의 그리기는 별도의 기회다.

현재 코드·Promise 반응·타이머 태스크현재 코드와 Promise executor는 동기로 실행한다. 타이머 등록은 API로, then 반응은 마이크로태스크 큐로 향한다. 현재 코드가 끝난 뒤 반응 큐를 처리한다. 타이머 준비 시점은 가변이며 준비된 작업은 태스크로 실행 기회를 얻는다. 모바일은 타이머 API와 태스크 예약 역할을 한 lifeline으로 묶어 같은 메시지 방향을 유지한다. 그리기는 반드시 실행되는 메시지가 아니라 별도 기회다. 현재 JS동기 호출타이머 API등록 · 대기MicrotaskPromise 반응Task queue타이머 작업executor 동기 실행타이머 등록then 반응 예약현재 코드 끝마이크로태스크 모두 처리타이머 준비 → 작업 예약실행 기회를 얻은 다음 태스크타이머 예약 시점은 가변 · 그리기는 별도 기회
현재 코드·Promise 반응·타이머 태스크현재 코드와 Promise executor는 동기로 실행한다. 타이머 등록은 API로, then 반응은 마이크로태스크 큐로 향한다. 현재 코드가 끝난 뒤 반응 큐를 처리한다. 타이머 준비 시점은 가변이며 준비된 작업은 태스크로 실행 기회를 얻는다. 모바일은 타이머 API와 태스크 예약 역할을 한 lifeline으로 묶어 같은 메시지 방향을 유지한다. 그리기는 반드시 실행되는 메시지가 아니라 별도 기회다.JS동기Promise반응 큐타이머API/taskexecutor는 동기타이머 등록then 반응 예약현재 코드 끝반응 큐 처리타이머 작업 준비다음 태스크 실행준비 시점은 가변그리기는 별도 기회API·태스크 역할 병합
  1. Current task

    script와 Promise executor가 call stack에서 동기로 실행된다.

  2. Host 등록

    setTimeout은 Host API에 timer를 등록하고 현재 task를 막지 않는다.

  3. Microtask 예약

    Promise가 fulfilled/rejected로 결정되면 해당 then 반응이 microtask queue에 예약된다. 이미 결정된 Promise에 then을 등록해도 동기 호출되지 않는다.

  4. Microtask checkpoint

    현재 task가 끝나면 queue가 비워질 때까지 microtask를 모두 실행한다.

  5. Render opportunity

    microtask drain 뒤 브라우저가 필요하면 layout·paint frame을 만든다.

  6. Next task

    Host completion은 task queue로 돌아오고 그 뒤 다음 task로 실행된다.

  • task 실행·전달
  • host·queue 예약
  • microtask checkpoint

Microtask는 다음 task 전에 처리

현재 작업이 끝난 뒤 microtask queue가 비워질 때까지 실행한다. 실행 중 추가된 microtask도 처리하므로, 이를 계속 추가하면 다음 task와 그리기가 지연될 수 있다.

타이머 완료의 큐 등록 시점과 그리기 여부는 이 예제의 순서로 항상 고정되지 않는다. timer 시간이 먼저 끝났더라도 현재 task와 microtask checkpoint를 끼어들어 실행하지 않는다. completion은 task queue에서 다음 차례를 기다린다.

이 예의 조건

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

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석Current task: script와 Promise executor가 call stack에서 동기로 실행된다.
원도표 조건·주석Host 등록: setTimeout은 Host API에 timer를 등록하고 현재 task를 막지 않는다.
원도표 조건·주석Microtask 예약: Promise가 fulfilled/rejected로 결정되면 해당 then 반응이 microtask queue에 예약된다. 이미 결정된 Promise에 then을 등록해도 동기 호출되지 않는다.
원도표 조건·주석Microtask checkpoint: 현재 task가 끝나면 queue가 비워질 때까지 microtask를 모두 실행한다.
원도표 조건·주석Render opportunity: microtask drain 뒤 브라우저가 필요하면 layout·paint frame을 만든다.
원도표 조건·주석Next task: Host completion은 task queue로 돌아오고 그 뒤 다음 task로 실행된다.
원도표 설명타이머 완료의 큐 등록 시점과 그리기 여부는 이 예제의 순서로 항상 고정되지 않는다. timer 시간이 먼저 끝났더라도 현재 task와 microtask checkpoint를 중단하고 실행하지 않는다. completion은 task queue에서 다음 차례를 기다린다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
현재 동기 taskstack: 현재 동기 실행 / reaction: 실행 전일반 코드와 Promise executor를 실행하고 timer를 등록한다executor는 생성 중 바로 실행된다
microtask checkpointmicrotask: 현재 reaction / timer: 후속 task 준비 가능stack 뒤 Promise reaction을 실행한다반응은 executor와 별도 작업이다
timer task현재 task: timer callback / paint: 고정 순서 보장 아님호스트가 선택한 timer callback이 새 stack에서 실행된다0ms 요청은 즉시 동기 호출을 뜻하지 않는다

위 그림은 이 순서가 만들어지는 과정을 보여 줍니다. 현재 스크립트는 하나의 태스크(task)로 실행되고, 실행 함수도 그 안에서 동기적으로 실행됩니다. setTimeout()은 타이머를 브라우저의 타이머 API에 등록만 하고, 이행된 Promise의 .then() 함수는 마이크로태스크 큐(microtask queue)에 예약됩니다. 이미 결정된 Promise에 .then()을 붙여도 그 자리에서 바로 호출되지 않고 예약된다는 점이 중요합니다.

현재 코드가 끝나면 마이크로태스크 큐가 빌 때까지 예약된 마이크로태스크를 모두 실행합니다. 그 뒤 브라우저가 필요하면 화면을 그리고, 준비된 타이머 작업이 태스크 큐(task queue)에서 다음 차례로 실행됩니다. 그래서 D가 E보다 먼저 나옵니다.

그림 아래의 설명처럼 마이크로태스크를 처리하는 도중에 새로 추가된 마이크로태스크도 같은 차례에 모두 실행됩니다. 마이크로태스크를 끝없이 추가하면 다음 태스크와 화면 그리기가 계속 미뤄질 수 있습니다. 또 타이머의 시간이 먼저 지났더라도 현재 태스크나 마이크로태스크 처리 중간에 끼어들지는 않으며, 타이머 작업이 큐에 들어가는 시점과 화면을 그리는 시점은 이 예제처럼 항상 정해져 있지 않습니다.

Promise 체이닝

Promise의 큰 장점은 .then()으로 여러 비동기 작업을 차례로 이어 붙이는 체이닝(chaining)입니다. 각 .then()은 앞 단계의 결과를 받아 다음 작업을 시작합니다.

function step1Promise() {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log("Step 1 완료");
            resolve("Step1 결과"); // 다음 .then()으로 전달될 값
        }, 1000);
    });
}

function step2Promise(prevResult) {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log(`Step 2 완료: 이전 결과는 ${prevResult}`);
            resolve("Step2 결과");
        }, 1000);
    });
}

function step3Promise(prevResult) {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log(`Step 3 완료: 이전 결과는 ${prevResult}`);
            resolve("모든 단계 완료!");
        }, 1000);
    });
}

// Promise 체이닝으로 콜백 헬 해결
step1Promise()
    .then(result1 => step2Promise(result1)) // Step1 결과 -> Step2로 전달
    .then(result2 => step3Promise(result2)) // Step2 결과 -> Step3로 전달
    .then(finalResult => {
        console.log(finalResult); // 최종 결과 출력
    })
    .catch(error => { // 체인 중 어느 한 곳에서라도 오류가 발생하면 catch로 이동
        console.error("오류 발생:", error);
    });

console.log("Promise 체인 시작!"); // 이 메시지가 가장 먼저 출력됩니다.

“Promise 체인 시작!”이 가장 먼저 출력되고, 약 1초 간격으로 “Step 1 완료”, “Step 2 완료: 이전 결과는 Step1 결과”, “Step 3 완료: 이전 결과는 Step2 결과”가 이어진 뒤, 곧바로 “모든 단계 완료!”가 출력됩니다. 앞 장의 콜백 헬 예제와 같은 순서지만, 중첩 없이 한 줄씩 아래로 읽힙니다. 어느 단계에서든 거부되면 나머지 .then()을 건너뛰고 마지막 .catch()로 갑니다.

.then()은 항상 새 Promise를 돌려주므로 계속 이어 붙일 수 있습니다. .then()에 넘긴 함수가 Promise를 돌려주면, 새 Promise는 그 Promise의 결과를 따릅니다. 그 Promise가 이행하면 값이 다음 .then()으로 넘어가고, 거부되거나 함수 안에서 오류를 던지면 다음 .catch()로 넘어갑니다. 위 코드의 result1 => step2Promise(result1)처럼 Promise를 돌려줘야 다음 단계가 그 작업을 기다립니다. 중괄호를 붙이고 return을 빠뜨리면 undefined가 넘어가, 다음 단계가 작업이 끝나기를 기다리지 않고 바로 실행됩니다.

더 깊이: 아무도 처리하지 않은 거부는 어떻게 될까

체인 끝에 .catch()가 없는 Promise가 거부되면, 그 오류를 받을 함수가 없습니다. 이런 거부를 처리되지 않은 거부(unhandled rejection)라고 하며, 실행 환경마다 다르게 다룹니다. 브라우저는 그 페이지의 전역 객체에 unhandledrejection 이벤트를 보내고, 아무도 막지 않으면 개발자 도구의 콘솔에 오류를 출력합니다. 이 이벤트의 reason에는 거부 이유가 들어 있으므로, window.addEventListener('unhandledrejection', …)로 받아 오류 수집 서버에 보내는 식으로 놓친 오류를 찾는 데 씁니다.

Node.js는 훨씬 엄격합니다. Node.js 15부터 --unhandled-rejections의 기본 모드가 throw로 바뀌어, process.on('unhandledRejection', …) 같은 처리기가 없으면 처리되지 않은 거부를 잡히지 않은 예외로 다루고 프로세스가 종료됩니다. 그 전에는 경고만 출력하고 계속 실행했습니다. 그래서 브라우저에서는 콘솔에 빨간 줄 하나로 끝나던 실수가, 같은 코드를 서버에서 실행하면 서버 전체를 멈추게 할 수 있습니다.

어느 환경이든 기본은 거부될 수 있는 모든 Promise 체인의 끝에 .catch()를 두거나 await를 try...catch로 감싸는 것입니다. 전역 이벤트는 그래도 빠진 곳을 찾아내기 위한 안전망으로 씁니다. Node.js의 모드별 동작은 Node.js의 명령줄 옵션 문서에 있습니다.


여러 Promise 조합하기

여러 Promise의 결과를 어떻게 모을지 정하는 메서드들이 있습니다. 이 메서드들은 작업을 새로 시작하는 것이 아니라 이미 시작된 작업의 결과를 모으는 규칙입니다. 아래 예제의 타이머들도 Promise.all()을 호출할 때가 아니라 각 new Promise()가 실행될 때 이미 등록됩니다.

Promise 조합과 실패 경로 — 상태 추적

본문 race 예제의 pB 1000ms 이행, pC 2000ms 거부, pA 3000ms 이행에서 조합은 B 완료를 유지하고 나머지 작업도 계속 정착한다. 실제 타이머의 값/결과/미취소 상태를 보여 주고 전체 정책 표는 보존할 가치가 있다.

Promise 조합과 실패 경로

FLOWCHART · WEB PLATFORM CONTRACT

Promise 조합과 실패 경로

모든 작업을 기다릴지, 가장 이른 결과를 받을지 정한다. 실패를 다루는 규칙에 따라 조합 메서드가 달라지며, 작업 취소는 별도로 구현한다.

Promise 조합의 분기와 취소 경계모든 결과 경로는 모든 성공이 필요하면 all, 실패도 기록하면 allSettled로 끝난다. 첫 결과 경로는 첫 결정이면 race, 첫 성공이면 any로 끝난다. all과 allSettled의 배열은 입력 순서이고, any에서 모두 거부되면 AggregateError다. 모바일은 두 경로를 세로로 배치하며 각 결과는 끝난다. 취소는 별도 계약이며 조합 메서드가 자동으로 제공하지 않는다.결과 조합의 목적모두 필요 / 첫 결과 필요모든 성공이 필요한가?아니면 성공·실패 모두 기록첫 결정인가, 첫 성공인가?실패를 첫 결과로 쓸지 확인모든 결과첫 결과Promise.all모두 이행 → 값 배열하나 거부 → 조합 거부allSettled모두 결정 → 상태 배열성공·실패 개별 기록Promise.race첫 이행 또는 거부첫 결정에 따름Promise.any첫 이행 값모두 거부 → AggregateError성공만모두 기록첫 결정첫 성공결과가 정해져도 나머지 작업은 자동 취소되지 않음작업 취소는 별도 API 계약해제 가능한 타이머 ID · signal을 지원하는 요청
Promise 조합의 분기와 취소 경계모든 결과 경로는 모든 성공이 필요하면 all, 실패도 기록하면 allSettled로 끝난다. 첫 결과 경로는 첫 결정이면 race, 첫 성공이면 any로 끝난다. all과 allSettled의 배열은 입력 순서이고, any에서 모두 거부되면 AggregateError다. 모바일은 두 경로를 세로로 배치하며 각 결과는 끝난다. 취소는 별도 계약이며 조합 메서드가 자동으로 제공하지 않는다.모든 결과 필요?예 / 아니오로 분기실패 즉시 거부?모두 성공 필요: 예Promise.all입력 순서의 값하나 거부: 조합 거부allSettled모든 상태 기록입력 순서 유지첫 성공 필요?첫 결과 경로Promise.any첫 성공의 값모두 거부: 오류Promise.race첫 이행 또는 거부첫 결정에 따름예예아니오 → 모두 기록모두 필요 없으면예아니오 → 첫 결정어느 경로도 자동 취소 없음취소는 작업별 계약타이머 ID 해제지원 API에 signal 전달

Promise.all

입력 순서의 모든 fulfilled 값이 필요하고 하나의 reject가 전체 실패일 때. 나머지 작업을 자동 취소하지는 않는다.

Promise.allSettled

입력 순서로 모든 작업의 fulfilled/rejected status와 value/reason을 모아 부분 실패를 각각 처리할 때

Promise.race

fulfilled 또는 rejected를 가리지 않고 첫 settled 결과가 필요할 때

Promise.any

첫 fulfilled가 필요할 때. 모두 reject면 AggregateError다.

조합 결과 결정 ≠ 나머지 작업 취소

all의 실패나 race·any의 결과가 정해져도 나머지 작업은 자동으로 중단되지 않는다. AbortController의 signal을 이를 지원하는 각 작업에 전달해 취소를 설계한다.

all·allSettled·race·any는 완료와 실패를 모으는 규칙이다. 실제 I/O 중단은 AbortSignal을 이해하는 작업이 협력해야 한다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
세 작업 시작A: 3000ms 대기 / B/C: 1000/2000ms 대기pA/pB/pC timer가 모두 등록되고 race는 pending이다race가 작업을 새로 시작하는 것은 아니다
B 먼저 이행race: B 완료 / A/C: 계속 대기B 완료로 race가 fulfilled된다첫 settlement 뒤 남은 작업은 자동 취소되지 않는다
나머지 정착race: B 완료 유지 / C/A: 거부/이행 완료C 실패와 A 완료 뒤에도 race 결과는 B다조합 결과와 원 작업들의 상태는 서로 다르다

Promise 조합과 실패 경로 — 상태 추적

본문 race 예제의 pB 1000ms 이행, pC 2000ms 거부, pA 3000ms 이행에서 조합은 B 완료를 유지하고 나머지 작업도 계속 정착한다. 실제 타이머의 값/결과/미취소 상태를 보여 주고 전체 정책 표는 보존할 가치가 있다.

Promise 조합과 실패 경로

FLOWCHART · WEB PLATFORM CONTRACT

Promise 조합과 실패 경로

모든 작업을 기다릴지, 가장 이른 결과를 받을지 정한다. 실패를 다루는 규칙에 따라 조합 메서드가 달라지며, 작업 취소는 별도로 구현한다.

Promise 조합의 분기와 취소 경계모든 결과 경로는 모든 성공이 필요하면 all, 실패도 기록하면 allSettled로 끝난다. 첫 결과 경로는 첫 결정이면 race, 첫 성공이면 any로 끝난다. all과 allSettled의 배열은 입력 순서이고, any에서 모두 거부되면 AggregateError다. 모바일은 두 경로를 세로로 배치하며 각 결과는 끝난다. 취소는 별도 계약이며 조합 메서드가 자동으로 제공하지 않는다.결과 조합의 목적모두 필요 / 첫 결과 필요모든 성공이 필요한가?아니면 성공·실패 모두 기록첫 결정인가, 첫 성공인가?실패를 첫 결과로 쓸지 확인모든 결과첫 결과Promise.all모두 이행 → 값 배열하나 거부 → 조합 거부allSettled모두 결정 → 상태 배열성공·실패 개별 기록Promise.race첫 이행 또는 거부첫 결정에 따름Promise.any첫 이행 값모두 거부 → AggregateError성공만모두 기록첫 결정첫 성공결과가 정해져도 나머지 작업은 자동 취소되지 않음작업 취소는 별도 API 계약해제 가능한 타이머 ID · signal을 지원하는 요청
Promise 조합의 분기와 취소 경계모든 결과 경로는 모든 성공이 필요하면 all, 실패도 기록하면 allSettled로 끝난다. 첫 결과 경로는 첫 결정이면 race, 첫 성공이면 any로 끝난다. all과 allSettled의 배열은 입력 순서이고, any에서 모두 거부되면 AggregateError다. 모바일은 두 경로를 세로로 배치하며 각 결과는 끝난다. 취소는 별도 계약이며 조합 메서드가 자동으로 제공하지 않는다.모든 결과 필요?예 / 아니오로 분기실패 즉시 거부?모두 성공 필요: 예Promise.all입력 순서의 값하나 거부: 조합 거부allSettled모든 상태 기록입력 순서 유지첫 성공 필요?첫 결과 경로Promise.any첫 성공의 값모두 거부: 오류Promise.race첫 이행 또는 거부첫 결정에 따름예예아니오 → 모두 기록모두 필요 없으면예아니오 → 첫 결정어느 경로도 자동 취소 없음취소는 작업별 계약타이머 ID 해제지원 API에 signal 전달

Promise.all

입력 순서의 모든 fulfilled 값이 필요하고 하나의 reject가 전체 실패일 때. 나머지 작업을 자동 취소하지는 않는다.

Promise.allSettled

입력 순서로 모든 작업의 fulfilled/rejected status와 value/reason을 모아 부분 실패를 각각 처리할 때

Promise.race

fulfilled 또는 rejected를 가리지 않고 첫 settled 결과가 필요할 때

Promise.any

첫 fulfilled가 필요할 때. 모두 reject면 AggregateError다.

조합 결과 결정 ≠ 나머지 작업 취소

all의 실패나 race·any의 결과가 정해져도 나머지 작업은 자동으로 중단되지 않는다. AbortController의 signal을 이를 지원하는 각 작업에 전달해 취소를 설계한다.

all·allSettled·race·any는 완료와 실패를 모으는 규칙이다. 실제 I/O 중단은 AbortSignal을 이해하는 작업이 협력해야 한다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
세 작업 시작A: 3000ms 대기 / B/C: 1000/2000ms 대기pA/pB/pC timer가 모두 등록되고 race는 pending이다race가 작업을 새로 시작하는 것은 아니다
B 먼저 이행race: B 완료 / A/C: 계속 대기B 완료로 race가 fulfilled된다첫 settlement 뒤 남은 작업은 자동 취소되지 않는다
나머지 정착race: B 완료 유지 / C/A: 거부/이행 완료C 실패와 A 완료 뒤에도 race 결과는 B다조합 결과와 원 작업들의 상태는 서로 다르다

위 그림은 목적에 따라 조합 메서드를 고르는 순서를 보여 줍니다. 먼저 모든 작업의 결과가 필요한지, 가장 먼저 나온 결과 하나면 되는지를 정합니다. 모든 결과가 필요하고 모두 성공해야 한다면 Promise.all()을, 실패한 작업까지 각각 기록해야 한다면 Promise.allSettled()를 씁니다. 첫 결과가 필요할 때 성공이든 실패든 먼저 결정된 것을 따르려면 Promise.race()를, 실패는 넘기고 첫 성공을 쓰려면 Promise.any()를 씁니다.

그림 아래쪽은 조합의 결과가 정해지는 것과 작업을 취소하는 것이 다르다는 점을 강조합니다. Promise.all()이 실패로 끝나거나 Promise.race()의 결과가 정해져도, 나머지 작업은 저절로 멈추지 않고 계속 진행됩니다. 작업을 실제로 중단하려면 타이머는 clearTimeout()으로 해제하고, fetch()처럼 취소를 지원하는 API에는 AbortController의 signal을 넘겨 중단해야 합니다.

Promise.all()

Promise.all()은 모든 Promise가 이행될 때까지 기다렸다가, 결과를 입력 배열과 같은 순서의 배열로 돌려줍니다. 하나라도 거부되면 다른 작업을 기다리지 않고 그 오류로 바로 거부됩니다.

const p1 = new Promise(resolve => setTimeout(() => resolve("P1 완료"), 3000));
const p2 = new Promise(resolve => setTimeout(() => resolve("P2 완료"), 1000));
const p3 = new Promise(resolve => setTimeout(() => resolve("P3 완료"), 2000));

Promise.all([p1, p2, p3])
    .then(results => {
        console.log("모두 완료:", results); // 약 3초 뒤: ["P1 완료", "P2 완료", "P3 완료"]
    })
    .catch(error => {
        console.error("하나라도 실패:", error.message);
    });

// 실패 예시
const p4 = Promise.reject(new Error("P4 실패"));
Promise.all([p1, p4, p3])
    .then(results => console.log("모두 완료:", results))
    .catch(error => console.error("하나라도 실패:", error.message)); // 곧바로: 하나라도 실패: P4 실패

p2가 가장 먼저 끝나지만 결과 배열의 첫 원소가 되지는 않으며, 배열은 입력한 [p1, p2, p3]의 순서를 그대로 따릅니다. 두 번째 조합은 이미 거부된 p4 때문에 타이머를 기다리지 않고 먼저 실패를 출력하고, 첫 번째 조합의 결과는 약 3초 뒤에 출력됩니다.

Promise.race()

Promise.race()는 가장 먼저 결정된 Promise의 결과를 그대로 따릅니다. 먼저 결정된 것이 이행이면 그 값으로 이행하고, 거부면 그 오류로 거부합니다.

const pA = new Promise(resolve => setTimeout(() => resolve("A 완료"), 3000));
const pB = new Promise(resolve => setTimeout(() => resolve("B 완료"), 1000));
const pC = new Promise((resolve, reject) => setTimeout(() => reject(new Error("C 실패")), 2000));

Promise.race([pA, pB, pC])
    .then(result => {
        console.log("가장 빠른 결과:", result); // 약 1초 뒤: 가장 빠른 결과: B 완료
    })
    .catch(error => {
        console.error("가장 빠른 오류:", error.message);
    });

// 배열의 순서만 바꿔도 각 작업이 끝나는 순서는 그대로입니다.
Promise.race([pA, pC, pB])
    .then(result => console.log("가장 빠른 결과:", result)) // 약 1초 뒤: 가장 빠른 결과: B 완료
    .catch(error => console.error("가장 빠른 오류:", error.message));

pB가 1초로 가장 먼저 이행되므로 두 조합 모두 “B 완료”를 결과로 씁니다. race는 배열의 순서가 아니라 실제로 결정된 순서를 따르기 때문에 배열에서 pC를 앞에 둬도 결과는 같습니다. 만약 pC의 지연이 가장 짧았다면 실패가 첫 결과가 되어 .catch()가 실행됩니다.

Promise.allSettled()와 Promise.any()

그림의 나머지 두 메서드는 실패를 다르게 다룰 때 씁니다.

필요한 것조합 메서드결과
성공과 실패를 모두 기록한 뒤 처리Promise.allSettled()입력 순서대로 각 작업의 상태와 값 또는 이유를 담은 배열
실패는 넘기고 첫 성공을 사용Promise.any()첫 이행 값, 모두 거부되면 AggregateError
const okTask = new Promise(resolve => setTimeout(() => resolve("성공 값"), 1000));
const failTask = new Promise((resolve, reject) => setTimeout(() => reject(new Error("실패 이유")), 500));

Promise.allSettled([okTask, failTask]).then(results => {
    results.forEach(result => {
        if (result.status === "fulfilled") {
            console.log("이행:", result.value);
        } else {
            console.log("거부:", result.reason.message);
        }
    });
});

Promise.any([okTask, failTask]).then(value => console.log("첫 성공:", value));

약 1초 뒤 Promise.allSettled()는 실패한 작업이 있어도 거부되지 않고, 입력 순서대로 “이행: 성공 값”과 “거부: 실패 이유”를 출력합니다. Promise.any()는 0.5초 뒤의 실패를 넘기고 1초 뒤의 첫 성공을 기다려 “첫 성공: 성공 값”을 출력합니다. race라면 0.5초에 먼저 결정된 실패가 결과가 되었을 것이므로, race는 첫 결정을, any는 첫 성공을 따른다는 차이를 기억해 두세요.


async와 await

async/await는 ES2017에 추가된 문법으로, Promise 기반의 비동기 코드를 동기 코드처럼 위에서 아래로 읽히게 써 줍니다. 콜백 헬은 물론 길어진 .then() 체인도 더 단순하게 만들 수 있습니다.

async 함수

함수 앞에 async를 붙이면 그 함수는 항상 Promise를 돌려줍니다. 함수 안에서 일반 값을 return하면 그 값으로 이행된 Promise가, 오류를 throw하면 그 오류로 거부된 Promise가 반환됩니다.

async function getGreeting() {
    return "안녕하세요!"; // Promise.resolve("안녕하세요!")와 동일
}

getGreeting().then(message => console.log(message)); // 결과: 안녕하세요!

async function getError() {
    throw new Error("오류 발생!"); // Promise.reject(new Error("오류 발생!"))와 동일
}

getError().catch(error => console.error(error.message)); // 결과: 오류 발생!

await 키워드

await는 async 함수 안이나 ES 모듈의 최상위에서 쓸 수 있습니다. await 뒤의 Promise가 이행되면 await 식은 그 결과 값이 되고, 거부되면 await 자리에서 그 오류가 던져지므로 try...catch로 잡을 수 있습니다. await 뒤에 Promise가 아닌 일반 값을 써도 되지만, 그때도 함수의 나머지 부분은 지금 실행 중인 동기 코드가 끝난 뒤에 이어서 실행됩니다.

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function processOrder() {
    console.log("주문 처리 시작...");

    try {
        // 1. 첫 번째 비동기 작업 대기
        await delay(1000); // 1초 대기
        console.log("Step 1: 주문 접수 완료");

        // 2. 두 번째 비동기 작업 대기
        await delay(1500); // 1.5초 대기
        console.log("Step 2: 상품 준비 완료");

        // 3. 세 번째 비동기 작업 대기 (오류 발생 가능성)
        const randomNumber = Math.random();
        if (randomNumber < 0.3) {
            throw new Error("배송 중 문제 발생!");
        }
        await delay(1000); // 1초 대기
        console.log("Step 3: 배송 시작");

        // 최종 결과 반환
        return "주문이 성공적으로 완료되었습니다!";

    } catch (error) {
        console.error("주문 처리 중 오류 발생:", error.message);
        throw error; // 오류를 외부로 다시 던져서 .catch()로 처리되도록 함
    } finally {
        console.log("주문 처리 과정 종료.");
    }
}

// async 함수 호출 및 결과 처리
processOrder()
    .then(message => console.log("최종 결과:", message))
    .catch(err => console.error("최종 실패:", err.message));

console.log("프로세스 시작 명령 (비동기)"); // "주문 처리 시작..." 다음, 첫 await로 중단된 뒤 출력됩니다.

processOrder()를 호출하면 첫 await까지는 동기적으로 실행되어 “주문 처리 시작...”이 먼저 출력됩니다. 첫 await에서 함수는 대기 중인 Promise를 호출한 쪽에 돌려주고 멈추므로, 그다음 “프로세스 시작 명령 (비동기)”가 출력됩니다. 이후 약 1초 뒤 “Step 1: 주문 접수 완료”, 다시 약 1.5초 뒤 “Step 2: 상품 준비 완료”가 출력됩니다.

성공하면 약 1초 뒤 “Step 3: 배송 시작”, “주문 처리 과정 종료.”, “최종 결과: 주문이 성공적으로 완료되었습니다!”가 이어집니다. 30% 확률로 실패하면 “주문 처리 중 오류 발생: 배송 중 문제 발생!”, “주문 처리 과정 종료.”, “최종 실패: 배송 중 문제 발생!” 순서입니다. 함수 안의 catch가 오류를 다시 던지므로 바깥의 .catch()까지 실패가 전달되고, 두 경우 모두 함수 안의 finally가 바깥의 결과 처리보다 먼저 실행됩니다.

await는 자바스크립트 전체의 실행을 멈추는 것이 아니라, 그 async 함수의 나머지 부분만 미뤄 둡니다. 호출한 쪽은 Promise를 받아 다음 코드를 계속 실행하고, 기다리던 Promise가 결정되면 async 함수가 멈춘 곳에서 다시 이어집니다. 그래서 비동기 흐름을 위에서 아래로 읽히게 쓰면서도, 오류는 익숙한 try...catch로 처리할 수 있습니다. 이 예제에는 진행 중인 주문을 취소하는 기능은 없습니다.


정리

  • Promise는 비동기 작업의 결과를 대기, 이행, 거부 세 상태로 나타내며, 한 번 결정되면 상태와 값이 바뀌지 않습니다.
  • new Promise()의 실행 함수는 동기적으로 실행되고, .then()의 함수는 이미 이행된 Promise라도 현재 코드가 끝난 뒤 마이크로태스크로 실행됩니다.
  • 마이크로태스크는 다음 태스크보다 먼저 모두 처리되므로, 지연 시간이 0인 타이머보다 .then()이 먼저 실행됩니다.
  • .then()은 새 Promise를 돌려주며, 넘긴 함수가 Promise를 return해야 다음 단계가 그 작업을 기다립니다.
  • all()은 모두 성공, allSettled()는 모든 결과 기록, race()는 첫 결정, any()는 첫 성공을 따르며, 결과가 정해져도 나머지 작업은 취소되지 않습니다.
  • async 함수는 항상 Promise를 돌려주고, await는 그 함수의 나머지만 미루며 거부된 Promise를 try...catch로 잡을 수 있게 합니다.

연습 문제

1. 실패에서 회복하기

다음 코드에는 네트워크도 난수도 없습니다. 실행하기 전에 검사, 회복, 최종이 어떤 순서로 출력되는지 적어 보세요.

Promise.resolve(4)
    .then(value => {
        console.log("검사", value);
        if (value < 5) throw new Error("최솟값 미달");
        return value * 2;
    })
    .catch(error => {
        console.log("회복", error.message);
        return 0;
    })
    .then(value => console.log("최종", value))
    .catch(error => console.log("마지막 오류", error.message));
해설 보기

출력은 검사 4, 회복 최솟값 미달, 최종 0입니다. 첫 .then()에서 4가 5보다 작아 오류를 던지므로 그 Promise는 거부되고, 오류는 첫 .catch()로 전달됩니다.

그 .catch()가 일반 값 0을 돌려주면서 체인은 다시 이행 상태로 이어지므로, 다음 .then()이 최종 0을 출력하고 마지막 .catch()는 실행되지 않습니다. 오류를 출력했다고 실패가 계속 전파되는 것은 아니며, .catch()가 무엇을 돌려주는지가 다음 단계를 정합니다.

2. 회복 대신 다시 던지기

앞 코드의 첫 .catch()에서 return 0;만 throw error;로 바꿨습니다. 최종은 출력될까요? 같은 오류를 받는 함수는 어느 것일까요?

해설 보기

검사 4, 회복 최솟값 미달, 마지막 오류 최솟값 미달이 출력되고 최종은 출력되지 않습니다. 다시 던진 오류가 첫 .catch()가 돌려주는 Promise를 거부하므로, 성공 처리 함수인 .then()을 건너뛰고 마지막 .catch()로 갑니다.

마지막 .catch()가 받는 오류는 첫 .then()에서 만든 바로 그 Error 객체입니다. 앞의 processOrder()가 안쪽 catch에서 오류를 다시 던져 바깥의 .catch()로 실패를 넘긴 것도 같은 원리입니다.

3. await 뒤의 순서

다음 코드의 출력 순서를 예상해 보세요. await 뒤의 값이 Promise가 아닌 null인데도 순서가 바뀌는 이유는 무엇일까요?

async function runAwait() {
    console.log("1 함수 시작");
    await null;
    console.log("3 await 뒤");
}

runAwait();
console.log("2 호출 다음 줄");
해설 보기

1 함수 시작, 2 호출 다음 줄, 3 await 뒤 순서입니다. runAwait()는 await를 만나기 전까지 동기적으로 실행되어 1을 출력합니다.

await 뒤의 값이 일반 값이어도 함수의 나머지 부분은 마이크로태스크로 미뤄지므로, runAwait()는 대기 중인 Promise를 돌려주고 바로 반환합니다. 그래서 호출한 쪽의 다음 줄이 2를 출력한 뒤, 현재 코드가 끝나고 나서야 3이 출력됩니다.

Promise의 상태와 체이닝, 조합 메서드는 MDN의 Promise 문서에서 확인할 수 있습니다.

다음 글 이터레이터와 제너레이터에서는 for...of가 값을 하나씩 꺼내는 이터레이션 규칙과, 실행을 멈췄다 이어 가며 값을 만들어 내는 제너레이터 함수를 살펴봅니다.