kakasoo

NestJS에서 S3로 이미지 업로드하기 본문

프로그래밍/NestJS

NestJS에서 S3로 이미지 업로드하기

카카수(kakasoo) 2023. 1. 31. 23:35
반응형

설치해야 하는 패키지 종류

$ npm i aws-sdk/client-s3 # 3.259.0
$ npm i multer # ^1.4.3"
$ npm i multer-s3 # ^3.0.1

$ npm i @types/multer-s3 --save-dev # ^.3.0.0

일단 필요한 package들을 install한다.

s3 package들의 경우 업데이트를 하면서 기존의 버전이 호환되지 않는 경우가 줄곧 발생한다.

혹시 몰라 버전도 주석으로 달아놓았으니 호환이 되지 않으면 버전을 맞추던가, 아니면 명시된 버전으로 진행하면 된다.

 

NestJS에서 클라이언트로부터 이미지 받기

@Post()
async upload(@UploadedFiles() files: Express.MulterS3.File[]) {
  if (!files?.length) {
      throw new BadRequestException(ERROR.SELECT_MORE_THAN_ONE_BODY_IMAGE);
  }
  return files.map(({ location }) => location);
}

일단 API의 형태부터 확인하자.

아직 모두 완성된 형태는 아니지만, 우선 Post() 메서드이며, body는 multi-part 형태로 전달된다고 해보자.

그러면 @UploadedFile() 또는 @UploadedFiles() 데코레이터를 통해서 파일을 request로부터 뽑아올 수 있다.

물론 AuthModule에서 전략을 세우듯이, request에서 file, 또는 files를 꺼내기 위해서는 또 다른 데코레이터들이 필요하다.

 

@UseInterceptors(FilesInterceptor('file', 10, CreateBodyImageMulterOptions()))
async upload(@UploadedFiles() files: Express.MulterS3.File[]) {}

이 코드의 UseInterceptors는 FileInterceptor를 해당 API에 적용하는 역할을 한다.
FileInterceptor의 각 파라미터 역할은 클라이언트로부터 이미지를 받을 키 즉, 프로퍼티 네임과 최대 숫자, 그리고 options이다.
여기까지는 NestJS 공식 문서에서 설명하고 있는 내용이기 때문에 @nestjs/common 패키지의 코드다.
이제, 사용자는 어떻게, 그리고 어떤 이미지를 받을 것인지를 옵션에 명시해주어야 한다.

 

빌더 패턴을 이용해서 필요한 옵션들을 그 때 그 때 생성하기

// image.builder.ts

import multerS3 from 'multer-s3';
import { Request } from 'express';
import { S3Client } from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';

export const imageMimeTypes = ['image/jpg', 'image/jpeg', 'image/png', 'image/bmp', 'image/webp'];
export const mediaMimeTypes = ['video/mp4'];

export class MulterBuilder {
  private readonly s3: S3Client;
  private readonly bucketName: string;
  private readonly allowedMimeTypes: Array<string> = [];

  private resource = '';
  private path = '';

  constructor(private readonly configService: ConfigService) {
    this.s3 = new S3Client({
      region: this.configService.get('AWS_BUCKET_REGION'),
      credentials: {
        accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'),
        secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'),
      },
    });
    this.bucketName = configService.get('AWS_BUCKET_NAME');
  }

  allowImageMimeTypes() {
    this.allowedMimeTypes.push(...imageMimeTypes);
    return this;
  }

  allowMediaMimeTypes() {
    this.allowedMimeTypes.push(...mediaMimeTypes);
    return this;
  }

  setResource(keyword: string) {
    this.resource = keyword;
    return this;
  }

  setPath(path: string) {
    this.path = path;
    return this;
  }

  build() {
    return multerS3({
      s3: this.s3,
      bucket: this.bucketName,
      contentType: multerS3.AUTO_CONTENT_TYPE,
      key: (req: Request, file, callback) => {
        let filename;
        const splitedFileNames = file.originalname.split('.');
        const extension = splitedFileNames.at(splitedFileNames.length - 1);
        if (this.path) {
          filename = `${this.path}/${new Date().getTime()}.${extension}`;
        } else {
          filename = `${new Date().getTime()}.${extension}`;
        }

        return callback(null, encodeURI(`${this.resource}/${filename}`));
      },
    });
  }
}

이미지는 프로필일 수도, 상품의 이미지, 리뷰의 이미지 등 여러 종류가 있을 수 있고, 저장되는 규칙과 위치가 모두 다를 것이다.

그 규칙에 맞게 옵션을 설계하면 시간이 많이 들 뿐더러 실수가 발생할 수 있다.

따라서 이걸 함수 형태로 빼내는 것이 가장 적당할 텐데, 이런 식의 코드 생성은 빌더 패턴으로 간략하게 정리할 수 있다.

빌더 패턴은 사용자에게 정보를 받을 수 있는 메서드들과, 최종적으로 실행할 build() 메서드로 이루어진다.

나 같은 경우에는 이미지와 영상 미디어를 구분하기 위해 allow 메서드들과, 경로를 저장하기 위한 set 메서드들을 정의했다.

최종적으로 build() 메서드는 생성자에 정의된 내용들을 토대로, 이미지를 저장하기 위한 옵션을 반환한다.

 

23.02.12. 위의 configService는 데코레이터에서는 줄 수 없기 때문에 process.env 로 변경했다.

 

 

아래는 빌더 패턴을 이용해 옵션을 생성하는 예시들이다.

 

// multer-options.ts

import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import multer from 'multer';
import { imageMimeTypes, mediaMimeTypes, MulterBuilder } from './multer.builder';

export const fileFilter = (kind: 'image' | 'media') => (req: any, file: any, cb: any) => {
  const types = kind === 'image' ? imageMimeTypes : mediaMimeTypes;
  const mimeType = types.find((im) => im === file.mimetype);
  if (!mimeType) {
    cb(new BadRequestException(`${types.join(', ')}만 저장할 수 있습니다.`), false);
  }

  if (kind === 'media') {
    file.originalname = `${new Date().getTime()}`;
  }

  return cb(null, true);
};

export const CreateProfileImageMulterOptions = (configService: ConfigService): multer.Options => {
  return {
    fileFilter: fileFilter('image'),
    storage: new MulterBuilder(configService).allowImageMimeTypes().setResource('user').setPath('profile').build(),
    limits: { fileSize: 1024 * 1024 * 20 },
  };
};

export const CreateBodyImageMulterOptions = (): multer.Options => {
  return {
    fileFilter: fileFilter('image'),
    storage: new MulterBuilder().allowImageMimeTypes().setResource('article').setPath('body-image').build(),
    limits: { fileSize: 1024 * 1024 * 20 },
  };
};

하나는 프로필 이미지를 저장하기 위해 user/profile/filename 형식으로 저장할 거라는 의미를 가진다.

두번째는 게시글의 본문 이미지를 저장하기 위한 예시다.

사용자가 내부 코드를 이해할 필요 없게 만드는 것은, 내가 미래에도 코드를 기억할 소요를 줄이는 데 도움을 준다.

반응형