모나드(monad)와 클로저(clojure)

 

브루스테이트의 세븐 랭귀지를 드디어 다 읽었다. 연습문제들은 그냥 스킵했기 때문에 완독이라는 표현은 양심에 찔리지만, 아무튼 7가지 각 언어들이 갖는 특성을 파악하는데는 큰 도움이 되었다. 여기서 내 관심을 끌었던 언어는 대학원 때 당시 아무 개념없이 수강신청했다가 고생했던 하스켈인데, 여기 모나드에 대한 내용이 언급되어 있어 그 개념 정리를 위해 포스팅해보기로 했다.

이 책에서 설명하기를, 모나드는 세 가지 요소를 갖고 있다고 한다.

  1. 어떤 자료형 컨테이너에 기초한 자료형 생성자
  2. 함수를 둘러싸서 컨테이너에 집어넣는 역할을 수행하는 return 이라는 이름의 함수
  3. 함수를 끄집어내는 >>=  라는 이름의 바인딩 함수

또한 모든 모나드가 만족시켜야 하는 세 가지 규칙을 다음과 같이 언급하고 있다.

  1. 자료형 생성자를 통해 값을 저장할 수 있는 어떤 자료형을 사용하는 모나드를 만들 수 있어야 한다.
  2. 값을 손실하지 않으면서 값을 보관하거나 꺼낼 수 있어야 한다. (즉 monad >>= return = monad )
  3. 바인딩 함수를 중첩하는 것은 그것들을 연속적으로 호출하는 것과 동일한 결과를 낳아야 한다. (즉 (m >>= f >>= g = m >>= (\x -> f x >>= g) )

모나드에 대해 깊이있게 공부한 것이 아니기에 아래 설명이 오류가 있을지도 모르겠지만, 위의 내용이 모나드의 핵심이라고 본다면 그 개념을 이해하는 것이 크게 어렵지는 않은 것 같다.

모나드 개념의 시작은 “함수  f 의 결과를 함수  g 에 전달하여 수행하고 싶다”에서 출발하는 것 같다. 함수형 프로그래밍은 잠시 잊고 생각해본다면, 이를 위해 택할 수 있는 방법은 함수  f 의 결과를 함수 특정 공간에 저장한 다음 함수  g 가 해당 위치에 접근하여 이어나가는 방법을 들 수 있다.

 

 

쉽게 이해할 수 있다시피, 이 방식은 대부분의 프로그래밍 언어들에서 널리 사용되는 방법이다. 즉, 변수 또는 로컬디스크에 값을 저장하고 다시 그 위치에 접근하여 값을 불러온 다음 사용하는 방식이다. 당연히 이 방식의 문제는   f 나   g 외에 다른 함수가 저장 공간에 접근하게 되면   g 의 결과를 장담할 수 없게 되므로 함수형 프로그래밍에서는 권장하지 않는 방식일 뿐더러, 하스켈과 같은 순수 함수형 프로그래밍 언어는 이러한 상태 저장이나 부수 효과 자체를 원천적으로 차단되어 있어 애초에 불가능하다.

그래서 함수형 프로그래밍에서는 저장 공간을 경유하는 대신   f 와   g 를 합성하여 하나의 함수인 것처럼 동작하는, 즉 동일한 입력에 대해 항상 동일한 결과를 리턴하는 함수를 만들어버린다.

 

 

자, 이렇게 하면 함수형 프로그래밍의 개념을 유지하면서도 깔끔하게 해결된 것 같지만 사실 여기에 숨겨져 있는 문제는 ‘  f 와   g 간에 어떻게 정보를 전달할 것인가’이다. 이 문제는 위 그림처럼 단순하지 않은데, 왜냐하면 저마다 정의된 함수의 리턴값 형태(자료형)를 다른 함수에서 활용할 수 있도록 일반화된 방식이 필요하기 때문이다. 즉  f 의 리턴값을   g 가 활용할 수 있어야 한다.

자 이쯤에서, 이 글의 처음에 인용했던 내용을 기반으로 모나드 이해를 위한 사고흐름을 다시 정리해보자.

  1. 함수들 간에 특정 자료형을 처리할 수 있도록 미리 협의(개발자 입장에서는 이렇게 설계)해 두고
  2. 이러한 함수  f 를 감싸는 컨테이너를 만든 다음      ← 모나드
  3. 이 컨테이너를 다음 함수  g 에 전달하면      ← return
  4. 함수  g 는 함수  f 의 결과를 자연스럽게(협의된 자료형에 근거하여) 활용할 수 있게 된다      ← 바인딩 /   >>= 

사고흐름의 1, 2번을 함수  g가 모나드(컨테이너)를 입력받아 모나드를 리턴하는, 즉 모나드로부터 함수를 꺼내어(바인딩) 모나드를 리턴하는 것으로 도식화해보면 다음과 같다 (모나드를 노란상자로 표시):

 

 

다음으로 사고흐름 3, 4번을 해당하는, 즉 함수  g에 함수  f의 결과를 전달하여 수행하는 것을 도식화해보면 위 그림의 입력과 출력을 다음과 같이 바꿔 그릴 수 있다.

 

 

이 때 콘솔 출력과 같은 I/O 작업을 모나드로 처리하면 위의 흐름과 동일하게 함수  g의 결과를 받아 출력할 수 있게 된다 (또는 함수  g를 I/O 작업을 처리하는 함수로 본다면 함수  g는 함수  f를 감싼 모나드를 받아 콘솔에 출력하는 그림을 그려볼 수도 있겠다).

사실 따지고 보면 함수를 first-class citizen으로 취급하는 방식이 ‘자료형’에 의존하고 있기 때문에 발생하는 복잡성이라고 할 수 있다. 특히 하스켈의 경우 견고한 프로그램을 작성을 위해 자료형을 중요하게 생각하는데다 ‘순수’ 함수형 프로그래밍답게 상태변경이나 부수효과를 고집스러울만치 차단하고 있기 때문에 단순한 콘솔 출력도 모나드의 도움을 받아야 한다 (물론 콘솔 I/O와 같이 자주 사용되는 동작들은 내부적으로 모나드가 구현되어 있어 개발자가 일일이 신경 쓸 필요는 없다).

리습이나 클로저의 경우는 좀 다르다. 소스코드 자체가 리스트로 작성되어 있기 때문에 (code is data), 모나드와 같은 컨테이너나 다른 객체 등의 도움이 없이 코드 자체를 다른 함수에 넘겨버리면 그만이기 때문이다. 물론 넘어온 데이터(함수) 자체를 잘 사용해야 할 책임을 받는 쪽에서 지게 되는 단점이 있기는 하지만, 모나드와 같은 복잡한 구조를 고민하지 않아도 되기 때문에 구현에 있어서 사고흐름의 단순함을 유지할 수 있다는 것은 큰 장점이다. 또한 하스켈 등의 함수형 프로그래밍 언어들이 제공하는 함수 합성을 위한 구조들도 당연히 제공되며, 그 구조 자체도 하나의 데이터이기 때문에 상당히 유연한 프로그래밍이 가능하다.

 

결론은..

클로저(Clojure) 만쉐이!