
자바는 가비지 컬렉터가 있잖아요?
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`를 일으켜 프로그램이 종료되기도 한다.
즉, 오래 살아 있어야 하는 객체에서 문제가 된다. 금방 사라질 객체가 나쁜 스택을 필드로 갖는다고 해도 그 객체가 금방 사라지면 가비지 컬렉터가 함께 수거해 줄 것이다. 오래 살아 있는 객체가 무엇이 있는지 생각해 보면 다음 예시가 있을 것이다.
- 풀(Pool). Thread pool, Connection pool 같은 것
- 싱글톤 (인스턴스가 static이니까)
- 캐시(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 구독을 등록하고 생명주기가 끝날 때 해제하지 않는 것"이다.

안드로이드의 메모리 누수 패턴
메모리 누수란?
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<>();
'프로그래밍 언어 > Java' 카테고리의 다른 글
| [Java] Optional.ofNullable( ) vs Optional.of( ) (0) | 2026.04.15 |
|---|---|
| [이펙티브 자바] Item 6. 불필요한 객체 생성 금지 - Boolean.valueOf( ) vs new Boolean( ) (0) | 2026.03.12 |
| [Java] Baeldung - String pool 번역본 (0) | 2026.02.10 |
| [이펙티브 자바] Item 6. 불필요한 객체 생성 금지 (0) | 2026.02.05 |
| [이펙티브 자바] Item5. 자원 명시 대신 의존 객체 주입을 사용하라 (0) | 2026.01.30 |