yarn add @types/multer
NestJS에서는 파일 업로드 처리를 위해 multer 모듈을 사용함.
POST 메소드로 multipart/form-data
컨텐츠 타입 지원!
그룹 동료분의 소개로 찾아보다 국룰인 걸 알게됐다.
추가로 테스트 방법에 대해 상당히 고민했는데, 찾다보니 Postman에도 파일 업로드가 가능함.
요런식으로
@Post('/upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File, @Body() body: any) {
console.log('body', body);
console.log('file', file);
}
간단하게 컨트롤러를 만들어 테스트해봤다.
껌이네
라고 하자마자 문제에 봉착했다.
// board.controller.ts
@Post('file-upload')
@UseInterceptors(FileInterceptor('file', { dest: './uploads' }))
uploadFile(
@Body('board_id') board_id: any,
@UploadedFile() file: Express.Multer.File,
): Promise<Partial<Board>> {
return this.boardService.uploadFile(board_id, file);
}
파일 업로드 자체는 잘 되는데, 이 파일 업로드가 서비스단이 아닌 @UseInterceptors
어노테이션을 이용한
저장방식이여서, 유효성 검증을 하기가 까다로운 것이 문제다.
위 메소드에서 서비스에 넘어가기 전 이미 /uploads
에 저장이 되어 버리는데, 지금이야 큰 문제 없지만
배포 후 클라우드에서 사용하게 되면, 이미지 파일이 아닌 잘못된 파일을 올리거나 board_id가 없는 게시물을 가리키면
그때 돼서 파일을 삭제하는 로직은 너무 큰 비용이 발생하게 된다.
// board.service.ts
async uploadFile(
board_id: number,
file: Express.Multer.File,
): Promise<Partial<Board>> {
console.log('file', file);
// 이미지 파일인지 확인
if (!file.mimetype.includes('image')) {
unlinkSync(file.path); // 파일 삭제
throw new BadRequestException('Only image files are allowed');
}
const board = await this.findBoardById(board_id);
// 게시글 존재 여부 확인
if (!board) {
unlinkSync(file.path); // 파일 삭제
throw new NotFoundException(`Not found board with id: ${board_id}`);
}
// 파일이 정상적으로 업로드 되었는지 확인
if (!file.path) {
throw new InternalServerErrorException('No file uploaded');
}
// 이미 파일이 존재하는지 확인
if (board.filename) {
unlinkSync(file.path); // 파일 삭제
}
board.filename = file.filename;
const updatedBoard = await this.boardRepository.save(board);
return updatedBoard;
}
이렇게 서비스 단에서 에러처리를 할 수 밖에 없다. .
@Post('file-upload')
@UseInterceptors(
FileInterceptor('file', {
fileFilter: (req, file, cb) => {
if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
throw new BadRequestException('Only image files are allowed');
} else if (file.size > 1024 * 1024 * 5) {
throw new BadRequestException('Image file size is too big');
} else if (!req.body.board_id) {
throw new BadRequestException('Board id is required');
}
cb(null, true);
},
dest: './uploads',
}),
)
uploadFile(
@Body('board_id') board_id: any,
@UploadedFile() file: Express.Multer.File,
): Promise<Partial<Board>> {
return this.boardService.uploadFile(board_id, file);
}
그게 아니라면 필터를 이런식으로 작성해줘야 하는데, 실제로 board 테이블에 들어가는지 확인해주기는 힘든 상태다.
심지어 여기서 Exception을 발생시키면 Response로 전달되지도 않는다.
그래서 파일 업로드를 multer에서 제공하는 인터셉터 없이 수동으로 해주거나,
S3(NCP Object Storage도 여기에 호환된다고 함) SDK만 이용해서 수동으로 서비스에서 업로드하는 방식이면 괜찮을 것 같기도 하다.
우선은 넘어가고 기능부터 만든 후 해결해보자.
// board.controller.ts
@Post('file-upload')
@UseInterceptors(FileInterceptor('file', { dest: './uploads' }))
uploadFile(
@Body('board_id') board_id: any,
@UploadedFile() file: Express.Multer.File,
): Promise<Partial<Board>> {
return this.boardService.uploadFile(board_id, file);
}
컨트롤러는 우선 필터 없이 기능 구현만 하는 것으로 롤백.
async uploadFile(
board_id: number,
file: Express.Multer.File,
): Promise<Partial<Board>> {
console.log('file', file);
// 이미지 파일인지 확인
if (!file.mimetype.includes('image')) {
unlinkSync(file.path); // 파일 삭제
throw new BadRequestException('Only image files are allowed');
}
const board = await this.findBoardById(board_id);
// 게시글 존재 여부 확인
if (!board) {
unlinkSync(file.path); // 파일 삭제
throw new NotFoundException(`Not found board with id: ${board_id}`);
}
// 파일이 정상적으로 업로드 되었는지 확인
if (!file.path) {
throw new InternalServerErrorException('No file uploaded');
}
// 이미 파일이 존재하는지 확인
if (board.filename) {
unlinkSync(file.path); // 파일 삭제
}
board.filename = file.filename;
const updatedBoard = await this.boardRepository.save(board);
return updatedBoard;
}
위에서도 언급했지만 서비스에선 꼼꼼하게 에러 처리해서 잘못된 요청이거나 에러면 다시 파일을 삭제하는 것으로 했다.
문제없이 잘 올라감.
검증하기가 좀 까다롭긴 하다. Buffer를 이용하되 mimetype을 image/png
로 해서 보내도록 했다.
// #61 [08-07] 사진 정보는 스토리지 서버에 저장한다.
it('POST /board/:id/image', async () => {
const board: CreateBoardDto = {
title: 'test',
content: 'test',
author: 'test',
};
const newBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
const image = Buffer.from('test');
const response = await request(app.getHttpServer())
.post(`/board/${newBoard.id}/image`)
.attach('file', image, 'test.png')
.expect(201);
expect(response).toHaveProperty('body');
expect((response as any).body).toHaveProperty('id');
expect(response.body.id).toBe(newBoard.id);
expect((response as any).body).toHaveProperty('filename');
});
앞서 구현한 인터페이스를 여기 맞춰서 조금 수정해줬다.
// board.controller.ts
@Post(':id/image')
@UseInterceptors(FileInterceptor('file', { dest: './uploads' }))
uploadFile(
@Param('id') board_id: string,
@UploadedFile() file: Express.Multer.File,
): Promise<Partial<Board>> {
return this.boardService.uploadFile(+board_id, file);
}
// board.controller.ts
@Post(':id/image')
@ApiOperation({
summary: '이미지 파일 업로드',
description: '이미지 파일을 업로드합니다.',
})
@ApiParam({ name: 'id', description: '게시글 번호' })
@ApiOkResponse({ status: 200, description: '이미지 파일 업로드 성공' })
@ApiBadRequestResponse({
status: 400,
description: '잘못된 요청으로 파일 업로드 실패',
})
@ApiNotFoundResponse({ status: 404, description: '게시글이 존재하지 않음' })
@UseInterceptors(FileInterceptor('file', { dest: './uploads' }))
uploadFile(
@Param('id') board_id: string,
@UploadedFile() file: Express.Multer.File,
): Promise<Partial<Board>> {
return this.boardService.uploadFile(+board_id, file);
}
API 어노테이션 추가해줬다.
추가로 이 file 관련 데이터도 어딘가에 저장해주는 것이 좋겠다 싶어 file 엔티티를 만들고 저장해줬다.
// create-image.dto.ts
export class CreateImageDto {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
destination: string;
filename: string;
path: string;
size: number;
}
Create DTO는 이렇게 Express.Multer.File
과 호환되게 모든 타입을 넣어주고
// image.entity.ts
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class Image extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 50, nullable: false })
mimetype: string;
@Column({ type: 'varchar', length: 50, nullable: false })
filename: string;
@Column({ type: 'varchar', length: 50, nullable: false })
path: string;
@Column({ type: 'int', nullable: false })
size: number;
@CreateDateColumn()
created_at: Date;
}
Image Entity는 여기서 꼭 필요한 정보, 예를 들면 mimetype과 path, size 등만 선별적으로 추출했다.
// board.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Board, Image])],
controllers: [BoardController],
providers: [BoardService],
})
export class BoardModule {}
모듈에 등록하고
// board.controller.ts
uploadFile(
@Param('id') board_id: string,
@UploadedFile() file: CreateImageDto,
): Promise<Board> {
return this.boardService.uploadFile(+board_id, file);
}
컨트롤러 단에서는 file 파라미터의 타입만 변경해줬다.
id는 이제 path parameter로 넘어오므로 string으로 받아서 숫자로 변경 (아까 해줬었음)
...
@Injectable()
export class BoardService {
constructor(
@InjectRepository(Board)
private readonly boardRepository: Repository<Board>,
@InjectRepository(Image)
private readonly imageRepository: Repository<Image>,
) {}
...
async uploadFile(board_id: number, file: CreateImageDto): Promise<Board> {
...
const { mimetype, filename, path, size } = file;
const image = this.imageRepository.create({
mimetype,
filename,
path,
size,
});
const updatedImage = await this.imageRepository.save(image);
board.image_id = updatedImage.id;
const updatedBoard = await this.boardRepository.save(board);
return updatedBoard;
}
}
service에선 image repository를 추가하고,
uploadFile 호출 시 image 레코드를 새로 생성한다.
board.filename 대신 board.image_id를 저장하여 image 테이블을 통해 접근할 수 있도록 한다.
추후 외래키 설정.
잘 저장됨
[NestJS] 파일 업로드하기
NestJS 기초 (14) 이미지 파일 업로드하기
How to unit test file upload with Supertest -and- send a token?
NestJS 파일업로드 공식문서