[번역] Javascript Visualized: Promise Execution - 자바스크립트 시각화하기: 프로미스 실행

eee·2025년 4월 12일
post-thumbnail

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


자바스크립트의 프로미스는 처음에는 조금 어려워 보일 수 있지만, 그 내부에서 무슨 일이 일어나는지 이해하면 훨씬 더 쉽게 다가갈 수 있습니다.

이 글에서는 프로미스의 내부 작동 방식을 깊이 있게 살펴보고, 프로미스가 어떻게 자바스크립트에서 논-블로킹 비동기 작업을 가능하게 하는지 살펴보겠습니다.


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

new Promise((resolve, reject) => {
	// TODO(Lydia): Some async stuff here
});

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

  1. 프로미스 객체가 생성됩니다.

    이 프로미스 객체는 [[PromiseState]], [[PromiseResult]], [[PromiseIsHandled]],[[PromiseFulfillReactions]], 그리고[[PromiseRejectReactions]]. 를 포함한 여러 내부 슬롯을 포함합니다.

  2. Promise Capability 레코드가 생성됩니다.

    이것은 프로미스를 “캡슐화”하고, 프로미스를 resolve 또는 reject 하기 위한 몇가지 추가적인 기능을 더합니다. 이들은 프로미스의 [[PromiseState]][[PromiseResult]] 를 제어하고, 비동기 작업들을 시작하는 함수들입니다.

promise-invoked

실행 함수로부터 전달받은 resolve를 호출함으로써 프로미스를 resolve 할 수 있습니다.
resolve를 호출하면:

  1. [[PromiseState]]"fulfilled" 로 설정됩니다.
  2. [[PromiseResult]]resolve에 전달한 값으로 설정되며, 이 경우에는 “Done!”입니다.

promise-resolve

reject를 호출할 때도 비슷한 과정이 발생합니다. 이때는 [[PromiseState]]“rejected”로 설정되며, [[PromiseResult]]reject에 전달한 값으로 설정되고 이 경우에는 “Fail!”입니다.

promise-reject

물론 좋습니다… 하지만 몇가지 객체의 내부 속성을 변경하기 위해 함수를 사용하는 것이 뭐가 그렇게 특별할까요?

답은 우리가 지금까지 건너뛰었던 두 가지 내부 슬롯인 [[PromiseFulfillReactions]][[PromiseRejectReactions]]에 연결된 동작에 있습니다.


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

Promise Reaction에는 다른 필드 중에서도, 우리가 then으로 전달한 콜백 함수를 가지는 [[Handler]] 속성이 포함되어 있습니다. 프로미스가 resolve될 때, 이 핸들러는 Microtask Queue에 추가되며 프로미스가 resolve된 값에 접근할 수 있습니다.

promise-reaction

프로미스가 resolve될 때, 이 핸들러는 [[PromiseResult]] 값을 인수로 전달받고, 이후 Microtask Queue에 푸시됩니다.

바로 여기서 프로미스의의 비동기적인 부분이 동작합니다!

promise-resolve-microtaskqueue

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

더 알고 싶다면 이벤트 루프 영상을 확인하세요!

유사하게, catch를 체이닝 하면 프로미스 리젝션을 처리하기 위한 Promise Reaction 레코드를 생성할 수 있습니다. 이 콜백 함수는 프로미스가 reject될 때 Microtask Queue에 추가됩니다.

promise-reaction


지금까지는 실행 함수 내에서만 resolve 또는 reject를 직접 호출했습니다. 이는 가능하지만, 프로미스의 전체 힘(그리고 주요 목적)을 활용하지 못합니다!

대부분의 경우, 우리는 resolve 또는 reject를 나중에, 일반적으로 비동기 작업이 완료되었을 때 호출하기를 원합니다.

비동기 작업은 메인 스레드 외부에서 발생합니다. 예를 들어 파일을 읽는 작업(예. fs.readFIle), 네트워크 요청(예. https.get or XMLHttpRequest) 또는 타이머(setTimeout)와 같은 간단한 작업 등이 그 예입니다.

asynchronous-tasks

이러한 작업이 미래의 어느 시점에 완료되면, 해당 비동기 작업들이 일반적으로 제공하는 콜백 함수를 이용해 비동기 작업에서 얻은 데이터로 resolve 하거나, 에러가 발생했다면 reject 합니다.


이를 시각화하기 위해, 단계별로 실행 흐름을 살펴보겠습니다. 이 예제를 단순하면서도 현실적으로 유지하기 위해, setTimeout을 사용하여 비동기 동작을 추가할 것입니다.

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

첫째로, new Promise 생성자는 Call Stack에 추가되고 프로미스 객체를 생성합니다.

first-of-new-promise

그리고 나서, 실행 함수가 실행됩니다. 함수 본문의 첫 번째 줄에서, setTimeout을 호출하고 이는 Call Stack에 추가됩니다.

setTimeout은 타이머 Web API에서 타이머를 100ms 지연으로 스케줄링하는 역할을 담당하며, 그 후 setTimeout으로 전달된 콜백 함수는 Task Queue로 푸시됩니다.

💡 여기서 이 비동기적인 동작은 프로미스가 아닌 setTimeout과 관련이 있습니다. 단지 프로미스가 사용되는 일반적인 방법 - 약간의 지연 후에 프로미스를 resolve 하는 것을 보여드리기 위해 이 예제를 들었을 뿐입니다.

그러나 지연 자체는 프로미스로 인한 것이 아닙니다. 프로미스는 비동기 연산과 함께 작동하도록 설계되었지만, 이러한 비동기 연산은 타이머나 네트워크 요청과 같은 다양한 소스에서 발생할 수 있습니다.

타이머와 생성자가 Call Stack에서 제거된 후, 엔진은 then을 만납니다.

thenCall Stack에 추가되고 Promise Reaction 레코드를 생성합니다. 이 레코드의 핸들러는 우리가 then 핸들러에 콜백 함수로 전달한 코드입니다.

[[PromiseState]] 가 여전히 “pending”이므로, 이 Promise Reaction 레코드는 [[PromiseFulfillReactions]] 목록에 추가됩니다.

promise-then

100ms가 지나면, setTimeout 콜백함수가 Task Queue에 푸시됩니다.

스크립트가 이미 이미 완료되어 Call Stack이 비어 있습니다. MicroTask Queue도 비어있으므로, 이 작업은 Task Queue에서 Call Stack으로 이동되어 실행되고, resolve가 호출됩니다.

task-queue

resolve가 호출되면 [[PromiseState]]"fulfilled"로, [[PromiseResult]]"Done!"으로 설정되며, Promise Reaction의 핸들러와 연관된 코드가 Microtask Queue 에 추가됩니다.

resolve와 콜백 함수는 Call Stack에서 제거 됩니다.

Call Stack 이 비어있으므로, 이벤트 루프는 먼저 then 핸들러의 콜백함수가 대기 중인 Microtask Queue 를 확인합니다.

이 콜백함수는 Call Stack에 추가되며, result의 값인 [[PromiseResult]] 의 값: 문자열 "Done!" 을 출력합니다.

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))

이러한 유형의 작업에는 종종 비동기 작업이 포함되므로, 이를 논-블로킹 방식으로 관리하기 위해 프로미스는 좋은 선택입니다.


Conclusion

요약하자면, 프로미스는 내부 상태를 변경할 수 있는 몇가지 추가 기능이 포함된 객체일 뿐입니다.

프로미스의 멋진 점은 then 또는 catch를 통해 핸들러가 연결되었을 때 비동기 작업을 트리거할 수 있다는 것입니다. 이 핸들러는 Microtask Queue에 푸시되기 때문에, 최종 결과를 논-블로킹 방식으로 처리할 수 있습니다. 이를 통해 에러를 쉽게 처리하고, 여러 작업들을 함께 체이닝 하며, 코드를 더 읽기 쉽고 유지보수하기 쉽게 유지할 수 있습니다!

프로미스는 여전히 모든 자바스크립트 개발자라면 알아야 할 기본적이고 중요한 개념입니다. async/await 구문(프로미스의 문법적 설탕), 또는 Async Generators와 같은 기능들은 비동기 코드를 사용하는 더 많은 방법들을 제공합니다. 더 깊이 알아보고 싶다면 이런 기능들도 살펴보면 좋습니다.

0개의 댓글