선착순 이벤트 쿠폰 발급 시스템을 구현하면서 Caffeine 로컬 캐시 + Redis 캐시를 활용한 계층적 캐싱 구조를 적용하여 성능 최적화 과정을 담고있습니다.
📌 캐시란?
자주 사용되는 데이터나 자원을 임시로 저장해두는 고속 저장 계층으로, 데이터 접근 속도 향상 및 시스템 부하 감소를 목표
- 캐시는 반복적인 I/O 작업, 네트워크 지연, 계산 비용을 줄여 전반적인 성능을 최적화하는데 목적이 있습니다.
캐시의 종류와 특징
캐시는 크게 두 가지로 나눌 수 있습니다.

종류 | 저장위치 | 장점 | 단점 |
로컬 캐시 (Caffeine 등) | 서버 내 메모리 | 빠른 속도, 네트워크 호출 없음 | 다중 서버 환경에서 데이터 일관성 문제 |
분산 캐시 (Redis 등) | 별도 독립 캐시 서버 (글로벌) |
여러 서버가 같은 데이터를 공유 가능 (데이터 일관성 확보) | 네트워크 호출로 인해 로컬 캐시보다는 느림 |
- 로컬 캐시는 속도는 빠르지만 다중 서버 환경에서는 데이터 불일치 문제가 있을수 있습니다.
- 분산 캐시는 여러 서버가 같은 데이터를 공유 가능하지만 별도의 독립 캐시서버이므로 네트워크 오버헤드가 발생합니다.
🔹로컬 캐시(Caffeine)만 사용할 때 문제점
🚨 1) 다중 서버 환경에서 데이터 불일치 문제 발생
- 로컬 캐시는 각 API 서버의 메모리에만 저장되어, 여러 서버 환경에서는 데이터 일관성 문제가 발생합니다.
- 예를 들어 쿠폰 수량이 0개가 되면, 특정 서버의 캐시만 업데이트되고 다른 서버는 여전히 수량이 남은 것으로 잘못 인식하는 데이터 일관성 문제가 발생할 수 있습니다.
🚨 2) 서버 재시작 시 캐시 초기화 문제
- 서버를 재시작하면 메모리가 초기화되어 캐시가 소멸됩니다.
- 최초 요청이 발생할 때마다 캐시 미스가 빈번하게 발생하여 성능이 저하될 수 있습니다.
🔹 Redis 캐시만 사용할 때의 문제점
본 프로젝트에서 선착순 쿠폰 발급 이벤트는 짧은 시간에 트래픽이 집중되는 특징을 가정하였습니다. 처음에는 Redis 캐시만 단독으로 사용하였으나, 트래픽이 많아질수록 Redis CPU 부하가 증가하였습니다.
초기 구조에서는 Redis 캐시만 단독으로 사용하여, 모든 요청마다 아래와 같은 과정이 반복되었습니다.
- Redis에서 쿠폰 데이터(잔여 수량)를 조회
- `availableIssueQuantity` 값을 기준으로 발급 가능 여부를 검증
즉 쿠폰 발급이 이미 마감된 이후에도 모든 요청이 Redis로 향하면서, Redis CPU 사용률이 급증하고, 불필요한 네트워크 호출이 지속되는 병목이 발생했습니다.
public void issue(long couponId, long userId) {
// 매 요청 시 항상 Redis에서 데이터를 조회하여 가져옴
CouponRedisEntity coupon = couponCacheService.getCouponCache(couponId);
coupon.checkIssuableCoupon(); // 발급 가능 여부 체크 (수량, 기간 등)
redisRepository.issueRequest(couponId, userId, coupon.totalQuantity());
}
// 기존 Redis만 사용
@Cacheable(cacheNames = "coupon")
public CouponRedisEntity getCouponCache(long couponId) {
Coupon coupon = couponIssueService.findCoupon(couponId);
return new CouponRedisEntity(coupon);
}
public void checkIssuableCoupon() {
if(!availableIssueQuantity){
throw new CouponIssueException(INVALID_COUPON_ISSUE_QUANTITY);
}
}
이 문제점을 해결하기위해 계층적 캐싱 구조와 비동기 이벤트 기반 캐시 갱신을 적용하였습니다.
🔥 계층적 캐싱 구조(Caffeine + Redis) 적용
- API 서버의 메모리(Caffeine 캐시)에서 먼저 쿠폰 상태를 조회
- 로컬 캐시에 없을 경우에만 Redis를 조회하여 Redis 트래픽을 줄임
- 이후 Redis 캐시 결과도 다시 로컬에 저장하여, 다음 요청 시 더 빠르게 처리
public void issue(long couponId, long userId) {
// 로컬 캐시 우선 조회, 없으면 Redis 호출 후 로컬 캐시 저장
CouponRedisEntity coupon = couponCacheService.getCouponLocalCache(couponId);
coupon.checkIssuableCoupon(); // 로컬 캐시에서 발급 가능 여부 확인
// 발급 가능할 때만 Redis에 요청
if (coupon.availableIssueQuantity()) {
redisRepository.issueRequest(couponId, userId, coupon.totalQuantity());
}
}
RequiredArgsConstructor
@Service
public class CouponCacheService {
private final CouponIssueService couponIssueService;
//계층적 캐싱(Caffeine + Redis) 구조 적용 후
@Cacheable(cacheNames = "coupon", cacheManager = "localCacheManager")
public CouponRedisEntity getCouponLocalCache(long couponId) {
return proxy().getCouponCache(couponId);
}
// 기존 Redis만 사용
@Cacheable(cacheNames = "coupon")
public CouponRedisEntity getCouponCache(long couponId) {
Coupon coupon = couponIssueService.findCoupon(couponId);
return new CouponRedisEntity(coupon);
}
private CouponCacheService proxy() {
return ((CouponCacheService) AopContext.currentProxy());
}
}
주의할점으로는 `Spring Cache`는 프록시 기반으로 동작하기 떄문에 내부 메서드 호출 시 AOP가 적용되지 않으므로 , 직접` AopContext`에서 현재 프록시를 꺼내와서 사용해야합니다.
🧩 비동기 이벤트 기반 캐시 갱신 적용
계층적 캐싱 구조가 Redis 트래픽을 줄이는 데 효과적이었다면, 여기에 “캐시를 언제, 어떻게 갱신할 것인가” 라는 문제가 남아있었습니다. 쿠폰 발급이 모두 완료된 경우, 더 이상 사용자가 쿠폰을 발급받을 수 없음에도 로컬 캐시나 Redis 캐시가 이전 상태를 유지하고 있다면, 불필요한 발급 시도가 계속 발생할 수 있습니다.
이를 해결하기 위해 비동기 이벤트 기반 캐시 갱신구조를 도입했습니다.
- 발급 완료 시점에 Redis와 로컬 캐시가 즉시 갱신되어, 이후 요청에서 `availableIssueQuantity = false` 값을 기준으로 즉시 차단하여, 기존에 발생하던 불필요한 Redis 조회를 방지하고 네트워크 부하를 줄일 수 있습니다.
- 비동기 이벤트 기반으로 동작하기 때문에, 트랜잭션 커밋 이후 안정적으로 캐시가 갱신
// 발급 완료 시점에 이벤트 발행
private void publishCouponEvent(Coupon coupon) {
if (coupon.isIssueComplete()) {
applicationEventPublisher.publishEvent(new CouponIssueCompleteEvent(coupon.getId()));
}
}
// 이벤트 리스너에서 캐시 갱신 수행 (AFTER_COMMIT)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void issueComplete(CouponIssueCompleteEvent event) {
couponCacheService.putCouponCache(event.couponId()); // Redis 갱신
couponCacheService.putCouponLocalCache(event.couponId()); // Caffeine 갱신
}
@CachePut(cacheNames = "coupon")
public CouponRedisEntity putCouponCache(long couponId){
return getCouponCache(couponId);
}
@CachePut(cacheNames = "coupon",cacheManager = "localCacheManager")
public CouponRedisEntity putCouponLocalCache(long couponId){
return getCouponLocalCache(couponId);
}
🔎 실제 프로젝트 내에서의 쿠폰 발급 동작 과정
1. API 서버에서 쿠폰 데이터 요청 → 로컬 캐시 조회
- `Cache Hit` 시, 바로 응답
- `Cache Miss` 시, Redis 조회 후 데이터를 로컬 캐시에 저장하고 응답
2. 비동기 서버에서 쿠폰 발급 완료 후 캐시 업데이트 이벤트 발생
쿠폰 발급이 최종적으로 모두 소진된 시점에는 `CouponIssueCompleteEvent`라는 이벤트가 발행됩니다.
이 이벤트는 아래와 같은 코드에서 발행됩니다.
// 쿠폰 발급 트랜잭션이 끝난 직후 발행되는 이벤트
private void publishCouponEvent(Coupon coupon) {
if (coupon.isIssueComplete()) { // 쿠폰 발급이 완료된 경우
applicationEventPublisher.publishEvent(new CouponIssueCompleteEvent(coupon.getId()));
}
}
3. 이벤트 리스너를 통한 캐시 갱신 과정
위의 이벤트가 발행되면, 이를 수신하는 리스너(CouponEventListener)가 작동하여 Redis와 로컬 캐시를 갱신합니다.
public class CouponEventListener {
private final CouponCacheService couponCacheService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void issueComplete(CouponIssueCompleteEvent event) {
log.info("쿠폰 발급 완료 이벤트 수신. 쿠폰 ID: {}", event.couponId());
couponCacheService.putCouponCache(event.couponId()); // Redis 캐시 갱신
couponCacheService.putCouponLocalCache(event.couponId()); // 로컬 캐시 갱신
log.info("쿠폰 발급 완료 후 캐시 업데이트 완료. 쿠폰 ID: {}", event.couponId());
}
}
즉, 이벤트가 트랜잭션 완료 직후`AFTER_COMMIT`에 처리되므로, DB에 반영된 최신 상태가 즉각적으로 Redis와 로컬 캐시에 반영됩니다.
@CachePut(cacheNames = "coupon")
public CouponRedisEntity putCouponCache(long couponId){
return getCouponCache(couponId);
}
@CachePut(cacheNames = "coupon",cacheManager = "localCacheManager")
public CouponRedisEntity putCouponLocalCache(long couponId){
return getCouponLocalCache(couponId);
}
이후에는 캐시의 쿠폰 발급 가능 여부`availableIssueQuantity`가 false로 갱신되어 더 이상의 Redis 호출(`SADD`, `RPUSH`)을 하지 않습니다.
public boolean availableIssueQuantity() {
if (totalQuantity == null) {
return true; // 수량 무제한 쿠폰
}
return totalQuantity > issuedQuantity; // 발급수량이 모두 소진되면 false 반환
}
📌 성능 개선 및 결론
계층적 캐시 구조를 적용한 결과, Redis CPU부하가 12.87% → 1,19%로 확실히 줄어든것을 확인할 수 있었습니다.
항목 | Redis 단독 캐시 | 계층적 캐시(Caffeine + Redis) |
Redis CPU 사용량 | 약 12.87 % | 1.19% (안정적) |
Redis 트래픽 | 높음 (모든 요청 Redis 호출) | 매우 낮음 (로컬 캐시 우선 조회로 Redis 호출 최소화) |
🔥결론
- Redis의 네트워크 트래픽 급감: 모든 요청이 Redis를 호출하는 대신, 쿠폰이 소진된 후 로컬 캐시에서 빠르게 처리되어 불필요한 Redis 접근 최소화
- Redis 부하 최소화 및 응답 속도 증가: Redis 서버는 필수적인 요청만 처리하게 되므로 안정성이 향상되고 CPU 사용률이 낮아져 전체 시스템이 안정
- 서버 재시작 시 캐시 유지 : API 서버가 재시작되어 로컬 캐시가 사라져도, Redis 캐시는 유지되므로 캐시를 빠르게 복원 가능
'BackEnd' 카테고리의 다른 글
Redis Lua Script를 활용한 쿠폰발급 동시성 제어 및 성능 개선기 (0) | 2025.03.19 |
---|---|
트래픽을 고려한 쿠폰 발급 서버 아키텍처 개선기 (0) | 2025.03.17 |
Spring 비동기 프로그래밍(@Async)과 ThreadPool 이해 (0) | 2025.03.09 |
스프링 IoC와 DI (0) | 2025.03.07 |
스프링에서 SOLID 원칙 알아보기 (0) | 2025.03.06 |