프로미스를 곁들인 for...of문

계기

함수형 프로그래밍 공부를 하던 중, take 함수에서 프로미스 객체가 포함된 배열을 인수로 받을 경우, 프로미스의 처리 결과를 배열에 넣도록 수정해야 했다.
수정한 코드를 보기전에 take함수의 역할을 설명하자면, 인수로 길이(숫자)와 반복가능한 객체(이터러블)를 받고 인수로 받은 길이 만큼의 배열을 반환하는 함수이다.

const take = (len, iter) => {
  let res = [];

  for (const a of iter) {
    res.push(a);
    if (res.length === len) return res;
  }

  return res;
};

프로미스를 처리하기 위해 위의 코드를 아래와 같이 수정했다.

const take = (len, iter) => {
  let res = [];

  return (function recur() {
    for (const a of iter) {
      if (a instanceof Promise) {
        return a.then((a) => {
          // (1)
          res.push(a);
          if (res.length === len) return res;
          return recur();
        });
      }

      res.push(a); // (2)
      if (res.length === len) return res;
    }

    return res;
  })();
};

반복문이 돌다가 프로미스 객체를 만나게 되면, 이후의 값들을 프로미스 처리가 끝난 후에 순차적으로 처리해야 한다.
그렇기 때문에 프로미스 처리가 끝난 후 recur함수를 호출하여 재귀적으로 다음 작업을 진행한다.
작업이 끝나면 결과값으로 [[PromiseResult]]가 배열인 프로미스 객체를 반환한다.
만약 (1)에서 반환하지 않으면 (2)로 넘어가서 프로미스 객체를 요소로 가진 배열을 결과값으로 반환한다.

코드가 제대로 동작하는지 확인하기위해 테스트를 해봤다.

take(2, [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]).then(console.log);

이 코드에 대한 결과로 [1, 2]가 출력될 것을 예상했다.

하지만…

예상된 결과와는 다르게 [1, 1]가 출력됐다.
왜 이런 결과가 나온걸까?
계속 고민을 해도 원인을 알 수 없었다.

함수형 프로그래밍 강의에서 소개해준 take함수는 아래와 같다.

const take = (len, iter) => {
  let res = [];
  iter = iter[Symbol.iterator]();

  return (function recur() {
    let cur;

    while (!(cur = iter.next()).done) {
      const a = cur.value;

      if (a instanceof Promise) {
        return a.then((a) => {
          res.push(a);
          if (res.length === len) return res;
          return recur();
        });
      }

      res.push(a);
      if (res.length === len) return res;
    }

    return res;
  })();
};

위의 코드로 테스트를 해보면 [1, 2]가 제대로 출력된다.
내 코드의 문제는 뭘까…

착각

처음에는 for…of문이 비동기 처리를 제대로 해주지 못해서 문제가 발생한줄 알았다.
그래서 for…of문을 for await…of문으로 수정하여 원하는 결과([1, 2])를 얻어냈다.

수정한 코드는 아래와 같다.

const take = (len, iter) => {
  let res = [];

  return (async () => {
    for await (const a of iter) {
      res.push(a);
      if (res.length === len) return res;
    }

    return res;
  })();
};

위 코드에도 문제가 있는데 프로미스 객체를 포함하지 않는 이터러블 객체를 인수로 받았을때도 결과값이 프로미스 객체라는 것이다. len2iter[1, 2, 3]을 인수로 받으면 결과값으로 [1, 2]가 나와야하는데, 프로미스 객체에 [1, 2]가 담겨서 온다.

그렇다면 for…of문의 문제가 아니라는 것인데…

해결

처음 구현한 코드에서 이곳저곳을 수정해보며 테스트했다.
하다보니 원하는 결과가 출력되는 코드를 알아냈다.

const take = (len, iter) => {
  let res = [];
  iter = iter[Symbol.iterator]();

  return (function recur() {
    for (const a of iter) {
      if (a instanceof Promise) {
        return a.then((a) => {
          res.push(a);
          if (res.length === len) return res;
          return recur();
        });
      }

      res.push(a);
      if (res.length === len) return res;
    }

    return res;
  })();
};

이터러블 객체를 이터레이터 객체로 바꾼 후 for…of문을 순회하면 제대로 동작한다.
생각해보면 당연한 것으로 recur 함수를 다시 실행해서 for…of문을 통해 배열을 순회하면 다시 배열의 첫번째 원소부터 순회하게 된다.
배열은 이터러블 객체이지만 for…of문에서 순회하는 방식이 다르며 이전에 순회한 원소의 위치를 기억하지 않는다.
그래서 배열을 이터레이터 객체로 바꾸고 for…of문을 순회해야 순서를 기억할 수 있다.
배열을 이터레이터 객체로 바꿔도 for…of문에서 사용할 수 있는 이유는 배열의 이터레이터 객체는 이터러블이면서 이터레이터인 객체이기 때문이다.

비로소 왜 처음 테스트했을 때 [1, 1]이 나왔는지 이해가 됐다.

비록 처음에는 비동기 처리 관련 문제로 착각하고 for await…of문으로 해결했다고 섣부른 결론을 내려버렸지만 take함수의 구현 목적을 다시 생각해봄으로써 문제를 제대로 이해하고 해결할 수 있었다.