상품 캐시 글에서 상품 조회를 Redis Cache Aside 구조로 옮겼습니다. @Cacheable을 붙이고 캐시별 TTL을 정하고 @CacheEvict로 무효화 경로도 정했습니다.
코드는 준비됐습니다. 확인할 질문이 하나 남았습니다.
그래서 실제로 빨라졌나?
“캐시를 붙였으니 빨라졌을 것”이라는 말은 추정입니다. 이 프로젝트의 성능 요구사항은 측정값을 요구합니다.
| 측정 항목 | 목표 | 검증 방법 |
|---|---|---|
| 상품 목록 API 응답시간 | p99 <= 100 ms | nGrinder 부하 테스트 |
| Redis 캐싱 개선 효과 | 캐시 미적용 대비 TPS 3배 이상 | 캐싱 전/후 TPS 비교 (동일 GKE 환경) |
이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다. 캐시는 Phase 2에서 붙였고, 이 글의 측정은 Phase 3에서 했습니다.
이 글은 부하 테스트 세션 B(2026-04-09)에서 위 요구사항을 숫자로 검증하려 한 기록입니다. 측정 작업은 과금 범위를 나누려고 세 세션으로 쪼갰습니다. 세션 A는 로컬 준비, 세션 B는 상품 조회 캐시 TPS 측정, 세션 C는 동시 주문과 Kafka Lag 측정입니다.
결과를 먼저 보면 목표에는 못 미쳤습니다.
| 비교 | 결과 |
|---|---|
| 캐시 OFF 평균 TPS | 265.0 |
| 캐시 ON 평균 TPS | 612.7 |
| 개선 배율 | x2.31 |
| 목표 | x3 이상 |
이 실패를 세션 안에서 억지로 고치지는 않았습니다. 이 글은 그 미달 값을 오염되지 않은 측정 결과로 남기기 위해 지킨 세 가지 규율을 다룹니다.
- 단일 변수 격리: 캐시 ON/OFF만 바꾸고 이미지, 시드, 부하, 노드, 시나리오는 고정했습니다.
- 개선 비율 중심 해석: 절대 TPS보다 같은 환경에서의 before/after 배율을 봤습니다.
- 환경 스펙 동봉: x2.31이 어떤 조건에서 나온 값인지 함께 기록했습니다.
용어
| 용어 | 이 글에서의 의미 |
|---|---|
| TPS | Transactions Per Second. 초당 처리한 요청 수. 처리량 지표 |
| MTT | Mean Test Time. nGrinder가 측정한 평균 테스트 수행 시간(ms). p95/p99가 아니라 평균 |
| VUser | Virtual User. 부하 발생기가 흉내 내는 동시 가상 사용자 |
| nGrinder | Groovy 스크립트로 시나리오를 작성하고 controller/agent로 부하를 거는 도구 |
| warm-up | 본측정 전 짧게 부하를 흘려 JIT, 커넥션 풀, 캐시 적재를 끝내는 단계 |
| baseline | 비교 기준선. 이 글에서는 캐시 OFF 측정값 |
| 단일 변수 실험 | 한 가지 변수만 바꾸고 나머지 조건은 모두 고정하는 실험 |
| saturation | CPU 같은 자원이 한계에 가까워져 더 이상 처리량이 늘지 않는 상태 |
| 가드레일 | 측정을 오염시키지 않기 위해 미리 정한 금지 규칙 |
| histogram bucket | 응답시간을 구간별 누적 카운트로 쪼갠 시계열. p95/p99 계산에 필요 |
이번 글의 질문
- “캐시가 효과 있다”를 어떻게 숫자로 바꿀 수 있을까요?
- 무엇을 고정하고 무엇만 바꿔야 측정이라고 부를 수 있을까요?
- 왜 측정 결과에 환경 스펙을 같이 기록해야 할까요?
- 왜 절대 TPS가 아니라 개선 배율(x2.31)을 중심으로 해석했을까요?
- p95/p99 패널이 No data였는데 응답시간 근거는 어떻게 다뤘을까요?
- 캐시를 켰는데도 목표 x3에 못 미친 원인은 어디까지 좁혔을까요?
- 이 측정 방식은 Phase 4 MSA에서 어떻게 다시 쓰일까요?
문제 상황: 캐시는 깔았지만 효과는 아직 추정이었다
Phase 2의 첫 작업으로 상품 목록/상세 조회를 Redis Cache Aside 뒤로 옮겼습니다(상품 캐시 글). 로컬에서는 빨라졌다는 느낌이 있었지만 느낌은 포트폴리오의 근거가 되기 어렵습니다.
요구사항은 명확했습니다.
동일 GKE 환경에서 캐시 미적용 대비 TPS 3배 이상
캐시 효과 측정은 생각보다 쉽게 오염됩니다.
“캐시를 켜고 부하를 걸었더니 빠르더라”만으로는 측정이 되지 않습니다. 측정이 되려면 두 실행 사이에서 캐시 하나만 바뀌어야 합니다.
다음 중 하나라도 달라지면 결과를 캐시 효과라고 말하기 어려워집니다.
- 이미지 버전
- 시드 데이터
- 부하 시나리오
- VUser 수
- 노드 스펙
- 부하 발생기 위치
- 애플리케이션 리소스 제한
이런 값이 달라지면 TPS 상승이 캐시 덕분인지, 더 좋은 노드 덕분인지, 다른 데이터 분포 덕분인지 분리할 수 없습니다. 이 값들은 아래 ‘측정 환경’ 절의 표에 적었습니다.
게다가 세션 B는 과금되는 1회성 GKE 세션에서 진행했습니다. 클러스터를 띄운 채 디버깅과 튜닝을 반복하면 비용이 늘고 더 큰 문제로 측정 중간에 교란변수를 직접 주입하게 됩니다.
그래서 세션 B는 시작 전부터 앞에서 나눈 세 세션 가운데 상품 조회 캐시 TPS 측정만 맡았고, 이 글은 그 세션 B만 다룹니다.
실험 설계
1. 캐시 하나만 토글한다
세션 B에서 바꾼 변수는 오직 하나입니다.
PEEKCART_CACHE_ENABLED=false
PEEKCART_CACHE_ENABLED=true
나머지는 모두 고정했습니다. 고정한 값은 아래 ‘측정 환경’ 절의 표에 한데 적었습니다.
이 구조를 가능하게 한 코드는 CacheConfig의 조건부 빈 등록입니다.
// global/config/CacheConfig.java
@Bean
@ConditionalOnProperty(
name = "peekcart.cache.enabled",
havingValue = "true",
matchIfMissing = true
)
public RedisCacheManager cacheManager(RedisConnectionFactory cf) {
...
}
@Bean
@ConditionalOnProperty(
name = "peekcart.cache.enabled",
havingValue = "false"
)
public CacheManager noOpCacheManager() {
return new NoOpCacheManager();
}
캐시 OFF는 NoOpCacheManager로 구현했습니다.
캐시를 끄기 위해 @Cacheable을 제거하거나 캐시 모듈을 빼면 두 측정의 코드 경로가 달라집니다. 그러면 캐시 외의 차이가 생깁니다.
반면 NoOpCacheManager는 @Cacheable과 AOP 경로를 그대로 둔 채, 캐시가 항상 miss처럼 동작하게 만듭니다. 즉 두 실행의 차이는 캐시 레이어가 값을 돌려주느냐 아니냐로 좁혀집니다.
2. 배포는 환경변수만 바꾼다
캐시 OFF 측정은 다음 절차로 실행했습니다.
kubectl set env deployment/peekcart PEEKCART_CACHE_ENABLED=false
rollout 완료 확인
readiness 확인
warm-up 실행
본측정 실행
캐시 ON 측정도 같은 절차를 반복했습니다. 이미지와 시나리오는 그대로 두고 환경변수만 바꿨습니다.
완전히 동일한 JVM 프로세스에서 캐시만 토글한 것은 아닙니다. set env 후 rollout이 일어나기 때문입니다. 대신 warm-up으로 cold-start 영향을 줄이고 실용적으로 통제 가능한 수준에서 단일 변수 실험을 구성했습니다.
측정 환경
측정 리포트는 수치보다 환경 스펙을 먼저 적었습니다. 두 실행 사이에서 고정한 값도 이 표에 함께 적었습니다.
| 항목 | 값 |
|---|---|
| 노드 | e2-standard-4 x 1 (4 vCPU / 16 GiB), 두 실행 모두 동일한 GKE 클러스터와 노드 |
| peekcart Pod | req 500m/1Gi, lim 2000m/2Gi, replicas=1 |
| 부하 발생기 | loadgen VM (e2-standard-2, 동일 zone) |
| nGrinder | 3.5.9-p1, agent 1, 본측정 VUser 50 |
| 이미지 | peekcart:3352c14 |
| 시드 | 동일 seed.sql |
| 시나리오 | 동일 nGrinder Groovy 스크립트 |
환경 스펙을 함께 적는 데는 이유가 있습니다.
TPS는 환경에 종속된다.
612.7 TPS라는 숫자는 e2-standard-4 노드 1대, 앱 Pod 2 vCPU limit, 같은 zone의 loadgen VM, Internal LB 경로에서 나온 값입니다. 노드를 키우거나 Pod 수를 늘리면 달라질 수 있습니다.
따라서 이 글에서 남길 결과는 절대 TPS 대신 다음 문장입니다.
같은 환경에서 캐시 하나만 켰더니 평균 TPS가 x2.31이 됐다.
이 문장은 캐시라는 변수의 효과를 비교적 잘 분리합니다. 다만 x2.31이 다른 환경에서도 그대로 반복된다는 뜻은 아닙니다. 병목이 CPU인지, DB인지, Redis인지에 따라 캐시의 개선 배율은 달라질 수 있습니다.
부하 시나리오
nGrinder 스크립트는 일반적인 이커머스 조회 패턴을 근사했습니다.
// 80% 목록, 20% 상세
if (random.nextInt(100) < 80) {
queryList() // GET /api/v1/products?categoryId={1..5}&page={0..49}&size=20
} else {
queryDetail() // GET /api/v1/products/{id}, id in [1, 1000]
}
설계마다 의도가 있습니다.
| 설계 | 이유 |
|---|---|
| 목록 80%, 상세 20% | 실제 조회 트래픽은 목록 비중이 더 높다 |
| category/page/productId 랜덤 분산 | 같은 키만 반복해서 캐시 효과가 과대평가되는 것을 막는다 |
측정 절차는 다음과 같이 나눴습니다.
| 단계 | 부하 | 목적 |
|---|---|---|
| warm-up | 1분 / 10 VUser | JIT, 커넥션 풀, 캐시 적재 |
| 본측정 | 5분 / 50 VUser | 정상 상태 처리량 측정 |
warm-up 구간은 본측정에 포함하지 않았습니다. 초기 cold-start 비용이 섞이면 캐시 효과가 흐려지기 때문입니다.
측정 결과
| 지표 | Baseline (캐시 OFF) | 개선 후 (캐시 ON) | 변화 |
|---|---|---|---|
| TPS (평균) | 265.0 | 612.7 | x2.31 (+131%) |
| TPS (최고) | 328.0 | 783.0 | x2.39 (+139%) |
| 평균 테스트시간(MTT) | 188.38 ms | 81.87 ms | -56.5% |
| 총 실행 테스트 | 76,361 | 175,330 | x2.30 |
| 에러율 | 0% | 0% | - |
캐시 효과는 분명했습니다.
하지만 요구사항은 “TPS 3배 이상”이었습니다. 측정 결과는 x2.31이므로 목표 미달입니다. 이 미달을 어떻게 다뤘는지는 다음 절에서 봅니다.
왜 x2.31을 그대로 남겼나
목표 미달이 나오면 바로 튜닝하고 싶어집니다.
- 커넥션 풀을 키웁니다.
- Redis 직렬화 방식을 바꿉니다.
- Pod 리소스를 늘립니다.
- JVM 옵션을 조정합니다.
하지만 세션 B의 목적은 캐시 ON/OFF 비교 측정이었고, 튜닝은 범위 밖이었습니다. 세션 중간에 여러 값을 바꾸면 마지막에 x3이 나오더라도 무엇이 그 결과를 만들었는지 알 수 없습니다.
그래서 시작 전에 두 가지 가드레일을 정했습니다.
| 가드레일 | 내용 |
|---|---|
| 1. 클러스터 안에서 디버깅하지 않는다 | 이상이 보여도 GKE 위에서 고치지 않고 로컬 재현/별도 Task로 분리한다 |
| 2. 목표 미달도 유효한 결과로 기록한다 | 세션 중 튜닝하지 않고 나온 숫자를 그대로 남기고, 원인 분석은 별도 작업으로 분리한다 |
이 결정은 비용 때문이기도 합니다. GKE는 띄워 둔 시간만큼 과금됩니다. 하지만 더 중요한 이유는 측정 조건의 일관성입니다.
flowchart LR
A["세션 B 목표<br/>캐시 TPS >= x3"] --> B{"측정 결과<br/>x2.31"}
B -->|"그대로 기록"| C["리포트: x2.31 미달<br/>(튜닝 안 함)"]
B -.->|"세션 내 튜닝"| X["교란변수 증가<br/>비용 증가"]
C --> D["후속 추적 항목<br/>원인 분석은 별도 작업"]
측정과 튜닝을 한 세션에 섞지 않았기 때문에, x2.31은 이후 원인 분석의 깨끗한 baseline으로 남았습니다.
p95/p99는 직접 검증하지 못했다
요구사항의 첫 줄은 “상품 목록 API p99 <= 100 ms”였습니다. 이 값은 세션 B에서 직접 검증하지 못했습니다.
Grafana PeekCart - API & JVM 대시보드의 API Response Time p95/p99 패널이 No data였기 때문입니다.
원인은 측정 시점에 다음 중 하나로 추정했습니다.
- histogram metric 미활성화
- PromQL label 불일치
세션 B에서는 이 문제를 클러스터에서 고치지 않았습니다. 가드레일에 따라 별도 작업으로 분리했습니다. 대신 nGrinder가 제공한 MTT(평균 테스트시간)를 응답시간의 간접 근거로 사용했습니다.
다만 MTT에는 한계가 있습니다.
| 한계 | 의미 |
|---|---|
| 평균이다 | p99처럼 꼬리 지연을 보여 주지 못한다 |
| 전체 시나리오 평균이다 | 목록 80%와 상세 20%가 섞여 있다 |
따라서 세션 B가 직접 증명한 것은 “p99 <= 100 ms”가 아닙니다.
세션 B가 말할 수 있는 것은 다음 정도입니다.
캐시 ON에서 평균 테스트시간이 188.38 ms에서 81.87 ms로 줄었고, TPS도 x2.31 증가했다.
따라서 캐시가 응답시간과 처리량에 긍정적인 영향을 준 정황은 강하다.
이 No data 문제는 이후 관측성 글의 출발점이 됐습니다. 세션 B에서 빈 패널을 발견해 후속 작업으로 분리했고, 그 작업에서 원인이 histogram bucket 설정 누락과 YAML 병합 문제라는 것을 확인했습니다. 관측성 글에서 그 결과를 관측성 계약으로 정리했습니다.
x3 미달 원인은 어디까지 좁혔나
세션 B의 Grafana 스크린샷은 한 가지 단서를 줬습니다.
| 항목 | 값 |
|---|---|
| peekcart Pod CPU 피크 | 약 175% |
| Pod CPU limit | 2 vCPU |
| Memory | 약 640 MiB, 안정 |
| Pod Restarts | 0 |
| 노드 CPU | 스파이크는 있으나 노드 전체 포화는 아님 |
해석은 이렇습니다.
캐시 히트로 DB 왕복은 줄었을 가능성이 높습니다. MTT가 절반 이하로 줄었고 TPS도 크게 올랐기 때문입니다. 하지만 50 VUser 부하에서 앱 Pod CPU가 이미 2코어 limit 근처까지 올라갔습니다.
즉 캐시가 DB I/O를 줄여도, 요청을 처리하는 앱 CPU가 병목이면 처리량은 더 이상 선형으로 늘지 않습니다.
후보는 다음과 같습니다.
- Redis 직렬화/역직렬화 비용
- JSON 응답 직렬화 비용
- 커넥션 풀 대기
- 단일 Pod CPU 한계
세션 B만으로는 이 중 하나를 확정하지 못했습니다. 그래서 결론은 CPU 병목 가설로 남겼습니다.
이 가설은 세션 C(2026-04-29)에서 강하게 보강됐습니다. 단, 세션 C는 동시 주문 워크로드였으므로 상품 조회에서 본 세션 B의 원인을 그대로 확증한 것은 아닙니다.
그래도 세션 C에서 다음 사실이 확인됐습니다.
- 1 pod cold-start 상태에서 CPU가 400% saturation에 도달했습니다.
- HPA가 1 -> 3 pod로 scale-out하자 처리량이 회복됐습니다.
따라서 “단일 Pod 조건에서 CPU saturation이 실제로 발생한다”는 점은 다른 워크로드에서도 재현됐습니다.
flowchart LR
B["세션 B (상품 조회)<br/>Pod CPU ~175%<br/>CPU 병목 가설"] -->|"다른 워크로드로 보강"| C["세션 C (동시 주문)<br/>1 pod CPU 400% saturation<br/>단일 Pod CPU 천장 재현"]
세션 C에서도 2차 병목(DB pool, lock, 연결 안정성)은 분리되지 않은 채 남았고, 이 항목은 동시 주문 부하 테스트 글에서 이어집니다.
측정하지 못한 것
| 항목 | 왜 못 봤나 | 영향 |
|---|---|---|
| p99 직접 검증 | p95/p99 패널 No data | 요구사항 첫 줄을 직접 증명하지 못함 |
| 캐시 hit rate | CacheMeterBinder 미등록 | TPS/MTT로만 간접 추정 |
| x3 미달의 확정 원인 | 세션 중 튜닝/디버깅 금지 | CPU 병목 가설까지만 좁힘 |
| 단일 노드 한계 이후 처리량 | e2-standard-4 x 1, 단일 Pod 조건 | scale-out 이후 성능은 세션 B 범위 밖 |
특히 캐시 hit rate를 보지 못한 점은 아쉽습니다.
실제 hit/miss 메트릭이 있었다면 다음 질문에 더 직접적으로 답할 수 있었습니다.
@CacheEvict(allEntries=true)가 hit rate를 얼마나 깎을까?- 목록/상세 각각의 hit rate는 얼마일까?
disableCachingNullValues()가 기대대로 동작할까?
이 공백은 별도 학습 부채로 남겼고 관측성 글에서도 같은 지점을 짚었습니다.
이 측정이 보장한 것과 못한 것
보장한 것
- 캐시 효과의 정량화: 추정을 x2.31(TPS), -56.5%(MTT)라는 숫자로 바꿨습니다.
- 단일 변수 격리:
NoOpCacheManager로 캐시 외 코드 경로를 최대한 동일하게 유지했습니다. - 재현 가능한 조건 기록: 노드, Pod, 도구, 이미지, 시드, 시나리오를 함께 남겼습니다.
- 정직한 baseline 확보: x3 미달을 튜닝으로 덮지 않고 후속 분석의 기준선으로 남겼습니다.
보장하지 못한 것
앞 절의 ‘측정하지 못한 것’ 표가 그 목록입니다. p99 직접 검증, 캐시 hit rate 가시성, x3 미달 원인의 단정, scale-out 이후 처리량이 여기에 해당합니다. 세션 B는 캐시 효과의 정량화에는 성공했고, 나머지는 후속 작업으로 남겼습니다.
Phase 4 MSA에서는 어떻게 바뀌나
세션 B는 모놀리스 한 덩어리의 처리량을 잰 것입니다. Phase 4에서 서비스가 분리되면 측정 단위도 서비스별로 쪼개집니다.
그대로 가져갈 것
- 단일 변수 실험: 서비스마다
@ConditionalOnProperty와NoOpCacheManager토글을 복제합니다. - 비율 중심 해석: 서비스별 리소스 프로파일이 다르므로 절대 TPS보다 before/after 배율을 봅니다.
- 환경 스펙 동봉: 서비스마다 req/lim이 달라지므로 스펙 기록은 더 중요해집니다.
달라질 것
- 측정 단위가 서비스별로 나뉘고 2차 병목을 더 쉽게 분리한다: Product Service 조회 TPS와 Order Service 주문 TPS를 따로 보고, 모놀리스에서 엉켜 있던 CPU, DB pool, lock contention을 서비스 단위로 나눠 볼 수 있습니다.
- 캐시 hit rate가 필수 지표가 된다: 서비스별 캐시 효과를 비교하려면 TPS뿐 아니라 hit/miss 메트릭이 필요합니다.
Phase 4에 들어가기 전에는 다음 세 가지를 준비해야 합니다.
- 관측성 글에서 정리한 p95/p99 histogram metric을 기본 측정 세트에 포함합니다.
CacheMeterBinder로 캐시 hit/miss를 수집합니다.- HikariCP wait, Redisson lock latency, Pod readiness 전이를 함께 수집해 2차 병목을 분리합니다.
자료는 어떤 질문에 연결해서 읽을까
| 질문 | 같이 읽을 자료 | 이 글에서 연결되는 지점 |
|---|---|---|
@ConditionalOnProperty로 빈을 토글하는 법 | Spring Boot Reference, Condition Annotations | 단일 변수 실험 |
NoOpCacheManager의 동작 | Spring Framework Reference, Cache Abstraction | 캐시 OFF 구현 |
| Cache Aside와 무효화 결정 | 상품 캐시 글 | 캐시 설계 배경 |
| p95/p99 패널이 왜 비었나 | 관측성 글 | No data와 histogram bucket |
| 캐시 hit rate를 메트릭으로 보는 법 | Micrometer CacheMeterBinder 문서 | 측정하지 못한 것 |
| 평균과 p95/p99의 차이 | Google SRE Book, Monitoring Percentiles | MTT 대체의 한계 |
| steady-state와 warm-up | nGrinder 문서 / Google SRE Book Load Testing | 부하 시나리오 |
| 동시 주문/HPA/2차 병목 | 동시 주문 부하 테스트 글 | x3 미달 분석 보강 |
세션 B의 결론은 다음과 같습니다.
캐시 효과는 있었다. 평균 TPS는 x2.31 증가했다.
하지만 목표 x3에는 못 미쳤고, 그 미달을 숨기지 않았기 때문에 다음 실험의 기준선이 생겼다.