새소식

Java

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

  • -
728x90
이전에 작성했던 선착순 이벤트의 동시성까지는 제어해냈다. 하지만 많은 트래픽에서도 고성능을 내기 위한 최적화, 그 이야기

 

 

 

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

선착순 쿠폰 발급 시스템을 진행하며 다뤘던 내용들이 너무나도 유용했고 재밌었기에 이를 공유하고 싶다는 욕심이 생겨 이 글을 작성한다. 들어가기 앞서, 간만에 글을 작성하려고 하니 너무

seung-seok.tistory.com

 

이전까지 작성했던 글의 연장편입니다. MySQL 과 Redis 를 사용한 분산락을 활용해 동시성 제어까지는 이뤄냈으나, 이게 더 많은 트래픽에서도 고성능을 낼 수는 없다는 생각이 듭니다. 이제는 최적화 하는 과정, 그 이야기에 대해서 말해볼까 합니다.

 

💡 현재 상황을 직시해 봅시다

현재 설계된 서비스의 트래픽 처리에 대해 말해보면

  1. N명의 유저가 요청을 보냅니다.
  2. API 서버에서는 N개의 요청을 처리합니다.
  3. N개의 트랜잭션을 처리합니다.
    1. 쿠폰 조회
    2. 쿠폰 발급 내역 조회
    3. 쿠폰 수량 증가 및 발급
N명의 유저의 요청,  N개의 트랜잭션을 데이터베이스가 안전하게 처리하며 부하를 감당할 수 있을까요 ?

 

위의 모든 과정을 동기적으로 처리한다면 이는 감당하기 어려울 수 있습니다. 그렇기에 서버 구조를 개선해 보자는 생각을 했습니다.

 

🟢 비동기 프로세스 with Queue

 

쿠폰 발급 기능을 분리했습니다!

  1. Redis 에서 요청을 처리하고 Queue 에 발급 대상을 저장합니다.
  2. 이 대상들을 쿠폰 발급 처리 서버에서 조회하고 발급 처리를 진행합니다.

이 구조에서 사용자의 쿠폰 발급 요청에 대한 응답은 쿠폰 발급 트랜잭션과 무관하게 응답합니다. Redis 에 보낸 요청에 대한 응답이지 쿠폰이 실제로 발급된 것이 아닙니다. 즉, 비동기로 처리되고 있습니다.

 

위의 구조에서 고려했던 것은 유저 트래픽과 쿠폰 발급 트랜잭션을 분리하는 것입니다. Redis 를 통해 트래픽에 대응하며 MySQL 까지 도달하는 트래픽을 대응하고 싶었습니다. 그렇다면 MySQL 조회없이 Redis 만 사용해서 사용자 요청을 어떻게 처리할 수 있을까요 ? 

 

🟢 Redis 데이터 구조 결정

 

Redis는 다양한 자료 구조를 제공하고 있습니다. 여기서 몇가지 선택지를 고려할 수 있습니다.

List, Set, Sorted Set ..

 

List

List 는 Stack 과 Queue 를 구현 가능하지만 중복을 허용하지 않습니다. 중복 발급 검증을 LPOS 를 통해서 지속적으로 탐색해야 하는데 이는 O(N) 의 시간이 소요돼 효율적이지 않고 까다로워서 배제했습니다.

 

Sorted Set

그 다음은 많은 사람들이 선착순하면 처음으로 떠올리는 Sorted Set 입니다! 이 자료구조는 중복을 허용하지 않으며 스코어를 사용하여 정렬이 가능합니다. 하지만 score 가 동일한 경우가 발생할 수 있습니다. 또한 시간을 score 로 할 경우 신뢰할 수 없는 상황이 발생할 수 있습니다. 한 가지 예시를 들어보겠습니다.

선착순 3명을 가정하고 유저 A,B,C,D 가 존재합니다. 유저 A 의 score 는 1, 유저 B 의 score 2, 유저 C 의 score 4 인 상황이 주어졌을 때 유저 C 의 발급 요청에는 선착순 3명 안에 들어가기에 발급 대상이 되어 정상적인 요청을 받습니다. 하지만 유저 D 의 score = 3 인 데이터가 삽입된 후, 정렬했을 때는 유저 D 가 3등  안에 드는 상황이 발생할 수 있습니다.

이러한 이유로 Sorted Set 도 신뢰할 수 없는 상황이 발생할 수 있다고 판단하여 배제했습니다.

 

Set

Set 은 중복 발급 요청 여부를 확인하는 SISMEMBER 와 수량 조회가 가능한 SCARD 을 지원하며, 검증에 통과한 유저 데이터를 List에 큐형식으로 적재한다면 효율적이라고 판단했습니다.
또한 X-Lock 을 사용하여 쿠폰 데이터를 조회하고 그 안에서 남은 발급 수량 정보를 가져와서 하나하나 비교하는 방식에서 Set 에 적재된 수량을 통해 MySQL 의 락을 사용하지 않아도 된다는 아주 큰 이점이 존재합니다!!

 

[AS-IS]

쿠폰 데이터를 조회하고 이에 대한 수량을 차감하며 최대 수량까지의 발급 과정

 

[TO-BE ]

쿠폰 데이터의 존재와 그 기한만을 검증하고 수량은 Set 에 적재된 요청 수와 최대 수량의 비교 과정

 

그렇다면 Coupon 의 존재와 기한만을 검증하게 되는데 이는 캐시를 적용해 더 빠른 응답을 줄 수 있지 않을까요 ? 이 또한 다뤄보겠습니다!

 

🔑 SET 을 활용한 구조의 예상 시나리오

제가 생각한 예상 시나리오는 아래와 같습니다.

  1. 유저 요청 (coupon_id, user_id)
  2. 쿠폰 데이터를 통한 유효성 검증
    1. 쿠폰의 존재
    2. 쿠폰의 유효 기간
  3. 중복 발급 요청 여부 확인 (SISMEMBER)
  4. 수량 조회 (SCARD) 및 발급 가능 여부 검증
  5. 요청 추가 (SADD)
  6. 쿠폰 발급 Queue(비동기 처리를 위한) 에 적재

이 과정을 코드로 구현해볼까요 ? 

 

CouponIssueRedisService

@Service
@RequiredArgsConstructor
public class CouponIssueRedisService {
    private final RedisRepository redisRepository;

    public void checkCouponIssueQuantity(Coupon coupon, long userId) {
        // Redis 에서 요청 큐의 수 검증
        if(!availableTotalIssueQuantity(coupon.totalQuantity(), coupon.id())) {
            throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초과했습니다. couponId : %s, userId : %s".formatted(coupon.id(), userId));
        }

        // 중복 발급 검증
        if(!availableUserIssueQuantity(coupon.id(), userId)) {
            throw new CouponIssueException(ErrorCode.DUPLICATED_COUPON_ISSUE, "이미 발급 요청이 처리됐습니다. couponId : %s, userId : %s".formatted(coupon.id(), userId));
        };
    }

    // 발급 수량 검증
    public boolean availableTotalIssueQuantity(Integer totalQuantity, long couponId) {
        // 무제한 발급
        if(totalQuantity == null)
            return true;

        String key = getIssueRequestKey(couponId);
        return totalQuantity > redisRepository.sCard(key);
    }

    // 중복 발급 검증
    public boolean availableUserIssueQuantity(long couponId, long userId) {
        String key = getIssueRequestKey(couponId);
        return !redisRepository.sIsMember(key, String.valueOf(userId));
    }
}

 

Redis 서비스 코드를 별도로 생성해서 검증 절차를 추가했습니다. 이제는 발급 수량 검증은 Redis`s SCARD, 중복 발급 검증은 Redis`s SISMEMBER 를 통해 처리할 수 있습니다.

 

AsyncCouponIssueService

@RequiredArgsConstructor
@Service
public class AsyncCouponIssueService {
    private final RedisRepository redisRepository;
    private final CouponIssueRedisService couponIssueRedisService;
    private final CouponIssueService couponIssueService;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private final DistributeLockExecutor distributeLockExecutor;

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

        // Redis 에서 요청 큐의 수 검증
        if(!couponIssueRedisService.availableTotalIssueQuantity(coupon.getTotalQuantity(), couponId)) {
            throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초과했습니다.. couponId : %s, userId : %s".formatted(couponId, userId));
        }

        distributeLockExecutor.execute("lock_%s".formatted(couponId), 3000, 3000, () -> {
            // 중복 발급 검증
            if(!couponIssueRedisService.availableUserIssueQuantity(couponId, userId)) {
                throw new CouponIssueException(ErrorCode.DUPLICATED_COUPON_ISSUE, "이미 발급 요청이 처리됐습니다. couponId : %s, userId : %s".formatted(couponId, userId));
            };

            // 발급 일자 조회
            if(!coupon.availableIssueDate()) {
                throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_DATE, "발급 가능한 일자가 아닙니다. couponId : %s, userId : %s".formatted(couponId, userId));
            }

            issueRequest(couponId, userId);
        });
    }

    // Redis 에 요청 적재, 발급 요청
    private void issueRequest(long couponId, long userId) {
        CouponIssueRequest issueRequest = new CouponIssueRequest(couponId, userId);

        try {
            // 직렬화
            String value = objectMapper.writeValueAsString(issueRequest);
            redisRepository.sADD(getIssueRequestKey(couponId), String.valueOf(userId));
            // 쿠폰 발급 대기열 관리 List, Queue 에 삽입
            redisRepository.rPush(getIssueRequestQueueKey(), String.valueOf(userId));
        } catch (JsonProcessingException e) {
            throw new CouponIssueException(FAIL_COUPON_ISSUE_REQUEST, "input : %s".formatted(issueRequest));
        }
    }
}

 

이 부분에 대해서 자세히 다뤄보겠습니다.

 

Coupon coupon = couponIssueService.findCoupon(couponId);

// Redis 에서 요청 큐의 수 검증
if(!couponIssueRedisService.availableTotalIssueQuantity(coupon.getTotalQuantity(), couponId)) {
    throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초과했습니다.. couponId : %s, userId : %s".formatted(couponId, userId));
}

 

쿠폰 데이터를 가져오고 수량 검증을 수행합니다. 수량을 초과한 경우에는 트래픽을 차단할 있는 1차 수단이 될 수 있습니다. 하지만 이 검증 시점에는 락이 걸리지 않았기 때문에 여러 스레드가 동시에 이 검사를 통과할 수 있습니다. 이는 밑에서 개선해보겠습니다!

또한 여기서 findCoupon() 은 추후 캐시의 대상이 될 것입니다!

 

distributeLockExecutor.execute("lock_%s".formatted(couponId), 3000, 3000, () -> {
    // 중복 발급 검증
    if(!couponIssueRedisService.availableUserIssueQuantity(couponId, userId)) {
        throw new CouponIssueException(ErrorCode.DUPLICATED_COUPON_ISSUE, "이미 발급 요청이 처리됐습니다. couponId : %s, userId : %s".formatted(couponId, userId));
    };

    // 발급 일자 조회
    if(!coupon.availableIssueDate()) {
        throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_DATE, "발급 가능한 일자가 아닙니다. couponId : %s, userId : %s".formatted(couponId, userId));
    }

    issueRequest(couponId, userId);
});

 

Redis 자체는 단일 스레드(Single-Thead) 로 동작하기 때문에 하나의 명령어는 원자적(Atomic)으로 실행됩니다. 하지만 여러 개의 Redis 명령어가 하나의 트랜잭션으로 묶여있지 않기 때문에 동시성 문제가 발생할 수 있어 기존의 분산락을 활용했습니다. 검증이 모두 끝나면 쿠폰을 발급해야 합니다! 이 과정까지를 하나의 작업 단위로 구분했습니다.

 

// Redis 에 요청 적재, 발급 요청
private void issueRequest(long couponId, long userId) {
    CouponIssueRequest issueRequest = new CouponIssueRequest(couponId, userId);

    try {
        // 직렬화
        String value = objectMapper.writeValueAsString(issueRequest);
        redisRepository.sADD(getIssueRequestKey(couponId), String.valueOf(userId));
        // 쿠폰 발급 대기열 관리 List, Queue 에 삽입
        redisRepository.rPush(getIssueRequestQueueKey(), String.valueOf(userId));
    } catch (JsonProcessingException e) {
        throw new CouponIssueException(FAIL_COUPON_ISSUE_REQUEST, "input : %s".formatted(issueRequest));
    }
}

 

사용자 요청을 적재하는 Set 과 쿠폰 발급 대기열 관리인 List 에 차례로 넣어줬습니다.

 

🟢 Redis 캐싱

Coupon coupon = couponIssueService.findCoupon(couponId);

 

이제 조회 성능을 더 하기 위해 위의 코드인 쿠폰 데이터 조회하는 부분을 캐싱해보겠습니다!

 

Caching

우선적으로 @EnableCaching 설정을 해주어야 합니다. 

 

CacheConfigure

@RequiredArgsConstructor
@Configuration
public class CacheConfiguration {

    private final RedisConnectionFactory redisConnectionFactory;

    @Bean
    @Primary
    public CacheManager redisCacheManager() {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(30));

        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }
}

 

Cache에 대한 Config 클래스를 구현해줬습니다. 해당 오브젝트를 통해 TTL(Time To Live), disableCachingNullValues, key&value 직렬화 등 캐싱 정책 등을 커스터마이징할 수 있습니다.

 

dependencies {
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
    implementation("com.fasterxml.jackson.core:jackson-databind")

 

의존성은 위와 같이 추가해주면 됩니다. 이제는 캐싱할 Redis 모델을 작성해보겠습니다.

 

CouponRedisEntity

public record CouponRedisEntity(
        Long id,
        CouponType couponType,
        Integer totalQuantity,

        @JsonSerialize(using = LocalDateTimeSerializer.class)
        @JsonDeserialize(using = LocalDateTimeDeserializer.class)
        LocalDateTime dateIssueStart,

        @JsonSerialize(using = LocalDateTimeSerializer.class)
        @JsonDeserialize(using = LocalDateTimeDeserializer.class)
        LocalDateTime dateIssueEnd
) {

    public CouponRedisEntity(Coupon coupon) {
        this(
                coupon.getId(),
                coupon.getCouponType(),
                coupon.getTotalQuantity(),
                coupon.getDateIssueStart(),
                coupon.getDateIssueEnd()
        );
    }

    private boolean availableIssueDate() {
        LocalDateTime now = LocalDateTime.now();
        return dateIssueStart.isBefore(now) && dateIssueEnd.isAfter(now);
    }

    public void checkIssuableCoupon() {
        if(!availableIssueDate()) {
            throw new CouponIssueException(INVALID_COUPON_ISSUE_DATE, "발급 가능한 일자가 아닙니다. request : %s, issueStart: %s, issueEnd: %s".formatted(LocalDateTime.now(), dateIssueStart, dateIssueEnd));
        }
    }
}

 

CouponCacheService

@Service
@RequiredArgsConstructor
public class CouponCacheService {
    private final CouponIssueService couponIssueService;

    // 캐싱
    @Cacheable(cacheNames = "coupon")
    public CouponRedisEntity getCouponCache(long couponId) {
        Coupon coupon = couponIssueService.findCoupon(couponId);
        return new CouponRedisEntity(coupon);
    }
}

 

특정 메소드의 호출 결과를 캐시에 저장하도록 스프링에 지시합니다. 이후 같은 입력 값으로 메소드가 호출 될 때, 캐시에서 직접 결과를 반환하므로 메소드 실행을 건너뛰어 처리 시간을 단축할 수 있습니다!

첫 호출 시 쿠폰 정보는 데이터베이스에서 로드되며, 이후 동일한 couponId 로 요청이 들어오면 데이터베이스 접근없이 캐시된 데이터를 반환합니다!

 

이제 이를 적용한 새로운 서비스를 작성해볼까요 ? 

 

AS-IS

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

    // Redis 에서 요청 큐의 수 검증
    if(!couponIssueRedisService.availableTotalIssueQuantity(coupon.getTotalQuantity(), couponId)) {
        throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초과했습니다.. couponId : %s, userId : %s".formatted(couponId, userId));
    }

    distributeLockExecutor.execute("lock_%s".formatted(couponId), 3000, 3000, () -> {
        // 중복 발급 검증
        if(!couponIssueRedisService.availableUserIssueQuantity(couponId, userId)) {
            throw new CouponIssueException(ErrorCode.DUPLICATED_COUPON_ISSUE, "이미 발급 요청이 처리됐습니다. couponId : %s, userId : %s".formatted(couponId, userId));
        };

        // 발급 일자 조회
        if(!coupon.availableIssueDate()) {
            throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_DATE, "발급 가능한 일자가 아닙니다. couponId : %s, userId : %s".formatted(couponId, userId));
        }

        issueRequest(couponId, userId);
    });
}

 

TO-BE

public void issue(long couponId, long userId) {
    CouponRedisEntity coupon = couponCacheService.getCouponCache(couponId);
    // 발급 일자 조회
    coupon.checkIssuableCoupon();

    distributeLockExecutor.execute("lock_%s".formatted(couponId), 3000, 3000, () -> {
        couponIssueRedisService.checkCouponIssueQuantity(coupon, userId);
        issueRequest(couponId, userId);
    });
}

 

이제는 CouponRedisEntity 내부에서 발급 일자에 대한 검증을 수행합니다.

public void checkCouponIssueQuantity(CouponRedisEntity couponRedisEntity, long userId) {
    // Redis 에서 요청 큐의 수 검증
    if(!availableTotalIssueQuantity(couponRedisEntity.totalQuantity(), couponRedisEntity.id())) {
        throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초과했습니다.. couponId : %s, userId : %s".formatted(couponId, userId));
    }

    // 중복 발급 검증
    if(!availableUserIssueQuantity(couponRedisEntity.id(), userId)) {
        throw new CouponIssueException(ErrorCode.DUPLICATED_COUPON_ISSUE, "이미 발급 요청이 처리됐습니다. couponId : %s, userId : %s".formatted(couponId, userId));
    };
}

 

이젠 테스트를 진행해볼까요 ? 처음과 마찬가지로 Locust 를 사용하여 부하 테스트를 진행해 보겠습니다.

 

안정적인 CPU

 

안정적인 RPS
정확한 발급 수량

 

많은 트래픽 환경에서도 RPS 와 Redis 와 MySQL 의 안정적인 CPU 를 확인할 수 있었으며, 정확히 의도한 500건의 쿠폰이 발급되며 동시성 문제까지 해결할 수 있었습니다 !

 

ㅎㅎㅎㅎㅎㅎㅎ

 

많은 트래픽에서 동시성 문제 때문에 무조건 Lock 을 걸면 안되는구나..
내가 생각했던 방안이 최선이 아닐 수 있으니 한번 더 생각해봐야겠다!
더 나은 방향을 위해 고민했던 순간들에 대한 성취감 !!!!!

 

이렇게 이번 프로젝트를 마무리하며 많은 것을 배울 수 있었던 것 같습니다. 또한 프로젝트를 회고하며 제가 고려했던 방향들에 대해 다시 한번 생각할 수 있는 좋은 기회라는 것을 다시 한번 깨닫고 꾸준히 포스팅을 이어나가도록 노력해 보겠습니다!

 

여기까지 읽어주셔서 감사합니다 : )

728x90
Contents

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

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