Java의 Exception: checked, unchecked 그리고 설계 방법
1. 개요
Exception은 예상대로 작동하지 않은 무언가를 보고하는 Java의 메커니즘입니다. 많은 프로젝트들이 exception을 일관성 있게 사용하지 않습니다. 어떤 곳에서는 throws Exception 선언이 그곳저곳에 난무합니다. 어떤 곳에서는 빈 catch 블록에서 에러를 무시합니다.
이 글에서는 checked exception과 unchecked exception의 차이를 이해하게 될 것입니다. 각 유형을 언제 사용해야 하는지도 알게 될 것입니다. 그리고 애플리케이션을 위한 커스텀 exception 시스템을 설계하는 방법을 배웁니다. 글의 마지막에는 JUnit 5와 AssertJ를 사용한 exception 던지기 행동의 테스트 방법이 있습니다.
2. Throwable의 계층 구조
throw로 던질 수 있는 모든 것은 java.lang.Throwable에서 상속됩니다. 단순화된 계층 구조는 다음과 같습니다:
Throwable
├── Error (unchecked) – JVM의 심각한 오류
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception (checked)
├── IOException
├── SQLException
└── RuntimeException (unchecked)
├── IllegalArgumentException
├── IllegalStateException
└── NullPointerException
분류 규칙은 매우 간단합니다:
- Checked exception은
Exception과 그 서브클래스들이며,RuntimeException분기는 제외합니다. 컴파일러가 이 유형을 처리하도록 강제합니다. - Unchecked exception은
RuntimeException,Error그리고 그들의 서브클래스들입니다. 컴파일러가 처리를 강제하지 않습니다.
절대 Error를 catch해서는 안 됩니다. JVM이 메모리를 잃으면 애플리케이션이 자신을 복구하기 어렵습니다.
3. Checked exception
Checked exception은 메서드의 ’계약’의 일부입니다. 메서드가 throws를 선언하면, 호출자가 두 가지 중 하나를 선택해야 합니다. 첫 번째는 try/catch로 예외를 잡는 것입니다. 두 번째는 throws를 상위로 선언하는 것입니다.
결제 게이트웨이가 거래를 거부할 수 있는 경우를 생각해 봅시다. 이 상황은 실제로 발생할 수 있으며, 호출자가 그 다음에 무엇을 할지 결정해야 합니다:
public class PaymentDeclinedException extends Exception {
private final String reason;
public PaymentDeclinedException(String reason) {
super("결제가 거부되었습니다: " + reason);
this.reason = reason;
}
public String getReason() {
return reason;
}
}
public class PaymentGateway {
public void charge(String cardNumber, long amount) throws PaymentDeclinedException {
if (amount > 10_000_000) {
throw new PaymentDeclinedException("한도 초과");
}
System.out.println("카드 " + cardNumber + "에서 " + amount + "를 차감했습니다");
}
}
PaymentDeclinedException은 Exception을 상속하므로 checked exception입니다. charge(...)를 호출하되 예외를 처리하지 않으면 컴파일러가 보고합니다:
error: 보고되지 않은 exception PaymentDeclinedException; 캐치되거나 throws로 선언되어야 합니다
호출자는 실패하는 경우를 고려해야 합니다:
public class CheckoutService {
private final PaymentGateway gateway = new PaymentGateway();
public String checkout(String card, long amount) {
try {
gateway.charge(card, amount);
return "SUCCESS";
} catch (PaymentDeclinedException e) {
System.out.println("이유: " + e.getReason());
return "DECLINED";
}
}
public static void main(String[] args) {
var service = new CheckoutService();
System.out.println(service.checkout("4111-xxxx", 50_000));
System.out.println(service.checkout("4111-xxxx", 20_000_000));
}
}
출력:
카드 4111-xxxx에서 50000를 차감했습니다
SUCCESS
이유: 한도 초과
DECLINED
여기서 예외는 비즈니스 상태(DECLINED)로 변환됩니다. 이것이 checked exception이 가장 잘 작동하는 상황의 유형입니다.
4. Unchecked exception
Unchecked exception은 일반적으로 프로그래밍 오류 또는 선결 조건 위반을 나타냅니다. 예를 들어 null을 전달하거나, 음수를 전달하거나, 객체가 잘못된 상태에 있을 때 메서드를 호출하는 것입니다.
public class Order {
private final long id;
private int quantity;
private boolean shipped;
public Order(long id, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("quantity는 0보다 커야 하는데, 받은 값: " + quantity);
}
this.id = id;
this.quantity = quantity;
}
public void changeQuantity(int newQuantity) {
if (shipped) {
throw new IllegalStateException("주문 " + id + "는 이미 배송되었으므로 수량을 변경할 수 없습니다");
}
this.quantity = newQuantity;
}
public void ship() {
this.shipped = true;
}
}
생성자는 입력이 유효하지 않을 때 IllegalArgumentException을 던집니다. 메서드 changeQuantity는 주문이 배송되었을 때 IllegalStateException을 던집니다. 아무도 호출한 각 곳에서 이러한 오류를 ’캐치하고 처리’해야 하지 않습니다. 올바른 방법은 잘못된 호출 코드를 수정하는 것입니다.
새로운 클래스를 만들기 전에 JDK에 있는 exception을 재사용해야 합니다:
| 상황 | 사용할 Exception |
|---|---|
| 잘못된 매개변수 | IllegalArgumentException |
| 잘못된 객체 상태 | IllegalStateException |
필수 매개변수가 null |
NullPointerException (Objects.requireNonNull 사용) |
| 지원하지 않는 작업 | UnsupportedOperationException |
| 범위 밖의 인덱스 | IndexOutOfBoundsException |
5. Checked인지 unchecked인지 선택하기?
실용적인 규칙이 있습니다: 호출자가 이 오류로 유용한 무언가를 할 수 있는지 묻는 것입니다.
- 호출자가 합리적으로 복구할 수 있으면, 예를 들어 재시도, 대체 방안 선택 또는 사용자 알림이 가능하면 checked exception이 허용되는 선택입니다.
- 버그 때문이거나 인프라 장애 때문에 호출자가 할 수 없는 오류라면 unchecked exception을 사용하세요.
실제로 Spring과 Hibernate 같은 현대 프레임워크는 거의 unchecked exception만 사용합니다. 예를 들어 Spring은 SQLException을 DataAccessException으로 래핑합니다. 이유는 checked exception이 몇 가지 단점이 있기 때문입니다:
- 그들은 모든 계층을 통해 점차적으로 확산됩니다: repository 계층의
throws하나가 service와 controller의throws를 끌어옵니다. - 그들은 lambda 및 Stream API와 호환되지 않습니다 (항목 8 참조).
- 그들은 구현 세부 사항을 노출합니다.
OrderRepository인터페이스는throws SQLException을 선언해서는 안 됩니다.
참고: Spring Boot 애플리케이션의 경우 기본적으로 비즈니스 오류에 unchecked exception을 사용하세요. 그 다음
@RestControllerAdvice로 중앙에서 처리하세요. 호출자가 제자리에서 처리하도록 강제하고 싶을 때만 checked exception을 사용하세요.
6. 애플리케이션을 위한 커스텀 exception 설계
6.1. 루트 클래스 만들기
각 도메인에 대해 루트 exception을 만들어야 합니다. 이 방법은 필요할 때 하나의 catch 블록으로 도메인의 모든 오류를 캐치할 수 있게 해줍니다:
public class OrderException extends RuntimeException {
public OrderException(String message) {
super(message);
}
public OrderException(String message, Throwable cause) {
super(message, cause);
}
}
이 클래스는 항상 cause를 받는 생성자를 가져야 합니다. 그 덕분에 원인 오류는 래핑할 때 손실되지 않습니다.
6.2. 맥락을 담는 서브클래스
좋은 exception은 “무엇이 일어났나, 어떤 데이터로?“라는 질문에 답할 수 있어야 합니다. 단지 "Not found"라고만 기록하지 마세요:
public class OrderNotFoundException extends OrderException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("주문을 찾을 수 없습니다. id=" + orderId);
this.orderId = orderId;
}
public long getOrderId() {
return orderId;
}
}
필드 orderId는 상위 계층에서 사용할 수 있도록 저장됩니다. 예를 들어, controller advice는 메시지 문자열을 파싱하지 않고도 클라이언트에 오류 JSON을 반환하기 위해 읽을 수 있습니다.
6.3. 하위 계층 오류 래핑 (exception translation)
서비스 계층이 인프라를 호출할 때, 기술적 오류를 도메인 오류로 변환하고 원인을 유지해야 합니다:
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class OrderExportService {
public void export(long orderId, Path target) {
try {
Files.writeString(target, "order=" + orderId);
} catch (IOException e) {
throw new OrderException("주문 " + orderId + "을 " + target + "으로 내보내기 실패", e);
}
}
}
두 번째 매개변수 e는 원인입니다. 스택 추적을 인쇄할 때 두 계층을 모두 볼 수 있습니다:
OrderException: 주문 42를 /readonly/order.txt으로 내보내기 실패
at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
...
throw new OrderException(e.getMessage())로 작성하되 e를 전달하지 않으면 Caused by 부분이 사라집니다. 그 경우 디버깅이 매우 어려워집니다.
7. 흔한 anti-pattern들
Exception 무시하기. 이것은 가장 위험한 실수입니다. 애플리케이션이 마치 아무 일도 일어나지 않은 것처럼 계속 실행되기 때문입니다:
try {
gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
// TODO
}
정말로 오류를 무시하고 싶다면 comment에 명확한 이유를 적으세요. 최소한 오류를 기록해야 합니다.
로깅하고 다시 던지기. log.error(...)를 작성한 후 throw e;를 하면 같은 오류가 로그에 여러 번 나타납니다. 하나를 선택해야 합니다: 그 자리에서 처리(및 로깅)하거나, 상위 계층이 처리하도록 던지기.
너무 광범위한 Exception 또는 Throwable 캐치하기. 이렇게 하면 의도하지 않게 NullPointerException과 다른 버그들도 캐치합니다. 처리할 수 있는 정확한 유형을 캐치하세요. 유일한 합리적인 예외는 애플리케이션의 가장 바깥쪽 경계, 예를 들어 global handler입니다.
제어 흐름으로 exception 사용하기. Exception을 생성하는 것은 스택 추적을 수집해야 하므로 비용이 많이 듭니다. 이런 코드는 읽기도 어렵습니다:
// 안 좋은 방법
try {
return Integer.parseInt(input);
} catch (NumberFormatException e) {
return 0;
}
자주 실패할 것으로 예상되는 데이터에 대해서는 먼저 확인하세요. Optional을 반환할 수도 있습니다. Exception은 예외적인 상황을 위해 예약해야 합니다.
throws Exception 선언하기. 이 방법은 발생할 수 있는 오류 유형에 대한 정보를 완전히 제거합니다. 또한 모든 호출자가 매우 광범위한 유형을 캐치하도록 강제합니다.
8. Lambda에서의 checked exception
인터페이스 Function은 throws를 선언하지 않으므로 다음 코드는 컴파일되지 않습니다:
List<String> contents = paths.stream()
.map(Files::readString) // 오류: 보고되지 않은 exception IOException
.toList();
일반적인 처리 방법은 오류를 UncheckedIOException으로 래핑하는 것입니다. 이 클래스는 정확히 이 목적을 위해 JDK에 포함되어 있습니다:
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class OrderFileReader {
public List<String> readAll(List<Path> paths) {
return paths.stream()
.map(OrderFileReader::readQuietly)
.toList();
}
private static String readQuietly(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException("파일을 읽을 수 없습니다: " + path, e);
}
}
}
메서드 readQuietly는 try/catch 블록을 파이프라인에서 분리하므로 스트림이 깔끔합니다. readAll의 호출자는 UncheckedIOException을 캐치하고 getCause()를 통해 원본 IOException을 가져올 수 있습니다.
9. try-with-resources와 suppressed exception
닫아야 할 리소스로 작업할 때는 try-with-resources를 사용하세요. 주목할 점이 하나 있습니다: try 본문과 close() 모두 오류를 던지면 close()의 오류가 주요 오류를 덮어쓰지 않습니다. 그것은 suppressed로서 주요 오류에 첨부됩니다:
public class SuppressedDemo {
static class ReportConnection implements AutoCloseable {
void query() {
throw new IllegalStateException("Query 실패");
}
@Override
public void close() {
throw new IllegalStateException("Close 실패");
}
}
public static void main(String[] args) {
try (var connection = new ReportConnection()) {
connection.query();
} catch (IllegalStateException e) {
System.out.println("주요: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println("Suppressed: " + s.getMessage());
}
}
}
}
출력:
주요: Query 실패
Suppressed: Close 실패
try/finally를 직접 작성하고 finally에서 close()를 호출하면 “Close 실패” 오류가 “Query 실패”를 덮어씁니다. 그 경우 실제 원인을 잃게 됩니다.
10. Exception 던지기 행동 테스트하기
Spring Boot를 사용하면 spring-boot-starter-test에 이미 JUnit 5와 AssertJ가 포함되어 있습니다. 순수 Java 프로젝트의 경우 다음 종속성을 추가하세요:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
예외 유형, 메시지, 컨텍스트 데이터 및 원인을 모두 확인해야 합니다:
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
class OrderExceptionTest {
@Test
void whenQuantityIsZero_thenThrowIllegalArgument() {
assertThatThrownBy(() -> new Order(1L, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("quantity는 0보다 커야");
}
@Test
void whenOrderShipped_thenCannotChangeQuantity() {
var order = new Order(7L, 2);
order.ship();
assertThatIllegalStateException()
.isThrownBy(() -> order.changeQuantity(5))
.withMessage("주문 7은 이미 배송되었으므로 수량을 변경할 수 없습니다");
}
@Test
void notFoundException_carriesOrderId() {
assertThatThrownBy(() -> { throw new OrderNotFoundException(42L); })
.isInstanceOf(OrderException.class)
.hasFieldOrPropertyWithValue("orderId", 42L);
}
@Test
void whenPaymentOverLimit_thenThrowCheckedException() {
var gateway = new PaymentGateway();
assertThatExceptionOfType(PaymentDeclinedException.class)
.isThrownBy(() -> gateway.charge("4111-xxxx", 20_000_000))
.satisfies(e -> assertThat(e.getReason()).isEqualTo("한도 초과"));
}
@Test
void whenExportFails_thenCauseIsPreserved() {
var service = new OrderExportService();
Path invalid = Path.of("/존재하지-않는-디렉토리/order.txt");
assertThatThrownBy(() -> service.export(42L, invalid))
.isInstanceOf(OrderException.class)
.hasMessageContaining("주문 42")
.hasCauseInstanceOf(java.io.IOException.class);
}
}
주목할 몇 가지 사항:
assertThatThrownBy는 checked exception을 포함한 모든Throwable을 던질 수 있는 lambda를 받습니다. 따라서 테스트에서try/catch가 필요하지 않습니다.hasFieldOrPropertyWithValue는 gettergetOrderId()를 읽습니다. 그 덕분에 컨텍스트가 올바르게 전달되는지 확인할 수 있습니다.hasCauseInstanceOf는 원인이 오류를 래핑할 때 손실되지 않음을 확인합니다. 원인을 잊어버린 오류는 이 테스트가 없으면 쉽게 놓칩니다. 테스트에서 부모 디렉토리가 존재하지 않으므로Files.writeString은IOException의 서브클래스인NoSuchFileException을 던집니다.
11. 결론
이 글에서는 호출자가 복구할 수 있는 상황을 위한 checked exception을 보았습니다. Unchecked exception은 프로그래밍 오류, 조건 위반 또는 호출자가 할 수 없는 인프라 오류를 위한 것입니다. 현대 애플리케이션, 특히 Spring Boot의 경우 unchecked exception이 합리적인 기본 선택입니다.
커스텀 exception을 설계할 때 다음 원칙을 따르세요:
- 각 도메인에 대한 루트 클래스를 만드세요.
- 서브클래스에 컨텍스트 데이터를 추가하세요.
- 계층 간에 오류를 변환할 때 항상
cause를 유지하세요.
마지막으로, 오류 무시, 로깅 및 다시 던지기, 또는 너무 광범위한 Exception 캐치와 같은 anti-pattern을 피해야 합니다. 동시에 코드의 오류 던지기 동작을 잠그는 테스트를 작성하세요.