각 계층 간 종속적으로 올라오는 예외를 처리하기 위해서는 예외 또한 추상화가 필요하다.
실제로 스프링이 어떻게 예외 추상화를 제공하는지 이해하기 전에 먼저 말하자면 *예외는 자바 기본 문법이다.*
예외 계층에 대해 다시 한 번 복습하고, 실무에서는 예외를 어떻게 활용하는지 알아보자.
예외 계층
자바의 예외 계층 구조를 살펴보자.
Thorwable
: 최상위 예외이다.Error
: 애플리케이션에서 복구 불가능한 수준의 시스템 예외이다. 개발자가 이 예외를 잡으려고 신경쓰면 안된다.Exception
: 체크 예외이다.- 애플리케이션에서 다루는 실질적인 최상위 예외 계층이다.
Exception
을 포함한 그 하위 예외는 체크 예외로, 컴파일러가 체크해주는 예외이다.RuntimeException
만 예외적으로 언체크 예외에 속한다.
RuntimeException
: 언체크 예외 (런타임 예외)- 컴파일러가 체크하지 않고 런타임 중 발생할 수 있는 예외이다.
RuntimeException
을 포함한 그 하위 예외는 모두 언체크 예외이다.
체크 예외 (CheckedException)
- 체크 예외 만들기
커스텀 체크 예외를 만들어보자.
static class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message);
}
}
Exception
을 상속 받은 클래스를 생성하면 체크 예외를 만들 수 있다.
생성자는 간단하게 메세지를 받도록 설정했다.
실제 위 예외를 발생시키는 메소드를 호출하는 Service 코드를 살펴보자.
static class Service {
Repository repository = new Repository();
public void callCatch() {
try {
repository.call();
} catch (MyCheckedException e) {
// 예외 처리 로직
log.info("예외 처리, message={}", e.getMessage(), e);
}
}
public void callThrow() throws MyCheckedException {
repository.call();
}
}
위와 같이 try-catch로 잡아서 처리하거나, 밖으로 던져야 컴파일 오류가 발생하지 않는다.
- 체크 예외의 문제점
체크 예외는 컴파일러가 예외 누락을 체크해주기 때문에 개발자가 실수로 놓치는 것을 방지해준다. 그래서 항상 명시적으로 예외를 잡아서 처리하거나, 예외를 던져야 한다.
위 말만 보면 안전하고 좋아보이는데 뭐가 문제일까?
바로 의존 관계 문제이다.
위 흐름도를 보면 리포지토리와 네트워크 클라이언트에서 발생하는 체크 예외가 컨트롤러까지 올라가고 있다.
SQLException
은 서비스나 컨트롤러 계층까지 올라온다고 해도 처리할 방법이 없다. 시스템 레벨의 장애이기 때문에, 애플리케이션 레벨에서 시스템 장애를 어찌 할 방도가 없기 때문이다.
그럼 결국 API 호출이었다면 500
상태코드를 반환한다는 사실은 변함이 없을텐데, Controller, Service 계층 모두 JDBC 기술 및 네트워크 기술에 의존적인 코드로 구현될 수 밖에 없다.
이런 의존 관계가 문제점이다.
만약 Repository에서 JDBC 기술이 아니라 JPA를 사용한다면 어떨까? 그때는 JPA에 관련된 Exception이 발생하기 때문에 *모든 계층의 코드를 전부 바꿔야한다.*
이런 문제를 해결하려면 어떤 예외를 사용해야할까?
예외처리의 대원칙 2가지를 기억하자.
1. 기본적으로 언체크 예외를 사용하자.
2. 체크 예외는 비지니스 로직 상 반드시 잡아서 처리해야 하는 문제일 때만 사용하자.
언체크 예외 (UncheckedException)
체크 예외를 알아볼 때 의존 관계에 대한 문제점이 있었다.
실무에서는 위 문제를 해결하기 위해 기본적으로 언체크 예외를 사용한다.
언체크 예외를 만들어보자.
static class RuntimeConnectException extends RuntimeException {
public RuntimeConnectException(String message) {
super(message);
}
}
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
RuntimeException
을 상속받으면 언체크 예외를 생성할 수 있다.
첫 번째 RuntimeConnectException
의 경우 단순하게 생성자에 메시지를 입력하게 만들었고,
두 번째 RuntimeSQLException
의 경우 이전 예외 정보인 cause를 입력하게 만들었다.
*이 둘은 중요한 차이를 갖는다.* 뒤에 예외 전환에서 알아볼 것이다.
먼저 체크 예외를 언체크 예외로 전환하는 방식을 살펴보자.
static class Repository {
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
}
체크 예외인 SQLException
이 발생했을 때, try-catch
를 통해 커스텀 런타임 예외인 RuntimeSQLException
으로 전화해 던지고 있다.
*그러나, 더이상 throws
로 상위 계층에 예외를 전달하지 않는다.*
런타임 예외를 사용한 계층 구조를 살펴보자.
체크 예외를 사용하던 방식과 다르게, 이제 더 이상 Controller, Service 계층에서 Exception 관련 처리가 필요하지 않다.
의존 관계 문제가 해결된 것이다!
런타임 예외를 사용해도 필요에 따라 throws를 통해 명시적으로 예외를 인지시킬 수는 있다.
보통은 문서화를 통해 해당 계층에서 어떤 예외가 발생할 수 있는지 기록을 남겨놓는다.
예외 전환과 스택 트레이스
언체크 예외를 생성할 때 위 예제에서 생성자에 메시지를 넣어주는 방식과 cause
를 넣어주는 방식 두 가지를 사용했다.
이 둘은 무슨 차이가 있을까?
먼저 단순히 메시지를 넣어주는 방식을 사용한 출력 결과를 살펴보자.
static class Repository {
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException("ex");
}
}
public void runSQL() throws SQLException {
throw new SQLException("ex");
}
}
원하는 결과로 RuntimeSQLException
으로 변경되어 출력되는 모습을 볼 수 있다.
그럼 무슨 문제가 있을까?
실제 상황이었다면, DB에서 어떤 문제 때문에 체크 예외인 SQLException
이 발생했는지 로그를 남겨줬을텐데 예외를 전환하는 과정에서 이전 예외의 정보를 런타임 예외로 넘겨주지 않았다. *심각한 문제이다.*
그래서 이렇게 런타임 예외로 전환할 때는 Throwable
의 cause
를 넘겨주는 방식으로 생성해야한다.
해당 방식으로 변경해보자.
static class Repository {
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
public void runSQL() throws SQLException {
throw new SQLException("ex");
}
}
하단에 보면 Cause By로 이전 예외에 대한 정보를 확인할 수 있다.
'Spring' 카테고리의 다른 글
[Spring] @Transactional의 다양한 기능과 주의사항 (1) | 2024.02.27 |
---|---|
[Spring] 스프링의 RuntimeException 추상화 (1) | 2023.12.18 |
[Spring] 스프링의 트랜잭션 기술 (0) | 2023.12.16 |
[Spring] 트랜잭션에 대한 이해와 과거의 활용 (0) | 2023.12.13 |
[Spring] Connection Pool, DataSource (0) | 2023.12.07 |