Post

Distributed Lock 과 Redis를 이용한 선착순 할인쿠폰 시스템

Distributed Lock 과 Redis를 이용한 선착순 할인쿠폰 시스템

1.개요

  • Redisson을 이용한 Distributed Lock의 도입 근거
  • Redis에 재고를 선반영하는 선착순 쿠폰 지급 로직 소개
  • Redis에 재고를 선반영하는 로직의 한계 및 대안 제시


2.배경

  • 회사에서 약 8개월에 걸쳐 여러 선착순 쿠폰 이벤트를 개발해왔습니다.
  • 다소 부끄러운 이야기지만 당시 신입이었던 저는 이벤트 첫 개발 당시 동시성 문제를 알지 못한 채 선착순 로직을 구현했습니다.
    • 그로 인해 이벤트를 처음으로 오픈했을 때 초과발급이 발생하는 심각한 문제가 발생했습니다.
    • 이와는 별개로 당시 MySQL을 싱글 인스턴스로 운영하고 있어 DB가 이벤트 트래픽을 감당하지 못해 서버가 마비되는 문제가 발생했습니다.
  • 위 문제를 해결하고자 Redis, Redisson을 이용한 Distributed Lock을 도입했습니다
  • 여담으로 외래키로 인한 데드락도 발생했습니다.
    • 결과적으로 RDB로 재고를 관리하지 않아 문제는 해결됬습니다. 다만 참고차 글의 최하단에 이유와 해결책을 서술했습니다.


3.Redisson을 이용한 Distributed Lock

1-1)선착순 이벤트 구현에 있어 Critical Section의 필요성

  • 선착순 이벤트는 크게 아래 3가지 단계로 진행됩니다
      1. 발급 가능한 재고가 있는지 확인
      1. 발급 가능할 경우 재고 Record UPDATE (’SET 재고 = 재고 - 발급수량’)
      1. RDB에 유저 별 쿠폰 Record INSERT
  • 위 단계를 실행할 때 재고 데이터는 반드시 Critical Section으로 격리해야 합니다.
    • Critical Section으로 보호되지 않을 경우 동시성 문제가 발생하여 정해진 재고보다 추가 발급 되는 문제가 발생할 수 있습니다.
  • Critical Section으로 재고 데이터를 격리하려면 process 혹은 thread가 동시에 접근할 수 없도록 Lock을 구현해야 합니다.

1-2)MySQL을 이용한 Pessimistic lock 과 Optimistic lock의 단점

  • Lock 은 MySQL등의 RDB에서도 구현할 수 있습니다. 그러나 동시에 여러 process 혹은 thread에서 경합이 발생할 경우 RDB 레벨에서 구현은 몇 가지 단점을 가지고 있습니다.
  • MySQL을 기준으로 설명 드리겠습니다.

(1)MySQL 레벨 Pessimistic lock 의 단점

  • 다른 트랜잭션의 Lock을 고려하지 않고 구현할 경우 deadlock이 발생할 수 있습니다.
  • commit이 완료되어 lock이 풀리기 전까지 다른 Transction이 대기해야 합니다.
    • 특히 Mysql에서 ‘SELECT … FOR UPDATE’로 구현한 경우 pessimistic lock에 필요하지 않은 다른 ROW도 LOCK이 걸릴 수 있습니다 (참고) [*추가내용(25-08) : 구체적으로는 Next-Key Lock이 걸린다. 따라서 적절히 인덱스를 설계하고 사용할 경우 필요한 ROW에만 LOCK을 걸 수 있다.]
      • MySQL의 구조 상 인덱스가 없을 경우 테이블 전체에 lock을 걸고 인덱스가 있을 경우 인덱스에 lock을 걸기 때문입니다.
    • 또한 MySQL의 기본 격리수준인 REPEATABLE READ은 MVCC로 인해 트랜잭션이 길어질 경우 언두 영역에 데이터가 쌓여 데이터베이스에 부담이 될 수 있습니다

(2)MySQL 레벨 Optimistic lock 의 단점

  • version이 다를 때마다 rollback이 일어나므로 선착순 이벤트처럼 경합이 많이 발생하는 로직에서는 부적절합니다.

2)대안 : Redisson을 이용한 Distributed Lock

  • 위 문제점 때문에 MySQL 레벨의 Lock 대신 Redisson을 이용하여 Distributed Lock을 구현하는 방식을 선택했습니다.

Redis, Redisson을 이용하여 Distributed Lock을 구현한 이유

  • Redis를 이용하여 RDB에 가해지는 부하를 분산
  • Redisson은 In-memory DB인 redis를 사용하므로 RDB을 이용한 구현에 비해 Lock을 빠르게 처리 가능
  • Redisson의 Lock은 부분적으로 pub/sub을 사용하므로 redis I/O에 대한 부하를 줄일 수 있음


✅ 참고자료

  • Distributed Lock 이란?
    • 다수의 서버에서 Critical Section 접근할 때 상호 배체를 보장하는 Lock을 의미합니다.
      • 단일 DB를 사용할 경우 앞서 설명한 RDB레벨의 Lock도 Distributed Lock에 해당합니다.
  • Redisson 이란?
    • 자바 및 lua script로 구현된 redis 분산락 클라이언트입니다
    • Distributed Lock 을 구현하기 위해 redis 공식문서에서 제안하는 오픈소스 중 하나입니다.


3)RedissonLock의 로직 상세 (RedissonLock.java)

*아래 설명은 Incheol 님의 글을 요약한 내용입니다 ( 링크 )

1. Lock 획득 시도

          <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
              return evalWriteSyncedAsync(getRawName(), LongCodec.INSTANCE, command,
                      "if ((redis.call('exists', KEYS[1]) == 0) " + 
                          "or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " + 
                          "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + 
                          "redis.call('pexpire', KEYS[1], ARGV[1]); " + 
                          "return nil; " + 
                      "end; " +
                      "return redis.call('pttl', KEYS[1]);",
                      Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
        }
    
2. waitTime이 초과되었는지 확인

      time -= System.currentTimeMillis() - current;
      if (time <= 0) {
          acquireFailed(waitTime, unit, threadId);
          return false;
      }
    
3. 고유 Thread Id를 채널로 구독하여 lock이 available할 때까지 대기

        current = System.currentTimeMillis();
        CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
        try {
            subscribeFuture.get(time, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            acquireFailed(waitTime, unit, threadId);
            return false;
        }
    
4. waitTime 이전까지 무한 루프를 수행하면서 lock 점유 시간을 한번 더 확인

        while (true) {
          long currentTime = System.currentTimeMillis();
          ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
          if (ttl == null) {
              return true;
          }
        }
    


4)Spring AOP를 이용하여 Redisson 실제 적용

  • 비즈니스 로직과 Lock 로직을 분리하기 위해 AOP를 사용했습니다

(1) 어노테이션을 위한 클래스 선언

1
2
3
4
5
6
7
8
9
10
11
12
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 5L;

    long leaseTime() default 3L;
}

(2)AOP 구현

*{25.09} @Order 어노테이션으로 반드시 순서를 명시해줘야 합니다.

  • 명시하지 않을 경우 기본값인 ‘Ordered.LOWEST_PRECEDENCE’이 설정되며 @Transactional 보다 후순위가 되는 문제가 발생합니다.
    • 왜냐하면 @Transactional은 가장 낮은 순위인 ‘Ordered.LOWEST_PRECEDENCE’이 기본값이지만 @Order 값이 동일한 경우 빈 등록 순서에 따라 AOP 적용 순서도 결정되기 때문입니다.
    • 이 경우 불필요하게 커넥션 풀을 미리 사용하게 되며 커밋보다 lock release가 더 빨라 정합성도 깨질 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Aspect
@Order(1)
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {
    private static final String REDISSON_KEY_PREFIX = "RLOCK_";

    private final RedissonClient redissonClient;

    @Around("@annotation(DistributeLock.java의_패키지경로.DistributeLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);

        String key = KEY_PREFIX + Arrays.toString(signature.getParameterNames()) + Arrays.toString(joinPoint.getArgs()) + distributeLock.key();

        RLock rLock = redissonClient.getLock(key);

        try {
            boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit());
            if (!available) {
                throw new RuntimeException();
            }
            return joinPoint.proceed();
        } catch (Exception e) {
            throw new RuntimeException();
        } finally {
            rLock.unlock();
        }
    }
}

(3)AOP 적용 예시

1
2
3
4
5
6
7
8
9
10
11
@DistributeLock(key = "#test")
public void issueCoupon(Long couponId, Long memberId) throws OutOfStockException {

    if (isCouponIssueAble()) { //1.발급 가능한 재고가 있는지 확인
        couponRepository.decreaseStock(couponId);//2.2. 발급 가능할 경우 재고 Record UPDATE
        couponRepository.IssueCoupon(couponId, memberId) //3. RDB에 유저 별 쿠폰 Record INSERT
    }
    else{
        throw new OutOfStockException();
    }
}


4.Redis 재고 변화 선반영 선착순 쿠폰 시스템 구현

1)개요

  • 위 AOP 적용 예시처럼 재고 확인, 재고 UPDATE, RDB에 쿠폰발급 3가지 단계를 atomic operation으로 묶을 수도 있습니다.
  • 그러나 RDB의 INSERT 혹은 UPDATE는 상대적으로 느리므로 3가지 단계를 모두 atomic operation으로 처리하면 모든 응모 Request는 앞선 응모 Request가 순차적으로 처리 완료 될 때까지 기다려야 합니다.
  • 위 문제는 재고 관련 처리만 atomic operation으로 묶어 redis에서 처리하고 RDB 관련 처리를 진행하여 해결할 수 있습니다
    • 1)Distributed Lock을 이용하여 Redis에서 재고조회 및 ’현재 발급된 쿠폰 수’의 증가를 atomic operation으로 묶어 처리하고 (Redis 재고 변화 선반영)
    • 2)1번 단계가 성공했을 때만 RDB에 INSERT만 하는 방식입니다.

2)상세로직

a.재고가 있는 경우

1)레디스관련 atomic operation(Distributed lock 적용)

  • (1)레디스에서 ’현재 발급된 쿠폰 수’ 조회
  • (2)발급가능 여부 판단
  • (3)레디스의 ’현재 발급된 쿠폰 수’ 증가 (INCR)

2)RDB에 쿠폰 Record INSERT

3)응모 성공 Reponse 반환

exhausted


b.재고가 소진된 경우

1)레디스관련 atomic operation(Distributed lock 적용)

  • (1)레디스에서 ’현재 발급된 쿠폰 수’ 조회
  • (2)발급가능 여부 판단

2)재고가 없으므로 재고 소진 Response 반환

exhausted


3)코드 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
@RequiredArgsConstructor
public class FirstComeFirstServedService {
    
    private final DiscountCouponService discountCouponService;

    public boolean couponApply(Long couponId , Long memberId) {

        try{   //재고 관련 처리
            discountCouponService.redisAtomicOperation(couponId);
        }
        catch (OutOfStockException e){
            return false;
        }

        try {  //RDB에 쿠폰 Record INSERT
            discountCouponService.issueCoupon(couponId,memberId); 
        }
        catch (Exception e){ //INSERT 실패 시 DECR
            discountCouponService.couponQuantityDECR(couponId);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Service
@RequiredArgsConstructor
public class DiscountCouponService {
    private final CouponRepository couponRepository;
    private final RedisRepository redisRepository;

    @DistributeLock(key = "#FCFS")
    public void redisAtomicOperation(Long couponId) throws OutOfStockException {
        
        int allowedCouponQuantity = couponRepository.findById(couponId).getCouponQuantity();
        //레디스에서 ’현재 발급된 쿠폰 수’ 조회
        int issuedCouponQuantity = redisRepository.getIssuedCouponQuantity(couponId);

				//발급가능 여부 판단
        if (issuedCouponQuantity >= allowedCouponQuantity) {
            throw new OutOfStockException();
        }
        else{  //레디스의 ’현재 발급된 쿠폰 수’ 증가 (INCR)
            redisRepository.couponQuantityINCR(couponId);
        }
    }
		//RDB에 쿠폰 Record INSERT
    public void issueCoupon(Long couponId , Long memberId){
        couponRepository.issueCoupon(couponId, memberId);
    }

    public void couponQuantityDECR(Long couponId){
        redisRepository.couponQuantityDECR(couponId);
    }
}

4)장점

  • 재고관련 처리를 RDB를 거치지 않고 Redis만 사용하므로 빠른 처리가 가능합니다.
    • 특히 재고가 소진된 시점에 발생한 Request는 아예 RDB를 거치지 않으므로 유저가 빠른 응답을 받을 수 있습니다.
  • 로직의 순서 상 절대 초과발급이 발생하지 않습니다.

5)문제점

  • WAS 혹은 RDB에서 장애가 발생할 경우 Redis의 ’현재 발급된 쿠폰 수’보다 실제 발급된 쿠폰의 수가 적을 수 있습니다.
    • RDB에서만 장애가 발생했을 경우 아래 예시처럼 Redis ’현재 발급된 쿠폰 수’를 차감하여 해결할 수 있습니다.
    • 그러나 서버에서도 장애가 발생한 경우 차감시도가 실패할 수 있습니다
  • 이는 레디스와 RDB의 작업을 단일 트랜잭션으로 묶을 수 없기 때문에 원자성을 지킬 수 없는 것이 근본적인 원인입니다.

failure


6)대안

  • 발급을 비동기로 처리하여 응모와 발급을 분리하면 굳이 Redis에 재고 변화 선반영하지 않아도 안정적으로 트래픽을 처리할 수 있습니다.
    • 우아한 형제들의 예시 (링크)
  • 혹은 Kafka를 활용하여 RDB INSERT을 안정적으로 진행하는 방법도 있습니다.


5.마치며

  • 불과 1년 전만 해도 저는 ‘동시성 문제’라는 키워드 조차 모르는 부족한 개발자였습니다.
  • 그러나 선착순 쿠폰 이벤트 API를 지속적으로 리팩토링하면서 아래와 같은 많은 경험을 얻었습니다.
    • 복잡한 이벤트 로직을 테스트 코드를 통해 견고하게 구현
    • 동시성 문제에 대한 이해 및 해결
    • 인덱스, Lock 등 MySQL의 구체적인 작동 원리
    • 트래픽으로 인한 장애 대응
  • 계속 바뀌는 요구사항, 널뛰는 트래픽에 대응하기 위해 어떻게 견고한 소프트웨어를 구현할지 매일 고민하고 있습니다. 그러나 이런 고민을 해결하는 과정이 전혀 괴롭지 않고 오히려 즐겁습니다. 즐거움을 원동력 삼아 앞으로도 열심히 정진하고자 합니다.


6.외래키로 인한 데드락 발생과 해결 방법

문제 상황 및 원인

  • 아래 방식으로 구현했습니다.
    • ‘쿠폰 재고’ 테이블을 부모 테이블로, ‘쿠폰 발급’ 테이블을 자식 테이블로 두었습니다.
    • (1)재고를 조회하고 (2)재고가 있을 경우 ‘쿠폰 발급’ 테이블에 INSERT 후 (3)’쿠폰 재고’ 테이블을 UPDATE 하도록 구현했습니다.
  • 위 순서로 구현할 경우 아래의 문제로 데드락이 발생합니다.
    • ‘쿠폰 발급’ 테이블에 INSERT하는 시점에 ‘쿠폰 재고’ 테이블에 S-LOCK이 걸립니다.
    • ‘쿠폰 재고’ 테이블을 UPDATE하는 시점에 ‘쿠폰 재고’ 테이블에 X-LOCK이 걸립니다.
    • 만약 2개 이상의 트랜잭션이 동시에 INSERT를 하여 ‘쿠폰 재고’ 테이블에 S-LOCK을 획득한 경우 X-LOCK을 걸기 위해 서로 상대의 S-LOCK이 해제되기를 기다립니다. 이로 인해 데드락이 발생합니다.

테이블 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
create table COUPON
(
    id    bigint auto_increment primary key,
    name  varchar(50) not null comment '쿠폰명',
    stock int         not null comment '재고'
)COMMENT='쿠폰 재고';
create table COUPON_USER
(
    id        bigint auto_increment primary key,
    coupon_id int not null comment '쿠폰id',
    user_id   int not null comment '회원id',
    FOREIGN KEY (coupon_id) REFERENCES COUPON (id)
)COMMENT='쿠폰 발급';

트랜잭션 예시

1
2
3
4
5
6
7
8
9
10
11
12
START TRANSACTION ;

# (1) 재고 조회 (이후 application에서 발급 가능한지 검증)
SELECT * FROM COUPON;

# (2) COUPON_USER INSERT
INSERT INTO COUPON_USER (coupon_id, user_id) VALUES (1, 1001);

# (3) COUPON UPDATE
UPDATE COUPON SET stock = stock + 1 ;

COMMIT ;

해결 방법

  • 순서를 바꾸어 UPDATE를 먼저 실행하고 INSERT를 하면 됩니다. 이 경우 X-LOCK이 먼저 걸리므로 데드락이 발생하지 않습니다. 단 동시성 문제는 이와 별개입니다.
1
2
3
4
5
6
7
8
9
10
11
12
START TRANSACTION ;

# (1)재고 조회 (이후 application에서 발급 가능한지 검증)
SELECT * FROM COUPON;

# (2) COUPON UPDATE
UPDATE COUPON SET stock = stock + 1 ;

# (3) COUPON_USER INSERT
INSERT INTO COUPON_USER (coupon_id, user_id) VALUES (1, 1001);

COMMIT ;


7.참고한 자료

This post is licensed under CC BY 4.0 by the author.