diff --git a/bidcompetition/build.gradle b/bidcompetition/build.gradle index 862f7974..8ccc4988 100644 --- a/bidcompetition/build.gradle +++ b/bidcompetition/build.gradle @@ -69,6 +69,12 @@ dependencies { // prometheus implementation 'io.micrometer:micrometer-registry-prometheus' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // MSK 설치 + implementation 'software.amazon.msk:aws-msk-iam-auth:2.2.0' } dependencyManagement { diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/application/repository/WinnerRepository.java b/bidcompetition/src/main/java/com/smore/bidcompetition/application/repository/WinnerRepository.java index 1c11b3c0..18265d97 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/application/repository/WinnerRepository.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/application/repository/WinnerRepository.java @@ -12,7 +12,7 @@ public interface WinnerRepository { Winner findById(UUID winnerId); - Winner findByIdempotencyKey(UUID idempotencyKey); + Winner findByIdempotencyKey(UUID bidId, UUID idempotencyKey); Winner findByAllocationKey(UUID allocationKey); diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/application/service/BidCompetitionService.java b/bidcompetition/src/main/java/com/smore/bidcompetition/application/service/BidCompetitionService.java index cbf84fcf..fb72a2bb 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/application/service/BidCompetitionService.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/application/service/BidCompetitionService.java @@ -27,8 +27,11 @@ import com.smore.bidcompetition.infrastructure.persistence.event.outbound.BidProductInventoryAdjustedEvent; import com.smore.bidcompetition.infrastructure.persistence.event.outbound.InventoryConfirmationTimeOutEvent; import com.smore.bidcompetition.infrastructure.persistence.event.outbound.WinnerCreatedEvent; +import com.smore.bidcompetition.infrastructure.redis.StockRedisService; import com.smore.bidcompetition.presentation.dto.BidResponse; +import io.lettuce.core.RedisCommandTimeoutException; import io.micrometer.tracing.Tracer; +import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.LocalDateTime; import java.util.UUID; @@ -36,6 +39,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,6 +48,7 @@ @RequiredArgsConstructor public class BidCompetitionService { + private final RedisTemplate redisTemplate; @Value("${app.allocation.valid-duration}") private long validDurationSeconds; @@ -54,6 +59,7 @@ public class BidCompetitionService { private final WinnerRepository winnerRepository; private final OutboxRepository outboxRepository; private final BidInventoryLogRepository bidInventoryLogRepository; + private final StockRedisService stockRedisService; private final Tracer tracer; private final ObjectMapper objectMapper; private final Clock clock; @@ -81,143 +87,250 @@ public void createBid(BidCreateCommand command) { command.getEndAt() ); - bidCompetitionRepository.save(newBid); + BidCompetition saved = bidCompetitionRepository.save(newBid); + + try { + long setResult = stockRedisService.setStock(saved.getId(), saved.getTotalQuantity()); + + if (setResult == -1L) { + log.error("재고 초기화 실패: bidId={}, stock={}", saved.getId(), saved.getTotalQuantity()); + } else if (setResult == 0L) { + log.info("이미 재고 키가 존재합니다. bidId={}", saved.getId()); + } else { + log.info("재고 초기화 완료: bidId={}, stock={}", saved.getId(), setResult); + } + } catch (Exception e) { + log.error("재고 초기화 중 예외 발생. bidId={}", saved.getId(), e); + } } @Transactional public BidResponse competition(CompetitionCommand command) { LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime expireAt = now.plusSeconds(validDurationSeconds); + UUID allocationKey = UUID.nameUUIDFromBytes( + ("ALLOC:" + command.getBidId() + ":" + command.getIdempotencyKey()).getBytes(StandardCharsets.UTF_8) + ); - Winner winner = winnerRepository.findByIdempotencyKey(command.getIdempotencyKey()); + long keyTtl = validDurationSeconds + bufferTimeSeconds + 120L; - if (winner != null) { - log.info("이미 처리된 작업입니다. userId : {}, bidId : {} idempotencyKey : {}", - command.getUserId(), command.getBidId(), command.getIdempotencyKey()); - return BidResponse.success( - winner.getBidId(), - winner.getProductId(), - winner.getQuantity(), - winner.getAllocationKey(), - winner.getExpireAt() + try { + long reserveResult = stockRedisService.reserve( + command.getBidId(), + allocationKey.toString(), + command.getIdempotencyKey().toString(), + command.getUserId().toString(), + command.getQuantity(), + keyTtl ); - } - // 비관락 - BidCompetition bid = bidCompetitionRepository.findByIdForUpdate(command.getBidId()); + // 처리중이거나 이미 처리된 작업 + if (reserveResult == -2L) { + + Winner winner = winnerRepository.findByIdempotencyKey(command.getBidId(), command.getIdempotencyKey()); + + if (winner != null) { + return BidResponse.success( + winner.getBidId(), + winner.getQuantity(), + winner.getAllocationKey(), + winner.getExpireAt() + ); + } + + return BidResponse.processing( + command.getBidId(), + command.getQuantity(), + allocationKey, + "처리중/중복 요청" + ); + } - LocalDateTime expireAt = now.plusSeconds(validDurationSeconds); + if (reserveResult != 1L) { + return BidResponse.fail( + command.getBidId(), + command.getQuantity(), + "재고 부족 또는 확보 실패" + ); + } + } catch (RedisCommandTimeoutException e) { + return BidResponse.processing( + command.getBidId(), + command.getQuantity(), + allocationKey, + "일시적 네트워크 오류. 재시도해주세요." + ); + } catch (Exception e) { + log.error("reserve 단계 예외", e); + + stockRedisService.rollback( + command.getBidId(), + allocationKey.toString(), + command.getIdempotencyKey().toString(), + command.getQuantity() + ); - // 경쟁 상태 점검 - if (bid.isNotActive() || bid.isExpired(now)) { - log.info("판매 경쟁이 종료되었습니다."); return BidResponse.fail( command.getBidId(), - bid.getProductId(), command.getQuantity(), - "판매 경쟁이 종료되어 주문을 받을 수 없습니다." + "예기치 못한 예외 발생" ); } - // 재고 확인 및 확보 - int updated = bidCompetitionRepository.decreaseStock( - command.getBidId(), - command.getQuantity(), - now - ); + try { + Winner winner = winnerRepository.findByIdempotencyKey(command.getBidId(), command.getIdempotencyKey()); - // 재고 확보 실패 - if (updated == 0) { - log.info("재고 확보에 실패했습니다 userId : {}, bidId : {}, quantity : {}", - command.getUserId(), command.getBidId(), command.getQuantity()); - return BidResponse.fail( + if (winner != null) { + log.info("이미 처리된 작업입니다. userId : {}, bidId : {} idempotencyKey : {}", + command.getUserId(), command.getBidId(), command.getIdempotencyKey()); + + stockRedisService.rollback( + command.getBidId(), + allocationKey.toString(), + command.getIdempotencyKey().toString(), + command.getQuantity() + ); + + return BidResponse.success( + winner.getBidId(), + winner.getQuantity(), + winner.getAllocationKey(), + winner.getExpireAt() + ); + } + + // 비관락 + BidCompetition bid = bidCompetitionRepository.findByIdForUpdate(command.getBidId()); + + // 경쟁 상태 점검 + if (bid.isNotAvailable() || bid.isEnd(now)) { + log.info("판매 경쟁이 종료되었습니다."); + + stockRedisService.rollback( + command.getBidId(), + allocationKey.toString(), + command.getIdempotencyKey().toString(), + command.getQuantity() + ); + + return BidResponse.fail( + command.getBidId(), + command.getQuantity(), + "판매 경쟁이 종료되어 주문을 받을 수 없습니다." + ); + } + + // 재고 확인 및 확보 + int updated = bidCompetitionRepository.decreaseStock( command.getBidId(), - bid.getProductId(), command.getQuantity(), - "재고 확보에 실패했습니다." + now ); - } - log.info("expiredAt : {}", expireAt); + // 재고 확보 실패 + if (updated == 0) { + log.info("재고 확보에 실패했습니다 userId : {}, bidId : {}, quantity : {}", + command.getUserId(), command.getBidId(), command.getQuantity()); + + stockRedisService.rollback( + command.getBidId(), + allocationKey.toString(), + command.getIdempotencyKey().toString(), + command.getQuantity() + ); - UUID allocationKey = UUID.randomUUID(); - Winner newWinner = Winner.create( - command.getUserId(), - bid.getId(), - bid.getProductId(), - command.getQuantity(), - allocationKey, - command.getIdempotencyKey(), - now, - expireAt - ); + return BidResponse.fail( + command.getBidId(), + command.getQuantity(), + "재고 확보에 실패했습니다." + ); + } - // Winner 등록 - Winner savedWinner = winnerRepository.save(newWinner); - - WinnerCreatedEvent event = WinnerCreatedEvent.of( - command.getUserId(), - bid.getProductId(), - bid.getProductPrice().intValue(), // FIXME: 나중에 수정해야 함 - command.getQuantity(), - bid.getCategoryId(), - bid.getSellerId(), - allocationKey, - expireAt, - command.getStreet(), - command.getCity(), - command.getZipcode() - ); + Winner newWinner = Winner.create( + command.getUserId(), + bid.getId(), + bid.getProductId(), + command.getQuantity(), + allocationKey, + command.getIdempotencyKey(), + now, + expireAt + ); - String idempotencyKey = InventoryChangeType.RESERVE.idempotencyKey( - String.valueOf(allocationKey) - ); + // Winner 등록 + Winner savedWinner = winnerRepository.save(newWinner); - Integer delta = command.getQuantity(); + WinnerCreatedEvent event = WinnerCreatedEvent.of( + command.getUserId(), + bid.getProductId(), + bid.getProductPrice().intValue(), + command.getQuantity(), + bid.getCategoryId(), + bid.getSellerId(), + allocationKey, + expireAt, + command.getStreet(), + command.getCity(), + command.getZipcode() + ); - Integer stockBefore = bid.getStock(); - Integer stockAfter = stockBefore - delta; + String idempotencyKey = InventoryChangeType.RESERVE.idempotencyKey( + String.valueOf(allocationKey) + ); - BidInventoryLog log = BidInventoryLog.create( - bid.getId(), - savedWinner.getId(), - InventoryChangeType.RESERVE, - stockBefore, - stockAfter, - delta, - idempotencyKey, - now - ); + Integer delta = command.getQuantity(); - bidInventoryLogRepository.saveAndFlush(log); + Integer stockBefore = bid.getStock(); + Integer stockAfter = stockBefore - delta; - Outbox outbox = Outbox.create( - AggregateType.BID, - bid.getId(), - EventType.BID_WINNER_SELECTED, - UUID.randomUUID(), - makePayload(event) - ); + BidInventoryLog log = BidInventoryLog.create( + bid.getId(), + savedWinner.getId(), + InventoryChangeType.RESERVE, + stockBefore, + stockAfter, + delta, + idempotencyKey, + now + ); + + bidInventoryLogRepository.saveAndFlush(log); - if (tracer.currentSpan() != null) { - outbox.attachTracing( - tracer.currentSpan().context().traceId(), - tracer.currentSpan().context().spanId() + Outbox outbox = Outbox.create( + AggregateType.BID, + bid.getId(), + EventType.BID_WINNER_SELECTED, + UUID.randomUUID(), + makePayload(event) ); - } + if (tracer.currentSpan() != null) { + outbox.attachTracing( + tracer.currentSpan().context().traceId(), + tracer.currentSpan().context().spanId() + ); + } + outboxRepository.save(outbox); - // Winner가 등록된 후, 등록되었음을 알리는 이벤트 발행 - outboxRepository.save(outbox); + return BidResponse.success( + savedWinner.getBidId(), + savedWinner.getQuantity(), + savedWinner.getAllocationKey(), + savedWinner.getExpireAt() + ); + } catch (Exception e) { - return BidResponse.success( - savedWinner.getBidId(), - savedWinner.getProductId(), - savedWinner.getQuantity(), - savedWinner.getAllocationKey(), - savedWinner.getExpireAt() - ); + stockRedisService.rollback( + command.getBidId(), + allocationKey.toString(), + command.getIdempotencyKey().toString(), + command.getQuantity() + ); + + throw e; + } } @@ -305,6 +418,12 @@ public ServiceResult orderCompleted(OrderCompletedCommand command) { throw new WinnerConflictException(BidErrorCode.WINNER_CONFLICT); } + stockRedisService.confirmCleanup( + winner.getBidId(), + winner.getAllocationKey().toString(), + winner.getIdempotencyKey().toString() + ); + return ServiceResult.SUCCESS; } @@ -374,6 +493,13 @@ public void orderFailed(OrderFailedCommand command) { log.error("예기치 못한 예외로 인해 처리하지 못했습니다. allocationKey : {}", command.getAllocationKey()); throw new WinnerConflictException(BidErrorCode.WINNER_CONFLICT); } + + stockRedisService.rollback( + winner.getBidId(), + winner.getAllocationKey().toString(), + winner.getIdempotencyKey().toString(), + winner.getQuantity() + ); } @Transactional @@ -480,6 +606,20 @@ public void refundSuccess(RefundSucceededCommand command) { throw new WinnerConflictException(BidErrorCode.WINNER_CONFLICT); } } + + + long restored = stockRedisService.refundRestore( + winner.getBidId(), + command.getRefundId(), + delta + ); + if (restored == 0) { + log.info("이미 처리된 환불 복구입니다. bidId={}, refundId={}", winner.getBidId(), + command.getRefundId()); + } else if (restored < 0) { + log.error("환불 복구 실패(redis) bidId={}, refundId={}, result={}", winner.getBidId(), + command.getRefundId(), restored); + } } // TODO: 나중에 클래스로 분리할 예정 diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/application/service/BidProcessor.java b/bidcompetition/src/main/java/com/smore/bidcompetition/application/service/BidProcessor.java index 5a9b2f6a..3e2c8469 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/application/service/BidProcessor.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/application/service/BidProcessor.java @@ -10,6 +10,7 @@ import com.smore.bidcompetition.domain.model.Winner; import com.smore.bidcompetition.domain.status.InventoryChangeType; import com.smore.bidcompetition.infrastructure.error.BidErrorCode; +import com.smore.bidcompetition.infrastructure.redis.StockRedisService; import java.time.Clock; import java.time.LocalDateTime; import java.util.List; @@ -37,6 +38,7 @@ public class BidProcessor { private final BidCompetitionRepository bidCompetitionRepository; private final BidInventoryLogRepository bidInventoryLogRepository; + private final StockRedisService stockRedisService; private final WinnerRepository winnerRepository; private final BidEndFinalizer bidEndFinalizer; private final Clock clock; @@ -174,6 +176,13 @@ public void recoveryStock(UUID winnerId) { winner.getId(), winner.getBidId()); throw new BidConflictException(BidErrorCode.BID_CONFLICT); } + + stockRedisService.rollback( + winner.getBidId(), + winner.getAllocationKey().toString(), + winner.getIdempotencyKey().toString(), + winner.getQuantity() + ); } } diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/domain/model/BidCompetition.java b/bidcompetition/src/main/java/com/smore/bidcompetition/domain/model/BidCompetition.java index ac66bb7a..f561e721 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/domain/model/BidCompetition.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/domain/model/BidCompetition.java @@ -106,12 +106,12 @@ public static BidCompetition of( .build(); } - public boolean isExpired(LocalDateTime now) { + public boolean isEnd(LocalDateTime now) { return this.endAt.isBefore(now); } - public boolean isNotActive() { - return this.bidStatus != BidStatus.ACTIVE; + public boolean isNotAvailable() { + return this.bidStatus != BidStatus.ACTIVE && this.bidStatus != BidStatus.CLOSED; } public boolean isEnd() { diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/AsyncConfig.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/AsyncConfig.java index 87a6edde..51ba2a63 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/AsyncConfig.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/AsyncConfig.java @@ -14,11 +14,13 @@ public class AsyncConfig { public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(10); - executor.setMaxPoolSize(100); - executor.setQueueCapacity(50); + executor.setCorePoolSize(60); + executor.setMaxPoolSize(60); + executor.setQueueCapacity(200); executor.setThreadNamePrefix("task-worker"); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); + executor.setPrestartAllCoreThreads(true); return executor; } @@ -41,7 +43,7 @@ public Executor winnerTaskExecutor() { executor.setCorePoolSize(10); executor.setMaxPoolSize(10); - executor.setQueueCapacity(50); + executor.setQueueCapacity(100); executor.setThreadNamePrefix("winner-task"); executor.initialize(); return executor; diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/KafkaConfig.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/KafkaConfig.java index 6a7486c8..e6377f2e 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/KafkaConfig.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/KafkaConfig.java @@ -24,7 +24,7 @@ public class KafkaConfig { @Bean public NewTopic bidWinnerConfirmTopic() { return TopicBuilder.name(bidWinnerConfirm) - .partitions(3) + .partitions(5) .replicas(3) .build(); } diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/RedisLuaConfig.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/RedisLuaConfig.java new file mode 100644 index 00000000..9714a449 --- /dev/null +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/RedisLuaConfig.java @@ -0,0 +1,49 @@ +package com.smore.bidcompetition.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +@Configuration +public class RedisLuaConfig { + @Bean + public DefaultRedisScript reserveStockScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setLocation(new ClassPathResource("redis/lua/reserve_stock.lua")); + script.setResultType(Long.class); + return script; + } + + @Bean + public DefaultRedisScript rollbackRestoreScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setLocation(new ClassPathResource("redis/lua/rollback_restore.lua")); + script.setResultType(Long.class); + return script; + } + + @Bean + public DefaultRedisScript confirmCleanupScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setLocation(new ClassPathResource("redis/lua/confirm_cleanup.lua")); + script.setResultType(Long.class); + return script; + } + + @Bean + public DefaultRedisScript setStockScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setLocation(new ClassPathResource("redis/lua/setting_stock.lua")); + script.setResultType(Long.class); + return script; + } + + @Bean + public DefaultRedisScript refundRestoreScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setLocation(new ClassPathResource("redis/lua/refund_restore.lua")); + script.setResultType(Long.class); + return script; + } +} diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/entity/WinnerEntity.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/entity/WinnerEntity.java index 21908f5f..93b9f446 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/entity/WinnerEntity.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/entity/WinnerEntity.java @@ -32,7 +32,7 @@ ), @UniqueConstraint( name = "uk_winner_idempotency_key", - columnNames = {"idempotency_key"} + columnNames = {"bid_id", "idempotency_key"} ) } ) diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerJpaRepositoryCustom.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerJpaRepositoryCustom.java index 41fbef9e..efa739c2 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerJpaRepositoryCustom.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerJpaRepositoryCustom.java @@ -10,7 +10,7 @@ public interface WinnerJpaRepositoryCustom { - WinnerEntity findByIdempotencyKey(UUID idempotencyKey); + WinnerEntity findByIdempotencyKey(UUID bidId, UUID idempotencyKey); WinnerEntity findByAllocationKey(UUID allocationKey); diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerJpaRepositoryCustomImpl.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerJpaRepositoryCustomImpl.java index 66688eb4..99a38457 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerJpaRepositoryCustomImpl.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerJpaRepositoryCustomImpl.java @@ -25,12 +25,13 @@ public class WinnerJpaRepositoryCustomImpl implements WinnerJpaRepositoryCustom{ private final EntityManager em; @Override - public WinnerEntity findByIdempotencyKey(UUID idempotencyKey) { + public WinnerEntity findByIdempotencyKey(UUID bidId, UUID idempotencyKey) { return queryFactory .select(winnerEntity) .from(winnerEntity) .where( + winnerEntity.bidId.eq(bidId), winnerEntity.idempotencyKey.eq(idempotencyKey) ) .fetchOne(); diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerRepositoryImpl.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerRepositoryImpl.java index 676e8e70..806d387a 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerRepositoryImpl.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/persistence/repository/winner/WinnerRepositoryImpl.java @@ -34,9 +34,9 @@ public Winner findById(UUID winnerId) { } @Override - public Winner findByIdempotencyKey(UUID idempotencyKey) { + public Winner findByIdempotencyKey(UUID bidId, UUID idempotencyKey) { - WinnerEntity entity = winnerJpaRepository.findByIdempotencyKey(idempotencyKey); + WinnerEntity entity = winnerJpaRepository.findByIdempotencyKey(bidId, idempotencyKey); if (entity == null) return null; diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisArgs.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisArgs.java new file mode 100644 index 00000000..6fdc258f --- /dev/null +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisArgs.java @@ -0,0 +1,42 @@ +package com.smore.bidcompetition.infrastructure.redis; + +import org.springframework.stereotype.Component; + +@Component +public class StockRedisArgs { + + // Object[] 타입을 반환하는 이유는 RedisTemplate.execute()의 시그니처를 맞추기 위함 + public Object[] reserveArgs(String userId, int quantity, long winnerTtlSeconds, long idemTtlSeconds) { + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("userId는 필수값입니다."); + } + if (quantity <= 0) { + throw new IllegalArgumentException("quantity는 1 이상이어야 합니다."); + } + if (winnerTtlSeconds <= 0) { + throw new IllegalArgumentException("winnerTtlSeconds는 1 이상이어야 합니다."); + } + if (idemTtlSeconds <= 0) { + throw new IllegalArgumentException("idemTtlSeconds는 1 이상이어야 합니다."); + } + + return new Object[] { + userId, + String.valueOf(quantity), + String.valueOf(winnerTtlSeconds), + String.valueOf(idemTtlSeconds) + }; + } + + public Object[] rollbackArgs(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("quantity는 1 이상이어야 합니다."); + } + return new Object[] { String.valueOf(quantity) }; + } + + public Object[] confirmArgs() { + return new Object[0]; + } + +} diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisKeys.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisKeys.java new file mode 100644 index 00000000..9dc69064 --- /dev/null +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisKeys.java @@ -0,0 +1,37 @@ +package com.smore.bidcompetition.infrastructure.redis; + +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class StockRedisKeys { + + private static final String STOCK_PREFIX = "stock"; + private static final String WINNER_PREFIX = "winner"; + private static final String IDEM_PREFIX = "idem"; + private static final String REFUND_PREFIX = "refund"; + + + public String stockKey(UUID bidId) { + if (bidId == null) throw new IllegalArgumentException("bidId는 필수값입니다."); + return STOCK_PREFIX + ":{" + bidId + "}"; + } + + public String winnerKey(UUID bidId, String allocationKey) { + if (bidId == null) throw new IllegalArgumentException("bidId는 필수값입니다."); + if (allocationKey == null) throw new IllegalArgumentException("allocationKey는 필수값입니다."); + return WINNER_PREFIX + ":{" + bidId + "}:" + allocationKey; + } + + public String idemKey(UUID bidId, String idempotencyKey) { + if (bidId == null) throw new IllegalArgumentException("bidId는 필수값입니다."); + if (idempotencyKey == null) throw new IllegalArgumentException("idempotencyKey는 필수값입니다."); + return IDEM_PREFIX + ":{" + bidId + "}:" + idempotencyKey; + } + + public String refundKey(UUID bidId, UUID refundId) { + if (bidId == null) throw new IllegalArgumentException("bidId는 필수값입니다."); + if (refundId == null) throw new IllegalArgumentException("refundId는 필수값입니다."); + return REFUND_PREFIX + ":{" + bidId + "}:" + refundId; + } +} diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisService.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisService.java new file mode 100644 index 00000000..f3b98cc4 --- /dev/null +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisService.java @@ -0,0 +1,77 @@ +package com.smore.bidcompetition.infrastructure.redis; + +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StockRedisService { + + private final StringRedisTemplate redis; + private final DefaultRedisScript reserveStockScript; + private final DefaultRedisScript rollbackRestoreScript; + private final DefaultRedisScript refundRestoreScript; + private final DefaultRedisScript confirmCleanupScript; + private final DefaultRedisScript setStockScript; + private final StockRedisKeys keys; + private final StockRedisArgs args; + + public long reserve(UUID bidId, String allocationKey, String idemKey, String userId, + int quantity, long ttl) { + + return redis.execute( + reserveStockScript, + List.of( + keys.stockKey(bidId), + keys.winnerKey(bidId, allocationKey), + keys.idemKey(bidId, idemKey) + ), + args.reserveArgs(userId, quantity, ttl, ttl) + ); + } + + public long rollback(UUID bidId, String allocationKey, String idemKey, int quantity) { + return redis.execute( + rollbackRestoreScript, + List.of( + keys.stockKey(bidId), + keys.winnerKey(bidId, allocationKey), + keys.idemKey(bidId, idemKey) + ), + args.rollbackArgs(quantity) + ); + } + + public long refundRestore(UUID bidId, UUID refundId, int quantity) { + return redis.execute( + refundRestoreScript, + List.of( + keys.stockKey(bidId), + keys.refundKey(bidId, refundId) + ), + args.rollbackArgs(quantity) + ); + } + + public void confirmCleanup(UUID bidId, String allocationKey, String idemKey) { + redis.execute( + confirmCleanupScript, + List.of( + keys.winnerKey(bidId, allocationKey), + keys.idemKey(bidId, idemKey) + ) + ); + } + + public long setStock(UUID bidId, int stockQuantity) { + return redis.execute( + setStockScript, + List.of(keys.stockKey(bidId)), + String.valueOf(stockQuantity) + ); + } +} diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/dto/BidResponse.java b/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/dto/BidResponse.java index 0ad2ce35..ecc75a71 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/dto/BidResponse.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/dto/BidResponse.java @@ -12,7 +12,6 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) public class BidResponse { private UUID bidId; - private UUID productId; private Integer quantity; private UUID allocationKey; private String expireAt; @@ -20,14 +19,12 @@ public class BidResponse { public static BidResponse success( UUID bidId, - UUID productId, Integer quantity, UUID allocationKey, LocalDateTime expireAt ) { return BidResponse.builder() .bidId(bidId) - .productId(productId) .quantity(quantity) .allocationKey(allocationKey) .expireAt(expireAt.toString()) @@ -37,15 +34,27 @@ public static BidResponse success( public static BidResponse fail( UUID bidId, - UUID productId, Integer quantity, String message ) { return BidResponse.builder() .bidId(bidId) - .productId(productId) .quantity(quantity) .message(message) .build(); } + + public static BidResponse processing( + UUID bidId, + Integer quantity, + UUID allocationKey, + String message + ) { + return BidResponse.builder() + .bidId(bidId) + .quantity(quantity) + .allocationKey(allocationKey) + .message(message) + .build(); + } } diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/scheduler/BidScheduler.java b/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/scheduler/BidScheduler.java index 109563b5..58457c75 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/scheduler/BidScheduler.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/scheduler/BidScheduler.java @@ -27,10 +27,10 @@ public void bidLifecycleScheduler() { // TODO: 페이지 스킵 발생하므로 이를 해결해야 함 // TODO: @Async 비동기 예외 모니터링/재시도 처리 추가 - @Scheduled(fixedDelay = 30_000) + @Scheduled(fixedDelay = 10_000) public void recoveryExpiredStockScheduler() { int page = 0; - int pageSize = 100; + int pageSize = 50; while (true) { Page taskIds = bidProcessor.getExpiredWinnerIds( diff --git a/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/scheduler/OutboxScheduler.java b/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/scheduler/OutboxScheduler.java index f5035b83..5a704645 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/scheduler/OutboxScheduler.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/presentation/scheduler/OutboxScheduler.java @@ -24,10 +24,10 @@ public class OutboxScheduler { EventStatus.PENDING ); - @Scheduled(fixedDelay = 10000) + @Scheduled(fixedDelay = 1000) public void outboxTasks() { int page = 0; - int pageSize = 100; + int pageSize = 50; while (true) { Page taskIds = outboxRepository.findPendingIds( diff --git a/bidcompetition/src/main/resources/application-dev.yml b/bidcompetition/src/main/resources/application-dev.yml index efcab971..f30f9255 100644 --- a/bidcompetition/src/main/resources/application-dev.yml +++ b/bidcompetition/src/main/resources/application-dev.yml @@ -25,6 +25,12 @@ spring: kafka: bootstrap-servers: kafka-1:9092,kafka-2:9092,kafka-3:9092 + data: + redis: + host: redis-stack + port: 6379 + timeout: 10s + eureka: client: service-url: @@ -44,7 +50,7 @@ management: include: health,info,prometheus tracing: sampling: - probability: 1.0 + probability: 0.1 zipkin: tracing: endpoint: http://zipkin:9411/api/v2/spans \ No newline at end of file diff --git a/bidcompetition/src/main/resources/application-local.yml b/bidcompetition/src/main/resources/application-local.yml index 4c60bdf7..3a4d0912 100644 --- a/bidcompetition/src/main/resources/application-local.yml +++ b/bidcompetition/src/main/resources/application-local.yml @@ -24,6 +24,12 @@ spring: kafka: bootstrap-servers: localhost:19092,localhost:29092,localhost:39092 + data: + redis: + host: redis-bid + port: 6379 + timeout: 2s + eureka: client: service-url: diff --git a/bidcompetition/src/main/resources/application-prod.yml b/bidcompetition/src/main/resources/application-prod.yml new file mode 100644 index 00000000..fed3e4f1 --- /dev/null +++ b/bidcompetition/src/main/resources/application-prod.yml @@ -0,0 +1,75 @@ +spring: + config: + activate: + on-profile: prod + + sql: + init: + mode: always + + datasource: + url: jdbc:postgresql://${RDS_END_POINT}/bid + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + show_sql: true + format_sql: true + use_sql_comments: true + defer-datasource-initialization: true + + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + properties: + security.protocol: SASL_SSL + sasl.mechanism: AWS_MSK_IAM + sasl.jaas.config: software.amazon.msk.auth.iam.IAMLoginModule required; + sasl.client.callback.handler.class: software.amazon.msk.auth.iam.IAMClientCallbackHandler + + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + acks: all + retries: 10 + properties: + enable.idempotence: true + + consumer: + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + group-id: order-service-consumer-group + auto-offset-reset: earliest + enable-auto-commit: false + + listener: + ack-mode: manual + observation-enabled: true + + data: + redis: + url: ${ELASTIC_CACHE_ADDR} + timeout: 2s + repositories: + enabled: false + +eureka: + client: + enabled: false + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + tracing: + sampling: + probability: 1.0 + + zipkin: + tracing: + endpoint: http://zipkin:9411/api/v2/spans \ No newline at end of file diff --git a/bidcompetition/src/main/resources/redis/lua/confirm_cleanup.lua b/bidcompetition/src/main/resources/redis/lua/confirm_cleanup.lua new file mode 100644 index 00000000..445d18f3 --- /dev/null +++ b/bidcompetition/src/main/resources/redis/lua/confirm_cleanup.lua @@ -0,0 +1,7 @@ +-- KEYS[1] = winner:{bidId}:{allocationKey} +-- KEYS[2] = idem:{bidId}:{idempotencyKey} +-- return 1 always (idempotent) + +redis.call('DEL', KEYS[1]) +redis.call('DEL', KEYS[2]) +return 1 \ No newline at end of file diff --git a/bidcompetition/src/main/resources/redis/lua/refund_restore.lua b/bidcompetition/src/main/resources/redis/lua/refund_restore.lua new file mode 100644 index 00000000..903ad462 --- /dev/null +++ b/bidcompetition/src/main/resources/redis/lua/refund_restore.lua @@ -0,0 +1,24 @@ +-- 환불 시 재고 복구 (winner/idempotency 키가 이미 정리된 경우용) +-- KEYS[1] = stock:{bidId} +-- KEYS[2] = refund:{bidId}:{refundId} (idempotency) +-- ARGV[1] = quantity +-- +-- return: +-- 1 restored +-- 0 already restored (idempotent) +-- -3 invalid args + +local quantity = tonumber(ARGV[1]) +if (not quantity) or quantity <= 0 then + return -3 +end + +-- 이미 복구된 환불이면 재실행 방지 +if redis.call('EXISTS', KEYS[2]) == 1 then + return 0 +end + +redis.call('INCRBY', KEYS[1], quantity) +redis.call('SET', KEYS[2], '1', 'EX', 60 * 60 * 24) -- 1 day TTL for traceability + +return 1 diff --git a/bidcompetition/src/main/resources/redis/lua/reserve_stock.lua b/bidcompetition/src/main/resources/redis/lua/reserve_stock.lua new file mode 100644 index 00000000..0828a204 --- /dev/null +++ b/bidcompetition/src/main/resources/redis/lua/reserve_stock.lua @@ -0,0 +1,42 @@ +-- KEYS[1] = stock:{bidId} +-- KEYS[2] = winner:{bidId}:{allocationKey} +-- KEYS[3] = idem:{bidId}:{idempotencyKey} +-- +-- ARGV[1] = userId +-- ARGV[2] = quantity +-- ARGV[3] = winnerTtlSeconds +-- ARGV[4] = idemTtlSeconds + +-- +-- return: +-- 1 success +-- 0 insufficient / invalid +-- -2 duplicate (idem) + +if redis.call('EXISTS', KEYS[3]) == 1 then + return -2 +end + +local stockStr = redis.call('GET', KEYS[1]) +if not stockStr then + return 0 +end + +local stock = tonumber(stockStr) +local quantity = tonumber(ARGV[2]) +local winnerTtlSeconds = tonumber(ARGV[3]) +local idemTtlSeconds = tonumber(ARGV[4]) + +if (not stock) or (not quantity) or quantity <= 0 or (not winnerTtlSeconds) or winnerTtlSeconds <= 0 or (not idemTtlSeconds) or idemTtlSeconds <= 0 then + return 0 +end + +if stock < quantity then + return 0 +end + +redis.call('DECRBY', KEYS[1], quantity) +redis.call('SET', KEYS[2], ARGV[1], 'EX', winnerTtlSeconds) +redis.call('SET', KEYS[3], '1', 'EX', idemTtlSeconds) + +return 1 \ No newline at end of file diff --git a/bidcompetition/src/main/resources/redis/lua/rollback_restore.lua b/bidcompetition/src/main/resources/redis/lua/rollback_restore.lua new file mode 100644 index 00000000..ad08c041 --- /dev/null +++ b/bidcompetition/src/main/resources/redis/lua/rollback_restore.lua @@ -0,0 +1,23 @@ +-- KEYS[1] = stock:{bidId} +-- KEYS[2] = winner:{bidId}:{allocationKey} +-- KEYS[3] = idem:{bidId}:{idempotencyKey} +-- ARGV[1] = quantity +-- +-- return: +-- 1 rolled back +-- 0 nothing to rollback (already done / no hold) +-- -3 invalid args + +local quantity = tonumber(ARGV[1]) +if (not quantity) or quantity <= 0 then + return -3 +end + +-- winner가 있을 때만 재고 복구 (중복복구 방지) +if redis.call('DEL', KEYS[2]) == 1 then + redis.call('INCRBY', KEYS[1], quantity) + redis.call('DEL', KEYS[3]) -- 실패면 재시도 허용(권장) + return 1 +end + +return 0 diff --git a/bidcompetition/src/main/resources/redis/lua/setting_stock.lua b/bidcompetition/src/main/resources/redis/lua/setting_stock.lua new file mode 100644 index 00000000..b3d0d0ff --- /dev/null +++ b/bidcompetition/src/main/resources/redis/lua/setting_stock.lua @@ -0,0 +1,14 @@ +-- KEYS[1] = stock:{bidId} +-- ARGV[1] = stockQuantity + +local stock = tonumber(ARGV[1]) +if not stock or stock < 0 then + return -1 +end + +if redis.call('EXISTS', KEYS[1]) == 1 then + return 0 +end + +redis.call('SET', KEYS[1], stock) +return stock diff --git a/order/build.gradle b/order/build.gradle index 8d5a0e4d..11125925 100644 --- a/order/build.gradle +++ b/order/build.gradle @@ -70,6 +70,9 @@ dependencies { // prometheus implementation 'io.micrometer:micrometer-registry-prometheus' + // MSK 설치 + implementation 'software.amazon.msk:aws-msk-iam-auth:2.2.0' + } dependencyManagement { diff --git a/order/src/main/resources/application-prod.yml b/order/src/main/resources/application-prod.yml new file mode 100644 index 00000000..92f72545 --- /dev/null +++ b/order/src/main/resources/application-prod.yml @@ -0,0 +1,64 @@ +spring: + config: + activate: + on-profile: prod + + sql: + init: + mode: always + + datasource: + url: jdbc:postgresql://${RDS_END_POINT}/d_order + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: false + properties: + hibernate: + format_sql: true + defer-datasource-initialization: true + + + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + properties: + security.protocol: SASL_SSL + sasl.mechanism: AWS_MSK_IAM + sasl.jaas.config: software.amazon.msk.auth.iam.IAMLoginModule required; + sasl.client.callback.handler.class: software.amazon.msk.auth.iam.IAMClientCallbackHandler + + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + acks: all + retries: 10 + properties: + enable.idempotence: true + + consumer: + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + group-id: order-service-consumer-group + auto-offset-reset: earliest + enable-auto-commit: false + + listener: + ack-mode: manual + observation-enabled: true + +eureka: + client: + enabled: false + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + tracing: + sampling: + probability: 1.0 \ No newline at end of file