NestJS 파일 업로드 기본 구조
NestJS는 내부적으로 Multer를 사용하여 multipart/form-data 요청을 처리합니다. @UseInterceptors와 FileInterceptor를 조합하여 단일/다중 파일 업로드를 구현하고, S3 등 클라우드 스토리지와 연동하는 것이 실전 패턴입니다.
이 글에서는 기본 업로드부터 파일 검증, S3 연동, 스트리밍 다운로드, Presigned URL, 이미지 리사이즈 파이프라인까지 다룹니다.
기본 파일 업로드
npm install @nestjs/platform-express multer
npm install -D @types/multer
import {
Controller, Post, UploadedFile, UploadedFiles,
UseInterceptors, ParseFilePipe, MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor, FileFieldsInterceptor } from '@nestjs/platform-express';
@Controller('files')
export class FileController {
// 단일 파일 업로드
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
return {
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
};
}
// 다중 파일 업로드 (같은 필드명, 최대 10개)
@Post('upload-multiple')
@UseInterceptors(FilesInterceptor('files', 10))
uploadMultiple(@UploadedFiles() files: Express.Multer.File[]) {
return files.map(f => ({
originalName: f.originalname,
size: f.size,
}));
}
// 다른 필드명의 파일들
@Post('upload-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,
documentCount: files.documents?.length,
};
}
}
파일 검증: ParseFilePipe
NestJS 내장 Pipe 시스템으로 파일을 검증합니다.
@Post('upload-validated')
@UseInterceptors(FileInterceptor('file'))
uploadValidated(
@UploadedFile(
new ParseFilePipe({
validators: [
// 파일 크기 제한: 5MB
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
// MIME 타입 제한
new FileTypeValidator({ fileType: /^image/(jpeg|png|webp)$/ }),
],
// 파일 필수 여부
fileIsRequired: true,
// 에러 상태 코드
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
}),
)
file: Express.Multer.File,
) {
return { filename: file.originalname };
}
커스텀 Validator
import { FileValidator } from '@nestjs/common';
export class ImageDimensionValidator extends FileValidator {
constructor(
private readonly options: { maxWidth: number; maxHeight: number },
) {
super({});
}
async isValid(file: Express.Multer.File): Promise<boolean> {
const sharp = await import('sharp');
const metadata = await sharp.default(file.buffer).metadata();
return (
(metadata.width ?? 0) <= this.options.maxWidth &&
(metadata.height ?? 0) <= this.options.maxHeight
);
}
buildErrorMessage(): string {
return `이미지 크기는 ${this.options.maxWidth}x${this.options.maxHeight} 이하여야 합니다`;
}
}
// 사용
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }),
new FileTypeValidator({ fileType: /^image// }),
new ImageDimensionValidator({ maxWidth: 4096, maxHeight: 4096 }),
],
})
S3 스토리지 연동
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// storage.service.ts
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@Injectable()
export class StorageService {
private readonly s3: S3Client;
private readonly bucket: string;
constructor(private readonly config: ConfigService) {
this.s3 = new S3Client({
region: config.get('AWS_REGION'),
credentials: {
accessKeyId: config.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: config.get('AWS_SECRET_ACCESS_KEY'),
},
});
this.bucket = config.get('S3_BUCKET');
}
async upload(file: Express.Multer.File, folder: string): Promise<UploadResult> {
const key = `${folder}/${crypto.randomUUID()}-${file.originalname}`;
await this.s3.send(new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
// 퍼블릭 접근 차단 (Presigned URL로만 접근)
ACL: undefined,
Metadata: {
'original-name': encodeURIComponent(file.originalname),
},
}));
return {
key,
url: `https://${this.bucket}.s3.amazonaws.com/${key}`,
size: file.size,
mimeType: file.mimetype,
};
}
// Presigned URL 생성 (임시 다운로드 링크)
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
return getSignedUrl(this.s3, command, { expiresIn });
}
// Presigned Upload URL (클라이언트 직접 업로드)
async getUploadUrl(
key: string,
contentType: string,
expiresIn = 300,
): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
ContentType: contentType,
});
return getSignedUrl(this.s3, command, { expiresIn });
}
async delete(key: string): Promise<void> {
await this.s3.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
}));
}
}
Presigned URL: 클라이언트 직접 업로드
대용량 파일은 서버를 경유하지 않고 클라이언트가 S3에 직접 업로드하는 것이 효율적입니다.
@Controller('files')
export class FileController {
constructor(private readonly storage: StorageService) {}
// 1단계: 업로드 URL 발급
@Post('presigned-upload')
async getUploadUrl(@Body() dto: RequestUploadDto) {
const key = `uploads/${crypto.randomUUID()}.${dto.extension}`;
const url = await this.storage.getUploadUrl(key, dto.contentType);
return { uploadUrl: url, key };
// 클라이언트가 이 URL로 PUT 요청하여 직접 업로드
}
// 2단계: 업로드 완료 알림
@Post('confirm-upload')
async confirmUpload(@Body() dto: ConfirmUploadDto) {
// DB에 파일 메타데이터 저장
return this.fileService.register({
key: dto.key,
originalName: dto.originalName,
size: dto.size,
});
}
}
스트리밍 다운로드
import { StreamableFile } from '@nestjs/common';
import { Readable } from 'stream';
@Get('download/:id')
async downloadFile(
@Param('id') id: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const fileMeta = await this.fileService.findById(id);
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: fileMeta.key,
});
const s3Response = await this.s3.send(command);
res.set({
'Content-Type': fileMeta.mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileMeta.originalName)}"`,
'Content-Length': fileMeta.size.toString(),
});
// S3 Body를 스트리밍으로 전달 (메모리 효율적)
return new StreamableFile(s3Response.Body as Readable);
}
이미지 처리 파이프라인
npm install sharp
@Injectable()
export class ImageProcessingService {
async processAndUpload(
file: Express.Multer.File,
storage: StorageService,
): Promise<ProcessedImage> {
const sharp = (await import('sharp')).default;
const id = crypto.randomUUID();
// 원본 메타데이터
const metadata = await sharp(file.buffer).metadata();
// 여러 사이즈 동시 생성
const variants = await Promise.all([
// 썸네일: 200x200 크롭
sharp(file.buffer)
.resize(200, 200, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer()
.then(buffer => ({ suffix: 'thumb', buffer, width: 200, height: 200 })),
// 미디엄: 최대 800px
sharp(file.buffer)
.resize(800, 800, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer()
.then(buffer => ({ suffix: 'medium', buffer, width: 800, height: 800 })),
// 라지: 최대 1920px
sharp(file.buffer)
.resize(1920, 1920, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 90 })
.toBuffer()
.then(buffer => ({ suffix: 'large', buffer, width: 1920, height: 1920 })),
]);
// S3에 병렬 업로드
const uploads = await Promise.all(
variants.map(v =>
storage.uploadBuffer(v.buffer, `images/${id}-${v.suffix}.webp`, 'image/webp'),
),
);
return {
id,
original: { width: metadata.width, height: metadata.height },
variants: uploads.map((u, i) => ({
size: variants[i].suffix,
key: u.key,
url: u.url,
})),
};
}
}
Multer 옵션: 메모리 vs 디스크
// 메모리 스토리지 (기본) — 작은 파일, S3 직접 전송
@UseInterceptors(FileInterceptor('file'))
// 디스크 스토리지 — 대용량 파일, 후처리 필요
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({
destination: '/tmp/uploads',
filename: (req, file, cb) => {
const uniqueName = `${crypto.randomUUID()}-${file.originalname}`;
cb(null, uniqueName);
},
}),
limits: {
fileSize: 100 * 1024 * 1024, // 100MB
files: 1,
},
}))
- 메모리:
file.buffer로 접근. 작은 파일(~10MB)에 적합. 대용량은 OOM 위험 - 디스크:
file.path로 접근. 대용량 안전. 처리 후 임시 파일 삭제 필수
보안 체크리스트
- 파일 타입 검증: MIME 타입 + 매직 바이트(file-type 라이브러리) 이중 검증
- 파일명 새니타이징: 원본 파일명 그대로 사용 금지 → UUID 기반 키 생성
- 크기 제한: Multer limits + ParseFilePipe 이중 제한
- 저장 경로: 웹 루트 외부에 저장, S3 퍼블릭 접근 차단
- 다운로드: Presigned URL 또는 서버 프록시로만 제공
마무리
NestJS 파일 업로드의 핵심 패턴: 소용량은 Multer 메모리 → S3 업로드, 대용량은 Presigned URL로 클라이언트 직접 업로드, 이미지는 Sharp로 리사이즈 후 WebP 변환. Interceptor로 업로드 로깅을 자동화하고, Throttler로 업로드 요청을 제한하면 안정적인 파일 서비스가 완성됩니다.