본문 바로가기

프로젝트/booktalk(책 중고 거래 서비스)

image파일 업로드 할때 리사이징 적용하기

 

이미지 리사이징을 도입한 이유는 S3 프리티어 저장 공간이 90% 이상 차면서 이미지 관리의 필요성을 느꼈습니다.

서버에 저장된 이미지의 용량을 최적화하면 저장 공간을 효율적으로 활용할 수 있습니다. 효율적인 용량 관리는 비용 절감에 도움이 될 수 있습니다. 그리고 사용자들은 다양한 디바이스에서 중고거래 사이트를 이용합니다. 이미지를 리사이징하여 다양한 디바이스에서도 적절한 크기로 보여줄 수 있습니다.

 

단순하게 용량을 제한 하는것 만으로도 무지막지한 용량의 이미지를 차단할 수 있는 장점이 있었다.
 java.awt.Graphics2D, Imgscalr, Marvin 등 이미지 리사이징 라이브러리 사용 시 ,이미지IO와 변환과정에서 3mb이상의 이미지는 리사이징하는데 상당히 오랜 시간이 소요된다.
 아래와 같이 multipart 사이즈에 제약 조건을 application.yml파일에 설정하였다.

 
spring:
  servlet:
    multipart:
      maxFileSize: 3MB # 파일 하나의 최대 크기
      maxRequestSize: 3MB  # 한 번에 최대 업로드 가능 용량​

 

백엔드 vs Lambda

 

3mb이상의 이미지 IO 발생 시, 제약없이 빠르게 업로드 후 리사이징 할 수 있는 방법이 있었다. CloudFront와 AWS LAMBDA를 사용하여 온디멘드 방식의 리사이징을 하는 것이다. Serverless인 람다에서 이미지를 리사이징하는 방법은 저장된 큰 이미지의 용량을 줄이고 좀 더 빠른 업로드를 하는 이점은 있었으나, 하나의 이미지가 원본 S3공간과 리사이징된 S3공간 두 곳으로 저장되어 관리포인트가 더 늘어났고 그 결과 용량절감의 효과가 떨어졌다.그렇기 때문에 Marvin 라이브러리 사용 백엔드 쪽에서 처리하는걸 선택하였다.

 

 

marvin 라이브러리를 사용하기 위해선 build.gradle에 아래의 내용을 추가해야한다.

implementation 'com.github.downgoon:marvin:1.5.5'
implementation 'com.github.downgoon:MarvinPlugins:1.5.5'

 

 

 

 

 

 

이 코드는 Spring Framework에서 이미지 업로드 및 리사이징을 처리하는 메서드와 리사이징을 담당하는 별도의 메서드 이다.

public String imageUpload(@RequestParam("upload") MultipartFile file) throws IOException {
    try {
        String fileName = file.getOriginalFilename();
        String ext = fileName.substring(fileName.lastIndexOf("."));
        String uuidFileName = UUID.randomUUID() + ext;

        // 리사이징
        MultipartFile resizedImage = resizer(file);

        try (InputStream inputStream = resizedImage.getInputStream()) {
            ObjectMetadata metadata = new ObjectMetadata();
            byte[] bytes = IOUtils.toByteArray(inputStream);
            metadata.setContentType(resizedImage.getContentType());
            metadata.setContentLength(bytes.length);
            ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);

            // S3에 업로드
            s3Config.amazonS3Client().putObject(
                    new PutObjectRequest(bucket, uuidFileName, byteArrayInputStream,
                            metadata).withCannedAcl(
                            CannedAccessControlList.PublicRead));
        }

        String s3Url = s3Config.amazonS3Client().getUrl(bucket, uuidFileName).toString();
        return s3Url;
    } catch (IOException e) {
        throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드 및 리사이징에 실패했습니다.", e);
    }
}

private MultipartFile resizer(MultipartFile originalImage) {
    try {
        BufferedImage image = ImageIO.read(originalImage.getInputStream());

        int originWidth = image.getWidth();
        int originHeight = image.getHeight();
        int targetWidth = 500; // 원하는 리사이즈 폭

        if (originWidth <= targetWidth) {
            return originalImage;
        }

        MarvinImage marvinImage = new MarvinImage(image);
        Scale scale = new Scale();
        scale.load();
        scale.setAttribute("newWidth", targetWidth);
        scale.setAttribute("newHeight", targetWidth * originHeight / originWidth);
        scale.process(marvinImage.clone(), marvinImage, null, null, false);

        BufferedImage resizedImage = marvinImage.getBufferedImageNoAlpha();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        // JPEG 화질 설정
        Iterator<ImageWriter> imageWriters = ImageIO.getImageWritersByFormatName("jpeg");
        if (imageWriters.hasNext()) {
            ImageWriter imageWriter = imageWriters.next();
            ImageWriteParam writeParam = imageWriter.getDefaultWriteParam();
            writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
            writeParam.setCompressionQuality(0.999f); // 예시로 0.9로 설정, 필요에 따라 조절

            imageWriter.setOutput(ImageIO.createImageOutputStream(baos));
            imageWriter.write(null, new IIOImage(resizedImage, null, null), writeParam);
            imageWriter.dispose();
        }

        baos.flush();

        return new CustomMultipartFile(originalImage.getName(), "jpg", originalImage.getContentType(), baos.toByteArray());
    } catch (IOException e) {
        throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 리사이징에 실패했습니다.", e);
    }
}
  1. imageUpload 메서드: 클라이언트로부터 받은 MultipartFile을 처리하여 S3에 업로드하고 해당 이미지의 URL을 반환합니다.
    • file.getOriginalFilename(): 업로드된 파일의 원래 파일 이름을 가져옵니다.
    • UUID.randomUUID() + ext: 파일 이름에 UUID를 추가하여 중복을 방지하고 고유성을 유지합니다.
    • resizer(file): 리사이징을 수행하는 메서드를 호출하여 이미지를 원하는 크기로 조절합니다.
    • 이미지를 S3에 업로드하고 해당 이미지의 URL을 생성하여 반환합니다.
  2. resizer 메서드: 주어진 MultipartFile을 리사이징하여 새로운 MultipartFile을 반환합니다.
    • ImageIO.read(originalImage.getInputStream()): MultipartFile에서 이미지를 읽어 BufferedImage로 변환합니다.
    • 리사이징을 위해 Marvin 프레임워크를 사용합니다. 이미지의 가로 폭이 목표 폭보다 작으면 리사이징을 수행하지 않고 원본 이미지를 반환합니다.
    • 이미지를 조절하고 JPEG로 변환하여 새로운 MultipartFile을 생성합니다.

 

 

 

메모리에 있는 이미지 데이터를 MultipartFile 인터페이스를 필요로 하는 코드와 통합할 때 유용합니다.

package com.example.booktalk.domain.imageFile.service;

import com.mysema.commons.lang.Assert;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.multipart.MultipartFile;

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

public class CustomMultipartFile implements MultipartFile {

    private final String name;

    private String originalFilename;

    private String contentType;

    private final byte[] content;
    boolean isEmpty;


    public CustomMultipartFile(String name, String originalFilename, String contentType, byte[] content) {
        Assert.hasLength(name, "Name must not be null");
        this.name = name;
        this.originalFilename = (originalFilename != null ? originalFilename : "");
        this.contentType = contentType;
        this.content = (content != null ? content : new byte[0]);
        this.isEmpty = false;
    }

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

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

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

    @Override
    public boolean isEmpty() {
        return (this.content.length == 0);
    }

    @Override
    public long getSize() {
        return this.content.length;
    }

    @Override
    public byte[] getBytes() throws IOException {
        return this.content;
    }

    @Override
    public InputStream getInputStream() throws IOException {
        return new ByteArrayInputStream(this.content);
    }

    @Override
    public void transferTo(File dest) throws IOException, IllegalStateException {
        FileCopyUtils.copy(this.content, dest);
    }

}

인터페이스 구현: MultipartFile 인터페이스의 메서드들을 구현합니다.

  • getName(): 파일의 이름을 반환합니다.
  • getOriginalFilename(): 원래 파일의 이름을 반환합니다.
  • getContentType(): 파일의 컨텐츠 타입을 반환합니다.
  • isEmpty(): 파일이 비어있는지 여부를 반환합니다.
  • getSize(): 파일의 크기를 반환합니다.
  • getBytes(): 파일의 내용을 바이트 배열로 반환합니다.
  • getInputStream(): 파일의 내용을 읽어오는 InputStream을 반환합니다.
  • transferTo(File dest): 파일의 내용을 지정된 파일로 복사합니다.

 

 

                                       리사이징 전                                                                                          리사이징 후

위에가 리사이징 전             아래가 리사이징 후    용량이 확실히 변한걸 확인할수 있다.