본문 바로가기

예외 클래스 계층 설계와 예외 변환

예외를 부모·자식 클래스로 나눠 catch 순서를 바르게 정하는 방법과, 하위 계층의 예외를 원인을 유지한 채 상위 계층의 예외로 바꾸는 예외 변환을 배웁니다.

앞 글에서는 연결 실패와 전송 실패를 ConnectException과 SendException으로 나누고, 둘 다 NetworkException을 상속하게 했습니다. 예외 클래스를 이렇게 여러 개로 나누는 목적은 이름을 늘리는 데 있지 않습니다. 호출자가 실패 종류마다 다르게 대응해야 할 때, 그 구분과 대응에 필요한 값을 타입으로 전달하려는 것입니다. 그리고 공통 부모가 있으면 메서드 선언에는 throws NetworkException 하나만 적어 두고, 받는 쪽에서는 필요한 만큼만 자식 타입으로 나눠 잡을 수 있습니다.

catch 순서 규칙과 예외 변환(exception translation)은 예외 계층을 다룬 글에서 이미 한 번 보았습니다. 이 글에서는 그 규칙을 직접 만든 예외 계층에 적용합니다. 예외 클래스를 언제 나누고 무엇을 담을지, 예외가 여러 계층을 지나갈 때 어디서 어떻게 바꿔야 하는지가 주제입니다.

직접 만든 예외도 자식 catch를 먼저 쓴다

예외 계층을 다룬 글에서는 catch (Exception error) 뒤에 catch (NumberFormatException error)를 써서 컴파일 오류가 나는 것을 보았습니다. 직접 만든 예외 계층에도 같은 규칙이 적용됩니다. 다음 코드에서 ConnectException은 NetworkException의 자식인데, catch를 부모, 자식 순서로 썼습니다.

lab/BroadCatchBeforeSpecificFailure.java
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을 던집니다.

src/SpecificExceptionRecovery.java
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-post

run("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를 먼저 둔 원문은 컴파일 실패하며 실행 출력이 없다. 구체 catch를 먼저 둔 SpecificExceptionRecovery의 실제 두 호출은 address=primary와 data=create-post를 각 catch에서 사용한다.

catch 순서 · 컴파일과 실행

catch 순서의 컴파일 실패와 실제 선택

부모 catch가 먼저인 원문은 컴파일 실패합니다. 구체 catch를 먼저 둔 실행 예제는 입력에 맞는 한 분기를 선택합니다.

컴파일 단계의 catch 순서와 실행 단계의 선택 첫 영역은 부모 catch가 뒤 구체 catch를 가리는 별도 원문의 컴파일 검사이며 실행 출력이 없습니다. 나머지 두 영역은 SpecificExceptionRecovery의 실제 connect와 send 호출입니다. 각 실제 타입과 일치하는 한 catch만 실행하고 부모 catch로 이어지지 않습니다. alternate-host-for=primary는 원문 한 줄입니다. 별도 원문 · 컴파일 검사 부모 catch가 먼저 NetworkException 뒤 자식 catch 가림 ConnectException 컴파일러가 거부 실행 출력 없음 원문 실행 프로그램 아님 실행 원문 · connect 입력 실제 자식 예외 ConnectException 첫 구체 catch 선택 address=primary 해당 정책의 출력 alternate-host-for= primary 실행 원문 · send 입력 실제 자식 예외 SendException 둘째 구체 catch 선택 data=create-post 해당 정책의 출력 keep-draft=create-post 첫 영역의 화살표는 검사 순서 · 나머지는 실제 catch와 출력 · 뒤 부모 catch도 실행되지 않음
컴파일 단계의 catch 순서와 실행 단계의 선택 첫 영역은 부모 catch가 뒤 구체 catch를 가리는 별도 원문의 컴파일 검사이며 실행 출력이 없습니다. 나머지 두 영역은 SpecificExceptionRecovery의 실제 connect와 send 호출입니다. 각 실제 타입과 일치하는 한 catch만 실행하고 부모 catch로 이어지지 않습니다. alternate-host-for=primary는 원문 한 줄입니다. 별도 원문 · 컴파일 검사 부모 catch가 먼저 NetworkException 뒤 자식 catch 가림 ConnectException 컴파일러가 거부 실행 출력 없음 원문 실행 프로그램 아님 실행 원문 · connect 입력 실제 자식 예외 ConnectException 첫 구체 catch 선택 address=primary 해당 정책의 출력 alternate-host-for= primary 실행 원문 · send 입력 실제 자식 예외 SendException 둘째 구체 catch 선택 data=create-post 해당 정책의 출력 keep-draft=create-post 첫 영역의 화살표는 검사 순서 나머지는 실제 catch와 출력 뒤 부모 catch도 실행되지 않음
catch 순서의 컴파일 실패와 실제 선택
원문 사례선택 또는 거부 이유관찰
부모 catch가 먼저NetworkException이 ConnectException까지 잡으므로 뒤의 자식 catch는 허용되지 않습니다.컴파일 실패. 프로그램 출력 없음.
run("connect")SpecificExceptionRecovery의 첫 catch가 ConnectException의 주소를 받습니다.
alternate-host-for=primary
run("send")두 번째 catch가 SendException의 데이터를 받습니다. 마지막 부모 catch로 이어서 실행하지 않습니다.
keep-draft=create-post
부모 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)으로 넘겨 줍니다.

src/CheckedExceptionBurden.java
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: java

repositoryWrite()가 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 사이에 계층을 하나 더 두고, 이때도 원인을 연결한 채 사용자에게 보여 줄 문구와 운영자가 볼 진단 정보를 나눠 출력합니다.

src/UncheckedInfrastructureTranslation.java
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를 지나 경계로 전달됩니다.

원인을 보존하는 두 공개 예외 계약 별도 원문의 체크 BoardPersistenceException 변환은 DatabaseException cause를 보존하며 호출자의 catch 또는 throws 의무와 연결됩니다. 언체크 BoardSystemException 변환은 DatabaseDriverException cause를 보존하고 Facade를 통과합니다. 실선은 변환과 전파의 순서입니다. 출력 필드를 줄 나눠 그려도 체크 원문은 한 줄, 언체크 원문은 세 줄이며 예외 객체 전체의 진단 출력은 아닙니다. 별도 원문 · 체크 변환 저장소의 원래 실패 DatabaseException connection refused 서비스의 새 예외 BoardPersistenceException 원래 객체를 cause로 보존 선언된 전달 계약 save의 throws 선언 호출자는 catch 또는 throws main의 한 줄 출력 unavailable= save failed: java 별도 원문 · 언체크 변환 드라이버의 원래 실패 DatabaseDriverException socket closed for 예외 처리 질문 서비스의 새 예외 BoardSystemException 원래 객체를 cause로 보존 Facade를 통과 catch·throws 없음 전달 자체는 계속됨 main 경계의 세 줄 user= temporarily unavailable operation=publish-post cause=socket closed for 예외 처리 질문 실선은 새 예외 변환·전달 · 체크 여부는 선언 의무와 관계 · 원인·스택 전체를 출력하진 않음
원인을 보존하는 두 공개 예외 계약 별도 원문의 체크 BoardPersistenceException 변환은 DatabaseException cause를 보존하며 호출자의 catch 또는 throws 의무와 연결됩니다. 언체크 BoardSystemException 변환은 DatabaseDriverException cause를 보존하고 Facade를 통과합니다. 실선은 변환과 전파의 순서입니다. 출력 필드를 줄 나눠 그려도 체크 원문은 한 줄, 언체크 원문은 세 줄이며 예외 객체 전체의 진단 출력은 아닙니다. 별도 원문 · 체크 변환 저장소의 원래 실패 DatabaseException connection refused 서비스의 새 예외 BoardPersistenceException 원래 객체를 cause로 보존 선언된 전달 계약 save의 throws 선언 호출자는 catch 또는 throws main의 한 줄 출력 unavailable= save failed: java 별도 원문 · 언체크 변환 드라이버의 원래 실패 DatabaseDriverException socket closed for 예외 처리 질문 서비스의 새 예외 BoardSystemException 원래 객체를 cause로 보존 Facade를 통과 catch·throws 없음 전달 자체는 계속됨 main 경계의 세 줄 user= temporarily unavailable operation=publish-post cause=socket closed for 예외 처리 질문 실선은 새 예외 변환·전달 체크 여부는 선언 의무와 관계 원인·스택 전체를 출력하진 않음

체크 변환

바깥으로 전달할 계약
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 의무를 없애지 않습니다.

이 예의 조건

  • 체크/언체크는 독립 원문이고 예외 전체 진단 출력이 아니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보변환된 예외와 보존된 원인을 함께 읽기 변환된 예외와 보존된 원인을 함께 읽기 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 의무를 없애지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원인 발생

저장소/드라이버의 실패를 받습니다.

  • 체크: DatabaseException:connection refused
  • 언체크: DatabaseDriverException:socket closed
  • 구현의 원인을 공개 경계에서 처리한다.
공개 예외로 변환

cause를 유지하며 서비스 예외를 던집니다.

  • 체크: BoardPersistenceException / catch·throws 의무
  • 언체크: BoardSystemException / Facade 통과
  • 선언 의무가 달라도 실제 예외 전달은 이어진다.
main 안내

각 경계가 자신에게 필요한 문자열을 출력합니다.

  • 체크: unavailable=save failed: java
  • 언체크: user=temporarily unavailable; operation=publish-post; cause=socket closed for 예외 처리 질문
  • 원인 보존과 출력한 정보의 범위는 다른 계약이다.

변환된 예외와 보존된 원인을 함께 읽기

독립 두 프로그램에서 원래 실패가 서비스·Facade/호출자를 거쳐 main으로 전파됩니다. 선언 의무와 실행 중 전파를 구분하는 실제 예외 경로입니다.

변환된 예외와 보존된 원인을 함께 읽기

CheckedExceptionBurden은 DatabaseException을 체크 BoardPersistenceException으로 감싸고 UncheckedInfrastructureTranslation은 DatabaseDriverException을 언체크 BoardSystemException으로 감싼다. 두 경우 모두 cause를 보존하지만 선언 의무와 경계 출력은 다르다.

공개 계약 · 체크와 언체크

변환된 예외와 보존된 원인을 함께 읽기

두 프로그램 모두 cause를 연결합니다. 체크 변환은 throws 선언을 요구하고 언체크 변환은 Facade를 지나 경계로 전달됩니다.

원인을 보존하는 두 공개 예외 계약 별도 원문의 체크 BoardPersistenceException 변환은 DatabaseException cause를 보존하며 호출자의 catch 또는 throws 의무와 연결됩니다. 언체크 BoardSystemException 변환은 DatabaseDriverException cause를 보존하고 Facade를 통과합니다. 실선은 변환과 전파의 순서입니다. 출력 필드를 줄 나눠 그려도 체크 원문은 한 줄, 언체크 원문은 세 줄이며 예외 객체 전체의 진단 출력은 아닙니다. 별도 원문 · 체크 변환 저장소의 원래 실패 DatabaseException connection refused 서비스의 새 예외 BoardPersistenceException 원래 객체를 cause로 보존 선언된 전달 계약 save의 throws 선언 호출자는 catch 또는 throws main의 한 줄 출력 unavailable= save failed: java 별도 원문 · 언체크 변환 드라이버의 원래 실패 DatabaseDriverException socket closed for 예외 처리 질문 서비스의 새 예외 BoardSystemException 원래 객체를 cause로 보존 Facade를 통과 catch·throws 없음 전달 자체는 계속됨 main 경계의 세 줄 user= temporarily unavailable operation=publish-post cause=socket closed for 예외 처리 질문 실선은 새 예외 변환·전달 · 체크 여부는 선언 의무와 관계 · 원인·스택 전체를 출력하진 않음
원인을 보존하는 두 공개 예외 계약 별도 원문의 체크 BoardPersistenceException 변환은 DatabaseException cause를 보존하며 호출자의 catch 또는 throws 의무와 연결됩니다. 언체크 BoardSystemException 변환은 DatabaseDriverException cause를 보존하고 Facade를 통과합니다. 실선은 변환과 전파의 순서입니다. 출력 필드를 줄 나눠 그려도 체크 원문은 한 줄, 언체크 원문은 세 줄이며 예외 객체 전체의 진단 출력은 아닙니다. 별도 원문 · 체크 변환 저장소의 원래 실패 DatabaseException connection refused 서비스의 새 예외 BoardPersistenceException 원래 객체를 cause로 보존 선언된 전달 계약 save의 throws 선언 호출자는 catch 또는 throws main의 한 줄 출력 unavailable= save failed: java 별도 원문 · 언체크 변환 드라이버의 원래 실패 DatabaseDriverException socket closed for 예외 처리 질문 서비스의 새 예외 BoardSystemException 원래 객체를 cause로 보존 Facade를 통과 catch·throws 없음 전달 자체는 계속됨 main 경계의 세 줄 user= temporarily unavailable operation=publish-post cause=socket closed for 예외 처리 질문 실선은 새 예외 변환·전달 체크 여부는 선언 의무와 관계 원인·스택 전체를 출력하진 않음

체크 변환

바깥으로 전달할 계약
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 의무를 없애지 않습니다.

이 예의 조건

  • 체크/언체크는 독립 원문이고 예외 전체 진단 출력이 아니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보변환된 예외와 보존된 원인을 함께 읽기 변환된 예외와 보존된 원인을 함께 읽기 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 의무를 없애지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원인 발생

저장소/드라이버의 실패를 받습니다.

  • 체크: DatabaseException:connection refused
  • 언체크: DatabaseDriverException:socket closed
  • 구현의 원인을 공개 경계에서 처리한다.
공개 예외로 변환

cause를 유지하며 서비스 예외를 던집니다.

  • 체크: BoardPersistenceException / catch·throws 의무
  • 언체크: BoardSystemException / Facade 통과
  • 선언 의무가 달라도 실제 예외 전달은 이어진다.
main 안내

각 경계가 자신에게 필요한 문자열을 출력합니다.

  • 체크: unavailable=save failed: java
  • 언체크: user=temporarily unavailable; operation=publish-post; cause=socket closed for 예외 처리 질문
  • 원인 보존과 출력한 정보의 범위는 다른 계약이다.

더 깊이: 원인 연결이 동작하는 방식

원인 연결은 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:로 원래 실패를 추적할 수 있습니다.
  • 호출자가 실제로 대응할 수 있으면 검사 예외로, 중간 계층이 할 수 있는 일이 없고 공통 처리기만 다루면 비검사 예외로 바꿉니다.

연습 문제

  1. 파일 저장소의 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=원인클래스이름 형식으로 한 줄을 출력합니다. 원인의 메시지는 출력하지 않습니다.
  2. 1번 해답의 main에 mode="cloud", archiveId="A-20"인 호출을 하나 더 추가하면 무엇이 출력될지 예상하세요. main이 잡는 예외 타입이 바뀌는지도 답하세요.
  3. SpecificExceptionRecovery에서 처리 방식이 같다고 보고 첫 번째와 두 번째 catch를 catch (ConnectException | SendException error) 하나로 합친 뒤, 그 블록 안에서 error.address()를 호출하면 컴파일될지 답하고 이유를 설명하세요.
해설 보기

1번에서는 두 저장소 예외를 처리하는 코드가 같으므로 다중 catch로 한 번에 받아 바꿉니다.

src/ExceptionTranslationExercise.java
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=CloudStoreException

main이 아는 예외 타입은 BoardArchiveException 하나뿐이므로, 나중에 저장 방식이 바뀌거나 새 저장소가 추가되어도 main은 고칠 필요가 없습니다. 그러면서도 getCause()가 원래 예외 객체를 그대로 돌려주므로, 어느 저장소에서 실패했는지는 getClass().getSimpleName()으로 알 수 있습니다. archiveId는 어떤 요청이 실패했는지 추적하는 데 쓰고, 원인 예외는 왜 실패했는지 진단하는 데 씁니다.

저장소 원인은 남기고 archiveId로 연결하기

file/cloud 호출의 원래 저장 오류를 같은 공개 예외로 감싸 archiveId와 cause를 보존합니다. 발생·변환·경계 출력의 전환을 추적할 수 있습니다.

저장소 원인은 남기고 archiveId로 연결하기

ExceptionTranslationExercise는 A-17의 FileStoreException과 A-18의 CloudStoreException을 BoardArchiveException으로 감싸고 두 archiveId와 실제 cause 타입명을 출력한다.

예외 변환 · 요청과 원인

저장소 원인은 남기고 archiveId로 연결하기

두 저장 방식은 같은 BoardArchiveException으로 전달합니다. 각 archiveId와 원래 cause의 타입은 함께 보존합니다.

같은 공개 타입에 요청 ID와 원인을 보존 file의 A-17과 cloud의 A-18은 각 저장소 실패를 별도 BoardArchiveException 객체로 감싸며 archiveId와 원래 cause를 보존합니다. 실선은 새 문맥 변환과 경계 전달입니다. 각 출력 상자에 나누어 쓴 failed와 cause 필드는 원문 stdout에서 한 줄이며 원인 메시지는 출력하지 않습니다. 첫 호출 · file / A-17 원래 파일 저장 실패 FileStoreException disk full 요청 문맥을 추가 BoardArchiveException archiveId=A-17 cause는 원래 객체 경계의 한 줄 출력 failed=A-17, cause=FileStoreException 다음 호출 · cloud / A-18 원래 클라우드 실패 CloudStoreException gateway timeout 같은 공개 예외 BoardArchiveException archiveId=A-18 cause는 원래 객체 경계의 한 줄 출력 failed=A-18, cause= CloudStoreException 실선은 문맥 변환·경계 전달 · 각 출력 박스의 내용은 한 줄 · 원인 메시지는 출력하지 않음
같은 공개 타입에 요청 ID와 원인을 보존 file의 A-17과 cloud의 A-18은 각 저장소 실패를 별도 BoardArchiveException 객체로 감싸며 archiveId와 원래 cause를 보존합니다. 실선은 새 문맥 변환과 경계 전달입니다. 각 출력 상자에 나누어 쓴 failed와 cause 필드는 원문 stdout에서 한 줄이며 원인 메시지는 출력하지 않습니다. 첫 호출 · file / A-17 원래 파일 저장 실패 FileStoreException disk full 요청 문맥을 추가 BoardArchiveException archiveId=A-17 cause는 원래 객체 경계의 한 줄 출력 failed=A-17, cause=FileStoreException 다음 호출 · cloud / A-18 원래 클라우드 실패 CloudStoreException gateway timeout 같은 공개 예외 BoardArchiveException archiveId=A-18 cause는 원래 객체 경계의 한 줄 출력 failed=A-18, cause= CloudStoreException 실선은 문맥 변환·경계 전달 각 출력 박스의 내용은 한 줄 원인 메시지는 출력하지 않음

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 원인 메시지는 출력하지 않습니다. 원인 타입명을 사용자 공개 응답에 반드시 노출해야 한다는 의미는 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
저장 실패

두 요청의 구현별 오류가 발생합니다.

  • file: A-17 / FileStoreException:disk full
  • cloud: A-18 / CloudStoreException:gateway timeout
  • 같은 공개 역할에도 구현의 실패 원인이 다르다.
문맥 변환

각 오류를 별도 공개 예외 객체로 감쌉니다.

  • 타입: BoardArchiveException
  • 추가 문맥: archiveId
  • cause: 원 객체 유지
  • 경계가 요청 식별자와 원인 타입을 함께 보유한다.
경계 출력

각 요청의 ID와 cause 타입을 한 줄로 출력합니다.

  • file: failed=A-17,cause=FileStoreException
  • cloud: failed=A-18,cause=CloudStoreException
  • 동일 공개 타입으로 처리하면서 원래 실패를 구별한다.

저장소 원인은 남기고 archiveId로 연결하기

file/cloud 호출의 원래 저장 오류를 같은 공개 예외로 감싸 archiveId와 cause를 보존합니다. 발생·변환·경계 출력의 전환을 추적할 수 있습니다.

저장소 원인은 남기고 archiveId로 연결하기

ExceptionTranslationExercise는 A-17의 FileStoreException과 A-18의 CloudStoreException을 BoardArchiveException으로 감싸고 두 archiveId와 실제 cause 타입명을 출력한다.

예외 변환 · 요청과 원인

저장소 원인은 남기고 archiveId로 연결하기

두 저장 방식은 같은 BoardArchiveException으로 전달합니다. 각 archiveId와 원래 cause의 타입은 함께 보존합니다.

같은 공개 타입에 요청 ID와 원인을 보존 file의 A-17과 cloud의 A-18은 각 저장소 실패를 별도 BoardArchiveException 객체로 감싸며 archiveId와 원래 cause를 보존합니다. 실선은 새 문맥 변환과 경계 전달입니다. 각 출력 상자에 나누어 쓴 failed와 cause 필드는 원문 stdout에서 한 줄이며 원인 메시지는 출력하지 않습니다. 첫 호출 · file / A-17 원래 파일 저장 실패 FileStoreException disk full 요청 문맥을 추가 BoardArchiveException archiveId=A-17 cause는 원래 객체 경계의 한 줄 출력 failed=A-17, cause=FileStoreException 다음 호출 · cloud / A-18 원래 클라우드 실패 CloudStoreException gateway timeout 같은 공개 예외 BoardArchiveException archiveId=A-18 cause는 원래 객체 경계의 한 줄 출력 failed=A-18, cause= CloudStoreException 실선은 문맥 변환·경계 전달 · 각 출력 박스의 내용은 한 줄 · 원인 메시지는 출력하지 않음
같은 공개 타입에 요청 ID와 원인을 보존 file의 A-17과 cloud의 A-18은 각 저장소 실패를 별도 BoardArchiveException 객체로 감싸며 archiveId와 원래 cause를 보존합니다. 실선은 새 문맥 변환과 경계 전달입니다. 각 출력 상자에 나누어 쓴 failed와 cause 필드는 원문 stdout에서 한 줄이며 원인 메시지는 출력하지 않습니다. 첫 호출 · file / A-17 원래 파일 저장 실패 FileStoreException disk full 요청 문맥을 추가 BoardArchiveException archiveId=A-17 cause는 원래 객체 경계의 한 줄 출력 failed=A-17, cause=FileStoreException 다음 호출 · cloud / A-18 원래 클라우드 실패 CloudStoreException gateway timeout 같은 공개 예외 BoardArchiveException archiveId=A-18 cause는 원래 객체 경계의 한 줄 출력 failed=A-18, cause= CloudStoreException 실선은 문맥 변환·경계 전달 각 출력 박스의 내용은 한 줄 원인 메시지는 출력하지 않음

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 원인 메시지는 출력하지 않습니다. 원인 타입명을 사용자 공개 응답에 반드시 노출해야 한다는 의미는 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
저장 실패

두 요청의 구현별 오류가 발생합니다.

  • file: A-17 / FileStoreException:disk full
  • cloud: A-18 / CloudStoreException:gateway timeout
  • 같은 공개 역할에도 구현의 실패 원인이 다르다.
문맥 변환

각 오류를 별도 공개 예외 객체로 감쌉니다.

  • 타입: BoardArchiveException
  • 추가 문맥: archiveId
  • cause: 원 객체 유지
  • 경계가 요청 식별자와 원인 타입을 함께 보유한다.
경계 출력

각 요청의 ID와 cause 타입을 한 줄로 출력합니다.

  • file: failed=A-17,cause=FileStoreException
  • cloud: failed=A-18,cause=CloudStoreException
  • 동일 공개 타입으로 처리하면서 원래 실패를 구별한다.

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)를 다룹니다. 처리하지 못한 예외를 애플리케이션 맨 바깥의 공통 처리기에서 한 번에 다루는 방법도 봅니다.