톰캣 같은 서블릿 컨테이너는 ** 스레드 풀 **을 사용합니다. 요청이 끝나도 스레드는 죽지 않고 풀에 반환돼요. remove()를 빠뜨리면 이전 요청의 데이터가 스레드에 계속 붙어 있어서, 메모리 누수뿐 아니라 다른 사용자의 세션이 보이는 ** 보안 이슈 **까지 발생할 수 있습니다.
패턴 4: 비정적 내부 클래스의 외부 참조
JAVA
publicclassOuter {privatebyte[] bigData = newbyte[10 * 1024 * 1024]; // 10MB// 비정적 내부 클래스 → Outer에 대한 암묵적 참조를 가짐publicclassInner {publicvoiddoSomething() {// bigData를 직접 안 써도, Outer 참조는 유지됨 } }}// Inner 객체가 살아있는 한 Outer(10MB)도 GC되지 않음Outer.Innerinner=newOuter().createInner();
해결: 외부 클래스 참조가 필요 없으면 static 내부 클래스를 사용합니다.
3. OOM 에러 유형
OOM이 터졌다고 다 같은 OOM이 아닙니다. 에러 메시지를 보면 어디가 문제인지 힌트를 얻을 수 있어요.
에러 메시지
문제 영역
주요 원인
확인 옵션
Java heap space
힙
객체 과다 생성 / 누수
-Xmx
Metaspace
메타스페이스
클래스 대량 동적 생성 (CGLIB, 리플렉션 프록시)
-XX:MaxMetaspaceSize
GC overhead limit exceeded
힙
GC가 98% 이상 시간을 쓰면서 2% 미만 회수
-Xmx
Unable to create native thread
OS
스레드 무한 생성, ulimit -u 제한
ulimit -u
Direct buffer memory
네이티브
ByteBuffer.allocateDirect() 누수
-XX:MaxDirectMemorySize
GC overhead limit exceeded는 사실상 heap space OOM의 전조 증상입니다. 메모리가 거의 꽉 차서 GC만 계속 도는 상황이에요.
4. 참조 유형과 GC의 관계
자바에는 Strong Reference 외에 세 가지 특수 참조 유형이 있습니다. 캐시 설계나 누수 방지에 활용할 수 있어요.
"http-nio-8080-exec-1" #25 daemon prio=5 java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at com.myapp.service.SlowService.process(SlowService.java:42)
** 주요 확인 사항:**
BLOCKED 스레드가 많은지 → 락 경합, 데드락 의심
같은 스택 트레이스를 가진 스레드가 대량으로 있는지 → 특정 코드에서 병목
jstack은 데드락을 자동으로 감지해서 Found one Java-level deadlock 메시지를 출력합니다.
8. VisualVM과 JFR로 실시간 모니터링
VisualVM
힙 메모리 그래프에서 정상과 누수의 차이를 한눈에 알 수 있습니다.
PLAINTEXT
[정상] 톱니 모양 — GC가 주기적으로 회수 ╱╲ ╱╲ ╱╲ ╱╲ ╱ ╲╱ ╲╱ ╲╱ ╲[누수] 우상향 — GC 후에도 기저 메모리가 계속 증가 ╱╲ ╱╲ ╱╲ ╱╲╱ ╲╱ ╲ ╱╲╱ ╱
JFR (Java Flight Recorder)
JVM에 내장된 프로파일링 도구로, 오버헤드가 매우 낮아 ** 프로덕션에서도 사용 가능 **합니다.
JFR 기록은 JDK Mission Control(JMC) 에서 열어서 메모리 할당 핫스팟, GC 이벤트 상세, 스레드 활동, 락 경합 등을 분석합니다.
9. 실전 디버깅 시나리오 — 단계별 접근법
"OOM이 터졌어요"라는 알람이 오면, 다음 순서대로 접근합니다.
Step 1: 증상 확인
BASH
jstat -gcutil <PID> 1000 10 # 1초 간격으로 10회 GC 통계 출력
PLAINTEXT
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 99.12 87.45 98.34 95.67 92.34 523 12.45 47 38.92 51.37
O(Old Gen)가 98% → 힙이 거의 꽉 참
FGC 47회, FGCT 38.92초 → Full GC가 너무 자주, 너무 오래 발생
Step 2: 힙 덤프 확보 & MAT 분석
BASH
jcmd <PID> GC.heap_dump /tmp/heapdump.hprof
MAT에서 Leak Suspects → Dominator Tree → Path to GC Roots 순서로 추적합니다.
Step 3: 원인 코드 수정
JAVA
// Before: 누수 — 세션을 넣기만 하고 제거하지 않음privatestaticfinal Map<String, UserSession> sessions = newConcurrentHashMap<>();// After: 만료 정책이 있는 캐시 사용privatestaticfinal Cache<String, UserSession> sessions = Caffeine.newBuilder() .expireAfterAccess(30, TimeUnit.MINUTES) // 30분 미접근 시 자동 제거 .maximumSize(10_000) // 최대 1만 개 .build();
Step 4: 검증
BASH
# JFR로 30분간 기록하며 Old Gen 사용률이 안정적으로 유지되는지 확인jcmd <PID> JFR.start duration=1800s filename=/tmp/after-fix.jfrjstat -gcutil <PID> 5000