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
48 changes: 48 additions & 0 deletions infra/loadtest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 부하·성능 테스트 (k6)

2026-10 부하테스트에서 쓴 시나리오. 결과·개선 내역은 노션 「부하테스트 및 성능개선」.

## 준비 (stg 만)

| 무엇 | 어떻게 |
|---|---|
| 로그인 우회 | 태스크 정의 env `OAUTH_LOAD_TEST_ENABLED=true` — IdP 호출만 빼고 실제 로그인 트랜잭션을 탄다(`LoadTestOAuthGate`, prod 프로파일엔 빈이 없다). `code=loadtest-<id>`, `codeVerifier=<DEV_TOKEN_SECRET>` |
| AI 초안 한도 | `APP_CHALLENGE_DRAFT_RATE_LIMIT_PER_MINUTE` (기본 10) |
| prod 와 같은 조건 | 태스크 0.5 vCPU/2GB·2대(오토스케일 2~4, CPU 60%·메모리 75%), `JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=60`, Hikari 15, RDS Multi-AZ, **DB 접속 `sslMode=REQUIRED`**(stg 기본 주소는 `useSSL=false` 라 그대로 재면 prod 보다 좋게 나온다) |
| Cloudflare 건너뛰기 | `-e ALB_IP=<stg ALB IP>` — TLS SNI·Host 는 도메인 그대로. 서버만 잰다 |

끝나면 반드시 되돌린다 — 우회 스위치 끄기, 스펙·Multi-AZ·오토스케일 원복.

## 실행

```sh
export LT_SECRET=... # stg 시크릿 ruleup-stg/app 의 DEV_TOKEN_SECRET. 저장소에 두지 않는다
k6 run -e LT_SECRET=$LT_SECRET -e ALB_IP=3.37.221.3 -e RUN_ID=lt2 -e USERS=3000 -e SCENARIO=seed -e VUS=25 infra/loadtest/k6/ruleup.js
k6 run ... -e SCENARIO=seedjoin ... # 가입·방 생성 뒤 남의 방 2곳 가입(사용자당 참여 방 3개 안팎)
k6 run ... -e SCENARIO=peak -e SCALE=1 -e DURATION=5m infra/loadtest/k6/ruleup.js
```

| SCENARIO | 내용 |
|---|---|
| `smoke.js` | 주요 API 를 한 번씩 — 계약 오류부터 거른다 |
| `seed` / `seedjoin` | 가짜 사용자 가입·공개 방 생성 / 남의 방 가입 |
| `login` | 아침 몰림 — 로그인 + 홈 + 리프레시, 초당 `RATE` |
| `browse` | 앱 열기·탐색(같은 정렬로 2페이지)·상세·내 방·마이페이지, 동시 `VUS` |
| `create` | 템플릿 초안 → 생성, 초당 `RATE` |
| `aidraft` | AI 초안(실제 LLM), 동시 `VUS` |
| `sync` | 건강정보 동의 → 인증 신호 업로드(backlog=true, 사용자당 10초 간격 — 429 는 정상 거절) |
| `peak` | 위를 실사용 비율로 섞음: 평상시(5분) → 피크(동시 400·로그인 15/s·sync 20/s·생성 3/s·가입 4/s, `DURATION`) → 마무리. `SCALE=2` 면 2배 |
| `breakpoint` | 동시 사용자를 200→2000 까지 2분씩 올리며 오류 5% 에서 멈춘다 |

임계값은 노션 SLO(로그인 3초·리프레시 1초·탐색/상세 1초·가입 1.5초·sync 3초·오늘/진행률 1초, p95)와 5xx 0건.

## 함정

- 시작일이 「내일」인 방을 대량으로 만들면 **자정에 한꺼번에 ACTIVE** 가 되어 그 뒤 측정의 데이터 모양이 바뀐다. 비교는 같은 데이터 상태에서 연달아 돈다.
- 같은 사용자 묶음으로 피크를 여러 번 돌리면 사용자당 참여 방이 계속 늘어난다(생성·가입). 비교마다 새 `RUN_ID` 로 시드하거나 순서를 붙여 돈다.
- Hikari `connection-timeout` 은 env 로 넣으면 「pool is sealed」로 기동 실패한다(풀이 먼저 시작됨).
- 비교 중에는 오토스케일 최대를 2로 묶어 2대에서 출발시키고, 시험 직전에 4로 푼다(앞 시험이 늘린 태스크가 남으면 출발점이 달라진다).
- 측정 중 로컬에서 gradle·docker 빌드를 돌리지 않는다(부하 발생기와 CPU 를 나눠 쓴다).
- 액세스 토큰은 30분이다. VU 가 오래 쥐는 세션은 `mine()` 이 25분마다 갱신한다 — 안 그러면 30분 넘는 시험 끝물이 전부 401 이다.
- 부하를 한 번 크게 받은 태스크는 힙을 다 커밋한 채 메모리 90% 에 머문다(누수 아님 — 장시간 시험에서 평평). 메모리 75% 오토스케일 정책은 그 뒤 CPU 가 한가해도 태스크를 늘린다. 태스크 수 비교는 새로 뜬 태스크에서 한다.
- 피크에서 X-Ray 전송이 계정 한도(429)에 걸리고 에이전트 스팬 큐가 넘친다 — `ruleup-api` 샘플링 규칙이 100% 면 그렇다.
164 changes: 164 additions & 0 deletions infra/loadtest/k6/lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// RuleUp 부하테스트 공통 — 계정·요청 헬퍼. 비밀값은 환경변수로만 받는다(저장소에 두지 않는다).
// BASE_URL 기본 https://staging-api.ruleup.co.kr
// ALB_IP 있으면 BASE_URL 호스트를 이 IP 로 직접 보낸다(Cloudflare 를 건너뛰고 서버만 잰다. TLS SNI 는 호스트 그대로)
// LT_SECRET stg DEV_TOKEN_SECRET — 로그인 우회(OAUTH_LOAD_TEST_ENABLED)·dev 토큰에 쓴다
// RUN_ID 계정 식별자 접두어(같은 RUN_ID 면 같은 가짜 사용자를 다시 쓴다)
import http from 'k6/http';
import { check, fail } from 'k6';
import { Trend, Counter } from 'k6/metrics';

export const BASE = __ENV.BASE_URL || 'https://staging-api.ruleup.co.kr';
export const SECRET = __ENV.LT_SECRET || '';
export const RUN_ID = __ENV.RUN_ID || 'lt1';
const HOST = BASE.replace(/^https?:\/\//, '').replace(/\/.*$/, '');

export function hosts() {
return __ENV.ALB_IP ? { [HOST]: __ENV.ALB_IP } : {};
}

export const apiErrors = new Counter('ruleup_api_errors');
export const businessRejects = new Counter('ruleup_business_rejects');

const JSON_HEADERS = { 'Content-Type': 'application/json', 'User-Agent': 'ruleup-loadtest/1.0 (k6)' };

export function auth(token, extra) {
return { headers: Object.assign({}, JSON_HEADERS, { Authorization: `Bearer ${token}` }, extra || {}) };
}

/** 응답 확인 — 2xx 가 아니면 오류로 세고, 코드·상태를 태그로 남긴다(로그 대조용). */
export function ok(res, name, allowed) {
const okStatus = (allowed || [200, 201]).includes(res.status);
let code = '';
if (!okStatus) {
try { code = (res.json('error.code') || res.json('code') || '') + ''; } catch (e) { code = 'non_json'; }
if (res.status >= 500 || res.status === 0) apiErrors.add(1, { api: name, status: `${res.status}`, code });
else businessRejects.add(1, { api: name, status: `${res.status}`, code });
}
check(res, { [`${name} ${(allowed || [200, 201]).join('/')}`]: () => okStatus });
return okStatus;
}

export function deviceInfo() {
return { platform: 'ANDROID', osVersion: '14', sdkInt: 34, deviceModel: 'SM-S921N', manufacturer: 'samsung',
lowRam: false, versionName: '1.0.0', versionCode: 100, country: 'KR', timeZone: 'Asia/Seoul', ramMb: 8192 };
}

function ids(n) {
return { code: `loadtest-${RUN_ID}-${n}`, installationId: `lt-inst-${RUN_ID}-${n}`, deviceId: `lt-dev-${RUN_ID}-${n}` };
}

/** 실제 로그인 경로(카카오) — 우회 스위치가 IdP 호출만 뺀다. */
export function oauthLogin(n) {
const i = ids(n);
const res = http.post(`${BASE}/api/v1/auth/oauth/kakao`, JSON.stringify({
code: i.code, codeVerifier: SECRET, redirectUri: null, installationId: i.installationId, deviceId: i.deviceId,
deviceInfo: deviceInfo(), permissions: { postNotifications: 'GRANTED', location: 'GRANTED', camera: 'GRANTED', screenTime: 'GRANTED' },
}), { headers: JSON_HEADERS, tags: { api: 'auth.oauth_kakao' } });
ok(res, 'auth.oauth_kakao');
return res;
}

export function signup(n, signupToken) {
const i = ids(n);
const agreed = { agreed: true };
const res = http.post(`${BASE}/api/v1/auth/signup`, JSON.stringify({
signupToken, installationId: i.installationId, deviceId: i.deviceId, deviceInfo: deviceInfo(),
nickname: `lt${RUN_ID.slice(-3)}${n}`.slice(0, 12), interestCategories: ['EXERCISE', 'STUDY'], birthDate: '1998-03-21', gender: 'MALE',
agreements: { termsOfService: agreed, privacyPolicy: agreed, locationService: agreed, marketing: agreed, event: agreed },
}), { headers: JSON_HEADERS, tags: { api: 'auth.signup' } });
ok(res, 'auth.signup');
return res;
}

/** 로그인(없으면 가입 후 다시 로그인)해서 {access, refresh} 를 돌려준다. */
export function session(n) {
let res = oauthLogin(n);
if (res.status !== 200) fail(`login ${n} ${res.status} ${res.body && res.body.slice(0, 200)}`);
if (res.json('data.isNewUser')) {
const s = signup(n, res.json('data.signupToken'));
if (s.status !== 200) fail(`signup ${n} ${s.status} ${s.body && s.body.slice(0, 200)}`);
res = oauthLogin(n);
}
return { access: res.json('data.accessToken'), refresh: res.json('data.refreshToken'), userId: res.json('data.user.id'), at: Date.now() };
}

export function refresh(s) {
const res = http.post(`${BASE}/api/v1/auth/refresh`, JSON.stringify({ refreshToken: s.refresh }),
{ headers: JSON_HEADERS, tags: { api: 'auth.refresh' } });
if (ok(res, 'auth.refresh')) {
s.access = res.json('data.accessToken');
s.refresh = res.json('data.refreshToken');
s.at = Date.now();
}
return res;
}

/** 앱 첫 화면 — 플랫폼·버전은 헤더로 보낸다(쿼리로 보내면 400). 로그인 전에도 부른다. */
export function intro() {
const res = http.get(`${BASE}/api/v1/intro`, { headers: Object.assign({}, JSON_HEADERS, { platform: 'ANDROID', appVersionCode: '100' }),
tags: { api: 'intro' } });
ok(res, 'intro');
return res;
}

// 허용한 4xx(예: 409 이미 가입)는 k6 실패율에 넣지 않는다 — 진짜 실패는 ruleup_api_errors(5xx·연결 실패)로 본다.
function expect(allowed) {
return { responseCallback: http.expectedStatuses(...(allowed || [200, 201])) };
}

export function get(s, path, name, allowed) {
const res = http.get(`${BASE}${path}`, Object.assign(auth(s.access), { tags: { api: name } }, expect(allowed)));
ok(res, name, allowed);
return res;
}

export function post(s, path, body, name, extraHeaders, allowed) {
const res = http.post(`${BASE}${path}`, body == null ? null : JSON.stringify(body),
Object.assign(auth(s.access, extraHeaders), { tags: { api: name } }, expect(allowed)));
ok(res, name, allowed);
return res;
}

export function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}

/** 템플릿 초안 → 생성. 초안을 고치지 않고 그대로 보내면 모더레이션 대상이 아니다(실사용의 대부분). */
export function createFromTemplate(s, templateId, overrides) {
const d = post(s, '/api/v1/challenges/recommendation/by-template', { templateId }, 'challenge.draft_template');
if (d.status !== 200) return null;
const draft = d.json('data.draft');
const body = Object.assign({
draftId: d.json('data.draftId'), title: draft.title, description: draft.description, category: draft.category,
mode: draft.mode, visibility: draft.visibility, rankingVisible: draft.rankingVisible, capacity: draft.capacity,
minTier: draft.minTier, period: { start: draft.period.start, end: draft.period.end }, weeklyCount: draft.weeklyCount,
params: (draft.params || []).map((p) => ({ key: p.key, value: p.value != null ? `${p.value}` : p.defaultValue != null ? `${p.defaultValue}` : null })),
verification: { type: draft.verification.type, method: draft.verification.method },
penalties: { watcher: false }, imageUrl: null,
}, overrides || {});
const c = post(s, '/api/v1/challenges', body, 'challenge.create', { 'Idempotency-Key': uuid() });
return c.status === 200 || c.status === 201 ? c.json('data.challengeId') || c.json('data.id') : null;
}

/** 탐색·가입 부하에 쓰는 공개 그룹 방 — 제목·설명은 초안 그대로라 모더레이션 대상이 아니다. */
export const GROUP_PUBLIC = { mode: 'GROUP', visibility: 'PUBLIC', capacity: 100 }; // 정원은 5·30·100·300·무제한만

export const SLO_MS = { // 노션 SLO 지연 목표(p95) — alarm-catalog.yaml operations 와 같다
'auth.oauth_kakao': 3000, 'auth.refresh': 1000, 'challenge.explore': 1000, 'challenge.detail': 1000,
'challenge.join': 1500, 'verification.sync': 3000, 'verification.today': 1000, 'verification.progress': 1000,
};

// 요약에 API 별 지연이 나오려면 태그별 임계값이 있어야 한다(k6 는 임계값이 걸린 태그만 따로 집계한다).
const ALL_APIS = ['intro', 'me.home', 'users.me', 'challenge.list', 'notification.list', 'notification.settings', 'challenge.categories',
'challenge.trending', 'challenge.recommendations', 'challenge.draft_template', 'challenge.create', 'challenge.draft_ai', 'room.home',
'room.ranking', 'challenge.members', 'verification.setup', 'verification.intro', 'profile', 'me.stats', 'me.tier', 'me.calendar', 'auth.signup'];

export function thresholds(extra) {
const t = { http_req_failed: ['rate<0.005'], ruleup_api_errors: ['count<1'] };
for (const [api, ms] of Object.entries(SLO_MS)) t[`http_req_duration{api:${api}}`] = [`p(95)<${ms}`];
for (const api of ALL_APIS) if (!t[`http_req_duration{api:${api}}`]) t[`http_req_duration{api:${api}}`] = ['p(95)<60000'];
return Object.assign(t, extra || {});
}
Loading
Loading