[쿠폰] Redis 기반 비동기 시스템 구현

2024. 4. 21. 20:13프로젝트

 

이전 포스팅에서 이어지는 내용입니다.

https://matt1235.tistory.com/73

 

Redis를 사용해 쿠폰 발급을 요청하는 로직을 비동기로 구현했습니다.

 

시스템 구조는 다음과 같습니다.

시스템 구조

 

동작 방식

  • 유저의 요청을 API 서버에서 받아서 처리하고, 쿠폰 발급 요청을 Redis 에 던집니다.
  • 레디스에서 쿠폰 발급 요청을 처리할 때 Set에 쿠폰 발급 내역을 저장하고, List로 된 대기열 큐에 쿠폰을 집어넣습니다.

비동기적 방식으로 쿠폰 발급을 처리하며, 레디스를 통한 많은 트래픽 대응이 가능하도록 설계했습니다.

Redis 에서 사용한 자료구조는 Set, List 입니다.

 

 

시나리오

1. 유저의 쿠폰 발급 요청이 들어온다. (coupon_id, user_id)

 

2. 발급 수량 조회 및 검증 

  • 지정된 쿠폰의 갯수(ex.1000) > 발급된 쿠폰 갯수(ex. 200) 라면, 통과.

3. 중복 발급 검증

  • 한 명의 유저 당, 하나의 쿠폰만 발급할 수 있다. (중복 발급 불가)

4. 쿠폰 발급 로직 수행

  • 쿠폰 엔티티 캐시 (String)
  • 대기열 큐에 쿠폰을 집어넣음 (List)
  • 쿠폰 발급 내역을 저장 (Set)

 

 

Redis - Set

레디스의 Set은 중복을 허용하지 않으므로, 유저가 쿠폰을 이미 발급했는지 검사하는데 용이합니다.

 

쿠폰 발급 요청이 들어올 때 (user_id, coupon_id)가 들어오게 되는데, 이에 대한 중복 여부를 확인할 수 있습니다.

해당 유저가 이미 쿠폰을 발급했다면, 쿠폰을 발급할 수 없습니다.

 

Set 연산

  • SISMEMBER : 쿠폰 중복 발급 요청 여부를 확인! O(1)
  • SADD : Set에 멤버를 추가한다. O(1)
  • SCARD : Set의 사이즈를 반환한다. O(1)

모두 상수 시간 복잡도로, 빠른 성능을 보여줍니다. 

 

 

쿠폰 발급 검증 로직 

@Service
@RequiredArgsConstructor
public class RedisCouponIssueService {

    private final RedisRepository redisRepository;

    public void verifyCouponIssuance(CouponRedisEntity coupon, long userId) {
        if (!hasSufficientQuantity(coupon.totalQuantity(), coupon.id())) {
            throw new CouponIssueException(ErrorCode.INVALID_COUPON_QUANTITY, "발급 가능한 수량을 초과합니다. couponId : %s, userId : %s".formatted(coupon.id(), userId));
        }
        if (!isCouponAvailableForUser(coupon.id(), userId)) {
            throw new ConflictException("해당 유저에게 이미 발급된 쿠폰입니다.");
        }
    }

    public boolean hasSufficientQuantity(Integer totalQuantity, long couponId) {
        if (totalQuantity == null) {
            return true;
        }
        String key = CouponRedisUtils.getIssueRequestKey(couponId);
        return totalQuantity > redisRepository.sCard(key);
    }

    public boolean isCouponAvailableForUser(long couponId, long userId) {
        String key = CouponRedisUtils.getIssueRequestKey(couponId);
        return !redisRepository.sIsMember(key, String.valueOf(userId));
    }
}
@Repository
@RequiredArgsConstructor
public class RedisRepository {

    public Long sCard(String key) {
        return redisTemplate.opsForSet().size(key);
    }

    public Boolean sIsMember(String key, String value) {
        return redisTemplate.opsForSet().isMember(key, value);
    }
    
}

 

 

RedisRepository의 sCard 메서드를 통해 set의 크기를 구하는데, 이는 발급된 쿠폰 내역의 개수입니다.

발급된 내역의 수가 지정한 쿠폰 개수보다 적어야만, 발급을 허락합니다.

 

sIsMember()는 SISMEMBER 연산을 통해 해당 유저(user_id) 가 발급한 쿠폰(coupon_id) 인지를 확인합니다.

이를 통해 중복 발급 여부를 확인할 수 있습니다.

 

위의 연산 모두 O(1) 만에 제공하는 연산이므로, 데이터가 늘거나 요청이 많이 몰려도 크게 무리가 없을 것 같습니다.

 

 

 

Redis - List

레디스 리스트는 스택이나 큐를 구현할 때 많이 사용한다고 합니다.

이 프로젝트에서는 선착순 조건에 대한 처리가 필요하므로, 유저 요청에 대한 대기열 큐가 필요합니다.

 

리스트의 연산을 살펴봅시다.

  • LPUSH : O(1). 리스트의 head 에 지정된 요소를 추가
  • RPUSH : O(1). 리스트의 tail 에 지정된 요소 추가
  • LLEN : O(1). 리스트의 크기 반환

Push 연산과 길이를 구하는 연산 LLEN 이 O(1) 에 수행된다고 합니다.

 

 

쿠폰 발급 로직

 

@RequiredArgsConstructor
@Service
public class AsyncCouponIssueServiceV1 {

    private final RedisRepository redisRepository;
    
    private void issueCoupon(long couponId, long userId) {
        CouponIssueRequest issueRequest = new CouponIssueRequest(couponId, userId);
        try {
            String value = objectMapper.writeValueAsString(issueRequest);
            redisRepository.sAdd(getIssueRequestKey(couponId), String.valueOf(userId));
            redisRepository.rPush(getQueueKey(), value);
        } catch (JsonProcessingException e) {
            throw new BaseException(400, "쿠폰 발급 실패");
        }
    }	
}
@Repository
@RequiredArgsConstructor
public class RedisRepository {

    public Long sAdd(String key, String value) {
        return redisTemplate.opsForSet().add(key, value);
    }

    public Long rPush(String key, String value) {
        return redisTemplate.opsForList().rightPush(key, value);
    }
}

 

sAdd() 를 통해 쿠폰 발급 내역을 Set에 저장하고, rPush() 를 통해 List의 끝부분에 발급 요청을 적재합니다.

큐에 순서대로 적재된 요청들은 쿠폰 발급 서버에서 순차적으로 처리합니다. => 실제 Mysql DB에 저장


 

Redis 를 채택한 이유

비동기 구조로 서버를 구현하고, 레디스에서 제공하는 빠른 연산을 통해 성능을 높이기 위함입니다.

 

 

시스템 구조를 보면 유저 트래픽과, 쿠폰 발급 트랜잭션이 분리되어 있습니다.

  • 쿠폰 발급 요청을 처리하는 API서버와, 쿠폰 발급을 수행하는 발급 서버를 분리했습니다.
  • API 서버는 쿠폰 발급 요청이 오면 Redis 에 해당 요청을 던지기만 하면 됩니다.

위 구조에서는 발급 요청을 적재하되, 쿠폰을 실제 발급하는 건 별개의 일입니다.

유저가 요청하는 API는 실제로 쿠폰 발급 트랜잭션과 무관하게 응답이 나갑니다. Redis 에서 요청 트래픽을 처리한 후에, 응답이 가게 되죠.

  • 따라서 성공이라고 응답이 왔는데 실제 쿠폰이 발급되지 않을 수 있습니다.

 

비동기 방식을 채택한 이유는 만약 사용자가 동시에 많은 트래픽을 보내는 경우, 서버가 죽을 수도 있기 때문입니다.

 

동기 방식이라면 서버에서 요청을 받아서 DB에서 개수와 중복 발급 여부를 체크하고, MySQL Insert 트랜잭션을 거친 후에 비로소 응답을 주게 될 것입니다. 이러면 너무 헤비할 것 같습니다.

 

 

 

소감

Redis 를 통해 비동기 구조로 코드를 짜봤습니다. 다만 레디스 연산마다 시간 복잡도가 다르므로, 주의해서 사용해야 합니다.

트래픽 제어에 효율적인 구조를 짜기 위해 비동기적 구조로 서버를 설계하고, 이해할 수 있었던 시간이었습니다.

하지만 비동기적 구조가 정답이 될 수는 없을 것입니다. 결제 시에 쿠폰을 사용할 때는 동기식으로 하는 것이 좋을 것 같습니다.