Item 8. finalizer와 cleaner 사용을 피하라

다람·2025년 2월 25일
0

Effective Java

목록 보기
8/13
post-thumbnail

1. 자바에서 객체 소멸자(finalizer, cleaner)이란?

자바는 C++와 달리 파괴자(destructor)가 없다. 대신 가비지 컬렉터(GC)가 자동으로 메모리를 회수한다. 그러나 자바도 예전에 "finalizer"라는 메커니즘, 그리고 자바 9부터 "cleaner"라는 메커니즘을 제공한다. 자바에서는 finalizercleaner 대신에, try-with-resourcestry-finally 같은 방법으로 비메모리 자원을 회수하도록 한다.

finalizer

  • 가비지 컬렉터가 객체를 수거하기 직전에 finalize() 메서드가 자동으로 호출되는 구조이다.
  • finalizer 스레드는 다른 애플리케이션 스레드보다 우선순위가 낮다.

cleaner

  • 자신을 수행할 수레드를 제어할 수 있다.
  • finalizer보다 좀 더 개선된 방법이지만 가비지 컬렉터의 통제를 받기 때문에 즉시 실행된다는 보장이 없다.

finalizercleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달려있다고 한다. 그래서 처음 읽을 때 finalize()가 그렇다면 가비지 컬렉터의 알고리즘인가?라고 오해를 했다.
finalize는 가비지 컬렉터의 알고리즘이 아니고 실제로 가비지 컬렉터가 객체를 수거해가기 직전에 호출되는 메서드일 뿐이다. 그렇기 때문에 실제로 가비지 컬렉터가 수행되지 않으면 finalzie도 수행되지 않는 것이다.

2. finalizer/cleaner를 피해야 하는 이유

2-1. 실행 시점을 예측할 수 없다

  • 가비지 컬렉터 알고리즘에 의존하기 때문에 언제 호출될지 보장할 수 없다.
  • 제때 실행이 필요한 작업(ex. 파일 닫기)을 맡기게 되면 프로그램이 실패하는 중대한 오류가 발생할 수 있다.

2-2. 시스템 자원 고갈 위험

파일 닫기 예시

  • 시스템은 파일과 같은 자원을 동시에 열수있는 개수에 제한이 있다.
  • finalizercleaner에게 파일 닫기를 맡기면 파일이 계속 열려 있을 수 있게되어서 새 파일 열기를 실패할 수 있다.

데이터베이스 락 해제 예시

  • 데이터베이스 공유 자원의 영구 락 해제시에도 위험하다. DB에서 어떤 레코드나 테이블에 락을 걸고 사용 중이라면, 트랜잭션 끝날 때 락을 풀어야 다른 사용자가 접근 가능하다.
  • 만약 락 해제를 finalizercleaner에 맡기게 되면 가비지 컬렉터가 돌 때까지 영원히 락이 유지되어서 공유 자원의 접근이 막혀서 전체 시스템이 멈출 수도 있다.

2-3. 동작 중 예외 발생 시 무시 & 동작 예측 불가능

  • finalizer 내부에서 예외가 발생해도 발생한 예외는 무시된다. 처리할 작업이 있어도 종료되어 버린다.
  • 이렇게 발생한 예외 때문에 객체가 엉망인 상태로 남아서 다른 스레드가 이를 사용하게 된다면 어떻게 동작할지 예측할 수 없게된다.
  • 경고조차 출력하지 않는다. 다만 cleaner는 사용자가 스레드를 어느정도 통제할 수 있기 때문에(Runnable.run() 메서드의 내부에서 구현 가능) 예외처리나 로그 등을 남길 수 있어서 이런 문제들이 발생하지 않는다.

2-4. 심각한 성능 문제

  • finalizer또는 cleaner가 달린 객체는 가비지 컬렉터가 처리할 때 수십 배 느려질 수 있다.
  • 일반 객체보다 가비지 컬렉터의 효율을 떨어뜨려 전체 성능에 악영향을 미친다.

2-5. 보안 문제 (finalizer 공격)

  • 악의적인 하위 클래스가 finalize()를 오버라이드해서 정상적으로 생성되지 않은 객체를 이상하게 이용할 수 있다.
  • final 클래스로 막거나, finalize()를 빈 메서드만들고 fianl로 선언해서 공격을 방어할 수 있다.

3. 자원 해제는 어떻게 해야 되지?

AutoCloseable과 try-with-resources

자바에서 자원 해제를 안정적으로 하기 위해 AutoCloseable 인터페이스를 구현하는 방법이 있다.

    private boolean closed = false; // 닫혔는지 추적

    @Override
    public void close() {
        if (!closed) {
            // 여기서 자원 해제 로직(파일 닫기, 소켓 닫기 등)
            closed = true;
        }
    }

    public void doSomething() {
        if (closed) {
            throw new IllegalStateException("이미 닫힌 자원입니다.");
        }
        // 실제 작업 로직...
    }
}
  • 이렇게 해두면 try-with-resources 구문을 사용할 수 있다.
  • 그리고 이렇게 closed 필드를 통해서 이미 닫힌 상태인지를 추적하면, 닫힌 후에 잘못 사용하려고하더라도 IllegalStateException 같은 예외를 던져서 버그를 빨리 발견할 수 있게 된다.
try (MyResource r = new MyResource()) {
    // r 사용
} // 스코프가 끝나고 r.close() 자동 호출
  • 스코프가 종료되면서 close() 메서드를 자동으로 호출하므로 가비지 컬렉션 시점에 의존하지 않아도 즉시 자원을 해제할 수 있다.

4. 언제 finalizer와 cleaner를 써야될까? (예외적 상황)

4-1. 안전망 역할

  • 사용자가 close() 호출을 깜빡했을 때 뒤늦게라도 자원을 회수하려는 목적으로 사용하는 것이다.
  • 즉시 호출은 보장 하지 않지만 아예 안 하는 것보다는 낫다는 관점이다.
  • 자바 표준 라이브러리 일부(FileInputStream, ThreadPoolExecutor 등)도 이런 안전망 용도로 finalizer를 제공하고 있다.

4-2. 네이티브 피어(native peer)와 연결된 객체

  • 네이티브 코드(C/C++ 영역)의 자원은 자바 가비지 컬렉터가 인지하지 못한다. 자바 Heap과 관련된 객체만 추적하기 때문이다.
  • 자바 객체가 사라지면 네이티브 자원도 해제해줘야 하는데, finalizercleaner로 뒤늦게라도 처리하는 것이다.
  • 성능에 민감하지 않고, 즉시 해제가 필요하지 않은" 경우에 한해서 사용해야한다.

5. cleaner 예시: Room 코드

public class Room implements AutoCloseable {
    private static final Cleaner cleaner = Cleaner.create();

    // 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!
    private static class State implements Runnable {
        int numJunkPiles; // 방 안의 쓰레기 수

        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);
        // this 객체가 가비지 컬렉터에 수거될 때, state.run()을 호출
        cleanable = cleaner.register(this, state);
    }

    @Override public void close() {
        cleanable.clean(); // 여기서 clean()을 호출하면 => State.run()이 바로 실행
    }
}

}
  • Room 객체에 쓰레기 더미(State)가 있고, cleaner.register로 등록해둔다.
  • close()를 직접 호출하면 즉시 cleanable.clean()을 실행하고 run() 메서드가 호출된다.
  • 만약 close()를 깜빡하면 나중에 Room이 회수될 때(시점 불명) state.run()이 실행된다.
  • 하지만 가비지 컬렉터가 언제 돌 지는 보장할 수 없다(즉시성 없음).
  • StateRoom을 참조하면 서로 물고 있는 순환 참조가 되어 가비지 컬렉터가 수거할 수 없으므로, 정적 중첩 클래스로 만들어 Room 참조를 막아야 한다.

실제 동작 예시

1. try-with-resources로 감싼 경우

public class Adult {
    public static void main(String[] args) {
        try (Room myRoom = new Room(7)) {
            System.out.println("안녕!");
        } // 스코프 끝나고 close() 호출 -> 방 청소 즉시 수행
    }
}
  • "안녕!" 출력 후 즉시 "방 청소"를 출력한다.

2. try-with-resources로 감싸지 않은 경우

public class Teenager {
    public static void main(String[] args) {
        new Room(99); // GC에게 맡김
        System.out.println("아무렴");
    }
}
  • 여기선 close() 호출을 하지 않기 때문에 "아무렴"만 출력될 수도 있고 "방 청소"도 출력될 수도있다.
  • 언제 가비지 컬렉터에 의해서 수거될지 모르기 때문에 "방 청소"가 출력되지 않는 예측할 수 없는 상황이 된다.
  • System.gc()를 호출해도 반드시 "방 청소"를 출력할 수 있는 것이 아니다.

6. 결론

  1. finalizer와 cleaner는 기본적으로 사용하지 말자.
  2. 비메모리 자원 해제try-with-resources 또는 AutoCloseable 방식을 사용하자.
  3. 예외적으로
    • 안전망(사용자가 close()를 깜빡했을 때 뒷수습) 용도, 또는
    • 네이티브 피어(자바 객체가 아닌 영역)에 한해 사용할 때도 있긴하다.
  4. cleaner도 결국 가비지 컬렉터의 통제하에 있기 때문에 즉시 실행을 보장할 수 없다.
profile
개발하는 다람쥐

0개의 댓글