Skip to content

Commit 82235eb

Browse files
authored
Merge: Mongo데이터 삭제 Outbox 도입 및 k6 부하테스트 스크립트
Refactor: Mongo데이터 삭제 Outbox 도입 및 k6 부하테스트 스크립트
2 parents 1d636e7 + 6b292ef commit 82235eb

49 files changed

Lines changed: 2053 additions & 565 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,7 @@ infra/mysql/logs/
4747
*.log
4848
*.pid
4949
.*.swp
50+
51+
# Perf benchmark outputs
52+
perf/**/results/*
53+
!perf/**/results/.gitkeep

perf/delete/README.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Deletion-Only Performance Benchmark (Multi-user)
2+
3+
목표:
4+
5+
- 다수 유저가 실사용처럼 문서/커밋/브랜치를 먼저 생성
6+
- 삭제 API 성능만 별도 측정
7+
- `이벤트 기반 삭제` vs `outbox` 비교를 같은 조건으로 수행
8+
9+
## Files
10+
11+
- `perf/delete/seed_dataset.js`
12+
- 유저별 문서/커밋/브랜치 데이터 생성
13+
- `perf/delete/delete_only_benchmark.js`
14+
- 생성된 문서를 찾아 삭제만 수행
15+
- `perf/delete/compare_delete_summary.mjs`
16+
- 두 브랜치 결과 비교 표 출력
17+
18+
## 0) App run (example)
19+
20+
로컬 부팅 예시:
21+
22+
```bash
23+
MONGO_URI='...' MYSQL_USERNAME='devbae' MYSQL_PASSWORD='' SPRING_PROFILES_ACTIVE='local' bash ./gradlew bootRun
24+
```
25+
26+
`dev` 브랜치에서 메일 env가 필요하면 아래도 같이 추가:
27+
28+
```bash
29+
MAIL_USERNAME='...' MAIL_PASSWORD='...'
30+
```
31+
32+
## 1) Prepare users (no script)
33+
34+
유저 생성은 애플리케이션의 `TestUserInitializer`를 사용합니다.
35+
36+
```bash
37+
PERF_SEED_USER_COUNT=100
38+
```
39+
40+
유저 패턴:
41+
42+
- `perfdel_u001@test.com` ~ `perfdel_u100@test.com`
43+
- 비밀번호: `Testtest1`
44+
45+
## 2) Seed realistic dataset
46+
47+
동일한 `RUN_ID`를 기록해 두고 삭제 벤치마크에서 재사용하세요.
48+
49+
```bash
50+
RUN_ID=run01 \
51+
BASE_URL=http://localhost:8080 \
52+
USER_PREFIX=perfdel USER_DOMAIN=test.com USER_PASSWORD=Testtest1 \
53+
USER_COUNT=100 DOCS_PER_USER=3 \
54+
MAIN_COMMITS=6 FEATURE_COMMITS=4 BLOCKS_PER_COMMIT=20 \
55+
SEED_VUS=20 \
56+
k6 run perf/delete/seed_dataset.js
57+
```
58+
59+
생성 규칙:
60+
61+
- 문서 제목: `PDEL-<RUN_ID>uNNNdNNN`
62+
- 각 문서에 main 커밋 + feature 브랜치 커밋 생성
63+
- 삭제 시 block 수가 충분히 커지도록 commit 당 block 수 지정
64+
65+
## 3) Delete-only benchmark
66+
67+
```bash
68+
RUN_ID=run01 \
69+
BASE_URL=http://localhost:8080 \
70+
USER_PREFIX=perfdel USER_DOMAIN=test.com USER_PASSWORD=Testtest1 \
71+
USER_COUNT=100 DOCS_PER_USER=3 \
72+
DELETE_VUS=30 \
73+
k6 run perf/delete/delete_only_benchmark.js \
74+
--summary-export perf/delete/results/delete_only_summary.json
75+
```
76+
77+
측정 포인트:
78+
79+
- `op_doc_delete_ms` (핵심)
80+
- `http_req_duration`
81+
- 실패율: `http_req_failed`, `delete_failed`
82+
83+
## 4) Branch comparison workflow
84+
85+
권장: 브랜치별로 아래 순서를 동일하게 반복
86+
87+
1. 브랜치 checkout
88+
2. 서버 실행
89+
3. 유저 준비(`PERF_SEED_USER_COUNT` 적용된 상태)
90+
4. 데이터 생성 (`seed_dataset.js`)
91+
5. 삭제 벤치 실행 (`delete_only_benchmark.js`)
92+
6. 결과 파일 저장
93+
94+
예시 결과 파일명:
95+
96+
- `perf/delete/results/dev_delete_summary.json`
97+
- `perf/delete/results/refactor_delete_summary.json`
98+
99+
비교:
100+
101+
```bash
102+
node perf/delete/compare_delete_summary.mjs \
103+
perf/delete/results/dev_delete_summary.json \
104+
perf/delete/results/refactor_delete_summary.json
105+
```
106+
107+
## 5) Fairness checklist
108+
109+
- 브랜치별 동일한 `USER_COUNT`, `DOCS_PER_USER`, `MAIN_COMMITS`, `FEATURE_COMMITS`, `BLOCKS_PER_COMMIT`
110+
- 동일 DB 스펙, 동일 JVM 옵션
111+
- 워밍업 1회 후 본측정 3회 이상 (평균/편차 사용)
112+
- 브랜치 순서 바꿔서 반복 (`dev -> refactor`, `refactor -> dev`)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env node
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
if (process.argv.length < 4) {
6+
console.error('Usage: node perf/delete/compare_delete_summary.mjs <dev-summary.json> <refactor-summary.json>');
7+
process.exit(1);
8+
}
9+
10+
const dev = JSON.parse(fs.readFileSync(path.resolve(process.argv[2]), 'utf-8'));
11+
const ref = JSON.parse(fs.readFileSync(path.resolve(process.argv[3]), 'utf-8'));
12+
13+
function getMetric(summary, name, key) {
14+
const m = summary.metrics?.[name];
15+
if (!m) return null;
16+
if (m.values && typeof m.values === 'object') return m.values[key] ?? null;
17+
if (key === 'rate') return m.value ?? null;
18+
return m[key] ?? null;
19+
}
20+
21+
function pct(base, target) {
22+
if (base === null || target === null || base === 0) return null;
23+
return ((target - base) / base) * 100;
24+
}
25+
26+
function f(v, d = 2) {
27+
if (v === null || Number.isNaN(v)) return '-';
28+
return Number(v).toFixed(d);
29+
}
30+
31+
function row(label, metric, key, digits = 2) {
32+
const b = getMetric(dev, metric, key);
33+
const t = getMetric(ref, metric, key);
34+
const delta = pct(b, t);
35+
const direction = delta === null ? '-' : delta < 0 ? 'improved' : delta > 0 ? 'regressed' : 'same';
36+
37+
return {
38+
label,
39+
base: f(b, digits),
40+
target: f(t, digits),
41+
delta: delta === null ? '-' : `${f(delta, 2)}%`,
42+
direction,
43+
};
44+
}
45+
46+
const rows = [
47+
row('http_req_duration p95 (ms)', 'http_req_duration', 'p(95)'),
48+
row('http_req_duration avg (ms)', 'http_req_duration', 'avg'),
49+
row('doc search p95 (ms)', 'op_doc_search_ms', 'p(95)'),
50+
row('doc delete p50 (ms)', 'op_doc_delete_ms', 'med'),
51+
row('doc delete p95 (ms)', 'op_doc_delete_ms', 'p(95)'),
52+
row('doc delete avg (ms)', 'op_doc_delete_ms', 'avg'),
53+
row('http_req_failed rate', 'http_req_failed', 'rate', 4),
54+
row('delete_failed rate', 'delete_failed', 'rate', 4),
55+
];
56+
57+
console.log('# Delete Benchmark Comparison');
58+
console.log(`- base(dev): ${path.resolve(process.argv[2])}`);
59+
console.log(`- target(refactor): ${path.resolve(process.argv[3])}`);
60+
console.log('');
61+
console.log('| Metric | dev | refactor | Delta | Direction |');
62+
console.log('|---|---:|---:|---:|---|');
63+
for (const r of rows) {
64+
console.log(`| ${r.label} | ${r.base} | ${r.target} | ${r.delta} | ${r.direction} |`);
65+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import http from 'k6/http';
2+
import exec from 'k6/execution';
3+
import { check } from 'k6';
4+
import { Rate, Trend } from 'k6/metrics';
5+
6+
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
7+
const USER_PREFIX = __ENV.USER_PREFIX || 'perfdel';
8+
const USER_DOMAIN = __ENV.USER_DOMAIN || 'test.com';
9+
const USER_PASSWORD = __ENV.USER_PASSWORD || 'Testtest1';
10+
const USER_COUNT = Number(__ENV.USER_COUNT || 50);
11+
const DOCS_PER_USER = Number(__ENV.DOCS_PER_USER || 3);
12+
const RUN_ID = __ENV.RUN_ID;
13+
14+
if (!RUN_ID) {
15+
throw new Error('RUN_ID is required. Use the same RUN_ID that was used in seed_dataset.js');
16+
}
17+
18+
const TOTAL_DOCS = USER_COUNT * DOCS_PER_USER;
19+
20+
export const options = {
21+
scenarios: {
22+
delete_only: {
23+
executor: 'shared-iterations',
24+
vus: Number(__ENV.DELETE_VUS || 30),
25+
iterations: TOTAL_DOCS,
26+
maxDuration: __ENV.DELETE_MAX_DURATION || '20m',
27+
},
28+
},
29+
thresholds: {
30+
http_req_failed: ['rate<0.02'],
31+
delete_failed: ['rate<0.02'],
32+
op_doc_delete_ms: ['p(95)<3000'],
33+
},
34+
};
35+
36+
const deleteFailed = new Rate('delete_failed');
37+
const tLogin = new Trend('op_login_ms');
38+
const tSearch = new Trend('op_doc_search_ms');
39+
const tDelete = new Trend('op_doc_delete_ms');
40+
41+
function pad3(n) {
42+
return String(n).padStart(3, '0');
43+
}
44+
45+
function userEmail(userNo) {
46+
return `${USER_PREFIX}_u${pad3(userNo)}@${USER_DOMAIN}`;
47+
}
48+
49+
function headers(cookie) {
50+
return {
51+
headers: {
52+
'Content-Type': 'application/json',
53+
Cookie: cookie,
54+
},
55+
};
56+
}
57+
58+
function ensureStatus(res, statuses, op) {
59+
const ok = check(res, {
60+
[`${op} status`]: (r) => statuses.includes(r.status),
61+
});
62+
deleteFailed.add(!ok, { op });
63+
if (!ok) {
64+
const snippet = typeof res.body === 'string' ? res.body.slice(0, 240) : '';
65+
throw new Error(`${op} failed status=${res.status}, body=${snippet}`);
66+
}
67+
}
68+
69+
function parseJson(res, op) {
70+
try {
71+
return res.json();
72+
} catch (_e) {
73+
throw new Error(`${op} invalid JSON`);
74+
}
75+
}
76+
77+
function login(email) {
78+
const res = http.post(
79+
`${BASE_URL}/api/user/login`,
80+
JSON.stringify({ email, password: USER_PASSWORD }),
81+
{ headers: { 'Content-Type': 'application/json' }, tags: { op: 'delete_login' } },
82+
);
83+
tLogin.add(res.timings.duration);
84+
ensureStatus(res, [200], 'delete_login');
85+
86+
const jsession = res.cookies.JSESSIONID && res.cookies.JSESSIONID[0];
87+
if (!jsession || !jsession.value) {
88+
throw new Error('delete_login missing JSESSIONID');
89+
}
90+
return `JSESSIONID=${jsession.value}`;
91+
}
92+
93+
function findDocIdByTitle(cookie, title) {
94+
const url = `${BASE_URL}/api/document/search?keyword=${encodeURIComponent(title)}&page=0&size=10&sort=updatedAt&order=desc`;
95+
const res = http.get(url, { ...headers(cookie), tags: { op: 'delete_doc_search' } });
96+
tSearch.add(res.timings.duration);
97+
ensureStatus(res, [200], 'delete_doc_search');
98+
99+
const body = parseJson(res, 'delete_doc_search');
100+
const content = Array.isArray(body.content) ? body.content : [];
101+
const exact = content.find((d) => d && d.title === title);
102+
if (!exact || !exact.id) {
103+
throw new Error(`target doc not found by title=${title}`);
104+
}
105+
return exact.id;
106+
}
107+
108+
function deleteDoc(cookie, docId) {
109+
const res = http.del(
110+
`${BASE_URL}/api/document/${docId}`,
111+
null,
112+
{ ...headers(cookie), tags: { op: 'delete_doc' } },
113+
);
114+
tDelete.add(res.timings.duration);
115+
ensureStatus(res, [204], 'delete_doc');
116+
}
117+
118+
export default function () {
119+
const it = exec.scenario.iterationInTest;
120+
const userNo = Math.floor(it / DOCS_PER_USER) + 1;
121+
const docNo = (it % DOCS_PER_USER) + 1;
122+
123+
const email = userEmail(userNo);
124+
const cookie = login(email);
125+
126+
const key = `${RUN_ID}u${pad3(userNo)}d${pad3(docNo)}`;
127+
const title = `PDEL-${key}`;
128+
129+
const docId = findDocIdByTitle(cookie, title);
130+
deleteDoc(cookie, docId);
131+
}
132+
133+
export function handleSummary(data) {
134+
return {
135+
stdout: `\n[delete-only] run_id=${RUN_ID}, users=${USER_COUNT}, docs_per_user=${DOCS_PER_USER}, total_docs=${TOTAL_DOCS}\n`,
136+
'perf/delete/results/delete_only_summary.json': JSON.stringify(data, null, 2),
137+
};
138+
}

perf/delete/results/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)