관리 메뉴

Mini

24. 11. 20. 개발일지 // redis 메모리 관리 본문

개발일지

24. 11. 20. 개발일지 // redis 메모리 관리

Mini_96 2024. 11. 21. 12:12

* redis 메모리 관리

  • 방법1 : redis의 모든키에대해 TTL을 달아주기

  • 이 작업때문에 redis 쓰기,조회가 block 될수있나요?

주요 개선사항:

  1. SCAN 사용:
    • smembers 대신 SCAN 명령어 사용
    • 대규모 데이터셋에서도 안전하게 작동
  2. Pipeline 활용:
    • 여러 Redis 명령어를 하나의 요청으로 묶음
    • 네트워크 오버헤드 감소
  3. 배치 처리:
    • BATCH_SIZE로 처리량 제어
    • 메모리와 성능의 균형 유지
  4. 비동기 처리:
    • Promise.allSettled 사용
    • 독립적인 작업의 병렬 처리
  5. 에러 격리:
    • 각 배치별 독립적인 에러 처리
    • 전체 프로세스의 안정성 향상SMEMBERS와 SCAN의 주요 차이점과 기존 코드와 개선된 코드를 비교해드리겠습니다.

1. SMEMBERS vs SCAN 차이점:

// 1. SMEMBERS 방식 (기존) - 블로킹 발생 가능
const allMembers = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS);
// 한 번에 모든 데이터를 가져옴
// 데이터가 많을 경우 블로킹 발생 위험
// 메모리 사용량 급증 가능

// 2. SCAN 방식 (개선) - 점진적 처리
let cursor = '0';
do {
  const [nextCursor, batch] = await this.redis.scan(
    cursor,
    'MATCH',
    'Room:*',
    'COUNT',
    100  // 한 번에 처리할 양 제한
  );
  cursor = nextCursor;
  // 배치 단위로 처리
} while (cursor !== '0');

2. 주요 개선사항 비교:

1) 배치 처리 방식:

// 기존 코드 - 한 번에 모든 처리
async manageTTL(): Promise<void> {
  const activeRooms = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS);
  for (const roomId of activeRooms) {
    await this.setRoomTTL(roomId);
  }
}

// 개선된 코드 - 배치 단위 처리
async manageTTL(): Promise<void> {
  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');
}

2) Pipeline 사용:

// 기존 코드 - 개별 요청
private async setRoomTTL(roomId: string): Promise<void> {
  await this.redis.expire(REDIS_KEY.ROOM(roomId), this.TTL.ROOM);
  await this.redis.expire(REDIS_KEY.ROOM_PLAYERS(roomId), this.TTL.ROOM);
  await this.redis.expire(REDIS_KEY.ROOM_LEADERBOARD(roomId), this.TTL.ROOM);
  // 각각의 명령어가 개별 네트워크 요청 발생

// 개선된 코드 - Pipeline 사용
private async processBatch(rooms: string[]): Promise<void> {
  const pipeline = this.redis.pipeline();
  for (const roomKey of rooms) {
    pipeline.expire(REDIS_KEY.ROOM(roomId), this.TTL.ROOM);
    pipeline.expire(REDIS_KEY.ROOM_PLAYERS(roomId), this.TTL.ROOM);
    pipeline.expire(REDIS_KEY.ROOM_LEADERBOARD(roomId), this.TTL.ROOM);
  }
  await pipeline.exec();  // 한 번의 네트워크 요청으로 처리
}

3) 비동기 처리 최적화:

// 기존 코드 - 순차적 처리
private async setRoomTTL(roomId: string): Promise<void> {
  await this.cleanupGameRoom(roomId);
  await this.cleanupDisconnectedPlayers();
  // 순차적으로 처리되어 시간 소요

// 개선된 코드 - 병렬 처리
public async setRoomTTL(roomId: string): Promise<void> {
  await Promise.allSettled([
    this.processBatch([REDIS_KEY.ROOM(roomId)]),
    this.setPlayersTTL(roomId),
    this.setQuizTTL(roomId)
  ]);
  // 독립적인 작업들을 병렬로 처리
}

4) 에러 처리 개선:

// 기존 코드
catch (error) {
  this.logger.error('TTL 관리 실패', error);
}

// 개선된 코드
catch (error) {
  this.logger.error(
    `TTL 관리 실패 - Room: ${roomId}`, 
    error?.message,
    error?.stack
  );
  // 더 자세한 에러 정보와 컨텍스트 제공
}

성능 차이 비교:

  1. 메모리 사용:
    • SMEMBERS: O(N) 메모리 사용
    • SCAN: O(1) 메모리 사용
  2. Redis 서버 영향:
    • SMEMBERS: 블로킹 발생 가능
    • SCAN: 점진적 처리로 영향 최소화
  3. 네트워크 요청:
    • 기존: N개의 개별 요청
    • 개선: 배치 처리와 파이프라인으로 요청 수 감소
  4. 처리 시간:
  5. // 성능 측정 예시 async measurePerformance() { const start = Date.now(); // 작업 수행 const end = Date.now(); this.logger.verbose(`처리 시간: ${end - start}ms`); }

// 일반적인 성능 차이
// SMEMBERS: 10,000개 처리 시 한 번에 블로킹
// SCAN: 100개씩 처리하여 부하 분산

```

각 방식의 적절한 사용 시기:

  • SMEMBERS: 데이터 세트가 작고 전체 데이터가 필요할 때
  • SCAN: 대규모 데이터 세트를 처리하거나 실시간 시스템에서
  • 다중 was에서 문제점은 없을지?
  • 여러 was에서 TTL Set이 들어올수 있다.

  • 트랜잭션으로 was가 자신만의 lock을가지고 하는방법도 있는데, 너무 복잡하다.

* 방정리 구현2

  • 설계
flowchart TD
    A[플레이어 퇴장] -->|마지막 플레이어| B[방 정리 이벤트 발생]
    C[방 비활성화] -->|30분 경과| B
    B --> D{Redis Pub/Sub}
    D --> E[방 데이터 삭제]
    D --> F[플레이어 데이터 삭제]
    D --> G[퀴즈 데이터 삭제]
    E --> H[정리 완료]
    F --> H
    G --> H

  • 잘되던게 갑자기 안된다.

  • pub/sub에 대한 이해필요
sequenceDiagram
    participant Client as 게임 클라이언트
    participant GameService as 게임 서비스
    participant Publisher as Redis Publisher
    participant Subscriber as Redis Subscriber
    participant RoomCleaner as 방 정리 서비스

    Note over Publisher,Subscriber: Redis pub/sub 채널: 'room:cleanup'

    Client->>GameService: 마지막 플레이어 퇴장
    GameService->>Publisher: publish('room:cleanup', roomId)
    Publisher->>Subscriber: 메시지 전달 (roomId)
    Subscriber->>RoomCleaner: cleanupRoom(roomId) 실행
    RoomCleaner-->>GameService: 방 정리 완료

  • 음, was에 저 이벤트를 받을 redis 구독자가 있어야 되는거구나.

  • 패턴 매핑구독을 사용할때는 패턴메시지를 구독해야함.

* redis pipe line

  • 트랜잭션이랑 비슷한건가? nob

  • updateRoomActivity는 어디서 호출하는게 좋을까
    • 채팅, 위치업데이트, 방설정변경, 퀴즈셋변경, 게임시작
  • 바로 redis를 정리하면 안되나? 왜 pub/sub으로 해야하지?

  • 다중서버 떄문에 그렇다.

  • 하지만, 데이터가 redis 집중적이기 때문에 현재는 바로삭제해도 문제가 없다.
  • but, 확장성과 pub-sub구조를 직접 경험 해보고싶어서 pub-sub구조로 간다.

* why 인터셉터?

요청-응답 파이프라인에서 각 기능의 차이를 살펴보겠습니다:

flowchart LR
    Client((Client))
    Middleware[Middleware]
    Guards[Guards]
    Interceptor1[Pre-Interceptor]
    Pipes[Pipes]
    Handler[Handler]
    Interceptor2[Post-Interceptor]

    Client --> Middleware
    Middleware --> Guards
    Guards --> Interceptor1
    Interceptor1 --> Pipes
    Pipes --> Handler
    Handler --> Interceptor2
    Interceptor2 --> Client

    style Interceptor1 fill:#f96,stroke:#333
    style Interceptor2 fill:#f96,stroke:#333

각 기능의 주요 용도:

  1. Middleware
  2. // 주로 요청 전처리, 낮은 수준의 작업 @Injectable() export class LoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: Function) { console.log('Request...'); next(); // 응답 후 처리 불가능 // 핸들러 정보 접근 불가 } }
  3. Guards
  4. // 주로 인증/인가 처리 @Injectable() export class AuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const client = context.switchToWs().getClient(); return client.auth?.isValid; // true/false만 반환 // 응답 변형 불가 } }
  5. Pipes
  6. // 주로 데이터 변환 및 유효성 검사 @Injectable() export class ValidationPipe implements PipeTransform { transform(value: any) { // 데이터 변환만 가능 // 요청/응답 수정 불가 return value; } }
  7. Interceptor
  8. // 요청/응답 모두 처리 가능, 높은 수준의 기능 @Injectable() export class GameActivityInterceptor implements NestInterceptor { async intercept(context: ExecutionContext, next: CallHandler) { // 1. 핸들러 실행 전 작업 const before = Date.now(); // 2. 핸들러 실행 const result = await next.handle().toPromise(); // 3. 핸들러 실행 후 작업 const data = context.switchToWs().getData(); await this.updateActivity(data.gameId); // 4. 응답 변형 가능 return of(result); } }

활동 시간 업데이트에 Interceptor가 적합한 이유:

  1. 실행 시점
  2. async intercept(context: ExecutionContext, next: CallHandler) { // 핸들러 실행 전 const before = Date.now(); // 핸들러 실행 const result = await next.handle(); // 핸들러 실행 후 <- 이 시점이 중요 await this.gameRoomService.updateRoomActivity(gameId); return result; }
  3. 컨텍스트 접근
  4. const data = context.switchToWs().getData(); // 요청 데이터 const client = context.switchToWs().getClient(); // 소켓 클라이언트 const handler = context.getHandler(); // 실행된 핸들러
  5. RxJS 지원
  6. return next.handle().pipe( tap(() => this.updateActivity()), catchError(err => { this.logger.error(err); return throwError(err); }), timeoutWith(5000, throwError('Timeout')) );
  7. 응답 변형 가능
  8. const result = await next.handle().toPromise(); // 응답에 추가 정보 포함 return of({ ...result, lastActivity: Date.now() });

각 기능을 사용했을 때의 문제점:

  1. Middleware로 구현시:
  2. // 문제점: // - 핸들러 실행 후 처리 불가 // - WebSocket 컨텍스트 접근 어려움 class ActivityMiddleware { use(req, res, next) { // 응답 후 처리 불가능 next(); } }
  3. Guard로 구현시:
  4. // 문제점: // - true/false만 반환 가능 // - 응답 수정 불가 class ActivityGuard { canActivate() { // 단순 검사만 가능 return true; } }
  5. Pipe로 구현시:
  6. // 문제점: // - 데이터 변환만 가능 // - 비동기 작업 처리 어려움 class ActivityPipe { transform(value) { // 데이터 변환만 가능 return value; } }

따라서 활동 시간 업데이트와 같은 작업은:

  • 핸들러 실행 후 처리 필요
  • WebSocket 컨텍스트 접근 필요
  • 비동기 작업 처리 필요
  • 에러 처리 필요

이러한 요구사항을 모두 만족하는 것이 Interceptor입니다.

 

* 인터셉터 구현

  • 스프링의 aop 랑 비슷하네.
  • 핵심 비즈니스 로직외의것 && 공통 관심사를처리해주는 기능

gamegateway의 어떤 이벤트 발생전

  • 이상한 값이 들어가있는것 같은데?
    • 밀리초구나

  • 퇴장했는데 방 id가 비었는데요?

  • 애초에 createRoom 할때 Player정보가 안생기는건가?
  • 어느시점에 Player 정보가 생기는거지?
  • 어쨋든 clientId로 어느방에 속했는지을 알아내야한다.
    • 음 host의 여부로 해도 되려나? 일단 clientId로 진행

  • joinRoom에서 Player의 정보를 저장하는군.

  • test 해보자

createRoom, joinRoom 후 상태
disconnet
이후상태

  • todo : Player:Socket 도 지워주기

==============================노션 이주 완료 ====================================

  • 2명 입장후 1명 연결을끊었는데 gameId가 왜 비었지?

  • 여기서 playerData를 null 로 넘기는듯

  • changes 감지 이해
    • Player:socket:Changes에서 어떤 감지가 발생했는지 확인

  • 엥 왜 다시 호출되는거지?
  •  

  • 이쪽에서 Changes를 감시중이어서 걸린다!!
  • Player:socket:Changes가 바뀌었으므로.
  • 문제는 이때는 Player의 정보가 지워졌다는 점이다. // disconnect에서 GC가 돌아가서 player의 정보를 지운후임.

  • 문제는 여기서 퇴장했음을 알려주고있네...

  • 그러면, 즉시 지우는게 아니라 TTL을 10초 정도로 설정하면될듯?
  • 음, disconnect를 미리 보내는게 더 좋을듯. -> 코드 수정이 클것같다. ttl로 가자.

로그도잘찍히고 10초후 삭제되는 모습!

 

* 마지막 플레이어가 나갔는데도 남은 데이터 확인

  • 플레이어 퇴장시 Player:socketId:* 이 모든것을 지우면 되겠다.

  • 이건왜 계속 남아있지?
  • playerExit후에 새로 삽입된 데이터일 가능성이 높다.

  • 일단 disconnect가 불린후에, playerExit가 호출된다.
  • 순서 문제인줄 알았떠니 안지워진다.
    • scan이 해당 패턴을 못찾거나 다른문제가 있는듯..
    • 일단은 ttl을 10초로 설정해버리자. (disconnect니까 상관없을듯)

방청소후 깨끗한 레디스

* 방장잠수인 방 체크하기

  • 명시적 플레이어 퇴장이 없는경우 메모리를 정리해야 한다.
  • 메소드는 이미 만들어놓았다.
  • Cron을 이용해서 주기적으로 메소드를 실행할 수 있음.

앱 모듈에 스케쥴러를 import 해야함