이번 프로젝트에서는 실시간 입찰 기능을 개발하게 되었습니다. 다수의 사용자가 동시에 입찰할 수 있기 때문에 정합성 문제가 발생할 수 있었고, 이를 어떻게 해결할지 고민이 많았습니다.
초기에는 가장 단순하고 안정적인 방법인 DB 비관적 락(Pessimistic Lock)을 적용했습니다. 입찰 처리 로직을 트랜잭션으로 감싸고, 그 안에서 락을 걸어 순차적으로 처리하는 방식이었습니다.
비관적 락은 다음과 같이 findByIdWithLock() 메소드로 특정 Row를 락 걸고 처리하는 구조였습니다.
@Transactional
public void placeBidWithDbLock(String email, PlaceBidRequestDto request) {
TransactionFeed feed = transactionFeedRepository.findByIdWithLock(request.getTransactionFeedId())
.orElseThrow(TransactionFeedNotFoundException::new);
User bidder = findUserByEmail(email);
validateBidPrecondition(bidder, feed, request.getBidAmount());
Optional<Bids> highestBid = bidsRepository.findHighestBidByFeed(feed);
highestBid.ifPresent(bid -> userPayService.refundPay(bid.getUser(), bid.getBidAmount()));
userPayService.usePay(bidder, request.getBidAmount());
saveBidHistory(feed, bidder, request.getBidAmount(), bidTimeStamp);
}
구현은 간단했지만, 문제는 성능이었습니다. JMeter로 동시 1,000명의 사용자가 입찰하는 테스트를 진행해본 결과는 다음과 같았습니다.
| 항목 | DB 비관적 락 |
|---|---|
| 성공률 | 50% |
| TPS | 14.35 |
| 95% 응답시간 | 60초 |
성공률이 절반에 불과했고, 95% 응답시간은 무려 60초였습니다. 트랜잭션 안에서 락을 점유한 채로 환불, 결제, 입찰 저장까지 모두 처리하고 있었기 때문에 요청이 몰리면 병목이 발생할 수밖에 없었습니다.

이 구조에서 문제의 핵심은 락이 걸린 상태에서 너무 많은 작업을 처리하고 있다는 점이었습니다.
그래서 모든 로직을 Redis로 옮기는 대신, 동시성 경쟁이 집중되는 “Hot Path”만 Redis로 분리하는 방향으로 설계를 변경했습니다.
입찰에서 실제로 경쟁이 발생하는 구간은 다음 두 가지였습니다.
이 두 연산은 반드시 원자적으로 실행되어야 하며, 동시에 가장 빈번하게 호출되는 Hot Path입니다.
반면 아래 작업들은 트랜잭션 정합성이 더 중요한 영역입니다.
따라서 구조를 다음과 같이 분리했습니다.
Redis는 싱글 스레드 기반이기 때문에 Lua Script를 활용하면 조건 확인과 상태 갱신을 하나의 연산으로 묶을 수 있습니다.
다음은 입찰 처리에 사용한 Lua Script입니다.
local prev_price = redis.call('get', KEYS[1])
local prev_bidder = redis.call('get', KEYS[2])
local new_price = tonumber(ARGV[1])
local new_bidder = ARGV[2]
local floor = prev_price and tonumber(prev_price) or tonumber(ARGV[3])
if prev_bidder == new_bidder then
return { 'SAME_BIDDER', '0', '0' }
end
if new_price > floor then
redis.call('set', KEYS[1], new_price)
redis.call('set', KEYS[2], new_bidder)
return { 'SUCCESS', prev_bidder or '0', prev_price or '0' }
else
return { 'BID_TOO_LOW', '0', '0' }
end
Java에서는 Redis Lua Script 실행 결과를 기준으로 후처리를 진행하도록 구성했습니다.
핵심은 Redis는 “결정”, DB는 “책임”을 갖도록 역할을 분리한 것입니다.
List result = stringRedisTemplate.execute(
bidScript,
List.of(highestPriceKey, highestBidderKey),
request.getBidAmount().toString(),
bidder.getUserId().toString(),
feed.getSalesPrice().toString()
);
이후 결과에 따라 DB 트랜잭션을 수행합니다.
특히 중요한 점은, 환불 → 차감 → 히스토리 저장이 하나의 트랜잭션으로 묶여 있다는 것입니다.
이 부분은 실제 코드에서도 다음과 같이 처리됩니다.
즉, Redis는 경쟁 상태를 해결하고, DB는 금전 데이터의 정합성을 책임지는 구조입니다.
이 구조에서 가장 신경 쓴 부분은 Redis와 DB 사이의 상태 불일치 가능성이었습니다.
예를 들어 다음과 같은 상황이 발생할 수 있습니다.
이 경우 Redis 상태와 DB 상태가 어긋나는 문제가 발생합니다.
이를 해결하기 위해 보상 로직(Compensation Logic)을 설계했습니다.
실제 구현에서는 DB 처리 중 예외가 발생하면, Redis 상태를 이전 값으로 롤백하도록 처리했습니다.
stringRedisTemplate.opsForValue().set(highestPriceKey, prevBidAmountStr);
stringRedisTemplate.opsForValue().set(highestBidderKey, prevBidderIdStr);
이렇게 하면 다음을 보장할 수 있습니다.
완벽한 분산 트랜잭션은 아니지만, 실용적인 수준에서 정합성을 맞추기 위한 설계였습니다.
동일한 조건에서 Redis 기반 구현으로 다시 성능 테스트를 진행했습니다.
| 항목 | DB 비관적 락 | Redis Lua Script |
|---|---|---|
| 성공률 | 50% | 100% |
| TPS | 14.35 | 91.58 |
| 95% 응답시간 | 60초 | 128ms |
| 평균 응답시간 | 31.8초 | 86ms |
성공률은 100%로 올라갔고, 평균 응답시간은 370배 이상 개선되었습니다. TPS도 약 6배 증가한 것을 확인할 수 있었습니다.

이번 작업을 하면서 느낀 점은, 단순히 Redis를 도입하는 것이 아니라 “어디까지 Redis로 처리할 것인가”를 구분하는 것이 중요하다는 것이었습니다.
모든 로직을 Redis로 옮기면 성능은 좋아질 수 있지만, 금전 처리와 같은 중요한 데이터의 정합성을 잃을 수 있습니다.
그래서 이번에는
로 설계했습니다.
이 경험을 통해 단순한 성능 개선을 넘어서,
성능과 정합성을 분리 설계하고, 실패 시 복구까지 고려하는 방식으로 문제를 해결하는 접근을 배울 수 있었습니다.