모나드가 함수형 프로그래밍에 찰떡인 이유

Posted on July 24, 2022

정보(상태)의 지역화Localize

프로그래밍의 모듈화는 모든 패러다임들의 궁극적인 목표입니다. 코드에 가깝게 이 말을 들여다보면, 상태를 최대한 지역local적이게 만드는 게 목표입니다.

-- 의사 코드
outer(){
  localVal;
  inner1(){
    access localVal;
  }
  inner2(){
    access localVal;
  }
}

outer2(){
  access localVal -- Error
}

위와 같이 변수들의 스코프를 최대한 지역적으로 만들면서 공유하는 방법을 고민합니다. localValouter안에 있는 모든 함수들에선 접근 가능하지만, outer2에서는 접근할 수 없습니다. OOP에서는 outer에 해당하는 것이 오브젝트가 되어 지역적인 상태들을 오브젝트의 속성으로 두면서 모듈화 합니다.

참조 투명성과 불변성

하지만, 함수형에서는 바깥 스코프에 있는 변수에 접근 하는 것이 불가능합니다. 이유는,
첫 째, 순수 함수는 같은 인자가 들어 온다면 오늘 실행해도, 내일 실행해도 항상 같은 결과가 나와야 합니다. 참조 투명성Referential Transparency
둘 째, 값을 변경할 수 있는 변수, 즉 mutable variable을 만들 수 없습니다. 불변성Immutability

※ 비 함수형 언어에서 함수형스럽게 작성하기 위한 라이브러리들은, 언어가 위 두 가지를 강요하지 않지만, 위 두가지 제약이 있는 것처럼 생각하고 풀어나가는 코드로 되어 있습니다.

의례 상태를 저장하는 mutable 변수를 만들 수 없기 때문에 “상태가 없다”라고 말하기도 하지만, 상태는 실용 프로그램을 만들 때는 반드시 있어야 하는 요소입니다. 참조 투명성, 불변성을 지키며 상태를 처리하는 방법은 두 가지가 있습니다.

상태를 저장할 매개 변수를 따로 두기

첫 째, 매개 변수parameter로 모든 상태를 계속 이어지는 함수에 넘기는 방법

func1 (val)
func2 (val)
...

이런 함수들에 모두 매개 변수를 추가합니다.

func1 (context, val)
func2 (context, val)
...

람다 변수

둘 째, 람다 변수에 상태를 넣어서 안쪽에 있는 람다에서는 바깥 쪽 람다 변수에 접근 가능한 걸 이용하는 방법

\a -> \b -> \c -> a.. b.. c..

\c -> a.. b.. c.. 람다 함수는 바깥 람다 함수의 헤드에 있는 a, b에 접근 가능합니다.

변수 자체가 상태를 가질 수 있는 타입으로 정의하기

여기 첫 째 방법을 살짝 변형한 테크닉이 있습니다. 매개 변수를 이용하긴 하지만, 보이지 않게 하는 방법입니다. val이라는 타입에 context를 보관할 곳을 따로 두어 context 매개 변수가 따로 보이지 않게 하는 방법입니다. (실제 사용할 때는 람다 변수도 같이 활용하는 패턴(모나드 제3법칙)을 쓰지만 여기선 잠시, 첫 째 방법에 집중해서 보겠습니다.)

func1 (val :: Int)
func2 (val :: Int)
...
-- 이렇게 정수만 있던 타입에

data IntWithContext context = IntWithContext Int context
func1 (val :: IntWithContext)
func2 (val :: IntWithContext)
...

이렇게 타입 안에 지역적인 정보를 가지고 있을 수 있는 공간을 마련할 수 있습니다.

합성

context를 만들어 내는 함수를 여러번 적용하는 상황을 보면

addContextFunc1 :: Int -> IntWithContext some
addContextFunc2 :: Int -> IntWithContext some
addContextFunc3 :: Int -> IntWithContext some
...
let valWithMultiContext = addContextFunc3 (addContextFunc2 (addContextFunc1 s)) -- error
-- 타입이 달라 위에처럼 계속 적용할 수는 없습니다.

context가 계속 추가되고, 타입은 IntWithContext로 같게 유지하면서 context들을 모두 잃어버리지 않아야 합니다.

컴비네이터

서로 타입이 다르지만, 컴비네이터(조합기 - 일종의 접착 도구)를 써서 합성하는 모양을 만들 수 있습니다. (함수 합성 (.)도 컴비네이터 중 하나일 뿐입니다.)
※ 람다 산법에서 자유 변수가 없는 abstraction을 컴비네이터라 부르는데, 인포멀하게는 뭔가를 조합해서 하나의 큰 덩어리가 만들 때 사용하는 도구들을 컴비네이터라 부릅니다.

combinator :: IntWithContext context -> (context -> IntWithContext) -> IntWithContext context
combinator (IntWithContext intval context) func = {
  -- 첫 번째 인자로 받은 IntWithContext에서 int와 context를 꺼내서
  -- context를 잠시 보관하고, int에 func를 적용해서 새로운 IntWithContext 값을 만듭니다.
  -- 그 후 보관했던 context를 새로운 IntWithContext에 합쳐서 반환합니다.
}

--           아래처럼 context를 갖고 있게
--           바꿔주는 함수가 필요합니다.
--           pure또는 return이라 부르는데
--           여기선 자세히 보지 않겠습니다.
let result = (IntWithContext 0 someContext) `combinator` addContextFunc1
                                            `combinator` addContextFunc2
                                            `combinator` addContextFunc3

IntWithContext에는 context를 넣어 둘 곳이 하나 뿐이 없습니다. 그런데, 두 번의 context가 생기면 어떻게 할까요? 두 번의 context를 합쳐서 하나의 context로 만들어 IntWithContext 타입에 넣습니다. 이 부분이 모나드의 핵심 동작입니다. (이렇게 두 번의 context를 하나의 context로 합칠 수 없으면 모나드로 만들 수 없습니다.)

모나드로 여러가지 effect를 해결할 수 있는데, 여기서는 그 중 참조 투명성, 불변성으로 인해 상태를 유지할 수 없는 문제를 모나드로 해결하는 방법을 다뤘습니다.

정리

모나드로 상태 처리 하기
상태를 상위 스코프의 변수로 두어 유지할 방법이 없는 함수형에선, 상태를 끌고 다닐 방법이 필요합니다. 타입 자체에 context를 위한 공간을 만들고, 컴비네이터가 합성을 하면서 context를 계속 합성해 주는데, 이 때 context 합성이 가능한 구조를 모나드라 합니다. 이렇게 하면, 프로그래머는 컴비네이터에게 상태를 끌고 다니는 것을 맡기고, 크게 신경쓰지 않아도 됩니다.

이 글에선, 모나드의 모든 걸 설명한 게 아니라, 모나드로 상태를 다루는 방법만을 설명했습니다. 모나드는 이해하기도 어렵고, 설명하기도 어렵습니다. 블로그에 몇 개의 모나드 글을 올렸는데, 모나드 이해가 어려워서 반복적으로 공부하듯이 모나드 설명도 반복적으로 하다 보면 늘지 않을까 하는 생각을 합니다. 카테고리 이론과 물려 공부하다보면 시원스럽게 이해하지 못한 것들이 쌓입니다. “내가 알고 있는 것이 과연 모나드의 본질인가?”라는 의심도 계속 들고요. 모나드를 통찰하는 글이 아니라, 이 방향에서 보는 사람도 있구나 정도의 글로 봐주시기 바랍니다.

※ 위 설명에서 나오는 context를 모두 effect로 바꾸어 읽어도 됩니다만, 여기서는 effect중 상태side effect를 유지할 수 있는 패턴이란 것에 집중하도록 effect 대신 context란 용어를 사용했습니다.

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com