
Redis: 캐시 그 이상의 인메모리 데이터 저장소
Redis를 단순 캐시로만 쓰고 있었는데, 세션 관리, 실시간 랭킹, Pub/Sub, Rate Limiting까지 가능한 만능 도구였다.

Redis를 단순 캐시로만 쓰고 있었는데, 세션 관리, 실시간 랭킹, Pub/Sub, Rate Limiting까지 가능한 만능 도구였다.
DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

Redis를 처음 접했을 때 나는 "빠른 캐시"라고만 이해했다. DB 조회가 느리니까 중간에 Redis를 두면 빨라진다는 단순한 논리였다. 실제로 그렇게 쓰면서도 만족했다. API 응답 속도가 200ms에서 20ms로 줄어드는 걸 보면서 "역시 캐시는 짱이야"라고 생각했다.
그런데 어느 날 실시간 랭킹 시스템을 만들어야 했다. 수천 명의 유저 점수를 실시간으로 정렬해서 보여줘야 했는데, PostgreSQL로는 ORDER BY score DESC LIMIT 10 쿼리가 점점 느려졌다. 인덱스를 걸어도 한계가 있었다. 이걸 어떻게 해결하지 고민하다가 Redis 문서를 뒤적이다가 깨달았다.
String만 저장하는 줄 알았는데, List, Set, Sorted Set, Hash, Stream까지 지원했다. 그것도 모두 O(1) 또는 O(log N) 시간 복잡도로. 마치 "나 빠른 데이터베이스인데 캐시로만 쓰여서 억울해"라고 말하는 것 같았다. 그 순간부터 Redis를 다시 보게 됐다.
Redis를 캐시 서버라고 생각하면 핵심을 놓친다. Redis는 메모리에 살고 있는 다목적 데이터 저장소다. 마치 스위스 아미 나이프처럼 하나의 도구로 여러 문제를 해결할 수 있다.
Redis가 제공하는 데이터 구조를 이해하면 왜 이게 캐시 이상인지 와닿는다.
String: 가장 기본. 텍스트, 숫자, JSON, 이진 데이터 모두 저장 가능. 조회수 카운터, 플래그, 토큰 저장에 쓴다.
// 조회수 증가
await redis.incr('post:123:views');
// 토큰 저장 (1시간 TTL)
await redis.setex('session:abc123', 3600, JSON.stringify(userData));
Hash: 객체를 필드별로 저장. User 프로필 같은 구조화된 데이터에 완벽하다. 전체 객체를 가져오지 않고 특정 필드만 업데이트할 수 있다.
// 유저 정보 저장
await redis.hset('user:1000', {
name: 'John',
email: 'john@example.com',
score: 1500
});
// 점수만 업데이트
await redis.hincrby('user:1000', 'score', 10);
// 이름만 조회
const name = await redis.hget('user:1000', 'name');
List: 순서가 있는 문자열 리스트. 최근 활동 로그, 작업 큐, 타임라인 피드에 쓴다. LPUSH로 앞에 추가하고 RPOP으로 뒤에서 꺼내면 큐가 된다.
// 최근 검색어 저장 (최대 10개)
await redis.lpush('user:1000:searches', 'Redis tutorial');
await redis.ltrim('user:1000:searches', 0, 9);
// 최근 5개 조회
const recent = await redis.lrange('user:1000:searches', 0, 4);
Set: 순서 없는 고유 값 집합. 태그, 팔로워, 좋아요 목록에 쓴다. 교집합(SINTER), 합집합(SUNION), 차집합(SDIFF)이 O(N)으로 가능하다.
// 팔로워 추가
await redis.sadd('user:1000:followers', 'user:2000', 'user:3000');
// 맞팔 확인 (교집합)
const mutualFollowers = await redis.sinter(
'user:1000:followers',
'user:1000:following'
);
Sorted Set: 점수가 있는 정렬된 집합. 내가 찾던 바로 그것. 랭킹, 우선순위 큐, 시간순 정렬에 최적이다. ZADD로 추가하고 ZRANGE로 범위 조회하면 끝.
// 리더보드에 점수 추가
await redis.zadd('leaderboard', 1500, 'user:1000');
await redis.zadd('leaderboard', 2300, 'user:2000');
await redis.zadd('leaderboard', 1800, 'user:3000');
// 상위 10명 조회 (내림차순)
const topPlayers = await redis.zrange('leaderboard', 0, 9, {
REV: true,
WITHSCORES: true
});
// 특정 유저 순위 확인
const rank = await redis.zrevrank('leaderboard', 'user:1000');
이 코드가 O(log N)으로 동작한다. 백만 명이 있어도 밀리초 안에 답이 나온다. PostgreSQL로 이걸 구현하면 인덱스 스캔에 수백 ms가 걸린다. 결국 적재적소에 맞는 데이터 구조를 쓰는 게 핵심이었다.
캐시로서의 Redis도 무시할 수 없다. 세 가지 패턴이 있다.
Cache-Aside (Lazy Loading): 가장 흔한 패턴. 데이터를 읽을 때 캐시를 먼저 확인하고, 없으면 DB에서 가져와서 캐시에 저장한다.
async function getUser(userId) {
// 1. 캐시 확인
const cached = await redis.get(`user:${userId}`);
if (cached) return JSON.parse(cached);
// 2. DB 조회
const user = await db.users.findUnique({ where: { id: userId } });
// 3. 캐시 저장 (1시간 TTL)
await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
return user;
}
Write-Through: 쓰기 시점에 캐시도 함께 업데이트. 데이터 일관성이 중요할 때 쓴다. 단점은 쓰기가 느려진다.
Write-Behind (Write-Back): 캐시에만 먼저 쓰고 나중에 비동기로 DB에 반영. 쓰기 성능이 중요할 때. 단점은 Redis가 죽으면 데이터가 날아갈 수 있다.
JWT 대신 Redis 세션을 쓰는 이유는 간단하다. 즉시 무효화할 수 있기 때문이다. JWT는 만료 시간까지 기다려야 하지만, Redis는 DEL 하나로 로그아웃이 완성된다.
// 로그인
app.post('/login', async (req, res) => {
const sessionId = crypto.randomUUID();
await redis.setex(
`session:${sessionId}`,
86400, // 24시간
JSON.stringify({ userId: user.id, role: user.role })
);
res.cookie('sessionId', sessionId, { httpOnly: true });
});
// 인증 미들웨어
async function authenticate(req, res, next) {
const sessionId = req.cookies.sessionId;
const session = await redis.get(`session:${sessionId}`);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
req.user = JSON.parse(session);
next();
}
// 로그아웃
app.post('/logout', async (req, res) => {
await redis.del(`session:${req.cookies.sessionId}`);
res.clearCookie('sessionId');
});
이게 Express-session이 내부적으로 하는 일이다. Redis Store를 쓰면 멀티 서버 환경에서도 세션이 공유된다.
API 요청 제한을 구현할 때 Redis의 Sorted Set + TTL 조합이 강력하다. 슬라이딩 윈도우 방식으로 정확한 제어가 가능하다.
async function rateLimit(userId, limit = 10, windowSec = 60) {
const now = Date.now();
const windowStart = now - windowSec * 1000;
const key = `ratelimit:${userId}`;
// 1. 만료된 요청 삭제
await redis.zremrangebyscore(key, 0, windowStart);
// 2. 현재 요청 수 확인
const requestCount = await redis.zcard(key);
if (requestCount >= limit) {
throw new Error('Rate limit exceeded');
}
// 3. 현재 요청 추가
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, windowSec);
return { remaining: limit - requestCount - 1 };
}
Vercel이나 Cloudflare Workers에서 쓰는 Upstash Rate Limiting이 정확히 이 원리다. 메모리에서 동작하니 레이턴시가 1ms 이하다.
채팅, 실시간 알림, WebSocket 브로드캐스트에 쓴다. Kafka보다 가볍고 설정이 간단하다. 메시지 영속성이 필요 없는 실시간 이벤트에 완벽하다.
// Publisher
await redis.publish('notifications', JSON.stringify({
userId: 1000,
message: 'New comment on your post'
}));
// Subscriber
const subscriber = redis.duplicate();
await subscriber.subscribe('notifications', (message) => {
const data = JSON.parse(message);
io.to(`user:${data.userId}`).emit('notification', data);
});
다만 Pub/Sub은 메시지를 저장하지 않는다. 구독자가 오프라인이면 메시지를 못 받는다. 영속성이 필요하면 Redis Streams를 써야 한다.
Redis는 메모리를 쓰기 때문에 무한정 쌓을 수 없다. TTL(Time To Live)로 자동 삭제를 설정하는 게 핵심이다.
// 이메일 인증 코드 (5분 후 삭제)
await redis.setex('verify:abc123', 300, '123456');
// 임시 데이터 (1시간)
await redis.setex('temp:calculation', 3600, result);
// 기존 키에 TTL 추가
await redis.expire('some:key', 86400);
TTL 없이 데이터를 쌓으면 메모리가 가득 차서 OOM(Out of Memory) 에러가 난다. Redis는 maxmemory-policy 설정으로 메모리가 찼을 때 어떻게 할지 정한다. allkeys-lru(가장 오래 사용 안 된 키 삭제)나 volatile-ttl(TTL이 짧은 키 먼저 삭제)을 많이 쓴다.
Redis는 메모리 DB라서 서버가 재시작되면 데이터가 날아간다. 이를 막으려면 영속성 설정이 필요하다.
특정 시점의 스냅샷을 디스크에 저장한다. 5분마다 또는 1000개 변경마다 저장하도록 설정할 수 있다. 복구가 빠르지만, 마지막 스냅샷 이후 데이터는 손실된다.
# redis.conf
save 900 1 # 900초(15분)마다 1개 이상 변경 시
save 300 10 # 5분마다 10개 이상 변경 시
save 60 10000 # 1분마다 10000개 이상 변경 시
모든 쓰기 명령어를 로그로 저장한다. 데이터 손실이 거의 없지만 파일이 커지고 복구가 느리다. appendfsync 옵션으로 매번(always), 1초마다(everysec), OS에 맡김(no) 중 선택할 수 있다.
현업에서는 RDB + AOF 조합을 많이 쓴다. RDB로 빠른 복구, AOF로 데이터 손실 최소화.
Sentinel: Master-Slave 복제 + 자동 장애 조치. Master가 죽으면 Slave를 자동으로 승격시킨다. 고가용성은 확보되지만 수평 확장은 안 된다.
Cluster: 데이터를 여러 노드에 샤딩해서 분산 저장. 수평 확장 가능. 복잡하지만 대용량 데이터 처리에 필수다.
개인 프로젝트나 중소규모는 Sentinel로 충분하다. 트래픽이 커지면 Cluster를 고려하면 된다.
Redis를 EC2에 직접 설치해서 운영하던 시절이 있었다. 모니터링, 백업, 업데이트, 보안 설정... 하나하나가 고통이었다. 관리형 서비스를 쓰면서 인생이 편해졌다.
AWS ElastiCache: 가장 안정적. 자동 백업, 스냅샷, 모니터링 다 해준다. 단점은 비싸고 VPC 안에서만 쓸 수 있다.
Vercel KV (by Upstash): Vercel 프로젝트에 최적화. 엣지에서 쓸 수 있고 설정이 너무 쉽다. Serverless라 쓴 만큼만 과금된다.
Upstash: 서버리스 Redis. HTTP API로 접근 가능해서 엣지 함수(Cloudflare Workers, Vercel Edge)에서 쓸 수 있다. 무료 티어로 시작하기 좋다.
// Upstash with REST API (엣지 함수에서 가능)
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
await redis.set('key', 'value');
개인적으로는 프로토타입은 Upstash, 프로덕션은 ElastiCache를 쓴다.
Redis는 만능이 아니다. 쓰면 안 되는 경우도 명확하다.
1. 대용량 데이터 저장: 메모리는 디스크보다 비싸다. GB 단위 넘어가면 PostgreSQL, MongoDB가 더 경제적이다.
2. 복잡한 쿼리: JOIN, 집계, 트랜잭션이 필요하면 관계형 DB를 써야 한다. Redis는 단순한 key-value 연산에 최적화돼 있다.
3. 절대적인 데이터 내구성: 금융 거래 같은 절대 잃어서는 안 되는 데이터는 PostgreSQL + WAL이 안전하다. Redis는 아무리 영속성을 설정해도 메모리 DB의 한계가 있다.
4. 비용이 중요한 경우: 메모리는 비싸다. 100GB Redis 인스턴스는 같은 용량 PostgreSQL의 5배 이상 비용이 든다.
Redis를 캐시로만 쓰는 건 Ferrari로 마트 가는 것과 같다. 물론 빠르긴 한데, 진짜 성능은 서킷에서 나온다.
핵심 깨달음:
Redis는 스위스 아미 나이프다. 칼도 되고, 가위도 되고, 드라이버도 된다. 단, 언제 어떤 날을 꺼낼지 아는 게 중요하다. 이제 나는 캐시 레이어를 넘어서 Redis를 시스템 설계의 핵심 도구로 쓴다. 랭킹은 Sorted Set, 세션은 Hash + TTL, Rate Limiting은 슬라이딩 윈도우. 적재적소에 쓰면 Redis는 마법이 된다.
When I first encountered Redis, I understood it as "fast cache." The logic was simple: database queries are slow, so putting Redis in between makes them faster. I was satisfied using it that way. Watching API response times drop from 200ms to 20ms, I thought, "Cache is amazing."
Then one day, I had to build a real-time leaderboard system. I needed to display thousands of users' scores sorted in real-time, but PostgreSQL's ORDER BY score DESC LIMIT 10 query kept getting slower. Even with indexes, there was a limit. While struggling with this problem and digging through Redis documentation, I had a realization.
I thought it only stored strings, but it supported Lists, Sets, Sorted Sets, Hashes, and Streams. All with O(1) or O(log N) time complexity. It felt like Redis was saying, "I'm a fast database but I'm frustrated being used only as a cache." From that moment, I saw Redis differently.
Thinking of Redis as just a cache server misses the point. Redis is a multi-purpose data store living in memory. Like a Swiss Army knife, one tool can solve multiple problems.
Understanding the data structures Redis provides reveals why it's more than just a cache.
String: The most basic. Stores text, numbers, JSON, binary data. Used for view counters, flags, token storage.
Hash: Stores objects field by field. Perfect for structured data like user profiles. You can update specific fields without fetching the entire object.
List: Ordered list of strings. Used for recent activity logs, job queues, timeline feeds. LPUSH to add at front, RPOP to pop from back creates a queue.
Set: Unordered collection of unique values. Used for tags, followers, like lists. Intersection (SINTER), union (SUNION), difference (SDIFF) all possible in O(N).
Sorted Set: Sorted collection with scores. Exactly what I was looking for. Optimal for rankings, priority queues, time-based sorting.
// Add scores to leaderboard
await redis.zadd('leaderboard', 1500, 'user:1000');
await redis.zadd('leaderboard', 2300, 'user:2000');
await redis.zadd('leaderboard', 1800, 'user:3000');
// Get top 10 players (descending)
const topPlayers = await redis.zrange('leaderboard', 0, 9, {
REV: true,
WITHSCORES: true
});
// Check specific user's rank
const rank = await redis.zrevrank('leaderboard', 'user:1000');
This code runs in O(log N). Even with a million users, it answers in milliseconds. Implementing this in PostgreSQL requires hundreds of milliseconds for index scans. The key was using the right data structure for the job.
Cache-Aside (Lazy Loading): Most common pattern. When reading data, check cache first; if not found, fetch from DB and store in cache.
async function getUser(userId) {
// 1. Check cache
const cached = await redis.get(`user:${userId}`);
if (cached) return JSON.parse(cached);
// 2. Query DB
const user = await db.users.findUnique({ where: { id: userId } });
// 3. Store in cache (1 hour TTL)
await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
return user;
}
Write-Through: Update cache at write time. Used when data consistency is critical. Downside: slower writes.
Write-Behind (Write-Back): Write to cache first, sync to DB asynchronously later. Used when write performance matters. Downside: data loss if Redis crashes.
The reason to use Redis sessions instead of JWT is simple: immediate invalidation. JWT requires waiting until expiration, but Redis completes logout with one DEL command.
// Login
app.post('/login', async (req, res) => {
const sessionId = crypto.randomUUID();
await redis.setex(
`session:${sessionId}`,
86400, // 24 hours
JSON.stringify({ userId: user.id, role: user.role })
);
res.cookie('sessionId', sessionId, { httpOnly: true });
});
// Authentication middleware
async function authenticate(req, res, next) {
const sessionId = req.cookies.sessionId;
const session = await redis.get(`session:${sessionId}`);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
req.user = JSON.parse(session);
next();
}
When implementing API request limiting, Redis's Sorted Set + TTL combination is powerful. The sliding window approach enables precise control.
async function rateLimit(userId, limit = 10, windowSec = 60) {
const now = Date.now();
const windowStart = now - windowSec * 1000;
const key = `ratelimit:${userId}`;
// 1. Remove expired requests
await redis.zremrangebyscore(key, 0, windowStart);
// 2. Check current request count
const requestCount = await redis.zcard(key);
if (requestCount >= limit) {
throw new Error('Rate limit exceeded');
}
// 3. Add current request
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, windowSec);
return { remaining: limit - requestCount - 1 };
}
This is exactly how Upstash Rate Limiting works on Vercel or Cloudflare Workers. Running in memory, latency is under 1ms.
Used for chat, real-time notifications, WebSocket broadcasts. Lighter than Kafka with simpler setup. Perfect for real-time events that don't need message persistence.
// Publisher
await redis.publish('notifications', JSON.stringify({
userId: 1000,
message: 'New comment on your post'
}));
// Subscriber
const subscriber = redis.duplicate();
await subscriber.subscribe('notifications', (message) => {
const data = JSON.parse(message);
io.to(`user:${data.userId}`).emit('notification', data);
});
However, Pub/Sub doesn't store messages. Offline subscribers miss messages. Use Redis Streams if you need persistence.
Redis uses memory, so you can't accumulate data infinitely. Setting TTL (Time To Live) for automatic deletion is crucial.
// Email verification code (delete after 5 minutes)
await redis.setex('verify:abc123', 300, '123456');
// Temporary data (1 hour)
await redis.setex('temp:calculation', 3600, result);
// Add TTL to existing key
await redis.expire('some:key', 86400);
Without TTL, accumulated data fills memory causing OOM (Out of Memory) errors. Redis's maxmemory-policy setting determines what happens when memory is full. Common choices: allkeys-lru (delete least recently used keys) or volatile-ttl (delete keys with shortest TTL first).
Redis is an in-memory DB, so data disappears when the server restarts. Persistence configuration prevents this.
Saves point-in-time snapshots to disk. Can be configured to save every 5 minutes or after 1000 changes. Fast recovery, but data after the last snapshot is lost.
Logs all write commands. Almost no data loss, but file grows large and recovery is slow. The appendfsync option lets you choose: every time (always), every second (everysec), or leave it to OS (no).
In production, RDB + AOF combination is common. RDB for fast recovery, AOF for minimal data loss.
Sentinel: Master-Slave replication + automatic failover. Automatically promotes Slave when Master dies. Ensures high availability but no horizontal scaling.
Cluster: Shards data across multiple nodes. Horizontal scaling possible. Complex but essential for large-scale data.
For personal projects or small-to-medium scale, Sentinel is sufficient. Consider Cluster when traffic grows.
I once ran Redis on EC2 myself. Monitoring, backups, updates, security settings... each one was painful. Life got easier with managed services.
AWS ElastiCache: Most stable. Handles automatic backups, snapshots, monitoring. Downside: expensive and only works inside VPC.
Vercel KV (by Upstash): Optimized for Vercel projects. Edge-compatible and incredibly easy to set up. Serverless, pay-per-use billing.
Upstash: Serverless Redis. HTTP API access makes it usable in edge functions (Cloudflare Workers, Vercel Edge). Great free tier for getting started.
// Upstash with REST API (works in edge functions)
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
await redis.set('key', 'value');
Personally, I use Upstash for prototypes and ElastiCache for production.
Redis isn't a silver bullet. There are clear cases when you shouldn't use it.
1. Large-scale data storage: Memory is more expensive than disk. Beyond GB-scale, PostgreSQL or MongoDB is more economical.
2. Complex queries: For JOINs, aggregations, transactions, use relational databases. Redis is optimized for simple key-value operations.
3. Absolute data durability: For data that absolutely cannot be lost (like financial transactions), PostgreSQL + WAL is safer. Redis has limits as an in-memory DB regardless of persistence settings.
4. Cost-sensitive scenarios: Memory is expensive. A 100GB Redis instance costs 5x more than the same capacity PostgreSQL.