반응형

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();
    }
}
반응형

+ Recent posts