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

wisdom·2022년 8월 17일
0

Effetctive Java

목록 보기
8/80
post-thumbnail

✔️ 객체 소멸자

자바는 두 가지 객체 소멸자를 제공한다.

1️⃣ 첫 번째는 finalizer 다. finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다.

2️⃣ 두 번째는 cleaner 다. finalizer는 오동작, 낮은 성능, 이식성 문제가 있었기 때문에, 자바 9에서 deprecated API로 지정되었고 finalizer의 대안으로 cleaner가 소개되었다. cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 성능이 느리고, 일반적으로 불필요하다.


✔️ finalizer와 cleaner의 문제점

그렇다면 지금부터 finalizer와 cleaner의 문제점을 알아보자.

1️⃣ 불확실성 (수행 미보장)

finalizer와 cleaner는 즉시 수행된다는 보장이 없다. 객체에 접근할 수 없게 된 후 finalizer나 cleaner가 실행되기까지 얼마나 걸릴지 알 수 없다. 즉, finalizer와 cleaner로는 제때 실행되어야 하는 작업은 절대 할 수 없다.

finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸다. (가비지 컬렉터 구현마다 천차만별이다.)

현업에서 클래스에 finalizer를 달아두면 인스턴스의 자원 회수가 지연되어 OutOfMemoryError와 같은 문제가 발생할 수 있다.

자바 언어 명세는 finalizer나 cleaner의 수행 시점 뿐만 아니라 수행 여부조차 보장하지 않는다. 접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다는 얘기다. 
따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안 된다. (ex. 데이터베이스의 영구 락 해제)


2️⃣ 예외 발생 시, 예외 무시 후 바로 종료

finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료된다.

잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있다. 보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, finalizer에서는 경고조차 출력되지 않는다.

그나마 cleaner를 사용하는 라이브러리는 자신의 스레드를 통제하기 때문에 이러한 문제가 발생하지 않는다.


3️⃣ 성능 문제

finalizer와 cleaner를 사용하여 객체를 생성하고 파괴하는 것은 심각한 성능 문제를 야기할 수 있다.

(저자의 컴퓨터 환경에서) AutoCloseable 객체를 생성하고 가비지 컬렉터가 수거하는 것과 비교했을 때 약 50배나 느리다.
이는 가비지 컬렉터의 효율을 떨어뜨리기 때문이다.

참고로, (저자의 컴퓨터 환경에서) 안전망 형태로만 사용할 경우는 5배 정도 느리다.


4️⃣ 보안 문제

finalizer를 사용한 클래스는 심각한 보안 문제를 일으킬 수 있다.

생성자나 직렬화 과정에서 예외가 발생하면, 이 생성되다만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 된다. 이 finalizer는 정적 필드에 자신의 참조를 할당하여 가비지 컬렉터가 수집하지 못하게 막을 수 있다. 이렇게 일그러진 객체가 만들어지면, 이 객체의 메서드를 호출해 허용되지 않는 작업을 수행하게 할 수 있다.

객체 생성을 막으려면 생성자에서 예외를 던지면 되지만, finalizer가 있다면 그렇지 않다. 
final 클래스의 경우 하위 클래스를 만들 수 없어 이 공격에 안전하다.
final이 아닌 클래스의 경우, 아무 일도 하지 않는 finalize 메서드를 만들고 final로 선언하여 공격을 막을 수 있다.


✔️ AutoCloseable

그렇다면 finalizer 혹은 cleaner를 사용하지 않고, 객체를 종료하는 방법은 무엇일까?

바로 AutoCloseable 을 구현하고, 클라이언트에서 인스턴스를 종료할 때 close 메서드를 호출해주는 것이다. 이때 일반적으로 예외가 발생해도 제대로 종료되도록 try-with-resources를 사용해야 한다.

또한, 각 인스턴스는 자신이 닫혔는지 추적하는 것이 좋다.
close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필들르 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것이다.


✔️ 적절한 활용 방법

이제 cleaner와 finalizer의 적절한 쓰임새 2가지를 알아보자.

1️⃣  첫 번째는 안전망 역할이다. 이것은 자원의 소유자가 close 메서드를 호출하지 않는 것에 대비하기 위한 것이다. 물론 cleaner 혹은 finalizer가 호출된다는 보장은 없지만, 클라이언트가 하지 않은 자원 회수를 늦게라도 해주는 것이 아예 안하는 것보다는 낫다. 

2️⃣  두 번째는 네이티브 피어(Native peer)와 연결된 객체에서 사용할 수 있다.

💡 네이티브 피어란, 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다.

네이티브 피어는 자바 객체가 아니기 때문에 가비지 컬렉터가 그 존재를 알지 못한다. 따라서 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못하므로 이때 cleaner 혹은 finalizer를 사용할 수 있다.
단, 성능 저하와 즉시 회수되지 않아도 될 때에만 사용하자. 그렇지 않다면 close 메서드를 사용해야 한다.

cleaner 안전망 예시

다음은 cleaner를 안전망으로 활용하는 AutoCloseable 클래스 예시이다. 

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;
        }
        
        @Override
        public void run() {
            System.out.println("방 청소");
            numJunkPiles = 0;
        }
    }
    
    private final State state;
    
    private final Cleaner.Cleanable cleanable;
    
    public Room(int numJunkPiles) {
    	state = new State(numJunkPiles);
        cleanable = cleaner.register(this, state);
    }
    
    @Override
    public void close() {
    	cleanable.clean();
    }
}

State 클래스

  • 정적 중첩 클래스
  • cleaner가 방을 청소할 때 수거할 자원을 담고 있다.
  • Runnable을 구한하고, run 메서드는 cleanable에 의해 딱 한 번만 호출된다.
    ➡️ close 메서드가 호출될 때, 혹은 클라이언트가 close를 호출하지 않고 cleaner가 run 메서드를 호출해줄 때
  • State의 인스턴스는 절대로 Room 인스턴스를 참조하면 안 된다. 이는 순환참조가 발생하여 가비지 컬렉터가 Room 인스턴스를 자동으로 회수해갈 수 없다. 

이제 위의 클래스를 사용하는 클라이언트 코드 예시를 보자.

public class Adult {
    public static void main(String[] args) {
        try (Room myRoom = new Room(7)) {
            System.out.println("안녕~");
        }
    }
}

cleaner는 단지 안전망으로만 쓰였다. 또한, Room의 생성을 try-with-resources 블록으로 감쌌다면 자동 청소는 전혀 필요하지 않다.

위의 프로그램은 "안녕~"을 출력하고 나서 "방 청소"를 출력할 것이다.

만약 try-with-resources 블록으로 감싸지 않으면 어떻게 될까?
다음 코드를 보자.

public class Teenager {
    public static void main(String[] args){
        new Room(99);
        System.out.println("아무렴");
    }
}

이 프로그램의 결과는 "아무렴"을 출력하고 나서 한 번도 "방 청소"를 출력하지 않을 수 있다.
이는 앞서 말한 "예측할 수 없는" 상황이다.


📌 핵심 정리

cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자.
물론 이런 경우라도 불확실성성능 저하에 주의해야 한다.

profile
백엔드 개발자

0개의 댓글