매일 충전되는 크레딧을 이용해보세요! 크레딧 서버 구현기

 

 

요구사항 - 크레딧 서버 개발

어느 날, 물건 판매 개발이 포함된 서비스의 개발자인 나에게 사용자의 크레딧을 관리할 수 있는 크레딧 서버의 개발 및 요청이 들어왔다.

이 크레딧은 매일 자정에 30으로 초기화되며, 사용자들은 매일 이 30개의 크레딧을 통해 물건을 구매할 수 있다. 구현 편의성을 위해 이 서버는 물건 당 크레딧, 사용자 세부 정보 등의 정보는 다루지는 않는다고 가정한다.

그리하여 이 크레딧 서버가 제공해야 하는 API는 2개이다.

  1. 크레딧 차감
    • 사용자 아이디, 크레딧 사용량을 입력으로 받아 해당 사용자의 크레딧을 차감
  2. 크레딧 조회
    • 사용자 아이디를 입력으로 받아 해당 사용자의 당일 잔여 크레딧을 반환

지금부터 2가지 방식의 설계 하에 2가지 기능을 구현하고, 2개 설계의 성능을 비교해보고자 한다.

 

 

설계

버전 1

버전 1의 설계는 크레딧의 차감 내역만을 저장하고, 잔여 크레딧을 조회할 때 차감 내역과 차감 일자를 기반으로 당일 사용 크레딧을 계산하여 반환하는 설계다.

  • 장점
    • 데이터 구조가 단순
    • 매번 사용자의 잔여 크레딧을 갱신(update)해 줄 필요가 없음
    • 매일 자정에 크레딧을 갱신해줄 필요가 없음
  • 단점
    • 잔여 크레딧 조회에 DB 연산이 포함되어 DB에 부하를 줄 수 있음

버전 2

버전 2의 설계는 사용자별 잔여 크레딧, 크레딧 차감 내역을 모두 저장하는 설계다. 크레딧 차감 시에는 사용자별 크레딧 테이블을 갱신하고 크레딧 차감 내역 테이블에 새 데이터를 추가하며, 잔여 크레딧 조회 시 사용자별 크레딧 테이블을 조회하여 반환한다.

  • 장점
    • 조회 비용이 낮음
  • 단점
    • 버전 1에 비해 저장하는 데이터가 많음
    • 사용자별 크레딧 테이블은 잦은 갱신이 발생
    • 매일 자정에 모든 사용자의 크레딧을 충전하는 배치가 돌아야 함

 

 

성능 테스트 구성

사용 도구

  • 크레딧 애플리케이션 서버: Java 21
  • 크레딧 DB 서버: MySQL 9
  • 성능 테스트 도구: K6

서버별 자원 분배

각 서버는 도커 컨테이너 위에서 동작한다.

성능 테스트를 수행하는 주체는 12 Core CPU, 16 GB Memory를 가진 단일 머신으로, 각 컨테이너에 할당할 자원은 아래와 같다.

  DB Server Application Server  K6 Server Total
CPU Core 2 (0-1) 2 (2-3) 2 (4-5) 6
Memory 4 4 2 10

 

각 컨테이너에 자원을 격리해도 결국 호스트 OS의 것을 나눠쓰니 서로 영향을 주는 것이 아닌가?

  • CPU - 사용할 코어의 넘버를 별도로 할당함으로써 각 서버가 동일한 코어를 함께 사용하는 일을 예방
  • Memory - 메모리는 컨테이너별로 서로 다른 공간을 할당

호스트 OS의 백그라운드 프로세스 활동 정도에 따라 퍼포먼스에 차이가 날 수 있는 것이 아닌가?

  • 이 점은 마땅한 해결 방법을 찾지 못함
  • 일단 동시 실행 프로세스를 최소화하고 각 버전 당 3번정도 테스트한 뒤 평균값을 냄으로써 최대한 영향도를 낮추고자 함

k6 스크립트

해당 크레딧 서버의 피크 시간대 RPS는 초당 500으로 산정했다. 따라서, RPS 가 약 500이 될 수 있게 차감/조회 요청을 하는 k6 기반 스크립트를 작성했다.

  • deduct.js
import http from 'k6/http';
import {check, sleep} from 'k6';
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';

export const options = {
    vus: 100,
    duration: '2s'
};

export default () => {
    const userId = __VU;

    // POST 요청
    for (let i = 0; i < 10; i++) {
        const postPayload = JSON.stringify({
            userId: userId,
            credit: randomIntBetween(1, 3), // 1~3개의 크레딧 차감
        });
        const postHeaders = {'Content-Type': 'application/json'};
        const postRes = http.post('http://credit-server:8080/api/credits', postPayload, {headers: postHeaders});

        check(postRes, {
            'POST status is 204': (r) => r.status === 204,
        });

        sleep(1 / 5); // 요청 간 대기 시간 (각 유저는 초당 5회 요청을 함)
    }
}
  • get.js
import http from 'k6/http';
import {check, sleep} from 'k6';

export const options = {
    vus: 100,
    duration: '2s'
};

export default () => {
    const userId = __VU;

    for (let i = 0; i < 10; i++) {
        // GET 요청
        const getRes = http.get(`http://credit-server:8080/api/credits/${userId}`);

        check(getRes, {
            'GET status is 200': (r) => r.status === 200,
        });

        sleep(1 / 5); // 요청 간 대기 시간 (각 유저는 초당 5회 요청을 함)
    }
}

 

 

예상 결과

  • 생성 속도는 크게 차이나지 않을 것으로 예상 (update 연산 1회 차이이므로)
  • 조회 속도는 DB 연산이 없는 2번이 더 빠르고, 데이터가 많아질수록 격차가 커질 것으로 예상

 

 

결과 분석

크레딧 차감 API의 성능은 다음과 같다.

  v1 v2
1 77.98 88.54
2 70.86 89.92
3 66.33 92.23
avg (ms) 71.72 90.23

 

크레딧 조회 API의 성능은 다음과 같다.

  v1 v2
1 27.62 24.06
2 23.17 17.7
3 21.85 17.17
avg (ms) 24.21 19.64

버전 2의 설계가 1의 설계에 비해 생성 속도는 1.25배로 느린 반면 조회 속도는 0.8배로 더 빠른 것을 알 수 있다.

 

 

무엇이 더 좋은 설계인가?

당연하게도 정답은 없다. 위에서 실제 사용량을 고려해 산정한 RPS 500을 기준으로는 어떤 설계를 선택해도 부하가 될 정도는 아니니, 트래픽이 크지 않은 서비스라면 최대한 단순한 설계를 선택하는 것이 나을 수도 있다.

이 요구사항을 받은 나 역시 구현의 단순함이 가장 큰 이점이라고 느껴 1번 설계를 선택했고, 큰 부하 없이 정상적으로 서비스가 운영되고 있다.

그러나 트래픽이 아주 크고, 크레딧의 사용보다 조회가 훨씬 빈번하게 일어나는 서비스라면 2번 설계를 선택할 수도 있다. 추후 사용자가 늘어나 1번 설계로 운영하던 서비스를 2번 설계로 Re-Architecturing하는 것 또한 하나의 방법일 수 있다.