이미지 리사이징?

  • 이미지의 크기를 재조정하는 것으로 서버에서 사용할 때 원본의 이미지를 보여줄 필요가 없는 경우 이미지의 크기를 줄여 서버의 부하를 줄일 수 있다.

기능 추가를 생각하게 된 이유

  • 사실 처음엔 이미지에 대한 처리를 고민을 한 적이 아예 없었다. 업로드가 잘 된다고 좋아라~ 하기만 했을뿐 ‘‘이미지’‘라는 포인트에 별다른 생각 자체가 없었다.
  • 헌데 AWS에 배포를 시작한 후부터 3가지 문제에 직면하게 되어 이들을 해결하고자 방법을 찾게 됐고, 해결법으로 알게 된 방법이 바로 이미지 리사이징이다. (3가지 이유가 모두 다 얽혀있음)

1. S3에 올라가는 이미지 크기가 들쑥날쑥

  • 이미지의 크기가 화면이 가득찰만큼 큰 경우부터 되게 작은 경우까지 크기가 너무 중구난방이다.
  • 특히 이 문제는 메뉴판을 모달로 띄울 때 크기가 너무 압도적인 경우가 있어 사소하지만 문제가 됐다.

2. 위의 이유로 인한 이미지 용량 증가

  • 결국 크기가 매우 큰 이미지들이 존재하므로 자연스럽게 용량 역시 커진다.
  • 이로 인해 AWS S3 스토리지 공간을 효율적이게 사용하지 못 하게 되고, 무엇보다 서버에서 이미지를 불러오거나 업로드를 하는 과정에서 많은 부하가 발생한다.

3. 결국 배포된 서비스가 자주 뻗음

  • 배포돼있는 EC2 서버는 프리티어라 RAM 용량이 1GB밖에 되지 않는다. 따라서 이미지를 단시간에 많이 업로드 하거나 출력을 자주 하는 경우 서비스가 정말 자주 뻗는 경험을 하게 되었다.


구현 방법 선정

  • 프론트엔드, 백엔드 모두에서 구현이 가능하고 AWS의 Lambda@Edge를 활용하여 구현하는 방법도 있다.

Lambda@Edge란?

  • AWS의 CloudFront의 기능 중 하나로 애플리케이션의 사용자에게 더 가까운 위치에서 코드를 실행하여 성능을 개선하고 지연 시간을 단축할 수 있게 해줍니다. (AWS Lambda@Edge 소개글)
  • 아직 사용해본적 없는 AWS의 CloudFront와 그에 속한 Lambda 기능으로, 많은 분들이 이 기능을 토대로 이미지 프로세싱을 구현하시는 듯 하다.
  • 결론부터 말하자면, 난 이 기능을 사용하지 않기로 했기에 현재 이 부분에 대하여 큰 공부를 하지 않았다. 이후에 이 서비스를 도입하게 될 때 다시 한 번 정리해야겠다.

백엔드로 결정

  • 언급했다싶이 Lambda@Edge를 사용하지 않고, 프론트엔드 역시 아닌 백엔드단에서 이를 구현하기로 했는데 이유는 아래와 같다.
    1. javascript가 아닌 java를 이용하여 코드를 구현하고 싶음.
    2. Lambda@Edge를 사용하기에 추가적으로 사용해야 하는 AWS 서비스에 대한 비용 부담이 크고, 이를 사용하게 되면 On-the-fly 방식으로 리사이징을 진행하는데 S3의 원본 이미지는 그대로 남아있으므로 S3 스토리지 용량 또한 보다 활용적이게 사용하지 못한다.
  • 물론 백엔드로 구현 하는 경우에도 java에서 이미지 리사이징을 처리하는 로직이 추가되므로 새로운 부하가 발생한다는 단점은 존재한다.


라이브러리 선택

  • Java 이미지 리사이징 참고 게시글
  • 막상 찾아보니 java 기본적으로 지원하는 기능과 함께 다양한 라이브러리들이 존재하는 것을 알게 되었는데 라이브러리들이 오래전부터 업데이트가 이루어지지 않고 있었다. (Lambda@Edge같은 Edge Computing 기술들의 보급으로 인한 것 같음)
  • 고민 끝에 준수한 품질과 처리 속도를 가진 Imgscarl 라이브러리를 선택하였다.
/*
	build.gradle 추가
	Imgscalr 이미지 리사이징 라이브러리
	https://mvnrepository.com/artifact/org.imgscalr/imgscalr-lib
*/
implementation group: 'org.imgscalr', name: 'imgscalr-lib', version: '4.2'


백엔드로 구현해보자

  • 기존 이미지 업로드 코드에 아래의 로직을 작성한다.
/*
    이미지 리사이징 메소드
 */
public static MultipartFile getResizedMultipartFile(MultipartFile multipartFile, String originalFileName) throws IOException {
    final int TARGET_IMAGE_WIDTH = 420;
    final int TARGET_IMAGE_HEIGHT = 200;
    final String TARGET_IMAGE_TYPE = "jpg";

    BufferedImage bi = ImageIO.read(multipartFile.getInputStream());
    bi = resizeImages(bi, TARGET_IMAGE_WIDTH, TARGET_IMAGE_HEIGHT);

    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ImageIO.write(bi, TARGET_IMAGE_TYPE, baos);
    baos.flush();

    // MultipartFile 인터페이스를 구현한 구현체를 통해 MultipartFile을 생성해줌
    return new CustomMultipartFile(baos.toByteArray(), multipartFile.getName(), originalFileName, TARGET_IMAGE_TYPE, false, baos.toByteArray().length);
}

private static BufferedImage resizeImages(BufferedImage originalImage, int width, int height) {
    return Scalr.resize(originalImage, width, height);
}

MultipartFile => BufferedImage => MultipartFile로 변환

  • 입력받은 MultipartFile을 BufferedImage로 변환한 후, Scalr.resize 메소드를 이용하여 BufferedImage를 리사이징해준다.
  • 문제는 이렇게 변환한 BufferdImage를 다시 MultipartFile로 변환할 방법이 구현되어 있지 않다. 따라서 이를 위해선 MultipartFile 인터페이스를 구현할 별도의 구현체를 만들어야 한다.
  • org.springframework.mock.web.MockMultipartFile를 사용해도 되는 것 같은데, 이는 너무 Spring에 의존되는 방법이라 구현체를 만들기로 결정하였다.
  • StackOverFlow 참고
  • 직접 만든 구현체
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;

/*
    MultipartFile을 생성하기 위해
    MultipartFile 인터페이스를 상속받은 클래스
 */
public class CustomMultipartFile implements MultipartFile {

    private final byte[] bytes;
    private final String name;
    private final String originalFilename;
    private final String contentType;
    private final boolean isEmpty;
    private final long size;

    public CustomMultipartFile(byte[] bytes, String name, String originalFilename, String contentType, boolean isEmpty, long size) {
        this.bytes = bytes;
        this.name = name;
        this.originalFilename = originalFilename;
        this.contentType = contentType;
        this.isEmpty = isEmpty;
        this.size = size;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public String getOriginalFilename() {
        return originalFilename;
    }

    @Override
    public String getContentType() {
        return contentType;
    }

    @Override
    public boolean isEmpty() {
        return isEmpty;
    }

    @Override
    public long getSize() {
        return size;
    }

    @Override
    public byte[] getBytes() throws IOException {
        return bytes;
    }

    @Override
    public InputStream getInputStream() throws IOException {
        return null;
    }

    @Override
    public void transferTo(File dest) throws IOException, IllegalStateException {

    }
}

반환 받은 MultipartFile을 이후 로직에서 사용

  • 아래는 AWS S3에 이미지 업로드를 하는 로직의 일부이다. 위에서 만든 메소드를 활용하여 리사이징된 새로운 MultipartFile을 만들어준 후, 이를 File로 변환하여 이후 로직에 사용하니 정상적으로 작동하는 것을 확인하였다.
/*
	이미지 리사이징
*/

		// 위는 생략...
		MultipartFile resizedMultipartFile = getResizedMultipartFile(multipartFile, originalFileName);

        File uploadFile = convert(resizedMultipartFile).orElseThrow(
            () -> new IllegalArgumentException("전환 실패"));

        imageUrl = getImageUrl(uploadFile, storeFileName);
    }

return new UploadFile(originalFileName, storeFileName, imageUrl);
}


적용 결과

  • 기존에 업로드한 이미지와 같은 이미지를 리사이징 후 올린 이미지의 크기를 비교해보자.

리사이징

  • 위에서 보는 것처럼 대략 3% 정도로 용량이 줄어든 것을 알 수 있다.
  • 물론 이는 모든 이미지에 대해서 해당할 수 있는 수치도 아니며 아직 완벽하게 리사이징 조건을 설정한 것도 아니라서 시행착오를 거친 후, 최적화된 옵션과 이미지 크기를 선정하기로 했다.


결론

  • 이미지 리사이징을 구현하는 시간 투자 대비 그 효과는 생각보다 매우 큰 것 같습니다. 현재 메인 페이지에서 노출되는 이미지의 수가 많은데, 이들을 원본 그대로 로드하는 시간이 길어 User Interaction에까지 도달하는 시간이 생각보다 길었으며 이로 인한 UX 역시 좋지 못하였습니다.
  • 하지만 이를 도입 후 이미지들을 바꿔주니 확실히 성능적인 체감이 컸기에 만족도가 매우 큽니다.
  • 현재 현실과 타협하여 Lambda@Edage를 활용한 On-thy-fly 방식의 리사이징을 채택하지 못하였으나 여유가 될 때 그 부분을 따로 공부하고 다시 한 번 정리하여 포스팅을 하도록 하겠습니다.

댓글남기기