..

Lamda 를 활용한 이미지 리사이징 적용기

2024.02.21 - AWS Lambda 를 활용한 이미지 리사이징 적용기

들어가면서

현재 진행하고 있는 프로젝트에는 이미지 업로드 기능이 있다. 클라이언트가 API 키를 이용하여 Cloudnary 에 이미지를 업로드 한 후 업로드 된 이미지 URI와 포스팅하려고 하는 내용과 함께 서버에 등록하는 방식이다. 하지만 이러한 방식은 다소 문제가 일어날 요지가 있어 이미지 업로드 과정을 개선해보았다.

문제

클라이언트 (프론트엔드) 가 미리 정의된 API 키를 이용하여 이미지서버 (Cloudnary) 에 이미지를 바로 저장하고 있다. 즉, 클라이언트가 이미지 저장에 대한 모든 권한을 가지고 있는 셈이다. 이때 API 키는 클라이언트에 노출되어있기때문에 KEY가 탈취된다면 언제든지 누군가 사용해서 이미지를 업로드 할 수 있는 문제가 생긴다. 또한 현재 프로젝트에서 사용되는 이미지 사이즈는 최대 500px 임에도 불구하고 클라이언트로 부터 원본 이미지를 저장한 후 저장된 원본이미지를 그대로 사용하고 있다. 원본 사이즈 크기를 사용할 필요가 없음에도 불구하고 불필요하게 이미지 다운로드 용량을 낭비하고 있다.

  1. 보안적인 문제로 인해 클라이언트가 아닌 서버에서 이미지 저장을 처리해야 한다.
  2. 불필요한 네트워크 낭비를 방지하고자 요청할 때는 리사이징된 작은 사이즈의 이미지를 요청할 수 있도록 한다.

해결

  1. S3 이미지 저장에 대한 권한은 일시간동안만 사용가능한 업로드 용 Presigned URI 를 만들어 해당 URI를 통해 클라이언트가 이미지를 업로드 할 수 있게 하여 클라이언트가 업로드에 대한 모든 권한을 가질 수 없도록 하였다.

  2. API 서버에서 하지 않아도 외부에서 리사이징 작업을 할 수 있기 위해서 S3 와 Lambda 를 이용하여 이미지 리사이징에 대한 처리를 하였다.

위와 같은 해결은 쉽게 나오진 않았다. 아래의 상황을 고려한 후 최종적으로 S3 와 Lambda, API Gateway 를 이용한 이미지 처리방식을 채택하였다.

방안 1

먼저 처음 생각한 이미지 처리 방식이다.

직관적으로 생각한 방식이다. 클라이언트가 이미지(최대 3개까지) multipart 방식으로 API 서버로 이미지를 업로드 하면, 서버는 S3에 권한을 가진 IAM 사용자의 AccessKey 와 SecretKey 를 이용하여 S3에 이미지를 저장한다. 저장 후에는 저장된 S3 이미지 URI를 저장하려고 하는 컨텐츠 정보와 함께 데이터베이스에 저장한다.

이 과정이 모두 완료되면 클라이언트에 성공 응답(200)을 한다.

이렇게 한다면 이미지 저장에 대한 권한은 서버에 있기 때문에 1번에 대한 문제가 해결된다. 또한 이미지 리사이징도 서버에서 하기 때문에 이 또한 문제가 될 상황은 없어 보인다.

하지만 클라이언트의 이미지 요청 이후 2, 3, 4번 의 응답이 늦어지면 늦어질 수록 최종 클라이언트로 가는 응답 역시 늦어질 수 밖에 없다.

그래서 이미지 리사이징 처리를 다른 곳에서 할 수 있는 방안을 생각해보았다.

(2번과 3번의 과정을 비동기 요청으로 할 경우 위와 같은 문제를 해결할 수 있을 것 같다. 하지만 비동기는 아직 익숙치 않기 때문에 그보다는 다른 방법으로 해결하기로 했다. 추후에 시간이 된다면 비동기에 대한 부분도 다루어 보도록 하겠다)

방안 2

방안 1에 비해서는 구조가 복잡해 보인다. S3 이외에 Lambda 가 추가되었다. 크게 두 가지의 FLOW가 존재한다.

FLOW 1

  1. 클라이언트가 API서버로 이미지를 전송한다.
  2. API 서버는 클라이언트로 부터 받은 이미지를 S3에 업로드 한다.
  3. S3에 저장된 이미지 URI를 요청받는 컨텐츠와 함께 DB에 저장한다.
  4. 저장이 완료되면 클라이언트로 성공 응답(200) 을 보낸다.

FLOW 2

  1. S3에 이미지가 저장될 경우 이미지 리사이징을 담당하는 Lambda 함수로 Events를 보낸다.
  2. Lambda 함수는 저장된 S3 이미지를 가져와 리사이징 작업을 한다.
  3. 리사이징 작업이 끝나면 크기별로 S3에 저장한다.

참고로 FLOW 1 번과 FLOW 2은 별개로 진행된 다.

그리고 서버는 더 이상 이미지 리사이징 작업을 하지 않아도 된다. 최소한 리사이징 작업으로 인해 응답이 지연되지는 않는다. 하지만 S3에 이미지를 저장하는 건 여전히 API 서버가 담당하고 있다.

방안 3

방안 2보다 훨씬 복잡해졌다.

방안2와 다른 점은 이미지저장을 더 이상 서버에서 하지 않는다. 대신 서버에서는 이미지를 저장할 수 있는 Presigned URI 를 생성하도록 S3에 요청한다.

클라이언트는 이 Presigned URI를 이용해 서버가 아닌 바로 S3에 이미지를 업로드한다. 이후 S3에 업로드 되어 진행되는 리사이징 과정은 Lambda 를 통해 이루어진다. (방안 2와 같다)

이후에는 Presigned URI 와 함께 추가 저장할 컨텐츠 정보를 서버로 전달합니다. 서버는 이미지 URI 를 저장한 후 클라이언트로 성공응답(200)을 보낸다.

Presigned URI 의 경우 20분의 유효시간을 정했다.

public class AWSPresignedUriService(){

    private final AmazonS3 s3;
    
    public AWSPresignedUriService(final AmazonS3 s3){
        this.s3 = s3;
    }
    
	public String generatePresignedUri(String bucketName, String filePath){
		Calendar calendar = Calendar.getInstance();
        calendar.setTime(new Date());
        calendar.add(Calendar.MINUTE, 20); // 20분의 유효시간
        return s3.generatePresigenedUri(bucketName, filePath, calendar.getTime(), HttpMethod.PUT).toString();
	}
}

20분의 유효시간이 끝나면 발급받은 Presigned URI 는 더 이상 사용할 수 없다. 이제 서버가 이미지와 관련된 작업을 하는 건 S3에 Presigned URI 를 생성하기 위해 요청하는 작업만 있다.

하지만 아직까지 이미지 처리와 관련해서 서버에서 프로그래밍 방식으로 Presigned URI를 생성하기 때문에 버킷 이름이나 파일 경로를 바꾸기 위해서는 서버 코드를 수정해야하는 단점이 존재한다.

이미지와 관련된 작업은 사실 크게 비지니스 로직에 벗어나지 않음에도 불구하고 새로운 Presigend URI를 만들기 위해 API 서버 코드를 수정하여 배포하고 싶지는 않았다.

방안 4

이미지 리사이징 Lambda 와 더불어 Presigned URI 를 생성하는 Lambda 를 추가한 후 API Gateway 를 만들어 Lambda 함수를 API 형태로 노출시킨다.

이로써 더 이상 API 서버에서는 이미지와 관련된 작업을 더 이상 하지 않게 되었다.

또한 API Gateway 는 “수신한 API 호출 1백만 건 무료” 이기 때문에 우리 프로젝트에 사용하기에는 충분했다.

만약 새로운 Bucket 에 대해서 Presigned URI 을 생성하고 싶다면 서버코드는 전혀 건들이지 않고 Lambda 함수만 수정하면 되는 장점이 생겼다. 이제 더 이상 이미지와 관련된 작업은 API 서버에서 생각하지 않아도 되었다.

개발과정

S3와 CloudWatch 의 권한을 가진 새로운 정책 생성하기

리사이징을 담당하는 람다 함수와 이미지 업로드 가능하도록 하는 Presigned URI 를 만들기 위해서는 S3에 권한이 있어야한다.

이미지 리사이징을 담당하는 람다 함수의 경우 S3 로 부터 Event 를 받아 S3 이미지를 조회한 후 이미지 사이즈 별로 S3에 업로드 해야하기 때문에 S3 GET, PUT 에 대해 허용권한이 있어야한다.

IAM > 정책 > 정책 생성 으로 들어가 필요한 권한을 설정해준다.

참고로 Cloudwatch 에서 람다 이벤트에 대한 로그가 수집될 수 있도록 필요한 권한 역시 설정한다.

정책을 생성했으니 이제 Role(역할)을 생성해야한다. Lambda 와 같은 AWS 리소스에 일시적으로 권한을 부여할 수 있는 데 Role 을 부여함으로써 가능하다. 또한 Role은 Policy와 연결되어야 사용할 수 있기 때문에 필요한 Lambda 가 사용할 역할을 만든다.

역할은 IAM > 역할 > 역할 생성 에 들어가서 만들면 된다.

권한 추가 항목에서는 아까 만든 정책(위의 스샷에서는 안보이지만 정책이름을 LambdaS3Policy 로 하였다) 선택한다.

역할 이름을 넣고 역할생성 버튼을 눌러 역할을 생성한다.

Lambda 함수 생성하기

Lambda > 함수 > 함수 생성에 들어가 Lambda 함수를 생성한다.

이때 Lambda 생성시 실행역할을 아까 만들었던 LambdaS3Role 로 선택한다. (먼저 Lambda 를 만들고 추구 Lambda > 구성 > 권한 > 편집 항목에서 원하는 역할로 바꿀 수 있기 때문에 무엇을 먼저 만들지는 상관없다.)

이미지 리사이징 코드

import {S3Client, GetObjectCommand, PutObjectCommand} from '@aws-sdk/client-s3';
import {Readable} from 'stream';
import sharp from 'sharp';
import util from 'util';
import convert from 'heic-convert';

const s3 = new S3Client({region: 'ap-northeast-2'});
const widths = [80, 500];

function parseBucketInfoFromEvent(event) {
  console.log(`Event: ${JSON.stringify(event)}`);
  const record = event.Records[0].s3;
  const srcBucket = record.bucket.name;
  const srcKey = decodeURIComponent(record.object.key.replace(/\+/g, " "));
  return {
    srcBucket,
    srcKey,
    dstBucket: `${srcBucket}-resized`,
    dstKey: `${srcKey}.jpg`,
  };
}

// Fetch object from S3
async function fetchObject({bucket, key}) {
  const params = {Bucket: bucket, Key: key};
  try {
    const response = await s3.send(new GetObjectCommand(params));
    const contentType = response.ContentType;
    console.log(`ContentType: ${contentType}`);
    if (!(response.Body instanceof Readable)) {
      throw new Error('Expected a stream in the response body');
    }
    return response;
  } catch (error) {
    console.error(`Error fetching object ${key} from bucket ${bucket}:`, error);
    throw error;
  }
}

function verifyImageType(contentType, dstKey) {
  if (!isImage(contentType)) {
    throw new Error(`not support contentType = ${contentType} / dstKey = ${dstKey}`);
  }
}

async function processAndUploadImages({
  dstBucket,
  dstKey,
  response
}) {
  const contentType = response.ContentType;
  const contentBuffer = Buffer.concat(await response.Body.toArray());

  verifyImageType(contentType, dstKey);

  for (const width of widths) {
    let processedImageBuffer;
    // HEIC 이미지의 경우 변환 처리
    if (contentType === 'image/heic') {
      console.log("heic file")
      const convertedBuffer  = await convertHeicToJpeg(contentBuffer);
      processedImageBuffer = await resizeImage(convertedBuffer, width, contentType);
    }else{
      processedImageBuffer = await resizeImage(contentBuffer, width, contentType);
    }

    await uploadResizedImage(dstBucket, `${width}/${dstKey}`, processedImageBuffer);
  }
}

// HEIC 이미지를 JPEG로 변환
async function convertHeicToJpeg(buffer) {
  try {
    return await convert({
      buffer: buffer, // HEIC 파일의 Buffer
      format: 'JPEG', // HEIC to JPEG
      quality: 1 // 품질 설정 (0 ~ 1)
    });
  } catch (error) {
    console.error('Error converting HEIC to JPEG:', error);
    throw error;
  }
}

// Resize image
async function resizeImage(buffer, width) {
  try {
    return sharp(buffer)
    .resize(width, null, {fit: 'contain'}) // height 는 width 에 자동적으로 맞춰짐
    .jpeg({quality: 80}) // 퀄리티 ( 0 ~ 100 )
    .withMetadata() // 원본 metadata 그대로 가지도록 함
    .toBuffer();
  } catch (error) {
    console.error('Error resizing image:', error);
    throw error;
  }
}

// Upload resized image to S3
async function uploadResizedImage(bucket, key, buffer) {
  const params = {
    Bucket: bucket,
    Key: key,
    Body: buffer,
    ContentType: 'image/jpeg',
  };
  try {
    await s3.send(new PutObjectCommand(params));
    console.log(`Successfully uploaded ${key} to ${bucket}`);
  } catch (error) {
    console.error(`Error uploading ${key} to ${bucket}:`, error);
    throw error;
  }
}

function isImage( contentType) {
  return contentType.startsWith("image/");
}

// Lambda handler function
export const handler = async (event) => {
  console.log("이벤트 처리 중 ... :", util.inspect(event, {depth: 5}));
  const {srcBucket, srcKey, dstBucket, dstKey} = parseBucketInfoFromEvent(event);
  const response = await fetchObject({bucket: srcBucket, key: srcKey});
  await processAndUploadImages({dstBucket, dstKey, response});
};

의존이 필요한 패키지들은 npm build 를 통해 설치한 후 zip 파일로 압축하여 업로드 한다.

코드를 간단하게 설명하면

  1. parseBucketInfoFromEvent(event) : 수신괸 S3 Events 로 부터 필요한 정보를 파싱(키, 버킷이름)
  2. fetchObject(bucket, key): 버킷이름와 키를 이용하여 S3에 방급 저장된 이미지를 조회
  3. processAndUploadImages(dstBucket, dstKey, response) : 가져온 이미지를 크기별(80, 500)로 리사이징 한 후 S3에 업로드

sharp 는 이미지 포맷 변환을 지원한다.

하지만 HEIC 포멧의 이미지파일인 경우 Nokia의 HEIF 라이브러리 라이센스로 인해 Sharp를 사용하려면 libheif, libde265 및 x265를 지원하도록 컴파일된 전역 설치 libvips를 사용해야 한다고 한다. 관련해서 해결한 블로그도 존재하지만, 나는 별도로 HEIC 를 JPG 로 바꾸는 라이브러리를 추가하여 해결했다.

조회한 이미지의 response.ContentType 의 경우가 “image/heic” 인 경우에는 jpg 로 변환 후 이미지 리사이징 작업을 하도록 했다.

업로드 용 PresignedURI 생성 코드

또 다른 람다함수를 만들고 아래의 코드를 추가한다. 만들 때 역할은 위에서 만든 역할 그대로 사용하였다.

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl, S3RequestPresigner } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from 'crypto';

const REGION = "리전";
const BUCKET = "버킷이름";

const createPresignedUrlWithClient = ({ region, bucket, key }) => {
  const client = new S3Client({ region });
  const command = new PutObjectCommand({ Bucket: bucket, Key: key });
  return getSignedUrl(client, command, { expiresIn: 360000 });
};

export const handler = async (event, context, callback) => {
    
    const clientUrl = await createPresignedUrlWithClient({
      region: REGION,
      bucket: BUCKET,
      key: randomUUID(),
  	});
    
    return {
      result: "SUCCESS",
      data: [ clientUrl ]
  	}
  }
};

업로드용 PresignedURI 의 경우, 방안4 에 나온 구조에서 보듯이 API Gateway 로 요청이 온 후 연결된 Lambda 함수에서 응답해야 하기 때문에 생성된 presignedUri 를 리턴하고 있다.

API Gateway

이제 PresignedURI 만드는 람다함수를 API 로 노출시키기 위해 먼저 API Gateway를 만들어야 한다.

API Gateway 생성 HTTP API 선택

이때 API Gateway가 Lambda 함수를 호출해야하기 때문에, 아까 만든 Lambda 함수를 지정한다.

다음버튼을 눌러 계속 진행한다.

여기서 API Gateway의 메서드와 경로를 구성할 수 있다. 물론 만들고 나서 수정해도 상관없다. 나는 “GET /image/presigned-uri” 으로 경로를 설정했다.

마지막으로 스테이지까를 정의한다. 이는 말그대로 스테이지 환경을 정의할 수 있으며 API 스테이지는 API ID 및 스테이지 이름으로 식별되며, API를 호출하는 데 사용되는 URL에 포함됩니다.

HTTP URI에 스테이지 변수를 사용하여 구성하는 자세한 사용법은 https://docs.aws.amazon.com/ko_kr/apigateway/latest/developerguide/http-api-stages.html 에 있으니 참고바란다.

새롭게 생성한 API를 볼 수 있다. 좀 전에 dev 라는 이름의 스테이지 이름을 사용하는 스테이지의 경우에는 현재 자동배포되지 않기 때문에 만약 dev 스테이지에도 배포를 원한다면 우측 상단에 있는 배포 버튼을 클릭하여 배포를 진행하면 된다.

이제 아래의 경로로 API 호출을 하면 https://{app_id}.execute-api.ap-northeast-2.amazonaws.com/image/presigned-uri

Presigned URI 를 받는 것을 알 수 있다.

번외, API Gateway 사용자 지정 도메인 설정하기

AWS 에서 제공하는 URI 주소 말고 사용자 지정 도메인 이름을 지정할 수 있다.

현재 itthatcat.xyz 도메인을 가비아 네임서버에 등록해놓은 상태이고 와일드 카드 인증서를 LetsEncrypted 를 통해 무료로 발급한 상태이다.

이제 img.itthatcat.xyz 의 새로운 서브도메인을 설정해보자. https://{app_id}.execute-api.ap-northeast-2.amazonaws.com/image/presigned-uri 로 호출하는 것이 아닌 https://img.itthatcat.xyz/image/presigned-uri 로 호출할 수 있도록 하자

도메인 이름에는 사용하고자 하는 도메인 이름을 입력한다. (당연히 사용하고자 하는 도메인에 대해서 구입이 완료되어야 한다.) 그리고 해당 도메인에 대해 발급한 인증서가 필요하다.

사용하려고 하는 도메인에 ACM SSL 인증서 발급받기 with AWS가 아닌 다른 네임서버 사용중

인증서의 경우 이미 만든 인증서를 가져와서 사용해도 좋지만, 나는 해당 도메인에 대해서는 새롭게 SSL 인증서를 만들기로 했다. 인증서가 여러개로 분산되어 관리의 포인트가 늘어난 다는 단점이 있지만, AWS ACM에서 만든 인증서의 경우 인증서의 갱신을 자동화할 수 있고 무료이다. 물론 원한다면 외부에서 사용된 인증서를 가져와서 사용할 수 있다. 다만 외부의 인증서의 경우 만료되었을 경우 갱신에 대한 로직은 대신해주지 않는다.

이제 네임 서버에 CNAME 레코드를 만들면 된다. 이를 통해 DNS 검증을 받는 것이다.

이때 CNAME 레코드 등록시 호스트에는 _21jkddfu2j.img.itthatcat.xyz. 에서 호스트 이름인 _21jkddfu2j.img 까지만 입력해야한다. 그 외는 그대로 입력한다.

만약 서브도메인이 아닌경우라면 _21jkddfu2j.itthatcat.xyz. 요렇게 될것이다. 그리고 CNAME 레코드 호스트는 _21jkddfu2j 입력하면 된다.

어느 정도 시간이 지나면 이렇게 “검증 대기중”에서 “발급” 상태로 바뀐 것을 알 수있다.(CNAME 레코드를 추가하고 10분 내로 발급되었다)

사용자지정 도메인 등록하기

사용하려고 하는 도메인 이름과 발급받은 ACM 인증서를 등록합니다.

사용자 지정 도메인와 API Gateway 매핑하기

이렇게 사용자 지정 도메인에 대해 새로운 API Gateway 도메인 이름이 생겼다. 이제 다시 가비아로 돌아와서 DNS 레코드를 추가해야한다. CNAME 레코드를 추가하여 img.itthatcat.xyz 로 접속했을 때 위의 가려진 도메인으로 이동할 수 있도록 해야한다.

매핑하려고 하는 API 를 선택한다.

이제 https://img.itthatcat.xyz/image/presigned-uri 로 요청해보자.

정상적으로 데이터가 잘 나오는 것을 알 수 있다.

번외

현재는 API Gateway 앞단에 Cloudfront 를 구성한 후 Cloudfront -> API Gateway -> Lambda 로 호출하도록 구성한 상태이다. Cloudfront 역시 API Gateway 와 마찬가지로 AWS 에서 제공하는 도메인 이름이 아닌 새롭게 커스텀 도메인을 사용할 수 있으며 SSL 인증서 적용하는 방식도 같다.


참고

https://obviy.us/blog/sharp-heic-on-aws-lambda/ https://docs.aws.amazon.com/ko_kr/acm/latest/userguide/troubleshooting-DNS-validation.html