🤫 ‘값’으로써 Promise

목표

  • 값으로써 Promise의 의미를 알아보자
  • 함수 합성 관점에서 Promise의 의미를 알아보자
  • Kleisli Composition 규칙 관점에서의 Promise의 의미를 알아보자
  • 위의 개념을 학습한 뒤에, reduce함수를 리팩토링 해보자

값으로서의 Promise 활용

  • go1 함수가 제대로 작동될려면, 인자 af가 동기적으로 값을 확인할 수 있어야 함
  • 즉, 비동기 상황이 일어나지 않는(일급 값이 아닌) 값이 들어와야 함 = Promise가 아닌 값이 들어와야 함
const go1 = (a, f) => f(a);
const add5 = (a) => a + 5;

go1(10, add5); // 15

  • 만약 go1 인자 a 자리에 시간이 지난 후, 값이 들어오면 어떻게 될까?
  • 정상적인 연산 불가
const go1 = (a, f) => f(a);
const add5 = (a) => a + 5;

go1(Promise.resolve(10), add5); // [object Promise]5

  • 그러면 어떻게 하면 위의 코드를 정상적으로 동작하게 할 수 있을까?
const delay100 = (a) => new Promise((resolve) => setTimeout(() => resolve(a), 100));

const go1 = (a, f) => (a instanceof Promise ? a.then(f) : f(a));
const add5 = (a) => a + 5;

// Promise{<pending>} 값(15를 담아서)으로 나옴
go1(Promise.resolve(10), add5);

const result = go1(10, add5);
console.log(result); // 15

const resultPromise = go1(delay100(10), add5);
resultPromise.then((a) => {
  console.log(a); // 15
});

  • go1함수를 일반 코드 평가와 Promise를 값으로 코드 평가 하는 함수를 동일한 상황에서 사용할 수 있도록 코드 수정
const go1 = (a, f) => (a instanceof Promise ? a.then(f) : f(a));

const n1 = 10;
go1(go1(n1, add5), log); // 15

const n2 = delay100(10);
go1(go1(n2, add5), log); // 15

함수 합성 관점에서 Promise

  • Promise는 비동기 상황에서 함수 합성을 안전하게 해주는 도구
  • 비동기 값을 가지고 연속적인 함수 합성 실행을 할 수 있음(모나드)
// 안전하게 함수 합성하는 도구 = 모나드
// f . g
// f(g(x))
  • 모나드 = Box
  • 밑에 코드 처럼 g함수에 안전한 인자가 들어올 수 있도록 하는 것이 모나드 개념임
const g = (a) => a + 1;
const f = (a) => a * a;

log(f(g(1))); // 4

// 밑에 코드는 안전한 코드가 아님
log(f(g())); // NaN

// 함수 합성을 map함수를 통해 만들기
// 여기서 [1] Array 값은, 필요한 값이 아님
// 다시 말하면, 개발자가 특정 효과를 만들거나 값을 다룰 때 사용하는 도구이지
// 사용자 화면에 노출되는 실제 결론 값은 아님
// 여기서 실제 결론 값은 밑에 배열에 들어있는 '4'가 필요한 값임
// 즉, Array 상태로 HTML에 렌더링 하지 않음
[1].map(g).map(f);

  • 밑에 [1] Array 값은, 필요한 값이 아님
  • 다시 말하면, 개발자가 특정 효과를 만들거나 값을 다룰 때 사용하는 도구이지
  • 사용자 화면에 노출되는 실제 결론 값은 아님
  • 여기서 실제 결론 값은 밑에 배열에 들어있는 ’4‘가 필요한 값임
  • 즉, Array 상태로 HTML에 렌더링 하지 않음
const g = (a) => a + 1;
const f = (a) => a * a;

// 함수 합성을 map함수를 통해 만들기
console.log([1].map(g).map(f)); // [4]

  • 그래서 밑에 코드 처럼 forEach함수를 통해 필요한 값을 추출함
  • 이렇게 작성했을 때의 이점은 만약 배열 []안에 아무 값도 들어오지 않는 나면 함수 합성을 하지 않음
  • 하지만 log(f(g())); 함수는 인자에 값이 안들어와도, NaN이라는 값을 리턴 함
  • 배열을 사용함에 있어 함수 합성을 진행할 때 안전하게 함수 합성을 할 수 있음
const g = a => a + 1;
const f = a => a * a;

console.log([1].map(g).map(f).forEach(r => console.log(r));  // 4
console.log([].map(g).map(f).forEach(r => console.log(r));   // 아무것도 없음
log(f(g()));  // NaN

  • 그러면 Promise는 어떠한 값을 함수 합성으로 진행하는 가
  • 위의 배열은 map을 통해 함수 합성하는 것처럼, Promise는 then을 통해 함수 합성을 진행함
  • Promise는 비동기(대기)상황에서 안전하게 함수를 합성하기 위한 도구
  • Promise는 resolve안의 인자가 있는지 없는지에대한 안전하게 함수 합성을 하는 것이 아니라,
  • 비동기 상황(대기가 일어난 상황)에서 안전한 함수 합성을 하기 위해 사용
const g = (a) => a + 1;
const f = (a) => a * a;

Promise.resolve(1)
  .then(g)
  .then(f)
  .then((r) => log(r)); // 4
Promise.resolve()
  .then(g)
  .then(f)
  .then((r) => log(r)); // NaN

new Promise((resolve) => setTimeout(() => resolve(2), 100))
  .then(g)
  .then(f)
  .then((r) => log(r));

Kleisli Composition 관점에서의 Promise

  • Promise는 Kleisli Composition를 지원하는 도구
  • Kleisli Composition(Arrow) 함수 합성 방법은 오류가 있을 수 있는 상황에서 함수 합성을 안전하게 하는 하나의 규칙
  • 예를 들어, 함수의 인자가 잘못 들어와 함수 안에서 오류가 발생하는 지, 정확한 인자가 들어왔더라도 외부에 의존하고 있는 함수가 외부의 상태때문에 무언가를 정확하게 전달할 수 없는 상황을 해결하기 위해서 Kleisli Composition 사용
// f . g

// 양 변에 x값이 같다면 이 식은 항상 같음
// 그러나 실무에서는 왼쪽 g함수가 옳바른 값을 리턴해도
// 오른쪽 항의 g 함수 내부 코드 안 상태가 변했을 경우, 리턴 값이 달라질 수 있음
// 그래서 특정한 규칙을 만들어 안전한 합성을 만드는 것이 Kleisli Composition 함수 합성임
f(g(x)) = f(g(x))

// Kleisli Composition 규칙
// 만약에 오른쪽 g(x)에서 에러가 발생해도
// 왼쪽 f(g(x))도 같은 에러가 발생하게 하는 것
f(g(x)) = g(x),

Kleisli Composition을 사용해야 하는 이유

// 상태
const users = [
  { id: 1, name: 'aa' },
  { id: 2, name: 'bb' },
  { id: 3, name: 'cc' },
];

// id값 찾는 함수
const getUserById = (id) => find((u) => u.id == id, users) || Promise.reject('없어요!');

// 객체에서 Name을 추출해서 Name을 리턴함
const f = ({ name }) => name;

const g = getUserById;

const fg = (id) => f(g(id));

log(fg(2)); // bb'
log(fg(2) == fg(2)); // true

  • 실무에서 users에 상태는 계속 변할 때 예시 : users.pop()
  • 그렇기 때문에 f,g 함수가 안전하지 않음
  • f는 객체에 name이라는 값이 있어야 하며, g는 객체 안에 id값이 무조건 있어야 함
  • 이렇게 에러를 발생시키지 않는 규칙이 Kleisli Composition
const users = [
  { id: 1, name: 'aa' },
  { id: 2, name: 'bb' },
  { id: 3, name: 'cc' },
];

const getUserById = (id) => find((u) => u.id == id, users) || Promise.reject('없어요!');

const f = ({ name }) => name;
const g = getUserById;
const fg = (id) => f(g(id));

// 사용자가 사용자 정보를 지웠을 경우
const r = fg(2);
console.log(r); // bb

users.pop();
users.pop();

const r2 = fg(2);
console.log(r2); // 에러발

Kleisli Composition 사용

  • 예외처리 같은 개념
const users = [
  { id: 1, name: 'aa' },
  { id: 2, name: 'bb' },
  { id: 3, name: 'cc' },
];

const getUserById = (id) => find((u) => u.id == id, users) || Promise.reject('없어요!');

const f = ({ name }) => name;
const g = getUserById;

const fg = (id) =>
  Promise.resolve(id)
    .then(g)
    .then(f)
    .catch((a) => a);

fg(2).then(log); // bb

users.pop();
users.pop();

fg(2).then(log); // 없어요!

go, pipe, reduce에서 비동기 제어

  • 비동기를 값으로 다루는 Promise 성질을 이용해서 go1이라는 함수를 만들어서 동기상황과 비동기 상황을 대응 하는 함수를 만들었음
const go1 = (a, f) => (a instanceof Promise ? a.then(f) : f(a));
  • go, pipe, reduce는 함수를 연속적으로 사용하는 함수 합성에 대한 함수들임
  • go, pipe함수에서도 비동기를 값으로 다루는 Promise를 이용해, 비동기적인 상황에서도 잘대응하는 함수를 만들 수도 있고
  • kleisli Composition처럼 중간에 에러(reject포함)가 발생했을 때, 이후 대기중인 함수들을 실행하지 않게 할 수 있음
  • 현재 밑에는 에러가 발생함
  • 그러면 중간에 Promise가 와도 정상적인 작동을 하는 코드를 만들려면 어떻게 해야 할까?
  • 또한 처음 시작 하는 값이 Promise.resolve(1) 일경우에는?
  • 마지막으로 Promise 이후에 값들을 동기적으로 즉 하나의 콜스택에 작동할려면 어떻게 해야 할까?
go(
  1,
  (a) => a + 10,
  a + Promise.resolve(a + 100),
  (a) => a + 1000,
  log,
); // [object Promise]1000

  • go함수를 수정해보자
  • go, pipe함수 내부에 reduce함수를 다루기 때문에 reduce함수를 수정하면 됨
const go1 = (a, f) => (a instanceof Promise ? a.then(f) : f(a));

const reduce = curry((f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  } else {
    iter = iter[Symbol.iterator]();
  }

  return go1(acc, function recur(acc) {
    let cur;

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

      if (acc instanceof Promise) {
        return acc.then(recur);
      }
    }

    return acc;
  });
});

// 사용
go(
  Promise.resolve(1),
  (a) => a + 10,
  a + Promise.resolve(a + 100),
  (a) => a + 1000,
  (a) => a + 10000,
);

go(
  Promise.resolve(1),
  (a) => a + 10,
  a + Promise.reject('Error!!'), // 여기서  끝남
  (a) => a + 1000,
  (a) => a + 10000,
).catch((error) => console.log(error)); // Error!!
  • Promise를 단순히 콜백지옥을 해결하는 용도로 사용하는 걸 넘어서
  • 내가 원하는 시점에 함수 처리 및 에러 대응을 할 수 있음

Promise.then의 중요한 규칙

  • Promise.then으로 값을 꺼냈을 때는 반드시 Promise 값이 아님
  • Promise가 중첩되어도 한 번의 then으로 꺼내서 사용 가능
Promise.resolve(Promise.resolve(1)).then(function (a) {
  log(a);
});

new Promise((resolve) => resolve(new Promise((resolve) => resolve(1)))).then(log);


느낀점

단순히 비동기 상황에서 사용했던 Promise를 다양한 관점에서 Promise를 바라보는 시간을 가졌다. Promise가 실무에서 너무나 중요한 것은 잘 알고 있었지만, 정말 제대로 사용하고 있는지에 대한 스스로 의심이 있었는데 이렇게 학습을 통해서 Promise의 사용 용도와 의미를 제대로 알게 되어 너무 기쁘다. 이제 실무에서 개발할 때 Promise에 대해 조금은 이해를 하며 개발 할 수 있겠다는 기대가 생겼다.


참고

유인동님의 함수형 프로그래밍과 JS ES6+ 강의