개요
현재 리팩토링중인 AI 기반 작품 경매 플랫폼 프로젝트: Another Art에서는 다음 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);
}
}
}
간략하게 정리해보면 다음과 같다
- 경매 정보(Auction), 작품 정보(Art), 입찰자 정보(Member) 가져오기
- 입찰 가능 여부 확인
- 입찰자가 작품 소유자인지 - validateBidderIsOwner
- 경매가 열렸는지 - validateAuctionIsOpen
- 입찰자가 현재 최고 입찰자랑 동일한지 - validateBidderIsCurrentHighestBidder
- 입찰가가 받아들일만한지 - validateNewBidPrice
- 입찰 진행
- 최고 입찰자 정보 갱신 - Auction Update
- 입찰 기록 저장 - AuctionRecord Update
- 이전 최고 입찰자 & 현재 최고 입찰자간의 사용 가능한 포인트 트랜잭션 처리 - Member Update
테스트 코드를 통해서 동시성 문제가 발생하는지 확인해보자
데드락
그런데 로그를 살펴보니 데드락이 발생한 것을 확인할 수 있다
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은 위의 상황과 마찬가지로 여전히 데드락이 발생하고 있다
- auction_record Insert로 인한 X-Lock 획득 + 연관 테이블(auction, member)에 S-Lock 전파
- auction & member 갱신을 위해서 Update X-Lock 요구
- 데드락…
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);