ghlee.dev
TypeScript에서의 Result<T, E>
2024. 7. 17.현재 JavaScript에서 지원하고 있는 에러(Error)를 처리하는 방법에는 try...catch
문법이 있다. 통칭 Exception이라고 불리는 예외 처리 방법인데 Java를 비롯하여 C++, Python 등 다양한 언어가 이를 지원한다.
try와 catch를 붙여쓸지 사소한 고민을 하다가 MDN 문서의 표현 방식(
try...catch
)을 따르기로 했다. 🤭
try...catch
문법은throw
와 함께 사용된다.
에러 코드를 if
표현식 같은 제어 흐름문을 이용해서 처리하는 방법과 try...catch
를 사용하는 Exception은 늘 비교당하는 뜨거운 감자같은 주제이다. 참고로 나는 이 주제에 낄만큼 지식과 경험이 많지 않아서 논쟁에 낄 생각은 전혀 없다.
다만 개인적으로 JavaScript의 try...catch
에 데인 경험이 많았고 살짝 마음에 들지 않았었다. 그러던 차에 최근 내가 자주 사용해보고 있는 Rust에서 Result<T, E>
타입을 알게 되었고, 계속 사용하다 보니 실용적이며 유용하다고 생각하게 되었다. 그래서 이 글에서는 Result<T, E>
타입을 TypeScript에서 사용해 볼 수 있을지 알아볼 것이다.
undefined
와null
을 대신하는Option<T, E>
타입도 있다.
미리 말하자면 지금부터 설명하는 에러 처리 방법이 Rust에만 있는 것은 아니다. 함수형 프로그래밍 패러다임을 따르는 언어에서는 흔한 방법으로 알려져 있지만, 본인은 함수형 프로그래밍을 누군가에게 설명할 정도의 지식을 가지고 있지 않다. 그저 Result<T, E>
타입을 실용적으로 느껴서 차용할 뿐임을 미리 말한다.
그렇다면 JavaScript에는 어떤 문제가 있는지부터 알아가 보자.
try...catch와 throw
서론에서 JavaScript를 이야기했지만 본문에서는 실무에서 주로 사용하는 TypeScript로 예를 들겠다. 동일한 문제를 가지고 있기 때문에 설명에 큰 차이는 없다.
우선 try...catch
의 문법을 보자. 사용 방법은 간단하다.
Something()
함수를 try
로 시작하는 블록에서 호출하였다. 실행 중 예외가 발생하면 제어 흐름이 catch
블록으로 넘어간다. catch
블록에서는 예외에 대한 값을 받아올 수 있다. 이는 꼭 에러 값이 아니어도 가능하다.
이런 괴랄한(?) 사용도 가능하다.
벌써부터 나의 눈에는 썩 마음에 들지 않는다. 이는 throw
가 모든 타입의 값을 던질 수 있기 때문에 덩달아 생기는 문제이다. try...catch
문이 에러 처리를 위한 문법으로 주어졌으면 그 목적으로만 사용할 수 있었으면 하는데, 이처럼 창의적인 사용도 가능하니 예측할 수 없는 코드가 만들어진다. 숫자 1
을 던지는 것도 에러라고 볼 수 있는 걸까.
하지만 이 정도는 귀여운 느낌이다. 내가 실무에서 겪은 try...catch
의 문제는 크게 두 가지인데 이 중 하나는 아래와 같다.
표현식(Expression)이란 값(Value)으로 평가(Evaluation)될 수 있는 문법 방식을 의미한다.
try...catch
의 블록 안에는 Something()
과 같은 함수 표현식을 여러 개 사용할 수 있다. 이로 인해 catch
블록에서 에러를 받더라도 어느 Something
함수에서 문제가 생겼는지 바로 알 기가 힘들다. 즉 디버깅(Debugging)이 힘들어진다는 의미이다.
다른 한 가지 문제는 다음과 같다.
만약에 TypeScript로 에러를 던지는 throwError
라는 함수를 정의한다고 가정하자. 이에 대한 반환 타입을 추론하면 Error 타입이 되어야 할 거 같지만 그저 void
로 취급한다. 이는 try...catch
블록 안에서 사용해도 동일하다.
에러를 던지는 함수이지만 안타깝게도
void
로 추론한다.
그러나 throw
로 던졌던 에러를 return
으로 바꾸면 의도한 대로 반환 타입을 추론할 수 있다.
하지만 return
을 사용하면 catch
블록에서 받아올 수가 없는 문제가 있다. 결국 try...catch
문법과 함께 throw
를 던지는 함수를 사용해야 하는데, 이 경우 함수 시그니처가 정확하지 않기 때문에 함수 내부 구현을 꼭 확인해야 하는 문제가 발생한다. 잘 만든 함수는 함수 시그니처 만으로도 동작을 유추할 수 있는데 throw
는 이를 불가능하게 만든다.
throw
와 void
타입 관련한 문제는 TypeScript의 해당 이슈에서 자세히 확인해 볼 수 있는데 TypeScript는 JavaScript의 기본 동작을 해치지 않는 것이 원칙인지라 이 문제를 해결할 순 없어보인다. 결국 JavaScript 언어 스펙에서 해결되거나 프로그래머 개인이 알아서 해결할 문제이다.
Result<T, E>
글 제목에 있는 Result<T, E>
의 정체는 앞서 서론에서 이야기했듯 Rust에서 지원하는 타입이다. 제네릭(Generic) T
는 결괏값에 대한 타입이며, E
는 에러가 났을 때 반환할 값의 타입에 해당한다. 그래서 함수를 사용하기 전에 함수 시그니처로 Result
타입을 보자마자 에러가 있을 수 있다는 것을 바로 알 수 있는 장점이 있다.
Rust 코드로 간단한 예제를 한번 보자.
Rust를 접해본 적 없다면 생소할 수 있지만, 그저 두 개의 부동소수점을 받아서 나눗셈하는 함수이다. 밑이 0
일 때 문자열로 된 메시지와 함께 에러를 반환하고 그렇지 않으면 정상적으로 나눗셈을 한 결과를 출력하는데, 이를 위해 Ok
또는 Err
을 사용하여 값을 한번 랩핑(Wrapping)하여 반환한다. 그래서 Result
타입이라 부르지만 Ok
와 Err
로 구성된 열거형(Enum)이기도 하다.
아래는 구현한 나눗셈 함수를 사용하는 예제이다.
Rust의
match
문법은 JavaScript의switch
와 유사하다고 일단 생각해두자. (엄연히 다르긴 하다.)
divide
함수로부터 받은 반환 값을 result
변수에 넣어준 후 match
문법을 이용하여 Ok
와 Err
케이스를 구분하여 처리한다. 또한 if let
문법을 사용할 수도 있다. (참고로 Rust에서 if
문은 값을 반환할 수 있는 표현식이다.) 이러한 제어 흐름을 사용하기 싫다면 unwrap
, unwrap_or_else
등 다양한 처리 방법을 지원한다.
사실 언어마다 이 방식을 구현하는 문법은 모두 다르기 때문에 구체적인 Rust의 문법을 알 필요는 전혀 없다. 다만 핵심은 에러가 발생할 수 있는 함수는 에러 혹은 정상적인 결괏값을 반환하도록 하여서 에러 처리를 수월하게 돕고 런타임에서 취약점을 줄이자는게 목적이다.
JavaScript의 try...catch
와 throw
로는 에러 처리를 엄격하게 하기 쉽지 않다. 하나의 try
블록에 에러를 던지는 함수를 잔뜩 사용하더라도 catch
블록에서 아무런 처리를 하지 않고 넘어가면 런타임에서 이를 잡지 못한다. 프로그램은 그저 에러가 발생하지 않은 것처럼 행동한다. 개발 과정에서는 일일이 에러 처리하는 것보다 훨씬 편리하지만 결과적으로 끔찍한 프로그램이 탄생하고 만다.
또한 throw
로 던지는 에러는 타입 추론이 안되기 때문에 함수 바깥에서 이를 알 방법이 없다. 함수 내부를 살펴서 에러가 발생할 만한 코드를 직접 찾아보는 수밖에 없다.
TypeScript에서 Result<T, E>
그렇다면 Result<T, E>
같은 에러 처리 방법은 TypeScript에서 어떤 식으로 구현해야 할까?
처음에는 직접 타입을 구현해 보려 했다. 몇 가지 인터페이스만 정의해 주고 코딩 스타일만 정립해두면 직접 구현하여 사용하는 게 그리 어렵지 않아 보였기 때문이다. 다만 이를 구현하기 전에 검색을 좀 해보았는데, 역시 사람들 생각은 비슷했는지 이미 잘 구현된 오픈 소스 라이브러리가 이미 다양하게 있었다.
그리하여 이제부터 아래 네 가지의 오픈 소스 라이브러리를 간략하게 설명해 보려고 한다.
neverthrow
throw
를 사용하지 말라고 하는 강렬한 이름의 이 라이브러리는 Rust의 Result<T, E>
과 상당히 유사하다. 말로 백번 말하는 거보다 실제 코드 예제를 살펴보는 게 빠르다.
아래는 Rust로 보여줬던 divide
함수를 neverthrow 라이브러리를 이용해서 재구현 해보았다.
글 작성 시간을 기준으로 neverthrow의 버전은 7.0.0이다.
Rust의 타입과 상당히 유사하게 바뀌었다. neverthrow 라이브러리에서 ok
와 err
, Result
만 가져오면 즉시 활용할 수 있다. divide
함수를 사용하는 예제도 한번 보자.
divide
함수를 사용해서 반환 값을 받았지만 바로 사용할 수 없다. Result
인터페이스에 구현된 isOk
를 활용해서 함수가 성공했는지 확인한 후 비로소 실제 결괏값인 value
프로퍼티(Property)에 접근할 수 있다. 확인하지 않고 바로 value
프로퍼티에 접근하려고 하면 타입 에러를 내뿜는다.
이 라이브러리는 내가 원하던 조건을 99% 만족한다. Result
타입을 반환 타입으로 선언함으로써 함수 시그니처만 보고도 오류가 발생할 수 있는 함수인지 확인할 수 있게 되었고, 결과에 바로 접근하는 게 아닌 if
혹은 switch
같은 제어 흐름 문법으로 함수가 성공했는지를 여부를 확인한 후 접근할 수 있게 되었다.
아래는 neverthrow 내부 코드의 일부이다.
코드는 생각보다 간단하다. Result
타입은 Ok
와 Err
클래스를 유니언(Union) 타입으로 가지며 Result
타입을 반환 타입으로 가지고 있는 함수는 호출한 결괏값에서 바로 value
에 접근할 수 없다.
왜냐하면 유니언 타입이기 때문에 Ok
와 Err
타입 중 어느 타입인지 TypeScript가 알지 못하기 때문이다. 그래서 isOk
같은 유틸 함수를 통해서 어느 유니언 타입인지 TypeScript의 타입 검사기가 알 수 있게 해야 한다. 그래서 직전의 예제에서 isOk
를 사용하지 않고서는 value
에 접근할 수 없고 TypeError를 보여주었던 것이다.
이를 순서대로 살펴보면 다음과 같다.
[1] 함수를 호출하여 Result
타입을 반환 값으로 받는다.
[2] 현재 TypeScript의 타입 검사기는 반환 값이 Ok
와 Err
중 어느 것인지 알지 못한다.
[3] isOk
함수와 if
문을 이용하여 둘 중 하나의 타입으로 범위를 좁힌다. 이 방법을 통해 타입 검사기는 둘 중 어느 타입인지 추론할 수 있게 된다.
[4] Result.value
또는 Result.err
값에 접근이 가능해진다.
그리고 isOk
와 isErr
같은 유틸 함수는 IResult
라는 인터페이스에 함수 시그니처만 정의되며, 이를 실제 구현하는 건 Ok
와 Err
클래스이다. Github에 가보면 isOk
와 isErr
외에도 다양한 유틸 함수가 있으니 참고해 보면 좋을듯하다.
fp-ts
fp-ts는 에러 처리를 위한 라이브러리 라기보다 함수형 프로그래밍 패러다임을 쉽게 구현하기 위한 라이브러리에 가깝다. fp-ts에서도 Result
타입과 유사한 Either
타입을 제공한다.
Either
타입은 Left
와 Right
타입을 유니언 타입으로 가지며 이를 통해 성공과 실패 케이스를 구분할 수 있다. 사실 Either
타입은 Rust의 Option
타입에 가깝다. (fp-ts에는 Option
타입을 별도로 제공하긴 한다.) Option
타입은 JavaScript로 비유하면 성공과 nullable
케이스를 유니언 타입으로 가진다고 생각하면 이해하기 수월할 것이다.
Effect
Effect도 함수형 프로그래밍 패러다임을 구현하는 라이브러리에 가깝다. Effect에서도 Result
타입과 유사한 Effect
타입을 제공한다.
이제는 구현 방식에서 유사한 패턴이 보이지 않는가?
true-myth
true-myth는 neverthrow와 유사하게 에러 처리를 위한 라이브러리로 탄생했으며 Result
타입과 Maybe
타입을 지원한다. Maybe
타입은 Rust의 Option
타입에 유사하다고 볼 수 있다.
neverthrow의 예제에서 라이브러리만 true-myth로 바꿔주었는데 어려움없이 적용된다.
이번에는 true-myth에 있는 Maybe
타입을 활용해서 내가 직접 SafeMap
이라고 하는 간단한 클래스를 만들어 보았다.
Map
의 get
함수는 결괏값이 없으면 undefined
를 반환하는데 이 부분을 Maybe
로 감싸고 get
함수를 오버라이딩하여 프로그래머가 undefined
를 무시하지 않고 안전하게 처리할 수 있게 강제해 보았다. 실제 오버라이딩된 get
함수의 함수 시그니처는 다음과 같다.
당연하지만 이외에도 다양한 활용 방법이 있을테니 직접 고민해보면 더 좋을듯 하다.
이로써 총 네 가지의 라이브러리를 간략하게 살펴보았는데 개인적으로는 neverthrow와 true-myth가 Error
와 Nullable
처리에 집중한 라이브러리여서 마음에 들었다. 물론 fp-ts와 Effect도 훌륭하지만 이 두 라이브러리는 함수형 프로그래밍 전반에 대한 기능을 제공해서 번들 크기도 상대적으로 크고 부담스럽게 느껴졌기 때문이다. 반대로 얘기하면 neverthrow와 true-myth에는 기능이 많지 않으니 풍부한 유틸(Util)을 원한다면 fp-ts와 Effect 쪽에 알맞을 것이다.
ECMAScript Safe Assignment Operator 제안
최근 ECMAScript의 새로운 제안으로 이른바 안전 할당 연산자라고 부를 ?=
라는 새로운 연산자를 Arthur Fiorette 라는 프로그래머가 제안했다. 안전 할당 연산자를 사용하면 [error, response]
처럼 에러와 결괏값을 가진 튜플로 결과를 반환하자는 제안이다. (Go 언어에 이러한 형태의 문법이 있다.)
구현 방식은 다르지만 결국 내가 이 글에서 얘기했던 에러 처리를 공고히 하려는 취지와 동일하며 재미있는 제안이라고 생각한다.
제안 작성자가 제시한 예시 중 하나이다. 실제로 작동하는 코드가 아니니 오해하면 안된다.
생각보다 구체적으로 안전 할당 연산자에 대해 많은 예시를 보여주고 있으니 궁금하다면 아래 링크에서 확인해 보기를 권한다.
마치며
다른 프로그래밍 언어에 있는 에러 처리 방법을 TypeScript로 구현해 보았다. 계속 보았다시피 구현되는 방법에는 차이가 있지만 결국 하고자 하는 건 에러가 발생하는 경우를 놓치지 않고 처리하자는 것이 핵심이다. 웹 서비스를 이용할 때도 간혹 Nullable
값이 그대로 노출되는 경우를 접하는데, 최소한 TypeScript의 컴파일 타임에는 이를 방지해야 한다고 생각한다.
실무에서는 옆자리 동료와의 코드 스타일을 맞춰야 하기 때문에 쉽사리 사용하기 쉽지 않지만, 만약 사용한다면 neverthrow 혹은 true-myth와 유사한 코드를 직접 만들어서 사용해 볼 수도 있을 것 같다. 왜냐하면 글에서는 언급하지 않았던 map
과 unwrap
같은 다양하게 있는 유틸 함수가 나에게는 필요하지 않고, 단순히 제어 흐름 문법으로 에러 처리를 명확하게 할 수만 있다면 충분하다고 생각한다.
마지막으로 이 글을 통해 에러 처리 방법론에 대해 고민해 보는 시간이 되었다면 좋겠다.