aeba
2018-08-29 04:09:26
0
1712

모나드 튜토리얼


이 글의 라이선스는 CC-BY-SA 4.0입니다. 혹시라도 복제나 다른 곳에서의 사용을 원하신다면 라이선스의 내용에만 따라주시면 됩니다.

네 저도 한번 모나드 튜토리얼을 써 보기로 했습니다. 원래 초짜들은 한번씩 다 써보는거라길래..

전 코드보단 말이 편해서 코드는 최대한 배제하고 말로 풀어서 길게길게 썼습니다.

  • 예시는 초반은 C/자바, 후반은 스칼라스러운 수도코드입니다.
  • 본 글에서 연산자와 함수는 동의어입니다. 사실 연산자는 어느 언어에서건 이름이 기호로 되어있다는 것만 제하면 함수와 하는 일 자체는 다르지 않습니다 (사용자가 정의할 수 있는가 아닌가 정도만 다를 뿐).

엥? 이게 뭐에요?

모나드는 함수형 프로그래밍에서 자주 사용되는 추상화 중 하나입니다. 간단히 용어정리부터 시작하겠습니다.

함수형 프로그래밍순수한 함수만을 사용해서 프로그램을 작성하는 패러다임입니다.

순수한 함수란, 인자와 리턴값을 1대1로 매핑해주는 역할만을 하는 함수입니다. 순수한 함수는 부작용이 없습니다.

부작용이란, 함수 또는 식이 "평가될 때에" 어떤 값으로 치환되는 것 이외에 추가적인 효과를 가지는 것을 의미합니다. 부작용이 있는지 없는지를 판별하려면, 해당 함수 또는 식을 평가한 결과값으로 치환했을때 프로그램의 의미에 변화가 없는지를 보면 됩니다.

함수형 프로그래밍을 하는 이유를 물어보면, 자주 원라이너들 .map(...).filter(...) //... 이 편하고 쉽기 때문이라는 답변을 자주 듣습니다. 그 답변도 틀리지 않습니다. 하지만 함수형 프로그래밍은 부작용이 없는 작은 함수들을 합성해 더 큰 프로그램을 만드는 것을 골자로 하고, 이를 통해 이해하기 쉬우면서 강력한 표현력을 프로그래머에게 제공한다는 것이 더욱 본질적인 이유입니다. 원라이너들은 단순히 그 강력한 표현력을 보여주는 단적인 예시 중 하나일 뿐입니다.

왜?

모나드는 "순차적으로" 실행되는 "효과를 가지는 연산"들을 순수한 함수만으로 표현해내기 위해 필요한 최소한의 구조를 정의한 인터페이스입니다.

말이 어렵다고 느끼실 수 있는데, 우선 아래에 있는 간단한 서브루틴을 봅시다.

int blah() {

  int i = 1;

  i = i + 1;

  return i;

}

프로그램을 분석해 봅시다:

1) 위의 프로그램은 어떤 구문이 어느 순서대로 실행될지 명백하게 정의되어 있습니다. 따라서 순차적입니다.

2) 상태변화라는 효과가 부작용을 통해 발생합니다. 위에서 말했듯이, 순수한 함수와 식은 인자와 결과를 1대1로 매핑하는 것이 다입니다. i = i + 1 에서, 우변은 순수합니다. i + 1을 평가해서 그 결과로 치환하면 i = 2만이 남게 됩니다. i = 2를 평가하면 결과는 2입니다. 하지만 i = 2는 부작용이 있는 식이기 때문에, 프로그램의 의미를 바꾸지 않으면서 i = 22로 치환할 수는 없습니다.


여기서 잠시 우리가 일반쩍으로 짜는 코드들에 대해 생각해 봅시다. 최소한 순차적이라는 점과 효과가 있다는 두 점에 대해서는, 저 예시 코드와 크게 다르지 않을겁니다.

그 이유는, 저 두가지 조건이 뭔가 쓸모있는 프로그램을 만들기 위해서 필수적인 경우가 대부분이기 때문입니다.

순차성

예를 들어서, 제가 어떤 프로그램을 짤 때, 코드가 어느 순서로 실행될지 정의할 수 없다고 생각해 봅시다. 그러한 "제약 조건"을 가지고, 어떤 쓸모있는 프로그램이 가능할까요?

음...

예를 들면,

  • HTML 입력 폼에 입력된 값들을 검증할 때, 각각의 검증은 순차적으로 진행될 필요가 없을 때가 있겠네요.
  • 병합 정렬을 할 때, 분리나 병합할 때 각각의 분리와 병합은 명백히 순서가 정해져 있지 않아도 되겠네요. 물론 분리와 병합 그 자체는 순서가 정해져 있어야 하겠지만요.

여튼 수많은 쓸모있는 프로그램들은 표현 자체가 불가능해진다는 사실은 명백해지는 것 같습니다. 계좌이체를 하는데 잔고를 확인하는 서브루틴이 이체 실행 전에 실행된다는 것이 명백하지 않다면......

효과

효과를 "일반적인 값과는 구별되지만 프로그램을 짤 때 영향을 주는 무언가"라고 생각한다면, 효과가 아예 없는 프로그램이란 상상하기 힘듭니다. 위의 예시에서 본 바로는, "상태의 변화"란 것은 분명히 존재하는 무언가이긴 하지만 값으로 나타내지지는 않았습니다. 효과의 예시로는 재대입(상태변화의 효과), null(값의 부재에 대한 효과), 예외(실패할 수 있는 연산에 대한 효과), 입출력 등을 들 수 있겠네요. 이거 없이 만들 수 있는 프로그램은 순수한 CPU바운드 수학 함수 이외엔 거의 없을거 같습니다.

함수형 프로그래밍의 세계에 존재하는 수많은 추상화들을 제치고 모나드가 단연 튜토리얼 1위, 입문자에게 있어서 공포의 M워드로 불리는 이유는 바로 여기에 있습니다. 함수형 프로그래밍을 시작한 이상, 참조투명성과 순수한 식은 반강제로 사용할 수밖에 없게 되었습니다. 하지만 위에서 설명했듯이, 모나드가 순차적이고 효과를 가지는 프로그램 그 자체라서 이 개념 없이는 뭔가 쓸데있는걸 만드는게 불가능하기 때문에, 일반적으로 첫번째로 마주치는 추상화가 바로 모나드입니다. 모나드가 특별히 어려워서 포기한다기 보단, 첫번째 관문이 모나드이기 때문에 모나드에서 포기하는 사람들이 가장 많을 뿐입니다.

어떻게?

자, 이제 순수한 수학적인 함수만을 가지고 순차성과 효과를 표현하려고 노력해 봅시다.

순차성

순차성은 간단합니다. 함수 합성으로 순차성은 쉽게 표현이 가능합니다.

여기 이 간단한 코드를 보시면, 함수 합성을 통해 f와 g의 처리 순서가 정의되어 있습니다. (스칼라 문법)

x => g(f(x))

이렇게 합성한 함수는 받은 인자로 f를 호출하고, 그 결과로 g를 호출한 다음 그 결과를 리턴합니다. 위의 스타일로 적는건 아무래도 x가 있어야 하니까, 문제의 본질인 "함수의 합성"만을 표현하기 위해 x를 제거해 봅시다. 다음 표기법은 언어만 다를 뿐이지 모두 위에 있는 식과 동등합니다. (이런 표기법을 point-free 스타일이라고 합니다)

  • 파이프 연산자(F#, Elm, 엘릭서 등): f |> g

  • andThen 연산자(스칼라): f andThen g

  • 하스켈: g . f

  • 수학: g ∘ f

  • 주의: f가 리턴하는 타입이 g가 인자로 받는 타입이여만 합성이 가능합니다.

효과

"일반적인 값과는 구별되지만 프로그램을 짤 때 영향을 주는 무언가"... 이건 도대체 어떻게 하죠?

가장 간단한 값의 부재에 대한 효과부터 생각해 봅시다.

int length(String str) {

  return str.length;

}

위의 메소드를 잘 보면 우리는 String을 받아서 int를 내놓는 함수를 정의했습니다. 확실히 위의 함수는 모든 String에 대해서 int를 리턴합니다. 하지만 인자에는 String이 아니라, null이 올 수도 있습니다. null 레퍼런스에 메소드 호출을 실행하게 되면 NullPointerException을 유발하게 됩니다. 분명히 모든 String에 대해 int를 내놓는 함수를 정의했음에도 불구하고 위의 함수는 어떤 입력에 대해서는 예외라는 부작용이 발생하는 코드입니다.

위에서 정의했듯이 함수형 프로그래밍의 순수한 함수라면, 어떤 입력에 대해서도 부작용을 일으키지 않아야 합니다. 하지만 자바 또는 스칼라 등의 비순수한 언어에서 함수형 프로그래밍을 하려면 이에 대해 컴파일러가 체크를 해줄 수 있는 방법이 없기 때문에 이에 대한 대처는 절대로 null을 사용하지 않는다는 팀 내의 규약 또는 합의로 정하게 됩다.

하지만 값의 부재를 나타낸 필요성은 엄연히 존재합니다. 단순히 그 처리가 되지 않아서 부작용이 일어나고, 그 부작용을 제어하기 위해 방어적으로 모든곳에서 null체킹을 하거나, 아니면 정말로 null이 리턴되지 않는지 문서화를 체크하거나 코드를 체크해야 하는 추가적인 노력, 그리고 그 실패에 따른 프로그램의 오작동이 나쁜 것입니다.

이에 대한 해답은, 값에 효과를 부여하는 제네릭 타입의 도입으로 해결할 수 있습니다. 값의 부재라는 효과를 나타내는 제네릭 타입의 유용성은 누구라도 쉽게 알 수 있기 때문에 자바 기본 라이브러리에도 내장되어 있습니다. java.util.Optional<T>가 바로 그것입니다. 따라서 프로그래밍을 할 때, 우리는 위에서 밝혔듯이 코드베이스에서 null의 사용을 금지하고 값이 존재하지 않을 수 있는 경우엔 Optional<T>를 리턴해서 효과의 표현은 가능하게 하되 부작용을 제거할 수 있습니다.

여기에선 너무 길어지기 때문에 다른 효과에 대해서는 더 적지는 않겠습니다만, 예외, 상태변경, 입출력 등 모든 효과는 제네릭 타입으로 추상화할 수 있습니다.

순차성 + 효과

우선은 효과를 가지는 간단한 함수 몇 가지를 합성해 봅시다. 여기서부터 스칼라 비스무리한 언어로 적겠습니다.(바닐라 스칼라로는 표현은 되는데 깔끔하게는 안되고, 하스켈은 깔끔하게는 되는데 문법이 너무 다르고, 자바로는 아예 아무것도 안 됩니다...)

여기선 타입을 왼쪽이 아니라 오른쪽에 적습니다.

자바                                   <->  스칼라(비스무리)
Optional<Byte> parseByte(String str)  <->  val parseByte: String => Optional<Byte>
Byte divideThree(Byte byte)           <->  val divideThree: Byte => Byte
Optional<Byte> multiplyTwo(Byte byte) <->  val multiplyTwo: Byte => Optional<Byte>

그럼 이 세 함수와 위에서 설명한 함수 합성 연산자(andThen)를 가지고 parseByteAndDivideByThree 함수와 parseByteAndMultiplyTwo 함수를 정의해 봅시다. 전자의 경우 parseByte가 실패하면 전체는 빈 값이 될 것이고, 후자는 두 함수 중 하나라도 실패하면 빈 값이 되겠네요.

val parseByteAndDivideByThree = 
  parseByte andThen divideThree

val parseByteAndMultiplyTwo =
  parseByte andThen multiplyTwo

아 잠깐만, 이거 컴파일 안되네요. divideThreemultiplyTwoByte를 인자로 가지는데, parseByteOptional<Byte>를 리턴해요.

컴파일 하려면 (A => B), (B => C) 두 함수를 합성해 (A => C) 함수를 만드는 andThen 이외에, (A => F<B>), (B => C) 같이 생긴 두 함수, 그리고 (A => F<B>), (B => F<C>) 같이 생긴 두 함수를 합성하는 다른 함수합성용 함수들이 필요하다는 걸 알 수 있네요.

그럼 그냥 만듭시다. 첫번째껀 map이라고 이름짓고, 두번째껀 flatMap이라고 이름짓죠.

val parseByteAndDivideByThree: String => Optional<Byte> = 
  parseByte map divideThree

map은 다음과 같은 두 함수를 인자로 받아 하나로 합성해주는 함수입니다.

(A => F<B>) map (B => C) ===> (A => F<C>)

val parseByteAndMultiplyTwo: String => Optional<Byte> =
  parseByte flatMap multiplyTwo

flatMap은 다음과 같은 두 함수를 인자로 받아 하나로 합성해주는 함수입니다.

(A => F<B>) flatMap (B => F<C>) ===> (A => F<C>)

네. 우리는 모나드에 도달했습니다. 정확히 말하면, 어떤 효과를 나타내는 제네릭 타입 F<?>에 대해 효과를 가진 함수 합성용 flatMap 연산자, 그리고 효과가 없는 값 T를 인자로 받아 효과를 부여한 값 F<T>를 리턴하는 pure 연산자가 정의되어 있다면 F<?>는 모나드 인스턴스를 가지고 있다, 또는 모나드 인스턴스가 정의되어 있다고 말합니다.

왜 방금 설명한 mapflatMap이 아니라 pure라는 생소한 함수와 flatMap이냐면, pureflatMap만 가지고 map과 같은 기능을 하게 만들수 있어서, 인터페이스에 map을 따로 구현하게 할 필요가 없기 때문입니다. 예를 들어, Optional에서, pure의 역할을 하는(효과가 없는 값에 효과를 부여해주는) 함수는 Optional.of()입니다. 그렇다면 위의 함수를 pureflatMap만으로 고쳐쓰면 다음과 같이 만들 수 있습니다.

val parseByteAndDivideByThree: String => Optional<Byte> = 
  parseByte flatMap (byte => Optional.of(divideThree(byte)))

(String => Optional<Byte>) flatMap (Byte => Optional<Byte>) ===> String => Optional<Byte>

정리하면, 모나드는 효과를 가진 함수들의 합성에 필요한 두 개의 특별한 함수(flatMap, pure)가 정의되어 있는 인터페이스 입니다. 인터페이스인 만큼 각각의 제네릭 효과 타입마다 구현은 당연히 다릅니다만, 일단 구현이 되어있다면 그 효과를 다루는 함수들은 (리턴값과 다음 함수의 인자의 값 타입이 같다면) 모두 mapflatMap이라는 일관된 API를 통한 합성이 가능해집니다.

잠깐만, map이랑 flatMap함수 모양이 내가 본거랑 다른데요?

일반적으로 map은 이렇게 생겼다고 많이들 알고 있습니다.

(F<A>) map (A => B) ===> (F<B>)

양쪽에 X => 를 더하면 됩니다.

X => (F<A>) map (A => B) ===> X => (F<B>)

둘이 같은 거라는걸 알 수 있군요.

flatMap도 마찬가지입니다.

(F<A>) flatMap (A => F<B>) ===> F<B>

이렇게 많이 알고 계시는데, 양쪽에 X =>를 더하게 되면

X => (F<A>) flatMap (A => F<B>) ===> X => (F<B>)

그래서 이걸 쓰면 좋나요?

전 그렇다고 생각합니다. 일단 모나드 튜토리얼은 여기까지고, 혹시 수요가 있다면 이에 대한 포스트도 생각해 보겠습니다.

읽어주셔서 감사합니다.

미흡한 점이나 질문 댓글로 부탁드립니다.

2
0
  • 댓글 0

  • 로그인을 하시면 댓글을 등록할 수 있습니다.