본문 바로가기

프로젝트/고민

성능 최적화 (S3업로드 병렬 처리와 이미지 리사이징)

이미지 리사이징

현재 톰캣, Nginx의 기본 설정으로 인해1MB 이상의 이미지는 받지 않도록 설정 되어 있다. 현재 프론트 단에서 따로 압축을 해주지 않았기 때문에 사용자는 높은 화질의 이미지를 업로드 할 수 없는 불편함을 겪게 될 것이다. 이에 사용자의 경험을 좋게 해주고 저장 공간도 효율적으로 관리하기 위해 리사이징 처리를 해주는 것이 좋을 것 같다.

내가 사용한 이미지는 9MB 크기의 고용량 이미지다

 

초기 상태

 

보는 바와 같이 이미지의 용량이 크더라도 전혀 압축되지 않고 S3에 업로드 된다. 저장 공간을 비효율적으로 쓰고 있을 뿐만 아니라, 사용자가 위 이미지를 볼 때 렌더링 되는데 소요되는 시간도 클 것이다. 아래는 실제 결과이다. 하나의 이미지를 로드하는데 4.2초가 걸렸고 크기 또한 9.8MB로 매우 크다.

 

 

리사이징하기

문제를 인지했고, 결과적으로 아래와 같이 리사이징을 했다. 리사이징 처리를 한 후 이미지의 크기는 아래와 같이 18배정도 줄은 것을 확인할 수 있다.

 

 

로드 시간도 167ms로 24배 향상된 것을 확인할 수 있다. 그에 비해 육안으로 확인하기 어려울 만큼 화질 차이는 없다.

 

 

 

리사이징을 하는데 사용한 라이브러리는 Scalr과 Marvin이라는 라이브러리를 이용해봤는데 최종적으로는 Scalr을 선택하게 됐다. 이유는 Marvin 같은 경우 리사이징 하는데 18초가 넘게 소요됐기 때문이다.

 

Scalr의 사용 방법은 매우 간단하고 코드는 아래와 같다.

 

public class ImageResizer {

    /**
     * 이미지 크기 리사이징 유틸 클래스
     *
     * @param fileName 원본 이미지 파일의 이름
     * @param fileFormatName 원본 이미지 파일의 포맷 (예: png, jpeg...)
     * @param originalImage 원본 Multipart
     * @param targetWidth  리사이징 할 너비
     * @return 리사이징한 Multipart
     */
    public static MultipartFile resizeImage(String fileName, String fileFormatName, MultipartFile originalImage, int targetWidth) {
        try {
            // MultipartFile -> BufferedImage Convert
            BufferedImage image = ImageIO.read(originalImage.getInputStream());
            // newWidth : newHeight = originWidth : originHeight
            int originWidth = image.getWidth();
            int originHeight = image.getHeight();

            // origin 이미지가 resizing될 사이즈보다 작을 경우 resizing 작업 안 함
            if(originWidth < targetWidth)
                return originalImage;

            BufferedImage resizedImage = Scalr.resize(image, targetWidth);

            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            ImageIO.write(resizedImage, fileFormatName, byteArrayOutputStream);
            byteArrayOutputStream.flush();

            return new ResizedMultipartFile(byteArrayOutputStream.toByteArray(), fileName, originalImage.getContentType());

        } catch (IOException e) {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 리사이즈에 실패했습니다.");
        }
    }
}

 

 

병렬 처리

별도의 이미지들을 순차적으로 리사이징, s3에 업로드 하는 과정이 비효율적이라고 생각이 들어 병렬처리를 생각했다.

병렬 처리 전

요청은 다음과 같이 평범한 이미지를 5개 전송했고 s3에 모두 업로드 하는데 4초정도 걸렸다.

 

이 때 병렬 스트림을 사용해도 괜찮지만, 자바의 CompletableFutre를 통해 구현하는 것이 다음과 같은 이유로 더 좋다고 생각한다.

1. 내가 커스텀해서 상세한 설정이 가능한 TaskExecutor 사용 가능

2. CompletionException 예외 발생시 복구에 대한 예외 핸들링

 

실제 코드는 아래와 같이 작성했다.

@Slf4j
@Component
@RequiredArgsConstructor
public class AWSS3Uploader implements ImageUploader {

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final AmazonS3 amazonS3;

    private static final String DEFAULT_DIRECTORY = "images/";

    private final TaskExecutor executor;

    @Override
    public List<String> upload(String directory, List<MultipartFile> multipartFiles) {
        if (multipartFiles.isEmpty()) {
            return List.of();
        }

        List<CompletableFuture<String>> futures = multipartFiles.stream()
            .map(multipartFile -> CompletableFuture.supplyAsync(() -> uploadProcess(directory, multipartFile), executor))
            .toList();

        return gatherFileNamesFromFutures(directory, futures);
    }

    private String uploadProcess(String directory, MultipartFile multipartFile) {
        String originalFilename = multipartFile.getOriginalFilename();
        String fileFormatName = Objects.requireNonNull(multipartFile.getContentType()).substring(multipartFile.getContentType().lastIndexOf("/") + 1);

        MultipartFile resizedFile = ImageResizer.resizeImage(originalFilename, fileFormatName, multipartFile, 1920);

        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentLength(resizedFile.getSize());
        objectMetadata.setContentType(resizedFile.getContentType());

        try {
            amazonS3.putObject(bucket, DEFAULT_DIRECTORY + directory + originalFilename, resizedFile.getInputStream(), objectMetadata);
        } catch (IOException e) {
            log.warn("[Warning] Image Upload to S3 has some exception", e);
            throw new AmazonServiceException("Some Error occurred during Upload Image to S3 Server", e);
        }
        return convertURL(amazonS3.getUrl(bucket, originalFilename).toString(), directory);
    }

    private List<String> gatherFileNamesFromFutures(String directory, List<CompletableFuture<String>> futures) {
        List<String> fileNames = new ArrayList<>();
        AtomicBoolean catchException = new AtomicBoolean(false);
        futures.forEach(future -> {
            try {
                fileNames.add(future.join());
            } catch (CompletionException e) {
                catchException.set(true);
            }
        });
        handleException(directory, catchException, fileNames);
        return fileNames;
    }

    private void handleException(String directory, AtomicBoolean catchException, List<String> fileNames) {
        if (catchException.get()) {
            executor.execute(() -> delete(directory, fileNames));
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드 예외 발생.");
        }
    }
}

 

 

병렬처리 후 결과

5개의 이미지를 업로드하는데 약 4초에서 1초정도로 개선된 것을 확인할 수 있다.