Effective Java Item 07

parrineau·2022년 7월 6일
0

EffectiveJava

목록 보기
7/14

7. 다 쓴 객체 참조를 해제하라

안녕하세요, 이번 포스팅은 "다 쓴 객체 참조를 해제하라"라는 주제로 포스팅을 진행하도록 하겠습니다.

여담으로, 영한님의 스프링 고급 강의를 마무리하고, 마지막에 중요한 말씀을 해주셨는데요.
바로 "기술적 겸손함"입니다.

주니어 개발자, 시니어 개발자 불문 모든 개발자들은 기술적으로 겸손해야 한다고 합니다.

A (4년차) : 나는 개발을 잘한다, 회사 업무를 모두 쳐낼 수 있다.
B (4년차) : 개발을 잘하는지 모르겠다, 공부를 하면 할수록 부족함을 느낀다.

(삽질은 수직으로만 절대 할 수 없습니다.)

흔히 속된말로 "삽질"이라고 합니다.
나무위키에서는... "하나 마나 어리석은 짓"이라고 표현되어 있지만, 여기서는 다른 의미로 해석하고 봐주시면 감사하겠습니다 ㅎㅎ

삽질을 오로지 밑으로만 판다면, 그새 다시 흙이 채워지고 어느새 포기하게 됩니다.
하지만, 수평으로 넓게 파면서 밑으로 간다면 어느새 깊어진 땅을 볼 수 있습니다.

이것을 공부로 비유하면, "아직까지 부족하다."라는 기술적 겸손함을 가져 좋은 개발자가 될 수 있다라고 하면 될까요...

저도 좋은 개발자가 되도록 노력하겠습니다.


흔히 코딩테스트를 보면 응시 언어 비율은 아래와 같습니다.

C++이 44%로 압도적인데, 이유는 바로 Time Complexity(시간 복잡도)를 크게 신경쓰지 않거나, 직접 메모리를 관리하여 효율적으로 알고리즘 문제를 풀 수 있기 때문입니다.
(좋다고 했지 제가 잘 쓴다고는 안했습니다.)

오늘 주제는 이것과 관련이 있습니다.

C++처럼 메모리를 직접 관리해야 하다가, JAVA에서 가비지 컬렉터를 사용한다면, 아주 평온한 개발자의 삶을 살 수 있습니다. (C++이 안좋다는 건 아닙니다..)

근데, 이것이 메모리 관리에 신경 쓸 필요가 없다라는 의미가 절대 아닙니다.

아래는 메모리 누수가 일어나는 Stack 구현 코드의 예시입니다.

package item7;

import java.util.Arrays;
import java.util.EmptyStackException;

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        return elements[--size];
    }


    /**
     * 원소를 위한 공간을 적어도 하나 이상 확보해야 합니다.
     * 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘어납니다.
     */

    private void ensureCapacity() {
        if (elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

문제가 없는 아주 완벽한 코드입니다.
어떠한 테스트를 수행해도 통과합니다.

숨어있는 문제가 있는데, 이 스택을 사용하는 프로그램이 많을수록 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하됩니다.

최악으로 디스크 페이징이나 OutOfMemory (OOM Killer)를 일으켜 프로그램이 예기치 않게 종료될 수 있습니다.

그래서, 위 예시는 도대체 어디에서 메모리 누수가 일어날까요?
주제에서 나왔듯이, 참조를 해제하지 않습니다. 즉, 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다는 의미입니다.

왜냐하면, 스택이 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문입니다. 여기서 다 쓴 참조란, 문자 그대로 앞으로 다시 쓰지 않을 참조를 뜻하는데, 여기서는 elements 배열의 '활성 영역'밖의 참조들이 해당합니다.

가비지 컬렉션 언어에서는, 메모리 누수를 찾기가 아주 까다롭다고 합니다.
예를 들어 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐만 아니라 그 객체가 참조하는 모든 객체(그리고 또 그 객체가 참조... 또 참조...)를 회수하지 못합니다. 그래서 단 몇개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있습니다.


여기서 해법은 아주 간단합니다. 문제에서 나와있듯이 객체 참조를 해제하면 됩니다.
예시의 스택 클래스에서는, 각 원소의 참조가 더 이상 필요 없어지는(클라이언트는 이미 pop했다고 생각하는)객체를 null 처리하면 됩니다.
아래는 pop 메서드를 제대로 구현한 예시입니다.

    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        Object result = elements[--size];
        elements[size] = null; // 다 쓴 참조 해제
        return result;
    }

다 쓴 참조를 null 처리하면, 다른 이점도 따라옵니다.
예를 들어 null 처리된 참조를 사용하게 될 경우, 즉시 NullPointerException을 던지며 런타임 과정에서 프로그램을 종료시킵니다. (미리 null 처리를 하지 않았다면... 이것도 모르고 사용하게 될 것입니다.)

이 문제로 한 번 피를 본 프로그래머는 모든 객체를 다 쓰자마자 일일이 null 처리를 한다고 합니다.

하지만 그럴 필요도 없고 바람직하지도 않습니다.

프로그램을 필요 이상으로 지저분하게 만들 뿐입니다.

객체 참조를 null 처리하는 일은 예외적인 경우여야 합니다.

다 쓴 참조를 해제하는 가장 좋은 방법은, 그 참조를 담은 변수를 scope 밖으로 보내는 것입니다. 만약 변수의 범위를 (여기서는 elements의 size를) 최소가 되게 정의했다면(item. 57) 이 일은 자연스럽게 이루어진다고 합니다.


그래서 null 처리하지 말라구요?

Stack 클래스의 예시는, 스택 자체가 메모리를 직접 관리합니다.
그리하여 메모리 누수에 취약하다고 할 수 있습니다. 이 스택은 elements 배열로 저장소 풀을 만들어 원소들을 관리하는데, 활성 영역과 비활성 영역으로 나누어 관리합니다.
문제는, 가비지 컬렉터는 이것을 알 수도 없고 알 필요도 없습니다.

그러므로, 비활성 영역이 되는 순간을 잘 파악하여 null 처리를 해줘야 합니다.


일반적으로 Stack 처럼 자기 메모리를 직접 관리하는 클래스라면, 메모리 누수에 주의해야 합니다.

흔히들 편히 사용하는 캐시 역시 메모리 누수를 일으키는 주범입니다.

객체 참조를 캐시에 넣고, 이 사실을 까맣게 잊은 채 그 객체를 다 쓴 뒤로도 그저 방치할 수 있기 때문입니다.

책에서 나오는 해결법은, 만약 운 좋게 캐시 외부에서 key를 참조하는 동안만 entry가 살아 있는 캐시가 필요한 상황이라면, WeakHashMap을 사용해 캐시를 만들라고 합니다. 이것은 다 쓴 엔트리는 그 즉시 자동으로 제거됩니다.

하지만, 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기가 어렵습니다.
그리하여 시간이 지날수록 (캐시에 넣은 시간이 길 수록) 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다고 합니다.

이런 방식에서는 ScheduledThreadPoolExcutor 같은 백그라운드 스레드를 활용하거나, 부수 작업으로 엔트리를 청소해줘야 합니다.

LinkedHashMap을 예시를 들면, removeEldesEntry 메서드를 사용하여 처리합니다.

추가로, 메모리 누수의 주범은 listener, callback이 있습니다.
만약 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 무조건 조치가 없으면 계속 쌓입니다. 이럴 때 콜백을 약한 참조(weak reference)로 저장하면 가비지 컬렉터가 직접 수거해갑니다. (WeakHashMap)


일반적으로 메모리 누수는 잘 드러나지 않습니다.

시간이 경과하여 발생할 수도 있고, 어쩌면 평생 모르고 지나갈 수 있습니다.
이러한 누수는 철저한 코드 리뷰나, 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 하는데, 그리하여 예방법을 익혀두는 것이 아주 중요합니다.


회고

다른 분들의 블로그 포스팅을 보다가.. 이미지를 넣어 꽤나 몰입도가 좋아서 저도 넣어봤는데, 어떠셨을지 모르겠습니다.

Object a= null;

null을 직접 선언한다는 것 자체가 어색할 수 있지만, 이따금 학부 시절 Stack를 직접 구현할 때를 생각해보니 꽤나 중요한 의미임을 알았습니다.

메모리 누수는 결국 자원의 소모로 이어지니, 책에 나온 약한 참조나 여러가지 누수 관리 기법을 알아야겠습니다.

profile
방황하는 귀여운 개발자

0개의 댓글