From fe685ace6fb0ec15fee4697574725df4d6755d21 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 29 May 2026 00:30:27 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore(test):=20=EB=B9=88=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=EB=B6=80=ED=95=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20?= =?UTF-8?q?JWT=20=ED=86=A0=ED=81=B0=20=EC=83=9D=EC=84=B1=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VacancyLoadTestTokenGenerator.java | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java diff --git a/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java b/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java new file mode 100644 index 0000000..5d3434c --- /dev/null +++ b/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java @@ -0,0 +1,82 @@ +package com.catchtable.loadtest; + +import com.catchtable.global.security.JwtTokenProvider; +import com.catchtable.user.entity.User; +import com.catchtable.user.entity.UserRole; +import com.catchtable.user.entity.UserStatus; +import com.catchtable.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * 빈자리 부하 테스트용 JWT 토큰 100개 생성 일회성 태스크. + * + * 실행: + * ./gradlew test --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" -DrunLoadTokenGen=true + * + * 결과: + * build/load-test-tokens.txt (한 줄당 토큰 1개, 총 100줄) + * build/load-test-tokens.csv (콤마 구분 한 줄 - k6 -e TOKENS= 에 바로 주입 가능) + */ +@SpringBootTest +class VacancyLoadTestTokenGenerator { + + private static final int DEFAULT_USER_COUNT = 100; + private static final String EMAIL_PREFIX = "loadtest+"; + private static final String EMAIL_DOMAIN = "@catchtable.test"; + private static final String GOOGLE_ID_PREFIX = "loadtest-google-"; + private static final String NICKNAME_PREFIX = "loadtest_user_"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Test + @Transactional + void generateTokens() throws IOException { + // -DrunLoadTokenGen=true 일 때만 실행. CI/일반 test 실행 시 스킵. + if (!"true".equals(System.getProperty("runLoadTokenGen"))) { + System.out.println("[SKIP] -DrunLoadTokenGen=true 옵션 없음. 건너뜀."); + return; + } + + int userCount = Integer.getInteger("tokenCount", DEFAULT_USER_COUNT); + List tokens = new ArrayList<>(userCount); + + for (int i = 1; i <= userCount; i++) { + String googleId = GOOGLE_ID_PREFIX + i; + User user = userRepository.findByGoogleId(googleId) + .orElseGet(() -> userRepository.save(User.builder() + .googleId(googleId) + .email(EMAIL_PREFIX + i + EMAIL_DOMAIN) + .nickname(NICKNAME_PREFIX + i) + .profileImage(null) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build())); + + String token = jwtTokenProvider.generateAccessToken(user.getId(), user.getRole()); + tokens.add(token); + } + + Path txt = Path.of("build", "load-test-tokens.txt"); + Path csv = Path.of("build", "load-test-tokens.csv"); + Files.createDirectories(txt.getParent()); + Files.write(txt, tokens); + Files.writeString(csv, String.join(",", tokens)); + + System.out.println("[OK] " + userCount + " tokens written:"); + System.out.println(" - " + txt.toAbsolutePath()); + System.out.println(" - " + csv.toAbsolutePath()); + } +} From 2661826deb654b96b0ac06146e40a7f25ae85949 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 29 May 2026 00:30:43 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20=EB=B9=88=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20k6=20=EB=B6=80=ED=95=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-Phase 구조로 SADD/SMEMBERS/TTL 각각 측정 - PHASE 1: POST /api/v1/vacancy 100회 호출로 SADD 부하 - PHASE 2: k6/experimental/redis 로 SMEMBERS 응답시간 측정 - PHASE 3: TTL/EXISTS 로 키 자동 삭제 확인 --- k6/11-vacancy-test.js | 263 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 k6/11-vacancy-test.js diff --git a/k6/11-vacancy-test.js b/k6/11-vacancy-test.js new file mode 100644 index 0000000..02b85fc --- /dev/null +++ b/k6/11-vacancy-test.js @@ -0,0 +1,263 @@ +/** + * 빈자리 구독 (Vacancy) 부하테스트 - Redis SADD/SMEMBERS 응답시간 측정 + * 실행: k6 run -e PHASE= ... 11-vacancy-test.js + * + * 시험 절차 (원본 스펙): + * 1) 100명 구독자를 SADD 후 SMEMBERS 평균 응답시간 측정 + * 2) 시간대 + 1시간 후 키가 자동 삭제되는지 확인 + * + * 측정 경로: + * - SADD : POST /api/v1/vacancy 를 통해 백엔드 내부에서 호출 (실제 운영 경로) + * -> backend/src/main/java/com/catchtable/vacancy/service/VacancyService.java:56 + * - SMEMBERS : 백엔드가 HTTP 로 노출하지 않음 (Kafka Consumer 내부 호출). + * k6/experimental/redis 모듈로 Redis 에 직접 붙어서 측정. + * -> backend/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java:155 + * - TTL : k6/experimental/redis 의 TTL/EXISTS 로 직접 확인. + * + * Redis 키 포맷 (백엔드 generateRedisKey 기준): + * wait:store:{storeId}:{remainDate}:{remainTime} + * 예) wait:store:1:2026-05-29:18:00 + * + * ============================================================================= + * 실행 환경 - 1안 vs 2안 + * ============================================================================= + * 본 시나리오는 두 가지 실행 환경을 지원한다. 명령 예시는 각 PHASE 안에서 + * [1안] [2안] 으로 구분해 표기. + * + * [1안] EC2 안에서 k6 실행 (권장) + * - URL = http://localhost:8080 (백엔드와 같은 호스트) + * - REDIS_ADDR = catchtable-redis:6379 (docker network 컨테이너명) + * - 필요 작업 : EC2 접속 (SSH 또는 AWS SSM) + k6 설치 + * - 운영 구성 : 변경 없음 + * - 측정 정확도 : 높음 (RTT 거의 0) + * - PHASE 2/3 는 docker network `catchtable-net` 에 붙어야 하므로 + * `docker run --network catchtable-net grafana/k6 run -` 방식 사용. + * + * [2안] 로컬에서 k6 실행 + * - URL = https://<운영도메인> + * - REDIS_ADDR = :6379 + * - 관리자 작업 : docker-compose.prod.yml 의 redis 섹션 expose -> ports + * 변경 + 컨테이너 재시작 + 보안그룹 6379 허용 + * - 운영 구성 : 변경 (테스트 종료 후 원복 필수) + * - 측정 정확도 : 로컬 ↔ EC2 RTT 노이즈 섞임 + * + * ============================================================================= + * PHASE 1: 100명 구독 (SADD 부하 생성) + * ============================================================================= + * + * [사전 준비] + * 1. 백엔드 코드 임시 수정 (테스트 끝나면 반드시 원복!) + * 파일: backend/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java + * generateTokens() 위의 `@Transactional` 어노테이션을 주석 처리한다. + * ( @Transactional 이 살아있으면 시드 유저가 롤백되어 k6 요청에서 USER_NOT_FOUND 발생 ) + * + * 2. 시드 유저 100명 + 토큰 100개 생성 (backend 디렉토리에서) + * ./gradlew test \ + * --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" \ + * -DrunLoadTokenGen=true + * + * 3. 테스트 대상 StoreRemain 준비 + * - remainTeam = 0 인 (잔여 0) StoreRemain 의 id 가 필요하다. + * - DB 에서 미리 하나 골라두고, 그 row 의 remainDate / remainTime 으로 Redis 키를 만든다. + * + * [실행] + * [1안] EC2 안에서 (Bash) + * k6 run -e PHASE=subscribe \ + * -e URL=http://localhost:8080 \ + * -e TOKENS=$(cat build/load-test-tokens.csv) \ + * -e REMAIN_ID= -e N=100 \ + * k6/11-vacancy-test.js + * + * [2안] 로컬에서 (PowerShell) + * $tokens = Get-Content backend\build\load-test-tokens.csv -Raw + * k6 run -e PHASE=subscribe ` + * -e URL=https://<운영도메인> ` + * -e TOKENS=$tokens ` + * -e REMAIN_ID= -e N=100 ` + * backend\k6\11-vacancy-test.js + * + * [2안] 로컬에서 (Bash) + * k6 run -e PHASE=subscribe \ + * -e URL=https://<운영도메인> \ + * -e TOKENS=$(cat backend/build/load-test-tokens.csv) \ + * -e REMAIN_ID= -e N=100 \ + * backend/k6/11-vacancy-test.js + * + * ============================================================================= + * PHASE 2: SMEMBERS 평균 응답시간 측정 + * ============================================================================= + * + * [사전 준비] + * 1. PHASE 1 이 끝나서 Redis 키에 100명이 들어있어야 한다. + * 2. 백엔드 로그에서 Redis 키를 확인한다. + * 예) [빈자리 알림 등록] Redis SADD 완료: key=wait:store:1:2026-05-29:18:00, userId=... + * + * [실행] + * [1안] EC2 안에서 (docker 로 k6 컨테이너를 catchtable-net 에 붙임) + * docker run --rm -i --network catchtable-net \ + * -e PHASE=smembers \ + * -e REDIS_ADDR=catchtable-redis:6379 \ + * -e REDIS_KEY=wait:store:1:2026-05-29:18:00 \ + * -e N=1000 \ + * grafana/k6 run - < k6/11-vacancy-test.js + * + * [2안] 로컬에서 + * k6 run -e PHASE=smembers \ + * -e REDIS_ADDR=:6379 \ + * -e REDIS_KEY=wait:store:1:2026-05-29:18:00 \ + * -e N=1000 \ + * backend/k6/11-vacancy-test.js + * + * (Redis 에 패스워드가 걸려있다면 -e REDIS_PASSWORD=... 추가. 현재 운영은 미설정) + * N = SMEMBERS 호출 반복 횟수 (1000회 정도면 분포가 안정됨). + * 결과: smembers_duration_ms 의 avg / p95 / p99 가 평균 응답시간. + * + * ============================================================================= + * PHASE 3: TTL 자동 삭제 확인 + * ============================================================================= + * + * [실행 - PHASE 1 직후] + * [1안] EC2 안에서 + * docker run --rm -i --network catchtable-net \ + * -e PHASE=ttl \ + * -e REDIS_ADDR=catchtable-redis:6379 \ + * -e REDIS_KEY=wait:store:1:2026-05-29:18:00 \ + * grafana/k6 run - < k6/11-vacancy-test.js + * + * [2안] 로컬에서 + * k6 run -e PHASE=ttl \ + * -e REDIS_ADDR=:6379 \ + * -e REDIS_KEY=wait:store:1:2026-05-29:18:00 \ + * backend/k6/11-vacancy-test.js + * + * 확인 사항: + * - exists == 1 + * - ttl 초 단위 값이 (remainDate+remainTime+1h - now) 와 일치 + * + * [실행 - 만료 시각 (remainDate+remainTime+1h) 이후] + * 동일 명령 다시 실행. + * 확인: exists == 0 (자동 삭제 성공) + * + * ============================================================================= + * 테스트 종료 후 정리 (필수) + * ============================================================================= + * - VacancyLoadTestTokenGenerator.java 의 `@Transactional` 주석 원복. + * - 시드 데이터 정리: + * DELETE FROM vacancy WHERE user_id IN (SELECT id FROM users WHERE google_id LIKE 'loadtest-google-%'); + * DELETE FROM users WHERE google_id LIKE 'loadtest-google-%'; + * - Redis 잔존 키 정리: + * redis-cli --scan --pattern 'wait:store:*' | xargs redis-cli DEL + */ + +import http from 'k6/http'; +import redis from 'k6/experimental/redis'; +import { check } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.URL || 'http://localhost:8080'; +const PHASE = __ENV.PHASE || 'subscribe'; +const N = Number(__ENV.N || 100); + +// PHASE=subscribe 용 +const TOKENS = (__ENV.TOKENS ? __ENV.TOKENS.split(',') : []).filter(Boolean); +const REMAIN_ID = Number(__ENV.REMAIN_ID || 0); + +// PHASE=smembers / ttl 용 +const REDIS_ADDR = __ENV.REDIS_ADDR || 'localhost:6379'; +const REDIS_KEY = __ENV.REDIS_KEY || ''; +const REDIS_PASSWORD = __ENV.REDIS_PASSWORD || ''; + +// 메트릭 +const status2xx = new Counter('status_2xx'); +const status4xx = new Counter('status_4xx'); +const status5xx = new Counter('status_5xx'); +const subscribeDuration = new Trend('subscribe_duration_ms', true); +const smembersDuration = new Trend('smembers_duration_ms', true); +const smembersSize = new Trend('smembers_set_size'); + +// Redis 클라이언트 (smembers / ttl phase 에서만 사용) +// URL 형식: redis://[:password@]host:port +const REDIS_URL = (() => { + const auth = REDIS_PASSWORD ? `:${encodeURIComponent(REDIS_PASSWORD)}@` : ''; + return `redis://${auth}${REDIS_ADDR}`; +})(); +const redisClient = new redis.Client(REDIS_URL); + +export const options = (() => { + if (PHASE === 'subscribe') { + return { vus: N, iterations: N }; + } + if (PHASE === 'smembers') { + // 단일 VU 가 N 번 반복 - SMEMBERS 자체 응답시간 분포만 본다. + // 동시성 부하를 보려면 vus 를 늘릴 것. + return { vus: 1, iterations: N }; + } + if (PHASE === 'ttl') { + return { vus: 1, iterations: 1 }; + } + throw new Error(`Unknown PHASE: ${PHASE}`); +})(); + +// ---- PHASE 1: subscribe ---- +function subscribe() { + if (TOKENS.length === 0) throw new Error('TOKENS env required'); + if (!REMAIN_ID) throw new Error('REMAIN_ID env required'); + + const vu = __VU - 1; + const token = TOKENS[vu % TOKENS.length]; + + const res = http.post( + `${BASE_URL}/api/v1/vacancy`, + JSON.stringify({ remainId: REMAIN_ID }), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + tags: { endpoint: 'subscribe' }, + } + ); + + subscribeDuration.add(res.timings.duration); + if (res.status >= 200 && res.status < 300) status2xx.add(1); + else if (res.status >= 400 && res.status < 500) status4xx.add(1); + else if (res.status >= 500) status5xx.add(1); + + check(res, { + 'subscribe 2xx': (r) => r.status >= 200 && r.status < 300, + }); +} + +// ---- PHASE 2: smembers ---- +async function smembers() { + if (!REDIS_KEY) throw new Error('REDIS_KEY env required'); + + const start = Date.now(); + const members = await redisClient.smembers(REDIS_KEY); + const duration = Date.now() - start; + + smembersDuration.add(duration); + smembersSize.add(members.length); +} + +// ---- PHASE 3: ttl ---- +async function ttl() { + if (!REDIS_KEY) throw new Error('REDIS_KEY env required'); + + const exists = await redisClient.exists(REDIS_KEY); + const ttlSec = await redisClient.ttl(REDIS_KEY); + + console.log(`[TTL CHECK] key=${REDIS_KEY}`); + console.log(` exists = ${exists} (1 = 키 존재, 0 = 자동 삭제됨)`); + console.log(` ttl = ${ttlSec}s (-1 = TTL 없음, -2 = 키 없음)`); + + check(null, { + 'key state observed': () => true, + }); +} + +export default async function () { + if (PHASE === 'subscribe') subscribe(); + else if (PHASE === 'smembers') await smembers(); + else if (PHASE === 'ttl') await ttl(); +} From 45b1eb4c344f89bc6194f3c8d28a0bebfa60508b Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 29 May 2026 00:41:52 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20PR=20#103=20Gemini=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Transactional 제거: 자동 롤백으로 시드 유저 미반영 문제 해결 - Redis 클라이언트 조건부 초기화: subscribe phase 단독 실행 가능 - Date.now() -> performance.now(): sub-ms SMEMBERS 측정 정확도 개선 --- k6/11-vacancy-test.js | 21 +++++++++---------- .../VacancyLoadTestTokenGenerator.java | 2 -- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/k6/11-vacancy-test.js b/k6/11-vacancy-test.js index 02b85fc..6306d33 100644 --- a/k6/11-vacancy-test.js +++ b/k6/11-vacancy-test.js @@ -46,17 +46,13 @@ * ============================================================================= * * [사전 준비] - * 1. 백엔드 코드 임시 수정 (테스트 끝나면 반드시 원복!) - * 파일: backend/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java - * generateTokens() 위의 `@Transactional` 어노테이션을 주석 처리한다. - * ( @Transactional 이 살아있으면 시드 유저가 롤백되어 k6 요청에서 USER_NOT_FOUND 발생 ) - * - * 2. 시드 유저 100명 + 토큰 100개 생성 (backend 디렉토리에서) + * 1. 시드 유저 100명 + 토큰 100개 생성 (backend 디렉토리에서) * ./gradlew test \ * --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" \ * -DrunLoadTokenGen=true + * ( -DrunLoadTokenGen=true 가드가 있어 일반 ./gradlew test 시 자동 스킵됨 ) * - * 3. 테스트 대상 StoreRemain 준비 + * 2. 테스트 대상 StoreRemain 준비 * - remainTeam = 0 인 (잔여 0) StoreRemain 의 id 가 필요하다. * - DB 에서 미리 하나 골라두고, 그 row 의 remainDate / remainTime 으로 Redis 키를 만든다. * @@ -141,7 +137,6 @@ * ============================================================================= * 테스트 종료 후 정리 (필수) * ============================================================================= - * - VacancyLoadTestTokenGenerator.java 의 `@Transactional` 주석 원복. * - 시드 데이터 정리: * DELETE FROM vacancy WHERE user_id IN (SELECT id FROM users WHERE google_id LIKE 'loadtest-google-%'); * DELETE FROM users WHERE google_id LIKE 'loadtest-google-%'; @@ -177,11 +172,13 @@ const smembersSize = new Trend('smembers_set_size'); // Redis 클라이언트 (smembers / ttl phase 에서만 사용) // URL 형식: redis://[:password@]host:port +// subscribe phase 에서는 Redis 접근이 불필요하므로 조건부 초기화 +// (Redis 접근 불가 환경에서도 subscribe phase 단독 실행 가능하게 함) const REDIS_URL = (() => { const auth = REDIS_PASSWORD ? `:${encodeURIComponent(REDIS_PASSWORD)}@` : ''; return `redis://${auth}${REDIS_ADDR}`; })(); -const redisClient = new redis.Client(REDIS_URL); +const redisClient = (PHASE === 'smembers' || PHASE === 'ttl') ? new redis.Client(REDIS_URL) : null; export const options = (() => { if (PHASE === 'subscribe') { @@ -232,9 +229,11 @@ function subscribe() { async function smembers() { if (!REDIS_KEY) throw new Error('REDIS_KEY env required'); - const start = Date.now(); + // performance.now() 는 마이크로초 정밀도 (소수점 ms) 라 sub-ms 측정 가능. + // SMEMBERS 가 1ms 이하인 경우 Date.now() 로는 0 으로 측정되어 분포가 왜곡됨. + const start = performance.now(); const members = await redisClient.smembers(REDIS_KEY); - const duration = Date.now() - start; + const duration = performance.now() - start; smembersDuration.add(duration); smembersSize.add(members.length); diff --git a/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java b/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java index 5d3434c..1e96a86 100644 --- a/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java +++ b/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.nio.file.Files; @@ -42,7 +41,6 @@ class VacancyLoadTestTokenGenerator { private JwtTokenProvider jwtTokenProvider; @Test - @Transactional void generateTokens() throws IOException { // -DrunLoadTokenGen=true 일 때만 실행. CI/일반 test 실행 시 스킵. if (!"true".equals(System.getProperty("runLoadTokenGen"))) { From 25bdca46e649b6cd8ed24d74a985f84bdf0f2589 Mon Sep 17 00:00:00 2001 From: dongan Date: Fri, 29 May 2026 15:56:35 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20k6=20=EB=B6=80=ED=95=98=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 01·03·05·06·07번: event_spike(워밍업 후 스파이크) + ramp_up(점진 증가) 두 시나리오 적용 - 04번: ramp_up 단독으로 서킷브레이커 OPEN 시점 탐색 시나리오로 개선 - docker-compose.prod.yml: Prometheus remote write 수신 활성화 및 포트 외부 노출 - run.ps1: 기본값 운영 서버 주소로 변경 - k6-dashboard.json: event_spike 시나리오명 동기화, 심화 지표 패널 4개 추가 Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.prod.yml | 5 +- k6/01-store-browse.js | 161 ++++- k6/03-full-flow.js | 189 +++++- k6/04-circuit-breaker.js | 114 +++- k6/05-store-list.js | 141 +++-- k6/06-store-nearby.js | 186 +++--- k6/07-remains.js | 125 ++-- k6/grafana/dashboards/k6-dashboard.json | 786 ++++++++++++++++++++++++ k6/run.ps1 | 30 +- 9 files changed, 1445 insertions(+), 292 deletions(-) create mode 100644 k6/grafana/dashboards/k6-dashboard.json diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 18b08ce..6bdb32e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -79,8 +79,9 @@ services: - '--storage.tsdb.retention.time=7d' - '--storage.tsdb.retention.size=1GB' - '--web.enable-lifecycle' - expose: - - "9090" + - '--web.enable-remote-write-receiver' + ports: + - "9090:9090" deploy: resources: limits: diff --git a/k6/01-store-browse.js b/k6/01-store-browse.js index 60eb48f..5afea1d 100644 --- a/k6/01-store-browse.js +++ b/k6/01-store-browse.js @@ -1,6 +1,6 @@ /** - * 매장 조회 부하테스트 - * 실행: k6 run k6/01-store-browse.js + * 매장 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 01 * * 테스트 항목: * - GET /stores (목록 + 필터) @@ -9,81 +9,153 @@ * - GET /stores/in-bounds (지도 영역 조회) * - GET /stores/{id} (상세) * - GET /remains (시간대별 좌석) + * + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 + * + * [두 시나리오 비교 포인트] + * - nearby / in-bounds: PostGIS 쿼리는 GIST 인덱스가 없으면 spike 구간에서 타임아웃 + * - 인기 매장 조회: Redis 캐시 히트율이 높으면 두 시나리오 모두 빠름 (차이 없음) + * - event_spike 점프 직후(30~35s) p95가 급등하면 → 서버가 스파이크를 못 버팀 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; -import { Trend } from 'k6/metrics'; +import exec from 'k6/execution'; +import { Trend, Rate } from 'k6/metrics'; import { BASE_URL, HEADERS_JSON, THRESHOLDS } from './config.js'; -const nearbyDuration = new Trend('nearby_duration', true); -const inBoundsDuration = new Trend('in_bounds_duration', true); +const nearbyDuration = new Trend('browse_nearby_duration', true); +const inBoundsDuration = new Trend('browse_in_bounds_duration', true); + +// 시나리오별 메트릭 — event_spike vs ramp_up 응답시간 분포 비교 +const spikeDuration = new Trend('browse_spike_duration', true); +const rampDuration = new Trend('browse_ramp_duration', true); + +const errorRate = new Rate('browse_error_rate'); export const options = { scenarios: { - // 단계적으로 부하 증가 → 유지 → 감소 + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [단계별 의미] + // 30s: 10명 유지 → 서버가 이미 운영 중인 상태 재현 (JIT 워밍업, 커넥션풀 확보) + // 5s: 10→50명 점프 → 이벤트 오픈 순간 재현 + // 2m: 50명 유지 → 피크 타임 지속 상황에서의 안정성 확인 + // + // [체크포인트] + // 점프 직후(30~35s) 구간 p95가 급등하는지 Grafana에서 확인 + // → 급등하면 서버가 순간 스파이크를 못 버팀 (커넥션풀 또는 스레드풀 부족) + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, // 종료 + ], + tags: { scenario: 'event_spike' }, + }, + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: 몇 명부터 응답시간이 꺾이는지 임계점 탐색 + // event_spike와 달리 서버가 단계적으로 적응하면서 50명에 도달 + // 체크: 10→20→35→50 각 단계에서 nearby p95 변화량 확인 ramp_up: { executor: 'ramping-vus', startVUs: 0, + startTime: '3m20s', stages: [ - { duration: '30s', target: 10 }, // 워밍업 - { duration: '30s', target: 30 }, // 1단계 - { duration: '1m', target: 50 }, // 2단계 - { duration: '1m', target: 50 }, // 50명 유지 - { duration: '30s', target: 0 }, // 종료 + { duration: '30s', target: 10 }, + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 50 }, + { duration: '30s', target: 0 }, ], + tags: { scenario: 'ramp_up' }, }, }, thresholds: { ...THRESHOLDS, - nearby_duration: ['p(95)<2000'], // PostGIS 쿼리는 2초 기준 - in_bounds_duration: ['p(95)<2000'], + 'browse_nearby_duration': ['p(95)<2000'], + 'browse_in_bounds_duration': ['p(95)<2000'], + 'browse_spike_duration': ['p(95)<1500'], + 'browse_ramp_duration': ['p(95)<1000'], + 'browse_error_rate': ['rate<0.01'], }, }; -// 서울 중심 좌표 -const SEOUL = { lat: 37.5665, lng: 126.9780 }; - -// 테스트할 매장 ID 목록 (실제 DB에 있는 ID로 교체) +const SEOUL = { lat: 37.5665, lng: 126.9780 }; const STORE_IDS = [1, 2, 3, 4, 5]; export default function () { + const isSpike = exec.scenario.name === 'event_spike'; const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + // +9h: 서버가 UTC로 실행될 경우 자정~오전9시 사이에 날짜가 하루 틀어지는 것 방지 const today = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().split('T')[0]; + function record(duration) { + isSpike ? spikeDuration.add(duration) : rampDuration.add(duration); + } + group('매장 목록 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores?page=0&size=10`, { headers: HEADERS_JSON }); - check(res, { '목록 200': (r) => r.status === 200 }); + const ok = check(res, { '목록 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); group('인기 매장 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); - check(res, { '인기 200': (r) => r.status === 200 }); + const ok = check(res, { '인기 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); - group('근처 매장 조회 (PostGIS)', () => { + group('근처 매장 조회 (PostGIS nearby)', () => { const res = http.get( `${BASE_URL}/api/v1/stores/nearby?latitude=${SEOUL.lat}&longitude=${SEOUL.lng}&page=0&size=10`, { headers: HEADERS_JSON }, ); - check(res, { '근처 200': (r) => r.status === 200 }); + const ok = check(res, { '근처 200': (r) => r.status === 200 }); + errorRate.add(!ok); nearbyDuration.add(res.timings.duration); + record(res.timings.duration); }); group('지도 영역 매장 조회 (in-bounds)', () => { - // 서울 강남 일대 bounding box const res = http.get( `${BASE_URL}/api/v1/stores/in-bounds` + `?minLat=37.49&maxLat=37.54&minLng=127.01&maxLng=127.07` + `¢erLat=${SEOUL.lat}¢erLng=${SEOUL.lng}&limit=100`, { headers: HEADERS_JSON }, ); - check(res, { 'in-bounds 200': (r) => r.status === 200 }); + const ok = check(res, { 'in-bounds 200': (r) => r.status === 200 }); + errorRate.add(!ok); inBoundsDuration.add(res.timings.duration); + record(res.timings.duration); }); group('매장 상세 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { headers: HEADERS_JSON }); - check(res, { '상세 200': (r) => r.status === 200 }); + const ok = check(res, { '상세 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); group('시간대별 좌석 조회', () => { @@ -91,8 +163,47 @@ export default function () { `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`, { headers: HEADERS_JSON }, ); - check(res, { '좌석 200': (r) => r.status === 200 }); + const ok = check(res, { '좌석 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); sleep(1); } + +export function handleSummary(data) { + const s_p95 = data.metrics.browse_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.browse_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.browse_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.browse_ramp_duration?.values?.['p(99)'] || 0; + const n_p95 = data.metrics.browse_nearby_duration?.values?.['p(95)'] || 0; + const b_p95 = data.metrics.browse_in_bounds_duration?.values?.['p(95)'] || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const errorR = data.metrics.browse_error_rate?.values?.rate || 0; + + const diff = s_p95 - r_p95; + const spikeNote = diff > 200 + ? `⚠ 스파이크 구간이 ${diff.toFixed(0)}ms 더 느림 → 커넥션풀/스레드풀 점검 필요` + : `✓ 스파이크 안정적 처리. 차이 ${diff.toFixed(0)}ms`; + + return { + stdout: ` +===== 매장 조회 부하테스트 결과 — 시나리오 비교 ===== +총 요청수 : ${totalReqs.toLocaleString()}건 +에러율 : ${(errorR * 100).toFixed(3)}% + +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms + +[스파이크 분석] ${spikeNote} + +[API별 p95] + nearby (PostGIS) : ${n_p95.toFixed(0)}ms (기준: 2000ms) + in-bounds (PostGIS) : ${b_p95.toFixed(0)}ms (기준: 2000ms) +==================================================== +`, + }; +} diff --git a/k6/03-full-flow.js b/k6/03-full-flow.js index 176a9ce..2f29cfc 100644 --- a/k6/03-full-flow.js +++ b/k6/03-full-flow.js @@ -1,61 +1,132 @@ /** - * 실제 사용자 플로우 부하테스트 - * 실행: k6 run -e AUTH_TOKEN= k6/03-full-flow.js + * 실제 사용자 플로우 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 03 -AuthToken "eyJ..." * - * 시나리오: 홈 진입 → 매장 검색 → 상세 조회 → 좌석 확인 - * (결제가 필요한 예약 확정은 외부 API 의존이라 포함하지 않음) + * 시나리오: 홈 진입 → 매장 검색 → 상세 조회 → 메뉴/리뷰 확인 → 좌석 확인 + * + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; -import { Trend } from 'k6/metrics'; +import exec from 'k6/execution'; +import { Trend, Rate } from 'k6/metrics'; import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, THRESHOLDS } from './config.js'; const flowDuration = new Trend('full_flow_duration', true); +// 시나리오별 메트릭 +const spikeDuration = new Trend('flow_spike_duration', true); +const rampDuration = new Trend('flow_ramp_duration', true); + +// Step별 Trend — 어느 단계에서 병목이 발생하는지 파악 +const stepDurations = { + popular: new Trend('flow_step_popular', true), + search: new Trend('flow_step_search', true), + mapView: new Trend('flow_step_map', true), + detail: new Trend('flow_step_detail', true), + batch: new Trend('flow_step_batch', true), + remains: new Trend('flow_step_remains', true), +}; + +const errorRate = new Rate('flow_error_rate'); + export const options = { scenarios: { - // 일반 트래픽 — 점진적 증가 - normal_traffic: { + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [체크포인트] + // 점프 직후(30~35s) Step별 p95 중 어느 step이 먼저 터지는지 확인 + // → 가장 먼저 급등하는 step = 이벤트 상황에서의 첫 번째 병목 + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, + ], + tags: { scenario: 'event_spike' }, + }, + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: 몇 명부터 어느 step이 먼저 나빠지는지 임계점 탐색 + ramp_up: { executor: 'ramping-vus', startVUs: 0, + startTime: '3m20s', stages: [ { duration: '30s', target: 10 }, - { duration: '1m', target: 30 }, + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 0 }, + { duration: '30s', target: 50 }, + { duration: '30s', target: 0 }, ], + tags: { scenario: 'ramp_up' }, }, }, thresholds: { ...THRESHOLDS, - full_flow_duration: ['p(95)<5000'], // 전체 플로우 5초 이내 + 'full_flow_duration': ['p(95)<5000'], + 'flow_spike_duration': ['p(95)<6000'], + 'flow_ramp_duration': ['p(95)<5000'], + 'flow_step_detail': ['p(95)<1000'], + 'flow_step_batch': ['p(95)<1500'], + 'flow_step_remains': ['p(95)<600'], + 'flow_error_rate': ['rate<0.01'], }, }; -const SEOUL = { lat: 37.5665, lng: 126.9780 }; -const STORE_IDS = [1, 2, 3, 4, 5]; +const SEOUL = { lat: 37.5665, lng: 126.9780 }; +const STORE_IDS = [1, 2, 3, 4, 5]; +const CATEGORIES = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN', 'MEAT', 'DESSERT']; export default function () { - const start = Date.now(); + const isSpike = exec.scenario.name === 'event_spike'; + const start = Date.now(); const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + // +9h: 서버가 UTC로 실행될 경우 자정~오전9시 사이에 날짜가 하루 틀어지는 것 방지 const today = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().split('T')[0]; + function record(duration) { + isSpike ? spikeDuration.add(duration) : rampDuration.add(duration); + } + group('1. 홈 - 인기 매장 조회', () => { - const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { - headers: HEADERS_JSON, - }); - check(res, { '인기 매장 200': (r) => r.status === 200 }); + const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); + const ok = check(res, { '인기 매장 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.popular.add(res.timings.duration); + record(res.timings.duration); sleep(0.5); }); group('2. 카테고리/지역 필터 검색', () => { - const categories = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN']; - const category = categories[Math.floor(Math.random() * categories.length)]; + const category = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)]; const res = http.get( - `${BASE_URL}/api/v1/stores?category=${encodeURIComponent(category)}&page=0&size=10`, + `${BASE_URL}/api/v1/stores?category=${category}&page=0&size=10`, { headers: HEADERS_JSON }, ); - check(res, { '필터 검색 200': (r) => r.status === 200 }); + const ok = check(res, { '필터 검색 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.search.add(res.timings.duration); + record(res.timings.duration); sleep(0.5); }); @@ -66,15 +137,19 @@ export default function () { `¢erLat=${SEOUL.lat}¢erLng=${SEOUL.lng}&limit=100`, { headers: HEADERS_JSON }, ); - check(res, { '지도 200': (r) => r.status === 200 }); + const ok = check(res, { '지도 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.mapView.add(res.timings.duration); + record(res.timings.duration); sleep(0.3); }); group('4. 매장 상세 조회', () => { - const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { - headers: HEADERS_JSON, - }); - check(res, { '상세 200': (r) => r.status === 200 }); + const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { headers: HEADERS_JSON }); + const ok = check(res, { '상세 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.detail.add(res.timings.duration); + record(res.timings.duration); sleep(1); }); @@ -83,8 +158,11 @@ export default function () { ['GET', `${BASE_URL}/api/v1/stores/${storeId}/menu`, null, { headers: HEADERS_JSON }], ['GET', `${BASE_URL}/api/v1/reviews/store/${storeId}`, null, { headers: HEADERS_JSON }], ]); - check(responses[0], { '메뉴 200': (r) => r.status === 200 }); - check(responses[1], { '리뷰 200': (r) => r.status === 200 }); + const ok = check(responses[0], { '메뉴 200': (r) => r.status === 200 }) && + check(responses[1], { '리뷰 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.batch.add(Math.max(responses[0].timings.duration, responses[1].timings.duration)); + record(Math.max(responses[0].timings.duration, responses[1].timings.duration)); sleep(0.5); }); @@ -93,20 +171,63 @@ export default function () { `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`, { headers: HEADERS_JSON }, ); - check(res, { '좌석 200': (r) => r.status === 200 }); + const ok = check(res, { '좌석 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.remains.add(res.timings.duration); + record(res.timings.duration); sleep(0.5); }); - // 로그인 유저만: 북마크 폴더 조회 if (HEADERS_AUTH.Authorization !== 'Bearer ') { group('7. 북마크 폴더 조회 (로그인)', () => { - const res = http.get(`${BASE_URL}/api/v1/bookmark-folders`, { - headers: HEADERS_AUTH, - }); - check(res, { '북마크 200': (r) => r.status === 200 }); + const res = http.get(`${BASE_URL}/api/v1/bookmark-folders`, { headers: HEADERS_AUTH }); + const ok = check(res, { '북마크 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); } flowDuration.add(Date.now() - start); sleep(1); } + +export function handleSummary(data) { + const s_p95 = data.metrics.flow_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.flow_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.flow_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.flow_ramp_duration?.values?.['p(99)'] || 0; + const flow_p95 = data.metrics.full_flow_duration?.values?.['p(95)'] || 0; + + const steps = [ + { name: '1. 인기 매장', p95: data.metrics.flow_step_popular?.values?.['p(95)'] || 0 }, + { name: '2. 필터 검색', p95: data.metrics.flow_step_search?.values?.['p(95)'] || 0 }, + { name: '3. 지도 조회', p95: data.metrics.flow_step_map?.values?.['p(95)'] || 0 }, + { name: '4. 매장 상세', p95: data.metrics.flow_step_detail?.values?.['p(95)'] || 0 }, + { name: '5. 메뉴+리뷰', p95: data.metrics.flow_step_batch?.values?.['p(95)'] || 0 }, + { name: '6. 잔여석', p95: data.metrics.flow_step_remains?.values?.['p(95)'] || 0 }, + ]; + const bottleneck = steps.reduce((a, b) => a.p95 > b.p95 ? a : b); + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const errorR = data.metrics.flow_error_rate?.values?.rate || 0; + + return { + stdout: ` +===== 전체 사용자 플로우 부하테스트 결과 ===== +총 요청수 : ${totalReqs.toLocaleString()}건 +에러율 : ${(errorR * 100).toFixed(3)}% +전체 플로우 p95 : ${flow_p95.toFixed(0)}ms + +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms + +[Step별 p95] +${steps.map(s => ` ${s.name.padEnd(12)}: ${s.p95.toFixed(0)}ms`).join('\n')} + +[병목 1순위] → ${bottleneck.name} (p95: ${bottleneck.p95.toFixed(0)}ms) +============================================= +`, + }; +} diff --git a/k6/04-circuit-breaker.js b/k6/04-circuit-breaker.js index b9fdef2..3ff7d95 100644 --- a/k6/04-circuit-breaker.js +++ b/k6/04-circuit-breaker.js @@ -1,15 +1,40 @@ /** - * 서킷브레이커 부하테스트 - * 실행: k6 run --out experimental-prometheus-rw -e AUTH_TOKEN= -e BASE_URL=https://api.catcheat.kro.kr k6/04-circuit-breaker.js + * 서킷브레이커 부하테스트 — 점진적 증가로 OPEN 전환 시점 탐색 + * 실행: .\k6\run.ps1 -Test 04 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr * * 목적: - * - LLM API(Spring AI)에 동시 요청을 쏴서 서킷브레이커가 열리는 시점 확인 - * - 서킷 CLOSED → OPEN → HALF-OPEN 상태 전환 관찰 + * - LLM API(Spring AI)에 점진적으로 부하를 올려 서킷브레이커가 열리는 VU 수 확인 + * - 서킷 CLOSED → OPEN → HALF-OPEN → CLOSED 전체 상태 전환 사이클 관찰 * - * 서킷브레이커 상태 판별: - * - 응답 느림 + 성공/실패 → CLOSED (정상 흐름, LLM 호출 중) - * - 응답 빠름 + 에러 → OPEN (서킷 차단, 즉시 실패 반환) - * - 응답 일부 성공 → HALF-OPEN (복구 테스트 중) + * [서킷브레이커에 ramp_up을 쓰는 이유] + * - LLM API는 응답시간이 수초~수십초로 가변적 + * - 50명 동시 접속(constant_50)은 서버가 감당하기 전에 타임아웃이 폭발해서 + * 서킷이 열리는 시점 자체를 관찰할 수 없음 + * - 점진적으로 올려야 "2명일 때는 괜찮다가 10명에서 서킷이 열린다"는 정보를 얻을 수 있음 + * + * 전체 소요 시간: 약 5분 30초 + * [0s ~ 30s] warmup — 2VU, 서킷 CLOSED 기준선 측정 + * [30s ~ 3m] ramp_up — 2→20VU 점진 증가, 서킷 OPEN 유발 + * [3m ~ 4m] sustained — 20VU 유지, OPEN 상태 지속 관찰 + * [4m ~ 4m30s] recovery — 2VU 복귀, HALF-OPEN → CLOSED 복구 확인 + * + * [서킷 상태 판별 방법] + * CLOSED (정상): 응답이 느리지만 200 반환. LLM이 실제 처리 중. + * OPEN (차단): 응답이 매우 빠르면서(< 500ms) 에러. 서킷이 즉시 차단. + * HALF-OPEN(복구): 부하 제거 후 일부 요청만 성공. 서킷이 복구 테스트 중. + * + * [체크포인트] + * 1. circuit_open_responses 카운터가 올라가기 시작하는 VU 수 + * → 그 수가 현재 서킷브레이커 설정의 failureRateThreshold 도달 지점 + * 2. ramp_up → sustained 전환 후에도 circuit_open_responses가 계속 올라가는지 + * → 계속 올라가면 서킷이 계속 OPEN 상태 (복구 안 됨) + * 3. recovery 구간에서 circuit_closed_responses가 다시 올라가는지 + * → 올라가면 HALF-OPEN → CLOSED 복구 성공 + * + * [Grafana 모니터링] + * - circuit_open_responses vs circuit_closed_responses 동시에 보기 + * - vus 그래프와 함께 보면 몇 명에서 서킷이 열리는지 정확히 파악 + * - chat_duration: OPEN 상태면 < 500ms, CLOSED 상태면 수초 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; @@ -21,31 +46,41 @@ const circuitClosedCount = new Counter('circuit_closed_responses'); // 정상 const llmErrorRate = new Rate('llm_error_rate'); const chatDuration = new Trend('chat_duration', true); -// 서킷 OPEN 판별 기준: 500ms 미만이면서 에러 → 즉시 차단된 것 +// 서킷 OPEN 판별 기준: 500ms 미만이면서 에러 → 서킷이 즉시 차단한 것 +// LLM 정상 처리는 최소 수백ms~수초이므로 500ms 이하 빠른 실패 = OPEN 확실 const CIRCUIT_OPEN_THRESHOLD_MS = 500; export const options = { scenarios: { - // 1단계: 워밍업 (서킷 CLOSED 기준선 확인) + // ── 워밍업: 서킷 CLOSED 기준선 측정 ───────────────────────────────────── + // 2VU로 정상 상태에서의 응답시간 확인. 이 구간 chat_duration이 기준선. + // 여기서도 에러가 나면 → LLM API 자체 문제 (부하와 무관) warmup: { executor: 'constant-vus', vus: 2, duration: '30s', tags: { phase: 'warmup' }, }, - // 2단계: 부하 증가 → 서킷 OPEN 유도 + + // ── ramp_up: 서킷 OPEN 유발 구간 ───────────────────────────────────────── + // VU를 점진적으로 올리면서 circuit_open_responses 카운터 증가 시점을 포착 + // 2→5→10→20명 단계에서 Grafana 확인: + // - 5명까지 정상 → 10명에서 circuit_open 증가 시작 → 10명이 임계점 ramp_up: { executor: 'ramping-vus', startVUs: 2, + startTime: '30s', stages: [ - { duration: '30s', target: 5 }, - { duration: '1m', target: 10 }, - { duration: '1m', target: 20 }, + { duration: '30s', target: 5 }, // LLM은 소규모에서도 느릴 수 있음 + { duration: '1m', target: 10 }, // 대부분 서킷 OPEN 시작 구간 + { duration: '1m', target: 20 }, // 확실한 OPEN 상태 유도 ], - startTime: '30s', tags: { phase: 'ramp_up' }, }, - // 3단계: 고부하 유지 → 서킷 OPEN 상태 관찰 + + // ── sustained: OPEN 상태 지속 관찰 ────────────────────────────────────── + // 서킷이 열린 상태를 1분간 유지. circuit_open_responses 비율 측정. + // 대부분의 요청이 즉시 차단(< 500ms)되어야 정상적인 OPEN 상태 sustained: { executor: 'constant-vus', vus: 20, @@ -53,7 +88,11 @@ export const options = { startTime: '3m', tags: { phase: 'sustained' }, }, - // 4단계: 부하 제거 → HALF-OPEN 전환 관찰 + + // ── recovery: HALF-OPEN → CLOSED 복구 확인 ────────────────────────────── + // 부하를 낮추면 서킷브레이커가 HALF-OPEN 상태로 전환되어 일부 요청만 통과시킴. + // circuit_closed_responses가 다시 올라오면 복구 성공. + // 복구가 안 되면 → 서킷브레이커 waitDurationInOpenState 설정 확인 recovery: { executor: 'constant-vus', vus: 2, @@ -63,12 +102,13 @@ export const options = { }, }, thresholds: { - llm_error_rate: ['rate<0.8'], // 에러율 80% 미만 (서킷 완전 차단 시 100%) - chat_duration: ['p(95)<60000'], // LLM 특성상 타임아웃 기준 60초 + // LLM 특성상 응답시간 기준을 60초로 매우 여유 있게 설정 + // 실제 서킷이 제대로 작동하면 OPEN 상태에서 대부분 < 500ms로 즉시 차단됨 + llm_error_rate: ['rate<0.8'], // 에러율 80% 미만 (서킷 OPEN 시 거의 100%) + chat_duration: ['p(95)<60000'], // LLM 타임아웃 기준 }, }; -// 테스트용 메시지 목록 (다양한 복잡도) const TEST_MESSAGES = [ { message: '안녕하세요', latitude: 37.5665, longitude: 126.9780 }, { message: '강남 근처 한식 맛집 추천해줘', latitude: 37.4979, longitude: 127.0276 }, @@ -77,22 +117,22 @@ const TEST_MESSAGES = [ ]; export default function () { - const msg = TEST_MESSAGES[Math.floor(Math.random() * TEST_MESSAGES.length)]; + const msg = TEST_MESSAGES[Math.floor(Math.random() * TEST_MESSAGES.length)]; const payload = JSON.stringify(msg); group('챗봇 메시지 전송', () => { const res = http.post(`${BASE_URL}/api/v1/chat/messages`, payload, { headers: HEADERS_AUTH, - timeout: 65000, // LLM 응답 최대 대기 (65초) + timeout: '65s', }); chatDuration.add(res.timings.duration); - const isSuccess = res.status === 200 || res.status === 201; + const isSuccess = res.status === 200 || res.status === 201; + // 빠른 실패 판별: 500ms 미만 + 에러 → 서킷 OPEN 상태 const isFastFail = res.timings.duration < CIRCUIT_OPEN_THRESHOLD_MS && !isSuccess; if (isFastFail) { - // 빠른 실패 = 서킷 OPEN 상태 circuitOpenCount.add(1); llmErrorRate.add(true); console.log(`[서킷 OPEN] ${res.timings.duration.toFixed(0)}ms - status: ${res.status}`); @@ -100,7 +140,7 @@ export default function () { circuitClosedCount.add(1); llmErrorRate.add(false); } else { - // 느린 실패 (LLM 타임아웃 등) + // 느린 실패 — LLM 타임아웃 또는 서킷 CLOSED 상태에서의 LLM 오류 llmErrorRate.add(true); console.log(`[LLM 실패] ${res.timings.duration.toFixed(0)}ms - status: ${res.status} - ${res.body?.substring(0, 100)}`); } @@ -121,21 +161,31 @@ export function handleSummary(data) { const p95 = data.metrics.chat_duration?.values?.['p(95)'] || 0; const p99 = data.metrics.chat_duration?.values?.['p(99)'] || 0; + const openRate = total > 0 ? ((open / total) * 100).toFixed(1) : '0.0'; + return { stdout: ` ===== 서킷브레이커 테스트 결과 ===== -총 요청수 : ${total} -정상 응답 (CLOSED) : ${closed} -즉시 차단 (OPEN) : ${open} -서킷 OPEN 비율 : ${total > 0 ? ((open / total) * 100).toFixed(1) : 0}% +총 요청수 : ${total}건 +정상 응답 (CLOSED) : ${closed}건 +즉시 차단 (OPEN) : ${open}건 +서킷 OPEN 비율 : ${openRate}% p95 응답시간 : ${(p95 / 1000).toFixed(2)}초 p99 응답시간 : ${(p99 / 1000).toFixed(2)}초 [분석] -- "즉시 차단" 수가 늘어나는 시점 = 서킷 OPEN 전환 시점 -- 부하 제거 후 정상 응답 돌아오면 HALF-OPEN → CLOSED 복구 확인 -===================================== + circuit_open_responses 카운터가 증가하기 시작한 시점 (Grafana 확인): + → warmup(2VU): 0건이어야 정상 + → ramp_up 중 5VU / 10VU / 20VU 구간 중 어느 시점에 open 증가? + 그 VU 수가 현재 서킷브레이커 설정의 실질적 임계 부하 + + recovery 구간(4m~4m30s)에서 closed_responses가 다시 올라오면 → 복구 성공 + 올라오지 않으면 → waitDurationInOpenState 설정 확인: + @CircuitBreaker(name=..., waitDurationInOpenState=30s) + + OPEN 비율 > 80% → LLM API 응답이 매우 불안정. 폴백 로직 강화 필요. +=================================== `, }; } diff --git a/k6/05-store-list.js b/k6/05-store-list.js index 57cee1f..23f416a 100644 --- a/k6/05-store-list.js +++ b/k6/05-store-list.js @@ -1,80 +1,105 @@ /** - * [단일 API] 매장 목록 조회 부하테스트 - * 실행: .\k6\run.ps1 -Test 05 -BaseUrl https://api.catcheat.kro.kr + * [단일 API] 매장 목록 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 05 * - * 목적: - * - GET /stores (필터 조합) 단일 API에 집중 부하 - * - Specification 기반 동적 필터링 + 페이지네이션 성능 확인 - * - GET /stores/popular (인기 매장 Top10) 함께 측정 - * - * 기대 결과: - * - p95 < 500ms (인덱스 정상 활용 시) - * - p95 > 1s 이면 popularity 정렬 컬럼 인덱스 검토 필요 + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; +import exec from 'k6/execution'; import { Trend, Rate } from 'k6/metrics'; import { BASE_URL, HEADERS_JSON } from './config.js'; const listDuration = new Trend('store_list_duration', true); const popularDuration = new Trend('store_popular_duration', true); +const spikeDuration = new Trend('list_spike_duration', true); +const rampDuration = new Trend('list_ramp_duration', true); const errorRate = new Rate('store_list_error_rate'); -// 테스트할 카테고리 필터 목록 — 실제 DB에 있는 값으로 교체 -const CATEGORIES = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN', 'CAFE']; -// 테스트할 지역(district) 필터 목록 -const DISTRICTS = ['GANGNAM', 'MAPO', 'JONGNO', 'YONGSAN', 'SEONGDONG']; - export const options = { scenarios: { - // 1단계: 워밍업 (JVM JIT, DB 커넥션풀 안정화) - warmup: { - executor: 'constant-vus', - vus: 5, - duration: '30s', - tags: { phase: 'warmup' }, + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [체크포인트] + // 인덱스가 제대로 걸려 있으면 점프 직후에도 p95가 크게 안 올라야 함 + // 급등하면 → DB 커넥션풀 부족 또는 Specification 쿼리 Full Scan + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, + ], + tags: { scenario: 'event_spike' }, }, - // 2단계: 단계적 부하 증가 → 50VU까지 + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: 몇 명부터 store_list_duration p95가 800ms를 넘는지 임계점 탐색 ramp_up: { executor: 'ramping-vus', - startVUs: 5, + startVUs: 0, + startTime: '3m20s', stages: [ + { duration: '30s', target: 10 }, { duration: '30s', target: 20 }, { duration: '30s', target: 35 }, { duration: '1m', target: 50 }, - { duration: '1m', target: 50 }, // 50VU 유지 + { duration: '30s', target: 50 }, { duration: '30s', target: 0 }, ], - startTime: '30s', - tags: { phase: 'ramp_up' }, + tags: { scenario: 'ramp_up' }, }, }, thresholds: { - store_list_duration: ['p(95)<800'], // 목록 조회 p95 800ms 기준 - store_popular_duration: ['p(95)<500'], // 인기 매장은 단순 정렬이라 500ms 기준 - store_list_error_rate: ['rate<0.01'], // 에러율 1% 미만 - http_req_failed: ['rate<0.01'], + 'store_list_duration': ['p(95)<800'], + 'store_popular_duration': ['p(95)<500'], + 'list_spike_duration': ['p(95)<1200'], + 'list_ramp_duration': ['p(95)<800'], + 'store_list_error_rate': ['rate<0.01'], + 'http_req_failed': ['rate<0.01'], }, }; +const CATEGORIES = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN', 'CAFE']; +const DISTRICTS = ['강남구', '마포구', '종로구', '용산구', '성동구']; + export default function () { - // 랜덤 필터 조합 — 실제 사용자가 다양한 필터로 조회하는 상황 재현 + const isSpike = exec.scenario.name === 'event_spike'; const randomCategory = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)]; const randomDistrict = DISTRICTS[Math.floor(Math.random() * DISTRICTS.length)]; - const randomPage = Math.floor(Math.random() * 5); // 0~4 페이지 - const useFilter = Math.random() > 0.5; // 50% 확률로 필터 적용 + const randomPage = Math.floor(Math.random() * 5); + const useFilter = Math.random() > 0.5; + + function record(duration) { + isSpike ? spikeDuration.add(duration) : rampDuration.add(duration); + } group('매장 목록 조회 (필터 조합)', () => { let url = `${BASE_URL}/api/v1/stores?page=${randomPage}&size=10`; if (useFilter) { - // 필터 조합을 랜덤하게 — 카테고리만, 지역만, 둘 다 섞어서 테스트 if (Math.random() > 0.5) url += `&category=${randomCategory}`; if (Math.random() > 0.5) url += `&district=${randomDistrict}`; } - const res = http.get(url, { headers: HEADERS_JSON }); listDuration.add(res.timings.duration); - + record(res.timings.duration); const ok = check(res, { '목록 조회 200': (r) => r.status === 200, '응답 body 존재': (r) => r.body && r.body.length > 0, @@ -85,39 +110,45 @@ export default function () { group('인기 매장 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); popularDuration.add(res.timings.duration); - - check(res, { - '인기 매장 200': (r) => r.status === 200, - }); + record(res.timings.duration); + check(res, { '인기 매장 200': (r) => r.status === 200 }); }); sleep(1); } export function handleSummary(data) { - const p95List = data.metrics.store_list_duration?.values?.['p(95)'] || 0; - const p99List = data.metrics.store_list_duration?.values?.['p(99)'] || 0; - const p95Popular = data.metrics.store_popular_duration?.values?.['p(95)'] || 0; - const totalReqs = data.metrics.http_reqs?.values?.count || 0; - const errorR = data.metrics.store_list_error_rate?.values?.rate || 0; + const s_p95 = data.metrics.list_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.list_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.list_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.list_ramp_duration?.values?.['p(99)'] || 0; + const p95List = data.metrics.store_list_duration?.values?.['p(95)'] || 0; + const p99List = data.metrics.store_list_duration?.values?.['p(99)'] || 0; + const p95Popular = data.metrics.store_popular_duration?.values?.['p(95)'] || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const errorR = data.metrics.store_list_error_rate?.values?.rate || 0; return { stdout: ` -===== 매장 목록 조회 단일 API 부하테스트 결과 ===== -총 요청수 : ${totalReqs} +===== 매장 목록 단일 API 부하테스트 결과 ===== +총 요청수 : ${totalReqs.toLocaleString()}건 에러율 : ${(errorR * 100).toFixed(2)}% -[GET /stores] -p95 응답시간 : ${p95List.toFixed(0)}ms -p99 응답시간 : ${p99List.toFixed(0)}ms +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms -[GET /stores/popular] -p95 응답시간 : ${p95Popular.toFixed(0)}ms +[API별 p95] + GET /stores (필터 조합) : ${p95List.toFixed(0)}ms (기준: 800ms) + GET /stores (p99) : ${p99List.toFixed(0)}ms + GET /stores/popular : ${p95Popular.toFixed(0)}ms (기준: 500ms) [분석] -- p95 > 800ms → popularity 정렬 컬럼(star, reviewCount, bookmarkCount)에 복합 인덱스 검토 -- p95 > 1s → Specification 동적 쿼리 실행계획 확인 (EXPLAIN ANALYZE) -================================================= + p95 > 800ms → popularity 정렬 컬럼 복합 인덱스 검토 + p95 > 1s → Specification 동적 쿼리 실행계획 확인: EXPLAIN ANALYZE +============================================== `, }; } diff --git a/k6/06-store-nearby.js b/k6/06-store-nearby.js index 493f12a..6bace33 100644 --- a/k6/06-store-nearby.js +++ b/k6/06-store-nearby.js @@ -1,30 +1,27 @@ /** - * [단일 API] PostGIS 지리 쿼리 부하테스트 - * 실행: .\k6\run.ps1 -Test 06 -BaseUrl https://api.catcheat.kro.kr - * - * 목적: - * - GET /stores/nearby (ST_DistanceSphere 거리 계산 + 정렬) - * - GET /stores/in-bounds (지도 bounding box 조회) - * 두 API가 PostGIS 지리 쿼리를 사용하는 병목 지점 + * [단일 API] PostGIS 지리 쿼리 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 06 * * 이전 테스트 결과: 50VU에서 nearby p95=10.33s, in-bounds p95=8.36s * → GIST 공간 인덱스 미적용 의심 → 이 테스트로 수치 재확인 * - * 기대 결과: - * - GIST 인덱스 적용 후: p95 < 1s - * - 미적용 시: 50VU 이상에서 타임아웃 발생 확인 + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; +import exec from 'k6/execution'; import { Trend, Rate, Counter } from 'k6/metrics'; import { BASE_URL, HEADERS_JSON } from './config.js'; const nearbyDuration = new Trend('nearby_duration', true); const inBoundsDuration = new Trend('in_bounds_duration', true); +const spikeDuration = new Trend('geo_spike_duration', true); +const rampDuration = new Trend('geo_ramp_duration', true); const timeoutCount = new Counter('geo_timeout_count'); const errorRate = new Rate('geo_error_rate'); -// 서울 주요 지역 좌표 — 다양한 지점에서 조회하여 캐시 히트 방지 const LOCATIONS = [ { name: '강남', lat: 37.4979, lng: 127.0276, minLat: 37.49, maxLat: 37.52, minLng: 127.01, maxLng: 127.05 }, { name: '홍대', lat: 37.5563, lng: 126.9236, minLat: 37.54, maxLat: 37.57, minLng: 126.91, maxLng: 126.94 }, @@ -36,60 +33,81 @@ const LOCATIONS = [ export const options = { scenarios: { - // 1단계: 소부하 — 기준선 측정 (인덱스 있어도 없어도 여기선 빠름) - baseline: { - executor: 'constant-vus', - vus: 10, - duration: '30s', - tags: { phase: 'baseline' }, - }, - // 2단계: 중부하 — 인덱스 없으면 이 구간부터 급격히 느려짐 - medium_load: { - executor: 'constant-vus', - vus: 30, - duration: '1m', - startTime: '35s', - tags: { phase: 'medium_load' }, + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [체크포인트] + // GIST 인덱스 없으면 점프 직후 타임아웃 폭발 → timeoutCount 급증 + // GIST 인덱스 있으면 50명에서도 p95 < 2s 유지 + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, + ], + tags: { scenario: 'event_spike' }, }, - // 3단계: 고부하 — 이전 테스트에서 타임아웃 발생한 구간 (50VU) - high_load: { - executor: 'constant-vus', - vus: 50, - duration: '1m', - startTime: '2m', - tags: { phase: 'high_load' }, + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: 몇 명부터 타임아웃이 발생하는지 → 인덱스 없는 서버의 한계 탐색 + ramp_up: { + executor: 'ramping-vus', + startVUs: 0, + startTime: '3m20s', + stages: [ + { duration: '30s', target: 10 }, + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 50 }, + { duration: '30s', target: 0 }, + ], + tags: { scenario: 'ramp_up' }, }, }, thresholds: { - nearby_duration: ['p(95)<2000'], // 2초 기준 (PostGIS 특성 고려) - in_bounds_duration: ['p(95)<2000'], - geo_error_rate: ['rate<0.05'], - http_req_failed: ['rate<0.05'], + 'nearby_duration': ['p(95)<2000'], + 'in_bounds_duration': ['p(95)<2000'], + 'geo_spike_duration': ['p(95)<3000'], + 'geo_ramp_duration': ['p(95)<2000'], + 'geo_error_rate': ['rate<0.05'], + 'http_req_failed': ['rate<0.05'], }, }; export default function () { - // 랜덤 위치 선택 — 매번 다른 좌표로 DB 풀스캔 유도 - const loc = LOCATIONS[Math.floor(Math.random() * LOCATIONS.length)]; - // 좌표에 미세한 오프셋 추가 → 캐시 효과 없애고 실제 쿼리 유도 - const latOffset = (Math.random() - 0.5) * 0.005; - const lngOffset = (Math.random() - 0.5) * 0.005; - const lat = (loc.lat + latOffset).toFixed(6); - const lng = (loc.lng + lngOffset).toFixed(6); - - group(`근처 매장 조회 (PostGIS nearby) - ${loc.name}`, () => { + const isSpike = exec.scenario.name === 'event_spike'; + const loc = LOCATIONS[Math.floor(Math.random() * LOCATIONS.length)]; + const lat = (loc.lat + (Math.random() - 0.5) * 0.005).toFixed(6); + const lng = (loc.lng + (Math.random() - 0.5) * 0.005).toFixed(6); + + function record(duration) { + isSpike ? spikeDuration.add(duration) : rampDuration.add(duration); + } + + group(`근처 매장 조회 (nearby) - ${loc.name}`, () => { const res = http.get( `${BASE_URL}/api/v1/stores/nearby?latitude=${lat}&longitude=${lng}&page=0&size=10`, { headers: HEADERS_JSON, timeout: 15000 }, ); - nearbyDuration.add(res.timings.duration); - - // 15초 이상 걸리면 타임아웃으로 카운트 - if (res.timings.duration > 15000 || res.status === 0) { - timeoutCount.add(1); - } - + record(res.timings.duration); + if (res.timings.duration > 15000 || res.status === 0) timeoutCount.add(1); const ok = check(res, { 'nearby 200': (r) => r.status === 200, 'nearby 15s 이내': (r) => r.timings.duration < 15000, @@ -103,14 +121,10 @@ export default function () { `?minLat=${loc.minLat}&maxLat=${loc.maxLat}` + `&minLng=${loc.minLng}&maxLng=${loc.maxLng}` + `¢erLat=${lat}¢erLng=${lng}&limit=50`; - - const res = http.get(url, { headers: HEADERS_JSON, timeout: 15000 }); + const res = http.get(url, { headers: HEADERS_JSON, timeout: '15s' }); inBoundsDuration.add(res.timings.duration); - - if (res.timings.duration > 15000 || res.status === 0) { - timeoutCount.add(1); - } - + record(res.timings.duration); + if (res.timings.duration > 15000 || res.status === 0) timeoutCount.add(1); check(res, { 'in-bounds 200': (r) => r.status === 200, 'in-bounds 15s 이내': (r) => r.timings.duration < 15000, @@ -121,35 +135,37 @@ export default function () { } export function handleSummary(data) { - const p50Nearby = data.metrics.nearby_duration?.values?.['p(50)'] || 0; - const p95Nearby = data.metrics.nearby_duration?.values?.['p(95)'] || 0; - const p99Nearby = data.metrics.nearby_duration?.values?.['p(99)'] || 0; - const p95Bounds = data.metrics.in_bounds_duration?.values?.['p(95)'] || 0; - const p99Bounds = data.metrics.in_bounds_duration?.values?.['p(99)'] || 0; - const timeouts = data.metrics.geo_timeout_count?.values?.count || 0; - const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const s_p95 = data.metrics.geo_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.geo_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.geo_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.geo_ramp_duration?.values?.['p(99)'] || 0; + const p95Nearby = data.metrics.nearby_duration?.values?.['p(95)'] || 0; + const p95Bounds = data.metrics.in_bounds_duration?.values?.['p(95)'] || 0; + const timeouts = data.metrics.geo_timeout_count?.values?.count || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + + const indexStatus = timeouts > 0 + ? `⚠ 타임아웃 ${timeouts}건 → GIST 인덱스 미적용 가능성` + : `✓ 타임아웃 0건 → GIST 인덱스 정상`; return { stdout: ` -===== PostGIS 지리 쿼리 단일 API 부하테스트 결과 ===== -총 요청수 : ${totalReqs} -타임아웃 횟수 : ${timeouts} - -[GET /stores/nearby] -p50 응답시간 : ${p50Nearby.toFixed(0)}ms -p95 응답시간 : ${p95Nearby.toFixed(0)}ms -p99 응답시간 : ${p99Nearby.toFixed(0)}ms - -[GET /stores/in-bounds] -p95 응답시간 : ${p95Bounds.toFixed(0)}ms -p99 응답시간 : ${p99Bounds.toFixed(0)}ms - -[분석] -- p95 > 2s 또는 타임아웃 존재 → GIST 공간 인덱스 미적용 가능성 높음 - 확인 쿼리: SELECT indexname FROM pg_indexes WHERE tablename = 'stores'; - 적용 예시: CREATE INDEX idx_stores_location ON stores USING GIST(location); -- p95 < 500ms → 인덱스 정상 적용 상태 -====================================================== +===== PostGIS 지리 쿼리 부하테스트 결과 ===== +총 요청수 : ${totalReqs.toLocaleString()}건 +타임아웃 횟수 : ${timeouts}건 + +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms + +[API별 p95] + GET /stores/nearby : ${p95Nearby.toFixed(0)}ms (기준: 2000ms) + GET /stores/in-bounds : ${p95Bounds.toFixed(0)}ms (기준: 2000ms) + +[GIST 인덱스 진단] ${indexStatus} +============================================= `, }; } diff --git a/k6/07-remains.js b/k6/07-remains.js index e58929f..285a588 100644 --- a/k6/07-remains.js +++ b/k6/07-remains.js @@ -1,55 +1,89 @@ /** - * [단일 API] 잔여석 조회 부하테스트 - * 실행: .\k6\run.ps1 -Test 07 -BaseUrl https://api.catcheat.kro.kr + * [단일 API] 잔여석 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 07 * - * 목적: - * - GET /remains?storeId={id}&date={date} 단일 API 집중 부하 - * - 예약 전 반드시 호출되는 API → 실제 트래픽에서 가장 빈번하게 호출됨 - * - (store_id, remain_date) 복합 인덱스 효과 검증 - * - * 기대 결과: - * - p95 < 200ms (인덱스 정상 활용 시, 단순 조회 쿼리) - * - 300VU에서도 안정적으로 처리 가능해야 함 + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; +import exec from 'k6/execution'; import { Trend, Rate } from 'k6/metrics'; import { BASE_URL, HEADERS_JSON } from './config.js'; const remainsDuration = new Trend('remains_duration', true); +const spikeDuration = new Trend('remains_spike_duration', true); +const rampDuration = new Trend('remains_ramp_duration', true); const errorRate = new Rate('remains_error_rate'); -// 테스트할 매장 ID 목록 — 실제 DB에 존재하는 ID로 교체 const STORE_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; export const options = { scenarios: { - // 최대 50VU + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [체크포인트] + // 잔여석은 단순 인덱스 조회라 점프 후에도 p95 300ms 이내가 정상 + // 넘으면 → (store_id, remain_date) 복합 인덱스 미적용 또는 Row Lock 경합 + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, + ], + tags: { scenario: 'event_spike' }, + }, + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: p95가 VU 수와 비례해서 증가하는지 확인 + // 인덱스가 제대로 걸리면 VU 수와 무관하게 p95가 일정해야 함 ramp_up: { executor: 'ramping-vus', startVUs: 0, + startTime: '3m20s', stages: [ - { duration: '30s', target: 10 }, // 워밍업 - { duration: '30s', target: 30 }, - { duration: '1m', target: 50 }, // 최대 50VU - { duration: '1m', target: 50 }, // 유지 - { duration: '30s', target: 0 }, // 종료 + { duration: '30s', target: 10 }, + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 50 }, + { duration: '30s', target: 0 }, ], + tags: { scenario: 'ramp_up' }, }, }, thresholds: { - remains_duration: ['p(95)<300', 'p(99)<500'], // 단순 인덱스 조회라 300ms 기준 - remains_error_rate: ['rate<0.01'], - http_req_failed: ['rate<0.01'], + 'remains_duration': ['p(95)<300', 'p(99)<500'], + 'remains_spike_duration': ['p(95)<400'], + 'remains_ramp_duration': ['p(95)<300'], + 'remains_error_rate': ['rate<0.01'], + 'http_req_failed': ['rate<0.01'], }, }; export default function () { - const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; - - // 오늘 포함 향후 7일 중 랜덤 날짜 — 캐시 히트 방지 + 실제 사용 패턴 재현 + const isSpike = exec.scenario.name === 'event_spike'; + const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + // +9h: 서버가 UTC로 실행될 경우 자정~오전9시 사이에 날짜가 하루 틀어지는 것 방지 const today = new Date(Date.now() + 9 * 60 * 60 * 1000); - const dayOffset = Math.floor(Math.random() * 7); // 0~6일 후 + const dayOffset = Math.floor(Math.random() * 7); today.setDate(today.getDate() + dayOffset); const date = today.toISOString().split('T')[0]; @@ -58,8 +92,8 @@ export default function () { `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${date}`, { headers: HEADERS_JSON }, ); - remainsDuration.add(res.timings.duration); + isSpike ? spikeDuration.add(res.timings.duration) : rampDuration.add(res.timings.duration); const ok = check(res, { '잔여석 200': (r) => r.status === 200, @@ -68,33 +102,40 @@ export default function () { errorRate.add(!ok); }); - sleep(0.5); // 잔여석은 짧은 sleep — 실제 클릭 패턴 반영 + sleep(0.5); } export function handleSummary(data) { - const p50 = data.metrics.remains_duration?.values?.['p(50)'] || 0; - const p95 = data.metrics.remains_duration?.values?.['p(95)'] || 0; - const p99 = data.metrics.remains_duration?.values?.['p(99)'] || 0; - const max = data.metrics.remains_duration?.values?.max || 0; - const reqs = data.metrics.http_reqs?.values?.count || 0; - const rps = data.metrics.http_reqs?.values?.rate || 0; + const s_p95 = data.metrics.remains_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.remains_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.remains_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.remains_ramp_duration?.values?.['p(99)'] || 0; + const p95 = data.metrics.remains_duration?.values?.['p(95)'] || 0; + const p99 = data.metrics.remains_duration?.values?.['p(99)'] || 0; + const reqs = data.metrics.http_reqs?.values?.count || 0; + const rps = data.metrics.http_reqs?.values?.rate || 0; + const errR = data.metrics.remains_error_rate?.values?.rate || 0; + + const indexNote = p95 < 300 + ? `✓ p95 ${p95.toFixed(0)}ms → 복합 인덱스 정상` + : `⚠ p95 ${p95.toFixed(0)}ms → 인덱스 미적용 또는 Row Lock 경합 의심`; return { stdout: ` ===== 잔여석 조회 단일 API 부하테스트 결과 ===== -총 요청수 : ${reqs} +총 요청수 : ${reqs.toLocaleString()}건 최대 RPS : ${rps.toFixed(1)} req/s +에러율 : ${(errR * 100).toFixed(3)}% + +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms -p50 응답시간 : ${p50.toFixed(0)}ms -p95 응답시간 : ${p95.toFixed(0)}ms -p99 응답시간 : ${p99.toFixed(0)}ms -최대 응답시간 : ${max.toFixed(0)}ms +[전체 p95 : ${p95.toFixed(0)}ms / p99 : ${p99.toFixed(0)}ms] -[분석] -- p95 < 200ms → 정상 (인덱스 활용 중) -- p95 200~500ms → 쿼리 실행계획 확인 권장 - EXPLAIN ANALYZE SELECT * FROM store_remains WHERE store_id=? AND remain_date=?; -- p95 > 500ms → (store_id, remain_date) 복합 인덱스 누락 가능성 +[인덱스 진단] ${indexNote} ================================================ `, }; diff --git a/k6/grafana/dashboards/k6-dashboard.json b/k6/grafana/dashboards/k6-dashboard.json new file mode 100644 index 0000000..ec6ccfc --- /dev/null +++ b/k6/grafana/dashboards/k6-dashboard.json @@ -0,0 +1,786 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" }, + { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" }, + { "type": "panel", "id": "stat", "name": "Stat", "version": "" }, + { "type": "panel", "id": "timeseries", "name": "Time series", "version": "" }, + { "type": "panel", "id": "gauge", "name": "Gauge", "version": "" } + ], + "annotations": { "list": [] }, + "description": "k6 부하테스트 실시간 모니터링", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "title": "요약", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "id": 2, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "가상 유저 (VUs)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "id": 3, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "초당 요청수 (RPS)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total[1m]))", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "id": 4, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "에러율", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{expected_response=\"false\"}[1m])) / sum(rate(k6_http_reqs_total[1m])) * 100", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 500 }, + { "color": "red", "value": 1000 } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "id": 5, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "p95 응답시간", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1000 }, + { "color": "red", "value": 3000 } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "id": 6, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "p99 응답시간", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "id": 7, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["sum"] } }, + "title": "총 요청수", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(k6_http_reqs_total)", + "instant": true, + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 8, + "title": "트래픽", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 9, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "가상 유저 수 (VUs)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus", + "legendFormat": "활성 VUs", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus_max", + "legendFormat": "최대 VUs", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 10, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "초당 요청수 (RPS)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total[1m]))", + "legendFormat": "전체 RPS", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{expected_response=\"false\"}[1m]))", + "legendFormat": "에러 RPS", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "id": 11, + "title": "응답시간", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "id": 12, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "응답시간 백분위 (p50 / p95 / p99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.50, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p99", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "id": 13, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "구간별 응답시간 (대기 / 연결 / 수신)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_http_req_waiting_seconds_sum[1m]) / rate(k6_http_req_waiting_seconds_count[1m]) * 1000", + "legendFormat": "서버 대기 (TTFB)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_http_req_connecting_seconds_sum[1m]) / rate(k6_http_req_connecting_seconds_count[1m]) * 1000", + "legendFormat": "TCP 연결", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_http_req_receiving_seconds_sum[1m]) / rate(k6_http_req_receiving_seconds_count[1m]) * 1000", + "legendFormat": "응답 수신", + "refId": "C" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, + "id": 14, + "title": "예약 동시성 테스트 (02번 전용)", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "id": 15, + "options": { "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "예약 결과 (성공 / 좌석소진 / 락충돌)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_reservation_success_total[1m])", + "legendFormat": "예약 성공", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_reservation_exhausted_total[1m])", + "legendFormat": "좌석 소진", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_reservation_conflict_total[1m])", + "legendFormat": "락 충돌", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 }, + "id": 16, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "예약 API 응답시간 (p95 / p99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_reservation_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p95", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_reservation_duration_seconds_bucket[1m])) by (le)) * 1000", + + "legendFormat": "p99", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 }, + "id": 20, + "title": "시나리오 비교 — event_spike (워밍업 후 스파이크) vs ramp_up (점진 증가)", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "같은 테스트를 두 시나리오로 실행했을 때 p95 응답시간 차이.\n두 선의 간격이 크면 → 서버가 워밍업(JIT, DB 커넥션풀)에 민감한 것.\n간격이 작으면 → 잘 설계된 서버.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 33 }, + "id": 21, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "시나리오별 p95 / p99 응답시간 비교", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"event_spike\"}[1m])) by (le)) * 1000", + "legendFormat": "event_spike p95 (워밍업 후 스파이크)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"ramp_up\"}[1m])) by (le)) * 1000", + "legendFormat": "ramp_up p95 (점진 증가)", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"event_spike\"}[1m])) by (le)) * 1000", + "legendFormat": "event_spike p99", + "refId": "C" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"ramp_up\"}[1m])) by (le)) * 1000", + "legendFormat": "ramp_up p99", + "refId": "D" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "VU 수(막대)와 ramp_up 시나리오 p95(선)를 겹쳐서 보면\n몇 명에서 응답시간이 꺾이는지(임계점) 한눈에 파악 가능.\n꺾임 지점 = 현재 서버 설정(커넥션풀, 스레드풀)의 실질적 한계.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "ramp_up p95 응답시간 (ms)" }, + "properties": [ + { "id": "custom.axisPlacement", "value": "right" }, + { "id": "unit", "value": "ms" }, + { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } } + ] + } + ] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 41 }, + "id": 22, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "VU 수 vs ramp_up p95 — 임계점(Inflection Point) 탐색", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus", + "legendFormat": "활성 VU 수", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"ramp_up\"}[1m])) by (le)) * 1000", + "legendFormat": "ramp_up p95 응답시간 (ms)", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "두 시나리오의 초당 요청 수(RPS) 비교.\nVU 수가 같은 시점(ramp_up 50VU 도달 후 vs event_spike)에서 RPS가 비슷하면 → 처리량 동일.\nevent_spike 초반 RPS가 낮으면 → 워밍업 구간 이후 스파이크 처리 안정화 확인.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 49 }, + "id": 23, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "시나리오별 RPS (처리량 비교)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{scenario=\"event_spike\"}[1m]))", + "legendFormat": "event_spike RPS", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{scenario=\"ramp_up\"}[1m]))", + "legendFormat": "ramp_up RPS", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "두 시나리오의 에러율 비교.\nevent_spike 초반에만 에러가 나면 → DB 커넥션풀 확보 전 타임아웃.\n에러가 특정 시점부터 계속 올라가면 → 서버 포화 상태.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 49 }, + "id": 24, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "시나리오별 에러율", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{scenario=\"event_spike\",expected_response=\"false\"}[1m])) / sum(rate(k6_http_reqs_total{scenario=\"event_spike\"}[1m]))", + "legendFormat": "event_spike 에러율", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{scenario=\"ramp_up\",expected_response=\"false\"}[1m])) / sum(rate(k6_http_reqs_total{scenario=\"ramp_up\"}[1m]))", + "legendFormat": "ramp_up 에러율", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 57 }, + "id": 25, + "title": "심화 지표", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "4xx와 5xx를 분리해서 보는 이유:\n4xx = 비즈니스 로직 문제 (토큰 만료, 좌석 소진 등) → 코드 수정 필요\n5xx = 서버 오류 (DB 장애, OOM, 타임아웃 등) → 즉시 원인 분석 필요\n에러율만 보면 원인을 알 수 없어서 이 패널이 반드시 필요함.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "5xx (서버 오류)" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "4xx (비즈니스 오류)" }, + "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 58 }, + "id": 26, + "options": { + "legend": { "calcs": ["max", "sum"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "HTTP 상태 코드별 에러 분리 (4xx vs 5xx)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{status=~\"5..\"}[1m]))", + "legendFormat": "5xx (서버 오류)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{status=~\"4..\"}[1m]))", + "legendFormat": "4xx (비즈니스 오류)", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{status=~\"2..\"}[1m]))", + "legendFormat": "2xx (성공)", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "Dropped Iterations: k6가 이전 이터레이션을 미처 끝내지 못해 요청 자체를 버린 횟수.\n이 수치가 올라가면 서버가 너무 느려서 k6가 계획한 대로 요청조차 못 보내는 상황.\n→ 테스트 결과 자체가 의미 없어지는 신호. 반드시 0이어야 함.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "bars", "fillOpacity": 80, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Dropped Iterations" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 58 }, + "id": 27, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "Dropped Iterations — 테스트 무결성 확인", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_dropped_iterations_total[1m])", + "legendFormat": "Dropped Iterations", + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "응답이 느릴 때 원인을 구분하는 방법:\n- TTFB(서버 대기)는 길고 수신량이 작으면 → 서버 처리 병목 (쿼리 최적화 필요)\n- 수신량이 크고 수신 시간이 길면 → 응답 크기 문제 (페이지네이션·캐시 검토)\n- 전송량이 크면 → 요청 body가 불필요하게 큰 것", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 66 }, + "id": 28, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "데이터 송수신량 (네트워크 vs 서버 병목 구분)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_data_received_bytes_total[1m])", + "legendFormat": "수신 (서버 → 클라이언트)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_data_sent_bytes_total[1m])", + "legendFormat": "송신 (클라이언트 → 서버)", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "두 시나리오의 VU 수를 분리해서 보여줌.\nevent_spike: 0~3m20s 구간에서 실행 (워밍업 10명 → 50명 점프)\nramp_up: 3m20s 이후 구간에서 실행 (0→50명 단계 증가)\n두 시나리오가 겹치지 않으므로 타임라인에서 구간별로 확인.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 66 }, + "id": 29, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "시나리오별 VU 수 분리", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus{scenario=\"event_spike\"}", + "legendFormat": "event_spike VUs", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus{scenario=\"ramp_up\"}", + "legendFormat": "ramp_up VUs", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus", + "legendFormat": "전체 VUs", + "refId": "C" + } + ] + } + + ], + "refresh": "5s", + "schemaVersion": 38, + "tags": ["k6", "load-test"], + "templating": { "list": [] }, + "time": { "from": "now-15m", "to": "now" }, + "timepicker": {}, + "timezone": "Asia/Seoul", + "title": "k6 부하테스트 모니터링", + "uid": "catcheat-k6-v1", + "version": 1, + "weekStart": "" +} diff --git a/k6/run.ps1 b/k6/run.ps1 index aaa4b69..513191c 100644 --- a/k6/run.ps1 +++ b/k6/run.ps1 @@ -1,23 +1,18 @@ # k6 부하테스트 실행 스크립트 # # 사용법: -# .\k6\run.ps1 -Test 01 # 인증 불필요 -# .\k6\run.ps1 -Test 02 -AuthToken "eyJ..." -RemainId 5 # 예약 동시성 -# .\k6\run.ps1 -Test 04 -AuthToken "eyJ..." # 서킷브레이커 -# .\k6\run.ps1 -Test 05 -BaseUrl https://api.catcheat.kro.kr # 운영서버 목록 부하 -# .\k6\run.ps1 -Test 06 -BaseUrl https://api.catcheat.kro.kr # PostGIS 지리 쿼리 -# .\k6\run.ps1 -Test 07 -BaseUrl https://api.catcheat.kro.kr # 잔여석 조회 -# .\k6\run.ps1 -Test 08 -AuthToken "eyJ..." -CouponTemplateId 1 # 쿠폰 선착순 발급 -# .\k6\run.ps1 -Test 10 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr # Soak +# .\k6\run.ps1 -Test 01 # 기본 (운영서버 + 서버 Grafana) +# .\k6\run.ps1 -Test 02 -AuthToken "eyJ..." -RemainId 5 +# .\k6\run.ps1 -Test 01 -BaseUrl http://localhost:8080 # 로컬 서버 테스트 # # 테스트 번호 목록: -# 01 - 매장 전체 탐색 플로우 (6개 API 조합) +# 01 - 매장 전체 탐색 플로우 (constant_50 + ramp_up) # 02 - 예약 동시성 (분산락 + 낙관적락 검증) -# 03 - 전체 사용자 플로우 (home → 예약) -# 04 - AI 챗봇 서킷브레이커 -# 05 - [단일] 매장 목록 조회 (필터 조합, 최대 200VU) -# 06 - [단일] PostGIS 지리 쿼리 (nearby + in-bounds, 최대 150VU) -# 07 - [단일] 잔여석 조회 (최대 300VU) +# 03 - 전체 사용자 플로우 (constant_50 + ramp_up) +# 04 - AI 챗봇 서킷브레이커 (ramp_up) +# 05 - [단일] 매장 목록 조회 (constant_50 + ramp_up) +# 06 - [단일] PostGIS 지리 쿼리 (constant_50 + ramp_up) +# 07 - [단일] 잔여석 조회 (constant_50 + ramp_up) # 08 - [단일] 쿠폰 선착순 발급 동시성 # 10 - [내구성] Soak 테스트 (10VU × 30분) @@ -29,8 +24,9 @@ param( [string]$AuthToken = "", [string]$RemainId = "1", [string]$CouponTemplateId = "1", - [string]$BaseUrl = "http://localhost:8080", - [string]$PrometheusUrl = "http://localhost:9091/api/v1/write" + [string]$BaseUrl = "https://api.catcheat.kro.kr", + [string]$PrometheusUrl = "http://54.116.98.27:9090/api/v1/write", + [string]$GrafanaUrl = "http://54.116.98.27:3001" ) $scriptMap = @{ @@ -52,7 +48,7 @@ Write-Host "===== k6 부하테스트 시작 =====" -ForegroundColor Cyan Write-Host "테스트 : $Test" Write-Host "스크립트 : $scriptName" Write-Host "대상 서버 : $BaseUrl" -Write-Host "Grafana : http://localhost:3002 (실시간 모니터링)" +Write-Host "Grafana : $GrafanaUrl (실시간 모니터링)" Write-Host "" $env:K6_PROMETHEUS_RW_SERVER_URL = $PrometheusUrl From 7a9b728839dd5f15dfe80c54130dafb019889f03 Mon Sep 17 00:00:00 2001 From: dongan Date: Fri, 29 May 2026 16:06:19 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20k6=20URL=20=EC=9D=B8=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=EB=B0=8F=20Date=20=ED=83=80=EC=9E=84=EC=A1=B4=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 05: district 파라미터 한글 encodeURIComponent 적용 - 07: setDate/getDate 타임존 혼용 → ms 연산으로 통일 Co-Authored-By: Claude Sonnet 4.6 --- k6/05-store-list.js | 2 +- k6/07-remains.js | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/k6/05-store-list.js b/k6/05-store-list.js index 23f416a..4322798 100644 --- a/k6/05-store-list.js +++ b/k6/05-store-list.js @@ -95,7 +95,7 @@ export default function () { let url = `${BASE_URL}/api/v1/stores?page=${randomPage}&size=10`; if (useFilter) { if (Math.random() > 0.5) url += `&category=${randomCategory}`; - if (Math.random() > 0.5) url += `&district=${randomDistrict}`; + if (Math.random() > 0.5) url += `&district=${encodeURIComponent(randomDistrict)}`; } const res = http.get(url, { headers: HEADERS_JSON }); listDuration.add(res.timings.duration); diff --git a/k6/07-remains.js b/k6/07-remains.js index 285a588..5ca2e58 100644 --- a/k6/07-remains.js +++ b/k6/07-remains.js @@ -82,10 +82,8 @@ export default function () { const isSpike = exec.scenario.name === 'event_spike'; const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; // +9h: 서버가 UTC로 실행될 경우 자정~오전9시 사이에 날짜가 하루 틀어지는 것 방지 - const today = new Date(Date.now() + 9 * 60 * 60 * 1000); - const dayOffset = Math.floor(Math.random() * 7); - today.setDate(today.getDate() + dayOffset); - const date = today.toISOString().split('T')[0]; + const dayOffset = Math.floor(Math.random() * 7) * 24 * 60 * 60 * 1000; + const date = new Date(Date.now() + 9 * 60 * 60 * 1000 + dayOffset).toISOString().split('T')[0]; group('잔여석 조회', () => { const res = http.get(