자바 메모리 누수 패턴 — ThreadLocal, Listener, 내부 클래스 함정
GC가 알아서 메모리를 관리해주니까 자바에서는 메모리 누수가 없다? 안타깝지만 그렇지 않습니다.
이게 뭔가요?
자바에서 메모리 누수(Memory Leak) 는 더 이상 사용하지 않는 객체에 대한 참조가 남아있어서 GC가 수거하지 못하는 현상입니다. C/C++처럼 메모리 해제를 깜빡하는 것은 아니지만, 의도치 않은 참조가 누적되면 결국 OutOfMemoryError가 발생합니다.
패턴 1: ThreadLocal 미정리
가장 흔하고 위험한 패턴입니다.
// 위험한 코드
public class UserContext {
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
public static void set(User user) { currentUser.set(user); }
public static User get() { return currentUser.get(); }
// remove()를 호출하지 않으면?
}
스레드 풀(Tomcat 등)에서 스레드가 재사용되므로, ThreadLocal에 저장한 값이 요청이 끝나도 남아있습니다. 요청마다 큰 객체가 쌓이면 메모리가 점점 증가합니다.
// 해결: finally에서 반드시 remove()
public class ThreadLocalFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res,
FilterChain chain) throws Exception {
try {
UserContext.set(extractUser(req));
chain.doFilter(req, res);
} finally {
UserContext.remove(); // 반드시 정리!
}
}
}
패턴 2: 리스너/콜백 등록 후 해제 안 함
// 위험한 코드
public class DashboardPanel {
public DashboardPanel(EventBus eventBus) {
// 리스너 등록 — DashboardPanel 인스턴스에 대한 참조가 EventBus에 남음
eventBus.register(this::onDataUpdate);
}
// DashboardPanel이 화면에서 사라져도 EventBus가 참조를 들고 있음 → GC 불가
}
// 해결: 해제 메서드 제공
public class DashboardPanel implements AutoCloseable {
private final Disposable subscription;
public DashboardPanel(EventBus eventBus) {
this.subscription = eventBus.register(this::onDataUpdate);
}
@Override
public void close() {
subscription.dispose(); // 리스너 해제
}
}
패턴 3: 비정적 내부 클래스
비정적(non-static) 내부 클래스는 외부 클래스의 참조를 암묵적으로 들고 있습니다.
// 위험한 코드
public class DataProcessor {
private byte[] largeData = new byte[10_000_000]; // 10MB
public Runnable createTask() {
// 비정적 내부 클래스 → DataProcessor 참조를 들고 있음
return new Runnable() {
@Override
public void run() {
System.out.println("작업 실행");
// largeData를 사용하지 않지만 참조는 남아있음
}
};
}
}
// DataProcessor를 null로 해도, Runnable이 살아있으면 GC 불가
// 해결: static 내부 클래스 또는 람다 사용
public class DataProcessor {
private byte[] largeData = new byte[10_000_000];
public Runnable createTask() {
// 람다는 캡처한 변수만 참조 (외부 클래스 전체를 참조하지 않음)
return () -> System.out.println("작업 실행");
}
// 또는 static 내부 클래스
private static class Task implements Runnable {
@Override
public void run() { System.out.println("작업 실행"); }
}
}
패턴 4: 무한 성장하는 캐시
// 위험한 코드
private static final Map<String, Object> cache = new HashMap<>();
public Object getCached(String key) {
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
// 캐시 항목이 영원히 쌓임 → 메모리 고갈
}
// 해결 1: 크기 제한 (LRU 캐시)
private static final Map<String, Object> cache =
new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) {
return size() > 1000; // 1000개 초과 시 가장 오래된 항목 제거
}
};
// 해결 2: WeakHashMap (키가 GC되면 자동 제거)
private static final Map<Object, String> cache = new WeakHashMap<>();
// 해결 3: Caffeine 캐시 라이브러리
Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();
패턴 5: 컬렉션에서 요소 제거 안 함
// 위험한 코드
public class TaskQueue {
private final List<Task> completedTasks = new ArrayList<>();
public void complete(Task task) {
task.markDone();
completedTasks.add(task); // 계속 쌓임
// 어디서도 completedTasks를 비우지 않음
}
}
패턴 6: String.intern() 남용
// 위험한 코드
for (String line : readLargeFile()) {
// intern()은 문자열을 String Pool(PermGen/Metaspace)에 저장
processedData.add(line.intern()); // 수백만 문자열이 해제 불가
}
누수 탐지 방법
# 힙 덤프 생성
jmap -dump:live,format=b,file=heap.hprof <PID>
# OOM 시 자동 덤프
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof \
-jar app.jar
Eclipse MAT, VisualVM, IntelliJ Profiler로 힙 덤프를 분석하면 어떤 객체가 메모리를 점유하고 있는지 확인할 수 있습니다.
자주 헷갈리는 포인트
- WeakReference vs SoftReference:
WeakReference는 다음 GC에서 바로 수거됩니다.SoftReference는 메모리가 부족할 때만 수거됩니다. 캐시에는SoftReference가 더 적합합니다. - finalize():
finalize()에서 객체 부활(resurrection)이 가능하지만, 이는 안티패턴입니다. Java 18에서 deprecated 되었고,CleanerAPI를 대신 사용하세요. - 커넥션 누수: DB 커넥션, HTTP 커넥션을
close()하지 않으면 커넥션 풀이 고갈됩니다. try-with-resources를 사용하세요.
정리
| 패턴 | 원인 | 해결 |
|---|---|---|
| ThreadLocal | remove() 미호출 | finally에서 remove() |
| 리스너/콜백 | 해제 안 함 | close() / dispose() |
| 내부 클래스 | 외부 클래스 암묵 참조 | static 클래스 또는 람다 |
| 무한 캐시 | 크기 제한 없음 | LRU, TTL, WeakHashMap |
| 컬렉션 누적 | 요소 미제거 | 주기적 정리 또는 크기 제한 |