이 함수가 에러를 던진다는 사실을 어떻게 알릴까?
/** @throws {DuplicateNumberException} */
private checkDuplicateNumber(input: string): void {
if (new Set(input).size !== input.length) {
throw new DuplicateNumberException();
}
}
최근 입력값 검증을 처리하는 모듈을 작성하면서 한 가지 생각이 떠올랐다.
내가 작성한 함수가 특정 상황에서 오류를 던질 수 있는데,
이 함수를 사용하는 쪽에서도 오류가 던져질 수 있는 가능성을 명확하게 알 수 있도록 할 수는 없을까?
이 글은 이런 의문에서 시작되었다.
throws 절에 대한 아이디어, Java의 Checked Execption,
그리고 TS 에서 암시적/명시적으로 에러를 나타내는 방법에 대해서 간략한 소개를 해보려 한다.
function that "throws" DivideByZeroException. throws 절이 등장한다면?
// type predicates
function isFish(param:Animal):param is Fish {}
// assertion function
function asserts(condition:boolean):asserts condition {}
// throws ??
function divide(x:number, y:number):number **throws** DivideByZeroException {}
const result = divide(2,0)
// ^^^ Error: This function might throw a [DivideByZeroException]
// ^^ 라는 건 없다..
타입을 좁힐 수 있는 type predicates
조건이 참임을 보장하여 이후 코드의 타입 안전성을 높이는 데 사용되는 assertion function
만약 이들과 비슷하게 함수의 반환 타입에 어떤 에러를 던지는지 명시하는 기능이 있다면
해당 함수의 사용자에게 try catch 를 통해 에러처리를 강제할 수 있어, 더 안전한 코드를 작성할 수 있을 것이다.
Java 의 Checked Exception
Java 언어에는 이런 기능이 이미 존재한다.
Java에서는 Checked Exception을 사용하여
함수의 에러 발생 가능성을 명확하게 알리고, 호출자에게 예외 처리를 강제할 수 있다.
public String readFile(String path) throws FileNotFoundException {}
String result = readFile("example.txt");
// Error: Unhandled exception: java.io.FileNotFoundException
Checked Exception 덕분에 개발자는 에러의 가능성을 미리 알고 예외에 적절히 대응할 수 있게 된다.
하지만 이것에 대한 부정적인 의견이 다수 존재한다.
현실적으로 모든 예외에 대한 처리는 어렵고,
Checked Exception 때문에 생기는 수많은 try ~ catch 문이 가독성을 해친다는 의견이었다.
현재는 Unchecked Exception을 선호하는 추세라고 한다.
Checked exception 에 대한 여러 논의가 있으니 살펴보는 것도 좋을 것 같다.
https://stackoverflow.com/questions/27578/when-to-choose-checked-and-unchecked-exceptions
그럼 타입스크립트에는 왜 없을까?
이 기능을 원하는 사람은 역시나 존재했다.
제안 역시 이루어졌고, 활발한 논의가 이뤄지고 있었다.
https://github.com/microsoft/TypeScript/issues/13219
https://github.com/microsoft/TypeScript/issues/56365
위 이야기들 중 도입하지 않았으면 하는 이야기를 골라보면
- 모든 예외 가능 함수에 예외를 명시하게 된다면 코드 복잡도가 올라가고,
- 그 때문에 이미 JAVA 에서 Checked Exception을 피하는 경향이 있음
- JavaScript는 기본적으로 동적 언어로 설계되었고, 다양한 환경에서 다양한 예외가 발생할 수 있음.
- throw 가 에러 말고도 모든 것을 던질 수 있음 예외가 발생할 수 있는 상황이 예측 가능하지 않고
- 예외의 유형도 일관되지 않아 throws 절을 통해 모든 예외를 명시하는 것이 현실적으로 어려움
결국 장기적인 코드 유지보수성이나 생태계의 복잡성을 고려했을 때 도입 시 그다지 실용적이지 않다는 점
등의 이유로 아직 채택되지 않고 있나보다.
이처럼 Checked exception 개념은 아직 TypeScript에는 없고, 도입하기도 힘들어 보인다.
하지만 내가 예외 가능성을 알려야 하는 경우 어떻게 할 수 있을까?
JSDoc의 @throws 활용, ESLint 규칙 사용
export interface InputValidateService {
/** @throws {Error} */
validate(input: string): void | never;
}
JSDoc 에는 @throws라는 키워드가 존재한다.
https://jsdoc.app/tags-throws
어떤 함수 내부에서 throw 가 존재한다면 JSDoc을 통해 암시적으로 나타낼 수는 있다.
ESLint Custom Rule을 이용하는 방법도 있다.
https://github.com/Akronae/eslint-plugin-exception-handling
하지만 이 두 가지 방법은 컴파일러가 try catch 사용을 직접 요구하지 않는 암시적인 방법이다.
이들 보다 조금 더 명시적인 방법이 있을까?
Either <L,R>
함수형 프로그래밍에서는 Either<L, R>과 같은 형식으로 에러와 성공을 구분하여 명시적으로 처리하는 방식으로 많이 사용된다.
성공일 경우 Right에 값을 담고, 실패일 경우 Left에 에러를 담아 호출자에게 반환한다.
이때 함수형 프로그래밍에서 자주 보이는Either라는것은 모나드(monad) 의 일종이라 하며,
함수형 프로그래밍과 모나드는 이 주제를 벗어남으로 링크로 대체하겠다.
이를 통해 함수의 호출자가 성공과 실패 케이스를 명시적으로 처리해야만하도록 유도할 수 있다.
type Either<L, R> = Left<L> | Right<R>;
// 에러거나, number 거나
function divide(a: number, b: number): Either<Error, number> {
if (b === 0) {
return left(new Error("Cannot divide by zero"));
}
return right(a / b);
}
Rust 언어에서는 Result <T, E> 타입을 사용해 함수의 성공과 실패를 타입 시스템에서 강제한다.
심지어 Rust는 일반적인 try-catch 예외 처리 구조를 사용하지 않고, Result를 통해 성공과 실패를 구분하여 처리한다.
Rust의 Result 타입은 함수 호출자가 성공과 실패를 모두 처리해야만 하도록 요구하며, 타입 안전성도 보장한다.
https://doc.rust-lang.org/std/result/
TypeScript에서도 이와 유사한 구조를 통해 Result나 Either을 활용할 수 있으며,
neverthrow와 같은 라이브러리로 구현이 가능하다.
https://github.com/supermacro/neverthrow
import { ok, err, Result } from 'neverthrow';
// Result<ReturnType,ErrorType>
function divide(a: number, b: number): Result<number, Error> {
if (b === 0) {
return err(new Error("Cannot divide by zero"));
}
return ok(a / b);
}
const result = divide(10, 2);
result.match(
(value) => console.log("Result:", value),
(error) => console.error("Error:", error.message),
);
이를 이용하여 오류가 던져지는 함수를 사용하는 곳에 Result <T, E>을 반환하면서,
에러의 가능성을 명시적으로 보여주며 또 사용자에게 결과에 대한 처리를 하도록 만들 수 있게 된다.
+ 이런 방법도 있다고 한다!
https://effect.website/docs/why-effect#the-effect-pattern
Safe Assignment Operator (?=)
https://github.com/arthurfiorette/proposal-safe-assignment-operator/tree/main
찾아보니, 최근에 제안된 Safe Assignment Operator(?=) 도
에러를 명시적으로 처리할 수 있는 방법 중 하나로 다룰 수 있을 것 같다.
이 제안에서는 함수나 비동기 호출에서 결과를 [error, data] 형태의 튜플로 반환하여,
try-catch 없이도 에러를 처리할 수 있게 한다.
아직 제안의 극 초기단계라 가능성은 적어 보이지만 제안이 도입된다면, 예를 들어 다음과 같은 코드가 가능해진다.
const [error, response] ?= await fetch("https://api.example.com/data");
if (error) { handleError(error); } else { processResponse(response); }
이 방식은 에러와 데이터를 일관된 방식으로 구조화하여 반환하고,
await과 함께 사용할 수 있어 다양한 API나 비동기 작업에서 오류를 간편하게 처리할 수 있다.
try-catch 블록의 중첩을 줄여 가독성을 높이면서도, 코드의 예측 가능성을 개선해 준다.
이러한 Safe Assignment Operator가 도입된다면, 자바스크립트에서도 더욱 직관적인 에러 처리가 가능해질 것이다.
마무리
이렇게 특정한 사례의 경험을 통한 의문을 풀어보는 과정을 거쳐보았다.
TypeScript는 Checked Exception을 지원하지 않지만, 에러 발생 가능성을 표현하는 여러 대안이 존재한다.
@throws 주석과 ESLint 규칙을 이용해 예외 가능성을 호출자에게 간접적으로 알리는 방식이나
함수형 프로그래밍에서 가져온 Either나 Result 패턴을 통해 성공과 실패를 명시적으로 처리하는 방식이 그 예이다.
최근에는 Safe Assignment Operator 같은 새로운 문법도 제안되면서
에러 처리를 더욱 간결하고 안전하게 만들어가려는 시도가 이뤄지고 있다.
이 과정에서 다양한 언어(Java, Rust 등)와 프로그래밍 패러다임에서 가져온 개념들이
에러 처리 방식에 영향을 주고 있음을 알 수 있었다.
다른 언어와 생태계를 통해 여러 방식을 배우고 적용해 보는 것이 성장에 큰 도움이 될 것 같다.
앞으로도 다양한 접근 방식을 탐구하며 더 나은 코드를 작성하는 경험을 이어갈 수 있으면 좋겠다.