사이드 프로젝트에서 프로필 사진 업로드를 구현해야해서 파일 업로드를 구현했습니다. 그 과정을 공유합니다.
구현 및 글 작성 날짜는 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
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;
}
}
파일이 정말 서버로 업로드 잘 되는지 확인해봐야겠죠? 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에 이런 식으로 찍히면서 파일이 제대로 서버로 들어오는 게 확인되었습니다.
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;
}
}