Skip to content

Commit 2e63039

Browse files
authored
feat/#120 -> staging merge commit
* feat: 이미지 조회를 위한 분산락 추가 * feat: redis media 캐시 어댑터 추가 * ref: 메서드 시그니처 변경에 따른 media fake 구현체 메서드 수정 * chore: 사용하지 않는 클래스 제거 * feat: media 조회 usecase 분산락 추가 * feat: redis binary template 추가 * feat: async config 추가 * fix: Redis 분산락 test profile 추가 * fix: media S3 fallback 후 cache 재확인 * fix: RedisMediaBinaryCacheAdapter nullable type 수정 * fix: media 캐시 ttl 만료 전 expire로 갱신하도록 수정 * fix: async 설정 제거 * fix: media type별 캐시 ttl 적용 * fix: 분산락 single-flight 제거
1 parent e755faf commit 2e63039

18 files changed

+1124
-85
lines changed

src/main/kotlin/com/yapp2app/common/config/RedisCacheConfig.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import org.springframework.context.annotation.Configuration
66
import org.springframework.data.redis.connection.RedisConnectionFactory
77
import org.springframework.data.redis.core.RedisTemplate
88
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer
9+
import org.springframework.data.redis.serializer.RedisSerializer
910
import org.springframework.data.redis.serializer.StringRedisSerializer
1011

1112
/**
@@ -48,4 +49,31 @@ class RedisCacheConfig(private val objectMapper: ObjectMapper) {
4849

4950
return template
5051
}
52+
53+
/**
54+
* 이미지 바이너리 데이터 전용 RedisTemplate
55+
* - Key: String 직렬화
56+
* - Value: ByteArray 직렬화 (JSON 직렬화 시 타입 변환 문제 발생)
57+
*
58+
* ByteArrayRedisSerializer 사용으로:
59+
* - 바이너리 데이터를 그대로 저장/조회
60+
* - 타입 변환 없이 ByteArray로 직접 반환
61+
* - 캐시 정상 동작
62+
*/
63+
@Bean
64+
fun binaryRedisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, ByteArray> {
65+
val template = RedisTemplate<String, ByteArray>()
66+
template.connectionFactory = redisConnectionFactory
67+
68+
// Key는 String으로 직렬화
69+
template.keySerializer = StringRedisSerializer()
70+
template.hashKeySerializer = StringRedisSerializer()
71+
72+
// Value는 ByteArray로 직렬화 (이미지 바이너리 데이터)
73+
val byteArraySerializer = RedisSerializer.byteArray()
74+
template.valueSerializer = byteArraySerializer
75+
template.hashValueSerializer = byteArraySerializer
76+
77+
return template
78+
}
5179
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.yapp2app.media.application.port
2+
3+
import java.time.Duration
4+
5+
/**
6+
* 분산 락 포트
7+
*
8+
* 여러 인스턴스 간 동시성 제어를 위한 분산 락 인터페이스입니다.
9+
*/
10+
interface DistributedLockPort {
11+
12+
fun <T> executeWithLock(key: String, ttl: Duration, action: () -> T): T?
13+
}

src/main/kotlin/com/yapp2app/media/application/port/MediaBinaryCachePort.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.yapp2app.media.application.port
22

3+
import java.time.Duration
4+
35
/**
46
* fileName : MediaBinaryCachePort
57
* author : koo
@@ -10,7 +12,7 @@ interface MediaBinaryCachePort {
1012

1113
fun get(key: String): ByteArray?
1214

13-
fun put(key: String, value: ByteArray)
15+
fun put(key: String, value: ByteArray, ttl: Duration)
1416

1517
fun evict(key: String)
1618
}

src/main/kotlin/com/yapp2app/media/application/usecase/GetImageByKeyUseCase.kt

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,81 @@
11
package com.yapp2app.media.application.usecase
22

33
import com.yapp2app.common.annotation.UseCase
4+
import com.yapp2app.common.api.dto.ResultCode
5+
import com.yapp2app.common.exception.BusinessException
46
import com.yapp2app.media.application.command.GetImageByKeyCommand
7+
import com.yapp2app.media.application.port.DistributedLockPort
58
import com.yapp2app.media.application.port.MediaBinaryCachePort
69
import com.yapp2app.media.application.port.MediaStoragePort
710
import com.yapp2app.media.application.result.GetImageByKeyResult
11+
import com.yapp2app.media.domain.MediaType
12+
import org.slf4j.LoggerFactory
13+
import java.time.Duration
814

915
/**
1016
* fileName : GetImageByKeyUseCase
1117
* author : koo
1218
* date : 2026. 1. 21.
1319
* description : object key로 이미지 바이너리 조회 (캐시 우선, cache miss 시 S3 조회)
20+
*
21+
* Cache stampede 방지:
22+
* - Cache hit: 즉시 반환
23+
* - Cache miss: 분산 락으로 중복 S3 호출 방지
24+
* - 캐싱 대상이 아닌 MediaType은 S3 직접 조회
1425
*/
1526
@UseCase
16-
class GetImageByKeyUseCase(private val mediaStorage: MediaStoragePort, private val cache: MediaBinaryCachePort) {
27+
class GetImageByKeyUseCase(
28+
private val mediaStorage: MediaStoragePort,
29+
private val cache: MediaBinaryCachePort,
30+
private val distributedLock: DistributedLockPort,
31+
) {
32+
private val log = LoggerFactory.getLogger(javaClass)
1733

1834
fun execute(command: GetImageByKeyCommand): GetImageByKeyResult {
1935
val objectKey = command.objectKey
2036
val contentType = resolveContentType(objectKey)
37+
val mediaType = MediaType.fromObjectKey(objectKey)
2138

22-
val binaryData = cache.get(objectKey)
23-
?: mediaStorage.fetchBinaryByKey(objectKey).also {
24-
cache.put(objectKey, it)
39+
// 캐싱 대상이 아닌 타입은 S3에서 직접 조회
40+
if (mediaType == null || !mediaType.isCacheable) {
41+
val binaryData = mediaStorage.fetchBinaryByKey(objectKey)
42+
return GetImageByKeyResult(binaryData, contentType)
43+
}
44+
45+
// cache를 조회하고 있다면 바로 반환
46+
val cachedData = cache.get(objectKey)
47+
if (cachedData != null) {
48+
log.debug("[GetImage] Cache hit for key: $objectKey")
49+
return GetImageByKeyResult(cachedData, contentType)
50+
}
51+
52+
// cache가 없다면 lock을 획득하여 S3에서 데이터를 가져오고 캐싱
53+
val cacheTtl = mediaType.cacheTtl!!
54+
val binaryData =
55+
distributedLock.executeWithLock(
56+
key = objectKey,
57+
ttl = Duration.ofSeconds(10),
58+
) {
59+
fetchAndCache(objectKey, cacheTtl)
2560
}
61+
?: cache.get(objectKey) // 락 홀더가 채운 캐시 재확인
62+
?: throw BusinessException(ResultCode.ERROR)
2663

27-
return GetImageByKeyResult(
28-
binaryData = binaryData,
29-
contentType = contentType,
30-
)
64+
return GetImageByKeyResult(binaryData, contentType)
65+
}
66+
67+
private fun fetchAndCache(objectKey: String, cacheTtl: Duration): ByteArray {
68+
// lock 획득 후에도 캐시가 채워졌는지 재확인
69+
cache.get(objectKey)?.let {
70+
log.debug("[GetImage] Cache hit after lock acquisition for key: $objectKey")
71+
return it
72+
}
73+
74+
// S3에서 이미지 바이너리 조회 후 캐싱
75+
log.debug("[GetImage] Fetching from S3 for key: $objectKey")
76+
return mediaStorage.fetchBinaryByKey(objectKey).also {
77+
cache.put(objectKey, it, cacheTtl)
78+
}
3179
}
3280

3381
private fun resolveContentType(objectKey: String): String {

src/main/kotlin/com/yapp2app/media/application/usecase/GetMediasUseCase.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,14 @@ class GetMediasUseCase(
2727
val mediaInfos = medias.map { it ->
2828
val storageKey = it.storageKey
2929

30-
val binaryData = cache.get(storageKey)
31-
?: mediaStorage.fetchBinaryByKey(storageKey).also {
32-
cache.put(storageKey, it)
33-
}
30+
val binaryData = if (it.mediaType.isCacheable) {
31+
cache.get(storageKey)
32+
?: mediaStorage.fetchBinaryByKey(storageKey).also { data ->
33+
cache.put(storageKey, data, it.mediaType.cacheTtl!!)
34+
}
35+
} else {
36+
mediaStorage.fetchBinaryByKey(storageKey)
37+
}
3438

3539
GetMediasResult.MediaInfo(
3640
mediaId = it.id!!,
Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,54 @@
11
package com.yapp2app.media.domain
22

3+
import java.time.Duration
4+
35
/**
46
* fileName : MediaType
57
* author : koo
68
* date : 2025. 12. 19. 오전 2:41
79
* description : 이미지 저장 type
810
*/
9-
enum class MediaType(val prefix: String) {
11+
enum class MediaType(val prefix: String, val cacheTtl: Duration?) {
1012

1113
/**
1214
* 사용자 프로필
1315
*/
14-
USER_PROFILE("user-profiles"),
16+
USER_PROFILE("user-profiles", Duration.ofHours(24)),
1517

1618
/**
1719
* 인생네컷
1820
*/
19-
PHOTO_BOOTH("photo-booth"),
21+
PHOTO_BOOTH("photo-booth", null),
2022

2123
/**
2224
* 확장성을 고려한 첨부 이미지
2325
*/
24-
ATTACHMENT("attachments"),
26+
ATTACHMENT("attachments", null),
2527

2628
/**
2729
* 로고 이미지
2830
*/
29-
LOGO("logo"),
31+
LOGO("logo", Duration.ofHours(24)),
3032

3133
/**
3234
* 포즈 이미지
3335
*/
34-
POSE("pose"),
36+
POSE("pose", Duration.ofHours(24)),
3537

3638
/**
3739
* 업로드 검증, 테스트 등
3840
*/
39-
TEMP("temp"),
41+
TEMP("temp", null),
42+
;
43+
44+
val isCacheable: Boolean get() = cacheTtl != null
45+
46+
companion object {
47+
private val PREFIX_MAP = entries.associateBy { it.prefix }
48+
49+
fun fromObjectKey(objectKey: String): MediaType? {
50+
val prefix = objectKey.substringBefore('/')
51+
return PREFIX_MAP[prefix]
52+
}
53+
}
4054
}

src/main/kotlin/com/yapp2app/media/infra/cache/FakeMediaBinaryCacheAdapter.kt

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.yapp2app.media.infra.cache.fake
2+
3+
import com.yapp2app.media.application.port.MediaBinaryCachePort
4+
import org.springframework.context.annotation.Profile
5+
import org.springframework.stereotype.Component
6+
import java.time.Duration
7+
import java.time.Instant
8+
import java.util.concurrent.ConcurrentHashMap
9+
10+
/**
11+
* fileName : FakeMediaBinaryAdapter
12+
* author : koo
13+
* date : 2026. 1. 8. 오후 4:20
14+
* description : TTL 추적이 가능한 In-Memory 캐시 어댑터 (테스트 환경 전용)
15+
*/
16+
@Component
17+
@Profile("test")
18+
class FakeMediaBinaryCacheAdapter : MediaBinaryCachePort {
19+
20+
private val cache = ConcurrentHashMap<String, CacheEntry>()
21+
22+
data class CacheEntry(val data: ByteArray, val expiresAt: Instant) {
23+
fun isExpired(): Boolean = Instant.now().isAfter(expiresAt)
24+
25+
fun remainingTtl(): Duration =
26+
Duration.between(Instant.now(), expiresAt).takeIf { !it.isNegative } ?: Duration.ZERO
27+
}
28+
29+
override fun get(key: String): ByteArray? {
30+
val entry = cache[key] ?: return null
31+
return if (entry.isExpired()) {
32+
cache.remove(key)
33+
null
34+
} else {
35+
entry.data
36+
}
37+
}
38+
39+
override fun put(key: String, value: ByteArray, ttl: Duration) {
40+
cache[key] = CacheEntry(
41+
data = value,
42+
expiresAt = Instant.now().plus(ttl),
43+
)
44+
}
45+
46+
override fun evict(key: String) {
47+
cache.remove(key)
48+
}
49+
50+
// 테스트용 메서드
51+
fun putWithTtl(key: String, value: ByteArray, ttl: Duration) {
52+
cache[key] = CacheEntry(
53+
data = value,
54+
expiresAt = Instant.now().plus(ttl),
55+
)
56+
}
57+
58+
fun getRemainingTtl(key: String): Duration? = cache[key]?.remainingTtl()
59+
60+
fun clearAll() {
61+
cache.clear()
62+
}
63+
64+
fun size(): Int = cache.size
65+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.yapp2app.media.infra.cache.redis
2+
3+
/**
4+
* Redis 캐시 키 네이밍 컨벤션 관리
5+
*
6+
* Media 도메인의 Redis 키 포맷을 중앙 관리합니다.
7+
* 포맷: media:binary:{objectKey}
8+
*/
9+
internal object MediaRedisCacheKey {
10+
private const val BINARY_PREFIX = "media:binary:"
11+
12+
fun binaryKey(objectKey: String): String = "$BINARY_PREFIX$objectKey"
13+
}

0 commit comments

Comments
 (0)