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
86 changes: 86 additions & 0 deletions scripts/bench-s3-upload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// S3 업로드 순차 vs 병렬(Promise.all) 소요 시간 측정
// 사용법: node scripts/bench-s3-upload.js [파일 개수=5] [파일 크기 MB=10] [반복 횟수=5]
// 실제 서비스 코드(uploadToS3)로 업로드하고, 측정이 끝나면 올린 파일은 모두 삭제한다.
import { DeleteObjectsCommand } from '@aws-sdk/client-s3';
import s3 from '../src/config/s3.config.js';
import { uploadToS3 } from '../src/middlewares/upload.middleware.js';

const FILE_COUNT = Number(process.argv[2] ?? 5);
const FILE_SIZE_MB = Number(process.argv[3] ?? 10);
const ROUNDS = Number(process.argv[4] ?? 5);

// multer memoryStorage가 넘겨주는 file 객체와 같은 형태
const makeFiles = (round, mode) =>
Array.from({ length: FILE_COUNT }, (_, i) => ({
originalname: `bench-${mode}-r${round}-${i}.bin`,
mimetype: 'application/octet-stream',
buffer: Buffer.alloc(FILE_SIZE_MB * 1024 * 1024, i),
}));

const uploadedKeys = [];
const keyFromUrl = (url) => new URL(url).pathname.slice(1);

const uploadSequential = async (files) => {
for (const file of files) {
uploadedKeys.push(keyFromUrl(await uploadToS3(file)));
}
};

// task.controller.js 의 modifyTask 와 같은 방식
const uploadParallel = async (files) => {
const urls = await Promise.all(files.map((file) => uploadToS3(file)));
uploadedKeys.push(...urls.map(keyFromUrl));
};

const measure = async (fn, files) => {
const start = performance.now();
await fn(files);
return performance.now() - start;
};

const median = (arr) => {
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};

const cleanup = async () => {
for (let i = 0; i < uploadedKeys.length; i += 1000) {
await s3.send(new DeleteObjectsCommand({
Bucket: process.env.S3_BUCKET_NAME,
Delete: { Objects: uploadedKeys.slice(i, i + 1000).map((Key) => ({ Key })) },
}));
}
console.log(`정리 완료: 업로드한 파일 ${uploadedKeys.length}개 삭제`);
};

const main = async () => {
console.log(`조건: 파일 ${FILE_COUNT}개 x ${FILE_SIZE_MB}MB, ${ROUNDS}회 반복\n`);

// 첫 연결(TLS 핸드셰이크 등) 비용이 측정에 섞이지 않도록 워밍업
await uploadSequential(makeFiles(0, 'warmup').slice(0, 1));

const sequential = [];
const parallel = [];
for (let round = 1; round <= ROUNDS; round++) {
// 순서에 따른 편향을 줄이기 위해 회차마다 실행 순서를 바꾼다
const order = round % 2 ? ['seq', 'par'] : ['par', 'seq'];
for (const mode of order) {
if (mode === 'seq') sequential.push(await measure(uploadSequential, makeFiles(round, mode)));
else parallel.push(await measure(uploadParallel, makeFiles(round, mode)));
}
console.log(`[${round}회차] 순차 ${sequential.at(-1).toFixed(0)}ms / 병렬 ${parallel.at(-1).toFixed(0)}ms`);
}

const seqMedian = median(sequential);
const parMedian = median(parallel);
console.log(`\n중앙값: 순차 ${seqMedian.toFixed(0)}ms → 병렬 ${parMedian.toFixed(0)}ms`);
console.log(`단축률: ${((1 - parMedian / seqMedian) * 100).toFixed(1)}% (약 ${(seqMedian / parMedian).toFixed(1)}배)`);
};

main()
.catch((err) => {
console.error('측정 실패:', err);
process.exitCode = 1;
})
.finally(cleanup);
5 changes: 4 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { corsOptions } from "./config/cors.config.js";
import apiRouter from "./routes/index.js";
import prisma from "./db.config.js";
import { swaggerHandler } from "./middlewares/swagger.middleware.js";
import { swaggerBasicAuth } from "./middlewares/swagger-auth.middleware.js";
import swaggerUi from "swagger-ui-express";
import YAML from "yamljs";
import path from "path";
Expand Down Expand Up @@ -82,6 +83,8 @@ swaggerDocument.servers = [
},
];

// Swagger UI, 정적 리소스, OpenAPI JSON에 동일한 접속 인증 적용
app.use(["/docs", "/openapi.json"], swaggerBasicAuth);
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));

app.get("/openapi.json", swaggerHandler);
Expand Down Expand Up @@ -130,4 +133,4 @@ const gracefulExit = async () => {

// SIGINT: Ctrl+C 종료, SIGTERM: 프로세스 종료
process.on("SIGINT", gracefulExit);
process.on("SIGTERM", gracefulExit);
process.on("SIGTERM", gracefulExit);
16 changes: 16 additions & 0 deletions src/middlewares/error.middleware.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
import multer from "multer";

export const errorHandler = (err, req, res, next) => {
if (err instanceof multer.MulterError) {
const errors = {
LIMIT_FILE_SIZE: [413, "FILE_TOO_LARGE", "업로드할 수 있는 파일 크기를 초과했습니다."],
LIMIT_FILE_COUNT: [400, "TOO_MANY_FILES", "업로드할 수 있는 파일 개수를 초과했습니다."],
LIMIT_UNEXPECTED_FILE: [400, "UNEXPECTED_FILE", "허용되지 않은 파일 필드이거나 파일 개수를 초과했습니다."],
};
const [status, errorCode, reason] = errors[err.code] ??
[400, "INVALID_UPLOAD", "파일 업로드 요청이 올바르지 않습니다."];

return res.status(status).json({
resultType: "FAIL", code: status, errorCode, reason,
data: { field: err.field ?? null },
});
}
const statusCode = err.statusCode || 500;

res.status(statusCode).json({
Expand Down
32 changes: 32 additions & 0 deletions src/middlewares/swagger-auth.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createHash, timingSafeEqual } from "node:crypto";

const digest = (value) => createHash("sha256").update(value).digest();

export const swaggerBasicAuth = (req, res, next) => {
const username = process.env.SWAGGER_USERNAME;
const password = process.env.SWAGGER_PASSWORD;

res.set("Cache-Control", "no-store");

if (!username?.trim() || !password?.trim() || username.includes(":")) {
return res.status(503).send("Swagger 접속 계정이 설정되지 않았습니다.");
}

const challenge = () => {
res.set("WWW-Authenticate", 'Basic realm="Check-Task Swagger", charset="UTF-8"');
return res.status(401).send("Swagger 접속 아이디와 비밀번호를 입력해 주세요.");
};

const match = /^Basic\s+([A-Za-z0-9+/]+={0,2})$/i.exec(req.get("authorization") ?? "");
if (!match) return challenge();

const credentials = Buffer.from(match[1], "base64").toString("utf8");
const separator = credentials.indexOf(":");
if (separator < 0) return challenge();

const usernameMatches = timingSafeEqual(digest(credentials.slice(0, separator)), digest(username));
const passwordMatches = timingSafeEqual(digest(credentials.slice(separator + 1)), digest(password));
if (!usernameMatches || !passwordMatches) return challenge();

return next();
};
32 changes: 31 additions & 1 deletion src/middlewares/upload.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,47 @@ import { PutObjectCommand } from '@aws-sdk/client-s3';
import s3 from '../config/s3.config.js';
import multer from "multer";
import dotenv from 'dotenv';
import { extname } from 'node:path';
import { BadRequestError } from '../errors/custom.error.js';

dotenv.config();

const storage = multer.memoryStorage();

export const IMAGE_EXTENSIONS = Object.freeze(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif']);
export const FILE_EXTENSIONS = Object.freeze([
...IMAGE_EXTENSIONS, 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'hwp', 'hwpx', 'zip',
]);

export const validateFileExtension = (file) => {
const allowedExtensions = ['image', 'profileImage'].includes(file.fieldname)
? IMAGE_EXTENSIONS : FILE_EXTENSIONS;
const extension = extname(file.originalname ?? '').slice(1).toLowerCase();
if (!allowedExtensions.includes(extension)) {
throw new BadRequestError(
'UNSUPPORTED_FILE_EXTENSION',
'지원하지 않는 파일 형식입니다. 허용된 확장자의 파일을 선택해 주세요.',
{ allowedExtensions },
);
}
};

const upload = multer({
storage: storage,
limits: { fileSize: 100 * 1024 * 1024 },
fileFilter: (req, file, callback) => {
try {
validateFileExtension(file);
callback(null, true);
} catch (error) {
callback(error);
}
},
});

export const uploadToS3 = async (file) => {
validateFileExtension(file);
const key = `profile/${Date.now()}-${file.originalname}`;

const command = new PutObjectCommand({
Expand All @@ -27,4 +57,4 @@ export const uploadToS3 = async (file) => {
return `https://${process.env.S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
};

export default upload;
export default upload;
3 changes: 1 addition & 2 deletions src/routes/modal.route.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Router } from "express";
import multer from "multer";
import upload from "../middlewares/upload.middleware.js";

import modalController from "../controllers/modal.controller.js";
import authenticate from "../middlewares/authenticate.middleware.js";
Expand All @@ -17,7 +17,6 @@ import {
} from "../validators/modal.schema.js";

const router = Router();
const upload = multer({ storage: multer.memoryStorage() });

// ─── 자료 ─────────────────────────────────────────────────────────
router.post(
Expand Down
3 changes: 1 addition & 2 deletions src/routes/task.route.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import express from "express";
import multer from "multer";
import upload from "../middlewares/upload.middleware.js";

import taskController from "../controllers/task.controller.js";
import authenticate from "../middlewares/authenticate.middleware.js";
Expand All @@ -26,7 +26,6 @@ import {
deleteAllSubTaskParamsSchema,
} from "../validators/task.schema.js";

const upload = multer({ storage: multer.memoryStorage() });

const router = express.Router();

Expand Down
1 change: 1 addition & 0 deletions tests/integration/modal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ vi.mock("../../src/middlewares/upload.middleware.js", () => ({
uploadToS3: vi.fn().mockResolvedValue("https://s3.example.com/file.pdf"),
default: {
single: () => (req, res, next) => next(),
array: () => (req, res, next) => next(),
},
}));
vi.mock("../../src/config/s3.config.js", () => ({
Expand Down
1 change: 1 addition & 0 deletions tests/integration/user.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vi.mock("../../src/middlewares/upload.middleware.js", () => ({
uploadToS3: vi.fn().mockResolvedValue("https://s3.example.com/profile.png"),
default: {
single: () => (req, res, next) => next(),
array: () => (req, res, next) => next(),
},
}));
vi.mock("../../src/config/s3.config.js", () => ({
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/swagger-auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import swaggerUi from "swagger-ui-express";
import { swaggerBasicAuth } from "../../src/middlewares/swagger-auth.middleware.js";

const app = express();
app.use(["/docs", "/openapi.json"], swaggerBasicAuth);
app.use("/docs", swaggerUi.serve, swaggerUi.setup({
openapi: "3.0.0",
info: { title: "Test", version: "1.0.0" },
paths: {},
}));
app.get("/openapi.json", (req, res) => res.json({ openapi: "3.0.0" }));
app.get("/api/v1/health", (req, res) => res.sendStatus(200));

const basic = (value) => `Basic ${Buffer.from(value).toString("base64")}`;

describe("Swagger 접속 인증", () => {
beforeEach(() => {
vi.stubEnv("SWAGGER_USERNAME", "docs-user");
vi.stubEnv("SWAGGER_PASSWORD", "long:test-password");
});
afterEach(() => vi.unstubAllEnvs());

it.each(["/docs", "/docs/", "/docs/swagger-ui.css", "/docs/swagger-ui-init.js", "/openapi.json"])(
"%s는 인증 없이 열리지 않는다",
async (url) => {
const response = await request(app).get(url);
expect(response.status).toBe(401);
expect(response.headers["www-authenticate"]).toContain('Basic realm="Check-Task Swagger"');
expect(response.headers["cache-control"]).toBe("no-store");
},
);

it.each(["/docs/", "/docs/swagger-ui.css", "/docs/swagger-ui-init.js", "/openapi.json"])(
"%s는 올바른 계정으로 접근할 수 있다",
async (url) => {
const response = await request(app).get(url).set("Authorization", basic("docs-user:long:test-password"));
expect(response.status).toBe(200);
},
);

it.each([
basic("wrong-user:long:test-password"),
basic("docs-user:wrong-password"),
basic("docs-user:"),
basic("no-separator"),
"Basic !!!",
"Bearer token",
])("잘못된 인증 %s를 거부한다", async (authorization) => {
const response = await request(app).get("/openapi.json").set("Authorization", authorization);
expect(response.status).toBe(401);
expect(response.text).not.toContain("long:test-password");
});

it.each([
["SWAGGER_USERNAME", undefined],
["SWAGGER_PASSWORD", undefined],
["SWAGGER_PASSWORD", ""],
["SWAGGER_PASSWORD", " "],
["SWAGGER_USERNAME", "invalid:user"],
])("계정 설정 오류 시 문서를 공개하지 않는다: %s=%s", async (name, value) => {
vi.stubEnv(name, value);
const response = await request(app).get("/docs/");
expect(response.status).toBe(503);
});

it("일반 API 경로에는 Swagger 인증을 요구하지 않는다", async () => {
const response = await request(app).get("/api/v1/health");
expect(response.status).toBe(200);
expect(response.headers["www-authenticate"]).toBeUndefined();
});
});
Loading
Loading