자바 예외 이해

고동현·2024년 6월 10일
0

DB

목록 보기
4/13

예외 계층

  • Object: 예외도 객체다. 모든 객체의 최상위 부모는 Object이다.
  • Throwable: 최상위 예외. 하위에 Exception과 Error가있음
  • Error: 메모리부족, 심각한 시스템 오류같이 복구 불가능한 예외 -> 개발자는 이 예외를 잡으려 하면 안된다.
  • Exception: 체크 예외
    컴파일러가 체크하는 예외
    애플리케이션 로직에서 사용할 수 있는 실질적이 최상위 예외
    -> Throwable을 예외로 잡아버리면 하위 예외까지 잡으므로 Error가 잡혀서 안됨
  • RuntimeException: 언체크 예외, 런타임 예외
    컴파일러가 체크하지 않는 예외, 하위 자식들 보두 언체크 예외

예외 기본 규칙

예외에 대해서는 2가지 기본 규칙이 있다.

  1. 예외는 잡아서 처리하거나 던져야한다.
  2. 예외를 잡아서 던질때 지정한 예외 뿐만 아니라 그 예외의 자식들도 함께 처리가 된다. -> Exception을 잡거나 던지면, 그 하위예외도 모두 잡거나 던질수 있다.

if). 예외를 처리하지 못하고 계속 던지면 어떻게 될까?

  • 자바 main()쓰레드의 경우 예외 로그를 출력하면서 시스템이 종료된다.
  • 웹 애플리케이션의 경우 여러 사용자가 요청하므로, 하나의 요청에 예외가 발생하더라도 시스템이 종료되면 안되므로, WAS가 해당 예외를 받아서 처리한다. 오류페이지를 보여주는등.
    해당 내용은 이 글을 참고하자

체크 예외

모든 예외는 잡아서 처리하거나, 던져야한다.
단, 쳬크 예외는 던질때 mehtod() throws예외를 만드시 지정해줘야한다.

Test

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

MyCheckedException은 Exception을 상속받고 있다. -> CheckedException이 된다.

Repository

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

리포지토리에서 CheckedException을 발생시킨다.

Service

static class Service{
        Repository repository = new Repository();
        public void callCheck(){
            try{
                repository.call();
            }catch (MyCheckedException e){
                log.info("예외 처리, message = {}",e.getMessage(),e);
            }
        }

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

callCheck메서드는 오류를 잡아서 처리하는 메서드이다.
callThrow메서드는 오류를 처리하지 않고 그냥 던지는 예외이다.
checked예외는 오류를 던지기 위해서 반드시 던지는 예외를 명시해줘야한다.(안하면 빨간줄 컴파일 오류 발생)
-> throws MyCheckedException

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

callCheck메서드 호출 -> Repository.call호출 -> Exception발생 -> Repository에서는 던지므로 상위 Service의 callCheck로 온다. catch부분이 있으므로 오류 처리 -> 정상처리 로직이 됨

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

동일하게 호출 -> 그러나 Service의 callTrow에서 상위로 예외를 던지고 있음, 그래서 해당 checked_throw메서드로 MychekedException이 올라오는데 Assertions로 해당 오류가 맞는지 검증

정리

  • 예외는 던지거나, 잡아서 처리해야한다.
  • 잡아서 처리하면 -> 정상로직
  • CheckedException은 던질때 method() throws 던질예외 를 명시해야한다.

체크예외의 장단점
장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아준다.
단점: 개발자가 모든 체크예외를 던지거나 잡아야하는데 너무 번거롭다. 만약 DB가 연결이 안되면 -> 애초에 서비스단에서 예외를 처리할 수 있는게 없는데 그럼에도 불구하고 throws를 작성해야한다.

언체크 예외

@Slf4j
public class UnCheckedTest {
    static class MyUncheckedException extends RuntimeException{
        public MyUncheckedException(String message){
            super(message);
        }
    }

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

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

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

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

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

    @Test
    void unchecked_throw(){
        Service service = new Service();
        assertThatThrownBy(() -> service.callThrow()).isInstanceOf(MyUncheckedException.class);
    }
}

로직 자체는 체크예외와 비슷하다.

  • MyUncheckedExcpetion은 RuntimeException을 상속받음 -> UncheckedException이 됨
  • 서비스에서 에외 처리 로직은 try catch로 동일,
    but, 그냥 예외를 던질때는 굳이 throws로 명시를 하지 않아도 알아서 던져줌

참고, throws로 명시해줘도 됨, 그냥 개발자한테 이런 예외를 던질거다라고 알려주는용도.

장점: 신경쓰지 않고 싶은 언체크 예오 ㅣ무시가능
단점: 개발자가 실수로 예외를 누락할 수 있음

고로, 예외는 처리하거나 던져야하는데

  • 체크예외: 예외를 잡아서 처리하지 않으면 항상 throws에 던지는 예외를 선언
  • 언체크 예외: 예외를 잡아서 처리하지 않아도 throws생략가능

체크 예외 활용


Repository에서 SQLException, NetWorkClient에서 ConnectException이 터진다고 가정해보자.

둘다 Checked예외이므로 Service, Controller입장에서 해당 에러를 해결할 수 없음에도 불구하고 throws를 선어하여 던저야만 한다.

웹 어플리케이션이라면 서블릿 오류페이지나 스프링 MVC가 제공하는 ControllerAdvice에서 해당 오류를 공통으로 처리한다.

CheckedAppTest

@Slf4j
public class CheckedAppTest {
    static class Repository{
        public void call() throws SQLException{
            throw new SQLException("ex");
        }
    }
    static class NetWorkClient{
        public void call() throws ConnectException {
            throw new ConnectException("연결실패");
        }
    }


    static class Service{
        Repository repository = new Repository();
        NetWorkClient netWorkClient = new NetWorkClient();

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

    static class Controller{
        Service service = new Service();
        public void request() throws SQLException, ConnectException {
            service.logic();
        }
    }

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

리포지토리와 네트워크 클라이언트 클래스의 call메서드는 checkedtype의 예외를 던진다.
그럼 서비스와 컨트롤러 부분을 보면, 해당 오류를 처리할 수 없음에도, 항상 throws로 해당 오류를 선언해줘야한다.

이러면 2가지의 치명적인 오류가 생긴다.

  1. 복구불가능한 예외
    대부분의 예외는 복구가 불가능, SQLEXCEPTION의 경우 DB에 문제가 생겨서 발생하는 예외, sQL문법에 문제가 있거나, DB자체가 문제가 있거나, 서버가 중간에 다운되었거나, 어쨋든 이런 오류는 컨트롤러나 서비스에서 해결할수없다.
    고로, 오류 로그를 남기고, 스프링의 ControllerAdvice를 통해 공통으로 해결하는것이 중요하다.

  2. 의존관계에 대한 문제,
    복구 불가능한 예외임에도 불구하고, Serivce와 Controller에서 throws를 통해서 던지는 예외를 선언해야한다.
    java.sql.SQLException에 의존하고 있는데, 향후 리포지토리를 JDBC기술이 아니라 JPA로 바꾸면, SQLExcpetion->JPAException으로 예외를 Service와 Controller에서 모든 코드를 뜯어 고쳐야한다.

  3. 만약 그럼 SQLException,ConectionException대신 그냥 최상위 예외인 Exception을 던지면 안될까?
    void method() throws Exception{}
    이건 안티 패턴이다. 왜? 최상위 타입인 Exception을 던지면, 중간에 중요한 체크예외가 발생되어도 다른 체크 예외를 체크할 수있는 기능이 무효화되고 그냥 Exception을 던지게 된다. 이러면 중요한 체크예외를 놓치게 된다.(컴파일 오류가 발생하지 않는다는 의미)

언체크 예외 활용


이제는 Repository와 NetworkClient에서 발생하는 예외를 언체크 type인 RuntimeException으로 던질것이다.

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

우선 RuntimeException을 두가지 만든다.

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

NetworkClient에서는 그냥 원래대로 RuntimeConnectException을 던질것이다.

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

Repository에서는 CheckedException인 SQLException이 발생하는데, 이걸 catch부분에서 RuntimeSQLException으로 전환해줘야한다.

  • Repository와 Service에서 더이상 CheckedException이 올라오지 않으므로, 더이상 throws를 선언하지 않아도 된다.
  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();
        }
    }

검증

 @Test
    void unchecked() {
        Controller controller = new Controller();
        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);
        }
    }

런타임 예외를 사용 -> 중간에 RuntimeSQLException -> RuntimeJPAExceptino으로 변경되더라도, 컨트롤러,서비스에서 코드 변경을 하지 않아도 된다.

다만 구현기술이 변경되는경우, 예외를 공통으로 처리하는 로직 ControllerAdvice등에서 RuntimeJPAException으로 변경해주기만 하면된다.
즉, 변경 범위가 최소화 된다.

참고로, RuntimeException도 마찬가지로 throws에 선언이 가능하다. 물론 생략해도 된다. 코드에 어떤 예외를 명시하면 개발자가 IDE를 통해 확인하기 편리하다.

예외 포함과 스택 트레이스

예외를 전환할때는 꼭 기존 예외를 포함해야한다.
만약 기존 예외를 포함하지 않으면, 예외가 로그로 남지 않기 때문이다.

로그를 출력할때 마지막 파라미터에 예외를 넣어주면 로그에 스택 트레이스를 출력할 수 있다.
log.info("message={}","message",ex)
이러면 ex에대한 예외가 출력되게 된다.

public void call() {
 	try {
 		runSQL();
    } 
 	catch (SQLException e) {
 		throw new RuntimeSQLException(e); //기존 예외(e) 포함
    }
 }

기존예외를 항상 포함해야 변환한 RuntimeSQLException으로부터 예외를 확인 할 수 있다.
DB에 연동했다면 DB에 대한 잘못된 쿼리를 로그로 확인해야하는데,
RuntimeSQLException();이렇게 포함을 안하면, SQLException에서 생기는 오류를 확인 할 수 없다.

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글