JAVA/Effective Java

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

꾸준함. 2024. 1. 23. 14:30

Java 8까지는 finalizer를 사용하여 자원을 정리했으며, Java 9부터는 cleaner가 도입되어 자원을 적절한 타이밍에 정리할 수 있도록 제공되었습니다.
기본적으로 Java에서는 객체가 더 이상 참조되지 않을 때 GC가 해당 객체를 수거하기 때문에 별도 처리를 안 해도 되는 케이스가 대부분이지만 파일이나 네트워크 리소스와 같은 외부 자원을 사용하는 객체의 경우, 해당 리소스를 명시적으로 해제해야 할 수 있습니다.
이를 위해 finalizer() 메서드와 cleaner가 기획되고 제공이 되었지만 아래에 열거할 이유들로 인해 이러한 메커니즘의 사용을 자제해야합니다.

  • finalizer와 cleaner는 즉시 수행된다는 보장이 없음
  • finalizer와 cleaner는 실행되지 않을 수도 있음
  • finalizer 동작 중 예외가 발생할 경우 정리 작업이 처리되지 않을 수 있음
  • finalizer는 보안 문제를 야기 가능
  • finalizer와 cleaner는 성능 문제를 야기 가능

 
이 때문에 아래에서도 언급하겠지만 반납할 자원이 있는 클래스는 AutoClosable을 구현하고 클라이언트에서 try-with-resource를 사용하거나 close()를 직접 호출하는 것을 권장합니다.

 

finalizer 개요 및 사용 권장하지 않는 이유

Java 8에서 finalizer는 객체가 가비지 컬렉션(Garbage Collection)되기 직전에 호출되는 메서드입니다.
finalize() 메서드를 사용하여 객체의 자원을 해제하거나 정리하는 작업을 수행할 수 있으며 일반적으로 객체가 더 이상 참조되지 않을 때 호출되며, 객체가 파괴되기 전에 마지막으로 실행되는 코드를 제공합니다.

 
finalizer 성능 문제

 
finalize() 메서드를 권장하지 않는 이유는 메서드가 내부적으로 앞서 item 7에서 간단히 언급한 Phantom Reference처럼 정리할 자원을 ReferenceQueue에 쌓아둔 뒤 처리하는데 자원을 정리하는 우선순위보다 객체를 우선순위가 더 높기 때문에 성능 문제를 야기할 수 있습니다.
실제로 아래 코드를 실행하면 finalize() 메서드로 인해 자원을 정리하기 전에 자원 정리 목록이 큐에 몇십 만개씩 쌓이고 있는 것을 확인할 수 있습니다.
 

finalizer 성능문제

 
finalizer attack

 
또한 finalizer는 finalizer attack이라는 보안 문제를 야기할 수 있습니다.
두 가지 예시를 들건대 둘 다 상속과 관련이 있습니다.

  • 은행 계좌 동결
  • 좀비 클래스

 
은행 계좌 동결
은행 계좌 Account가 있고 우크라이나 전쟁 주범 푸틴의 계좌를 동결하기 위해 생성자에서 전달된 계좌 ID가 "푸틴"일 경우 예외를 던진다고 가정하겠습니다.
"푸틴"의 Account 인스턴스를 생성할 때 예외가 발생하므로 계좌가 동결된 것처럼 보이지만 사실 Account 클래스를 상속받는 서브 클래스가 존재할 경우 아래 프로세스를 통해 동결된 계좌를 통해 차명 계좌로 돈을 송금하는 것이 가능해집니다.

  • Account를 상속하는 BrokenAccount의 finalize() 메서드를 오버라이드하여 동결된 계좌로부터 돈을 보내는 메서드를 호출하도록 구현
  • BrokenAccount를 생성하되 생성자에서 예외를 던진 것을 잡기만 하고 던지지 않음
  • GC가 돌 때 오버라이드 된 finalize 메서드를 호출하여 동결된 계좌로부터 돈을 송금

 

 
좀비 클래스
생성자나 직렬화하는 과정에서 예외 발생 시 finalizer() 메서드가 수행되는데 해당 메서드를 악의적으로 오버라이딩한 하위 클래스의 finalizer()가 실행될 수 있습니다.
이때 finalizer를 정적 필드에 할당할 경우 참조가 여전히 유효한 것으로 판단하여 GC에 의해 수거되지 않는 문제점이 발생합니다.
따라서, finalizer나 cleaner 모두 정리할 자원을 절대 직접 참조하면 안 됩니다!
 

 
finalizer attack 방지법

 
앞서 두 예시에서 확인할 수 있다시피 finalizer attack은 주로 상속에서 발생합니다.
따라서 아래 두 방법을 통해 방지가 가능합니다.

  • 클래스 자체를 final로 선언하여 상속 불가능하게 처리
  • 좀 더 유연하게 상속은 가능하되 finalize() 메서드를 final로 선언하여 오버라이딩 불가능하게 처리

 
finalizer deprecated

 
위와 같은 문제점들이 발견되며 finalizer는 java 9+ 버전부터 deprecated 되었습니다.
이에 따라 저자는 자원의 close() 메서드를 직접 호출하거나 AutoClosable을 구현하고 클라이언트에서 try-with-resource를 사용하는 것을 권장하고 있습니다.
 

finalize deprecated

 

cleaner 개요 및 사용 권장하지 않는 이유

Java 9에서는 finalizer() 메서드 대신 java.lang.ref.Cleaner 클래스가 도입되었습니다.
이 클래스는 자바의 가비지 컬렉션 시스템과 관련이 있으며 별도의 쓰레드(Runnable)가 돌며 더 이상 참조되지 않는 자원의 메모리 회수 작업을 수행합니다.

 
Cleaner 예제 코드

 
 

 
 
 
cleaner는 자체적으로 관리하는 쓰레드를 통해 정리 작업을 수행하기 때문에 finalize() 메서드가 실행되는 쓰레드가 불명확하고 제어하기 어려웠던 문제점을 보완했습니다.
그럼에도 불구하고 cleaner는 finalizer() 메서드와 마찬가지로 즉시 수행된다는 보장이 없고 실행 여부를 예측할 수 없을뿐더러 성능 문제를 야기할 수 있기 때문에 권장하지 않습니다.
다만, 이후 대안에서 후술 하겠지만 cleaner의 경우 최종 안전망 및 네이티브 피어와 연결된 객체의 회수에 사용될 수 있습니다.

 
cleaner 성능 문제

 
cleaner를 사용할 때 발생할 수 있는 성능 문제는 아래와 같습니다.

  • 콜백 비용
  • 메모리 오버헤드

 
콜백 비용
cleaner는 객체가 참조를 잃었을 때 람다 표현식 혹은 Runnable 객체로 정의된 콜백을 실행하는데 이때 비용이 발생할 수 있습니다.
특히, 정리 작업이 복잡하고 많은 리소스를 소비하는 경우 콜백 실행 비용이 높아질 수 있습니다.
 
메모리 오버헤드
cleaner를 사용할 경우 객체마다 Cleanable 인스턴스를 생성하고 유지해야 하고 이로 인해 추가적인 메모리 오버헤드가 발생할 수 있습니다.
특히 많은 수의 객체가 생성된 상태에서 모두 Cleaner를 사용하여 참조되지 않은 자원을 정리하는 환경에서 위에 언급한 오버헤드가 누적될 수 있습니다.

 

finalizer와 cleaner의 대안

앞서 언급했다시피 finalizer와 cleaner의 대안으로 AutoCloseable 인터페이스를 구현하고 클라이언트에서 try-with-resource를 사용하거나 close()를 직접 호출하는 방법이 있습니다.
AutoClosable 인터페이스는 이펙티브 자바의 저자가 구현했으며 Java 7+ 버전부터 적용할 수 있습니다.
 

AutoClosable

 
마침 아이템 9가 try-with-resource이기 때문에 다음 게시글에서 자세하게 설명하겠지만 AutoClosable 구현체를 통해 close() 메서드를 오버라이딩하면 클라이언트에서 try-with-resource 사용 시 finally 구문을 추가할 필요가 없어집니다.
try-with-resource 사용 시 이점은 아래와 같습니다.

  • 리소스 관리 코드를 생략할 수 있어 가독성 및 유지보수성 향상
  • 자동으로 자원이 해제되기 때문에 자원 반납 보장

 

 
 
AutoClosable 구현체인데도 불구하고 클라이언트가 try-with-resource를 사용하지 않을 경우 Cleanable은 안전망 역할을 합니다.
즉 클라이언트가 명시적으로 close()를 호출하지 않더라도 GC가 돌 때 자원을 회수할 수 있도록 서브 개념으로 Cleanable을 구현해 줄 경우 참조하지 않는 자원을 확실하게 회수한다고 보장할 수 있습니다.
또한 cleaner는 내부적으로 phantom reference를 사용하는데 C와 C++로 작성된 OS에 특화된 코드인 native peer 자원을 해제하는 데 사용되기도 합니다.

 

정리

java에서 참조되지 않는 자원을 회수하기 위해 finalizer()와 cleanable을 기획하고 구현했지만 치명적인 단점들이 존재하기 때문에 AutoClosable 인터페이스를 구현하고 try-with-resource를 사용하는 것을 권장합니다.
finalizer()는 java 9+ 버전부터 deprecated 되었고 사용을 지양해야 하지만 cleanable의 경우 finalize()보다는 안전성이 있어 try-with-resource의 서브 개념으로 사용할 수 있습니다.
 

참고

이펙티브 자바
이펙티브 자바 완벽 공략 1부 - 백기선 강사님
https://jjingho.tistory.com/51

 

[이펙티브 자바] Item8- finalizer와 cleaner 사용을 피하라

자바의 객체와 관련된 자원 회수는 가비지 컬렉터가 담당하고 있다. 따라서 프로그래머에게 객체 자원 수거에 대한 아무런 작업도 요구하지 않지만, 그럼에도 자바는 두 가지 객체 소멸자를 가

jjingho.tistory.com

https://olrlobt.tistory.com/76

 

[Java] 객체 소멸자 Finalizer와 Cleaner의 문제점과 대안책

Finalizer finalize() 메서드는 java.lang.Object 클래스에 정의되어 있으며, 자바에서 객체가 가비지 컬렉션에 의해 제거될 때 실행된다. 즉, Finalizer는 자바에서 객체가 소멸될 때 마지막으로 수행할 수

olrlobt.tistory.com

 

반응형