본문 바로가기
TIL(Today I Learned)

Java의 Exception에 대해서

by kkkdh 2023. 7. 5.
728x90

이번 글에서는 Java에서 예외 처리 방식을 어떻게 가져가는지에 대한 내용을 다루어 보려고 합니다.

 

요즈음 개발을 하면서 예외 처리 방식에 대해 공부의 필요성을 느껴 간단히 정리했던 부분이기도 하고, 김영한 님 강의에서는 한 파트를 따로 빼서 설명을 해주셔서 내용을 취합해서 정리해보려 합니다.

 

글에서 등장하는 개념은 주로 김영한님의 Spring DB 1편 강의를 참고했습니다.


Exception 계층 구조

우선 자바의 예외 계층은 다음 구조도와 같이 구분됩니다.

(출처: 스프링 DB 1편 강의 - 김영한님)

예외 관련 객체들 역시 최상위 객체인 Object 객체를 상속받고 있으며, 그 아래에 Throwable 객체가 있음을 확인할 수 있습니다.

 

Throwable class는 모든 예외들의 조상으로 getMessage, printStackTrace 등 공통으로 사용되는 메서드를 정의하고 있습니다.

 

그 하위에 Exception과 Error 클래스가 있는데, 둘의 차이점은 다음과 같이 정리할 수 있습니다.

  • Exception: 예외
  • Error: 해결할 수 없는 에러, 이미 상황이 끝났고, application 개발자는 대처할 수 없다.

한 마디로 Error는 개발자가 handling 할 수 없는 케이스를 의미하고, exception은 프로그램 실행 중 처리할 수 있는 케이스를 의미한다고 볼 수 있을 것 같습니다.

 

다음으로 Exception의 자식 클래스들은 RuntimeException 계열을 제외하고 체크 예외라고 하며, RuntimeException의 하위 예외 클래스들은 언체크 예외(런타임 예외)라고 부릅니다.

 

  • Exception: 체크 예외
    • 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.
    • Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외. 단, RuntimeException은 예외로 한다.
  • RuntimeException 언 체크 예외 (= 런타임 예외)
    • 컴파일러가 체크하지 않는 예외
    • RuntimeException과 그 자식예외는 모두 언체크 예외
    • RuntimeException의 이름을 따라 RuntimeException과 그 하위 언체크 예외를 ‘런타임 예외’라고 많이 부른다.

참고로 Error도 uncheck 예외입니다.


예외의 기본 규칙

  • 일종의 폭탄 돌리기라고 합니다.
  • 내가 처리하거나!
    • throws로 던지면, 그 하위 예외들도 모두 던질 수 있다.
  • 나를 호출한 곳으로 던지거나!
    • catch로 잡으면, 그 하위 예외들도 모두 잡을 수 있다.

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

→ 자바 main() thread의 경우 예외 로그를 출력하면서 시스템이 종료됩니다.

→ 웹 애플리케이션의 경우 여러 사용자의 요청을 처리하기 때문에, 하나의 예외 때문에 시스템이 종료되면 안된다. 따라서 WAS가 해당 예외를 받아 처리하는데, 주로 사용자에게 개발자가 지정한 오류 페이지를 보여주는 방식으로 처리한다.

 

CheckException을 이용한 테스트 코드

check 예외의 경우 예외를 처리하거나, 던져야 하기 때문에 위의 상황처럼 던져주지 않으면 컴파일 에러로 인해 빌드가 불가능합니다.

 

폭탄 돌리기에 대해 조금 더 자세히 설명하자면..

Exception을 throws 하는 경우 → 가장 상위 Exception에 대한 throws만 명시해도 하위 예외들을 모두 처리해주고,

Exception을 handling 하는 경우 → 하위의 exception에 대해서도 모두 handling 하는 결과를 확인할 수 있습니다.

 

첫 번째 케이스는 예제 코드로 확인할 수 있고, 두 번째 케이스는 try-catch를 이용해 상위 Exception을 handling 하는 경우 해당 예외의 하위 예외들도 catch 문에서 처리되는 상황을 떠올리면 이해가 될 것 같습니다.

 

하지만, 예외를 던진다고 하더라도 throw Exception 과 같이 모든 예외를 던진다고 처리하는 것은 바람직하지 못한 코드입니다. (예외를 명시적으로 확인할 수 없고, 예외에 따른 개별 처리가 불가능하기 때문!)


체크 예외란

체크 예외는 컴파일 시점에 컴파일러에 의해 확인되는 예외를 의미합니다.

체크 예외를 try-catch로 처리하거나, throws를 하지 않으면 컴파일에 실패하게 되고, 당연히 빌드 또한 불가능합니다.

 

Exception class를 상속 받은 예외는 체크 예외가 됩니다.


언체크 예외란

  • RuntimeException과 그 하위 예외는 언체크 예외로 분류됩니다.
  • 언체크 예외는 말 그대로 컴파일러가 체크하지 않는 예외
  • 기본적으로 예외를 던지거나 처리해야 한다는 점에서 체크 예외와 동일하나, 차이가 있다면 thorws를 선언하지 않고 생략할 수 있다는 점 → 이 경우 자동으로 던지도록 처리 됨

따라서 언체크 예외는 throws 생략이 가능하다는 점에 유의해야 하며, 언체크 예외의 경우 주로 생략하는 형태로 사용한다고 합니다.

 

하지만, 중요한 예외의 경우 선언을 통해 개발자가 이런 예외가 발생한다는 점을 IDE를 통해 더 편리하게 인지할 수 있다는 장점이 존재 → 그렇다고 막을 수 있는 것은 당연히 아니지만, 인지할 수 있겠죠?

 

체크 예외와 언체크 예외의 차이점은 사실 예외를 처리할 수 없는 경우 밖으로 던지는 부분에 있습니다.

이 부분을 필수로 선언 or 생략 가능의 차이가 있을 뿐!


체크 예외 활용

그렇다면, 언제 체크 예외를 사용하고 언제 언체크 예외(런타임 예외)를 사용하면 좋은걸까??

기본 원칙 2가지를 기억하자!

  • 기본적으로 런타임 예외를 사용하자. (언체크 예외)
  • 체크 예외는 비즈니스 로직 상 의도적으로 던지는 경우에만 사용하자!! (너무 중요한 케이스)
    • 해당 예외를 잡아서 반드시 처리해야할 필요가 있는 경우 체크 예외를 사용해야 한다.
    • 예시
      • 계좌 이체 실패 예외
      • 결제시 포인트 부족 예외
      • 로그인 ID/PW 불일치 예외
    • 물론 이러한 케이스 들도 반드시 체크 예외로 처리할 필요는 X, 다만 계좌 이체와 같이 매우 심각한 문제에 대해서는 개발자가 실수로 예외를 놓치면 안된다고 생각할 수도 있다. → 이런 경우 체크 예외로 처리

그렇다면, 언체크(런타임) 예외를 기본으로 사용하는 이유는 뭘까??

→ 체크 예외가 갖는 기본적인 문제 때문입니다.

 

체크 예외 문제점은 다음과 같습니다.

(그림 출처: Spring DB 1편 강의 - 김영한님)

  • Repository는 DB에 접근해서 데이터를 저장하고 관리, 이 때 SQLException check exception을 throw한다.
  • NetworkClient는 외부 네트워크에 접속해서 어떠한 기능을 처리하는 객체이다. 여기서 ConnectException throw
  • ServiceRepository, NetworkClient를 모두 호출하고
    • 이에 따라 두 개의 Exception을 모두 처리해야..
    • 하지만, 서비스는 두 가지 에러를 처리하는 방법을 알 수 없다. → SQL 문법 오류나 connection 생성 실패 등을 처리할 수 있을리가 없음..
  • Service가 두 가지 Exception을 다시 호출한 곳으로 throw 처리
    • 체크 예외이므로 method에 throws SQLException, ConnectException 이라고 코드를 작성해줘야 한다.
  • 컨트롤러 또한 마찬가지로 예외 처리 방법을 알 수 없음 → 다시 밖으로 던져야 함
  • 웹 애플리케이션 이라면 서블릿의 오류 페이지나, 스프링 MVC에서 제공하는 ControllerAdvice에서 이러한 예외를 공통으로 처리한다.
    • 사용자에게 적절한 메시지 전달에 어려움이 있음 (보안에 문제가 될 수도 있음)
    • API라면, 보통 HTTP status code 500을 사용해서 응답을 내려준다.
    • 이렇게 해결이 불가능한 공통 예외는 별도의 오류 로그를 남기고, 개발자가 오류를 빨리 인지할 수 있도록 메일, 알림(문자, 슬랙) 등을 통해 전달 받아야.

 

위 그림을 코드로 작성하면 다음과 같습니다.

package hello.jdbc.exception.basic;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.net.ConnectException;
import java.sql.SQLException;

public class CheckedAppTest {

    @Test
    void checked(){
        Controller controller = new Controller();
        Assertions.assertThrows(Exception.class, controller::logic);
    }

    static class Controller {
        Service service = new Service();
        public void logic() throws SQLException, ConnectException{ // 호출하는 부분에 throws를 매번 작성해줘야 한다.
            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");
        }
    }
}

 

Service, Repository 모두 check exception을 처리하지 못해 밖으로 던지기 위해

logic() throws SQLException, ConnectException을 선언하고 있습니다..

 

이를 포함 위 코드는 2가지의 문제점을 갖고 있습니다.

  1. 복구 불가능한 예외
  2. 의존 관계에 대한 문제

대부분의 예외는 복구가 불가능합니다. (극히 일부만 가능)

 

따라서 이러한 문제들은 일관성있게 공통으로 처리해야 하며 → 오류 로그를 남기고 개발자가 빠르게 오류 인지를 하는 것이 가장 중요 → 서블릿 필터, 스프링 인터셉터, ControllerAdvice를 사용하면 깔끔하게 공통으로 해결할 수 있습니다.

 

체크 예외의 또 다른 심각한 문제는 불필요한 의존 관계의 생성입니다.

 

본인이 처리를 못하는 경우 호출한 곳으로 에러 전파를 위해 throws 구문을 선언해야 하며

→ 코드가 포함되었다는 것은 해당 예외에 코드가 의존하게 되었음을 의미한다.

(SQLException → JDBC 의존, 만약 JDBC → JPA 바꾼다면???)

어차피 본인이 처리도 불가능한데, 특정 기술에 의존하게 되는 문제가 발생.. (OCP, DIP 위배)

 

따라서 정리하면

  • 처리할 수 있는 체크 예외라면, 해당 레벨에서 처리할 수 있겠지만, 대부분 그렇지 않습니다. → 예외 발생한 경우 거의 다 복구 불가능하다.
  • 심지어는 해당 체크 예외에 다른 클래스들이 의존하게 된다는 문제도 발생합니다. → 불필요한 의존성의 증가

throws Exception으로 처리하는 것으로 문제 해결이 가능해 보입니다만

 

의존 관계 문제 해결 → 그러나, 이러한 경우 모든 예외를 전부 다 밖으로 던지는 문제가 발생 (체크 예외 자체가 안잡히게 된다. 특정 체크예외에 대한 처리가 불가능해짐) → anti pattern에 해당 (비생산적인 패턴을 칭하는 용어라고 합니다.)


언체크 예외를 활용하자!!

이 방법이 효과적인 대안이 될 수 있습니다.

(그림 출처: Spring DB 1편 강의 - 김영한님)

위와 같이 예외를 모두 RuntimeException의 자식으로 처리한다고 해봅시다.

 

위에서 살펴보았듯이 런타임 예외는 throws를 생략 가능합니다. 이에 따라서 쓸데 없는 의존성이 사라지며, 그냥 방치하면 예외가 상위 레벨로 알아서 전파되는 구조로 변경할 수 있게 됩니다.

 

이렇게 되는 경우 예외 공통 처리 로직으로 전파되며, 후에 공통 처리를 하면 됩니다. (그림 기준으로 가장 좌측 부분에서 예외를 공통으로 처리한다고 보면 됩니다.)

 

코드로 작성하면 다음과 같습니다.

package hello.jdbc.exception.basic;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.sql.SQLException;

public class UnCheckedAppTest {
    @Test
    void unchecked(){
        Controller controller = new Controller();
        Assertions.assertThrows(RuntimeSQLException.class, controller::request);
    }

    static class Controller {
        Service service = new Service();
        public void request() { // 호출하는 부분에 throws를 매번 작성해줘야 한다.
            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() throws RuntimeConnectException{
            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) { // cause 이용해 이전 예외를 포함해서 가지도록 설정 가능
            super(cause);
        }
    }
}

이제 불필요한 throws가 사라져, 불필요한 의존성 또한 생략할 수 있게 되었습니다.

 

 

따라서 앞으로는 throws 작성에 따라 Exception 유형 변경에 따른 코드 수정의 필요가 사라졌습니다. (에러 로직 공통 처리 부분에서만 해당 Exception handling이 가능하도록 수정하면 됨)

 

이러한 체크 예외 사용의 문제로 인해서 최근 라이브러리들의 대부분은 Runtime Exception을 기본으로 제공한다고 합니다. → JPA 또한 런타임 예외를 기본으로 제공 (언체크 예외)

 

하지만, 런타임 예외는 문서화를 잘 해야 한다!!!

혹은 명시적으로 throws를 사용하기도 한다.


예외 포함과 스택 트레이스

체크 예외를 언체크 예외로 변경해서 처리하게 되면, Exception을 wrap 하는 과정이 추가로 필요하게 됩니다.

 

이 과정에서 기존의 예외 정보를 포함하지 않아, 기존 예외의 stacktrace가 생략되는 문제가 발생하는데, 다음과 같이 반드시 기존 예외를 포함해서 새로운 RuntimeException을 만들어줘야 합니다.

이렇게 Throwable 객체를 받아들이는 생성자를 overloading 하고, 기존 예외를 포함해서 run time exception을 throw 해줘야 합니다. (누락된 예외 정보가 없게끔)

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

이렇게 기본 생성자 외에도 Throwable을 인수로 받아들이는 생성자를 추가로 생성해서, 기존 예외 정보를 포함한 예외를 생성할 수 있도록 만들어줘야 합니다.

 

Throwable인 이유는 모든 예외를 포함하는 가장 상위의 타입이기 때문입니다.

728x90

댓글