빌드노트
파일 처리 오류 대신 close() 오류만 보이면 finally를 확인한다
파일 처리와 close()가 함께 실패하면 수동 finally는 정리 오류만 전달할 수 있다. 짧은 코드와 실제 primary·suppressed 출력을 비교해 try-with-resources로 바꿀 위치와 예외 객체를 보존하는 로깅 방식을 판단한다.
Java에서 파일 처리 중 오류가 났는데 로그에는 close() 오류만 남았다면, 자원을 닫는 finally부터 확인한다. 본문 예외가 전파되는 도중 finally도 예외를 던지면 정리 예외가 밖으로 전달되고, 본문 예외는 자동으로 보존되지 않는다. 자원의 수명을 현재 메서드가 관리한다면 try-with-resources로 옮겨 본문 예외와 정리 예외를 함께 남길 수 있다.
finally에서 본문 예외가 사라지는 지점
다음은 합성 자원으로 실행한 예제의 핵심 부분이다. Resource는 AutoCloseable을 구현하며, failClose=true이면 close()가 IOException("close-failed")를 던진다. process(failBody)는 failBody=true이면 IOException("processing-failed")를 던진다. 두 값을 모두 true로 주어 처리와 정리를 함께 실패시켰다. 실제 파일 장애를 재현한 사례는 아니다.
Resource r = new Resource(failClose);
try {
process(failBody);
} finally {
r.close();
}
process()가 던진 예외가 바깥으로 전달되기 전에 r.close()가 실행된다. 여기서 다시 예외가 나면 바깥 catch는 close-failed를 받는다. Java 21 명세의 try-finally 실행 규칙 (새 탭에서 열림)에 따른 동작이다. 로그 순서를 바꿔도 이미 전달받은 예외 객체 안에 본문 예외가 생기지는 않는다.
같은 처리 코드를 try-with-resources로 바꾸면 다음과 같다. 자원이 AutoCloseable을 구현해야 하며, 이 자원을 닫던 기존 finally는 제거한다.
try (Resource r = new Resource(failClose)) {
process(failBody);
}
OpenJDK 21.0.1에서 두 방식에 같은 실패 입력을 준 실제 출력이다. primary는 바깥 catch가 받은 예외의 메시지, suppressed는 getSuppressed() 배열, cause는 getCause() 값이다. twr는 try-with-resources를 뜻한다.
finally body=true close=true primary=close-failed suppressed=[] cause=null
twr body=true close=true primary=processing-failed suppressed=[java.io.IOException: close-failed] cause=null
수동 finally에서는 본문 예외가 suppressed에도 cause에도 연결되지 않았다. 따로 보관하거나 기록하지 않았다면 이 close-failed의 스택만으로 본문 오류를 되찾을 수 없다. try-with-resources에서는 processing-failed가 주 예외로 남고 close-failed가 suppressed에 붙었다. Java 21의 자원 정리 규칙 (새 탭에서 열림)은 이미 전달할 본문 예외가 있으면 정리 실패를 그 예외의 addSuppressed()로 연결하도록 정의한다.
정리만 실패했다면 판단이 다르다. 본문을 성공시키고 close()만 실패시킨 대조 실행에서는 try-with-resources도 primary=close-failed suppressed=[] cause=null을 출력했다. 정리 오류가 주 예외라는 사실만으로 본문 오류를 덮었다고 단정할 수는 없다. 반대로 두 작업이 모두 실패한 수동 finally에서도 suppressed는 비어 있었으므로, 빈 배열이 다른 실패의 부재를 증명하지도 않는다.
예외를 보존해도 메시지만 기록하면 놓친다
두 작업이 모두 실패한 try-with-resources 실행에서 e.getMessage()는 processing-failed만 반환했다. 이어서 e.printStackTrace(System.out)를 호출하자 전체 스택 안에 다음 행도 나왔다.
Suppressed: java.io.IOException: close-failed
Throwable의 스택 출력 문서 (새 탭에서 열림)에도 suppressed 예외를 함께 출력한다고 나와 있다. 로그를 남기는 경계에서는 메시지 문자열만 넘기지 말고 예외 객체를 받는 로거 호출을 사용한다. 실제 로그에 주 예외의 스택과 Suppressed 행이 함께 나오는지는 해당 로거의 출력 설정에서 확인해야 한다.
이 실행의 cause는 null이었다. suppressed는 함께 실패했지만 별도로 전파하지 못한 예외이고, cause는 다른 예외를 일으킨 원인을 연결하는 관계다. 둘은 별개이므로 getCause()만 따라가면 이번 정리 실패를 찾지 못한다. 예외 객체를 조사할 때는 getSuppressed() 배열 (새 탭에서 열림)도 확인한다.
자기 코드에서는 실패 가능한 close()를 그대로 호출하는 finally가 있는지 먼저 찾는다. 그 메서드가 자원의 수명을 소유한다면 try-with-resources로 바꾸고, 처리 실패와 정리 실패를 함께 일으켰을 때 바깥 catch가 무엇을 받는지 확인한다. 여전히 정리 오류만 보인다면 본문 예외를 삼키거나 새 예외로 바꾸는 catch, try-with-resources 뒤에 남은 실패 가능한 finally도 살펴본다. 공유 자원처럼 다른 코드가 수명을 관리한다면 현재 메서드에서 임의로 닫지 않는다.
이번에 직접 확인한 범위는 합성 자원 하나와 JDK 표준 스택 출력이다. 실제 파일 시스템 장애, 여러 자원의 정리 실패, suppression을 비활성화한 사용자 정의 예외, 운영 로깅 프레임워크의 출력은 실행하지 않았다.