znftm97 / coupon_platform

쿠폰과 관련된 다양한 기능들을 마음대로 구현해보기 위한 저장소

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

coupon_platform

쿠폰과 관련된 다양한 기능들을 마음대로 구현해보기 위한 저장소

자세한 내용은 노션으로 문서화

기술 스택

  • 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

프로젝트 구조

화면 캡처 2023-11-11 201227

주요 기능

  1. 코드입력을 통한 쿠폰 발급
  2. 출석체크 쿠폰 발급 이벤트
  3. 쿠폰 통계 조회 기능

1. 코드입력을 통한 쿠폰 발급

해당 기능에 대한 자세한 내용은 노션에 문서화

1-1. 요구사항

난수를 사용하는 곳은 총 세군데다.

  1. 쿠폰 코드
  2. 엔티티의 외부 노출 id
  3. 요청 트래킹 id

[공통 조건]

  1. 난수는 중복되면 안된다.
    1. 분산 환경이어도 중복되서는 안된다.
  2. 난수는 영문자, 숫자, '-' 로만 이루어져야 한다.

[쿠폰 코드 조건]

  1. 쿠폰 코드의 길이는 19글자여야 한다.
    1. ex) xxxx-xxxx-xxxx-xxxx
  2. 사용자는 쿠폰 코드를 입력하면 쿠폰을 발급받는다.
  3. 하나의 쿠폰당 하나의 쿠폰 코드만 가질 수 있다.
  4. 동일한 사용자가, 동일한 쿠폰 코드를 여러번 입력해도, 한번만 발급되어야 한다.

[엔티티의 외부 노출 id]

  • 외부 노출 id의 길이는 12 이어야한다.
  • 정렬이 가능해야 한다.

[요청 트래킹 id]

  • 난수의 길이는 12 이어야 한다.
    • {prefix} + {난수}
    • prefix는 쓰레드 id

1-2. 기술 선택

[사용 가능한 기술]

  1. 티켓 서버
  2. snowflake 서비스
  3. UUID, ULID, TSID

[쿠폰 코드, 요청 트래킹 id]

  • 각 19,16자리 길이 조건을 만족하고, 시간순으로 정렬할 필요가 없기 때문에 UUID 사용

[외부 노출 id]

  • 12자리 길이 난수를 생성할 수 있고, 시간순으로 정렬할 수 있는 TSID 사용

1-3. 클래스 다이어 그램

Untitled

직접 UUID.randomUUID() 또는 TSID.getTsid()를 호출해서 사용해도 되지만, 객체지향적으로 구현하여 DIP 개념을 함께 활용했다.

2. 출석체크 쿠폰 발급 이벤트

해당 기능에 대한 자세한 내용은 노션에 문서화

2-1. 요구사항

  1. 연속 출석체크시 쿠폰을 발급한다.
    • 3일 연속 출석체크시 3일 연속 출석체크 1000원 쿠폰 발급
    • 7일 연속 출석체크시 7일 연속 출석체크 5000원 쿠폰 발급
    • 30일 연속 출석체크시 30일 연속 출석체크 50000원 쿠폰 발급
  2. 로그인하면 출석체크된다고 가정하지만, 로그인 기능은 구현하지 않는다.
    • 로그인 기능을 대신할 임시 API를 통해 출석체크 기능을 구현한다.
  3. 출석체크 쿠폰 발급 이벤트 기간동안에는, 계속해서 참여할 수 있다.
    • 예로 4일 연속 출석체크 후, 5일째 출석체크를 하지 않고, 6일째 다시 출석체크를 하는경우 처음부터 다시 출석체크 이벤트에 참여하게 된다.
  4. 각 쿠폰은 중복 발급되지 않는다.
    • 예로 6일 연속 출석체크해도, 3일 연속 출석체크 쿠폰은 3일째 한번만 발급된다.
    • 단, 연속성이 깨지면 즉 처음부터(1일차) 다시 출석체크를 하는 경우에는 쿠폰이 발급된다.
  5. 애플리케이션 서버는 여러개로, 분산 환경이라고 가정한다.
  6. 해당 서비스에 가입한 사용자 수는 1000만명이라고 가정한다.

2-2. 설계

2-2-1. Redis의 BITOP 명령어

Redis Blog에서 얻은 아이디어를 기반으로 구현해보고자 한다.

Redis의 비트 연산자를 정확히는 BITOP 명령어를 이용하는 방법이다.

[key,value][prefix:날짜,bit]로 구성한다.

keyvalue

2-2-2. 동작 과정

  1. id가 1인 사용자가 2023-09-17에 출석체크를 하게 되면 1번째 offset의 bit를 1로 만든다.

    Blank_diagram_(1)

  2. id가 5인 사용자가 2023-09-17에 출석체크를 하게 되면 5번째 offset의 bit를 1로 만든다.

    Blank_diagram_(2)

  3. id가 1인 사용자가 3일 연속 방문해서 데이터가 쌓이게 되면 아래와 같아진다.

    Blank_diagram

  4. 3개의 key에 대해 BITOP 명령어를 통해 AND 연산을 하면 아래와 같은 결과가 나온다.

    asd

    BITOP AND destkey attendance:check:20230917 attendance:check:20230918 attendance:check:20230919
  5. BITOP 명령어 결과를 애플리케이션에서 몇 번째 offset이 1로 되어있는지 계산

  6. offset이 곧, 사용자의 id가 되므로 offset이 1인 사용자에게 쿠폰 발급

2-2-3. 연속 출석 체크를 계산하는 시기는?

  1. 로그인할 때 마다, 즉 출석체크 할 때마다 x일 연속 출석체크했는지 확인하는 방법
    • 하루동안 사용자가 계속 로그아웃, 로그인을 반복하면, 출석체크 확인을 반복해야 한다.
      • BITOP 명령어는 O(N) 시간복잡도를 가지기 때문에, 명령어 사용을 최소화 해야한다.
    • 이를 방지하려면 DB 어딘가에 출석체크 확인을 했다는 데이터를 저장해야 하는데, 로그인할 때마다 DB를 한번 더 확인해야 하는 오버헤드가 발생하기 때문에 좋은 방법은 아닌듯 하다.
  2. 매일 한번씩 배치 도는 방법
    • 정해진 시간에 배치를 돌리면, 하루에 한번만 확인하면 된다.

2-2-4. 전체적인 플로우

%EC%8B%9C%ED%80%80%EC%8A%A4_%EB%8B%A4%EC%9D%B4%EC%96%B4%EA%B7%B8%EB%9E%A8_(1)

2-3 요구사항 확장해보기

자세한 내용은 노션으로 문서화

  • 기존 요구사항
    • 해당 서비스에 가입한 사용자 수는 1000만명이라고 가정한다.
  • 변경된 요구사항
    • 서비스가 흥행해서, 가입한 사용자 수가 1억명으로 늘어났다.

2-3-1. 개선 할 수 있는 부분 고민해보기

1. N일 출석체크 여부 확인 과정
  • N일 연속 출석체크 여부를 확인하는 과정은, 마찬가지로 N개의 비트데이터에 대해 한 번의 BITOP 명령어를 수행하고, 결과데이터를 하나만 조회하면 된다. 이 과정은 단순하다.
  • 이후 결과데이터(bit 데이터)에서 bit값이 1인 사용자를 필터링 해야하는데, 1억명이라면, BitSet size가 1억이 된다. 즉 1억개의 element를 순회해야 하는데, 이때 Kotlin의 sequence를 이용해서 개선할 수 있다.
  • 사실 element 개수가 많을 뿐이지, 중간에 체이닝된 함수가 많지도 않고, limit()이나 take()로 인해 쇼트서킷이 되지 않는데 효과가 있을까 싶었는데, 간단히 테스트해보니 꽤 큰 차이(약 2초차이)가 있었다.
  • 2. 쿠폰을 발급하는 과정 - write
  • 전체 가입자 1억명 중 10%인 1000만명이 출석체크 이벤트에 참여해서, 1000만명에게 쿠폰을 발급해야 한다면?
  • 현재는 1개씩 쿠폰 발급하면서, 1번씩 insert가 발생한다. 그러면 총 1000만번의 insert가 발생한다. → batch insert를 통해 개선할 수 있다.
  • 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를 이용했다.

    3. 쿠폰 통계 조회 기능

    3-1. 요구사항

    쿠폰 시스템 개선을 위해, 쿠폰 관련 통계 데이터를 산출하라는 업무가 주어졌다고 가정해보자.
    이 데이터는 화면을 통해 제공되고, 내부 운영자가 사용한다.

    1. 통계 데이터
      • 쿠폰 발급 개수
      • 쿠폰 사용 개수
      • 기간이 만료된 쿠폰 개수
      • 쿠폰 사용 비율
        • 소수점은 버린다.
      • 조회한 데이터의, 위 4개 항목 합계 및 평균 데이터
    2. 통계 데이터의 날짜 범위는, 클라이언트의 요청에 포함된 날짜를 기준으로 한다.
      • 날짜 범위는 클라이언트에서 자유롭게 직접 지정할 수 있다.
      • 유효하지 않은 날짜 데이터는 인입되지 않는다고 가정한다. (validation 생략)
    3. 통계 데이터는 하루마다 갱신된다.
      • 오늘이 2023-10-13일 이라면, 2023-10-12일 까지 데이터만 조회할 수 있다.
    4. 날짜 기준 오름차순으로 응답한다.
    5. 발급된 쿠폰의 개수는 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

    3-2. 개선사항 고민해보기

    3-2-1. 인덱스

    사실 인덱스 적용은 필수적인 과정이다. 그러나 인덱스는 적은양의 데이터를 빠르게 조회할 때 효과를 발휘한다. MySQL InnoDB 스토리지 엔진 기준, 조회하는 데이터가 전체 데이터의 약 25%가 넘어가면 옵티마이저가 테이블 풀 스캔을한다. 이처럼 적은 양의 데이터를 조회하는 경우에는 인덱스로도 충분하지만, 현재는 한번에 많은 양의 데이터를 조회하므로, 인덱스로 성능 개선을 기대하기는 어렵다.

    3-2-2. 글로벌 캐싱

    통계 데이터를 계산하기 위해, 발급된 쿠폰 데이터를 RDB가 아닌 Redis에 저장된 데이터를 읽어오도록 하면, RDB에 대한 부하 감소와, 빠른 응답속도 등 이점을 챙길 수 있다. 또한 발급된 쿠폰은 마이페이지, 주문서 등 다른 API에서도 조회할 일이 종종 있기 때문에, 추후 캐싱 데이터를 활용할 수 있다.

    Cache Aside 패턴을 적용하고, 해당 프로젝트에는 이미 Redis를 사용중이기 때문에, Redis를 이용해 발급된 쿠폰 데이터를 캐싱하고자 한다.

    여기서 고려해야할 점은, 쿠폰이 발급될 때 마다 Redis에 저장해야하는가? 이다. 매번 RDB 뿐만 아니라 Redis에 저장하는 network I/O가 추가되기 때문에, 응답속도에 영향을 미친다. 잘 생각해보면 통계 데이터는 하루에 한번 갱신되기 때문에, 하루에 한번만 하루동안 발급된 쿠폰의 데이터를 Redis에 넣어주면 된다. 이렇게 되면 Cache Aside 패턴의 단점 중 하나인 첫 요청은 무조건 캐시 미스가 발생하는 문제도 자연스럽게 해결된다.

    3-2-3. 로컬 캐싱

    각 애플리케이션 서버에 통계 데이터를 캐싱해놓는 방법도 있다. 이를 로컬 캐싱이라 부르는데, 로컬 캐싱을 적용하기 어려운 이유는 동기화이다. A,B 애플리케이션 서버가 존재할 때, A 서버에서 인입된 요청으로 데이터가 변경되었다면 B 애플리케이션 서버에 저장된 캐시 데이터는 정합성이 깨지게 된다.

    그런데 여기서 통계 데이터는 하루마다 갱신하기 때문에, 하루동안 데이터 정합성이 깨질일이 없다. 하루마다 데이터를 갱신만 해주면 된다. 따라서 로컬 캐싱을 적용하여 더 빠른 응답속도, RDB에 대한 부하 감소 효과를 얻을 수 있다.

    3-3. 개선 결과

    3-3-1. 성능 개선 전

    성능테스트_성능개선전 PNG

    • 100만개라서 2회 이상 요청하면 서버가 다운되기 때문에 1번의 요청을 기준으로 이후 테스트와 비교해본다.
    • 평균 응답시간인 Average를 기준으로 비교한다.
    • 약 19초

    3-3-2. 글로벌 캐싱 적용

    글로벌캐싱 PNG

    • 약 9초
    • 19초 → 9초로 10초정도 개선됐다.

    3-3-3. 로컬 캐싱 적용

    막상 테스트를 하려니 한 가지 간과한 사실이 있다. 로컬 캐싱은 해당 서버의 메모리에 저장되는데, 100만개를 메모리에 저장하는 것은 매우 비효율적이고, 메모리가 부족해서 테스트도 불가능했다. 그래서 26만개를 기준으로 다시 테스트했다.

    • 성능 개선 전 (4초) 인덱스

    • 글로벌 캐싱 (2초) 글로벌캐싱2

    • 로컬 캐싱 (1.5초) 로컬캐싱22

    3-3-4. 정리

    100만개 기준 평균 응답시간(초)
    성능 개선 전 19
    글로벌 캐싱 적용 9
    26만개 기준 평균 응답시간(초)
    성능 개선 전 4
    글로벌 캐싱 적용 2
    로컬 캐싱 적용 1.5

    About

    쿠폰과 관련된 다양한 기능들을 마음대로 구현해보기 위한 저장소


    Languages

    Language:Kotlin 100.0%