Skip to content

Commit

Permalink
feat: 구매자 입찰 과정은 원자적으로 처리되어야한다. (#296)
Browse files Browse the repository at this point in the history
* refactor: (Member) 다른 사용자에게 포인트를 송금하는 기능 추가

* feat: 포인트가 음수가 되는 경우에 대한 검증 추가

- validatePositiveAmount를 통해 생성 또는 거래하려는 포인트가 양수인지 검증한다.
- 거래 포인트가 음수인 경우를 P008 예외로 표현한다.

* refactor: 경매 입찰 시, 포인트 거래를 PaymentService로 분리한다.

- pointTransfer(senderId, recipientId, amount)를 통해 포인트를 이체한다.

* feat: 어노테이션을 통해 락 동작을 수행하는 기능 추가

- 메서드에 DistributedLock 어노테이션을 붙여 분산락 수행을 요청한다.
- DistributedLockAspect는 해당 어노테이션이 붙은 메서드를 분산락과 함께 수행한다.
- LockProvider를 구현해 사용할 락 방식을 선택할 수 있다.

* feat: LettuceLockProvider 클래스 추가

* feat: RedissonLockProvider 클래스 추가

- 시간 초과로 락을 획득하지 못하는 경우를 표현하는 G002 에러 추가

* feat: 환경 변수를 통해 사용할 Lock을 선택하는 LockProviderConfig 추가

- lock.provider 값을 통해 사용할 lock을 선택한다.
- lettuce, redisson, none 중에 하나를 입력하면 적용된다.

* refactor: AOP를 통해 Lock을 사용하도록 수정

* refactor: 사용되지 않는 AuctioneerProxy 및 구현 클래스 제거

* refactor: Redisson 디버깅용 주석 추

* feat: 서버 과부하로 인한 예외를 표현하는 ServiceUnavailableException 클래스 추가

* feat: 과부하로 인해 정상적으로 락을 획득하지 못하는 경우 ServiceUnavailableException이 발생한다.

* feat: Lock 구현체에서 사용되는 값들을 환경변수로 분
  • Loading branch information
chhs2131 authored Aug 25, 2024
1 parent 4de33bc commit 2a0175c
Show file tree
Hide file tree
Showing 21 changed files with 437 additions and 176 deletions.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,49 @@
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;

/**
* 1. 구매자 확인 <br> 2. 구매자 포인트를 감소 <br> 3. 판매자에게 포인트 지급 <br> 4. 구매 요청 <br> - 실패하면 -> 예외 발생 및 구매자와 판매자 포인트 롤백 <br> -
* 성공하면 -> 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));
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<String, Long> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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) {
// 락 동작을 수행하지 않습니다.
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,32 @@ 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);
}
amount -= 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) {
Expand Down
Loading

0 comments on commit 2a0175c

Please sign in to comment.