Backend/NestJs

[NestJS] Redis 가이드: ioredis와 CacheManager의 효율적인 공존

Hamin_Abba_Dev 2026. 1. 14. 00:23
728x90
반응형

NestJS에서 Redis를 연동하려고 할 때 가장 고민되는 점은 무엇인가요?

 

"공식 문서대로 @nestjs/cache-manager를 쓰자니 INCR 같은 원자적(Atomic) 연산이 안 되고, 그렇다고 ioredis만 쓰자니 단순 캐싱 로직까지 일일이 짜는 게 너무 번거롭다"는 점일 겁니다.

게다가 무턱대고 두 라이브러리를 다 쓰면 Redis 커넥션이 두 배로 늘어나는 리소스 낭비까지 발생하죠.

오늘은 이 모든 문제를 한 번에 해결하는 '실무형 하이브리드 아키텍처'를 소개합니다.

단 하나의 Redis 연결을 재사용하여, 편리한 캐싱과 강력한 성능 제어를 동시에 잡는 방법을 단계별로 알아봅시다.

1. 왜 이 조합이어야 할까요?

우리는 세 가지 라이브러리를 조합해서 사용할 겁니다.

  • @nestjs/cache-manager: NestJS의 표준 캐싱 인터페이스입니다. 비즈니스 로직 침범 없이 데코레이터나 get/set으로 캐싱을 쉽게 구현합니다.
  • ioredis: Node.js 진영에서 가장 신뢰받는 Redis 클라이언트입니다. INCR, ZADD, Pipeline 등 Redis의 모든 기능을 제약 없이 사용할 수 있습니다.
  • cache-manager-redis-yet: cache-manager v5 버전의 호환성 문제를 해결하고, 내부적으로 ioredis를 사용하여 성능을 보장하는 최신 어댑터입니다.

핵심은 "ioredis로 맺은 단 하나의 강력한 연결을 cache-manager에게 빌려준다"는 것입니다.

2. 프로젝트 설정 및 설치

먼저 필요한 패키지들을 설치합니다.

# NestJS 캐시 모듈, Redis 어댑터, ioredis 클라이언트 설치
npm install @nestjs/cache-manager cache-manager cache-manager-redis-yet ioredis
npm install -D @types/cache-manager

3. Redis 모듈 구성 (커넥션 생성의 주체)

먼저 ioredis를 사용하여 실제 Redis 연결을 담당할 모듈을 만듭니다.

이 모듈은 애플리케이션 전역에서 쓰이므로 @Global()로 설정합니다.

// src/redis/redis.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Redis from 'ioredis';

// 유지보수를 위해 주입 토큰을 상수로 관리
export const REDIS_CLIENT = 'REDIS_CLIENT';

@Global()
@Module({
  imports: [ConfigModule], // 환경변수 사용을 위해 import
  providers: [
    {
      provide: REDIS_CLIENT,
      useFactory: (configService: ConfigService) => {
        return new Redis({
          host: configService.get<string>('REDIS_HOST'),
          port: configService.get<number>('REDIS_PORT'),
          password: configService.get<string>('REDIS_PASSWORD'),
          // 리소스 관리 및 안정성 옵션 (실무 권장)
          connectTimeout: 10000,
          maxRetriesPerRequest: 3,
        });
      },
      inject: [ConfigService],
    },
  ],
  exports: [REDIS_CLIENT], // 다른 모듈(CacheModule 등)에서 쓸 수 있게 export
})
export class RedisModule {}

4. AppModule 설정 (리소스 최적화의 핵심)

이 부분이 가장 중요합니다.

CacheModule을 설정할 때 새로운 연결을 만드는 게 아니라, 방금 만든 RedisModule의 클라이언트를 주입(Inject) 받아 재사용합니다.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
import { RedisModule, REDIS_CLIENT } from './redis/redis.module';
import Redis from 'ioredis';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    
    // 1. RedisModule 로드 (여기서 실제 연결이 생성됨)
    RedisModule,

    // 2. CacheModule 설정 (생성된 연결을 재사용)
    CacheModule.registerAsync({
      isGlobal: true,
      imports: [RedisModule],
      useFactory: async (redisClient: Redis) => {
        return {
          store: await redisStore({
            // 핵심: 호스트/포트를 다시 적지 않고, 이미 연결된 인스턴스를 넘깁니다.
            // 라이브러리 버전에 따라 redisInstance 또는 ioRedis 옵션을 사용합니다.
            redisInstance: redisClient, 
            ttl: 5000, // 기본 만료 시간 (밀리초 단위! v5 주의)
          }),
        };
      },
      inject: [REDIS_CLIENT], // RedisModule에서 만든 클라이언트를 주입받음
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

왜 이렇게 하나요? 이렇게 구성하면 애플리케이션은 Redis와 단 하나의 TCP 연결만 맺습니다. 트래픽이 늘어나거나 오토스케일링이 될 때 Redis 서버의 커넥션 부하를 절반으로 줄일 수 있는 아주 중요한 최적화 패턴입니다.

5. 실무 적용: Service에서 하이브리드로 사용하기

이제 서비스 레이어에서는 상황에 따라 두 가지 도구를 적재적소에 꺼내 쓰면 됩니다.

  • CACHE_MANAGER: 단순 데이터 조회/캐싱
  • REDIS_CLIENT: 동시성 제어, 복잡한 자료구조, Atomic 연산
// src/app.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { REDIS_CLIENT } from './redis/redis.module';
import Redis from 'ioredis';

@Injectable()
export class AppService {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
    @Inject(REDIS_CLIENT) private readonly redisClient: Redis,
  ) {}

  /**
   * [Case 1] 단순 조회: CacheManager 사용
   * 편의성이 중요할 때 (상품 목록, 공지사항 등)
   */
  async getSimpleData(key: string) {
    // 1. 캐시 조회 (코드 간결)
    const cachedData = await this.cacheManager.get(key);
    if (cachedData) return cachedData;

    // 2. 데이터 페칭 및 캐싱
    const data = 'Some DB Data';
    await this.cacheManager.set(key, data, 10000); // 10초
    
    return data;
  }

  /**
   * [Case 2] 정밀 제어: ioredis 사용
   * 데이터 무결성이 생명일 때 (선착순 이벤트, 조회수, 재고 차감)
   */
  async increaseViewCount(postId: number) {
    const key = `post:view:${postId}`;

    // INCR 명령어 사용: Race Condition(경쟁 상태) 원천 차단
    // cache-manager로는 구현하기 까다로운 부분입니다.
    const currentViews = await this.redisClient.incr(key);

    return { postId, views: currentViews };
  }

  /**
   * [Case 3] 고급 자료구조: ioredis 사용
   * 실시간 랭킹 (Sorted Set)
   */
  async updateRanking(userId: number, score: number) {
    // ZADD: 랭킹 점수 추가
    await this.redisClient.zadd('game:ranking', score, userId.toString());
  }
}

6. 결론 및 마무리

이제 여러분의 NestJS 프로젝트는:

  1. cache-manager의 편리함으로 생산성을 높였고,
  2. ioredis의 강력함으로 복잡한 요구사항(동시성 등)을 해결할 수 있으며,
  3. 단일 커넥션 패턴으로 리소스까지 아끼는 튼튼한 구조를 갖췄습니다.

처음에는 설정이 조금 복잡해 보일 수 있지만, 한 번 구성해 두면 프로젝트 규모가 커져도 흔들리지 않는 단단한 기반이 되어줄 것입니다.