같은 조회 요청에서 계산을 줄이고 캐시를 적용한다고 하자. 애플리케이션이 더 많은 요청을 통과시키면 DB에 도착하는 작업도 늘 수 있다. API CPU가 낮아졌는데 DB 연결 대기가 증가했다면 개선이 실패했는지, 다음 제한 요인이 드러났는지 구분해야 한다.

이 글의 질문은 같은 부하 조건에서 병목이 어느 계층으로 이동했는지 어떻게 판단할까다. k6의 요청 도착 모델과 캐시·확장 시간축을 나눠 비교 가능한 측정을 설계한다.

VU 숫자보다 iteration 도착을 먼저 정의한다

고정 VU 모델은 이전 iteration이 끝나야 다음을 시작한다. 한 VU가 한 요청을 쉬지 않고 반복하면 실제 사용자 한 명의 행동과도 다르다. 서버가 느려질수록 시작률도 떨어져 포화 구간의 부하가 스스로 낮아질 수 있다.

목표 도착률을 유지하려면 응답과 새 iteration 시작을 분리하는 arrival-rate 모델을 선택한다. 어느 모델이 더 좋은지는 검증할 질문에 달려 있다. 고정 수의 활동 사용자 흐름과 외부에서 계속 들어오는 요청률은 다른 조건이다. k6의 open·closed 모델.

고정 VU에서는 응답 지연이 반복 시작을 늦추고 도착률 모델에서는 실행 자원이 부족할 때 dropped iteration이 생기는 비교

그림의 dropped iteration은 서버가 처리한 오류 응답과 다르다. 도착률 모델에서는 준비한 VU가 부족해 iteration 자체를 시작하지 못한 상태도 따로 읽어야 한다.

작은 도착률 예제로 지표의 의미를 맞춘다

다음은 검색 시나리오를 초당 40회, 10분 동안 시작하는 설정 예제다. 목표와 임계값은 서비스의 트래픽·응답 약속에 맞춰 정한다.

import http from "k6/http";
import { check } from "k6";

export const options = {
  scenarios: {
    search_flow: {
      executor: "constant-arrival-rate",
      rate: 40,
      timeUnit: "1s",
      duration: "10m",
      preAllocatedVUs: 80,
      maxVUs: 240,
    },
  },
  thresholds: {
    http_req_failed: ["rate<0.01"],
    http_req_duration: ["p(95)<800"],
    dropped_iterations: ["count==0"],
    checks: ["rate==1"],
  },
};

export default function () {
  const result = http.get(`${__ENV.BASE_URL}/v1/programs/search?q=example`);
  check(result, { "status is 200": r => r.status === 200 });
}

BASE_URL과 테스트 데이터는 실행 전에 준비한다. 80은 미리 준비할 실행 자원, 240은 사용할 VU 상한이며 사용자 수 주장이 아니다. 이 예제는 iteration당 HTTP 요청 하나지만 업무 흐름이 여러 요청이면 iteration rate와 RPS가 달라진다. Constant arrival rate 설정.

지연이 길어지면 VU가 더 필요하고 상한에서 시작하지 못한 작업은 dropped_iterations로 나타난다. http_req_failed, 지연 분포와 완료 iteration도 함께 읽는다. check 실패를 출력하는 것과 threshold로 테스트를 실패시키는 것도 구분한다. 기본 지표, k6 thresholds.

cold·warm·안정 부하·회복 구간을 분리한다

smoke에서는 스크립트와 데이터가 맞는지 확인한다. warm-up에서는 JIT·연결 풀·DNS·TLS·캐시 초기화 구간을 관측한다. steady-state에서는 목표 부하를 유지하며 누적 대기와 포화를 보고 recovery에서는 부하를 내린 뒤 자원이 회복하는지 확인한다.

초기 지연이 실제 사용자 경험이라면 warm-up 결과를 무조건 버리지 않는다. cold와 warm 시나리오를 별도로 보존하고 같은 조건끼리 비교한다. 캐시 hit 상태만 검사하면 새 인스턴스 투입과 miss 경로가 빠질 수 있다.

테스트 warm-up은 측정 절차이고 Auto Scaling instance warmup은 새 인스턴스의 지표 반영 정책이다. target tracking은 warmup 중인 인스턴스를 집계 EC2 지표에서 제외하고 동적 scale-in에도 영향을 준다. 다음 scale-out이 항상 warmup 시간만큼 지연된다는 뜻은 아니다. Target tracking warmup.

네 계층을 같은 시간축에 놓는다

계층비교할 관측
k6목표·시작·완료 iteration, dropped, P50·P95·P99, 오류·check
ALB·인스턴스요청 수, healthy target, 응답 시간·5xx, 생성·종료
애플리케이션CPU·GC, thread, connection pool active·pending, 외부 대기
DBCPU·연결, IOPS·queue, lock wait, 쿼리·실행계획·버퍼

RDS의 CPUUtilization은 CPU 사용률이고 DatabaseConnections와 DiskQueueDepth는 다른 자원 단서다. RDS CloudWatch 지표를 엔진별 제공 범위와 맞춘다. CPU 상승 하나만으로 느린 쿼리를 확정하지 않는다.

예를 들어 API CPU가 90%이고 DB 연결 풀이 여유로운 조건과, 변경 뒤 API CPU가 낮아졌지만 DB pending·lock wait가 늘어난 조건을 비교할 수 있다. 병목 이동 판단에는 같은 입력과 도착률, 더 많은 완료 작업이 DB에 전달됐는지의 근거가 필요하다. 캐시 hit·miss, endpoint·tenant와 hot key도 나눠 본다.

확장 결정과 실제 용량 증가는 동시에 일어나지 않는다

부하 상승, 지표 평가, 인스턴스 생성, 앱 준비, target 등록과 health 통과의 시각을 기록한다.

부하와 지표 평가 뒤 인스턴스가 생성되고 대상 등록·앱 준비·health 통과를 거쳐 실제 트래픽을 받는 순서

앱 준비와 등록은 일부 겹칠 수 있지만 요청을 처리하는 시점은 뒤다. CPU 기반 정책이 짧은 burst에 늦게 반응한다면 최소 용량과 평가·기동 시간을 함께 비교한다. 요청량이 용량과 비례하는 서비스에서는 인스턴스당 request count도 대안이지만 DB 병목에서 서버 수만 늘리면 연결 경합이 커질 수 있다. scale-in의 요청 배출도 별도 확인한다.

같은 조건에서 다음 제한 요인을 좁힌다

코드·인스턴스·DB 사양, 데이터 분포, 캐시 초기 상태, 네트워크 위치와 executor를 기록하고 반복한다. 최고 VU보다 안정 구간의 완료율·지연 분포·오류와 포화 지점을 비교한다. 결과 행 수나 검색 조건이 달라지면 같은 기능의 성능 개선으로 묶지 않는다.

변경 뒤 DB가 먼저 포화되는 자료가 있다면 다음 측정 대상을 찾은 것이다. 그 자체가 전체 병목 해결을 뜻하지 않는다. 쿼리·round trip, 풀 크기, 락·I/O, miss 경로를 좁힌 뒤 최적화와 증설 비용을 비교한다.

이 방법은 캐시·로직·확장 정책 변경 뒤 처리량과 지연이 함께 달라진 서비스에서 유효하다. 다음 테스트는 목표 도착률과 캐시 상태를 먼저 고정한다. 그 뒤 완료 작업과 각 계층의 대기를 같은 시간축으로 읽어야 다음 변경의 근거가 생긴다.