reduce() 를 이용해 순차적으로 프라미스를 해결하는 방법

🗓 2018-10-24

원글: Why Using reduce() to Sequentially Resolve Promises Works

https://css-tricks.com/why-using-reduce-to-sequentially-resolve-promises-works/

reduce() 를 이용해 순차적으로 프라미스를 해결하는 방법

Promise 객체를 사용하지 않고 비동기 자바스크립트 코드를 작성하는 것은 눈을 감고 케이크를 굽는 것 만큼 어렵다. 물론 할 수 있지만 복잡해질 것이고 결국 화딱지가 나서 못 해먹을 것이다. 꼭 필요하다고 말할 순 없지만 일단 사용해보면 곧 얼마나 좋은지 알 수 있을 것이다. 좋기는 하지만 가끔 특정 상황에서는 난감할 때가 있다. 많은 양의 프로미스들을 하나 하나 순차적으로 resolve 할 때와 같은 경우 말이다. 예를 들어 AJAX를 통해 몇 가지 순차적인 처리를 하고 있을 때 서버에서 많은 양의 처리를 하기를 원하지만 한 번에 모든 것을 처리하는 것이 아니라 시간에 두고 하나씩 처리하고자 할 때의 경우이다. 이럴 때 적용할 수 있는 좋은 방법이 있다.

Caolan McMahon의 async 라이브러리와 같이 이런 작업을 도와줄 패키지를 배제한다면 순차적인 프로미스의 해결을 위한 가장 일반적인 방법은 바로 Array.prototype.reduce() 를 사용하는 것이다. 아마 이 글을 봤을지 모르겠다. reduce() 는 컬렉션을 순회하며 하나의 값을 만들 수 있다.

let result = [1,2,5].reduce((accumulator, item) => {
  return accumulator + item;
}, 0); // <-- 0은 초기값

console.log(result); // 8

우리의 목적에 맞게 reduce() 를 사용한다면 코드는 아래와 같은 모습이다.

let userIDs = [1,2,3];

userIDs.reduce( (previousPromise, nextID) => {
  return previousPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

조금 더 요즘에 맞게 수정한다면..

let userIDs = [1,2,3];

userIDs.reduce( async (previousPromise, nextID) => {
  await previousPromise;
  return methodThatReturnsAPromise(nextID);
}, Promise.resolve());

훨씬 깔끔해 졌다. 오랜 시간 동안 나는 이 해결책을 그저 받아드리고 대부분 생각 없이 코드 조각을 복사, 붙여넣기 해서 어플리케이션에 적용했다. 잘 동작했기 때문이다. 이 글은 꼭 이해해야 할 두 가지에 대해 다룬다.

  1. 어떻게 이 방법이 동작할까?
  2. Array 의 다른 메서드들은 같은 방법으로 사용할 수 없는가?

이 방법은 어떻게 동작할까?

reduce() 의 주 목적은 많은 것들을 하나로 줄여주는 것이다. 이런 작업은 루프가 돌아갈 때마다 누산기 에 결과를 저장하는 형태로 구현한다. 하지만 누산기 가 꼭 숫자일 필요는 없다. 루프에서는 뭐든 리턴할 수 있다.(프로미스 같은 것들도) 그리고 매 순회 콜백에서 그 값을 재활용할 수 있다. 명확한 점은 누산기 의 값이 무엇이든 이런 루프의 동작은 바뀔 일이 없다는것이다. 실행 주기를 포함해서 말이다. 스레드가 허용하는 속도로 계속 컬렉션을 순회할것이다.

기존 루프의 동작과는 다르기 때문에 이해하기가 힘들겠지만, (나 역시 그랬다.) 순차적인 프로미스의 해결을 위해 사용되는 reduce() 루프는 실제로 전혀 느려지지 않는다. 완벽히 동기적이고 가능한 빠르게 루프의 정상적인 동작을 한다. 언제나처럼 말이다.

아래의 코드를 통해 어떻게 루프의 진행이 콜백안에서 리턴되는 프로미스에 의해 방해받지 않는지 알아보자.

function methodThatReturnsAPromise(nextID) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {

      console.log(`Resolve! ${dayjs().format('hh:mm:ss')}`);

      resolve();
    }, 1000);
  });
}

[1,2,3].reduce( (accumulatorPromise, nextID) => {

  console.log(`Loop! ${dayjs().format('hh:mm:ss')}`);

  return accumulatorPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

콘솔에는 아래와 같이 출력된다.

"Loop! 11:28:06"
"Loop! 11:28:06"
"Loop! 11:28:06"
"Resolve! 11:28:07"
"Resolve! 11:28:08"
"Resolve! 11:28:09"

프로미스들은 의도한 순서대로 해결되지만 루프 자체는 동기적으로 일정하고 빠르게 실행됐다.. MDN 폴리필 코드를 한번 보고 나면 이런 동작이 이해가 될것이다. while 루프가 callback() 을 계속 해서 실행할때 비동기적인것은 아무것도 없다.

while (k < len) {
  if (k in o) {
    value = callback(value, o[k], k, o);
  }
  k++;
}

실제 진짜 매직은 아래의 코드에서 이루어진다.

return previousPromise.then(() => {
  return methodThatReturnsAPromise(nextID)
});

콜백시 실행될 때마다 프로미스를 리턴하고 그 프로미스는 다른 프로미스에 의해 해결된다. 그리고 reduce() 는 그 어떤 것도 해결될 때까지 기다리지 않는다. reduce() 가 제공하는 장점은 콜백이 실행 된뒤 또다시 같은 콜백에 무언가를 전달할 수 있는 능력이다. 이는 reduce() 의 고유한 기능이다. 그 결과로 프로미스의 해결을 통해 또 다른 프로미스를 만들어내는 프로미스 체인을 만들 수 있는 것이다. 모든 걸 멋지고 순차적으로 말이다.

new Promise( (resolve, reject) => {
  // Promise #1

  resolve();
}).then( (result) => { 
  // Promise #2

  return result;
}).then( (result) => { 
  // Promise #3

  return result;
}); // ... 이후 계속...

그리고 또 한가지 밝혀야 할 것이 있다. 왜 그냥 하나의 새로운 프로미스를 각 순회마다 리턴하지 않았을까? 그 이유는 루프 자체는 동기적으로 돌기 때문이다. 각 프로미스는 즉각 실행되고 다른 프로미스를 기다리지 않는다.

[1,2,3].reduce( (previousPromise, nextID) => {

  console.log(`Loop! ${dayjs().format('hh:mm:ss')}`);

  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`Resolve! ${dayjs().format('hh:mm:ss')}`);
      resolve(nextID);
    }, 1000);
  });
}, Promise.resolve());

콘솔에는 아래와 같이 출력된다.

"Loop! 11:31:20"
"Loop! 11:31:20"
"Loop! 11:31:20"
"Resolve! 11:31:21"
"Resolve! 11:31:21"
"Resolve! 11:31:21"

프로미스의 모든 작업들이 종료되었을 때 특정 작업을 수행하도록 할 수 있을까? 가능하다. reduce() 의 동기적인 성질이 모든 항목이 작업을 끝낸 시점을 알 수 없다는 것을 의미하지는 않는다.

function methodThatReturnsAPromise(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`Processing ${id}`);
      resolve(id);
    }, 1000);
  });
}

let result = [1,2,3].reduce( (accumulatorPromise, nextID) => {
  return accumulatorPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

result.then(e => {
  console.log("Resolution is complete! Let's party.")
});

콜백이 반환하는 모든 것은 연결된 프로미스기 때문에 루프의 실행이 종료되면 얻을 것 역시 프로미스다. 그래서 reduce() 가 실행을 종료한 뒤에 우리가 원하는 작업을 수행할 수 있습니다.

왜 배열의 다른 메서드들을 사용할 수 없는가?

다음 순회에 도달하기 전에 이전 콜백의 작업이 완료되는 것을 기다리지 않았던 reduce() 의 내부 동작을 기억하는가? 완벽히 동기적이다. 이는 배열의 다른 메서드들도 동일하다.

하지만 Reduce()는 조금 특별하다

reduce() 가 우리에게 도움을 줄 수 있던 이유는 무언가를 동일한 콜백으로 반환할 수 있다는 점이다. 이를 통해 우리는 프로미스의 해결이 또 다른 프로미스를 만들어낼 수 있었다. 하지만 다른 메서드들을 이용해서는 이전 콜백이 리턴한것을 인수로 전달받을 수 없다. 대신 인수로 미리 약속된 값이 넘어오게 되는데 그것들은 순차적인 프로미스의 해결 구조를 만들기 위해 활용하기 힘들다.

[1,2,3].map((item, [index, array]) => [value]);
[1,2,3].filter((item, [index, array]) => [boolean]);
[1,2,3].some((item, [index, array]) => [boolean]);
[1,2,3].every((item, [index, array]) => [boolean]);

도움이 되었으면 좋겠다!

최소한 왜 reduce() 가 이런 방법으로 프로미스를 처리하는데 유용한 것인지에 대한 이유라도 알게 되었으면 한다. 그리고 아마도 배열의 일반적인 메서드들이 내부에서 어떻게 동작하는지에 대한 더 나은 이해도 얻었으면 좋겠다. 만약 내가 놓친 것이 있거나 잘못된 점이 있다면 알려줬으면 좋겠다!

♥ Support writer ♥
with kakaopay

크리에이티브 커먼즈 라이선스이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.

© Sungho Kim2023