예외 계층화와 변환
부모 catch가 자식 catch를 가리는 컴파일 실패와 체크 예외 전파 부담을 살펴보고 구체 처리·부모 처리·예외 변환의 경계를 설계합니다.
예외를 여러 클래스로 나누는 목적은 이름을 늘리는 데 있지 않습니다. 호출자가 연결 실패와 전송 실패에 다른 대응을 할 수 있도록 필요한 데이터와 복구 범위를 타입으로 표현하는 것입니다. 상속을 사용하면 부모 계약 하나로 전달하면서도 경계에서는 구체 하위 타입을 선택할 수 있습니다.
catch 순서와 도달 불가능 코드
ConnectException은 NetworkException의 자식입니다.
부모가 이미 모든 자식 예외를 잡기 때문에 뒤의 구체 catch는 실행될 가능성이 없고 javac가 소스를 거부합니다.
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("study.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; }
}
}error: exception ConnectException has already been caughtcatch는 위에서 아래로 검사합니다. 구체 예외를 먼저 처리하고 마지막에 부모 타입을 안전망으로 둡니다. 처리 방식이 동일하면 애초에 자식을 따로 잡지 않고 부모 하나만 잡는 편이 더 단순합니다.
구체 예외의 문맥 정보
문자열 errorCode를 검사하는 대신 각 예외가 자기 상황의 값을 보관합니다. 연결 실패는 주소, 전송 실패는 보낼 데이터를 제공하므로 catch가 문자열을 다시 파싱하지 않습니다.
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("chapter12");
} 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=chapter12예외 필드에는 비밀번호나 토큰 같은 민감 값을 그대로 넣지 않습니다. 운영 로그와 오류 응답을 거쳐 외부로 노출될 수 있기 때문입니다. 디버깅에 필요한 식별자와 안전한 문맥만 보관하고 비밀 값은 마스킹합니다.
검사 예외의 계층 간 반복
저장소가 NetworkException, DatabaseException 같은 체크 예외를 던지고 서비스·파사드 어느 곳에서도 복구할 수 없다면 각 메서드가 같은 throws를 전달합니다.
구현체가 새 체크 예외를 추가할 때 중간 계약까지 연쇄 변경됩니다.
그렇다고 throws Exception으로 한꺼번에 덮으면 컴파일러의 구체 체크가 무력해집니다.
문자열 파싱처럼 실제로 처리해야 할 새 체크 예외를 실수로 놓쳐도 넓은 선언에 섞여 버립니다.
공개 계약은 간단해졌지만 중요한 구분 정보가 사라집니다.
public final class CheckedExceptionBurden {
public static void main(String[] args) {
ApiBoundary boundary = new ApiBoundary(new StudyService());
boundary.handle("java");
}
private record ApiBoundary(StudyService service) {
void handle(String topic) {
try {
service.save(topic);
} catch (StudyPersistenceException error) {
System.out.println("unavailable=" + error.getMessage());
}
}
}
private static final class StudyService {
void save(String topic) throws StudyPersistenceException {
try {
repositoryWrite(topic);
} catch (DatabaseException cause) {
throw new StudyPersistenceException("save failed: " + topic, cause);
}
}
private void repositoryWrite(String topic) throws DatabaseException {
throw new DatabaseException("connection refused");
}
}
private static final class DatabaseException extends Exception {
DatabaseException(String message) { super(message); }
}
private static final class StudyPersistenceException extends Exception {
StudyPersistenceException(String message, Throwable cause) { super(message, cause); }
}
}unavailable=save failed: java서비스 경계에서 기술 예외를 안정된 업무 예외 하나로 바꾸었습니다. 호출자는 데이터베이스 제품과 연결 방식에 의존하지 않습니다. 이 체크 계약이 가치 있으려면 boundary가 재시도나 사용자 안내처럼 실제 처리를 해야 합니다.
인프라 예외의 비검사 변환
대부분의 중간 계층이 아무것도 할 수 없고 최상단 공통 처리기만 오류 응답과 운영 로그를 만든다면 언체크 예외가 반복 throws를 줄입니다. 변환할 때 원인을 반드시 연결하고, 사용자에게 보여 줄 메시지와 운영자 진단 정보를 구분합니다.
public final class UncheckedInfrastructureTranslation {
public static void main(String[] args) {
try {
new StudyFacade().complete("exceptions");
} catch (StudySystemException error) {
System.out.println("user=temporarily unavailable");
System.out.println("operation=" + error.operation());
System.out.println("cause=" + error.getCause().getMessage());
}
}
private static final class StudyFacade {
void complete(String topic) { new StudyService().complete(topic); }
}
private static final class StudyService {
void complete(String topic) {
try {
writeDatabase(topic);
} catch (DatabaseDriverException cause) {
throw new StudySystemException("complete-study", cause);
}
}
private void writeDatabase(String topic) {
throw new DatabaseDriverException("socket closed for " + topic);
}
}
private static final class DatabaseDriverException extends RuntimeException {
DatabaseDriverException(String message) { super(message); }
}
private static final class StudySystemException extends RuntimeException {
private final String operation;
StudySystemException(String operation, Throwable cause) {
super("system failure", cause);
this.operation = operation;
}
String operation() { return operation; }
}
}user=temporarily unavailable
operation=complete-study
cause=socket closed for exceptions중간 Facade에는 catch나 throws가 없습니다. 그래도 예외는 사라지지 않고 경계까지 전파됩니다. 경계는 안전한 사용자 문구와 진단 문맥을 따로 출력합니다. 실제 서버에서는 스택 추적을 포함해 로깅하고 요청 식별자로 사용자 응답과 연결합니다.
예외 계층과 변환 기준
같은 복구 정책과 같은 공개 의미를 가진 실패는 부모 타입으로 묶습니다. 호출자가 다른 대응을 해야 하거나 추가 데이터가 필요할 때만 자식 타입을 만듭니다. 타입이 달라도 처리 코드가 완전히 같다면 multi-catch나 부모 catch가 중복을 줄입니다.
예외 변환은 추상화 경계에서 수행합니다.
repository 예외를 service 예외로, HTTP 클라이언트 예외를 외부 연동 예외로 바꾸는 식입니다.
메서드마다 새로운 타입으로 감싸면 스택이 불필요하게 깊어지고 타입 수만 늘어납니다.
원인 예외를 잃는 변환, 같은 메시지만 반복하는 변환, 모든 것을 RuntimeException 하나로 만드는 변환은 진단성과 의미를 낮춥니다.
체크/언체크 선택은 팀 규칙과 프레임워크 경계도 고려합니다. 웹 프레임워크가 언체크 예외를 중앙에서 응답으로 바꾸는 구조라면 시스템 실패는 그 흐름에 맞추고, 사용자가 즉시 수정 가능한 업무 거절은 명시 결과로 돌려줄 수 있습니다.
연습 문제
FileStoreException과 CloudStoreException을 호출자에게 노출하지 말고 둘 다 StudyArchiveException으로 변환하세요.
원인 타입은 유지하고, 경계에서는 archiveId만 안전하게 출력합니다.
해설 보기
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 (StudyArchiveException 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 StudyArchiveException(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 StudyArchiveException extends RuntimeException {
private final String archiveId;
StudyArchiveException(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는 요청 추적에 사용합니다.