자바스크립트 진영에서는 이미지를 Multer를 이용해서 쉽게 풀어나갑니다. 따라서 Nest.js에서도 Multer 라이브러리를 사용해서 파일 업로드를 구현하겠습니다. 먼재 4개의 페키지multer @types/multer uuid @types/uuid
를 설치하겠습니다.
// multer + multer의 타입 definition을 제공해주는 타입스크립트 파일 추가
yarn add multer @types/multer uuid @types/uuid
@Entity()
export class PostsModel extends BaseModel {
@ManyToOne(() => UsersModel, (user) => user.posts, {
nullable: false,
})
author: UsersModel;
@Column()
@IsString({
message: stringValidationMessage
})
title: string;
@Column()
@IsString({
message: stringValidationMessage
})
content: string;
@Column({
nullable: true,
})
image?: string; // 추가(null 일수도 있고 아닐수도 있다!)
@Column()
likeCount: number;
@Column()
commentCount: number;
}
posts.module.ts에 MulterModule을 등록하겠습니다.
import { MulterModule } from '@nestjs/platform-express';
@Module({
imports: [
TypeOrmModule.forFeature([
PostsModel,
]),
AuthModule,
UsersModule,
CommonModule,
MulterModule.register({ // 파일을 다룰 떄 여러가지 옵션 기능
limits: {
fileSize: 10000000, // 바이트 단위로 입력
},
fileFilter: (req, file, cb) => { // req: 요청 | file: req에 들어있는 파일
/**
* cb(error, boolean)
*
* 첫번째 파라미터에는 에러가 있을경우 에러정보를 넣어준다.
* 두번쨰 파라미터는 파일을 받을지 말지 boolean을 넣어준다.
*/
}
}),
],
controllers: [PostsController],
providers: [PostsService],
exports: [PostsService]
})
export class PostsModule {}
jpg, jpeg, png
만 받을 수 있도록 fileFilter
를 추가하겠습니다.
MulterModule.register({
limits: {
fileSize: 10000000,
},
// 추가
fileFilter: (req, file, cb) => {
const extension = extname(file.originalname); // asdasd.jpg -> .jpg
if (extension !== '.jpg' && extension !== '.jpeg' && extension !== '.png') {
return cb(new BadRequestException('jpg/jpeg/png 파일만 없로드 가능합니다. '), false); // cb(에러, boolean)
}
return cb(null, true); // 파일을 받는다.
},
저장소(폴더) storage
경로를 추가하겠습니다. storage
에서의 cb
는 2가지 인수로 받는데 error와 definition
을 받습니다. fileFilter
는 error과 acceptFile
을 받습니다.
import * as multer from 'multer';
.
.
MulterModule.register({
limits: {
fileSize: 10000000,
},
fileFilter: (req, file, cb) => {
const extension = extname(file.originalname);
if (extension !== '.jpg' && extension !== '.jpeg' && extension !== '.png') {
return cb(new BadRequestException('jpg/jpeg/png 파일만 없로드 가능합니다. '), false);
}
return cb(null, true);
},
storage: multer.diskStorage({
// 파일 저장시 어디로 이동 시킬까?
destination: function(req, res, cb) {
cb(null, 경로)
},
}),
}),
경로를 작성하기에 앞서, 저희는 앞으로 여러 파일과 이미지를 많이 사용할 것이기 때문에, common.const에다가 각각의 path를 정리하겠습니다.
// 서버 프로젝트의 루트 폴더
export const PROJECT_ROOT_PATH = process.cwd(); // current working directory 약자
// 외부에서 접근 가능한 파일들을 모아둔 폴더 이름
export const PUBLIC_FOLDER_NAME = 'public';
그리고 public 폴더를 생성해주고, 계속해서 추가를 하겠습니다.
import { join } from "path";
// /Users/asdqewdsadad/asdasd/bfgb/DF_SNS_2
export const PROJECT_ROOT_PATH = process.cwd(); // 서버 프로젝트의 루트 폴더
export const PUBLIC_FOLDER_NAME = 'public';
export const POSTS_FOLDER_NAME = 'posts'; // 포스트 이미지들을 저장할 폴더 이름
// 실제 공개폴더의 절대경로
// /{프로젝트의 위치}/public
export const PUBLIC_FOLDER_PATH = join( // 경로를 만들어 주는 함수
// string을 무한히 넣을 수 있다.
PROJECT_ROOT_PATH,
PUBLIC_FOLDER_NAME
)
// 포스트 이미지를 저장할 폴더
// /{프로젝트의 위치}/public/posts
export const POST_IMAGE_PATH = join(
PUBLIC_FOLDER_PATH,
POSTS_FOLDER_NAME,
)
지금까지 작성한 것은 프로젝트의 위치에서부터 경로를 가져오고 있습니다. 그 의미는 PUBLIC_FOLDER_PATH
과 POST_IMAGE_PATH
는 완전히 절대경로인 것입니다. 하지만 우리가 요청을 받을 때는 절대경로
를 사용하지 않고 /public/posts/xxx.png
이렇게 받을 것입니다. 뒤에 이미지는 다를 수 있으니까 /public/posts
까지 작성해 보겠습니다.
import { join } from "path";
export const PROJECT_ROOT_PATH = process.cwd();
export const PUBLIC_FOLDER_NAME = 'public';
export const POSTS_FOLDER_NAME = 'posts';
export const PUBLIC_FOLDER_PATH = join(
PROJECT_ROOT_PATH,
PUBLIC_FOLDER_NAME
)
export const POST_IMAGE_PATH = join(
PUBLIC_FOLDER_PATH,
POSTS_FOLDER_NAME,
)
// 절대경로 x
// http://localhost:3000 + /public/posts/xxx.png
export const POST_PUBLIC_IMAGE_PATH = join(
PUBLIC_FOLDER_NAME,
POSTS_FOLDER_NAME,
)
/public/posts
뒤에 나오는 xxx.png
는 나중에 처리하겠습니다. 왜냐하면 그때마다 확장자가 달라지기 때문입니다.
다시 posts.module.ts로 가서 마저 storage의 cb함수를 작성하겠습니다.
MulterModule.register({
limits: {
fileSize: 10000000,
},
fileFilter: (req, file, cb) => {
const extension = extname(file.originalname);
if (extension !== '.jpg' && extension !== '.jpeg' && extension !== '.png') {
return cb(new BadRequestException('jpg/jpeg/png 파일만 없로드 가능합니다. '), false);
}
return cb(null, true);
},
storage: multer.diskStorage({
destination: function(req, res, cb) {
cb(null, POST_IMAGE_PATH); // 여기로 파일을 업로드 한다
},
}),
}),
definition에 파일을 업로드시 필요한 filename을 추가하겠습니다.
import {v4 as uuid} from 'uuid'; // uuid 사용시, v4가 일반적
.
.
MulterModule.register({
limits: {
fileSize: 10000000,
},
fileFilter: (req, file, cb) => {
const extension = extname(file.originalname);
if (extension !== '.jpg' && extension !== '.jpeg' && extension !== '.png') {
return cb(new BadRequestException('jpg/jpeg/png 파일만 없로드 가능합니다. '), false);
}
return cb(null, true);
},
storage: multer.diskStorage({
// 파일 저장시 어디로 이동 시킬까?
destination: function(req, res, cb) {
cb(null, POST_IMAGE_PATH); // 파일 업로드 위치
},
// 파일 이름 뭐로 지을래?
filename: function(req, res, cb) {
// uuid + file의 확장자
cb(null, `${uuid()}${extname(file.originalname)}`) // POST_IMAGE_PATH로 이동
}
}),
}),
컨트롤러에서 파일업로드를 어떻게 받는지 확인해보겠습니다. postsPost는 데이터를 새롭게 추가하는 메소드이기 때문에 @UseInterceptor
를 추가합니다. 그리고 내부에는 FileInterceptor를 넣고 파라미터로 파일을 업로드할 필드의 이름을 넣습니다.
만약 image
가 되면 image라는 키값에 uuid.png
가 value로 매칭이 되고 컨트롤러 로직이 실행됩니다.
즉, 컨트롤러 로직은 @UseInterceptor
를 통해서 posts.module.ts에 작성한 로직이 전부 통과되어야 발동하는 것입니다.
@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(FileInterceptor('image')) // 파일을 업로드할 필드의 이름 -> image라는 키값에 넣어서 보냄
postPosts(
@User('id') userId: number,
@Body() body: CreatePostDto,
) {
return this.postsService.createPost(
userId, body
);
}
그러면 만들어진 파일을 받고 사용하는 코드를 작성하겠습니다.
@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(FileInterceptor('image'))
postPosts(
@User('id') userId: number,
@Body() body: CreatePostDto,
@UploadedFile() file?: Express.Multer.File, // 추가
) {
return this.postsService.createPost(
userId, body
);
}
객체를 생성할 때 이미지를 넣어야하니까 controller와 posts.service.ts 모두 추가를 하겠습니다.
@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(FileInterceptor('image'))
postPosts(
@User('id') userId: number,
@Body() body: CreatePostDto,
@UploadedFile() file?: Express.Multer.File,
) {
return this.postsService.createPost(
userId, body, file?.filename, // filename은 file이 null이 아닌 경우에만 들어올 수 있으니까
);
}
async createPost(authorId: number, postDto: CreatePostDto, image?: string) { // 추가
const post = this.postsRepository.create({
author: {
id: authorId,
},
...postDto,
image, // 추가
likeCount: 0,
commentCount: 0,
});
const newPost = await this.postsRepository.save(post);
return newPost;
}
포스트맨으로 테스트를 해보겠습니다.
{
"title": "파일 업로드",
"content": "파일 업로드 테스트",
"image": "06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png", // uuid
"likeCount": 0,
"commentCount": 0,
"author": {
"id": 1
},
"id": 109,
"updatedAt": "2024-02-04T04:30:17.814Z",
"createdAt": "2024-02-04T04:30:17.814Z"
}
실제로 저장이 되었는지 확인해보겠습니다.
{
"data": [
{
"id": 109,
"updatedAt": "2024-02-04T04:30:17.814Z",
"createdAt": "2024-02-04T04:30:17.814Z",
"title": "파일 업로드",
"content": "파일 업로드 테스트",
"image": "06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png",
"likeCount": 0,
"commentCount": 0,
"author": {
"id": 1,
"updatedAt": "2024-01-26T05:58:10.800Z",
"createdAt": "2024-01-26T05:58:10.800Z",
"nickname": "codefactory",
"email": "codefactory@codefactory.ai",
"role": "USER"
}
},
.
.
{
"id": 90,
"updatedAt": "2024-01-28T02:12:54.437Z",
"createdAt": "2024-01-28T02:12:54.437Z",
"title": "임의로 생성된 81",
"content": "임의로 생성된 포수트 내용 81",
"image": null,
"likeCount": 0,
"commentCount": 0,
"author": {
"id": 1,
"updatedAt": "2024-01-26T05:58:10.800Z",
"createdAt": "2024-01-26T05:58:10.800Z",
"nickname": "codefactory",
"email": "codefactory@codefactory.ai",
"role": "USER"
}
}
],
"cursor": {
"after": 90
},
"count": 20,
"next": "http://localhost:3000/posts?order__createdAt=DESC&take=20&where__id__less_than=90"
}