junyeokk
Blog
Testing·2024. 11. 12

ioredis-mock

Redis를 사용하는 애플리케이션의 단위 테스트를 작성하다 보면 근본적인 딜레마에 부딪힌다. 테스트를 실행할 때마다 실제 Redis 서버가 필요한가? 로컬에서야 Redis를 띄워놓으면 되지만, CI 환경에서는 매번 Redis를 설치하고 실행해야 한다. Testcontainers 같은 도구로 Docker 기반 Redis를 띄울 수도 있지만, 단위 테스트에서 컨테이너까지 올리는 건 과하다. 단위 테스트는 빠르고 가벼워야 하는데, 외부 인프라 의존성이 끼어들면 그 원칙이 무너진다.

ioredis-mock은 이 문제를 해결한다. ioredis의 API를 인메모리로 구현한 모킹 라이브러리로, 실제 Redis 서버 없이도 Redis 의존 코드를 테스트할 수 있다.


기본 원리

ioredis-mock은 ioredis의 Redis 클래스와 동일한 인터페이스를 구현하되, 내부적으로 JavaScript 객체(Map)에 데이터를 저장한다. TCP 연결도 없고, 프로토콜 파싱도 없다. 순수하게 메모리에서 Redis 명령어들의 동작을 시뮬레이션한다.

typescript
import Redis from 'ioredis-mock';

const redis = new Redis();

await redis.set('key', 'value');
const result = await redis.get('key');
console.log(result); // 'value'

이게 실제 ioredis와 사용법이 완전히 동일하다는 점이 핵심이다. import만 바꾸면 기존 코드가 그대로 동작한다.


환경 분기 패턴

테스트 환경에서만 mock을 사용하고 프로덕션에서는 실제 Redis에 연결하는 패턴이 가장 일반적이다. 환경 변수로 분기하는 방식을 보자.

typescript
import Redis from 'ioredis';
import RedisMock from 'ioredis-mock';

class RedisConnection {
  private redis: Redis;

  constructor() {
    this.connect();
  }

  connect() {
    if (process.env.NODE_ENV === 'test') {
      this.redis = new RedisMock();
    } else {
      this.redis = new Redis({
        host: process.env.REDIS_HOST,
        port: parseInt(process.env.REDIS_PORT),
        password: process.env.REDIS_PASSWORD,
      });
    }
  }

  async get(key: string) {
    return this.redis.get(key);
  }

  async set(key: string, value: string) {
    return this.redis.set(key, value);
  }
}

이 패턴의 장점은 비즈니스 로직을 전혀 수정하지 않아도 된다는 것이다. RedisConnection 클래스를 사용하는 서비스 코드는 내부가 실제 Redis인지 mock인지 전혀 알 필요가 없다. 인터페이스가 동일하기 때문이다.

테스트 코드에서는 이렇게 사용한다:

typescript
describe('CacheService', () => {
  let connection: RedisConnection;

  beforeEach(() => {
    process.env.NODE_ENV = 'test';
    connection = new RedisConnection();
  });

  it('값을 저장하고 조회할 수 있다', async () => {
    await connection.set('user:1', JSON.stringify({ name: 'Alice' }));
    const result = await connection.get('user:1');
    expect(JSON.parse(result)).toEqual({ name: 'Alice' });
  });
});

지원하는 Redis 명령어

ioredis-mock은 대부분의 일반적인 Redis 명령어를 지원한다. 다만 100% 완전한 구현은 아니기 때문에, 사용하기 전에 어떤 명령어가 지원되는지 확인할 필요가 있다.

String 계열: get, set, mget, mset, incr, decr, append, setex, setnx, getset

List 계열: lpush, rpush, lpop, rpop, lrange, llen, lindex, lset

Hash 계열: hset, hget, hgetall, hdel, hexists, hincrby, hkeys, hvals

Set 계열: sadd, srem, smembers, sismember, scard, sunion, sinter, sdiff

Sorted Set 계열: zadd, zrem, zrange, zrevrange, zscore, zrank, zincrby, zrangebyscore

키 관리: del, exists, expire, ttl, keys, scan, type, rename

기타: flushall, flushdb, pipeline, multi/exec (트랜잭션)


Pipeline 지원

ioredis-mock은 Pipeline도 지원한다. Pipeline은 여러 명령어를 한 번에 묶어서 보내는 기능인데, mock에서도 동일하게 동작한다.

typescript
import Redis from 'ioredis-mock';

const redis = new Redis();

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

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

각 결과는 [error, result] 형태의 배열이다. 에러가 없으면 첫 번째 요소가 null이다. 이 동작 방식은 실제 ioredis Pipeline과 완전히 동일하다.

Pipeline을 사용하는 래퍼 함수도 mock에서 자연스럽게 테스트할 수 있다:

typescript
async function batchInsert(
  redis: Redis,
  items: Array<{ key: string; value: string }>
) {
  const pipeline = redis.pipeline();
  items.forEach(({ key, value }) => {
    pipeline.hset('items', key, value);
  });
  return pipeline.exec();
}

// 테스트
it('여러 아이템을 한 번에 저장한다', async () => {
  const redis = new RedisMock();
  await batchInsert(redis, [
    { key: 'a', value: '1' },
    { key: 'b', value: '2' },
  ]);

  const result = await redis.hgetall('items');
  expect(result).toEqual({ a: '1', b: '2' });
});

초기 데이터 주입

테스트 시 특정 상태의 Redis를 시뮬레이션해야 할 때가 있다. ioredis-mock은 생성자에서 초기 데이터를 주입할 수 있다.

typescript
const redis = new Redis({
  data: {
    'user:1': 'Alice',
    'user:2': 'Bob',
    'counter': '42',
    'mylist': ['a', 'b', 'c'],
    'myset': new Set(['x', 'y', 'z']),
    'myhash': new Map([['field1', 'value1'], ['field2', 'value2']]),
  },
});

const user = await redis.get('user:1'); // 'Alice'
const list = await redis.lrange('mylist', 0, -1); // ['a', 'b', 'c']
const members = await redis.smembers('myset'); // ['x', 'y', 'z']

각 자료형에 맞는 JavaScript 타입을 사용한다:

  • String → string
  • List → Array
  • Set → Set
  • Hash → Map

이걸 활용하면 테스트마다 동일한 초기 상태를 보장할 수 있어서 테스트의 결정성(determinism)이 높아진다.


데이터 격리와 인스턴스 공유

ioredis-mock의 한 가지 주의할 점은 같은 옵션으로 생성한 인스턴스끼리 데이터를 공유한다는 것이다. 이는 실제 Redis의 동작을 모방한 것으로, 같은 서버에 연결한 여러 클라이언트가 동일한 데이터를 보는 것과 같다.

typescript
const redis1 = new Redis();
const redis2 = new Redis();

await redis1.set('shared', 'hello');
const result = await redis2.get('shared');
console.log(result); // 'hello' — 데이터가 공유된다

이 동작이 테스트 간 데이터 오염을 일으킬 수 있다. 각 테스트를 독립적으로 유지하려면 beforeEach에서 flushall()을 호출하거나, 매번 고유한 옵션으로 인스턴스를 생성해야 한다:

typescript
beforeEach(async () => {
  await redis.flushall();
});

혹은 매 테스트마다 새로운 mock 인스턴스를 만들어서 격리할 수도 있다:

typescript
beforeEach(() => {
  // 고유 URL을 주면 별도 데이터 저장소를 사용
  redis = new Redis(`redis://localhost:${Math.random()}`);
});

DI(의존성 주입)와 함께 사용하기

테스트에서 환경 변수로 분기하는 대신, 의존성 주입을 활용하면 더 유연하게 mock을 교체할 수 있다. Redis 연결을 인터페이스로 추상화하고, 테스트 시 mock 구현을 주입하는 방식이다.

typescript
// redis.interface.ts
export interface IRedisClient {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<string>;
  del(...keys: string[]): Promise<number>;
  pipeline(): any;
}

// 프로덕션 구현
import Redis from 'ioredis';

export class RedisClient implements IRedisClient {
  private client: Redis;

  constructor(options: { host: string; port: number }) {
    this.client = new Redis(options);
  }

  get(key: string) { return this.client.get(key); }
  set(key: string, value: string) { return this.client.set(key, value); }
  del(...keys: string[]) { return this.client.del(...keys); }
  pipeline() { return this.client.pipeline(); }
}

// 테스트 구현
import RedisMock from 'ioredis-mock';

export class MockRedisClient implements IRedisClient {
  private client = new RedisMock();

  get(key: string) { return this.client.get(key); }
  set(key: string, value: string) { return this.client.set(key, value); }
  del(...keys: string[]) { return this.client.del(...keys); }
  pipeline() { return this.client.pipeline(); }
}

DI 컨테이너(tsyringe, NestJS 등)에서 토큰만 교체하면 테스트 전체에서 mock이 적용된다:

typescript
// 테스트 설정
container.register('REDIS_CLIENT', { useClass: MockRedisClient });

// 프로덕션 설정
container.register('REDIS_CLIENT', { useClass: RedisClient });

ioredis-mock vs 실제 Redis 테스트

ioredis-mock이 만능은 아니다. 언제 mock을 쓰고 언제 실제 Redis를 쓸지 판단이 필요하다.

ioredis-mock이 적합한 경우:

  • 단위 테스트: 비즈니스 로직이 Redis 명령어를 올바르게 호출하는지 검증
  • CI 환경: Redis 설치 없이 빠르게 테스트 실행
  • 간단한 CRUD: get/set/del 같은 기본 명령어 위주의 코드

실제 Redis (Testcontainers 등)가 필요한 경우:

  • Lua 스크립트 테스트: ioredis-mock의 Lua 지원이 불완전함
  • Pub/Sub 테스트: 구독/발행 패턴은 mock에서 제한적
  • 성능 특성 검증: 실제 네트워크 지연, 동시성 문제 확인
  • Cluster/Sentinel: mock에서 지원하지 않는 고가용성 기능

실무에서는 단위 테스트에 ioredis-mock, 통합 테스트에 Testcontainers를 조합하는 전략이 효과적이다. 빠른 피드백 루프는 mock으로 확보하고, 실제 Redis와의 호환성은 통합 테스트에서 보장한다.


한계점

ioredis-mock을 사용할 때 알아두어야 할 제한사항들이 있다.

1. 명령어 커버리지가 100%가 아니다

지원하지 않는 명령어를 호출하면 에러가 발생하거나 undefined를 반환한다. 특히 최신 Redis 명령어(Redis 7.x 이상)는 구현이 늦어질 수 있다.

2. 동작 미묘한 차이

일부 명령어에서 실제 Redis와 미세하게 다른 동작을 보일 수 있다. 예를 들어 TTL 관련 명령어에서 타이밍이 정확하지 않거나, SCAN 커서의 동작이 실제와 다를 수 있다.

3. Pub/Sub 제한

기본적인 publish/subscribe는 동작하지만, 패턴 구독(psubscribe)이나 복잡한 Pub/Sub 시나리오는 제한적이다.

4. Stream 미지원

Redis Streams(XADD, XREAD, XGROUP 등)는 대부분 지원되지 않는다.

이런 한계를 감안하더라도, 단위 테스트 용도로는 충분히 실용적이다. 중요한 건 mock의 한계를 인지하고, 핵심 로직은 통합 테스트에서 추가로 검증하는 것이다.


정리

  • import만 교체하면 동일한 인터페이스로 동작하고, DI와 조합하면 프로덕션 코드를 전혀 수정하지 않아도 된다
  • 초기 데이터 주입과 flushall로 테스트 상태를 제어하되, 인스턴스 간 데이터 공유에 주의해서 격리한다
  • 단위 테스트는 ioredis-mock, 통합 테스트는 Testcontainers로 역할을 나누면 속도와 신뢰성을 모두 확보할 수 있다

관련 문서