Category Archives: Tech

모나드(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) 만쉐이!

 

Clojure 프로젝트 공유하기 (Gorilla REPL)

파이썬에 Jupyter가 있다면, 클로저에는 Gorilla REPL이 있다. 물론 Jupyter에도 클로저 커널을 올려서 사용할 수 있지만, 기본 REPL을 띄워서 연습하는 경우면 몰라도   project.clj에 dependencies나 기타 세팅을 포함한 프로젝트를 테스트하는데는 무리가 있다. 파이썬의 경우에는 virtual env에 세팅을 해버리면 그만이지만 클로저는 프로젝트 세팅을 참조해서 Leiningen REPL을 올려야 하기 때문이다.

물론 동적으로 의존성을 추가할 수 있는 라이브러리도 존재하긴 하지만 여전히 번거로운 작업이고, Jupyter나 Gorilla REPL과 같은 노트북 스타일을 사용하는 목적은 스크립트와 그 실행 결과를 Markdown 기반의 설명과 함께 공유하기 위함인데 노트북에 프로젝트 세팅하는 코드가 난무하는 것도 그리 바람직하지 못하다. 이런 면에서 클로저 프로젝트를 위해선 Jupyter보다는 Gorilla REPL을 사용하는 것이 그 목적에 더 부합하는 것 같다.

Gorilla REPL도 Jupyter와 유사하게 웹 UI상에서 클로저 코드를 실행하고 그 결과를 동적으로 렌더링해주며, Markdown 문법을 사용한 설명을 덧붙여 저장할 수 있는 기능을 제공한다.

이렇게 작성한 결과는 클로저 파일로 저장된다.

웹 UI 상에서 실행했던 코드와 결과들을 Gorilla REPL에서 정의한 notation들이 포함된 주석으로 감싸고 있다. 그런데 앞서 언급했듯이, 노트북 스타일의 결과물은 공유 기능이 상당히 중요한 부분 중 하나인데, Gorilla REPL의 웹 UI와 같이 HTML로 렌더링된 결과물을 공유하고자 할 때는 해당 프로젝트를 통째로 공유한 다음 다른 사용자가 Gorilla REPL을 띄워 확인하도록 해야 하는데 상당히 번거롭다. 공유 대상이 클로저 개발자라면 Gorilla REPL이 저장한 클로저 파일을 공유할 수도 있겠지만, 번잡스러운 주석이 걸리적거리는 것을 감수해야 한다.

Github에 프로젝트를 공개하는 경우 Jupyter 노트북의 저장 결과물인 ‘.ipynb’는 자동으로 렌더링되어 Markdown 형식으로 살펴볼 수 있지만 Gorilla REPL의 결과 클로저 파일은 렌더링되지 않기 때문에 변환과정을 거치는 수 밖에 없다. 다행히도 Gist에 Timothy Renner가 작성해둔 변환 코드가 공개되어 있길래, 이걸 조금 다듬어서 변환 프로젝트를 배포했는데, Gorilla REPL 기반의 프로젝트에 포함된 모든 노트북들을 한 번에 Markdown으로 변환해주는 프로젝트가 있으면 편리할 것 같아 아예 Leiningen 플러그인으로 배포해 보았다.

Gorilla REPL을 사용하는 프로젝트에  lein-gorildown 플러그인을 세팅하고 터미널에서   $ lein gorildown 을 실행해주면 Gorilla REPL 노트북만 골라내어 Markdown으로 변환해준다. 변환된 파일을 Github에 공유하거나 Markdown 뷰어를 사용하여 확인하면 되니 공유하기에도 편리하다.

open-korean-text-4clj 개발 시작

한글 자연어처리 라이브러리 중 관심을 갖고 있던 open-korean-text를 사용해보기 위해 Clojure용 wrapper를 만들어보기로 하고, 드디어 첫 배포를 완료했다.

https://github.com/open-korean-text/open-korean-text-4clj

문서와 테스트도 추가해야 하고, 기능측면에서도 개선/확장해보고 싶은 것이 산적해 있지만, 일단 시작한 것으로 만족.

open-korean-text 자체는 Scala로 개발되어 있고 중요 기능에 대해선 Java Wrapper를 제공하는데, 둘 모두 JVM에 기반하고 있기 때문에 Clojure에서 이들을 적절히 섞어 사용해가면서 Clojure스러운 라이브러리를 개발해가는 것이 목표다.

도전해보자 🙂

Maven으로 Leiningen 프로젝트 패키징하기

클로저 프로젝트를 작성할 때 Leiningen을 사용할 때 느끼는 불편함은 일단 ‘느리다’가 첫번째 직면하게 되는 문제이고, 두번째는 아직 널리 사용되는 프로젝트 관리 모델이 아니다보니 클라우드 배포시에 Leiningen이 지원되지 않을 수 있다는 점이다.

결국 Leiningen이 참조하는 project.clj를 Maven용으로 pom.xml로 변환하는 작업이 필요한데, Leiningen은  lein pom 과 같은 옵션을 제공하고 있다. 그러나 필자가 테스트해본 결과에 의하면 그 결과가 썩 만족스럽지 않다. dependencies를 자동으로 작성해주고, project.clj에  :pom-plugins 를 추가해주면 해당 maven 플러그인 세팅을 해주는 정도의 노위 노가다 대체용 정도가 아니라면 결국 pom.xml을 직접 컨트롤 해줘야 하는 것 같다.

다양한 설정에 대해 테스트해 본 것은 아니지만 필자의 경우를 예로 들어보면, Luminus가 자동으로 생성해 준 project.clj를 대상으로 한   lein pom 결과로 얻은 pom.xml로 패키징( mvn package)을 해보면 (  :pom-plugins 를 설정하거나 pom.xml을 직접 수정하여 추가한 이후) jar파일이 성공적으로 생성되는 듯 하지만 다음과 같은 에러를 뱉어 버린다.

처음에는 executable jar 생성에 많이 사용되는 maven-share-plugin을 사용했는데 플러그인 세팅의 문제로 진입점을 찾지 못하는 것으로 생각하여 여러가지로 시도해보았지만 실패하고, maven-assembly-plugin도 사용하는 방법도 써봤지만 재미를 보지 못하다 결국 방법을 찾았다. (참고링크: http://www.elangocheran.com/blog/2015/12/compiling-a-leiningen-project-from-maven/)

exec-maven-plugin을 사용하여  lein uberjar 를 직접 실행하는 것처럼 강제하는 것. 당연히 배포 서버에는 Leiningen이 설치되어 있지 않은 것을 가정하고 있으니,  dependency에 Leiningen 라이브러리를 추가해주어야 한다.

플러그인 세팅은 다음과 같다.

가까스로 방법을 찾긴 했는데,   lein pom 에서 좀 깔끔하게 지원해줬으면 어땠을까 하는 아쉬움이 남는다.

 

Compress a Sequence – 4Clojure #30

http://www.4clojure.com/problem/30

Difficulty:    Easy
Topics:   seqs

test not run (= (apply str (__ "Leeeeeerrroyyy")) "Leroy")
test not run (= (__ [1 1 2 3 3 2 2 3]) '(1 2 3 2 3))
test not run (= (__ [[1 2] [1 2] [3 4] [1 2]]) '([1 2] [3 4] [1 2]))

 

내 풀이 방법..

  1. 입력할 요소( a)를 확인한 후
  2. 추가대상이면 현재 시퀀스( s)에 추가하고,
  3. 아니면 시퀀스( s)를 그대로 유지
reduce 를 활용한 단순한(?) 방법이다.

일단 머리에 떠오르는대로 알고리즘을 구현한 것인데, 다른 사람들의 솔루션을 확인하고 대충격.

partition-by 라는 충격적인 함수가 있었던 것.

콜렉션의 함수를 인자로 받아서 콜렉션의 각 요소에 적용한 다음, 다른 결과가 나오는 요소들끼리 콜렉션으로 묶어준다. 위 문제의 경우  partition-by 을 적용한 다음 하나씩만 뽑아내면( first 또는  last) 끝.

 

4clojure #26을 풀 때도 그러했지만, ‘클로저가 제공하는 함수들과 그 관점을 얼마나 익숙하게 떠올리고 활용할 수 있는가’ 가 간단한 문제해결에 다가설 수 있는 비결인 것 같다.

 

 

Fibonacci Sequence – 4Clojure #26

http://www.4clojure.com/problem/26

Difficulty:    Easy
Topics:    Fibonacci seqs

Write a function which returns the first X fibonacci numbers.

test not run (= (__ 3) '(1 1 2))
test not run (= (__ 6) '(1 1 2 3 5 8))
test not run (= (__ 8) '(1 1 2 3 5 8 13 21))

 

분명 난이도가 ‘쉬움 (Easy)’인데.. 아직까지도 사고의 전환이 잘 안되어서인지 몰라도 그리 쉽게 풀리지 않았다..

일단 나는 이렇게 풀었다.

아이디어는 다음과 같다.

  1. 벡터에서 마지막 두 숫자를 잡아서 더하고 기존 벡터에 이어붙이는(conj) 함수를 만들자.
  2. 1번의 과정은 [1] 에서 시작하도록 하되, 한 번 수행한 결과를 다시 그 함수에 집어넣자.
  3. 2번의 수행 횟수를 지정하기 위해 다시 함수로 감싸자 (수행 횟수는 이미 [1]로 시작하므로 1회 줄여서 입력)

처음에는 iterate 함수의 존재를 몰랐기 때문에 2번 과정에서 시간이 많이 걸렸다.  reduce 의 경우 아이템 갯수가 정해진 벡터내에서 동작하므로 이 문제를 해결하려면 재귀함수를 별도로 만들어 적용해야할 것 같은데, 더 간단한 방법이 있지 않을까 꽤나 고민을 하다가   iterate 함수가 정확히 그런 용도로 사용되는 것을 발견했다 (ClojureDocs – iterate).

다음으로,   iterate 적용 후에 3번을 수행하는 과정에서도 #(func %) 과 같은 방식의 익명함수를 시도했는데(코드를 조금이라도 더 줄이기 위해), 에러가 나서 실행을 할 수가 없었다.   iterate 에 입력되는 함수에서 인자( %)를 받고 있기는 하지만 이미   iterate 자체가 콜렉션을 리턴하므로 바깥쪽에서 감싸더라도 문제가 없을 것이라 생각했는데 에러가 발생해서 결국 3번과 같은 방식으로 감싸야 했다 (이유는 정확히 모르겠다).

문제를 풀고나면 확인할 수 있는 다른 사람의 풀이도 살펴보자.

maximental 의 경우 ClojureDocs의 iterate에 있는 예제와 동일한 방식을 사용한다. 두 숫자를 포함하는 벡터 [f s] 에서 뒷쪽 숫자인   s 와,   f 와   s 를 더한 숫자로 구성된 벡터를 만들어내는 과정을 반복하면서 각 벡터로부터 첫번째 숫자만을 골라낸다  (map first) .

daowen의 경우는 처음에 생각해보려했던 재귀적인 방식을 사용한다. 재귀 아이디어를 제외하면 이 방식에서의 키 팁은   lazy-seq 를 사용하는 것이다. 그냥   seq 를 사용해버리면 재귀호출에 의해 overflow 가 발생해버린다.