목록으로
AWS

동일한 부하 스크립트를 온프레미스와 AWS 환경에 각각 테스트 해 보았다.

김민석2026년 08월 18일13분 읽기

온프레미스(As-Is) vs AWS(To-Be)

같은 k6 스크립트, 같은 부하를 온프레미스 단일 VM과 AWS ECS Fargate에 그대로 가해서, "정말 클라우드가 버티는가"를 숫자로 확인한 기록. 결론부터: 온프레미스는 평상시 부하(VU 100)에서조차 이미 마비 상태였고, 클라우드는 42만 건이 넘는 요청을 에러 0%로 처리했다.

들어가며

부하테스트를 하면 보통 "얼마나 버티는지"를 확인한다고 생각한다. 그런데 이번 테스트의 결론은 조금 달랐다. 버티고 말고를 따질 필요도 없이, 온프레미스는 평상시 트래픽 수준에서조차 이미 죽어있었다.

같은 k6 시나리오(Baseline·Stress·Spike)를 온프레미스 단일 VM과 AWS ECS Fargate에 똑같이 쐈다. 온프는 세 시나리오 전부 p95가 44초 근처로 수렴하며 마비됐고, 클라우드는 태스크를 1 → 4개로 자동 확장하며 요청을 전부 받아냈다. 그리고 이 과정에서 클라우드 쪽에 잠복해 있던 버그 2건(Redis 역직렬화, RDS ReplicaLag)까지 실제로 찾아내서 고쳤다.

이 글의 한 문장 요약은 이거다 — 느려서 무너진 게 아니라, 캐시도 없고 수평 확장도 안 되는 구조라서 무너졌다.


한눈에 보는 결론

같은 부하·같은 스크립트(k6_load_v3.js)를 온프레미스 단일 VM과 AWS ECS Fargate에 그대로 가했다. 결과는 정반대였다.

시나리오온프레미스 (As-Is)AWS 클라우드 (To-Be)
Baseline (~100 RPS)p95 43.99sp95 31ms (워밍업 후)
Stress (→2000 RPS)p95 45.02s, 처리량 60 → 33 RPS 역행, 유실 93%p95 ~2.2s, 에러 0%, 태스크 1 → 4 자동 확장, 427,421건 무장애
Spike (→2500 RPS)DB CPU 100% 2회 재포화, 첫 타임아웃 3건단일 태스크 큐잉 흡수, 에러 0%, 종료 후 1분 내 복구
공통 병목DB CPU 포화 + HikariCP pending 169 (App CPU 13~20% 유휴)병목 없음 — RDS CPU 한 자릿수, 태스크 수평 확장

온프레미스 단일 VM은 평시 부하조차 p95 44초로 사실상 응답 불가였고, 부하를 올릴수록 처리량이 오히려 꺾이는 구조적 천장(DB CPU 포화 + 커넥션 풀 고갈)을 보였다. 동일 부하에서 AWS는 에러 0%로 흡수하며 태스크를 1 → 4로 자동 확장했다. 이 격차가 VMware → AWS 마이그레이션의 정량적 당위성이다.


1. 목적과 테스트 설계

마이그레이션의 당위성을 "느낌"이 아니라 As-Is/To-Be 수치 대비로 증명하는 것이 목적이다. 멘토 조언("부하 테스트는 As-Is/To-Be 대비가 정석, 기준은 동시 접속자 수")에 따라 ① 온프레미스 현 구성의 한계점과 병목 특정 ② AWS 구성의 탄력성(오토스케일 실작동·무장애 흡수) ③ 장애·복구 거동을 MTTR·SLO·BCP·RCA 관점으로 해석한 운영 설계 근거 확보를 목표로 잡았다.

시나리오 3종 (공통 스크립트)

시나리오목적부하 패턴
baseline평상시 기준선~100 RPS 일정 (RATE_MULT=0.5)
stress한계점 탐색100 → 2000 RPS 계단식 11단계
spike순간 폭증 내성순간 최대 2500 RPS 급증 → 급감

공통 파라미터는 RATE_MULT=0.5, MAX_HANDLE=100(반복 조회 농가 100명 = 캐시 효과 측정 범위). 엔드포인트는 공개 프로필 60% + health 40%(+ 일부 calendar), 부하 기준은 동시 접속자(VU)로 현실적 동시 농부 100~200명을 가정했다.

환경 비교

항목온프레미스 (As-Is)AWS 클라우드 (To-Be)
컴퓨팅VMware VM, App 2vCPU/2GB 고정 1대ECS Fargate, 오토스케일 1 → N (CPU 70%, 3분 평가)
DBPostgreSQL VM 2vCPU/2GBRDS (연결 한도 ~225, Multi-AZ)
캐시없음ElastiCache Redis
입구 / 부하생성WAS 직접(192.168.x) / 노트북 k6 + TailscaleALB(HTTPS) / 배스천 EC2(t3.small)

측정 한계 (정직한 기록): 두 환경 모두 k6에서 "Insufficient VUs, reached 2000" 경고가 떴다. 부하 생성기(클라이언트) 자체가 상한이었다는 뜻이며, dropped_iterations의 일부는 클라이언트 도착률 한계다. 그러나 서버 거동 차이는 명확하다. 온프는 달성 처리량이 수십 RPS로 붕괴하며 p95 44초(서버 포화)인 반면, 클라우드는 같은 조건에서 427,421건을 에러 0%로 처리하고 태스크를 확장했다. 비교는 유효하며, 클라우드의 진짜 한계는 클라이언트에 막혀 미측정인 만큼 더 높다.


2. As-Is — 온프레미스 (단일 VM)

Baseline — 평시 부하에서 이미 "마비"

100 RPS 평상시 부하인데도 p95 43.99초(기준 0.8초의 약 55배), 달성 처리량은 목표의 60%(max 60.5 / mean 35.3 RPS)에 그쳤다. 에러는 0%지만 응답이 44초 걸리는 것은 사실상 장애다.


이미지

온프 Baseline · 자원포화 — DB CPU max 72.1%로 위험선(70%) 돌파, App(WAS) CPU는 20.4%로 유휴. 병목은 명확히 DB 레이어.


이미지

온프 Baseline · 커넥션풀 — HikariCP pending 169(풀 30 전부 점유·idle 0). 응답 지연의 직접 원인. JVM Heap 32.6%로 앱은 여유. 총 9,515건 처리 중 dropped 5,087건(약 35% 유실).

Stress — DB CPU 100% 천장에 고정

100 → 2000 RPS로 올리자 DB CPU가 100% 완전 포화(mean 75.8%)됐다. 흡수할 추가 용량이 없어 달성 처리량이 오히려 60→33 RPS로 역행하고, 목표 부하의 93%(480,047건)가 유실됐다. p95 45.02초. HikariCP pending 169는 baseline과 동일하게 고정됐다.


이미지

온프 Stress · 자원포화 — DB CPU가 100%에 평탄하게 붙어 정체. WAS CPU는 13.2%. "더 줘도 DB가 먼저 천장을 친다"의 가장 명확한 증거.


이미지

온프 Stress · 부하 — 달성 요청률 max 32.9 / mean 12.2 RPS. baseline(60.5)보다 낮다. 서버 포화로 처리량이 역행하는 구간.

Spike — 폭증 흡수 불가 + 첫 타임아웃

순간 폭증(목표 2500 RPS)에서 DB CPU가 "두 개의 산" 모양으로 두 차례 100% 재포화됐다(mean 66.2%). 폭증을 흡수할 버퍼가 없어 처음으로 타임아웃 실패 3건(60초 초과)이 발생했다. p95 44.88초, max 1분 0초. 커넥션풀은 세 시나리오 모두 동일하게 pending 169에서 막혔다.


이미지

온프 Spike · 자원포화 — DB CPU "두 개의 산": 1차 상승 → 중간 저점(~36%) → 2차 100% 재포화. 충격 후 회복 전에 재포화.


이미지

온프 Spike · 지연·에러 — 폭증 구간(15:49~15:50)에 4xx 401 스파이크, k6 로그에 request timeout 3건. 온프에서 처음 발생한 실질 실패.

종합 — "죽지는 않지만 마비"

10분 이상 고부하에도 전 인스턴스는 UP(Healthy 4/0)을 유지했다. 문제는 "죽음"이 아니라 "마비"다 — 살아는 있으나 p95 44초로 응답 불가다. 일관된 천장은 ① p95 4445초 ② DB CPU 72 → 100% 포화 vs App CPU 13~20% 유휴 ③ HikariCP pending 169 고정 ④ 유실 35% → 93% → 91% ⑤ 부하를 올려도 처리량 역행으로 나타났다.

왜 이렇게 무너졌나 — 인과관계 5단계

App CPU는 노는데 p95 44초인 현상을 인과로 풀면 이렇다.

  1. 근본 원인 = 캐시 부재 + 단일 DB 2vCPU. Redis가 없어 모든 읽기가 DB로 직행한다. 프로필·캘린더 조회가 매번 복수 테이블 JOIN으로 떨어지고, DB는 2vCPU뿐이라 연산이 곧 천장이 된다.
  2. App CPU가 노는데 마비인 이유 — 톰캣/HikariCP 스레드가 DB 응답을 블로킹 대기(I/O wait)로 멈춰 있다. "일하는 중"이 아니라 "기다리는 중"이라 CPU를 거의 안 쓴다. 그래서 App을 증설해도 소용없다.
  3. pending 169의 이유 — 풀 30개가 전부 느린 쿼리에 묶이면 새 요청이 대기 큐에 쌓인다. 동시 처리량 = 도착률 × 응답시간(Little's law)인데 응답시간(44초)이 폭발해 풀 처리율을 초과하며 169까지 적체됐다. 풀을 키워도 근본(느린 DB)이 그대로면 pending 위치만 바뀐다.
  4. 처리량 역행의 이유 — DB CPU가 100%에 닿은 뒤엔 동시 쿼리들이 같은 자원(CPU·락·버퍼)을 두고 경쟁해 개별 쿼리가 더 느려진다. 전형적 혼잡 붕괴(congestion collapse)다.
  5. 세 시나리오 p95가 모두 4445초인 이유 — 부하 방식과 무관하게 같은 평형점(풀 포화 + DB 100%)에 수렴하기 때문. 한계가 부하 패턴이 아니라 구조(용량)로 정해진다는 뜻이다.

요약하면 온프의 문제는 "느려서"가 아니라 구조가 한 곳(단일 DB)에서 직렬화되기 때문이다. 캐시가 그 직렬 구간을 우회시키고 수평 확장이 용량을 늘리는데, 온프엔 그 두 레이어가 없다.


3. To-Be — AWS 클라우드 (ECS Fargate)

Baseline & RCA — Redis 역직렬화 버그 발견·수정

초기 baseline에서 응답은 빨랐으나(p95 65ms) app_errors가 59.67%에 달했다. 관측 → 로그 → 코드를 추적해 원인을 규명했다. 증상은 "첫 요청 200 → 두 번째부터 500 → TTL(10분) 만료 후 다시 200"이라는 간헐 장애·자가복구 패턴이었다.

원인은 RedisConfigGenericJackson2JsonRedisSerializer + DefaultTyping.NON_FINAL 조합이 final 타입(Java record)에는 타입 정보를 붙이지 않는 비대칭이었다. record DTO 캐시가 쓰기는 {본문}, 읽기는 [타입, {본문}](WRAPPER_ARRAY)을 기대해, 캐시 HIT마다 역직렬화 예외 → 500이 발생했다. non-final 타입(crops)이 멀쩡했던 점이 이 가설을 뒷받침했다.

조치는 단순 버그 수정에 그치지 않고 재발 방지·장애 내성까지 설계했다.

  • 직렬화 정상화DefaultTyping.NON_FINAL → EVERYTHING으로 record/final 루트에도 타입 정보를 부여해 쓰기·읽기 라운드트립을 복원.
  • 캐시 키 버전화CacheNames.VERSION(v2) 도입으로 직렬화 포맷이 또 바뀌어도 옛 키가 자동 격리돼 롤링 배포 중 포맷 혼재를 차단.
  • 캐시 장애 내성CacheErrorHandler로 역직렬화 오류를 캐시 미스로 강등 + 깨진 키 evict → DB 폴백, Redis 일시 장애에도 500 대신 서비스가 지속되도록 설계.

변경 파일은 RedisConfig.java·CacheNames.java 2개이며, 코드 수정 후 에러는 **59.67% → 0%**로 떨어졌다.


이미지

클라우드 Baseline(버그) — ALB Target 5xx(앱 500) 8.94K 폭증. 캐시 히트 시점마다 500. 캐시가 독이 된 상태.


이미지

클라우드 Baseline(버그) — 현재 캐시 히트율 0%(빨강). 역직렬화 실패로 캐시가 무력화됨.


이미지

클라우드 Baseline(수정 후) — ALB 5xx 전부 0, 응답은 콜드스타트 첫 1점(p99 7.9s)만 튀고 14:21부터 평탄(p50 7.56ms). 버그 해소 입증.


이미지

클라우드 Baseline(수정 후) 요약 — 에러율 0%, Running 태스크 1(100 RPS는 CPU 13%로 오토스케일 트리거 미달 — 설계대로), RDS 연결 32.

이 과정에서 부하 시험이 운영 전 잠복 버그를 드러냈고, 관측 → 로그 기반 원인 규명 → 코드 수정 → 재현 시험으로 검증 루프를 완성했다.

Stress — 오토스케일 1 → 4, 2000 RPS 에러 0% 흡수

CPU 평균 70%를 3분 연속 돌파하자 CloudWatch 알람이 발동해 desired 태스크가 1 → 2(14:53)→3(14:55) → 4(14:57)로 단계 증설됐다. 전 구간 app_errors 0% / http_req_failed 0%(0/427,421건), ALB 5xx 0이었다.


이미지

클라우드 Stress · 가용성·오토스케일 — ECS 태스크 Running/Desired 1 → 2 → 3 → 4 단계 증설. 탄력 확장의 직접 증거.


이미지

클라우드 Stress · 자원포화 — ECS CPU avg 99.8% / max 100%(mean 70.1%)로 70·80% 선 돌파 → 스케일아웃 트리거. RDS CPU는 낮음.


이미지

클라우드 Stress · 요약 — 에러율 0%, Running 태스크 4, RDS 연결 122/225(한도 내 여유). RequestCount는 peak 66.6K/분(mean 20.4K)으로 온프와 자릿수가 다른 처리량.

지연 SLO 관점에서는 p95 2.18s로 "p95<2s" 목표를 살짝 초과(knee 부근), p99 3.06s였다. 무결성(에러 0)은 지키되 지연이 SLO 경계 — 다음 튜닝 포인트로 남긴다.

Spike — 단일 태스크 큐잉 흡수 + 즉시 복구(MTTR)

직전 stress 후 desired=1로 축소된 단일 태스크에 순간 폭증이 충돌했다. 폭증이 1분 고점이라 오토스케일 3분 평가를 미충족 → 미발동(설계상 정상). 그럼에도 에러 0%(0/75,278)로, 단일 태스크가 에러 대신 큐잉(지연)으로 흡수하고 종료 후 1분 내 p50 ms 단위로 복귀했다.


이미지

클라우드 Spike · 자원포화 — ECS CPU 15:36 29% → 15:37 avg 84.9%/max 99.9% → 15:38 ~20% 급락(고점 약 1분). 짧아서 오토스케일 평가창(3분) 미충족.


이미지

클라우드 Spike · 지연·에러 — 응답시간 15:36 피크(p99 9.52s) 후 15:38 ~0으로 감쇠, ALB 5xx 전부 0. 에러 없이 지연으로 흡수 후 즉시 회복.


이미지

클라우드 Spike · 캐시 — 현재 캐시 히트율 100%(초록). Redis가 DB를 보호. RDS CPU 피크도 35%에 그침. 에러율은 평탄 ~0%(max 0.0346%).

교훈: 짧은 스파이크는 반응형 오토스케일(3분 지연)로 못 막는다. 기존 용량이 큐잉으로 버티되 지연이 오르므로, 최소 태스크 버퍼 + 요청 큐/타임아웃 설계가 필요하다(개선 백로그).


4. As-Is vs To-Be 직접 비교

시나리오 / 지표온프레미스 (As-Is)AWS 클라우드 (To-Be)
Baseline p9543.99s~31ms (워밍업 후)
Stress p95 / 에러45.02s / 0% (마비)~2.18s / 0%
Stress 처리량 거동60 → 33 RPS 역행정상 흡수, 427,421건 무장애
Stress 확장불가 (단일 VM 고정)태스크 1 → 4 자동 확장
Stress 유실93% (480,047건)0% 실패 (도착률은 클라이언트 한계)
Spike 거동DB CPU 100% 2회 재포화, 타임아웃 3건단일 태스크 큐잉, 에러 0% 흡수
Spike 복구(MTTR)자력 회복 느림·재포화종료 후 1분 내 복귀
병목 자원DB CPU 100% + HikariCP pending 169없음 (RDS CPU 한 자릿수, 연결 122/225)

5. 멘토링 지표로 본 해석 (MTTR·SLO·BCP·RCA)

MTTR(평균 복구 시간): 클라우드 spike는 충격 종료 후 1분 내 p50 ms 복귀로 빠른 자력 회복을 보였다. 온프는 회복 전 재포화로 MTTR이 길고 불안정하다. "120초"는 일반값일 뿐, Farmily 기준 자체 복구 목표(SLO)를 정해 알람·헬스체크 기준으로 삼는다.

SLO: 무결성 SLO(에러율<1%)는 클라우드가 전 시나리오 충족, 온프는 응답시간 측면에서 전면 미달이다. 지연 SLO(p95<2s)는 클라우드 stress에서 경계(2.18s)로, 다음 튜닝 대상으로 명문화한다.

BCP(비즈니스 연속성): 온프는 "안 죽지만 마비"인 단일 장애점이다. 클라우드는 다태스크 + Multi-AZ + 오토스케일로 한 태스크 문제 시에도 연속성을 유지한다.

RCA + Lessons Learned — 부하 시험이 운영 전 잠복 이슈를 세 건 드러냈고, 모두 관측 → 원인 규명 → 조치로 종결했다.

  1. Redis record 캐시 역직렬화 버그 — NON_FINAL 직렬화 설정이 record(final)에 타입 정보를 안 붙여 캐시 HIT마다 500. 관측 → 로그 → 코드수정(EVERYTHING 전환 + 키 버전화 + CacheErrorHandler 폴백) → 재현검증으로 운영 전 차단. 에러율 59.67% → 0%.
  2. HikariCP 풀 고갈(pending 169) — 온프의 구조적 병목을 정량화. 클라우드 전환이 근본 해결.
  3. RDS ReplicaLag 이상(상시 평균 2.5분·최대 5분, 정상은 수 초) — 원인 규명·해결 완료. CPU·메모리·EBS BurstBalance·네트워크는 모두 정상이었고, 파라미터 그룹 farmily-pg18-logicalrds.logical_replication=1이 범인이었다. DMS 마이그레이션 CDC용으로 켰던 설정인데, DMS 태스크는 이미 삭제된 채 방치돼 logical replication이 WAL 생성량을 늘려 복제 지연을 유발했다. 영향은 Replica read 시 최대 5분 전 데이터 반환, Failover 시 최대 5분치 데이터 유실 위험이었다. 조치로 rds.logical_replication=0 전환 → ReplicaLag 즉시 정상화. 단 static 파라미터라 Primary 재시작(1~3분 다운타임)이 필요해 점검 시간에 적용한다.

Farmily SLO 제안 (AWS Well-Architected 신뢰성 기둥 기반)

멘토 조언("120초는 일반값일 뿐, 자체 복구 목표를 우리가 정한다")에 따라, 실측치 + AWS Well-Architected 신뢰성 기둥(REL11~REL13)에 근거해 제안한다. 팀·멘토 확인 후 확정한다.

SLI (측정 지표)SLO 목표근거
가용성 (성공률, non-5xx)99.9% / 월 (에러버짓 ≈ 43분/월)WA 신뢰성 기본선. Multi-AZ·다태스크로 달성 가능, 실측 에러 0%
지연 — 평상시p95 < 500ms, p99 < 1s실측 baseline p95 31ms로 충분히 충족. 읽기 위주 공개 API
지연 — 피크/스케일아웃 중p95 < 2s (허용 열화)실측 stress p95 2.18s(경계). 스케일아웃 5~6분 창 동안만 허용
에러율< 0.1% (5xx / 총요청)가용성 99.9%와 정합. 실측 0%
MTTR (순간부하 자력 회복)< 2분실측 spike 종료 후 1분 내 p50 복귀
오토스케일 반응< 6분 (트리거 → ALB 등록)실측 5~6분(3분 평가 + 기동)
RTO (복구목표시간)< 5분WA REL13. FIS failover(DB 15~30초) + 앱 회복 개선 후 목표
RPO (데이터 유실허용)≈ 0 (Multi-AZ 동기복제)Replica 비동기였으나 ReplicaLag 해결로 read 정합성 회복

에러 버짓 운용(SRE): 가용성 99.9% = 월 약 43분의 실패 허용량이다. 한 달 내 소진 시 신규 기능 배포를 멈추고 안정화에 우선순위를 두고(error-budget policy), 정상 시엔 배포 속도를 우선한다. 향후 Multi-AZ·개선 효과 검증 시 99.95%(월 ≈ 22분)로 상향 검토한다.


6. 마이그레이션 당위성 (정량 근거)

  1. 처리 능력: 동일 부하에서 온프는 수십 RPS에서 마비(p95 44초), 클라우드는 427,421건을 에러 0%로 처리했다. 자릿수 차이다.
  2. 탄력성: 온프는 단일 VM 고정이라 부하 증가 시 처리량이 역행한다. 클라우드는 CPU 70% 트리거로 태스크 1 → 4 자동 확장했다.
  3. 복원력: 온프는 spike에서 타임아웃·재포화, 클라우드는 무장애 + 1분 내 복구다.
  4. 여유와 비용 효율: 클라우드는 평시 태스크 1(CPU 13%)로 동작하다 필요할 때만 확장해 유휴 자원 낭비 없이 피크를 대응한다. 온프는 App CPU 20%가 놀면서도 DB 때문에 전체가 마비된다.

"동시 농부 100~200명" 현실 부하조차 온프 단일 VM은 감당하지 못하며, AWS는 그 수십 배 부하를 무장애로 흡수·확장한다. 마이그레이션은 선택이 아니라 서비스 지속의 전제다.


7. 발견 · 개선 백로그 · 다음 단계

영역항목담당
앱(캐시)calendar API @Cacheable 적용, publicProfile TTL 11s→300s 상향 후 재시험앱팀 협조
RDS 해결ReplicaLag 원인 = rds.logical_replication=1(DMS CDC 잔재) WAL 증가. 파라미터 0 전환 + Primary 재시작 점검 → 정상화인프라
오토스케일spike 대비 최소 태스크 버퍼 + 요청 큐/타임아웃 설계, 지연 SLO(p95<2s) 튜닝인프라
부하 생성배스천/생성기 스펙 상향(t3.medium+) 후 클라우드 진짜 한계(>2000 RPS) 재측정인프라
관측SLO 공식화, 알람 기준(5xx·DB CPU·HikariCP·ReplicaLag) 정비모니터링

결론

부하 시험은 마이그레이션의 당위성을 숫자로 확정했다. 온프레미스 단일 VM은 평상시 부하에서 이미 p95 44초로 마비했고, 그 원인(DB CPU 포화 + HikariCP 풀 고갈, 캐시·수평확장 부재)은 튜닝이 아니라 구조의 문제였다. 동일 부하에서 AWS는 에러 0%로 흡수하며 태스크를 자동 확장했고, 시험 과정에서 잠복 버그(Redis 역직렬화)와 ReplicaLag 이상까지 사전에 제거했다. 남은 과제(지연 SLO 튜닝·스파이크 버퍼)는 클라우드 위에서 푸는 운영 최적화 영역이며, 온프에서는 애초에 도달할 수 없는 단계다.

App CPU가 20%로 놀고 있다고 "여유 있다"는 뜻은 아니다. 기다리는 중인지, 일하는 중인지부터 구분해야 한다. 온프레미스가 무너진 건 느려서가 아니라, 캐시도 없고 수평으로 확장할 수도 없는 구조였기 때문이다. 그 구조를 그대로 두고 서버 스펙만 올렸다면, 아마 같은 자리에서 또 무너졌을 것이다.

그림 출처: Grafana 대시보드 스크린샷 (온프 = Prometheus exporters, 클라우드 = CloudWatch 데이터소스)


Further Reading

댓글 0

첫 번째 댓글을 남겨보세요

댓글 작성