들어가며
서비스가 어느 정도 안정적으로되면서 자연스럽게 다음 스텝인 운영 고도화에 눈이 가기 시작했다. 서비스가 단순히 뜨는 것을 넘어 얼마나 버틸 수 있을지 궁금해졌다.
- 트래픽이 10 RPS, 20 RPS로 올라갈 때 응답 시간(Latency)은 안정적으로 유지되는가?
- 설정해 둔 HPA가 제때 발동하는가? 발동한다면 새로운 Pod이 뜨기까지 몇 초나 걸리는가?
- 만약 시스템이 무너진다면 그 임계점은 어디인가? 앱 서버가 먼저 뻗을까? DB가 먼저 뻗을까?
용어 정리
- RPS (Requests Per Second): 초당 요청 수. "이 서비스가 1초에 몇 건을 처리하는가"를 재는 결과 지표. 성능테스트가 보려는 값.
- VU (Virtual User): 가상 사용자 한 명. 부하 도구가 띄우는 동시 사용자 단위로, 한 VU가 요청을 던지고 응답을 받으면 곧바로 다음 요청을 던지는 식으로 순환한다. RPS가 결과라면 VU는 입력이다.
- VU와 RPS의 관계: 두 값은 1:1이 아니다. 응답시간이 50ms면 1 VU가 초당 20요청(=20 RPS)을 만들지만, 응답이 500ms로 늘어지면 1 VU가 초당 2요청(=2 RPS)밖에 못 만든다. 즉 VU를 고정하면 RPS는 응답시간에 따라 출렁이고, RPS를 고정하면 도구가 필요한 만큼 VU를 자동으로 늘린다. 시나리오 작성 시 측정 의도가 "특정 RPS에서의 응답시간"이라면 VU 기반(
ramping-vus)이 아니라 RPS 기반(ramping-arrival-rate) 방식으로 부하를 거는 게 정확하다. - p95 / p99: 응답시간 분포의 95 / 99 백분위수. p95가 200ms라는 건 100건 중 95건은 200ms 안에 응답했다는 뜻. 평균보다 꼬리(tail) 응답을 보기 위해 쓴다.
1. 성능테스트의 종류
성능테스트는 트래픽을 어떤 모양으로, 얼마나 오랫동안 주느냐에 따라 종류가 나뉜다.
| 종류 | 목적 | 형태 |
|---|---|---|
| Load Test | 예상 피크 트래픽을 안정적으로 견디는가? p95/p99 응답 시간이 만족스러운가? | 일정 RPS를 유지하면서 응답시간 측정 |
| Stress Test | 시스템이 어디서부터 깨지는가? 최대 한계점(Breakpoint)은 어디인가? | RPS를 점진적으로 올리면서 에러율 관찰 |
| Spike Test | 갑작스러운 트래픽 폭증에 오토스케일링이 따라잡는가? | 짧은 시간에 RPS를 급격히 ramp-up |
| Soak Test | 장시간 부하에서 메모리/커넥션 누수가 없는가? | 같은 부하를 수 시간~수 일 유지 |
용어 정리
- HPA (Horizontal Pod Autoscaler): 쿠버네티스의 기본 오토스케일러. CPU나 메모리 사용량을 보고 파드 개수를 자동으로 늘리거나 줄인다. "Horizontal"인 이유는 파드 하나의 스펙을 키우는(vertical) 게 아니라 개수를 늘리기(horizontal) 때문.
- SLA (Service Level Agreement): 서비스 품질 기준. 보통 "99.9% 가용성, p95 < 500ms" 같은 식으로 정의.
2. 성능테스트 실행 전
2-1. 성능테스트 선택 이유
처음에는 Load / Stress / Spike / Soak 네 가지 성능테스트를 모두 진행하는 방향으로 생각했다. 하지만 AWS 비용과 테스트 및 분석 시간 등의 작업량을 함께 고려해야 했다. 그래서 네 가지를 한 번에 모두 수행하기보다는 우선순위를 정한 후 차례대로 진행하기로 했다. 그래서 Load Test와 Spike Test를 먼저 해보았다.
- Load Test — 이 서비스가 어느 정도의 트래픽을 안정적으로 처리할 수 있는지에 대한 기준선이 아직 없었다. 실제로 어느 RPS 구간까지 안정적인지, p95/p99 응답 시간과 에러율은 어떤지를 확인하면 HPA 임계값, 리소스 request/limit, DB 인스턴스 크기 등을 판단할 때 기준이 된다. Load Test는 이후 성능 개선 작업의 출발점 역할을 한다.
- Spike Test — DevOps 관점에서 가장 궁금했던 부분은 트래픽이 갑자기 튈 때 인프라가 어떻게 반응하는지였다. 실제 서비스 트래픽은 이벤트, 푸시 알림, 외부 유입 등으로 짧은 시간에 급증할 수 있고, 이때 HPA가 몇 초 만에 scale-out을 시작하는지, 새 Pod이 Ready 되기 전까지 latency와 error rate가 얼마나 흔들리는지 체크해봐야한다.
Stress Test와 Soak Test는 후속 작업으로 남겨두기로 했다.
- Stress Test는 시스템의 최대 한계와 병목 레이어를 찾는 데 의미가 있지만 현재 단계에서는 Load Test 결과만으로도 병목 후보를 어느 정도 확인할 수 있다. 또한 높은 RPS를 오래 밀어 넣으면 RDS, 노드, 애플리케이션 전체에 부담이 크기 때문에 먼저 기준 부하와 순간 부하를 확인한 뒤 진행하는 편이 낫다고 판단했다.
- Soak Test는 장시간 부하에서 메모리 누수나 커넥션 누수, 메트릭 수집 안정성을 보는 테스트다. 장시간 안정성 검증은 기본 성능과 오토스케일링 결과를 정리한 뒤 진행할 예정이다.
2-2. 테스트 범위와 측정 기준
이번 테스트는 외부 인터넷 구간 전체의 end-to-end latency를 측정하는 테스트는 아니다. k6는 Kubernetes 클러스터 내부 Pod로 실행하고, 요청은 istio-ingressgateway.istio-system.svc.cluster.local을 통해 서비스로 전달한다.
따라서 CloudFront, WAF, ALB, 인터넷 왕복 구간의 지연 시간은 측정 대상에서 제외된다. 이번 테스트의 목적은 클러스터 내부에서 Istio Gateway를 경유한 애플리케이션 처리 성능과 HPA scale-out 반응을 확인하는 것이다.
즉, 측정 기준은 다음에 가깝다.
- 특정 RPS에서 애플리케이션이 안정적으로 응답하는가
- 부하 증가 시 Pod CPU 사용률과 HPA가 어떻게 반응하는가
- latency와 error rate가 어느 구간에서 흔들리기 시작하는가
- RDS, Pod, Node 중 어느 레이어가 병목 후보로 보이는가
2-3. 도구 선택
처음에는 어떤 도구를 써야 할지 감이 없어서, JMeter, k6, Locust, Gatling처럼 자주 언급되는 도구들을 먼저 검색해보고 각각 어떤 특징이 있는지 정리했다. 아래 비교는 직접 모든 도구를 실습해본 결과라기보다는 공식 문서와 사용 후기와 비교 글들을 보면서 초보자 입장에서 이해한 내용을 내 프로젝트 상황에 맞게 정리한 것이다.
조사한 도구 특징
| 항목 | JMeter | k6 | Locust | Gatling |
|---|---|---|---|---|
| 시나리오 언어 | GUI / JMX(XML) | JavaScript | Python | Scala / Java |
| 런타임 | JVM | Go | CPython + gevent | JVM (Akka) |
| 단일 머신 성능 | 중간 (JVM 메모리 부담) | 높음 (Go goroutine) | 중간 (GIL 우회하지만 한계 있음) | 높음 (비동기 I/O) |
| Prometheus 연동 | 플러그인 별도 설치 필요 | 표준 지원 (remote write) | 별도 exporter 필요 | 플러그인 별도 설치 필요 |
| 기본 리포트 | HTML 리포트 | CLI 요약 | 웹 UI (실시간) | HTML 리포트 (상세) |
| 코드 버전 관리 | 어색함 (XML) | 자연스러움 | 자연스러움 | 자연스러움 |
| 러닝 커브 | 낮음 (GUI 있음) | 낮음~중간 | 낮음 (Python) | 높음 (Scala) |
선택 근거
도구 자체의 우열을 단정하기보다는 지금 내 프로젝트에서 중요하게 보는 조건을 먼저 정했다. Shoong은 이미 Prometheus/Grafana 기반 옵저버빌리티를 구성해둔 상태이고, 테스트 시나리오도 GitOps 레포에서 코드로 관리하고 싶었다. 또한 별도의 부하 발생기 서버를 크게 구성할 여유가 없어서, 가능한 단순한 구조로 시작할 수 있는 도구가 필요했다.
첫째, 부하 발생 구조가 단순해야 했다. 별도 부하 발생기 서버를 따로 만들기보다는, Kubernetes 내부에서 Job/CronJob 형태로 실행하고 싶었다. 검색해보니 JMeter는 오래된 표준 도구에 가깝고 자료도 많지만 JVM 기반이고 GUI/XML 중심의 예제가 많아 GitOps로 시나리오를 관리하기에는 조금 무겁게 느껴졌다. Gatling은 성능이 좋다는 평가가 많았지만 Scala/Java 기반이라 처음 시작하는 입장에서는 러닝 커브가 부담스러웠다.
둘째, Prometheus/Grafana와 연결하기 쉬워야 했다. 이번 테스트는 단순히 CLI 결과만 보는 것이 아니라 앱 메트릭·인프라 메트릭·부하 메트릭을 같은 Grafana 화면에서 시간대별로 맞춰보는 것이 목적이었다. k6는 Prometheus remote write 연동과 Grafana 대시보드 예제가 많이 보여서 이미 구성해둔 모니터링 스택과 연결하기 가장 자연스러워 보였다. 다른 도구들도 연동은 가능하지만 exporter나 플러그인을 추가로 붙여야 하는 경우가 많아 현재 단계에서는 작업량이 더 커질 것 같았다.
셋째, 시나리오를 코드로 관리하기 쉬워야 했다. Locust는 Python으로 작성할 수 있어서 접근성이 좋아 보였고, k6는 JavaScript로 작성할 수 있다는 점이 눈에 들어왔다. Shoong API 서비스들이 Node.js/Express 기반이기 때문에 JavaScript로 요청 시나리오를 작성하는 k6 쪽이 프로젝트 맥락과 더 잘 맞는다고 판단했다. 나중에 테스트 코드를 GitOps 레포에 넣고 변경 이력을 남기기에도 k6 스크립트가 읽기 쉬워 보였다.
현재 내 상황에서 가장 적은 시행착오로 시작할 수 있고, 이미 구성한 Grafana/Prometheus와 연결하기 쉬우며, JavaScript 코드로 시나리오를 관리할 수 있기 때문에 k6를 선택했다. 이후 더 복잡한 테스트가 필요해지면 Locust나 Gatling도 다시 검토할 수 있겠지만 이번 성능테스트의 첫 도구로는 k6가 가장 합리적이라고 판단했다.
용어 정리
- k6: Grafana Labs에서 만든 성능테스트 도구. Go로 짜여있어서 한 머신에서 수만 VU를 띄울 수 있고, 결과를 Prometheus로 바로 보낼 수 있다.
2-4. 성능테스트 시 인프라 스펙
| 구성 요소 | 스펙 | 비고 |
|---|---|---|
| EKS 노드 | c7i-flex.large × 3 (2vCPU/4GB) | desired 3, min 2, max 4 |
| RDS | db.t3.micro, single-AZ | single-AZ |
| ALB | 단일 ALB | 변경 없음 |
| Istio | 기본 sidecar 주입 | 변경 없음 |
| HPA | min 2 / max 5 / CPU 70% | dev에서만 enabled: true. min=2는 Istio sidecar 재시작 중 단일 파드 503 방지 목적 |
| 앱 리소스 | cpu req 100m, limit 200m / mem req 64Mi, limit 128Mi | 변경 없음 |
CPU request 100m, target 70% 설정이면 평균 CPU 사용률이 70m(= request의 70%)을 안정적으로 넘기 시작할 때 HPA가 파드를 늘리는 결정을 내린다.
3. Load Test
3-1. 시나리오
대상: POST /orders/:menuId (주문 생성, DB write 포함)
처음에는 100 RPS와 300 RPS 구간을 기준으로 Load Test를 설계했다. 하지만 실제로 시도해보니 에러율이 너무 크게 올라가고(약 47%), 일부 요청은 15초 이상 지연되면서 Istio timeout으로 503이 발생했다.
주문 생성 API는 단순 조회가 아니라 DB write, kitchen/notification 서비스 호출, 상태 변경 흐름까지 포함하고 있고, RDS도 db.t3.micro인 상태라 100 RPS부터 시작하는 것은 기준 부하 측정이라기보다 이미 한계에 가까운 부하를 주는 방식에 가까웠다.

100→300 RPS 첫 시도. HTTP failures 25,750건(전체 대비 약 47%).
그래서 현재 인프라에서 안정적으로 처리 가능한 기준선을 찾는 방향으로 조정했다. 최종 시나리오는 10 RPS, 30 RPS, 50 RPS 구간을 순차적으로 유지하면서 응답시간과 에러율, HPA 반응을 확인하는 방식으로 잡았다.
export const options = {
scenarios: {
load: {
executor: "ramping-arrival-rate", // RPS를 직접 제어 (도착률 기반)
startRate: 0, // 시작 RPS
timeUnit: "1s", // rate의 단위 (초당 N건)
preAllocatedVUs: 20, // 미리 띄워둘 VU 풀
maxVUs: 200, // 부족할 때 늘릴 수 있는 상한
stages: [
{ duration: "2m", target: 10 }, // 0 → 10 RPS
{ duration: "5m", target: 10 }, // 10 RPS 유지
{ duration: "2m", target: 30 }, // 10 → 30 RPS
{ duration: "5m", target: 30 }, // 30 RPS 유지
{ duration: "2m", target: 50 }, // 30 → 50 RPS
{ duration: "5m", target: 50 }, // 50 RPS 유지
{ duration: "2m", target: 0 }, // ramp-down
],
},
},
thresholds: {
http_req_duration: ["p(95)<500"], // p95 500ms 미만
http_req_failed: ["rate<0.01"], // 에러율 1% 미만
},
};
- thresholds: 테스트의 성능 합격/불합격(Pass/Fail) 기준. 예를 들어, "응답 시간 200ms 이하, 에러율 1% 미만"처럼 목표 수치를 미리 설정해 두고, 테스트 종료 시 이 기준을 통과했는지 도구가 자동 판정
시나리오 선택 메모: k6의 기본 executor인 ramping-vus는 VU 수를 시간에 따라 변동시키는 방식인데 이 경우 실제 RPS는 응답시간에 따라 출렁인다. 이 테스트는 "10 RPS / 30 RPS / 50 RPS에서의 응답시간"이 측정 의도라서 RPS를 고정하는 ramping-arrival-rate executor를 선택했다. VU는 도구가 알아서 풀(preAllocatedVUs~maxVUs 범위)에서 끌어와 쓴다.
측정 대상:
- RPS별 p50/p95/p99 응답시간
- 에러율
- DB CPU 사용률 (CloudWatch)
- 노드/파드 CPU 사용률 (Prometheus)
실행 방식
외부 ALB 방식 대신 내부 Pod로 실행을 택한 이유는 두 가지다.
Prometheus를 외부에 노출하지 않고 ClusterIP 그대로 쓰기 위해 외부로 빼면 메트릭 데이터 공개 면적이 늘어난다. >> 원하지 않음
측정 노이즈를 줄이기 위해 부하 발생기에 Istio sidecar가 끼면 그 자체의 latency가 측정값에 섞인다.
loadtestnamespace를 만들고istio-injection=disabled라벨을 붙여 sidecar 미주입. >> 방해되는 수준은 미미할 수 있겠지만 최대한 줄여보고 싶었음.
운영 패턴은 suspended CronJob으로 잡았다. 시나리오·이미지·리소스는 GitOps(ArgoCD)로 관리하되, 실행 타이밍은 kubectl create job --from=cronjob/... 으로 사람이 수동 trigger한다. ArgoCD가 sync마다 부하를 자동 실행해버리면 곤란하기 때문.
3-2. 테스트 전 상태
노드 3개(c7i-flex.large) 전체 자원 사용률

CPU Utilisation 7.31%, Memory Utilisation 55.1%. Limits Commitment가 100%를 넘는 건
쿠버네티스 정상 동작으로, limits 합산이 노드 용량을 초과해도 실제 사용량이 낮으면
문제없다. 실제 사용량 기준으로는 부하를 받을 여유가 충분한 상태다.
HPA 상태

CURRENT=2, CPU 사용률 한 자릿수%. HPA 트리거 조건(70%)에 한참 못 미치는 평시 상태. min=2는 Istio sidecar 재시작 중 단일 파드 503을 막기 위한 설정이라 부하 없이도 항상 2개가 유지된다.
Pod 상태

kubectl top 결과. 각 파드가 수십 mCore 수준으로 request(100m)의 절반 이하. 노드도 여유가 충분하다.
- RDS CloudWatch
- CPUCreditBalance: t3.micro는 burstable이라 크레딧이 얼마나 남아있냐가 테스트 시작 조건에 영향을 줌. 높을수록 좋음
- CPUUtilization: DB가 부하 전에 얼마나 쉬고 있는지
- DatabaseConnections: 테스트 전 커넥션 수 기준값. 나중에 테스트 중 급증하는 것과 비교할 때 기준이 됨
- FreeableMemory: 메모리 여유

테스트 직전 RDS 4개 메트릭. 부하가 없는 안정 상태에서 시작했다.
3-3. 실행 절차
테스트는 다음 순서로 진행했다.
- ArgoCD에서
loadtest-dev애플리케이션이 Synced 상태인지 확인 loadtestnamespace에 Istio sidecar injection이 비활성화되어 있는지 확인- k6 CronJob이
suspend: true상태인지 확인 - 테스트 전 HPA, Pod, Node, RDS baseline 상태 캡처
kubectl create job --from=cronjob/k6-load-test ...명령으로 k6 Job 수동 실행- 실행 중 Grafana, HPA, Pod 상태를 동시에 관찰
- 테스트 종료 후 k6 Job 로그와 Grafana/CloudWatch 지표 확인
- 필요 시 테스트 데이터 정리
실제 실행 전 점검 명령은 다음과 같다.
# ArgoCD Application 동기화 상태 확인
kubectl get application loadtest-dev -n argocd
# loadtest namespace의 Istio sidecar injection 비활성화 확인
kubectl get namespace loadtest --show-labels
# k6 CronJob이 자동 실행되지 않도록 suspend 상태인지 확인
kubectl get cronjob k6-load-test -n loadtest
# 부하 전 HPA / Pod / Node 상태 확인
kubectl get hpa -n shoong
kubectl get pods -n shoong -o wide
kubectl top pods -n shoong
kubectl top nodes
실행 전 loadtest-dev 애플리케이션은 Synced/Healthy 상태였고, k6 CronJob은 suspend: true로 설정되어 있었다. 즉, 테스트 리소스는 GitOps로 관리하지만 ArgoCD sync만으로 부하 테스트가 자동 실행되지는 않는 상태다.

k6 Job은 CronJob 정의를 기준으로 수동 생성했다.
jobName="k6-load-$(date +%Y%m%d-%H%M%S)"
kubectl create job --from=cronjob/k6-load-test $jobName -n loadtest
실행 중에는 HPA와 Pod 변화를 별도 터미널에서 계속 관찰했다.
kubectl get hpa -n shoong -w
kubectl get pods -n shoong -w
테스트 종료 후에는 Job 상태와 k6 로그를 확인했다.
kubectl get jobs -n loadtest
kubectl logs -n loadtest job/$jobName
3-4. 실행 결과
테스트는 20:02쯤 시작했고, 10 RPS → 30 RPS → 50 RPS 순서로 약 23분 동안 진행했다. Grafana k6 대시보드 기준으로 전체 요청 수는 약 3.7만 건이었고, 50 RPS 구간에서 실패 요청이 본격적으로 보이기 시작했다.

전체 overview에서는 10 RPS와 30 RPS 구간은 비교적 안정적으로 유지되었지만, 50 RPS 구간 진입 후 http_req_s_errors가 나타났다. Grafana의 HTTP Request Duration 패널은 No data로 표시되었기 때문에, 최종 latency 값은 k6 Job summary를 기준으로 확인했다.
10 RPS 구간

10 RPS 유지 구간에서는 요청 수가 9~11 req/s 범위에서 안정적으로 유지되었고, HTTP request failures 패널에도 실패 데이터가 표시되지 않았다. 이 구간은 현재 인프라에서 무리 없이 처리 가능한 수준으로 볼 수 있다.
30 RPS 구간

30 RPS 유지 구간도 대체로 목표 RPS 근처를 유지했다. 이 구간까지는 실패 요청이 눈에 띄게 발생하지 않았고, Load Test의 기준 부하로는 안정적인 편이었다.
50 RPS 구간

50 RPS 구간에 들어가면서 요청 성공선과 실패선이 함께 나타났다. 순간적으로 전체 request rate가 100 req/s 근처까지 튀는 구간도 있었고, 실패 요청이 누적되었다. 최종 summary 기준 실패율은 1.58%로, 설정한 threshold인 1% 미만을 넘었다.
HPA 반응
부하가 올라가자 HPA는 실제로 scale-out을 수행했다. 20:19쯤에는 order 서비스가 최대 replica 5까지 증가했고, kitchen과 notification도 replica가 늘어났다.

20:22쯤에도 order는 replica 5를 유지했고, kitchen은 4개까지 증가했다. 즉, 50 RPS 구간에서 주문 생성 경로의 주요 서비스들이 HPA target CPU 70% 근처 또는 그 이상으로 올라갔음을 확인할 수 있었다.

delivery는 order/kitchen처럼 크게 증가하지 않았다. 처음에는 이 부분이 이상해 보였지만, 실제 호출 흐름을 다시 보면 자연스러운 결과다. k6가 직접 호출한 것은 주문 생성 API이고, 이 요청은 즉시 order → kitchen /start 흐름을 만든다. 반면 delivery는 주문 생성 직후 바로 호출되는 것이 아니라, COOKING 상태 주문을 배치가 kitchen /complete로 넘긴 뒤 kitchen /complete → delivery /assign 흐름에서 호출된다.
따라서 order와 kitchen은 k6 요청마다 직접 부하를 받았고, kitchen은 /start와 /complete 양쪽 흐름의 영향을 받았다. delivery도 후속 배달 배정 흐름에서 영향을 받았지만 호출 빈도와 처리 비용이 상대적으로 낮아 HPA target CPU 70%에 도달하지 않은 것으로 보인다.
테스트가 끝난 뒤 20:40에는 모든 서비스가 다시 minReplicas인 2개로 돌아왔다. 이 시점에도 delivery CPU는 28% 정도로 약간의 부하 흔적이 남아 있었지만, replica를 늘릴 정도는 아니었다. 즉, scale-out뿐 아니라 부하 종료 후 scale-in까지 정상적으로 동작했다.


Pod 상태
scale-out 중에는 새 Pod이 Pending, Running, 1/2 상태를 거쳐 올라오는 모습이 보였다. 특히 order, kitchen, notification 쪽에서 새 replica가 생성되었다.

테스트 종료 후에는 다시 기존 replica 중심으로 안정화되었고, 주요 애플리케이션 Pod은 Running 상태를 유지했다.

애플리케이션 리소스 사용량
Grafana Kubernetes namespace 대시보드에서도 테스트 구간 동안 Pod CPU 사용량이 증가했다가 종료 후 내려가는 흐름을 확인할 수 있었다. 화면의 시간 범위는 UTC 기준 11:00 ~11:29로 표시되어 있는데 한국 시간으로는 20:00 ~ 20:29 테스트 구간에 해당한다.

RDS 상태
RDS CloudWatch 지표를 보면 CPUUtilization은 테스트 중 약 12% 수준까지 상승했지만, CPUCreditBalance는 감소하지 않고 오히려 증가했다. 따라서 이번 테스트에서 DB CPU credit 소진이 직접적인 병목이었다고 보기는 어렵다. 다만 DatabaseConnections는 약 20개 수준에서 41개까지 증가했고, FreeableMemory도 테스트 중 일시적으로 감소했다.

k6 summary와 threshold 결과
최종 k6 summary 기준으로 전체 요청은 37,645건, 실패 요청은 597건이었다. http_req_failed는 1.58%로 threshold인 1% 미만을 넘었고, http_req_duration p95도 2.59초로 threshold인 500ms 미만을 만족하지 못했다. 그래서 k6 Job은 정상적으로 끝까지 실행되었지만 threshold 실패로 Failed 상태가 되었다.


이번 Load Test의 결과를 정리하면 다음과 같다.
| 항목 | 결과 |
|---|---|
| 총 요청 수 | 37,645건 |
| 실패 요청 수 | 597건 |
| 실패율 | 1.58% |
| p95 응답시간 | 2.59초 |
| checks | 98.41% |
| dropped iterations | 154건 |
| threshold 결과 | 실패 (http_req_duration, http_req_failed) |
| HPA 반응 | order 최대 5개, kitchen 최대 4개, notification 최대 3개까지 증가 |
| delivery 반응 | 후속 흐름 영향으로 CPU는 증가했지만 HPA target 미달로 replica 2개 유지 |
10 RPS와 30 RPS 구간은 비교적 안정적으로 처리되었지만, 50 RPS 구간에서는 latency와 실패율이 기준치를 넘었다. RDS CPU credit은 소진되지 않았기 때문에, 현재로서는 DB CPU보다 애플리케이션 처리 지연, 내부 서비스 호출 흐름, DB 커넥션 대기 가능성을 우선 의심해볼 수 있다. 다음 단계에서는 order 서비스의 요청 처리 흐름과 Prisma 커넥션 풀 설정을 먼저 확인해볼 필요가 있다.
다음 할 일
이번 테스트는 "50 RPS에서 threshold를 넘는다"는 사실까지 확인한 단계다. 다음에는 단순히 RPS를 더 올리기보다 실패 원인을 좁히는 작업을 먼저 해야 할 것 같다...
order API 처리 흐름 확인
주문 생성 요청 하나가 내부적으로 어떤 순서로 처리되는지 먼저 확인한다. 특히 DB write가 몇 번 발생하는지, kitchen/notification 호출을 동기적으로 기다리는지, 내부 API 호출 timeout이 있는지, 응답을 언제 반환하는지 봐야 한다.
Prisma connection pool 확인
RDS CPU credit은 소진되지 않았지만 DatabaseConnections는 증가했다. 따라서
DATABASE_URL에connection_limit설정이 있는지, Prisma 기본 pool size가 컨테이너 CPU 제한에서 어떻게 잡히는지 확인해야 한다. Pod 수가 늘어날 때 서비스별 connection pool이 함께 늘어나므로, 전체 DB connection 수가 RDS 한계에 가까워지는지도 봐야 한다.50 RPS 구간 애플리케이션 로그 확인
k6 summary만으로는 어느 서비스에서 지연이나 실패가 시작됐는지 알기 어렵다. 테스트 시간대인 20:17~20:23 구간의 order, kitchen, notification 로그에서 timeout, error, 긴 responseTime 로그를 확인해야 한다.
kubectl logs -n shoong deploy/dev-shoong-order --since=1h kubectl logs -n shoong deploy/dev-shoong-kitchen --since=1h kubectl logs -n shoong deploy/dev-shoong-notification --since=1h시나리오 분리
이번 테스트는 주문 생성 API 중심이라 order/kitchen에 부하가 집중되었다. delivery까지 명확히 검증하려면 별도 시나리오가 필요하다. 예를 들면 주문 생성 전용, 조리 완료 전용, 배달 완료 전용, 알림 조회 전용으로 나누어 각 서비스가 어떤 조건에서 scale-out되는지 확인할 수 있다.
이번 결과는 단순 실패라기보다는 현재 인프라에서 10/30 RPS는 비교적 안정적이고 50 RPS부터 병목이 드러난다는 기준선을 잡은 것으로 볼 수 있다. 또한 모든 서비스가 동시에 늘어난 것이 아니라 실제 부하가 걸린 서비스 중심으로 HPA가 반응했다는 점에서 서비스별 독립 스케일링도 확인할 수 있었다.
3-5. Load Test 후 확인
Load Test 결과에서 50 RPS 진입 직후 에러가 집중된 것을 확인한 뒤, 원인 추정을 위해 애플리케이션 로그와 Prisma 설정을 간단하게 점검했다.
Loki — order 서비스 로그 볼륨

dev-shoong-order전체 로그. 테스트 구간(20:02~20:25) 동안 info 로그가 급증했고, 20:18 부근에 error 88건이 집중됐다.
- Loki — order error 필터 분포

| json | level = "error"필터를 적용한 결과. error 88건 전부가 20:18~20:20 구간에 몰려 있고, 로그 항목에는POST /api/orders/:menuId?userName=tester요청이 담겨 있다. 에러가 분산되지 않고 특정 시점에 집중된 것은 점진적 지연이 아니라 임계점 도달 직후 일시에 터진 패턴에 가깝다.
- Loki — order 500 에러 상세

order 서비스 error 레벨 로그 필터 결과. statusCode 500, responseTime 7089ms. 정상 요청(2ms) 대비 3500배로, 커넥션 대기 후 타임아웃으로 추정된다.
- Loki — kitchen 500 에러 상세

kitchen 서비스 error 레벨 로그 필터 결과. statusCode 500, responseTime 6398ms. order와 동일한 20:18 시점에 76건 발생. 로그에서 Prisma 에러 코드(P2024)가 직접 확인되지 않아 단정할 수는 없지만, Prisma connection_limit 기본값(pod당 5) 상태에서 두 서비스가 같은 시각에 동시 실패한 점은 DB 커넥션 경합이 원인 후보임을 보여준다.
Prisma connection_limit 미설정 확인
connection_limit미설정으로 pod당 커넥션이 5개로 제한된 상태에서, 50 RPS 도달 시 응답 지연이 누적되어 500 에러로 이어진 것으로 추정된다. 정확한 원인(DB 커넥션 경합, 내부 서비스 호출 지연 등)은 Prisma 에러 코드 확인이나 distributed tracing으로 별도 분석이 필요하다.
3-6. Load Test 결과 요약
| 구간 | 결과 | 비고 |
|---|---|---|
| 10 RPS | 안정 | p95 정상, 에러 없음 |
| 30 RPS | 안정 | p95 정상, 에러 없음 |
| 50 RPS | 에러 발생 | 진입 직후(20:18) order/kitchen 동시 500, responseTime 6~7초 |
- threshold:
p(95)<500ms,error rate<1%모두 실패 - HPA: order는 max(5)까지, kitchen은 4까지 scale-out 발생 (notification도 일부 증가, delivery는 부하 경로 영향이 간접적이라 2 유지)
- 병목 후보: Prisma connection_limit 기본값(pod당 5) 상태에서 50 RPS 부하 시 커넥션 경합 추정. 단, 로그에서 Prisma 에러 코드 미확인으로 단정하지 않음
- 다음 단계: Spike Test로 갑작스러운 트래픽 폭증 시 HPA 반응 속도 확인
4. Spike Test
4-1. 시나리오
Load Test에서 50 RPS에 도달하는 데 약 16분이 걸렸다(0 → 10 → 30 → 50 RPS 단계적 ramp-up). 이 과정에서 HPA는 단계가 높아질수록 서서히 pod를 늘려갈 수 있었다. 하지만 실제 트래픽은 예고 없이 몰린다.
Spike Test는 안정 기준선(10 RPS)에서 순식간에 100 RPS로 올린 뒤 다음을 확인한다.
- HPA가 스파이크를 감지하고 pod를 추가하기까지 걸리는 시간
- scale-out 완료 전후 에러율 변화 (HPA가 따라잡기 전 에러가 발생하고, 이후 회복되는지)
- 스파이크가 끝난 뒤 latency가 정상으로 돌아오는지
스파이크 목표를 100 RPS로 잡은 이유: Load Test에서 50 RPS가 이미 한계에 가까웠다. "정상 운영 중 갑자기 10배 트래픽이 몰리는 상황"을 시뮬레이션하려면 기준선(10 RPS) 대비 10배인 100 RPS 정도가 적절하다.
import { orderRequest } from "./common.js";
export const options = {
scenarios: {
spike: {
executor: "ramping-arrival-rate",
startRate: 0,
timeUnit: "1s",
preAllocatedVUs: 50, // 스파이크 대비해 Load Test(20)보다 크게 설정
maxVUs: 500,
stages: [
{ duration: "2m", target: 10 }, // 기준선 (Load Test에서 안정 확인된 구간)
{ duration: "30s", target: 100 }, // 스파이크: 30초 만에 10 → 100 RPS
{ duration: "5m", target: 100 }, // 스파이크 유지 (HPA 반응 관찰)
{ duration: "30s", target: 10 }, // 급격한 감소
{ duration: "2m", target: 10 }, // 회복 확인
{ duration: "1m", target: 0 }, // ramp-down
],
},
},
thresholds: {
http_req_duration: ["p(95)<500"],
http_req_failed: ["rate<0.01"],
},
};
export default function () {
orderRequest();
}
Load Test와 다른 점은 preAllocatedVUs를 50으로 높인 것이다. 스파이크 구간에서 VU가 갑자기 늘어나는 걸 풀에 미리 준비해 두지 않으면 k6 자체가 VU 생성에 시간을 써서 RPS 목표를 제때 달성하지 못한다.
측정 포인트
- Grafana k6 대시보드: 스파이크 구간 req/s, error rate
kubectl get hpa -n shoong -w: HPA REPLICAS 변화 타임라인kubectl get pods -n shoong -w: pod 추가까지 걸리는 시간- Loki: 스파이크 구간 에러 로그 밀도
준비
scenarios-configmap.yaml에 spike.js 추가 후 ArgoCD sync → CronJob 수동 트리거 방식은 Load Test와 동일. spike.js를 실행하는 별도 CronJob k6-spike-test를 추가한다.
4-2. 테스트 전 상태
Load Test 종료 후 HPA가 scale-in 완료한 것을 확인한 뒤 Spike Test를 시작했다. 시작 시점의 pod 수가 min=2임을 확인해두지 않으면, 나중에 scale-out 타임라인을 볼 때 기준점이 없어진다.
HPA 상태

모든 서비스 REPLICAS=2, CPU 2~3%/70%. Load Test가 끝나고 HPA scale-in이 완료된 상태. Spike Test 시작 기준점으로 충분하다.
Pod 상태

delivery, kitchen, notification, order 모두 Running 2/2. batch Job은 Completed 상태로 서비스 파드에는 영향 없음.
4-3. 실행 절차
Load Test와 절차는 거의 동일하다. 다른 점은 ArgoCD sync가 한 번 더 필요하다는 것(ConfigMap과 CronJob을 새로 추가했으므로)과 트리거할 CronJob 이름이 k6-spike-test라는 것뿐이다.
shoong-gitopspush 후 ArgoCD에서loadtest-dev애플리케이션 Synced 상태 확인k6-spike-testCronJob이 배포됐는지,suspend: true상태인지 확인- 테스트 전 HPA, Pod 기준 상태 캡처 (4-2에서 완료)
kubectl create job --from=cronjob/k6-spike-test ...명령으로 k6 Job 수동 실행- HPA와 Pod 변화를 별도 터미널에서 관찰 — 스파이크 진입 시점을 기록
- 테스트 종료 후 k6 Job 로그, Grafana 대시보드 확인
# k6-spike-test CronJob 배포 및 suspend 상태 확인
kubectl get cronjob k6-spike-test -n loadtest
# Spike Test Job 수동 트리거
jobName="k6-spike-$(date +%Y%m%d-%H%M%S)"
kubectl create job --from=cronjob/k6-spike-test $jobName -n loadtest
실행 중에는 HPA와 Pod 변화를 별도 터미널에서 관찰했다. Spike Test에서 이 두 명령이 핵심이다. 스파이크가 들어오는 순간부터 REPLICAS 숫자가 언제 바뀌는지를 타임라인으로 기록해두어야 나중에 "HPA가 몇 초 만에 반응했는가"를 설명할 수 있다.
kubectl get hpa -n shoong -w
kubectl get pods -n shoong -w
테스트 종료 후 k6 Job 로그를 확인했다.
kubectl get jobs -n loadtest
kubectl logs -n loadtest job/$jobName
4-4. 실행 결과
Spike Test는 23:09:38에 시작했다. 시나리오상 2분간 10 RPS 기준선을 유지한 뒤 30초 만에 100 RPS로 올라가므로 스파이크 진입 시각은 약 23:11:38 + 30초 = 23:12:08 부근으로 예상됐다. 그런데 실제 부하 반영은 그보다 늦게 나타났고 HPA가 처음 반응한 시각은 23:18:55였다.
- Job 생성 직후

k6-spike-20260527-230938Job이 Running 상태. 이 시각(23:09:38)을 기준으로 stage별 진입 시각을 역산한다.
- HPA 첫 반응

23:18:47에 order CPU가 108%, kitchen 76%, notification 62%까지 치솟았다(target 70% 초과). 8초 뒤인 23:18:55에 order REPLICAS가 2→4로 바뀌었다. HPA는 CPU 임계치 초과를 감지하면 한 번에 2단계 점프할 수도 있다.
- 새 Pod Init 단계

HPA scale-out 결과 새 order/kitchen pod이 생성됐다.Init:0/2는 Istio sidecar init container가 도는 단계로, 이 시점에는 아직 트래픽을 받지 못한다.
- scale-in 시작 시점

부하가 끝난 뒤(23:24 ramp-down) HPA가 곧바로 scale-in 하지 않고 약 5분간 cooldown을 유지했다. 23:29:09부터 kitchen 5→4, notification 4→3 순으로 줄어들기 시작했다. HPA 기본 stabilization window(downscale 5분) 동작이다.
- 완전 복귀

23:31:45 기준 4개 서비스 모두 REPLICAS=2, CPU 5~19%/70%. scale-in 완료.
Job 종료

Job duration 16분, threshold를 두 개 모두 넘겨 Failed로 종료.backoffLimit: 0이라 재시도 없이 한 번에 끝났다.k6 summary

전체 35158 요청 중 실패 15826건(45.01%). p(95) 6.83초, max 18.8초. threshold(p(95)<500ms,error rate<1%) 둘 다 큰 폭으로 미달.
- Grafana 전체 흐름

vus(노란선)는 23:18 부근에 0 → 최대 300까지 튀고, http_req_s는 약 118 req/s까지 도달했지만 동시에 http_req_s_errors(빨간선)도 100 req/s에 육박했다. peak RPS는 118 req/s. 패널의 "HTTP requests 19,332"는 checks 성공 카운트 기준이라 전체 요청 수와는 다르다 — 전체 통계(35,158 요청 / 15,826 실패 / 45.01%)는 4-5 결과 요약의 k6 summary 기준.
타임라인 정리
| 시각 | 사건 |
|---|---|
| 23:09:38 | k6-spike Job 시작 |
| 23:11:38~ | 기준선 10 RPS 유지 |
| 23:18:30~ | 100 RPS 부하 본격 진입 (Grafana 기준) |
| 23:18:47 | order CPU 108%, HPA 임계치 초과 |
| 23:18:55 | HPA 첫 반응 관측 — order REPLICAS 2→4 (CLI 스냅샷 간격 5초) |
| 23:24 부근 | k6 ramp-down 시작 |
| 23:29:09 | HPA scale-in 시작 (cooldown 약 5분) |
| 23:31:45 | 모든 서비스 REPLICAS=2 복귀 |
관찰 포인트
- HPA 반응 자체는 한 polling 주기(CLI 5초) 안에 관측됐다 — 23:18:47 스냅샷에서 임계 돌파, 다음 스냅샷인 23:18:55에 REPLICAS 변경 관측. 정확한 반응 시간은 polling 한계로 초 단위 단정은 어렵지만, HPA controller sync 주기(15초) 안에 끝났다는 의미에서 충분히 빠른 편.
- 다만 새 Pod이 Init container 단계를 거쳐 트래픽을 받기까지의 시간이 더 길어서 그 사이 누적된 요청들이 그대로 에러로 잡혔다(전체 실패율 45%).
- Spike Test 의도("HPA가 따라잡을 수 있는가") 관점에서는 HPA는 반응했지만 새 Pod이 Ready 되기 전 구간을 어떻게 흡수할 것인가가 다음 과제로 남는다. (warm pool, PDB, 사전 ramp-up, Istio retry 등이 후보)
- scale-in은 cooldown 5분 후 천천히 일어났는데, 이는 의도된 동작(짧은 부하 변동에 반복 scale 하지 않도록)이라 문제는 아니다.
4-5. Spike Test 결과 요약
| 항목 | 값 |
|---|---|
| Peak RPS | 118 req/s |
| 전체 요청 | 35,158건 |
| 실패 | 15,826건 (45.01%) |
| p95 응답시간 | 6.83s |
| HPA 첫 반응 시간 | 한 polling 주기(5초) 안 (CLI 스냅샷 기준) |
| Pod Ready까지의 흡수 갭 | 발생 (그 사이 요청 실패) |
| scale-in 완료 | ramp-down 후 약 7분 (cooldown 5분 포함) |
- threshold:
p(95)<500ms,error rate<1%모두 큰 폭 실패 - HPA: 한 polling 주기 안에 첫 scale-out 관측. order는 한 번에 2→4로 2단계 점프
- 병목: HPA 반응 자체는 빠르지만 새 Pod이 Init/Ready 단계를 거치는 사이 누적 요청이 그대로 에러로 잡힘
- 다음 단계: HPA 반응이 아니라 "새 Pod이 트래픽 받기까지의 갭"을 어떻게 줄일지가 개선 포인트
5. Spike Test (재시도, 50 RPS)
Spike Test를 정리하고 보니 100 RPS는 인프라가 정적으로도 못 견디는 부하였다. Load Test에서 30 RPS 안정 / 50 RPS 임계 / 100 RPS 한계 초과로 이미 확인된 영역이었는데 왜 100 RPS를 골랐을까.. 규모가 작은 인프라니까 좀 낮게 잡고 점점 커져야했을텐데 말이다.
4장의 측정값은 'HPA가 따라잡는가?'라는 Spike Test 본래 질문에 답하지 못했다. Spike Test가 답하고 싶은 건 단순히 'HPA가 발동하는가?'가 아니라 'HPA가 발동한 뒤 새 Pod이 합류해서 에러율이 회복되는가?'이다. 그러려면 부하 자체가 HPA가 max replica(=5)까지 띄웠을 때 처리 가능한 영역 안에 있어야 한다. 100 RPS는 그 영역을 이미 초과한 부하라 회복 곡선 자체가 그려질 수 없었고, 45% 실패율도 HPA 반응이 느려서인지 그냥 부하가 한계 초과라서인지 분리되지 않는다.
다시 시도한다. 이번에는 부하를 50 RPS로 낮춰서. 갑작스러운 진입 시점에 실패가 누적되다가 HPA scale-out 후 회복되는지를 측정한다.
5-1. 시나리오
후보 부하 수준을 Load Test 데이터와 맞춰보면 다음과 같다.
| RPS | Load Test에서 본 처리 가능성 | Spike로 줬을 때 의미 |
|---|---|---|
| 30 RPS | 안정 (p95 정상, 에러 없음) | HPA 트리거 임계(CPU 70%)에 못 닿을 가능성. scale-out 자체가 안 일어날 수 있음 |
| 50 RPS | 점진 ramp-up 시 실패율 1.58% — max=5까지 띄우면 정상 상태로 처리 | 초기 burst 실패 → scale-out → 1.58% 근처로 수렴하는 회복 곡선 측정 가능 |
| 100 RPS | 47% 실패 — HPA max=5도 부족 | 회복 곡선 자체가 안 그려짐. Stress Test에 가까움 |
50 RPS는 "정적 상태로는 빠듯하지만 max replica까지 띄우면 결국 처리되는" 영역이다. 이 부하를 갑자기 주면 시작 시점에는 Pod 2개로 못 받아 실패가 누적되다가, HPA scale-out 후 새 Pod이 합류하면 실패율이 1.58% 근처로 수렴해야 한다. 그 수렴 곡선이 보이면 "HPA가 따라잡았다"고 말할 수 있다.
100 RPS는 이 곡선 자체가 그려질 수 없었고, 70 RPS 같은 중간 값은 Load Test에서 검증되지 않아 처리 가능 여부가 불명확하다. 결과 해석을 흐리지 않으려면 Load Test로 임계가 검증된 50 RPS에서 다시 측정하는 것이 가장 합리적이라고 봤다.
import { orderRequest } from "./common.js";
export const options = {
scenarios: {
spike: {
executor: "ramping-arrival-rate",
startRate: 0,
timeUnit: "1s",
preAllocatedVUs: 30,
maxVUs: 300,
stages: [
{ duration: "2m", target: 10 }, // 기준선
{ duration: "30s", target: 50 }, // 스파이크: 30초 만에 10 → 50 RPS
{ duration: "5m", target: 50 }, // 50 RPS 유지 (HPA 반응 + 회복 관찰)
{ duration: "30s", target: 10 }, // 감소
{ duration: "2m", target: 10 }, // 회복 확인
{ duration: "1m", target: 0 }, // ramp-down
],
},
},
thresholds: {
http_req_duration: ["p(95)<500"],
http_req_failed: ["rate<0.01"],
},
};
export default function () {
orderRequest();
}
기존 100 RPS 시나리오와 다른 점:
target: 100→target: 50preAllocatedVUs: 50→30(부하가 줄었으므로)maxVUs: 500→300
측정 포인트는 동일
- HPA REPLICAS 첫 변경 시각 (스파이크 진입 대비 몇 초)
- 새 Pod Running 전환 시각 (HPA 변경 대비 몇 초)
- 50 RPS 유지 구간에서 에러율이 시간에 따라 감소하는가
- 회복 구간(10 RPS) 응답시간 정상화 여부
이전 100 RPS 테스트와 비교했을 때 가장 다르게 보고 싶은 것은 마지막 항목이다. 100 RPS에서는 인프라가 어차피 처리할 수 없어서 시간이 지나도 에러율이 떨어지지 않았다. 50 RPS에서는 HPA가 scale-out을 마치고 새 Pod이 트래픽을 받기 시작하면 에러율이 감소세로 돌아서야 한다. 그 변곡점이 보이면 "HPA가 따라잡았다"고 말할 수 있다.
5-2. 준비
scenarios-configmap.yaml의 spike.js를 50 RPS 시나리오로 교체한다. CronJob은 그대로 k6-spike-test를 사용한다(시나리오 파일만 바뀌므로 새 CronJob은 필요 없음).
5-3. 실행 절차
절차는 4-3와 동일하다. 트리거할 CronJob 이름도 k6-spike-test 그대로(시나리오 파일만 50 RPS로 교체됐다).
jobName="k6-spike-$(date +%Y%m%d-%H%M%S)"
kubectl create job --from=cronjob/k6-spike-test $jobName -n loadtest
이번 테스트에서 주목한 캡처 포인트
| 시점 | 캡처 대상 | 봐야 할 것 |
|---|---|---|
| Job 생성 직후 | k6 Job Running | 시작 시각 기준점 |
| 스파이크 진입 (~2:30 경과) | 터미널 A — HPA REPLICAS 변경 | 4-4 대비 반응 양상 차이 |
| HPA 변경 직후 | 터미널 B — 새 Pod Init/Running | Ready까지 걸린 시간 |
| 50 RPS 유지 후반 | 터미널 A — HPA CPU 안정, 에러 감소 | 회복 곡선이 보이는가 (핵심) |
| 종료 후 | k6 Job 로그, Grafana overview | 최종 실패율, 시간대별 에러 분포 |
4-4와 가장 다르게 봐야 할 것은 50 RPS 유지 구간 중반 이후 에러 발생이 줄어드는 변곡점이 있는가이다. 100 RPS 때는 끝까지 평탄했지만, 이번에는 HPA가 따라잡으면 분명한 변곡점이 생긴다. Grafana는 테스트 종료 후 시간 범위를 맞춰 캡처하면 되고, 진행 중에는 터미널 A/B를 우선 확보한다.
테스트 전 HPA/Pod 평시 상태 확인

4-4 Spike Test 종료 후 충분히 시간이 지나 모든 서비스가 REPLICAS=2로 돌아온 상태. Spike Test 2 시작 기준점.
5-4. 실행 결과
k6 컨테이너 로그 기준 테스트 실제 시작 시각은 17:01:34. 시나리오 stage별 진입 시각을 다시 정리하면:
| 누적 경과 | 시각 | 단계 |
|---|---|---|
| 0:00 | 17:01:34 | 10 RPS 기준선 시작 |
| 2:00 | 17:03:34 | 스파이크 ramp-up 시작 |
| 2:30 | 17:04:04 | 50 RPS 도달 |
| 7:30 | 17:09:04 | 50 RPS 유지 끝, ramp-down 시작 |
| 11:00 | 17:12:34 | 시나리오 종료 |
Job 생성 직후 — 16:54:43

k6-spike-20260528-165443Job이 Running 9s. Job 이름의 timestamp(16:54:43)는 jobname 환경변수를 내가 따로 추가해서 나온거다. 실제 Job 생성 시각이나 부하 시작 시각과는 별개고, 진짜 시작은 17:01:34.k6 실제 시작 직후 상태 — 17:01


17:01:45 — k6 시작 11초 후. 10 RPS 기준선 구간이라 HPA REPLICAS=2, CPU 2~3% 그대로다. 약 2분 뒤 17:03:34부터 스파이크 ramp-up이 들어온다.HPA 첫 반응 — 17:04

CLI 캡처. 17:04:26 스냅샷에서 order CPU 111%, kitchen 87%로 임계 돌파가 보였고, 5초 뒤 17:04:34에 order REPLICAS 2→4, kitchen 2→3으로 첫 scale-out이 잡혔다.

Grafana kube_horizontalpodautoscaler_status_current_replicas{namespace="shoong"} 시계열. CLI보다 정밀도는 낮지만(scrape 주기 15초) step function이라 변경 시점이 한 눈에 들어온다. 17:04:45 datapoint에서 order 2→4, kitchen 2→3, notification 2→3이 동시에 점프하고, 17:05:00에 order 4→5, 17:05:15에 kitchen 3→4가 추가로 들어갔다. delivery는 50 RPS 부하 경로에 직접 안 걸려서 끝까지 2 유지.
두 출처 다 polling 한계가 있어서 진짜 반응 시간을 초 단위로 못 잡는다. 다만 둘 다 한 polling 주기 안에 잡혔고, HPA controller 자체 sync 주기가 15초인 걸 감안하면 이 설정에서 나올 수 있는 거의 최선으로 보인다.
새 Pod Init / Ready 갭

CLI 캡처. HPA scale-out 결과 새 order/kitchen pod이 떴다.Init:0/2,PodInitializing상태라 아직 트래픽은 못 받는 시점.
kube_deployment_status_replicas_ready{namespace="shoong"}시계열을 17:04:20~17:06:30로 줌인. HPA가 결정한 시각과 실제 Ready replica가 늘어난 시각을 같은 축에서 비교.서비스 HPA current_replicas Deployment replicas_ready Pod startup 갭 order 17:04:45 → 4 17:04:50 부근 → 4 약 5~10초 order 17:05:00 → 5 17:05:30 부근 → 5 약 30초 kitchen 17:04:45 → 3 17:05:00 부근 → 3 약 15초 kitchen 17:05:15 → 4 17:05:30 부근 → 4 약 15초 notification 17:04:45 → 3 17:05:00 부근 → 3 약 15초 첫 scale-out에서는 새 pod이 빠르게 떴고(이미지 캐시·Init container 통과가 짧았던 듯), max까지 늘어나는 두 번째 단계에선 길어졌다. HPA가 결정한 시점부터 실제 트래픽을 받을 capacity가 늘기까지 최대 30초 정도 갭이 있다는 얘기.

같은 시간대 k6 메트릭 줌인. 17:04~17:06 2분 동안 6,602 요청 중 697건 실패(Checks Success 94%). 전체 1,630건 실패의 약 43%가 이 burst 구간에서 나왔다 — 부하 진입 직후부터 새 Pod Ready 직전까지가 실패가 몰리는 구간.HPA 반응 자체는 한 polling 주기 안에 끝났는데 Pod startup이 30초 정도라, 16% 실패의 큰 몫은 결국 이 startup 갭에서 나온 셈이다. 그래서 개선 포인트도 HPA 설정 쪽이 아니라 Pod warm-up 단계(이미지 pull, Istio sidecar init, readinessProbe 통과) 쪽으로 봐야 할 것 같다.
REPLICAS max 도달 + 부하 분산 — 17:05

CLI 캡처. 17:05:20에 order REPLICAS=5(max) 도달 후 HPA가 보는 평균 CPU가 94% → 81% → 60%로 내려간다(17:05:13~17:05:44). HPA target view 기준으로는 안정화가 시작된 모양.
order pod별 CPU(mCore) 시계열. 보라색 점선이 HPA target(70 mCore = CPU request 100m의 70%). 그래프 흐름을 따라가보면:- 17:04:00
17:04:45 — 기존 pod 2개(green, yellow)가 임계 한참 위인 220245 mCore까지 올라간다. 단일 pod이 50 RPS를 다 떠안고 있는 모양. - 17:05:00~17:05:30 — 새 pod 3개(blue, orange, red)가 0에서 올라온다. HPA가 띄운 pod이 실제 트래픽을 받기 시작한 시점.
- 17:05:30
17:06:30 — 기존 pod CPU가 200+에서 3080으로 떨어진다. 부하가 5개로 분산된 모양이 그대로 보인다.
- 17:04:00

kitchen도 같은 모양이다. pod 4개(max=4까지 도달)에서 분산이 같은 방식으로 일어났으니 order에서만 우연히 그런 건 아닌 듯.
다만 CPU가 임계선(70 mCore) 아래로 안정화된 건 아니다. 5개 pod에서 100~250 mCore 사이로 계속 출렁였고, 정상 수준 회복이라기보단 "max replica 안에서 부하를 나눠가지며 임계 위에서 처리 가능한 영역으로 들어온" 정도로 보는 게 맞을 것 같다. CPU가 임계선 아래로 명확히 내려온 건 17:09 ramp-down 이후고.
4-4(100 RPS)와 차이는 CPU 값 자체보단 요청을 처리하느냐인 것 같다. 4-4는 같은 max=5에서도 실패율 45% / Checks 54%로 처리가 안 됐고, 5-4는 16% / 91.9%로 빠듯해도 처리는 됐다. 50 RPS는 max replica 기준 "빠듯하지만 들어가는" 부하 정도로 정리할 수 있겠다. (4-4 시점 Prometheus 메트릭이 이미 날아가서 CPU 시계열 비교 캡처는 못 했고, 처리 결과 비교는 4-4 vs 5-4 비교 표로 대신.)
scale-in 시작 — 17:14

HPA가 부하 종료 후 곧바로 줄이지 않고 ramp-down 시작 시점(17:09:04, CPU가 임계 아래로 내려가기 시작)부터 약 5분 stabilization window를 두고 17:14:52에 첫 scale-in을 했다. 4-4와 동일한 HPA 기본 downscale 동작.완전 복귀 — 17:15

17:15:48 시점에 모두 REPLICAS=2, CPU 2~3%. scale-in 완료.Job 종료

Job duration 15분. threshold(p(95)<500ms,error rate<1%)를 넘겨 여전히 Failed로 마감. 다만 threshold 미달이 곧 회복 실패는 아니다 — 시간대별 실패 분포는 Grafana에서 확인한다.Grafana 전체 흐름

전체 10,209 요청 중 실패 1,630건(약 16%). Checks Success Rate 91.9%. 4-4 대비 큰 폭 개선이고, 그래프 모양도 17:09 이후부터 빨간 실패선이 점차 0으로 수렴하는 형태로 잡힌다.
4-4(100 RPS) vs 5-4(50 RPS) 비교
| 항목 | 4-4 (100 RPS) | 5-4 (50 RPS) |
|---|---|---|
| 전체 요청 | 35,158 | 10,209 |
| 실패 | 15,826 (45.01%) | 1,630 (약 16%) |
| Checks Success | 54.98% | 91.9% |
| HPA 첫 반응 | 한 polling 주기 안 | 한 polling 주기 안 |
| order REPLICAS | 2→4 (한 번에 2단계) | 2→4 (한 번에 2단계) |
| 처리 결과 | max=5에서도 처리 불가 | max=5에서 빠듯하지만 처리됨 |
100 RPS는 max=5까지 띄워도 부족한 부하였고, 50 RPS는 max에 도달한 뒤 부하가 5개 pod으로 분산되며 빠듯하게나마 처리되는 영역으로 들어왔다. Spike Test가 답하려던 "HPA가 따라잡는가"에 50 RPS 기준으로는 "그렇다"고 정리할 수 있을 것 같다.
관찰 포인트
- HPA 반응 자체는 한 polling 주기 안에 끝났고 4-4와 동일하게 빠른 편. 즉 HPA 설정 자체에는 문제가 없는 것 같다.
- 새 Pod이 트래픽 받기 시작하기까지의 갭은 여전히 있다 — 16% 실패의 대부분이 이 초기 burst 구간(17:04~17:06)에 몰렸다.
- 부하가 max replica 처리 영역 안에 있으면 빠듯해도 처리는 된다는 게 5-4의 결론. 다만 CPU 자체가 평시 수준으로 돌아온 건 아니고, 임계 위에서 5개 pod이 분산해서 부담한 정도.
- 16% 실패율은 SLA 목표(<1%)와는 거리가 멀다. 다음 개선 후보는 초기 burst 구간 흡수 — warm pool, readinessProbe 튜닝, HPA
behavior.scaleUppolicy 조정 등.
5-5. Spike Test 2 결과 요약
| 항목 | 값 |
|---|---|
| Peak RPS | 187 req/s |
| 전체 요청 | 10,209건 |
| 실패 | 1,630건 (약 16%) |
| Checks Success | 91.9% |
| HPA 첫 반응 | 한 polling 주기 안 (4-4와 동일) |
| Pod startup 갭 | 최대 약 30초 (HPA 결정 → 새 Pod Ready) |
| scale-in 완료 | ramp-down 후 약 7분 (stabilization 5분) |
- threshold:
p(95)<500ms,error rate<1%여전히 실패. 그치만 4-4 대비 실패율이 45% → 16%, Checks Success가 55% → 91.9%로 크게 개선됐다. - HPA: 4-4와 동일한 패턴으로 작동 — order 2→4→5, kitchen 2→3→4, notification 2→3. CPU 임계 인지 후 한 polling 주기 안에 scale-out 결정.
- 병목: HPA 자체가 아니라 새 Pod이 트래픽 받기까지의 startup 갭. 전체 실패의 약 43%가 burst 구간(17:04~17:06)에 몰림.
- 다음 단계: Pod warm-up 단계(readinessProbe 튜닝, warm pool, HPA
behavior.scaleUppolicy) 개선 후 재테스트.
6. 종합 결론과 다음 액션
6-1. 세 테스트로 본 것
- Load Test (10/30/50 RPS) — 30 RPS까지는 안정, 50 RPS부터 임계. order/kitchen이 동시에 500을 뱉어서 DB 커넥션 경합으로 추정했지만 로그에서 Prisma 에러 코드(P2024)는 직접 확인되지 않아 가설로만 남았다.
- Spike Test 1 (10 → 100 RPS) — 부하 자체가 Load Test 기준 한계 초과 영역이라 HPA 반응 능력을 깔끔하게 측정하지 못했다. 45% 실패라는 결과가 HPA가 따라잡지 못한 건지, 그냥 부하가 한계 초과인 건지 분리되지 않은 셈.
- Spike Test 2 (10 → 50 RPS) — 처리 가능한 부하로 재조정해서 측정. HPA 자체 반응은 polling 주기 안에 끝났고, max replica에 도달한 뒤 부하가 분산되며 처리되는 영역으로 들어갔다. 다만 16% 실패의 큰 몫은 새 Pod이 트래픽 받기까지의 startup 갭에서 나왔다.
HPA 설정 자체에는 문제가 없어 보이고, 진짜 병목은 (a) 부하 진입 직후의 Pod warm-up 갭과 (b) DB 레이어 두 군데로 좁혀진다.
6-2. 개선해보고 싶은 것
1순위 — Pod warm-up 갭 단축
Spike Test 2에서 16% 실패의 약 43%가 burst 구간에 몰렸고, HPA 결정부터 새 Pod이 트래픽 받기까지 최대 30초 갭이 있었다. 이 갭을 줄이는 게 가장 효과가 클 것 같다.
- readinessProbe 튜닝 — initialDelaySeconds·periodSeconds·failureThreshold 값 조정 여지 검토
- HPA
behavior.scaleUppolicy 조정 — 한 번에 더 많은 pod을 미리 띄우도록 (예:policies: [{type: Pods, value: 4, periodSeconds: 15}]) - min replica 상향 — min=2를 3 또는 4로. burst 흡수력은 좋아지지만 평시 비용도 같이 올라감
- Istio sidecar 시작 시간 단축 —
holdApplicationUntilProxyStarts, image pre-pull 등
2순위 — Prisma 커넥션 가설 검증
Load Test 50 RPS에서 추정만 했던 DB 커넥션 경합을 확인:
- Prisma
connection_limit명시 설정 (지금은 미설정 → pod당 5 default) - 재테스트로 Prisma 에러 코드(P2024) 직접 관측
- 검증되면
pod 수 × connection_limit ≤ RDS max_connections범위 안에서 적정 값 튜닝
3순위 — 분산 트레이싱
추정에 의존하는 결론을 정량 데이터로 바꾸기 위해:
- Tempo로 확인하기
- order → kitchen → notification 호출 체인에서 latency가 어디서 쌓이는지
- DB 호출 시간과 내부 서비스 호출 시간 분리 측정
4순위 — 인프라 자체 검토
- RDS db.t3.micro 인스턴스 한계 — 더 큰 인스턴스로 변경 시 임계 RPS가 어디로 옮겨가는지
- HPA max=5 → 8/10 상향 (노드 capacity와 같이 검토 필요)
'Project: Shoong-Delivery' 카테고리의 다른 글
| [보안] WAF · CloudFront OAC · IRSA · ESO — 보안 설계와 트레이드오프 (0) | 2026.05.25 |
|---|---|
| [Observability] EKS MSA 옵저버빌리티 통합 — Prometheus + Loki + Tempo + Grafana (0) | 2026.05.22 |
| [AWS] Shoong Delivery 네트워크 설계 정리 (0) | 2026.05.20 |
| [자동화] terrafom apply 후 수동 작업 대신 자동화 스크립트 (0) | 2026.05.20 |
| [Terraform] shoong-delivery 테라폼 구조 및 회고 (0) | 2026.05.20 |