[꿀팁] NestJS에서 S3에 이미지 업로드 시 워터마크 추가하기

in-ch·2024년 2월 25일
2

꿀팁

목록 보기
9/14
post-thumbnail
post-custom-banner

서론


이미지를 업로드하고 웹사이트나 애플리케이션에서 업로드된 이미지를 사용할 때, 종종 워터마크를 추가하는 것은 이미지의 소유권을 보호하고 브랜딩을 강화하는 데에 유용합니다.

NestJS와 AWS를 사용하여 이미지 업로드 시 워터마크를 자동으로 추가하는 간편한 방법을 알아보겠습니다.

AWS 설정


먼저 이미지를 업로드해야 하므로 AWS S3를 설정한 후 IAM을 설정해서 권한을 받아와야 합니다.

  • Amazon S3 (Simple Storage Service)는 아마존 웹 서비스(AWS)에서 제공하는 클라우드 스토리지 서비스로, 인터넷을 통해 사용되는 다양한 데이터를 손쉽게 저장하고 관리할 수 있는 플랫폼입니다.

  • IAM (Identity and Access Management)은 AWS 리소스에 대한 접근을 관리하고 보안을 강화하기 위한 서비스입니다. IAM을 사용하면 사용자와 그룹에게 필요한 권한을 부여하거나 제한하여 AWS 리소스에 대한 접근을 조절할 수 있습니다.

Amazon S3 버킷 생성

  1. Amazon S3 접속

  2. [버킷 만들기]를 클릭하여 새로운 버킷을 만들면 됩니다.

    여기서는 예제이므로 [이 버킷의 퍼블릭 액세스 차단 설정] > [모든 퍼블릭 액세스 차단]은 체크 해제합니다.
    추후 퍼블릭 엑세스를 차단한 후 Cloudfront 등을 통해서 추가적인 보안 설정이 가능합니다.

  3. 생성한 Bucket 이름을 잘 저장해둡니다!

IAM 사용자 생성

  1. IAM 사용자에 접속
  2. [사용자 생성]을 클릭하여 s3 접근용 사용자를 생성합니다.
  3. 생성된 IAM의 ACCESS_KEYSECRET_KEY를 잘 저장해둡니다!

NestJS 설정


필요 패키지 설치

먼저 sharp 패키지와 aws-sdk 패키지가 필요합니다.
이 패키지들은 NestJS 프로젝트에서 이미지 처리 및 AWS S3와의 상호작용을 가능하게 합니다.

npm install sharp aws-sdk

혹은

yarn add sharp aws-sdk

위 명령어를 실행하면 프로젝트에 sharp와 aws-sdk 패키지가 설치됩니다.

환경 변수 파일 설정

NestJS 프로젝트 루트에 워터마크로 쓸 이미지를 하나 넣어줍시다. 그 후 환경 변수에 위에서 생성한 버킷과 iam 키들을 다음과 같이 추가합니다.

S3_ACESS_KEY = 위에서 생성한 키
S3_SECRET_KEY = 위에서 생성한 시크릿 키
S3_BUCKET_NAME = 위에서 생성한 버킷 네임
WATERMARK_IMG = 워터 마크 이미지

업로드 코드 작성!


S3에 이미지를 업로드하는 코드 작성

@Controller('uploads')
export class UploadsController {
  @Post('')
  @UseInterceptors(FileInterceptor('file'))
  async uploadFile(@UploadedFile() file: any) {
    AWS.config.update({
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY,
        secretAccessKey: process.env.S3_SECRET_KEY,
      },
    });
    const Image = await sharp(file.buffer, { failOnError: false })
      .withMetadata()
      .resize(700)
      .jpeg({ mozjpeg: true })
      .png()
      .toBuffer();

    try {
      const objectName = `${generateRandomString(10)}.png`;
      await new AWS.S3()
        .putObject({
          Body: Image,
          Bucket: BUCKET_NAME,
          Key: objectName,
          ContentEncoding: 'base64',
          ContentType: 'image/png',
          ACL: 'public-read',
        })
        .promise();
      const url = `${process.env.CLOUD_FRONT}/${objectName}`;
      return { url };
    } catch (e) {
      console.error(e);
      return null;
    }
  }
  • @Controller('uploads'): 이 데코레이터는 '/uploads' 엔드포인트에 대한 컨트롤러를 정의합니다.

  • @Post(''): 이 데코레이터는 HTTP POST 요청을 처리하며, 해당 요청은 파일 업로드를 의미합니다.

  • @UseInterceptors(FileInterceptor('file')): FileInterceptor 인터셉터는 'file'이라는 필드 이름으로 전송된 파일을 처리합니다. 이 필드는 클라이언트에서 전송된 이미지 파일을 포함합니다.

  • async uploadFile(@UploadedFile() file: any) { ... }: 이 메서드는 파일 업로드를 처리합니다.
    @UploadedFile() 데코레이터를 사용하여 업로드된 파일을 가져옵니다.

  • AWS.config.update({ credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY, }, });: AWS SDK의 AWS 설정을 업데이트하여 AWS 자격 증명을 제공합니다.
    환경 변수에서 AWS S3의 액세스 키 ID 및 비밀 액세스 키를 가져와 사용합니다.

  • const Image = await sharp(file.buffer, { failOnError: false }) ... .toBuffer();: Sharp를 사용하여 이미지를 리사이징하고 JPEG 또는 PNG 형식으로 변환합니다. 리사이즈된 이미지는 버퍼 형식으로 반환됩니다.

  • const objectName = ${generateRandomString(10)}.png;: 저장될 객체의 키 이름을 생성합니다. generateRandomString 함수를 사용하여 날짜 및 랜덤 문자열을 포함한 고유한 이름을 생성합니다.

    이 부분에서 랜덤 문자열을 포함한 고유한 이름을 생성하는 이유는 동일한 이름의 객체가 중복되지 않도록 하기 위함입니다.
    파일을 업로드할 때 저장할 객체의 키 이름은 고유해야 합니다. 그렇지 않으면 이전에 업로드된 파일이나 다른 파일과 충돌이 발생하여 데이터 손실이나 덮어쓰기 등의 문제가 발생할 수 있습니다.

  • await new AWS.S3() ... .promise();: AWS SDK의 S3 클래스를 사용하여 이미지를 AWS S3에 업로드합니다. putObject 메서드를 사용하여 이미지를 S3 버킷에 저장하고, 해당 메서드는 Promise를 반환합니다.

  • const url = ${process.env.CLOUD_FRONT}/${objectName};: 업로드된 이미지의 URL을 생성합니다. 클라우드프론트(CDN)를 사용하여 S3 버킷에 저장된 이미지에 대한 URL을 생성합니다.

    저는 추가적인 보안을 위해 Cloudfront를 연결했지만 다른 분은 여기에 버킷의 public access 주소를 넣어주시면 됩니다!

generateRandomString() 함수

아래 코드는 입맛에 맞게 알아서 수정해 사용하면 됩니다.

/**
 * @param num 문자열 길이
 * @description 인자로받은 문자열 길이만큼의 랜덤 문자열을 생성합니다.
 *              이때 날짜값도 앞에 추가합니다.
 * @returns {string} 랜덤 문자열
 */
const generateRandomString = (num: number): string => {
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
  let result = '';
  const charactersLength = characters.length;
  for (let i = 0; i < num; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }

  const date = new Date();

  const year = date.getFullYear();
  const month = ('0' + (date.getMonth() + 1)).slice(-2);
  const day = ('0' + date.getDate()).slice(-2);
  const dateStr = year + '_' + month + '_' + day;

  return `${dateStr}_${result}`;
};

sharp을 활용한 워터마크 추가

업로드할 이미지의 워터마크를 사용하기 위해서 sharpcomposite 함수를 사용하면 됩니다.

이 함수는 이미지를 합성(composite)할 때 사용됩니다. 기본적으로 처리된 이미지 위에 다른 이미지나 오버레이를 합성합니다.

공식 문서

composite 함수의 주요 파라미터

  • images: 합성할 이미지들의 목록입니다. 여러 이미지를 합성할 수 있습니다.
  • images[].input: 합성할 이미지를 나타내는 버퍼나 파일 경로입니다.
  • images[].blend: 이미지를 합성할 때 사용할 블렌딩 모드입니다. 다양한 블렌딩 모드가 있습니다.
  • images[].gravity: 합성할 이미지의 위치를 지정합니다.
  • images[].top 및 images[].left: 합성할 이미지의 상단과 왼쪽으로부터의 픽셀 오프셋입니다.
  • 기타 옵션들로는 이미지를 반복(tile)시킬지 여부, 이미지의 해상도(density) 등이 있습니다.

사용법

  • 로컬에 저장하기
await sharp(background)
  .composite([
    { input: layer1, gravity: 'northwest' },
    { input: layer2, gravity: 'southeast' },
  ])
  .toFile('combined.png');
  • Buffer로 변환
const output = await sharp('input.gif', { animated: true })
  .composite([
    { input: 'overlay.png', tile: true, blend: 'saturate' }
  ])
  .toBuffer();

여기서 주의할 점은 합성할 이미지는 합설될 이미지와 동일한 크기 또는 작아야 합니다. 아니면 [Error: Image to composite must have same dimensions or smaller]라는 오류 메시지가 나옵니다.

최종 코드

업로드와 워터마크를 포함한 업로드를 분리하였습니다.

예제이니깐 controller에 비지니스 로직을 포함시켰지만 service 레이어를 새로 만들어 비지니스 로직을 service 로직에 넣는 걸 추천드립니다.

@Controller('uploads')
export class UploadsController {
  @Post('')
  @UseInterceptors(FileInterceptor('file'))
  async uploadFile(@UploadedFile() file: any) {
    return this.uploadImageToS3(file);
  }

  @Post('/watermark')
  @UseInterceptors(FileInterceptor('file'))
  async uploadWatermarkFile(@UploadedFile() file: any) {
    const watermarkImage = process.env.WATERMARK_IMG;
    return this.uploadImageToS3(file, watermarkImage);
  }

  private async uploadImageToS3(file: any, watermarkImage?: string) {
    AWS.config.update({
      credentials: {
        accessKeyId: process.env.S3_ACESS_KEY,
        secretAccessKey: process.env.S3_SECRET_KEY,
      },
    });

    try {
      const s3 = new AWS.S3();
      const imgSize = 700;

      // 이미지 리사이징
      let image = await sharp(file.buffer, { failOnError: false })
        .withMetadata()
        .resize(imgSize)
        .jpeg({ mozjpeg: true })
        .png()
        .toBuffer();

      // 워터마크 적용
      if (watermarkImage) {
        image = await sharp(image)
          .composite([{ input: watermarkImage, gravity: 'southeast' }])
          .resize({ width: imgSize })
          .toBuffer();
      }

      const objectName = `${generateRandomString(10)}.png`;
      await s3
        .putObject({
          Body: image,
          Bucket: process.env.S3_BUCKET_NAME,
          Key: objectName,
          ContentType: 'image/png',
          ACL: 'public-read',
        })
        .promise();

      const url = `${process.env.CLOUD_FRONT}/${objectName}`;
      return { url };
    } catch (e) {
      console.error(e);
      return null;
    }
  }
}

워터 마크를 s3에서 가져오기

위의 코드는 워터 마크를 로컬에 위치시켜 놓고 적용하도록 하였지만, 워터 마크 이미지를 s3에 올려놓고 불러와서 저장하도록 코드를 수정해보도록 하겠습니다.

downloadWatermarkFromS3 함수 추가

private async downloadWatermarkFromS3() {
    AWS.config.update({
      credentials: {
        accessKeyId: process.env.S3_ACESS_KEY,
        secretAccessKey: process.env.S3_SECRET_KEY,
      },
    });

    const s3 = new AWS.S3();
    const params = {
      Bucket: process.env.S3_BUCKET_NAME,
      Key: process.env.WATERMARK_IMG,
    };

    try {
      const { Body } = await s3.getObject(params).promise();
      return Body;
    } catch (error) {
      console.error('Error downloading watermark image from S3:', error);
      return null;
    }
  }

Controller에 적용

  @Post('/watermark')
  @UseInterceptors(FileInterceptor('file'))
  async uploadWatermarkFile(@UploadedFile() file: any) {
    const watermarkImage = await this.downloadWatermarkFromS3();
    return this.uploadImageToS3(file, watermarkImage);
  }

끝 ... !!

profile
인치
post-custom-banner

0개의 댓글