기본 콘텐츠로 건너뛰기

자바스크립트 Promise를 상태 흐름과 실행 순서로 이해하기

자바스크립트 Promise를 상태 흐름과 실행 순서로 이해하기

빠른 답

  • Promise는 비동기 작업의 성공 값이나 실패 이유를 나중에 전달하기 위한 객체입니다.
  • 상태는 pending에서 fulfilled 또는 rejected로 한 번만 바뀌며, 이후 다시 바뀌지 않습니다.
  • then, catch, finally 콜백은 현재 동기 코드가 끝난 뒤 마이크로태스크로 실행됩니다.
  • 복잡한 비동기 흐름은 긴 then 체인보다 async/await와 좁은 범위의 try/catch로 나누면 읽기 쉬워집니다.

시간 흐름으로 이해하기

생성 시점
new Promise(...) 가 실행되면 executor 함수는 즉시 실행됩니다.
대기 시점
아직 결과가 정해지지 않은 Promise는 pending 상태입니다.
결정 시점
resolve 또는 reject 중 먼저 호출된 쪽으로 결과가 정해집니다.
예약 시점
then , catch , finally 콜백은 마이크로태스크 큐에 등록됩니다.
실행 시점
현재 동기 코드가 끝난 뒤 예약된 콜백이 실행됩니다.

Promise를 “비동기니까 전부 나중에 실행되는 코드”로만 이해하면 콘솔 출력 순서가 자주 헷갈립니다. Promise 생성자 안의 executor는 동기적으로 실행됩니다. 나중에 실행되는 쪽은 Promise의 결과를 관찰하는 then, catch, finally 콜백입니다.

이 차이를 구분하면 상태 전환, 에러 전파, Promise.all 같은 조합 메서드의 동작을 더 차분하게 따라갈 수 있습니다.

흐름으로 보기

흐름 다이어그램
자바스크립트 Promise를 상태 흐름과 실행 순서로 이해하기 흐름 다이어그램

이 흐름에서 중요한 지점은 “작업이 시작되는 시점”과 “결과를 받는 콜백이 실행되는 시점”이 다르다는 점입니다. Promise를 만들면 executor는 바로 실행되지만, then으로 등록한 콜백은 현재 콜 스택이 비워진 뒤 실행됩니다.

또한 한 번 결과가 정해진 Promise는 다시 다른 상태로 바뀌지 않습니다. 이 성질 덕분에 같은 Promise를 여러 곳에서 관찰해도 결과 자체는 흔들리지 않습니다.

값, 상태, 오류를 나눠 보기

Promise를 이해할 때는 값, 상태, 오류를 분리해서 보는 편이 좋습니다.

  • 값: resolve(value)로 전달되는 성공 결과입니다.
  • 상태: Promise가 현재 어떤 단계에 있는지를 나타냅니다. pending, fulfilled, rejected가 있습니다.
  • 오류 또는 실패 이유: reject(reason)으로 전달되거나 then 내부에서 throw된 값입니다.

예를 들어 resolve(10)은 “성공 값이 10인 fulfilled Promise”를 만든다는 뜻입니다. 10 자체가 상태는 아닙니다. 반대로 reject(new Error("fail"))은 “실패 이유가 Error 객체인 rejected Promise”를 만든다는 뜻입니다.

실패 이유는 문자열일 수도 있지만, 실제 디버깅에서는 Error 객체를 쓰는 쪽이 메시지와 스택 정보를 함께 남기기 좋습니다. reject("fail")처럼 문자열만 넘기면 어디서 문제가 시작됐는지 추적하기 어려워질 수 있습니다.

Promise 생성자는 언제 실행될까

아래 예제는 executor, then, setTimeout의 실행 순서를 함께 보여줍니다. 브라우저 콘솔에 그대로 붙여 넣거나, Node.js에서 promise-order.js 파일로 저장한 뒤 node promise-order.js로 실행해 볼 수 있습니다.

console.log("1. 동기 시작");

setTimeout(() => {
  console.log("5. setTimeout");
}, 0);

const promise = new Promise((resolve) => {
  console.log("2. executor 실행");
  resolve("성공 값");
});

promise.then((value) => {
  console.log("4. then 실행:", value);
});

console.log("3. 동기 끝");

출력은 보통 다음 순서로 나타납니다.

1. 동기 시작
2. executor 실행
3. 동기 끝
4. then 실행: 성공 값
5. setTimeout

executor 실행동기 끝보다 먼저 찍히는 이유는 Promise 생성자 안의 함수가 즉시 실행되기 때문입니다. 반면 then 실행resolve가 이미 호출됐더라도 현재 동기 코드가 모두 끝난 뒤 마이크로태스크로 처리됩니다.

setTimeout(..., 0)은 “지금 즉시 실행”이 아니라 타이머 작업으로 예약한다는 의미에 가깝습니다. 일반적인 이벤트 루프 흐름에서는 마이크로태스크인 Promise 콜백이 타이머 콜백보다 먼저 처리됩니다.

상태는 한 번만 결정된다

Promise는 처음에는 pending 상태입니다. 이후 resolve가 먼저 호출되면 fulfilled, reject가 먼저 호출되면 rejected가 됩니다. 둘 중 하나로 결정된 상태를 통틀어 settled 상태라고 부릅니다.

아래 코드에서는 resolve 뒤에 reject가 호출되지만, 이미 결과가 정해졌기 때문에 catch는 실행되지 않습니다.

const promise = new Promise((resolve, reject) => {
  resolve("첫 번째 성공 값");
  reject(new Error("나중에 발생한 실패"));
});

promise
  .then((value) => {
    console.log("then:", value);
  })
  .catch((error) => {
    console.log("catch:", error.message);
  });

// 출력
// then: 첫 번째 성공 값

이 예제에서 reject 코드가 존재한다는 사실보다 먼저 호출된 resolve가 더 중요합니다. Promise는 첫 번째 결정만 받아들이고, 이후의 resolvereject 호출은 상태를 바꾸지 않습니다.

이 성질은 비동기 코드에서 특히 중요합니다. 네트워크 응답, 타임아웃, 사용자 취소 같은 여러 이벤트가 얽혀 있어도 하나의 Promise 결과는 한 번만 정해집니다.

에러 전파와 catch의 의미

Promise 체인 안에서 발생한 예외는 다음 catch로 전파됩니다. then 안에서 throw된 오류도 Promise의 실패 흐름으로 바뀌어 처리됩니다.

Promise.resolve({ id: "user-1", plan: "free" })
  .then((user) => {
    console.log("권한 확인:", user.id);

    if (user.plan === "free") {
      throw new Error("유료 기능을 사용할 수 없습니다.");
    }

    return `${user.id}-report`;
  })
  .then((report) => {
    console.log("리포트 생성:", report);
  })
  .catch((error) => {
    console.log("에러 처리:", error.message);
  });

// 출력
// 권한 확인: user-1
// 에러 처리: 유료 기능을 사용할 수 없습니다.

두 번째 then이 실행되지 않는 이유는 앞 단계에서 예외가 발생했기 때문입니다. Promise 체인은 중간 단계에서 실패하면 가장 가까운 다음 catch로 이동합니다.

다만 모든 실패를 마지막 catch 하나에만 모으면 오류의 의미가 흐려질 수 있습니다. 사용자 조회 실패, 권한 확인 실패, 저장 실패처럼 성격이 다른 오류라면 메시지를 보강하거나 처리 범위를 나누는 편이 원인을 찾기 쉽습니다.

여러 작업을 조합할 때의 차이

Promise는 여러 비동기 작업을 함께 다룰 때도 자주 사용됩니다. 이때 Promise.allPromise.allSettled의 차이를 구분하면 실패를 어떻게 다룰지 정하기 쉽습니다.

Promise.all은 모든 작업이 성공해야 성공합니다. 하나라도 실패하면 전체 Promise는 rejected가 됩니다. 여러 데이터가 모두 있어야 다음 단계로 갈 수 있는 경우에 맞습니다.

Promise.allSettled는 각 작업의 성공과 실패를 모두 결과 배열로 돌려줍니다. 일부 실패가 있어도 가능한 결과를 모아서 보여줘야 하는 화면, 리포트, 배치 작업에서 유용합니다.

const profile = Promise.resolve({ name: "Mina" });
const orders = Promise.resolve(["order-1", "order-2"]);
const point = Promise.reject(new Error("포인트 서버 응답 없음"));

Promise.all([profile, orders, point]).catch((error) => {
  console.log("Promise.all 실패:", error.message);
});

Promise.allSettled([profile, orders, point]).then((result) => {
  console.log(
    "Promise.allSettled 상태:",
    result.map((item) => item.status)
  );
});

// 출력 예
// Promise.all 실패: 포인트 서버 응답 없음
// Promise.allSettled 상태: [ 'fulfilled', 'fulfilled', 'rejected' ]

여기서 Promise.all은 포인트 조회 실패 때문에 전체 실패로 끝납니다. 반면 Promise.allSettled는 프로필, 주문, 포인트 요청의 상태를 각각 남깁니다. 결과를 전부 버릴지, 성공한 일부라도 사용할지는 메서드 선택에서 드러납니다.

then 체인과 async await

async/await는 Promise를 없애는 문법이 아닙니다. Promise 기반 흐름을 동기 코드와 비슷한 모양으로 읽게 해주는 문법입니다.

짧은 변환이나 단순한 연결은 then 체인으로도 충분히 읽을 수 있습니다. 하지만 조건 분기, 반복, 여러 단계의 에러 처리가 섞이면 체인이 길어지고 실패 지점이 잘 보이지 않을 수 있습니다.

await는 Promise가 settled될 때까지 해당 async 함수의 다음 줄 진행을 기다립니다. 이때 rejected Promise는 예외처럼 동작하므로 try/catch에서 다룰 수 있습니다. 자바스크립트 전체 실행이 멈추는 것은 아니고, 해당 async 함수의 흐름만 잠시 멈춘다고 보는 편이 정확합니다.

디버깅할 때 자주 보는 신호

Promise 관련 문제를 볼 때는 로그 순서를 먼저 확인하는 것이 도움이 됩니다. 특히 다음 세 가지가 섞이면 예상과 다른 순서로 출력되는 것처럼 보일 수 있습니다.

  • 동기 코드
  • Promise 마이크로태스크
  • 타이머나 입출력 콜백

앞의 실행 예제처럼 then 로그가 setTimeout보다 먼저 찍힌다면, Promise 콜백이 마이크로태스크로 처리되고 있다는 신호입니다. 반대로 then 자체가 찍히지 않는다면 Promise가 아직 pending에 머물러 있거나, 앞 단계에서 rejected가 되었는데 catch 위치가 다를 가능성을 확인해 볼 수 있습니다.

또 하나의 흔한 신호는 처리되지 않은 rejection입니다. 실패한 Promise를 아무도 catch하지 않으면 런타임에서 unhandled rejection 관련 경고나 오류를 볼 수 있습니다. 비동기 작업이 실패할 수 있는 곳에는 실패 이유를 관찰하는 경로를 남겨 두어야 로그가 끊기지 않습니다.

흔한 오해

resolvereturn은 다릅니다. Promise executor 안에서 return "값"을 한다고 Promise가 그 값으로 fulfilled 되지는 않습니다. Promise의 결과를 정하려면 resolve("값") 또는 reject(reason)을 호출해야 합니다.

then을 붙인다고 원래 Promise의 상태가 계속 바뀌는 것도 아닙니다. then은 새로운 Promise를 반환합니다. 그래서 체인에서는 이전 단계의 반환값이 다음 단계의 입력이 되고, 이전 단계의 예외가 다음 catch로 이어집니다.

또한 Promise는 비동기 작업을 동기 작업으로 바꾸지 않습니다. 비동기 결과를 값처럼 연결하고 관찰할 수 있게 해주는 객체입니다. 이 차이를 놓치면 resolve를 호출했는데 왜 바로 다음 줄에서 결과를 못 쓰는지, setTimeout보다 then이 왜 먼저 찍히는지 계속 헷갈리게 됩니다.

Promise를 상태, 값, 오류의 흐름으로 나눠 보면 동작 방식이 훨씬 선명해집니다. 생성자는 즉시 실행되고, 결과는 한 번만 결정되며, 관찰 콜백은 현재 동기 코드가 끝난 뒤 실행됩니다.

원문 참고

https://www.maeil-mail.kr/question/65

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...