※ 함수형 자바스크립트 라는 책을 한 장씩 리뷰해보도록 하겠습니다.
Chapter 5. 복잡성을 줄이는 디자인 패턴
"널 참조는 10억 달러짜리 실수다"
프로그램 실행 중 여러 케이스 (예외, 네크워크 에러 등) 에 의해 데이터가 null, undefined 로 세팅되면서 생기는 문제를 대비하기 위해 코딩을 하다보면 코드는 점점 복잡해집니다. 이런 복잡성을 해결하기 위해 함수자 (함수 매핑이 가능한 단순 자료형을 생성하는 것) 라는 개념을 보도록 하겠습니다. 다양한 방식으로 에러를 처리하는 로직이 들어 있는 모나드 라는 자료형에 함수자를 적용합니다. (모나드 : 범주론 이란 수학분야에서 비롯된 결과물)
5.1 명령형 에러 처리의 문제점
명령형 코드는 대부분 try - catch 구문으로 예외를 처리합니다.
5.1.1 try - catch 에러 처리
try {
// 예외가 날 가능성 있는 코드
} catch (e) {
// 예외를 처리하는 구문
console.log('에러 : ' + e.message);
}
예외가 날 가능성 있는 코드를 try - catch 블록으로 감싸야 하다보니 다른 함수와 합성 / 체이닝을 할 수 없고 코드 설계시 압박을 받기 때문에, 루프와 조건문을 함수로 추상했던 것처럼 에러 처리도 추상화 할 필요가 있습니다.
5.1.2 함수형 프로그램은 왜 예외를 던지지 않을까?
명령형 자바스크립트 코드에서 예외를 붙잡아 던지는 방법은 단점이 많고 함수형 설계와도 잘 맞지 않습니다. 예외를 던지는 함수의 특징은 다음과 같습니다.
- 다른 함수형 장치처럼 합성이나 체이닝을 할 수 없음
- 예외를 던지는 행위는 함수 호출에서 빠져나갈 구멍을 찾는 것이므로 단일한, 예측 가능한 값을 지향하는 참조 투명성 원리에 위배됨
- 스택이 풀리면 함수 호출 범위를 벗어나 전체 시스템에 영향을 미치는 부수 효과를 일으킴
- 에러를 조치하는 코드가 함수를 호출한 지점과 동떨어져 있어서 비지역성 원리에 위배됨
- catch 블록을 처리하는데 자원 소모가 됨
- 다양한 에러 조건을 처리하는 블록들이 중첩되어 사용하기 어려움
하지만 실제로 함수형 프로그래밍에서 예외를 완전히 없애야 할 필요는 없습니다. 오히려 완전히 없애기란 불가능에 가깝고, 개발자가 어쩔 수 없는 요인들 또한 많기 때문입니다. (라이브러리에서 발생하는 예외라던지..) 오히려 RangeError 처럼 복구 불가능한 상황을 전달하는데 유용한 에러도 있기에 예외를 던지는 경우를 과용하지 않도록 할 것입니다.
5.1.3 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('국가 조회 중 에러 발생!');
}
null 체크가 여럿 들어가있어 코드가 깔끔해 보이지않고, 이 함수는 객체 속성을 얻는 기능이 전부라서 더 간단히 구현될 수 있습니다. 그 방안으로 함수자를 보도록 하겠습니다.
5.2 더 나은 방안 : 함수자
함수형 에러 처리에 있어 잠재적으로 위험한 코드 주위에 안전망 (컨테이너) 을 설치하는 아이디어 입니다.
위험한 코드를 감싼다는 개념은 그대로 가져가되 함수형 프로그래밍에서는 try - catch 블록을 제거할 수 있습니다.
5.2.1 불안전한 값을 감쌈
값을 컨테이너화해서 값을 안전하게 다루고 프로그램의 불변성이 지켜지도록 직접적인 접근을 차단하는 것입니다.
이렇게 감싼 값에 접근하는 유일한 방법은 연산을 컨테이너에 매핑하는것입니다. 먼저 컨테이너를 열고 그 안에 보관된 값에 주어진 함수를 적용한 다음, 그 결과를 동일한 형식의 새 컨테이너에 넣고 닫는, 이런 함수를 함수자 라고 합니다.
Wrapper 라는 자료형을 만들어 구체적으로 보도록 하겠습니다.
class Wrapper {
constructor(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); // 값을 래퍼로 감싸주는 도우미 함수
요점은 에러가 날지 모를 값을 래퍼 객체로 감싸는 것이며, 값에 직접 접근할 수 없으니 값을 얻으려면 identity 함수를 사용해야 합니다. (자료형에 명시적인 get 메서드가 없으므로)
5.2.2 함수자의 세계로
함수자는 값을 래퍼 안으로 승급한 다음 수정하고 다시 래퍼에 넣을 목적을 염두에 둔 함수 매핑이 가능한 자료구조입니다. 이 함수를 일반적으로 정의하면 다음과 같습니다.
- fmap :: (A -> B) -> Wrapper(A) -> Wrapper(B) (Wrapper 는 임의의 컨테이너 형)
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
함수자를 사용해 함수를 체이닝하는 패턴은, map 과 filter 함수로 배열을 다루었던 방식과 같습니다.
- map :: (A -> B) -> Array(A) -> Array(B)
- filter :: (A -> Boolean) -> Array(A) -> Array(A)
map 과 filter 는 형식을 보존하는 함수인 까닭에 체이닝 패턴을 쓸 수 있습니다.
이러한 함수자 역시 몇가지 중요한 전제 조건이 있습니다.
- 부수효과가 없어야 함
- 합성이 가능해야함
함수자는 unll 데이터를 다루지 못하므로 이 자체로는 효용성이 없습니다. 예를 들어 R.compose 로 합성 시 함수 레퍼런스를 null 로 전달하면 동작하지 않습니다. 이는 설계상의 결함이 아니라 일부러 그렇게 만든 것입니다. 함수자는 한 형식의 함수를 다른형식의 함수로 매핑합니다.
구체적인 동작은 모나드 라는 함수형 자료형에서 일어납니다. 모나드는 능률적으로 코드 에러를 처리해 함수 합성을 가능케 하며, 함수자가 건드리는 컨테이너가 바로 모나드 입니다.
제이쿼리 역시 일종의 DOM 모나드 라고 할 수 있습니다.
$(#student-info).fadeIn(3000).text(student.fullname());
위 예시에서 student-info 패널이 없더라도 예외를 던지는 게 아니라 빈 제이쿼리 객체에 메서드를 적용하므로 얌전히 실패합니다. 에러 처리를 겨냥한 모나드는 이처럼 한전하게 에러를 전파해 장애 허용 어플리케이션을 만드는 데 강력합니다.
5.3 모나드를 응용한 함수형 에러 처리
모나드를 함수형 프로그램에 응용하면 앞서 언급한 전통적인 에러 처리의 문제점을 일거에 해소할 수 있습니다.
먼저 함수자 사용의 한계점을 보도록 하겠습니다.
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 모나드 : 제어흐름에서 데이터 흐름으로
특정한 케이스를 특정한 로직에 위임하여 처리할 수 있다는 점을 제외하면 모나드는 함수자와 비슷합니다.
const empty = () => new Empty(); // Empty 라는 컨테이너가 있다고 가정
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
컨테이너 안으로 값을 승급하고 어떤 규칙을 통해 통제한다는 생각으로 자료형을 생성하는 것이 바로 모나드 입니다.
함수자처럼 모나드도 자신의 상대가 어떤 값인지는 전혀 모른채, 일련의 단계로 계산 과정을 서술하는 디자인 패턴입니다. 함수자로 값을 보호하되, 합성을 할 경우 데이터를 안전하고 부수효과 없이 흘리려면 모나드가 필요합니다.
모나드의 두가지 중요한 개념은 아래와 같습니다.
- 모나드 : 모나드 연산을 추상한 인터페이스를 제공
- 모나드형 : 모나드 인터페이스를 실제로 구현한 형식
모나드형마다 연산 체이닝 또는 타 형식의 함수를 중첩시키는 의미는 다르지만, 무릇 모든 모나드형은 다음 인터페이스를 준수해야 합니다.
- 형식 생성자 : 모나드형을 생성 (Wrapper 생성자와 비슷)
- 단위 함수 : 어떤 형식의 값을 모나드에 삽입 (모나드에선 of 라고 함수를 명명)
- 바인드 함수 : 연산을 서로 체이닝 (함수자의 fmap 에 해당하며, flatMap 이라고도 함)
- 조인 연산 : 모나드 자료구조의 계층을 눌러 폄 (flatten)
이러한 인터페이스에 따라 5.2.1 에서 본 Wrapper 클래스를 리팩토링 해보겠습니다.
class Wrapper {
constructor(value) {
this._value = value;
}
static of(a) { // 단위 함수
return new Wrapper(a);
}
map(f) { // 바인드 함수 (함수자)
return Wrapper.of(f(this._value));
}
join() { // 중첩된 계층을 눌려 폄 (flatten)
if (!(this._value instanceof Wrapper)) {
return this;
}
return this._value.join();
}
get() {
return this._value;
}
toString() { // 자료구조를 나타낸 문자열을 반환
return `Wrapper (${this._value})`;
}
}
이를 통해 앞서 래퍼가 중첩됐던 함수자의 문제점을 해결하겠습니다.
// 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(); // 주소
함수형 프로그래밍에서 많이 쓰는 모나드형 몇 가지만 있으면 많은 판박이 코드를 제거하고 많은 일을 해낼 수 있습니다. 그중 유용한 모나드형 몇가지 (Maybe, Either, IO) 를 보도록 하겠습니다.
5.3.2 Maybe 와 Either 모나드로 에러를 처리
모나드는 유효한 값을 감싸기도 하지만 null, undefined 를 모형화할 수도 있습니다. 함수형 프로그래밍에선 Maybe/Either 형으로 에러를 구상화해 아래의 것들을 처리합니다.
- 불순 코드를 격리
- null 체크 로직을 정리
- 예외를 던지지 않음
- 함수 합성을 지원
- 기본값 제공 로직을 한곳에 모음
- null 체크를 Maybe 로 일원화
Maybe 모나드는 Just, Nothing 두 하위형으로 구성된 빈 형식으로서 null 체크 로직을 효과적으로 통합하는 목적을 갖고 있습니다.
- Just (value) : 존재하는 값을 감싼 컨테이너를 나타냄
- Nothing () : 값이 없는 컨테이너, 또는 추가 정보 없이 실패한 컨테이너를 나타냄, Nothing 값에도 함수 적용 가능
이 모나드는 DB 쿼리, 컬렉션에서 값을 검색하거나 서버에 데이터를 요청하는 등 결과가 불확실한 호출을 할 때 자주 씁니다. 아래 예시)
// 결과를 예측할 수 없으니 Maybe 로 감싸고 연산명 앞에 safe 를 prefix 로 붙여서 구분
// 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
Maybe 는 잘못된 데이터를 한곳에서 컨트롤하기 좋은 수단이지만, 에러가 발생하거나 값이 없을 때 Nothing 만 받을 수 있습니다. 실패한 원인까지 받고자 할 땐 Either 모나드를 사용합니다.
- Either 로 실패를 복구
Either 는 절대로 동시에 발생하지 않는 두 값 a, b 를 논리적으로 구분한 자료구조로서, 아래의 두 경우를 모형화한 형식입니다.
- Left (a) : 에러 메시지 또는 예외 객체를 담음
- Right (b) : 성공한 값을 담음
Either 는 어떤 계산 도중 실패할 경우 그 원인에 관한 추가 정보를 결과와 함께 제공할 목적으로 사용합니다. Maybe 모나드에서 본 예시를 변경해보겠습니다.
const safeFindObject = R.curry((db, id) => {
const obj = find(db, id);
if (obj) {
return Either.of(obj);
}
return Either.left(`ID 가 ${id} 인 객체를 찾을 수 없습니다`);
});
const findStudent = safeFindObject(DB('student'));
findStudent('444-44-4444').getOrElse(new Student()); //-> Right(student)
위의 경우엔 데이터가 정상 조회되면 학생 객체는 오른쪽에 저장되고, 그렇지 않으면 에러 메시지가 왼쪽에 담깁니다.
Either 에서 결과값을 얻을 때는 getOrElse 함수를 사용합니다.
지금까지 모나드를 이용해 잠재적으로 위험한 함수 호출을 try - catch 블록으로 감싸는 행위를 대체 해보았습니다. 스칼라 언어에도 이와 개념이 비슷한 Try 라는 형식이 있어서 try - catch 를 함수형으로 대체할 수 있습니다. Try 는 완벽한 모나드는 아니지만, 온전히 계산된 값을 반환하거나 예외가 날지 모를 계산을 표현한 자료구조입니다.
5.3.3 IO 모나드로 외부 자원과 상호작용
const read = (document, selector) => document.querySelector(selector).innerHTML;
// -> read 를 계속 호출하면 그때마다 결과가 달라질 수 있음
const write = (document, selector, val) => {
document.querySelector(selector).innerHTML = val;
return val;
}
// -> 값을 반환하지 않고 변이를 일으킴
이런 단독형 함수를 독립적으로 실행하면 실행 순서에 다라 결과가 바뀌는 등 결과를 장담할 수 없습니다. 부수효과 문제를 바로잡거나 변이를 막을 순 없지만, 적어도 어플리케이션 관점에서 IO 연산이 불변한 것처럼 작동시키기 위해 IO 모나드를 사용합니다.
5.4 모나드 체인 및 합성
모나드는 부수효과를 억제하므로 합성 가능한 자료구조로 활용할 수 있습니다. IO 모나드를 활용한 아래 예시를 보겠습니다.
const lifeIO = function (val) {
return IO.of(val);
};
const getOrElse = R.curry((message, container) => container.getOrElse(message));
const showStudent = R.compose(
map(append('#student-info')),
liftIO,
getOrElse('학생을 찾을 수 없습니다'),
map(csv),
map(R.props(['ssn', 'firstname', 'lastname'])),
chain(findStudent),
chain(checkLengthSsn),
lift(cleanInput)
);
showStudent(studentId).run(); // -> 444-44-4444, Alonzo, Church
위의 코드를 아래의 비함수형 버전과 비교해보면 코드에 큰 차이를 알 수 있습니다.
function showStudent(ssn) {
if (ssn != null) {
ssn = ssn.replace(/^\s*|\-|\s*$/g, '');
if (ssn.length !== 9) {
throw new Error('잘못된 입력입니다');
}
let student = db.get(ssn);
if (student) {
document.querySelector(`#${elementId}`).innerHTML =
`${student.ssn},
${student.firstname},
${student.lastname}`;
} else {
throw new Error('학생을 찾을 수 없습니다');
}
} else {
throw new Error('잘못된 SSN 입니다');
}
}
5.5 Summary
- 객체지향 방식으로 예외를 처리하면 결국 호출자가 try-catch 로직으로 예외를 붙잡아야 하므로 담당해야 할 일이 많음
- 하나의 참조 투명한 프로세스에서 가능한 변이를 모두 감싸는 컨테이너화 패턴을 따르면 부수효과 없이 코드 작성 가능
- 부수효과 없이 불변적으로 객체를 접근, 변경하려면 함수자를 써서 함수를 컨테이너에 매핑
- 모나드는 함수형 프로그래밍의 디자인 패턴으로 함수 간에 데이터가 안전하게 흘러가도록 조정하여 어플리케이션의 복잡도를 낮추는 역할을 함
- Maybe, Either, IO 등의 모나드형을 교차 배치하면 탄력적으로 함수 합성 가능
출처 : 함수형 자바스크립트(책)
'책 리뷰 > 함수형 자바스크립트' 카테고리의 다른 글
함수형 자바스크립트 [7장] (3) | 2020.07.06 |
---|---|
함수형 자바스크립트 [6장] (0) | 2020.06.30 |
함수형 자바스크립트 [4장 - (2)] (0) | 2020.06.22 |
함수형 자바스크립트 [4장 - (1)] (1) | 2020.06.02 |
함수형 자바스크립트 [3장] (0) | 2020.05.25 |