개념
도구가 아니라 부하가 틀린다
모듈 16 레슨 3에서 k6 스크립트를 처음 썼을 때, 대개 이런 모양이었다.
export const options = { vus: 50, duration: '30s' };
export default function () {
http.get('https://test.k6.io/');
sleep(1);
}이 스크립트는 동작하는 부하 테스트 지만, 이것으로 "우리 서비스가 다음 주 프로모션을 견딘다"를 말할 수는 없다. 이유는 세 가지다.
- 실제 사용자는 한 가지 요청만 반복하지 않는다. 조회·검색·장바구니·결제가 섞여 있고, 그 비율은 시간대마다 다르다.
- 실제 사용자 수는 30초 동안 50명으로 고정되지 않는다. 늘어나고, 튀고, 오래 유지된다.
- 실제 사용자는 응답이 느려져도 기다려 주지 않는다 — 새 사용자는 앞 사용자의 응답과 무관하게 계속 들어온다.
워크로드 모델(workload model) 은 이 세 가지를 문서로 고정한 것이다. 성능 테스트의 첫 산출물은 스크립트가 아니라 이 한 장이다. 스크립트는 이 문서를 도구 언어로 옮긴 결과일 뿐이다.
워크로드 모델의 네 가지 구성요소
| 구성요소 | 답해야 할 질문 | 근거 데이터 |
|---|---|---|
| 사용자 유형 | 누가 들어오는가 (신규·기존·봇·내부 배치) | 로그인 로그, User-Agent, 세션 분석 |
| 트랜잭션 믹스 | 어떤 시나리오가 몇 퍼센트인가 | 액세스 로그 경로별 집계 |
| 도착률·동시성 | 단위 시간당 몇 건이 들어오는가 | 시간대별 요청 수, 동시 세션 수 |
| 부하 모양 | 시간에 따라 어떻게 변하는가 | 일·주·이벤트 단위 트래픽 그래프 |
여기에 데이터 프로파일 이 하나 더 붙는다. 같은 GET /orders/{id} 라도 주문 1건짜리 계정과 3,000건짜리 계정은 다른 쿼리다. 이건 뒤에서 따로 다룬다.
로그에서 트랜잭션 믹스를 뽑는다
가장 먼저 할 일은 추측을 지우는 것이다. 기획자·개발자에게 "어떤 기능을 많이 쓰나요"라고 묻지 말고 로그를 센다. nginx 액세스 로그가 기본 포맷이라면 이 정도로 충분하다.
# 어제 피크 1시간(19:00~19:59)의 경로별 요청 수 상위 15개
awk '$4 ~ /24\/Sep\/2026:19:/ {print $6, $7}' access.log \
| sed -E 's/\?.*//; s#/orders/[0-9]+#/orders/{id}#; s#/products/[0-9]+#/products/{id}#' \
| sort | uniq -c | sort -rn | head -15sed 로 경로의 숫자 ID를 {id} 로 치환하는 단계가 핵심이다. 이걸 빼면 /orders/10231 이 1건씩 수천 줄로 흩어져서 비율이 보이지 않는다. 결과는 이런 표가 된다.
48210 GET /api/products
13904 GET /api/products/{id}
11022 GET /api/search
6180 POST /api/cart/items
2431 GET /api/cart
1205 POST /api/orders
980 GET /api/orders/{id}
412 POST /api/auth/login여기서 곧바로 퍼센트를 계산하면 안 된다. 요청(request) 비율과 시나리오(business transaction) 비율은 다르다. 사용자가 주문을 한 번 완료하려면 장바구니 조회 → 결제 페이지 → 주문 생성 → 주문 상세 확인이 함께 일어난다. 요청 단위로 세면 POST /api/orders 는 1.4%지만, 시나리오 단위로는 "주문 완료 흐름"이 전체의 5%일 수 있다.
그래서 요청을 시나리오로 묶은 뒤 각 시나리오의 시작 요청(entry point) 건수로 센다.
| 시나리오 | 시작 요청 | 피크 1시간 건수 | 비율 |
|---|---|---|---|
| 상품 탐색 | GET /api/products | 48,210 | 71.6% |
| 검색 | GET /api/search | 11,022 | 16.4% |
| 장바구니 담기 | POST /api/cart/items | 6,180 | 9.2% |
| 주문 완료 | POST /api/orders | 1,205 | 1.8% |
| 로그인 | POST /api/auth/login | 412 | 0.6% |
| 합계 | 67,029 | 100% |
이 표가 워크로드 모델의 절반이다. 대략 7 : 2 : 1 (탐색 : 검색 : 쓰기) 이라는 모양이 잡히면 스크립트의 시나리오 가중치가 정해진다.
도착률(arrival rate)을 시간대별로 본다
비율 다음은 절대량이다. 시간대별 요청 수를 뽑아 평균과 피크의 비율 을 본다.
# 하루치 시간대별 요청 수
awk -F'[:[]' '{print $3":00"}' access.log | sort | uniq -c 1820 00:00 12410 12:00 31204 18:00
1104 01:00 10880 13:00 67029 19:00 ← 피크
902 02:00 9950 14:00 52180 20:00
... 11200 15:00 33900 21:00
8930 09:00 13800 16:00 18420 22:00
11450 10:00 19870 17:00 6210 23:00여기서 두 숫자를 계산한다.
- 피크 도착률 = 67,029 ÷ 3,600 = 초당 18.6건
- 피크 대 평균 비율(peak-to-average ratio) = 67,029 ÷ (일 총합 ÷ 24). 일 총합이 393,000이라면 평균은 16,375, 비율은 4.1배
피크 대 평균 비율은 용량 산정(레슨 2)에서 그대로 쓰인다. 그리고 한 단계 더 짧은 창으로도 본다. 1시간 평균은 1분 피크를 숨긴다. 푸시 알림을 19:30에 보냈다면 19:30~19:31의 도착률이 초당 120건일 수 있다. 1분 단위 집계를 반드시 함께 본다.
awk -F'[:[]' '{print $3":"$4}' access.log | sort | uniq -c | sort -rn | head -5닫힌 모델과 열린 모델 — 이 선택이 결과를 바꾼다
여기가 이 레슨에서 가장 중요한 대목이다. 부하 생성기는 두 가지 방식 중 하나로 동작한다.
| 닫힌 모델(closed) | 열린 모델(open) | |
|---|---|---|
| 조절 대상 | 동시 사용자 수(VU) | 초당 도착 건수(arrival rate) |
| 다음 요청 시점 | 이전 응답을 받은 뒤 + think time | 응답과 무관하게 일정 비율로 |
| 서버가 느려지면 | 부하가 자동으로 줄어든다 | 부하가 그대로 쌓인다 |
| 대응하는 현실 | 콜센터 상담원 N명, 내부 업무 시스템 | 공개 웹/API, 푸시 알림 직후 |
| k6 실행기 | constant-vus, ramping-vus | constant-arrival-rate, ramping-arrival-rate |
| JMeter | 기본 스레드 그룹 | Concurrency/Arrivals Thread Group, Throughput Shaping Timer |
닫힌 모델의 함정이 실무 사고의 단골 원인이다. VU 100명으로 테스트했는데 응답이 2초로 느려지면, 그 VU들은 2초를 기다리느라 다음 요청을 못 보낸다. 부하 생성기가 스스로 브레이크를 밟는 것이다. 결과적으로 서버는 자신이 감당할 수 있는 만큼만 부하를 받고, 리포트에는 "TPS 50에서 안정"이라고 찍힌다. 실제 운영에서는 사용자가 기다려 주지 않고 새 요청이 계속 들어와 큐가 무너진다.
모듈 31 레슨 4에서 다룬 coordinated omission 이 바로 이 구조의 측정 왜곡판이다. 거기서는 "측정기가 느린 응답을 덜 센다"는 통계 문제로 봤고, 여기서는 "부하 생성기가 부하를 덜 준다"는 워크로드 문제로 본다. 같은 뿌리다.
판단 기준은 간단하다.
- 검증 질문이 "사용자 N명이 동시에 쓰면 어떤가" → 닫힌 모델
- 검증 질문이 "초당 N건이 들어오면 버티는가" → 열린 모델
- 공개 서비스의 용량 한계를 찾는 테스트는 거의 항상 열린 모델 이다
think time과 pacing을 근거 있게 정한다
닫힌 모델을 쓸 때 sleep(1) 을 그대로 두면 안 된다. think time은 사용자가 화면을 읽고 다음 동작을 하기까지의 시간 이고, 이것 역시 로그에서 얻는다. 같은 세션 ID의 연속 요청 간격 중앙값을 보면 된다.
| 화면 | 관측 간격 중앙값 | 테스트에 쓸 값 |
|---|---|---|
| 상품 목록 → 상세 | 4.2초 | sleep(randomBetween(2, 7)) |
| 상세 → 장바구니 | 11.5초 | sleep(randomBetween(5, 20)) |
| 장바구니 → 결제 | 38초 | 시나리오 분리 권장 |
고정값(sleep(1))은 모든 VU가 같은 박자로 움직이게 만들어 인위적인 동기화 파동 을 만든다. 실제 트래픽은 그렇게 생기지 않는다. 항상 범위를 두고 무작위로 뽑는다.
pacing 은 다른 개념이다. think time이 "요청 사이 쉬는 시간"이라면, pacing은 "한 시나리오를 몇 초에 한 번 반복할 것인가" 다. "사용자 1명이 5분에 한 번 주문 흐름을 돈다"를 재현하려면 시나리오 전체 소요와 무관하게 300초 주기를 맞춰야 한다.
부하의 모양 네 가지를 k6로 구현한다
모듈 16 레슨 2에서 부하·스트레스·스파이크·내구 테스트의 목적 차이 를 배웠다. 여기서는 그것을 실행 가능한 설정으로 옮긴다. 아래는 하나의 k6 스크립트 안에 네 시나리오를 정의하고 --env SHAPE=... 로 골라 실행하는 형태다.
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
const browseLatency = new Trend('browse_latency', true);
const BASE = __ENV.BASE_URL || 'https://shop.example.com';
// --- 부하 모양 정의: 모두 열린 모델(arrival rate) ---
const shapes = {
// 1) 정상 부하 — 피크 도착률 18.6 rps 를 30분 유지
normal: {
executor: 'constant-arrival-rate',
rate: 19, timeUnit: '1s', duration: '30m',
preAllocatedVUs: 60, maxVUs: 200,
},
// 2) 피크 부하 — 평시에서 피크까지 서서히 올려 한계 탐색
peak: {
executor: 'ramping-arrival-rate',
startRate: 5, timeUnit: '1s',
preAllocatedVUs: 100, maxVUs: 800,
stages: [
{ target: 19, duration: '10m' }, // 평시 → 관측된 피크
{ target: 38, duration: '10m' }, // 피크의 2배
{ target: 60, duration: '10m' }, // 한계 탐색
{ target: 0, duration: '2m' },
],
},
// 3) 스파이크 — 푸시 알림 직후 1분(관측된 1분 피크 120 rps)
spike: {
executor: 'ramping-arrival-rate',
startRate: 19, timeUnit: '1s',
preAllocatedVUs: 200, maxVUs: 2000,
stages: [
{ target: 19, duration: '3m' },
{ target: 120, duration: '20s' }, // 급상승
{ target: 120, duration: '1m' },
{ target: 19, duration: '30s' }, // 급하강
{ target: 19, duration: '10m' }, // 회복 관찰이 진짜 목적
],
},
// 4) 내구 — 평시의 70% 를 8시간. 메모리 누수·커넥션 누수 관찰
soak: {
executor: 'constant-arrival-rate',
rate: 13, timeUnit: '1s', duration: '8h',
preAllocatedVUs: 60, maxVUs: 300,
},
};
export const options = {
scenarios: { main: shapes[__ENV.SHAPE || 'normal'] },
thresholds: {
'http_req_failed': ['rate<0.005'],
'http_req_duration{scenario:main}': ['p(95)<800', 'p(99)<2000'],
'browse_latency': ['p(95)<600'],
},
};
// --- 트랜잭션 믹스: 로그에서 뽑은 7 : 2 : 1 ---
export default function () {
const dice = Math.random() * 100;
if (dice < 71.6) browse();
else if (dice < 88.0) search();
else if (dice < 97.2) addToCart();
else checkout();
}
function browse() {
const res = http.get(`${BASE}/api/products?page=${1 + Math.floor(Math.random() * 20)}`,
{ tags: { txn: 'browse' } });
check(res, { 'browse 200': (r) => r.status === 200 });
browseLatency.add(res.timings.duration);
sleep(randomBetween(2, 7)); // 관측 중앙값 4.2초
}
function search() {
const kw = ['운동화', '가디건', '텀블러', '무선이어폰'][Math.floor(Math.random() * 4)];
http.get(`${BASE}/api/search?q=${encodeURIComponent(kw)}`, { tags: { txn: 'search' } });
sleep(randomBetween(3, 9));
}
function addToCart() {
http.post(`${BASE}/api/cart/items`, JSON.stringify({ productId: pickProductId(), qty: 1 }),
{ headers: { 'Content-Type': 'application/json' }, tags: { txn: 'cart' } });
sleep(randomBetween(5, 20));
}
function checkout() {
const res = http.post(`${BASE}/api/orders`, JSON.stringify({ cartId: 'c-' + __VU }),
{ headers: { 'Content-Type': 'application/json' }, tags: { txn: 'checkout' } });
check(res, { 'order created': (r) => r.status === 201 });
sleep(randomBetween(2, 5));
}
function randomBetween(a, b) { return a + Math.random() * (b - a); }
// 데이터 왜곡 재현: 상위 20% 상품이 조회의 80% 를 차지
function pickProductId() {
const hot = Math.random() < 0.8;
return hot ? 1 + Math.floor(Math.random() * 200)
: 201 + Math.floor(Math.random() * 800);
}k6 run --env SHAPE=peak --env BASE_URL=https://staging.shop.example.com workload.js이 스크립트에서 눈여겨볼 다섯 가지가 이 레슨의 요약이다.
- 모두 arrival-rate 실행기 다 — 열린 모델을 기본으로 삼았다.
- rate 값이 로그에서 나왔다 — 19는 관측 피크 18.6을 올림한 값이고, 120은 1분 피크다.
- 시나리오 분기 비율이 로그 비율과 같다 — 71.6 / 16.4 / 9.2 / 1.8.
- think time에 범위가 있다 — 관측 중앙값을 중심으로 흔들린다.
- 스파이크 시나리오의 진짜 목적은 마지막 10분 이다. 튀는 동안의 오류율보다 튄 뒤에 원래대로 돌아오는가 가 판정 대상이다.
JMeter·Gatling으로 같은 모양 만들기
도구를 바꿔도 개념은 그대로다. JMeter 기본 스레드 그룹은 닫힌 모델이므로, 열린 모델을 쓰려면 플러그인이 필요하다.
JMeter (열린 모델)
Concurrency Thread Group 또는
기본 Thread Group + Throughput Shaping Timer (plugins-manager 로 설치)
- "start 5 rps, end 19 rps in 600s"
- "hold 19 rps for 600s"
- "start 19 rps, end 60 rps in 600s"
Throughput Controller 로 시나리오 비율 71.6 / 16.4 / 9.2 / 1.8 분배
비GUI 실행 (부하 테스트는 항상 비GUI)
jmeter -n -t workload.jmx -l result.jtl -e -o report/Gatling은 injection 프로파일이 언어 DSL로 표현된다. constantUsersPerSec · rampUsersPerSec 가 열린 모델이고, atOnceUsers 뒤의 maxDuration 조합이 닫힌 모델에 가깝다.
setUp(
browse.injectOpen(
constantUsersPerSec(13.6).during(Duration.ofMinutes(10)), // 71.6% of 19rps
rampUsersPerSec(13.6).to(43.0).during(Duration.ofMinutes(10))
),
checkout.injectOpen(
constantUsersPerSec(0.34).during(Duration.ofMinutes(20)) // 1.8% of 19rps
)
).protocols(httpProtocol)
.assertions(
global().responseTime().percentile4().lt(2000), // p99 < 2s
global().failedRequests().percent().lt(0.5)
);데이터 프로파일 — 같은 요청도 같은 비용이 아니다
워크로드 모델에서 가장 자주 빠지는 항목이다. 세 가지를 확인한다.
| 항목 | 빠뜨리면 생기는 일 | 재현 방법 |
|---|---|---|
| 데이터 왜곡(skew) | 테스트 계정은 주문 3건, 실사용 계정은 3,000건 → 쿼리 비용이 다름 | 실운영 분포를 흉내 낸 시드 데이터 |
| 캐시 히트율 | 같은 ID만 조회해 히트율 99% → 운영(70%)보다 훨씬 빠르게 측정 | 핫/콜드 비율을 코드로 분배(위 pickProductId) |
| 데이터 증가 | 테이블이 빈 상태로 테스트 → 인덱스 없이도 빠름 | 운영 규모의 행 수를 채운 뒤 측정 |
캐시 히트율이 특히 조용한 왜곡 원인이다. 부하 테스트는 같은 URL을 반복하므로 자연히 히트율이 높아진다. 운영 히트율이 70%인데 테스트가 99%라면, 그 테스트는 DB 부하의 1/10만 재현한 것이다. 운영 히트율을 먼저 확인하고, 그 값에 맞춰 핫/콜드 요청 비율을 코드에서 강제한다.
워크로드 모델 문서 한 장
마지막으로 위 내용을 한 장으로 고정한다. 이 문서가 없으면 테스트가 끝난 뒤 "이 결과가 어떤 상황을 가정한 건데?"라는 질문에 답할 수 없다.
워크로드 모델 v1.2 — 커머스 웹/API (2026-09-24)
────────────────────────────────────────────
근거 데이터 : 2026-09-17 ~ 09-23 nginx access.log, APM 트랜잭션 통계
대상 시각 : 평일 19:00~19:59 (주간 최대 트래픽 구간)
[트랜잭션 믹스] 탐색 71.6% / 검색 16.4% / 장바구니 9.2% / 주문 1.8% / 로그인 0.6%
[도착률] 1시간 평균 18.6 rps, 1분 최대 121 rps (09-20 19:31 푸시 발송 직후)
[피크/평균] 4.1배
[부하 모델] 열린 모델(arrival rate). 근거: 공개 API, 재시도 있음
[think time] 목록→상세 2~7s, 상세→장바구니 5~20s (세션 간격 중앙값 기준)
[데이터 프로파일] 상품 조회 80%가 상위 200개 SKU, 운영 캐시 히트율 72%,
주문 이력 분포 p50=4건 / p95=180건 / p99=2,900건
[제외] 내부 배치(02:00), 크롤러 UA 9종 — 별도 시나리오로 분리
[유효기간] 분기 1회 재집계. 프로모션 정책 변경 시 즉시 갱신[제외] 항목을 반드시 적는다. 크롤러와 헬스체크를 빼지 않고 집계하면 도착률이 부풀려지고, 반대로 내부 배치를 빼고 용량을 잡으면 새벽 장애를 못 본다. 무엇을 뺐는지가 적혀 있어야 다음 사람이 판단할 수 있다.
실무에서 왜 필요한가
성능 테스트가 통과했는데 운영에서 장애가 나는 상황은 거의 언제나 워크로드 불일치에서 온다. 세 가지 전형이 있다.
첫째, 모양이 달랐다. 30분 동안 서서히 올리는 테스트는 통과하지만, 푸시 알림으로 20초 만에 6배가 들어오면 커넥션 풀이 먼저 마른다. 스파이크는 총량이 아니라 기울기 가 문제다.
둘째, 믹스가 달랐다. 조회 위주로 테스트하고 쓰기 비율을 낮게 잡으면 DB 락 경합과 복제 지연을 못 본다. 쓰기 1.8%라는 작은 비율도 절대량이 초당 0.34건에서 2.2건으로 뛰면 전혀 다른 그림이 된다.
셋째, 데이터가 달랐다. 스테이징 DB의 주문 테이블이 5만 행이고 운영이 4,200만 행이면, 같은 쿼리가 같은 계획으로 돌지 않는다. 이건 튜닝으로 해결할 문제가 아니라 워크로드 문서에 적어 둘 전제다.
워크로드 모델은 또한 협상 도구 다. "성능 테스트를 언제까지 해야 하나요"라는 질문에 "피크 도착률 19 rps와 1분 스파이크 121 rps 두 모양을 각각 통과할 때까지"라고 답할 수 있으면 일정이 근거를 갖는다. 반대로 모델이 없으면 성능 테스트는 끝이 없거나, 아무 때나 끝난다.
채용시장에서의 위치
경력기술서에 "k6 사용 경험"이라고 쓰면 도구 사용자로 읽히고, "운영 로그 기반 워크로드 모델 수립 후 부하 시나리오 설계"라고 쓰면 설계자로 읽힌다. 실제 면접에서도 "부하를 어떻게 정했나요"라는 질문이 거의 반드시 따라온다. 이때 열린 모델과 닫힌 모델을 구분해 답하면 성능 테스트를 제대로 해 본 사람이라는 신호가 된다.