자바 예외 처리

바그다드·2023년 7월 27일
0

예외 처리

목록 보기
1/1

프로젝트를 진행하면서 기능을 구현할 때 예외가 발생하는 경우가 종종 있었다.
그때는 마냥 throws를 이용해 예외를 던지거나 try catch를 이용하는 선에서 그쳤지, 예외를 어떻게 처리해야 하는지, 구조는 어떻게 되어있고, 각 예외의 차이는 무엇인지 하는 것에 대해서는 잘 알지 못하였다.
하지만 자바 개발자가 되려고 하는만큼 필수적인 역량중 하나인 예외 처리에 대해 알아보고자 한다.

1. 예외계층

  • 예외도 객체로서 최상위 부모는 object이다
  • Throwable : 최상위 예외 객체이다.
  • Error : 시스템 에러와 같이 어플리케이션에서 복구할 수 없는 문제로 어플리케이션 개발자가 잡으려 해선 안된다.
    • 예외를 잡으면 기본적으로 자식 예외도 함께 처리하기 때문에 개발자는 Exception부터 필요한 예외로 생각하고 잡으면 된다.
  • Exception : 체크 예외
    • 어플리케이션 로직에서 사용할 수 있는 실질적 최상위 예외
    • Exception과 그 자식 예외는 모두 컴파일러가 체크하는 예외이다.
      다만 RuntimeException은 예외적으로 언체크 예외이다.
  • RuntimeException : 언체크 예외, 런타임 예외
    • 컴파일러가 체크 하지 않는 언체크 예외이다.
    • RuntimeException과 그 자식 예외는 모두 언체크 예외이다.
    • RuntimeException과 그 하위 언체크 예외를 런타임 예외라고들 부른다.

예외 기본 규칙

  1. 예외는 잡아서 처리하거나, 처리할 수 없을 경우 외부로 던져야 한다.
  2. 예외를 잡거나 던질 때 해당 예외와 그 자식 예외도 함께 처리된다.
    • cacth의 경우 그 자식 예외도 모두 잡히고,
    • throws의 경우 그 자식 예외도 모두 던져진다.
  • 예외를 처리할 경우
    예외 처리 이후 어플리케이션 로직은 정상 흐름으로 동작한다.
  • 예외를 던질 경우
    예외를 처리하지 못할 경우 호출한 곳으로 예외가 계속 올라가게 된다.

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

  • 자바 main()스레드의 경우 예외 로그를 출력하며 작동이 종료된다.
  • 하지만 웹의 경우 여러 사용자의 요청을 처리해야 하기 때문에 시스템이 종료되면 안된다.
    웹의 경우 WAS가 예외를 받아서 처리하는데, 주로 사용자에게 지정된 오류페이지를 보여준다.

체크 예외 vs 언체크 예외

  • 이 둘의 차이는 결국 컴파일러가 예외 처리를 확인하느냐 하지 않느냐는 것이다.

2. 체크예외

  • Exception과 그 하위 예외는 체크 예외이다.
  • 체크 예외는 컴파일러가 예외를 체크해주는 예외이다.
  • 반드시 try catch를 이용해 예외를 잡아주거나,
    throws를 이용해 예외를 던져줘야 한다.

코드로 확인해보자.

@Slf4j
public class CheckedTest {

    // service에서 예외를 잡아서 처리했기 때문에 정상 흐름으로 반환되어
    // 이후 코드도 정상적으로 처리를 진행한다.
    @Test
    void checked_catch() {
        Service service = new Service();
        service.callCatch();
        log.info("예외 처리 완료");
    }

    @Test
    void checked_throw() {
        Service service = new Service();
        // 이렇게 처리하면 예외를 처리하지 않았으므로 컴파일 에러가 발생함
        // service.callThrow();
        // 따라서 테스트에서는 아래처럼 처리를 해야함
        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) {
                // 로그로 예외 처리할 때 exception을 스택트레이스로 출력할 때는 마지막 파라미터로 넣어주면 됨
                log.info("예외 처리, message", e.getMessage(), e);
            }
        }

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

    /*
    * 체크 예외를 밖으로 던지는 코드
    * 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야한다.
    * */
    static class Repository {
        // 모든 예외는 잡아서 처리를 하거나 던져야 하므로 여기서는 던짐
        public void call() throws MyCheckedException {
            throw new MyCheckedException("ex");
        }
    }
}
  • 일반적인 3tire 구조로 이뤄진 예시이다.
  • MyCheckedException은 Exception을 상속받고 있으므로 체크예외이다.
    따라서 throws를 선언하거나 try-catch로 잡아줘야 하는데, 이걸 하지 않을 경우 컴파일 에러가 발생한다.
	static class MyCheckedException extends Exception{
        public MyCheckedException(String message) {
            super(message);
        }
    }
  • 예외는 기본적으로 여러 기능이 있는데, 그 중에 오류 메세지를 보관하는 기능이 있다.
    이 코드처럼 생성자를 이용해 해당 기능을 그대로 사용하면 편리하다.

그럼 테스트를 확인해보자.
1. checked_catch()의 경우
예외를 처리해줬기 때문에 그 이후 로직이 정상적으로 처리된 것을 확인할 수 있다.

2. checked_throw()의 경우
Assertion를 이용해 예외가 발생한 것을 확인할 수 있다.

3. 언체크 예외

  • RuntimeException 과 그 하위 예외는 언체크 예외로 분류된다.
  • 언체크 예외는 컴파일러가 예외를 체크하지 않는 예외다.
  • 예외를 잡지 않아도 throws를 생략할 수 있다.

코드로 확인해보자.

@Slf4j
public class UncheckedTest {

    /*
    * RuntimeException을 상속받은 예외는 언체크 예외가 된다.
    * 언체크 예외는 컴파일러가 체크하지 않는 예외를 말한다.
    * 다만 throws를 선언해두면 중요한 예외의 경우 개발자가 ide를 통해 인지할 수 있다.
    * */
    static class MyUncheckedException extends RuntimeException{
        public MyUncheckedException(String message) {
            super(message);
        }
    }

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

    /*
    * 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");
        }
    }

}
  • MyUncheckedException은 RuntimeException을 상속받고 있는데 call() 메서드에서 throws를 선언하지 않아도 된다.
  • call()을 호출하는 callThrow()메서드에서도 마찬가지로 throws를 선언하지 않고 있다.
        public void callThrow() {
            repository.call();
        }

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

그럼 테스트를 확인해보자

  • 예외를 잡아 처리하는 callCatch()는 예외를 잡아 처리하였으므로 그 이후 로직이 정상처리 되고,
    따라서 Assertions를 이용한 검증 로직도 정상적으로 수행되는 것을 확인할 수 있다!!

3. 예외 활용

그럼 언제 체크 예외를 사용하고, 언제 언체크 예외를 사용할까?

  1. 기본적으로 언체크 예외를 사용하자
  2. 비즈니스 로직상 의도적으로 던지는 예외에만 사용하자.
    • 예를 들어 계좌이체 실패나, 결제시 포인트 부족 등
      개발자가 놓쳐선 안된다고 판단되는 경우 체크 예외를 활용해 컴파일 단계에서 놓친 예외를 인지할 수 있다.

1. 체크 예외 활용

앞선 설명만 봐서는 컴파일러가 예외를 체크해준다는 안정성이 있으므로 체크 예외를 사용하는게 더 좋지 않나? 하는 생각이 들었다. 그런데 왜 체크 예외를 기본으로 사용하는게 문제가 될까?
아래의 그림을 보자

  • Repository는 DB에 접근해서 데이터를 관리하기 때문에 SQLException을 던진다.
    NetworkClient는 외부 네트워크에 접근하므로 ConnectException을 던진다.<>br따라서 둘 모두 예외를 처리해줘야 한다.
    • 이 두 경우처럼 연결이 실패하거나 DB에 문제가 있을 경우 어플리케이션에서 문제를 처리할 방법이 없다.
      따라서 이 예외를 밖으로 던지는 수 밖에 없다.
      • SQLException의 경우 SQL문에 문제가 있거나 DB자체에 발생한 문제를 해결해야 하고,
        ConnectException의 경우 외부 네트워크의 문제가 해결되어야 하므로 어플리케이션에서 해결할 수가 없다.
    • 결국 'Repository/NetworkClient -> Service -> Controller -> ...'
      이처럼 예외는 계속 밖으로 던져져 오류페이지나, ControllerAdvice 같이 공통으로 처리를 하는 부분에서 이 예외를 처리해야 한다.
    • 따라서 해결이 불가능한 예외의 경우 별도로 예외 로그를 남기고, 개발자가 빠르게 인지할 수 있도록 메신저나 메일, 문자 등을 통해 전달받아야 한다. 그렇지 않으면 해당 문제가 해결될 때까지 같은 문제가 발생한다.

코드로 확인해보자.

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");
        }
    }
}
  • Controller단, Service단에서 예외를 처리하지 못해 throws를 선언해 예외를 던지고 있다.

여기서 2가지 문제를 확인할 수 있다.

  1. 해결 불가능한 예외
    • 대부분의 예외는 복구가 불가능한 경우가 많은데, 위에서 예시를 들었던 SQLException이나 ConnectException 어플리케이션에서 해결할 수 없는 문제들이다. 따라서 throws로 예외를 던지는 방법 밖에 없다.
  2. 의존 관계 문제
    • 객체지향원칙에서 객체는 추상화에 의존해야지 구현체에 의존하면 안된다고 했다.
      물론 Exception도 구현체이지만 개발자가 신경써야 하는 최상위 예외가 Exception이므로 대부분의 예외는 Exceptiond을 통해 함께 처리가 가능하다.
      - 다만 Exception을 사용할 경우 중요한 체크 예외가 발생하도 놓치게 되므로 꼭 필요한 경우가 아니면 발생한 문제에 대한 예외를 던지도록 하자.
    • 그런데 아래의 그림처럼 의존 관계가 형성되어 있다고 했을 때, DB에 접근하는 기술이 JDBC에서 JPA로 바뀌었을 경우 throws로 해당 예외를 선언하고 있는 모든 객체의 의존관계를 수정해줘야 한다.

2. 언체크 예외 활용

앞에서 체크 예외를 활용했을 경우를 확인해보았다. 그럼 언체크 예외(런타임 예외)의 경우는 어떻게 흘러갈까? 아래의 그림을 보자.

  • Repository와 NetworkClient가 Runtime예외를 사용하고 있기 때문에 Controller와 Service에서 해당 예외를 처리할 수 없는 상황에도 별도의 선언 없이 그냥 두면 된다.

코드로 확인해보자.

public class UncheckedAppTest {

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

    @Test
    void printEx() {

    }

    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);
        }
    }
}
  • SQLException을 catch를 이용해 RuntimeSQLException으로 전환해 예외를 던졌고, NetworkClient는 RuntimeConnectException를 던졌다.
  • 그 결과 아래의 코드처럼 Service에서 throws로 SQLException이나 ConnectException을 의존하지 않고 있다.
    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic(){
            repository.call();
            networkClient.call();
        }
    }
  • 어플리케이션에서 발생한 예외는 대부분 해결 불가능한 예외인데, 런타임 예외의 경우 Controller나 Service가 예외에 대해서 신경쓰지 않아도 된고, 의존하지 않아도 된다.

아래의 그림을 보자.

  • 앞서 체크 예외에서 들었던 예시처럼 JDBC에서 JPA로 기술을 변경해도, 예외 공통 처리 부분만 변경하면 되기 때문에 변경의 영향 범위를 최소화 할 수 있다.

런타임 예외는 문서화하자

런타임 예외의 경우 컴파일러가 체크하지 않기 때문에 개발자가 놓치기 쉽다.
따라서 문서화를 잘 해두고, 중요한 예외의 경우 throws를 선언해 개발자가 인지할 수 있도록 명시해두자.

체크 예외의 장단점

장점

컴파일러가 예외 처리를 체크해주기 때문에 개발자가 실수로 예외처리를 누락하는 것을 막아준다.

단점

예외를 처리할 수 없을 때마다 throws에 모든 예외를 선언해줘야 한다.

  • 심지어 인터페이스를 상속받고 있을 경우 인터페이스에도 예외가 선언되어 있어야 한다.

의존 관계에서 문제가 발생한다.

언체크 예외의 장단점

장점

컨트롤러나 서비스에서 예외를 신경쓰지 않아도 되고, 의존 관계도 최소화할 수 있다.

단점

컴파일러가 예외를 체크해주지 않기 때문에 예외를 놓치기 쉽다.

예외 포함과 스택트레이스(중요!!!)

    @Test
    void printEx() {
        Controller controller = new Controller();
        try {
            controller.request();
        } catch (Exception e) {
//e.printStackTrace();
			// 로그에 예외 스택 트레이스 포함
            log.info("ex", e);
        }
    }
    
        static class Repository {
        public void call(){
            try {
                runSQL();
            } catch (SQLException e) {
            	// 기존 예외 포함
                throw new RuntimeSQLException(e);
            }
        }
        public void runSQL() throws SQLException {
            throw new SQLException("ex");
        }
    }
  • log.info("ex", e);
    로그를 출력할 때 마지막 파라미터로 예외를 넣어주면 스택 트레이스를 출력할 수 있다.
  • 프로젝트에서 예외 처리를 할 때 e.printStackTrace()를 이용했는데, 이건 System.out을 이용한 출력이다.
  • Repository의 call()메서드에서 SQLException을 RuntimeSQLException으로 변경하고 있는데, 이 때 기존의 예외를 추가해줘야 문제가 발생한 원인을 확인할 수 있기 때문에 예외를 변환할 때는 꼭 기존 예외를 포함해주자!!!!
    포함하지 않으면 정작 중요한 원인이 되는 문제를 확인할 수 없다.
    static class RuntimeSQLException extends RuntimeException {
        public RuntimeSQLException(Throwable cause) {
            super(cause);
        }
    }
  • 예외에서 제공하는 생성자 중에 원인을 포함할 수 있는 생성자가 있으므로 이걸 활용하자.

이것으로 자바 예외 처리에 대해 알아보았다. 여태 예외처리를 하면서 예외의 계층이나 처리 방법에 대해 별다른 생각 없이 사용해왔는데, 좀 더 깊이 알아볼 수 있는 시간이라 좋았다. 다음 포스팅에서는 스프링을 이용한 예외 처리에 대해 알아보도록 하자!!

출처 : 김영한 스프링 DB 1편

profile
꾸준히 하자!

0개의 댓글