예외 계층
스프링이 제공하는 예외 추상화를 이해하기 위해서는 자바 기본 예외에 대한 이해가 필요하다.
복습하는 과정이라고 생각하고, 실무에 필요한 체크 예외와 언체크 예외의 차이와 활용 방안에 대해 알아보자

예외도 객체이기 때문에 Object가 최상위에 있다.
Throwable : 최상위 예외
Error : 메모리 부족이나 심각한 시스템 오류로 애플리케이션에서 복구 불가능한 시스팀 예외. 개발자는 이 예외를 잡으려고 하면 안됨
- 상위 예외를 잡으면 하위 예외도 같이 잡힌다. 따라서 Throwable 예외도 잡으면 안된다. Error를 잡을 수 있기 때문
Exception : 체크 예외
- 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외
- Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. (RuntimeException은 제외)
RuntimeException : 언체크 예외
- 컴파일러가 체크하지 않는 언체크 예외이다. 그 자식도 모두 언체크 예외이다.
예외 기본 규칙
예외는 잡아서 처리하거나, 처리할 수 없다면 던져야 한다.


1. 예외는 잡아서 처리하거나 던져야한다.
2. 예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 자식들도 함께 처리된다.
- Exception을 catch로 잡으면 그 하위 예외들도 모두 잡을 수 있다.
- Exception은 throws로 던지면 그 하위 예외들도 모두 던질 수 있다.
예외를 처리하지 못하고 계속 던지면 main() 쓰레드의 경우 예외 로그를 출력하면서 시스템이 종료된다.
웹 애플리케이션의 경우 시스템이 종료되면 안되기 때문에, WAS가 해당 예외를 받아서 처리하는데, 주로 사용자에게 개발자가 지정한 오류 페이지를 보여준다.
체크 예외 기본 이해
- Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. (RuntimeException 제외)
- 체크 예외는 잡아서 처리하거나, 밖으로 던지도록 선언해야 한다.
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
@Slf4j
public class CheckedTest {
@Test
void checked_catch() {
Service service = new Service();
service.callCatch();
}
@Test
void checked_throws() {
Service service = new Service();
Assertions.assertThatThrownBy(() -> service.callThrow())
.isInstanceOf(MyCheckedException.class);
}
// Exception을 상속받은 예외는 체크 예외가 된다.
static class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message);
}
}
// 체크 예외는 예외를 잡아서 처리하거나, 던지거나 둘 중 하나 필수
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();
}
}
static class Repository {
public void call() throws MyCheckedException {
throw new MyCheckedException("ex");
}
}
}
Exception을 상속받은 예외는 체크 예외가 된다. - MycheckedException extends Exception
RuntimeException을 상속받으면 언체크 예외가 된다. 이건 규칙이다.
예외 잡아서 처리
callCatch()
- catch로 예외를 잡았기 때문에, service.callCatch()는 정상 흐름이다. 테스트 메서드까지 예외가 올라오지 않는다.
log.info("예외처리, message={}", e.getMessage(), e);
- 마지막 인수에 예외 객체를 전달해주면 해당 예외의 스택트레이스를 추가로 출력해준다.

예외 던지기
callThrow()
- throws MyCheckedException이라고 필수로 선언해줘야 예외가 던져진다.
체크 예외의 장단점
예외를 밖으로 던질 때 throws 예외를 필수로 선언해야한다. 그렇지 않으면 컴파일 에러가 발생한다. 이것 때문에 장점과 단점이 존재한다.
장점 : 컴파일 에러가 발생하기 때문에 예외를 누락할 일이 없다.
단점: 모든 체크예외를 반드시 잡거나, 던져야하기 때문에 catch를 하던 throws 예외를 선언해야하는데 이게 번거롭다. 신경쓰지 않아도 될 예외까지 모두 일일이 코드를 작성해야한다.
언체크 예외 기본 이해
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
@Slf4j
public class UncheckedTest {
@Test
void unchecked_catch() {
Service service = new Service();
service.callCatch();
}
@Test
void unchecked_throw() {
Service service = new Service();
Assertions.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을 상속받으면 그 하위 예외도 언체크 예외가 된다.
체크 예외와 코드 차이는 던지는 부분에 있다. throws를 선언하냐 생략하냐 차이다.
그것 외엔 별 차이 없다. (현재까지는)
언체크 예외 장단점
장점 : 신경쓰고 싶지 않은 예외를 무시할 수 있다. throws를 매번 선언할 필요가 없다.
단점 : 예외 누락할 가능성이 생긴다. 체크예외는 컴파일러를 통해 누락을 잡아준다.
체크 예외 활용
그렇다면 언제 체크예외를 사용하고 언제 런타임 예외를 사용하면 좋은가?
기본 원칙 2가지
- 기본적으로 언체크(런타임) 예외를 사용할 것
- 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용
(계좌 이체 실패, 결제 관련 예외, 로그인 관련 예외, 물론 이 경우에도 100% 체크 예외로 만들어야 하는것은 아님)
체크 예외의 문제점
체크예외는 컴파일러가 예외 누락을 체크해주기 때문에 개발자가 실수로 예외를 놓치는것을 막아준다. 그래서 항상 명시적으로 예외를 잡아서 처리하거나, 처리할 수 없을 땐 throws 예외로 선언해야 한다.
그러면 체크 예외가 더 안전하고 좋은 것 같은데 왜 사용하지 말라고 하는가?

- 리포지토리는 SQLException, NetworkClient는 ConnectionException 체크 예외를 던진다.
- 서비스는 Repository, NetworkClient 둘다 호출하기 때문에, 서비스는 SQLException, ConnectionException을 둘 다 처리해야함. 그런데 서비스는 둘을 처리하는 방법을 모름. 위 예외가 터졌을 때 서비스 계층에서 해결할 수 있는 방법이 없기 때문에 컨트롤러로 예외를 던져야함
- 컨트롤러도 마찬가지로 해결 못하면, 또 throws를 선언해서 던져야함.
(불필요한 과정이 추가로 생기는 일이 되어버리는 것임)
- 웹 애플리케이션이라면 서블릿의 오류 페이지나, 또는 스프링 MVC가 제공하는 ControllerAdvice에서 이런 예외를 공통으로 처리한다.
(@ControllerAdvice : @ExceptionHandler 어노테이션이 붙은 메서드를 global로 적용하게 해주는 어노테이션. @ExceptionHandler는 해당 컨트롤러 내부에서만 작동하는데, @ControllerAdvice는 전체 컨트롤러에서도 사용 가능하다.)
이런 문제들은 사용자에게 에러 메세지를 보여줘도, DB 문제로 오류가 발생했다. 라고 말해줘도 사용자 입장에선 아무 도움이 안된다.
API라면 보통 상태코드 500으로 응답을 내려준다. 이런 공통 예외는 별도 오류를 남기고, 개발자가 오류를 빨리 인지하는게 중요하다.
개발자가 수정해서 배포하지 않는 한 예외를 계속 던지기만 하는 과정만 반복되고 서비스가 정상적으로 작동되지 않는다.
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import java.net.ConnectException;
import java.sql.SQLException;
@Slf4j
public class CheckedAppTest {
@Test
void checked() {
Controller controller = new Controller();
Assertions.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 ConnectException, SQLException {
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");
}
}
}
코드를 보면 서비스, 컨트롤러 모두 throws Exception이 선언된것을 확인할 수 있따.
2가지 문제
1. 복구 불가능한 예외
- 대부분의 예외는 복구가 불가능하다. SQLException도 SQL 문법 문제인지, DB 자체에 대한 문제인지, DB 서버문제일지도 모른다. 이 문제들은 서비스나 컨트롤러에서 복구가 불가능하다. 위에서 언급했던대로 빠른 인지가 필요하고 @ControllerAdvice로 공통 예외를 처리하면 좋다.
2. 의존관계에 대한 문제
- 앞서 대부분 예외는 복구 불가능하다고 했따. 그런데 체크예외이기 때문에 어쩔수 없이 throws 를 선언해야되는데, 이게 서비스, 컨트롤러에서 java.sql.SQLException을 의존하기 때문에 문제가 되는것이다.
향 후 리포지토리를 JDBC기술이 아닌 다른 기술로 변경한다면, SQLException이 아닌 다른 예외로 변경해야하는데, 일일이 코드를 전부 수정해야하는 일이 발생한다.

위 같은 참사가 발생한다.
정리하자면
- 처리할 수 있는 체크 예외라면 서비스나 컨트롤러에서 처리하겠지만, 위 같은 문제는 해결할 수 없다.
- 이런 경우엔 체크예외를 쓰면 의존관계 문제가 발생하기 때문에 문제가 된다.
throws Exception같은 코드는 쓰지말자. 당장 해결은 가능하지만 이후에 큰 문제가 될 가능성이 높다.
언체크 예외 활용

- SQLException을 런타임 예외인 RuntimeSQLException으로 변환했다.
- ConnectionException 대신 RuntimeConnectException을 사용하도록 바꿨다.
- 런타임 예외이기 때문에, 서비스, 컨트롤러는 해당 예외들을 처리할 수 없다면 별도의 선언 없이 그냥 두면 된다.
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import java.net.ConnectException;
import java.sql.SQLException;
@Slf4j
public class UnCheckedAppTest {
@Test
void unchecked() {
Controller controller = new Controller();
Assertions.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);
}
}
}
예외 전환
- 리포지토리에서 체크 예외인 SQLException이 발생하면, 런타임 예외인 RuntimeSQLException으로 전환해서 예외를 던진다.
- 이 때 기존 예외를 포함해줘야 예외 출력시 스택 트레이스에서 기존 예외도 함께 확인할 수 있다.
- NetworkClient는 단순히 기존 체크 예외를 RuntimeConnectException이라는 런타임 예외가 발생하도록 바꿨다.
런타임 예외 - 대부분 복구 불가능한 예외
시스템에서 발생한 예외는 대부분 복구가 불가능하기 때문에, 서비스나 컨트롤러가 이런 복구 불가능한 예외를 신경쓰지 않아도 된다. 물론 이런 예외들은 일관성있게 공통으로 처리해야한다.
런타임 예외 - 의존 관계에 대한 문제
런타임 예외를 사용했기 때문에, 처리할 수 없는 예외는 무시했다. 더이상 throws를 선언하지 않아도 된다.
(선언하지 않아도 될 뿐이지 선언하면 안되는건 아님)

런타임 예외로 전환 후에, 기술 변경을 하더라도 예외를 공통으로 처리하는 부분만 코드를 변경하면 된다. 코드 변경 영향 범위가 매우 최소화 되었다.
런타임 예외는 놓칠 수 있기 때문에 문서화가 중요하다.
중요한 로직이라면 런타임 예외를 던지는 상황이어도 throws에 선언할 수 있다.
예외 포함과 스택 트레이스
@Test
void printEx() {
Controller controller = new Controller();
try {
controller.request();
} catch (Exception e) {
log.info("ex", e);
}
}
예외를 전환할 때는 꼭 기존 예외를 포함해야 한다. 그렇지 않으면 스택 트레이스를 확인할 떄 심각한 문제가 발생한다.
지금 예에선 파라미터가 없기 때문에, 예외만 파라미터로 전달했다.
기존 예외를 포함하는 경우엔 기존에 발생한 체크예외(SQLException)와 스택 트레이스를 확인할 수 있다.
예외를 포함하지 않으면 변환한 RuntimeSQLException부터 예외를 확인할 수 있다. 만약 DB에 연동했다면 DB에서 발생한 예외를 확인할 수 없게 된다. 기존 예외를 포함시키면 DB에서 어떤 문제로 예외가 발생했는지 확인이 가능해 에러를 더 쉽게 잡아낼 수 있다.
예외 전환시엔 꼭 기존예외를 포함할 것
'Spring' 카테고리의 다른 글
| 데이터 접근 기술 - 스프링 JdbcTemplate (0) | 2025.10.18 |
|---|---|
| 스프링과 문제 해결 - 예외처리, 반복 (0) | 2025.10.17 |
| 트랜잭션 2 (0) | 2025.10.13 |
| 트랜잭션 (0) | 2025.10.02 |
| 커넥션 풀과 데이터소스 (0) | 2025.09.29 |
