새소식

Java

[Spring] 선착순 이벤트를 위한 모든 것 with Redis

  • -
728x90
선착순 쿠폰 발급 시스템을 진행하며 다뤘던 내용들이 너무나도 유용했고 재밌었기에 이를 공유하고 싶다는 욕심이 생겨 이 글을 작성한다.

 

들어가기 앞서, 간만에 글을 작성하려고 하니 너무나도 어색하면서도 설렘이 공존합니다. 제가 개발한 것의 과정에 대한 기록과 회고, 그리고 공유의 소중함을 느끼고 다시 한번 꾸준하게 포스팅하는 습관을 길러보려고 합니다!

이전에 고모가 임영웅님의 팬이여서 티켓팅을 몇번 도와드렸던 경험이 있는데 하늘에서 별 따기 수준의 난이도였습니다. 3일의 일정에 100만명이 넘는 사람들이 몰리는 것을 보며 수강신청은 껌이였나 ? 를 느끼기도 했습니다. 이러한 과정을 수차례 겪으며 대체 어떤 사람이 이것에 성공하는 거지 ? 라는 물음에서 아래와 같은 물음이 생겨나기 시작했습니다 ..

 100만명이 넘게 몰리는 트래픽을 어떻게 감당할 수 있으며 코드는 어떻게 구현되어 있을까 ?
대체 어떻게 설계해야 안정적인 처리가 가능하지 ?

이러한 궁금증이 너무 커져서 "내가 직접하면서 파악해볼까?" 라는 생각과 함께 제가 직접 구현해보기로 결심하며 이번 프로젝트를 진행하게 되었습니다! 

 

그래서 뭐부터 해야되지 ?

 

프로젝트 실행을 맘 먹었지만 당장 무엇부터 해야 할까 싶은 생각에 기가 죽기 시작합니다.. 그렇기에 작은 기능부터 구현하고 확장시켜가는 여정을 떠나기로 하며 프로토타입부터 만들고 추후에 기능을 확장시키는 방식으로 채택합니다! (개인 프로젝트니까 내 맘대로 〰️)

 

제가 잡은 요구사항은 아래와 같습니다!

  • 이벤트 기간내에 발급
  • 선착순 이벤트는 유저당 1번의 쿠폰 발급
  • 선착순 쿠폰의 최대 쿠폰 발급 수량 설정

선착순 이벤트에 재미들린 저는 여러 개의 이벤트를 경험해봤습니다. 이러한 이벤트들의 공통적인 선착순 이벤트의 특성들을 고려해보며,  쿠폰 발급 기간과 수량에 대한 검증, 검증이된 사용자에 대한 쿠폰을 발급하며 발급 수량을 증가시키는 식으로 진행해보자! 라는 큰 틀을 짜게 되었습니다.

 

📚 코드를 작성하며 세부적인 틀을 잡아보자!

우선 선착순 쿠폰 발급 서비스에 맞게 사용자들이 발급할 쿠폰에 대한 정책발급 내역에 대한 정보가 필요합니다. 이를 모델링한 엔티티는 아래와 같습니다.

 

Coupon

@Getter
@Entity
@Table(name = "coupon")
public class Coupon extends  AuditingFields {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false) private CouponType couponType;
    @Column(nullable = false) private String title;

    private Integer totalQuantity;

    @Column(nullable = false) private int issuedQuantity;
    @Column(nullable = false) private int discountAmount;
    @Column(nullable = false) private int minAvailableAmount;
    @Column(nullable = false) LocalDateTime dateIssueStart;
    @Column(nullable = false) LocalDateTime dateIssueEnd;
}

..
..

public enum CouponType {
    FIRST_COME_FIRST_SERVED
}

 

CouponIssue

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "coupon_issues")
public class CouponIssue extends AuditingFields {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false) private Long couponId;
    @Column(nullable = false) private Long userId;

    @CreatedDate
    @Column(nullable = false) private LocalDateTime dateIssued;

    private LocalDateTime dateUsed;
}

 

쿠폰 정책과 쿠폰 발급 내역에 공통으로 생성일과 수정일 데이터가 필요하다고 판단하여, 자동으로 컬럼에 추가되게 AuditingFields 를 생성하고 이를 확장해서 사용합니다.

쿠폰 타입은 FIRST_COME_FIRST_SERVED 만 추가해 두었고, 나중에 필요하다면 추가할 생각입니다. 이제는 서비스 코드를 작성해봅시다!

 

Coupon, 검증과 발급 기능

// [쿠폰 발급 가능 여부 검증 1], 수량
public boolean availableIssueQuantity() {
    return totalQuantity > issuedQuantity;
}

// [쿠폰 발급 가능 여부 검증 2], 기한
public boolean availableIssueDate() {
    LocalDateTime now = LocalDateTime.now();
    return dateIssueStart.isBefore(now) && dateIssueEnd.isAfter(now);
}

public boolean isIssueComplete() {
    LocalDateTime now = LocalDateTime.now();
    return dateIssueEnd.isBefore(now) || !availableIssueQuantity();
}

// 쿠폰 발급
public void issue() {
    if (!availableIssueQuantity()) {
        throw new CouponIssueException(INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초과합니다. total : %s, issued: %s".formatted(totalQuantity, issuedQuantity));
    }
    if (!availableIssueDate()) {
        throw new CouponIssueException(INVALID_COUPON_ISSUE_DATE, "발급 가능한 일자가 아닙니다. request : %s, issueStart: %s, issueEnd: %s".formatted(LocalDateTime.now(), dateIssueStart, dateIssueEnd));
    }

    issuedQuantity++;
}

 

쿠폰이 발급 수량과 기간에 대한 검증 로직은 쿠폰 엔티티 내부에 추가해 두었습니다. 이러한 검증은 엔티티라는 개념 자체에 강하게 종속된 개념이며 이를 내부에 배치하여 응집도를 높이고 도메인이 비즈니스 로직의 주도권을 가지게 하고 싶다는 생각으로 위와 같은 코드를 작성했습니다.

 

CouponIssueService

@Service
 @RequiredArgsConstructor
 @Transactional
 public class CouponIssueService {
 
     private final CouponJpaRepository couponJpaRepository;
     private final CouponIssueRepository couponIssueRepository;
     private final CouponIssueJpaRepository couponIssueJpaRepository;
 
     public void issue(long couponId, long userId) {
         Coupon coupon = findCoupon(couponId);
         coupon.issue();
         saveCouponIssue(couponId, userId);
     }
 
     @Transactional(readOnly = true)
     public Coupon findCoupon(long couponId) {
         return couponJpaRepository.findById(couponId).orElseThrow(() -> {
            throw new CouponIssueException(ErrorCode.COUPON_NOT_EXIST, "쿠폰 정책이 존재하지 않습니다. %s".formatted(couponId));
         });
     }
 
     public CouponIssue saveCouponIssue(long couponId, long userId) {
         checkAlreadyIssuance(couponId, userId);
         CouponIssue issue = CouponIssue.builder()
                 .couponId(couponId)
                 .userId(userId)
                 .build();
 
         return couponIssueJpaRepository.save(issue);
     }
 
     public void checkAlreadyIssuance(long couponId, long userId) {
         CouponIssue issue = couponIssueRepository.findFirstCouponIssue(couponId, userId);
 
         if(issue != null) {
             throw new CouponIssueException(DUPLICATED_COUPON_ISSUE, "이미 발급된 쿠폰입니다. user_id : %s, coupon_id %s".formatted(userId, couponId));
         }
     }
 }

 

엔티티 내부에서 수량과 기한에 대한 검증까지 처리했으면, 다음으로 중복 발급에 대한 검증을 해주어야 합니다. 중복 발급 검증까지 마쳤으면 발급 내역에 저장해줍니다!

 

이제 기능을 모두 구현했다면 이를 성능 테스트를 해봐야 합니다! 대량의 트래픽에서도 정상적으로 쿠폰이 발급되는지, 또한 성능은 괜찮은지를 검증해야 합니다. 테스트는 Locust 를 통해 진행했으며 간단한 사용법에 대해선 포스팅한 글이 있으니 참고해 주세요!

 

[Spring] 성능 테스트 ? Locust로! with Docker

선착순 쿠폰 서비스를 기획, 구현 중이다. 하지만 혼자서 접속을 해서 동시 접속자의 상황을 재연하기에는 무리가 있다. 마우스를 잡은 손은 하나니까! 그렇기에 고민하던 중, Locust 에 대해 알게

seung-seok.tistory.com

 

 

Docker 를 통해 Locust 환경을 구성하였고, 쿠폰 정책에 대한 정보를 미리 기입해두며 발급 수량은 500으로 지정했습니다. 시작은 가볍게 ?100초동안 10000명의 사용자의 요청을 받기로 설정했습니다.

  • Locust`s Number of users : 10000
  • Locust`s Ramp up : 100
"당연히 정상적으로 발급되겠지?" 를 기대하며 테스트 버튼을 누르는 순간 

떴냐 ?

 


급격히 떨어지는 RPS

 

아래와 같이 발급 수량을 푸는 시점에 RPS 가 급격히 떨어지며, 데이터베이스에도 과부하가 걸리기 시작했습니다.. 또한 예상한 발급 수량인 500보다 더 많은 수량의 쿠폰이 발급되었습니다. 즉, 동시성 이슈가 발생했습니다.

 

요약하면 성능도 좋지 않고 예기치 못한 추가 수량이 발생한 최악의 상황이 발생했습니다. 대체 왜 이런 일이 발생한걸까요 ? 

 

🟢 문제를 파악하며

성능에 대한 문제부터 파악해보겠습니다!

 

 

지금의 구조는 사용자의 요청을 단일 API 서버에서 처리하며, 단일 데이터베이스를 통해 동기적으로 처리하고 있는 구조입니다. 이러한 구조에서 사용자의 트래픽이 증가한다면 우리는 Scale Out 을 고려해 볼 수 있습니다.

 

 

하지만 API 서버를 확장한다고 처리량이 늘어날 수 있을까요 ? 단일 데이터베이스를 사용한다면 이는 서버를 확장한다고 해서 부하를 해소할 수 없습니다. 이러한 경우에는 캐싱, 데이터베이스 서버 확장 또는 샤딩 등을 고려할 수 있습니다.

 

저는 애플리케이션 서버와 데이터베이스를 확장하는 방법 중, 애플리케이션 서버를 확장하는 방법(Scale Out)이 관리하기에 더 용이하다고 판단했습니다. 그렇기에 데이터베이스인 MySQL 의 병목이 되는 Point 를 개선해 볼 것입니다.

 

그 전에! 동시성 문제의 원인부터 파악을 해보겠습니다.

 

🟢 동시성 ? 그게 뭔데 

정상 케이스

 

제가 생각했던 쿠폰 발급 케이스는 위와 같습니다.  발급된 쿠폰 수량을 확인하고 발급하면서 발급 수량을 증가시키는 상황입니다. 이러한 상황을 기대하며 테스트를 했지만 동시성 문제가 발생했습니다. 대체 어떠한 상황에 동시성 이슈가 발생하는 것일까요 ? 

 

동시성 이슈가 발생하는 케이스

 

동시에 쿠폰이 발급된 상황에서 발급된 쿠폰 수량을 확인했을 때, 양측 다 0 으로 확인하고 이를 1 증가시키며 결과적으로 issuedQuantity 가 1 이 되는 상황이 발생할 수 있습니다. 이러한 상황을 해결하기 위해서는 Lock 을 사용하여 다른 사용자의 접근을 막아줘야 합니다. 그렇다면 Lock 을 사용한 케이스에 대해 설명해 보겠습니다!

 

 

위의 과정은 [1. 발급된 쿠폰 수량 확인] 과 [2. 쿠폰 발급] 의 과정을 Lock 으로 지정하여 여러 사용자(스레드)의 접근을 막아주는 과정을 고려했습니다.

 

public void issue(long couponId, long userId) {
    synchronized (this) {
        Coupon coupon = findCoupon(couponId);
        coupon.issue();
        saveCouponIssue(couponId, userId);
    }
}

 

synchronized 를 사용하여 여러 스레드가 하나의 공유 자원에 동시에 접근하지 못하게 지정하는 방법을 채택했습니다.
이제 또 한번의 최적화를 이뤄냈으니? 뭔가 해결했을 것 같다는 희망찬 생각으로 Locust 를 실행해보지만 

 

500개만 나와야 하는데 ?

 

예상했던 500개의 쿠폰이 발급된게 아니라 1325개의 쿠폰이 발급되었습니다 .. 대체 왜 또 이런 일이 발생했을까요 ? 상황을 분석해 봅시다.

 

user A 가 쿠폰 발급 로직을 진행하고 있을 때 user B 가 발급을 신청한 경우

 

User A 가 Lock 을 획득하고 쿠폰을 발급하고 있는 서비스 로직을 실행하고 있으므로 User B 는 Lock 이 걸려있어서 대기합니다.

 

 

Lock 을 반납하고 Transaction Commit 을 하기 직전에 User B 는 Lock 을 획득하는 상황이 발생합니다. 그 순간에 쿠폰 수량을 확인했을 때는 아직 User A 의 내역이 Commit 되지 않았기 때문에 issuedQuantity 는 동일하게 0 이며, 그로 인해 500건 보다 많은 쿠폰이 발급됩니다. 

 

위와 같은 케이스 때문에, Trasaction 내부에 Lock 을 거는 행위는 주의해야 합니다.

 

 

Transaction 이전에 Lock 을 반영

 

CouponIssueRequestService

public void issueRequestV1(CouponIssueRequestDto requestDto) {
    synchronized (this) {
        couponIssueService.issue(requestDto.couponId(), requestDto.userId());
    }
    log.info("쿠폰 발급 완료. couponId: %s, userId : %s".formatted(requestDto.couponId(), requestDto.userId()));
}

..
..

[CouponIssueService]

public void issue(long couponId, long userId) {
    Coupon coupon = findCoupon(couponId);
    coupon.issue();
    saveCouponIssue(couponId, userId);
}

 

위의 케이스와는 반대로 CouponIssueRequestService에서 issue() 메서드 상위 레벨에서 Lock 을 지정해주었습니다! 수정된 코드로 다시 부하테스트를 진행해보면 

 

[Locust] 안정적인 RPS

 

드디어 RPS 와 DB 의 CPU 또한 안정적인 모습을 보이며, 쿠폰 발급 수도 정확히 500개 임을 확인할 수 있었습니다! 제가 원했던 선착순 기능을 모두 구현한 순간이었습니다.

 

 

하지만 이게 최선일까 ?

 

 

성능이 보다 안정적이지만 100만명 그 이상의 트래픽에서도 이러한 성능을 낼 수 있을까 ? 라는 생각과 동시에 여러가지 극단적인 상황에서 고려해야 할 생각들이 들었습니다. 이제부터 이를 최적화하며, 여러가지 이슈들에 대해 다뤄보겠습니다.

 

🟢 Scale Out 에서의 동시성 이슈

synchronized 를 사용하여 동시성 이슈를 해결했습니다! 하지만 사용자의 트래픽이 증가할 경우, 우린 Scale Out 을 사용하여 Application 의 병목을 해결하기로 했습니다. 하지만 이 상황에서도 동시성 이슈로부터 안전할까요 ?

 

synchronized 가 Java 애플리케이션에 종속이 되기 때문에 여러 서버로 확장되는 순간, Lock 을 제대로 관리하지 못하는 이슈가 생길 수 있습니다. 즉, 분산 시스템에서 동시성 문제가 발생할 수 있습니다. 이러한 문제를 해결하기 위해 다양한 동시성 제어 기법이 사용되는데, 제가 찾은 대안은 바로 분산락입니다.

 

분산 락을 사용하면 여러 노드가 동시에 동일한 자원에 접근하는 것을 방지할 수 있습니다. 이러한 분산락을 구현하는데 매우 유용한 도구인 Redis 를 사용합니다. (Redis 는 단일 스레드로 동작하기 때문에, 락을 구현하는데 매우 적합합니다.)

MySQL 의 사용도 고려했지만 Lock 이 걸려있는지 지속적으로 확인해야하고 이러한 session 에 대한 비용이 발생합니다. Redis 는 분산형 메모리 내 데이터 저장소로 사용할 수 있기에 디스크 기반의 데이터베이스보다 빠른 응답이 가능할 거라고 판단했습니다.

 

🟢 Redis 를 사용한 분산락 구현기

이제는 Redis 를 사용해 분산락을 구현해 보겠습니다! Redisson 과 Lettuce 를 사용할 수 있는데 어떤 특성이 있는지부터 알아보겠습니다.

 

Lettuce 는 비동기/반응형 Redis 클라이언트입니다. 동기(Sync), 비동기(Async), 반응형(Reactive) API 모두 제공하며 하나의 커넥션으로 여러 스레드가 동시에 작업이 가능(Thread-safe)합니다.
하지만 Redission 과는 다르게 직접 분산락을 구현해야 하며, 락을 획득하는 과정에서 스핀 락 방식을 사용해서 대규모 사용자의 유입 시에는 위험할 수 있다고 판단했습니다.

 

스핀락(Spin Lock) 은 락을 획득할 때까지 계속해서 요청을 보내는 방식을 의미합니다. 

 

RLock 라이브러리

boolean tryLock(long waitTime, long leaseTime, TimeUnit timeUnit) throws InterruptedException;

 

이 설정에 따라 락 획득을 요청했을 때

1. 락을 획득할 수 없다면 waitTime 만큼 기다리게 됩니다.

2. 락을 획득했다면, 최대 leaseTime 만큼 락을 점유합니다.

 

이러한 방법을 통해서 하나의 공유 자원에 대한 경쟁 상황에서 데이터에 접근할 때, 데이터의 결함이 발생하지 않도록 원자성을 보장하게 했습니다. 이제 코드로 구현해 봐야겠죠 ? 

 

🟢 Redisson 클라이언트를 사용한 분산락 

@RequiredArgsConstructor
@Component
public class DistributeLockExecutor {
    private final RedissonClient redissonClient;
    private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName());

    public void execute(String lockName, long waitMilliSecond, long leaseMilliSecond, Runnable logic) {
        RLock lock = redissonClient.getLock(lockName);

        try {
            // tryLock을 사용하여 지정된 시간 동안 락을 시도.
            // waitMilliSecond 동안 락을 획득하지 못하면 false를 반환.
            boolean isLocked = lock.tryLock(waitMilliSecond, leaseMilliSecond, TimeUnit.MILLISECONDS);

            // 동시성 이슈 방지
            if(!isLocked) {
                throw new IllegalStateException("[" + lockName + "] lock 획득 실패");
            }

            logic.run();
        } catch (InterruptedException e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException(e);
        } finally {
            // 현재 스레드가 락을 보유하고 있다면 락을 해제.
            if(lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

 

이 코드를 기반으로 CouponIssueRequestService 코드를 작성해 주었습니다.

 

CouponIssueRequestService

@RequiredArgsConstructor
@Service
public class CouponIssueRequestService {
    private final CouponIssueService couponIssueService;
    private final DistributeLockExecutor distributeLockExecutor;

    private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName());

    public void issueRequestV1(CouponIssueRequestDto requestDto) {
        distributeLockExecutor.execute ("lock_" + requestDto.couponId(), 10000, 10000, ()-> {
            couponIssueService.issue(requestDto.couponId(), requestDto.userId());
        });

        log.info("쿠폰 발급 완료. couponId: %s, userId : %s".formatted(requestDto.couponId(), requestDto.userId()));
    }
}

 

이제는 락을 획득한 스레드에서만 couponIssueService.issue() 메서드를 실행할 수 있게 되었습니다!

 

이게 전부가 아닙니다!

 

이제 하나씩 설명드리겠습니다.

 

public void execute(String lockName, long waitMilliSecond, long leaseMilliSecond, Runnable logic) {
    RLock lock = redissonClient.getLock(lockName);

    try {
        // tryLock을 사용하여 지정된 시간 동안 락을 시도.
        // waitMilliSecond 동안 락을 획득하지 못하면 false를 반환.
        boolean isLocked = lock.tryLock(waitMilliSecond, leaseMilliSecond, TimeUnit.MILLISECONDS);

        // 동시성 이슈 방지
        if(!isLocked) {
            throw new IllegalStateException("[" + lockName + "] lock 획득 실패");
        }

        logic.run();
    } catch (InterruptedException e) {
        log.error(e.getMessage(), e);
        throw new RuntimeException(e);
    } finally {
        // 현재 스레드가 락을 보유하고 있다면 락을 해제.
        if(lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

 

위의 코드에서 logic.run() 이 끝나면 finally 에서 락을 해제합니다. 만약 트랜잭션 Commit 전에 락이 풀리고 다른 쓰레드가 들어와서 같은 쿠폰을 발급하려고 하면 문제가 발생할 수 있습니다.
그렇기에 쿠폰을 조회하고 수량을 검증하는 findCoupon() 에도 락을 구현해주어야 합니다.

 

저는 중첩해서 걸 수 없는 X-Lock 을 사용할 것입니다. X-Lock은 주로 중복 업데이트 방지 또는 수정 충돌 방지에 사용합니다.
따라서 X-Lock 이 해제될 때까지 SELECT 를 포함해서 자원에 접근이 불가능하게 만들어 줍니다. 

 

public interface CouponJpaRepository extends JpaRepository<Coupon, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM Coupon c WHERE c.id = :id")
    Optional<Coupon> findCouponWithLock(long id);
}

 

코드 레벨에서 LockModeType.PESSIMISTIC_WRITE 를 통해서 X-Lock 을 지정해 줄 수 있습니다. 이를 기반으로 리팩토링을 진행하겠습니다.

 

public void issue(long couponId, long userId) {
    Coupon coupon = findCouponWithLock(couponId);
    coupon.issue();
    saveCouponIssue(couponId, userId);
}

public Coupon findCouponWithLock(long couponId) {
    return couponJpaRepository.findCouponWithLock(couponId).orElseThrow(() -> {
        throw new CouponIssueException(ErrorCode.COUPON_NOT_EXIST, "쿠폰 정책이 존재하지 않습니다. %s".formatted(couponId));
    });
}

 

💡 Redis 와 MySQL 두개의 분산락을 사용하면 성능이 더 안 좋은 것이 아닌가요 ? 

높은 트래픽 환경에서 MySQL의 X-Lock 만을 사용한다면 다른 스레드는 락 대기 시간이 길어질 수 있습니다. 데이터베이스는 wait timeout, 데드락이 발생할 가능성이 존재하며, 데이터베이서 커넥션 풀이 잠식될 수 있습니다. 즉, 높은 트래픽 환경에서 데이터베이스 락 경합이 심각한 병목 포인트가 될 수 있습니다.

 

이러할 때 데이터베이스까지 트래픽이 가지 않게 Redis 선행 락을 걸어서 걸러주는 용도로 사용할 수 있습니다.

예를 들어, 수십만개의 동시 요청이 들어왔을 때, Redis 에서 소수의 락을 획득하고 MySQL 은 그 소수만 처리하는 방식을 채택한다면 데이터베이스를 보호하며 동시에 락 경합 비용을 줄일 수 있으며, 락 획득 시도 및 실패를 빠르게 반환할 수 있습니다. (네트워크 Round Trip 만 소요)

 

즉, DB 락 + Redis 락을 조합하여 빠르게 경합을 걸러낼 수 있으며 실제 데이터베이스까지 도달하는 트래픽을 대폭 줄일 수 있습니다!


 

이렇게 성능을 고려하며, 동시성 이슈로 부터 안전한 선착순 이벤트를 구현했습니다! 이에 대한 최적화를 추가로 진행하려고 하는데 글이 너무나도 길어져서 다음 편에 추가로 작성하겠습니다.

 

여기까지 읽어주셔서 감사하고 피드백이나 조언, 질의를 남겨주시면 감사하겠습니다 : ) 

728x90
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.