자바 예외

이정원·2024년 12월 13일

1.예외 이해


1.Object: 예외도 객체이기 때문에 최상위 부모는 Object이다.
2.Throwable: 최상위 예외이다. 하위에 Exception,Error가 있다.
3.Error: 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다. 개발자는 해당 예외를 잡으면 안된다.
(또한 상위 예외(ex.Throwable)를 catch로 잡으면 자식예외도 함께 잡기 때문에 구체적인 객체를 잡는게 중요하다.)
4.Exception: 체크 예외로 컴파일러가 체크하는 예외이다.
5. RuntimeException: 언체크 예외,런타임에 발생하는 예외이다.

2.예외 기본 규칙

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

예외를 처리하는 경우(try-catch)

Repository에서 예외가 발생하여 Service로 넘어가게 되고 해당 객체에서 예외를 처리한다면 Controller로 정상 흐름이 반환되게 된다.

예외를 던지는 경우(throws)

반면에 Service에서 예외를 처리하지 못하는 경우 Controller까지 예외가 전달되게 된다. 예외를 계속 Throw 한다면 웹 애플리케이션의 경우 사용자에게 오류 페이지로 보여주게 된다.

catch 블록은 특정 타입의 예외만 처리할 수 있으며, 상위 클래스 타입의 예외는 하위 클래스 타입으로 캐스팅되지 않는다. 따라서 throws로 던질때 자세한 예외를 던진다.

2-1.체크 예외 테스트

체크 예외를 던지거나,해결하는 경우의 테스트를 실행해보자.

@Slf4j
public class CheckedTest {
    @Test
    void checked_catch(){
        Service service=new Service();
        service.callCatch();
    }
    @Test
    void checked_throw() {
        Service service=new Service();
        Assertions.assertThatThrownBy(()->service.callThrow()).isInstanceOf(MyCheckedException.class);
    }


/**
 *  Exception을 상속받는 예외는 체크 예외가 된다.
 **/
    static class MyCheckedException extends Exception{
        public MyCheckedException(String message){
            super(message);
        }
    }
    /**
     *  Checked 예외는
     *  예외를 잡아서 처리하거나,던지거나 둘중 하나를 필수로 선택해야 한다.
     */
    static class Service{
        Repository repository=new Repository();

        /**
         *  예외를 잡아서 처리하는 코드
         * */
        public void callCatch(){
            try {
                repository.call();
            } catch (MyCheckedException e) {
                // 예외 처리 로직
                log.info("예외 처리, message={}",e.getMessage(),e);
            }
        }
        /**
         * 체크 예외를 밖으로 던지는 코드
         * 밖으로 던지려면 throws 를 필수로 선언해야한다.
         */
        public void callThrow() throws MyCheckedException{
            repository.call();
        }
    }
    static class Repository{
        public void call() throws MyCheckedException {
            throw new MyCheckedException("ex");
        }
    }
}

정상 흐름으로 동작하게 된다.

2-2.언체크 예외 테스트

언체크 예외는 throws를 선언하지 않고 생략할수 있다.

@Slf4j
public class UncheckedTest {
    @Test
    void unchecked_catch(){
        Service service=new Service();
        service.callCatch();
    }
    @Test
    void unchecked_throw(){
        Service service=new Service();
        Assertions.assertThatThrownBy(()->service.callThrow())
                .isInstanceOf(MyUncheckedException.class);
    }

    /***
     *  RuntimeException을 상속받은 예외는 언체크 예외가 된다.
     */
    static class MyUncheckedException extends RuntimeException{
        public MyUncheckedException(String message) {
            super(message);
        }
    }
    static class Service{
        Repository repository=new Repository();

        /***
         * 예외를 잡는 경우
         */
        public void callCatch(){
            try {
                repository.call();
            }catch (MyUncheckedException e){
                log.info("예외 처리,message={}",e.getMessage(),e);
            }
        }

        /***
         *  예외를 잡지 않는 경우,
         *  상위로 넘어간다. -> throws 예외 선언을 하지 않아도 된다.
         */
        public void callThrow(){
            repository.call();
        }
    }
    static class Repository{
        public void call(){
            throw new MyUncheckedException("ex");
        }
    }
}

3.체크 예외 활용

그렇다면 언제 체크 or 언체크 예외를 사용할까?

3-1.기본 원칙

  • 기본적으로 언체크(런타임)예외를 사용한다.
  • 체크 예외는 비지니스 로직상 의도적으로 사용하는 경우만 사용한다.(반드시 처리해야 하는 로직) -> 계좌 이체 실패

3-2.체크 예외의 문제점

  • 만약 Repository에서 DB관련 Exception과 Connection 관련 Exception이 동시에 Service 계층으로 올라온다면 대부분 애플리케이션 로직에서 처리할 방법이 없다. 그러므로 둘다 밖으로 던진다.(throws SQLException, ConnectException)

  • 컨트롤러에서도 두 예외를 처리할수 없기 때문에 밖으로 던진다.(throws SQLException, ConnectException)

  • 웹 애플리케이션이라면 서블릿의 오류 페이지나, 또는 스프링 MVC가 제공하는 ControllerAdvice에서 이런 예외를 공통으로 처리한다.

  • 개발자가 오류를 빨리 인지할 수 있도록 메일, 알림(문자, 슬랙)등을 통해서 전달 받아야 한다. 예를들어 SQL 쿼리 관련 오류라면 수정해서 배포하기 전까지 사용자의 서버 접근을 막는다.

테스트

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 SQLException, ConnectException {
            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");
        }
    }

해당 코드를 보면 2가지의 문제점을 알수 있다.
1.복구 불가능한 예외
2.의존 관계에 대한 문제

1.복구 불가능한 예외

대부분의 예외는 복구가 불가능하다.
예외 발생 시 오류 로그를 남기고 개발자가 문제를 빠르게 인지하는 것이 중요하다. 예를 들어, SQLException은 SQL 문법 오류, 데이터베이스 문제, 서버 다운 등으로 발생하며, 이는 서비스나 컨트롤러에서 복구하기 어렵다.
이런 예외는 일관적으로 공통 처리해야 하며 이를 위해 서블릿 필터, 스프링 인터셉터, 또는 @ControllerAdvice를 활용하여 공통 예외 처리 로직을 구현할 수 있다.

2.의존 관계에 대한 문제

체크 예외이기 때문에 Service에서도 Repository 관련 예외가 넘어오면 throws로 던져야한다. 이 경우 java.sql.SQLException를 import 하여 의존해야 하기 때문에 OCP를 위반하게 된다.

Exception을 던지면 해결되는것 같지만 다른 체크 예외를 체크할수 있는 기능이 무효화된다.(catch {구체 타입})

4.언체크 예외 활용


런타임 예외이기 때문에 서비스, 컨트롤러는 해당 예외들을 처리할 수 없다면 별도의 선언 없이 그냥 두면 된다.

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

    }
    static class Controller{
        Service service=new Service();
        public void request(){
            service.logic();
        }
    }
    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("ex");
        }
    }
    static class RuntimeConnectException extends RuntimeException{
        public RuntimeConnectException(String message) {
            super(message);
        }
    }
    static class RuntimeSQLException extends RuntimeException{
        public RuntimeSQLException(Throwable cause) {
            super(cause);
        }
    }
}

Repository에서 call 메서드를 호출할경우 SQLException을 잡아서 RuntimeSQLException(e)로 변환해서 예외를 던진다. 이때 예외 내용을 전달하려면 해당 클래스의 생성자를 cause로 생성한다.

이전 예외를 확인하기 위해 생성자를 cause로 생성한다.

런타임 예외를 적용함으로써 구체 클래스에 대한 의존관계가 사라진것을 볼수 있다.

결과적으로 중간에 기술이 변경되어도 해당 예외를 사용하지 않는 컨트롤러, 서비스에서는 코드를 변경하지 않아도 되고 한정적으로 예외 공통 처리만 변경하면 되므로 유지보수성이 높아진다.

실무에서 런타임 예외는 문서화가 중요하다.(코드 주석,협업툴)

5.예외 포함과 스택 트레이스

예외를 전환할 때는 꼭! 기존 예외를 포함해야 한다. 그렇지 않으면 스택 트레이스를 확인할 때 심각한 문제가 발생한다.

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

log.info에서 마지막 파라미터에 예외(e)를 넣어주면 로그에 스택 트레이스를 출력할 수 있다. 이전에 Repository의 call() 메서드에서 SQLException을 RuntimeSQLException로 변경과 함께 예외를 파라미터로 넣어 줬다.

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

덕분에 기존 예외까지 출력이 되는것을 확인할수 있다.

만약 예외를 전달하지 않으면 기존에 발생한 예외의 스택 트레이스를 확인할수 없다. -> 변환한 RuntimeSQLException만 확인 가능하다. 만약 실제 DB에 연동했다면 DB에서 발생한 예외를 확인할수 없는 심각한 문제가 발생한다.

0개의 댓글