[이펙티브자바 : 아이템8] finalizer와 cleaner 사용을 피하라

cchoijjinyoung·2023년 8월 25일
0

이펙티브자바

목록 보기
2/5

들어가기 앞서

아이템8은 객체의 소멸과 관련된 내용이다.
객체가 가지고 있는 리소스를 제대로 정리하지 않고 소멸시키게 되면 리소스 고갈, 성능 저하, 데이터 손실 등의 문제가 생길 수 있기 때문에 객체의 소멸을 어떻게 하느냐는 매우 중요하다.

자바에서는 이러한 리소스들을 가비지 컬렉터(GC)가 자동으로 정리해주는데, 가비지컬렉터는 보통 JVM내의 메모리 리소스에 초점을 두어 관리한다. 그래서 메모리 외의 다른 리소스들도 관리해줘야하는데,
이 것을 해결하기 위해 기획된게 finalizercleaner이다.

* finalizercleaner 둘 다 객체가 가비지 컬렉터에 의해 회수되기 전에 리소스 정리나 기타 정리 작업을 수행하기 위해 사용된다.

그러나 단점이 너무 많아서 사실 상 쓸 수 없다. (cleaner는 그나마 사용할 수 있다.)

우리는 왜 사용하면 안되는 지 알아볼 것이고, 해결방법 또한 알아볼 것이다.


🤬 finalizer

권장하지 않지만, finalizer를 사용하려면,
우리가 갖고 있는 클래스에 finalize()를 오버라이딩 하면된다.
finalize()는 Object클래스에 정의되어 있고, 자바9부터 @Deprecated 됐다.

package item8

public class 내가_사용할_클래스 {
    @Override
    protected void finalize() throws Throwable {
    	// TODO 리소스 정리
    }
}

한가지 예시를 들어보자.

예를 들어, 'File' 객체를 사용하여 파일을 열게 되면, 그 파일에 대한 핸들 또는 참조가 생긴다.
이렇게 열린 파일은 시스템 리소스를 점유하게 된다.
여러 파일을 계속 열게 되면 사용 가능한 리소스가 줄어들 것이고, 결국 파일을 열 수 없게 될 수도 있다.
따라서 파일을 사용한 후에는 반드시 close() 메서드를 호출하여 해당 파일 리소스를 시스템에 반환해야한다.

지금까지의 정리

  1. 객체가 가비지 컬렉션의 대상이 되기 전에 finalize()메서드가 호출된다.
  2. 만약 해당 객체가 파일과 같은 리소스를 점유하고 있고,
  3. finalize() 내에 파일을 닫는 등의 작업하는 코드가 있다면,
  4. finalize() 호출 시점에 그 리소스가 반환된다.

이제부터는 문제점에 대해 알아보겠다.

🤔 대체 문제점이 뭐길래?

  • finalize()의 호출 시점은 정확히 예측할 수 없다. 이로 인해 파일과 같은 리소스가 필요 이상으로 오랜 시간 점유될 수 있다.
  • finalize()가 오류로 인해 제대로 실행되지 않으면 리소스는 반환되지 않을 수 있다.
  • finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수 있다.

* finalizer 공격이란 ?

코드로 예시를 들어보겠다.

  • User.class : name을 받는 생성자 존재, 단순 출력 hello() 메서드 존재.
  • CustomUser.class : User클래스를 상속받고, finalize()을 오버라이딩함.
  • AttackExam : 공격 예제

User.class

public class User {

    private String name;

    public User(String name) {
        this.name = name;
		// 단순히 name이 홍길동인 유저는 생성자에 들어올 때 예외를 던지게 만들었다.
        if (name.equals("홍길동")) {
            throw new IllegalArgumentException("홍길동은 가입 금지입니다.");
        }
    }
    public void hello() {
        System.out.println("안녕하세요. " + this.name + "입니다.");
    }
}

CustomUser.class

public class CustomUser extends User {

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

    @Override
    protected void finalize() throws Throwable {
    // finalize() 코드 내에서 hello()를 호출하고 있다는 것에 집중하자.
        hello();
    }
}

AttackExam.class

public class AttackExam {
    public static void main(String[] args) throws InterruptedException {
        User user;
        try {
            user = new CustomUser("홍길동");
        } catch (IllegalArgumentException e) {
            System.out.println("이러면??");
        }
        System.gc();
        Thread.sleep(3000L);
    }
}

위의 예제를 잠깐 살펴보면,
User user = new CustomUser("홍길동"); 에서 자식클래스인 CustomUser의 생성자가 호출되고, 당연히 부모클래스의 생성자도 호출된다.
하지만 우리는 name으로 홍길동을 받게되면 예외를 던지도록 했고, 이 예제에서도 물론 예외가 발생한다.
문제는 여기서 발생한다. '만들어지다 만 객체'GC되면서 finalize()가 호출된다.
그럼 finalize() 메서드내에 hello()메서드가 호출될 것이고 우리는 실행결과로 아래와 같은 콘솔창을 볼 수 있을 것이다.

// 이러면??
// 안녕하세요. 홍길동입니다.

그럼 어떻게 해결해야하나?
⚡️ User 클래스를 final로 작성하여 상속을 못하게 하자.
= 좋은 방법이다. 그렇지만 내 클래스가 상속을 해야만 한다면?
⚡️ User 클래스에도 finalize()를 오버라이딩 한 후에 final로 선언하자.
= 그럼 자식클래스가 finalize()는 오버라이딩할 수 없기 때문에 finalizer 공격으로 부터 안전하다.

다음으로는 cleaner에 대해 알아보겠다.

😡 cleaner

public class{

    private List<Object> 쓰레기들;

    public(List<Object> 쓰레기들) {
        this.쓰레기들 = 쓰레기들;
    }

    public static class 상태 implements Runnable {
        private List<Object> 청소할_쓰레기들;

        public 상태(List<Object> 청소할_쓰레기들) {
            this.청소할_쓰레기들 = 청소할_쓰레기들;
        }

        @Override
        public void run() {
            청소할_쓰레기들 = null;
            System.out.println("방 청소");
        }
    }
}

간단하게 설명하면 방.class 에는 쓰레기들이란 필드가 있고, Inner클래스로 상태.class를 갖는다.
위 코드에서 봐야할 것은
1. 상태.class 가 static 으로 선언됨으로써 방.class 를 참조하지 않는다는 것.
2. 상태.class 가 Runnable 을 구현했고 run()메서드를 오버라이딩한 것.

아래 보이는 나.classCleaner 객체를 생성하고,
register() 메서드로 해당 객체가 GC가 될 때 상태.classrun() 메서드를 호출하라고 등록하였다.

public class{
    public static void main(String[] args) throws InterruptedException {
        Cleaner cleaner = Cleaner.create();

        List<Object> 쓰레기들 = new ArrayList<>();
        방 내방 = new(쓰레기들);

        // 클리너에 등록 : 내방이 GC가 될 때, 상태의 Runnable을 사용해서 정리해라.
        cleaner.register(내방, new.상태(쓰레기들));

        내방 = null; // 내방 객체가 null이 됨으로써
        System.gc(); // 가비지컬렉터가 해당 객체를 메모리에서 제거하려 할 것이고, 
        			 // 그 직전에 run() 메서드가 호출된다.
        Thread.sleep(3000L);
    }
}

Cleaner도 마찬가지로 즉시 수행된다는 보장이 없다.
그러면 이것들을 대신해줄 묘안은 무엇일까?

바로 AutoCloseable이다.

😊 AutoCloseable

public classimplements AutoCloseable { 
// 그저 AutoCloseable을 구현하고, close() 메서드 오버라이딩

    private int 쓰레기들_갯수 = 100;

    public void autoCleanUp() {
        System.out.println("자동 청소");
        this.쓰레기들_갯수 = 0;
    }

    @Override
    public void close() {
        try {
            autoCleanUp();
        } catch (Exception e) {
            throw new RuntimeException("faild to cleanUp ");
        }
    }
}
public class Exam {

    public static void main(String[] args) {
		// close() 메서드를 호출하면 된다.    
        // try-with-resources 를 사용해서 예외가 발생해도 제대로 종료하도록 한다.
        try (방 내_방 = new()) {
            // TODO 자원 반납 처리
        }
    }
}

간단한 예제이므로 주석으로만 설명해두었다.

그럼 마지막으로 AutoCloseable + cleaner(안전망 역할) 코드를 보겠다.

보기 전에 간략하게 설명하면, 방, 엄마, 나 세 개의 클래스로 구성되어있다.
방.class 는 AutoCloseable을 구현했고, Runnable을 구현한 상태.class 내부클래스가 존재한다.
엄마.class 는 try-with-resources를 사용한것이고,
나.class 는 사용하지 않았다.

public classimplements AutoCloseable {
    private static final Cleaner cleaner = Cleaner.create();

    private static class 상태 implements Runnable {
        int 쓰레기_갯수;

        상태(int 쓰레기_갯수) {
            this.쓰레기_갯수 = 쓰레기_갯수;
        }

        @Override
        public void run() {
            System.out.println("방 청소");
            쓰레기_갯수 = 0;
        }
    }
    private final 상태 내_방_상태;

    private final Cleaner.Cleanable cleanable;

    public(int 쓰레기_갯수) {
        내_방_상태 = new 상태(쓰레기_갯수);
        // 어떤 오브젝트(this)가 GC가 될 때, 이 자원(내_방_상태)을 해제하라.
        cleanable = cleaner.register(this, 내_방_상태);
    }

    @Override
    public void close() {
        cleanable.clean();
    }
}
public class 엄마 {
    public static void main(String[] args) {
        try (방 내_방 = new(10)) {
            System.out.println("방이 지저분하네.");
        }
    }
}
public class{
    public static void main(String[] args) {
        new(10);
        System.out.println("방이 지저분하네.");
    }
}
// 엄마.class 
// 실행결과 :
// 방이 지저분하네.
// 방 청소

// ----------------------

// 나.class
// 실행결과 : 
// 방이 지저분하네.

나.class에서도 방 청소가 출력될 것이라고 예상했다면, 이게 바로 앞서 말한 '예측할 수 없는 상황'이다.

public class{
    public static void main(String[] args) {
        new(10);
        System.out.println("방이 지저분하네.");
        
        System.gc(); // 이 코드를 추가함으로써 "방 청소"를 출력할 수도 있고 안 할수도 있다.
    }
}

✔️ 정리

  • finalizer와 cleaner는 즉시 수행된다는 보장이 없다.
  • finalizer와 cleaner는 실행되지 않을 수도 있다.
  • finalizer 동작 중에 예외가 발생하면 정리 작업이 처리되지 않을 수도 있다.
  • finalizer와 cleaner는 심각한 성능 문제가 있다.
  • finalizer는 보안 문제가 있다.
  • * 반납할 자원이 있는 클래스는 AutoCloseable을 구현하고 클라이언트에서 close()를 호출하거나 try-with-resource를 사용해야 한다.
profile
반갑습니다 :)

0개의 댓글

관련 채용 정보