Redis 캐싱 및 TTL 활용 임시 저장소 설계 가이드
개요
이 문서는 DTA-WI 시스템에서 도메인 주도 설계(DDD) 원칙에 따라 Redis를 활용한 캐싱 및 TTL 기반 임시 저장소 설계에 대한 가이드라인을 제공합니다.
목적
- 성능 최적화: 자주 접근하는 데이터의 빠른 조회
- 부하 분산: 데이터베이스 부하 감소
- 일시적 데이터 저장: TTL 기반 임시 데이터 관리
- 분산 락 관리: 분산 환경에서의 동시성 제어
활용 영역
- 도메인별 엔티티 캐싱
- JWT 토큰 블랙리스트 관리
- 디바이스 접속 관리 (사용자당 활성 디바이스 제한)
- API 응답 캐싱
- 분산 락 구현
- 임시 토큰 저장
- 속도 제한(Rate Limiting)
- 빠른 데이터 조회
참고: DTA-WIDE는 토큰 기반의 stateless 서버 아키텍처를 사용하므로, 서버 세션을 저장하는 용도로 Redis를 사용하지 않습니다. 대신 JWT 토큰의 블랙리스트를 관리하여 로그아웃된 토큰을 무효화하고, 사용자별 활성 디바이스를 제한하는 데 활용합니다.
중요 사항: cache-manager 사용 금지
⚠️ 주의: DTA-WIDE 프로젝트에서는
@nestjs/cache-manager나cache-manager패키지를 사용하지 않습니다. 대신RedisService를 직접 사용하여 캐싱을 구현합니다.
이유
- 버전 호환성 문제:
cache-managerv6와 관련 Redis store 패키지들 간의 호환성 문제 - 복잡성 감소: 중간 추상화 계층 없이 Redis를 직접 제어
- 일관성: 모든 도메인에서 동일한 방식으로 Redis 사용
- 성능: 불필요한 추상화 오버헤드 제거
권장 구현 방식
// ❌ 사용하지 마세요
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
// ✅ 이렇게 사용하세요
import { RedisService } from '@core/redis';
constructor(private readonly redisService: RedisService) {}
아키텍처 설계
1. 계층 구조
┌───────────────────────────────────────────────┐
│ │
│ Application Layer │
│ │
└───────────────────────┬───────────────────────┘
│
┌───────────────────────┼───────────────────────┐
│ │ │
│ Domain Layer │ Repository Layer │
│ │ │
└───────────────────────┼───────────────────────┘
│
┌───────────────────────┼───────────────────────┐
│ │ │
│ Cache Layer │ Database Layer │
│ (RedisService) │ │
└───────────────────────┴───────────────────────┘
2. 구성 요소
-
Redis 서비스 (Core)
- 중앙 집중식 Redis 서비스 제공
- 도메인 간 공유 인프라스트럭처로 위치
@core/redis에서 제공
-
도메인별 캐싱 레이어
- 각 도메인의 캐시 서비스에서 구현
- 도메인 특화 캐싱 정책 적용
- RedisService를 직접 사용
-
분산 락 서비스
- 동시성 제어를 위한 Redis 기반 분산 락 구현
- 트랜잭션 일관성 유지
RedisService 사용 가이드
1. 기본 사용법
import { Injectable } from '@nestjs/common';
import { RedisService } from '@core/redis';
import { LoggerService } from '@core/logging';
@Injectable()
export class DomainCacheService {
constructor(
private readonly redisService: RedisService,
private readonly logger: LoggerService
) {
this.logger.setContext(DomainCacheService.name);
}
// 캐시 조회
async getCacheItem<T>(key: string): Promise<T | null> {
try {
const value = await this.redisService.get(key);
if (value) {
return JSON.parse(value) as T;
}
return null;
} catch (error) {
this.logger.warn(`캐시 조회 중 오류 발생. Key: ${key}`, error);
return null;
}
}
// 캐시 저장
async setCacheItem<T>(key: string, value: T, ttl?: number): Promise<void> {
try {
const stringValue = JSON.stringify(value);
await this.redisService.set(key, stringValue, ttl);
this.logger.debug(`캐시 저장 완료: ${key} (TTL: ${ttl || 'default'}s)`);
} catch (error) {
this.logger.warn(`캐시 저장 중 오류 발생. Key: ${key}`, error);
}
}
// 캐시 삭제
async invalidateCacheItem(key: string): Promise<void> {
try {
await this.redisService.del(key);
this.logger.debug(`캐시 무효화 완료: ${key}`);
} catch (error) {
this.logger.warn(`캐시 무효화 중 오류 발생. Key: ${key}`, error);
}
}
}
2. 패턴 기반 캐시 관리
// 패턴 매칭으로 여러 키 조회
async invalidateByPattern(pattern: string): Promise<void> {
try {
// 주의: 프로덕션에서는 SCAN 사용 권장
const keys = await this.redisService.keys(pattern);
if (keys.length > 0) {
await Promise.all(keys.map(key => this.redisService.del(key)));
this.logger.debug(`패턴 ${pattern}으로 ${keys.length}개 키 삭제`);
}
} catch (error) {
this.logger.error(`패턴 기반 캐시 무효화 실패: ${pattern}`, error);
}
}
3. 주의사항
- JSON 직렬화: Redis는 문자열만 저장하므로 객체는 JSON으로 직렬화
- TTL 단위: RedisService는 초 단위 TTL 사용
- 에러 처리: 캐시 오류가 애플리케이션 중단으로 이어지지 않도록 처리
- 메모리 관리: 적절한 TTL 설정으로 메모리 효율성 유지
도메인별 캐싱 전략
TBD
참고: 각 도메인은 이 문서에서 설명하는 일반적인 캐싱 원칙과 패턴을 기반으로 자체 캐싱 전략을 구현해야 합니다. 이 분리는 도메인 경계를 명확히 하고 각 도메인의 특화된 요구사항을 더 잘 반영할 수 있게 합니다.
캐시 운영 전략
1. 캐시 키 네이밍 컨벤션
일관된 캐시 키 명명 규칙은 캐시 관리의 효율성을 높이고 충돌을 방지하는 데 필수적입니다. 데이터의 성격에 따라 다음 두 가지 패턴을 사용합니다:
-
사용자 개인 데이터:
- 형식:
private:{userId}:[도메인]:[엔티티/기능]:[추가 식별자...] - 설명: 특정 사용자에게 귀속되는 데이터입니다.
userId를 통해 명확하게 범위가 지정됩니다. - 예시:
private:123:user:profile- 사용자 123의user도메인 프로필 정보private:123:health:sleep:summary:2023-10-26- 사용자 123의health도메인 2023년 10월 26일 수면 요약private:456:auth:devices- 사용자 456의auth도메인 활성 디바이스 목록 (Sorted Set 키)
- 형식:
-
공유 데이터:
- 형식:
[도메인]:[엔티티/기능]:[식별자] - 설명: 시스템 전반에서 공유되거나 특정 사용자와 직접적인 연관이 없는 데이터입니다.
- 예시:
config:feature-flags:new-dashboard-config도메인의new-dashboard기능 플래그 상태product:details:item-abc-product도메인의 상품item-abc상세 정보notification:template:welcome-email-notification도메인의welcome-email템플릿
- 형식:
주의: 이 규칙을 일관되게 적용하여 캐시 데이터의 성격과 범위를 명확히 파악할 수 있도록 합니다.
2. 캐시 무효화 전략
-
명시적 무효화:
- 데이터 변경 시 관련 캐시 항목 직접 삭제
-
TTL 기반 무효화:
- 모든 캐시 항목에 TTL 설정
- 도메인/데이터 유형별 적절한 TTL 설정
-
패턴 기반 무효화:
- 데이터 변경 시 관련 패턴의 모든 캐시 항목 삭제
- 컬렉션/리스트 캐시 무효화에 유용
3. TTL(Time-to-Live) 설정 가이드라인
| 데이터 유형 | 권장 TTL | 설명 |
|---|---|---|
| 사용자 프로필 | 3600초 (1시간) | 자주 접근하지만 변경이 적은 데이터 |
| 토큰 블랙리스트 | 토큰 만료 시간과 동일 | 토큰 라이프사이클과 일치시킴 |
| 활성 디바이스 목록 | 604800초 (7일) | 사용자의 디바이스 접속 정보 |
| 설정 데이터 | 86400초 (24시간) | 거의 변경되지 않는 데이터 |
| API 응답 | 300초 (5분) | 외부 API 응답 등 중간 정도 변경 빈도 데이터 |
| 임시 코드 | 코드 만료 시간과 동일 | 일회성 코드의 유효 기간과 일치시킴 |
| 속도 제한 | 60초~3600초 | API 호출 빈도 제한 데이터 |
4. 디바이스 접속 관리 구현 예시
다중 디바이스에서의 동시 접속을 제한하기 위해 다음과 같은 Redis 구현을 활용할 수 있습니다:
키 구조
private:{userId}:auth:devices = [디바이스1_ID, 디바이스2_ID, ...] (Sorted Set)
auth:device:{deviceId} = {deviceInfo} (Hash)
새 디바이스 로그인 처리
async handleDeviceLogin(userId: string, deviceId: string, deviceInfo: DeviceInfo): Promise<boolean> {
// 1. 사용자의 활성 디바이스 목록 조회
const userDevices = await this.redisService.zrange(`private:${userId}:auth:devices`, 0, -1);
// 2. 기존 디바이스인지 확인
const isExistingDevice = userDevices.includes(deviceId);
// 3. 새 디바이스이고 최대 허용 개수를 초과하는 경우
const MAX_DEVICES = 3; // 사용자당 최대 3개 디바이스
if (!isExistingDevice && userDevices.length >= MAX_DEVICES) {
// 가장 오래된 디바이스 제거
const oldestDevice = await this.redisService.zrange(`private:${userId}:auth:devices`, 0, 0);
if (oldestDevice.length > 0) {
await this.redisService.zrem(`private:${userId}:auth:devices`, oldestDevice[0]);
await this.redisService.del(`auth:device:${oldestDevice[0]}`);
}
}
// 4. 현재 디바이스 정보 저장
const now = Date.now();
await this.redisService.zadd(`private:${userId}:auth:devices`, now, deviceId);
await this.redisService.hset(`auth:device:${deviceId}`, {
...deviceInfo,
lastActive: now,
});
// 5. 사용자의 디바이스 목록 TTL 설정 (7일)
await this.redisService.expire(`private:${userId}:auth:devices`, 604800);
await this.redisService.expire(`auth:device:${deviceId}`, 604800);
return true;
}
디바이스 로그아웃 처리
async handleDeviceLogout(userId: string, deviceId: string): Promise<void> {
// 1. 사용자의 디바이스 목록에서 제거
await this.redisService.zrem(`private:${userId}:auth:devices`, deviceId);
// 2. 디바이스 정보 삭제
await this.redisService.del(`auth:device:${deviceId}`);
// 3. 해당 디바이스의 토큰을 블랙리스트에 추가
// (토큰 블랙리스트 처리 로직 생략)
}
접속 검증
async validateDeviceAccess(userId: string, deviceId: string): Promise<boolean> {
// 1. 사용자의 활성 디바이스 목록 조회
const userDevices = await this.redisService.zrange(`private:${userId}:auth:devices`, 0, -1);
// 2. 디바이스 ID가 목록에 있는지 확인
return userDevices.includes(deviceId);
}
모니터링 및 운영 가이드
1. 모니터링 핵심 지표
- 히트율 (Hit Rate): 캐시 조회 성공 비율
- 메모리 사용량: Redis 인스턴스의 메모리 사용량
- 명령 지연 시간: Redis 명령 실행 소요 시간
- 연결 상태: Redis 서버 연결 상태
2. 알림 설정 권장사항
- 메모리 사용량 임계값: 80% 초과 시 알림
- 연결 오류: 연속 3회 이상 발생 시 알림
- 높은 지연 시간: p95 > 10ms 지속 시 알림
프로덕션 환경 고려사항
1. 고가용성 구성
- Redis Sentinel 또는 Redis Cluster 사용
- 다중 영역 배포
- 자동 장애 조치 설정
2. 백업 및 복구
- RDB 스냅샷 및 AOF 로그 활성화
- 주기적인 백업 설정
- 복구 프로세스 자동화
3. 보안 설정
- TLS 암호화 활용
- 인증 활성화
- VPC 내부 통신으로 제한
- IAM 기반 접근 제어
4. 메모리 관리
maxmemory-policy: volatile-lru (TTL이 설정된 키 중 LRU 방식으로 제거)- 메모리 한도 설정
- 메모리 사용량 모니터링
DDD 관점에서의 캐싱 전략
1. 도메인 경계 유지
Redis 캐싱을 도입할 때도 도메인 경계를 존중해야 합니다:
- 각 도메인은 자신만의 캐싱 정책을 정의
- 도메인 간 캐시 키 중복/충돌 방지
- 도메인 이벤트에 따른 캐시 무효화 처리
2. 레포지토리 패턴과의 통합
┌───────────────────────────────────────────────┐
│ │
│ Domain Service │
│ │
└───────────────────────┬───────────────────────┘
│
┌───────────────────────┼───────────────────────┐
│ │ │
│ Repository │ Repository │
│ Interface │ Implementation │
│ │ │
└───────────────────────┼───────────────────────┘
│
┌───────────────────────┼───────────────────────┐
│ │ │
│ Redis Cache Adapter │ Database Adapter │
│ │ │
└───────────────────────┴───────────────────────┘
캐싱 로직의 구현 위치는 캐싱 전략의 복잡성에 따라 달라질 수 있습니다:
1. 기본 캐싱(단순 키-값 캐싱)
레포지토리 패턴을 통해 기본적인 캐싱 로직을 도메인 로직으로부터 분리할 수 있습니다:
- 레포지토리 인터페이스는 캐싱 구현 세부사항을 숨김
- 캐싱은 레포지토리 구현체 내부에서 처리
- 도메인 서비스는 기본적인 캐싱 여부를 신경쓰지 않음
// 레포지토리 인터페이스 - 캐싱에 대한 언급 없음
interface UserRepository {
findById(id: number): Promise<User | null>;
save(user: User): Promise<void>;
}
// 캐싱을 포함한 구현체
class CachedUserRepository implements UserRepository {
constructor(
private readonly redisService: RedisService,
private readonly database: Database
) {}
async findById(id: number): Promise<User | null> {
// 캐시 확인
const cacheKey = `user:${id}`;
const cached = await this.redisService.get(cacheKey);
if (cached) return JSON.parse(cached);
// DB 조회
const user = await this.database.users.findUnique({ where: { id } });
// 캐시 저장
if (user) {
await this.redisService.set(cacheKey, JSON.stringify(user), 3600);
}
return user;
}
async save(user: User): Promise<void> {
// DB 저장
await this.database.users.upsert({ /* ... */ });
// 캐시 무효화
const cacheKey = `user:${user.id}`;
await this.redisService.del(cacheKey);
}
}
2. 데코레이터 패턴을 활용한 캐싱 구현
더 유연하고 확장 가능한 접근 방식으로, 데코레이터 패턴을 사용하여 기존 리포지토리에 캐싱 기능을 추가할 수 있습니다. 이 방식은 단일 책임 원칙을 따르며 관심사를 분리합니다:
- 원본 리포지토리는 데이터 접근만 담당
- 캐싱 리포지토리는 캐시 관리만 담당
- 인터페이스 구현을 통해 투명한 대체 가능성 제공
// 기본 리포지토리 구현 (데이터베이스 접근)
@Injectable()
class PrismaPlanRoleRepository implements PlanRoleRepository {
constructor(private readonly prisma: PrismaService) {}
async findRolesByPlanId(planId: string): Promise<PlanRoleWithRelations[]> {
// 데이터베이스에서 직접 조회하는 로직
return this.prisma.planRole.findMany({
where: { planId },
include: { /* 관계 포함 */ }
});
}
}
// 캐싱 데코레이터 구현
@Injectable()
class CachedPlanRoleRepository implements PlanRoleRepository {
constructor(
private readonly delegate: PrismaPlanRoleRepository,
private readonly cacheService: PlanRoleCacheService,
private readonly logger: LoggerService
) {
this.logger.setContext(CachedPlanRoleRepository.name);
}
async findRolesByPlanId(planId: string): Promise<PlanRoleWithRelations[]> {
// 1. 캐시 확인
try {
const cached = await this.cacheService.getCachedRolesByPlanId(planId);
if (cached) {
this.logger.debug(`캐시에서 플랜(${planId})의 역할 조회 성공`);
return cached;
}
} catch (error: unknown) {
// 캐시 오류는 무시하고 원본 소스에서 조회
this.logger.warn(`캐시 조회 중 오류 발생, DB에서 직접 조회: ${error instanceof Error ? error.message : String(error)}`);
}
// 2. 원본 리포지토리를 통해 데이터베이스에서 조회
const roles = await this.delegate.findRolesByPlanId(planId);
// 3. 조회 결과를 캐시에 저장
try {
if (roles.length > 0) {
await this.cacheService.cacheRolesByPlanId(planId, roles);
}
} catch (error: unknown) {
// 캐시 저장 오류는 무시 (애플리케이션 핵심 기능에 영향을 주지 않음)
this.logger.warn(`캐시 저장 중 오류 발생: ${error instanceof Error ? error.message : String(error)}`);
}
return roles;
}
}
// 의존성 주입 설정
@Module({
providers: [
// 기본 구현체
PrismaPlanRoleRepository,
// 캐싱 데코레이터 구현체
{
provide: PLAN_ROLE_REPOSITORY,
useFactory: (
prismaRepo: PrismaPlanRoleRepository,
cacheService: PlanRoleCacheService,
logger: LoggerService
) => {
return new CachedPlanRoleRepository(prismaRepo, cacheService, logger);
},
inject: [PrismaPlanRoleRepository, PlanRoleCacheService, LoggerService]
}
],
exports: [PLAN_ROLE_REPOSITORY]
})
export class RepositoryModule {}
데코레이터 패턴 장점:
- 단일 책임 원칙: 원본 리포지토리는 데이터 접근만, 캐싱 리포지토리는 캐시 관리만 담당
- 개방-폐쇄 원칙: 원본 코드 변경 없이 캐싱 기능 추가 가능
- 확장성: 추가 기능(로깅, 지표 수집 등)을 다른 데코레이터로 쉽게 추가 가능
- 테스트 용이성: 각 구성 요소를 독립적으로 테스트 가능
- 런타임 전환: 필요에 따라 캐싱 활성화/비활성화 가능
구현 시 고려사항:
- 캐시 오류가 발생해도 핵심 기능이 작동하도록 설계
- 적절한 로깅을 통해 캐시 동작 모니터링
- 트랜잭션이 필요한 경우 데코레이터 체인이 일관성을 유지하도록 주의
3. 캐시 키 중앙화 패턴
캐시 관리의 일관성과 유지보수성을 위해 각 도메인별로 캐시 서비스를 구현하여 캐시 키 생성과 캐싱 로직을 중앙화하는 것을 권장합니다.
문제점
서비스나 리포지토리에서 직접 캐시 키를 생성하면 다음과 같은 문제가 발생합니다:
// ❌ 안티패턴: 직접 캐시 키 생성 (User 도메인 예시)
async getUserTreatmentInfo(userId: string): Promise<UserTreatmentInfoDto> {
const cacheKey = `private:${userId}:user:treatment:info`; // 직접 키 생성
const cached = await this.redisService.get(cacheKey);
// ...
}
// ❌ 안티패턴: 직접 캐시 키 생성 (Learning 도메인 예시)
async saveLearningHistory(params: any): Promise<{ historyId: string; eventId: string }> {
const cacheKey = `private:${userId}:learning:session:${userCycleId}:${lessonId}:${sessionId}`;
// 같은 키를 여러 메서드에서 반복 생성
const cached = await this.redisService.get(cacheKey);
// ...
await this.redisService.set(cacheKey, data);
// ...
await this.redisService.del(cacheKey);
}
// ❌ 안티패턴: 직접 캐시 키 생성 (Relaxation 도메인 예시)
async saveRelaxationHistory(params: any): Promise<{ historyId: string; eventId: string }> {
const cacheKey = `${userId}:${userCycleId}:${contentId}`; // 불일치하는 키 형식
this.activeSessionCache.set(cacheKey, { id: historyId, startedAt: timestamp });
// ...
}
async invalidateTreatmentCache(userId: string): Promise<void> {
// 캐시 키 형식이 여러 곳에 중복됨
const cacheKeys = [
`private:${userId}:user:treatment:info`,
`private:${userId}:user:treatment:basic-info`,
// ...
];
// ...
}
이러한 방식의 문제점:
- 코드 중복: 같은 캐시 키가 여러 메서드에서 반복해서 정의됨
- 유지보수성 저하: 캐시 키 형식 변경 시 모든 곳을 수정해야 함
- 일관성 부족: 캐시 키 네이밍 실수 가능성 (Relaxation 예시처럼
private:접두사 누락 등) - 테스트 어려움: 캐시 로직을 독립적으로 테스트하기 어려움
- 캐시 무효화의 어려움: 관련된 모든 키를 찾기 어려움
해결 방법: 도메인별 캐시 서비스
각 도메인마다 전용 캐시 서비스를 구현하여 캐시 키 생성과 캐싱 로직을 캡슐화합니다:
// 예시 1: User 도메인 캐시 키 생성 factory 함수
export const createUserCacheKeys = () => ({
USER_BY_ID: (userId: string) => `private:${userId}:user:profile`,
USER_TREATMENT_INFO: (userId: string) => `private:${userId}:user:treatment:info`,
USER_TREATMENT_BASIC_INFO: (userId: string) => `private:${userId}:user:treatment:basic-info`,
USER_TREATMENT_ACTIVE_CYCLE: (userId: string) => `private:${userId}:user:treatment:active-cycle`,
USER_TREATMENT_SUSPENSION_HISTORY: (userId: string) => `private:${userId}:user:treatment:suspension-history`,
USER_TREATMENT_PATTERN: (userId: string) => `private:${userId}:treatment:*`,
});
// 예시 2: Learning 도메인 캐시 키 생성 factory 함수
export const createLearningCacheKeys = () => ({
USER_LEARNING_SESSION_ACTIVE: (userId: string, userCycleId: string, lessonId: string, sessionId: string) =>
`private:${userId}:learning:session:${userCycleId}:${lessonId}:${sessionId}`,
USER_LEARNING_TIME_STATS: (userId: string, userCycleId: string, lessonId: string) =>
`private:${userId}:learning:stats:${userCycleId}:${lessonId}`,
USER_ALL_LEARNING_TIME_STATS: (userId: string, userCycleId: string) =>
`private:${userId}:learning:stats:all:${userCycleId}`,
USER_LEARNING_HISTORY: (userId: string, historyId: string) =>
`private:${userId}:learning:history:${historyId}`,
});
// 예시 3: Relaxation 도메인 캐시 키 생성 factory 함수
export const createRelaxationCacheKeys = () => ({
USER_ACTIVE_SESSION: (userId: string, userCycleId: string, contentId: string) =>
`private:${userId}:relaxation:session:${userCycleId}:${contentId}`,
USER_PLAYBACK_STATS: (userId: string, userCycleId: string, contentId: string) =>
`private:${userId}:relaxation:stats:${userCycleId}:${contentId}`,
USER_ALL_PLAYBACK_STATS: (userId: string, userCycleId: string) =>
`private:${userId}:relaxation:stats:all:${userCycleId}`,
USER_RELAXATION_HISTORY: (userId: string, historyId: string) =>
`private:${userId}:relaxation:history:${historyId}`,
});
@Injectable()
export class UserCacheService {
private readonly cacheKeys: ReturnType<typeof createUserCacheKeys>;
// 캐시 TTL 값들
private readonly CACHE_TTL = {
USER_PROFILE: 12 * secondsInHour,
USER_TREATMENT_INFO: 1 * secondsInHour,
USER_TREATMENT_BASIC_INFO: 1 * secondsInHour,
USER_TREATMENT_ACTIVE_CYCLE: 24 * secondsInHour,
USER_TREATMENT_SUSPENSION_HISTORY: 24 * secondsInHour,
};
constructor(
private readonly redisService: RedisService,
private readonly logger: LoggerService
) {
this.cacheKeys = createUserCacheKeys();
this.logger.setContext(UserCacheService.name);
}
// 사용자 치료 정보 조회
async getUserTreatmentInfo(userId: string): Promise<any | null> {
const key = this.cacheKeys.USER_TREATMENT_INFO(userId);
return this.getCacheItem<any>(key);
}
// 사용자 치료 정보 저장
async setUserTreatmentInfo(userId: string, treatmentInfo: any): Promise<void> {
const key = this.cacheKeys.USER_TREATMENT_INFO(userId);
await this.setCacheItem(key, treatmentInfo, this.CACHE_TTL.USER_TREATMENT_INFO);
}
// 사용자 치료 정보 캐시 무효화
async invalidateTreatmentInfo(userId: string): Promise<void> {
const key = this.cacheKeys.USER_TREATMENT_INFO(userId);
await this.invalidateCacheItem(key);
}
// 모든 치료 관련 캐시 무효화
async invalidateAllTreatmentCache(userId: string): Promise<void> {
await Promise.all([
this.invalidateTreatmentInfo(userId),
this.invalidateTreatmentBasicInfo(userId),
this.invalidateTreatmentActiveCycle(userId),
this.invalidateTreatmentSuspensionHistory(userId),
]);
}
}
// 예시 2: Learning 도메인 캐시 서비스
@Injectable()
export class LearningCacheService {
private readonly cacheKeys: ReturnType<typeof createLearningCacheKeys>;
// 캐시 TTL 값들
private readonly CACHE_TTL = {
ACTIVE_SESSION: 5 * secondsInMinute, // 활성 세션: 5분
LEARNING_HISTORY: 1 * secondsInHour, // 학습 이력: 1시간
LEARNING_TIME_STATS: 30 * secondsInMinute, // 학습 시간 통계: 30분
};
constructor(
private readonly redisService: RedisService,
private readonly logger: LoggerService
) {
this.cacheKeys = createLearningCacheKeys();
this.logger.setContext(LearningCacheService.name);
}
// 활성 학습 세션 정보 캐시 조회
async getActiveSession(userId: string, userCycleId: string, lessonId: string, sessionId: string): Promise<{ id: string; startedAt: Date } | null> {
const key = this.cacheKeys.USER_LEARNING_SESSION_ACTIVE(userId, userCycleId, lessonId, sessionId);
return this.getCacheItem<{ id: string; startedAt: Date }>(key);
}
// 활성 학습 세션 정보 캐시 저장
async setActiveSession(userId: string, userCycleId: string, lessonId: string, sessionId: string, activeSession: { id: string; startedAt: Date }): Promise<void> {
const key = this.cacheKeys.USER_LEARNING_SESSION_ACTIVE(userId, userCycleId, lessonId, sessionId);
await this.setCacheItem(key, activeSession, this.CACHE_TTL.ACTIVE_SESSION);
}
// 활성 학습 세션 정보 캐시 삭제
async invalidateActiveSession(userId: string, userCycleId: string, lessonId: string, sessionId: string): Promise<void> {
const key = this.cacheKeys.USER_LEARNING_SESSION_ACTIVE(userId, userCycleId, lessonId, sessionId);
await this.invalidateCacheItem(key);
}
// 기타 공통 캐시 메서드들...
private async getCacheItem<T>(key: string): Promise<T | null> { /* 구현 */ }
private async setCacheItem<T>(key: string, value: T, ttl?: number): Promise<void> { /* 구현 */ }
private async invalidateCacheItem(key: string): Promise<void> { /* 구현 */ }
}
서비스에서의 사용
서비스나 리포지토리에서는 캐시 서비스의 메서드를 호출하여 캐싱을 처리합니다:
// ✅ 권장: User 도메인에서 캐시 서비스 사용
@Injectable()
export class UserTreatmentService {
constructor(
private readonly cacheService: UserCacheService,
private readonly prisma: PrismaService,
private readonly logger: LoggerService
) {}
async getUserTreatmentInfo(userId: string): Promise<UserTreatmentInfoDto> {
// 캐시에서 먼저 조회
const cached = await this.cacheService.getUserTreatmentInfo(userId);
if (cached) {
this.logger.debug(`Treatment info cache hit for user ${userId}`);
return cached as UserTreatmentInfoDto;
}
// 캐시에 없으면 DB에서 로드
const treatmentInfo = await this.loadTreatmentInfoFromDatabase(userId);
// 캐시에 저장
await this.cacheService.setUserTreatmentInfo(userId, treatmentInfo);
return treatmentInfo;
}
async invalidateTreatmentCache(userId: string): Promise<void> {
// 중앙화된 무효화 메서드 호출
await this.cacheService.invalidateAllTreatmentCache(userId);
this.logger.log(`Treatment cache invalidated for user ${userId}`);
}
}
// ✅ 권장: Learning 도메인에서 캐시 서비스 사용
@Injectable()
export class PrismaLearningHistoryRepository implements LearningHistoryRepository {
constructor(
private readonly prisma: PrismaClient,
private readonly cacheService: LearningCacheService,
) {}
async saveLearningHistory(params: SaveLearningHistoryParams): Promise<{ historyId: string; eventId: string }> {
// ❌ 이전 방식: 직접 캐시 키 생성
// const cacheKey = `private:${userId}:learning:session:${userCycleId}:${lessonId}:${sessionId}`;
const { userId, userCycleId, lessonId, sessionId, eventType, timestamp } = params;
switch (eventType) {
case LearningEventType.START:
return this.handleStartEvent(userId, userCycleId, lessonId, sessionId, timestamp);
// ...
}
}
private async handleStartEvent(userId: string, userCycleId: string, lessonId: string, sessionId: string, timestamp: Date): Promise<{ historyId: string; eventId: string }> {
const result = await this.prisma.$transaction(async (tx) => {
// DB 작업 수행
const newHistory = await tx.lessonLearningHistory.create(/* ... */);
const learningEvent = await tx.learningEvent.create(/* ... */);
return { historyId: newHistory.id, eventId: learningEvent.id };
});
// ✅ 새로운 방식: 캐시 서비스 사용
await this.cacheService.setActiveSession(userId, userCycleId, lessonId, sessionId, {
id: result.historyId,
startedAt: timestamp,
});
return result;
}
private async handleEndEvent(userId: string, userCycleId: string, lessonId: string, sessionId: string, timestamp: Date): Promise<{ historyId: string; eventId: string }> {
// ✅ 캐시 서비스를 통해 활성 세션 조회
let activeSession = await this.cacheService.getActiveSession(userId, userCycleId, lessonId, sessionId);
if (!activeSession) {
// 캐시에 없으면 DB에서 조회
const existingHistory = await this.prisma.lessonLearningHistory.findFirst(/* ... */);
// ...
}
// DB 업데이트 수행
const result = await this.prisma.$transaction(/* ... */);
// ✅ 캐시에서 활성 세션 제거
await this.cacheService.invalidateActiveSession(userId, userCycleId, lessonId, sessionId);
return result;
}
}
장점
- 단일 책임 원칙: 캐시 관리 로직이 전용 서비스에 캡슐화됨
- DRY 원칙: 캐시 키 형식이 한 곳에서만 정의됨
- 유지보수성: 캐시 키 형식 변경 시 한 곳만 수정하면 됨
- 일관성: 도메인 전체에서 일관된 캐시 키 네이밍 보장
- 테스트 용이성: 캐시 로직을 독립적으로 테스트 가능
- 확장성: 새로운 캐시 타입 추가가 용이함
구현 가이드라인
- 각 도메인마다
infrastructure/cache디렉토리 아래에 캐시 서비스 구현 - 캐시 키 생성 로직을 factory 함수로 분리하여 테스트 용이성 확보
- TTL 값들을 상수로 정의하여 일관된 캐시 만료 정책 적용
- 에러 처리를 통해 캐시 장애가 핵심 기능에 영향을 주지 않도록 설계
- 적절한 로깅을 통해 캐시 히트율과 성능 모니터링
결론 및 권장사항
-
도메인 경계 유지:
- 캐시 로직을 도메인별로 독립적으로 유지
- 공유 인프라스트럭처만 재사용
-
적절한 TTL 설정:
- 데이터 유형별 적절한 TTL 설정
- 너무 길면 오래된 데이터 문제, 너무 짧으면 캐시 효과 감소
-
장애 대응 전략 수립:
- Redis 장애 시 대체 로직 구현
- Circuit Breaker 패턴 고려
-
캐싱 전략 결정:
- Cache-Aside: 레포지토리에서 직접 관리
- Read-Through: 캐시 서비스가 데이터 로딩 처리
- Write-Through: 캐시와 DB 동시 업데이트
- Write-Behind: 캐시 업데이트 후 비동기 DB 업데이트
-
지속적인 모니터링 및 튜닝:
- 히트율 모니터링
- 메모리 사용량 최적화
- 성능 병목 식별 및 해결
참고 자료
TBD
변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-03-30 | bok@weltcorp.com | 최초 작성 |
| 0.2.0 | 2025-03-30 | bok@weltcorp.com | context 기반 캐시 무효화 제거 |
| 0.3.0 | 2025-03-30 | bok@weltcorp.com | 캐시 키 중앙화 패턴 추가 |
| 0.4.0 | 2025-06-10 | bok@weltcorp.com | Learning 도메인 캐시 키 직접 생성 안티패턴 추가, 도메인별 캐시 서비스 권장 패턴 강화 |
| 0.5.0 | 2025-06-19 | bok@weltcorp.com | Relaxation 도메인 안티패턴 예시 추가, IAM 도메인 캐시 서비스 패턴 예시 추가 |