Memory DB는 무엇으로 가득 차 있었을까 - 2부 : BCAST 전환과 Failover
Software Engineer
안녕하세요. 할당시스템 파트 서버 엔지니어, Simon 이상원입니다.
'MemoryDB는 무엇으로 가득 차 있었을까?'의 2부 글입니다. 1부에서는 MemoryDB 메모리의 85%를 채우고 있던 것의 정체를 찾아가는 과정을 다뤘습니다. 2부는 이 문제를 어떻게 해결했는가, 그리고 더 나은 방법은 없었을까에 대한 회고를 담았습니다.
1부에서는 무슨 일이 있었나요?
어느 날 MemoryDB의 메모리 사용률이 85%(2.63GB)에 도달했다는 알럿을 받았습니다. 사용되고 있는 실제 데이터는 165MB뿐인데 말이죠. 조사 결과, tracking_items가 누적되고 있었습니다.
Redis는 클라이언트 캐싱 설정이 되어 있는 키를 특정 클라이언트가 읽어가면, 키에 변경이 생겼을 때 클라이언트에 알려줄 수 있도록 클라이언트의 명부를 저장해두는데요. 이 명부가 tracking_items입니다. 이 명부는 해당 키에 저장된 값이 변할 때(TTL 만료 등도 포함) 클라이언트에게 알려준 후 회수되지만, 저희 서비스에서 클라이언트 캐싱을 사용하는 라이브 키들을 전부 무효화해 봐도 회수되는 항목은 거의 없었습니다.
원인을 더 깊게 조사해본 결과, 존재하지 않는 키를 읽어도 tracking_items는 기록된다는 점이 의심스러웠습니다. 존재하지 않는 키는 TTL 만료도 없으니, 해당 키에 새 값을 쓰지 않는 이상 그 키의 tracking_items는 영원히 회수되지 않는 것이죠. 저는 이 존재하지 않는 키를 유령 키라고 부르기로 했습니다. 실제로 빈 요청이 읽고 있던 유령 키 하나에 값을 써보니 양 노드에서 54만 개의 tracking_items(약 18MB)이 회수됐습니다.
원인은 찾았습니다. 그렇다면 어떻게 해결할 수 있을까요?
유령 키를 전부 막으면 될까요?
가장 먼저 떠오르는 방법이었습니다.
앞서 테스트에 사용된 유령 키 w:KR-은 원래 유저의 지역 정보가 주어졌을 때 w:KR-{지역 정보} 등의 키로 해당 지역의 날씨를 조회하기 위한 용도입니다. 유저의 지역 정보가 담겨오지 않으면 날씨를 조회하지 않도록 한다면 쉽게 해결할 수 있죠.
그러나 모든 유령 키를 찾으려면 로그 등의 방법으로 하나씩 찾아 막아야 합니다. 또한 이번에 조치한다고 해도, 누군가가 추가한 로직에 의해 또다시 발생할 여지가 있고 그때마다 두더지 잡기 식으로 해결해야 합니다. '유령 키를 읽으면 안 된다'는 것이 아니라 '읽은 기록이 쓰기 없이는 영원히 지워지지 않는 구조'를 해결할 방법을 찾아야 합니다.
OPTIN vs BCAST
Redis의 클라이언트 사이드 캐싱에는 두 가지 모드가 있습니다. 현재 저희가 사용하고 있는 방식이 기본 모드(OPTIN)이고, 또 하나가 BCAST(broadcasting) 모드입니다.
OPTIN 방식을 간단히 다시 정리해볼게요. OPTIN 방식에서는 클라이언트가 클라이언트 캐싱이 설정되어 있는 키를 읽는 시점에 tracking_items를 기록합니다. 즉 (키 × 읽은 클라이언트 수)만큼 tracking_items가 누적되는 구조입니다. 또한 이는 해당 키에 쓰기가 발생했을 경우에만 회수됩니다.
BCAST 방식에서는 클라이언트가 연결을 맺을 때 "나는 w:로 시작하는 키에 관심이 있어"라고 prefix를 선언해두면, 서버는 그 prefix에 해당하는 키에 쓰기가 발생할 때 구독자 전원에게 알려줍니다.
쓰기가 발생했을 때 클라이언트에게 알려준다는 점에서는 동일하므로 얼핏 보면 비슷해 보입니다. BCAST와 OPTIN의 차이가 느껴지는 지점은
-
키 단위가 아닌 prefix 단위로 명부를 기록합니다. OPTIN 방식에서 클라이언트가
w:KR-11,w:KR-12키를 읽었다면 각각의 키에 대해 클라이언트 ID가 기록되지만, BCAST에서는 클라이언트가 연결 시점에 'w:로 시작하는 키들을 구독할 거야'라고 한다면w:에 대한 하나의 명부만 기록됩니다. -
BCAST에서는 클라이언트가 키를 읽는 시점이 아닌 클라이언트 - MemoryDB 간의 연결이 생성될 때 기록됩니다. 즉 실제로 클라이언트가 해당 키를 읽지 않아도, 연결 시점에 '나 이 prefix에 해당하는 키를 읽을 예정이야'라고 알려주는 것만으로도 명부에 등록이 됩니다. 당연히
w:KR-11을 실제로 읽지 않았더라도w:prefix를 구독했다면w:KR-11에 쓰기 작업이 발생했을 때 MemoryDB는 클라이언트에게 알려줍니다. -
BCAST 방식에서는 클라이언트의 연결이 끊기면 해당 클라이언트의 명부는 모두 지워집니다. 한 번 연결이 설정되면 아무리 다른 키를 읽어도 기록이 쌓이지 않고, 연결이 끊겼을 때 모두 회수되므로 명부가 누적되는 근본적인 문제 자체가 사라지게 됩니다.
| 기존 (OPTIN) | BCAST | |
|---|---|---|
| 저장 단위 | (키 × 클라이언트) | (prefix × 클라이언트) |
| 기록 시점 | 키를 읽을 때마다 | 연결 시점 |
| 연결 종료 시 | 방치 (lazy) | 즉시 제거 |
| 유령 키 읽기 | 기록 생성 | 기록 없음 |
| 메모리 | 누적 연결 수에 비례 | 현재 연결 수로 고정 |
| 새로운 비용 | — | 쓰기 시 실제로 읽지 않은 클라이언트에게도 알림 전송 |
BCAST는 어떻게 클라이언트의 연결 종료 시점에 명부를 지울 수 있나요?
1부에서 OPTIN 방식은 연결 종료 시점에 회수할 수 없다고 했습니다. 캐싱한 키가 많은 클라이언트의 연결이 끊기면 해당 클라이언트의 tracking item을 찾기 위해 전체 Tracking Table(Radix Tree 구조)을 스캔해야 하고, 이는 많은 비용을 소모하게 되기 때문인데요. BCAST 방식은 어떻게 연결 종료 시 명부를 회수할 수 있을까요? 두 방식의 자료구조를 비교하면 알 수 있습니다.
OPTIN 방식의 Tracking Table은 키 → 클라이언트 방향으로만 색인되어 있습니다. "이 클라이언트가 어떤 키들을 읽었나"를 알려주는 역방향 인덱스가 없어서, 연결 하나를 정리하려면 수천만 항목 전체를 훑어야 합니다. 초당 수천 건을 처리하는 서버가 연결 하나 끊길 때마다 이 작업을 할 수는 없으니, "나중에 키가 무효화될 때 지우자"는 설계가 된 것이지요.
반면 BCAST의 Prefix Table은 prefix → 그 prefix를 구독한 클라이언트 객체 포인터들입니다. 그리고 클라이언트 객체가 자기가 구독한 prefix 목록을 직접 들고 있습니다. 기존 방식에 없던 역방향 인덱스입니다. 이를 자세히 살펴보기 위해, 클라이언트가 MemoryDB에 연결을 맺는 과정부터 따라가 보겠습니다.
연결이 맺어지면 클라이언트 객체가 생깁니다
클라이언트가 MemoryDB에 TCP 연결을 맺으면 MemoryDB는 해당 클라이언트의 객체(client 구조체)를 하나 만듭니다. 이 객체는 연결이 살아 있는 동안 유지되며, 끊기면 해제됩니다. 이 객체에는 클라이언트 ID, 소켓, 처리 중인 명령 같은 정보와 함께 트래킹 모드 플래그와 client_tracking_prefixes(이 클라이언트가 구독한 prefix 목록)가 들어 있습니다. 연결 직후에는 플래그와 목록 등이 비어 있습니다.
구독 요청을 통해 Prefix Table에 등록됩니다
클라이언트 라이브러리(rueidis)는 연결을 맺은 직후 CLIENT TRACKING ON BCAST PREFIX ab: PREFIX w: …를 보냅니다. MemoryDB는 이 명령을 받으면 prefix마다 세 가지 작업을 합니다.
- MemoryDB의 Prefix Table에서 해당 prefix의 항목을 찾습니다. 없으면(이 prefix의 첫 구독자라면) 새로 만듭니다. 항목 하나는
bcastState라는 작은 구조체이고, 그 안에 구독자 목록(clients)과 쓰기가 일어난 키를 잠시 모아두는 발송 대기 목록(keys)이 들어 있습니다 - 구독자 목록
clients에 이 클라이언트 객체의 포인터(8바이트)를 넣습니다(이미지의→#1024581) - 동시에 클라이언트 객체의
client_tracking_prefixes에 그 prefix를 넣습니다(이미지 우측의w:,ab:)
여기서 눈여겨볼 점이 하나 있습니다. OPTIN 방식의 Tracking Table에는 클라이언트의 ID(번호) 가 적히는데, BCAST 방식의 Prefix Table 구독자 목록에는 클라이언트 객체의 포인터(주소) 가 들어갑니다. OPTIN 방식은 연결이 끊긴 뒤에도 항목이 남을 수 있습니다. 연결이 끊겨 이미 사라진 객체를 가리키면 안 되니 ID로 적어둔 것이죠. BCAST는 연결이 끊기는 순간 반드시 지운다는 보장이 있으니 주소를 그대로 들고 있어도 됩니다. 저장하는 값의 종류 자체가 두 방식의 회수 정책을 말해주는 셈입니다.
키 읽기는 아무것도 남기지 않고, 쓰기 작업이 있으면 모아서 한 번에 알립니다
w:KR-11 키에 쓰기가 발생했다고 가정해보겠습니다. w:KR-11의 값을 새로 쓰게 되면 MemoryDB는 다음 순서로 움직입니다.
- Prefix Table을 훑어
w:KR-11이라는 이름과 맞는 prefix를 찾습니다.w:는 맞고ab:는 아니네요.w:의bcastState에 있는 발송 대기 목록(keys)에w:KR-11을 넣어둡니다. 아직 아무에게도 보내지 않습니다. - 발송이 일어나기 전에
w:KR-26,w:KR-27등의 키에 쓰기 작업이 일어나면 같은 대기 목록(keys)에 함께 쌓입니다. 같은 키를 두 번 쓰면 항목은 하나로 합쳐집니다. - 이벤트 루프가 요청들을 모두 처리하고 다시 대기에 들어가기 직전(
beforeSleep), MemoryDB는w:의 구독자 목록에 있는 클라이언트 객체 전원(#1024581,#1031002)에게 대기 목록 전체를 무효화 메시지 하나로 묶어 보냅니다. - 대기 목록을 비웁니다. 다음 바퀴는 다시 빈 상태에서 시작합니다.
여기서 #1031002가 w:KR-11을 읽은 적이 없더라도 메시지는 똑같이 받습니다. 클라이언트가 그 키를 읽은 적이 있는지 MemoryDB는 따지지 않습니다. 기록을 하지 않으니까요. 메시지를 받은 클라이언트는 자기 캐시에 w:KR-11이 있으면 지우고 없으면 메시지를 버립니다.
BCAST에서 MemoryDB가 지는 책임은 "이 prefix에 쓰기가 있었다"고 알리는 것까지입니다. 나머지는 전부 클라이언트의 몫입니다. 자기가 캐싱할 키의 prefix만 골라 구독하는 것도, 받은 무효화 메시지 가운데 자기 캐시에 있는 키만 지우고 나머지는 버리는 것도 클라이언트가 합니다. "누가 어떤 키를 갖고 있는지"를 아는 주체가 MemoryDB에서 클라이언트로 옮겨간 셈이죠.
연결이 끊기면 클라이언트의 prefix 목록만 따라가 지웁니다
이제 다시 질문으로 돌아옵니다. 연결이 끊기면 클라이언트 객체가 들고 있던 prefix 목록을 따라가, 각 prefix의 clients 목록에서 자기 포인터만 빼면 끝입니다. 전체 테이블을 훑어 자기 자리를 찾아야 하는 OPTIN과 달리, 어디를 지울지 이미 알고 있는 것이지요.
결국 MemoryDB가 들고 있는 것은 prefix 수 × 살아있는 연결 수뿐입니다. 하루 26,000개의 연결이 생기고 사라져도, 유령 키를 얼마나 읽어도 이 수는 변하지 않습니다. 이것이 BCAST가 연결 종료 시점에 명부를 지울 수 있는 이유입니다.
어떻게 적용했나요?
BCAST를 적용할 때 고려할 점
앞서 살펴봤듯 BCAST는 '누가 어떤 키를 읽는가?'를 MemoryDB가 매번 기억하는 것이 아니라 클라이언트가 사전에 알려주는 구조입니다. 그래서 적용 전에 주의할 점이 있습니다.
-
쓰기가 잦은 키에는 맞지 않습니다. OPTIN 방식은 그 키를 실제로 읽은 클라이언트에게만 알리지만, BCAST는 prefix가 맞으면 읽은 적 없는 연결에도 전부 보냅니다. 자주 갱신되는 키를 BCAST로 구독하면 MemoryDB는 클라이언트에게 알리는 것에, 클라이언트는 그것을 버리는 데 힘을 씁니다(사실 쓰기가 잦은 키는 애초에 클라이언트 캐싱을 사용하지 않는 것이 나을 것 같네요.)
-
prefix의 범위를 정하는 것이 중요합니다. prefix 범위를 너무 넓게 잡으면 관심 없는 키의 쓰기까지 전부 받습니다. 예를 들어 빈 prefix('')를 구독하면, 모든 키의 쓰기 작업에 대해 알림을 받게 됩니다. 너무 좁게 잡아 캐싱하는 키가 등록에서 빠지면 그 키는 무효화를 받지 못해 클라이언트 TTL까지 오래된 값을 씁니다. 또한 겹치는 prefix(
w:와w:KR)는 MemoryDB가 등록을 거부합니다. -
모든 연결이 모든 prefix를 구독하면 의미가 약해집니다. 어떤 키도 읽지 않을 클라이언트가 prefix를 구독하고 있으면 그만큼 MemoryDB는 불필요한 알림을 전송하게 됩니다. 클라이언트 인스턴스마다 자기가 실제로 캐싱하는 키의 prefix만 등록해야 합니다.
-
그 외 키 이름이 prefix로 묶이는 구조여야 합니다. 캐싱 키가 소수의 고정된 접두사로 나뉘지 않으면 등록할 prefix 자체를 정할 수 없습니다. 또한 클라이언트 라이브러리가 제대로 지원해야 합니다. 저희가 쓰던 버전에는 BCAST 모드에서 서버가 거부하는 커맨드를 계속 보내는 버그가 있어, 수정된 버전으로 먼저 올려야 했습니다.
우리 서버에 적용할 수 있을까?
위 조건을 저희 워크로드에 하나씩 체크해봤습니다.
| 조건 | 저희 서비스 |
|---|---|
| 쓰기 빈도 | 캐싱 키 모두 배치성 쓰기. 대부분 하루 한 번, 특정 키는 거의 바뀌지 않음 |
| 읽기 빈도 | 요청마다 읽음 (피크 초당 7,000건 이상) → 쓰기와 읽기의 비대칭이 극단적으로, 클라이언트 캐싱이 적절 |
| prefix 구조 | 다섯 계열이 서로 겹치지 않는 고정 prefix 5개로 정확히 나뉨 |
| 연결별 구독 | 캐싱 키를 쓰는 클라이언트 인스턴스가 둘뿐이라, 하나는 4개·다른 하나는 1개 prefix만 등록하고 나머지는 구독 X |
조건은 만족하는 것 같습니다. 다만 배치성 쓰기 작업 시, 해당 prefix를 구독한 클라이언트로 알림을 보내는 규모가 얼마나 되는지 개발 환경에서 한 번 테스트해보고 가야 할 것 같습니다.
그럼 적용해보자!
우선 저희가 쓰는 Go Redis 클라이언트인 rueidis는 BCAST 모드를 지원합니다. 다만 저희가 쓰던 버전에는 BCAST에서 서버가 거부하는 커맨드를 계속 보내는 버그가 있어서 수정된 버전으로 먼저 올려야 했습니다.
연결을 만드는 곳에서 CLIENT TRACKING ON BCAST PREFIX ab: PREFIX w: ... 옵션만 넘겨주면 됐기 때문에, 캐시를 읽고 쓰는 애플리케이션 코드는 바꿀 필요가 없었습니다.
한 가지 조심할 점이 있었습니다. 나중에 캐싱 키를 새로 추가할 때 prefix 등록을 빠뜨리면 그 키는 클라이언트 TTL이 끝날 때까지 무효화되지 않습니다. 서버도 라이브러리도 이를 검증해주지 않기에, 연결을 만드는 코드에 이 주의사항을 주석으로 남기고, 클라이언트 사이드 캐싱을 쓰는 키가 추가됐을 때 실제로 구독 중인 prefix 목록으로 커버가 되는지 대조하는 테스트를 추가했습니다.
개발 환경에 배포하여 문제가 없음을 확인했습니다. 배치성 쓰기 작업 시 클라이언트 알림 전송 비용은 배치를 실행해 실측했습니다. 약 3,000키가 한꺼번에 갱신되는 시각에 노드의 네트워크 송신은 분당 약 30MB에서 4050MB로 잠깐 올랐고(13Mbps 추가), 엔진 CPU 피크는 17%를 넘지 않았습니다. OPTIN 방식이 매달 0.2GB씩 서버 메모리를 갉아먹던 것과 비교하면 기꺼이 치를 만한 비용이었습니다.
카나리 배포로 프로덕션 환경의 일부 파드에서 먼저 확인한 뒤, 같은 날 100%로 확대했습니다.
결과는요?
배포 직후 서버 지표에서 tracking_total_prefixes가 0에서 5로 바뀌었고, BCAST 모드로 붙은 연결이 Primary 473개, Replica 444개로 잡혔습니다. BCAST가 잘 등록되었다고 볼 수 있죠. 그리고 전환 전 하루 약 28만 개씩 늘어나던 Replica의 tracking item이 더 이상 늘지 않았습니다. 이젠 기존에 쌓여있던 tracking items를 날리는 일만 남았습니다.
Failover
BCAST로 바꿨다고 기존 tracking items가 지워지지는 않습니다. 1부에서 봤듯 유령 키의 기록은 그 키에 쓰기가 일어나야 회수되는데, 어떤 유령 키의 기록이 남아 있는지 알 수 없고 Redis 6.2에는 Tracking Table을 비우는 명령도 없습니다. 남은 수단은 failover뿐이었습니다. failover로 노드의 프로세스가 새로 만들어지면 그 안의 Tracking Table도 함께 사라지니까요.
failover는 강등되는 노드만 재구축하고, 승격되는 노드는 프로세스를 그대로 유지합니다. 저희는 Primary와 Replica 양쪽에 tracking items가 남아있으니 failover를 두 번 해야 양쪽을 다 비울 수 있었습니다.
Failover를 해도 괜찮을까?
failover는 실서비스에서 사용하는 MemoryDB의 연결을 한 번 끊는 작업입니다. 초당 수천 건의 광고 요청이 지나가는 저장소를 끊는다고 하니, 진행하기 전에 답해야 할 질문이 있었습니다.
- 다운타임은 얼마이고, 실서비스에 어떤 영향이 있는가?
- failover 순간 클라이언트에 발생할 수 있는 장애가 있는가? (rueidis에는 failover 중 발생할 수 있는 알려진 크래시 버그가 있었습니다)
문서나 코드만으로는 판단하기 어려워, 개발 환경에서 같은 구성을 구축해 요청을 흘려보내며 리허설을 해봤습니다.
- 클라이언트: 크래시 0건, 파드 재시작 0건
- MemoryDB: 강등된 노드의 uptime이 989일 → 13초로 리셋되고, tracking item 47,303개 → 0. 프로세스가 새로 만들어진 것이 확인됐습니다
- 클라이언트 - MemoryDB 간 단절은 약 15초(자동 복구)
15초라는 단절 시간만 보면 '실서비스에서 15초나 단절되면 문제가 있는 것 아닌가?' 싶을 수 있습니다. 그러나 실제로 이 저장소가 담고 있는 데이터와 사용 방식을 고려했을 때 크리티컬하지 않다고 판단할 수 있었습니다.
- 유저에게 보여줄 광고 자체는 Elasticsearch에서 조회해오고, MemoryDB에서 읽는 정보는 날씨·실험 설정·마진율 같은 보조 데이터입니다. 이 보조 데이터가 조회되지 않아도 광고 송출이 중단되진 않습니다.
- 클라이언트 사이드 캐싱 키이므로, 평소에는 이 읽기의 대부분이 MemoryDB까지 가지 않습니다. 다만 failover로 MemoryDB와의 연결이 끊기면 그 연결에서 읽어온 로컬 캐시도 함께 버려지므로, 단절 동안의 읽기는 캐시 없이 MemoryDB로 향하게 됩니다. 이 읽기들은 전부 짧은 타임아웃(키 종류에 따라 20~60ms)을 갖고 있어서, 응답이 없으면 그 항목만 빼고 광고 선정을 계속 진행합니다.
15초 동안 일어나는 일은 일부 요청이 보조 데이터 없이 나가는 것으로, 광고 송출이 중단되는 등의 크리티컬한 문제는 발생하지 않습니다.
쓰기 경로는 더 단순합니다. MemoryDB에 값을 쓰는 주체는 유저의 요청이 아니라 주기적으로 도는 시스템들입니다. 짧게는 약 15초 간격, 길게는 하루 한 번 안팎의 배치가 갱신합니다. 광고 요청 한 건이 처리되는 동안 MemoryDB에 무언가를 쓰는 일은 없습니다.
그래서 failover로 Primary가 잠시 사라지는 동안 일어나는 일은 '그 시각에 진행되는 갱신 배치 작업 한 번이 실패하는 것'뿐입니다. 이 쓰기들은 전부 같은 키를 최신값으로 덮어쓰는 방식이라, 실패한 주기는 건너뛰고 다음 주기가 정상적으로 덮으면 그걸로 원상 복구됩니다. 배치 작업 시간을 피해 failover를 진행하기만 하면 됩니다.
이미 저장돼 있던 값이 사라질 걱정도 없습니다. MemoryDB는 쓰기를 여러 가용 영역의 트랜잭션 로그에 남긴 뒤에 응답하는 구조라 failover에서 데이터가 유실되지 않는다고 보장하고, 리허설에서도 failover 전후 양 노드의 키 수가 일치하는 것을 확인했습니다.
마지막으로 광고 서버의 헬스체크는 MemoryDB를 보지 않습니다. 저장소가 잠시 응답하지 않는다고 서버가 "비정상"으로 판정돼 연쇄 재시작되는 구조가 아닙니다. 정리하면, 읽기는 잠깐 덜 정교해지고 쓰기는 한 주기 밀리는 것이 전부이며, 광고 송출이 멈추는 경로는 없습니다.
그래서 어떻게 됐나요?
프로덕션 failover는 배포와 배치가 없고, 유저의 요청이 적은 새벽 시간대를 골라 진행했습니다.
1차로 기존 Primary를 강등시켰습니다. 33초 만에 승격이 끝났고, 강등된 노드의 tracking item 55,945,680개가 0이 됐습니다. 양 노드의 키 수가 일치하는 것을 확인한 뒤, 같은 날 아침 2차로 나머지 노드를 강등시켜 20,900,737개를 마저 비우고 역할을 원래대로 되돌렸습니다.
- tracking item: 양 노드 합계 76,846,417 → 0
- 메모리 사용률: Primary 85%, Replica 30% → 양쪽 모두 약 4% (남은 것은 실제 데이터뿐)
- 클라이언트 크래시 0건, 광고 서빙 이상 없음
이 글을 쓰는 시점에서 failover 후 4주가 지났습니다. 30분마다 기록하는 수집기의 값은 양 노드 모두 tracking item 0, 메모리 사용률 약 4%를 유지하고 있습니다. BCAST가 재누적을 막고 있다는 것이 확인된 셈입니다.
돌아보며
증상 완화보다 원인 규명이 먼저입니다.
tracking_total_items가 범인이라는 것만 알고 바로 failover를 했다면, 몇 달 뒤 같은 알럿을 다시 받았을 겁니다. 유령 키라는 원인을 찾았기에 BCAST를 도입해 구조적으로 재발을 막았고, 그 후 안심하고 failover를 진행할 수 있었습니다.
편리한 기능 뒤의 동작 원리를 파고들 수 있어야 하고, 그러기 위해서 CS 지식이 필요합니다.
클라이언트 사이드 캐싱은 좋은 기능이지만, 그 기록이 언제 생기고 언제 지워지는지, 특히 지워지지 않는 경우가 언제인지를 모르면 이번 같은 누적을 만나게 됩니다. 그리고 왜 이런 기록 방식을 택했는지를 알기 위해서는 자료구조에 대한 이해가 필요합니다. 취업 후 CS 지식을 활용할 일이 생각보다 없었는데, 좋은 개발자가 되기 위해서는 꼭 필요한 지식임을 또 한 번 느꼈습니다.
"저 과정을 거치면서까지 클라이언트 사이드 캐싱을 사용해야 했을까요?"
이 주제로 사내 개발자 세미나에서 발표했을 때 받은 첫 질문이었습니다. 이 키들은 광고 요청마다 읽는 값이라, 캐시 없이 매번 서버에 물으면 초당 수천 건의 조회와 실험 설정 JSON 같은 페이로드가 노드 하나로 몰리기 때문에 필요하긴 했다고 생각합니다.
그러나 질문의 핵심은 다른 데 있었습니다. 저는 "이 기능을 어떻게 고칠까"만 생각했지 "이 기능이 꼭 필요한가"는 묻지 못했습니다. 문제를 발견한 기능 안에서만 답을 찾고 있었던 셈입니다. 메모리의 85%를 차지하고 있는 것이 무엇인지 찾고 이를 해결하는 것에만 집중했고, "실제로 이게 필요한가?"에 대해 고민하지 못했죠. 제가 놓친 관점을 제시해주는 동료들이 있음에 또다시 감사함을 느꼈습니다.
앞으로의 계획
메모리 사용률이 4%로 내려온 지금, 이 클러스터를 어떻게 할 수 있을까요?
메모리 사용이 줄었으니 노드를 다운그레이드할 수 있을 것 같네요. 실제 데이터가 150MB 남짓이라 지금의 db.t4g.medium(3.09GiB)은 과합니다. 한 단계 아래인 db.t4g.small(1.37GiB)로 내리면 도쿄 리전 정가 기준 노드당 시간당 $0.147에서 $0.074로, Primary와 Replica 두 대를 합쳐 월 약 $215에서 $108로, 연간 약 $1,280을 아낄 수 있습니다.
그러나 3GB 넘는 여유를 '서비스에 임팩트를 줄 수 있는 다른 곳에 사용해볼 수 있지 않을까?' 고민해보고 있습니다. 현재 요청 또는 유저가 광고를 클릭했을 때 실시간으로 RDS를 조회하는 패턴이 있는데, 이 부분에서 MemoryDB를 활용할 수 있다면 유저가 더 빨리 광고에 참여할 수 있을 것 같아요. 어느 쪽으로 개선하든 정리해서 다시 글로 남기겠습니다.
혹시 MemoryDB의 메모리 사용률이 높다면, 실제 데이터가 아닌 다른 무언가로 가득 차 있지 않은가요?
읽어주셔서 감사합니다.