예외 클래스 계층 설계와 예외 변환
예외를 부모·자식 클래스로 나눠 catch 순서를 바르게 정하는 방법과, 하위 계층의 예외를 원인을 유지한 채 상위 계층의 예외로 바꾸는 예외 변환을 배웁니다.
앞 글에서는 연결 실패와 전송 실패를 ConnectException과 SendException으로 나누고, 둘 다 NetworkException을 상속하게 했습니다. 예외 클래스를 이렇게 여러 개로 나누는 목적은 이름을 늘리는 데 있지 않습니다. 호출자가 실패 종류마다 다르게 대응해야 할 때, 그 구분과 대응에 필요한 값을 타입으로 전달하려는 것입니다. 그리고 공통 부모가 있으면 메서드 선언에는 throws NetworkException 하나만 적어 두고, 받는 쪽에서는 필요한 만큼만 자식 타입으로 나눠 잡을 수 있습니다.
catch 순서 규칙과 예외 변환(exception translation)은 예외 계층을 다룬 글에서 이미 한 번 보았습니다. 이 글에서는 그 규칙을 직접 만든 예외 계층에 적용합니다. 예외 클래스를 언제 나누고 무엇을 담을지, 예외가 여러 계층을 지나갈 때 어디서 어떻게 바꿔야 하는지가 주제입니다.
직접 만든 예외도 자식 catch를 먼저 쓴다
예외 계층을 다룬 글에서는 catch (Exception error) 뒤에 catch (NumberFormatException error)를 써서 컴파일 오류가 나는 것을 보았습니다. 직접 만든 예외 계층에도 같은 규칙이 적용됩니다. 다음 코드에서 ConnectException은 NetworkException의 자식인데, catch를 부모, 자식 순서로 썼습니다.
public final class BroadCatchBeforeSpecificFailure {
public static void main(String[] args) {
try {
connect();
} catch (NetworkException error) {
System.out.println("network");
} catch (ConnectException error) {
System.out.println("connect=" + error.address());
}
}
private static void connect() throws ConnectException {
throw new ConnectException("board.local");
}
private static class NetworkException extends Exception {
NetworkException(String message) { super(message); }
}
private static final class ConnectException extends NetworkException {
private final String address;
ConnectException(String address) { super(address); this.address = address; }
String address() { return address; }
}
}lab/BroadCatchBeforeSpecificFailure.java:7: error: exception ConnectException has already been caught
} catch (ConnectException error) {
^
1 error자바는 catch를 위에서부터 차례로 검사해 처음 맞는 블록 하나만 실행하는데, ConnectException 객체는 첫 번째 catch (NetworkException error)에 이미 맞습니다. 그러니 두 번째 catch는 어떤 경우에도 실행될 수 없고, 컴파일러는 이렇게 절대 실행될 수 없는 코드를 실수로 보고 거부합니다. 오류 메시지 has already been caught는 '그 예외는 앞에서 이미 잡혔다'는 뜻입니다. 검사 예외든 비검사 예외든 마찬가지여서, catch (RuntimeException e) 다음에 catch (IllegalStateException e)를 써도 같은 오류가 납니다.
고치는 방법은 순서를 바꾸는 것입니다. 구체적인 자식 타입을 위에 두고, 부모 타입은 마지막에 남은 경우를 받는 안전망으로 둡니다. 실제로 두 catch의 순서만 바꾸면 컴파일되고 connect=board.local이 출력됩니다. 다만 이 예제처럼 connect()가 ConnectException만 던진다고 선언했다면, 맨 뒤의 NetworkException 블록에는 실제로 도달할 일이 없습니다. 그래서 javac가 unreachable catch clause라는 경고를 냅니다. 오류는 아니지만 필요 없는 코드라는 신호입니다.
부모 catch는 언제 의미가 있을까요? 메서드가 throws NetworkException처럼 부모 타입으로 선언되어 있어서, 아직 따로 나누지 않은 다른 자식 예외가 올 수 있을 때입니다. 다음 절의 예제가 그런 경우입니다. 반대로 모든 자식을 똑같이 처리한다면 자식을 따로 잡지 말고 부모 하나만 잡는 편이 단순합니다.
예외 객체에 대응에 필요한 값을 담기
실패 종류를 오류 코드 문자열로만 알리면, 받는 쪽은 문자열을 비교한 다음 대응에 필요한 값을 따로 구해야 합니다. 예외 타입을 나누면 각 예외가 자기 상황에 맞는 값을 직접 들고 다닐 수 있습니다. 다음 예제의 execute()는 throws NetworkException으로 부모 타입만 선언하고, 실행할 때 상황에 따라 ConnectException이나 SendException을 던집니다.
public final class SpecificExceptionRecovery {
public static void main(String[] args) {
run("connect");
run("send");
}
private static void run(String scenario) {
try {
new NetworkClient(scenario).execute("create-post");
} catch (ConnectException error) {
System.out.println("alternate-host-for=" + error.address());
} catch (SendException error) {
System.out.println("keep-draft=" + error.data());
} catch (NetworkException error) {
System.out.println("network=" + error.getMessage());
}
}
private record NetworkClient(String scenario) {
void execute(String data) throws NetworkException {
if (scenario.equals("connect")) throw new ConnectException("primary");
if (scenario.equals("send")) throw new SendException(data);
}
}
private static class NetworkException extends Exception {
NetworkException(String message) { super(message); }
}
private static final class ConnectException extends NetworkException {
private final String address;
ConnectException(String address) { super("connect failed"); this.address = address; }
String address() { return address; }
}
private static final class SendException extends NetworkException {
private final String data;
SendException(String data) { super("send failed"); this.data = data; }
String data() { return data; }
}
}alternate-host-for=primary
keep-draft=create-postrun("connect")에서 던진 ConnectException은 첫 번째 catch에 맞아 접속하려던 주소 primary를 꺼내 대체 서버를 안내합니다. run("send")에서 던진 SendException은 두 번째 catch에 맞아 보내려던 데이터 create-post를 꺼내 임시 저장을 안내합니다. 어느 경우든 처음 맞는 catch 하나만 실행되므로, 마지막 NetworkException 블록은 이번 실행에서 한 번도 실행되지 않았습니다.
그래도 마지막 블록은 필요합니다. execute()가 throws NetworkException으로 선언되어 있으니, 컴파일러가 보기에는 ConnectException도 SendException도 아닌 다른 NetworkException이 올 수 있습니다. 이 블록을 지우면 unreported exception NetworkException; must be caught or declared to be thrown 오류가 납니다. 나중에 TimeoutException 같은 새 자식이 추가되어도 이 블록이 받아 주므로, 호출하는 코드를 고치지 않아도 컴파일과 실행이 계속됩니다.
예외에 값을 담을 때 주의할 점이 하나 있습니다. 비밀번호, 인증 토큰, 개인 정보 같은 민감한 값은 예외 필드나 메시지에 그대로 넣지 않습니다. 예외는 로그 파일에 기록되거나 오류 응답에 섞여 생각보다 멀리 퍼지기 때문입니다. 디버깅에 필요한 식별자처럼 노출되어도 괜찮은 정보만 담고, 민감한 값은 일부를 가려서 넣습니다.
부모 catch를 먼저 둔 원문은 컴파일 실패하며 실행 출력이 없다. 구체 catch를 먼저 둔 SpecificExceptionRecovery의 실제 두 호출은 address=primary와 data=create-post를 각 catch에서 사용한다.
catch 순서 · 컴파일과 실행
catch 순서의 컴파일 실패와 실제 선택
부모 catch가 먼저인 원문은 컴파일 실패합니다. 구체 catch를 먼저 둔 실행 예제는 입력에 맞는 한 분기를 선택합니다.
| 원문 사례 | 선택 또는 거부 이유 | 관찰 |
|---|---|---|
| 부모 catch가 먼저 | NetworkException이 ConnectException까지 잡으므로 뒤의 자식 catch는 허용되지 않습니다. | 컴파일 실패. 프로그램 출력 없음. |
run("connect") | SpecificExceptionRecovery의 첫 catch가 ConnectException의 주소를 받습니다. | |
run("send") | 두 번째 catch가 SendException의 데이터를 받습니다. 마지막 부모 catch로 이어서 실행하지 않습니다. | |
- 부모 catch가 먼저
- 선택 또는 거부 이유
NetworkException이ConnectException까지 잡으므로 뒤의 자식 catch는 허용되지 않습니다.- 관찰
- 컴파일 실패. 프로그램 출력 없음.
run("connect")- 선택 또는 거부 이유
SpecificExceptionRecovery의 첫 catch가ConnectException의 주소를 받습니다.- 관찰
alternate-host-for=primary
run("send")- 선택 또는 거부 이유
- 두 번째 catch가
SendException의 데이터를 받습니다. 마지막 부모 catch로 이어서 실행하지 않습니다. - 관찰
keep-draft=create-post
첫 실행 출력은 alternate-host-for=primary 한 줄입니다. throws NetworkException은 부모 타입의 전파 선언이고 실행 시 구체 타입이 바뀌는 것은 아닙니다. 이 두 호출에서 network= 출력은 없습니다.
검사 예외를 계층마다 throws로 넘기는 부담
실제 애플리케이션은 여러 계층으로 나뉩니다. 예를 들어 웹 요청을 받는 계층이 서비스 계층을 호출하고, 서비스는 데이터베이스에 접근하는 저장소(repository) 계층을 호출합니다. 저장소가 DatabaseException 같은 검사 예외를 던지는데 서비스도 그 위의 계층도 이 실패에 대응할 방법이 없다고 해 봅시다. 그러면 중간의 모든 메서드가 똑같은 throws DatabaseException을 적어야 합니다. 나중에 저장소 구현이 바뀌어 다른 검사 예외를 던지게 되면, 그 예외를 실제로 다루지도 않는 중간 계층의 선언까지 줄줄이 고쳐야 합니다. 게다가 서비스를 쓰는 쪽은 서비스 뒤에 어떤 데이터베이스가 있는지까지 알게 됩니다.
선언을 throws Exception으로 넓히는 것이 답이 아니라는 점은 예외 계층을 다룬 글에서 보았습니다. 호출자의 처리 부담은 그대로인 채, 어떤 실패가 올 수 있는지에 대한 정보만 사라지기 때문입니다.
해결책은 계층이 바뀌는 곳에서 예외를 바꾸는 것입니다. 서비스가 저장소의 DatabaseException을 잡아, 서비스의 언어로 된 BoardPersistenceException으로 바꿔 던집니다. 이때 원래 예외를 새 예외의 원인(cause)으로 넘겨 줍니다.
public final class CheckedExceptionBurden {
public static void main(String[] args) {
ApiBoundary boundary = new ApiBoundary(new BoardService());
boundary.handle("java");
}
private record ApiBoundary(BoardService service) {
void handle(String title) {
try {
service.save(title);
} catch (BoardPersistenceException error) {
System.out.println("unavailable=" + error.getMessage());
}
}
}
private static final class BoardService {
void save(String title) throws BoardPersistenceException {
try {
repositoryWrite(title);
} catch (DatabaseException cause) {
throw new BoardPersistenceException("save failed: " + title, cause);
}
}
private void repositoryWrite(String title) throws DatabaseException {
throw new DatabaseException("connection refused");
}
}
private static final class DatabaseException extends Exception {
DatabaseException(String message) { super(message); }
}
private static final class BoardPersistenceException extends Exception {
BoardPersistenceException(String message, Throwable cause) { super(message, cause); }
}
}unavailable=save failed: javarepositoryWrite()가 connection refused라는 DatabaseException을 던지면, save()의 catch가 이를 받아 BoardPersistenceException("save failed: java", cause)를 새로 만들어 던집니다. 생성자의 super(message, cause)가 원래 예외를 원인으로 보관하는 것은 앞에서 본 그대로입니다. 요청을 받는 ApiBoundary는 BoardPersistenceException 하나만 알면 되고, 뒤에서 어떤 데이터베이스를 어떻게 쓰는지는 몰라도 됩니다. 저장소를 다른 제품으로 바꿔도 서비스의 throws 선언과 호출하는 코드는 그대로입니다.
출력에는 connection refused가 보이지 않지만 원인이 사라진 것은 아닙니다. catch 블록에 error.printStackTrace();를 추가해 스택 트레이스를 출력해 보면, Caused by: CheckedExceptionBurden$DatabaseException: connection refused 줄 아래에 원래 예외가 생긴 repositoryWrite()의 위치가 그대로 남아 있습니다. 원인을 넘기지 않고 바꿔 던졌다면 이 부분이 통째로 사라져, 운영 중 장애가 났을 때 실제 원인을 추적할 수 없었을 것입니다.
이 예제에서 BoardPersistenceException은 여전히 검사 예외이므로, 호출자는 catch나 throws로 이 실패를 다뤄야 합니다. 이 강제가 의미가 있으려면 호출자가 실제로 무언가를 할 수 있어야 합니다. 잠시 뒤 다시 시도하거나, 사용자에게 지금은 저장할 수 없다고 안내하는 것처럼 말입니다.
대응할 수 없는 장애는 비검사 예외로 바꾸기
데이터베이스 연결이 끊긴 상황에서 중간 계층이 할 수 있는 일은 대개 없습니다. 비검사 예외를 다룬 글에서 본 것처럼, 이런 장애를 처리하는 곳이 맨 위의 공통 처리기 한 곳뿐이라면 바꾼 예외를 비검사 예외로 만드는 편이 낫습니다. 중간 계층에 throws를 줄줄이 적지 않아도 예외가 공통 처리기까지 그대로 전파되기 때문입니다. 다음 예제는 서비스와 main 사이에 계층을 하나 더 두고, 이때도 원인을 연결한 채 사용자에게 보여 줄 문구와 운영자가 볼 진단 정보를 나눠 출력합니다.
public final class UncheckedInfrastructureTranslation {
public static void main(String[] args) {
try {
new BoardFacade().publish("예외 처리 질문");
} catch (BoardSystemException error) {
System.out.println("user=temporarily unavailable");
System.out.println("operation=" + error.operation());
System.out.println("cause=" + error.getCause().getMessage());
}
}
private static final class BoardFacade {
void publish(String title) { new BoardService().publish(title); }
}
private static final class BoardService {
void publish(String title) {
try {
writeDatabase(title);
} catch (DatabaseDriverException cause) {
throw new BoardSystemException("publish-post", cause);
}
}
private void writeDatabase(String title) {
throw new DatabaseDriverException("socket closed for " + title);
}
}
private static final class DatabaseDriverException extends RuntimeException {
DatabaseDriverException(String message) { super(message); }
}
private static final class BoardSystemException extends RuntimeException {
private final String operation;
BoardSystemException(String operation, Throwable cause) {
super("system failure", cause);
this.operation = operation;
}
String operation() { return operation; }
}
}user=temporarily unavailable
operation=publish-post
cause=socket closed for 예외 처리 질문BoardFacade는 서비스 앞에서 호출을 받아 넘겨 주는 중간 계층인데, catch도 throws도 없습니다. BoardSystemException이 RuntimeException의 자식이라 컴파일러가 처리를 강제하지 않기 때문입니다. 그래도 예외가 사라지지는 않습니다. 실행 중에 생긴 예외는 BoardFacade.publish()를 그대로 빠져나가 main의 catch에 도착합니다. main은 세 줄을 출력합니다. 첫 줄은 사용자에게 보여 줄 안전한 문구이고, 둘째와 셋째 줄은 운영자를 위한 정보로 어떤 작업이 실패했는지와 getCause()로 꺼낸 원래 예외의 메시지입니다. 실제 서버라면 운영자용 정보는 화면이 아니라 로그로 보내고, 스택 트레이스 전체를 함께 기록합니다. 요청마다 붙인 식별자를 사용자 응답과 로그에 함께 남겨 두면 둘을 서로 찾아 연결할 수 있습니다.
검사 예외로 바꾼 앞 예제와 비교해 보면, 두 방식 모두 원인을 보존한다는 점은 같습니다. 다른 것은 컴파일러가 중간 계층에 처리를 요구하는지입니다. 검사 예외는 모든 호출 경로에 catch나 throws를 요구하고, 비검사 예외는 선언 없이 그대로 지나갑니다.
변환된 예외와 보존된 원인을 함께 읽기
독립 두 프로그램에서 원래 실패가 서비스·Facade/호출자를 거쳐 main으로 전파됩니다. 선언 의무와 실행 중 전파를 구분하는 실제 예외 경로입니다.
CheckedExceptionBurden은 DatabaseException을 체크 BoardPersistenceException으로 감싸고 UncheckedInfrastructureTranslation은 DatabaseDriverException을 언체크 BoardSystemException으로 감싼다. 두 경우 모두 cause를 보존하지만 선언 의무와 경계 출력은 다르다.
공개 계약 · 체크와 언체크
변환된 예외와 보존된 원인을 함께 읽기
두 프로그램 모두 cause를 연결합니다. 체크 변환은 throws 선언을 요구하고 언체크 변환은 Facade를 지나 경계로 전달됩니다.
체크 변환
- 바깥으로 전달할 계약
DatabaseException→BoardPersistenceException서비스의save는 변환된 체크 타입을 throws로 선언합니다.- 원인과 경계 관찰
cause 메시지:
connection refused원인 메시지는 이 main에서 출력하지 않습니다.
unavailable=save failed: java
언체크 변환
- 바깥으로 전달할 계약
DatabaseDriverException→BoardSystemExceptionFacade는 catch/throws 없이 예외를 경계로 전달합니다.- 원인과 경계 관찰
user=temporarily unavailable operation=publish-post cause=socket closed for 예외 처리 질문
체크 예제의 stdout은 unavailable=save failed: java 한 줄입니다. 언체크 예제의 cause 출력은 원인 메시지를 읽은 것이며 예외 객체 전체의 스택·suppressed를 출력한 것은 아닙니다. throws Exception도 호출자의 catch/throws 의무를 없애지 않습니다.
이 예의 조건
- 체크/언체크는 독립 원문이고 예외 전체 진단 출력이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 변환된 예외와 보존된 원인을 함께 읽기 변환된 예외와 보존된 원인을 함께 읽기 CheckedExceptionBurden은 DatabaseException을 체크 BoardPersistenceException으로 감싸고 UncheckedInfrastructureTranslation은 DatabaseDriverException을 언체크 BoardSystemException으로 감싼다. 두 경우 모두 cause를 보존하지만 선언 의무와 경계 출력은 다르다. 공개 계약 · 체크와 언체크 변환된 예외와 보존된 원인을 함께 읽기 두 프로그램 모두 cause를 연결합니다. 체크 변환은 throws 선언을 요구하고 언체크 변환은 Facade를 지나 경계로 전달됩니다. 체크 변환 바깥으로 전달할 계약 DatabaseException → BoardPersistenceException 서비스의 save 는 변환된 체크 타입을 throws로 선언합니다. 원인과 경계 관찰 cause 메시지: connection refused 원인 메시지는 이 main에서 출력하지 않습니다. unavailable=save failed: java 언체크 변환 바깥으로 전달할 계약 DatabaseDriverException → BoardSystemException Facade는 catch/throws 없이 예외를 경계로 전달합니다. 원인과 경계 관찰 user=temporarily unavailable operation=publish-post cause=socket closed for 예외 처리 질문 체크 예제의 stdout은 unavailable=save failed: java 한 줄입니다. 언체크 예제의 cause 출력은 원인 메시지를 읽은 것이며 예외 객체 전체의 스택·suppressed를 출력한 것은 아닙니다. throws Exception 도 호출자의 catch/throws 의무를 없애지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원인 발생 저장소/드라이버의 실패를 받습니다. |
|
|
| 공개 예외로 변환 cause를 유지하며 서비스 예외를 던집니다. |
|
|
| main 안내 각 경계가 자신에게 필요한 문자열을 출력합니다. |
|
|
변환된 예외와 보존된 원인을 함께 읽기
독립 두 프로그램에서 원래 실패가 서비스·Facade/호출자를 거쳐 main으로 전파됩니다. 선언 의무와 실행 중 전파를 구분하는 실제 예외 경로입니다.
CheckedExceptionBurden은 DatabaseException을 체크 BoardPersistenceException으로 감싸고 UncheckedInfrastructureTranslation은 DatabaseDriverException을 언체크 BoardSystemException으로 감싼다. 두 경우 모두 cause를 보존하지만 선언 의무와 경계 출력은 다르다.
공개 계약 · 체크와 언체크
변환된 예외와 보존된 원인을 함께 읽기
두 프로그램 모두 cause를 연결합니다. 체크 변환은 throws 선언을 요구하고 언체크 변환은 Facade를 지나 경계로 전달됩니다.
체크 변환
- 바깥으로 전달할 계약
DatabaseException→BoardPersistenceException서비스의save는 변환된 체크 타입을 throws로 선언합니다.- 원인과 경계 관찰
cause 메시지:
connection refused원인 메시지는 이 main에서 출력하지 않습니다.
unavailable=save failed: java
언체크 변환
- 바깥으로 전달할 계약
DatabaseDriverException→BoardSystemExceptionFacade는 catch/throws 없이 예외를 경계로 전달합니다.- 원인과 경계 관찰
user=temporarily unavailable operation=publish-post cause=socket closed for 예외 처리 질문
체크 예제의 stdout은 unavailable=save failed: java 한 줄입니다. 언체크 예제의 cause 출력은 원인 메시지를 읽은 것이며 예외 객체 전체의 스택·suppressed를 출력한 것은 아닙니다. throws Exception도 호출자의 catch/throws 의무를 없애지 않습니다.
이 예의 조건
- 체크/언체크는 독립 원문이고 예외 전체 진단 출력이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 변환된 예외와 보존된 원인을 함께 읽기 변환된 예외와 보존된 원인을 함께 읽기 CheckedExceptionBurden은 DatabaseException을 체크 BoardPersistenceException으로 감싸고 UncheckedInfrastructureTranslation은 DatabaseDriverException을 언체크 BoardSystemException으로 감싼다. 두 경우 모두 cause를 보존하지만 선언 의무와 경계 출력은 다르다. 공개 계약 · 체크와 언체크 변환된 예외와 보존된 원인을 함께 읽기 두 프로그램 모두 cause를 연결합니다. 체크 변환은 throws 선언을 요구하고 언체크 변환은 Facade를 지나 경계로 전달됩니다. 체크 변환 바깥으로 전달할 계약 DatabaseException → BoardPersistenceException 서비스의 save 는 변환된 체크 타입을 throws로 선언합니다. 원인과 경계 관찰 cause 메시지: connection refused 원인 메시지는 이 main에서 출력하지 않습니다. unavailable=save failed: java 언체크 변환 바깥으로 전달할 계약 DatabaseDriverException → BoardSystemException Facade는 catch/throws 없이 예외를 경계로 전달합니다. 원인과 경계 관찰 user=temporarily unavailable operation=publish-post cause=socket closed for 예외 처리 질문 체크 예제의 stdout은 unavailable=save failed: java 한 줄입니다. 언체크 예제의 cause 출력은 원인 메시지를 읽은 것이며 예외 객체 전체의 스택·suppressed를 출력한 것은 아닙니다. throws Exception 도 호출자의 catch/throws 의무를 없애지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원인 발생 저장소/드라이버의 실패를 받습니다. |
|
|
| 공개 예외로 변환 cause를 유지하며 서비스 예외를 던집니다. |
|
|
| main 안내 각 경계가 자신에게 필요한 문자열을 출력합니다. |
|
|
더 깊이: 원인 연결이 동작하는 방식
원인 연결은 Throwable에 들어 있는 기능입니다. Exception, RuntimeException 같은 표준 예외 클래스에는 (String message, Throwable cause) 생성자와 (Throwable cause) 생성자가 있습니다. 메시지 없이 원인만 넘기면 메시지는 원인의 toString(), 즉 java.lang.IllegalStateException: disk full처럼 원인의 클래스 이름과 메시지를 합친 문자열이 됩니다. 원인을 받는 생성자가 없는 예외라면 만든 뒤에 initCause()로 연결할 수 있습니다. 다만 원인은 한 번만 정할 수 있어서, 이미 원인이 정해진 예외에 다시 initCause()를 호출하면 IllegalStateException이 납니다.
getCause()는 원인 예외의 복사본이 아니라 원래 예외 객체 그 자체를 돌려줍니다. 그래서 원인의 타입, 메시지, 스택 트레이스, 원인의 원인까지 모두 그대로 남습니다. 예외가 여러 번 바뀌면 getCause()를 따라 사슬처럼 이어지고, printStackTrace()나 로깅 라이브러리는 이 사슬을 따라가며 Caused by:를 여러 번 출력합니다.
예외 계층과 변환을 설계하는 기준
예외 클래스를 새로 만들지는 호출자의 대응이 달라지는지를 보고 정합니다. 대응 방법이 같고 호출자에게 같은 의미로 전달되는 실패들은 부모 타입 하나로 묶습니다. 호출자가 다르게 대응해야 하거나, 대응에 필요한 추가 데이터가 있을 때만 자식 타입을 만듭니다. 타입은 다르지만 처리 코드가 완전히 같다면 부모 타입 catch나 예외 계층을 다룬 글에서 본 다중 catch(multi-catch)로 중복을 줄입니다.
예외 변환은 추상화 수준이 바뀌는 곳에서 합니다. 저장소의 예외를 서비스의 예외로, 외부 HTTP 클라이언트의 예외를 '외부 연동 실패' 예외로 바꾸는 식입니다. 메서드를 하나 지날 때마다 새 타입으로 감싸면 Caused by:만 길어지고 타입 수만 늘어날 뿐, 호출자가 얻는 정보는 없습니다. 다음과 같은 변환도 피해야 합니다.
- 원인을 넘기지 않는 변환: 장애의 실제 원인과 발생 위치가 사라집니다.
- 메시지만 그대로 옮기는 변환: 새 타입이 아무 의미도 더하지 않습니다.
- 모든 것을
RuntimeException하나로 바꾸는 변환: 호출자가 실패 종류를 구분할 수 없습니다.
검사 예외로 바꿀지 비검사 예외로 바꿀지는 팀의 규칙과 사용하는 프레임워크도 고려해서 정합니다. 예를 들어 웹 프레임워크가 처리되지 않은 비검사 예외를 한곳에서 받아 오류 응답으로 바꿔 주는 구조라면, 시스템 장애는 그 흐름에 맞춰 비검사 예외로 보내는 것이 자연스럽습니다. 반면 '제목이 비어 있음'처럼 사용자가 바로 고칠 수 있는 업무상 거절은 예외 대신 결과 값으로 돌려줄 수도 있습니다. 이 선택은 반환 코드와 결과 타입을 다룬 글의 기준과 같습니다.
정리
catch는 위에서부터 차례로 검사하고 처음 맞는 블록 하나만 실행합니다. 그래서 자식 타입을 위에, 부모 타입을 아래에 둡니다. 부모를 먼저 쓰면 뒤의 자식catch는 실행될 수 없으므로 컴파일 오류가 납니다.- 예외 타입을 나누면 실패 종류와 대응에 필요한 값을 한 객체에 담아 전달할 수 있습니다. 민감한 값은 예외에 넣지 않습니다.
- 계층이 바뀌는 곳에서 하위 계층의 예외를 상위 계층의 예외로 바꾸면, 호출자가 구현 세부에 묶이지 않고 중간 계층의
throws도 줄어듭니다. - 예외를 바꿀 때는 원래 예외를 원인으로 넘깁니다. 그래야
getCause()와Caused by:로 원래 실패를 추적할 수 있습니다. - 호출자가 실제로 대응할 수 있으면 검사 예외로, 중간 계층이 할 수 있는 일이 없고 공통 처리기만 다루면 비검사 예외로 바꿉니다.
연습 문제
- 파일 저장소의
FileStoreException과 클라우드 저장소의CloudStoreException을 호출자에게 그대로 노출하지 말고, 둘 다BoardArchiveException으로 바꿔 던지는ArchiveService를 작성하세요. 세 예외는 모두RuntimeException의 자식입니다. 서비스의 저장 방식(mode)이"file"이면FileStoreException("disk full")을, 그 밖의 값이면CloudStoreException("gateway timeout")을 던진다고 가정합니다.BoardArchiveException은 보관 작업 번호archiveId를 필드로 갖고, 생성자에서super("archive failed", cause)로 원래 예외를 연결합니다.main은mode="file",archiveId="A-17"과mode="cloud",archiveId="A-18"로 한 번씩 호출하고, 실패할 때마다failed=작업번호,cause=원인클래스이름형식으로 한 줄을 출력합니다. 원인의 메시지는 출력하지 않습니다. - 1번 해답의
main에mode="cloud",archiveId="A-20"인 호출을 하나 더 추가하면 무엇이 출력될지 예상하세요.main이 잡는 예외 타입이 바뀌는지도 답하세요. SpecificExceptionRecovery에서 처리 방식이 같다고 보고 첫 번째와 두 번째catch를catch (ConnectException | SendException error)하나로 합친 뒤, 그 블록 안에서error.address()를 호출하면 컴파일될지 답하고 이유를 설명하세요.
해설 보기
1번에서는 두 저장소 예외를 처리하는 코드가 같으므로 다중 catch로 한 번에 받아 바꿉니다.
public final class ExceptionTranslationExercise {
public static void main(String[] args) {
archive(new ArchiveService("file"), "A-17");
archive(new ArchiveService("cloud"), "A-18");
}
private static void archive(ArchiveService service, String id) {
try {
service.archive(id);
} catch (BoardArchiveException error) {
System.out.println("failed=" + error.archiveId()
+ ",cause=" + error.getCause().getClass().getSimpleName());
}
}
private record ArchiveService(String mode) {
void archive(String id) {
try {
if (mode.equals("file")) throw new FileStoreException("disk full");
throw new CloudStoreException("gateway timeout");
} catch (FileStoreException | CloudStoreException cause) {
throw new BoardArchiveException(id, cause);
}
}
}
private static final class FileStoreException extends RuntimeException {
FileStoreException(String message) { super(message); }
}
private static final class CloudStoreException extends RuntimeException {
CloudStoreException(String message) { super(message); }
}
private static final class BoardArchiveException extends RuntimeException {
private final String archiveId;
BoardArchiveException(String archiveId, Throwable cause) {
super("archive failed", cause);
this.archiveId = archiveId;
}
String archiveId() { return archiveId; }
}
}failed=A-17,cause=FileStoreException
failed=A-18,cause=CloudStoreExceptionmain이 아는 예외 타입은 BoardArchiveException 하나뿐이므로, 나중에 저장 방식이 바뀌거나 새 저장소가 추가되어도 main은 고칠 필요가 없습니다. 그러면서도 getCause()가 원래 예외 객체를 그대로 돌려주므로, 어느 저장소에서 실패했는지는 getClass().getSimpleName()으로 알 수 있습니다. archiveId는 어떤 요청이 실패했는지 추적하는 데 쓰고, 원인 예외는 왜 실패했는지 진단하는 데 씁니다.
저장소 원인은 남기고 archiveId로 연결하기
file/cloud 호출의 원래 저장 오류를 같은 공개 예외로 감싸 archiveId와 cause를 보존합니다. 발생·변환·경계 출력의 전환을 추적할 수 있습니다.
ExceptionTranslationExercise는 A-17의 FileStoreException과 A-18의 CloudStoreException을 BoardArchiveException으로 감싸고 두 archiveId와 실제 cause 타입명을 출력한다.
예외 변환 · 요청과 원인
저장소 원인은 남기고 archiveId로 연결하기
두 저장 방식은 같은 BoardArchiveException으로 전달합니다. 각 archiveId와 원래 cause의 타입은 함께 보존합니다.
mode="file" · A-17
- 연결된 cause
FileStoreException메시지:disk full- 경계에서 출력하는 한 줄
failed=A-17,cause=FileStoreException
mode="cloud" · A-18
- 연결된 cause
CloudStoreException메시지:gateway timeout- 경계에서 출력하는 한 줄
failed=A-18,cause=CloudStoreException
경계는 각 archiveId와 원인 타입명을 한 줄로 출력합니다. disk full과 gateway timeout 원인 메시지는 출력하지 않습니다. 원인 타입명을 사용자 공개 응답에 반드시 노출해야 한다는 의미는 아닙니다.
이 예의 조건
- 원 cause 메시지는 출력하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 3 | 첫 호출 · file |
| 15 | 다음 호출 · cloud |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 저장소 원인은 남기고 archiveId로 연결하기 저장소 원인은 남기고 archiveId로 연결하기 ExceptionTranslationExercise는 A-17의 FileStoreException과 A-18의 CloudStoreException을 BoardArchiveException으로 감싸고 두 archiveId와 실제 cause 타입명을 출력한다. 예외 변환 · 요청과 원인 저장소 원인은 남기고 archiveId로 연결하기 두 저장 방식은 같은 BoardArchiveException으로 전달합니다. 각 archiveId와 원래 cause의 타입은 함께 보존합니다. mode="file" · A-17 연결된 cause FileStoreException 메시지: disk full 경계에서 출력하는 한 줄 failed=A-17,cause=FileStoreException mode="cloud" · A-18 연결된 cause CloudStoreException 메시지: gateway timeout 경계에서 출력하는 한 줄 failed=A-18,cause=CloudStoreException 경계는 각 archiveId와 원인 타입명을 한 줄로 출력합니다. disk full과 gateway timeout 원인 메시지는 출력하지 않습니다. 원인 타입명을 사용자 공개 응답에 반드시 노출해야 한다는 의미는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 저장 실패 두 요청의 구현별 오류가 발생합니다. |
|
|
| 문맥 변환 각 오류를 별도 공개 예외 객체로 감쌉니다. |
|
|
| 경계 출력 각 요청의 ID와 cause 타입을 한 줄로 출력합니다. |
|
|
저장소 원인은 남기고 archiveId로 연결하기
file/cloud 호출의 원래 저장 오류를 같은 공개 예외로 감싸 archiveId와 cause를 보존합니다. 발생·변환·경계 출력의 전환을 추적할 수 있습니다.
ExceptionTranslationExercise는 A-17의 FileStoreException과 A-18의 CloudStoreException을 BoardArchiveException으로 감싸고 두 archiveId와 실제 cause 타입명을 출력한다.
예외 변환 · 요청과 원인
저장소 원인은 남기고 archiveId로 연결하기
두 저장 방식은 같은 BoardArchiveException으로 전달합니다. 각 archiveId와 원래 cause의 타입은 함께 보존합니다.
mode="file" · A-17
- 연결된 cause
FileStoreException메시지:disk full- 경계에서 출력하는 한 줄
failed=A-17,cause=FileStoreException
mode="cloud" · A-18
- 연결된 cause
CloudStoreException메시지:gateway timeout- 경계에서 출력하는 한 줄
failed=A-18,cause=CloudStoreException
경계는 각 archiveId와 원인 타입명을 한 줄로 출력합니다. disk full과 gateway timeout 원인 메시지는 출력하지 않습니다. 원인 타입명을 사용자 공개 응답에 반드시 노출해야 한다는 의미는 아닙니다.
이 예의 조건
- 원 cause 메시지는 출력하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 3 | 첫 호출 · file |
| 15 | 다음 호출 · cloud |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 저장소 원인은 남기고 archiveId로 연결하기 저장소 원인은 남기고 archiveId로 연결하기 ExceptionTranslationExercise는 A-17의 FileStoreException과 A-18의 CloudStoreException을 BoardArchiveException으로 감싸고 두 archiveId와 실제 cause 타입명을 출력한다. 예외 변환 · 요청과 원인 저장소 원인은 남기고 archiveId로 연결하기 두 저장 방식은 같은 BoardArchiveException으로 전달합니다. 각 archiveId와 원래 cause의 타입은 함께 보존합니다. mode="file" · A-17 연결된 cause FileStoreException 메시지: disk full 경계에서 출력하는 한 줄 failed=A-17,cause=FileStoreException mode="cloud" · A-18 연결된 cause CloudStoreException 메시지: gateway timeout 경계에서 출력하는 한 줄 failed=A-18,cause=CloudStoreException 경계는 각 archiveId와 원인 타입명을 한 줄로 출력합니다. disk full과 gateway timeout 원인 메시지는 출력하지 않습니다. 원인 타입명을 사용자 공개 응답에 반드시 노출해야 한다는 의미는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 저장 실패 두 요청의 구현별 오류가 발생합니다. |
|
|
| 문맥 변환 각 오류를 별도 공개 예외 객체로 감쌉니다. |
|
|
| 경계 출력 각 요청의 ID와 cause 타입을 한 줄로 출력합니다. |
|
|
2번에서 추가한 호출은 failed=A-20,cause=CloudStoreException을 출력합니다. archiveId는 요청마다 다르지만, main이 받는 예외 타입은 여전히 BoardArchiveException입니다. 공개된 예외 타입이 바뀌지 않으니 main의 catch도 그대로 동작합니다.
3번은 컴파일되지 않습니다. 다중 catch의 변수 error는 나열한 타입들의 가장 가까운 공통 부모, 여기서는 NetworkException 타입으로 취급됩니다. address()는 ConnectException에만 있는 메서드이므로 컴파일러는 cannot find symbol 오류를 냅니다. 실제로 전달된 객체가 ConnectException일 수도 있지만, SendException일 수도 있기 때문입니다. 이 예제처럼 타입마다 쓰는 값이 다르다면 처리 방식이 같은 것이 아니므로 catch를 따로 두어야 합니다. 다중 catch는 공통 부모의 기능만으로 처리가 끝날 때 씁니다.
다음 글
다음 글에서는 이 장에서 미뤄 둔 문제를 해결합니다. AutoCloseable과 try-with-resources로 자원을 자동으로 닫고, 정리 코드가 실패해도 원래 예외를 잃지 않는 억제된 예외(suppressed exception)를 다룹니다. 처리하지 못한 예외를 애플리케이션 맨 바깥의 공통 처리기에서 한 번에 다루는 방법도 봅니다.