개발자 노트

이게 모나드인가? 본문

컴퓨터 언어/함수형 프로그래밍

이게 모나드인가?

jurogrammer 2023. 6. 25. 19:37

참조

도입

이번에는 글의 흐름을 다르게 진행해보려고 합니다.

모나드가 무엇인지 바로 말씀드리지 않고, 연속적으로 함수의 합성할 때 마주하는 문제점을 고민 해보고, 해결 방법을 제시함으로써 모나드(라고 불릴만한 것)을 이해해보도록 하겠습니다.

그래서 먼저 함수가 무엇인지 다시 살펴봄으로써 이해를 높힌 뒤 문제되는 예제를 설명드리겠습니다.

개인적인 견해가 있으므로 참고하시어 읽어주세요. 그래서 제목에서 보시다시피 이게 모나드다!라고 자신있게 말할 순 없겠네요.

함수

함수라고도 불릴 수 있는 것

야밤에 나가서 멍때리다가,, 실생활에서 쓰이는 아래와 같은 표현들이 함수라고 할 수 있겠다. 라는 생각이 들었습니다.

case1) 동사의 과거형

$$
go \overset{과거형}{\longrightarrow} went
$$

$$
run \overset{과거형}{\longrightarrow} ran
$$

동사 go과거형으로 표현하면 went이다.

동사 run과거형으로 표현하면 ran이다.

case2) 브랜치의 이름

이슈 번호 5번은 다음처럼 브랜치 명을 작성해주세요~ feature/issue-5

$$
issue \#5 \overset{브랜치는}{\longrightarrow} feature/issue-5
$$

이슈 번호 10번은 다음 처럼요~ feature/issue-10

$$
issue \#10 \overset{브랜치는}{\longrightarrow} feature/issue-10
$$

어떠신가요? 함수처럼 보이시나요?

특정한 규칙 또는 작업으로 인해 x가 y로 매핑이 되었습니다.

영어에서, 브랜치 이름 짓기에서 그림으로 표현한 방식은 함수를 나타내는 수학적 기호와도 매우 유사합니다.

$$
X \overset{f}{\longrightarrow} Y
$$

이쯤에서 함수의 정의를 살펴보겠습니다.

함수

수학에서 함수(函數, 영어: function) 또는 사상(寫像, 영어: map, mapping)은 어떤 집합의 각 원소를 다른 어떤 집합의 유일한 원소에 대응시키는 이항 관계이다 - 한글위키

수학적으로 엄밀한 정의가 있겠지만,, wiki 위 정의에 따라 case1) case2)는 각각 아래처럼 말할 수 있겠습니다.

case1) 동사의 과거형

집합X: 현재형 동사

집합X의 원소: {go, run}

함수: 현재형 동사를 과거형 동사로 표현

집합 Y: 과거형 동사

집합Y의 원소: {went, ran}

case2) 브랜치의 이름

집합X: 이슈 번호

집합X의 원소: {5, 10}

함수: 이슈 번호를 브랜치의 이름으로 나타내는 방법

집합Y: 브랜치의 이름

집합Y의 원소: {feature/issue-5, feature/issue-10}

함수를 단순하게 말하자면

어떤 놈다른 놈으로 매핑해주는 것

라고 말할 수 있겠습니다!

함수는 이정도로 살펴보도록 하고, 함수를 연속적으로 합성할 때 어떤 문제가 있는 지 알아보겠습니다.

함수 합성시 문제

null 문제

modern java in action에서 언급했던 null의 문제점 일부를 발췌하겠습니다.

11.1.2 Problems with null
null은 타입시스템에 큰 구멍을 만든다.
null은 어떤 타입 또는 다른 정보가 없으므로 모든 reference type에 할당이 가능하다.
이 때문에 다른 시스템에도 null이 전파되어 (…이하 중략)

즉, null은 어떤 reference type에도 할당이 가능하기 때문에 reference type으로 선언된 변수들은 2가지를 가질 수 있게 됩니다.

  1. reference type의 객체
  2. null

reference type의 객체가 있다라 생각하고 method를 호출하는 순간 NullPointException이 발생합니다.

어떻게 함수의 연속된 합성으로 풀어낼 수 있을까요? 예제를 통해 하나씩 살펴보겠습니다. 먼저 javascript로 함수를 보여드리고, java에서 객체지향으로 풀어나가겠습니다.

예제

(설명의 단순화를 위해 undefined와 null은 동일하다 가정하겠습니다.)

문제 설명

이름 사이에 공백이 추가되어 입력이 들어옵니다.

const name1 = "Hong Gil Dong"
const name2 = "Young Hee"

이때, 3번째에 위치한 문자의 글자 수를 구하세요. 만약에 3번째 글자가 없다면 null을 반환하세요.

주어진 함수는 다음과 같습니다.

// 3번째 자리 문자
function getThirdCharacter(name) {
    return name.split(" ")[2];
}

// 글자 수
function getLength(str) {
    return str.length;
}

결과

name1 ⇒ 4

name2 ⇒ null

방법1 절차지향

이를 절차지향 언어로 풀어낸다면 다음과 같습니다.

function solution(name) {
    if(name == null) {
        return null;
    }

    let thirdCharacter = getThirdCharacter(name);

    if (thirdCharacter == null) {
        return null;
    }

    return getLength(thirdCharacter);
}

name이 null이 들어오는 경우도 있을 수 있기 때문에 방어적으로 코드를 짜면 name, 및 각 함수 호출마다 null인지를 체크해야 합니다. 코드가 길어지고, 읽기가 어려워집니다.

함수의 합성으로 간단히 표기해봅시다.

방법2 단순 합성

단순히 함수를 합성한다면 아래와 같습니다.

function solution(name) {
    return getLength(getThirdCharacter(name))
}

하지만, getThiredCharacter 에서 3번째 글자가 없기 때문에 undefined가 반환됩니다. 그래서 아래와 같은 에러가 발생하죠

TypeError: Cannot read properties of undefined (reading 'length')

에러 발생없이, 절차지향 방법과 동일한 결과를 함수의 합성으로 어떻게 표현할 수 있을까요?

새로운 방법

문제는 서로 다른 두 값이 반환된다는 것

방법2가 뭐가 문제였길래, 에러가 발생할 수 밖에 없을까요? 함수가 어떤 타입을 어떤 타입으로 매핑하는 지 살펴봅시다. 앞에 언급한 함수의 표현을 써보겠습니다.

함수 표현

getLength 함수

$$
String \overset{getLength}{\longrightarrow} Number
$$

getThirdCharacter 함수

$$
String \overset{getThirdCharacter}{\longrightarrow} String
$$

이게 기대하는 매핑이겠지만, 사실 다음과 같은 매핑도 존재합니다.

 

$$
String \overset{getThirdCharacter}{\longrightarrow} null
$$

 

따라서 함수를 합성했을 때 다음과 같은 케이스가 생기는 것이죠

함수 합성의 종류

case1 정상적인 함수의 합성

$$
String \overset{getThirdCharacter}{\longrightarrow} String \overset{{getLength}}{\longrightarrow} Number
$$

case2 null이 반환되어 매핑이 안된 경우

$$
String \overset{getThirdCharacter}{\longrightarrow} null \overset{getLength}{\not\longrightarrow} Number
$$

새로운 타입을 정의

함수의 합성 결과 기대하는 타입이 달라서 발생하는 문제라면, 다음처럼 생각해보면 어떨까요?

함수의 합성에서 사용되었던 값인 name, thirdCharacter 를 내포하는 제3의 타입 X를 정의하고, X를 합성했을 때 반드시 X를 반환하는 함수를 정의한다.

$$
X \overset{f}{\longrightarrow} X
$$

이렇게 된다면 에러 발생없이 함수를 연속적으로 합성할 수 있게 됩니다. X를 매핑한 결과가 다시 X가 되니까요.

$$
X \overset{f}{\longrightarrow} X \overset{f}{\longrightarrow} X \overset{f}{\longrightarrow} ... \overset{f}{\longrightarrow}X
$$

이 생각을 적용해보겠습니다.

예제에 적용

타입 정의

앞서 발생했던 이유가 value에 값이 있을수도, 없을수도 있었던 문제였습니다. 따라서 이 value를 감쌀 수 있는 제 3의 타입 X를 정의합니다. 이를 Maybe라고 정의하겠습니다.

class Maybe {
    value

    constructor(value) {
        this.value = value;
    }
}

타입 컨버터 정의

그리고, nameMaybe 로 감쌀 수 있는 함수를 선언하겠습니다. (생성자가 있지만, 함수형 패러다임을 설명하기 위해 함수로 선언합니다.)

function maybeConverter(value) {
    return new Maybe(value);
}

따라서, name은 다음과 같은 형태가 되었다. 생각할 수 있죠

$$
name \overset{maybeConverter}{\longrightarrow} Maybe_{name}
$$

Maybe를 합성할 수 있는 f 정의

Maybe_name에 곧바로 getThirdCharacter 를 적용할 수 없습니다. getThirdCharacter의 정의역 타입은 String이기 때문입니다.

Maybe_name, getThridName

이 두개를 받아 Maybe_thridName으로 다시 변환시켜주는 함수 f를 정의해보겠습니다.

그리고 이 함수를 map이라고 명명하도록 하겠습니다.

$$
(Maybe_{name}, getThridCharacter) \overset{map}{\longrightarrow} Maybe_{thridCharacter}
$$

이 map이란 함수의 역할을 다음과 같이 말할 수 있습니다.

  1. Maybe_name에서 name 값을 가져온다.
  2. Maybe 내 name 값이 있는지 없는지 판단
    1. name 값이 있을 경우 - getThridName을 합성 후, Maybe_thridName을 반환
    2. name 값이 없을 경우 - null을 지닌 Maybe_null 반환

2에서 값이 있으나 없으나 반드시 Maybe를 반환하도록 함으로써, map으로 연속된 함수의 합성을 할 수 있도록 합니다.

function map(maybe, mappingFunction) {
    // test map
    if (maybe.value == null) {
        return maybeConverter(null);
    }

    const newValue = mappingFunction(maybe.value)

    return maybeConverter(newValue)
}

위와 같이 함수를 정의하면 length 또한 추가로 정의할 필요없이 map함수를 이용할 수 있습니다.

$$
(Maybe_{thridCharacter}, getLength) \overset{map}{\longrightarrow} Maybe_{length}
$$

따라서 이를 코드로 나타내면 다음과 같습니다.

function solution(name) {
    let maybeName = maybeConverter(name);
    let maybeThirdCharacter = map(maybeName, getThirdCharacter);
    let maybeLength = map(maybeThirdCharacter, getLength);
    return maybeLength.value;
}

하나의 statement로 표현하면 다음과 같죠.

function solution(name) {
    return map(map(maybeConverter(name), getThirdCharacter), getLength).value;
}

결과는 다음과 같습니다.

const name1 = "Hong Gil Dong"
const name2 = "Young Hee"

console.log(solution(name1)) // 4
console.log(solution(name2)) // null

이렇게 제3의 타입을 정의함으로써 연속된 함수의 합성으로 표현이 가능할 수 있게 됬습니다.

반환이 Maybe인 함수는?

또한, 다음과 같이 반환 타입이 maybe인 케이스도 있습니다.

function getThirdCharacterWithMaybe(name) {
    return maybeConverter(name.split(" ")[2])
}

$$
name \overset{getThirdCharacterWithMaybe}{\longrightarrow} Maybe_{thridCharacter}
$$

이 함수에 대해 map을 적용할 경우 Maybe에 Maybe를 감싸는 케이스가 생깁니다.

$$
(Maybe_{name}, getThirdCharacterWithMaybe) \overset{map}{\longrightarrow} Maybe_{Maybe_{length}}
$$

2번 중첩할 경우, Maybe의 의미가 사라집니다. 값이 있을 수도 없을 수도 있는 타입에 또 값이 있을수도있고 없을 수 있다니… 우리가 원하는 결과는 다음과 같습니다.

$$
(Maybe_{name},getThirdCharacterWithMaybe) \overset{f}{\longrightarrow} Maybe_{length}
$$

앞으로 이 fflatMap이라고 부르겠습니다.

flatMap 역할은 다음과 같습니다.

  1. Maybe에 있는 name의 값을 가져옵니다.
  2. name 값이 있는지 없는지 확인합니다.
    1. name 값이 존재한다면, getThirdCharacterWithMaybe 를 호출하여 얻은 값을 그대로 반환합니다. 호출 결과가 이미 Maybe 이니까요.
    2. name 값이 없다면, 빈 Maybe를 반환합니다.

flatMap을 구현하면 다음과 같습니다.

function flatMap(maybe, maybeReturnFunction) {
    if (maybe.value == null) {
        return maybeConverter(null);
    }

    return maybeReturnFunction(maybe.value);
}

구현한 코드는 아래와 같고, 결과 또한 동일 합니다.

function solution(name) {
    let maybeName = maybeConverter(name);
    let maybeThirdCharacter = flatMap(maybeName, getThirdCharacterWithMaybe);
    let maybeLength = map(maybeThirdCharacter, getLength);
    return maybeLength.value;
}

전체 코드

class Maybe {
    value

    constructor(value) {
        this.value = value;
    }
}

function maybeConverter(value) {
    return new Maybe(value);
}

function map(maybe, mappingFunction) {
    // test map
    if (maybe.value == null) {
        return new Maybe(null);
    }

    const newValue = mappingFunction(maybe.value)

    return maybeConverter(newValue)
}

function flatMap(maybe, maybeReturnFunction) {
    if (maybe.value == null) {
        return maybeConverter(null);
    }

    return maybeReturnFunction(maybe.value);
}

function getThirdCharacter(name) {
    return name.split(" ")[2];
}

function getThirdCharacterWithMaybe(name) {
    return maybeConverter(name.split(" ")[2])
}

function getLength(str) {
    return str.length;
}

// 성공
const name1 = "Hong Gil Dong"
const name2 = "Young Hee"
console.log(solution4(name1)) //4
console.log(solution4(name2)) //null

function solution1(name) {
    if (name == null) {
        return null;
    }

    let thirdCharacter = getThirdCharacter(name);

    if (thirdCharacter == null) {
        return null;
    }

    return getLength(thirdCharacter);
}

function solution2(name) {
    return getLength(getThirdCharacter(name))
}

function solution3(name) {
    let maybeName = maybeConverter(name);
    let maybeThirdCharacter = map(maybeName, getThirdCharacter);
    let maybeLength = map(maybeThirdCharacter, getLength);
    return maybeLength.value;
}

function solution4(name) {
    let maybeName = maybeConverter(name);
    let maybeThirdCharacter = flatMap(maybeName, getThirdCharacterWithMaybe);
    let maybeLength = map(maybeThirdCharacter, getLength);
    return maybeLength.value;
}

자바로 변환하기

앞에선 최대한 함수를 이용하여 간단하게 코드를 구현하려 노력하다보니 어색한 부분도 있었죠. 이제는

  • Maybe
  • MaybeConverter
  • map
  • flatMap

에 대해 충분히 이해했으리라 보고, 객체지향언어에서 어떻게 fluent하게 표현할 수 있는지, 정적 타입 언어에서는 어떻게 표현할 지 코드로 구현해보겠습니다.

Maybe

public class Maybe<T> {
    private final T value;

    public T getValue() {
        return value;
    }

}

Maybe 타입을 선언합니다. 타입이 T인 값을 내포하고 있음을 표현하기 위해 제너릭 파라미터 T를 선언했습니다.

MaybeConverter

public class Maybe<T> {
    private final T value;

  // maybeConverter
    public Maybe(T value) {
        this.value = value;
    }

  // maybeConverter - 편의 메서드
  public static <T> Maybe<T> of(T value) {
        return new Maybe<>(value);
    }
  // maybeConverter - 편의 메서드
    public static <T> Maybe<T> empty() {
        return new Maybe<>(null);
    }

    public T getValue() {
        return value;
    }

}

value를 지닌 Maybe 타입을 생성하기 위해 생성자를 추가합니다.

    public Maybe(T value) {
        this.value = value;
    }

다음 편의 메서드 2개를 추가했습니다. Maybe가 내포한 값은 다음 2가지 유형 중 하나이니까요.

  • value를 지닌 Maybe를 생성하기 위한 of(T)
  • public static <T> Maybe<T> of(T value) { return new Maybe<>(value); }
  • 비어있음을 나타내는 empty()
  • public static <T> Maybe<T> empty() { return new Maybe<>(null); }

map

객체지향 언어에서는 객체의 property로 연산이 필요한 경우 객체의 클래스에 메서드를 선언해줍니다. 이에 따라 map 연산을 Maybe에 추가합니다.

import java.util.function.Function;

public class Maybe<T> {
    private final T value;

    public Maybe(T value) {
        this.value = value;
    }

    public static <T> Maybe<T> empty() {
        return new Maybe<>(null);
    }

    public T getValue() {
        return value;
    }

  // map 추가
    public <R> Maybe<R> map(Function<T, R> mappingFunction) {
        if (value == null) {
            return empty();
        }

        return new Maybe<>(mappingFunction.apply(value));
    }

}

이 코드를 살펴보겠습니다.

public <R> Maybe<R> map(Function<T, R> mappingFunction) {
        if (value == null) {
            return empty();
        }

        return new Maybe<>(mappingFunction.apply(value));
    }

설명을 돕기 위해 보여드렸던 자바스크립트 코드와 함수 표현 가져왔습니다.

$$
name \overset{getThirdCharacter}{\longrightarrow} thirdCharacter
$$

$$
(Maybe_{name}, getThridCharacter) \overset{map}{\longrightarrow} Maybe_{thridCharacter}
$$

function map(maybe, mappingFunction) {
    // test map
    if (maybe.value == null) {
        return new Maybe(null);
    }

    const newValue = mappingFunction(maybe.value)

    return maybeConverter(newValue)
}

파라미터 변화

첫번째 파라미터인 maybe는 Maybe 객체의 프로퍼티에 있기 때문에 map에 mappingFunction만 있으면 됩니다.

mappingFunction 타입

getThirdCharacter가 mappingFunction 입니다. Maybe내 name (T)을 thridCharacter(R)로 매핑합니다.

반환 타입

결과가 Maybe_thirdcharacter 이기 때문에 타입이 Maybe<R>로 변환됩니다.

결국 Maybe의 내부 값 T가 mappingFunction에 의해 다른 R로 변환되고, 다시 Maybe로 반환되었다. 라고 보시면 됩니다.

flatMap

import java.util.function.Function;

public class Maybe<T> {
    private final T value;

    public Maybe(T value) {
        this.value = value;
    }

    public static <T> Maybe<T> empty() {
        return new Maybe<>(null);
    }

    public T getValue() {
        return value;
    }

    public <R> Maybe<R> map(Function<T, R> mappingFunction) {
        if (value == null) {
            return empty();
        }

        return new Maybe<>(mappingFunction.apply(value));
    }

  // flatMap 추가
    public <R> Maybe<R> flatMap(Function<T, Maybe<R>> maybeReturnFunction) {
        if (value == null) {
            return empty();
        }

        return maybeReturnFunction.apply(value);
    }
}

이 코드에서 map과 다른 점은 2가지가 있습니다.

    public <R> Maybe<R> flatMap(Function<T, Maybe<R>> maybeReturnFunction) {
        if (value == null) {
            return empty();
        }

        return maybeReturnFunction.apply(value);
    }

maybeReturnFunction

flatMap에서 내포된 값을 매핑시킬 함수의 반환 타입은 R이 아닌, Maybe 입니다.

반환

maybeReturnFunction이 이미 Maybe를 반환하기 때문에 그대로 반환합니다.

앞서 다룬 자바스크립트와 동일한 내용이며, 타입만 표기했습니다. 참고차 첨부드립니다.

function flatMap(maybe, maybeReturnFunction) {
    if (maybe.value == null) {
        return maybeConverter(null);
    }

    return maybeReturnFunction(maybe.value);
}

그렇다면 동일하게 solution을 작성해보겠습니다.

import java.util.function.Function;

import org.example.maybe.Maybe;

public class Solution {
    public Integer solution3(String name) {
        return Maybe.of(name)
            .map(getThirdCharacter)
            .map(getLength)
            .getValue();
    }

    public Integer solution4(String name) {
        return Maybe.of(name)
            .flatMap(getThirdCharacterWithMaybe)
            .map(getLength)
            .getValue();
    }

    private static final Function<String, String> getThirdCharacter = name -> {
        String[] s = name.split(" ");
        if (s.length < 3) {
            return null;
        }

        return s[2];
    };
    private static final Function<String, Maybe<String>> getThirdCharacterWithMaybe = name -> {
        String[] s = name.split(" ");
        if (s.length < 3) {
            return Maybe.empty();
        }

        return Maybe.of(s[2]);
    };

    private static final Function<String, Integer> getLength = str -> str.length();

}

3번째 글자 반환의 경우, 자바에서는 ArrayIndexOutOfBoundsException 이 발생하기 때문에 null을 반환하도록 코드를 수정했습니다.

이건 테스트 코드이고, 모두 통과되었습니다.

import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

class SolutionTest {

    Solution solution = new Solution();

    @Test
    void solution_3() {
        // 3글자인 경우
        //given
        String name1 = "Hong Gil Dong";

        // when
        Integer result1 = solution.solution3(name1);

        // then
        assertEquals(result1, 4);

        // 2글자인 경우
        //given
        String name2 = "Young Hee";

        // when
        Integer result2 = solution.solution3(name2);

        // then
        assertNull(result2);
    }

    @Test
    void solution_4() {
        // 3글자인 경우
        //given
        String name1 = "Hong Gil Dong";

        // when
        Integer result1 = solution.solution4(name1);

        // then
        assertEquals(result1, 4);

        // 2글자인 경우
        //given
        String name2 = "Young Hee";

        // when
        Integer result2 = solution.solution4(name2);

        // then
        assertNull(result2);
    }

}

눈여겨 보실 점은 객체지향 언어에서는 함수의 합성 대신 메서드 체이닝으로 변한 것을 볼 수 있습니다!

Optional…?

네 맞습니다. 사실 Maybe는 Optional 입니다. Optional을 모나드 중 하나라고 합니다.

모나드 중 하나의 유형이라고 하는 것들은 다음과 같은 것들이 있습니다.

  • javascript의 Promise
  • webfluxd의 flux, mono

값이 없을 수 있지만, 값이 있는 것 처럼 함수를 합성할 수 있게 도와주는 모나드들입니다.

이를 종합해보면 모나드는 다음처럼 말할 수 있는 것 같습니다.

함수의 연속된 합성이 어려운 상황을 극복하기 위해 새로운 타입을 정의하는 방법

그리고 위키의 정의들도 위에 적은 함수들과 어느정도 맞아떨어지는 것 같습니다.

아래는 모나드의 구성요소 이며 위키에서 발췌하였습니다.

  • A type constructor M that builds up a monadic type M T
  • A type converter, often called unit or return, that embeds an object x in the monad:
  • unit : T → M T
  • A combinator, typically called bind (as in binding a variable) and represented with an infix operator >>= or a method called flatMap, that unwraps a monadic variable, then inserts it into a monadic function/expression, resulting in a new monadic value:
  • (>>=) : (M T, T → M U) → M U so if mx : M T and f : T → M U, then (mx >>= f) : M U

마무리

모나드가 무엇인지는 엄밀하게 정의하지는 않았지만, 실용적인 관점에서 충분히 이해되었으리라 생각합니다.

적다보니 설명이 어려워진 것 같기도 하네요. 적절한 예시 및 정확한 설명을 위해 이 글은 계속 다듬어 나가도록 하겠습니다.

반응형

'컴퓨터 언어 > 함수형 프로그래밍' 카테고리의 다른 글

Lambda Expression과 Method reference 차이  (1) 2022.10.13
Stream API 연습들  (1) 2022.10.13
lazy evaluation  (0) 2021.08.08
closure  (0) 2021.08.08
functional하게 decorator pattern 구현  (0) 2021.06.19
Comments