From 8d551e955dd18716075503aefd8fbcdb25d02451 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Sat, 19 Jul 2025 13:26:47 +0900 Subject: [PATCH 01/10] =?UTF-8?q?chore:=20TTL=EC=9D=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=ED=95=98=EB=8A=94=20=EC=9D=B8=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=20=EC=BA=90=EC=8B=9C=20`CaffeineCache`=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 20ee058..b6c32d9 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-cache' // Lombok compileOnly 'org.projectlombok:lombok' @@ -67,6 +68,7 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + implementation 'com.github.ben-manes.caffeine:caffeine' // in-memory cache // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' From bda1747727940369bbce15155f7a447605994386 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Sat, 19 Jul 2025 13:27:32 +0900 Subject: [PATCH 02/10] =?UTF-8?q?chore:=20=EC=BA=90=EC=8B=9C=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85=20=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/eatda/config/CacheConfig.java | 36 +++++++++++++++++++ .../java/eatda/repository/CacheSetting.java | 21 +++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/main/java/eatda/config/CacheConfig.java create mode 100644 src/main/java/eatda/repository/CacheSetting.java diff --git a/src/main/java/eatda/config/CacheConfig.java b/src/main/java/eatda/config/CacheConfig.java new file mode 100644 index 0000000..28fdd67 --- /dev/null +++ b/src/main/java/eatda/config/CacheConfig.java @@ -0,0 +1,36 @@ +package eatda.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import eatda.repository.CacheSetting; +import java.util.Arrays; +import java.util.List; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; + +@Component +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + List caches = Arrays.stream(CacheSetting.values()) + .map(this::createCaffeineCache) + .toList(); + + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(caches); + cacheManager.afterPropertiesSet(); + return cacheManager; + } + + private CaffeineCache createCaffeineCache(CacheSetting cacheSetting) { + return new CaffeineCache( + cacheSetting.getName(), + Caffeine.newBuilder() + .expireAfterWrite(cacheSetting.getTimeToLive()) + .maximumSize(cacheSetting.getMaximumSize()) + .build()); + } +} diff --git a/src/main/java/eatda/repository/CacheSetting.java b/src/main/java/eatda/repository/CacheSetting.java new file mode 100644 index 0000000..dd3719f --- /dev/null +++ b/src/main/java/eatda/repository/CacheSetting.java @@ -0,0 +1,21 @@ +package eatda.repository; + +import java.time.Duration; +import lombok.Getter; + +@Getter +public enum CacheSetting { + + IMAGE("image", Duration.ofMinutes(25), 1_000), + ; + + private final String name; + private final Duration timeToLive; + private final int maximumSize; + + CacheSetting(String image, Duration timeToLive, int maximumSize) { + this.name = image; + this.timeToLive = timeToLive; + this.maximumSize = maximumSize; + } +} From b6b907e4044ea9b2e8febc3c458065dd3f5c13f4 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Sat, 19 Jul 2025 13:29:06 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EC=BA=90=EC=8B=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=ED=8F=AC=ED=95=A8=ED=95=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../image/CachePreSignedUrlRepository.java | 27 ++++ .../repository/image/ImageRepository.java | 40 ++++++ .../repository/image/S3ImageRepository.java | 98 +++++++++++++ .../repository/BaseCacheRepositoryTest.java | 13 ++ .../repository/image/ImageRepositoryTest.java | 93 ++++++++++++ .../image/S3ImageRepositoryTest.java | 132 ++++++++++++++++++ 6 files changed, 403 insertions(+) create mode 100644 src/main/java/eatda/repository/image/CachePreSignedUrlRepository.java create mode 100644 src/main/java/eatda/repository/image/ImageRepository.java create mode 100644 src/main/java/eatda/repository/image/S3ImageRepository.java create mode 100644 src/test/java/eatda/repository/BaseCacheRepositoryTest.java create mode 100644 src/test/java/eatda/repository/image/ImageRepositoryTest.java create mode 100644 src/test/java/eatda/repository/image/S3ImageRepositoryTest.java diff --git a/src/main/java/eatda/repository/image/CachePreSignedUrlRepository.java b/src/main/java/eatda/repository/image/CachePreSignedUrlRepository.java new file mode 100644 index 0000000..a8993bf --- /dev/null +++ b/src/main/java/eatda/repository/image/CachePreSignedUrlRepository.java @@ -0,0 +1,27 @@ +package eatda.repository.image; + +import eatda.repository.CacheSetting; +import java.util.Optional; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; + +@Component +public class CachePreSignedUrlRepository { + + private static final String CACHE_NAME = CacheSetting.IMAGE.getName(); + + private final Cache cache; + + public CachePreSignedUrlRepository(CacheManager cacheManager) { + this.cache = cacheManager.getCache(CACHE_NAME); + } + + public void put(String imageKey, String preSignedUrl) { + cache.put(imageKey, preSignedUrl); + } + + public Optional get(String imageKey) { + return Optional.ofNullable(cache.get(imageKey, String.class)); + } +} diff --git a/src/main/java/eatda/repository/image/ImageRepository.java b/src/main/java/eatda/repository/image/ImageRepository.java new file mode 100644 index 0000000..3b414ac --- /dev/null +++ b/src/main/java/eatda/repository/image/ImageRepository.java @@ -0,0 +1,40 @@ +package eatda.repository.image; + +import eatda.service.common.ImageDomain; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@RequiredArgsConstructor +public class ImageRepository { + + private final S3ImageRepository s3Repository; + private final CachePreSignedUrlRepository cachePreSignedUrlRepository; + + + public String upload(MultipartFile file, ImageDomain domain) { + String imageKey = s3Repository.upload(file, domain); + + String preSignedUrl = s3Repository.getPresignedUrl(imageKey); + cachePreSignedUrlRepository.put(imageKey, preSignedUrl); + return imageKey; + } + + @Nullable + public String getPresignedUrl(@Nullable String imageKey) { + if (imageKey == null || imageKey.isEmpty()) { + return null; + } + + Optional cachedUrl = cachePreSignedUrlRepository.get(imageKey); + if (cachedUrl.isPresent()) { + return cachedUrl.get(); + } + String preSignedUrl = s3Repository.getPresignedUrl(imageKey); + cachePreSignedUrlRepository.put(imageKey, preSignedUrl); + return preSignedUrl; + } +} diff --git a/src/main/java/eatda/repository/image/S3ImageRepository.java b/src/main/java/eatda/repository/image/S3ImageRepository.java new file mode 100644 index 0000000..d026715 --- /dev/null +++ b/src/main/java/eatda/repository/image/S3ImageRepository.java @@ -0,0 +1,98 @@ +package eatda.repository.image; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import eatda.repository.CacheSetting; +import eatda.service.common.ImageDomain; +import java.io.IOException; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +@Component +public class S3ImageRepository { + + private static final Set ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png"); + private static final String DEFAULT_CONTENT_TYPE = "bin"; + private static final String PATH_DELIMITER = "/"; + private static final String EXTENSION_DELIMITER = "."; + private static final Duration PRESIGNED_URL_DURATION = CacheSetting.IMAGE.getTimeToLive() + .plus(Duration.ofMinutes(5)); + + private final S3Client s3Client; + private final String bucket; + private final S3Presigner s3Presigner; + + public S3ImageRepository( + S3Client s3Client, + @Value("${spring.cloud.aws.s3.bucket}") String bucket, + S3Presigner s3Presigner) { + this.s3Client = s3Client; + this.bucket = bucket; + this.s3Presigner = s3Presigner; + } + + public String upload(MultipartFile file, ImageDomain domain) { + validateContentType(file); + String extension = getExtension(file.getOriginalFilename()); + String uuid = UUID.randomUUID().toString(); + String key = domain.getName() + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension; + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + return key; + } catch (IOException exception) { + throw new BusinessException(BusinessErrorCode.FILE_UPLOAD_FAILED); + } + } + + private void validateContentType(MultipartFile file) { + String contentType = file.getContentType(); + if (!ALLOWED_CONTENT_TYPES.contains(contentType)) { + throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_TYPE); + } + } + + private String getExtension(String filename) { + if (filename == null + || filename.lastIndexOf(EXTENSION_DELIMITER) == -1 + || filename.startsWith(EXTENSION_DELIMITER)) { + return DEFAULT_CONTENT_TYPE; + } + return filename.substring(filename.lastIndexOf(EXTENSION_DELIMITER) + 1); + } + + + public String getPresignedUrl(String key) { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .getObjectRequest(getObjectRequest) + .signatureDuration(PRESIGNED_URL_DURATION) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } catch (Exception exception) { + throw new BusinessException(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); + } + } +} diff --git a/src/test/java/eatda/repository/BaseCacheRepositoryTest.java b/src/test/java/eatda/repository/BaseCacheRepositoryTest.java new file mode 100644 index 0000000..e26db01 --- /dev/null +++ b/src/test/java/eatda/repository/BaseCacheRepositoryTest.java @@ -0,0 +1,13 @@ +package eatda.repository; + +import eatda.config.CacheConfig; +import org.springframework.cache.CacheManager; + +public abstract class BaseCacheRepositoryTest { + + private final CacheManager cacheManager = new CacheConfig().cacheManager(); + + protected CacheManager getCacheManager() { + return cacheManager; + } +} diff --git a/src/test/java/eatda/repository/image/ImageRepositoryTest.java b/src/test/java/eatda/repository/image/ImageRepositoryTest.java new file mode 100644 index 0000000..e7783bc --- /dev/null +++ b/src/test/java/eatda/repository/image/ImageRepositoryTest.java @@ -0,0 +1,93 @@ +package eatda.repository.image; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import eatda.repository.BaseCacheRepositoryTest; +import eatda.service.common.ImageDomain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.springframework.mock.web.MockMultipartFile; + +class ImageRepositoryTest extends BaseCacheRepositoryTest { + + private S3ImageRepository s3ImageRepository; + private CachePreSignedUrlRepository cachePreSignedUrlRepository; + private ImageRepository imageRepository; + + @BeforeEach + void setUp() { + s3ImageRepository = mock(S3ImageRepository.class); + cachePreSignedUrlRepository = new CachePreSignedUrlRepository(getCacheManager()); + imageRepository = new ImageRepository(s3ImageRepository, cachePreSignedUrlRepository); + } + + @Nested + class Upload { + + @Test + void 이미지가_S3에_업로드된다() { + MockMultipartFile file = new MockMultipartFile( + "image", "test-image.jpg", "image/jpeg", "image-content".getBytes() + ); + doReturn("test-image-key").when(s3ImageRepository).upload(file, ImageDomain.MEMBER); + + String imageKey = imageRepository.upload(file, ImageDomain.MEMBER); + + assertThat(imageKey).isEqualTo("test-image-key"); + } + + @Test + void 이미지_업로드_시_PreSignedUrl이_캐시에_저장된다() { + MockMultipartFile file = new MockMultipartFile( + "image", "test-image.jpg", "image/jpeg", "image-content".getBytes() + ); + doReturn("test-image-key").when(s3ImageRepository).upload(file, ImageDomain.MEMBER); + doReturn("https://example.url.com").when(s3ImageRepository).getPresignedUrl("test-image-key"); + + imageRepository.upload(file, ImageDomain.MEMBER); + + assertThat(cachePreSignedUrlRepository.get("test-image-key")).contains("https://example.url.com"); + } + } + + @Nested + class GetPresignedUrl { + + @ParameterizedTest + @NullAndEmptySource + void 이미지_키가_null이면__null을_반환한다(String imageKey) { + String actual = imageRepository.getPresignedUrl(imageKey); + + assertThat(actual).isNull(); + } + + @Test + void 이미지_키가_캐시에_존재하면_s3에_요청하지_않고_PreSignedUrl을_반환한다() { + String imageKey = "test-image-key"; + cachePreSignedUrlRepository.put(imageKey, "https://example.url.com"); + + String preSignedUrl = imageRepository.getPresignedUrl(imageKey); + + assertThat(preSignedUrl).isEqualTo("https://example.url.com"); + } + + @Test + void 이미지_키가_캐시에_존재하지_않으면_S3에서_PreSignedUrl을_조회하고_캐시에_저장한다() { + String imageKey = "test-image-key"; + doReturn("https://example.url.com").when(s3ImageRepository).getPresignedUrl(imageKey); + + String preSignedUrl = imageRepository.getPresignedUrl(imageKey); + + assertAll( + () -> assertThat(preSignedUrl).isEqualTo("https://example.url.com"), + () -> assertThat(cachePreSignedUrlRepository.get(imageKey)).contains("https://example.url.com") + ); + } + } +} diff --git a/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java b/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java new file mode 100644 index 0000000..30f3eb3 --- /dev/null +++ b/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java @@ -0,0 +1,132 @@ +package eatda.repository.image; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import eatda.service.common.ImageDomain; +import eatda.service.common.ImageService; +import java.net.URL; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; +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; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +class S3ImageRepositoryTest { + + private static final String TEST_BUCKET = "test-bucket"; + + private S3Client s3Client; + private S3Presigner s3Presigner; + private ImageService imageService; + + @BeforeEach + void setUp() { + s3Client = mock(S3Client.class); + s3Presigner = mock(S3Presigner.class); + imageService = new ImageService(s3Client, TEST_BUCKET, s3Presigner); + } + + @Nested + class FileUpload { + + @ParameterizedTest + @EnumSource(ImageDomain.class) + void 허용된_이미지_타입이면_정상적으로_업로드되고_생성된_Key를_반환한다(ImageDomain imageDomain) { + String originalFilename = "test-image.jpg"; + String contentType = "image/jpeg"; + + MockMultipartFile file = new MockMultipartFile( + "image", originalFilename, contentType, "image-content".getBytes() + ); + + String key = imageService.upload(file, imageDomain); + + ArgumentCaptor putObjectRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(putObjectRequestCaptor.capture(), any(RequestBody.class)); + PutObjectRequest capturedRequest = putObjectRequestCaptor.getValue(); + + String expectedPattern = imageDomain.getName() + "/[a-f0-9\\-]{36}\\.jpg"; + + assertAll( + () -> assertThat(key).matches(expectedPattern), + () -> assertThat(capturedRequest.key()).isEqualTo(key), + () -> assertThat(capturedRequest.bucket()).isEqualTo(TEST_BUCKET), + () -> assertThat(capturedRequest.contentType()).isEqualTo(contentType) + ); + } + + @Test + void 허용되지_않은_파일_타입이면_BusinessException을_던진다() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.txt", "text/plain", "file-content".getBytes() + ); + + BusinessException exception = assertThrows(BusinessException.class, + () -> imageService.upload(file, ImageDomain.STORY)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE); + } + } + + @Nested + class GeneratePresignedUrl { + + @Test + void 유효한_key로_요청_시_Presigned_URL을_성공적으로_반환한다() throws Exception { + String key = "stores/image.jpg"; + String expectedUrlString = "https://example.com/presigned-url-for-image.jpg"; + URL expectedUrl = new URL(expectedUrlString); + + PresignedGetObjectRequest presignedRequestResult = mock(PresignedGetObjectRequest.class); + + when(presignedRequestResult.url()).thenReturn(expectedUrl); + when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) + .thenReturn(presignedRequestResult); + + String presignedUrl = imageService.getPresignedUrl(key); + + ArgumentCaptor presignRequestCaptor = + ArgumentCaptor.forClass(GetObjectPresignRequest.class); + verify(s3Presigner).presignGetObject(presignRequestCaptor.capture()); + GetObjectPresignRequest capturedPresignRequest = presignRequestCaptor.getValue(); + + assertAll( + () -> assertThat(presignedUrl).isEqualTo(expectedUrlString), + () -> assertThat(capturedPresignRequest.getObjectRequest().key()).isEqualTo(key), + () -> assertThat(capturedPresignRequest.getObjectRequest().bucket()).isEqualTo(TEST_BUCKET), + () -> assertThat(capturedPresignRequest.signatureDuration()).isEqualTo(Duration.ofMinutes(30)) + ); + } + + @Test + void Presigner가_예외를_던지면_BusinessException으로_전환하여_던진다() { + String key = "stores/image.jpg"; + + when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) + .thenThrow(SdkClientException.create("AWS SDK 통신 실패")); + + BusinessException exception = assertThrows(BusinessException.class, + () -> imageService.getPresignedUrl(key)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); + } + } +} From a45aadc333d2a2ad8325e5cee2200c62ec47bc58 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Sat, 19 Jul 2025 13:57:56 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20ImageService=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20ImageRepository=20=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatda/service/store/CheerService.java | 6 +++--- .../eatda/service/store/StoreService.java | 6 +++--- .../eatda/service/story/StoryService.java | 8 ++++---- .../eatda/controller/BaseControllerTest.java | 9 ++++----- ...ryTest.java => BaseJpaRepositoryTest.java} | 2 +- .../repository/store/CheerRepositoryTest.java | 4 ++-- .../java/eatda/service/BaseServiceTest.java | 12 +++++++---- .../eatda/service/story/StoryServiceTest.java | 20 +++---------------- 8 files changed, 28 insertions(+), 39 deletions(-) rename src/test/java/eatda/repository/{BaseRepositoryTest.java => BaseJpaRepositoryTest.java} (95%) diff --git a/src/main/java/eatda/service/store/CheerService.java b/src/main/java/eatda/service/store/CheerService.java index e8a98a3..1324248 100644 --- a/src/main/java/eatda/service/store/CheerService.java +++ b/src/main/java/eatda/service/store/CheerService.java @@ -3,8 +3,8 @@ import eatda.controller.store.CheerPreviewResponse; import eatda.controller.store.CheersResponse; import eatda.domain.store.Cheer; +import eatda.repository.image.ImageRepository; import eatda.repository.store.CheerRepository; -import eatda.service.common.ImageService; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -15,8 +15,8 @@ @RequiredArgsConstructor public class CheerService { - private final ImageService imageService; private final CheerRepository cheerRepository; + private final ImageRepository imageRepository; @Transactional(readOnly = true) public CheersResponse getCheers(int size) { @@ -27,7 +27,7 @@ public CheersResponse getCheers(int size) { private CheersResponse toCheersResponse(List cheers) { return new CheersResponse(cheers.stream() .map(cheer -> new CheerPreviewResponse(cheer, cheer.getStore(), - imageService.getPresignedUrl(cheer.getImageKey()))) + imageRepository.getPresignedUrl(cheer.getImageKey()))) .toList()); } } diff --git a/src/main/java/eatda/service/store/StoreService.java b/src/main/java/eatda/service/store/StoreService.java index ded2b72..065fffb 100644 --- a/src/main/java/eatda/service/store/StoreService.java +++ b/src/main/java/eatda/service/store/StoreService.java @@ -9,9 +9,9 @@ import eatda.controller.store.StoreSearchResponses; import eatda.controller.store.StoresResponse; import eatda.domain.store.Store; +import eatda.repository.image.ImageRepository; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; -import eatda.service.common.ImageService; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -26,7 +26,7 @@ public class StoreService { private final StoreSearchFilter storeSearchFilter; private final StoreRepository storeRepository; private final CheerRepository cheerRepository; - private final ImageService imageService; + private final ImageRepository imageRepository; // TODO : N+1 문제 해결 public StoresResponse getStores(int size) { @@ -38,7 +38,7 @@ public StoresResponse getStores(int size) { private Optional getStoreImageUrl(Store store) { return cheerRepository.findRecentImageKey(store) - .map(imageService::getPresignedUrl); + .map(imageRepository::getPresignedUrl); } public StoreSearchResponses searchStores(String query) { diff --git a/src/main/java/eatda/service/story/StoryService.java b/src/main/java/eatda/service/story/StoryService.java index 17168ed..b60d5fb 100644 --- a/src/main/java/eatda/service/story/StoryService.java +++ b/src/main/java/eatda/service/story/StoryService.java @@ -8,10 +8,10 @@ import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; +import eatda.repository.image.ImageRepository; import eatda.repository.member.MemberRepository; import eatda.repository.story.StoryRepository; import eatda.service.common.ImageDomain; -import eatda.service.common.ImageService; import eatda.service.store.StoreService; import java.util.List; import lombok.RequiredArgsConstructor; @@ -29,7 +29,7 @@ public class StoryService { private static final int PAGE_SIZE = 5; private final StoreService storeService; - private final ImageService imageService; + private final ImageRepository imageRepository; private final StoryRepository storyRepository; private final MemberRepository memberRepository; @@ -38,7 +38,7 @@ public void registerStory(StoryRegisterRequest request, MultipartFile image, Lon Member member = memberRepository.getById(memberId); List searchResponses = storeService.searchStoreResults(request.query()); FilteredSearchResult matchedStore = filteredSearchResponse(searchResponses, request.storeKakaoId()); - String imageKey = imageService.upload(image, ImageDomain.STORY); + String imageKey = imageRepository.upload(image, ImageDomain.STORY); Story story = Story.builder() .member(member) @@ -75,7 +75,7 @@ public StoriesResponse getPagedStoryPreviews() { orderByPage.getContent().stream() .map(story -> new StoriesResponse.StoryPreview( story.getId(), - imageService.getPresignedUrl(story.getImageKey()) + imageRepository.getPresignedUrl(story.getImageKey()) )) .toList() ); diff --git a/src/test/java/eatda/controller/BaseControllerTest.java b/src/test/java/eatda/controller/BaseControllerTest.java index 3827893..9709f7f 100644 --- a/src/test/java/eatda/controller/BaseControllerTest.java +++ b/src/test/java/eatda/controller/BaseControllerTest.java @@ -15,11 +15,10 @@ import eatda.fixture.CheerGenerator; import eatda.fixture.MemberGenerator; import eatda.fixture.StoreGenerator; +import eatda.repository.image.ImageRepository; import eatda.repository.member.MemberRepository; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; -import eatda.service.common.ImageService; -import eatda.service.common.ImageService; import eatda.service.story.StoryService; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; @@ -78,7 +77,7 @@ public class BaseControllerTest { private MapClient mapClient; @MockitoBean - private ImageService imageService; + private ImageRepository imageRepository; @MockitoBean protected StoryService storyService; // TODO 실 객체로 변환 @@ -110,8 +109,8 @@ final void mockingClient() throws URISyntaxException { ); doReturn(searchResults).when(mapClient).searchShops(anyString()); - doReturn(MOCKED_IMAGE_URL).when(imageService).getPresignedUrl(anyString()); - doReturn(MOCKED_IMAGE_KEY).when(imageService).upload(any(), any()); + doReturn(MOCKED_IMAGE_URL).when(imageRepository).getPresignedUrl(anyString()); + doReturn(MOCKED_IMAGE_KEY).when(imageRepository).upload(any(), any()); } protected final RequestSpecification given() { diff --git a/src/test/java/eatda/repository/BaseRepositoryTest.java b/src/test/java/eatda/repository/BaseJpaRepositoryTest.java similarity index 95% rename from src/test/java/eatda/repository/BaseRepositoryTest.java rename to src/test/java/eatda/repository/BaseJpaRepositoryTest.java index 2fd3320..9de25ca 100644 --- a/src/test/java/eatda/repository/BaseRepositoryTest.java +++ b/src/test/java/eatda/repository/BaseJpaRepositoryTest.java @@ -13,7 +13,7 @@ @Import({MemberGenerator.class, StoreGenerator.class, CheerGenerator.class}) @DataJpaTest -public abstract class BaseRepositoryTest { +public abstract class BaseJpaRepositoryTest { @Autowired protected MemberGenerator memberGenerator; diff --git a/src/test/java/eatda/repository/store/CheerRepositoryTest.java b/src/test/java/eatda/repository/store/CheerRepositoryTest.java index 88b8ef3..906f76d 100644 --- a/src/test/java/eatda/repository/store/CheerRepositoryTest.java +++ b/src/test/java/eatda/repository/store/CheerRepositoryTest.java @@ -4,12 +4,12 @@ import eatda.domain.member.Member; import eatda.domain.store.Store; -import eatda.repository.BaseRepositoryTest; +import eatda.repository.BaseJpaRepositoryTest; import java.util.Optional; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -class CheerRepositoryTest extends BaseRepositoryTest { +class CheerRepositoryTest extends BaseJpaRepositoryTest { @Nested class FindRecentImageKey { diff --git a/src/test/java/eatda/service/BaseServiceTest.java b/src/test/java/eatda/service/BaseServiceTest.java index 631d25a..9468a18 100644 --- a/src/test/java/eatda/service/BaseServiceTest.java +++ b/src/test/java/eatda/service/BaseServiceTest.java @@ -10,10 +10,11 @@ import eatda.fixture.CheerGenerator; import eatda.fixture.MemberGenerator; import eatda.fixture.StoreGenerator; +import eatda.repository.image.ImageRepository; import eatda.repository.member.MemberRepository; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; -import eatda.service.common.ImageService; +import eatda.repository.story.StoryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -34,7 +35,7 @@ public abstract class BaseServiceTest { protected MapClient mapClient; @MockitoBean - protected ImageService imageService; + protected ImageRepository imageRepository; @Autowired protected MemberGenerator memberGenerator; @@ -54,9 +55,12 @@ public abstract class BaseServiceTest { @Autowired protected CheerRepository cheerRepository; + @Autowired + protected StoryRepository storyRepository; + @BeforeEach void mockingImageService() { - doReturn(MOCKED_IMAGE_URL).when(imageService).getPresignedUrl(anyString()); - doReturn(MOCKED_IMAGE_KEY).when(imageService).upload(any(), any()); + doReturn(MOCKED_IMAGE_URL).when(imageRepository).getPresignedUrl(anyString()); + doReturn(MOCKED_IMAGE_KEY).when(imageRepository).upload(any(), any()); } } diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index 82f1664..02c03bb 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -13,13 +13,10 @@ import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; -import eatda.repository.story.StoryRepository; import eatda.service.BaseServiceTest; import eatda.service.common.ImageDomain; -import eatda.service.store.StoreService; import java.util.Collections; import java.util.List; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,10 +26,6 @@ public class StoryServiceTest extends BaseServiceTest { @Autowired private StoryService storyService; - @Autowired - private StoryRepository storyRepository; - @Autowired - private StoreService storeService; @Nested class RegisterStory { @@ -49,7 +42,7 @@ class RegisterStory { "서울 강남구", "서울 강남구", 37.0, 127.0 ); doReturn(List.of(store)).when(mapClient).searchShops(request.query()); - when(imageService.upload(image, ImageDomain.STORY)).thenReturn("image-key"); + when(imageRepository.upload(image, ImageDomain.STORY)).thenReturn("image-key"); assertDoesNotThrow(() -> storyService.registerStory(request, image, member.getId())); } @@ -71,13 +64,6 @@ class RegisterStory { @Nested class GetPagedStoryPreviews extends BaseServiceTest { - private StoryService storyService; - - @BeforeEach - void setUp() { - storyService = new StoryService(storeService, imageService, storyRepository, memberRepository); - } - @Test void 스토리_목록을_조회할_수_있다() { Member member = memberGenerator.generate("12345"); @@ -104,8 +90,8 @@ void setUp() { storyRepository.saveAll(List.of(story1, story2)); - when(imageService.getPresignedUrl("image-key-1")).thenReturn("https://s3.com/story1.jpg"); - when(imageService.getPresignedUrl("image-key-2")).thenReturn("https://s3.com/story2.jpg"); + when(imageRepository.getPresignedUrl("image-key-1")).thenReturn("https://s3.com/story1.jpg"); + when(imageRepository.getPresignedUrl("image-key-2")).thenReturn("https://s3.com/story2.jpg"); var response = storyService.getPagedStoryPreviews(); From 57ec26d8831d5c60c6c27a36567a2c9fd5fdd00d Mon Sep 17 00:00:00 2001 From: leegwichan Date: Sat, 19 Jul 2025 14:04:22 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20ImageService=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20ImageRepository=20=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../image}/ImageDomain.java | 2 +- .../repository/image/ImageRepository.java | 1 - .../repository/image/S3ImageRepository.java | 1 - .../eatda/service/common/ImageService.java | 100 ------------- .../eatda/service/story/StoryService.java | 3 +- .../controller/story/StoryControllerTest.java | 5 +- .../java/eatda/document/BaseDocumentTest.java | 5 +- .../document/story/StoryDocumentTest.java | 14 +- .../repository/image/ImageRepositoryTest.java | 1 - .../image/S3ImageRepositoryTest.java | 14 +- .../service/common/ImageServiceTest.java | 135 ------------------ .../eatda/service/story/StoryServiceTest.java | 2 +- 12 files changed, 19 insertions(+), 264 deletions(-) rename src/main/java/eatda/{service/common => repository/image}/ImageDomain.java (88%) delete mode 100644 src/main/java/eatda/service/common/ImageService.java delete mode 100644 src/test/java/eatda/service/common/ImageServiceTest.java diff --git a/src/main/java/eatda/service/common/ImageDomain.java b/src/main/java/eatda/repository/image/ImageDomain.java similarity index 88% rename from src/main/java/eatda/service/common/ImageDomain.java rename to src/main/java/eatda/repository/image/ImageDomain.java index 5d155c0..8836678 100644 --- a/src/main/java/eatda/service/common/ImageDomain.java +++ b/src/main/java/eatda/repository/image/ImageDomain.java @@ -1,4 +1,4 @@ -package eatda.service.common; +package eatda.repository.image; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/eatda/repository/image/ImageRepository.java b/src/main/java/eatda/repository/image/ImageRepository.java index 3b414ac..90ca6c8 100644 --- a/src/main/java/eatda/repository/image/ImageRepository.java +++ b/src/main/java/eatda/repository/image/ImageRepository.java @@ -1,6 +1,5 @@ package eatda.repository.image; -import eatda.service.common.ImageDomain; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.lang.Nullable; diff --git a/src/main/java/eatda/repository/image/S3ImageRepository.java b/src/main/java/eatda/repository/image/S3ImageRepository.java index d026715..108ce88 100644 --- a/src/main/java/eatda/repository/image/S3ImageRepository.java +++ b/src/main/java/eatda/repository/image/S3ImageRepository.java @@ -3,7 +3,6 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import eatda.repository.CacheSetting; -import eatda.service.common.ImageDomain; import java.io.IOException; import java.time.Duration; import java.util.Set; diff --git a/src/main/java/eatda/service/common/ImageService.java b/src/main/java/eatda/service/common/ImageService.java deleted file mode 100644 index 676a89d..0000000 --- a/src/main/java/eatda/service/common/ImageService.java +++ /dev/null @@ -1,100 +0,0 @@ -package eatda.service.common; - -import eatda.exception.BusinessErrorCode; -import eatda.exception.BusinessException; -import java.io.IOException; -import java.time.Duration; -import java.util.Set; -import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; - -@Service -public class ImageService { - - private static final Set ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png"); - private static final String DEFAULT_CONTENT_TYPE = "bin"; - private static final String PATH_DELIMITER = "/"; - private static final String EXTENSION_DELIMITER = "."; - private static final Duration PRESIGNED_URL_DURATION = Duration.ofMinutes(30); - - private final S3Client s3Client; - private final String bucket; - private final S3Presigner s3Presigner; - - public ImageService( - S3Client s3Client, - @Value("${spring.cloud.aws.s3.bucket}") String bucket, - S3Presigner s3Presigner) { - this.s3Client = s3Client; - this.bucket = bucket; - this.s3Presigner = s3Presigner; - } - - public String upload(MultipartFile file, ImageDomain domain) { - validateContentType(file); - String extension = getExtension(file.getOriginalFilename()); - String uuid = UUID.randomUUID().toString(); - String key = domain.getName() + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension; - - try { - PutObjectRequest request = PutObjectRequest.builder() - .bucket(bucket) - .key(key) - .contentType(file.getContentType()) - .build(); - - s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); - return key; - } catch (IOException exception) { - throw new BusinessException(BusinessErrorCode.FILE_UPLOAD_FAILED); - } - } - - private void validateContentType(MultipartFile file) { - String contentType = file.getContentType(); - if (!ALLOWED_CONTENT_TYPES.contains(contentType)) { - throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_TYPE); - } - } - - private String getExtension(String filename) { - if (filename == null - || filename.lastIndexOf(EXTENSION_DELIMITER) == -1 - || filename.startsWith(EXTENSION_DELIMITER)) { - return DEFAULT_CONTENT_TYPE; - } - return filename.substring(filename.lastIndexOf(EXTENSION_DELIMITER) + 1); - } - - @Nullable - public String getPresignedUrl(@Nullable String key) { - if (key == null) { - return null; - } - - try { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucket) - .key(key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .getObjectRequest(getObjectRequest) - .signatureDuration(PRESIGNED_URL_DURATION) - .build(); - - return s3Presigner.presignGetObject(presignRequest).url().toString(); - } catch (Exception exception) { - throw new BusinessException(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); - } - } -} diff --git a/src/main/java/eatda/service/story/StoryService.java b/src/main/java/eatda/service/story/StoryService.java index b60d5fb..631008d 100644 --- a/src/main/java/eatda/service/story/StoryService.java +++ b/src/main/java/eatda/service/story/StoryService.java @@ -8,10 +8,10 @@ import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; +import eatda.repository.image.ImageDomain; import eatda.repository.image.ImageRepository; import eatda.repository.member.MemberRepository; import eatda.repository.story.StoryRepository; -import eatda.service.common.ImageDomain; import eatda.service.store.StoreService; import java.util.List; import lombok.RequiredArgsConstructor; @@ -25,6 +25,7 @@ @Service @RequiredArgsConstructor public class StoryService { + private static final int PAGE_START_NUMBER = 0; private static final int PAGE_SIZE = 5; diff --git a/src/test/java/eatda/controller/story/StoryControllerTest.java b/src/test/java/eatda/controller/story/StoryControllerTest.java index d1c77ce..96f678d 100644 --- a/src/test/java/eatda/controller/story/StoryControllerTest.java +++ b/src/test/java/eatda/controller/story/StoryControllerTest.java @@ -2,12 +2,10 @@ import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import eatda.controller.BaseControllerTest; -import eatda.service.common.ImageDomain; import io.restassured.response.Response; import java.nio.charset.StandardCharsets; import java.util.List; @@ -42,7 +40,8 @@ class SearchStores { Response response = given() .contentType("multipart/form-data") .header("Authorization", accessToken()) - .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), "application/json") + .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), + "application/json") .multiPart("image", "image.png", imageBytes, "image/png") .when() .post("/api/stories"); diff --git a/src/test/java/eatda/document/BaseDocumentTest.java b/src/test/java/eatda/document/BaseDocumentTest.java index 3bb276a..a899302 100644 --- a/src/test/java/eatda/document/BaseDocumentTest.java +++ b/src/test/java/eatda/document/BaseDocumentTest.java @@ -8,7 +8,6 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.EtcErrorCode; import eatda.service.auth.AuthService; -import eatda.service.common.ImageService; import eatda.service.member.MemberService; import eatda.service.store.CheerService; import eatda.service.store.StoreService; @@ -52,11 +51,9 @@ public abstract class BaseDocumentTest { @MockitoBean protected StoryService storyService; - @MockitoBean - protected ImageService imageService; - @MockitoBean protected CheerService cheerService; + @MockitoBean protected JwtManager jwtManager; diff --git a/src/test/java/eatda/document/story/StoryDocumentTest.java b/src/test/java/eatda/document/story/StoryDocumentTest.java index 0705834..925ecdb 100644 --- a/src/test/java/eatda/document/story/StoryDocumentTest.java +++ b/src/test/java/eatda/document/story/StoryDocumentTest.java @@ -15,7 +15,6 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import eatda.exception.EtcErrorCode; -import eatda.service.common.ImageDomain; import io.restassured.response.Response; import java.nio.charset.StandardCharsets; import java.util.List; @@ -41,10 +40,6 @@ class RegisterStory { @Test void 스토리_등록_성공() { - doReturn("https://dummy-s3.com/story.png") - .when(imageService) - .upload(any(), org.mockito.ArgumentMatchers.eq(ImageDomain.STORY)); - doNothing().when(storyService) .registerStory(any(), any(), any()); @@ -66,7 +61,8 @@ class RegisterStory { Response response = given(document) .contentType("multipart/form-data") .header(HttpHeaders.AUTHORIZATION, accessToken()) - .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), "application/json") + .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), + "application/json") .multiPart("image", "image.png", imageBytes, "image/png") .when().post("/api/stories"); @@ -96,7 +92,8 @@ class RegisterStory { given(document) .contentType("multipart/form-data") .header(HttpHeaders.AUTHORIZATION, accessToken()) - .multiPart("request", "request.json", invalidJson.getBytes(StandardCharsets.UTF_8), "application/json") + .multiPart("request", "request.json", invalidJson.getBytes(StandardCharsets.UTF_8), + "application/json") .multiPart("image", "image.png", imageBytes, "image/png") .when().post("/api/stories") .then().statusCode(EtcErrorCode.CLIENT_REQUEST_ERROR.getStatus().value()); @@ -126,7 +123,8 @@ class RegisterStory { Response response = given(document) .contentType("multipart/form-data") .header(HttpHeaders.AUTHORIZATION, accessToken()) - .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), "application/json") + .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), + "application/json") .multiPart("image", "image.txt", invalidImage, "text/plain") .when().post("/api/stories"); diff --git a/src/test/java/eatda/repository/image/ImageRepositoryTest.java b/src/test/java/eatda/repository/image/ImageRepositoryTest.java index e7783bc..2a8413c 100644 --- a/src/test/java/eatda/repository/image/ImageRepositoryTest.java +++ b/src/test/java/eatda/repository/image/ImageRepositoryTest.java @@ -6,7 +6,6 @@ import static org.mockito.Mockito.mock; import eatda.repository.BaseCacheRepositoryTest; -import eatda.service.common.ImageDomain; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java b/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java index 30f3eb3..fe84ace 100644 --- a/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java +++ b/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java @@ -10,8 +10,6 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; -import eatda.service.common.ImageDomain; -import eatda.service.common.ImageService; import java.net.URL; import java.time.Duration; import org.junit.jupiter.api.BeforeEach; @@ -35,13 +33,13 @@ class S3ImageRepositoryTest { private S3Client s3Client; private S3Presigner s3Presigner; - private ImageService imageService; + private S3ImageRepository s3ImageRepository; @BeforeEach void setUp() { s3Client = mock(S3Client.class); s3Presigner = mock(S3Presigner.class); - imageService = new ImageService(s3Client, TEST_BUCKET, s3Presigner); + s3ImageRepository = new S3ImageRepository(s3Client, TEST_BUCKET, s3Presigner); } @Nested @@ -57,7 +55,7 @@ class FileUpload { "image", originalFilename, contentType, "image-content".getBytes() ); - String key = imageService.upload(file, imageDomain); + String key = s3ImageRepository.upload(file, imageDomain); ArgumentCaptor putObjectRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); verify(s3Client).putObject(putObjectRequestCaptor.capture(), any(RequestBody.class)); @@ -80,7 +78,7 @@ class FileUpload { ); BusinessException exception = assertThrows(BusinessException.class, - () -> imageService.upload(file, ImageDomain.STORY)); + () -> s3ImageRepository.upload(file, ImageDomain.STORY)); assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE); } @@ -101,7 +99,7 @@ class GeneratePresignedUrl { when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) .thenReturn(presignedRequestResult); - String presignedUrl = imageService.getPresignedUrl(key); + String presignedUrl = s3ImageRepository.getPresignedUrl(key); ArgumentCaptor presignRequestCaptor = ArgumentCaptor.forClass(GetObjectPresignRequest.class); @@ -124,7 +122,7 @@ class GeneratePresignedUrl { .thenThrow(SdkClientException.create("AWS SDK 통신 실패")); BusinessException exception = assertThrows(BusinessException.class, - () -> imageService.getPresignedUrl(key)); + () -> s3ImageRepository.getPresignedUrl(key)); assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); } diff --git a/src/test/java/eatda/service/common/ImageServiceTest.java b/src/test/java/eatda/service/common/ImageServiceTest.java deleted file mode 100644 index ec551d2..0000000 --- a/src/test/java/eatda/service/common/ImageServiceTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package eatda.service.common; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import eatda.exception.BusinessErrorCode; -import eatda.exception.BusinessException; -import java.net.URL; -import java.time.Duration; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -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; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; - -@ExtendWith(MockitoExtension.class) -class ImageServiceTest { - - private static final String TEST_BUCKET = "test-bucket"; - - @Mock - private S3Client s3Client; - - @Mock - private S3Presigner s3Presigner; - private ImageService imageService; - - @BeforeEach - void setUp() { - imageService = new ImageService(s3Client, TEST_BUCKET, s3Presigner); - } - - @Nested - class FileUpload { - - @ParameterizedTest - @EnumSource(ImageDomain.class) - void 허용된_이미지_타입이면_정상적으로_업로드되고_생성된_Key를_반환한다(ImageDomain imageDomain) { - String originalFilename = "test-image.jpg"; - String contentType = "image/jpeg"; - - MockMultipartFile file = new MockMultipartFile( - "image", originalFilename, contentType, "image-content".getBytes() - ); - - String key = imageService.upload(file, imageDomain); - - ArgumentCaptor putObjectRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - verify(s3Client).putObject(putObjectRequestCaptor.capture(), any(RequestBody.class)); - PutObjectRequest capturedRequest = putObjectRequestCaptor.getValue(); - - String expectedPattern = imageDomain.getName() + "/[a-f0-9\\-]{36}\\.jpg"; - - assertAll( - () -> assertThat(key).matches(expectedPattern), - () -> assertThat(capturedRequest.key()).isEqualTo(key), - () -> assertThat(capturedRequest.bucket()).isEqualTo(TEST_BUCKET), - () -> assertThat(capturedRequest.contentType()).isEqualTo(contentType) - ); - } - - @Test - void 허용되지_않은_파일_타입이면_BusinessException을_던진다() { - MockMultipartFile file = new MockMultipartFile( - "file", "test.txt", "text/plain", "file-content".getBytes() - ); - - BusinessException exception = assertThrows(BusinessException.class, - () -> imageService.upload(file, ImageDomain.STORY)); - - assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE); - } - } - - @Nested - class GeneratePresignedUrl { - - @Test - void 유효한_key로_요청_시_Presigned_URL을_성공적으로_반환한다() throws Exception { - String key = "stores/image.jpg"; - String expectedUrlString = "https://example.com/presigned-url-for-image.jpg"; - URL expectedUrl = new URL(expectedUrlString); - - PresignedGetObjectRequest presignedRequestResult = mock(PresignedGetObjectRequest.class); - - when(presignedRequestResult.url()).thenReturn(expectedUrl); - when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) - .thenReturn(presignedRequestResult); - - String presignedUrl = imageService.getPresignedUrl(key); - - ArgumentCaptor presignRequestCaptor = - ArgumentCaptor.forClass(GetObjectPresignRequest.class); - verify(s3Presigner).presignGetObject(presignRequestCaptor.capture()); - GetObjectPresignRequest capturedPresignRequest = presignRequestCaptor.getValue(); - - assertAll( - () -> assertThat(presignedUrl).isEqualTo(expectedUrlString), - () -> assertThat(capturedPresignRequest.getObjectRequest().key()).isEqualTo(key), - () -> assertThat(capturedPresignRequest.getObjectRequest().bucket()).isEqualTo(TEST_BUCKET), - () -> assertThat(capturedPresignRequest.signatureDuration()).isEqualTo(Duration.ofMinutes(30)) - ); - } - - @Test - void Presigner가_예외를_던지면_BusinessException으로_전환하여_던진다() { - String key = "stores/image.jpg"; - - when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) - .thenThrow(SdkClientException.create("AWS SDK 통신 실패")); - - BusinessException exception = assertThrows(BusinessException.class, - () -> imageService.getPresignedUrl(key)); - - assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); - } - } -} diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index 02c03bb..1a29a6b 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -13,8 +13,8 @@ import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; +import eatda.repository.image.ImageDomain; import eatda.service.BaseServiceTest; -import eatda.service.common.ImageDomain; import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Nested; From b2171019025b7bd5f61d0f829bd833dccc1e3b39 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Sat, 19 Jul 2025 14:35:20 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix(CacheConfig):=20=EB=B9=88=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=8C=EC=9D=BC=EC=97=90=EC=84=9C=20`@Configurat?= =?UTF-8?q?ion`=20=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/eatda/config/CacheConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/eatda/config/CacheConfig.java b/src/main/java/eatda/config/CacheConfig.java index 28fdd67..908ec1b 100644 --- a/src/main/java/eatda/config/CacheConfig.java +++ b/src/main/java/eatda/config/CacheConfig.java @@ -8,9 +8,9 @@ import org.springframework.cache.caffeine.CaffeineCache; import org.springframework.cache.support.SimpleCacheManager; import org.springframework.context.annotation.Bean; -import org.springframework.stereotype.Component; +import org.springframework.context.annotation.Configuration; -@Component +@Configuration public class CacheConfig { @Bean From 34f60ed708d99816c7e1476e26daa679130ae1f2 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Sat, 19 Jul 2025 14:36:10 +0900 Subject: [PATCH 07/10] =?UTF-8?q?test(ImageRepositoryTest):=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=97=90=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=A0=20=EB=95=8C,=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=20API=EB=A5=BC=20=ED=98=B8=EC=B6=9C=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=8C=EC=9D=84=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/eatda/repository/image/ImageRepositoryTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/test/java/eatda/repository/image/ImageRepositoryTest.java b/src/test/java/eatda/repository/image/ImageRepositoryTest.java index 2a8413c..c120acd 100644 --- a/src/test/java/eatda/repository/image/ImageRepositoryTest.java +++ b/src/test/java/eatda/repository/image/ImageRepositoryTest.java @@ -2,8 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import eatda.repository.BaseCacheRepositoryTest; import org.junit.jupiter.api.BeforeEach; @@ -73,7 +76,10 @@ class GetPresignedUrl { String preSignedUrl = imageRepository.getPresignedUrl(imageKey); - assertThat(preSignedUrl).isEqualTo("https://example.url.com"); + assertAll( + () -> assertThat(preSignedUrl).isEqualTo("https://example.url.com"), + () -> verify(s3ImageRepository, never()).getPresignedUrl(anyString()) + ); } @Test From 3fcf66a84de4a6f5320824e608c1a2354b420d65 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Tue, 22 Jul 2025 12:00:47 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor(S3ImageRepository):=20CacheSetti?= =?UTF-8?q?ng=20=EA=B3=BC=EC=9D=98=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/eatda/repository/image/S3ImageRepository.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/eatda/repository/image/S3ImageRepository.java b/src/main/java/eatda/repository/image/S3ImageRepository.java index 108ce88..de0ea9f 100644 --- a/src/main/java/eatda/repository/image/S3ImageRepository.java +++ b/src/main/java/eatda/repository/image/S3ImageRepository.java @@ -2,7 +2,6 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; -import eatda.repository.CacheSetting; import java.io.IOException; import java.time.Duration; import java.util.Set; @@ -24,8 +23,7 @@ public class S3ImageRepository { private static final String DEFAULT_CONTENT_TYPE = "bin"; private static final String PATH_DELIMITER = "/"; private static final String EXTENSION_DELIMITER = "."; - private static final Duration PRESIGNED_URL_DURATION = CacheSetting.IMAGE.getTimeToLive() - .plus(Duration.ofMinutes(5)); + private static final Duration PRESIGNED_URL_DURATION = Duration.ofMinutes(30); private final S3Client s3Client; private final String bucket; From e0862f677c8eb483aa3b2f28bf0eaa4951fcacdc Mon Sep 17 00:00:00 2001 From: leegwichan Date: Tue, 22 Jul 2025 12:01:30 +0900 Subject: [PATCH 09/10] =?UTF-8?q?test(StoryServiceTest):=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=97=AC=EB=9F=AC=EA=B0=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C,=20id=EA=B0=92=20=EA=B2=80=EC=A6=9D=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 --- src/test/java/eatda/service/story/StoryServiceTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index 4684706..2d3ed85 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -2,13 +2,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import eatda.client.map.StoreSearchResult; +import eatda.controller.story.StoriesResponse.StoryPreview; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryResponse; import eatda.domain.member.Member; @@ -93,7 +93,10 @@ class GetPagedStoryPreviews { var response = storyService.getPagedStoryPreviews(5); - assertThat(response.stories()).hasSize(2); + assertThat(response.stories()) + .hasSize(2) + .extracting(StoryPreview::storyId) + .containsExactlyInAnyOrder(story2.getId(), story1.getId()); } } From a22fd5c23ad827cdf66083d4c7100e8610bbecdb Mon Sep 17 00:00:00 2001 From: leegwichan Date: Wed, 23 Jul 2025 12:07:28 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor=20:=20ImageRepository=20?= =?UTF-8?q?=EB=A5=BC=20ImageStorage=20=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../image => domain}/ImageDomain.java | 2 +- .../repository/image/ImageRepository.java | 39 ---------------- .../eatda/service/article/ArticleService.java | 6 +-- .../eatda/service/store/CheerService.java | 12 ++--- .../eatda/service/store/StoreService.java | 6 +-- .../eatda/service/story/StoryService.java | 12 ++--- .../image/CachePreSignedUrlStorage.java} | 6 +-- .../image/ExternalImageStorage.java} | 7 +-- .../eatda/storage/image/ImageStorage.java | 41 +++++++++++++++++ .../eatda/controller/BaseControllerTest.java | 8 ++-- ...itoryTest.java => BaseRepositoryTest.java} | 2 +- .../repository/store/CheerRepositoryTest.java | 4 +- .../java/eatda/service/BaseServiceTest.java | 8 ++-- .../eatda/service/story/StoryServiceTest.java | 6 +-- .../BaseStorageTest.java} | 4 +- .../image/ExternalImageStorageTest.java} | 17 +++---- .../image/ImageStorageTest.java} | 45 ++++++++++--------- 17 files changed, 115 insertions(+), 110 deletions(-) rename src/main/java/eatda/{repository/image => domain}/ImageDomain.java (89%) delete mode 100644 src/main/java/eatda/repository/image/ImageRepository.java rename src/main/java/eatda/{repository/image/CachePreSignedUrlRepository.java => storage/image/CachePreSignedUrlStorage.java} (81%) rename src/main/java/eatda/{repository/image/S3ImageRepository.java => storage/image/ExternalImageStorage.java} (96%) create mode 100644 src/main/java/eatda/storage/image/ImageStorage.java rename src/test/java/eatda/repository/{BaseJpaRepositoryTest.java => BaseRepositoryTest.java} (95%) rename src/test/java/eatda/{repository/BaseCacheRepositoryTest.java => storage/BaseStorageTest.java} (77%) rename src/test/java/eatda/{repository/image/S3ImageRepositoryTest.java => storage/image/ExternalImageStorageTest.java} (90%) rename src/test/java/eatda/{repository/image/ImageRepositoryTest.java => storage/image/ImageStorageTest.java} (55%) diff --git a/src/main/java/eatda/repository/image/ImageDomain.java b/src/main/java/eatda/domain/ImageDomain.java similarity index 89% rename from src/main/java/eatda/repository/image/ImageDomain.java rename to src/main/java/eatda/domain/ImageDomain.java index 1b321c6..3559662 100644 --- a/src/main/java/eatda/repository/image/ImageDomain.java +++ b/src/main/java/eatda/domain/ImageDomain.java @@ -1,4 +1,4 @@ -package eatda.repository.image; +package eatda.domain; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/eatda/repository/image/ImageRepository.java b/src/main/java/eatda/repository/image/ImageRepository.java deleted file mode 100644 index 90ca6c8..0000000 --- a/src/main/java/eatda/repository/image/ImageRepository.java +++ /dev/null @@ -1,39 +0,0 @@ -package eatda.repository.image; - -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -@Component -@RequiredArgsConstructor -public class ImageRepository { - - private final S3ImageRepository s3Repository; - private final CachePreSignedUrlRepository cachePreSignedUrlRepository; - - - public String upload(MultipartFile file, ImageDomain domain) { - String imageKey = s3Repository.upload(file, domain); - - String preSignedUrl = s3Repository.getPresignedUrl(imageKey); - cachePreSignedUrlRepository.put(imageKey, preSignedUrl); - return imageKey; - } - - @Nullable - public String getPresignedUrl(@Nullable String imageKey) { - if (imageKey == null || imageKey.isEmpty()) { - return null; - } - - Optional cachedUrl = cachePreSignedUrlRepository.get(imageKey); - if (cachedUrl.isPresent()) { - return cachedUrl.get(); - } - String preSignedUrl = s3Repository.getPresignedUrl(imageKey); - cachePreSignedUrlRepository.put(imageKey, preSignedUrl); - return preSignedUrl; - } -} diff --git a/src/main/java/eatda/service/article/ArticleService.java b/src/main/java/eatda/service/article/ArticleService.java index 55d19b0..825bc8d 100644 --- a/src/main/java/eatda/service/article/ArticleService.java +++ b/src/main/java/eatda/service/article/ArticleService.java @@ -3,7 +3,7 @@ import eatda.controller.article.ArticleResponse; import eatda.controller.article.ArticlesResponse; import eatda.repository.article.ArticleRepository; -import eatda.repository.image.ImageRepository; +import eatda.storage.image.ImageStorage; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; @@ -14,7 +14,7 @@ public class ArticleService { private final ArticleRepository articleRepository; - private final ImageRepository imageRepository; + private final ImageStorage imageStorage; public ArticlesResponse getAllArticles(int size) { PageRequest pageRequest = PageRequest.of(0, size); @@ -24,7 +24,7 @@ public ArticlesResponse getAllArticles(int size) { article.getTitle(), article.getSubtitle(), article.getArticleUrl(), - imageRepository.getPresignedUrl(article.getImageKey()) + imageStorage.getPresignedUrl(article.getImageKey()) )) .toList(); diff --git a/src/main/java/eatda/service/store/CheerService.java b/src/main/java/eatda/service/store/CheerService.java index fdbf205..8050b9f 100644 --- a/src/main/java/eatda/service/store/CheerService.java +++ b/src/main/java/eatda/service/store/CheerService.java @@ -6,16 +6,16 @@ import eatda.controller.store.CheerRegisterRequest; import eatda.controller.store.CheerResponse; import eatda.controller.store.CheersResponse; +import eatda.domain.ImageDomain; import eatda.domain.member.Member; import eatda.domain.store.Cheer; -import eatda.repository.image.ImageDomain; -import eatda.repository.image.ImageRepository; import eatda.domain.store.Store; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import eatda.repository.member.MemberRepository; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; +import eatda.storage.image.ImageStorage; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -34,7 +34,7 @@ public class CheerService { private final MemberRepository memberRepository; private final StoreRepository storeRepository; private final CheerRepository cheerRepository; - private final ImageRepository imageRepository; + private final ImageStorage imageStorage; @Transactional public CheerResponse registerCheer(CheerRegisterRequest request, MultipartFile image, long memberId) { @@ -43,12 +43,12 @@ public CheerResponse registerCheer(CheerRegisterRequest request, MultipartFile i List searchResults = mapClient.searchShops(request.storeName()); StoreSearchResult result = storeSearchFilter.filterStoreByKakaoId(searchResults, request.storeKakaoId()); - String imageKey = imageRepository.upload(image, ImageDomain.CHEER); + String imageKey = imageStorage.upload(image, ImageDomain.CHEER); Store store = storeRepository.findByKakaoId(result.kakaoId()) .orElseGet(() -> storeRepository.save(result.toStore())); // TODO 상점 조회/저장 동시성 이슈 해결 Cheer cheer = cheerRepository.save(new Cheer(member, store, request.description(), imageKey)); - return new CheerResponse(cheer, imageRepository.getPresignedUrl(imageKey), store); + return new CheerResponse(cheer, imageStorage.getPresignedUrl(imageKey), store); } private void validateRegisterCheer(Member member, String storeKakaoId) { @@ -69,7 +69,7 @@ public CheersResponse getCheers(int size) { private CheersResponse toCheersResponse(List cheers) { return new CheersResponse(cheers.stream() .map(cheer -> new CheerPreviewResponse(cheer, cheer.getStore(), - imageRepository.getPresignedUrl(cheer.getImageKey()))) + imageStorage.getPresignedUrl(cheer.getImageKey()))) .toList()); } } diff --git a/src/main/java/eatda/service/store/StoreService.java b/src/main/java/eatda/service/store/StoreService.java index 7dc71dd..7fd0927 100644 --- a/src/main/java/eatda/service/store/StoreService.java +++ b/src/main/java/eatda/service/store/StoreService.java @@ -9,9 +9,9 @@ import eatda.controller.store.StoreSearchResponses; import eatda.controller.store.StoresResponse; import eatda.domain.store.Store; -import eatda.repository.image.ImageRepository; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; +import eatda.storage.image.ImageStorage; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -26,7 +26,7 @@ public class StoreService { private final StoreSearchFilter storeSearchFilter; private final StoreRepository storeRepository; private final CheerRepository cheerRepository; - private final ImageRepository imageRepository; + private final ImageStorage imageStorage; // TODO : N+1 문제 해결 public StoresResponse getStores(int size) { @@ -38,7 +38,7 @@ public StoresResponse getStores(int size) { private Optional getStoreImageUrl(Store store) { return cheerRepository.findRecentImageKey(store) - .map(imageRepository::getPresignedUrl); + .map(imageStorage::getPresignedUrl); } public StoreSearchResponses searchStores(String query) { diff --git a/src/main/java/eatda/service/story/StoryService.java b/src/main/java/eatda/service/story/StoryService.java index 17e3c88..5f1183f 100644 --- a/src/main/java/eatda/service/story/StoryService.java +++ b/src/main/java/eatda/service/story/StoryService.java @@ -7,14 +7,14 @@ import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryRegisterResponse; import eatda.controller.story.StoryResponse; +import eatda.domain.ImageDomain; import eatda.domain.member.Member; import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; -import eatda.repository.image.ImageDomain; -import eatda.repository.image.ImageRepository; import eatda.repository.member.MemberRepository; import eatda.repository.story.StoryRepository; +import eatda.storage.image.ImageStorage; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -30,7 +30,7 @@ public class StoryService { private static final int PAGE_START_NUMBER = 0; - private final ImageRepository imageRepository; + private final ImageStorage imageStorage; private final MapClient mapClient; private final StoryRepository storyRepository; private final MemberRepository memberRepository; @@ -40,7 +40,7 @@ public StoryRegisterResponse registerStory(StoryRegisterRequest request, Multipa Member member = memberRepository.getById(memberId); List searchResponses = mapClient.searchShops(request.query()); FilteredSearchResult matchedStore = filteredSearchResponse(searchResponses, request.storeKakaoId()); - String imageKey = imageRepository.upload(image, ImageDomain.STORY); + String imageKey = imageStorage.upload(image, ImageDomain.STORY); Story story = Story.builder() .member(member) @@ -81,7 +81,7 @@ public StoriesResponse getPagedStoryPreviews(int size) { orderByPage.getContent().stream() .map(story -> new StoriesResponse.StoryPreview( story.getId(), - imageRepository.getPresignedUrl(story.getImageKey()) + imageStorage.getPresignedUrl(story.getImageKey()) )) .toList() ); @@ -99,7 +99,7 @@ public StoryResponse getStory(long storyId) { story.getAddressDistrict(), story.getAddressNeighborhood(), story.getDescription(), - imageRepository.getPresignedUrl(story.getImageKey()) + imageStorage.getPresignedUrl(story.getImageKey()) ); } } diff --git a/src/main/java/eatda/repository/image/CachePreSignedUrlRepository.java b/src/main/java/eatda/storage/image/CachePreSignedUrlStorage.java similarity index 81% rename from src/main/java/eatda/repository/image/CachePreSignedUrlRepository.java rename to src/main/java/eatda/storage/image/CachePreSignedUrlStorage.java index a8993bf..675b07e 100644 --- a/src/main/java/eatda/repository/image/CachePreSignedUrlRepository.java +++ b/src/main/java/eatda/storage/image/CachePreSignedUrlStorage.java @@ -1,4 +1,4 @@ -package eatda.repository.image; +package eatda.storage.image; import eatda.repository.CacheSetting; import java.util.Optional; @@ -7,13 +7,13 @@ import org.springframework.stereotype.Component; @Component -public class CachePreSignedUrlRepository { +public class CachePreSignedUrlStorage { private static final String CACHE_NAME = CacheSetting.IMAGE.getName(); private final Cache cache; - public CachePreSignedUrlRepository(CacheManager cacheManager) { + public CachePreSignedUrlStorage(CacheManager cacheManager) { this.cache = cacheManager.getCache(CACHE_NAME); } diff --git a/src/main/java/eatda/repository/image/S3ImageRepository.java b/src/main/java/eatda/storage/image/ExternalImageStorage.java similarity index 96% rename from src/main/java/eatda/repository/image/S3ImageRepository.java rename to src/main/java/eatda/storage/image/ExternalImageStorage.java index de0ea9f..68b896a 100644 --- a/src/main/java/eatda/repository/image/S3ImageRepository.java +++ b/src/main/java/eatda/storage/image/ExternalImageStorage.java @@ -1,5 +1,6 @@ -package eatda.repository.image; +package eatda.storage.image; +import eatda.domain.ImageDomain; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import java.io.IOException; @@ -17,7 +18,7 @@ import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; @Component -public class S3ImageRepository { +public class ExternalImageStorage { private static final Set ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png"); private static final String DEFAULT_CONTENT_TYPE = "bin"; @@ -29,7 +30,7 @@ public class S3ImageRepository { private final String bucket; private final S3Presigner s3Presigner; - public S3ImageRepository( + public ExternalImageStorage( S3Client s3Client, @Value("${spring.cloud.aws.s3.bucket}") String bucket, S3Presigner s3Presigner) { diff --git a/src/main/java/eatda/storage/image/ImageStorage.java b/src/main/java/eatda/storage/image/ImageStorage.java new file mode 100644 index 0000000..2aea0b3 --- /dev/null +++ b/src/main/java/eatda/storage/image/ImageStorage.java @@ -0,0 +1,41 @@ +package eatda.storage.image; + +import eatda.domain.ImageDomain; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@RequiredArgsConstructor +public class ImageStorage { + + private final ExternalImageStorage externalImageStorage; + private final CachePreSignedUrlStorage cachePreSignedUrlStorage; + + + public String upload(MultipartFile file, ImageDomain domain) { + String imageKey = externalImageStorage.upload(file, domain); + + String preSignedUrl = externalImageStorage.getPresignedUrl(imageKey); + cachePreSignedUrlStorage.put(imageKey, preSignedUrl); + return imageKey; + } + + @Nullable + public String getPresignedUrl(@Nullable String imageKey) { + if (imageKey == null || imageKey.isEmpty()) { + return null; + } + + Optional cachedUrl = cachePreSignedUrlStorage.get(imageKey); + if (cachedUrl.isPresent()) { + return cachedUrl.get(); + } + + String preSignedUrl = externalImageStorage.getPresignedUrl(imageKey); + cachePreSignedUrlStorage.put(imageKey, preSignedUrl); + return preSignedUrl; + } +} diff --git a/src/test/java/eatda/controller/BaseControllerTest.java b/src/test/java/eatda/controller/BaseControllerTest.java index ace1978..cabb5f8 100644 --- a/src/test/java/eatda/controller/BaseControllerTest.java +++ b/src/test/java/eatda/controller/BaseControllerTest.java @@ -16,11 +16,11 @@ import eatda.fixture.CheerGenerator; import eatda.fixture.MemberGenerator; import eatda.fixture.StoreGenerator; -import eatda.repository.image.ImageRepository; import eatda.repository.member.MemberRepository; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; import eatda.service.story.StoryService; +import eatda.storage.image.ImageStorage; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.filter.Filter; @@ -81,7 +81,7 @@ public class BaseControllerTest { private MapClient mapClient; @MockitoBean - private ImageRepository imageRepository; + private ImageStorage imageStorage; @MockitoBean protected StoryService storyService; // TODO 실 객체로 변환 @@ -113,8 +113,8 @@ final void mockingClient() throws URISyntaxException { ); doReturn(searchResults).when(mapClient).searchShops(anyString()); - doReturn(MOCKED_IMAGE_URL).when(imageRepository).getPresignedUrl(anyString()); - doReturn(MOCKED_IMAGE_KEY).when(imageRepository).upload(any(), any()); + doReturn(MOCKED_IMAGE_URL).when(imageStorage).getPresignedUrl(anyString()); + doReturn(MOCKED_IMAGE_KEY).when(imageStorage).upload(any(), any()); } protected final RequestSpecification given() { diff --git a/src/test/java/eatda/repository/BaseJpaRepositoryTest.java b/src/test/java/eatda/repository/BaseRepositoryTest.java similarity index 95% rename from src/test/java/eatda/repository/BaseJpaRepositoryTest.java rename to src/test/java/eatda/repository/BaseRepositoryTest.java index 9de25ca..2fd3320 100644 --- a/src/test/java/eatda/repository/BaseJpaRepositoryTest.java +++ b/src/test/java/eatda/repository/BaseRepositoryTest.java @@ -13,7 +13,7 @@ @Import({MemberGenerator.class, StoreGenerator.class, CheerGenerator.class}) @DataJpaTest -public abstract class BaseJpaRepositoryTest { +public abstract class BaseRepositoryTest { @Autowired protected MemberGenerator memberGenerator; diff --git a/src/test/java/eatda/repository/store/CheerRepositoryTest.java b/src/test/java/eatda/repository/store/CheerRepositoryTest.java index 906f76d..88b8ef3 100644 --- a/src/test/java/eatda/repository/store/CheerRepositoryTest.java +++ b/src/test/java/eatda/repository/store/CheerRepositoryTest.java @@ -4,12 +4,12 @@ import eatda.domain.member.Member; import eatda.domain.store.Store; -import eatda.repository.BaseJpaRepositoryTest; +import eatda.repository.BaseRepositoryTest; import java.util.Optional; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -class CheerRepositoryTest extends BaseJpaRepositoryTest { +class CheerRepositoryTest extends BaseRepositoryTest { @Nested class FindRecentImageKey { diff --git a/src/test/java/eatda/service/BaseServiceTest.java b/src/test/java/eatda/service/BaseServiceTest.java index 5efc5ca..7ec2ca9 100644 --- a/src/test/java/eatda/service/BaseServiceTest.java +++ b/src/test/java/eatda/service/BaseServiceTest.java @@ -11,11 +11,11 @@ import eatda.fixture.CheerGenerator; import eatda.fixture.MemberGenerator; import eatda.fixture.StoreGenerator; -import eatda.repository.image.S3ImageRepository; import eatda.repository.member.MemberRepository; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; import eatda.repository.story.StoryRepository; +import eatda.storage.image.ExternalImageStorage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -36,7 +36,7 @@ public abstract class BaseServiceTest { protected MapClient mapClient; @MockitoBean - protected S3ImageRepository imageRepository; + protected ExternalImageStorage externalImageStorage; @Autowired protected MemberGenerator memberGenerator; @@ -64,7 +64,7 @@ public abstract class BaseServiceTest { @BeforeEach void mockingImageService() { - doReturn(MOCKED_IMAGE_URL).when(imageRepository).getPresignedUrl(anyString()); - doReturn(MOCKED_IMAGE_KEY).when(imageRepository).upload(any(), any()); + doReturn(MOCKED_IMAGE_URL).when(externalImageStorage).getPresignedUrl(anyString()); + doReturn(MOCKED_IMAGE_KEY).when(externalImageStorage).upload(any(), any()); } } diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index e562741..7256203 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -10,12 +10,12 @@ import eatda.controller.story.StoriesResponse.StoryPreview; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryResponse; +import eatda.domain.ImageDomain; import eatda.domain.member.Member; import eatda.domain.store.StoreCategory; import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; -import eatda.repository.image.ImageDomain; import eatda.service.BaseServiceTest; import java.util.Collections; import java.util.List; @@ -44,7 +44,7 @@ class RegisterStory { "서울 강남구", "서울 강남구", 37.0, 127.0 ); doReturn(List.of(store)).when(mapClient).searchShops(request.query()); - when(imageRepository.upload(image, ImageDomain.STORY)).thenReturn("image-key"); + when(externalImageStorage.upload(image, ImageDomain.STORY)).thenReturn("image-key"); var response = storyService.registerStory(request, image, member.getId()); @@ -122,7 +122,7 @@ class GetStory { storyRepository.save(story); - when(imageRepository.getPresignedUrl("story-image-key")) + when(externalImageStorage.getPresignedUrl("story-image-key")) .thenReturn("https://s3.bucket.com/story/dummy/1.jpg"); StoryResponse response = storyService.getStory(story.getId()); diff --git a/src/test/java/eatda/repository/BaseCacheRepositoryTest.java b/src/test/java/eatda/storage/BaseStorageTest.java similarity index 77% rename from src/test/java/eatda/repository/BaseCacheRepositoryTest.java rename to src/test/java/eatda/storage/BaseStorageTest.java index e26db01..8fb2405 100644 --- a/src/test/java/eatda/repository/BaseCacheRepositoryTest.java +++ b/src/test/java/eatda/storage/BaseStorageTest.java @@ -1,9 +1,9 @@ -package eatda.repository; +package eatda.storage; import eatda.config.CacheConfig; import org.springframework.cache.CacheManager; -public abstract class BaseCacheRepositoryTest { +public abstract class BaseStorageTest { private final CacheManager cacheManager = new CacheConfig().cacheManager(); diff --git a/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java b/src/test/java/eatda/storage/image/ExternalImageStorageTest.java similarity index 90% rename from src/test/java/eatda/repository/image/S3ImageRepositoryTest.java rename to src/test/java/eatda/storage/image/ExternalImageStorageTest.java index fe84ace..4376485 100644 --- a/src/test/java/eatda/repository/image/S3ImageRepositoryTest.java +++ b/src/test/java/eatda/storage/image/ExternalImageStorageTest.java @@ -1,4 +1,4 @@ -package eatda.repository.image; +package eatda.storage.image; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -8,6 +8,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import eatda.domain.ImageDomain; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import java.net.URL; @@ -27,19 +28,19 @@ import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; -class S3ImageRepositoryTest { +class ExternalImageStorageTest { private static final String TEST_BUCKET = "test-bucket"; private S3Client s3Client; private S3Presigner s3Presigner; - private S3ImageRepository s3ImageRepository; + private ExternalImageStorage externalImageStorage; @BeforeEach void setUp() { s3Client = mock(S3Client.class); s3Presigner = mock(S3Presigner.class); - s3ImageRepository = new S3ImageRepository(s3Client, TEST_BUCKET, s3Presigner); + externalImageStorage = new ExternalImageStorage(s3Client, TEST_BUCKET, s3Presigner); } @Nested @@ -55,7 +56,7 @@ class FileUpload { "image", originalFilename, contentType, "image-content".getBytes() ); - String key = s3ImageRepository.upload(file, imageDomain); + String key = externalImageStorage.upload(file, imageDomain); ArgumentCaptor putObjectRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); verify(s3Client).putObject(putObjectRequestCaptor.capture(), any(RequestBody.class)); @@ -78,7 +79,7 @@ class FileUpload { ); BusinessException exception = assertThrows(BusinessException.class, - () -> s3ImageRepository.upload(file, ImageDomain.STORY)); + () -> externalImageStorage.upload(file, ImageDomain.STORY)); assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE); } @@ -99,7 +100,7 @@ class GeneratePresignedUrl { when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) .thenReturn(presignedRequestResult); - String presignedUrl = s3ImageRepository.getPresignedUrl(key); + String presignedUrl = externalImageStorage.getPresignedUrl(key); ArgumentCaptor presignRequestCaptor = ArgumentCaptor.forClass(GetObjectPresignRequest.class); @@ -122,7 +123,7 @@ class GeneratePresignedUrl { .thenThrow(SdkClientException.create("AWS SDK 통신 실패")); BusinessException exception = assertThrows(BusinessException.class, - () -> s3ImageRepository.getPresignedUrl(key)); + () -> externalImageStorage.getPresignedUrl(key)); assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); } diff --git a/src/test/java/eatda/repository/image/ImageRepositoryTest.java b/src/test/java/eatda/storage/image/ImageStorageTest.java similarity index 55% rename from src/test/java/eatda/repository/image/ImageRepositoryTest.java rename to src/test/java/eatda/storage/image/ImageStorageTest.java index c120acd..57f2182 100644 --- a/src/test/java/eatda/repository/image/ImageRepositoryTest.java +++ b/src/test/java/eatda/storage/image/ImageStorageTest.java @@ -1,4 +1,4 @@ -package eatda.repository.image; +package eatda.storage.image; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -8,7 +8,8 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import eatda.repository.BaseCacheRepositoryTest; +import eatda.domain.ImageDomain; +import eatda.storage.BaseStorageTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -16,17 +17,17 @@ import org.junit.jupiter.params.provider.NullAndEmptySource; import org.springframework.mock.web.MockMultipartFile; -class ImageRepositoryTest extends BaseCacheRepositoryTest { +class ImageStorageTest extends BaseStorageTest { - private S3ImageRepository s3ImageRepository; - private CachePreSignedUrlRepository cachePreSignedUrlRepository; - private ImageRepository imageRepository; + private ExternalImageStorage externalImageStorage; + private CachePreSignedUrlStorage cachePreSignedUrlStorage; + private ImageStorage imageStorage; @BeforeEach void setUp() { - s3ImageRepository = mock(S3ImageRepository.class); - cachePreSignedUrlRepository = new CachePreSignedUrlRepository(getCacheManager()); - imageRepository = new ImageRepository(s3ImageRepository, cachePreSignedUrlRepository); + externalImageStorage = mock(ExternalImageStorage.class); + cachePreSignedUrlStorage = new CachePreSignedUrlStorage(getCacheManager()); + imageStorage = new ImageStorage(externalImageStorage, cachePreSignedUrlStorage); } @Nested @@ -37,9 +38,9 @@ class Upload { MockMultipartFile file = new MockMultipartFile( "image", "test-image.jpg", "image/jpeg", "image-content".getBytes() ); - doReturn("test-image-key").when(s3ImageRepository).upload(file, ImageDomain.MEMBER); + doReturn("test-image-key").when(externalImageStorage).upload(file, ImageDomain.MEMBER); - String imageKey = imageRepository.upload(file, ImageDomain.MEMBER); + String imageKey = imageStorage.upload(file, ImageDomain.MEMBER); assertThat(imageKey).isEqualTo("test-image-key"); } @@ -49,12 +50,12 @@ class Upload { MockMultipartFile file = new MockMultipartFile( "image", "test-image.jpg", "image/jpeg", "image-content".getBytes() ); - doReturn("test-image-key").when(s3ImageRepository).upload(file, ImageDomain.MEMBER); - doReturn("https://example.url.com").when(s3ImageRepository).getPresignedUrl("test-image-key"); + doReturn("test-image-key").when(externalImageStorage).upload(file, ImageDomain.MEMBER); + doReturn("https://example.url.com").when(externalImageStorage).getPresignedUrl("test-image-key"); - imageRepository.upload(file, ImageDomain.MEMBER); + imageStorage.upload(file, ImageDomain.MEMBER); - assertThat(cachePreSignedUrlRepository.get("test-image-key")).contains("https://example.url.com"); + assertThat(cachePreSignedUrlStorage.get("test-image-key")).contains("https://example.url.com"); } } @@ -64,7 +65,7 @@ class GetPresignedUrl { @ParameterizedTest @NullAndEmptySource void 이미지_키가_null이면__null을_반환한다(String imageKey) { - String actual = imageRepository.getPresignedUrl(imageKey); + String actual = imageStorage.getPresignedUrl(imageKey); assertThat(actual).isNull(); } @@ -72,26 +73,26 @@ class GetPresignedUrl { @Test void 이미지_키가_캐시에_존재하면_s3에_요청하지_않고_PreSignedUrl을_반환한다() { String imageKey = "test-image-key"; - cachePreSignedUrlRepository.put(imageKey, "https://example.url.com"); + cachePreSignedUrlStorage.put(imageKey, "https://example.url.com"); - String preSignedUrl = imageRepository.getPresignedUrl(imageKey); + String preSignedUrl = imageStorage.getPresignedUrl(imageKey); assertAll( () -> assertThat(preSignedUrl).isEqualTo("https://example.url.com"), - () -> verify(s3ImageRepository, never()).getPresignedUrl(anyString()) + () -> verify(externalImageStorage, never()).getPresignedUrl(anyString()) ); } @Test void 이미지_키가_캐시에_존재하지_않으면_S3에서_PreSignedUrl을_조회하고_캐시에_저장한다() { String imageKey = "test-image-key"; - doReturn("https://example.url.com").when(s3ImageRepository).getPresignedUrl(imageKey); + doReturn("https://example.url.com").when(externalImageStorage).getPresignedUrl(imageKey); - String preSignedUrl = imageRepository.getPresignedUrl(imageKey); + String preSignedUrl = imageStorage.getPresignedUrl(imageKey); assertAll( () -> assertThat(preSignedUrl).isEqualTo("https://example.url.com"), - () -> assertThat(cachePreSignedUrlRepository.get(imageKey)).contains("https://example.url.com") + () -> assertThat(cachePreSignedUrlStorage.get(imageKey)).contains("https://example.url.com") ); } }