pile·
백엔드·마켓컬리마켓컬리 Hello World·

99%가 모른다는 DB Connection 누수 문제

컬리로 팀이 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 누수의 원인 추적과 정리 스레드 비활성화 해법 레퍼런스.
마켓컬리
마켓컬리 Hello World 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. 백엔드·github-engGitHub Engineering·

    조기 종료를 없애야 벡터화된다 — 메모리 속도 소스 코드 케이스 폴딩

    GitHub의 코드 검색 엔진 Blackbird는 480TB 이상의 소스 코드를 인덱싱하기 전 모든 바이트에 case folding을 적용한다. 이 글은 Rust로 구현한 case folding을 메모리 대역폭 한계(45+ GiB/s)까지 끌어올린 두 가지 반직관적 최적화를 상세히 다룬다. 핵심은 루프 조기 종료(break) 제거로 LLVM 벡터화를 유도하고, UTF-8을 디코딩하지 않고 바이트 공간 산술만으로 fold를 수행하는 것이다.

    #rust#unicode#simd+2
  2. 백엔드·여기어때 (GC컴퍼니)여기어때 (GC컴퍼니)·

    트랜잭션 스크립트에서 숙소 메타 + 가격 계산 모듈로 — 전시 아키텍처 개선기 (2/3)

    여기어때 전시개발팀이 숙소 상세(PDP) API를 해부한 결과, 코드상으로는 DB 호출 3번처럼 보이던 요청이 실제로는 MongoDB $lookup 체인으로 컬렉션을 19회 접근하는 구조였다. 이 트랜잭션 스크립트 방식의 핵심 문제는 "aggregation이 I/O를 가린다"는 점으로, 독립적인 쿼리 10개가 단일 파이프라인에 직렬화되어 병렬화 기회를 잃고, 가격 때문에 거의 안 바뀌는 이미지까지 매 요청마다 읽어야 하는 읽기 증폭이 발생했다. V3에서는 "조회 시점 조립"을 "쓰기 시점 사전 조립"으로 전환하고, 화면별로 복제되던 가격 계산 로직을 goodsprice 단일 모듈로 수렴했다. 4개 API(PLP/PDP/RDP/ILP)의 반복 마이그레이션은 Claude Code skill로 절차를 고정하고 쉐도잉 + 동일성 검증으로 안전망을 마련하는 방식으로 진행됐다.

    #architecture#migration#caching+2