(번역) 자바스크립트 시각화하기 : 프로미스 실행

sehyun hwang·2024년 4월 7일
131

FE 번역글

목록 보기
30/30
post-thumbnail

원문 : https://www.lydiahallie.com/blog/promise-execution

동영상 URL : https://youtu.be/Xs1EMmBLpn4

자바스크립트의 프로미스는 처음엔 다소 어렵게 느껴질 수 있지만, 내부에서 일어나는 일을 이해하면 훨씬 더 쉽게 다가갈 수 있습니다.

이 글에서는 프로미스의 내부 동작에 대해 자세히 알아보고, 프로미스가 어떻게 자바스크립트에서의 논-블로킹(non-blocking) 비동기 작업을 가능하게 하는지도 함께 살펴보겠습니다.

모바일 브라우저는 항상 제가 원하는 방식으로 동영상을 렌더링하지 않아서, 블로그가 모바일 기기에서 잘 보일 수 있도록 노력중입니다. 문제가 발생할 수 있으니 양해 부탁드립니다!


프로미스를 생성하는 한 가지 방법은 resolvereject 인자가 있는 실행 함수를 받는 new Promise 생성자를 이용하는 것입니다.

new Promise((resolve, reject) => {
   // TODO: 몇몇 비동기 작업
});

프로미스 생성자가 호출되면 몇 가지 일이 발생합니다.

  • 프로미스 객체가 생성됩니다.
    • 프로미스 객체[[PromiseState]], [[PromiseResult]], [[PromiseIsHandled]],[[PromiseFulfillReactions]] 그리고 [[PromiseRejectReactions]] 를 포함한 여러 내부 슬롯을 가집니다.
  • Promise Capability 레코드가 생성됩니다.
    • 이는 프로미스를 "캡슐화"하고 프로미스를 이행(resolve)하거나 거부(reject)하기 위한 부가 기능을 더합니다. 이들은 최종적으로 프로미스의 [[PromiseState]][[PromiseResult]]를 제어하고 비동기 작업을 시작하는 함수입니다.

실행 함수를 통해 접근할 수 있는 resolve를 호출하여 프로미스를 이행할 수 있습니다. resolve를 호출하면 아래의 상황이 발생합니다.
1. [[PromiseState]]"fulfilled"로 설정됩니다.
2. resolve에 넘겨준 값으로 [[PromiseResult]]가 설정되고, 이 예제의 경우에는 "Done!"입니다.

reject를 호출할 때도 유사한 과정이 발생하며, [[PromiseState]]"rejected"로 설정됩니다. [[PromiseResult]]reject에 넘겨준 값으로 설정되고 예제의 경우에는 "Fail!"입니다.

네 좋습니다.. 그런데 객체 내부의 몇몇 프로퍼티를 변경하기 위해 함수를 사용하는 것이 왜 그렇게 특별할까요?

정답은 지금까지 건너뛴 두 개의 내부 슬롯 [[PromiseFulfillReactions]], [[PromiseRejectReactions]]와 관련된 동작에 있습니다.


[[PromiseFulfillReactions]] 필드는 Promise Reactions를 포함합니다. 이 객체는 프로미스에 then 핸들러를 체이닝하여 생성됩니다.

Promise Reaction에는 여러 필드 중에서도 then에 전달한 콜백을 보관하는 [[Handler]] 프로퍼티가 포함되어 있습니다. 프로미스가 이행되면 이 핸들러는 Microtask Queue에 추가되고 프로미스가 이행될 때 함께 전달된 값에 접근할 수 있습니다.

프로미스가 이행되면 이 핸들러는 [[PromiseResult]]의 값을 인자로 전달받고, Microtask Queue로 들어갑니다.

여기가 프로미스의 비동기 부분이 동작하는 곳입니다!

Microtask Queue는 이벤트 루프에 있는 특수한 대기열입니다. Call Stack이 비었을 때, 이벤트 루프는 일반적인 Task Queue("callback queue" 또는 "macrotask queue"라고 불림)내의 작업을 처리하기 전에 Microtask Queue내에서 대기 중인 작업을 먼저 처리합니다.

2019년에 게시한 블로그 글을 업데이트하고 있지만, 여기서 이벤트 루프에 대해 좀 더 자세히 알아볼 수 있습니다. 또한 FrontendMasters 워크숍에서도 설명합니다.

마찬가지로 catch 체이닝에 의한 프로미스 거부를 처리하기 위해 Promise Reaction 레코드를 생성할 수 있습니다. 이 콜백은 프로미스가 거부될 때 Microtask Queue에 추가됩니다.


지금까지 우리는 실행 함수 내에서 직접적으로 resolvereject를 호출하였습니다. 이것도 가능하지만, 프로미스의 모든 기능(과 주요 목적)을 활용하지는 못합니다!

대부분의 경우 resolve 또는 reject를 원하는 것은 비동기 작업이 완료된 이후의 어느 시점입니다.

비동기 작업은 파일 읽기(ex. fs.readFile), 네트워크 요청(ex. https.get 또는 XMLHttpRequest) 또는 타이머(setTimeout)와 같은 간단한 작업처럼 메인 스레드 밖에서 이루어집니다.


이러한 작업이 미래의 어느 시점에 완료되면 비동기 작업이 일반적으로 제공하는 콜백을 사용하여 비동기 작업에서 반환된 데이터로 resolve하거나, 오류가 발생한 경우 reject할 수 있습니다.


이를 시각화하기 위해, 실행 과정을 단계별로 살펴보겠습니다. 단순하면서도 현실적인 예제를 위해 setTimeout을 사용하여 비동기 동작을 추가하겠습니다.

new Promise((resolve) => {
    setTimeout(() => resolve("Done!"), 100);
}).then(result => console.log(result))

먼저, Call Stacknew Promise 생성자가 추가되고 프로미스 객체를 생성합니다.

그다음 실행 함수가 실행됩니다. 함수 본문의 첫 번째 줄에서 Call Stack에 추가되는 setTimeout을 호출합니다.

setTimeoutWeb API내의 타이머를 100ms로 지연되도록 예약하며, 그 후 setTimeout으로 넘겨준 콜백 함수는 Task Queue에 들어갑니다.

💡 여기에서 비동기 동작은 프로미스가 아닌 setTimeout과 관련이 있습니다. 이 예제에서는 프로미스가 사용되는 일반적인 방식, 즉 약간의 지연 후에 이행되는 동작을 위해 보여드리는 것입니다.

그러나 지연 자체는 프로미스에 의해 발생하지 않습니다. 프로미스는 비동기 작업과 함께 동작하도록 설계되었지만, 비동기 작업은 타이머나 네트워크 요청처럼 다양한 곳에서 발생할 수 있습니다.


타이머와 생성자가 Call Stack에서 빠져나오면, 엔진은 then을 마주합니다.

Call Stackthen이 추가되고, Promise Reaction 레코드를 생성하며, 이 내부의 핸들러는 then 핸들러에 콜백으로 전달한 코드가 됩니다.

[[PromiseState]]가 여전히 "pending" 상태이기 때문에, Promise Reaction 레코드는 [[PromiseFulfillReactions]] 목록에 추가됩니다.

100ms가 지나고 setTimeout 콜백은 Task Queue에 추가됩니다.

스크립트는 이미 실행이 종료되어서 Call Stack이 비었으므로, 콜백은 Task Queue에서 Call Stack으로 넘어가고 resolve가 호출됩니다.

resolve를 호출하여 [[PromiseState]"fulfilled", [[PromiseResult]]"Done!"이 되고, Promise Reaction의 핸들러와 관련된 코드는 Microtask Queue에 추가됩니다.

resolve와 콜백은 Call Stack에서 제거됩니다.

Call Stack이 비어있기 때문에 이벤트 루프는 then 핸들러의 콜백이 대기하고 있는 Microtask Queue를 가장 먼저 체크합니다.

이 콜백은 이제 Call Stack에 추가되고, [[PromiseResult]]의 값인 result 값, 즉 "Done!" 문자열을 기록합니다.

콜백의 실행이 종료되고 Call Stack에서 제거되면, 프로그램이 완료됩니다!


Promise Reaction을 생성하는 것 외에도, then은 프로미스를 반환합니다. 이는 아래의 예제와 같이 여러 개의 then을 서로 연결할 수 있다는 뜻입니다.

new Promise((resolve) => {
   resolve(1)
})
  .then(result => result * 2)
  .then(result => result * 2)
  .then(result => console.log(result));

이 코드가 실행되면 Promise 생성자가 호출될 때 프로미스 객체가 생성됩니다. 이후 엔진이 then을 마주할 때마다, Promise Reaction 레코드와 프로미스 객체가 생성됩니다.

두 경우 모두에서 then 콜백은 전달받은 [[PromiseResult]] 값에 2를 곱합니다. then[[PromiseResult]]는 이 연산의 결괏값으로 설정되고, 이는 그다음 then 핸들러에서 차례로 사용됩니다.

최종적으로 결괏값이 기록됩니다. 마지막 then 프로미스의 [[PromiseResult]]는 명시적으로 값을 반환하지 않았기 때문에 undefined가 되며, 이는 암시적으로 undefined를 반환했음을 의미합니다.


물론 숫자를 사용하는 것이 가장 현실적인 시나리오라고 할 수는 없습니다. 대신에 이미지 모양을 점진적으로 변경하는 것처럼 프로미스의 결과를 단계별로 변경하고 싶을 수 있습니다.

예를 들어, 이미지의 모양을 변경하는 리사이즈, 필터 적용, 워터마크 추가 등과 같은 작업을 일련의 점진적인 단계로 진행하고 싶을 수 있습니다.

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
  })
}

loadImage(src)
 .then(image => resizeImage(image))
 .then(image => applyGrayscaleFilter(image))
 .then(image => addWatermark(image))

이러한 유형의 작업에는 종종 비동기 작업이 포함되므로, 이를 차단하지 않는 방식으로 관리하기 위해 프로미스를 선택하는 것이 좋습니다.


결론

짧게 요약하자면 프로미스는 내부 상태를 변경할 수 있는 추가적인 기능을 갖춘 객체일 뿐입니다.

프로미스의 멋진 점은 then이나 catch로 핸들러를 연결하면 비동기 작업을 트리거할 수 있다는 것입니다. 핸들러는 Microtask Queue에 들어가기 때문에, 차단하지 않는 방식으로 최종 결과를 다룰 수 있습니다. 이를 통해 에러를 더 쉽게 처리하고, 여러 작업을 함께 연결하고, 코드를 더 가독성 있고 유지 보수하기 쉽게 만들 수 있습니다!

프로미스는 기본 개념이며 모든 자바스크립트 개발자가 알아야 할 중요한 내용입니다. async/await 구문(프로미스에 대한 문법적 설탕)이나 Async Generators는 비동기 코드를 처리할 수 있는 훨씬 더 다양한 방법을 제공합니다. 관심이 있으신 분들은 더 살펴보시길 바랍니다.

ECMAScript® 2025 Language Specification

6개의 댓글

comment-user-thumbnail
2024년 4월 9일

javascript로 개발을 하고 있다면 6만5천7백5십3번 봐야 할 글입니다.

답글 달기
comment-user-thumbnail
2024년 4월 9일

진짜 잘 읽었습니다. 좋은 글 멋지게 번역해주셔서 감사합니다!

답글 달기
comment-user-thumbnail
2024년 4월 9일

프로미스의 내부 동작 원리에 대해 정말 알기 쉽게 정리된 좋은 글이네요!!! 감사합니다 ☺️☺️☺️

답글 달기
comment-user-thumbnail
2024년 4월 12일

Thanks for this info. dgcustomerfirst survey

답글 달기
comment-user-thumbnail
2024년 4월 16일

광성님 댓글에 7만2천9백2십5번 동의합니다.

답글 달기
comment-user-thumbnail
2024년 4월 16일

좋은글 감사합니다

답글 달기