쿠폰과 관련된 다양한 기능들을 마음대로 구현해보기 위한 저장소
- kotlin 1.8.22
- Spring Boot 3.1.3
- Spring Data JPA
- Spring Data Redis
- Spring Batch
- kotest, mockk, rest-assured
- MySQL 8.0.34
- Redis 7.0.10 , Lettuce 6.2.6
- 코드입력을 통한 쿠폰 발급
- 출석체크 쿠폰 발급 이벤트
- 쿠폰 통계 조회 기능
난수를 사용하는 곳은 총 세군데다.
- 쿠폰 코드
- 엔티티의 외부 노출 id
- 요청 트래킹 id
[공통 조건]
- 난수는 중복되면 안된다.
- 분산 환경이어도 중복되서는 안된다.
- 난수는 영문자, 숫자, '-' 로만 이루어져야 한다.
[쿠폰 코드 조건]
- 쿠폰 코드의 길이는 19글자여야 한다.
- ex)
xxxx-xxxx-xxxx-xxxx
- ex)
- 사용자는 쿠폰 코드를 입력하면 쿠폰을 발급받는다.
- 하나의 쿠폰당 하나의 쿠폰 코드만 가질 수 있다.
- 동일한 사용자가, 동일한 쿠폰 코드를 여러번 입력해도, 한번만 발급되어야 한다.
[엔티티의 외부 노출 id]
- 외부 노출 id의 길이는 12 이어야한다.
- 정렬이 가능해야 한다.
[요청 트래킹 id]
- 난수의 길이는 12 이어야 한다.
{prefix} + {난수}
- prefix는 쓰레드 id
[사용 가능한 기술]
- 티켓 서버
- snowflake 서비스
- UUID, ULID, TSID
[쿠폰 코드, 요청 트래킹 id]
- 각 19,16자리 길이 조건을 만족하고, 시간순으로 정렬할 필요가 없기 때문에
UUID
사용
[외부 노출 id]
- 12자리 길이 난수를 생성할 수 있고, 시간순으로 정렬할 수 있는
TSID
사용
직접 UUID.randomUUID()
또는 TSID.getTsid()
를 호출해서 사용해도 되지만, 객체지향적으로 구현하여 DIP 개념을 함께 활용했다.
- 연속 출석체크시 쿠폰을 발급한다.
- 3일 연속 출석체크시
3일 연속 출석체크 1000원 쿠폰
발급 - 7일 연속 출석체크시
7일 연속 출석체크 5000원 쿠폰
발급 - 30일 연속 출석체크시
30일 연속 출석체크 50000원 쿠폰
발급
- 3일 연속 출석체크시
- 로그인하면 출석체크된다고 가정하지만, 로그인 기능은 구현하지 않는다.
- 로그인 기능을 대신할 임시 API를 통해 출석체크 기능을 구현한다.
- 출석체크 쿠폰 발급 이벤트 기간동안에는, 계속해서 참여할 수 있다.
- 예로 4일 연속 출석체크 후, 5일째 출석체크를 하지 않고, 6일째 다시 출석체크를 하는경우 처음부터 다시 출석체크 이벤트에 참여하게 된다.
- 각 쿠폰은 중복 발급되지 않는다.
- 예로 6일 연속 출석체크해도,
3일 연속 출석체크 쿠폰
은 3일째 한번만 발급된다. - 단, 연속성이 깨지면 즉 처음부터(1일차) 다시 출석체크를 하는 경우에는 쿠폰이 발급된다.
- 예로 6일 연속 출석체크해도,
- 애플리케이션 서버는 여러개로, 분산 환경이라고 가정한다.
- 해당 서비스에 가입한 사용자 수는 1000만명이라고 가정한다.
Redis Blog에서 얻은 아이디어를 기반으로 구현해보고자 한다.
Redis의 비트 연산자를 정확히는 BITOP
명령어를 이용하는 방법이다.
[key,value]
를 [prefix:날짜,bit]
로 구성한다.
-
id가 1인 사용자가 2023-09-17에 출석체크를 하게 되면 1번째 offset의 bit를 1로 만든다.
-
id가 5인 사용자가 2023-09-17에 출석체크를 하게 되면 5번째 offset의 bit를 1로 만든다.
-
id가 1인 사용자가 3일 연속 방문해서 데이터가 쌓이게 되면 아래와 같아진다.
-
3개의 key에 대해
BITOP
명령어를 통해AND
연산을 하면 아래와 같은 결과가 나온다.BITOP AND destkey attendance:check:20230917 attendance:check:20230918 attendance:check:20230919
-
BITOP
명령어 결과를 애플리케이션에서 몇 번째 offset이 1로 되어있는지 계산 -
offset이 곧, 사용자의 id가 되므로 offset이 1인 사용자에게 쿠폰 발급
- 로그인할 때 마다, 즉 출석체크 할 때마다 x일 연속 출석체크했는지 확인하는 방법
- 하루동안 사용자가 계속 로그아웃, 로그인을 반복하면, 출석체크 확인을 반복해야 한다.
BITOP
명령어는O(N)
시간복잡도를 가지기 때문에, 명령어 사용을 최소화 해야한다.
- 이를 방지하려면 DB 어딘가에 출석체크 확인을 했다는 데이터를 저장해야 하는데, 로그인할 때마다 DB를 한번 더 확인해야 하는 오버헤드가 발생하기 때문에 좋은 방법은 아닌듯 하다.
- 하루동안 사용자가 계속 로그아웃, 로그인을 반복하면, 출석체크 확인을 반복해야 한다.
- 매일 한번씩 배치 도는 방법
- 정해진 시간에 배치를 돌리면, 하루에 한번만 확인하면 된다.
- 기존 요구사항
- 해당 서비스에 가입한 사용자 수는 1000만명이라고 가정한다.
- 변경된 요구사항
- 서비스가 흥행해서, 가입한 사용자 수가 1억명으로 늘어났다.
1. N일 출석체크 여부 확인 과정
BITOP
명령어를 수행하고, 결과데이터를 하나만 조회하면 된다. 이 과정은 단순하다.limit()
이나 take()
로 인해 쇼트서킷이 되지 않는데 효과가 있을까 싶었는데, 간단히 테스트해보니 꽤 큰 차이(약 2초차이)가 있었다.2. 쿠폰을 발급하는 과정 - write
3. insert 과정 병렬 또는 멀티스레드 처리
Spring Batch에서 병렬 또는 멀티스레드 처리를 지원해준다. 문제는 chunk로 처리했을 때 가능하다. 여기서는 간단히 Tasklet으로 처리했기 때문에, chunk로 처리하도록 변경이 필요하다.
문제는 데이터를 읽는 개수와, 데이터를 쓰는 개수가 달라서, 별도의 처리가 필요할 듯 하다. 게다가 병렬, 멀티스레드 이런 처리는 테스트, 디버깅, 트러블 슈팅이 쉽지 않다. 구현의 복잡도도 고려해야 하고, 코드의 가독성에도 영향을 미칠 것 같다. 그러므로 정말로 성능에 문제가 있을 때 적용해야 한다는 것인데, 현재 배치 작업은 하루에 한번만 수행하면 된다. 즉 batch insert 만으로 충분할 듯 하다.
4. 이벤트 참여한 사용자를 구할 때, 병렬처리
1억개니까, 예로 쿼드코어라고 하면 2500만개씩 chunk로 쪼개서, 병렬처리한다면, 더 빠르게 처리할 수 있을 듯 하다. 하지만 위에서 이야기했듯이 병렬처리는 최후의 수단 느낌으로 생각해야 한다.
5. 이벤트 참여한 사용자를 구할 때, 1로 표시된 첫 번째 offset 부터 계산하기
BITPOS
명령어를 통해 1로 표시된 첫 번째 offset값을 구할 수 있다. 이를 통해, 예로 0~5천번 사용자는, 서비스를 이용안한지 오래되어 이벤트 참여를 안해서 다 0이고 5001번부터 1인 경우에는, 앞의 5000개 데이터는 무시할 수 있다.
근데 주의해야 하는점은 CPU 연산이 줄어들었고, Redis에 BITPOS
명령어를 날리기 때문에 네트워크 비용이 늘어났다. 즉 오히려 응답시간이 늘어나기 때문에, 클라이언트에게 응답하는 API 라면 역효과라고 생각한다. 하지만 API가 아니고, 하루에 한번씩 수행하는 배치 작업이므로, 불필요한 CPU 연산
을 줄이는게 더 나은 방향 같다.
- 개선점을 고민해보는 것 자체로 유의미하다고 생각하고 위 1,2번 방법만 적용했다. batch insert는 JPA, MyBatis, Exposed, JDBC 등 어떤 기술을 통해 batch insert를 구현할지 고민했고, 가장 적합하다고 생각한 JDBC를 이용했다.
쿠폰 시스템 개선을 위해, 쿠폰 관련 통계 데이터를 산출하라는 업무가 주어졌다고 가정해보자.
이 데이터는 화면을 통해 제공되고, 내부 운영자가 사용한다.
- 통계 데이터
- 쿠폰 발급 개수
- 쿠폰 사용 개수
- 기간이 만료된 쿠폰 개수
- 쿠폰 사용 비율
- 소수점은 버린다.
- 조회한 데이터의, 위 4개 항목 합계 및 평균 데이터
- 통계 데이터의 날짜 범위는, 클라이언트의 요청에 포함된 날짜를 기준으로 한다.
- 날짜 범위는 클라이언트에서 자유롭게 직접 지정할 수 있다.
- 유효하지 않은 날짜 데이터는 인입되지 않는다고 가정한다. (validation 생략)
- 통계 데이터는 하루마다 갱신된다.
- 오늘이 2023-10-13일 이라면, 2023-10-12일 까지 데이터만 조회할 수 있다.
- 날짜 기준 오름차순으로 응답한다.
- 발급된 쿠폰의 개수는 100만개라고 가정한다.
날짜 | 쿠폰 발급 개수 | 쿠폰 사용 개수 | 기간이 만료된 쿠폰 개수 | 쿠폰 사용 비율(%) |
---|---|---|---|---|
2023-10-15 | 1 | 1 | 0 | 100 |
2023-10-16 | 10 | 5 | 1 | 50 |
2023-10-17 | 1 | 0 | 0 | 0 |
합계 | 12 | 6 | 1 | 50 |
사실 인덱스 적용은 필수적인 과정이다. 그러나 인덱스는 적은양의 데이터를 빠르게 조회할 때 효과를 발휘한다. MySQL InnoDB 스토리지 엔진 기준, 조회하는 데이터가 전체 데이터의 약 25%가 넘어가면 옵티마이저가 테이블 풀 스캔을한다. 이처럼 적은 양의 데이터를 조회하는 경우에는 인덱스로도 충분하지만, 현재는 한번에 많은 양의 데이터를 조회하므로, 인덱스로 성능 개선을 기대하기는 어렵다.
통계 데이터를 계산하기 위해, 발급된 쿠폰 데이터를 RDB가 아닌 Redis에 저장된 데이터를 읽어오도록 하면, RDB에 대한 부하 감소와, 빠른 응답속도 등 이점을 챙길 수 있다. 또한 발급된 쿠폰은 마이페이지, 주문서 등 다른 API에서도 조회할 일이 종종 있기 때문에, 추후 캐싱 데이터를 활용할 수 있다.
Cache Aside 패턴을 적용하고, 해당 프로젝트에는 이미 Redis를 사용중이기 때문에, Redis를 이용해 발급된 쿠폰 데이터를 캐싱하고자 한다.
여기서 고려해야할 점은, 쿠폰이 발급될 때 마다 Redis에 저장해야하는가? 이다. 매번 RDB 뿐만 아니라 Redis에 저장하는 network I/O가 추가되기 때문에, 응답속도에 영향을 미친다. 잘 생각해보면 통계 데이터는 하루에 한번 갱신되기 때문에, 하루에 한번만 하루동안 발급된 쿠폰의 데이터를 Redis에 넣어주면 된다. 이렇게 되면 Cache Aside 패턴의 단점 중 하나인 첫 요청은 무조건 캐시 미스가 발생하는 문제도 자연스럽게 해결된다.
각 애플리케이션 서버에 통계 데이터를 캐싱해놓는 방법도 있다. 이를 로컬 캐싱이라 부르는데, 로컬 캐싱을 적용하기 어려운 이유는 동기화이다. A,B 애플리케이션 서버가 존재할 때, A 서버에서 인입된 요청으로 데이터가 변경되었다면 B 애플리케이션 서버에 저장된 캐시 데이터는 정합성이 깨지게 된다.
그런데 여기서 통계 데이터는 하루마다 갱신하기 때문에, 하루동안 데이터 정합성이 깨질일이 없다. 하루마다 데이터를 갱신만 해주면 된다. 따라서 로컬 캐싱을 적용하여 더 빠른 응답속도, RDB에 대한 부하 감소 효과를 얻을 수 있다.
- 100만개라서 2회 이상 요청하면 서버가 다운되기 때문에 1번의 요청을 기준으로 이후 테스트와 비교해본다.
- 평균 응답시간인
Average
를 기준으로 비교한다. - 약 19초
- 약 9초
- 19초 → 9초로 10초정도 개선됐다.
막상 테스트를 하려니 한 가지 간과한 사실이 있다. 로컬 캐싱은 해당 서버의 메모리에 저장되는데, 100만개를 메모리에 저장하는 것은 매우 비효율적이고, 메모리가 부족해서 테스트도 불가능했다. 그래서 26만개를 기준으로 다시 테스트했다.
100만개 기준 | 평균 응답시간(초) |
---|---|
성능 개선 전 | 19 |
글로벌 캐싱 적용 | 9 |
26만개 기준 | 평균 응답시간(초) |
---|---|
성능 개선 전 | 4 |
글로벌 캐싱 적용 | 2 |
로컬 캐싱 적용 | 1.5 |