NestJS-파일 업로드

jaegeunsong97·2024년 2월 4일
0

NestJS

목록 보기
24/37

🖊️Multer 세팅

자바스크립트 진영에서는 이미지를 Multer를 이용해서 쉽게 풀어나갑니다. 따라서 Nest.js에서도 Multer 라이브러리를 사용해서 파일 업로드를 구현하겠습니다. 먼재 4개의 페키지multer @types/multer uuid @types/uuid 를 설치하겠습니다.

// multer + multer의 타입 definition을 제공해주는 타입스크립트 파일 추가
yarn add multer @types/multer uuid @types/uuid
  • posts/entities/posts.entity.ts
@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을 등록하겠습니다.

  • posts.module.ts
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을 받습니다. fileFiltererror과 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를 정리하겠습니다.

  • common/const/path.const.ts
// 서버 프로젝트의 루트 폴더
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_PATHPOST_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로 이동
        }
    }),
}),

🖊️FileInterceptor 적용

컨트롤러에서 파일업로드를 어떻게 받는지 확인해보겠습니다. postsPost는 데이터를 새롭게 추가하는 메소드이기 때문에 @UseInterceptor를 추가합니다. 그리고 내부에는 FileInterceptor를 넣고 파라미터로 파일을 업로드할 필드의 이름을 넣습니다.

만약 image가 되면 image라는 키값에 uuid.png가 value로 매칭이 되고 컨트롤러 로직이 실행됩니다.

즉, 컨트롤러 로직은 @UseInterceptor를 통해서 posts.module.ts에 작성한 로직이 전부 통과되어야 발동하는 것입니다.

  • posts.controller.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이 아닌 경우에만 들어올 수 있으니까
    );
}
  • posts.service.ts
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"
}

profile
블로그 이전 : https://medium.com/@jaegeunsong97

0개의 댓글