아이템 8. finalizer와 cleaner 사용을 피하라

문법식·2022년 3월 9일
0

Effective Java 3/E

목록 보기
8/52

자바는 finalizercleaner라는 두 가지 객체 소멸자를 제공한다. 그 중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다. 오동작, 낮은 성능, 이식성 문제의 원인이 되기도 한다. 자바 9에서는 finalizerdeprecated됐고 대안으로 cleaner라는게 새로 생겼다. cleanerfinalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다.
C++의 파괴자(destructor)와는 다른 개념이다. C++에서의 파괴자는 특정 객체와 관련된 자원을 회수하는 보편적인 방법이다. 하지만 자바에서는 접근 불가능 객체를 회수하는 역할을 가비지 컬렉터가 담당한다. C++의 파괴자는 비메모리 자원을 회수하는 용도로도 쓰인다. 하지만 자바에서는 try-with-resourcestry-finally를 사용한다.

단점 1

finalizercleaner는 즉시 수행된다는 보장이 없다. 객체에 접근할 수 없게 된 후 finalizercleaner가 실행되기까지 얼마나걸릴지 알 수 없다. 즉, finalizer와 cleaner는 제때 실행되어야 하는 작업은 절대 할 수 없다. 예를 들어, 파일 닫기를 finalizerclenar에 맡기면 중대한 오류를 일으킬 수 있다. 시스템이 동시에 열 수 있는 파일 개수에도 제한이 있고, finalizercleaner가 언제 실행될지 알 수 없기 때문이다. 만약 게으르게 작동한다면 새로운 파일을 열지 못하는 오류가 발생할 수 있다.


단점 2

finalizer는 인스턴스 자원 회수를 지연시킬 수도 있다. finalizer 스레드는 우선 순위가 낮아서 실행될 기회를 제대로 얻지 못할 수 있다. 자바 언어 명세는 어떤 스레드가 finalizer를 수행할지 명시하지 않으니 이 문제를 예방할 보편적인 방법은 finalizer를 사용하지 않는 것이다. 한편, cleaner는 자신을 수행할 스레드를 제어할 수 있다는 면에서 조금 낫다. 하지만 여전히 백그라운드에서 수행되며 가비지 컬렉터의 통제하에 있으니 즉각 수행될 보장은 없다.


단점 3

finalizercleaner가 수행이 지연되는 것보다 아에 수행되지 않을 수도 있다는 것을 명심해야 한다. 접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다는 얘기다. 따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다. 예를 들어 데이터베이스 같은 공유 자원의 영구 락 해제를finalizercleaner에 맡겨 놓으면 분산 시스템 전체가 서서히 멈출 것이다.
System.gcSystem.runFinalization에 속지 말라. 그걸 실행해도 finalizercleaner를 바로 실행한다고 보장하진 못한다. 그걸 보장해주겠다고 만든 System.runFinalizersOnExit와 그 쌍둥이 Runtime.runFinalizersOnExit은 둘다 망했고 수십년간 deprecated 상태다.


단점 4

finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료된다. [JLS, 12.6] 잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있다. 그리고 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없다. 보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, 같은 일이 finalizer에서 일어난다면 경고조차 출력하지 않는다. 그나마 cleaner를 사용하는 라이브러리는 자신의 스레드를 통제하기 때문에 이러한 문제가 발생하지 않는다.


단점 5

finalizercleaner는 심각한 성능 문제도 동반한다. AutoCloseable 객체를 생성하고 가바지 컬렉터가 수거하기까지 12ms가 걸린다. 반면, finalizer를 사용하면 550ms가 걸린다. 약 50배가 더 걸린다. cleaner의 경우에도 66ns가 걸리는데 5배가 더 걸렸다.


단점 6

finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수 있다. finalizer 공격 원리는 간단하다. 한 클래스가 있고 그 클래스를 공격하려는 클래스가 해당 클래스를 상속 받는다. 그리고 그 악의적인 클래스의 인스턴스를 생성하는 도중에 예외가 발생하거나, 직렬화할 때 예외가 발생하면, 원래는 죽어야할 객체의 finalizer가 실행될 수 있다. 그럼 그 그안에서 해당 인스턴스의 레퍼런스를 기록할 수도 있고, 가비지 컬렉터가 수집하지 못하게 막을 수 있다. 또한 그 객체의 메서드를 호출해 애초에는 허용되지 않았을 작업을 수행할 수도 있다.
객체 생성을 막으려면 생성자에서 예외를 던지는 것만으로 충분하지만, finalizer가 있다면 그렇지도 않다. 원래는 생성자에서 예외가 발생해서 존재하질 않았어야 하는 인스턴스인데 finalizer 때문에 살아 남은 것이다.
final클래스는 그 누구도 상속으로 하위 클래스를 만들 수 없다. 그러므로 finalizer 공격에서 안전하다. final이 아닌 클래스에서 finalizer 공격으로부터 방어하려면 아무 일도하지 않는 finalizer 메서드를 만들고 final 선언을 해서 오버라이딩을 막을 수 있다.


자원 반납하는 방법

그렇다면 파일이나 스레드 등 종료해야할 자원을 담고 있는 객체의 클래스에서 finalizercleaner를 대신해줄 묘안은 무엇일까? 그냥 AutoCloseable을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고 나면 close() 메서드를 호출하면 된다. 구체적인 구현법과 관련하여 알아두면 좋은게 있다. 각 인스턴스는 자신이 닫혀는지를 추척하는 것이 좋다. 다시 말해, close()메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면, IllegalStateException을 던지는 것이다.


finalizer와 cleaner의 사용

이쯤이면 finalizercleaner는 대체 어디에 쓰는 물건인지 궁금해진다. 적절한 쓰임새는 (아마도) 두 가지 있다.

안전망으로 쓰기

하나는 자원의 소유자가 close() 메서드를 호출하지 않는 것에 대비한 안전망 역할이다. finalizercleaner가 즉시(혹은 끝까지) 호출되리라는 보장은 없지만, 클라이언트가 하지 않은 자원 회수를 늦게라도 해주는 것이 아에 안 하는 것보다 나으니 말이다. 이런 안전망 역할의 finalizer를 작성할 때는 그럴만한 값어치가 있는지 심사숙고해야 한다.
실제로 자바에서 제공하는 FileInputStream, FileOutputStream, ThreadPoolExecuter는 안전망 역할의 finalizer를 제공한다.

네이티브 피어 정리할 때 쓰기

finalizercleaner의 적절한 활용 두 번째 예는 네이티브 피어와 연결된 객체이다.

자바 클래스 -> 네이티브 메소드 호출 -> 네이티브 객체(native peer)

네이티브 피어는 자바 객체가 아니라서 가비지 컬렉터가 회수를 하지 못한다. 그 결과 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못한다. finalizercleaner로의 처리가 적절한 때이다. 단, 성능 저하를 감당할 수 있고 네이티브 피어가 중요한 자원을 가지고 있지 않을 때에만 해당된다. 성능 저하를 감당할 수 없거나 네이티브 피어가 사용하는 자원을 즉시 회수해야 한다면 앞서 설명한 close() 메서드를 사용해야 한다.

cleaner 예제 코드

public class CleanerSample implements AutoCloseable {

    private static final Cleaner CLEANER = Cleaner.create();

    private final CleanerRunner cleanerRunner;

    private final Cleaner.Cleanable cleanable;

    public CleanerSample() {
        cleanerRunner = new CleanerRunner();
        cleanable = CLEANER.register(this, cleanerRunner);
    }

    @Override
    public void close() {
        cleanable.clean();
    }

    public void doSomething() {

        System.out.println("do it");
    }
    private static class CleanerRunner implements Runnable {

        // TODO 여기에 정리할 리소스 전달

        @Override
        public void run() {
            // 여기서 정리
            System.out.printf("close");
        }
    }

}

static으로 선언된 중첩 클래스인 CleanerRunnercleaner가 방을 청소할 때 수거할 자원들을 담고 있다. CleanerRunnerRunnable을 구현하고, 그 안의 run()메서드는 cleanable에 의해 딱 한번 호출된다. cleanable 객체는 CleanerSample생성자에서 cleanerCleanerSampleCleanerRunner를 등록할 때 얻는다. run메소드가 호출되는 상황은 둘 중 하나다. 보통은 CleanerSampleclose메서드를 호출할 때다. close()메서드에서 Cleanableclean을 호출하면 이 메서드 안에서 run을 호출한다. 혹은 가바지 컬렉터가 CleanerSample을 회수할 때까지 클라이언트가 close()를 호출하지 않으면, cleanerCleanerRunnerrun 메서드를 호출해 줄 것이다.
Cleaner 스레드는(CleanerRunner)는 정리할 대상인 인스턴스(cleanerSample)을 참조하면 안된다. 순환 참조가 생겨서 가비지 컬렉터의 대상이 되질 못한다. Cleaner 스레드를 만들 클래스는 반드시 static 클래스이어야 한다. static이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문이다. 이와 비슷하게 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋다.
앞서 이야기한 대로 CleanerSamplecleaner는 단지 안전망으로만 쓰였다. 클라이언트가 모든 CleanerSample 생성을 try-with-resoureces블록으로 감쌌다면 자동 청소는 전혀 필요하지 않다. 예시 코드는 아래와 같다.

public class Main {
    public static void main(String[] args) throws InterruptedException{
        //try with 
        //이상적인 자원 반납 방법
        try (CleanerSample cleanerSample=new CleanerSample()){
            sampleResource.hello();
        }
    }
}
profile
백엔드

0개의 댓글