서버 애플리케이션은 클라이언트와 달리 프로세스가 계속 살아있어야 한다. 소켓, 스트림, DB 커넥션 같은 외부 자원을 제때 닫지 않으면 자원이 고갈되고 결국 서버가 죽는다. 자원 정리는 네트워크 서버 개발자에게 선택이 아닌 필수다.
이번 글에서는 자원 정리 코드를 단계적으로 개선하면서, 그 과정에서 드러나는 문제들을 하나씩 해결하고, 최종적으로 Java 자원 정리 – finally의 함정과 try-with-resources가 왜 정답인지 확인한다
예제 준비
설명에 사용할 자원 클래스와 예외 클래스를 먼저 정의한다
public class CallException extends Exception {
public CallException(String message) { super(message); }
}
public class CloseException extends Exception {
public CloseException(String message) { super(message); }
}
public class ResourceV1 {
private String name;
public ResourceV1(String name) { this.name = name; }
public void call() { System.out.println(name + " call"); }
public void callEx() throws CallException {
System.out.println(name + " callEx");
throw new CallException(name + " ex");
}
public void close() { System.out.println(name + " close"); }
public void closeEx() throws CloseException {
System.out.println(name + " closeEx");
throw new CloseException(name + " ex");
}
}
call()은 정상 비즈니스 로직, callEx()는 예외가 발생하는 비즈니스 로직이다. close()는 정상 종료, closeEx()는 자원을 닫다가 예외가 발생하는 상황을 시물레이션한다
자원 닫는 순서 원칙: 나중에 생성한 자원을 먼저 닫아야 한다.resource1→resource2순서로 열었다면,resource2→resource1순서로 닫아야 한다.resource2가resource1에 의존하고 있을 수 있기 때문이다. (소켓과 스트림의 관계가 대표적인 예)
V1 — 자원 정리 코드가 아예 실행되지 않는다
private static void logic() throws CallException, CloseException {
ResourceV1 resource1 = new ResourceV1("resource1");
ResourceV1 resource2 = new ResourceV1("resource2");
resource1.call();
resource2.callEx(); // CallException 발생
System.out.println("자원 정리"); // 실행되지 않음
resource2.closeEx();
resource1.closeEx();
}
**실행 결과**
resource1 call
resource2 callEx
CallException 예외 처리
callEx()에서 예외가 발생하는 순간 실행 흐름이 즉시 호출부로 빠져나간다. 그 아래의 자원 정리 코드는 단 한 줄도 실행되지 않는다.
V2 — finally 도입, 하지만 새로운 문제 등장
private static void logic() throws CallException, CloseException {
ResourceV1 resource1 = null;
ResourceV1 resource2 = null;
try {
resource1 = new ResourceV1("resource1");
resource2 = new ResourceV1("resource2");
resource1.call();
resource2.callEx(); // CallException 발생
} catch (CallException e) {
System.out.println("ex: " + e);
throw e;
} finally {
if (resource2 != null) {
resource2.closeEx(); // CloseException 발생!
}
if (resource1 != null) {
resource1.closeEx(); // 실행되지 않음
}
}
}
**실행 결과**
resource1 call
resource2 callEx
ex: CallException: resource2 ex
resource2 closeEx
CloseException예외 처리 ←CallException이 사라졌다finally를 도입했지만 두 가지 심각한 문제가 생겼다
문제 1 – 자원 1번을 닫지 못한다
resource2.closeEx()에서CloseException이 발생하면finally블록 내에서도 실행 흐름이 끊긴다.resource1.closeEx()는 아예 호출되지 않는다.
문제 2 – 핵심 예외가 사라진다
try블록에서CallException이 발생했다. 이것이 진짜 문제의 원인이다. 그런데finally에서CloseException이 새로 발생하면서 원래의CallException을 덮어버린다. 호출부에는CloseException만 전달된다. 개발자는 실제 원인을 알 수 없게 된다
V3 — finally 안에서 try-catch로 부가 예외를 처리
자원을 닫는 코드 각각을 try-catch로 감싸서 부가 예외를 흡수한다
private static void logic() throws CallException, CloseException {
ResourceV1 resource1 = null;
ResourceV1 resource2 = null;
try {
resource1 = new ResourceV1("resource1");
resource2 = new ResourceV1("resource2");
resource1.call();
resource2.callEx();
} catch (CallException e) {
System.out.println("ex: " + e);
throw e;
} finally {
if (resource2 != null) {
try {
resource2.closeEx();
} catch (CloseException e) {
// 자원 닫기 예외는 로깅만 하고 흘려보낸다
System.out.println("close ex: " + e);
}
}
if (resource1 != null) {
try {
resource1.closeEx();
} catch (CloseException e) {
System.out.println("close ex: " + e);
}
}
}
}
**실행 결과**
resource1 call
resource2 callEx
ex: CallException: resource2 ex
resource2 closeEx
close ex: CloseException: resource2 ex
resource1 closeEx
close ex: CloseException: resource1 ex
CallException예외 처리 ← 핵심 예외가 살아돌아왔다- V2의 두 문제를 모두 해결했다. 자원 정리 중 예외가 발생해도 다음 자원을 계속 닫을 수 있고, 핵심 예외인
CallException이 그대로 전달된다 - 자원을 닫는 중에 발생한 예외는 대부분 처리할 수 있는 방법이 없다. 로깅으로 개발자에게 알리는 것으로 충분하다
- 하지만 코드가 너무 복잡하다. V3에는 여전히 해결하지 못한 부가적인 문제들이 남아있다
| 문제 | 원인 |
| 변수 선언과 할당을 분리해야 함 | try와 finally의 변수 스코프가 다르기 때문 |
| 자원 정리 타이밍이 늦다 | catch 실행 후 finally에서 정리하기 때문 |
close() 호출을 실수로 누락할 수 있다 | 개발자의 수동 작업에 의존 |
close() 호출 순서를 실수할 수 있다 | 개발자가 반대 순서를 인지해야 함 |
V4 — try-with-resources가 모든 문제를 해결한다
try-with-resources를 사용하려면 AutoCloseable를 구현해야 한다
public class ResourceV2 implements AutoCloseable {
private String name;
public ResourceV2(String name) { this.name = name; }
public void call() { System.out.println(name + " call"); }
public void callEx() throws CallException {
System.out.println(name + " callEx");
throw new CallException(name + " ex");
}
@Override
public void close() throws CloseException {
System.out.println(name + " close");
throw new CloseException(name + " ex"); // 자원 닫기도 예외 발생
}
}
private static void logic() throws CallException, CloseException {
try (ResourceV2 resource1 = new ResourceV2("resource1");
ResourceV2 resource2 = new ResourceV2("resource2")) {
resource1.call();
resource2.callEx(); // CallException 발생
} catch (CallException e) {
System.out.println("ex: " + e);
throw e;
}
}
**실행 결과**
resource1 call
resource2 callEx
resource2 close ← 선언 역순으로 자동 닫힘
resource1 close
ex: CallException: resource2 ex
CallException 예외 처리
suppressedEx: CloseException: resource2 ex
suppressedEx: CloseException: resource1 ex
V3의 복잡한 코드가 단 몇 줄로 압축되었다. 그리고 동작이 더 정확하다
핵심 예외와 부가 예외를 모두 보존한다
close()중에 예외가 발생해도try-with-resources는 이를 없애지 않는다. 핵심 예외(CallException) 안에 부가 예외 (CloseException)를Suppressed로 담아서 함께 반환한다
} catch (CallException e) {
// 핵심 예외를 처리하면서 부가 예외도 확인할 수 있다
Throwable[] suppressed = e.getSuppressed();
for (Throwable t : suppressed) {
System.out.println("suppressedEx: " + t);
}
throw new RuntimeException(e);
}
- 자바는
Exception.addSuppressed()라는 메서드를 통해 예외 안에 다른 예외를 담을 수 있도록 지원한다.try-with-resources가 등장하면서 이 기능도 함께 추가됐다
try-with-resources가 해결하는 6가지 문제
핵심 문제
close()시점에 예외가 발생해도 다음 자원을 계속 닫는다- 부가 예외가 핵심 예외를 덮어쓰지 않는다. 핵심 예외가 그대로 전달된다
부가 문제
- 변수 선언과 할당을
try블록 안에서 동시에 할 수 있다 try블록이 끝나면 즉시close()를 호출해 자원을 빠르게 반납한다close()호출을 실수로 누락할 수 없다. 컴파일러가 보장한다- 선언 역순으로 자동 닫힌다. 순서를 실경 쓸 필요가 없다