본문으로 건너뛰기

Redis 캐싱 및 TTL 활용 임시 저장소 설계 가이드

개요

이 문서는 DTA-WI 시스템에서 도메인 주도 설계(DDD) 원칙에 따라 Redis를 활용한 캐싱 및 TTL 기반 임시 저장소 설계에 대한 가이드라인을 제공합니다.

목적

  • 성능 최적화: 자주 접근하는 데이터의 빠른 조회
  • 부하 분산: 데이터베이스 부하 감소
  • 일시적 데이터 저장: TTL 기반 임시 데이터 관리
  • 분산 락 관리: 분산 환경에서의 동시성 제어

활용 영역

  • 도메인별 엔티티 캐싱
  • JWT 토큰 블랙리스트 관리
  • 디바이스 접속 관리 (사용자당 활성 디바이스 제한)
  • API 응답 캐싱
  • 분산 락 구현
  • 임시 토큰 저장
  • 속도 제한(Rate Limiting)
  • 빠른 데이터 조회

참고: DTA-WIDE는 토큰 기반의 stateless 서버 아키텍처를 사용하므로, 서버 세션을 저장하는 용도로 Redis를 사용하지 않습니다. 대신 JWT 토큰의 블랙리스트를 관리하여 로그아웃된 토큰을 무효화하고, 사용자별 활성 디바이스를 제한하는 데 활용합니다.

중요 사항: cache-manager 사용 금지

⚠️ 주의: DTA-WIDE 프로젝트에서는 @nestjs/cache-managercache-manager 패키지를 사용하지 않습니다. 대신 RedisService를 직접 사용하여 캐싱을 구현합니다.

이유

  1. 버전 호환성 문제: cache-manager v6와 관련 Redis store 패키지들 간의 호환성 문제
  2. 복잡성 감소: 중간 추상화 계층 없이 Redis를 직접 제어
  3. 일관성: 모든 도메인에서 동일한 방식으로 Redis 사용
  4. 성능: 불필요한 추상화 오버헤드 제거

권장 구현 방식

// ❌ 사용하지 마세요
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. 주의사항

  1. JSON 직렬화: Redis는 문자열만 저장하므로 객체는 JSON으로 직렬화
  2. TTL 단위: RedisService는 초 단위 TTL 사용
  3. 에러 처리: 캐시 오류가 애플리케이션 중단으로 이어지지 않도록 처리
  4. 메모리 관리: 적절한 TTL 설정으로 메모리 효율성 유지

도메인별 캐싱 전략

TBD

참고: 각 도메인은 이 문서에서 설명하는 일반적인 캐싱 원칙과 패턴을 기반으로 자체 캐싱 전략을 구현해야 합니다. 이 분리는 도메인 경계를 명확히 하고 각 도메인의 특화된 요구사항을 더 잘 반영할 수 있게 합니다.

캐시 운영 전략

1. 캐시 키 네이밍 컨벤션

일관된 캐시 키 명명 규칙은 캐시 관리의 효율성을 높이고 충돌을 방지하는 데 필수적입니다. 데이터의 성격에 따라 다음 두 가지 패턴을 사용합니다:

  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 키)
  2. 공유 데이터:

    • 형식: [도메인]:[엔티티/기능]:[식별자]
    • 설명: 시스템 전반에서 공유되거나 특정 사용자와 직접적인 연관이 없는 데이터입니다.
    • 예시:
      • config:feature-flags:new-dashboard - config 도메인의 new-dashboard 기능 플래그 상태
      • product:details:item-abc - product 도메인의 상품 item-abc 상세 정보
      • notification:template:welcome-email - notification 도메인의 welcome-email 템플릿

주의: 이 규칙을 일관되게 적용하여 캐시 데이터의 성격과 범위를 명확히 파악할 수 있도록 합니다.

2. 캐시 무효화 전략

  1. 명시적 무효화:

    • 데이터 변경 시 관련 캐시 항목 직접 삭제
  2. TTL 기반 무효화:

    • 모든 캐시 항목에 TTL 설정
    • 도메인/데이터 유형별 적절한 TTL 설정
  3. 패턴 기반 무효화:

    • 데이터 변경 시 관련 패턴의 모든 캐시 항목 삭제
    • 컬렉션/리스트 캐시 무효화에 유용

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;
}
}

장점

  1. 단일 책임 원칙: 캐시 관리 로직이 전용 서비스에 캡슐화됨
  2. DRY 원칙: 캐시 키 형식이 한 곳에서만 정의됨
  3. 유지보수성: 캐시 키 형식 변경 시 한 곳만 수정하면 됨
  4. 일관성: 도메인 전체에서 일관된 캐시 키 네이밍 보장
  5. 테스트 용이성: 캐시 로직을 독립적으로 테스트 가능
  6. 확장성: 새로운 캐시 타입 추가가 용이함

구현 가이드라인

  1. 각 도메인마다 infrastructure/cache 디렉토리 아래에 캐시 서비스 구현
  2. 캐시 키 생성 로직을 factory 함수로 분리하여 테스트 용이성 확보
  3. TTL 값들을 상수로 정의하여 일관된 캐시 만료 정책 적용
  4. 에러 처리를 통해 캐시 장애가 핵심 기능에 영향을 주지 않도록 설계
  5. 적절한 로깅을 통해 캐시 히트율과 성능 모니터링

결론 및 권장사항

  1. 도메인 경계 유지:

    • 캐시 로직을 도메인별로 독립적으로 유지
    • 공유 인프라스트럭처만 재사용
  2. 적절한 TTL 설정:

    • 데이터 유형별 적절한 TTL 설정
    • 너무 길면 오래된 데이터 문제, 너무 짧으면 캐시 효과 감소
  3. 장애 대응 전략 수립:

    • Redis 장애 시 대체 로직 구현
    • Circuit Breaker 패턴 고려
  4. 캐싱 전략 결정:

    • Cache-Aside: 레포지토리에서 직접 관리
    • Read-Through: 캐시 서비스가 데이터 로딩 처리
    • Write-Through: 캐시와 DB 동시 업데이트
    • Write-Behind: 캐시 업데이트 후 비동기 DB 업데이트
  5. 지속적인 모니터링 및 튜닝:

    • 히트율 모니터링
    • 메모리 사용량 최적화
    • 성능 병목 식별 및 해결

참고 자료

TBD

변경 이력

버전날짜작성자변경 내용
0.1.02025-03-30bok@weltcorp.com최초 작성
0.2.02025-03-30bok@weltcorp.comcontext 기반 캐시 무효화 제거
0.3.02025-03-30bok@weltcorp.com캐시 키 중앙화 패턴 추가
0.4.02025-06-10bok@weltcorp.comLearning 도메인 캐시 키 직접 생성 안티패턴 추가, 도메인별 캐시 서비스 권장 패턴 강화
0.5.02025-06-19bok@weltcorp.comRelaxation 도메인 안티패턴 예시 추가, IAM 도메인 캐시 서비스 패턴 예시 추가