실시간 경매의 마감, 입찰, 낙찰에서 동시성처리.feat Redis

킼키키킼·2024년 9월 3일

Spring stomp를 이용해서 실시간 입찰 경매를 구현했는데, 이때
여러 명의 유저가 동시에 입찰을 진행하고, 경매가 마감되면 낙찰자가 선정된다. 이 과정에 2가지 문제점을 발견했다.


첫 번째 문제
초기엔 경매 마감을 스프링 스케줄러를 통해 구현했는데 문제점을 발견했다.

경매 시스템에서 스케줄러를 사용해 1분마다 경매 마감 여부를 확인하는 방식으로 운영했을 때, 서버에 큰 부하가 발생하는 문제가 있었다. 스케줄러가 매 분마다 데이터베이스(DB)를 조회하면서 입찰, 낙찰, 결제 등 여러 요청들이 느려졌고, 시스템 성능 저하로 이어졌다. 또한, 경매 마감 시간이 지나도 스케줄러가 대기하는 1분 동안은 실제로 마감이 이루어지지 않아 사용자 경험이 좋지 않았다.

이 문제를 해결하기 위해, Redis의 TTL(Time-To-Live) 기능을 활용한 방안을 도입했다. 경매를 등록할 때 Redis의 TTL 기능을 이용해 미리 마감 시간을 설정하고, 설정된 키가 만료(EXPIRED) 되면 그 시점에 즉시 경매 마감 처리를 진행하도록 하였다.
이러한 방법을 통해 데이터베이스를 지속적으로 조회할 필요가 없어졌고, 예로 작성 했던 테스트 코드를 실행한 결과 마감 경매 조회 트랜잭션과 3개의 입찰 트랜잭션 겹치면 모든 트랜잭션이 완료 되는데 약 2772ms의 시간이 소요됐지만, TTL을 도입하고 마감경매 로직이 없어진 이후엔 3개의 입찰 트랜잭션이 처리되는데 항상 1442ms가 소요됐다. 또한 경매 마감도 완벽히 실시간으로 처리돼서 불편한 유저 경험도 해결할 수 있었다.

레디스 TTL 등록 코드

 public ProductRepDto createAuction(String email, ProductReqDto productReqDto) {
        // Redis에 경매 ID와 TTL 설정

        ProductRepDto productRepDto = createProduct(email,productReqDto);
        Timestamp currentTime = Timestamp.from(Instant.now());
        long durationInSeconds = calculateTimeDifference(currentTime, productRepDto.getEndTime()).getSeconds();
        // 경매 시작 시 TTL 설정 (예: 1시간)

        String pid = ManagementFactory.getRuntimeMXBean().getName();
        String key =  "Auction:ProductId:" + productRepDto.getId();
        redisTemplate.opsForValue().set(key,pid);
        redisTemplate.expire(key,durationInSeconds, TimeUnit.SECONDS);

        log.info("Auction started for product ID: " + productRepDto.getId());
        return productRepDto;
    }

레디스 Expire 리스너 코드

public class RedisExpirationListener implements MessageListener {

    
    @Override
    public void onMessage(Message message, byte[] pattern) {
    if (key.startsWith("Auction:ProductId:")) {
            String productId = key.substring("Auction:ProductId:".length());
            endAuction(Long.valueOf(productId)); // 경매 마감 트랜잭션

        }
    }

두번째 문제
실시간 입찰을 진행하는 도중에 여러 명의 유저가 같은 상품에 대해 동시에 입찰하기 요청을 보내면 같은 가격에 여러 명의 입찰자가 생성되는 것을 확인할 수 있다
또한 즉시 구매 로직이 있는데 이때 낙찰자의 경우도 같은 상품에 대해 여러 명이 생성되는 것을 발견했다.

이를 해결하기 위해 해당 요청에 락을 걸어서 한 번에 하나의 요청만 처리 되게 설정하려고 했다.

방법은 비관 락, 낙관 락, 분산 락이 있었다.
낙관적락을 사용하기엔 한 개의 상품 ID에 지속적인 충돌이 야기되는 구조라서, 충돌이 많이 일어나 롤백 연산이 많아지게 돼 서버의 성능 저하를 일으킬 거 같아서 배제했다.

비관적락을 쓰면 입찰 트랜잭션 이 완료되기 전 까진 DB에서 해당 상품 ID 튜 프리에 Exclusive Lock이 걸리기 때문에 경매 목록 페이지에서 상품 조회 로직의 성능이 많이 내려가기 때문에 도입할 수 없었다.
최종적으로 db 튜 프리에 직접적으로 Exclusive Lock을 거는 것이 아닌 입찰 이벤트(트랜잭션)에 대해서만 Exclusive Lock 락을 걸 수 있는 레이디스의 분산 락을 선택했다.

위 두 로직이 완전히 분리된 트랜잭션이었지만 레이디스의 분산 락을 구현하면서C

위와 같이 상품 ID를 기준으로 분산락으로 트랜잭션들을 묶었다.


@Slf4j
@RequiredArgsConstructor
@Service
public class RedisLockService {

    private final RedissonClient redissonClient;

    /**
     * Executes the given callback within a Redis lock.
     *
     * @param lockKey      the key for the lock
     * @param waitTime     the maximum time to wait for the lock
     * @param leaseTime    the time to hold the lock after granting it
     * @param timeUnit     the time unit of the waitTime and leaseTime parameters
     * @param callback     the callback function to execute within the lock
     * @param <T>          the return type of the callback function
     * @return the result of the callback function
     */
    public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, LockCallback<T> callback) {
        final RLock lock = redissonClient.getLock(lockKey);
        try {
            if (!lock.tryLock(waitTime, leaseTime, timeUnit)) {
                throw new RuntimeException("Redisson lock timeout for key: " + lockKey);
            }
            return callback.execute();
        } catch (Exception e) {
            throw new RuntimeException("Error during operation with lock: " + e.getMessage(), e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    @FunctionalInterface
    public interface LockCallback<T> {
        T execute() throws Exception;
    }
}

Redis 템플릿 코드를 작성해두고,

public class BidService {

    private final RedisLockService redisLockService;
    public BidResDto bidLock(String email, BidDto bidDto) {
        String lockKey = String.format("Product:productId:%d", bidDto.getProductId());
        return redisLockService.executeWithLock(lockKey, 3, 2, TimeUnit.SECONDS, () -> {
            // Execute the business logic within the lock
            return startBid(email, bidDto); // 입찰 트랜잭션
        });
    }
}

서비스 부분에서 호출 되도록 작성했다. 이를 통해 동시성 문제를 해결했다.

0개의 댓글