예외 계층과 검사 예외
Throwable 아래 Error, Exception, RuntimeException이 이루는 예외 계층을 익히고, 처리하지 않으면 컴파일되지 않는 검사 예외를 catch로 잡을지 throws로 넘길지 정하는 방법을 배웁니다.
앞 글의 마지막 예제에서 try/catch를 지우자 unreported exception NetworkException이라는 컴파일 오류가 났습니다. 반환 코드는 무시해도 컴파일되었는데, 이 예외는 처리하지 않으면 프로그램을 실행해 볼 수조차 없었습니다. 그렇다고 모든 예외가 그런 것은 아닙니다. 3장에서 본 NumberFormatException은 try/catch 없이도 컴파일되었고, 실행 중에 발생해서 프로그램을 멈췄습니다.
이 차이는 예외 클래스가 어느 클래스를 상속했는지에서 나옵니다. 자바의 예외는 메시지와 원인을 담은 평범한 객체이고, 예외 클래스들은 하나의 상속 계층을 이룹니다. 이 글에서는 그 계층을 살펴보고, 처리하지 않으면 컴파일되지 않는 검사 예외를 그 자리에서 잡을지(catch) 호출한 쪽으로 넘길지(throws) 어떻게 정하는지 배웁니다.
처리하지 않으면 컴파일되지 않는 예외
게시글 저장소에서 글을 읽어 오는 코드를 생각해 봅시다. 다음 프로그램의 PostRepository.load()는 글을 찾지 못하면 BoardLoadException을 던집니다. 이 예외 클래스는 Exception을 직접 상속했습니다. load()의 선언 끝에 붙은 throws BoardLoadException은 이 메서드가 그 예외를 던질 수 있다는 표시입니다. 그런데 main은 load()를 호출하면서 아무 처리도 하지 않았습니다.
public final class UnhandledCheckedBoardException {
public static void main(String[] args) {
PostRepository repository = new PostRepository();
System.out.println(repository.load("missing"));
}
private static final class PostRepository {
String load(String id) throws BoardLoadException {
throw new BoardLoadException("not found: " + id);
}
}
private static final class BoardLoadException extends Exception {
BoardLoadException(String message) { super(message); }
}
}UnhandledCheckedBoardException.java:4: error: unreported exception BoardLoadException; must be caught or declared to be thrown
System.out.println(repository.load("missing"));
^
1 error이 오류는 프로그램을 실행하다 생긴 것이 아니라 컴파일 단계에서 난 것입니다. 메시지를 그대로 옮기면 '보고되지 않은 예외 BoardLoadException이 있다. 잡거나(caught), 던진다고 선언해야(declared to be thrown) 한다'는 뜻입니다. load()가 이 예외를 던질 수 있다고 선언했으니, 그것을 호출하는 main도 둘 중 하나를 골라야 합니다.
첫 번째 방법은 try/catch로 잡아서 그 자리에서 처리하는 것입니다. main의 출력 줄을 다음과 같이 감싸면 컴파일되고, 실행하면 load-failed=not found: missing이 출력됩니다.
try {
System.out.println(repository.load("missing"));
} catch (BoardLoadException error) {
System.out.println("load-failed=" + error.getMessage());
}두 번째 방법은 main의 선언에 throws BoardLoadException을 붙여 호출한 쪽으로 넘기는 것입니다. 이것도 컴파일은 됩니다. 하지만 main을 호출하는 것은 JVM이므로 예외는 결국 아무도 잡지 않은 채 프로그램 밖으로 나가고, 프로그램은 스택 트레이스를 출력하며 끝납니다.
컴파일러가 정해 주는 것은 둘 중 하나를 고르라는 것까지입니다. 어떻게 복구할지는 정해 주지 않습니다. 다만 '이 실패를 누가 처리할 것인가'를 정하지 않은 채 넘어가는 것만은 막습니다. 이렇게 컴파일러가 처리 여부를 검사하는 예외를 검사 예외(checked exception) 또는 체크 예외라고 합니다.
Throwable에서 시작하는 예외 계층
그렇다면 어떤 예외가 검사 예외일까요? 답은 클래스 계층에 있습니다. 자바에서 throw로 던지고 catch로 잡을 수 있는 객체는 모두 java.lang.Throwable이나 그 하위 클래스의 객체이고, 그 아래는 다음과 같이 나뉩니다. 오른쪽 괄호 안은 이 교재에서 이미 만났거나 곧 만날 예입니다.
Throwable
├── Error (StackOverflowError, OutOfMemoryError)
└── Exception (IOException, InterruptedException)
└── RuntimeException (NullPointerException, ArithmeticException)
└── IllegalArgumentException
└── NumberFormatExceptionError는 메모리 부족(OutOfMemoryError)이나 호출 스택 넘침(StackOverflowError)처럼, 일반적인 애플리케이션이라면 잡으려 하지 말아야 할 심각한 문제를 나타냅니다. 이런 문제는 대개 애플리케이션 코드가 복구할 수 없으므로, 업무 코드에서 잡아 놓고 계속 진행하는 대상이 아닙니다. 애플리케이션이 다루는 실패는 Exception 쪽입니다. Exception 아래에는 RuntimeException이라는 하위 계층이 따로 있는데, 지금까지 만난 NullPointerException, ArithmeticException, NumberFormatException이 모두 여기에 속합니다.
검사 예외인지는 이 계층에서의 위치로 정해집니다. RuntimeException과 그 하위 클래스, 그리고 Error와 그 하위 클래스는 비검사 예외(unchecked exception) 또는 언체크 예외라고 부르며, 컴파일러가 catch나 throws를 요구하지 않습니다. 나머지, 즉 Throwable 자신과 Exception, 그리고 RuntimeException이나 Error 계열에 속하지 않는 모든 하위 클래스는 검사 예외입니다. Java 언어 명세 JLS 11.1.1이 이렇게 정의합니다. BoardLoadException은 Exception을 직접 상속했으므로 검사 예외이고, 그래서 앞의 컴파일 오류가 났습니다.
흔히 '비검사 예외 = RuntimeException'으로 외우지만 Error도 비검사라는 점을 기억해 두세요. 또 검사와 비검사는 어느 쪽이 더 심각한지를 나타내는 서열이 아닙니다. 컴파일러가 처리 여부를 검사하느냐 마느냐의 차이일 뿐입니다.
다음 프로그램은 예외 객체가 RuntimeException의 하위 타입인지를 instanceof로 확인해, 검사 예외인지 출력합니다. inspect의 매개변수 타입이 Exception이므로 Error는 넘길 수 없고, 넘긴 객체가 RuntimeException이 아니면 검사 예외입니다.
public final class ExceptionHierarchyObservation {
public static void main(String[] args) {
inspect(new BoardConnectionException("offline"));
inspect(new IllegalArgumentException("viewCount"));
}
private static void inspect(Exception exception) {
System.out.println(exception.getClass().getSimpleName()
+ ":checked=" + !(exception instanceof RuntimeException));
}
private static final class BoardConnectionException extends Exception {
BoardConnectionException(String message) { super(message); }
}
}BoardConnectionException:checked=true
IllegalArgumentException:checked=falseBoardConnectionException은 Exception을 직접 상속했으므로 RuntimeException이 아니고, 첫 줄은 checked=true입니다. IllegalArgumentException은 RuntimeException의 하위 클래스이므로 두 번째 줄은 checked=false입니다. 같은 방법으로 new IOException("disk")를 넘기면 checked=true, new NumberFormatException("forty")를 넘기면 checked=false가 나옵니다.
분류는 오직 상속 관계로만 정해집니다. 클래스 이름에 Runtime을 넣거나 문서에 '치명적'이라고 적는다고 바뀌지 않습니다. BoardLoadException의 부모를 Exception에서 RuntimeException으로 바꾸면, 앞의 프로그램은 try/catch 없이도 컴파일되고 실행 중에 예외로 끝납니다. 그러니 직접 예외 클래스를 만들 때 부모를 고르는 일은, 그 예외를 호출하는 쪽에 처리를 강제할지 말지를 정하는 일이기도 합니다.
더 깊이: 왜 RuntimeException과 Error는 검사하지 않을까
Java 언어 명세 JLS 11.2는 두 종류를 검사에서 뺀 이유를 따로 설명합니다. Error는 프로그램의 거의 모든 지점에서 일어날 수 있고 복구하기가 어렵거나 불가능하므로, 이를 모두 선언하게 하면 코드만 어지러워진다는 것입니다. RuntimeException은 배열 접근이나 나눗셈, 참조 사용처럼 아주 많은 연산에서 생길 수 있습니다. 그런데 컴파일러가 가진 정보로는 그런 예외가 절대 일어나지 않는다는 것을 증명하기 어려우므로, 선언을 강제해도 프로그램의 정확성에 별 도움이 되지 않고 번거롭기만 하다고 판단한 것입니다.
오라클의 자바 튜토리얼은 이 판단을 직접 예외를 만드는 사람을 위한 기준으로 정리합니다. 호출자가 그 예외에서 복구하리라고 합리적으로 기대할 수 있으면 검사 예외로, 복구를 위해 할 수 있는 일이 없으면 비검사 예외로 만들라는 것입니다(Unchecked Exceptions — The Controversy). 이 기준은 다음 글에서 비검사 예외를 다루며 다시 살펴봅니다.
catch로 잡아서 대안 값으로 복구하기
검사 예외를 만난 메서드가 고를 수 있는 첫 번째 길은 직접 잡아서 처리하는 것입니다. 이 길은 지금 메서드가 실패를 대신할 의미 있는 대안을 가지고 있을 때 맞습니다. 예를 들어 게시글 조회 수를 저장소에서 읽지 못했을 때, 화면에 0을 보여 줘도 괜찮다고 해 봅시다.
다음 프로그램의 PostRepository는 조회 수를 읽는 인터페이스이고, 그 구현인 FailingRepository는 항상 BoardReadException을 던집니다. BoardService.viewCountFor()는 이 예외를 잡아서 안내 메시지를 출력하고 0을 돌려줍니다.
public final class CheckedExceptionRecovery {
public static void main(String[] args) {
BoardService service = new BoardService(new FailingRepository());
System.out.println("viewCount=" + service.viewCountFor("java"));
}
private static final class BoardService {
private final PostRepository repository;
BoardService(PostRepository repository) { this.repository = repository; }
int viewCountFor(String title) {
try {
return repository.loadViewCount(title);
} catch (BoardReadException error) {
System.out.println("fallback=" + error.getMessage());
return 0;
}
}
}
private interface PostRepository {
int loadViewCount(String title) throws BoardReadException;
}
private static final class FailingRepository implements PostRepository {
public int loadViewCount(String title) throws BoardReadException {
throw new BoardReadException("store unavailable: " + title);
}
}
private static final class BoardReadException extends Exception {
BoardReadException(String message) { super(message); }
}
}fallback=store unavailable: java
viewCount=0repository.loadViewCount(title)가 예외를 던지므로 try 블록의 return은 완료되지 못하고, 실행은 catch 블록으로 넘어갑니다. catch 블록은 fallback=store unavailable: java를 출력한 뒤 return 0으로 viewCountFor를 끝냅니다. main은 예외가 있었다는 사실을 모른 채 돌려받은 0으로 viewCount=0을 출력합니다. viewCountFor에는 throws가 없습니다. 검사 예외를 메서드 안에서 모두 처리했으므로 호출한 쪽에 알릴 것이 없기 때문입니다.
인터페이스 메서드의 throws도 눈여겨볼 만합니다. PostRepository.loadViewCount()가 throws BoardReadException을 선언했기 때문에, 구현 클래스도 이 예외를 던질 수 있습니다. 반대로 구현 쪽에서 더 넓은 throws Exception을 선언하면 overridden method does not throw Exception이라는 컴파일 오류가 납니다. 인터페이스를 보고 BoardReadException만 대비한 호출자가 다른 검사 예외를 받는 일이 없도록, 재정의하는 메서드는 원래 메서드의 throws가 허용하지 않는 검사 예외를 선언할 수 없습니다. 같은 예외나 그 하위 예외를 선언하거나, 아예 선언하지 않는 것은 괜찮습니다.
이 복구 방식에서 가장 중요한 질문은 0이 정말 안전한 대안인가입니다. 저장소가 고장 났다는 사실과 아직 아무도 글을 읽지 않았다는 사실이 모두 0으로 나타나면, 화면을 보는 사람은 둘을 구별할 수 없습니다. 그 0이 다른 계산에 쓰이기라도 하면 오류 메시지 하나 없이 틀린 데이터가 퍼집니다. 대안 값이 의미를 바꿔 버린다면 이 자리에서 복구하지 말고, 상황을 판단할 수 있는 위쪽 계층으로 실패를 넘겨야 합니다.
조회 수 대신 '조회에 성공했는가'와 '조회 수'를 함께 돌려주는 방법도 있습니다. 다음 프로그램은 별도의 예제로, 실제 조회 수가 0인 경우(empty)와 저장소에 접근하지 못한 경우(offline)를 레코드 Lookup으로 구별합니다.
public final class CheckedLookupResult {
public static void main(String[] args) {
print("empty");
print("offline");
}
private static void print(String scenario) {
Lookup result = countFor(scenario);
System.out.println(scenario + "=" + (result.available()
? "count=" + result.count() : "unavailable"));
}
private static Lookup countFor(String scenario) {
try {
if (scenario.equals("offline")) {
throw new BoardReadException("store unavailable");
}
return new Lookup(true, 0);
} catch (BoardReadException error) {
return new Lookup(false, 0);
}
}
private record Lookup(boolean available, int count) { }
private static final class BoardReadException extends Exception {
BoardReadException(String message) { super(message); }
}
}empty=count=0
offline=unavailableempty에서는 예외가 나지 않으므로 new Lookup(true, 0)이 돌아오고, print는 available()이 참인 것을 보고 count=0을 출력합니다. offline에서는 try 블록 안에서 던진 예외를 바로 아래의 catch가 잡아 new Lookup(false, 0)을 돌려주고, print는 조회 수를 읽지 않고 unavailable을 출력합니다. 실패했을 때 count에 넣은 0은 자리를 채우는 값일 뿐이므로, 호출자는 항상 available()부터 확인해야 합니다. 앞 글에서 본 것처럼, 예상할 수 있는 결과를 값으로 표현하는 방법입니다.
throws로 넘기고 원인을 연결하기
두 번째 길은 예외를 호출한 쪽으로 넘기는 것입니다. 지금 메서드가 실패를 대신할 대안을 모른다면 억지로 잡지 말고 위로 보내야 합니다. 그대로 넘기려면 메서드 선언에 throws를 붙이면 됩니다. 그런데 넘기기 전에 상황 정보를 덧붙이고 싶을 때도 있습니다.
여기서 두 키워드를 구별해 둡시다. throw는 예외 객체를 실제로 던지는 문장이고, throws는 메서드가 바깥으로 던질 수 있는 예외 타입을 선언하는 부분입니다. 다음 프로그램의 persist()는 조회 수가 음수이면 throw로 IllegalArgumentException을 던집니다. save()는 그 예외를 잡아 '어떤 글을 저장하다 실패했는지'를 담은 BoardWriteException으로 바꾸어 다시 던지고, 선언에 throws BoardWriteException을 적어 호출한 쪽에 알립니다. 콘솔 프로그램의 입구인 main은 그 예외를 잡아 사용자에게 보여 줄 안내를 출력합니다.
public final class CheckedExceptionPropagation {
public static void main(String[] args) {
BoardService service = new BoardService();
try {
service.save("exception", -10);
} catch (BoardWriteException error) {
System.out.println("retry-guide=" + error.getMessage());
System.out.println("cause=" + error.getCause().getClass().getSimpleName());
}
}
private static final class BoardService {
void save(String title, int viewCount) throws BoardWriteException {
try {
persist(title, viewCount);
} catch (IllegalArgumentException cause) {
throw new BoardWriteException("could not save " + title, cause);
}
}
private void persist(String title, int viewCount) {
if (viewCount < 0) throw new IllegalArgumentException("negative viewCount");
}
}
private static final class BoardWriteException extends Exception {
BoardWriteException(String message, Throwable cause) { super(message, cause); }
}
}retry-guide=could not save exception
cause=IllegalArgumentExceptionsave("exception", -10)가 호출되면 persist()가 IllegalArgumentException을 던지고, save()의 catch가 그것을 받아 cause라는 이름으로 가리킵니다. catch 블록은 새 BoardWriteException을 만들면서 메시지와 함께 cause를 생성자에 넘깁니다. BoardWriteException의 생성자는 받은 두 값을 super(message, cause)로 Exception의 생성자에 그대로 전달하므로, 새 예외 객체는 원래 예외를 원인(cause)으로 기억합니다. main의 catch에서 getMessage()는 새로 붙인 메시지 could not save exception을, getCause()는 원래의 IllegalArgumentException 객체를 돌려줍니다. 출력의 두 줄이 바로 그 두 값입니다.
원인을 연결해 두면 실패를 끝까지 추적할 수 있습니다. main의 try/catch를 지우고 service.save("exception", -10); 한 줄만 남긴 뒤 main에 throws BoardWriteException을 붙이면, 프로그램이 끝나면서 출력하는 스택 트레이스에 원인이 함께 나옵니다. 줄 번호는 그렇게 고친 파일을 기준으로 합니다.
Exception in thread "main" CheckedExceptionPropagation$BoardWriteException: could not save exception
at CheckedExceptionPropagation$BoardService.save(CheckedExceptionPropagation.java:12)
at CheckedExceptionPropagation.main(CheckedExceptionPropagation.java:4)
Caused by: java.lang.IllegalArgumentException: negative viewCount
at CheckedExceptionPropagation$BoardService.persist(CheckedExceptionPropagation.java:17)
at CheckedExceptionPropagation$BoardService.save(CheckedExceptionPropagation.java:10)
... 1 moreCaused by: 아래에 원래 예외의 타입과 메시지, 그리고 그 예외가 처음 발생한 persist()의 위치가 남아 있습니다. 마지막의 ... 1 more는 남은 한 줄이 바깥 예외의 스택 트레이스 끝부분과 같아서 생략했다는 뜻입니다. 여기서 생략된 줄은 save()를 호출한 main의 위치입니다. 만약 원인을 넘기지 않고 "could not save " + title + ": " + cause.getMessage()처럼 메시지 문자열만 이어 붙였다면, 원래 예외의 타입과 발생 위치는 사라집니다.
이렇게 낮은 수준의 예외를 잡아 호출하는 쪽이 이해하기 쉬운 예외로 바꾸어 던지는 것을 예외 변환(exception translation)이라고 합니다. 예외 변환은 다음 계층에 의미 있는 정보를 줄 때 쓰는 것이지, 단지 throws를 쓰기 싫어서 모든 예외를 다른 예외로 감싸는 수단이 아닙니다. 예외를 바꾸는 기준은 이 장의 다섯 번째 글에서 더 자세히 다룹니다.
아래 그림은 앞의 두 프로그램을 나란히 놓고, catch에서 값을 돌려주는 복구와 새 예외로 바꿔 던지는 변환이 호출한 쪽에 각각 무엇을 남기는지 비교합니다.
catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환
별도 복구 예제는 catch에서 0 반환, 변환 예제는 cause를 보존해 새 예외를 전달합니다. 각 독립 실행의 실패→처리→호출자 결과를 단계로 대조하며 반환과 예외 전달의 차이를 확인할 수 있습니다.
CheckedExceptionRecovery는 BoardReadException을 잡고 0을 반환한다. 별도 CheckedExceptionPropagation은 IllegalArgumentException을 원인으로 가진 BoardWriteException을 던져 main이 잡는다.
catch 책임 · 반환과 변환
catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환
복구와 변환은 별도 프로그램입니다. 한 서비스는 0을 반환하고, 다른 서비스는 원인을 연결한 체크 예외를 던집니다.
기본값으로 복구하는 원문
- 실패 발생
loadViewCount가BoardReadException을 던집니다.메시지: store unavailable: java- 서비스의 catch
- 실패 메시지를 출력하고
return 0으로 메서드에서 반환합니다. - main이 관찰
fallback=store unavailable: java viewCount=0
새 문맥으로 변환하는 원문
- 실패 발생
persist가 음수-10을 거부해IllegalArgumentException을 던집니다.- 서비스의 catch
BoardWriteException을 새로 던집니다.기존 예외는cause로 연결합니다.- main이 관찰
retry-guide=could not save exception cause=IllegalArgumentException
복구 원문은 catch 블록 뒤의 문장으로 진행하는 것이 아니라 호출자에게 0을 돌려줍니다. 변환 원문의 throws BoardWriteException은 전달 가능한 타입의 선언이며, 실제 새 예외는 throw가 발생시킵니다.
이 예의 조건
- 복구와 변환은 독립 프로그램이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환 catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환 CheckedExceptionRecovery는 BoardReadException을 잡고 0을 반환한다. 별도 CheckedExceptionPropagation은 IllegalArgumentException을 원인으로 가진 BoardWriteException을 던져 main이 잡는다. catch 책임 · 반환과 변환 catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환 복구와 변환은 별도 프로그램입니다. 한 서비스는 0을 반환하고, 다른 서비스는 원인을 연결한 체크 예외를 던집니다. 기본값으로 복구하는 원문 실패 발생 loadViewCount 가 BoardReadException 을 던집니다. 메시지: store unavailable: java 서비스의 catch 실패 메시지를 출력하고 return 0 으로 메서드에서 반환합니다. main이 관찰 fallback=store unavailable: java viewCount=0 새 문맥으로 변환하는 원문 실패 발생 persist 가 음수 -10 을 거부해 IllegalArgumentException 을 던집니다. 서비스의 catch BoardWriteException 을 새로 던집니다. 기존 예외는 cause 로 연결합니다. main이 관찰 retry-guide=could not save exception cause=IllegalArgumentException 복구 원문은 catch 블록 뒤의 문장으로 진행하는 것이 아니라 호출자에게 0 을 돌려줍니다. 변환 원문의 throws BoardWriteException 은 전달 가능한 타입의 선언이며, 실제 새 예외는 throw 가 발생시킵니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원래 실패 읽기와 음수 저장이 각기 실패합니다. |
|
|
| 서비스 처리 읽기는 0으로 복구하고 저장은 원인을 새 예외에 보존합니다. |
|
|
| 호출자 결과 main이 값을 읽거나 경계 catch로 안내합니다. |
|
|
catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환
별도 복구 예제는 catch에서 0 반환, 변환 예제는 cause를 보존해 새 예외를 전달합니다. 각 독립 실행의 실패→처리→호출자 결과를 단계로 대조하며 반환과 예외 전달의 차이를 확인할 수 있습니다.
CheckedExceptionRecovery는 BoardReadException을 잡고 0을 반환한다. 별도 CheckedExceptionPropagation은 IllegalArgumentException을 원인으로 가진 BoardWriteException을 던져 main이 잡는다.
catch 책임 · 반환과 변환
catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환
복구와 변환은 별도 프로그램입니다. 한 서비스는 0을 반환하고, 다른 서비스는 원인을 연결한 체크 예외를 던집니다.
기본값으로 복구하는 원문
- 실패 발생
loadViewCount가BoardReadException을 던집니다.메시지: store unavailable: java- 서비스의 catch
- 실패 메시지를 출력하고
return 0으로 메서드에서 반환합니다. - main이 관찰
fallback=store unavailable: java viewCount=0
새 문맥으로 변환하는 원문
- 실패 발생
persist가 음수-10을 거부해IllegalArgumentException을 던집니다.- 서비스의 catch
BoardWriteException을 새로 던집니다.기존 예외는cause로 연결합니다.- main이 관찰
retry-guide=could not save exception cause=IllegalArgumentException
복구 원문은 catch 블록 뒤의 문장으로 진행하는 것이 아니라 호출자에게 0을 돌려줍니다. 변환 원문의 throws BoardWriteException은 전달 가능한 타입의 선언이며, 실제 새 예외는 throw가 발생시킵니다.
이 예의 조건
- 복구와 변환은 독립 프로그램이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환 catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환 CheckedExceptionRecovery는 BoardReadException을 잡고 0을 반환한다. 별도 CheckedExceptionPropagation은 IllegalArgumentException을 원인으로 가진 BoardWriteException을 던져 main이 잡는다. catch 책임 · 반환과 변환 catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환 복구와 변환은 별도 프로그램입니다. 한 서비스는 0을 반환하고, 다른 서비스는 원인을 연결한 체크 예외를 던집니다. 기본값으로 복구하는 원문 실패 발생 loadViewCount 가 BoardReadException 을 던집니다. 메시지: store unavailable: java 서비스의 catch 실패 메시지를 출력하고 return 0 으로 메서드에서 반환합니다. main이 관찰 fallback=store unavailable: java viewCount=0 새 문맥으로 변환하는 원문 실패 발생 persist 가 음수 -10 을 거부해 IllegalArgumentException 을 던집니다. 서비스의 catch BoardWriteException 을 새로 던집니다. 기존 예외는 cause 로 연결합니다. main이 관찰 retry-guide=could not save exception cause=IllegalArgumentException 복구 원문은 catch 블록 뒤의 문장으로 진행하는 것이 아니라 호출자에게 0 을 돌려줍니다. 변환 원문의 throws BoardWriteException 은 전달 가능한 타입의 선언이며, 실제 새 예외는 throw 가 발생시킵니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원래 실패 읽기와 음수 저장이 각기 실패합니다. |
|
|
| 서비스 처리 읽기는 0으로 복구하고 저장은 원인을 새 예외에 보존합니다. |
|
|
| 호출자 결과 main이 값을 읽거나 경계 catch로 안내합니다. |
|
|
catch와 throws 중 무엇을 고를까
판단의 출발점은 하나입니다. 지금 메서드가 실패를 대신할 의미 있는 결과를 실제로 만들 수 있는가를 묻는 것입니다. 재시도 횟수와 대기 시간을 알고 있어서 다시 시도해 볼 수 있다면 catch가 맞습니다. 사용자가 고칠 수 있는 입력 오류로 바꿔 안내할 수 있거나, 추천 목록 같은 부가 기능만 포기하고 핵심 작업은 계속할 수 있을 때도 마찬가지입니다. 그런 대안이 없다면 throws로 넘겨서, 판단할 수 있는 계층이 처리하게 합니다.
잡기로 했다면 몇 가지 규칙을 알아 두어야 합니다. 먼저 catch 블록이 여러 개이면 예외가 발생했을 때 위에서부터 차례로 타입을 비교해 처음 맞는 블록 하나만 실행합니다. 부모 타입은 자식 타입의 예외도 받으므로, 구체적인 자식 타입을 먼저 쓰고 넓은 부모 타입을 나중에 써야 합니다. 부모를 먼저 쓰면 뒤의 자식 catch에는 결코 도달할 수 없으므로 컴파일러가 막습니다.
public final class CatchOrder {
public static void main(String[] args) {
try {
Integer.parseInt("x");
} catch (Exception error) {
System.out.println("exception");
} catch (NumberFormatException error) {
System.out.println("number");
}
}
}CatchOrder.java:7: error: exception NumberFormatException has already been caught
} catch (NumberFormatException error) {
^
1 error처리 방법이 완전히 같은 예외가 여러 개라면 catch (FileStoreException | CloudStoreException error)처럼 |로 묶는 다중 catch(multi-catch)를 쓸 수 있습니다. 다만 묶는 타입끼리 부모와 자식 관계이면 안 됩니다. 부모 하나만 써도 자식까지 받기 때문에, NumberFormatException | IllegalArgumentException처럼 쓰면 Alternatives in a multi-catch statement cannot be related by subclassing이라는 컴파일 오류가 납니다.
넓은 타입으로 잡는 것은 그만큼 많은 실패를 한꺼번에 가립니다. catch (Exception error)는 NullPointerException 같은 프로그래밍 실수까지 받아 버리므로, 미처 생각하지 못한 버그가 조용히 묻힐 수 있습니다. Error까지 받는 catch (Throwable error)는 프로그램 전체의 마지막 안전망이나 프레임워크 내부처럼 아주 제한된 곳이 아니라면 쓰지 않습니다. 또 아무것도 하지 않고 로그 한 줄만 남긴 뒤 같은 예외를 다시 던지는 catch는 여러 계층에 같은 기록을 반복해서 남기기 쉬우니, 실제로 덧붙일 정보가 있을 때만 씁니다.
넘기기로 했다면 throws에 무엇을 적을지 정해야 합니다. throws에는 호출자가 실제로 알아야 하고 쉽게 바뀌지 않는 예외를 적습니다. 저장소 구현에서 생긴 세부 예외를 공개 메서드의 throws에 그대로 노출하면, 나중에 저장소를 파일에서 데이터베이스로 바꿀 때 호출하는 코드까지 함께 고쳐야 합니다. 앞에서 본 예외 변환이 이럴 때 쓰입니다. 반대로 throws Exception처럼 지나치게 넓게 선언하면 호출자는 어떤 실패에 대비해야 하는지 알 수 없습니다. 나중에 새로운 검사 예외가 추가되어도 이미 선언한 범위에 포함되어 아무 표시가 나지 않습니다. 넓게 선언한다고 호출자가 catch나 throws를 써야 하는 의무가 사라지는 것도 아닙니다.
정리
- 던지고 잡을 수 있는 객체는 모두
Throwable이나 그 하위 클래스의 객체입니다.Throwable아래에Error와Exception이,Exception아래에RuntimeException이 있습니다. RuntimeException과Error계열은 비검사 예외, 나머지는 검사 예외입니다. 검사 예외를 던질 수 있는 메서드를 호출하면catch로 잡거나throws로 선언해야 컴파일됩니다.- 검사 여부는 상속 관계로만 정해집니다. 직접 만든 예외의 부모를 고르는 일은 호출자에게 처리를 강제할지 정하는 일입니다.
- 지금 메서드가 의미 있는 대안을 만들 수 있으면
catch로 복구하고, 없으면throws로 넘깁니다. 대안 값이 의미를 바꾸지 않는지 꼭 확인해야 합니다. - 예외를 바꿔 던질 때는 원래 예외를 원인(cause)으로 넘겨야 타입과 발생 위치가
Caused by:로 남습니다. catch는 구체적인 타입부터 쓰고,throws에는 호출자가 알아야 할 예외를 구체적으로 적습니다.
연습 문제
BoardAccessException아래에BoardNotFoundException과BoardPermissionException을 만들고, 서비스의open(id)는 부모 타입만throws로 선언하세요. 호출하는 쪽에서는 두 예외를 구별해 서로 다른 안내를 출력합니다.
부모 BoardAccessException은 Exception을 상속하는 검사 예외입니다. open(id)의 id는 null이 아니며, missing이면 BoardNotFoundException을, private이면 BoardPermissionException을 던지고, 그 밖의 id는 opened=id를 출력합니다. 두 자식 예외는 id를 보관하고 id() 메서드로 돌려줍니다.
호출하는 쪽의 안내는 각각 create-new=id와 request-access=id 형식입니다. 마지막에는 부모 타입을 받아 unexpected-access=메시지를 출력하는 catch도 안전망으로 두되, 앞의 자식 catch가 처리한 예외를 다시 처리하지 않아야 합니다. 완성한 뒤에는 public을 넣으면 무엇이 출력되고 어느 catch가 실행되는지도 예상해 보세요.
해설 보기
public final class CheckedHierarchyExercise {
public static void main(String[] args) {
open("missing");
open("private");
}
private static void open(String id) {
BoardService service = new BoardService();
try {
service.open(id);
} catch (BoardNotFoundException error) {
System.out.println("create-new=" + error.id());
} catch (BoardPermissionException error) {
System.out.println("request-access=" + error.id());
} catch (BoardAccessException error) {
System.out.println("unexpected-access=" + error.getMessage());
}
}
private static final class BoardService {
void open(String id) throws BoardAccessException {
if (id.equals("missing")) throw new BoardNotFoundException(id);
if (id.equals("private")) throw new BoardPermissionException(id);
System.out.println("opened=" + id);
}
}
private static class BoardAccessException extends Exception {
BoardAccessException(String message) { super(message); }
}
private static final class BoardNotFoundException extends BoardAccessException {
private final String id;
BoardNotFoundException(String id) { super("missing " + id); this.id = id; }
String id() { return id; }
}
private static final class BoardPermissionException extends BoardAccessException {
private final String id;
BoardPermissionException(String id) { super("denied " + id); this.id = id; }
String id() { return id; }
}
}create-new=missing
request-access=private서비스의 throws는 부모 타입 하나로 짧고 안정적으로 유지하면서, 호출하는 쪽은 실제로 던져진 자식 타입에 맞춰 서로 다른 방식으로 대응합니다. missing에서 던진 BoardNotFoundException은 첫 번째 catch와 타입이 맞으므로 그 블록만 실행되고, 마지막 부모 catch는 실행되지 않습니다. 부모 catch는 나중에 BoardAccessException의 새 하위 예외가 생겼을 때를 대비한 안전망입니다. 반대로 부모 catch를 맨 위로 올리면 두 자식 catch에 도달할 수 없어 has already been caught 컴파일 오류가 납니다.
public을 넣으면 서비스가 opened=public을 출력하고 정상적으로 반환하므로 어느 catch도 실행되지 않습니다. throws BoardAccessException은 그 예외를 던질 수도 있다는 선언일 뿐, 호출할 때마다 실제로 던진다는 뜻이 아닙니다.
부모 throws 계약에서도 실제 자식 타입으로 복구한다
서비스의 부모 throws 계약 아래 실제 자식 예외가 한 구체 catch와 출력으로 이어집니다. 발생→catch 선택→한 번의 결과 관찰을 강조하면 뒤 catch 미실행을 설명하기 좋습니다.
CheckedHierarchyExercise의 서비스는 BoardAccessException을 선언한다. missing과 private은 서로 다른 자식 예외가 발생해 CLI의 서로 다른 catch로 들어간다.
예외 계층 · 실제 catch 선택
부모 throws 계약에서도 실제 자식 타입으로 복구한다
서비스는 부모 BoardAccessException을 선언합니다. CLI는 실제 자식 타입과 일치하는 catch 하나를 선택합니다.
missing
- 발생 타입과 선택된 catch
BoardNotFoundException첫 번째 구체 catch- 원문 출력
create-new=missing
private
- 발생 타입과 선택된 catch
BoardPermissionException두 번째 구체 catch- 원문 출력
request-access=private
마지막 BoardAccessException catch에는 이 두 입력이 들어가지 않습니다. 앞에서 일치한 catch 하나만 실행합니다. 부모 catch를 먼저 두어 자식 catch를 가리는 순서는 컴파일되지 않습니다.
이 예의 조건
- 각 입력은 한 구체 catch만 실행한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 부모 throws 계약에서도 실제 자식 타입으로 복구한다 부모 throws 계약에서도 실제 자식 타입으로 복구한다 CheckedHierarchyExercise의 서비스는 BoardAccessException을 선언한다. missing과 private은 서로 다른 자식 예외가 발생해 CLI의 서로 다른 catch로 들어간다. 예외 계층 · 실제 catch 선택 부모 throws 계약에서도 실제 자식 타입으로 복구한다 서비스는 부모 BoardAccessException을 선언합니다. CLI는 실제 자식 타입과 일치하는 catch 하나를 선택합니다. missing 발생 타입과 선택된 catch BoardNotFoundException 첫 번째 구체 catch 원문 출력 create-new=missing private 발생 타입과 선택된 catch BoardPermissionException 두 번째 구체 catch 원문 출력 request-access=private 마지막 BoardAccessException catch에는 이 두 입력이 들어가지 않습니다. 앞에서 일치한 catch 하나만 실행합니다. 부모 catch를 먼저 두어 자식 catch를 가리는 순서는 컴파일되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 서비스 계약 부모 BoardAccessException을 throws로 선언합니다. |
|
|
| 실제 예외 입력에 맞는 자식 타입이 발생합니다. |
|
|
| 단일 처리 해당 구체 catch가 한 줄을 출력합니다. |
|
|
부모 throws 계약에서도 실제 자식 타입으로 복구한다
서비스의 부모 throws 계약 아래 실제 자식 예외가 한 구체 catch와 출력으로 이어집니다. 발생→catch 선택→한 번의 결과 관찰을 강조하면 뒤 catch 미실행을 설명하기 좋습니다.
CheckedHierarchyExercise의 서비스는 BoardAccessException을 선언한다. missing과 private은 서로 다른 자식 예외가 발생해 CLI의 서로 다른 catch로 들어간다.
예외 계층 · 실제 catch 선택
부모 throws 계약에서도 실제 자식 타입으로 복구한다
서비스는 부모 BoardAccessException을 선언합니다. CLI는 실제 자식 타입과 일치하는 catch 하나를 선택합니다.
missing
- 발생 타입과 선택된 catch
BoardNotFoundException첫 번째 구체 catch- 원문 출력
create-new=missing
private
- 발생 타입과 선택된 catch
BoardPermissionException두 번째 구체 catch- 원문 출력
request-access=private
마지막 BoardAccessException catch에는 이 두 입력이 들어가지 않습니다. 앞에서 일치한 catch 하나만 실행합니다. 부모 catch를 먼저 두어 자식 catch를 가리는 순서는 컴파일되지 않습니다.
이 예의 조건
- 각 입력은 한 구체 catch만 실행한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 부모 throws 계약에서도 실제 자식 타입으로 복구한다 부모 throws 계약에서도 실제 자식 타입으로 복구한다 CheckedHierarchyExercise의 서비스는 BoardAccessException을 선언한다. missing과 private은 서로 다른 자식 예외가 발생해 CLI의 서로 다른 catch로 들어간다. 예외 계층 · 실제 catch 선택 부모 throws 계약에서도 실제 자식 타입으로 복구한다 서비스는 부모 BoardAccessException을 선언합니다. CLI는 실제 자식 타입과 일치하는 catch 하나를 선택합니다. missing 발생 타입과 선택된 catch BoardNotFoundException 첫 번째 구체 catch 원문 출력 create-new=missing private 발생 타입과 선택된 catch BoardPermissionException 두 번째 구체 catch 원문 출력 request-access=private 마지막 BoardAccessException catch에는 이 두 입력이 들어가지 않습니다. 앞에서 일치한 catch 하나만 실행합니다. 부모 catch를 먼저 두어 자식 catch를 가리는 순서는 컴파일되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 서비스 계약 부모 BoardAccessException을 throws로 선언합니다. |
|
|
| 실제 예외 입력에 맞는 자식 타입이 발생합니다. |
|
|
| 단일 처리 해당 구체 catch가 한 줄을 출력합니다. |
|
|
CheckedExceptionRecovery의BoardReadException이Exception대신RuntimeException을 상속하도록 바꾸면, 인터페이스와 구현 클래스의throws BoardReadException을 지워도 컴파일될까요? 실행 결과는 달라질까요?
해설 보기
컴파일됩니다. RuntimeException의 하위 클래스는 비검사 예외이므로 throws로 선언하지 않아도 던질 수 있고, 호출하는 쪽도 반드시 잡을 필요가 없습니다. viewCountFor의 try/catch는 그대로 두었으므로 예외는 여전히 잡히고, 출력도 fallback=store unavailable: java와 viewCount=0으로 같습니다.
달라지는 것은 컴파일러의 도움입니다. 누군가 viewCountFor의 try/catch를 지워도 이제는 컴파일 오류가 나지 않고, 저장소가 실패하는 순간 프로그램이 예외로 끝납니다. 부모 클래스를 바꾸는 한 줄이 호출하는 모든 곳의 처리 의무를 바꾼 것입니다.
다음 글
다음 글에서는 컴파일러가 처리 여부를 검사하지 않는 비검사 예외를 다룹니다. 아무도 잡지 않은 예외가 호출 스택을 따라 어떻게 프로그램을 멈추는지 보고, 직접 만드는 예외를 검사 예외로 할지 비검사 예외로 할지 정하는 기준을 정리합니다.