[Spring] 선착순 이벤트를 위한 모든 것 with Redis 고도화!
- -
이전에 작성했던 선착순 이벤트의 동시성까지는 제어해냈다. 하지만 많은 트래픽에서도 고성능을 내기 위한 최적화, 그 이야기
[Spring] 선착순 이벤트를 위한 모든 것 with Redis
선착순 쿠폰 발급 시스템을 진행하며 다뤘던 내용들이 너무나도 유용했고 재밌었기에 이를 공유하고 싶다는 욕심이 생겨 이 글을 작성한다. 들어가기 앞서, 간만에 글을 작성하려고 하니 너무
seung-seok.tistory.com
이전까지 작성했던 글의 연장편입니다. MySQL 과 Redis 를 사용한 분산락을 활용해 동시성 제어까지는 이뤄냈으나, 이게 더 많은 트래픽에서도 고성능을 낼 수는 없다는 생각이 듭니다. 이제는 최적화 하는 과정, 그 이야기에 대해서 말해볼까 합니다.
💡 현재 상황을 직시해 봅시다
현재 설계된 서비스의 트래픽 처리에 대해 말해보면
- N명의 유저가 요청을 보냅니다.
- API 서버에서는 N개의 요청을 처리합니다.
- N개의 트랜잭션을 처리합니다.
- 쿠폰 조회
- 쿠폰 발급 내역 조회
- 쿠폰 수량 증가 및 발급
N명의 유저의 요청, N개의 트랜잭션을 데이터베이스가 안전하게 처리하며 부하를 감당할 수 있을까요 ?
위의 모든 과정을 동기적으로 처리한다면 이는 감당하기 어려울 수 있습니다. 그렇기에 서버 구조를 개선해 보자는 생각을 했습니다.
🟢 비동기 프로세스 with Queue
쿠폰 발급 기능을 분리했습니다!
- Redis 에서 요청을 처리하고 Queue 에 발급 대상을 저장합니다.
- 이 대상들을 쿠폰 발급 처리 서버에서 조회하고 발급 처리를 진행합니다.
이 구조에서 사용자의 쿠폰 발급 요청에 대한 응답은 쿠폰 발급 트랜잭션과 무관하게 응답합니다. 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 을 활용한 구조의 예상 시나리오
제가 생각한 예상 시나리오는 아래와 같습니다.
- 유저 요청 (coupon_id, user_id)
- 쿠폰 데이터를 통한 유효성 검증
- 쿠폰의 존재
- 쿠폰의 유효 기간
- 중복 발급 요청 여부 확인 (SISMEMBER)
- 수량 조회 (SCARD) 및 발급 가능 여부 검증
- 요청 추가 (SADD)
- 쿠폰 발급 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 를 사용하여 부하 테스트를 진행해 보겠습니다.
많은 트래픽 환경에서도 RPS 와 Redis 와 MySQL 의 안정적인 CPU 를 확인할 수 있었으며, 정확히 의도한 500건의 쿠폰이 발급되며 동시성 문제까지 해결할 수 있었습니다 !
많은 트래픽에서 동시성 문제 때문에 무조건 Lock 을 걸면 안되는구나..
내가 생각했던 방안이 최선이 아닐 수 있으니 한번 더 생각해봐야겠다!
더 나은 방향을 위해 고민했던 순간들에 대한 성취감 !!!!!
이렇게 이번 프로젝트를 마무리하며 많은 것을 배울 수 있었던 것 같습니다. 또한 프로젝트를 회고하며 제가 고려했던 방향들에 대해 다시 한번 생각할 수 있는 좋은 기회라는 것을 다시 한번 깨닫고 꾸준히 포스팅을 이어나가도록 노력해 보겠습니다!
여기까지 읽어주셔서 감사합니다 : )
'Java' 카테고리의 다른 글
[Spring] 선착순 이벤트를 위한 모든 것 with Redis (2) | 2025.03.20 |
---|---|
[Spring] MSA 기반 SNS 서비스 with AWS EKS [설계 / AWS 설정] (0) | 2024.08.18 |
[Spring] 결제시스템, Spring Boot + MSA 로 리팩토링 [구현] (0) | 2024.08.07 |
[Spring] 결제시스템, Spring Boot + MSA 로 리팩토링 [기획 & 설계] (0) | 2024.07.28 |
[Spring] feign 에 대하여 (0) | 2024.07.17 |
소중한 공감 감사합니다