8 min read

<Java> 예외처리 활용

앞선 포스팅에서 체크 예외와 언체크 예외의 차이점에 대해서 알아보았습니다.이번 포스팅에서는 위와 같은 예외들을 실제로 어떻게 적용시키는지 알아보겠습니다.

체크 예외의 단점

앞선 포스팅에서 체크 예외는 친절하게 컴파일러가 잡아주는 예외이며 throws 선언을 하지 않을 경우 빨간줄이 뜬다는것을 배웠습니다.그러면 상식적으로 체크 예외가 말 그대로 체크하기 편하니까 더 자주 활용되어야하지 않을까라고 생각할 수도 있습니다.하지만 실제로는 그 상황과 완전 반대입니다.실제로 throws 선언을 매번 해야한다는 것은 개발자가 항상 명시적으로 해당 예외를 catch 구문에서 잡거나 메서드 옆에 throws를 선언하여 예외를 던져야합니다.

아래의 상황을 예시로 설명해보겠습니다.

김영한 - 스프링 DB 1편
김영한 - 스프링 DB 1편

서비스단의 코드가 사용하는 레포지토리와 NetworkClient의 경우 각각,SQLExceptionConnectionException을 발생시킬 수 있습니다.즉,서비스단에서는 레포지토리와 네트워크에서 올라오는 체크 예외에 대한 처리를 해주어야 합니다.자,그러면 서비스단이 취할 수 있는 방식은 1)잡거나 2)던지거나둘 중 하나을 선택해야 합니다.만약 catch를 통해 잡았다 한들 서비스단에서 순수 SQL문제와 네트워크 커넥션 에러를 해결할 수 있을까요?불가능할 것입니다.그러면 서비스단은 던지는 선택을 할것입니다.여기서 던져진 에러들은 어디로 갈까요?바로 controller단입니다.컨트롤러 또한 마찬가지로 해결을 못하기에 던지는 선택을 할 것입니다.결국 서블릿까지가서 해당 예외는 클라이언트에게 전달 될 것입니다.

위에서 언급된 상황을 테스트 코드를 통해 실제로 한번 만나봅시다.

public class CheckedAppTest {
    @Test
    void checked() {
        Controller controller = new Controller();
        Assertions.assertThatThrownBy(() -> controller.request()).isInstanceOf(Exception.class);
    }

    static class Controller {
        Service service = new Service();

        public void request() throws SQLException, ConnectException {
            service.logic();
        }
    }

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic() throws ConnectException, SQLException {
            repository.call();
            networkClient.call();
        }

    }

    static class NetworkClient {
        public void call() throws ConnectException {
            throw new ConnectException("연결 실패");
        }

    }

    static class Repository {
        public void call() throws SQLException {
            throw new SQLException("ex");
        }

    }
}

코드를 보시면 알겠지만,레포지토리와 네트워크단에서 발생한 예외에 대해 서비스와 컨트롤러가 모두 throws 선언을 통해 명시적으로 해당 예외에 대한 처리를 진행해주고 있습니다.이는 어쩔 수 없이 서비스단에서 SQL 및 네트워크에 대한 의존으로 이어지고 단일 책임 원칙을 지키지 못하게 되는 포인트가 됩니다.실제 트랜잭션쪽의 마지막 포스팅의 최종 코드를 보아도 throws를 해결하지 못한것을 확인 가능합니다.결론적으로 저희는 Checked 예외를 사용했고 이로 인한 명시적 선언 때문에 의존 관계에 대한 문제가 발생한것을 파악했습니다.이러한 문제 때문에 저희는 언체크 예외를 사용합니다.

언체크 예외 활용하기

위와 동일한 테스트 코드를 이번에는 언체크 예외를 활용해서 작성해보겠습니다.

@Slf4j
public class UncheckedAppTest {
    @Test
    void unchecked() {
        Controller controller = new Controller();
        Assertions.assertThatThrownBy(() -> controller.request()).isInstanceOf(Exception.class);
    }

    static class Controller {
        Service service = new Service();

        public void request(){
            service.logic();
        }
    }

    @Test
    void print() {
        Controller controller = new Controller();
        try {
            controller.request();
        } catch (Exception e) {
            log.info("ex", e);
        }
    }

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic() {
            repository.call();
            networkClient.call();
        }

    }

    static class NetworkClient {
        public void call(){
            throw new RuntimeConnectException("연결 실패");
        }

    }

    static class Repository {
        public void call() {
            try {
                runSQL();
            } catch (SQLException e) {
                throw new RuntimeSQLException(e);
            }
        }

        public void runSQL() throws SQLException {
            throw new SQLException();
        }

    }

    static class RuntimeConnectException extends RuntimeException {
        public RuntimeConnectException(String message) {
            super(message);
        }
    }

    static class RuntimeSQLException extends RuntimeException {
        public RuntimeSQLException(Throwable cause) {
            super(cause);
        }
    }
}

우선 예외 클래스를 먼저 보시면 각각 extends RuntimeException으로 런타임 에러를 상속하고 있습니다.결국 저희가 사용하려는 언체크 예외를 만들었고 이를 레포지토리와 서비스에서 call() 메서드를 통해 각각 던지고 있습니다.

unchecked() 테스트 메서드를 실행시켜보면 아래 그림과 같이 깔끔하게 Exception을 발생시키며 통과하는것을 볼 수 있습니다.

assertThatThrownBy를 통한 검증로직에 RuntimeException.class 또는 RuntimeSQLException.class 를 입력해도 테스트가 통과하는것 또한 확인 가능합니다.서비스단의 로직 상 SQL에러 -> 커넥션 에러가 발생하기에 먼저 터지 에러가 SQL 에러가 됩니다.

또한 print() 메서드를 통해서도 SQL 예외가 먼저 터지는 것을 아래와 같이 확인가능합니다.

이처럼 자바 로직으로 해결하지 못하는 에러들을 런타임에러로 바꾸면 우선 1)서비스,레포지토리단의 의존성 문제를 해결하고 2)복구 불가능한 에러들에 대한 신경을 쓰지 않아도 된다는 두가지 이점이 존재합니다.

심지어 첫번째경우는 데이터 접근 기술이 바뀔경우,이 기술을 사용하지 않는 서비스 및 컨트롤러단에서 코드를 변경하지 않아도 된다는 이점도 생깁니다.예를들어 JDBC에서 JPA로 데이터 접근 기술을 바꿀 경우 기술마다 터지는 에러가 달라집니다.만약 체크예외를 사용한다면 throws를 통해 컨트롤러 및 서비스단에 선언된 에러를 일일히 바꿔줘야할것입니다.하지만 언체크 예외를 사용할 경우 이럴 필요가 없어집니다.

예외 포함과 스택 트레이스

위의 코드 중 RuntimeSQLException을 정의하는 코드를 아래와 같이 보겠습니다.

생성자를 보시면 Throwable객체를 받고 있는 것을 확인할 수 있습니다.이는 체크예외를 언체크예외로 변환시 원래 생긴 체크 예외의 스택 트레이스를 알기 위해 필수적인 파라미터입니다.즉, 아래와 같이 실제 예외 SQLException을 인자로 받아서 RuntimeSQLException이라는 새로운 언체크 예외를 만들어내야 기존의 SQLException에 대한 스택 로그들을 확인 가능합니다.

이를 확인하기위해 print() 메서드를 다시 한번 실행시켜봅시다.SQLException 까지 로그에 출력되는것을 확인 가능합니다.

이번에는 아래와 같이 파라미터로 들어가는 SQLException를 제외하고 RuntimeSQLException를 만들고 print()를 실행시켜봅시다.아래와 같이 코드 변경하시면 됩니다.(디폴트 생성자를 만들어 주시면 됩니다)

아래는 로그 출력 결과입니다.단순히 RuntimeSQLException가 발생했다는 것까지 나오고 해당 예외가 어떠한 진짜 예외 때문에 발생했는지 로그에 남겨져 있지 않습니다.즉,실제로 DB에서 발생한 예외를 확인할 수 없게 된 것입니다.

결론적으로 정리하면 특정한 예외를 런타임 예외로 전환할 경우,항상 기존예외를 해당 예외에 포함시켜 새로운 예외를 생성해야합니다.

이상으로 자바 예외처리를 할 경우,런타임 예외를 활용하는 원칙에 대해서 알아보았습니다.