[Spring DB] 5.Java 예외
Updated:
Java 예외
Spring이 제공하는 예외 추상화를 이해하기에 앞서 Java 기본 예외들에 대해 알아본다. 뿐만 아니라 체크 예외와 언체크 예외에 대해 알아보고 활용법에 대해 살펴본다
1. 예외 계층과 기본규칙
1-1. 예외 계층

- 예외도 객체이기 때문에 최상위 부모는
Object이다 - 최상위 예외인
Throwable은Exception과Error로 나눠진다 Error는 메모리 부족, 심각한 시스템 오류 등 애플리케이션에서 불가능한 시스템 예외로 개발자가 잡지 않는다Exception이 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다- 개발자는 Exception부터 예외를 잡는다고 생각하면 된다
Exception과Exception하위 중RuntimeException을 제외하고는 체크 예외이다- 체크 예외는 컴파일러가 체크한다는 의미이다
RuntimeException과 그 하위 예외는 모두 언체크 예외이다- 언체크 예외는 컴파일러가 체크하지 않는다는 의미이다
1-2. 예외 기본규칙
- 예외는 현재 계층에서 잡아서 처리하거나, 처리할 수 없는 경우 밖으로 던지거나 둘 중 하나를 선택해야 한다
try ~ catch문을 통해 잡거나throw를 통해 던져야 한다
- 예외를 잡거나 던질 때 해당 예외 뿐만 아니라 그 하위의 예외들도 모두 함께 처리된다

- 예외를 처리하면 그 이후부터는 애플리케이션 로직이 정상적으로 동작한다
- 예외를 처리하지 못할 경우 호출한 곳으로 예외를 계속 던진다
2. 체크 예외와 언체크 예외
예외를 2가지로 나누고 각각의 특징과 처리방법에 대해 알아보자
2-1. 체크 예외
체크 예외는 잡아서 처리가 불가능한 경우
throws를 통해 던진다는 것을 필수로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다. 즉, 개발자가 예외를 실수로 누락해도 컴파일 시점에 문제를 잡아준다는 장점을 지닌다. 반대로, 개발자가 모든 체크 예외를 반드시 잡거나 던져야 하기 때문에 번거롭다는 단점이 있다
Exception과 그 하위 예외 중RuntimeException을 제외한 나머지가 여기에 포함된다- 예외를 잡지 않고 밖으로 던질 경우, throws를 통해 선언해야 하고 그렇지 않을 경우 컴파일 오류가 발생한다
아래 코드를 통해 자세히 알아보자
@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에 예외를 필수로 선언해야 한다
* @throws MyCheckedException
*/
public void callThrow() throws MyCheckedException {
repository.call();
}
}
static class Repository {
public void call() throws MyCheckedException {
throw new MyCheckedException("ex");
}
}
}
-
우선
Exception을 상속받은MyCheckedException예외 클래스를 만들었다Exception을 상속받을 경우 체크 예외가 된다
-
Repository에서call()메서드가MyCheckedException를 던진다고 가정한다MyCheckedException은 체크 예외이므로 예외를 던질 경우throws를 통해 선언해야 한다
-
Service에 예외를 잡아서 처리하는callCatch()메서드와 예외를 밖으로 던지는callThrow()메서드 총 2개를 만든다callCatch()는try ~ catch문을 통해 예외를 잡고callThrow()는 예외를 밖으로 던진다callCatch()에서 예외를 잡은 이후부터는 정상 흐름으로 작동한다- 체크 예외이므로
callThrow()는throws를 통해 예외를 던진다는 것을 선언해야 한다
-
@Test수행 결과callCatch()는 아래와 같이 로그를 출력된다18:25:21.326 [main] INFO hello.jdbc.exception.basic.CheckedTest -- 예외 처리, message=ex hello.jdbc.exception.basic.CheckedTest$MyCheckedException: ex at hello.jdbc.exception.basic.CheckedTest$Repository.call(CheckedTest.java:64) at hello.jdbc.exception.basic.CheckedTest$Service.callCatch(CheckedTest.java:45)log.info()의{}안에 두번째 인자값인e.getMessage()값이 들어가서 맨 윗줄 로그가 출력된다log.info()의 세번째 인자값인 e에 의해 나머지 3줄 로그가 출력된다
-
@Test수행 결과callThrow()는assertThatThrownBy를 통해 예외가 던져졌음을 검증할 수 있다
2-2. 언체크 예외
잡아서 처리가 불가능한 예외에 대해 언체크 예외는 예외를 밖으로 던질 때 사용하는
throws를 생략 가능하다. 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다는 점에서 편리하다. 하지만, 개발자가 실수로 예외를 누락할 수 있다는 단점이 존재한다
RuntimeException과 그 하위 예외는 언체크 예외로 분류된다- 언체크 예외는 컴파일러가 예외를 체크하지 않으며, 예외를 던질 때 throws를 생략가능하다
아래 코드를 통해 자세히 살펴보자
@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);
}
/**
* RuntimeException을 상속받은 예외는 언체크 예외가 된다
*/
static class MyUncheckedException extends RuntimeException {
public MyUncheckedException(String message) {
super(message);
}
}
/**
* 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");
}
}
}
- 우선
RuntimeException을 상속받은MyUncheckedException예외 클래스를 만들었다RuntimeException을 상속받을 경우 언체크 예외가 된다
- 예외를 잡지 않을 경우 자동으로 밖으로 예외를 던진다
- 이때 체크 예외와 달리
throws선언을 하지 않는다는 차이점이 존재한다
- 이때 체크 예외와 달리
3. 체크 예외와 언체크 예외의 활용
그렇다면 실제로 언제 체크 예외를 사용하고, 언제 언체크(런타임) 예외를 사용할지에 대한 고민이 생긴다
3-1. 예외 사용의 기본 원칙
- 기본적으로 언체크(런타임) 예외를 사용하자
- 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용하자
- 임의의 예외를 반드시 잡아서 처리해야 하는 경우에만 체크 예외를 사용하자
- 예시
- 계좌 이체 실패 예외
- 로그인 실패 예외
3-2. 체크 예외의 문제점
아래 그림을 예시로 이해해보자

Repository와NetworkClient가 각각SQLException과ConnectException예외를 던지는 상황이다Service는 물론Controller까지도SQLException과ConnectException예외를 처리할 방법이 없기 때문에 밖으로 던진다- 웹 애플리케이션에서는 서블릿의 오류 페이지나 Spring MVC가 제공하는
ControllerAdvice에서 이런 예외들을 공통으로 처리한다- 이런 예외들은 사용자들에게 알려주어도 이해하기 어렵기 때문에 일반적인 메세지만 보여준다
- 이렇게 해결이 불가능한 공통 예외는 오류 로그를 남기고 개발자들에게 빠르게 전달하여 문제를 해결해야 한다
다음은 코드를 통해 살펴보자
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");
}
}
}
NetworkClient와Repository가 각각ConnectException과SQLException예외를 던진다Service와Controller에서도 처리가 불가능하므로 밖으로 던진다
정리하자면 체크 예외 사용시에 크게 2가지 문제가 존재한다
- 복구 불가능한 예외
- 대부분의 예외는 복구불가능한 예외이기 때문에
Controller나Service에서 처리가 불가능하다 - 그래서 서블릿 필터, Spring의
ControllerAdvice등을 이용하여 공통으로 처리해야 한다 - 또한, 오류 로그를 남기고 개발자가 해당 오류를 빠르게 인지하도록 해야한다
- 대부분의 예외는 복구불가능한 예외이기 때문에
- 의존관계에 대한 문제
Controller나Service에서도 처리가 불가능하기 때문에throws를 통해 계속 던져야 한다- 그래서
Controller와Service모두 해당 예외에 의존하는 심각한 문제가 발생한다- 추후에 JDBC 기술을 JPA 기술로 변경할 경우 모든
SQLException을JPAException으로 변경해야 한다
- 추후에 JDBC 기술을 JPA 기술로 변경할 경우 모든
혹여나 throws Exception으로 해결할 수 있지 않냐는 의문이 들 수 있다. 하지만, Exception은 최상위 예외이므로 다른 하위 체크 예외들을 다 놓치게 되어 오류가 발생해야 하는 상황에 오류가 발생하지 않는 경우가 생긴다. 꼭 필요한 경우가 아니면 절대 throws Exception을 사용하지 말자!!!
3-3. 언체크 예외의 활용
언체크 예외를 이용하면 위에서 언급한 의존관계에 대한 심각한 문제를 해결가능하다. 복구 불가능한 예외 또한 신경쓰지 않아도 된다
이번에는 기존의 체크 예외를 언체크(런타임) 예외로 변경해보고자 한다

SQLException을RuntimeSQLException으로 변경하고,ConnectException을RuntimeConnectException으로 바꾸었다- 언체크(런타임) 예외는 처리할 수 없다면 별도의 선언 없이 그냥 두면 자동으로 밖에 던진다
아래 코드를 통해 구체적으로 살펴보자
public class UncheckedAppTest {
@Test
void unchecked() {
Controller controller = new Controller();
assertThatThrownBy(() -> controller.request())
.isInstanceOf(RuntimeSQLException.class);
}
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);
}
}
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) {
super(cause);
}
}
}
RuntimeException을 상속받은RuntimeConnectException과RuntimeSQLException클래스를 생성한다Repository에서runSQL()을 통해SQLException예외를 던지도록 하고call()에서 해당 예외를try ~ catch문으로 잡아서RuntimeSQLException로 전환해서 던진다
3-4. 주의점
예외를 전환할 때는 꼭 기존 예외를 포함하자!!!
우선 기존 예외를 포함하는 경우를 코드로 살펴보자
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
-
이는 위에서
Repository의call()에 해당되는 코드이다 -
try ~ catch를 통해 잡은SQLException예외를RuntimeSQLException예외로 전환하여 던지는 로직이다- 기존 예외인
SQLException를 포함하여 전환한다
- 기존 예외인
-
이 경우에 출력되는 로그는 아래와 같다
``` cmd 23:46:26.785 [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)
Caused by: java.sql.SQLException: ex at hello.jdbc.exception.basic.UncheckedAppTest$Repository.runSQL(UncheckedAppTest.java:65) at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.java:58)
- RuntimeSQLException 예외를 포함하여 기존 예외인 SQLException 예외와 스택 트레이스를 확인할 수 있다
이번에는 기존 예외를 포함하지 않는 코드를 살펴보자
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException();
}
}
-
기존 예외인
SQLException을 포함하지 않은 상태로RuntimeSQLException예외만 던진다 -
이 경우에 출력되는 로그는 아래와 같다
00:03:55.398 [main] INFO hello.jdbc.exception.basic.UncheckedAppTest -- ex hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: null 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)- 기존에 발생한
SQLException예외와 스택 트레이스를 확인할 수 없다 - 만약, 실제 DB에 연동했다면 DB에서 발생한 예외를 확인할 수 없는 심각한 문제가 발생한다
- 기존에 발생한
Leave a comment