부하 테스트 보고서의 P99가 1ms인데 운영에서는 수 초짜리 응답이 나온다면, 측정 도구가 느린 순간을 통째로 빼먹었을 가능성이 크다. 응답을 기다리는 동안 다음 요청을 보내지 않는 부하 도구는 서버가 멈춘 구간을 표본 한 건으로 줄여 기록하기 때문이다. 이 글은 이 현상(Coordinated Omission)이 생기는 원리, 숫자로 본 착시의 크기, 그리고 도구별로 피하는 설정과 보고서를 검산하는 방법을 정리한다.

한눈에 보기
| 구분 | 닫힌 모델(응답을 기다린 뒤 다음 요청) | 열린 모델(예정 시각에 요청) |
|---|---|---|
| 요청을 보내는 시점 | 이전 응답이 온 뒤 | 정해진 도착률에 따라 |
| 서버가 10초 멈추면 | 그동안 요청을 거의 보내지 않는다 | 예정된 요청을 계속 보내고, 요청이 쌓인다 |
| 멈춘 구간의 기록 | 느린 표본 1건 | 예정된 요청마다 1건씩 |
| 응답 시간을 재는 기준 | 실제로 보낸 시각 | 보내기로 예정된 시각 |
| 퍼센타일 | 실제보다 낮게 나온다 | 사용자가 겪는 대기와 가깝다 |

착시는 어디서 생기나
Coordinated Omission은 Gil Tene가 붙인 이름이다. 그는 지연 시간 기록 라이브러리 HdrHistogram을 만들었고, 발표 "How NOT to Measure Latency"에서 이 문제를 설명했다. 이름 그대로 측정 도구가 서버와 "보조를 맞춰(coordinated)" 표본을 "빠뜨리는(omission)" 현상이다.
흔한 부하 도구는 스레드(가상 사용자)마다 다음 순서로 돈다.
- 요청을 보낸다.
- 응답이 올 때까지 기다린다.
- 응답 시간을 기록하고 1번으로 돌아간다.
서버가 정상일 때는 문제가 없다. 서버가 GC나 락 대기로 10초 멈추면 이야기가 달라진다. 스레드는 2번에서 10초 동안 멈춰 있고, 그 사이 보냈어야 할 요청은 아예 만들어지지 않는다. 기록에는 "10초 걸린 요청 1건"만 남는다.
실제 사용자는 서버가 멈췄다고 접속을 미루지 않는다. 멈춘 10초 동안 들어온 사용자는 모두 기다렸다. 측정 도구는 그 사용자들을 세지 않았으니, 꼬리 지연이 통계에서 사라진다. 스레드 수를 늘려도 같다. 모든 스레드가 같은 멈춤에 함께 묶이기 때문이다.
예시로 계산해 보기
아래 수치는 원리를 보이려고 만든 예시다.
조건
- 목표 도착률: 10ms마다 1건(초당 100건), 연결 1개
- 시험 시간: 100초
- 정상 응답 시간: 1ms
- 시험 중 서버가 한 번 10초 동안 멈춘다
닫힌 모델이 기록한 것
- 정상 구간 90초 동안 10ms마다 1건: 9,000건, 모두 1ms
- 멈춘 구간: 10,000ms짜리 1건
- 합계 9,001건
퍼센타일은 정렬한 뒤 위치(올림)로 찾는다. P99는 9,001 × 0.99 = 8,910.99, 올려서 8,911번째 값이다. 앞의 9,000건이 모두 1ms이므로 P99는 1ms다. P99.9도 8,992번째라 1ms다. 10초짜리 기록은 9,001번째, 즉 최댓값에만 보인다. 평균은 (9,000 + 10,000) ÷ 9,001 ≈ 2.1ms다.
열린 모델(또는 보정)이 기록한 것
멈춘 10초 동안에도 10ms마다 요청이 예정되어 있었다. 10,000 ÷ 10 = 1,000건이다. 응답 시간을 예정 시각부터 재면 멈춤이 시작될 때 예정된 요청은 10,000ms, 10ms 뒤 요청은 9,990ms, 마지막 요청은 10ms를 기다렸다. 계산을 단순하게 하려고 멈춤이 풀린 뒤 처리 시간 1ms는 뺐다.
- 정상 구간: 9,000건, 모두 1ms
- 멈춘 구간: 10ms, 20ms, …, 10,000ms인 1,000건
- 합계 10,000건
느린 표본의 i번째 값은 10 × i ms다. P95는 9,500번째 값이고, 느린 표본 중 500번째이므로 5,000ms다. P99는 9,900번째, 느린 표본 900번째인 9,000ms다. 평균은 (9,000 + 10 × 1,000 × 1,001 ÷ 2) ÷ 10,000 = 5,014,000 ÷ 10,000 ≈ 501ms다.
| 지표 | 닫힌 모델 기록(9,001건) | 보정 후(10,000건) |
|---|---|---|
| 평균 | 약 2.1ms | 약 501ms |
| P50 | 1ms | 1ms |
| P95 | 1ms | 5,000ms |
| P99 | 1ms | 9,000ms |
| P99.9 | 1ms | 9,900ms |
| 최대 | 10,000ms | 10,000ms |
최댓값은 같지만 P99는 9,000배 차이가 난다. 같은 시험, 같은 서버인데 보고서 한 장은 "P99 1ms 달성"이라 쓰고 다른 한 장은 "100명 중 1명은 9초 대기"라고 쓴다. 닫힌 모델 보고서에는 단서가 하나 남는다. 처리량이 9,001 ÷ 100 ≈ 초당 90건으로, 목표 100건보다 낮다.
닫힌 모델과 열린 모델
닫힌 모델이 늘 틀린 것은 아니다. 사용자 수가 정해져 있고, 각 사용자가 응답을 본 뒤에야 다음 행동을 하는 시스템이면 닫힌 모델이 현실에 가깝다. 예를 들어 상담원 50명이 쓰는 내부 업무 시스템이다.
공개 웹 서비스나 API는 다르다. 사용자는 서로 독립적으로 도착하고, 서버가 느리다고 도착을 미루지 않는다. 이런 시스템의 응답 시간 목표(SLO)를 검증하려면 열린 모델로 도착률을 고정해야 한다.
| 시스템 | 맞는 모델 | 이유 |
|---|---|---|
| 공개 웹·모바일 API | 열린 모델 | 도착이 서버 상태와 무관하다 |
| 외부 기관 연계 수신 API | 열린 모델 | 상대 시스템이 정한 주기로 호출한다 |
| 정해진 인원이 쓰는 내부 업무 시스템 | 닫힌 모델(생각 시간 포함) | 사용자 수가 상한이고, 응답을 본 뒤 다음 행동을 한다 |
| 배치·큐 소비자 | 처리량 위주 측정 | 대기 시간보다 처리 완료 시간이 목표다 |
응답 시간과 서비스 시간을 나눠 기록한다
착시를 잡는 핵심은 시각 세 개를 따로 남기는 것이다.
- 예정 시각(scheduled): 도착률에 따라 요청이 출발했어야 하는 시각
- 전송 시각(sent): 부하 도구가 실제로 요청을 보낸 시각
- 완료 시각(done): 응답을 다 받은 시각
여기서 두 값을 구한다. 완료 − 전송은 서비스 시간이다. 서버가 요청 하나를 처리한 시간이다. 완료 − 예정은 응답 시간이다. 사용자가 실제로 기다린 시간이다. Coordinated Omission은 서비스 시간을 응답 시간인 것처럼 보고하는 실수다.
자체 부하 스크립트를 쓴다면 요청별로 이렇게 남긴다.
seq scheduled_ms sent_ms done_ms service_ms response_ms send_delay_ms
4500 45000 45000 55000 10000 10000 0
4501 45010 55000 55001 1 9991 9990
4502 45020 55001 55002 1 9982 9981
send_delay_ms(전송 − 예정)가 0보다 커지는 구간은 도구가 예정대로 보내지 못한 구간이다. 이 값이 크게 남아 있는데 응답 시간을 전송 시각 기준으로 계산했다면 그 보고서는 꼬리를 숨기고 있다.
도구별 설정
wrk2: wrk를 고친 도구로, -R 옵션으로 초당 요청 수를 고정한다. 응답 시간을 예정 시각부터 재고 HdrHistogram으로 기록한다. 실행 파일 이름은 wrk 그대로다.
wrk -t4 -c100 -d60s -R2000 --latency http://test-server:8080/api/items
연결 수(-c)가 모자라면 목표 도착률을 낼 수 없다. 결과의 실제 초당 요청 수가 -R 값과 맞는지 먼저 본다.
k6: constant-arrival-rate 실행기가 열린 모델이다. 가상 사용자가 모자라 예정된 반복을 시작하지 못하면 dropped_iterations 지표에 남는다.
export const options = {
scenarios: {
steady: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '10m',
preAllocatedVUs: 50,
maxVUs: 500,
},
},
};
dropped_iterations가 0이 아니면 도구가 목표만큼 보내지 못한 것이다. maxVUs를 늘리거나 부하 발생기를 나눈다.
Gatling: 주입 프로파일이 열린 모델(constantUsersPerSec, rampUsersPerSec)과 닫힌 모델(constantConcurrentUsers)로 나뉜다. SLO 검증에는 열린 프로파일을 쓴다. 다만 한 가상 사용자가 요청 여러 개를 차례로 보내는 시나리오라면, 그 사용자 안에서는 앞 응답을 기다린다는 점을 기억한다.
JMeter: 기본 Thread Group은 닫힌 모델이다. Constant Throughput Timer는 요청 사이를 늦출 수만 있고, 스레드가 응답을 기다리는 동안 요청을 더 보내 주지는 못한다. 그래서 타이머만으로는 이 문제가 풀리지 않는다. 최근 버전에는 열린 모델용 Thread Group이 있으니 사용 중인 버전의 문서를 확인한다. 그렇지 않다면 스레드를 넉넉히 두고 처리량 제어 플러그인을 쓰되, 실제 처리량이 목표와 같은지 반드시 검산한다.
HdrHistogram 보정: 닫힌 모델 기록을 나중에 보정할 수도 있다. Java 판은 recordValueWithExpectedInterval(value, expectedInterval)로 기록할 때 빠진 표본을 채운다. 앞의 예시에서 10,000ms를 기대 간격 10ms로 기록하면 9,990ms, 9,980ms, …, 10ms가 함께 들어가 1,000건이 된다. 이미 쌓인 히스토그램은 copyCorrectedForCoordinatedOmission(expectedInterval)으로 보정본을 만든다. 보정은 도착 간격이 일정하다고 가정하므로, 가능하면 처음부터 열린 모델로 시험하는 편이 낫다.
보고서를 검산하는 법
퍼센타일 숫자를 믿기 전에 아래를 먼저 본다.
| 확인 항목 | 어디서 보나 | 놓치면 생기는 일 |
|---|---|---|
| 실제 요청 수 = 도착률 × 시험 시간 | 도구 요약의 총 요청 수 | 모자란 만큼이 빠진 표본이다 |
| 예정 시각 대비 전송 지연 | 요청별 로그, k6 dropped_iterations |
도구가 밀린 구간의 응답 시간이 짧게 기록된다 |
| 부하 발생기의 CPU·GC | 발생기 서버 모니터링 | 발생기 자체가 멈춰 요청이 빠진다 |
| 최댓값과 P99의 차이 | 퍼센타일 표 | 최댓값만 크고 P99가 평평하면 표본 누락을 의심한다 |
| 서버 쪽 측정과 비교 | APM, 서버 히스토그램 | 클라이언트 보고서만으로는 대기열 시간을 못 본다 |
검산 순서는 간단하다. 목표 도착률이 초당 100건, 시험이 100초면 요청은 10,000건이어야 한다. 9,001건이면 999건이 빠졌고, 그 999건은 대부분 가장 느렸어야 할 요청이다.
자주 하는 실수
- 스레드 수를 동시 사용자 수로 여긴다. 닫힌 모델의 스레드 수는 도착률이 아니다. 서버가 느려지면 실제로 보내는 요청이 줄어든다.
- 평균 처리량만 맞추고 끝낸다. 시험 전체 평균이 목표에 가까워도 멈춘 구간에서는 요청이 비어 있을 수 있다. 초 단위 처리량 그래프에서 움푹 꺼진 곳을 찾는다.
- 타이머로 속도를 맞추면 열린 모델이 된다고 믿는다. 요청 간격을 늦추는 타이머는 멈춘 동안 보내지 못한 요청을 채워 주지 않는다.
- 부하 발생기를 시험 대상 서버와 같은 장비에 둔다. 서버가 CPU를 다 쓰면 발생기도 함께 멈추고, 그 구간이 통째로 빠진다.
- 최댓값을 이상치로 지운다. 최댓값이 유일하게 멈춤을 보여 주는 기록일 수 있다. 지우기 전에 그 시각에 처리량이 꺼졌는지 본다.
점검표
- 시험 대상이 열린 모델인지 닫힌 모델인지 정했고 이유를 적었다
- SLO 검증 시험은 도착률을 고정하는 실행기·옵션을 쓴다
- 응답 시간을 예정 시각부터 잰다
- 실제 총 요청 수가 도착률 × 시험 시간과 맞는다
- k6라면
dropped_iterations가 0이다 - wrk2라면 실제 초당 요청 수가
-R값과 맞는다 - 부하 발생기의 CPU·GC를 시험 중에 함께 기록했다
- 초 단위 처리량 그래프에 꺼진 구간이 없다
- 클라이언트 퍼센타일을 서버 쪽 측정과 비교했다
- 보정을 썼다면 기대 간격 값과 보정 사실을 보고서에 적었다
부하 테스트의 퍼센타일은 도구가 보낸 요청만큼만 진실하다. 예정된 시각에 요청을 보내고 그 시각부터 응답 시간을 재야, 서버가 멈춘 순간이 통계에 제대로 남는다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.