이번에 새로운 서비스에 합류하게 됐는데,
(문제 발생 당시) DAU 50명일 때 갑자기 월 S3 요금만 50만원이 발생했다는 소식을 들었다.
-> 현재는 라즈베리파이로 이동해 있음. 명확한 해결은 없는 상태라 S3를 다시 사용한다면 언제 다시 발생할 지 모름.
그래서 이 문제를 해결하는 기록을 하려고 한다.
- 고민 기록..? -
그런데 제일 큰 문제는.. 이전에 개발하던 분이 남긴 로그도 없고,, 일단 요금 폭탄부터 막으려고 급하게 계정부터 삭제해버렸다는데.. 복구도 안된다ㅠ
이러면 로그도 못 보고 원인 파악이 불가능하지만, 이전 개발자분의 추측대로라면 S3 트래픽에서 발생한 요금이었다고 한다.
아무리 검색해도 DAU 50명의 구조에 이 정도 요금이 발생하는 건 발견하지 못해서,
내 나름대로 두 가지 가설을 세워봤다.
- 2024년에 잠시 붐이었던 S3 버킷명만 알면 악의적으로 어느 서버든 요금 폭탄을 만들 수 있었던 그 방법..? (마침 딱 그 시즌이어서 가능했을지도..?!) 링크 : https://youtu.be/propgtDEMgM?si=SN9cxMSeezCvxTfW
- 현재까지 쌓인 저장공간은 40GB이다. 뭐, 이미지 파일들을 조회하는 건 얼마나 됐는지 알 수 없으니.. 가능성이 0은 아니다.
여유 시간이 있었어서 거의 한 달 내내 이 문제만 붙잡고 살았는데, 다른 원인은 생각할 수도, 찾아볼 수도 없었다.
그래서 이제 그만 해야 할 해결 과정부터 정의를 내리려고 한다.
- 라즈베리파이에서 S3로 이전하려고 할 때, 40GB의 용량을 이전하는 방법을 고려해보다가, 현재 모든 이미지 파일이 원본 파일로 되어있는 걸 확인했다. 우선 이미지 파일 변환부터 시급하다.
- 우선 모든 이미지 파일을 Webp로 변경하려고 한다. 마이그레이션 할 새로운 레포에서 이미지 업로드 기능부터 작성.
- 기획 정책에서 정해준 기간 이전에 저장 되었던 이미지들은 전부 삭제 후 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());
}
}
테스트 결과
직접 이미지 파일을 넣어서 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는 저장 용량과 전송 트래픽에 따라 비용이 청구됩니다. 파일 크기가 작으면 저장 비용뿐만 아니라, 다운로드하는 동안 발생하는 전송 비용도 줄어듭니다.
- 성능 향상: 작은 파일을 빠르게 다운로드하면, 네트워크 대역폭을 효율적으로 사용할 수 있고, 더 빠르게 응답할 수 있습니다. 이는 특히 다수의 이미지 요청이 동시에 발생하는 웹 애플리케이션에서 중요한 요소가 됩니다.
'대충 넘어가지 않는 습관을 위한 기록' 카테고리의 다른 글
이미지 업로드 최적화 : 자바 - 코틀린 변환 기록 (0) | 2025.01.23 |
---|---|
S3 최적화 - 이미지 리사이징 (0) | 2025.01.22 |
Kafka 이벤트 수신 및 연동 기능 구현 기록 (ClassNotFoundException, SerializationException 에러 기록..) (1) | 2025.01.21 |
Bucket4j를 이용한 Rate Limit 적용 (2) | 2024.11.13 |
API 요청 방식 변경 : RestTemplate -> FeignClient (6) | 2024.11.12 |