본문 바로가기

OOP, FP/이펙티브 자바

이펙티브 자바 06. 종료자

종료자


개발자가 메모리에 직접적으로 관여하는 C++와 같은 언어는 생성자와 쌍으로 존재하야하는 소멸자가 있다. C++에서 이와같은 소멸자는 생성된 자원을 반환하는 수단으로 사용된다. 반면에, 쓰레기 수집기가 존재하는 자바와 같은 언어는 객체에 할당된 자원을 수집기가 알아서 반환하므로 개발자가 직접 소멸자를 호출할 필요가 없기에, 소멸자가 존재하지 않는다.


하지만 자바의 최상위 클래스인 Object 클래스에는 finalize라는 종료자 메소드가 정의되어 있다. 이 메소드는 JVM이 메모리 누수(leak) 를 방지하기 위해 실행하는 메소드로, 가비지 컬렉션이 수행될 때 더 이상 사용하지 않는 자원에 대한 정리 작업을 진행하기 위한 종료자 메소드이다.


C++ 의 소멸자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <string>
 
using namespace std;
 
class Foo {
    private:
        int id;
    public:
        Foo(int id) {
            this->id = id;
            cout << "Foo 생성자 호출" << endl;
        }
        ~Foo() {
            cout << "Foo 소멸자 호출" << endl;
        }
};
 
class Bar : public Foo {
    private:
        int id;
        string name;
    public:
        Bar(int id, string name) : Foo(id) {
            this->name = name;
            cout << "Bar 생성자 호출" << endl;
        }
        ~Bar() {
            cout << "Bar 소멸자 호출" << endl;
        }
};
 
int main() {
    // 메모리 head 동적 할당
    Bar *foo = new Bar(1"plposer");
    
    // 명시적인 동적할당 해제
    delete foo;
}
 
// 호출결과
Foo 생성자 호출
Bar 생성자 호출
Bar 소멸자 호출
Foo 소멸자 호출
cs


Foo를 상속하는 Bar 하위 클래스를 만들고, 자식 클래스인 Bar의 인스턴스를 생성한다. delete를 이용하여 명시적으로 동적할당을 해제한 결 과이다. C++는 위와 같이 반드시 소멸자가 호출된다는 특징을 가진다.


자바의 종료자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class Foo {
    
    private int id;
    
    public Foo(int id) {
        this.id = id;
        System.out.println("Foo 생성자 호출");
    }
    
    @Override
    protected void finalize() throws Throwable {
        System.out.println("Foo 종료자 호출");
    }
}
 
class Bar extends Foo {
 
    private int id;
    
    private String name;
    
    public Bar(int id, String name) {
        super(id);
        this.name = name;
        System.out.println("Bar 생성자 호출");
    }
    
    @Override
    protected void finalize() throws Throwable {
        System.out.println("Bar 종료자 호출");
    }
}
 
class Main {
    public static void main(String args[]) throws Throwable {
        Bar bar = new Bar(1"plposer");
        
        bar.finalize();
    }
}
 
호출결과
Foo 생성자 호출
Bar 생성자 호출
Bar 종료자 호출
 
cs


C++의 프로그램을 자바로 작성한 예제이다. C++과 달리 자바는 자동으로 상위 클래스의 종료자를 호출하지 않는다. 따라서 자식 클래스의 종료자안에서 super.finalize();와 같이 상위 클래스의 종료자를 명시적으로 호출해야한다. 결국, 자바의 종료자는 반드시 실행된다는 것을 보장하지 않는다.


종료자 사용을 피하라


C++ 에서 소멸자는 메모리 이외의 자원(파일 기술자와 같은)을 반환하는 용도로도 사용된다. 자바에서는 try-catch-finally 블록이 그런 용도로 사용된다.


종료자의 실행을 보장하지 않기 때문에, 어떤 객체에 대한 모든 참조가 사라지고 나서 종료자가 실행되기까지 긴 시간이 걸릴 수도 있다. 그렇기 때문에 중요한 작업을 종료자 안에서 처리하면 안된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Foo {
    private FileOutputStream foStream;
    
    public Foo(File file) throws FileNotFoundException {
        foStream = new FileOutputStream(file);
    }
    
    @Override
    protected void finalize() throws Throwable {
        try {
            if (foStream != null) foStream.close();
        } finally {
            super.finalize();
        }
    }
}
cs


예를 들어, 위처럼 종료자 안에서 파일을 닫도록 하면 치명적 결함이 생긴다. 파일 기술자(file descriptor)가 유한한 자원이기 때문이다. 종료자의 실행이 보장되지 않으며, JVM이 종료자를 천천히 실행하기 때문에 열려있는 상태의 파일이 많이 남아있을 수 있다. 그 상태에서 새로운 파일을 열려고 하면 오류가 나게 된다. 종료자를 호출했으므로 즉시 자원이 반환되었을거라 생각했지만, 실제로는 아직 실행되지 않아서 오류가 나게 되는 것이다. 만약 종료자 안에서 무점검 예외가 던져지면 경고조차 출력되지 않는다.


자바 언어 명세에는 종료자가 즉시 실행되어야만 한다는 문구도, 반드시 실행되어야만 한다는 문구도 없다. 따라서 종료자가 실행되지 않은 객체가 남은 상태로 프로그램이 끝나는 일도 있을 수 있다. 그러므로 지속성이 보장되어아햐는 중요 상태 정보는 종료자로 갱신하면 안된다.


마지막으로 종료자는 프로그램 성능이 심각하게 떨어진다. 저자의 말에 따르면 간단한 객체를 만들고 삭제하는 프로그램에 종료자를 붙이자 속도가 430배 가량 느려졌다고 한다.



명시적인 종료 메소드


그렇다면 자바에서 파일이나 스레드처럼 명시적으로 반환해야하는 자원을 포함하는 클래스는 어떻게 작성해야 할까? 저자는 명시적인 종료 메소드를 하나 정의하고, 객체가 더 이상 필요하지 않을 경우 클라이언트가 해당 메소드를 호출할 수 있도록 작성하라고 한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void close() throws IOException {
    synchronized (
        if (closed) {
            return;
        }
        closed = true;
    }
    if (channel != null) {
       channel.close();
    }
 
    fd.closeAll(new Closeable() {
        public void close() throws IOException {
           close0();
       }
    });
}
cs


이런 명시적 종료 메소드의 예로는 위와 같은 파일과 관련된 스트림, java.sql.Connection에 정의된 close 메소드가 있다. 한가지 명심할 것은 종료 여부를 객체 안에 보관하여 유효하지 않은 객체임을 표시해야한다. 다른 메소드에서 해당 필드를 검사하여 이미 종료된 객체에 메소드를 호출하면 IllegalStateException과 같은 예외를 던지도록 하기 위함이다. 위의 코드는 FileInputStream의 코드인데, closed 필드를 사용했다. 간혹 실수로 이런 메소드를 호출하지 않는 경우가 있는데, 그럴 경우 성능이 떨어질 위험이 있다.


명시적 종료 메소드는 객체 종료를 보장하기 위해 보통 try-catch-finally 문과 함께 쓰인다. 명시적 종료 메소드를 finally 문 안에서 호출하도록 하면 객체 사용 과정에서 예외가 던져져도 종료 메소드가 실행되도록 만들 수 있기 때문이다.


* 자바 1.7부터는 try-with-resource 문을 지원하며, 이 구문을 이용하면 finally 블록을 사용하지 않아도 된다.


종료자의 쓰임


단점이 명확한 종료자가 쓰이기에 적합한 곳이 두 군데 정도 있다. 하나는 명시적 종료 메소드 호출을 잊을 경우에 대비하는 안전망으로서의 역할이다. 앞서 설명했듯이 언제 호출될 지 알 수 없어 반환이 늦어지긴 하겠지만, 클라이언트가 명시적 종료 메소드의 호출을 잊어도 어쩃든 자원은 반환된다. 종료자가 이런 자원을 발견하게 될 경우 반드시 경고 메시지를 로그로 남겨야 한다. 다만 이런 안전망을 구현하려 할 때는, 추가적인 비용을 감당하면서 구현할 만한 가치가 있는지 고려해야 한다.


두번 째는 네이티브 피어와 연결된 객체를 다룰 때이다. 네이티브 피어는 다른 언어로 만들어진 애플리케이션과 연결된 객체 즉, 일반 자바 객체가 아닌 네이티브 메소드를 통해 기능을 수행하는 네이티브 객체를 말한다. 이러한 네이티브 피어는 일반 객체가 아니므로 쓰레기 수집기가 알 수 없을 뿐더러 일반 자바 객체가 반환될 때 같이 반환할 수도 없다. 네이티브 피어가 중요한 자원을 점유하고 있지 않다면 종료자는 그런 객체의 반환에 적합하다. 만약 네이티브 피어가 즉시 종료되어야하는 중요 자원을 포함하는 경우에는, 명시적 종료 메소드를 정의하여 클라이언트가 호출하면 자원을 반환하게 해야한다.


만약 종료자를 사용하는 경우 주의할 점이 한가지 있다. 상속 관계에서 종료자의 연결이 자동으로 이루어지지 않는다는 것이다. 이는 앞서 등장한 Foo, Bar의 예제에서도 살펴볼 수 있었는데, 하위 클래스인 Bar의 종료자를 호출하여도 상위 클래스인 Foo의 종료자가 자동으로 호출되지 않는다. 또, 반드시 상위 클래스의 종료자는 하위 클래스의 종료자의 finally 블록 안에서 호출해야 하기 떄문에 상당히 불편하다. 이러한 불편함을 극복하는 한가지 방법은 종료되어야 하는 모든 객체마다 여벌의 객체를 하나 더 만드는 것이다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class Foo {
 
    private int id;
 
    public Foo(int id) {
        this.id = id;
        System.out.println("Foo 생성자 호출");
    }
 
    /*
     * 종료자 대신 여벌의 객체를 추가 
    @Override
    protected void finalize() throws Throwable {
        System.out.println("Foo 종료자 호출");
    }
    */
    
    private final Object finalizerGuardian = new Object() {
        @Override
        protected void finalize() throws Throwable {
            System.out.println("바깥 Foo 객체를 종료시킨다.");
        }
    };
}
 
class Bar extends Foo {
 
    private int id;
 
    private String name;
 
    public Bar(int id, String name) {
        super(id);
        this.name = name;
        System.out.println("Bar 생성자 호출");
    }
 
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("Bar 종료자 호출");
    }
}
 
class Main {
    public static void main(String args[]) throws Throwable {
        Bar bar = new Bar(1"s");
 
        bar.finalize();
        Thread.sleep(3000);
        bar = null;
        System.gc();
        while(true) {
            
        }
    }
}
 
호출결과
Foo 생성자 호출
Bar 생성자 호출
Bar 종료자 호출
Bar 종료자 호출
바깥 Foo 객체를 종료시킨다.
 
cs


하위 클래스에서 상위 클래스의 종료자를 재정의하면서 상위 클래스의 종료자 호출을 잊으면 상위 클래스 종료자는 절대로 호출되지 않는다. 이를 방지하기 위해 종료되어야하는 객체의 클래스 안에 종료자를 정의하는 대신, 익명 클래스 안에 종료자를 정의한다. 이 익명 클래스는 해당 클래스의 객체를 포함하는 바깥 객체를 종료시키는 것이다. 이러한 객체를 종료 보호자(finalizer guardian)라고 부른다. 종료 보호자는 종료되어야 하는 객체 안에 하나씩 넣는다. 종료 보호자의 바깥 객체에는 종료 보호자를 참조하는 private 필드가 있으며, 바깥 객체에 대한 모든 참조가 사라지는 순간, 종료 보호자의 종료자도 실행 가능한 상태가 된다. 이 보호자 객체의 종료자는 필요한 종료 작업을, 마치 바깥 객체의 종료자인 것처럼 수행한다.


코드를 살펴보면 Foo 클래스에 종료자가 없다는 것을 알 수 있다. 따라서 하위 클래스의 종료자가 상위 종료자를 호출하건 말건 상관없다. 이러한 종료 보호자 기법은 상속이 가능한 비-final 클래스를 구현할 때 반드시 고려해야 한다.


정리


이번 장의 내용은 자원반환에 대한 최종적 안전장치, 그다지 중요하지 않는 네이티브 자원의 종료, 이 두 가지 상황이 아니라면 종료자는 사용하지 말라는 것이다. 최종적 안전장치로 종료자를 사용하는 경우, 클라이언트에게 메시지를 로그로 남겨야 한다. 


마지막으로, 종료자를 사용해야 하는 상황이라면 상위 클래스의 종료자를 호출하는 것을 잊으면 안된다. 그러한 상위 클래스를 정의하는 경우 하위 클래스에서 상위 클래스의 종료자 호출을 잊어도 종료 작업이 진행될 수 있도록 종료 보호자 패턴을 도입하면 좋을지 고려해보자.