본문으로 건너뛰기

5장 복잡성을 줄이는 디자인 패턴

이 장의 내용
  • 명령형 에러 처리 체계의 문제점
  • 컨테이너로 잘못된 데이터 접근을 차단
  • 함수자를 자료 변환 도구로 활용
  • 모나드는 합성을 촉진하는 자료형
  • 에러 처리 전략을 모나드형에 통합
  • 모나드형의 교차 배치 및 합성
찰스 앤터니 리처드 호어, 2009년 QCon 발표에서
"널 참조는 (...) 10억 달러짜리 실수다."

함수형 프로그래밍이 수치에 관한 학술적 문제만을 다루는 패러다임이라서 실세계에서 맞닥뜨리는 실패 가능성에 대해선 거의 관심이 없다고 오해하는 사람들이 있습니다. 하지만 최근 수년 동안, 외려 함수형 프로그래밍이 다른 프로그래밍보다 에러를 더 깔끔하게 잘 처리한다는 사실이 밝혀졌습니다.

프로그램 실행중 언제 발생할지 모를 이슈를 대비해 코딩하는데요, 그러다 보니 코드는 어쩔 수 없이 점점 복잡해집니다. 예외를 잡기 위해 많은 시간을 소비하고 더 복잡하게 꼬인 코드만 양산하게 됩니다.

이 장에서는 함수 매핑이 가능한 단순 자료형을 생성하는 함수자(functor)라는 개념을 소개합니다. 다양한 방식으로 에러를 처리하는 로직이 들어 있는 모나드(monad)라는 자료형에 함수자를 적용합니다. 모나드는 범주론이란 수학 분야에서 비롯된 결과물입니다. 이 책에서는 범주론을 다루지 않고 실용적인 부분에만 집중하고자 합니다.

5.1 명령형 에러 처리의 문제점

명령형 코드는 대부분 try-catch 구문으로 예외를 처리합니다.

5.1.1 try-catch 에러 처리

오늘날 자바스크립트 에러 처리 체계는 현대 프로그래밍 언어에서 보편접인 try-catch 구문으로서 예외를 붙잡아 던지는 방식에 기반합니다.

try {
// 예외가 날 가능성 있는 코드
} catch (e) {
// 예외를 처리하는 구문
console.log("에러: " + e.message);
}

안전하지 않은 코드 조각은 둘러싸자는 발상이지요. catch 블록이 프로그램을 잠재적으로 복원할 피난처 구실을 하는 셈입니다.

5.1.2 함수형 프로그램은 왜 예외를 던지지 않을까?

예외를 던지는 함수의 특징은 다음과 같습니다.

  • 다른 함수형 장치처럼 합성이나 체이닝을 할 수 없습니다.
  • 예외를 던지는 행위는 함수 호출에서 빠져나갈 구멍을 찾는 것이므로 단일한, 예측 가능한 값을 지향하는 참조 투명성 원리에 위배됩니다.
  • 예기치 않게 스택이 풀리면 함수 호출 범위를 벗어나 전체 시스템에 영향을 미치는 부수효과를 일으킵니다.
  • 에러를 조치하는 코드가 당초 함수를 호출한 지점과 동떨어져 있어서 비지역성 원리에 위배됩니다. 에러가 나면 함수는 지역 스택과 환경에서 벗어납니다.
  • 함수의 단일 반환값에 신경 써야 할 에너지를, catch 블록을 선언해 특정 예외를 붙잡아 처리하는 데에 낭비하면서 호출자의 부담이 가중됩니다.
  • 다양한 에러 조건을 처리하는 블록들이 중첩되어 사용하기 어렵습니다.

그럼, 함수형 프로그래밍에서는 예외를 완전히 없애야 할까요? 예외를 아주 없애기란 불가능에 가깝고, 개발자가 어쩔 도리가 없는 요인들이 너무 많습니다. 더구나 빌려 쓰는 라이브러리에 자리잡은 예외는 속수무책입니다.

5.1.3 null 체크라는 고질병

뜻밖의 함수 호출이 실패하는 것보다. 차라리 null을 돌려받으면 적어도 함수를 한군데로 흘러가게 할 수는 있습니다. 하지만 나아질 건 조금도 없습니다. 함수가 null을 반환하면 이 함수를 부른 호출자는 성가신 null 체크를 해야하는 부담을 떠안습니다.

function getCountry(student) {
let school = student.getSchool();
if (school !== null) {
let addr = school.getAddress();
if (addr !== null) {
var country = addr.getCountry();
return country;
}
return null;
}
throw new Error("국가 조회 중 에러 발생!");
}

5.2 더 나은 방안: 함수자

함수형 에러 처리는 다른 방법으로 접근해 난관을 해결합니다. 기본 아이디어는 비슷합니다. 잠재적으로 위험한 코드 주위에 안전망을 설치하는 겁니다.

img

함수형 프로그램에서는 위험한 코드를 감싼다는 개념은 그대로 가져가되 try-catch 블록은 제거할 수 있습니다. 이것이 명령형과 가장 큰 차이점입니다. 함수형 자료형을 사용하여 불순함과의 분리를 일급시민으로 만드는 것이지요.

5.2.1 불안전한 값을 감쌈

값을 컨테이너화하는 행위는 함수형 프로그래밍의 기본 디자인 패턴입니다. 값을 안전하게 다루고 프로그램의 불변성이 지켜지도록 직접적인 접근을 차단하는 것입니다. 이렇게 감싼 값에 접근하는 유일한 방법은 연산을 컨테이너에 매핑하는 것입니다. 함수형 자바스크립트에서 맵은 함수 그 이상, 이하도 아닙니다. 모두 참조 투명성에서 출발한 사상으로, 함수는 반드시 동일 입력을 동일 결과에 '매핑'해야 합니다. 이런 점에서 보면, 맵은 (캡슐화한 값을 변환하는) 특정한 동작이 구현된 람다 표현식을 끼워 넣을 수 있는 관문에 해당합니다.

Wrapper라는 단순 자료형을 만들어 개념을 좀 더 구체적으로 알아봅시다.

[코드 5-1] 값을 함수형 자료형으로 감쌈
class Wrapper {
container(value) {
this._value = value;
}

// map :: (A -> B) -> A -> B
map(f) {
return f(this._value);
}

toString() {
return "Wrapper (" + this._value + ")";
}
}

// wrap :: A -> Wrapper(A)
const wrap = (val) => new Wrapper(val);

요점은 에러가 날지 모를 값을 래퍼 객체로 감싼다는 것입니다. 값에 직접 접근할 순 없으니 값을 얻으려면 4장에서 배운 identity 함수를 써야 합니다. 어떤 값이 컨테이너 속으로 들어가면 절대로 값을 직접 조회/변경할 수 없습니다.

const wrappedValue = wrap("Get Functional");
wrappedValue.map(R.identity); // -> 'Get Functional'
wrappedValue.map(console.log); // -> 내부에 들어 있는 값에 함수를 실행합니다.
wrappedValue.map(R.toUpper); // -> 'GET FUNCTIONAL

어떤 콘텍스트로 감싼, 보호된 값을 얻으려면 반드시 어떤 함수를 이 콘텍스트에 적용할 수밖에 없습니다. 직접 함수를 호출하진 못합니다. 그래서 설사 에러가 나더라도 그 뒷일은 구체화한 래퍼 형식에 넘길 수 있지요.

다음은 map을 변형한 fmap 함수입니다.

// fmap :: (A -> B) -> Wrapper[A] -> Wrapper[B]
fmap (f) {
return new Wrapper(f(this._value)); // 변환된 값을 호출부에 반환하기 전에 컴테이너로 감쌉니다.
}

fmap은 주어진 함수를 콘텍스트로 감싼 값에 적용하는 방법이 구현된 함수입니다. 먼저 컨테이너를 열고 그 안에 보관된 값에 주어진 함수를 적용한 다음, 그 결과를 동일한 형식의 새 컨테이너에 넣고 닫는 것으로 마무리하지요. 이런 함수를 함수자라고 합니다.

5.2.2 함수자의 세계로

함수자는 값을 래퍼 안으로 승급한 다음 수정하고 다시 래퍼에 넣을 목적을 염두에 둔 함수 매핑이 가능한 자료구조입니다.

fmap 함수는 함수(A -> B)와 함수자 Wrapper(A)를 받아 새로운 함수자 Wrapper(B)를 반환합니다. 어떻게 반환된 함수자에는 주어진 함수를 값에 적용한 후 다시 래퍼로 감싼 결과가 담겨 있습니다.

2 + 3 = 5 덧셈을 함수자로 풀어볼까요? 일단 add 함수를 커리한 plus3 함수를 만듭니다.

const plus = R.curry((a, b) => a + b);
const plus3 = plus(3);

그리고 숫자 2를 Wrapper 함수자에 넣습니다.

const two = wrap(2);

fmap을 호출해서 컨테이너에 plus3를 매핑하면 두 수가 더해집니다.

const five = two.fmap(plus3); // -> Wrapper(5)
five.map(R.identity); // -> 5

결국 fmap을 실행하면 형식이 동일한 콘텍스트가 하나 더 생성되고 R.identity 함수를 매핑하여 그 값을 빼내는 것입니다. 이 값이 래퍼 밖으로 탈출할 일은 없으므로 각 단계마다 여러 함수를 자유자재로 매핑하여 값을 변환할 수 있습니다.

two.fmap(plus3).fmap(plus10); // -> Wrapper(15)

img

fmap이 같은 형식을 반환하기 때문에, 즉 같은 형식의 컨테이너로 결과를 감싸기 때문에 뒤이어 계속 체이닝을 할 수 있는 것입니다.

[코드 5-2] 주어진 콘텍스트에 추가 로직을 적용하기 위해 함수자를 체이닝
const two = wrap(2);
two.fmap(plus3).fmap(R.tap(infoLogger)); // -> Wrapper(5)

이걸 실행하면 콘솔에 메시지가 출력되죠.

infoLogger [INFO] 5

이런 식으로 함수를 체이닝하는 패턴을 map과 filter 함수로 배열을 다루었던 방식으로 경험했습니다.

map :: (A -> B) -> Array(A) -> Array(B)
filter :: (A -> Boolean) -> Array(A) -> Array(A)

map과 filter는 형식을 보존하는 함수자인 까닭에 체이닝 패턴을 쓸 수 있습니다. 지금껏 줄곧 등장한 compose도 사실 함수자입니다.

함수자 역시 몇 가지 중요한 전제 조건이 있습니다.

  • 부수효과가 없어야 합니다. : 콘텍스트에 R.identity 함수를 매핑하면 동일한 값을 얻습니다. 이는 함수자가 부수효과 없이 감싼 값의 자료구조를 그대로 유지한다는 결정적 증거입니다.
wrap('Get Functional').fmap(R.identity); // -> Wrapper('Get Functional')
  • 합성이 가능해야 합니다. : 합성 함수에 fmap을 적용한 것과 fmap 함수를 함께 체이닝한 것이 동일하다는 뜻입니다. [코드 5-2]를 이런 표현식으로도 쓸 수 있습니다.
two.fmap(R.compose(plus3, R.tap(infoLogger))).map(R.identity); // -> 5

결국 함수자로는 예외를 던지거나, 원소를 바꾸거나, 함수 로직을 변경하는 일 따위는 할 수 없습니다. 콘텍스트를 생성 또는 추상하여 원본값을 바꾸지 않은 상태로 안전하게 값을 꺼내어 연산을 수행하는 것이 함수자의 존재 이유입니다. map 함수가 한 배열을 다른 배열로 변환하면서 원본 배열은 전혀 건드리지 않는 것과 같은 이치라고 볼 수 있죠.

함수자는 한 형식의 함수를 다른 형식의 함수로 매핑합니다. 더 구체적인 동작은 모나드라는 함수형 자료형에서 일어납니다. 모나드는 그 무엇보다 능률적으로 코드 에러를 처리해서 물 흐르듯 매끄럽게 함수 합성을 가능케 합니다. 함수자가 '건드리는'컨테이너가 바로 모나드입니다.

모나드의 주목적은 어떤 자원(단순 값이든, DOM 요소든, 이벤트건, AJAX 호출이건)을 추상하여 그 속에 든 데이터를 안전하게 처리하는 겁니다. 이런 점에서 제이쿼리 역시 일종의 DOM 모나드인 셈입니다.

$('#student-info').fadeIn(3000).text(student.fullname());

fadeIn, text라는 변환 작업을 제이쿼리가 안전하게 담당하므로 이 코드 역시 모나드와 작동원리는 같습니다. 만약 student-info 패널이 없다 해도 예외를 던지는 게 아니라 빈 제이쿼리 객체에 메서드를 적용하므로 얌전하게 실패합니다. 에러 처리를 겨냥한 모나드는 이처럼 안전하게 에러를 전파하여 장애 허용 애플리케이션을 만드는 데 강력한 힘을 발휘합니다.

5.3 모나드를 응용한 함수형 에러 처리

모나드를 함수형 프로그램에 응용하면 앞서 언급한 전통적인 에러 처리의 문제점을 일거에 해소할 수 있습니다. 먼저 함수자 사용의 한계점을 짚고 넘어가겠습니다. 함수자를 쓰면 값에 어떤 함수를 불변/안전하게 적용할 수 있지만, 곳곳에서 남용한다면 금세 난처한 상황에 빠질 수 있다고 말했습니다. SSN으로 학생 레코드를 찾아 주소 속성을 얻는다고 합시다. 이 작업은 크게 findStudent와 getAddress 두 함수로 구분됩니다. 둘 다 함수자 객체를 써서 반환값을 안전한 콘텍스트로 감쌉니다.

const findStudent = R.curry((db, ssn) => 
wrap(find(db, ssn)) // 객체를 발견하지 못할 경우를 대비하여 조회한 객체를 감쌉니다.
);

const getAddress = student =>
wrap(student.fmap(R.prop('address'))); // R.prop() 함수를 객체에 매핑하여 주소를 얻고 그 결과를 다시 감쌉니다.

프로그램 실행은 지금까지 해왔던 대로 두 함수를 합성하여 호출합니다.

const studentAddress = R.compose(
getAddress,
findStudent(DB('student'))
);

에러 처리 코드는 자취를 감췄지만, 실행 결과는 예상과 다릅니다. 실제로 감싼 주소 객체가 아닌, 이중으로 감싼 주소 객체가 반환됩니다.

studentAddress('444-44-4444'); // -> Wrapper(Wrapper(address))

값을 얻으려면 R.identity도 두 번 적용해야겠군요.

studentAddress('444-44-4444').map(R.identity).map(R.identity);

5.3.1 모나드: 제어 흐름에서 데이터 흐름으로

특정한 케이스를 특정한 로직에 위임하여 처리할 수 있다는 점을 제외하면 모나드는 함수자와 비슷합니다.

Wrapper(2).fmap(half); // -> Wrapper(1)
Wrapper(3).fmap(half); // -> Wrapper(1.5)

윗 코드에서 짝수에만 half를 적용하고 싶다고 해보죠. 함수자는 정의상 주어진 함수를 그대로 적용하고 그 결과를 다시 래퍼에 감싸는 일만 할뿐 다른 일은 안 합니다. 그럼 입력값이 홀수인 경우는 어떻게 처리할까요? null을 반환하거나 예외를 던지는 것도 방법이겠지만, 올바른 입력값이 넘어오면 유효한 숫자를, 그렇지 않으면 그냥 무시하게끔 털털하게 일을 시키는 편이 낫습니다.

Wrapper 정신을 계승한 Empty 컨테이너를 작성합시다.

class Empty {
map(f) {
return this;
}

fmap(_) {
return new Empty();
}

toString() {
return 'Empty ()';
}
}

이제 half 코드를 다음과 같이 고치면 짝수만 2로 나눕니다.

const isEven = (n) => Number.isFinite(n) && (n % 2 === 0);
const half = (val) => isEven(val) ? wrap(val / 2) : empty();

half(4); // -> Wrapper(2)
half(3); // -> Empty

half 함수는 입력값에 따라 감싼 값을 반환하거나 빈 컨테이너를 반환합니다.

컨테이너 안으로 값을 승급하고 어떤 규칙을 정해 통제한다는 생각으로 자료형을 생성하는 것이 바로 모나드입니다. 함수자처럼 모나드도 자신의 상대가 어떤 값인지는 전혀 모른 채, 일련의 단계로 계산 과정을 서술하는 디자인 패턴입니다. 함수자로 값을 보호하되, 합성을 할 경우 데이터를 안전하고 부수효과 없이 흘리려면 모나드가 필요합니다.

half(4).fmap(plus3); // -> Wrapper(5)
half(3).fmap(plus3); // -> Empty (잘못된 입력이 넘어와도 컨테이너가 알아서 함수를 매핑합니다.)

다음 두 가지 중요 개념을 이해해야 합니다.

  • 모나드: 모나드 연산을 추상한 인터페이스를 제공합니다.
  • 모나드형(monadic type): 모나드 인터페이스를 실제로 구현한 형식입니다.

모나드형은 이 장 첫부분에서 설명한 Wrapper 객체와 원리는 같지만, 모나드마다 개성이 있습니다. 따라서 모나드 모나드형마다 연산 체이닝 또는 타 형식의 함수를 중첩시키는 의미는 다르지만, 무릇 모든 모나드형은 다음 인터페이스를 준수해야 합니다.

  • 형식 생성자(type constructor): 모나드형을 생성합니다. (Wrapper 생성자와 비슷합니다)
  • 단위 함수(unit function): 어떤 형식의 값을 모나드에 삽입합니다. 방금 전 wrap, empty 함수와 비슷하나, 모나드에서는 of라고 함수를 명명합니다.
  • 바인드 함수(bind function): 연산을 서로 체이닝합니다. (함수자의 fmap에 해당하며, flatMap이라고 합니다.) 지금부터 필자는 편의상 map으로 줄이겠습니다.
  • 조인 연산(join operation): 모나드 자료구조의 계층을 눌러 폅니다. (평탄화) 모나드 반환 함수를 다중 합성할 때 특히 중요합니다.

[코드 5-3]은 이 인터페이스에 따라 Wrapper를 리팩터링한 코드입니다.

[코드 5-3] Wrapper 모나드
class Wrapper {
// type constructor
constructor(value) {
this._value = value;
}

// unit function
static of(a) {
return new Wrapper(a);
}

// bind function
map(f) {
return Wrapper.of(f(this._value));
}

// join operation
join() {
if(!(this._value instanceof Wrapper)) {
return this;
}
return this._value.join();
}

get() {
return this._value;
}

toString() {
return `Wrapper (${this._value})`;
}
}

Wrapper는 데이터를 외부 세계와 완전히 단절시킨 채 부수효과 없이 다루기 위해 함수자로 데이터를 컨테이너 안에 승급합니다. 내용이 궁금하면 _.identity 함수를 매핑해야합니다.

Wrapper.of('Hello Monads!')
.map(R.toUpper)
.map(R.identity); // -> Wrapper('HELLO MONADS!')

여기서 map은 주어진 함수를 매핑하고 컨테이너의 대문을 닫는 일이 전부인 중립 함수라입니다. join은 중첩된 구조를 양파 껍질을 벗기듯 눌러 펴는 함수입니다. 앞서 래퍼가 중첩됐던 함수자의 문제점도 말끔히 해결할 수 있습니다.

[코드 5-4] 모나드를 눌러 폄
// findObject :: DB -> String -> Wrapper
const findObject = R.curry((db, id) => Wrapper.of(find(db, id)));

// getAddress :: Student -> Wrapper
const getAddress = student => Wrapper.of(student.map(R.prop('address')));

const studentAddress = R.compose(getAddress, findObject(DB('student')));

studentAddress('444-44-4444').join().get(); // 주소

[코드 5-4]처럼 합성을 하면 중첩된 래퍼 집합이 반환되는데, join 함수를 적용하면 납작한 단층 구조로 눌러 펴집니다.

Wrapper.of(Wrapper.of(Wrapper.of('Get Functional'))).join();

// -> Wrapper('Get Functional')

img

모나드는 특정한 목적에 맞게 활용하고자 많은 연산을 보유하는 게 보통이라서 이 책에서 제시한 인터페이스는 전체 API의 극히 일부분에 불과한 최소한의 규격입니다. 함수형 프로그래밍에서는 많이 쓰는 모나드형 몇 가지만 있으면 엄청난 판박이 코드를 제거하고 무수히 많은 일을 해낼 수 있습니다.

5.3.2 Maybe와 Either 모나드로 에러를 처리

모나드는 유효한 값을 감싸기도 하지만 값이 없는 상태, 즉 null 이나 undefined를 모형화할 수 있습니다. 함수형 프로그래밍에서는 Maybe/Either형으로 에러를 구상화하여 이런 일들을 처리합니다.

  • 불순 코드를 격리
  • null 체크 로직을 정리
  • 예외르 던지지 않음
  • 함수 합성을 지원
  • 기본값 제공 로직을 한곳에 모음

null 체크를 Maybe로 일원화

Maybe 모나드는 Just, Nothing 두 하위형으로 구성된 빈 형식으로서, 주목적은 null 체크 로직을 효과적으로 통합하는 것입니다.

  • Just(value): 존재하는 값을 감싼 컨테이너를 나타냅니다.
  • Nothing(): 값이 없는 컨테이너, 또는 추가 정보 없이 실패한 컨테이너를 나타냅니다. Nothing 값에도 얼마든지 함수를 적용할 수 있습니다.

[코드 5-5]는 Maybe 모나드 및 그 하위형을 구현한 코드입니다.

[코드 5-5] Maybe 모나드와 그 하위형 Just와 Nothing
class Maybe {
static just(a) {
return new Just(a);
}

static nothing() {
return new Nothing();
}

// 널 허용 형에서 Maybe를 만듭니다(생성자 함수). 모나드에 승급된 값이 null이면 Nothing 인스턴스를 생성하나, 값이 있으면 하위형 Just에 값을 담습니다.
static fromNullable(a) {
return a !== null ? Maybe.just(a) : Maybe.nothing();
}

static of(a) {
return just(a);
}

get isNothing() {
return false;
}

get isJust() {
return false;
}
}
// Just는 값이 있는 경우에 해당하는 하위형입니다.
class Just extends Maybe {
constructor(value) {
super();
this._value = value;
}

get value() {
return this._value;
}
// Just에 함수를 매핑하고 값을 변환 후, 다시 컨테이너에 담습니다.
map(f) {
return Maybe.fromNullable(f(this._value));
}
// 자료구조의 값을 추출합니다.
getOrElse() {
return this._value;
}

filter(f) {
Maybe.fromNullable(f(this._value) ? this._value : null);
}

chain(f) {
return f(this._value);
}

toString() {
return 'Maybe.Nothing';
}
}

class Nothing extends Maybe {
map(f) {
return this;
}

get value() {
throw new TypeError('Nothing 값을 가져올 수 없습니다.');
}

// 자료구조의 값은 무시하고 무조껀 other를 반환합니다.
getOrElse(other) {
return other;
}

// 값이 존재하고 주어진 술어를 만족하면 해당 값이 담긴 Just를 반환하고, 그 외에는 Nothing을 반환합니다.
filter(f) {
return this._value;
}

chain(f) {
return this;
}

toString() {
return 'Maybe.Nothing';
}
}

Maybe는 '널 허용' 값을 다루는 작업을 명시적으로 추상하여 개발자가 중요한 비즈니스 로직에만 전념할 수 있게 합니다. Maybe는 두 실제 모나드형 Just와 Nothing의 추상적인 우산 노릇을 합니다.

모나드는 DB 쿼리, 컬렉션에서 값을 검색하거나 서버에 데이터를 요청하는 등 결과가 불확실한 호출을 할 때 자주 씁니다. 찾는 레코드가 정말 있는지 없는지 예측할 수 없으니 조회 결과를 Maybe로 감싸고 연산명 앞에 safe를 붙여 구분합니다.

// safeFindObject :: DB -> String -> Maybe
const safeFindObject = R.curry((db, id) => Maybe.fromNullable(find(db, id)));

// safeFindStudent :: String -> Maybe(Student)
const safeFindStudent = safeFindObject(DB('student'));

const address = safeFindStudent('444-44-4444').map(R.prop('address'));
address; // -> Just(Address(...)) 또는 Nothing

고맙게도 null 체크는 Maybe.fromNullable이 대신 해주고 safeFindStudent를 호출해서 값이 있으면 Just(Address(...)), 없으면 Nothing이 반환됩니다. 모나드에 R.prop을 매핑하면 고대하던 결과를 얻을 수 있겠죠.

API를 잘못 사용하거나 프로그램 에러가 발생하면 이를 감지하는 역할도 해주니, 잘못된 인수 값의 허용 여부를 나타내는 사전 조건을 강제하는 효과도 있습니다. Maybe.fromNullable에 잘못된 값이 넘어오면 Nothing형을 내므로 get()을 호출해 컨테이너를 열어보려고 하면 예외가 납니다.

TypeError: Can't extract the value of a Nothing
(Nothing 값을 가져올 수 없습니다.)

모나드는 내용물을 직접 추출하는 대신, 이 내용물에 함수를 계속 매핑하리라 전제합니다. Maybe 연산 중 getOrElse는 기본값을 반환하는 멋진 방법입니다. 다음은 값이 있으면 폼 필드 값으로 세팅하고, 없으면 기본 문구를 보여주는 예제입니다.

const userName = findStudent('444-44-4444').map(R.prop('firstname'));

document.querySelector('#student-firstname').value = username.getOrElse('이름을 입력하세요');
Maybe의 다른 이름

자바 8과 스칼라 등의 언어에서는 Maybe를 Optional 또는 Option이라고 합니다. Just, Nothing도 각각 Some, None으로 용어는 달리 쓰지만 의미는 같습니다.

null 체크 안티패턴을 떠올려봅시다.

function getCountry(student) {
let school = student.school();
if (school !== null) {
let addr = school.address();
if(addr !== null) {
return adr.country();
}
}
return '존재하지 않는 국가입니다!';
}

이 함수가 '존재하지 않는 국가입니다!'를 반환하면 과연 어디서 실패했는지 알 수 있을까요? 이런 식으로 코딩하면 스타일, 정확성은 아주 뒤로 미뤄둔 채 방어 코드를 함수 호출 주변에 도배하는 일에 급급하게 될 겁니다. Maybe는 이 모든 로직을 재사용가능한 형태로 캡슐화한 자료구조입니다.

const country = R.compose(getCountry, safeFindStudent);

감싼 학생 객체를 safeFindStudent가 반환하므로 방어 코드를 짜던 습관에서 탈피해 잘못된 값이 넘어와도 안전하게 전파할 수 있습니다. getCountry를 고치면 다음과 같습니다.

const getCountry = (student) => student
.map(R.prop('school'))
.map(R.prop('address'))
.map(R.prop('country'))
.getOrElse('존재하지 않는 국가입니다!'); // 이 단계 중 하나라도 결과가 Nothing이면, 이후 연산은 전부 건너뜁니다.

세 속성 중 하나라도 null이면 에러는 Nothing으로 둔갑하여 모든 계층에 전파되므로 후속 연산은 모두 조용히 건너뜁니다. 이제 선언적이고 우아하면서, 동시에 장애를 허용하는 품격높은 프로그램이 되었습니다.

"함수 승급"
const safeFindObject = R.curry((db, id) => Maybe.fromNullable(find(db, id)));

함수명 앞에 safe를 붙였고 반환값은 직접 모나드로 감쌌습니다. 이처럼 함수가 잠재적으로 위험한 값을 지니고 있을지 모른다는 점을 호출자에게 분명하게 밝히는 건 좋은 습관입니다. 함수 승급이란 기법을 쓰면 어떤 일반 함수라도 컨테이너에서 작동하는 '안전한' 함수로 변신시킬 수 있습니다. 기존 코드를 굳이 바꾸지 않고 쓸 수 있는 편리한 유틸리티입니다.

const lift = R.curry((f, value) => Maybe.fromNullable(value).map(f));

함수 본체 안에서 모나드를 직접 쓰지 않고,

const findObject = R.curry((db, id) => find(db, id));

원래 모습을 그대로 유지한 채 lift를 이용해 함수를 컨테이너로 보내면 됩니다.

const safeFindObject = R.compose(lift(console.log), findObject);
safeFindObject(DB('student'), '444-44-4444');

Either로 실패를 복구

Either는 Maybe와 약간 다릅니다. Either는 절대로 동시에 발생하지 않는 두 값 a, b를 논리적으로 구분한 자료구조로서, 다음 두 경우를 모형화한 형식입니다.

  • Left(a): 에러 메시지 또는 예외 객체를 담습니다.
  • Right(b): 성공한 값을 담습니다.

Either는 오른쪽 피연산자를 중심으로 작동합니다. 그래서 컨테이너에 함수를 매핑하면 항상 하위형 Right(b)에 적용됩니다. Maybe에서 Just로 분기한 거나 마찬가지죠.

보통 Either는 어떤 계산 도중 실패할 경우 그 원인에 관한 추가 정보를 결과와 함께 제공할 목적으로 씁니다. 복구 불가능한 예외가 발생한 경우, 던질 예외 객체를 왼쪽에 두는 것입니다.

[코드 5-6] EitherLeftRight
class Either {
// Either형 생성자 함수. 예외(왼쪽) 또는 정상 값(오른쪽)을 가집니다.
constructor(value) {
this._value = value;
}

get value() {
return this._value;
}

static left(a) {
return new Left(a);
}

static right(a) {
return new Right(a);
}

// 값이 올바르면 Right, 아니면 Left를 취합니다.
static fromNullable(val) {
return val !== null && val !== undefined ? Either.right(val) : Either.left(val);
}

// 주어진 값을 Right에 넣고 새 인스턴스를 만듭니다.
static of(a) {
return Either.right(a);
}
}

class Left extends Either {
map(_) {
return this; // 쓰지 않음
}

// Right 값이 있으면 가져오고, 없으면 TypeError를 냅니다.
get value() {
throw new TypeError('Left(a) 값을 가져올 수 없습니다.');
}

// Right 값이 있으면 가져오고, 없으면 주어진 기본값을 반환합니다.
getOrElse(other) {
return other;
}

// Left 값에 주어진 함수를 적용합니다. Right는 아무 일도 안합니다.
orElse(f) {
return f(this._value);
}

// Right에 함수를 ㅈ거용하고 그 값을 반환합니다. Left는 아무일도 안합니다.
chain(f) {
return this;
}

// Left에서만 주어진 값으로 예외를 던집니다. Right는 예외 없이 그냥 정상 값을 반환합니다.
getOrElseThrow(a) {
throw new Error(a);
}

// 만족하는 값이 존재하면 해당 값이 담긴 Right를 반환하고, 그 외에는 빈 Left를 반환합니다.
filter(f) {
return this;
}

toString() {
return `Either.Left(${this._value})`;
}
}

class Left extends Either {
// Right 값에 함수를 매핑하여 반환합니다.
map(f) {
return Either.of(f(this._value));
}

// Right 값을 얻습니다. 값이 없으면 주어진 기본값 other를 반환합니다.
getOrElse(other) {
return this._value;
}

orElse() {
return this; // 쓰지 않음
}

// Right에 함수를 적용하고 그 값을 반환합니다.
chain(f) {
return f(this._value);
}

// Left에서만 주어진 값으로 예외를 던집니다. Right는 예외 없이 그냥 정상 값을 반환합니다.
getOrElseThrow(_) {
return this._value;
}

filter(f) {
return Either.fromNullable(f(this._value) ? this._value : null);
}

toString() {
return `Either.Right(${this._value})`;
}
}

safeFindObject 함수에 Either 모나드를 응용하면 다음과 같이 작성할 수 있습니다.

const safeFindObject = R.curry((db, id) => {
const obj = find(db, id);
if(obj) {
return Either.of(obj);
}
return Either.left(`ID가 ${id}인 객체를 찾을 수 없습니다.`)
})

데이터가 정상 조회되면 학생 객체는 오른쪽에 저장되고, 그렇지 않으면 에러 메시지가 왼쪽에 담깁니다.

img

Either에서 결괏값을 얻을 때는 getOrElse 함수(값이 없으면 적절한 기본값을 제공)를 씁니다.

const findStudent = safeFindObject(DB('student'));
findStudent('444-44-4444').getOrElse(new Student()); // Right(Student)

Maybe.Nothing과 달리 Either.Left는 함수 적용이 가능한 값을 담을 수 있습니다. 그래서 findStudent가 객체를 반환하지 않으면 orElse 함수를 Left 피연산자에 적용해서 에러 로그를 남길 수 있습니다.

const errorLogger = _.partial(logger, 'console', 'basic', 'MyErrorLogger', 'ERROR');
findStudent('444-44-4444').orElse(errorLogger);
// MyErrorLogger [ERROR] ID가 444-44-4444인 객체를 찾을 수 없습니다.

Either는 예외가 날지 모를, 예측하기 어려운 함수로부터 코드를 보호하기 위해 씁니다. 예외를 퍼뜨리지 않고 일찌감치 없애버려 좀 더 형식에 안전하고 부수효과 없는 함수로 만들자는 것이죠.

자바스크립트의 decodeURLComponent 함수는 인수로 받은 URL이 올바르지 않으면 URIError를 냅니다.

function decode(url) {
try {
const result = decodeURIComponent(url); // URIError를 던집니다.
return Either.of(result);
} catch (uriError) {
return Either.Left(uriError);
}
}

보다시피 에러 메시지와 스택 추적 정보가 포함된 Error 객체를 Either.Left에 채워 넣고 이 객체를 던져 복구 불가능한 연산임을 알리는 식으로 작성합니다.

주어진 URL로 넘어가기 전에 디코딩을 하고 싶다고 합시다.

const parse = (url) => url.parseUrl();
decode('%').map(parse); // Left(Error('URI malformed'))
decode('http%3A%2F%2Fexample.com').map(parse); // -> Right(true)

함수형으로 프로그래밍하면 사실상 예외를 던질 필요 자체가 사라집니다. 대신 이렇게 모나드를 예외 객체를 왼쪽에 담고 느긋하게 예외를 던질 수 있죠.

5.3.3 IO 모나드로 외부 자원과 상호작용