Redis Lua로 살린 실시간 입찰 시스템

k_bell·2025년 8월 2일

트러블슈팅

목록 보기
5/7
post-thumbnail

이번 프로젝트에서는 실시간 입찰 기능을 개발하게 되었습니다. 다수의 사용자가 동시에 입찰할 수 있기 때문에 정합성 문제가 발생할 수 있었고, 이를 어떻게 해결할지 고민이 많았습니다.

초기에는 가장 단순하고 안정적인 방법인 DB 비관적 락(Pessimistic Lock)을 적용했습니다. 입찰 처리 로직을 트랜잭션으로 감싸고, 그 안에서 락을 걸어 순차적으로 처리하는 방식이었습니다.

1차 구현: 비관적 락 적용

비관적 락은 다음과 같이 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%
TPS14.35
95% 응답시간60초

성공률이 절반에 불과했고, 95% 응답시간은 무려 60초였습니다. 트랜잭션 안에서 락을 점유한 채로 환불, 결제, 입찰 저장까지 모두 처리하고 있었기 때문에 요청이 몰리면 병목이 발생할 수밖에 없었습니다.


Redis + Lua Script로 병목 지점 분리

이 구조에서 문제의 핵심은 락이 걸린 상태에서 너무 많은 작업을 처리하고 있다는 점이었습니다. 그래서 병목 지점을 Redis로 옮기기로 했습니다.

입찰에서 동시성이 가장 중요한 부분은 아래 두 가지입니다.

  1. 내 입찰가가 현재 최고가보다 높은지 확인
  2. 조건을 만족하면 최고가와 최고 입찰자를 갱신

이 두 작업만 Redis에서 원자적으로 처리하고, 나머지 로직(결제, 환불 등)은 DB에서 처리하는 구조로 바꾸었습니다. 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 트랜잭션이 담당하는 구조입니다.

public void placeBid(String email, PlaceBidRequestDto request) {
    User bidder = findUserByEmail(email);
    TransactionFeed feed = findTransactionFeedById(request.getTransactionFeedId());

    List<Object> result = redisTemplate.execute(
        bidScript,
        List.of(highestPriceKey, highestBidderKey),
        request.getBidAmount().toString(),
        bidder.getUserId().toString(),
        feed.getSalesPrice().toString()
    );

    switch (result.get(0).toString()) {
        case "SUCCESS" -> {
            // 환불, 결제, 입찰 내역 저장
        }
        case "BID_TOO_LOW" -> throw new BidException(ErrorCode.BID_AMOUNT_TOO_LOW);
        case "SAME_BIDDER" -> throw new BidException(ErrorCode.CANNOT_BID_ON_OWN_HIGHEST);
        default -> throw new InternalServerException(ErrorCode.LUA_SCRIPT_ERROR);
    }
}

성능 비교

동일한 조건에서 Redis 기반 구현으로 다시 성능 테스트를 진행했습니다.

항목DB 비관적 락Redis Lua Script
성공률50%100%
TPS14.3591.58
95% 응답시간60초128ms
평균 응답시간31.8초86ms

성공률은 100%로 올라갔고, 평균 응답시간은 370배 이상 개선되었습니다. TPS도 약 6.4배 증가한 것을 확인할 수 있었습니다.


마무리

이번 작업을 하면서 느낀 점은, 성능 병목이 발생하는 위치를 잘 식별하는 게 가장 중요하다는 것입니다. 모든 처리를 DB에 맡기면 정합성은 확보할 수 있지만, 대량의 요청이 몰릴 때는 한계가 명확히 드러납니다.

이번처럼 병목 구간만 Redis로 분리하고, 나머지 처리는 그대로 DB에서 트랜잭션으로 묶는 방식이 생각보다 효과적이었습니다. 입찰 시스템뿐 아니라, 특정 조건 판단과 상태 업데이트가 동시에 일어나는 구조라면 Redis + Lua 방식도 좋은 대안이 될 수 있다고 생각합니다.

0개의 댓글