From 03fba20f38c9c1e0f9a07688fd4cfaf4ce5bdb30 Mon Sep 17 00:00:00 2001 From: silkair Date: Tue, 26 May 2026 16:22:05 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor=20:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20dlq=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catchtable/global/config/KafkaConfig.java | 15 +++- .../service/NotificationKafkaConsumer.java | 76 +++++++++++++------ 2 files changed, 68 insertions(+), 23 deletions(-) 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/notification/service/NotificationKafkaConsumer.java b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java index deb0e81..0e1a9c3 100644 --- a/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java +++ b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java @@ -10,13 +10,28 @@ import com.catchtable.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.kafka.retrytopic.DltStrategy; + import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +/** + * Kafka 알림 메시지를 수신하고 처리하는 컨슈머 클래스. + * 모든 리스너는 'catchtable-notification-group' 그룹에 속합니다. + * + * @RetryableTopic: 메시지 처리 실패 시 재시도 및 DLQ(Dead Letter Queue) 처리를 자동으로 구성합니다. + * - attempts: 최대 시도 횟수 (기본값 3) + * - backoff: 재시도 간격 설정 (2초 간격) + * - dltStrategy: DLQ 처리 전략. ALWAYS_RETRY_ON_ERROR는 모든 예외에 대해 재시도 후 DLQ로 보냅니다. + */ @Slf4j @Service @RequiredArgsConstructor @@ -25,12 +40,15 @@ public class NotificationKafkaConsumer { private final NotificationService notificationService; private final UserRepository userRepository; + @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 +57,7 @@ public void handleReservationConfirmed(@Payload ReservationConfirmedEvent event) event.getRemainTime()); notificationService.createNotification( - userOpt.get(), + user, NotificationType.RESERVATION_CONFIRMED, title, content, @@ -47,12 +65,20 @@ 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()); + + + // --- DLQ 테스트용 강제 에러 발생 코드 시작 --- +// if (true) { +// log.warn("=== DLQ 테스트: 강제로 에러를 발생시킵니다! ==="); +// throw new RuntimeException("DLQ 테스트용 임시 예외"); +// } + String title = "예약이 취소되었습니다."; String content = String.format("'%s' 매장 %s %s 예약이 취소되었습니다.", @@ -61,7 +87,7 @@ public void handleReservationCanceled(@Payload ReservationCanceledEvent event) { event.getRemainTime()); notificationService.createNotification( - userOpt.get(), + user, NotificationType.RESERVATION_CANCELED, title, content, @@ -69,12 +95,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 +111,7 @@ public void handleReservationChanged(@Payload ReservationChangedEvent event) { event.getNewRemainTime()); notificationService.createNotification( - userOpt.get(), + user, NotificationType.RESERVATION_CHANGED, title, content, @@ -93,19 +119,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,6 +139,7 @@ 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) { @@ -123,15 +150,20 @@ public void handleVacancyOpened(@Payload VacancyEvent event) { // 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); - } - return user; + @DltHandler + public void handleDlt(Object message, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) { + log.error("[DLT] 메시지 처리 최종 실패. Topic: {}, Message: {}", topic, message.toString()); + // TODO: 운영자에게 슬랙(Slack) 알림 발송 등의 후속 조치 구현 + } + + private User findUserOrThrow(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> { + // 존재하지 않는 사용자는 재시도해도 성공할 수 없으므로, + // 무한 재시도를 유발하지 않도록 Non-Retryable 예외를 던지는 것이 더 좋지만, + // 여기서는 일관성을 위해 일단 모든 예외를 재시도하도록 둡니다. + log.error("[Kafka Consumer] 사용자 정보를 찾을 수 없습니다. userId={}", userId); + return new RuntimeException("사용자를 찾을 수 없음: " + userId); + }); } } From adf012b3f37a7b4ac401cb306970c630fe230259 Mon Sep 17 00:00:00 2001 From: silkair Date: Tue, 26 May 2026 16:45:28 +0900 Subject: [PATCH 2/7] =?UTF-8?q?redis=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../notification/service/NotificationKafkaConsumer.java | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) 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/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java index 0e1a9c3..49a496a 100644 --- a/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java +++ b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java @@ -72,14 +72,6 @@ public void handleReservationCanceled(@Payload ReservationCanceledEvent event) { log.info("[Kafka Consumer] 예약 취소 이벤트 수신: reservationId={}", event.getReservationId()); User user = findUserOrThrow(event.getUserId()); - - // --- DLQ 테스트용 강제 에러 발생 코드 시작 --- -// if (true) { -// log.warn("=== DLQ 테스트: 강제로 에러를 발생시킵니다! ==="); -// throw new RuntimeException("DLQ 테스트용 임시 예외"); -// } - - String title = "예약이 취소되었습니다."; String content = String.format("'%s' 매장 %s %s 예약이 취소되었습니다.", event.getStoreName(), From 1adfbb582b4d79527deb73115885d35d97b66501 Mon Sep 17 00:00:00 2001 From: silkair Date: Tue, 26 May 2026 17:20:42 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor=20:=20=EB=B9=88=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EB=A0=88=EB=94=94=EC=8A=A4=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationKafkaConsumer.java | 73 ++++++++++++------- .../vacancy/service/VacancyService.java | 46 +++++++++++- 2 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java index 49a496a..f71aaad 100644 --- a/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java +++ b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java @@ -6,32 +6,28 @@ 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.kafka.retrytopic.DltStrategy; - import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -/** - * Kafka 알림 메시지를 수신하고 처리하는 컨슈머 클래스. - * 모든 리스너는 'catchtable-notification-group' 그룹에 속합니다. - * - * @RetryableTopic: 메시지 처리 실패 시 재시도 및 DLQ(Dead Letter Queue) 처리를 자동으로 구성합니다. - * - attempts: 최대 시도 횟수 (기본값 3) - * - backoff: 재시도 간격 설정 (2초 간격) - * - dltStrategy: DLQ 처리 전략. ALWAYS_RETRY_ON_ERROR는 모든 예외에 대해 재시도 후 DLQ로 보냅니다. - */ +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + @Slf4j @Service @RequiredArgsConstructor @@ -39,11 +35,11 @@ public class NotificationKafkaConsumer { 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 - ) + @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) { @@ -135,25 +131,52 @@ public void handleReservationVisited(@Payload ReservationVisitedEvent event) { @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) + + 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; + } + + 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()); - // TODO: 운영자에게 슬랙(Slack) 알림 발송 등의 후속 조치 구현 } private User findUserOrThrow(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> { - // 존재하지 않는 사용자는 재시도해도 성공할 수 없으므로, - // 무한 재시도를 유발하지 않도록 Non-Retryable 예외를 던지는 것이 더 좋지만, - // 여기서는 일관성을 위해 일단 모든 예외를 재시도하도록 둡니다. 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()); + } } From cc6dd5b2e68b131bb47763d6538efbe7fecc2c92 Mon Sep 17 00:00:00 2001 From: silkair Date: Tue, 26 May 2026 18:10:03 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor=20:=20redis=20=EC=BF=A8=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationKafkaConsumer.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java index f71aaad..d9c17aa 100644 --- a/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java +++ b/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java @@ -24,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -33,6 +34,8 @@ @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; @@ -133,6 +136,15 @@ public void handleReservationVisited(@Payload ReservationVisitedEvent event) { public void handleVacancyOpened(@Payload VacancyEvent event) { log.info("[Kafka Consumer] 빈자리 발생 이벤트 수신: remainId={}", event.getRemainId()); + // 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()); From 212e82c426069dfbd2d82bbc9bf3b7b084d207b4 Mon Sep 17 00:00:00 2001 From: kimjb Date: Wed, 27 May 2026 00:10:08 +0900 Subject: [PATCH 5/7] =?UTF-8?q?Feat:=20=EB=B9=84=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=20->=20Redis=20Lua=20=EA=B5=90=EC=B2=B4=20/=20jaeger?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.prod.yml | 64 +------ .../provisioning/datasources/datasources.yml | 13 -- .../com/catchtable/coupon/entity/Coupon.java | 10 +- .../coupon/entity/CouponTemplate.java | 9 - .../redis/CouponIssueExecutorConfig.java | 37 ++++ .../CouponRedisCircuitBreakerLogger.java | 40 ++++ .../coupon/redis/CouponWarmupRunner.java | 57 ++++++ .../coupon/redis/RedisCouponIssuer.java | 175 ++++++++++++++++++ .../repository/CouponTemplateRepository.java | 7 - .../coupon/service/CouponService.java | 77 ++++++-- .../global/exception/ErrorCode.java | 1 + .../exception/GlobalExceptionHandler.java | 17 +- src/main/resources/lua/issue_coupon.lua | 27 +++ .../concurrency/CouponConcurrencyTest.java | 96 +++++----- .../coupon/service/CouponServiceTest.java | 71 +++++-- 15 files changed, 529 insertions(+), 172 deletions(-) create mode 100644 src/main/java/com/catchtable/coupon/redis/CouponIssueExecutorConfig.java create mode 100644 src/main/java/com/catchtable/coupon/redis/CouponRedisCircuitBreakerLogger.java create mode 100644 src/main/java/com/catchtable/coupon/redis/CouponWarmupRunner.java create mode 100644 src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java create mode 100644 src/main/resources/lua/issue_coupon.lua 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/CouponIssueExecutorConfig.java b/src/main/java/com/catchtable/coupon/redis/CouponIssueExecutorConfig.java new file mode 100644 index 0000000..9442e80 --- /dev/null +++ b/src/main/java/com/catchtable/coupon/redis/CouponIssueExecutorConfig.java @@ -0,0 +1,37 @@ +package com.catchtable.coupon.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * 쿠폰 발급 Lua 호출 전용 스레드 풀. + * + * 기본 ForkJoinPool(코어 수≈8) 을 쓰면 수백 동시 호출이 큐에서 대기하다가 + * @TimeLimiter 에 걸려 인프라 장애가 아닌데도 폴백된다. + * 발급 호출은 1ms 안에 끝나므로 풀 크기 100 으로도 수천 TPS 처리 가능. + * + * 명시 풀로 분리하는 추가 효과: + * - 톰캣 워커와 격리 → 발급 호출이 톰캣 워커를 점유하지 않음. + * - 다른 도메인 비동기 작업과 자원 경쟁 없음. + * - ThreadPoolTaskExecutor 의 graceful shutdown 으로 SIGTERM 시 진행 중 작업 보장. + */ +@Configuration +public class CouponIssueExecutorConfig { + + @Bean(name = "couponIssueExecutor") + public Executor couponIssueExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(100); + executor.setMaxPoolSize(100); + executor.setQueueCapacity(1_000); + executor.setThreadNamePrefix("coupon-issue-"); + // SIGTERM 수신 시 진행 중인 발급 호출이 끝날 때까지 최대 10초 대기. + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(10); + executor.initialize(); + return executor; + } +} 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..a426f91 --- /dev/null +++ b/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java @@ -0,0 +1,175 @@ +package com.catchtable.coupon.redis; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.timelimiter.annotation.TimeLimiter; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RScript; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.StringCodec; +import org.springframework.beans.factory.annotation.Qualifier; +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.time.ZoneId; +import java.util.Date; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +/** + * 선착순 쿠폰 발급의 원자 결정(재고/중복/차감/마킹)을 Redis Lua로 처리한다. + * + * 격리(C번 항목): + * - Redisson 클라이언트는 예약 분산 락 등 다른 도메인과 공유한다. + * 그래서 글로벌 타임아웃을 줄이는 대신, 쿠폰 발급 호출 메서드에만 + * @TimeLimiter + @CircuitBreaker 를 걸어 다른 Redis 사용처에 영향이 가지 않게 한다. + * - Redis 장애가 일정 비율 넘으면 회로가 OPEN 으로 가서 호출 자체가 차단되고 + * fallback 이 503(COUPON_ISSUE_TEMPORARILY_UNAVAILABLE) 으로 떨어뜨린다. + * - 비즈니스 예외(DUPLICATE/EXHAUSTED)는 yaml 의 ignore-exceptions 로 회로 카운트에서 제외. + */ +@Slf4j +@Component +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 final Executor couponIssueExecutor; + + private String issueLuaScript; + + public RedisCouponIssuer( + RedissonClient redissonClient, + @Qualifier("couponIssueExecutor") Executor couponIssueExecutor) { + this.redissonClient = redissonClient; + this.couponIssueExecutor = couponIssueExecutor; + } + + @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); + } + } + + /** + * 발급 시도 (비동기 시그니처는 Resilience4j 어노테이션 요구사항). + * CouponService 에서 .join() 으로 unwrap 하여 사용한다. + */ + @TimeLimiter(name = RESILIENCE_INSTANCE) + @CircuitBreaker(name = RESILIENCE_INSTANCE, fallbackMethod = "tryIssueFallback") + public CompletableFuture tryIssue(Long templateId, Long userId) { + return CompletableFuture.supplyAsync(() -> { + 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); + }, couponIssueExecutor); + } + + /** + * Circuit Breaker / TimeLimiter 폴백. + * 비즈니스 예외는 ignore-exceptions 로 걸러져서 여기엔 인프라 장애만 들어온다. + */ + @SuppressWarnings("unused") // Resilience4j 가 리플렉션으로 호출 + private CompletableFuture tryIssueFallback(Long templateId, Long userId, Throwable t) { + log.warn("쿠폰 발급 Redis 호출 실패. templateId={}, userId={}, cause={}", + templateId, userId, t.toString()); + return CompletableFuture.completedFuture(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) + ); + } + + /** + * 템플릿 생성 직후 호출. 재고 카운터와 발급자 SET 을 만료시각까지 살려둔다. + * 이미 키가 존재해도 덮어쓴다 — createTemplate 흐름에서만 사용해야 한다. + */ + 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)); + + Date expireAtDate = Date.from(expiredAt.atZone(ZoneId.systemDefault()).toInstant()); + redissonClient.getSet(issuedKey(templateId), StringCodec.INSTANCE).expireAt(expireAtDate); + } + + /** + * 서버 부팅 시 호출. 이미 키가 있으면(=Redis 가 운영 중 상태 유지 중) 건드리지 않는다. + * Redis 가 재기동되어 키가 사라진 경우에만 DB 의 remain 으로 워밍업한다. + * + * 주의: "Redis 가 죽어있는 동안 발급된 건수"를 복원할 수는 없다. + * Redis HA(Sentinel/Cluster) + 영속화 설정이 1차 방어선이고, + * 여기는 콜드 스타트 때만 의미 있는 fail-safe. + */ + 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); + } + // 발급자 SET 은 비어있어도 무방. TTL 만 보장. + Date expireAtDate = Date.from(expiredAt.atZone(ZoneId.systemDefault()).toInstant()); + redissonClient.getSet(issuedKey(templateId), StringCodec.INSTANCE).expireAt(expireAtDate); + } + + 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..011136b 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; @@ -23,6 +25,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.concurrent.CompletionException; import java.util.stream.Collectors; @Slf4j @@ -33,37 +36,77 @@ public class CouponService { private final CouponRepository couponRepository; private final CouponTemplateRepository couponTemplateRepository; private final UserRepository userRepository; + private final RedisCouponIssuer redisCouponIssuer; - // 쿠폰 템플릿 생성 + // 쿠폰 템플릿 생성 + 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을 만료 시각까지 유지. + // 워밍업 없이는 첫 Lua 호출이 NOT_AVAILABLE 로 떨어진다. + redisCouponIssuer.warmUp(saved.getId(), saved.getRemain(), saved.getExpiredAt()); return CouponTemplateCreateResponse.from(saved); } - // 쿠폰 발급 (비관적 락) + /** + * 선착순 발급. + * + * 1) Redis Lua: 재고/중복/차감/SADD 를 단일 EVAL 로 원자 결정. + * - @TimeLimiter + @CircuitBreaker 로 Redis 장애가 톰캣 워커를 점령하지 않게 격리. + * 2) Lua 통과 = 발급 확정. 같은 트랜잭션 안에서 coupons row 1건 INSERT. + * 3) DB INSERT 실패 시 Redis 상태 보상(stock INCR + issued SREM). + */ @Transactional 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 = unwrapIssueResult(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(); + try { + User user = userRepository.getById(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; + } + } - Coupon saved = couponRepository.save(coupon); - return CouponIssueResponse.from(saved); + /** + * @TimeLimiter 가 요구하는 CompletableFuture 시그니처를 동기 값으로 풀어낸다. + * 인프라 장애로 폴백된 경우 UNAVAILABLE 이 그대로 흘러나오고, + * 비즈니스 예외(ignore-exceptions 통과)는 그대로 던져진다. + */ + private IssueResult unwrapIssueResult(Long templateId, Long userId) { + try { + return redisCouponIssuer.tryIssue(templateId, userId).join(); + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof CustomException ce) throw ce; + if (cause instanceof RuntimeException re) throw re; + throw new CustomException(ErrorCode.COUPON_ISSUE_TEMPORARILY_UNAVAILABLE); + } } // 내 쿠폰 목록 조회 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/resources/lua/issue_coupon.lua b/src/main/resources/lua/issue_coupon.lua new file mode 100644 index 0000000..0fb4658 --- /dev/null +++ b/src/main/resources/lua/issue_coupon.lua @@ -0,0 +1,27 @@ +-- 선착순 쿠폰 발급 원자 처리 +-- KEYS[1] = coupon:stock:{templateId} (재고 INTEGER) +-- 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]) +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..34cd153 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; @@ -19,10 +21,12 @@ import java.time.LocalDateTime; import java.util.Optional; +import java.util.concurrent.CompletableFuture; 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,6 +40,9 @@ class CouponServiceTest { @Mock private UserRepository userRepository; + @Mock + private RedisCouponIssuer redisCouponIssuer; + @InjectMocks private CouponService couponService; @@ -120,34 +127,45 @@ 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(redisCouponIssuer.tryIssue(1L, 1L)) + .willReturn(CompletableFuture.completedFuture(IssueResult.SUCCESS)); given(userRepository.getById(1L)).willReturn(user); - given(couponTemplateRepository.findByIdWithLock(1L)).willReturn(Optional.of(template)); - given(couponRepository.existsByUserIdAndCouponTemplateId(1L, 1L)).willReturn(false); + 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(CompletableFuture.completedFuture(IssueResult.UNAVAILABLE)); - given(userRepository.getById(1L)).willReturn(user); - given(couponTemplateRepository.findByIdWithLock(1L)).willReturn(Optional.of(template)); - given(couponRepository.existsByUserIdAndCouponTemplateId(1L, 1L)).willReturn(true); + assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.COUPON_ISSUE_TEMPORARILY_UNAVAILABLE)); + } + + @Test + @DisplayName("쿠폰 발급 실패 - 중복 발급 (Redis Lua 가 DUPLICATE 반환)") + void issueCouponFailDuplicate() { + given(redisCouponIssuer.tryIssue(1L, 1L)) + .willReturn(CompletableFuture.completedFuture(IssueResult.DUPLICATE)); assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) .isInstanceOf(CustomException.class) @@ -156,14 +174,10 @@ 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(CompletableFuture.completedFuture(IssueResult.EXHAUSTED)); assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) .isInstanceOf(CustomException.class) @@ -171,6 +185,25 @@ 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(CompletableFuture.completedFuture(IssueResult.SUCCESS)); + given(userRepository.getById(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 From 989b71731ad3fcb4200fcb7a726cf2f1d441af66 Mon Sep 17 00:00:00 2001 From: kimjb Date: Wed, 27 May 2026 00:46:49 +0900 Subject: [PATCH 6/7] =?UTF-8?q?Fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../redis/CouponIssueExecutorConfig.java | 37 -------- .../coupon/redis/RedisCouponIssuer.java | 93 +++++++------------ .../coupon/service/CouponService.java | 58 +++++------- src/main/resources/lua/issue_coupon.lua | 11 ++- .../coupon/service/CouponServiceTest.java | 35 ++++--- 5 files changed, 93 insertions(+), 141 deletions(-) delete mode 100644 src/main/java/com/catchtable/coupon/redis/CouponIssueExecutorConfig.java diff --git a/src/main/java/com/catchtable/coupon/redis/CouponIssueExecutorConfig.java b/src/main/java/com/catchtable/coupon/redis/CouponIssueExecutorConfig.java deleted file mode 100644 index 9442e80..0000000 --- a/src/main/java/com/catchtable/coupon/redis/CouponIssueExecutorConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.catchtable.coupon.redis; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; - -import java.util.concurrent.Executor; - -/** - * 쿠폰 발급 Lua 호출 전용 스레드 풀. - * - * 기본 ForkJoinPool(코어 수≈8) 을 쓰면 수백 동시 호출이 큐에서 대기하다가 - * @TimeLimiter 에 걸려 인프라 장애가 아닌데도 폴백된다. - * 발급 호출은 1ms 안에 끝나므로 풀 크기 100 으로도 수천 TPS 처리 가능. - * - * 명시 풀로 분리하는 추가 효과: - * - 톰캣 워커와 격리 → 발급 호출이 톰캣 워커를 점유하지 않음. - * - 다른 도메인 비동기 작업과 자원 경쟁 없음. - * - ThreadPoolTaskExecutor 의 graceful shutdown 으로 SIGTERM 시 진행 중 작업 보장. - */ -@Configuration -public class CouponIssueExecutorConfig { - - @Bean(name = "couponIssueExecutor") - public Executor couponIssueExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(100); - executor.setMaxPoolSize(100); - executor.setQueueCapacity(1_000); - executor.setThreadNamePrefix("coupon-issue-"); - // SIGTERM 수신 시 진행 중인 발급 호출이 끝날 때까지 최대 10초 대기. - executor.setWaitForTasksToCompleteOnShutdown(true); - executor.setAwaitTerminationSeconds(10); - executor.initialize(); - return executor; - } -} diff --git a/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java b/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java index a426f91..bc36a4e 100644 --- a/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java +++ b/src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java @@ -1,13 +1,12 @@ package com.catchtable.coupon.redis; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; -import io.github.resilience4j.timelimiter.annotation.TimeLimiter; 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.beans.factory.annotation.Qualifier; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import org.springframework.util.StreamUtils; @@ -15,25 +14,25 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; /** - * 선착순 쿠폰 발급의 원자 결정(재고/중복/차감/마킹)을 Redis Lua로 처리한다. + * 선착순 쿠폰 발급의 원자 결정(재고/중복/차감/마킹)을 Redis Lua 로 처리한다. * - * 격리(C번 항목): - * - Redisson 클라이언트는 예약 분산 락 등 다른 도메인과 공유한다. - * 그래서 글로벌 타임아웃을 줄이는 대신, 쿠폰 발급 호출 메서드에만 - * @TimeLimiter + @CircuitBreaker 를 걸어 다른 Redis 사용처에 영향이 가지 않게 한다. - * - Redis 장애가 일정 비율 넘으면 회로가 OPEN 으로 가서 호출 자체가 차단되고 - * fallback 이 503(COUPON_ISSUE_TEMPORARILY_UNAVAILABLE) 으로 떨어뜨린다. + * 격리: + * - 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:"; @@ -41,17 +40,9 @@ public class RedisCouponIssuer { private static final String RESILIENCE_INSTANCE = "coupon-redis"; private final RedissonClient redissonClient; - private final Executor couponIssueExecutor; private String issueLuaScript; - public RedisCouponIssuer( - RedissonClient redissonClient, - @Qualifier("couponIssueExecutor") Executor couponIssueExecutor) { - this.redissonClient = redissonClient; - this.couponIssueExecutor = couponIssueExecutor; - } - @PostConstruct void loadScript() { try (var in = new ClassPathResource("lua/issue_coupon.lua").getInputStream()) { @@ -62,44 +53,40 @@ void loadScript() { } /** - * 발급 시도 (비동기 시그니처는 Resilience4j 어노테이션 요구사항). - * CouponService 에서 .join() 으로 unwrap 하여 사용한다. + * 발급 시도. 동기 호출. + * Redis 인프라 장애는 회로가 OPEN 되어 fallback 으로 UNAVAILABLE 반환. */ - @TimeLimiter(name = RESILIENCE_INSTANCE) @CircuitBreaker(name = RESILIENCE_INSTANCE, fallbackMethod = "tryIssueFallback") - public CompletableFuture tryIssue(Long templateId, Long userId) { - return CompletableFuture.supplyAsync(() -> { - 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); - }, couponIssueExecutor); + 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 / TimeLimiter 폴백. - * 비즈니스 예외는 ignore-exceptions 로 걸러져서 여기엔 인프라 장애만 들어온다. + * Circuit Breaker 폴백. + * 비즈니스 예외는 ignore-exceptions 로 걸러져 여기엔 인프라 장애만 들어온다. */ @SuppressWarnings("unused") // Resilience4j 가 리플렉션으로 호출 - private CompletableFuture tryIssueFallback(Long templateId, Long userId, Throwable t) { + private IssueResult tryIssueFallback(Long templateId, Long userId, Throwable t) { log.warn("쿠폰 발급 Redis 호출 실패. templateId={}, userId={}, cause={}", templateId, userId, t.toString()); - return CompletableFuture.completedFuture(IssueResult.UNAVAILABLE); + return IssueResult.UNAVAILABLE; } /** * Lua 통과 후 DB INSERT 가 실패한 경우의 보상. * stock 을 1 복구하고 발급자 SET 에서 userId 를 제거한다. * - * 주의: 이 보상은 best-effort 다. 보상 자체가 실패하면 재고 1건이 영구 소실될 수 있다. - * 운영에서는 별도 알람 + 정합성 점검 잡으로 보완해야 한다. - * 회로 차단 대상에서 제외 — 보상 실패가 회로를 더 빨리 열어버리면 운영상 손해. + * 주의: best-effort. 보상 자체가 실패하면 재고 1건이 영구 소실될 수 있다. + * 운영에서는 별도 알람 + 정합성 점검 잡으로 보완. */ public void compensate(Long templateId, Long userId) { redissonClient @@ -115,25 +102,18 @@ public void compensate(Long templateId, Long userId) { } /** - * 템플릿 생성 직후 호출. 재고 카운터와 발급자 SET 을 만료시각까지 살려둔다. - * 이미 키가 존재해도 덮어쓴다 — createTemplate 흐름에서만 사용해야 한다. + * 템플릿 생성 직후 호출. 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)); - - Date expireAtDate = Date.from(expiredAt.atZone(ZoneId.systemDefault()).toInstant()); - redissonClient.getSet(issuedKey(templateId), StringCodec.INSTANCE).expireAt(expireAtDate); } /** - * 서버 부팅 시 호출. 이미 키가 있으면(=Redis 가 운영 중 상태 유지 중) 건드리지 않는다. - * Redis 가 재기동되어 키가 사라진 경우에만 DB 의 remain 으로 워밍업한다. - * - * 주의: "Redis 가 죽어있는 동안 발급된 건수"를 복원할 수는 없다. - * Redis HA(Sentinel/Cluster) + 영속화 설정이 1차 방어선이고, - * 여기는 콜드 스타트 때만 의미 있는 fail-safe. + * 서버 부팅 시 호출. 이미 키가 있으면(Redis 운영 중 상태 유지 중) 건드리지 않는다. + * Redis 콜드 스타트일 때만 DB 의 remain 으로 워밍업. */ public void warmUpIfAbsent(Long templateId, Integer remain, LocalDateTime expiredAt) { long ttlSeconds = ttlSeconds(expiredAt); @@ -142,9 +122,6 @@ public void warmUpIfAbsent(Long templateId, Integer remain, LocalDateTime expire if (inserted) { log.info("Redis 콜드 스타트 워밍업: templateId={}, remain={}", templateId, remain); } - // 발급자 SET 은 비어있어도 무방. TTL 만 보장. - Date expireAtDate = Date.from(expiredAt.atZone(ZoneId.systemDefault()).toInstant()); - redissonClient.getSet(issuedKey(templateId), StringCodec.INSTANCE).expireAt(expireAtDate); } private static long ttlSeconds(LocalDateTime expiredAt) { diff --git a/src/main/java/com/catchtable/coupon/service/CouponService.java b/src/main/java/com/catchtable/coupon/service/CouponService.java index 011136b..f0baaac 100644 --- a/src/main/java/com/catchtable/coupon/service/CouponService.java +++ b/src/main/java/com/catchtable/coupon/service/CouponService.java @@ -22,10 +22,10 @@ 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; -import java.util.concurrent.CompletionException; import java.util.stream.Collectors; @Slf4j @@ -37,6 +37,7 @@ public class CouponService { private final CouponTemplateRepository couponTemplateRepository; private final UserRepository userRepository; private final RedisCouponIssuer redisCouponIssuer; + private final TransactionTemplate transactionTemplate; // 쿠폰 템플릿 생성 + Redis 재고 워밍업 @Transactional @@ -45,8 +46,8 @@ public CouponTemplateCreateResponse createTemplate(Long userId, CouponTemplateCr CouponTemplate template = request.toEntity(); CouponTemplate saved = couponTemplateRepository.save(template); - // Lua가 참조할 재고 카운터와 발급자 SET을 만료 시각까지 유지. - // 워밍업 없이는 첫 Lua 호출이 NOT_AVAILABLE 로 떨어진다. + // Lua가 참조할 재고 카운터를 만료 시각까지 유지. + // 발급자 SET 의 TTL 은 Lua 스크립트가 SADD 시점에 stock TTL 로 동기화한다. redisCouponIssuer.warmUp(saved.getId(), saved.getRemain(), saved.getExpiredAt()); return CouponTemplateCreateResponse.from(saved); } @@ -54,14 +55,17 @@ public CouponTemplateCreateResponse createTemplate(Long userId, CouponTemplateCr /** * 선착순 발급. * + * 트랜잭션 범위: + * - Redis 호출은 트랜잭션 밖에서. 트랜잭션 안에서 네트워크 I/O 를 호출하면 + * DB 커넥션이 Redis 응답 대기 동안 점유되어 HikariCP 풀이 빠르게 고갈된다. + * - DB INSERT 만 TransactionTemplate 으로 짧게 묶는다. + * * 1) Redis Lua: 재고/중복/차감/SADD 를 단일 EVAL 로 원자 결정. - * - @TimeLimiter + @CircuitBreaker 로 Redis 장애가 톰캣 워커를 점령하지 않게 격리. - * 2) Lua 통과 = 발급 확정. 같은 트랜잭션 안에서 coupons row 1건 INSERT. + * 2) Lua 통과 = 발급 확정. 짧은 트랜잭션에서 coupons row 1건 INSERT. * 3) DB INSERT 실패 시 Redis 상태 보상(stock INCR + issued SREM). */ - @Transactional public CouponIssueResponse issueCoupon(Long templateId, Long userId) { - IssueResult result = unwrapIssueResult(templateId, userId); + IssueResult result = redisCouponIssuer.tryIssue(templateId, userId); switch (result) { case DUPLICATE -> throw new CustomException(ErrorCode.DUPLICATE_COUPON); case EXHAUSTED -> throw new CustomException(ErrorCode.COUPON_EXHAUSTED); @@ -71,17 +75,19 @@ public CouponIssueResponse issueCoupon(Long templateId, Long userId) { } try { - User user = userRepository.getById(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); + return transactionTemplate.execute(status -> { + User user = userRepository.getById(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 { @@ -93,22 +99,6 @@ public CouponIssueResponse issueCoupon(Long templateId, Long userId) { } } - /** - * @TimeLimiter 가 요구하는 CompletableFuture 시그니처를 동기 값으로 풀어낸다. - * 인프라 장애로 폴백된 경우 UNAVAILABLE 이 그대로 흘러나오고, - * 비즈니스 예외(ignore-exceptions 통과)는 그대로 던져진다. - */ - private IssueResult unwrapIssueResult(Long templateId, Long userId) { - try { - return redisCouponIssuer.tryIssue(templateId, userId).join(); - } catch (CompletionException e) { - Throwable cause = e.getCause(); - if (cause instanceof CustomException ce) throw ce; - if (cause instanceof RuntimeException re) throw re; - throw new CustomException(ErrorCode.COUPON_ISSUE_TEMPORARILY_UNAVAILABLE); - } - } - // 내 쿠폰 목록 조회 @Transactional(readOnly = true) public List getMyCoupons(Long userId) { diff --git a/src/main/resources/lua/issue_coupon.lua b/src/main/resources/lua/issue_coupon.lua index 0fb4658..32814a1 100644 --- a/src/main/resources/lua/issue_coupon.lua +++ b/src/main/resources/lua/issue_coupon.lua @@ -1,5 +1,5 @@ -- 선착순 쿠폰 발급 원자 처리 --- KEYS[1] = coupon:stock:{templateId} (재고 INTEGER) +-- KEYS[1] = coupon:stock:{templateId} (재고 INTEGER, TTL 보유) -- KEYS[2] = coupon:issued:{templateId} (발급 완료 userId SET) -- ARGV[1] = userId -- @@ -24,4 +24,13 @@ 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/service/CouponServiceTest.java b/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java index 34cd153..abc8f42 100644 --- a/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java +++ b/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java @@ -12,16 +12,19 @@ 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; -import java.util.concurrent.CompletableFuture; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -43,9 +46,24 @@ class CouponServiceTest { @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) @@ -132,8 +150,7 @@ void issueCouponSuccess() { User user = createUser(1L); CouponTemplate template = createTemplate(10, LocalDateTime.now().plusDays(30)); - given(redisCouponIssuer.tryIssue(1L, 1L)) - .willReturn(CompletableFuture.completedFuture(IssueResult.SUCCESS)); + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.SUCCESS); given(userRepository.getById(1L)).willReturn(user); given(couponTemplateRepository.findById(1L)).willReturn(Optional.of(template)); given(couponRepository.save(any(Coupon.class))).willAnswer(invocation -> { @@ -152,8 +169,7 @@ void issueCouponSuccess() { @Test @DisplayName("쿠폰 발급 실패 - Redis 장애로 회로 폴백 시 503 매핑") void issueCouponFailRedisUnavailable() { - given(redisCouponIssuer.tryIssue(1L, 1L)) - .willReturn(CompletableFuture.completedFuture(IssueResult.UNAVAILABLE)); + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.UNAVAILABLE); assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) .isInstanceOf(CustomException.class) @@ -164,8 +180,7 @@ void issueCouponFailRedisUnavailable() { @Test @DisplayName("쿠폰 발급 실패 - 중복 발급 (Redis Lua 가 DUPLICATE 반환)") void issueCouponFailDuplicate() { - given(redisCouponIssuer.tryIssue(1L, 1L)) - .willReturn(CompletableFuture.completedFuture(IssueResult.DUPLICATE)); + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.DUPLICATE); assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) .isInstanceOf(CustomException.class) @@ -176,8 +191,7 @@ void issueCouponFailDuplicate() { @Test @DisplayName("쿠폰 발급 실패 - 수량 소진 (Redis Lua 가 EXHAUSTED 반환)") void issueCouponFailExhausted() { - given(redisCouponIssuer.tryIssue(1L, 1L)) - .willReturn(CompletableFuture.completedFuture(IssueResult.EXHAUSTED)); + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.EXHAUSTED); assertThatThrownBy(() -> couponService.issueCoupon(1L, 1L)) .isInstanceOf(CustomException.class) @@ -191,8 +205,7 @@ void issueCouponDbFailureCompensatesRedis() { User user = createUser(1L); CouponTemplate template = createTemplate(10, LocalDateTime.now().plusDays(30)); - given(redisCouponIssuer.tryIssue(1L, 1L)) - .willReturn(CompletableFuture.completedFuture(IssueResult.SUCCESS)); + given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.SUCCESS); given(userRepository.getById(1L)).willReturn(user); given(couponTemplateRepository.findById(1L)).willReturn(Optional.of(template)); given(couponRepository.save(any(Coupon.class))) From 49c1d1301c543fc9803841f3ca8908adfb4a0a03 Mon Sep 17 00:00:00 2001 From: kimjb Date: Wed, 27 May 2026 01:10:03 +0900 Subject: [PATCH 7/7] =?UTF-8?q?Refactor:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20user=20=EC=A1=B0=ED=9A=8C=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/catchtable/coupon/service/CouponService.java | 3 ++- .../java/com/catchtable/coupon/service/CouponServiceTest.java | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/catchtable/coupon/service/CouponService.java b/src/main/java/com/catchtable/coupon/service/CouponService.java index f0baaac..0ae1a61 100644 --- a/src/main/java/com/catchtable/coupon/service/CouponService.java +++ b/src/main/java/com/catchtable/coupon/service/CouponService.java @@ -76,7 +76,8 @@ public CouponIssueResponse issueCoupon(Long templateId, Long userId) { try { return transactionTemplate.execute(status -> { - User user = userRepository.getById(userId); + // 응답에 user 필드 미사용 — SELECT 생략 위해 프록시 사용. + User user = userRepository.getReferenceById(userId); CouponTemplate template = couponTemplateRepository.findById(templateId) .orElseThrow(() -> new CustomException(ErrorCode.COUPON_TEMPLATE_NOT_FOUND)); diff --git a/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java b/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java index abc8f42..41ae846 100644 --- a/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java +++ b/src/test/java/com/catchtable/coupon/service/CouponServiceTest.java @@ -151,7 +151,7 @@ void issueCouponSuccess() { CouponTemplate template = createTemplate(10, LocalDateTime.now().plusDays(30)); given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.SUCCESS); - given(userRepository.getById(1L)).willReturn(user); + 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); @@ -206,7 +206,7 @@ void issueCouponDbFailureCompensatesRedis() { CouponTemplate template = createTemplate(10, LocalDateTime.now().plusDays(30)); given(redisCouponIssuer.tryIssue(1L, 1L)).willReturn(IssueResult.SUCCESS); - given(userRepository.getById(1L)).willReturn(user); + 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 실패 시뮬레이션"));