안녕하세요. 올리브영 전시 영역 백엔드 개발을 담당하고 있는 robi입니다.
오래 기다리셨죠? 작년 11월 28일에 작성된 🎁 올리브영은 왜 선물하기를 개편했을까? Part - 1의 후속편이 드디어 작성되었습니다.
1부에서 말씀드렸던 대로 2부에서는 개편 과정의 세부 기술과 구현 과정을 더 자세히 다뤄볼 예정인데요.
여러 주제 중, 가장 중요했던 "캐싱"에 관해 여러분에게 소개해 드리고자 합니다.
🔑 What is 캐싱
캐싱(Caching)은 자주 사용하는 데이터를 빠른 저장소에 미리 저장해두어, 같은 요청이 들어올 때 더 빠르게 응답할 수 있도록 돕는 기술입니다.
'숨겨진 보물창고'라는 'Cache'의 본래 의미처럼, 개발에서도 자주 쓰는 데이터를 미리 숨겨두고 꺼내 쓰면 시스템이 훨씬 민첩해질 수 있는데요.
이러한 캐싱 기술은 비단 웹뿐만 아니라 하드웨어, CPU, 데이터베이스, DNS 등 다양한 분야에서 널리 활용됩니다.
특히 실시간 트래픽이 많은 전시 영역에서는 DB나 외부 API 호출 같은 느린 작업의 결과를 캐시에 저장해두어, 약간의 정합성을 포기하더라도 빠르게 응답할 수 있도록 사용합니다.
🫒 올리브영의 캐싱
전시 서비스에서는 캐싱을 위한 NoSQL로 주로 레디스(Redis)를 사용하며 장애 확산을 방지하고 시스템을 보호하기 위해 서킷 브레이커로 Resilience4j를 함께 사용 중인데, 이를 위해 작성된 Spring Boot에서의 Kotlin 코드는 아래와 같았습니다.
⚠️ 이하 모든 코드는 예시로 작성되었음을 알려드립니다.
@CircuitBreaker(
name = "commonCircuitBreaker",
fallbackMethod = "fallbackPagedFooData"
)
@Cacheable(
cacheManager = "displayCacheManager",
value = "display:foo",
key = "{#pageNumber}",
unless = "#result == null or #result.isEmpty()"
)
fun fetchPagedFooData(pageNumber: Long): List<FooDisplayResponseDto> {
// ➀ HttpAPI호출, DB조회, 비즈니스 로직 ...
}
private fun fallbackPagedFooData(): List<FooDisplayResponseDto> {
// ➁ fallback 로직 ...
}
- Case 1 : 'commonCircuitBreaker'가 닫혀(CLOSED) 있는 경우 'displayCacheManager'로부터 데이터 획득 시도
- 레디스에서 'display:foo:{#pageNumber}'에 해당하는 값 획득
- 값을 획득하지 못했다면 함수 구현체인 ➀을 수행
- ➀의 수행 결과가 null이 아니고 'isEmpty() == false'라면 레디스에 저장
- 수행 결과를 반환
- Case 2 : 'commonCircuitBreaker'가 열려(OPENED) 있거나 허용되지 않은 예외가 발생한 경우 'fallbackPagedFooData'함수를 수행
- 오류 상황을 로깅
- ➁를 통해 DB 데이터를 조회하여 반환하거나, emptyList()를 반환
위 코드로 서비스를 개발하는 데 큰 문제는 없었지만, 캐시를 많이 사용하는 선물하기 영역에서는 새로운 기능을 추가할 때마다 아래와 같은 단점을 느꼈습니다.
- 써킷과 캐시 적용을 위한 2개의 어노테이션의 개별 사용으로 인한 불편함
- 명확한 캐시 key값과 TTL등 설정을 빠르게 확인하기 어려움
- 레디스의 hash타입 데이터를 사용할 수 없음 ❌
- @CircuitBreaker의 fallbackMethod속성을 문자열로 지정하여 IDE도움을 받을 수 없음에 의한 실수 유발 가능성 ⬆️
- @Cacheable의 key, unless속성에 SpEL(Spring Expression Language)사용으로 인한 실수 유발 가능성 ⬆️
따라서, 저희는 이러한 불편함을 개선해보고자 별도의 캐시 모듈을 만들었고, 이를 Spring의 AoP에 묶는 작업도 함께 진행했습니다.
🛠️ 개선된 캐시 모듈
설계에 앞서 저희는 캐시를 가장 효율적으로 활용할 방안을 고민했으며, 다음과 같은 요구사항을 바탕으로 논의를 시작했습니다.
- 레디스 연동: 캐시는 레디스에 적재하고, 필요할 때 불러올 수 있어야 합니다.
- 예시: 선물하기 서비스의 전시 데이터 적재 구조
- 동적 캐시 키: 캐시 키는 문자열로 설정하며, 함수의 매개변수(parameter)를 키 값에 동적으로 포함할 수 있어야 합니다.
- TTL 설정: 캐시의 유효 시간(TTL, Time-To-Live)을 설정할 수 있어야 합니다.
- 일자별 버전 설정: 일자별 캐시 버전을 키 값을 통해 표현할 수 있어야 합니다.
- 다양한 자료구조 지원: 레디스의 string(value) 및 hash타입을 모두 지원해야 합니다.
- 실패 처리 옵션: 캐시 조회에 실패했을 때, 예외(Exception)를 발생시키거나 대체 로직(Fallback)을 수행하는 것 중 선택할 수 있어야 합니다.
- Fallback 결과 처리: 대체 로직(Fallback)이 수행된 후, 그 결과를 캐시에 다시 적재할지 여부를 선택할 수 있어야 합니다.
- 기존 방식의 단점 개선: 앞서 언급했던 캐시 활용 방식이 가진 문제점들을 개선해야 합니다.
위 요구사항들을 바탕으로 논의한 끝에, 저희는 아래와 같은 구조로 모듈을 호출하는 @DisplayCaching 어노테이션을 제공하기로 결정했습니다.
❗️1부에서 언급됐던 @GiftCaching, @GiftCachingKey가 각각 @DisplayCaching, @DisplayCachingKey로 이름이 변경되었습니다.
// @CircuitBreaker + @Cacheable 어노테이션의 기능을 대체하는 @DisplayCaching
@DisplayCaching( ··· ①
displayCacheInfo = DisplayCacheInfo.FOO_PAGED, ··· ②
dateTimeKeySuffix = DateTimeKeySuffixType.LOCAL_DATE, ··· ③
throwWhenReadFail = false, ··· ④
putDataAfterProceed = true ··· ⑤
)
fun fetchPagedFooData(
@DisplayCachingKey pageNumber: Long ··· ⑥
): List<FooDisplayResponseDto> {
// HttpAPI호출, DB조회, 비즈니스 로직
}
-
① @DisplayCaching : 캐싱을 적용할 함수 위에 어노테이션을 선언하고, 적용할 캐시 속성을 지정합니다.
-
DisplayCaching.kt (클릭하여 펼치기)
@Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class DisplayCaching( val displayCacheInfo: DisplayCacheInfo, val dateTimeKeySuffix: DateTimeKeySuffixType = DateTimeKeySuffixType.NOT_USE, val throwWhenReadFail: Boolean = false, val putDataAfterProceed: Boolean = true )
-
-
② displayCacheInfo : 열거형 상수를 지정합니다. 여기에는 캐시명, TTL, 캐시 타입이 정의되어 있습니다.
-
DisplayCacheInfo.kt
enum class DisplayCacheInfo( val key: String, val ttl: Long, val redisDataType: RedisDataType = RedisDataType.VALUE ) { FOO_PAGED( key = "display:foo", // 레디스 키값으로 사용 ttl = 900L, // 15분 redisDataType = RedisDataType.VALUE ), }
-
-
③ dateTimeKeySuffix : 열거형 상수를 지정합니다. 값에 따라 Key의 마지막에 붙을 접미사(suffix)를 현재 시간을 기준으로 생성합니다. (-yyyyMMdd)
-
DateTimeKeySuffixType.kt
interface DateTimeKeySuffix { fun getSuffixString(currentTime: LocalDateTime): String } enum class DateTimeKeySuffixType : DateTimeSuffix { NOT_USE { override fun getSuffixString(currentTime: LocalDateTime): String = "" }, LOCAL_DATE { override fun getSuffixString(currentTime: LocalDateTime): String = String.format("%04d%02d%02d", currentTime.year, currentTime.month.value, currentTime.dayOfMonth) // yyyyMMdd } }
-
-
④ throwWhenReadFail : 값이 true인 경우 캐시 읽기 작업이 실패하면 함수 구현부(fallback)을 수행하지 않고 예외를 던집니다.
-
⑤ putDataAfterProceed : 값이 true인 경우 fallback을 수행한 결과를 캐시로 저장합니다.
-
⑥ @DisplayCachingKey : 캐시 키 값에 사용할 파라미터 앞에 어노테이션을 선언하면 키를 생성할 때 해당 파라미터를 활용합니다. 또한, forHashKey = true로 설정한다면 해당 파라미터는 레디스의 hash타입 데이터의 해시 키 값으로 사용할 수 있습니다.
(위 예제의 경우 pageNumber에 1L이 전달된 경우 최종적으로 생성되는 키는 "display:foo:1")-
DisplayCachingKey.kt
@Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) annotation class DisplayCachingKey( val forHashKey: Boolean = false, // true인 경우 RedisDataType.HASH의 HashKey로 사용 ) { companion object { fun convertParamValueToKeyString(value: Any?): String { return when (value) { null -> "null" is String -> value is LocalDateTime -> String.format( "%04d%02d%02d%02d%02d%02d", value.year, value.month.value, value.dayOfMonth, value.hour, value.minute, value.second ) is LocalDate -> String.format("%04d%02d%02d", value.year, value.month.value, value.dayOfMonth) else -> value.toString() } } } }
-
그리고, 어노테이션 속성값에 따라 캐시 모듈이 어떻게 분기 처리되는지를 Excalidraw로 도식화했습니다.
이러한 설계를 바탕으로 DisplayCacheModule을 구현했으며, 그 구조는 다음과 같습니다.
DisplayCacheModule.kt
class DisplayCacheModule(
private val circuitBreaker: CircuitBreaker,
private val redisTemplate: RedisTemplate<String, Any?>,
private val objectMapper: ObjectMapper
) {
private val logger = LoggerFactory.getLogger(DisplayCacheModule::class.java)
companion object {
private val SAFE_NULL_OBJECT = Object()
/**
* 파라미터 기반의 캐시 키를 생성
*/
fun makeDefaultCacheKey(
baseCacheKey: String,
paramValuesForKey: List<String> = emptyList(),
dateTimeSuffixType: DateTimeKeySuffixType = DateTimeKeySuffixType.NOT_USE,
): String {
// paramValuesForKey 처리
val parameterCacheKey = paramValuesForKey.joinToString(separator = "") { it }
.let { if (it.isNotBlank()) ":$it" else "" } // ":xyz" or ""
// dateTimeKeySuffix 처리
val dateTimeKeySuffix = if (dateTimeSuffixType != DateTimeKeySuffixType.NOT_USE) {
dateTimeSuffixType.getSuffixString(LocalDateTime.now())
.let { if (it.isNotBlank()) "-$it" else "" } // "-yyyyMMdd" or ""
} else ""
// 최종적으로 base, parameterCacheKey, dateTimeKeySuffix를 조립하여 반환
return baseCacheKey + parameterCacheKey + dateTimeKeySuffix
}
}
/**
* 캐시를 읽어옵니다. 캐시를 획득하지 못한 경우 함수 본체를 수행한 결과를 캐싱 후 반환합니다.
* @param redisDataType 레디스 데이터 타입
* @param cacheKey 캐시 키
* @param hashKey 해시 키 ([RedisDataType.HASH]의 경우 사용)
* @param ttlBySeconds 데이터의 TTL(초)
* @param putDataAfterProceed fallback로직 수행 후 데이터 적재 수행 여부
* @param throwWhenReadFail 읽기작업 실패 시 예외 던지기 여부
* @param fallbackFunction 읽기작업 실패 시 수행할 fallback 함수
* @param fallbackReturnType fallback함수의 반환 자료형
*/
fun fetchAndPut(
redisDataType: RedisDataType,
cacheKey: String,
hashKey: String,
ttlBySeconds: Long,
putDataAfterProceed: Boolean = true,
throwWhenReadFail: Boolean = false,
fallbackFunction: Any?.() -> Any?,
fallbackReturnType: Class<*>
): Any? {
// 2.레디스로부터 데이터 읽기 시도
val fetchResult = tryReadFromRedis(
redisDataType = redisDataType,
cacheKey = cacheKey,
hashKey = hashKey,
// 3-3.throwWhenReadFail가 true면 데이터 읽기 실패 예외를 던지고 종료
throwWhenReadFail = throwWhenReadFail,
returnType = fallbackReturnType
)
// 3-1.읽기 성공 시 데이터 반환
if (fetchResult !== SAFE_NULL_OBJECT) {
return fetchResult
}
// 3-4.데이터 획득을 위한 fallback함수 수행
val fallbackResult = fallbackFunction()
// 4-2.fallback함수 수행 결과를 반환
if (!putDataAfterProceed || isNullOrEmptyData(fallbackResult)) {
return fallbackResult
}
// 4-1.레디스에 데이터 적재 시도
tryWriteIntoRedis(
redisDataType = redisDataType,
cacheKey = cacheKey,
hashKey = hashKey,
ttlBySeconds = ttlBySeconds,
writeData = fallbackResult!!, // isNullOrEmptyData(..)에서 null 검증을 수행
)
// 7.적재 후 fallback함수 수행 결과를 반환
return fallbackResult
}
/**
* 빈 데이터인지 검증합니다.
*/
private fun isNullOrEmptyData(data: Any?): Boolean {
return when (data) {
null -> true
is Collection<*> -> data.size == 0
is Map<*, *> -> data.size == 0
else -> data.toString().isBlank()
}
}
/**
* Resilience4j적용, 레디스에서 데이터를 읽어옵니다.
* 데이터 읽기에 실패하거나 데이터가 없는 경우(null) [SAFE_NULL_OBJECT]를 반환합니다.
*/
private fun tryReadFromRedis(
redisDataType: RedisDataType,
cacheKey: String,
hashKey: String,
throwWhenReadFail: Boolean,
returnType: Class<*>
): Any { /* 생략 ... */ }
/**
* Resilience4j적용, 레디스에 데이터를 쓰고 예외 발생 시 핸들링을 수행합니다.
*/
private fun tryWriteIntoRedis(
redisDataType: RedisDataType,
cacheKey: String,
hashKey: String,
ttlBySeconds: Long,
writeData: Any
): Boolean { /* 생략 ... */ }
}
그리고 Spring AoP를 활용하여 함수 실행 시 캐시 모듈을 호출하는 DisplayCachingAspect는 아래와 같이 작성되었습니다.
DisplayCachingAspect.kt
@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
class DisplayCachingAspect(
private val circuitBreaker: CircuitBreaker,
private val redisTemplate: RedisTemplate<String, Any?>,
private val objectMapper: ObjectMapper,
) {
/**
* joinPoint와 method로부터 @DisplayCachingKey가 붙은 모든 매개변수들을 추출하여 키 값을 생성합니다.
*/
private fun makeCacheKey(joinPoint: ProceedingJoinPoint, displayCaching: DisplayCaching, method: Method): String {
// @DisplayCachingParamKey가 있는 파라미터 획득
val annotatedParamAndValues = method.parameters
.zip(joinPoint.args) // Pair(first: Parameter, second: Actual value)
.filter { (param, _) ->
param.isAnnotationPresent(DisplayCachingKey::class.java) &&
!param.getAnnotation(DisplayCachingKey::class.java).forHashKey
}
// 파라미터 접두사 및 값 변환 일괄 처리
val prefixProcessedParamValue = annotatedParamAndValues.map {
val param = it.first
val annotation = param.getAnnotation(DisplayCachingKey::class.java)
val prefix = annotation.prefix
val convertedValue = DisplayCachingKey.convertParamValueToKeyString(it.second)
prefix + convertedValue
}
// 캐시 키 생성
return DisplayCacheModule.makeDefaultCacheKey(
baseCacheKey = displayCaching.displayCacheInfo.key,
paramValuesForKey = prefixProcessedParamValue,
dateTimeSuffixType = displayCaching.dateTimeKeySuffix
)
}
/**
* joinPoint와 method로부터 @DisplayCachingKey(forHash = true)인 매개변수를 추출하여
* 레디스 해시에 사용할 키 값을 생성합니다.
*/
private fun readHashKeyOrThrow(
joinPoint: ProceedingJoinPoint,
method: Method,
cacheKey: String
): String {
try {
// @DisplayCachingParamKey가 있는 파라미터중 forHashKey = true인 파라미터 획득
val hashKeyAnnotatedParam = method.parameters
.zip(joinPoint.args)
.first { (param, _) ->
param.isAnnotationPresent(DisplayCachingKey::class.java) &&
param.getAnnotation(DisplayCachingKey::class.java).forHashKey
}
val annotatedValue = hashKeyAnnotatedParam.second
?: throw IllegalArgumentException(
"'@${DisplayCachingKey::class.simpleName}.forHashKey = true' parameter value should not be null.")
// 해시 키 생성 및 반환
val hashKey = DisplayCachingKey.convertParamValueToKeyString(annotatedValue)
if (hashKey.isBlank()) {
throw IllegalArgumentException(
"'@${DisplayCachingKey::class.simpleName}.forHashKey = true' parameter value should not be blank.")
}
return hashKey
} catch (e: Exception) {
val exceptionMessage = "Fail to proper '@${DisplayCachingKey::class.simpleName}.forHashKey=true' " +
"annotated parameter or value from the '${cacheKey}'. Please check annotation or actual value."
throw IllegalStateException(exceptionMessage, e)
}
}
/**
* [@DisplayCaching]어노테이션의 around로 동작합니다.
*/
@Around("@annotation(display.module.cache.annotation.DisplayCaching)")
@Throws(Throwable::class)
fun aroundDisplayCaching(joinPoint: ProceedingJoinPoint): Any? {
// 캐시 모듈을 호출하기 위한 전처리: 리플랙션을 통한 fallback함수 속성, 캐시 키 생성, 어노테이션 속성 추출 등 수행
val method = (joinPoint.signature as MethodSignature).method
val annotation = method.getAnnotation(DisplayCaching::class.java)
val cacheKey = makeCacheKey(joinPoint, annotation, method)
val hashKey = when (annotation.displayCacheInfo.redisDataType) {
RedisDataType.HASH -> readHashKeyOrThrow(joinPoint, method, cacheKey)
else -> "" // hashKey 는 RedisDataType.HASH일때만 사용
}
// 캐시 모듈 호출
return DisplayCacheModule(
circuitBreaker = circuitBreaker,
redisTemplate = redisTemplate,
objectMapper = objectMapper
).fetchAndPut(
redisDataType = annotation.displayCacheInfo.redisDataType,
cacheKey = cacheKey,
hashKey = hashKey,
ttlBySeconds = annotation.displayCacheInfo.ttl,
putDataAfterProceed = annotation.putDataAfterProceed,
throwWhenReadFail = annotation.throwWhenReadFail,
fallbackFunction = { joinPoint.proceed(joinPoint.args) },
fallbackReturnType = method.returnType
)
}
}
최종적으로는 아래와 같은 모습으로 패키지가 구성되었습니다.
📂 display
├── 📂 aop
│ └── 📄 DisplayCachingAspect.kt ① Spring AoP 기능을 활용하기 위한 클래스
├── 📂 config
│ └── 📄 DisplayCacheInfo.kt ② 전시영역에서 사용하는 캐시 정보를 담은 열거형(enum) 클래스
└── 📂 module
└── 📂 cache
├── 📂 annotation
│ ├── 📄 DisplayCaching.kt ③ @DisplayCaching 어노테이션 클래스
│ └── 📄 DisplayCachingKey.kt ④ @DisplayCachingKey 어노테이션 클래스
├── 📄 DateTimeKeySuffixType.kt ⑤ 일자 접미사로 키 버전을 구현하기 위한 정보를 담은 열거형 클래스
├── 📄 DisplayCacheModule.kt ⑥ 실질적인 캐시 모듈의 기능을 담당하는 클래스
└── 📄 RedisDataType.kt ⑦ 지원하는 레디스 데이터 종류를 표현하기 위한 열거형 클래스
자체적으로 구현한 커스텀 캐시 모듈 덕분에, 앞서 언급했던 다섯 가지 문제점을 모두 해결할 수 있었습니다.
하지만 프로젝트가 성공적으로 마무리될 무렵, 저희는 예상치 못한 큰 난관에 봉착했습니다.
⚡️ 숨은 문제점: 캐시 스탬피드
캐시 모듈에 숨어있던 문제는 서비스 출시를 앞두고 진행한 부하 테스트 과정에서 발견되었습니다.
특정 캐시가 만료되고 재생성되는 과정에서 서비스 전반의 성능이 저하되는 현상이 발생한 것인데요.
면밀한 디버깅 끝에 저희는 그 원인이 바로 캐시 스탬피드(Cache Stampede) 현상임을 파악했습니다.
📒 캐시 스탬피드 : 캐시 스탬피드란 캐시 데이터의 유효 기간(TTL)이 만료되는 순간, 수많은 동시 요청이 캐시에서 데이터를 찾지 못하고 한꺼번에 백엔드 데이터베이스(DB)로 몰려들어 과도한 부하를 일으키는 현상을 말합니다.
아래 그림처럼, 평소에는 대부분의 트래픽이 Redis와 같은 NoSQL 캐시에서 처리되어 DB는 안정적인 상태를 유지합니다.
- ✅ 일반 케이스 : 트래픽이 NoSQL(Redis)로 몰려 DB 부하 없음
하지만 캐시 스탬피드가 발생하면 상황은 달라집니다. 캐시 데이터가 만료되는 짧은 순간, 마치 댐이 터지듯 엄청난 수의 요청이 캐시를 뚫고 DB로 쏟아져 들어옵니다.
- ❌ 캐시 스탬피드 발생 케이스 : 캐시 데이터의 TTL 만료 순간, 수많은 요청이 동시에 DB로 폭주
이 문제는 특히 선물하기 기능의 랭킹 데이터 조회와 같이 무거운 쿼리를 수행하는 곳에서 치명적이었습니다.
평소에도 DB에서 데이터를 가져오는 데 시간이 오래 걸리던 쿼리였기에, 캐시 만료 시점에 요청이 급증하자 서비스 전체의 응답 속도가 현저히 느려지고 장애 발생 위험까지 높아지는 상황에 직면했습니다.
이러한 캐시 스탬피드(Cache Stampede) 문제를 해결하기 위해 저희는 여러 대안을 검토했고, 다음과 같은 세 가지 접근법을 찾을 수 있었습니다.
- 분산 락 (Distributed Lock)
- 캐시가 만료되었을 때, 락(Lock)을 획득한 하나의 요청만 캐시를 재생성하도록 허용하고 락을 얻지 못한 나머지 요청들은 캐시가 재생성될 때까지 기다림
- 👍 : DB를 확실하게 보호 가능
- 👎 : 락을 획득하지 못한 요청들의 지연 발생 및 락 관리의 복잡함 발생
- 확률적 조기 갱신 (PER: Probabilistic Early Recomputation)
- 캐시의 TTL이 만료되기 전 확률적으로 fallback을 호출하여 캐시 갱신을 수행, TTL이 만료에 가까워질수록 fallback 수행 확률을 높임
- 👍 : 비동기 처리를 활용하면 별도의 지연시간 발생 없음
- 👎 : 낮은 확률이지만 DB에 여러번의 요청이 발생할 수 있음
- 백그라운드 업데이트
- 배치 등을 활용하여 데이터를 주기적으로 미리 생성
- 👍 : DB를 확실하게 보호 가능
- 👎 : 별도의 배치 서비스 개발 및 운영의 필요성과 실시간 데이터 제공에 제약 발생
프로젝트 마감 기간이 얼마 남지 않아있던 저희는 남아있던 개발 기간과 위험성을 고려하여 분산 락을 이용한 방식으로 문제를 해결하기로 결정하여 설계를 시작했고, 위험 부담이 큰 DB에 락을 거는 방식보다 레디스를 활용하여 락을 거는 방식으로 접근했습니다.
레디스 캐시에 접근했을 때 "display:foo:1"키의 값이 존재하지 않고, 락 옵션을 사용하는 경우 "display:foo:1.lock"을 키로 갖는 데이터를 10초의 TTL로 생성하여 락을 획득한 것을 표현했습니다. (TTL은 프로세스가 비정상 종료되어 영원히 락 해제를 할 수 없는 상황에 대비하기 위해 결정)
이어서, ".lock"데이터 생성에 실패한 경우, 매 초마다 "display:foo:1" 데이터가 생성되었는지 확인하도록 로직을 변경했습니다.
이러한 설계를 바탕으로 DisplayCacheModule에 기능을 추가했으며 코드는 다음과 같이 작성되었습니다.
DisplayCacheModule.kt (Fallback Lock 로직 추가)
/**
* fallback 기능을 분산락을 사용하여 한 번만 수행할 때,
* 락의 수명과 다시 읽는 주기 등을 설정하기 위한 열거형 클래스
*/
enum class FallbackLockOption(
val maxLockDurationMs: Long, // 락 수명(밀리초)
val sleepMsBetweenObtainLock: Long // 읽기/락 획득 주기(밀리초)
) {
// 락 미사용
DISABLED(0L, 0L),
// 기본 락 (최대 10초간 락을 걸고, 1초마다 락 해제/데이터 적재여부를 확인)
LOCK_10S_CHECK_EVERY_1S(10000L, 1000L);
}
class DisplayCacheModule(
private val circuitBreaker: CircuitBreaker,
private val redisTemplate: RedisTemplate<String, Any?>,
private val objectMapper: ObjectMapper
) {
companion object {
// 이전 코드 생략 ...
/**
* Resilience4j적용, 레디스에서 데이터를 읽어오고 예외 발생 시 핸들링 수행합니다.
* 추가로, 데이터 획득 실패 시 락 획득을 시도하고 획득에 성공한 경우 '[isCurrentThreadHasFallbackLock] = true'로 플래그를 설정합니다.
*/
private fun tryReadFromRedisWithObtainLock(
redisDataType: RedisDataType,
cacheKey: String,
hashKey: String,
throwWhenReadFail: Boolean = false,
fallbackReturnType: Class<*>,
fallbackLockOption: FallbackLockOption = FallbackLockOption.DISABLED,
isCurrentThreadHasFallbackLock: AtomicBoolean
): Any {
val isFallbackLockEnabled = (fallbackLockOption != FallbackLockOption.DISABLED)
val readRetryCount =
if (isFallbackLockEnabled) (fallbackLockOption.maxLockDurationMs / fallbackLockOption.sleepMsBetweenObtainLock).toInt()
else 1
repeat(readRetryCount) {
try {
// 1.레디스로부터 데이터 읽기 시도
val readData = circuitBreaker.executeSupplier {
tryReadFromRedis(
redisDataType = redisDataType,
cacheKey = cacheKey,
hashKey = hashKey,
throwWhenReadFail = throwWhenReadFail,
returnType = fallbackReturnType
)
}
// 2-1.읽기 성공 시 반환
if (readData !== SAFE_NULL_OBJECT) {
return readData
}
} catch (e: Exception) {
logger.warn("No data '${cacheKey}' in redis. (readRetryCount: ${readRetryCount})")
if (throwWhenReadFail) throw e
}
// 2-2.읽기 실패 시
if (throwWhenReadFail) {
throw NoSuchElementException("No such data '${cacheKey}' in redis.")
}
// 3.락 획득 시도
if (isFallbackLockEnabled) {
if (tryObtainRedisLock(
cacheKey = cacheKey,
fallbackLockOption = fallbackLockOption,
isCurrentThreadHasFallbackLock = isCurrentThreadHasFallbackLock)
) {
return@repeat
}
// 락 획득 실패 시 (1)부터 다시 시작
}
}
// 레디스에 데이터가 없고, 락 획득도 실패한 경우 도달
return SAFE_NULL_OBJECT
}
/**
* Redis에 .lock 데이터를 생성하여 락 획득을 시도합니다.
*/
private fun tryObtainRedisLock(
cacheKey: String,
fallbackLockOption: FallbackLockOption,
isCurrentThreadHasFallbackLock: AtomicBoolean
): Boolean {
val lockKey = cacheKey + ".lock"
val lockValue = "true"
try {
val lockObtained = circuitBreaker.executeSupplier {
redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, fallbackLockOption.maxLockDurationMs, TimeUnit.MILLISECONDS)
}
if (lockObtained) {
// 4-1.락 획득 성공
isCurrentThreadHasFallbackLock.set(true)
return true
} else {
// 4-2.락 획득 실패
// 과부하 방지를 위해 일정 시간만큼(최소 1ms) 대기 후 레디스 읽기 재시도
// 대기 후 다음 싸이클에 데이터 읽기가 시도됐을 때,
// 다른 컨테이너/스레드에서 fallback수행 결과를 적재했다면 여기에 다시 진입하지 않음
Thread.sleep(fallbackLockOption.sleepMsBetweenObtainLock.coerceAtLeast(1L))
return false
}
} catch (e: Exception) {
logger.warn("Fail to obtain lock '${lockKey}' from redis. (${e.message})")
return false
}
}
/**
* Resilience4j적용, 레디스에 데이터를 쓰고 예외 발생 시 핸들링을 수행합니다.
* 추가로, 레디스에 생성되어있던 .lock 데이터의 삭제를 시도합니다.
*/
private fun tryWriteIntoRedisWithReleaseLock(
redisDataType: RedisDataType,
cacheKey: String,
hashKey: String,
ttlBySeconds: Long,
writeData: Any,
isCurrentThreadHasFallbackLock: AtomicBoolean
) {
var isWriteSuccess = false
try {
// 5.레디스에 데이터 적재 시
isWriteSuccess = tryWriteIntoRedis(
redisDataType = redisDataType,
cacheKey = cacheKey,
hashKey = hashKey,
ttlBySeconds = ttlBySeconds,
writeData = writeData
)
} catch (e: Exception) {
logger.warn("Fail to write data '${cacheKey}' into redis.", e)
} finally {
// 6.락 해제
if (isWriteSuccess && isCurrentThreadHasFallbackLock.get()) {
tryReleaseRedisLock(cacheKey = cacheKey)
}
}
}
}
}
이에 따라, DisplayCacheModule의 fetchAndPut(..)함수도 약간의 변화가 생겼습니다.
DisplayCacheModule.kt - fetchAndPut(..) (Fallback Lock 로직 추가)
/**
* 캐시를 읽어옵니다. 캐시를 획득하지 못한 경우 함수 본체를 수행한 결과를 캐싱 후 반환합니다.
* @param redisDataType 레디스 데이터 타입
* @param cacheKey 캐시 키
* @param hashKey 해시 키 ([RedisDataType.HASH]의 경우 사용)
* @param ttlBySeconds 데이터의 TTL(초)
* @param putDataAfterProceed fallback로직 수행 후 데이터 적재 수행 여부
* @param throwWhenReadFail 읽기작업 실패 시 예외 던지기 여부
* @param fallbackFunction 읽기작업 실패 시 수행할 fallback 함수
* @param fallbackReturnType fallback함수의 반환 자료형
* @param fallbackLockOption fallback함수 수행 시 Lock 사용 옵션
*/
fun fetchAndPut(
redisDataType: RedisDataType,
cacheKey: String,
hashKey: String,
ttlBySeconds: Long,
putDataAfterProceed: Boolean = true,
throwWhenReadFail: Boolean = false,
fallbackFunction: Any?.() -> Any?,
fallbackReturnType: Class<*>,
fallbackLockOption: FallbackLockOption = FallbackLockOption.DISABLED
): Any? {
val threadHasFallbackLock = AtomicBoolean(false) // [Note] 지역 변수는 동시성을 고려하지 않아도 되지만, 락 획득 여부를 참조로 전달하기 위해 사용
// 2.레디스로부터 데이터 읽기 시도 (읽기 실패 시 Lock획득 시도)
val fetchResult = tryReadFromRedisWithObtainLock(
redisDataType = redisDataType,
cacheKey = cacheKey,
hashKey = hashKey,
// 3-3.throwWhenReadFail가 true면 데이터 읽기 실패 예외를 던지고 종료
throwWhenReadFail = throwWhenReadFail,
fallbackReturnType = fallbackReturnType,
fallbackLockOption = fallbackLockOption,
isCurrentThreadHasFallbackLock = threadHasFallbackLock
)
// 3-1.읽기 성공 시 데이터 반환
if (fetchResult !== SAFE_NULL_OBJECT) {
return fetchResult
}
// 3-4.데이터 획득을 위한 fallback함수 수행
val fallbackResult = try {
fallbackFunction()
} catch (e: Exception) {
// fallback수행 중 오류 발생 시 빠른 재시도를 위해 락 해제를 시도하고 예외를 던짐
if (threadHasFallbackLock.get()) { tryReleaseRedisLock(cacheKey) }
throw e
}
// 4-2.fallback함수 수행 결과를 반환
if (!putDataAfterProceed || isNullOrEmptyData(fallbackResult)) {
return fallbackResult
}
// 4-1.레디스에 데이터 적재 시도 (적재 후 Lock해제 시도)
tryWriteIntoRedisWithReleaseLock(
redisDataType = redisDataType,
cacheKey = cacheKey,
hashKey = hashKey,
ttlBySeconds = ttlBySeconds,
writeData = fallbackResult!!, // isNullOrEmptyData(..)에서 null 검증을 수행
isCurrentThreadHasFallbackLock = threadHasFallbackLock
)
// 7.적재 후 fallback함수 수행 결과를 반환
return fallbackResult
}
마지막으로 캐시 호출부인 @DisplayCaching에도 FallbackLockOption속성이 추가되어 아래와 같이 호출하도록 변경되었습니다.
@DisplayCaching(
displayCacheInfo = DisplayCacheInfo.FOO_PAGED,
fallbackLockOption = FallbackLockOption.LOCK_10S_CHECK_EVERY_1S // 기본 락 (최대 10초간 락을 걸고, 1초마다 락 해제/데이터 적재여부를 확인)
)
fun fetchPagedFooData(
@DisplayCachingKey pageNumber: Long
): List<FooDisplayResponseDto> {
// HttpAPI호출, DB조회, 비즈니스 로직
}
캐시 모듈에 분산 락을 적용하자, 재실행된 부하 테스트에서 폴백 로직이 정확히 한 번 실행됨을 보여주었습니다.
폴백에 의해 데이터가 채워지는 동안, 다른 프로세스와 스레드들은 매초 레디스에 데이터 적재 여부를 반복적으로 확인했고, 데이터가 적재된 후에야 정상적으로 읽기를 진행했습니다.
🎁 마치며
많은 고민과 노력 끝에 다사다난했던 선물하기 기능 개편을 성공적으로 마무리할 수 있었는데요. 👏
이로써 평균 1.74초가 소요되던 페이지 로딩 시간을 5밀리초로 크게 개선시킬 수 있었습니다.
또한, 새로 개발된 캐시 모듈을 통해 기존의 불편함을 개선하고 '캐시 스탬피드'라는 잠재적 위험 요소까지 해결할 수 있었습니다.
하지만 여기서 끝이 아니었습니다. 개인적으로는 아무리 좋은 기능을 만들었더라도 이를 제대로 공유하고 전파하는 과정 또한 매우 중요하다고 생각했기에 새로 개발된 기능에 대해 팀 내에 공유하고 전파하는 시간을 별도로 마련했습니다.
여러분도 새로운 기능을 개발했다면, 팀원들과 지식을 나누는 시간을 가져보는 것은 어떨까요?
선물하기 캐시 모듈 개발은 단순히 기능을 구현하는 것을 넘어, 안정적인 서비스의 기반을 다지는 여정이었습니다. 그리고 이 여정은 현재 진행형입니다.
저희는 예측 가능한 문제를 넘어, 아직 수면 위로 드러나지 않은 위험까지 찾아 해결하는 과정을 통해 기술적 깊이를 더해가고 있습니다.
혹시 저희가 놓치고 있는 부분이 보이시나요? 올리브영은 함께 더 나은 기술적 해답을 찾아낼 동료를 언제나 기다리고 있습니다.
저희와 함께 다음 기술 블로그의 주인공이 되어 보시는 것은 어떠신가요?
긴 글 함께해주셔서 진심으로 감사합니다.