함수형 프로그래밍 - 비동기 / 동시성

MyeonghoonNam·2021년 8월 12일
1

함수형 프로그래밍

목록 보기
5/10

함수형 프로그래밍 시리즈 내용으로 계속 이어서 내용이 진행되므로 처음 부터 포스팅을 확인해주세요.


Callback

콜백 함수 ?

함수의 인자값으로 함수를 받아 해당 함수에 인자값을 전달하면서 로직을 수행하는 방법이라 할 수 있습니다.

function add10(a, callback){
	setTimeout(()=>callback(a+10), 100);
}
add10(5, console.log); // 15
//console.log("hi") // 이 코드의 주석을 해제한다면 비동기적 수행으로 인해 hi가 먼저 출력될 것 이다.

Promise

Promise ?

callback과 가장 큰 차이점중 하나는 Promise객체를 반환한다는 것입니다.

return 당시에는 Promise타입 객체를 반환하며 .then 메소드를 통해 내부의 값을 꺼내어 사용합니다.

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

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

Callback과 Promise의 차이

callback은 함수를 인자로 받아 수행하는 반면, Promise는 Promise객체를 반환합니다. 로직이 수행되는 주체가 다릅니다.

Code Depth가 callback의 경우 계속해서 증가하는 반면 Promise 의 경우 1 Depth에서 더이상 깊어지지 않습니다.

위에서 간략하게 callback과 Promise의 차이점을 나열했지만 사실 가장 큰 차이점은 Promise는 일급값으로 비동기상황을 다룬다는 점입니다.

Promise는 Promise로 만들어진 인스턴스를 반환하여 대기,성공,실패를 다루는 일급 값으로 이뤄져있는데, 이는 로직을 끝내는 것을 코드나 컨텍스트로만 다루는게 아니라 대기중이라는 값을 생성한다는 점에서 Callback과 가장 큰 차이점입니다.

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


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


const cb = add10(5, res => {
  add10(res, res => {
    add10(res, res => {
      console.log(res);
    })
  })
});

const p = add20(5)
  .then(add20)
  .then(add20)
  .then(console.log)

console.log(cb); // undefined, 추후 콜백에 의한 값이 출력된다.
console.log(p); // promise 객체 반환, 추후 값 제어를 통한 값 호출

callback의 결과를 담은 cb와 다르게 Promise의 반환객체를 담은 p의 출력값을 보면 Promise 객체를 보여주는데, 이는 callback에서는 반환값에 중점을 두는게아닌 코드적인 상황(setTimeout)이나 Context(callback)만 중점으로 둔다는 것입니다.

Promise는 즉시 Promise객체르 반환된다는 특징이 있는데, 이는 callback과는 다르게 Context에 함수의 로직에 이어지는 로직을 then을 통해 추가적으로 이어갈 수 있다는 것입니다.

즉, Promise의 경우 비동기로 이뤄진 상황에 대해서 값으로 다룬다는 것이고 일급값이라는 의미가 되며 다른 곳에서 해당 일급값을 재사용할 수 있다는 의미에서 연속성을 가질 수 있다는 점입니다.


값으로서의 Promise활용

Promise가 비동기 상황을 일급의 상황을 가지고 있다는점을 활용해서 여러가지를 시도할 수 있습니다.

일급 값으로 다룰 수 있다는 의미는 비동기 상황을 값으로 다뤄서 해당 값이 비동기상황인지 상태를 검사할 수 있다는 것입니다.

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

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

a라는 인자값을 f에 들어온 인자값 함수에 전달해 로직을 수행하는 go1을 만들었습니다.

이때 go1이라는 함수가 정상적으로 동작하기 위해서는
인자값(a, f)들이 모두 동기적으로 값을 알 수 있는 값이어야 합니다.

즉, Promise객체가 아닐 때만 정상적으로 동작을 한다는 것입니다.

만일 a라는 인자값이 비동기적으로 100ms 후에 나타나는 값이라면 어떻게 동작할까요?

const delay100 = a => new Promise(resolve => setTimeout(()=>resolve(a),100));

console.log(go1(delay100(10), add5));//[object Promise]5

결과는 정상적이지 못합니다. Promise객체를 일급 값으로써 받는 경우에도 정상적으로 동작하게 하기 위해서는 어떻게 구현을 해야 할까요?

const go1 = (a, f) =>  a instanceof Promise ? a.then(f): f(a);
const add5 = a => a + 5;
const delay100 = a => new Promise(resolve => setTimeout(()=>resolve(a),1000));

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

const n2 = delay100(10);
console.log(go1(go1(n2, add5), console.log)); //Promise {<pending>}, 15

go1 함수에서 a인자값이 Promise인지 평가해서 상황에 맞는 로직(a.then or f(a))을 수행하도록 합니다.

결과값이 나온 후에 출력을하면 Promise 객체가 출력됩니다. 이는 해당 일급값을 아직 이어서 추가적인 작업을 지속적으로 수행할 수 있다는 의미입니다.


함수합성 관점 Promise와 모나드

Promise는 비동기상황에서 함수합성을 안전하게 하기 위한 모나드라고 할 수 있습니다.

자바스크립트는 동적타입언어이자 스크립트 언어이기 때문에 타입에 대해 엄격하지 않기에 모나드라던지 대수구조의 타입이 잘 묻어나지 않는 경향이 있기 때문에 자바스크립트에서는 모나드를 직접적으로 사용하거나 모나드 개념을 이용한 사용자 정의 객체를 만들면서 구현을 하지는 않습니다.

하지만, 함수형 프로그래밍이나 함수합성에서 모나드에 개념을 알고 있으면 좀 더 높은 퀄리티의 코드를 작성할 수 있습니다.

모나드

모나드는 일종의 박스이고 박스안에 값이 들어있다고 볼 수 있습니다. (ex: [1])

그리고 이 값을 통해서 함수합성들을 안전하게 수행해나가는 것이라 볼 수 있습니다.

우선, 인자값을 받아 1을 더해주는 g라는 함수와 인자값을 제곱해 반환하는 f라는 함수를 만들어봅시다.

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

함수를 만들었다면 함수합성을 통해 인자값에 1을 더한 뒤 제곱한 값을 출력해봅니다.

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

정상적으로 인자값 1에 1을 더해 2로만든뒤 제곱값인 4가 출력됩니다.

그런데 만약 인자값이 없는 상태로 함수합성이 되어 로직이 수행된다면 어떻게 될까요?

console.log(f(g())); //NaN

정상적으로 값이 호출되지않거나 에러가 발생합니다. 최종적으로 수행되는 log가 비정상적인 동작을 했다는 것입니다.

이는 함수합성에 사용되는 인자값이 유의미한 값이 아니라면 문제가 발생한다는 것인데, 실무에서는 인자값으로 어떤 값이 올 지 모르고 빈값이 올 가능성도 충분합니다.

즉, 해당 함수합성(f(g(x)))은 안전하지 않은 함수합성이라 할 수 있습니다.

그럼 이처럼 인자값이 어떤 값이 올 지 모르는 불안한 상황에서 함수합성을 어떻게 안전하게 할 지 고려하며 나온 것이 모나드입니다.

모나드는 위에서 말했듯 박스를 가지고있고 그 내부에 실제 효과나 연산에 필요한 재료들을 가지고 있고 이를 통해 함수합성을 합니다.

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

반환값이 Array 타입인걸 볼 수 있는데 이는 필요한 값은 아닙니다. Array라는 값은 개발자가 값을 다룰 때 사용하는 도구이지, 최종 유효값이라 볼 수는 없습니다. 그렇기 때문에 Array안에 있는 최종 값을 꺼낼 필요가 있습니다.

[1].map(g).map(f).forEach(r => console.log(r)); // 4

forEach를 통해 map(f)의 결과값인 Array타입의 [4]를 실제 효과(값)를 만들어냅니다. 이는 log(f(g(1))); 와 동일합니다.

그럼 이 방법(모나드)은 유효하지않거나 없는 값에 대해서 어떤 결과가 나올까요 ?

[].map(g).map(f).forEach(r=>console.log(r)); // 결과 없음

안에 아무런 값이 없어도 어떤 잘못된 출력이나 에러가 발생하지 않습니다. 실제 최종 결과값을 도출해내는 함수(forEach)에 도착하기전에 안전하게 종료됩니다.

Promise

Promise는 비동기적으로 일어나는 상황에 대해 안전하게 함수합성을 하기위한 도구입니다.

좀 더 정확히는 잘못된 값보다는 지연되어 나오는 값들(비동기적)에 대해서 안전하게 핸들링 하기 위한 도구라고 할 수 있습니다.

Promise.resolve(1).then(g).then(f).then(console.log); // 4

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

Promise.resolve().then(g).then(f).then(console.log); // NaN

Kleisli Composition 관점 Promise

Promise는 Kleisli Composition을 지원하는 도구라 볼 수 있는데, Kleisli Composition 혹은 Kleisli Arrow라고 불리는
함수합성 방법은 오류가 있을수 있는 상황에서의 함수함성을 안전하게 하는 하나의 규칙이라 볼 수 있습니다.

수학적인 프로그래밍을 하게 되면 함수를 합성하게 되고, 수학적이란 것은 항상 정확하고 안전한 변수를 통해서 함수합성및 평가와 결과도출까지 된다는 것인데, 실무에서는 외부와의 상황이나 여러 상황으로 인해 함수합성이 원하는대로 되지 않을 가능성이 있습니다.

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);
const f = ({name}) => name;
const g = getuserById;
const fg = id => f(g(id));

const r = fg(2);
console.log(r); // bb
// fg(2);를 호출하면 g(2)가 실행되어 users에서 id가 2인 obj를 꺼내전달하고 ({id:2, name:'bb}) f함수에서는 name을 구조분해하여 추출후 반환합니다. 

여기서 찾고자하는 id가 현재 users에 없는 id(ex: 4,5,6..n)이거나 외부 상황에 의해 users의 내용이 변한다면 어떻게 될까요?

const r2 = fg(5); //Uncaught TypeError: Cannot destructure property 'name' of 'undefined' as it is undefined.

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

const r3 = fg(2)//Uncaught TypeError: Cannot destructure property 'name' of 'undefined' as it is undefined.

잘못 된 값 혹은 외부의 변화에 따라 상황에따라 에러가 발생할 수 있습니다. 이런 상황에서 문제가 발생하지 않도록 하는 것을 Kleisli Composition라고 합니다.

위에서 모나드에 대해 얘기하며 했던 방법으로 then을 통해 함수합성을 시도합니다.

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

하지만 외부 환경에 의해서 변경이 생기면 여전히 에러가 발생합니다.

이는 getuserById에서 찾으려는 users의 항목이 외부변화에 의해 없어졌기 때문인데 이를 해결하기 위해 검색결과가 없을 경우에 대한 반환값을 만들어 줍니다.

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("없어요!"); // users에서 인자값으로 받은 id와 동일한 user를 찾는 함수 getuserById
const f = ({name}) => name; // name을 구조분해하여 얻어 반환하는 f 
const g = getuserById; // getuserById를 값으로 취급하는 g
const fg = id => Promise.resolve(id).then(g).then(f).catch(a => a); // f와 g를 합성해 users에서 특정 id의 name을 추출해 반환하는 fg

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

fg(2).then(console.log);

getuserById함수에서는 이제 검색결과가 없을 경우 Promise객체를 반환하게되고 fg에서는 해당객체의 reject가 호출되면 catch부분에서 받아서 출력을 함으로써 에러는 발생하지 않게 됩니다.


go, pipe, reduce 비동기 제어

비동기를 다루는 Promise객체를 이용해 go, pipe, reduce함수들에서도 비동기를 값으로 다루는 성질을 이용해 프로미스와 같이 비동기상황에 놓여져도 잘 대응하는 함수를 만들 수 있습니다. 또는 Kleisli처럼 중간에 reject가 발생했을 경우에도 대응하는 방법을 적용할 수 있습니다.

go(1,
    a=>a+10,
    a=>a+100,
    a=>a+1000,
    console.log
); //1111

위와같은 인자값에 10,100,1000을 더한 뒤 출력해주는 함수가 있다고 합니다. 근데 여기서 3Line 의 a+100이 Promise타입으로 바뀌면 어떻게 될까요.

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

올바르지 않은 값이 출력되는걸 볼 수 있습니다. 이는 go 함수내부에서 호출하는 reduce부분에서 발생한 문제 때문입니다.

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;
    }
    for (const a of iter) {
        acc = f(acc, a);
    }
    return acc;
});

go를 통해 reduce에 함수가 전해져서 실행이되고있는게 reduce인데 Promise가 인자로 전달되면 반복문 부분에서 f(Promise, a)가 되버려서 다음 루프가 도는 경우 에러가 발생합니다.

const reduce = curry((f, acc, iter) => {
  if (!iter) {
      iter = acc[Symbol.iterator]();
      acc = iter.next().value;
  }
  for (const a of iter) {
      //acc = f(acc, a);
      acc = acc instanceof Promise ? acc.then(acc=>f(acc,a)): f(acc, a);
  }
  return acc;
}); // 1111

코드는 동작합니다. 하지만, 아직 고려해야할 부분이 있는데, 함수가 중간에 Promise를 만나게되면 그 뒤로는 계속해서 Promise Chaning이 걸리게 됩니다. 그리고 이런 함수합성이 많으면 불필요한 분기를 타게 되면서 성능저하도 일어나게 됩니다.

그럼 Promise 가 된 다음 실행 함수에서는 또 동기적으로 즉시평가를 하고싶으면 어떻게 해야할까요?

const reduce = curry((f, acc, iter) => {
  if (!iter) {
      iter = acc[Symbol.iterator]();
      acc = iter.next().value;
  }
  return function recur(acc) {
      for (const a of iter) {
          acc = f(acc, a);
          if(acc instanceof Promise) return acc.then(recur);
      }
      return acc;
  }(acc);
});

go(1,
  a=>a+10,
  a=>Promise.resolve(a+100),
  a=>a+1000,
  console.log
); //1111

재귀를 이용한 방법을 사용합니다. 유명함수 를 이용해 return 값에 recur함수를 선언해서 내부에서 우선 인자값 함수를 실행 후 해당 값이 Promise이면 acc.then(recure)로 재귀호출을 하면, Promise의 내용의 실제 값이 인자로 전달됩니다. 이렇게 되면 go내부의 [1, a⇒a+1`] 이 하나의 callstack, [a⇒a+1000, log ]가 하나의 callstack으로 동작해 성능향상을 바랄 수 있습니다.

아직까지 go에서 사용되는 첫 번째 인자값이 Promise인 경우에는 아직 에러가 발생합니다.

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

위와같이 첫 번째 인자부터 Promise이라면 reduce의 유명함수 즉시실행부분에서 이미 첫 번째 인자를 Promise로 전달하기 때문에 제대로 된 결과가 나오지 않습니다. 이런 경우에는 첫 번째 인자값을 받더라도 즉시 전달하기전 Promise인지 아닌지 판단해서 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;
    }
    return go1(acc, function recur(acc) {
        for (const a of iter) {
            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,
    console.log
); //1111

reduce 함수내에서 유명함수를 그대로 즉시호출하는게 아닌 go1이라는 Promise 대응함수를 만들어 첫 번째 인자값과 실행될 유명함수 recur를 인자값으로 전달하면 첫 인자값이 Promise인지 아닌지에 따라 로직이 수행되기 때문에 첫 번째 인자가 Promise일지라도 정상동작합니다.

만약 중간의 인자값함수가 Promise.reject('error')라면 ?

reject가 발생하여 실패했을 때에 대한 catch문을 작성해서 보완해줍니다.

go(Promise.resolve(1),
    a=>a+10,
    a=>Promise.reject('error'),
    a=>log('----'),
    a=>a+1000,
    console.log
).catch(a=>console.log(a));//error~

이제 함수합성에서 초기값 혹은 중간에서 Promise를 받거나 수행할 경우에도 정상적으로 동작을 할 수 있게 됩니다.

이제 go, pipe, reduce같은 함수에서는 Promise를 만났을때 좀 더 다형적으로 대응이 가능하고 비동기객체(Promise)를 값으로 다루면서 Promise의 기능인 then으로 이루어진 로직수행만 하는게 아닌 원하는 시점에서 원하는 동작을 할 수 있는 고차함수를 만드는 등의 응용이 가능해집니다.


promise.then의 중요한 규칙

Promise에는 아무리 Promise가 중첩되서 사용도더라도 원하는 곳에서 then을 통해 값을 꺼내 쓸 수 있다는 규칙이 있습니다.

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

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

그냥 보기에는 then에 들어가는 인자값이 2depth의 Promise객체일 것 같지만 실제로는 아무리 Promise가 중첩되있더라도 그 내부에있는 결과값이 도출되어 전달됩니다.


지연 평가와 Promise 적용 - L.map, map, take

이터러블을 다루는 많은 함수들은 map, filter, reduce를 뼈대로 응용해서 함수합성등을 통해 (ex: L.map + takeAll, flatMap...) 응용 함수들을 만들었습니다.

L.map, map, take는 기본적으로 동기적으로 돌아가는 상황에서만 정상적인 동작을 보장했습니다.

reduce, pipe 등 비동기상황에서도 동작하는 이런 함수들처럼 L.map, map, take함수들도 비동기적 상황에서도 정상동작하도록 코드를 리팩토링 해보겠습니다.

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);
    }
});

// 리펙토링 후

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

//실행결과
//0: Promise {<fulfilled>: 11}
//1: Promise {<fulfilled>: 12}

이제 프로미스에 map의 인자값으로 받은 함수는 적용되어 11,12과 되었습니다. 이제 Promise안에 들어있는 값을 꺼내도록 take 함수를 리펙토링 합시다.

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;
    }();
});

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

기존 take 함수에서 currentValue(cur.value)이 Promise일 경우 내부의 값을 then을 통해 res Array에 넣어준 뒤 재귀적으로 다시 유명함수 recur()를 호출해 문제를 해결하고 있습니다.


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

filter에서 지연평과와 비동기성을 함께 지원하려면 Kleisli Composition을 적용해야 합니다.

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

현재 위 코드를 실행하면 정상적으로 동작을 하지 않습니다. 그 이유는 filter로 넘어오는 값이 Promise이기 때문입니다.

그럼 a⇒a%2는 결국 Promise % 2 가 되는 것이기에 정상적으로 동작할 수 없죠.

이 부분을 해결하기 위해서는 L.filter를 살펴보고 수정해야 할 필요가 있습니다.


L.filter = curry(function* (f, iter) {
    for (const a of iter) {
        const b = go1(a, f);
        if (b) yield a;
    }
});

// 리펙토링 후

go([1, 2, 3, 4, 5, 6],
  L.filter(a => a % 2),
  take(2),
  console.log
); //[1, 3]

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

첫 번째 go 로직은 정상적으로 동작을 하는 것을 확인할 수 있습니다. 하지만, 두 번째 비동기적인 go는 정상적이지 못한 값이 나오는것을 확인할 수 있습니다.

그 이유는 L.filter부분에서 const b = go1(a, f);에서 b를 출력해보면 Promise인 것을 확인할 수 있고, if문에서 Promise인 b는 true로 판단되어 정상적으로 동작하지 않는 것입니다.

그렇기에 L.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;
    }
});

b가 Promise 인지 검사를 한 뒤 promise이라면 then을 통해 b내부의 값을 풀어주는데 b가 true이면 a를 resolve 해주는데 a가 Promise이라 할지라도 then내부에서 다른곳으로 전달될때는 풀어져서 전달되기 때문에 괜찮습니다.

그리고 b가 false일 경우에는 아무 행동도 하지않도록 해야하는데, yield 를 통해 generated되서 전달되기 때문에 다음 함수의 인자값으로 들어가지 않도록 해야합니다.

그렇게 하기 위해서 Kleisli Composittion을 활용합니다. 위의 코드에서 b의 값이 없거나 false인 경우 reject를 해주는데, 그냥 reject를 하면 아무것도 안하길 바라는 reject인지 에러발생 reject인지 알 수 없기 때문에 플래그가 될 수 있는 값을 넣어줘야 하는데, nop이라는 구분자를 만들어서 사용하도록 합니다.

추가적으로 take에서도 reject에 대한 처리를 해줄 필요가 있습니다.

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())
                  .catch(e=> e === nop ? recur() : Promise.reject(e)); //reject로 nop이 올 경우 다음 코드를 평가한다.
          res.push(a);
          if (res.length === l) return res;
      }
      return res;
  }();
}); 

// 리펙토링 후

go([1, 2, 3, 4, 5, 6],
  L.filter(a => a % 2),
  take(2),
  console.log
); // [1, 3]

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

이처럼 take에서 reject를 통해 catch가 잡혔을 때 해당 paramater(e)가 nop일 경우 무시하고 다음 함수를 평가하도록 합니다.


reduce에서 nop 지원

take 가 아닌 reduce에서도 nop를 지원하도록 해서 지연성과 Promise를 지원하도록 만들어봅시다.

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

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

  while (!(cur = iter.next()).done) {
    acc = reduceF(acc, cur.value, f);
  
    if (acc instanceof Promise) return acc.then(recur);
  }

  return acc;
});

reduceF라는 함수를 만들어 a가 promise일 경우 then을 통해 a값을 꺼내어 f(acc,a)를 수행하고 reject(nop)인 경우 acc를 그대로 반환합니다.

promise가 아닌 경우에는 그대로의 f(acc,a)를 수행해 반환합니다.

위 함수중 reduce에서 iter가 없을경우 acc에서 iterator를 꺼내 iter에 넣어주고 첫 번째값을 다시 acc에 넣어주는데 이 역시 모듈화가 가능합니다.

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

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

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;
  });
});

// 리펙토링 후

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

head라는 함수를 만들어 인자값으로 받은 iter에서 첫번째 인자값을 take로 가져옵니다. 그 뒤 take는 배열값을 반환하기에 구조분해로 내부값을 꺼내 반환합니다.

head라는 함수로 reduce 함수에서 iter가 없는 경우 재귀적으로 인자를 만들어 다시 호출해 정상동작하게 합니다.


지연 평가 + Promise의 효율성

지연평가 및 즉시평가에서 동기&비동기 모든상황에 맞춰서 map, filter, reduce가 동작하도록 만들어봤습니다.

Promise와 같은 비동기적이기에 비용의 소모가 큰 작업이 로직안에 들어가게 되면, 전체적인 성능부분에서 많은 딜레이 생길 수 있습니다.

  • 비동기상황이 함수대기열에 등록되있는 경우 - 즉시평가
go([1, 2, 3, 4, 5, 6, 7, 8],
  map(a => {
      console.log(a);
      return new Promise(resolve => setTimeout(() => resolve(a * a), 1000))
  }),
  filter(a => {
      console.log(a);
      return new Promise(resolve => setTimeout(() => resolve(a % 2), 1000))
  }),
  take(2),
  console.log
)

동기상황을 즉시평가로 가져오는 경우, take로 2개를 가져오던 5개를가져오던 혹은 전부 가져오던 소요시간은 같습니다.

  • 비동기상황이 함수대기열에 등록되어있는 경우 - 지연평가
go([1, 2, 3, 4, 5, 6, 7, 8],
  L.map(a => {
      console.log(a);
      return new Promise(resolve => setTimeout(() => resolve(a * a), 1000))
  }),
  L.filter(a => {
      console.log(a);
      return new Promise(resolve => setTimeout(() => resolve(a % 2), 1000))
  }),
  take(2),
  console.log
)

위와같이 지연평가를 하는 L.map, L.filter를 사용하게된다면 필요한 값을 다 구했다면, 그 다음 내용들은 비용소모가 큰 map이나 filter에 들어가 수행되지도 않기때문에 즉시평가와 비교해 성능상 이점을 얻을 수 있습니다.


지연된 함수열 병렬 평가 - C.reduce, C.take (1)

자바스크립트가 수행되는 환경은 브라우저나 Nodejs 가 대표적인데 보통 비동기 IO로 동작합니다. 비동기 IO는 일반적으로 싱글스레드를 기반으로 해서 IO를 동기적으로 처리하기보단 비동기적으로 처리를 해서 하나의 쓰레드에서도 CPU를 점유하는 것들을 효율적으로 IO작업을 하는 최신 트렌드 중 하나입니다.

자바스크립트가 이렇게 싱글스레드로 돌아가기 때문에 병렬 프로그래밍을 할 일이 없다고 생각하지만, 자바스크립트에서 어떤 로직을 제어하는것을 싱글스레드로 비동기적으로 제어하는 것일 뿐 병렬적인 프로그래밍은 충분이 사용할 수 있습니다.(ex: postgreSQL에 query를 날리는 것을 병렬적으로 처리해 동시에 보내 결과를 받아오는 것)

이처럼 Nodejs가 실제로 로직을 직접 수행하는 것이아닌 네트워크나 기타 IO로 작업을 보내놓고 대기하는 시점을 다루는 것을 Nodejs가 하는 것이기 때문에, 특정 처리에 대한 요청들을 동시에 보내서 하나의 로직으로 귀결시키는 로직은 개발자가 자바스크립트에서도 할 수 있고 잘 다뤄야 할 필요도 있습니다. 이와같은 동시성을 처리하는 병렬 프로그래밍에 대해 알아봅니다.

const delay1000 = a => new Promise(resolve => {
  console.log('hi');
  console.log('');
  setTimeout(() => resolve(a), 1000)
});

console.time("Immediately time:");
go([1, 2, 3, 4, 5],
  L.map(a => delay1000(a * a)),
  L.filter(a => a % 2),
  reduce(add),
  console.log,
  ()=>console.timeEnd("Immediately time:")
); //35 Immediately time: 2.547s

L.map에서는 2.547s정도의 시간이 걸리는 로직이 수행되고 있습니다.

map이나 filter가 즉시평가라면 평가의 방향은 가로입니다. map에서 [1,2,3,4,5]배열의 전부 평가되어 다음으로 넘어가고 filter에서도 해당 배열들을 전부 필터링한 뒤 reduce에서 add를 통해 합쳐집니다.

그렇기에 즉시평가로 돌아간다면 병렬 프로그래밍을 적용하기 적절치 않습니다. 하지만, 위와 같이 지연평가를 사용하게된다면 평가순서는 가로에서 세로로 변경됩니다. 처음 1이 map에서 평가된 후 filter에서 조건검사 후 reduce에서 축약됩니다.

그 다음 배열의 값 '2'가 map, filter를 거치게 되죠. 이처럼 순서가 가로에서 세로로 바뀌면 각각의 값은 독립적으로 수행이 되게되고 이는 병렬 프로그래밍을 사용할 수 있다는 의미가 됩니다.

위 예제에서 reduce는 하나씩 값을 기다려서 값을 더해주고 있습니다. 그렇다면 값들을 전부 보내서 reduce를 수행한다면 어떨까요? 부하는 좀 생길 수 있지만, 더 빠르게 결과를 만들 수 있을 것입니다.

위와 같이 병렬 프로그래밍이 적용 가능한 상황에서는 reduce를 그에 맞게 만들어봅니다.

let C = {};
C.reduce = curry((f, acc, iter) => iter ? reduce(f, acc, [...iter]):reduce(f, [...acc]));

const delay1000 = a => new Promise(resolve => {
    console.log('hi');
    setTimeout(() => resolve(a), 1000)
});

console.time("Conquer time:");
go([1, 2, 3, 4, 5],
    L.map(a => delay1000(a * a)),
    L.filter(a => a % 2),
    C.reduce(add),
    console.log,
		()=>console.timeEnd("Conquer time:")
);//3535 Conquer time:: 1.010s

일반적인 직렬 로직 수행보다 빨라진 성능을 확인할 수 있습니다.


지연된 함수열 병렬 평가 - C.reduce, C.take (2)

go([1, 2, 3, 4, 5, 6],
  L.map(a => delay1000(a * a)),
  L.filter(a => delay1000(a % 2)),
  L.map(a => delay1000(a * a)),
  C.reduce(add),
  console.log
);

위 코드를 실행했을때, catch되지 않은 부분이 있다고 나오지만, 결과는 정상적으로 나온다. catch되지 않는 부분이 있지만, 이는 자바스크립트 특성상 콜스택에 reject 관련 코드가 존재하게 되어 출력문이 형성 되는데 이후에 잘 catch를 해서 정리를 해줄 것이기 때문에, 비동기적으로 해당하는 에러를 캐치해줄 것이란것을 명시해줘야 한다.

function noop() {}
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])));
   
// 리펙토링 후

go([1, 2, 3, 4, 5, 6],
   L.map(a => delay1000(a * a)),
   L.filter(a => delay1000(a % 2)),
   L.map(a => delay1000(a * a)),
   C.take(2),
   reduce(add),
   console.log);

위와 같이 해주면, 정상적으로 동작하면서, catch에러를 품지않는다. C.take 역시 병렬적으로 실행되기 때문에, 기존 take보다 빠르게 실행된다.


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

특정 함수 라인에서만 병렬적으로 평가하는 C.map, C.filter를 만들어 보자.

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

C.takeAll = C.take(Infinity);

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

// 리펙토링 후

C.map(a => delay1000(a * a), [1, 2, 3, 4]).then(console.log); // [1, 4, 9, 16]
C.filter(a => delay1000(a % 2), [1, 2, 3, 4]).then(console.log); // [1, 3]

즉시병렬평가가 이루어진 실행결과를 확인할 수 있다.


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

상황에 맞는 전략을 짜서 다양한 선언적 로직을 구현할 수 있다. 아래의 코드에서 각 메소드에 즉시, 지연 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],
  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(4),
  reduce(add),
  console.log,
  _ => console.timeEnd(''))

마치며

이번 포스팅에서는 함수형 프로그래밍의 Promise(비동기)와 동시성에 관하여 직접 사용자 정의 함수를 구현하는 방법을 지난 포스팅에 이어서 공부하였다.

마지막으로 callback, Promise에 이어 async/await 문법에 대해서도 이해하여 보자.

profile
꾸준히 성장하는 개발자를 목표로 합니다.

0개의 댓글