From 5f02af1c3a59067fa6f1ea48bfb636289dd6da31 Mon Sep 17 00:00:00 2001 From: kimjb Date: Sat, 30 May 2026 23:34:32 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Fix:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=8B=9C=20db=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=95=88=20=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../read/CouponTemplateActiveResponse.java | 6 ++- .../coupon/entity/CouponTemplate.java | 4 ++ .../coupon/redis/RedisCouponIssuer.java | 16 ++++++ .../repository/CouponTemplateRepository.java | 6 +-- .../scheduler/CouponStockSyncScheduler.java | 53 +++++++++++++++++++ .../coupon/service/CouponService.java | 8 ++- 6 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java 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..8e634e4 --- /dev/null +++ b/src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java @@ -0,0 +1,53 @@ +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.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Redis stock → DB coupon_templates.remain 주기 동기화. + * + * 발급 트랜잭션은 hot row contention 회피를 위해 DB remain 을 건드리지 않고 + * Redis 가 단일 진실원 역할을 한다. 관리/통계/콜드 스타트 워밍업이 보는 + * DB remain 을 SoT 와 일치시키기 위해 본 잡이 백그라운드에서 sync. + * + * Redis 키가 만료/소실된 템플릿(stock == null)은 건드리지 않는다. + * 워밍업 책임은 CouponWarmupRunner 에 있고, 본 잡이 0 으로 덮으면 정합성이 깨진다. + * + * 잡 자체 실패는 다음 주기에 재시도되므로 별도 재시도 큐 없음. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponStockSyncScheduler { + + private static final long SYNC_INTERVAL_MS = 5_000L; + + private final CouponTemplateRepository couponTemplateRepository; + private final RedisCouponIssuer redisCouponIssuer; + + @Scheduled(fixedDelay = SYNC_INTERVAL_MS) + @Transactional + public void syncStock() { + 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())) { + template.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..de4b8dd 100644 --- a/src/main/java/com/catchtable/coupon/service/CouponService.java +++ b/src/main/java/com/catchtable/coupon/service/CouponService.java @@ -112,10 +112,16 @@ public List getMyCoupons(Long userId) { @Transactional(readOnly = true) 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) { From 34549e023954abb065c18f77802cd55fcbc0e4d1 Mon Sep 17 00:00:00 2001 From: kimjb Date: Sun, 31 May 2026 00:05:25 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Fix:=20kafka=20consumer=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EB=9F=89=20=EC=97=86=EC=9D=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catchtable/global/config/KafkaConfig.java | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) 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; } From 1c671c146e542c01c2f7c221519ddd76b4880d3a Mon Sep 17 00:00:00 2001 From: kimjb Date: Sun, 31 May 2026 00:22:44 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Fix:=20=EC=BF=A0=ED=8F=B0=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=ED=92=80=20=EC=95=95=EB=B0=95=20?= =?UTF-8?q?=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/CouponStockSyncScheduler.java | 49 ++++++++++++++----- .../coupon/service/CouponService.java | 5 +- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java b/src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java index 8e634e4..d4a736a 100644 --- a/src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java +++ b/src/main/java/com/catchtable/coupon/scheduler/CouponStockSyncScheduler.java @@ -5,24 +5,27 @@ 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.annotation.Transactional; +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 주기 동기화. * - * 발급 트랜잭션은 hot row contention 회피를 위해 DB remain 을 건드리지 않고 - * Redis 가 단일 진실원 역할을 한다. 관리/통계/콜드 스타트 워밍업이 보는 - * DB remain 을 SoT 와 일치시키기 위해 본 잡이 백그라운드에서 sync. + * 발급은 Redis 가 단일 진실원이라 DB remain 을 건드리지 않는다. 관리/통계 조회와 + * Redis 콜드 스타트 워밍업이 보는 DB 값을 Redis 와 맞추기 위해 본 잡이 sync. * - * Redis 키가 만료/소실된 템플릿(stock == null)은 건드리지 않는다. - * 워밍업 책임은 CouponWarmupRunner 에 있고, 본 잡이 0 으로 덮으면 정합성이 깨진다. - * - * 잡 자체 실패는 다음 주기에 재시도되므로 별도 재시도 큐 없음. + * 함정: + * - Redis 키 만료/소실 시(stock == null) DB 를 0 으로 덮지 않는다. 워밍업이 + * DB remain 으로 Redis 를 다시 채우는 책임을 가지므로 0 으로 덮으면 정합성이 깨진다. + * - 메서드에 @Transactional 을 걸지 않는다. 루프 안의 Redis I/O 동안 DB 커넥션이 + * 점유되어 HikariCP 풀이 압박을 받는다. UPDATE 가 필요한 템플릿만 좁게 묶는다. */ @Slf4j @Component @@ -30,21 +33,43 @@ 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) - @Transactional 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())) { - template.syncRemain(stock); - } + 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 de4b8dd..25759dd 100644 --- a/src/main/java/com/catchtable/coupon/service/CouponService.java +++ b/src/main/java/com/catchtable/coupon/service/CouponService.java @@ -108,8 +108,9 @@ 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(template -> CouponTemplateActiveResponse.from(template, resolveStock(template)))