비동기 - 동시성 프로그래밍

ZeroJun·2022년 5월 18일
0

함수형프로그래밍

목록 보기
5/5

Call Back과 Promise

const log = console.log;

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

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

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

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

콜백과 프로미스를 통한 비동기 구현 함수를 보면 위와 같이 indent의 차이와 복잡성의 차이가 있다. 흔히 promise는 콜백의 저런 복잡성(콜백지옥)을 해결주는 도구라고 인식된다.

하지만 함수형 프로그래밍에서는 다른 중요한 점이 있다.

프로미스가 특별한 점은 then메서드를 통해 결과를 꺼내본다는 것이 아닌 비동기상황을 1급값으로 다룰 수 있다는 점이다.

프로미스는 프로미스라는 클래스를 통해서 만들어진 인스턴스를 반환한다. 그 값은 대기와 성공과 실패를 다루는 1급 값으로 이루어져 있다.

대기, 성공, 실패 상황을 코드나 컨택스트로 다루는 것이 아니라 그 상황을 나타내는 값을 만든다는 점이 콜백과 가장 큰 차이를 가지는 것이다.

add10은 비동기 상황을 다루는 것이 코드(setTimeout이 일어난다는 코드적인 상황과 끝났을 때 어떤함수가 실행되는지에 관한 컨택스트)로만 표현되고 있다. add20은 비동기 상황에 대한 값을 만들어서 리턴을 하고 있다는 점이 가장 중요한 점이다.

const log = console.log;

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

const a = add10(5, (res) => {
  add10(res, (res) => {
    add10(res, (res) => {
      log(res); // 35
    });
  });
});

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

const b = add20(5).then(add20).then(add20).then(log); // 65

log(a, b); // undefined, Promise{ <pending> }

이렇게 값으로 다룰 수 있다는 것은 일급이라는 이야기고, 일급이라는 이야기는 변수할당, 함수로 전달, 전달된 값을 통해 어떤 일들을 계속 이어나갈 수 있다는 것이다.

값으로서의 Promise활용

// 일급 활용
const go1 = (a, f) => f(a);
const add5 = (a) => a + 5;

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

위 코드는 인자로 들어오는 것은 동기적으로 바로 알수 있는 값이어야한다.

그렇다면 인자로 받은 10이라는 값이 어느정도 시간이 걸린 후 알 수 있는 값이라면 어떻게 될까?

const delay100 = (a) =>
  new Promise((resolve) => setTimeout(() => resolve(a), 100));
const go1 = (a, f) => f(a);
const add5 = (a) => a + 5;

log(go1(delay100(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));
// 받은 값이 프로미스일 경우 f를 then으로 넘겨서 실행.
const add5 = (a) => a + 5;

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

const res2 = go1(delay100(10), add5);
log(res2); // Promise{ <pending> }, // promise result :15
res2.then(log); // 15

위처럼 작성하면 프로미스도 받아서 결과를 낼 수 있다.
저렇게 표현할 수 있다는 것은 아래처럼 표현할 수 있다는 것이다.

go1(go1(10, add5), log);
go1(go1(delay100(10), add5), log);

const n1 = 10;
go1(go1(n1, add5), log); // return undefined : 즉시 실행된 최종 결과를 받고 끝

const n2 = delay100(10);
go1(go1(n2, add5), log); // return promise : 프로미스를 계속해서 이어주게 된다.

이처럼 값으로 다루어지는 프로미스는 비동기 코드라도 동기코드와 똑같이 표현할 수 있다.

함수 합성 관점에서의 Promise와 모나드

promise는 비동기 상황에서 함수합성을 안전하게 해주는 모나드라고도 볼 수 있다.

여기서 함수 합성은 수학에서의 함수합성과 같은 말이다.

f.g = f(g(x)) // x를 인자로 받은 g함수의 실행 결과를 f의 인자로 받아 실행

모나드는 본래 복잡한 개념이지만 함수형 프로그래밍을 위해 간략하게 요약하자면 결국 함수합성을 안전하게 하기 위한 도구이다.

모나드는 값을 감싸고 있는 컨테이너 같은 것이다. (array 같은)

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

log(f(g(1))); // 4
log(f(g())); // NaN
// 이 코드는 안전하게 합성이 되었다고 보기 어렵다.
// 이 코드는 특정 범주의 인자가 들어와야 안전하게 합성된다.


log([1].map(g).map(f)); // [4]

[1]
  .map(g)
  .map(f) // 여기까지는 함수 합성
  .forEach((r) => log(r)); // 이 코드는 실제효과를 만들어 낸다.

이렇게 함수합성을 했을 때 이점은 무엇일까?

  []
  .map(g)
  .map(f) // 여기까지는 함수 합성, 
  .forEach((r) => log(r)); // array안에 값이 없어서 아예 실행되지 않는다.

이처럼 어떤 박스안에 값이 있는지 없는지에 따라서 함수합성이 안전하게 이루어진다.

그럼 프로미스는 어떤 함수합성을 하는 값일까?

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)), 1000)
  .then(g)
  .then(f)
  .then((r) => log(r));

모나드의 정의에 너무 집착하지 말자. 모나드는 함수 합성을 안전하게 하기위한 도구이며 대표적으로 array promise등이 있고, 이것들은 서로 다른 상황에서 함수 합성을 안전하게 하도록 만드는 모나드다.

Kleisli Composition 관점에서의 Promise

kleisli composition은 오류가 있을 수 있는 상황에서의 함수합성을 안전하게 하는 하나의 규칙이다.

들어오는 인자가 완전히 잘못되는 인자라서 오류가 나오는상황, 정확한 인자가 들어오더라도 어떤 함수가 의존하고 있는 외부의 상태에 의해 결과를 제대로 전달할 수 없는 상황일 때 에러가 나는 것을 해결하기 위한 함수합성이다.

// Kleisli Composition
// f . g
// f(g(x)) = f(g(x)) // 이 결과는 항상 같다.
// 그러나 실무에서는 g가 바라보는 값이 다른 값으로 변해있거나 없어져서
// 오류가 날 수 있다. 그럴 경우 이 합성은 성립하지 않게되어
// 순수한 함수형 프로그래밍을 할 수 없다.
// 그런 상황에서도 특정한 규칙을 만들어 함수를 안전하게 합성하고
// 좀 더 수학적으로 바라볼 수 있게 만드는 함수합성이 Kleisli Composition이다.

// Kleisli Composition 예시
// f.g
// f(g(x)) = g(x)
// 만일 g에서 에러가 나는 경우 f.g와 g가
// 같은 결과를 만들어 내도록 하는 것이다.

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

// id를 통해 객체를 찾은 후 해당 id의 name을 찾는 함수 만들기
const getUserById = (id) => users.find((u) => u.id === id);

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

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

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

하지만 현실 세계에서는 users에 pop이 일어나는 등 users가 변하는 상황이 발생할 수 있다.

const r = fg(3);
log(r); // cc

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

const r2 = fg(3); // 에러 발생
log(r2);

이런 상황에서도 합성이 잘 되도록 하는 것이 kleisli composition이다.

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

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

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

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

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

fg(3).then(log);

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

fg(3).then(log); // Promise{<rejected>: "없어요!"}
g(3).then(log); // Promise{<rejected>: "없어요!"}
// fg의 결과와 g의 결과가 같다.

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

기존의 go함수는 중간에 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);
  }
  return acc;
});

const go = (...args) => reduce((a, f) => f(a), args);

go(
  1,
  (a) => a + 10,
  (a) => Promise.resolve(a + 100), 
  (a) => a + 1000,
  log // [object Promise]1000
); 

go함수의 실행 제어권은 reduce가 가지고 있기 때문에 reduce만 고쳐주면 위의 문제를 해결할 수 있다.

// reduce 수정 1
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 = acc instanceof Promise ? acc.then(acc => f(acc,a)) : f(acc, a);
  }
  return acc;
});

위 코드는 동작을 하기는 하나 중간에 프로미스를 만나면 그 이후의 함수를 프로미스 체인으로 합성하게 되어 연속적으로 비동기가 일어나게된다.

또한 불필요한 로드가 생기기 때문에 성능저하가 일어난다.

// reduce 수정 2
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)
});

위 코드는 중간에 프로미스를 만날 경우 재귀적으로 실행시키는데, 이 때 인자를 프로미스가 아닌 일반 값으로 넘겨준 후 실행시켜주기 때문에 나머지 함수들은 프로미스 체인이 아닌 동기적으로 합성이 되게 된다.

하지만 이 코드도 문제가 하나 있는데, 첫번째 go함수가 promise로 들어왔을 경우다.

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,
  log // 1111
);

go(
  Promise.resolve(1),
  (a) => a + 10,
  (a) => Promise.reject("error~~"),
  // 에러를 뿜은 뒤의 코드들은 실행되지 않는다. (정상적 동작)
  (a) => console.log("---"),
  (a) => a + 1000,
  log //"error~~"
);

go1함수를 통해 첫번째 함수가 promise일 경우 a를 풀어서 다음으로 넘겨주고 아닐 경우 그대로 실행시키게 된다.

promise.then의 중요한 규칙

then메서드를 통해서 결과를 꺼냈을 때의 값은 절대로 promise가 아니다.

Promise.resolve((Promise.resolve(1)).then(log); // 1

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

위와 같은 코드를 보면 프로미스가 중첩되어 있어 then의 결과는 중첩이 한꺼풀 벗겨진 Promise로 예상되지만 그렇지 않고 중첩된 프로미스 속 값이 꺼내진다. 즉, 아무리 중첩되어 있어도 절대 promise가 꺼내지지 않고 언제나 값이 꺼내진다.

지연 평가 + Promise - L.map, map, take

go(
  [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)],
  L.map((a) => a + 1),
  take(3),
  log
);

위처럼 인자가 Promise인 경우 기존의 L.map과 take를 Promise를 처리하기 위해서 수정해야한다.

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

L.map = curry(function* (f, iter) {
  for (const a of iter) {
    yield go1(a, f); // Promise를 처리하는 go1에 위임한다.
  }
});
export const take = curry((l, 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), res.length) == l ? res : recur()));
      res.push(a);
      if (res.length == l) return res;
    }
    return res;
  })();
});

이렇게 구성하면 로직 중 Promise가 껴도 정상적으로 동작한다.

go(
  [1, 2, 3],
  L.map(a => Promise.resolve(a + 10)),
  take(2),
  log);

go(
  [Promise.resolve(1), Promise.resolve(2),Promise.resolve(3)],
  L.map(a => a + 10),
  take(2),
  log);

go(
  [Promise.resolve(1), Promise.resolve(2),Promise.resolve(3)],
  L.map(a => Promise.resolve(a + 10)),
  take(2),
  log);

go(
  [1, 2, 3],
  L.map(a => a + 10),
  take(2),
  log);

Kleisli Composition - L.filter, filter, nop, take

go(
  [1, 2, 3, 4, 5],
  L.map((a) => Promise.resolve(a * a)),
  L.filter((a) => a % 2),
  take(2),
  log
);

위의 코드는 filter에 값이 promise로 들어오기 때문에 빈 배열로 출력된다. 제대로 동작시키기 위해서 filter도 promise를 처리할 수 있도록 해야한다.

const nop = Symbol("nop");

L.filter = curry(function* (f, iter) {
  for (const a of iter) {
    const b = go1(a, f);
    if (b instanceof Promise)
      yield b.then((b) => (b ? a : Promise.reject(nop)));
    else if (b) yield a;
  }
});

filter에 promise가 들어올 경우 우선 go1을 통해 Promise를 처리해준다. 그럼 들어온 인자가 true혹false로 fulfilled되어 Promise로 b에 할당 될 것이다. 그 후 then을 통해 b가 true인 경우 현재 인자를 그대로 넘긴다.
false인 경우 reject를 하는데 nop이라는 심볼을 통해 프로미스의 에러와 구분하여 거를인자를 넘긴다.

reduce에서 nop 지원

const reduceF = (acc, a, f) =>
  a instanceof Promise ?
    a.then(a => f(acc, a), e => e === nop ? acc : Promise.reject(e)) :
    f(acc, a);

const head = iter => go1(take(1, iter), ([h]) => h);

export const reduce = curry((f, acc, iter) => {
  if (!iter) return reduce(f, head(iter = acc[Symbol.iterator]()), iter);
  
  iter = iter[Symbol.iterator]();
  return go1(acc, function recur(acc) {
    let cur;
    while (!(cur = iter.next()).done) {
      acc = reduceF(acc, cur.value, f);
      if (acc instanceof Promise) return acc.then(recur);
    }
    return acc;
  });
});


const add = (a, b) => a + b;
go(
  [1, 2, 3, 4, 5],
  L.map((a) => Promise.resolve(a * a)),
  L.filter((a) => a % 2),
  reduce(add),
  log // 35
);

이를 통해 reduce를 통해서도 비동기를 제어할 수 있다.

지연평가 + Promise의 효율성

go(
  [1, 2, 3, 4, 5, 6, 7, 8, 9],
  L.map(el => new Promise(resolve => setTimeout(() => resolve(el * el), 1000))),
  L.filter((a) => a % 2),
  take(3),
  log // 35
);

위 코드의 map의 비동기 부분 console을 찍어보면 꼭 필요할 때만 접근하는 것을 확인할 수 있다. 이는 많은 연산을 할 때 보다 효율적으로 동작한다는 것을 의미한다.

지연된 함수열을 병렬적으로 평가하기 - C.r

nodejs에서 쿼리 등을 병렬적으로 출발시켜서 결과를 한번에 얻어오거나 이미지 처리 등을 할 때, 실제로 nodejs가 이 작업들을 처리하는 것이 아닌 네트워크나 기타 io로 작업을 보내놓고 대기를 하며 시점을 다루는 일을 nodejs가 하는 것이다. 따라서 어떤 처리를 동시에 출발시키고 하나의 로직으로 귀결시키는 경우도 있는데, 이 때 병렬적으로 평가하는 방법을 사용할 수 있다.

export const C = {};
C.reduce = curry((f, acc, iter) => iter ?
  reduce(f, acc, [...iter]) :
  reduce(f, [...acc]));
let cnt = 0;
const delay500 = a => new Promise(resolve => {
  log("do!" + cnt);
  cnt++
  setTimeout(() => resolve(a), 1000);
});

const add = (a, b) => a + b;
console.time('');
go(
  [1, 2, 3, 4, 5],
  L.map(el => delay500(el * el)),
  L.filter((a) => a % 2),
  reduce(add),
  log,
  _ => console.timeEnd('')
); // 1에대한 연산 수행, 2에대한 연산 수행 ...

콘솔창을 보면 do1부터 5까지 1초마다 실행된 후 결과가 도출된다.

let cnt = 0;
const delay500 = a => new Promise(resolve => {
  log("do!" + cnt);
  cnt++
  setTimeout(() => resolve(a), 1000);
});

const add = (a, b) => a + b;
console.time('');
go(
  [1, 2, 3, 4, 5],
  L.map(el => delay500(el * el)),
  L.filter((a) => a % 2),
  C.reduce(add),
  log,
  _ => console.timeEnd('')
); // 1에대한 연산 수행, 2에대한 연산 수행 ...

콘솔창을 보면 1초만에 do1부터 do5까지 모두 실행된 후 결과가 도출된다.

작업이 nodejs에서 일어나는 것이 아닌 작업을 외부로 보내서 병렬적으로 처리하는 상황이고, 해당 상황이 부하가 아닌 효율인 상황이면 reduce에 C를 붙여 효율적인 프로그래밍을 할 수 있다.

비동기 상황에서 코드 실행 중간에 promise reject에 의한 에러를 뿜게 하지 않고, 추후에 catch하도록 하는 방법

function noop() {
};
const catchNoop = arr => (arr.forEach(a => a instanceof Promise ? a.catch(noop) : a), arr);
C.reduce = curry((f, acc, iter) => {
  const iter2 = catchNoop(iter ? [...iter] : [...acc]);
  return iter ?
    reduce(f, acc, iter2) :
    reduce(f, iter2)
});

C.take = curry((l, iter) => take(l, catchNoop([...iter])));

즉시 병렬적으로 평가하기 - C.map, C.filter

C.takeAll = C.take(Infinity);
C.map = curry(pipe(L.map, C.takeAll));
C.filter = curry(pipe(L.filter, C.takeAll));

즉시, 지연, Promise 병렬적 조합하기

기본 조합

const delay500 = (a, name) => new Promise(resolve => {
  console.log(`${name}: ${a}`);
  setTimeout(() => resolve(a), 500);
});

console.time('');
go([1, 2, 3, 4, 5, 6, 7, 8],
  map(a => delay500(a * a, 'map 1')),
  filter(a => delay500(a % 2, 'filter 2')),
  map(a => delay500(a + 1, 'map 3')),
  take(2),
  log,
  _ => console.timeEnd(''));

지연 평가

const delay500 = (a, name) => new Promise(resolve => {
  console.log(`${name}: ${a}`);
  setTimeout(() => resolve(a), 500);
});

console.time('');
go([1, 2, 3, 4, 5, 6, 7, 8],
  L.map(a => delay500(a * a, 'map 1')),
  L.filter(a => delay500(a % 2, 'filter 2')),
  L.map(a => delay500(a + 1, 'map 3')),
  take(2),
  log,
  _ => console.timeEnd(''));

const delay500 = (a, name) => new Promise(resolve => {
  console.log(`${name}: ${a}`);
  setTimeout(() => resolve(a), 500);
});

console.time('');
go([1, 2, 3, 4, 5, 6, 7, 8],
  L.map(a => delay500(a * a, 'map 1')),
  L.filter(a => delay500(a % 2, 'filter 2')),
  L.map(a => delay500(a + 1, 'map 3')),
  C.take(2),
  log,
  _ => console.timeEnd(''));

이런식으로 특정 위치에 C로 병렬성을 부여하여 평가 시점과 병렬적 실행을 원하는대로 조절할 수 있다.

c.reduce, catchnope, c.take 간결하게 표현하기

const catchNoop = ([...arr]) => (arr.forEach(a => a instanceof Promise ? a.catch(noop) : a), arr);

C.reduce = curry((f, acc, iter) => iter ?
  reduce(f, acc, catchNoop(iter)) :
  reduce(f, catchNoop(acc))
);

C.take = curry((l, iter) => take(l, catchNoop(iter)));

0개의 댓글