TL;DR
이 글의 핵심은 '더 좋은 evict 패턴을 찾는 것'이 아니다.
페이지 DTO를 통째로 캐싱하는 구조에서는, 어떤 evict를 붙여도 고빈도 write 앞에서 결국 무너질 수밖에 없다.
그래서 답도 eviction 최적화가 아니다.
캐시를 2계층(ID 리스트 + 상품 상세)으로 나누고, evict 대신 write-through + TTL 안전망으로 바꾸는 것이 근본 해법이었다.
이커머스 프로젝트에서 '조회' 로직의 성능 개선을 위해 캐시를 도입하고 있는데, 브랜드명 수정 로직을 보다가 누락된 지점을 하나 발견했다. 분명 '상품의 브랜드명을 수정'했는데, '상품 상세 캐시'는 그대로 남아 있는 것이다.
처음에는 'brandId 에 해당하는 상품 캐시만 찾아서 지우면 되는 거 아닌가?' 라고 정말 단순하게 생각했다.
그런데 캐시 키 구조를 뜯어보는 순간, 생각보다 훨씬 구조적인 문제라는 걸 알게 됐다.
'좋아요 기능' 같이 아주 간단한 수정 한 번에도 'evictByPattern("products:list:*")'이 실행되고 있었고, 이렇게 한번에 모두 제거된 캐시때문에 강제로 캐시 스탬피드를 일으키고 있었다.
이번 글에서는 왜 이 구조가 위험했는지, 어떤 선택지를 검토했는지, 그리고 왜 최종적으로 캐시 구조 자체를 바꾸게 됐는지 정리해보려고 한다.
문제 상황
처음 눈에 띈 문제는 브랜드명 수정 시 캐시 무효화 누락이었다.
하지만 조금만 더 따라가 보니, 실제로는 eviction 하나가 아니라 캐시 전략 전체를 다시 봐야 하는 상황이었다.
- 브랜드명 수정 시 상품 상세 캐시가 갱신되지 않았다.
- 좋아요, 재고, 상품 수정 때마다
evictByPattern("products:list:*")이 돌고 있었다. - 목록 캐시는 페이지 단위 DTO 전체를 들고 있었다.
- 상품 하나만 바뀌어도 페이지 전체가 '낡은 데이터'가 되었다.
- eviction 한 번마다 Redis
SCAN기반 패턴 삭제 비용도 따라왔다.
영향은 명확했다. 캐시는 히트율을 높이는 장치가 아니라, 오히려 고빈도 write마다 전체 캐시를 초기화하는 장치처럼 동작하고 있었다.
특히 좋아요는 사용자 행동이라 빈도가 높았고, 결국 좋아요를 누를 때마다 목록 캐시를 전부 날리는 구조가 되어버렸다.
이쯤 되면 자연스럽게 이런 질문이 나온다.
'정말 이 캐시는 시스템을 빠르게 만들고 있었을까?'
분석
1. 수정된 엔티티의 캐시만 정확히 지울 수 없었다
브랜드명 수정 버그를 처음 봤을 때는, 'brandId=5에 해당하는 상품만 찾아서 상세 캐시를 지우면 되겠지.' 라고 생각했다.
문제는 Redis 키가 그렇게 생기지 않았다는 점이었다.
키에는 productId만 있고, brandId는 value 안에만 들어 있었다. 그러면 Redis 입장에서는 이런 식의 역탐색이 불가능하다.
product:1 -> { id:1, brandId:5, brandName:"나이키", ... }
product:2 -> { id:2, brandId:5, brandName:"나이키", ... }
product:3 -> { id:3, brandId:7, brandName:"아디다스", ... }
FIND_KEYS_WHERE value.brandId == 5
결국 선택지는 셋 중 하나였다.
- 전체
product:*를 뒤져서 역직렬화하며 찾는다. - 그냥
product:*를 전부 지운다. - 별도 역인덱스를 유지한다.
셋 다 마음에 들지 않았다. 첫 번째는 O(N) 풀스캔이고, 두 번째는 영향 범위가 너무 크다. 세 번째는 캐시 메타데이터라는 관리포인트가 늘어난다.
2. 목록 캐시는 더 골치 아팠다
상세 캐시는 그래도 "하나의 상품 = 하나의 키"라서 문제를 설명하기 쉽다. 하지만 목록 캐시는 그렇지 않았다.
products:list:all:LATEST:0:20
products:list:all:LIKES_DESC:0:20
products:list:5:LATEST:0:20
얼핏 보면 brandId=5 필터 목록만 지우면 될 것처럼 보인다.
그런데 all 목록 안에도 해당 브랜드 상품이 섞여 있다. 여기서부터 이미 타겟 eviction이 애매해진다.
게다가 정렬과 페이지네이션이 들어오면 문제는 더 커진다.
예를 들어 LIKES_DESC 정렬에서 어떤 상품의 좋아요 수가 크게 올라가면, 바뀌는 건 그 상품 한 개가 아니다.
[변경 전]
page 0: A(100), B(80), C(60)
page 1: D(50), E(40), F(30)
[상품 E 좋아요 +70]
page 0: E(110), A(100), B(80)
page 1: C(60), D(50), F(30)
상품 E만 바뀌었는데, page 0과 page 1이 동시에 흔들린다.
즉, 키에 아무리 많은 필드를 넣어도 "어느 페이지가 영향을 받는지" 를 사전에 정확히 찍어내기 어렵다.
여기서 처음으로 감이 왔다. 문제는 eviction 코드 한 줄이 아니었다. 목록 캐시를 한 덩어리 DTO로 들고 있는 구조 자체가 더 큰 문제처럼 보였다.
3. 판단 기준을 다시 세웠다
이 시점부터는 질문이 바뀌었다. "어떤 기술을 쓸까?"보다 "이 캐시가 실제로 이득을 주고 있는가?"를 먼저 보기 시작했다.
판단 기준은 세 가지로 정리했다.
| 기준 | 질문 |
| 무효화 빈도 | 이 캐시가 '쓰기 작업'에 의해 얼마나 자주 영향을 받는가? |
| DB 조회 비용 | cache miss가 나면 DB 비용이 정말 큰가? |
| 영향 범위 | 한 번 무효화할 때 몇 개의 키가 함께 영향을 받는가? |
이걸 현재 구조에 대입해보면 차이가 선명했다.
| 대상 | 무효화 빈도 | DB 조회 비용 | 영향 범위 |
| 상품 상세 캐시 | 낮음 | 중간 | 1개 키 |
| 상품 목록 캐시 | 매우 높음 | 낮음 | 전체 키 |
상품 상세는 해당 상품이 바뀔 때만 캐시를 건드리면 되니까 무효화 빈도가 낮고, 영향 범위도 1개 키로 좁다.
반대로 상품 목록은 아무 상품의 좋아요나 재고가 바뀌어도 전체 목록 캐시에 영향이 번지기 쉬워서, 세 기준 모두에서 불리했다.
전략 수립
처음에는 "그냥 eviction 방식만 더 똑똑하게 바꾸면 되지 않을까?" 라고 생각했다. 그런데 검토해볼수록 각각 한계가 분명했다.
전략 1. 키에 식별 정보를 더 넣어서 타겟 eviction 하기
단건 조회 캐시라면 꽤 괜찮은 방식이다.
예를 들어 user:{id}:profile 같은 키는 수정 시 대상을 정확히 찍어서 지울 수 있다.
하지만 상품 목록은 정렬 + 페이지네이션이 들어간다. 좋아요 수가 변하면 페이지 경계가 밀리고, 가격이 바뀌면 PRICE_ASC 순서가 다시 섞이고, 생성 시각이 바뀌면 LATEST도 영향을 받는다. 결국 어떤 페이지가 변하는지 사전에 정확히 알 수 없다.
즉, 이 전략은 정렬된 목록 캐시에는 근본적으로 맞지 않았다.
전략 2. 전체 eviction을 유지하되 스탬피드 방어를 믿기
이 접근도 한동안 솔깃했다. 이미 LocalCacheLock과 PER 같은 장치가 있었으니, 전체 eviction이 나도 어느 정도 막아주지 않을까 싶었다.
하지만 여기에는 중요한 맹점이 있었다. LocalCacheLock이 막아주는 것은 같은 키에 대한 중복 조회지, 서로 다른 키가 한꺼번에 miss 나는 상황까지 막아주지는 못한다.
예를 들어 products:list:all:LATEST:0:20 하나에 요청 100개가 몰리면 한 요청만 DB에 가고 나머지는 대기할 수 있다.
그런데 전체 eviction 직후에는 상황이 이렇게 바뀐다.
products:list:all:LATEST:0:20
products:list:all:LATEST:1:20
products:list:all:LIKES_DESC:0:20
products:list:5:LATEST:0:20
...
키마다 한 번씩 DB를 치면 결과는 뻔하다. "키 하나당 1쿼리"로 줄어들 뿐이고, 전체 eviction 자체가 만드는 부하는 그대로 남는다.
좋아요가 초당 여러 번 들어오는 구조라면 상황은 더 나빠진다.
TTL이 5분이든 10분이든 의미가 거의 없고, 캐시는 다시 채워질 틈이 없다.
전략 3. 목록 캐시를 아예 제거하기
흥미롭게도, 성능 테스트 결과에 따르면 이 전략은 꽤 합리적이다.
이유는 단순하다. Read Model + 복합 인덱스를 적용한 뒤 목록 조회 쿼리가 이미 굉장히 빨라졌기 때문이다.
1M 데이터 기준으로 DB 쿼리가 0.85~2.44ms 수준까지 내려왔고, 단건 응답만 보면 Redis hit보다 DB가 더 빠른 구간도 있었다.
그래서 처음에는 "이 정도면 목록 캐시 자체가 필요 없는 것 아닌가?"라고 판단했다.
여기까지는 맞는 분석처럼 보였다. 그런데 하나를 놓치고 있었다. 바로 트래픽 볼륨이다.
PLP(Product List Page)는 사용자가 가장 자주 들어오는 진입점이다. 보통 커머스에서는 PLP 트래픽이 PDP보다 훨씬 많다.
즉, 단건 쿼리가 빠르다는 사실만으로 목록 캐시를 없애면 안 된다.
이 구간에서 캐시의 진짜 역할은 '한 건을 더 빠르게 응답하는 것'보다 'DB로 가는 대량 트래픽을 흡수'하는 데 있었다. 그래서 전략 3은 한 번 선택했다가 다시 제외했다.
전략 4. 역인덱스를 직접 관리하거나 ES로 간다
정밀한 eviction만 생각하면 이 방향은 꽤 매력적이다.
brand:5:products -> [1, 2, 9999]
브랜드명이 바뀌면 brand:5:products를 읽어서 해당 상품 캐시만 골라서 지울 수 있다.
문제는 그 다음부터다.
상품 생성, 삭제, 브랜드 변경, 정렬 조건 변화가 있을 때마다 역인덱스를 함께 맞춰야 한다. 결국 캐시 때문에 관리 포인트가 훨씬 많아진다.
ES도 비슷했다. 물론 역인덱스 기반 검색엔진이라 복잡한 검색 요구사항이 있으면 매우 좋은 선택이 될 수 있다.
하지만 지금 프로젝트는 브랜드 필터 + 정렬 정도의 요구사항이었다.
이 상황에서 ES는 캐시 문제를 해결한다기보다, 문제를 다른 형태의 동기화 문제로 바꾸는 쪽에 더 가까웠다.
전략 5. TTL만 믿고 eviction을 없앤다
이 전략 또한 의외로 꽤 괜찮았다. 좋아요 수, 브랜드명, 상품명은 몇 분 정도 늦게 반영 되어도 UX에 치명적인 수준은 아니기 때문이다.
실시간 정합성이 아주 중요하지 않다면 TTL만으로도 충분히 운영 가능한 경우가 많다.
다만 이번 케이스에서는 이것만으로는 조금 아쉬웠다.
목록 캐시를 유지하면서도 write 직후 최신 상태를 더 안정적으로 반영하고 싶었다. 그래서 TTL을 버리지는 않았고, 주 전략이 아니라 안전망으로 쓰기로 했다.
여기까지 정리하고 나니 질문이 훨씬 선명해졌다.
"결국 우리가 바꿔야 하는 건 eviction 방식일까, 아니면 캐시 구조 자체일까?"
원인
원인은 '캐시 키 설계와 무효화 범위가 서로 맞지 않았기 때문' 이었다.
더 정확히 말하면, 1계층의 페이지 DTO 캐시 구조와, 개별 상품 변경을 정확히 반영하려는 요구가 충돌하고 있었다.
목록 캐시는 페이지 DTO를 통째로 캐싱하고 있었다. 그 상태에서 개별 상품의 변경을 정밀하게 반영하려고 하니, 상품 하나가 여러 페이지를 동시에 낡은 데이터로 만드는 문제가 발생했다.
그러면 결국 선택지는 둘 중 하나로 좁혀진다.
- 너무 많은 키를 한 번에 지우거나
- 최신이 아닌 낡은 데이터를 한동안 감수하거나
그리고 현재 구현은 첫 번째를 택하고 있었다. evictByPattern("products:list:*")은 구현은 단순하지만, 사실상 "목록 캐시 전체 초기화"에 가까웠다.
고빈도 write가 들어오는 순간 이 구조는 곧바로 문제를 드러낸다. 캐시 히트율은 떨어지고, 서로 다른 키가 한꺼번에 miss 나면서 DB 부하가 증가하고, Redis는 매번 SCAN 비용을 감당해야 했다.
즉, 캐시는 시스템을 안정화하는 장치가 아니었다. 오히려 전체 시스템을 흔드는 트리거가 되어버린 것이다.
해결
결론부터 말하면 eviction 로직만 고친 게 아니다. 캐시 구조 자체를 2계층으로 분리했다.
핵심은 단순하다. 자주 변하는 데이터와 덜 변하는 데이터를 같은 키 안에 넣지 않는 것이다.
1. 1계층 DTO 캐시를 버리고 2계층으로 분리
AS-IS는 이랬다.
products:list:all:LATEST:0:20
-> [상품A 전체 DTO, 상품B 전체 DTO, 상품C 전체 DTO, ...]
이 구조에서는 상품 A의 좋아요 수가 바뀌는 순간, 이 키 전체가 낡은 데이터가 된다.
그래서 TO-BE를 다음처럼 설계했다.
Layer 1: ID 리스트 캐시
products:ids:v1:{brandId|all}:{sort}:{page}:{size}
-> { ids: [1, 5, 12, ...], totalElements: 523 }
Layer 2: 상품 상세 캐시
product:v1:{productId}
-> ProductCacheDto
이제 목록 캐시는 상품 전체 DTO가 아니라 ID 리스트만 들고 있다. 좋아요 수, 브랜드명, 재고 같은 값이 바뀌어도 Layer 1은 훨씬 덜 흔들린다.
반대로 자주 바뀌는 실제 상품 데이터는 Layer 2로 내렸다. 여기서는 상품 단위로 정확히 갱신하면 된다.
2. 읽기 경로는 cache-aside + MGET으로 정리
읽기 흐름은 다음처럼 'cache-aside' 방식으로 가져갔다. 즉, 캐시에 없으면 DB에서 조회한 뒤 캐시에 올리는 방식이다.
- Layer 1에서 ID 리스트 캐시를 조회한다.
- ID를 기준으로 Layer 2 상세 캐시를
MGET한다. - 일부 상품에 cache miss가 발생하면, 그 상품만 DB에서 조회한다.
- miss 난 상세 캐시를 적재한다.
- 원래 ID 순서에 맞춰 응답을 조합한다.
이 방식의 장점은 명확하다. PLP와 PDP가 같은 상세 캐시를 공유할 수 있어서, 목록에서 본 상품을 상세로 들어갔을 때도 캐시 재사용이 가능해진다.
3. 쓰기 경로는 eviction 대신 write-through
여기서 가장 크게 바뀐 지점이 있다. 이전에는 write가 발생하면 "일단 지운다"였다면, 이제는 "해당 캐시를 최신 값으로 갱신한다"로 바꿨다.
즉, evictByPattern을 제거하고, DB를 갱신한 직후 캐시도 바로 같은 값으로 갱신하는 'write-through'를 기본 전략으로 정했다. 이렇게 하면 캐시가 비워지지 않고 계속 채워진 상태를 유지할 수 있기 때문이다.
좋아요 한 건이 발생했을 때도 아래 정도면 충분하다.
- 해당 상품 상세 캐시 1개를 갱신한다.
- 영향 받는 ID 리스트 캐시만 선별적으로 갱신한다.
전체 목록 캐시를 전부 날릴 필요가 없다.
4. TTL을 최신 데이터로 갱신하는 수단이 아니라 안전망으로 사용
이 부분은 꽤 중요했다. 보통 TTL을 "몇 분 안에 최신 데이터가 반영되게 하는 장치"처럼 생각하기 쉽다.
하지만 이번 설계에서는 전제가 다르다. write-through가 정상적으로 동작하는 한 캐시는 이미 최신 상태다.
즉, TTL의 역할은 항상 최신화를 유지하는 것 보다는, 'write-through'가 실패했을 때 '이전 데이터'가 얼마나 오래 남을지 제한하는 안전망이다.
그래서 TTL을 다음처럼 설정했다.
| 캐시 | TTL | 이유 |
| ID 리스트 (PLP) | 3분 | 어느정도 이전 데이터를 허용할 수 있고, write-through가 주 메커니즘이기 때문 |
| 상품 상세 (PDP) | 2분 | 구매 결정 단계라 조금 더 짧은 안전망이 필요하기 때문 |
짧은 TTL이 항상 좋은 것도 아니었다. write-through가 이미 최신 데이터를 보장하고 있는데 TTL만 더 줄이면, '조회가 적은' 상품에서 cache miss만 늘고 DB 부하가 불필요하게 올라간다.
5. 스키마 버저닝
캐시 키에는 v1을 포함시켰다.
product:v1:{productId}
products:ids:v1:{brandId|all}:{sort}:{page}:{size}
이건 eviction 전략과는 다른 층위의 문제다. 배포 시 DTO 구조가 바뀌면 기존 캐시를 그대로 읽다가 역직렬화 실패나 필드 누락이 생길 수 있다.
그래서 스키마 버저닝은 운영 안정성을 위한 기본 장치로 넣었다.
6. 변경 작업마다 갱신 대상을 다시 매핑했다
write-through를 넣기로 했으면, 어떤 write가 어떤 캐시를 흔드는지 다시 정리해야 한다.
| 변경 작업 | 상세 캐시 | ID 리스트 캐시 |
| 좋아요 증가/감소 | 해당 상품 1개 갱신 | LIKES_DESC 기준 hot page 갱신 |
| 재고 차감 | 해당 상품 1개 갱신 | 없음 |
| 가격 수정 | 해당 상품 1개 갱신 | PRICE_ASC 기준 hot page 갱신 |
| 상품 생성/삭제 | 신규/삭제 상품 갱신 또는 제거 | 모든 정렬의 hot page 갱신 |
| 브랜드명 수정 | 해당 브랜드 상품 상세 일괄 갱신 | 없음 |
브랜드명 수정은 특히 의미가 컸다. 기존에는 상세 캐시 무효화가 누락되던 버그였는데, 이제는 해당 brandId 상품 목록을 읽어 상세 캐시를 전부 write-through 하면 된다.
즉, '찾을 수 없어서 못 지우던 문제'를 '알고 있는 ID 목록으로 최신 값을 다시 써주는 방식'으로 바꾼 셈이다.
중요했던 건 '무조건 최신'보다 '무해한 불일치'였다
이 설계에서는 완벽한 실시간 정합성보다, 비즈니스적으로 무해한 불일치와 치명적인 불일치를 먼저 구분했다.
- 좋아요 수 1~2개 차이는 사용자가 거의 인지하지 못한다.
- 품절 검증은 장바구니/주문 단계에서 서버가 다시 막아줄 수 있다.
- 삭제된 상품 노출은 짧은 시간 동안만 남고, TTL이나 다음 갱신으로 복구된다.
즉, 모든 값을 실시간으로 강박적으로 맞추지 않았다. 어디까지를 캐시가 감당해도 되는지를 먼저 구분한 뒤 설계를 골랐다.
검증
이제 중요한 건 '그래서 실제로 좋아졌느냐'다. 이번 설계가 감으로만 끝나는 것이 아니라, 정말로 효과가 있는지를 검증해보자.
검증은 다음 세 개의 섹션으로 나누어서 성능측정을 진행했다.
- 'AS-IS' API
- Read Model + 복합 인덱스만 적용한 DB 직접 조회
- 2계층 캐시 적용 후 API의 MISS/HIT 경로
이렇게 나눠봐야 DB가 빨라진 효과와 캐시가 추가로 준 효과를 섞지 않고 볼 수 있다.
또한 측정 데이터는 10만 / 100만 / 1000만 건 기준으로 진행했고, 트래픽 종류에 따라서도 분리해서 측정을 진행했다.
- 단건 응답: warmup 3회 후 5회 평균
- 동시 요청: 100개 동시 요청
- 지속 부하: 20 RPS x 10초, 즉 총 200 요청 기준
1. Read Model + 복합 인덱스 적용
먼저 Read Model + 복합 인덱스를 적용한 뒤 목록 쿼리 성능은 크게 개선되었고, DB가 cache miss를 감당할 만큼 빨라졌다.
다만 AS-IS 구간의 목록 조회가 모두 동일한 쿼리를 비교한 것은 아니다.
특히 LIKES_DESC는 이전 구조에서 likes 테이블 LEFT JOIN, GROUP BY, COUNT까지 들어가 더 무거웠다. 그래서 아래 수치는 단순히 정렬만 바뀐 정도가 아니라, 쿼리 구조 차이까지 함께 반영된 결과로 보는 편이 맞다.
| 데이터 규모 | AS-IS DB 쿼리 (ms) | 인덱스 적용 후 DB 직접 조회 (ms) |
| 10만건 | 20~33 | 0.94~6.96 |
| 100만건 | 408~585 | 0.85~2.44 |
| 1000만건 | 3,489~4,184 | 2.29~8.20 |
이 결과는 의미가 분명하다.
이제 목록 조회가 cache miss가 나더라도, DB로 바로 내려가 감당할 수 있는 기반이 생긴다. 즉, 캐시를 반드시 모든 요청의 생명줄처럼 붙잡고 있을 필요는 없어진다.
다만 여기서 바로 "그럼 캐시를 빼도 되겠다"로 넘어가면 안 된다.
그건 DB 직접 조회 성능만 본 결론이고, 실제 서비스 요청 전체를 본 결론은 아니기 때문이다.
2. Cache Miss시에 DB 조회 비용
Cache Miss시 DB 조회 비용을 확인하기 위해, warmup과 측정 직전 모두 Redis를 비워서, 실제로 캐시가 비어 있는 상태의 첫 요청만 다시 잡는다.
이 값이 중요한 이유는 분명하다.
지금 구조에서 miss는 예외 상황이 아니라, 캐시에 값이 없을 때 실제로 서비스가 타게 되는 fallback 경로이기 때문이다.
즉, 캐시를 없애면 일부 요청만 이 경로를 타는 게 아니라, 사실상 모든 요청이 다음 비용을 계속 부담하게된다.
| 데이터 규모 | 브랜드 필터 없는 목록 miss (ms) | 브랜드 필터 있는 목록 miss (ms) | 상세 miss (ms) |
| 10만건 | 32.85~54.95 | 21.50~25.41 | 9.50 |
| 100만건 | 122.44~334.30 | 20.77~26.85 | 6.41 |
| 1000만건 | 1,160.68~3,884.57 | 44.58~97.49 | 6.32 |
여기서 봐야 하는 건 "miss도 버틸 만한가"와 "그 비용을 주 경로로 써도 되는가"는 다른 질문이라는 점이다.
예를 들어 브랜드 필터가 없는 PRICE_ASC 목록은 1000만건에서 3.88초까지 올라간다.
이 정도면 단 한번의 fallback 경로로는 감당할 수 있더라도, 지속적으로 PLP의 주 경로로 쓰기에는 부담이 크다.
그래서 'Read Model + 복합 인덱스' 덕분에 miss fallback은 감당 가능해졌지만, PLP의 주 경로를 전부 DB 직접 조회로 돌릴 만큼 충분히 싸진 것은 아니라는 것이다.
즉, '인덱스를 썼으니 캐시를 빼도 된다'가 아니라, '인덱스를 썼기 때문에 miss fallback은 버틸 수 있고, 그래도 주 경로는 캐시가 맡아야 한다'가 더 정확한 결론이다.
3. Cache Hit 응답 성능
반대로 캐시가 채워진 뒤에는 양상이 완전히 달라졌다. 이 구간은 데이터 규모보다 Redis 조회와 직렬화 비용에 더 가까운 형태로 수렴했다.
| 데이터 규모 | 목록 Hit | 상세 Hit |
| 10만건 | 5.76~7.65ms | 4.98ms |
| 100만건 | 4.66~7.39ms | 4.77ms |
| 1000만건 | 4.53~6.42ms | 3.85ms |
지속 부하도 같은 방향을 보였다.
| 데이터 규모 | 실제 QPS | 평균 응답 시간 | 에러율 |
| 10만건 | 20.0 | 6.16~9.13ms | 0% |
| 100만건 | 20.0 | 6.07~8.34ms | 0% |
| 1000만건 | 20.0 | 5.91~6.62ms | 0% |
즉, 지금 캐시의 핵심 가치는 "캐시가 비어 있는 상태를 무조건 빠르게 만든다"가 아니다. 캐시가 충분히 채워진 운영 상태를 데이터 규모와 거의 분리해준다는 점이 더 중요했다. 실제로 운영에서는 이 구간이 주 경로가 되기 때문에, 이 차이가 훨씬 크게 느껴졌다.
4. 같은 키에 요청이 몰릴 때 miss 버스트 성능 (동시 요청 100개)
실제 운영에서는 hit만 잘 나오는 상황만 보는 것으로는 부족했다.
첫 요청이나 TTL 만료 직후처럼 캐시가 비어 있는 순간에도, 같은 키로 요청이 한꺼번에 몰릴 수 있다. 그래서 이 구간에서는 같은 키에 동시 요청이 몰릴 때 miss 경로가 얼마나 버티는지를 따로 확인했다.
| 데이터 규모 | miss 버스트 평균 응답 | 에러율 |
| 10만건 | 225.83 ms | 0% |
| 100만건 | 216.68 ms | 0% |
| 1000만건 | 1,145.78 ms | 0% |
절대 응답 시간만 보면 당연히 hit 보다 훨씬 느리다.
하지만 중요한 건 1000만건에서도 에러율이 0%였다는 점이다. AS-IS는 같은 조건에서 90%가 실패했고, 성공한 요청도 30~37초가 걸렸다.
그 상태와 비교하면 완전히 다른 그림이었다.
이 차이는 같은 키로 몰린 요청을 한 번에 DB로 보내지 않았기 때문이다.
LocalCacheLock + double-check 조합으로 1개 요청만 먼저 DB에 가고, 나머지는 기다렸다가 같은 결과를 재사용하도록 만들었다.
즉, 이 측정은 "miss가 느릴 수 있다"보다 "miss가 나도 한 번에 무너지지는 않는다"는 점을 보여준다.
5. DB 직접 조회와 cache hit 응답 시간 비교 (단건 응답)
여기서 한 가지 흥미로운 점도 보였다.
2계층 캐시 적용 후 cache hit 응답은 대략 4~6ms 수준이었는데, 순수 조회 시간만 놓고 보면 어떤 구간에서는 DB 직접 조회가 더 빨랐다.
예를 들어 100만건 기준 목록 DB 직접 조회는 0.85~2.44ms였고, API cache hit는 4.66~7.39ms였다.
처음에는 캐시인데 왜 DB보다 느리지 싶었다.
하지만 이 둘은 같은 레벨의 측정이 아니다.
하나는 순수 DB 조회 시간이고, 다른 하나는 Controller부터 직렬화까지 포함한 API 응답 시간이기 때문이다.
숫자만 가로로 놓고 "캐시가 더 느리다"라고 결론 내리면 안 되는 이유다.
결국 여기서 다시 보게 된 건 시스템 전체 관점이었다.
PLP는 트래픽이 집중되는 진입점이고, 이 구간에서 캐시의 핵심 가치는 1건 응답 시간을 극적으로 줄이는 것보다 DB로 가는 요청량을 흡수하는 데 있었다.
즉, 캐시는 응답 시간만으로 판단할 수 없다.
처리량과 DB를 얼마나 보호해주는지까지 같이 봐야 했다.
6. 10만 / 100만 기준 최종 API 성능 비교 (단건 응답 / 동시 요청 / 지속 부하)
앞선 측정을 한 번에 모아보면 결론은 더 선명해졌다.
DB 직접 조회가 빨라진 덕분에 miss fallback은 감당 가능해졌고, cache hit 경로가 주 경로를 맡으면서 전체 응답 시간과 에러율이 안정됐다.
[10만 데이터 기준 종합 비교 표 (단건 응답 + 동시 요청 + 지속 부하)]
| 지표 | AS-IS | Cache Hit |
| 단건 목록 응답 | 482~516ms | 4.66~7.39ms |
| 동시 요청 에러율 | 71~80% | 0% |
| 지속 부하 QPS | 3.7~5.9 | 20.0 |
| 지속 부하 에러율 | 37~60% | 0% |
[100만 데이터 기준 종합 비교 표 (단건 응답 + 동시 요청 + 지속 부하)]
| 지표 | AS-IS | Cache Hit |
| 단건 목록 응답 | 6,174~11,604ms | 4.53~6.42ms |
| 동시 요청 에러율 | 90% | 0% |
| 지속 부하 QPS | 0.3~0.5 | 20.0 |
| 지속 부하 에러율 | 90~95% | 0% |
이 표가 보여주는 건 단순히 "더 빨라졌다"가 아니다.
데이터 규모가 커질수록 기존 구조는 쉽게 무너지지만, 바꾼 구조는 데이터 규모가 커져도 응답 시간과 에러율을 비교적 안정적으로 붙잡았다.
체감상 가장 의미 있었던 건 두 가지였다.
- 첫째, 자주 반복되는 조회 경로가 데이터 규모와 거의 분리됐다.
- 둘째, 고빈도 write와 동시 요청이 몰려와도 시스템이 예측 가능하게 움직이기 시작했다.
7. AS-IS / TO-BE evict 전략 비교
이번 변화는 성능 수치만 좋아진 게 아니었다. 캐시를 어떻게 갱신하느냐도 함께 바뀌면서, 구조적인 위험도 같이 줄었다.
즉, 단순히 응답 시간이 짧아진 것이 아니라, 캐시가 시스템을 흔드는 방식 자체가 달라졌다.
| 항목 | AS-IS | TO-BE |
| 좋아요 1건당 영향 | 전체 목록 캐시 삭제 | 상세 1개 + 관련 ID 리스트 갱신 |
| 브랜드명 수정 기능에서 evict 패턴 | 상세 캐시 무효화 누락 | 해당 브랜드 상품 상세 일괄 write-through |
| 캐시 스탬피드 방어 | eviction 직후 다수 키 miss | 캐시를 계속 채운 상태로 유지해, 사시상 제거 |
| Redis SCAN 비용 | 존재 | 제거 |
| 자주 사용되는 캐시값 공유 | 불가 | 가능 |
이 변화는 단순히 "더 빨라졌다"보다 더 중요했다.
AS-IS에서는 write가 들어올 때마다 캐시가 시스템을 흔드는 쪽에 가까웠다.
반면 TO-BE에서는 캐시가 예측 가능한 방식으로 최신성을 유지하고, DB로 가는 요청량도 안정적으로 줄여줄 수 있게 되었다.
정리
이번에 정리하고 보니 문제는 Redis 자체도 아니었고, eviction 코드 한 줄도 아니었다. 핵심은 '전체 DTO를 캐싱한 목록 구조'와 '고빈도 write 환경'이 서로 맞지 않았다는 점이었다.
그래서 해결도 eviction 최적화가 아니었다. 캐시를 어떤 단위로 나눌지 다시 설계하는 것에서 답이 나왔다.
목록은 ID 리스트로, 상세는 상품 단위로 쪼개고, evict 대신 write-through + TTL 안전망으로 가져가자, 구조 전체가 훨씬 안정적으로 바뀌었다.
결국 이번 선택을 한 줄로 줄이면 다음과 같다.
"최선의 evict 패턴은, 가장 정교한 evict가 아니라 evict가 필요 없는 설계다."