자바 Checked Exception vs Unchecked Exception: 언제 복구하고 언제 코드부터 고칠까
빠른 답
Checked Exception은 호출자가 재시도, 대체 경로, 사용자 안내 같은 대응을 선택할 수 있을 때 API 계약으로 드러내기 좋습니다.Unchecked Exception은 잘못된 값, 잘못된 호출 순서, 깨진 상태처럼 코드 쪽 수정이 필요한 문제를 표현하는 경우가 많습니다.- 둘 다 실제로는 실행 중에 발생합니다. 차이는 예외가 아니라 컴파일러가 처리 여부를 강제하느냐에 있습니다.
Error는 일반 애플리케이션 로직에서 복구 대상으로 보기보다, JVM이나 프로세스 수준의 심각한 문제로 보는 편이 가깝습니다.
목차
한눈에 비교
시간 흐름으로 이해하기
왜 둘 다 Exception인데 처리 방식은 다를까
자바는 예외를 단순한 오류 메시지가 아니라 메서드 계약의 일부로 다루려는 성향이 있습니다. 그래서 어떤 실패는 호출자에게 "이 가능성을 알고 결정해 달라"는 식으로 드러내고, 어떤 실패는 "호출 전제가 이미 깨져 있다"는 신호로 남겨 둡니다.
이 차이를 이해할 때 도움이 되는 축은 값, 상태, 외부 조건입니다.
IllegalArgumentException은 값이 잘못됐다는 뜻에 가깝습니다. 메서드가 기대한 범위나 형식에 맞지 않는 입력이 들어왔을 때 주로 보입니다. IllegalStateException은 값 자체보다 현재 객체나 시스템 상태가 그 호출을 받을 준비가 안 됐다는 뜻에 가깝습니다. 반면 IOException은 값도 상태도 맞았지만 파일, 네트워크, 디스크처럼 바깥 조건 때문에 작업이 실패했다는 뜻으로 읽는 편이 자연스럽습니다.
여기서 자주 생기는 오해가 하나 있습니다. Checked Exception을 흔히 "컴파일 타임 예외"라고 부르지만, 예외 자체가 컴파일 중에 발생하는 것은 아닙니다. 더 정확한 표현은 "컴파일 시점에 처리 여부를 검사받는 예외"입니다. 실제 throw는 checked와 unchecked 모두 런타임에 일어납니다.
Error와 Exception의 차이
Error는 Exception의 하위 타입이 아닙니다. 둘 다 Throwable 아래에 있지만 가지가 다릅니다. 이 차이는 단순한 상속 구조보다 복구 가능성의 관점에서 많이 설명됩니다.
OutOfMemoryError, StackOverflowError처럼 Error로 표현되는 문제는 프로세스 자체가 불안정해질 수 있는 상황과 자주 연결됩니다. 이런 경우 애플리케이션 코드가 정상 흐름 안에서 복구 로직을 세우기 어렵습니다. 그래서 일반 업무 코드에서 catch (Throwable)로 모두 잡아 처리하는 방식은 대개 피하는 편이 낫습니다. 예외와 오류를 한 바구니에 담으면, 값 오류와 JVM 고갈 상태가 같은 방식으로 보이기 때문입니다.
반대로 Exception은 애플리케이션이 의미를 붙여 다룰 여지가 있는 실패를 담는 경우가 많습니다. checked와 unchecked는 그 안에서 다시 "호출자가 이 실패를 미리 알고 대응해야 하는가"라는 언어 차원의 구분으로 나뉩니다.
실전 코드와 출력 예시로 보기
먼저 checked 예외는 호출부가 아무 처리도 하지 않으면 실행 전에 막힙니다. 아래 코드는 IOException 가능성을 메서드 시그니처에 드러냈지만, main에서 아무 처리도 하지 않았습니다.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class CheckedBroken {
static String readConfig(Path path) throws IOException {
return Files.readString(path);
}
public static void main(String[] args) {
String text = readConfig(Path.of("app.txt"));
System.out.println(text);
}
}
이 경우 관찰되는 것은 런타임 오류가 아니라 컴파일 오류입니다.
$ javac CheckedBroken.java
CheckedBroken.java:10: error: unreported exception IOException; must be caught or declared to be thrown
String text = readConfig(Path.of("app.txt"));
^
1 error
왜 이런 결과가 나오는지는 메서드 선언만 보면 바로 드러납니다. readConfig가 throws IOException을 갖고 있으므로, 호출자는 catch로 잡거나 자기 메서드도 throws로 올려야 합니다. 자바 컴파일러가 여기서 계약 누락을 검사합니다.
같은 메서드라도 호출부가 실패 시 행동을 정하면 이야기가 달라집니다. 파일을 읽지 못했을 때 기본 설정으로 계속 진행하겠다는 선택을 코드에 담을 수 있습니다.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class CheckedHandled {
static String readConfig(Path path) throws IOException {
return Files.readString(path);
}
public static void main(String[] args) {
try {
String text = readConfig(Path.of("app.txt"));
System.out.println(text);
} catch (IOException e) {
System.out.println("config read failed: " + e.getClass().getSimpleName());
System.out.println("use default profile: local");
}
}
}
/*
실행 결과
config read failed: NoSuchFileException
use default profile: local
*/
이 예시는 checked 예외가 "더 안전하다"는 뜻이라기보다, 호출자가 복구 방향을 코드에 적을 기회를 강제로 받는다는 뜻에 가깝습니다. 실패가 사라지는 것이 아니라, 실패를 어떻게 다룰지 선택 지점이 드러납니다.
반대로 unchecked 예외는 컴파일러가 호출부 처리 여부를 강제하지 않습니다. 컴파일은 통과하지만, 실행 중 전제조건이 깨지면 그때 예외가 터집니다.
public class UncheckedDemo {
static int divide(int total, int people) {
if (people < 1) {
throw new IllegalArgumentException("people must be positive: " + people);
}
return total / people;
}
public static void main(String[] args) {
System.out.println(divide(10, 0));
}
}
/*
실행 결과
Exception in thread "main" java.lang.IllegalArgumentException: people must be positive: 0
at UncheckedDemo.divide(UncheckedDemo.java:4)
at UncheckedDemo.main(UncheckedDemo.java:10)
*/
여기서 보이는 메시지는 "복구 가능한 외부 실패"보다 "호출 코드가 잘못된 값을 넘겼다"는 쪽에 더 가깝습니다. 컴파일러가 막지 않는 이유도 비슷한 맥락으로 이해할 수 있습니다. 이 문제를 해결하려면 catch를 추가하는 것보다, divide(10, 0) 같은 호출을 만들지 않도록 코드를 고치는 쪽이 먼저입니다.
언제 무엇을 선택할까
예외 타입을 정할 때는 발생 원인을 분류하는 것보다, 호출자가 그 실패를 받아서 무엇을 바꿀 수 있는지 먼저 보는 편이 덜 헷갈립니다.
- 재시도 여부가 달라진다면 checked를 고려할 수 있습니다.
- 다른 파일, 다른 서버, 다른 구현체로 대체할 수 있다면 checked가 잘 맞는 경우가 많습니다.
- 잘못된 인자, 잘못된 호출 순서, 불변식 위반이라면 unchecked 쪽이 더 읽기 쉽습니다.
- 이미 상위 계층에서 동일한 방식으로만 실패를 처리한다면 checked를 그대로 길게 끌고 가기보다 경계에서 변환하는 선택도 가능합니다.
- JVM 자원 고갈처럼 애플리케이션이 의미 있게 복구하기 어려운 문제는
Error로 보는 편이 맞습니다.
예를 들어 파일 읽기 실패는 호출자가 기본 설정을 쓸지, 경로를 다시 받을지, 프로그램을 종료할지 선택할 수 있으니 checked로 드러낼 이유가 있습니다. 반면 null을 넣으면 안 되는 메서드에 null이 들어온 경우는 호출부를 바로잡는 것이 핵심이라 NullPointerException이나 IllegalArgumentException 같은 unchecked 예외가 더 어울립니다.
다만 "외부 자원 실패는 항상 checked"라고 외우면 중간 계층에서 코드가 불필요하게 번거로워질 수 있습니다. 예를 들어 가장 바깥 경계에서는 결국 실패 응답 하나로 바뀔 뿐이라면, 하위 계층의 checked 예외를 도메인 의미가 있는 runtime 예외로 변환하는 쪽이 읽기 쉬운 경우도 있습니다.
커스텀 예외는 어디에 둘까
커스텀 예외를 만들 때도 기준은 같습니다. 이름을 늘리는 것보다, 그 예외를 받은 호출자가 어떤 행동을 실제로 달리하게 되는지가 더 중요합니다.
아래처럼 하위 계층에서는 외부 시스템 실패를 checked로 드러내고, 그 위 서비스 계층에서는 애플리케이션 관점의 runtime 예외로 바꾸는 구성이 자주 보입니다.
import java.io.IOException;
class PartnerUnavailableException extends Exception {
public PartnerUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
class ProfileLoadException extends RuntimeException {
public ProfileLoadException(String message, Throwable cause) {
super(message, cause);
}
}
class PartnerClient {
String fetch(String memberId) throws PartnerUnavailableException {
try {
return callPartner(memberId);
} catch (IOException e) {
throw new PartnerUnavailableException("partner api timeout", e);
}
}
private String callPartner(String memberId) throws IOException {
throw new IOException("read timed out");
}
}
class MemberService {
private final PartnerClient client = new PartnerClient();
String load(String memberId) {
try {
return client.fetch(memberId);
} catch (PartnerUnavailableException e) {
throw new ProfileLoadException("member profile load failed", e);
}
}
}
이런 구성이 도움이 되는 이유는 계층마다 관심사가 다르기 때문입니다. PartnerClient를 직접 쓰는 쪽은 재시도나 다른 파트너 선택을 고민할 수 있지만, MemberService 바깥에서는 이미 그런 선택지가 사라졌을 수 있습니다. 그 경우에는 도메인 이름이 붙은 runtime 예외가 더 읽기 쉬울 수 있습니다.
이때 중요한 점은 원인 예외를 버리지 않는 것입니다. cause를 연결하지 않으면, 나중에 로그를 봤을 때 실제 실패 지점이 끊겨 버립니다.
자주 틀리는 지점
checked와 unchecked를 단순히 "좋은 예외"와 "나쁜 예외"로 나누면 오히려 판단이 흐려집니다. 몇 가지 오해는 미리 분리해 두는 편이 좋습니다.
- checked면 더 안전하다는 오해: 컴파일러가 처리 여부를 강제할 뿐, 복구 전략이 자동으로 좋아지는 것은 아닙니다.
catch만 해 두고 아무 의미 있는 처리도 하지 않으면 오히려 장애 신호가 묻힐 수 있습니다. - unchecked는 절대 잡지 않는다는 오해: 최상위 경계에서는 로깅, 종료 코드 변환, 사용자 메시지 정리를 위해 unchecked 예외를 잡는 경우가 있습니다. 다만 그 지점은 보통 "복구"보다 "정리"에 가깝습니다.
throws Exception이면 충분하다는 오해: 너무 넓은 선언은 호출자에게 필요한 정보를 주지 못합니다. 실패 유형이 무엇인지, 어떤 대응이 가능한지 계약이 흐려집니다.catch (Exception)이 편하다는 오해:IllegalArgumentException,InterruptedException,IOException처럼 성격이 다른 문제를 한데 모아 버리면 값 오류와 외부 실패가 같은 톤으로 처리됩니다.catch (Throwable)도 괜찮다는 오해: 이 경우Error까지 잡게 됩니다. 일반 애플리케이션 코드에서는 범위를 너무 넓게 잡는 편입니다.
특히 InterruptedException은 checked 예외지만 성격이 조금 다르게 느껴질 수 있습니다. 외부 실패라기보다 "이 작업을 멈추라"는 스레드 제어 신호에 가깝기 때문입니다. 이런 예외를 잡고 무시하면 상위 코드가 의도한 취소 흐름이 사라질 수 있습니다. checked와 unchecked 구분만 외우기보다, 그 예외가 값 오류인지, 상태 문제인지, 외부 조건인지, 제어 신호인지 함께 보는 편이 도움이 됩니다.
원문 참고
https://www.maeil-mail.kr/question/50
댓글
댓글 쓰기