Projects/hub-eleven

[부하테스트] 재고 API - (1) k6로 테스트 해보기

annovation 2026. 7. 28. 22:37

재고 차감 성능 문제 발견

현재 재고 차감 로직은 분산락을 획득한 상태에서 다음 작업을 수행한다.

락 획득
-> 상품 조회
-> 재고 조회
-> 재고 수량 검증
-> 재고 차감
-> 트랜잭션 커밋
-> 응답 데이터 생성
-> 락 해제

 

재고 정합성을 위해 순차 처리가 필요한 핵심 구간은 재고 조회, 수량 검증, 차감 및 트랜잭션 커밋이다.

반면 상품명과 같은 상품 기본 정보 조회는 재고 수량의 정합성을 보장하기 위해 반드시 재고 락 안에서 실행할 필요는 없다.

따라서 상품 조회가 락 내부에 포함되면서 각 요청의 락 점유 시간이 길어지고, 뒤의 요청들이 락을 기다리는 시간도 함께 증가했을 가능성이 있다.

 

💡가설

재고 정합성과 직접 관계없는 상품 조회와 응답 생성을 락 밖으로 이동하면 락 점유 시간이 감소하고, 동일 상품에 대한 동시 요청의 락 대기 시간과 API 응답시간도 개선될 것이다.

 

다만 API 지연은 DB 커넥션 풀, SQL 실행, 애플리케이션 스레드 또는 로컬 실행 환경의 자원 부족 때문에 발생했을 가능성도 있다.

따라서 코드를 바로 수정하는 것이 아니라, 먼저 락 대기 시간과 점유 시간을 측정해 가설을 검증해야 한다.


어떻게 테스트할 것인가?

외부 API 성능은 k6로 측정하고, 애플리케이션 내부 락 구간은 Micrometer Timer로 측정한다.

측정 대상 측정 도구 측정 지표
전체 재고 차감 API k6 TPS, avg, p95, p99, 실패율
락 획득 대기 Micrometer Timer 평균, p95, 최대
락 점유 Micrometer Timer 평균, p95, 최대
재고 정합성 k6 + DB 조회 성공 요청 수와 실제 차감량 일치 여부

 

💡락 획득 대기 시간

최초 tryLock() 호출 직전
-> 락 획득 성공 또는 최종 타임아웃

 

락 획득 대기 시간은 앞 요청이 락을 해제할 때까지 현재 요청이 기다린 시간을 의미한다.

 

💡락 점유 시간

락 획득 성공
-> 재고 조회
-> 재고 차감
-> 트랜잭션 커밋
-> unlock() 완료

 

요청마다 로그를 출력하면 로그 I/O가 부하 테스트 결과를 왜곡할 수 있다.

따라서 Micrometer Timer를 사용해 요청 경로에서는 실행 시간을 메모리에 집계하고, 테스트 종료 후 집계된 결과만 확인한다.


왜 k6를 사용하는가?

단위 테스트와 동시성 테스트는 재고 수량이 정확하게 차감되는지 검증할 수 있지만, 요청이 증가할 때 응답시간과 처리량이 어떻게 변하는지는 확인하기 어렵다.

k6는 실제 HTTP 요청을 반복해서 전송하므로 Controller, 분산락, 트랜잭션, DB 처리와 응답 변환을 포함한 전체 API 성능을 측정할 수 있다.

  • 동일 상품에 동시 재고 차감 요청을 집중할 수 있다.
  • VU 수를 고정해 리팩토링 전·후를 동일한 조건으로 비교할 수 있다.
  • 평균뿐 아니라 p95, p99와 같은 꼬리 지연을 확인할 수 있다.
  • TPS와 실패 요청 수를 함께 확인할 수 있다.
  • 성공 요청 수와 실제 재고 차감량을 비교해 정합성도 검증할 수 있다.

하지만 k6는 애플리케이션 외부에서 HTTP 응답을 관찰하기 때문에 락 대기와 점유 시간을 직접 측정할 수 없다.

k6
-> 사용자가 체감하는 전체 API 성능 측정

Micrometer Timer
-> 애플리케이션 내부 락 구간 측정

 

따라서 두 측정 결과를 함께 사용해야 API가 느려진 현상과 내부 원인의 연관성을 설명할 수 있다.


왜 VU 200을 사용하는가?

explore 테스트에서 VU를 증가시켰을 때 VU 200 구간에서 처리량 증가가 정체되고 p95 응답시간이 크게 증가하는 현상이 확인됐다.

VU 증가
-> 동일 상품의 락 획득 요청 증가
-> 락 대기 요청 누적
-> p95 증가
-> TPS 증가 정체

 

VU 20 또는 VU 50에서는 락 대기가 충분히 발생하지 않아 락 내부 로직을 줄여도 개선 효과가 작게 나타날 수 있다.

따라서 병목이 드러난 VU 200을 고정 비교 지점으로 선택했다.

VU 200은 실제 사용자 200명을 의미하거나 서비스의 절대적인 처리 한계를 의미하지 않는다. 동일 상품에 요청을 집중시킨 리팩토링 전·후 비교 조건이다.

리팩토링 전·후 검증 절차

1. Micrometer Timer 적용

2. 리팩토링 전 VU 200 부하 실행
   - 락 대기 시간
   - 락 점유 시간

3. 측정 결과로 락 병목 가설 확인

4. 상품 조회 및 응답 생성을 락 밖으로 이동

5. 동시성 테스트로 재고 정합성 검증

6. 리팩토링 후 동일한 VU 200 부하 실행
   - k6 TPS, avg, p95, p99
   - 락 대기 시간
   - 락 점유 시간

7. 3회 측정 결과의 중앙값 비교

 

각 측정 전에는 Product 서비스를 재시작하고, 시드 SQL을 실행해 재고를 동일한 수량으로 초기화한다.

  • 동일한 VU와 실행 시간 사용
  • 동일한 MySQL 및 Redis 설정 사용
  • 동일한 상품 ID와 초기 재고 사용
  • 다른 서비스와 테스트를 동시에 실행하지 않음
  • 한 번의 좋은 결과를 선택하지 않고 3회 중앙값 사용

k6 스크립트 구현

k6는 크게 두 가지 방식으로 부하를 만들 수 있다.

 

💡Closed Model (vus / ramping-vus executor)

  • 이전 Iteration이 끝나야 다음 Iteration이 시작되는 모델
    (Iteration : Virtual User(VU)가 default() 함수를 한 번 처음부터 끝까지 실행하는 것을 의미)
"The next iteration doesn't start until the previous one finishes."
  • 흐름 예시
VU 1 -> HTTP 요청 -> 응답 대기 -> 응답 완료 -> 다음 요청 시작
  • 새로운 요청 시작 시점이 이전 요청이 언제 끝났는지에 의존한다.
  • 코드 예시
예시

 

❗️문제점

  • 부하가 없는 정상적인 서버의 응답시간이 100ms 라고 가정했을 때, 1초동안 약 10번의 요청을 보낼 수 있다.
  • 그런데 부하로 인해 서버가 느려지고 응답시간이 2초가 되었지만, closed model 은 응답을 받아야 VU가 다음 요청을 보낼 수 있기 때문에 오히려 부하가 줄어드는 현상이 생긴다.
  • 이 현상을 Coordinated Omission 이라고 한다.
"Slower response times means longer iterations and a lower arrival rate."
응답이 느려질수록 요청 도착률(Arrival Rate)도 함께 줄어드는 문제
  • 흐름 예시
응답시간

100ms

↓

1초에

10번 요청

----------------

응답시간

2초

↓

2초에

1번 요청

 

💡Open Model (constant-arrival-rate / ramping-arrival-rate executor)

  • Closed Model의 문제를 해결하기 위해 나온 모델
  • 핵심은, 응답이 끝났는지와 관계없이 새로운 요청을 시작한다는 것
"VU iterations arrive independently of iteration completion."
  • 흐름 예시
1초 -> 무조건 요청 시작 -> 응답 기다리지 않음 -> 2초 -> 또 요청 시작
  • 코드 예시
예시

 

❗️왜 Open Model이 필요할까?

  • 실제 서비스는 사용자가 응답이 느리다고 새로운 사용자가 안 들어오는 것은 아니다.
  • 예를 들어, 네이버에서 
  • 따라서, Open Model 이 현실 트래픽을 더 잘 모방한다고 할 수 있다.

💡비교

항목 Closed Model Open Model
요청 시작 기준 이전 Iteration 종료 후 시작 정해진 시간마다 시작
응답시간 영향 받음 받지 않음
부하량 응답이 느려질수록 감소 일정하게 유지
현실 트래픽 재현 상대적으로 낮음 높음
대표 Executor constant-vus, ramping-vus constant-arrival-rate, ramping-arrival-rate
  • Open : 이 API의 최대 지속 가능 처리량(천장)은 어디인지 = 한계점 찾기(capacity test)
  • Closed : 한계 안에서 동시성이 오를 때 어떻게 저하되고, 리팩토링으로 얼마나 개선됐는지 = 저하 특성 + 전후 비교

프로젝트에 적용된 파일 구조

product/load-test/
├── README.md
├── docs/
│   ├── scenario1-lock-design.md
│   └── test-environment-isolation-design.md
├── results/
│   └── baseline-summary.json
├── scripts/
│   └── scenario1-stock-decrease-lock.js
└── seed/
    └── seed-scenario1-lock.sql

 

💡README.md

  • 부하 테스트 환경 구성 방법을 기록한다.
  • MySQL, Redis, Eureka, Config Server, Product 서비스 실행 순서를 설명한다.
  • 시드 SQL과 k6 스크립트 실행 방법을 제공한다.
  • 리팩토링 전·후 동일하게 적용해야 하는 측정 절차를 기록한다.

 

💡docs/scenario1-lock-design.md

  • 동일 상품에 재고 차감 요청을 집중시키는 테스트 시나리오를 설명한다.
  • explore 모드와 compare 모드를 분리한 이유를 기록한다.
  • 측정할 k6 지표와 재고 정합성 검증 기준을 정의한다.
  • VU 200을 리팩토링 전·후 고정 비교 지점으로 선택한 근거를 기록한다.

 

💡docs/test-environment-isolation-design.md

  • 개발, Spring Boot 테스트, k6 부하 테스트의 데이터 저장 공간을 분리한 이유를 설명한다.
  • MySQL 스키마와 Redis 논리 DB를 환경별로 분리한 구조를 기록한다.
  • 별도 컨테이너 대신 논리적 격리를 선택한 근거와 한계를 정리한다.
개발 환경
├── MySQL: hubeleven
└── Redis: DB 0

Spring Boot 테스트
├── MySQL: hubeleven_test
└── Redis: DB 1

k6 부하 테스트
├── MySQL: hubeleven_loadtest
└── Redis: DB 2

 

💡results/baseline-summary.json

  • VU 200 compare 테스트를 3회 실행한 결과를 저장한다.
  • 각 실행의 TPS, 평균 응답시간, p95, p99를 기록한다.
  • 한 번의 결과를 선택하지 않고 3회 측정값의 중앙값을 공식 baseline으로 사용한다.
  • 성공 요청 수와 실제 재고 차감량이 일치했는지 기록한다.

 

💡scripts/scenario1-stock-decrease-lock.js

  • 동일 상품 ID에 재고 차감 요청을 집중시키는 k6 스크립트다.
  • Closed Model의 constant-vus executor를 사용한다.
  • explore 모드에서는 VU 20, 50, 100, 200 구간을 순차적으로 실행한다.
  • compare 모드에서는 선택한 VU를 고정해 리팩토링 전·후를 비교한다.
  • 성공, 락 타임아웃, 예상하지 못한 실패를 별도 메트릭으로 집계한다.
  • 테스트 전후 재고를 조회해 성공 요청 수와 실제 차감량을 비교한다.
explore
-> VU 20
-> VU 50
-> VU 100
-> VU 200
-> 병목이 나타나는 비교 지점 탐색

compare
-> VU 200 고정
-> 1분 측정
-> 리팩토링 전·후 동일 조건 비교

 

💡seed/seed-scenario1-lock.sql

  • 재고 차감 부하 테스트에 사용할 상품과 재고 데이터를 생성한다.
  • 고정 UUID를 사용해 k6 스크립트가 항상 동일한 상품을 요청하도록 한다.
  • 테스트 중 재고가 고갈되지 않도록 충분한 초기 재고를 설정한다.
  • 기존 테스트 데이터를 삭제한 후 다시 삽입해 여러 번 실행해도 같은 초기 상태를 만든다.
  • 개발 데이터와 분리된 hubeleven_loadtest 스키마만 사용한다.
현재는 재고 차감 락 테스트인 시나리오 1만 구현되어 있다. 조회 쿼리 및 Redis Cache 성능 테스트인 시나리오 2는 이후 별도 스크립트와 시드 데이터로 추가할 예정이다.

참고 자료

1) Grafana Docs : Open and closed models

https://grafana.com/docs/k6/latest/using-k6/scenarios/concepts/open-vs-closed/

 

Open and closed models | Grafana k6 documentation

Open and closed models Different k6 executors have different ways of scheduling VUs. Some executors use the closed model, while the arrival-rate executors use the open model. In short, in the closed model, VU iterations start only when the last iteration f

grafana.com