본문 바로가기

함수형 프로그래밍

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

비동기 다루기

JS에서 비동기 동시성 프로그래밍을 하는 방법은 두가지이다.

1. 콜백
2. 프로미스 , async , await

function add10(a, callback) {
  setTimeout(() => callback(a+10), 100);
}
//전달받은 콜백함수에 100ms 있다가 a + 10 을 해서 넣어주는 함수

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

add20(5)
  .then(add20)
  .then(add20)
  .then(add20)
  .then(add20);

 add10과 add20의 차이는 add20은 프로미스를 리턴한다는 것이다. 이는 중첩해서 사용할 경우에 큰 차이를 보인다. add10은 콜백함수를 받아 실행하기 때문에 콜백지옥에 빠지지만 add20은 프로미스를 리턴하므로 그렇지 않다.

프로미스가 콜백과 가장 다른점은 비동기 상황을 일급값으로 다룬다는 점이다.
프로미스는 이 프로미스라는 클래스를 통해 대기, 성공, 실패 중 하나의 상태를 지닐 수 있는 인스턴스를 반환하며 이러한 것을 일급값으로 다루는 것이다. 위의 코드를 다시 살펴보면 add20은 비동기 상황에 대한 것을 값으로 만들어서 리턴하고 있다. 

다시말해 add10은 실행하고 난 결과를 받아서 출력해보면 undefined가 나온다. 이를 가지고는 어떤 일도 할 수 없지만 add20은 비동기 상황에 대한 프로미스라는 값을 반환하여 나중에 원하는 순간에 값으로써 다뤄질 수 있는 일급이라는 것이다.

 

값으로써 Promise 활용

const delay100 = a => new Promise(resolve => setTimeout(() => resolve(a), 100));
const go1 = (a,f) => a instanceof Promise ? a.then(a => { console.log('go1 내부 ',a); return f(a) }) : f(a);
const add5 = a => a+5;

let n1 = 10;
let n2 = delay100(10);

let res1 = go1(n1, add5);
let res2 = go1(n2, add5);

console.log(res1, ' & ', res2);
console.log('' ,go1(go1(delay100(10), add5), console.log));

n1 같은 경우에는 즉시 10으로 평가되는 값인 반면에 n2는 100ms 뒤에 10으로 평가될, 비동기 상황이 프로미스라는 값으로 다루어지게 된다. 그래서 res1의 결과 또한 즉시평가되는 값이라 콘솔에 15라는 값으로 출력이 되고 n2는 프로미스가 완료됐을 경우에 실행될 then 함수를 달아서 나오게 된다. 따라서 res2도 여전히 "비동기적 상황"을 값으로 다루고 있으므로 콘솔에 Promise { <pending> } 이라고 값이 출력되는 것이다.

이후에 상황은 다음과 같다.
1. 콜스택이 모두 비워지면 delay100에서 반환한 프로미스의 resolve 함수가 마이크로 태스크 큐에 들어온다.
2. 이벤트 루프는 마이크로 태스크큐에서 이 resolve 함수를 콜스택에 가져와서 실행한다.
3. 이때 setTimeout이라는 web api가 호출되고 그 결과로 100ms가 지나 task queue에 setTimeout의 콜백함수가 들어온다.
4. 이벤트 루프는 해당 콜백함수를 콜스택으로 옮겨서 실행한다. 이때 resolve 함수가 호출되며 마이크로태스크큐에 go1에서 then 내의 콜백함수 a.then(a => { console.log('go1 내부 ',a); return f(a) } 가 들어온다.
5. setTimeout의 콜백함수 로직을 모두 실행한 후 이벤트루프는 마이크로 태스크 큐로 이동하여 4. 의 콜백함수를 콜스택으로 가져온다. resolve(a) 가 setTimeout 내부에서 호출되었으므로 a의 값인 10이 전달된 채로 해당 함수가 실행되게 된다. 따라서 go1 내부 10 이라는 로그가 출력되는 것이다.

 

모나드와 Kleisli

모나드란 함수를 안전하게 합성할 수 있게 하기 위해 나온 개념이다. js에서는 비동기상황을 안전하게 합성하는 것이 프로미스이다. 모나드 관점에서 이 프로미스를 분석해보자

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

f(g(1))
f(g())

위의 함수 합성은 안전하지 않은 합성이다. 함수 합성이 정상적으로 일어나기 위해서는 올바른 인자가 넘어가야하는데 위의 코드에서는 어떤 인자가 넘어왔는지에 대해서는 신경을 쓰지 않은채 함수 합성이 일어나기 때문이다. 모나드는 어떤 값이 들어올지 모르는 상황에서 안전하게 함수합성을 하기위한 것이다. 그렇다면 어떻게 해야할까

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

바로 배열을 활용하면 된다. 배열 내부에 아무 값이 없으면 실행이 되지 않을테니 말이다. 이처럼 js에서 모나드는 위와같이 배열이라는 박스안에다가 값을 넣어서 함수 합성을 진행한다.

인자들을 배열에 담고 Array.prototype.map으로 g와 f를 호출함으로써 배열안에 값이 있으면 실행될 것이고 값이 없다면 실행되지않는 비교적 안전한 함수 합성이 일어나게 될 것이다. (이 부분에서 타입스크립트가 왜 중요한지를 좀 알 것 같았다 ㅋㅋㅋㅋ 값이 있다고 해도 타입에 대한 검사가 없기때문에..)

그렇다면 프로미스와 위의 코드를 비교해보고 모나드의 관점에서 생각해보자

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

코드의 모양도, 동작하는 방식도 비슷할 것이다. 위에서 봤던 코드는 배열안에 값들이 g 함수에 넘어가서 실행이 되고 g 함수가 끝나면 뒤에 붙은 map을 통해 f 함수가 실행된다. 프로미스는 여기에 비동기 개념이 추가되었다. 언제 들어올진 모르지만 값이 들어오게 된다면, 뒤에 있는 then 함수들이 연쇄적으로 동일한 방식을 따라 실행될 것이다.

모나드 관점에서 프로미스는 언제 실행될 지 모르는 상황에서도 함수를 적절한 시점에 평가하고 합성시키기 위한 도구인 것이다. 그렇다면 Kleisli 관점에서 프로미스는 어떨까

먼저 Kleisli는 오류가 있을 수 있는 상황에서 안전하게 함수를 합성하기 위한 것이다. 들어오는 인자에 대해서 검사를 하는 과정이 추가된 것이라 할 수 있겠다. f(g(x)) 를 했을때 만약 g 내부에서 에러가 일어나게 되면 f를 합성했더라도 결과는 합성하지 않았을 때와 동일하게, 마치 합성하지 않은 것처럼 동작해야 하는것이 Kleisli 관점에서의 합성이다.

const getUserId = id => find(u => u.id == id, userss);
const f = ({name}) => name;
const g = getUserId;
const fg = id => f(g(id));

// 위의 코드를 Kleisli 관점에서 안전한 함수 합성이 가능하도록 수정해보자
const getUserId = id =>
 find(u => u.id == id, userss) || Promise.reject('없음');
const f = ({name}) => name;
const g = getUserId;
const fg = id => Promise.resolve(id).then(g).then(f).catch(a => a);

아래의 코드는 만약 찾는 대상이 없다면 Promise.reject()를 반환한다. 따라서 g 에서 Promise.reject가 반환된다면 f도 마찬가지로 Promise.reject를 반환하며 f(g(x)) = g(x) 를 만족할 것이다. Promise를 활용하여 올바른 인자가 들어오지 않았을 경우를 처리해서 f(g(x)) = g(x)를 성립시키는 것이다.

 

관련글

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