Spring DB : 체크 예외 / 런타임 예외 활용

    이 글은 인프런 김영한님의 강의를 복습하며 작성한 글입니다.

    체크 예외와 언체크 예외는 언제 사용하지?

    내가 고려해야 할 수많은 문제 100개가 있다고 가정해보자. 이 때, 이 문제를 모두 해결하기 위한 방법은 하나씩 모든 문제를 처리하는 방법이 있을 것이다. 또 다른 방법은 대원칙을 하나 정하고, 그 대원칙에서 벗어나는 문제들을 하나씩 처리하는 방법이 있을 것이다. 개발자 입장에서는 후자의 방법이 더 좋다.  앞으로 예외를 처리할 때는 다음과 같이 처리하자! 

    1. 기본적으로 런타임 예외를 사용한다. 런타임 예외를 잘 사용하기 위해 Document에 잘 정리한다. 
    2. 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용한다.

    그렇다면 어떤 경우가 체크 예외를 사용하는 예시일까? 

    1. 계좌 이체 실패 예외 → 비즈니스적으로 중요. 복구가 필요하거나, 손님에게 알려야함.
    2. 결제 시 포인트 부족 예외
    3. 로그인 ID, PW 불일치 예외 

    물론 위 경우에도 굳이 체크 예외를 사용할 필요는 없다. 그렇지만 계좌이체 같은 비즈니스 로직 실패 시, 개발자가 놓치면 안된다고 판단할 수 있다. 이 때는 의도적으로 체크 예외를 사용하고, 컴파일러를 통해 한번 더 정밀하게 처리해줄 수 있도록 하는 것이 좋다. 

     

    체크 예외의 문제점

    체크 예외는 컴파일러가 컴파일 단계에서 미리 처리를 해주는 장점이 있는데, 왜 런타임 예외를 주로 사용하라고 하는 것일까? 이것은 체크 예외에 아주 큰 단점이 하나 존재하기 때문이다. 그 단점은 현재 상태에서 Exception을 처리할 수 없는 경우, "method() Throws" 형식으로 체크 예외를 던져야 한다는 점이다. 

    위의 그림을 살펴보면 SQL Exception / ConnectException이 Service / Controller 계층으로 넘어오는 것을 볼 수 있다.

    1. Repository는 SQL Exception / NetworkClient는 ConnectException이 발생함. 
    2. Service는 두 곳에서 올라오는 체크 예외인 SQLException, ConnectionException 체크 예외를 처리해야한다.
      • Service에서는 이 두 예외를 처리할 방법이 없다. ConnectException처럼 연결 실패 / SQL Exception처럼 DB 문제들은 Service에서 처리할 수 없다. 
      • Service는 처리할 수 없는 문제기 때문에 Controller로 예외를 던진다. → Method에 Throws SQLException, ConnectionException이 명시됨.
    3. Controller도 두 Exception을 처리할 방법이 없다
      • Controller도 예외를 처리할 수 없으므로 윗쪽으로 throws SQLException, ConnectException을 한다.
      • Controller도 두 Exception으로 인한 문제가 발생한다. Controller가 100개가 있다면, 100개에 대한 예외 처리를 해줘야한다. 
    4. WEB은 서블릿 컨테이너 / ControllerAdvice에서 이런 예외를 공통으로 처리해준다.
      • 사용자들에게는 오류 페이지를 렌더링 해준다.
      • 개발자들에게는 해당 오류를 인지할 수 있도록 메일링 처리를 해준다.

    여기서 이야기하는 문제는 DB단에서 올라온 문제를 Service / Controller 단에서 처리할 수 있는 방법이 없음에도 불구하고 체크 예외기 때문에 명시적으로 Throws를 해줘야한다는 것이다. 

     

    코드 실습

    위의 상황을 실습해본다. DB에서 만들어진 체크 예외가 Controller까지 명시적으로 넘어가고, Controller에서도 예외를 처리하지 못해 던지는 상황이다.

     

    ConnectException → 체크 예외처리

    static class ConnectException extends Exception{
        public ConnectException(String message) {
            super(message);
        }
    }

     

    Repository + NetworkClient 작성

    static class Repository{
        public void call() throws SQLException {
            throw new SQLException();
        }
    }
    
    static class NetworkClient{
        public void call() throws ConnectException {
            throw new ConnectException("ex");
        }
    }
    • 각각 SQLException / ConnectException을 발생시킨다.
    • 각 예외는 Exception을 상속했기 때문에 체크 예외다. 따라서 Throws로 던져줘야한다.

     

    Controller / Service

    static class Controller{
        Service service = new Service();
    
        public void request() throws SQLException, ConnectException {
            service.call();
        }
    
    }
    
    static class Service{
    
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();
    
        public void call() throws SQLException, ConnectException {
            repository.call();
            networkClient.call();
        }
    }
    • Service / Controller는 본인이 SQLException / ConnectException을 처리할 수 없어 모두 Throws한다.

     

    테스트 코드

    @Test
    void checked() {
        Controller controller = new Controller();
        Assertions.assertThatThrownBy(() -> controller.request()).isInstanceOf(Exception.class);
    }
    • Controller.request()를 하면 체크 예외가 넘어오게 된다. 

     

     

    체크 예외의 두 가지 문제

    1. 복구 불가능한 예외
    2. 의존 관계 문제

    체크 예외가 각 계층에서 야기시키는 문제는 위 두 가지 문제가 존재한다. 각각의 문제를 하나씩 뜯어서 살펴본다.

    복구 불가능한 예외

    대부분의 예외는 복구가 불가능하다. 예를 들어 SQLException은 DB에 문제가 있어서 발생하는 문제다. SQL 문제, DB 문제, DB 서버 다운 같은 문제들인데 모두 복구가 불가능하다. 그리고 이런 문제는 Controller / Service 단에서 복구할 수 있는 문제가 아니다. 

    이처럼 복구 불가능한 예외들은 일관성있게 공통으로 처리해야한다. 복구 불가능한 예외들은 사용자들에게는 오류 페이지를 보여주고, 개발자가 해당 오류를 빠르게 인식할 수 있도록 처리해야한다. 이런 공통의 예외처리를 하는 것은 Filter / Interceptor, ControllerAdvice 등을 사용해서 처리할 수 있다.


    의존관계에 대한 문제

    체크 예외는 각 계층에 의존관계 문제를 남긴다. 대부분의 예외는 복구가 불가능하다. 따라서 Controller / Service 입장에서 이런 예외를 신경 쓸 필요가 없다. 왜냐하면 어차피 대응을 할 수 없는 문제다. 그렇지만 체크 예외를 사용하게 될 경우, 신경을 써야하는 것이 강제된다. 즉, 각 메서드에 Throws를 통해 체크 예외를 명시해줘야한다. 

    왜 이것이 문제일까? 바로 Service / Controller 계층에 각 예외 정보가 남기 때문이다. 예를 들어 SQLException은 java.sql.SQLException이다. 이 예외가 Service / Controller 계층에 명시되는 순간 그 계층은 JDBC 기술에 의존하게 된다. 나중에 Repository의 기술을 JPA로 변경하게 된다면, Service / Controller 계층은 SQLException이 아닌 JPAException이 나오도록 처리가 되어야 한다.

    JDBC → JPA 기술 변경 : 각 계층에 컴파일 오류 발생

    Repository를 인터페이스로 구현해서 DI를 한다고 하더라도, Repository 계열에서 발생한 Exception 누수가 Service / Controller 계층을 특정 DB 기술에 종속적으로 만들어 버리기 때문에 다른 DB 기술의 Repository를 DI 하게되면 체크 예외를 추가해야한다는 큰 불상사가 발생한다. 즉, 안티패턴의 역할을 한다. 

     

    정리

    • 대부분의 예외는 시스템적으로 발생하는 것이기 때문에 Service / Controller 계층에서 처리할 수 없다. 따라서 Service / Controller 계층은 이런 예외들에 대해 모르는 것이 최선이다. 이 때 체크 예외를 사용하게 되면 Service / Controller 계층이 각 체크 예외를 명시해야만 한다. 이 과정에서 불필요한 의존관계 문제도 발생할 수 있다.
    • 불필요한 의존 관계 해결을 위해서 Throws Exception으로 처리할 수 없다. 왜냐하면 모든 예외를 던지기 때문이다. 특정 예외들은 내가 특정 계층에서 잡아서 처리해줄 수 있는 방법이 있다. 그리고 모든 예외를 항상 던지기 때문에 사실상 체크 예외의 기능이 무효화 된다. 즉, 중요한 예외를 잡아야 하는데 잡지 못하게 된다. 
    • 방법은 기본적으로 런타임 예외를 사용한다.

     

    런타임 예외 활용

    체크 예외를 사용하게 되면 각 계층에 불필요한 의존성 문제가 발생할 수 있음을 확인했다. 이런 문제는 런타임 예외를 사용할 경우 깔끔하게 해결된다.

    체크 예외 → 런타임 예외로 변경

    앞서 만들었던 SQLException / ConnectException을 모두 런타임 예외로 바꿔서 던지면 의존성 문제가 해결된다. 런타임 예외는 반드시 예외를 Catch / Throws를 하지 않아도 되기 때문에 각 계층에 명시하지 않아도 되기 때문이다. 다시 말해, 런타임 예외이기 때문에 Service / Controller는 처리할 수 없는 예외를 별도의 선언없이 그냥 두기만 하면 된다. 

    테스트 코드 작성

    SQLException / ConnectionException을 모두 RuntimeException을 상속받은 런타임 예외로 만들어서 발생시킨다. 그리고 Controller / Service 계층에서 런타임 예외가 표기 되지 않아서 불필요한 의존 관계가 사라지는 것을 확인하면 된다. 

    런타임 예외 작성

    static class RuntimeConnectException extends RuntimeException{
        public RuntimeConnectException(String message) {
            super(message);
        }
    }
    
    static class RuntimeSQLException extends RuntimeException{
        public RuntimeSQLException(String message) {
            super(message);
        }
    }
    • RuntimeException을 상속받은 각 예외를 만든다.

     

    Repository / NetworkClient

    static class Repository{
        public void call() {
            throw new RuntimeSQLException("ex");
        }
    }
    
    static class NetworkClient{
        public void call() {
            throw new RuntimeConnectException("ex");
        }
    }
    • Repository / NetworkClient 클래스는 각 런타임 예외를 발생한다.
    • 런타임 예외이기 때문에 Throws를 하지 않아도 된다.

     

    Controller / Service

    static class Controller{
        Service service = new Service();
    
        public void request(){
            service.call();
        }
    
    }
    
    static class Service{
    
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();
    
        public void call(){
            repository.call();
            networkClient.call();
        }
    }
    • Controller / Service에서 각각 아래 계층을 호출한다.
    • Repository / NetworkClient 계층부터 발생한 문제는 언체크 예외이기 때문에 Controller / Service 계층에서 처리할 필요가 없다. 즉, Throws + 예외로 인한 불필요한 의존 문제가 해결된다.

     

    체크 예외 → 런타임 예외 변경 이점

    런타임 예외는 대부분 복구 불가능한 예외!

    시스템에서 발생한 예외는 어플리케이션에서 복구 할 수 있는 방법이 전무하다. 이 때, 런타임 예외를 사용하면 Service / Controller가 복구 불가능한 예외를 신경쓰지 않아도 된다(Throws). 이렇게 던져진 예외는 ControllerAdvice 등에서 일관적으로 처리될 수 있도록 한다.

    런타임 예외는 의존관계 문제를 처리해준다.

    런타임 예외를 사용하게 되면 Throws를 사용하지 않아도 된다. Throws를 사용하게 되면, 해당 메서드 + 해당 계층에서 불필요한 의존관계가 형성되게 된다. 런타임 예외는 이런 것을 방지해주기 때문에 불필요한 의존관계 문제를 처리해준다.

     

    언체크 예외 사용 + 구현 기술 변경 시 파급효과

    체크 예외를 사용하게 되면 Repository에서 발생하는 JDBC 기술의 SQLException이 Service / Controller 계층에 전파되었던 것을 알 수 있다. 이 때, JDBC → JPA로 변경하게 되면 체크 예외가 JPAException이 추가되면서 Service / Controller 계층에서의 코드 변경이 필요했다.

    런타임 예외를 사용하면, JDBC → JPA로 기술 변경을 해도 Repository 단에서 발생하는 예외를 RuntimeSQLException → RuntimeJPAException으로 바꿔주기만 하면 된다. 그리고 Service / Controller는 런타임 예외 덕분에 Repository 단에서 발생하는 예외 누수를 걱정하지 않아도 된다. 즉, 변경범위가 Repository 단으로 최소화 된다. 

    체크 예외 / 런타임 예외 사용 시, 의존관계

    정리해보면 위와 같이 볼 수 있겠다.

    1. 체크 예외 사용 시, Repository 단의 예외가 누수되면서 Repository의 기술 변경 시 전범위한 코드 수정이 필요하다.
    2. 런타임 예외 사용 시, Repository 단의 예외가 누수되지 않기 때문에 Service / Controller 계층은 Repository와 완전 독립된다.

     

    정리

    • 처음에는 체크 예외가 컴파일 시점에 모든 예외를 잡아주기 때문에 좋은 선택이라고 생각했다. 그렇지만 시간이 흐르면서 복구할 수 없는 예외가 많아지며 체크 예외 때문에 불필요한 의존성이 추가되었다. 개발자들은 이 문제를 해결하기 위해 "throws Exception"이라는 극단적인 방법도 자주 사용하게 되었다. 이런 사용법은 체크 예외 자체의 장점을 무력화 시키는 방법이다. 
    • 기본적으로 런타임 예외를 사용한다. 위와 같은 이유 때문에 대부분의 라이브러리들은 런타임 예외를 기본으로 제공한다. 런타임 예외도 잡을 수 있는 건 잡을 수 있다. 필요한 경우에는 예외를 잡아서 처리해주면 되고, 그렇지 않은 경우 공통 예외 처리계층까지 나가도록 두기만 하면 된다. 
    • 런타임 예외는 놓칠 가능성이 있다. 따라서 문서화가 아주 중요하다!

    댓글

    Designed by JB FACTORY