BackEnd/Java

X바(자바) finalize 메소드 쓰지마세요

Potwings 2024. 5. 6. 23:56

Java9부터는 Deprecate 되었어요 쓰지 마세요.

 

과거 필자는 특정 객체 동작하면서 남긴 임시 파일을 해당 객체가 소멸될 때 제거되도록 하기 위해

finalize 메소드를 사용하여 처리하려 했던 적이 있었다.

허나 적용하기 위해 검색하여 보니 finalize 메소드를 사용하지 말라는 내용이 많았고

이펙티브 자바에 자세하게 설명되어 있어 이 내용을 한번 정리해보고자 한다.

 

finalizer란?

자바에서 객체가 더 이상 참조되지 않을 때 Garbage Collector가 객체에 할당한 메모리를 회수하기 전

객체의 finalize 메소드의 내용을 진행하는 객체 소멸자이다.

이외에도 자바에는 cleaner라는 객체 소멸자가 하나 더 있다.

 

finalizer 예시

public class Finalize {

  @Override
  protected void finalize() throws Throwable {
    // 객체 소멸 전 수행할 내용
    System.out.println("finalize를 Override하면 finalizer 사용");
  }
}

 

cleaner 예시

import java.lang.ref.Cleaner;

public class Resource {
  private static final Cleaner cleaner = Cleaner.create();
  private Cleaner.Cleanable cleanable;

  private static class ResourceCleaner implements Runnable {
    @Override
    public void run() {
      // 객체 소멸 전 수행할 내용
      System.out.println("Cleaner 진행 시 수행할 내용");
    }
  }
  public Resource() {
	// 객체 생성 시 cleaner에 등록
	this.cleanable = cleaner.register(this, new ResourceCleaner());
  }
}

 

허나 이펙티브 자바에서는 finalizer와 cleaner 둘 다 사용하지 말라고 한다.

내용만 보면 객체가 소멸될 때 뒤처리를 해주는 아주 편리한 메소드로 보이지만 왜 쓰지 말라 할까?

 


객체 소멸자(finalizer, cleaner)를 사용하면 안 되는 이유

finalizer, cleaner는 언제 실행될지 모른다

객체 소멸자가 얼마나 정확하고 신속하게 수행될지는 gc의 알고리즘에 따라 천차만별이다. 이로 인해 제때 실행하는 작업은 절대 할 수 없다.

finalizer는 다른 쓰레드보다 우선순위도 낮아 실행될 기회를 제대로 얻지 못한다.

한편 cleaner는 자신을 수행할 스레드를 제어할 수 있으나 이 또한 GC의 통제하에 있어 즉각 수행되리라는 보장은 없다.

 

심지어 둘은 실행이 될 거라는 보장도 할 수 없다.

System.gc나 System.runFinalization 메소드를 사용하여도 실행 가능성을 높여줄 뿐 객체 소멸자가 실행되지 않을 수도 있다.

 

 

finalize 메소드 내부에서 발생한 exception 처리할 수 없다.

finalize 메소드 실행 중 Exception이 발생했을 경우 그 순간 메소드가 종료된다.

처리하지 못한 예외로 인하여 메모리 회수를 진행하는 해당 객체는 덜 마무리된 상태로 남을 수 있다.

이 덜 마무리된 객체를 다른 스레드에서 사용할 경우 어떻게 동작될지 예측할 수 없다.

 

그나마 cleaner는 자신의 스레드를 통제하기 때문에 이 문제는 발생하지 않는다.

 

 

성능 저하 발생

finalizer와 cleaner를 사용할 경우 객체 생성 후 GC가 객체를 수거하기까지 시간이 더 느려진다.

책에 나온 각 방식별 처리하는데 소요되는 시간은 아래와 같다.

AutoCloseable 객체를 try-with-resource를 활용하여 처리 - 12ns
finalizer가 있는 객체에 대한 처리 - 550ns
cleaner를 사용하는 객체에 대한 처리 - 500ns

 

필자는 여기서 finalize()에서 큰 작업이 없을 경우 성능의 차이가 없을 것이라 생각하였으나,

책에 나와있는 수행시간이 왜 이리 큰 차이가 나는지 의문이 들었다.

 

검색해 보니 잘 정리되어 있는 글이 있어 참고하였다.

https://jaeyeong951.medium.com/finalize-%EC%9D%80%ED%87%B4%EC%8B%9D-4a52fb855910

 

Java finalize() 은퇴식

Java Object 클래스의 메소드 중에는 finalize() 라는 녀석이 있습니다. 아주 오래전 Java 태동기부터 존재했고 그 설계 의도도 명확했죠. 하지만 최근 몇년간 많은 문제점과 논란이 제기된 끝에 JDK 9 버

jaeyeong951.medium.com

 

성능이 저하되는 이유는 finalize()를 Override한 객체의 경우 GC의 처리 방식이 달라지기 때문이었다.

finalize()를 Override한 객체에 대한 GC의 처리 방식
1. finalize()를 Override한 객체는 별도의 큐로 이동
2. 별도의 finalize 스레드가 큐에 저장된 각 객체마다 정의된 finalize()를 호출하며 처리
3. finalize()가 종료된 객체는 다음 GC 사이클에 수집될 준비

 

 

보안 문제 발생(finalizer attack)

finalize()를 사용하는 클래스는 finalizer 공격에 노출되어 심각한 보안문제를 일으킬 수 있다. 우선 finalizer attack이 어떻게 이루어지는지부터 알아보자.

 

finalizer attack의 예시

백기선님의 좋은 영상이 있어 이를 참고하여 예시를 만들어 보았다.

https://www.youtube.com/watch?v=6kNzL1bl1kI

 

우선 Player라는 클래스가 있다. 이 클래스는 생성될 때 누구를 막을지 지정하고 생성된다.

public class Player {

  private String target;

  public Player(String target) {
    this.target = target;
    if (target.equals("신명호")) {
      throw new IllegalArgumentException("신명호는 놔두라고!");
    }
  }

  public void block() {
    System.out.println("Block player " + this.target);
  }
}

 

허나 신명호는 막을 필요가 없기 때문에 Player의 target을 신명호로 생성할 경우 Exception을 발생한다.

 

우리는 finalizer attack을 진행하기 위해 아래와 같은 Player을 상속받는 BadPlayer를 만들어준다.

public class BadPlayer extends Player {

  public BadPlayer(String name) {
    super(name);
  }

  @Override
  protected void finalize() throws Throwable {
    this.block();
  }
}

 

BadPlayer는 finalize()에서 Player의 block 메소드를 호출한다.

 

BadPlayer에 있는 finalizer로 인해 어떤 문제가 발생하는지 아래 코드를 실행하여 확인해 보자.

public class PlayerMain {

  public static void main(String[] args) throws Exception {
    Player player = null;
    try {
      player = new BadPlayer("신명호");
    } catch (Exception e) {
      System.out.println("40분 내내 얘기했는데 안 들어 먹으면 어떡하자는 거야!");
    }
    System.gc(); // finalize 실행 가능성 높이기 위해 사용
    Thread.sleep(3000L); // finalize 실행을 위한 대기
  }
}

 

신명호를 타겟으로는 생성이 안될 것으로 예상되나. 실제 실행 결과는 아래와 같다.

 

일반적인 Player는 신명호를 막기 위해 생성하였을 경우 정상적으로 생성되지 않아 신명호를 Block할 수 없다.

허나 BadPlayer에서는 생성 실패 후 객체 소멸 시 block 메소드를 호출하여 신명호를 Block하고 있었다.

 

생성자에서 정상적으로 객체 생성이 안되었음에도 불구하고 finalize()에서 block을 진행하기 때문에 block메소드가 실행된 것이다.

이를 우리는 finalizer attack이라 부른다.

 

그럼 이와 같이 비정상적인 동작을 하는 finalizer attack을 막기 위해선 어떻게 해야 할까?

1. Class에 final을 선언

2. Class에 finalize() 메소드 상속 후 final 선언

위와 같은 방법을 통하여 하위 클래스에서 finalize를 Override할 수 없도록 하면 된다.

 

 

지금까지 finalizer와 cleaner를 사용하면 안 되는 이유만 알아보았다.

그럼 자바는 이 둘을 그냥 없애버릴 것이지 왜 남겨두었을까?

우리 개똥친구(finalizer, cleaner)도 약에 쓰이는 경우가 있다.


객체 소멸자(finalizer, cleaner)의 올바른 사용처

네이티브 피어와 연결된 객체에 사용

네이티브 피어란 일반 자바 객체에서 관리하는 이외의 자원을 말한다. 이는 자바 객체가 아니니 GC는 그 존재를 알지 못한다.

이로 인해 자바 피어를 회수할 때 네이티브 피어는 회수되지 못한다.

따라서 cleaner와 finalizer을 통하여 자바 피어가 회수될 때 처리하기 적당한 작업이다.

 

허나 앞서 말했듯이 실행이 객체 소멸자의 처리가 보장되진 않으므로 심각한 자원을 가지고 있지 않을 경우만 사용한다.

만일 성능 저하나 즉시 회수가 필요할 경우 AutoCloseable를 상속받아 close 메소드를 사용하도록 하자.

 

 

안전망 역할로 사용

자원의 소유자가 close 메소드를 호출하지 않는 것을 대비하기 위한 안전망 역할로 사용 가능하다.

객체 소멸자를 통한 처리가 보장되진 않지만 클라이언트가 수행하지 않은 자원 회수를 아예 안 해주는 것보단 낫기 때문이다.

 

허나 여기서 finalizer는 사용하지 말자.

책에서는 FileInputStream, FileOutputStream, ThreadPoolExecutor이 finalizer을 안전망 역할로 사용한다 하였으나

Java17에서 확인한 결과 모두재는 finalizer을 사용하지 않는 것으로 확인되었다.

 

Cleaner를 안전망으로 사용하는 방법을 예시를 통해 알아보자

 

안전망으로 Cleaner 사용

import java.lang.ref.Cleaner;

public class Room implements AutoCloseable {

  private static final Cleaner cleaner = Cleaner.create();
  private final State state;
  private final Cleaner.Cleanable cleanable;

  public static class State implements Runnable {

    int trash; // 청소 대상

    public State(int trash) {
      this.trash = trash;
    }

    @Override
    public void run() {
      // close에서 진행했어야할 마무리 작업
      trash = 0;
      System.out.println("청소 완료");
    }
  }

  public Room(int trash) {
    this.state = new State(trash);
    cleanable = cleaner.register(this, state);
  }

  @Override
  public void close() throws Exception {
    cleanable.clean(); // 마무리 작업 대신 clean 호출
  }
}

 

일반적인 AutoCloseable을 상속받는 객체에서 close 시 객체에 대한 마무리 작업을 하는 것 대신 cleanable의 clean을 호출하는 것이다.

그 후 cleaner를 통해 기존 close에서 진행했어야 할 작업(State의 run)을 진행해 주는 것이다. 

이렇게 구현할 경우 클라이언트가 close를 호출하지 않았다면 cleaner가 State의 run을 자동으로 수행해 줄 것이다.

 

그럼 이렇게 안전망 방식으로 구현하였을 경우 안심해도 될까?

아니다. 이전에도 말했듯이 cleaner의 동작은 보장할 수 없기 때문이다.

 

따라서 안전망 방식은 그저 안전망일 뿐 반드시 try-with-resource를 통하여 처리해 주자.

 

또 책에서는 안전망을 추가했을 경우 객체 생성 후 GC가 수거하기까지 66ns가 걸린다고 한다.

안전망으로 인해 try-with-resource으로만 처리(10ns)보다 5배 정도 느려진다.

만일 안전망으로 사용하려 한다면 어느 정도 성능 상의 손해가 있음은 기억하자


정리

finalizer와 cleaner는 아래와 같은 위험이 있으므로 사용하지 말자.
1. finalizer, cleaner는 언제 실행될지 모른다
2. finalize 메소드 내부에서 발생한 exception 처리할 수 없다.
3. 성능저하 발생
4. 보안 문제 발생(finalizer attack)

 

가장 좋은 방법은 try-with-resource을 사용하여 처리해 주는 것이다.