Project : RainMind 개발일지 - 12 [1번째 최적화 수행 기록(수치화) - 2nd : 스케줄 생성]
스케줄 생성 API 테스트시 사용한 스크립트와 PromQL 쿼리, 데이터 수치화를 기록한다.
스케줄 생성 기능
-
문제 제시: ScheduleService.kt의 scheduleRepository.findAllByUserId(user.id!!).size 부분은, JVM 힙 메모리에 엔티티 객체들을 모두 들고오므로 메모리 사용량 측면과 응답 시간(가비지 콜렉터의 stop the world로 인해 비즈니스 로직이 느려질 수 있다) 측면에서 불리하다.
-
해결: scheduleRepository.countByUserId(user.id!!)로 힙 메모리에 객체들을 모두 들고오지 않고, 개수만 반환하는 함수를 사용한다.
-
시나리오: 테스트 시, 각 200명의 유저 당 1000개의 스케줄을 미리 할당해 둔 후, 각 요청마다 엔티티 객체들을 모두 가져오는 스크립트를 구성하여 전후 변화를 관찰한다. 또한 SELECT 문법과 INSERT 문법을 조합한 native query 방식에서도 속도의 변화를 관찰한다.
-
사용 스크립트:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
steady_800rps: {
executor: 'constant-arrival-rate',
rate: 400,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 200,
maxVUs: 200,
},
},
setupTimeout: '3m',
};
const BASE_URL = __ENV.BASE_URL;
const PASSWORD = 'password12345678';
export function setup() {
const tokens = [];
for (let i = 0; i < 200; i++) {
const res = http.post(`${BASE_URL}/v1/auth/user/login`,
JSON.stringify({ nickname: `seed_${i}`, password: PASSWORD }),
{ headers: { 'Content-Type': 'application/json' } }
);
tokens.push(res.json('token'));
}
return { tokens };
}
export default function (data) {
const userIdx = __ITER % 200;
const token = data.tokens[userIdx];
const startTime = Date.now() + (__ITER * 1000);
const res = http.post(
`${BASE_URL}/v1/schedules`,
JSON.stringify({
title: `s_${userIdx}_${__ITER}`,
locationId: 1,
startAt: new Date(startTime).toISOString(),
endAt: new Date(startTime + 1800000).toISOString(),
}),
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
timeout: '30s',
}
);
check(res, { '201': (r) => r.status === 201 });
check(res, { '401': (r) => r.status === 401 });
check(res, { '406': (r) => r.status === 406 });
}
- 측정 지표:
- (1) 응답 시간(평균, p(90), p(95), max) 및 RPS
- (2-1) sum(jvm_memory_used_bytes{job=”rainmind-app”, area=”heap”}) / 1000 / 1000 JVM 힙 메모리 사용량
- (2-2) 100 * sum(jvm_memory_used_bytes{job=”rainmind-app”, area=”heap”}) / sum(jvm_memory_max_bytes{job=”rainmind-app”, area=”heap”}) JVM 힙 메모리 사용률
- (2-3) rate(jvm_gc_pause_seconds_sum{job=”rainmind-app”}[1m]) * 1000 JVM Stop the world 시간
- (3) hikaricp_connections_active{job=”rainmind-app”, instance=”app:8080”} DB 커넥션 풀 사용량
- (4) container_memory_working_set_bytes{container_label_com_docker_compose_service=”app”} / 1000 / 1000 어플리케이션 총 메모리 사용량
- (5) sum(rate(container_cpu_usage_seconds_total{container_label_com_docker_compose_service=”app”}[1m])) CPU 사용량
(결과 - findAllByUserId() 사용시(전))
| avg | p(90) | p(95) | max | RPS(req / s) | JVM Heap Memory(MB) | JVM Heap Memory(%) | JVM Stop the world | DB 커넥션 풀 | App Total Memory(MB) | CPU Total |
|---|---|---|---|---|---|---|---|---|---|---|
| 1.6s | 2.75s | 3.33s | 12.87s | 123.596799 | 359.12 | 17.61 | 36.25 | 44.48 | 1002.95 | 1.28 |
(결과 - countByUserId() 사용시(후))
| avg | p(90) | p(95) | max | RPS(req / s) | JVM Heap Memory(MB) | JVM Heap Memory(%) | JVM Stop the world | DB 커넥션 풀 | App Total Memory(MB) | CPU Total |
|---|---|---|---|---|---|---|---|---|---|---|
| 104.24ms | 497.11ms | 759.8ms | 2.66s | 387.704068 | 171.05 | 8.39 | 4.78 | 1.95 | 589.60 | 0.79 |
모든 부문에서 수치가 전반적으로 크게 개선되었다.
특이사항: race condition 발생 가능
(결과 - native query + SELECT LAST_INSERT_ID() 사용시(후))
| avg | p(90) | p(95) | max | RPS(req / s) | JVM Heap Memory(MB) | JVM Heap Memory(%) | JVM Stop the world | DB 커넥션 풀 | App Total Memory(MB) | CPU Total |
|---|---|---|---|---|---|---|---|---|---|---|
| 17.2ms | 9.25ms | 72.25ms | 1.82s | 398.995026 | 147.97 | 7.41 | 3.68 | 0.62 | 596 | 0.55 |
countByUserId()와 비교하여, 수치가 또 한번 전반적으로 개선되었다.
- 왜 속도가 개선되었을까? SELECT LAST_INSERT_ID()를 이용하여, 멀티 스레드 환경에서도 가장 마지막에 삽입한 entity의 id를 안전하게 가져올 수 있게 하였으므로 별도의 .save() 과정이 필요없다. 즉 DB에 1번만 접근하면 된다. 또한 JPA 환경에서는, 엔티티의 변경 사항을 캡쳐하는 등 dirty checking 또한 발생 가능하다(현재는 JDBC라 해당 사항은 제외).
특이사항: race condition 발생 가능. 내부의 SELECT문과 외부의 INSERT 문이 원자적 실행이 보장되지 않는다. race condition은 발생하지 않는다. 다만 deadlock에 취약한 부분이 있고, 현재 사용하는 락 매커니즘이 DB의 종류에 의존하고 있기에 다른 종류의 DB를 사용했을 때 추가적인 로직 수정이 필요할 것이다, deadlock 발생에 따른 Error 발생으로 인한 재시도 로직/혹은 에러 메시지 출력을 따로 구축해야 하는 추가적인 과제가 발생한다(2026/02/23 수정).
따라서 이전 포스팅에서 기록했던 비관적 락 도입 또는 User 테이블 스키마 수정(유저당 현재 스케줄 수 column 추가 등) 후 Atomic Update를 도입하는 것이 깔끔한 해결책이 될 것이다.