본문 바로가기

함수형 프로그래밍

JS 함수형 프로그래밍 - 비동기, Promise [2]

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

이해하기 많이 어려웠던 코드... 이벤트 루프의 동작을 고려하며 분석해보자.
우선 go 함수와 reduce함수를 통해 promise도 다룰 수 있도록 만들 수 있다. reduce코드를 조금만 수정하면 된다.

go(1, 
  a => a+ 2,
  a=> Promise.resolve(a + 100), //Promise 등장
  a => a+ 20,
  a => a+ 200,
  (a) => console.log('go 결과', a));


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

acc가 Promise 인지를 확인하여 Promise 일 경우엔 그에 맞는 로직으로 실행되게 하면된다. 하지만 위의 코드는 조금 비효율 적일 수 있다. 왜냐하면 위의 go 인자에서 Promise가 등장한 이후로는 전부 Promise 값들이 전달되며 이후에 처리될때 동일한 콜스택에서 처리되지 않고 Microtask Queue를 왔다갔다하며 처리하기 때문이다. 그럼 어떻게 해야할까?

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) {
      let a = cur.value;
      acc = f(acc, a);
      if (acc instanceof Promise) {
        return acc.then(recur);
      } 
    }
    return acc;
  });
})

이처럼 재귀 방식으로 해결할 수 있다. 로그를 찍으며 하나하나 이해해보자

const reduce = curry((f, acc, iter)=> {
  if(!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  } else {
    iter = iter[Symbol.iterator]();
  }
  console.log("유명함수 실행", f, acc)
  return go1(acc, function recur(acc) {
    let cur;
    while(!(cur = iter.next()).done) {
      let a = cur.value;
      console.log("-------")
      acc = f(acc, a);
      console.log("while 문 내부 acc : ", acc)
      if (acc instanceof Promise) {
        console.log("Promise acc : ", acc)
        return acc.then(recur);
      } 
    }
    console.log("acc 반환")
    return acc;
  });
})

 

 

 

go의 첫번째 인자가 1이므로 로그1에 console.log(유명함수 실행, f, acc) 에서 acc값이 1로 찍힌다.

아래 go1 함수는 인자를 두개 넘기며 즉시 실행되는 코드이다. 그래서 while 내부에 로그들이 이어서 출력된다. 그러다가 Pomise를 만나게 되면 acc.then(recur) 를 리턴하면서 함수가 종료된다. 그리고 코드의 맨 마지막줄에 console.log("콜스택 종료")를 추가하여 2 로그를 찍는다. 이후의 코드는 promise의 then이 호출되며 microtask queue로 부터 가져온 작업임을 알 수 있다. 여기서 위의 비효율적이라고 했던 코드와 다른것은 microtask queue로부터 가져와서 남은 로직이 모두 한번에 콜스택에서 처리된다는 것이다. 한번 확인해보자

콜스택 종료 라는 로그 이후에는 acc.then(recure) 라고 했으므로 이벤트루프가 microtask queue로부터 가져온 recur 함수가 실행될 것이다. a의 값은 Promise의 resolve 에서 넘어온 103 (즉시평가값) 이므로 이후의 go 인자로 들어간 함수들이 실행되고 마지막인자는 console.log 이므로 3 로그를 출력하며 마지막에 acc가 반환된다. (console.log의 리턴값은 없으므로 undefined라고 찍힌다.)

콜스택이 한번 종료됐는데 어떻게 iter 가 이어서 실행되는거지?? ⇒ js 에서는 변수들이 위치하는 힙 영역은 콜스택과 별개의 영역이기 때문이다. 인줄 알았으나 for of 문은 루프가 중간에 끊기면 이터레이터를 강제 리턴해버린다고 한다. 근데 for of 문으로 바꿔 실행해도 여전히 iter가 이어진다. 바벨로 테스트 해봤는데 강제리턴 코드는 보이지 않았다. 무엇을 놓친걸까... 힙영역에 위치하기 때문이라는 생각에 대한 예제코드를 짜봤다.

let iterTest = [1,2,3,4,5,6]
let iterTestIterator = iterTest[Symbol.iterator]();

let respro = new Promise(resolve => setTimeout(() => {
  console.log(iterTestIterator.next());
  console.log(iterTestIterator.next());
  resolve(1)}, 100))
respro.then(console.log);

console.log(iterTestIterator.next());
console.log(iterTestIterator.next());
console.log(iterTestIterator.next());

console.log('=========');

조잡한 예제지만 스택과 힙에 대한 궁금증은 확실히 풀리는 예제라 생각한다. ====== 가 출력된 이후로는 마이크로 태스크큐에서 콜스택으로 가져오며 실행하는 내용이다. next 함수를 호출시 이어지면서 잘 호출되는 것을 볼 수 있었다.

 

지연성 함수들 리팩토링

이제 L 함수들과 map, take 등의 함수들도 프로미스를 다룰 수 있도록 바꿔보자. L.map과 map은 이전에 구현했던 go1 함수를 사용하면 쉽게 바꿀 수 있다. take는 원래 코드가 아래와 같이 res 배열에 a를 푸쉬할때 프로미스든 아니든 그냥 푸쉬해주는 것을 확인할 수 있다.

const take = curry((l, iter) => {
  let res = [];
  iter = iter[Symbol.iterator]();
  let cur;
  while (!(cur = iter.next()).done) {
    const a = cur.value;
    res.push(a); //이 부분이 문제
    if (res.length == l) return res;
  }
  return res;
});

따라서 다음과 같이 코드를 수정해줘야한다.

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((result) => {
          res.push(result);
	      if (res.length == l) return res;
          return recur();
        })
      res.push(a);
      if (res.length == l) return res;
    }
    return res;
  }();
});

이렇게 만약 프로미스일 경우에는 a.then을 실행하도록 리턴함으로써 프로미스가 실행되고 난 이후에 res 배열에 푸쉬하도록 한다. 만약 길이가 l보다 작다면 종료하며 recur 함수를 실행해서 재귀적으로 실행되도록 한다. (저번에 들었을땐 이 부분에서 멘탈이 나갔는데 이벤트 루프의 흐름을 공부하고 오니 슬슬 이해가 되기 시작한다.. ㅎ)

이어서 L.filter와 take함수도 프로미스를 다룰 수 있도록 바꿔보자

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

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((result) => {
                        res.push(result);
                        return res.length == l ? res : recur();
                      })
                .catch(e => e == nop ? recur() : Promise.reject(e));
      res.push(a);
      if (res.length == l) return res;
    }
    return res;
  }();
});

L.filter은 b를 확인해서 b가 프로미스일 경우에는 b.then() 을 하도록 바꿔졌다. 함수형 프로그래밍으로 여러 함수를 파이프처럼 연결하면서 프로미스를 다루는 것을 보니 프로미스라는 개념이 많이 익숙해진듯 하다. go1 함수도 그렇고 변경한 filter, take 함수도 그렇고 전달 받은 값이 프로미스라면 "이 프로미스가 언제 처리될지는 모르겠지만 처리되면 이렇게 해줄게~" 하는 then 함수를 붙인다. 프로미스가 아무리 중첩이 되더라도 then을 붙이면 비동기상황이 처리된 후의 값이 나온다. 그래서 then의 역할은 프로미스는 비동기라는 상황을 값으로써 다룰 수 있도록 해주는 도구이며 비동기상황은 언제 처리될 지 모르는 것이니 처리된 이후에 이런 동작을 해주겠다라는 것이다.

                         if(b instanceof Promise) yield b.then(b => b ? a : Promise.reject(nop));
이번 강의는 위에 코드가 뭐지?? 하면서 이해하는데 꽤나 오래 걸렸다. b 가 참이면 b를 반환하지 왜 a를 반환하지? 하며 뇌정지 ㅎ filter가 무슨함수였는지를 순간 까먹었다 ㅋㅋㅋㅋㅋㅋㅋ f(a)가 참이면 a를 반환하니 당연하지.. 그 다음부턴 프로미스의 동작을 어느정도 이해하니 흐름이 어느정도 잡힌 듯 하다

마지막으로 reduce는 이렇게 바꿔볼 수 있겠다.

go([2, 1, 3,4],
  L.map(a=> Promise.resolve(a*a)),
  L.filter(a => Promise.resolve(a % 2)),
  reduce(add),
  console.log)
//위의 go 함수가 동작하려면 

const reduceF = (acc, a, f) => {
  return a instanceof 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]();
  }
  return go1(acc, function recur(acc) {
    let cur;
    while (!(cur = iter.next()).done) {
      console.log('여기', cur.value);
      acc = reduceF(acc, cur.value, f);
      if (acc instanceof Promise) return acc.then(recur);
    }
    return acc;
  });
})

acc 에 할당될 값을 a가 프로미스인지 아닌지를 확인하고 프로미스일 경우엔 then 을 호출하고 아닐경우엔 acc 와 a를 f 에 넣은 값을 반환하도록 한다. 그리고 여기서 문제가 하나 발생한다. acc 에 대해서는 따로 검사를 하고 있지 않기 때문에 filter로부터 넘어온 첫번째 인자가 만약 L.filter에서 발생시킨 reject라면 에러가 발생할 것이다. 그래서 아래와 같이 리팩토링한다.

const head = (iter) => go1(take(1, iter), ([h]) => { console.log(h); return h; });

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

 

마무리

이제 병렬처리 부분만 올리면 끝...!

 

관련글

https://www.inflearn.com/course/functional-es6 강의를 들으며 정리한 글입니다.