문제 상황
팀 프로젝트에서 경매 기능이 추가되면서 여러 사용자가 하나의 경매에 입찰할 수 있도록 기능을 구현하게 되었다.
먼저 경매(auctions) 테이블은 다음과 같이 정의되었다.
CREATE TABLE `auctions`
(
`id` bigint AUTO_INCREMENT NOT NULL,
`member_id` bigint NOT NULL,
`file_name` varchar(255) NOT NULL,
`title` varchar(255) NOT NULL,
`content` text NOT NULL,
`product_category` varchar(255) NOT NULL,
`product_status` varchar(255) NOT NULL,
`auction_status` varchar(255) NOT NULL,
`final_bid` integer NOT NULL,
`view_count` integer NOT NULL,
`started_at` datetime NOT NULL,
`ended_at` datetime NOT NULL,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
foreign key (`member_id`) references members (id) on delete cascade
)
여기서 사용자가 입찰을 요청할 때마다 final_bid
값이 업데이트해야한다.
하지만 여러 사용자가 동시에 입찰을 요청하게된다면 마지막에 요청한 사용자가 입찰한 결과만 반영될 것이다. (Isolation Level이 READ_COMMITTED
일 경우)
A | B |
---|---|
read -> 1000 | |
write(2000) | |
read -> 1000 | |
write(3000) | |
commit | |
commit |
update auctions a set a.final_bid = ?1 where a.id = ?2
요구사항 추가
여기에 다음과 같은 요구사항이 추가되었다.
- 요청한 입찰가는 이전 입찰가(DB에 저장된 값)보다 높아야한다.
- 경매를 올린 사용자는 입찰 요청이 불가능하다.
- 경매가 시작하기 전 또는 후에는 입찰이 불가능하다.
위와같이 요구사항이 추가되었을 경우 각각의 요구사항을 쿼리를 날려 확인하는 것보다 경매 엔티티를 조회하여 속성 값을 확인하는 것이 더 적은 쿼리를 사용할 수 있다.
- 각각의 요구사항을 쿼리를 통해 확인할 경우 (쿼리 3개 사용)
select (a.final_bid < ?1) from auctions a where a.id = ?2
select (a.memberId ?1) from auctions a where a.id = ?2
select (a.started_at > ?1 or a.ended_at < ?1) from auctions a where a.id = ?2
- 경매 엔티티를 조회하여 값을 확인할 경우 (쿼리 1개 사용)
- 경매 엔티티 조회:
select * from auctions a where a.id = ?1
auction.getFinalBid() >= biddingPrice
Objects.equals(auction.getMemberId(), memberId)
now.isBefore(auction.getStartedAt()) || now.isAfter(auction.getEndedAt())
- 경매 엔티티 조회:
또 다른 방법으로 그냥 update 쿼리에 모든 조건을 추가해줄 수 있을 것이다.
update auctions a set a.final_bid = :biddingPrice where a.id = :auctionId and a.final_bid < :biddingPrice and a.member_id != :memberId and (a.started_at <= now() and now() < a.ended_at)
문제
일단 데이터(경매 엔티티)를 조회하여 값을 확인하고 업데이트하는 경우에 발생할 수 있는 문제를 생각해보자.
- 두 명의 사용자가 동시에 입찰 요청을 했을 경우 모두 같은 가격을 조회하고 자신이 제시한 입찰 가격과 비교하게된다. 예를 들어, 기존 입찰가가 500원이고 A가 1000원, B가 2000원을 제시했을 때 B가 먼저 업데이트되고 A가 나중에 업데이트될 수 있게된다. 즉, B가 더 높은 가격임에도 A의 가격이 반영된다.
@Transactional
가장 먼저 @Transactional
를 생각해 볼 수 있는데 기본적으로 MySQL, MariaDB에서 Isolation Level이 REPEATABLE_READ
로 지정되어있다.
테스트
2명의 사용자가 입찰 요청을 다음과 같은 금액들로 동시에 시도한다고 하자.
[pool-6-thread-2] f.f.integration.AuctionIntegrationTest : Bidding price: 2627
[pool-6-thread-1] f.f.integration.AuctionIntegrationTest : Bidding price: 4843
코드를 제외하고 동작 순서를 정리하면 다음과 같다. 이때 문제 재현을 위해 select 조회 후 sleep을 걸어주었다.
pool-6-thread-1 | pool-6-thread-2 |
---|---|
Read finalBid -> 1000 | |
Read finalBid -> 1000 | |
Sleep | Sleep |
Update finalBid(4843) | |
Commit | |
Update finalBid(2627) | |
Commit |
결과는 pool-6-thread-2
에서 요청한 값(2627)이 반영된다. 위에서 정리한 문제가 그대로 발생한 것을 확인할 수 있었다.
비관적 락(for update)
MariaDB(InnoDB)에서는 for update
구문을 추가하여 read locking을 적용할 수 있다.
select * from auctions a where a.id = ?1 for update
바로 위처럼 for update
를 추가해주고 실행해보면 다음과 같이 동작하는 것을 확인 할 수 있었다.
pool-6-thread-1 | pool-6-thread-2 |
---|---|
Read finalBid -> 1000 | |
Read finalBid -> 1000 (Lock을 얻기위해 대기) | |
Sleep | |
Update finalBid(1074) | |
Commit | |
Read finalBid -> 1074 (Lock을 얻고 다시 조회) | |
Sleep | |
Update finalBid(1397) | |
Commit |
결과적으로 같은 값을 조회하는 것이 아닌 먼저 조회한 트랜잭션이 반영한 값을 다른 트랜잭션이 조회하고, 순서대로 처리해주는 것을 볼 수 있었다. 만약 pool-6-thread-2에서 1074보다 낮은 값을 요청했다면 입찰에 실패하게될 것이다.
낙관적 락
또 한 가지 방법으로 낙관적 락을 적용해볼 수 있다. 간단하게 version
속성을 추가해주면 된다.
CREATE TABLE `auctions`
(
...
`version` integer default 0 NOT NULL,
PRIMARY KEY (`id`),
foreign key (`member_id`) references members (id) on delete cascade
)
이처럼 version
속성만 추가해주고 @Version
을 적용해주면 기본적으로 LockMode가 NONE
으로 설정된 상태이다. 낙관적 락에 대한 LockMode로 다음과 같은 것들이 있다.
- NONE
@Version
이 적용된 필드만 있으면 낙관적 락이 적용- 엔티티를 수정할 때 버전을 체크하면서 버전을 증가
- OPTIMISTIC
@Version
만 적용했을 때는 엔티티를 수정해야 버전을 체크하지만 이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크- 트랜잭션을 커밋할 때 버전을 조회해서 버전 검증
- OPTIMISTIC_FORCE_INCREMENT
- 버전 정보를 강제로 증가
- 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용하여 버전을 강제로 증가
이 중에서 NONE
, OPTIMISTIC
을 각각 설정해보고 테스트해보았다.
NONE
NONE
이 설정된 경우 실행해보면 update
쿼리를 수행할 때 where
문에 version
도 추가된 것을 확인할 수 있다.
Hibernate:
update
auctions
set
auction_status=?,
content=?,
ended_at=?,
file_name=?,
final_bid=?,
member_id=?,
product_category=?,
product_status=?,
started_at=?,
title=?,
version=?, # <==
view_count=?
where
id=?
and version=? # <==
그리고 조회했을 때 version
값은 같았지만 update
할 때 버전 정보가 달라져 ObjectOptimisticLockingFailureException
가 발생한다.
이 설정은 second lost update
문제를 예방해줄 수 있지만 여전히 같은 값을 조회하여 비교하고 있다. 그래서 Spring Retry
를 사용하여 예외가 발생할 경우 재시도하도록 하게 만들 수 있을 것 같다.
OPTIMISTIC
OPTIMISTIC
은 커밋할 때 버전 정보를 조회하고 검증한다. 이 또한 Update 후 커밋할 때 버전이 다르기 때문에 예외가 발생한다.
Hibernate:
select
version as version_
from
auctions
where
id =?
비관적 락
다시 비관적 락으로 돌아와서 생각해보면 결론적으로 for update
구문을 사용하여 read locking을 적용한다. 그런데 LockModeType
에도 비관적 락 옵션이 존재한다. 일반적으로 사용되는 PESSIMISTIC_WRITE
이 있는데 똑같이 for update
를 적용해준다.
@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
@EntityGraph(attributePaths = "member")
Optional<Auction> findById(Long auctionId);
NOTE
PESSIMISTIC_WRITE 옵션으로 Lock을 걸어줄 때 같이 명시적으로 @Transactional을 같이 추가해주어야한다. 안그러면 no transaction is in progress 에러가 발생한다.
위처럼 적용해준 후 실행해보면 먼저 조회할 때 for update
가 추가되는 것을 확인할 수 있다.
Hibernate:
select
auction0_.id as id1_1_0_,
auction0_.created_at as created_2_1_0_,
auction0_.auction_status as auction_3_1_0_,
auction0_.content as content4_1_0_,
auction0_.ended_at as ended_at5_1_0_,
auction0_.file_name as file_nam6_1_0_,
auction0_.final_bid as final_bi7_1_0_,
auction0_.member_id as member_i8_1_0_,
auction0_.product_category as product_9_1_0_,
auction0_.product_status as product10_1_0_,
auction0_.started_at as started11_1_0_,
auction0_.title as title12_1_0_,
auction0_.version as version13_1_0_,
auction0_.view_count as view_co14_1_0_,
member1_.id as id1_5_1_,
member1_.created_at as created_2_5_1_,
member1_.modified_at as modified3_5_1_,
member1_.account_status as account_4_5_1_,
member1_.address as address5_5_1_,
member1_.email as email6_5_1_,
member1_.file_name as file_nam7_5_1_,
member1_.flag_count as flag_cou8_5_1_,
member1_.login_type as login_ty9_5_1_,
member1_.nickname as nicknam10_5_1_,
member1_.password as passwor11_5_1_,
member1_.rating as rating12_5_1_,
member1_.user_role as user_ro13_5_1_
from
auctions auction0_
inner join
members member1_
on auction0_.member_id=member1_.id
where
auction0_.id=? for update # <==
그리고 앞서 native query에 적용했던 것과 같은 결과를 확인 할 수 있었다.
Update 쿼리
마지막으로 모든 조건을 update 쿼리의 where 구문에 추가하여 실행해보자.
@Transactional
@Modifying
@Query(
"""
update Auction a
set a.finalBid = :price
where a.id = :auctionId
and a.finalBid < :price
and a.memberId != :memberId
and (a.startedAt <= now() and now() < a.endedAt)
""")
int updateFinalBidById(
@Param("auctionId") Long auctionId, @Param("price") int biddingPrice, @Param("memberId") Long memberId);
여기서 반환 타입(return type)을 int로 지정해준 이유는 update 여부를 판단하기위해서이다. 조건에 모두 부합하여 update가 되면 1
이 반환되고, 하나라도 맞지 않는다면 0
이 반환된다.
결과는 요청한 순서대로 update 쿼리가 실행되며 중간에 반영된 입찰가격이 더 크면 조건에 맞지 않게되어 update 되지 않는다.
하지만 이렇게 하면 사용자에게 어떤 조건에서 문제가 발생했는지 알려주기 어렵다. 단지, 반환되는 int 값이 0
일 경우 조건에 맞지 않는다는 사실만 알게될 뿐이다. 구체적으로 경매 시간이 아니라던지, 입찰 가격이 낮다던지 등의 이유를 알 수가 없게된다.
개발자 입장에서는 이 방법을 사용하는 것이 간편하고 효율적일 수 있겠지만, 사용자의 입장에서는 알 수 없는 이유로 입찰이 안되는 것이기 때문에 좋지 않은 방법이 될 수 있다.
결론
낙관적 락을 적용하여 예외가 발생하면 Spring Retry로 재시도 하도록 했다. 그리고 중간에 조건을 확인하면서 어떤 조건에서 문제가 발생했는지 알려줄 수 있도록 코드를 작성했다.
@Transactional
@Retryable(
value = {ObjectOptimisticLockingFailureException.class, CannotAcquireLockException.class},
backoff = @Backoff(delay = 300, maxDelay = 700))
public void requestBidding(Long auctionId, int biddingPrice, Long memberId) {
...
}
비관적 락의 경우 순차적으로 동작할 수 있게 만들어주고 예외도 안던져주기 때문에 더 좋아보일 수 있다. 하지만 비관적 락은 DeadLock 문제가 발생할 수 있다. 예를 들어, A와 B가 동시에 요청했을 때 A가 작업을 처리하는 동안 B는 Connection을 유지한 채로 기다리고 있는다. 즉, 다른 스레드가 처리하는 동안 계속 Connection을 반납하지않고 계속 들고 있는 것이다.
반면 낙관적 락은 트랜잭션 간의 경합(충돌)이 발생할 경우 Connection을 반납하고 예외를 발생시킨다. 그리고 Retry를 할 때 다시 Connection을 가져와서 작업을 수행하게된다. 이 과정에서 다른 스레드에서 무한정 기다리지 않고 반납하는 순간 해당 Connection을 사용할 수 있다. (Retry에서 delay를 설정하기 때문에 그 시간동안 충분히 다른 작업이 수행될 수 있을 것이다.) 따라서 비관적 락 보다 더 효율적이고 안정적으로 요청을 처리할 수 있을 것이라 판단하여 낙관적 락을 채택하게되었다.
Appendix
테스트 코드
@Test
@WithUserDetails(value = "testUser@gmail.com", setupBefore = TestExecutionEvent.TEST_EXECUTION)
void bidding() throws InterruptedException {
// given
int numThreads = 2;
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
CountDownLatch latch = new CountDownLatch(numThreads);
Random random = new Random();
Long auctionId = 2L;
// when
for (int i = 1; i <= numThreads; i++) {
executorService.execute(() -> {
try {
int price = random.nextInt((5000 - 1000) + 1) + 1000;
log.info("Bidding price: {}", price);
auctionApi.placeBidding(auctionId, new BiddingRequest(price), FixtureDto.createMemberPrincipal());
latch.countDown();
latch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
// then
Thread.sleep(1000);
int finalBiddingPrice = auctionService.getAuction(auctionId).getFinalBid();
log.info("final bidding price: {}", finalBiddingPrice);
}
서비스 코드
@Transactional
public void requestBidding(Long auctionId, int biddingPrice, Long memberId) {
Auction auction = getAuction(auctionId);
if (log.isDebugEnabled()) {
try {
log.debug("sleep...");
Random random = new Random();
Thread.sleep(random.nextLong(100) + 200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
validateBiddingRequest(auction, biddingPrice, memberId);
// 입찰가 변경
auction.setFinalBid(biddingPrice);
}
컨트롤러 코드
@PutMapping("/{auctionId}/bid")
public ResponseEntity<Void> placeBidding(
@PathVariable Long auctionId,
@RequestBody @Valid BiddingRequest biddingRequest,
@AuthenticationPrincipal MemberPrincipal memberPrincipal) {
auctionService.requestBidding(auctionId, biddingRequest.biddingPrice(), memberPrincipal.id());
return ResponseEntity.ok(null);
}