도당탕탕

Item7 : 다 쓴 객체 참조를 해제하라 본문

JAVA

Item7 : 다 쓴 객체 참조를 해제하라

backlo 2022. 12. 8. 14:00

자바는 C와 C++과 다르게, 가비지 컬렉터에서 다 쓴 객체를 알아서 회수해 간다. 즉 메모리 관리를 따로 할 필요가 없어 프로그래머의 삶이 훨씬 편안해진다.

그렇다고 메모리 관리를 아예 신경쓰지 않아도 된다고 오해 할 수 있는데, 절대 그런 오해를 하면 안된다. 다음 코드를 보면

  • Memory leak - Stack Code

    public class Stack {
        private Obejct[] 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() { // 배열이 다 찾을때 마다 배열 크키를 2배로 늘리는 메소드
            if (elements.length == size) {
                elements = Arrays.copyOf(elements, 2 * size + 1);
            }
        }
    }

위 코드에서 동작하는데에는 무리가 없을 것이다. 하지만 좀 더 깊이 들어가보자면 한가지 문제가 발생할 것이다. 이는 바로 메모리 누수 로, 이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다. 또한 상대적으로 드문 케이스이긴 하지만 심할 때는 OOM이 발생하거나 Disk Paging을 이르켜 프로그램이 예기치 않게 종료 될 것이다.

그렇다면 위 코드는 어디에서 메모리가 누수될까? 바로 pop() 에서 메모리가 누수 될 것이다. 그. 이유는 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수 하지 않고 EmptyStackException을 던지기 때문이다. 즉 이 스택이 객체들의 다 쓴 참조를 여전히 가지고 있어 메모리 누수가 일어난다. 여기서 다 쓴 참조란 앞으로 다시 쓰지 않을 참조를 뜻한다. 앞의 코드에서는 elements 배열의 활성 영역 밖의 참조들이 모드 여기에 해당한다.

이렇듯 가비지 컬렉션 언어에서는 메모리 누수를 찾기가 어렵다. 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐만 아니라 그 객체가 참조하는모든객체를 회수해가지 못한다. 그래서 단 몇개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능을 악영향을 줄 수 있다.

그럼 어떻게 다 쓴 참조를 없앨 수 있을까? 간단하다. 바로 해당 참조를 다 썻을 때 null 처리 하면 된다. 다음 예를 보자.

  • 제대로 구현한 pop 메소드

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

null로 처리함으로써 다음과 같은 이점도 따라온다.

바로 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointException을 던지며 종료된다. 즉 미리 프로그램 오류를 찾아 시스템 셧다운 문제를 해결할 수 있다.

하지만 모든 객체를 null 처리하는 것은 바람직하지 않다. 그 이유는 프로그램을 필요 이상으로 지저분하게 만들기 때문이다. 따라서 객체 참조 null 처리하는 일은 예외적인 경우여야 한다. 다쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이다. 즉 변수의 범위를 최소가 되게 정의하는 것이 중요하다. (Item 57)

Null 처리는 언제해야 할까? 이팩티브 자바에서는 다음과 같이 말한다.

  1. Stack 클래스처럼 자기가 메모리를 직접 관리하는 클래스에서 null 처리는 유용하다.

이 스택은 객체 자체가 아닌 객체 참조를 담기 때문에 elements 배열로 저장소 풀을 만들어 원소들을 관리한다. 배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 쓰이지 않는다. 문제는 가비지 컬렉터가 활성영역과 비활성영역을 모르기 때문에 프로그래머가 직접 가비지 컬렉터에게 알려주어 다 쓴 객체를 없애주어야 한다.

정리하자면 일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다. 그리고 원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해주어야 한다.

  1. 캐시 역시 메모리 누수를 일으키는 주범이다.

메모리 누수를 일으키는 원인은 다음과 같다.

  1. 외부에서 키를 참조하는 동안만 엔트리가 살아 있는 캐시가 필요한 상황
  2. 캐시 엔트리의 유효기간을 정확히 정의하기 어렵기 때문에 시간이 지날수록 엔트리의 가치를 떨어트리는 방식

이와 같은 문제는 아래와 같이 해결할 수 있다.

  1. WeakHashMap을 사용해 캐시 사용
    1. 즉 다 쓴 엔트리는 그 즉시 자동으로 제거
    2. 단 WeakHashMap은 이러한 상황에서만 유용하다는 사실을 기억해야함
  2. 쓰지 않는 엔트리를 이따금 청소하여 해결
    1. 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가하여 부수 작업으로 수행
    2. LinkedHashMap은 removeEldestEntry 메소드를 써서 처리

  1. 리스너 혹은 콜백이라 부르는 것들의 메모리 누수를 해결한다.

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓여갈 것이다. 이럴 때 콜백을 약한 참조로 저장하면 가비지 컬렉터가 즉시 수거해간다. 예를 들어 WeakHashMap에 키로 저장하면 해결이 된다.

이와 같이 객체 참조 해제에 대해 알아봤다. 여기서 중요한점은 메모리 누수가 시스템에 얼마나 악영향을 많이 끼치는지 볼 수 있을 것이다. 수년간 시스템에 잠복해 있다가 디버깅과 코드리뷰 힙 프로파일러 같은 도구를 사용해 메모리 누수가 발견되기도 하기 때문에, 메모리 누수 부분의 예방법을 익혀두는 것이 매우 중요하다.

Comments