본문으로 건너뛰기

Lettuce DefaultCommandLatencyCollector 메모리 누수: Tile38CommandType 하나가 힙의 72%를 잡아먹은 이야기

장애 대응FMSFleet OSBackend
FMS오수진2026년 7월 23일

Pleos Fleet(FMS)

FMS 소개

안녕하세요, UMOSONE FMS 개발팀의 오수진입니다.

본론에 앞서 저희 팀이 만드는 서비스를 짧게 소개합니다. FMS(Fleet Management System)는 차량이라는 자산과 현장을 데이터로 연결해, 차량을 실시간으로 관제하고 모니터링하는 서비스를 만드는 팀입니다. 자체 개발한 차량 단말에서 올라오는 데이터뿐 아니라 현대/기아 자동차의 데이터 파이프라인(HMG Connected Car)과도 연동해, 서로 다른 소스에서 들어오는 차량 데이터를 하나의 플랫폼으로 모으고 있습니다.

자율주행 시대가 가까워질수록, 사람이 타지 않은 무인 차량이라도 누군가는 위치와 상태를 실시간으로 확인하고 이상 징후에 대응할 수 있어야 합니다. 저희는 그 역할을 책임지는, 자율주행 시대에 꼭 필요한 관제 서비스를 만드는 팀입니다. 차량 한 대에서만도 위치, 속도, 배터리, 도어, 각종 센서 상태 등 수십 가지 종류의 데이터가 끊임없이 올라오고, 이를 실시간으로 수집·적재·조회하는 대용량 데이터 처리 파이프라인을 만들고 운영하는 것이 저희 팀의 핵심 과제입니다. 이번 글의 무대가 된 fms-vehicle-data-consumer도 이 파이프라인의 일부로, 차량에서 올라오는 위치 이벤트를 Kafka로 받아 지오스페이셜 데이터베이스인 Tile38에 반영하는 컨슈머입니다.

TL;DR

  • fms-vehicle-data-consumer 파드가 반복적으로 OOMKilled 됨
  • 힙덤프 분석 결과, Lettuce의 DefaultCommandLatencyCollector 인스턴스 1개가 힙 전체의 72.28%(약 404MB) 를 점유
  • 원인: 커스텀 ProtocolKeyword 구현체인 Tile38CommandTypeequals()/hashCode()를 구현하지 않은 채 호출할 때마다 새 객체를 반환
  • Lettuce의 메트릭 맵 키(CommandLatencyId)가 이 객체의 identity로 만들어지다 보니, 커맨드 이름은 8개뿐인데 키는 무한히 늘어남
  • of()를 캐싱하고 equals/hashCode를 추가해서 해결. 검증 결과 객체 수가 0 → 700+ 로 계속 증가하던 것이 캐싱 적용 후 한 자릿수로 수렴하는 것을 직접 확인 를 캐싱하고 equals/hashCode`를 추가해서 해결. 검증 결과 객체 수가 0 → 700+ 로 계속 증가하던 것이 캐싱 적용 후 한 자릿수로 수렴하는 것을 직접 확인

1. 증상

차량의 위치(위도,경도)정보를 실시간으로 geofencing 서버로 보내 관심지역으로 설정한 구역에 입출 로그를 기록하는 핵심기능중 하나를 처리하는데 차량대수가 늘어나면서 kafka message가 급증했고 위치데이터를 처리하는
서비스 파드가 반복적으로 OOMKilled 되는 문제가 있었습니다. 힙덤프를 열어보니 원인은 커맨드 이름이 8개뿐인 곳에서, 메트릭 맵의 키가 1,660개까지 불어나 있는 이상한 모습이었습니다.

프로덕션 힙덤프를 Eclipse Memory Analyzer(MAT)로 분석해보니 원인이 명확하게 드러났습니다.

시점Used HeapLeak Suspect
이전 (7/8 17:54)129.8 MB없음
현재 (7/10 13:35)534 MBProblem Suspect 1 발견

MAT의 Leak Suspects / Leak Hunter / Top Consumers가 한목소리로 지목한 대상은 다음과 같았습니다.

io.lettuce.core.metrics.DefaultCommandLatencyCollector
└ Shallow: 32 B / Retained: 404,683,800 B (72.28%)

Dominator 경로:
DefaultCommandLatencyCollector
└ AtomicReference (latencyMetricsRef)
└ ConcurrentHashMap
└ ConcurrentHashMap$Node[4096] ← 404MB 전부 몰림

인스턴스는 딱 3개인데(before/after 동일), 그중 하나가 통째로 404MB를 물고 있는 전형적인 "런타임 중 점진적으로 자라나는" 누수 패턴이었습니다.

2. MAT OQL로 잡아낸 결정적 증거

Eclipse MAT 헤드리스 OQL로 덤프를 직접 쿼리해보니 다음과 같은 모순이 발견됐습니다.

OQL결과
SELECT * FROM io.lettuce.core.metrics.CommandLatencyId1,660개 인스턴스
SELECT DISTINCT t.command FROM ai.dot42.tile38.Tile38CommandType tdistinct 8개 (SET/DEL/DROP/SETHOOK/DELHOOK/PDELHOOK/GET/OUTPUT)
CommandLatencyId.localAddressLocalAddress.ANY (고정 상수)
CommandLatencyId.remoteAddress단일 호스트
Tile38CommandType 인스턴스 수 (Class Histogram)1,654개

커맨드 이름은 8개뿐인데, 메트릭 맵의 키(CommandLatencyId)는 1,660개. local/remote 주소는 둘 다 고정값이니, 키를 서로 다르게 만드는 유일한 요인은 Tile38CommandType 객체 그 자체였습니다.

3. 근본 원인

Lettuce는 커맨드 레이턴시를 아래와 같은 키로 집계합니다.

CommandLatencyId(local, remote, commandType)

여기서 commandTypeProtocolKeyword 타입인데, 저희 팀의 Tile38CommandType은 다음과 같이 구현되어 있었습니다.

package ai.dot42.tile38
import io.lettuce.core.protocol.ProtocolKeyword

class Tile38CommandType private constructor(
private val command: String,
) : ProtocolKeyword {
override fun getBytes(): ByteArray = command.toByteArray(Charsets.US_ASCII)
override fun name(): String = command

companion object {
val SET = Tile38CommandType("SET")
val DEL = Tile38CommandType("DEL")
val DROP = Tile38CommandType("DROP")
val SETHOOK = Tile38CommandType("SETHOOK")
val DELHOOK = Tile38CommandType("DELHOOK")
val PDELHOOK = Tile38CommandType("PDELHOOK")
val GET = Tile38CommandType("GET")

// 문제의 그 함수: 호출할 때마다 새 인스턴스를 만들어 반환
fun of(command: String): Tile38CommandType = Tile38CommandType(command.uppercase())
}
}

문제는 두 가지가 겹쳐서 발생했습니다.

  1. equals() / hashCode()를 오버라이드하지 않음 → 기본 구현은 객체 identity 비교
  2. of()가 호출할 때마다 Tile38CommandType(...)새로 생성해서 반환

Tile38Client.execute()가 커맨드를 디스패치할 때마다 Tile38CommandType.of("SET")을 호출하는데, 커맨드 이름은 항상 "SET"으로 같아도 매번 다른 객체가 만들어집니다. Lettuce의 메트릭 맵은 이 객체를 키의 일부로 쓰기 때문에:

키 = (고정 local, 고정 remote, 매번 다른 Tile38CommandType 객체)
→ 절대 같은 키로 인식되지 않음
→ dispatch할 때마다 새 키가 맵에 쌓임
→ 키 1개당 HdrHistogram 기반 통계 객체가 ~244KB씩 물림
→ 1,654개 유니크 키 × 244KB ≈ 404MB

이 패턴은 Lettuce의 알려진 이슈(#1210: 커스텀 ProtocolKeyword + identity 기반 키잉으로 인한 메트릭 맵 무한 성장)와 정확히 일치합니다.

참고로, Tile38Client.pipeline()이 호출마다 새 물리 커넥션을 여는 부분도 있었지만, Lettuce의 localDistinction 기본값이 false라 local 주소는 항상 고정값(LocalAddress.ANY)으로 잡힙니다. 따라서 이 커넥션 처칠(churn)은 별개의 리소스 이슈일 뿐, 이번 OOM의 직접 원인은 아니었습니다.

4. 재현 실험 — 눈으로 직접 확인하기

말로만 하면 설득력이 떨어지니, 실제로 of()를 반복 호출하면서 힙에 Tile38CommandType 인스턴스가 어떻게 쌓이는지 VisualVM으로 직접 관찰했습니다.

Given("of() 결과가 CommandLatencyId 키로 사용되는 시뮬레이션") {
When("동일 커맨드로 1000회 반복 호출") {
val iterations = 1000
val uniqueInstances = mutableSetOf<Tile38CommandType>()

repeat(times = iterations) {
uniqueInstances.add(Tile38CommandType.of(command = "SET"))
delay(duration = 100.milliseconds)
}
}
}

수정 전 코드로 실행 — 계속 늘어남

같은 "SET" 커맨드로만 호출하는데도, 반복 횟수가 늘어날수록 힙에 살아있는 Tile38CommandType 인스턴스 수가 함께 늘어나는 것을 확인했습니다.

  • 반복 80회 시점: 88개 인스턴스

80회 반복 시점 - 88개 인스턴스

  • 반복 701회 시점: 709개 인스턴스

701회 반복 시점 - 709개 인스턴스

즉 호출 횟수와 거의 1:1로 인스턴스가 계속 쌓입니다. 실제 서비스에서 QPS가 높은 Tile38 SET 트래픽이 지속될수록 힙이 점점 부풀어 오른 이유가 바로 이것입니다.

수정 후 코드로 실행 — 8~9개로 수렴

캐싱과 equals/hashCode를 추가한 뒤 동일한 시뮬레이션을 132회 반복해도, 인스턴스 수는 늘어나지 않고 9개(커맨드 종류 수 + α)로 수렴했습니다.

132회 반복 시점 - 9개로 수렴

수정 전/후 비교:

코드반복 횟수살아있는 인스턴스 수
수정 전80회88개
수정 전701회709개
수정 후132회9개

수정 전에는 반복 횟수만큼 인스턴스가 그대로 쌓이지만, 수정 후에는 반복 횟수와 무관하게 커맨드 종류 수 근처에서 더 이상 늘어나지 않습니다.

5. 수정 코드

companion objectConcurrentHashMap 기반 캐시를 두고, of()가 같은 커맨드 이름에 대해 항상 같은 인스턴스를 반환하도록 바꿨습니다. 방어적으로 equals()/hashCode()도 문자열 기준으로 추가해서, 혹시 어딘가에서 캐시를 거치지 않고 생성되더라도 값 기반으로 동일하게 취급되도록 했습니다.

class Tile38CommandType private constructor(
private val command: String,
) : ProtocolKeyword {
override fun getBytes(): ByteArray = command.toByteArray(Charsets.US_ASCII)

override fun name(): String = command

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Tile38CommandType) return false
return command == other.command
}

override fun hashCode(): Int = command.hashCode()

companion object {
private val CACHE = ConcurrentHashMap<String, Tile38CommandType>()

val SET = of("SET")
val DEL = of("DEL")
val DROP = of("DROP")
val SETHOOK = of("SETHOOK")
val DELHOOK = of("DELHOOK")
val PDELHOOK = of("PDELHOOK")
val GET = of("GET")
val RENAME = of("RENAME")
val OUTPUT = of("OUTPUT")

// 캐싱: 같은 이름 → 같은 인스턴스 (메트릭 맵 키가 8~9개로 수렴)
fun of(command: String): Tile38CommandType =
CACHE.computeIfAbsent(command.uppercase()) { Tile38CommandType(it) }
}
}

이렇게 하면 equals/hashCode가 값 기반이든, Lettuce가 여전히 객체 identity로 비교하든 상관없이 캐싱된 동일 인스턴스가 반환되므로 어느 경우에도 키가 수렴합니다.

효과: 맵 키 1,660개 → 8~9개 수렴, DefaultCommandLatencyCollector retained heap 약 404MB → 약 2MB.

6. 검증 체크리스트

  1. 수정 배포 후 일정 시간 운영하며 힙덤프 재채취
  2. MAT OQL SELECT * FROM io.lettuce.core.metrics.CommandLatencyId로 인스턴스 수가 한 자릿수로 줄었는지 확인
  3. DefaultCommandLatencyCollector의 retained heap이 더 이상 증가하지 않는지 모니터링
  4. 관련 모듈 컴파일 및 테스트(Tile38Client*Test, Tile38CommandType 관련) 통과 확인

7. 힙덤프 수집 방법 (kubectl 활용)

수정 배포 후 검증이나 OOM 발생 시점 분석을 위해 운영 클러스터에서 힙덤프를 수집해야 할 때가 있습니다. 아래는 fms-vehicle-data-consumer 파드를 대상으로 검증된 방법입니다.

7.1. 컨테이너 환경 확인

  • Corretto-21 JRE (JDK 아님, jmap 미포함)
  • curl, python3, dnf 사용 가능 (Amazon Linux 2023 기반)
  • tar 미포함 → kubectl cp 사용 불가 (대체 전송 방식 필요)

7.2. 힙덤프 생성

Full GC를 유발하는 -dump:live 옵션 대신 -dump (live 생략)로 생성해야 STW(Stop-The-World) 시간을 단축할 수 있습니다.

# 1. JDK 다운로드 및 추출 (컨테이너에 jmap이 없으므로)
kubectl exec -n <namespace> <pod> -c <container> -- sh -c '
curl -sL -o /tmp/corretto-jdk.tar.gz \
https://corretto.aws/downloads/latest/amazon-corretto-21-x64-linux-jdk.tar.gz
python3 -c "import tarfile; tarfile.open(\"/tmp/corretto-jdk.tar.gz\",\"r:gz\").extractall(\"/tmp/\")"
'

# 2. 힙덤프 생성 (live 옵션 제외)
kubectl exec -n <namespace> <pod> -c <container> -- sh -c '
JDK_HOME=$(ls -d /tmp/amazon-corretto-*/ | head -1)
LD_LIBRARY_PATH="$JDK_HOME/lib/server:$JDK_HOME/lib" \
$JDK_HOME/bin/jmap -dump:format=b,file=/tmp/heap.hprof 1
'

주의: 힙 크기에 따라 STW 시간이 비례합니다. 예를 들어 4.9GB 힙은 약 39초, 2.7GB 힙은 약 20초간 애플리케이션이 멈춥니다. health check probe의 timeout/threshold에 따라 파드가 재시작될 수 있습니다. 운영 시간이 오래된 파드일수록 힙이 커서 위험이 높아집니다.

7.3. 힙덤프 전송 (HTTP 서버 + port-forward)

컨테이너에 tar가 없어 kubectl cp를 사용할 수 없습니다. Python HTTP 서버와 kubectl port-forward 조합으로 전송합니다.

# 1. 컨테이너에서 HTTP 서버 실행
kubectl exec -n <namespace> <pod> -c <container> -- sh -c '
nohup python3 -m http.server 9999 --directory /tmp > /dev/null 2>&1 &
'

# 2. 로컬에서 port-forward + 다운로드
kubectl port-forward -n <namespace> pod/<pod> 9999:9999 &
curl -o ./heap-dump.hprof http://localhost:9999/heap.hprof

8. 배포 후 모니터링

문제가 있던 v1.11.2 버전에서는 kafka event를 처리하던 consumer가 멈추면서 누적된 lag를 처리할 때 순간적으로 메모리 사용량이 급등했고, 이로 인해 OOM이 발생해 애플리케이션이 다운되면서 scale out으로 파드 개수가 늘어나는 현상을 볼 수 있었습니다.

v1.11.2 - OOM으로 인한 scale out

메모리 문제를 수정한 v1.11.3 버전에서는 안정적으로 운영되고 있습니다.

v1.11.3 - 안정적인 메모리 사용량

9. 한 줄 요약

Tile38CommandType.of()가 매 커맨드 dispatch마다 equals/hashCode가 없는 새 ProtocolKeyword 객체를 만들었고, Lettuce의 CommandLatencyId가 이 객체의 identity로 메트릭 맵 키를 만들다 보니 키가 절대 충돌하지 않고 무한히 누적됐습니다. of()에 캐싱을 적용하고 값 기반 equals/hashCode를 추가해 해결했으며, VisualVM 재현 실험으로 인스턴스 수가 반복 호출과 무관하게 수렴하는 것을 직접 확인했습니다.