Post

Monad 1 - What's monad?

Monad 1 - What's monad?

Before starting

사실 모나드에 대해선 이미 너무나도 많은 글들이 나와있고, 개중엔 내가 이야기하려는 내용과 크게 다르지 않은 것들도 있을 것이다. 그래도 예전에 내가 다른 곳에서 작성했던 글을 다시 정리하는 겸 해서 쓰는 것이니 필요한 누군가에겐 도움이 되기를…

Prerequisites

함수형 언어는 다양하게 존재하지만 모나드를 설명하는 데에는 하스켈만한 것이 없는데, 이는 하스켈만 오로지 순수한 함수형 언어이기 때문이다. 그래서 이 글에서도 먼저 하스켈의 기본 문법을 딱 필요한만큼만 짚고 넘어가려고 한다.

하스켈은 함수의 타입을 지정할 수 있으며, 당연히 추론 가능한 경우는 이를 생략할 수도 있다. 그리고 인자가 2개 이상인 함수의 경우, 아래처럼 괄호 없이 화살표 체이닝으로 연결한다.

1
2
add :: Int -> Int -> Int
add x y = x + y

또한 하스켈에도 클래스라는 개념이 있다. 하지만 OOP 언어의 그 클래스와는 개념이 많이 다르다. 하스켈의 클래스는 타입 클래스로, 특정 타입이 클래스의 인스턴스가 되려면 그 클래스에서 정의하는 함수들을 지원해야 한다는 제약사항을 나타낸 것이다. 가장 간단한 예시로 아래의 Eq 클래스를 들 수 있다.

1
2
3
4
5
6
class Eq a where
    (==), (/=) :: a -> a -> Bool

    -- Minimal complete definition --
    x /= y = not (x == y)
    x == y = not (x /= y)

즉, 어떤 타입 a가 Eq 클래스로 인정받기 위해서는 (==) 연산자와 (/=) 연산자를 모두 정의해야 하며, 그러한 타입 a를 우리는 Eq 클래스의 인스턴스라고 부를 수 있다.

1
2
3
4
data Foo = Foo {x :: Integer, str :: String}
instance Eq Foo where
    (Foo x1 str1) == (Foo x2 str2) = (x1 == x2) && (str1 == str2)
    (Foo x1 str1) /= (Foo x2 str2) = not ((Foo x1 str1) == (Foo x2 str2))

위와 같이, IntString을 묶은 타입 Foo(==)(/=)Eq 클래스에서 요구하는 대로 잘 정의했으므로 Eq 클래스의 인스턴스가 될 수 있다.

Pure function

이제 잠시 다른 주제로 넘어가보자. 순수 함수(Pure function)이라는 것은 모든 입력이 인자로 선언되고, 역시 모든 출력을 다 리턴하는 함수를 뜻한다. 반대로 어떤 함수가 순수하지 않다는 것은 이 함수는 숨겨진 입력이나 출력이 1개 이상 존재한다는 뜻이며, 이를 side-effect라고 부른다.

1
2
3
4
5
int add (int x)
{
    cin >> a;
    return x + a;
}

이 함수는 add 함수 내부에서 cin을 통해 입력을 받고 있으므로 순수하지 않다. 이 함수를 순수하게 만드려면 숨겨진 입력을 없애야 하므로 다음과 같이 고쳐야 한다.

1
2
3
4
int add(int x, int y)
{
    return x + y;
}

이러면 이제 숨겨진 입출력이 존재하지 않으므로 순수 함수라고 부를 수 있다.

이렇게 순수하게 고쳐서 얻을 수 있는 이득이 뭘까? 가장 큰 이점은 테스트하기 용이하다는 것이다.

순수한 함수는 마치 수학에서 다루는 함수처럼 모든 입력을 넣으면 정해진 출력값이 튀어나오는 블랙박스고, 모든 입력값을 통제할 수 있고 리턴값도 예측이 가능하기 때문에 디버깅 난이도가 상당히 내려간다.

그리고 함수형은 모든 함수를 순수하게 작성하는 것을 지향하는 패러다임이고, 따라서 side-effect가 없게끔 구성하여 모든 코드가 단지 입력과 출력 간의 상관관계만 기술하도록 하는 게 이상적이다.

왜 그래야 할까? 함수형 패러다임은 함수를 이리저리 굴려서 재사용을 많이 하게 되는데, 그렇게 많이 굴리는 함수가 순수하지 않다면 언제 어디서 무슨 문제가 발생할지 모르는 잠재적인 위험성을 갖고 있기 때문이다.

필수불가결한 side-effect

그래서 저렇게 프로그램의 모든 함수를 순수 함수로만 구현할 수 있으면 참 좋겠지만, 안타깝게도 코딩을 해보면 그게 불가능한 경우가 상당히 많다.

가장 쉬운 예시는 로그다. 만약에 어떤 함수 f가 잘 실행되었는지 확인하고 싶으면 어떻게 해야 할까? 디버거로 한줄씩 따라가는게 아니라면 보통 로그를 출력하도록 할 것이다.

그런데 함수형은 여기서 문제가 생긴다. 로그를 출력하는 것도 엄연히 함수에서 수행하는 출력이기 때문에, 순수 함수를 유지하기 위해서는 함수가 바로 콘솔이나 파일에 로그를 쓰는게 아니라 로그로 출력할 그 내용도 리턴해야만 한다.

1
f :: Int -> (Int, String)

그렇기 때문에 단순히 Int를 받아 Int를 리턴해야 하는 함수조차도 위와 같은 형태로 타입을 정의해야 한다.

그러면 이렇게 타입을 선언했으면 모든 문제가 해결됐을까? 안타깝게도 또 그렇지가 않다.

위와 동일하게 Int를 받아 Int를 리턴하도록 설계된 함수인 g가 있다고 해보자. 로그를 같이 신경을 쓴다고 할지라도 본질적으론 Int를 1개 받아 Int를 리턴하는 함수이기에, f로 얻은 값을 g에 바로 집어넣어도 문제가 없어야만 한다.

그런데 로그 때문에 f의 리턴값은 더 이상 Int가 아니라 (Int, String)이 되었고, 따라서 이걸 바로 g에 집어넣을 수가 없게 되었다.

그렇다고 g의 인자를 수정하는건 더 이상한 행동이 되어버린다. 이 함수가 필요한건 결국 Int 1개뿐이고, 그 외의 다른 값들을 받을 이유가 없다.

따라서 fg 사이의 중간다리 역할을 해줄 함수 compose를 하나 더 만들어야 한다는 결론에 이르게 된다.

그럼 이 compose 함수의 인자와 리턴값은 어떻게 될까? Int -> (Int, String) 타입의 함수 f를 인자로 받아야 하고, 그렇게 (compose f)를 계산하면 (Int, String)을 받아 (Int, String)을 리턴하는 함수를 리턴할 것이다.

1
2
3
f :: Int -> (Int, String)
g :: Int -> (Int, String)
compose :: (Int -> (Int, String)) -> ((Int, String) -> (Int, String))

즉, compose 함수의 타입을 위와 같이 정의할 수 있다. 복잡한 형태가 되었지만 결국 위에서 논의한 내용과 동일하다. Int -> (Int, String)을 인자로 받아 (Int, String) -> (Int, String)을 리턴하는 함수이다.

이런 식으로 compose 함수를 구현해두면 동일한 Int -> (Int, String) 타입의 함수 f, g, h, i, …에 대해서 compose f (compose g (compose h (i x)))처럼 계속 합성함수를 이어줄 수 있게 되어 함수형 패러다임을 만족할 수 있게 되었다.

비슷하지만 조금 더 복잡한 예시를 하나 더 보자.

일반적으로 완벽한 랜덤은 구현이 불가능하고 따라서 대부분의 랜덤함수는 시드값을 이용해 의사난수를 구하는 식으로 구현하고 있다. 따라서 이 랜덤함수를 구현하려면 시드값이 필요하다.

함수형 패러다임에서는 순수 함수를 지향하기 때문에 이 시드값도 당연 외부에서 받아와야만 하고, 리턴값도 그 시드값을 포함해야 한다.

1
random :: Seed -> (Int, Seed)

그렇기 때문에 랜덤함수의 형태를 생각해본다면 위와 같이 나올 것이다. 이 함수는 더 이상 숨기고 있는 입출력이 없으므로 순수 함수이다.

이제 이 함수에 한 가지 시나리오를 추가해보자. 만일 저 random 함수가 Int 1개를 같이 받아서 그에 상응하는 난수를 리턴해야 한다면 어떻게 되어야 할까?

1
f :: Int -> Seed -> (Int, Seed)

당연히 같이 받아야 하는 Int도 인자에 포함시켜야 하므로 이 함수의 타입은 위와 같이 바뀌어야 한다.

이제 이 함수를 기반으로, compose 함수를 생각해보면 다음과 같은 형태가 나올 것이다.

1
2
3
f :: Int -> Seed -> (Int, Seed)
g :: Int -> Seed -> (Int, Seed)
compose :: (Int -> Seed -> (Int, Seed)) -> ((Seed -> (Int, Seed)) -> (Seed -> (Int, Seed)))

위의 로그 예시와 동일한 원리로, compose 함수의 타입을 이렇게 정의할 수 있음을 알 수 있을 것이다.

So, what’s monad?

위에서 다룬 고찰들의 결과만 다시 가져와보자.

Int -> (Int, String) 함수의 compose(Int -> (Int, String)) -> ((Int, String) -> (Int String))이며,

Int -> Seed -> (Int, Seed) 함수의 compose(Int -> Seed -> (Int, Seed)) -> ((Seed -> (Int, Seed)) -> (Seed -> (Int, Seed)))이다.

형태가 상당히 유사하지 않은가? 공통 부분을 쉽게 묶어내기 위해 원래 함수를 a -> M a라고 생각해보면, 이에 상응하는 compose 함수는 (a -> M b) -> (M a -> M b)의 형태가 된다. 그리고 이건 (a -> M b) -> M a -> M b의 형태로 풀어 쓸 수 있고, 다시 M a -> (a -> M b) -> M b로 바꿔 쓸 수 있다.

여기서 얻은 두 가지 함수의 의미는 다음과 같다.

  • a -> M a: 데이터 a에 side-effect M을 붙일 수 있음.
  • M a -> (a -> M b) -> M b: side-effect가 붙은 데이터 M a를 동일한 side-effect가 붙은 다른 데이터 M b로 변환할 수 있음.

주의할 점은, 이 M이라는 것은 각각의 함수, 혹은 상황마다 의미가 다를 수 있다는 점이며, 딱히 “데이터”만을 의미하지는 않는다는 점이다. 위의 예시에서만 봐도, 로그의 경우 M은 그저 String일 뿐이지만, 랜덤 함수의 경우 M은 시드값을 받아 랜덤값을 리턴하는 함수 자체를 뜻하게 된다.

이처럼 M의 종류는 매우 다양하게 정의될 수 있지만, 중요한 점은 이 함수가 원래 해야 하는 일 외에 추가적으로 생길 수 밖에 없는 side-effect를 이런 식으로 묶어서 관리할 수 있다는 점이다. 그게 무엇이든지간에.

즉, 순수한 함수형 패러다임을 추구하는 상황에서도,

  • 데이터에 side-effect를 묶어서 같이 관리할 수 있으며, (a -> M a)
  • 그 side-effect를 유지한 채로 다른 함수에 자유롭게 사용할 수 있다. (M a -> (a -> M b) -> M b)

는 것을 알 수 있다. 그리고 이것을 모나드라고 부른다.

마지막에 확인해보는 Definition

이제 모나드의 정의를 한 번 보자. 하스켈에서 모나드는 다음과 같은 클래스로 정의되어 있다.

1
2
3
class Applicative m => Monad M where
    (>>=)   :: M a -> (a -> M b) -> M b
    return  :: a -> M a

여기서 >>= 연산자는 “Bind”라고 부른다.

위에서 side-effect로 분류한 M 자체를 모나드로 취급한다는 점을 제외하면 위에서 고찰한 내용과 동일함을 알 수 있다.

물론 이거만으로는 살짝 부족하고, 실제로는 Functor 및 Applicative functor에 대한 이해도 같이 필요하다. 이는 모나드는 Applicative functor 위에서 정의되고, 이는 다시 Functor 위에서 정의되기 때문이며, 위의 정의에서 Applicative m => Monad m으로 된 것만 봐도 모나드가 Applicative Functor를 상속받음을 알 수 있다.

그렇다 할지라도, 모나드가 무엇이고 왜 필요한 것인지에 대한 이해는 어느 정도 되리라고 생각한다.

흔히 하스켈에서 많이 보이는 Maybe, Try, IO 같은 것들은 자주 쓰일법한 side-effect를 미리 정의한 것에 불과하다.

참고로, F#에서는 모나드 대신 “Computational Expression”이라는 표현을 사용하지만 본질은 동일하다.

This post is licensed under CC BY 4.0 by the author.