diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/auction/infra/AuctionLockOperation.java b/src/main/java/com/wootecam/luckyvickyauction/core/auction/infra/AuctionLockOperation.java deleted file mode 100644 index a5f4be68..00000000 --- a/src/main/java/com/wootecam/luckyvickyauction/core/auction/infra/AuctionLockOperation.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.wootecam.luckyvickyauction.core.auction.infra; - -import java.util.concurrent.TimeUnit; -import lombok.AllArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; - -@Repository -@AllArgsConstructor -public class AuctionLockOperation { - private static final Logger log = LoggerFactory.getLogger(AuctionLockOperation.class); - private static final String KEY_SUFFIX = "auction:lock"; - private static final int LOCK_RETRY_DURATION = 50; - private static final int LOCK_EXPIRE_TIME = 5; - - private final RedisTemplate redisOperations; - - private static String getKeyName(long keyPrefix) { - return new StringBuilder().append(keyPrefix).append(":").append(KEY_SUFFIX).toString(); - } - - public boolean lock(long auctionId) { - String key = getKeyName(auctionId); - Boolean success = redisOperations.opsForValue().setIfAbsent(key, 1L, LOCK_EXPIRE_TIME, TimeUnit.SECONDS); - - return success != null && success; - } - - public void unLock(long auctionId) { - String key = getKeyName(auctionId); - redisOperations.delete(key); - } - - public void lockLimitTry(long auctionId, int maxRetry) { - int retry = 0; - while (!lock(auctionId)) { - if (++retry == maxRetry) { - throw new IllegalStateException("최대 시도 횟수에 도달했습니다. 재시도: " + maxRetry); - } - - try { - Thread.sleep(LOCK_RETRY_DURATION); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - - log.debug("레투스 락을 획득! 경매번호: " + auctionId); - } -} diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/AuctioneerProxy.java b/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/AuctioneerProxy.java deleted file mode 100644 index fc7ef353..00000000 --- a/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/AuctioneerProxy.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.wootecam.luckyvickyauction.core.auction.service.auctioneer; - -import com.wootecam.luckyvickyauction.core.auction.service.Auctioneer; - -public interface AuctioneerProxy extends Auctioneer { -} diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/BasicAuctioneer.java b/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/BasicAuctioneer.java index 115643f5..44ab1def 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/BasicAuctioneer.java +++ b/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/BasicAuctioneer.java @@ -3,27 +3,23 @@ import com.wootecam.luckyvickyauction.core.auction.dto.AuctionInfo; import com.wootecam.luckyvickyauction.core.auction.service.AuctionService; import com.wootecam.luckyvickyauction.core.auction.service.Auctioneer; -import com.wootecam.luckyvickyauction.core.member.domain.Member; -import com.wootecam.luckyvickyauction.core.member.domain.MemberRepository; import com.wootecam.luckyvickyauction.core.member.dto.SignInInfo; import com.wootecam.luckyvickyauction.core.payment.domain.Receipt; import com.wootecam.luckyvickyauction.core.payment.domain.ReceiptRepository; import com.wootecam.luckyvickyauction.core.payment.domain.ReceiptStatus; -import com.wootecam.luckyvickyauction.global.exception.ErrorCode; -import com.wootecam.luckyvickyauction.global.exception.NotFoundException; +import com.wootecam.luckyvickyauction.core.payment.service.PaymentService; +import com.wootecam.luckyvickyauction.global.aop.DistributedLock; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Primary @Service @RequiredArgsConstructor public class BasicAuctioneer implements Auctioneer { private final AuctionService auctionService; - private final MemberRepository memberRepository; + private final PaymentService paymentService; private final ReceiptRepository receiptRepository; /** @@ -31,30 +27,25 @@ public class BasicAuctioneer implements Auctioneer { * 성공하면 -> Receipt 저장 및 구매자, 판매자 업데이트 적용 */ @Transactional + @DistributedLock("#auctionId + ':auction:lock'") public void process(SignInInfo buyerInfo, long price, long auctionId, long quantity, LocalDateTime requestTime) { - Member buyer = findMemberObject(buyerInfo.id()); AuctionInfo auctionInfo = auctionService.getAuction(auctionId); - Member seller = findMemberObject(auctionInfo.sellerId()); - buyer.usePoint(price * quantity); - seller.chargePoint(price * quantity); - auctionService.submitPurchase(auctionId, price, quantity, requestTime); - Member savedBuyer = memberRepository.save(buyer); - Member savedSeller = memberRepository.save(seller); + + long buyerId = buyerInfo.id(); + long sellerId = auctionInfo.sellerId(); + paymentService.pointTransfer(buyerId, sellerId, price * quantity); + Receipt receipt = Receipt.builder() .productName(auctionInfo.productName()) .price(price) .quantity(quantity) .receiptStatus(ReceiptStatus.PURCHASED) - .sellerId(savedSeller.getId()) - .buyerId(savedBuyer.getId()) + .sellerId(sellerId) + .buyerId(buyerId) .auctionId(auctionId) .build(); receiptRepository.save(receipt); } - private Member findMemberObject(Long id) { - return memberRepository.findById(id) - .orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다. id=" + id, ErrorCode.M002)); - } } diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/LettuceLockAuctioneerProxy.java b/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/LettuceLockAuctioneerProxy.java deleted file mode 100644 index 4d504983..00000000 --- a/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/LettuceLockAuctioneerProxy.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.wootecam.luckyvickyauction.core.auction.service.auctioneer; - -import com.wootecam.luckyvickyauction.core.auction.infra.AuctionLockOperation; -import com.wootecam.luckyvickyauction.core.member.dto.SignInInfo; -import java.time.LocalDateTime; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class LettuceLockAuctioneerProxy implements AuctioneerProxy { - - private final BasicAuctioneer basicAuctioneer; - private final AuctionLockOperation auctionLockOperation; - - @Override - public void process(SignInInfo buyerInfo, long price, long auctionId, long quantity, LocalDateTime requestTime) { - auctionLockOperation.lockLimitTry(auctionId, 30); - - try { - basicAuctioneer.process(buyerInfo, price, auctionId, quantity, requestTime); - } finally { - auctionLockOperation.unLock(auctionId); - } - } -} diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/RedissonLockAuctioneerProxy.java b/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/RedissonLockAuctioneerProxy.java deleted file mode 100644 index b605445f..00000000 --- a/src/main/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/RedissonLockAuctioneerProxy.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.wootecam.luckyvickyauction.core.auction.service.auctioneer; - -import com.wootecam.luckyvickyauction.core.member.dto.SignInInfo; -import java.time.LocalDateTime; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class RedissonLockAuctioneerProxy implements AuctioneerProxy { - - private final BasicAuctioneer basicAuctioneer; - private final RedissonClient redissonClient; - - @Override - public void process(SignInInfo buyerInfo, long price, long auctionId, long quantity, LocalDateTime requestTime) { - RLock rLock = redissonClient.getLock(auctionId + ":auction:lock"); - - try { - boolean available = rLock.tryLock(5, 5, TimeUnit.SECONDS); - if (!available) { - throw new IllegalStateException("TimeOut에 도달했습니다."); - } - log.debug("레디슨 락 획득! 경매번호: {}", buyerInfo.id()); - basicAuctioneer.process(buyerInfo, price, auctionId, quantity, requestTime); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } finally { - rLock.unlock(); - } - } -} diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/lock/LettuceLockProvider.java b/src/main/java/com/wootecam/luckyvickyauction/core/lock/LettuceLockProvider.java new file mode 100644 index 00000000..ec8403b6 --- /dev/null +++ b/src/main/java/com/wootecam/luckyvickyauction/core/lock/LettuceLockProvider.java @@ -0,0 +1,52 @@ +package com.wootecam.luckyvickyauction.core.lock; + +import com.wootecam.luckyvickyauction.global.aop.LockProvider; +import com.wootecam.luckyvickyauction.global.exception.ErrorCode; +import com.wootecam.luckyvickyauction.global.exception.ServiceUnavailableException; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; + +@Slf4j +@RequiredArgsConstructor +public class LettuceLockProvider implements LockProvider { + + private final RedisTemplate redisOperations; + + @Value("${lock.lettuce.retry_duration:50}") + private int retryDuration; + @Value("${lock.lettuce.max_retry:10}") + private int maxRetry; + @Value("${lock.lettuce.lease_time:200}") + private int leaseTime; + + @Override + public void tryLock(String key) { + int retry = 0; + while (!lock(key)) { + if (++retry == maxRetry) { + throw new ServiceUnavailableException("TimeOut에 도달했습니다. 최대재시도 횟수: " + maxRetry, ErrorCode.G002); + } + + try { + Thread.sleep(retryDuration); + } catch (InterruptedException e) { + throw new ServiceUnavailableException("시스템 문제로 락을 획득할 수 없습니다.", ErrorCode.G003); + } + } + + log.debug("레투스 락 획득! LOCK: " + key); + } + + @Override + public void unlock(String key) { + redisOperations.delete(key); + } + + private boolean lock(String key) { + Boolean success = redisOperations.opsForValue().setIfAbsent(key, 1L, leaseTime, TimeUnit.MILLISECONDS); + return success != null && success; + } +} diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/lock/NoOperationLockProvider.java b/src/main/java/com/wootecam/luckyvickyauction/core/lock/NoOperationLockProvider.java new file mode 100644 index 00000000..578857e9 --- /dev/null +++ b/src/main/java/com/wootecam/luckyvickyauction/core/lock/NoOperationLockProvider.java @@ -0,0 +1,17 @@ +package com.wootecam.luckyvickyauction.core.lock; + +import com.wootecam.luckyvickyauction.global.aop.LockProvider; + +public class NoOperationLockProvider implements LockProvider { + + @Override + public void tryLock(String key) { + // 락 동작을 수행하지 않습니다. + } + + @Override + public void unlock(String key) { + // 락 동작을 수행하지 않습니다. + } + +} diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/lock/RedissonLockProvider.java b/src/main/java/com/wootecam/luckyvickyauction/core/lock/RedissonLockProvider.java new file mode 100644 index 00000000..09c202c1 --- /dev/null +++ b/src/main/java/com/wootecam/luckyvickyauction/core/lock/RedissonLockProvider.java @@ -0,0 +1,45 @@ +package com.wootecam.luckyvickyauction.core.lock; + +import com.wootecam.luckyvickyauction.global.aop.LockProvider; +import com.wootecam.luckyvickyauction.global.exception.ErrorCode; +import com.wootecam.luckyvickyauction.global.exception.ServiceUnavailableException; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; + +@Slf4j +@RequiredArgsConstructor +public class RedissonLockProvider implements LockProvider { + + private final RedissonClient redissonClient; + + @Value("${lock.redisson.wait_time:500}") + private int waitTime; + @Value("${lock.redisson.lease_time:200}") + private int leaseTime; + + @Override + public void tryLock(String key) { + RLock rLock = redissonClient.getLock(key); + + try { + boolean available = rLock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS); + if (!available) { + throw new ServiceUnavailableException("TimeOut에 도달했습니다.", ErrorCode.G002); + } + log.debug("==> 레디슨 락 획득! LOCK: {}", key); + } catch (InterruptedException e) { + throw new ServiceUnavailableException("시스템 문제로 락을 획득할 수 없습니다.", ErrorCode.G003); + } + } + + @Override + public void unlock(String key) { + RLock rLock = redissonClient.getLock(key); + rLock.unlock(); + log.debug("<== 레디슨 락 해제! LOCK: {}", key); + } +} diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/member/domain/Member.java b/src/main/java/com/wootecam/luckyvickyauction/core/member/domain/Member.java index 4372d07a..8c3dfe8f 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/core/member/domain/Member.java +++ b/src/main/java/com/wootecam/luckyvickyauction/core/member/domain/Member.java @@ -85,6 +85,18 @@ private void validateSignInId(String signInId) { } } + /** + * 다른 사용자에게 포인트를 송금합니다. + * + * @param recipient 포인트를 받을 사용자 + * @param amount 지불할 포인트 + * @throws BadRequestException 포인트가 부족하거나, 포인트 최대 보유량을 초과하는 경우 + */ + public void pointTransfer(Member recipient, long amount) { + point.minus(amount); + recipient.point.plus(amount); + } + public static Member createMemberWithRole(String signInId, String password, String userRole) { Role role = Role.find(userRole); diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/member/domain/Point.java b/src/main/java/com/wootecam/luckyvickyauction/core/member/domain/Point.java index c48e1920..f56bbbd3 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/core/member/domain/Point.java +++ b/src/main/java/com/wootecam/luckyvickyauction/core/member/domain/Point.java @@ -10,10 +10,12 @@ public class Point { private long amount; public Point(final long amount) { + validatePositiveAmount(amount); this.amount = amount; } public void minus(final long minusAmount) { + validatePositiveAmount(minusAmount); if (amount < minusAmount) { throw new BadRequestException("포인트가 부족합니다.", ErrorCode.P001); } @@ -21,12 +23,19 @@ public void minus(final long minusAmount) { } public void plus(final long price) { + validatePositiveAmount(price); if (price > 0 && amount > Long.MAX_VALUE - price) { throw new BadRequestException("포인트가 최대치를 초과하였습니다.", ErrorCode.P006); } amount += price; } + private void validatePositiveAmount(final long amount) { + if (amount < 0) { + throw new BadRequestException("금액은 양수여야 합니다.", ErrorCode.P008); + } + } + @Override public boolean equals(final Object o) { if (this == o) { diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/payment/service/PaymentService.java b/src/main/java/com/wootecam/luckyvickyauction/core/payment/service/PaymentService.java index 433d2201..04fc88e0 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/core/payment/service/PaymentService.java +++ b/src/main/java/com/wootecam/luckyvickyauction/core/payment/service/PaymentService.java @@ -8,15 +8,19 @@ import com.wootecam.luckyvickyauction.core.member.dto.SignInInfo; import com.wootecam.luckyvickyauction.core.payment.domain.Receipt; import com.wootecam.luckyvickyauction.core.payment.domain.ReceiptRepository; +import com.wootecam.luckyvickyauction.global.aop.DistributedLock; import com.wootecam.luckyvickyauction.global.exception.AuthorizationException; import com.wootecam.luckyvickyauction.global.exception.BadRequestException; import com.wootecam.luckyvickyauction.global.exception.ErrorCode; import com.wootecam.luckyvickyauction.global.exception.NotFoundException; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class PaymentService { @@ -24,6 +28,7 @@ public class PaymentService { private final AuctionService auctionService; private final MemberRepository memberRepository; private final ReceiptRepository receiptRepository; + private final RedissonClient redissonClient; @Transactional public void refund(SignInInfo buyerInfo, long receiptId, LocalDateTime requestTime) { @@ -91,4 +96,24 @@ public void chargePoint(SignInInfo memberInfo, long chargePoint) { member.chargePoint(chargePoint); memberRepository.save(member); } + + @Transactional + @DistributedLock("#recipientId + ':point:lock'") + public void pointTransfer(long senderId, long recipientId, long amount) { + Member sender = findMemberObject(senderId); + Member recipient = findMemberObject(recipientId); + + sender.pointTransfer(recipient, amount); + log.debug(" - Member.{}의 포인트 {}원을 Member.{} 에게 전달합니다.", sender.getId(), amount, recipientId); + log.debug(" - Member.{}의 잔고: {}, Member.{}의 잔고: {}", sender.getId(), sender.getPoint().getAmount(), + recipientId, recipient.getPoint().getAmount()); + + memberRepository.save(sender); + memberRepository.save(recipient); + } + + private Member findMemberObject(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다. id=" + id, ErrorCode.M002)); + } } diff --git a/src/main/java/com/wootecam/luckyvickyauction/global/aop/DistributedLock.java b/src/main/java/com/wootecam/luckyvickyauction/global/aop/DistributedLock.java new file mode 100644 index 00000000..61f1e03f --- /dev/null +++ b/src/main/java/com/wootecam/luckyvickyauction/global/aop/DistributedLock.java @@ -0,0 +1,12 @@ +package com.wootecam.luckyvickyauction.global.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + String value(); // Lock Name +} diff --git a/src/main/java/com/wootecam/luckyvickyauction/global/aop/DistributedLockAspect.java b/src/main/java/com/wootecam/luckyvickyauction/global/aop/DistributedLockAspect.java new file mode 100644 index 00000000..8ce0c448 --- /dev/null +++ b/src/main/java/com/wootecam/luckyvickyauction/global/aop/DistributedLockAspect.java @@ -0,0 +1,53 @@ +package com.wootecam.luckyvickyauction.global.aop; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.annotation.Order; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +@Order(1) // 커스텀 AOP의 우선순위를 높게 설정합니다. +public class DistributedLockAspect { + + private final LockProvider lockProvider; + + @Around("@annotation(distributedLock)") + public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable { + String key = getLockName(joinPoint, distributedLock); + + try { + lockProvider.tryLock(key); + return joinPoint.proceed(); + } finally { + lockProvider.unlock(key); + } + + } + + private String getLockName(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) { + // 메서드 파라미터 정보 가져오기 + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + String[] parameterNames = methodSignature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + // 파라미터를 컨텍스트에 추가 + StandardEvaluationContext context = new StandardEvaluationContext(); + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + // SpEL 파싱 + ExpressionParser parser = new SpelExpressionParser(); + String key = parser.parseExpression(distributedLock.value()).getValue(context, String.class); + return key; + } + +} diff --git a/src/main/java/com/wootecam/luckyvickyauction/global/aop/LockProvider.java b/src/main/java/com/wootecam/luckyvickyauction/global/aop/LockProvider.java new file mode 100644 index 00000000..5f54fc5c --- /dev/null +++ b/src/main/java/com/wootecam/luckyvickyauction/global/aop/LockProvider.java @@ -0,0 +1,9 @@ +package com.wootecam.luckyvickyauction.global.aop; + +public interface LockProvider { + + void tryLock(String key); + + void unlock(String key); + +} diff --git a/src/main/java/com/wootecam/luckyvickyauction/global/config/LockProviderConfig.java b/src/main/java/com/wootecam/luckyvickyauction/global/config/LockProviderConfig.java new file mode 100644 index 00000000..e3cf2099 --- /dev/null +++ b/src/main/java/com/wootecam/luckyvickyauction/global/config/LockProviderConfig.java @@ -0,0 +1,43 @@ +package com.wootecam.luckyvickyauction.global.config; + +import com.wootecam.luckyvickyauction.core.lock.LettuceLockProvider; +import com.wootecam.luckyvickyauction.core.lock.NoOperationLockProvider; +import com.wootecam.luckyvickyauction.core.lock.RedissonLockProvider; +import com.wootecam.luckyvickyauction.global.aop.LockProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class LockProviderConfig { + + private final RedisTemplate redisOperations; + private final RedissonClient redissonClient; + + @Value("${lock.provider}") + private String lockProviderType; + + @Bean + public LockProvider lockProvider() { + switch (lockProviderType) { + case "lettuce": + log.info("Lettuce Lock Provider로 락을 관리합니다."); + return new LettuceLockProvider(redisOperations); + case "redisson": + log.info("Redisson Lock Provider로 락을 관리합니다."); + return new RedissonLockProvider(redissonClient); + case "none": + log.info("락을 사용하지 않습니다."); + return new NoOperationLockProvider(); + default: + throw new IllegalArgumentException("Unknown lock provider type: " + lockProviderType); + } + } + +} diff --git a/src/main/java/com/wootecam/luckyvickyauction/global/exception/ErrorCode.java b/src/main/java/com/wootecam/luckyvickyauction/global/exception/ErrorCode.java index ecff309b..bf7cdee3 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/global/exception/ErrorCode.java +++ b/src/main/java/com/wootecam/luckyvickyauction/global/exception/ErrorCode.java @@ -59,6 +59,7 @@ public enum ErrorCode { P005("포인트 충전 시, 충전할 포인트가 0보다 작을 경우 예외가 발생합니다."), P006("포인트 충전 시, 충전 후 포인트가 Long 최대값을 초과할 경우 예외가 발생합니다."), P007("환불 요청 시, 종료된 경매에 대한 환불이 아닌 경우 예외가 발생합니다."), + P008("포이늩 연산 시, 음수 값을 연산에 사용하는 경우 예외가 발생합니다."), // 인증, 인가 관련 예외 코드 AU00("API 요청 시, 비로그인 사용자가 허락되지 않은 엔드포인트에 접근 할 경우 예외가 발생합니다."), @@ -69,6 +70,8 @@ public enum ErrorCode { // Global 예외 G000("DTO 생성 시, 필드의 값이 NULL인 경우 예외가 발생합니다."), G001("목록 조회시, 과도한 데이터를 조회할 수 없습니다."), + G002("Lock 획득 시, TimeOut 시간을 초과하면 예외가 발생합니다."), + G003("Lock 획득 시, 시스템 문제로 락을 획득하지 못한 경우 예외가 발생합니다."), // 서버 예외 SERVER_ERROR("서버에서 예기치 못한 예외가 발생한 경우"); diff --git a/src/main/java/com/wootecam/luckyvickyauction/global/exception/ServiceUnavailableException.java b/src/main/java/com/wootecam/luckyvickyauction/global/exception/ServiceUnavailableException.java new file mode 100644 index 00000000..20123549 --- /dev/null +++ b/src/main/java/com/wootecam/luckyvickyauction/global/exception/ServiceUnavailableException.java @@ -0,0 +1,10 @@ +package com.wootecam.luckyvickyauction.global.exception; + +import static org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE; + +public class ServiceUnavailableException extends BusinessException { + + public ServiceUnavailableException(final String message, final ErrorCode errorCode) { + super(message, SERVICE_UNAVAILABLE.value(), errorCode); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d91e547b..ca6b5d5c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -36,6 +36,16 @@ server: mbeanregistry: enabled: true +lock: + provider: redisson + lettuce: + max_retry: 10 + retry_duration: 50 + lease_time: 200 + redisson: + wait_time: 500 + lease_time: 200 + logging: level: org: diff --git a/src/test/java/com/wootecam/luckyvickyauction/core/member/domain/MemberTest.java b/src/test/java/com/wootecam/luckyvickyauction/core/member/domain/MemberTest.java index 8d913483..bcce0d0c 100644 --- a/src/test/java/com/wootecam/luckyvickyauction/core/member/domain/MemberTest.java +++ b/src/test/java/com/wootecam/luckyvickyauction/core/member/domain/MemberTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; +import com.wootecam.luckyvickyauction.core.member.fixture.MemberFixture; import com.wootecam.luckyvickyauction.global.exception.BadRequestException; import com.wootecam.luckyvickyauction.global.exception.ErrorCode; import java.util.stream.Stream; @@ -228,4 +229,35 @@ class isBuyer_메소드는 { .isInstanceOf(BadRequestException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.M005); } + + @Nested + class pointTransfer_메소드는 { + + @Test + void 다른_사용자에게_포인트를_송금할_수_있다() { + // given + Member buyer = MemberFixture.createBuyerWithDefaultPoint(); + Member seller = MemberFixture.createSellerWithDefaultPoint(); + + // when + buyer.pointTransfer(seller, 500); + + // then + assertThat(buyer.getPoint()).isEqualTo(new Point(500)); + assertThat(seller.getPoint()).isEqualTo(new Point(1500)); + } + + @Test + void 포인트가_부족한_경우_예외가_발생한다() { + // given + Member buyer = MemberFixture.createBuyerWithDefaultPoint(); + + // expect + assertThatThrownBy(() -> buyer.pointTransfer(buyer, 1234567890)) + .isInstanceOf(BadRequestException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.P001); + } + + } + } diff --git a/src/test/java/com/wootecam/luckyvickyauction/core/member/domain/PointTest.java b/src/test/java/com/wootecam/luckyvickyauction/core/member/domain/PointTest.java index db92e4b7..dbfe2098 100644 --- a/src/test/java/com/wootecam/luckyvickyauction/core/member/domain/PointTest.java +++ b/src/test/java/com/wootecam/luckyvickyauction/core/member/domain/PointTest.java @@ -5,55 +5,104 @@ import com.wootecam.luckyvickyauction.global.exception.BadRequestException; import com.wootecam.luckyvickyauction.global.exception.ErrorCode; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class PointTest { @Test - void 포인트를_사용할_수_있다() { - // given - Point point = new Point(100); + void 포인트_생성시_음수인_경우_예외가_발생한다() { + // expect + assertThatThrownBy(() -> new Point(-1)) + .isInstanceOf(BadRequestException.class) + .hasMessage("금액은 양수여야 합니다.") + .satisfies(exception -> assertThat(exception).hasFieldOrPropertyWithValue("errorCode", ErrorCode.P008)); + } - // when - point.minus(100); + @Nested + class minus_메소드는 { - // then - assertThat(point.getAmount()).isEqualTo(0); - } + @Test + void 포인트를_사용할_수_있다() { + // given + Point point = new Point(100); - @Test - void 포인트_잔액보다_많은_양을_사용하려하면_예외가_발생한다() { - // given - Point point = new Point(100); + // when + point.minus(100); - // expect - assertThatThrownBy(() -> point.minus(101)) - .isInstanceOf(BadRequestException.class) - .hasMessage("포인트가 부족합니다.") - .satisfies(exception -> assertThat(exception).hasFieldOrPropertyWithValue("errorCode", ErrorCode.P001)); - } + // then + assertThat(point.getAmount()).isEqualTo(0); + } - @Test - void 포인트를_충전할_수_있다() { - // given - Point point = new Point(0); + @Test + void 포인트_잔액보다_많은_양을_사용하려하면_예외가_발생한다() { + // given + Point point = new Point(100); + + // expect + assertThatThrownBy(() -> point.minus(101)) + .isInstanceOf(BadRequestException.class) + .hasMessage("포인트가 부족합니다.") + .satisfies(exception -> assertThat(exception).hasFieldOrPropertyWithValue("errorCode", + ErrorCode.P001)); + } + + @Test + void 음수를_빼려고하면_예외가_발생한다() { + // given + Point point = new Point(1000); - // when - point.plus(100); + // expect + assertThatThrownBy(() -> point.minus(-1000)) + .isInstanceOf(BadRequestException.class) + .hasMessage("금액은 양수여야 합니다.") + .satisfies(exception -> assertThat(exception).hasFieldOrPropertyWithValue("errorCode", + ErrorCode.P008)); + } - // then - assertThat(point.getAmount()).isEqualTo(100); } - @Test - void 포인트가_최대치_이상_충전되면_예외가_발생한다() { - // given - Point point = new Point(Long.MAX_VALUE); + @Nested + class plus_메소드는 { + + @Test + void 포인트를_충전할_수_있다() { + // given + Point point = new Point(0); + + // when + point.plus(100); + + // then + assertThat(point.getAmount()).isEqualTo(100); + } + + @Test + void 포인트가_최대치_이상_충전되면_예외가_발생한다() { + // given + Point point = new Point(Long.MAX_VALUE); + + // expect + assertThatThrownBy(() -> point.plus(1)) + .isInstanceOf(BadRequestException.class) + .hasMessage("포인트가 최대치를 초과하였습니다.") + .satisfies(exception -> assertThat(exception).hasFieldOrPropertyWithValue("errorCode", + ErrorCode.P006)); + } + + @Test + void 음수를_더하려고하면_예외가_발생한다() { + // given + Point point = new Point(1000); + + // expect + assertThatThrownBy(() -> point.plus(-1000)) + .isInstanceOf(BadRequestException.class) + .hasMessage("금액은 양수여야 합니다.") + .satisfies(exception -> assertThat(exception).hasFieldOrPropertyWithValue("errorCode", + ErrorCode.P008)); + } - // expect - assertThatThrownBy(() -> point.plus(1)) - .isInstanceOf(BadRequestException.class) - .hasMessage("포인트가 최대치를 초과하였습니다.") - .satisfies(exception -> assertThat(exception).hasFieldOrPropertyWithValue("errorCode", ErrorCode.P006)); } + } diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 4627da60..4c27fff8 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -13,3 +13,13 @@ spring: port: 6379 password: 1q2w3e host: localhost + +lock: + provider: redisson + lettuce: + max_retry: 10 + retry_duration: 50 + lease_time: 200 + redisson: + wait_time: 500 + lease_time: 200