[Spring] 이미지 최적화

Kyungmin·2024년 4월 30일
0

Spring

목록 보기
16/39
post-thumbnail

이번 프로젝트를 진행하면서 메인페이지인 지도에 이미지가 업로드되는 속도가 너무 느리다는 유저 피드백과...


그게 아니더라도 그 부분에 대해서 인지하고 있었기 때문에 필수적으로 이 부분에 대한 최적화를 진행해야겠다 생각하였고 진행한 부분에 대해 적어보고자 한다.

🛠️ thumbnailator 적용

➡️ build.gradle

// thumbnailator
implementation 'net.coobird:thumbnailator:0.4.14'

Thumbnailator 는 자바(JAVA)를 위한 간편하고 강력한 이미지 처리 라이브러리 입니다. 이 라이브러리는 이미지를 쉽게 리사이즈하거나 **변환할 수 있는 기능을 제공하여, 고품질의 썸네일 이미지 생성을 쉽게 만들어 줍니다. Thumbnailator는 단순한 API를 제공하며, 이미지의 크기 조정, 크롭, 회전, 품질 조절 등 다양한 작업을 지원합니다.

🤔 기존 코드

기존에 나는 클라이언트로 부터받은 이미지 원본을 그대로 AWS S3 에 저장하였다. 하지만 이는 앞서 설명한 것처럼 이미지의 양이 많아지고 용량이 높아짐에 따라 데이터 로딩 속도가 느려지는 현상으로 이어졌다.

기존 AWS S3 업로드

AwsS3Service 메서드

@Service
@RequiredArgsConstructor
@Slf4j
public class AwsS3Service {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final AmazonS3 amazonS3;

    public List<String> uploadFile(List<MultipartFile> multipartFiles, String dirName){
        if(multipartFiles.size() > 5) {
            throw new CustomException(ErrorCode.MAX_UPLOAD_PHOTO);
        }
        List<String> fileUrlList = new ArrayList<>();

        multipartFiles.forEach(file -> {
            String fileName = createFileName(file.getOriginalFilename());
            String filePath = dirName + "/" + fileName; // 폴더 경로 추가
            ObjectMetadata objectMetadata = new ObjectMetadata();
            objectMetadata.setContentLength(file.getSize());
            objectMetadata.setContentType(file.getContentType());

            log.info("Uploading file: {} | Size: {} | ContentType: {}", fileName, file.getSize(), file.getContentType());


            try (InputStream inputStream = file.getInputStream()) {
                amazonS3.putObject(new PutObjectRequest(bucket, filePath, inputStream, objectMetadata)
                        .withCannedAcl(CannedAccessControlList.PublicRead));

                // 추가
                String fileUrl = amazonS3.getUrl(bucket, filePath).toString();

                // S3 URL 생성 및 리스트에 추가
                fileUrlList.add(amazonS3.getUrl(bucket, filePath).toString());

                log.info("Uploaded file URL: {}", fileUrl);

            } catch (IOException e) {
                throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.");
            }
        });

        return fileUrlList;
    }



    // 먼저 파일 업로드시, 파일명을 난수화하기 위해 UUID 를 활용하여 난수를 돌린다.
    public String createFileName(String fileName){
        return UUID.randomUUID().toString().concat(getFileExtension(fileName));
    }

    // file 형식이 잘못된 경우를 확인하기 위해 만들어진 로직이며, 파일 타입과 상관없이 업로드할 수 있게 하기위해, "."의 존재 유무만 판단
    private String getFileExtension(String fileName){
        try{
            return fileName.substring(fileName.lastIndexOf("."));
        } catch (StringIndexOutOfBoundsException e){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 형식의 파일" + fileName + ") 입니다.");
        }
    }


    public void deleteFile(String fileName){
        amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName));
        System.out.println(bucket);
    }

기존 게시물 생성

createPost 메서드

 @Transactional
    public PostDto.ResponseDto createPost(User user, PostDto.RequestDto requestDto, List<MultipartFile> files) {
        User findUser = userService.getUserId(user.getId());

        Town town = new Town(
                requestDto.getLatitude(),
                requestDto.getLongitude(),
                requestDto.getDistrict(),
                requestDto.getPlaceName(),
                requestDto.getPlaceAddr()
        );

        Post post = Post.builder()
                .contents(requestDto.getContents())
                .category(requestDto.getCategory())
                .createdAt(LocalDateTime.now())
                .modifiedAt(LocalDateTime.now())
                .town(town)
                .user(findUser)
                .build();

        List<String> imageUrls = awsS3Service.uploadFile(files, "postImages");

        imageUrls.forEach(url -> {
            PostImage image = PostImage.builder()
                    .fileName(url)
                    .imgUrl(url)
                    .post(post)
                    .build();
            post.getImages().add(image);
        });


        Post savedPost = postRepository.save(post);
        PostSearch postSearch = createPostSearch(savedPost, imageUrls, findUser);
        postSearchRepository.save(postSearch);

        User userUp = post.getUser();
        if (promoteGrade(userUp)){
            userUp.updateRank(userUp.getRank());
        }

        return new PostDto.ResponseDto(post);

    }

😎 최적화 코드

<코드 동작 설명>

  1. 파일 제한 검사
    사용자가 업로드할 수 있는 파일 수를 최대 5개로 제한합니다. 이를 초과하면 CustomException을 통해 에러를 반환
  2. 파일 처리
    업로드된 각 파일에 대해 반복문을 실행합니다.
    createFileName 메소드를 통해 각 파일의 저장될 이름을 생성합니다.
  3. 원본 파일 저장
    파일의 바이트 배열을 읽어 ByteArrayInputStream을 생성합니다.
    AWS S3에 원본 이미지 파일을 저장하고, 접근 권한을 Public Read로 설정합니다.
    원본 이미지의 URL을 리스트에 추가합니다.
  4. 이미지 리사이즈 및 저장
    첫 번째 파일에 대해서만 리사이즈를 수행합니다 (isFirst 플래그 사용).
    Thumbnailator 라이브러리의 Thumbnails.of() 메소드를 사용하여 입력 스트림에서 이미지를 읽고, 60x60 픽셀 크기로 리사이즈한 후, 품질을 75%로 설정합니다.
    리사이즈된 이미지를 S3에 저장하고, 그 URL을 별도로 관리합니다.
  5. 응답 생성
    함수는 원본 이미지 URL 목록과 리사이즈된 이미지 URL을 포함하는 맵을 반환합니다. 이 맵은 API의 응답으로 사용될 수 있습니다.

✅ 파일 처리 및 원본 이미지 S3 저장

이 섹션에서는 업로드된 각 파일을 반복 처리합니다. 파일 이름은 createFileName 메소드를 통해 생성되며, 각 파일에 대한 메타데이터를 설정합니다.
파일의 바이트 배열을 읽어 ByteArrayInputStream으로 다시 패키징한 후, 이를 AWS S3에 업로드합니다. 파일은 PublicRead 권한으로 업로드되어 외부에서 접근 가능합니다. 업로드된 파일의 URL은 리스트에 추가되어 반환됩니다.

List<String> originalUrls = new ArrayList<>();
String compressedUrl = null;
boolean isFirst = true;

for (MultipartFile file : multipartFiles) {
    String fileName = createFileName(file.getOriginalFilename());
    String filePath = dirName + "/" + fileName;
    ObjectMetadata metadata = new ObjectMetadata();
    metadata.setContentLength(file.getSize());
    metadata.setContentType(file.getContentType());

    try (InputStream inputStream = file.getInputStream()) {
        byte[] bytes = inputStream.readAllBytes();
        InputStream fileInputStream = new ByteArrayInputStream(bytes);

        amazonS3.putObject(new PutObjectRequest(bucket, filePath, fileInputStream, metadata)
                          .withCannedAcl(CannedAccessControlList.PublicRead));
        String url = amazonS3.getUrl(bucket, filePath).toString();
        originalUrls.add(url);

✅ 이미지 리사이즈 및 압축 이미지 S3 저장

첫 번째 파일에 대해서만 이미지를 리사이즈하고 압축합니다. 이 과정에서 Thumbnailator 라이브러리를 사용하여 이미지 크기를 60x60 픽셀로 조정하고, 품질을 75%로 설정합니다.
리사이즈된 이미지는 별도의 파일로 S3에 저장되며, 압축된 이미지의 URL도 반환됩니다.

if (isFirst) {
    InputStream compressedInputStream = new ByteArrayInputStream(bytes);
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    Thumbnails.of(compressedInputStream).size(60, 60)
               .outputQuality(0.75)
               .toOutputStream(os);
    byte[] compressedImage = os.toByteArray();
    ByteArrayInputStream uploadInputStream = new ByteArrayInputStream(compressedImage);
    ObjectMetadata compressedMetadata = new ObjectMetadata();
    compressedMetadata.setContentLength(compressedImage.length);
    compressedMetadata.setContentType(file.getContentType());

    String compressedFilePath = "compressed_" + filePath;
    amazonS3.putObject(new PutObjectRequest(bucket, compressedFilePath, uploadInputStream, compressedMetadata)
                       .withCannedAcl(CannedAccessControlList.PublicRead));
    compressedUrl = amazonS3.getUrl(bucket, compressedFilePath).toString();
    isFirst = false;
}

✅ 응답 구성

함수는 원본 이미지 URL 목록과 압축 이미지의 URL을 포함하는 맵을 반환합니다. 이 맵은 원본과 리사이즈된 이미지에 대한 접근 정보를 제공하여, 클라이언트 측에서 필요에 따라 적절한 이미지를 선택할 수 있도록 합니다

Map<String, Object> response = new HashMap<>();
response.put("resizedUrl", compressedUrl);
response.put("originalUrls", originalUrls);
return response;

AWS S3 업로드

public Map<String, Object> uploadFile(List<MultipartFile> multipartFiles, String dirName) throws IOException {

        if(multipartFiles.size() > 5) {
            throw new CustomException(ErrorCode.MAX_UPLOAD_PHOTO);
        }

        List<String> originalUrls = new ArrayList<>();
        String compressedUrl = null;
        boolean isFirst = true;

        for (MultipartFile file : multipartFiles) {
            String fileName = createFileName(file.getOriginalFilename());
            String filePath = dirName + "/" + fileName;
            ObjectMetadata metadata = new ObjectMetadata();
            metadata.setContentLength(file.getSize());
            metadata.setContentType(file.getContentType());

            try (InputStream inputStream = file.getInputStream()) {
                byte[] bytes = inputStream.readAllBytes();  // InputStream을 byte 배열로 변환
                InputStream fileInputStream = new ByteArrayInputStream(bytes);  // 원본 파일 InputStream

                amazonS3.putObject(new PutObjectRequest(bucket, filePath, fileInputStream, metadata)
                        .withCannedAcl(CannedAccessControlList.PublicRead));
                String url = amazonS3.getUrl(bucket, filePath).toString();
                originalUrls.add(url);

                if (isFirst) {
                    InputStream compressedInputStream = new ByteArrayInputStream(bytes);  // 리사이즈를 위한 복사된 InputStream
                    ByteArrayOutputStream os = new ByteArrayOutputStream();
                    Thumbnails.of(compressedInputStream).size(60, 60)
                            .outputQuality(0.75)
                            .toOutputStream(os);
                    byte[] compressedImage = os.toByteArray();
                    ByteArrayInputStream uploadInputStream = new ByteArrayInputStream(compressedImage);
                    ObjectMetadata compressedMetadata = new ObjectMetadata();
                    compressedMetadata.setContentLength(compressedImage.length);
                    compressedMetadata.setContentType(file.getContentType());

                    String compressedFilePath = "compressed_" + filePath;
                    amazonS3.putObject(new PutObjectRequest(bucket, compressedFilePath, uploadInputStream, compressedMetadata)
                            .withCannedAcl(CannedAccessControlList.PublicRead));
                    compressedUrl = amazonS3.getUrl(bucket, compressedFilePath).toString();
                    isFirst = false;
                }
            }
        }
        Map<String, Object> response = new HashMap<>();
        response.put("resizedUrl", compressedUrl);
        response.put("originalUrls", originalUrls);
        return response;
    }

게시물 생성 서비스 로직

@Transactional
    public PostDto.ResponseDto createPost(User user, PostDto.RequestDto requestDto, List<MultipartFile> files) throws IOException {
        User findUser = userService.getUserId(user.getId());

        Town town = new Town(
                requestDto.getLatitude(),
                requestDto.getLongitude(),
                requestDto.getDistrict(),
                requestDto.getPlaceName(),
                requestDto.getPlaceAddr()
        );

        Post post = Post.builder()
                .contents(requestDto.getContents())
                .category(requestDto.getCategory())
                .createdAt(LocalDateTime.now())
                .modifiedAt(LocalDateTime.now())
                .town(town)
                .user(findUser)
                .build();

        Map<String, Object> uploadResults = awsS3Service.uploadFile(files, "postImages");
        String resizedImageUrl = (String) uploadResults.get("resizedUrl");
        List<String> originalUrls = (List<String>) uploadResults.get("originalUrls");

        // 리사이즈된 이미지
        PostImage resizeImage = PostImage.builder()
                .fileName(resizedImageUrl)
                .imgUrl(resizedImageUrl)
                .post(post)
                .build();
        post.getImages().add(resizeImage);

        // 원본 이미지 URL들을 각각 처리
        originalUrls.forEach(url -> {
            PostImage image = PostImage.builder()
                    .fileName(url)
                    .imgUrl(url)
                    .post(post)
                    .build();
            post.getImages().add(image);
        });

        Post savedPost = postRepository.save(post);
        PostSearch postSearch = createPostSearch(savedPost, originalUrls, findUser);
        postSearchRepository.save(postSearch);

        User userUp = post.getUser();
        if (promoteGrade(userUp)){
            userUp.updateRank(userUp.getRank());
        }

        return new PostDto.ResponseDto(savedPost);
    }

📣 결과

🏞️ 원본사진 & 파일 크기

🏞️ 리사이즈된 사진 & 파일 크기

👉 파일 크기 비교

<변경 전>

<변경 후>

결과적으로 1.4MB -> 11.5KB 로 줄어든 것을 알 수 있었고 99.2% 의 성능향상을 이끌어 낼 수 있었다.

profile
Backend Developer

0개의 댓글

관련 채용 정보