diff --git a/build.gradle b/build.gradle index 113a24f..f04e3dd 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.kafka:spring-kafka' implementation 'org.springframework.ai:spring-ai-starter-model-google-genai' implementation 'org.jetbrains.kotlin:kotlin-reflect' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e535b37..7ab7361 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,20 +9,10 @@ services: - "8080" # t3.small 메모리 한도 고려한 app 한도 (OTel Agent 80MB 포함 600M) environment: - OTEL_SERVICE_NAME: catchtable-backend - # OTel Collector로 전송 (Collector에서 path 기반 노이즈 필터링 후 Jaeger로 전달) - OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 - OTEL_EXPORTER_OTLP_PROTOCOL: grpc - OTEL_TRACES_EXPORTER: otlp - OTEL_METRICS_EXPORTER: none # 메트릭은 Micrometer/Prometheus로 처리 - OTEL_LOGS_EXPORTER: none # 로그는 Promtail/Loki로 처리 - OTEL_RESOURCE_ATTRIBUTES: deployment.environment=production - # 샘플링 100% — 노이즈 제거는 Collector에서 path 기반으로 처리 - OTEL_TRACES_SAMPLER: parentbased_traceidratio - OTEL_TRACES_SAMPLER_ARG: "1.0" - OTEL_INSTRUMENTATION_SPRING_SCHEDULING_ENABLED: "false" - OTEL_INSTRUMENTATION_LOGBACK_APPENDER_ENABLED: "true" - OTEL_INSTRUMENTATION_LOGBACK_MDC_ENABLED: "true" + # 분산 트레이싱(Jaeger + OTel Collector) 제거 — OTel javaagent SDK 전체 비활성화. + # 메트릭은 Micrometer/Prometheus, 로그는 Promtail/Loki가 담당하므로 트레이싱만 끔. + # 트레이싱 복원 시: 이 줄 제거 후 export/sampler 설정 복구 + otel-collector/jaeger 서비스 재추가. + OTEL_SDK_DISABLED: "true" deploy: resources: limits: @@ -135,50 +125,6 @@ services: networks: - catchtable-net - otel-collector: - image: otel/opentelemetry-collector-contrib:0.152.1 - container_name: catchtable-otel-collector - volumes: - - ./monitoring/otel-collector-config.yml:/etc/otelcol-contrib/config.yaml:ro - command: ["--config=/etc/otelcol-contrib/config.yaml"] - expose: - - "4317" # OTLP gRPC (app에서 전송) - depends_on: - - jaeger - deploy: - resources: - limits: - memory: 150M - restart: unless-stopped - networks: - - catchtable-net - - jaeger: - image: jaegertracing/all-in-one:1.62.0 - container_name: catchtable-jaeger - user: "0" # named volume mkdir 퍼미션 회피 (내부 도구라 보안 영향 없음) - environment: - COLLECTOR_OTLP_ENABLED: "true" - SPAN_STORAGE_TYPE: badger - BADGER_EPHEMERAL: "false" - BADGER_DIRECTORY_VALUE: /badger/data - BADGER_DIRECTORY_KEY: /badger/key - BADGER_SPAN_STORE_TTL: 72h0m0s - volumes: - - jaeger-data:/badger - ports: - - "16686:16686" # Jaeger UI (외부 접근) - expose: - - "4317" # OTLP gRPC (앱에서 전송) - - "4318" # OTLP HTTP - deploy: - resources: - limits: - memory: 400M - restart: unless-stopped - networks: - - catchtable-net - grafana: image: grafana/grafana:11.3.0 container_name: catchtable-grafana @@ -194,7 +140,6 @@ services: depends_on: - prometheus - loki - - jaeger deploy: resources: limits: @@ -277,7 +222,6 @@ networks: volumes: prometheus-data: loki-data: - jaeger-data: grafana-data: promtail-positions: kafka-prod-data: diff --git a/grafana/provisioning/datasources/datasources.yml b/grafana/provisioning/datasources/datasources.yml index 7c5bc1c..f233885 100644 --- a/grafana/provisioning/datasources/datasources.yml +++ b/grafana/provisioning/datasources/datasources.yml @@ -15,16 +15,3 @@ datasources: access: proxy url: http://loki:3100 editable: true - jsonData: - derivedFields: - - datasourceUid: jaeger - matcherRegex: 'trace_id=(\w+)' - name: TraceID - url: '$${__value.raw}' - - - name: Jaeger - type: jaeger - access: proxy - url: http://jaeger:16686 - uid: jaeger - editable: true diff --git a/src/main/java/com/catchtable/coupon/entity/Coupon.java b/src/main/java/com/catchtable/coupon/entity/Coupon.java index 1d8be09..61c515d 100644 --- a/src/main/java/com/catchtable/coupon/entity/Coupon.java +++ b/src/main/java/com/catchtable/coupon/entity/Coupon.java @@ -11,7 +11,15 @@ import java.time.LocalDateTime; @Entity -@Table(name = "coupons") +@Table( + name = "coupons", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_coupons_user_template", + columnNames = {"user_id", "coupon_template_id"} + ) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor diff --git a/src/main/java/com/catchtable/coupon/entity/CouponTemplate.java b/src/main/java/com/catchtable/coupon/entity/CouponTemplate.java index 0f1be86..08a0b02 100644 --- a/src/main/java/com/catchtable/coupon/entity/CouponTemplate.java +++ b/src/main/java/com/catchtable/coupon/entity/CouponTemplate.java @@ -1,7 +1,5 @@ package com.catchtable.coupon.entity; -import com.catchtable.global.exception.CustomException; -import com.catchtable.global.exception.ErrorCode; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; @@ -50,11 +48,4 @@ public class CouponTemplate { @Column(name = "is_deleted", nullable = false) @Builder.Default private Boolean isDeleted = false; - - public void decreaseRemain() { - if (this.remain <= 0) { - throw new CustomException(ErrorCode.COUPON_EXHAUSTED); - } - this.remain--; - } } diff --git a/src/main/java/com/catchtable/coupon/redis/CouponRedisCircuitBreakerLogger.java b/src/main/java/com/catchtable/coupon/redis/CouponRedisCircuitBreakerLogger.java new file mode 100644 index 0000000..22331fd --- /dev/null +++ b/src/main/java/com/catchtable/coupon/redis/CouponRedisCircuitBreakerLogger.java @@ -0,0 +1,40 @@ +package com.catchtable.coupon.redis; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * coupon-redis 회로의 상태 전환을 운영 로그에 남긴다. + * 회로가 OPEN 으로 갔다 → Redis 장애 신호. + * HALF_OPEN → CLOSED 면 복구 완료. + * 사후 추적·알람 룰 작성의 근거가 된다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponRedisCircuitBreakerLogger { + + private static final String INSTANCE = "coupon-redis"; + + private final CircuitBreakerRegistry circuitBreakerRegistry; + + @PostConstruct + void registerEventListeners() { + CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker(INSTANCE); + cb.getEventPublisher() + .onStateTransition(event -> + log.warn("[CircuitBreaker:{}] 상태 전환 {} → {}", + INSTANCE, + event.getStateTransition().getFromState(), + event.getStateTransition().getToState())) + .onCallNotPermitted(event -> + log.debug("[CircuitBreaker:{}] OPEN 상태로 호출 차단됨", INSTANCE)) + .onError(event -> + log.debug("[CircuitBreaker:{}] 호출 실패: {}", + INSTANCE, event.getThrowable().toString())); + } +} diff --git a/src/main/java/com/catchtable/coupon/redis/CouponWarmupRunner.java b/src/main/java/com/catchtable/coupon/redis/CouponWarmupRunner.java new file mode 100644 index 0000000..a9dec20 --- /dev/null +++ b/src/main/java/com/catchtable/coupon/redis/CouponWarmupRunner.java @@ -0,0 +1,57 @@ +package com.catchtable.coupon.redis; + +import com.catchtable.coupon.entity.CouponTemplate; +import com.catchtable.coupon.repository.CouponTemplateRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 서버 부팅 시 발급 가능한 쿠폰 템플릿의 Redis 상태를 점검·복원한다. + * + * 시나리오: + * - Redis 가 재기동되어 coupon:stock:{id} 키가 사라진 경우, + * DB 의 remain 값으로 워밍업하지 않으면 Lua 가 NOT_AVAILABLE 을 반환한다. + * - DB 에는 멀쩡한 템플릿이 있는데 발급 API 가 안 되는 운영 사고로 직결됨. + * + * 안전장치: + * - setIfAbsent 의미라 Redis 가 정상이고 운영 중 차감된 값이 있으면 덮어쓰지 않는다. + * - 따라서 평시 부팅에는 no-op, Redis 콜드 스타트일 때만 실효. + * + * 한계: + * - "Redis 가 죽은 동안 발급된 건수"는 복원 불가. Redis HA + AOF 영속화가 1차 방어선이다. + */ +@Slf4j +@Component +@Order(Integer.MAX_VALUE) // 다른 ApplicationRunner 이후 실행 +@RequiredArgsConstructor +public class CouponWarmupRunner implements ApplicationRunner { + + private final CouponTemplateRepository couponTemplateRepository; + private final RedisCouponIssuer redisCouponIssuer; + + @Override + public void run(ApplicationArguments args) { + List activeTemplates = couponTemplateRepository.findActiveTemplates(LocalDateTime.now()); + if (activeTemplates.isEmpty()) { + log.info("쿠폰 Redis 워밍업: 활성 템플릿 없음, 스킵"); + return; + } + + for (CouponTemplate t : activeTemplates) { + try { + redisCouponIssuer.warmUpIfAbsent(t.getId(), t.getRemain(), t.getExpiredAt()); + } catch (Exception e) { + // 한 템플릿 실패가 다른 템플릿 워밍업을 막지 않게 한다. + log.error("쿠폰 Redis 워밍업 실패: templateId={}", t.getId(), e); + } + } + log.info("쿠폰 Redis 워밍업 완료: 활성 템플릿 {}건 점검", activeTemplates.size()); + } +} diff --git a/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java b/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java new file mode 100644 index 0000000..bc36a4e --- /dev/null +++ b/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java @@ -0,0 +1,152 @@ +package com.catchtable.coupon.redis; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RScript; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.StringCodec; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 선착순 쿠폰 발급의 원자 결정(재고/중복/차감/마킹)을 Redis Lua 로 처리한다. + * + * 격리: + * - Redis 호출은 동기. 1ms 미만으로 끝나는 가벼운 호출이라 별도 executor 가 오히려 비용. + * - 인프라 장애 격리는 @CircuitBreaker 만 사용. 일정 실패율 누적 시 회로 OPEN → 즉시 폴백. + * - 단일 명령 hang 보호는 Redisson 글로벌 timeout(기본 3초)에 위임. + * - 비즈니스 예외(DUPLICATE/EXHAUSTED)는 yaml 의 ignore-exceptions 로 회로 카운트에서 제외. + * + * 발급자 SET TTL 동기화: + * - 빈 SET 에 미리 EXPIREAT 을 거는 방식은 Redis 가 무시하므로 메모리 누수가 발생한다. + * - 따라서 Lua 스크립트가 SADD 직후 stock 키의 TTL 을 읽어 issued SET 에 동기화한다. + * - 자바 코드에서는 stock 키만 TTL 을 갖도록 관리하고 issued SET 의 TTL 은 건드리지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisCouponIssuer { + + private static final String STOCK_KEY_PREFIX = "coupon:stock:"; + private static final String ISSUED_KEY_PREFIX = "coupon:issued:"; + private static final String RESILIENCE_INSTANCE = "coupon-redis"; + + private final RedissonClient redissonClient; + + private String issueLuaScript; + + @PostConstruct + void loadScript() { + try (var in = new ClassPathResource("lua/issue_coupon.lua").getInputStream()) { + this.issueLuaScript = StreamUtils.copyToString(in, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("issue_coupon.lua 로드 실패", e); + } + } + + /** + * 발급 시도. 동기 호출. + * Redis 인프라 장애는 회로가 OPEN 되어 fallback 으로 UNAVAILABLE 반환. + */ + @CircuitBreaker(name = RESILIENCE_INSTANCE, fallbackMethod = "tryIssueFallback") + public IssueResult tryIssue(Long templateId, Long userId) { + Long code = redissonClient + .getScript(StringCodec.INSTANCE) + .eval( + RScript.Mode.READ_WRITE, + issueLuaScript, + RScript.ReturnType.INTEGER, + List.of(stockKey(templateId), issuedKey(templateId)), + String.valueOf(userId) + ); + return IssueResult.of(code); + } + + /** + * Circuit Breaker 폴백. + * 비즈니스 예외는 ignore-exceptions 로 걸러져 여기엔 인프라 장애만 들어온다. + */ + @SuppressWarnings("unused") // Resilience4j 가 리플렉션으로 호출 + private IssueResult tryIssueFallback(Long templateId, Long userId, Throwable t) { + log.warn("쿠폰 발급 Redis 호출 실패. templateId={}, userId={}, cause={}", + templateId, userId, t.toString()); + return IssueResult.UNAVAILABLE; + } + + /** + * Lua 통과 후 DB INSERT 가 실패한 경우의 보상. + * stock 을 1 복구하고 발급자 SET 에서 userId 를 제거한다. + * + * 주의: best-effort. 보상 자체가 실패하면 재고 1건이 영구 소실될 수 있다. + * 운영에서는 별도 알람 + 정합성 점검 잡으로 보완. + */ + public void compensate(Long templateId, Long userId) { + redissonClient + .getScript(StringCodec.INSTANCE) + .eval( + RScript.Mode.READ_WRITE, + "if redis.call('EXISTS', KEYS[1]) == 1 then redis.call('INCR', KEYS[1]) end; " + + "redis.call('SREM', KEYS[2], ARGV[1]); return 1", + RScript.ReturnType.INTEGER, + List.of(stockKey(templateId), issuedKey(templateId)), + String.valueOf(userId) + ); + } + + /** + * 템플릿 생성 직후 호출. stock 키만 만료시각까지 살려둔다. + * issued SET 은 Lua 스크립트가 SADD 시점에 stock TTL 로 동기화한다. + */ + public void warmUp(Long templateId, Integer remain, LocalDateTime expiredAt) { + long ttlSeconds = ttlSeconds(expiredAt); + var stock = redissonClient.getBucket(stockKey(templateId), StringCodec.INSTANCE); + stock.set(String.valueOf(remain), Duration.ofSeconds(ttlSeconds)); + } + + /** + * 서버 부팅 시 호출. 이미 키가 있으면(Redis 운영 중 상태 유지 중) 건드리지 않는다. + * Redis 콜드 스타트일 때만 DB 의 remain 으로 워밍업. + */ + public void warmUpIfAbsent(Long templateId, Integer remain, LocalDateTime expiredAt) { + long ttlSeconds = ttlSeconds(expiredAt); + var stock = redissonClient.getBucket(stockKey(templateId), StringCodec.INSTANCE); + boolean inserted = stock.setIfAbsent(String.valueOf(remain), Duration.ofSeconds(ttlSeconds)); + if (inserted) { + log.info("Redis 콜드 스타트 워밍업: templateId={}, remain={}", templateId, remain); + } + } + + private static long ttlSeconds(LocalDateTime expiredAt) { + return Math.max(Duration.between(LocalDateTime.now(), expiredAt).getSeconds(), 60L); + } + + private static String stockKey(Long templateId) { + return STOCK_KEY_PREFIX + templateId; + } + + private static String issuedKey(Long templateId) { + return ISSUED_KEY_PREFIX + templateId; + } + + public enum IssueResult { + SUCCESS, DUPLICATE, EXHAUSTED, NOT_AVAILABLE, UNAVAILABLE; + + static IssueResult of(Long code) { + if (code == null) return NOT_AVAILABLE; + return switch (code.intValue()) { + case 1 -> SUCCESS; + case 0 -> DUPLICATE; + case -1 -> EXHAUSTED; + default -> NOT_AVAILABLE; + }; + } + } +} diff --git a/src/main/java/com/catchtable/coupon/repository/CouponTemplateRepository.java b/src/main/java/com/catchtable/coupon/repository/CouponTemplateRepository.java index e26df52..eab4d2a 100644 --- a/src/main/java/com/catchtable/coupon/repository/CouponTemplateRepository.java +++ b/src/main/java/com/catchtable/coupon/repository/CouponTemplateRepository.java @@ -1,22 +1,15 @@ package com.catchtable.coupon.repository; import com.catchtable.coupon.entity.CouponTemplate; -import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; public interface CouponTemplateRepository extends JpaRepository { - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT ct FROM CouponTemplate ct WHERE ct.id = :id AND ct.isDeleted = false") - Optional findByIdWithLock(@Param("id") Long id); - // 현재 발급 가능한 쿠폰 템플릿 (시작 시각 도래 + 만료 전 + 잔여 수량 > 0) // 정렬: 곧 만료되는 것 우선 @Query(""" diff --git a/src/main/java/com/catchtable/coupon/service/CouponService.java b/src/main/java/com/catchtable/coupon/service/CouponService.java index 8d57884..0ae1a61 100644 --- a/src/main/java/com/catchtable/coupon/service/CouponService.java +++ b/src/main/java/com/catchtable/coupon/service/CouponService.java @@ -8,6 +8,8 @@ import com.catchtable.coupon.entity.Coupon; import com.catchtable.coupon.entity.CouponStatus; import com.catchtable.coupon.entity.CouponTemplate; +import com.catchtable.coupon.redis.RedisCouponIssuer; +import com.catchtable.coupon.redis.RedisCouponIssuer.IssueResult; import com.catchtable.coupon.repository.CouponRepository; import com.catchtable.coupon.repository.CouponTemplateRepository; import com.catchtable.global.exception.CustomException; @@ -20,6 +22,7 @@ import org.springframework.ai.chat.model.ToolContext; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import java.util.List; @@ -33,37 +36,68 @@ public class CouponService { private final CouponRepository couponRepository; private final CouponTemplateRepository couponTemplateRepository; private final UserRepository userRepository; + private final RedisCouponIssuer redisCouponIssuer; + private final TransactionTemplate transactionTemplate; - // 쿠폰 템플릿 생성 + // 쿠폰 템플릿 생성 + Redis 재고 워밍업 @Transactional public CouponTemplateCreateResponse createTemplate(Long userId, CouponTemplateCreateRequest request) { userRepository.getAdminOrThrow(userId, ErrorCode.ADMIN_ONLY_COUPON_CREATE); CouponTemplate template = request.toEntity(); CouponTemplate saved = couponTemplateRepository.save(template); + + // Lua가 참조할 재고 카운터를 만료 시각까지 유지. + // 발급자 SET 의 TTL 은 Lua 스크립트가 SADD 시점에 stock TTL 로 동기화한다. + redisCouponIssuer.warmUp(saved.getId(), saved.getRemain(), saved.getExpiredAt()); return CouponTemplateCreateResponse.from(saved); } - // 쿠폰 발급 (비관적 락) - @Transactional + /** + * 선착순 발급. + * + * 트랜잭션 범위: + * - Redis 호출은 트랜잭션 밖에서. 트랜잭션 안에서 네트워크 I/O 를 호출하면 + * DB 커넥션이 Redis 응답 대기 동안 점유되어 HikariCP 풀이 빠르게 고갈된다. + * - DB INSERT 만 TransactionTemplate 으로 짧게 묶는다. + * + * 1) Redis Lua: 재고/중복/차감/SADD 를 단일 EVAL 로 원자 결정. + * 2) Lua 통과 = 발급 확정. 짧은 트랜잭션에서 coupons row 1건 INSERT. + * 3) DB INSERT 실패 시 Redis 상태 보상(stock INCR + issued SREM). + */ public CouponIssueResponse issueCoupon(Long templateId, Long userId) { - User user = userRepository.getById(userId); - - CouponTemplate template = couponTemplateRepository.findByIdWithLock(templateId) - .orElseThrow(() -> new CustomException(ErrorCode.COUPON_TEMPLATE_NOT_FOUND)); - - if (couponRepository.existsByUserIdAndCouponTemplateId(userId, templateId)) { - throw new CustomException(ErrorCode.DUPLICATE_COUPON); + IssueResult result = redisCouponIssuer.tryIssue(templateId, userId); + switch (result) { + case DUPLICATE -> throw new CustomException(ErrorCode.DUPLICATE_COUPON); + case EXHAUSTED -> throw new CustomException(ErrorCode.COUPON_EXHAUSTED); + case NOT_AVAILABLE -> throw new CustomException(ErrorCode.COUPON_TEMPLATE_NOT_FOUND); + case UNAVAILABLE -> throw new CustomException(ErrorCode.COUPON_ISSUE_TEMPORARILY_UNAVAILABLE); + case SUCCESS -> { /* fall through */ } } - template.decreaseRemain(); - - Coupon coupon = Coupon.builder() - .user(user) - .couponTemplate(template) - .build(); - - Coupon saved = couponRepository.save(coupon); - return CouponIssueResponse.from(saved); + try { + return transactionTemplate.execute(status -> { + // 응답에 user 필드 미사용 — SELECT 생략 위해 프록시 사용. + User user = userRepository.getReferenceById(userId); + CouponTemplate template = couponTemplateRepository.findById(templateId) + .orElseThrow(() -> new CustomException(ErrorCode.COUPON_TEMPLATE_NOT_FOUND)); + + Coupon saved = couponRepository.save( + Coupon.builder() + .user(user) + .couponTemplate(template) + .build() + ); + return CouponIssueResponse.from(saved); + }); + } catch (RuntimeException e) { + // Redis 는 발급 확정 상태인데 DB INSERT 실패 → 보상. + try { + redisCouponIssuer.compensate(templateId, userId); + } catch (Exception compEx) { + log.error("쿠폰 발급 Redis 보상 실패. templateId={}, userId={}", templateId, userId, compEx); + } + throw e; + } } // 내 쿠폰 목록 조회 diff --git a/src/main/java/com/catchtable/global/config/KafkaConfig.java b/src/main/java/com/catchtable/global/config/KafkaConfig.java index 5b724e7..5f75cb4 100644 --- a/src/main/java/com/catchtable/global/config/KafkaConfig.java +++ b/src/main/java/com/catchtable/global/config/KafkaConfig.java @@ -10,6 +10,9 @@ 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; @@ -36,9 +39,19 @@ public ProducerFactory producerFactory() { } @Bean - public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(KafkaTemplate kafkaTemplate) { 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; } diff --git a/src/main/java/com/catchtable/global/exception/ErrorCode.java b/src/main/java/com/catchtable/global/exception/ErrorCode.java index e48af03..d56789d 100644 --- a/src/main/java/com/catchtable/global/exception/ErrorCode.java +++ b/src/main/java/com/catchtable/global/exception/ErrorCode.java @@ -42,6 +42,7 @@ public enum ErrorCode implements ResponseCode { COUPON_EXPIRED(HttpStatus.BAD_REQUEST, "만료된 쿠폰입니다."), COUPON_NOT_RETURNABLE(HttpStatus.BAD_REQUEST, "사용된 쿠폰만 반환할 수 있습니다."), COUPON_EXHAUSTED(HttpStatus.BAD_REQUEST, "쿠폰이 모두 소진되었습니다."), + COUPON_ISSUE_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "쿠폰 발급이 일시적으로 불가합니다. 잠시 후 다시 시도해주세요."), // Menu MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 메뉴입니다."), diff --git a/src/main/java/com/catchtable/global/exception/GlobalExceptionHandler.java b/src/main/java/com/catchtable/global/exception/GlobalExceptionHandler.java index efc4f33..f486311 100644 --- a/src/main/java/com/catchtable/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/catchtable/global/exception/GlobalExceptionHandler.java @@ -1,21 +1,30 @@ package com.catchtable.global.exception; import com.catchtable.global.common.ApiResponse; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -@RestControllerAdvice +// basePackages 제한: 액추에이터(/actuator/prometheus 등 org.springframework.boot.actuate.*) +// 엔드포인트의 예외까지 이 advice가 가로채면, ApiResponse(JSON)를 openmetrics 응답으로 +// 직렬화하려다 HttpMessageNotWritableException → 스크랩 500 → 모니터링 DOWN 오표시가 발생. +// 본인 컨트롤러(com.catchtable.*)로만 적용 범위를 한정해 액추에이터를 건드리지 않게 한다. +@RestControllerAdvice(basePackages = "com.catchtable") public class GlobalExceptionHandler { // CustomException 통합 처리 (403, 404, 400 등 ErrorCode 기반) @ExceptionHandler(CustomException.class) protected ResponseEntity> handleCustomException(CustomException e) { ErrorCode errorCode = e.getErrorCode(); - return ResponseEntity - .status(errorCode.getHttpStatus()) - .body(ApiResponse.error(errorCode)); + ResponseEntity.BodyBuilder builder = ResponseEntity.status(errorCode.getHttpStatus()); + // 일시적 장애(503)는 Retry-After 헤더로 클라이언트 자동 재시도 안내. + // 회로 wait-duration-in-open-state(10s) 와 동일 값을 사용. + if (errorCode == ErrorCode.COUPON_ISSUE_TEMPORARILY_UNAVAILABLE) { + builder.header(HttpHeaders.RETRY_AFTER, "10"); + } + return builder.body(ApiResponse.error(errorCode)); } // 400 - 입력값 검증 실패 (@Valid 에러) diff --git a/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java index deb0e81..d9c17aa 100644 --- a/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java +++ b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java @@ -6,31 +6,48 @@ import com.catchtable.notification.event.ReservationConfirmedEvent; import com.catchtable.notification.event.ReservationVisitedEvent; import com.catchtable.notification.event.VacancyEvent; +import com.catchtable.remain.entity.StoreRemain; +import com.catchtable.remain.repository.StoreRemainRepository; import com.catchtable.user.entity.User; import com.catchtable.user.repository.UserRepository; +import com.catchtable.vacancy.service.VacancyService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; @Slf4j @Service @RequiredArgsConstructor public class NotificationKafkaConsumer { + private static final Duration COOLDOWN_DURATION = Duration.ofMinutes(5); + private final NotificationService notificationService; private final UserRepository userRepository; + private final StoreRemainRepository storeRemainRepository; + private final StringRedisTemplate redisTemplate; + private final VacancyService vacancyService; + @RetryableTopic(attempts = "3", dltStrategy = DltStrategy.ALWAYS_RETRY_ON_ERROR) @KafkaListener(topics = "notification.reservation.confirmed", groupId = "catchtable-notification-group") @Transactional public void handleReservationConfirmed(@Payload ReservationConfirmedEvent event) { log.info("[Kafka Consumer] 예약 확정 이벤트 수신: reservationId={}", event.getReservationId()); - Optional userOpt = findUser(event.getUserId()); - if (userOpt.isEmpty()) return; + User user = findUserOrThrow(event.getUserId()); String title = "예약이 확정되었습니다."; String content = String.format("'%s' 매장 %s %s 예약이 확정되었습니다.", @@ -39,7 +56,7 @@ public void handleReservationConfirmed(@Payload ReservationConfirmedEvent event) event.getRemainTime()); notificationService.createNotification( - userOpt.get(), + user, NotificationType.RESERVATION_CONFIRMED, title, content, @@ -47,12 +64,12 @@ public void handleReservationConfirmed(@Payload ReservationConfirmedEvent event) ); } + @RetryableTopic(attempts = "3", dltStrategy = DltStrategy.ALWAYS_RETRY_ON_ERROR) @KafkaListener(topics = "notification.reservation.canceled", groupId = "catchtable-notification-group") @Transactional public void handleReservationCanceled(@Payload ReservationCanceledEvent event) { log.info("[Kafka Consumer] 예약 취소 이벤트 수신: reservationId={}", event.getReservationId()); - Optional userOpt = findUser(event.getUserId()); - if (userOpt.isEmpty()) return; + User user = findUserOrThrow(event.getUserId()); String title = "예약이 취소되었습니다."; String content = String.format("'%s' 매장 %s %s 예약이 취소되었습니다.", @@ -61,7 +78,7 @@ public void handleReservationCanceled(@Payload ReservationCanceledEvent event) { event.getRemainTime()); notificationService.createNotification( - userOpt.get(), + user, NotificationType.RESERVATION_CANCELED, title, content, @@ -69,12 +86,12 @@ public void handleReservationCanceled(@Payload ReservationCanceledEvent event) { ); } + @RetryableTopic(attempts = "3", dltStrategy = DltStrategy.ALWAYS_RETRY_ON_ERROR) @KafkaListener(topics = "notification.reservation.changed", groupId = "catchtable-notification-group") @Transactional public void handleReservationChanged(@Payload ReservationChangedEvent event) { log.info("[Kafka Consumer] 예약 변경 이벤트 수신: newReservationId={}", event.getNewReservationId()); - Optional userOpt = findUser(event.getUserId()); - if (userOpt.isEmpty()) return; + User user = findUserOrThrow(event.getUserId()); String title = "예약이 변경되었습니다."; String content = String.format("'%s' 매장 예약이 %s %s에서 %s %s으로 변경되었습니다.", @@ -85,7 +102,7 @@ public void handleReservationChanged(@Payload ReservationChangedEvent event) { event.getNewRemainTime()); notificationService.createNotification( - userOpt.get(), + user, NotificationType.RESERVATION_CHANGED, title, content, @@ -93,19 +110,19 @@ public void handleReservationChanged(@Payload ReservationChangedEvent event) { ); } + @RetryableTopic(attempts = "3", dltStrategy = DltStrategy.ALWAYS_RETRY_ON_ERROR) @KafkaListener(topics = "notification.reservation.visited", groupId = "catchtable-notification-group") @Transactional public void handleReservationVisited(@Payload ReservationVisitedEvent event) { log.info("[Kafka Consumer] 방문 완료 이벤트 수신: reservationId={}", event.getReservationId()); - Optional userOpt = findUser(event.getUserId()); - if (userOpt.isEmpty()) return; + User user = findUserOrThrow(event.getUserId()); String title = "방문은 즐거우셨나요?"; String content = String.format("'%s' 매장 방문이 완료되었습니다. 소중한 경험을 리뷰로 남겨주세요!", event.getStoreName()); notificationService.createNotification( - userOpt.get(), + user, NotificationType.RESERVATION_VISITED, title, content, @@ -113,25 +130,67 @@ public void handleReservationVisited(@Payload ReservationVisitedEvent event) { ); } + @RetryableTopic(attempts = "3", dltStrategy = DltStrategy.ALWAYS_RETRY_ON_ERROR) @KafkaListener(topics = "notification.vacancy.opened", groupId = "catchtable-notification-group") @Transactional public void handleVacancyOpened(@Payload VacancyEvent event) { - // 이 부분은 Redis 도입 후, Redis에서 구독자 목록을 가져와서 처리해야 합니다. - // 현재는 구현하지 않고 로그만 남깁니다. log.info("[Kafka Consumer] 빈자리 발생 이벤트 수신: remainId={}", event.getRemainId()); - // TODO: Redis에서 remainId에 해당하는 구독자(userId) 목록 조회 - // TODO: 각 구독자에게 알림 생성 (notificationService.createNotification) - } - /** - * 사용자 조회. 없으면 빈 Optional 반환 + 로그만 남기고 메시지 정상 소모. - * 예외를 던지면 Kafka가 무한 재시도하므로 (존재하지 않는 사용자는 재시도해도 성공 못 함) drop. - */ - private Optional findUser(Long userId) { - Optional user = userRepository.findById(userId); - if (user.isEmpty()) { - log.warn("[Kafka Consumer] 사용자 정보 없음, 메시지 스킵: userId={}", userId); + // 1. 쿨다운 키 확인 + String cooldownKey = "cooldown:vacancy:" + event.getRemainId(); + Boolean isNew = redisTemplate.opsForValue().setIfAbsent(cooldownKey, "locked", COOLDOWN_DURATION); + + if (Boolean.FALSE.equals(isNew)) { + log.info("[Kafka Consumer] 쿨다운 적용 중, 빈자리 알림을 건너뜁니다. remainId={}", event.getRemainId()); + return; + } + + StoreRemain storeRemain = storeRemainRepository.findById(event.getRemainId()).orElse(null); + if (storeRemain == null) { + log.warn("[Kafka Consumer] 빈자리 알림 처리 실패: 존재하지 않는 remainId={}", event.getRemainId()); + return; + } + + String redisKey = vacancyService.generateRedisKey(storeRemain); + Set subscriberIds = redisTemplate.opsForSet().members(redisKey); + + if (subscriberIds == null || subscriberIds.isEmpty()) { + log.info("[Kafka Consumer] 빈자리 알림 구독자가 없습니다. key={}", redisKey); + return; } - return user; + + List userIds = subscriberIds.stream().map(Long::parseLong).collect(Collectors.toList()); + List users = userRepository.findAllById(userIds); + + String title = "빈자리 알림"; + String content = String.format("'%s' 매장 %s %s에 빈자리가 발생했습니다! 지금 바로 예약하세요.", + storeRemain.getStore().getStoreName(), + storeRemain.getRemainDate(), + storeRemain.getRemainTime()); + + for (User user : users) { + notificationService.createNotification( + user, + NotificationType.VACANCY, + title, + content, + storeRemain.getStore().getId() + ); + } + + log.info("[Kafka Consumer] {}명에게 빈자리 알림을 생성했습니다. key={}", users.size(), redisKey); + } + + @DltHandler + public void handleDlt(Object message, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) { + log.error("[DLT] 메시지 처리 최종 실패. Topic: {}, Message: {}", topic, message.toString()); + } + + private User findUserOrThrow(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> { + log.error("[Kafka Consumer] 사용자 정보를 찾을 수 없습니다. userId={}", userId); + return new RuntimeException("사용자를 찾을 수 없음: " + userId); + }); } } diff --git a/src/main/java/com/catchtable/vacancy/service/VacancyService.java b/src/main/java/com/catchtable/vacancy/service/VacancyService.java index c6a2c1f..e3c14e9 100644 --- a/src/main/java/com/catchtable/vacancy/service/VacancyService.java +++ b/src/main/java/com/catchtable/vacancy/service/VacancyService.java @@ -11,11 +11,17 @@ import com.catchtable.vacancy.entity.Vacancy; import com.catchtable.vacancy.repository.VacancyRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; import java.util.List; +@Slf4j @Service @RequiredArgsConstructor public class VacancyService { @@ -23,6 +29,7 @@ public class VacancyService { private final VacancyRepository vacancyRepository; private final StoreRemainRepository storeRemainRepository; private final UserRepository userRepository; + private final StringRedisTemplate redisTemplate; @Transactional public Long register(Long userId, Long remainId) { @@ -41,7 +48,26 @@ public Long register(Long userId, Long remainId) { } Vacancy vacancy = new Vacancy(user, storeRemain); - return vacancyRepository.save(vacancy).getId(); + Vacancy savedVacancy = vacancyRepository.save(vacancy); + + // Redis에 구독자 정보 저장 (wait:store:{storeId}:{date}:{time}) + String redisKey = generateRedisKey(storeRemain); + try { + redisTemplate.opsForSet().add(redisKey, String.valueOf(userId)); + + // TTL 설정: 예약 시간 + 1시간 + LocalDateTime expireTime = LocalDateTime.of(storeRemain.getRemainDate(), storeRemain.getRemainTime()).plusHours(1); + Date expireDate = Date.from(expireTime.atZone(ZoneId.systemDefault()).toInstant()); + redisTemplate.expireAt(redisKey, expireDate); + + log.info("[빈자리 알림 등록] Redis SADD 완료: key={}, userId={}", redisKey, userId); + } catch (Exception e) { + log.error("[빈자리 알림 등록] Redis 저장 실패: key={}, userId={}", redisKey, userId, e); + // Redis 저장이 실패하더라도 DB 저장은 유지하도록 예외를 던지지 않습니다. + // 필요에 따라 예외를 던져 트랜잭션을 롤백할 수도 있습니다. + } + + return savedVacancy.getId(); } @Transactional(readOnly = true) @@ -68,6 +94,24 @@ public Long delete(Long vacancyId, Long userId) { throw new CustomException(ErrorCode.FORBIDDEN); } vacancy.delete(); + + // Redis에서 구독자 정보 제거 + StoreRemain storeRemain = vacancy.getStoreRemain(); + String redisKey = generateRedisKey(storeRemain); + try { + redisTemplate.opsForSet().remove(redisKey, String.valueOf(userId)); + log.info("[빈자리 알림 취소] Redis SREM 완료: key={}, userId={}", redisKey, userId); + } catch (Exception e) { + log.error("[빈자리 알림 취소] Redis 제거 실패: key={}, userId={}", redisKey, userId, e); + } + return vacancy.getId(); } + + public String generateRedisKey(StoreRemain storeRemain) { + return String.format("wait:store:%d:%s:%s", + storeRemain.getStore().getId(), + storeRemain.getRemainDate(), + storeRemain.getRemainTime()); + } } diff --git a/src/main/resources/lua/issue_coupon.lua b/src/main/resources/lua/issue_coupon.lua new file mode 100644 index 0000000..32814a1 --- /dev/null +++ b/src/main/resources/lua/issue_coupon.lua @@ -0,0 +1,36 @@ +-- 선착순 쿠폰 발급 원자 처리 +-- KEYS[1] = coupon:stock:{templateId} (재고 INTEGER, TTL 보유) +-- KEYS[2] = coupon:issued:{templateId} (발급 완료 userId SET) +-- ARGV[1] = userId +-- +-- 반환: +-- 1 = 발급 성공 +-- 0 = 중복 발급 (이미 받은 사용자) +-- -1 = 재고 소진 +-- -2 = 템플릿 미존재 / 미오픈 / 만료 (stock 키 없음) + +if redis.call('EXISTS', KEYS[1]) == 0 then + return -2 +end + +if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then + return 0 +end + +local stock = tonumber(redis.call('GET', KEYS[1])) +if stock == nil or stock <= 0 then + return -1 +end + +redis.call('DECR', KEYS[1]) +redis.call('SADD', KEYS[2], ARGV[1]) + +-- 빈 SET 에 미리 EXPIREAT 을 거는 방식은 Redis 가 무시하므로(존재하지 않는 키), +-- SADD 직후 stock 키의 남은 TTL 로 issued SET 의 만료 시각을 동기화한다. +-- 이렇게 하면 만료된 쿠폰의 발급자 목록이 메모리에 영구 잔류하는 누수를 막을 수 있다. +local stockTtl = redis.call('TTL', KEYS[1]) +if stockTtl > 0 then + redis.call('EXPIRE', KEYS[2], stockTtl) +end + +return 1 diff --git a/src/test/java/com/catchtable/coupon/concurrency/CouponConcurrencyTest.java b/src/test/java/com/catchtable/coupon/concurrency/CouponConcurrencyTest.java index cd8703e..a513a62 100644 --- a/src/test/java/com/catchtable/coupon/concurrency/CouponConcurrencyTest.java +++ b/src/test/java/com/catchtable/coupon/concurrency/CouponConcurrencyTest.java @@ -2,6 +2,7 @@ import com.catchtable.coupon.entity.Coupon; import com.catchtable.coupon.entity.CouponTemplate; +import com.catchtable.coupon.redis.RedisCouponIssuer; import com.catchtable.coupon.repository.CouponRepository; import com.catchtable.coupon.repository.CouponTemplateRepository; import com.catchtable.coupon.service.CouponService; @@ -10,9 +11,10 @@ import com.catchtable.user.repository.UserRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.StringCodec; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -28,41 +30,39 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * 쿠폰 발급 비관적 락(PESSIMISTIC_WRITE) 동시성 검증. - * - 잔여 N개 쿠폰을 더 많은 사용자(M명, M > N)가 동시에 발급 시도 - * - 락이 정상 동작하면 정확히 N건만 성공해야 한다. - * - 실패한 요청은 CouponTemplate.decreaseRemain() 에서 COUPON_EXHAUSTED 예외를 던진다. + * 선착순 쿠폰 발급 동시성 검증 (Redis Lua + 즉시 DB INSERT). + * + * 시나리오: + * - 잔여 STOCK 개를 CONCURRENT 명(CONCURRENT > STOCK)이 동시 발급 시도 + * - Redis Lua 가 정확히 STOCK 건만 통과시켜야 한다 (재고 0 + 발급자 SET size = STOCK). + * - 통과한 요청은 trans안에서 coupons row 1건 INSERT 한다 → DB row count == STOCK. + * - 초과 요청은 EXHAUSTED 예외로 떨어진다. + * + * 전제: + * - docker compose -f docker-compose.dev.yml up -d (DB + Redis) 가 실행 중이어야 한다. * * 주의: - * - @Transactional 을 클래스/메서드에 붙이면 안 된다. - * 각 스레드가 별도 트랜잭션 안에서 락을 잡아야 검증이 가능하다. - * - 테스트 종료 후 cleanup() 으로 직접 데이터를 지운다. + * - 클래스/메서드에 @Transactional 을 붙이면 안 된다. + * 각 스레드가 독립 트랜잭션 안에서 발급해야 동시성 검증이 의미를 갖는다. */ -@Disabled("사전 부채: 머지 전 develop에서도 깨진 테스트. 별도 PR로 점검·재작성 예정.") @SpringBootTest class CouponConcurrencyTest { - @Autowired - private CouponService couponService; - - @Autowired - private CouponTemplateRepository couponTemplateRepository; - - @Autowired - private CouponRepository couponRepository; - - @Autowired - private UserRepository userRepository; + @Autowired private CouponService couponService; + @Autowired private CouponTemplateRepository couponTemplateRepository; + @Autowired private CouponRepository couponRepository; + @Autowired private UserRepository userRepository; + @Autowired private RedisCouponIssuer redisCouponIssuer; + @Autowired private RedissonClient redissonClient; private CouponTemplate template; private List users; - private static final int STOCK = 5; // 잔여 수량 - private static final int CONCURRENT = 10; // 동시 요청 수 + private static final int STOCK = 100; // 잔여 수량 + private static final int CONCURRENT = 300; // 동시 요청 수 @BeforeEach void setUp() { - // 쿠폰 템플릿 (잔여 5개) template = couponTemplateRepository.save( CouponTemplate.builder() .couponName("동시성 테스트 쿠폰") @@ -74,7 +74,10 @@ void setUp() { .build() ); - // 사용자 10명 (각자 unique email/nickname/googleId) + // Redis 워밍업: 재고 카운터/발급자 SET 셋업. + // 운영 경로는 createTemplate 안에서 자동 호출되지만, 테스트는 엔티티만 직접 save 했으므로 명시 호출. + redisCouponIssuer.warmUp(template.getId(), template.getRemain(), template.getExpiredAt()); + long suffix = System.currentTimeMillis(); users = new ArrayList<>(); for (int i = 0; i < CONCURRENT; i++) { @@ -90,23 +93,29 @@ void setUp() { @AfterEach void cleanUp() { - // 테스트로 생성된 쿠폰만 정리 (운영 데이터 보호 — deleteAllInBatch 절대 금지). // 외래키 의존 순서: coupon → user / coupon_template + // 운영 데이터 보호를 위해 deleteAllInBatch 절대 사용 금지. 테스트로 생성한 row 만 정리. List couponsToDelete = users.stream() .flatMap(u -> couponRepository.findAllByUserId(u.getId()).stream()) .toList(); couponRepository.deleteAll(couponsToDelete); userRepository.deleteAll(users); couponTemplateRepository.delete(template); + + // Redis 키 정리 + redissonClient.getBucket("coupon:stock:" + template.getId(), StringCodec.INSTANCE).delete(); + redissonClient.getSet("coupon:issued:" + template.getId(), StringCodec.INSTANCE).delete(); } @Test - @DisplayName("재고 5개 쿠폰을 10명이 동시 발급해도 정확히 5건만 성공한다 (비관적 락 검증)") + @DisplayName("재고 100개 쿠폰을 300명이 동시 발급해도 정확히 100건만 성공한다") void issueCoupon_concurrent() throws InterruptedException { + // 풀 크기 < CONCURRENT 면 큐에 대기 중인 task 가 ready.countDown 을 호출하지 못해 + // ready.await 가 영원히 풀리지 않는다. 풀 크기는 반드시 CONCURRENT 와 같아야 한다. ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT); - CountDownLatch ready = new CountDownLatch(CONCURRENT); // 모든 스레드 준비 완료 대기 - CountDownLatch start = new CountDownLatch(1); // 동시 시작 트리거 - CountDownLatch done = new CountDownLatch(CONCURRENT); // 모든 스레드 종료 대기 + CountDownLatch ready = new CountDownLatch(CONCURRENT); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(CONCURRENT); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failureCount = new AtomicInteger(0); @@ -115,14 +124,12 @@ void issueCoupon_concurrent() throws InterruptedException { executor.submit(() -> { try { ready.countDown(); - start.await(); // 동시 발사 + start.await(); couponService.issueCoupon(template.getId(), user.getId()); successCount.incrementAndGet(); } catch (CustomException e) { - // COUPON_EXHAUSTED 등 비즈니스 예외는 실패로 카운트 failureCount.incrementAndGet(); } catch (Exception e) { - // 락/JPA 충돌 등 예상 외 예외도 실패로 묶어서 가시화 failureCount.incrementAndGet(); } finally { done.countDown(); @@ -130,23 +137,28 @@ void issueCoupon_concurrent() throws InterruptedException { }); } - ready.await(); // 모든 스레드가 start 대기 상태가 될 때까지 기다림 - start.countDown(); // 동시 시작 - boolean finished = done.await(30, TimeUnit.SECONDS); + ready.await(); + start.countDown(); + boolean finished = done.await(60, TimeUnit.SECONDS); executor.shutdown(); - assertThat(finished).as("30초 안에 모든 스레드 종료").isTrue(); + assertThat(finished).as("60초 안에 모든 스레드 종료").isTrue(); assertThat(successCount.get()).as("성공 건수는 재고와 같아야 함").isEqualTo(STOCK); - assertThat(failureCount.get()).as("실패 건수").isEqualTo(CONCURRENT - STOCK); + assertThat(failureCount.get()).as("실패 건수 = 초과 요청 수").isEqualTo(CONCURRENT - STOCK); + + // Redis 상태 검증 + String stock = (String) redissonClient + .getBucket("coupon:stock:" + template.getId(), StringCodec.INSTANCE).get(); + assertThat(stock).as("Redis 재고 = 0").isEqualTo("0"); - // DB 상태 검증 - CouponTemplate refreshed = couponTemplateRepository.findById(template.getId()).orElseThrow(); - assertThat(refreshed.getRemain()).as("템플릿 잔여 = 0").isZero(); + int issuedSize = redissonClient + .getSet("coupon:issued:" + template.getId(), StringCodec.INSTANCE).size(); + assertThat(issuedSize).as("Redis 발급자 SET size = 재고").isEqualTo(STOCK); - // 우리가 만든 user들 기준으로만 카운트 (운영 데이터 영향 배제) + // DB 상태 검증 — 테스트 user 한정 long issuedToTestUsers = users.stream() .mapToLong(u -> couponRepository.findAllByUserId(u.getId()).size()) .sum(); - assertThat(issuedToTestUsers).as("테스트 user들에게 발급된 쿠폰 = 재고").isEqualTo(STOCK); + assertThat(issuedToTestUsers).as("DB coupons row = 재고").isEqualTo(STOCK); } } diff --git a/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java b/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java index dd36b36..41ae846 100644 --- a/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java +++ b/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java @@ -3,6 +3,8 @@ import com.catchtable.coupon.entity.Coupon; import com.catchtable.coupon.entity.CouponStatus; import com.catchtable.coupon.entity.CouponTemplate; +import com.catchtable.coupon.redis.RedisCouponIssuer; +import com.catchtable.coupon.redis.RedisCouponIssuer.IssueResult; import com.catchtable.coupon.repository.CouponRepository; import com.catchtable.coupon.repository.CouponTemplateRepository; import com.catchtable.global.exception.CustomException; @@ -10,12 +12,16 @@ import com.catchtable.user.entity.User; import com.catchtable.user.entity.UserRole; import com.catchtable.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import java.util.Optional; @@ -23,6 +29,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class CouponServiceTest { @@ -36,9 +43,27 @@ class CouponServiceTest { @Mock private UserRepository userRepository; + @Mock + private RedisCouponIssuer redisCouponIssuer; + + @Mock + private TransactionTemplate transactionTemplate; + @InjectMocks private CouponService couponService; + @BeforeEach + void stubTransactionTemplate() { + // CouponService 가 TransactionTemplate.execute() 로 짧은 트랜잭션을 묶기 때문에 + // 단위 테스트에서는 콜백을 즉시 실행하도록 stub 한다. + // lenient — 트랜잭션을 안 쓰는 테스트(쿠폰 사용/반환/조회 등)에서 미사용 경고 방지. + Mockito.lenient().when(transactionTemplate.execute(Mockito.any())) + .thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(null); + }); + } + private User createUser(Long id) { return User.builder() .id(id) @@ -120,34 +145,42 @@ void createTemplateFailNotAdmin() { // === 쿠폰 발급 === @Test - @DisplayName("쿠폰 발급 성공 - 수량 차감 확인") + @DisplayName("쿠폰 발급 성공 - Redis Lua 통과 후 coupons row INSERT") void issueCouponSuccess() { User user = createUser(1L); CouponTemplate template = createTemplate(10, LocalDateTime.now().plusDays(30)); - given(userRepository.getById(1L)).willReturn(user); - given(couponTemplateRepository.findByIdWithLock(1L)).willReturn(Optional.of(template)); - given(couponRepository.existsByUserIdAndCouponTemplateId(1L, 1L)).willReturn(false); + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.SUCCESS); + given(userRepository.getReferenceById(1L)).willReturn(user); + given(couponTemplateRepository.findById(1L)).willReturn(Optional.of(template)); given(couponRepository.save(any(Coupon.class))).willAnswer(invocation -> { Coupon coupon = invocation.getArgument(0); setField(coupon, "id", 1L); return coupon; }); - couponService.issueCoupon(1L, 1L); + var response = couponService.issueCoupon(1L, 1L); - assertThat(template.getRemain()).isEqualTo(9); + assertThat(response.couponId()).isEqualTo(1L); + assertThat(response.couponName()).isEqualTo("10% 할인"); + verify(couponRepository).save(any(Coupon.class)); } @Test - @DisplayName("쿠폰 발급 실패 - 중복 발급") - void issueCouponFailDuplicate() { - User user = createUser(1L); - CouponTemplate template = createTemplate(10, LocalDateTime.now().plusDays(30)); + @DisplayName("쿠폰 발급 실패 - Redis 장애로 회로 폴백 시 503 매핑") + void issueCouponFailRedisUnavailable() { + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.UNAVAILABLE); + + assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.COUPON_ISSUE_TEMPORARILY_UNAVAILABLE)); + } - given(userRepository.getById(1L)).willReturn(user); - given(couponTemplateRepository.findByIdWithLock(1L)).willReturn(Optional.of(template)); - given(couponRepository.existsByUserIdAndCouponTemplateId(1L, 1L)).willReturn(true); + @Test + @DisplayName("쿠폰 발급 실패 - 중복 발급 (Redis Lua 가 DUPLICATE 반환)") + void issueCouponFailDuplicate() { + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.DUPLICATE); assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) .isInstanceOf(CustomException.class) @@ -156,14 +189,9 @@ void issueCouponFailDuplicate() { } @Test - @DisplayName("쿠폰 발급 실패 - 수량 소진") + @DisplayName("쿠폰 발급 실패 - 수량 소진 (Redis Lua 가 EXHAUSTED 반환)") void issueCouponFailExhausted() { - User user = createUser(1L); - CouponTemplate template = createTemplate(0, LocalDateTime.now().plusDays(30)); - - given(userRepository.getById(1L)).willReturn(user); - given(couponTemplateRepository.findByIdWithLock(1L)).willReturn(Optional.of(template)); - given(couponRepository.existsByUserIdAndCouponTemplateId(1L, 1L)).willReturn(false); + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.EXHAUSTED); assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) .isInstanceOf(CustomException.class) @@ -171,6 +199,24 @@ void issueCouponFailExhausted() { .isEqualTo(ErrorCode.COUPON_EXHAUSTED)); } + @Test + @DisplayName("쿠폰 발급 - DB INSERT 실패 시 Redis 보상 호출") + void issueCouponDbFailureCompensatesRedis() { + User user = createUser(1L); + CouponTemplate template = createTemplate(10, LocalDateTime.now().plusDays(30)); + + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.SUCCESS); + given(userRepository.getReferenceById(1L)).willReturn(user); + given(couponTemplateRepository.findById(1L)).willReturn(Optional.of(template)); + given(couponRepository.save(any(Coupon.class))) + .willThrow(new RuntimeException("DB INSERT 실패 시뮬레이션")); + + assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) + .isInstanceOf(RuntimeException.class); + + verify(redisCouponIssuer).compensate(1L, 1L); + } + // === 쿠폰 사용 === @Test