0. Intro


이미지와 같은 파일을 AWS S3에 업로드하면 객체 URL에 접근하여 불러올 수 있다.

어떤 웹 페이지에서 S3에 저장해둔 이미지를 불러와야 한다고 생각해보자.

이미지 1개를 불러와야 한다면 이미지 크기가 로딩 속도에 큰 영향을 주지 않을지도 모른다.

만약 여러 이미지 사진, 이를테면 동시에 수 백개를 불러와서 웹 페이지를 로딩해야한다면??

이미지 크기가 로딩 속도에 큰 영향을 줄 수 있다.

✅ 그렇기에 빠른 이미지 로딩을 위해서 이미지 크기를 줄이는 것은 중요한 부분이다.


🚨 문제 상황

프로젝트를 진행하면서 이와 관련한 문제가 있었다.

우선, 상품과 브랜드 이미지를 웹 페이지 한 곳에서 동시에 불러올 일이 많을 것이라 예상했다.

뿐 만 아니라, 썸네일 혹은 미리보기와 같은 곳에서도 이미지를 불러올 상황이 많을 것인데 이 때 원본 이미지 그대로를 불러온다면 이미지 크기로 인해 로딩 속도에 문제가 생길 수도 있다고 생각했다.


❓ 이미지 크기를 줄이는 방법

이미지 크기를 줄이는 단순한 방법은 1️⃣ 서버에서 직접 이미지 크기를 조정하는 로직을 작성하는 것이다.

🚫 그런데, 이 방법은 앞서 말했듯이 동시에 많은 이미지를 조정하려고 한다면 CPU 리소스를 잡아먹기 때문에 서버에 상당한 부하를 줄 수 있다.

다음으로 2️⃣ AWS Lambda를 이용하는 방법이 있다.


AWS Lambda

AWS Lambda는 서버리스 컴퓨팅 Faas(Function as a Service)이다.

여기서 서버리스란 개발자가 서버를 관리할 필요 없이 애플리케이션을 빌드하고 실행할 수 있도록 하는 것을 말한다.

즉, Lambda의 경우 AWS가 서버 인프라에 대한 프로비저닝, 유지 관리 등을 대신 처리해준다는 것을 의미한다.

간단히 말해, AWS가 개발자가 할 일을 대신 처리해준다는 뜻이다.

이렇게 되면 개발자는 비즈니스 로직 작성에 집중할 수 있다는 장점이 있다.


AWS Lambda와 이미지 resizing

다시 본론으로 돌아와, 서버리스 개념에 따르면, AWS Lambda에 원하는 함수를 작성하고, 필요할 때 그 함수를 실행시켜 우리가 원하는 동작을 하게끔 할 수 있다는 의미가 된다.

그렇다면 Lambda로 어떻게 이미지 크기를 조정할 수 있는지에 대한 의문이 들 것이다.

간단하다.

이미지 크기를 조정하는 함수를 작성해 Lambda에 등록해두어 Lambda가 처리하게끔 하는 것이다.

고맙게도 AWS 공식문서에 이미지 리사이징과 관련한 코드가 자세히 나와있으며, AWS Lambda는 다른 AWS 서비스들과의 연동 또한 용이하기에 S3 버킷에 저장해둔 이미지 파일이라면 쉽게 이미지 크기를 조정할 수 있다.


그래서 결론은, 프로젝트엔 이 AWS Lambda를 이용할 것이다.

브랜드, 상품을 등록할 때 이미지 파일을 보내게 될텐데 원본 이미지 파일이 등록됨과 동시에 Lambda의 트리거가 동작하고, 이미지 리사이징 함수를 통해서 크기가 작아진 이미지 파일을 생성해 S3 버킷에 저장하도록 할 것이다.

image


적용 순서

AWS 공식문서에 보면 이미지 리사이징은 아래와 같은 순서를 밟으면 된다고 나와있는데, 이를 기본으로 하여 진행해보겠다.

image


1. IAM 정책 생성


우린 이미 브랜드, 상품 이미지를 저장할 S3 버킷이 존재하기 때문에 단계 1은 패스하고, IAM 정책 생성 단계로 넘어간다.

IAM 콘솔의 정책 페이지를 열고, 아래 사진에서 정책 생성 버튼을 누른다.

image


정책 생성 페이지에 들어오면 JSON 탭을 선택한 뒤 정책을 입력해야 한다.

image


정책은 다음과 같다.

개인마다 달라서 바꿔야 할 부분은 "Resource": "arn:aws:s3:::sourcebucket/*"와, "Resource": "arn:aws:s3:::sourcebucket-resized/*"부분이다.

sourcebucket부분엔 원본 이미지를 등록해둔 S3 버킷 이름으로 바꿔준다.

sourcebucket-resized부분엔 리사이징한 이미지를 등록할 S3 버킷 이름으로 바꿔준다.

당연히, 리사이징한 이미지를 등록할 S3 버킷은 생성된 상태여야 한다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:PutLogEvents",
                "logs:CreateLogGroup",
                "logs:CreateLogStream"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": "arn:aws:s3:::sourcebucket/*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject"
            ],
            "Resource": "arn:aws:s3:::sourcebucket-resized/*"
        }
    ]
}


태그 추가 부분은 default로 두고, 아래와 같이 정책 검토의 이름에 AWSLambdaS3Policy를 입력하고, 정책 생성 버튼을 선택한다.

image


2. 실행 역할 생성


다음으로, Lambda 함수에 AWS 리소스 액세스 권한을 제공하는 실행 역할을 만들어야 한다.

IAM 콘솔에서 역할 페이지를 연다.

image


역할 만들기를 선택한다.

image


다음 속성을 사용하여 역할을 만든다.

  • 신뢰할 수 있는 엔터티 – Lambda

image


  • 권한 정책 – AWSLambdaS3Policy

image


  • 역할 이름 – lambda-s3-role

image


3. 배포 패키지 생성


이제 이미지 리사이징을 위한 함수를 작성해야 한다.

여러 언어가 지원되지만 Java/Spring 기반의 프로젝트로 진행하고 있기에 Java11로 진행했다.

Java11은 콘솔 창에서 바로 편집할 수 없기 때문에 따로 프로젝트를 만들어 그 안에 함수를 작성하고 .zip파일로 빌드하는 과정을 거쳐야 한다.

우선 새 프로젝트를 만들고 의존성을 추가해줘야 한다.

.zip 파일 아카이브 배포를 참고하여 build.gradle엔 아래와 같은 종속성을 추가해주었다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    implementation platform('software.amazon.awssdk:bom:2.15.0')
    implementation 'software.amazon.awssdk:s3'
    implementation 'com.amazonaws:aws-lambda-java-core:1.2.2'
    implementation 'com.amazonaws:aws-lambda-java-events:3.11.1'
    implementation 'com.amazonaws:aws-java-sdk-s3:1.12.429'
    implementation 'com.google.code.gson:gson:2.10.1'
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2'
}


다음으로, Handler.java 클래스를 만들고, 그 안에 이미지 리사이징 함수를 만들면 된다.

리사이징 로직은 예시 코드 그대로 가져오고, jpeggif 타입에 관한 것만 추가해주었다.

Handler.java
public class Handler implements RequestHandler<S3Event, String> {
    private static final float MAX_DIMENSION = 100;
    private final String REGEX = ".*\\.([^\\.]*)";
    private final String JPG_TYPE = (String) "jpg";
    private final String JPG_MIME = (String) "image/jpeg";
    private final String JPEG_TYPE = (String) "jpeg";
    private final String JPEG_MIME = (String) "image/jpeg";
    private final String PNG_TYPE = (String) "png";
    private final String PNG_MIME = (String) "image/png";
    private final String GIF_TYPE = (String) "gif";
    private final String GIF_MIME = (String) "image/gif";

    @Override
    public String handleRequest(S3Event s3event, Context context) {
        LambdaLogger logger = context.getLogger();
        try {
            S3EventNotificationRecord record = s3event.getRecords().get(0);

            String srcBucket = record.getS3().getBucket().getName();

            // Object key may have spaces or unicode non-ASCII characters.
            String key = record.getS3().getObject().getUrlDecodedKey();

            String dstBucket = srcBucket + "-resized";
            String dstKey = "resized-" + key;

            // Infer the image type.
            Matcher matcher = Pattern.compile(REGEX).matcher(key);
            if (!matcher.matches()) {
                logger.log("Unable to infer image type for key " + key);
                return "";
            }
            String imageType = matcher.group(1);
            if (!(JPG_TYPE.equals(imageType)) && !(JPEG_TYPE.equals(imageType))
                    && !(PNG_TYPE.equals(imageType)) && !(GIF_TYPE.equals(imageType))) {
                logger.log("Skipping non-image " + key);
                return "";
            }

            // Download the image from S3 into a stream
            S3Client s3Client = S3Client.builder().build();
            InputStream s3Object = getObject(s3Client, srcBucket, key);

            // Read the source image
            BufferedImage srcImage = ImageIO.read(s3Object);
            BufferedImage newImage = resizeImage(srcImage);

            // Re-encode image to target format
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            ImageIO.write(newImage, imageType, outputStream);

            // Upload new image to S3
            putObject(s3Client, outputStream, dstBucket, dstKey, imageType, logger);

            logger.log(
                    "Successfully resized " + srcBucket + "/" + key + " and uploaded to " + dstBucket
                            + "/" + dstKey);
            return "Ok";
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void putObject(S3Client s3Client, ByteArrayOutputStream outputStream,
                           String bucket, String key, String imageType, LambdaLogger logger) {

        Map<String, String> metadata = new HashMap<>();
        metadata.put("Content-Length", Integer.toString(outputStream.size()));
        if (JPG_TYPE.equals(imageType)) {
            metadata.put("Content-Type", JPG_MIME);
        }
        if (PNG_TYPE.equals(imageType)) {
            metadata.put("Content-Type", PNG_MIME);
        }
        if (JPEG_TYPE.equals(imageType)) {
            metadata.put("Content-Type", JPEG_MIME);
        }
        if (GIF_TYPE.equals(imageType)) {
            metadata.put("Content-Type", GIF_MIME);
        }

        software.amazon.awssdk.services.s3.model.PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .metadata(metadata)
                .build();

        // Uploading to S3 destination bucket
        logger.log("Writing to: " + bucket + "/" + key);
        try {
            s3Client.putObject(putObjectRequest,
                    RequestBody.fromBytes(outputStream.toByteArray()));
        }
        catch(AwsServiceException e)
        {
            logger.log(e.awsErrorDetails().errorMessage());
            System.exit(1);
        }

    }

    private InputStream getObject(S3Client s3Client, String bucket, String key) {
        software.amazon.awssdk.services.s3.model.GetObjectRequest getObjectRequest = software.amazon.awssdk.services.s3.model.GetObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build();
        return s3Client.getObject(getObjectRequest);
    }

    private BufferedImage resizeImage(BufferedImage srcImage) {
        int srcHeight = srcImage.getHeight();
        int srcWidth = srcImage.getWidth();
        // Infer scaling factor to avoid stretching image unnaturally
        float scalingFactor = Math.min(
                MAX_DIMENSION / srcWidth, MAX_DIMENSION / srcHeight);
        int width = (int) (scalingFactor * srcWidth);
        int height = (int) (scalingFactor * srcHeight);

        BufferedImage resizedImage = new BufferedImage(width, height,
                BufferedImage.TYPE_INT_RGB);
        Graphics2D graphics = resizedImage.createGraphics();
        // Fill with white before applying semi-transparent (alpha) images
        graphics.setPaint(Color.white);
        graphics.fillRect(0, 0, width, height);
        // Simple bilinear resize
        graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
                RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        graphics.drawImage(srcImage, 0, 0, width, height, null);
        graphics.dispose();
        return resizedImage;
    }
}


4. 배포 패키지 빌드


함수를 다 작성했으면 .zip파일로 build해야 한다.

Gradle을 사용하여 배포 패키지 빌드 공식문서에 따르면 gradle 빌드의 경우 build.gradle에 다음과 같은 코드를 작성하면 build시 .zip파일이 생성된다고 한다.

task buildZip(type: Zip) {
    from compileJava
    from processResources
    into('lib') {
        from configurations.runtimeClasspath
    }
}
build.dependsOn buildZip

위 코드를 잠시 설명하면, build/distributions 디렉터리에 배포 패키지를 생성한다.

compileJava 작업은 함수의 클래스를 컴파일하며, 이 processResources 작업은 Java 프로젝트 리소스를 대상 디렉터리에 복사하고 잠재적으로 처리한다.

그런 다음 into('lib') 명령이 빌드의 클래스 경로에서 lib라는 폴더로 종속성 라이브러리를 복사하게 된다.


build.gradle에 작성이 끝났다면 build한 뒤,

image

build/distributions 디렉터리에 .zip 파일이 생성됐는지 확인한다.

image


5. Lambda 함수 생성


빌드한 배포 패키지를 Lambda에 등록하기 위한 과정은 Lambda 배포 패키지를 참고했다.

Lambda 콘솔을 사용해서 .zip파일을 업로드해보겠다.

image


우선, Lambda > 함수 > 함수 생성에서 새로 작성을 선택한다.

image


기본 정보에는 적절한 이름과 Java 11, 역할엔 앞서 생성한 lambda-s3-role을 선택한다.

image


다음으로, 코드 소스에 .zip 혹은 .jar파일로 업로드하면 되는데,

image


용량이 10MB를 초과하면 다음과 같이 S3를 사용해서 업로드 하라고 한다.

image


그래서 임의의 S3 버킷을 새로 생성해서 .zip파일을 업로드 후 해당 .zip파일 객체 URL을 가져오는 방법으로 진행해야 한다.

다음과 같이 임의의 S3 버킷을 생성하고, zip파일을 업로드했다.

image


그러면 다시 돌아와서 Lambda 코드 소스에 해당 객체 URL을 입력하면 된다.

image


그리고, 런타임 설정의 핸들러 값을 [패키지명.클래스명::메서드명]으로 설정하고 등록한다.

image

image


6. Amazon S3 트리거를 생성


한 가지 과정이 더 남아있다.

우리가 Lambda에 등록해 둔 함수를 언제 실행할건지에 관한 설정을 해주어야 한다.

Amazon S3 트리거를 사용하여 Lambda 함수를 호출할 것이고, Amazon S3 트리거를 사용하여 Lambda 함수 호출의 순서를 참고했다.

문서에 나와 있는 순서는 다음과 같다.

image


함수 개요 에서 트리거 추가를 클릭 한 뒤,

image


S3를 소스로 선택한다.

버킷은 이미지를 변환할 대상 버킷을 선택한다.

그리고, 이벤트 유형은 모든 객체 생성 이벤트를 선택하고, 재귀 호출 경고를 체크 한뒤, 트리거를 추가한다.

image


7. 요청 확인


다음과 같이 나이키 로고 브랜드 생성 요청을 보내면,

image


원본 이미지가 shoekream 버킷의 brand 폴더에 250.6KB 용량으로 저장되고,

image


리사이징 이미지는 shoekream-resized 버킷의 resized-brand 폴더에 1.4KB 용량으로 저장되었다.

image


모니터링 > 로그 > CloudWatch Logs를 살펴보면, 작성한 트리거가 실행되어 Lambda가 정상적으로 호출되었고, 그 결과인 리사이징 작업이 잘 이루어졌음을 알 수 있다.

image


마무리


이미지를 불러올 때 동시에 여러 이미지를 불러와야 한다면 이미지 크기가 로딩 속도에 영향을 줄 수 있고, 썸네일 혹은 미리보기와 같은 곳에서도 크기가 큰 원본 이미지를 계속해서 요청할 필요가 없기 때문에 이미지 크기를 줄이는 작업이 필요하다.

이미지 크기를 줄이는 로직을 내 서버 안에서 작성할 수도 있겠지만 그러한 방법은 동시에 여러 이미지를 줄여야 하기엔 서버의 부하가 심해져 적합한 방법은 아니다.

그래서 서버리스 Faas인 AWS Lambda를 이용하게 되었다.

내가 처리해야 할 이미지 크기 조정 작업을 AWS Lambda에 함수로 등록해두면, Lambda가 특정 조건이 만족할 때 해당 작업을 실행시켜 리사이징 이미지를 생성하게 된다.

이렇게 함으로써 이미지 크기 조정 작업을 내 애플리케이션 서버 로직에서 실행하지 않기 때문에 서버의 부하를 줄일 수 있고, 목적에 맞는 이미지를 불러올 수 있기에 여러 이미지가 포함된 웹 페이지의 로딩 속도를 빠르게 할 수 있다.

References

Leave a comment