-
Notifications
You must be signed in to change notification settings - Fork 0
[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
base: develop
Are you sure you want to change the base?
Changes from 5 commits
8d551e9
bda1747
b6b907e
a45aadc
57ec26d
1310295
b217101
34f60ed
3fcf66a
e0862f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,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()); | ||
} | ||
} |
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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [제안] 현재 개발, 프로덕션 인스턴스 메모리가 여유가 거의 없기에 1000개는 많을수도 있을것 같아요 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 캐시가 차지하는 전반적인 용량을 이번 기회에 고려해 보았습니다.
위 두 조건을 고려했을 때, 크기는 어느 정도가 좋을까요? |
||
; | ||
|
||
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 |
---|---|---|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [질문] 음.. 현재 이런 역할은 보통 Service 계층에서 수행하는 책임으로 보고, 왜 계층을 바꾸신 건지 잘 이해가 되지 않아요 😿 혹시 단순히 기술적 의존성을 줄이려는 의도라면, Service 내부에서 cache + S3를 위임하는 구조로도 충분히 가능한데, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저는 서비스의 상호 참조를 막기 위해서는 다른 서비스를 참조하는 것을 완전히 막아야 한다고 생각해요. 그래서 '해당 서비스는 다른 서비스에서 사용할 작업을 내포한다는 점'에서 서비스로 보기 힘들다고 생각했어요. 그래서 그나마 적합한 것이 Repository라고 생각했습니다. (너무 여러 고민을 하기 보다는 빠르게 구현하기 위해 일단 Repository로 배치했습니다) 해당 부분을 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를 이용해 파일 업로드
}
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [질문] 클래스명과 변수명이 다른건 의도하신건가요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 클래스 이름이 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(); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 공백이 추가되면 가독성이 높아질것 같아요! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저희가 이전에 합의했던 컨벤션 중 "의미없는 빈 줄은 넣지 않는다" 가 있었습니다. 그래서 공백을 추가할까 고민하다가 일단 추가 안했습니다. 해당 메서드의 공백을 어디에 넣어야 가독성이 좋을까요? |
||
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; | ||
|
@@ -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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [수정] 현재 PRESIGNED_URL_DURATION 설정이 CacheSetting의 TTL에 의존하게 바뀌었는데, Duration.ofMinutes(30) 등으로 기준을 고정하고 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저도 애매했는데..., 그냥 이전처럼 |
||
.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) { | ||
|
@@ -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) | ||
|
Uh oh!
There was an error while loading. Please reload this page.