diff --git a/src/main/java/com/catchtable/coupon/dto/read/CouponTemplateActiveResponse.java b/src/main/java/com/catchtable/coupon/dto/read/CouponTemplateActiveResponse.java index 8f4103a..44d64bc 100644 --- a/src/main/java/com/catchtable/coupon/dto/read/CouponTemplateActiveResponse.java +++ b/src/main/java/com/catchtable/coupon/dto/read/CouponTemplateActiveResponse.java @@ -14,12 +14,16 @@ public record CouponTemplateActiveResponse( LocalDateTime expiredAt ) { public static CouponTemplateActiveResponse from(CouponTemplate template) { + return from(template, template.getRemain()); + } + + public static CouponTemplateActiveResponse from(CouponTemplate template, Integer remain) { return new CouponTemplateActiveResponse( template.getId(), template.getCouponName(), template.getDiscountRate(), template.getAmount(), - template.getRemain(), + remain, template.getStartedAt(), template.getExpiredAt() ); diff --git a/src/main/java/com/catchtable/coupon/entity/CouponTemplate.java b/src/main/java/com/catchtable/coupon/entity/CouponTemplate.java index 08a0b02..bccd45c 100644 --- a/src/main/java/com/catchtable/coupon/entity/CouponTemplate.java +++ b/src/main/java/com/catchtable/coupon/entity/CouponTemplate.java @@ -48,4 +48,8 @@ public class CouponTemplate { @Column(name = "is_deleted", nullable = false) @Builder.Default private Boolean isDeleted = false; + + public void syncRemain(int stock) { + this.remain = stock; + } } diff --git a/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java b/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java index bc36a4e..7cf9cda 100644 --- a/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java +++ b/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java @@ -111,6 +111,22 @@ public void warmUp(Long templateId, Integer remain, LocalDateTime expiredAt) { stock.set(String.valueOf(remain), Duration.ofSeconds(ttlSeconds)); } + /** + * Redis 가 보유한 현재 재고. 키가 없으면 null. + * 워밍업 누락/만료 상황과 stock=0 을 호출자가 구분할 수 있도록 null 을 보존. + */ + public Integer getStock(Long templateId) { + var bucket = redissonClient.getBucket(stockKey(templateId), StringCodec.INSTANCE); + Object value = bucket.get(); + if (value == null) return null; + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException e) { + log.warn("쿠폰 stock 키 값이 정수가 아님: templateId={}, value={}", templateId, value); + return null; + } + } + /** * 서버 부팅 시 호출. 이미 키가 있으면(Redis 운영 중 상태 유지 중) 건드리지 않는다. * Redis 콜드 스타트일 때만 DB 의 remain 으로 워밍업. diff --git a/src/main/java/com/catchtable/coupon/repository/CouponTemplateRepository.java b/src/main/java/com/catchtable/coupon/repository/CouponTemplateRepository.java index eab4d2a..b3d38f2 100644 --- a/src/main/java/com/catchtable/coupon/repository/CouponTemplateRepository.java +++ b/src/main/java/com/catchtable/coupon/repository/CouponTemplateRepository.java @@ -10,14 +10,14 @@ public interface CouponTemplateRepository extends JpaRepository { - // 현재 발급 가능한 쿠폰 템플릿 (시작 시각 도래 + 만료 전 + 잔여 수량 > 0) - // 정렬: 곧 만료되는 것 우선 + // 활성 시간대(시작 도래 + 만료 전)의 템플릿. + // 잔여 재고는 Redis 가 진실 원천이므로 DB remain 필터는 사용하지 않는다. + // 호출자(CouponService)가 Redis stock 으로 필터링하고 스케줄러가 주기 sync 한다. @Query(""" SELECT ct FROM CouponTemplate ct WHERE ct.isDeleted = false AND ct.startedAt <= :now AND ct.expiredAt >= :now - AND ct.remain > 0 ORDER BY ct.expiredAt ASC """) List findActiveTemplates(@Param("now") LocalDateTime now); diff --git a/src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java b/src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java new file mode 100644 index 0000000..d4a736a --- /dev/null +++ b/src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java @@ -0,0 +1,78 @@ +package com.catchtable.coupon.scheduler; + +import com.catchtable.coupon.entity.CouponTemplate; +import com.catchtable.coupon.redis.RedisCouponIssuer; +import com.catchtable.coupon.repository.CouponTemplateRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Redis stock → DB coupon_templates.remain 주기 동기화. + * + * 발급은 Redis 가 단일 진실원이라 DB remain 을 건드리지 않는다. 관리/통계 조회와 + * Redis 콜드 스타트 워밍업이 보는 DB 값을 Redis 와 맞추기 위해 본 잡이 sync. + * + * 함정: + * - Redis 키 만료/소실 시(stock == null) DB 를 0 으로 덮지 않는다. 워밍업이 + * DB remain 으로 Redis 를 다시 채우는 책임을 가지므로 0 으로 덮으면 정합성이 깨진다. + * - 메서드에 @Transactional 을 걸지 않는다. 루프 안의 Redis I/O 동안 DB 커넥션이 + * 점유되어 HikariCP 풀이 압박을 받는다. UPDATE 가 필요한 템플릿만 좁게 묶는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponStockSyncScheduler { + + private static final long SYNC_INTERVAL_MS = 5_000L; + private static final String SYNC_LOCK_KEY = "lock:coupon:sync"; + private static final long LOCK_LEASE_SECONDS = 4L; + + private final CouponTemplateRepository couponTemplateRepository; + private final RedisCouponIssuer redisCouponIssuer; + private final RedissonClient redissonClient; + private final TransactionTemplate transactionTemplate; + + @Scheduled(fixedDelay = SYNC_INTERVAL_MS) + public void syncStock() { + RLock lock = redissonClient.getLock(SYNC_LOCK_KEY); + boolean acquired = false; + try { + acquired = lock.tryLock(0, LOCK_LEASE_SECONDS, TimeUnit.SECONDS); + if (!acquired) return; + doSync(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("쿠폰 stock sync 락 획득 인터럽트", e); + } finally { + if (acquired && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + private void doSync() { + List templates = couponTemplateRepository.findActiveTemplates(LocalDateTime.now()); + for (CouponTemplate template : templates) { + try { + Integer stock = redisCouponIssuer.getStock(template.getId()); + if (stock == null) continue; + if (stock.equals(template.getRemain())) continue; + transactionTemplate.executeWithoutResult(status -> + couponTemplateRepository.findById(template.getId()) + .ifPresent(t -> t.syncRemain(stock)) + ); + } catch (Exception e) { + log.warn("쿠폰 stock sync 실패. templateId={}", template.getId(), e); + } + } + } +} diff --git a/src/main/java/com/catchtable/coupon/service/CouponService.java b/src/main/java/com/catchtable/coupon/service/CouponService.java index 0ae1a61..25759dd 100644 --- a/src/main/java/com/catchtable/coupon/service/CouponService.java +++ b/src/main/java/com/catchtable/coupon/service/CouponService.java @@ -108,14 +108,21 @@ public List getMyCoupons(Long userId) { .toList(); } - // 현재 발급 가능한 쿠폰 템플릿 목록 (홈 배너용) — 글로벌 데이터, 인증 불필요 - @Transactional(readOnly = true) + // 트랜잭션 없음: stream 안의 resolveStock 이 Redis I/O 라 트랜잭션이 길어지면 + // 풀이 압박을 받는다. CouponTemplate 에 lazy 연관관계가 없어 트랜잭션 밖에서도 안전. + // (lazy 필드를 추가하려면 이 메서드의 트랜잭션 범위도 함께 재검토할 것.) public List getActiveTemplates() { return couponTemplateRepository.findActiveTemplates(LocalDateTime.now()).stream() - .map(CouponTemplateActiveResponse::from) + .map(template -> CouponTemplateActiveResponse.from(template, resolveStock(template))) + .filter(response -> response.remain() != null && response.remain() > 0) .toList(); } + private Integer resolveStock(CouponTemplate template) { + Integer stock = redisCouponIssuer.getStock(template.getId()); + return stock != null ? stock : template.getRemain(); + } + // 쿠폰 사용 (예약 생성 시 호출) @Transactional public Coupon useCoupon(Long couponId, Long userId) { diff --git a/src/main/java/com/catchtable/global/config/KafkaConfig.java b/src/main/java/com/catchtable/global/config/KafkaConfig.java index f651f31..f821ce1 100644 --- a/src/main/java/com/catchtable/global/config/KafkaConfig.java +++ b/src/main/java/com/catchtable/global/config/KafkaConfig.java @@ -11,9 +11,6 @@ import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.*; -import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; -import org.springframework.kafka.listener.DefaultErrorHandler; -import org.springframework.util.backoff.FixedBackOff; import java.util.HashMap; import java.util.Map; @@ -47,20 +44,13 @@ public ProducerFactory producerFactory() { return factory; } + // @RetryableTopic 이 retry/dlt 토픽 라우팅과 ErrorHandler 를 자체 관리한다. + // 여기서 commonErrorHandler 를 강제하면 RetryTopicConfigurer 의 ErrorHandler swap 이 + // 차단되어 main listener container 가 partition assignment 단계 진입에 실패한다. @Bean - public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(KafkaTemplate kafkaTemplate) { + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(consumerFactory()); - - // DLQ 설정을 위한 ErrorHandler 추가 - // 2초 간격으로 최대 3번 재시도 (총 4번 시도) - FixedBackOff backOff = new FixedBackOff(2000L, 3L); - // 재시도 후에도 실패하면 원래 토픽 이름 뒤에 ".DLT"를 붙인 토픽으로 메시지 전송 - DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate); - DefaultErrorHandler errorHandler = new DefaultErrorHandler(recoverer, backOff); - - factory.setCommonErrorHandler(errorHandler); - return factory; }