RuntimeException과 비검사 예외
아무도 잡지 않은 비검사 예외가 호출 스택을 거슬러 올라가 프로그램을 멈추는 과정을 따라가 보고, 예외를 검사 예외와 비검사 예외 중 무엇으로 만들지, 어느 메서드에서 잡을지 정하는 기준을 배웁니다.
앞 글에서 본 것처럼 RuntimeException과 그 하위 클래스는 비검사 예외(unchecked exception)입니다. 컴파일러가 catch나 throws를 요구하지 않으므로, 이런 예외를 던지는 메서드는 아무 선언 없이 호출할 수 있습니다. 하지만 처리하지 않아도 컴파일된다는 것이 처리하지 않아도 된다는 뜻은 아닙니다. 컴파일러가 아무 말도 하지 않는 만큼, 어느 메서드에서 잡아 복구하고 어디까지 그대로 보낼지는 프로그래머가 직접 정해야 합니다.
이 글에서는 먼저 아무도 잡지 않은 비검사 예외가 프로그램에 어떤 결과를 남기는지 따라가 봅니다. 그다음 직접 만드는 예외를 검사 예외로 할지 비검사 예외로 할지 정하는 기준과, 예외를 어느 메서드에서 잡을지 정하는 방법을 정리합니다.
아무도 잡지 않은 예외가 프로그램을 멈추는 과정
다음 프로그램에서 main은 BoardService.load()를 호출하고, load()는 다시 repositoryCall()을 호출합니다. repositoryCall()은 저장된 기록이 손상되었다고 가정하고 PostDataException을 던집니다. 이 예외는 RuntimeException을 상속한 비검사 예외입니다. 세 메서드 어디에도 catch나 throws가 없는데도 컴파일은 문제없이 됩니다. main의 마지막 줄은 정상적으로 끝났다는 표시로 normal-end를 출력합니다.
public final class UncaughtUncheckedBoardException {
public static void main(String[] args) {
BoardService service = new BoardService();
service.load("corrupt");
System.out.println("normal-end");
}
private static final class BoardService {
void load(String id) { repositoryCall(id); }
private void repositoryCall(String id) {
throw new PostDataException("invalid record: " + id);
}
}
private static final class PostDataException extends RuntimeException {
PostDataException(String message) { super(message); }
}
}Exception in thread "main" UncaughtUncheckedBoardException$PostDataException: invalid record: corrupt
at UncaughtUncheckedBoardException$BoardService.repositoryCall(UncaughtUncheckedBoardException.java:12)
at UncaughtUncheckedBoardException$BoardService.load(UncaughtUncheckedBoardException.java:9)
at UncaughtUncheckedBoardException.main(UncaughtUncheckedBoardException.java:4)normal-end는 출력되지 않습니다. repositoryCall()이 예외를 던지는 순간 그 메서드의 실행이 멈추고, 예외는 호출한 load()로 전달됩니다. load()에는 이 예외를 잡을 catch가 없으므로 load()도 그 자리에서 끝나고, 예외는 다시 main으로 전달됩니다. main에도 catch가 없으므로 main은 4번째 줄에서 끝나 버리고, 5번째 줄의 normal-end에는 도달하지 못합니다. 이처럼 예외는 맞는 catch를 찾을 때까지 호출한 메서드들을 하나씩 거슬러 올라가며 빠져나갑니다. 이것을 예외의 전파(propagation)라고 합니다.
main까지 빠져나간 예외는 더 이상 잡을 곳이 없으므로, 예외가 난 스레드는 그대로 끝납니다. 스레드가 끝나기 직전에 JVM은 기본 처리기를 호출하고, 이 처리기가 예외의 타입과 메시지, 스택 트레이스를 표준 오류에 출력합니다. 첫 줄의 $PostDataException은 PostDataException이 UncaughtUncheckedBoardException 안에 선언된 중첩 클래스라는 표시입니다. 그 아래 at으로 시작하는 줄들은 return과 호출 스택에서 배운 대로 예외가 발생한 순간의 호출 스택을 위에서부터 보여 줍니다. 예외가 처음 발생한 repositoryCall이 맨 위에 있고, 그 메서드를 부른 load와 main이 차례로 아래에 있습니다.
이 프로그램처럼 다른 스레드가 없는 단순한 프로그램에서는 main 스레드가 끝나면 프로그램 전체가 끝나고, 운영체제에는 실패를 뜻하는 종료 코드 1이 전달됩니다. 다만 잡히지 않은 예외가 끝내는 것은 그 예외가 발생한 스레드 하나입니다. 데몬이 아닌 다른 스레드가 아직 일하고 있다면 프로그램은 그 스레드가 끝날 때까지 계속 실행됩니다. 스레드는 18장에서 배웁니다.
아래 그림은 호출이 main에서 repositoryCall까지 들어가는 방향과, 같은 예외가 거꾸로 빠져나오는 방향을 단계별로 보여 줍니다.
잡히지 않은 예외 뒤 normal-end는 실행되지 않는다
main→load→repositoryCall 호출 뒤 동일 예외가 호출자를 빠져나오고 normal-end에 도달하지 않는 추적입니다. 호출 방향과 역방향 전파·스레드 종료를 단계로 따라가기 좋습니다.
UncaughtUncheckedBoardException은 repositoryCall에서 PostDataException을 던지고 load와 main에 catch가 없다. normal-end는 출력되지 않으며 해당 main 스레드가 미처리 예외로 끝난다.
미처리 예외 · 빠져나오는 순서
잡히지 않은 예외 뒤 normal-end는 실행되지 않는다
service.load 호출 뒤 repositoryCall에서 예외가 발생합니다. catch가 없어 load와 main을 빠져나오며 normal-end에 도달하지 않습니다.
repositoryCall- 발생·전달
PostDataException발생메시지: invalid record: corrupt- 정상 흐름의 결과
- 정상 반환하지 않습니다.
load- 발생·전달
catch가 없어 같은 예외가 호출자로 전달됩니다.- 정상 흐름의 결과
- 서비스 호출이 정상 완료하지 않습니다.
main- 발생·전달
catch가 없어 미처리 예외가 됩니다.- 정상 흐름의 결과
normal-end출력 문장에 도달하지 않습니다.
이 단독 main 예제의 기본 미처리 예외 처리에서는 타입·메시지·스택 추적이 표준 오류로 출력됩니다. 예외는 해당 스레드를 끝내는 것이며, 다른 비데몬 스레드가 있는 모든 프로그램의 즉시 종료를 뜻하지 않습니다.
이 예의 조건
- 단독 예제 main 스레드의 종료이며 모든 스레드 종료로 일반화하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 잡히지 않은 예외 뒤 normal-end는 실행되지 않는다 잡히지 않은 예외 뒤 normal-end는 실행되지 않는다 UncaughtUncheckedBoardException은 repositoryCall에서 PostDataException을 던지고 load와 main에 catch가 없다. normal-end는 출력되지 않으며 해당 main 스레드가 미처리 예외로 끝난다. 미처리 예외 · 빠져나오는 순서 잡히지 않은 예외 뒤 normal-end는 실행되지 않는다 service.load 호출 뒤 repositoryCall에서 예외가 발생합니다. catch가 없어 load와 main을 빠져나오며 normal-end에 도달하지 않습니다. repositoryCall 발생·전달 PostDataException 발생 메시지: invalid record: corrupt 정상 흐름의 결과 정상 반환하지 않습니다. load 발생·전달 catch 가 없어 같은 예외가 호출자로 전달됩니다. 정상 흐름의 결과 서비스 호출이 정상 완료하지 않습니다. main 발생·전달 catch 가 없어 미처리 예외가 됩니다. 정상 흐름의 결과 normal-end 출력 문장에 도달하지 않습니다. 이 단독 main 예제의 기본 미처리 예외 처리에서는 타입·메시지·스택 추적이 표준 오류로 출력됩니다. 예외는 해당 스레드를 끝내는 것이며, 다른 비데몬 스레드가 있는 모든 프로그램의 즉시 종료를 뜻하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호출 체인 main에서 load(corrupt), repositoryCall로 들어갑니다. |
|
|
| 동일 예외 전파 PostDataException이 catch 없는 두 호출자를 빠져나옵니다. |
|
|
| 미처리 종료 normal-end를 건너뛰고 기본 처리가 진단합니다. |
|
|
잡히지 않은 예외 뒤 normal-end는 실행되지 않는다
main→load→repositoryCall 호출 뒤 동일 예외가 호출자를 빠져나오고 normal-end에 도달하지 않는 추적입니다. 호출 방향과 역방향 전파·스레드 종료를 단계로 따라가기 좋습니다.
UncaughtUncheckedBoardException은 repositoryCall에서 PostDataException을 던지고 load와 main에 catch가 없다. normal-end는 출력되지 않으며 해당 main 스레드가 미처리 예외로 끝난다.
미처리 예외 · 빠져나오는 순서
잡히지 않은 예외 뒤 normal-end는 실행되지 않는다
service.load 호출 뒤 repositoryCall에서 예외가 발생합니다. catch가 없어 load와 main을 빠져나오며 normal-end에 도달하지 않습니다.
repositoryCall- 발생·전달
PostDataException발생메시지: invalid record: corrupt- 정상 흐름의 결과
- 정상 반환하지 않습니다.
load- 발생·전달
catch가 없어 같은 예외가 호출자로 전달됩니다.- 정상 흐름의 결과
- 서비스 호출이 정상 완료하지 않습니다.
main- 발생·전달
catch가 없어 미처리 예외가 됩니다.- 정상 흐름의 결과
normal-end출력 문장에 도달하지 않습니다.
이 단독 main 예제의 기본 미처리 예외 처리에서는 타입·메시지·스택 추적이 표준 오류로 출력됩니다. 예외는 해당 스레드를 끝내는 것이며, 다른 비데몬 스레드가 있는 모든 프로그램의 즉시 종료를 뜻하지 않습니다.
이 예의 조건
- 단독 예제 main 스레드의 종료이며 모든 스레드 종료로 일반화하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 잡히지 않은 예외 뒤 normal-end는 실행되지 않는다 잡히지 않은 예외 뒤 normal-end는 실행되지 않는다 UncaughtUncheckedBoardException은 repositoryCall에서 PostDataException을 던지고 load와 main에 catch가 없다. normal-end는 출력되지 않으며 해당 main 스레드가 미처리 예외로 끝난다. 미처리 예외 · 빠져나오는 순서 잡히지 않은 예외 뒤 normal-end는 실행되지 않는다 service.load 호출 뒤 repositoryCall에서 예외가 발생합니다. catch가 없어 load와 main을 빠져나오며 normal-end에 도달하지 않습니다. repositoryCall 발생·전달 PostDataException 발생 메시지: invalid record: corrupt 정상 흐름의 결과 정상 반환하지 않습니다. load 발생·전달 catch 가 없어 같은 예외가 호출자로 전달됩니다. 정상 흐름의 결과 서비스 호출이 정상 완료하지 않습니다. main 발생·전달 catch 가 없어 미처리 예외가 됩니다. 정상 흐름의 결과 normal-end 출력 문장에 도달하지 않습니다. 이 단독 main 예제의 기본 미처리 예외 처리에서는 타입·메시지·스택 추적이 표준 오류로 출력됩니다. 예외는 해당 스레드를 끝내는 것이며, 다른 비데몬 스레드가 있는 모든 프로그램의 즉시 종료를 뜻하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호출 체인 main에서 load(corrupt), repositoryCall로 들어갑니다. |
|
|
| 동일 예외 전파 PostDataException이 catch 없는 두 호출자를 빠져나옵니다. |
|
|
| 미처리 종료 normal-end를 건너뛰고 기본 처리가 진단합니다. |
|
|
전파는 어느 메서드에서든 멈출 수 있습니다. 예를 들어 load()가 repositoryCall(id) 호출을 try로 감싸고 catch (PostDataException error)에서 skipped= 뒤에 메시지를 붙여 출력한다면, 예외는 load() 안에서 처리됩니다. 그러면 main은 예외가 있었는지 모른 채 다음 줄로 넘어가므로 skipped=invalid record: corrupt와 normal-end가 차례로 출력됩니다. 예외를 어디서 멈출지가 이 글 후반부의 주제입니다.
비검사 예외도 throws로 알릴 수 있다
비검사 예외는 throws에 적지 않아도 되지만, 적는 것도 허용됩니다. 호출하는 사람이 꼭 알아야 할 실패라면 메서드 선언에 적어서 드러낼 수 있습니다. 그러면 IDE와 API 문서에서 그 메서드가 어떤 예외를 던질 수 있는지 바로 보입니다. 다만 선언했다고 해서 호출하는 쪽에 처리 의무가 생기지는 않습니다.
다음 프로그램의 Post.create()는 제목이 비어 있거나 조회 수가 음수이면 비검사 예외인 InvalidPostException을 던지고, 그 사실을 throws로 알립니다. main은 올바른 값으로 한 번, 음수 조회 수로 한 번 호출합니다. 첫 번째 호출에는 try가 없는데도 컴파일된다는 점을 눈여겨보세요.
public final class DocumentedUncheckedContract {
public static void main(String[] args) {
Post entry = Post.create("exception", 45);
System.out.println(entry);
try {
Post.create("exception", -1);
} catch (InvalidPostException error) {
System.out.println("rejected=" + error.getMessage());
}
}
private record Post(String title, int viewCount) {
static Post create(String title, int viewCount)
throws InvalidPostException {
if (title == null || title.isBlank()) {
throw new InvalidPostException("blank title");
}
if (viewCount < 0) {
throw new InvalidPostException("viewCount=" + viewCount);
}
return new Post(title, viewCount);
}
}
private static final class InvalidPostException extends RuntimeException {
InvalidPostException(String message) { super(message); }
}
}Post[title=exception, viewCount=45]
rejected=viewCount=-1첫 번째 호출은 두 검사를 모두 통과해 레코드를 만들고, 레코드가 자동으로 만든 toString()이 첫 줄을 출력합니다. 두 번째 호출은 viewCount < 0 검사에 걸려 예외를 던지고, catch가 그 메시지를 받아 rejected=viewCount=-1을 출력합니다. InvalidPostException이 Exception을 직접 상속한 검사 예외였다면, try 없이 쓴 첫 번째 호출에서 unreported exception InvalidPostException 컴파일 오류가 났을 것입니다.
이 실패는 같은 값으로 다시 시도해도 결코 성공하지 않습니다. 음수 조회 수는 Post가 받아들이지 않기로 한 값이므로, 고쳐야 하는 것은 이 값을 넘긴 쪽입니다. 사용자가 입력한 값이라면 입력을 받는 곳에서 다시 입력받아야 하고, 코드가 계산한 값이라면 그 계산이 버그입니다. 저장소나 서비스 깊은 곳에서 재시도한다고 해결되지 않으므로, 이런 실패는 즉시 알리고 값을 만든 쪽까지 전달하는 것이 맞습니다.
검사 예외로 만들까, 비검사 예외로 만들까
직접 예외 클래스를 만들 때는 부모를 골라야 합니다. Exception을 상속하면 검사 예외가 되고, RuntimeException을 상속하면 비검사 예외가 됩니다. 이 선택의 기준은 호출하는 쪽이 그 실패를 만났을 때 실제로 할 수 있는 일이 있는가입니다.
검사 예외는 호출하는 쪽이 놓쳐서는 안 되고, 놓치지 않으면 대응할 수 있는 실패에 어울립니다. 사용자가 고른 파일이 없어서 다른 파일을 고르게 할 수 있는 경우가 그렇고, 외부 결제 연동에서 승인과 거절을 반드시 나눠 처리해야 하는 경우도 그렇습니다. 이런 실패는 컴파일러가 처리를 강제할 가치가 있습니다.
반대로 네트워크나 데이터베이스 장애처럼 중간 계층의 메서드 대부분이 아무것도 할 수 없는 실패를 모두 검사 예외로 만들면, 그 예외가 지나가는 모든 메서드에 같은 throws가 반복됩니다. 실제로 처리하는 곳은 맨 위 한 곳뿐인데, 그 사이의 메서드들은 그저 선언을 이어 붙이기만 합니다. 이런 실패나, 앞 절의 음수 조회 수처럼 호출하는 쪽의 실수로 생긴 실패는 비검사 예외로 만들어 처리할 수 있는 곳까지 곧바로 올려 보내는 편이 낫습니다. 대신 컴파일러가 알려 주지 않으므로, 어떤 예외가 나올 수 있는지 문서에 적고 예외 계층을 명확하게 설계해야 합니다. 프로그램의 입구 근처에는 처리되지 않은 예외를 모아서 받는 공통 처리기도 두어야 합니다.
'throws를 쓰기 귀찮아서 RuntimeException'은 기준이 될 수 없습니다. 기준은 언제나 호출하는 쪽이 무엇을 할 수 있는가입니다.
더 깊이: 검사 예외에 대한 비판과 UncheckedIOException
자바의 초기 표준 라이브러리는 IOException, SQLException처럼 검사 예외를 많이 썼습니다. 그런데 이런 예외가 계층마다 throws로 번져 나가자, 번거로움을 피하려고 빈 catch 블록으로 예외를 삼켜 버리는 코드가 많아졌다는 비판이 나왔습니다. 람다가 들어온 뒤에는 불편이 하나 더 늘었습니다. Function 같은 표준 함수형 인터페이스의 메서드는 검사 예외를 선언하지 않기 때문에, 람다 본문에서 IOException을 던지면 unreported exception IOException 컴파일 오류가 납니다.
그래서 자바 8은 IOException을 감싸는 비검사 예외 java.io.UncheckedIOException을 추가했고, 데이터베이스 접근을 돕는 프레임워크 가운데에도 드라이버가 던지는 검사 예외를 자체 비검사 예외 계층으로 바꿔 던지는 것이 많습니다. Spring이 SQLException 같은 예외를 비검사 예외인 DataAccessException 계층으로 바꿔 던지는 방식은 Spring 데이터 접근 예외에서 다룹니다. 그렇다고 검사 예외가 사라져야 한다는 뜻은 아닙니다. 호출자가 대응할 수 있는 실패를 컴파일러가 확인하게 하는 것은 여전히 유용하며, 앞의 기준으로 실패마다 판단하면 됩니다. 람다와 검사 예외의 관계는 30장의 람다 문법과 실행 시점에서 다시 다룹니다.
계층이 바뀌는 곳에서 예외 바꿔 던지기
비검사 예외도 앞 글에서 본 예외 변환을 할 수 있습니다. 다음 프로그램에서 저장소 구현 BrokenStore는 디스크를 읽지 못했다는 뜻의 StoreAccessException을 던집니다. BoardService.summary()는 그 예외를 잡아 서비스 수준의 의미를 담은 BoardSystemException으로 바꾸어 던지면서, 원래 예외를 원인(cause)으로 넘깁니다. 두 예외 모두 RuntimeException의 하위 클래스이므로 어느 메서드에도 throws가 없습니다.
public final class UncheckedExceptionTranslation {
public static void main(String[] args) {
BoardService service = new BoardService(new BrokenStore());
try {
service.summary("java");
} catch (BoardSystemException error) {
System.out.println("public=" + error.getMessage());
System.out.println("root=" + error.getCause().getClass().getSimpleName());
}
}
private static final class BoardService {
private final BoardStore store;
BoardService(BoardStore store) { this.store = store; }
String summary(String title) {
try {
return title + "=" + store.viewCount(title);
} catch (StoreAccessException cause) {
throw new BoardSystemException("board data unavailable", cause);
}
}
}
private interface BoardStore { int viewCount(String title); }
private static final class BrokenStore implements BoardStore {
public int viewCount(String title) {
throw new StoreAccessException("disk read failed: " + title);
}
}
private static final class StoreAccessException extends RuntimeException {
StoreAccessException(String message) { super(message); }
}
private static final class BoardSystemException extends RuntimeException {
BoardSystemException(String message, Throwable cause) { super(message, cause); }
}
}public=board data unavailable
root=StoreAccessExceptionstore.viewCount(title)가 예외를 던지므로 summary()의 return은 완료되지 못하고 catch로 넘어갑니다. catch는 새 BoardSystemException을 던지고, main의 catch가 그것을 받습니다. 첫 줄은 새 예외의 메시지이고, 두 번째 줄은 getCause()로 꺼낸 원래 예외의 타입입니다. 원래 예외의 메시지 disk read failed: java는 원인 객체 안에 그대로 남아 있지만, 이 main은 타입만 출력했습니다.
변환 덕분에 main은 BoardSystemException 하나만 알면 됩니다. 나중에 저장소가 디스크 대신 원격 서버를 쓰게 되어 다른 예외를 던지더라도, 서비스가 그것을 같은 BoardSystemException으로 바꿔 주는 한 main의 코드는 바뀌지 않습니다. 원인은 그대로 연결되어 있으므로, 이 예외를 잡지 않았거나 로그에 스택 트레이스를 남긴 경우라면 운영자는 Caused by: 부분을 보고 디스크 오류까지 따라갈 수 있습니다.
main이 실패했을 때만 출력한다는 점도 짚어 둡시다. main은 summary()가 돌려주는 문자열을 출력하지 않습니다. 그래서 저장소가 40을 돌려주도록 바꾸면 summary()는 java=40을 반환하지만 화면에는 아무것도 나오지 않습니다. 성공한 값을 보려면 System.out.println(service.summary("java"))처럼 반환값을 출력해야 합니다.
예외를 잡아서 같은 수준의 예외로 메시지만 바꿔 다시 던지는 것은 얻는 것이 별로 없습니다. 변환은 저장소에서 서비스로처럼 계층이 바뀌는 지점에서, 위 계층이 이해할 수 있는 타입과 의미를 줄 때 가치가 있습니다. 아래 그림은 main이 잡은 새 예외 객체와 원인으로 연결된 원래 예외 객체가 서로 다른 객체라는 것과, 출력한 두 값이 각각 어디서 왔는지 보여 줍니다.
UncheckedExceptionTranslation은 StoreAccessException을 잡아 BoardSystemException으로 바꾸고 cause를 보존한다. main은 새 메시지와 원인의 타입을 각각 출력한다.
예외 객체 · 메시지와 cause
서비스 예외의 메시지와 보존한 원인은 별개다
서비스가 저장소 실패를 한 번 변환합니다. main이 잡는 서비스 예외와 cause로 연결된 원인은 서로 다른 객체입니다.
main이 잡은 서비스 예외
- 타입
BoardSystemException- 각 객체의 메시지
- board data unavailable
- main의 출력
public=board data unavailable
cause로 보존한 저장소 예외
- 타입
StoreAccessException- 각 객체의 메시지
- disk read failed: java
- main의 출력
root=StoreAccessException
서비스 예외 생성자가 super(message, cause)로 원인을 전달합니다. getMessage()는 새 문맥을, getCause()는 보존한 원인 객체를 줍니다. 원문은 원인의 전체 메시지나 스택을 출력한 예제가 아닙니다.
예외를 어디서 잡을까
비검사 예외는 컴파일러가 처리를 강요하지 않으므로, 어디서 잡을지를 의식적으로 정해야 합니다. 기본 원칙은 앞 글과 같습니다. 중간의 메서드가 그 실패를 복구할 방법이 없다면 잡지 말고 그대로 보냅니다. 콘솔 프로그램의 main이나 웹 요청을 받는 첫 메서드처럼, 사용자에게 응답을 만들어 줄 수 있는 맨 위 계층에서 잡아 처리하는 것이 보통입니다.
로그도 그 한곳에서 한 번만 남기는 편이 좋습니다. 예외가 지나가는 모든 계층이 저마다 잡아서 로그를 남기고 다시 던지면, 장애 하나가 로그에는 여러 건처럼 찍히고 같은 스택 트레이스가 반복되어 원인을 찾기 어려워집니다.
중간에서 잡아도 되는 경우는 부가 기능을 포기하는 것만으로 핵심 작업을 계속할 수 있을 때입니다. 예를 들어 글쓰기 화면에서 추천 제목을 불러오지 못했더라도 사용자가 제목을 직접 입력할 수 있다면, 추천 예외만 잡고 빈 추천 목록을 보여 주면 됩니다. 반대로 게시글을 데이터베이스에 저장하지 못했는데 그 예외를 잡아 성공한 것처럼 처리해서는 안 됩니다. 사용자는 글이 저장되었다고 믿고 화면을 떠날 것이기 때문입니다. 실패를 잡아 복구했다면, 복구한 뒤에도 프로그램이 지켜야 할 조건이 그대로 지켜지는지 확인해야 합니다. 저장에 실패한 글이 목록에 나타나는 일은 없어야 합니다.
예외 타입을 고르는 질문
새로운 실패를 예외로 표현해야 할 때는 다음 질문을 차례로 던져 보세요.
- 호출하는 쪽이 이 실패를 알게 되면 다른 방법으로 원래 하려던 일을 해낼 수 있는가?
- 이 예외를 검사 예외로 만들면, 중간 계층의 거의 모든 메서드가 같은
throws를 이어 붙이기만 하게 되는가? - 이 실패는 네트워크나 디스크 같은 외부 환경의 문제인가, 호출하는 쪽이 잘못된 값을 넘긴 실수인가?
- 공개 메서드가 특정 구현의 예외를 그대로 노출하게 되는가?
- 맨 위의 처리기가 이 예외로 사용자에게 보여 줄 응답과 운영자에게 남길 기록을 만들 수 있는가?
첫 질문의 답이 분명히 '예'이고 호출하는 곳마다 대응 방법이 다르다면 검사 예외나 첫 글에서 본 결과 타입을 검토합니다. 두 번째 질문의 답이 '예'라면 비검사 예외로 바꾸고 맨 위에서 한꺼번에 처리하는 편이 단순합니다. 세 번째 질문에서 호출하는 쪽의 실수라는 답이 나오면 새 클래스를 만들기 전에 표준 예외부터 떠올립니다. 인수 값이 잘못되었다면 IllegalArgumentException을, 객체가 지금 그 동작을 할 수 없는 상태라면 IllegalStateException을 쓰는 것이 기본입니다. 직접 만든 예외 클래스는 표준 예외로는 표현할 수 없는 업무상의 의미가 필요할 때만 추가합니다. 네 번째와 다섯 번째 질문은 앞 절의 예외 변환과 이 장의 마지막 글에서 다룰 공통 처리기로 이어집니다.
정리
- 비검사 예외는
throws없이 던질 수 있지만, 아무도 잡지 않으면 호출한 메서드들을 차례로 빠져나가 결국 그 스레드를 끝냅니다. 기본 처리기는 타입과 메시지, 스택 트레이스를 표준 오류에 출력합니다. - 비검사 예외도
throws에 적어 알릴 수 있습니다. 문서 역할을 할 뿐 호출하는 쪽에 처리 의무가 생기지는 않습니다. - 호출하는 쪽이 대응할 수 있는 실패는 검사 예외로, 호출하는 쪽의 실수나 중간 계층이 아무것도 할 수 없는 장애는 비검사 예외로 만드는 것이 기본 기준입니다.
- 계층이 바뀌는 지점에서는 원인을 연결한 채 위 계층이 이해할 수 있는 예외로 바꿔 던질 수 있습니다.
- 복구할 방법이 없는 중간 계층은 잡지 말고 보내고, 맨 위에서 한 번 처리하고 기록합니다. 부가 기능의 실패는 중간에서 복구해도 되지만, 필수 작업의 실패를 성공으로 바꿔서는 안 됩니다.
연습 문제
- 추천 저장소는
RecommendationUnavailableException을 던질 수 있습니다. 추천에 실패하면 빈 목록으로 복구하고, 게시글 저장에 실패하면 계속 위로 전달하는 서비스 코드를 작성하세요.
loadRecommendations()는 RecommendationUnavailableException("model offline")을 던지고, recommendations()의 반환 타입은 List<String>입니다. save(title)은 BoardWriteFailure("store offline: " + title)을 던집니다. 두 예외는 RuntimeException의 하위 클래스이고 title은 null이 아니라고 가정합니다.
main은 먼저 recommendations()의 결과를 recommendations=목록 형식으로 출력한 뒤 save("exception")을 호출합니다. 저장 실패는 main의 catch (BoardWriteFailure error)에서 save-failed=메시지 형식으로 출력합니다. 서비스 안에서는 추천 예외만 잡고, 저장 예외를 잡아 성공으로 바꾸지 않습니다. 다 작성했다면 저장 제목만 draft로 바꿨을 때 두 줄이 각각 어떻게 바뀌는지도 예상해 보세요.
해설 보기
import java.util.List;
public final class SelectiveUncheckedRecoveryExercise {
public static void main(String[] args) {
BoardService service = new BoardService();
System.out.println("recommendations=" + service.recommendations());
try {
service.save("exception");
} catch (BoardWriteFailure error) {
System.out.println("save-failed=" + error.getMessage());
}
}
private static final class BoardService {
List<String> recommendations() {
try {
return loadRecommendations();
} catch (RecommendationUnavailableException error) {
return List.of();
}
}
void save(String title) {
throw new BoardWriteFailure("store offline: " + title);
}
private List<String> loadRecommendations() {
throw new RecommendationUnavailableException("model offline");
}
}
private static final class RecommendationUnavailableException extends RuntimeException {
RecommendationUnavailableException(String message) { super(message); }
}
private static final class BoardWriteFailure extends RuntimeException {
BoardWriteFailure(String message) { super(message); }
}
}recommendations=[]
save-failed=store offline: exceptionrecommendations()는 추천 예외를 잡아 빈 목록 List.of()를 돌려주므로, main은 먼저 recommendations=[]를 출력합니다. 추천은 부가 기능이라 빈 목록으로도 글쓰기를 계속할 수 있기 때문입니다. 반면 save()의 예외는 서비스 안에서 잡지 않으므로 main까지 올라가고, main의 catch가 두 번째 줄을 출력합니다. 저장은 실패를 대신할 결과를 만들 수 없으니 실패를 알리는 것이 맞습니다. 두 예외는 모두 비검사 예외지만 처리 방식이 다릅니다. 검사 여부보다 복구한 뒤에도 결과의 의미가 유지되는지가 더 중요한 판단 기준입니다.
제목을 draft로 바꾸면 첫 줄 recommendations=[]는 그대로이고, 두 번째 줄은 save-failed=store offline: draft가 됩니다. 이 연습의 save()는 어떤 제목에도 예외를 던지도록 만든 테스트용 구현이므로, 제목을 바꿨다고 저장이 성공하지는 않습니다.
추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다
같은 main에서 추천 실패를 빈 목록으로 복구한 뒤 저장 실패를 경계로 전달합니다. 실제 recommendations 출력→save 시도→catch 출력의 시간 순서가 핵심입니다.
SelectiveUncheckedRecoveryExercise는 추천 예외를 서비스에서 빈 목록으로 바꾼다. 저장 실패는 서비스에서 복구하지 않고 main이 잡아 실패를 출력한다.
지역 복구 · 기능별 책임
추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다
추천 실패는 빈 목록으로 복구합니다. 저장 실패는 성공 값을 만들 수 없어 main의 경계로 전달합니다.
recommendations()
- 서비스의 실제 처리
RecommendationUnavailableException을 잡아List.of()를 반환합니다.- main이 관찰하는 결과
recommendations=[]
save("exception")
- 서비스의 실제 처리
BoardWriteFailure를 던집니다.서비스에는 이를 복구하는 catch가 없습니다.- main이 관찰하는 결과
save-failed=store offline: exception
main은 추천 결과를 먼저 출력한 뒤 저장을 시도합니다. 두 번째 줄은 저장 성공이 아니라 경계에서 잡은 실패입니다. 이 예제에는 실제 재시도나 저장 성공 경로가 실행되지 않습니다.
이 예의 조건
- 동일 main의 실제 순서이며 저장 성공/재시도를 수행하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다 추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다 SelectiveUncheckedRecoveryExercise는 추천 예외를 서비스에서 빈 목록으로 바꾼다. 저장 실패는 서비스에서 복구하지 않고 main이 잡아 실패를 출력한다. 지역 복구 · 기능별 책임 추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다 추천 실패는 빈 목록으로 복구합니다. 저장 실패는 성공 값을 만들 수 없어 main의 경계로 전달합니다. recommendations() 서비스의 실제 처리 RecommendationUnavailableException 을 잡아 List.of() 를 반환합니다. main이 관찰하는 결과 recommendations=[] save("exception") 서비스의 실제 처리 BoardWriteFailure 를 던집니다. 서비스에는 이를 복구하는 catch가 없습니다. main이 관찰하는 결과 save-failed=store offline: exception main 은 추천 결과를 먼저 출력한 뒤 저장을 시도합니다. 두 번째 줄은 저장 성공이 아니라 경계에서 잡은 실패입니다. 이 예제에는 실제 재시도나 저장 성공 경로가 실행되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 추천 실패 복구 추천 서비스가 오류를 빈 목록으로 반환합니다. |
|
|
| main 다음 호출 recommendations=[] 출력 뒤 필수 save를 시도합니다. |
|
|
| 저장 실패 경계 BoardWriteFailure를 main이 잡습니다. |
|
|
추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다
같은 main에서 추천 실패를 빈 목록으로 복구한 뒤 저장 실패를 경계로 전달합니다. 실제 recommendations 출력→save 시도→catch 출력의 시간 순서가 핵심입니다.
SelectiveUncheckedRecoveryExercise는 추천 예외를 서비스에서 빈 목록으로 바꾼다. 저장 실패는 서비스에서 복구하지 않고 main이 잡아 실패를 출력한다.
지역 복구 · 기능별 책임
추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다
추천 실패는 빈 목록으로 복구합니다. 저장 실패는 성공 값을 만들 수 없어 main의 경계로 전달합니다.
recommendations()
- 서비스의 실제 처리
RecommendationUnavailableException을 잡아List.of()를 반환합니다.- main이 관찰하는 결과
recommendations=[]
save("exception")
- 서비스의 실제 처리
BoardWriteFailure를 던집니다.서비스에는 이를 복구하는 catch가 없습니다.- main이 관찰하는 결과
save-failed=store offline: exception
main은 추천 결과를 먼저 출력한 뒤 저장을 시도합니다. 두 번째 줄은 저장 성공이 아니라 경계에서 잡은 실패입니다. 이 예제에는 실제 재시도나 저장 성공 경로가 실행되지 않습니다.
이 예의 조건
- 동일 main의 실제 순서이며 저장 성공/재시도를 수행하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다 추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다 SelectiveUncheckedRecoveryExercise는 추천 예외를 서비스에서 빈 목록으로 바꾼다. 저장 실패는 서비스에서 복구하지 않고 main이 잡아 실패를 출력한다. 지역 복구 · 기능별 책임 추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다 추천 실패는 빈 목록으로 복구합니다. 저장 실패는 성공 값을 만들 수 없어 main의 경계로 전달합니다. recommendations() 서비스의 실제 처리 RecommendationUnavailableException 을 잡아 List.of() 를 반환합니다. main이 관찰하는 결과 recommendations=[] save("exception") 서비스의 실제 처리 BoardWriteFailure 를 던집니다. 서비스에는 이를 복구하는 catch가 없습니다. main이 관찰하는 결과 save-failed=store offline: exception main 은 추천 결과를 먼저 출력한 뒤 저장을 시도합니다. 두 번째 줄은 저장 성공이 아니라 경계에서 잡은 실패입니다. 이 예제에는 실제 재시도나 저장 성공 경로가 실행되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 추천 실패 복구 추천 서비스가 오류를 빈 목록으로 반환합니다. |
|
|
| main 다음 호출 recommendations=[] 출력 뒤 필수 save를 시도합니다. |
|
|
| 저장 실패 경계 BoardWriteFailure를 main이 잡습니다. |
|
|
- 다음 세 실패에 각각 어떤 예외가 어울리는지 고르고 이유를 설명하세요. (가) 회원 나이에 -5가 넘어왔다. (나) 사용자가 불러오기를 요청한 파일이 없어서, 다른 파일을 고르게 할 수 있다. (다) 이미 닫은 연결 객체로
send()를 호출했다.
해설 보기
(가)는 값을 넘긴 쪽의 실수이므로 비검사 예외인 IllegalArgumentException이 어울립니다. 같은 값으로 다시 시도해도 성공하지 않으니, 값을 만든 쪽이 고쳐야 합니다. 사용자 입력을 그대로 넘긴 것이라면 입력을 받는 곳에서 먼저 검사하고 다시 입력받는 것이 맞습니다.
(나)는 호출하는 쪽이 다른 파일을 고르게 하는 식으로 대응할 수 있는 실패입니다. 그래서 놓치지 않도록 검사 예외로 알리는 것이 어울리며, 표준 라이브러리도 이런 경우 IOException 계열의 FileNotFoundException이나 NoSuchFileException을 씁니다. 파일이 없는 것이 흔하고 예상된 결과라면 미리 존재 여부를 확인하거나, 첫 글에서 본 결과 값으로 돌려주는 방법도 있습니다.
(다)는 인수가 아니라 객체의 현재 상태 때문에 동작을 할 수 없는 경우이므로 IllegalStateException이 기본 선택입니다. 연결을 닫은 뒤에도 그 객체를 계속 쓰는 코드의 실수이니 비검사 예외가 맞습니다.
다음 글
다음 글에서는 예외가 나도 연결 끊기 같은 정리 코드를 실행하게 만드는 finally를 배웁니다. 첫 글의 연습 문제 2에서 잡히지 않은 예외 때문에 disconnected가 출력되지 않았던 문제를 거기서 고칩니다.