monad란?

Jason Kim·2020년 5월 17일
3

시작에 앞서

모나드를 이해한 개인적인 경험과 의견을 정리하였습니다. 각종 이론적 지식의 전문가가 아니기 때문에 잘못되거나 모호한 설명이 있을 수 있습니다. 그런 부분을 알려주시면 최대한 빠른시일내에 정확한 표현으로 고쳐놓도록 노력하겠습니다.

작성 편의를 위해 이어지는 글은 평서체로 작성되었으며 typescript를 기반으로 설명하였습니다.

준비물

계산이 실패하면 null을 리턴하는 어떤 함수들이 있다고 가정하자. 이 함수들의 구체적인 내용은 중요하지 않다.

function divide10(a): number | null {
  if (a === 0) return null
  return 10 / a
}

function nagativeToPositive(a): number | null {
  if (a < 0) return null
  return -a
}

function sumLimit100(a, b): number | null {
  const c = a + b
  if (c > 100) return null
  return c
}

사용해보기

이 함수들을 사용하는 임의의 함수 calc를 만들어보자.

function calc(x, y, z) {
  const a = divide10(x);
  const b = nagativeToPositive(y);
  const c = sumLimit100(a, b);
  return c;
}

이 함수는 계산의 실패 여부를 판단하지 않고 있기 때문에 기대하지 않았던 예외 발생등으로 인해 버그가 있을 가능성이 있다. 각 계산이 끝날때 마다 결괏값이 null인지 여부를 판단해서 null이 아닐 경우에만 다음 계산을 실행하도록 개선하자.

function calc(x, y, z) {
  const a = divide10(x);
  if (a !== null) {
    const b = nagativeToPositive(y);
    if (b !== null) {
      const c = sumLimit100(a, b);
      if (c !== null) {
      	return c;
      } else {
        return null
      }
    } else {
      return null
  } else {
    return null
  }
}

리팩토링

의도한대로 동작은 되지만 이제는 코드의 흐름이 너무 복잡해졌다. 이러한 코드는 버그가 발생되기도 쉽고 유지보수도 까다로워지기 마련이다. 지금 코드에서 if ... else { return null } 의 패턴을 반복시킨것은 의도적인것이다. 이 코드를 정리할 많은 방법이 있겠지만, 여기서는 이 패턴 자체를 함수로 만들어보도록 하겠다.

function checkNull(context: number | null, fn: (a: number) => number | null) {
  if (context !== null) {
    return fn(context)
  } else {
    return context
  }
}

checkNull 함수는 하나의 값과 이 값이 null이 아닐때 실행할 함수를 받는다. 다른말로 하면 null-check의 맥락을 다루는 함수가 된 것이다. 이제 이 함수를 사용해서 우리의 calc 함수를 수정해보자.

function calc(x, y, z) {
  return
    checkNull(divide10(x), (a) =>
      checkNull(nagativeToPositive(y), (b) =>
        checkNull(sumLimit100(a, b), (c) => {
          return c
        })))
}

number | null은 무엇인가?

지금까지의 코드에서 사용된 number | null은 이 타입을 사용하는 값이 number이외에 다른 추가적인 정보(여기서는 계산의 실패)를 가지고 있음을 암시하는것이다. 이것을 generic을 사용해 더 일반화 한다면 type Maybe<A> = A | null을 사용해서 Maybe<number>와 같이 표현 할 수 있다.

계산의 실패 이외에도 비동기(Promise, Future), 반복(Array, List), 내용이 있는 오류(Either, try-catch)등 무수히 많은 정보들을 이와 같은 방식으로 인코딩 할 수 있다.

프로그래머의 관점에서 보자면 모나드는 원래의 값(number)이외에 추가적인 정보(null)를 포함해서 맥락을 다루는 방식을 공통된 패턴으로 추상화 시켜놓은것이다. 맥락이 없는 값과 함께 사용하거나 서로 다른 맥락들을 조합하는 등의 방식(unit(return), map, kleisli composition, monad transformers 등)들이 마련되어 있으며, 이것을 수학적인 방식(category-theory)을 사용해서 설명하고 증명하고 있는것이다.

다시 checkNull로 돌아가 보자. checkNull이 처리해주는 타입이 Maybe대신 Array라면 0개 이상의 값에 fn을 적용해주고 Promise라면 비동기 계산이 완료되었을때 fn을 적용하는 맥락으로 동작할것이다.

더 일반적으로는 추가적인 정보가 포함된 맥락 내부에서 계산에 사용할 값을 꺼내 주어진 연산(fn)에 전달한다고 말 할 수 있겠다. 주어진 연산이 context의 값에 종속된다는 의미로 checkNull과 같은 함수를 bind라고 부르기도 한다. map을 적용 후 flatten(join) 한다는 의미에서 flatMap이라고도 부른다.

표현식 개선하기

if 문의 반복이 없어진건 좋지만 callback-hell 패턴이 나타난것이 불만스럽다. 이런 상상을 해보자.

checkNull을 사용하는 문법에 편의 기능을 추가해서 checkNull(context, (a) => ...some code...)에서 checkNull;으로 바꾼 후 ,위치로 보내고 (a) =>a =으로 변경하고 맨 앞으로 보내는 등의 재배열을 하면 어떻게 될까?

그러니까 이렇게 해보면? (a = conext; ...some code...)

그리고 이러한 문법이 사용된 함수라고 지정하는 의미에서 함수선언 맨 앞에 do를 붙여보도록 하자.

이 문법으로 calc를 재작성해보면

do function calc(x, y, z) {
  return
    (a = divide10(x);
      checkNull(nagativeToPositive(y), (b) =>
        checkNull(sumLimit100(a, b), (c) => {
          return c
        })))
}

이런 변환 과정을거쳐서 이런 코드가 될 것이며,

do function calc(x, y, z) {
  return
    (a = divide10(x);
      (b = nagativeToPositive(y);
        (c = sumLimit100(a, b); {
          return c
        })))
}

괄호를 없애고 조금 더 정리하면

do function calc(x, y, z) {
  a = divide10(x);
  b = nagativeToPositive(y);
  c = sumLimit100(a, b);
  return c
}

최초의 코드와 같은 형태로 복구가 되었다. checkNull이 ;이 되었기 때문에 원래의 함수와는 다르게 이제 이 함수 내부의 표현식 한 줄이 실행될때마다 자동으로 null체크를 수행하게 되었다.

programmable semicolon

대부분의 언어는 이러한 문법이 지원되지 않지만 하스켈은 nested된 bind 함수 호출(callback-hell)을 imperative-style로 작성이 가능하도록 do-notation을 지원해주고 있다. 이러한 의미에서 표현식사이의 ;이 정해진 맥락을 해석하고 수행하도록 프로그래밍 할 수 있다는 의미에서 모나드를 programmable semicolon이라고 부르기도 한다.

더 생각해보기

callback-hell스타일에 syntax-sugar를 입히는 과정이 익숙하다고 생각하는 분도 있을 것이다. 우리가 준비한 함수가 Maybe대신 Promise를 리턴하고 do를 async로, ;을 await로 부르고 위치를 바꿔준다면

async function calc(x, y, z) {
  a = await divide10(x)
  b = await nagativeToPositive(y)
  c = await sumLimit100(a, b)
  return c
}

우리에게 익숙한 async-await가 되었다. Promise들을 어떻게 하면 쉽게 조합할지 고민해보고 cps/promise/async-await 상호 변환을 통해서 변환 과정들을 관찰해보는것도 흥미로울 수 있다.

같이 보면 좋은 글

https://overcurried.com/3%EB%B6%84%20%EB%AA%A8%EB%82%98%EB%93%9C/

3개의 댓글

comment-user-thumbnail
2021년 8월 1일

좋은 글 공개해주셔서 감사합니다.
Promise의 경우 엄격한 수학적 정의에서 보면 모나드가 아니라고 합니다. 몇가지 따라야 하는 조건(몇가지 지켜야 하는 법칙)들을 모두 만족하지 않아서라 합니다. 하지만, 프로그래밍에서 모나드의 개념을 넓게 본다면, 컨텍스트가 있는 작업들을 체이닝한다는 아이디어와 Promise가 다르지 않기 때문에, 모나드로 부르는 사람들도 있습니다.
저는 배경 지식 부족으로 결국 아이디어정도 이해로 넘어가는 중인데, 모나드 각 법칙들이 어떻게 프로그래밍에 녹아있는지 궁금하긴 합니다. 혹 모나드의 디테일한 부분들이 눈에 들어오신다면 언젠가 추가 포스팅을 해주시면 좋겠습니다. 제 블로그에 jason님의 글을 링크 걸어 두었습니다.

2개의 답글