From 42d15b61f7a7f38125295d59a994978e41cc05d5 Mon Sep 17 00:00:00 2001 From: Arin Lee Date: Sun, 12 Jan 2025 17:49:38 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(#22):=20Redis=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 Redisson 분산 락 방식의 DB 부하 문제 해결 - 분산 락으로 인한 DB 부하 한계를 개선하기 위해 Redis 캐싱 도입 - Redis 캐싱을 통해 쿠폰 발급 수량 및 상태를 캐싱하여 발급을 수행하고, 잔여 수량 부족 시 즉시 예외를 반환하도록 변경 - 로직 변경에 따른 컨트롤러 수정사항 반영 - 기존의 분산 락 코드 제거 - Redis 저장 시 JSON 직렬화를 위한 설정 추가 - isZero() 메서드로 인한 직렬화 문제를 해결하기 위해 메서드명을 checkIsZero 로 변경 Coupon 캐싱 구조 개선 - 더티 체킹 문제 방지를 위해 CouponCache 객체를 사용하고, 이를 통해 Redis에 저장 - CouponCache 클래스에 비즈니스 로직을 구현하여 쿠폰 발급을 처리 - CouponPrefix 클래스를 생성하여 Redis 키 네이밍을 일관되게 관리 --- .../application/CouponIssueService.java | 79 +++++++++++++++++++ .../application/CouponLockService.java | 38 --------- .../couponapi/application/CouponService.java | 23 ------ .../couponapi/common/CouponPrefix.java | 8 ++ .../couponapi/config/RedisConfig.java | 9 +++ .../couponapi/exception/CouponErrorCode.java | 3 +- .../presentation/CouponController.java | 9 ++- .../coupondomain/domain/coupon/Coupon.java | 2 +- .../domain/coupon/CouponCache.java | 79 +++++++++++++++++++ .../coupondomain/domain/coupon/Quantity.java | 2 +- 10 files changed, 184 insertions(+), 68 deletions(-) create mode 100644 service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java delete mode 100644 service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java create mode 100644 service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java create mode 100644 service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java new file mode 100644 index 0000000..83c09fc --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java @@ -0,0 +1,79 @@ +package com.couponify.couponapi.application; + + +import com.couponify.couponapi.common.CouponPrefix; +import com.couponify.couponapi.exception.CouponErrorCode; +import com.couponify.couponapi.exception.CouponException; +import com.couponify.coupondomain.domain.coupon.Coupon; +import com.couponify.coupondomain.domain.coupon.CouponCache; +import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; +import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RMap; +import org.redisson.api.RSet; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j(topic = "CouponIssueService") +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponIssueService { + + private static final int QUANTITY_TO_ISSUE_COUPON = 1; + private final CouponRepository couponRepository; + private final IssuedCouponRepository issuedCouponRepository; + private final RedissonClient redissonClient; + + @Transactional + public void issue(Long couponId, Long userId) { + CouponCache couponCache = getCouponCache(couponId); + checkUserAlreadyIssued(couponId, userId); + + // TODO User 검증 필요 + + couponCache.issue(QUANTITY_TO_ISSUE_COUPON); + + addIssuer(couponId, userId); + updateCouponCache(couponId, couponCache); + } + + private CouponCache getCouponCache(Long couponId) { + RMap couponInfo = redissonClient.getMap(CouponPrefix.COUPON_INFO); + CouponCache couponCache = couponInfo.get(couponId); + + if (couponCache == null) { + Coupon coupon = getCoupon(couponId); + couponCache = CouponCache.of(coupon); + couponInfo.put(couponId, couponCache); + } + + return couponCache; + } + + private Coupon getCoupon(Long couponId) { + return couponRepository.findById(couponId).orElseThrow( + () -> new CouponException(CouponErrorCode.COUPON_NOT_FOUND, couponId) + ); + } + + private void checkUserAlreadyIssued(Long couponId, Long userId) { + RSet issuedUsers = redissonClient.getSet(CouponPrefix.COUPON_ISSUER + couponId); + if (issuedUsers.contains(userId)) { + throw new CouponException(CouponErrorCode.COUPON_ALREADY_ISSUED); + } + } + + private void addIssuer(Long couponId, Long userId) { + RSet issuedUsers = redissonClient.getSet(CouponPrefix.COUPON_ISSUER + couponId); + issuedUsers.add(userId); + } + + private void updateCouponCache(Long couponId, CouponCache couponCache) { + RMap couponInfo = redissonClient.getMap(CouponPrefix.COUPON_INFO); + couponInfo.put(couponId, couponCache); + } + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java deleted file mode 100644 index 2756bef..0000000 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.couponify.couponapi.application; - -import com.couponify.couponapi.exception.CouponErrorCode; -import com.couponify.couponapi.exception.CouponException; -import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CouponLockService { - - private final RedissonClient redissonClient; - private final CouponService couponService; - private final CouponRepository couponRepository; - - public Long issueRLock(Long couponId, Long userId) { - RLock lock = redissonClient.getLock("issue:coupon:" + couponId); - - try { - if (lock.tryLock(10, 5, TimeUnit.SECONDS)) { - return couponService.issue(couponId, userId); - } else { - throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); - } - } catch (InterruptedException e) { - throw new RuntimeException(e); - } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } - } - } - -} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java index 6748e18..c210dfc 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java @@ -1,11 +1,8 @@ package com.couponify.couponapi.application; -import com.couponify.couponapi.exception.CouponErrorCode; -import com.couponify.couponapi.exception.CouponException; import com.couponify.couponapi.presentation.request.CouponCreateRequest; import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; -import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; import java.time.LocalDateTime; import java.util.List; @@ -20,7 +17,6 @@ @RequiredArgsConstructor public class CouponService { - private static final int QUANTITY_TO_ISSUE_COUPON = 1; private final CouponRepository couponRepository; private final IssuedCouponRepository issuedCouponRepository; @@ -31,19 +27,6 @@ public Long create(CouponCreateRequest couponCreateRequest) { return savedCoupon.getId(); } - @Transactional - public Long issue(Long couponId, Long userId) { - final Coupon coupon = validateCoupon(couponId); - coupon.issue(QUANTITY_TO_ISSUE_COUPON); - - //TODO User 검증 필요 - - final IssuedCoupon issuedCoupon = IssuedCoupon.of(userId, coupon); - final IssuedCoupon savedIssuedCoupon = issuedCouponRepository.save(issuedCoupon); - - return savedIssuedCoupon.getId(); - } - @Transactional public void expire() { List expiredCoupons = couponRepository.findExpiredCoupons(LocalDateTime.now()); @@ -54,10 +37,4 @@ public void expire() { log.info("Expired {} coupons", expiredCoupons.size()); } - private Coupon validateCoupon(Long couponId) { - return couponRepository.findById(couponId).orElseThrow( - () -> new CouponException(CouponErrorCode.COUPON_NOT_FOUND, couponId) - ); - } - } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java b/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java new file mode 100644 index 0000000..1cd1138 --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java @@ -0,0 +1,8 @@ +package com.couponify.couponapi.common; + +public class CouponPrefix { + + public static final String COUPON_INFO = "coupon:info"; + public static final String COUPON_ISSUER = "coupon:issuer:"; + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/config/RedisConfig.java b/service/coupon-api/src/main/java/com/couponify/couponapi/config/RedisConfig.java index 8c32d85..c77b296 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/config/RedisConfig.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/config/RedisConfig.java @@ -1,7 +1,10 @@ package com.couponify.couponapi.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.redisson.Redisson; import org.redisson.api.RedissonClient; +import org.redisson.codec.JsonJacksonCodec; import org.redisson.config.Config; import org.redisson.spring.data.connection.RedissonConnectionFactory; import org.springframework.beans.factory.annotation.Value; @@ -26,6 +29,12 @@ public RedissonClient redissonClient() { config.useSingleServer() .setAddress("redis://" + host + ":" + port) .setPassword(password); + + config.setCodec(new JsonJacksonCodec( + new ObjectMapper() + .registerModule(new JavaTimeModule()) + )); + return Redisson.create(config); } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java b/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java index 73d3e19..e9df44c 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java @@ -6,7 +6,8 @@ @Getter public enum CouponErrorCode { COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 쿠폰입니다. : [%s]"), - LOCK_ACQUISITION_FAILED(HttpStatus.LOCKED, "락을 획득할 수 없습니다."); + LOCK_ACQUISITION_FAILED(HttpStatus.LOCKED, "락을 획득할 수 없습니다."), + COUPON_ALREADY_ISSUED(HttpStatus.BAD_REQUEST, "이미 쿠폰을 발급받은 사용자입니다."); private final HttpStatus status; private final String message; diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java index 9deb9db..9a67f63 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java @@ -1,11 +1,12 @@ package com.couponify.couponapi.presentation; -import com.couponify.couponapi.application.CouponLockService; +import com.couponify.couponapi.application.CouponIssueService; import com.couponify.couponapi.application.CouponService; import com.couponify.couponapi.presentation.request.CouponCreateRequest; import jakarta.validation.Valid; import java.net.URI; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -20,7 +21,7 @@ public class CouponController { private final CouponService couponService; - private final CouponLockService couponLockService; + private final CouponIssueService couponIssueService; @PostMapping public ResponseEntity create( @@ -33,8 +34,8 @@ public ResponseEntity create( public ResponseEntity issue( @PathVariable(name = "couponId") Long couponId, @RequestParam(name = "user-id") Long userId) { - Long savedIssuedCouponId = couponLockService.issueRLock(couponId, userId); - return ResponseEntity.created(URI.create("/issuedCoupon/" + savedIssuedCouponId)).build(); + couponIssueService.issue(couponId, userId); + return ResponseEntity.status(HttpStatus.CREATED).build(); } } diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java index 2da8fbb..f9e2907 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java @@ -129,7 +129,7 @@ private boolean isIssuePeriod() { private void decreaseQuantity(int quantity) { this.quantity.decrease(quantity); - if (this.quantity.isZero()) { + if (this.quantity.checkIsZero()) { updateStatus(CouponStatus.SOLD_OUT); } } diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java new file mode 100644 index 0000000..4367dc1 --- /dev/null +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java @@ -0,0 +1,79 @@ +package com.couponify.coupondomain.domain.coupon; + +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponCache { + + private Long id; + private CouponStatus status; + private Quantity quantity; + private LocalDateTime issueStartAt; + private LocalDateTime issueEndAt; + + public static CouponCache of(Coupon coupon) { + return new CouponCache( + coupon.getId(), + coupon.getStatus(), + new Quantity(coupon.getQuantity()), + coupon.getIssueStartAt(), + coupon.getIssueEndAt() + ); + } + + public void issue(int quantity) { + validateIssuable(quantity); + decreaseQuantity(quantity); + } + + public int getQuantity() { + return quantity.getValue(); + } + + private void validateIssuable(int quantity) { + validateIssueStatus(); + validateIssueQuantity(quantity); + validateIssuePeriod(); + } + + private void validateIssueStatus() { + if (!this.status.isIssuable()) { + throw new IllegalArgumentException("쿠폰 발급 가능 상태가 아닙니다."); + } + } + + private void validateIssueQuantity(int quantity) { + if (!this.quantity.isGreaterThanOrEqualTo(quantity)) { + throw new IllegalArgumentException("쿠폰 수량이 부족합니다."); + } + } + + private void validateIssuePeriod() { + if (!isIssuePeriod()) { + throw new IllegalArgumentException("쿠폰 발급 기간이 아닙니다."); + } + } + + private boolean isIssuePeriod() { + return (this.issueStartAt.isBefore(LocalDateTime.now()) + && this.issueEndAt.isAfter(LocalDateTime.now())); + } + + private void decreaseQuantity(int quantity) { + this.quantity.decrease(quantity); + if (this.quantity.checkIsZero()) { + soldOut(); + } + } + + private void soldOut() { + this.status = CouponStatus.SOLD_OUT; + } + +} diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Quantity.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Quantity.java index d654b5b..4ebbb51 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Quantity.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Quantity.java @@ -27,7 +27,7 @@ public void increase(int amount) { this.value += amount; } - public boolean isZero() { + public boolean checkIsZero() { return this.value == 0; } From 89ee9029a63056649f43925cef2fc52071655142 Mon Sep 17 00:00:00 2001 From: linavell Date: Wed, 15 Jan 2025 17:52:55 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(#22):=20=EC=BF=A0=ED=8F=B0=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=20=EC=BA=90=EC=8B=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존의 단일 Coupon 서비스를 기능별로 분리 - CouponCreateService, CouponIssueService, CouponExpireService 로 세분화 쿠폰 상태가 만료로 변경될 경우 Redis에 캐싱된 데이터를 삭제하는 로직 추가 - 현재는 메서드를 직접 호출하여 처리 - 추후 이벤트 핸들러를 활용한 비동기 처리로 전환 예정 --- ...nService.java => CouponCreateService.java} | 18 +------- .../application/CouponExpireService.java | 45 +++++++++++++++++++ .../application/CouponIssueService.java | 23 +++++----- .../application/CouponSchedulerService.java | 5 ++- .../presentation/CouponController.java | 6 +-- 5 files changed, 64 insertions(+), 33 deletions(-) rename service/coupon-api/src/main/java/com/couponify/couponapi/application/{CouponService.java => CouponCreateService.java} (58%) create mode 100644 service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponExpireService.java diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponCreateService.java similarity index 58% rename from service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java rename to service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponCreateService.java index c210dfc..d763a40 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponCreateService.java @@ -3,22 +3,18 @@ import com.couponify.couponapi.presentation.request.CouponCreateRequest; import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; -import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; -import java.time.LocalDateTime; -import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j(topic = "CouponService") +@Slf4j(topic = "CouponCreateService") @Service @Transactional(readOnly = true) @RequiredArgsConstructor -public class CouponService { +public class CouponCreateService { private final CouponRepository couponRepository; - private final IssuedCouponRepository issuedCouponRepository; @Transactional public Long create(CouponCreateRequest couponCreateRequest) { @@ -27,14 +23,4 @@ public Long create(CouponCreateRequest couponCreateRequest) { return savedCoupon.getId(); } - @Transactional - public void expire() { - List expiredCoupons = couponRepository.findExpiredCoupons(LocalDateTime.now()); - if (expiredCoupons.isEmpty()) { - return; - } - expiredCoupons.forEach(Coupon::expire); - log.info("Expired {} coupons", expiredCoupons.size()); - } - } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponExpireService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponExpireService.java new file mode 100644 index 0000000..e1de31d --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponExpireService.java @@ -0,0 +1,45 @@ +package com.couponify.couponapi.application; + +import com.couponify.couponapi.common.CouponPrefix; +import com.couponify.coupondomain.domain.coupon.Coupon; +import com.couponify.coupondomain.domain.coupon.CouponCache; +import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RMap; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j(topic = "CouponExpireService") +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponExpireService { + + private final CouponRepository couponRepository; + private final RedissonClient redissonClient; + + @Transactional + public void expire() { + List expiredCoupons = couponRepository.findExpiredCoupons(LocalDateTime.now()); + if (expiredCoupons.isEmpty()) { + return; + } + expiredCoupons.forEach(Coupon::expire); + deleteCouponCache(expiredCoupons.stream().map(Coupon::getId).toList()); + log.info("Expired {} coupons", expiredCoupons.size()); + } + + private void deleteCouponCache(List couponIds) { + couponIds.forEach(couponId -> deleteCouponCache(couponId)); + } + + private void deleteCouponCache(Long couponId) { + RMap couponInfo = redissonClient.getMap(CouponPrefix.COUPON_INFO); + couponInfo.remove(couponId); + } + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java index 83c09fc..6da865c 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java @@ -7,10 +7,11 @@ import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.coupon.CouponCache; import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; -import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RMap; +import org.redisson.api.RMapCache; import org.redisson.api.RSet; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; @@ -24,10 +25,8 @@ public class CouponIssueService { private static final int QUANTITY_TO_ISSUE_COUPON = 1; private final CouponRepository couponRepository; - private final IssuedCouponRepository issuedCouponRepository; private final RedissonClient redissonClient; - @Transactional public void issue(Long couponId, Long userId) { CouponCache couponCache = getCouponCache(couponId); checkUserAlreadyIssued(couponId, userId); @@ -35,30 +34,24 @@ public void issue(Long couponId, Long userId) { // TODO User 검증 필요 couponCache.issue(QUANTITY_TO_ISSUE_COUPON); - addIssuer(couponId, userId); updateCouponCache(couponId, couponCache); } private CouponCache getCouponCache(Long couponId) { - RMap couponInfo = redissonClient.getMap(CouponPrefix.COUPON_INFO); + RMapCache couponInfo = redissonClient.getMapCache( + CouponPrefix.COUPON_INFO); CouponCache couponCache = couponInfo.get(couponId); if (couponCache == null) { Coupon coupon = getCoupon(couponId); couponCache = CouponCache.of(coupon); - couponInfo.put(couponId, couponCache); + couponInfo.put(couponId, couponCache, 1, TimeUnit.HOURS); } return couponCache; } - private Coupon getCoupon(Long couponId) { - return couponRepository.findById(couponId).orElseThrow( - () -> new CouponException(CouponErrorCode.COUPON_NOT_FOUND, couponId) - ); - } - private void checkUserAlreadyIssued(Long couponId, Long userId) { RSet issuedUsers = redissonClient.getSet(CouponPrefix.COUPON_ISSUER + couponId); if (issuedUsers.contains(userId)) { @@ -76,4 +69,10 @@ private void updateCouponCache(Long couponId, CouponCache couponCache) { couponInfo.put(couponId, couponCache); } + private Coupon getCoupon(Long couponId) { + return couponRepository.findById(couponId).orElseThrow( + () -> new CouponException(CouponErrorCode.COUPON_NOT_FOUND, couponId) + ); + } + } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java index 175b87f..8219cde 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java @@ -11,14 +11,15 @@ @RequiredArgsConstructor public class CouponSchedulerService { - private final CouponService couponService; + private final CouponExpireService couponExpireService; + @Value("${schedule.use}") private boolean useSchedule; @Scheduled(cron = "${schedule.cron}") public void expireCoupon() { if (useSchedule) { - couponService.expire(); + couponExpireService.expire(); } } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java index 9a67f63..fea4017 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java @@ -1,7 +1,7 @@ package com.couponify.couponapi.presentation; +import com.couponify.couponapi.application.CouponCreateService; import com.couponify.couponapi.application.CouponIssueService; -import com.couponify.couponapi.application.CouponService; import com.couponify.couponapi.presentation.request.CouponCreateRequest; import jakarta.validation.Valid; import java.net.URI; @@ -20,13 +20,13 @@ @RequiredArgsConstructor public class CouponController { - private final CouponService couponService; + private final CouponCreateService couponCreateService; private final CouponIssueService couponIssueService; @PostMapping public ResponseEntity create( @Valid @RequestBody CouponCreateRequest couponCreateRequest) { - Long savedCouponId = couponService.create(couponCreateRequest); + Long savedCouponId = couponCreateService.create(couponCreateRequest); return ResponseEntity.created(URI.create("/coupon/" + savedCouponId)).build(); } From 187fbd379697456bc5d1aa1f22bffdf7542f54c6 Mon Sep 17 00:00:00 2001 From: linavell Date: Wed, 15 Jan 2025 20:23:32 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(#22):=20=EC=BF=A0=ED=8F=B0=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20TTL=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RTransaction을 활용하여 Redis 트랜잭션 처리 적용 - 기본 Redis 트랜잭션은 완전한 원자성을 보장하지 않으므로 RTransaction을 사용하여 트랜잭션 원자성 보장 쿠폰 캐시 TTL 적용 - RTransaction 내에서는 TTL 설정이 불가능하여 트랜잭션 종료 후 캐시에 TTL을 설정하는 방식으로 처리 --- .../application/CouponIssueService.java | 56 ++++++++++++------- .../couponapi/exception/CouponErrorCode.java | 4 +- .../src/main/resources/application.yml | 5 ++ 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java index 6da865c..caa4387 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java @@ -7,13 +7,15 @@ import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.coupon.CouponCache; import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; -import java.util.concurrent.TimeUnit; +import java.time.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RMap; import org.redisson.api.RMapCache; import org.redisson.api.RSet; +import org.redisson.api.RTransaction; import org.redisson.api.RedissonClient; +import org.redisson.api.TransactionOptions; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,48 +29,64 @@ public class CouponIssueService { private final CouponRepository couponRepository; private final RedissonClient redissonClient; + @Value("${cache.coupon.expiration.hours}") + private Long couponExpirationHours; + public void issue(Long couponId, Long userId) { - CouponCache couponCache = getCouponCache(couponId); - checkUserAlreadyIssued(couponId, userId); + RTransaction transaction = redissonClient.createTransaction(TransactionOptions.defaults()); + try { + CouponCache couponCache = getCouponCache(transaction, couponId); + + checkUserAlreadyIssued(transaction, couponId, userId); + couponCache.issue(QUANTITY_TO_ISSUE_COUPON); + addIssuer(transaction, couponId, userId); + updateCouponCache(transaction, couponId, couponCache); - // TODO User 검증 필요 + transaction.commit(); - couponCache.issue(QUANTITY_TO_ISSUE_COUPON); - addIssuer(couponId, userId); - updateCouponCache(couponId, couponCache); + setCouponCacheTTL(couponId, couponCache); + } catch (Exception e) { + transaction.rollback(); + throw new CouponException(CouponErrorCode.TRANSACTION_COMMIT_FAILED, e.getMessage()); + } } - private CouponCache getCouponCache(Long couponId) { - RMapCache couponInfo = redissonClient.getMapCache( - CouponPrefix.COUPON_INFO); + private CouponCache getCouponCache(RTransaction transaction, Long couponId) { + RMapCache couponInfo = transaction.getMapCache(CouponPrefix.COUPON_INFO); CouponCache couponCache = couponInfo.get(couponId); if (couponCache == null) { Coupon coupon = getCoupon(couponId); - couponCache = CouponCache.of(coupon); - couponInfo.put(couponId, couponCache, 1, TimeUnit.HOURS); + return CouponCache.of(coupon); } return couponCache; } - private void checkUserAlreadyIssued(Long couponId, Long userId) { - RSet issuedUsers = redissonClient.getSet(CouponPrefix.COUPON_ISSUER + couponId); + private void checkUserAlreadyIssued(RTransaction transaction, Long couponId, Long userId) { + RSet issuedUsers = transaction.getSet(CouponPrefix.COUPON_ISSUER + couponId); if (issuedUsers.contains(userId)) { throw new CouponException(CouponErrorCode.COUPON_ALREADY_ISSUED); } } - private void addIssuer(Long couponId, Long userId) { - RSet issuedUsers = redissonClient.getSet(CouponPrefix.COUPON_ISSUER + couponId); + private void addIssuer(RTransaction transaction, Long couponId, Long userId) { + RSet issuedUsers = transaction.getSet(CouponPrefix.COUPON_ISSUER + couponId); issuedUsers.add(userId); } - private void updateCouponCache(Long couponId, CouponCache couponCache) { - RMap couponInfo = redissonClient.getMap(CouponPrefix.COUPON_INFO); + private void updateCouponCache(RTransaction transaction, Long couponId, + CouponCache couponCache) { + RMapCache couponInfo = transaction.getMapCache(CouponPrefix.COUPON_INFO); couponInfo.put(couponId, couponCache); } + private void setCouponCacheTTL(Long couponId, CouponCache couponCache) { + RMapCache couponInfo = redissonClient.getMapCache( + CouponPrefix.COUPON_INFO); + couponInfo.expire(Duration.ofHours(couponExpirationHours)); + } + private Coupon getCoupon(Long couponId) { return couponRepository.findById(couponId).orElseThrow( () -> new CouponException(CouponErrorCode.COUPON_NOT_FOUND, couponId) diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java b/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java index e9df44c..63255e3 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java @@ -6,8 +6,8 @@ @Getter public enum CouponErrorCode { COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 쿠폰입니다. : [%s]"), - LOCK_ACQUISITION_FAILED(HttpStatus.LOCKED, "락을 획득할 수 없습니다."), - COUPON_ALREADY_ISSUED(HttpStatus.BAD_REQUEST, "이미 쿠폰을 발급받은 사용자입니다."); + COUPON_ALREADY_ISSUED(HttpStatus.BAD_REQUEST, "이미 쿠폰을 발급받은 사용자입니다."), + TRANSACTION_COMMIT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "트랜잭션 커밋에 실패했습니다. : [%s]"); private final HttpStatus status; private final String message; diff --git a/service/coupon-api/src/main/resources/application.yml b/service/coupon-api/src/main/resources/application.yml index 3c9efbf..9b9e54f 100644 --- a/service/coupon-api/src/main/resources/application.yml +++ b/service/coupon-api/src/main/resources/application.yml @@ -11,3 +11,8 @@ spring: schedule: cron: "0 0 0 * * *" use: true + +cache: + coupon: + expiration: + hours: 1 From 0a0784e837b313de4483040f4a5150d09d1b580d Mon Sep 17 00:00:00 2001 From: linavell Date: Sat, 18 Jan 2025 15:13:18 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(#22):=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=EB=90=9C=20=EB=B0=9C=EA=B8=89=20=EC=BF=A0=ED=8F=B0=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=A3=BC=EA=B8=B0=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20DB=EC=97=90=20=EC=9D=B4=EA=B4=80=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발급 쿠폰 데이터 구조 변경 - 기존 Set 기반 구조 (coupon:issuer:1 - 1, 2, 3)를 HashMap 기반 구조 (coupon:issuer - (1 - 1, 2, 3))로 변경하여 쿠폰 ID별 데이터 생성 가능 DB로 데이터 이관 및 쿠폰 수량 감소 - 이관 시 실제 DB에 저장된 쿠폰 수량을 정확히 감소하도록 처리 캐시 데이터 삭제 - DB로 데이터 이관 완료 후 기존 캐시 데이터를 삭제하는 로직 추가 중복 사용자 검증 로직 개선 - 기존에는 coupon:issuer 캐시에서만 중복 검증을 수행했으나, DB와 캐시 간의 중복 사용자를 모두 검증할 수 있도록 CouponCache에 Set issuerIds를 추가하고 검증 로직 수정 --- .../application/CouponIssueService.java | 104 +++++++++++++++--- .../application/CouponSchedulerService.java | 12 +- .../couponapi/common/CouponPrefix.java | 2 +- .../presentation/CouponController.java | 2 +- .../src/main/resources/application.yml | 4 +- .../coupondomain/domain/coupon/Coupon.java | 13 ++- .../domain/coupon/CouponCache.java | 25 ++++- .../repository/IssuedCouponRepository.java | 6 + .../IssuedCouponRepositoryImpl.java | 12 ++ .../JpaIssuedCouponRepository.java | 7 ++ 10 files changed, 158 insertions(+), 29 deletions(-) diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java index caa4387..83c6231 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java @@ -1,17 +1,25 @@ package com.couponify.couponapi.application; -import com.couponify.couponapi.common.CouponPrefix; +import static com.couponify.couponapi.common.CouponPrefix.COUPON_INFO; +import static com.couponify.couponapi.common.CouponPrefix.COUPON_ISSUER; + import com.couponify.couponapi.exception.CouponErrorCode; import com.couponify.couponapi.exception.CouponException; import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.coupon.CouponCache; import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; +import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; +import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RMap; import org.redisson.api.RMapCache; -import org.redisson.api.RSet; import org.redisson.api.RTransaction; import org.redisson.api.RedissonClient; import org.redisson.api.TransactionOptions; @@ -27,18 +35,19 @@ public class CouponIssueService { private static final int QUANTITY_TO_ISSUE_COUPON = 1; private final CouponRepository couponRepository; + private final IssuedCouponRepository issuedCouponRepository; private final RedissonClient redissonClient; @Value("${cache.coupon.expiration.hours}") private Long couponExpirationHours; - public void issue(Long couponId, Long userId) { + public void cacheCouponIssuance(Long couponId, Long userId) { RTransaction transaction = redissonClient.createTransaction(TransactionOptions.defaults()); try { CouponCache couponCache = getCouponCache(transaction, couponId); checkUserAlreadyIssued(transaction, couponId, userId); - couponCache.issue(QUANTITY_TO_ISSUE_COUPON); + couponCache.issue(userId, QUANTITY_TO_ISSUE_COUPON); addIssuer(transaction, couponId, userId); updateCouponCache(transaction, couponId, couponCache); @@ -51,42 +60,111 @@ public void issue(Long couponId, Long userId) { } } + @Transactional + public void persistCouponIssuance() { + RTransaction transaction = redissonClient.createTransaction(TransactionOptions.defaults()); + try { + RMap> couponIssuer = transaction.getMap(COUPON_ISSUER); + if (couponIssuer.isEmpty()) { + return; + } + + processCouponIssuance(transaction, couponIssuer); + couponIssuer.delete(); + + transaction.commit(); + } catch (Exception e) { + transaction.rollback(); + throw new CouponException(CouponErrorCode.TRANSACTION_COMMIT_FAILED, e.getMessage()); + } + } + private CouponCache getCouponCache(RTransaction transaction, Long couponId) { - RMapCache couponInfo = transaction.getMapCache(CouponPrefix.COUPON_INFO); + RMapCache couponInfo = transaction.getMapCache(COUPON_INFO); CouponCache couponCache = couponInfo.get(couponId); if (couponCache == null) { Coupon coupon = getCoupon(couponId); - return CouponCache.of(coupon); + Set issuerIds = getIssuerIds(coupon); + return CouponCache.of(coupon, issuerIds); } - return couponCache; } + private Set getIssuerIds(Coupon coupon) { + return new HashSet<>(issuedCouponRepository.findUserIdsByCoupon(coupon)); + } + private void checkUserAlreadyIssued(RTransaction transaction, Long couponId, Long userId) { - RSet issuedUsers = transaction.getSet(CouponPrefix.COUPON_ISSUER + couponId); - if (issuedUsers.contains(userId)) { + RMap> couponIssuer = transaction.getMap(COUPON_ISSUER); + Set issuers = couponIssuer.get(couponId); + + if (issuers == null) { + issuers = new HashSet<>(); + couponIssuer.put(couponId, issuers); + return; + } + + if (issuers.contains(userId)) { throw new CouponException(CouponErrorCode.COUPON_ALREADY_ISSUED); } } private void addIssuer(RTransaction transaction, Long couponId, Long userId) { - RSet issuedUsers = transaction.getSet(CouponPrefix.COUPON_ISSUER + couponId); - issuedUsers.add(userId); + RMap> couponIssuer = transaction.getMap(COUPON_ISSUER); + Set issuers = couponIssuer.get(couponId); + issuers.add(userId); + couponIssuer.put(couponId, issuers); } private void updateCouponCache(RTransaction transaction, Long couponId, CouponCache couponCache) { - RMapCache couponInfo = transaction.getMapCache(CouponPrefix.COUPON_INFO); + RMapCache couponInfo = transaction.getMapCache(COUPON_INFO); couponInfo.put(couponId, couponCache); } private void setCouponCacheTTL(Long couponId, CouponCache couponCache) { RMapCache couponInfo = redissonClient.getMapCache( - CouponPrefix.COUPON_INFO); + COUPON_INFO); couponInfo.expire(Duration.ofHours(couponExpirationHours)); } + private void processCouponIssuance( + RTransaction transaction, + RMap> couponIssuer + ) { + List issuedCoupons = new ArrayList<>(); + Set couponIds = couponIssuer.keySet(); + + // 쿠폰 ID별 IssuedCoupon 생성 및 저장 + for (Long couponId : couponIds) { + Set issuers = couponIssuer.get(couponId); + Coupon coupon = getCoupon(couponId); + issuedCoupons.addAll(createIssuedCoupon(issuers, coupon)); + + coupon.decreaseQuantity(issuers.size()); + log.info("{} 쿠폰의 수량을 {}개 차감합니다.", couponId, issuers.size()); + + updateCouponInfoWithIssuer(transaction, couponId, issuers); + } + + issuedCouponRepository.saveAll(issuedCoupons); + log.info("{}개의 쿠폰 발급을 완료했습니다.", issuedCoupons.size()); + } + + private List createIssuedCoupon(Set issuers, Coupon coupon) { + return issuers.stream() + .map(issuer -> IssuedCoupon.of(issuer, coupon)) + .toList(); + } + + private void updateCouponInfoWithIssuer(RTransaction transaction, Long couponId, + Set issuers) { + CouponCache couponCache = getCouponCache(transaction, couponId); + couponCache.addIssuers(issuers); + updateCouponCache(transaction, couponId, couponCache); + } + private Coupon getCoupon(Long couponId) { return couponRepository.findById(couponId).orElseThrow( () -> new CouponException(CouponErrorCode.COUPON_NOT_FOUND, couponId) diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java index 8219cde..3892e0a 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java @@ -12,15 +12,23 @@ public class CouponSchedulerService { private final CouponExpireService couponExpireService; - + private final CouponIssueService couponIssueService; + @Value("${schedule.use}") private boolean useSchedule; - @Scheduled(cron = "${schedule.cron}") + @Scheduled(cron = "${schedule.cron.expire}") public void expireCoupon() { if (useSchedule) { couponExpireService.expire(); } } + @Scheduled(cron = "${schedule.cron.issue}") + public void issueCoupon() { + if (useSchedule) { + couponIssueService.persistCouponIssuance(); + } + } + } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java b/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java index 1cd1138..65cc563 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java @@ -3,6 +3,6 @@ public class CouponPrefix { public static final String COUPON_INFO = "coupon:info"; - public static final String COUPON_ISSUER = "coupon:issuer:"; + public static final String COUPON_ISSUER = "coupon:issuer"; } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java index fea4017..5512a35 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java @@ -34,7 +34,7 @@ public ResponseEntity create( public ResponseEntity issue( @PathVariable(name = "couponId") Long couponId, @RequestParam(name = "user-id") Long userId) { - couponIssueService.issue(couponId, userId); + couponIssueService.cacheCouponIssuance(couponId, userId); return ResponseEntity.status(HttpStatus.CREATED).build(); } diff --git a/service/coupon-api/src/main/resources/application.yml b/service/coupon-api/src/main/resources/application.yml index 9b9e54f..65544f2 100644 --- a/service/coupon-api/src/main/resources/application.yml +++ b/service/coupon-api/src/main/resources/application.yml @@ -9,8 +9,10 @@ spring: - classpath:properties/jpa.yml schedule: - cron: "0 0 0 * * *" use: true + cron: + expire: "0 0 0 * * *" + issue: "*/10 * * * * *" cache: coupon: diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java index f9e2907..9ba7b14 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java @@ -66,6 +66,13 @@ public void issue(int quantity) { decreaseQuantity(quantity); } + public void decreaseQuantity(int quantity) { + this.quantity.decrease(quantity); + if (this.quantity.checkIsZero()) { + updateStatus(CouponStatus.SOLD_OUT); + } + } + public void expire() { updateStatus(CouponStatus.EXPIRED); } @@ -127,11 +134,5 @@ private boolean isIssuePeriod() { && this.issueEndAt.isAfter(LocalDateTime.now())); } - private void decreaseQuantity(int quantity) { - this.quantity.decrease(quantity); - if (this.quantity.checkIsZero()) { - updateStatus(CouponStatus.SOLD_OUT); - } - } } diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java index 4367dc1..9f22339 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java @@ -1,6 +1,8 @@ package com.couponify.coupondomain.domain.coupon; import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -16,24 +18,37 @@ public class CouponCache { private Quantity quantity; private LocalDateTime issueStartAt; private LocalDateTime issueEndAt; + private Set issuerIds = new HashSet<>(); - public static CouponCache of(Coupon coupon) { + public static CouponCache of(Coupon coupon, Set issuerIds) { return new CouponCache( coupon.getId(), coupon.getStatus(), new Quantity(coupon.getQuantity()), coupon.getIssueStartAt(), - coupon.getIssueEndAt() + coupon.getIssueEndAt(), + issuerIds ); } - public void issue(int quantity) { + public void issue(Long userId, int quantity) { + validateDuplicateIssuer(userId); validateIssuable(quantity); decreaseQuantity(quantity); } - public int getQuantity() { - return quantity.getValue(); + public void addIssuers(Set issuerIds) { + issuerIds.forEach(this::addIssuer); + } + + private void addIssuer(Long userId) { + issuerIds.add(userId); + } + + private void validateDuplicateIssuer(Long userId) { + if (issuerIds.contains(userId)) { + throw new IllegalArgumentException("이미 쿠폰을 발급한 사용자입니다."); + } } private void validateIssuable(int quantity) { diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/issuedCoupon/repository/IssuedCouponRepository.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/issuedCoupon/repository/IssuedCouponRepository.java index b7a0405..6deddb5 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/issuedCoupon/repository/IssuedCouponRepository.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/issuedCoupon/repository/IssuedCouponRepository.java @@ -1,10 +1,16 @@ package com.couponify.coupondomain.domain.issuedCoupon.repository; +import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; +import java.util.List; public interface IssuedCouponRepository { IssuedCoupon save(IssuedCoupon issuedCoupon); + List saveAll(List issuedCoupons); + + List findUserIdsByCoupon(Coupon coupon); + } diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/IssuedCouponRepositoryImpl.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/IssuedCouponRepositoryImpl.java index 7df4792..5d565f5 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/IssuedCouponRepositoryImpl.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/IssuedCouponRepositoryImpl.java @@ -1,7 +1,9 @@ package com.couponify.coupondomain.infrastructure.jpa.issuedCoupon; +import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -11,6 +13,16 @@ public class IssuedCouponRepositoryImpl implements IssuedCouponRepository { private final JpaIssuedCouponRepository jpaIssuedCouponRepository; + @Override + public List saveAll(List issuedCoupons) { + return jpaIssuedCouponRepository.saveAll(issuedCoupons); + } + + @Override + public List findUserIdsByCoupon(Coupon coupon) { + return jpaIssuedCouponRepository.findUserIdsByCoupon(coupon); + } + @Override public IssuedCoupon save(IssuedCoupon issuedCoupon) { return jpaIssuedCouponRepository.save(issuedCoupon); diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/JpaIssuedCouponRepository.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/JpaIssuedCouponRepository.java index 178c775..618dc9f 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/JpaIssuedCouponRepository.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/JpaIssuedCouponRepository.java @@ -1,8 +1,15 @@ package com.couponify.coupondomain.infrastructure.jpa.issuedCoupon; +import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface JpaIssuedCouponRepository extends JpaRepository { + @Query("SELECT i.userId from IssuedCoupon i WHERE i.coupon = :coupon") + List findUserIdsByCoupon(@Param("coupon") Coupon coupon); + } From bf01d122a8d6642904c406c2f06fe2e920ff58b3 Mon Sep 17 00:00:00 2001 From: Arin Lee Date: Wed, 22 Jan 2025 16:55:00 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(#22):=20RTransaction=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B8=ED=95=B4=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RTransaction은 Redis 명령어를 원자적으로 실행하지만, 락을 걸지 않아 다른 트랜잭션의 명령어와 교차 실행될 수 있어 동시성 문제가 발생함 - 이를 해결하기 위해 쿠폰 발급 시, RLock을 사용하여 couponId에 대한 락 설정 - 발급된 쿠폰을 캐시에서 DB로 이관할 때도 동시성 문제가 발생할 수 있으므로 couponId에 대한 락 추가 - RLock을 제공하는 RedissonLockManager 클래스 구현 --- service/coupon-api/docs/CouponApiTest.http | 8 ++- .../application/CouponIssueService.java | 28 ++++----- .../application/CouponLockService.java | 45 ++++++++++++++ .../couponapi/common/CouponPrefix.java | 2 + .../couponapi/common/RedissonLockManager.java | 59 +++++++++++++++++++ .../couponapi/exception/CouponErrorCode.java | 3 +- .../presentation/CouponController.java | 4 +- 7 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java create mode 100644 service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java diff --git a/service/coupon-api/docs/CouponApiTest.http b/service/coupon-api/docs/CouponApiTest.http index c19ba5b..32b9bea 100644 --- a/service/coupon-api/docs/CouponApiTest.http +++ b/service/coupon-api/docs/CouponApiTest.http @@ -4,9 +4,11 @@ Content-Type: application/json { "name": "여름 할인 쿠폰", - "status": "ISSUABLE", - "quantity": 100 + "status": "AVAILABLE", + "quantity": 1, + "issueStartAt": "2025-01-11T09:00:00", + "issueEndAt": "2025-01-11T15:15:59" } ### 쿠폰 발급 -POST http://localhost:8080/coupon/issue/1?user-id=1 +POST http://localhost:8080/coupon/issue/1?user-id=1372 diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java index 83c6231..093379f 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java @@ -68,10 +68,7 @@ public void persistCouponIssuance() { if (couponIssuer.isEmpty()) { return; } - processCouponIssuance(transaction, couponIssuer); - couponIssuer.delete(); - transaction.commit(); } catch (Exception e) { transaction.rollback(); @@ -79,6 +76,11 @@ public void persistCouponIssuance() { } } + public Set getIssuedCouponIds() { + RMap> couponIssuer = redissonClient.getMap(COUPON_ISSUER); + return couponIssuer.keySet(); + } + private CouponCache getCouponCache(RTransaction transaction, Long couponId) { RMapCache couponInfo = transaction.getMapCache(COUPON_INFO); CouponCache couponCache = couponInfo.get(couponId); @@ -102,10 +104,7 @@ private void checkUserAlreadyIssued(RTransaction transaction, Long couponId, Lon if (issuers == null) { issuers = new HashSet<>(); couponIssuer.put(couponId, issuers); - return; - } - - if (issuers.contains(userId)) { + } else if (issuers.contains(userId)) { throw new CouponException(CouponErrorCode.COUPON_ALREADY_ISSUED); } } @@ -134,20 +133,21 @@ private void processCouponIssuance( RMap> couponIssuer ) { List issuedCoupons = new ArrayList<>(); - Set couponIds = couponIssuer.keySet(); + Set issuedCouponIds = couponIssuer.keySet(); // 쿠폰 ID별 IssuedCoupon 생성 및 저장 - for (Long couponId : couponIds) { - Set issuers = couponIssuer.get(couponId); - Coupon coupon = getCoupon(couponId); + for (Long issuedCouponId : issuedCouponIds) { + Set issuers = couponIssuer.get(issuedCouponId); + Coupon coupon = getCoupon(issuedCouponId); issuedCoupons.addAll(createIssuedCoupon(issuers, coupon)); coupon.decreaseQuantity(issuers.size()); - log.info("{} 쿠폰의 수량을 {}개 차감합니다.", couponId, issuers.size()); + log.info("{} 쿠폰의 수량을 {}개 차감합니다.", issuedCouponId, issuers.size()); - updateCouponInfoWithIssuer(transaction, couponId, issuers); + updateCouponInfoWithIssuer(transaction, issuedCouponId, issuers); + // 발급 쿠폰 캐시 삭제 + couponIssuer.remove(issuedCouponId); } - issuedCouponRepository.saveAll(issuedCoupons); log.info("{}개의 쿠폰 발급을 완료했습니다.", issuedCoupons.size()); } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java new file mode 100644 index 0000000..646b90f --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java @@ -0,0 +1,45 @@ +package com.couponify.couponapi.application; + +import static com.couponify.couponapi.common.CouponPrefix.LOCK_COUPON_PREFIX; +import static com.couponify.couponapi.common.CouponPrefix.LOCK_ISSUER_PREFIX; + +import com.couponify.couponapi.common.RedissonLockManager; +import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j(topic = "CouponLockService") +@Service +@RequiredArgsConstructor +public class CouponLockService { + + private final CouponIssueService couponIssueService; + private final CouponRepository couponRepository; + private final RedissonLockManager redissonLockManager; + + public void cacheCouponIssuance(Long couponId, Long userId) { + List lockNames = generateIssuanceLockNames(List.of(couponId)); + redissonLockManager.executeMultipleLocks(lockNames, 10, 5, 3, + () -> couponIssueService.cacheCouponIssuance(couponId, userId)); + } + + public void persistCouponIssuance() { + Set issuedCouponIds = couponIssueService.getIssuedCouponIds(); + List lockNames = generateIssuanceLockNames(issuedCouponIds.stream().toList()); + redissonLockManager.executeMultipleLocks(lockNames, 10, 5, 3, + couponIssueService::persistCouponIssuance); + } + + private List generateIssuanceLockNames(List couponIds) { + return couponIds.stream() + .flatMap(couponId -> Stream.of( + LOCK_COUPON_PREFIX + couponId, LOCK_ISSUER_PREFIX + couponId + )) + .toList(); + } + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java b/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java index 65cc563..62c7c83 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java @@ -4,5 +4,7 @@ public class CouponPrefix { public static final String COUPON_INFO = "coupon:info"; public static final String COUPON_ISSUER = "coupon:issuer"; + public static final String LOCK_COUPON_PREFIX = "lock:coupon:"; + public static final String LOCK_ISSUER_PREFIX = "lock:issuer:"; } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java b/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java new file mode 100644 index 0000000..17101b5 --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java @@ -0,0 +1,59 @@ +package com.couponify.couponapi.common; + +import com.couponify.couponapi.exception.CouponErrorCode; +import com.couponify.couponapi.exception.CouponException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedissonLockManager { + + private final RedissonClient redissonClient; + + public void executeMultipleLocks( + List lockNames, long waitSeconds, long leaseSeconds, int retryCount, + Runnable logic) { + for (int attempt = 0; attempt < retryCount; attempt++) { + List locks = new ArrayList<>(); + try { + if (tryAcquireLocks(lockNames, locks, waitSeconds, leaseSeconds)) { + logic.run(); + return; + } + } catch (InterruptedException e) { + throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); + } finally { + releaseLocks(locks); + } + } + throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); + } + + private boolean tryAcquireLocks(List lockNames, List locks, long waitSeconds, + long leaseSeconds) throws InterruptedException { + for (String lockName : lockNames) { + RLock lock = redissonClient.getLock(lockName); + locks.add(lock); + + if (!lock.tryLock(waitSeconds, leaseSeconds, TimeUnit.SECONDS)) { + return false; + } + } + return true; + } + + private void releaseLocks(List locks) { + for (RLock lock : locks) { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java b/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java index 63255e3..7555430 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java @@ -7,7 +7,8 @@ public enum CouponErrorCode { COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 쿠폰입니다. : [%s]"), COUPON_ALREADY_ISSUED(HttpStatus.BAD_REQUEST, "이미 쿠폰을 발급받은 사용자입니다."), - TRANSACTION_COMMIT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "트랜잭션 커밋에 실패했습니다. : [%s]"); + TRANSACTION_COMMIT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "트랜잭션 커밋에 실패했습니다. : [%s]"), + LOCK_ACQUISITION_FAILED(HttpStatus.LOCKED, "락을 획득할 수 없습니다."); private final HttpStatus status; private final String message; diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java index 5512a35..514c1aa 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java @@ -2,6 +2,7 @@ import com.couponify.couponapi.application.CouponCreateService; import com.couponify.couponapi.application.CouponIssueService; +import com.couponify.couponapi.application.CouponLockService; import com.couponify.couponapi.presentation.request.CouponCreateRequest; import jakarta.validation.Valid; import java.net.URI; @@ -22,6 +23,7 @@ public class CouponController { private final CouponCreateService couponCreateService; private final CouponIssueService couponIssueService; + private final CouponLockService couponLockService; @PostMapping public ResponseEntity create( @@ -34,7 +36,7 @@ public ResponseEntity create( public ResponseEntity issue( @PathVariable(name = "couponId") Long couponId, @RequestParam(name = "user-id") Long userId) { - couponIssueService.cacheCouponIssuance(couponId, userId); + couponLockService.cacheCouponIssuance(couponId, userId); return ResponseEntity.status(HttpStatus.CREATED).build(); } From f708259225c872210738ca5d2c47a81794daeecd Mon Sep 17 00:00:00 2001 From: Arin Lee Date: Wed, 22 Jan 2025 16:55:00 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(#22):=20RTransaction=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B8=ED=95=B4=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RTransaction은 Redis 명령어를 원자적으로 실행하지만, 락을 걸지 않아 다른 트랜잭션의 명령어와 교차 실행될 수 있어 동시성 문제가 발생함 - 이를 해결하기 위해 쿠폰 발급 시, RLock을 사용하여 couponId에 대한 락 설정 - 발급된 쿠폰을 캐시에서 DB로 이관할 때도 동시성 문제가 발생할 수 있으므로 couponId에 대한 락 추가 - RLock을 제공하는 RedissonLockManager 클래스 구현 --- .../couponapi/application/CouponLockService.java | 11 ++--------- .../couponapi/common/RedissonLockManager.java | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java index 646b90f..3702f47 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java @@ -1,13 +1,11 @@ package com.couponify.couponapi.application; import static com.couponify.couponapi.common.CouponPrefix.LOCK_COUPON_PREFIX; -import static com.couponify.couponapi.common.CouponPrefix.LOCK_ISSUER_PREFIX; import com.couponify.couponapi.common.RedissonLockManager; import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; import java.util.List; import java.util.Set; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -22,8 +20,7 @@ public class CouponLockService { private final RedissonLockManager redissonLockManager; public void cacheCouponIssuance(Long couponId, Long userId) { - List lockNames = generateIssuanceLockNames(List.of(couponId)); - redissonLockManager.executeMultipleLocks(lockNames, 10, 5, 3, + redissonLockManager.executeLock(LOCK_COUPON_PREFIX + couponId, 10, 5, () -> couponIssueService.cacheCouponIssuance(couponId, userId)); } @@ -35,11 +32,7 @@ public void persistCouponIssuance() { } private List generateIssuanceLockNames(List couponIds) { - return couponIds.stream() - .flatMap(couponId -> Stream.of( - LOCK_COUPON_PREFIX + couponId, LOCK_ISSUER_PREFIX + couponId - )) - .toList(); + return couponIds.stream().map(couponId -> LOCK_COUPON_PREFIX + couponId).toList(); } } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java b/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java index 17101b5..2f88997 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java @@ -16,6 +16,21 @@ public class RedissonLockManager { private final RedissonClient redissonClient; + public void executeLock( + String lockName, long waitSeconds, long leaseSeconds, Runnable logic) { + RLock lock = redissonClient.getLock(lockName); + try { + if (!lock.tryLock(waitSeconds, leaseSeconds, TimeUnit.SECONDS)) { + throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); + } + logic.run(); + } catch (InterruptedException e) { + throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); + } finally { + lock.unlock(); + } + } + public void executeMultipleLocks( List lockNames, long waitSeconds, long leaseSeconds, int retryCount, Runnable logic) {