[functional JS ES6+] 비동기/동시성 프로그래밍 1 : callback과 Promise, Monad와 then

작성:    

업데이트:

카테고리:

태그: , ,

본 포스트는 인프런의 함수형 프로그래밍과 JavaScript ES6+ 강의(링크)를 듣고 정리한 내용입니다.


callback과 Promise

callback

add10 함수를 의도적으로 100ms 이후에 실행하도록 해보자

function add10(a, callback) {
  setTimeout(() => callback(a + 10), 100);
}

add10(5, res => {
  log(res); // 15
});

add10(5, res => {
  add10(res, res => {
    add10(res, res => {
      log(res); // 35
    });
  });
});
  • 전달된 a에 10을 더해 callback 함수에 전달
  • 이것을 100ms 지연한 뒤 실행
  • 아래에서 callback 함수는 인자를 단순히 출력하는 함수
  • 합성하려면 내부적으로 계속 깊이가 깊어짐. callback 지옥 주의


Promise 사용

같은 일을 Promise를 사용해 실행해보자

function add20(a) {
  return new Promise(resolve => setTimeout(() => resolve(a + 20), 100));
}

add20(5)
  .then(log); // 25

add20(5)
  .then(add20)
  .then(add20)
  .then(log); // 65
  • Promise를 만들어서 return return 한다는 점이 중요!
  • .then을 계속 이어붙여도 깊이가 깊어지지 않음 → 가독성, 유지보수에 유리


callback과 Promise의 중요한 차이점 ⭐

Promise

  • Promise는 비동기 상황을 일급 값으로 취급
  • Promise라는 class를 통해서 만들어진 인스턴스를 반환
  • 이 인스턴스는 대기/성공/실패를 다루는 일급 값(Promise 객체)으로 구성
  • 반환값이 일급
    • 변수에 할당 가능
    • 함수에 인자로 전달 가능
    • 연속적인 작업으로 처리 가능(.then)


callback

  • 반면 callback은 단순히 코드로만 구성
  • 코드 진행에 대한 상황 파악이 불가능


일급 활용

const go1 = (a, f) => f(a);
const add5 = a => a + 5;

log(go1(10, add5)); // 15


add5 함수가 정상적으로 작동하기 위한 조건

  • go1의 f 함수인자가 동기적으로 동작하는 함수
  • a 값 역시 동기적으로 값을 알 수 있는 값
  • 즉, Promise가 아닌 값일 때 함수에 값 적용


만약 go1 함수의 a 인자가 비동기적으로 평가되는 경우

// 100ms 뒤에 받아둔 인자를 그대로 반환하는 함수
const delay100 = a => new Promise(resolve => setTimeout(( => resolve(a), 100)))


log(go1(delay100(10), add5)); // [object Prmoise]5
  • 원하는 값 도출 불가능


이런 상황을 해결하기 위해서는?

go1 함수가 일급이라는 점을 활용해 해결해보자.

// 기존 go1 함수
const go1 = (a, f) => f(a);

// 일급 활용 go1 함수
const go1 = (a, f) => a instanceof Promise ? a.then(f) : f(a);
  • a가 Promise라면 a 값이 값으로 평가된 이후에 f 함수 실행
  • a가 Promise가 아니라면 f 함수 바로 실행


함수 합성 관점에서의 Promise

모나드(Monad)

  • 함수 합성을 안전하게 하기 위한 도구
  • 비동기 상황에서 사용하는 모나드 : Promise
  • JS에서 직접적으로 설명하거나 거론하지는 않지만, 함수 합성에서의 응용을 위해서 어느 정도 필요


const g = a => a + 1;
const f = a => a * a;

log(f(g(1))); // 4
log(f(g())); // NaN
  • 인자가 있는 경우, g함수에 전달되어 값을 반환하고, 다시 f함수에 전달되어 log 함수에 유의미한 인자로 작용
  • 인자가 비어있는 경우, f 함수를 거쳐 나온 값이 비정상적일지라도 log 함수를 통해 출력되어 문제 발생
  • 즉, 안전하게 합성되지 않았다, 또는 반드시 안전한 인자(함수합성이 동작할 수 있는 인자)만 전달되어야 하는 함수 합성


Monad식 사고 : 어떤 type의 인자가 들어오든지, 또는 들어오지 않더라도 안전하게 함수 합성을 할 수 있는 방법은 없을까?

log(f(g(1)));
log(f(g()));

log([1].map(g).map(f)); // [4]
[1].map(g).map(f).forEach(r => log(r)); // 4
[].map(g).map(f).forEach(r => log(r)); // _
  • monad의 인자는 array에 넣으며 시작
  • map 함수를 통해서 함수 합성

  • 만약 인자가 없어서 배열이 비어있다면, 아무 일도 일어나지 않는다.
  • .map(f), .forEach(r => log(r)) 자체가 실행되지 않는다.
  • 오류를 출력하지 않는 안전한 함수 합성


Monad와 Promise

// resolve를 통해 Promise라는 값을 만드는 것
Promise.resolve(1).then(g).then(f).then(r => log(r));

// 이는 위의 Monad에서 처음 배열을 만드는 것과 같다.
Array.of(1).map(g).map(f).forEach(r => log(r)); // 4
  • 기존 array에서는 map chain으로 함수를 합성
  • 비동기 상황에서 Promise는 then chain으로 함수를 합성

  • 단, 기존 monad가 map chain으로 인자의 유무 여부에 관계 없이 안전한 함수 합성을 하려는 목적이라면,
  • Promise는 비동기 상황(대기 상황)에서의 안전한 함수 합성을 보장하려는 목적
  • Promise에 인자가 없는 경우에 대한 안전한 합성을 보장하지는 않는다.


Kleisli Composition

오류가 있을 수 있는 함수 합성에서의 안정성을 보장하는 하나의 규칙

Promise는 Kleisli Composition을 지원하는 도구


오류가 발생하는 상황

  1. 외부적 요인 : 들어오는 인자 자체가 잘못된 경우
  2. 내부적 요인 : 인자는 정상이어도 어떠한 환경적이거나 로직적인 부분에 의해
// 수학적 관점
f(g(x)) = f(g(x))

// 실무적 환경
f(g(x)) != f(g(x))

// 이유는 g 함수가 바라보고 있는 어떤 값이 비교하는 시점에서 달라지거나 없어졌을 수 있기 때문


예시

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

// user의 id를 통해 user를 찾는 함수
const getUserById = id =>
  find(u => u.id == id, users);

// 함수 합성
const f = ({name}) => name;
const g = getUserById;
const fg = id => f(g(id));


// 이상적인 경우
log(fg(2)); // bb
log(fg(2) == fg(2)); // true


// 오류 발생 예시 : users에 데이터가 없어지는 경우 
const r1 = fg(2);
users.pop();
users.pop();
const r2 = fg(2);
log(r1 == r2); // TypeError

이런 합성 과정에서 에러가 발생하지 않도록 하는 것이 Kleisli Composition


Kleisli Composition 적용

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();
g(1) // {id: 1, name: "aa"}
g(2) // Promise {<rejected>: "없어요"}
fg(2).then(log); // Promise {<rejected>: "없어요"}
  • getUserById 함수에서 find 함수의 결과가 없을 때 오류가 아닌 reject 결과를 반환
  • g(2)에서 rejected 된 경우 이후 then(f)를 실행하지 않는다.
  • 이때 단순히 console에 오류로 찍히는 것이 아닌, 반환하기 위해서는 catch 문을 사용
  • fg 함수 전체의 반환값이 rejected Promise인 a가 반환
  • .then(log)만 실행되어 a가 출력


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

go

const go = (...args) => reduce((a, f) => f(a), args);
const reduce = curry((f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  } else {
    iter = iter[Symbol.iterator]();
  }
  let cur;
  while (!(cur = iter.next()).done) {
    const a = cur.value;
    acc = f(acc, a);
  }
  return acc;
});

go(1,
  a => a + 10,
  a => Promise.resolve(a + 100),
  a => a + 1000,
  log);
  • go 함수는 reduce 함수에 의해 정의된다.
  • reduce 함수는 acc에 매 인자 a를 함수 f를 통해 누적하여 처리한다.

  • 이때 go 함수 내부에서 함수의 반환값이 Promise라면, 어느 시점에서는 reduce 함수의 while문에서 acc가 Promise가 되게 된다.
  • Promise를 f 함수의 인자로 사용해야 하고, 이는 기다렸다가 값으로 평가되면 f 함수를 돌리게 되는 것
  • 때문에 이에 대한 로직으로 reduce의 while문을 다시 작성해주어야 한다.


Sol A(비권장) : acc의 Promise 인스턴스 여부를 확인하여 다르게 처리

const reduce = curry((f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  } else {
    iter = iter[Symbol.iterator]();
  }
  let cur;
  while (!(cur = iter.next()).done) {
    const a = cur.value;
    // acc = f(acc, a);
    acc = acc instanceof Promise ? acc.then(acc => f(acc, a)) : f(acc, a); 🔆
  }
  return acc;
});
  • 이는 마냥 좋은 코드는 아니다.
  • go 함수에서 중간에 Promise를 사용한다면, 이후 함수에도 계속 Promise chain에다 함수를 합성
  • 연속적으로 비동기가 발생

  • 만약 개발자가 이후 함수는 동기적으로 하나의 call stack에서 실행되기를 바랐다면, 처리가 불가능
  • 또한 불필요한 load가 많아져 성능 저하 야기


Sol B(권장) : 재귀적 해결

const reduce = curry((f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  } else {
    iter = iter[Symbol.iterator]();
  }
  return 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;
  } (acc);
});
  • return에 유명함수를 작성해 while문을 이동
  • 유명함수 : 함수를 값으로 다루면서 이름을 짓는 것
  • recur 함수는 acc를 인자로 받아 Promise라면 비동기적으로 처리하고, 아니라면 바로 return
  • 동기적으로 동작하는 함수는 하나의 call stack에서 작동하므로 성능 저하 회피 가능


Sol B+ : 첫 값이 Promise인 경우도 해결

처음 실행될 때 acc가 Promise라면 Promise가 풀린, 즉 평가된 값으로 들어가야 함

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

const reduce = curry((f, acc, iter) => {
  ...
  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;
  });
});
  • a 인자가 Promise면 기다려서 값을 받아 f 함수에 처리한 값, 아니라면 바로 처리한 값을 반환하는 go1 함수
  • 이를 reduce의 return 유명함수에 씌워주어 처음으로 전달되는 acc에 대한 동기/비동기 처리


Sol B++ : go 함수 합성 과정에서 rejected 되는 경우

go(Promise.resoleve(1),
  a => a + 10,
  a => Promise.reject('error'),
  a => a + 1000,
  a => a + 10000,
  log).catch(a => console.log(a));
  • reject 반환 이후 코드는 실행되지 않는다.
  • go 함수 외부에서 catch문을 작성해 return 값이 rejected Promise인 경우 console에 이를 출력하도록 작성
  • Promise를 값으로 다루며 안전한 비동기 함수 합성 처리 가능


Promise.then의 중요한 규칙

then 메서드를 통해 결과를 도출했을 때의 값이 반드시 Promise인 것은 아니다!

Promise.resolve(Promise.resolve(Promise.resolve(1))).then(log);
  • Promise chain이 연속적으로 중첩되어 있는 경우에도, 외부의 단 한 번의 then으로 안에 있는 결과를 볼 수 있다.
  • 어디든지 내가 원하는 지점에서 해당하는 결과를 받아볼 수가 있다는 것
  • JS에서 언어-개발자-개발자 간의 소통하는 데에 있어서 중요한 법칙

댓글남기기