Java + Spring 이미지 리사이즈
1. 이미지 리사이즈를 한 이유
몇 주전 요즘카페 서비스를 실제 사용자들에게 배포 후, 설문을 통해 피드백을 받았다.
피드백의 내용 중 압도적으로 많은 내용을 차지하는 것이 이미지 로딩 속도와 관련된 것이었다.
현재 요즘 카페에서 보여지고 있는 모든 이미지들은 최대한 예쁘고 잘나온 사진들의 원본을 EC2의 로컬에 저장해두고, 저장된 원본사진을 그대로 보여주고 있었다.
이러한 방식 때문에 이미지 로딩에 긴 시간이 걸릴 뿐 더러, 데이터 사용량 또한 상당히 높았다.
이를 해결하기 위해, 이미지 업로드 시 리사이징을 진행한 후 S3에 업로드를 하기로 하였다.
2. 리사이즈 툴 선정
우선 리사이즈를 할 툴을 선정해야했다.
고려했던 툴은 다음과 같다.
- marvin
- thumbnailator
- Imgscalr
- 2DGraphics
- getScaledInstance
위의 세가지는 외부 라이브러리이고, 나머지 두개는 java 내부라이브러리이다..
우선 marvin과 thumbnailator로 리사이즈를 진행해봤는데,
옵션을 어떻게 변경해도 품질이 전혀 만족스럽지 않았다.
그리고 Imgsclr같은 경우는 내부적으로 2DGraphics가 사용하길래
이럴거면 직접 2DGraphics를 사용해서 직접 리사이즈 하는게 낫다고 판단했다.
결국 선정한 것은 getScaledInstance이다.
2DGraphics 같은 경우는 단순 리사이즈 뿐만 아니라 회전이나 반전 등등 여러가지 기능을 지원한다.
우리가 필요한 것은 단순 크기를 변환하는 기능이기에, getScaledInstace를 이용하여 리사이즈하기로 결정하였다.
3. 리사이즈
API를 통해 전달받은 MultiPartFile
을 원하는 사이즈로 리사이징 후 업로드하기로 하였다.
타겟기기가 모바일이기 때문에 모바일에 꽉차게 보이는 경우에 대한 사이즈와(width 500), 썸네일에 사용될 사이즈를(width 100) 정해 리사이즈 하기로 하였다.
👆Width 500 짜리 화면
👇Width 100 짜리 화면
@Service
public class ImageService {
private final S3Client s3Client;
public ImageService(final S3Client s3Client) {
this.s3Client = s3Client;
}
public List<String> uploadAndGetImageNames(final List<MultipartFile> files) {
final List<ImageResizer> resizers = createResizers(files); // 1
resizers.forEach(this::resizeAndUpload); // 2
return resizers.stream()
.map(ImageResizer::getFileName)
.toList();
}
private List<ImageResizer> createResizers(final List<MultipartFile> files) {
List<ImageResizer> imageResizers = new ArrayList<>();
for (final MultipartFile file : files) {
final ImageName imageName = ImageName.from(file.getOriginalFilename()); // 1-1
imageResizers.add(new ImageResizer(file, imageName.get()));
} return imageResizers;
}
private void resizeAndUpload(final ImageResizer imageResizer) {
final MultipartFile originalImage = imageResizer.getOriginalImage();
final List<MultipartFile> resizedImages = imageResizer.getResizedImages(Size.getAllSizesExceptOriginal()); // 2-1
s3Client.upload(originalImage);
resizedImages.forEach(s3Client::upload); // 2-2
}
}
Image를 리사이징하고 업로드하는 ImageService
의 코드이다.
업로드기능의 엔트리포인트는 uploadAndGetImageNames()
이다.
위 코드의 흐름은 대략 다음과 같다.
- 인자로 받은
List<MultpartFile>
을ImageResizer
로 변환한다.
1-1.ImageResizer
를 만들 때 파일이름을 새로 붙여준다.- 이미지를 리사이즈 후 S3에 업로드 한다.
2-1. 사이즈 별 리사이즈(사이즈는 Size Enum안에 정의함)
2-2. 업로드
별거 없다.
이미지의 이름을 새로 붙여주는 것은 혹여나 같은 이름의 이미지가 업로드 되는 경우를 방지하기 위함이다.
public class ImageName {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSS");
private static final String EXTENSION_DELIMITER = ".";
private final String fileName;
private ImageName(final String fileName) {
this.fileName = fileName;
}
public static ImageName from(String originalFileName) {
final String fileName = FORMATTER.format(LocalDateTime.now());
final String extension = getExtension(originalFileName);
return new ImageName(fileName + extension);
}
private static String getExtension(final String originalFileName) {
return originalFileName.substring(originalFileName.lastIndexOf(EXTENSION_DELIMITER));
}
public String get() {
return fileName;
}
}
현재시간을 yyyyMMddHHmmssSSSSSS
형식으로 파일이름을 지정하고, 원래 파일명의 확장자를 다시 붙여주는 방식이다.
만약 파일명의 형식이 바뀐다면 요쪽 코드만 수정할 수 있게끔 분리하였다.
이렇게 만들어진 이름을 통해 ImageResizer
를 만든다.
public ImageResizer(final MultipartFile image, final String fileName) {
validate(image);
this.image = image;
this.fileName = fileName;
}
private void validate(final MultipartFile image) {
if (isNull(image.getContentType()) || isNotImage(image)) {
throw new BadRequestException(NOT_IMAGE);
} if (image.getSize() > MAX_IMAGE_SIZE) {
throw new BadRequestException(INVALID_IMAGE_SIZE);
}
}
ImageResizer를 생성할 때, 생성자의 인자로 원본파일을 받는데, 이 파일이 이미지가 맞는지 validate를 진행한 후 생성한다.
다음은 ImageResizer의 리사이즈기능을 구현한 코드이다.
private MultipartFile getResizedImage(final Size size) {
final BufferedImage bufferedImage = getBufferedImage();
final int width = size.getWidth();
final int height = getResizedHeight(width, bufferedImage);
final BufferedImage scaledImage = resize(bufferedImage, width, height);
final byte[] bytes = toByteArray(scaledImage);
return toMultipartFile(bytes, size);
}
private BufferedImage getBufferedImage() {
try {
return ImageIO.read(image.getInputStream());
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
private int getResizedHeight(final int resizedWidth, final BufferedImage bufferedImage) {
final double ratio = (double) resizedWidth / bufferedImage.getWidth();
return (int) (bufferedImage.getHeight() * ratio);
}
리사이즈 할 Width Size를 받아서 리사이즈를 진행하는데,
원본의 비율을 유지하도록 getResizedHeight()
를 통해 Height를 구해준다.
그렇게 구한 height를 포함해 resize() 메소드에 파라미터로 넘겨 이미지를 리사이즈 하는데,
이때 옵션을 지정해줄 수 있다.
private BufferedImage resize(final BufferedImage image, final int width, final int height) {
final BufferedImage canvas = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
final Graphics graphics = canvas.getGraphics();
graphics.drawImage(image.getScaledInstance(width, height, Image.SCALE_SMOOTH), 0, 0, null); // 리사이즈 옵션
graphics.dispose();
return canvas;
}
private byte[] toByteArray(final BufferedImage result) {
try {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ImageIO.write(result, getFormat(), byteArrayOutputStream);
return byteArrayOutputStream.toByteArray();
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
private ResizedImage toMultipartFile(final byte[] bytes, final Size imageSize) {
return ResizedImage.of(bytes, image.getContentType(), fileName, imageSize.getFileNameWithPath(fileName));
}
옵션은 다음과 같다.
- SCALE_DEFAULT
- SCALE_FAST
- SCALE_SMOOTH
- SCALE_REPLICATE
- SCALE_AREA_AVERAGING
REPLICATE와 SCALE_AREA_AVERAGING은 설명을 읽어도 뭔지 잘 모르겠더라..
Defualt, Fast, Smooth가 고려대상들이었는데,
각각의 옵션들을 비교하면 다음과 같다.
속도
FAST > DEFAULT > SMOOTH품질
SMOOTH > DEFAULT > FAST
요즘카페 서비스는 시각적으로 매력적인 카페를 보여주는 서비스인데, 보여주는 이미지의 품질이 중요하다고 생각하여 SMOOTH를 선택하여 리사이즈를 진행하였다.
이렇게 리사이즈를 진행한 결과
546MB에서 250MB 약 45%가량 줄어든 것을 확인할 수 있다.