프레임워크(Framework)/Spring

[Spring DB] 체크 예외와 언체크 예외 총정리 - (2)

잇트루 2022. 11. 22. 23:22
반응형
본 내용은 온라인 강의 사이트 인프런의 김영한 님의 강의 내용이 포함되어 있습니다.
'스프링 DB 1편 - 데이터 접근 핵심 원리'
 

스프링 DB 1편 - 데이터 접근 핵심 원리 - 인프런 | 강의

백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., - 강의

www.inflearn.com

 

체크 예외 vs 언체크 예외

체크 예외

예외를 잡아서 처리하지 않으면 항상 throws에 던지는 예외를 선언해야 한다.

 

언체크 예외

예외를 잡아서 처리하지 않아도 throws를 생략할 수 있다.

 

결국 체크 예외와 언체크 예외의 차이는 예외를 잡아서 처리할 수 없을 때 밖으로 던지는 부분에 있다. throws를 필수로 선언해야 하는지와 생략할 수 있는지의 차이이다.

 

예외 활용의 기본 원칙

  • 기본적으로 언체크 예외(RuntimeException)를 사용한다.
  • 체크 예외의 경우 비즈니스 로직상 의도적으로 던지는 예외에만 사용한다.
    • 체크 예외는 해당 예외를 반드시 잡아서 처리해야 하는 문제일 때만 사용해야 한다.
    • 예를 들어 실제 서비스 로직에서 계좌 이체 실패, 결제 시 포인트 부족, 로그인 ID 또는 PW 불일치 등이 있다.

 

체크 예외의 문제점

체크 예외는 컴파일러가 예외 누락을 체크해주기 때문에 개발자가 실수로 예외를 놓치는 것을 방지한다. 이는 항상 catch 또는 throws를 명시적으로 작성해 주어야 하기 때문이다.

 

하지만, 실제 서비스에서 체크 예외를 사용하는 것에 문제점이 있다.

먼저, Repository는 DB에 접근해서 데이터를 저장하고 관리하며, NetworkClient는 외부 네트워크에 접속해서 어떤 기능을 처리하는 객체이다.

  • Repository는 SQLException 체크 예외를 던진다.
  • NetowrkClient는 ConnectException 체크 예외를 던진다.
  • 참고로 SQLException은 데이터베이스에서 발생하는 예외이고, ConnectException은 네트워크 연결 실패 시 발생하는 예외이다.

 

Service는 Repository와 NetworkClient를 둘 다 호출한다.

  • 예외가 발생할 경우 체크 예외인 SQLExcpetion과 ConnectException를 처리해야 한다.
  • 하지만 Service와 Controller는 SQLException, ConnectException과 같은 심각한 문제들을 처리할 방법이 없다.
  • 따라서 예외를 밖으로 던지기 위해 throws SQLException, ConnectException을 선언해야 한다.

 

이 경우, 웹 애플리케이션이라면 서블릿의 오류 페이지나 스프링 MVC가 제공하는 ControllerAdvice에서 예외를 공통으로 처리하게 된다.

  • 이 경우 보통 HTTP 상태 코드 500(내부 서버 오류)을 응답하게 된다.
  • 사용자에게 어떤 문제가 발생했는지 자세히 설명하기 어렵다.
  • 사용자에게 문제 발생을 알리더라도 보안에 문제가 생길 수 있다.

 

SQLException과 ConeectException과 같이 해결이 불가능한 예외는 별도의 오류 로그를 남겨야 하며, 개발자가 오류를 인지할 수 있도록 최대한 빨리 알려야 한다.

예를 들어, SQLException이 잘못된 SQL을 작성해서 발생했다면, 해당 SQL을 수정해서 다시 배포하기 전까지 사용자는 같은 문제를 겪게 된다.

 

코드로 알아보는 체크 예외의 문제점

Repository

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

리포지토리에서는 SQLException 예외가 발생한다.

 

NetworkClient

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

네트워크 클라이언트에서는 ConnectException 예외가 발생한다.

 

Service

class Service {
    Repository repository = new Repository();
    NetworkClient networkClient = new NetworkClient();

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

서비스는 이 둘을 모두 호출하게 된다.

하지만, 서비스에서 처리가 불가능하기 때문에 throws SQLException, ConnectException을 선언해야 한다.

 

Controller

class Controller {
    Service service = new Service();

    public void request() throws SQLException, ConnectException {
        service.logic();
    }
}

결국 Service에 대한 로직은 컨트롤러에서 호출하기 때문에 해당 예외를 받게 된다.

하지만 컨트롤러에서도 처리가 불가능하여 throws SQLException, ConnectException을 선언해야 한다.

 

문제점

결국 SQLException과 ConnectException은 어디에서도 처리가 불가능하며, 2가지 문제가 발생한다.

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

 

복구 불가능한 예외

SQLException이 발생했다고 가정하면 SQL 문법 오류, 데이터베이스의 문제, 서버가 중간에 종료되는 등의 문제가 발생했을 수도 있다.

이러한 문제들은 대부분 복구가 불가능하며 Service나 Controller에서 문제를 해결할 수 없는 경우가 대부분이다.

 

즉, 로그를 통해 개발자가 오류를 빠르게 인지하는 것이 중요하며, 서블릿 필터, 스프링 인터셉터, ControllerAdvice를 사용하여 해결해야 한다.

 

의존 관계에 대한 문제

SQLException과 ConnectException은 체크 예외이다.

하지만, Service나 Controller에서는 이 문제를 처리할 수 없기 때문에 어쩔 수 없이 throws를 선언하여 통해 예외를 던질 수밖에 없다.

 

여기서 Service와 Controller가 SQLException을 의존하게 되기 때문에 문제가 발생한다.

만약, JDBC를 사용하다가 JPA와 같은 기술로 변경하게 된다면, SQLException에 의존하던 모든 서비스와 컨트롤러의 코드를 JPAException에 의존하도록 바꿔야 한다.

 

즉, OCP, DI를 통해 클라이언트 코드의 변경 없이 구현체를 변경할 수 있는 장점이 훼손된다.

 

언체크 예외 활용

SQLException과 ConnectException을 RuntimeSQLException, RuntimeConnectException으로 변환하여 throws 선언을 하지 않도록 할 수 있다.

 

예외 전환

Repository와 NetworkClient에서 체크 예외인 SQLException 또는 ConnectException이 발생하면 런타임 예외(언체크 예외)로 전환하여 예외를 던질 수 있다.

 

참고로 Repository에서 기존 예외를 포함해주어야 예외 출력 시 스택 트레이스에서 기존 예외도 함께 확인할 수 있다.

 

RuntimeSQLException

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

예외 전환을 하기 위해 RuntimeException을 상속받은 언체크 예외 클래스인 RuntimeSQLException를 작성한다.

  • 이때 생성자를 Throwable를 받게 되면 기존에 발생한 예외를 넣을 수 있다.

 

 

Repository

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

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

Repository에서 SQLException이 발생하도록 하여 잡아서 RuntimeSQLException을 호출하도록 하여 체크 예외를 처리한다.

  • 위 코드와 같이 기존의 예외를 포함하여 처리해야 스택 트레이스에서 확인이 가능하다.

 

RuntimeConnectException

class RuntimeConnectException extends RuntimeException {
    public RuntimeConnectException(String message) {
        super(message);
    }
}

예외 전환을 하기 위해 RuntimeException을 상속받은 클래스인 RuntimeConnectException을 작성한다.

 

NetworkClinet

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

NetworkClinet는 단순히 RuntimeConnectException 언체크 예외를 발생하도록 전환한다.

 

Service

class Service {
    Repository repository = new Repository();
    NetworkClient networkClient = new NetworkClient();

    public void logic() {
        repository.call();
        networkClient.call();
    }
}

서비스는 Repository와 NetworkClinet를 호출하게 된다. 체크 예외를 언체크 예외로 전환했기 때문에 throws를 선언하지 않아도 된다.

 

Controller

class Controller {
    Service service = new Service();

    public void request() {
        service.logic();
    }
}

Controller는 Service 로직을 호출하기 때문에 예외가 발생하게 되지만 마찬가지로 언체크 예외로 전환했기 때문에 throws를 선언하지 않아도 된다.

 

UncheckedAppTest

@Slf4j
public class UncheckedAppTest {

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

이를 테스트하여 검증할 수 있다.

 

unchecked()

throws를 선언하지 않아도 정상적으로 예외가 발생하는 것을 검증하는 코드로 정상 종료된다.

 

결과 (printEx()는 아래에서 설명)

예외 전환을 통해 체크 예외의 문제점을 해결할 수 있게 된다.

복구 불가능한 예외는 런타임 예외(언체크 예외) 전환으로 인해 서비스나 컨트롤러가 신경 쓰지 않게 되었다.

 

하지만, 복구 불가능한 예외는 일관성 있게 공통으로 처리해야 한다.

 

또한, 의존 관계에 대한 문제는 런타임 예외로 인해 해당 객체가 처리할 수 없는 예외는 무시할 수 있게 되었고, 체크 예외처럼 예외를 강제로 의존하지 않아도 된다.

 

  • 런타임 예외를 사용하면 중간에 기술 변경이 되어도 해당 예외를 사용하지 않는 컨트롤러, 서비스에서는 코드를 변경하지 않아도 된다.
  • 구현 기술이 변경되는 경우, 예외를 공통으로 처리하는 곳에서는 예외에 따른 다른 처리가 필요할 수 있다.
  • 하지만, 공통으로 처리하는 곳에서만 변경이 일어나기 때문에 영향 범위가 최소화된다.

 

체크 예외의 문제점 때문에 최근 라이브러리들은 대부분 런타임 에러를 기본으로 제공한다.

런타임 예외는 앞서 Repository와 같이 필요에 따라, 잡아서(catch) 처리가 가능하기 때문에 공통으로 처리하는 부분을 만들어서 처리할 수 있다.

 

런타임 예외는 놓칠 수 있는 부분이 있기 때문에 문서화가 중요하다.

 

런타임 예외의 문서화

  • 런타임 예외는 문서화를 잘해야 한다.
  • 또는 코드에 직접 throws를 남겨서 중요한 예외를 인지할 수 있게 할 수 있다.

 

문서화 예시

JPA EntityManager

/**
 * Make an instance managed and persistent.
 * @param entity entity instance
 * @throws EntityExistsException if the entity already exists.
 * @throws IllegalArgumentException if the instance is not an
 * entity
 * @throws TransactionRequiredException if there is no transaction when
 * invoked on a container-managed entity manager of that is of type 
 * <code>PersistenceContextType.TRANSACTION</code>
 */
public void persist(Object entity);

예외를 직접 명시하여 문서화한다.

 

스프링 JdbcTemplate

/**
 * Issue a single SQL execute, typically a DDL statement.
 * @param sql static SQL to execute
 * @throws DataAccessException if there is any problem
 */
void execute(String sql) throws DataAccessException;

throws DataAccessException와 같이 문서화와 함께 코드에 throws를 선언하여 명시한다.

  • 이 경우 throws를 생략해도 되지만 발생하는 예외가 명확하거나 중요하면 throws를 선언하여 개발자가 편리하게 확인할 수 있다.

 

예외 포함과 스택 트레이스

예외를 전환할 때는 기존 예외를 포함해야 한다. 그렇지 않으면 기존에 발생한 예외에 대해서 스택 트레이스를 확인할 수 없다.

 

만약, 실제 DB에 연동했다면 DB에서 발생한 예외를 확인할 수 없어 문제점을 찾을 수 없는 심각한 상황이 발생할 수 있다.

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

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

  • log.info(”message={}”, e.getMessage(), e)
  • log.info(”ex”, e)

printStackTrace() 메서드를 통해서도 출력이 가능하다.

 

printEx()

출력된 결과는 다음과 같다.

02:20:03.675 [main] INFO hello.jdbc.exception.basic.UncheckedAppTest - ex
hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: java.sql.SQLException: ex
	at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.java:60)
	at hello.jdbc.exception.basic.UncheckedAppTest$Service.logic(UncheckedAppTest.java:44)
	at hello.jdbc.exception.basic.UncheckedAppTest$Controller.request(UncheckedAppTest.java:35)
...

이처럼 기존 예외에 대한 로그를 확인할 수 있다.

반응형