diff --git a/scripts/bench-s3-upload.js b/scripts/bench-s3-upload.js new file mode 100644 index 0000000..fb431bb --- /dev/null +++ b/scripts/bench-s3-upload.js @@ -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); diff --git a/src/index.js b/src/index.js index 1b0df43..f7aef3f 100644 --- a/src/index.js +++ b/src/index.js @@ -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"; @@ -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); @@ -130,4 +133,4 @@ const gracefulExit = async () => { // SIGINT: Ctrl+C 종료, SIGTERM: 프로세스 종료 process.on("SIGINT", gracefulExit); -process.on("SIGTERM", gracefulExit); \ No newline at end of file +process.on("SIGTERM", gracefulExit); diff --git a/src/middlewares/error.middleware.js b/src/middlewares/error.middleware.js index 9e44fed..d4e8d59 100644 --- a/src/middlewares/error.middleware.js +++ b/src/middlewares/error.middleware.js @@ -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({ diff --git a/src/middlewares/swagger-auth.middleware.js b/src/middlewares/swagger-auth.middleware.js new file mode 100644 index 0000000..eb51262 --- /dev/null +++ b/src/middlewares/swagger-auth.middleware.js @@ -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(); +}; diff --git a/src/middlewares/upload.middleware.js b/src/middlewares/upload.middleware.js index 6a319cb..1508c0b 100644 --- a/src/middlewares/upload.middleware.js +++ b/src/middlewares/upload.middleware.js @@ -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({ @@ -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; \ No newline at end of file +export default upload; diff --git a/src/routes/modal.route.js b/src/routes/modal.route.js index e8aee11..6fd89cf 100644 --- a/src/routes/modal.route.js +++ b/src/routes/modal.route.js @@ -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"; @@ -17,7 +17,6 @@ import { } from "../validators/modal.schema.js"; const router = Router(); -const upload = multer({ storage: multer.memoryStorage() }); // ─── 자료 ───────────────────────────────────────────────────────── router.post( diff --git a/src/routes/task.route.js b/src/routes/task.route.js index 3997095..cea01f9 100644 --- a/src/routes/task.route.js +++ b/src/routes/task.route.js @@ -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"; @@ -26,7 +26,6 @@ import { deleteAllSubTaskParamsSchema, } from "../validators/task.schema.js"; -const upload = multer({ storage: multer.memoryStorage() }); const router = express.Router(); diff --git a/tests/integration/modal.test.js b/tests/integration/modal.test.js index d9131a8..5c0210f 100644 --- a/tests/integration/modal.test.js +++ b/tests/integration/modal.test.js @@ -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", () => ({ diff --git a/tests/integration/user.test.js b/tests/integration/user.test.js index a61a606..1047e9b 100644 --- a/tests/integration/user.test.js +++ b/tests/integration/user.test.js @@ -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", () => ({ diff --git a/tests/unit/swagger-auth.test.js b/tests/unit/swagger-auth.test.js new file mode 100644 index 0000000..2163b62 --- /dev/null +++ b/tests/unit/swagger-auth.test.js @@ -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(); + }); +}); diff --git a/tests/unit/upload.test.js b/tests/unit/upload.test.js new file mode 100644 index 0000000..20212ac --- /dev/null +++ b/tests/unit/upload.test.js @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest'; +import multer from 'multer'; +vi.mock('../../src/config/s3.config.js', () => ({ default: { send: vi.fn() } })); +import s3 from '../../src/config/s3.config.js'; +import upload, { validateFileExtension, uploadToS3 } from '../../src/middlewares/upload.middleware.js'; +import { errorHandler } from '../../src/middlewares/error.middleware.js'; + +const response = () => { + const res = { status: vi.fn(), json: vi.fn() }; + res.status.mockReturnValue(res); + return res; +}; + +describe('파일 확장자 검증', () => { + it.each(['report.PDF', '보고서.hwpx', 'archive.zip', 'photo.JpG'])('%s 첨부 허용', (originalname) => { + expect(() => validateFileExtension({ originalname, fieldname: 'files' })).not.toThrow(); + }); + it.each(['run.exe', 'photo.jpg.exe', 'script.svg', 'no-extension', '.pdf', 'file.pdf '])('%s 거부', (originalname) => { + expect(() => validateFileExtension({ originalname, fieldname: 'files' })).toThrow(); + }); + it.each(['image', 'profileImage'])('프로필 %s에 PDF 거부', (fieldname) => { + expect(() => validateFileExtension({ originalname: 'report.pdf', fieldname })).toThrow(); + expect(() => validateFileExtension({ originalname: 'photo.PNG', fieldname })).not.toThrow(); + }); + it('Multer 필터에서 거부하고 오류 응답에 허용 목록 제공', () => { + const callback = vi.fn(); + upload.fileFilter({}, { originalname: 'run.exe', fieldname: 'file_url' }, callback); + const error = callback.mock.calls[0][0]; + const res = response(); + errorHandler(error, {}, res, vi.fn()); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + errorCode: 'UNSUPPORTED_FILE_EXTENSION', data: { allowedExtensions: expect.arrayContaining(['pdf', 'hwp']) }, + })); + }); + it('직접 S3 호출도 확장자 검증 후 차단', async () => { + await expect(uploadToS3({ originalname: 'run.exe', buffer: Buffer.from('test') })).rejects.toMatchObject({ errorCode: 'UNSUPPORTED_FILE_EXTENSION' }); + expect(s3.send).not.toHaveBeenCalled(); + }); +}); + +describe('Multer 오류 응답', () => { + it.each([ + ['LIMIT_FILE_SIZE', 413, 'FILE_TOO_LARGE'], + ['LIMIT_FILE_COUNT', 400, 'TOO_MANY_FILES'], + ['LIMIT_UNEXPECTED_FILE', 400, 'UNEXPECTED_FILE'], + ['LIMIT_PART_COUNT', 400, 'INVALID_UPLOAD'], + ])('%s를 클라이언트 오류로 반환', (code, status, errorCode) => { + const res = response(); + errorHandler(new multer.MulterError(code, 'files'), {}, res, vi.fn()); + expect(res.status).toHaveBeenCalledWith(status); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ errorCode, data: { field: 'files' } })); + }); +}); + +describe('multipart 요청 검증', async () => { + const { default: express } = await import('express'); + const { default: request } = await import('supertest'); + const app = express(); + app.post('/profile', upload.single('image'), (req, res) => res.json({ name: req.file.originalname })); + app.post('/files', upload.array('files'), (req, res) => res.json({ count: req.files.length })); + app.use(errorHandler); + + it('금지된 확장자에 400 응답', async () => { + const res = await request(app).post('/files').attach('files', Buffer.from('test'), 'run.exe'); + expect(res.status).toBe(400); + expect(res.body.errorCode).toBe('UNSUPPORTED_FILE_EXTENSION'); + }); + it('이미지 필드에 PDF 업로드 차단', async () => { + const res = await request(app).post('/profile').attach('image', Buffer.from('test'), 'report.pdf'); + expect(res.status).toBe(400); + expect(res.body.data.allowedExtensions).not.toContain('pdf'); + }); + it('허용된 문서 여러 개 수신', async () => { + const res = await request(app).post('/files') + .attach('files', Buffer.from('test'), 'report.PDF') + .attach('files', Buffer.from('test'), 'report.hwpx'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + }); + it('잘못된 파일 필드에 400 응답', async () => { + const res = await request(app).post('/profile').attach('other', Buffer.from('test'), 'photo.png'); + expect(res.status).toBe(400); + expect(res.body.errorCode).toBe('UNEXPECTED_FILE'); + }); +});