CoreRedisModule 구현 가이드라인
1. 개요
CoreRedisModule은 DTA-WIDE 프로젝트에서 Redis 캐싱 및 관련 기능을 제공하는 핵심 라이브러리입니다. NestJS의 CacheModule과 cache-manager-redis-store를 사용하여 Redis 서버와의 상호작용을 관리하고, 분산 캐싱, 세션 관리, 메시지 큐 등의 기능을 지원합니다.
2. 설계 원칙
- 추상화: Redis의 복잡한 세부 사항을 숨기고 간단한 인터페이스 제공
- 성능: 빠른 데이터 접근을 위한 인메모리 캐싱 활용
- 확장성: 다양한 Redis 설정 및 클러스터링 지원
- 일관성: 프로젝트 전반에 걸쳐 일관된 캐싱 전략 적용
- 타입 안전성: 가능한 경우 타입 추론 및 제네릭 활용
3. 주요 컴포넌트
3.1 CacheModule (from @nestjs/common)
NestJS의 기본 캐싱 모듈
3.2 RedisStore (from cache-manager-redis-store)
CacheModule이 Redis를 저장소로 사용하도록 하는 팩토리
3.3 RedisService (Custom Service)
Redis 관련 커스텀 로직 또는 편의 기능을 제공하는 서비스 (선택 사항)
3.4 RedisHealthIndicator (from @nestjs/terminus)
Redis 연결 상태를 확인하는 헬스 체크 인디케이터
4. 구현 가이드
4.1 프로젝트 구조
libs/core/redis/
├── src/
│ ├── lib/
│ │ ├── redis.module.ts
│ │ ├── redis.service.ts # 선택적 사용자 정의 서비스
│ │ ├── redis.config.ts # Redis 설정 관련
│ │ └── index.ts
│ └── index.ts
└── test/
4.2 Redis 설정
RedisModule 설정 예시:
// libs/core/redis/src/lib/redis.module.ts
import { Module, Global, DynamicModule } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store';
import { ConfigModule, ConfigService } from '@core/config';
import { RedisService } from './redis.service'; // 선택적 서비스
@Global()
@Module({})
export class CoreRedisModule {
static forRoot(): DynamicModule {
return {
module: CoreRedisModule,
imports: [
ConfigModule,
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
store: redisStore,
host: configService.getRequired<string>('REDIS_HOST'),
port: configService.getRequired<number>('REDIS_PORT'),
password: configService.get<string>('REDIS_PASSWORD'),
ttl: configService.get<number>('CACHE_TTL', 60), // 기본 TTL (초)
// 추가 Redis 옵션 설정 가능
}),
isGlobal: true, // CacheModule을 글로벌로 설정
}),
],
providers: [RedisService], // 선택적 서비스 등록
exports: [CacheModule, RedisService], // CacheModule 및 선택적 서비스 Export
};
}
}
4.3 RedisService 구현 (선택 사항)
@nestjs/cache-manager의 CacheManager는 표준적인 캐싱 요구사항에 매우 유용하지만, Redis의 Set 기능을 활용한 컨텍스트 기반의 복잡한 캐시 무효화나 분산 락 같은 고급 기능을 직접 지원하지는 않습니다. 만약 이러한 Redis 특화 기능이 필요하다면, 별도의 RedisService를 구현하여 CacheManager와 함께 사용하는 것을 고려할 수 있습니다.
이 RedisService는 CACHE_MANAGER를 주입받아 기본적인 캐시 인터페이스에 접근하는 동시에, 내부적으로 실제 Redis 클라이언트 인스턴스에 접근하여 Redis의 모든 명령어를 사용할 수 있도록 구현합니다. 이를 통해 복잡한 Redis 로직을 캡슐화하고 다른 서비스에서는 간편하게 사용할 수 있습니다.
// libs/core/redis/src/lib/redis.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
// 실제 사용하는 Redis 스토어 라이브러리에 따라 타입과 접근 방식이 다를 수 있습니다.
// 예시: cache-manager-redis-store 사용 시
import { RedisStore } from 'cache-manager-redis-store';
import { RedisClientType } from 'redis'; // 예시: 'redis' 라이브러리 사용 시
// 레거시 코드 또는 프로젝트의 키 생성 규칙을 따르는 Enum (예시)
enum CacheType { REFERENCES = 'references' }
enum CacheReferenceType { CONTEXTS = 'contexts' }
enum CacheResource { USERS = 'users' }
@Injectable()
export class RedisService {
private readonly logger = new Logger(RedisService.name);
private redisClient: RedisClientType | undefined;
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {
this.initializeRedisClient();
}
private initializeRedisClient(): void {
try {
// cacheManager.store를 통해 내부 스토어 인스턴스에 접근
const store = this.cacheManager.store as RedisStore;
if (store && typeof store.getClient === 'function') {
// 실제 Redis 클라이언트 인스턴스를 가져옴
this.redisClient = store.getClient();
this.logger.log('Successfully accessed underlying Redis client.');
} else {
this.logger.warn(
'Could not access underlying Redis client from CacheManager store.'
);
}
} catch (error) {
this.logger.error('Failed to initialize Redis client access', error);
}
}
private getClient(): RedisClientType {
if (!this.redisClient) {
throw new Error(
'Redis client is not available. Check Redis module configuration and connection.'
);
}
return this.redisClient;
}
/**
* CacheManager 인스턴스를 반환합니다.
* (참고: 간단한 캐싱은 CACHE_MANAGER를 직접 주입하여 사용)
*/
getCacheManager(): Cache {
return this.cacheManager;
}
/**
* 특정 키 패턴에 해당하는 모든 키를 조회합니다. (SCAN 사용 권장)
*/
async getKeysByPattern(pattern: string): Promise<string[]> {
const client = this.getClient();
const keys = [];
let cursor = '0';
try {
do {
// SCAN 명령어 사용 (운영 환경에서 KEYS보다 안전)
const reply = await client.scan(cursor, {
MATCH: pattern,
COUNT: 100, // 한 번에 가져올 키 개수 (조절 가능)
});
cursor = String(reply.cursor); // cursor 타입 확인 필요
keys.push(...reply.keys);
} while (cursor !== '0');
return keys;
} catch (error) {
this.logger.error(`Failed to get keys by pattern: ${pattern}`, error);
throw error;
}
}
/**
* 키와 값, TTL을 설정하고, 지정된 컨텍스트 ID 및 사용자 ID와 연결합니다.
* 컨텍스트 참조는 Redis Set을 사용하여 관리합니다.
*/
async setWithUserContext<T>(
key: string,
userId: number,
contextIds: number[],
value: T,
ttl?: number, // 초 단위 TTL (선택 사항)
): Promise<void> {
const client = this.getClient();
try {
// 1. CacheManager를 사용하여 기본 키-값 저장 (직렬화는 CacheManager에 위임)
await this.cacheManager.set(key, value, ttl);
// 2. 컨텍스트 참조를 Redis Set에 저장
for (const contextId of contextIds) {
const referenceKey = this.generateContextReferenceKey(contextId, userId);
// 해당 컨텍스트-사용자 조합에 대한 Set에 캐시 키(key)를 추가
await client.sAdd(referenceKey, key);
// 필요시 참조 키 자체에도 TTL 설정 가능 (관리는 더 복잡해짐)
// await client.expire(referenceKey, ttl); // 예시
}
this.logger.debug(`Set cache ${key} with user context ${userId}`);
} catch (error) {
this.logger.error(
`Failed to set cache with user context: ${key}, user ${userId}`,
error
);
throw error;
}
}
/**
* 특정 컨텍스트 ID와 사용자 ID에 연결된 모든 캐시 항목을 무효화합니다.
*/
async clearByUserContext(contextId: number, userId: number): Promise<void> {
const client = this.getClient();
const referenceKey = this.generateContextReferenceKey(contextId, userId);
try {
// 1. 컨텍스트 참조 Set에서 연결된 모든 캐시 키들을 가져옴
const keysToInvalidate = await client.sMembers(referenceKey);
if (keysToInvalidate.length > 0) {
// 2. CacheManager를 사용하여 각 캐시 키를 삭제
await Promise.all(
keysToInvalidate.map((key) => this.cacheManager.del(key))
);
// 또는 Redis 클라이언트로 직접 삭제: await client.del(keysToInvalidate);
}
// 3. 컨텍스트 참조 Set 자체를 삭제
await client.del(referenceKey);
this.logger.log(
`Cleared cache for user ${userId} in context ${contextId}`
);
} catch (error) {
this.logger.error(
`Failed to clear cache by user context: user ${userId}, context ${contextId}`,
error
);
throw error;
}
}
/**
* 특정 컨텍스트 ID에 연결된 모든 사용자들의 캐시 항목을 무효화합니다.
* (주의: SCAN을 사용하므로 많은 참조 키가 있을 경우 성능 영향 고려)
*/
async clearByContext(contextId: number): Promise<void> {
const client = this.getClient();
// 예시 패턴: "references:contexts:123:users:*" (* 사용 주의)
const pattern = `${CacheType.REFERENCES}:${CacheReferenceType.CONTEXTS}:${contextId}:${CacheResource.USERS}:*`;
let cursor = '0';
try {
do {
const reply = await client.scan(cursor, {
MATCH: pattern,
COUNT: 100,
});
cursor = String(reply.cursor);
const referenceKeys = reply.keys;
for (const refKey of referenceKeys) {
const keysToInvalidate = await client.sMembers(refKey);
if (keysToInvalidate.length > 0) {
await Promise.all(
keysToInvalidate.map((key) => this.cacheManager.del(key))
);
// await client.del(keysToInvalidate); // 직접 삭제 시
}
// 스캔한 참조 키 자체도 삭제
await client.del(refKey);
}
} while (cursor !== '0');
this.logger.log(`Cleared all cache entries for context ${contextId}`);
} catch (error) {
this.logger.error(
`Failed to clear cache by context: context ${contextId}`,
error
);
throw error;
}
}
// 컨텍스트 참조 키 생성 로직 (프로젝트 규칙에 맞게 조정)
private generateContextReferenceKey(contextId: number, userId: number): string {
return `${CacheType.REFERENCES}:${CacheReferenceType.CONTEXTS}:${contextId}:${CacheResource.USERS}:${userId}`;
}
// 여기에 추가적인 Redis 특화 기능 메서드들을 구현할 수 있습니다.
// 예: 분산 락 (tryLock), HyperLogLog 사용 등
}
사용 시 고려사항:
- 의존성: 이
RedisService는 내부적으로 특정 Redis 스토어 어댑터(cache-manager-redis-store)와 Redis 클라이언트 라이브러리(redis)의 구현 방식에 의존하게 됩니다. 라이브러리 버전 변경 시 호환성을 확인해야 합니다. - 복잡성:
SCAN명령어는KEYS보다 안전하지만, 여전히 많은 키를 스캔해야 하는 경우 Redis 서버에 부하를 줄 수 있습니다. 컨텍스트 관리 대상 키의 규모를 고려해야 합니다.
이러한 RedisService를 구현하면, 일반적인 캐싱은 CACHE_MANAGER를 직접 주입하여 간단하게 사용하고, 컨텍스트 기반의 복잡한 무효화가 필요한 경우에는 이 RedisService를 주입하여 해당 기능을 사용할 수 있습니다.
4.4 헬스 체크 통합
@nestjs/terminus를 사용하여 Redis 연결 상태를 확인하는 헬스 체크 엔드포인트를 구현할 수 있습니다.
// apps/dta-wide-api/src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
RedisHealthIndicator, // 수정: Terminus의 Redis 인디케이터 사용
} from '@nestjs/terminus';
import { ConfigService } from '@core/config';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private redis: RedisHealthIndicator, // 수정: Redis 인디케이터 주입
private configService: ConfigService,
) {}
@Get()
@HealthCheck()
check() {
const redisHost = this.configService.getRequired<string>('REDIS_HOST');
const redisPort = this.configService.getRequired<number>('REDIS_PORT');
return this.health.check([
// 다른 헬스 체크 항목들...
() =>
this.redis.pingCheck('redis', {
// 수정: pingCheck 사용 및 옵션 전달
host: redisHost,
port: redisPort,
password: this.configService.get<string>('REDIS_PASSWORD'),
// 필요시 추가 ioredis 옵션 전달 가능
}),
]);
}
}
5. 사용 예시
5.1 모듈에서 사용
CoreRedisModule.forRoot()를 루트 모듈(AppModule)에 임포트합니다. CacheModule이 @Global()로 설정되어 있으므로 다른 모듈에서 별도로 임포트할 필요 없이 CACHE_MANAGER를 주입받아 사용할 수 있습니다.
// apps/dta-wide-api/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { CoreConfigModule } from '@core/config';
import { CoreDatabaseModule } from '@core/database';
import { CoreRedisModule } from '@core/redis';
// ... 다른 모듈 임포트
@Module({
imports: [
CoreConfigModule.forRoot(),
CoreDatabaseModule.forRoot(),
CoreRedisModule.forRoot(), // Redis 모듈 임포트
// ... 다른 모듈
],
// ... 컨트롤러, 프로바이더
})
export class AppModule {}
5.2 서비스에서 캐시 사용
각 도메인별로 캐시 서비스를 구현하여 캐시 키 생성과 캐싱 로직을 중앙화하는 것을 권장합니다.
// libs/feature/product/src/lib/infrastructure/cache/product-cache.service.ts
import { Injectable } from '@nestjs/common';
import { RedisService } from '@core/redis';
import { LoggerService } from '@core/logging';
import { secondsInHour } from '@shared/utils';
// 캐시 키 생성 factory 함수
export const createProductCacheKeys = () => ({
PRODUCT_BY_ID: (productId: string) => `product:details:${productId}`,
PRODUCT_LIST: () => `product:list:all`,
PRODUCT_BY_CATEGORY: (categoryId: string) => `product:category:${categoryId}`,
});
@Injectable()
export class ProductCacheService {
private readonly cacheKeys: ReturnType<typeof createProductCacheKeys>;
// 캐시 TTL 값들
private readonly CACHE_TTL = {
PRODUCT_DETAILS: 6 * secondsInHour, // 6시간
PRODUCT_LIST: 1 * secondsInHour, // 1시간
PRODUCT_BY_CATEGORY: 2 * secondsInHour, // 2시간
};
constructor(
private readonly redisService: RedisService,
private readonly logger: LoggerService
) {
this.cacheKeys = createProductCacheKeys();
this.logger.setContext(ProductCacheService.name);
}
// 제품 정보 조회
async getProductById(productId: string): Promise<Product | null> {
const key = this.cacheKeys.PRODUCT_BY_ID(productId);
try {
const cached = await this.redisService.get(key);
if (cached) {
return JSON.parse(cached) as Product;
}
return null;
} catch (error) {
this.logger.warn(`제품 캐시 조회 중 오류: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
// 제품 정보 저장
async setProductById(productId: string, product: Product): Promise<void> {
const key = this.cacheKeys.PRODUCT_BY_ID(productId);
try {
await this.redisService.set(
key,
JSON.stringify(product),
this.CACHE_TTL.PRODUCT_DETAILS
);
this.logger.debug(`제품 캐시 저장 완료: ${productId}`);
} catch (error) {
this.logger.warn(`제품 캐시 저장 중 오류: ${error instanceof Error ? error.message : String(error)}`);
}
}
// 제품 캐시 무효화
async invalidateProductById(productId: string): Promise<void> {
const key = this.cacheKeys.PRODUCT_BY_ID(productId);
try {
await this.redisService.del(key);
this.logger.debug(`제품 캐시 무효화 완료: ${productId}`);
} catch (error) {
this.logger.warn(`제품 캐시 무효화 중 오류: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
interface Product { // 예시 인터페이스
id: string;
name: string;
price: number;
}
서비스에서는 이 캐시 서비스를 주입하여 사용합니다:
// libs/feature/product/src/lib/product.service.ts
import { Injectable } from '@nestjs/common';
import { ProductCacheService } from './infrastructure/cache/product-cache.service';
// ... 다른 임포트
@Injectable()
export class ProductService {
constructor(
private readonly productCacheService: ProductCacheService,
private readonly productRepository: ProductRepository,
// ... 다른 서비스 주입
) {}
async getProductById(id: string): Promise<Product | null> {
// 캐시에서 먼저 조회
let product = await this.productCacheService.getProductById(id);
if (product) {
console.log(`Cache hit for product: ${id}`);
return product;
}
console.log(`Cache miss for product: ${id}`);
// 데이터베이스에서 조회
product = await this.productRepository.findById(id);
if (product) {
// 조회된 데이터를 캐시에 저장
await this.productCacheService.setProductById(id, product);
}
return product;
}
async updateProduct(id: string, data: Partial<Product>): Promise<Product | null> {
const product = await this.productRepository.update(id, data);
if (product) {
// 데이터 변경 시 캐시 무효화
await this.productCacheService.invalidateProductById(id);
}
return product;
}
}
// 예시 인터페이스
interface ProductRepository {
findById(id: string): Promise<Product | null>;
update(id: string, data: Partial<Product>): Promise<Product | null>;
}
5.2.1 고급 캐싱 예시 - 복잡한 시나리오
다음은 주문(Order) 도메인에서 캐시 서비스를 구현하는 더 복잡한 예시입니다:
// libs/feature/order/src/lib/infrastructure/cache/order-cache.service.ts
import { Injectable } from '@nestjs/common';
import { RedisService } from '@core/redis';
import { LoggerService } from '@core/logging';
import { secondsInHour, secondsInMinute } from '@shared/utils';
// 캐시 키 생성 factory 함수
export const createOrderCacheKeys = () => ({
USER_ORDERS: (userId: string) => `private:${userId}:order:list`,
USER_ORDERS_IN_CONTEXT: (userId: string, contextId: string) =>
`private:${userId}:order:context:${contextId}`,
ORDER_BY_ID: (orderId: string) => `order:details:${orderId}`,
ORDER_STATUS: (orderId: string) => `order:status:${orderId}`,
RECENT_ORDERS: (shopId: string) => `shop:${shopId}:recent-orders`,
});
@Injectable()
export class OrderCacheService {
private readonly cacheKeys: ReturnType<typeof createOrderCacheKeys>;
// 캐시 TTL 값들
private readonly CACHE_TTL = {
ORDER_LIST: 30 * secondsInMinute, // 30분
ORDER_DETAILS: 2 * secondsInHour, // 2시간
ORDER_STATUS: 5 * secondsInMinute, // 5분 (자주 변경됨)
RECENT_ORDERS: 10 * secondsInMinute, // 10분
};
constructor(
private readonly redisService: RedisService,
private readonly logger: LoggerService
) {
this.cacheKeys = createOrderCacheKeys();
this.logger.setContext(OrderCacheService.name);
}
// 사용자의 주문 목록 조회
async getUserOrders(userId: string): Promise<Order[] | null> {
const key = this.cacheKeys.USER_ORDERS(userId);
try {
const cached = await this.redisService.get(key);
if (cached) {
return JSON.parse(cached) as Order[];
}
return null;
} catch (error) {
this.logger.warn(`주문 목록 캐시 조회 중 오류: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
// 사용자의 주문 목록 저장
async setUserOrders(userId: string, orders: Order[]): Promise<void> {
const key = this.cacheKeys.USER_ORDERS(userId);
try {
await this.redisService.set(
key,
JSON.stringify(orders),
this.CACHE_TTL.ORDER_LIST
);
this.logger.debug(`사용자 ${userId}의 주문 목록 캐시 저장 완료`);
} catch (error) {
this.logger.warn(`주문 목록 캐시 저장 중 오류: ${error instanceof Error ? error.message : String(error)}`);
}
}
// 컨텍스트별 사용자 주문 조회
async getUserOrdersInContext(userId: string, contextId: string): Promise<Order[] | null> {
const key = this.cacheKeys.USER_ORDERS_IN_CONTEXT(userId, contextId);
try {
const cached = await this.redisService.get(key);
if (cached) {
return JSON.parse(cached) as Order[];
}
return null;
} catch (error) {
this.logger.warn(`컨텍스트별 주문 캐시 조회 중 오류: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
// 컨텍스트별 사용자 주문 저장
async setUserOrdersInContext(userId: string, contextId: string, orders: Order[]): Promise<void> {
const key = this.cacheKeys.USER_ORDERS_IN_CONTEXT(userId, contextId);
try {
await this.redisService.set(
key,
JSON.stringify(orders),
this.CACHE_TTL.ORDER_LIST
);
this.logger.debug(`사용자 ${userId}의 컨텍스트 ${contextId} 주문 캐시 저장 완료`);
} catch (error) {
this.logger.warn(`컨텍스트별 주문 캐시 저장 중 오류: ${error instanceof Error ? error.message : String(error)}`);
}
}
// 개별 주문 정보 조회
async getOrderById(orderId: string): Promise<Order | null> {
const key = this.cacheKeys.ORDER_BY_ID(orderId);
try {
const cached = await this.redisService.get(key);
if (cached) {
return JSON.parse(cached) as Order;
}
return null;
} catch (error) {
this.logger.warn(`주문 상세 캐시 조회 중 오류: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
// 개별 주문 정보 저장
async setOrderById(orderId: string, order: Order): Promise<void> {
const key = this.cacheKeys.ORDER_BY_ID(orderId);
try {
await this.redisService.set(
key,
JSON.stringify(order),
this.CACHE_TTL.ORDER_DETAILS
);
this.logger.debug(`주문 ${orderId} 캐시 저장 완료`);
} catch (error) {
this.logger.warn(`주문 상세 캐시 저장 중 오류: ${error instanceof Error ? error.message : String(error)}`);
}
}
// 사용자의 모든 주문 관련 캐시 무효화
async invalidateUserOrderCache(userId: string): Promise<void> {
try {
// 패턴 기반 삭제 (주의: 프로덕션에서는 SCAN 사용 권장)
const pattern = `private:${userId}:order:*`;
const keys = await this.redisService.keys(pattern);
if (keys.length > 0) {
await Promise.all(keys.map(key => this.redisService.del(key)));
this.logger.debug(`사용자 ${userId}의 주문 캐시 ${keys.length}개 무효화 완료`);
}
} catch (error) {
this.logger.error(`사용자 주문 캐시 무효화 중 오류: ${error instanceof Error ? error.message : String(error)}`);
}
}
// 컨텍스트별 주문 캐시 무효화
async invalidateContextOrderCache(contextId: string): Promise<void> {
try {
// 해당 컨텍스트와 관련된 모든 사용자의 주문 캐시 무효화
const pattern = `private:*:order:context:${contextId}`;
const keys = await this.redisService.keys(pattern);
if (keys.length > 0) {
await Promise.all(keys.map(key => this.redisService.del(key)));
this.logger.debug(`컨텍스트 ${contextId}의 주문 캐시 ${keys.length}개 무효화 완료`);
}
} catch (error) {
this.logger.error(`컨텍스트 주문 캐시 무효화 중 오류: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
// 예시 인터페이스
interface Order {
id: string;
userId: string;
contextId: string;
items: any[];
status: string;
createdAt: Date;
}
서비스에서 캐시 서비스 사용:
// libs/feature/order/src/lib/order.service.ts
import { Injectable } from '@nestjs/common';
import { OrderCacheService } from './infrastructure/cache/order-cache.service';
import { OrderRepository } from './infrastructure/repositories/order.repository';
import { LoggerService } from '@core/logging';
@Injectable()
export class OrderService {
constructor(
private readonly orderCacheService: OrderCacheService,
private readonly orderRepository: OrderRepository,
private readonly logger: LoggerService
) {
this.logger.setContext(OrderService.name);
}
/**
* 특정 사용자의 주문 목록을 조회합니다.
*/
async getUserOrders(userId: string, contextId?: string): Promise<Order[]> {
try {
// 컨텍스트가 있는 경우 컨텍스트별 캐시 확인
if (contextId) {
const cachedOrders = await this.orderCacheService.getUserOrdersInContext(userId, contextId);
if (cachedOrders) {
this.logger.debug(`캐시 히트: 사용자 ${userId}, 컨텍스트 ${contextId}`);
return cachedOrders;
}
} else {
// 전체 주문 목록 캐시 확인
const cachedOrders = await this.orderCacheService.getUserOrders(userId);
if (cachedOrders) {
this.logger.debug(`캐시 히트: 사용자 ${userId} 전체 주문`);
return cachedOrders;
}
}
// 캐시 미스 시 DB에서 조회
this.logger.debug(`캐시 미스: 사용자 ${userId}`);
const orders = contextId
? await this.orderRepository.findByUserAndContext(userId, contextId)
: await this.orderRepository.findByUser(userId);
// 캐시에 저장
if (orders.length > 0) {
if (contextId) {
await this.orderCacheService.setUserOrdersInContext(userId, contextId, orders);
} else {
await this.orderCacheService.setUserOrders(userId, orders);
}
}
return orders;
} catch (error) {
this.logger.error(`주문 조회 중 오류 발생: ${error instanceof Error ? error.message : String(error)}`);
// 오류 발생 시 캐시 없이 DB 직접 조회
return contextId
? await this.orderRepository.findByUserAndContext(userId, contextId)
: await this.orderRepository.findByUser(userId);
}
}
/**
* 주문 상태를 업데이트합니다.
*/
async updateOrderStatus(orderId: string, newStatus: string): Promise<Order | null> {
const order = await this.orderRepository.updateStatus(orderId, newStatus);
if (order) {
// 주문이 업데이트되면 관련 캐시 무효화
await Promise.all([
// 사용자의 주문 목록 캐시 무효화
this.orderCacheService.invalidateUserOrderCache(order.userId),
// 해당 주문의 상세 정보 캐시도 무효화
this.redisService.del(this.orderCacheService.cacheKeys.ORDER_BY_ID(orderId)),
]);
this.logger.log(`주문 ${orderId} 상태 업데이트 및 캐시 무효화 완료`);
}
return order;
}
/**
* 상점 정보가 업데이트되었을 때 관련 캐시를 무효화합니다.
*/
async handleShopUpdate(shopId: string): Promise<void> {
// 상점(컨텍스트)과 관련된 모든 주문 캐시 무효화
await this.orderCacheService.invalidateContextOrderCache(shopId);
this.logger.log(`상점 ${shopId} 업데이트로 인한 캐시 무효화 완료`);
}
}
// 예시 인터페이스
interface OrderRepository {
findByUser(userId: string): Promise<Order[]>;
findByUserAndContext(userId: string, contextId: string): Promise<Order[]>;
updateStatus(orderId: string, status: string): Promise<Order | null>;
}
이 예시처럼, 기본적인 캐싱은 CACHE_MANAGER를 사용하고, 컨텍스트 관리나 Redis Set 조작이 필요한 복잡한 로직은 RedisService를 주입하여 처리함으로써, 각 서비스의 역할을 명확히 하고 코드의 가독성과 유지보수성을 높일 수 있습니다.
5.3 커스텀 캐시 인터셉터 구현 (선택 사항)
반복적인 캐시 로직을 줄이기 위해 캐시 서비스를 활용하는 커스텀 인터셉터를 구현할 수 있습니다.
// libs/feature/product/src/lib/infrastructure/interceptors/product-cache.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { ProductCacheService } from '../cache/product-cache.service';
import { LoggerService } from '@core/logging';
@Injectable()
export class ProductCacheInterceptor implements NestInterceptor {
constructor(
private readonly productCacheService: ProductCacheService,
private readonly logger: LoggerService
) {
this.logger.setContext(ProductCacheInterceptor.name);
}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const productId = request.params.id;
if (!productId) {
return next.handle();
}
// 캐시에서 먼저 확인
const cachedProduct = await this.productCacheService.getProductById(productId);
if (cachedProduct) {
this.logger.debug(`캐시 히트 (인터셉터): 제품 ${productId}`);
return of(cachedProduct);
}
// 캐시 미스 시 핸들러 실행 후 결과 캐싱
this.logger.debug(`캐시 미스 (인터셉터): 제품 ${productId}`);
return next.handle().pipe(
tap(async (product) => {
if (product) {
await this.productCacheService.setProductById(productId, product);
}
}),
);
}
}
컨트롤러에서 커스텀 인터셉터 사용:
// libs/feature/product/src/lib/product.controller.ts
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { ProductService } from './product.service';
import { ProductCacheInterceptor } from './infrastructure/interceptors/product-cache.interceptor';
@Controller('products')
export class ProductController {
constructor(private readonly productService: ProductService) {}
@Get(':id')
@UseInterceptors(ProductCacheInterceptor) // 커스텀 캐시 인터셉터 적용
async getProduct(@Param('id') id: string) {
// 인터셉터가 캐시를 확인하고, 없으면 아래 로직 실행 후 결과를 캐싱함
return this.productService.getProductById(id);
}
}
6. 테스트 전략
6.1 테스트 설정
캐싱 로직을 테스트하기 위해 캐시 서비스를 모킹하고, 독립적으로 테스트합니다.
// libs/feature/product/test/product.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ProductService } from '../src/lib/product.service';
import { ProductCacheService } from '../src/lib/infrastructure/cache/product-cache.service';
import { ProductRepository } from '../src/lib/infrastructure/repositories/product.repository';
describe('ProductService', () => {
let service: ProductService;
let cacheService: ProductCacheService;
let repository: ProductRepository;
const mockProductCacheService = {
getProductById: jest.fn(),
setProductById: jest.fn(),
invalidateProductById: jest.fn(),
};
const mockProductRepository = {
findById: jest.fn(),
update: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ProductService,
{
provide: ProductCacheService,
useValue: mockProductCacheService,
},
{
provide: ProductRepository,
useValue: mockProductRepository,
},
],
}).compile();
service = module.get<ProductService>(ProductService);
cacheService = module.get<ProductCacheService>(ProductCacheService);
repository = module.get<ProductRepository>(ProductRepository);
// 각 테스트 전에 모든 mock 초기화
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getProductById', () => {
const productId = 'test-id';
const productData = { id: productId, name: 'Test Product', price: 100 };
it('should return cached product when cache hit', async () => {
// 캐시에서 데이터 반환
mockProductCacheService.getProductById.mockResolvedValue(productData);
const result = await service.getProductById(productId);
expect(result).toEqual(productData);
expect(cacheService.getProductById).toHaveBeenCalledWith(productId);
expect(repository.findById).not.toHaveBeenCalled();
});
it('should fetch from DB and cache when cache miss', async () => {
// 캐시 미스
mockProductCacheService.getProductById.mockResolvedValue(null);
// DB에서 데이터 반환
mockProductRepository.findById.mockResolvedValue(productData);
const result = await service.getProductById(productId);
expect(result).toEqual(productData);
expect(cacheService.getProductById).toHaveBeenCalledWith(productId);
expect(repository.findById).toHaveBeenCalledWith(productId);
expect(cacheService.setProductById).toHaveBeenCalledWith(productId, productData);
});
it('should not cache null results', async () => {
// 캐시 미스
mockProductCacheService.getProductById.mockResolvedValue(null);
// DB에서도 없음
mockProductRepository.findById.mockResolvedValue(null);
const result = await service.getProductById(productId);
expect(result).toBeNull();
expect(cacheService.setProductById).not.toHaveBeenCalled();
});
});
describe('updateProduct', () => {
const productId = 'test-id';
const updateData = { name: 'Updated Product' };
const updatedProduct = { id: productId, name: 'Updated Product', price: 100 };
it('should update product and invalidate cache', async () => {
mockProductRepository.update.mockResolvedValue(updatedProduct);
const result = await service.updateProduct(productId, updateData);
expect(result).toEqual(updatedProduct);
expect(repository.update).toHaveBeenCalledWith(productId, updateData);
expect(cacheService.invalidateProductById).toHaveBeenCalledWith(productId);
});
it('should not invalidate cache when update fails', async () => {
mockProductRepository.update.mockResolvedValue(null);
const result = await service.updateProduct(productId, updateData);
expect(result).toBeNull();
expect(cacheService.invalidateProductById).not.toHaveBeenCalled();
});
});
});
6.2 캐시 서비스 단위 테스트
// libs/feature/product/test/product-cache.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ProductCacheService } from '../src/lib/infrastructure/cache/product-cache.service';
import { RedisService } from '@core/redis';
import { LoggerService } from '@core/logging';
describe('ProductCacheService', () => {
let service: ProductCacheService;
let redisService: RedisService;
let logger: LoggerService;
const mockRedisService = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
keys: jest.fn(),
};
const mockLoggerService = {
setContext: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ProductCacheService,
{
provide: RedisService,
useValue: mockRedisService,
},
{
provide: LoggerService,
useValue: mockLoggerService,
},
],
}).compile();
service = module.get<ProductCacheService>(ProductCacheService);
redisService = module.get<RedisService>(RedisService);
logger = module.get<LoggerService>(LoggerService);
jest.clearAllMocks();
});
describe('getProductById', () => {
const productId = 'test-id';
const productData = { id: productId, name: 'Test Product', price: 100 };
it('should return parsed product when cache exists', async () => {
mockRedisService.get.mockResolvedValue(JSON.stringify(productData));
const result = await service.getProductById(productId);
expect(result).toEqual(productData);
expect(redisService.get).toHaveBeenCalledWith('product:details:test-id');
});
it('should return null when cache is empty', async () => {
mockRedisService.get.mockResolvedValue(null);
const result = await service.getProductById(productId);
expect(result).toBeNull();
});
it('should handle errors and return null', async () => {
mockRedisService.get.mockRejectedValue(new Error('Redis error'));
const result = await service.getProductById(productId);
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalled();
});
});
describe('setProductById', () => {
const productId = 'test-id';
const productData = { id: productId, name: 'Test Product', price: 100 };
it('should save product with correct TTL', async () => {
await service.setProductById(productId, productData);
expect(redisService.set).toHaveBeenCalledWith(
'product:details:test-id',
JSON.stringify(productData),
21600 // 6시간 in seconds
);
expect(logger.debug).toHaveBeenCalled();
});
it('should handle save errors gracefully', async () => {
mockRedisService.set.mockRejectedValue(new Error('Redis error'));
await service.setProductById(productId, productData);
expect(logger.warn).toHaveBeenCalled();
});
});
});
7. 모범 사례
- 캐시 키 중앙화: 각 도메인별로 캐시 서비스를 구현하여 캐시 키 생성과 관리를 중앙화합니다. 이를 통해 일관성과 유지보수성을 높입니다.
- 적절한 TTL 설정: 데이터의 변경 빈도를 고려하여 캐시 만료 시간(TTL)을 적절히 설정합니다. 캐시 서비스에서 데이터 유형별로 TTL을 상수로 관리합니다.
- 캐시 키 전략:
redis-caching.md문서의 네이밍 컨벤션을 따릅니다:- 사용자 개인 데이터:
private:{userId}:[도메인]:[엔티티/기능]:[추가 식별자...] - 공유 데이터:
[도메인]:[엔티티/기능]:[식별자]
- 사용자 개인 데이터:
- 캐시 무효화: 데이터가 변경되었을 때 관련 캐시를 적절히 무효화하는 로직을 구현합니다. 캐시 서비스에서 무효화 메서드를 제공하여 일관된 처리를 보장합니다.
- 캐싱 대상 선정: 모든 데이터를 캐싱하는 것이 아니라, 자주 조회되고 변경 빈도가 낮은 데이터를 우선적으로 캐싱합니다.
- 오류 처리: Redis 연결 오류나 캐시 작업 실패 시의 동작 방식을 정의합니다. 캐시 오류가 핵심 기능에 영향을 주지 않도록 설계합니다.
- 보안: Redis 인스턴스에 비밀번호를 설정하고 네트워크 접근 제어를 적용합니다.
- 관심사 분리: 서비스 로직과 캐싱 로직을 분리하여 각자의 책임을 명확히 합니다.
8. 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | YYYY-MM-DD | your-email@example.com | 최초 작성 (템플릿 기반) |
| 0.2.0 | 2025-03-30 | bok@weltcorp.com | 캐시 키 중앙화 패턴 적용 |