Coupon Issuance System

Common Used Redis Data Structure in Coupon Issuance System:

# 쿠폰 재고 관리
coupon:stock:{couponId} → "1000"

# 발급 큐
coupon:issue:queue → [request1, request2, request3...]

# 대기열 (Sorted Set)
coupon:waiting:{eventId} → {userId: timestamp}

# 중복 발급 방지 (Set)
coupon:issued:{couponId} → {userId1, userId2, userId3...}

# 사용자별 발급 내역 (Hash)
user:coupons:{userId} → {couponId: issueTime}

# 쿠폰 상태 추적
coupon:status:{requestId} → "PROCESSING|COMPLETED|FAILED"

# 분산 락
coupon:lock:{couponId} → "locked"

Story1: Black Friday Event

Scenario:

  • 블랙프라이데이 이벤트: 10,000원 할인 쿠폰 1,000장 한정, 오후 2시 정각 시작
  • 예상 동시 접속자: 50,000명
  • 이벤트 시작과 동시에 폭발적인 트래픽 발생

Phase1: Cache Warming & Event Start Scheduling

// 1. 캐시 워밍업 (이벤트 시작 10분 전)
@Scheduled(cron = "0 50 13 * * *") // 오후 1시 50분
public void warmUpCacheForBlackFridayEvent() {
    String couponId = "BLACK_FRIDAY_2024";
    
    // Redis에 쿠폰 재고 초기화
    redisTemplate.opsForValue().set("coupon:stock:" + couponId, "1000");
    
    // 쿠폰 정보 캐시 로드
    CouponInfo couponInfo = couponRepository.findById(couponId);
    redisTemplate.opsForValue().set("coupon:info:" + couponId, couponInfo, Duration.ofHours(2));
    
    // 이벤트 활성화 플래그 설정
    redisTemplate.opsForValue().set("event:active:" + couponId, "false");
    
    log.info("Black Friday event cache warmed up. Stock: 1000, Event: READY");
}

// 2. 정확히 오후 2시에 이벤트 활성화
@Scheduled(cron = "0 0 14 * * *") // 오후 2시 정각
public void activateBlackFridayEvent() {
    String couponId = "BLACK_FRIDAY_2024";
    redisTemplate.opsForValue().set("event:active:" + couponId, "true");
    
    // 대기열 초기화
    redisTemplate.delete("coupon:waiting:" + couponId);
    
    log.info("🚀 Black Friday event ACTIVATED!");
}

Phase2: Massive Traffic

// 1. API Gateway에서 요청 수신
@PostMapping("/api/v1/coupons/issue")
public Mono<ResponseEntity<CouponIssueResponse>> issueCoupon(
    @RequestHeader("X-User-ID") String userId,
    @RequestBody CouponIssueRequest request) {
    
    String requestId = UUID.randomUUID().toString();
    log.info("🎫 Coupon issue request received. User: {}, RequestId: {}", userId, requestId);
    
    return couponIssueService.issueCouponAsync(userId, request, requestId)
        .map(result -> ResponseEntity.ok(result))
        .onErrorResume(ex -> handleError(ex, requestId));
}

// 2. 쿠폰 발급 서비스에서 즉시 검증
@Service
public class CouponIssueService {
    
    public Mono<CouponIssueResponse> issueCouponAsync(String userId, CouponIssueRequest request, String requestId) {
        String couponId = request.getCouponId();
        
        return Mono.fromCallable(() -> {
            // Step 1: 이벤트 활성화 확인
            Boolean isActive = (Boolean) redisTemplate.opsForValue().get("event:active:" + couponId);
            if (!Boolean.TRUE.equals(isActive)) {
                throw new EventNotActiveException("이벤트가 아직 시작되지 않았습니다.");
            }
            
            // Step 2: 중복 발급 확인 (Redis Set 사용)
            String issuedKey = "coupon:issued:" + couponId;
            Boolean alreadyIssued = redisTemplate.opsForSet().isMember(issuedKey, userId);
            if (Boolean.TRUE.equals(alreadyIssued)) {
                throw new DuplicateIssueException("이미 발급받은 쿠폰입니다.");
            }
            
            // Step 3: 재고 확인 및 원자적 차감 (Lua Script)
            Long remainingStock = decrementStockAtomically(couponId);
            if (remainingStock < 0) {
                throw new OutOfStockException("쿠폰이 모두 소진되었습니다.");
            }
            
            // Step 4: 발급 큐에 추가
            CouponIssueQueueItem queueItem = CouponIssueQueueItem.builder()
                .requestId(requestId)
                .userId(userId)
                .couponId(couponId)
                .timestamp(System.currentTimeMillis())
                .build();
                
            redisTemplate.opsForList().rightPush("coupon:issue:queue", queueItem);
            
            // Step 5: 중복 발급 방지를 위해 사용자 추가
            redisTemplate.opsForSet().add(issuedKey, userId);
            
            // Step 6: 요청 상태 추적
            redisTemplate.opsForValue().set("coupon:status:" + requestId, "PROCESSING", Duration.ofMinutes(10));
            
            log.info("✅ Coupon issue queued. User: {}, Remaining stock: {}", userId, remainingStock);
            
            return CouponIssueResponse.builder()
                .requestId(requestId)
                .status("PROCESSING")
                .message("쿠폰 발급이 진행 중입니다.")
                .estimatedProcessingTime("30초 이내")
                .build();
                
        }).subscribeOn(Schedulers.boundedElastic());
    }
    
    // 원자적 재고 차감을 위한 Lua Script
    private Long decrementStockAtomically(String couponId) {
        String script = 
            "local stockKey = KEYS[1] " +
            "local current = redis.call('GET', stockKey) " +
            "if current and tonumber(current) > 0 then " +
            "  local newStock = redis.call('DECR', stockKey) " +
            "  return newStock " +
            "else " +
            "  return -1 " +
            "end";
            
        return redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList("coupon:stock:" + couponId)
        );
    }
}

Phase3: Background Async

// Consumer Service가 큐에서 요청을 처리
@Component
public class CouponIssueConsumer {
    
    @Scheduled(fixedDelay = 50) // 50ms마다 폴링
    public void processCouponIssueQueue() {
        try {
            // 배치 단위로 처리 (성능 최적화)
            List<CouponIssueQueueItem> batch = getBatchFromQueue(10);
            
            if (!batch.isEmpty()) {
                log.info("🔄 Processing batch of {} coupon requests", batch.size());
                processBatch(batch);
            }
            
        } catch (Exception e) {
            log.error("❌ Error processing coupon queue", e);
        }
    }
    
    private List<CouponIssueQueueItem> getBatchFromQueue(int batchSize) {
        List<CouponIssueQueueItem> batch = new ArrayList<>();
        
        for (int i = 0; i < batchSize; i++) {
            CouponIssueQueueItem item = (CouponIssueQueueItem) 
                redisTemplate.opsForList().leftPop("coupon:issue:queue");
            if (item == null) break;
            batch.add(item);
        }
        
        return batch;
    }
    
    @Transactional
    private void processBatch(List<CouponIssueQueueItem> batch) {
        List<UserCoupon> couponsToSave = new ArrayList<>();
        
        for (CouponIssueQueueItem item : batch) {
            try {
                // 1. 쿠폰 엔티티 생성
                UserCoupon userCoupon = UserCoupon.builder()
                    .id(UUID.randomUUID().toString())
                    .userId(item.getUserId())
                    .couponId(item.getCouponId())
                    .issueDate(LocalDateTime.now())
                    .expiryDate(LocalDateTime.now().plusDays(30))
                    .status(CouponStatus.ACTIVE)
                    .build();
                    
                couponsToSave.add(userCoupon);
                
                // 2. 상태 업데이트
                updateCouponStatus(item.getRequestId(), "COMPLETED", userCoupon.getId());
                
                // 3. 사용자별 쿠폰 목록 캐시 업데이트
                updateUserCouponCache(item.getUserId(), userCoupon);
                
                log.info("✅ Coupon issued successfully. User: {}, CouponCode: {}", 
                    item.getUserId(), userCoupon.getId());
                
            } catch (Exception e) {
                // 실패 처리
                handleFailure(item, e);
            }
        }
        
        // 배치 DB 저장
        if (!couponsToSave.isEmpty()) {
            userCouponRepository.saveAll(couponsToSave);
            log.info("💾 Batch saved {} coupons to database", couponsToSave.size());
        }
    }
    
    private void updateCouponStatus(String requestId, String status, String couponCode) {
        CouponStatusUpdate statusUpdate = CouponStatusUpdate.builder()
            .requestId(requestId)
            .status(status)
            .couponCode(couponCode)
            .timestamp(System.currentTimeMillis())
            .build();
            
        redisTemplate.opsForValue().set("coupon:status:" + requestId, statusUpdate, Duration.ofHours(1));
        
        // 실시간 알림을 위한 pub/sub
        redisTemplate.convertAndSend("coupon:status:updates", statusUpdate);
    }
}

Phase4: Real-time Status Inquiry

// 사용자가 발급 상태를 실시간으로 확인
@GetMapping(value = "/api/v1/coupons/status/{requestId}", 
           produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<CouponStatusUpdate>> getCouponStatus(@PathVariable String requestId) {
    
    return Flux.create(sink -> {
        // 1. 현재 상태 즉시 전송
        CouponStatusUpdate currentStatus = getCurrentStatus(requestId);
        if (currentStatus != null) {
            sink.next(ServerSentEvent.builder(currentStatus).build());
        }
        
        // 2. Redis Pub/Sub으로 실시간 업데이트 구독
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(redisConnectionFactory);
        
        MessageListener listener = (message, pattern) -> {
            CouponStatusUpdate update = parseMessage(message);
            if (requestId.equals(update.getRequestId())) {
                sink.next(ServerSentEvent.builder(update).build());
                
                // 완료되면 스트림 종료
                if ("COMPLETED".equals(update.getStatus()) || "FAILED".equals(update.getStatus())) {
                    sink.complete();
                }
            }
        };
        
        container.addMessageListener(listener, new PatternTopic("coupon:status:updates"));
        container.start();
        
        // 정리 작업
        sink.onDispose(() -> {
            container.stop();
            container.destroy();
        });
    })
    .timeout(Duration.ofMinutes(5)) // 5분 타임아웃
    .onErrorResume(ex -> Flux.just(ServerSentEvent.builder(
        CouponStatusUpdate.error(requestId, "상태 조회 중 오류가 발생했습니다.")).build()));
}

Story2: System overload situation

Scenario:

  • Black Friday Event 에 "예상보다 10배 많은 트래픽 발생 (500,000명 동시 접속)"

Circuit Breaker

@Component
public class CouponServiceWithResilience {
    
    @CircuitBreaker(name = "coupon-service", fallbackMethod = "fallbackToWaitingQueue")
    @RateLimiter(name = "coupon-service")
    @TimeLimiter(name = "coupon-service")
    public Mono<CouponIssueResponse> issueCoupon(String userId, CouponIssueRequest request) {
        return couponIssueService.issueCouponAsync(userId, request, UUID.randomUUID().toString());
    }
    
    // 폴백: 대기열 시스템으로 전환
    public Mono<CouponIssueResponse> fallbackToWaitingQueue(String userId, CouponIssueRequest request, Exception ex) {
        log.warn("🚨 Circuit breaker activated. Redirecting to waiting queue. User: {}", userId);
        
        return waitingQueueService.addToWaitingQueue(userId, request.getCouponId())
            .map(position -> CouponIssueResponse.builder()
                .status("QUEUED")
                .message("현재 대기 중입니다.")
                .queuePosition(position)
                .estimatedWaitTime(calculateEstimatedWaitTime(position))
                .build());
    }
}

// 대기열 서비스
@Service
public class WaitingQueueService {
    
    public Mono<Integer> addToWaitingQueue(String userId, String couponId) {
        return Mono.fromCallable(() -> {
            String queueKey = "coupon:waiting:" + couponId;
            double score = System.currentTimeMillis(); // 타임스탬프를 스코어로 사용
            
            // Sorted Set에 추가
            redisTemplate.opsForZSet().add(queueKey, userId, score);
            
            // 현재 대기 순번 반환
            Long rank = redisTemplate.opsForZSet().rank(queueKey, userId);
            return rank != null ? rank.intValue() + 1 : 1;
            
        }).subscribeOn(Schedulers.boundedElastic());
    }
    
    // 대기열에서 배치 단위로 처리
    @Scheduled(fixedDelay = 1000) // 1초마다
    public void processWaitingQueue() {
        String couponId = "BLACK_FRIDAY_2024";
        String queueKey = "coupon:waiting:" + couponId;
        
        // 현재 시스템 부하 확인
        if (isSystemHealthy()) {
            // 상위 100명을 실제 발급 큐로 이동
            Set<String> nextBatch = redisTemplate.opsForZSet().range(queueKey, 0, 99);
            
            if (!nextBatch.isEmpty()) {
                for (String userId : nextBatch) {
                    // 실제 발급 프로세스로 이동
                    CouponIssueRequest request = CouponIssueRequest.builder()
                        .couponId(couponId)
                        .build();
                        
                    couponIssueService.issueCouponAsync(userId, request, UUID.randomUUID().toString())
                        .subscribe(
                            result -> {
                                // 대기열에서 제거
                                redisTemplate.opsForZSet().remove(queueKey, userId);
                                log.info("✅ User {} moved from waiting queue to processing", userId);
                            },
                            error -> log.error("❌ Failed to process user {} from waiting queue", userId, error)
                        );
                }
            }
        }
    }
}

Story3: Users use Coupons

Scenario:

  • 사용자가 발급 받은 쿠폰을 결제 시 사용
@PostMapping("/api/v1/orders/{orderId}/apply-coupon")
public Mono<OrderResponse> applyCoupon(
    @PathVariable String orderId,
    @RequestHeader("X-User-ID") String userId,
    @RequestBody CouponApplyRequest request) {
    
    return couponUsageService.applyCoupon(userId, orderId, request.getCouponCode())
        .map(result -> OrderResponse.builder()
            .orderId(orderId)
            .originalAmount(result.getOriginalAmount())
            .discountAmount(result.getDiscountAmount())
            .finalAmount(result.getFinalAmount())
            .appliedCoupon(result.getCouponCode())
            .build());
}

@Service
public class CouponUsageService {
    
    public Mono<CouponApplyResult> applyCoupon(String userId, String orderId, String couponCode) {
        return Mono.fromCallable(() -> {
            // 1. 쿠폰 소유권 확인
            String userCouponKey = "user:coupons:" + userId;
            Boolean hasCoupon = redisTemplate.opsForHash().hasKey(userCouponKey, couponCode);
            
            if (!Boolean.TRUE.equals(hasCoupon)) {
                throw new CouponNotOwnedException("보유하지 않은 쿠폰입니다.");
            }
            
            // 2. 쿠폰 상태 확인 (분산 락 사용)
            String lockKey = "coupon:lock:" + couponCode;
            Boolean lockAcquired = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "locked", Duration.ofSeconds(10));
                
            if (!Boolean.TRUE.equals(lockAcquired)) {
                throw new CouponLockedException("쿠폰이 다른 곳에서 사용 중입니다.");
            }
            
            try {
                // 3. DB에서 쿠폰 상세 정보 조회
                UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCode(userId, couponCode)
                    .orElseThrow(() -> new CouponNotFoundException("쿠폰을 찾을 수 없습니다."));
                
                // 4. 쿠폰 유효성 검증
                validateCouponUsage(userCoupon, orderId);
                
                // 5. 쿠폰 사용 처리
                userCoupon.use(orderId);
                userCouponRepository.save(userCoupon);
                
                // 6. 캐시 업데이트
                updateCouponCacheAfterUsage(userId, couponCode);
                
                // 7. 사용 이벤트 발행
                publishCouponUsedEvent(userId, couponCode, orderId);
                
                return CouponApplyResult.builder()
                    .couponCode(couponCode)
                    .discountAmount(userCoupon.getDiscountAmount())
                    .build();
                    
            } finally {
                // 락 해제
                redisTemplate.delete(lockKey);
            }
            
        }).subscribeOn(Schedulers.boundedElastic());
    }
}