Skip to content

Latest commit

 

History

History
1026 lines (779 loc) · 39 KB

File metadata and controls

1026 lines (779 loc) · 39 KB

부하 테스트 보고서

🖼️ 배경

이커머스 시스템의 핵심 비즈니스 기능에 대해 고가용성(High Availability)을 확보하기 위해 부하 테스트를 수행한다.
이번 테스트를 통해 병목 지점을 사전에 파악하여 성능 저하를 방지하고,
테스트 결과를 기반으로 한 TPS(Transactions Per Second)를 기준으로 적정 서버 Pod 수와 필요한 리소스를 산정할 수 있다.
또한, 실제 트래픽 상황을 재현하여 장애 발생 구간을 사전에 점검함으로써 시스템 안정성을 확보하고자 한다.

📌 대상 선정

부하 테스트의 대상은 시스템의 핵심 비즈니스 기능을 기준으로 선정하였다.
이번 테스트에서는 주문/결제 시나리오와 선착순 쿠폰 발급 시나리오를 중심으로 진행한다.

🛒 주문/결제

주문/결제는 사용자 경험과 직접적으로 연결된 핵심 서비스 기능으로,
실제 사용 흐름을 기반으로 시나리오를 구성하여 부하 테스트를 수행하였다.

테스트 시나리오는 다음과 같은 단계로 구성된다.

  1. 인기상품 조회
  2. 잔액 충전
  3. 잔액 조회
  4. 주문/결제 진행
  5. 주문 완료 상태 확인

실제 사용자 전체가 상품을 조회한 후 주문까지 진행하지는 않기 때문에,
현실적인 사용자 행동 양상을 반영하여 다음과 같은 가정을 두고 테스트를 구성하였다.

  • 전체 사용자 중 20% 사용자만 잔액 충전
  • 충전 사용자 중 10% 사용자만 주문/결제

이와 같은 조건을 통해 정상적인 비즈니스 흐름을 유지하면서도 실제 트래픽 양상에 가까운 테스트를 설계하였다.

🎟️ 선착순 쿠폰 발급

선착순 쿠폰 발급 기능은 시스템 내에서 가장 높은 단일 시점 트래픽이 집중되는 기능이다.
이 기능의 안정성을 검증하기 위해, 고부하 상황을 집중적으로 발생시키는 Peak Test 방식으로 시나리오를 구성하였다.

특정 시점에 다수의 사용자가 동시에 쿠폰을 요청하는 상황을 재현하여,
트래픽 집중 시의 응답 시간, 처리율, 에러 발생 여부 등을 중점적으로 검증한다.
이를 통해 트래픽 폭주 상황에서도 시스템이 안정적으로 동작하는지 확인하고자 한다.

🛠️ 테스트 환경 구축

테스트 환경은 로컬 PC의 도커 컨테이너를 활용하여 구성하였다.

Spring 테스트 환경

다음은 스플링 어플리케이션 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:ro

Spring 애플리케이션의 리소스를 제한하기 위해 CPU는 2 vCPU, 메모리는 4 GB로 설정하였다.
추가로, Spring Actuator를 통해 메트릭을 수집하고, Prometheus 및 Grafana와 연동하여 모니터링 대시보드를 구성하였다.

build.gradle.kts의 의존성 추가

dependencies {
    // ... 생략 ...

    // Actuator
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("io.micrometer:micrometer-registry-prometheus")
}

prometheus.yml 추가

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'spring-app'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: [ 'api:8080' ]

application.yml의 actuator 설정 추가

management:
  endpoint:
    health:
      show-details: always
  endpoints:
    web:
      exposure:
        include: "*"
  metrics:
    enable:
      all: true

K6 테스트 환경

부하 테스트 도구로는 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 기반의 통합 테스트 및 모니터링 환경을 구축하였다.

img_8

🎯 테스트 설계 및 목적

🛒 주문/결제

테스트 시나리오

주문/결제 기능은 단기간 폭증보다는 일정 수준의 트래픽이 지속적으로 유지되는 특성이 있으므로,
Load Test 방식으로 최대 300 Virtual Users(VU)까지 점진적으로 증가시키는 테스트를 설계하였다.

  1. 10초 동안 100 VU 까지 증가
  2. 10초 동안 200 VU 까지 증가
  3. 10초 동안 300 VU 까지 증가
  4. 30초 동안 300 VU 유지
  5. 10초 동안 100 VU 까지 감소
  6. 10초 동안 50 VU 까지 감소
  7. 10초 동안 0 VU 까지 감소

목표 TPS

기능 사용자 기준 처리 빈도 목표 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 ;

테스트 스크립트

SLA 설정
  • HTTP 요청의 P99 응답 시간1초 이하를 목표로 한다.
  • HTTP 요청 실패율1% 미만으로 제한한다.
시나리오 구성
  1. 인기상품 조회 : 인기상품 목록을 조회한 후, 응답받은 상품 중 하나를 랜덤으로 선택한다.
  2. 포인트 충전 : 20% 확률로 포인트를 충전하고, 이어서 잔액을 조회한다.
  3. 주문/결제 : 10% 확률로 선택된 상품을 주문하여 주문 요청을 수행한다.
  4. 주문 상태 확인 : 주문/결제는 이벤트 기반으로 처리되므로, 일정 시간 지연 후 상태 조회 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까지 테스트를 진행한다.

  1. 10초 동안 10 VU 까지 증가
  2. 10초 동안 10 VU 유지
  3. 10초 동안 700 VU 까지 증가
  4. 10초 동안 10 VU 까지 감소
  5. 10초 동안 10 VU 유지
  6. 10초 동안 1000 VU 까지 증가
  7. 10초 동안 10 VU 까지 감소
  8. 10초 동안 0 VU 까지 감소

목표 TPS

기능 사용자 기준 처리 빈도 목표 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

테스트 스크립트

SLA 설정
  • 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 CLI 명령어

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)" 

K6 결과 요약

         /\      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  

K6 Grafana 대시보드

img_9

Spring Boot Actuator 메트릭

img_10

결과 분석

주요 API별 응답 시간 분포
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 CLI 명령어

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)"

K6 결과 요약

         /\      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 

K6 Grafana 대시보드

img_5

Spring Boot Actuator 메트릭

img_4

결과 분석

주요 API 응답 시간 분포
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 파티션 및 컨슈머 추가

개선 후 동일한 테스트 시나리오로 재측정한 결과는 다음과 같다.

K6 결과 요약

         /\      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

K6 Grafana 대시보드

img_13

Spring Boot Actuator 메트릭

img_11

결과 분석 및 개선

인기상품조회 응답 시간
구간 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 목표 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 발생을 방지하였다.

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);
    }
}

K6 결과 요약

         /\      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

K6 Grafana 대시보드

img_7

Spring Boot Actuator 메트릭

img_6

결과 분석 및 개선

쿠폰 발급 응답 시간
구간 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 분석
구간 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 방지

이와 같이 부하 테스트 기반의 사전 검증을 통해 병목 지점을 조기에 식별하고,
적절한 대응으로 운영 중 발생 가능한 장애를 효과적으로 예방할 수 있었다.

향후 운영 환경에서는 다음과 같은 추가적인 방어 체계를 통해 장애 예방 효과를 더욱 극대화할 수 있다.

  • 로깅: 병목 또는 예외 발생 시 추적 가능한 구조 확보
  • 서킷 브레이커 적용: 외부 서비스 지연 시 시스템 안정성 유지
  • 모니터링 및 알림 시스템: 지표 기반 실시간 감지 및 대응 체계 강화