-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 미사용 s3 객체 제거 스케쥴러 #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 2 commits
5061ccc
c9ff3cf
be90f1f
5f4ecdd
9f3cc22
704f920
d0ddd8c
e21f191
d979767
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package be.dash.dashserver.api.shceduler; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import org.springframework.context.annotation.Profile; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
| import be.dash.dashserver.core.exception.ImageStorageException; | ||
| import be.dash.dashserver.core.image.ImageService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Profile("dev") | ||
| @Component | ||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| public class S3CleanUpScheduler { | ||
|
|
||
| private final ImageService imageService; | ||
|
|
||
| @Scheduled(cron = "0 0 3 * * *") | ||
| public void cleanUp() { | ||
| log.info("S3 정리 작업 시작 {}", LocalDateTime.now()); | ||
| try { | ||
| imageService.cleanUpUnusedProfileImages(); | ||
| } catch (ImageStorageException e) { | ||
| log.error("S3 정리 작업 실패: {}", e.getMessage()); | ||
| if (!e.getFailedKeys().isEmpty()) { | ||
| log.error("삭제 실패한 키: {}", e.getFailedKeys()); | ||
| } | ||
| } | ||
| log.info("S3 정리 작업 끝 {}", LocalDateTime.now()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package be.dash.dashserver.core.exception; | ||
|
|
||
| import java.util.List; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public class ImageStorageException extends RuntimeException { | ||
| private List<String> failedKeys; | ||
| public ImageStorageException(String message) { | ||
| super(message); | ||
| } | ||
|
|
||
| public ImageStorageException(String message, List<String> failedKeys) { | ||
| super(message); | ||
| this.failedKeys = failedKeys; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,11 @@ | ||
| package be.dash.dashserver.core.image; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.List; | ||
| import org.springframework.web.multipart.MultipartFile; | ||
|
|
||
| public interface ImageUploader { | ||
| public interface ImageManager { | ||
| String uploadImage(MultipartFile file) throws IOException; | ||
| List<String> getAllKeys(); | ||
| void deleteAllByKeys(List<String> keysToDelete); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| package be.dash.dashserver.external.s3; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.ArrayList; | ||
| import java.util.Arrays; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.multipart.MultipartFile; | ||
| import be.dash.dashserver.core.exception.BadRequestException; | ||
| import be.dash.dashserver.core.exception.ImageStorageException; | ||
| import be.dash.dashserver.core.image.ImageManager; | ||
| import be.dash.dashserver.external.config.s3.S3Config; | ||
| import be.dash.dashserver.external.config.s3.S3Properties; | ||
| import lombok.RequiredArgsConstructor; | ||
| import software.amazon.awssdk.awscore.exception.AwsServiceException; | ||
| import software.amazon.awssdk.core.exception.SdkClientException; | ||
| import software.amazon.awssdk.core.sync.RequestBody; | ||
| import software.amazon.awssdk.services.s3.S3Client; | ||
| import software.amazon.awssdk.services.s3.model.Delete; | ||
| import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; | ||
| import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; | ||
| import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; | ||
| import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; | ||
| import software.amazon.awssdk.services.s3.model.ObjectIdentifier; | ||
| import software.amazon.awssdk.services.s3.model.PutObjectRequest; | ||
| import software.amazon.awssdk.services.s3.model.S3Error; | ||
| import software.amazon.awssdk.services.s3.model.S3Object; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class S3ImageManager implements ImageManager { | ||
| private final S3Properties s3Properties; | ||
| private final S3Config s3Config; | ||
| private static final List<String> IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp", "image/heic", "image/heif"); | ||
|
|
||
| @Override | ||
| public String uploadImage(MultipartFile image) throws IOException { | ||
| final String key = generateImageFileName(); | ||
| final S3Client s3Client = s3Config.getS3Client(); | ||
|
|
||
| validateExtension(image); | ||
|
|
||
| PutObjectRequest request = PutObjectRequest.builder() | ||
| .bucket(s3Properties.s3BucketName()) | ||
| .key(key) | ||
| .contentType(image.getContentType()) | ||
| .contentDisposition("inline") | ||
| .build(); | ||
|
|
||
| RequestBody requestBody = RequestBody.fromBytes(image.getBytes()); | ||
| s3Client.putObject(request, requestBody); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
try {
s3Client.putObject(request, requestBody);
} catch (S3Exception | SdkClientException e) {
// 이 블록에 들어온 순간 모든 자동 재시도를 다 했지만 실패했다는 의미
log.error("S3 업로드 실패: 모든 재시도 실패", e);
throw e;
}
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 재시도 모두 실패 시 네트워크 오류인지, 서비스 문제(권한, 버킷 존재 유무)인지 구분해서 로깅하도록 반영했습니다! |
||
| return s3Properties.s3Endpoint() + key; | ||
| } | ||
|
|
||
| @Override | ||
| public List<String> getAllKeys() { | ||
| //여기도 retrey붙여야 되나? 어차피 실패하면 내일 해버리면 떙 아닌감.. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
@Bean
public S3Client getS3Client() {
return S3Client.builder()
.region(getRegion())
.credentialsProvider(systemPropertyCredentialsProvider())
.overrideConfiguration(
ClientOverrideConfiguration.builder()
.retryPolicy(RetryPolicy.defaultRetryPolicy()) // 기본 Retry 정책 적용
.build()
)
.build();
}
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기서 구분한다는게 retry 여러번 후 실패, 1회 후 실패를 구분하자는 말씀이신가용??
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1회 실패 후 재시도 했을 때 성공이 되는 경우와 3번 모두 실패하는 장애상황을 구분하고자 했습니다! <logger name="software.amazon.awssdk.core.retry" level="DEBUG"/>
<logger name="software.amazon.awssdk.core.internal.http.pipeline.stages.RetryableStage" level="DEBUG"/>
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저희 |
||
| List<String> keys = new ArrayList<>(); | ||
| ListObjectsV2Request request = ListObjectsV2Request.builder() | ||
| .bucket(s3Properties.s3BucketName()) | ||
| .build(); | ||
|
|
||
| ListObjectsV2Response response; | ||
| S3Client s3Client = s3Config.getS3Client(); | ||
|
|
||
| do { | ||
| response = s3Client.listObjectsV2(request); | ||
| keys.addAll( | ||
| response.contents() | ||
| .stream() | ||
| .map(S3Object::key) | ||
| .toList() | ||
| ); | ||
| request = request.toBuilder() | ||
| .continuationToken(response.nextContinuationToken()) | ||
| .build(); | ||
| } while (response.isTruncated()); | ||
| return keys; | ||
| } | ||
|
|
||
| @Override | ||
| public void deleteAllByKeys(List<String> keysToDelete) { | ||
| S3Client s3Client = s3Config.getS3Client(); | ||
|
|
||
| List<ObjectIdentifier> list = keysToDelete.stream() | ||
| .map(key -> ObjectIdentifier.builder().key(key).build()).toList(); | ||
| DeleteObjectsRequest deleteObjectRequest = DeleteObjectsRequest.builder() | ||
| .bucket(s3Properties.s3BucketName()) | ||
| .delete(Delete.builder().objects(list).build()) | ||
| .build(); | ||
| DeleteObjectsResponse response; | ||
| try { | ||
| response = s3Client.deleteObjects(deleteObjectRequest); | ||
| } catch (AwsServiceException | SdkClientException e) { | ||
| throw new ImageStorageException("S3 객체 삭제에 실패했습니다."); | ||
| } | ||
|
|
||
| List<S3Error> errors = response.errors(); | ||
| if(!errors.isEmpty()) { | ||
| throw new ImageStorageException("이미지 삭제에 실패했습니다.", errors.stream().map(S3Error::key).toList()); | ||
| } | ||
| } | ||
|
|
||
| private String generateImageFileName() { | ||
| return UUID.randomUUID() + ".jpg"; | ||
| } | ||
|
|
||
| private void validateExtension(MultipartFile image) { | ||
| String contentType = image.getContentType(); | ||
| if (!IMAGE_EXTENSIONS.contains(contentType)) { | ||
| throw new BadRequestException("이미지 확장자는 jpg, png, webp, heic, heif만 가능합니다."); | ||
| } | ||
| } | ||
| } | ||
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
전역 핸들러에서
BadGatewayException을 잡고있는 부분이 없기에, 아래와 같이 새롭게 정의한ImageStorageException으로 처리해도 괜찮을 것 같습니다!전역 예외 핸들러에도 해당 예외를 추가해주세요!