본문 바로가기

Web[웹]/ES6+ 함수형 언어

[JS: ES6+] 비동기: 동시성 프로그래밍 (1)

 

#1 Promise

 

끔찍한 callback에서 벗어나 promise 적용하기

 

먼저 callback과 promise를 통해 짜여진 두 코드를 보자.

 

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

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

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

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

 

일단 가장 먼저 보이는 명확하게 보이는 차이는 promise에는 return 해준다는 것이다. 이 두 함수는 연속적으로 실행함에 있어 차이가 있다. 연속적으로 실행하는 코드를 타이핑하기엔 callback이 promise에 비해 많이 번거로운 것을 아래 코드를 통해 알 수 있을 것이다. 

 

 

/* callback */
add10(5, res => {
   add10(res, res => {
      add10(res, res => {
         console.log(res); //35
      });
   });
});

/* promise */
add20(5)
   .then(add20)
   .then(add20)
   .then(console.log); //65

 

이처럼 promise를 활용하면, 훨씬 코드 사용이 간편하다.

 

그러나 가장 큰 차이는 promise에는 상태가 존재한다. promise가 호출되면 대기상태가 되며, 이행상태가 되면 then()을 이용해

처리 결과 값을 받을 수 있다.

 


 

Promise의 3가지 상태를 알아보자.

 

  • Pending(대기): 비동기 처리 로직이 아직 미완료인 상태
  • Fulfilled(이행): 비동기 처리가 완료되어 promise가 결과 값을 반환해준 상태
  • Rejected(실패): 비동기 처리가 실패하거나 오류가 발생한 상태

 

Pending 상태

 

아래 처럼 메서드를 호출하면 Pending 상태가 된다.

new Promise();

 

이 후 new Promise() 메서드를 호출 시 콜백 함수의 인자로, resolve, reject에 접근할 수 있다.

new Promise(function (resolve, reject) {
   // ...
});

 

Fulfilled 상태

 

다음 콜백 함수의 인자 resolve를 실행하면 Fulfilled상태가 된다.

new Promise(function (resolve, reject) {
   resolve();
});

 

이 후 이행 상태가 되면 then()을 이용해 처리 결과 값을 받을 수 있다.

 

 

Rejected 상태

 

reject인자로 reject()를 실행하면 Rejected상태가 된다. 그리고 실패 상태가 되면, 실패한 처리 결과 값을 catch()로 받을 수 있다.

 


 

그럼 이제 다시 아래코드를 통해 callback과 promise 차이를 한번 더 보자.

 

/* callback */
var a = add10(5, res => {
   add10(res, res => {
      add10(res, res => {
         console.log(res);
      });
   });
});
console.log(a); // undefined

/* promise */
var b = add20(5)
   .then(add20)
   .then(add20)
   .then(console.log);
console.log(b); // Promise {<pending>}

 

위와 같이 각각 a, b 변수에 저장후 아래를 실행 해보자.

 

/* callback의 경우 */
add10(5, _ => _); // undefined
/* <더 이상 어떠한 일도 할 수 없다> */


/* promise의 경우 */
add20(5, _ => _); // Promise {<pending>} 

var c = add20(5, _ => _);
console.log(c) // Promise {<resolved>: 25}

var d = c.then(a => a - 5);
console.log(d) // Promise {<resolved>: 20}
d.then(console.log); // 20
/* <이 후 계속해서 원하는 일을 다룰 수 있음 > */

 

이처럼 promise는 callback과는 다르게 상태값으로 연속적으로 실행할 수 있다.

 


값으로서의 Promise 활용

 

일급을 활용해서 값으로서의 Promise를 다뤄 볼 것이다. 먼저 아래와 같은 함수가 있다고 가정하자.

 

const go1 = (a, f) => f(a);
const add5 = a => a + 5;
console.log(go1(10, add5)); // 15

 

위 코드가 잘 동작하기 위해선 'f'라는 함수가 동기적으로 동작하는 함수여야 하고, a라는 값 역시 동기적으로 값을 알 수 있어야 한다. 다시말해서 비동기 상황이 아닌 즉, Promise가 아닌 값이 들어와야 위 함수에 값을 정상적 잘 적용할 수 있을 것이다.

 

 

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

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

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

 

위 코드에서 Promise는 정상적으로 동작하지 않았음을 볼 수 있다. Promise가 일급이라는 성질을 이용해서 위 코드가 잘 동작 할 수 있도록 만들어 볼 것이다.

 

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;

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

var r2 = go1(delay100(10), add5);
r2.then(console.log); // Promise{<pending>}

 

instanceof 구문을 통해 go1에게 전달된 첫번째 인자가 promise라면, then을 한 후에 해당하는 값에 함수의 적용하는 방식으로 정상동작하게끔 표현할 수 있다. 위 두코드가 완전 똑같이 동작하도록 작성할 수도 있다.

 

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

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

 


 

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

 

이 챕터에선 함수합성을 안전하게 하는 관점에서 살펴 볼 것 이다.

 

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

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

[1].map(g).map(f).forEach(r => console.log(r)); // 4
[].map(g).map(f).forEach(r => console.log(r)); /* 아무 동작 X */

 

위 코드에서 일반적인 함수 합성을 봤을때, 인자값으로 null값을 넣으면 NaN이 반환되지만, 모나드를 활용하면 null값일 경우 아무 값도 반환되지 않으므로, 좀 더 안전한 합성을 이룰 수 있는 것을 볼 수 있다.

 

Promise도 위에서의 모나드와 유사한데, 아래를 보면 null값을 넣으면 NaN을 반환되는 것을 볼 수 있다.

 

Promise.resolve(2).then(g).then(f).then(r => console.log(r)); // 9
Promise.resolve().then(g).then(f).then(r => console.log(r)); // NaN

 

정리하자면, Promise는 인자값이 있고 없고를 따지는 것이 아니라, 비동기 상황속에서 안전하게 합성을 이룰 수 있는 가를 다루는 것이다. 아래를 보고 마지막으로 이해해보자.

 

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

 

다시말해서, Promise는 합성 관점에서 보았을때 얼마만큼 딜레이가 필요한 상황에서도 함수를 적절한 시점에 평가해서 합성시키기 위한 도구로서 바라 볼 수 있다.

 


Kleisli Composition 관점에서의 Promise

 

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

 

아래와 같은 코드가 있다고 보자.

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

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

 

위 코드 까지는 항상 오류 없는 동일한 결과를 나타낼 것이다.

 

console.log(fg(2) == fg(2)); // true

 

하지만, 외부에서 user의 상태가 변화되었다고 가정해보자.

 

/* 변화 전 */
const r = fg(2);
console.log(r); // bb


/* 변화 후 */
users.pop();
users.pop();

const r2 = fg(2);
console.log(r2); /* error발생 */

 

위와 같이 외부에 어떠한 변화가 일어난다면 상황에 따라 에러가 일어 날 수 있으므로, 합성 하면서 에러가 나지 않게 하는 것이 kleisli composition이라고 한다.

 

이를 구현하기 위해, 함수 합성을 Promise를 통해 하며, 값을 찾았을때 그 값이 없으면, Promise.reject()을 반환하면 된다.

 

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);
fg(2).then(console.log); // bb

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

fg(2).then(console.log); /* 콘솔 출력 x : Promise {<rejected>: "없어요!"} */

 

위와같이 값이 엉뚱한 결과를 받지 않고 log값도 출력되지 않으므로, 안전한 합성을 이룰 수 있다.

 

이제 이 값을 catch를 통해 다음과 같은 값을 뽑아 낼 수 있을 것이다.

 

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에서 비동기 제어

 

go, pipe, reduce 상황에서 Promise를 제어해볼 것이다.

 

아래 코드를 보자.

 

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

 

위와 같은 상황에서는 "[Object Promise]1000" 라고 뜨며, 비정상적으로 코드가 실행되지 않을 것이다. 

그럼, 앞에서 사용했던 go, pipe, reduce 함수를 보자.

 

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

const pipe = (f, ...fs) => (...as) => go(f(...as), ...fs);

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

 

pipe는 go를, go는 reduce를 사용하고 있기 때문에, reduce만 고치면 이 문제를 해결 할 수 있다.

 

reduce 함수에서 수정해야 되는 부분을 보자.

 

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); /* 이부분을 Promise 값을 기다려서 만들어지는 값으로 변환해야 함 */
  }
  return 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 함수에서 정상적으로 "1111"값을 출력할 것이다.

 

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

 

하지만, 여전히 불안한 상태의 코드이다. 왜냐하면, Promise 구문이 끝나고 다음 Promise 구문이 아닌 "a => a+ 1000" 구문에서는 계속해서 Promise 체인에다가 함수를 합성하기 때문이다.  만일 위 코드를 즉시 하나의 콜스택에서 실행하길 바랬다면, 코드를 수정해야 한다.

 

다음과 같이 go1함수를 추가해서 수정해보자.

 

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

 

위와같이 코드가 구성되었다면, Promise가 go함수의 제일 첫 인자로와도 오류없이 잘 실행된다.

 

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

 


 

Promise.then의 중요한 규칙

 

then 메서드를 통해 결과를 꺼냈을때의 값이 반드시 Promise가 아니라는 규칙이다.

 

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

 

위와같이 Promise가 중첩되어 선언되어 있어도, 한번의 then으로 안에있는 결과값을 얻을 수 있다. 다시말해서, Promise 체인이 연속적으로 대기가 걸려있어도, 내가 원하는 곳에서 한번의 then으로 해당하는 결과를 얻을 수 있다는 말이다.

 

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

 

위와 같이 연속적으로 resolve를 한다고 하더라도, 한번의 then으로 값을 얻을 수 있다.