공부메모 & 오류해결/Spring Boot

[Spring Boot] 한정 수량 이벤트에서 동시성 문제를 어떻게 막을까?

남건욱 2026. 6. 9. 20:11

목차

    반응형

    서비스를 만들다 보면 한정된 수량을 여러 사용자가 동시에 가져가려는 상황을 자주 만난다.

    예를 들면 이런 기능들이다.

    선착순 이벤트 신청
    한정판 상품 주문
    공연 좌석 예매
    수강 신청
    사전 예약 인원 제한

     

    처음에는 단순하게 생각할 수 있다.

    "현재 신청 수량을 조회하고, 제한 수량보다 작으면 저장하면 되는 것 아닌가?"

     

    코드로 쓰면 대략 이런 모양이다.

    long acceptedCount = applicationRepository.countAccepted(eventId);
    
    if (acceptedCount >= limit) {
        return ApplyResult.notAvailable();
    }
    
    applicationRepository.save(Application.accepted(eventId, userId));
    return ApplyResult.accepted();

     

    혼자 테스트할 때는 잘 동작한다.
    하지만 동시에 요청이 몰리면 문제가 생긴다.

     

    예를 들어 남은 수량이 1개라고 해보자.

    요청 A: 현재 신청 수량 조회 -> 99
    요청 B: 현재 신청 수량 조회 -> 99
    
    요청 A: 제한 수량보다 작으니 성공 처리
    요청 B: 제한 수량보다 작으니 성공 처리

    분명 남은 수량은 1개였는데 2명이 성공할 수 있다.

     

    이런 문제가 바로 동시성 문제다.
    여러 요청이 동시에 같은 데이터를 읽고 수정하면서, 비즈니스 규칙이 깨지는 상황이다.

    이번 글에서는 한정 수량 이벤트를 예시로 동시성 제어 방법을 정리해보려고 한다.

    1. 비관적 락
    2. 낙관적 락
    3. Redis 분산 락
    4. Redis Lua Script

    그리고 각각 어떤 상황에 어울리는지 비교해보자.

    1. 왜 동시성 제어가 필요할까?

    한정 수량 이벤트는 결국 하나의 공유 자원을 여러 요청이 동시에 수정하는 문제다.

    공유 자원은 이런 값일 수 있다.

    현재 신청 성공 수량
    남은 재고 수량
    좌석 점유 여부
    사용자별 신청 여부

     

    여기서 중요한 것은 조회와 변경이 분리되어 있으면 안 된다는 점이다.

     

    아래 흐름은 위험하다.

    1. 현재 수량 조회
    2. 제한 수량과 비교
    3. 신청 내역 저장
    4. 현재 수량 증가

     

    이 4단계가 각각 따로 실행되면, 여러 요청이 중간에 끼어들 수 있다.

     

    그래서 동시성 제어의 핵심은 이것이다.

    조회, 검증, 변경을 하나의 안전한 단위로 묶는다.

     

     

    이제 방법을 하나씩 보자.

    2. 비관적 락

    비관적 락은 이름 그대로 비관적으로 접근하는 방식이다.

    "다른 요청이 동시에 같은 데이터를 수정할 수 있으니, 내가 처리하는 동안 남들은 기다려라."

    DB의 row lock을 이용해서 특정 데이터를 잠그는 방식이다.

    JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE)를 사용할 수 있다.

    public interface EventStockRepository extends JpaRepository<EventStock, Long> {
    
        @Lock(LockModeType.PESSIMISTIC_WRITE)
        @Query("""
            select s
            from EventStock s
            where s.eventId = :eventId
        """)
        Optional<EventStock> findByEventIdForUpdate(Long eventId);
    }

     

    서비스 코드는 이런 형태가 된다.

    @Transactional
    public ApplyResult apply(Long eventId, String userId) {
        EventStock stock = eventStockRepository.findByEventIdForUpdate(eventId)
                .orElseThrow(EventNotFoundException::new);
    
        if (applicationRepository.existsByEventIdAndUserId(eventId, userId)) {
            return applicationRepository.findResult(eventId, userId);
        }
    
        if (!stock.canAccept()) {
            Application rejected = Application.notAvailable(eventId, userId);
            applicationRepository.save(rejected);
            return ApplyResult.notAvailable();
        }
    
        stock.increaseAcceptedCount();
    
        Application accepted = Application.accepted(eventId, userId);
        applicationRepository.save(accepted);
    
        return ApplyResult.accepted();
    }

    이 방식에서는 같은 이벤트 재고 row를 잡은 요청이 먼저 처리된다.
    다른 요청은 해당 트랜잭션이 끝날 때까지 기다린다.

    비관적 락의 장점

    동작 방식이 직관적이다.

    DB 트랜잭션 안에서 데이터 정합성을 강하게 지킬 수 있다.

    중복 신청 확인, 수량 확인, 신청 저장을 하나의 트랜잭션으로 묶기 좋다.

    트래픽이 아주 크지 않은 관리자성 기능이나 일반 주문 처리에서는 충분히 실용적이다.

    비관적 락의 단점

    요청이 몰리면 DB에 대기열이 생긴다.

    락을 오래 잡고 있으면 커넥션도 오래 점유된다.

    여러 테이블을 함께 잠그는 구조에서는 데드락 가능성도 생긴다.

    애플리케이션 서버를 여러 대로 늘려도 결국 DB row 하나에 요청이 몰리면 병목은 DB에 생긴다.

    즉, 비관적 락은 정확하지만 요청이 한 지점에 몰리는 이벤트성 트래픽에는 부담이 커질 수 있다.

    3. 낙관적 락

    낙관적 락은 반대로 낙관적으로 접근하는 방식이다.

    "대부분은 충돌이 안 날 것이다. 대신 저장할 때 버전이 바뀌었는지 확인하자."

    JPA에서는 @Version을 사용한다.

    @Entity
    public class EventStock {
    
        @Id
        private Long id;
    
        private Long eventId;
    
        private int limitCount;
    
        private int acceptedCount;
    
        @Version
        private Long version;
    
        public boolean canAccept() {
            return acceptedCount < limitCount;
        }
    
        public void increaseAcceptedCount() {
            if (!canAccept()) {
                throw new IllegalStateException("수량이 부족합니다.");
            }
    
            this.acceptedCount++;
        }
    }

     

    서비스에서 같은 데이터를 동시에 수정하면, 먼저 커밋한 요청만 성공한다.
    나중에 커밋하는 요청은 version이 달라졌기 때문에 예외가 발생한다.

    @Transactional
    public ApplyResult apply(Long eventId, String userId) {
        EventStock stock = eventStockRepository.findByEventId(eventId)
                .orElseThrow(EventNotFoundException::new);
    
        if (applicationRepository.existsByEventIdAndUserId(eventId, userId)) {
            return applicationRepository.findResult(eventId, userId);
        }
    
        if (!stock.canAccept()) {
            applicationRepository.save(Application.notAvailable(eventId, userId));
            return ApplyResult.notAvailable();
        }
    
        stock.increaseAcceptedCount();
        applicationRepository.save(Application.accepted(eventId, userId));
    
        return ApplyResult.accepted();
    }

     

    동시에 수정이 발생하면 보통 이런 예외를 만나게 된다.

    ObjectOptimisticLockingFailureException
    OptimisticLockException

     

    그래서 낙관적 락은 재시도 로직과 함께 쓰는 경우가 많다.

    public ApplyResult applyWithRetry(Long eventId, String userId) {
        int maxRetry = 3;
    
        for (int i = 0; i < maxRetry; i++) {
            try {
                return apply(eventId, userId);
            } catch (ObjectOptimisticLockingFailureException e) {
                sleep(50);
            }
        }
    
        return ApplyResult.temporaryFailed();
    }

     

    또는 조건부 update 쿼리로 처리할 수도 있다.

    @Modifying
    @Query("""
        update EventStock s
           set s.acceptedCount = s.acceptedCount + 1,
               s.version = s.version + 1
         where s.eventId = :eventId
           and s.acceptedCount < s.limitCount
           and s.version = :version
    """)
    int tryIncrease(
            @Param("eventId") Long eventId,
            @Param("version") Long version
    );

    update 결과가 1이면 성공이고, 0이면 누군가 먼저 수정했거나 수량이 없는 것이다.

    낙관적 락의 장점

    락을 잡고 기다리지 않는다.

    충돌이 적은 상황에서는 비관적 락보다 효율적이다.

    DB row를 오래 점유하지 않기 때문에 일반적인 수정 기능에 잘 어울린다.

    낙관적 락의 단점

    충돌이 많으면 재시도가 많아진다.

    이벤트 오픈 직후처럼 같은 row에 요청이 몰리는 상황에서는 실패와 재시도가 반복될 수 있다.

    재시도 횟수, 대기 시간, 최종 실패 정책을 직접 설계해야 한다.

    사용자 입장에서는 응답 시간이 길어지거나, 일시 실패 응답을 받을 수 있다.

    즉, 낙관적 락은 충돌이 가끔 있는 서비스에는 좋지만, 한 시점에 요청이 폭발적으로 몰리는 구조에는 조심해서 써야 한다.

    4. Redis 분산 락

    애플리케이션 서버가 한 대라면 Java의 synchronized 같은 키워드로도 임계 구역을 막을 수 있다.

    하지만 서버가 여러 대라면 이야기가 달라진다.

    API 서버 1
    API 서버 2
    API 서버 3

     

    각 서버의 JVM 메모리는 서로 다르다.
    따라서 한 서버 안에서만 잠그는 방식은 전체 시스템의 동시성 문제를 해결하지 못한다.

    이때 Redis 같은 외부 저장소를 이용해서 분산 락을 만들 수 있다.

    가장 기본적인 형태는 SET key value NX PX다.

    SET lock:event:1 random-token NX PX 3000

     

    의미는 이렇다.

    NX: 키가 없을 때만 저장
    PX 3000: 3초 뒤 자동 만료

     

    Java 코드로 단순화하면 이런 느낌이다.

    public ApplyResult apply(Long eventId, String userId) {
        String lockKey = "lock:event:" + eventId;
        String token = UUID.randomUUID().toString();
    
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, token, Duration.ofSeconds(3));
    
        if (!Boolean.TRUE.equals(locked)) {
            return ApplyResult.temporaryFailed();
        }
    
        try {
            return applyInDatabase(eventId, userId);
        } finally {
            unlock(lockKey, token);
        }
    }

     

    락 해제는 그냥 DEL lockKey로 하면 위험할 수 있다.

    왜냐하면 내 락이 만료된 뒤 다른 요청이 같은 key로 락을 잡았는데,
    뒤늦게 내가 DEL을 호출하면 다른 요청의 락을 지워버릴 수 있기 때문이다.

    그래서 value에 token을 넣고, token이 일치할 때만 삭제해야 한다.

    if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('DEL', KEYS[1])
    end
    
    return 0

     

    이 패턴을 직접 구현할 수도 있지만, 운영 코드에서는 Redisson 같은 라이브러리를 사용하는 경우도 많다.

    RLock lock = redissonClient.getLock("lock:event:" + eventId);
    
    boolean locked = lock.tryLock(1, 3, TimeUnit.SECONDS);
    
    if (!locked) {
        return ApplyResult.temporaryFailed();
    }
    
    try {
        return applyInDatabase(eventId, userId);
    } finally {
        lock.unlock();
    }

    Redis 분산 락의 장점

    여러 애플리케이션 인스턴스 사이에서도 하나의 락을 공유할 수 있다.

    DB row lock에 몰리는 부담을 어느 정도 줄일 수 있다.

    기존 DB 로직을 크게 바꾸지 않고 감쌀 수 있다.

    Redis 분산 락의 단점

    락 획득, 만료 시간, 재시도, 해제 안정성을 모두 신경 써야 한다.

    락을 잡은 상태에서 DB 작업이 오래 걸리면 TTL이 먼저 만료될 수 있다.

    락의 범위를 너무 크게 잡으면 결국 요청이 한 줄로 처리된다.

    Redis 장애가 발생하면 락 자체를 잡을 수 없다.

    결국 Redis 분산 락은 "여러 서버에서 하나의 임계 구역을 보호하는 방법"이다.
    하지만 수량 차감 자체를 가장 가볍게 처리하는 방식은 아니다.

    5. Redis Lua Script

    Redis Lua Script는 Redis에서 여러 명령을 하나의 스크립트로 실행하는 방식이다.

    Redis는 Lua Script를 실행하는 동안 다른 명령을 끼워 넣지 않는다.
    즉, 스크립트 내부 로직은 원자적으로 실행된다.

     

    여기서 중요한 포인트는 이것이다.

    락을 잡고 애플리케이션 코드로 돌아오는 것이 아니라,
    검증과 변경을 Redis 안에서 한 번에 끝낸다.

     

    한정 수량 신청에서는 다음 로직을 하나로 묶을 수 있다.

    1. 이미 신청한 사용자인지 확인
    2. 이벤트가 마감되었는지 확인
    3. 현재 성공 수량 확인
    4. 성공 가능하면 순번 발급
    5. 사용자 결과 저장
    6. 마지막 수량이면 마감 시각 저장

     

    예시 Lua Script를 보자.

    local resultKey = KEYS[1]
    local countKey = KEYS[2]
    local acceptedSetKey = KEYS[3]
    local endedAtKey = KEYS[4]
    
    local userId = ARGV[1]
    local limitCount = tonumber(ARGV[2])
    local requestedAt = tonumber(ARGV[3])
    
    local alreadyApplied = redis.call('HGET', resultKey, userId)
    if alreadyApplied then
        return {alreadyApplied}
    end
    
    local endedAt = redis.call('GET', endedAtKey)
    if endedAt and requestedAt > tonumber(endedAt) then
        return {'ended'}
    end
    
    local currentCount = tonumber(redis.call('GET', countKey) or '0')
    
    if currentCount >= limitCount then
        redis.call('SETNX', endedAtKey, requestedAt)
        redis.call('HSET', resultKey, userId, 'not_available')
        return {'not_available'}
    end
    
    local nextSequence = redis.call('INCR', countKey)
    
    redis.call('ZADD', acceptedSetKey, nextSequence, userId)
    redis.call('HSET', resultKey, userId, 'accepted:' .. nextSequence)
    
    if nextSequence == limitCount then
        redis.call('SETNX', endedAtKey, requestedAt)
    end
    
    return {'accepted', tostring(nextSequence)}

     

    여기서 사용하는 Redis 자료구조는 다음과 같다.

    Hash
    - 사용자별 신청 결과 저장
    - key: event:{eventId}:results
    - field: userId
    - value: accepted:1, not_available, ended
    
    String
    - 현재 성공 수량 저장
    - key: event:{eventId}:accepted-count
    - value: 0, 1, 2, ...
    
    Sorted Set
    - 성공한 사용자 목록 저장
    - key: event:{eventId}:accepted-users
    - score: 성공 순번
    - member: userId
    
    String
    - 마감 시각 저장
    - key: event:{eventId}:ended-at
    - value: timestamp

    왜 Lua Script가 동시성에 강할까?

    아래 로직이 따로 실행된다고 생각해보자.

    HGET resultKey userId
    GET countKey
    INCR countKey
    HSET resultKey userId accepted
    ZADD acceptedSetKey sequence userId

     

    명령이 분리되어 있으면 중간에 다른 요청이 들어올 수 있다.

    하지만 Lua Script로 묶으면 Redis 입장에서는 하나의 명령처럼 처리된다.

    요청 A의 Lua Script 실행 시작
    요청 A의 Lua Script 실행 종료
    요청 B의 Lua Script 실행 시작
    요청 B의 Lua Script 실행 종료

    그래서 같은 수량을 두 요청이 동시에 가져가는 문제가 생기지 않는다.

    6. Spring Boot에서 Lua Script 실행하기

    Spring Boot에서는 RedisTemplateRedisScript를 이용해서 Lua Script를 실행할 수 있다.

    먼저 스크립트를 파일로 둔다.

    src/main/resources/redis/apply-event.lua

     

    그리고 빈으로 등록한다.

    @Configuration
    public class RedisScriptConfig {
    
        @Bean
        public RedisScript<List> applyEventScript() {
            DefaultRedisScript<List> script = new DefaultRedisScript<>();
            script.setLocation(new ClassPathResource("redis/apply-event.lua"));
            script.setResultType(List.class);
            return script;
        }
    }

     

    실행 코드는 이런 식으로 작성할 수 있다.

    @Component
    public class EventApplyRedisCommand {
    
        private final StringRedisTemplate redisTemplate;
        private final RedisScript<List> applyEventScript;
    
        public EventApplyRedisCommand(
                StringRedisTemplate redisTemplate,
                RedisScript<List> applyEventScript
        ) {
            this.redisTemplate = redisTemplate;
            this.applyEventScript = applyEventScript;
        }
    
        public ApplyResult apply(Long eventId, String userId, int limitCount, long requestedAt) {
            List<String> keys = List.of(
                    "event:" + eventId + ":results",
                    "event:" + eventId + ":accepted-count",
                    "event:" + eventId + ":accepted-users",
                    "event:" + eventId + ":ended-at"
            );
    
            List result = redisTemplate.execute(
                    applyEventScript,
                    keys,
                    userId,
                    String.valueOf(limitCount),
                    String.valueOf(requestedAt)
            );
    
            return ApplyResult.from(result);
        }
    }

     

    실제 서비스 코드에서는 Redis 결과를 도메인 응답으로 변환한다.

    public record ApplyResult(
            ApplyStatus status,
            Integer sequence
    ) {
    
        public static ApplyResult from(List result) {
            String status = String.valueOf(result.get(0));
    
            if ("accepted".equals(status)) {
                return new ApplyResult(
                        ApplyStatus.ACCEPTED,
                        Integer.valueOf(String.valueOf(result.get(1)))
                );
            }
    
            if ("not_available".equals(status)) {
                return new ApplyResult(ApplyStatus.NOT_AVAILABLE, null);
            }
    
            if ("ended".equals(status)) {
                return new ApplyResult(ApplyStatus.ENDED, null);
            }
    
            throw new IllegalArgumentException("알 수 없는 신청 결과입니다.");
        }
    }

    여기서 중요한 것은 Redis 결과를 그대로 컨트롤러까지 흘려보내지 않는 것이다.

    Redis는 구현 세부사항이고, 서비스 바깥으로는 도메인에 맞는 응답을 내려주는 편이 좋다.

    7. 신청 결과를 DB에도 저장해야 할까?

    Redis는 빠르지만, 모든 데이터를 Redis에만 두는 것은 부담스러울 수 있다.

    예를 들어 나중에 아래 기능이 필요할 수 있다.

    관리자 페이지에서 신청자 목록 조회
    이벤트별 결과 통계 조회
    정산 또는 운영 로그 보관
    장애 이후 데이터 복구

     

    이런 요구사항이 있다면 Redis에서 즉시 결과를 결정하고,
    최종 결과는 DB에 저장하는 구조를 생각할 수 있다.

     

    흐름은 이렇게 나눌 수 있다.

    1. 사용자 요청
    2. Redis Lua Script로 신청 결과 즉시 결정
    3. 사용자에게 바로 응답
    4. 결과 저장 작업은 별도 흐름으로 DB에 반영

     

    이렇게 하면 사용자 응답 경로에서 DB 병목을 줄일 수 있다.

    다만 DB 저장을 비동기로 분리하면 반드시 멱등성을 고려해야 한다.

    예를 들어 같은 저장 요청이 두 번 들어와도 결과가 한 번만 반영되어야 한다.

    DB에는 보통 이런 제약조건을 둔다.

    alter table event_application
        add constraint uk_event_application_user
            unique (event_id, user_id);

     

    그리고 저장할 때는 중복 insert가 발생해도 최종 상태가 깨지지 않도록 처리한다.

    insert into event_application (
        event_id,
        user_id,
        result,
        sequence,
        requested_at
    ) values (
        :eventId,
        :userId,
        :result,
        :sequence,
        :requestedAt
    )
    on duplicate key update
        result = values(result),
        sequence = values(sequence),
        requested_at = values(requested_at);

    핵심은 "Redis에서 한 번 결정된 결과가 DB에 여러 번 저장되더라도 동일한 결과로 수렴하게 만드는 것"이다.

    8. 마감 처리는 어떻게 볼까?

    한정 수량 이벤트에서는 "수량이 다 찼다"와 "이벤트가 마감됐다"를 구분하는 것이 좋다.

    예를 들어 이런 상황을 생각해보자.

    14:00:00 이벤트 시작
    14:01:10 마지막 수량 성공 처리
    14:01:11 신규 요청 도착

    14:01:10에 마지막 수량이 처리되었다면,
    그 이후 들어온 요청은 이미 마감된 이벤트에 대한 요청으로 볼 수 있다.

     

    반대로 마감 시각 전에 접수된 요청이 뒤늦게 처리되었는데 수량이 없다면,
    그 요청은 "참여했지만 수량 부족"으로 볼 수 있다.

     

    이 차이를 분리하고 싶다면 요청 접수 시각과 마감 시각을 함께 다뤄야 한다.

    requestedAt <= endedAt
    -> 마감 전에 들어온 요청
    -> 수량이 없으면 not_available
    
    requestedAt > endedAt
    -> 마감 이후 들어온 요청
    -> ended

    물론 모든 서비스가 이 정도까지 구분해야 하는 것은 아니다.

    단순히 "현재 수량이 없으면 실패"로 처리해도 되는 서비스가 있고,
    "마감 전 접수된 요청과 마감 후 요청"을 구분해야 하는 서비스도 있다.

    중요한 것은 정책을 먼저 정하고, 코드와 응답을 그 정책에 맞추는 것이다.

    9. Redis Lua Script의 장점

    1. 원자적이다

    중복 확인, 수량 확인, 순번 발급, 결과 저장을 한 번에 처리할 수 있다.

    애플리케이션에서 락을 잡고 여러 명령을 호출하는 것보다 경쟁 구간이 짧다.

    2. 빠르다

    DB row lock을 잡고 기다리는 구조보다 가볍다.

    특히 단순한 카운팅, 중복 확인, 순번 발급처럼 Redis 자료구조로 표현하기 좋은 문제에 잘 맞는다.

    3. 멀티 인스턴스에 유리하다

    API 서버가 여러 대여도 Redis Script 한 곳에서 결과가 결정된다.

    서버별 JVM 락이나 인스턴스 간 락 공유 문제를 고민하지 않아도 된다.

    4. 응답을 빠르게 줄 수 있다

    신청 결과 자체는 Redis에서 즉시 결정할 수 있다.

    DB 저장을 별도 흐름으로 분리하면 사용자 응답 시간도 줄일 수 있다.

    10. Redis Lua Script의 주의점

    Lua Script가 항상 정답은 아니다.

    주의할 점도 분명히 있다.

    1. 스크립트는 짧게 유지해야 한다

    Redis는 기본적으로 단일 스레드 이벤트 루프 기반으로 동작한다.

    Lua Script가 오래 실행되면 그동안 다른 Redis 명령도 기다려야 한다.

    따라서 Lua Script 안에서는 짧고 단순한 연산만 해야 한다.

    좋은 예시는 이런 것이다.

    중복 확인
    카운터 증가
    Hash 저장
    Sorted Set 저장
    String 저장

     

    반대로 이런 작업은 넣으면 안 된다.

    DB 호출
    HTTP 호출
    긴 반복문
    무거운 계산
    외부 시스템 응답 대기

    Lua Script는 Redis 내부 데이터 조작에만 집중시키는 것이 좋다.

    2. DB 트랜잭션과 하나로 묶이지 않는다

    Redis Script가 성공했다고 해서 DB 저장까지 성공한 것은 아니다.

    Redis와 DB는 서로 다른 저장소다.
    하나의 로컬 트랜잭션으로 묶을 수 없다.

    그래서 DB 저장을 함께 사용한다면 재처리와 멱등성을 설계해야 한다.

    Redis 결과 결정 성공
    DB 저장 실패
    재시도
    같은 데이터가 다시 들어와도 중복 저장 방지

    여기서 unique key와 upsert가 중요해진다.

    3. Redis 장애를 고려해야 한다

    Lua Script는 Redis에 의존한다.

    Redis가 내려가면 신규 신청을 처리할 수 없다.

    운영 환경에서는 최소한 아래를 고민해야 한다.

    Redis persistence 설정
    Replica 구성
    Sentinel 또는 Cluster 구성
    장애 시 신규 신청 차단 정책
    복구 후 데이터 정합성 점검

     

    단순한 토이 프로젝트라면 여기까지 전부 구현하지 않아도 된다.
    하지만 운영 서비스라면 "Redis가 실패하면 어떻게 할 것인가"를 반드시 정해야 한다.

    4. 시간 기준을 맞춰야 한다

    마감 시각이나 요청 접수 시각을 다룬다면 시간 기준도 중요하다.

    API 서버가 여러 대인데 각 서버의 시간이 다르면,

    어떤 요청은 마감 전으로 보고 어떤 요청은 마감 후로 볼 수 있다.

    운영 환경에서는 서버 시간을 동기화하거나,
    Redis의 TIME 명령처럼 중앙 기준 시간을 사용하는 방법도 고려할 수 있다.

    11. 네 가지 방식 비교

    정리하면 대략 이렇게 볼 수 있다.

    방식 핵심 아이디어 장점 단점 어울리는 상황
    비관적 락 DB row를 잠근다 이해하기 쉽고 정합성이 강하다 요청이 몰리면 DB 대기 증가 트래픽이 크지 않은 핵심 변경
    낙관적 락 version으로 충돌을 감지한다 락 대기가 없다 충돌이 많으면 재시도 증가 충돌이 가끔 있는 수정
    Redis 분산 락 외부 락으로 임계 구역을 보호한다 여러 서버에서 하나의 락 공유 TTL, 해제, 장애 처리가 필요 기존 로직을 보호해야 할 때
    Redis Lua Script Redis 안에서 검증과 변경을 한 번에 처리한다 빠르고 원자적이다 Redis 의존성과 후속 저장 설계 필요 짧은 원자 연산, 대량 트래픽

    12. 그래서 어떤 방식을 선택해야 할까?

    정답은 상황마다 다르다.

    트래픽이 많지 않고 DB 정합성이 가장 중요하다면 비관적 락이 단순하고 안전하다.

    충돌이 드물고 대부분의 요청이 서로 다른 데이터를 수정한다면 낙관적 락이 잘 맞는다.

    여러 서버에서 특정 작업을 한 번에 하나만 실행해야 한다면 Redis 분산 락을 고려할 수 있다.

    한정 수량 신청처럼 짧은 검증과 카운팅이 핵심이고, 요청이 한 순간에 몰린다면 Redis Lua Script가 좋은 선택지가 될 수 있다.

    내가 기준을 잡는다면 이렇게 볼 것 같다.

    DB 데이터 자체를 안전하게 수정해야 한다
    -> 비관적 락 또는 낙관적 락
    
    여러 서버에서 임계 구역 하나를 보호해야 한다
    -> Redis 분산 락
    
    중복 확인, 수량 차감, 순번 발급을 빠르게 끝내야 한다
    -> Redis Lua Script
    
    Redis 결과를 DB에 남겨야 한다
    -> unique key, upsert, 재처리 설계 추가

    13. 마무리

    동시성 제어는 단순히 락을 거는 문제가 아니다.

    먼저 비즈니스 규칙을 정확히 정해야 한다.

    한 사용자는 몇 번 신청할 수 있는가?
    수량이 다 찬 요청은 어떤 결과로 남길 것인가?
    마감 이후 요청은 어떻게 응답할 것인가?
    성공 순서는 어떤 기준으로 정할 것인가?
    Redis와 DB 결과가 잠시 달라도 되는가?

    이 질문에 대한 답이 정해져야 기술 선택도 자연스러워진다.

    비관적 락, 낙관적 락, Redis 분산 락, Lua Script는 모두 동시성 문제를 해결할 수 있다.


    다만 해결하는 방식과 감당해야 하는 비용이 다르다.

     

    개인적으로 한정 수량 이벤트처럼 요청이 짧은 시간에 몰리고,
    중복 확인과 수량 차감이 핵심인 문제라면 Redis Lua Script가 꽤 잘 맞는다고 생각한다.

     

    단, Redis에서 끝나는 문제가 아니라면 DB 저장, 재처리, 장애 대응까지 함께 설계해야 한다.

    결국 좋은 동시성 제어는 빠른 코드 하나가 아니라, 정책과 저장 구조와 장애 상황까지 같이 맞물려 있는 설계에 가깝다.

    반응형
    프로필사진

    남건욱's 공부기록