다중 인스턴스와 Redis 도입 판단
API를 4대로 늘린다고 Redis가 바로 필요한 것은 아니었다. 성능, 정합성, 중복 로딩을 나눠 보고 다음 부하 실험에서 확인할 지표를 정리했다.
배경
ETF 가격 변동 설명 조회 API를 단일 인스턴스에서 4대로 확장하는 과정에서 Redis 도입 여부 검토
- 이 API는
(ticker, trade_date)로 검토·승인된 설명을 반환한다. 응답은 고객별로 달라지지 않으므로 캐시 키에 고객 식별자가 들어가지 않는다. - 현재는 단일 인스턴스에 Caffeine 인프로세스 캐시를 두고 TTL 3초로 운영한다.
- 처음 떠오른 답은 Redis였다. 인프로세스 캐시는 공유되지 않으니 서버가 늘면 공유 캐시가 필요하다고 봤다.
- 그런데 캐시가 공유되지 않는다는 사실만으로 Redis 도입까지 결정할 수는 없었다. 응답이 느린지, DB를 불필요하게 반복 조회하는지, 인스턴스마다 다른 응답을 내보내는지부터 나눠 봐야 했다.
Redis 도입 여부보다 먼저 문제와 판정 기준을 정리하는 것이 이 글의 범위다.
이 글을 쓸 당시 4대 확장, Redis 비교, TTL 동시 만료 실험은 계획 단계였다. 확정된 구현은 단일 인스턴스 Caffeine TTL 3초까지였고, 수치는 다음 실험에서 확인하기로 했다.
인스턴스 확장과 캐시 분리
Caffeine은 JVM 힙 안의 자료구조라 프로세스 경계를 넘지 않는 캐시
- 인스턴스를 4대로 늘리면 서로 독립적인 캐시가 4개 생긴다. 히트율이 곧바로 4분의 1이 되는 것은 아니다.
- 첫 줄은 비용 문제고, 둘째 줄은 응답 정합성 문제다. 원인은 같아도 허용 기준과 해법은 다르다.
| 단일 인스턴스에서 성립하던 것 | 4대에서 벌어지는 일 |
|---|---|
| 같은 키의 첫 요청 1건만 DB를 조회한다 | 각 인스턴스에서 첫 요청이 한 번씩, 최대 4건이 DB를 조회한다 |
| 캐시에 담긴 값이 곧 이 서비스의 현재 응답이다 | 인스턴스마다 다른 시점의 값을 들고 있을 수 있다 |
| TTL 3초 = 정정 반영 지연의 상한 | 상한은 여전히 3초지만, 그 안에서 응답이 갈린다 |
| 프로세스 재시작 1회 = cold start 1회 | 배포·스케일아웃마다 cold start가 인스턴스 수만큼 발생한다 |
인스턴스가 4대면 캐시도 4개이고, 비용 문제와 정합성 문제가 함께 생긴다.
성능·정합성·공유의 분리
운영에서 캐시가 문제라고 말할 때 섞여 있는 세 가지 문제
- 성능 : 응답이 느리거나 처리량 상한이 낮다. 지표는 p99와 포화 처리율이다.
- 정합성 : 차단된 콘텐츠가 계속 나가거나, 정정 전 문구가 보인다. 지표는 최대 스테일 시간과 차단 후 200 응답 수다.
공유 : 같은 값을 여러 인스턴스가 각자 로딩한다. 지표는 loader 호출 수와 DB SELECT 수다.
- 히트율이 100%여도 차단된 콘텐츠를 반환하면 실패다. 반대로 응답이 정확하더라도 각 인스턴스가 같은 값을 계속 DB에서 조회하면 비용이 남는다.
- Redis는 중복 로딩을 줄일 수 있지만 정합성을 자동으로 해결하지는 않는다. 성능도 실제로 재기 전에는 좋아진다고 단정할 수 없다. Redis를 거치면서 네트워크 왕복이 추가될 수도 있다.
세 문제를 한꺼번에 Redis 도입 여부로 바꾸면 판단 기준이 사라진다.
공유 캐시와 정합성
Redis를 두면 인스턴스 간 값이 같아진다는 말은 캐시 계층이 하나일 때만 성립
- L1이 남아 있으면 공유되지 않는다 : Caffeine L1 위에 Redis L2를 얹는 구성에서 인스턴스가 보는 값은 L1의 값이다. L2가 최신으로 갱신돼도 L1 TTL이 남아 있는 동안은 이전 값을 반환한다. 공유되는 것은 L2뿐이고, 관측 가능한 응답은 여전히 L1이 결정한다.
- 캐시와 DB 갱신은 dual-write다 : 게시 상태가 바뀔 때 DB를 갱신하고 캐시를 갱신(또는 삭제)하는 두 개의 write가 생긴다. 둘은 한 트랜잭션이 아니므로, 사이에서 프로세스가 죽거나 순서가 뒤집히면 캐시가 DB보다 오래된 값을 들고 남는다. 공유 캐시를 도입해도 이 문제는 그대로다.
- 만료는 동시에 일어난다 : 공유 키 하나가 TTL로 사라지는 순간, 그 키를 기다리던 모든 인스턴스의 모든 요청이 동시에 miss가 된다. 공유 캐시는 miss도 공유한다.
| Redis 도입으로 얻는 것 | Redis 도입으로 얻지 못하는 것 |
|---|---|
| 인스턴스 간 캐시 적중의 공유 | DB와 캐시의 정합성 (dual-write는 그대로) |
| L1이 있는 경우의 공유 (관측 값은 L1이 결정) | |
| 만료 순간의 부하 평탄화 (오히려 한 점에 모인다) | |
| 장애 격리 (새 공용 의존성이 생긴다) |
공유 캐시는 적중을 공유할 뿐, DB와의 정합성은 따로 해결해야 한다.
single-flight의 범위
같은 키에 대한 동시 miss를 원자 로더로 합치는 요청 병합 보장은 프로세스 경계에서 종료
- 현재 구현은
cache.get(key, loader)형태라서, 동시에 도착한 N개 요청은 loader를 1회만 호출하고 나머지는 그 결과에 합류한다. 이것이 single-flight, 곧 요청 병합이다. - 원자 로더가 있으면 DB 호출은 요청 수 N이 아니라 인스턴스 수에 비례한다. 초당 10,000건이 하나의 인기 종목에 몰려도 loader 호출은 TTL 주기마다 최대 4건이다.
- 이 글에서는 TTL 만료 순간 loader나 DB 호출이 동시 요청 수에 비례해 폭증할 때만 스탬피드라고 부른다. 인스턴스 수만큼 늘어나는 것은 상수배 증가로 구분한다.
- DB 호출이 요청 수에 비례한다면 분산 락이나 확률적 조기 갱신을 검토해야 한다. 인스턴스 수 수준에 머문다면 복잡도를 늘릴 이유가 없다.
- 당시에는 이 수치를 재지 못했기 때문에, 4대 환경의 TTL 동시 만료 시 loader 호출 수를 다음 실험 항목에 넣었다.
| 구성 | 동시 miss N건에 대한 loader 호출 |
|---|---|
| 1대, single-flight 없음 | N |
| 1대, 원자 로더 | 1 |
| 4대, 원자 로더 | 최대 4 (인스턴스당 1) |
| 4대 + 공유 L2, 분산 락 없음 | 최대 4 (L2 miss가 동시에 나면) |
요청 수 비례 폭증만 스탬피드이고, 인스턴스 수 비례 증가는 상수배다.
TTL·jitter·refresh·negative cache의 역할
자주 함께 언급되지만 각각 다른 문제를 푸는 네 가지 장치
| 장치 | 해결하는 문제 | 해결하지 못하는 문제 |
|---|---|---|
| TTL | 무효화 경로 없이도 stale의 상한을 보장 | 상한 안에서의 불일치, 만료 순간의 동시 miss |
| TTL jitter | 여러 키가 같은 순간 만료되며 생기는 피크 | 단일 hot key의 만료 (키가 하나면 흩뿌릴 대상이 없다) |
| 비동기 refresh | 만료 시점에 요청이 로딩을 기다리는 지연 | 아무도 조회하지 않던 키의 첫 요청(cold start) |
| negative cache | 결과 없음(204)이 매번 DB를 치는 문제 | 그 사이 생성된 데이터의 즉시 반영 |
| 분산 락 | 인스턴스 간 중복 로딩 | 락 획득 실패 시의 대기·타임아웃 설계 부담 |
- jitter는 오해가 잦다. jitter는 만료 시점이 동일한 키가 여럿일 때 그 시점을 흩뿌린다. 요청의 90%가 하나의 hot key에 몰리는 시나리오에서는 흩뿌릴 키가 사실상 하나이므로, 그 키의 만료 순간에 몰리는 동시 요청은 jitter로 줄지 않는다. 그 부하를 줄이는 것은 single-flight다.
- 우리 시스템의 TTL 3초는 차단·정정이 반영되기까지의 최대 지연을 묶기 위해 정했다. 프로세스 간 무효화 경로가 없기 때문이다.
캐시 전략을 바꿔도 TTL이 보장하는 정합성 상한은 별도로 지켜야 한다.
불변 축과 변경 축
캐시 키 (ticker, trade_date) 가 가리키는 값은 두 종류의 데이터가 섞인 객체
- 불변 축 : 게시된 콘텐츠 본문이다. 게시 식별자나
콘텐츠 ID + 버전으로 지정하면 그 내용은 다시 바뀌지 않는다. 변경 축 : 현재 발행 버전이 무엇인지, 차단됐는지, 제공 범위에 포함되는지, 최신 면책 문구가 무엇인지다.
- 둘을 한 객체로 캐싱하면 캐시 정책이 항상 변경 축에 끌려간다. 본문은 영원히 재사용해도 되는데, 차단 여부 때문에 TTL 3초를 걸어야 한다. 캐시의 이득 대부분이 불변 축에서 나오는데 비용은 변경 축이 결정하는 구조다.
- 축을 나누면 각각에 맞는 정책을 줄 수 있다. 불변 축은 버전 키로 길게 캐싱하고(같은 키가 다른 값을 가질 수 없으므로 무효화 자체가 필요 없다), 변경 축은 짧은 TTL이나 조회 시 확인으로 다룬다.
- 대신 조회 한 번에 확인이 두 번 들어가므로, 나누는 것이 항상 이득은 아니다.
TTL을 몇 초로 할지 답하려면 먼저 이 키가 가리키는 값이 바뀔 수 있는지를 확인해야 한다.
메모리 용량과 처리량
로컬 실험의 Redis에 걸기로 한 maxmemory 1GB는 저장 용량 조건일 뿐 처리 성능과 무관
- 처리 성능을 결정하는 것은 초당 처리 가능한 명령 수, 동시 연결 수 상한과 클라이언트 대기, 명령 지연과 그 꼬리 분포, 네트워크 입출력 대역이다.
- 요청이 10만 건이니 1GB가 찬다는 추론은 성립하지 않는다. 캐시는 키를 저장하므로 사용량은
키 수 × 값 크기로 결정된다. - hot key 시나리오에서 키 수는 종목 수 수준이라 메모리는 거의 남고, 병목이 생긴다면 연결이나 명령 지연 쪽이다.
- 지연이 올라가더라도
used_memory에 여유가 있고 eviction이 0이라면 메모리는 원인이 아니다. 결과에 맞춰 설명을 바꾸지 않도록 이 조건을 실험 전에 적어 뒀다.
메모리 용량과 처리량은 같은 축이 아니다.
결정에 필요한 지표
히트율은 캐시가 동작했는지를 말할 뿐, 그 동작이 무엇을 바꿨는지는 말하지 않는 지표
- 성능 수치만으로는 아래 표의 절반도 채울 수 없다. 정확성과 장애 상황까지 함께 재야 캐시 구성을 선택할 수 있다.
| 결정할 것 | 필요한 지표 |
|---|---|
| 인스턴스별 캐시로 충분한가 | loader 호출과 DB SELECT의 절대 건수 |
| 공유 캐시가 DB를 지키는가 | 신규 인스턴스 투입·전체 재기동 시 DB 호출 피크 |
| 공유 캐시가 새 병목인가 | Redis 명령 지연·연결 풀 대기와 API p99의 동반 상승 여부 |
| 정합성이 허용 범위인가 | 게시 변경·차단 후 모든 인스턴스 반영까지 걸린 시간, 차단 후 200 응답 수 |
| 장애를 감당할 수 있는가 | 공유 캐시 정지 시 오류율과 DB로 열리는 동시 로딩 수 |
hit ratio만으로는 캐시 전략을 고를 수 없다.
다중 인스턴스에서 캐시 문제를 성능·정합성·공유로 나누고, 결정에 필요한 지표를 정했다.
그렇다면 같은 조건에서 네 가지 캐시 구성에 부하를 걸면 어느 지표에서 차이가 날까?
다음 편에서는 로드밸런서 뒤 4대 환경에서 캐시 없음·인프로세스만·공유 캐시만·2계층을 비교한 부하 실험을 다룬다.
캐시 전략 시리즈
- 다중 인스턴스와 Redis 도입 판단
- 4가지 캐시 구성 부하 실험
- Caffeine 유지 결정 기록
- 캐시 경계 측정 (워킹셋 임계점)