Redis는 빠르지만 생각해보니 메시지 쌓이면 메모리 아깝겠는데?… 이거 큐에서 써도 되나?
관리 도구까지 만들고 나서, 더 할거 없나 찾는 도중에 정말로 현실적인 고민이 시작되더라고요. Redis는 분명 빠르지만, Queue로 쓰기에는 뭔가 아쉬운 부분들이 있었어요.
솔직한 고민들:
- 사용자가 늘어나면 메시지도 폭증할 텐데…
- 이걸 다 메모리에 담고 있어야 하나?
- 메모리 비용이 너무 비싸지 않을까?
이번 포스트에서는 처음 고민부터 실제 구현까지의 전 과정을 솔직하게 공유해보겠습니다.
🤔 처음 고민: “Redis는 빠르지만 Queue로는 아쉬워”
메모리 기반의 한계
Redis를 쓰면서 계속 마음에 걸렸던 부분들:
1. 메모리는 코스트가 높다
안그래도 메모리는 별로 없는데 메시지는 쌓이면 계속 쌓일거란 말이지…
2. Queue로 쓰기엔 뭔가 아쉬운 게 있어
Redis는 캐시나 세션 저장용으로는 완벽하지만, Queue로 쓰기엔:
- 모든 데이터가 메모리에 상주해야 함
- 사용자가 늘면 메시지도 폭증할 텐데…
- 오래된 메시지까지 비싼 메모리를 점유
- 14일 보관 정책이면 정말 큰 메모리가 필요
“Queue에서 Redis 써도 되는거 맞아?…”
“Kafka처럼 파일시스템으로 가볼까?”
Kafka 써보기도 했고… 대략 구조도 알고 하니… 이거 따라가볼까??..
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Producer │───▶│ Disk-based │◀───│ Consumer │
│ │ │ Storage │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
│
┌─────▼─────┐
│ segment │
│ files │
└───────────┘
Kafka의 매력:
- 디스크 기반이라 용량 걱정 없음
- 순차 접근으로 디스크도 충분히 빠름
걱정:
“아니… 파일시스템.. 느리겠는데?… 이거 나는 빠르게 구현할 자신이 없는디… 이게 Simple Queue 인데 느리면 이거 뭐 대용량 쓰는것도 아니고 굳이… 그리고 생각해야될게 너무 많은데?…”
구현해야 할 것들:
- 파일 포맷 설계
- 인덱싱 시스템
- 파일 로테이션
- 동시성 제어
- 에러 복구
- 데이터 압축
“아… 이걸 어쩌냐… 흠…”
그러다가 번뜩 든 생각: 메모리에 들고있다가 오래된 것만 파일로 넘길까?
핵심 아이디어:
- 인메모리에 들고 있다가
- 좀 쌓인 메시지는 디스크에 넣는 거지
Hot Storage (Redis Memory):
└─ 최근 메시지, 즉시 처리 필요
└─ 비싸지만 빠름
Cold Storage (File System):
└─ 오래된 메시지, 장기 보관
└─ 저렴하지만 느림
예상 효과:
- 성능: Hot 영역은 Redis 수준 유지
- 비용: 대부분 데이터는 저렴한 디스크에
- 복잡도: 점진적 구현 가능
🚨 그런데… FIFO가 문제였다
설계하다가 깨달은 치명적 문제
Hot/Cold 아이디어에 신이 나서 설계하다가, 갑자기 든 생각…
“FIFO는 결국 언제나 디스크를 확인해야 하는 거 아닌가?”
“어.. 음.. FIFO 는 순서가 보장되어야하는데… 이게 오래된건 Cold 에 가있을 테니… FIFO 는 언제나 디스크를 봐야하네?…”
문제의 핵심:
- FIFO 보장을 위해 항상 Cold Storage 확인 필요
- Hot이 비어있어도 Cold에 더 오래된 메시지가 있을 수 있음
- 결국 매번 디스크 I/O를 피할 수 없음
그 순간의 절망:
성능 향상을 위해 Hot/Cold로 나눴는데...
FIFO 때문에 결국 Cold를 매번 확인해야 함
그럼 성능 이점이 없어지는 거 아닌가? 🤯
뭐 FIFO 는 느릴 수도 있지~
┌─────────────┐ Hot (Redis Memory) ┌─────────────┐
│ Producer │───▶ 즉시 처리 메시지 │ Consumer │
└─────────────┘ 1시간 보관 └─────────────┘
│
자동 이관 (1시간 후)
▼
Warm (Redis Disk)
중간 빈도 접근
24시간 보관
│
자동 이관 (24시간 후)
▼
Cold (File System)
장기 보관 저장소
14일간 보관
기대했던 효과:
- Hot에만 최근 1시간 메시지 → 메모리 사용량 95% 감소
- Warm에서 중간 처리 → 성능 손실 최소화
- Cold에서 장기 보관 → 비용 절약
설정값들:
# application.yml 설정
app:
storage:
hot:
ttl-hours: 1
max-memory-usage-percent: 80
warm:
ttl-days: 1
max-disk-usage-gb: 100
cold:
base-path: ${COLD_STORAGE_PATH:/tmp/sqs-cold-storage}
compression-enabled: true
encryption-enabled: false
max-file-size-mb: 100
tiering:
enabled: true
check-interval-seconds: 300 # 5분
batch-size: 1000
hot-to-warm-age-seconds: 3600 # 1시간
warm-to-cold-age-seconds: 86400 # 24시간
실제 구현: UnifiedStorageService
핵심 아키텍처
@Service
class UnifiedStorageService(
private val hotStorage: HotStorageService, // Redis 메모리
private val warmStorage: WarmStorageService, // Redis 디스크
private val coldStorage: ColdStorageService, // 파일시스템
private val tieringManager: TieringManagerService // 계층화 관리
) {
// 메시지 저장 (항상 Hot부터 시작)
suspend fun saveMessage(message: Message): Boolean {
val saved = hotStorage.saveMessage(message)
if (saved) {
tieringManager.trackMessageLocation(message.messageId, StorageTier.HOT)
}
return saved
}
// 메시지 조회 (위치 추적으로 최적화)
suspend fun getMessage(messageId: String): Message? {
// 1. 위치 정보로 빠른 조회
val location = tieringManager.getMessageLocation(messageId)
if (location != null) {
val storageService = getStorageService(location.tier)
val message = storageService.getMessage(messageId)
if (message != null) {
// 접근했으므로 Hot 승격 고려
considerPromotion(messageId, location.tier)
return message
}
}
// 2. 전체 계층 순차 검색 (Hot → Warm → Cold)
return hotStorage.getMessage(messageId)
?: warmStorage.getMessage(messageId)?.also {
tieringManager.promoteMessage(messageId, StorageTier.HOT)
}
?: coldStorage.getMessage(messageId)?.also {
tieringManager.promoteMessage(messageId, StorageTier.WARM)
}
}
}
첫 번째 현실: FIFO가 진짜 문제였다
예상했던 문제가 현실로
// FIFO 큐에서 가장 오래된 메시지 조회
suspend fun getOldestMessage(queue: Queue): Message? {
val queueName = "${queue.tenantId}:${queue.queueName}"
return when (queue.queueType) {
QueueType.FIFO -> {
// 😱 결국 모든 계층을 순서대로 검색해야 함
hotStorage.getOldestMessage(queueName)
?: warmStorage.getOldestMessage(queueName)?.also {
tieringManager.promoteMessage(it.messageId, StorageTier.HOT)
}
?: coldStorage.getOldestMessage(queueName)?.also {
tieringManager.promoteMessage(it.messageId, StorageTier.HOT)
}
}
QueueType.STANDARD -> {
// Standard는 Hot 우선으로 최적화 가능
hotStorage.getOldestMessage(queueName) ?: run {
scheduleAsyncPromotion(queueName)
null
}
}
}
}
발견한 문제:
- FIFO는 순서 보장 때문에 항상 모든 계층 확인 필요
- Hot이 비어있어도 Warm/Cold에 더 오래된 메시지가 있을 수 있음
- 결국 Cold Storage I/O를 피할 수 없음
해결책: 큐 타입별 다른 전략
enum class QueueType {
FIFO, // 순서 보장 (모든 계층 검색)
STANDARD // 최적 성능 (Hot 우선)
}
FIFO 큐:
- 정확성 우선: 모든 계층 검색
- 성능 손실 감수
- 중요한 워크플로우용
Standard 큐:
- 성능 우선: Hot 스토리지만 확인
- 백그라운드에서 비동기 승격
- 대용량 처리용
😅 두 번째 현실: 메시지 위치 추적도 메모리를 먹는다
예상치 못한 메타데이터 오버헤드
// 메시지마다 위치 정보를 추적해야 함
data class MessageLocation(
val messageId: String,
val tier: StorageTier,
val lastAccessTime: Instant,
val accessCount: Long = 0,
val migrationHistory: List<TierMigrationRecord> = emptyList()
)
// 이것도 결국 Redis 메모리 사용... 😅
현실 체크:
- 메시지 1백만 개 = 위치 정보도 1백만 개
- 메타데이터만으로도 상당한 메모리 사용
- “메모리 절약하려다가 메모리 더 쓰는 거 아닌가?” 싶은 순간
해결책: 캐시 + 만료 정책
@Service
class TieringManagerServiceImpl {
private val messageLocationCache = ConcurrentHashMap<String, MessageLocation>()
override suspend fun trackMessageLocation(messageId: String, tier: StorageTier) {
val location = MessageLocation(
messageId = messageId,
tier = tier,
lastAccessTime = Instant.now()
)
// 메모리 캐시
messageLocationCache[messageId] = location
...
}
}
🚀 다음에 할 것
- 음… FIFO 는 순서가 보장되어야 하니까 Cold 만 보면 될 것 같다?…
- 그렇다면 FIFO 는 오래된 걸 Cold 로 보내는게 아니라 최근 메시지를 Cold 로 보내면 되지않을까?…
- 이럴려면 현재는 오래된 즉, 시간으로만 Hot/Cold 를 보내는데, 메시지 갯수로도 보내는 걸 추가하고..
- FIFO 는 메시지 갯수가 넘어가면 최신부터 Cold 로 넘기는걸 하면 될지도?…
- 다음에 해보자~
💬 댓글