[NestJS] sharp를 활용한 이미지 리사이징

Devhslee·2024년 2월 2일

네스뜨

목록 보기
3/3
post-thumbnail

간혹 가다 백엔드 서버를 거쳐서 외부 스토리지에 이미지 파일을 업로드해야 할 때가 있을 수도 있다.

클라이언트가 업로드한 이미지 파일의 해상도가 너무 크다던가 하는 등의 이유로
별도의 처리를 해주어야 한다면 sharp 라이브러리를 활용하여 Node.js 에서 이미지 리사이징을 해줄 수 있다.


sharp

sharp는 노드 진영에서 활발히 사용되는 이미지 처리 라이브러리이다.

주간 다운로드 횟수가 4백만을 넘어갈 정도로 활발히 사용되는 패키지임을 알 수 있다.

sharp 가 제공하는 기능

1) 이미지 리사이징, 자르기

2) 이미지 회전 및 수평/수직으로 뒤집기

3) 이미지 형식 변환 (e.g. JPEGPNG로 변환)

4) 이미지 합성 및 오버레이(겹침)

5) 색상 조정 및 필터 적용

6) 고급 이미지 처리: (e.g. 블러 처리, 샤프닝 등)


보다시피 상당히 쓸모있는 기능들을 제공하고 있다. 이미지를 비동기적으로 처리하기 때문에 노드 환경에서 써먹기 알맞다.

저 중에서 리사이징 기능을 활용하여 NestJS 서버로 넘어온 이미지 파일을 리사이징을 해보도록 하겠다.


sharp 설치

현재 프로젝트에 sharp 패키지를 설치해준다.

npm i sharp

Controller에 FileInterceptor 사용

라우트로 들어오는 파일 데이터는 반드시 FileInterceptor 또는 FileFieldsInterceptor 를 거쳐야 한다.
(단일 파일이면 전자, 복수의 파일이면 후자)

파일처리 인터셉터를 거쳐서 나오는 Express.Multer.File 타입을 바탕으로 리사이징 작업을 할 것이기 때문이다.

import { Post, UseInterceptors, UploadedFile, UploadedFiles, Bind } from '@nestjs/common';
import { FileFieldsInterceptor, FileInterceptor } from '@nestjs/platform-express';
import { ApiConsumes } from '@nestjs/swagger';

// 예시1) 파일 하나
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
@Post('/single-file')
async uploadSingleFile(
  @UploadedFile() file: Express.Multer.File
) {
	return await this.fileService.uploadSingleFile(file);
}


// 예시2) 파일 여러개
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileFieldsInterceptor([
  { name: 'thumbnail' },
  { name: 'image' },
]))
@Bind(UploadedFiles())
@Post('/multi-file')
async uploadSingleFile(
  @UploadedFiles() files: { thumbnail?: Express.Multer.File, image?: Express.Multer.File },
) {
	return await this.fileService.uploadMultiFile(files);
}

파일 리사이징 Pipe 구현

처음에 구상한 방식은 FileInterceptorFileFieldsInterceptor를 거친 request를 또다시 인터셉터를 거치게 해서 그 내부에서 리사이징을 해볼 생각이었는데, 뭔가 잘 안됬다.

그래서 FileInterceptorFileFieldsInterceptor 거친 Express.Multer.File 타입을 Pipe로 받아서 리사이징 처리를 해주는 걸로 바꿨다.

일단 전체 코드는 다음과 같다.

// resize-image.pipe.ts

import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
import sharp from 'sharp';

const MAX_LENGTH = 1024;

@Injectable()
export class ResizeImagePipe implements PipeTransform {
  // 인터셉터를 통해 들어온 파일이 한개인지 복수인지 체크
  // FileInterceptor(1개) 또는 FileFieldsInterceptor(여러개) 를 거쳤는지 확인
  isSingleFile(value: any): value is Express.Multer.File {
    return (value && 'fieldname' in value && 'originalname' in value);
  }

  async transform(value, metadata: ArgumentMetadata) {
    if (!this.isSingleFile(value)) {
      const result = { ...value };
      const keys = Object.keys(value);

      for (const key of keys) {
        const filetype = value[key][0].mimetype.split('/');

        if (filetype[0] === 'image') {
          result[key][0] = await this.resizeImage(value[key][0]);
        }
      }

      return result;
    }

    const filetype = value.mimetype.split('/');
    if (filetype[0] === 'image') {
      value = await this.resizeImage(value);
    }
    return value;
  }

  async resizeImage(value: Express.Multer.File) {
    let width;
    let height;

    // 메타데이터를 읽어서 이미지의 가로세로 길이를 알아냄
    await sharp(value.buffer)
      .metadata()
      .then((metadata) => {
        width = metadata.width;
        height = metadata.height;
      });

    // 가로세로 길이가 MAX_LENGTH를 넘어가지 않으면 리사이징 불필요
    if (width < MAX_LENGTH && height < MAX_LENGTH) {
      return value;
    }

    // 가로세로 길이중 더 긴것을 MAX_LENGTH에 맞춰준다
    const resizeOption = width >= height ? { width: MAX_LENGTH } : { height: MAX_LENGTH };

    const buffer = await sharp(value.buffer)
      .resize({ ...resizeOption })
      .toBuffer();

    value.buffer = buffer;
    return value;
  }


차례대로 코드 설명을 해보자면,

우선 이 pipe는 FileInterceptorFileFieldsInterceptor 둘 다 거친 파일 데이터를 처리할 수 있어야 하기 때문에 우선 그걸 구분하는 부분을 넣어줬다.

isSingleFile(value: any): value is Express.Multer.File {
    return (value && 'fieldname' in value && 'originalname' in value);
}

FileInterceptor를 거친 싱글 파일일 경우, 해당 타입은 Express.Multer.File 타입일 것이기 때문에
Express.Multer.File의 속성인 fieldnameoriginalname이 있는지 확인해 줌으로서 true/false를 반환하게 했다.

싱글/멀티 파일인지에 따라 처리가 살짝 달라진다. transform() 내부를 다음과 같이 구현했다.

파이프로 들어온게 여러 파일(Express.Multer.File [])이면 반복문으로 파일 각각을 리사이징하도록 했다.

if (!this.isSingleFile(value)) {
  const result = { ...value };
  const keys = Object.keys(value);

  for (const key of keys) {
    const filetype = value[key][0].mimetype.split('/');

    if (filetype[0] === 'image') {
      result[key][0] = await this.resizeImage(value[key][0]);
    }
  }

  return result;
}

const filetype = value.mimetype.split('/');
if (filetype[0] === 'image') {
  value = await this.resizeImage(value);
}
return value;

리사이징은 이미지 파일에만 적용되어야 하므로 파일 데이터의 mimetypeimage인지 확인하는 부분도 넣어줬다.

이제 리사이징하는 부분이 중요한데,

리사이징을 하기에 앞서 이미지 파일의 가로, 세로 길이를 알아내야 한다.

metadata()를 사용하여 이미지의 가로, 세로 길이를 알아낼 수 있다.

let width;
let height;

await sharp(value.buffer)
      .metadata()
      .then((metadata) => {
        width = metadata.width;
        height = metadata.height;
      });

알아낸 가로, 세로 길이를 바탕으로 어떻게 리사이징 할 건지를 정해주면 된다.

sharp가 참 좋은 게, 가로/세로 길이 중 하나만 지정해주면 원본 이미지의 비율에 맞게 나머지 크기도 조정 해준다는것이다.

const resizeOption = width >= height ? { width: MAX_LENGTH } : { height: MAX_LENGTH };

const buffer = await sharp(value.buffer)
.resize({ ...resizeOption })
.toBuffer();

그래서 나는

  • 가로 길이 > 세로 길이
    -> 가로 길이를 최대 길이로 줄이고 (이 경우 세로 길이가 가로 길이에 맞게 리사이징)

  • 세로 길이 > 가로 길이
    -> 세로 길이를 최대 길이로 줄임 (이 경우 가로 길이가 세로 길이에 맞게 리사이징)

위의 조건으로 리사이징되도록 해줬다.


Pipe 적용

만들어놓은 pipe는 @UploadFile 이나 @UploadFiles에 적용해주면 된다.

async uploadSingleFile(
  @UploadedFile(new ResizeImagePipe()) file: Express.Multer.File
) {
	return await this.fileService.uploadSingleFile(file);
}

async uploadSingleFile(
  @UploadedFiles(new ResizeImagePipe()) files: { thumbnail?: Express.Multer.File, image?: Express.Multer.File },
) {
	return await this.fileService.uploadMultiFile(files);
}

리사이징을 적용하여 파일을 업로드할 경우, 그 파일의 용량도 줄어든 걸 확인할 수 있을 것이다.

profile
코딩-버그-좌절-해결-희열

0개의 댓글