부하 테스트란?
일반적으로 REST API 서버의 특정 기능을 개발하여 상용 서비스에 올릴 때에는 기본 기능들은 잘 동작 하는지, 여러 테스트케이스에서 예외 상황은 발생하지 않는지, 엣지 케이스에서의 버그로인한 서버 다운의 가능성은 없는지 등을 테스트 한다.
위 테스트들은 개발자의 유닛/통합 테스트, QA 팀에서의 기능 테스트 등으로 충분히 잡을 수 있으나, 서버가 많은 요청을 받을 때에도 잘 동작하는지는 위 방법으로는 테스트하기 어렵다. 서버가 많은 요청에 대해 잘 버티는지를 테스트 하는 방법을 "부하 테스트" 라고 한다. 물론 이런 부하 테스트를 간편하게 실행하게 해줄 여러 도구들은 이미 잘 개발되어 있고, 이 포스팅에서는 여러 툴중 간단하게 사용이 가능한 K6 를 사용하여 테스트를 해보고자 한다.
부하 테스트의 종류
부하 테스트에는 여러 가지 유형이 있으며, 각 테스트는 특정한 목적을 가지고 서버의 성능을 점검한다. 아래는 대표적인 부하 테스트의 종류들이다.

1. 부하 테스트 (Load Test)
• 시스템이 예상되는 부하를 정상적으로 처리할 수 있는지 평가
• 특정한 부하를 제한된 시간 동안 제공하여 이상 여부를 확인
• 목표치를 설정하여 적절한 서버 스펙을 결정하는 데 활용
2. 내구성 테스트 (Endurance Test)
• 시스템이 장기간 동안 안정적으로 운영될 수 있는지 평가
• 특정한 부하를 오랜 시간 동안 유지했을 때 발생하는 문제를 탐색
• 메모리 누수(Memory Leak), 느린 쿼리(Slow Query) 등의 장기적 문제를 조기에 발견
3. 스트레스 테스트 (Stress Test)
• 시스템이 점진적으로 증가하는 부하를 얼마나 잘 처리할 수 있는지 평가
• 부하를 지속적으로 증가시키며 시스템이 한계에 도달하는 시점을 분석
• 장기적인 애플리케이션의 확장성과 운영 계획 수립에 활용
4. 최고 부하 테스트 (Peak Test)
• 시스템이 순간적으로 높은 트래픽을 받았을 때 정상적으로 동작하는지 평가
• 임계 부하를 짧은 시간 동안 가했을 때 안정적으로 처리할 수 있는지 확인
• 선착순 이벤트, 한정 수량 판매 등의 상황에서 서버의 대응 능력 검증
테스트 시나리오
1. 포인트 충전 API
포인트 충전은 보통 사용자들이 가끔씩 이용하는 기능이지만, 이벤트 기간 중에는 갑자기 많은 요청이 몰릴 가능성이 있다.
• 예를 들어, 포인트 충전 이벤트가 오픈된 경우, 이벤트 기간 동안 꾸준한 트래픽 증가가 예상됨.
• 만약 초당 1회의 포인트 충전 요청이 발생하는 수준이라면, 서버 입장에서는 아주 해피한 상황.
예상 부하 패턴
- 일반적인 경우: 초당 0.1~1건 수준의 요청
- 이벤트 기간: 점진적인 증가, 특정 시간대에 집중될 가능성 있으나 단시간에 요청이 몰리진 않을 것
2. 결제 처리 API
특정 인기 상품이나 선착순 한정 판매 상품이 있을 경우, 결제 요청이 급증할 가능성이 높음.
특히, 유명 유튜버와 협업하여 상품을 오픈하는 경우, 트래픽이 폭발적으로 증가할 수 있음.
보통 구매 과정은 주문서 작성 → 결제 요청 순으로 진행되므로, 두 단계 모두 부하 테스트가 필요하다
예상 부하 패턴
- 평소: 초당 1~5건 수준의 결제 요청
- 특정 인기 상품 오픈 시:
유튜버(구독자 100만 명) 협업 시, 초당 100건의 주문도 들어올 수 있다고 가정
선착순 이벤트라면, 특정 시간에 트래픽이 몰리는 스파이크 테스트가 필수적이다.
3. 선착순 쿠폰 발급 API
이 기능 자체가 트래픽이 몰리기 쉬운 구조이므로, 부하 테스트가 반드시 필요하다.
1초당 100건~최대 10000건까지 요청이 올 수 있다고 가정
단순 HTTP 요청이 아니라, Redis, DB 트랜잭션까지 연계된 부하 상황을 고려해야 한다
예상 부하 패턴
- 일반적인 경우: 초당 10~50건
- 이벤트 기간: 초당 1000~10000건 가능 (서버가 감당할 수 있는 최대 트래픽 측정 필요)
위 시나리오 중에서 가장 단시간 트래픽이 많이 몰릴 것으로 예상되는 선착순 쿠폰 발급에 대한 부하 테스트를 peak test 로 진행하고자 한다.
K6 테스트 진행
K6 를 활용한 테스트는 간단하다. 아래와 같이 테스트 스크립트를 javascript 로 작성한 다음, k6 cli 를 통해 실행하면 바로 결과가 나온다
한정 쿠폰 peak test 스크립트
import http from 'k6/http';
import { check, sleep } from 'k6';
export let options = {
stages: [
{ duration: '5s', target: 10000 }, // 5초 동안 10000
{ duration: '5s', target: 5000 }, // 5초 동안 5000
{ duration: '5s', target: 1000 }, // 5초 동안 1000
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% 요청이 500ms 이하인지 확인
http_req_failed: ['rate<0.01'], // 실패율 1% 미만인지 확인
}
};
export default function () {
let couponId = 1;
let userId = Math.floor(Math.random() * 100000) + 1; // 랜덤 유저 ID
let url = `http://localhost:8080/v2/coupons/${couponId}/users/${userId}`;
let res = http.post(url);
check(res, {
'status is 200': (r) => r.status === 200,
'response time is < 500ms': (r) => r.timings.duration < 500,
});
}
테스트 스크립트는 실제 쿠폰 발급 상황과 같이 시작하마자 첫 5초때부터 유저가 엄청 많이 몰리고, 5초 간격으로 요청이 점점 줄어드는 상황을 연출할 것이다. 실제 쿠폰 발급 상황의 트래픽이 어떨지는 모르기 때문에 우선은 유저들이 처음에 몰리고 점차 늦게 온 일부 유저들이 요청하는 상황을 가정하였다.
테스트 환경
local 에서 docker 를 띄워서 실행하였다. 현재 머신의 사양은 아래와 같다
MacBook Air 15(M2, 2023년)
CPU
Apple M2
- 8코어 CPU
- 10코어 GPU
RAM
16GB
SSD
512GB
아래와 같은 docker-compose.yml 파일을 작성하여, 서버에 필요한 인프라와 스프링 서버를 docker 에 올려서 실행하도록 하였다.
version: '3.8'
services:
# ✅ Spring Boot 애플리케이션
app:
build: .
container_name: spring-app
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy # ✅ MySQL이 정상적으로 실행될 때까지 대기
redis:
condition: service_healthy # ✅ Redis가 정상적으로 실행될 때까지 대기
kafka:
condition: service_started # ✅ Kafka 컨테이너가 시작될 때까지 대기
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/hhplus
- SPRING_DATASOURCE_USERNAME=application
- SPRING_DATASOURCE_PASSWORD=application
- SPRING_REDIS_HOST=redis
- SPRING_REDIS_PORT=6379
- SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:9092
networks:
- my_network
# ✅ MySQL 데이터베이스
mysql:
image: mysql:8.0
container_name: mysql-db
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_USER=application
- MYSQL_PASSWORD=application
- MYSQL_DATABASE=hhplus
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
retries: 5
timeout: 5s
networks:
- my_network
# ✅ Redis 캐시
redis:
image: redis:6.0
container_name: redis-cache
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 5
timeout: 3s
networks:
- my_network
# ✅ Zookeeper (Kafka를 위한 필수 서비스)
zookeeper:
image: confluentinc/cp-zookeeper:latest
container_name: kafka-zookeeper
environment:
- ZOOKEEPER_CLIENT_PORT=2181
- ZOOKEEPER_TICK_TIME=2000
ports:
- "2181:2181"
networks:
- my_network
# ✅ Kafka 메시지 브로커
kafka:
image: confluentinc/cp-kafka:latest
container_name: kafka-broker
depends_on:
- zookeeper
environment:
- KAFKA_BROKER_ID=1
- KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
- KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092
- KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
ports:
- "9092:9092"
networks:
- my_network
volumes:
kafka_data:
# ✅ 모든 컨테이너가 같은 네트워크를 사용하도록 설정
networks:
my_network:
driver: bridge
테스트 대상 구조
테스트를 진행할 쿠폰 발급 요청 API 의 경우, 아래와 같은 시퀀스로 작업이 진행된다
1. Client -> Spring Sever: 쿠폰 발급 요청 발생 [HTTP API]
2. Spring Server -> Redis : redis ordered set 에 쿠폰 발행 job 을 넣는다 [Redis ZADD 요청]
3. Spring Server -> Redis : Spring Scheduler 는 redis ordered set 에서 job 을 모두 가져온다 [Redis ZRANGE 요청]
4. Spring Server -> MySQL : Spring Scheduler 는 쿠폰 발행 가능여부를 확인하고, 유저에게 쿠폰 발행을 한다. [DB Select / DB Insert]
실행
k6 run ./src/test/k6/limited_coupon_spike_test.js
결과


k6 의 결과는 위와 같이 나왔다.
Docker Stat 결과
Spring Server

Redis

MYSQL

분석 및 대응방안
Spring Server
1. 많은 API 요청
결과를 보았을 때 0.02% 의 요청이 실패가 났는데, 해당 요청들은 스프링 서버가 요청을 받을 수 없을 정도로 과도한 요청이 발생한 것으로 보인다. 그래프를 보면 초기에 CPU 를 800% 가까이 점유하는데 로컬 머신의 모든 CPU 자원을 먹고 있다. 아마 800% 를 찍는 시점에서 요청이 실패했을 것으로 예측된다. (*여기서 800% 는 수치는 8코어 CPU 머신에서 테스트중이기 때문에 나온 수치이다. 즉 모든 CPU 자원을 끌어다 쓴 것)
-> 이러한 경우는 하나의 스프링 서버가 너무 많은 요청을 받은 것이기 때문에, 머신의 사양을 올리거나 API 서버를 여러개로 분산하여 로드밸런싱 하는 방법으로 해결할수 있다.
# 장애 대응
이러한 상황이 발생할 경우에 오토 스케일링을 설정해 둔다면 자동으로 서버 인스턴스가 추가될 것이기 때문에 대응이 자동으로 될 수도 있다. 하지만 서버 인스턴스가 많아진다는 것은 동시 DB Connection 이 많아진다는 것이기 때문에 충분한 주의가 필요할 것이다.
2. 이후의 scheduler
스케쥴러가 동작할 때에는 CPU 자원을 그렇게 많이 먹지 않는데, 이는 하나의 스레드에서 순차적으로 처리를 진행하고 있기 때문이다.
Redis
1. Spring Server 에서의 많은 요청
초반에 요청이 몰리면서 CPU 점유율이 90% 가까이 된다. (로컬 머신이 8코어이기 때문에 실질적으로는 11% 정도의 수치임)
상용 서버에서 서비스를 운영하는데 인프라가 80% 를 넘어가는 상황은 위험 단계이고 예의 주시해야될 상황인데, 90% 를 넘는 것은 금방이라도 서버가 죽을 수 있다는 신호이기 때문에 만약에 싱글 코어 환경이었다면 이는 매우 위험한 수치일 수 있다.
-> Redis 의 성능을 높이는 것도 방법이겠으나, 사실 이러한 대용량 queue 작업은 싱글 스레드인 Redis 보다 이런 작업에 특화된 MQ 를 이용하면 좋다. kafka 로 요청을 넘겨서 처리하도록 하는것도 하나의 방법이 되겠다.
# 장애 대응
위와 같은 구조에서 Redis 의 CPU 가 높아진다면.. 클러스터 설정을 해두지 않은 상황에서는 어찌할 방도가 없지 않을까 싶다.. 다른 방법이 있으려나? 이벤트를 빨리 종료시키는 방법 밖에는...
MySQL
1. 쿠폰 발행 과정에서의 부하
초반에 요청이 몰리는 상황에서는 실질적으로 일을 하고 있지는 않지만, Spring Scheduler 가 동작하면서 MYSQL 에 많은 Select/Insert 요청을 날리고 있기 때문에 CPU 사용량이 90% 를 찍은 상태로 유지되고 있는데, DB 에서 장애가 발생하면 그곳에 연관된 모든 서버가 먹통이 될 수 있기 때문에 이 또한 위험한 상황일 수 있다. (마찬 가지로 싱글 코어라고 가정 했다면)
-> 일단 현재 코드가 DB 배타락을 걸어서 쿠폰의 잔여수를 조회하고, 쿠폰 하나가 발급될 때 마다 하나의 commit 이 발생하는 구조이다. 쿠폰의 잔여수를 redis 로 관리를 하면 DB 의 부하는 줄어들 수 있다
-> 또는 스케쥴러의 동작 주기를 더 길게 잡는다면 DB 에 가해지는 부하는 줄어들 것이다.
# 장애 대응
이러한 상황에서는.. DB 를 죽을 위기에 처하게 하느냐, 추후에 고객들에게 사과를 한다고 하고 DB에 가해지는 부하를 제거하거나(redis 의 job 을 모두 제거한다거나)
'개발자의 길' 카테고리의 다른 글
[MSA] 분산 환경에서의 트랜잭션 처리 - 분산 트랜잭션과 보상 트랜잭션 (1) | 2025.02.13 |
---|---|
[Database] MySQL 에서 Index 를 설계하고 추가하는 방법 (0) | 2025.02.12 |
[Cache] 백엔드 서버 입장에서의 캐싱 전략과 활용 방안에 대해 (1) | 2025.02.04 |
[항해플러스] 미니 e-commerce 프로젝트에서 발생가능한 동시성 문제와 그 해결법 (0) | 2025.01.19 |
[Infra] Docker (1) - Docker Engine (0) | 2024.04.14 |