Skip to content

Commit

Permalink
�feat: 경매 환불 시 동시성 해결은 DB 락을 사용한다 (#316)
Browse files Browse the repository at this point in the history
* feat: 경매 환불 시, 경매 Lock 획득 코드 구현

* feat: 경매 환불 시, 거래 내역 Lock 획득 코드 구현

* fix: 구매 취소 시 Select For Update로 실행하도록 코드 작성

* test: 환불 동시성 테스트 코드 작성

* fix: ReceiptEntity 식별자 GeneratedValue 제거

- 지정한 ID가 아닌 새로운 ID 생성이 되어 제거

* fix: 경매 조회 순서 변경으로 Lock 범위 변경

- verifyEndAuction의 인자를 경매의 종료 시간으로 변경합니다

* fix: 거래 내역 id 지정을 위한 추가

* fix(test): 시간 정밀도 차이를 마이크로초까지 제한한 공통 LocalDateTime 필드를 사용하도록 테스트를 수정

* refactor: 사용하지 않는 분산락 적용 코드 삭제

---------

Co-authored-by: HiiWee <[email protected]>
  • Loading branch information
minseok-oh and HiiWee authored Aug 27, 2024
1 parent 918b31d commit 1dbe550
Show file tree
Hide file tree
Showing 13 changed files with 140 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ public interface AuctionRepository {
List<Auction> findAllBy(AuctionSearchCondition condition);

List<Auction> findAllBy(SellerAuctionSearchCondition condition);

Optional<Auction> findByIdForUpdate(long auctionId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,10 @@ public List<Auction> findAllBy(SellerAuctionSearchCondition condition) {
.map(Mapper::convertToAuction)
.toList();
}

@Override
public Optional<Auction> findByIdForUpdate(long auctionId) {
Optional<AuctionEntity> auction = auctionJpaRepository.findByIdForUpdate(auctionId);
return auction.map(Mapper::convertToAuction);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package com.wootecam.luckyvickyauction.core.auction.infra;

import com.wootecam.luckyvickyauction.core.auction.entity.AuctionEntity;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

public interface AuctionJpaRepository extends JpaRepository<AuctionEntity, Long>, AuctionQueryDslRepository {

Optional<AuctionEntity> findById(long id);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from AuctionEntity a where a.id = :id")
Optional<AuctionEntity> findByIdForUpdate(Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import com.wootecam.luckyvickyauction.core.auction.dto.SellerAuctionSimpleInfo;
import com.wootecam.luckyvickyauction.core.member.domain.Role;
import com.wootecam.luckyvickyauction.core.member.dto.SignInInfo;
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;
Expand Down Expand Up @@ -99,13 +98,18 @@ public void submitPurchase(long auctionId, long price, long quantity, LocalDateT
* @param quantity 환불할 수량
*/
@Transactional
@DistributedLock("#auctionId + ':auction:lock'")
public void cancelPurchase(long auctionId, long quantity) {
Auction auction = findAuctionObject(auctionId);
Auction auction = findAuctionObjectForUpdate(auctionId);
auction.refundStock(quantity);
auctionRepository.save(auction);
}

private Auction findAuctionObjectForUpdate(long auctionId) {
return auctionRepository.findByIdForUpdate(auctionId)
.orElseThrow(
() -> new NotFoundException("경매(Auction)를 찾을 수 없습니다. AuctionId: " + auctionId, ErrorCode.A030));
}

private Auction findAuctionObject(long auctionId) {
return auctionRepository.findById(auctionId)
.orElseThrow(
Expand Down Expand Up @@ -172,4 +176,10 @@ public List<SellerAuctionSimpleInfo> getSellerAuctionSimpleInfos(SellerAuctionSe
.map(Mapper::convertToSellerAuctionSimpleInfo)
.toList();
}

public AuctionInfo getAuctionForUpdate(long auctionId) {
Auction auction = findAuctionObjectForUpdate(auctionId);

return Mapper.convertToAuctionInfo(auction);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,21 @@ public void process(AuctionPurchaseRequestMessage message) {
*/
@Override
@Transactional
@DistributedLock("#message.receiptId + ':receipt:lock'")
public void refund(AuctionRefundRequestMessage message) {
verifyHasBuyerRole(message.buyerInfo());

Receipt receipt = findRefundTargetReceipt(message.receiptId());
verifyEndAuction(message.requestTime(), receipt.getAuctionId());
Receipt receipt = findRefundTargetReceiptForUpdate(message.receiptId());
verifySameBuyer(message.buyerInfo(), receipt.getBuyerId());
receipt.markAsRefund();

AuctionInfo auction = auctionService.getAuctionForUpdate(receipt.getAuctionId());
verifyEndAuction(message.requestTime(), auction.finishedAt());

auctionService.cancelPurchase(receipt.getAuctionId(), receipt.getQuantity());
paymentService.pointTransfer(receipt.getSellerId(), receipt.getBuyerId(),
receipt.getPrice() * receipt.getQuantity());
auctionService.cancelPurchase(receipt.getAuctionId(), receipt.getQuantity());

receiptRepository.save(receipt); // 정상적으로 환불 처리된 경우 해당 이력을 '환불' 상태로 변경
receiptRepository.save(receipt);
}

private void verifyHasBuyerRole(SignInInfo buyerInfo) {
Expand All @@ -89,10 +90,8 @@ private void verifyHasBuyerRole(SignInInfo buyerInfo) {
}
}

private void verifyEndAuction(LocalDateTime requestTime, long auctionId) {
AuctionInfo auction = auctionService.getAuction(auctionId);

if (requestTime.isBefore(auction.finishedAt())) {
private void verifyEndAuction(LocalDateTime requestTime, LocalDateTime auctionFinishedAt) {
if (requestTime.isBefore(auctionFinishedAt)) {
throw new BadRequestException("종료된 경매만 환불할 수 있습니다.", ErrorCode.P007);
}
}
Expand All @@ -103,9 +102,8 @@ private void verifySameBuyer(SignInInfo buyerInfo, long receiptBuyerId) {
}
}

private Receipt findRefundTargetReceipt(UUID receiptId) {
return receiptRepository.findById(receiptId).orElseThrow(
private Receipt findRefundTargetReceiptForUpdate(UUID receiptId) {
return receiptRepository.findByIdForUpdate(receiptId).orElseThrow(
() -> new NotFoundException("환불할 입찰 내역을 찾을 수 없습니다. 내역 id=" + receiptId, ErrorCode.P002));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ public interface ReceiptRepository {
List<Receipt> findAllByBuyerId(Long buyerId, BuyerReceiptSearchCondition condition);

List<Receipt> findAllBySellerId(Long sellerId, SellerReceiptSearchCondition condition);

Optional<Receipt> findByIdForUpdate(UUID receiptId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
Expand All @@ -21,7 +19,6 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ReceiptEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String productName;
private long price;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,10 @@ public List<Receipt> findAllBySellerId(Long sellerId, SellerReceiptSearchConditi
.map(Mapper::convertToReceipt)
.toList();
}

@Override
public Optional<Receipt> findByIdForUpdate(UUID receiptId) {
Optional<ReceiptEntity> found = receiptJpaRepository.findByIdForUpdate(receiptId);
return found.map(Mapper::convertToReceipt);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package com.wootecam.luckyvickyauction.core.payment.infra;

import com.wootecam.luckyvickyauction.core.payment.entity.ReceiptEntity;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

public interface ReceiptJpaRepository extends JpaRepository<ReceiptEntity, UUID>, ReceiptQueryDslRepository {

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select r from ReceiptEntity r where r.id = :id")
Optional<ReceiptEntity> findByIdForUpdate(UUID id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
import com.wootecam.luckyvickyauction.core.member.domain.Member;
import com.wootecam.luckyvickyauction.core.member.domain.Point;
import com.wootecam.luckyvickyauction.core.member.domain.Role;
import com.wootecam.luckyvickyauction.core.member.dto.SignInInfo;
import com.wootecam.luckyvickyauction.core.member.fixture.MemberFixture;
import com.wootecam.luckyvickyauction.global.dto.AuctionPurchaseRequestMessage;
import com.wootecam.luckyvickyauction.global.dto.AuctionRefundRequestMessage;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
Expand All @@ -20,6 +24,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;

class BasicAuctioneerTest extends ServiceTest {

Expand Down Expand Up @@ -81,6 +86,7 @@ class 재고보다_많은_구매_요청이_들어오면 {
doneSignal.await();
executorService.shutdown();

// then
assertAll(
() -> assertThat(successCount.get()).isEqualTo(5L),
() -> assertThat(failCount.get()).isEqualTo(5L)
Expand Down Expand Up @@ -172,5 +178,76 @@ class 두명_중_한명이_포인트가_부족하다면 {
);
}
}

@Nested
class 다수가_동시에_같은_경매에_환불을_하는_경우 {

@Test
void 정상적으로_재고와_포인트가_수정되어야_한다() throws InterruptedException {
// given
Member seller = memberRepository.save(MemberFixture.createSellerWithDefaultPoint());
Member buyer = memberRepository.save(Member.builder()
.signInId("buyerId")
.password("password00")
.role(Role.BUYER)
.point(new Point(1000000000L))
.build());

Auction auction = auctionRepository.save(Auction.builder()
.sellerId(seller.getId())
.productName("상품 이름")
.originPrice(1000L)
.currentPrice(1000L)
.originStock(10L)
.currentStock(10L)
.maximumPurchaseLimitCount(5)
.pricePolicy(new ConstantPricePolicy(100L))
.variationDuration(Duration.ofMinutes(10))
.startedAt(now)
.finishedAt(now.plusHours(1))
.build());

List<UUID> requestIds = new ArrayList<>();
for (int i = 0; i < 10; i++) {
UUID requestId = UUID.randomUUID();
requestIds.add(requestId);
auctioneer.process(
new AuctionPurchaseRequestMessage(requestId, buyer.getId(), auction.getId(), 1000L, 1L,
now));
}

SignInInfo buyerInfo = new SignInInfo(buyer.getId(), Role.BUYER);

int numThreads = 10;
CountDownLatch doneSignal = new CountDownLatch(numThreads);
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);

// when
for (int i = 0; i < numThreads; i++) {
UUID finalI = requestIds.get(i);
executorService.execute(() -> {
try {
auctioneer.refund(new AuctionRefundRequestMessage(buyerInfo, finalI, now.plusHours(2)));
} catch (Exception e) {
e.printStackTrace();
} finally {
doneSignal.countDown();
}
});
}
doneSignal.await();
executorService.shutdown();

// then
Auction updatedAuction = auctionRepository.findById(auction.getId()).get();
Member updatedSeller = memberRepository.findById(seller.getId()).get();
Member updatedBuyer = memberRepository.findById(buyer.getId()).get();
assertAll(
() -> assertThat(updatedAuction.getCurrentStock()).isEqualTo(10L),
() -> assertThat(updatedSeller.getPoint().getAmount()).isEqualTo(1000L),
() -> assertThat(updatedBuyer.getPoint().getAmount()).isEqualTo(1000000000L)
);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class 구매자_거래_내역_동적_쿼리_실행시 {

for (int i = 0; i < size + 1; i++) {
repository.save(ReceiptEntity.builder()
.id(UUID.randomUUID())
.productName("상품1")
.price(1000)
.quantity(1)
Expand Down Expand Up @@ -67,6 +68,7 @@ class 구매자_거래_내역_동적_쿼리_실행시 {
var condition = new BuyerReceiptSearchCondition(size, offset);

repository.save(ReceiptEntity.builder()
.id(UUID.randomUUID())
.productName("상품1")
.price(1000)
.quantity(1)
Expand All @@ -79,6 +81,7 @@ class 구매자_거래_내역_동적_쿼리_실행시 {
.build());

repository.save(ReceiptEntity.builder()
.id(UUID.randomUUID())
.productName("상품1")
.price(1000)
.quantity(1)
Expand Down Expand Up @@ -142,6 +145,7 @@ class 판매자_거래_내역_동적_쿼리_실행시 {
var condition = new SellerReceiptSearchCondition(offset, size);

repository.save(ReceiptEntity.builder()
.id(UUID.randomUUID())
.productName("상품1")
.price(1000)
.quantity(1)
Expand All @@ -154,6 +158,7 @@ class 판매자_거래_내역_동적_쿼리_실행시 {
.build());

repository.save(ReceiptEntity.builder()
.id(UUID.randomUUID())
.productName("상품1")
.price(1000)
.quantity(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ class 정상적인_요청_흐름이면 {
auctionRepository.save(auction);

Receipt receipt = Receipt.builder()
.id(UUID.randomUUID())
.auctionId(1L)
.productName("test")
.price(100L)
Expand Down Expand Up @@ -425,6 +426,7 @@ class 만약_입찰_내역의_구매자가_요청한_사용자가_아니라면 {
auctionRepository.save(auction);

Receipt receipt = Receipt.builder()
.id(UUID.randomUUID())
.auctionId(1L)
.productName("test")
.price(100L)
Expand Down Expand Up @@ -471,6 +473,7 @@ class 만약_아직_종료되지_않은_경매상품을_환불하려_하면 {
auctionRepository.save(auction);

Receipt receipt = Receipt.builder()
.id(UUID.randomUUID())
.auctionId(1L)
.productName("test")
.price(100L)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ static Stream<Arguments> provideMembersForSuccess() {
SignInInfo nonOwner = new SignInInfo(3L, Role.BUYER);

Receipt receipt = Receipt.builder()
.id(UUID.randomUUID())
.sellerId(seller.getId())
.buyerId(buyer.getId())
.build();
Expand Down

0 comments on commit 1dbe550

Please sign in to comment.