5 minute read

모나드monad와 관련해서는 함수형 프로그래밍 언어인 Haskell의 설계 책임자 필립 와들러의 설명을 밈처럼 만들어놓은 인용구가 유명합니다.

“a monad is a monoid in the category of endofunctors, what’s the problem?” - 1990, Phillip Wadler

와~ 정말 이해가 잘되네요! 사실 무슨 소리인지 하나도 모르겠다 Haskell은 함수형 프로그래밍 언어 중에서도 수학에 있는 개념들을 가져와서 언어로 구현하는 것으로 유명한데, 모나드의 경우 범주론Category Theory에서 가져온 개념입니다.

Figure 1. 재미로 그린 Haskell의 Learning Curve [ref]

실제로 Haskell을 배우기 어려운 이유 중 하나가 monad를 이해하기 힘들어서라고 하기도 합니다.

순수한 함수

순수한 함수pure function에 대한 다음 한 줄이 의미심장해서 가져와 봤습니다(출처).

Output depends only on input

순수한 함수형 프로그래밍 언어에서 모든 순수한 함수는 수학에 나오는 함수처럼 입력에 따른 결과를 계산해 주기만 해야 합니다. (C는 함수형 패러다임을 지원하지 않지만, 쉬운 설명을 위해 사용했습니다)

int count = 0;

void incr(){
    count++;   // 외부의 정보 count를 읽고, 수정
    return;
}

이 코드처럼 인자로 넘겨지지 않은 전역변수나 라이브러리에 있는 값을 조정하거나, 로그를 출력하는 등 side-effect가 발생해서는 안 됩니다. 순수한 함수는 외부 상태에 직접 접근할 방법이 없고, 사용할 수 있는 정보는 오직 인자로 전달되는 값뿐입니다. 따라서 순수한 함수로 모든 걸 해결하려면, 외부 상태를 직접 접근하는 방법을 버리고 상태를 직접 인자로 넘겨줘야 합니다.

int count = 0;

int incr(int i){
    return i+1;
}
int new_count = incr(count);    // 변수를 수정해서도 안된다.

incr()에서 인자를 받도록 조정하여 순수한 함수로 만들었습니다.

문제 상황

하지만 언제나 상황이 이렇게 간단하지는 않습니다. 예를 들어, 디버깅을 위해 함수의 실행 로그를 계속 쌓는 상황을 보겠습니다. 지금부터 F#으로 코드를 적을 건데, 그 이유는 제가 monad를 처음 접한 언어이기 때문입니다(…). F#의 문법이 이해에 걸림돌이 되지 않도록 최대한 풀어 적겠습니다.

type ResultWithDebugMessage <'a> = {
    Result : 'a
    DbgMsg : string
}
let inc x = { Result = x + 1; DbgMsg = "incremented" }
let dec x = { Result = x - 1; DbgMsg = "decremented" }
let id x = inc(dec x)   // type mismatch
  • 1번째 줄에서 ResultWithDebugMessage라는 타입은 Result라는 원래 값과 DbgMsg라는 문자열의 쌍으로 정의되어 있습니다. 여기서 'a는 제너릭generic 타입으로, Result에 들어가는 값의 타입에 따라 그에 맞게 변화되는 타입으로 보면 됩니다. 예컨대 { Result = 2; DbgMsg = "Two" }의 경우 Result가 int 타입이기 때문에 ResultWithDebugMessage가 됩니다.

  • inc라는 함수를 정의하는데, 이는 x를 받아서 ResultWithDebugMessage 타입의 값인 { Result = x + 1; DbgMsg = " incremented " }를 리턴합니다. dec도 비슷하게 정리됩니다.

하지만 type mismatch 에러가 발생해버립니다. dec는 ‘a를 받아서 ResultWithDebugMessage<’a>를 반환했는데, inc도 ‘a를 받으려고 하니 생긴 문제입니다.

해결 방법

간단하게는, inc 함수의 입력으로 ResultWithDebugMessage<’a>를 받도록 함수를 수정할 수 있겠습니다.

let new_inc x = { Result = x.Result + 1; DbgMsg = x.DbgMsg + "\n" + "incremented"}
let id x = new_inc (dec x)

하지만 매번 함수를 정의할 때마다 같은 패턴을 쓰게 되면 코드가 길어지고, 가독성도 떨어집니다. 이를 일반화하는건 어떨까요?

let combine f r =
    let r' = f r.Result
    { Result = r'.Result, DbgMsg = r.DbgMsg + "\n" + r'.DbgMsg }

let id x =  combine inc (dec x)

이러면 다음과 같은 복잡한 합성도 한 줄로 적을 수 있습니다.

let complex_expr x = combine inc (combine dec (combine inc (dec x)))

일반적으로 함수 f를 r에 대해 적용하는 상황에 대해 사용 가능한 함수이므로, 여러 다른 종류의 함수 합성을 할 때도 문제가 없이 잘 작동합니다.

마지막으로, complex_expr의 식을 쓸 때 dec 혼자 'a->ResultWithDebugMessage<'a> 타입이기 때문에 일관성을 주기 위해 result만 있을 때 ResultWithDebugMessage를 초기화해주는 함수가 있으면 좋을 것 같습니다.

let combine f r =
    let r' = f r.Result
    { r' with DbgMsg = r.DbgMsg + "\n" + r'.DbgMsg }

let wrap r = { Result = r; DbgMsg = "" }

이 정도가 있으면 ResultWithDebugMessage를 쓰는 데에 더 이상 문제가 없는, 훌륭한 디자인입니다.

let complex_expr x = combine inc (combine dec (combine inc (combine dec (wrap x))))

그래서, 모나드는 어디 있나요?

ResultWithDebugMessage가 모나드입니다.

어떤 타입 M이 모나드라는 것은 다음의 두 함수가 정의되어 있을 때를 뜻합니다.

val Bind: M<'T> * ('T -> M<'U>) -> M<'U>
val Return: 'T -> M<'T>

ResultWithDebugMessage의 함수들의 타입은 다음과 같습니다:

val combine: ResultWithDebugMessage<'a> * ('a -> ResultWithDebugMessage<'b>) -> ResultWithDebugMessage<'b>
val wrap: 'a -> ResultWithDebugMessage<'a>

(combine의 경우 원래 currying과 인자 순서로 표현이 다르지만 Bind와 동일한 구조임을 보이게 적었습니다) 따라서 ResultWithDebugMessage는 모나드이고, 이런 종류의 타입을 모두 모나드라고 부릅니다. 특히, monad에서 bind의 경우 infix 연산자인 >>=로 많이 적습니다.

let (>>=) m f = combine f m
let id x = (dec x) >>= inc                                     // combine inc (dec x)와 같다
let complex_expr x = (wrap x) >>= dec >>= inc >>= dec >>= inc  // 일관성 최고!

모나드가 중요한 이유는 이렇게 모나드 내부의 값을 사용하는 함수들 사이의 결합을 쉽게 표현할 수 있기 때문입니다.

Haskell에서도 >>=와 비슷한 로고를 찾아볼 수 있는데, 그만큼 하스켈 언어 디자이너들의 모나드에 대한 사랑을 알 것 같습니다.

모나드가 도입된 역사적 배경

역사적으로 하스켈에서 I/O 연산들을 효율적으로 관리하기 위해 도입된 개념입니다. I/O 연산을 생각해 보면, 무언가를 저장 공간에서 읽고 쓰는 행위이기 때문에 I/O 연산을 포함하게 된다면 순수한 함수가 될 수 없습니다. 순수한 함수는 외부 요소를 읽거나 변화해서는 안 되는데 저장 공간을 변화시키기 때문입니다. 사실 함수형 패러다임이 컴퓨터의 구조와 잘 맞지 않는 이유가, 컴퓨터는 CPU 레지스터, 메모리, 그리고 하드디스크 등 저장 공간을 끊임없이 변화시키며 작동하도록 설계되어 있기 때문입니다.

그래서 어쩔 수 없이 순수하지 않은 I/O 연산을 사용하게 되면, 함수형 프로그래밍 언어에서는 이 I/O 연산을 행한 함수가 비순수impure하다는 것을 알려야 합니다. 그 방법으로 리턴 타입을 IO라는 이름의 모나드로 덮게 됩니다. 예를 들어, standard input으로부터 한 줄 읽어오는 함수는 다음과 같은 타입을 반환합니다.

getLine :: IO String

컴파일러는 순수한 함수들에 대해 non-IO 코드들의 순서를 바꾸는 등의 최적화(예를 들면 lazy evaluation)가 가능하기 때문에, 이렇게 비순수성을 타입에 명시했을 때 컴파일러는 최적화를 해서는 안되는 구간을 명확히 할 수 있게 됩니다.

벌써 모나드라는 느낌이 드나요? 이 IO로 덮여 있는 타입들 사이의 함수를 합성하는 상황을 생각해 봅시다. 예를 들면 readLine으로 stdin에서 한 줄 얻어온 문자열에 대해 첫 번째 글자를 가져와보는 함수가 있겠네요. IO로 덮여있는 것을 걷어내고 연산을 수행해야 하는데, 모나드 없이는 코드가 아주 길어질 겁니다.

getLine :: IO String
getFirstCharFromString :: String -> Char
getFirstCharFromReadLine :: Char
getFirstCharFromReadLine = getLine >>= (\line -> getFirstCharFromReadLine(line))

이런 이유로 Haskell에 모나드라는 개념이 추가되었고, Haskell 뿐만이 아닌 Scala, F# 등 다른 함수형 언어들도 가져가 쓰게 됩니다. 즉, 모나드는 함수형 언어에서 함수를 실행했을 때 사이드이펙트가 포함된 결과를 관리하기 위해 도입된 타입에 대한 디자인 패턴이라고 일축할 수 있습니다.

마치며

지금까지 긴 글을 읽어주셔서 감사합니다. 저도 배우면서 쓴 부족한 글이라 오류가 있을지도 모르니, 오타가 보이거나 설명이 부족해 보인다면 댓글을 달아주세요. 작은 댓글이 더 좋은 글을 만드는 큰 원동력이 됩니다.

출처 및 참고자료

Leave a comment