티스토리 뷰

Backend

Monad 를 이해하는 방법

지마켓 선현상 2021. 4. 16. 13:10

Monad

어떤 것을 설명하는 방법에는 몇 가지가 있습니다. 그것의 정의를 가지고 설명할 수도 있고 그것이 갖는 특성을 나열하며 설명할 수도 있습니다. Monad 를 설명할 때는 어떤 방법이 좋을까요? 사실 이 글을 쓰기 전 Monad 를 이해하기 위해 꽤 많은 설명들을 대하였습니다.

bind   :: m a -> (a -> m b) -> m b
return :: a -> m a

이런 설명은 너무 단순하거나 수학적이어서 그 조건을 만족한 것이 어떤 기능을 할 수 있는지 상상하기가 어려웠습니다.

Maybe is Monadic
jQuery is Monadic
Stream (in Java) is Monadic

이런 설명들은 그 사용 예들을 살피며 Monad 가 어떻게 기능하는 패턴이 있는지 대충 느끼게 해주지만 제가 어떤 문제에 부딪혔을 때 Monad 를 통해 도움을 받을 수 있는가 하는 점은 역시 잘 상상하기 어려웠습니다. 그래서 저 나름대로의 또 다른 방법으로 Monad 를 설명해보려고 합니다.

함수와 합성

우리는 함수를 프로그래밍에 사용하곤 합니다. 함수는 큰 코드 블럭사이에서 좋은 장점들을 제공합니다.

  • 부수효과가 없음
  • 합성이 가능함

이 두 특징은 수학적으로 함수들의 안전한 사용을 증명합니다. 수학적으로 증명하였다는 점은 한번 증명한 내용을 다시 증명할 것 없이 사용 가능하다는 것이기도 합니다. 우리는 함수를 코드의 기본단위로 사용하여 이들을 합성하는 것으로 안전한 동작을 증명하는 과정으로 프로그래밍할 수 있습니다.

하지만 이미 느끼셨겠지만 프로그래밍과 이 세상은 그리 호락하진 않습니다. 사실 엄밀하게 함수를 만드는 것도 쉽지 않고 또 세상을 모두 함수로 표현하는 것도 쉬운일이 아닙니다. 당장 함수를 합성하는 일도 매우 제약적입니다. 하나의 함수의 치역이 다음 함수의 정의역과 일치해야 합성을 할 수 있죠. 다음과 같은 함수는 합성할 수 없습니다.

function greeting(name: string): string { return "hello " + name }
function twice(n: number): number { return n * 2 }

twice(greeting(name)) // ??

함수와 합성은 뜻은 좋지만 실용성은 떨어지는것 같습니다. 다행히도 우리는 이런 점을 충분히 알면서도 좀 더 함수나 합성에 대한 장점들을 확장할 수는 있습니다.

꾸밈 함수

아래 두 함수가 있습니다.

function toUpper(s: string): string {
  return s.toUpperCase()
}

function toWords(s: string): string[] {
  return s.split(' ')
}

toWords(toUpper("hello world")) // ["HELLO", "WORLD"]

이 두 함수는 toUpper, toWords 순으로 자연스럽게 합성이 가능합니다. 매우 간단하죠.

우리는 종종 주요한 연산 외에 부차적인 기능이 필요한 경우가 종종 있습니다. 예를 들면 수행한 행위를 기록하는 기능이 필요할 수도 있습니다.
그래서 다음과 같은 클래스를 하나 준비해 보았습니다.

class Writer<T> {
  content: T;
  log: string;

  constructor(content: T, log: string) {
    this.content = content;
    this.log = log;
  }
}

그리고 위에서 준비했던 두 함수는 이제 Writer 를 반환합니다.

function toUpper(s: string): Writer<string> {
  return new Writer(s.toUpperCase(), "toUpper ")
}

function toWords(s: string): Writer<string[]> {
  return new Writer(s.split(' '), "toWords ")
}

그런데 이제 곤란해졌습니다. 이렇게 바꾼 함수는 이제 합성이 어렵습니다. toUpper 의 반환인 Writer<string> 은 더 이상 toWords 에 입력에 쓰일 수 없기 때문입니다. 그러면 이제 어떡해야 할까요? 잠시 생각해보면 Writer#content 만을 이용하면 기존의 함수와 다를 게 없는데 합성이 되지 않는다니 안타깝기도 합니다. 여기서 바로 그 점에 착안해 볼 수 있습니다. 어쩌면 트릭이라고 말할 수도 있을 것입니다.

실제로 이 두 함수를 차례로 수행하려면 다음과 같이 진행할 수 있습니다.

function run(s: string): Writer<string[]> {
  const r1 = toUpper(s)
  const r2 = toWords(r1.content)
  return new Writer(r2.content, r1.log + r2.log)
}

이것을 특별한 합성 규칙이라고 하면 어떨까요? 어떤 값을 받아 Writer 를 반환하는 함수들에 대한 합성 규칙이라고 해버리는 겁니다. 다음같이 함수도 하나 만들어보고 말이죠.

function compose<T, U, R>(
  first: (_:T) => Writer<U>,
  second: (_:U) => Writer<R>
) {
  return (t:T) => {
    const r1 = first(t)
    const r2 = second(r1.content)
    return new Writer<R>(r2.content, r1.log + r2.log)
  }
}

이제 다음과 같이 사용할 수 있습니다.

const run = compose(toUpper, toWords)
run("hello world") // { content: ["HELLO", "WORLD"], log: "toUpper toWords " }

훌륭하게 합성을 할 수 있게 되었습니다. 이 합성도 원래 함수들의 합성과는 조금 다르지만 규칙이 있습니다. 그것은 Writer 를 반환하는 함수여야 하고 앞선 함수가 반환한 Writer 가 포함한 content 타입을 다음 함수의 입력으로 사용하는 경우에 합성이 가능합니다. 이것도 결국 규칙에 맞는 경우로 한하겠지만 그래도 원래보다 더 많은 함수들을 합성할 수 있게 되었습니다.

합성 가능함

위에서 소개한 특별한 합성 규칙은 뭔가 트릭 같을 수도 있습니다. 하지만 중요한 점은 등장한 함수들은 모두 안전을 증명할 수 있고, 한번 증명한 후에는 다시 증명을 할 필요가 없다는 것입니다.

우리는 함수를 사용하여 얻을 수 있는 프로그래밍적인 장점들을 고스란히 이 패턴에서 얻을 수 있습니다. 그러면 이 특별한 합성 규칙을 마구 만들면 어떻게 될까요? javascript 에 Promise 를 보면 다음과 같은 사용법을 가지고 있습니다.

Promise.resolve("hello")
  .then(word => word + " world")
  .catch(err => console.error(err))

// "hello world"

then() 이 하는 일은 Promise 객체에 word => word + " world" 라는 함수를 합성하는 것입니다. catch() 가 하는 일은 Promise 객체에 err => console.error(err) 라는 함수를 합성하는 것입니다. 객체와 함수를 합성한다는 것이 뜬금없게 들리실 수도 있겠지만 넘어가셔도 좋습니다. 중요한 것은 특별한 합성 규칙들을 모아두었다는 점입니다. 위의 Writer 예제에서 합성의 결과는 언제나 "Writer 를 반환하는 함수"입니다. Promise 예제에서는 합성의 결과가 모두 Promise 이고요. 이것이 이 특별한 규칙들을 이루는 규칙 중 하나입니다.

그러면 특별한 합성 규칙은 어떻게 정하는 걸까요? 사실 필요할 때마다 만드는 것입니다. 대신 한번 규칙을 정하면 우리는 그것을 규칙에 맞게 사용하는 한 다시 규칙을 증명할 필요가 없습니다. 누군가가 만들었을 Promise 를 이루는 합성 규칙을 다시 증명해서 사용하지 않는 것처럼 말이죠.

정리: 합성이 주는 다른 관점

Monad는 이런 패턴으로 이해할 수 있습니다. 어떤 방식에서 훌륭히 합성을 해내는 특별한 규칙들을 모아둔 패턴입니다. 특별한 규칙은 부차적인 요구사항들을 합성 가능하게 해 줍니다. 그 말은 부차적인 문제들을 다루는 것을 재사용할 수도 있고, 신경 쓰지 않게 감출 수도 있다는 점입니다. Promise 는 비동기 흐름이라는 부차적인 요구사항을 합성 가능하며 신경 쓰지 않게 감춰줍니다. 또, 여기에 합성할 함수들을 당연히 재사용할 수도 있습니다.

Bartosz Milewski는 Category Theory For Programmers 에서 합성 가능한 규칙을 더 만들 수 있다는 점은 반대로 어떤 문제를 합성 가능한 두 문제로 분해하는 것이 가능하다는 것이라는 점을 짚었습니다. 즉 합성이 가능한 규칙들을 좀 더 세련되게 많이 활용한다는 것과 어떤 문제를 분해하여 작은 문제들의 합성으로 다루는 것은 어떤 면에서 동일한 문제의식이라는 점입니다. 또 Monad 는 모든 것을 합성하기 위해 사용하는 것이라는 얘기도 합니다. 조금은 과장일 수도 있지만 저는 Monad 를 설명하는 가장 단순한 문장이라고 생각합니다.

이에 저는 Monad 를 합성이라는 키워드로 설명하려고 노력해 보았습니다. 긴 글이 어떻게 닿을지 모르겠으나 여기까지 읽어주셔서 감사합니다.

댓글