프로그래밍에서 가장 흔히 마주치는 오류는 값이 예상과 달리 존재하지 않을 때 일어납니다. Java에서의 NullPointerException, JavaScript에서의 uncaught reference error와 같은 오류들이 이에 해당하죠. 이를 방지하기 위해, 값이 있을 수도 있고 없을 수도 있는 상황을 안전하고 효과적으로 처리하는 방법이 필요합니다.
Maybe(Optional) 모나드라는 개념이 여기서 등장합니다. 사실 우리는 알게모르게 유사한 개념을 사용해왔을지도 모릅니다. Java에서는 8버전부터 도입 된 Optional class, JavaScript에서는 Optional Chaining이 생각이 나는군요.
Maybe는 값의 존재를 명시적으로 나타내는 타입으로, 값이 존재할 경우(Just)와 존재하지 않을 경우(Nothing)를 구분합니다. 이를 통해 값이 없는 상황을 명확하게 표현하고, null이나 undefined로 인한 오류를 방지할 수 있습니다.
이 글에서는 Maybe 모나드가 어떻게 값의 부재를 안전하게 처리하는 데 도움을 주는지, 그리고 그것을 어떻게 TypeScript에서 구현할 수 있는지 살펴보겠습니다.
Typescript Playground
이 글의 코드가 포함 된 Playground 링크
Maybe는 Just와 Nothing이라는 두 가지 서브 타입을 가집니다.
Just: 값이 존재하는 상태. 실제 값을 포함하며, 이 값을 사용하여 계산이나 처리를 수행할 수 있음
Nothing: 값이 존재하지 않는 상태. 값이 없음을 나타내며, 값의 부재를 안전하게 처리할 수 있음
Maybe가 어떤 식으로 동작하는지 먼저 그림으로 살펴보겠습니다.
우리가 필요한 어떤 값(value)을 상자에 담는 것으로 시작합니다.

숫자 5라는 값이 Just라는 상자에 담겨져 있습니다.
만약에 값이 존재하지 않을 경우엔 그 상태를 우리는 Nothing이라는 빈 상자라고 합시다.

“0” 값을 가지는 것이 아니라 Null이다.
그리고 이제 이 값이 있거나(Just) 없을때(Nothing)의 상황들을 Maybe 라는 큰 상자로 관리합니다.
Maybe(Optional)의 특성 중 하나인 값의 포장(Wrapping Values)입니다.

Wrapping Values
- value를 Just 또는 Nothing으로 Wrapping하고, value가 없을 경우 null이 아닌 비어있는 상자(Nothing)를 참조하게 하여 null-safe한 작업 가능
이제 이 Maybe를 가지고 연산을 해봅시다.

Maybe 상자에 add 2 라는 함수를 적용해 새로운 Maybe 상자를 얻었습니다.
상자안에 값이 있다면 그 값에 add 2 함수가 적용된 값이 들어있고 Nothing box라면 연산이 수행되지 않고 여전히 Nothing box입니다.
Maybe 모나드에 연산을 수행하는 것을 bind라고 합니다.
Maybe 모나드는 bind 메소드를 사용하여 연산을 수행합니다. bind는 Maybe에 포장된 value를 함수에 적용하고, 그 결과를 새로운 Maybe로 반환합니다. 이를 통해 값이 존재할 때만 연산을 수행하고, 값이 없는 경우는 연산을 수행하지 않습니다.
개념적인 부분은 여기까지 알아보고 이제 code로 살펴봅시다.
abstract class Maybe<T> {
abstract bind<U>(func: (value: T) => U): Maybe<U>;
}
Maybe는 bind라는 추상메소드를 가지고 있는 추상클래스로 표현할 수 있습니다.
Just와 Nothing은 Maybe를 상속받아 구현합니다.
그 전에 Maybe를 쉽게 만들기 위해 static 함수를 추가하고..
abstract class Maybe<T> {
abstract bind<U>(func: (value: T) => U): Maybe<U>;
static of<T>(value: T | undefined | null): Maybe<T> {
return (value === undefined || value === null)
? new Nothing() : new Just(value);
}
}
class Just<T> extends Maybe<T> {
constructor(private value: T) {
super();
}
bind<U>(func: (value: T) => U): Maybe<U> {
return Maybe.of(func(this.value));
}
}
Just는 value라는 private 프로퍼티를 가지고 bind는 value에 인자로 받은 func를 적용한 뒤 새로운 Maybe를 리턴합니다.
Nothing은 더 간단합니다.
class Nothing<T> extends Maybe<T> {
bind<U>(func: (value: T) => U): Maybe<U> {
return new Nothing<U>();
}
}
비어있는 상자니까 당연히 value는 없고 bind는 연산 수행 없이 항상 새로운 Nothing을 리턴합니다.
그럼 이제 실제로 Maybe를 활용해봅시다.
먼저 위의 예시에서 사용한 add 2를 적용해보겠습니다.
add 2를 만들고
const add2 = (value: number) => value + 2;
Maybe instance에 bind를 적용하면
const maybeNumber = Maybe.of<number>(5);
const addedNumber = maybeNumber.bind(add2);
console.log(addedNumber); // Just: { "value": 7 }
value에 bind가 적용 된 Just를 얻었습니다.
다음으로 Nothing인 경우
const maybeNumber = Maybe.of<number>(null);
const addedNumber = maybeNumber.bind(add2);
console.log(addedNumber); // Nothing: {}
역시 value가 없는 Nothing을 얻습니다.
Maybe 박스에 값을 넣고 여러 연산을 binding 한 뒤 결국엔 그 값을 꺼내야되겠죠? Maybe에서 값을 얻어내는 match 함수를 만들어보겠습니다.
abstract class Maybe<T> {
abstract bind<U>(func: (value: T) => U): Maybe<U>;
abstract match<R1, R2>(
ifJust: (value: T) => R1,
ifNothing: () => R2,
): R1 | R2
/// 생략
}
그리고 Just와 Nothing에 match를 구현하러 가보죠
class Just<T> extends Maybe<T> {
// 생략
match<R1, R2>(ifJust: (value: T) => R1, ifNothing: () => R2): R1 | R2 {
return ifJust(this.value);
}
}
class Nothing<T> extends Maybe<T> {
// 생략
match<R1, R2>(ifJust: (value: T) => R1, ifNothing: () => R2): R1 | R2 {
return ifNothing();
}
}
만약 Maybe에 value가 존재한다면 ifJust 가 수행될 것이고, 존재하지 않는다면 ifNothing이 수행이 될겁니다.
match를 통해 리턴되는 값은 ifJust나 ifNothing이 무엇을 리턴하는지에 따라 결정 될 것이고요.
그럼 값을 꺼내봅시다.
const getValueMsg = (value: number) => `value is ${value}`;
const getNothingMsg = () => 'There is no value';
그냥 꺼내오면 심심하니까 value의 유무에 따라서 그에 해당하는 메세지를 가지고 옵시다.
const msg1 = Maybe.of<number>(5).bind(add2).bind(double).match(getValueMsg, getNothingMsg);
const msg2 = Maybe.of<number>(null).bind(add2).bind(double).match(getValueMsg, getNothingMsg);
console.log(msg1); // "value is 14"
console.log(msg2); // "There is no value"
이번엔 string에 여러 함수를 bind 시켜봅시다.
const getSecondLetter = (value: string) => value.charAt(1);
const toUpperCase = (value: string) => value.toUpperCase();
string의 2번째 문자를 가지고오는 함수와 대문자로 변환하는 함수입니다.
우리의 목적은 string의 2번째 문자를 대문자로 가지고 오는 것입니다.
Maybe.of<string>('default')
.bind(getSecondLetter)
.bind(toUpperCase)
.match(printValueMsg, printNothingMsg); // "value is E"
Maybe.of<string>(null)
.bind(getSecondLetter)
.bind(toUpperCase)
.match(printValueMsg, printNothingMsg); // "There is no value"
여기까지는 기존과 동일합니다. 하지만 length가 2 미만인 문자열이 들어갔을 때 문제가 발생합니다.
Maybe.of<string>("z")
.bind(getSecondLetter)
.bind(toUpperCase)
.match(printValueMsg, printNothingMsg); // "value is "
문자열의 2번째 문자는 존재하지 않지만 값이 null이 아니기 때문에
"value is "라는 우리가 원하지 않는 결과를 얻었습니다.
우리가 원하는 조건을 충족하지 못했을 때 다른 방식으로 처리하고 싶다면 어떻게 해야할까요?
이럴 때 필요한 도구가 다음 게시글에 소개 할 Either입니다.
이번 글에서는 Maybe Monad와 그것이 어떻게 TypeScript에서 구현될 수 있는지 살펴보았습니다. Maybe는 값이 있거나 없을 수 있는 상황들을 안전하게 처리할 수 있게 해주며, NullPointerException이나 uncaught reference error 같은 일반적인 오류를 피하는 데 큰 도움이 됩니다. 특히 bind와 match 메소드를 통해 값이 존재하는 경우와 존재하지 않는 경우에 대해 일관된 방식으로 처리할 수 있습니다. 이로써 코드의 안정성과 가독성이 향상되며, 오류 처리가 더 명확하고 예측 가능해집니다.
하지만 Maybe만으로는 모든 상황을 처리할 수 없습니다. 특히, 실패의 원인을 더 자세히 표현하거나, 다양한 종류의 결과를 다루기 위해서는 더 복잡한 도구가 필요합니다. 이것이 바로 다음 글에서 다룰 Either 모나드의 역할입니다. Either는 성공(Right)과 실패(Left)의 경우를 더 명확하게 구분하며, 실패의 원인에 대한 정보를 제공하는 데 유용합니다. Maybe에서 한 걸음 더 나아가, Either를 통해 더 복잡한 오류 처리와 조건부 로직을 어떻게 다룰 수 있는지 알아보겠습니다.