NestJS Multer 파일 업로드
웹 애플리케이션에서 파일 업로드는 거의 필수 기능이다. 프로필 사진, 첨부 파일, 문서 등 사용자가 서버에 파일을 보내야 하는 상황은 항상 있다. 그런데 HTTP는 기본적으로 텍스트 기반 프로토콜이라, 바이너리 파일을 그냥 보낼 수 없다. multipart/form-data 인코딩을 사용해야 하는데, 이걸 직접 파싱하는 건 꽤 복잡한 작업이다.
Node.js 생태계에서 파일 업로드 파싱의 사실상 표준은 Multer다. Express 미들웨어로 시작했고, multipart/form-data 요청을 파싱해서 파일 정보를 req.file이나 req.files에 담아준다. NestJS는 Express 위에서 돌아가기 때문에 Multer를 그대로 쓸 수 있는데, NestJS 스타일에 맞게 데코레이터와 인터셉터로 감싸서 제공한다.
multipart/form-data란
일반적인 폼 전송은 application/x-www-form-urlencoded를 사용한다. 이 방식은 모든 데이터를 key=value&key=value 형태의 문자열로 인코딩한다. 텍스트 데이터에는 문제없지만, 바이너리 파일을 이 방식으로 보내면 URL 인코딩 과정에서 크기가 엄청나게 불어난다.
multipart/form-data는 이 문제를 해결한다. 각 필드를 boundary로 구분된 독립적인 파트로 나누고, 각 파트가 자체 Content-Type을 가질 수 있다. 텍스트 필드와 바이너리 파일을 하나의 요청에 섞어 보낼 수 있는 이유가 이것이다.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"
john
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
(바이너리 데이터)
------WebKitFormBoundary7MA4YWxkTrZu0gW--
Multer는 이 multipart 요청을 파싱해서, 텍스트 필드는 req.body에, 파일은 req.file(단일) 또는 req.files(복수)에 담아주는 역할을 한다.
NestJS에서의 기본 사용법
단일 파일 업로드
가장 기본적인 패턴이다. @UseInterceptors(FileInterceptor())로 multipart 파싱을 활성화하고, @UploadedFile()로 파싱된 파일 객체를 주입받는다.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"
john
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
(바이너리 데이터)
------WebKitFormBoundary7MA4YWxkTrZu0gW--
FileInterceptor('file')에서 'file'은 클라이언트가 보내는 form-data의 필드명이다. 클라이언트에서 formData.append('file', selectedFile)로 보냈다면 여기서도 'file'로 맞춰야 한다.
Express.Multer.File 타입을 사용하려면 @types/multer 패키지를 설치해야 한다.
import { Controller, Post, UseInterceptors, UploadedFile } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('files')
export class FileController {
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
console.log(file);
return { filename: file.originalname, size: file.size };
}
}
파일 객체의 구조
Express.Multer.File이 담고 있는 정보는 다음과 같다.
| 속성 | 타입 | 설명 |
|---|---|---|
fieldname | string | 폼에서 지정한 필드명 |
originalname | string | 클라이언트가 보낸 원본 파일명 |
encoding | string | 파일의 인코딩 방식 |
mimetype | string | 파일의 MIME 타입 (예: image/jpeg) |
size | number | 파일 크기 (바이트) |
buffer | Buffer | 파일의 실제 바이너리 데이터 (메모리 스토리지일 때) |
destination | string | 저장 디렉토리 경로 (디스크 스토리지일 때) |
filename | string | 저장된 파일명 (디스크 스토리지일 때) |
path | string | 저장된 파일의 전체 경로 (디스크 스토리지일 때) |
buffer와 destination/filename/path는 스토리지 설정에 따라 둘 중 하나만 존재한다. 기본 스토리지는 메모리이므로 buffer가 채워진다.
복수 파일 업로드
같은 필드에서 여러 파일
하나의 필드에서 여러 파일을 받으려면 FilesInterceptor를 사용한다.
npm install -D @types/multer
두 번째 인자 10은 최대 파일 수 제한이다. 이를 초과하면 Multer가 에러를 던진다.
다른 필드에서 각각 파일
서로 다른 필드에서 파일을 받아야 할 때는 FileFieldsInterceptor를 사용한다. 예를 들어 프로필 사진과 이력서를 동시에 받는 경우다.
import { Controller, Post, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
@Controller('files')
export class FileController {
@Post('upload-multiple')
@UseInterceptors(FilesInterceptor('files', 10)) // 최대 10개
uploadMultiple(@UploadedFiles() files: Express.Multer.File[]) {
return files.map(f => ({ name: f.originalname, size: f.size }));
}
}
반환 타입이 Express.Multer.File[]인 점에 주의해야 한다. maxCount: 1이라도 배열로 담긴다.
필드 제한 없이 모든 파일
필드명에 상관없이 모든 파일을 받으려면 AnyFilesInterceptor를 사용한다. 유연하지만 어떤 필드에서 어떤 파일이 왔는지 구분이 어렵기 때문에, 명확한 구조가 필요한 API에서는 권장하지 않는다.
import { Controller, Post, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
@Controller('files')
export class FileController {
@Post('upload-fields')
@UseInterceptors(
FileFieldsInterceptor([
{ name: 'avatar', maxCount: 1 },
{ name: 'resume', maxCount: 1 },
]),
)
uploadFields(
@UploadedFiles() files: { avatar?: Express.Multer.File[]; resume?: Express.Multer.File[] },
) {
return {
avatar: files.avatar?.[0]?.originalname,
resume: files.resume?.[0]?.originalname,
};
}
}
파일 유효성 검사
업로드된 파일을 그대로 신뢰하면 안 된다. 파일 크기가 수 GB일 수도 있고, 실행 파일이 이미지인 척 들어올 수도 있다. NestJS는 ParseFilePipe를 통해 파일 검증을 데코레이터 레벨에서 처리할 수 있다.
ParseFilePipe + 내장 Validator
import { AnyFilesInterceptor } from '@nestjs/platform-express';
@Post('upload-any')
@UseInterceptors(AnyFilesInterceptor())
uploadAny(@UploadedFiles() files: Express.Multer.File[]) {
return files.map(f => ({ field: f.fieldname, name: f.originalname }));
}
MaxFileSizeValidator: maxSize를 바이트 단위로 지정한다. 초과하면 413 Payload Too Large 에러가 발생한다. 커스텀 에러 메시지를 message 옵션으로 지정할 수도 있다.
import {
UploadedFile,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5MB
new FileTypeValidator({ fileType: /image\/(png|jpg|jpeg|webp)/ }),
],
}),
)
file: Express.Multer.File,
) {
return { filename: file.originalname };
}
FileTypeValidator: MIME 타입을 문자열이나 정규식으로 지정한다. 주의할 점은 이 검증이 기본적으로 클라이언트가 보낸 Content-Type 헤더를 기반으로 한다는 것이다. 클라이언트가 Content-Type을 조작하면 우회할 수 있다.
보안이 중요한 경우 파일의 실제 바이너리 데이터(매직 넘버)를 검사해야 하는데, file-type 같은 라이브러리로 매직 넘버 검증을 직접 구현하거나 커스텀 Validator를 만들어야 한다.
커스텀 Validator
내장 Validator로 부족하면 FileValidator를 상속해서 직접 만들 수 있다.
new MaxFileSizeValidator({
maxSize: 10 * 1024 * 1024,
message: '파일 크기는 10MB를 초과할 수 없습니다.',
})
import { FileValidator } from '@nestjs/common';
export class ImageDimensionValidator extends FileValidator<{ maxWidth: number; maxHeight: number }> {
constructor(options: { maxWidth: number; maxHeight: number }) {
super(options);
}
isValid(file: Express.Multer.File): boolean | Promise<boolean> {
// sharp 등의 라이브러리로 이미지 크기 검사
// 실제 구현에서는 비동기 처리가 필요
return true;
}
buildErrorMessage(): string {
return `이미지 크기는 ${this.validationOptions.maxWidth}x${this.validationOptions.maxHeight}px 이하여야 합니다.`;
}
}
파일 선택 사항 처리
파일이 필수가 아닌 경우 fileIsRequired: false를 설정한다. 이렇게 하면 파일 없이 요청이 와도 에러가 발생하지 않는다.
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
new ImageDimensionValidator({ maxWidth: 2000, maxHeight: 2000 }),
],
}),
)
스토리지 설정
Multer의 스토리지는 파일을 어디에 저장할지 결정한다. 크게 메모리 스토리지와 디스크 스토리지 두 가지가 있다.
메모리 스토리지 (기본값)
파일이 Node.js의 Buffer로 메모리에 저장된다. 별도 설정 없이 FileInterceptor를 사용하면 메모리 스토리지가 적용된다.
장점은 파일 시스템에 임시 파일을 만들지 않기 때문에 처리가 빠르고, 파일을 S3 같은 외부 스토리지에 바로 업로드할 때 편리하다는 것이다. 단점은 대용량 파일이 여러 개 동시에 업로드되면 메모리가 빠르게 소진된다는 것이다.
new ParseFilePipe({
fileIsRequired: false,
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
],
})
디스크 스토리지
파일을 로컬 파일 시스템에 직접 저장한다. 대용량 파일을 다루거나, 서버의 파일 시스템을 그대로 저장소로 사용하는 경우에 적합하다.
// 메모리 스토리지 (기본값이므로 별도 설정 불필요)
@UseInterceptors(FileInterceptor('file'))
destination은 저장 디렉토리, filename은 저장할 파일명을 결정하는 콜백이다. 원본 파일명을 그대로 사용하면 파일명 충돌이나 보안 문제가 생길 수 있으므로, UUID 같은 유니크한 이름으로 바꾸는 게 일반적이다.
디스크 스토리지를 사용하면 file.buffer는 undefined가 되고, 대신 file.path, file.destination, file.filename이 채워진다.
파일 크기 제한 (Multer 레벨)
ParseFilePipe의 MaxFileSizeValidator와는 별개로, Multer 자체에서도 파일 크기를 제한할 수 있다. 이 방식은 파일이 완전히 업로드되기 전에 스트리밍 단계에서 차단하므로 더 효율적이다.
import { diskStorage } from 'multer';
import { extname } from 'path';
import { v4 as uuid } from 'uuid';
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: './uploads',
filename: (req, file, callback) => {
const ext = extname(file.originalname);
const filename = `${uuid()}${ext}`;
callback(null, filename);
},
}),
}),
)
ParseFilePipe는 파일이 메모리에 완전히 올라간 후에 검증하지만, Multer의 limits는 업로드 중에 제한을 건다. 대용량 파일을 차단할 때는 limits가 더 적절하다.
실전 파일 업로드 서비스 구현
실제 프로덕션에서는 단순히 파일을 받는 것 이상으로, 파일 메타데이터를 DB에 저장하고, 서빙 URL을 생성하고, 삭제 처리까지 해야 한다.
서비스 레이어 설계
@UseInterceptors(
FileInterceptor('file', {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 5, // 최대 파일 수
},
}),
)
핵심 포인트를 살펴보면:
-
날짜별 디렉토리 분리: 모든 파일을 하나의 폴더에 넣으면 파일 수가 많아졌을 때 파일 시스템 성능이 저하된다. 날짜별로 나누면 이 문제를 완화할 수 있다.
-
UUID 파일명: 원본 파일명은 DB에 저장하되, 실제 저장 시에는 UUID를 사용한다. 파일명 충돌을 방지하고, 원본 파일명에 포함된 특수문자나 한글로 인한 문제도 피할 수 있다.
-
삭제 시 try-catch: 파일이 이미 삭제된 상태에서
unlink를 호출하면 에러가 발생한다. 파일 시스템의 상태와 DB의 상태가 항상 일치하지 않을 수 있으므로, 파일 삭제 실패 시에도 DB 레코드는 정리해야 한다.
정적 파일 서빙
저장된 파일을 HTTP로 서빙하려면 NestJS의 정적 파일 서빙 기능을 설정해야 한다.
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs/promises';
import * as path from 'path';
import { v4 as uuid } from 'uuid';
import { FileEntity } from './file.entity';
@Injectable()
export class FileService {
private readonly basePath = '/app/uploads';
constructor(
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
) {}
async upload(file: Express.Multer.File, userId: number) {
// 날짜별 디렉토리 생성
const today = new Date().toISOString().split('T')[0];
const targetDir = path.join(this.basePath, today);
await fs.mkdir(targetDir, { recursive: true });
// UUID 기반 파일명으로 저장
const ext = path.extname(file.originalname);
const filename = `${uuid()}${ext}`;
const filePath = path.join(targetDir, filename);
await fs.writeFile(filePath, file.buffer);
// DB에 메타데이터 저장
const saved = await this.fileRepository.save({
originalName: file.originalname,
mimetype: file.mimetype,
size: file.size,
path: filePath,
userId,
});
return {
id: saved.id,
url: this.generateAccessUrl(filePath),
};
}
async delete(fileId: number) {
const file = await this.fileRepository.findOne({ where: { id: fileId } });
if (!file) throw new NotFoundException('파일을 찾을 수 없습니다.');
// 파일 시스템에서 삭제
try {
await fs.access(file.path);
await fs.unlink(file.path);
} catch {
// 파일이 이미 없어도 DB 레코드는 삭제
}
await this.fileRepository.delete(fileId);
}
private generateAccessUrl(filePath: string): string {
return filePath.replace(this.basePath, '/files');
}
}
이렇게 하면 /app/uploads/2025-06-27/abc123.jpg 파일이 /files/2025-06-27/abc123.jpg URL로 접근 가능해진다.
메모리 스토리지 vs 디스크 스토리지 비교
어떤 스토리지를 선택할지는 파일을 어디에 최종 저장하느냐에 달려있다.
| 기준 | 메모리 스토리지 | 디스크 스토리지 |
|---|---|---|
| 파일 접근 | file.buffer (Buffer) | file.path (파일 경로) |
| 메모리 사용 | 파일 크기만큼 메모리 점유 | 최소한의 메모리만 사용 |
| 적합한 경우 | S3 등 외부 업로드, 작은 파일 | 로컬 저장, 대용량 파일 |
| 임시 파일 | 생성하지 않음 | 디스크에 직접 저장 |
| 처리 속도 | 빠름 (I/O 없음) | 디스크 I/O 발생 |
S3나 GCS 같은 클라우드 스토리지를 쓴다면 메모리 스토리지로 받아서 바로 업로드하는 게 효율적이다. 로컬 파일 시스템에 저장한다면 디스크 스토리지가 메모리 효율 측면에서 유리하다.
Multer 옵션 요약
FileInterceptor의 두 번째 인자로 전달하는 MulterOptions의 주요 옵션들을 정리하면 다음과 같다.
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: '/app/uploads',
serveRoot: '/files',
}),
],
})
export class AppModule {}
fileFilter는 ParseFilePipe보다 먼저 실행되는 필터링 레이어다. 파일을 메모리에 올리기 전에 거부할 수 있어서 리소스 절약에 도움이 된다.
interface MulterOptions {
storage?: StorageEngine; // 메모리 or 디스크 스토리지
limits?: {
fieldNameSize?: number; // 필드명 최대 바이트 (기본 100)
fieldSize?: number; // 필드 값 최대 바이트 (기본 1MB)
fields?: number; // 비파일 필드 최대 수 (기본 무제한)
fileSize?: number; // 파일 최대 바이트 (기본 무제한)
files?: number; // 파일 최대 수 (기본 무제한)
parts?: number; // 파트(필드+파일) 최대 수 (기본 무제한)
headerPairs?: number; // 헤더 키-값 쌍 최대 수 (기본 2000)
};
fileFilter?: (
req: Request,
file: Express.Multer.File,
callback: (error: Error | null, acceptFile: boolean) => void,
) => void; // 파일 필터링 함수
}
주의사항
MIME 타입을 맹신하지 말 것
클라이언트가 보내는 Content-Type은 조작 가능하다. .exe 파일을 image/jpeg로 위장해서 보낼 수 있다. 보안이 중요한 서비스라면 파일의 매직 넘버(파일 시작 부분의 바이트 시그니처)를 검사하는 file-type 라이브러리를 사용해야 한다.
@UseInterceptors(
FileInterceptor('file', {
fileFilter: (req, file, callback) => {
if (!file.mimetype.match(/image\/(png|jpg|jpeg|webp)/)) {
callback(new BadRequestException('이미지 파일만 업로드할 수 있습니다.'), false);
return;
}
callback(null, true);
},
limits: { fileSize: 5 * 1024 * 1024 },
}),
)
원본 파일명 그대로 사용하지 말 것
사용자가 보내는 파일명에는 ../, 특수문자, 매우 긴 이름 등이 포함될 수 있다. 경로 탐색 공격(Path Traversal)의 위험이 있으므로, 서버에서 UUID 등으로 파일명을 재생성하고 원본 이름은 DB에만 기록하는 것이 안전하다.
업로드 디렉토리 권한 확인
디스크 스토리지를 사용할 때, Node.js 프로세스가 해당 디렉토리에 쓰기 권한이 있는지 확인해야 한다. Docker 환경에서는 볼륨 마운트 시 권한 문제가 자주 발생한다.
동시 업로드와 메모리
메모리 스토리지를 사용하면서 동시에 여러 사용자가 대용량 파일을 업로드하면 Node.js 프로세스의 메모리가 급격히 증가한다. limits.fileSize를 반드시 설정하고, 프로덕션에서는 Nginx 등 리버스 프록시 레벨에서도 client_max_body_size를 설정하는 것이 좋다.
정리
- FileInterceptor/FilesInterceptor/FileFieldsInterceptor로 단일/복수/다중 필드 업로드를 처리하고, ParseFilePipe로 크기와 타입을 선언적으로 검증한다
- 메모리 스토리지는 S3 직접 업로드에, 디스크 스토리지는 로컬 저장+대용량에 적합하며, Multer limits는 스트리밍 단계에서 차단하므로 ParseFilePipe보다 먼저 걸어야 한다
- MIME 타입 조작과 Path Traversal에 대비해 매직 넘버 검사와 UUID 파일명을 사용하고, Nginx client_max_body_size도 함께 설정한다
관련 문서
- NestJS Interceptor - 인터셉터의 동작 원리
- NestJS Pipes and Validation - ParseFilePipe의 기반이 되는 파이프 시스템
- S3 Presigned URL - 클라우드 스토리지 직접 업로드 패턴