ITEM 8 "finalizer 와 cleaner 사용을 피하라"
이 ITEM 을 확인하기 전에 위 백기선님의 Effective Java 해설 영상을 보는 것을 추천한다.
처음에 글을 읽자마자 이해가 안 되었는데 영상을 통해 이해 되는 부분이 많았다. 무조건 시청하는 것을 추천한다.
아래 동영상은 finalizer 를 오버라이딩 했을 때 부모 객체의 필드를 접근할 수 있는 공격 예제이다. 단점 4에서 설명한다.
자바는 두 가지 객체 소멸자를 제공한다. finalizer 와 cleaner.
finalizer 와 cleaner 는 해당 객체가 JVM 에서 Garbage Collection 을 해야 할 대상이 될 때 호출되는 메서드이며, 모두 GC 로 인해 자원이 회수가 되어야만 소멸을 할 수 있다.
자바 9에서는 finalizer 를 deprecated API 로 지정하고 cleaner 를 사용 대안으로 제시하고 있지만, 둘 다 엄청난 단점들을 가지고 있어 두 소멸자를 의지하여 코딩하면 안 된다. C++ 에서 destructor(소멸자) 와 같은 개념으로 오해하기 쉽지만 전혀 다른 개념이다. 정말 자원 반납을 하고 싶다면 다음 장에서 언급할 try-with-resource 나 try-finally 를 통해 해야한다.
단점 1
finalizer 와 cleaner 가 언제 실행이 될지 알 수 없다. 스코프를 벗어난 지역변수라던가, 값이 할당되지 않은(null 인) 변수들이 GC 의 대상이 된다는 것은 얼핏 알고 있지만 GC 의 대상이 될 뿐 언제 GC 가 회수해 가는지 누구도 알지 못 한다. 전적으로 GC 알고리즘에 달려 있으며, 구현체마다 천차만별이다. 자원 해제하는데 시간이 얼마나 걸릴지 모른다는 것은 타이밍이 중요한 작업에서는 hell(지옥)이다.
finalizer 스레드는 다른 애플리케이션 스레드보다 우선 순위가 낮아서 실행될 기회를 제대로 얻지 못 할 수도 있다. 한편, cleaner 는 백그라운드에서 실행하며 자신을 수행할 스레드를 제어할 수 있다는 면이 있지만 GC 통제하에 있다 보니 즉각 수행되리라는 보장이 없다.
즉시 실행되지 않을 뿐 아니라 아예 실행하지 않을 수도 있다. 자바 언어 명세서에서도 수행 여부조차 보장하지 않는 것을 볼 수 있다. 따라서, Finalizer나 Cleaner로 저장소 상태를 변경하는 일을 하지 말아야 된다. 데이터베이스 같은 자원의 락을 그것들로 반환하는 작업을 한다면 전체 분산 시스템이 멈춰 버릴 수도 있다.
System.gc 나 System.runFinalization 메서드에 속지 말아야 한다. 실행될 가능성을 높여줄 수는 있으나 보장하지 않는다.
사실 실행을 보장하기 위해 System.runFinalizersOnExit 와 Runtime.runFinalizersOnExit 메서드를 제공하려고 시도했지만 심각한 결함 때문에 수십 년간 지탄받아 왔다.
단점 2
finalizer 동작 중 발생한 예외는 무시된다. 처리할 작업이 있다고 하더라도 바로 종료된다. 종료될 때 마무리가 덜 된 상태로 남을 수 있고 다른 스레드가 훼손된 객체를 사용한다면 어떻게 동작할지 예측이 불가하다. 스택 추적 내역을 확인할 수도 없다.
단점 3
심각한 성능문제가 있다. AutoCloseable 객체를 생성하고 try-with-resource 로 자신을 닫게 했다면 12ns 가 걸리지만 finalizer 를 사용하면 550ns 가 걸렸다고 한다. cleaner 도 마찬가지이다.
단점 4
원래 객체 생성이 안 되는데 상속 받아 하위 클래스에서 객체를 생성하고 finalize 를 오버라이딩해 상위 클래스의 기능을 사용할 수 있다. (finalize attack) finalize 는 객체가 소멸될 때 GC 에 의해 발생하므로 강제로 exception 을 발생시켜 해제함과 동시에 GC 가 회수해 가야 된다는 조건이 있긴 하다. 객체 생성을 막기 위해 생성자에서 예외를 던졌는데 finalize 를 통해 우회한 것이다. 사실 하위 클래스를 만들 권한이 있다면, 이미 심각한 수준의 보안 문제를 야기한 것이라고 생각하지만 Trigger 과정에서 공격의 범위가 넓어지는 것이니 보안 문제라고 볼 수 있다.
public class Account {
private String accountName;
public Account(String accountName) {
this.accountName = accountName;
if (this.accountName.equals("러시아")) {
throw new IllegalArgumentException("돌아가");
}
}
public void transfer(int amount, String to) {
System.out.printf("transfer %d from %s to %s.", amount, this.accountName, to);
}
}
public class BrokenAccount extends Account {
public BrokenAccount(String name) {
super(name);
}
@Override
protected void finalize() throws Throwable {
this.transfer(10000, "gold-egg");
}
}
class AccountTest {
@Test
void 오_마이_갓() {
Account account = null;
try {
account = new BrokenAccount("러시아");
} catch (Exception exception) {
System.out.println("No?");
}
System.gc();
Thread.sleep(3000L);
}
}
정말 finalize 를 사용해야 한다면 위와 같은 보안 문제를 막기 위해 final 로 선언하자.
그렇다면, 어떻게 자원을 해제해야 하는 것일까?
그저 자원을 해제할 클래스에 AutoCloseable 을 구현해주고 클라이언트에서 close 메서드를 호출하면 된다.
public class Resource implements AutoCloseable {
private boolean closed;
@Override
public void close() throws RuntimeException {
if (this.closed) {
throw new IllegalStateException();
}
closed = true;
System.out.println("close");
}
public void hello() {
System.out.println("hello");
}
// 안정망
@override
protected final void finalize() throws Throwable {
if(!this.closed) close();
}
}
public class Runner {
public static void main(String[] args) {
Resource resource1 = null;
// 첫 번째 방법. try-catch
try{
resource1 = new Resource();
resource1.hello();
} finally {
if(resource1 != null) {
resource1.close();
}
}
// 두번째 방법. try-with-resource
// scope 를 벗어나면 자동으로 close 메서드 호출
try(Resource resource2 = new Resource()) {
resource2.hello();
}
}
}
그럼 Cleaner 와 Finalizer 는 언제 필요할까?
첫 번째. 안전망 역할.
클라이언트가 실수로 close 메서드를 호출하지 않아 자원 반납을 안 할 수 있다. cleaner 와 finalizer 가 즉시 호출되지는 않지만 늦게라도 자원 회수를 해주는 것이 안전하기 때문에 사용된다. 바로 위 코드에서 finalize 를 오버라이딩해 close 를 삽입하여 안전망을 설치하는 것을 볼 수 있다.
두 번째. 네이티브 피어와 연결된 객체를 해제할 때 사용.
네이티브 오브젝트는 순수 자바코드로만 프로그래밍되지 않은 오브젝트를 말한다. 간혹 다른 언어나 어셈블리어를 통해 기능을 구현할 때가 있는데 그 기능들의 메서드를 통해 위임받아 기능을 수행하는 자바 오브젝트를 네이티브 피어라고 한다. 이 네티이브 피어는 가비지 컬렉터에서 그 존재를 알지 못하기 때문에 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못한다. 명시적으로 close 메서드를 사용하는 것이 best 이나 cleaner 와 finalizer 로도 자원 회수가 가능하다.
권장은 역시 close 메서드!
cleaner 사용 방법
위에서 finalizer 와 관련된 코드를 살펴보았지만 cleaner 사용방법은 확인하지 않았다.
AutoClosable 구현체 내부에 Cleaner 와 Cleanable 를 생성 후 생성자에서 해당 객체와 청소할 객체를 연결시켜주면 끝이다. 주의할 점은 청소할 객체에 AutoClosable 구현 객체를 주입하면 안 된다. 순환참조가 발생한다.
public class Resource implements AutoCloseable {
private boolean closed;
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
private final ResourceCleaner resourceCleaner;
public Resource(){
this.resourceCleaner = new ResourceCleaner();
this.cleanable = CLEANER.register(this, resourceCleaner);
}
private static class ResourceCleaner implements Runnable {
// 절대로 Resource 객체를 이 클래스에 주입하면 안 된다.
@Override
public void run() {
System.out.println("Clean");
}
}
@Override
public void close() throws RuntimeException {
if (this.closed) {
throw new IllegalStateException();
}
closed = True;
cleanable.clean();
}
}
'독후감 > Effective JAVA' 카테고리의 다른 글
[Effective JAVA] 10 "equals는 일반 규약을 지켜 재정의하라" (0) | 2022.04.30 |
---|---|
[Effective JAVA] 9 "try-with-resource 를 사용하라" (0) | 2022.04.29 |
[Effective JAVA] 7 "다 쓴 객체 참조를 해제하라" (0) | 2022.04.28 |
[Effective JAVA] 6 "불필요한 객체 생성을 피하라" (0) | 2022.04.28 |
[Effective JAVA] 5 "자원을 직접 명시하지 말고 의존 객체 주입을 사용하라" (0) | 2022.04.27 |