Skip to content

Latest commit

 

History

History
324 lines (250 loc) · 12.2 KB

File metadata and controls

324 lines (250 loc) · 12.2 KB

6. 예외 처리

목차

  • ES6+의 3가지 예외
  • pipe().nullable()
  • pipe().error()
  • pipe().exception() ()
  • 여러가지 달기
  • pipe().error().complete()
  • 다른 컬렉션 조작 함수들과의 조합
  • 비동기를 지원하지 않는 함수에서 발생한 예외 처리 실패
  • 동기/비동기를 함께 지원하는 함수의 필요성
  • 정리

자바스크립트는 런타임에 코드를 해석하는 언어이고, 동적 타입 언어입니다. 동적 타입 언어에서 함수 합성을 안전하게 하려면 어떻게 해야할까요. 하스켈은 대수 구조, 모나드를 통해 순수한 세상을 만들고, Kleisli 화살표와 강력한 타입 시스템에 힘입어 안전한 합성을 가능케 합니다. 하스켈의 Functor, Applicative Functor, Maybe, Either 등은 강력하고 우아합니다. 그런데 자바스크립트에는 모나드도 없고, 타입 시스템이 강력하지도 않고, 컴파일단에서 타입을 검증할 수도 없습니다.

그럼 모나드가 없는 다른 함수형 프로그래밍 언어들은 예외 처리를 어떻게 할까요? 대표적인 함수형 프로그래밍 언어인 클로저와 엘릭서는 모나드가 없습니다. 엘릭서는 강력한 패턴 매칭을 활용하여 예외를 다룹니다. 클로저는 다른 언어들과 비슷한 방식으로 예외를 다룹니다.

어쨌든 모든 함수형 프로그래밍 언어들은 공통적으로 예외를 최대한 만들지 않는 경향을 가지고 있습니다. 이를테면 어떤 함수가 무언가를 찾지 못했다고 에러를 발생시키지는 않습니다. 예로 find, reduce, filter 같은 함수들은 약간 예외적인 상황을 만나더라도 Maybe를 리턴하거나, nil, null, undefined, [], 0 등을 리턴합니다. 그 외에도 많은 함수들이 이러한 전략을 갖습니다. 가능하다면 예외를 만들지 않고, 자연스럽게 흘려보내는 방향으로 프로그래밍을 한다는 점이 특징입니다.

함수형 프로그래밍에서 함수 합성은 가장 중요한 개념 중 하나입니다. 그것이 pipe이든 compose이든 말이죠. 함수 합성에서 가장 중요한 점은 안전한 합성입니다. 그런데 프로그래밍 세상은 안전하지 않습니다. 예외가 발생할 수 있는 세상에서 어떻게 하면 안전하게 함수를 합성할 수 있을까요.

어떤 함수형 언어이든지간에 공통적인 부분은 흘려보내는 아이디어를 가졌다는 것입니다. 예를 들어 (f · g)(x) = f(g(x)) 라고 가정했을 때, g(x) 에서 오류가 나면 (f · g)(x) = g(x) 가 되도록 만드는 것입니다.

이것을 해결하기 위해 하스켈의 모나드를 자바스크립트에 가져오는 방법도 있겠지만, 자바스크립트에서 커스텀 타입은 성능 저하, JSON 직렬화/역직렬화 추가 비용 발생 등의 이슈가 있고, 컴파일단에서 체크해주지 못하는 문제 등으로 인해 그 가치가 떨어집니다. 그렇다면 ES6+에 어울리는 예외 처리 방식은 무엇일까요?

ES6+의 3가지 예외

우선 ES6+ 에서 일어날 예외가 무엇인지 살펴보면 크게 2가지로 에러(throw, Promise.reject), Nullable(null, undefined)가 있습니다. 한 가지 더 있을 수 있는데, 개발자가 직접 정의한 커스텀 에러가 있을 수 있습니다.

  • 에러 - throw, Promise.reject
  • Nullable - null || undefined
  • 개발자 정의 에러

pipe().nullable()

pipe 함수는 pipe()()와 같이 실행하면 아무런 예외 처리를 하지 않습니다. 들어온 모든 함수가 순수 함수일 것이라고 가정합니다.

pipe().nullable()와 같이 실행하면 함수들이 실행되는 과정에서 undefinednull을 만났을 때 함수 실행을 중단하고 nullable()을 정의한 곳으로 이동합니다.

const f0 = pipe(
  a => a + 10, // <--- null 온 경우 실행되지 않음
  a => a + 100 // <--- null 온 경우 실행되지 않음
).nullable();

console.log( f0(1) ); // 111
console.log( f0(null) ); // null

const f1 = pipe(
  a => a + 10, // <--- null 온 경우 실행되지 않음
  a => a + 100 // <--- null 온 경우 실행되지 않음
).nullable(
  _ => '이런!'
);

console.log(f1(1)); // 111
console.log(f1(null)); // 이런!

const f2 = pipe(
  a => a + 10,
  a => null,
  a => a + 100 // <--- null 온 경우 실행되지 않음
).nullable(
  _ => '이런!'
);

console.log(f2(1)); // 이런!

비동기적으로 결과가 내려와도 동일하게 처리됩니다.

const log = then(console.log);

const f3 = pipe(
  a => a + 10,
  a => Promise.resolve(null),
  a => a + 100 // <--- null 온 경우 실행되지 않음
).nullable(
  _ => '이런!'
);

log(f3(1)); // 이런!

pipe().error()

pipe().error()와 같이 실행하면 함수들이 실행되는 과정에서 에러가 던져졌을 때 함수 실행을 중단하고 error()를 정의한 곳으로 이동합니다. 역시 동기/비동기 에러 모두 검출합니다.

const f4 = pipe(
  a => a + 10,
  a => asdasd123asdasd,
  a => a + 100 // <-- 오지 않습니다.
).error(log); // ReferenceError: asdasd123asdasd is not defined

log(f4(1)); // undefined 리턴되고, error로 가서 에러 출력

const f5 = pipe(
  a => a + 10,
  a => { throw '던져!' },
  a => a + 100 // <-- 오지 않습니다.
).error(log); // 던져!

log(f5(1)); // undefined 리턴되고, error로 가서 던져! 출력

const f6 = pipe(
  a => a + 10,
  a => Promise.reject('리젝!'),
  a => a + 100 // <-- 오지 않습니다.
).error(log); // 리젝!

log(f6(1)); // undefined 리턴되고, error로 가서 리젝! 출력

pipe().exception() ()

pipe().exception() ()과 같이 실행하면 함수들이 실행되는 과정에서 사용자 정의 예외를 리턴했을 때 함수 실행을 중단하고 exception() ()을 정의한 곳으로 이동합니다.

const isNegativeNumber = a => a < 0;

const f7 = pipe(
  a => a - 10,
  a => a + 100
).exception(isNegativeNumber) (
  _ => console.log('중간에 빠져나옴')
);

console.log( f7(10) ); // 100
console.log( f7(5) ); // // undefined 리턴되고, exception으로 가서 중간에 빠져나옴 출력

const f8 = pipe(
  a => ({ status: a }),
  a => a + 100
).exception(e => e && e.status == 1) (
  e => console.log('e 1')
).exception(e => e && e.status == 2) (
  e => console.log('e 2')
);

f8(1); // e 1
f8(2); // e 2

여러가지 달기

const f9 = pipe(
  a => ({ status: a }),
  a => Promise.reject('reject!'),
).exception(e => e && e.status == 10) (
  e => console.log('e 10')
).error(
  e => console.log(e)
).nullable(
  e => console.log('nullable~')
);

f9(10); // e 10
f9(null); // nullable~
f9(20); // reject!

error 부분에서 더 복잡하게 분기를 하고자 한다면 앞서 소개했던 match 함수를 조합할 수 있습니다.

pipe(
  a => asdasdasd,
  a => 10
)
.error(e => match(e)
  .case(e => e instanceof ReferenceError) (
    _ => console.log('ref 에러!')
  ).else (
    _ => console.log('에러!')
  )
) ();
// ref 에러!

pipe(
  a => { throw 'ho' },
  a => 10
)
.error(e => match(e)
  .case(e => e instanceof ReferenceError) (
    _ => console.log('ref 에러!')
  ).else (
    _ => console.log('에러!')
  )
) ();
// 에러!

pipe().error().complete()

성공한 경우에 추가로 실행해야하는 일이 있을 수 있습니다. 그럴 때는 pipe().error().complete()와 같이 실행하면 됩니다.

pipe(
  a => { throw 'ho' },
  a => 10
).error(
  _ => console.log('에러!')
).complete(
  a => console.log('성공 - ', a)
) ();
// 에러!

pipe(
  a => 10
).error(
  _ => console.log('에러!')
).complete(
  a => console.log('성공 -', a)
) ();
// 성공 - 10

다른 컬렉션 조작 함수들과의 조합

파이프라인안에서 고차 함수를 사용했을 때, 안쪽 함수에서 예외가 발생할 경우 바깥으로 .error로 넘어오게 됩니다.

const f10 = pipe(
  arr => arr.map(a => a + 5),
  arr => arr.some(a => a > 10),
  console.log
).error(
  e => console.log('err --->', e)
);

f10([5, 10, 15]); // true

const f11 = pipe(
  arr => arr.map(a => aasdqwe),
  arr => arr.some(a => a > 10),
  console.log
).error(
  e => console.log('err --->', e)
);

f11([5, 10, 15]); // err ---> ReferenceError: aasdqwe is not defined

여기까지는 콜 스택 덕분에 동기적인 try/catch로도 충분히 해결이 가능하여, Array.prototype.map이나 비동기를 다루지 않는 다른 컬렉션 조작 라이브러리들의 함수를 사용해도 예외 처리가 가능합니다.

비동기를 지원하지 않는 함수에서 발생한 예외 처리 실패

만일 이와 같은 코드에서 비동기적인 예외가 발생되면 어떻게 될까요?

const f12 = pipe(
  arr => arr.map(a => new Promise(function(resolve, reject) {
    reject(`${a} reject!`);
  })),
  arr => arr.some(a => a > 10), // <-- 여기에 [Promise, Promise, Promise] 가 내려가고
  console.log // <-- false가 찍힙니다.
).error(
  e => console.log('err --->', e)
);

f12([5, 10, 15]);
// Uncaught (in promise) 5 reject!
// Uncaught (in promise) 10 reject!
// Uncaught (in promise) 15 reject!

동기적 상황만 고려한 Array.prototype.map은 위와 같은 상황에서 예외가 발생할 뿐아니라, 예외처리를 하지 못하게 됩니다.

동기/비동기를 함께 지원하는 함수의 필요성

아래는 FunctionalES의 map, some으로 변경한 코드입니다.

const { map, some } = Functional;

const f13 = pipe(
  map(a => new Promise(function(resolve) {
    resolve(a + 5);
  })),
  some(a => a > 10), // <- map에서 비동기를 제어한 후 some에 [10, 15, 20] 전달
  console.log
).error(
  e => console.log('err --->', e)
);

f13([5, 10, 15]); // true (정상 동작)

const f14 = pipe(
  map(a => new Promise(function(resolve, reject) {
    reject(`${a} reject!`); // 10, 15 는 실행되지 않습니다.
  })),
  some(a => a > 10), // <-- 여기에 오지 않습니다.
  console.log        // <-- 여기에 오지 않습니다.
).error(
  e => console.log('err --->', e)
);

f14([5, 10, 15]); // err ---> 5 reject!

const f15 = pipe(
  map(a => a + 5),
  some(a => new Promise(function() {
    Grrrr Kack Kack Bang Bang;
  })),
  console.log // <-- 여기에 오지 않습니다.
).error(
  e => console.log('aya ~ ', e)
);

f15([5, 10, 15]); // aya ~ ReferenceError: Grrrr Kack Kack Bang Bang is not defined

FunctionalES의 함수들은 기본적으로 동기와 비동기 상황을 함께 지원하도록 구현되어있기 때문에 동기/비동기와 상관없이 성공과 실패를 안전하게 제어할 수 있습니다.

정리

pipe는 기본적으로는 동기/비동기 상황의 함수 합성을 처리해주는 함수입니다. 여기서 pipe가 동기와 비동기를 구분 짓는 규칙은 ES6+의 내장 값인 Promise를 기준으로 합니다. 추가로 개발자가 원하는대로 nullable/error/exception 규칙을 심어줄 수 있고, 이 역시 자바스크립트의 기본 에러 값들을 다룹니다. 이를 통해 자바스크립트의 내장 값만을 이용해도 안전한 함수 합성을 가능하도록 합니다.

자바스크립트는 동적 언어이자 인터프리터 언어입니다. prototype 기반으로 다양한 사용자 정의 클래스를 만들 수 있지만 인터프리터 언어의 특성상 타입을 강하게 지원하지 못합니다. 메서드나 함수가 처리되는 과정에서 타입을 파악하기 때문에 안전한 조합을 보장하기 어렵습니다. 복잡한 타입들을 도입하거나 많은 수의 사용자 정의 클래스를 만들게 되면 IDE의 지원을 받아도 너무나 복잡한 세상들의 조합을 네비게이션하기조차 힘들어지기도 합니다.

그렇다면 자바스크립트는 안전할 수 없는 걸까요? 사용자 정의 클래스를 만들지 않고 자바스크립트의 기본 값들을 기준으로 프로그래밍을 하게되면 안전하게 프로그래밍이 가능합니다. 서로 다른 세상을 살펴볼 필요도 없습니다. 어떤 함수든 기본 값을 리턴하기 때문에 그 기본 값을 잘 다루는 함수들을 조합하면서 안전하게 프로그래밍 해나갈 수 있습니다.