본문 바로가기

Backend/Node.js

Promise & Promisify

- 프라미스 (promise)

 

이미 JS 혹은 Node.js 로 프로그래밍을 꽤 해오신 개발자분들이라면 좀 진부할 수 있지만, 프라미스(Promise) 에 대해 약간 적어볼까 합니다. 프라미스는 Javascript 에서 콜백 패턴에 대한 대안으로 현재는 굉장히 많이 쓰이며, Node.js 에서도 Node4 이후부터 기본 사용이 가능해졌습니다.

 

단어가 담고 있는 뜻 처럼 프라미스는 비동기 작업의 최종 결과를 나타낸다는 것을 약속합니다.

비동기 작업이 아직 완료되지 않았을 땐 pending, 성공적으로 끝나면 fulfilled(이행된), 실패하면 rejected(거부된) 라 부르고,
성공하거나 끝난 상태를 settled(처리된) 로 간주합니다. fulfill 된 값이나 reject 된 에러를 받기 위해선 then 메서드를 사용해야 합니다.

 

promise.then([onFulfilled], [onRejected])

 

프라미스 then 메서드의 기본적인 형식입니다. onFulfilled 는 fulfill 된 값을 받는 함수고, onRejected 엔 reject 된 이유를 받는 함수입니다. onFulfilled 혹은 onRejected 에서 에러가 발생한 경우 then 메서드로 반환된 메서드는 에러를 이유로 하여 자동으로 reject 됩니다. 이 점에 의해 에러는 자동으로 전파되며 프라미스 체인을 구성하는데 큰 이점으로 다가옵니다.

 

- Promises/A+

 

저는 그러한 시절을 겪진 않아서 잘 모르겠지만, 원래 이 프라미스는 여러 구현체가 있었고 서로 호환이 되지 않았다고 합니다.

그 말은 즉 구현체가 다른 프라미스 객체를 이어 붙여 체이닝할 수 없다는 것이죠.

이러한 한계를 극복하기 위해 Promises/A+ 명세가 만들어졌고 then 메서드의 스펙을 담고 있어 각각의 프라미스 구현체들도 이 스펙을 구현해 서로 호환이 되도록 하는 과정이 있었습니다. (링크)

 

이미 위에서 일부 얘기한 것들이고 위 링크에서 확인이 가능하지만, Promises/A+ 명세에 나와있는 내용 일부를 좀 더 붙여보겠습니다.

 

- 용어

 

promise 는 이 스펙을 준수하는 then 메서드를 가진 객체 혹은 함수입니다.

thenable 은 then 메서드를 가진 프라미스 객체를 의미합니다. 보통 thenable 은 외부의 다른 프라미스를 지칭할 때 사용합니다.

value 는 javascript 가 허용하는 모든 값이며, undefined 와 thenable, promise 를 포함합니다.

exception 은 throw 구문을 사용해 던져진 값입니다.

reason 은 promise 가 왜 거부되었는지를 나타내는 값입니다.

 

대부분 알고 있던 용어이지만, 이러한 정의가 Promises/A+ 명세에 되어 있다는 점은 이번에 처음 알게 되었습니다.

 

- then 메서드

 

Promises/A+ 명세에선 위에 얘기한 것 처럼, then 메서드에 대한 스펙을 기술하고 있습니다.

promise 는 현재 또는 최종 값이나 이유에 접근하기 위한 then 메서드를 제공해야 하며, 이 메서드는 onFulfilled 와 onRejected 두 인자를 허용합니다.

 

이 두 인자는 required 가 아닌 optional 이며, 함수가 아닐 경우엔 무시됩니다.

onFulfilled 가 함수면 이 함수는 프라미스가 fulfill 된 이후에 첫번째 인자를 프라미스의 value 로 하여 호출해야 합니다.

프라미스가 fulfill 되기 전에 호출되어선 안되고, 두 번 이상 호출하면 안됩니다.

onRejected 가 함수면 이 함수는 프라미스가 reject 된 이후에 첫번째 인자를 프라미스의 reason 으로 하여 호출해야 합니다.

마찬가지로 reject 되기 전에 호출되어선 안되고, 두 번 이상 호출하면 안됩니다.

 

promise2 = promise1.then(onFulfilled, onRejected)

 

then 은 반드시 프라미스를 반환해야 합니다.

만약 onFulfilled 혹은 onRejected 가 x 값을 반환한다면, [[Resolve]](promise2, x) 의 Promise Resolution Procedure 가 실행됩니다. 반대로 onFulfilled 혹은 onRejected 가 exception e 를 던진다면, promise2 는 e 를 reason 으로 하여 reject 됩니다.

만약 onFulfilled 가 함수가 아니고 promise1 이 fulfill 되었다면, promise2 또한 promise1 과 같은 값으로 fulfill 됩니다.

onRejected 가 함수가 아니고 promise1 이 reject 되었다면, promise1 과 같은 reason 으로 promise2 또한 reject 됩니다.

 

- Promise Resolution Procedure

 

이 부분이 영어로 된 문서를 처음 보면서 꽤나 난해하게 느껴졌던 것 같습니다. 영어로 계속 안쓰고 프라미스 해결 절차 라고 하겠습니다. (그게 그거긴한데..)

프라미스 해결 절차는 위에서 [[Resolve]](promise, x) 와 같이 표시했는데 프라미스와 값을 input 으로 갖는 추상적인 동작입니다.

만약 x 가 thenable 이면 이것은 x 가 적어도 프라미스처럼 동작한다는 가정 하에 프라미스가 x 의 상태를 취하도록 시도합니다.

그렇지 않으면 값 x 와 함께 프라미스는 fulfill 합니다.

thenable 을 이렇게 처리하면 Promises/A+ 스펙을 준수하는 then 메서드를 노출하는 이상 프라미스 구현은 서로 호환됩니다.

또한 Promises/A+ 스펙 구현이 합당한 then 메서드를 통해 스펙에 맞지 않는 구현을 동화하는 것을 허용합니다.

 

만약 프라미스가 재귀적 특성을 갖는 thenable 을 resolve 하게되면 [[Resolve]](promise, thenable) 은 결국 [[Resolve]](promise, thenable) 을 한 번 더 호출하게 되고, 이 알고리즘은 무한 재귀에 빠지게 됩니다.

이러한 재귀를 감지하고 TypeError 를 reason 으로 하는 프라미스를 reject 하는 구현이 권장되지만 필수는 아닙니다.

 

저는 대체로(거의) native promise 만 사용하지만, Promises/A+ 스펙을 구현한 BluebirdQ 를 사용하는 개발자 혹은 개발팀은 종종 봤던 것 같습니다. 위에서 얘기한 것 처럼 이들은 서로 호환되겠지만, 오히려 같이 쓰는 코드는 그동안 못본 것 같네요.

이런 라이브러리간의 프라미스 처리에 대한 성능 비교도 꽤 빈번히 있었던 것 같지만, 이쪽 내용은 일단 각설하겠습니다.

 

- 프라미스화 (Primisification)

 

Bluebird 를 주로 써왔던 개발자라면 promisify 라는 이름이 더 익숙할 듯 합니다. 프라미스화 라고도 부르는 이 용어는 일반적인 콜백 기반의 함수를 프라미스를 반환하는 함수로 바꾸는 걸 의미합니다. 요즘은 어지간해서는 라이브러리가 콜백, 프라미스 두 가지를 모두 지원하거나 오히려 프라미스만 지원하기도 합니다만 내용만 간단히 보도록 하겠습니다.

 

// utils.js
const promisify = callbackApi => {
  return function promisified() {
    const args = [].slice.call(arguments);

    return new Promise((resolve, reject) => {
      args.push((err, result) => {
        if (err) {
          return reject(err);
        }
        if (arguments.length <= 2) {
          resolve(result);
        } else {
          resolve([].slice.call(arguments, 1));
        }
      });
      callbackApi.apply(null, args);
    });
  }
};

module.exports = {
  promisify,
};

// test.js
const fs = require('fs');
const { promisify } = require('./utils');

const readFile = promisify(fs.readFile);

readFile('./tes.txt', 'utf-8').then(result => {
  console.info(result);
}, err => {
  console.error(err);
});

 

utils.js 에 promisify 라는 함수를 만들었고, 흔히 callback 패턴으로 사용하는 fs.readFile 함수를 promisify 에 적용해서 프라미스를 반환하도록 만들었습니다. 코드의 주 내용은 push 부분일텐데, callback api 는 항상 마지막 인자로 callback 이 위치한다는 점을 알기 때문에 args 목록의 마지막에 위치하도록 push 해주는 내용입니다.

위의 Promises/A+ 스펙에서 본 것 처럼 then 에 onFulfilled 와 onRejected 함수 두개를 전달하여 실행했을 때 fulfill 혹은 reject 됨에 따라 console.info 혹은 console.error 의 결과를 확인할 수 있습니다.

 

이건 하나의 예시일뿐 실무에서 이런 코드를 직접 구현해서 넣을 필요는 없고, 필요하다면 잘 만들어진 라이브러리를 가져다 쓰면 됩니다.

Bluebird 의 promisfy 혹은 util 의 promisify 를 사용하는 케이스를 자주 봤던 것 같습니다. util 라이브러리는 이번에 보니 반대로 콜백 스타일로 만들어주는 callbackify 라는 함수도 제공하네요.

 

때로는 콜백 스타일이 더 좋은 선택일 수 있지만 이러한 패턴도 익혀서 적재적소에 활용하는 것이 중요할 듯 합니다.

'Backend > Node.js' 카테고리의 다른 글

스트림 (Stream)  (0) 2022.09.03
제네레이터 (Generator)  (0) 2022.08.27
Observer Pattern in Node.js  (0) 2022.08.13
모듈 시스템  (0) 2022.08.07
Callback 패턴  (0) 2022.07.23