대충 넘어가지 않는 습관을 위한 기록

S3 최적화 - WebP 변환 및 이미지 압축 적용기

uhyvn 2025. 1. 21. 18:04

이번에 새로운 서비스에 합류하게 됐는데,

(문제 발생 당시) DAU 50명일 때 갑자기 월 S3 요금만 50만원이 발생했다는 소식을 들었다.

-> 현재는 라즈베리파이로 이동해 있음. 명확한 해결은 없는 상태라 S3를 다시 사용한다면 언제 다시 발생할 지 모름.

 

그래서 이 문제를 해결하는 기록을 하려고 한다.

 

 

- 고민 기록..? -

 

더보기

그런데 제일 큰 문제는.. 이전에 개발하던 분이 남긴 로그도 없고,, 일단 요금 폭탄부터 막으려고 급하게 계정부터 삭제해버렸다는데.. 복구도 안된다ㅠ

 

이러면 로그도 못 보고 원인 파악이 불가능하지만, 이전 개발자분의 추측대로라면 S3 트래픽에서 발생한 요금이었다고 한다.

아무리 검색해도 DAU 50명의 구조에 이 정도 요금이 발생하는 건 발견하지 못해서,

내 나름대로 두 가지 가설을 세워봤다.

  1. 2024년에 잠시 붐이었던 S3 버킷명만 알면 악의적으로 어느 서버든 요금 폭탄을 만들 수 있었던 그 방법..? (마침 딱 그 시즌이어서 가능했을지도..?!) 링크 : https://youtu.be/propgtDEMgM?si=SN9cxMSeezCvxTfW
  2. 현재까지 쌓인 저장공간은 40GB이다. 뭐, 이미지 파일들을 조회하는 건 얼마나 됐는지 알 수 없으니.. 가능성이 0은 아니다.

 

여유 시간이 있었어서 거의 한 달 내내 이 문제만 붙잡고 살았는데, 다른 원인은 생각할 수도, 찾아볼 수도 없었다.

그래서 이제 그만 해야 할 해결 과정부터 정의를 내리려고 한다.

 

  1. 라즈베리파이에서 S3로 이전하려고 할 때, 40GB의 용량을 이전하는 방법을 고려해보다가, 현재 모든 이미지 파일이 원본 파일로 되어있는 걸 확인했다. 우선 이미지 파일 변환부터 시급하다.
  2. 우선 모든 이미지 파일을 Webp로 변경하려고 한다. 마이그레이션 할 새로운 레포에서 이미지 업로드 기능부터 작성.
  3. 기획 정책에서 정해준 기간 이전에 저장 되었던 이미지들은 전부 삭제 후 Webp 변환.

이렇게 되면 저장공간 및 마이그레이션, 그리고 트래픽 문제는 어느정도 해결된다.

그러나..

 

Webp 변환을 하면 이미지 용량은 많이 압축되겠지만, 변환을 하는 과정이 로직에 추가됐기 때문에 이미지 업로드 기능에서 응답을 받기까지의 시간이 분명한 차이를 보일 것이다.

 

이 문제는 마지막에 언급하도록 하겠다.

문제 해결 과정은 여기까지만 작성하고,

 

 ... 이번 글은 Webp 변환에 대해서만 기록하려고 한다.

 

 

 


 

 

 

 

 

- 기본 설정 -

 

S3Controller

@RestController
@RequestMapping(value = {"/v1/api/images"})
@RequiredArgsConstructor
public class S3Controller {

    private final S3Service s3Service;

    @Operation(summary = "이미지 파일 업로드")
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<CommonResponse<ImageUploadResponse>> uploadImage(
        @Parameter(hidden = true) @LoginMember Member loginMember,
        @RequestPart(value = "image") MultipartFile image
    ) {
        return CommonResponse.success(SuccessCode.SUCCESS, s3Service.uploadMemberImage(image));
    }

}

 

테스트 편의를 위해 api를 하나 만들어버렸다.

 

 

S3Service

@Service
@RequiredArgsConstructor
public class S3Service {

    private final S3Uploader s3Uploader;

    public static final String CATEGORY_MEMBER = "member";

    public ImageUploadResponse uploadMemberImage(MultipartFile image) {
        UploadImageInfo uploadImageInfo = s3Uploader.uploadMultipartFileToBucket(CATEGORY_MEMBER, image);
        return new ImageUploadResponse(uploadImageInfo.ImageUrl());
    }
}

 

다른 로직들은 제거하고 이번 webp 변환만 이해하기 위해 해당 코드만 공유한다.

우리 서비스에서는 각 카테고리마다 s3 디렉토리 구조를 달리 하기 때문에 카테고리가 있다는 것만 알아도 좋다.

 

 

 

이제 webp 변환을 적용하기 전에 의존성부터 추가하자.

implementation("com.sksamuel.scrimage:scrimage-core:4.3.0")
implementation("com.sksamuel.scrimage:scrimage-webp:4.3.0")

 


 

 

 

- 주요 코드  -

S3Uploader

public class S3Uploader {

    // WebP 형식으로 변환한 후 S3에 파일을 업로드하는 메서드
    public UploadImageInfo uploadMultipartFileToBucket(String category, MultipartFile file) {
        String filePath = getFilePath(category);
        File convertedFile = convertToWebpWithResize(file, filePath);
        ObjectMetadata metadata = createMetadataFromFile(convertedFile);

        try (FileInputStream fileInputStream = new FileInputStream(convertedFile)) {
            amazonS3.putObject(
                new PutObjectRequest(bucket, filePath, fileInputStream, metadata)
                    .withCannedAcl(CannedAccessControlList.PublicRead)
            );
        } catch (Exception e) {
            throw new BaseException(ErrorCode.S3_UPLOADER_ERROR);
        }

        return new UploadImageInfo(getUrlFromBucket(filePath));
    }

    // WebP 형식으로 변환하고 리사이징하는 메서드
    public File convertToWebpWithResize(MultipartFile file, String fileName) {
        try {
            File originalFile = convertMultipartToFile(file);
            File webpFile = new File(createDatePath() + File.separator + fileName + ".webp");
            Files.createDirectories(webpFile.getParentFile().toPath());

            // 원본 이미지를 로드하고, 리사이징한 후 WebP 형식으로 출력
            ImmutableImage.loader()
                .fromFile(originalFile)
                .output(WebpWriter.DEFAULT, webpFile);

            return webpFile;
        } catch (Exception e) {
            throw new BaseException(ErrorCode.S3_UPLOADER_ERROR);
        }
    }

    private String getFilePath(String category) {
        return category + File.separator + createDatePath() + File.separator + generateRandomFilePrefix() + ".webp";
    }

    private ObjectMetadata createMetadataFromFile(File file) {
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentType("image/webp");
        metadata.setContentLength(file.length());
        return metadata;
    }

}

 

 

다른 로직을 제외하고 이해를 위한 코드만 남겨놓았다.

 

 

 

이번 포스팅에서 중요한 코드는 아래와 같다.

 

    public File convertToWebpWithResize(MultipartFile file, String fileName) {
        try {
            // MultipartFile을 일반 File로 변환
            File originalFile = convertMultipartToFile(file);
            
            // .webp 확장자를 가진 출력 파일 경로를 생성
            File webpFile = new File(createDatePath() + File.separator + fileName + ".webp");
            
            // 디렉토리 구조가 존재하는지 확인하고 없으면 생성
            Files.createDirectories(webpFile.getParentFile().toPath());

            // 원본 이미지를 로드하고, 리사이징한 후 WebP 형식으로 출력
            ImmutableImage.loader()
                .fromFile(originalFile)
                .output(WebpWriter.DEFAULT, webpFile);

            return webpFile;
        } catch (Exception e) {
            throw new BaseException(ErrorCode.S3_UPLOADER_ERROR); // 변환 오류에 대한 예외 처리
        }
    }

 

사실 압축률을 따로 커스텀하지도 않았고, 라이브러리를 이용한 간단한 압축이라서 어려운 코드는 하나도 없을 것이다.

 

 

 

 

바로 테스트 결과를 보자면,

 


 

 

 

 

 

테스트 코드

@SpringBootTest
class S3UploaderTest {

    @Autowired
    private S3Uploader s3Uploader;

    @Test
    public void testConvertToWebpWithResize() throws Exception {
        // Given
        File testFile = new ClassPathResource("DSCF1381.JPG").getFile();
        MockMultipartFile multipartFile = new MockMultipartFile(
            "file",
            "DSCF1381.JPG",
            "image/jpeg",
            new FileInputStream(testFile)
        );
        String filePath = getFilePath("member");

        // When
        File convertedFile = s3Uploader.convertToWebpWithResize(multipartFile, filePath);

        // Then
        double originalFileSizeKB = testFile.length() / 1024.0;
        double convertedFileSizeKB = convertedFile.length() / 1024.0;

        double compressionRate = 100 - (convertedFileSizeKB / originalFileSizeKB) * 100; // 압축률 계산

        System.out.printf("Original File Size: %.2f KB%n", originalFileSizeKB);
        System.out.printf("Converted File Size: %.2f KB%n", convertedFileSizeKB);
        System.out.printf("Compression Rate: %.2f%%%n", compressionRate);

        assertTrue(compressionRate > 0, "압축률이 0% 이상이어야 합니다.");
        Files.deleteIfExists(convertedFile.toPath());
    }
    
}

 

 

 

 

 

테스트 결과

01234567

 

 

직접 이미지 파일을 넣어서 webp로 변환하는 로직을 테스트한 결과,

평균 약 85% 압축에 성공했다.

 

테스트 코드를 통해 변환된 WebP 파일의 크기와 원본 파일의 크기를 비교하여 압축률을 계산할 수 있었다.

이를 통해 변환 과정에서 얼마나 효율적으로 이미지 크기가 줄어들었는지 확인하고, 예상했던 압축 효율을 얻었는지 판단할 수 있었다.

 

예시로 아래 사진은 파일 해상도 비교를 위해 넣어봤다.

 

위는 이미지 압축과 리사이징까지 완료한 webp 파일이고, 용량은 무려 11KB 이다.

그리고 아래는 원본 이미지 jpg 파일이고, 용량은 4MB 이다.

 

99.73% 압축률에 비교하면 화질 차이는 거의 미비하다고 볼 수 있다!

 

 

 


 

 

 

 

 

 

 

그런데 예상대로 또 다른 문제가 발생했다.

s3 트래픽 및 저장공간 최적화는 되었으나, Webp를 변환하는 로직을 추가했기 때문에
사용자가 이미지를 업로드하는 시간이 어마어마하게 늘어났다는 것이다......

 

 

이걸 해결하려면, 위의 접은 글에서 언급했던 방식 외에도 비동기 처리를 이용한 방법, cdn 캐싱까지 총 세 가지의 방법이 있다.

 

그러나 우리 서버는 아직 t2.micro를 사용하기 때문에 ,
기본적으로 성능이 제한적이고 CPU 리소스가 제한되어 있어서 비동기 처리로 인한 큰 효과를 볼 수 없겠다는 판단이 들었다.

 

그래서 여러 방법으로 시도해봤으나, 변환하기 전에 이미지를 리사이징하고 변환을 하면 업로드 시간이 대폭 줄어드는 것을 확인할 수 있었다.

 

다음 s3 최적화는 이미지 리사이징 적용기가 되겠다..!

 

 

 

자잘한 검색 기록

 

왜 사진마다 압축되는 퍼센트가 다를까?

더보기

이미지의 압축률은 다음과 같은 여러 요인에 따라 달라집니다.

  • 원본 이미지의 형식: JPG, PNG, BMP 등의 포맷은 각기 다른 압축 특성을 가지고 있습니다. 예를 들어, JPG 파일은 기본적으로 손실 압축을 사용하고, PNG는 무손실 압축을 사용합니다. JPG는 더 높은 압축률을 제공하는 경향이 있습니다.
  • 이미지 내용: 이미지에 복잡한 패턴이나 많은 세부 사항이 포함되어 있으면 압축률이 낮을 수 있습니다. 반면, 단색 배경이나 단순한 디자인의 이미지는 압축률이 높을 수 있습니다.
  • 파일 크기: 이미지 파일이 이미 압축된 상태라면 (예: 기존의 JPG 파일), 추가 압축을 해도 크기가 크게 줄어들지 않을 수 있습니다. 원본 파일이 압축되지 않은 경우 더 많은 압축이 가능할 수 있습니다.

압축이 S3 트래픽에 미치는 영향

더보기

 

  • 전송 대역폭 절약: 압축된 이미지 파일은 더 적은 데이터 양을 전송하므로, 이를 통해 사용되는 트래픽량을 줄일 수 있습니다.
  • 비용 절감: AWS S3는 저장 용량과 전송 트래픽에 따라 비용이 청구됩니다. 파일 크기가 작으면 저장 비용뿐만 아니라, 다운로드하는 동안 발생하는 전송 비용도 줄어듭니다.
  • 성능 향상: 작은 파일을 빠르게 다운로드하면, 네트워크 대역폭을 효율적으로 사용할 수 있고, 더 빠르게 응답할 수 있습니다. 이는 특히 다수의 이미지 요청이 동시에 발생하는 웹 애플리케이션에서 중요한 요소가 됩니다.