[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (1) - 동시성 이슈

들어가기 전에 우아한테크톡의 선착순 이벤트 서버 생존기를 보고 아이디어를 얻었고, 그 과정을 처음부터 부딪히면서 배우기 위해 바닥부터 만들어보면서 느낀점과 배운것들을 정리하기 위

tmd8633.tistory.com

 

이전 글에서 이어집니다.

 

 

 

개요

저번 글에서 동시성이슈를 해결했었습니다. 이번에는 순간적인 대량의 트래픽을 감당하기 위해 TPS를 높혀보겠습니다.

우아한테크톡에서도 보았듯이 Redis를 통해 이 문제를 해결해 보도록 하겠습니다.

 

 

 

 

구조

 

  1. 유저가 이벤트 쿠폰 발행을 요청하면 Redis에서 SortedSet으로 정렬해서 대기열에 넣습니다.
  2. Schedule로 1초간격으로 한정된 유저만큼 쿠폰 발급을 요청합니다.
  3. /api/v3/status 로 발급이 완료되었는지 확인할 수 있습니다.

 

이런 순서로 동작하도록 설계했습니다. 여기서 중요한 것은 많은 트래픽을 감당하기 위해서 대기열에 진입하는 로직에서 무거운 작업을 빼서 최소화 하기로했습니다. 

 

 

CouponConfig

@Configuration
@RequiredArgsConstructor
public class CouponConfig {

    private final JpaCouponRepository jpaCouponRepository;
    public static final int COUPON_COUNT = 200;

    @PostConstruct
    public void init() {
        Coupon saveCoupon = Coupon.builder()
            .name("한정수량 쿠폰")
            .amount(COUPON_COUNT)
            .build();
        jpaCouponRepository.save(saveCoupon);
    }
}

쿠폰 발급갯수를 public static으로 만들어놓았습니다.

 

 

CouponController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v3")
public class CouponControllerV3 {

    private final CouponRedisService couponRedisService;

    @PostMapping("/coupon")
    public ResponseEntity<ResponseCouponStatus> coupon() {
        long userId = Math.abs(new Random().nextLong() % 2000) + 1;
        boolean register = couponRedisService.register(userId);

        return ResponseEntity.ok(new ResponseCouponStatus(register ? "요청" : "마감", userId));
    }

    @GetMapping("/status")
    public ResponseEntity<Boolean> status(Long userId) {
        boolean isIssued = couponRedisService.isIssued(userId);
        return ResponseEntity.ok(isIssued);
    }

}

/api/v3/coupon 은 쿠폰 발급 요청 API

/api/v3/status 는 쿠폰 발급이 완료되었는지를 확인하는 API 입니다.

boolean register는 대기열에 정상적으로 등록되었으면 true, 등록이 되지않았으면(이미 마감된경우) false를 반환합니다.

userId 를 Response에 담아서 보내는 이유는 테스트를 위함입니다. 랜덤으로 userId를 만들었기때문에 랜덤으로 만들어진 userId로 /api/v3/status 로 발급여부를 확인하기 위해 다시 한번 요청을 해야하기때문입니다.

 

CouponRedisService

@Service
@RequiredArgsConstructor
public class CouponRedisService {

    private final CouponRedisRepository couponRedisRepository;

    private static final String WAITING_QUEUE_KEY = "coupon:waiting";     // 대기열
    private static final String ISSUED_SET_KEY = "coupon:issued";   // 처리된 유저 목록
    private boolean isFull = false;

    @PostConstruct
    void init() {
        couponRedisRepository.delete(WAITING_QUEUE_KEY);
        couponRedisRepository.delete(ISSUED_SET_KEY);
    }

    public boolean register(Long userId) {
        if (isFull) return false;
        return addIfAbsent(WAITING_QUEUE_KEY, String.valueOf(userId));
    }

    public List<String> getWaitingUsers() {
        if (isFull) return null;
        return addIssued();
    }

    private Long getSize(String key) {
        return couponRedisRepository.getSize(key);
    }

    private List<String> addIssued() {
        List<String> temp = new ArrayList<>(COUPON_COUNT);

        String userId = popMinIfExists(WAITING_QUEUE_KEY);

        while (userId != null) {
            if (getSize(ISSUED_SET_KEY) >= COUPON_COUNT) {
                isFull = true;
                couponRedisRepository.delete(WAITING_QUEUE_KEY);
                return temp;
            }
            Boolean isAdded = addIfAbsent(ISSUED_SET_KEY, userId);

            if (Boolean.TRUE.equals(isAdded)) {
                temp.add(userId);
            }

            userId = popMinIfExists(WAITING_QUEUE_KEY);
        }
        return temp;
    }

    private String popMinIfExists(String key) {
        return couponRedisRepository.popMinIfExists(key);
    }

    private Boolean addIfAbsent(String key, String userId) {
        return couponRedisRepository.addIfAbsent(key, userId);
    }

    public boolean isIssued(long userId) {
        return couponRedisRepository.existsByUserId(ISSUED_SET_KEY, userId);
    }
}

 

코드를 분리한게 많아서 하나하나 설명하겠습니다.

public 메소드는 register, getWaitingUsers, isIssued 뿐입니다.

register는 Redis로 대기열에 추가하는 메소드,

getWaitingUsers는 Schedule에서 대기열의 유저를 받아오는 메소드로 DB에 이벤트쿠폰을 넣어주는 역할을 합니다.

isIssued는 유저가 발급되었는지 확인하는 메소드로 /api/v3/status 요청에 사용됩니다.

 

COUPON_COUNT 는 import static 된 데이터입니다. CouponConfig에 COUPON_COUNT를 말합니다.

getWaitinUers는 ISSUED_SET_KEY size를 확인하고 최대 남은 발급수량만큼 대기열에서 유저를 pop 해줍니다.

 

 

CouponRedisRepository

@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {

    private final StringRedisTemplate redisTemplate;

    public Long getSize(String key) {
        return redisTemplate.opsForZSet().size(key);
    }

    public void delete(String key) {
        redisTemplate.delete(key);
    }

    public String popMinIfExists(String key) {
        if (getSize(key) < 1) return null;
        var tuple = redisTemplate.opsForZSet().popMin(key);
        return (tuple == null) ? null : tuple.getValue();
    }

    public Boolean addIfAbsent(String key, String userId) {
        return redisTemplate.opsForZSet().addIfAbsent(
            key,
            userId,
            System.currentTimeMillis()
        );
    }

    public boolean existsByUserId(String key, long userId) {
        return redisTemplate.opsForZSet().score(key, String.valueOf(userId)) != null;
    }
}

RedisTemplate를 관리하는 Repository입니다.

 

 

Scheduler

@EnableScheduling
@Configuration
@RequiredArgsConstructor
public class CouponScheduler {

    private final CouponRedisService couponRedisService;
    private final CouponServiceV3 couponServiceV3;

    @Scheduled(fixedDelay = 1000)
    public void processCouponRequests() {

        var waitingUsers = couponRedisService.getWaitingUsers();

        if (waitingUsers == null || waitingUsers.isEmpty()) return;

        couponServiceV3.issueEventCoupons(waitingUsers);
    }
}

대기중인 유저를 가져와 saveAll 하는 메소드입니다. 작업이 1초 이상이 걸릴 수 있기 때문에 fixedDelay를 사용했습니다.

 

CouponServiceV3

@Service
@RequiredArgsConstructor
public class CouponServiceV3 {

    private final JpaCouponRepository jpaCouponRepository;
    private final CouponRepository couponRepository;


    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public boolean issueEventCoupons(List<String> waitingUsers) {
        Coupon coupon = jpaCouponRepository.findByIdWithLock(1L)
            .orElseThrow(() -> new CouponException("쿠폰을 찾을 수 없습니다."));
        if (!coupon.isAvailable()) {
            return false;
        }

        coupon.decreaseAmount(waitingUsers.size());

        List<EventCoupon> saveEventCoupons = waitingUsers
            .stream()
            .map(x -> EventCoupon.builder()
                .userId(Long.valueOf(x))
                .createdAt(LocalDateTime.now())
                .coupon(coupon)
                .build())
            .toList();

        couponRepository.saveEventCoupons(saveEventCoupons);

        return coupon.isAvailable();
    }
}

대기중인 유저들에게 EventCoupon saveAll 하는 역할을 합니다.

 

 

결과

400 Thread, 10초간 요청하도록 설정

환경은 이전과 동일합니다.

 

 

Samples는 2.5배, 평균응답시간(ms) 2.8배, TPS 2.5배 향상이 있었습니다.

 

응답스펙이 Redis가 30 bytes가 높아서 그렇지 스펙을 똑같이 맞춘다면 어마어마한 속도차이가 있습니다.

Samples는 4.4배, 평균응답시간(ms) 4.8배, TPS 4.2배 빨랐습니다.

 

 

결론

우연히 우아한테크톡에서 봤던 영상덕분에 이벤트 쿠폰을 발행하는 과정에서 생길 수 있는 문제 (동시성, 트래픽처리)에 대해서 배울 수 있었던 것같습니다. Lock 에 대해서도 공부가 되었고, Redis가 아직 익숙하지 않지만 더 공부해서 잘 사용하게 될때쯤 글을 적어보려고합니다.  이게 글로는 엄청 짧아보이는데 Redis를 사용해 어떻게 해야 조금 더 빨라 질 수 있을까에 대해서 고민하면서 코드를 지우고 적고 하면서 배운걸 적다보니 결론만 내린 느낌같습니다. 그런데 지금 위에 코드를 보면 변경하고싶은 사항들이 많아보입니다. 대기열에서 발급받은유저목록으로 보내는 과정에서 조금더 최적화 할 수 있을 것같거든요. 하지만 더이상 손대지 않을겁니다. 이번 실기나 이벤트 쿠폰 발급은 메인이 아니거든요. 다음 글 부터는 대기열을 이용해 rank를 반환해주고 다음 과정을 넘어가기 전에 buffer를 만들어볼까합니다.

큐넷

이런 기능처럼요. 아직 해보지 않아서 며칠 걸릴 수 있습니다. 

+ Recent posts