이커머스 시스템의 핵심 비즈니스 기능에 대해 고가용성(High Availability)을 확보하기 위해 부하 테스트를 수행한다.
이번 테스트를 통해 병목 지점을 사전에 파악하여 성능 저하를 방지하고,
테스트 결과를 기반으로 한 TPS(Transactions Per Second)를 기준으로 적정 서버 Pod 수와 필요한 리소스를 산정할 수 있다.
또한, 실제 트래픽 상황을 재현하여 장애 발생 구간을 사전에 점검함으로써 시스템 안정성을 확보하고자 한다.
부하 테스트의 대상은 시스템의 핵심 비즈니스 기능을 기준으로 선정하였다.
이번 테스트에서는 주문/결제 시나리오와 선착순 쿠폰 발급 시나리오를 중심으로 진행한다.
주문/결제는 사용자 경험과 직접적으로 연결된 핵심 서비스 기능으로,
실제 사용 흐름을 기반으로 시나리오를 구성하여 부하 테스트를 수행하였다.
테스트 시나리오는 다음과 같은 단계로 구성된다.
- 인기상품 조회
- 잔액 충전
- 잔액 조회
- 주문/결제 진행
- 주문 완료 상태 확인
실제 사용자 전체가 상품을 조회한 후 주문까지 진행하지는 않기 때문에,
현실적인 사용자 행동 양상을 반영하여 다음과 같은 가정을 두고 테스트를 구성하였다.
- 전체 사용자 중 20% 사용자만 잔액 충전
- 충전 사용자 중 10% 사용자만 주문/결제
이와 같은 조건을 통해 정상적인 비즈니스 흐름을 유지하면서도 실제 트래픽 양상에 가까운 테스트를 설계하였다.
선착순 쿠폰 발급 기능은 시스템 내에서 가장 높은 단일 시점 트래픽이 집중되는 기능이다.
이 기능의 안정성을 검증하기 위해, 고부하 상황을 집중적으로 발생시키는 Peak Test 방식으로 시나리오를 구성하였다.
특정 시점에 다수의 사용자가 동시에 쿠폰을 요청하는 상황을 재현하여,
트래픽 집중 시의 응답 시간, 처리율, 에러 발생 여부 등을 중점적으로 검증한다.
이를 통해 트래픽 폭주 상황에서도 시스템이 안정적으로 동작하는지 확인하고자 한다.
테스트 환경은 로컬 PC의 도커 컨테이너를 활용하여 구성하였다.
다음은 스플링 어플리케이션 docker-compose.yml 일부이다.
version: '3'
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=dev
- TZ="Asia/Seoul"
deploy:
resources:
limits:
cpus: '2.0'
memory: 4G
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:roSpring 애플리케이션의 리소스를 제한하기 위해 CPU는 2 vCPU, 메모리는 4 GB로 설정하였다.
추가로, Spring Actuator를 통해 메트릭을 수집하고, Prometheus 및 Grafana와 연동하여 모니터링 대시보드를 구성하였다.
dependencies {
// ... 생략 ...
// Actuator
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
}global:
scrape_interval: 15s
scrape_configs:
- job_name: 'spring-app'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: [ 'api:8080' ]management:
endpoint:
health:
show-details: always
endpoints:
web:
exposure:
include: "*"
metrics:
enable:
all: true부하 테스트 도구로는 K6를 사용하였으며, InfluxDB 및 Grafana와 연동하여 테스트 결과를 시각화하였다.
다음은 InfluxDB와 Grafana를 위한 docker-compose.yml 일부이다.
services:
influxdb:
image: influxdb:1.8
networks:
- k6
- grafana
ports:
- "8086:8086"
environment:
- INFLUXDB_DB=k6
grafana:
image: grafana/grafana:9.3.8
networks:
- grafana
ports:
- "3000:3000"
environment:
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_BASIC_ENABLED=false
volumes:
- ./grafana:/etc/grafana/provisioning/이와 같이 로컬 Docker 기반의 통합 테스트 및 모니터링 환경을 구축하였다.
주문/결제 기능은 단기간 폭증보다는 일정 수준의 트래픽이 지속적으로 유지되는 특성이 있으므로,
Load Test 방식으로 최대 300 Virtual Users(VU)까지 점진적으로 증가시키는 테스트를 설계하였다.
- 10초 동안 100 VU 까지 증가
- 10초 동안 200 VU 까지 증가
- 10초 동안 300 VU 까지 증가
- 30초 동안 300 VU 유지
- 10초 동안 100 VU 까지 감소
- 10초 동안 50 VU 까지 감소
- 10초 동안 0 VU 까지 감소
| 기능 | 사용자 기준 | 처리 빈도 | 목표 TPS |
|---|---|---|---|
| 인기상품 조회 | 100명 | 1초에 1건 | 100 TPS |
| 잔액 충전 | 100명 | 10초에 1건 | 10 TPS |
| 주문/결제 | 100명 | 100초에 1건 | 1 TPS |
인기상품 조회 시 필요한 랭킹 데이터를 Redis에 사전 적재하였다.
ZINCRBY rank:sell:{date:yyyMMdd} {score : 판매량} {productId : 상품 ID}
주문/결제 시에 필요한 상품 및 재고 데이터를 각각 10,000건 생성하였다.
-- 상품 및 재고 데이터 생성을 위한 프로시저
DELIMITER
//
CREATE PROCEDURE generate_product_stock_data()
BEGIN
DECLARE
i INT DEFAULT 1;
DECLARE
product_id INT;
WHILE
i <= 10000 DO
-- 상품 추가
INSERT INTO product (name, price, sell_status)
VALUES (CONCAT('상품명', i), 1000, 'SELLING');
-- 방금 추가된 상품의 ID 가져오기
SET
product_id = LAST_INSERT_ID();
-- 재고 추가
INSERT INTO stock (product_id, quantity)
VALUES (product_id, 1000);
SET
i = i + 1;
END WHILE;
END
//
DELIMITER ;잔액 충전 및 조회를 위해 사용자 데이터를 약 10,000 개 생성하였다.
DELIMITER
//
CREATE PROCEDURE generate_balance_data()
BEGIN
DECLARE
i INT DEFAULT 1;
WHILE
i <= 10000 DO
INSERT INTO balance (user_id, amount, version)
VALUES (i, 1000000, 0);
SET
i = i + 1;
END WHILE;
END
//
DELIMITER ;- HTTP 요청의 P99 응답 시간은 1초 이하를 목표로 한다.
- HTTP 요청 실패율은 1% 미만으로 제한한다.
- 인기상품 조회 : 인기상품 목록을 조회한 후, 응답받은 상품 중 하나를 랜덤으로 선택한다.
- 포인트 충전 : 20% 확률로 포인트를 충전하고, 이어서 잔액을 조회한다.
- 주문/결제 : 10% 확률로 선택된 상품을 주문하여 주문 요청을 수행한다.
- 주문 상태 확인 : 주문/결제는 이벤트 기반으로 처리되므로, 일정 시간 지연 후 상태 조회 API를 호출하여 주문 상태가
"COMPLETED"로 변경되었는지 확인한다.
import http from 'k6/http';
import {sleep, check, group} from "k6";
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
export const options = {
stages: [
{duration: '10s', target: 100},
{duration: '10s', target: 200},
{duration: '10s', target: 300},
{duration: '30s', target: 300},
{duration: '10s', target: 100},
{duration: '10s', target: 50},
{duration: '10s', target: 0}
],
thresholds: {
http_req_duration: ['p(99)<1000'],
http_req_failed: ['rate<0.01']
}
};
const BASE_URL = 'http://127.0.0.1:8080/api/v1';
const ORDER_CHECK_INTERVAL = 2; // 주문 상태 확인 간격(초)
export default function main() {
// 1~1000 사이의 랜덤 사용자 ID 생성
const userId = randomIntBetween(1, 1000);
// 생성된 주문 ID를 저장할 변수
let orderId = null;
let shouldOrder = false;
let shouldChargeBalance = false;
let selectedProduct = null;
group('주문/결제 시나리오', () => {
// 1. 인기 상품 조회
const popularProductsResponse = http.get(`${BASE_URL}/api/v0/products/ranks`, {
tags: {name: '인기상품조회'}
});
check(popularProductsResponse, {
'인기상품 조회 성공': (r) => r.status === 200,
'인기상품 데이터 확인': (r) => {
const body = JSON.parse(r.body);
return body.data && Array.isArray(body.data.products) && body.data.products.length > 0;
}
});
if (popularProductsResponse.status === 200) {
const body = JSON.parse(popularProductsResponse.body);
if (body.data && Array.isArray(body.data.products) && body.data.products.length > 0) {
// 인기 상품 목록에서 랜덤하게 하나 선택
const products = body.data.products;
selectedProduct = products[Math.floor(Math.random() * products.length)];
shouldChargeBalance = Math.random() < 0.2;
}
}
// 2. 포인트 충전 및 조회 진행
if (shouldChargeBalance) {
const payload = JSON.stringify({
amount: 10000
});
const params = {
headers: {
'Content-Type': 'application/json',
},
tags: {name: '포인트충전'}
};
const chargeResponse = http.post(`${BASE_URL}/users/${userId}/balance/charge`, payload, params);
check(chargeResponse, {
'포인트 충전 성공': (r) => r.status === 200,
'포인트 충전 확인': (r) => {
if (r.status === 200) {
const body = JSON.parse(r.body);
return body.code === 200 && body.message === 'OK';
}
return false;
}
});
// 포인트 조회
const balanceResponse = http.get(`${BASE_URL}/users/${userId}/balance`, {
tags: {name: '포인트조회'}
});
check(balanceResponse, {
'포인트 조회 성공': (r) => r.status === 200,
'포인트 잔액 확인': (r) => {
if (r.status === 200) {
const body = JSON.parse(r.body);
return body.data && body.data.amount !== undefined;
}
return false;
}
});
// 10% 확률로 주문 진행
shouldOrder = Math.random() < 0.1;
}
// 3. 주문 결제 진행
if (shouldOrder && selectedProduct) {
const orderPayload = JSON.stringify({
userId: userId,
products: [
{
id: selectedProduct.id,
quantity: 1
}
]
});
const orderParams = {
headers: {
'Content-Type': 'application/json',
},
tags: {name: '상품주문'}
};
const orderResponse = http.post(`${BASE_URL}/orders`, orderPayload, orderParams);
check(orderResponse, {
'주문 생성 성공': (r) => r.status === 200,
'주문 확인': (r) => {
if (r.status === 200) {
const body = JSON.parse(r.body);
if (body.data && body.data.orderId) {
orderId = body.data.orderId;
return true;
}
}
return false;
}
});
// 4. 주문 상태 확인
if (orderId) {
sleep(ORDER_CHECK_INTERVAL);
const orderStatusResponse = http.get(`${BASE_URL}/orders/${orderId}`, {
tags: {name: '주문상태확인'}
});
check(orderStatusResponse, {
'주문 상태 조회 성공': (r) => r.status === 200,
'주문 상태 확인': (r) => {
if (r.status === 200) {
const body = JSON.parse(r.body);
if (body.data && body.data.status === 'COMPLETED') {
return true;
}
}
return false;
}
});
}
} else {
sleep(1);
}
});
sleep(1);
}선착순 쿠폰 발급은 이벤트성 트래픽이 단시간에 많이 집중되는 비지니스로,
급격한 부하가 집중되는 Peak Test 방식으로 최대 1000 VU까지 테스트를 진행한다.
- 10초 동안 10 VU 까지 증가
- 10초 동안 10 VU 유지
- 10초 동안 700 VU 까지 증가
- 10초 동안 10 VU 까지 감소
- 10초 동안 10 VU 유지
- 10초 동안 1000 VU 까지 증가
- 10초 동안 10 VU 까지 감소
- 10초 동안 0 VU 까지 감소
| 기능 | 사용자 기준 | 처리 빈도 | 목표 TPS |
|---|---|---|---|
| 쿠폰 발급 | 100명 | 0.5초당 1건 처리 | 200 TPS |
테스트를 위해 쿠폰 데이터를 생성하였다.
CREATE PROCEDURE generate_coupon_data()
BEGIN
DECLARE
i INT DEFAULT 1;
INSERT INTO coupon (name, quantity, discount_rate, expired_at, status)
VALUES (CONCAT('쿠폰명', i), 10000, 0.3, DATE_ADD(CURRENT_DATE(), INTERVAL 7 DAY), 'PUBLISHABLE');
END
//쿠폰 발급을 위한 쿠폰 발급 여부를 확인하는 값을 Redis에 적재 하였다.
SET coupon_avaliable:{couponId} true
- HTTP 요청의 P99 응답 시간은 1초 이하를 목표로 한다.
- HTTP 요청 실패율은 5% 미만으로 제한한다.
쿠폰 ID는 고정하였으며, 사용자 ID는 최대한 중복 ID가 생기지 않게 끔 VU ID와 시나리오 반복 횟수를 조합하여 생성하였다.
import http from 'k6/http';
import {sleep, check, group} from "k6";
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
import exec from 'k6/execution';
export const options = {
stages: [
{duration: '10s', target: 10},
{duration: '10s', target: 10},
{duration: '10s', target: 700},
{duration: '10s', target: 10},
{duration: '10s', target: 10},
{duration: '10s', target: 1000},
{duration: '10s', target: 10},
{duration: '10s', target: 0}
],
thresholds: {
http_req_duration: ['p(99)<1000'],
http_req_failed: ['rate<0.05']
},
};
const BASE_URL = 'http://127.0.0.1:8080/api/v0';
export default function main() {
const userId = (exec.vu.idInTest * 1_000_000) + exec.vu.iterationInScenario;
const couponId = 1;
// 쿠폰 발급 요청
group('쿠폰발급', () => {
const payload = JSON.stringify({
couponId: couponId
});
const params = {
headers: {
'Content-Type': 'application/json',
},
tags: {name: '쿠폰발급'}
};
const response = http.post(
`${BASE_URL}/users/${userId}/coupons/publish`,
payload,
params
);
check(response, {
'쿠폰 발급 성공': (r) => r.status === 200,
});
});
sleep(randomIntBetween(1, 3));
}아래는 주문/결제 기능에 대해 Load Test를 수행한 결과 요약이다.
k6 run --out influxdb=http://localhost:8086/k6 k6/order_payment.js --summary-trend-stats="avg,min,med,max,p(50),p(90),p(95),p(99),p(99.9)" /\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: k6/order_payment.js
output: InfluxDBv1 (http://localhost:8086)
scenarios: (100.00%) 1 scenario, 300 max VUs, 2m0s max duration (incl. graceful stop):
* default: Up to 300 looping VUs for 1m30s over 7 stages (gracefulRampDown: 30s, gracefulStop: 30s)
█ 주문/결제 시나리오
✓ 인기상품 조회 성공
✓ 인기상품 데이터 확인
✗ 포인트 충전 성공
↳ 99% — ✓ 1392 / ✗ 1
✗ 포인트 충전 확인
↳ 99% — ✓ 1392 / ✗ 1
✓ 포인트 조회 성공
✓ 포인트 잔액 확인
✓ 주문 생성 성공
✓ 주문 확인
✓ 주문 상태 조회 성공
✓ 주문 상태 확인
checks.........................: 99.98% 19938 out of 19940
data_received..................: 3.5 MB 38 kB/s
data_sent......................: 1.1 MB 12 kB/s
group_duration.................: avg=1.42s min=1s med=1.11s max=7.23s p(50)=1.11s p(90)=2.5s p(95)=2.88s p(99)=3.94s p(99.9)=4.87s
http_req_blocked...............: avg=53.58µs min=0s med=6µs max=39.7ms p(50)=6µs p(90)=23µs p(95)=99.09µs p(99)=770.64µs p(99.9)=5.62ms
http_req_connecting............: avg=17.48µs min=0s med=0s max=12.86ms p(50)=0s p(90)=0s p(95)=0s p(99)=329.92µs p(99.9)=2.76ms
✗ http_req_duration..............: avg=282.56ms min=1.5ms med=70.92ms max=3.21s p(50)=70.92ms p(90)=818.66ms p(95)=1.56s p(99)=2.19s p(99.9)=2.93s
{ expected_response:true }...: avg=282.58ms min=1.5ms med=70.92ms max=3.21s p(50)=70.92ms p(90)=818.71ms p(95)=1.56s p(99)=2.19s p(99.9)=2.93s
✓ http_req_failed................: 0.01% 1 out of 9970
http_req_receiving.............: avg=83.14µs min=7µs med=53µs max=16.37ms p(50)=53µs p(90)=126µs p(95)=172µs p(99)=461.85µs p(99.9)=2.43ms
http_req_sending...............: avg=69.1µs min=2µs med=19µs max=39.63ms p(50)=19µs p(90)=60µs p(95)=127µs p(99)=858.16µs p(99.9)=5.9ms
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(50)=0s p(90)=0s p(95)=0s p(99)=0s p(99.9)=0s
http_req_waiting...............: avg=282.41ms min=1.44ms med=70.68ms max=3.21s p(50)=70.68ms p(90)=818.52ms p(95)=1.56s p(99)=2.19s p(99.9)=2.93s
http_reqs......................: 9970 108.457378/s
iteration_duration.............: avg=2.42s min=2s med=2.11s max=8.23s p(50)=2.11s p(90)=3.5s p(95)=3.88s p(99)=4.94s p(99.9)=5.87s
iterations.....................: 6934 75.430638/s
vus............................: 2 min=2 max=300
vus_max........................: 300 min=300 max=300
running (1m31.9s), 000/300 VUs, 6934 complete and 0 interrupted iterations
default ✓ [======================================] 000/300 VUs 1m30s
ERRO[0092] thresholds on metrics 'http_req_duration' have been crossed | P50 | P90 | P95 | P99 | P99.9 | |
|---|---|---|---|---|---|
| 인기상품조회 | 90 ms | 1 s | 2 s | 2 s | 3 s |
| 잔액 충전 | 61 ms | 480 ms | 782 ms | 2 s | 3 s |
| 잔액 조회 | 33 ms | 271 ms | 430 ms | 1 s | 3 s |
| 주문/결제 | 26 ms | 188 ms | 225 ms | 439 ms | 462 ms |
| 주문 확인 | 113 ms | 875 ms | 2 s | 2 s | 2 s |
- 인기상품 조회, 잔액 충전, 주문 확인 단계에서 P99 응답 시간이 2초 이상으로 측정되어, 해당 구간들이 성능 병목 지점으로 판단된다.
- 전체 HTTP 요청의 P99 응답 시간은 2.19초로 측정되어, SLA(1초 이내)를 초과하였다.
- Spring Actuator 기준 Timed Waiting Thread 수 증가도 병렬 처리에 영향을 준 것으로 분석된다.
아래는 선착순 쿠폰 발급 기능에 대해 Peak Test를 수행한 결과 요약이다.
k6 run --out influxdb=http://localhost:8086/k6 k6/coupon_publish.js --summary-trend-stats="avg,min,med,max,p(90),p(95),p(99),p(99.9)" /\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: k6/coupon_publish.js
output: InfluxDBv1 (http://localhost:8086)
scenarios: (100.00%) 1 scenario, 1000 max VUs, 1m50s max duration (incl. graceful stop):
* default: Up to 1000 looping VUs for 1m20s over 8 stages (gracefulRampDown: 30s, gracefulStop: 30s)
WARN[0056] The flush operation took higher than the expected set push interval. If you see this message multiple times then the setup or configuration need to be adjusted to achieve a sustainable rate. output=InfluxDBv1 t=1.400323666s
█ 쿠폰발급
✓ 쿠폰 발급 성공
checks.........................: 100.00% 5093 out of 5093
data_received..................: 932 kB 11 kB/s
data_sent......................: 941 kB 11 kB/s
group_duration.................: avg=2.18s min=2.33ms med=1.33s max=8.63s p(90)=5.41s p(95)=6.05s p(99)=6.92s p(99.9)=8.55s
http_req_blocked...............: avg=208µs min=1µs med=10µs max=137.34ms p(90)=375.6µs p(95)=531.59µs p(99)=2.16ms p(99.9)=21.05ms
http_req_connecting............: avg=74µs min=0s med=0s max=7.19ms p(90)=271µs p(95)=354.39µs p(99)=771.03µs p(99.9)=3.91ms
✗ http_req_duration..............: avg=2.18s min=2.2ms med=1.33s max=8.63s p(90)=5.41s p(95)=6.04s p(99)=6.92s p(99.9)=8.55s
{ expected_response:true }...: avg=2.18s min=2.2ms med=1.33s max=8.63s p(90)=5.41s p(95)=6.04s p(99)=6.92s p(99.9)=8.55s
✓ http_req_failed................: 0.00% 0 out of 5093
http_req_receiving.............: avg=92.98µs min=10µs med=66µs max=10.34ms p(90)=141µs p(95)=192µs p(99)=514.16µs p(99.9)=2.64ms
http_req_sending...............: avg=194.72µs min=5µs med=33µs max=70.37ms p(90)=124µs p(95)=260.59µs p(99)=2.93ms p(99.9)=24.81ms
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s p(99)=0s p(99.9)=0s
http_req_waiting...............: avg=2.18s min=2.14ms med=1.33s max=8.63s p(90)=5.41s p(95)=6.04s p(99)=6.92s p(99.9)=8.55s
http_reqs......................: 5093 61.893725/s
iteration_duration.............: avg=4.19s min=1s med=3.74s max=11.57s p(90)=7.3s p(95)=8.19s p(99)=9.39s p(99.9)=10.84s
iterations.....................: 5093 61.893725/s
vus............................: 1 min=1 max=1000
vus_max........................: 1000 min=1000 max=1000
running (1m22.3s), 0000/1000 VUs, 5093 complete and 0 interrupted iterations
default ✓ [======================================] 0000/1000 VUs 1m20s
ERRO[0082] thresholds on metrics 'http_req_duration' have been crossed | P50 | P90 | P95 | P99 | P99.9 | |
|---|---|---|---|---|---|
| 쿠폰발급 | 1 s | 5 s | 6 s | 7 s | 9 s |
전체 요청 중 99%가 7초 이내, 일부는 9초에 근접하는 응답 지연을 보이며,
평균 응답 시간 2.19초 및 P99 7초로 SLA(1초 이내)를 초과하였다.
이는 병목 현상으로, 성능 튜닝 및 병목 원인 분석이 필요하다.
주문/결제 Load Test 결과에서 P99 기준 2초 이상의 응답 지연이 발생하여 병목 지점이 확인되었다.
이를 해결하기 위해 다음과 같은 성능 개선을 적용하였다
- 인기상품 조회 API : Redis 캐싱 적용
- 주문/결제 이벤트 처리 : Kafka 파티션 및 컨슈머 추가
개선 후 동일한 테스트 시나리오로 재측정한 결과는 다음과 같다.
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: k6/order_payment.js
output: InfluxDBv1 (http://localhost:8086)
scenarios: (100.00%) 1 scenario, 300 max VUs, 2m0s max duration (incl. graceful stop):
* default: Up to 300 looping VUs for 1m30s over 7 stages (gracefulRampDown: 30s, gracefulStop: 30s)
█ 주문/결제 시나리오
✓ 인기상품 조회 성공
✓ 인기상품 데이터 확인
✗ 포인트 충전 성공
↳ 99% — ✓ 1637 / ✗ 2
✗ 포인트 충전 확인
↳ 99% — ✓ 1637 / ✗ 2
✓ 포인트 조회 성공
✓ 포인트 잔액 확인
✓ 주문 생성 성공
✓ 주문 확인
✓ 주문 상태 조회 성공
✓ 주문 상태 확인
checks.........................: 99.98% 23458 out of 23462
data_received..................: 4.1 MB 45 kB/s
data_sent......................: 1.3 MB 15 kB/s
group_duration.................: avg=1.07s min=1s med=1.01s max=3.58s p(50)=1.01s p(90)=1.13s p(95)=1.34s p(99)=2.15s p(99.9)=2.93s
http_req_blocked...............: avg=33.09µs min=0s med=4µs max=63.44ms p(50)=4µs p(90)=14µs p(95)=45µs p(99)=412.39µs p(99.9)=2.72ms
http_req_connecting............: avg=11.43µs min=0s med=0s max=20.36ms p(50)=0s p(90)=0s p(95)=0s p(99)=283.39µs p(99.9)=714.08µs
✓ http_req_duration..............: avg=35.01ms min=629µs med=6.82ms max=1.83s p(50)=6.82ms p(90)=86.58ms p(95)=169.16ms p(99)=407.12ms p(99.9)=806.73ms
{ expected_response:true }...: avg=34.79ms min=629µs med=6.82ms max=1.25s p(50)=6.82ms p(90)=86.4ms p(95)=167.51ms p(99)=405.17ms p(99.9)=785.54ms
✓ http_req_failed................: 0.01% 2 out of 11731
http_req_receiving.............: avg=62.48µs min=5µs med=34µs max=85.73ms p(50)=34µs p(90)=93µs p(95)=126µs p(99)=302.69µs p(99.9)=2.45ms
http_req_sending...............: avg=50.36µs min=2µs med=13µs max=48.56ms p(50)=13µs p(90)=35µs p(95)=70µs p(99)=473.69µs p(99.9)=6.14ms
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(50)=0s p(90)=0s p(95)=0s p(99)=0s p(99.9)=0s
http_req_waiting...............: avg=34.89ms min=612µs med=6.73ms max=1.75s p(50)=6.73ms p(90)=86.5ms p(95)=168.22ms p(99)=406.91ms p(99.9)=806.62ms
http_reqs......................: 11731 128.139369/s
iteration_duration.............: avg=2.07s min=2s med=2.01s max=4.58s p(50)=2.01s p(90)=2.13s p(95)=2.34s p(99)=3.16s p(99.9)=3.93s
iterations.....................: 8101 88.488367/s
vus............................: 3 min=3 max=300
vus_max........................: 300 min=300 max=300
running (1m31.5s), 000/300 VUs, 8101 complete and 0 interrupted iterations
default ✓ [======================================] 000/300 VUs 1m30s| 구간 | AS-IS | TO-BE | 개선율 |
|---|---|---|---|
| P50 | 90 ms | 5 ms | 🔻94% |
| P90 | 1 s | 71 ms | 🔻92% |
| P95 | 2 s | 119 ms | 🔻94% |
| P99 | 2 s | 303 ms | 🔻85% |
| P99.9 | 3 s | 492 ms | 🔻84% |
| 구간 | AS-IS | TO-BE | 개선율 |
|---|---|---|---|
| P50 | 61 ms | 13 ms | 🔻78% |
| P90 | 480 ms | 191 ms | 🔻60% |
| P95 | 782 ms | 349 ms | 🔻55% |
| P99 | 2 s | 663 ms | 🔻66% |
| P99.9 | 3 s | 976 ms | 🔻67% |
| 구간 | AS-IS | TO-BE | 개선율 |
|---|---|---|---|
| P50 | 33 ms | 7 ms | 🔻79% |
| P90 | 271 ms | 113 ms | 🔻58% |
| P95 | 430 ms | 219 ms | 🔻49% |
| P99 | 1 s | 525 ms | 🔻47% |
| P99.9 | 3 s | 978 ms | 🔻67% |
| 구간 | AS-IS | TO-BE | 개선율 |
|---|---|---|---|
| P50 | 26 ms | 16 ms | 🔻38% |
| P90 | 188 ms | 87 ms | 🔻54% |
| P95 | 225 ms | 156 ms | 🔻31% |
| P99 | 439 ms | 339 ms | 🔻23% |
| P99.9 | 462 ms | 666 ms | 🔺↑ (악화) |
P99.9는 일시적 피크로 악화된 것으로 추정됨
| 구간 | AS-IS | TO-BE | 개선율 |
|---|---|---|---|
| P50 | 113 ms | 9 ms | 🔻92% |
| P90 | 875 ms | 181 ms | 🔻79% |
| P95 | 2 s | 318 ms | 🔻84% |
| P99 | 2 s | 398 ms | 🔻80% |
| P99.9 | 2 s | 404 ms | 🔻79% |
| 지표 항목 | AS-IS | TO-BE | 개선 효과 |
|---|---|---|---|
| 전체 요청 수 | 9,841 | 11,610 | 📈 +18.0% 증가 |
| 평균 응답시간 | 282.56 ms | 34.79 ms | 🟢 87.7% 감소 |
| P99 응답시간 | 2.19 s | 405.17 ms | 🟢 81.5% 개선 |
| P99.9 응답시간 | 2.93 s | 785.54 ms | 🟢 73.2% 개선 |
| 항목 | 결과 TPS | 목표 TPS | 달성 여부 |
|---|---|---|---|
| 인기상품 조회 | 90 TPS | 100 TPS | ❌ 부족 (스케일 아웃 필요) |
| 잔액 충전 | 18 TPS | 10 TPS | ✅ 초과 달성 |
| 주문/결제 | 1.9 TPS | 1 TPS | ✅ 초과 달성 |
인기 상품 조회 TPS는 목표치에 다소 미달했지만, Pod 스케일 아웃을 통해 충분히 보완 가능한 수준이다.
선착순 쿠폰 발급에 대한 Peak Test 결과, P99 기준 6초 이상의 응답 지연이 확인되어 심각한 병목 지점이 존재함을 확인하였다.
이를 해결하기 위해 다음과 같은 성능 개선 작업을 수행하였다.
- 쿠폰 발급 요청 흐름에 Redis 캐싱 및 Kafka 비동기 처리 도입
- Kafka Consumer Lag 해소를 위한 예외 핸들링 적용
(중복 발급, 재고 부족 등 재시도가 불필요한 상황에서 무의미한 재시도 방지)
특히, 쿠폰 발급이 불가능한 경우(중복 발급, 쿠폰 수량 부족 등)에는
@KafkaListener 내부에서 예외가 발생하더라도 재시도 없이 처리 완료되도록 개선하였다.
이러한 상황은 재시도를 하더라도 발급이 절대 성공할 수 없는 상태이므로,
CoreException 발생 시 acknowledge()를 호출하여 커밋 처리함으로써 불필요한 재처리와 Lag 발생을 방지하였다.
CoreException(의도한 에러 핸들링) 예외 시, acknowledge()를 호출하여 재시도 방지한다.
@KafkaListener(topics = Topic.COUPON_PUBLISH_REQUESTED, groupId = GroupId.COUPON, concurrency = "3")
public void handle(String message, Acknowledgment ack) {
log.info("쿠폰 발급 요청 이벤트 수신 {}", message);
try {
Event<CouponEvent.PublishRequested> event = Event.of(message, CouponEvent.PublishRequested.class);
CouponEvent.PublishRequested payload = event.getPayload();
couponService.publishUserCoupon(CouponCommand.Publish.of(payload.getUserId(), payload.getCouponId()));
ack.acknowledge();
} catch (CoreException e) {
log.warn("쿠폰 발급 요청 이벤트 처리 중 오류 발생: {}", e.getMessage(), e);
ack.acknowledge();
} catch (Exception e) {
log.error("쿠폰 발급 요청 이벤트 처리 중 예기치 않은 오류 발생: {}", e.getMessage(), e);
}
} /\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: k6/coupon_publish.js
output: InfluxDBv1 (http://localhost:8086)
scenarios: (100.00%) 1 scenario, 1000 max VUs, 1m50s max duration (incl. graceful stop):
* default: Up to 1000 looping VUs for 1m20s over 8 stages (gracefulRampDown: 30s, gracefulStop: 30s)
WARN[0064] The flush operation took higher than the expected set push interval. If you see this message multiple times then the setup or configuration need to be adjusted to achieve a sustainable rate. output=InfluxDBv1 t=1.457464958s
█ 쿠폰발급
✓ 쿠폰 발급 성공
checks.........................: 100.00% 9549 out of 9549
data_received..................: 1.7 MB 21 kB/s
data_sent......................: 1.8 MB 21 kB/s
group_duration.................: avg=55.52ms min=784.83µs med=14.1ms max=1.02s p(90)=155.94ms p(95)=253.85ms p(99)=531.4ms p(99.9)=842.38ms
http_req_blocked...............: avg=67.81µs min=1µs med=7µs max=7.42ms p(90)=267µs p(95)=378µs p(99)=800µs p(99.9)=3.2ms
http_req_connecting............: avg=35.32µs min=0s med=0s max=6.35ms p(90)=198µs p(95)=279µs p(99)=461.63µs p(99.9)=1.45ms
✓ http_req_duration..............: avg=55.2ms min=723µs med=13.8ms max=1.02s p(90)=155.32ms p(95)=253.13ms p(99)=531.3ms p(99.9)=842.23ms
{ expected_response:true }...: avg=55.2ms min=723µs med=13.8ms max=1.02s p(90)=155.32ms p(95)=253.13ms p(99)=531.3ms p(99.9)=842.23ms
✓ http_req_failed................: 0.00% 0 out of 9549
http_req_receiving.............: avg=74.36µs min=5µs med=37µs max=19.95ms p(90)=104µs p(95)=154µs p(99)=576.07µs p(99.9)=3.41ms
http_req_sending...............: avg=71.34µs min=3µs med=23µs max=38.27ms p(90)=89µs p(95)=170µs p(99)=959.55µs p(99.9)=3.96ms
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s p(99)=0s p(99.9)=0s
http_req_waiting...............: avg=55.06ms min=688µs med=13.73ms max=1.02s p(90)=155.17ms p(95)=252.7ms p(99)=531.24ms p(99.9)=842.15ms
http_reqs......................: 9549 115.199133/s
iteration_duration.............: avg=2.04s min=1s med=2.01s max=3.91s p(90)=3.04s p(95)=3.1s p(99)=3.33s p(99.9)=3.78s
iterations.....................: 9549 115.199133/s
vus............................: 1 min=1 max=992
vus_max........................: 1000 min=1000 max=1000
running (1m22.9s), 0000/1000 VUs, 9549 complete and 0 interrupted iterations
default ✓ [======================================] 0000/1000 VUs 1m20s| 구간 | AS-IS | TO-BE | 개선율 |
|---|---|---|---|
| P50 | 1 s | 14 ms | 🔻98.6% |
| P90 | 5 s | 156 ms | 🔻96.8% |
| P95 | 6 s | 253 ms | 🔻95.8% |
| P99 | 7 s | 529 ms | 🔻92.4% |
| P99.9 | 9 s | 847 ms | 🔻90.6% |
| 지표 항목 | AS-IS | TO-BE | 개선 효과 |
|---|---|---|---|
| 전체 요청 수 | 5,093 | 9,549 | 📈 +87.4% 증가 |
| 평균 응답시간 | 2.18s | 55.2ms | 🟢 97.5% 감소 |
| P99 응답시간 | 6.92s | 531.3ms | 🟢 92.3% 개선 |
| P99.9 응답시간 | 8.55s | 842.2ms | 🟢 90.1% 개선 |
| 구간 | TPS | 비고 |
|---|---|---|
| 개선 전 (AS-IS) | 62 TPS | 목표 100 TPS 미달 |
| 개선 후 (TO-BE) | 115 TPS | ✅ 목표 초과 달성 |
TPS는 기존 62 TPS → 115 TPS로 약 85% 증가하여 목표 TPS(100)를 초과 달성하였다.
다만, 본 기능은 이벤트성 트래픽 특성이 강하므로, 운영 환경에서는 Pod 수를 스케일 아웃하여 탄력적으로 대응할 필요가 있다.
이번 부하 테스트를 통해 주문/결제 및 선착순 쿠폰 발급 기능의 주요 성능 병목 지점을 식별하고,
이에 대한 적절한 개선 조치를 수행함으로써 전체적인 응답 속도 및 처리량을 크게 향상시킬 수 있었다.
주요 개선 사항은 다음과 같다.
- 주문/결제: 인기상품 조회에 Redis 캐싱 적용, 주문/결제 Kafka 파티션 및 컨슈머 추가
- 쿠폰 발급: Redis 캐싱 및 Kafka 비동기 처리 도입, 예외 핸들링 개선으로 Lag 방지
이와 같이 부하 테스트 기반의 사전 검증을 통해 병목 지점을 조기에 식별하고,
적절한 대응으로 운영 중 발생 가능한 장애를 효과적으로 예방할 수 있었다.
향후 운영 환경에서는 다음과 같은 추가적인 방어 체계를 통해 장애 예방 효과를 더욱 극대화할 수 있다.
- 로깅: 병목 또는 예외 발생 시 추적 가능한 구조 확보
- 서킷 브레이커 적용: 외부 서비스 지연 시 시스템 안정성 유지
- 모니터링 및 알림 시스템: 지표 기반 실시간 감지 및 대응 체계 강화








