본문 바로가기
Node.js

[NestJS] 파일 업로드(File upload) Multer

by WhoamixZerOne 2023. 7. 25.

 

✔ 필요한 종속성 설치

Nest는 Express용 multer 미들웨어 패키지를 기반으로 하는 내장 모듈을 제공한다.

$ npm i -D @types/multer

 

✔ 단일 파일 업로드

단일 파일을 업로드하려면 FileInterceptor()를 @UseInterceptors에 연결하고 @UploadedFile() 데코레이터를 사용하여 요청에서 file를 추출하면 된다.

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
  console.log(file);
}

FileInterceptor()는 2개의 인수를 가진다.

  • fieldName : multipart/form-data에서 name에 해당하는 값(HTML 양식에서 필드 이름을 제공하는 문자열)
  • options : 선택적 객체이고, multer 생성자가 사용하는 것과 동일한 객체로 MulterOptions에 해당(자세한 내용은 MulterOptions 참고)

 

✔ 다중 파일 업로드

단일 파일 업로드와 다른 점은 거의 없다. 그저 사용하는 데코레이터의 명칭이 복수의 "s"가 붙는 점과 인수가 달라진다.

@Post('upload')
@UseInterceptors(FilesInterceptor('files'))
uploadFile(@UploadedFiles() files: Array<Express.Multer.File>) {
  console.log(files);
}

FilesInterceptor()는 3개의 인수를 가진다.

  • fieldName : multipart/form-data에서 name에 해당하는 값(HTML 양식에서 필드 이름을 제공하는 문자열)
  • maxCount : 선택적 숫자이고, 허용할 최대 파일 수
  • options: 선택적 객체이고, multer 생성자가 사용하는 것과 동일한 객체로 MulterOptions에 해당(자세한 내용은 MulterOptions 참고)

 

✔ 다중 파일 업로드(필드 이름 사용)

필드 이름 키를 다르게 해서 다중 파일을 업로드할 수 있다.

FileFieldsInterceptor()를 @UseInterceptors에 연결하고 @UploadedFiles() 데코레이터를 사용하여 요청에서 file를 추출하면 된다.

@Post('upload')
@UseInterceptors(
  FileFieldsInterceptor([
    { name: 'movie', maxCount: 1 },
    { name: 'background', maxCount: 3 },
  ]),
)
uploadFile(
  @UploadedFiles()
  files: {
    movie?: Express.Multer.File[];
    background?: Express.Multer.File[];
  },
) {
  console.log(files);
}

FileFieldsInterceptor() 2개의 인수를 가진다.

  • uploadedFields name : multipart/form-data에서 name에 해당하는 값(HTML 양식에서 필드 이름을 제공하는 문자열)
  • options : 선택적 객체이고, multer 생성자가 사용하는 것과 동일한 객체로 MulterOptions에 해당(자세한 내용은 MulterOptions 참고)

 

✔ Multer 옵션

export interface MulterOptions {
    dest?: string;    
    storage?: any;    
    limits?: {        
        fieldNameSize?: number;
        fieldSize?: number;       
        fields?: number;
        fileSize?: number;        
        files?: number;
        parts?: number;
        headerPairs?: number;
    };    
    preservePath?: boolean;
    fileFilter?(req: any, file: {
        fieldname: string;
        originalname: string;
        encoding: string;
        mimetype: string;
        size: number;
        destination: string;
        filename: string;
        path: string;        
        buffer: Buffer;
    }, callback: (error: Error | null, acceptFile: boolean) => void): void;
}
Key Description
dest or storage 파일을 저장할 위치
fileFilter 허용되는 파일을 제어하는 함수
limits 업로드되는 데이터의 제한
preservePath 기본 이름 대신 파일의 전체 경로 유지

 

✔ 파일 검증

@Post('upload')
@UseInterceptors(FilesInterceptor('files', 10, multerOptions('posts')))
uploadFiles(
  @UploadedFiles(
    new ParseFilePipe({
      validators: [
        new FileTypeValidator({
          fileType: /image\/(jpg|jpeg|png)$/,
        }),
      ],
    }),
  )
  files: Array<Express.Multer.File>,
): {
  url: string[];
} {
  console.log(files);
  let urlPath = [];
  for (const file of files) {
    console.log(file.path);
    urlPath = [...urlPath, file.path];
  }
  return { url: urlPath };
}

multerOptions에 diskStorage로 구현하였고, 파일을 이미지 jpg, jpeg, png만 업로드할 수 있도록 구현을 하였다.

하지만 문제는 2가지가 있었다...

  1. FileTypeValidator 에러 핸들링(역시나 응답 에러 메시지가 영어로 내려온다)
  2. jpg, jpeg, png가 아니어도 업로드가 되고 에러 메시지가 내려온다

먼저 첫 번째 문제를 살펴보면 FileTypeValidator 클래스에서 검증은 객체가 있는지, 객체에 mimetype이 있는지, fileType은 문자열 혹은 정규식을 지정할 수 있는데 지정한 것이 mimetype에 있는지 match로 확인한다. validator 검증 자체는 잘 되는데 아쉬운 점은 지정한 타입이 아닐 때 영어로 내려온다는 점이다.

메시지를 변경하기 위해 FileValidator를 상속받는 클래스를 구현해 주고, FileTypeValidator 대신에 FileTypeValidatorPipe 클래스로 변경한다.

// common/validators/file-type.validator.ts
import { FileValidator } from '@nestjs/common';

export class FileTypeValidatorPipe extends FileValidator<{
  fileType: string | RegExp;
}> {
  constructor(
    protected readonly validationOptions: { fileType: string | RegExp },
  ) {
    super(validationOptions);
  }

  isValid(file?: any): boolean | Promise<boolean> {
    if (!this.validationOptions) {
      return true;
    }

    return (
      !!file &&
      'mimetype' in file &&
      !!file.mimetype.match(this.validationOptions.fileType)
    );
  }

  buildErrorMessage(file: any): string {
    return 'jpg, jpeg, png, gif 파일들만 업로드할 수 있습니다.';
  }
}

isValid() 함수에서 검증을 구현하면 된다. 아직은 검증 구현에 추가할 게 없어서 원래 코드로 구현하였고,

buildErrorMessage() 함수에서 에러 메시지를 알맞게 구현하면 된다.

그러면 지정한 타입이 아닐 시 변경한 에러 메시지가 제대로 내려오는 것을 볼 수 있다.

 

두 번째 문제는 실행 순서의 문제였다. 먼저 @UseInterceptors() 데코레이터에 FilesInterceptor()가 실행이 되면서 MulterOption에 지정한 경로로 파일이 업로드되고, 메서드 파라미터 @UploadedFiles() 데코레이터의 new FileTypeValidatorPipe()가 실행되면서 파일에 대한 검증을 한다. 이미지 파일이 아니라서 에러 메시지를 응답해 준다.

 

내가 원했던 것은 지정한 파일 타입이 아니면 업로드도 안되고 에러 메시지를 보내주는 것인데 FilesInterceptor가 먼저 실행이 되면서 업로드를 하고 검증을 하는 것이다.

 

위의 문제들을 아래의 구현 부분에서 다른 방법으로 구현했다.

 

✔ 구현

1. Module 등록

MulterModule.register({
  dest: './uploads',
});

 

 

기본 옵션인 dest에 './uploads'를 지정할 경우 소스 코드의 루트 경로인 src 하위에 uploads 폴더에 파일이 업로드된다.

uploads 폴더가 없을 시 자동으로 생성해 준다. 또한 파일 업로드 시 파일의 이름을 임의로 생성해서 저장이 된다.(확장자도 없다)

 

위의 방식으로 사용하면 컨트롤하기 힘들기 때문에 개인 프로젝트에서는 보통? storage를 사용한다.

storage는 파일을 저장하는 방식으로 2가지의 설정 방법이 존재한다.

 

  • DiskStorage
    • 파일을 디스크(하드 디스크)에 저장하는 방식
    • 업로드된 파일은 서버의 파일 시스템에 저장되고, 사용자가 지정한 디렉터리에 파일이 저장
    • 대용량 파일 및 지속적인 파일 저장에 적합
  • MemoryStorage
    • 파일을 메모리(RAM)에 저장하는 방식
    • 업로드된 파일은 메모리에 일시적으로 저장되고, 디스크에 저장되지 않는다
    • 서버가 재시작되거나 메모리가 비워지면 업로드된 파일은 사라진다(휘발성)
    • 작은 크기의 파일이나 임시적으로 파일을 사용하는 경우에 적합
※ dest: './uploads', multer.diskStorage일 때 uploads 파일이 서로 다른 곳에 생성되는 이유
그 이유는 프로젝트 빌드 및 실행 시점에서의 기준 경로가 다르기 때문이다.

dest: './uploads'는 "."는 현재 작업 디렉터리를 나타내며, 프로세스가 실행되는 디렉터리이다.
즉, 개발 모드에서 애플리케이션을 실행하면 작업 디렉터리는 프로젝트 루트 경로(src)가 되기 때문에 src/uploads에 생성된다.
반면 multer.diskStorage에서는 파일 경로를 서술할 때 path.join(__dirname, '../uploads')식으로 사용하는데
__dirname은 nodejs에서 현재 파일의 디렉터리를 나타낸다.

nestjs 프로젝트 파일 구조는 개발 시 소스 코드가 있는 "src" 디렉터리와 빌드 시 컴파일된 "dist" 디렉터리로 분리된다.
따라서 "dist" 디렉터리는 "src" 디렉터리에서 실행되는 애플리케이션 런타임 디렉터리가 된다.
그래서 __dirname이 가리키는 곳은 런타임 디렉터리인 "사용자의 프로젝트 경로/dist"가 돼서 dist/uploads가 만들어진다.
// products/products.module.ts
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { MulterModule } from '@nestjs/platform-express';
import { MulterConfigService } from '../config/multer.config';

@Module({
  imports: [
    MulterModule.registerAsync({
      useClass: MulterConfigService,
    }),    
  ],
  controllers: [ProductsController],
  providers: [ProductsService],
})
export class ProductsModule {}

registerAsync() 비동기를 사용하여 storage를 등록한다. MulterOption은 내용이 길어서 다른 파일에 따로 정의해 준다.

또, 비동기로 한 이유는 추후에 AWS S3에 파일을 업로드할 것이기 때문에 ConfigService 사용을 위해 쓴 이유기도 하다.

 

2. Multer 설정

// config/multer.config.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import {
  MulterModuleOptions,
  MulterOptionsFactory,
} from '@nestjs/platform-express';
import path from 'path';
import fs from 'fs';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import multer from 'multer';
import crypto from 'crypto';

@Injectable()
export class MulterConfigService implements MulterOptionsFactory {
  private readonly dirPath: string;

  constructor() {
    this.dirPath = path.join(__dirname, '..', 'uploads');
    this.mkdir();
  }

  mkdir(): void {
    try {
      fs.readdirSync(this.dirPath);
    } catch (err) {
      fs.mkdirSync(this.dirPath);
    }
  }

  diskStorage(dirPath: string): multer.StorageEngine {
    return multer.diskStorage({
      destination(
        req: Express.Request,
        file: Express.Multer.File,
        callback: (error: Error | null, destination: string) => void,
      ) {
        callback(null, dirPath);
      },
      filename(
        req: Express.Request,
        file: Express.Multer.File,
        callback: (error: Error | null, filename: string) => void,
      ) {
        const ext = path.extname(file.originalname);
        const fileName = `${crypto.randomUUID()}-${path.basename(
          file.originalname,
          ext,
        )}${ext}`;
        callback(null, fileName);
      },
    });
  }

  fileFilter(
    req: Express.Request,
    file: Express.Multer.File,
    callback: (error: Error | null, acceptFile: boolean) => void,
  ): void {
    const fileType: string | RegExp = /image\/(jpg|jpeg|png)$/;
    const isMimeType = file.mimetype.match(fileType);
    if (!isMimeType) {
      return callback(
        new BadRequestException(
          'jpg, jpeg, png 이미지 파일만 업로드 할 수 있습니다.',
        ),
        false,
      );
    }

    return callback(null, true);
  }

  createMulterOptions(): Promise<MulterModuleOptions> | MulterModuleOptions {
    const dirPath = this.dirPath;
    const options: MulterOptions = {
      storage: this.diskStorage(dirPath),
      fileFilter: this.fileFilter,
      limits: { fileSize: 10 * 1024 * 1024 },
    };

    return options;
  }
}

생성자에서 폴더 위치를 변수에 지정하고, mkdir() 함수에서 해당 폴더가 있는지 확인하고 없으면 폴더를 만들어 준다.

 

MulterOptionsFactory 상속받아 createMulterOptions() 함수에서 MulterOptions을 만들어 준다.

diskStorage() 함수의 destination()에서 업로드 파일 위치를 설정해 주고, filename()에서 업로드한 파일의 파일명을 만들어 준다.

그리고 위에서 얘기했던 검증을 fileFilter()에서 검증 처리를 해준다. 파일 크기는 10MB로 지정했다.

이제 지정한 파일 타입이 아닌 다른 파일이면 업로드되지 않고 에러 메시지도 지정한 걸로 응답한다.

 

또 한 가지 아쉬운 점은 파일 크기 검증하는 부분이 없어서 아쉬웠다.

위에서 얘기했던 new FileTypeValidator()에 "new MaxFileSizeValidator({ maxSize: 1000 })" 넣을 수 있지만, 결국 또 사용자 메시지가 처리에서 문제가 생길 것이다.

 

파일 크기 에러가 발생하면 상태 값 413, 에러 메시지 "Payload Too Large"가 나온다.

그래서 현재는 ExceptionFilter 처리에서 상태 값이 413일 때 조건문으로 처리를 하였다.

// common/filters/http-exception.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();
    const status = exception.getStatus();
    const error = exception.getResponse() as
      | string
      | { error: string; statusCode: number; message: string | string[] };

    if (status === 413) {
      response.status(status).json({
        success: false,
        error: 'Payload Too Large',
        message: '10MB 이하 크기의 파일만 가능합니다.',
        statusCode: 413,
      });
    }
    ...
  }
}

 

3. Controller

// products/products.controller.ts
import {
  Controller,
  Post,
  UploadedFiles,
  UseInterceptors,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { Authorization } from '../common/decorators/roles.decorator';
import { UsersRole } from '../users/user-role.enum';
import { FilesInterceptor } from '@nestjs/platform-express';

@Controller('products')
export class ProductsController {
  constructor(private readonly productsService: ProductsService) {}

  @Post('upload')
  @Authorization([UsersRole.ADMIN])
  @UseInterceptors(FilesInterceptor('files'))
  uploadFile(@UploadedFiles() files: Array<Express.Multer.File>): {
    url: string[];
  } {
    let urlPath = [];
    for (const file of files) {
      urlPath = [...urlPath, file.path];
    }

    return { url: urlPath };
  }
}

@Authorization() 데코레이터는 커스텀 데코레이터이며 기능은 권한 확인과 Jwt 토큰 인증을 확인한다.

그리고 uploadFile() 메서드에서 업로드 한 파일의 경로들을 하나의 배열에 넣고 응답으로 보낸다.

 

이렇게 파일 업로드의 구현을 마쳤다. 추후에 AWS S3에 파일 업로드를 구현하고 글을 작성할 예정이다.

 

파일 업로드하는 방법을 알아보다가 AWS S3에 파일 업로드는 백엔드에서 처리하지 않고 클라이언트에서 업로드를 해서

url를 다른 데이터와 같이 요청 본문에 넣어서 보낸다고 하는 것을 보았다. 클라이언트에서 처리하면 리소스도 낭비하지 않고 미리 파일을 업로드해서 시간적으로 좀 더 빠르게 처리가 된다고 한다.

 

 

🔗 Reference

댓글