
동기
패키지 설치 시 경고 문구가 나오는 것을 정말 싫어한다. 새로운 서버 구축을 위해 Nest에서 기존에 사용하던 redis 모듈 오픈소스를 자연스레 설치했는데, 위와 같은 경고 문구가 발생했다. 경고에 따르면 이 오픈소스에서 terminus를 사용하는데, terminus의 의존성 버전들이 나의 현재 프로젝트 버전과 맞지 않는다고 한다.
terminus?
terminus를 간략히 설명하자면 NestJS에서 Health Check를 제공하는 모듈이다. 다양한 Health Indicator로 특히 마이크로서비스나 인프라등 애플리케이션의 기능들이 정상적으로 동작하는지 확인하는 기능을 제공하는 모듈이다. 단순 Redis Client Module에서 필요한 것은 아니라는 생각이 들었고, 위의 warning 의존성 문구와 더불어 이런 이유들로 인해 직접 NestJS의 11버전과 호환되는 모듈을 직접 만들어 배포하기로 했다.
(아래는 이번 포스팅의 주제인 직접 만든 Redis Module 라이브러리)
nestjs-ioredis-module
Redis Module With Nest, ioredis. Latest version: 1.0.14, last published: 3 hours ago. Start using nestjs-ioredis-module in your project by running `npm i nestjs-ioredis-module`. There are no other projects in the npm registry using nestjs-ioredis-module.
www.npmjs.com
NestJS Module System
NestJS는 기본적으로 Static Module과 Dynamic Module을 통해 모듈을 생성할 수 있다. 이 모듈들은 모두 런타임에 모듈을 스캔하여 내부 구조를 구성하고, 각 모듈마다 식별자인 불투명 키를 생성한다.
(모듈 구성에 대한 자세한 설명은 Nest11 릴리즈 포스팅 분석글에서 확인할 수 있다.)
NestJS의 DynamicModule은 동적으로 옵션을 받아서 런타임에 원하는 모듈을 구성할 수 있다.
물론 StaticModule 역시 런타임의 환경 변수 값 등의 변동에 따른 동적인 모듈 세팅을 할 수 있으나, 아래처럼 Redis를 상황에 따라 클러스터 모드로 모듈을 구성해야하는 경우, 극단적으로 외부 API 호출 값에 따라 변동되는 모듈 세팅 등 실행 환경에 따라 모듈 내부의 Providers 등을 다르게 주입해야한다면 StaticModule로는 불가능하다. (Nest에는 TypeOrmModule, ConfigModule 등 다양한 곳에 DynamicModule이 사용되고 있다.)
(Nest에는 TypeOrmModule, ConfigModule 등 다양한 곳에 DynamicModule이 사용되고 있다.)
// Static Module
@Module({
providers: [
{
provide: 'REDIS_OPTIONS',
useValue: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
},
},
RedisService,
],
exports: [RedisService],
})
export class RedisModule {}
// Dynamic Module
@Module({})
export class RedisModule {
static forRoot(options: { cluster: boolean; nodes?: RedisOptions[]; single?: RedisOptions }): DynamicModule {
const redisProvider: Provider = options.cluster
? {
provide: 'REDIS_CLIENT',
useFactory: () => new Redis.Cluster(options.nodes),
}
: {
provide: 'REDIS_CLIENT',
useFactory: () => new Redis(options.single),
};
return {
module: RedisModule,
providers: [redisProvider],
exports: [redisProvider],
};
}
}
@Module({
imports: [
RedisModule.forRoot({
cluster: process.env.USE_REDIS_CLUSTER === 'true',
nodes: process.env.USE_REDIS_CLUSTER === 'true' ? [{ host: 'node1' }, { host: 'node2' }] : undefined,
single: process.env.USE_REDIS_CLUSTER !== 'true' ? { host: 'localhost', port: 6379 } : undefined,
}),
],
})
export class AppModule {}
ioredis
라이브러리를 만들어보기 전에, 코어가 되는 의존성인 ioredis에 대해 먼저 확인해보아야했다.
ioredis란 Node 진영에서 많이 사용되는 Redis Client이다. 내부를 뜯어보니 내가 사용하고자하는 단일 레디스와 더불어 엄청나게 많은 기능을 제공하고 있었다. 우선 만들고자 하는 모듈이 ioredis에 의존적일 수 밖에 없어서, 혹시 직접 Redis Client를 직접 만들 수도 있을까 하고 연결 과정을 살펴보게 되었다.
확인해 본 결과 기본적으로 Node의 net 모듈을 통해 L4 연결을 수립하고 있었고, 필요 시 TLS Handshake가 추가 수립되었다. 추상커넥터를 통해서 단일 Redis와 센티넬 Redis를 자동으로 감지하여 연결을 수립시켜주고 있었다.
차후에 Node의 net 모듈을 통해 OS의 소켓 API를 직접 사용해보면서 레디스에 직접 연결해서 사용해보고, 필요한 기능들만 래핑해서 사용해보면 좋을 것 같았다. 이 부분은 차후에 개발 후 추가 포스팅으로 연계하려고 한다.
Redis Dynamic Module 만들기
위에서 언급한 DynamicModule을 바탕으로 실제 Redis와 연결하는 Dynamic Module을 구현해보았다.
NestJS의 DynamicModule 컨셉에 맞게 동기, 비동기 옵션 주입을 모두 지원하며 Redis 연결을 담당하는 Provider와 옵션을 useValue로 넣어주는 Provider을 분리하여 제작하였다.
// redis.module.ts
import { DynamicModule, Module, Provider } from '@nestjs/common';
import { REDIS_OPTIONS } from './redis.constasnts';
import { RedisProvider } from './redis.providers';
import { RedisModuleAsyncOptions, RedisModuleOptions } from './redis.interface';
@Module({})
export class RedisModule {
static forRoot(options: RedisModuleOptions): DynamicModule {
const optionsProvider: Provider = {
provide: REDIS_OPTIONS,
useValue: { ...options },
};
return {
module: RedisModule,
providers: [optionsProvider, RedisProvider],
exports: [RedisProvider],
};
}
static forRootAsync(options: RedisModuleAsyncOptions): DynamicModule {
const asyncProvider: Provider = {
provide: REDIS_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
};
return {
module: RedisModule,
imports: options.imports || [],
providers: [asyncProvider, RedisProvider],
exports: [RedisProvider],
};
}
}
Redis Module은 외부에서는 Redis Client를 동적으로 옵션을 넣어 생성할 수 있는 형태로, RedisProvider은 신경쓰지 않고, 옵션만 넣어서 레디스 모듈을 구성할 수 있게 만들었다.
// redis.interface.ts
import { ClusterNode, ClusterOptions, RedisOptions } from 'ioredis';
export interface RedisSingleOptions {
type: 'single';
host: string;
port: number;
commonOptions?: RedisOptions;
}
export interface RedisClusterOptions {
type: 'cluster';
nodes: ClusterNode[];
commonOptions: ClusterOptions;
}
export type RedisModuleOptions = RedisSingleOptions | RedisClusterOptions;
export interface RedisModuleAsyncOptions {
imports: any[];
inject: any[];
useFactory: (...args: any[]) => RedisModuleOptions | Promise<RedisModuleOptions>;
}
// redis.provider.ts
import { Provider } from '@nestjs/common';
import { REDIS_CLIENT, REDIS_OPTIONS } from './redis.constasnts';
import { createRedisClient } from './redis.factory';
import { RedisModuleOptions } from './redis.interface';
export const RedisProvider: Provider = {
provide: REDIS_CLIENT,
useFactory: async (options: RedisModuleOptions) => createRedisClient(options),
inject: [REDIS_OPTIONS],
};
모듈 생성과 Providers 주입 등에 사용될 인터페이스를 정의하고, Redis Connection 전용 Provider을 생성했다. RedisProvider을 통해 내가 레디스 클라이언트까지 생각하지 않고 옵션만 원하는 데로 넣어서 사용할 수 있게 유도했다.
// redis.factory.ts
import Redis from 'ioredis';
import { RedisModuleOptions } from './redis.interface';
export async function createRedisClient(options: RedisModuleOptions) {
const { type, commonOptions } = options;
if (type === 'cluster') {
return new Redis.Cluster(options.nodes, commonOptions);
}
if (type === 'single') {
return new Redis(options);
}
throw new Error('Invalid Redis options');
}
현재 개발 환경에서 레디스 클러스터를 사용할 일이 없어 만들지 않으려다가 ioredis에서 Cluster 인스턴스를 만들어서 Redis node별 host, port만 넣으면 되는 것을 확인하여 간단하게 작업이 될 수 있을 것 같아 클러스터 환경의 레디스 클라이언트도 추가해두었다. 이렇게 추가해도 실제 사용하는 레디스 인스턴스는 ioredis의 Redis Client이기 때문에 무리없이 사용이 가능하다.
(하지만 실제 사용하는 환경이 아니다보니 테스트 코드는 작성하지 않았다.)
import { Inject } from '@nestjs/common';
import { REDIS_CLIENT } from './redis.constasnts';
export const InjectRedis = () => Inject(REDIS_CLIENT);
마지막으로, Redis Client를 특정 모듈들에서 주입받아 원하는 구간에서 사용할 수 있도록 주입을 위한 데코레이터를 만들었다. 이제 아래처럼 다른 Providers에서 Redis Module을 통해 생성된 Redis Client를 사용할 수 있다.
import { Injectable } from '@nestjs/common';
import { InjectRedis } from './redis.decorator';
import Redis from 'ioredis';
@Injectable()
export class RedisService {
constructor(@InjectRedis() private readonly redis: Redis) {}
async getValue(key: string): Promise<string | null> {
return this.redis.get(key);
}
}
이 @InjectRedis()는 강제로 REDIS_CLIENT 상수를 넣어두었다. 내가 여러 대의 레디스를 사용하게 되거나, 누군가의 요청으로 인해 변경해야 한다면 상수 대신 옵션으로 넣고 옵션으로 들어온 값이 없다면 디폴트로 사용할 수 있게 구성할 생각이다.
실제 적용해보기
지금까지 만든 라이브러리를 NPM에 배포하고 새로 구축하고 있던 서버에서 기존 오픈소스를 제거한 뒤 내가 만든 라이브러리를 주입시켜보았다.
import { Global, Module } from '@nestjs/common';
import { RedisModule } from 'nestjs-ioredis-module';
import { RedisStringManager } from './managers/string.manager';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Global()
@Module({
imports: [
RedisModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
type: 'single',
host: configService.get<string>('REDIS_HOST') ?? 'localhost',
port: configService.get<number>('REDIS_PORT') ?? 6379,
}),
inject: [ConfigService],
}),
],
providers: [RedisStringManager],
exports: [RedisStringManager],
})
export class CustomRedisModule {}
import { InjectRedis } from 'nestjs-ioredis-module';
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { RedisStringOperation } from '../interfaces/redis-operation.interface';
@Injectable()
export class RedisStringManager implements RedisStringOperation {
constructor(@InjectRedis() protected readonly redis: Redis) {}
protected getInstance(): Redis {
return this.redis;
}
async set<T>(key: string, value: T): Promise<T | null> {
return await this.redis.set(key, JSON.stringify(value)).then((context) => (context === 'OK' ? value : null));
}
async setex<T>(key: string, value: T, expire: number): Promise<T | null> {
return await this.redis
.setex(key, expire, JSON.stringify(value))
.then((context) => (context === 'OK' ? value : null));
}
async get<T>(key: string): Promise<T | null> {
return await this.redis.get(key).then((value) => (value ? JSON.parse(value) : null));
}
async del(key: string): Promise<boolean> {
return await this.redis
.del(key)
.then(() => true)
.catch(() => false);
}
}
현재 만들고 있는 서버 내부에서 레디스 관련 모듈의 응집을 위해 사용되는 CustomRedisModule에 적용시켰다.
적용시킨 CustomRedisModule을 바탕으로 각 모듈에서 사용할 수 있는지 또한 확인해보기 위해 레디스가 정상 연결되었는지, 또 주입받아 사용하는 구간들에서 잘 적용이 되었는지 테스트 해보기로 했다.
import { Module } from '@nestjs/common';
import { AuthController } from './controllers/auth.controller';
import { AuthService } from './services/auth.service';
import { RedisAuthStore } from './services/redis-auth-store';
import { SmsModule } from '../sms/sms.module';
import { NONCE_STORAGE, OTP_STORAGE } from '../../common/constants/tokens';
@Module({
imports: [SmsModule],
providers: [
AuthService,
RedisAuthStore,
{
provide: NONCE_STORAGE,
useExisting: RedisAuthStore,
},
{
provide: OTP_STORAGE,
useExisting: RedisAuthStore,
},
],
controllers: [AuthController],
})
export class AuthModule {}
import { Injectable } from '@nestjs/common';
import { RedisStringManager } from '../../../infra/redis/managers/string.manager';
import { OtpGenerateDto } from '../dtos/otp.dto';
import { NonceStorage } from '../interfaces/nonce-storage.interface';
import { OtpStorage } from '../interfaces/otp-storage.interface';
@Injectable()
export class RedisAuthStore implements NonceStorage, OtpStorage {
constructor(private readonly redis: RedisStringManager) {}
async setNonce(key: string | number, value: string, ttl: number = 600): Promise<string | null> {
return await this.redis.setex<string>(this.generateNonceKey(key), value, ttl);
}
async getNonce(key: string | number): Promise<string | null> {
return await this.redis.get<string>(this.generateNonceKey(key));
}
async delNonce(key: string | number): Promise<boolean> {
return await this.redis.del(this.generateNonceKey(key));
}
async setOtp(key: string | number, value: OtpGenerateDto, ttl: number = 300): Promise<string | null> {
return await this.redis.setex<string>(this.generateOtpKey(key), JSON.stringify(value), ttl);
}
async getOtp(key: string | number): Promise<OtpGenerateDto | null> {
const response = await this.redis.get<string>(this.generateOtpKey(key));
return response ? (JSON.parse(response) as OtpGenerateDto) : null;
}
async delOtp(key: string | number): Promise<boolean> {
return await this.redis.del(this.generateOtpKey(key));
}
generateNonceKey(key: string | number): string {
return `freelancer:login:${key}`;
}
generateOtpKey(key: string | number): string {
return `otp:${key}`;
}
}
테스트
import { Test } from "@nestjs/testing";
import { RedisStringManager } from "../../src/infra/redis/managers/string.manager";
import { CustomRedisModule } from "../../src/infra/redis/redis.module";
import { setupApp, setupModule } from "../settings/setup";
describe('Redis Module Migration Test', () => {
let redisStringManager: RedisStringManager;
beforeAll(async () => {
const moduleRef = await setupModule([CustomRedisModule]);
redisStringManager = moduleRef.get<RedisStringManager>(RedisStringManager);
})
it ('RedisStringManager가 정상적으로 주입되었는지?', () => {
expect(redisStringManager).toBeDefined();
});
it ('RedisStringManager Migration 결과는!?!?!?', async () => {
await redisStringManager.set('test', 'test');
const sut = await redisStringManager.get('test');
expect(sut).toBe('test');
});
});
import { CustomRedisModule } from '../../src/infra/redis/redis.module';
import { setupModule } from '../settings/setup';
import { RedisAuthStore } from '../../src/app/auth/services/redis-auth-store';
describe('AuthModule Redis 연동 테스트', () => {
let redisAuthStore: RedisAuthStore;
beforeAll(async () => {
const moduleRef = await setupModule([CustomRedisModule]);
redisAuthStore = moduleRef.get<RedisAuthStore>(RedisAuthStore);
});
it('RedisAuthStore가 정상적으로 주입되었는지?', () => {
expect(redisAuthStore).toBeDefined();
});
it('Nonce 저장 및 조회 테스트', async () => {
// given
await redisAuthStore.setNonce('test-user', 'nonce-value', 600);
// when
const nonce = await redisAuthStore.getNonce('test-user');
// then
expect(nonce).toBe('nonce-value');
});
it('OTP 저장 및 조회 테스트', async () => {
// given
const otpData = { otp: '123456', issueDate: Date.now() };
await redisAuthStore.setOtp('test-user', otpData, 300);
// when
const retrievedOtp = await redisAuthStore.getOtp('test-user');
// then
expect(retrievedOtp).toMatchObject(otpData);
});
it('Nonce 삭제 테스트', async () => {
// given
await redisAuthStore.setNonce('delete-user', 'delete-value', 600);
// when
await redisAuthStore.delNonce('delete-user');
const afterDelete = await redisAuthStore.getNonce('delete-user');
// then
expect(afterDelete).toBeNull();
});
it('OTP 삭제 테스트', async () => {
// given
const otpData = { otp: '654321', issueDate: Date.now() };
await redisAuthStore.setOtp('delete-user', otpData, 300);
// when
await redisAuthStore.delOtp('delete-user');
const afterDelete = await redisAuthStore.getOtp('delete-user');
// then
expect(afterDelete).toBeNull();
});
});
둘 다 정상적으로 동작하는 모습을 볼 수 있다. 성공적으로 마이그레이션이 완료된 것 같다.
과제
NestJS 11버전을 기준으로 만들어졌는데, 사내 다른 NestJS 서버의 버전(v7, v10)들에도 사용할 수 있는지, 어느 버전까지 호환이 되는지 확인해보는 과정이 필요하다.
마치며
최근에 토스에서 발행한 NestJS 환경에 맞는 Custom Decorator 만들기 라는 포스팅을 봤다. 특정 모듈, 라이브러리, 기능 등이 필요한 상황에서 현재 사용중인 프레임워크, 라이브러리 전수 조사를 통해 함수 레벨에서 사용 가능한 데코레이터를 커스터마이징하여 사용한 것을 보고 충격을 받았다. 현재 사용중인 기술을 바탕으로 현재 기술에서 지원하지 않는 다른 무언가를 창조해낼 수 있다는 것이 신기하기도 했고 많이 부족하구나 라는 생각이 들었다.
이번 Redis Module을 만든 것은 어찌보면 기존의 nestjs-modules/ioredis라는 꽤 사용량이 높은 오픈소스가 기존에 있기도 했고, 단순 Dynamic Module을 하나 구성하여 ioredis와의 연계만 시켜주면 되기 때문에 쉬운 편이라고 생각한다. 하지만 난이도를 떠나 항상 무엇을 개선할 수 있을지 생각해보고 적용시킬 수 있다면 머리통이 깨지더라도(?) 적용해보는 편이 좋겠다는 생각이 든다. 오픈소스에서 필요 없는 기능을 빼서 커스터마이징하기만 하더라도 작게나마 리소스를 절감할 수 있기도 하고, 과정에서 배우는 것이 많기 때문이다. 단순히 필요 없는 기능을 걷어내고 최적화하는 과정도 소중하지 않을까? 라는 생각으로 마무리해본다.
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!