프로미스 직접 구현해보기

최영호·2023년 2월 4일
1

해당 포스트는 turtle601님의 "[JS] 자바스크립트 Promise 객체 직접 구현해보기" 포스트의 코드를 읽으며 직접 이해한 내용을 설명하는 포스트입니다!

서론

프론트엔드 개발을 하면서 프로미스에 대해 한번도 들어본 적이 없다면 그것은 그것 대로 대단하다고 말할 수 있을 것이다.

만약 프로미스에 대해 모르거나 아직 사용해보지 않았다는 사람의 코드를 살펴본다면 분명 알게 모르게 프로미스를 사용하고 있을 것이 분명하다.

비동기 로직을 컨트롤 하기 위해선 프로미스는 이제 선택이 아닌 필수가 되어버린 시대를 우린 살고 있다.

사실 프로미스에 대한 중요성은 직접 글에 작성하지 않아도 코딩을 하다보면 분명 내가 프로미스에 대해 잘 모르고 쓰고 있었구나 라는 사실을 깨닫게 되는데, 나 또한 프로미스에 대해 좀 더 깊게 이해하고 싶은 욕심이 있었다.

그러던 중, velog 포스트 중 프로미스를 직접 구현한 분의 글을 읽게 되었고, 흥미로운 글이라 생각하여 천천히 읽다보니 욕심이 나 직접 글에 작성 되어 있는 코드를 따라 쳐보면서 내가 깨닫게 된 부분들을 개인 velog에 정리해보고자 한다.

개발자는 코드로 말하고, 코드를 통해 성장하는 법!

혹여나 도전해보고 싶은 분이 계신다면, 꼭 직접 vscode 같은 에디터를 열고 코딩 해보면서 프로미스의 원리에 대해 이해하고 가면 좋을 것 같다.

좋다고 말하기 이전에 프로미스는 이렇게 동작하는거구나 라는 개인적인 깨달음과 동시에 행복도 밀려올 것이다 ㅎㅎ

프로미스 그리고 요구사항

프로미스에 대해 구현해보기 전에 과연 우린 프로미스가 뭔지에 대해 확실하게 이해하고 있는지 파악할 필요가 있다.

그렇게 자주 말하고, 실제로 자주 쓰고 있는 프로미스는 무엇일까?

프로미스?

mdn에서 소개하고 있는 프로미스에 대해 알아보자.

프로미스 객체 는 비동기 작업이 맞이할 미래의 완료(fulfilled) 또는 실패(rejected) 와 그 결과 값을 나타냅니다.

주목해야 할 부분은 크게 2가지이다.

  • 프로미스는 객체이다.
  • 프로미스는 상태값과 결과 값을 가지는데, 상태값에는 미래 시점의 완료 혹은 실패 상태값을 가진다.

즉 정리하자면 프로미스는 내부에 상태값과 결과 값을 가지고 있는 객체이다.

상태값에 관한 좀 더 자세한 얘기는 mdn에 등장하고 있다.

프로미스는 정확하게는 3개의 상태값을 가지고 있음을 알 수 있다.
미래의 완료 시점에 대한 2개의 상태값 + 초기 상태 값인 Pending 상태

이들을 종합해보자면 프로미스란
3개의 상태값과, 결과값을 가지는 객체
라고 정리할 수 있을 것이다.

즉 프로미스는 미래의 어느 시점에 완료 상태가 되어 결과 값을 반환 하거나 무언가의 오류로 인해 실패하여(실패 상태가 되어) 특정 결과 값을 가지는 객체이다.

그렇다면 이 프로미스란 객체는 단순히 값만 가질까?

생각해보면 그럴수가 없을 것 같다.

왜냐면 프로미스란 객체는 결국 당장 선언과 동시에 실행 되는 것이 아니라, 미래 어느 시점에 실행 되어 값을 반환하는 객체이기 때문에 값을 반환 한 그 미래 시점에 프로미스 객체가 뱉어낸 결과 값을 사용하여 뭔가를 해야 프로미스에 의의가 생기기 때문이다.

따라서 프로미스 객체에는 분명히 미래 시점에 뱉어낸 결과 값을 컨트롤 할 수 있는 메소드가 반드시 필요하게 된다.

그리고 이에 상응하여 등장하는 메소드가 바로 then, catch 이다.

실제 프로미스에는 finally 라는 메소드가 하나 더 있지만, 프로미스의 코어 기능은 아니기 때문에 이번 글에서는 생략합니다.

mdn에 기술되어 있는 프로미스 객체가 가지는 메소드에 대해 살펴본다면 then, catch 에 대한 설명이 적혀져 있음을 알 수 있다.

좀 더 쉽게 메소드에 대한 설명을 해보자면

then

  • then 메소드는 프로미스의 미래 상태인 성공상태(fulfilled) 일 때 호출 되는 함수로 성공상태일 때 반환 되는 결과 값을 바탕으로 then 함수의 인자로 주어지는 콜백 함수를 실행 시켜 준다.
  • then 메소드의 결과는 새로운 프로미스이다.

catch

  • then 과 동일한 역할을 하는 함수지만, 프로미스의 미래 상태인 실패상태(rejected) 일 때 호출 되는 함수로 실패상태일때 발생 하는 에러 값을 바탕으로 catch 함수의 인자로 주어지는 콜백 함수를 실행 시켜 준다.
  • catch 메소드의 결과도 마찬가지로 새로운 프로미스이다.

메소드에 대한 소개를 간단하게 해보았는데, 정리해보자면

  • then, catch 라는 메소드를 활용하여 프로미스의 미래 시점에 반환 된 결과 값을 바탕으로 뭔가를 할 수 있으며
  • then, catch의 경우 리턴 값이 새로운 프로미스이다.

여기서 then, catch의 경우 왜 리턴 값이 새로운 프로미스인지에 대해 고민할 수 있을 텐데,
실제 우리가 프로미스를 사용하는 모습을 생각해 본다면 왜 그런지 바로 이해할 수 있다.

new Promise((resolve, reject)=>{
	resolve(2)
}).then((res)=>{
	console.log(res)
  	return ++res
}).then((res)=>{
	console.log(res)
  	return ++res
})
// 결과 값: 2, 3

이런식으로 프로미스의 결과 값을 바탕으로 계속 then, catch를 사용했던 경험이 있을 것이다.

이런 프로미스 체이닝을 가능케 하려면 결국 then, catch 가 리턴하는 값은 then, catch를 메소드로 가지고 있는 프로미스 객체여야만 한다.

요구사항

지금까지 프로미스에 대해 간단하게 알아보았다.

한번 더 간단하게 요약해보자면 다음과 같이 요약할 수 있을 것이다.

프로미스는 비동기 처리를 위한 객체인데, 내부에 미래 시점의 상태값과 결과 값을 가지고 있으며, 이를 미래 시점의 상태에 따라 결과 값을 가지고 뭔가를 할 수 있게 해주는 then, catch 라는 메소드를 보유하고 있다.

이제 프로미스에 대해 간단하게 알아보았으니, 직접 구현할 시간이다!

다만 직접 구현하기 앞서 무엇을 구현하면 좋을지 생각해보고 구현해본다면 체계적으로 구현할 수 있을 것 같다.

따라서 프로미스를 구현하는데 필요한 요소들에 대해 생각해보자.

앞서 프로미스에 대해 생각해본다면 다음과 같은 필요 요소들이 존재할 것이다.

  • 프로미스는 객체로 이루어져야 한다.
  • 객체 내부에는 다양한 속성과 메소드들이 존재해야 한다.
  • 속성으론 미래 상태를 판별할 수 있는 상태 값과 결과 값이 존재해야 한다.
  • 메소드로는 결과 값을 통해 다음 작업을 할 수 있게 해주는 then, catch가 필요하다.
  • 메소드들은 전부 리턴 값이 프로미스로 프로미스 체이닝이 가능하게끔 해주어야 한다.
  • 추가적으로 프로미스 체이닝이 일어나는 경우 동기적으로 실행 될 수 있도록 지연 실행이 이루어져야 한다.(중요)

이제 우린 프로미스가 무엇인지에 대해 파악해보았고, 프로미스 구현을 위해 무엇을 구현 해야 하는지도 알게 되었다.

이제 남은건 직접 구현해 보는 것이다!

프로미스 직접 구현해보기

원본 포스트의 경우 초심자를 위해 단계적으로 구현을 하지만, 이번 포스트의 경우 결과 코드를 바탕으로 한번에 구현할 계획입니다.
단계적으로 구현하면서 프로미스의 개념에 대해 정확하게 공부하고 싶다면 원본 글에서 참고하세요~

자 본격적으로 프로미스를 구현해보자!

요구사항들을 하나하나 체크해가며 구현하면 분명히 100% 작동하는 프로미스에 대해 구현할 수 있을 것이다.

요구사항

  • 프로미스는 객체로 이루어져야 한다.
  • 객체 내부에는 다양한 속성과 메소드들이 존재해야 한다.
  • 속성으론 미래 상태를 판별할 수 있는 상태 값과 결과 값이 존재해야 한다.
  • 메소드로는 결과 값을 통해 다음 작업을 할 수 있게 해주는 then, catch가 필요하다.
  • 메소드들은 전부 리턴 값이 프로미스로 프로미스 체이닝이 가능하게끔 해주어야 한다.
  • 추가적으로 프로미스 체이닝이 일어나는 경우 동기적으로 실행 될 수 있도록 지연 실행이 이루어져야 한다.(중요)

프로미스는 객체로 이루어져야 한다.

이 부분은 간단하다.

JS에서 객체를 생성하는 것 만큼 간단한 작업은 없을 것이다.

다만 프로미스 객체는 한번 만들고 다시는 안쓰는 객체가 아니기 때문에 객체 리터럴로 만들 순 없을 것이다.

즉 우리에게 필요한 것은 일정한 인스턴스를 만들어 줄 수 있는 클래스 가 필요하다.

클래스를 만드는 방법은 다양하지만, 이번에는 class 키워드를 통해 클래스를 코딩해보도록 하자.

그렇다면 프로미스 클래스는 이렇게 생길 것이다.

class CustomPromise {...}

간단 그 자체다 구현 끝!

객체 내부에는 다양한 속성과 메소드들이 존재해야 한다.

이제 클래스 내부에 무언가를 채워 넣어야할 시간이다.

클래스에는 변수와 함수가 들어갈 수 있는데, 각각 속성, 메소드라고 불린다.

우린 이 속성과 메소드를 정의하기만 하면 된다.

그렇다면 우선 속성을 먼저 정의하고 메소드로 넘어가자.

속성으론 미래 상태를 판별할 수 있는 상태 값과 결과 값이 존재해야 한다.

속성에는 미래 상태를 판별할 수 있는 상태 값과 결과 값이 존재해야 한다고 한다.

요구사항에 맞춰 클래스 내부에 속성들을 집어 넣자!

//프로미스 상태 값 정의 객체
//Object.freeze() 를 통해 수정 불가능하게 생성
const PROMISE_STATE = Object.freeze({
	pending: "pending",
  	fulfilled: "fulfilled",
  	rejected: "rejected"
})

class CustomPromise {
	#value = null //결과 값
    #state = PROMISE_STATE.pending // 상태 값(초기 값은 Pending 상태)
    ...
}

코드 내에서 사용하는 Object.freeze() 메소드는 메소드 내로 들어온 객체를 말 그대로 얼려서 객체의 접근만 가능케 하고, 수정은 불가능 하게 만드는 메소드이다.

자세한 내용은 해당 mdn을 참고하면 된다.

우선 초기 결과 값은 아무것도 없기 때문에 null로 지정하였다.
중요한 건 상태값인데, 앞서 프로미스에는 3가지 상태를 가질 수 있기에 해당 상태들을 상수로 만들어 사용하기 위해 PROMISE_STATE 라는 객체를 생성하여 관리하도록 하였다.

상태값은 미래 시점에 이행(Fulfilled) , 실패(Rejected) 상태를 가질 수 있고, 초기 값으로 Pending 상태를 가진다고 mdn에 명시되어 있기 때문에 상태 값의 초기 값은 Pending 값으로 설정 해주었다.

이정도로 해주면 속성에 대한 요구사항은 만족시켰다.

다음으로 넘어가자!

메소드로는 결과 값을 통해 다음 작업을 할 수 있게 해주는 then, catch가 필요하다.

메소드의 경우 then, catch에 대한 요구사항을 만족시켜야 한다.

우선 만드는건 그리 어렵지 않으니 직접 만들어 보자!

//프로미스 상태 값 정의 객체
//Object.freeze() 를 통해 수정 불가능하게 생성
const PROMISE_STATE = Object.freeze({
	pending: "pending",
  	fulfilled: "fulfilled",
  	rejected: "rejected"
})

class CustomPromise {
	#value = null //결과 값
    #state = PROMISE_STATE.pending // 상태 값(초기 값은 Pending 상태)
    
    then(){...}

	catch(){...}
}

흠.. 선언은 잘 하였는데, 과연 내부에 들어갈 내용은 뭐가 좋을지 전혀 생각해보지 않았다.

문제 없다! 지금 생각해보면 된다.

then 메소드 부터 한번 생각해보자.

과연 내부에 어떤 로직이 들어가면 좋을까?

간단하게 나열해보자면 다음과 같이 표현할 수 있다.

  1. then은 메소드의 리턴 값으로 새로운 프로미스를 리턴해야 한다(프로미스 체이닝을 위해)
  2. 리턴 되는 프로미스 내에서는 프로미스 체이닝을 위한 작업이 필요할 것이다.
  3. 프로미스 체이닝이란 결국 .then, .catch로 연결 되는 프로미스 간 실행의 순서가 보장 되어야 한다는 것(호출 하는 호스트 프로미스에서의 결과값이 도출 되어야 .then, .catch로 넘어가야 함.)
  4. 그렇다면 .then으로 호출 되어 생성 되는 프로미스 내부에서 호스트 프로미스에서 결과 값이 업데이트 된 후에 실행 할 함수를 넣어주기만 하면 프로미스 체이닝을 만들어 낼 수 있음.
  5. 즉 .then의 실행으로 리턴 되는 프로미스는 호스트 프로미스의 결과 값이 업데이트 되고 난 뒤 실행 될 함수들을 호스트 프로미스의 내부 어딘가에 저장하는 로직을 사용하면 됨.
  6. 추가로 then의 사용법을 생각해 본다면 호스트 프로미스의 결과 값을 인자로 받는 콜백을 받아야 함.

설명을 읽어본 사람이 있다면 감사의 표시를 하고 싶다.

정말 길지 않는가? 나 같으면 읽지도 않고 바로 코드가 있는 부분을 볼 것이다!

각설하고, then 은 프로미스의 핵심 기능이기 때문에 어쩔 수 없이 하는 일이 많아서 그렇다.

문제는 then을 구현하기 위해선 추가로 필요한 기능들이 있다는 것이다.

위의 순서를 잘 읽어보면 알 수 있지만, 호스트 프로미스의 값의 업데이트에 대한 얘기를 계속 하고 있다.

그러나 우리가 지금까지 만들어 낸 코드 속 클래스에는 그 어디에서도 무언가를 업데이트 하는 메소드가 존재하지 않음을 확인할 수 있다.

따라서 우리가 then, catch를 구현하기 이전에 클래스 내의 속성들을 업데이트 해주는 함수가 필요하다.

여기에 추가적으로 프로미스를 사용했다면 알 수 있겠지만, 프로미스의 객체를 생성하기 위해선 하나의 인자를 받게 된다.

그 인자는 함수인데, 해당 함수의 인자로는 resolve, reject 라는 프로미스의 상태를 변경하면서 동시에 결과 값을 업데이트 해주는 함수를 프로미스 객체를 생성할 때 인자로 넘겨 주어야 개발자들이 프로미스의 상태를 변경하고, 결과 값을 업데이트 할 수 있게 된다.

설명이 길었는데, 바로 코드로 해당 내용들을 구현해보자!

//프로미스 상태 값 정의 객체
//Object.freeze() 를 통해 수정 불가능하게 생성
const PROMISE_STATE = Object.freeze({
	pending: "pending",
  	fulfilled: "fulfilled",
  	rejected: "rejected"
})

class CustomPromise {
	#value = null //결과 값
    #state = PROMISE_STATE.pending // 상태 값(초기 값은 Pending 상태)
    
    constructor(executor){
    	try{
        	executor(this.#resolve.bind(this), this.#reject.bind(this))
        }
      	catch(error){
        	this.#reject(error)
        }
    }

	#resolve(value){
		this.#update(PROMISE_STATE.fulfilled, value)
	}
    
    #reject(error){
		this.#update(PROMISE_STATE.rejected, error)
	}
    
    #update(state, value){
		queueMicrotask(() => {
	    	if (this.#state !== PROMISE_STATE.pending) return;
  	    	this.#state = state;
    		this.#value = value;
    	});
	}
    
    
    then(){...}

	catch(){...}
}

총 4가지 파트가 추가 되었다.

constructor(executor)

  • 클래스 내부에서 생성자 함수를 표기하는 키워드
  • 생성자 함수는 executor 라는 함수를 받는다.
  • 생성자 함수는 executor를 실행 시키는데, 인자로 클래스 메소드인 resolve, reject를 넣어준다.
  • 이 때 resolve, reject 함수에 bind 메소드를 통해 명시적으로 this 바인딩을 진행 해준다.
  • this 바인딩을 명시적으로 해주는 이유는 resolve, reject 메소드 내에서 this를 사용하는데, 이 this의 값이 특정 함수 내부에서 실행 되는 경우 함수 스코프에 this가 바인딩 되기 때문이다.
  • 이를 통해 new CustomPromise((resolve, reject)=>{...}) 같은 코드를 사용할 수 있게 되었다.

resolve(value)

  • resolve 메소드의 역할은 프로미스 객체 내부의 value 값을 인자로 받은 value로 업데이트 하는 역할이다.
  • reject 메소드에서도 동일한 로직이 중복 되기 때문에 여기선 update 라는 내부 메소드에게 업데이트를 일임 해주고 있다.

reject(error)

  • reject 메소드의 역할은 프로미스 객체 내부의 value 값을 에러 데이터로 업데이트 하는 역할이다.
  • resolve 와 하는 일은 동일하나, resolve는 프로미스 이행 됨에 따른 결과 값으로 업데이트가 이루어 진다면, reject는 프로미스 이행 도중 발생한 에러로 인해 프로미스가 정상적으로 동작하지 않는 경우 해당 에러 값을 결과 값으로 저장해주는 역할을 수행한다.

update(state, value)

  • update 메소드는 resolve, reject 메소드에서 객체 속성을 업데이트 하는데 사용 하는 헬퍼 함수이다.
  • 해당 메소드는 2개의 인자를 받는데, 하나는 state로 이는 #state로 구현 된 객체 내부 속성의 값을 의미하고, value는 resolve, reject를 통해 업데이트 되어야 할 데이터를 의미한다. 이는 객체 내부 속성인 #value를 업데이트 시킨다.
  • 메소드에 queueMicrotask 라는 함수를 통해 로직이 전개 되고 있음을 알 수 있는데, 이는 WebAPI로 흔히 프로미스는 setTimeout 같은 매크로 큐에 담기지 않고, 마이크로 큐에 담긴다고 알고 있는데, 그 마이크로 큐에 해당 로직을 넣을 수 있게 도와주는 함수이다.
  • queueMicrotask 를 통해 우린 아주 쉽게 프로미스의 동작 중 하나를 해결할 수 있다.(마이크로 큐에 저장 되었다가, 콜 스택이 비었을 때 팝 되어서 처리 되는 과정.)
  • update 메소드의 로직의 첫번째를 보면 프로미스의 상태 값을 체크하여 pending 상태가 아니면 아무 일도 일어나지 않도록 하고 있는데, 이는 update 가 호출 되는 경로가 resolve, reject 뿐임을 생각하면 이해할 수 있다. (resolve, reject를 호출 하였다면 이미 프로미스는 성공 혹은 실패로 이행 되려는 상태이기 때문에 Pending 상태만 올 수 있음, 즉 에러를 걸러내는 로직)

지금까지 일종의 then 메소드를 구현하기 위한 준비 작업을 마쳤다.

그렇다면 then 메소드를 마저 구현해 보자.

까먹었을 까봐 다시 then 메소드 구현의 요건들을 살펴보자.

  1. then은 메소드의 리턴 값으로 새로운 프로미스를 리턴해야 한다(프로미스 체이닝을 위해)
  2. 리턴 되는 프로미스 내에서는 프로미스 체이닝을 위한 작업이 필요할 것이다.
  3. 프로미스 체이닝이란 결국 .then, .catch로 연결 되는 프로미스 간 실행의 순서가 보장 되어야 한다는 것(호출 하는 호스트 프로미스에서의 결과값이 도출 되어야 .then, .catch로 넘어가야 함.)
  4. 그렇다면 .then으로 호출 되어 생성 되는 프로미스 내부에서 호스트 프로미스에서 결과 값이 업데이트 된 후에 실행 할 함수를 넣어주기만 하면 프로미스 체이닝을 만들어 낼 수 있음.
  5. 즉 .then의 실행으로 리턴 되는 프로미스는 호스트 프로미스의 결과 값이 업데이트 되고 난 뒤 실행 될 함수들을 호스트 프로미스의 내부 어딘가에 저장하는 로직을 사용하면 됨.
  6. 추가로 then의 사용법을 생각해 본다면 호스트 프로미스의 결과 값을 인자로 받는 콜백을 받아야 함.

이를 코드로 구현하면 다음과 같다.

...

#thenCallbacks = [] // 프로미스 체이닝을 위한 콜백 저장 배열


#runCallbacks(){
	if(this.#state === PROMISE_STATE.fulfilled){
    	this.#thenCallbacks.forEach((callback)=>{
       		callback(this.#value)
       	})   
  	}
}


#update(state, value){
	queueMicrotask(()=>{
    	if(this.#state !== PROMISE_STATE.pending) return
        this.#state = state
        this.#value = value
        this.#runCallbacks()
    })
}

then(thenCallback){
	return new CustomPromise((resolve, reject)=>{
    	this.#thenCallbacks.push((resolvedValue)=>{
    		if(!thenCallback){
            	resolve(resolvedValue)
              	return;
            }
      	
      		try{
            	resolve(thenCallback(resolvedValue))
            }
      		catch(error){
            	reject(error)
            }
    	})
    })
}

...

... 꽤 복잡하다.

아니 좀 많이 복잡하다.

특히 나같이 리액트 개발만 주구장창 하면서 프로미스의 소중함을 모른 채 무지성 코딩을 해온 무지렁이에게 프로미스 내부에서 프로미스를 리턴하는 이런 구조는 정말 이해하기 어려웠다.

어려울 수 있겠지만, 일단 차근차근 설명 하면서 최대한 쉽게 풀어내 보겠다.

  1. 우선 then 의 역할은 프로미스 체이닝 + 호스트 프로미스의 업데이트 이후 then에 주어진 콜백을 실행 할 수 있게 하는 지연 실행이다.
  2. 이를 가능케 하려면 결국 호스트 프로미스에서 업데이트 이후에 then 메소드의 인자로 들어온 콜백을 실행 시켜야 한다.
  3. 즉 호스트 프로미스에서 콜백들을 관리 해야 함을 의미하며, 그 구현체가 바로 thenCallbacks 라는 배열이다.
  4. then에 인자로 호스트 프로미스가 실행 되고 난 뒤 처리 할 함수인 thenCallback 을 넣어주었다면, then은 새로운 프로미스를 리턴하면서 프로미스 내부에서 호스트 프로미스의 thenCallbacks 배열에 함수 하나를 푸쉬 한다.
  5. 해당 함수는 호스트 프로미스가 이행 되고 난 뒤에 실행 될 함수이며, 인자로 호스트 프로미스의 업데이트 된 #value 값을 전달 해주고, 이는 곧 코드에선 resolvedValue로 표현 된다.
  6. resolvedValue를 가지고선 조건문을 만나게 되는데, 조건은 thenCallback이 없는 경우를 체크하고 있다.
  7. 이를 체크하는 이유는 프로미스 체이닝에 항상 thenCallback이 들어오지 않기 때문이다. 이에 대해선 catch 메소드를 구현한 뒤에 좀 더 자세하게 설명하겠지만, 간단하게 말하자면 앞서 계속 언급한 프로미스 체이닝, 지연 실행을 위함이다.
  8. thenCallback이 없다면, 함수는 단순하게 resolve 메소드를 호출하여 then을 통해 만들어진 프로미스의 결과 값을 업데이트 하게 된다.
  9. 그 이후가 우리가 좀 더 집중해서 봐야할 부분인데, try~catch로 감싸져 있는 부분이다.
  10. 로직은 간단하다 앞서 resolve를 사용했던 것 처럼 resolve를 호출하고 있지만, 그 내부 인자는 thenCallback(resolvedValue) 이다.
  11. 즉 thenCallback이 존재하는 경우라면, 해당 함수를 실행 시키는데, 호스트 프로미스에서 받은 결과 값인 resolvedValue를 넘겨 실행한 리턴 값을 resolve 함수로 전달하게 되는 것이다.
  12. 이를 통해 우린 new CustomPromise(...).then((res)=>{...}) 의 res를 resolvedValue로 채워낼 수 있게 된 것이다.
  13. try~catch 구문을 쓴 이유는 thenCallback 에서 실행 도중 에러가 발생할 가능성이 있기 때문에 에러가 발생하는 경우 resolve 가 아닌 reject로 에러 값으로 then으로 생성 된 프로미스의 #value 값을 업데이트 하게 된다.

최대한 풀어서 설명해보았는데, 과연 잘 설명 하였는지 잘 모르겠다.

산넘어 산이라고 then에 대한 설명은 끝났지만, 코드를 살펴보면 then 뿐만 아니라 뭔가 더 추가 된 모습을 볼 수 있다.

이에 대해서도 설명해보고자 한다.

update(state, value)

  • update 함수에 앞서 없던 함수의 호출 부분이 추가 된 것을 알 수 있다.
  • this.#runCallbacks() 라는 로직이 추가 되었다.
  • 과연 해당 함수는 어떤 기능을 담당하는 함수인걸까?

runCallbacks()

  • 해당 메소드는 thenCallbacks 때문에 탄생한 메소드이다.
  • 앞서 then 호출로 인해 호스트 프로미스의 thenCallbacks에 호스트 프로미스의 결과 값 업데이트 이후 실행 될 콜백 함수들이 저장 된다고 설명하였다.
  • runCallbacks 메소드는 이 콜백들을 실행 시키는 메소드라고 생각하면 된다.
  • 현재 결과 값이 업데이트 된 호스트 프로미스의 상태를 보고 이행이 잘 되었는지, 체크하여 이행이 잘 되었다면 thenCallbacks에 있는 콜백 함수들을 하나씩 꺼내어 실행 시키는데, 핵심은 업데이트 된 호스트 프로미스의 결과 값을 인자로 넣어준다는 것이다.
  • 앞서 then 구현부에서 resolvedValue는 사실 여기서 넘겨준 this.#value 였던 것이다.

후우... 엄청나게 복잡한 프로세스 하나를 넘어온 것 같다.

이로써 then의 구현은 끝났다!

드디어!!

then의 핵심은 한마디로 요약하자면 then을 호출한 호스트가 되는 호스트 프로미스의 먼저 실행됨을 보장하면서 동시에 프로미스 체이닝이 가능하게끔 만들어준다는 것이다.

산넘어 산... catch를 구현해야 한다...

then 만큼 어렵겠지... 라고 생각할 수 있겠지만

사실 then에는 인자로 thenCallback 만을 받는게 아니다.

then의 두번째 인자로는 catchCallback 을 전달할 수 있다(실제 프로미스 구현에서도 then의 두번째 ㄴ인자로 에러가 발생할 경우 처리할 콜백을 넣어줄 수 있다.)

그 말은 어떻게 이케저케 잘 하면 catch의 구현은 then 메소드에 약간의 추가 혹은 수정을 통해 만들어 낼 수 있다는게 된다!

그렇다면 catch 메소드를 구현하기 위해 필요한 필수 요건이 무엇인지 파악해보자.

  1. then 과 마찬가지로 프로미스 체이닝을 위해선 프로미스를 리턴해야 한다.
  2. catch는 호스트 프로미스가 rejected 된 상태일 때만 동작하며, thenCallbacks 처럼 rejected 되었을 때 실행 할 콜백을 담아낼 배열이 필요하다.
  3. then의 두번째 인자로 catchCallback을 받도록 하고, thenCallback을 처리하는 방식과 동일하게 catchCallback을 처리한다.

이 필수 요건들을 종합하여 catch 메소드를 구현해 보자!

...

#catchCallbacks = []

#runCallbacks(){
	if(this.#state === PROMISE_STATE.fulfilled){
    	this.#thenCallbacks.forEach((callback)=>{
       		callback(this.#value)
       	})   
  	}
    
    if(this.#state === PROMISE_STATE.rejected){
    	this.#catchCallbacks.forEach((callback)=>{
       		callback(this.#value)
       	})   
  	}
}

then(thenCallback, catchCallback){
	return new CustomPromise((resolve, reject)=>{
    	this.#thenCallbacks.push((resolvedValue)=>{
    		if(!thenCallback){
            	resolve(resolvedValue)
              	return;
            }
      	
      		try{
            	resolve(thenCallback(resolvedValue))
            }
      		catch(error){
            	reject(error)
            }
    	})
  
  		this.#catchCallbacks.push((rejectedValue)=>{
    		if(!catchCallback){
            	reject(rejectedValue)
              	return;
            }
      	
      		try{
            	resolve(catchCallback(rejectedValue))
            }
      		catch(error){
            	reject(error)
            }
    	})
    })
}

catch(catchCallback){
	return this.then(undefined, catchCallback)
}
...

then 코드를 잘 이해했다면, catch 코드는 그 연장선이기 때문에 쉽게 이해할 수 있다.

추가/수정 된 코드를 살펴보자면

catchCallbacks

  • thenCallbacks와 마찬가지로 catchCallback 들을 담아 두는 컨테이너 배열이다.

runCallbacks()

  • then 구현 때는 fulfilled 된 상태를 체크하여 thenCallbacks를 순회하며 콜백들을 실행하였다면, catch는 rejected 된 상태를 체크하여 catchCallbacks를 순회하며 콜백들을 실행한다.
  • 프로미스는 이행 된 경우 fulfilled or rejected 이기 때문에 runCallbacks() 함수의 호출로 인해 두 경우 모두가 해당 되어 thenCallbacks, catchCallbacks 모두 실행 될 가능성은 0에 가깝다.

then(thenCallback, catchCallback)

  • then 메소드에 2번째 인자로 catchCallback 이 들어오게 되었다.
  • 이에 따라 catchCallbacks에 후에 실행 할 콜백 함수를 푸쉬 하는 로직이 보이는데, 잘 살펴보면 resolve가 reject로 바뀌었을 뿐 로직은 거의 동일하다.
  • 간혹 catch인데 왜 reject가 아니라 resolve(catchCallback(rejectedValue))이냐 라고 생각할 수 있지만, 잘 생각해보면 catch문이 쓰이는 경우는 이미 에러가 콜백 함수의 인자로 들어온 상태이기 때문에 에러에 대한 핸들링이 이루어졌고, 그렇기 때문에 추가적인 에러가 없기 때문에 resolve를 해주는 것이다.
  • 만약 여기서 resolve를 해주지 않으면, catch 문에서 아무런 문제가 없었는데, 그 후에 붙는 .then에선 앞서 실행 된 프로미스의 상태가 fulfilled 상태가 아니기 때문에 100%로 실행 되지 않는다.
  • 명심하자 catch 콜백이 실행 되는 경우라면 이미 앞서 에러가 발견 되었고, 그 에러는 catchCallback의 rejectedValue로 들어오게 된다. 즉 이미 에러 검출 되서 catchCallback에서 해당 에러를 파악할 수 있는 상태이므로 이후 프로미스 체이닝을 위해선 resolve가 맞다.

catch(catchCallback)

  • then에 이미 2번째 인자로 catchCallback을 처리할 수 있는 로직을 마련해 두었기 때문에 프로미스 체이닝을 위해 단순히 then 함수의 2번째 인자에 catchCallback을 넘기고, 만들어 지는 프로미스를 리턴해 주기만 하면 된다.

드디어 완성?

와! 드디어 완성 한 것 같다.

then, catch 를 구현했고, 이제 겉 보기엔 완벽하게 구현한 느낌이다!

한번 직접 코드 테스트를 해보자.

전체적인 코드의 모습

const PROMISE_STATE = Object.freeze({
  pending: "pending",
  fulfilled: "fulfilled",
  rejected: "rejected",
});

class CustomPromise {
  #value = null;
  #state = PROMISE_STATE.pending;
  #thenCallbacks = [];
  #catchCallbacks = [];

  constructor(executor) {
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (error) {
      this.#reject(error);
    }
  }

  #resolve(value) {
    this.#update(PROMISE_STATE.fulfilled, value);
  }

  #reject(error) {
    this.#update(PROMISE_STATE.rejected, error);
  }

  #runCallbacks() {
    if (this.#state === PROMISE_STATE.fulfilled) {
      this.#thenCallbacks.forEach((callback) => callback(this.#value));
    }

    if (this.#state === PROMISE_STATE.rejected) {
      this.#catchCallbacks.forEach((callback) => callback(this.#value));
    }
  }

  #update(state, value) {
    console.log(
      `${this.#key} Promise updated! state: ${state}, value: ${value}`
    );
    queueMicrotask(() => {
      if (this.#state !== PROMISE_STATE.pending) return;
      this.#state = state;
      this.#value = value;
      this.#runCallbacks();
    });
  }

  then(thenCallback, catchCallback) {
    return new CustomPromise((resolve, reject) => {
      this.#thenCallbacks.push((resolvedValue) => {
        if (!thenCallback) {
          resolve(resolvedValue);
          return;
        }
        try {
          resolve(thenCallback(resolvedValue));
        } catch (error) {
          reject(error);
        }
      });

      this.#catchCallbacks.push((rejectedValue) => {
        if (!catchCallback) {
          reject(rejectedValue);
          return;
        }
        try {
          resolve(catchCallback(rejectedValue));
        } catch (error) {
          reject(error);
        }
      });
    });
  }

  catch(callback) {
    return this.then(undefined, callback);
  }
}

간단하게 프로미스를 사용하고, then을 사용해서 프로미스가 잘 이행되는지 파악해 보자.

new CustomPromise((resolve, reject) => {
  resolve(2);
})
  .then((res) => {
    console.log(res);
    return ++res;
  })
  .then((res) => {
    console.log(res);
  });

/**
 * 결과:
 * 2
 * 3
 */

간단하게 프로미스를 만들고, 2를 resolve 하고 then을 연결하여 값을 1씩 올리면서 콘솔 로그를 찍어보았다.

이제 setTimeout 같은 비동기 구현 내용이 포함 된 프로미스도 프로미스 체이닝 및 지연 실행이 잘 동작하는지 확인해보자.

new CustomPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(2);
  }, 1_000);
})
  .then((res) => {
    console.log(res);
    return ++res;
  })
  .then((res) => {
    console.log(res);
  });

/**
 * 결과:
 * 1초 후...
 * 2 
 * 1초 후...
 * 3
 */

결과는 대성공이다!

이게 성공할 수 있는 이유는 then, catch 에서 프로미스를 리턴하면서 동시에 콜백을 호스트 프로미스에 저장하고, 호스트 프로미스에서 update 함수가 호출 되어 결과값, 상태값이 업데이트 된 이후에 then 내부에 넣은 콜백을 호출하기 때문에 무리 없이 실제 프로미스와 동일하게 동작하고 있는 모습을 볼 수 있다.

여기서 한번 더 업그레이드를 해보자 then 내부에서 리턴 값을 프로미스로 주면 어떨까?

과연 동작할까?

new CustomPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(2);
  }, 1_000);
})
  .then((res) => {
    console.log(res);
    return new CustomPromise((resolve, reject) => {
      setTimeout(() => {
        resolve(++res);
      }, 1_000);
    });
  })
  .then((res) => {
    console.log(res);
  });

/**
 * 결과:
 * 2
 * CustomPromise {} // ? 이게 뭐여?
 */

앗... 뭔가 문제가 생겼다.

우리가 원하는 동작 방식은 2가 1초 후 출력 -> 1초 후에 3이 출력이지만, CustomPromise {} 라는 그냥 프로미스 객체가 res에 담겨서 콘솔에 찍히게 되었다.

흠...일반 프로미스로 돌려보면 어떻게 나올까?

new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(2);
  }, 1_000);
})
  .then((res) => {
    console.log(res);
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(++res);
      }, 1_000);
    });
  })
  .then((res) => {
    console.log(res);
  });

/**
 * 결과:
 * 1초후...
 * 2
 * 1초후...
 * 3
 */

확실히 일반 프로미스는 우리가 예상했던 동작 그대로 동작하고, 콘솔에 표현 되고 있다.

이 문제는 어디서 발생 하는 것일까?

코드를 디버깅 해보자.

then 콜백 함수 내부에서 프로미스 리턴 디버깅

흠... 우리의 문제점에 대해 다시 한번 상기 시켜 보자.

then을 호출하고, 그 내부 콜백에서 리턴 값으로 프로미스를 넘겨 주었더니 다음 then 부분의 res로 그 프로미스 객체가 그대로 등장하였다.

그렇다면 then에 뭔가 문제가 있을 것이 분명하다.

따라서 then을 확인해보자.

  then(thenCallback, catchCallback) {
    return new CustomPromise((resolve, reject) => {
      this.#thenCallbacks.push((resolvedValue) => {
        if (!thenCallback) {
          resolve(resolvedValue);
          return;
        }
        try {
          resolve(thenCallback(resolvedValue)); // 콜백을 호출하는 부분이 여기이므로 여기가 문제의 시발점!
        } catch (error) {
          reject(error);
        }
      });

      this.#catchCallbacks.push((rejectedValue) => {
        if (!catchCallback) {
          reject(rejectedValue);
          return;
        }
        try {
          resolve(catchCallback(rejectedValue));
        } catch (error) {
          reject(error);
        }
      });
    });
  }

thenCallback이 우리가 앞서 then 내부에서 프로미스를 리턴한 콜백 함수일 것이다. 그리고 그 결과 값이 resolve 메소드의 인자로 들어갔다.

따라서 resolve 메소드를 통해 update 함수의 2번째 인자인 value로 프로미스 객체가 그대로 들어갔고, update 함수의 호출에 따라 그 다음 .then 메소드의 콜백 함수를 실행시키게 되면서 해당 콜백 함수의 res에 #value 값인 프로미스가 그대로 들어가게 된 것이다.

후우~ 디버깅을 통해 왜 문제가 발생하게 되었는지 파악할 수 있었다.

역시 컴퓨터는 잘못이 없다.

개발자인 우리의 잘못일뿐...

이제 남은건 이걸 고쳐서 실제 프로미스와 동일하게 동작하도록 만드는 일이다.

그렇다면 어떻게 고치지?

해결 방법은 생각해보면 간단할 수 있다.

프로미스 객체의 #value 값이 프로미스 객체라면, 그 프로미스 객체가 resolve 하는 값을 찾아서 #value에 있는 프로미스 객체를 resolve 한 값으로 업데이트 해주면 되지 않을까?

괜찮은 전략이다.

바로 실행해보자.

#value 값을 체크하는 곳은 어디일까? 그렇다 update 함수이다.

즉 update 함수를 고쳐야할 시간이다!

  #update(state, value) {
    queueMicrotask(() => {
      if (this.#state !== PROMISE_STATE.pending) return;
      if (value instanceof CustomPromise) {
      	value.then(this.#resolve.bind(this), this.#reject.bind(this));
      	return;
      }
      this.#state = state;
      this.#value = value;
      this.#runCallbacks();
    });
  }

코드를 살펴보자.

이전과 달라진 점은 딱 한가지 업데이트 되는 value의 값이 프로미스 객체인지를 확인하고 있다.

그리고 value가 프로미스 객체가 맞다면 value.then()을 통해 value 프로미스가 resolve 된 경우 어떻게 할 것인지를 명시하고 있다.

여기서 우린 this.#resolve.bind(this), this.#reject.bind(this)를 주목해야 한다.

현재 update 함수의 호출 지점은 어디인지 생각해보자. 어디인가?

호스트 프로미스이다.

앞서 본 예시에선 new CustomPromise(...).then() 으로 만들어진 프로미스이다.

해당 프로미스의 value에 현재 프로미스 객체가 들어온 것이고, 그러므로 this.resolve, this.reject의 주체인 this는 이 호스트 프로미스가 되는 것이다.

그리고 우린 value.then 으로 value 프로미스가 resolve 되는 경우 호출 될 콜백을 이들로 지정하고 있다.

이는 곧 무엇을 의미할까?

그렇다 현재 value는 프로미스 객체이지만, value 프로미스 내부에서 발생하는 resolve, reject는 곧 value.then 을 호출하게 되고, 호출과 동시에 value 프로미스의 결과 값을 콜백 함수에 넣어준다.

여기서 콜백 함수는 무엇인가?

그렇다 this.#resolve.bind(this) 이것이다.

해당 메소드는 어디서 온 것인가? 좀 더 정확하게 표현하자면 여기서 말하는 this는 누구인가?

그렇다 호스트 프로미스!! new CustomPromise(...).then() 으로 만들어진 프로미스 객체이다.

즉 value.then(this.#resolve.bind(this), ...) 는 value가 프로미스 객체인 호스트 프로미스의 value 값을 변경 시키게 된다.

그리고 그 변경 된 값은 value에 담겼던 프로미스의 이행 된 값이 된다!

해결~

실제로 코드의 동작을 살펴보자.

new CustomPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(2);
  }, 1_000);
})
  .then((res) => {
    console.log(res);
    return new CustomPromise((resolve, reject) => {
      setTimeout(() => {
        resolve(++res);
      }, 1_000);
    });
  })
  .then((res) => {
    console.log(res);
  });

/**
 * 결과:
 * 1초후
 * 2
 * 1초후
 * 3
 */

너무나도 아릅답게 잘 동작하고 있다.

혹여나 마지막 .then은 어떻게 정상적으로 결과 값을 받을 수 있는 건지 궁금하다면 new CustomPromise(...).then 의 콜백 함수에 무엇이 담겨 있을지 생각해 보면 바로 답이 나온다.
(마지막 .then의 콜백 함수가 담겨 있다.)

결과 코드

const PROMISE_STATE = Object.freeze({
  pending: "pending",
  fulfilled: "fulfilled",
  rejected: "rejected",
});

class CustomPromise {
  #value = null;
  #state = PROMISE_STATE.pending;
  #thenCallbacks = [];
  #catchCallbacks = [];

  constructor(executor) {
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (error) {
      this.#reject(error);
    }
  }

  #resolve(value) {
    this.#update(PROMISE_STATE.fulfilled, value);
  }

  #reject(error) {
    this.#update(PROMISE_STATE.rejected, error);
  }

  #runCallbacks() {
    if (this.#state === PROMISE_STATE.fulfilled) {
      this.#thenCallbacks.forEach((callback) => callback(this.#value));
    }

    if (this.#state === PROMISE_STATE.rejected) {
      this.#catchCallbacks.forEach((callback) => callback(this.#value));
    }
  }

  #update(state, value) {
    queueMicrotask(() => {
      if (this.#state !== PROMISE_STATE.pending) return;
      if (value instanceof CustomPromise) {
        value.then(this.#resolve.bind(this), this.#reject.bind(this));
        return;
      }
      this.#state = state;
      this.#value = value;
      this.#runCallbacks();
    });
  }

  then(thenCallback, catchCallback) {
    return new CustomPromise((resolve, reject) => {
      this.#thenCallbacks.push((resolvedValue) => {
        if (!thenCallback) {
          resolve(resolvedValue);
          return;
        }
        try {
          resolve(thenCallback(resolvedValue));
        } catch (error) {
          reject(error);
        }
      });

      this.#catchCallbacks.push((rejectedValue) => {
        if (!catchCallback) {
          reject(rejectedValue);
          return;
        }
        try {
          resolve(catchCallback(rejectedValue));
        } catch (error) {
          reject(error);
        }
      });
    });
  }

  catch(callback) {
    return this.then(undefined, callback);
  }
}

느낀점

사실 프로미스의 내부 구현따윈 몰라도 괜찮다.

리액트를 딥하게 공부할 때도 들었던 얘기지만

결국 이렇게 라이브러리 혹은 WebAPI로 제공 되는 요소들은

천재 개발자들이 내부 구현 따윈 파악하지 않고도 잘 사용할 수 있도록 멋지게 추상화를 잘 해두었기 때문에

이를 까뒤집어 보는건 그들의 의도한 바와는 다를 수 있다는 점이다.

하지만 우린 누구인가? 개발자들 아닌가.

그들만 개발자가 아니다 우리도 개발자고, 어떻게 이렇게 구현했는지 궁금할 때가 생긴다.

그럴 때 이렇게 직접 맨날 쓰고 있지만, 어떻게 내부적으로 구현이 되어 있는지 파악하지 못한 것들을

직접 구현하다보면, 익숙하게 쓰던 그 요소가 더 친근하고 달라져 보일 것이다.

나도 그렇다.

이번에 프로미스를 정말 100% 동일하게 구현한 것은 아니다.

finally 라는 키워드도 없고, 구현에 필요한 요소들을 적어서 구현하긴 했지만, 실제로 Promise.all, Promise.race 같은 메소드는 구현해보지 못했다.

어디까지나 프로미스 라는 개념을 좀 더 확실하게 하고, 어떻게 내부에서 동작하는지, 흔히 말하는 그 마법을 들춰서 그 속에 존재하는 과학을, 로직을 보고 싶었기 때문에 시작한 공부였다.

결과는 너무나도 뿌듯하고, 뜻 깊은 시간이 아니었나 싶다.

이제 어디가서 프로미스에 대해선 확실하게 대답할 수 있을 것 같다.

TMI지만, 개인적으로 가장 이해하기 어려운 부분은 then 내부 콜백 함수에서 프로미스를 리턴하는 경우 어떻게 처리해 주어야 하는지 너무 이해가 되지 않았다.

특히나 이미 누군가 만들어 둔 코드를 답습하여 공부하는 것이었기에 이미 답이 다 나와있는 와중에도 이해가 잘 안되서 하루종일 머리 싸매고 고민하고, 아이패드에 그림 그려가며 이해했던 힘들던 시절이 있었다.

그러나 모든게 그렇듯, 계속 하다보면 결국 언젠간 이해가 된다.(열심히 했다는 가정 하에)

프로미스에 대해 100% 이해하지 못했지만, 적어도 프로미스가 무엇이고, 왜 써야하며, 어떻게 then, catch를 구현했을지 이제 누구 앞에서 말할 수 있는 정도는 된 것 같다.

이 공부를 시작하게 해준 원작자 분과 원작 포스트에 감사함을 전하며 마무리 하고자 한다.

profile
무친 프론트엔드 개발자를 꿈꾸며...

0개의 댓글