초기 대응 및 현황 파악
장애 발생 당시 3개의 인스턴스에서 Full GC가 발생했으며, 나머지 인스턴스들의 Old Generation 영역도 임계치에 도달해 있었습니다. 다수의 인스턴스가 동시에 Full GC에 진입하면 서비스 전체의 연쇄 장애(Snowball) 현상이 발생할 수 있으므로, 현장 조사를 위해 하나의 인스턴스를 격리한 후 나머지 인스턴스들을 순차적으로 재시작하는 롤링 재시작을 진행했습니다.
원래 JVM 메모리 사용률에 대한 모니터링 및 알림이 설정되어 있었으나, 최근 인프라 마이그레이션 과정에서 알림 설정이 누락되어 사전에 인지하지 못한 점이 아쉬웠습니다.
메모리 회수 불가 객체 추적
Young GC로는 회수되지 않고 계속해서 누적되어 Full GC가 되어서야 간신히 회수되는 객체가 무엇인지 파악해야 했습니다. 먼저 힙 메모리 내 객체 분포를 확인하기 위해 다음 명령어를 실행했습니다.
jmap -histo:live $TARGET_PID | head -n 15
출력 결과 상단에서 HashMap$Node가 비정상적으로 많은 메모리를 차지하고 있는 것을 확인할 수 있었지만, 구체적인 내부 상태는 알 수 없었습니다. 따라서 정확한 분석을 위해 힙 덤프를 추출하여 Eclipse MAT(Memory Analyzer Tool)로 분석을 진행했습니다.
jmap -dump:live,format=b,file=heap_analysis.hprof $TARGET_PID
MAT 분석 및 MySQL 드라이버 내부 동작 확인
MAT의 Leak Suspects 보고서를 확인한 결과, 전체 힙 메모리의 80% 이상을 com.mysql.cj.jdbc.AbandonedConnectionCleanupThread 클래스가 점유하고 있었습니다. 이 클래스는 MySQL Connector/J에서 명시적으로 종료되지 않은 데이터베이스 연결을 정리하는 역할을 담당하는 백그라운드 스레드입니다.
해당 클래스의 소스 코드를 살펴보면, 연결 참조를 관리하기 위해 다음과 같은 정적 Set을 유지하고 있습니다.
private static final Set<PhantomConnectionReference> trackedConnections = ConcurrentHashMap.newKeySet();
이 trackedConnections가 앞서 jmap 결과에서 높게 나타났던 HashMap$Node의 정체였습니다. MAT의 객체 참조 그래프를 통해 이 Set 내부에 무엇들이 저장되어 있는지 추적해 보았습니다.
이 Set에는 데이터베이스 커넥션 객체를 감싸고 있는 PhantomReference가 저장되어 있습니다. 팬텀 참조(Phantom Reference)는 객체가 가비지 컬렉션에 의해 회수될 때, 해당 참조를 ReferenceQueue에 삽입하여 후속 정리 작업을 수행할 수 있도록 해주는 메커니즘입니다.
MySQL 드라이버는 새로운 커넥션을 생성할 때마다 이 팬텀 참조를 만들어 Set에 추가합니다. 이후 정리 스레드는 무한 루프를 돌며 ReferenceQueue에 들어온 참조를 꺼내 실제 커넥션을 종료하는 작업을 수행합니다. 단, 커넥션 객체에 대한 강한 참조가 모두 사라지고 팬텀 참조만 남았을 때라야 GC 대상이 되어 큐에 들어갈 수 있습니다.
커넥션 객체가 지속적으로 누적되는 원인
커넥션이 생성되면 드라이버의 참조 Set에 추가됩니다. 초기에는 정상적으로 사용되다가 Minor GC를 거치며 회수되지 않고 Old Generation으로 승격(Promotion)됩니다. 그러다 어떤 이유로 커넥션이 유효하지 않게 되어 커넥션 풀이 새로운 커넥션을 다시 생성하는 상황이 반복되고 있었습니다.
현재 프로젝트에서는 Druid 커넥션 풀을 사용 중이며, 주요 설정은 다음과 같았습니다.
keepAlive: trueminEvictableIdleTimeMillis: 300000 (5분)minIdle: 30
이 설정에 따르면, 유휴 상태의 커넥션은 5분이 지났을 때 keepAlive 검증 쿼리를 날리게 됩니다. 트래픽 변동이 크지 않다면 최소 30개의 커넥션만 유지되면 되므로 커넥션이 빈번하게 재생성되지 않아야 합니다. 하지만 실제 모니터링 결과, 평상시 활성 커넥션 수는 3개에서 20개 사이를 오가고 있었고, 30분에서 1시간 주기의 배치 작업 시에만 스파이크가 발생하고 있었습니다.
Druid의 keepAlive 메커니즘이 정상 작동한다면 커넥션이 끊기지 않아야 합니다. 이에 MySQL 서버 측의 타임아웃 설정을 확인해 보았습니다.
SHOW GLOBAL VARIABLES WHERE Variable_name LIKE '%timeout%';
확인 결과, MySQL 서버의 wait_timeout 값이 300초(5분)로 설정되어 있었습니다. 이것이 핵심 충돌 지점이었습니다.
근본 원인 요약
- Druid의 keepAlive 유효성 검사 스레드는 기본적으로 60초마다 실행되며, 커넥션의 유휴 시간이
minEvictableIdleTimeMillis(5분)를 초과한 경우에만 검증 쿼리를 전송합니다. - 따라서 커넥션은 최대 5분 + 60초 사이의 시점에 keepAlive 검사를 받게 됩니다. 하지만 MySQL 서버의
wait_timeout역시 5분으로 설정되어 있어, Druid가 검사를 시도하기 직전에 이미 서버 측에서 연결을 강제 종료해 버리는 상황이 발생했습니다. - 배치 작업 등으로 활성 커넥션이 증가하면 풀은 새로운 커넥션을 대량으로 생성합니다. 이후 트래픽이 줄어들면 유휴 상태가 된 커넥션들이 keepAlive 검사에서 실패(이미 서버에서 끊김)하여 풀에서 제거됩니다. 이 과정이 끊임없이 반복되었습니다.
- 새로 생성된 커넥션은 MySQL 드라이버의 팬텀 참조 Set에 추가되며, 활성 상태로 사용되다가 Old Generation으로 승격됩니다. 끊어진 커넥션이 제거되고 새로운 커넥션이 계속 생성되면서 Set의 크기가 기하급수적으로 커져 결국 Full GC를 유발한 것입니다.
설정을 통한 문제 해결
원인이 명확해졌으므로 해결책은 간단합니다. Druid의 minEvictableIdleTimeMillis 값을 MySQL 서버의 wait_timeout보다 짧게 설정하여, 서버에서 연결을 끊기 전에 미리 keepAlive 검사를 수행하도록 변경하면 됩니다. 해당 값을 3분(180000ms)으로 조정하여 커넥션 풀의 keepAlive 유효성을 보장하고, 불필요한 커넥션 재생성 및 메모리 누수를 완전히 차단했습니다.