Search
🚀

프로젝트로 경험하는 대용량 트래픽 - 선착순 쿠폰 발급 이벤트 (2)

목차

 현재 구조

현재 구조는 매우 간단한 구조이다.
요청 로직은 다음과 같다.
1.
유저가 요청을 보낸다.
2.
API Server에서 요청을 받는다.
3.
트랜잭션 처리를 한다.
a.
쿠폰 조회
b.
쿠폰 발급 내역 조회
c.
쿠폰 수량 증가
d.
쿠폰 발급 내역 적재
하지만 유저 1명이 아닌 여러 유저가 몰릴 경우 병목 현상이 발생하는 것을 확인했다.
이 상황에서 더 많은 요청을 처리하기 위해 API Server를 Scale-Out하여 인스턴스를 늘릴 수는 있다.
하지만 이것은 수용 가능한 요청의 양을 늘리는 것이지 요청의 처리량을 늘리는 것은 아니다.
이 구조에서는 Database의 Scale-Up을 하더라도 대용량 트래픽을 감당하기에는 한계가 존재한다.

 개선된 구조

먼저 로직을 분리해서 디테일하게 보면
1.
유저로부터 쿠폰 발급 요청이 들어온다.
2.
현재 쿠폰 발급이 가능한지(잔여 수량이 있는지) 확인한다.
3.
사용한 쿠폰 수량을 증가시킨다.
4.
쿠폰 발급 내역을 저장한다.
현재 2~4번의 일련의 과정이 한 트랜잭션으로 수행되고 있는데
요청 부분인 2와 쿠폰 발급 코어 로직인 3~4로 나눌 수 있을 것 같다. (영화 예매 시나리오의 고객과 직원처럼)
또한 이전 구조에서 DB의 부하를 감당하기가 힘들었는데
쿠폰 발급 요청 서버에서는 2번 로직을 DB 말고 Redis를 사용해보려 한다.
Redis를 사용하는 이유는
Redis가 메모리 내에 데이터를 저장해두어 읽기/쓰기 작업에 있어서 높은 성능을 자랑하기 때문이다. (초당 5만~25만 요청 처리 가능)
또한 쿠폰 잔여 수량에 따라 요청을 제어하는 것이 Redis가 가지고 있는 자료구조를 이용하면 효율적으로 제어할 수 있을 것 같다.
위 내용대로 아키텍처를 수정해보면 아래와 같다.
먼저 쿠폰 발급 요청부인 API Server에서는 쿠폰 수량 제어, 중복 발급 관리, 쿠폰 발급 요청 로직을 처리하도록 한다.
쿠폰 수량 제어와 중복 요청은 Redis의 Set 자료구조를 활용하면 효율적으로 제어할 수 있을 것 같다.
그리고 쿠폰 발급 요청에 대한 건도 List 자료구조를 이용한 Queue에다 적재해두면 대용량 트래픽의 전파를 막을 수 있다.
이후 Queue를 Polling하는 서버에서 실제 쿠폰 발급 처리를 한다.
** Polling 할 때 Kafka를 이용하면 좀 더 유연한 아키텍처를 구성할 수 있다. 또한 Kafka의 Consumer Group을 활용하면 더 쉽게 더 높은 처리량을 낼 수 있다.
하지만 이번 프로젝트에서는 Kafka 없이 진행 해보도록 한다.

Key Point

유저 트래픽과 쿠폰 발급 트랜잭션 분리
Redis를 이용한 트래픽 대응
MySQL 트래픽 제어
비동기 쿠폰 발급 시스템
Queue를 사이에 두어 발급 요청과 발급 과정을 분리

Action Plan

어떻게 MySQL 조회 없이 Redis만 사용해서 사용자 요청을 처리할지?
쿠폰 기한 검증
쿠폰 발급 수량 검증
중복 발급 검증

쿠폰 발급 요청

지난 글에서 세 가지 버전으로 동시성 이슈를 제어 해보았다.
그 중 Redisson을 이용한 버전을 활용할 예정인데 다시 한 번 로직을 상기시켜보자

기존 쿠폰 발급 로직

API 요청이 들어오면
couponIssueService의 issue 메서드를 분산락을 이용하여 호출한다.
쿠폰 엔티티를 조회한 뒤 coupon.issue()를 호출하여 쿠폰의 issuedQuantity를 +1 한다. 이때 발급 가능한 수량과 발급 가능한 일자를 검증한다.
쿠폰 발급 내역을 저장하기 위해 saveCouponIssue()를 호출하는데 이때 중복 발급인지 아닌지 확인한다.
(전체 코드는 여기에서 볼 수 있다.)

쿠폰 발급 요청 ver.2

먼저 해볼 것은 기존 로직에서 요청 부분쿠폰 발급 코어 로직을 분리 시킬 것이다. (요청 부분만 먼저 다룰 예정)
그리고 MySQL 없이 Redis만으로 쿠폰 기한, 쿠폰 발급 수량, 중복 발급 검증을 해보자
couponIssueService.issue()를 바로 호출하지 않고 asyncCouponIssueServiceV1.issue()를 호출하여 요청을 분리시킨다.
asyncCouponIssueServiceV1의 issue 메서드의 로직은 다음과 같다.
(1) 쿠폰을 조회한다.
(2) 쿠폰이 발급 가능한 상태인지 체크한다. (발급 기한만 체크)
// 분산락 획득
(3) 쿠폰의 발급 수량과 중복 발급을 체크한다.
(4) 쿠폰 발급을 요청한다.
// 분산락 해제
먼저, (1) 쿠폰을 조회할 때 MySQL의 부하을 줄이기 위해 캐시를 활용하였다. (CachManager 는 RedisCacheManager를 사용)
(2) 쿠폰이 발급 가능한 상태인지 체크할 때, 캐시 데이터를 조회해서 발급 기한만 체크한다.
발급 수량과 중복 발급도 체크해야하지만 단순 캐시 데이터로는 알 수 없으므로 다른 기능에서 체크하도록 한다.
다음은 (3) 쿠폰의 발급 수량과 중복 발급을 체크하는데 이때 Redis의 Set 자료 구조를 활용하였다.
발급 수량 검증을 할 때는 redisTemplate.opsForSet().size(key)를 통해 SCARD 커맨드를 사용했다.
SCARD는 Set의 원소 개수를 반환하는 커맨드인데 시간 복잡도가 O(1)로 빠른 커맨드이다.
중복 발급을 검증할 때는 redisTemplate.opsForSet().isMember(key, value) 를 통해 SISMEMBER 커맨드를 사용했다.
SISMEMBER의 경우 Set 내 value 가 저장되어있는지 확인하는 커맨드로 마찬가지로 시간 복잡도가 O(1)로 매우 빠르다.
마지막으로 (4) 쿠폰 발급을 요청을 할 때
Set에는 ‘어떤 유저가 쿠폰을 발급했는지’ 저장한다.
redisTemplate.opsForSet().add(key, value)를 통해 SADD 커맨드를 사용한다.
SADD의 경우 Set에 요소를 추가하는 커맨드로 시간 복잡도는 O(1)이다. (추가할 요소가 N개면 O(N).)
Queue에는 CouponIssueRequest를 저장한다.
redisTemplate.opsForList().rightPush(key, value)를 통해 RPUSH 커맨드를 사용한다.
RPUSH의 경우 List에 요소를 오른쪽에 추가하는 커맨드로 시간 복잡도는 O(1)이다. (추가할 요소가 N개면 O(N).)

Issue

개선된 버전으로 부하 테스트를 돌려보자.
지난번과 마찬가지로 쿠폰 수량은 500개 한정이며 유저는 1,000명으로 실시한다.
다만, 요청까지만 진행하고 실제 쿠폰 발급 로직은 잠시 보류한다.
쿠폰은 동시성 이슈 없이 정확히 500개 발급 요청이 되었다.
그러나 요청 로직과 발급 로직을 분리하고 MySQL의 부하도 줄였기 때문에 성능이 어느정도 향상될거라 기대했는데
성능을 보면 약 1,300 RPS이다..
왜 성능이 향상되지 않았을까?
비밀은 분산락에 있었다.
좌측은 분삭락이 있을 때, 우측은 분산락을 제거했을 때 그래프이다.
물론 분산락을 해제했기 때문에 동시성 문제는 발생하겠지만
성능 차이를 보면 기존에 1,300 RPS를 처리하는데 비해 약 10배 가량 높은 성능인 약 12,500 RPS까지 처리하는 것을 볼 수 있다.
그렇다고 Redisson을 활용한 분산락이 성능이 좋지않다는 것은 아니다.
성능이 좋다 나쁘다의 기준은 어떤 서비스에서 사용되고 있느냐에 따라서 달라진다고 생각한다.
RPS 1,000이 오버스펙인 서비스도 존재한다는 것이다.
다만, 우리 서비스에서는 좀 더 개선할 여지가 보여진다고 판단된다.
다음 버전에서는 분산락을 활용하지 않고도 동시성 이슈를 해결해보는 방법을 살펴보자

쿠폰 발급 요청 ver.3

어떻게하면 분산락을 쓰지 않고서 동시성 이슈를 해결할 수 있을까?
다시 말하면
어떻게하면 분산락 내 로직을 atomic하게 보장할 수 있을까?
분산락 내 로직들을 살펴보면 모두 Redis를 이용하여 데이터를 다루고 있는 로직들이다.
N개의 Redis 명령어를 atomic하게 실행시켜야 분산락 없이도 동시성 이슈를 해결할 수 있을 것이다.
이럴때 사용할 수 있는것이 Redis Lua Script 이다.
Lua Script를 작성하여 Redis의 EVAL 커맨드를 사용하면 스크립트를 실행시킬 수 있다.
분산락 내 어떤 로직이 있었는지 정리해보면 아래와 같다.
현재 쿠폰 발급 수량 검증 → Set 자료구조의 size를 확인한다(SCARD)
쿠폰 중복 발급 검증 → Set 자료구조에 중복된 유저가 있는지 확인한다(SISMEMBER)
쿠폰 발급 유저 기록 → Set 자료구조에 유저 등록(SADD)
쿠폰 발급 요청 큐에 저장 → List(큐 역할)에 요청 데이터를 저장한다(RPUSH)
이 로직을 Lua Script로 작성하면 아래와 같다.
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then -- 쿠폰 중복 발급 검증 return '2' -- DUPLICATED_COUPON_ISSUE end if tonumber(ARGV[2]) > redis.call('SCARD', KEYS[1]) then -- 현재 쿠폰 발급 수량 검증 redis.call('SADD', KEYS[1], ARGV[1]) -- 쿠폰 발급 유저 기록 redis.call('RPUSH', KEYS[2], ARGV[3]) -- 쿠폰 발급 요청 큐에 저장 return '1' -- SUCCESS end return '3' -- INVALID_COUPON_ISSUE
Lua
복사
이 스크립트를 호출할 수 있도록 RedisRepository에 소스를 구현해보자 (소스 보러가기)
이제 issueRequest를 호출하도록 변경해보면 아래와 같다.
이제 다시 Locust를 돌려보자 !
성능이 14,693 RPS로 확연히 좋아진것을 확인할 수 있었다.
분산락을 단순히 제거했을 때(12,000RPS)보다 조금 더 높은 이유는 N개의 명령어를 처리하면서 생긴 네트워크 비용이 절감되어서 생긴 성능 이득이라고 생각한다.
이제 그럼 쿠폰 등록 부분으로 넘어가보자 !

쿠폰 발급

쿠폰 발급 부분은 발급 요청만 담당하는 coupon-consumer 서버를 만들것이다.
해당 서버에서는 CouponIssueRequest가 저장되고 있는 Queue를 주기적으로 Polling하여 작업할 수 있도록 구현해보자
CouponIssueListener는 1초마다 스케쥴을 돌며 Queue의 size를 확인한다.
redisRepository.lSize(issueReqeustQueueKey)redisTemplate.opsForList().size(key) 를 호출하게되어 Redis에 LLEN 커맨드를 보낸다. LLEN 커맨드의 경우 LIST의 size를 확인하는 커맨드로 시간 복잡도는 O(1)이다.
이때 Queue에 요청이 존재한다면 couponIssueService.issue를 통해 쿠폰을 발급하는 로직을 수행한다.
마지막으로 모든 로직 수행이 끝이나면 Queue에서 제거해준다.
(쿠폰 발급 중에 에러가 발생하는 경우 재시도나 실패처리에 대해서 핸들링 해야하지만 해당 예제에서는 생략한다)
이렇게하여 한정된 수량의 쿠폰을 유저에게 '신속하고', '정확하게' 발급할 수 있게 되었다.
쿠폰 발급 처리하는 과정을 보다 높은 성능으로 처리하고 싶으면 Kafka를 도입하면 더욱 좋다.
쿠폰 발급 요청을 Redis Queue에 적재하기 보다는 요청을 Event로 처리하여 Kafka에 coupon topic으로 publish 한다.
이후 coupon-consumer의 인스턴스를 컨슈머 그룹으로 두어 coupon topic을 subscribe하게 한 뒤 병렬 처리를 할 수 있게 하는 것이다.

마무리 정리

선착순 쿠폰 발급 서비스를 할 때
한정된 수량의 쿠폰을 유저에게 ‘신속하고’, ‘정확하게’ 발급 해주는 일은 그리 쉬운 일이 아니다.
아무런 고려없이 개발을 하게되면 ‘동시성 이슈’와 ‘성능 이슈’를 필연적으로 겪게된다.
대부분은 대량으로 트래픽이 몰릴 경우 Auto Scale을 통해 대응을 할 수 있다.
다만 이때 병목 지점을 잘 예상하고 그에 맞는 시스템 대응이 필요하다.
이 프로젝트에서는 DB를 사용하는 지점이 그러했다.
서비스를 다음과 같은 방법들을 활용하여 개선해나갔다.
캐시를 데이터를 이용하여 DB로부터의 부하를 줄였다.
비동기 처리 방식을 도입하여 요청과 발급을 나누었다.
Redis의 자료구조를 활용해 고성능으로 쿠폰 유효성을 검증하였다.
Redis Lua Script를 통해 여러 커맨드들을 atomic하게 동작하게 하였다
Redis를 이용하여 다양한 방법으로 개선을 해보았다.
Redis는 기본적으로 읽기/쓰기 성능이 좋다. 하지만 모든 커맨드들이 성능이 좋은 것은 아니다.
기능에 맞는 적절한 자료구조를 선정한 뒤, 트래픽을 고려하여 커맨드를 사용하도록 하자.