Skip to content

Commit

Permalink
Merge pull request #68 from f-lab-edu/feature/55-load-test
Browse files Browse the repository at this point in the history
[#55] 부하 테스트
  • Loading branch information
yanggwangseong authored Jan 7, 2025
2 parents d6abc30 + 5673f17 commit ee98e3a
Show file tree
Hide file tree
Showing 15 changed files with 397 additions and 83 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: auto deploy
on:
push:
branches:
- feature/54-fake-dataset-seeding
- feature/55-load-test

jobs:
push_to_registry:
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,7 @@ uploads/
# docker
Dockerfile.dev
docker-compose-dev.yml
docker-compose-dev.yml
docker-compose.dev-bak.yml
grafana
prometheus

4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ docker-compose-dev.yml
docker-compose.yml
Dockerfile
mysql-data

grafana/**
prometheus/prometheus.yml
docker-compose.dev-bak.yml
66 changes: 66 additions & 0 deletions k6/scripts/articles-performance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { check, sleep } from "k6";
import http from "k6/http";
import { Trend } from "k6/metrics";

const dataReceivedTrend = new Trend("data_received_size", true);

export const options = {
scenarios: {
simple_rps_test: {
/* 일정한 RPS(Request Per Second)를 유지하는 실행기 타입 */
executor: "constant-arrival-rate",
/* 초당 실행할 반복 횟수 */
rate: 4,
/* rate의 시간 단위 (1s, 1m, 1h 등) */
timeUnit: "1s",
/* 전체 테스트 실행 시간 */
duration: "1m",
/* 테스트 시작 시 미리 할당할 가상 사용자 수 */
preAllocatedVUs: 10,
/* 최대 가상 사용자 수 (필요시 추가 할당) */
maxVUs: 30,
},
},
// 태그 추가
tags: {
testName: "articles-api-test",
testType: "performance",
component: "articles",
version: "1.0",
},
thresholds: {
/* HTTP 요청 실패율이 5% 미만이어야 함 */
http_req_failed: [{ threshold: "rate<0.05", abortOnFail: true }],
/* 부하로 인한 요청 누락률이 5% 미만이어야 함 */
dropped_iterations: [{ threshold: "rate<0.05", abortOnFail: true }],
// /* 95%의 요청이 3초 이내에 완료되어야 함 */
http_req_duration: [{ threshold: "p(95)<3000", abortOnFail: true }],
},
};

export default function () {
const BASE_URL = __ENV.BASE_URL || "http://localhost:4000";
const ACCESS_TOKEN = __ENV.ACCESS_TOKEN || "access_token";

const cursors = [12001, 23000, 30000, 40000, 50000];
const cursor = cursors[Math.floor(Math.random() * cursors.length)];
const limit = 10;

const articlesResponse = http.get(
`${BASE_URL}/articles?cursor=${cursor}&limit=${limit}`,
{
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
timeout: "180s",
},
);

dataReceivedTrend.add(articlesResponse.body.length);

check(articlesResponse, {
"articles status is 200": (r) => r.status === 200,
});

sleep(1);
}
59 changes: 59 additions & 0 deletions k6/scripts/participations-performance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { check, sleep } from "k6";
import http from "k6/http";
import { Trend } from "k6/metrics";

const dataReceivedTrend = new Trend("data_received_size", true);

export const options = {
scenarios: {
simple_rps_test: {
executor: "constant-arrival-rate",
rate: 450, // 초당 10개의 요청 (RPS)
timeUnit: "1s", // RPS 단위 설정
duration: "1m", // 테스트 지속 시간: 5분
preAllocatedVUs: 1000, // 미리 할당할 VU 수
maxVUs: 2000, // 최대 VU 수
},
},
// 태그 추가
tags: {
testName: "participations-api-test",
testType: "performance",
component: "participations",
version: "1.0",
},
thresholds: {
http_req_failed: [{ threshold: "rate<0.05", abortOnFail: true }],
dropped_iterations: [{ threshold: "rate<0.05", abortOnFail: true }],
http_req_duration: [{ threshold: "p(95)<3000", abortOnFail: true }],
},
};

export default function () {
const BASE_URL = __ENV.BASE_URL || "http://localhost:4000";
const ACCESS_TOKEN = __ENV.ACCESS_TOKEN || "access_token";

const cursors = [12001, 23000, 30000, 40000, 50000];
const cursor = cursors[Math.floor(Math.random() * cursors.length)];
const limit = 10;

const articleIds = [23640, 12714, 11621, 43514];

const participationsResponse = http.get(
`${BASE_URL}/participations/articles/${articleIds[Math.floor(Math.random() * articleIds.length)]}?cursor=${cursor}&limit=${limit}`,
{
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
timeout: "60s",
},
);

dataReceivedTrend.add(participationsResponse.body.length);

check(participationsResponse, {
"participations status is 200": (r) => r.status === 200,
});

sleep(1);
}
52 changes: 52 additions & 0 deletions k6/scripts/signup-performance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { check, sleep } from "k6";
import http from "k6/http";
import { Trend } from "k6/metrics";

const dataReceivedTrend = new Trend("data_received_size", true);

export const options = {
scenarios: {
simple_rps_test: {
executor: "constant-arrival-rate",
rate: 1,
timeUnit: "1s",
duration: "1m",
preAllocatedVUs: 5,
maxVUs: 10,
},
},
thresholds: {
http_req_failed: [{ threshold: "rate<0.05", abortOnFail: true }],
dropped_iterations: [{ threshold: "rate<0.05", abortOnFail: true }],
http_req_duration: [{ threshold: "p(95)<3000", abortOnFail: true }],
},
};

export default function () {
const BASE_URL = __ENV.BASE_URL || "http://localhost:4000";

const timestamp = new Date().getTime();
const randomValue = Math.random().toString(36).substring(2, 15);
const uniqueId = `${timestamp}-${randomValue}`;

const signupResponse = http.post(`${BASE_URL}/auth/sign-up`, {
email: `user-${uniqueId}@test.com`,
password: "123456",
name: `user-${uniqueId}`.substring(0, 6),
nickname: `nickname-${uniqueId}`.substring(0, 6),
});

dataReceivedTrend.add(signupResponse.body.length);

const success = check(signupResponse, {
"signup status is 201": (r) => r.status === 201,
});

if (!success) {
console.error(
`Request failed. Status: ${signupResponse.status}, Body: ${signupResponse.body}`,
);
}

sleep(1);
}
15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,20 @@
"prepare": "husky",
"--------------------------------------------": "",
"seed": "cross-env NODE_ENV=production ts-node -r tsconfig-paths/register ./node_modules/typeorm-extension/bin/cli.cjs -d ./data-source.ts seed:run --name",
"seed:dev": "cross-env NODE_ENV=development ts-node -r tsconfig-paths/register ./node_modules/typeorm-extension/bin/cli.cjs -d ./data-source.ts seed:run --name"
"seed:dev": "cross-env NODE_ENV=development ts-node -r tsconfig-paths/register ./node_modules/typeorm-extension/bin/cli.cjs -d ./data-source.ts seed:run --name",
"-------------------------------------------": "",
"k6:run:articles": "docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/articles-performance.js",
"k6:run:participations": "docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/participations-performance.js",
"k6:run:signup": "docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/signup-performance.js",
"clinic:doctor-participations": "cross-env NODE_ENV=development clinic doctor --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/participations-performance.js' -- node dist/main.js",
"clinic:doctor-articles": "cross-env NODE_ENV=development clinic doctor --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/articles-performance.js' -- node dist/main.js",
"clinic:doctor-signup": "cross-env NODE_ENV=development clinic doctor --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/signup-performance.js' -- node dist/main.js",
"clinic:flame-participations": "cross-env NODE_ENV=development clinic flame --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/participations-performance.js' -- node dist/main.js",
"clinic:flame-articles": "cross-env NODE_ENV=development clinic flame --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/articles-performance.js' -- node dist/main.js",
"clinic:flame-signup": "cross-env NODE_ENV=development clinic flame --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/signup-performance.js' -- node dist/main.js",
"clinic:bubbleprof-participations": "cross-env NODE_ENV=development clinic bubbleprof --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/participations-performance.js' -- node dist/main.js",
"clinic:bubbleprof-articles": "cross-env NODE_ENV=development clinic bubbleprof --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/articles-performance.js' -- node dist/main.js",
"clinic:bubbleprof-signup": "cross-env NODE_ENV=development clinic bubbleprof --on-port 'docker-compose -f docker-compose-dev.yml run --rm k6 run /scripts/signup-performance.js' -- node dist/main.js"
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
Expand Down
11 changes: 11 additions & 0 deletions src/common/typeorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ export const TypeOrmModuleOptions = {
password: configService.get(ENV_DB_PASSWORD) || "test",
entities: [path.resolve(process.cwd(), "dist/**/*.entity.{js,ts}")],
synchronize: configService.get<boolean>(ENV_DB_SYNCHRONIZE) || true,
extra: {
connectionLimit: 30,
queueLimit: 0,
waitForConnections: true,
},
// 커넥션 풀 사이즈 설정
poolSize: 30,
connectTimeout: 120000,
...(configService.get("NODE_ENV") === "development"
? { retryAttempts: 10, logging: false }
: { logging: false }),
};

return option;
Expand Down
4 changes: 2 additions & 2 deletions src/controllers/articles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ export class ArticlesController {
getArticles(
@Query("cursor", new ParseIntPipe()) cursor: number,
@Query("limit", new ParseIntPipe()) limit: number,
@CurrentMemberDecorator("id") currentMemberId: number,
@CurrentMemberDecorator("id") _currentMemberId: number,
) {
return this.articlesService.findAll(cursor, limit, currentMemberId);
return this.articlesService.findAll(cursor, limit, _currentMemberId);
}

@Get(":articleId")
Expand Down
18 changes: 12 additions & 6 deletions src/controllers/participations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@ export class ParticipationsController {
@Query("cursor", new ParseIntPipe()) cursor: number,
@Query("limit", new ParseIntPipe()) limit: number,
) {
await this.articlesService.findById(articleId);
const [article, participation] = await Promise.all([
this.articlesService.findById(articleId),
this.participationsService.getParticipationsByArticleId(
articleId,
cursor,
limit,
),
]);

return this.participationsService.getParticipationsByArticleId(
articleId,
cursor,
limit,
);
return {
...participation,
article,
};
}

@Get()
Expand Down
86 changes: 86 additions & 0 deletions src/repositories/articles.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,90 @@ export class ArticlesRepository extends Repository<ArticleEntity> {
? qr.manager.getRepository<ArticleEntity>(ArticleEntity)
: this.repository;
}

findAllV1(currentMemberId: number, cursor: number, limit: number = 10) {
// 해당 아티클의 좋아요 총갯수
// 모각밥 참여자 갯수
// 내가 해당 아티클 좋아요 여부
// 지역, 음식 카테고리, 멤버 테이블(글쓴사람)과 조인하여 해당 칼럼들 가져오기
const query = this.repository
.createQueryBuilder("article")
.select([
'article.id AS "articleId"',
'article.title AS "title"',
'article.content AS "content"',
'article.startTime AS "startTime"',
'article.endTime AS "endTime"',
'article.articleImage AS "articleImage"',
'article.createdAt AS "createdAt"',
'article.updatedAt AS "updatedAt"',
'member.id AS "memberId"',
'member.name AS "memberName"',
'member.nickname AS "memberNickname"',
'member.profileImage AS "memberProfileImage"',
'category.id AS "categoryId"',
'category.name AS "categoryName"',
'region.id AS "regionId"',
'region.name AS "regionName"',
'district.id AS "districtId"',
'district.name AS "districtName"',
])
.addSelect((qb) => {
return qb
.select("COUNT(*)")
.from("article_likes", "al")
.where("al.articleId = article.id");
}, "likeCount")
.addSelect((qb) => {
return qb
.select("COUNT(*)")
.from("participation", "p")
.where("p.articleId = article.id")
.andWhere("p.status = :status", { status: "ACTIVE" });
}, "participantCount")
.addSelect((qb) => {
return qb
.select("CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END")
.from("article_likes", "al")
.where("al.articleId = article.id")
.andWhere("al.memberId = :currentMemberId", {
currentMemberId,
});
}, "isLiked")
.innerJoin("article.member", "member")
.innerJoin("article.category", "category")
.innerJoin("article.region", "region")
.innerJoin("article.district", "district")
.where(cursor ? "article.id < :cursor" : "1=1", { cursor })
.orderBy("article.id", "DESC")
.limit(limit + 1);

return query.getRawMany();
}

findAllV2(cursor: number, limit: number = 10) {
// 해당 아티클의 좋아요 총갯수
// 모각밥 참여자 갯수
// 내가 해당 아티클 좋아요 여부
// 지역, 음식 카테고리, 멤버 테이블(글쓴사람)과 조인하여 해당 칼럼들 가져오기
const query = this.repository
.createQueryBuilder("article")
.select([
'article.id AS "articleId"',
'article.title AS "title"',
'article.content AS "content"',
'article.startTime AS "startTime"',
'article.endTime AS "endTime"',
'article.articleImage AS "articleImage"',
'article.createdAt AS "createdAt"',
'article.updatedAt AS "updatedAt"',
'COUNT(like.articleId) AS "likeCount"',
])
.leftJoin("article.articleLikes", "like")
.where(cursor ? "article.id < :cursor" : "1=1", { cursor })
.orderBy("article.id", "DESC")
.limit(limit + 1);

return query.getRawMany();
}
}
Loading

0 comments on commit ee98e3a

Please sign in to comment.