Skip to content

[최적화] effective한 redis memory 관리

DongHoonYu edited this page Dec 4, 2024 · 1 revision

📍 Redis 메모리 관리의 목표

우리의 프로젝트 목표

  • 한 게임방에서 200명이 원활히 플레이 하는 것!
  • 이에 대해 게임방과 유저 정보가 생성되고, 게임 종료 후 필요 없는 정보가 계속 남아있게 된다면 redis의 메모리 사용량이 꾸준히 증가하여 원활한 게임이 어려워짐

요구사항

  • 이 작업으로 인해 게임 진행 속도에 큰 영향을 주면 안된다.
  • 진행 중인 게임, 플레이어 정보는 삭제되면 안된다.
  • 연결이 종료된 사용자, 활동이 없는 게임 방의 정보는 바로바로 삭제되어야 한다.
  • 이 작업으로 인해 기존 코드가 변경되면 안된다.

⏰ Redis 메모리 관리 Version 1 - TTL

매 10분마다 TTL이 없는 키들을 검사하고 TTL 설정

@Cron(CronExpression.EVERY_MINUTE)
async manageTTL(): Promise<void> {
   try {
       // 활성화된 방 목록 조회  
       const activeRooms: string[] = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS);

       // 각 방에 대해 TTL 설정
       for (const roomId of activeRooms) {
           await this.setRoomTTL(roomId);
       }

       this.logger.verbose(`TTL 관리 완료: ${activeRooms.length}개 방 처리됨`);
   } catch (error) {
       this.logger.error('TTL 관리 실패', error?.message);
   }
}

적용 후 redis data에 ttl이 적용된 모습

적용 후 redis data에 ttl이 적용된 모습

redis 메모리 관리 작업 때문에 redis 쓰기, 조회에 영향이 덜 가도록 수정

  • 기존코드의 문제점
    • 순차적인 expire 명령어 실행
    • 한번에 많은 키를 처리
  • 해결책
    • SMEMBERS 대신 SCAN을 사용

    • Redis Pipeline으로 명령을 모아서 한번의 네트워크 요청으로 보내기

    • 코드

      @Cron(CronExpression.EVERY_MINUTE)
      async manageTTL(): Promise<void> {
        try {
          // SCAN으로 활성 방 목록을 배치로 처리
          let cursor = '0';
          do {
            const [nextCursor, rooms] = await this.redis.scan(
              cursor,
              'MATCH',
              'Room:*',
              'COUNT',
              this.BATCH_SIZE
            );
            cursor = nextCursor;
      
            if (rooms.length > 0) {
              await this.processBatch(rooms);
            }
          } while (cursor !== '0');
      
          this.logger.verbose('TTL 관리 완료');
        } catch (error) {
          this.logger.error('TTL 관리 실패', error?.message);
        }
      }
      
      /**
       * 배치 단위로 TTL 설정
       * Pipeline 사용으로 네트워크 요청 최소화
       */
      private async processBatch(rooms: string[]): Promise<void> 
        const pipeline = this.redis.pipeline();
      
        for (const roomKey of rooms) {
          const roomId = roomKey.split(':')[1];
          if (!roomId) {
            continue;
          }
      
          // 방 관련 기본 키들
          const baseKeys = [
            REDIS_KEY.ROOM(roomId),
            REDIS_KEY.ROOM_PLAYERS(roomId),
            REDIS_KEY.ROOM_LEADERBOARD(roomId),
            REDIS_KEY.ROOM_CURRENT_QUIZ(roomId)
          ];
      
          // TTL 설정을 파이프라인에 추가
          for (const key of baseKeys) {
            pipeline.expire(key, this.TTL.ROOM);
          }
        }
      
        // 파이프라인 실행
        await pipeline.exec();
      }

성능 차이 비교

특성 SMEMBERS SCAN
작동 방식 Atomic Operation (원자적 작업) Non-Atomic Operation (비원자적 작업)
Blocking 모든 데이터를 가져올 때까지 block 1ms마다 체크하여 yield 가능
메모리 사용 모든 데이터를 한번에 메모리에 로드 배치 단위로 메모리 사용
결과 정확성 정확한 결과 보장 중복 데이터가 반환될 수 있음
성능 (소규모) 빠름 (10,000개 미만 권장) SMEMBERS보다 느림
성능 (대규모) 매우 느림 (Redis 블로킹) 점진적 처리로 안정적
메모리 점유 높음 낮음
사용 권장 상황 작은 데이터셋 (< 10,000) 큰 데이터셋 (>= 10,000)
다른 명령어 실행 불가능 (블로킹) 가능 (non-블로킹)

🚀 Redis 메모리 관리 Version 2 - lastActivityAt

  • TTL 방식의 문제점

    • 방장이 잠수인 경우에 방 관련 데이터가 계속 남아있는 문제 발생
    • 2시간 이상 게임 진행을 원하는 사용자인 경우, 2시간 후에는 접속이 끊기게 됨
  • 방법

    • room.lastActivityAt을 체크 후, 30분이 지났으면 관련 정보(방, 플레이어, 퀴즈) 삭제하기
  • 설계

       flowchart TD
        A[플레이어 퇴장] -->|마지막 플레이어| B[방 정리 이벤트 발생]
        C[방 비활성화] -->|30분 경과| B
        B --> D{Redis Pub/Sub}
        D --> E[방 데이터 삭제]
        D --> F[플레이어 데이터 삭제]
        D --> G[퀴즈 데이터 삭제]
        E --> H[정리 완료]
        F --> H
        G --> H
    
    Loading
    • 이를 위해서 모든 활동이 발생할 때마다, 방의 활동 시간 업데이트를 하는 것으로 결정
  • 단점

    • 모든 서비스 메소드에 lastActivityAt 업데이트 코드를 추가해야 하는 문제 발생

🎯 인터셉터 를 통해 서비스 코드를 순수 비즈니스 로직으로 유지하기

  • 인터셉터 의미

    • 스프링의 AOP와 비슷한 개념
    • 핵심 비즈니스 로직 외의 것 && 공통 관심사를 처리해주는 기능
  • 인터셉터를 선택한 이유

    image

    • response를 조정할 수 있어서 향후 확장성 부분에서 인터셉터를 선택
    • 향후 기획적 요구사항이 추가되더라도 유연하게 대응하기 쉬울것이라고 생각
  • 적용

    • 게이트웨이에 Interceptor를 달아주면, 모든 메소드가 실행될 때마다 인터셉터가 실행되고, 기존 코드는 순수한 비즈니스 코드만 남게 되고, 핵심 비즈니스 로직이 아닌 방 활성화 시간 갱신 은 인터셉터에서 진행한다
  • 구현 코드

    //기존코드에서 단 1줄 추가
    @UseInterceptors(GameActivityInterceptor)
    @UseFilters(new WsExceptionFilter())
    @WebSocketGateway({
      cors: {
        origin: '*'
      },
      namespace: '/game'
    })
    export class GameGateway {
      ...
    }
    // 인터셉터
    // 활동 시간 업데이트는 비즈니스 로직과 분리
    import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
    import { of } from 'rxjs';
    import { GameRoomService } from '../service/game.room.service';
    
    @Injectable()
    export class GameActivityInterceptor implements NestInterceptor {
      private readonly logger = new Logger(GameActivityInterceptor.name);
    
      constructor(private readonly gameRoomService: GameRoomService) {}
    
      async intercept(context: ExecutionContext, next: CallHandler) {
        // 핵심 로직 실행 전
        const before = Date.now();
    
        // 핵심 로직 실행
        const result = await next.handle().toPromise();
    
        // 활동 시간 업데이트 (부가 기능)
        const data = context.switchToWs().getData();
        if (data.gameId) {
          await this.gameRoomService.updateRoomActivity(data.gameId);
          this.logger.debug(`방 ${data.gameId} 활동시간 갱신 완료 / after ${Date.now() - before}ms`);
        }
    
        return of(result);
      }
    }

lastActivityAt 의 단점

  • 모든 사용자가 활동할 때마다(채팅, 이동 등) room의 lastActivityAt에 지속적 쓰기 작업이 일어남
  • 개선
    • 모든 방에는 방장이 1명 존재한다는 특징을 이용
    • 방장이 활동할 때만 갱신하는 방법으로 개선하면 어떨까?

⭐️ 결과

  1. 한 기능을 만드는것에도 방법이 여러가지이다.
  2. 기존 코드를 수정하지 않고 성능이 좋은 방법을 선택하자라는 원칙을 배웠다.
Clone this wiki locally