try-with-resources와 Java의 AutoCloseable

1. 개요

파일, 소켓, 데이터베이스 연결, 또는 결제 게이트웨이와의 세션 같은 것들은 모두 사용한 후 닫아야 하는 리소스입니다. 닫지 않으면 애플리케이션에서 파일 디스크립터가 누수되고, 커넥션 풀이 고갈되거나, 락을 불필요하게 오래 잡고 있게 됩니다.

이 글에서는 try-with-resources를 사용해서 리소스를 자동으로 닫는 방법을 설명합니다. AutoCloseable을 구현하는 클래스를 직접 작성하는 방법, Java가 리소스 종료 중에 발생하는 예외를 처리하는 방식(suppressed exception), 그리고 이 문법을 사용할 때 자주 만나는 오류들을 알게 될 것입니다.

2. try-finally의 문제점

Java 7 이전에는 리소스를 닫기 위한 표준 방법이 finally 블록을 사용하는 것이었습니다:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class LegacyOrderReader {

    public static String readFirstLine(String path) throws IOException {
        BufferedReader reader = new BufferedReader(new FileReader(path));
        try {
            return reader.readLine();
        } finally {
            reader.close();
        }
    }
}

이 코드는 작동하지만, 두 가지 문제가 있습니다:

  • 장황합니다. 리소스가 두 개, 세 개 있으면 여러 try-finally 블록을 중첩해야 합니다. 각 블록마다 null 체크도 필요합니다.
  • 원본 예외를 덮습니다. readLine()이 IOException을 던지고, close()도 예외를 던지면, finally의 예외가 원본 예외를 대체합니다. 실제 오류에 대한 정보를 잃게 됩니다.

try-with-resources는 이 두 문제를 모두 해결합니다.

3. try-with-resources 문법

try 키워드 바로 뒤의 괄호 안에 리소스를 선언합니다:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class OrderReader {

    public static String readFirstLine(String path) throws IOException {
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
            return reader.readLine();
        }
    }
}

try 블록이 끝나면 Java가 자동으로 reader.close()를 호출합니다. 블록이 정상적으로 실행되든, return을 만나든, 예외가 발생하든 모두 그렇습니다. finally를 작성할 필요가 없습니다.

유일한 조건은 변수의 타입이 java.lang.AutoCloseable을 구현해야 한다는 것입니다. JDK의 대부분의 I/O 클래스들이 이 조건을 만족합니다. 예를 들어 InputStream, Reader, Connection, Statement, ResultSet, Scanner 등이 있습니다.

3.1. 여러 리소스와 종료 순서

여러 리소스를 선언할 수 있으며, 세미콜론으로 구분합니다. Java는 선언한 순서의 역순으로 리소스를 종료합니다:

public class ClosingOrderDemo {

    record NamedResource(String name) implements AutoCloseable {
        NamedResource {
            System.out.println("열기: " + name);
        }

        @Override
        public void close() {
            System.out.println("닫기: " + name);
        }
    }

    public static void main(String[] args) {
        try (var connection = new NamedResource("connection");
             var statement = new NamedResource("statement");
             var resultSet = new NamedResource("resultSet")) {
            System.out.println("고객 데이터 읽기");
        }
    }
}

결과:

열기: connection
열기: statement
열기: resultSet
고객 데이터 읽기
닫기: resultSet
닫기: statement
닫기: connection

이 순서는 타당합니다. 나중에 선언한 리소스가 보통 먼저 선언한 리소스에 의존합니다. 예를 들어 statement는 connection에 의존하므로, 먼저 닫아야 합니다.

한 가지 기억할 점이 있습니다. 두 번째 리소스의 생성자에서 예외가 발생하면, Java는 여전히 첫 번째 리소스를 닫습니다. 생성되지 않은 리소스는 close()가 호출되지 않습니다.

3.2. 이미 있는 변수 사용하기 (Java 9+)

Java 9부터는 try 안에 이미 외부에서 선언한 변수를 넣을 수 있습니다. 단, 그 변수가 final이거나 effectively final이어야 합니다:

import java.util.Scanner;

public class CustomerInput {

    public static void main(String[] args) {
        Scanner scanner = new Scanner("Nguyen Van A\n0901234567");
        try (scanner) {
            System.out.println("이름: " + scanner.nextLine());
            System.out.println("전화: " + scanner.nextLine());
        }
    }
}

이 방식은 리소스가 매개변수에서 전달되거나 복잡한 factory에서 생성될 때 유용합니다.

3.3. catch와 finally는 닫은 후에 실행됨

try-with-resources는 여전히 catch와 finally를 추가할 수 있습니다. 그러나 둘 다 리소스가 닫힌 후에 실행됩니다. 따라서 catch에서는 해당 리소스를 다시 사용할 수 없으며, 리소스 변수도 더 이상 범위 내에 없습니다.

4. AutoCloseable 클래스 직접 작성하기

AutoCloseable은 메서드 하나만 있습니다:

void close() throws Exception;

결제 게이트웨이와의 세션이 있고, 이 세션을 각 주문 후에 해제해야 한다고 가정해 봅시다:

public class PaymentGatewaySession implements AutoCloseable {

    private final String orderId;
    private boolean closed;

    public PaymentGatewaySession(String orderId) {
        this.orderId = orderId;
        System.out.println("결제 세션 시작: " + orderId);
    }

    public void charge(long amount) {
        if (closed) {
            throw new IllegalStateException("세션이 닫혔습니다");
        }
        if (amount <= 0) {
            throw new IllegalArgumentException("유효하지 않은 금액: " + amount);
        }
        System.out.println("주문 " + orderId + "에서 " + amount + " VND 차감");
    }

    public boolean isClosed() {
        return closed;
    }

    @Override
    public void close() {
        if (closed) {
            return;
        }
        closed = true;
        System.out.println("결제 세션 종료: " + orderId);
    }
}

이 클래스에서 주목할 세 가지 점이 있습니다:

  • close()은 throws Exception을 선언하지 않습니다. 오버라이드된 메서드는 checked exception 목록을 좁힐 수 있습니다. 덕분에 클래스를 사용하는 사람은 catch (Exception e)를 강요받지 않습니다.
  • close()은 멱등성입니다: 여러 번 호출해도 오류가 발생하지 않고 두 번 해제되지 않습니다.
  • charge()는 closed 상태를 확인하여 리소스가 닫힌 후 사용되는 것을 방지합니다.

사용 방법:

public class CheckoutService {

    public static void main(String[] args) {
        try (var session = new PaymentGatewaySession("ORD-1001")) {
            session.charge(250_000);
        }
    }
}
결제 세션 시작: ORD-1001
주문 ORD-1001에서 250000 VND 차감
결제 세션 종료: ORD-1001

4.1. AutoCloseable인가 Closeable인가?

java.io.Closeable은 Java 5부터 있으며 AutoCloseable을 상속받습니다. 이 두 인터페이스는 다음 점에서 다릅니다:

AutoCloseable Closeable
close()의 예외 Exception IOException
멱등성 권장 계약으로 필수
목적 모든 종류의 리소스 I/O 리소스

리소스가 I/O와 관련이 있으면 Closeable을 사용하세요. 락, 세션, 트랜잭션 같은 다른 경우들은 AutoCloseable을 사용하고 throws를 가능한 한 좁혀야 합니다.

5. Suppressed exception

2번 항목의 문제로 돌아가 봅시다: try 블록에서 예외를 던지고, close()도 예외를 던집니다. try-with-resources를 사용하면, try 블록의 예외가 주요 예외로 유지됩니다. close()의 예외는 주요 예외에 suppressed로 붙습니다:

public class SuppressedDemo {

    static class FlakyConnection implements AutoCloseable {
        @Override
        public void close() {
            throw new IllegalStateException("연결을 닫을 때 오류 발생");
        }
    }

    public static void main(String[] args) {
        try (var connection = new FlakyConnection()) {
            throw new RuntimeException("주문 저장 중 오류");
        } catch (RuntimeException e) {
            System.out.println("주요: " + e.getMessage());
            for (Throwable suppressed : e.getSuppressed()) {
                System.out.println("억제됨: " + suppressed.getMessage());
            }
        }
    }
}

결과:

주요: 주문 저장 중 오류
억제됨: 연결을 닫을 때 오류 발생

두 정보를 모두 유지하게 됩니다. e.printStackTrace() 또는 로거로 스택 추적을 출력할 때, Suppressed: 부분도 나타납니다. 덕분에 production에서 디버깅이 훨씬 쉬워집니다.

try 블록이 정상적으로 실행되고 close()만 예외를 던진다면, close()의 예외가 주요 예외가 됩니다.

6. 리소스 종료 동작 테스트하기

테스트를 작성하려면 JUnit 5와 AssertJ를 추가해야 합니다:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

아래 테스트는 두 가지 주요 동작을 검증합니다: 리소스는 오류가 있어도 항상 닫히고, close()의 예외는 주요 예외에 suppressed로 붙습니다.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class PaymentGatewaySessionTest {

    @Test
    void shouldCloseSessionEvenWhenChargeFails() {
        var session = new PaymentGatewaySession("ORD-2002");

        assertThatThrownBy(() -> {
            try (session) {
                session.charge(-5);
            }
        }).isInstanceOf(IllegalArgumentException.class)
          .hasMessageContaining("-5");

        assertThat(session.isClosed()).isTrue();
    }

    @Test
    void shouldAttachCloseFailureAsSuppressed() {
        AutoCloseable failingResource = () -> {
            throw new IllegalStateException("close failed");
        };

        assertThatThrownBy(() -> {
            try (failingResource) {
                throw new RuntimeException("business failed");
            }
        }).hasMessage("business failed")
          .satisfies(e -> assertThat(e.getSuppressed())
                  .singleElement()
                  .extracting(Throwable::getMessage)
                  .isEqualTo("close failed"));
    }

    @Test
    void closeShouldBeIdempotent() {
        var session = new PaymentGatewaySession("ORD-3003");
        session.close();
        session.close();

        assertThat(session.isClosed()).isTrue();
        assertThatThrownBy(() -> session.charge(100))
                .isInstanceOf(IllegalStateException.class);
    }
}

두 번째 테스트는 람다를 사용하여 빠르게 AutoCloseable을 만듭니다. AutoCloseable이 추상 메서드 하나만 가지고 있기 때문에 functional interface처럼 사용할 수 있습니다.

7. 자주 만나는 오류들

7.1. 생성자에 리소스 중첩

try (var in = new ObjectInputStream(new FileInputStream("orders.bin"))) {
    // ...
}

ObjectInputStream 생성자는 스트림의 헤더를 읽고 IOException을 던질 수 있습니다. 이 경우 변수 in은 아직 할당되지 않았습니다. 내부의 FileInputStream은 이미 열려 있지만 절대 닫히지 않을 것입니다. 안전한 방법은 각 리소스를 별도로 선언하는 것입니다:

try (var file = new FileInputStream("orders.bin");
     var in = new ObjectInputStream(file)) {
    // ...
}

7.2. try 블록 밖으로 리소스 반환하기

public static Stream<String> loadOrderLines(Path path) throws IOException {
    try (Stream<String> lines = Files.lines(path)) {
        return lines; // 잘못됨: 스트림이 호출자에게 도달할 때 이미 닫혀있음
    }
}

호출자는 스트림을 사용할 때 바로 IllegalStateException을 받게 됩니다. 두 가지 방법으로 수정할 수 있습니다. 첫 번째는 try 안에서 모든 데이터를 처리하는 것입니다. 예를 들어 return lines.toList(); 같이요. 두 번째는 스트림을 반환하고 호출자가 try-with-resources로 감싸도록 하는 것입니다.

참고: I/O 리소스와 연결된 Stream만 닫아야 합니다. 예를 들어 Files.lines, Files.list, Files.walk 같은 것들입니다. List.stream()으로 만든 Stream은 닫을 필요가 없습니다.

7.3. close()에서 InterruptedException 던지기

AutoCloseable의 Javadoc은 close()에서 InterruptedException을 던지지 말 것을 권장합니다. 이유는 이 예외가 suppress될 수 있고, 그러면 스레드의 interrupt 상태가 손실되기 때문입니다. close()에서 대기해야만 하면, 예외를 잡은 후 Thread.currentThread().interrupt()를 호출하세요.

7.4. null 리소스

try (var conn = findConnectionOrNull())을 선언하는 것은 유효합니다. 값이 null이면 Java는 단순히 close() 호출을 건너뜁니다. 그러나 이것은 보통 설계가 좋지 않다는 신호입니다. Optional을 사용하거나 factory에서 예외를 던져야 합니다.

7.5. 사용하면 안 되는 경우

AutoCloseable을 구현하는 모든 객체를 메서드 내에서 바로 종료해야 하는 것은 아닙니다. 예를 들어, Spring Boot에서 DataSource나 HttpClient는 공유되는 빈이고, 애플리케이션 컨텍스트와 함께 살아갑니다. Spring은 컨텍스트가 닫힐 때 이들을 종료합니다. 공유되는 빈을 try-with-resources로 감싸면, 다른 컴포넌트가 여전히 사용 중일 때 종료해 버립니다.

try-with-resources는 당신의 코드가 만들어서 소유하는 리소스에만 사용하세요.

7.6. 팁: Java 21의 ExecutorService

Java 19부터 ExecutorService는 AutoCloseable을 구현합니다. close() 메서드는 모든 작업이 완료될 때까지 기다립니다. 이것은 virtual thread를 사용할 때 매우 편합니다:

import java.util.concurrent.Executors;

public class InvoiceBatch {

    public static void main(String[] args) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 1; i <= 3; i++) {
                int invoiceId = i;
                executor.submit(() -> System.out.println("청구서 #" + invoiceId + " 전송"));
            }
        }
        System.out.println("모든 청구서 전송 완료");
    }
}

“모든 청구서 전송 완료”는 항상 마지막에 출력됩니다. 왜냐하면 close()가 세 개의 작업이 모두 완료될 때까지 차단하기 때문입니다. 세 청구서의 순서는 고정되지 않습니다.

8. 결론

try-with-resources는 try-finally보다 코드를 더 짧고 안전하게 만들어줍니다. 리소스는 항상 닫히고, 열린 순서의 역순으로 닫힙니다. 원본 예외는 suppressed exception 메커니즘 덕분에 숨겨지지 않습니다.

AutoCloseable 클래스를 직접 작성할 때는:

  • close()의 throws를 좁혀야 합니다;
  • close()를 멱등성으로 만들어야 합니다;
  • 리소스가 닫힌 후 사용되는 것을 방지해야 합니다.

또한 생성자에 리소스를 중첩하지 말고, try 블록 밖으로 리소스를 반환하지 말며, 코드가 실제로 소유한 리소스만 닫아야 합니다.

javatry-with-resourcesautocloseableexceptionbest-practices