공통 예외 처리와 자원 정리
본문과 close가 함께 실패할 때 원인이 가려지는 문제를 관찰하고 AutoCloseable, 억제 예외, 역순 정리, 경계 로깅으로 자원 수명을 완성합니다.
처리할 수 없는 언체크 예외는 여러 중간 계층이 각각 잡기보다 애플리케이션 경계에서 한 번 처리하는 편이 일관됩니다. 자원 정리는 그 경계까지 기다릴 수 없으므로 자원을 확보한 가장 가까운 코드에서 구조적으로 보장해야 합니다. try-with-resources는 이 두 책임을 분리하면서 close 실패 정보도 보존합니다.
본문 예외와 자원 정리 예외
본문의 전송 오류가 주된 실패인데 close도 예외를 던집니다. try-with-resources는 본문 예외를 밖으로 보내고 close 예외를 suppressed 목록에 붙입니다. 아무도 잡지 않으므로 프로세스는 실패하지만 두 원인을 모두 관찰할 수 있습니다.
public final class TryWithResourcesPrimaryFailure {
public static void main(String[] args) {
try (StudyChannel channel = new StudyChannel()) {
channel.send();
}
}
private static final class StudyChannel implements AutoCloseable {
void send() {
throw new IllegalStateException("primary send failure");
}
@Override
public void close() {
throw new IllegalArgumentException("secondary close failure");
}
}
}java.lang.IllegalStateException: primary send failure
Suppressed: java.lang.IllegalArgumentException: secondary close failure수동 finally에서 close 예외가 그대로 던져지면 전송 오류를 덮을 수 있습니다. try-with-resources는 최초 실패를 primary로 유지하고 정리 실패를 잃지 않습니다. 운영 로그에는 예외 객체 전체를 전달해야 suppressed와 cause가 함께 기록됩니다.
AutoCloseable 자원 계약
try 괄호에서 선언한 객체는 AutoCloseable을 구현해야 합니다. 블록을 벗어날 때 컴파일러가 close 호출을 구성하고, 변수 범위도 try 안으로 제한합니다. 정상, catch 처리, return, 처리되지 않은 예외 모두 같은 정리 규칙을 사용합니다.
public final class AutoCloseableStudyClient {
public static void main(String[] args) {
send("chapter12");
}
private static void send(String data) {
try (StudyClient client = new StudyClient("study.local")) {
client.connect();
client.send(data);
} catch (StudyNetworkException error) {
System.out.println("handled=" + error.getMessage());
}
}
private static final class StudyClient implements AutoCloseable {
private final String address;
private boolean connected;
StudyClient(String address) { this.address = address; }
void connect() {
connected = true;
System.out.println("connect=" + address);
}
void send(String data) {
if (!connected) throw new StudyNetworkException("not connected");
System.out.println("send=" + data);
}
@Override
public void close() {
if (connected) {
connected = false;
System.out.println("close=" + address);
}
}
}
private static final class StudyNetworkException extends RuntimeException {
StudyNetworkException(String message) { super(message); }
}
}connect=study.local
send=chapter12
close=study.localclose는 한 번 호출되는 것이 기본 계약이지만 직접 중복 호출될 가능성까지 고려해 멱등적으로 만들면 방어력이 높아집니다. 닫힌 객체를 다시 쓰면 명확한 IllegalStateException을 던지도록 상태 계약을 정할 수도 있습니다.
여러 자원의 역순 정리
두 번째 자원이 첫 번째 자원에 의존할 수 있으므로 나중에 연 자원을 먼저 닫습니다. 생성 도중 두 번째 객체가 실패해도 이미 생성된 첫 번째 객체는 자동으로 닫힙니다.
public final class MultiResourceCloseOrder {
public static void main(String[] args) {
try (Resource connection = new Resource("connection");
Resource channel = new Resource("channel")) {
System.out.println("work");
}
}
private static final class Resource implements AutoCloseable {
private final String name;
Resource(String name) {
this.name = name;
System.out.println("open=" + name);
}
@Override
public void close() { System.out.println("close=" + name); }
}
}open=connection
open=channel
work
close=channel
close=connection필드에 오래 보관하는 자원에는 이 문법을 바로 적용할 수 없습니다. 수명 소유 객체 자체가 AutoCloseable을 구현하고 상위 조립 코드가 닫거나, 자원 풀처럼 프레임워크가 수명을 관리하도록 해야 합니다. 소유권이 불분명한 객체를 임의로 닫으면 다른 소비자의 작업을 깨뜨립니다.
경계 처리기의 응답과 진단
공통 처리기는 모든 예상치 못한 예외를 잡아 사용자에게 안전한 메시지를 제공하고, 운영자에게는 원인·스택·요청 문맥을 남깁니다. 예외 메시지를 그대로 사용자에게 노출하면 경로, 쿼리, 내부 주소가 유출될 수 있습니다.
public final class CommonExceptionBoundary {
public static void main(String[] args) {
handle("REQ-42", () -> new StudyService().complete("exception"));
}
private static void handle(String requestId, Command command) {
try {
command.run();
System.out.println("status=ok");
} catch (StudyValidationException error) {
System.out.println("status=bad-request,field=" + error.field());
} catch (Exception error) {
System.out.println("status=system-error,request=" + requestId);
logForOperator(requestId, error);
}
}
private static void logForOperator(String requestId, Exception error) {
System.out.println("log=" + requestId + ":"
+ error.getClass().getSimpleName() + ":" + error.getMessage());
}
private interface Command { void run(); }
private static final class StudyService {
void complete(String topic) {
throw new StudySystemException("database offline for " + topic);
}
}
private static final class StudyValidationException extends RuntimeException {
private final String field;
StudyValidationException(String field) { this.field = field; }
String field() { return field; }
}
private static final class StudySystemException extends RuntimeException {
StudySystemException(String message) { super(message); }
}
}status=system-error,request=REQ-42
log=REQ-42:StudySystemException:database offline for exception실무 로거에는 logger.error("request={}", requestId, error)처럼 예외 객체 자체를 마지막 인수로 전달해 스택 추적을 보존합니다.
예제를 단순화한 문자열 출력은 운영 로깅의 대체물이 아닙니다.
같은 예외를 중간과 경계에서 반복 기록하지 않고, 지역 복구를 실제로 수행한 계층은 필요한 수준의 짧은 이벤트만 남깁니다.
자원 정리 방식의 선택
AutoCloseable로 표현되는 파일, 스트림, 소켓, JDBC 연결·문장·결과 집합에는 try-with-resources를 우선합니다. 다중 자원의 역순 정리와 suppressed 처리를 언어가 보장합니다. 잠금 해제, 임시 플래그 복원, 트랜잭션 문맥처럼 close 객체가 아닌 정리는 finally가 더 직접적일 수 있습니다.
자원을 직접 생성하지 않았다면 누가 소유하고 누가 닫는지 먼저 확인합니다. 메서드 인수로 받은 공유 스트림을 무조건 닫지 않습니다. 성공 시 반환해야 하는 자원은 호출자에게 소유권이 넘어간다는 계약과 AutoCloseable 타입을 명확히 합니다. close 실패 정책도 로깅만 할지 작업 전체를 실패로 볼지 정해야 합니다.
예외 처리의 완성 기준은 catch 수가 아닙니다. 실패 뒤에도 불변식과 자원 상태가 안전하고, 복구 가능한 오류는 적절한 계층이 해결하며, 복구 불가능한 오류는 원인을 보존한 채 한 경계에서 사용자 응답과 운영 진단으로 나뉘어야 합니다.
연습 문제
두 자원을 선언하고 본문과 두 close가 모두 실패하게 만드세요. 경계에서 주 예외 메시지와 suppressed 배열의 순서를 출력해 어떤 정리 실패가 먼저 보존되는지 확인합니다.
해설 보기
public final class SuppressedExceptionExercise {
public static void main(String[] args) {
try {
execute();
} catch (IllegalStateException primary) {
System.out.println("primary=" + primary.getMessage());
for (Throwable suppressed : primary.getSuppressed()) {
System.out.println("suppressed=" + suppressed.getMessage());
}
}
}
private static void execute() {
try (FailingResource first = new FailingResource("first");
FailingResource second = new FailingResource("second")) {
throw new IllegalStateException("work");
}
}
private record FailingResource(String name) implements AutoCloseable {
@Override
public void close() { throw new IllegalArgumentException("close-" + name); }
}
}primary=work
suppressed=close-second
suppressed=close-first정리 순서와 같은 순서로 suppressed가 추가됩니다. 첫 번째 주 예외는 그대로 유지되므로 장애의 시작점과 정리 문제를 동시에 진단할 수 있습니다.