본문 바로가기

Yonsei Golf

쿠폰 발급 동시성 제어

1. 문제 정의

연세대학교 골프동아리 웹사이트는 회원들의 참여 유도를 위해 무료 커피 쿠폰 , 스크린 골프 할인 쿠폰등을 지급하고 있습니다. 이러한 선착순 쿠폰 이벤트의 경우 순간적으로 사용자가 몰려 쿠폰 발급의 동시성을 제어하는 것이 관건이라고 할 수 있습니다.

동시성을 고려하지 않은 채 코드를 작성한 후 어떤 것이 문제가 되는지 살펴보겠습니다.

public void createCoupon(Long couponId, Long userId, Long quantity) {

        Coupon coupon = couponRepository.findById(couponId).orElseThrow();

        coupon.decrease(quantity);

        couponRepository.save(coupon);
        userCouponRepository.save(UserCoupon
                .builder()
                .userId(userId)
                .couponId(couponId)
                .build()
        );
    }

쿠폰의 개수가 100개 이하일 경우에만 쿠폰을 발급하도록 로직을 작성한 후, 동시에 여러 유저가 요청하는 상황을 테스트 하기 위해 멀티스레드 환경에서 테스트 해보도록 하겠습니다.

      @Test
    @DisplayName("쿠폰을 100개 발급받을 수 있다.")
    void multiThreadCouponTest() throws InterruptedException {
        // given
        int threadCount = 1000;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        Long couponId = 1L;

        // when
        for (int i = 0; i < threadCount; i++) {
            long userId = i;
            executorService.submit(() -> {
                try {
                    couponService.createCoupon(couponId, userId, 1L);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        // then
        assertThat(userCouponRepository.countByCouponId(couponId)).isEqualTo(100);
    }

테스트를 실행해보면 실패하게 됩니다.

image

2. synchronized

위 문제를 해결하기 위해 synchronized를 사용하는 방법을 생각할 수 있습니다.

    public synchronized void createCoupon(Long couponId, Long userId, Long quantity) {

        Coupon coupon = couponRepository.findById(couponId).orElseThrow();

        coupon.decrease(quantity);

        couponRepository.save(coupon);
        userCouponRepository.save(UserCoupon
                .builder()
                .userId(userId)
                .couponId(couponId)
                .build()
        );
    }

테스트를 실행해보면 통과하는 것을 확인할 수 있습니다.

image

하지만 synchronized를 사용할 경우 두 개의 문제점이 발생하게 됩니다.

2 - 1 synchronized 문제점 1

Java의 synchronized는 하나의 프로세스 안에서만 보장됩니다.

서비스가 하나의 서버라면 문제가 되지 않지만, 추후에 서버를 확장하게 된다면 문제가 발생할 수 있습니다.

즉 데이터를 여러대에서 접근할 경우 문제가 발생합니다.

이렇게 될 경우, 여러대의 서버에서 하나의 공유 자원에 접근하기 때문에 race condition이 발생하게 됩니다.

2 - 2 synchronized 문제점 2

두 번째 문제는 transactional 어노테이션을 사용했을 때입니다.

    @Transactional
    public synchronized void createCoupon(Long couponId, Long userId, Long quantity) {

        Coupon coupon = couponRepository.findById(couponId).orElseThrow();

        coupon.decrease(quantity);

        couponRepository.save(coupon);
        userCouponRepository.save(UserCoupon
                .builder()
                .userId(userId)
                .couponId(couponId)
                .build()
        );
    }

Transactional 어노테이션을 사용하게 되면 우리가 작성한 클래스를 래핑한 클래스를 만들어서 실행합니다.

간략히 살펴보자면 다음과 같은 클래스가 실행된다고 볼 수 있습니다.

public class TransactionCouponService {

    private CouponService couponService;

        public TransactionCouponSerivce(CouponService couponService) {
            this.couponService = couponService;
        }

        public void createCoupon(Long userId) {
            startTransaction();

            couponService.createCoupon(userId);

            endTransaction();
        }

문제는 여기서 발생합니다.

startTransaction() 이후 쿠폰을 생성하였지만,

endTransaction()이 호출되지 않으면 DB에 반영되지 않습니다.

즉 100번째 쿠폰이 createCoupon()을 호출하였지만 endTransaction()을 호출하지 않은 시점에

101번째 쿠폰이 startTransaction()을 실행하게 된다면 쿠폰은 100개가 아닌 101개 혹은 그 이상 생성될 수 있는 문제점을 가지고 있습니다.

3. MySQL을 통한 해결책

이러한 동시성 문제는 race condition에 의해 발생했다고 볼 수 있습니다.

race condition을 해결하기 위해 MySQL은 세 가지 해결 방법을 제시합니다.

3 - 1 Pessimistic Lock

  • 실제로 데이터에 락을 걸어서 정합성을 맞추는 방법입니다.
  • exclusive lock을 걸게 되면 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없게 됩니다.
    @Transactional
    public void createCoupon(Long couponId, Long userId, Long quantity) {

        Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId);

        coupon.decrease(quantity);

        couponRepository.save(coupon);
        userCouponRepository.save(UserCoupon
                .builder()
                .userId(userId)
                .couponId(couponId)
                .build()
        );
    }
public interface CouponRepository extends JpaRepository<Coupon, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select c from Coupon c where c.id =:id")
    Coupon findByIdWithPessimisticLock(@Param("id") Long id);
}

이렇게 Pessimistic Lock을 사용할 경우 테스트가 통과하게 됩니다.

image

Pessimistic Lock을 사용하는 경우 다음과 같은 플로우로 진행됩니다.

image

3 - 2 Optimistic Lock

  • 실제로 Lock을 사용하지 않고 버전을 이용함으로써 데이터 정합성을 맞추는 방법
  • 먼저 데이터를 읽은 후 update를 진행할 때 내가 읽은 버전이 맞는지 확인하며 업데이트
  • 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은 후에 작업을 수행

image

Optimistic Lock을 사용하기 위해서 @Version을 Coupon 필드에 추가해줍니다.

       @Version
    private Long version;

Optimistic Lock을 사용하기 위해 다음의 메서드를 CouponRepository에 작성해줍니다.

        @Lock(LockModeType.OPTIMISTIC)
    @Query("select c from Coupon c where c.id =:id")
    Coupon findByIdWithOptimisticLock(@Param("id") Long id);

Optimistic Lock은 실패했을 경우 재시도해야 하므로 Facade 클래스를 생성해주도록 하겠습니다.

@Component
public class OptimisticLockCouponFacade {

    private final OptimisticLockCouponService optimisticLockCouponService;

    public OptimisticLockCouponFacade(OptimisticLockCouponService optimisticLockCouponService) {
        this.optimisticLockCouponService = optimisticLockCouponService;
    }

    public void createCoupon(Long couponId, Long userId, Long quantity) throws InterruptedException {
        while (true) {
            try {
                optimisticLockCouponService.createCoupon(couponId, userId, quantity);
                // 정상적인 업데이트 됨
                break;
            } catch (Exception e) {
                                // 실패한 경우 50밀리초 이후에 재시도
                Thread.sleep(50);       
            }
        }
    }
}

테스트를 실행해보면 성공하는 것을 확인할 수 있습니다.

하지만 테스트 케이스의 경우 빈번한 충돌이 발생하기 때문에 성능상 많은 불리함이 있는 것을 확인할 수 있습니다.

4. Redis를 활용한 해결

4 - 1 Redisson

  • pub - sub 기반으로 Lock 구현을 제공해줍니다.

redisson을 활용하기 위해서 redisson 의존성을 추가해줍니다.

implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.25.2'
@Component
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final CouponService couponService;

    @Autowired
    public RedissonLockStockFacade(RedissonClient redissonClient, CouponService couponService) {
        this.redissonClient = redissonClient;
        this.couponService = couponService;
    }

    public void createCoupon(Long couponId, Long userId, Long quantity) {

        RLock lock = redissonClient.getLock(couponId.toString());

        try {
            boolean available = lock.tryLock(20, 1, TimeUnit.SECONDS);

            if (!available) {
                return;
            }

            couponService.createCoupon(couponId, userId, quantity);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

}

위와 같이 RedissonLockFacade 클래스를 생성해줍니다.

어떻게 Redisson은 동시성을 제어할 수 있을까?

Redisson 은 pub / sub 기반으로 다음과 같이 동작합니다.

  1. 락 획득: 프로세스가 락을 획득하려고 시도할 때, Redis의 키를 사용하여 락을 걸고, 이 키에 대한 PUB/SUB 채널을 생성합니다.
  2. 락 대기: 락을 획득하지 못한 프로세스는 PUB/SUB 채널을 구독하고, 락이 해제되었음을 알리는 메시지를 기다립니다.
  3. 락 해제 알림: 락을 보유하고 있는 프로세스가 작업을 완료하고 락을 해제하면, 해당 PUB/SUB 채널을 통해 락 해제 메시지를 발행합니다.
  4. 락 해제 메시지 수신: 대기 중인 다른 프로세스들이 락 해제 메시지를 받으면, 다시 락을 획득하기 위한 시도를 할 수 있습니다.

위와 같은 프로세스로 진행되기에 동시성을 보장하며 쿠폰을 발급받을 수 있습니다.

5. 마무리

동시성을 제어하기 위한 방법으로 여러가지가 있지만, 연세골프 사이트에서는 Redisson을 이용한 동시성 제어를 선택하였습니다.
이에대한 이유로는 다음과 같습니다.

1.쿠폰 발급의 경우 데이터의 충돌이 잦습니다.
데이터 충돌이 잦은 경우 Optimistic Lock은 성능이 떨어질 수 있습니다.
2.인프라 구축 비용
Redisson을 사용하기 위해서 redis server를 추가로 구축해야 합니다. 인프라 비용을 최소한으로 유지하기 위해 Pessimistic Lock을 적용하였습니다.

'Yonsei Golf' 카테고리의 다른 글

Spring 처리율 제한 장치 (Rate Limiter)  (0) 2023.12.30
Email 성능 개선기  (0) 2023.12.09
모니터링 with Docker  (0) 2023.12.09
JWT Token + Refresh Token  (1) 2023.11.27
CloudFront, S3 배포 자동화  (2) 2023.11.26