From 5061ccce67c364a9abaf2de93508aea42f462d0c Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Sat, 26 Apr 2025 07:20:01 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20s3=20?= =?UTF-8?q?object=20=EC=82=AD=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/shceduler/S3CleanUpScheduler.java | 35 ++++++ .../member/service/MemberRepository.java | 2 + .../core/exception/ImageStorageException.java | 17 +++ .../{ImageUploader.java => ImageManager.java} | 5 +- .../dashserver/core/image/ImageService.java | 14 ++- .../core/member/MemberJpaRepository.java | 4 + .../core/member/MemberRepositoryAdapter.java | 5 + .../external/s3/S3ImageManager.java | 118 ++++++++++++++++++ .../external/s3/S3ImageUploader.java | 55 -------- 9 files changed, 197 insertions(+), 58 deletions(-) create mode 100644 src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java create mode 100644 src/main/java/be/dash/dashserver/core/exception/ImageStorageException.java rename src/main/java/be/dash/dashserver/core/image/{ImageUploader.java => ImageManager.java} (57%) create mode 100755 src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java delete mode 100755 src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java diff --git a/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java b/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java new file mode 100644 index 0000000..8b5e7a5 --- /dev/null +++ b/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java @@ -0,0 +1,35 @@ +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()); + + } + +} diff --git a/src/main/java/be/dash/dashserver/core/domain/member/service/MemberRepository.java b/src/main/java/be/dash/dashserver/core/domain/member/service/MemberRepository.java index ee0086c..b526b22 100755 --- a/src/main/java/be/dash/dashserver/core/domain/member/service/MemberRepository.java +++ b/src/main/java/be/dash/dashserver/core/domain/member/service/MemberRepository.java @@ -25,4 +25,6 @@ public interface MemberRepository { void update(Member member); Optional findNicknameById(long memberId); + + List findAllProfileImages(); } diff --git a/src/main/java/be/dash/dashserver/core/exception/ImageStorageException.java b/src/main/java/be/dash/dashserver/core/exception/ImageStorageException.java new file mode 100644 index 0000000..a467f5e --- /dev/null +++ b/src/main/java/be/dash/dashserver/core/exception/ImageStorageException.java @@ -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 failedKeys; + public ImageStorageException(String message) { + super(message); + } + + public ImageStorageException(String message, List failedKeys) { + super(message); + this.failedKeys = failedKeys; + } +} diff --git a/src/main/java/be/dash/dashserver/core/image/ImageUploader.java b/src/main/java/be/dash/dashserver/core/image/ImageManager.java similarity index 57% rename from src/main/java/be/dash/dashserver/core/image/ImageUploader.java rename to src/main/java/be/dash/dashserver/core/image/ImageManager.java index ad6ab33..905327f 100755 --- a/src/main/java/be/dash/dashserver/core/image/ImageUploader.java +++ b/src/main/java/be/dash/dashserver/core/image/ImageManager.java @@ -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 getAllKeys(); + void deleteAllByKeys(List keysToDelete); } diff --git a/src/main/java/be/dash/dashserver/core/image/ImageService.java b/src/main/java/be/dash/dashserver/core/image/ImageService.java index bbb6fa2..ce59918 100755 --- a/src/main/java/be/dash/dashserver/core/image/ImageService.java +++ b/src/main/java/be/dash/dashserver/core/image/ImageService.java @@ -1,8 +1,10 @@ package be.dash.dashserver.core.image; import java.io.IOException; +import java.util.List; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import be.dash.dashserver.core.domain.member.service.MemberRepository; import be.dash.dashserver.core.exception.BadGatewayException; import be.dash.dashserver.core.log.annotation.Trace; import lombok.RequiredArgsConstructor; @@ -11,13 +13,21 @@ @Service @RequiredArgsConstructor public class ImageService { - private final ImageUploader imageUploader; + private final ImageManager imageManager; + private final MemberRepository memberRepository; public String upload(MultipartFile file) { try { - return imageUploader.uploadImage(file); + return imageManager.uploadImage(file); } catch (IOException e) { throw new BadGatewayException("이미지 업로드 중에 오류가 발생했습니다."); } } + + public void cleanUpUnusedProfileImages() { + List profileImages = memberRepository.findAllProfileImages(); + List keysToDelete = imageManager.getAllKeys().stream() + .filter(key -> !profileImages.contains(key)).toList(); + imageManager.deleteAllByKeys(keysToDelete); + } } diff --git a/src/main/java/be/dash/dashserver/database/core/member/MemberJpaRepository.java b/src/main/java/be/dash/dashserver/database/core/member/MemberJpaRepository.java index ee1c280..c22dbbe 100755 --- a/src/main/java/be/dash/dashserver/database/core/member/MemberJpaRepository.java +++ b/src/main/java/be/dash/dashserver/database/core/member/MemberJpaRepository.java @@ -1,5 +1,6 @@ package be.dash.dashserver.database.core.member; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -16,4 +17,7 @@ public interface MemberJpaRepository extends JpaRepository findNicknameById(long memberId); + + @Query("select m.profileImageUrl from MemberJpaEntity m") + List findAllProfileImages(); } diff --git a/src/main/java/be/dash/dashserver/database/core/member/MemberRepositoryAdapter.java b/src/main/java/be/dash/dashserver/database/core/member/MemberRepositoryAdapter.java index d8dbf10..5a38d0b 100755 --- a/src/main/java/be/dash/dashserver/database/core/member/MemberRepositoryAdapter.java +++ b/src/main/java/be/dash/dashserver/database/core/member/MemberRepositoryAdapter.java @@ -85,4 +85,9 @@ public void update(Member member) { public Optional findNicknameById(long memberId) { return memberJpaRepository.findNicknameById(memberId); } + + @Override + public List findAllProfileImages() { + return memberJpaRepository.findAllProfileImages(); + } } diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java new file mode 100755 index 0000000..9c83fcd --- /dev/null +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java @@ -0,0 +1,118 @@ +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 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); + return s3Properties.s3Endpoint() + key; + } + + @Override + public List getAllKeys() { + //여기도 retrey붙여야 되나? 어차피 실패하면 내일 해버리면 떙 아닌감.. + List 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 keysToDelete) { + S3Client s3Client = s3Config.getS3Client(); + + List 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 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만 가능합니다."); + } + } +} diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java deleted file mode 100755 index d26ac01..0000000 --- a/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java +++ /dev/null @@ -1,55 +0,0 @@ -package be.dash.dashserver.external.s3; - -import java.io.IOException; -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.image.ImageUploader; -import be.dash.dashserver.external.config.s3.S3Config; -import be.dash.dashserver.external.config.s3.S3Properties; -import lombok.RequiredArgsConstructor; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; - -@Component -@RequiredArgsConstructor -public class S3ImageUploader implements ImageUploader { - private final S3Properties s3Properties; - private final S3Config s3Config; - private static final List 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); - return s3Properties.s3Endpoint() + key; - } - - 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만 가능합니다."); - } - } -} From c9ff3cf14428a7d44b6f18dda48c99ead7020471 Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Sat, 26 Apr 2025 07:25:07 +0900 Subject: [PATCH 2/9] =?UTF-8?q?style:=20=EA=B0=9C=ED=96=89=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java | 2 -- .../java/be/dash/dashserver/external/s3/S3ImageManager.java | 3 --- 2 files changed, 5 deletions(-) diff --git a/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java b/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java index 8b5e7a5..b6bb71f 100644 --- a/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java +++ b/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java @@ -29,7 +29,5 @@ public void cleanUp() { } } log.info("S3 정리 작업 끝 {}", LocalDateTime.now()); - } - } diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java index 9c83fcd..d5af7f6 100755 --- a/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java @@ -76,7 +76,6 @@ public List getAllKeys() { .continuationToken(response.nextContinuationToken()) .build(); } while (response.isTruncated()); - return keys; } @@ -101,14 +100,12 @@ public void deleteAllByKeys(List keysToDelete) { 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)) { From be90f1fdb4a67d59f6ed70c428154b511c275c62 Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Tue, 29 Apr 2025 16:08:22 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20retry=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashserver/core/image/ImageDeleter.java | 7 ++ .../dashserver/core/image/ImageManager.java | 11 -- .../dashserver/core/image/ImageReader.java | 7 ++ .../dashserver/core/image/ImageService.java | 31 +++-- .../dashserver/core/image/ImageUploader.java | 7 ++ .../external/config/s3/S3Config.java | 17 +++ .../external/s3/S3ImageDeleter.java | 63 ++++++++++ .../external/s3/S3ImageManager.java | 115 ------------------ .../dashserver/external/s3/S3ImageReader.java | 64 ++++++++++ .../external/s3/S3ImageUploader.java | 71 +++++++++++ 10 files changed, 257 insertions(+), 136 deletions(-) create mode 100644 src/main/java/be/dash/dashserver/core/image/ImageDeleter.java delete mode 100755 src/main/java/be/dash/dashserver/core/image/ImageManager.java create mode 100644 src/main/java/be/dash/dashserver/core/image/ImageReader.java create mode 100755 src/main/java/be/dash/dashserver/core/image/ImageUploader.java create mode 100755 src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java delete mode 100755 src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java create mode 100755 src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java create mode 100755 src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java diff --git a/src/main/java/be/dash/dashserver/core/image/ImageDeleter.java b/src/main/java/be/dash/dashserver/core/image/ImageDeleter.java new file mode 100644 index 0000000..47f2767 --- /dev/null +++ b/src/main/java/be/dash/dashserver/core/image/ImageDeleter.java @@ -0,0 +1,7 @@ +package be.dash.dashserver.core.image; + +import java.util.List; + +public interface ImageDeleter { + void deleteAllByKeys(List keysToDelete); +} diff --git a/src/main/java/be/dash/dashserver/core/image/ImageManager.java b/src/main/java/be/dash/dashserver/core/image/ImageManager.java deleted file mode 100755 index 905327f..0000000 --- a/src/main/java/be/dash/dashserver/core/image/ImageManager.java +++ /dev/null @@ -1,11 +0,0 @@ -package be.dash.dashserver.core.image; - -import java.io.IOException; -import java.util.List; -import org.springframework.web.multipart.MultipartFile; - -public interface ImageManager { - String uploadImage(MultipartFile file) throws IOException; - List getAllKeys(); - void deleteAllByKeys(List keysToDelete); -} diff --git a/src/main/java/be/dash/dashserver/core/image/ImageReader.java b/src/main/java/be/dash/dashserver/core/image/ImageReader.java new file mode 100644 index 0000000..ac96742 --- /dev/null +++ b/src/main/java/be/dash/dashserver/core/image/ImageReader.java @@ -0,0 +1,7 @@ +package be.dash.dashserver.core.image; + +import java.util.List; + +public interface ImageReader { + List getAllKeys(); +} diff --git a/src/main/java/be/dash/dashserver/core/image/ImageService.java b/src/main/java/be/dash/dashserver/core/image/ImageService.java index ce59918..412fe31 100755 --- a/src/main/java/be/dash/dashserver/core/image/ImageService.java +++ b/src/main/java/be/dash/dashserver/core/image/ImageService.java @@ -1,11 +1,10 @@ package be.dash.dashserver.core.image; -import java.io.IOException; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import be.dash.dashserver.core.domain.member.service.MemberRepository; -import be.dash.dashserver.core.exception.BadGatewayException; +import be.dash.dashserver.core.exception.BadRequestException; import be.dash.dashserver.core.log.annotation.Trace; import lombok.RequiredArgsConstructor; @@ -13,21 +12,33 @@ @Service @RequiredArgsConstructor public class ImageService { - private final ImageManager imageManager; + + private static final List IMAGE_EXTENSIONS = List.of( + "image/jpeg", "image/png", "image/jpg", "image/webp", "image/heic", "image/heif" + ); + + private final ImageUploader imageUploader; + private final ImageReader imageReader; + private final ImageDeleter imageDeleter; private final MemberRepository memberRepository; public String upload(MultipartFile file) { - try { - return imageManager.uploadImage(file); - } catch (IOException e) { - throw new BadGatewayException("이미지 업로드 중에 오류가 발생했습니다."); - } + validateExtension(file); + return imageUploader.upload(file); } public void cleanUpUnusedProfileImages() { List profileImages = memberRepository.findAllProfileImages(); - List keysToDelete = imageManager.getAllKeys().stream() + List keysToDelete = imageReader.getAllKeys().stream() + // images 도메인 용도별로 분리 후 도메인 로직으로 넣기 .filter(key -> !profileImages.contains(key)).toList(); - imageManager.deleteAllByKeys(keysToDelete); + imageDeleter.deleteAllByKeys(keysToDelete); + } + + private void validateExtension(MultipartFile image) { + String contentType = image.getContentType(); + if (!IMAGE_EXTENSIONS.contains(contentType)) { + throw new BadRequestException("이미지 확장자는 jpg, png, webp, heic, heif만 가능합니다."); + } } } diff --git a/src/main/java/be/dash/dashserver/core/image/ImageUploader.java b/src/main/java/be/dash/dashserver/core/image/ImageUploader.java new file mode 100755 index 0000000..eef67de --- /dev/null +++ b/src/main/java/be/dash/dashserver/core/image/ImageUploader.java @@ -0,0 +1,7 @@ +package be.dash.dashserver.core.image; + +import org.springframework.web.multipart.MultipartFile; + +public interface ImageUploader { + String upload(MultipartFile file); +} diff --git a/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java b/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java index 0c1108d..b548d4d 100755 --- a/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java +++ b/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java @@ -1,10 +1,15 @@ package be.dash.dashserver.external.config.s3; +import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import lombok.RequiredArgsConstructor; import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.core.retry.conditions.RetryCondition; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; @@ -31,9 +36,21 @@ public Region getRegion() { @Bean public S3Client getS3Client() { + ClientOverrideConfiguration overrideConfig = ClientOverrideConfiguration.builder() + .apiCallAttemptTimeout(Duration.ofSeconds(3)) + .apiCallTimeout(Duration.ofSeconds(14)) + .retryPolicy( + RetryPolicy.builder() + .numRetries(3) + .retryCondition(RetryCondition.defaultRetryCondition()) + .backoffStrategy(BackoffStrategy.defaultStrategy()) + .build() + ) + .build(); return S3Client.builder() .region(getRegion()) .credentialsProvider(systemPropertyCredentialsProvider()) + .overrideConfiguration(overrideConfig) .build(); } } diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java new file mode 100755 index 0000000..928ff32 --- /dev/null +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java @@ -0,0 +1,63 @@ +package be.dash.dashserver.external.s3; + +import java.util.List; +import org.springframework.stereotype.Component; +import be.dash.dashserver.core.exception.ImageStorageException; +import be.dash.dashserver.core.image.ImageDeleter; +import be.dash.dashserver.external.config.s3.S3Config; +import be.dash.dashserver.external.config.s3.S3Properties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +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.ObjectIdentifier; +import software.amazon.awssdk.services.s3.model.S3Error; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3ImageDeleter implements ImageDeleter { + private final S3Properties s3Properties; + private final S3Config s3Config; + + @Override + public void deleteAllByKeys(List keysToDelete) { + if (keysToDelete.isEmpty()) { + return; + } + DeleteObjectsRequest request = buildRequest(keysToDelete); + DeleteObjectsResponse response = performDeleteAllByKeys(request); + handlePartialDeleteErrors(response); + } + + private DeleteObjectsResponse performDeleteAllByKeys(DeleteObjectsRequest deleteObjectRequest) { + try { + return s3Config.getS3Client().deleteObjects(deleteObjectRequest); + } catch (AwsServiceException e) { + log.error("S3 삭제 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage()); + throw new ImageStorageException("이미지 삭제에 실패했습니다.(서비스 오류)"); + } catch (SdkClientException e) { + log.error("S3 삭제 실패 - 에러메시지 : {}", e.getMessage()); + throw new ImageStorageException("이미지 삭제에 실패했습니다.(내부 네트워크 오류)"); + } + } + + private DeleteObjectsRequest buildRequest(List keysToDelete) { + List list = keysToDelete.stream() + .map(key -> ObjectIdentifier.builder().key(key).build()).toList(); + return DeleteObjectsRequest.builder() + .bucket(s3Properties.s3BucketName()) + .delete(Delete.builder().objects(list).build()) + .build(); + } + + private void handlePartialDeleteErrors(DeleteObjectsResponse response) { + List errors = response.errors(); + if(!errors.isEmpty()) { + throw new ImageStorageException("이미지 일부 삭제에 실패했습니다.", errors.stream().map(S3Error::key).toList()); + } + } +} diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java deleted file mode 100755 index d5af7f6..0000000 --- a/src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java +++ /dev/null @@ -1,115 +0,0 @@ -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 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); - return s3Properties.s3Endpoint() + key; - } - - @Override - public List getAllKeys() { - //여기도 retrey붙여야 되나? 어차피 실패하면 내일 해버리면 떙 아닌감.. - List 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 keysToDelete) { - S3Client s3Client = s3Config.getS3Client(); - - List 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 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만 가능합니다."); - } - } -} diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java new file mode 100755 index 0000000..3015c65 --- /dev/null +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java @@ -0,0 +1,64 @@ +package be.dash.dashserver.external.s3; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; +import be.dash.dashserver.core.exception.ImageStorageException; +import be.dash.dashserver.core.image.ImageReader; +import be.dash.dashserver.external.config.s3.S3Config; +import be.dash.dashserver.external.config.s3.S3Properties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.S3Object; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3ImageReader implements ImageReader { + private final S3Properties s3Properties; + private final S3Config s3Config; + + @Override + public List getAllKeys() { + try { + return performGetAllKeys(); + } catch (AwsServiceException e) { + log.error("S3 키 목록 조회 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage()); + throw new ImageStorageException("키 조회에 실패했습니다.(서비스 오류)"); + } catch (SdkClientException e) { + log.error("S3 키 목록 조회 실패 - 에러메시지 : {}", e.getMessage()); + throw new ImageStorageException("키 조회에 실패했습니다.(내부 네트워크 오류)"); + } + } + + private List performGetAllKeys() { + S3Client s3Client = s3Config.getS3Client(); + ListObjectsV2Request request = buildRequest(); + List keys = new ArrayList<>(); + ListObjectsV2Response response; + 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; + } + + private ListObjectsV2Request buildRequest() { + return ListObjectsV2Request.builder() + .bucket(s3Properties.s3BucketName()) + .build(); + } +} diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java new file mode 100755 index 0000000..9077c3e --- /dev/null +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java @@ -0,0 +1,71 @@ +package be.dash.dashserver.external.s3; + +import java.io.IOException; +import java.util.UUID; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import be.dash.dashserver.core.exception.ImageStorageException; +import be.dash.dashserver.core.image.ImageUploader; +import be.dash.dashserver.external.config.s3.S3Config; +import be.dash.dashserver.external.config.s3.S3Properties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.model.PutObjectRequest; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3ImageUploader implements ImageUploader { + private static final String IMAGE_EXTENSION = ".jpg"; + private static final String CONTENT_DISPOSITION = "inline"; + + private final S3Properties s3Properties; + private final S3Config s3Config; + + @Override + public String upload(MultipartFile image) { + final String key = generateImageFileName(); + PutObjectRequest request = buildRequest(image, key); + RequestBody requestBody = toRequestBody(image); + performUpload(request, requestBody); + return s3Properties.s3Endpoint() + key; + } + + private String generateImageFileName() { + return UUID.randomUUID() + IMAGE_EXTENSION; + } + + private PutObjectRequest buildRequest(MultipartFile image, String key) { + return PutObjectRequest.builder() + .bucket(s3Properties.s3BucketName()) + .key(key) + .contentType(image.getContentType()) + .contentDisposition(CONTENT_DISPOSITION) + .build(); + } + + private static RequestBody toRequestBody(MultipartFile image) { + RequestBody requestBody; + try { + requestBody = RequestBody.fromBytes(image.getBytes()); + } catch (IOException e) { + throw new ImageStorageException("이미지 저장에 실패했습니다."); + } + return requestBody; + } + + private void performUpload(PutObjectRequest request, RequestBody requestBody) { + try { + s3Config.getS3Client().putObject(request, requestBody); + } catch (AwsServiceException e) { + log.error("S3 업로드 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage()); + throw new ImageStorageException("이미지 저장에 실패했습니다.(서비스 오류)"); + } catch (SdkClientException e) { + log.error("S3 업로드 실패 - 에러메시지 : {}", e.getMessage()); + throw new ImageStorageException("이미지 저장에 실패했습니다.(내부 네트워크 오류)"); + } + } +} From 5f4ecdde9509f1a8cc6310ac6dbb8de32431a588 Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Tue, 29 Apr 2025 16:27:32 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20GlobalExceptionHandler=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashserver/api/support/GlobalExceptionHandler.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java b/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java index 1d46c45..6a79294 100755 --- a/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java +++ b/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java @@ -17,6 +17,7 @@ import be.dash.dashserver.core.exception.BadRequestException; import be.dash.dashserver.core.exception.ConflictException; import be.dash.dashserver.core.exception.ForbiddenException; +import be.dash.dashserver.core.exception.ImageStorageException; import be.dash.dashserver.core.exception.NotFoundException; import be.dash.dashserver.core.exception.PaymentClientException; import be.dash.dashserver.core.log.LogForm; @@ -118,6 +119,12 @@ public ResponseEntity handleDashApiException(DashApiException e) { return ResponseEntity.badRequest().body(new ErrorMessage(e.getMessage())); } + @ExceptionHandler(ImageStorageException.class) + public ResponseEntity handleImageStorageException(ImageStorageException e) { + log.warn("handleImageStorageException in GlobalExceptionHandler throw {} : {}", e.getClass(), e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorMessage(e.getMessage())); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error(LogForm.ERROR_LOGGING_FORM, e.getClass(), e.getMessage(), e.getStackTrace()); From 9f3cc224125ea20729d8fac050dafa2faffb2bdd Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Tue, 29 Apr 2025 16:45:47 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20ImageStorageException=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=A0=88=EB=B2=A8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/dash/dashserver/api/support/GlobalExceptionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java b/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java index 6a79294..03e65ca 100755 --- a/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java +++ b/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java @@ -121,7 +121,7 @@ public ResponseEntity handleDashApiException(DashApiException e) { @ExceptionHandler(ImageStorageException.class) public ResponseEntity handleImageStorageException(ImageStorageException e) { - log.warn("handleImageStorageException in GlobalExceptionHandler throw {} : {}", e.getClass(), e.getMessage()); + log.error("handleImageStorageException in GlobalExceptionHandler throw {} : {}", e.getClass(), e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorMessage(e.getMessage())); } From 704f92069de4399a44342b72ae5a88b2fe116110 Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Mon, 5 May 2025 23:11:05 +0900 Subject: [PATCH 6/9] =?UTF-8?q?test:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../external/config/s3/LocalStackConfig.java | 47 +++++++++++ .../external/config/s3/S3Config.java | 6 +- .../external/s3/S3ImageDeleter.java | 6 +- .../dashserver/external/s3/S3ImageReader.java | 6 +- .../external/s3/S3ImageUploader.java | 6 +- .../core/image/ImageServiceTest.java | 83 +++++++++++++++++++ .../fixture/MemberJpaEntityFixture.java | 1 + src/test/resources/application-test.yml | 4 +- 9 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 src/main/java/be/dash/dashserver/external/config/s3/LocalStackConfig.java create mode 100644 src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java diff --git a/build.gradle b/build.gradle index 9d8c3a8..dbb73ec 100755 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,8 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation("org.testcontainers:junit-jupiter") + implementation("org.testcontainers:localstack") // AWS implementation("software.amazon.awssdk:s3:2.21.0") diff --git a/src/main/java/be/dash/dashserver/external/config/s3/LocalStackConfig.java b/src/main/java/be/dash/dashserver/external/config/s3/LocalStackConfig.java new file mode 100644 index 0000000..21c0ce6 --- /dev/null +++ b/src/main/java/be/dash/dashserver/external/config/s3/LocalStackConfig.java @@ -0,0 +1,47 @@ +package be.dash.dashserver.external.config.s3; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import lombok.RequiredArgsConstructor; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; + +@Configuration +@Profile({"local", "test"}) +@RequiredArgsConstructor +@EnableConfigurationProperties(S3Properties.class) +public class LocalStackConfig { + + private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:3.5.0"); + + private final S3Properties s3Properties; + + @Bean(initMethod = "start", destroyMethod = "stop") + public LocalStackContainer localStackContainer() { + return new LocalStackContainer(LOCALSTACK_IMAGE) + .withServices(S3); + } + + @Bean + public S3Client s3Client(LocalStackContainer localStackContainer) { + S3Client client = S3Client.builder() + .endpointOverride(localStackContainer.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localStackContainer.getAccessKey(), localStackContainer.getSecretKey()) + ) + ) + .region(Region.of(localStackContainer.getRegion())) + .build(); + client.createBucket(b -> b.bucket(s3Properties.s3BucketName())); + return client; + } +} diff --git a/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java b/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java index b548d4d..a573f9b 100755 --- a/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java +++ b/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java @@ -1,9 +1,10 @@ package be.dash.dashserver.external.config.s3; import java.time.Duration; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import lombok.RequiredArgsConstructor; import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; @@ -14,8 +15,9 @@ import software.amazon.awssdk.services.s3.S3Client; @Configuration +@Profile("develop") @RequiredArgsConstructor -@ConfigurationPropertiesScan(basePackages = "be.dash.dashserver.external.config.s3") +@EnableConfigurationProperties(S3Properties.class) public class S3Config { private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java index 928ff32..96cb5f3 100755 --- a/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java @@ -4,12 +4,12 @@ import org.springframework.stereotype.Component; import be.dash.dashserver.core.exception.ImageStorageException; import be.dash.dashserver.core.image.ImageDeleter; -import be.dash.dashserver.external.config.s3.S3Config; import be.dash.dashserver.external.config.s3.S3Properties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import software.amazon.awssdk.awscore.exception.AwsServiceException; import software.amazon.awssdk.core.exception.SdkClientException; +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; @@ -21,7 +21,7 @@ @Slf4j public class S3ImageDeleter implements ImageDeleter { private final S3Properties s3Properties; - private final S3Config s3Config; + private final S3Client s3Client; @Override public void deleteAllByKeys(List keysToDelete) { @@ -35,7 +35,7 @@ public void deleteAllByKeys(List keysToDelete) { private DeleteObjectsResponse performDeleteAllByKeys(DeleteObjectsRequest deleteObjectRequest) { try { - return s3Config.getS3Client().deleteObjects(deleteObjectRequest); + return s3Client.deleteObjects(deleteObjectRequest); } catch (AwsServiceException e) { log.error("S3 삭제 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage()); throw new ImageStorageException("이미지 삭제에 실패했습니다.(서비스 오류)"); diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java index 3015c65..fd3043c 100755 --- a/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java @@ -5,7 +5,6 @@ import org.springframework.stereotype.Component; import be.dash.dashserver.core.exception.ImageStorageException; import be.dash.dashserver.core.image.ImageReader; -import be.dash.dashserver.external.config.s3.S3Config; import be.dash.dashserver.external.config.s3.S3Properties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,7 +20,7 @@ @Slf4j public class S3ImageReader implements ImageReader { private final S3Properties s3Properties; - private final S3Config s3Config; + private final S3Client s3Client; @Override public List getAllKeys() { @@ -36,8 +35,7 @@ public List getAllKeys() { } } - private List performGetAllKeys() { - S3Client s3Client = s3Config.getS3Client(); + private List performGetAllKeys() {; ListObjectsV2Request request = buildRequest(); List keys = new ArrayList<>(); ListObjectsV2Response response; diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java index 9077c3e..9c40121 100755 --- a/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java @@ -6,13 +6,13 @@ import org.springframework.web.multipart.MultipartFile; import be.dash.dashserver.core.exception.ImageStorageException; import be.dash.dashserver.core.image.ImageUploader; -import be.dash.dashserver.external.config.s3.S3Config; import be.dash.dashserver.external.config.s3.S3Properties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.PutObjectRequest; @Component @@ -23,7 +23,7 @@ public class S3ImageUploader implements ImageUploader { private static final String CONTENT_DISPOSITION = "inline"; private final S3Properties s3Properties; - private final S3Config s3Config; + private final S3Client s3Client; @Override public String upload(MultipartFile image) { @@ -59,7 +59,7 @@ private static RequestBody toRequestBody(MultipartFile image) { private void performUpload(PutObjectRequest request, RequestBody requestBody) { try { - s3Config.getS3Client().putObject(request, requestBody); + s3Client.putObject(request, requestBody); } catch (AwsServiceException e) { log.error("S3 업로드 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage()); throw new ImageStorageException("이미지 저장에 실패했습니다.(서비스 오류)"); diff --git a/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java b/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java new file mode 100644 index 0000000..04ec272 --- /dev/null +++ b/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java @@ -0,0 +1,83 @@ +package be.dash.dashserver.core.image; + +import java.nio.charset.StandardCharsets; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.mock.web.MockMultipartFile; +import be.dash.dashserver.ServiceSliceTest; +import be.dash.dashserver.database.core.member.MemberJpaEntity; +import be.dash.dashserver.database.core.member.MemberJpaRepository; +import be.dash.dashserver.database.fixture.MemberJpaEntityFixture; +import be.dash.dashserver.external.config.s3.S3Properties; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.ResponseTransformer; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; + +@EnableConfigurationProperties(S3Properties.class) +class ImageServiceTest extends ServiceSliceTest { + + @Autowired + private ImageService imageService; + @Autowired + private S3Client s3Client; + @Autowired + private S3Properties s3Properties; + @Autowired + private MemberJpaRepository memberJpaRepository; + + @Test + @DisplayName("이미지를 업도드하면 S3에 저장된다.") + void upload() { + // given + MockMultipartFile file = createMultipartFile("testImage"); + // when + String url = imageService.upload(file); + + // then + ResponseBytes object = s3Client.getObject( + GetObjectRequest.builder() + .bucket(s3Properties.s3BucketName()) + .key(url.substring(url.lastIndexOf("/") + 1)) + .build(), + ResponseTransformer.toBytes() + ); + String downloaded = new String(object.asByteArray(), StandardCharsets.UTF_8); + Assertions.assertThat(downloaded).isEqualTo("testImage"); + } + + @Test + @DisplayName("사용자의 프로필에 사용하지 않는 이미지를 삭제한다.") + void cleanUpUnusedProfileImages() { + // given + String url = imageService.upload(createMultipartFile("testImage")); // dangling + MemberJpaEntity member = MemberJpaEntityFixture.create(); + memberJpaRepository.save(member); + + // when + imageService.cleanUpUnusedProfileImages(); + + // then + ListObjectsV2Response listObjectsV2Response = s3Client.listObjectsV2(ListObjectsV2Request.builder() + .bucket(s3Properties.s3BucketName()) + .build()); + + Assertions.assertThat(listObjectsV2Response.contents().size()).isEqualTo(0); + } + + + private MockMultipartFile createMultipartFile(String imageKey) { + return new MockMultipartFile( + "file", + "test.jpg", + "image/jpeg", + imageKey.getBytes(StandardCharsets.UTF_8) + ); + } +} diff --git a/src/test/java/be/dash/dashserver/database/fixture/MemberJpaEntityFixture.java b/src/test/java/be/dash/dashserver/database/fixture/MemberJpaEntityFixture.java index 1efcda3..b895cda 100755 --- a/src/test/java/be/dash/dashserver/database/fixture/MemberJpaEntityFixture.java +++ b/src/test/java/be/dash/dashserver/database/fixture/MemberJpaEntityFixture.java @@ -18,6 +18,7 @@ public static MemberJpaEntity create() { .name("김영희") .phoneNumber("010-8765-4321") .nickname("younghee") + .profileImageUrl("http://localhost/profile.jpg") .build(); } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 18fd6a2..255becd 100755 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -30,9 +30,9 @@ kakao: s3: secret-key: 1234 access-key: 1234 - s3-endpoint: www.s3.com + s3-endpoint: http://localhost/ region: ap-northeast-2 - s3-bucket-name: s3 + s3-bucket-name: test naver: client-id: 1234 From d0ddd8c87dc8ed7d95bcdc441f7fcef08860b161 Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Mon, 5 May 2025 23:25:44 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/be/dash/dashserver/core/image/ImageServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java b/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java index 04ec272..d98f385 100644 --- a/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java +++ b/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java @@ -20,7 +20,7 @@ import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; -@EnableConfigurationProperties(S3Properties.class) +//@EnableConfigurationProperties(S3Properties.class) class ImageServiceTest extends ServiceSliceTest { @Autowired From e21f191e0181e2ea53d8b1f54e91ffd7a5c7fede Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Mon, 5 May 2025 23:25:44 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/be/dash/dashserver/core/image/ImageServiceTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java b/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java index d98f385..a50609e 100644 --- a/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java +++ b/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java @@ -5,7 +5,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.mock.web.MockMultipartFile; import be.dash.dashserver.ServiceSliceTest; import be.dash.dashserver.database.core.member.MemberJpaEntity; @@ -20,7 +19,6 @@ import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; -//@EnableConfigurationProperties(S3Properties.class) class ImageServiceTest extends ServiceSliceTest { @Autowired From d979767c448b53e38bfc0d647395e55ce4cafebd Mon Sep 17 00:00:00 2001 From: Parkjyun Date: Tue, 6 May 2025 09:39:14 +0900 Subject: [PATCH 9/9] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index dbb73ec..9260838 100755 --- a/build.gradle +++ b/build.gradle @@ -53,8 +53,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testImplementation("org.testcontainers:junit-jupiter") - implementation("org.testcontainers:localstack") + implementation 'org.testcontainers:localstack' // AWS implementation("software.amazon.awssdk:s3:2.21.0")