6834 단어
34 분
[ Effective Java ] 자원의 해제

들어가며#

자바와 같은 OOP 언어인 C++은 생성자의 개념이 동일하게 존재한다. 하지만 생성자와 함께 소멸자의 개념도 있다. 메모리 관리를 직접 해주어야 하는 C++에서는 객체를 생성하고, 사용한 후에는 자원을 반납해야 하기 때문이다.

자바는 GC(Garbage Collector)에서 생성된 객체 자원을 반납해주는 시스템을 갖추고 있다. 하지만, GC가 관리하지 못하는 자원(예: 파일 핸들, 네트워크 연결 등)에 대해서 안전망의 필요성이 대두되었다.

finalizer와 Cleaner#

자바 초기에는 finalizer(즉, finalize() 메서드)가 GC가 객체를 회수하기 직전에 자원을 정리할 수 있도록 하였다. 하지만, finalizer는 심각한 문제가 있어 자바 11부터 deprecated 되었으며, 현재는 사용하지 않는다.

이러한 문제점을 해결하기 위해 등장한 것이 Cleaner이다. Cleaner는 finalizer의 한계를 일부 보완하기 위해 자바9부터 도입된 대안으로, 정리 작업(Runnable)을 명시적으로 등록하여 객체의 소멸과 별개로 별도의 스레드에서 실행된다. 하지만 Cleaner 역시 완벽한 해결책은 아니었다.

이번 포스팅에서는 자원 해제를 위해 나온 finalizer와 Cleaner의 문제점, 그리고 자원해제를 위한 대안책에 대해 알아보려 한다. 참고로, finalizer와 Cleaner를 현재 개발할 때에 사용하지 않기 때문에 개념에 집중해서 알아보고, 이후 대안책에 대해 더 자세하게 알아볼 예정이다.

finalizer와 cleaner의 문제점#

둘 다 즉시 실행된다는 보장이 없고, 실행되지 않을 수도 있다.#

finalizer#

public class FinalizerIsBad {  
  
    @Override  
    protected void finalize() throws Throwable {  
        System.out.print("");  
    }  
}

finalizer는 Object 클래스에 정의된 메서드이다. 그래서 다음과 같이 Override 해서 사용한다.(물론 지금은 depretecated되었다.)

public class App {  
  
    /**  
     * 코드 참고 https://www.baeldung.com/java-finalize  
     */
    public static void main(String[] args) throws InterruptedException, ClassNotFoundException, NoSuchFieldException, IllegalAccessException {  
        int i = 0;  
        while(true) {  
            i++;  
            new FinalizerIsBad();  
  
            if ((i % 1_000_000) == 0) {  
                Class<?> finalizerClass = Class.forName("java.lang.ref.Finalizer");  
                Field queueStaticField = finalizerClass.getDeclaredField("queue");  
                queueStaticField.setAccessible(true);  
                ReferenceQueue<Object> referenceQueue = (ReferenceQueue) queueStaticField.get(null);  
  
                Field queueLengthField = ReferenceQueue.class.getDeclaredField("queueLength");  
                queueLengthField.setAccessible(true);  
                long queueLength = (long) queueLengthField.get(referenceQueue);  
                System.out.format("There are %d references in the queue%n", queueLength);  
            }  
        }  
    }  
}

코드가 복잡한데 그냥 단순하게 백만개마다 finalizer를 실행시킨다고 보면 된다. 여기서 finalize는 reference하고 있는 객체들을 소멸시키는 역할을 한다. 실행시키고 결과를 보면,

finalizer1.png

위 결과를 보면 references 가 0이다가 한번씩 튈 때가 있다. 객체가 1866896개 만큼 생겼다가 다시 줄고 0이 되는 것을 볼 수 있다. 왜 이런 결과가 나올까? references가 0이라는 것은 GC가 잘 동작하여 객체를 모두 소멸시켰다는 것을 의미한다. 하지만, 값이 한번씩 튈 때마다 객체를 정상적으로 소멸하지 못한 것이다. 이런 경우에 finalizer를 실행시킬 여력도 없이 객체 생성만을 하느라 한번씩 튀는 것을 볼 수 있다.

Cleaner#

public class BigObject {  
  
    private List<Object> resource;  
  
    public BigObject(List<Object> resource) {  
        this.resource = resource;  
    }  
  
    public static class ResourceCleaner implements Runnable {  
  
        private List<Object> resourceToClean;  
  
        public ResourceCleaner(List<Object> resourceToClean) {  
            this.resourceToClean = resourceToClean;  
        }  
  
        @Override  
        public void run() {  
            resourceToClean = null;  
            System.out.println("cleaned up.");  
        }  
    }  
}

Cleaner는 단일 Runnable 인터페이스 구현체를 정리하기 위해 리소스 클래스 내의 내부 정적 클래스로 정의된다. 또 한가지 특징은 리소스 클래스의 필드를 참조하면 안된다. 정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문이다.

public class CleanerIsNotGood {  
  
    public static void main(String[] args) throws InterruptedException {  
        Cleaner cleaner = Cleaner.create();  
  
        // 주기적으로 Cleaner가 실행되는지 확인  
        while (true) {  
            List<Object> resourceToCleanUp = new ArrayList<>();  
            BigObject bigObject = new BigObject(resourceToCleanUp);  
            cleaner.register(bigObject, new BigObject.ResourceCleaner(resourceToCleanUp));  
  
            bigObject = null;  
            System.gc();  
  
            System.out.println("Attempting to clean up...");  
        }  
          
    }  
  
}

이 코드는 자바의 Cleaner를 사용하여 객체가 가비지 컬렉션될 때 자동으로 리소스를 정리하는 과정을 보여준다. 먼저 Cleaner 인스턴스를 생성하고, 정리할 리소스를 담을 리스트를 준비한 후, 이를 인자로 받아 BigObject 객체를 생성한다.

이후 Cleaner의 register 메서드를 통해 BigObject와 해당 객체의 리소스를 정리할 ResourceCleaner를 등록한다. 마지막으로 BigObject에 대한 참조를 제거하고 가비지 컬렉션을 유도한 뒤, 정리 작업이 완료될 시간을 확보하기 위해 잠시 대기한다.

위 코드를 실행해보면 finalizer2.png

다음과 같이 실행되는 것을 볼 수 있다. “Attempting to clean up…” 메시지가 반복적으로 출력되는 이유는, Cleaner가 청소 작업을 할 타이밍을 잡지 못하고 있기 때문이다. 가비지 컬렉션이 발생하지 않으면 Cleaner는 청소를 수행하지 않고 계속 실행을 대기하는 상태가 된다.

NOTE

어떻게 정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되는가?

public class OuterClass {  
  
    private void hi() {  
  
    }  
  
    class InnerClass {  
  
        public void hello() {  
            // OuterClass.this.hi();  
        }  
  
    }  
  
    public static void main(String[] args) {  
        OuterClass outerClass = new OuterClass();  
        InnerClass innerClass = outerClass.new InnerClass();  
  
        System.out.println(innerClass);  
  
        outerClass.printField();  
    }  
  
    private void printField() {  
        Field[] declaredFields = InnerClass.class.getDeclaredFields();  
        for(Field field : declaredFields) {  
            System.out.println("field type:" + field.getType());  
            System.out.println("field name:" + field.getName());  
        }  
    }  
}

위 코드에서 InnerClass는 OuterClass의 내부 클래스이다. 그리고 printField()는 이너 클래스에서 정의된 필드값들을 가지고 온다. 이 코드를 실행하면 다음과 같은 결과가 나온다. (주석처리된 hello()가 OuterClass의 hi()를 참조하고 있는 경우도 같은 결과가 발생한다.)

finalizer3.png

필드 타입을 보면 OuterClass를 참조하는 것을 볼 수 있다. 만약 Cleaner나 다른 정리하는 메서드를 사용하는 경우에, 정적타입으로 선언하지 않는다면 GC가 제대로 정리하지 못한다.

finalizer 동작 중에 예외 발생시, 정리 작업이 처리되지 않을 수 있다.#

finalizer 메서드는 객체가 가비지 컬렉션(GC)에 의해 회수되기 직전에 호출되는 메서드이다. 하지만 이 메서드에서 예외가 발생하면, 정리 작업이 제대로 수행되지 않을 수 있다. 이는 예외가 발생한 시점에서 객체의 소멸이 완료되지 않았기 때문에, 해당 객체가 정상적으로 정리되지 않는 문제가 발생할 수 있다.

예를 들어, finalizer에서 리소스를 정리하는 작업 중 예외가 발생한다면, 그 객체의 자원 해제가 제대로 이루어지지 않고, 이로 인해 메모리 누수나 파일 핸들, 네트워크 연결 등의 자원 누수가 발생할 위험이 있다. 또한, 예외 발생으로 인해 GC가 해당 객체를 정상적으로 회수하지 못할 수도 있다.

class FileHandler {  
    private FileWriter writer;  
  
    public FileHandler(String path) throws IOException {  
        // 파일 자원 획득 (파일 열기)  
        writer = new FileWriter(path);  
        writer.write("Hello, world!");  // 파일에 일부 내용 작성  
    }  
  
    @Override  
    protected void finalize() throws Throwable {  
        System.out.println("Finalizer: 파일 자원 해제 시도...");  
        if (writer != null) {  
            // Finalizer 실행 중 에러 상황을 가정 (예외 발생)  
            throw new RuntimeException("finalize 중 에러 발생!");  
            // writer.close();  // 이 코드는 실행되지 않음 - 자원 누수 발생  
        }  
    }  
  
    public static void main(String[] args) throws Exception {  
        FileHandler fh = new FileHandler("test.txt");  
        // 파일을 연 후 명시적으로 닫지 않음 (finalize에 의존)  
        fh = null;              // FileHandler 객체를 사용 후 참조 해제  
        System.gc();            // 가비지 컬렉션 유도 (finalize 호출 유도)  
        Thread.sleep(1000);     // Finalizer 스레드 실행 대기 (예시를 위한 일시 정지)  
        System.out.println("프로그램 종료");  
    }  
}

위 코드에서 finalize() 메서드 내에서 예외가 발생하면, 예외가 잡히긴 하지만, 자원 해제 작업이 제대로 처리되지 않을 수 있다. 코드를 실행시켜보면,

finalizer5.png

자원 해제를 하지 않은 채 프로그램이 종료되는 것을 볼 수 있다.

이러한 방식은 예상치 못한 문제를 일으킬 수 있으므로, finalize() 메서드 내에서 예외를 처리하는 것은 권장되지 않는다. 실제로 finalize() 메서드는 예외가 발생하더라도 객체가 회수될 수 있도록 보장하지 않기 때문에, 이 방식은 매우 불안정한 리소스 정리 방법입니다.

둘 다 심각한 성능 문제가 있다.#

finalize() 메서드를 클래스에서 오버라이드하면 성능에 영향을 미친다. GC는 이러한 클래스들을 추적해야 하며, 객체 생성과 종료 과정에서 추가적인 단계를 수행해야 한다.

일부 가비지 컬렉터는 처리량을 최적화하는 방식으로 설계되어 있으며, 전반적인 가비지 컬렉션의 pause time을 최소화하는 것이 성능을 높이는 데 중요하다. 이러한 가비지 컬렉터에서 finalizer는 pause time을 증가시켜 불리한 영향을 미친다.

또한, finalize() 메서드는 항상 활성화되어 있기 때문에 리소스를 닫는 작업이 이미 처리된 경우에도 GC가 이 메서드를 호출한다. 이로 인해 finalize() 메서드는 요구되지 않아도 항상 호출되므로 성능에 불이익을 초래할 수 있다.

finalizer와 cleaner는 심각한 성능 문제를 동반한다. 내 컴퓨터에서 간단한 AutoCloseable 객체를 생성하고, 가비지 컬렉터가 수거하기까지 12ns가 걸린 반면 (try-with-resources로 자신을 닫도록 했다), finalizer를 사용하면 550ns가 걸렸다. 다시 말해 finalizer를 사용한 객체를 생성하고 파괴하는 데 50배나 느렸다. finalizer가 가비지 컬렉터의 효율을 떨어뜨리기 때문이다. cleaner도 클래스의 모든 인스턴스를 수거하는 형태로 사용하면 성능은 finalizer와 비슷하다 (내 컴퓨터에서는 인스턴스당 500ns 정도 걸렸다). 하지만, 잠시 후에 살펴볼 안전망 형태로만 사용하면 훨씬 빨라진다. 안전망 방식에서는 객체 하나를 생성, 정리, 파괴하는 데 내 컴퓨터에서 약 66ns가 걸렸다. 안전망을 설치하는 대가로 성능이 약 5배 정도 느려진다는 뜻이다.

성능 비교 (ns 단위)

이펙티브 자바에서 저자가 한 테스트 결과이다.

방식성능 (객체 생성 및 파괴)
AutoCloseable (try-with-resources)12ns
Finalizer550ns
Cleaner (클래스 모든 인스턴스 수거)500ns
Cleaner (안전망 방식)66ns

위 표를 보면 finalizer를 사용하면 AutoCloseable보다 약 50배가 느렸다. finalizer가 GC의 효율을 떨어트리기 때문이다. 일부 가비지 컬렉터는 처리량을 최적화하는 방식으로 설계되어 있으며, 전반적인 가비지 컬렉션의 pause time을 최소화하는 것이 성능을 높이는 데 중요하다. 이러한 가비지 컬렉터에서 finalizer는 pause time을 증가시켜 불리한 영향을 미친다.

cleaner도 클래스의 모든 인스턴스를 수거하는 형태로 사용하면 성능은 finalizer와 비슷하다. 하지만 안전망 형태로 사용하면 훨씬 빨라진다.

finalizer는 보안 문제가 있다.#

finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수 있다. finalizer 공격의 원리는 간단하다. 생성자나 직렬화 과정(예: readObject와 readResolve 메서드, 12장 참조)에서 예외가 발생하면, 이 생성되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 된다.

  • 이펙티브 자바 p42

코드로 예시를 보자.

public class Account {  
  
    private String accountId;  
  
    public Account(String accountId) {  
        this.accountId = accountId;  
  
        if (accountId.equals("김정은")) {  
            throw new IllegalArgumentException("김정은의 계정을 막습니다.");  
        }  
    }  
  
    public void transfer(BigDecimal amount, String to) {  
        System.out.printf("transfer %f from %s to %s\n", amount, accountId, to);  
    }  
  
}

다음과 같은 계정이 있고, 코드에 나온 저 분이 만드는 계정을 막을 것이다. 그래서 필드값이 일치하면 예외를 던지게 설정해 두었다.

public class BrokenAccount extends Account {  
  
    public BrokenAccount(String accountId) {  
        super(accountId);  
    }  
  
    @Override  
    protected void finalize() throws Throwable {  
        this.transfer(BigDecimal.valueOf(100_000_000), "speculatingwook");  
    }  
}

이제 뚫어보자. Account를 상속받는 클래스 BrokenAccount를 생성하고, 생성자를 그대로 받는다. 그리고 finalize() 메서드에 계좌의 돈을 내 계좌로 넣는 코드를 설정한다.(1억이라니 상상만 해도 행복하다)

여기까지 한다면 똑같이 위의 계정이 막힐 것이다.

class AccountTest {  
  
    @Test  
    void 김정은_계정() throws InterruptedException {  
        Account account = null;  
        try {  
            account = new BrokenAccount("김정은");  
        } catch (Exception exception) {  
            System.out.println("이러면???");  
        }  
  
        System.gc();  
        Thread.sleep(3000L);  
    }  
  
}

그래서 이제 try catch로 예외를 받은 다음, GC를 호출해보자. 이렇게 되면 finalizer를 실행시키게 된다. 결과를 보면

finalizer4.png

이제 돈이 내 계좌로 입금되었다. 위와 같은 문제를 해결하려면 Account를 상속 불가하게 설정하거나, finalizer()를 비어있는 final 메서드로 Account에 선언하는 방법이 있다.

AutoCloseable#

finalizer와 cleaner는 위 설명에서 나온 것처럼 문제가 많다. 그래서 대안이자, 권하는 방법은 AutoCloseable이다. 일단 어떻게 사용하는지 알아보자.

public class AutoCloseableIsGood implements AutoCloseable {  
  
    private BufferedReader reader;  
  
    public AutoCloseableIsGood(String path) {  
        try {  
            this.reader = new BufferedReader(new FileReader(path));  
        } catch (FileNotFoundException e) {  
            throw new IllegalArgumentException(path);  
        }  
    }  
  
    @Override  
    public void close() {  
        try {  
            reader.close();  
        } catch (IOException e) {  
            throw new RuntimeException(e);  
        }  
    }  
}

이 코드는 AutoCloseableIsGood라는 클래스를 정의하며, 이 클래스는 AutoCloseable 인터페이스를 구현한다. AutoCloseable 인터페이스는 close() 메서드만 구현하면 된다.

클래스는 파일 경로를 받아 BufferedReader 객체를 생성하는 생성자를 가진다. 생성자에서 파일을 열 때 FileNotFoundException이 발생하면 IllegalArgumentException을 던진다. close() 메서드는 BufferedReader를 닫는 역할을 하며, 이 과정에서 IOException이 발생하면 RuntimeException으로 감싸서 던진다.

public class App {  
  
    public static void main(String[] args) {  
        try(AutoCloseableIsGood good = new AutoCloseableIsGood("")) {  
            // TODO 자원 반납 처리가 됨.  
  
        }  
    }  
}

위 코드와 같이 try 이후 바로 자원을 정의해주면, 작업을 한 후 자원을 알아서 반납한다. 예시 코드로 나온 기법이 try-with-resources인데, 뒤에 더 자세히 설명하려 한다.

NOTE
package java.lang;

public interface AutoCloseable {
	void close() throws Exception;  
}

인터페이스에 정의된 close() 메서드에서는 Exception 타입으로 예외를 던지지만 실제 구현체에서는 예외를 던져야 한다면 구체적인 예외를 던지는 것을 추천하고, 가능하다면 예외를 던지지 않는 것도 권장한다. 예외를 던지지 않으면 클라이언트에서 처리하지 않아도 된다. (이 인터페이스는 이펙티브 자바의 저자가 만든 인터페이스이다… ㄷㄷ)

또한, AutoCloseable을 구현하는 구현체는 idempotent하는 것이 좋다.(몇번을 실행하던 같은 결과가 나와야 한다.- 멱등성)

NOTE

AutoCloseable의 하위 클래스 Closeable

package java.io;

public interface Closeable extends AutoCloseable {
	public void close() throws IOException;  
}

AutoCloseable과 다른 점은 예외를 IOException을 던진다는 것이다. 그래서, IO 작업과 관련된 경우에는 Closeable을 사용하는 것도 좋은 선택이 될 수 있다. (그리고 보면 AutoCloseable, Closeable의 패키지가 다르다.)

그리고 Closeable의 경우는 idempotent 해야 한다.(몇번을 실행하던 같은 결과가 나와야 한다.)

그러면 Cleaner를 사용하지 말아야 하는건가?#

Cleaner를 사용한다고 해서 반드시 피해야 하는 것은 아니다. 오히려 Cleaner는 자원을 안전하게 정리하는 데 유용하게 활용될 수 있다. 이 방식은 자원 해제를 자동화하려는 목적에 부합하며, 예외가 발생해도 자원 정리가 정상적으로 이루어지도록 보장할 수 있다.

public class Room implements AutoCloseable {  
    private static final Cleaner cleaner = Cleaner.create();  
  
    // 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!  
    private static class State implements Runnable {  
        int numJunkPiles; // Number of junk piles in this room  
  
        State(int numJunkPiles) {  
            this.numJunkPiles = numJunkPiles;  
        }  
  
        // close 메서드나 cleaner가 호출한다.  
        @Override public void run() {  
            System.out.println("Cleaning room");  
            numJunkPiles = 0;  
        }  
    }  
  
    // 방의 상태. cleanable과 공유한다.  
    private final State state;  
  
    // cleanable 객체. 수거 대상이 되면 방을 청소한다.  
    private final Cleaner.Cleanable cleanable;  
  
    public Room(int numJunkPiles) {  
        state = new State(numJunkPiles);  
        cleanable = cleaner.register(this, state);  
    }  
  
    @Override public void close() {  
        cleanable.clean();  
    }  
}

위 코드에서처럼 Cleaner를 사용하면 객체가 더 이상 필요하지 않을 때 자원을 자동으로 정리할 수 있다. Room 클래스에서 Cleaner를 사용하여 State 객체를 청소하는 메커니즘을 구현한 것처럼, Cleaner는 객체가 더 이상 사용되지 않거나 참조되지 않을 때 특정 작업을 실행하게 한다. 이 방식은 try-with-resources와는 다르게, 객체가 가비지 컬렉션에 의해 수거될 때 자동으로 실행되므로 자원 정리가 확실하게 이루어진다.

하지만 중요한 점은, CleanerAutoCloseable을 구현한 자원들을 안전하게 관리하는 방식으로만 사용해야 한다는 것이다. 자원의 해제가 명확히 필요한 시점에만 Cleaner를 사용하는 것이며, 자원의 수거와 해제를 명시적으로 관리하는 것이 아닌, Cleaner는 보조적인 역할로 활용하는 것이 좋다. 예를 들어, close() 메서드나 명시적인 자원 정리 방법을 잊었을 때 이를 보완하는 안전망 역할로 사용할 수 있다.

// cleaner 안전망을 갖춘 자원을 제대로 활용하는 클라이언트 (45쪽)  
public class Adult {  
    public static void main(String[] args) {  
        try (Room myRoom = new Room(7)) {  
            System.out.println("안녕~");  
        }  
    }  
}

// cleaner 안전망을 갖춘 자원을 제대로 활용하지 못하는 클라이언트 (45쪽)  
public class Teenager {  
  
    public static void main(String[] args) {  
        new Room(99);  
        System.out.println("Peace out");  
  
        // 다음 줄의 주석을 해제한 후 동작을 다시 확인해보자.  
        // 단, 가비지 컬렉러를 강제로 호출하는 이런 방식에 의존해서는 절대 안 된다!  
        System.gc();  
    }  
}

위 코드에서 Adult 클래스는 try-with-resources를 사용하여 Room 객체를 안전하게 정리한다. 이 방식은 AutoCloseable을 활용하여 명시적으로 자원을 해제하므로 가장 권장되는 방법이다. 반면 Teenager 클래스는 Room 객체를 생성만 하고 close()를 호출하지 않는다. 이 경우 Cleaner가 백업 안전망 역할을 하여, 객체가 가비지 컬렉션(GC)에 의해 제거될 때 자동으로 정리된다.

try-finally#

이제 자원을 처리하는 기법에 대해 알아보고, 어떤 방식을 사용하는 게 더 좋은지 알아보려고 한다.

public class TopLine {  
    // 코드 9-1 try-finally - 더 이상 자원을 회수하는 최선의 방책이 아니다! (47쪽)  
    static String firstLineOfFile(String path) throws IOException {  
        BufferedReader br = new BufferedReader(new FileReader(path));  
        try {  
            return br.readLine();  
        } finally {  
            br.close();  
        }  
    }  
  
    public static void main(String[] args) throws IOException {  
        String path = args[0];  
        System.out.println(firstLineOfFile(path));  
    }  
}

자원 처리에서 가장 기본적인 방법은 try-finally 구조를 사용하는 것이다. 위 코드와 같이 이 방법은 자원을 다룰 때 자원을 반드시 반환하거나 해제하는 코드를 finally 블록에 두어, 예외가 발생하더라도 자원이 제대로 처리되도록 보장한다.

public class Copy {  
    private static final int BUFFER_SIZE = 8 * 1024;  
  
    // 코드 9-2 자원이 둘 이상이면 try-finally 방식은 너무 지저분하다! (47쪽)  
    static void copy(String src, String dst) throws IOException {  
        InputStream in = new FileInputStream(src);  
        try {  
            OutputStream out = new FileOutputStream(dst);  
            try {  
                byte[] buf = new byte[BUFFER_SIZE];  
                int n;  
                while ((n = in.read(buf)) >= 0)  
                    out.write(buf, 0, n);  
            } finally {  
                out.close();  
            }  
        } finally {  
            in.close();  
        }  
    }  
  
    public static void main(String[] args) throws IOException {  
        String src = args[0];  
        String dst = args[1];  
        copy(src, dst);  
    }  
}

하지만 실제로는 이 방법을 잘 사용하지 않는 경우가 많다. 위 코드와 같이try-finally 구조는 자원이 많아질수록 코드가 길어지고 반복될 수 있기 때문에, 자원을 처리하는 더 깔끔하고 직관적인 방법이 선호된다.

try-with-resources#

try-with-resources는 이러한 문제를 해결하는 더 나은 방법이다. 아래 코드는 위 try-finally의 TopLineCopy를 작성한 예이다.

public class TopLine {  
    // 코드 9-3 try-with-resources - 자원을 회수하는 최선책! (48쪽)  
    static String firstLineOfFile(String path) throws IOException {  
        try (BufferedReader br = new BufferedReader(  
                new FileReader(path))) {  
            return br.readLine();  
        }  
    }  
  
    public static void main(String[] args) throws IOException {  
        String path = args[0];  
        System.out.println(firstLineOfFile(path));  
    }  
}

try-with-resources는 자원을 자동으로 관리해 주기 때문에 코드가 훨씬 간결하고 직관적이다. try 블록에 선언된 자원들은 자동으로 닫히며, 예외가 발생하더라도 자원 해제를 보장한다. 이를 통해 자원을 명시적으로 해제하는 코드를 반복적으로 작성할 필요가 없어진다.

NOTE

BufferedReader는 어떻게 try 구문에 들어갈 수 있을까?

위 코드를 보면, AutoCloseableIsGood good = new AutoCloseableIsGood("") 같은 AutoCloseable 인터페이스를 구현하는 구현체가 try 구문에 들어가야 한다고 알고 있다. 그런데 어떻게 BufferedReader가 try 구문에 들어갈 수 있을까?

BufferedReader를 타고 들어가보면 다음과 같다.

public class BufferedReader extends Reader {
	...
}

BufferedReader 클래스를 보면 Reader를 상속받고 있다. 그러면 상속받는 Reader를 들어가보면,

public abstract class Reader implements Readable, Closeable {
	...
}

Reader는 Closeable 인터페이스를 구현하고 있다. Closeable을 구현하기 때문에 try 구문에 들어갈 수 있는 것이다.!

public class Copy {  
    private static final int BUFFER_SIZE = 8 * 1024;  
  
    // 코드 9-4 복수의 자원을 처리하는 try-with-resources - 짧고 매혹적이다! (49쪽)  
    static void copy(String src, String dst) throws IOException {  
        try (InputStream   in = new FileInputStream(src);  
             OutputStream out = new FileOutputStream(dst)) {  
            byte[] buf = new byte[BUFFER_SIZE];  
            int n;  
            while ((n = in.read(buf)) >= 0)  
                out.write(buf, 0, n);  
        }  
    }  
  
    public static void main(String[] args) throws IOException {  
        String src = args[0];  
        String dst = args[1];  
        copy(src, dst);  
    }  
}

특히 복수의 자원을 처리할 때 try-with-resources는 강력한 장점을 발휘한다. 여러 자원을 try-with-resources 한 줄에 나열할 수 있어, 자원 관리가 더욱 효율적이고 코드가 깔끔하게 유지된다. 또한, 자원 해제 순서도 자동으로 처리되기 때문에, 개발자가 실수할 여지를 줄일 수 있다. 하지만 알아야 할 것은, 두 자원의 close() 실행은 무조건적으로 보장하지만 두 자원의 실행이 항상 성공하는 것은 보장하지 않는다.

가장 중요한 장점은 에러를 잡아먹지 않는다는 것이다. 에러를 잡아먹는다는 것은 위 BadBufferedReader 를 통해 알 수 있다. 아래 코드를 보자.

public class BadBufferedReader extends BufferedReader {  
    public BadBufferedReader(Reader in, int sz) {  
        super(in, sz);  
    }  
  
    public BadBufferedReader(Reader in) {  
        super(in);  
    }  
  
    @Override  
    public String readLine() throws IOException {  
        throw new CharConversionException();  
    }  
  
    @Override  
    public void close() throws IOException {  
        throw new StreamCorruptedException();  
    }  
}

BadBufferedReader 클래스를 보면, readLine()close()시에 모두 예외를 발생시킨다.

public class TopLine {  
    // 코드 9-1 try-finally - 더 이상 자원을 회수하는 최선의 방책이 아니다! (47쪽)  
    static String firstLineOfFile(String path) throws IOException {  
	    BufferedReader br = new BufferedReader(new FileReader(path));  
	    try {  
	        return br.readLine();  
	    } finally {  
	        br.close();  
	    }  
	}
  
    public static void main(String[] args) throws IOException {  
        System.out.println(firstLineOfFile("pom.xml"));  
    }  
}

위 TopLine 클래스를 보면, readLine()과, close()가 모두 호출되는데 실제 콘솔 창에서는 CharConversionExceptionStreamCorruptedException중 어떤 에러가 발생할까?

finalizer6.png

실행시켜 보면 StreamCorruptedException을 발생시킨다. close()에 예외가 발생한다는 것은 readline()의 에러를 보지 못한다는 것이고, 사실상 에러가 먹힌 것과 같이 보인다.

실제 디버깅을 하는데 처음 발생한 에러를 보지 못한다는 것은 치명적이다. 가장 중요한 예외가 가장 처음에 있을 확률이 높기 때문이다. 그래서 try-finally를 사용해서 안먹히게 코드를 작성할 순 있지만, 매우 지저분해진다.

try-with-resources를 사용하면 이런 지저분한 문제도 사라진다.

public class TopLine {  
    // 코드 9-1 try-finally - 더 이상 자원을 회수하는 최선의 방책이 아니다! (47쪽)  
    static String firstLineOfFile(String path) throws IOException {  
        try(BufferedReader br = new BadBufferedReader(new FileReader(path))) {  
            return br.readLine();  
        }  
    }  
  
    public static void main(String[] args) throws IOException {  
        System.out.println(firstLineOfFile("pom.xml"));  
    }  
}

이제 위 코드를 실행하면 어떤 결과가 나올까?

finalizer7.png 놀랍게도 두 에러 모두 콘솔창에서 보인다. 가장 먼저 CharConversionException 이 보이고 후속으로 발생했던 StreamCorruptedException도 보인다.

NOTE

try-with-resources는 어떻게 되어있는데 작동하는거지?

위 TopLine 클래스를 바이트코드로 변환한 내용을 보자. 아 그렇지만 실제 바이트코드까지는 보지 않고, 바이트코드를 우리가 읽을 수 있는 자바로 변환한 내용을 보자.(필자도 읽을 줄 모른다.) Intellij의 기능을 통해 다음과 같은 코드를 볼 수 있다.

public class TopLine {  
    public TopLine() {  
    }  
  
    static String firstLineOfFile(String path) throws IOException {  
        BufferedReader br = new BufferedReader(new FileReader(path));  
  
        String var2;  
        try {  
            var2 = br.readLine();  
        } catch (Throwable var5) {  
            try {  
                br.close();  
            } catch (Throwable var4) {  
                var5.addSuppressed(var4);  
            }  
  
            throw var5;  
        }  
  
        br.close();  
        return var2;  
    }  
  
    public static void main(String[] args) throws IOException {  
        String path = args[0];  
        System.out.println(firstLineOfFile(path));  
    }  
}

예외처리를 보면, 만약 readLine() 과정에서 예외(Throwable var5)가 발생하면, 내부에서 다시 try-catch 블록을 사용하여 리소스를 닫는다. 이때 리소스를 닫는 과정에서도 예외(Throwable var4)가 발생할 수 있는데, 이 경우 var5.addSuppressed(var4)를 호출하여 원래 발생한 예외에 리소스 종료 시 발생한 예외를 억제된(suppressed) 형태로 추가한다. 그리고 원래 발생한 예외(var5)를 다시 던진다. 그래서 아까 봤던 모든 예외가 나오는 것이 suppressed 형태로 추가되어있는 상태에서 에러를 던졌기 때문에 발생한다.

만약 readLine() 작업이 정상적으로 완료되면, try 블록 이후에 br.close()를 호출하여 리소스를 명시적으로 닫고, 읽어온 라인(var2)을 반환한다.

  • 그리고 추가로 br.close()가 두번 호출되는데, Closeable 인터페이스는 멱등성(idempotent)를 유지해야 하기 때문에 같은 실행을 보장하기 위해서 두번 호출된 거라 볼 수 있다.

출처#

  • 이펙티브 자바 아이템 8: finalizer와 cleaner 사용을 피하라
  • 이펙티브 자바 아이템 9: try-finally보다는 try-with-resources를 사용하라.
  • 백기선의 이펙티브 자바(인프런)
  • Deprecate Finalization in Java - baeldung
[ Effective Java ] 자원의 해제
https://blog-full-of-desire-v3.vercel.app/posts/java/effective-java/item89-finalizer/finalizer-try/
저자
SpeculatingWook
게시일
2025-03-07
라이선스
CC BY-NC-SA 4.0