Redis로 캐싱과 세션 관리: 데이터 타입, 캐싱 전략, Pub/Sub, 트랜잭션, API 응답 캐싱

이 글의 핵심

Redis를 캐시로 붙이는 것 자체는 쉽지만, 인기 키가 동시에 만료돼 DB로 요청이 몰리는 캐시 스탬피드나 캐시와 DB 값이 어긋나는 일관성 문제는 운영에 들어간 뒤에야 드러납니다. Memcached와의 차이, Pipeline과 MULTI/EXEC의 역할 구분, RDB·AOF 지속성의 트레이드오프까지 함께 짚습니다.

Redis란?

Redis(Remote Dictionary Server)는 인메모리 데이터 구조 저장소입니다. 2009년 처음 공개된 뒤 캐시, 세션 저장소, 큐, 순위표 용도로 가장 널리 쓰이는 키-값 저장소 중 하나가 되었습니다. 2024년 라이선스가 오픈소스 라이선스에서 소스 공개 라이선스로 바뀌면서 Linux Foundation 산하의 포크인 Valkey가 등장했고, 이후 Redis 8에서 AGPL 선택지가 다시 추가됐습니다. 이 글의 명령과 개념은 두 제품에 거의 그대로 적용됩니다.

핵심 특징

  1. 빠른 응답

    • 인메모리 저장
    • 명령 실행은 단일 스레드(이벤트 루프)
    • 대부분의 명령이 O(1) 또는 O(log N)
  2. 다양한 데이터 구조

    • String, Hash, List
    • Set, Sorted Set
    • Bitmap, HyperLogLog
  3. 지속성

    • RDB 스냅샷
    • AOF 로그
    • 하이브리드 모드
  4. 고급 기능

    • Pub/Sub
    • 트랜잭션
    • Lua 스크립팅
    • 클러스터링

“싱글 스레드인데 빠르다”는 설명은 절반만 맞습니다. Redis가 빠른 이유는 데이터가 메모리에 있고, 명령 하나가 매우 짧은 시간에 끝나며, 락 없이 명령을 순서대로 처리하기 때문입니다. 대신 명령 하나가 오래 걸리면 그동안 다른 모든 클라이언트가 기다립니다. 수백만 개 키가 있는 인스턴스에서 KEYS *를 실행하거나, 원소가 백만 개인 Set에 SMEMBERS를 호출하거나, 수십 MB짜리 값을 DEL하면 그 순간 서버 전체의 응답이 멈춥니다. 운영 중 Redis 지연이 갑자기 튀었다면 SLOWLOG GET 10으로 느린 명령부터 확인하는 것이 첫 단계입니다. 큰 키 삭제는 백그라운드 스레드에서 메모리를 해제하는 UNLINK를 쓰는 편이 안전합니다.

Redis vs Memcached 비교

항목RedisMemcached
데이터 구조다양 (Hash, List, Set 등)Key-Value만
지속성RDB, AOF없음
복제Master-Replica없음
Pub/Sub지원미지원
트랜잭션MULTI/EXEC없음
멀티스레드싱글 (v6부터 I/O 멀티)멀티스레드
메모리 관리정교함단순함
사용 사례캐시, 세션, 큐, 리더보드단순 캐시

설치

Redis를 시작하는 가장 빠른 방법은 Docker를 사용하는 것입니다. 몇 초 만에 Redis 서버를 실행할 수 있으며, 개발 환경을 오염시키지 않습니다. 프로덕션 환경에서는 전용 Redis 서버나 관리형 서비스(AWS ElastiCache, Redis Cloud)를 사용하는 것이 좋습니다.

Redis 설치

macOS 사용자라면 Homebrew로 설치하는 것이 편리합니다. 설치 후 백그라운드 서비스로 실행되어 자동으로 시작됩니다. Ubuntu에서는 APT 패키지 매니저로 간단히 설치할 수 있습니다.

Docker 방식은 alpine 이미지를 사용하여 용량이 작고(약 30MB) 시작이 빠릅니다. 개발 환경에서는 데이터 지속성이 중요하지 않으므로 볼륨 마운트 없이 사용해도 됩니다.

# macOS
brew install redis
brew services start redis

# Ubuntu
sudo apt update
sudo apt install redis-server
sudo systemctl start redis

# Docker
docker run -d -p 6379:6379 --name redis redis:7-alpine

# 연결 테스트
redis-cli ping
# PONG

Node.js 클라이언트

Node.js에서 Redis를 사용하려면 클라이언트 라이브러리가 필요합니다. ioredis는 Promise 기반이고 Cluster·Sentinel 지원이 성숙해 실무에서 오래 쓰여 왔고, node-redis는 Redis 측이 관리하는 공식 클라이언트로 v4부터 Promise 기반 API로 바뀌었으며 새 기능 지원이 빠른 편입니다. 이 글의 예제는 ioredis 문법을 씁니다. 두 라이브러리는 명령 이름의 대소문자(setEx 대 setex)와 옵션 전달 방식이 달라서, 예제를 옮겨 쓸 때 가장 흔히 걸리는 부분입니다.

# ioredis (권장)
npm install ioredis

# node-redis
npm install redis

기본 사용법

연결

import Redis from 'ioredis';

const redis = new Redis({
  host: 'localhost',
  port: 6379,
  password: 'your-password',
  db: 0,
  retryStrategy: (times) => {
    const delay = Math.min(times * 50, 2000);
    return delay;
  }
});

redis.on('connect', () => console.log('Redis Connected'));
redis.on('error', (err) => console.error('Redis Error:', err));

retryStrategy는 연결이 끊겼을 때 다음 재접속까지 기다릴 밀리초를 돌려주는 함수입니다. 위 코드는 재시도할 때마다 50ms씩 늘리다가 2초에서 멈춥니다. error 이벤트 핸들러를 등록하지 않으면 연결 오류가 처리되지 않은 이벤트로 로그에 계속 찍히므로 항상 등록해 두는 것이 좋습니다. 또 ioredis는 연결이 끊긴 동안 호출된 명령을 오프라인 큐에 쌓아 두었다가 재연결되면 보냅니다. 편리하지만 Redis가 오래 죽어 있으면 요청이 에러 없이 한참 멈춰 있는 것처럼 보이므로, 캐시 용도라면 maxRetriesPerRequest를 작게 잡아 빨리 실패하고 DB로 넘어가게 하는 편이 낫습니다.

String 데이터 타입

// SET/GET
await redis.set('user:1:name', '홍길동');
const name = await redis.get('user:1:name');

// TTL 설정 (초)
await redis.setex('session:abc', 3600, 'user-data');

// NX (존재하지 않을 때만)
await redis.set('key', 'value', 'NX');

// INCR/DECR (원자적 증감)
await redis.incr('page:views');
await redis.incrby('score', 10);
await redis.decr('stock:item:1');

// MGET/MSET (배치)
await redis.mset('key1', 'val1', 'key2', 'val2');
const values = await redis.mget('key1', 'key2');

Hash 데이터 타입

// HSET/HGET
await redis.hset('user:1', {
  name: '홍길동',
  email: '[email protected]',
  age: 25
});

const email = await redis.hget('user:1', 'email');
const user = await redis.hgetall('user:1');

// HINCRBY
await redis.hincrby('user:1', 'login_count', 1);

// HMSET (여러 필드)
await redis.hmset('user:2', 'name', '김철수', 'age', 30);

Redis 4.0부터 HSET이 여러 필드를 한 번에 받으므로 HMSET은 폐기 예정(deprecated) 명령입니다. 새 코드에서는 위의 hset 형태만 쓰면 됩니다. 한 가지 주의할 점은 Redis의 값이 모두 문자열이라는 것입니다. age: 25로 넣어도 hgetall은 { age: '25' }를 돌려주므로, 숫자 비교나 계산 전에 변환해야 합니다. 객체를 JSON 문자열 하나로 저장할지 Hash로 저장할지는 접근 방식에 따라 고릅니다. 필드 하나만 자주 읽거나 갱신한다면 Hash가 유리하고, 항상 통째로 읽고 쓴다면 JSON 문자열이 단순합니다. Hash의 필드에는 개별 TTL을 줄 수 없다는 제약도 있습니다(Redis 7.4부터 HEXPIRE가 추가되었습니다).

List 데이터 타입

// LPUSH/RPUSH (왼쪽/오른쪽 추가)
await redis.lpush('queue:jobs', 'job1', 'job2');
await redis.rpush('timeline', 'post1', 'post2');

// LPOP/RPOP (제거 및 반환)
const job = await redis.lpop('queue:jobs');

// LRANGE (범위 조회)
const posts = await redis.lrange('timeline', 0, 9);

// LLEN (길이)
const length = await redis.llen('queue:jobs');

// BLPOP (블로킹 팝)
const item = await redis.blpop('queue:jobs', 5); // 5초 대기

List를 작업 큐로 쓸 때 LPUSH와 RPOP(또는 BRPOP)을 짝지으면 먼저 넣은 작업이 먼저 나옵니다. 예제처럼 LPUSH와 LPOP을 짝지으면 스택이 되어 나중에 넣은 작업이 먼저 처리되므로, 방향을 헷갈리지 않아야 합니다. 더 큰 함정은 BLPOP이 연결을 점유한다는 점입니다. 블로킹 명령이 대기하는 동안 그 연결로는 다른 명령이 나가지 않으므로, 애플리케이션 전체가 공유하는 연결에서 BLPOP을 호출하면 다른 요청들이 최대 5초씩 막힙니다. 워커는 전용 연결을 따로 만들어야 합니다. 또 POP으로 꺼낸 순간 작업이 List에서 사라지므로, 처리 중에 워커가 죽으면 작업이 유실됩니다. 이런 경우에는 LMOVE로 처리 중 목록에 옮겨 두거나, 뒤에서 다루는 Streams를 쓰는 것이 안전합니다.

Set 데이터 타입

// SADD/SREM (추가/제거)
await redis.sadd('tags:post:1', 'javascript', 'nodejs', 'redis');
await redis.srem('tags:post:1', 'redis');

// SMEMBERS (모든 멤버)
const tags = await redis.smembers('tags:post:1');

// SISMEMBER (존재 확인)
const exists = await redis.sismember('tags:post:1', 'nodejs');

// SINTER/SUNION/SDIFF (집합 연산)
await redis.sadd('set1', 'a', 'b', 'c');
await redis.sadd('set2', 'b', 'c', 'd');

const intersection = await redis.sinter('set1', 'set2'); // ['b', 'c']
const union = await redis.sunion('set1', 'set2'); // ['a', 'b', 'c', 'd']
const diff = await redis.sdiff('set1', 'set2'); // ['a']

Sorted Set 데이터 타입

// ZADD (스코어와 함께 추가)
await redis.zadd('leaderboard', 100, 'user1', 200, 'user2', 150, 'user3');

// ZRANGE (순위 조회)
const top10 = await redis.zrange('leaderboard', 0, 9, 'WITHSCORES');

// ZREVRANGE (역순)
const topPlayers = await redis.zrevrange('leaderboard', 0, 2);

// ZINCRBY (스코어 증가)
await redis.zincrby('leaderboard', 50, 'user1');

// ZRANK (순위 확인)
const rank = await redis.zrank('leaderboard', 'user1');

// ZREM (제거)
await redis.zrem('leaderboard', 'user3');

캐싱 전략

캐시 패턴

// 1. Cache-Aside (Lazy Loading)
async function getUser(userId) {
  const cacheKey = `user:${userId}`;
  
  // 캐시 확인
  let user = await redis.get(cacheKey);
  if (user) {
    return JSON.parse(user);
  }
  
  // DB 조회
  user = await db.users.findById(userId);
  
  // 캐시 저장 (1시간)
  await redis.setex(cacheKey, 3600, JSON.stringify(user));
  
  return user;
}

// 2. Write-Through (쓰기 시 캐시 갱신)
async function updateUser(userId, data) {
  // DB 업데이트
  const user = await db.users.update(userId, data);
  
  // 캐시 갱신
  await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
  
  return user;
}

// 3. Write-Behind (비동기 쓰기)
async function updateUserAsync(userId, data) {
  // 캐시 즉시 갱신
  await redis.setex(`user:${userId}`, 3600, JSON.stringify(data));
  
  // DB는 큐에 넣어 나중에 업데이트
  await redis.lpush('queue:db-updates', JSON.stringify({ userId, data }));
}

세 패턴은 “누가 진실의 원천(source of truth)인가”에서 갈립니다. Cache-Aside와 Write-Through는 DB가 원천이고 캐시는 사본입니다. Write-Behind는 한동안 캐시가 원천이 되므로, 큐에 쌓인 업데이트가 DB에 반영되기 전에 Redis가 재시작되거나 큐 처리 워커가 실패하면 쓰기가 사라집니다. 위 예제는 부분 업데이트 data를 그대로 캐시에 덮어써서 사용자 객체의 나머지 필드가 사라지는 문제도 있습니다. 쓰기 지연을 줄이는 대가가 크기 때문에, 조회수나 좋아요 수처럼 일부 유실을 감수할 수 있는 데이터에만 쓰는 편이 좋습니다.

Write-Through 쪽도 동시성 문제가 있습니다. 두 요청이 거의 동시에 같은 사용자를 수정하면, DB에는 B가 나중에 반영됐는데 캐시에는 A의 값이 나중에 기록되는 순서 역전이 생길 수 있고, 이 경우 TTL이 끝날 때까지 캐시가 틀린 값을 들고 있습니다. 그래서 실무에서는 쓰기 시 캐시를 갱신하기보다 삭제하고, 다음 읽기가 DB에서 새로 채우게 하는 방식(뒤의 updateUser 예제)이 더 흔히 쓰입니다.

캐시 무효화

// 단일 키 삭제
await redis.del('user:1');

// 패턴 매칭 삭제
const keys = await redis.keys('user:*');
if (keys.length > 0) {
  await redis.del(...keys);
}

// SCAN을 사용한 안전한 삭제 (프로덕션)
async function deleteByPattern(pattern) {
  let cursor = '0';
  do {
    const [newCursor, keys] = await redis.scan(
      cursor,
      'MATCH',
      pattern,
      'COUNT',
      100
    );
    cursor = newCursor;
    
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  } while (cursor !== '0');
}

await deleteByPattern('session:*');

KEYS는 전체 키 공간을 한 번에 훑는 동안 서버를 붙잡고 있으므로, 키가 많은 운영 인스턴스에서는 몇 초씩 모든 요청을 멈추게 할 수 있습니다. SCAN은 커서로 조금씩 나눠 훑기 때문에 그 사이에 다른 명령이 처리됩니다. 대신 SCAN은 순회 중에 추가·삭제된 키를 빠뜨리거나 같은 키를 두 번 돌려줄 수 있고, COUNT는 “대략 이만큼씩 보라”는 힌트일 뿐 반환 개수를 보장하지 않아 빈 배열이 돌아와도 커서가 0이 될 때까지 계속 돌아야 합니다. Cluster 환경이라면 SCAN은 연결된 노드 하나의 키만 보므로 노드마다 따로 실행해야 합니다.

패턴 삭제가 자주 필요하다면 설계를 다시 보는 편이 낫습니다. 예를 들어 “사용자 123과 관련된 캐시 전부 무효화”가 필요하다면, 관련 키 이름을 Set에 모아 두거나, 키에 버전 번호를 넣어(user:123:v7:profile) 버전만 올리면 옛 키들은 TTL로 자연히 사라지게 하는 방법이 있습니다.

Pub/Sub

// Publisher
const publisher = new Redis();

await publisher.publish('news:tech', JSON.stringify({
  title: 'Breaking News',
  content: 'Something happened'
}));

// Subscriber
const subscriber = new Redis();

subscriber.subscribe('news:tech', (err, count) => {
  console.log(`Subscribed to ${count} channels`);
});

subscriber.on('message', (channel, message) => {
  console.log(`${channel}: ${message}`);
  const data = JSON.parse(message);
  // 처리 로직
});

// 패턴 구독
subscriber.psubscribe('news:*', (err, count) => {
  console.log(`Subscribed to ${count} patterns`);
});

Pub/Sub은 “지금 연결된 구독자에게만” 전달하고 메시지를 저장하지 않습니다. 구독자가 재시작하거나 네트워크가 잠깐 끊긴 동안 발행된 메시지는 그대로 사라집니다. 실시간 알림이나 캐시 무효화 브로드캐스트처럼 한두 건 놓쳐도 괜찮은 용도에는 충분하지만, 주문 처리처럼 빠짐없이 처리해야 하는 작업이라면 Redis Streams를 씁니다. Streams는 메시지를 로그로 남기고, 컨슈머 그룹이 나눠 읽은 뒤 XACK로 처리 완료를 표시하며, 확인되지 않은 메시지는 다른 컨슈머가 가져가 재처리할 수 있습니다. 어느 쪽이든 같은 메시지가 두 번 처리될 수 있다는 전제로 소비 로직을 멱등하게 만들어야 합니다.

await redis.xadd('orders', '*', 'orderId', '123');                       // 로그에 추가
await redis.xgroup('CREATE', 'orders', 'workers', '$', 'MKSTREAM');     // 그룹 생성 (최초 1회)
const res = await redis.xreadgroup('GROUP', 'workers', 'w1', 'COUNT', 10, 'BLOCK', 5000, 'STREAMS', 'orders', '>');
// 처리 후: await redis.xack('orders', 'workers', messageId);

XGROUP CREATE는 그룹이 이미 있으면 BUSYGROUP Consumer Group name already exists 에러를 던지므로, 애플리케이션 시작 시 매번 호출하는 코드라면 이 에러를 무시하도록 처리해야 합니다. '$'는 “지금 이후에 들어오는 메시지부터”라는 뜻이라, 그룹을 만들기 전에 쌓인 메시지를 처리하려면 '0'을 줍니다. 스트림은 자동으로 줄어들지 않으므로 XADD orders MAXLEN ~ 100000 * ...처럼 길이 상한을 두지 않으면 메모리를 계속 차지합니다.

트랜잭션

// MULTI/EXEC
const multi = redis.multi();

multi.set('key1', 'value1');
multi.set('key2', 'value2');
multi.incr('counter');

const results = await multi.exec();

// Pipeline (트랜잭션 아님, 배치)
const pipeline = redis.pipeline();

pipeline.set('key1', 'value1');
pipeline.set('key2', 'value2');
pipeline.get('key1');

const pipeResults = await pipeline.exec();

// WATCH (낙관적 락)
await redis.watch('balance:1');

const balance = await redis.get('balance:1');
if (parseInt(balance) >= 100) {
  const multi = redis.multi();
  multi.decrby('balance:1', 100);
  multi.incrby('balance:2', 100);
  const txResult = await multi.exec();  // WATCH한 키가 바뀌었으면 null
  if (txResult === null) {
    // 다른 클라이언트가 먼저 수정함: 처음부터 다시 시도
  }
} else {
  await redis.unwatch();
}

Redis의 MULTI/EXEC는 관계형 DB의 트랜잭션과 다릅니다. 명령들이 중간에 다른 클라이언트의 명령이 끼어들지 않고 연속으로 실행된다는 격리는 보장하지만, 중간 명령 하나가 실패해도 롤백하지 않습니다. 예를 들어 문자열 키에 INCR을 걸어 ERR value is not an integer 에러가 나도 앞뒤 명령은 그대로 실행됩니다. ioredis의 exec()가 [에러, 결과] 쌍의 배열을 돌려주는 이유이며, 각 원소의 에러를 직접 확인해야 합니다.

WATCH는 감시한 키가 EXEC 전에 바뀌면 트랜잭션 전체를 취소하고 null을 돌려주는 낙관적 락입니다. 위 코드처럼 null을 확인해 재시도하지 않으면 이체가 조용히 무시됩니다. 더 조심해야 할 점은 WATCH가 연결 단위 상태라는 것입니다. 웹 서버처럼 하나의 ioredis 연결을 여러 요청이 공유하면, 한 요청의 WATCH와 MULTI 사이에 다른 요청의 명령이 같은 연결로 섞여 들어가 감시가 엉뚱하게 풀리거나 걸립니다. 이런 읽기-판단-쓰기 로직은 전용 연결을 쓰거나, 판단까지 서버에서 한 번에 실행되는 Lua 스크립트로 옮기는 것이 안전합니다.

실전 사례: API 응답 캐싱

가장 흔한 Redis 사용 사례는 API 응답 캐싱입니다. 메모리에서 키 하나를 읽는 비용은 디스크와 쿼리 계획을 거치는 DB 조회보다 훨씬 작으므로, 복잡한 집계 쿼리나 조인이 많은 쿼리일수록 효과가 큽니다. 다만 개선 폭은 원래 쿼리가 얼마나 무거웠는지와 캐시 적중률에 달려 있으므로, 도입 전후의 응답 시간과 적중률을 직접 측정해 판단해야 합니다.

캐시 aside 패턴

캐시 aside는 가장 일반적인 캐싱 패턴입니다. 먼저 캐시를 확인하며, 없으면 DB에서 조회한 후 캐시에 저장합니다. 코드는 간단하지만 캐시 일관성을 유지하기 위해 업데이트 시 캐시를 무효화해야 합니다.

async function getUser(userId) {
  const cacheKey = `user:${userId}`;
  
  // 1. 캐시 확인
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log('Cache hit');
    return JSON.parse(cached);
  }
  
  // 2. DB 조회
  console.log('Cache miss');
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  
  // 3. 캐시 저장 (1시간 TTL)
  await redis.setex(cacheKey, 3600, JSON.stringify(user));
  
  return user;
}

// 업데이트 시 캐시 무효화
async function updateUser(userId, data) {
  await db.query('UPDATE users SET ... WHERE id = $1', [userId]);
  await redis.del(`user:${userId}`);
}

이 패턴은 읽기가 많고 쓰기가 적은 경우에 적합합니다. 캐시 히트율이 높을수록 DB 부하가 줄어듭니다.

처음 이 패턴을 붙일 때 흔히 놓치는 경우가 두 가지 있습니다. 하나는 if (cached) 검사입니다. 존재하지 않는 사용자를 조회하면 DB가 null을 돌려주고, 이를 JSON.stringify(null)인 "null"로 캐시하면 다음 조회에서 JSON.parse가 null을 돌려주므로 괜찮아 보입니다. 하지만 캐시하지 않도록 분기해 두면, 없는 ID로 반복 요청이 들어올 때마다 캐시를 지나쳐 DB를 두드리게 됩니다(cache penetration). 없는 결과도 짧은 TTL로 캐시해 두는 것이 방어책입니다. 다른 하나는 updateUser의 순서입니다. DB를 수정한 뒤 캐시를 지우는데, 그 사이에 다른 요청이 옛 값을 DB에서 읽어 캐시에 다시 넣는 경합이 드물게 생깁니다. 대부분은 TTL이 이 틈을 메워 주지만, 일관성이 중요하다면 삭제를 짧은 지연 후 한 번 더 하는 방식(delayed double delete)도 쓰입니다.

실시간 리더보드

Sorted Set을 사용하면 실시간 게임 리더보드를 쉽게 구현할 수 있습니다. 점수 업데이트와 순위 조회가 모두 O(log N) 시간 복잡도로 매우 빠릅니다.

// 점수 추가/업데이트
await redis.zadd('leaderboard:2026-04', 1500, 'player:홍길동');
await redis.zadd('leaderboard:2026-04', 2300, 'player:김철수');

// 점수 증가
await redis.zincrby('leaderboard:2026-04', 100, 'player:홍길동');

// 상위 10명 조회 (내림차순)
const topPlayers = await redis.zrevrange('leaderboard:2026-04', 0, 9, 'WITHSCORES');

// 특정 플레이어 순위 조회
const rank = await redis.zrevrank('leaderboard:2026-04', 'player:홍길동');
console.log(`순위: ${rank + 1}위`);

// 특정 플레이어 점수 조회
const score = await redis.zscore('leaderboard:2026-04', 'player:홍길동');

Sorted Set은 점수 기준 정렬 상태를 항상 유지하므로, 플레이어 수가 많아도 상위 N명 조회와 특정 플레이어 순위 조회가 빠릅니다. 같은 점수의 멤버는 멤버 이름의 사전순으로 정렬되므로, “먼저 달성한 사람이 위”처럼 동점 처리 규칙이 필요하다면 점수에 시각 정보를 섞어(예: 점수 × 10^10 + (최대시각 − 달성시각)) 인코딩해야 합니다. zrevrank는 멤버가 없으면 null을 돌려주므로 위 코드의 rank + 1은 이 경우 1이 되어 버립니다. 점수는 배정밀도 부동소수로 저장되므로 2^53을 넘는 정수는 정확히 표현되지 않는다는 점도 알아 둘 만합니다. 월별 키(leaderboard:2026-04)로 나눈 것은 기간이 끝난 순위표를 통째로 만료시키거나 보관하기 쉽게 하기 위한 설계입니다.

성능 최적화

Pipeline으로 RTT 최소화

Network Round-Trip Time(RTT)은 Redis 성능의 주요 병목입니다. Pipeline을 사용하면 여러 명령을 한 번에 전송하여 RTT를 줄일 수 있습니다. 10,000개의 SET 명령을 개별 실행하면 10,000번의 RTT가 필요하지만, Pipeline은 1번의 RTT만 필요합니다.

// 1. Pipeline 사용
const pipeline = redis.pipeline();
for (let i = 0; i < 10000; i++) {
  pipeline.set(`key:${i}`, `value:${i}`);
}
await pipeline.exec();

// 2. 연결 옵션 (ioredis는 풀 없이 연결 하나를 파이프라이닝으로 공유)
const redisClient = new Redis({
  maxRetriesPerRequest: 3,
  enableReadyCheck: false,
  lazyConnect: true
});

// 3. 적절한 Eviction 정책
// redis.conf
// maxmemory 2gb
// maxmemory-policy allkeys-lru

ioredis는 기본적으로 연결 하나에 여러 요청을 연달아 보내고 응답을 순서대로 짝지으므로, 일반 명령에는 별도의 연결 풀이 필요하지 않습니다. 풀이나 전용 연결이 필요한 것은 앞에서 본 블로킹 명령(BLPOP), 구독(SUBSCRIBE), WATCH 트랜잭션처럼 연결 상태를 바꾸는 경우입니다. 파이프라인을 너무 크게 만드는 것도 주의해야 합니다. 10,000개 명령을 한 번에 보내면 Redis는 모든 응답을 메모리에 모아 두었다가 보내므로, 수십만 개 단위라면 1,000개 정도씩 나눠 보내는 편이 서버와 클라이언트 메모리 모두에 안전합니다.

메모리 관리

Redis는 인메모리 저장소이므로 메모리 관리가 중요합니다. maxmemory를 설정하고 적절한 eviction 정책을 선택하세요. LRU(Least Recently Used)는 캐시에, LFU(Least Frequently Used)는 접근 빈도가 중요한 경우에 적합합니다.

키 설계

키 이름은 짧고 명확하게 만드세요. user:1:profile 같은 계층적 구조를 사용하면 관리가 쉽습니다. 너무 긴 키는 메모리를 낭비하고 성능을 저하시킵니다.

레이트 리밋: INCR과 만료를 원자적으로

API 호출 제한은 Redis가 특히 잘 맞는 용도입니다. 가장 단순한 고정 윈도우 방식은 “사용자별·분 단위 키를 INCR하고 처음 만들어질 때만 만료를 건다”인데, INCR 다음 줄에서 EXPIRE를 따로 호출하면 그 사이에 프로세스가 죽거나 네트워크가 끊겼을 때 만료 없는 키가 남아 그 사용자가 영원히 막힐 수 있습니다. 두 명령을 MULTI로 묶거나 Lua 스크립트 하나로 실행해 원자성을 보장해야 합니다. 분 경계에서 요청이 두 배까지 몰리는 고정 윈도우의 한계가 문제라면, Sorted Set에 요청 시각을 쌓고 오래된 것을 지우는 슬라이딩 윈도우를 씁니다. 그리고 제한 기준이 사용자인지, IP인지, API 키인지를 먼저 정해 두어야 공유 IP 뒤의 사용자들이 한꺼번에 막히는 일을 피할 수 있습니다.

async function rateLimit(userId: string, limit = 100) {
  const key = `rate:${userId}:${Math.floor(Date.now() / 60000)}`;
  // ioredis: exec()는 [에러, 결과] 쌍의 배열을 돌려줌
  const results = await redis.multi().incr(key).expire(key, 60).exec();
  const count = results![0][1] as number;
  if (count > limit) throw new Error('Rate limit exceeded');
  return { remaining: limit - count };
}

메모리가 가득 찼을 때: maxmemory-policy

Redis는 maxmemory에 도달하면 maxmemory-policy에 따라 동작합니다. 순수 캐시 용도라면 allkeys-lru(또는 allkeys-lfu)로 오래 안 쓴 키부터 지우게 하고, 세션처럼 지워지면 안 되는 데이터와 캐시를 한 인스턴스에 섞어 쓴다면 캐시 키에만 TTL을 달고 volatile-lru로 TTL 있는 키만 지우게 합니다. 기본값 noeviction은 메모리가 차면 쓰기 명령을 에러로 거부하므로, 캐시 서버를 이 설정 그대로 두면 트래픽이 몰리는 순간 애플리케이션 쓰기가 실패하기 시작합니다. “중요한 키가 사라졌다”는 문제는 대개 정책보다 설계 문제라서, 성격이 다른 데이터는 인스턴스를 나누는 편이 깔끔합니다.

주의사항

캐시 스탬피드

캐시가 만료된 순간 여러 요청이 동시에 DB를 조회하는 문제입니다. 트래픽이 많은 인기 키 하나가 만료되면, 그 순간 들어온 수백 개의 요청이 모두 캐시 미스를 보고 같은 무거운 쿼리를 동시에 실행해 DB가 느려지고, 느려진 만큼 더 많은 요청이 몰리는 악순환이 생깁니다. 가장 흔한 해결책은 한 요청만 DB를 읽도록 짧은 락을 거는 것입니다.

async function getWithLock(key, loader, ttl = 3600) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  // 락 획득 시도: 없을 때만(NX) 10초 동안(PX) 설정
  const locked = await redis.set(`lock:${key}`, '1', 'PX', 10000, 'NX');
  if (!locked) {
    await new Promise((r) => setTimeout(r, 50));  // 잠시 후 캐시 재확인
    return getWithLock(key, loader, ttl);
  }
  try {
    const value = await loader();
    await redis.set(key, JSON.stringify(value), 'EX', ttl);
    return value;
  } finally {
    await redis.del(`lock:${key}`);
  }
}

락에 만료 시간(PX)을 반드시 거는 이유는, 락을 잡은 프로세스가 죽어도 락이 영원히 남지 않게 하기 위해서입니다. 다만 로더가 락 만료 시간보다 오래 걸리면 다른 요청이 락을 잡은 뒤 원래 요청의 finally가 남의 락을 지우는 문제가 생기므로, 엄밀하게는 락 값에 고유 토큰을 넣고 값이 같을 때만 지우는 Lua 스크립트를 씁니다. 이런 복잡도가 부담스럽다면 TTL에 무작위 값을 더해 인기 키들의 만료 시점을 흩뜨리거나, 만료 전에 백그라운드에서 미리 갱신하는 방식(확률적 조기 만료)이 더 단순한 대안입니다. 재시도가 무한히 이어지지 않도록 횟수 제한을 두는 것도 잊지 말아야 합니다.

데이터 일관성

Redis는 캐시이므로 데이터베이스와 불일치할 수 있습니다. 중요한 데이터는 항상 DB를 source of truth로 사용하세요.

지속성 트레이드오프

RDB는 주기적으로 전체 메모리를 스냅샷으로 저장하므로 파일이 작고 재시작이 빠르지만, 마지막 스냅샷 이후의 쓰기는 장애 시 사라집니다. AOF는 모든 쓰기 명령을 로그로 남기며, appendfsync everysec(기본값)이면 최대 약 1초 분량의 쓰기만 잃습니다. 매 쓰기마다 디스크에 동기화하는 always는 가장 안전하지만 쓰기 처리량이 크게 떨어집니다. Redis 4.0 이후에는 AOF 재작성 시 RDB 형식을 앞부분에 섞는 하이브리드 방식으로 두 장점을 절충할 수 있습니다.

지속성 설정에서 운영 중 자주 만나는 문제는 RDB 스냅샷의 fork()입니다. Redis는 스냅샷을 만들 때 자식 프로세스를 fork하는데, 쓰기가 많으면 copy-on-write로 복제되는 페이지가 늘어 메모리 사용량이 순간적으로 크게 늘 수 있습니다. 메모리가 빠듯한 서버에서 maxmemory를 물리 메모리 가까이 잡아 두면 이때 OOM으로 프로세스가 죽거나, 로그에 Can't save in background: fork: Cannot allocate memory가 남습니다. 순수 캐시 용도라면 지속성을 끄는 것(save "", appendonly no)도 합리적인 선택입니다. 캐시가 비어도 DB에서 다시 채우면 되기 때문입니다. 다만 재시작 직후 모든 요청이 DB로 몰리는 콜드 스타트를 DB가 견딜 수 있는지는 미리 확인해 두어야 합니다.


자주 묻는 질문 (FAQ)

Q. Pipeline과 MULTI/EXEC는 무엇이 다른가요?

A. Pipeline은 여러 명령을 한 번에 보내 네트워크 왕복(RTT)을 줄이는 배치일 뿐 트랜잭션이 아니므로, 중간에 다른 클라이언트의 명령이 끼어들 수 있습니다. MULTI/EXEC는 묶인 명령을 순서대로 한꺼번에 실행하며, 잔액 이체처럼 읽은 값에 따라 쓰기를 결정해야 할 때는 WATCH로 키를 감시해 그 사이 값이 바뀌면 트랜잭션이 실행되지 않게 합니다. 단순히 대량 쓰기를 빠르게 하려면 Pipeline, 원자성이 필요하면 MULTI/EXEC를 고르면 됩니다.


같이 보면 좋은 글