스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션5. 자바 예외 이해
CS/김영한 스프링 강의

스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션5. 자바 예외 이해

앞으로 스프링에서의 예외 배울 건데 개념을 짚고 넘어가자. 

자바의 모든 객체는 항상 Object가 부모다. 이건 예외 클래스도 마찬가지. 단지 예외 클래스 시작은 Throwable부터 시작한다고 보면 된다. 여기서 Exception과 Error 자식 클래스로 나뉘는데 Error클래스는 메모리 초과나 컴퓨터의 심각한 오류같은 것이기 때문에 잡으면 안되고 그냥 생기도록 그대로 두어야 한다. 무리해서 더 진행하면 컴퓨터 망가지겠지?

그럼 Exception부터 애플리케이션 관리 영역이다. 실행 전 컴파일러가 잡을 수 있는 에러를 체크예외, 실행 중에 잡을수 있는 에러를 언체크 예외 또는 런타임 예외라고 하는데, Exception부터 자식들은 모두 체크예외지만 그의 자식인 RuntimeException만 예외라고 보면 된다고 한다. 아까 앞에서 봤던 언체크만 잡네 마네 했던게 이거 말하는 거.

 

던진다는 계념이 뭐냐면 그냥 자기를 호출했던 함수들 위로 계속 올리는 폭탄던지기 같은거다... 누군가 catch (e) {} 해서 처리해야 함. 만약 모든 애들이 던져서 main() 스레드 밖으로 나갈 경우 프로그램이 종료된다. 하지만 웹 애플리케이션의 경우 종료되면 안되기 때문에 WAS가 잡아서 처리하고 개발자에게 에러를 띄우는 방식으로 진행한다.

 

 

이렇게 체크 예외는 컴파일러에서 잡을 수 있다. 자기가 처리하지 않고 넘길거면 던진다는 걸 적으라는 경고를 내린다. 안하면 빌드 자체가 안된다.

@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.error("예외 처리, message={}", e.getMessage(), e);
            }
        }

        /**
         * 체크 예외를 밖으로 던지는 코드
         * 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야 함.
         *
         * @throws MyCheckedException
         */
        public void callThrow() throws MyCheckedException {
            repository.call();
        }
    }

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

}

체크 에러에 대한 이론을 테스트 해본거다. 자기가 에러를 잡아 try catch 했으면 그 밑에서부터는 정상 실행되어 상위로 던질 필요가 없다는 점, 자기가 처리 못하겠으면 throws를 해서 자기를 호출한 상위로 던진다는 점이 있다. 주의할건 그냥 Exception으로 받으면 모든 예외를 처리하기 때문에 구체적인 에러를 작성하는게 좋다.

 

다음은 언체크 예외

@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();
        }
        
        // throws로 선언해줘도 된다.
//        public void callThrow() throws MyUncheckedException {
//            repository.call();
//        }
    }

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

        // throws로 선언해줘도 된다.
//        public void call() throws MyCheckedException {
//            throw new MyUncheckedException("ex");
//        }
    }

}

RuntimeException은 언체크 예외기 때문에 throws를 안적어도 알아서 상위로 간다. 적어도 되지만 달라지는게 전혀 없어서 IDE 작성 쉽게하는 정도. 장점은 안적어도 되서 편하고 단점은 놓칠수도 있다는거.. 물론 얘도 try catch해서 잡고 하는건 다 똑같다.

 

그냥 throws를 적느냐 마느냐의 작은 차이인데 실제 업무에선 이 차이때문에 사용법이 많이 달라진다고 한다. 추천해준 원칙은 왠만하면 런타임 예외를 사용하고 비즈니스적인 개발자도 반드시 알아야 하는 계좌이체 예외, 결제 예외, 아이디 비밀번호 실패 예외같은 것들은 체크예외로 명시적으로 적는 것이다.

 

그럼 컴파일러에서 한번 체크해주는게 정말정말 좋으니까 체크예외로만 하면 되지 않냐? 일단 체크 예외 문제점을 보자.

일일히 throws해서 위까지 보내야 하는데, 이러면 순수 서비스 계층 만들겠다고 이런저런거 했던게 다 헛고생이 된다. 안던지고 안에서 처리할려고 해도 보통 예외는 애플리케이션에서 고치기 불가능한 경우가 대부분이므로 빨리 위로 올려서 공통적인 에러를 내고 개발자에게 이런 에러가 떴으니 확인하라고 날리는게 중요하다. 왜 공통적으로 내야하냐면 어떤 에러든 사용자는 관심없기 때문이고 해킹 위험도 있음.

 

일단 체크로만 했을 때의 상황 테스트인데 직접 보면 좀 와닿을것 같다. 자기가 어차피 해결 불가능해서 위로 올려야 하기 때문에 계속 throws를 작성한다는 점, 이 작성을 위한 것 때문에 패키지를 불러와야 해서 순수성이 깨진다는 점. 거기다 throws를 패키지마다 일일히 적어주기 때문에 DB를 바꾼다던가 하는 밑의 환경이 달라지면 위에도 일일히 다 수정해야 한다. 그럼 기막힌 방법으로 Exception은 모든 에러를 받아들이니 throws Exception만 하면 깔끔하지 않겠냐 하면 진짜 다 받아들이기 때문에 의도하지 않은 것들도 처리해서 문제 자체를 못 발견할 수도 있는 더 큰 문제가 생길 수 있다.

 

런타임 예외는 위와 같이 막 덕지덕지 할 필요가 없다. 어차피 내가 처리할 수 없는 거라면 알아서 위로 넘긴다는 마인드이다.

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

    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 RuntimeConnectionException("연결 실패");
        }
    }

    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 RuntimeConnectionException extends RuntimeException {
        public RuntimeConnectionException(String message) {
            super(message);
        }
    }

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

체크 예외도 런타임 예외로 바꾸기까지 하면서 위로 넘기는 걸 볼 수 있다. throws가 사라지면서 어차피 위로 예외가 가는걸 확인할 수 있다. 중간중간에 throws를 작성하지 않기 때문에 의존성 문제를 해결할 수 있다. 바뀌는 것도 처리하고 싶은 공통 부분의 맨 위만 바뀌면 되서 최소화 되었다.

처음에는 개발자들도 컴파일러가 한번 체크해주는게 좋으니 체크 예외를 사용했다고 한다. 그러나 시간이 지나 여러 라이브러리를 사용하게 되면서 막 덕지덕지 붙어대니 걍 Exception을 catch해서 사용했지만 위험하니 이럴 바엔 그냥 런타임으로 바꿔서 날리자고 하여 이런 방식으로 많이 사용하고 최근 라이브러리들로 대부분 런타임 예외를 제공한다. 스프링도 대부분 런타임 예외를 제공한다. 추가로 런타임 예외는 놓칠 수 있기 때문에 문서화가 중요한다.

 

 

반드시 조심해야 할 건 체크 예외를 런타임 예외로 바꿀 때 에러 내용 e까지 반드시 넣어서 반환해줘야 한다. 안 그럼 에러 추적을 못 한다. 깜빡하지 않도록 조심하라고.