NestJS 캐싱 데코레이터 직접 만들어서 사용하기
멀티 테넌시 환경에서 모든 레이어에 적용 가능한 캐싱 데코레이터를 직접 구현하고, DB 부하를 줄이고 응답 속도를 개선한 경험을 공유합니다.
필요해? 직접 만들어
최근 프로젝트에서 특정 함수의 반환값을 캐싱해서 사용하면 좋겠다는 생각이 들었습니다. 자주 호출되지만 값이 잘 바뀌지 않는 함수들이 많았기 때문에 해당 함수들에 캐싱을 적용하면 DB 부하를 줄이고 속도를 높일 수 있을 것 같다고 생각했습니다.
그래서 캐시 데코레이터를 직접 만들어 쓰기로 했습니다.
필수 조건 정의
현재 상황에서 제가 생각한 두 가지 필수 조건은 다음과 같습니다.
- 멀티 테넌시 환경: 요청마다 서로 다른 캐시 클라이언트를 사용해야 한다.
- 모든 레이어에서 사용 가능: 컨트롤러, 서비스, 레포지토리 상관없이 간편하게 사용
두 가지 조건을 만족하기 위해 고민했던 내용들을 정리해봅니다.
멀티 테넌시를 만족시켜야 한다
여러 테넌트들이 우리 모듈을 사용해서 서비스를 할 수 있도록 해야하기 때문에 어떤 테넌트가 요청했느냐에 따라서 서로 다른 캐시를 사용해야 합니다.
A서비스에서 들어온 요청에 대해서는 1번 Redis, B서비스에서 들어온 요청에 대해서는 2번 Redis에 요청해야하는 상황인 것입니다.
CacheServiceManager의 역할과 구현
우선, 각 테넌트별로 Redis 클라이언트를 관리하고, 해당 클라이언트를 사용하는 CacheService를 생성해주는 CacheServiceManager를 구현했습니다.
@Injectable()
export class CacheServiceManager {
private static cacheServices = new Map<string, CacheService>();
private static redisUrls: Record<string, string>;
constructor(private readonly configService: ConfigService) {
CacheServiceManager.redisUrls = JSON.parse(
this.configService.get<string>('REDIS_URLS'),
);
}
static async init() {
for (const [tenantId, endpoint] of Object.entries(this.redisUrls)) {
const client = await this.connectToRedis(endpoint);
const cacheService = new CacheService(client, tenantId);
this.cacheServices.set(tenantId, cacheService);
}
}
private static connectToRedis(endpoint: string): Promise<RedisClientType> {
// Redis 연결 설정 하는 코드
}
getCacheService(tenantId: string) {
if (!CacheServiceManager.cacheServices.has(tenantId)) {
throw new BadRequestException('Invalid tenantId');
}
return CacheServiceManager.cacheServices.get(tenantId);
}
}주요 설계 결정:
- 정적 멤버 사용해서 클라이언트는 전역으로 관리하자:
cacheServices와redisUrls를 정적으로 선언하여 애플리케이션 전역에서 공유되도록 했습니다. - 애플리케이션이 실행될 때 모두 연결할까?:
init메서드를 통해 애플리케이션 시작 시점에 각 테넌트별 Redis 클라이언트를 생성했습니다. 이 부분은 조금 더 고민이 필요한데, 애플리케이션의 시작 시점에서 초기화를 할 지, 혹은 각 테넌트의 최초 요청에서 초기화할 지 크게 중요하지 않은 것 같아 전자를 사용했습니다. - 테넌트끼리는 데이터 격리 유지: 각 테넌트별로 별도의 Redis 클라이언트를 사용함으로써 데이터가 섞이는 것을 방지했습니다.
CacheService의 구현과 테넌트별 데이터 격리
CacheService는 Redis와의 통신을 담당하는 역할을 하고, 각 테넌트별로 인스턴스가 생성됩니다. Redis를 호출하고 응답을 받는 것은 오롯이 CacheService가 담당합니다.
@Injectable()
export class CacheService {
constructor(
private readonly client: RedisClientType,
private readonly tenantId: string,
) {}
private addTenantIdPrefix(key: string) {
return `${this.tenantId}:${key}`;
}
// 다양한 Redis 명령어 메서드를 통해 nest에서 redis를 호출하기 예시는 setEx
setEx(key: string, ttl: number, data: string) {
const client = this.getOrThrowClient();
return this.redisCommandMapper<any>(
client.setEx(this.addTenantIdPrefix(key), ttl, data),
'setEx',
);
}
private async redisCommandMapper<T>(func: any, cmdName: string): Promise<T> {
try {
const data = await func;
return data as T;
} catch (err) {
throw new InternalServerErrorException(
`Can't execute redis command: ${cmdName}, Because ${err}`,
);
}
}
private getOrThrowClient() {
if (!this.client) {
throw new InternalServerErrorException(
`Can't found redis client for ${this.tenantId}`,
);
}
return this.client;
}
}핵심 설계 포인트:
- 테넌트별 prefix를 추가해서 같은 캐시 인스턴스에서도 구분하자:
addTenantIdPrefix메서드를 통해 모든 캐시 키에 테넌트 ID를 prefix로 추가했습니다. Redis 자체를 분리하면 가장 좋겠지만, 테넌트 별로 분리할지, 리전별로 분리할지 상황에 따라서 바뀔 수 있다고 생각했습니다. 두 가지를 모두 만족할 수 있도록 prefix를 사용하여 동일 인스턴스에서도 격리될 수 있도록 했습니다. - 단일 책임으로 에러 핸들링을 편하게 하자:
CacheService를 분리한 것은 궁극적으로 Redis와 소통을 담당하는 단일 역할을 주기 위해서였습니다. 이렇게 되면 Redis와 연결에서 에러가 발생했을 때 어떻게 처리할지를CacheService내부에서 전부 핸들링할 수 있습니다.redisCommandMapper함수를 통해 처리할 수 있게 했습니다.
요청마다 올바른 CacheService를 주입하기
각 요청마다 어떤 테넌트가 요청했는지를 파악하고, 해당 테넌트에 맞는 CacheService를 주입해야 했습니다.
이 동작은 이미 RDS도 이런 식으로 동작하고 있기 때문에 적용하기가 어렵지는 않았습니다. 요청 스코프에서 CacheService를 주입하는 CacheServiceProvider를 만들어주었습니다.
export const CacheServiceProvider: FactoryProvider<CacheService> = {
provide: CacheService,
scope: Scope.REQUEST,
useFactory: (
ctxPayload: { tenantId: string },
manager: CacheServiceManager,
) => {
return manager.getCacheService(ctxPayload.tenantId);
},
inject: [REQUEST, CacheServiceManager],
};구현 요점:
- 요청 스코프에서 주입하기:
scope: Scope.REQUEST를 지정하여 각 요청마다 별도의CacheService인스턴스를 주입받도록 했습니다. - 팩토리 프로바이더 사용:
useFactory를 통해 런타임에tenantId를 기반으로CacheServiceManager에서 올바른CacheService를 가져옵니다.
Cacheable 데코레이터 구현
이제 핵심인 Cacheable 데코레이터입니다.
이 데코레이터는 함수의 결과를 캐싱하여 동일한 입력에 대해 연산이나 DB 접근을 방지할 수 있습니다.
RDB는 캐시에 비해서 요청/응답이 느리고 비용이 크기 때문에 결과가 자주 바뀌지 않을 요청에 대해서는 특정 시간동안 캐싱을 해두고 사용하면 비용을 줄일 수 있습니다.
이 데코레이터는 컨트롤러, 서비스, 레포지토리 등 어떤 레이어에서도 간편하게 사용할 수 있도록 했습니다.
import { Inject } from '@nestjs/common';
import { CacheService } from './cache.service';
export interface CacheableOptions {
key: string;
ttl?: number;
canRecomputeAfterTtl?: number;
dynamicKeyPath?: {
argsIndex: number;
path?: string;
};
}
export function Cacheable(options: CacheableOptions): MethodDecorator {
const {
key,
ttl = 0,
canRecomputeAfterTtl = 60,
dynamicKeyPath,
} = options;
const injectCacheService = Inject(CacheService);
return (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) => {
injectCacheService(target, 'cacheService');
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const cacheService: CacheService = this.cacheService;
let cacheKey = '';
try {
cacheKey = generateCacheKey(args, dynamicKeyPath, key);
const cachedValue = await cacheService.get(cacheKey);
if (cachedValue) {
return JSON.parse(cachedValue);
}
} catch (error) {
console.error(error);
}
const result = await originalMethod.apply(this, args);
try {
const effectiveTtl = calculateEffectiveTtl(ttl, canRecomputeAfterTtl);
await cacheService.setEx(
cacheKey,
effectiveTtl,
JSON.stringify(result),
);
} catch (error) {
console.error(error);
}
return result;
};
return descriptor;
};
}데코레이터 각 부분에 대한 간단한 설명
데코레이터 내부에서 의존성 주입하기:
Inject(CacheService)를 사용하여cacheService를 주입했습니다. 데코레이터는 클래스의 생성자에 접근할 수 없기 때문에, 프로퍼티 기반 주입을 사용하여this.cacheService를 사용할 수 있게 했습니다.동적으로 키를 생성하기:
generateCacheKey함수를 통해 고정 키(key)와 동적 키(dynamicKeyPath)를 조합하여 고유한 캐시 키를 생성했습니다. 이렇게 하면 동일한 함수라도 입력값에 따라 다른 캐시를 사용할 수 있습니다.캐시를 조회해서 값이 없으면 원래 실행하려던 함수 실행: 캐시에서 값을 조회하고, 캐시가 없을 경우 원본 함수를 실행하여 결과를 가져옵니다.
캐시 저장: 원본 함수의 실행 결과를 캐시에 저장합니다.
calculateEffectiveTtl함수를 사용하여 TTL을 랜덤하게 설정하여 캐시 갱신 시점을 분산시켰습니다.아직은 동시에 캐싱 데이터가 만들어질 일이 없지만, 연산이 복잡한 로직 여러 개가 같은 타이밍에 캐시가 만료되어서, 동시에 재연산을 하게 되면 CPU에 부하가 걸리거나 RDB에 부하가 옮겨가서 서비스 전체가 영향을 받을 수 있습니다. 이를 방지하기 위해서 TTL을 범위 안에서 랜덤하게 조절합니다.
에러 처리: 캐싱을 사용할 때, Redis가 죽으면 해당 메서드 자체가 동작하지 못하게 설계되어있는 코드들이 있습니다. Redis가 정상 동작하지 않더라도 원본 로직은 제대로 실행될 수 있도록
try-catch로 에러를 잡고 로그만 출력했습니다.
사용 예시
이제 Cacheable 데코레이터를 실제로 어떻게 사용하는지 예시를 보겠습니다.
import { Injectable } from '@nestjs/common';
import { Cacheable } from './cacheable.decorator';
@Injectable()
export class SomeService {
@Cacheable({
key: 'getData',
ttl: 300,
dynamicKeyPath: {
argsIndex: 0,
path: 'userId',
},
})
async getData(dto: { userId: string }) {
// 복잡한 연산이나 DB 조회 등 시간 소요되는 작업
const result = await this.someRepository.findData(dto.userId);
return result;
}
}굳이굳이 이렇게 해야할까?
컨트롤러 메소드의 값을 캐싱하는 간단한 방법은 이미 NestJS에서 제공해주고 있고, 공식문서에서도 설명해주고 있습니다.
다만 굳이 직접 만들어서 사용하는 이유는 제가 원하는 기능을 Nest에서 제대로 지원해주지 않기 때문이랄까요?
특정 범위 내에서 랜덤으로 TTL을 만드는 방식이나, 에러 핸들링 등, 내 서비스에서 동작하는 코드, 요구사항에 맞춘 기능을 커스터마이징 할 수 있어서 좋았습니다.
무엇보다 보이지 않는 적과 싸움을 줄일 수 있습니다. 예를 들어 Nest에서 제공해주는 다른 패키지, 모듈을 사용했다면 거기 안에서 어떤 식으로 예외처리가 되는지, 그러면 또 우리는 무슨 에러를 잡아서 핸들링해줘야하는지 전부 다 파악을 해야합니다. 이러한 내용은 기록으로 남기기가 어렵기 때문에 다른 개발자들에게 인수인계를 하기가 참 어렵습니다.
하지만 이렇게 코드로 직접 만들어서 사용하면, 모든 개발자들이 코드를 보고 흐름을 이해할 수 있다는 점에서 참 좋은 것 같습니다.
(물론 코드를 잘 짜야 이해하기 쉽겠지만)
마치며
이번 글에서는 NestJS에서 멀티 테넌시 환경에 맞춘 커스텀 캐싱 데코레이터를 구현하는 과정을 정리했습니다.
핵심 포인트를 요약하면:
CacheServiceManager로 테넌트별 Redis 클라이언트 관리CacheService로 단일 책임 원칙을 지키며 에러 핸들링 중앙화- 요청 스코프 팩토리 프로바이더로 올바른
CacheService주입 Cacheable데코레이터로 모든 레이어에서 간편하게 사용- TTL 랜덤화로 동시 캐시 만료 방지
- 에러에 강건한 구조로 Redis 장애 시에도 원본 로직 실행 보장
직접 만든 도구는 프로젝트의 요구사항에 정확히 맞출 수 있고, 팀원들이 코드로 동작을 이해할 수 있다는 장점이 있습니다.


