Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +83 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

운영 환경(docker-compose.prod.yml)에서 Prometheus 포트 9090을 외부(0.0.0.0:9090)에 그대로 노출하는 것은 심각한 보안 위험입니다.

특히 현재 설정에는 --web.enable-lifecycle--web.enable-remote-write-receiver 옵션이 활성화되어 있어, 인증되지 않은 외부 사용자가 다음과 같은 악의적인 행위를 수행할 수 있습니다:

  1. POST /-/reload 또는 POST /-/quit 호출을 통한 Prometheus 강제 종료 및 설정 변경
  2. 원격 쓰기 수신기(/api/v1/write)에 대량의 허위 메트릭을 전송하여 디스크 고갈 및 서비스 거부(DoS) 유발
  3. 내부 시스템 메트릭 및 민감한 인프라 정보 무단 조회

해결 방안:

  • 외부 노출이 불필요하다면 루프백 주소로 바인딩하여 외부 접근을 차단하세요: 127.0.0.1:9090:9090
  • 외부(예: 로컬 k6)에서 원격 쓰기가 필요하다면, 이미 동일한 compose 파일에 정의된 Nginx 프록시를 통해 기본 인증(Basic Auth) 및 SSL을 적용하여 안전하게 라우팅하는 것을 권장합니다.
    ports:
      - "127.0.0.1:9090:9090"

deploy:
resources:
limits:
Expand Down
161 changes: 136 additions & 25 deletions k6/01-store-browse.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* 매장 조회 부하테스트
* 실행: k6 run k6/01-store-browse.js
* 매장 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가
* 실행: .\k6\run.ps1 -Test 01
*
* 테스트 항목:
* - GET /stores (목록 + 필터)
Expand All @@ -9,90 +9,201 @@
* - 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` +
`&centerLat=${SEOUL.lat}&centerLng=${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('시간대별 좌석 조회', () => {
const res = http.get(
`${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)
====================================================
`,
};
}
Loading
Loading