공통 예외 처리와 자원 정리
본문과 close가 함께 실패할 때 원인이 가려지는 문제를 관찰하고 AutoCloseable, 억제 예외, 역순 정리, 경계 로깅으로 자원 수명을 완성합니다.
처리할 수 없는 언체크 예외는 여러 중간 계층이 각각 잡기보다 애플리케이션 경계에서 한 번 처리하는 편이 일관됩니다.
자원 정리는 그 경계까지 기다릴 수 없으므로 자원을 확보한 가장 가까운 코드에서 구조적으로 보장해야 합니다.
try-with-resources는 이 두 책임을 분리하면서 close 실패 정보도 보존합니다.
본문 예외와 자원 정리 예외
본문의 전송 오류가 주된 실패인데 close도 예외를 던집니다.
try-with-resources는 본문 예외를 밖으로 보내고 close 예외를 suppressed 목록에 붙입니다.
아무도 잡지 않으므로 프로세스는 실패하지만 두 원인을 모두 관찰할 수 있습니다.
public final class TryWithResourcesPrimaryFailure {
public static void main(String[] args) {
try (BoardChannel channel = new BoardChannel()) {
channel.send();
}
}
private static final class BoardChannel 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 AutoCloseableBoardClient {
public static void main(String[] args) {
send("publish-post");
}
private static void send(String data) {
try (BoardClient client = new BoardClient("board.local")) {
client.connect();
client.send(data);
} catch (BoardGatewayException error) {
System.out.println("handled=" + error.getMessage());
}
}
private static final class BoardClient implements AutoCloseable {
private final String address;
private boolean connected;
BoardClient(String address) { this.address = address; }
void connect() {
connected = true;
System.out.println("connect=" + address);
}
void send(String data) {
if (!connected) throw new BoardGatewayException("not connected");
System.out.println("send=" + data);
}
@Override
public void close() {
if (connected) {
connected = false;
System.out.println("close=" + address);
}
}
}
private static final class BoardGatewayException extends RuntimeException {
BoardGatewayException(String message) { super(message); }
}
}connect=board.local
send=publish-post
close=board.local이 try-with-resources는 해당 자원을 한 번 자동으로 닫습니다. 직접 중복 호출될 가능성까지 고려해 close를 멱등적으로 만들면 방어력이 높아집니다.
닫힌 객체를 다시 쓰면 명확한 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이미 할당된 final 필드도 AutoCloseable 하위 타입이면 try-with-resources의 기존 자원으로 사용할 수 있습니다. 다만 오래 보관하거나 공유하는 자원은 필드 여부보다 현재 블록에서 닫아도 되는 수명·소유권인지 먼저 판단해야 합니다.
긴 수명이 필요하면 수명 소유 객체 자체가 AutoCloseable을 구현하고 상위 조립 코드가 닫거나, 자원 풀처럼 프레임워크가 수명을 관리하도록 할 수 있습니다.
소유권이 불분명한 객체를 임의로 닫으면 다른 소비자의 작업을 깨뜨립니다.
단일 BoardClient는 작업 후 한 번 닫힌다. MultiResourceCloseOrder는 connection 다음 channel을 생성하고 work 뒤 channel 다음 connection을 닫는다.
두 원문 예제 모두 try 블록을 벗어날 때 자원을 정리합니다. 다중 자원에서는 초기화의 역순으로 close를 호출합니다.
| 원문 자원 | 초기화·사용 | close 순서와 출력 |
|---|---|---|
| 단일 client | 첫 번째 예제 connect=board.localsend=publish-post | 그 자원의 정리 close=board.local |
connection | 다중 예제에서 먼저 초기화 open=connection | 다중 예제에서 두 번째 정리 close=connection |
channel | 다중 예제에서 나중 초기화 open=channel | 다중 예제에서 첫 번째 정리 close=channel |
- 단일 client
- 초기화·사용:첫 번째 예제
connect=board.localsend=publish-postclose 순서와 출력:그 자원의 정리close=board.local connection- 초기화·사용:다중 예제에서 먼저 초기화
open=connectionclose 순서와 출력:다중 예제에서 두 번째 정리close=connection channel- 초기화·사용:다중 예제에서 나중 초기화
open=channelclose 순서와 출력:다중 예제에서 첫 번째 정리close=channel
다중 예제의 work는 두 open 뒤, 두 close 앞에 출력됩니다. 뒤 자원의 초기화가 예외로 끝나면 앞서 초기화한 자원만 닫는 것은 언어 규칙이며, 이 원문 main에서는 초기화가 모두 성공합니다. 자동 close 호출과 객체의 물리적 소멸은 같은 뜻이 아닙니다.
경계 처리기의 응답과 진단
공통 처리기는 모든 예상치 못한 예외를 잡아 사용자에게 안전한 메시지를 제공하고, 운영자에게는 원인·스택·요청 문맥을 남깁니다.
예외 메시지를 그대로 사용자에게 노출하면 경로, 쿼리, 내부 주소가 유출될 수 있습니다.
public final class CommonExceptionBoundary {
public static void main(String[] args) {
handle("REQ-42", () -> new BoardService().publish("예외 처리 질문"));
}
private static void handle(String requestId, Command command) {
try {
command.run();
System.out.println("status=ok");
} catch (BoardValidationException 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("board=" + requestId + ":"
+ error.getClass().getSimpleName() + ":" + error.getMessage());
}
private interface Command { void run(); }
private static final class BoardService {
void publish(String title) {
throw new BoardSystemException("database offline for " + title);
}
}
private static final class BoardValidationException extends RuntimeException {
private final String field;
BoardValidationException(String field) { this.field = field; }
String field() { return field; }
}
private static final class BoardSystemException extends RuntimeException {
BoardSystemException(String message) { super(message); }
}
}status=system-error,request=REQ-42
board=REQ-42:BoardSystemException:database offline for 예외 처리 질문실무 로거에는 logger.error("request={}", requestId, error)처럼 예외 객체 자체를 마지막 인수로 전달해 스택 추적을 보존합니다.
예제를 단순화한 문자열 출력은 운영 로깅의 대체물이 아닙니다.
CommonExceptionBoundary의 현재 main은 BoardSystemException을 공통 catch에서 처리하여 안전한 system-error/request 줄과 운영용 클래스명·메시지 줄을 출력한다. 문자열 출력에는 전체 스택·cause·suppressed가 포함되지 않는다.
CommonExceptionBoundary의 현재 입력은 시스템 실패입니다. 사용자용 안내와 운영용 문자열을 분리하지만 둘 다 예제의 stdout 출력입니다.
| 원문 경계 출력 | 들어 있는 정보 | 이 출력이 의미하지 않는 것 |
|---|---|---|
| 사용자용 안내 | status=system-error,request=REQ-42 | 내부 오류 메시지를 포함하지 않습니다. 실제 HTTP 응답이나 5xx 코드를 전송한 예제는 아닙니다. |
| 운영용 문자열 | board=REQ-42:BoardSystemException:database offline for 예외 처리 질문 | 요청 식별자·클래스명·메시지만 있습니다. 스택 추적이나 cause·suppressed 전체 출력이 아닙니다. |
- 사용자용 안내
- 들어 있는 정보:
status=system-error,request=REQ-42이 출력이 의미하지 않는 것: 내부 오류 메시지를 포함하지 않습니다. 실제 HTTP 응답이나 5xx 코드를 전송한 예제는 아닙니다. - 운영용 문자열
- 들어 있는 정보:
board=REQ-42:BoardSystemException:database offline for예외 처리 질문이 출력이 의미하지 않는 것: 요청 식별자·클래스명·메시지만 있습니다. 스택 추적이나 cause·suppressed 전체 출력이 아닙니다.
각 셀의 조각은 원문 stdout에서는 한 줄로 연결됩니다. 이 main은 status=ok나 검증 실패 catch를 실행하지 않습니다. 일반적인 예외 객체 전체 진단은 로거에 객체를 전달하는 별도 책임이며, 현재 오류에 cause나 suppressed가 실제로 있다고 주장하지 않습니다.
같은 예외를 중간과 경계에서 반복 기록하지 않고, 지역 복구를 실제로 수행한 계층은 필요한 수준의 짧은 이벤트만 남깁니다.
자원 정리 방식의 선택
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가 추가됩니다.
첫 번째 주 예외는 그대로 유지되므로 장애의 시작점과 정리 문제를 동시에 진단할 수 있습니다.
SuppressedExceptionExercise의 본문 IllegalStateException work는 primary로 유지된다. second.close의 close-second가 suppressed 첫 번째, first.close의 close-first가 두 번째로 보존된다.
본문과 두 close가 모두 실패합니다. 주 예외는 그대로 전달하고 정리 실패만 suppressed에 추가합니다.
| 발생 위치와 순서 | 전달되는 예외 객체의 위치 | 경계 stdout 순서 |
|---|---|---|
| ① 본문 | IllegalStateException메시지: work경계가 받는 주 예외 | primary=work |
second.close() · 먼저 정리 | IllegalArgumentExceptiongetSuppressed()[0]에 보존 | suppressed=close-second |
first.close() · 나중 정리 | IllegalArgumentExceptiongetSuppressed()[1]에 보존 | suppressed=close-first |
- ① 본문
- 전달되는 예외 객체의 위치:
IllegalStateException메시지:work경계가 받는 주 예외경계 stdout 순서:primary=work second.close()· 먼저 정리- 전달되는 예외 객체의 위치:
IllegalArgumentExceptiongetSuppressed()[0]에 보존경계 stdout 순서:suppressed=close-second first.close()· 나중 정리- 전달되는 예외 객체의 위치:
IllegalArgumentExceptiongetSuppressed()[1]에 보존경계 stdout 순서:suppressed=close-first
자원 선언은 first 다음 second입니다. 두 자원은 초기화에 성공하며 second.close()가 실패해도 first.close()를 시도합니다. suppressed는 정리 중의 추가 실패이고, 앞 절의 원인 변환에 쓰는 cause와 다릅니다. 이 프로그램은 스택 전체가 아니라 메시지 세 줄을 출력합니다.