Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
28 changes: 28 additions & 0 deletions src/main/kotlin/com/yapp2app/common/config/RedisCacheConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer
import org.springframework.data.redis.serializer.RedisSerializer
import org.springframework.data.redis.serializer.StringRedisSerializer

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

return template
}

/**
* 이미지 바이너리 데이터 전용 RedisTemplate
* - Key: String 직렬화
* - Value: ByteArray 직렬화 (JSON 직렬화 시 타입 변환 문제 발생)
*
* ByteArrayRedisSerializer 사용으로:
* - 바이너리 데이터를 그대로 저장/조회
* - 타입 변환 없이 ByteArray로 직접 반환
* - 캐시 정상 동작
*/
@Bean
fun binaryRedisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, ByteArray> {
val template = RedisTemplate<String, ByteArray>()
template.connectionFactory = redisConnectionFactory

// Key는 String으로 직렬화
template.keySerializer = StringRedisSerializer()
template.hashKeySerializer = StringRedisSerializer()

// Value는 ByteArray로 직렬화 (이미지 바이너리 데이터)
val byteArraySerializer = RedisSerializer.byteArray()
template.valueSerializer = byteArraySerializer
template.hashValueSerializer = byteArraySerializer

return template
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.yapp2app.media.application.port

import java.time.Duration

/**
* 분산 락 포트
*
* 여러 인스턴스 간 동시성 제어를 위한 분산 락 인터페이스입니다.
*/
interface DistributedLockPort {

fun <T> executeWithLock(key: String, ttl: Duration, action: () -> T): T?
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.yapp2app.media.application.port

import java.time.Duration

/**
* fileName : MediaBinaryCachePort
* author : koo
Expand All @@ -10,7 +12,7 @@ interface MediaBinaryCachePort {

fun get(key: String): ByteArray?

fun put(key: String, value: ByteArray)
fun put(key: String, value: ByteArray, ttl: Duration)

fun evict(key: String)
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,81 @@
package com.yapp2app.media.application.usecase

import com.yapp2app.common.annotation.UseCase
import com.yapp2app.common.api.dto.ResultCode
import com.yapp2app.common.exception.BusinessException
import com.yapp2app.media.application.command.GetImageByKeyCommand
import com.yapp2app.media.application.port.DistributedLockPort
import com.yapp2app.media.application.port.MediaBinaryCachePort
import com.yapp2app.media.application.port.MediaStoragePort
import com.yapp2app.media.application.result.GetImageByKeyResult
import com.yapp2app.media.domain.MediaType
import org.slf4j.LoggerFactory
import java.time.Duration

/**
* fileName : GetImageByKeyUseCase
* author : koo
* date : 2026. 1. 21.
* description : object key로 이미지 바이너리 조회 (캐시 우선, cache miss 시 S3 조회)
*
* Cache stampede 방지:
* - Cache hit: 즉시 반환
* - Cache miss: 분산 락으로 중복 S3 호출 방지
* - 캐싱 대상이 아닌 MediaType은 S3 직접 조회
*/
@UseCase
class GetImageByKeyUseCase(private val mediaStorage: MediaStoragePort, private val cache: MediaBinaryCachePort) {
class GetImageByKeyUseCase(
private val mediaStorage: MediaStoragePort,
private val cache: MediaBinaryCachePort,
private val distributedLock: DistributedLockPort,
) {
private val log = LoggerFactory.getLogger(javaClass)

fun execute(command: GetImageByKeyCommand): GetImageByKeyResult {
val objectKey = command.objectKey
val contentType = resolveContentType(objectKey)
val mediaType = MediaType.fromObjectKey(objectKey)

val binaryData = cache.get(objectKey)
?: mediaStorage.fetchBinaryByKey(objectKey).also {
cache.put(objectKey, it)
// 캐싱 대상이 아닌 타입은 S3에서 직접 조회
if (mediaType == null || !mediaType.isCacheable) {
val binaryData = mediaStorage.fetchBinaryByKey(objectKey)
return GetImageByKeyResult(binaryData, contentType)
}

// cache를 조회하고 있다면 바로 반환
val cachedData = cache.get(objectKey)
if (cachedData != null) {
log.debug("[GetImage] Cache hit for key: $objectKey")
return GetImageByKeyResult(cachedData, contentType)
}

// cache가 없다면 lock을 획득하여 S3에서 데이터를 가져오고 캐싱
val cacheTtl = mediaType.cacheTtl!!
val binaryData =
distributedLock.executeWithLock(
key = objectKey,
ttl = Duration.ofSeconds(10),
) {
fetchAndCache(objectKey, cacheTtl)
}
?: cache.get(objectKey) // 락 홀더가 채운 캐시 재확인
?: throw BusinessException(ResultCode.ERROR)

return GetImageByKeyResult(
binaryData = binaryData,
contentType = contentType,
)
return GetImageByKeyResult(binaryData, contentType)
}

private fun fetchAndCache(objectKey: String, cacheTtl: Duration): ByteArray {
// lock 획득 후에도 캐시가 채워졌는지 재확인
cache.get(objectKey)?.let {
log.debug("[GetImage] Cache hit after lock acquisition for key: $objectKey")
return it
}

// S3에서 이미지 바이너리 조회 후 캐싱
log.debug("[GetImage] Fetching from S3 for key: $objectKey")
return mediaStorage.fetchBinaryByKey(objectKey).also {
cache.put(objectKey, it, cacheTtl)
}
}

private fun resolveContentType(objectKey: String): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ class GetMediasUseCase(
val mediaInfos = medias.map { it ->
val storageKey = it.storageKey

val binaryData = cache.get(storageKey)
?: mediaStorage.fetchBinaryByKey(storageKey).also {
cache.put(storageKey, it)
}
val binaryData = if (it.mediaType.isCacheable) {
cache.get(storageKey)
?: mediaStorage.fetchBinaryByKey(storageKey).also { data ->
cache.put(storageKey, data, it.mediaType.cacheTtl!!)
}
} else {
mediaStorage.fetchBinaryByKey(storageKey)
}

GetMediasResult.MediaInfo(
mediaId = it.id!!,
Expand Down
28 changes: 21 additions & 7 deletions src/main/kotlin/com/yapp2app/media/domain/MediaType.kt
Original file line number Diff line number Diff line change
@@ -1,40 +1,54 @@
package com.yapp2app.media.domain

import java.time.Duration

/**
* fileName : MediaType
* author : koo
* date : 2025. 12. 19. 오전 2:41
* description : 이미지 저장 type
*/
enum class MediaType(val prefix: String) {
enum class MediaType(val prefix: String, val cacheTtl: Duration?) {

/**
* 사용자 프로필
*/
USER_PROFILE("user-profiles"),
USER_PROFILE("user-profiles", Duration.ofHours(24)),

/**
* 인생네컷
*/
PHOTO_BOOTH("photo-booth"),
PHOTO_BOOTH("photo-booth", null),

/**
* 확장성을 고려한 첨부 이미지
*/
ATTACHMENT("attachments"),
ATTACHMENT("attachments", null),

/**
* 로고 이미지
*/
LOGO("logo"),
LOGO("logo", Duration.ofHours(24)),

/**
* 포즈 이미지
*/
POSE("pose"),
POSE("pose", Duration.ofHours(24)),

/**
* 업로드 검증, 테스트 등
*/
TEMP("temp"),
TEMP("temp", null),
;

val isCacheable: Boolean get() = cacheTtl != null

companion object {
private val PREFIX_MAP = entries.associateBy { it.prefix }

fun fromObjectKey(objectKey: String): MediaType? {
val prefix = objectKey.substringBefore('/')
return PREFIX_MAP[prefix]
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.yapp2app.media.infra.cache.fake

import com.yapp2app.media.application.port.MediaBinaryCachePort
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import java.time.Duration
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap

/**
* fileName : FakeMediaBinaryAdapter
* author : koo
* date : 2026. 1. 8. 오후 4:20
* description : TTL 추적이 가능한 In-Memory 캐시 어댑터 (테스트 환경 전용)
*/
@Component
@Profile("test")
class FakeMediaBinaryCacheAdapter : MediaBinaryCachePort {

private val cache = ConcurrentHashMap<String, CacheEntry>()

data class CacheEntry(val data: ByteArray, val expiresAt: Instant) {
fun isExpired(): Boolean = Instant.now().isAfter(expiresAt)

fun remainingTtl(): Duration =
Duration.between(Instant.now(), expiresAt).takeIf { !it.isNegative } ?: Duration.ZERO
}

override fun get(key: String): ByteArray? {
val entry = cache[key] ?: return null
return if (entry.isExpired()) {
cache.remove(key)
null
} else {
entry.data
}
}

override fun put(key: String, value: ByteArray, ttl: Duration) {
cache[key] = CacheEntry(
data = value,
expiresAt = Instant.now().plus(ttl),
)
}

override fun evict(key: String) {
cache.remove(key)
}

// 테스트용 메서드
fun putWithTtl(key: String, value: ByteArray, ttl: Duration) {
cache[key] = CacheEntry(
data = value,
expiresAt = Instant.now().plus(ttl),
)
}

fun getRemainingTtl(key: String): Duration? = cache[key]?.remainingTtl()

fun clearAll() {
cache.clear()
}

fun size(): Int = cache.size
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.yapp2app.media.infra.cache.redis

/**
* Redis 캐시 키 네이밍 컨벤션 관리
*
* Media 도메인의 Redis 키 포맷을 중앙 관리합니다.
* 포맷: media:binary:{objectKey}
*/
internal object MediaRedisCacheKey {
private const val BINARY_PREFIX = "media:binary:"

fun binaryKey(objectKey: String): String = "$BINARY_PREFIX$objectKey"
}
Loading