devthewild

WTF - 3. Maybe or Not

What is the Functional?

  1. Introduction
  2. Algebraic Data Type
  3. Maybe or Not
  4. Monadic Molecule Parser

Maybe

바로 앞에서 언급했듯이 최근에 생겼거나 메이저 업데이트를 한 언어들이라면 대부분 지원하는 Maybe(Optional, Option)라는 타입이 있다. 값을 가지고 있는 Just라는 타입과 값이 없는 Nothing이라는 타입 중 하나가 되는 섬 타입이다. 일단 함수형이니 하는 이야기는 잠시 미뤄두고 간단하게 Maybe를 만들어보자. Maybe의 정의를 간단하게 표현해보자면 다음과 같다.

Haskell
1
data Maybe a = Just a | Nothing
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Maybe {
toString() { throw new Error("Must be implemented."); }
}

class Just extends Maybe {
constructor(v) {
super();
this.value = v;
Object.freeze(this);
}
toString() { return `Just ${this.value.toString()}`; }
}

class Nothing extends Maybe {
constructor(v) {
super();
Object.freeze(this);
}
toString() { return "Nothing"; }
}

const nothing = new Nothing();

정의는 단순하지만 같은 내용을 JavaScript로 구현하면 다소 길어진다. 소스를 보면 알겠지만, Just는 생성될 때만 값을 받을 수 있고 생성된 후에는 값을 변경할 수 없다. 이제 이 Maybe를 어떻게 다룰지에 대해서 생각을 해보자. Maybe 타입을 통해 어떤 연산을 하고 싶을 때 메소드를 추가해서 Maybe를 계속 생산하도록 만들면 편하겠지만, 값이 있다 없다의 속성을 가질 수 있다면 Maybe의 연산 결과를 Maybe라고 유지하고, 값이 없을 때는 계속 값이 없도록 유지하려면 그 결괏값을 보장해줘야한다. 이걸 만족하는 연산들을 생각해보자.

  1. 값을 Maybe로 감싸서 새로운 Maybe를 만들어준다.
  2. Maybe의 값에 그 값을 처리하는 함수를 적용하고 싶다.
  3. 그런데 그 함수가 Maybe의 값일 수도 있다.
  4. 함수의 결괏값 자체가 Maybe라면 어떨까?

1번의 구현은 간단하다.

JavaScript
1
2
///// unit : a -> Maybe a
const unit = (x) => new Just(x)

값만 존재할 때는 두 배로 만들려면 단순히 x * 2를 하면 되지만, Maybe로 감싸져 있으니 바로 적용하기 어렵다. 그러니 2번처럼 값을 처리하는 함수를 적용할 수 있는 기능을 구현해보자.

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
const isNothing = (m) =>
m.constructor.name === "Nothing"

///// fmap : Maybe a, (a -> b) -> Maybe b
const fmap = (m, fn) =>
isNothing(m)
? new Nothing
: new Just(fn(m.value))

const doub = (d) => d * 2
console.log( fmap(unit(1), doub).toString() ); // Just 2
console.log( fmap(nothing, doub).toString() ); // Nothing

Maybe의 값이 함수일 경우에 그 함수를 다른 Maybe의 값에 적용해보자.

JavaScript
1
2
3
4
5
6
7
8
9
///// appl : Maybe (a -> b), Maybe a -> Maybe b
const appl = (mfn, ma) =>
isNothing(mfn) || isNothing(ma)
? new Nothing()
: unit(mfn.value(ma.value))

const mdoub = unit(doub);
console.log( appl(mdoub, nothing).toString() ); // Nothing
console.log( appl(mdoub, unit(1)).toString() ); // Just 2

그런데 모양을 보면 fmap과 비슷해서 fmap을 재사용해서 구현할 수도 있다.

JavaScript
1
2
3
4
5
6
7
const appl2 = (mfn, ma) =>
isNothing(mfn)
? new Nothing()
: fmap(ma, mfn.value)

console.log( appl2(mdoub, nothing).toString() ); // Nothing
console.log( appl2(mdoub, unit(1)).toString() ); // Just 2

이제 마지막으로 함수의 결과 자체가 Maybe일 경우를 생각해보자.

JavaScript
1
2
3
4
5
6
7
8
9
///// bind :  Maybe a, (a -> Maybe b) -> Maybe b
const bind = (ma, fn) =>
isNothing(ma)
? new Nothing()
: fn(ma.value)

const udoub = (d) => unit(doub(d)); // a -> Maybe b
console.log( bind(nothing, udoub).toString() ); // Nothing
console.log( bind(unit(1), udoub).toString() ); // Just 2

지금까지의 구현에서 JavaScript 자체의 복잡한 기능을 사용한 곳은 없다. 구현 자체가 어렵지도 않고 짧아서 여기까지는 다들 이해할 수 있을 것으로 생각한다. 그런데 안에 값을 넣을 수 있는 타입 중에서 개발자들이 항상 사용하고 있으며, 다들 사용법에 대해 아주 잘 알고 있는 타입이 하나 있다. 이제 Maybe를 Array와 비교해보자.

F, A, M with Array

1. Functor

앞에서 구현했던 fmap에서 설명을 돕기 위해 구현 위에 주석으로 타입을 적어놓은 것이 있다. 처음에는 Haskell 식으로 타입을 적었다가 이해하기 편하도록 수정했더니 무슨 언어인지 모를 내용이 되긴 했지만.

JavaScript
1
///// fmap : Maybe a, (a -> b) -> Maybe b

값을 가지고 있는 타입과 값을 변환하는 함수를 받아서 다른 값을 가지고 있는 타입으로 변환해준다. 이걸 이해하기 좋게 조금 수정해보자면,

JavaScript
1
2
3
///// amap : Array a, (a -> b) -> Array b
const amap = (arr, fn) => arr.map(fn);
console.log( amap([1,2,3], (n => String(n))) ); // [ '1', '2', '3' ]

a라는 타입의 값을 가지고 있는 어떤 타입을 ⓐ라고 하고, b의 경우를 ⓑ라고 하면, (a -> b) 함수를 통해 결과적으로 (ⓐ -> ⓑ)를 만족하도록 연산할 수 있는 타입을 Functor라고 부른다. Array에서는 그런 연산을 해주는 map 메소드를 가지고 있다.

2. Applicative Functor

순서대로 fmap 다음에 구현했던 appl을 이야기할 차례다.

JavaScript
1
///// appl : Maybe (a -> b), Maybe a -> Maybe b

applicative라는 표현 그대로 어딘가에 적용할 수 있는 Functor이다. 즉, 함수를 가지고 있는 Functor.

JavaScript
1
2
3
4
5
6
7
8
9
const doub = d => d * 2;
const incr = d => d + 1;

///// Array (a -> b), Array a -> Array b
const appl = (fns, as) => fns.map(fn => as.map(fn))
.reduce((r,b) => r.concat(b), [])
console.log(
appl( [doub, incr], [1, 2, 3] )
); // [ 2, 4, 6, 2, 3, 4 ]

이렇게 (a -> b)를 가지고 있는 Array와 Array a를 통해 Array b를 만들었다. 위에서 말했듯 함수를 가지고 있는 Functor(Array (a -> b))와 다른 Functor(Array a)를 통해 다른 Functor(Array b)를 만들어내는 Applicative Functor를 map을 사용해서 간단히 구현해보았다. 하지만 Applicative Functor 자체를 본 적이 별로 없어서 내가 맞게 이해하고 있는 것인지 잘 모르겠다.

3. Monad

JavaScript
1
///// bind :  Maybe a, (a -> Maybe b) -> Maybe b

JavaScript에서는 lodash같은 라이브러리를 사용하지 않은 사람에게 익숙하지 않은 개념일 수 있지만, 다른 함수형 언어들을 써본 사람이라면 Sequence 종류에서 기본적으로 지원해주는 익숙한 개념이 있다.

JavaScript
1
2
3
4
5
6
7
const flatMap = function(fn) {
return this.map(fn).reduce((r,a) => r.concat(a), [])
};

console.log(
[1,2,3]::flatMap(d => Array(d).fill(d))
); // [ 1, 2, 2, 3, 3, 3 ]

바로 flatMap(간혹 concatMap)이라는 개념인데, (a -> ⓑ) 함수를 통해 (ⓐ -> ⓑ)를 만족하는 함수를 말한다. [1, [2], [[3]]]처럼 깊이가 다른 배열을 1차원으로 합치는 함수를 flatten이라고 표현하는데, flatten + map이 아닐까 싶다. Haskell에서는 Monad라면 Applicative Functor를 만족하고, Applicative Functor라면 Functor를 만족한다. 즉 Monad > Applicative > Functor로 상속하는 구조다. 잠깐 접해본 짧은 생각으로는 독립적인 개념으로 봐도 될 것 같은데(굳이 따지자면 Functor와 Applicative 정도는 has-a로 봐도 될 것 같지만), 내가 놓치고 있는 뭔가가 있는 것 같다.


이렇게 대수형 타입끼리 어떻게 연산하는지의 패턴들에 대해 알아봤는데, 왜 이렇게 복잡한 설명과 패턴을 통해 타입을 유지해야 하는가 싶은 생각이 들 수 있다. 가장 기본이 되는 개념은 간단한 개념이니 이것만 알면 된다! 라는 식의 글을 참 많이 봤는데, 내가 원점에 있을 때 (10, 0)쯤에 있던 글보다 쉽다는 글은 (0, 10)쯤에 있었고 그보다 쉽다고 주장하는 글은 (-10, 0)쯤에 있었다. 방향만 달라질 뿐, 거리는 좁혀지지 않는 느낌이었다. 그래도 그나마 알아들을만 했던 예제는 Railway oriented Programming이었다.

Just에 어떤 연산bind을 할 때 결과는 다시 Maybe가 되어야 하니 Just(그림에서의 Success) 혹은 Nothing(그림에서의 Failure) 둘 중 하나가 된다.

그런 연산이 여러 개 존재할 수 있다.

그때, 앞에서 어떤 처리들이 있었고 어디에서 Nothing으로 갔는지 관계없이 현재 들어온 값을 보고 Just인지 Nothing인지 구분(switch)해주는 하나의 블럭을 만들기만 하면 된다.

한번 Nothing이 되면 그 뒤에 어떤 연산이 오든 관계없이 Nothing으로 계속 유지된다. 앞의 어디에서 Nothing이 되었다는 것에 신경 쓰지 않고 현재의 값만 보고 Just인지 Nothing인지 연결하면 된다. 즉, bind(혹은 flatMap)에서는 현재 값과 앞뒤 타입만 맞추면 입력에서 출력까지 연산이 안전하다고 보장된다.

패턴이라는 것은 약속이고, 약속이라는 것은 그것이 보장된다는 말이다. 즉 일종의 추상화로 블랙박스 모델처럼 그림에서의 스위치만 구현해서 레일을 연결하면 안전하게 연산이 잘 흘러간다. OOP처럼 객체 단위의 추상화가 없으니 타입클래스에서 이런 패턴들이 그 역할을 대신하고, 덕분에 재사용하기 좋고 확장 가능해진다. 그런 것들의 기초가 되기 때문에 사람들이 중요하다고 많이 이야기하는 것이라고 생각한다.


여전히 이게 뭐다라고 정의내려서 설명하기는 어렵지만 이제 A가 B다라는 말에서 그게 맞거나 틀리다는걸 구분할 수는 있는 것 같다. 사실 이 글을 쓰게 된 목적 중 하나는 이거다. 그동안 함수형 언어를 기껏해야 퀴즈 몇개 풀어보는 정도 이외에는 제대로 써본 적이 없다보니 알듯말듯 한 상태가 몇년째 계속되고 있는데, 최근에 Haskell 책 한권을 읽으면서 그 감이 약간 더 구체화된 김에 정리를 해서 더 잡기 위해서다. 물론 조금 어긋난 내용이 있을 수도 있고 아예 잘못된 내용이 있을 수 있어서 언젠가 이 글을 읽고 이불킥할지도 모르겠지만, 이번 기회에 정리하지 않으면 몇년 더 이해할 기회가 오지 않을 것같다는 느낌이 들었다. 그러니 틀린게 있으면 틀린거고, 아니면 좋고. 이제 Monad라는게 뭔지 대충 정리를 해밨으니 이걸로 뭘 할 수 있는지 한번 써먹어보자.

ps, 타입을 유지하기 위한 연산의 패턴들에 대해서 알아봤는데 이런 것들이 타입론 (Type Theory)이나 범주론(Category Theory)에 속한 것이라면, "정수의 덧셈은 정수에 '닫혀있다'"라고 말하는 것처럼 연산 자체의 성질에 대해서 논하는 군론(Group Theory)라는 것이 있고 그 중 모노이드(Monoid)라는 개념을 모나드와 함께 사용하면 더 편하게 사용할 수 있는데 그 부분에 대해서는 지금보다 아는 것이 좀 더 생기면 다뤄보고 싶다.

ps2, 다시 말하지만 ps도 맞는지 확신이 없다.


Reference

Callback에서 Future로(그리고 Functor, Monad)

Translation of "From callback to (Future -> Functor -> Monad)" into Korean, under the same license as the original.

동기

함수형 프로그래밍에서 기본개념은 조합(composition) 이다. 간단히 설명해서, 단순한 것들을 엮어서 더 복잡한 것을 만들 수 있고 그 결과를 다시 엮어서 더 복잡한 것을 만들 수도 있다. 함수의 의미나 리턴값이 무엇인지만 알고 있으면 조합으로 무엇이든 만들어낼 수 있다.

Node.js를 써봤으면 아래와 같은코드를 본 적이 있을 것이다.

1
2
3
4
fs.readFile('...', function (err, data) {
if (err) throw err;
....
});

위의 코드는 전형적인 CPS(continuation-passing style) 함수이다. fs.readFile이라는 CPS 함수는 계속(continuation) 진행될 콜백을 추가 파라미터로 받는다. 이 CPS가 끝나면 호출한 곳에 값을 반환하는게 아니라 계속 함수, 콜백에 계산 결과를 넘겨준다.

나는 콜백을 쓰는걸 꺼리진 않는다. 사실 콜백은 부수효과를 표현하거나 이벤트 알림같은 것을 다룰 때 훌륭하다. 그렇지만 그걸로 흐름을 관리하기 시작하면 함정에 빠진 것이다. 왜냐면 조합할 수 없기 때문에.

자, 생각해보자. "위에 나온 함수의 **표기(denotation)**나 리턴값은 무슨 의미일까?" 답은 undefined이다. undefined라는건 테스트할 때 실제로 undefined인지 확인하는 용도 이외에는 쓸 데가 없다.

콜백 안에서 다른 실행 흐름으로 넘어갈 때 일방통행이라는게 문제다.

물리학에서 유명한 블랙홀처럼 콜백을 생각해보자:

블랙홀은 수학적으로 정의된 지역이다. 강한 인력이 작용하거나, 어떤 티끌이나 전자기적 파장조차 빠져나갈 수 없는

그 중에서도

어떤 빛도 반사하지 않는 등 블랙홀은 많은 부분에서 이상적인 검은 물체처럼 작용한다.

콜백 함수 또한 흐름에서의 어떤 것도 반사하지 못한다.

나중에 첫번째 콜백에 들어갈 때 다른 콜백 스타일 함수를 쓸 수 있는데, 그때는 두번째 흐름을 잃게 되고 다른 구멍에 빠지게 된다. 콜백을 쓰면 쓸 수록 지옥에 빠지게 된다.

그럼 블랙홀에 빠지지 않고 코드를 진행할 수는 없을까?

답은 조합이다. 하지만 조합을 사용하려면 일단 CPS 함수가 어디로도 돌아갈 수 없다는 사실을 알아야하고, 함수로부터 뭔가를 받아와야한다. 그러니 어떻게든 함수가 뭔가를 반환하게 만들어야한다. 어떤 값이 반환될까? 이게 이 글의 동기이다.

이미 자바스크립트에서의 해답을 알고 있을 수 있다. 하지만 계속 이 글을 읽도록, 강하게, 추천한다. 지시적인(즉 함수형) 생각의 힘을 보게 될 것이고, 깔끔하고 간결한 해답을 어떻게 사용할지 보게 될 것이다.

future로 입문

파일 읽기, 네트워크 요청, DOM 이벤트, 이런 함수들의 공통점은 뭘까?

이 함수들은 즉시 완료되지 않는 것들이다. 즉, (보통 함수들을 다루는 식으로는) 현재 프로그램 흐름에서 저 함수들이 완료될 때까지 기다릴 수 없다는 뜻이다. 그래서 _future_를 설명할 것이다.

그래서 특별한 반환 타입, 나중에 결과를 만들어준다고 명시하는 Future를 만들어보자. 요점은 다른 함수들로 넘길 수 있는 1등급 클래스 값을 사용하는 것이다.

Future는 무슨 의미일까? 특정 시간(0이 될 수도 있다) 후에 발생할 것이라고 명시해놓은 값이다. 그 시간은 우리가 x초 후라고 말하는 것처럼 명시적인 시간이 될 수도 있지만, Future 2개가 완료된 후 혹은 Future 하나가 완료된 뒤 다른 Future 완료될 때처럼 상대적인 개념일 수도 있다.

여기서 중요한 점은: Future의 결과는 항상 불변값이다.

즉, 완료 값을 어떤 방법으로든 변경할 수 없다. 이 제약으로 구현 뿐만 아니라 의미론에 대한 추론도 간단해진다.

Future는 일회용의 간단한 상태머신처럼 구현될 수 있다. 이 머신은 대기 로 시작했다가 완료 가 된 후에 멈춘다. 한번 완료되면 계속 완료상태에 고정된다.

내부적으로 Future는 콜백에 여전히 의존하고 있지만, 그 콜백들이 컨트롤 흐름 매커니즘을 방해하지는 않는다. 대신 올바른 목적으로만 사용된다, 이벤트 알림.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Future() {
// 대기중인 구독들을 저장하는 리스트
this.slots = [];
}

// 완료를 알린다
Future.prototype.ready = function(slot) {
if(this.completed) slot(this.value);
else this.slots.push(slot);
}

// 간단한 로그 유틸리티
function logF(f) {
f.ready( v => console.log(v) );
}

Future를 완료시키는 외부 인터페이스로 메소드가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Future.prototype.complete = function(val) {
// 불변성 보장
if(this.completed)
throw "이미 완료된 Future는 완료시킬 수 없다."

this.value = val;
this.completed = true;

// 구독들에게 알림
for(var i=0, len=this.slots.length; i< len; i++) {
this.slots[i](val);
}

// 모두 실행되면 이제 필요없다.
this.slots = null;
}

Future의 가장 간단한 예제로 어떤 값으로 즉시 완료시켜보자. 그 역할을 unit이란 메소드를 만들어보자.

1
2
3
4
5
6
7
8
// unit: Value -> Future<Value>
Future.unit = function(val) {
var fut = new Future();
fut.complete(val);
return fut;
}

logF( Future.unit('hi now') );

코드에 대해 간단히 설명하기 위해 타입 표기(type annotation) 를 사용했다.

unit: Value -> Future<Value>를 풀어보면 1- unit은 함수고, 2- 제네릭 타입 Value를 입력으로 받으며, 3- 제너릭 타입을 가진 Future 인스턴스를 리턴한다. 여기서 타입 정보는 중요하지 않으므로 Value라는 제너릭은 신경쓰지 않아도 된다.

다음 예제는 특정 시간이 지나고 완료되는 값이다.

1
2
3
4
5
6
7
8
9
10
// delay: (Value, Number) -> Future<Value>
Future.delay = function(v, millis) {
var f = new Future();
setTimeout(function() {
f.complete(v);
}, millis);
return f;
}

logF( Future.delay('안녕, 이건 5초 걸린다', 5000) );

delay의 결과는 주어진 값만큼의 시간이 지난 뒤에 완료되는 Future다.

readFile 예제로 돌아가서, 이제 CPS 함수 대신에 Future를 리턴하는 함수를 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var fs = require('fs');

// readFileF: (String, Object) -> Future<String>
function readFileF(file, options) {
var f = new Future();
fs.readFile(file, options, function(err, data) {
// 에러는 잠시 후에 다루겠다

if(err) throw err;
f.complete(data);
});
return f;
}

logF( readFileF('test.txt', {encoding: 'utf8'}) );

readFileF의 결과는 인자로 받은 파일 이름의 내용을 잡고 있는 Future가 된다.

Future 다루기: 첫번째 게스트

Future를 결과적으로 함수의 결과를 잡고 있는 마법 상자처럼 생각할 수도 있다.

뭔가 쓸모있는 것을 하려면 Future 타입에서 쓸모있는 연산들을 제공해야한다. 그러지 않는다면 필요없이 또 다른 undefined를 만든 것과 다를 바 없다.

그러면 어떤 연산을 Future에서 제공해야할까?

Future 상자에서 잡고 있는 값에 어떤 연산을 하고 싶을 때 (function map을 줄인)fmap을 호출할 것이다.

fmap의 예제를 보자. 여기서 Future는 텍스트 파일의 내용을 잡고 있고, 이 내용의 길이를 계산하려고 한다.

1
2
3
4
5
var textF = readFileF('test.txt', {encoding: 'utf8'});

// fmap: (Future<String, (String -> Number)> -> Future<Number>)
var lengthF = textF.fmap( text => text.length );
logF( lengthF );

lengthF의 뜻은 인자로 받은 Future가 잡고 있는 파일 내용의 길이를 잡고 있는 Future다.

일반화를 해보자면, fmap은 인자를 둘 받는데, 하나는 값을 잡고 있는 Future고 하나는 일반값을 다루는 매핑 함수다. 입력으로 받은 Future의 결과물에 매핑 함수를 적용한 결과를 잡고 이는 Future가 결과로 나온다. 받은 Future와 결과 Future는 둘 다 동시에 완료된다.

정확하진 않지만, 이렇게 표현할 수 있다

1
fmap( Future<value>, func ) = Future< func(value) >

fmap은 몇줄만으로 구현할 수 있다.

1
2
3
4
5
6
7
Future.prototype.fmap = function(fn) {
var fut = new Future();
this.ready(function(val) {
fut.complete( fn(val) );
});
return fut;
}

현재 Future가 완료되었을 때 결과로 나온 Future도 완료된다. 그 때 매핑 함수를 적용시킨다.

위의 예제에서는, 파일 내용을 잡고 있는 Future를 내용 길이를 잡고 있는 다른 Future로 변이시켰다.

어디서 들어본 말 같지 않은가? 잘 알고 있는 자바스크립트 Array의 map 메소드와 꽤 비슷하다. 실제로 정확히 같은 개념이다.

  • Array 타입은 여러 값들을 잡고 있는 박스다
  • Future 타입은 완료될 값을 잡고 있는 박스다
  • Array.map(...) 은 Array 박스 안의 값들을 변이시켜서, 변이된 값들을 잡고 있는 다른 Array 박스를 돌려준다
  • Future.fmap(...)은 Future 박스 안의 값을 변이시켜서, 변이된 값을 잡고 있는 다른 Future 박스를 돌려준다

Array와 Future 타입 둘 모두 포함되는 Functor라는 첫번째 게스트가 등장했다. 일반 함수를 하나 받아서 안에 무엇을 가지고 있든 그것이 변이된 결과를 표현하는 다른 인스턴스를 만들어내는 타입이다.

  • 다른 타입을 감싸는 컨텍스트처럼 작동할 수 있는 타입이고
  • 내부에 있는 것을 일반 함수에 적용시킬 수 있다면

Array와 Future가 아니더라도 그게 무엇이든간에 그 타입을 Functor라고 부를 수 있다.

이제 Future를 다른 Future로 매핑할 수 있다. 이제 일반값을 다루듯이 Future를 직접적으로 다루는 함수를 만들 수 있다는 뜻이다. textF.fmap( c => c.length )처럼 호출하는 대신에 Future를 직접 다루는 lengthF라는 특별한 종류의 함수를 만들 수도 있다.

1
2
3
4
// lengthF: Future<String> -> Future<Number>
function lengthF(strF) {
return strF.fmap( s => s.length )
}

파일 길이를 읽는 예제를 흔히 보던 방법처럼 다시 작성할 수 있게 되었다.

1
nbCharsF = lengthF( readFileF('...') )

lengthFlift된 함수라고 부른다. Functor같은 _박스 타입_을 다루는 함수를 lift한다는 것은 일반값을 다루는 함수를 박스 타입을 다루는 함수로 만든다는 뜻이다. 여기에서는 문자열을 다루는 함수 length(String)를 lift해서 Future를 다루는 함수lengthF( Future<String> )로 lift했다.

일반화된 lift1(인자를 하나만 받아서 lift하는 함수)를 정의해보자.

1
2
3
Future.lift1 = function(fn) {
return fut => fut.fmap(fn);
}

비동기 실행을 일반 함수 실행처럼 만들어주는 간단한 추상 함수다. 위에서 lengthF( readFileF('...') )readFileFlengthF를 조합해서 비동기 연산을 현재의 흐름을 떠나지 않고 실행할 수 있다.

인자를 여러개 받는 함수는 어떻게? (두번째 게스트?)

질문에 대답하기 전에 잠시 기초지식에 대해 생각해보자: Future 박스가 잡을 수 있는 타입에는 뭐가 있을까? Future는 모든 타입에 대해 같은 의미를 가질까?

Future<String>의 뜻은 명확하다: 시간이 지난 뒤에 문자열 타입의 값이 발생한다는 뜻이다. 다른 타입들에 이 의미를 확장할 수 있을까? 숫자, 객체, 배열? 그럴듯... 그럼 Future 자체에 대해서는 어떨까? Future<Future>는 무슨 뜻일까? 그러니까 Future의 Future는?

보고 바로 이해할 수 있도록, 디렉토리를 보고 첫번째 파일의 내용을 읽는 간단한 예제를 만들어보자 (간단히 생각하기 위해 내부에 다른 디렉토리가 없다고 가정한다).

Node.js에서는 비동기 함수 fs.readdir을 통해 디렉토리 속 파일들 이름의 배열을 가져올 수 있다. 먼저 이걸 Future식 함수로 만들어보자.

1
2
3
4
5
6
7
8
9
10
// readDirF: String -> Future< Array<String> >
function readDirF(path) {
var f = new Future();
fs.readdir(path, (err, files) => {
// 기다리면 곧 실행된다
if(err) throw err;
f.complete(files);
});
return f;
}

readDirF는 디렉토리 내 파일 이름들의 배열을 기다리는 Future를 뜻한다.

위에서 말한걸 구현하려면 필요한 나머지는

  1. Future가 잡고 있는 파일 이름들의 배열을 기다린다.
  2. 첫번째 파일명을 가져온다.

여기서 fmap을 사용할 수 있을까? Node에서 이걸 실행해보자

1
2
var resultF = readDirF("testdir").fmap( files => readFileF( files[0]) )
logF( resultF )

기다리면... 아차

1
{ slots: [] }

확실히 뭔가 잘못됐다. 콘솔에서 파일 내용이 나오는게 아니라 Future 인스턴스 객체의 내용이 나왔다.

왜냐면 fmap은 매핑 함수의 결과가 무엇이든 받아서 그걸 Future로 잡아 돌려주기 때문이다. 위에서의 매핑 함수는 또다른 Future(readFileF의 결과)를 fmap은 그 Future를 잡는 Future를 만들어 resultF에 보내기만 한다.

하지만 Future는 잡고 있는 Future와 함께 끝나는지 않으므로, 속에 있는 Future가 완료될 때까지 계속 기다릴 뿐이다.

그래서 이럴 때 필요한 함수를 만들어보자. Future를 리턴하고 끝내는 대신에 속에 있는 Future가 끝날 때까지 기다리는 함수다.

(이중 Future)Future를 그냥 Future로 만들어주는 flatten를 만들어보자.

1
2
3
4
5
6
7
8
9
10
// flatten: Future< Future<Value> > -> Future<Value>
Future.prototype.flatten = function() {
var fut = new Future();
this.ready(function(fut2) {
fut2.ready( function(val) {
fut.complete(val);
} );
});
return fut;
}

이렇게 하면 원하는 결과를 얻을 수 있다.

1
2
3
var result = readDirF("testdir")
.fmap( files => readFileF(files[0], {encoding: 'utf8'}) )
logF( result.flatten() )

fmapflatten을 따로 부르는 대신에 한번에 부를 수 있게 합쳐보자: 매핑 함수에서 나온 2중 Future를 압축(flatten)하는 두가지 일을 한다. 하는 일 그대로 flatMap이라고 하자 (좀 이상한건 나도 안다).

1
2
3
Future.prototype.flatMap = function( fn ) {
return this.fmap(fn).flatten();
}

개념상으로는 위에서 독립적인 두 연산을 _이어서_하는 것인데, readDirF에서 나오는 파일 이름의 배열을 readFileF에 넘겨준다.

여기에서 두번째 게스트가 등장하는데, Future를 Functor라고 부를 수 있는 것처럼 Monad라고 부를 수도 있다. 순서대로 연산할 수 있는 방법에 대한 개념이다. 위에서 flatMap에서처럼, 이전 단계에서의 결과를 다음 단계로 넘겨서 여러 함수를 연이어 연산할 수 있다.

Functor처럼 Monad도 많은 사용법이 있는데, 기술적으로 모든 모나드는 다음을 만족한다.

  • 일반값을 Monad식(Monoadic) 값 으로 lift하는 방법: 예를 들어, Future.unit은 일반값을 Future로 만든다.
  • 연이은 연산 2개를 이어서 실행하는 방법: Monad는 연산을 이어서 실행하게 해주는 방법이 포함된다. 위에서 flatMap은 그냥 Future 하나만 만들고 다음으로 넘어가는게 아니라, 앞의 Future가 끝날 때까지 기다렸다가 넘어가는 방법이 들어있다.

위에서 2개의 다른 연산(fmapflatten)으로 두번째 인터페이스(flatMap)를 만들 수 있다는 것을 확인했다. fmap 함수를 정의하는 Functor라면 이중 구조를 단순화시켜서 합치는(flatten) 연산이 필요해진다.

이제 처음의 질문으로 돌아가보자, Future들 여러개를 받는 함수를 어떻게 lift할 수 있을까?

다시 파일 예제로 돌아가서, 디렉토리의 모든 파일 내용을 합치려면 이런 코드가 될 것이다.

1
2
// concatF: (Future<String, ...) -> Future<String>
var resultF = concatF( text1F, text2F, ...)

이건 무슨 뜻일까? 입력받은 Future들이 잡고 있는 각 문자열들을 합친 것을 다시 잡고 있는 Future를 만들어준다. concatF는 입력받은 모든 Future들이 순서대로 처리되도록 기다려야하므로 결과로 나온 Future는 입력받은 모든 Future가 완료될 때 완료된다.

인자 2개를 받는 경우부터 시작해보자.

1
2
3
4
5
6
7
8
9
10
11
// fn: (Value, Value) -> Value
// lift2: ( (Value, Value) -> Value ) -> ( (Future, Future) -> Future )
Future.lift2 = function(fn) {
return (fut1, fut2) => {
fut1.flatMap( value1 =>
fut2.flatMap( value2 =>
Future.unit( fn(value1, value2) );
)
)
};
}

보이는 것과는 별개로 코드의 로직은 꽤 간단하다. 한줄씩 읽어보자면:

  • Future.lift2는 "일반값 2개를 다루는 함수"를 받아서 "Future 2개를 다루는 함수"를 리턴한다.
  • 리턴된 (lift된) 함수가 실제로 하는 일은
    • (중첩되어 실행되는) 2개의 연산을 순서대로 flatMap에 넣고
    • 첫번째 연산은 그 자체로 하는게 없지만 value1을 바인딩해서 스코프에 묶어두는 역할을 하고
    • 두번째로 중첩된 연산은 value1value2fn에 넘긴다.
    • fn은 일반값을 리턴하는데 flatMap은 받은 함수가 Future를 리턴해야하므로 Future.unit을 통해 일반값을 Future로 lift한다.

이게 트릭이다: 모든 Future에서 순차적으로 flatMap을 실행해서 모두 끝나길 기다린 다음에 모든 완료값이 한 스코프에 모였을 때 함수를 실행한다.

readDir 내부에서 readFile를 실행하는 것처럼 순차 연산으로 설명되는 Monadic 값과는 다르게 여러 인자를 한번에 lift하도록 마지막에 Future.unit를 사용했다.

파일 2개의 내용을 합치기 위한 예제다.

1
2
3
4
5
6
var concat2 = Future.lift2( (str1, str2) => str1+' '+str2 );

var text1 = readFileF('test1.txt', {encoding: 'utf8'});
var text2 = readFileF('test2.txt', {encoding: 'utf8'});

logF( concat2(text1, text2) );

두번째 Future text2가 첫번째의 text1보다 먼저 끝나더라도 text1을 기다리게 되고, text1이 끝나면 text2는 이미 끝났으므로 바로 함수를 실행한다.

여러 인자를 받는 함수는, 입력들이 언제 끝나는지나 의존성과는 관련없다는 것을 알 수 있다. 이걸 정리하면 다음과 같다.

  • fmap이 하나의 연산을 실행하고
  • flatMap은 순차 연산을 실행하지만
  • 여러 인자를 lift하는 함수는 병렬 실행이다.

위에서 봤듯이, Future들을 한번에 실행하고 연산이 진행되기 전에 이미 그 결과를 기다리고 있다.

lift2의 패턴을 lift3이나 lift4로 쉽게 확장할 수 있지만, 인자의 갯수와 관계없이 위에서 나온 중첩과 스코프를 통해 일반화를 구현해볼 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function toArray(args) {
return Array.prototype.slice.call(args);
}

Future.lift = function(fn) {
return function() {
var futArgs = toArray(arguments), // Future 인자들
ctx = this; // 컨텍스트(`this`)를 저장

return bindArg(0, []);

function bindArg(index, valArgs) {
// 현재 Future 인자를 기다린다
return futArgs[index].flatMap(function(val) {
valArgs = valArgs.concat(val); // 완료값들을 모은다.

return (idnex < futArgs.length - 1) ? // 아직 마지막 Future 인자가 아니라면
bindArg(index+1, valArgs) : // 다음 인자를 flatMap에 넘기고 기다린다
Future.unit( fn.apply(ctx, valArgs) ); // 끝까지 오면 모은 완료값들을 함수에 넘긴다
});
}
}
}

lift에서는 lift2의 패턴을 재활용했다. 인자가 몇개 들어올지 정확히 모르니 재귀를 통해 전체를 순회(iterate)하고, 완료를 기다렸다가 결과를 계속 넘겨서 모은다.(index번째의 Future를 기다렸다가 완료값을 저장하고, 모든 입력이 완료될 때까지 다음 Future 입력에 이 연산을 반복한다.) 마지막 Future까지 오면 함수를 실행하고 결과를 lift해서 리턴한다.

노트: 'Applicative Functor'라는 자료구조를 통해 n개 인자를 lift하도록 구현할 수 있지만, 그러려면 람다나 커리에 대한 설명을 해야하므로 오늘은 일단 생략하자.

에러 처리

위에서 fs.readFile의 에러값을 어떻게 뒀는지 다시 확인해보자.

1
2
3
4
5
function readFileF(file, options) {
var f = new Future();
fs.readFile(file, options, function(err, data) {
if(err) throw err;
...

실제로 작동하지 않는 코드다. 프로그램 흐름에서 떨어져서 실행중이므로 발생하는 에러를 잡을 방법이 없다. 위의 상황에서 에러는 상위로 전파되며 잡는 핸들러가 없어서 Node.js 전체 프로그램을 중단시킨다.

에러를 잡아 흐름을 고치려고 한다거나 의미있는 메시지를 사용자에게 전달하는게 필요할 수도 있다.

가능한 방법으로는 Future실패 의 개념을 붙여서 의미를 확장하는 것이 있다. 아직까지는 Future의 결과에 어떤 의미를 붙이지는 않았지만, 가능한 2가지 결과(완료 혹은 실패)로 Future를 생각해볼 수도 있다. 실패에 대한 경우가 포함되었는지 확인해보자.

먼저, 완료를 알리는 ready 메소드가 있으니 실패를 알리는 메소드를 정의해보자.

1
2
3
4
5
6
7
8
9
function Future() {
this.slots = [];
this.failslots = [];
}

Future.prototype.failed = function(slot) {
if(this.hasFailed) slot(this.error);
else this.failslots.push(slot);
}

Future가 실패할 때의 메소드도 정의해보자.

1
2
3
4
5
6
7
8
9
Future.prototype.fail = function(err) {
if(this.completed || this.hasFailed)
throw "이미 끝난 Future를 실패할 수는 없다!"
this.hasFailed = true;
this.error = err;
for(var i=0, len=this.failslots.length ; i<len ; i++) {
this.failslots[i](err);
}
}

이제 fmap를 다시 생각해보자.

readFileF(...).fmap( s => s.length) 예제에서 파일이 없을 때에 대한 처리가 없다. 제대로 읽었을 때에 대해서만 변환하기 때문에 아닐 때는 에러와 함께 실패할 것이다. 혹시 변환 중 실패할 경우에도 실패해야한다.

1
2
3
4
5
6
7
8
9
10
Future.prototype.fmap = function(fn) {
var fut = new Future();
this.ready( val => {
try { fut.complete( fn(val) ); }
catch(err) { fut.fail(err); }
});
this.failed( err => fut.fail(err) );

return fut;
}

flatten은 약간 복잡하다. 안쪽과 바깥쪽의 Future 2개가 있고, 각각 완료될 수도 실패할 수도 있다. 그래서 4가지(2x2) 경우를 다뤄야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Future.prototype.flatten = function() {
var fut = new Future();

// 1- 밖깥 실패 안쪽 실패 => 결과 실패
// 2- 바깥 실패 안쪽 완료 => 결과 실패
this.failed( _ => fut.fail(err) );

// 3- 바깥 완료 안쪽 실패 => 결과 실패
this.ready( fut2 =>
fut2.failed( err => fut.fail(err) );
);

// 4- 바깥 완료 안쪽 완료 => 결과 완료
this.ready( fut2 =>
fut2.ready( val => fut.complete(val) );
);

return fut;
}

flatten에서 안쪽과 바깥쪽 모두 완료되었을 때만 결과가 완료된다.

flatMaplift는 수정할 필요가 없다. 이미 fmapflatten의 의미를 가져오는 것이기 때문에 자동으로 에러에 대한 의미가 추가된다.

자, 이제 실패한 Future들은 연산에서 제외하게 만들었다. 그럼 실패한 Future들을 어떻게 다뤄야할까?

Future 에러를 잡아서 고치면 된다. 어떻게? 실패한 Future를 완료값으로 변이시켜서 원래의 연산에 포함시키면된다.

fmap과 비슷하지만 좌우반전같은 fmapError 함수를 만들 것이다.

1
2
3
4
5
6
7
8
9
Future.prototype.fmapError = funciton(fn) {
var fut = new Future();
this.ready( val => fut.complete(val) );
this.failed( err => {
try { fut.complete( fn(err) ); }
catch(err1) { fut.fail(err1); }
});
return fut;
}

fmapErrorcatch문의 비동기 버전처럼 작동하며, 정상적으로 완료되면 그냥 값을 넘기고 에러가 발생했을 때는 매핑 함수에 적용시켜서 완료값으로 넘긴다.

간단히 예제를 만들어보자

1
readFileF('unknown file').fmapError( err => 'alternate content')

그럼 에러를 Monad식으로 파이프라인처럼 다음 연산으로 넘기려면?

flatMap의 좌우반전같은 flatMapError를 만들어보자

1
2
3
Future.prototype.flatMapError = funciton( fn ){
return this.fmapError(fn).flatten();
}

예를 들어서 어떤 주소(URL)에서 내용을 가져오려고 할 때 요청이 실패한다면 다른 주소에서 가져오도록 시도를 하려고 하는데, flatMapError을 사용해서 앞의 실패를 잡아서 다른 요청을 만들 수 있다.

1
resultF = requestF('/url1').flatMapError( err => requestF('/url2') )

resultF는 첫번째 요청이 성공할 때 'url1'의 내용을 잡고 있고, 실패할 때는 'url2'를 요청해서 그 결과를 잡고 있다는 뜻이다.

부수효과

합성해서 연산할 수 있는 방법에 대해 필요한 것들을 모두 다뤄보았다. 지금까지 다뤘던 함수들을 통해서 Future를 동기 연산을 할 때처럼 일반값으로 넘겨서 비동기 처리를 하게 해봈다.

하지만 연산들은 끝까지 도달해야 결과가 나온다. 부수효과가 필요한 연산들을 다뤄 볼 시간이다. UI를 업데이트한다거나 콘솔에 로그를 찍는다거나 데이터베이스에 저장을 한다거나.

readyfailed 이벤트를 사용할 수도 있지만 좋은 방법은 아니라고 생각한다.

실제 어플리케이션에서 한 Future가 여러 자식 Future들을 가지고 그 Future들은 또 자식 Future들을 갖게 되는 트리같은 구조가 된다. Future하나가 완료돌 때 매핑된 Future들이 연쇄적으로 완료된다.

Future의 ready알림을 통해서 부수효과를 실행하려고 한다면 트리 내부에 있는 Future들 전체에 영향을 끼치게 된다. 의미적으로나 구현상으로나 업데이트가 끝날 때까지 부수효과 연산을 미뤄두는 것이 좋다. 예를 들어 DOM을 업데이트할 때는 requestAnimationFrame같은 스케쥴러에 맡기는게 더 좋을 수도 있다.

위에서 말한 이유로 do라는 메소드를 하나 만들텐데 부수효과 연산을 명시하는 것이다. fmap처럼 부수효과 함수를 받겠지만, 내부의 알림들(readyfailed)이 완료될 때까지 지연될 것이다.

예를 들어

1
requestF('/url').do( val => /* update ... */ )

이번에도 do의 의미와 리턴값이 무엇인지 생각해보자.

변이없이 그냥 Future를 리턴한다면 future.fmap( Id )(여기에서 Idx => x 같은 항등함수) 와 같은 형태이다. fmap과 다른 점은, 먼저 do에서 부수효과가 발생한다는 점이고 두번째는 다른 컨텍스트에서 실행된다는 점이다.(fmap은 즉시, do는 나중에). 가장 다른건 _의미_다.

정정: 2015년 4월 6일. Action이라는 새로운 타입을 통해 do를 적용했는데, 굳이 Monad(Future) 안에 다른 Monad(Action)을 넣어 복잡하게 만들 필요가 없었다. 서버에 데이터를 넘기거나 응답을 기다리는 등의 상황에서 리턴값이 필요할 수도 있는데, 다음 글에 이걸 개발해 볼 수도 있다.

빠르게 대충 구현해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Future.prototype.do = funciton(action) {
var fut = new Future();
if(this.completed) {
action(this.value);
fut.complete(this.value);
} else {
this.actions.push( val => {
action(val);
fut.complete(val);
});
}

return fut;
}

Future.prototype.complete = function(val) {
...
var me = this;
setTimeout( () => {
for(var i=0, len=me.actions.length ; i<len; i++)
me.actions[i](val);
});
}

덧붙여서, 비동기실행을 제대로 구현하려면 process.nextTick이나 MessageChannel 등을 사용해야 하지만 여기서는 간단히 구현하고 넘어가자. 비슷하게, 부수효과의 실패에 대응해 doError도 만들어야 하는데, do와 비슷하므로 각자 알아서 구현해보자. (Gist에 코드 전체가 있다)


역주 1: Promise와의 비교는 Future/Functor/Monad 개념을 이해하는데 관계없다고 생각해서 생략했다.

역주 2: 그림으로 설명하는 Functor, Applicative, Monad(번역)과 함께 읽으면 이해하는데 도움이 될 것이다.