Nestjs로 이미지파일 업로드 구현하기

·2024년 3월 24일
0

nestjs

목록 보기
8/10

사이드 프로젝트에서 프로필 사진 업로드를 구현해야해서 파일 업로드를 구현했습니다. 그 과정을 공유합니다.
구현 및 글 작성 날짜는 2024.03.24~03.31입니다.

가장 많은 참조: https://jisu-log.tistory.com/11
https://velog.io/@dev_leewoooo/NestJs-%ED%8C%8C%EC%9D%BC%EC%97%85%EB%A1%9C%EB%93%9C-%EC%9D%B4-%EA%B8%80%EB%A1%9C-%EB%81%9D

1. 파일 업로드 받는 API 컨트롤러 작성

nestjs에서는 multer 라이브러리로 파일 업로드를 지원하고 있습니다.
타입스크립트 타입지정을 위해 아래 multer의 타입 라이브러리를 설치해줍니다.

$ npm i -D @types/multer (from https://docs.nestjs.com/techniques/file-upload#basic-example)
저는 이번 사이드프로젝트에서 pnpm을 사용 중이라 아래 처럼 추가했습니다.

pnpm add -D @types/multer

"@types/multer": "^1.4.11" (package.json)


(24.03.31 추가) 저같은 경우 패키지매니저가 pnpm이라 그런지
테스트환경(start:dev)까진 잘 됐지만 프로덕션 start:prod (빌드 후 main.js 실행)시
Error: Cannot find module 'multer' 에러가 나서
pnpm add multer로 multer 패키지도 추가해주었습니다.
"multer": "1.4.5-lts.1" (package.json)

원하는 nestjs 컨트롤러에 @UseInterceptors 데커레이터와 FileInterceptor를 아래처럼 추가합니다.
인자로 준 'image'는 API 요청시 파일을 받을 body 파라미터명입니다.

import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFile,
} from '@nestjs/common';

@Controller('writer')
export class WriterController {
  @Post('/photo')
  @UseInterceptors(FileInterceptor('image'))
  async uploadPhoto(
    @UploadedFile() file: Express.Multer.File,
  ): Promise<boolean> {
    console.log(file);
    return true;
  }
}

2. 파일 업로드 잘 되는지 테스트해보기(feat. insomnia)

파일이 정말 서버로 업로드 잘 되는지 확인해봐야겠죠? postman, insomnia 등을 쓰는데 이번 프로젝트는 insomnia를 사용하고 있습니다. 로컬에서 서버를 켜준 뒤(pnpm start:dev), localhost:3000 에 컨트롤러에 작성한 URL로 파일을 첨부해 요청합니다.

Body의 type을 Multipart form으로 선택해줍니다.

name에 위에서 FileInterceptor에 지정했던 이름인 'image'를 넣어주고,
value에 우측 아래로 향하는 화살표(체크박스 왼쪽에 보이는)를 눌러 맨 아래 File을 누른 뒤 파일을 첨부합니다.
(처음에 unexpected end of contents같은 에러가 떴었는데 화살표 눌러서 File로 바꾸지 않고 value창을 클릭한 뒤 이미지파일을 첨부해서 그런 거였습니다. 바꾸고나서도 한번 더 에러가 났는데 한번 껐다켜고 다시 하니까 잘 동작하네요.)

이렇게 해서 이미지파일을 바디에 넣고 요청하니 위 코드에 작성해둔 console.log에 이런 식으로 찍히면서 파일이 제대로 서버로 들어오는 게 확인되었습니다.

3. 업로드 받은 파일 검증 및 서버 내 저장하기

  1. 서비스가 커지면 S3 등으로 연결해야겠지만 일단은 MVP이므로 애플리케이션이 구동되고 있는 서버에 저장하도록 구현해봤습니다.
  2. 이미지 파일이 아니거나 용량이 너무 크면 검사해서 error를 주고 서버에는 저장되지 않게 처리하였습니다. @UploadedFile() 데커레이터에서 pipe로 검사하는 방법도 있지만, 위 글 에서 잘 설명해주셔서 그렇게 할 경우 파일을 이미 저장한 뒤에 검사를 하기 때문에 파일을 다시 지우는 것까지 구현해야하는 문제가 있다는 걸 알게 되었고 저도 FileInterceptor에서 구현하였습니다.
import {  
  Post,
  InternalServerErrorException,
  UnsupportedMediaTypeException,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';
import {
  ApiBody,
  ApiConsumes,
  ApiOperation,
  ApiPayloadTooLargeResponse,
  ApiResponse,
  ApiTags,
  ApiUnsupportedMediaTypeResponse,
} from '@nestjs/swagger';
import { FILE_SIZE, PUBLIC_PROFILE_IMAGE_PATH } from '../../common/constants';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage, FileFilterCallback } from 'multer';

@Controller('writer')
export class WriterController {
  
  @Post('/photo')
  @ApiOperation({
    summary: '저자 사진 등록',
    description: '저자 사진을 등록합니다.',
  })
  @ApiConsumes('multipart/form-data')
  @ApiBody({
    schema: {
      type: 'object',
      properties: { image: { type: 'string', format: 'binary' } },
    },
  })
  @ApiResponse({
    status: 200,
    description: 'success',
    type: String,
  })
  @ApiResponse({ status: 400, description: '존재하지 않는 유저입니다.' })
  @ApiUnsupportedMediaTypeResponse({
    description: 'gif, jpeg, png 형식의 파일이 아닌 경우',
  })
  @ApiPayloadTooLargeResponse({ description: '이미지 용량 초과' })
  @UseInterceptors(
    FileInterceptor('image', {
      fileFilter: (req: Request, file, callback: FileFilterCallback) => {
        // 여기서 파일사이즈 체크 및 exception도 처리하려고 했으나 console.log찍어보니 file object에 size값이 없어 limits로 처리
        if (!file.mimetype.match(/image\/(gif|jpeg|png)/)) {
          return callback(
            new UnsupportedMediaTypeException(
              'gif, jpeg, png 형식의 파일만 업로드 가능합니다.',
            ),
          );
        }
        callback(null, true);
      },
      storage: diskStorage({
        destination: 'public/assets/test', // 상수화 처리 추천(단 맨 앞에 / 를 붙이면 안됨 주의)
        filename: (req, file, callback) => {
          const extArray = file.mimetype.split('/');
          // 저는 파일명을 DB에 저장하고 불러오는 방식으로 처리할 예정이라 랜덤문자열로 저장하였습니다.
          const randomString = Math.random().toString(36).substring(2, 12);
          callback(null, randomString + '.' + extArray[extArray.length - 1]);
        },
      }),
      limits: { fileSize: FILE_SIZE.FOUR_MB }, // 상수값 = 4 * 1024 * 1024
    }),
  )
  async uploadPhoto(
    @AuthAdmin() user: AuthUserDto,
    @UploadedFile() image: Express.Multer.File,
  ): Promise<string> {
    if (image === undefined) {
      throw new InternalServerErrorException('이미지 업로드에 실패했습니다.');
    }
    return image.filename;
  }
  
}
profile
백엔드 개발자. 공동의 목표를 함께 이해한 상태에서 솔직하게 소통하며 일하는 게 가장 즐겁고 효율적이라고 믿는 사람.

0개의 댓글