안녕하세요, 이중혁입니다

배우고 경험한 기술들을 하나씩 정리하는 공간

Redis DAO 설계

세션 저장소

핵심 포인트

  • TTL 관리: 만료 기반 세션 수명 관리
  • KEEPTTL: TTL 유지한 채 업데이트
  • Context 연동: 세션/멀티 트랜잭션 컨텍스트 주입

운영 체크

  • null 가드: get 결과 파싱 전 null 체크
  • 멀티 실행: 상위 서비스에서 exec 커밋
  • 키 정책: userId 기반 단일 키

세션 설계 팁

  • 원자성: 관련 쓰기는 멀티에 모아 한 번에 실행
  • 격리: 사용자 단위 키로 경합 최소화
  • 가시성: 세션 스냅샷을 로그로 남겨 디버깅 용이
@Injectable()
export class SessionRepository extends AbstractRedisRepository {
  protected readonly dbNumber = RedisDbNumber.SESSION;

  constructor() {
    super();
    this.createRedisClient();
  }

  async getSession(userId: number): Promise<Session | null> {
    const raw = await this.redis.get(userId.toString());
    if (!raw) return null;
    return plainToInstance(Session, JSON.parse(raw));
  }

  async setSession(userId: number, session: Session): Promise<void> {
    const key = userId.toString();
    const multi = this.multi;
    const ttl = SESSION_EXPIRED_TIME;
    ttl > 0 ? multi.set(key, JSON.stringify(session), 'EX', ttl)
            : multi.set(key, JSON.stringify(session));
    ContextProvider.setSession(session);
    ContextProvider.setRedisMulti(this.dbNumber, multi);
  }
}

Redis

트랜잭션 처리 시스템

구현 내용

트랜잭션 기본 원리

  • ACID 속성 보장
  • 원자성(Atomicity): 모두 성공 또는 모두 실패
  • 일관성(Consistency): 데이터 무결성 유지
  • 격리성(Isolation): 동시 실행 트랜잭션 분리
  • 지속성(Durability): 커밋된 변경사항 영구 저장

Redis 트랜잭션

  • Redis Multi: 원자적 명령 실행
  • 세션 시퀀스: 트랜잭션 성공 시 시퀀스 증가
  • EXEC/DISCARD: 트랜잭션 커밋/롤백
  • RedisHelper: Multi 풀 관리

트랜잭션 테스트

테스트 방법

  • 1단계: 로그인으로 Session ID와 User ID 발급
  • 2단계: Role/Gold/Error Point 설정
  • 3단계: 트랜잭션 테스트 실행
  • Error Point 0: 정상 트랜잭션 (성공)
  • Error Point 1: 1번 포인트 실패 (롤백)
  • Error Point 2: 2번 포인트 실패 (롤백)
  • Error Point 3: 3번 포인트 실패 (롤백)

사용자 정보

트랜잭션 테스트 설정
@ApiTags('database')
@Controller('database')
export class DatabaseController {
  @Post('transaction')
  @ApiResponseEntity({
    summary: '트랜잭션 확인하기',
  })
  @Auth()
  @UserLevelLock()
  async testTransaction(
    @Body() transactionInDto: TransactionInDto,
  ): Promise<ResponseEntity<string>> {
    await this.databaseService.testTransaction(transactionInDto);
    
    return ResponseEntity.ok().body('업데이트 성공');
  }
}

캐싱 전략

캐시 아키텍처

3계층 캐시 구조

  • L1 - 애플리케이션 메모리: LRU 캐시, 정적 데이터
  • L2 - Redis 인메모리 DB: 세션, 공통 데이터
  • L3 - MySQL 데이터베이스: 영구 저장소
  • 캐시 미스 처리: 상위 계층에서 하위 계층으로 조회

캐시 패턴

  • Cache-Aside: 애플리케이션에서 캐시 관리
  • Write-Through: 쓰기 시 캐시와 DB 동시 업데이트
  • Write-Behind: 비동기 캐시 업데이트
  • Refresh-Ahead: 만료 전 미리 갱신

데이터 분류

  • 정적 데이터: 게임 데이터, 설정 정보 (메모리)
  • 세션 데이터: 사용자 세션, 상태 (Redis)
  • 동적 데이터: 사용자 정보, 게임 진행 (DB)
  • 임시 데이터: 쿼리 결과, 계산 결과 (LRU)

캐시 무효화

  • TTL 기반: 시간 기반 자동 삭제
  • 이벤트 기반: 데이터 변경 시 삭제
  • 버전 관리: 버전 변경 시 무효화
  • Pub/Sub 알림: Redis 메시지로 동기화

Portfolio 캐시 구조

실제 구현 구조

  • StaticRepository: 메모리 정적 데이터 관리
  • SessionRepository: Redis 세션 데이터 관리
  • LRU Cache: 쿼리 결과 캐싱
  • Pub/Sub System: 실시간 데이터 동기화

L1 - 메모리 캐시

StaticRepository - JSON 데이터 메모리 로드

LRU Cache (max: 1000) - 쿼리 결과 캐싱

deepFreeze() - 불변성 보장

Object.freeze() - 메모리 최적화

L2 - Redis 캐시

user:session:{userId} - 사용자 세션

event:info - 이벤트 정보 (전체 공통)

ranking:global - 전체 랭킹

ranking:guild - 길드 랭킹

TTL: 3600초 (1시간)

L3 - MySQL DB

user 테이블 - 사용자 정보

user_detail 테이블 - 상세 정보

game 테이블 - 게임 진행 데이터

GET_LOCK() - 사용자 락 (분산 락)

영구 저장소

Pub/Sub 동기화

Redis 채널: 'data' 구독

메시지: 'all' - 전체 리로드

메시지: ['Character'] - 특정 데이터 리로드

실시간 캐시 무효화

export abstract class StaticBaseRepository<T> {
  items: Record<number, T> = {};
  cache = createLRU<string, T[]>({
    max: 1000,
  });

  abstract load(): void;

  keys(): number[] {
    return Object.keys(this.items).map(Number);
  }

  values(): T[] {
    return Object.values(this.items);
  }

  findById(id: number): T {
    return this.items[id];
  }

  findByIdIn(ids: number[]): T[] {
    return ids.map((id) => this.items[id]).filter((it) => !!it);
  }

  query(queryInterface: QueryInterface<T>): T[] {
    const queryKey = JSON.stringify(queryInterface);
    const cached = this.cache.get(queryKey);
    if (cached) {
      return [...cached];
    }

    const result = this.values().filter((data) => {
      for (const key in queryInterface) {
        const value = data[key];
        const conditions = queryInterface[key];

        if (!Array.isArray(conditions) || conditions.length === 0) {
          continue;
        }

        if (value === undefined) {
          return false;
        }

        if (Array.isArray(conditions[0])) {
          const valueStr = JSON.stringify(value);
          const match = conditions.some(
            (condition) => JSON.stringify(condition) === valueStr,
          );

          if (!match) {
            return false;
          }

          continue;
        }

        if (!conditions.includes(value)) {
          return false;
        }
      }
      return true;
    });

    Object.freeze(result);

    this.cache.set(queryKey, result);

    return [...result];
  }
}