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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ infra/mysql/logs/
*.log
*.pid
.*.swp

# Perf benchmark outputs
perf/**/results/*
!perf/**/results/.gitkeep
112 changes: 112 additions & 0 deletions perf/delete/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Deletion-Only Performance Benchmark (Multi-user)

목표:

- 다수 유저가 실사용처럼 문서/커밋/브랜치를 먼저 생성
- 삭제 API 성능만 별도 측정
- `이벤트 기반 삭제` vs `outbox` 비교를 같은 조건으로 수행

## Files

- `perf/delete/seed_dataset.js`
- 유저별 문서/커밋/브랜치 데이터 생성
- `perf/delete/delete_only_benchmark.js`
- 생성된 문서를 찾아 삭제만 수행
- `perf/delete/compare_delete_summary.mjs`
- 두 브랜치 결과 비교 표 출력

## 0) App run (example)

로컬 부팅 예시:

```bash
MONGO_URI='...' MYSQL_USERNAME='devbae' MYSQL_PASSWORD='' SPRING_PROFILES_ACTIVE='local' bash ./gradlew bootRun
```

`dev` 브랜치에서 메일 env가 필요하면 아래도 같이 추가:

```bash
MAIL_USERNAME='...' MAIL_PASSWORD='...'
```

## 1) Prepare users (no script)

유저 생성은 애플리케이션의 `TestUserInitializer`를 사용합니다.

```bash
PERF_SEED_USER_COUNT=100
```

유저 패턴:

- `perfdel_u001@test.com` ~ `perfdel_u100@test.com`
- 비밀번호: `Testtest1`

## 2) Seed realistic dataset

동일한 `RUN_ID`를 기록해 두고 삭제 벤치마크에서 재사용하세요.

```bash
RUN_ID=run01 \
BASE_URL=http://localhost:8080 \
USER_PREFIX=perfdel USER_DOMAIN=test.com USER_PASSWORD=Testtest1 \
USER_COUNT=100 DOCS_PER_USER=3 \
MAIN_COMMITS=6 FEATURE_COMMITS=4 BLOCKS_PER_COMMIT=20 \
SEED_VUS=20 \
k6 run perf/delete/seed_dataset.js
```

생성 규칙:

- 문서 제목: `PDEL-<RUN_ID>uNNNdNNN`
- 각 문서에 main 커밋 + feature 브랜치 커밋 생성
- 삭제 시 block 수가 충분히 커지도록 commit 당 block 수 지정

## 3) Delete-only benchmark

```bash
RUN_ID=run01 \
BASE_URL=http://localhost:8080 \
USER_PREFIX=perfdel USER_DOMAIN=test.com USER_PASSWORD=Testtest1 \
USER_COUNT=100 DOCS_PER_USER=3 \
DELETE_VUS=30 \
k6 run perf/delete/delete_only_benchmark.js \
--summary-export perf/delete/results/delete_only_summary.json
```

측정 포인트:

- `op_doc_delete_ms` (핵심)
- `http_req_duration`
- 실패율: `http_req_failed`, `delete_failed`

## 4) Branch comparison workflow

권장: 브랜치별로 아래 순서를 동일하게 반복

1. 브랜치 checkout
2. 서버 실행
3. 유저 준비(`PERF_SEED_USER_COUNT` 적용된 상태)
4. 데이터 생성 (`seed_dataset.js`)
5. 삭제 벤치 실행 (`delete_only_benchmark.js`)
6. 결과 파일 저장

예시 결과 파일명:

- `perf/delete/results/dev_delete_summary.json`
- `perf/delete/results/refactor_delete_summary.json`

비교:

```bash
node perf/delete/compare_delete_summary.mjs \
perf/delete/results/dev_delete_summary.json \
perf/delete/results/refactor_delete_summary.json
```

## 5) Fairness checklist

- 브랜치별 동일한 `USER_COUNT`, `DOCS_PER_USER`, `MAIN_COMMITS`, `FEATURE_COMMITS`, `BLOCKS_PER_COMMIT`
- 동일 DB 스펙, 동일 JVM 옵션
- 워밍업 1회 후 본측정 3회 이상 (평균/편차 사용)
- 브랜치 순서 바꿔서 반복 (`dev -> refactor`, `refactor -> dev`)
65 changes: 65 additions & 0 deletions perf/delete/compare_delete_summary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';

if (process.argv.length < 4) {
console.error('Usage: node perf/delete/compare_delete_summary.mjs <dev-summary.json> <refactor-summary.json>');
process.exit(1);
}

const dev = JSON.parse(fs.readFileSync(path.resolve(process.argv[2]), 'utf-8'));
const ref = JSON.parse(fs.readFileSync(path.resolve(process.argv[3]), 'utf-8'));

function getMetric(summary, name, key) {
const m = summary.metrics?.[name];
if (!m) return null;
if (m.values && typeof m.values === 'object') return m.values[key] ?? null;
if (key === 'rate') return m.value ?? null;
return m[key] ?? null;
}

function pct(base, target) {
if (base === null || target === null || base === 0) return null;
return ((target - base) / base) * 100;
}

function f(v, d = 2) {
if (v === null || Number.isNaN(v)) return '-';
return Number(v).toFixed(d);
}

function row(label, metric, key, digits = 2) {
const b = getMetric(dev, metric, key);
const t = getMetric(ref, metric, key);
const delta = pct(b, t);
const direction = delta === null ? '-' : delta < 0 ? 'improved' : delta > 0 ? 'regressed' : 'same';

return {
label,
base: f(b, digits),
target: f(t, digits),
delta: delta === null ? '-' : `${f(delta, 2)}%`,
direction,
};
}

const rows = [
row('http_req_duration p95 (ms)', 'http_req_duration', 'p(95)'),
row('http_req_duration avg (ms)', 'http_req_duration', 'avg'),
row('doc search p95 (ms)', 'op_doc_search_ms', 'p(95)'),
row('doc delete p50 (ms)', 'op_doc_delete_ms', 'med'),
row('doc delete p95 (ms)', 'op_doc_delete_ms', 'p(95)'),
row('doc delete avg (ms)', 'op_doc_delete_ms', 'avg'),
row('http_req_failed rate', 'http_req_failed', 'rate', 4),
row('delete_failed rate', 'delete_failed', 'rate', 4),
];

console.log('# Delete Benchmark Comparison');
console.log(`- base(dev): ${path.resolve(process.argv[2])}`);
console.log(`- target(refactor): ${path.resolve(process.argv[3])}`);
console.log('');
console.log('| Metric | dev | refactor | Delta | Direction |');
console.log('|---|---:|---:|---:|---|');
for (const r of rows) {
console.log(`| ${r.label} | ${r.base} | ${r.target} | ${r.delta} | ${r.direction} |`);
}
138 changes: 138 additions & 0 deletions perf/delete/delete_only_benchmark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import http from 'k6/http';
import exec from 'k6/execution';
import { check } from 'k6';
import { Rate, Trend } from 'k6/metrics';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
const USER_PREFIX = __ENV.USER_PREFIX || 'perfdel';
const USER_DOMAIN = __ENV.USER_DOMAIN || 'test.com';
const USER_PASSWORD = __ENV.USER_PASSWORD || 'Testtest1';
const USER_COUNT = Number(__ENV.USER_COUNT || 50);
const DOCS_PER_USER = Number(__ENV.DOCS_PER_USER || 3);
const RUN_ID = __ENV.RUN_ID;

if (!RUN_ID) {
throw new Error('RUN_ID is required. Use the same RUN_ID that was used in seed_dataset.js');
}

const TOTAL_DOCS = USER_COUNT * DOCS_PER_USER;

export const options = {
scenarios: {
delete_only: {
executor: 'shared-iterations',
vus: Number(__ENV.DELETE_VUS || 30),
iterations: TOTAL_DOCS,
maxDuration: __ENV.DELETE_MAX_DURATION || '20m',
},
},
thresholds: {
http_req_failed: ['rate<0.02'],
delete_failed: ['rate<0.02'],
op_doc_delete_ms: ['p(95)<3000'],
},
};

const deleteFailed = new Rate('delete_failed');
const tLogin = new Trend('op_login_ms');
const tSearch = new Trend('op_doc_search_ms');
const tDelete = new Trend('op_doc_delete_ms');

function pad3(n) {
return String(n).padStart(3, '0');
}

function userEmail(userNo) {
return `${USER_PREFIX}_u${pad3(userNo)}@${USER_DOMAIN}`;
}

function headers(cookie) {
return {
headers: {
'Content-Type': 'application/json',
Cookie: cookie,
},
};
}

function ensureStatus(res, statuses, op) {
const ok = check(res, {
[`${op} status`]: (r) => statuses.includes(r.status),
});
deleteFailed.add(!ok, { op });
if (!ok) {
const snippet = typeof res.body === 'string' ? res.body.slice(0, 240) : '';
throw new Error(`${op} failed status=${res.status}, body=${snippet}`);
}
}

function parseJson(res, op) {
try {
return res.json();
} catch (_e) {
throw new Error(`${op} invalid JSON`);
}
}

function login(email) {
const res = http.post(
`${BASE_URL}/api/user/login`,
JSON.stringify({ email, password: USER_PASSWORD }),
{ headers: { 'Content-Type': 'application/json' }, tags: { op: 'delete_login' } },
);
tLogin.add(res.timings.duration);
ensureStatus(res, [200], 'delete_login');

const jsession = res.cookies.JSESSIONID && res.cookies.JSESSIONID[0];
if (!jsession || !jsession.value) {
throw new Error('delete_login missing JSESSIONID');
}
return `JSESSIONID=${jsession.value}`;
}

function findDocIdByTitle(cookie, title) {
const url = `${BASE_URL}/api/document/search?keyword=${encodeURIComponent(title)}&page=0&size=10&sort=updatedAt&order=desc`;
const res = http.get(url, { ...headers(cookie), tags: { op: 'delete_doc_search' } });
tSearch.add(res.timings.duration);
ensureStatus(res, [200], 'delete_doc_search');

const body = parseJson(res, 'delete_doc_search');
const content = Array.isArray(body.content) ? body.content : [];
const exact = content.find((d) => d && d.title === title);
if (!exact || !exact.id) {
throw new Error(`target doc not found by title=${title}`);
}
return exact.id;
}

function deleteDoc(cookie, docId) {
const res = http.del(
`${BASE_URL}/api/document/${docId}`,
null,
{ ...headers(cookie), tags: { op: 'delete_doc' } },
);
tDelete.add(res.timings.duration);
ensureStatus(res, [204], 'delete_doc');
}

export default function () {
const it = exec.scenario.iterationInTest;
const userNo = Math.floor(it / DOCS_PER_USER) + 1;
const docNo = (it % DOCS_PER_USER) + 1;

const email = userEmail(userNo);
const cookie = login(email);

const key = `${RUN_ID}u${pad3(userNo)}d${pad3(docNo)}`;
const title = `PDEL-${key}`;

const docId = findDocIdByTitle(cookie, title);
deleteDoc(cookie, docId);
}

export function handleSummary(data) {
return {
stdout: `\n[delete-only] run_id=${RUN_ID}, users=${USER_COUNT}, docs_per_user=${DOCS_PER_USER}, total_docs=${TOTAL_DOCS}\n`,
'perf/delete/results/delete_only_summary.json': JSON.stringify(data, null, 2),
};
}
Empty file added perf/delete/results/.gitkeep
Empty file.
Loading
Loading