Skip to content
Open
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
149 changes: 149 additions & 0 deletions loadtest/follow_change_state_load_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';

const BASE_URL = 'http://localhost:8080';
const TARGET_USER_ID= 1; // 팔로우/언팔 대상 사용자 ID
const USERS_START = 10000; // 토큰 발급 시작 userId
const USERS_COUNT = 500; // 총 사용자 = VU 수
const TOKEN_BATCH = 200; // 토큰 발급 배치 크기
const BATCH_PAUSE_S = 0.2; // 배치 간 대기 (for 토큰 발급 API 병목 방지)
const START_DELAY_S = 5; // 테스트 시작 전 대기 (for 동시 시작)

const followLatency = new Trend('follow_change_latency'); // API 지연(ms) - 네이밍 포맷 유지
const http5xx = new Counter('follow_change_5xx'); // 5xx 개수
const http2xx = new Counter('follow_change_2xx'); // 2xx 개수
const http4xx = new Counter('follow_change_4xx'); // 4xx 개수

const token_issue_failed = new Counter('token_issue_failed');
const fail_USER_ALREADY_FOLLOWED = new Counter('fail_USER_ALREADY_FOLLOWED');
const fail_USER_ALREADY_UNFOLLOWED = new Counter('fail_USER_ALREADY_UNFOLLOWED');
const fail_USER_CANNOT_FOLLOW_SELF = new Counter('fail_USER_CANNOT_FOLLOW_SELF');
const fail_OTHER_4XX = new Counter('fail_OTHER_4XX');

const ERR = {
USER_ALREADY_FOLLOWED: 70001,
USER_ALREADY_UNFOLLOWED: 75001,
USER_CANNOT_FOLLOW_SELF: 75002,
};

function parseError(res) {
try {
const j = JSON.parse(res.body || '{}'); // BaseResponse 구조
return {
code: Number(j.code), // 정수 코드
message: j.message || '',
requestId: j.requestId || '',
isSuccess: !!j.isSuccess
};
} catch (e) {
return { code: NaN, message: '', requestId: '', isSuccess: false };
}
}

// ------------ 시나리오 ------------
// [다수 유저가 동일 타깃(TARGET_USER_ID)에게 '팔로우' 요청을 동시에 보내는 상황]
export const options = {
scenarios: {
// 각 VU가 "정확히 1회" 실행 → 1 VU = 1명 유저
join_once_burst: {
executor: 'per-vu-iterations',
vus: USERS_COUNT,
iterations: 1,
startTime: '0s', // 모든 VU가 거의 동시에 스케줄링
gracefulStop: '5s',
},
},
thresholds: {
follow_change_5xx: ['count==0'], // 서버 오류는 0건이어야 함
follow_change_latency: ['p(95)<1000'], // p95 < 1s
},
};

/** ===== setup: 토큰 일괄 발급 ===== */
export function setup() {
const userIds = Array.from({ length: USERS_COUNT }, (_, i) => USERS_START + i);
const tokens = [];

for (let i = 0; i < userIds.length; i += TOKEN_BATCH) {
const slice = userIds.slice(i, i + TOKEN_BATCH);
const reqs = slice.map((uid) => [
'GET',
`${BASE_URL}/api/test/token/access?userId=${uid}`,
null,
{ tags: { phase: 'setup_token_issue', target: `${TARGET_USER_ID}` } },
]);

const responses = http.batch(reqs);
for (const r of responses) {
if (r.status === 200 && r.body) {
tokens.push(r.body.trim());
}
else {
tokens.push(''); // 실패한 자리도 인덱스 유지
token_issue_failed.add(1);
}
}
sleep(BATCH_PAUSE_S);
}
if (tokens.length > USERS_COUNT) tokens.length = USERS_COUNT;

const startAt = Date.now() + START_DELAY_S * 1000; // 동시 시작 시간
return { tokens, startAt };
}

// VU : 각자 자기 토큰으로 팔로우 호출 & 각자 1회만 실행
export default function (data) {
const idx = __VU - 1; // VU <-> user 매핑(1:1)
const token = data.tokens[idx];

const now = Date.now();
if (now < data.startAt) {
sleep((data.startAt - now) / 1000);
}

if (!token) { // 토큰 발급 실패 -> 스킵
return;
}

const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};

const body = JSON.stringify({ type: true });
const url = `${BASE_URL}/users/following/${TARGET_USER_ID}`;

const res = http.post(url, body, { headers, tags: { phase: 'follow_change', target: `${TARGET_USER_ID}` } });

followLatency.add(res.timings.duration);
if (res.status >= 200 && res.status < 300) {
http2xx.add(1);
}
else if (res.status >= 400 && res.status < 500) {
http4xx.add(1);
const err = parseError(res);
switch (err.code) {
case ERR.USER_ALREADY_FOLLOWED:
fail_USER_ALREADY_FOLLOWED.add(1);
break;
case ERR.USER_ALREADY_UNFOLLOWED:
fail_USER_ALREADY_UNFOLLOWED.add(1);
break;
case ERR.USER_CANNOT_FOLLOW_SELF:
fail_USER_CANNOT_FOLLOW_SELF.add(1);
break;
default:
fail_OTHER_4XX.add(1);
}
}
else if (res.status >= 500) {
http5xx.add(1);
}

check(res, {
'follow_change responded': (r) => r.status !== 0,
// 이미 팔로우 상태에서 재요청 등 합리적 4xx 허용
'follow_change 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500),
});
}
162 changes: 162 additions & 0 deletions loadtest/follow_change_toggle_load_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';

const BASE_URL = 'http://localhost:8080';
const TARGET_USER_ID = 1; // 팔로우/언팔 대상 사용자
const USERS_START = 10000; // 토큰 발급 시작 userId
const USERS_COUNT = 500; // 동시 사용자 수 = VU 수
const TOKEN_BATCH = 200; // 토큰 발급 배치 크기
const BATCH_PAUSE_S = 0.2; // 배치 간 휴지 (발급 API 보호)
const START_DELAY_S = 5; // 전체 동기 시작 대기
const TOGGLE_ITER = 20; // 각 VU가 시도할 토글 횟수(팔로우/언팔 번갈아 최대 20회)
const TOGGLE_PAUSE_S = 0.1; // 한 번 호출 후 다음 토글까지 간격

const followLatency = new Trend('follow_change_latency');
const http5xx = new Counter('follow_change_5xx');
const http2xx = new Counter('follow_change_2xx');
const http4xx = new Counter('follow_change_4xx');

const token_issue_failed = new Counter('token_issue_failed');
const fail_USER_ALREADY_FOLLOWED = new Counter('fail_USER_ALREADY_FOLLOWED');
const fail_USER_ALREADY_UNFOLLOWED = new Counter('fail_USER_ALREADY_UNFOLLOWED');
const fail_USER_CANNOT_FOLLOW_SELF = new Counter('fail_USER_CANNOT_FOLLOW_SELF');
const fail_OTHER_4XX = new Counter('fail_OTHER_4XX');

const success_follow_200 = new Counter('success_follow_200');
const success_unfollow_200 = new Counter('success_unfollow_200');

const ERR = {
USER_ALREADY_FOLLOWED: 70001,
USER_ALREADY_UNFOLLOWED: 75001,
USER_CANNOT_FOLLOW_SELF: 75002,
};

function parseError(res) {
try {
const j = JSON.parse(res.body || '{}');
return {
code: Number(j.code),
message: j.message || '',
requestId: j.requestId || '',
isSuccess: !!j.isSuccess,
};
} catch (_) {
return { code: NaN, message: '', requestId: '', isSuccess: false };
}
}

/** ===== 시나리오 ===== */
export const options = {
scenarios: {
follow_toggle_spam: {
executor: 'per-vu-iterations',
vus: USERS_COUNT,
iterations: TOGGLE_ITER, // 각 VU가 TOGGLE_ITER번 default() 실행
startTime: '0s',
gracefulStop: '5s',
},
},
thresholds: {
follow_change_5xx: ['count==0'],
follow_change_latency: ['p(95)<1000'],
},
};

/** ===== setup: 토큰 일괄 발급 ===== */
export function setup() {
const userIds = Array.from({ length: USERS_COUNT }, (_, i) => USERS_START + i);
const tokens = [];

for (let i = 0; i < userIds.length; i += TOKEN_BATCH) {
const slice = userIds.slice(i, i + TOKEN_BATCH);
const reqs = slice.map((uid) => [
'GET',
`${BASE_URL}/api/test/token/access?userId=${uid}`,
null,
{ tags: { phase: 'setup_token_issue', target: `${TARGET_USER_ID}` } },
]);
const responses = http.batch(reqs);
for (const r of responses) {
if (r.status === 200 && r.body) tokens.push(r.body.trim());
else {
tokens.push('');
token_issue_failed.add(1);
}
}
sleep(BATCH_PAUSE_S);
}
if (tokens.length > USERS_COUNT) tokens.length = USERS_COUNT;

const startAt = Date.now() + START_DELAY_S * 1000;
return { tokens, startAt };
}

/** ===== 각 VU의 토글 상태 =====
* false = 아직 팔로우 안 한 상태로 가정 → 다음 요청은 follow(type:true)
* true = 이미 팔로우 한 상태로 가정 → 다음 요청은 unfollow(type:false)
*/
let isFollowing = false;

/** ===== 실행 루프 ===== */
export default function (data) {
const idx = __VU - 1;
const token = data.tokens[idx];

// 동기 시작
const now = Date.now();
if (now < data.startAt) {
sleep((data.startAt - now) / 1000);
}

if (!token) return;

const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};

// 현재 상태에 따라 요청 결정
// 규칙: "이전 요청이 200일 때만" 상태를 뒤집는다 → 4xx/5xx면 상태 유지(같은 동작 재시도)
const wantFollow = !isFollowing; // 현재 false면 follow, true면 unfollow
const body = JSON.stringify({ type: wantFollow });
const url = `${BASE_URL}/users/following/${TARGET_USER_ID}`;

const res = http.post(url, body, { headers, tags: { phase: 'follow_change', target: `${TARGET_USER_ID}`, want: wantFollow ? 'follow' : 'unfollow' } });

followLatency.add(res.timings.duration);
if (res.status >= 200 && res.status < 300) {
http2xx.add(1);
// 200일 때만 상태 반전
isFollowing = !isFollowing;
if (wantFollow) success_follow_200.add(1);
else success_unfollow_200.add(1);
} else if (res.status >= 400 && res.status < 500) {
http4xx.add(1);
const err = parseError(res);
switch (err.code) {
case ERR.USER_ALREADY_FOLLOWED:
fail_USER_ALREADY_FOLLOWED.add(1);
break;
case ERR.USER_ALREADY_UNFOLLOWED:
fail_USER_ALREADY_UNFOLLOWED.add(1);
break;
case ERR.USER_CANNOT_FOLLOW_SELF:
fail_USER_CANNOT_FOLLOW_SELF.add(1);
break;
default:
fail_OTHER_4XX.add(1);
}
} else if (res.status >= 500) {
http5xx.add(1);
}

check(res, {
'follow_change responded': (r) => r.status !== 0,
'follow_change 200 or expected 4xx': (r) =>
r.status === 200 || (r.status >= 400 && r.status < 500),
});

// 토글 간격
sleep(TOGGLE_PAUSE_S);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@
import lombok.*;

@Entity
@Table(name = "followings")
@Table(
Copy link
Member

Choose a reason for hiding this comment

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

확인했습니닷 !! 👍🏻

name = "followings",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_followings_user_target",
columnNames = {"user_id", "following_user_id"}
)
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE followings
Copy link
Member

Choose a reason for hiding this comment

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

확인했습니닷 !! 별거아니지만 COMMENT나 주석처리 가능할까요??
다른 마이그레이션 파일들도 변경사항이 COMMENT나 주석으로 추가설명이 기재되어있고 추후에 마이그레이션 파일들을보며 어떤사항때문에 db가 변경되었다는걸 알기가 편할것같습니다!!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

네넵 다음 브랜치에서 주석 추가하도록 하겠습니다!
근데 저희 지금까지 마이그레이션 파일 대부분 주석처리 안해뒀던 것 같네요 😂

ADD CONSTRAINT uq_followings_user_target
UNIQUE (user_id, following_user_id);
Loading