NestJS 파일 업로드 기본 구조
NestJS는 내부적으로 Multer를 사용하여 multipart/form-data 요청을 처리합니다. Express 어댑터 기준으로 @nestjs/platform-express에 Multer 타입이 포함되어 있으며, Fastify를 사용한다면 @fastify/multipart로 대체합니다. 파일 업로드는 단순해 보이지만, 대용량 처리·보안 검증·스토리지 추상화까지 고려하면 설계가 복잡해집니다.
단일·다중 파일 업로드
NestJS는 4가지 파일 인터셉터 데코레이터를 제공합니다:
import {
Controller, Post, UseInterceptors,
UploadedFile, UploadedFiles,
} from '@nestjs/common';
import {
FileInterceptor,
FilesInterceptor,
FileFieldsInterceptor,
AnyFilesInterceptor,
} from '@nestjs/platform-express';
@Controller('files')
export class FileController {
// 단일 파일
@Post('single')
@UseInterceptors(FileInterceptor('file'))
uploadSingle(@UploadedFile() file: Express.Multer.File) {
return {
originalName: file.originalname,
size: file.size,
mimetype: file.mimetype,
};
}
// 같은 필드명으로 최대 10개
@Post('multiple')
@UseInterceptors(FilesInterceptor('files', 10))
uploadMultiple(@UploadedFiles() files: Express.Multer.File[]) {
return files.map(f => ({
originalName: f.originalname,
size: f.size,
}));
}
// 서로 다른 필드명
@Post('fields')
@UseInterceptors(FileFieldsInterceptor([
{ name: 'avatar', maxCount: 1 },
{ name: 'documents', maxCount: 5 },
]))
uploadFields(
@UploadedFiles() files: {
avatar?: Express.Multer.File[];
documents?: Express.Multer.File[];
},
) {
return {
avatar: files.avatar?.[0]?.originalname,
documents: files.documents?.map(f => f.originalname),
};
}
}
| 데코레이터 | 용도 | 파라미터 |
|---|---|---|
| FileInterceptor | 단일 파일 | 필드명 |
| FilesInterceptor | 같은 필드 다중 파일 | 필드명, 최대 개수 |
| FileFieldsInterceptor | 서로 다른 필드 다중 파일 | 필드 배열 |
| AnyFilesInterceptor | 모든 필드의 파일 | 없음 |
파일 검증 파이프
업로드된 파일의 MIME 타입과 크기를 검증하는 커스텀 Pipe를 만듭니다. NestJS 공식 ParseFilePipe와 내장 Validator를 활용합니다:
import {
ParseFilePipe, MaxFileSizeValidator,
FileTypeValidator, HttpStatus,
} from '@nestjs/common';
@Post('avatar')
@UseInterceptors(FileInterceptor('avatar'))
uploadAvatar(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5MB
new FileTypeValidator({ fileType: /(jpg|jpeg|png|webp)$/ }),
],
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
fileIsRequired: true,
}),
)
file: Express.Multer.File,
) {
return { filename: file.originalname };
}
하지만 FileTypeValidator는 MIME 타입 문자열만 검사하므로, 확장자를 위조한 악성 파일을 차단하지 못합니다. 매직 바이트(Magic Bytes) 기반 검증이 필요합니다:
import { FileValidator } from '@nestjs/common';
import { fileTypeFromBuffer } from 'file-type';
export class MagicBytesValidator extends FileValidator {
private allowedTypes: string[];
constructor(options: { allowedTypes: string[] }) {
super({});
this.allowedTypes = options.allowedTypes;
}
async isValid(file: Express.Multer.File): Promise<boolean> {
const result = await fileTypeFromBuffer(file.buffer);
if (!result) return false;
return this.allowedTypes.includes(result.mime);
}
buildErrorMessage(): string {
return `파일 형식이 허용되지 않습니다. 허용: ${this.allowedTypes.join(', ')}`;
}
}
// 사용
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }),
new MagicBytesValidator({
allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
}),
],
}),
)
file: Express.Multer.File
S3 스토리지 추상화
프로덕션에서는 로컬 디스크 대신 S3(또는 MinIO)에 파일을 저장합니다. 스토리지 로직을 서비스로 추상화합니다:
import { Injectable } from '@nestjs/common';
import {
S3Client, PutObjectCommand,
GetObjectCommand, DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuid } from 'uuid';
import * as path from 'path';
@Injectable()
export class StorageService {
private readonly s3: S3Client;
private readonly bucket: string;
constructor(private readonly config: ConfigService) {
this.bucket = config.get('S3_BUCKET');
this.s3 = new S3Client({
region: config.get('S3_REGION'),
endpoint: config.get('S3_ENDPOINT'), // MinIO 호환
forcePathStyle: true, // MinIO 필수
credentials: {
accessKeyId: config.get('S3_ACCESS_KEY'),
secretAccessKey: config.get('S3_SECRET_KEY'),
},
});
}
async upload(
file: Express.Multer.File,
folder: string = 'uploads',
): Promise<{ key: string; url: string }> {
const ext = path.extname(file.originalname);
const key = `${folder}/${uuid()}${ext}`;
await this.s3.send(new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
ContentDisposition: `inline; filename="${file.originalname}"`,
}));
return {
key,
url: `${this.config.get('CDN_URL')}/${key}`,
};
}
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
return getSignedUrl(this.s3, command, { expiresIn });
}
async delete(key: string): Promise<void> {
await this.s3.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
}));
}
}
대용량 파일: Streaming 업로드
Multer의 기본 memoryStorage는 파일 전체를 메모리에 버퍼링합니다. 100MB 이상의 대용량 파일은 메모리 부족을 유발합니다. 스트리밍 방식으로 직접 S3에 파이프하는 패턴이 필요합니다:
import { Controller, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { Upload } from '@aws-sdk/lib-storage';
import * as Busboy from 'busboy';
@Controller('files')
export class StreamUploadController {
constructor(
private readonly s3: S3Client,
private readonly config: ConfigService,
) {}
@Post('stream')
async streamUpload(@Req() req: Request, @Res() res: Response) {
const busboy = Busboy({
headers: req.headers,
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB
});
const uploads: Promise<any>[] = [];
busboy.on('file', (fieldname, stream, info) => {
const { filename, mimeType } = info;
const key = `uploads/${Date.now()}-${filename}`;
// S3로 직접 스트리밍 — 메모리에 버퍼링하지 않음
const upload = new Upload({
client: this.s3,
params: {
Bucket: this.config.get('S3_BUCKET'),
Key: key,
Body: stream, // ReadableStream 직접 전달
ContentType: mimeType,
},
queueSize: 4, // 동시 파트 업로드 수
partSize: 10 * 1024 * 1024, // 10MB 파트
});
uploads.push(
upload.done().then(() => ({ key, filename }))
);
});
busboy.on('finish', async () => {
const results = await Promise.all(uploads);
res.json({ uploaded: results });
});
req.pipe(busboy);
}
}
이 방식의 메모리 사용량은 파일 크기와 무관하게 파트 크기 × 큐 크기(약 40MB)로 일정합니다.
Presigned URL로 클라이언트 직접 업로드
서버를 거치지 않고 클라이언트가 S3에 직접 업로드하는 패턴입니다. 서버 대역폭과 메모리를 절약합니다:
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@Controller('files')
export class PresignedController {
constructor(private readonly storage: StorageService) {}
@Post('presigned-url')
async getPresignedUrl(
@Body() dto: { filename: string; contentType: string },
) {
const key = `uploads/${uuid()}-${dto.filename}`;
const command = new PutObjectCommand({
Bucket: this.config.get('S3_BUCKET'),
Key: key,
ContentType: dto.contentType,
});
const url = await getSignedUrl(this.s3, command, {
expiresIn: 300, // 5분간 유효
});
return { uploadUrl: url, key };
}
// 업로드 완료 후 클라이언트가 호출
@Post('confirm')
async confirmUpload(@Body() dto: { key: string }) {
// S3에서 파일 존재 확인 후 DB에 메타데이터 저장
const head = await this.s3.send(new HeadObjectCommand({
Bucket: this.config.get('S3_BUCKET'),
Key: dto.key,
}));
return this.fileRepository.save({
key: dto.key,
size: head.ContentLength,
contentType: head.ContentType,
});
}
}
// 클라이언트 (프론트엔드)
const { uploadUrl, key } = await api.post('/files/presigned-url', {
filename: file.name,
contentType: file.type,
});
// S3에 직접 업로드
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
// 서버에 완료 알림
await api.post('/files/confirm', { key });
이미지 리사이징 파이프라인
업로드된 이미지를 BullMQ 작업 큐로 비동기 리사이징하는 패턴입니다:
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import * as sharp from 'sharp';
@Processor('image-processing')
export class ImageProcessor extends WorkerHost {
constructor(private readonly storage: StorageService) {
super();
}
async process(job: Job<{ key: string; sizes: number[] }>) {
const { key, sizes } = job.data;
// S3에서 원본 다운로드
const original = await this.storage.download(key);
const results = await Promise.all(
sizes.map(async (width) => {
const resized = await sharp(original)
.resize(width, null, { withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
const resizedKey = key.replace(
/(.[^.]+)$/,
`-${width}w.webp`,
);
await this.storage.uploadBuffer(resized, resizedKey, 'image/webp');
return { width, key: resizedKey };
}),
);
return results;
}
}
보안 체크리스트
파일 업로드는 공격 벡터가 많으므로 반드시 다층 방어를 적용합니다:
- ✅ 매직 바이트 검증: 확장자가 아닌 파일 헤더로 실제 타입 확인
- ✅ 파일 크기 제한: Multer limits + Nginx
client_max_body_size이중 설정 - ✅ 파일명 새니타이징: 원본 파일명을 UUID로 교체, path traversal 차단
- ✅ 저장 경로 분리: 업로드 디렉토리를 웹 루트 밖에 배치
- ✅ 바이러스 스캔: ClamAV 연동으로 악성 파일 차단
- ✅ Rate Limiting: Throttler로 업로드 빈도 제한
- ✅ CORS 설정: Presigned URL 사용 시 S3 CORS 정책 필수
Nginx 연동 설정
NestJS 앞단의 Nginx에서도 업로드 크기를 제한해야 합니다:
# nginx.conf
server {
client_max_body_size 100M; # 최대 업로드 크기
client_body_buffer_size 10M; # 메모리 버퍼 크기
client_body_temp_path /tmp/nginx; # 임시 파일 경로
proxy_request_buffering off; # 스트리밍 시 버퍼링 비활성화
location /api/files/stream {
proxy_pass http://nestjs:3000;
proxy_request_buffering off; # 스트리밍 업로드 필수
proxy_read_timeout 600s; # 대용량 파일 타임아웃
}
}
정리
NestJS 파일 업로드는 단순한 Multer 래핑을 넘어, 매직 바이트 검증으로 보안을 강화하고, Busboy 스트리밍으로 대용량을 처리하며, Presigned URL로 서버 부하를 제거하는 것까지 고려해야 합니다. 스토리지를 서비스로 추상화하면 S3, GCS, MinIO 간 전환도 코드 변경 없이 가능합니다. 파일 업로드는 공격 표면이 넓으므로, 보안 체크리스트를 빠짐없이 적용하는 것이 핵심입니다.