사용자가 쿠폰 발급 요청을 하면 쿠폰 재고 체크 후 재고가 있다면 1씩 감소시키며, 쿠폰 지갑에다가 저장하는 로직을 짜보겠습니다.
<쿠폰 엔티티>
@Entity
@Getter
@Table(name = "tbl_priority_coupon")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Coupon {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "stock", nullable = false)
private int stock;
@Column(name = "title", nullable = false)
private String title;
public Coupon(int stock, String title) {
this.stock = stock;
this.title = title;
}
public void decrease() {
this.stock--;
}
<멤버 엔티티>
@Entity
@Getter
@Table(name = "tbl_member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false)
private String name;
}
<쿠폰 지갑 엔티티>
@Entity
@Getter
@Table(name = "tbl_priority_coupon_wallet")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CouponWallet {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "member_id", updatable = false, nullable = false)
private Long memberId;
@Column(name = "coupon_id", updatable = false, nullable = false)
private Long couponId;
public CouponWallet(Long memberId, Long couponId) {
this.memberId = memberId;
this.couponId = couponId;
}
}
이렇게, 3개의 엔티티가 있고 쿠폰 발급 로직을 간략하게 짜보겠습니다.
@Transactional
public void publish(PublishDto dto) {
final Member member = memberRepository.findById(dto.memberId())
.orElseThrow(() -> new BadRequestException(ErrorCode.FAIL_INVALID_REQUEST));
final Coupon coupon = couponRepository.findById(dto.couponId())
.orElseThrow(() -> new BadRequestException(ErrorCode.FAIL_INVALID_REQUEST));
if (coupon.getStock() <= 0) {
throw new BadRequestException(ErrorCode.FAIL_STOCK);
}
coupon.decrease();
final CouponWallet couponWallet = CouponWallet.save(member.getId(), coupon.getId());
couponWalletRepository.save(couponWallet);
}
해당 서비스 로직은, 회원과 쿠폰 객체를 가져온 뒤 쿠폰의 재고가 없다면, 예외를 던져줍니다.
따라서 이 조건문을 확인하고, 쿠폰의 재고를 1씩 감소시켜 주며 쿠폰 지갑 엔티티에 사용자 id와, 쿠폰 id를 저장해 주는 로직입니다.
이제 재고가 10개인 쿠폰이 있다고 가정하고, 1초 동안에 300명이 동시에 요청을 보내보겠습니다.
<before>
<JMETER 부하테스트 시작>
<결과>
분명히 쿠폰 재고를 체크하고, 저장해 주는 로직입니다.
하지만 재고가 10개인 상황에서, 저장된 것은 24개의 쿠폰이 저장되었습니다.
왜 이런 상황이 발생했을까요?
RACE CONDITION
여러개의 프로세스 혹은 여러 개의 스레드가 동시에 하나의 자원에 접근하여 값을 변경하려고 할 때 발생하는 문제 상황을 말합니다.
어떻게 해결할 수 있을까?
Lock을 이용해 해결하기
MySQL Lock을 이용해 충분히 해결할 수는 있습니다.
1. 쿠폰을 조회합니다.
2. 쿠폰의 재고가 남았는지 확인합니다.
3. 해당 사용자 지갑에 쿠폰을 저장합니다.
결국, 이 세 과정 동안 락을 걸어야 하는데, 만약 이 과정이 N초가 걸린다면 다른 사용자들은 N초 동안 대기해야 합니다.
이로 인해 대용량 트래픽 상황에서는 락을 사용하는 것이 적합하지 않다고 판단했습니다.
쿠폰 대기열 구축
사용자가 쿠폰 발급을 요청하면, I/O 속도가 빠르고 싱글 스레드 방식인 Redis를 대기열로 활용하여 정확한 순서를 보장합니다.
이후 서버는 스케줄러를 통해 대기열에 등록된 사용자 중 10명씩 쿠폰 발급 처리를 진행합니다.
따라서 저는 쿠폰 대기열 구축 방식을 선택했습니다.
레디스의 다양한 자료구조 지원
대기열을 생각했을 때, LIST 혹은 ZSET을 고민했는데 ZSET을 선택하게 되었습니다.
먼저 각 자료구조의 핵심 메서드의 시간복잡도를 확인해 보겠습니다.
LIST
<삽입 및 삭제 연산 >
LPUSH: O(1)
RPUSH: O(1)
LPOP: O(1)
RPOP: O(1)
<특정 인덱스 값 조회>
LINDEX key index: O(N)
ZSET
<삽입 및 수정 연산>
ZADD key score member: O(log N)
<특정 점수 범위의 요소 조회>
ZRANGE key start stop [WITHSCORES]: O(log N + M)
사용자 요청을 저장할 때, 리스트의 삽입 연산을 사용하면 시간 복잡도가 O(1)로 매우 효율적입니다.
하지만 서버 스케줄러 로직에서 10명 단위로 요청을 처리하려는 경우, 현재까지 저장된 인원수에 10을 더해 특정 순서를 찾아야 하는데, Redis 리스트에서 특정 요소의 순서를 찾는 연산의 시간 복잡도가 O(N)이라 성능 상 부적합합니다. 또한, 쿠폰은 1인당 1개만 받을 수 있어야 하는데, 리스트 구조를 사용할 경우 요청이 중복으로 추가될 수 있어 이 요구사항을 만족하지 못합니다.
반면, 정렬된 집합(ZSET)을 사용하면 사용자 요청을 삽입할 때 시간 복잡도는 O(log N)으로 리스트보다는 느리지만 충분히 효율적입니다. ZSET은 특정 요소의 순서를 찾을 때 풀 스캔을 하지 않으며, 중복된 데이터를 허용하지 않으므로 중복 문제를 방지할 수 있습니다. 또한, ZSET의 SCORE에 밀리세컨드 단위의 타임스탬프를 저장해 요청이 들어온 순서대로 정렬할 수 있습니다.
따라서 이러한 요구사항을 만족하기 위해 ZSET을 사용하는 것이 적합하다고 판단했습니다.
1. 1000명의 사용자가 50개의 재고가 있는 쿠폰에 발급 요청 API를 보냅니다.
2. 1000명의 사용자는 밀리초 단위의 타임스탬프를 기반으로 대기열에 순서대로 등록됩니다.
3 서버 스케줄러 로직에서는 대기열에 있는 사용자를 순서대로 10명씩 가져옵니다.
4. 현재 쿠폰 발급 수와 재고를 비교해 재고가 남아 있다면 쿠폰 발급 수를 증가시키고, 해당 사용자에게 쿠폰을 발급해 줍니다.
따라서 이러한 흐름으로 D'art 서비스에 들어가기 전, 실제로 동시성 이슈가 해결되는지 확인하기 위해 사이드프로젝트로 간단하게 로직을 구성하고, JMeter로 부하테스트를 해보겠습니다.
<쿠폰 대기열에 진입하는 함수>
@Transactional
public void registerQueue(PublishDto dto) {
final Coupon coupon = couponRepository.findById(dto.couponId())
.orElseThrow(() -> new BadRequestException(ErrorCode.FAIL_INVALID_REQUEST));
final double registerTime = System.currentTimeMillis();
Random random = new Random();
long randomId = random.nextLong(); //테스트용 회원ID
priorityCouponRedisRepository.addIfAbsentQueue(coupon.getId(), randomId, registerTime, 10000);
}
회원 1000명을 만들어 JMETER로 요청은 보낼 수 없으며, 1명의 회원이 1000번 동시에 요청하더라도 SET을 사용하여 ID값이 중복 저장되지 않으므로, 랜덤 값을 사용하였습니다.
<대기열에서 발행해 주는 스케쥴러 메서드>
@Scheduled(fixedDelay = 1000)
public void publish() {
final LocalDate nowDate = LocalDate.now();
final Optional<Coupon> optionalCoupon = couponRepository.findByStartedAt(nowDate);
if (optionalCoupon.isEmpty()) {
return;
}
final Coupon coupon = optionalCoupon.get();
final int maxCount = coupon.getStock();
final Long couponId = coupon.getId();
final long currentCount = priorityCouponRedisRepository.getCount(coupon.getId());
final Set<Long> membersId = priorityCouponRedisRepository
.rangeQueue(coupon.getId(), currentCount, currentCount + 10);
if (membersId.isEmpty()) {
return;
}
decideEvent(membersId, couponId, maxCount, coupon);
}
private void decideEvent(Set<Long> membersId, Long couponId, int maxCount, Coupon coupon) {
for (Long memberId : membersId) {
final int rank = priorityCouponRedisRepository.rankQueue(couponId, memberId);
if (maxCount <= rank) {
continue;
}
couponWalletRepository.save(CouponWallet.save(memberId, couponId));
}
priorityCouponRedisRepository.increase(coupon.getId(), membersId.size());
}
스케쥴러 로직은, 쿠폰 엔티티에 시작 날짜를 넣어준 다음에, 날짜를 통해 DB에 접근하여 객체를 찾는 방식을 택했습니다.
스케쥴러 로직 특성상 API요청을 받아 응답을 해주는 것이 아닌, 서버 자체에서 동작하기 때문에 DTO를 받기 어려움에 있어서 선택하였습니다. 이후 dicideEvent 메서드를 통해, 재고 체크 후 쿠폰을 발급해 주게 됩니다.
이제 재고가 100인 쿠폰에, 1번만 요청해 보겠습니다.
위에 사진처럼 redis를 이용하여 "coupon : [쿠폰의 id]"을 통해 대기열을 구축했습니다.
또한 "coupon count: [쿠폰의 id]"를 통해 쿠폰 개수의 정합성을 관리해 줍니다.
이제 재고가 30인 쿠폰에서, 1000명의 사용자가 1초 동안에 동시에 요청했을 때, 정확히 재고만큼 발급되는지 확인해 보겠습니다.
<before>
<부하테스트>
<결과: 쿠폰 지갑 엔티티>
이처럼, 레디스를 사용해 쿠폰 개수의 정합성을 관리해 주니 정확한 재고만큼 발급해 주었습니다.
ZSET의 SCORE로 밀리 세컨드 단위의 타임스탬프를 넣어주니, 정확한 순서 또한 보장해 줍니다.
쿠폰 만료 기간 설정
레디스의 쿠폰의 정보가 계속 남아있을 경우, 쿠폰 이벤트가 진행하지 않는 날에도 요청이 갈 수 있습니다.
따라서 쿠폰 만료 기간 설정이 필요했습니다.
저희 D'art의 요구사항 중 하나는 선착순 쿠폰 이벤트는 하루 동안만 진행한다입니다.
따라서, TTL을 (오늘 날짜의 마지막 시간을 타임 밀리초로 변환) - (요청을 받았을 때의 타임 밀리초)을 넣어주어 정확하게 쿠폰 이벤트가 발생하는 날짜까지만 레디스에 남아있도록 했습니다.
final LocalDateTime endDateTime = clockHolder.minusOneDaysAtTime(priorityCoupon.getEndedAt());
final double registerTime = System.currentTimeMillis();
final long expiredTime = Duration.between(nowDateTime, endDateTime).getSeconds();
이렇게 D'art 서비스는 사이드 프로젝트로 부하테스트를 통해 대기열 구축 시스템에 안전성을 얻었고, 이에 따라 개발을 진행했습니다.
또 다른 문제
final Optional<Coupon> optionalCoupon = couponRepository.findByStartedAt(nowDate);
final Coupon coupon = couponRepository.findById(dto.couponId())
.orElseThrow(() -> new BadRequestException(ErrorCode.FAIL_INVALID_REQUEST));
위 코드에서는 1초마다 동작하는 스케줄러 로직이 오늘 날짜에 쿠폰이 있는지 조회하고, 대기열에 등록하는 과정에서도 계속해서 쿠폰을 조회하고 있습니다.
데이터베이스에 1초마다 혹은, 대용량 트래픽으로 인해 순식간에 많은 요청이 계속해서 접근합니다.
이 부분에 대한 해결법은 다음 게시글에서 캐싱을 통해 성능을 개선한 방법으로 소개하겠습니다. 감사합니다 :)