Java try-with-resources에서 close() 예외는 어디로 가나: suppressed 예외 읽는 법
try 블록과 close()가 모두 예외를 던지면 무엇이 전파되고 무엇이 getSuppressed()에 남는지, 자원을 닫는 순서와 함께 JLS 14.20.3 기준으로 정리합니다.
try-with-resources 블록 안에서 예외가 났는데 자원을 닫는 close()까지 예외를 던졌다면, 호출한 쪽은 어느 예외를 받을까? 이 동작은 Java 언어 명세(JLS)에 정해져 있다. try 블록의 예외가 전파되고, close()의 예외는 그 예외의 suppressed 목록에 붙는다. 사라지지는 않지만 메시지만 로그로 남기는 코드에서는 눈에 띄지 않는다.
이 글은 Java SE 25 문서를 기준으로 삼는다. JLS §14.20.3, Throwable, AutoCloseable API 문서를 2026-10-12에 확인했다. 25를 기준으로 고른 것이지 최신 버전이라는 뜻은 아니다. addSuppressed()와 getSuppressed()는 API 문서에 Java 1.7부터 있었다고 적혀 있다.
예제 프로그램
아래 파일 하나로 이 글의 시나리오를 모두 확인할 수 있다. Res는 생성될 때와 닫힐 때 이름을 출력하고, failOnClose가 true면 close()에서 IllegalStateException을 던진다. run()은 시나리오가 던진 예외의 메시지와 getSuppressed() 결과를 출력한다.
public class TwrDemo {
static final class Res implements AutoCloseable {
private final String name;
private final boolean failOnClose;
Res(String name, boolean failOnClose) {
this.name = name;
this.failOnClose = failOnClose;
System.out.println("open " + name);
}
@Override
public void close() {
System.out.println("close " + name);
if (failOnClose) {
throw new IllegalStateException("close " + name + " failed");
}
}
}
static void bodyAndCloseFail() {
try (Res a = new Res("A", true)) {
throw new RuntimeException("body failed");
}
}
static void onlyCloseFails() {
try (Res a = new Res("A", true)) {
System.out.println("body ok");
}
}
static void closeOrder() {
try (Res a = new Res("A", false); Res b = new Res("B", false)) {
System.out.println("body ok");
}
}
static void twoClosesFail() {
try (Res a = new Res("A", true); Res b = new Res("B", true)) {
System.out.println("body ok");
}
}
static void bodyAndTwoClosesFail() {
try (Res a = new Res("A", true); Res b = new Res("B", true)) {
throw new RuntimeException("body failed");
}
}
static void naiveFinally() {
Res a = new Res("A", true);
try {
throw new RuntimeException("body failed");
} finally {
a.close();
}
}
static void run(String label, Runnable scenario) {
System.out.println("== " + label);
try {
scenario.run();
System.out.println("no exception");
} catch (RuntimeException e) {
System.out.println("thrown: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println(" suppressed: " + s.getMessage());
}
}
}
public static void main(String[] args) {
run("1 body + close", TwrDemo::bodyAndCloseFail);
run("2 close only", TwrDemo::onlyCloseFails);
run("3 close order", TwrDemo::closeOrder);
run("4 two closes", TwrDemo::twoClosesFail);
run("5 body + two closes", TwrDemo::bodyAndTwoClosesFail);
run("6 naive finally", TwrDemo::naiveFinally);
}
}
javac TwrDemo.java로 컴파일하고 java TwrDemo로 실행한다. 아래에 시나리오별로 나눠 실은 출력은 JLS §14.20.3의 규칙을 따라 나오는 결과다.
본문과 close()가 모두 실패할 때
== 1 body + close
open A
close A
thrown: body failed
suppressed: close A failed
호출한 쪽이 받는 예외는 try 블록에서 던진 body failed이고, close A failed는 그 예외의 getSuppressed() 배열에 들어 있다. JLS §14.20.3.1은 try 블록이 값 V를 던지고 자동 닫기가 V2를 던지면 문장 전체가 V를 던지며 V2는 V의 suppressed 예외 목록에 추가된다고 정한다.
같은 절에 실린 변환 코드를 보면 구조가 더 분명하다. 컴파일러가 만든 finally는 이미 잡아 둔 주 예외(#primaryExc)가 있으면 close()를 다시 try로 감싸고, 여기서 나온 예외를 #primaryExc.addSuppressed(...)로 붙인다.
close()만 실패할 때
== 2 close only
open A
body ok
close A
thrown: close A failed
try 블록이 정상으로 끝났으니 붙을 주 예외가 없다. 이때는 close()의 예외가 그대로 전파되고, suppressed 배열은 비어 있어서 suppressed: 줄이 나오지 않는다. 변환 코드에서 #primaryExc가 null이면 close()를 감싸지 않고 바로 호출하는 분기에 해당한다.
닫는 순서는 선언의 역순
== 3 close order
open A
open B
body ok
close B
close A
no exception
자원은 왼쪽부터 초기화되고 그 역순으로 닫힌다(§14.20.3). 자원이 여러 개인 문장은 자원 하나짜리 try-with-resources를 중첩한 것으로 취급되므로, 나중에 연 B가 안쪽에 있고 먼저 닫힌다.
여러 close()가 실패할 때
한 자원의 close()가 예외를 던져도 나머지 자원은 계속 닫힌다. 그리고 앞서 초기화 식, try 블록, 다른 자원의 닫기에서 이미 예외가 나왔다면 뒤에 나온 닫기 예외는 suppressed가 된다(§14.20.3).
본문은 정상이고 두 close()가 모두 실패하는 경우부터 보자.
== 4 two closes
open A
open B
body ok
close B
close A
thrown: close B failed
suppressed: close A failed
전파되는 예외는 close B failed다. 먼저 닫히는 쪽, 즉 가장 오른쪽에 선언된 자원의 예외가 주 예외가 되고, 왼쪽 자원의 예외는 거기에 붙는다. JLS는 이를 V1(가장 오른쪽 자원의 닫기 실패)이 던져지고 V2…Vn이 V1의 suppressed 목록에 추가된다고 표현한다. “처음 선언한 자원이 주 예외”라고 기억하면 반대로 읽게 된다.
본문까지 실패하면 주 예외는 다시 본문 예외다.
== 5 body + two closes
open A
open B
close B
close A
thrown: body failed
suppressed: close B failed
suppressed: close A failed
중첩 변환을 따라가면 안쪽 문장이 B를 닫으며 close B failed를 본문 예외에 붙이고, 바깥 문장이 A를 닫으며 close A failed를 같은 예외에 붙인다. addSuppressed()는 목록 끝에 추가(append)하므로 배열에는 닫힌 순서대로 B, A가 들어간다. Throwable.printStackTrace() 문서의 예시 출력도 Resource ID = 2 다음에 Resource ID = 1이 나오는 같은 모양이다.
초기화 단계에서 실패하는 경우도 같은 규칙을 따른다. 어떤 자원의 초기화 식이 예외를 던지면 그때까지 초기화된 자원만 닫고, 그 닫기에서 나온 예외는 초기화 예외에 suppressed로 붙는다.
getSuppressed()로 확인하기
Throwable.getSuppressed() 는 이 예외를 전달하려고 억제된 예외들을 배열로 돌려준다. 억제된 예외가 없으면 빈 배열이고, 반환된 배열을 고쳐도 이후 호출 결과에는 영향이 없다.
빈 배열이 나오는 경우가 하나 더 있다. 예외 클래스가 Throwable의 4인자 생성자로 suppression을 꺼 두었다면 addSuppressed()는 인자 검사 말고는 아무것도 하지 않는다. 그런 예외가 주 예외라면 close() 예외는 어디에도 남지 않는다.
printStackTrace()는 suppressed 예외를 주 예외의 스택 트레이스 아래에 더 들여써서 출력한다. 정확한 형식은 구현에 따라 다르지만, 문서의 예시에서는 Suppressed:로 시작하는 줄로 나온다. 반대로 위의 run()처럼 getMessage()만 기록하는 코드라면 getSuppressed()를 직접 돌지 않는 한 닫기 실패가 보이지 않는다. cause(getCause())와 suppressed는 별개의 관계여서, 한 예외가 둘을 함께 가질 수 있다는 점도 addSuppressed() 문서에 적혀 있다.
finally로 직접 닫으면
== 6 naive finally
open A
close A
thrown: close A failed
naiveFinally()는 try-with-resources 없이 finally에서 close()를 부른다. 본문이 던진 body failed는 결과 어디에도 없다. JLS §14.20.2에 따르면 try 블록이 V를 던졌는데 finally 블록이 이유 S로 갑자기 끝나면 try 문은 S로 끝나고 V는 버려진다.
정확히 말하면 이렇게 사라지는 건 finally 안의 close()가 실제로 예외를 던졌을 때뿐이다. close()가 정상으로 끝나면 본문 예외가 그대로 전파된다. 또 손으로 쓴 finally라고 해서 모두 이런 것도 아니다. JLS의 변환 코드처럼 주 예외를 변수에 잡아 두고, close()를 try로 감싸 그 예외를 addSuppressed()로 붙이면 같은 결과를 얻는다. try-with-resources는 그 코드를 컴파일러가 대신 써 주는 셈이다.
catch나 finally를 함께 쓸 때
try-with-resources에 catch나 finally를 붙이면 JLS §14.20.3.2는 이를 기본 try-with-resources를 바깥 try 문으로 감싼 것으로 해석한다. 그래서 catch나 finally 블록이 실행될 때 모든 자원은 이미 닫혔거나 닫기를 시도한 상태이고, 그 catch는 자원 초기화나 닫기에서 나온 예외도 잡을 수 있다.
close()에서 InterruptedException을 던지지 말 것
AutoCloseable.close() API 노트는 구현체가 close()에서 InterruptedException을 던지지 않도록 강하게 권고한다. 이 예외는 스레드의 interrupted 상태와 맞물려 있는데, suppressed가 되면 런타임에서 잘못된 동작이 생길 가능성이 크다는 이유다. 같은 노트는 더 일반적으로, suppressed가 되면 문제가 되는 예외라면 close()에서 던지지 말라고 덧붙인다.
같은 노트에는 close()가 실패할 수 있다면 예외를 던지기 전에 하위 자원을 해제하고 자원을 닫힌 상태로 표시해 두라는 권고도 있다. close()는 여러 번 호출될 가능성이 낮기 때문이다.
참고 자료
모두 2026-10-12에 확인했다.
- Java Language Specification, Java SE 25, §14.20.2 Execution of try-finally and try-catch-finally
- Java Language Specification, Java SE 25, §14.20.3 try-with-resources
- Java SE 25 API: Throwable.getSuppressed()
- Java SE 25 API: Throwable.addSuppressed(Throwable)
- Java SE 25 API: Throwable.printStackTrace()
- Java SE 25 API: AutoCloseable.close()