From de07a833baefc2ee18ee6e9acee5006491396cff Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Thu, 18 Dec 2025 21:42:35 +0900 Subject: [PATCH 01/14] =?UTF-8?q?chore(Bid)=20:=20=EB=A0=88=EB=94=94?= =?UTF-8?q?=EC=8A=A4=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bidcompetition/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bidcompetition/build.gradle b/bidcompetition/build.gradle index 862f7974..017aa9d7 100644 --- a/bidcompetition/build.gradle +++ b/bidcompetition/build.gradle @@ -69,6 +69,9 @@ dependencies { // prometheus implementation 'io.micrometer:micrometer-registry-prometheus' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } dependencyManagement { From 895158050e12cf3826cc1e33a09a4a1535122a5c Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 02:33:49 +0900 Subject: [PATCH 02/14] =?UTF-8?q?chore(Bid)=20:=20=EB=A0=88=EB=94=94?= =?UTF-8?q?=EC=8A=A4=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bidcompetition/src/main/resources/application-dev.yml | 8 +++++++- bidcompetition/src/main/resources/application-local.yml | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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: From bfabe3c9818fa1ab87093e92456c51265a2932fe Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 02:35:20 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(Bid)=20:=20=EB=A0=88=EB=94=94?= =?UTF-8?q?=EC=8A=A4=EC=97=90=EC=84=9C=20=EC=8B=A4=ED=96=89=ED=95=A0=20Lua?= =?UTF-8?q?=20Script=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/redis/lua/confirm_cleanup.lua | 7 ++++ .../resources/redis/lua/reserve_stock.lua | 42 +++++++++++++++++++ .../resources/redis/lua/rollback_restore.lua | 23 ++++++++++ .../resources/redis/lua/setting_stock.lua | 14 +++++++ 4 files changed, 86 insertions(+) create mode 100644 bidcompetition/src/main/resources/redis/lua/confirm_cleanup.lua create mode 100644 bidcompetition/src/main/resources/redis/lua/reserve_stock.lua create mode 100644 bidcompetition/src/main/resources/redis/lua/rollback_restore.lua create mode 100644 bidcompetition/src/main/resources/redis/lua/setting_stock.lua 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/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 From 2d9edb6de01ba4f9479dbcf62c986bd43a9b67d4 Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 02:36:31 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat(Bid)=20:=20Lua=20Scripte=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=B9=88=EB=93=A4=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/config/RedisLuaConfig.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/RedisLuaConfig.java 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..85c887c0 --- /dev/null +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/RedisLuaConfig.java @@ -0,0 +1,41 @@ +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; + } +} From d5cae724b5c2e2909d606916318fb57ca0fcccaa Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 02:39:00 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat(Bid)=20:=20StockRedisService(Lua=20S?= =?UTF-8?q?cripte=EB=A5=BC=20=EC=8B=A4=ED=96=89=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84),=20StockRedisKeys(?= =?UTF-8?q?=EB=A0=88=EB=94=94=EC=8A=A4=20=ED=82=A4=EB=A5=BC=20=EB=A7=8C?= =?UTF-8?q?=EB=93=9C=EB=8A=94=20=EB=A1=9C=EC=A7=81),=20StockRedisArgs(?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=EB=90=A0=20=EC=9D=B8=EC=9E=90=20=EB=B0=B0?= =?UTF-8?q?=EC=97=B4=20=EB=A7=8C=EB=93=9C=EB=8A=94=20=EB=A1=9C=EC=A7=81)?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/redis/StockRedisArgs.java | 42 ++++++++++++ .../infrastructure/redis/StockRedisKeys.java | 30 +++++++++ .../redis/StockRedisService.java | 65 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisArgs.java create mode 100644 bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisKeys.java create mode 100644 bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisService.java 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..e577d6a9 --- /dev/null +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisKeys.java @@ -0,0 +1,30 @@ +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"; + + + 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; + } +} 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..76b2d809 --- /dev/null +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisService.java @@ -0,0 +1,65 @@ +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 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 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) + ); + } +} From 5c61bf87d5bda088d333f7e6bbf4e9fdc38efa3f Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 02:42:53 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix(Bid)=20:=20winner=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=8B=9C=20bidId=EB=8F=84=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EC=97=AC=20=EA=B2=BD=EC=9F=81?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EB=A9=B1=EB=93=B1=ED=82=A4=EA=B0=80=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/repository/WinnerRepository.java | 2 +- .../repository/winner/WinnerJpaRepositoryCustom.java | 2 +- .../repository/winner/WinnerJpaRepositoryCustomImpl.java | 3 ++- .../persistence/repository/winner/WinnerRepositoryImpl.java | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) 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/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; From 41c51cba08c72533dce17d7d74bdd2fa76ac62d3 Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 02:44:16 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix(Bid)=20:=20bid=5Fid,=20idempotencyKey?= =?UTF-8?q?=20=EB=B3=B5=ED=95=A9=ED=82=A4=EB=A1=9C=20=EC=9C=A0=EB=8B=88?= =?UTF-8?q?=ED=81=AC=ED=82=A4=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/persistence/entity/WinnerEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"} ) } ) From 4be1c72711dd3b6de244d5a82c08a0c0bd04f4ab Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 02:45:59 +0900 Subject: [PATCH 08/14] =?UTF-8?q?fix(Bid)=20:=20isEnd()=EB=A1=9C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9D=B4=EB=A6=84=20=EB=AA=85?= =?UTF-8?q?=ED=99=95=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95=20=EB=B0=8F,?= =?UTF-8?q?=20ACTIVE,=20CLOSED=20=EC=83=81=ED=83=9C=EA=B0=80=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EB=9D=BC=EB=A9=B4=20isNotAvilable()=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20true=EB=A5=BC=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../smore/bidcompetition/domain/model/BidCompetition.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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() { From 6deec56d05b4a4a7df2d707346866d46d2fd5b9d Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 04:12:03 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat(Bid)=20:=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=EC=8B=9C,=20=EC=8B=A4=ED=96=89=EC=8B=9C=ED=82=AC=20Lua=20Scrip?= =?UTF-8?q?t=20=EC=B6=94=EA=B0=80,=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BidCompetitionService.java | 333 ++++++++++++------ .../infrastructure/config/RedisLuaConfig.java | 8 + .../infrastructure/redis/StockRedisKeys.java | 7 + .../redis/StockRedisService.java | 12 + .../resources/redis/lua/refund_restore.lua | 24 ++ 5 files changed, 284 insertions(+), 100 deletions(-) create mode 100644 bidcompetition/src/main/resources/redis/lua/refund_restore.lua 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..6e3d38ff 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 + ); - if (tracer.currentSpan() != null) { - outbox.attachTracing( - tracer.currentSpan().context().traceId(), - tracer.currentSpan().context().spanId() + bidInventoryLogRepository.saveAndFlush(log); + + 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,13 @@ public void refundSuccess(RefundSucceededCommand command) { throw new WinnerConflictException(BidErrorCode.WINNER_CONFLICT); } } + + stockRedisService.rollback( + winner.getBidId(), + winner.getAllocationKey().toString(), + winner.getIdempotencyKey().toString(), + delta + ); } // TODO: 나중에 클래스로 분리할 예정 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 index 85c887c0..9714a449 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/RedisLuaConfig.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/config/RedisLuaConfig.java @@ -38,4 +38,12 @@ public DefaultRedisScript setStockScript() { 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/redis/StockRedisKeys.java b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisKeys.java index e577d6a9..9dc69064 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisKeys.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisKeys.java @@ -9,6 +9,7 @@ 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) { @@ -27,4 +28,10 @@ public String idemKey(UUID bidId, String idempotencyKey) { 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 index 76b2d809..f3b98cc4 100644 --- a/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisService.java +++ b/bidcompetition/src/main/java/com/smore/bidcompetition/infrastructure/redis/StockRedisService.java @@ -14,6 +14,7 @@ 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; @@ -45,6 +46,17 @@ public long rollback(UUID bidId, String allocationKey, String idemKey, int quant ); } + 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, 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 From 896421c0eda63b84ec3beab98517c1be5034ad8c Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 04:14:13 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix(Bid)=20:=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=ED=9B=84,=20=EC=9E=AC=EA=B3=A0=20Redis=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EC=95=88=20=EB=90=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/BidCompetitionService.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 6e3d38ff..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 @@ -607,12 +607,19 @@ public void refundSuccess(RefundSucceededCommand command) { } } - stockRedisService.rollback( + + long restored = stockRedisService.refundRestore( winner.getBidId(), - winner.getAllocationKey().toString(), - winner.getIdempotencyKey().toString(), + 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: 나중에 클래스로 분리할 예정 From 276e11d6320c717267e87484a9d264a5c000c4f4 Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 04:15:43 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat(Bid)=20:=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=EA=B0=80=20=EC=9E=AC=EA=B3=A0=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=ED=95=A0=20=EB=95=8C,=20=EB=A0=88=EB=94=94=EC=8A=A4?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=A0=EB=8F=84=20=EB=B3=B5=EA=B5=AC=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20rollback()=20=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidcompetition/application/service/BidProcessor.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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() + ); } } From f7f2b21b6308527cc0fa9a1d8723d34d2d05256a Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 04:16:43 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat(Bid)=20:=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=A4=91=20=EC=9D=91=EB=8B=B5=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?processing()=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/dto/BidResponse.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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(); + } } From eabd2fafce2778437d8d911c314977eacd78eca4 Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 04:19:11 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix(Bid)=20:=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=ED=92=80=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=EA=B3=A0?= =?UTF-8?q?=EA=B0=88=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20Task=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EA=B0=80=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/config/AsyncConfig.java | 10 ++++++---- .../infrastructure/config/KafkaConfig.java | 2 +- .../presentation/scheduler/BidScheduler.java | 4 ++-- .../presentation/scheduler/OutboxScheduler.java | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) 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/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( From 9690042020ee25b2e7865f8927dd046be9d8aaa0 Mon Sep 17 00:00:00 2001 From: yoo20370 Date: Wed, 24 Dec 2025 04:21:42 +0900 Subject: [PATCH 14/14] =?UTF-8?q?chore(Bid,=20Order)=20:=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20MSK=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20application-prod.yml=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bidcompetition/build.gradle | 3 + .../src/main/resources/application-prod.yml | 75 +++++++++++++++++++ order/build.gradle | 3 + order/src/main/resources/application-prod.yml | 64 ++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 bidcompetition/src/main/resources/application-prod.yml create mode 100644 order/src/main/resources/application-prod.yml diff --git a/bidcompetition/build.gradle b/bidcompetition/build.gradle index 017aa9d7..8ccc4988 100644 --- a/bidcompetition/build.gradle +++ b/bidcompetition/build.gradle @@ -72,6 +72,9 @@ dependencies { // 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/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/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