2 분 소요

스케줄 생성 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를 도입하는 것이 깔끔한 해결책이 될 것이다.

카테고리:

업데이트: