
자바 checked exception과 unchecked exception 차이는 문법 분류로만 외우면 실무에서 금방 흐려집니다. 정작 중요한 질문은 더 단순합니다. 지금 이 예외를 여기서 복구할 수 있는가, 아니면 상위 계층이 결정해야 하는가입니다.
이번 글에서는 checked와 unchecked의 기본 차이부터, 언제 catch하고 언제 throws로 넘기고 언제 다른 예외로 감싸야 하는지까지 실무 기준으로 정리하겠습니다.
한눈에 보기
- checked exception: 컴파일러가 처리 여부를 확인한다. 보통 throws 선언이나 catch가 필요하다
- unchecked exception: RuntimeException 계열이다. 컴파일러가 선언을 강제하지 않는다
- 실무 기준: 복구 가능성, 계층 경계, API 계약, 트랜잭션 경계를 함께 본다
한 줄로 줄이면 이렇습니다. checked exception은 호출자가 이 실패를 알고 선택해야 하는 경우에 어울리고, unchecked exception은 프로그래밍 오류이거나 선언 강제가 오히려 API를 더럽히는 경우에 자주 어울립니다.
checked exception의 역할
checked exception은 메서드 시그니처에 실패 가능성을 드러내는 도구입니다. 파일 읽기처럼 외부 자원 상태에 따라 실패할 수 있는 작업은, 호출자가 재시도할지 대체 경로를 쓸지 결정할 여지가 있습니다.
public String readConfig(Path path) throws IOException {
return Files.readString(path);
}이 경우 IOException은 호출자에게 이 작업은 성공이 보장되지 않는다는 계약을 보여줍니다. 즉, checked exception의 핵심은 귀찮게 throws를 붙이게 하는 데 있지 않고, 실패를 무시하지 말라고 신호를 주는 데 있습니다.
unchecked exception의 역할
unchecked exception은 대표적으로 RuntimeException 하위 타입입니다. 보통 애초에 코드가 잘못되었거나, 호출자에게 선언을 강제해도 별 도움이 되지 않는 경우에 많이 등장합니다.
public void changeEmail(String email) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("email must not be blank");
}
}이 경우 중요한 것은 선언이 아니라 잘못된 입력을 빨리 드러내는 것입니다. throws IllegalArgumentException를 일일이 선언하게 해도 API 사용자가 얻는 실익은 크지 않습니다.
복구 가능성
실무에서 가장 쓸모 있는 첫 기준은 복구 가능성입니다. 이름보다 먼저, 이 자리에서 실제로 다음 행동을 할 수 있는지를 봐야 합니다.
checked가 맞는 경우
- 파일이 없어서 다른 경로를 시도할 수 있다
- 네트워크 호출 실패 후 재시도 정책을 적용할 수 있다
- 사용자 입력 검증 실패를 다시 입력받아 해결할 수 있다
- 외부 시스템 응답 실패를 상위 흐름에서 보상 처리할 수 있다
unchecked가 맞는 경우
- null이 오면 안 되는 자리에 null이 왔다
- 메서드 호출 순서가 잘못되었다
- 내부 불변식이 깨졌다
- 개발자가 계약을 어긴 사용법이다
이 예외를 잡은 자리에서 실제로 의미 있는 다음 행동을 할 수 있는가를 먼저 묻는 습관이 가장 실용적입니다. 할 수 있으면 catch를 검토하고, 못 하면 억지로 잡지 말고 전파하거나 더 적절한 예외로 바꾸는 편이 낫습니다.
언제 catch할까
catch는 예외를 보았기 때문에 하는 것이 아니라, 처리할 수 있기 때문에 해야 합니다.
복구 로직이 있을 때
public String loadTemplate(Path primary, Path backup) throws IOException {
try {
return Files.readString(primary);
} catch (IOException e) {
return Files.readString(backup);
}
}이 코드는 실패를 삼킨 것이 아니라, 대체 경로라는 실제 대응을 했습니다. 이런 catch는 의미가 있습니다.
응답으로 바꿀 때
웹 계층에서는 도메인 예외를 HTTP 상태 코드와 메시지로 바꾸는 일이 필요합니다. 이런 경계 지점의 catch는 표현 책임이 분명합니다.
자원 정리와 경계 제어
try-with-resources, 트랜잭션 종료 지점, 배치 작업 경계처럼 현재 계층이 책임지는 범위가 분명한 곳에서는 catch가 필요할 수 있습니다.
로그 후 재던지기
같은 예외를 여러 계층에서 모두 로그로 남기면 중복 로그만 늘어납니다. 복구, 변환, 경계 응답 생성, 정리 후 재던지기 중 하나가 없다면 대부분 불필요한 catch일 가능성이 큽니다.
언제 전파할까
현재 계층이 결정할 수 없는 실패라면 전파가 더 정직합니다. repository가 만난 읽기 실패를 재시도할지 사용자에게 어떻게 보여줄지는 service가 더 잘 판단할 수 있습니다.
public UserProfile loadUserProfile(long userId) throws IOException {
return remoteClient.fetchUserProfile(userId);
}- 현재 계층이 복구 정책을 모른다
- 상위 계층이 더 많은 문맥을 가진다
- 지금 잡아도 메시지나 로직이 빈약하다
- 여기서 처리하면 오히려 책임 분리가 무너진다
이럴 때는 억지로 잡는 것보다 전파가 더 낫습니다.
언제 변환할까
하위 계층 예외를 그대로 노출하면 상위 API가 구현 세부에 묶일 수 있습니다. 예를 들어 SQLException이 service 밖까지 그대로 퍼지면, API 계약이 저장소 기술 선택에 종속됩니다.
public Order findOrder(long id) {
try {
return orderRepository.findById(id);
} catch (SQLException e) {
throw new OrderQueryException("주문 조회 중 데이터 접근 실패", e);
}
}- 상위 계층은 SQLException이라는 구현 세부를 몰라도 된다
- 원인 예외를 cause로 보존해서 디버깅 정보를 잃지 않는다
여기서 중요한 것은 무작정 wrapping하는 것이 아니라, 추상화 경계를 지키기 위해 변환하는 것입니다. 그리고 원인 예외는 꼭 보존해야 합니다.
throws는 계약이다
checked exception은 단순 구현 디테일이 아니라 API 계약의 일부가 됩니다. 그래서 public API에 checked exception을 추가하는 일은 가볍지 않습니다. 호출자 코드 전체에 try-catch나 throws 변경이 번질 수 있기 때문입니다.
checked를 둘 만한 경우
- 호출자가 실패를 알고 분기해야 한다
- 재시도, 대체 경로, 사용자 안내 등 선택지가 호출자 쪽에 있다
- 실패 의미가 비즈니스적으로 중요하다
unchecked가 더 나은 경우
- 잘못된 사용법을 빨리 드러내는 계약 위반이다
- 모든 호출자에게 선언을 강제해도 처리 전략이 거의 비슷하다
- 하위 구현 세부가 API 밖으로 새는 것을 막고 싶다
checked exception은 호출자에게 선택권을 주는 계약일 때 강하고, unchecked exception은 계약 위반이나 내부 정책 실패를 드러낼 때 더 자연스럽다고 보면 됩니다.
트랜잭션 경계
트랜잭션 안에서는 어설픈 catch가 특히 위험합니다. 중간 예외를 잡고 로그만 남긴 뒤 계속 진행하면, 호출자는 성공으로 오해할 수 있고 데이터는 부분 반영 상태가 될 수 있습니다.
public void placeOrder(OrderCommand command) {
try {
orderRepository.save(command.order());
paymentRepository.save(command.payment());
} catch (DataAccessException e) {
log.error("order save failed", e);
throw e;
}
}위 코드는 최소한 실패를 숨기지 않습니다. 반대로 로그만 찍고 예외를 삼켜 버리면 상위 계층은 주문이 성공한 줄 알 수 있습니다.
public void placeOrder(OrderCommand command) {
try {
orderRepository.save(command.order());
paymentRepository.save(command.payment());
} catch (DataAccessException e) {
log.warn("temporary error: {}", e.getMessage());
}
}- 롤백이 필요하면 예외를 숨기지 않는다
- 부분 성공을 허용할지 명확히 결정한다
- 보상 로직이 없으면 조용히 계속 진행하지 않는다
프레임워크마다 기본 롤백 규칙은 다를 수 있지만, 자바 일반 원칙만 놓고 봐도 처리할 수 없는 예외를 잡아서 성공처럼 보이게 만드는 것은 거의 항상 나쁜 신호입니다.
남용의 비용
- 의미 없는 throws 전파로 상위 계층이 인프라 예외 타입에 오염된다
- catch 후 로그만 찍고 계속 진행하면서 실패를 숨긴다
- 복구 불가능한 문제까지 checked로 강제해 빈약한 try-catch만 늘어난다
- SQLException, IOException 같은 기술 예외가 도메인 계층 밖까지 그대로 노출된다
try {
service.doSomething();
} catch (SomeCheckedException e) {
throw new RuntimeException(e);
}이런 코드가 프로젝트 곳곳에 반복된다면, checked exception이 실제 계약이 아니라 단순한 의식 절차가 되었을 가능성이 큽니다.
판단 순서
- 이 예외는 복구 가능한가
- 지금 계층이 그 복구를 결정할 수 있는가
- 호출자가 이 실패를 계약으로 알아야 하는가
- 하위 구현 세부를 그대로 노출해도 되는가
- 트랜잭션이나 상태 일관성에 어떤 영향이 있는가
빠른 결론은 보통 이렇게 정리됩니다. 여기서 복구 가능하다면 catch, 여기서 복구는 못 하지만 상위가 더 잘 판단하면 전파, 하위 기술 예외를 그대로 보여주면 경계가 새어 나오면 변환, 호출자에게 선언 강제를 해도 실익이 거의 없으면 unchecked를 검토하면 됩니다.
예외 계층 설계
도메인에서 자주 실패하는 규칙 위반은 별도 예외 타입으로 의미를 주는 편이 좋습니다.
public class InsufficientBalanceException extends RuntimeException {
public InsufficientBalanceException(String message) {
super(message);
}
}- 이름이 실패 의미를 드러내야 한다
- 메시지는 사람이 읽을 수 있어야 한다
- 원인 예외가 있으면 cause를 보존해야 한다
- 예외 타입이 계층 경계를 어지럽히지 않아야 한다
예외 설계는 문법이 아니라 커뮤니케이션 문제에 가깝습니다.
정리
자바 checked exception과 unchecked exception 차이는 단순히 컴파일러가 검사하느냐에서 끝나지 않습니다. 실전에서는 복구 가능성, API 계약, 추상화 경계, 트랜잭션 경계를 함께 봐야 제대로 판단할 수 있습니다.
다시 한 줄로 정리하면 이렇습니다. 처리할 수 있으면 catch, 상위가 더 잘 판단하면 전파, 구현 세부가 새면 적절한 예외로 변환, 복구보다 계약 위반에 가까우면 unchecked를 검토하면 됩니다.
예외 처리는 많이 잡는 사람이 잘하는 것이 아닙니다. 어디서 책임지고, 어디서는 일부러 잡지 않는지 구분하는 사람이 더 잘합니다.
관련해서 자바 예외 블록 흐름이 헷갈린다면 자바 final, finally, finalize 차이 글을 함께 보면 좋습니다. 문법 구분 감각을 더 다지고 싶다면 자바 ==와 equals 차이와 자바 오버로딩과 오버라이딩 차이도 이어서 읽어보면 도움이 됩니다.
공식 기준은 Oracle Exception API, RuntimeException API, Throwable API, JLS Chapter 11를 참고했습니다.