[Spring] 경매 입찰 & 작품 구매 동시성 문제 해결 1) DB Lock

개요

 

GitHub - sjiwon/Advanced-Another-Art: AI 기반 작품 경매 플랫폼 (Refactoring)

AI 기반 작품 경매 플랫폼 (Refactoring). Contribute to sjiwon/Advanced-Another-Art development by creating an account on GitHub.

github.com

현재 리팩토링중인 AI 기반 작품 경매 플랫폼 프로젝트: Another Art에서는 다음 2가지 주요 기능이 존재한다

  1. 경매 작품 입찰
  2. 작품 구매

경매 작품 입찰 & 일반 작품 구매에서는 멀티 쓰레드 환경에서 동시성 문제가 발생할 수 있고 이를 반드시 제어해야 한다

 

입찰 프로세스

@UseCase
@RequiredArgsConstructor
public class BidUseCase {
    private final AuctionReader auctionReader;
    private final ArtReader artReader;
    private final MemberReader memberReader;
    private final BidInspector bidInspector;
    private final BidProcessor bidProcessor;
 
    // TODO need `Concurrency Control`
    @AnotherArtWritableTransactional
    public void invoke(final BidCommand command) {
        final Auction auction = auctionReader.getById(command.auctionId());
        final Art art = artReader.getById(auction.getArtId());
        final Member bidder = memberReader.getById(command.memberId());
 
        bidInspector.checkBidCanBeProceed(auction, art, bidder, command.bidPrice());
        bidProcessor.execute(auction, bidder, command.bidPrice());
    }
}
@Service
@RequiredArgsConstructor
public class BidInspector {
    public void checkBidCanBeProceed(
            final Auction auction,
            final Art art,
            final Member bidder,
            final int newBidPrice
    ) {
        validateBidderIsOwner(art, bidder);
        validateAuctionIsOpen(auction);
        validateBidderIsCurrentHighestBidder(auction, bidder);
        validateNewBidPrice(auction, newBidPrice);
    }
 
    private void validateBidderIsOwner(final Art art, final Member bidder) {
        if (art.isOwner(bidder)) {
            throw new AuctionException(ART_OWNER_CANNOT_BID);
        }
    }
 
    private void validateAuctionIsOpen(final Auction auction) {
        if (!auction.isInProgress()) {
            throw new AuctionException(AUCTION_IS_NOT_IN_PROGRESS);
        }
    }
 
    private void validateBidderIsCurrentHighestBidder(final Auction auction, final Member bidder) {
        if (auction.isHighestBidder(bidder)) {
            throw new AuctionException(HIGHEST_BIDDER_CANNOT_BID_AGAIN);
        }
    }
 
    private void validateNewBidPrice(final Auction auction, final int newBidPrice) {
        if (!auction.isNewBidPriceAcceptable(newBidPrice)) {
            throw new AuctionException(BID_PRICE_IS_NOT_ENOUGH);
        }
    }
}
@Service
@RequiredArgsConstructor
public class BidProcessor {
    private final MemberReader memberReader;
    private final AuctionWriter auctionWriter;
 
    @AnotherArtWritableTransactional
    public void execute(
            final Auction auction,
            final Member newBidder,
            final int newBidPrice
    ) {
        final Long previousBidderId = auction.getHighestBidderId();
        final int previousBidPrice = auction.getHighestBidPrice();
 
        // 1. 최고 입찰자 정보 갱신
        auction.updateHighestBid(newBidder, newBidPrice);
        auctionWriter.saveRecord(auction, newBidder, newBidPrice);
 
        // 2. 이전 입찰자 <-> 새로운 입찰자 포인트 트랜잭션
        doPointTransaction(previousBidderId, newBidder, previousBidPrice, newBidPrice);
    }
 
    private void doPointTransaction(
            final Long previousBidderId,
            final Member newBidder,
            final int previousBidPrice,
            final int newBidPrice
    ) {
        newBidder.decreaseAvailablePoint(newBidPrice);
 
        if (previousBidderId != null) {
            final Member previousBidder = memberReader.getById(previousBidderId);
            previousBidder.increaseAvailablePoint(previousBidPrice);
        }
    }
}

간략하게 정리해보면 다음과 같다

  1. 경매 정보(Auction), 작품 정보(Art), 입찰자 정보(Member) 가져오기
  2. 입찰 가능 여부 확인
    • 입찰자가 작품 소유자인지 - validateBidderIsOwner
    • 경매가 열렸는지 - validateAuctionIsOpen
    • 입찰자가 현재 최고 입찰자랑 동일한지 - validateBidderIsCurrentHighestBidder
    • 입찰가가 받아들일만한지 - validateNewBidPrice
  3. 입찰 진행
    • 최고 입찰자 정보 갱신 - Auction Update
    • 입찰 기록 저장 - AuctionRecord Update
    • 이전 최고 입찰자 & 현재 최고 입찰자간의 사용 가능한 포인트 트랜잭션 처리 - Member Update

 

테스트 코드를 통해서 동시성 문제가 발생하는지 확인해보자

1) 동일 가격에 대해서 2개의 경매 기록이 Insert된 상황
2) 서로 다른 가격 입찰에 대해서 최고 입찰가가 아닌 입찰이 선택된 상황

 

데드락

그런데 로그를 살펴보니 데드락이 발생한 것을 확인할 수 있다
DB 트랜잭션 로그를 살펴보자

show engine innodb status를 통해서 상태를 체크해보니 특정 Record에 대한 S-Lock을 보유한 상태에서 서로 다른 Transaction에서 동일한 Record에 대한 X-Lock을 요구하고 있고 공존할 수 없는 S-Lock & X-Lock이기 때문에 데드락이 발생한거라고 판단된다

 

현재 로직에는 어떠한 DB Lock도 걸지 않았는데 왜 Transaction마다 S-Lock을 잡고 있는걸까?

 

로그를 보니 Auction & Member에 대한 Update Query 이전에 AuctionRecord에 대한 Insert Query가 먼저 진행되고 있다

  • AuctionRecord는 AuctionWriter에 의해서 Data JPA save를 통한 영속화가 진행된다
    • 키 전략은 IDENTITY이고 영속성 컨텍스트에서 관리되기 위해서 ID 값이 필요하므로 즉시 Insert Query
  • Auction, Member에 대한 수정은 JPA Transaction Dirty Checking에 의해서 진행된다
    • 트랜잭션에 대한 flush 시점에 동작


이러한 이유로 인해 AuctionRecord에 대한 Insert가 Auction & Member에 대한 Update보다 먼저 발생한다


이 과정에서 auction_record 테이블은 auction, member 테이블의 PK를 FK(외래키)로 잡고 있다 

그에 따라서 auction_record insert 과정에서 X-Lock을 얻음과 동시에 외래키로 잡혀있는 auction, member 테이블에 대한 S-Lock이 전파되는 것이다

외래키 잠금 전파

 

 

해결 시도

1. Optimistic Lock

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
@Table(name = "auction")
public class Auction extends BaseEntity<Auction> {
    @Column(name = "art_id", nullable = false, updatable = false, unique = true)
    private Long artId;
 
    @Embedded
    private Period period;
 
    @Column(name = "highest_bidder_id")
    private Long highestBidderId;
 
    @Column(name = "highest_bid_price", nullable = false)
    private int highestBidPrice;
 
    @Version
    private long version;
 
    @OneToMany(mappedBy = "auction", cascade = CascadeType.PERSIST)
    private final List<AuctionRecord> auctionRecords = new ArrayList<>();
    
    ...
}
public interface AuctionRepository extends JpaRepository<Auction, Long> {
    // @Query
    @Query("""
            SELECT ac
            FROM Auction ac
            WHERE ac.id = :id
            """)
    @Lock(LockModeType.OPTIMISTIC)
    Optional<Auction> findByIdWithLock(@Param("id") Long id);
    
    ...
}
ALTER TABLE auction ADD COLUMN version BIGINT NOT NULL DEFAULT 0;

Optimistic Lock은 위의 상황과 마찬가지로 여전히 데드락이 발생하고 있다

  1. auction_record Insert로 인한 X-Lock 획득 + 연관 테이블(auction, member)에 S-Lock 전파
  2. auction & member 갱신을 위해서 Update X-Lock 요구
  3. 데드락…

 

2. Pessimistic Lock

1) S-Lock → PESSIMISTIC_READ

public interface AuctionRepository extends JpaRepository<Auction, Long> {
    // @Query
    @Query("""
            SELECT ac
            FROM Auction ac
            WHERE ac.id = :id
            """)
    @Lock(LockModeType.PESSIMISTIC_READ)
    Optional<Auction> findByIdWithLock(@Param("id") Long id);
    
    ...
}

  • 위와 동일한 이유로 데드락이 발생해서 로직을 정상적으로 처리할 수 없는 구조

 

2) X-Lock → PESSIMISTIC_WRITE

public interface AuctionRepository extends JpaRepository<Auction, Long> {
    // @Query
    @Query("""
            SELECT ac
            FROM Auction ac
            WHERE ac.id = :id
            """)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Auction> findByIdWithLock(@Param("id") Long id);
    
    ...
}

X-Lock을 통해서 Auction을 읽는 진입점부터 다른 Thread들을 대기하게 함으로써 입찰에 대한 동시성 문제를 해결하였다

 

 

Pessimistic Write Lock Performance Issue

Pessimistic Write Lock은 다른 Thread들의 읽기/수정/삭제 모든 연산들을 대기시킴으로써 동시성을 제어할 수 있는 기법이다
하지만 Lock을 얻은 트랜잭션의 처리가 지연된다면 지연된만큼 다른 Thread들의 처리 역시 밀리게 되는 문제가 발생할 수 있다

  • 과도한 대기 후 Timeout or Deadlock 발생 가능


따라서 이러한 문제를 해결하기 위해서 Lock 점유를 위해서 일정 시간만 대기하고 해당 시간동안 얻지 못하면 해당 트랜잭션을 실패 또는 다른 방식으로 처리할 필요가 있다

  • 이래야 Cascading으로 다른 Thread들의 처리가 점점 밀리는 현상을 최소화시킬 수 있다

 

1) @QueryHints

@Query("""
        SELECT ac
        FROM Auction ac
        WHERE ac.id = :id
        """)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="3000")})
Optional<Auction> findByIdWithLock(@Param("id") Long id);

 

2) EntityManager

Map<String, Object> properties = new HashMap();
properties.put("javax.persistence.query.timeout", 3000);
final Auction auction = entityManager.find(Auction.class, id, LockModeType.PESSIMISTIC_READ, properties);