junyeokk
Blog
Database·2024. 11. 12

ioredis

Node.js에서 Redis를 사용하려면 클라이언트 라이브러리가 필요하다. 가장 오래된 선택지는 redis (node-redis)인데, 이 라이브러리는 오랫동안 콜백 기반 API만 제공했고, Redis의 고급 기능을 사용하기 불편했다. ioredis는 이런 문제를 해결하기 위해 만들어진 라이브러리로, 처음부터 Promise 기반으로 설계되었고, Cluster·Sentinel·Pipeline·Lua 스크립팅 같은 Redis의 핵심 기능을 네이티브로 지원한다.

참고로 node-redis도 v4부터 Promise를 지원하기 시작했지만, ioredis는 이미 그 이전부터 Promise 기반이었고 커뮤니티 생태계가 넓어서 여전히 많이 사용된다.


설치와 기본 연결

bash
npm install ioredis
typescript
import Redis from 'ioredis';

const redis = new Redis({
  host: '127.0.0.1',
  port: 6379,
  password: 'my-password',
  db: 0,
});

new Redis()를 호출하면 내부적으로 TCP 연결이 생성된다. 연결이 아직 준비되지 않은 상태에서 명령을 보내면 ioredis가 자동으로 큐에 넣어두었다가 연결이 완료된 후 순서대로 실행한다. 이걸 "offline queue"라고 하는데, 덕분에 연결 완료를 기다리는 코드를 따로 작성하지 않아도 된다.

typescript
// 연결이 완료되기 전에 호출해도 안전하다
redis.set('key', 'value');
const result = await redis.get('key'); // 'value'

연결 옵션 중 알아두면 유용한 것들:

옵션설명기본값
hostRedis 서버 호스트'127.0.0.1'
port포트6379
password인증 비밀번호undefined
db사용할 DB 번호 (0~15)0
maxRetriesPerRequest요청당 재시도 횟수20
enableReadyCheck연결 후 INFO 명령으로 상태 확인true
lazyConnectconnect() 호출 전까지 연결하지 않음false
connectTimeout연결 타임아웃 (ms)10000

db 옵션은 테스트 환경에서 특히 유용하다. 테스트 워커마다 다른 DB 번호를 할당하면 데이터가 섞이지 않는다.

typescript
const workerId = Number(process.env.JEST_WORKER_ID ?? 0);

const redis = new Redis({
  host: 'localhost',
  port: 6379,
  db: workerId,  // 워커별 격리
});

기본 명령어

ioredis는 Redis 명령어와 1:1로 매핑되는 메서드를 제공한다. 모든 메서드는 Promise를 반환한다.

String

가장 기본적인 key-value 저장이다.

typescript
await redis.set('name', 'chloe');
const name = await redis.get('name'); // 'chloe'

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

// set + EX 옵션 (동일한 동작)
await redis.set('session:abc', 'user-data', 'EX', 3600);

// 키 삭제
await redis.del('name');

// 여러 키 한 번에 조회
const values = await redis.mget('key1', 'key2', 'key3');
// ['value1', 'value2', null]  — 없는 키는 null

setex는 세션 토큰이나 캐시처럼 만료가 필요한 데이터에 자주 사용한다. setEX 옵션을 붙이는 것과 동일하지만, setex가 의도가 더 명확하다.

List

Redis의 List는 양쪽에서 push/pop이 가능한 double-ended queue이다.

typescript
// 왼쪽에 삽입 (가장 앞에 추가)
await redis.lpush('queue', 'item1', 'item2');

// 오른쪽에 삽입 (가장 뒤에 추가)
await redis.rpush('queue', 'item3');

// 범위 조회 (0부터 -1은 전체)
const items = await redis.lrange('queue', 0, -1);
// ['item2', 'item1', 'item3']

// 범위 밖의 요소 제거 (최신 100개만 유지)
await redis.ltrim('queue', 0, 99);

lpush + ltrim 조합은 "최근 N개 항목 유지" 패턴에 자주 쓰인다. 예를 들어 최근 피드 목록을 유지할 때, 새 항목을 lpush로 앞에 넣고 ltrim으로 뒤를 잘라내면 된다.

Set

중복 없는 값의 집합이다. 멤버 존재 여부를 O(1)로 확인할 수 있다.

typescript
// 멤버 추가
await redis.sadd('tags', 'javascript', 'typescript', 'nodejs');

// 멤버 존재 여부 (1 = 있음, 0 = 없음)
const exists = await redis.sismember('tags', 'javascript'); // 1

// 멤버 제거
await redis.srem('tags', 'nodejs');

"이 유저가 이미 좋아요를 눌렀는가?" 같은 체크에 적합하다. 관계형 DB에서 SELECT EXISTS를 날리는 것보다 훨씬 빠르다.

Sorted Set

Set에 score가 붙어서 자동 정렬되는 자료구조이다. 랭킹, 트렌드, 리더보드에 핵심적으로 사용된다.

typescript
// score와 함께 멤버 추가
await redis.zadd('trending', 100, 'post:1', 50, 'post:2', 200, 'post:3');

// score 증가
await redis.zincrby('trending', 1, 'post:1'); // post:1의 score가 101이 됨

// 높은 score 순으로 조회 (상위 10개)
const top10 = await redis.zrevrange('trending', 0, 9);
// ['post:3', 'post:1', 'post:2']

// score도 함께 조회
const withScores = await redis.zrevrange('trending', 0, 9, 'WITHSCORES');
// ['post:3', '200', 'post:1', '101', 'post:2', '50']

// 특정 멤버의 score 조회
const score = await redis.zscore('trending', 'post:1'); // '101'

// 멤버 제거
await redis.zrem('trending', 'post:2');

zadd로 넣고 zincrby로 점수를 올리고 zrevrange로 상위 항목을 가져오는 패턴이 트렌드 시스템의 기본 뼈대다. 내부적으로 skip list 자료구조를 사용하기 때문에 삽입과 조회 모두 O(log N)이다.


Pipeline

Redis는 요청-응답 방식으로 동작하기 때문에, 명령 10개를 보내면 네트워크 라운드트립이 10번 발생한다. Pipeline은 여러 명령을 하나의 요청으로 묶어서 보내고, 응답도 한 번에 받는다.

일반 실행: Pipeline: CMD1 → REPLY1 CMD1 ─┐ CMD2 → REPLY2 CMD2 ─┤── 한 번에 전송 CMD3 → REPLY3 CMD3 ─┘ REPLY1 ─┐ REPLY2 ─┤── 한 번에 수신 REPLY3 ─┘
typescript
일반 실행:         Pipeline:
CMD1 → REPLY1     CMD1 ─┐
CMD2 → REPLY2     CMD2 ─┤── 한 번에 전송
CMD3 → REPLY3     CMD3 ─┘
                  REPLY1 ─┐
                  REPLY2 ─┤── 한 번에 수신
                  REPLY3 ─┘

exec()의 반환값은 [error, result] 튜플의 배열이다. 각 명령이 개별적으로 성공/실패할 수 있기 때문에 에러가 명령별로 분리된다.

Pipeline의 특성

Pipeline은 트랜잭션이 아니다. 명령들이 원자적으로 실행된다는 보장이 없다. 다른 클라이언트의 명령이 중간에 끼어들 수 있다. 원자성이 필요하면 multi/exec (트랜잭션)을 사용해야 한다.

typescript
const pipeline = redis.pipeline();

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

const results = await pipeline.exec();
// [
//   [null, 'OK'],      // [error, result]
//   [null, 'OK'],
//   [null, 'value1'],
//   [null, 'value2'],
// ]

Pipeline의 진짜 장점은 네트워크 비용 절감이다. Redis 서버 자체는 싱글 스레드라서 명령을 순차 처리하지만, 네트워크 왕복이 줄어드는 효과가 크다. 특히 원격 Redis 서버를 사용할 때 체감 차이가 크다.

서비스 계층에서 Pipeline 사용

실제 서비스에서는 Pipeline을 래핑한 메서드를 만들어서 사용하면 편하다.

typescript
// 트랜잭션 (원자적 실행)
const multi = redis.multi();
multi.set('key1', 'value1');
multi.set('key2', 'value2');
await multi.exec();
typescript
@Injectable()
class RedisService {
  constructor(@Inject('REDIS_CLIENT') private readonly client: Redis) {}

  async executePipeline(
    commands: (pipeline: ChainableCommander) => void,
  ) {
    const pipeline = this.client.pipeline();
    commands(pipeline);
    return pipeline.exec();
  }
}

콜백 패턴으로 Pipeline을 받아서 명령을 채우게 하면, Pipeline의 생성과 실행을 한 곳에서 관리할 수 있다.


키 탐색: keys vs scan

keys 명령은 패턴에 맞는 모든 키를 한 번에 반환한다.

typescript
// 사용 예: 여러 Sorted Set에 동시에 score 업데이트
await redisService.executePipeline((pipe) => {
  pipe.zincrby('trending:daily', 1, 'post:42');
  pipe.zincrby('trending:weekly', 1, 'post:42');
  pipe.lpush('recent:posts', JSON.stringify(postData));
  pipe.ltrim('recent:posts', 0, 99);
});

문제는 이 명령이 블로킹이라는 것이다. Redis는 싱글 스레드이므로 keys가 실행되는 동안 다른 모든 명령이 대기한다. 키가 수만~수십만 개면 서버가 수 초간 멈출 수 있다. 개발 환경에서는 편하지만 프로덕션에서는 절대 사용하면 안 된다.

scan은 커서 기반으로 키를 조금씩 가져온다.

typescript
const allSessionKeys = await redis.keys('session:*');

COUNT는 "한 번에 대략 이만큼 확인해라"라는 힌트일 뿐, 정확히 그만큼 반환한다는 보장은 없다. 커서가 '0'으로 돌아오면 전체 순회가 완료된 것이다. 각 순회 사이에 다른 명령이 실행될 수 있으므로 서버가 멈추지 않는다.


연결 이벤트와 재연결

ioredis는 연결 상태에 따라 이벤트를 발생시킨다.

typescript
async function scanKeys(redis: Redis, pattern: string): Promise<string[]> {
  const allKeys: string[] = [];
  let cursor = '0';

  do {
    const [nextCursor, keys] = await redis.scan(
      cursor,
      'MATCH', pattern,
      'COUNT', 100,
    );
    cursor = nextCursor;
    allKeys.push(...keys);
  } while (cursor !== '0');

  return allKeys;
}

connectready의 차이가 중요하다. connect는 TCP 연결만 된 상태이고, ready는 Redis 서버가 실제로 명령을 받을 준비가 된 상태이다. enableReadyCheck: true(기본값)이면 연결 후 INFO 명령을 보내서 서버 상태를 확인한 뒤에야 ready가 발생한다.

자동 재연결

ioredis는 연결이 끊어지면 자동으로 재연결을 시도한다. 재연결 전략은 retryStrategy 옵션으로 커스텀할 수 있다.

typescript
redis.on('connect', () => {
  console.log('TCP 연결 수립됨');
});

redis.on('ready', () => {
  console.log('Redis 명령 수신 가능');
});

redis.on('error', (err) => {
  console.error('Redis 에러:', err.message);
});

redis.on('close', () => {
  console.log('연결 종료됨');
});

redis.on('reconnecting', (delay) => {
  console.log(`${delay}ms 후 재연결 시도`);
});

기본 전략은 지수 백오프 비슷한 방식으로, 시도할 때마다 대기 시간이 길어진다. null을 반환하면 재연결을 완전히 포기한다.


NestJS에서 ioredis 사용하기

NestJS에서 ioredis를 사용하는 일반적인 패턴은 커스텀 프로바이더로 Redis 인스턴스를 등록하고, 서비스 클래스로 래핑하는 것이다.

모듈 설정

typescript
const redis = new Redis({
  retryStrategy(times) {
    // times: 현재까지 재시도 횟수
    const delay = Math.min(times * 50, 2000);
    return delay; // ms 후 재시도
    // null을 반환하면 재연결을 중단한다
  },
});

@Global() 데코레이터를 붙이면 다른 모듈에서 importsRedisModule을 추가하지 않아도 RedisService를 주입받을 수 있다. Redis는 거의 모든 모듈에서 사용하는 인프라 서비스이므로 Global로 등록하는 게 합리적이다.

useFactory를 사용하면 ConfigService에서 환경 변수를 읽어 동적으로 Redis 인스턴스를 생성할 수 있다. useClassuseValue로는 이런 동적 설정이 불가능하다.

서비스 래핑

typescript
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Redis from 'ioredis';

@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: 'REDIS_CLIENT',
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        return new Redis({
          host: configService.get<string>('REDIS_HOST'),
          port: configService.get<number>('REDIS_PORT'),
          password: configService.get<string>('REDIS_PASSWORD'),
          db: 0,
        });
      },
    },
    RedisService,
  ],
  exports: [RedisService],
})
export class RedisModule {}

Redis 클라이언트를 직접 노출하는 대신 서비스로 감싸면 여러 장점이 있다:

  1. 테스트 용이성: RedisService를 모킹하면 Redis 없이 단위 테스트가 가능하다.
  2. 인터페이스 제한: 필요한 명령만 노출해서 실수로 위험한 명령(flushall 등)을 호출하는 것을 방지한다.
  3. 일관된 에러 처리: 서비스 레벨에서 에러 핸들링이나 로깅을 추가할 수 있다.

사용 예시

typescript
import { Inject, Injectable } from '@nestjs/common';
import Redis, { ChainableCommander } from 'ioredis';

@Injectable()
export class RedisService {
  constructor(@Inject('REDIS_CLIENT') public readonly redisClient: Redis) {}

  async get(key: string): Promise<string | null> {
    return this.redisClient.get(key);
  }

  async set(
    key: string,
    value: string | number,
    ...args: any[]
  ): Promise<'OK' | null> {
    return this.redisClient.set(key, value, ...args);
  }

  async del(...keys: string[]): Promise<number> {
    return this.redisClient.del(...keys);
  }

  async setex(
    key: string,
    seconds: number,
    value: string | number,
  ): Promise<'OK' | null> {
    return this.redisClient.setex(key, seconds, value);
  }

  async executePipeline(
    commands: (pipeline: ChainableCommander) => void,
  ) {
    const pipeline = this.redisClient.pipeline();
    commands(pipeline);
    return pipeline.exec();
  }

  disconnect() {
    this.redisClient.disconnect();
  }
}

node-redis와의 비교

항목ioredisnode-redis (v4+)
Promise 지원초기부터 네이티브v4부터 추가
Cluster 지원네이티브v4에서 추가
Sentinel 지원네이티브네이티브
Pipeline APIredis.pipeline()redis.multi() (EXEC 모드)
자동 재연결기본 내장기본 내장
TypeScript타입 정의 포함타입 정의 포함
Lua 스크립팅defineCommand()scripts 옵션
주간 다운로드~1,200만+~500만+

현재 시점에서 기능 차이는 크지 않다. 선택 기준은 주로 이미 사용 중인 생태계(NestJS 커뮤니티에서는 ioredis가 더 일반적)와 API 스타일 선호도에 따라 갈린다.


관련 문서


정리

ioredis를 쓸 때 기억할 핵심:

  • 연결 관리: offline queue 덕분에 연결 완료를 기다릴 필요 없음. 단, 에러 이벤트는 반드시 리스닝할 것.
  • 자료구조 선택: 단순 캐시는 String, 최근 목록은 List, 중복 체크는 Set, 랭킹은 Sorted Set.
  • Pipeline 활용: 여러 명령을 보낼 때는 반드시 Pipeline으로 묶어서 네트워크 비용을 줄일 것.
  • keys 금지: 프로덕션에서 keys 대신 scan을 사용할 것.
  • 서비스 래핑: 직접 사용하지 말고 서비스 클래스로 감싸서 테스트와 유지보수를 쉽게 할 것.