티스토리 뷰
상품 목록 페이지에 트래픽이 몰리는 상황을 생각해봤다. 수백 명의 사용자가 동시에 같은 상품 목록을 요청하는데, 매 요청마다 DB에 쿼리를 날리면 어떻게 될까?
데이터베이스는 디스크 I/O 기반으로 동작하기 때문에 동시 요청이 급증하면 응답 지연이 발생하고, 심한 경우 장애로 이어질 수 있다. 상품 정보는 자주 바뀌지 않는다. 수백 번의 요청이 들어와도 매번 동일한 데이터를 DB에서 다시 읽어온다.
이 문제를 해결하는 것이 캐싱이다. 자주 조회되는 데이터를 메모리에 임시 저장해두고, DB를 거치지 않고 빠르게 응답할 수 있게 한다. 대표적인 구현체로 Redis가 있다.
캐싱은 캐시 저장소에 데이터만 저장하면 끝인 줄 알았다. 그런데 실제로 적용해보니 Race Condition, Cache Stampede 같은 문제들을 하나씩 마주하게 됐다. 이 글에서는 상품 단건·다건 조회 시나리오를 중심으로 캐싱 적용 시 발생하는 문제들을 살펴보고 이에 따른 해결 방법을 정리한다.
단건 조회
Cache-Aside
캐시 저장소에서 상품 키로 데이터를 조회하고, 캐싱된 데이터가 없으면 DB에서 조회한다. 상품 정보가 수정되거나 삭제되면 캐시 저장소에서 캐시를 삭제한다. 가장 일반적인 방법이고 Cache-Aside 전략이라고 한다.
sequenceDiagram
participant C as 클라이언트
participant R as Redis
participant DB as DB
C->>R: 캐시 조회 (product:{id})
alt 캐시 히트
R-->>C: Product 반환
else 캐시 미스
R-->>C: nil
C->>DB: 상품 조회
DB-->>C: Product
C->>R: 캐시 저장 (product:{id})
C-->>C: 반환
end
Product product = productCacheRepository.get(productId)
.orElseGet(() -> {
Product origin = productRepository.findByIdAndDeletedAtIsNull(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
productCacheRepository.put(productId, origin);
return origin;
});
Race Condition
여기서 끝이면 좋겠지만, 한 가지 마음에 걸리는 부분이 있다. 상품 정보가 수정되면 캐시에 있는 예전 데이터를 삭제해야 한다. 삭제해야 다음 조회 시점에 Cache Miss가 발생하고 DB에서 새로운 데이터를 가져오니까. 그런데 이 삭제 타이밍에 Race Condition이 발생할 수 있다. 캐시에서 왠 Race Condition이냐고? 아래 그림을 살펴보자.

B 트랜잭션에서 A 트랜잭션이 캐시 제거 후 커밋 전 시점에 DB에서 예전 데이터를 조회해서 다시 캐싱하면 캐시 저장소에는 옛날 데이터가 남는 문제가 발생한다.
TransactionalEventListener
문제의 원인은 "커밋 전에 캐시를 삭제한다"는 점이다. 그렇다면 커밋이 완료된 후에 캐시를 삭제하면 되지 않을까? Spring에서는 @TransactionalEventListener를 통해 트랜잭션의 특정 시점에 작업을 수행할 수 있게 한다.

// ProductService.java
product.changeInfo(brandId, command.name(), command.price(), command.stock());
eventPublisher.publishEvent(new ProductModifiedEvent(productId)); // 이벤트 발행
// ProductCacheEventHandler.java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleProductModified(ProductModifiedEvent event) {
productCacheRepository.evictProduct(event.productId()); // 커밋 후 캐시 삭제
}
상품 수정 시 ProductModifiedEvent를 발행하고, ProductCacheEventHandler가 커밋이 완료된 시점에(AFTER_COMMIT) 캐시를 삭제하도록 구현했다.
phase는 TransactionalEventListener가 트랜잭션의 어느 단계에서 실행될지 지정하는 옵션이다. "단계, 국면"이라는 뜻을 가진다. 프로젝트 단계 처럼 하나의 과정에서 구분되는 시점을 의미한다. 자세한 내용은 https://snvlqkq.tistory.com/56 를 살펴보자.
AFTER_COMMIT 실패 시
그런데 트랜잭션 commit 이후 캐시 삭제가 실패하는 케이스도 존재할 수 있다. 이 문제는 TTL로 방어해야 한다. 일시적 데이터 불일치가 발생할 수 있지만 TTL을 너무 길게 가져가지 않으면, 불일치 현상은 잠시일 뿐이다. 나는 데이터를 캐싱한다는 것 자체가 완전 무결성이 필요한 데이터가 아니라는 뜻이라고 생각한다.
단건 캐시의 TTL은 5분으로 설정되어 있다. 이벤트 핸들러가 실패하더라도 최대 5분 후에는 stale 데이터가 자연 만료된다.
Spring Cache의 한계
Race Condition을 방지하기 위해 코드가 조금 복잡해졌다. 여기서 한 가지 의문이 들었다. 캐시를 간단하게 적용하기 위한 라이브러리인 Spring Cache는 이 문제를 어떻게 방지할까?
Spring Cache는 편의성에 목적을 둔 추상화라서 Race Condition과 같은 문제는 감수하고 있다. TTL에 의존한다. 정합성이 중요한 데이터는 직접 Redis를 다루고 일반적으로는 TTL이 만료되면 해소되니까 감수하는 경우가 많다.
세밀한 제어가 필요한 경우 Spring Cache로는 한계가 있어 직접 구현이 필요하다.
다건 조회
단건 조회 캐싱은 생각보다 단순했다. 문제는 다건(목록) 조회였다. 단건과 같은 방식으로 접근하면 될 줄 알았는데, 고려해야 할 것이 훨씬 많았다.
전체 상품 정보 캐싱의 문제
상품 다건 조회 시 페이징 정보 + 상품 정보 + 정렬 정보를 반환한다.
처음에는 단순하게 조회한 데이터 전체를 그대로 캐싱하면 된다고 생각했다. 캐시키는 products:page:0:sort:createdAt,desc 이다. 정렬조건이 최신순인 경우 0페이지에 속하는 상품목록 정보이다.
// KEY: products:page:0:size:20:sort:createdAt,desc
{
"content": [
{
"id": 3,
"name": "에어팟",
"price": 350000,
"stock": 100,
"brandId": 5,
"likeCount": 91
},
{
"id": 2,
"name": "아이패드",
"price": 1200000,
"stock": 30,
"brandId": 5,
"likeCount": 18
},
{
"id": 1,
"name": "맥북",
"price": 2000000,
"stock": 15,
"brandId": 5, "likeCount": 42
} ],
"totalElements": 3,
"totalPages": 1
}
위와 같은 방법으로 데이터를 캐싱하는 경우 상품의 상세 정보가 변경되는 경우 파생되는 다양한 문제를 마주하게 될것이다.
예를 들어 특정 상품의 가격이 바뀌었다고 가정하자. 해당 상품의 캐시 정보를 초기화 해야하는데 이 상품이 어디에 저장되어 있는지 알 수 있을까?
products:page:0:size:20:sort:createdAt,desc
products:page:1:size:20:sort:createdAt,desc
products:page:2:size:20:sort:createdAt,desc
...
products:page:10:size:20:sort:createdAt,desc
상품 데이터는 키의 값 안에 있기 때문에 키만 봐서는 "특정 상품이 어느 키에 들어있는지" 알 수 없다. 때문에 상품 정보가 변경된 경우에는 캐싱된 상품 목록 관련 데이터를 전부 캐시 저장소에서 초기화 시키거나, TTL이 만료될 때까지 불일치를 감수해야한다.
상품 목록을 캐싱하는 경우 다음과 같은 질문이 따라오게 된다.
- 상품의 정보가 변경되도 전체 목록 캐시를 삭제하지 않는 방법은 없는가?
- 모든 목록 캐시를 삭제하는 경우 발생하는 문제는 무엇인가?
상품 ID만 캐싱
특정 상품 정보만 변경되었는데 어떤 정보가 변경된지 모른다고 해서 전체 캐시 정보를 날리는 방법은 비효율적으로 보인다. 상품 데이터 자체를 목록 캐시에 저장하는게 아니라 상품 ID만 저장하는건 어떨까?
목록 캐시키: products:page:0:size:20:sort:createdAt,desc
TO-BE
목록 캐시값: {"productIds":[3,2,1],"totalElements":3}
조회 흐름
실제 코드에서 목록 조회 시 동작하는 흐름은 다음과 같다.
sequenceDiagram
participant C as 애플리케이션
participant R as Redis
C->>R: ① 목록 캐시 조회<br>products:page:0:size:20:sort:createdAt,desc
R-->>C: {"productIds":[3,2,1], "totalElements":3}
C->>R: ② 단건 캐시 MGET 조회<br>MGET product:1 product:2 product:3
R-->>C: [Product x3]
완전한 상품 목록 정보를 조회하기 위해서는 캐시 저장소에 두 번 접근 해야 한다. 하지만 상품 상세 정보가 변경되도 목록 캐시를 초기화하지 않아도 된다.
MGET (Multiple GET)
- 상품 목록 정보 조회
- 상품 상세 정보 조회
1차 조회로 캐싱된 상품 id를 조회한 후 2차 상품 상세 조회 시, 상품을 건별로 캐시 저장소에서 반복해서 조회하면 네트워크 비용이 증가하게 된다. 이를 방지하기 위해 Redis 기준으로는 MultipleGet을 사용하여 네트워크 왕복 비용을 줄일 수 있다.
GET을 20번 호출 (네트워크 왕복 20번)
GET product:1
GET product:2
....
GET product:20
Multiple Get 사용 (네트워크 왕복 1번)
MGET product:1 product:2 ... product:20
| 방식 | 네트워크 왕복 | 예상 시간 (Redis 별도 서버) |
|---|---|---|
| GET × 20 | 20회 | 20 × ~2ms = ~40ms |
| MGET × 1 | 1회 | 1 × ~2ms = ~2ms |
상세 정보가 캐시 저장소에 없는 경우는 어떡하는가?
2차 상품 정보를 캐시 저장소에서 조회 경우 조회한 데이터의 수와 1차에서 조회한 상품의 id의 수를 비교하여 모든 상품 정보들이 캐싱되어 있는지 확인한다. 불일치 하는 경우 없는 데이터가 존재하는 것이다.
List<Product> cached = productCacheRepository.multiGet(productIds);
if (cached.size() == productIds.size()) {
// 모든 상품이 캐시에 존재 → 바로 반환
return new PageImpl<>(cached, pageable, totalElements);
}
// 일부 상품이 캐시에 없음 → DB 재조회 필요
만약 캐시 저장소에 특정 상품의 데이터가 없는 경우 해당 상품정보만 DB에서 조회해서 캐시 저장소에 넣는 방법이 있고, 해당 목록의 상품 정보를 전부 DB에서 재조회하여 캐싱하는 방법도 있다. 첫 번째 방식을 선택하게 되면 구현 복잡도가 올라간다. 나는 두 번째 방식을 선택했다.
// 목록의 상품 정보를 전부 DB에서 재조회
Page<Product> page = productRepository.findAll(pageable);
// 목록 캐시(ID 리스트) 갱신
productCacheRepository.putProductIds(pageable, ids, page.getTotalElements());
// 단건 캐시도 함께 갱신
page.forEach(p -> productCacheRepository.put(p.getId(), p));
Cache Stampede
만약 상품 상세 정보를 목록 캐시에 저장한 경우 상품 정보 수정 시 캐시 저장소에 저장한 모든 목록 캐시를 삭제해야한다. 이 경우 어떤 문제가 발생할 수 있을까? (사실 해당 문제는 첫번째 해결 전략을 선택해도 발생할 수 있는 현상이다. 극적으로 와닿게 하기 위해 전체를 제거하는 경우를 빗대었다.)
Cache Stampede : 캐시가 만료되는 순간 대량의 요청이 DB로 몰리는 현상.
stampede = 동물 떼가 한번에 달려드는 것을 의미한다.
Thread A: 캐시 조회 → 미스 → DB 조회 시작
Thread B: 캐시 조회 → 미스 → DB 조회 시작
Thread C: 캐시 조회 → 미스 → DB 조회 시작
...
Thread 100: 캐시 조회 → 미스 → DB 조회 시작
→ 100개의 스레드가 동일한 쿼리를 DB에 날림
캐시가 있을 때는 초당 1,000건을 처리하던 서비스가 캐싱된 데이터의 일괄 삭제로 인해 DB에 1,000건의 조회 요청이 한꺼번에 들어가면서 DB 과부하 → 응답 지연 → 장애로 이어질 수 있다. 캐싱된 목록 데이터를 전부 삭제한다면 Cache Stampede 현상이 발생할 수 있다.
이를 위해 다양한 해결 방법이 있다.
- Mutex Lock: 하나의 요청만 DB 조회, 나머지는 대기
- TTL Jitter: 캐시마다 만료 시점을 살짝 다르게
- 사전 갱신: TTL 만료 전에 미리 캐시 갱신
Mutex Lock과 Jitter에 대해 살펴보자.
Mutex Lock
Mutex Lock = Mutual Exclusion Lock (상호 배제 잠금).
상호: 서로 간에
배제: 제외시킴
"서로를 제외시킨다"는 건 스레드들이 서로를 밀어낸다는 뜻이다.
- 스레드 A가 진입하면 → 스레드 B를 배제
- 스레드 B가 진입하면 → 스레드 A를 배제
한쪽이 진입하면 다른 쪽은 들어갈 수 없다는 원칙이다. 동시에 하나의 스레드만 접근할 수 있도록 보장하는 잠금 장치이다. 캐시 저장소에 데이터가 없는 경우는 락을 활용해서 하나의 요청만 DB 접근을 가능하게 하고 나머지 요청들은 대기하게 한다. DB 접근이 완료된 요청이 데이터를 캐싱하면 나머지 요청들이 캐싱된 데이터를 읽어 일시적 캐시 만료로 인한 DB 부하문제를 막는다.
환경에 따라 다양한 방법으로 구현이 가능하다.
| 환경 | 구현 방법 | 예시 |
|---|---|---|
| 단일 인스턴스 | Java ReentrantLock | ConcurrentHashMap으로 키별 Lock 관리 |
| 단일 인스턴스 | synchronized 블록 | Java 기본 제공 |
| 다중 인스턴스 | Redis SETNX | 분산 환경에서 사용 |
| 다중 인스턴스 | Redisson RLock | Redis 기반 분산 락 라이브러리 |
1. Java ReentrantLock (단일 인스턴스)
JVM 내부에서 스레드 간 동기화. 서버가 1대일 때 사용.ConcurrentHashMap으로 캐시 키별 Lock을 관리하여 서로 다른 상품 조회는 병렬로 진행된다.
Reentrant = "다시 진입할 수 있는". 같은 스레드가 이미 획득한 Lock을 다시 획득할 수 있어 메서드 중첩 호출 시 데드락에 빠지지 않는다. 다른 스레드는 차단(Mutex)된다.
private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();
public Product getProduct(Long productId) {
// 1. 캐시 조회
Optional<Product> cached = cacheRepository.get(productId);
if (cached.isPresent()) return cached.get();
// 2. 캐시 미스 → Lock 획득 시도
ReentrantLock lock = locks.computeIfAbsent("product:" + productId, k -> new ReentrantLock());
boolean acquired = lock.tryLock(3, TimeUnit.SECONDS);
try {
// 3. Double-Check: 대기하는 동안 다른 스레드가 캐시를 채웠을 수 있음
cached = cacheRepository.get(productId);
if (cached.isPresent()) return cached.get();
// 4. DB 조회 → 캐시 저장
Product product = productRepository.findById(productId);
cacheRepository.put(productId, product);
return product;
} finally {
lock.unlock();
locks.remove("product:" + productId); // 메모리 누수 방지
}
}
tryLock(3초): 3초 대기 후에도 Lock을 못 얻으면 DB 직접 조회로 폴백 (무한 대기 방지)- Double-Check 패턴: Lock 획득 후 캐시를 한 번 더 확인. 대기하는 동안 다른 스레드가 이미 캐시를 채웠을 수 있기 때문
- 단점: 서버가 여러 대면 각 서버에서 1개씩 Lock을 획득하므로 서버 수만큼 DB 조회 발생
2. synchronized 블록 (단일 인스턴스)
Java 기본 제공 동기화. ReentrantLock보다 단순하지만 타임아웃 설정이 불가능.
- 메서드 전체에 Lock이 걸려 모든 상품 조회가 직렬화되는 문제
- 키별 Lock 분리가 어렵고, tryLock(타임아웃)을 지원하지 않음
- 단순한 경우에만 사용하고, 실무에서는 ReentrantLock을 선호
3. Redis SETNX (다중 인스턴스)
SETNX = SET if Not Exists. "키가 없을 때만 저장"하는 Redis 명령.
서버가 여러 대일 때 Redis를 공유 Lock 저장소로 활용.
Thread A (서버1): SETNX lock:product:1 → 성공 → DB 조회 → 캐시 저장 → DEL lock
Thread B (서버2): SETNX lock:product:1 → 실패 → 잠시 대기 → 캐시 히트 → 반환- TTL도 함께 설정하여 Lock이 영원히 남는 것을 방지:
SET lock:product:1 "owner" NX EX 3 - 직접 구현 시 처리해야 할 것이 많음: Lock 연장, owner 검증, 재진입 등
4. Redisson RLock (다중 인스턴스)
SETNX의 예외 상황들을 라이브러리가 처리해주는 방식.
| 문제 | SETNX 직접 구현 | Redisson RLock |
|---|---|---|
| 락 만료 전에 작업이 안 끝나면? | 직접 연장 로직 구현 | Watchdog이 자동 연장 |
| 다른 스레드가 내 락을 해제하면? | 직접 owner 검증 | 내부적으로 스레드 ID 검증 |
| 재진입(같은 스레드가 다시 락)? | 직접 카운트 관리 | ReentrantLock 지원 |
| 락 획득 대기/타임아웃? | 직접 polling 구현 | tryLock(waitTime, leaseTime) 제공 |
RLock lock = redissonClient.getLock("lock:product:" + productId);
boolean acquired = lock.tryLock(3, 5, TimeUnit.SECONDS);
// └ 대기시간 └ Lock 유지시간
TTL Jitter
Cache Stampede는 캐싱된 데이터가 한번에 사라지는 경우 발생하는 문제라고 설명했다. 이는 데이터를 일괄적으로 지우지 않더라도 캐싱된 데이터의 TTL이 동일하기 만료된다면 발생할 수 있는 현상이기도 하다.
데이터를 캐싱할 때 하나의 고정된 TTL값을 사용하는 경우 여러 데이터를 한번에 캐싱한다면 해당 데이터들의 TTL은 동일하게 되고 한번에 만료될 수 있다. 이를 방지하기 위해 고정 TTL값에 일정 폭으로 랜덤값을 곱해 TTL 값이 동일하게 지정되는것을 방지하여, 캐시의 일괄만료를 방지할 수 있다.
// Jitter 적용: 5분 ± 10% (4분30초 ~ 5분30초)
Duration applyJitter(Duration baseTtl) {
long baseSeconds = baseTtl.getSeconds();
long jitter = (long) (baseSeconds * 0.1 * (ThreadLocalRandom.current().nextDouble() * 2 - 1));
return Duration.ofSeconds(Math.max(1, baseSeconds + jitter));
}
Jitter는 원래 신호 처리 분야에서 온 용어로, "타이밍의 불규칙한 변동"을 뜻한다. 소프트웨어에서는 의도적으로 타이밍을 흩뜨리는 기법 전반을 Jitter라고 부른다. 낙관적 락의 재시도 전략에서 사용한 Backoff의 Jitter와 같은 원리이다.
Mutex Lock만으로 충분하지 않은 이유
처음에는 Mutex Lock으로 충분할거라고 생각했다. Mutex Lock은 만료 후 사후처리에 대한 개념이고, Jitter는 일괄 만료를 막는 선제적 방어 개념이다. TTL이 동일해 일괄 만료되는걸 방지한다.
예: Cold Start (서버 배포 후 캐시가 비어있는 상태)
09:00:00 — 사용자 요청 몰림 → 상품 1,000개 캐싱 (TTL 5분)
09:05:00 — 1,000개 동시 만료 → 각 키별 Mutex Lock이 걸려도 1,000번 DB 조회 동시 발생
Jitter를 적용하면 만료 시점이 분산된다:
09:04:32 — 상품 317 만료 → DB 조회 1건
09:04:48 — 상품 42 만료 → DB 조회 1건
09:05:03 — 상품 891 만료 → DB 조회 1건
09:05:15 — 상품 156 만료 → DB 조회 1건
...
→ DB 조회가 시간적으로 분산됨
| 전략 | 보호 방향 | 설명 |
|---|---|---|
| Mutex Lock | 수직적 보호 | 같은 키에 대한 동시 요청 방지 |
| TTL Jitter | 수평적 보호 | 다른 키들의 동시 만료 방지 |
둘은 역할이 다르므로 함께 적용하는 것이 맞다.
Cache Stampede와 그 해결 전략(Mutex Lock, TTL Jitter)은 다건 조회에만 적용해야 하는 것이 아니라 단건 조회에도 동일하게 적용해야 한다. 여기서는 전체 목록 캐시가 삭제되는 상황에서 문제의 심각성이 더 극적으로 드러나기 때문에 다건 조회와 연결하여 설명했다.
전체 목록 캐시 삭제 시 블로킹 문제
만약 상품 다건 조회 시 상품 상세 정보도 함께 캐싱하는 방법을 선택했다면 어떤 목록 캐시에 포함되어 있는지 알 수 없기 때문에 products:* 패턴으로 전체 목록 캐시를 삭제해야 한다. 이때 데이터 조회를 위해 KEYS라는 명령어를 사용해야 하는데 KEYS products:* 명령을 사용하면 Redis가 전체 키를 순차적으로 스캔하는 동안 다른 모든 요청이 대기하게 된다. Redis는 싱글 스레드로 동작하기 때문에 KEYS 명령이 실행되는 시간 동안 서비스 전체가 영향을 받는다.
이를 해결하기 위해 SCAN 명령을 사용한다. SCAN은 커서 방식으로 조금씩 나눠서 스캔하기 때문에 블로킹이 발생하지 않는다.
KEYS products:*
→ O(N), 전체 키 스캔이 완료될 때까지 Redis 블로킹
SCAN 0 MATCH products:* COUNT 100
→ 커서 방식으로 조금씩 스캔, 블로킹 없음
결론
처음에 캐싱을 접했을 때는 "데이터를 메모리에 올려두면 빨라지는구나" 정도로만 이해했다. 그런데 실제로 적용해보니 캐시를 언제 삭제할지, 삭제 타이밍에 Race Condition은 없는지, 목록 캐시는 어떻게 구성할지, 캐시가 한꺼번에 만료되면 어떻게 되는지 같은 문제들을 연쇄적으로 마주하게 됐다.
결국 캐싱은 "저장하고 읽는" 단순한 작업이 아니라, 데이터 정합성과 시스템 안정성 사이에서 트레이드오프를 설계하는 과정이었다. 어디까지 불일치를 허용할지, 어디서부터 Lock을 걸지, TTL은 어떤 기준으로 잡을지. 이런 판단들을 하나씩 내리면서 캐싱 전략이라는 게 단순한 기술 적용이 아니라 설계의 영역이라는 걸 느꼈다.
'데이터베이스' 카테고리의 다른 글
| Redis Sorted Set 명령어 정리 (0) | 2026.04.11 |
|---|---|
| 일간 랭킹 시스템을 만들면서 마주친 것들 (0) | 2026.04.10 |
| 서브쿼리 이해하기: 인라인뷰, 스칼라 서브쿼리, 상관·비상관 쿼리 (1) | 2025.12.01 |
| SQL을 입력하면, DB 안에서는 무슨 일이 벌어질까? (0) | 2025.08.17 |
| 인덱스 미활용 안티 쿼리 패턴 (0) | 2025.08.17 |
