NestJS 파일 업로드 구현 (multer)

박재하·2023년 11월 24일
0

목표

  • Multer 설치 및 파일 업로드 API 구현
    • Multer 설치 및 검증
    • 트러블 로그: Interceptor 방식 대안 구상
    • 로컬 업로드 기능 구현
    • RED: 실패하는 테스트 코드 작성
    • GREEN: 성공하도록 구현
    • REFACTOR: 리팩토링
    • 개선

Multer 설치 및 파일 업로드 API 구현

Multer 설치 및 검증

yarn add @types/multer

NestJS에서는 파일 업로드 처리를 위해 multer 모듈을 사용함.
POST 메소드로 multipart/form-data 컨텐츠 타입 지원!

그룹 동료분의 소개로 찾아보다 국룰인 걸 알게됐다.
추가로 테스트 방법에 대해 상당히 고민했는데, 찾다보니 Postman에도 파일 업로드가 가능함.

스크린샷 2023-11-21 오후 1 05 23

요런식으로

@Post('/upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File, @Body() body: any) {
  console.log('body', body);
  console.log('file', file);
}

간단하게 컨트롤러를 만들어 테스트해봤다.

스크린샷 2023-11-21 오후 1 06 30

껌이네

라고 하자마자 문제에 봉착했다.

트러블 로그: Interceptor 방식 대안 구상

// 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 테이블에 들어가는지 확인해주기는 힘든 상태다.

스크린샷 2023-11-21 오후 2 33 20 스크린샷 2023-11-21 오후 2 33 34

심지어 여기서 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;
}

위에서도 언급했지만 서비스에선 꼼꼼하게 에러 처리해서 잘못된 요청이거나 에러면 다시 파일을 삭제하는 것으로 했다.

스크린샷 2023-11-21 오후 3 21 09 스크린샷 2023-11-21 오후 3 21 24

문제없이 잘 올라감.

RED: 실패하는 테스트 코드 작성

검증하기가 좀 까다롭긴 하다. 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');
});
스크린샷 2023-11-21 오후 3 30 59

GREEN: 성공하도록 구현

앞서 구현한 인터페이스를 여기 맞춰서 조금 수정해줬다.

// 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);
}
스크린샷 2023-11-21 오후 3 38 37

REFACTOR: 리팩토링

// 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 어노테이션 추가해줬다.

개선

스크린샷 2023-11-21 오후 3 11 06

추가로 이 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 테이블을 통해 접근할 수 있도록 한다.
추후 외래키 설정.

스크린샷 2023-11-21 오후 4 07 25 스크린샷 2023-11-21 오후 4 07 17

잘 저장됨

학습메모

NestJS 파일 업로드

[NestJS] 파일 업로드하기
NestJS 기초 (14) 이미지 파일 업로드하기
How to unit test file upload with Supertest -and- send a token?
NestJS 파일업로드 공식문서

profile
해커 출신 개발자

0개의 댓글