NestJS 파일 업로드 Multer·S3 심화

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 간 전환도 코드 변경 없이 가능합니다. 파일 업로드는 공격 표면이 넓으므로, 보안 체크리스트를 빠짐없이 적용하는 것이 핵심입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux