[Java] 체크 예외 vs 언체크 예외

dondonee·2023년 12월 16일
0
post-thumbnail

체크 예외 vs 언체크 예외

예외란?


예외란 프로그램 실행 중에 발생하는, 정상 로직에서 벗어난 의도하지 않은 상황이다.


예외 객체

자바는 예외 상황을 처리하기 위해 예외를 객체로써 다룬다. 따라서 예외 객체는 다른 객체와 마찬가지로 Object가 최상위 객체이다.

Throwable

최상위 예외 객체이다. 하위에 ExceptionError가 있다. 예외 객체는 크게 체크 예외와 언체크 예외로 분류할 수 있다. 아래에서 살펴보자.

Exception

애플리케이션 로직에서 다룰 수 있는 실질적인 최상위 예외이다. 컴파일 단계에서 체크하기 때문에 체크 예외라고 한다.

RuntimeException Exception의 하위 예외 객체이지만 언체크 예외이다. 이름을 따서 주로 런타임 예외라고 불린다.

Error

메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다. 개발자는 이 예외를 잡으려고 해서는 안된다.

Error도 언체크 예외에 속한다.



예외 기본 규칙


예외는 폭탄 돌리기와 같다. 잡아서 처리하거나 처리할 수 없으면 외부로 던져야 한다.


예외 처리

컨트롤러의 의해 호출된 리포지토리에서 예외가 발생했다고 하자. 리포지토리는 서비스로 예외를 던졌고 서비스에서 이 예외를 처리했다면 컨트롤러는 정상 흐름을 반환받는다.

예외 던짐

중간에 예외를 처리하지 못하면 호출한 곳으로 계속 예외가 던져지게 된다.

예외를 처리하지 못하고 계속 던지면 어떻게 될까?

  • 자바 main() 쓰레드의 경우 예외 로그가 출력되면서 시스템이 종료된다.
  • 웹 애플리케이션의 경우 여러 사용자의 요청을 처리하기 때문에 하나의 예외 때문에 시스템 전체가 종료되어서는 안 된다. 따라서 WAS에서 해당 예외를 잡아서 처리하는데, 보통 개발자가 지정한 오류 페이지를 사용자에게 보여준다.

예외 처리의 기본 규칙

  1. 예외는 잡아서 처리하거나 외부로 던질 수 있다.
  2. 예외를 처리할 때 지정한 예외는 그 하위 예외까지 모두 포함된다.
    • 예) Exceptioncatch로 잡으면 그 하위 예외들도 모두 잡을 수 있다.
    • 예) Exceptionthrows로 던지면 그 하위 예외들도 모두 던질 수 있다


체크 예외 vs 언체크 예외


차이점

체크 예외와 언체크 예외의 차이는 간단하다. 체크 예외는 try-catch로 잡아서 처리하거나 throws를 통해 외부로 던지거나 두 방법 중 하나를 택해야 한다. 그렇지 않으면 컴파일 단계에서 오류가 발생한다.

반면 언체크 예외는 개발자가 잡아 처리하거나 외부로 던지지 않아도 된다. 개발자가 예외 처리를 생략한다면 언체크 예외는 자동으로 외부로 던져진다.

  • 체크 예외 : 개발자는 예외를 잡아서 처리하거나 던져야 한다.
  • 언체크 예외 : 개발자가 예외 처리를 하지 않아도 된다.

작은 차이지만 이 차이로 인해 실제 코드에서는 큰 차이가 생기게 된다.


선택 원칙

예외를 다룰 때 기본 원칙으로 다음 2가지를 기억하자 :

  1. 기본적으로 런타임 예외를 사용하자.
  2. 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용하자.
    • 해당 예외를 반드시 잡아서 처리해야 하는 중요한 문제일 때만 체크 예외를 사용해야 한다.
      • 계좌 이체 실패 예외
      • 결제시 포인트 부족 예외
      • 로그인 ID, PW 불일치 예외 등
    • 그러나 중요하다고 꼭 체크 예외로 처리할 필요는 없다. 매뉴얼만 잘 만들어 놓고 런타임 예외로 처리하는 경우도 많다.

체크 예외 활용


체크 예외 예시

위와 같이 서비스의 로직에 의해 Repository에서 SQLException이 발생했고 NetworkClient에서는ConnectException이 발생했다고 하자.

  • NetworkClient는 외부 네트워크에 접속해서 어떤 기능을 처리하는 객체이다.

그런데 서비스와 컨트롤러는 이러한 DB 예외나 네트워크 예외를 처리할 방법이 없다. 이러한 예외들은 보통 DB 서버 장애, 네트워크 연결 오류 등의 심각한 문제이기 때문에 서비스나 컨트롤러가 처리하지 못하고 마지막까지 밖으로 던져진다.

웹 애플리케이션은 이러한 예외들을 모아 공통으로 처리한다. 서블릿의 오류페이지나 스프링 MVC의 ControllerAdvice의 경우이다.

  • 웹 애플리케이션은 사용자에게는 “서비스에 문제가 있습니다.”와 같이 일반적인 오류 페이지를 보여준다. 상세한 내용은 사용자가 알 필요도 없고 보안 문제가 있어서 노출하지 않는 것이 좋다. API의 경우 보통 상태코드 500을 내보낸다.
  • 개발자를 위해서는 별도로 로그를 남기고 알림을 통해 개발자가 빨리 인지할 수 있도록 한다.

체크 예외의 문제점

체크 예외는 두 가지 문제를 가지고 있다.

1. 복구 불가능한 예외

대부분의 예외는 복구가 불가능하다.

SQLException을 예로 들면 쿼리에 문제가 있거나, DB 자체에 문제가 있거나, 연결에 문제가 있는 등 시스템의 문제인 경우일 것이다. 이러한 문제들은 서비스나 컨트롤러에서 해결할 수도 없고 복구하기 어려운 치명적인 문제이다.

2. 의존 관계에 대한 문제

체크 예외는 필수적으로 처리를 해야 하기 때문에 필연적으로 의존성이 생긴다. 예를 들어 서비스나 컨트롤러에서 throws SQLException와 같은 코드를 가지고 있다는 것은 즉 JDBC(java.sql.SQLException) 기술에 대한 의존을 갖고 있다는 것이다.

만약 JPA가 JPAException이라는 예외를 사용한다 하면, JDBC를 JPA로 변경하고 싶다면 서비스 및 컨트롤러의 모든 throws SQLException 코드를 수정해주어야 한다.

  • 참고) JPAException는 존재하지 않는 예외 (쉽게 예를 들기 위함)

결과적으로 OCP, DI를 통해 클라이언트 코드의 변경 없이 대상 구현체를 변경할 수 있다는 스프링의 장점이 체크 예외 때문에 사라지게 된다.

  • OCP(Open–closed principle) 원칙: 기존 코드를 변경하지 않고도 확장할 수 있어야 한다는 객체 지향 프로그래밍의 기본 원칙 중 하나
  • DI(Dependency Injection): 객체 외부에서 종속성을 결정하는 기법

체크 예외 정리

실무에서 발생하는 예외는 대부분 데이터베이스나 네트워크에서 발생하는 시스템 예외이다. 그러나 이러한 예외는 서비스나 컨트롤러에서 복구할 수 없으므로 컴파일 단계에서 체크를 해도 소용이 없다.

체크 예외는 특별한 경우가 아니면 런타임 예외로 변환하여 사용하자.

참고) throws Exception (안티 패턴)

void method() throws Exception {..}

SQLException, ConnectException을 상위 예외인 Exception으로 간편하게 처리하는 것이 가능하지만 다른 예외까지 모두 한번에 처리되므로 사용하지 말자. 정말 필요한 체크 예외도 놓칠 수 있다.



언체크 예외 활용


체크 예외였던 SQLException, ConnectException을 상속을 통해 사용자 정의 예외인 RuntimeSQLException, RuntimeConnectException로 변환하여 사용해보자.

언체크 예외를 사용하면 의존성이 제거되어 다른 기술로 변경할 때도 편리하다.


언체크 예외 변경 예시

RuntimeException 상속

static class RuntimeSQLException extends RuntimeException {

  public RuntimeSQLException(Throwable cause) {
      super(cause)
  }
}

처음 발생한 예외를 받을 수 있도록 cause 파라미터를 가지는 생성자를 만들었다.


예외 발생시키기

static class Repository {
    public void call() {
        try {
            run();
        } catch (SQLException e) {
            throw new RuntimeSQLException(e);  //기존 예외 e를 바꿔서 던지기
        }
    }

    public void run() throws SQLException {
        throw new SQLException("ex"); 
    }
}
  • 리포지토리 run()에서 SQLException 예외를 발생시켰다.
  • call()run()을 호출한다. try-catch를 통해 체크 예외인 SQLException이 발생하는 경우 이 예외를 받아 RuntimeSQLException(e)으로 생성하여 런타임 예외로 변환한 뒤 외부로 던진다.

테스트

@Test
void unChecked() {
    Controller controller = new Controller();
    Assertions.assertThatThrownBy(() -> controller.request())
            .isInstanceOf(RuntimeException.class);
}

최초에는 체크 예외인 SQLException이 발생했지만 사용자 정의 런타임 예외로 변환되어 최종적으로는 언체크 예외인 RuntimeException이 발생한 것을 확인할 수 있다.

  • controller.request()는 서비스를 통해 리포지토리의 call()을 호출하는 메서드이다.

언체크 예외 정리

처음 자바를 설계할 때는 체크 예외가 더 좋다고 생각해서 이렇게 만들어졌다고 한다. 그러나 함께 사용하는 라이브러리가 많아지면서 개발자가 처리해야 하는 체크 예외가 너무 많아졌다. 이에 종종 throws Exception이라는 극단적인 방식이 사용되기도 한다.

이러한 체크 예외의 문제점 때문에 최근의 라이브러리들은 보통 런타임 예외를 제공한다. JPA와 스프링도 마찬가지이다. 런타임 예외도 필요하면 잡을 수 있기 때문에 필요하면 잡아서 처리하고, 그렇지 않다면 그냥 두면 된다.

  • 참고) 런타임 예외는 놓칠 수 있으므로 문서화가 중요하다.


참고) 스택 트레이스

스택 트레이스란 애플리케이션이 실행되는 동안의 움직임을 저장하는 스택 기록이다. 예외가 발생했을 때 이것을 통해 예외를 추적할 수 있다.


스택 트레이스 출력

@Test
void printEx() {
 Controller controller = new Controller();

 try {
	 controller.request();
 } catch (Exception e) {
	 //e.printStackTrace();
	 log.info("ex", e);
 }
}
  • log.info("ex", e) :
    • 로그에서 마지막 파라미터에 예외 객체를 넣어주면 로그에 스택 트레이스를 출력할 수 있다.
  • System.out으로 출력하려면 e.printStackTrace()를 사용한다. (권장 X)
    • 실무에서는 로그를 사용하자.

예외를 다룰 때 주의점

static class RuntimeSQLException extends RuntimeException {

    public RuntimeSQLException() {
    }

    public RuntimeSQLException(Throwable cause) {
        super(cause);
    }
}

체크 예외를 언체크 예외로 변경하는 예시에서 RuntimeException를 상속한 RuntimeSQLException 객체를 만들 때 cause를 파라미터로 갖는 생성자를 만들었었다. 왜 cuase가 필요한 지 알아보자.


public void call() {
      try {
          run();
      } catch (SQLException e) {
          throw new RuntimeSQLException();
      }
  }

SQLException을 사용자 정의 런타임 예외인 RuntimeSQLException로 바꾸는 코드이다.

이 때 만약 기본 생성자를 사용하여 전달 인자로 아무것도 주지 않는다면 스택 트레이스는 RuntimeSQLException에 대한 로그만 출력한다. 처음에 어디서 예외가 발생했는지 알 수 없게 된다.


catch (SQLException e) {
    throw new RuntimeSQLException(e);
}

이번에는 cause를 파라미터를 갖는 생성자를 사용하여 원래의 예외인 e를 파라미터로 전달했다.


hello.jdbc.exception.basic.UnCheckedAppTest$RuntimeSQLException: java.sql.SQLException: ex
	at hello.jdbc.exception.basic.UnCheckedAppTest$Repository.call(UnCheckedAppTest.java:64)
	...
Caused by: java.sql.SQLException: ex
	at hello.jdbc.exception.basic.UnCheckedAppTest$Repository.run(UnCheckedAppTest.java:69)
	...

이번에는 로그의 Caused by:에서 예외가 처음에 어디서 발생했는 지 확인할 수 있다. 디버깅을 위해서는 꼭 cause를 사용하도록 하자.




🔗 Reference

0개의 댓글