Programming/Spring

Spring - 자바 예외 이해

잇(IT) 2023. 7. 2. 11:47

- 예외 계층

 

1. Object : 예외도 객체이다. 모든 객체의 최상위 부모는 Object 이므로 예외의 최상위 부모도 Object 이다.

2. Throwable : 최상위 예외이다. 하위에 Exception 과 Error가 있다.

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

   3.1. 상위 예외를 catch 로 잡으면 그 하위 예외까지 함께 잡는다. 따라서 애플리케이션 로직에서는 Throwable 예외도 잡으면 안되는데, 앞서 이야기한 Error 예외도 함께 잡을 수 있기 때문이다. 애 플리케이션 로직은 이런 이유로 Exception 부터 필요한 예외로 생각하고 잡으면 된다.

   3.2. 참고로 Error도 언체크 예외이다.

4. Exception : 체크 예외

   4.1. 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.

   4.2. Exception 과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단 RuntimeException 은 예외로 한다.

5. RuntimeException : 언체크 예외, 런타임 예외

   5.1.컴파일러가 체크 하지 않는 언체크 예외이다.

   5.2. RuntimeException 과 그 자식 예외는 모두 언체크 예외이다.


- 예외 기본 규칙

1. 예외는 잡아서 처리하거나 던져야 한다.

2. 예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리된다.

   2.1. 예를 들어서 Exception 을 catch 로 잡으면 그 하위 예외들도 모두 잡을 수 있다.

   2.2. 예를 들어서 Exception 을 throws 로 던지면 그 하위 예외들도 모두 던질 수 있다.

 

- 예외를 처리하지 못하고 계속 던지면

1. 자바 main() 쓰레드의 경우 예외 로그를 출력하면서 시스템이 종료된다.

2. 예외 시스템으로 인해 많은 사용자가 사용하는 웹 애플리케이션의 시스템이 종료되면 안되기 때문에 주로 개발자가 지정한 오류 페이지를 보여준다.


- 체크 예외 기본 이해

 

- Exception 과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단 RuntimeException 은 예외 로 한다.

- 체크 예외는 잡아서 처리하거나, 또는 밖으로 던지도록 선언해야한다. 그렇지 않으면 컴파일 오류가 발생한 다.

 

@Slf4j
public class CheckedTest {

    @Test
    void checked_catch() {
        Service service = new Service();
        service.callCatch();
    }

    @Test
    void checked_throw() {
        Service service = new Service();
        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 예외를 메서드에 필수로 선언해야한다.
        * @throws MyCheckedException
        * */
        public void callThrow() throws MyCheckedException {
            repository.call();
        }
    }

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

- Exception을 상속받은 예외는 체크 예외가 된다.

    /*
    * Exception을 상속받은 예외는 체크 예외가 된다.
    * */
    static class MyCheckedException extends Exception {
        public MyCheckedException(String message) {
            super(message);
        }
    }

1. MyCheckedException 는 Exception 을 상속받았다. Exception 을 상속받으면 체크 예외가 된다.

2. 참고로 RuntimeException 을 상속받으면 언체크 예외가 된다. 이런 규칙은 자바 언어에서 문법으로 정한 것이다.

3. 예외가 제공하는 여러가지 기본 기능이 있는데, 그 중에 오류 메시지를 보관하는 기능도 있다. 예제에서 보는 것 처럼 생성자를 통해서 해당 기능을 그대로 사용하면 편리하다.

 

 @Test
    void checked_catch() {
        Service service = new Service();
        service.callCatch();
    }

- callCatch()를 호출했을 때

/*
        * 예외를 잡아서 처리하는 코드
        * */
        public void callCatch() {
            try {
                repository.call();
            } catch (MyCheckedException e) {
                //예외 처리 로직
                log.info("예외 처리, message={}", e.getMessage(), e);
            }
        }

- 해당 메서드에서 예외를 잡아버리기 때문에 호출한 곳까지 올라가지 않는다.

 

1. test service.callCatch() repository.call() [예외 발생, 던짐]

2. test service.callCatch() [예외 처리] repository.call()

3. test [정상 흐름] service.callCatch() repository.call()

 

log.info("예외 처리, message={}", e.getMessage(), e);

1. 실행 결과 로그를 보면 첫줄은 우리가 남긴 로그가 그대로 남는 것을 확인할 수 있다.

2. 그런데 두 번째 줄 부터 예외에 대한 스택 트레이스가 추가로 출력된다.

3. 이 부분은 로그를 남길 때 로그의 마지막 인수에 예외 객체를 전달해주면 로그가 해당 예외의 스택 트레이스 를 추가로 출력해주는 것이다.

   3.1. log.info("예외 처리, message={}", e.getMessage(), e); 여기서 마지막에 있는 e 부분이다.

 

public void callCatch() {
            try {
                repository.call();
            } catch (Exception e) {
                //예외 처리 로직
                log.info("예외 처리, message={}", e.getMessage(), e);
            }
        }

- catch 에 MyCheckedException 의 상위 타입인 Exception 을 적어주어도 MyCheckedException 을 잡을 수 있다.


- 예외를 밖으로 던지는 경우

@Test
    void checked_throw() {
        Service service = new Service();
        assertThatThrownBy(() -> service.callThrow())
                .isInstanceOf(MyCheckedException.class);
    }
/*
        * 체크 예외를 밖으로 던지는 코드
        * 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야한다.
        * @throws MyCheckedException
        * */
        public void callThrow() throws MyCheckedException {
            repository.call();
        }
    }

1. test service.callThrow() repository.call() [예외 발생, 던짐]

2. test service.callThrow() [예외 던짐] repository.call()

3. test [예외 도착] service.callThrow() repository.call()

 

- 체크 예외의 경우 throws를 지정하지 않으면 컴파일 오류가 발생한다.

 

public void callThrow() throws Exception {
            repository.call();
        }

- 상위 타입인 Exception을 적어주게되면 throws에 지정한 타입과 그 하위 타입 예외를 밖으로 던진다.


- 언체크 예외 기본 이해

 

1. RuntimeException 과 그 하위 예외는 언체크 예외로 분류된다.

2. 언체크 예외는 말 그대로 컴파일러가 예외를 체크하지 않는다는 뜻이다.

3. 언체크 예외는 체크 예외와 기본적으로 동일하다. 차이가 있다면 예외를 던지는 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);
        }
    }

    /*
     * UnChecked 예외는
     * 예외를 잡거나, 던지지 않아도 된다.
     * 예외를 잡지 않으면 자동으로 밖으로 던진다.
     * */
    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");
        }
    }
}
/*
        * 필요한 경우 예외를 잡아서 처리하면 된다.
        * */
        public void callCatch() {
            try {
                repository.call();
            } catch (MyUncheckedException e) {
                //예외 처리 로직
                log.info("예외 처리, message={}", e.getMessage(), e);
            }
        }

- 언체크 예외도 필요한 경우 catch를 통해 처리할 수 있다.

public void callThrow() {
            repository.call();
        }

1. 언체크 예외는 체크 예외와 다르게 throws 예외 를 선언하지 않아도 된다.

2. 말 그대로 컴파일러가 이런 부분을 체크하지 않기 때문에 언체크 예외이다.

 

- 언체크 예외도 throws 예외를 선언해도 된다. 선언해두면 해당 코드를 호출하는 개발자가 이런 예외가 발생한다는 점을 IDE를 통해 좀 더 편리하게 인지할 수 있다.


- 체크 예외의 장단점

 

1. 체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외 를 필수로 선언해야 한 다. 그렇지 않으면 컴파일 오류가 발생한다. 이것 때문에 장점과 단점이 동시에 존재한다.

- 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전 장치이다.

- 단점: 하지만 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에, 너무 번 거로운 일이 된다. 크게 신경쓰고 싶지 않은 예외까지 모두 챙겨야 한다. 추가로 의존관계에 따른 단점도 있 는데 이 부분은 뒤에서 설명하겠다.

 

- 언체크 예외의 장단점

 

1. 언체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외 를 생략할 수 있다. 이 것 때문에 장점과 단점이 동시에 존재한다.

- 장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던 지려면 항상 throws 예외 를 선언해야 하지만, 언체크 예외는 이 부분을 생략할 수 있다. 이후에 설명하겠지 만, 신경쓰고 싶지 않은 예외의 의존관계를 참조하지 않아도 되는 장점이 있다.

- 단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 반면에 체크 예외는 컴파일러를 통해 예외 누 락을 잡아준다.


- 체크 예외 활용


- 언체크 예외 활용

 

1. SQLException 을 런타임 예외인 RuntimeSQLException 으로 변환했다.

2. ConnectException 대신에 RuntimeConnectException 을 사용하도록 바꾸었다.

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

 

@Slf4j
public class UnCheckedAppTest {

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

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

    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);
        }
    }
}

- 예외 전환

1. 리포지토리에서 체크 예외인 SQLException 이 발생하면 런타임 예외인 RuntimeSQLException 으로 전 환해서 예외를 던진다.

   1.1. 참고로 이때 기존 예외를 포함해주어야 예외 출력시 스택 트레이스에서 기존 예외도 함께 확인할 수 있 다. 예외 포함에 대한 부분은 조금 뒤에 더 자세히 설명한다.

2. NetworkClient 는 단순히 기존 체크 예외를 RuntimeConnectException 이라는 런타임 예외가 발생하 도록 코드를 바꾸었다.

 

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();
        }
    }

1. 런타임 예외이기 때문에 컨트롤러나 서비스가 예외를 처리할 수 없다면 다음 부분을 생략할 수 있다.

2. method() throws RuntimeSQLException, RuntimeConnectException

3. 따라서 컨트롤러와 서비스에서 해당 예외에 대한 의존 관계가 발생하지 않는다.

 

1. 런타임 예외를 사용하면 중간에 기술이 변경되어도 해당 예외를 사용하지 않는 컨트롤러, 서비스에서는 코 드를 변경하지 않아도 된다.

2. 구현 기술이 변경되는 경우, 예외를 공통으로 처리하는 곳에서는 예외에 따른 다른 처리가 필요할 수 있다. 하지만 공통 처리하는 한곳만 변경하면 되기 때문에 변경의 영향 범위는 최소화 된다


- 예외 포함과 스택 트레이스

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

- 로그를 출력할 때 마지막 파라미터에 예외를 넣어주면 로그에 스택 트레이스를 출력할 수 있다.

1. 예) log.info("message={}", "message", ex) , 여기에서 마지막에 ex 를 전달하는 것을 확인할 수 있다. 이렇게 하면 스택 트레이스에 로그를 출력할 수 있다.

2. 예) log.info("ex", ex) 지금 예에서는 파라미터가 없기 때문에, 예외만 파라미터에 전달하면 스택 트레이스를 로그에 출력할 수 있다.

 

- 기존 예외를 포함하는 경우

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

- 예외를 포함해서 기존에 발생한 java.sql.SQLException 과 스택 트레이스를 확인할 수 있다.

 

- 기존 예외를 포함하지 않는 경우

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

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

- 예외를 포함하지 않아서 기존에 발생한 java.sql.SQLException 과 스택 트레이스를 확인할 수 없다. 변 환한 RuntimeSQLException 부터 예외를 확인할 수 있다. 만약 실제 DB에 연동했다면 DB에서 발생한 예 외를 확인할 수 없는 심각한 문제가 발생한다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

출처 : 인프런 - 우아한 형제들 기술이사 김영한의 스프링 완전 정복 (스프링 DB 1편 - 데이터 접근 핵심 원리)

728x90