자바 예외 이해
예외 계층
스프링이 제공하는 예외 추상화를 이해하기 위해선 자바 기본 예외에 대한 이해가 필요하다. 실무에 필요한 체크 예외와 언체크 예외에 대해 알아보자.
Throwable
은 최상위 예외로 하위에Exception
과Error
가 있다.Error
는 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다. 애플리케이션 개발자는 이 예외를 잡으려고 해서는 안된다.- 상위 예외를
catch
로 잡으면 그 하위 예외까지 잡기 때문에 애플리케이션 로직은 최상위인Exception
까지만 잡는 것이 올바르다. - 참고로
Error
도 언체크 예외이다.
- 상위 예외를
Exception
: 체크 예외- 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.
Exception
과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단,RuntimeException
은 예외로 한다.
RuntimeException
: 언체크 예외, 런타임 예외- 컴파일러가 체크하지 않는 언체크 예외이다.
- 해당 하위 예외도 모두 언체크 예외이다.
예외 기본 규칙
예외는 폭탄 돌리기와 같다. 잡아서 처리하거나, 처리할 수 없으면 밖으로 던져야한다.
예외는 잡아서 처리하거나 던져야한다. catch
는 지정한 예외 또는 그 하위 예외를 모두 잡을 수 있다. throws
는 그 하위 예외들도 모두 던질 수 있다.
참고로 예외를 처리하지 못하고 계속 던져서 main()
쓰레드 까지 온 경우 예외 로그를 출력하며 시스템이 종료된다. 웹 애플리케이션의 경우 WAS가 해당 예외를 받아서 처리하는데, 오류 페이지를 보여준다.
예외 처리
- 5번에서 예외를 처리하면 이후에는 애플리케이션 로직이 정상 흐름으로 동작한다.
예외 던짐
- 예외를 처리하지 못하면 호출한 곳으로 예외를 계속 던진다.
체크 예외 기본 이해
- 체크 예외는 잡아서 처리하거나, 또는 밖으로 던지도록 선언해야한다. 그렇지 않으면 컴파일 오류가 발생한다.
- 장점 : 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 안전 장치 역할을 한다.
- 단점 : 신경쓰고 싶지 않은 예외까지 모두 챙겨야한다. 의존관계에 따른 단점도 존재한다.
CheckedTest
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
@Slf4j
public class CheckedTest {
@Test
void checked_catch() {
Service service = new Service();
service.callCatch();
}
@Test
void checked_throw() {
Service service = new Service();
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) {
// 예외 처리 로직
log.info("예외 처리, message={}", e.getMessage(), e);
}
}
/**
* 체크 예외를 밖으로 던지는 코드
* 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언
*/
public void callThrow() throws MyCheckedException {
repository.call();
}
}
/**
* 체크 예외를 밖으로 던지는 코드
* 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언
*/
static class Repository {
public void call() throws MyCheckedException {
throw new MyCheckedException("ex");
}
}
}
Exception
을 상속받으면 체크 예외가 된다. 예외를 잡아서 던지려면 메서드에throws
와 기능에throw
가 필요하며, 잡으려면catch
가 필요하다.
언체크 예외 기본 이해
RuntimeException
과 그 하위 예외는 언체크 예외로 분류된다. 말 그대로 컴파일러가 예외를 체크하지 않는다는 뜻으로, 예외를 던지는 throws
를 선언하지 않고 생략할 수 있다. 이 경우 자동으로 예외를 던진다.
장점 : 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 예외를 밖으로 던질 때 throws
예외를 생략할 수 있어서 의존관계 참조 등에 편리한 장점이 있다.
단점 : 개발자가 실수로 예외를 누락할 수 있다.
UncheckedTest
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
@Slf4j
public class UncheckedTest {
@Test
void unchecked_catch() {
Service service = new Service();
service.callCatch();
}
@Test
void unchecked_throw() {
Service service = new Service();
assertThatThrownBy(() -> service.callThrow())
.isInstanceOf(MyUncheckedException.class);
}
static class MyUncheckedException extends RuntimeException {
public MyUncheckedException(String message) {
super(message);
}
}
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() { // throws 생략
repository.call();
}
}
static class Repository {
public void call() {
throw new MyUncheckedException("ex");
}
}
}
- 언체크 예외는
throws
를 선언해도 되고 생략해도 된다. 중요한 예외의 경우 선언해두면 해당 코드를 호출하는 개발자가 IDE를 통해 편리하게 인지할 수 있다.
체크 예외와 언체크 예외의 차이는 사실 예외를 처리할 수 없을 때 예외를 밖으로 던지는 부분에 있다. 이 부분을 필수로 선언해야 하는 가, 생략할 수 있는 가의 차이라고 보면 된다.
체크 예외 활용
기본적으로 언체크 예외를 사용하자.
체크 예외는 비즈니스 로직 상 의도적으로 던지는 예외에만 사용하자.
- 이 경우 예외를 잡아서 반드시 잡아서 처리해야 하는 문제 (심각한 문제)일 때만 체크 예외를 사용한다.
- 계좌 이체 실패 예외, 결제 시 포인트 부족 예외, 로그인 불일치 예외 등
체크 예외의 문제점
- 서비스의 입장에서 보면
SQLException
과ConnectException
두 가지 예외를 처리할 방법을 모른다. 그러므로 둘다 밖으로 던진다. - 컨트롤러도 마찬가지이므로 예외를 밖으로 던지고, 웹 애플리케이션 레벨에서 서블릿의 오류 페이지나, MVC가 제공하는 ControllerAdvice에서 예외를 공통처리한다.
- 오류 페이지라면 서비스에 문제가 있습니다와 같은 공통된 오류 페이지 또는 API라면 500 오류를 응답을 남길 것이다.
- 사용자에게 어떤 문제가 발생했는지 자세하게 설명하기 어렵기 때문이다.
- 예를 들어 SQL에서 예외가 발생했다면 SQL을 수정해서 배포하기 전 까지 사용자는 같은 문제를 겪게 된다.
CheckedAppTest - 체크 예외 문제점
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.net.ConnectException;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.*;
@Slf4j
public class CheckedAppTest {
@Test
void checked() {
Controller controller = new Controller();
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 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");
}
}
}
- 체크드 예외 코드 에서 2가지 문제가 존재한다.
- 복구 불가능한 예외
- SQL 문법에 문제, 데이터베이스 자체 문제 등 오류 로그를 남기고 빠르게 인지하는 것이 필요하다. 즉, 서블릿 필터, 스프링 인터셉터, 스프링 컨트롤러어드바이스를 사용하면 이런 부분을 깔끔하게 공통 해결이 가능하다.
- 의존 관계에 대한 문제
- 체크 예외의 또 다른 심각한 문제는 예외에 대한 의존 관계 문제이다. 컨트롤러나 서비스 입장에서는 본인이 처리할 수 없어도 어쩔 수 없이
throws
를 통해 던지는 예외를 선언해야 한다. - 즉, 예를들어 JDBC를 사용하다 JPA로 기술을 변경하면, 예외를 JPA에 의존하도록 고쳐야 한다. 따라서 OCP, DI를 통해 클라이언트 코드의 변경 없이 대상 구현체를 변경할 수 있는 장점을 예외 처리에 발목 잡히게 된다.
- 체크 예외의 또 다른 심각한 문제는 예외에 대한 의존 관계 문제이다. 컨트롤러나 서비스 입장에서는 본인이 처리할 수 없어도 어쩔 수 없이
- 의존 관계에 대한 문제(구현체 변경 시)는 다음 그림과 같이 발생하게 된다.
언체크 예외 활용
SQLException
을 런타임 예외인RuntimeSQLException
으로 변환,ConnectException
대신에RuntimeConnectException
을 사용하도록 바꾸었다.- 런타임 예외이기 때문에 서비스, 컨트롤러는 별도의 예외 없이 그냥 두면 된다.
UncheckedAppTest
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@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) {
log.info("ex", e);
}
}
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 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");
}
}
static class RuntimeConnectException extends RuntimeException {
public RuntimeConnectException(String message) {
super(message);
}
}
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
}
- 예외 전환 : 리포지토리에서 체크 예외를 런타임 예외로 전환해서 던진다. 참고로 이때 기존 예외를 포함해주어야 예외 출력 스택 트레이스에서 기존 예외도 함께 확인할 수 있다.
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");
}
}
- 다음과 같이 기존 예외를 포함하는 예외로 잡아야만 스택 트레이스를 확인할 수 있다.
RuntimeSQLException
이 기존 예외인SQLException
의 스택 트레이스를 갖도록 설정하자.
- 다음과 같이 스택 트레이스를 확인할 수 있다.
Caused by
로 예외를 추적할 수 있다. 기존 예외를 포함하지 않는다면null
로 표현된다.
본 포스팅은 인프런 - 김영한님의 '스프링 DB 1편 - 데이터 접근 핵심 원리'를 정리한 내용입니다.