안동민 개발노트

본문 시작

예외 계층화와 변환

부모 catch가 자식 catch를 가리는 컴파일 실패와 체크 예외 전파 부담을 살펴보고 구체 처리·부모 처리·예외 변환의 경계를 설계합니다.

예외를 여러 클래스로 나누는 목적은 이름을 늘리는 데 있지 않습니다.

호출자가 연결 실패와 전송 실패에 다른 대응을 할 수 있도록 필요한 데이터와 복구 범위를 타입으로 표현하는 것입니다.

상속을 사용하면 부모 계약 하나로 전달하면서도 경계에서는 구체 하위 타입을 선택할 수 있습니다.


catch 순서와 도달 불가능 코드

ConnectException은 NetworkException의 자식입니다.

부모가 이미 모든 자식 예외를 잡기 때문에 뒤의 구체 catch는 실행될 가능성이 없고 javac가 소스를 거부합니다.

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; }
    }
}
javac 핵심 진단
error: exception ConnectException has already been caught

catch는 위에서 아래로 검사합니다.

구체 예외를 먼저 처리하고 마지막에 부모 타입을 안전망으로 둡니다.

처리 방식이 동일하면 애초에 자식을 따로 잡지 않고 부모 하나만 잡는 편이 더 단순합니다.


구체 예외의 문맥 정보

문자열 errorCode를 검사하는 대신 각 예외가 자기 상황의 값을 보관합니다.

연결 실패는 주소, 전송 실패는 보낼 데이터를 제공하므로 catch가 문자열을 다시 파싱하지 않습니다.

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

예외 필드에는 비밀번호나 토큰 같은 민감 값을 그대로 넣지 않습니다.

운영 로그와 오류 응답을 거쳐 외부로 노출될 수 있기 때문입니다.

디버깅에 필요한 식별자와 안전한 문맥만 보관하고 비밀 값은 마스킹합니다.

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

부모 catch를 먼저 둔 원문은 컴파일 실패하며 실행 출력이 없다. 구체 catch를 먼저 둔 SpecificExceptionRecovery의 실제 두 호출은 address=primary와 data=create-post를 각 catch에서 사용한다.

컴파일 단계의 거부와 실행 중 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= 출력은 없습니다.


검사 예외의 계층 간 반복

저장소가 NetworkException, DatabaseException 같은 체크 예외를 던지고 서비스·파사드 어느 곳에서도 복구할 수 없다면 각 메서드가 같은 throws를 전달합니다.

구현체가 새 체크 예외를 추가할 때 중간 계약까지 연쇄 변경됩니다.

throws Exception은 구체 체크 예외를 넓은 선언에 포함하므로 변경을 알아보기 어렵게 하지만, 호출자의 catch 또는 throws 의무 자체를 없애지는 않습니다.

문자열 파싱처럼 실제로 처리해야 할 새 체크 예외를 실수로 놓쳐도 넓은 선언에 섞여 버립니다.

공개 계약은 간단해졌지만 중요한 구분 정보가 사라집니다.

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

서비스 경계에서 기술 예외를 안정된 업무 예외 하나로 바꾸었습니다.

호출자는 데이터베이스 제품과 연결 방식에 의존하지 않습니다.

이 체크 계약이 가치 있으려면 boundary가 재시도나 사용자 안내처럼 실제 처리를 해야 합니다.


인프라 예외의 비검사 변환

대부분의 중간 계층이 아무것도 할 수 없고 최상단 공통 처리기만 오류 응답과 운영 로그를 만든다면 언체크 예외가 반복 throws를 줄입니다.

변환할 때 원인을 반드시 연결하고, 사용자에게 보여 줄 메시지와 운영자 진단 정보를 구분합니다.

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 예외 처리 질문

중간 Facade에는 catch나 throws가 없습니다.

그래도 예외는 사라지지 않고 경계까지 전파됩니다.

경계는 안전한 사용자 문구와 진단 문맥을 따로 출력합니다.

실제 서버에서는 스택 추적을 포함해 로깅하고 요청 식별자로 사용자 응답과 연결합니다.

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

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

두 예제 모두 생성자의 cause 인수로 원인 객체를 연결합니다. 체크 여부는 전달 규칙을 바꾸며, 실패나 원인 자체를 없애지 않습니다.

변환된 예외와 보존된 원인을 함께 읽기
원문 변환바깥으로 전달할 계약원인과 경계 관찰
체크 변환
DatabaseException
→
BoardPersistenceException
서비스의 save는 변환된 체크 타입을 throws로 선언합니다.
cause 메시지: connection refused
원인 메시지는 이 main에서 출력하지 않습니다.
stdout: unavailable=
save failed: java
언체크 변환
DatabaseDriverException
→
BoardSystemException
Facade는 catch/throws 없이 예외를 경계로 전달합니다.
user=temporarily unavailable
operation=publish-post
cause=socket closed for 예외 처리 질문
체크 변환
바깥으로 전달할 계약:
DatabaseException
→
BoardPersistenceException
서비스의 save는 변환된 체크 타입을 throws로 선언합니다.
원인과 경계 관찰:
cause 메시지: connection refused
원인 메시지는 이 main에서 출력하지 않습니다.
stdout: 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 의무를 없애지 않습니다.


예외 계층과 변환 기준

같은 복구 정책과 같은 공개 의미를 가진 실패는 부모 타입으로 묶습니다.

호출자가 다른 대응을 해야 하거나 추가 데이터가 필요할 때만 자식 타입을 만듭니다.

타입이 달라도 처리 코드가 완전히 같다면 multi-catch나 부모 catch가 중복을 줄입니다.

예외 변환은 추상화 경계에서 수행합니다.

repository 예외를 service 예외로, HTTP 클라이언트 예외를 외부 연동 예외로 바꾸는 식입니다.

메서드마다 새로운 타입으로 감싸면 스택이 불필요하게 깊어지고 타입 수만 늘어납니다.

원인 예외를 잃는 변환, 같은 메시지만 반복하는 변환, 모든 것을 RuntimeException 하나로 만드는 변환은 진단성과 의미를 낮춥니다.

체크/언체크 선택은 팀 규칙과 프레임워크 경계도 고려합니다.

웹 프레임워크가 언체크 예외를 중앙에서 응답으로 바꾸는 구조라면 시스템 실패는 그 흐름에 맞추고, 사용자가 즉시 수정 가능한 업무 거절은 명시 결과로 돌려줄 수 있습니다.


연습 문제

FileStoreException과 CloudStoreException을 호출자에게 노출하지 말고 둘 다 BoardArchiveException으로 변환하세요.

원인 타입은 유지하고, 경계에서는 archiveId와 진단용 원인 타입명만 출력합니다.

해설 보기
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

서비스의 공개 계약은 저장 방식이 바뀌어도 유지됩니다.

원인 연결은 운영 진단에 쓰고 archiveId는 요청 추적에 사용합니다.

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

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

두 경로는 같은 BoardArchiveException으로 전달되지만 원래 예외 타입과 archiveId는 각각 유지됩니다.

저장소 원인은 남기고 archiveId로 연결하기
원문 입력연결된 cause경계에서 출력하는 한 줄
mode="file" · A-17
FileStoreException
메시지: disk full
failed=A-17,
cause=FileStoreException
mode="cloud" · A-18
CloudStoreException
메시지: gateway timeout
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

각 출력 셀의 두 조각은 실제 stdout에서는 쉼표로 이어진 한 줄입니다. 원문 경계는 archiveId와 원인 타입명을 출력하고, disk full·gateway timeout 메시지는 출력하지 않습니다. 원인 타입명을 사용자 공개 응답에 반드시 노출해야 한다는 의미는 아닙니다.