GC가 알아서 메모리를 관리해주니까 자바에서는 메모리 누수가 없다? 안타깝지만 그렇지 않습니다.

이게 뭔가요?

자바에서 메모리 누수(Memory Leak) 는 더 이상 사용하지 않는 객체에 대한 참조가 남아있어서 GC가 수거하지 못하는 현상입니다. C/C++처럼 메모리 해제를 깜빡하는 것은 아니지만, 의도치 않은 참조가 누적되면 결국 OutOfMemoryError가 발생합니다.

패턴 1: ThreadLocal 미정리

가장 흔하고 위험한 패턴입니다.

JAVA
// 위험한 코드
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에 저장한 값이 요청이 끝나도 남아있습니다. 요청마다 큰 객체가 쌓이면 메모리가 점점 증가합니다.

JAVA
// 해결: 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: 리스너/콜백 등록 후 해제 안 함

JAVA
// 위험한 코드
public class DashboardPanel {
    public DashboardPanel(EventBus eventBus) {
        // 리스너 등록 — DashboardPanel 인스턴스에 대한 참조가 EventBus에 남음
        eventBus.register(this::onDataUpdate);
    }
    // DashboardPanel이 화면에서 사라져도 EventBus가 참조를 들고 있음 → GC 불가
}
JAVA
// 해결: 해제 메서드 제공
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) 내부 클래스는 외부 클래스의 참조를 암묵적으로 들고 있습니다.

JAVA
// 위험한 코드
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 불가
JAVA
// 해결: 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: 무한 성장하는 캐시

JAVA
// 위험한 코드
private static final Map<String, Object> cache = new HashMap<>();

public Object getCached(String key) {
    return cache.computeIfAbsent(key, k -> expensiveCompute(k));
    // 캐시 항목이 영원히 쌓임 → 메모리 고갈
}
JAVA
// 해결 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: 컬렉션에서 요소 제거 안 함

JAVA
// 위험한 코드
public class TaskQueue {
    private final List<Task> completedTasks = new ArrayList<>();

    public void complete(Task task) {
        task.markDone();
        completedTasks.add(task); // 계속 쌓임
        // 어디서도 completedTasks를 비우지 않음
    }
}

패턴 6: String.intern() 남용

JAVA
// 위험한 코드
for (String line : readLargeFile()) {
    // intern()은 문자열을 String Pool(PermGen/Metaspace)에 저장
    processedData.add(line.intern()); // 수백만 문자열이 해제 불가
}

누수 탐지 방법

BASH
# 힙 덤프 생성
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 되었고, Cleaner API를 대신 사용하세요.
  • 커넥션 누수: DB 커넥션, HTTP 커넥션을 close() 하지 않으면 커넥션 풀이 고갈됩니다. try-with-resources를 사용하세요.

정리

패턴원인해결
ThreadLocalremove() 미호출finally에서 remove()
리스너/콜백해제 안 함close() / dispose()
내부 클래스외부 클래스 암묵 참조static 클래스 또는 람다
무한 캐시크기 제한 없음LRU, TTL, WeakHashMap
컬렉션 누적요소 미제거주기적 정리 또는 크기 제한

References

댓글 로딩 중...