요약: 이 글에서는 분산 시스템에서 로컬 캐시를 설계하고 구현한 경험을 공유합니다. 캐시의 기본 개념부터 로컬 캐시와 글로벌 캐시의 차이점, 데이터 정합성을 유지하기 위한 메시징 시스템 활용 방법을 세부적으로 다루며, 각 환경에 적합한 캐시 전략 선택의 중요성을 강조합니다.
💡 리뷰어 한줄평
jerry.this 규모가 있는 서비스에서 캐싱 전략에 대한 고민은 꼭 필요한 것 같아요! 그 부분에 대해 너무 이해하기 쉽게 설명해 주셔서 캐싱을 적용해보고자 하는 분들은 꼭 읽어보셨으면 좋겠어요. 🙂
geuru.geon 더 빠른 데이터 조회를 위해 캐시 시스템을 구현한 경험을 잘 풀어낸 글입니다. 평소 Spring 애플리케이션의 캐시 도입에 관심 있으셨다면 입문서로 읽어보는 것은 어떨까요?
시작하며
안녕하세요, 플랫폼서비스파티에서 통신중개 플랫폼 서버 개발을 맡고 있는 로이입니다. 통신중개 플랫폼은 사용자와 통신사 사이를 중개하는 서비스입니다. 여러 통신사의 요금제 상품을 나열해 보여주며, 사용자는 자신의 요구에 맞는 요금제를 선택하고 가입할 수 있습니다. 사용자는 요금제 구분, 데이터양, 통화량, 가격, 혜택 등 다양한 선택지를 비교하며 자신에게 맞는 요금제를 선택합니다. 이러한 특성으로 인해 상품, 통신사, 혜택 등에 대해 빈번한 조회 요청이 발생합니다.
하지만, 이러한 단순 조회 작업에서 매번 데이터베이스를 호출하는 것은 비효율적일 수 있습니다. 이때 저희가 흔히 사용하는 캐시(Cache)를 조금 더 유용하게 사용하는 방법에 대해 다뤄보려고 합니다. 도입 배경부터 유연한 캐싱 시스템 및 데이터 최신화를 위한 메시징 시스템 구축 방법까지, 실제 구현 코드를 활용해 구체적으로 설명드리겠습니다.
도입 배경
캐시란?
본격적으로 캐시 구현 방법을 살펴보기에 앞서 캐시란 무엇인지 정의를 우선 살펴보겠습니다. 캐시는 데이터를 더 빠르게 접근할 수 있는 고속 저장소입니다. 자주 사용하는 데이터나 반복적으로 조회되는 데이터를 캐시에 저장하면, 데이터베이스를 호출하지 않고도 데이터를 빠르게 반환할 수 있습니다. 이를 통해 조회 속도가 빨라지고 데이터베이스 부하가 크게 줄어들어 전체적인 서비스 성능이 향상됩니다.
흔히 캐시를 이야기할 때, 우리는 Redis를 자주 언급하곤 합니다. 그렇다면, 왜 웹 애플리케이션 서버에 저장하는 방식의 로컬 캐시가 아닌 글로벌 캐시인 Redis를 먼저 떠올릴까요?
근래의 서비스는 고가용성을 위해 Scale-Out 하여 동일한 서버를 2대 이상 운영하는 경우가 많습니다. 이때, 로컬 캐시만을 사용한다면 각 서버 간 데이터 정합성 문제로 인해 서비스 운영에 문제를 일으킬 수 있습니다.
글로벌 캐시인 Redis를 사용할 경우, 서버 간 데이터 공유가 가능하므로 두 Client 모두 같은 데이터를 바라봅니다. 하지만 로컬 캐시를 사용할 경우, 서버 간 데이터 공유가 불가능하기 때문에 캐싱된 데이터에 따라 서버 간 데이터 불일치 문제가 발생할 수 있습니다.
로컬 캐시
그럼에도 불구하고, 로컬 캐시는 다음과 같은 장점이 있어 무시할 수 없습니다.
- 웹 애플리케이션 서버가 조회를 위해 네트워크를 트래픽을 사용하지 않기 때문에 빠른 응답 속도
- 외부 서비스의 지연 의존도 감소
- 서버 리소스 효율성 향상을 통한 서버 성능 개선
저희 서비스는 앞서 말씀드린 것처럼 중개 서비스이기 때문에 한번 등록된 통신사 정보나 상품과 같은 자주 변화하지 않는 데이터에 대한 잦은 조회가 발생합니다. 또한, 데이터가 변경되더라도 각 서버 간 데이터를 완벽히 실시간으로 동기화할 필요가 없는 조회용 데이터이기 때문에 최종적 일관성(Eventual Consistency)을 채택했습니다.
이러한 이유로 저희는 로컬 캐시를 사용하고, 각 서버의 데이터를 최신화하기 위해 Redis를 보조적으로 사용하고 있습니다. 이후에는 저희 팀에서 어떻게 캐싱 전략을 구현했는지 소개해드리겠습니다.
Eventual Consistency란?
각 서버 간 데이터의 일시적 불일치를 허용하지만, 최종적으로는 모든 서버의 데이터 일관성이 맞춰지는 것
유연한 캐싱 시스템 구축하기
기본적으로 저희는 로컬 캐시와 레디스 캐시를 목적에 따라서 구분해서 사용하고 있습니다. 로컬 캐시를 잘 변하지 않고 단순한 조회성 데이터 저장소로 사용한다면, 레디스는 세션과 같은 조금 더 동적인 데이터나 자주 변경되는 데이터, 그리고 서버 간 데이터 공유가 필요한 경우에 저장소로 사용하고 있습니다.
CacheManager를 활용한 통합 캐시 처리
우선 로컬 캐시와 레디스 캐시를 통합해서 관리하는 클래스인 CacheManager
를 살펴보도록 하겠습니다.
@Component
class CacheManager(
private val localCacheManager: SimpleCacheManager,
private val redisCacheManager: RedisCacheManager,
) {
fun <T> getOrPut(
cache: MyCache,
key: String,
clazz: Class<T>,
block: () -> T?,
): T? = runCatching {
get(cache, key, clazz)
}.getOrElse {
block()?.also {
put(cache, key, it)
}
}
fun <T> get(cache: MyCache, key: String, clazz: Class<T>): T? =
runCatching {
getByCache(cache).get(key, clazz)
}.recoverCatching {
evict(cache, key)
throw FailedToGetCacheException()
}.getOrThrow()
fun put(cache: MyCache, key: String, value: Any) =
runCatching {
getByCache(cache).put(key, value)
}.recoverCatching {
throw FailedToPutCacheException()
}.getOrThrow()
fun evict(cache: MyCache, key: String) =
runCatching {
getByCache(cache).evict(key)
}.getOrDefault(Unit)
private fun getByCache(cache: MyCache) =
when (cache.type) {
CacheType.LOCAL -> localCacheManager.getCache(cache.name)
CacheType.REDIS -> redisCacheManager.getCache(cache.name)
} ?: throw FailedToHandleCacheException()
}
sealed class MyCache(
val type: CacheType,
val description: String,
val ttl: Duration,
) {
val name: String = this.javaClass.simpleName
}
sealed class LocalCache(
description: String,
ttl: Duration = Duration.ofHours(1),
) : MyCache(CacheType.LOCAL, description, ttl) {
data object MetaCache : LocalCache("메타 정보", Duration.ofHours(1))
}
CacheManager
는 저희가 일반적으로 사용하는 캐시 조회, 삽입 및 삭제를 목적으로 두고 있습니다. 이때, 캐시를 구분하기 위해 캐시 타입과 이름에 따른 처리를 별도로 진행하고 있으며 어느 종류의 캐시가 들어와도 제한 없이 삽입이 가능하도록 구현한 것이 특징입니다.
코틀린의 sealed class
를 이용해서 캐시 타입을 구현하고 있고, 이때 선언된 MetaCache
란 저희 서비스 내의 기본적인 상품, 통신사, 혜택 등을 메타(Meta) 정보
로 아울러 표기하고 있습니다.
MongoDB Collection의 통합 처리
@Service
class MetaService(
private val mongoOperations: MongoOperations,
private val cacheManager: CacheManager,
) {
fun <T> getOne(clazz: Class<T>, id: ObjectId): T =
get(id, clazz) {
mongoOperations.findById(id, clazz)
}
private fun <T> get(id: ObjectId, clazz: Class<T>, block: () -> T?): T =
cacheManager.getOrPut(
LocalCache.MetaCache,
"${clazz.simpleName}:${id}",
clazz,
) { block.invoke() } ?: throw NotFoundException()
}
@Service
class ProductService(
private val metaService: MetaService,
) {
fun getProduct(id: ObjectId): Product {
val product = metaService.getOne(Product::class.java, id)
// 6 - 조회한 상품 반환
return product
}
}
MetaService
는 다른 서비스 레이어에서 호출이 가능하며, MongoDB의 특정 Collection과 관계없이 상속받은 모든 Collection(Product
, Benefit
…)에서 캐시 조회 및 삽입이 가능하도록 구현되어 있습니다. 이를 통해 각종 메타 정보를 효율적으로 관리할 수 있으며, 캐시 데이터가 없을 경우에도 데이터베이스를 활용하여 필요한 정보를 가져오는 구조를 제공합니다.
이 방식은 코드의 재사용성을 높이는 동시에, 데이터 조회 경로를 단순화하여 유지보수성을 향상합니다. 예를 들어, 새로운 메타 정보가 추가되더라도 MetaService
내에서 캐시와 데이터베이스를 일관된 방식으로 처리할 수 있어 별도의 수정 없이도 기존 구조를 활용할 수 있습니다. 또한, 캐시 데이터의 일관성을 유지하기 위해 조회 실패 시 캐시를 자동으로 초기화하거나, 갱신할 수 있는 로직이 포함되어 있어 안정성을 보장합니다.
MetaService
의 동작 방식은 아래와 같습니다.
- 서버에 Meta 정보(ex. 상품)에 대한 데이터 조회 요청
- Meta 정보이기 때문에 서버는 로컬 캐시를 우선 탐색
- 로컬 캐시에서 찾은 경우 데이터를 리턴한 뒤 6번으로 이동
- 데이터가 null일 경우 DB 조회
- 데이터 존재 여부에 따라 서버에 전달
- 서버는 전달받은 데이터를 클라이언트에 응답
이처럼 로컬 캐시를 활용한 메타 정보 관리 방법을 통해 효율적인 데이터 조회를 구현할 수 있습니다. 요청이 특정 서버에 도착하면, 먼저 로컬 캐시에서 데이터를 확인하고, 캐시에 데이터가 없을 경우 데이터베이스에서 조회한 뒤 이를 캐시에 저장합니다. 이를 통해 데이터베이스 부하를 줄이고, 반복 조회 시 빠른 응답을 제공합니다. 이렇게 구성된 시스템은 빈번한 데이터 조회 요청에도 안정적으로 대응할 수 있도록 설계되었습니다.
데이터 최신화를 위한 메시징 시스템 구축하기
앞선 코드의 MetaCache
는 기본적으로 1시간의 time-to-live(캐시에 지속 가능한 시간)를 부여하여, 데이터가 수정되더라도 최대 1시간 내에 반영되도록 설정되어 있습니다. 그러나 캐시에 데이터가 저장된 동안 주요 정보(예: 가격)가 변경되면 어떻게 될까요? 예를 들어, 사용자가 10,000원
상품에 가입하려 했으나, 상품 정보가 갱신되어 20,000원
상품이 된다면 사용자에게 부정적 경험을 전달하게 될 것입니다.
이러한 데이터 불일치 문제를 효과적으로 해결하기 위해, 메시징 시스템을 활용한 데이터 동기화 방식을 사용하곤 합니다. 메시징 시스템은 시스템 간 데이터를 비동기적으로 전달하거나 이벤트를 중개하는 소프트웨어로, 데이터 변경 이벤트를 다른 서버나 시스템에 실시간으로 전달합니다. 예를 들어, 상품의 가격이 변경되면 메시징 시스템에 해당 아이템에 수정이 있다는 이벤트를 발행합니다. 각 서버는 이 특정 채널을 구독해 자신의 로컬 캐시를 무효화하거나 최신 데이터로 갱신합니다. 해당 방식은 데이터 정합성을 최대한 유지하며 빠른 조회 성능을 보장할 수 있습니다. 결론적으로, 메시징 시스템은 내가 아닌 다른 서버의 상황을 모르더라도 실시간에 가까운 데이터 정합성을 유지할 수 있는 효과적인 방법입니다. 이는 사용자 경험을 개선하고, 서비스의 신뢰성을 높이는 데 도움이 됩니다.
Redis Pub/Sub 구현하기
메시징 시스템을 구현하는 방법에는 여러 가지가 있지만, 다음과 같은 이유로 Redis Pub/Sub
을 선택했습니다.
- 기존 인프라 활용: Redis를 이미 활용하고 있어 별도의 설정이나 추가적인 인프라 구성이 필요 없었습니다.
- 가벼운 구조와 실시간 처리:
Redis Pub/Sub
은 메시징 기능이 가볍고 단순하며, 실시간성이 중요한 데이터 갱신 이벤트 처리에 적합합니다. - 잦은 데이터 변경이 없음: 데이터 갱신 빈도가 낮을수록 메시지 발행량이 줄어들어 메시지 유실 가능성이 상대적으로 적습니다. 또한 유실이 되더라도, 저희 서비스에서 치명적인 문제로 이어지지 않는 특성을 고려했습니다. 상품의 가격과 같은 민감한 정보도 정해진 주기에 따라 변경되거나 노출이 종료되고 새로운 상품이 등록되기 때문에 서비스 안정성에 영향을 주지 않습니다.
@Component
class MetaAfterSaveCallback(
private val redisPublisher: RedisPublisher,
) : AfterSaveCallback<Meta> {
override fun onAfterSave(entity: Meta, document: Document, collection: String): Meta {
publishRedisEvent(entity, collection)
return entity
}
private fun publishRedisEvent(
entity: Meta,
collection: String,
) {
redisPublisher.publish(
RedisMetaEvent(
action = if (entity.isNew()) ActionType.CREATE else ActionType.UPDATE,
collection = collection,
id = entity.id,
)
)
}
}
@Component
class RedisPublisher(
private val redisTemplate: StringRedisTemplate,
) {
private val objectMapper = ObjectMapper()
fun publish(event: RedisMetaEvent) {
val message = objectMapper.writeValueAsString(event)
redisTemplate.convertAndSend(RedisMetaEvent.TOPIC, message)
}
}
@Component
class MetaEventSubscriber(
private val cacheManager: CacheManager,
) {
fun subscribe(metaEvent: RedisMetaEvent) {
cacheManager.evict(LocalCache.MetaCache, metaEvent.toKey())
}
}
data class RedisMetaEvent(val action: ActionType, val collection: String, val id: ObjectId) {
companion object {
const val TOPIC = "redis:meta-event"
}
fun toKey(): String {
return "$collection:$id"
}
}
enum class ActionType {
CREATE,
UPDATE,
DELETE
}
우선 이벤트 발행 방법에 대해 알아보겠습니다. MetaAfterSaveCallback
클래스는 AfterSaveCallback
클래스를 상속받아 MongoDB에서 Meta
객체가 저장될 때 동작합니다. 이를 이용해 Meta
가 변경될 때 해당 이벤트를 Redis로 발행하는 역할을 합니다. Meta
객체가 새로 생성되었는지 아니면 업데이트된 것인지를 entity에 isNew()
함수를 구현해 확인하고, 그에 맞는 ActionType
을 설정하여 RedisMetaEvent
를 생성한 후 Redis로 발행합니다. RedisPublisher
클래스는 이 이벤트를 Redis의 TOPIC
채널로 메시지를 전송합니다. 이렇게 발행된 이벤트는 MetaEventSubscriber
에서 구독되어, 해당 이벤트에 대한 캐시를 갱신하는 방식으로 처리됩니다. 구체적으로, MetaEventSubscriber
는 RedisMetaEvent
를 받아 해당하는 캐시 항목을 제거하여 데이터 정합성을 유지합니다.
다음은 설정 관련 코드입니다. RedisMessageListener
는 Redis에서 발행된 메시지를 수신하고, 이를 처리하는 역할을 합니다. 메시지가 RedisMetaEvent.TOPIC
채널에서 발행된 경우, RedisMetaEvent
객체로 변환하고, 이를 MetaEventSubscriber
에 전달하여 캐시를 갱신합니다. 이를 동작시키기 위해서는 다음과 같은 Redis 관련 설정이 필요합니다.
@Component
class RedisMessageListener(
private val metaEventSubscriber: MetaEventSubscriber,
) : MessageListener {
private val objectMapper = ObjectMapper()
override fun onMessage(message: Message, pattern: ByteArray?): Unit = runBlocking {
runCatching {
when (String(message.channel)) {
RedisMetaEvent.TOPIC -> {
val metaEvent = objectMapper.readValue(message.body, RedisMetaEvent::class.java)
metaEventSubscriber.subscribe(metaEvent)
}
else -> throw InvalidParameterException()
}
}
}
}
@Configuration
class RedisConfig {
@Bean
fun redisCodeMessageListenerAdaptor(redisMessageListener: RedisMessageListener): MessageListener =
MessageListenerAdapter(redisMessageListener)
@Bean
fun redisMetaEventTopic(): ChannelTopic = ChannelTopic(RedisMetaEvent.TOPIC)
@Bean
fun redisContainer(
redisConnectionFactory: RedisConnectionFactory,
messageListenerAdapter: MessageListenerAdapter,
): RedisMessageListenerContainer =
RedisMessageListenerContainer().also {
it.connectionFactory = redisConnectionFactory
it.addMessageListener(messageListenerAdapter, redisMetaEventTopic())
}
}
지금까지의 내용을 요약하면 저희는 다음과 같은 방식을 이용해 데이터 일관성을 유지하며 로컬 캐시에서 오래된 데이터가 참조되지 않도록 방지했습니다.
- 데이터베이스 데이터 갱신 시: 데이터베이스에 변경이 발생하면 Redis에 해당 데이터를 업데이트가 있음을 발행합니다.
- Redis Pub/Sub 알림: Redis는 데이터 변경 이벤트를 모든 구독자(애플리케이션 서버)에 전파합니다.
- 로컬 캐시 동기화: 애플리케이션 서버는 Redis의 이벤트를 받아 로컬 캐시를 무효화합니다.
더 나아가기
이번에는 로컬 캐시와 Redis를 연계하여 메시징 및 데이터 정합성 처리에 초점을 맞췄지만, 더 나아가 유실 처리나 다양한 캐싱 전략 및 아키텍처도 고려할 수 있습니다.
- 유실에 대한 처리: 네트워크 장애나 열결 해제로 인한 메시지 유실을 방지하기 위한 메시지 로그 저장 및 복구 (ex. Redis Streams). RabbitMQ 또는 Kafka를 이용한 ACK 기반 메시징 시스템 활용.
- 다단계 캐시(Multi-Level Caching): 1차 캐시 → 2차 캐시 → DB와 같은 구조를 사용하여, 데이터 조회 시 우선 1차 캐시(로컬 캐시)를 조회하고, 없으면 2차 캐시(Redis 등)로 넘어가며, 그다음 DB를 조회하는 방식.
- Write-Through 캐시(Write-Through Cache): 데이터를 저장할 때 캐시, DB에 동시에 저장. 양쪽에 모두 데이터가 저장하므로 읽기 작업 시 캐시에서 바로 조회 가능하며, 쓰기 작업 시 캐시를 우선 업데이트하고 이후 DB를 갱신.
- 캐시 크기 관리: Redis와 같은 분산 캐시 시스템에서는 캐시 메모리가 한정되어 있기 때문에, 클러스터의 데이터 용량이 초과되면 기존 데이터를 자동으로 삭제하거나, 캐시의 우선순위를 정할 수 있는 방식으로 크기 관리.
마치며
이번 글에서는 분산 환경에서 로컬 캐시를 설계하고 구현한 경험을 공유했습니다. 캐시는 단순히 조회 속도를 개선하는 데 그치지 않고, 데이터베이스의 부하를 줄이며 시스템의 확장성을 높이는 중요한 역할을 합니다.
각기 다른 요구사항에 맞춰 Redis와 로컬 캐시를 활용할 수 있습니다. 분산 환경에서 데이터 일관성이 중요하거나 여러 애플리케이션 간 캐시를 공유해야 한다면 Redis가 적합합니다. 반면, 애플리케이션 내에서 빠른 응답 속도가 중요하고 데이터가 단순하며 변경 빈도가 낮은 경우 로컬 캐시를 활용하는 것이 효과적입니다.
저희는 위에서 언급한 이유들을 바탕으로 도메인과 아키텍처에 적합한 캐싱 전략을 도입했습니다. 그러나 캐시 전략으로 시스템 복잡도가 증가할 수도 있기 때문에, 각 시스템 특성에 맞는 필요성과 목적을 명확히 정의하고 적용하는 것이 중요합니다. 앞으로도 캐시 전략 복잡도를 줄이면서 데이터 일관성을 유지하고 성능을 극대화할 수 있는 기술적 방안을 연구할 계획입니다. 이 과정에서 더 나은 성능과 안정성을 제공하는 시스템을 만들어가겠습니다. 감사합니다!