읽어본 중 가장 좋았던 모나드 입문 글의 일부를 발췌하여 Haskell 대신 JavaScript로 설명한 글

cadenzah·2022년 2월 22일
3

Functional Programming

목록 보기
1/1
post-thumbnail

(John GruberA Neighborhood of Infinity에 사과 말씀을 전하며)

예 맞습니다, 이미 세상에 차고 넘치는 또 한편의 모나드 입문 글입니다(혹은, 이 세상에 모나드 입문 글이 너무 많다고 투덜대는 글 중 하나이기도 합니다). 다른 글과 달리, 이 글은 전혀 새롭지 않은 내용을 다룬다는 점에서 여러분 마음에 드실 겁니다. 저는 다음 두 가지 이유로 이 글을 적기로 다짐하였는데, 우선 모나드는 알아둘 가치가 있는 내용이기 때문이고, 두번째로 모나드가 비동기 프로그래밍과 관련이 있다는 점을 다루면서 이후에 작성할 다른 JavaScript 관련 글의 기반으로 삼고 싶었기 때문입니다. 또한, 모나드는 타입 관점에서 사고하는 훈련을 하기에도 좋은 주제입니다. 만약 여러분이 Haskell을 읽는 데에 문제가 없으시다면, 원문인 당신이 모나드를 발명할 수 있었을지도(그리고 이미 발명했을지도)를 읽어보시길 적극 추천합니다.

먼저, 약간의 배경 설명을 드리도록 합시다. 모나드는 Haskell에서 훨씬 널리 사용되고 있는데, Haskell에서는 부수 효과가 없는 함수인 순수 함수만을 허용하기 때문입니다. 순수 함수란 입력을 인자로 받아 출력을 반환하는 함수, 그게 전부입니다. 제가 주로 사용하는 언어인 Ruby와 JavaScript에는 이러한 제약은 따로 없지만, 이러한 제약을 스스로 지키는 것이 종종 유용합니다. 전형적인 모나드 입문 글에서는 모나드란 입/출력을 수행할 수 있도록 부수 효과를 모델 안으로 집어넣는 것에 대한 것이라고 설명하겠지만, 이것은 단지 하나의 예시에 불과합니다. 곧 보시겠지만, 모나드는 단지 함수의 합성에 대한 것입니다.

한 가지 예시를 떠올려봅시다. 한 수의 사인값을 알아내는 함수가 있다고 합시다. JavaScript로 작성한다면 Math 라이브러리를 사용한 간단한 래퍼일 것입니다.

var sine = function(x) { return Math.sin(x); };

그리고 한 수의 세제곱을 취하는 함수가 있다고 합시다.

var cube = function(x) { return x * x * x; };

이 함수들은 입력으로 숫자 하나를 받고, 출력으로 한 숫자를 돌려받으므로, 합성이 가능한 구조입니다. 즉, 한 함수의 출력은 그 다음 함수의 입력으로 사용할 수 있습니다.

var sineCubed = cube(sine(x));

이 함수 합성을 캡슐화하는 함수를 만들어보겠습니다. 이 함수는 두 개의 함수 fg를 받아서 f(g(x))를 계산하는 또다른 함수를 반환합니다.

var compose = function(f, g) {
  return function(x) {
    return f(g(x));
  };
};

var sineOfCube = compose(sine, cube);
var y = sineOfCube(x);

다음으로, 이 함수들을 디버그할 필요가 있다고 판단하여, 각 함수가 호출되었다는 사실을 로그로 남기고 싶다고 합시다. 아래와 같이 하면 되겠죠.

var cube = function(x) {
  console.log('cube was called.');
  return x * x * x;
};

하지만 순수 함수만이 허용되는 체계 아래에서는 위와 같은 코드가 허용되지 않습니다. console.log()는 함수의 인자도, 반환값도 아닌 부수 효과입니다. 해당 로그 정보를 남겨야 한다면, 반환값의 일부로 존재해야만 합니다. 아래와 같이 함수의 결과값, 그리고 디버그 정보의 쌍(Pair)을 반환하도록 함수를 수정하겠습니다.

var sine = function(x) {
  return [Math.sin(x), 'sine was called.'];
};

var cube = function(x) {
  return [x * x * x, 'cube was called.']; 
};

하지만 끔찍하게도, 이 함수들은 더 이상 합성이 되지 않는다는 사실을 깨닫습니다.

cube(3) // -> [27, 'cube was called.']
compose(sine, cube)(3) // -> [NaN, 'sine was called.']

두 가지 이유로 고장이 났습니다. 우선 sine이 배열의 사인값을 계산하려고 시도하기 때문에, 이는 NaN을 반환하게 되고, 두번째로는 cube를 호출할 때의 디버그 정보를 잃어버렸습니다. 이 함수들을 합성한다면 x의 세제곱의 사인값과, cubesine이 모두 호출되었음을 알 수 있는 어떤 문장이 반환될 것으로 기대했을 겁니다.

compose(sine, cube)(3)
// -> [0.956, 'cube was called.sine was called.']

이 간단한 합성은 더 이상 작동하지 않는데, 그 이유는 cube의 반환 타입(Array)이 sine의 인자 타입(Number)와 다르기 떄문입니다. 약간의 접착제가 필요합니다. '디버그할 수 있는(debuggable)' 함수가 합성될 수 있도록 함수를 새로 작성해볼 수 있겠습니다. 이 함수는 각 함수의 반환값들을 분해하고, 의미있는 방식으로 다시 서로 이어 붙입니다.

역자 주: console.log()를 통한 디버그 기능이 추가된 함수를 가리킬 때 가리켜 이제부터는 '디버그할 수 있는(debuggable)' 이라는 수식어를 사용하겠습니다.

var composeDebuggable = function(f, g) {
  return function(x) {
    var gx = g(x),      // e.g. cube(3) -> [27, 'cube was called.']
        y  = gx[0],     //                 27
        s  = gx[1],     //                 'cube was called.'
        fy = f(y),      //     sine(27) -> [0.956, 'sine was called.']
        z  = fy[0],     //                 0.956
        t  = fy[1];     //                 'sine was called.'
    
    return [z, s + t];
  };
};

composeDebuggable(sine, cube)(3)
// -> [0.956, 'cube was called.sine was called.']

위 코드는 숫자 하나를 인자로 받아 숫자+문자열 쌍을 반환하는 두 함수를 합성하여, 동일한 시그니처를 가지는 다른 함수를 생성하였습니다. 이말인즉슨 새로 생성된 함수는 또다른 디버그할 수 있는 함수와도 합성할 수 있습니다.

단순한 표기를 위하여 Haskell에서 사용하는 표기법을 빌려와야 할 것 같습니다. 아래 타입 시그니처는 cube 함수가 숫자 하나를 인자로 받을 수 있고, 숫자와 문자열로 이루어진 튜플을 반환한다는 의미입니다.

cube :: Number -> (Number, String)

디버그할 수 있는 함수, 그리고 해당 함수들을 합성한 함수는 모두 이 시그니처를 따릅니다. 참고로 원래 함수는 보다 간단한 시그니처인 Number -> Number를 따랐죠. 원래 함수들에 대하여 합성이 가능했던 이유는 인자와 반환값의 타입이 대칭을 이루기 때문입니다. 디버그할 수 있는 함수에 별도 합성 로직을 적용하지 말고, 그 대신 아래처럼 함수 시그니처를 바꾸면 어떨까요?

cube :: (Number, String) -> (Number, String)

이제 기존의 compose 함수를 그대로 사용하더라도 각 함수들을 서로 이어 붙일 수 있을 겁니다. cubesine의 함수 본문을 직접 수정하여 이 함수들이 Number뿐 아니라 (Number, String)을 인자로 받을 수 있도록 만들 수도 있겠지만, 이는 확장성이 떨어질뿐더러 같은 수정을 다른 모든 함수들에 반복해서 적용해야만 할 겁니다. 그 대신, 각 함수는 본연의 임무를 수행하도록 두고, 각 함수를 특정 형식으로 변환해주는 도구를 만들어보는 편이 더 좋을 것 같습니다. 이 도구는 bind라고 부르며, 이 도구의 역할은 Number -> (Number, String) 형식의 함수를 받아서 (Number, String) -> (Number, String) 형식의 함수를 반환하는 것입니다.

var bind = function(f) {
  return function(tuple) {
    var x  = tuple[0],
        s  = tuple[1],
        fx = f(x),
        y  = fx[0],
        t  = fx[1];
    
    return [y, s + t];
  };
};

이제 이 도구를 사용하면 각 함수들의 시그니처를 합성가능한 형식으로 변환하고, 각 함수의 결과를 합성할 수 있습니다.

var f = compose(bind(sine), bind(cube));
f([3, '']) // -> [0.956, 'cube was called.sine was called.']

그런데 이제 함수를 호출할 때 항상 (Number, String) 형식의 인자가 필요해졌는데, 그냥 Number 하나만 전달할 수 있으면 좋겠습니다. 함수의 변환뿐 아니라, 함수에서 허용하는 형식으로 값을 변환하는 방법 또한 필요합니다. 즉, 아래와 같은 함수가 필요합니다.

unit :: Number -> (Number, String)

unit의 역할은 한 값을 받아서, 함수가 다룰 수 있는 형식의 기본 컨테이너로 한겹 감싸는 것입니다. 앞서 예시의 디버그할 수 있는 함수의 경우, 숫자를 빈 문자열과 함께 쌍으로 만드는 것이 이에 해당합니다.

// unit :: Number -> (Number, String)
var unit = function(x) { return [x, '']; };

var f = compose(bind(sine), bind(cube));
f(unit(3)); // -> [0.956, 'cube was called.sine was called.']

// 또는...
compose(f, unit)(3) // -> [0.956, 'cube was called.sine was called.']

unit 함수를 사용하면 어떤 함수를 디버그할 수 있도록 변환할 수도 있습니다. 아래와 같이, 디버그할 수 있는 함수의 입력으로 사용할 수 있도록 함수의 반환값을 변환하면 됩니다.

// round :: Number -> Number
var round = function(x) { return Math.round(x); };

// roundDebug :: Number -> (Number, String)
var roundDebug = function(x) { return unit(round(x)); };

마찬가지로, 이렇게 '일반' 함수를 디버그할 수 있는 함수로 변환하는 작업은 lift라 부르는 함수로 추상화할 수 있습니다. 이 함수는 인자로 Number -> Number 시그니처를 가지는 함수를 받아, Number -> (Number, String) 시그니처를 가지는 함수를 반환합니다.

// lift :: (Number -> Number) -> (Number -> (Number, String))
var lift = function(f) {
  return function(x) {
    return unit(f(x)); 
  };
};

// 더 간단하게 작성할 수도 있습니다
var lift = function(f) { return compose(unit, f); };

앞서 작성한 함수들에 적용해보고, 제대로 작동하는지 확인해보겠습니다.

var round = function(x) { return Math.round(x); };

var roundDebug = lift(round);

var f = compose(bind(roundDebug), bind(sine));
f(unit(27)); // [1, 'sine was called.']

여기까지 해서, 디버그할 수 있는 함수들을 서로 이어 붙이기 위한 세 가지의 중요한 추상화 함수를 발견했습니다.

  • lift: '일반' 함수를 디버그할 수 있는 함수로 변환
  • bind: 디버그할 수 있는 함수를 합성가능한 형식으로 변환
  • unit: 컨테이너로 한겹 감싸서, 단순 값을 디버그에 필요한 형식으로 변환

이 추상화 함수들은 (사실, bindunit은) 모나드를 정의합니다. Haskell 표준 라이브러리에서는 Writer 모나드로 불립니다. 이 패턴의 일반적인 구성 요소가 무엇인지 아직 명확하지 않으실 테니, 또 다른 예시를 하나 들어보겠습니다.

어떤 함수의 입력으로 단일값을 받아야 할지, 혹은 여러 값으로 이루어진 배열을 받아야 할지, 함수를 만들다보면 흔히 마주치는 상황 중 하나입니다. 이 둘은 함수 본문을 for 루프로 감싸는지 정도의 차이만 있고, 그마저도 반복 코드(boilerplate)입니다. 하지만 이 결정은 해당 함수가 어떻게 합성되는지에 상당한 영향을 끼칩니다. 예를 들어, DOM 노드 하나를 취하여 해당 노드의 자식들을 배열로 반환하는 함수가 있다고 합시다. 이때 해당 함수는 단일 HTMLElement를 인자로 받아 HTMLElement의 배열을 반환합니다.

// children :: HTMLElement -> [HTMLElement]
var children = function(node) {
  var children = node.childNodes, ary = [];
  for (var i = 0, n = children.length; i < n; i++) {
    ary[i] = children[i]; 
  }
  return ary;
};

// e.g.
var heading = docuemnt.getElementsByTagName('h3')[0];
children(heading);
// -> [
//     "Translation from Haskell to JavaScript...",
//     <span class="edit">...</span>
//    ]

이제 heading의 손자 노드들, 즉 heading의 자식의 자식을 찾고 싶다고 합시다. 아래 코드는 직관적으로 잘 작성된 함수 정의처럼 보입니다.

var grandchildren = compose(children, children);

하지만 children은 입력과 출력의 타입이 대칭을 이루지 않으므로, 위와 같은 합성은 불가능합니다. grandchildren을 직접 작성한다면, 아래와 같을 것입니다.

// grandchildren :: HTMLElement -> [HTMLElement]
var grandchildren = function(node) {
  var output = [], childs = children(node);
  for (var i = 0, n = childs.length; i < n; i++) {
    output = output.concat(children(childs[i])); 
  }
  return output;
};

위 코드는 단순히 입력으로 들어온 노드의 모든 자식에 대하여 모든 자식을 찾고, 각 결과 배열을 연결하여 하나의 손자 배열을 만들고 있습니다. 하지만 그다지 좋지 않은 코드이며, 해결하고자 하는 문제 자체가 아닌 배열을 다루는 데에 집중한 탓에 상당 부분의 코드가 반복되고 있는 것이 사실입니다. 배열을 다루는 함수 둘을 합성하여 문제를 해결하는 편이 더 좋을 것 같습니다.

다시 위 예제로 돌아가서, 위 코드를 개선하려면 두 단계를 거쳐야 합니다. 먼저, children을 합성가능한 형태로 변환해주는 bind 함수가 필요합니다. 그리고, 최초 입력인 heading을 특정 함수의 인자 형태로 변환해주는 unit 함수가 필요합니다.

여기서 가장 핵심적인 문제는, 위에 작성한 함수 grandchildren이 하나의 HTMLElement만을 받아 그것의 배열을 반환하므로, 변환 로직 역시 단일 항목을 배열로 변환하는 것 또는 배열을 단일 항목으로 변환하는 것에 초점을 두어야 합니다. 여기서 각 항목이 HTMLElement 타입인 것은 중요하지 않으며, 이처럼 다루는 값의 구체적인 타입이 달라질 수 있는 경우, Haskell에서는 해당 값을 단일 문자로 표현합니다. unit은 단일 항목을 인자로 받아 해당 항목을 포함하는 배열을 반환하고, bind는 '일대다' 함수를 인자로 받아 '다대다' 함수를 반환합니다.

// unit :: a -> [a]
var unit = function(x) { return [x]; };

// bind :: (a ->: [a]) -> ([a] -> [a])
var bind = function(f) {
  return function(list) {
    var output = [];
    for (var i = 0, n = list.length; i < n; i++) {
      output = output.concat(f(list[i])); 
    }
    return output;
  };
};

이제 처음 의도했던 대로 children을 합성할 수 있게 되었습니다.

var div = document.getElementByTagName('div')[0];
var grandchildren = compose(bind(children), bind(children));

grandchildren(unit(div));
// -> [<h1>...</h1>, <p>...</p>, ...]

방금까지 위에서 구현한 것은 Haskell에서 List 모나드라고 부르는 것으로, 이 모나드를 사용하면 단일 항목을 인자로 받아 배열을 반환하는 함수를 합성가능하도록 변환할 수 있습니다.

자 이제, 모나드는 무엇일까요? 모나드란 디자인 패턴입니다. 모나드에 따르면, 어떤 타입의 값을 인자로 받아 다른 타입의 값을 반환하는 함수가 있을 때, 해당 함수가 합성가능하도록 만들려면 두 가지 함수를 사용될 수 있습니다.

  • bind: 어떤 함수의 인자 타입이 반환 타입과 동일하도록 변환하여, 해당 함수가 합성가능하도록 만듭니다.
  • unit: 합성가능한 함수의 인자로 사용될 수 있도록, 어떤 값을 (컨테이너로) 감쌉니다.

위 내용은 모나드의 수학적 기반을 무시하는, 엄밀하다고는 할 수 없는 부정확한 정의임을 강조드리며, 이해한 척하고자 하는 것도 아닙니다. 다만 모나드는 저와 비슷한 부류의 개발을 하는 사람에게 있어 알아두면 꽤 유용한 디자인 패턴인데, 의도치 않게 발생한 복잡성 - 어떤 문제를 직접 다루는 대신, 여러 데이터 타입들을 연결하여 해결하는 코드 - 을 알아채는 데에 도움이 되기 때문입니다. 그러한 유형의 반복 코드를 식별하고 의미를 알 수 있으면, 코드를 보다 명확하게 개선할 수 있게 됩니다.

0개의 댓글