최근 백엔드 면접에서 한 면접관님이 던진 질문이 머리에 오래 남았다.
"1,000만 건의 데이터가 전부 Redis에 올라가면 문제가 생기지 않을까요?"
처음엔 "TTL을 짧게 걸면 되지 않나?" 정도로 답했지만, 면접관님의 표정에서 이미 부족한 답변이라는 걸 알 수 있었다. 면접이 끝나고 곰곰이 생각해보니, 나는 캐시를 '도구'로만 알고 있었지 '비용'까지는 보지 못하고 있었다.
이 글은 그 면접에서 받은 피드백을 계기로 Redis 용량 문제를 처음부터 정리한 회고이자 학습 노트다.
내가 처음 떠올린 답은 이런 흐름이었다.
하지만 면접관님이 의도한 본질은 다른 곳에 있었다.
"Redis는 In-Memory DB라는 사실을, 정말로 인지하고 있는가?"
In-Memory라는 단어를 알고만 있는 것과, 그것이 만들어내는 비용/한계/위험까지 이해하는 것은 다르다.
Redis는 모든 데이터를 RAM에 올린다. 한 건당 500바이트만 잡아도:
RAM은 디스크보다 수십 배 비싸다. 즉, 10GB의 캐시는 단순히 "메모리를 좀 쓴다" 수준이 아니라 운영 비용 그 자체이다.
Redis는 메모리 한계에 도달하면 maxmemory-policy에 따라 동작한다.
| 정책 | 동작 |
|---|---|
noeviction (기본값) | 쓰기 명령 거부 → 서비스 장애 |
allkeys-lru | 모든 키 중 가장 오래 안 쓴 것 제거 |
volatile-lru | TTL 있는 키 중 LRU 제거 |
allkeys-lfu | 모든 키 중 가장 적게 쓴 것 제거 |
정책 설정을 안 하면 기본값이 noeviction이라, 캐시가 그대로 장애 트리거가 된다. 이걸 모르면 운영 환경에서 캐시가 가득 찰 때 갑자기 쓰기가 거부되는 사고가 난다.
Redis는 빠르기 위해 단일 스레드 구조를 택했다. 이 선택이 빛을 발하려면 데이터셋이 적당히 작아야 한다.
데이터셋이 커질수록 발생하는 두 가지 부담:
(1) 백그라운드 작업의 fork 비용
RDB(Redis Database) 스냅샷이나 AOF(Append Only File) rewrite는 자식 프로세스를 fork해서 디스크에 쓴다. 그런데 fork 자체가 메모리 크기에 비례해 페이지 테이블 복사 시간이 든다. 이 시간 동안 단일 스레드라 모든 요청이 블로킹된다.
<RDB 스냅샷 만들기>
[목표]
"메모리에 있는 50GB 데이터를 디스크에 dump.rdb 파일로 저장"
[딜레마]
Redis는 단일 스레드.
저장 작업을 메인 스레드가 직접 하면?
→ 50GB 디스크 쓰는 동안 모든 요청 블로킹
→ 수십 초간 서비스 마비 ⚠️
[해결]
fork()로 자식 프로세스 만들기
→ 자식이 디스크 쓰기 담당
→ 부모(메인 Redis)는 평소처럼 요청 처리
<AOF Rewrite>
[Rewrite 작업]
1. 부모가 fork() 호출
2. 자식이 현재 메모리 상태를 보고
"이 상태를 만드는 최소한의 명령어 세트"를 새 AOF 파일에 작성
3. 자식 종료, 새 파일이 기존 AOF 대체
→ 이것도 메인 스레드 블로킹 안 됨 (자식 담당)
fork가 느려지는 이유
메모리 1GB:
- 페이지 약 26만 개
- 페이지 테이블 작음
- fork 시간: 수 ms
메모리 50GB:
- 페이지 약 1,300만 개
- 페이지 테이블 큼
- fork 시간: 수백 ms
메모리 200GB:
- 페이지 약 5,200만 개
- 페이지 테이블 매우 큼
- fork 시간: 수 초 ⚠️
fork() 진행 중에는 단일 스레드인 부모도 블로킹되므로, 데이터셋이 클수록 그 시간만큼 모든 요청이 정지.
P99 지연이 튀는 가장 흔한 원인 중 하나다.
(2) KEYS * 한 번이면 전체 블로킹
KEYS * 같은 명령은 모든 키를 순회한다. 단일 스레드라 그 시간 동안 모든 요청이 멈춘다. 키가 1천만 개면 수 초간 서비스가 정지할 수 있다.
운영 환경에선 이런 명령을 아예 비활성화해야 한다.
# redis.conf
rename-command KEYS ""
rename-command FLUSHALL ""
키 패턴 조회가 필요하면 커서 기반의 SCAN을 써야 한다.
가장 본질적인 문제는 이거다.
캐시는 전체 데이터의 복제본이 아니라, 핫 데이터의 단축 경로다.
1,000만 건 전체를 캐싱한다는 건 사실상 Redis를 메인 DB처럼 쓰는 것이다. 그러면 MySQL을 굳이 둘 이유가 없다. 보통 80/20 법칙에 따라 20%의 데이터가 80%의 트래픽을 처리하기 때문에, 그 20%만 캐싱하는 게 효율적이다.
CreatorLink의 통계 캐시 한 건이 이런 JSON이라고 가정하면,
{
"creatorId": 12345678,
"creatorName": "일류 크리에이터",
"channelId": 98765432,
"channelDisplayName": "유튜브_공식채널_메인",
"todayClicks": 1250,
"rangeClicks": 45000,
"totalClicks": 1200000
}
한 건당 크기 추정:
1,000만 건 저장 시:
이 수치를 보고 나서야 "캐시가 무겁다"는 말이 추상적이지 않게 다가왔다.
여기서 한 단계 더 깊은 의문이 생겼다.
트래픽이 균등 분포라면, 60초 안에 1,000만 건이 모두 캐시에 들어올 수도 있는 것 아닌가?
이 시나리오는 이론적으로 완전히 가능하다. 그리고 이게 바로 TTL의 한계다.
TTL = 60초
60초 안에 천만 개의 서로 다른 키가 조회됨
→ 어느 것도 만료 전이라 모두 캐시에 누적
→ 메모리 폭발
TTL은 시간 기반 제거일 뿐, 메모리 한계 보호 기능이 아니다. 이를 보완하는 게 다음 조합이다.
maxmemory 4gb
maxmemory-policy allkeys-lru
이렇게 설정하면 새 키가 들어와 메모리 한계에 도달할 때 가장 오래 사용 안 된 키가 자동으로 제거된다. 천만 건이 들어와도 메모리는 4GB를 절대 넘지 않는다.
예리한 후속 질문이 따라온다. 균등 분포 트래픽에서는 캐시 히트율 자체가 낮아진다.
80/20 분포: 핵심 20%만 캐싱해도 80% 히트율 ✅
균등 분포: 캐시 크기 / 전체 크기 만큼만 히트 (예: 30%) ⚠️
캐시는 본질적으로 트래픽 편향을 활용하는 도구다. 분포가 균등하면 캐시 자체가 효과가 없다.
이런 데이터는 캐시 대신 다른 카드를 검토해야 한다.
이번 학습으로 정리된 내 생각은 이렇다.
1. 트래픽 분포가 편향적인가? (편향이 없으면 캐시 효과 낮음)
2. 어떤 데이터를 캐싱할지 선별 (Hot Data만)
3. TTL + maxmemory + Eviction Policy 조합 설정
4. 캐싱 데이터의 직렬화/구조 최적화(캐시에 데이터를 저장할 떄 어떤 형식(포맷)으로 변환해서 저장할 것인가)
5. 단일 스레드 한계를 의식한 운영 (위험 명령 차단, 모니터링)
"캐시는 전체 데이터의 복제본이 아니라, 핫 데이터의 단축 경로다."
"TTL은 시간을 막을 뿐, 메모리는 maxmemory가 막는다."
면접에서 답변이 부족했던 게 결과적으로 좋은 학습 계기가 됐다. "캐시 적용했어요"가 아니라 "왜 그 패턴을, 왜 그 구현으로, 왜 그 트레이드오프를 선택했는가" 를 답할 수 있어야 한다는 걸 배웠다.
다음 면접에서 같은 질문을 받으면 이렇게 답할 것이다.
"Redis는 In-Memory DB이기 때문에 모든 데이터를 RAM에 올리는 비용이 큽니다. 1,000만 건 캐싱은 메모리 폭발 위험과 fork 시 블로킹, 캐시 본질 훼손까지 여러 문제를 만듭니다. 그래서 TTL만이 아니라 maxmemory와 Eviction Policy를 함께 설정하고, 핫 데이터만 선택적으로 캐싱하는 게 캐시 설계의 출발점이라고 생각합니다."