프로그래밍 언어/Java

[이펙티브 자바] Item 7. 다 쓴 객체의 참조를 해제하라.

Sauter 2026. 4. 16. 14:03


자바는 가비지 컬렉터가 있잖아요?

C/C++와 Java의 다른 점은 Java에는 가비지 컬렉터가 있다는 것이다. 이 가비지 컬렉터는 사용되지 않는 인스턴스를 힙에서 제거하는 기능을 한다. 여기서 '사용되지 않는'의 기준이 문제가 된다.

가비지 컬렉터의 수거 방식

가비지 컬렉터는 사용하고 있는 객체가 참조하는 객체를 '사용되고 있다'라고 취급한다. 즉 루트(GC Root)에 도달 가능한(reachable) 객체는 살리고, 도달 불가능한 객체는 없앤다.

그렇다면 사용하지도 않는데 참조가 되고 있으면? 그 객체는 메모리 낭비의 원인이 된다. 다음 예시들을 보자.

누수 패턴 1: 스택 구현

이 아이템의 가장 중요한 사례다. 블로흐가 제시하는 스택 구현을 보자.

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);
    }
}

 

문제는 `pop()`에서 size만 줄이고 `elements[size]`의 참조를 그대로 둔다는 것이다. 이 구현에서 원소 3개를 push하고 pop을 하면 어떻게 될까?

이 시점에서 size 기준으로 인덱스 2 이상은 제거된 영역인데, 배열은 여전히 C를 참조하고 있다. GC 입장에선 `elements[2]`가 유효한 참조니까 C를 회수 못 한다. 의미적으로는 삭제된 원소지만, 엄연히 Object 배열의 원소로서 참조되고 있으니까. 이를 obsolete reference(다 쓴 참조)라고 부른다.

해결 방법

원소 삭제를 하면 null 처리로 배열과 원소 사이의 연결고리를 끊어 주자.

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


이제 GC가 C를 회수할 수 있다. C가 가지고 있는 객체도 함께 수거될 것이다.

개인적 의견

책에서는 위의 나쁜 스택에 대해 다음과 같이 설명하고 있다.

이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다. 심할 때는 디스크 페이징이나 `OutOfMemoryError`를 일으켜 프로그램이 종료되기도 한다.

 

즉, 오래 살아 있어야 하는 객체에서 문제가 된다. 금방 사라질 객체가 나쁜 스택을 필드로 갖는다고 해도 그 객체가 금방 사라지면 가비지 컬렉터가 함께 수거해 줄 것이다. 오래 살아 있는 객체가 무엇이 있는지 생각해 보면 다음 예시가 있을 것이다.

  1. 풀(Pool). Thread pool, Connection pool 같은 것
  2. 싱글톤 (인스턴스가 static이니까)
  3. 캐시(static 컬렉션에서 가져옴)

누수 패턴 2: 캐시

캐시는 누수의 온상이다. "나중에 쓸 수 있으니까"하고 넣어두고 까먹는 것이다.

// 흔한 누수 패턴
Map<String, HeavyObject> cache = new HashMap<>();

public void processUser(String userId) {
    HeavyObject data = loadExpensiveData(userId);
    cache.put(userId, data);
    // ... 사용 ...
    // 이 유저가 다시 올 일이 없어도 cache에 계속 남아 있다.
}

 

해결방법 1: `WeakHashMap`

외부에서 키를 참조하는 동안만 캐싱이 필요한 경우 `WeakHashMap`을 활용하자. 키가 외부에서 더 이상 참조되지 않으면 엔트리를 자동으로 제거해 준다.

Map<String, HeavyObject> cache = new WeakHashMap<>();

 

 

단, 이는 키의 생명주기가 외부 참조에 의해 결정될 때만 유효하다. 예를 들어 키가 String 리터럴이면 String Pool 때문에 런타임에 살아 있으니 `WeakHashMap`이 아무 소용 없다.

해결 방법 2: 시간 기반 만료

`ScheduledThreadPoolExecutor`이나 Caffeine 같은 캐시 라이브러리의 `expireAfterWrite`/`expireAfterAccess` 정책을 활용할 수 있다.

Cache<String, HeavyObject> cache = Caffeine.newBuilder()
    .expireAfterAccess(10, TimeUnit.MINUTES)
    .maximumSize(1000)
    .build();

해결 방법 3: 엔트리 추가할 때마다 정리하기

캐시에 새 엔트리를 추가할 때마다 오래된 엔트리를 정리하는 방식이다. `LinkedHashMap`의 `removeEldestEntry(...)`에 정책을 구현하는 방식이 대표적이다:

Map<String, HeavyObject> cache = new LinkedHashMap<>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, HeavyObject> eldest) {
        return size() > MAX_ENTRIES;
    }
};

 

누수 패턴 3: 리스너와 콜백

리스너를 등록하고 해제하지 않는 패턴이다. GUI 프로그래밍에서 특히 흔하고, 안드로이드 개발에서 자주 언급되는 문제다. 메모리 누수 탐지 라이브러리인 LeakCanary도 대표적 누수 원인으로 Fragment backstack에 넣고 view 필드를 안 비우는 경우, Activity를 Context 필드로 저장하는 경우와 함께 "리스너, 브로드캐스트 리시버, RxJava 구독을 등록하고 생명주기가 끝날 때 해제하지 않는 것"이다.

https://square.github.io/leakcanary/fundamentals/

 

안드로이드의 메모리 누수 패턴

메모리 누수란?

blog.kmshack.kr

 

 

public class EventSource {
    private final List<EventListener> listeners = new ArrayList<>();

    public void addListener(EventListener listener) {
        listeners.add(listener);
    }

    // removeListener를 안 만들었거나, 호출을 까먹으면?
}

 

이 패턴이 특히 위험한 이유는 리스너 객체가 보통 자기를 등록한 객체(Activity, Controller 등)를 참조하고 있어서 리스너 하나가 살아 있으면 그 뒤의 객체 그래프 전체가 살아남기 때문이다.

 

약한 참조로 콜백을 저장하거나, 명시적 해제(deregister) API를 제공하고 반드시 호출하도록 하여 해결할 수 있다.

// WeakReference 활용
private final List<WeakReference<EventListener>> listeners = new ArrayList<>();