* redis 메모리 관리
- 방법1 : redis의 모든키에대해 TTL을 달아주기
- 이 작업때문에 redis 쓰기,조회가 block 될수있나요?
주요 개선사항:
- SCAN 사용:
- smembers 대신 SCAN 명령어 사용
- 대규모 데이터셋에서도 안전하게 작동
- Pipeline 활용:
- 여러 Redis 명령어를 하나의 요청으로 묶음
- 네트워크 오버헤드 감소
- 배치 처리:
- BATCH_SIZE로 처리량 제어
- 메모리와 성능의 균형 유지
- 비동기 처리:
- Promise.allSettled 사용
- 독립적인 작업의 병렬 처리
- 에러 격리:
- 각 배치별 독립적인 에러 처리
- 전체 프로세스의 안정성 향상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
);
// 더 자세한 에러 정보와 컨텍스트 제공
}
성능 차이 비교:
- 메모리 사용:
- SMEMBERS: O(N) 메모리 사용
- SCAN: O(1) 메모리 사용
- Redis 서버 영향:
- SMEMBERS: 블로킹 발생 가능
- SCAN: 점진적 처리로 영향 최소화
- 네트워크 요청:
- 기존: N개의 개별 요청
- 개선: 배치 처리와 파이프라인으로 요청 수 감소
- 처리 시간:
// 성능 측정 예시 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
각 기능의 주요 용도:
- Middleware
// 주로 요청 전처리, 낮은 수준의 작업 @Injectable() export class LoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: Function) { console.log('Request...'); next(); // 응답 후 처리 불가능 // 핸들러 정보 접근 불가 } }
- Guards
// 주로 인증/인가 처리 @Injectable() export class AuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const client = context.switchToWs().getClient(); return client.auth?.isValid; // true/false만 반환 // 응답 변형 불가 } }
- Pipes
// 주로 데이터 변환 및 유효성 검사 @Injectable() export class ValidationPipe implements PipeTransform { transform(value: any) { // 데이터 변환만 가능 // 요청/응답 수정 불가 return value; } }
- Interceptor
// 요청/응답 모두 처리 가능, 높은 수준의 기능 @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가 적합한 이유:
- 실행 시점
async intercept(context: ExecutionContext, next: CallHandler) { // 핸들러 실행 전 const before = Date.now(); // 핸들러 실행 const result = await next.handle(); // 핸들러 실행 후 <- 이 시점이 중요 await this.gameRoomService.updateRoomActivity(gameId); return result; }
- 컨텍스트 접근
const data = context.switchToWs().getData(); // 요청 데이터 const client = context.switchToWs().getClient(); // 소켓 클라이언트 const handler = context.getHandler(); // 실행된 핸들러
- RxJS 지원
return next.handle().pipe( tap(() => this.updateActivity()), catchError(err => { this.logger.error(err); return throwError(err); }), timeoutWith(5000, throwError('Timeout')) );
- 응답 변형 가능
const result = await next.handle().toPromise(); // 응답에 추가 정보 포함 return of({ ...result, lastActivity: Date.now() });
각 기능을 사용했을 때의 문제점:
- Middleware로 구현시:
// 문제점: // - 핸들러 실행 후 처리 불가 // - WebSocket 컨텍스트 접근 어려움 class ActivityMiddleware { use(req, res, next) { // 응답 후 처리 불가능 next(); } }
- Guard로 구현시:
// 문제점: // - true/false만 반환 가능 // - 응답 수정 불가 class ActivityGuard { canActivate() { // 단순 검사만 가능 return true; } }
- Pipe로 구현시:
// 문제점: // - 데이터 변환만 가능 // - 비동기 작업 처리 어려움 class ActivityPipe { transform(value) { // 데이터 변환만 가능 return value; } }
따라서 활동 시간 업데이트와 같은 작업은:
- 핸들러 실행 후 처리 필요
- WebSocket 컨텍스트 접근 필요
- 비동기 작업 처리 필요
- 에러 처리 필요
이러한 요구사항을 모두 만족하는 것이 Interceptor입니다.
* 인터셉터 구현
- 스프링의 aop 랑 비슷하네.
- 핵심 비즈니스 로직외의것 && 공통 관심사를처리해주는 기능
- 이상한 값이 들어가있는것 같은데?
- 밀리초구나
- 퇴장했는데 방 id가 비었는데요?
- 애초에 createRoom 할때 Player정보가 안생기는건가?
- 어느시점에 Player 정보가 생기는거지?
- 어쨋든 clientId로 어느방에 속했는지을 알아내야한다.
- 음 host의 여부로 해도 되려나? 일단 clientId로 진행
- joinRoom에서 Player의 정보를 저장하는군.
- test 해보자
- todo : Player:Socket 도 지워주기
==============================노션 이주 완료 ====================================
- 2명 입장후 1명 연결을끊었는데 gameId가 왜 비었지?
- 여기서 playerData를 null 로 넘기는듯
- changes 감지 이해
- Player:socket:Changes에서 어떤 감지가 발생했는지 확인
- 엥 왜 다시 호출되는거지?
- 이쪽에서 Changes를 감시중이어서 걸린다!!
- Player:socket:Changes가 바뀌었으므로.
- 문제는 이때는 Player의 정보가 지워졌다는 점이다. // disconnect에서 GC가 돌아가서 player의 정보를 지운후임.
- 문제는 여기서 퇴장했음을 알려주고있네...
- 그러면, 즉시 지우는게 아니라 TTL을 10초 정도로 설정하면될듯?
- 음, disconnect를 미리 보내는게 더 좋을듯. -> 코드 수정이 클것같다. ttl로 가자.
* 마지막 플레이어가 나갔는데도 남은 데이터 확인
- 플레이어 퇴장시 Player:socketId:* 이 모든것을 지우면 되겠다.
- 이건왜 계속 남아있지?
- playerExit후에 새로 삽입된 데이터일 가능성이 높다.
- 일단 disconnect가 불린후에, playerExit가 호출된다.
- 순서 문제인줄 알았떠니 안지워진다.
- scan이 해당 패턴을 못찾거나 다른문제가 있는듯..
- 일단은 ttl을 10초로 설정해버리자. (disconnect니까 상관없을듯)
* 방장잠수인 방 체크하기
- 명시적 플레이어 퇴장이 없는경우 메모리를 정리해야 한다.
- 메소드는 이미 만들어놓았다.
- Cron을 이용해서 주기적으로 메소드를 실행할 수 있음.
'개발일지' 카테고리의 다른 글
24. 11 . 25. 개발일지 // 강퇴구현, 핀포인트 도입 (0) | 2024.11.26 |
---|---|
24. 11. 21. 개발일지 // 자동배포 수정, 인터셉터 버그수정(redis event trigger 이해) (0) | 2024.11.22 |
24. 11. 19. 개발일지 // test-code 의존성 수정, redis 구독 리팩토링, 진행중게임에 들어올수없음 (0) | 2024.11.20 |
24. 11. 18. 개발일지 // api vs 서비스주입, redis 캐시 (1) | 2024.11.19 |
24. 11. 17. 개발일지 redis 탐색 (0) | 2024.11.18 |