컬리로 팀이 JDBC 드라이버를 MariaDB에서 MySQL로 바꾼 뒤 겪은 주기적 OOM과 EC2 재시작의 원인을 추적해, HikariCP의 짧은 max-lifetime과 mysql-connector-j의 AbandonedConnectionCleanupThread 병목이 만든 Connection 누수임을 밝혀낸 과정을 다룬다. 최종적으로 해당 정리 스레드를 비활성화해 해결했다.
핵심 포인트- 증상은 메모리가 0에 수렴하며 OOM으로 인스턴스가 종료되고, Old 영역이 2G에 닿아도 GC가 못 돌고 New 영역 GC만 반복되는 것이었다.
- 응답 2분짜리 API에서 DB 점유는 짧고 애플리케이션 점유만 비정상적으로 긴 특징도 나타났다.
- 힙 덤프 분석 결과 AbandonedConnectionCleanupThread가 보유한 PhantomReference 구조에 ConnectionImpl이 13,689개 쌓여 메모리를 점유하고 있었다.
- 이 스레드는 단일 스레드로 한 번에 하나씩 네트워크 자원을 닫는데, max-lifetime 50초(기본값의 36배 빠른 재생성)를 따라잡지 못해 누수가 났다.
- mysql-connector-j 8.0.22 이상으로 올리고 정리 스레드를 비활성화해 해결했다.
상세 정리- 증상1(OOM): 메모리가 거의 0으로 유지되다 인스턴스가 비정상 종료되고 인프라 설정에 의해 재부팅되는 현상이 주기적으로 발생했다.
- 증상2(GC 이상): Old 영역 최대 2G에 도달해도 GC가 돌지 않고 New 영역 점유만 반복돼 결국 종료됐다.
- 증상3(점유 시간): 이슈 시간대에 응답이 긴 API는 DB 쿼리보다 애플리케이션 점유가 월등히 길었다.
- 진단: jmap -dump 로 힙 덤프를 뽑아 MemoryAnalyzer로 열자 AbandonedConnectionCleanupThread가 대부분 메모리를 차지함을 확인했다.
- 정리 스레드 역할: 이 스레드는 명시적으로 닫히지 않은 MySQL connection의 네트워크 자원을 GC 수거 전에 정리하는 단일 스레드다.
- 생성 흐름: HikariCP가 Connection을 만들면 곧바로 PhantomReference를 만들어 connectionFinalizerPhantomRefs에 보관하고 referenceQueue와 연결한다.
- 종료 흐름: Connection 수명이 끝나면 Phantomly Reachable 상태가 되고, GC가 이를 큐에 enqueue하면 스레드가 remove로 꺼내 finalizeResource로 네트워크 자원을 닫는다.
- 구조적 병목: 정리는 단일 스레드가 한 번에 하나씩 TCP/IP 소켓 연결을 확인하며 닫아, 네트워크 환경에 따라 병목이 생긴다.
- 설정 원인: 컬리로는 DB failover 시 slave로 빠르게 붙으려 max-lifetime을 50초로 둬, 기본값 30분보다 36배 빠르게 Connection이 재생성됐다.
- 누적 메커니즘: 재생성 속도가 정리 속도를 앞질러 PhantomReference가 계속 쌓이며 메모리 누수로 이어졌다.
- 해결 선택지: max-lifetime을 늘리면 누수는 막지만 failover 요건과 상충해, 대신 정리 스레드 비활성화를 택했다.
- 적용법: mysql-connector-j 8.0.22 이상으로 올리고 자바 실행 옵션에 -Dcom.mysql.cj.disableAbandonedConnectionCleanup=true 를 추가했다.
- 결과: 적용 후 힙 덤프에서 해당 스레드 메모리 점유가 사라졌고 몇 달간 안정적으로 운영됐다. 교훈으로 메모리·CPU 장애 시 힙 덤프부터 보기로 했다.
왜 읽나HikariCP와 mysql-connector-j를 쓰는 JVM 백엔드 엔지니어에게 Connection 누수의 원인 추적과 정리 스레드 비활성화 해법 레퍼런스.