
기능 테스트는 했는데 왜 서버가 터질까: 부하 테스트 입문
기능 테스트는 통과했는데 트래픽이 몰리니 서버가 뻗었다는 사례는 흔하다. '나 혼자 100번' 테스트와 '100명이 동시에 1번' 테스트는 완전히 다른 이야기다. k6로 부하 테스트를 시작하고 병목을 찾아 해결하는 과정을 정리해본다.

기능 테스트는 통과했는데 트래픽이 몰리니 서버가 뻗었다는 사례는 흔하다. '나 혼자 100번' 테스트와 '100명이 동시에 1번' 테스트는 완전히 다른 이야기다. k6로 부하 테스트를 시작하고 병목을 찾아 해결하는 과정을 정리해본다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

프링글스 통(Stack)과 맛집 대기 줄(Queue). 가장 기초적인 자료구조지만, 이걸 모르면 재귀 함수도 메시지 큐도 이해할 수 없습니다.

새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

부하 테스트를 처음 접한 건 단순한 의문에서 시작했다. 서비스를 런칭하고 트래픽이 몰리면 서버가 버텨줄까?
실제로 이런 사례는 흔하다. 커뮤니티에 링크가 한 번 올라가거나, 마케팅 이벤트가 터지거나, SNS에서 바이럴이 나면 평소보다 수십 배의 동시 접속자가 몰린다. 기능 테스트는 다 통과했는데 서비스가 그 순간 502 Bad Gateway를 뱉으며 뻗는다는 이야기는 개발자 커뮤니티에서 드물지 않게 볼 수 있다.
"로그인이 안 돼요." "페이지가 하얀색으로 나와요."
로그를 열어보면 Connection Timed Out과 502 Bad Gateway가 도배되어 있고, CPU 사용률은 100%를 찍고 있다. 마케팅에 비용을 쓰고 런칭했다면 그 비용이 허공으로 사라지는 순간이기도 하다.
이런 상황을 미리 막는 것이 부하 테스트다. 정리해본다.
QA도 다 통과했고, 버그도 없었다. 직접 로그인도 100번 넘게 해봤다.
문제는 "나 혼자 100번"과 "100명이 동시에 1번"이 완전히 다른 테스트라는 점이다.
기능 테스트(Functional Testing)만 하고 부하 테스트(Load Testing)를 하지 않으면 이런 결과가 나온다. 마치 튼튼한 다리를 지어놓고, 트럭 1대가 지나가는 건 확인했으나 트럭 100대가 동시에 지나가면 무너지는지 확인 안 한 꼴이다.
단순히 서버가 죽는 것 자체보다 그 타이밍이 문제다.
부하 테스트 도구를 찾다가 두 가지 후보를 만났습니다.
개발자로서 저는 당연히 k6를 선택했습니다. "코드로 테스트 시나리오를 짤 수 있다"는 게 너무 매력적이었습니다.
부하 테스트 도구는 많습니다. 하지만 제 기준은 명확했습니다. "Git으로 버전 관리가 가능한가?" "동료 개발자들이 러닝 커브 없이 쓸 수 있는가?"
| 특징 | JMeter | k6 | nGrinder | Gatling |
|---|---|---|---|---|
| 언어 | GUI / XML | JavaScript / TS | Groovy | Scala / Java |
| 실행 방식 | 스레드(Thread) | Goroutine (가벼움) | 스레드 | Akka Actor |
| DevOps | 불편함 (XML 지옥) | 매우 좋음 (CLI) | 보통 | 좋음 |
| 리포트 | HTML 생성 필요 | CLI / Dashboard | Web UI 내장 | HTML 생성 |
JMeter는 훌륭하지만, XML 기반 설정 파일은 Git 충돌(Conflict)의 주범입니다.
JMeter를 사용하다 보면 .jmx 파일이 5MB짜리 XML 괴물이 된다.
Git 머지 컨플릭트가 나면 XML 태그를 추적하는 데만 몇 시간이 걸리고, 결국 포기하고 덮어씌우는 경우도 생긴다.
반면 k6는 개발자에게 친숙한 JavaScript를 사용하고, 코드로 시나리오를 짜기 때문에(Code-as-Configuration) 리뷰하기도 좋다. 충돌 나면 5분 만에 고친다. 이게 진짜 생산성이다.
가장 먼저 한 일은 "사용자가 실제로 하는 행동"을 코드로 옮기는 것이었습니다.
/* script.js */
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
// 1분 동안 사용자 0명 -> 100명으로 증가
stages: [
{ duration: '1m', target: 100 },
{ duration: '3m', target: 100 }, // 3분간 유지
{ duration: '1m', target: 0 }, // 1분간 감소
],
};
export default function () {
// 1. 메인 접속
const res = http.get('https://my-service.com');
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1); // 사람이니까 1초 쉼
// 2. 로그인
const loginRes = http.post('https://my-service.com/api/login', {
email: 'user@test.com',
password: 'password123',
});
check(loginRes, { 'logged in': (r) => r.status === 200 });
sleep(2);
}
### 4.1. 스크립트 해설 - 왜 이렇게 짰나요?
* **`stages`**: 트래픽을 서서히 올립니다. 갑자기 100명이 들어오는 것보다, 1분 동안 0명에서 100명으로 증가하는 게 더 현실적이기 때문입니다. (Ramp-up)
* **`check`**: 테스트 성공/실패 기준입니다. 응답 코드가 200이 아니면 실패로 간주합니다.
* **`sleep`**: 가장 중요한 부분입니다. 실제 사용자는 버튼을 0.001초마다 누르지 않습니다. 고민하고(Thinking Time), 스크롤하는 시간을 시뮬레이션해야 정확한 부하를 측정할 수 있습니다.
이 코드를 실행(`k6 run script.js`)하자마자 제 로컬 터미널에 그래프가 그려졌습니다.
### 4.2. 왜 100명인가요? (Little's Law)
누군가는 '100명이면 너무 적지 않나요?'라고 물을 수 있습니다.
제 계산 근거는 <strong>리틀의 법칙(Little's Law)</strong>이었습니다.
* **목표 일간 사용자(DAU):** 10,000명
* **피크 타임:** 오후 8~9시에 30% 접속 (3,000명/시간)
* **평균 체류 시간:** 5분
**동시 접속자 수 = 시간당 접속자 * 체류 시간**
3,000 * (5 / 60) = **250명**
즉, 평소 피크타임은 250명 정도입니다.
100명은 초기 테스트용이었고, 나중에는 마케팅 이슈를 고려해 <strong>1000명</strong>으로 상향했습니다.
---
---
## 5. 부하 테스트의 4가지 맛 (Types of Load Testing)
스크립트를 짰다고 무작정 1000명을 쏘면 안 됩니다. 목적에 따라 4단계로 나눠야 합니다.
### 1. 스모크 테스트 (Smoke Test)
가장 기초적인 테스트입니다. 스크립트를 작성하자마자 바로 1000명을 쏘는 건 자살행위입니다.
먼저 **가상 유저(VUS) 1명으로 1분만** 돌려보세요. 스크립트 로직에 오류가 없는지, 서버가 최소한의 응답은 하는지 확인하는 단계입니다. 자동차 시동이 걸리는지 확인하는 것과 같습니다.
### 2. 로드 테스트 (Load Test)
우리가 흔히 말하는 부하 테스트입니다. <strong>"평소 트래픽"</strong>이나 <strong>"목표 트래픽"</strong>을 감당할 수 있는지 검증합니다.
예를 들어, "동시 접속자 100명이 30분 동안 지속적으로 들어와도 안전한가?"를 확인합니다. 배포 전에 반드시 거쳐야 하는 관문(Gate)입니다.
### 3. 스트레스 테스트 (Stress Test)
이 테스트의 목적은 **"시스템을 파괴하는 것"**입니다.
서버가 감당할 수 있는 <strong>한계점(Breaking Point)</strong>이 어디인지 알기 위해 트래픽을 점진적으로 증가시킵니다.
100명 -> 500명 -> 1000명 -> 2000명... 이렇게 늘려가다 보면 어느 순간 CPU가 100%를 치거나 에러가 쏟아지는 지점이 옵니다. 그 지점이 바로 우리 서비스의 현재 스펙입니다.
### 4. 소크 테스트 (Soak Test)
"물에 푹 담가둔다"는 의미처럼, 적당한 부하를 **장시간(12시간 이상)** 지속합니다.
짧은 테스트에서는 발견되지 않는 <strong>메모리 누수(Memory Leak)</strong>나 **디스크 용량 부족**, **DB 커넥션 풀 누수** 같은 문제를 찾아내는 데 탁월합니다. 주로 주말이나 밤새 돌려놓습니다.
실무에서는 <strong>2번(로드)</strong>으로 기본기를 다지고, <strong>3번(스트레스)</strong>으로 한계를 파악한 뒤 서버 스펙을 결정하는 순서로 진행한다.
### 5.1. 부하 테스트 3대 실수
초보자가 가장 많이 하는 실수 3가지가 있습니다.
1. **로컬(Localhost)에서 쏘기:** 여러분의 노트북이 먼저 뻗거나, 회사 와이파 대역폭이 병목이 됩니다. AWS EC2 같은 별도 서버에서 쏘세요.
2. **프로덕션(Production) 공격하기:** 예고 없이 운영 서버에 부하를 주면 그건 테스트가 아니라 **DDoS 공격**입니다. 반드시 Staging 환경이나 격리된 환경에서 하세요.
3. **Think Time 무시하기:** `sleep()` 없이 무한 루프를 돌리면 비현실적인 부하가 걸립니다. 실제 사용자는 버튼을 누르고 다음 행동까지 최소 1~3초는 쉽니다.
---
## 6. 병목 발견 - 범인은 DB였다
테스트 결과는 처참했습니다.
동시 접속자가 50명을 넘어가자 <strong>응답 시간(p95)</strong>이 200ms에서 <strong>3초</strong>로 치솟았습니다.
에러율도 1%에서 20%로 급증했죠.
원인을 분석해 보니 두 가지 병목이 발견되었습니다.
### 1. N+1 문제 (DB가 비명을 지른 이유)
APM(Datadog)을 확인해보니, `GET /api/products` API 하나에 **SQL 쿼리가 101개** 찍혀 있었습니다.
`product` 테이블을 조회한 뒤, 각 상품의 `image`를 가져오기 위해 루프를 돌며 쿼리를 날리고 있었던 거죠.
* 1명 접속: 101번 쿼리 (빠름)
* 100명 접속: **10,100번 쿼리** (DB CPU 폭발)
**해결책:** JPA의 `fetch join`을 사용하여 한 방 쿼리로 가져오도록 수정했습니다.
```java
// Before: N+1 발생
@OneToMany
List<Image> images;
// After: Fetch Join (한 번에 가져옴)
@Query("SELECT p FROM Product p JOIN FETCH p.images")
List<Product> findAllWithImages();
Spring Boot(HikariCP)의 기본 풀 사이즈는 10개입니다.
하지만 동시 접속자 100명이 들어오니, 10명은 처리 중이고 나머지 90명은 DB 연결을 얻기 위해 대기(Blocked) 상태에 빠졌습니다.
결국 Connection Timeout 에러가 발생하며 502 에러로 이어졌습니다.
해결책:
maximum-pool-size를 10 -> 50으로 증설 (DB 스펙 고려)부하 테스트 리포트를 볼 때 가장 주의해야 할 지표는 **평균 응답 시간(Average)**입니다. "평균 1초"라는 말은 "대부분 1초"라는 뜻이 아닙니다. 99명이 0.1초, 1명이 100초여도 평균은 약 1초입니다. 그 1명에게는 끔찍한 경험이었겠죠.
그래서 반드시 p95(95th Percentile) 혹은 p99를 봐야 합니다. "하위 95%의 사용자가 겪는 최대 응답 시간"이야말로 진짜 성능 지표입니다. 테스트에서 발견한 병목도 평균은 500ms였지만, p95는 3초를 넘고 있었습니다.
서버 코드만 고친다고 끝이 아닙니다. OS 설정도 중요합니다.
동시 접속자가 늘어나면 File Descriptor(FD)가 부족해질 수 있습니다.
Linux 기본 ulimit은 보통 1024인데, 1000명이 열면 금방 고갈됩니다.
또한 TCP 소켓이 닫히면서 생기는 TIME_WAIT 상태의 소켓이 포트를 점유하여 새로운 연결을 맺지 못하는 포트 고갈(Port Exhaustion) 문제도 겪었습니다.
net.ipv4.tcp_tw_reuse 같은 커널 파라미터 튜닝이 필요했습니다.
병목을 수정하고 다시 k6를 돌렸다. 이번엔 목표를 높여서 동시 접속자 1000명으로 설정했다.
결과는? 응답 시간 평균 150ms, 에러율 0%. 서버 CPU도 40% 선에서 안정적이었다.
이 수치를 확인하고 나서야 배포에 자신감이 생겼다.
부하 테스트를 진행하다 보면 또 다른 병목을 발견하게 된다. 예를 들어 "선착순 50% 할인 쿠폰" 같은 이벤트는 DB가 아니라 Redis가 터지는 경우가 있다. 1초에 1000명이 동시에 "쿠폰 주세요!"를 외치면, Redis의 싱글 스레드가 감당을 못한다. (Redis는 빠르지만, 명령어를 하나씩 처리한다.)
해결책: Lua Script & Rate Limiter Redis 호출 횟수를 줄이기 위해 Lua Script로 "재고 확인 + 차감"을 원자적(Atomic)으로 묶는다. 그리고 IP당 요청 횟수를 제한하는 Rate Limit를 Nginx 레벨에서 건다. 부하 테스트 시나리오에 "악성 매크로 사용자"를 추가해서 검증하는 방식도 함께 쓴다.
부하 테스트를 공부하면서 와닿은 원칙 3가지를 정리해본다.
한 번 고쳤다고 끝이 아닙니다. 다음 배포 때 또 느려질 수 있습니다. 그래서 저는 GitHub Actions에 k6를 연동했습니다.
# .github/workflows/load-test.yml
name: Load Test
on: [push]
jobs:
k6_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run k6
uses: grafana/k6-action@v0.3.0
with:
filename: script.js
flags: --vus 50 --duration 1m
그리고 성능 예산(Performance Budget)을 설정했습니다. "응답 시간이 500ms를 넘으면 배포 실패(Fail) 처리한다." 이 규칙 덕분에 똥코드가 프로덕션에 나가는 걸 원천 봉쇄할 수 있었습니다.
단순히 VUS를 늘리는 게 아니라, "초당 요청 수(RPS)"를 일정하게 유지하고 싶다면 constant-arrival-rate를 써야 합니다.
서버 스펙 산정할 때 아주 유용합니다.
export const options = {
scenarios: {
constant_request_rate: {
executor: 'constant-arrival-rate',
rate: 1000, // 초당 1000 리퀘스트 유지
timeUnit: '1s',
duration: '1m',
preAllocatedVUs: 100,
maxVUs: 200,
},
},
};
지금까지는 로컬이나 단일 EC2에서 테스트했습니다. 하지만 만약 사용자가 100만 명이라면? 단일 서버에서는 k6 자체가 병목이 됩니다. (내 노트북이 먼저 죽으니까요.)
이럴 때는 분산 부하 테스트(Distributed Load Testing)가 필요합니다. 여러 대의 EC2에 k6를 설치하고, 중앙에서 명령을 내리는 방식이죠. k6 Cloud를 쓰면 클릭 한 번으로 전 세계 10개 리전에서 트래픽을 쏠 수 있습니다. 또한 테스트 결과를 Grafana + InfluxDB로 시각화하면, 경영진에게 보고하기 아주 좋은 대시보드가 만들어집니다. 다음 글에서는 이 심화 과정을 다뤄보겠습니다.
개발자는 종종 "내 코드는 완벽해"라는 착각에 빠집니다. 하지만 성능은 코드가 아니라 실제 환경에서 나옵니다.
부하 테스트는 단순히 "서버가 버티나?"를 보는 게 아닙니다. 우리 시스템의 한계점이 어디인지(Capacity Planning)를 파악하고, 어디가 먼저 터지는지(Bottleneck)를 미리 아는 과정입니다.
혹시 지금 런칭을 앞두고 계신가요? 그렇다면 지금 당장 k6를 설치하고 스크립트를 돌려보세요. 여러분의 서버가 비명을 지르는 소리를 듣게 될 겁니다. (런칭 전에 듣는 게 백번 낫습니다.)
# 1. 단순 실행
k6 run script.js
# 2. VUS(유저수)와 시간 조절 (CLI에서 바로)
k6 run --vus 10 --duration 30s script.js
# 3. 결과를 JSON으로 저장
k6 run --out json=result.json script.js
# 4. 환경 변수 주입
k6 run -e HOSTNAME=staging.my-site.com script.js