[Java] Reference 2편 : WeakHashMap으로 메모리 누수 잡기

HenryHong·2026년 1월 19일

java

목록 보기
14/15
post-thumbnail

1. 강한 참조 Map으로 생기는 은근한 메모리 누수

  • 예제 코드:
Map<Object, byte[]> cache = new HashMap<>();

void work() {
    Object key = new Object();
    cache.put(key, new byte[1024 * 1024]); // 1MB
    // 작업 끝
}
  • work()를 수천 번 호출하면?
    • key는 지역 변수라 스코프를 벗어나면 GC 대상이 되지만,
    • cache가 키를 강하게 들고 있어서 키·값 둘 다 계속 살아 있음.
    • 결과적으로 HashMap이 점점 커져서 OOM 위험.

이걸 “눈에 안 띄는 메모리 누수(코드 상에서는 아무것도 안 남은 것처럼 보이는데, 컬렉션이 붙잡고 있는 경우)”로 설명.


2. WeakHashMap으로 같은 코드 다시 짜보기

  • WeakHashMap 예제:
Map<Object, byte[]> cache = new WeakHashMap<>();

void work() {
    Object key = new Object();
    cache.put(key, new byte[1024 * 1024]);
    // 작업 끝
}
  • 차이점 설명:
    • WeakHashMap은 키를 WeakReference로 래핑해서 저장한다.
    • 메서드 밖으로 나가면 key에 대한 강한 참조는 사라진다.
    • 그 순간부터 GC는 “키 객체는 아무도 안 쓰네?”라고 보고 수거 대상에 올린다.
    • 키가 수거되면, WeakHashMap 내부에서도 해당 엔트리를 정리한다.
public static void main(String[] args) throws Exception {
    Map<Object, String> map = new WeakHashMap<>();
    Object key = new Object();
    map.put(key, "value");

    System.out.println("before: " + map.size()); // 1

    key = null;   // 강한 참조 제거
    System.gc();
    Thread.sleep(100);

    System.out.println("after: " + map.size());  // 보통 0
}

3. 내부 구조 & equals/hashCode 주의점

여기서는 살짝 더 깊게:

  • WeakHashMap 엔트리는 WeakReference<K>를 상속한 Entry로 구현되어 있고,
    키가 수거되면 reference.get()이 null을 반환하면서, 내부에서 그 엔트리를 제거한다.
  • 이때도 HashMap 계열답게 hashCode와 equals 규약을 그대로 따른다.

그래서 이 포인트들을 강조:

  • 키로 쓸 객체는 반드시 equals/hashCode를 일관되게 오버라이드해야 한다.
  • 그렇지 않으면, WeakHashMap에서조차
    • put은 했는데 get이 안 나오는 기괴한 상황이 생길 수 있다.

equals만 고치고 hashCode 안 고쳤을 때 생기는 일

import java.util.Map;
import java.util.WeakHashMap;

class User {
    private final String id;

    User(String id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User other = (User) o;
        return id.equals(other.id);
    }

    // hashCode 오버라이드 안 함 (전형적인 버그)
}

public class WeakHashMapBug {
    public static void main(String[] args) {
        Map<User, String> map = new WeakHashMap<>();

        User u1 = new User("gh");
        User u2 = new User("gh"); // 논리적으로는 같은 사용자

        map.put(u1, "first");
        System.out.println(map.get(u1)); // "first"
        System.out.println(map.get(u2)); // null (같은 사용자처럼 보여도 다른 키)

        // 해시 기반 Map에서는 "논리적으로 같다"의 기준을
        // equals와 hashCode 둘 다로 보기 때문에 생기는 전형적인 삑사리
    }
}
  • 겉으로 보기엔 u1 / u2가 같은 유저처럼 보여도, hashCode를 안 건드렸기 때문에 HashMap / WeakHashMap 입장에서는 “완전 다른 키”로 취급한다.
  • 이 상태에서 캐시 키로 User를 쓰면, 같은 의미의 키가 계속 다른 엔트리로 쌓이면서 메모리 낭비가 난다.

WeakHashMap이라고 해서 이 규칙이 특별히 달라지는 건 없다.
어차피 내부적으로도 해시 기반으로 키를 찾기 때문에, “equals만 고쳐놓고 hashCode는 기본 Object 걸 쓰는 버그”는 WeakHashMap에서도 그대로 터진다.


4. Value가 Key를 붙잡고 있을 때 생기는 함정

WeakHashMap의 핵심은 간단하다.

“키에 대한 강한 참조가 모두 끊기면, 그 엔트리는 자연스럽게 사라진다.”

그래서 보통은 이런 그림을 기대한다.

Map<Object, byte[]> cache = new WeakHashMap<>();

void work() {
    Object key = new Object();
    cache.put(key, new byte[1024 * 1024]);
    // ...
    key = null;   // 이제 키를 더 이상 안 쓰니까 날려버림
    // -> 언젠가 GC가 key를 수거하면서 엔트리도 같이 정리됨
}

문제는 Value 쪽에서 다시 Key를 붙잡기 시작할 때 터진다.

class Wrapper {
    Object key;
    byte[] data;
}

Map<Object, Wrapper> map = new WeakHashMap<>();

void work() {
    Object key = new Object();
    Wrapper wrapper = new Wrapper();
    wrapper.key = key;               // Value가 Key를 강하게 참조
    wrapper.data = new byte[1024 * 1024];

    map.put(key, wrapper);

    key = null;                      // 지역 변수는 끊겼지만...
    // wrapper.key가 계속 잡고 있음
}

코드만 보면 “key를 null로 만들었으니까, 이제 GC가 수거하겠지?”라고 생각하기 쉬운데, 실제로는 wrapper.key 때문에 키 객체가 여전히 강하게 살아 있다.
결국 WeakHashMap 입장에선 “키에 대한 강한 참조가 아직 있음”으로 보이기 때문에, 엔트리가 전혀 지워지지 않는 구조가 된다.

이 패턴이 은근 자주 나온다.

  • “디버깅 편하게 하려고 Key를 Value 안에 한 번 더 넣어두자”
  • “로깅용으로 Key를 들고 있게 하자”

같은 식으로 아무 생각 없이 넣어두면, WeakHashMap이 가진 장점이 통째로 사라진다.

그래서 라이브러리 레벨에서는 Value 쪽도 약한 참조로 감싸는 식으로 방지하기도 한다.

class Wrapper {
    WeakReference<Object> keyRef;
    byte[] data;
}

이렇게 만들어 두면, Value가 Key를 “기억”은 하되 강하게 붙잡지는 않아서, GC가 Key를 수거하는 데 방해가 되지 않는다.


5. ReferenceQueue + WeakReference를 직접 써보는 맛보기

WeakHashMap 내부 구현을 뜯어보면, 단순히 WeakReference만 쓰는 게 아니라 ReferenceQueue를 같이 물려서 “죽은 키”를 감지한다.
이 조합을 직접 한 번 써보면 동작 구조가 확실히 와 닿는다.

import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;

public class WeakRefWithQueue {
    private static final ReferenceQueue<Object> queue = new ReferenceQueue<>();

    public static void main(String[] args) throws Exception {
        Object obj = new Object();
        WeakReference<Object> ref = new WeakReference<>(obj, queue);

        obj = null;
        System.gc();

        // 여기서 블로킹으로 대기하다가,
        // ref가 가리키던 객체가 수거되면 큐로 들어온다.
        Reference<?> polled = queue.remove();
        if (polled == ref) {
            System.out.println("Object collected. Do cleanup here.");
        }
    }
}

여기서 포인트 두 개만 잡으면 된다.

  • WeakReference만 쓸 때는 ref.get() == null인지 보면서 “아, 수거됐구나”라고 수동으로 확인해야 한다.
  • ReferenceQueue를 붙이면, “이 참조가 가리키던 객체가 수거됐다”는 사실을 이벤트처럼 통지받을 수 있다.

6. 마무리

  • WeakHashMap도 내부적으로는 이런 식으로 큐에 쌓인 참조들을 주기적으로 훑으면서, 이미 죽은 키에 해당하는 엔트리들을 치워준다.
  • WeakHashMap, WeakReference까지 이해하고 나면 “GC는 알아서 치워주니까 신경 안 써도 된다”에서 한 단계 더 나아가서, 언제 어떤 식으로 메모리가 회수되는지를 설계 차원에서 컨트롤할 수 있게 된다.
  • 대규모 트래픽 받는 서버나 오래 떠 있는 배치/백오피스에서, 이런 작은 차이가 슬금슬금 쌓이는 누수를 막아 주는 포인트를 알고 있으면 나중에 실무에서도 적용해볼 수 있겠다.
profile
주니어 백엔드 개발자

0개의 댓글