Skip to content

[Refactor] Presigned URL 캐싱 #89

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
36 changes: 36 additions & 0 deletions src/main/java/eatda/config/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -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<CaffeineCache> 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());
}
}
21 changes: 21 additions & 0 deletions src/main/java/eatda/repository/CacheSetting.java
Original file line number Diff line number Diff line change
@@ -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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[제안]

현재 개발, 프로덕션 인스턴스 메모리가 여유가 거의 없기에 1000개는 많을수도 있을것 같아요
우선 100개정도로만 조정해보는것은 어떨까요?

Copy link
Member Author

@leegwichan leegwichan Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

캐시가 차지하는 전반적인 용량을 이번 기회에 고려해 보았습니다.

  • 저희 서비스 자체가 이미지가 주가 되는 서비스다 보니, 개수를 널널하게 잡은 것 같아요. 저희 홈 화면에 조회되는 스토리/응원/상점 이미지만 약 20~30 장 정도 되고, 각 상점들에도 꽤 이미지가 나올 것 같아서 100장은 부족할 것 같다고 생각했습니다.
  • 만들어지는 imageKey와 S3에서 주는 ImageUrl 길이를 측정해 보았을 때, 1000 개 정도 저장하면 4.5-5MB 정도 차지한다고 하더라구요. 참고 자료(ChatGPT)

위 두 조건을 고려했을 때, 크기는 어느 정도가 좋을까요?

;

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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String> get(String imageKey) {
return Optional.ofNullable(cache.get(imageKey, String.class));
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package eatda.service.common;
package eatda.repository.image;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/eatda/repository/image/ImageRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[질문]

음.. 현재 ImageRepository는 실질적으로 S3 업로드, Presigned URL 생성, 캐시 저장/탐색 등
도메인 수준의 정책 판단과 흐름 제어를 포함하고 있어 보이는데요

이런 역할은 보통 Service 계층에서 수행하는 책임으로 보고,
지금 구조는 이름만 Repository이지 실제로는 완전히 Service의 역할을 하고 있다고 느껴지는데요.

왜 계층을 바꾸신 건지 잘 이해가 되지 않아요 😿

혹시 단순히 기술적 의존성을 줄이려는 의도라면, Service 내부에서 cache + S3를 위임하는 구조로도 충분히 가능한데,
이걸 Repository로 옮긴 이유가 궁금합니다.

Copy link
Member Author

@leegwichan leegwichan Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 서비스의 상호 참조를 막기 위해서는 다른 서비스를 참조하는 것을 완전히 막아야 한다고 생각해요. 그래서 '해당 서비스는 다른 서비스에서 사용할 작업을 내포한다는 점'에서 서비스로 보기 힘들다고 생각했어요. 그래서 그나마 적합한 것이 Repository라고 생각했습니다. (너무 여러 고민을 하기 보다는 빠르게 구현하기 위해 일단 Repository로 배치했습니다)
저도 해당 부분이 "여러가지 검증을 한다는 점"에서 레포지토리라고 보기 힘들다고 생각합니다. 기존의 "ImageService"가 서비스 치고는 여러 책임들(MultipartFile 유효성 검사, 이미지 key 이름 지정, S3Client를 통한 업로드,...)이 다 같이 응집되어 있어 어느 네이밍을 붙여도 어색한 포지션이 되지 않았나 싶습니다.

해당 부분을 Repository로 판단하기 보다는, 리팩토링을 나누어 작업하기 위해 일단 임시적으로 ImageRepository로 두었다고 생각해주시면 감사드리겠습니다. 그래서 앞으로 리팩토링을 진행하면서 아래처럼 역할을 나누어 관리하려고 합니다. 아래와 같은 코드에 대해 편하게 의견 주시면 감사드리겠습니다.

public class Image {

   private final MultipartFile multipartFile;
   private final ImageDomain domain;

   public Image(MultipartFile multipartFile, ImageDomain domain) {
       // multipartFile 이미지 유효성 검사 (파일 확장자 검사, )
   }
}

@Getter
@Embeddable
public ImageKey {

      private final String imageKey;
      ....
}
public class ImageRepository {

       public ImageKey save(Image image) {
               // 현재 PR의 S3ImageRepository, CachePreSignedUrlRepository를 이용한 로직
       }
}

public class S3ImageRepository {

       public ImageKey save(Image image) {
           // 이미지 key 생성 로직
           // FileClient 를 통한 업로드
       }
}

public class FileClient { // 이름은 고민 중입니다...
        
        public String upload(MultipartFile file, String key) {
         // AWS SDK 의 s3Client를 이용해 파일 업로드
        }
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public class ImageStorage {

       public ImageKey upload(Image image) {
               // 현재 PR의 S3ImageStorage, CachePreSignedUrlStorage를 이용한 로직
       }
}

public class S3ImageStorage {

       public ImageKey upload(Image image) {
           // 이미지 key 생성 로직
           // FileClient 를 통한 업로드
       }
}

public class FileClient { // 이름은 고민 중입니다...
        
        public String upload(MultipartFile file, String key) {
         // AWS SDK 의 s3Client를 이용해 파일 업로드
        }
}


private final S3ImageRepository s3Repository;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[질문]

클래스명과 변수명이 다른건 의도하신건가요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클래스 이름이 ImageRepository 이니까, 굳이 해당 레포지토리가 image임을 명시할 필요는 없다고 생각했습니다.
관련해서 승로님의 의견도 궁금합니다!

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<String> cachedUrl = cachePreSignedUrlRepository.get(imageKey);
if (cachedUrl.isPresent()) {
return cachedUrl.get();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공백이 추가되면 가독성이 높아질것 같아요!

Copy link
Member Author

@leegwichan leegwichan Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희가 이전에 합의했던 컨벤션 중 "의미없는 빈 줄은 넣지 않는다" 가 있었습니다. 그래서 공백을 추가할까 고민하다가 일단 추가 안했습니다. 해당 메서드의 공백을 어디에 넣어야 가독성이 좋을까요?

String preSignedUrl = s3Repository.getPresignedUrl(imageKey);
cachePreSignedUrlRepository.put(imageKey, preSignedUrl);
return preSignedUrl;
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package eatda.service.common;
package eatda.repository.image;

import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import eatda.repository.CacheSetting;
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.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
Expand All @@ -17,20 +17,21 @@
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;

@Service
public class ImageService {
@Component
public class S3ImageRepository {

private static final Set<String> 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 static final Duration PRESIGNED_URL_DURATION = CacheSetting.IMAGE.getTimeToLive()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[수정]

현재 PRESIGNED_URL_DURATION 설정이 CacheSetting의 TTL에 의존하게 바뀌었는데,
정책 상으로는 오히려 Presigned URL의 유효시간이 기준이 되고,
캐시 TTL은 그보다 짧게 설정되어야 하지 않을까요?

Duration.ofMinutes(30) 등으로 기준을 고정하고
캐시는 PRESIGNED_URL_DURATION 을 기준으로 바뀌는게 좋을것 같아요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 애매했는데..., 그냥 이전처럼 Duration.ofMinutes(30)으로 바꿔놓겠습니다!

.plus(Duration.ofMinutes(5));

private final S3Client s3Client;
private final String bucket;
private final S3Presigner s3Presigner;

public ImageService(
public S3ImageRepository(
S3Client s3Client,
@Value("${spring.cloud.aws.s3.bucket}") String bucket,
S3Presigner s3Presigner) {
Expand Down Expand Up @@ -75,12 +76,8 @@ private String getExtension(String filename) {
return filename.substring(filename.lastIndexOf(EXTENSION_DELIMITER) + 1);
}

@Nullable
public String getPresignedUrl(@Nullable String key) {
if (key == null) {
return null;
}

public String getPresignedUrl(String key) {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucket)
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/eatda/service/store/CheerService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -27,7 +27,7 @@ public CheersResponse getCheers(int size) {
private CheersResponse toCheersResponse(List<Cheer> cheers) {
return new CheersResponse(cheers.stream()
.map(cheer -> new CheerPreviewResponse(cheer, cheer.getStore(),
imageService.getPresignedUrl(cheer.getImageKey())))
imageRepository.getPresignedUrl(cheer.getImageKey())))
.toList());
}
}
6 changes: 3 additions & 3 deletions src/main/java/eatda/service/store/StoreService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -38,7 +38,7 @@ public StoresResponse getStores(int size) {

private Optional<String> getStoreImageUrl(Store store) {
return cheerRepository.findRecentImageKey(store)
.map(imageService::getPresignedUrl);
.map(imageRepository::getPresignedUrl);
}

public StoreSearchResponses searchStores(String query) {
Expand Down
11 changes: 6 additions & 5 deletions src/main/java/eatda/service/story/StoryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.common.ImageService;
import eatda.service.store.StoreService;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand All @@ -25,11 +25,12 @@
@Service
@RequiredArgsConstructor
public class StoryService {

private static final int PAGE_START_NUMBER = 0;
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;

Expand All @@ -38,7 +39,7 @@ public void registerStory(StoryRegisterRequest request, MultipartFile image, Lon
Member member = memberRepository.getById(memberId);
List<StoreSearchResult> 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)
Expand Down Expand Up @@ -75,7 +76,7 @@ public StoriesResponse getPagedStoryPreviews() {
orderByPage.getContent().stream()
.map(story -> new StoriesResponse.StoryPreview(
story.getId(),
imageService.getPresignedUrl(story.getImageKey())
imageRepository.getPresignedUrl(story.getImageKey())
))
.toList()
);
Expand Down
9 changes: 4 additions & 5 deletions src/test/java/eatda/controller/BaseControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,7 +77,7 @@ public class BaseControllerTest {
private MapClient mapClient;

@MockitoBean
private ImageService imageService;
private ImageRepository imageRepository;

@MockitoBean
protected StoryService storyService; // TODO 실 객체로 변환
Expand Down Expand Up @@ -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() {
Expand Down
5 changes: 2 additions & 3 deletions src/test/java/eatda/controller/story/StoryControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
5 changes: 1 addition & 4 deletions src/test/java/eatda/document/BaseDocumentTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -52,11 +51,9 @@ public abstract class BaseDocumentTest {
@MockitoBean
protected StoryService storyService;

@MockitoBean
protected ImageService imageService;

@MockitoBean
protected CheerService cheerService;

@MockitoBean
protected JwtManager jwtManager;

Expand Down
Loading