-
Notifications
You must be signed in to change notification settings - Fork 5
[최적화] effective한 redis memory 관리
DongHoonYu edited this page Dec 4, 2024
·
1 revision
- 한 게임방에서 200명이 원활히 플레이 하는 것!
- 이에 대해 게임방과 유저 정보가 생성되고, 게임 종료 후 필요 없는 정보가 계속 남아있게 된다면 redis의 메모리 사용량이 꾸준히 증가하여 원활한 게임이 어려워짐
- 이 작업으로 인해 게임 진행 속도에 큰 영향을 주면 안된다.
- 진행 중인 게임, 플레이어 정보는 삭제되면 안된다.
- 연결이 종료된 사용자, 활동이 없는 게임 방의 정보는 바로바로 삭제되어야 한다.
- 이 작업으로 인해 기존 코드가 변경되면 안된다.
@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이 적용된 모습
- 기존코드의 문제점
- 순차적인 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-블로킹) |
-
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
- 이를 위해서 모든 활동이 발생할 때마다, 방의 활동 시간 업데이트를 하는 것으로 결정
-
단점
- 모든 서비스 메소드에
lastActivityAt
업데이트 코드를 추가해야 하는 문제 발생
- 모든 서비스 메소드에
-
인터셉터
의미- 스프링의 AOP와 비슷한 개념
- 핵심 비즈니스 로직 외의 것 && 공통 관심사를 처리해주는 기능
-
인터셉터를 선택한 이유
- 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); } }
- 모든 사용자가 활동할 때마다(채팅, 이동 등) room의 lastActivityAt에 지속적 쓰기 작업이 일어남
- 개선
- 모든 방에는 방장이 1명 존재한다는 특징을 이용
- 방장이 활동할 때만 갱신하는 방법으로 개선하면 어떨까?
- 한 기능을 만드는것에도 방법이 여러가지이다.
- 기존 코드를 수정하지 않고 성능이 좋은 방법을 선택하자라는 원칙을 배웠다.