← Blog
Memory DB는 무엇으로 가득 차 있었을까 - 1부 : 클라이언트 캐싱과 tracking item
MemoryDBRedisengineering

Memory DB는 무엇으로 가득 차 있었을까 - 1부 : 클라이언트 캐싱과 tracking item

Simon Lee
Simon Lee

Software Engineer

안녕하세요. 할당시스템 파트 서버 엔지니어, Simon 이상원입니다.

최근 저희 할당시스템 파트에서 활용하는 MemoryDB(Redis)의 메모리 사용량이 85%까지 차올랐습니다. 사용 가능한 메모리가 500MB도 남지 않은 상황. 과연 무엇이 메모리를 점유하고 있었는지, 그리고 다시 3GB 이상의 여유 공간을 확보하기까지 어떤 여정이 있었는지 정리해보았습니다. 1부에서는 원인을 파악하는 과정을, 2부에서는 해결 과정을 담았습니다.

왜 MemoryDB를 쓰나요?

저희 할당시스템 파트는 유저가 버즈빌의 지면에 진입했을 때 이 유저에게 어떤 광고를 보여주는 것이 좋을지 결정하는 시스템을 관리하고 있습니다. 유저는 어떤 광고에 관심이 있을까요? 이를 알기 위해서는 이 유저가 어떤 유저인지, 그리고 현재 내보낼 수 있는 광고들은 어떠한 광고인지 등을 다양한 측면에서 고려해야 합니다. 초당 약 7,000건의 요청마다 여러 정보들을 실시간으로 읽어오기 위해 저희는 AWS의 MemoryDB를 활용하고 있습니다.

어쩌다 MemoryDB를 파헤치게 됐나요?

MemoryDB 메모리 사용률 85% 도달 알럿 어느 날 MemoryDB의 메모리 사용률이 85%에 도달했다는 알럿을 받았습니다. 당장 고려할 수 있는 방법으로는 스케일업이 있었는데요. 그러나 이미 한 차례 스케일업을 했고, 그 후에 몇 번의 임시 조치를 했음에도 꾸준히 차오르고 있어 근본적인 문제를 찾아보고 싶었습니다.

사실 저는 입사 6개월차 신입 엔지니어이고, MemoryDB에 대해서는 아주 기초적인 동작 원리 정도밖에 알지 못했는데요. 이번 기회에 좀 더 알고 싶어 저에게 맡겨달라고 호기롭게 던졌습니다. (버즈빌에서는 적극적인 사람이 많은 기회를 받을 수 있답니다)

어떤 것부터 시작했나요?

생각해볼 수 있는 가장 단순한 것부터 시작했습니다. TTL이 설정되지 않은 키가 계속해서 쌓이고 있지 않을까요? 아쉽게도 코드를 확인했을 때 그러한 키는 없었습니다.

다음으로는 메모리를 한번 들여다봤습니다. 저희 MemoryDB는 Primary와 Replica, 각각 1대씩 운영하고 있는데요. 이 둘의 메모리를 조사한 결과 이상한 점을 발견했습니다.

Primary 85% vs Replica 30%

Primary와 Replica는 같은 데이터를 복제합니다. 그런데 메모리 사용률은 Primary 85%(2.63GB), Replica 30%(0.96GB)였습니다. '같은 데이터를 복제한다면 같아야 하는 거 아닌가?' 이 의문을 가지고 하나씩 조사해보기 시작했습니다.

가설 1 : Primary에 오버헤드가 더 많다? - 기각 Primary와 Replica의 오버헤드 비중 — 1.6GB 격차를 설명하지 못한다 Primary는 클라이언트가 직접 데이터를 쓰고, 또 Replica에 복제하는 작업도 하죠. 단순히 생각했을 때 Primary가 하는 역할이 더 많아서 오버헤드가 더 크지 않을까 생각했습니다. 그러나 클라이언트 버퍼와 복제 백로그를 합쳐도 Primary 34MB, Replica 21MB. 1.6GB가 넘는 격차를 설명하기에는 너무 작은 차이였습니다.

가설 2 : Primary에 데이터가 더 많다? - 기각 실데이터 165MB가 전체 메모리에서 차지하는 비중 혹시나 복제가 잘 되고 있지 않아서 Primary에 데이터가 더 많이 담겨 있을 수도 있을까요? 전체 키를 세어봤습니다. Primary 104,848개, Replica 104,864개로 사실상 동일하네요. 키와 값이 차지하고 있는 실데이터 용량을 직접 조사해봐도 약 165MB로 양쪽이 같았습니다.

잠깐, 뭔가 이상합니다. 실데이터가 165MB밖에 되지 않네요? 현재 Primary의 메모리 사용량은 2.63GB인데, 그럼 약 2.5GB에 해당하는 메모리는 대체 무엇이 차지하고 있을까요? 메모리 할당 내역(MEMORY MALLOC-STATS)을 열어보았습니다.

malloc 사이즈 클래스 분포

Primary에 8바이트짜리 객체가 5,709만 개, Replica에는 1,802만 개가 있었습니다. 그리고 24바이트짜리 객체도 그 수와 비례해서 존재하고 있었고, Primary는 8B 객체와 24B 객체가 약 2.0GB를 차지하고 있었습니다.

저희 서비스에서 사용하는 정보들은 이 정도로 작은 객체들을 사용하지 않을 텐데, 8B 짜리 무언가가 5,700만 개나 있네요. 이게 무엇인지 알아내야 할 것 같습니다.

Redis에는 메모리·연결 등 자신의 내부 상태를 나타내는 지표를 이름: 값 형태로 보고하는 INFO라는 명령어가 있습니다. 이 명령어를 통해 어딘가에 5,709만과 맞아떨어지는 무언가가 있지 않을까요?

tracking_total_items: 56,988,980

8B 객체 수 57,099,184와 오차 0.2%로 일치하는 지표를 찾아냈습니다. 드디어 원인에 좀 더 가까워진 것 같아요. 그럼 이제 저것의 정체를 알아보겠습니다.

tracking_total_items

tracking item이란 무엇일까요?

Redis 6부터는 클라이언트가 원하는 키를 매번 Redis로부터 읽어가지 않도록, 읽어간 값을 클라이언트 내부에 캐싱해 두는 클라이언트 사이드 캐싱이라는 기능을 제공합니다. 그리고 클라이언트가 유효하지 않은 값을 사용하는 것을 막기 위해, Redis는 클라이언트가 캐싱한 키의 값이 바뀌면 클라이언트에게 "값이 바뀌었으니 그 키의 캐싱을 무효화해"라고 알려주는데요.

이를 위해 Redis는 어떤 키를 어떤 클라이언트가 읽어갔는지를 기억해야 합니다. 그 명부가 Tracking Table이고, 여기에 담기는 항목 하나하나가 tracking item입니다. tracking_total_items는 (키 × 클라이언트) 항목의 총 개수입니다.

트래킹 테이블의 Radix Tree 구조 — 중간 노드(24B)와 리프의 클라이언트 ID(8B)

내부 구조는 Radix Tree로 관리됩니다. 위 이미지는 w:KR-11 키를 읽어간 클라이언트(#1031002, #1029744)와 w:KR-26 키를 읽어간 클라이언트(#1030112, #1024310)를 기록해둔 예시인데요. 이름이 트리의 경로가 되고(중간 노드, 24B), 그 끝에 읽어간 클라이언트 ID들이 매달립니다(리프 노드, 8B). 아까 본 24B와 8B의 정체가 이것으로 설명이 됩니다.

tracking item은 언제 생성되나요? 클라이언트가 캐싱 키를 읽어 tracking item이 생성되는 과정

클라이언트가 캐싱 설정한 키를 읽을 때마다 (키 × 연결) 항목이 하나 기록됩니다. 위 이미지는 클라이언트(#1024581)이 클라이언트 캐싱이 설정된 w:KR-11 키를 읽었고, 이때 w:KR-11 키를 읽은 명부에 #1024581 이 새로 추가된 모습을 보여줍니다.

그럼 언제 삭제되나요?키에 쓰기가 발생해 tracking item이 회수되는 과정

클라이언트가 캐싱한 키에 쓰기가 발생할 때(값 변경, 삭제, TTL 만료 등) 해당 키의 tracking items가 통째로 회수되고, 그 키를 읽어간 클라이언트 전원에게 무효화가 전송됩니다.

여기까지는 합리적입니다. 그러나 여기서 클라이언트의 연결이 끊기면 어떻게 될까요? 클라이언트와 Redis 사이의 연결이 끊기면 더 이상 무효화를 전송할 수 없으니, 해당 클라이언트에 대한 tracking item은 제거하는 것이 맞을 것 같습니다. 그러나 클라이언트의 연결이 끊겨도 그 클라이언트의 명부는 회수되지 않습니다. Redis GitHub에서는 이 점에 대한 issue도 확인할 수 있는데요. 의도된 것일까요?

Redis GitHub에서 tracking item을 무효화하는 코드를 확인하면 이런 주석이 적혀있습니다.

"we'll remove the ID reference in a lazy way. Otherwise when a client with many entries in the table is removed, it would cost a lot of time to do the cleanup." — redis/src/tracking.c, disableTracking() (Redis 6.2 기준 64~66행)

연결 종료 시 특정 클라이언트를 찾기 위한 트래킹 테이블 전체 스캔 경로

캐싱한 키가 많은 클라이언트의 연결이 끊기면, 해당 클라이언트의 tracking item을 찾기 위해 전체 Tracking Table(Radix Tree 구조)을 스캔해야 합니다. 위 이미지는 #1024581 클라이언트의 연결이 끊겼을 때의 스캔 경로를 보여줍니다. 이는 많은 비용을 소모하게 되므로 나중에 그 키가 무효화될 때에야 지우겠다는 설계입니다.

저희 서비스에서는 배포·스케일링·배치 등의 작업으로 MemoryDB에 하루 약 26,000개의 연결이 새로 만들어지고 사라지고 있습니다. 특정 시점의 실제 연결은 약 500개뿐인데 말이죠. 떠난 클라이언트들의 명부가 쌓이고 있는 것일까요?

라이브 키로 실험해보자

저희 서비스에서 클라이언트 사이드 캐싱을 쓰는 키는 아래와 같습니다.

키 패턴TTL역할
ab:__all_config__없음실험(AB) 설정
unit_margin_rate_<id>72h지면 마진율
{lineitem_margin_rate}:<id>72h광고 마진율
w:<region>24h지역 날씨(타겟팅)
best_product:<feed>하루 1회 재생성추천 상품 목록

ab:__all_config__ 키를 제외하고는 주기적으로 정리되고 있습니다. 그럼 ab:__all_config__에 대한 tracking items가 누적되고 있는걸까요? 확인을 위해 ab:__all_config__ 키를 같은 값으로 재기록해 강제로 무효화해 보았습니다.

결과: 회수된 항목 991개, 약 33KB

범위를 넓혀 모든 키를 각각 무효화해 봤습니다.

결과: 회수된 항목 ≈ 0개

살아있는 키들은 명부를 거의 들고 있지 않았습니다. 그리고 어떤 키가 tracking items에 남아있는지 확인하거나 임의로 이를 정리하는 방법은 없습니다. 현재 사용되고 있는 키 중에는 없으니, Failover를 통해 누적된 tracking_total_items를 회수한다면 더 이상 늘어나지 않을까요?

언제부터 누적되고 있었을까요?

CloudWatch에서 15개월치 메모리 추이를 분석해봤습니다.

15개월 메모리 추이

Primary는 월 +0.2GB의 기울기로 선형 증가하다가 2026년 3월 25일에 딱 멈춥니다. 그리고 정확히 같은 날, 이번엔 Replica가 자라기 시작합니다. 그리고 지금까지도 계속 증가하고 있었습니다. 이대로라면 Failover를 하더라도 다시 메모리가 차오르겠네요.

2026년 3월 25일에 무슨 일이 있었는지 확인한다면, 이번에야말로 진짜 원인을 잡을 수 있을 것 같습니다.

무슨 일이 있었나요?

3월 25일은 Redis 클라이언트 구성을 정리하면서, 읽기를 Primary에서 Replica로 보내는 변경이 배포된 날이었습니다. 클라이언트 캐싱을 사용하는 5개 키 중 4개에 대해서도 Replica로 읽기가 전환되었습니다.

키 패턴TTL역할읽기
ab:__all_config__없음실험(AB) 설정Primary
unit_margin_rate_<id>72h지면 마진율Primary -> Replica
{lineitem_margin_rate}:<id>72h광고 마진율Primary -> Replica
w:<region>24h지역 날씨(타겟팅)Primary -> Replica
best_product:<feed>하루 1회 재생성추천 상품 목록Primary -> Replica

tracking items는 '캐싱 키를 읽어간 클라이언트 명부'이므로, 클라이언트가 읽는 곳에 명부가 생깁니다. 쌓이는 자리가 Primary에서 Replica로 옮겨간 것뿐, 쌓임 자체는 계속되고 있었던 겁니다.

그런데 이상하네요. 위 표에서 봤듯 옮겨간 캐싱 키는 전부 TTL이 있거나 매일 재생성됩니다. 키가 만료되면 tracking items는 회수됩니다. 그럼에도 왜 누적이 되고 있었을까요? 그리고 ab:__all_config__가 남아있는 Primary는 그 후로 왜 더 이상 쌓이지 않을까요? 저는 ab:__all_config__와 달리 나머지 4개의 키는 <id>, <region> 등 변하는 값이 있다는 점이 의심스러워 보였습니다.

진짜 원인 : 유령 키는 만료되지 않는다

답은 Redis 소스 코드에 있었습니다. 명부에 기록하는 함수(tracking.ctrackingRememberKeys)는 커맨드 인자에서 키 이름만 꺼내 그대로 기록합니다. 그 키가 실제로 존재하는지 확인하지 않습니다. 존재하지 않는 유령 키를 읽었을 때의 흐름 얼핏 보면 버그처럼 보이지만, 다시 생각해보면 꽤 자연스러운 동작입니다. 클라이언트는 "이 키는 없다"는 사실 또한 캐싱할 수 있습니다. 그리고 나중에 그 키가 생성되면 알려줘야 하니, Redis 입장에서는 키의 존재 여부와 무관하게 기억해 두는 것이 맞는 설계입니다.

이제 답이 보입니다. 존재한 적 없는 유령 키를 읽으면 tracking items는 남습니다. 그러나 그 키는 생성된 적이 없으니 TTL 만료가 영원히 없습니다. 해당 키에 쓰지 않는 이상 무효화되지 않는 기록이라는 것이지요.

로그와 지표로 측정해 보니 저희 서비스에서 유령 키를 실제로 읽고 있었습니다. w:KR-<region>에서 <region>이 생략된 w:KR- 키를 계속해서 읽고 있었습니다.

정말 이 유령 키가 명부의 주인인지 확인하는 마지막 실험. w:KR-에 값을 한 번 써서 강제로 무효화를 일으켜 봤더니 Primary에서 393,597개, Replica에서 146,436개의 tracking items가 즉시 회수됐습니다. 존재하지 않는 유령 키 하나의 tracking items가 약 18MB를 차지하고 있었습니다. 다른 유령 키들은 로그에 남지 않아 더 이상 파악하지 못했지만, 이러한 유령 키 조회가 원인이라는 점은 확인이 완료되었습니다.

2부로 이어집니다.

원인은 파악이 됐는데, 어떻게 해결했을까요? 유령 키를 모두 찾아 조회를 못하도록 했을까요? 개선 과정과 회고는 2부에 이어집니다. 읽어주셔서 감사합니다.

Related posts