Skip to content
Closed
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
1,247 changes: 1,247 additions & 0 deletions grafana/provisioning/dashboards/catcheat-backend-dashboard.json

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions k6/01-store-browse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* ๋งค์žฅ ์กฐํšŒ ๋ถ€ํ•˜ํ…Œ์ŠคํŠธ
* ์‹คํ–‰: k6 run k6/01-store-browse.js
*
* ํ…Œ์ŠคํŠธ ํ•ญ๋ชฉ:
* - GET /stores (๋ชฉ๋ก + ํ•„ํ„ฐ)
* - GET /stores/popular (์ธ๊ธฐ ๋งค์žฅ)
* - GET /stores/nearby (PostGIS ๊ฑฐ๋ฆฌ ๊ณ„์‚ฐ)
* - GET /stores/in-bounds (์ง€๋„ ์˜์—ญ ์กฐํšŒ)
* - GET /stores/{id} (์ƒ์„ธ)
* - GET /remains (์‹œ๊ฐ„๋Œ€๋ณ„ ์ขŒ์„)
*/
import http from 'k6/http';
import { sleep, check, group } from 'k6';
import { Trend } 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);

export const options = {
scenarios: {
// ๋‹จ๊ณ„์ ์œผ๋กœ ๋ถ€ํ•˜ ์ฆ๊ฐ€ โ†’ ์œ ์ง€ โ†’ ๊ฐ์†Œ
ramp_up: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 10 }, // ์›Œ๋ฐ์—…
{ duration: '30s', target: 30 }, // 1๋‹จ๊ณ„
{ duration: '1m', target: 50 }, // 2๋‹จ๊ณ„
{ duration: '1m', target: 50 }, // 50๋ช… ์œ ์ง€
{ duration: '30s', target: 0 }, // ์ข…๋ฃŒ
],
},
},
thresholds: {
...THRESHOLDS,
nearby_duration: ['p(95)<2000'], // PostGIS ์ฟผ๋ฆฌ๋Š” 2์ดˆ ๊ธฐ์ค€
in_bounds_duration: ['p(95)<2000'],
},
};

// ์„œ์šธ ์ค‘์‹ฌ ์ขŒํ‘œ
const SEOUL = { lat: 37.5665, lng: 126.9780 };

// ํ…Œ์ŠคํŠธํ•  ๋งค์žฅ ID ๋ชฉ๋ก (์‹ค์ œ DB์— ์žˆ๋Š” ID๋กœ ๊ต์ฒด)
const STORE_IDS = [1, 2, 3, 4, 5];

export default function () {
const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)];
const today = new Date().toISOString().split('T')[0];

group('๋งค์žฅ ๋ชฉ๋ก ์กฐํšŒ', () => {
const res = http.get(`${BASE_URL}/api/v1/stores?page=0&size=10`, { headers: HEADERS_JSON });
check(res, { '๋ชฉ๋ก 200': (r) => r.status === 200 });
});

group('์ธ๊ธฐ ๋งค์žฅ ์กฐํšŒ', () => {
const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON });
check(res, { '์ธ๊ธฐ 200': (r) => r.status === 200 });
});

group('๊ทผ์ฒ˜ ๋งค์žฅ ์กฐํšŒ (PostGIS)', () => {
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 });
nearbyDuration.add(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 });
inBoundsDuration.add(res.timings.duration);
});

group('๋งค์žฅ ์ƒ์„ธ ์กฐํšŒ', () => {
const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { headers: HEADERS_JSON });
check(res, { '์ƒ์„ธ 200': (r) => r.status === 200 });
});

group('์‹œ๊ฐ„๋Œ€๋ณ„ ์ขŒ์„ ์กฐํšŒ', () => {
const res = http.get(
`${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`,
{ headers: HEADERS_JSON },
);
check(res, { '์ขŒ์„ 200': (r) => r.status === 200 });
});

sleep(1);
}
108 changes: 108 additions & 0 deletions k6/02-reservation-concurrency.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* ์˜ˆ์•ฝ ๋™์‹œ์„ฑ ๋ถ€ํ•˜ํ…Œ์ŠคํŠธ (ํ•ต์‹ฌ ์‹œ๋‚˜๋ฆฌ์˜ค)
* ์‹คํ–‰: k6 run -e AUTH_TOKEN=<JWT> -e REMAIN_ID=<ID> k6/02-reservation-concurrency.js
*
* ๋ชฉ์ : ๊ฐ™์€ remainId์— 100๋ช…์ด ๋™์‹œ์— ์˜ˆ์•ฝ ์š”์ฒญ โ†’ ๋ถ„์‚ฐ๋ฝ/Optimistic Lock ๊ฒ€์ฆ
* ๊ธฐ๋Œ€ ๊ฒฐ๊ณผ:
* - ์ขŒ์„ ์ˆ˜๋งŒํผ๋งŒ ์„ฑ๊ณต (201)
* - ๋‚˜๋จธ์ง€๋Š” REMAIN_EXHAUSTED (400/409)
* - ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ ๊นจ์ง€๋ฉด ์•ˆ ๋จ (์ค‘๋ณต ์˜ˆ์•ฝ X)
*/
import http from 'k6/http';
import { sleep, check, group } from 'k6';
import { Counter, Rate, Trend } from 'k6/metrics';
import { BASE_URL, HEADERS_AUTH, REMAIN_ID, THRESHOLDS } from './config.js';

const successCount = new Counter('reservation_success');
const exhaustedCount = new Counter('reservation_exhausted');
const conflictCount = new Counter('reservation_conflict');
const errorRate = new Rate('reservation_error_rate');
const reserveDuration = new Trend('reservation_duration', true);

export const options = {
scenarios: {
// 100๋ช…์ด ๋™์‹œ์— ํ•œ๊บผ๋ฒˆ์— ์˜ˆ์•ฝ ์š”์ฒญ โ€” ์ŠคํŒŒ์ดํฌ ์‹œ๋‚˜๋ฆฌ์˜ค
spike: {
executor: 'ramping-arrival-rate',
startRate: 0,
timeUnit: '1s',
preAllocatedVUs: 150,
maxVUs: 200,
stages: [
{ duration: '5s', target: 100 }, // 5์ดˆ ๋งŒ์— ์ดˆ๋‹น 100 ์š”์ฒญ
{ duration: '30s', target: 100 }, // 30์ดˆ ์œ ์ง€
{ duration: '5s', target: 0 },
],
},
},
thresholds: {
...THRESHOLDS,
reservation_duration: ['p(95)<3000'],
// ๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ์ด๋ฏ€๋กœ ์—๋Ÿฌ์œจ ๊ธฐ์ค€ ์™„ํ™” (์ขŒ์„ ๋ถ€์กฑ ์—๋Ÿฌ๋Š” ์ •์ƒ)
http_req_failed: ['rate<0.01'], // 5xx ์—๋Ÿฌ๋งŒ ์‹คํŒจ๋กœ ๊ฐ„์ฃผ
},
};

export default function () {
group('์˜ˆ์•ฝ ์ƒ์„ฑ (๋™์‹œ์„ฑ)', () => {
const payload = JSON.stringify({
remainId: parseInt(REMAIN_ID),
member: 2,
});

const res = http.post(`${BASE_URL}/api/v1/reservations`, payload, {
headers: HEADERS_AUTH,
});

reserveDuration.add(res.timings.duration);

if (res.status === 201) {
successCount.add(1);
errorRate.add(false);
check(res, { '์˜ˆ์•ฝ ์„ฑ๊ณต (201)': (r) => r.status === 201 });
} else if (res.status === 400 || res.status === 409) {
const body = res.json();
const code = body?.code || '';

if (code === 'REMAIN_EXHAUSTED') {
exhaustedCount.add(1);
} else if (code === 'OPTIMISTIC_LOCK_CONFLICT') {
conflictCount.add(1);
}

errorRate.add(false); // ์ขŒ์„ ๋ถ€์กฑ/๋ฝ ์ถฉ๋Œ์€ ์ •์ƒ ๋™์ž‘
check(res, { '์ขŒ์„ ๋ถ€์กฑ/๋ฝ ์ถฉ๋Œ (์ •์ƒ)': () => true });
} else {
errorRate.add(true);
check(res, { '์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์—๋Ÿฌ': (r) => r.status < 500 });
console.error(`์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์‘๋‹ต: ${res.status} - ${res.body}`);
}
});

sleep(0.1);
}

export function handleSummary(data) {
const success = data.metrics.reservation_success?.values?.count || 0;
const exhausted = data.metrics.reservation_exhausted?.values?.count || 0;
const conflict = data.metrics.reservation_conflict?.values?.count || 0;
const total = success + exhausted + conflict;
const p99 = data.metrics.reservation_duration?.values?.['p(99)'] || 0;
const p95 = data.metrics.reservation_duration?.values?.['p(95)'] || 0;

return {
stdout: `
===== ์˜ˆ์•ฝ ๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ =====
์ด ์š”์ฒญ์ˆ˜ : ${total}
์˜ˆ์•ฝ ์„ฑ๊ณต : ${success}
์ขŒ์„ ์†Œ์ง„ (์ •์ƒ) : ${exhausted}
๋ฝ ์ถฉ๋Œ (์ •์ƒ) : ${conflict}
p95 ์‘๋‹ต์‹œ๊ฐ„ : ${p95.toFixed(0)}ms
p99 ์‘๋‹ต์‹œ๊ฐ„ : ${p99.toFixed(0)}ms

[๊ฒ€์ฆ] ์˜ˆ์•ฝ ์„ฑ๊ณต ์ˆ˜๊ฐ€ ์‹ค์ œ ์ขŒ์„ ์ˆ˜์™€ ์ผ์น˜ํ•˜๋Š”์ง€ DB์—์„œ ํ™•์ธํ•˜์„ธ์š”.
SELECT COUNT(*) FROM reservations WHERE remain_id = ${REMAIN_ID} AND status != 'PAYMENT_FAILED';
===================================
`,
};
}
112 changes: 112 additions & 0 deletions k6/03-full-flow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* ์‹ค์ œ ์‚ฌ์šฉ์ž ํ”Œ๋กœ์šฐ ๋ถ€ํ•˜ํ…Œ์ŠคํŠธ
* ์‹คํ–‰: k6 run -e AUTH_TOKEN=<JWT> k6/03-full-flow.js
*
* ์‹œ๋‚˜๋ฆฌ์˜ค: ํ™ˆ ์ง„์ž… โ†’ ๋งค์žฅ ๊ฒ€์ƒ‰ โ†’ ์ƒ์„ธ ์กฐํšŒ โ†’ ์ขŒ์„ ํ™•์ธ
* (๊ฒฐ์ œ๊ฐ€ ํ•„์š”ํ•œ ์˜ˆ์•ฝ ํ™•์ •์€ ์™ธ๋ถ€ API ์˜์กด์ด๋ผ ํฌํ•จํ•˜์ง€ ์•Š์Œ)
*/
import http from 'k6/http';
import { sleep, check, group } from 'k6';
import { Trend } from 'k6/metrics';
import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, THRESHOLDS } from './config.js';

const flowDuration = new Trend('full_flow_duration', true);

export const options = {
scenarios: {
// ์ผ๋ฐ˜ ํŠธ๋ž˜ํ”ฝ โ€” ์ ์ง„์  ์ฆ๊ฐ€
normal_traffic: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 10 },
{ duration: '1m', target: 30 },
{ duration: '1m', target: 50 },
{ duration: '30s', target: 0 },
],
},
},
thresholds: {
...THRESHOLDS,
full_flow_duration: ['p(95)<5000'], // ์ „์ฒด ํ”Œ๋กœ์šฐ 5์ดˆ ์ด๋‚ด
},
};

const SEOUL = { lat: 37.5665, lng: 126.9780 };
const STORE_IDS = [1, 2, 3, 4, 5];

export default function () {
const start = Date.now();
const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)];
const today = new Date().toISOString().split('T')[0];

group('1. ํ™ˆ - ์ธ๊ธฐ ๋งค์žฅ ์กฐํšŒ', () => {
const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, {
headers: HEADERS_JSON,
});
check(res, { '์ธ๊ธฐ ๋งค์žฅ 200': (r) => r.status === 200 });
sleep(0.5);
});

group('2. ์นดํ…Œ๊ณ ๋ฆฌ/์ง€์—ญ ํ•„ํ„ฐ ๊ฒ€์ƒ‰', () => {
const categories = ['ํ•œ์‹', '์ผ์‹', '์ค‘์‹', '์–‘์‹'];
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`,
{ headers: HEADERS_JSON },
);
check(res, { 'ํ•„ํ„ฐ ๊ฒ€์ƒ‰ 200': (r) => r.status === 200 });
sleep(0.5);
});

group('3. ์ง€๋„ ํ™”๋ฉด - ์˜์—ญ ๋‚ด ๋งค์žฅ ์กฐํšŒ', () => {
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, { '์ง€๋„ 200': (r) => r.status === 200 });
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 });
sleep(1);
});

group('5. ๋ฉ”๋‰ด + ๋ฆฌ๋ทฐ ์กฐํšŒ (๋™์‹œ)', () => {
const responses = http.batch([
['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 });
sleep(0.5);
});

group('6. ์˜ˆ์•ฝ ๊ฐ€๋Šฅ ์‹œ๊ฐ„ ์กฐํšŒ', () => {
const res = http.get(
`${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`,
{ headers: HEADERS_JSON },
);
check(res, { '์ขŒ์„ 200': (r) => r.status === 200 });
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 });
});
}

flowDuration.add(Date.now() - start);
sleep(1);
}
Loading
Loading