Java의 예외

10283 단어 JavaJava

서론

SimpleBBS 애플리케이션을 구현하면서 서비스 인터페이스와 구현체를 분리해서 진행했다. 그러면서 서비스 구현체 클래스의 메서드에서 여러가지 예외가 발생하기도 하고 이를 예외 처리기(ExceptionHandler)에서 잡아서 처리하는 로직도 많이 구현되었는데 문득 인터페이스나 메서드에서 throws로 예외를 표시해야 하는 게 아닐까 생각했다. 그래서 일단 자바 예외의 기초부터 다시 훑어보기로 했다.

본론

Checked, Unchecked

일단 Java의 예외는 크게 checked, unchecked 예외로 분류할 수 있다. 전자는 코드 작성 시점에 확인된(checked), 즉 발생할 수 있다고 확인되어 처리된 예외며 후자는 발생할 지 모르기 때문에 확인되지 않은(unchecked) 예외에 속한다.

Checked 예외와 Unchecked 예외의 가장 큰 차이점은 애플리케이션을 작성하는 시점에 예외 처리를 강제한다는 것이다. 이에 대해 자세히 알아보자.

Checked Exception

발생할 수 있다고 확인된 예외는 헷갈릴 수 있지만 애플리케이션을 작성할 때 무슨 수를 써도 완전히 방지할 수 없는 예외다. 그렇기 때문에 항상 발생할 수 있는 가능성이 존재하며 이를 개발자가 확인하고 처리해야 하는 예외가 checked 예외에 속한다.

대표적인 예로 write같은 파일 처리 메서드에서 throws하는 IOException이나 데이터베이스와 통신할 때 발생하는 SQLException 등이 이 checked 예외에 해당한다. 하드웨어에 문제가 생겨 파일을 읽고 쓸 수 없거나 데이터베이스 서버가 제대로 동작하지 않는 것은 애플리케이션을 잘 작성한다고 발생하지 않는 문제가 아니기 때문이다.

그렇기 때문에 메서드나 생성자에서 발생할 수 있는 예외를 try-catch로 직접 처리하거나 throws로 propagate해야 하는 예외가 checked 예외에 속한다. JdbcTemplate이 등장하기 이전 JDBC API를 이용하여 직접 데이터베이스와 통신하는 코드를 살펴보면 무슨 얘기인지 알 수 있을 것이다.

Unchecked Exception

발생할 지 아닐 지 모른다는 Unchecked 예외는 Checked 예외와는 반대로 애플리케이션을 잘 작성하면 발생하지 않는 예외다. 예를 들어 배열을 참조하기 전에 배열의 크기를 확인한다던지(ArrayIndexOutOfBoundsException) 나눗셈 연산 시 나누는 값이 0인지 확인한다면(ArithmeticException) 예외가 발생할 수 있는 상황을 사전에 제거하여 충분히 발생하지 않도록 할 수 있다.

그렇기 때문에 Checked 예외처럼 try-catch로 예외처리가 강제되지 않는다. 즉 확인하지 않아도 된다. 대신 사용자가 자율적으로 예외가 발생하지 않도록 강건하게 애플리케이션을 작성해야 한다.

Exception, RuntimeException

실제로 자바 코드에서 던지는 예외들은 모두 Exception 클래스의 하위 클래스에 속하게 된다. 이 Exception 클래스의 자식 클래스들은 Throwable하기 때문에 throw 메서드로 던질 수 있는데 이들도 Checked, Unchecked 예외를 구분하는 클래스가 정의되어 있다.

Exception

Exception 클래스는 모든 예외의 부모 클래스라는 특징 말고도 자식 클래스들 중 RuntimeException 클래스의 자식 클래스가 아닌 클래스들이 모두 checked 예외에 해당한다는 특징이 있다.

실제로 위에서 언급한 IOException이나 SQLException들의 상속 관계를 살펴보면 Exception 클래스의 하위 클래스에 속하는 Checked 예외인 것을 볼 수 있다.

그렇기 때문에 이 Exception 클래스를 상속받은 예외가 발생한다면 try-catch로 처리하거나 발생하는 곳의 헤더에 throws로 명시해야 한다.

RuntimeException

Exception 클래스와는 다르게 RuntimeException은 실행 환경이 정상적으로 동작하는 중에도 발생할 수 있는 예외들로 unchecked 예외에 속한다. 그렇기 때문에 따로 throws로 명시하거나 직접 처리해 줄 필요는 없다.

산술 연산에서 발생하는 ArithmeticException이나 null을 참조할 때 발생하는 NullPointerException이 해당하며 명시되었든 아니든 예외처리가 강제되지 않는다.

Spring의 RuntimeException

서론에서 언급했듯이 지금 진행하는 SimpleBBS 프로젝트는 상황에 따라 서비스 메서드에서 RuntimeException을 상속한 각종 예외를 발생시킨다. 이 예외들은 ControllerAdvice의 ExceptionHandler가 잡아서 적절한 곳으로 리다이렉트하는 방식으로 진행하고 있다.

문득 생각이 든 것은 인터페이스에서 해당 역할에 필요한 예외를 throws로 명시하면 구현체에서 예외 처리가 강제되어 어떤 상황에서 예외가 발생할 수 있는 지 쉽게 알 수 있을 것이라는 것이었다.

하지만 실제로 예외를 명시해도 예외처리가 강제되지 않아서 왜 그런가 했는데 이는 예외들이 RuntimeException을 상속받아서 만든 unchecked 예외였기 때문이었다. ExceptionHandler 활용을 위해 RuntimeException을 상속받은 예외를 정의해서 필요한 곳에서 throw하는 과정을 별 생각 없이 따라하다 보니 위처럼 Exception과 RuntimeException의 차이를 제대로 인지하지 못해 이 같은 착각을 하게 된 것이다.

try {
    BoardAccountDTO loginResult = accountService.loginAccount(
        BoardAccountDTO.builder().userId(command.getUserid()).build(),
        AuthDTO.builder().rawPassword(command.getPassword()).build());
    HttpSession session = request.getSession();
    ...
} catch (NoAccountFoundException e){
    bindingResult.addError(...);
    return "account/login";
} catch (AuthenticationFailedException e) {
    bindingResult.addError(...);
    return "account/login";
} catch (AccountChallengeThresholdLimitExceededException e){
    bindingResult.addError(...);
    return "account/login";
}

SimpleBBS의 로그인 서비스에서는 위처럼 로그인 과정에서 발생할 수 있는 다양한 예외를 try-catch로 처리하고 있다. 계정이 존재하지 않는 NoAccountFoundException, 인증 수단(비밀번호 등)이 올바르지 않은 AuthenticationFailedException, 로그인 시도 횟수를 초과한 AccountChallengeThresholdLimitExceededException 예외 모두 RuntimeException으로 원래는 ExceptionHandler에서 처리되지만 MVC 방식에서 편의성을 고려하여 컨트롤러에서 직접 try-catch로 예외처리 후 상황에 맞는 뷰를 렌더링하고 있다.

생각해볼 점은 만약 모든 예외를 Exception을 상속한 checked 예외로 만들어서 강제한다면 서비스 메서드를 활용할 때 어떤 예외가 발생할 수 있는지 정확히 알 수 있다는 장점이 있다는 것이다. 하지만 모든 예외를 try-catch로 처리해줘야 하기 때문에 스프링의 ExceptionHandler 기능을 쓸 수 없다는 단점이 있다.

그렇기 때문에 스프링에서 예외 처리는 RuntimeException을 상속받은 unchecked 예외로 만들고 필요에 따라 처리하는 편이 더 유연할 것이며 비슷한 이유로 튜토리얼에서도 RuntimeException을 상속받으라고 권장한 것 같다.

Interface의 예외

자바에서는 인터페이스의 메서드에도 예외를 정의할 수 있다. 정확히는 인터페이스에 정의된 메서드가 던질 수 있는 예외를 명시하는 것인데 이 때도 checked 예외는 예외처리가 강제되며 unchecked 예외는 그렇지 않다. 그렇다면 인터페이스에는 왜 예외를 정의할 수 있는 것일까?

이는 "less restrictive"라는 원칙에 따른 것이다. 즉 인터페이스를 구현하는 구현체에서는 인터페이스에 명시된 예외를 자율적으로, 즉 less restrictive하게 발생시킬 수 있다. 그러나 명시되지 않은 예외들, 즉 more restrictive하게는 발생시킬 수 없는데 왜냐면 DIP 원칙에 따라 구현체에 의존하지 않고 인터페이스에 의존하는 클래스는 구현체에서 추가적으로 발생된(즉 인터페이스에서 명시되지 않은) 예외를 처리할 수 없기 때문이다.

결론

자바의 예외는 개념적으로 Checked와 Unchecked가 있으며 이를 실제로 구현하게 되는 클래스가 Exception, RuntimeException이다. 그동안 스프링 프레임워크에서는 ExceptionHandler를 사용하기 위해 처음에 학습한 대로 무의식적으로 RuntimeException을 상속한 예외를 사용해왔지만 예외처리 필요에 따라 Exception을 상속한 Checked 예외를 사용하는 것도 고려해봐야 할 것이다.

참고

Difference between java.lang.RuntimeException and java.lang.Exception
Java interface throws an exception but interface implementation does not throw an exception?

좋은 웹페이지 즐겨찾기