요구사항 - 크레딧 서버 개발
어느 날, 물건 판매 개발이 포함된 서비스의 개발자인 나에게 사용자의 크레딧을 관리할 수 있는 크레딧 서버의 개발 및 요청이 들어왔다.
이 크레딧은 매일 자정에 30으로 초기화되며, 사용자들은 매일 이 30개의 크레딧을 통해 물건을 구매할 수 있다. 구현 편의성을 위해 이 서버는 물건 당 크레딧, 사용자 세부 정보 등의 정보는 다루지는 않는다고 가정한다.
그리하여 이 크레딧 서버가 제공해야 하는 API는 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하는 것 또한 하나의 방법일 수 있다.