Initial commit

This commit is contained in:
2026-10-26 12:28:48 +09:00
commit 18f10cece4
269 changed files with 33654 additions and 0 deletions
@@ -0,0 +1,84 @@
import { HttpStatus, ServiceUnavailableException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import {
HealthCheckResult,
HealthCheckService,
TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import { Response } from 'express';
import { HealthController } from './health.controller';
import { SKIP_TRANSFORM_KEY } from '@/common/decorators/skip-transform.decorator';
const okResult = {
status: 'ok',
info: { database: { status: 'up' } },
error: {},
details: { database: { status: 'up' } },
} as unknown as HealthCheckResult;
const downResult = {
status: 'error',
info: {},
error: { database: { status: 'down', message: 'timeout' } },
details: { database: { status: 'down', message: 'timeout' } },
};
describe('HealthController', () => {
let controller: HealthController;
let check: jest.Mock;
let res: { status: jest.Mock };
beforeEach(async () => {
check = jest.fn();
res = { status: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [
{ provide: HealthCheckService, useValue: { check } },
{ provide: TypeOrmHealthIndicator, useValue: { pingCheck: jest.fn() } },
],
}).compile();
controller = module.get(HealthController);
});
it('헬스체크는 표준 응답 래핑에서 제외된다 (모니터링 도구가 원본 형식을 읽는다)', () => {
// SkipTransform 메타데이터가 컨트롤러에 붙어 있어야 인터셉터가 래핑을 건너뛴다
const skip: unknown = Reflect.getMetadata(
SKIP_TRANSFORM_KEY,
HealthController,
);
expect(skip).toBe(true);
});
it('정상이면 terminus 결과를 그대로 반환한다', async () => {
check.mockResolvedValue(okResult);
const result = await controller.check(res as unknown as Response);
expect(result).toEqual(okResult);
// 성공 시에는 상태 코드를 건드리지 않는다
expect(res.status).not.toHaveBeenCalled();
});
it('실패하면 503 과 함께 어느 구성요소가 죽었는지 그대로 노출한다', async () => {
check.mockRejectedValue(new ServiceUnavailableException(downResult));
const result = await controller.check(res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE);
// SYS_001 로 덮어쓰지 않고 원인을 유지해야 한다
expect(result).toEqual(downResult);
expect(JSON.stringify(result)).toContain('database');
});
it('terminus 예외가 아닌 오류는 전역 필터로 넘긴다', async () => {
check.mockRejectedValue(new Error('예상치 못한 오류'));
await expect(controller.check(res as unknown as Response)).rejects.toThrow(
'예상치 못한 오류',
);
});
});
@@ -0,0 +1,73 @@
import {
Controller,
Get,
HttpStatus,
Res,
ServiceUnavailableException,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import {
HealthCheck,
HealthCheckResult,
HealthCheckService,
TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import type { Response } from 'express';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다.
// 커넥션 풀의 유휴 커넥션이 정리된 뒤(idleTimeoutMillis) 재연결하는 경우 1초를 넘겨
// 정상인 DB 를 down 으로 오판하고 헬스체크가 간헐적으로 실패한다.
const DB_PING_TIMEOUT_MS = 5_000;
/**
* 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템이 읽는다.
*
* 이 엔드포인트는 **사람이 아니라 기계가 소비**하므로 표준 응답 래퍼와 에러 코드 규칙에서 제외한다.
* (`{success,data}` 로 감싸거나 실패를 SYS_001 로 덮어쓰면 "어느 컴포넌트가 죽었는지"가 사라진다)
*
* - 성공 200: `{ status: 'ok', info: { database: { status: 'up' } }, ... }`
* - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }`
*
* 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다).
*/
@SkipThrottle()
@SkipTransform()
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
) {}
@Get()
@HealthCheck()
@ApiOperation({
summary: '애플리케이션 상태 확인 (표준 응답 래퍼 미적용)',
})
@ApiResponse({ status: 200, description: '모든 구성요소 정상' })
@ApiResponse({
status: 503,
description: '하나 이상의 구성요소 비정상 — 본문의 error 에 원인이 담긴다',
})
async check(
@Res({ passthrough: true }) res: Response,
): Promise<HealthCheckResult> {
try {
return await this.health.check([
() => this.db.pingCheck('database', { timeout: DB_PING_TIMEOUT_MS }),
]);
} catch (error) {
// terminus 는 실패 시 ServiceUnavailableException 을 던지며 본문에 상세 결과를 담는다.
// 전역 필터가 SYS_001 로 덮어쓰지 않도록 여기서 직접 503 과 원본 본문을 반환한다.
if (error instanceof ServiceUnavailableException) {
res.status(HttpStatus.SERVICE_UNAVAILABLE);
return error.getResponse() as HealthCheckResult;
}
throw error;
}
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
})
export class HealthModule {}
@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
/** 업로드 결과 — 저장 경로(절대경로)는 노출하지 않는다 */
export class UploadResponseDto {
@ApiProperty({
description: '서버가 생성한 파일 식별자 (저장 파일명)',
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f.png',
})
fileName!: string;
@ApiProperty({
description: '업로드 당시 원본 파일명',
example: '프로필.png',
})
originalName!: string;
@ApiProperty({ description: 'MIME 타입', example: 'image/png' })
mimeType!: string;
@ApiProperty({ description: '파일 크기 (바이트)', example: 20480 })
size!: number;
}
@@ -0,0 +1,30 @@
import { allowedExtensionList, isAllowedFile } from './upload.config';
describe('파일 업로드 검증', () => {
it('확장자와 MIME 이 모두 맞으면 통과한다', () => {
expect(isAllowedFile('photo.png', 'image/png')).toBe(true);
expect(isAllowedFile('scan.PDF', 'application/pdf')).toBe(true);
});
it('허용되지 않은 MIME 은 거부한다', () => {
expect(isAllowedFile('script.js', 'application/javascript')).toBe(false);
expect(isAllowedFile('page.html', 'text/html')).toBe(false);
});
it('MIME 은 허용이지만 확장자가 불일치하면 거부한다 (확장자 위장 방지)', () => {
expect(isAllowedFile('payload.html', 'image/png')).toBe(false);
expect(isAllowedFile('payload.exe', 'application/pdf')).toBe(false);
});
it('확장자가 없으면 거부한다', () => {
expect(isAllowedFile('nofile', 'image/png')).toBe(false);
});
it('실행 가능한 형식은 허용 목록에 없다', () => {
const allowed = allowedExtensionList();
for (const dangerous of ['.html', '.svg', '.js', '.exe', '.sh']) {
expect(allowed).not.toContain(dangerous);
}
});
});
@@ -0,0 +1,48 @@
import { extname } from 'path';
import { parsePositiveInt } from '@/shared/utils/env';
/**
* 파일 업로드 정책 (로컬 디스크 기준).
*
* 오브젝트 스토리지(S3 등)로 전환할 때는 저장 어댑터만 교체하고
* 검증 규칙(허용 형식·크기)은 그대로 재사용한다.
*/
/** 업로드 파일 저장 디렉터리 — 컨테이너에서는 compose 볼륨이 연결된다 */
export const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads';
const MEGABYTE = 1024 * 1024;
/** 파일 1개 최대 크기 (기본 5MB) */
export const MAX_FILE_SIZE_BYTES =
parsePositiveInt(process.env.UPLOAD_MAX_SIZE_MB, 5) * MEGABYTE;
/**
* 허용 형식 — 확장자와 MIME 타입을 **둘 다** 검사한다.
* 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
*
* ⚠️ 실행 가능한 형식(.html, .svg, .js 등)은 넣지 말 것 —
* 저장된 파일을 그대로 서빙할 경우 XSS 로 이어진다.
*/
export const ALLOWED_FILE_TYPES: ReadonlyMap<string, readonly string[]> =
new Map([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
['image/webp', ['.webp']],
['image/gif', ['.gif']],
['application/pdf', ['.pdf']],
]);
/** 허용 형식인지 검사한다 (확장자 ↔ MIME 조합이 일치해야 통과) */
export function isAllowedFile(originalName: string, mimeType: string): boolean {
const allowedExtensions = ALLOWED_FILE_TYPES.get(mimeType);
if (!allowedExtensions) return false;
return allowedExtensions.includes(extname(originalName).toLowerCase());
}
/** 사용자에게 안내할 허용 확장자 목록 */
export function allowedExtensionList(): string {
return [...ALLOWED_FILE_TYPES.values()].flat().join(', ');
}
@@ -0,0 +1,54 @@
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadResponseDto } from './dto/upload-response.dto';
import { UploadService } from './upload.service';
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
@ApiTags('Upload')
@Controller('files')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '파일 업로드 (단일)' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
})
@ApiStandardResponse(UploadResponseDto, {
status: 201,
description: '업로드 성공',
})
@ApiErrorResponse('FILE_001', '허용되지 않는 파일 형식')
@ApiErrorResponse('FILE_002', '파일 크기 초과')
upload(@UploadedFile() file?: Express.Multer.File): UploadResponseDto {
if (!file) {
// 파일 없이 요청한 경우 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
message: '업로드할 파일을 선택해 주세요.',
});
}
return this.uploadService.toResponse(file);
}
}
@@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import { mkdirSync } from 'fs';
import { extname } from 'path';
import { BusinessException } from '@/common/errors/business.exception';
import {
MAX_FILE_SIZE_BYTES,
UPLOAD_DIR,
isAllowedFile,
} from './upload.config';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다)
mkdirSync(UPLOAD_DIR, { recursive: true });
@Module({
imports: [
MulterModule.register({
storage: diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, callback) => {
// ⚠️ 클라이언트가 보낸 파일명을 경로에 쓰지 않는다.
// '../../etc/passwd' 같은 값이 그대로 들어오면 경로 탈출이 된다.
// 서버가 생성한 UUID + 검증된 확장자만 사용한다.
const safeExtension = extname(file.originalname).toLowerCase();
callback(null, `${randomUUID()}${safeExtension}`);
},
}),
limits: {
fileSize: MAX_FILE_SIZE_BYTES,
// 파일 외 필드 수를 제한해 과도한 파싱을 막는다
files: 1,
fields: 10,
},
fileFilter: (_req, file, callback) => {
if (!isAllowedFile(file.originalname, file.mimetype)) {
// 에러 코드와 함께 거부한다 (전역 필터가 표준 응답으로 변환)
callback(new BusinessException('FILE_001'), false);
return;
}
callback(null, true);
},
}),
],
controllers: [UploadController],
providers: [UploadService],
exports: [UploadService],
})
export class UploadModule {}
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { UPLOAD_DIR } from './upload.config';
import { UploadResponseDto } from './dto/upload-response.dto';
// 업로드된 파일의 메타데이터를 다루는 계층.
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 그 결과를 정리한다.
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
/** multer 가 저장한 파일 정보를 응답 DTO 로 변환한다 */
toResponse(file: Express.Multer.File): UploadResponseDto {
this.logger.log(
`파일 업로드 완료: ${file.filename} (${file.size} bytes, ${file.mimetype})`,
);
return {
fileName: file.filename,
// 원본 파일명은 표시용 메타데이터일 뿐이며 경로 조립에 사용하지 않는다
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
};
}
/**
* 저장된 파일을 삭제한다.
* 파일명은 서버가 생성한 값만 받아야 한다 — 외부 입력을 그대로 넘기면 경로 탈출 위험이 있다.
*/
async remove(fileName: string): Promise<void> {
await unlink(join(UPLOAD_DIR, fileName));
}
}
@@ -0,0 +1,20 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 사용자 생성 DTO 샘플 — 모든 검증 메시지는 한국어, Swagger 메타 필수
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password!: string;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
@IsString()
@IsNotEmpty({ message: '이름은 필수 입력 항목입니다.' })
name!: string;
}
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
/** 사용자 응답 DTO — Swagger 스키마와 클라이언트 타입의 기준이 된다. */
export class UserResponseDto {
@ApiProperty({ description: '사용자 ID', example: 1 })
id!: number;
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
email!: string;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
name!: string;
}
@@ -0,0 +1,59 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('User')
@ApiBearerAuth()
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({ summary: '사용자 목록 조회 (커서 페이지네이션)' })
@ApiStandardResponse(UserResponseDto, {
status: 200,
description: '사용자 목록 조회 성공',
isArray: true,
})
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
findMany(@Query() query: CursorPaginationQueryDto) {
return this.userService.findMany(query.limit, query.cursor);
}
@Get(':id')
@ApiOperation({ summary: '사용자 단건 조회' })
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
}
@Post()
@ApiOperation({ summary: '사용자 생성' })
@ApiStandardResponse(UserResponseDto, {
status: 201,
description: '사용자 생성 성공',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('BIZ_001', '이메일 중복 등 비즈니스 규칙 위반')
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
+58
View File
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 비즈니스 로직 계층 샘플 — 실제 구현 시 Repository 주입하여 데이터 접근
@Injectable()
export class UserService {
// 임시 인메모리 저장소 — TypeORM Repository 도입 시 교체
private readonly users = new Map<number, UserResponseDto>();
findOne(id: number): UserResponseDto {
const user = this.users.get(id);
if (!user) {
// 사용자 노출 실패는 항상 에러 코드와 함께 던진다.
throw new BusinessException('RES_001', { context: { userId: id } });
}
return user;
}
/** 커서 기반 목록 조회 샘플 — 목록 API 는 항상 페이지네이션을 적용한다. */
findMany(limit: number, cursor?: string): PaginatedResult<UserResponseDto> {
const afterId = cursor ? Number(cursor) : 0;
const sorted = [...this.users.values()].sort((a, b) => a.id - b.id);
const page = sorted.filter((user) => user.id > afterId).slice(0, limit);
const last = page.at(-1);
const hasNext = last ? sorted.some((user) => user.id > last.id) : false;
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext && last ? String(last.id) : null,
total: sorted.length,
});
}
create(dto: CreateUserDto): UserResponseDto {
const duplicated = [...this.users.values()].some(
(user) => user.email === dto.email,
);
if (duplicated) {
// 도메인 고유 코드가 필요하면 error-codes.ts 의 DOMAIN_ERROR_CODES 에 등록 후 사용한다.
throw new BusinessException('BIZ_001', {
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
context: { email: dto.email },
});
}
const id = this.users.size + 1;
const user: UserResponseDto = { id, email: dto.email, name: dto.name };
this.users.set(id, user);
return user;
}
}