NestJS 파일 업로드·S3 연동

NestJS 파일 업로드 기본 구조

NestJS는 내부적으로 Multer를 사용하여 multipart/form-data 요청을 처리합니다. @UseInterceptorsFileInterceptor를 조합하여 단일/다중 파일 업로드를 구현하고, 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로 업로드 요청을 제한하면 안정적인 파일 서비스가 완성됩니다.

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