From 2f2e75268c4b2a0731697acb48636d9f8b0d4233 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Wed, 20 Nov 2024 11:47:35 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20[BE]=20redis=EA=B5=AC=EB=8F=85?= =?UTF-8?q?=20=ED=8F=B4=EB=8D=94=20=EC=9C=84=EC=B9=98=20game=EB=82=B4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 game내에서만 사용중이므로 --- BE/src/app.module.ts | 116 +++++++++--------- BE/src/game/game.module.ts | 10 +- .../redis/redis-subscriber.service.ts | 0 .../redis/subscribers/base.subscriber.ts | 2 +- .../redis/subscribers/player.subscriber.ts | 2 +- .../redis/subscribers/room.subscriber.ts | 2 +- .../redis/subscribers/scoring.subscriber.ts | 4 +- .../redis/subscribers/timer.subscriber.ts | 4 +- BE/src/game/service/game.service.ts | 2 +- BE/test/integration/game.integration.spec.ts | 10 +- 10 files changed, 76 insertions(+), 76 deletions(-) rename BE/src/{common => game}/redis/redis-subscriber.service.ts (100%) rename BE/src/{common => game}/redis/subscribers/base.subscriber.ts (95%) rename BE/src/{common => game}/redis/subscribers/player.subscriber.ts (97%) rename BE/src/{common => game}/redis/subscribers/room.subscriber.ts (96%) rename BE/src/{common => game}/redis/subscribers/scoring.subscriber.ts (92%) rename BE/src/{common => game}/redis/subscribers/timer.subscriber.ts (96%) diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index 1c5e272..8262c80 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -1,58 +1,58 @@ -import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { GameModule } from './game/game.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { RedisModule } from '@nestjs-modules/ioredis'; -import { ConfigModule } from '@nestjs/config'; -import { QuizSetModel } from './quiz-set/entities/quiz-set.entity'; -import { QuizModel } from './quiz-set/entities/quiz.entity'; -import { QuizChoiceModel } from './quiz-set/entities/quiz-choice.entity'; -import { UserModel } from './user/entities/user.entity'; -import { UserQuizArchiveModel } from './user/entities/user-quiz-archive.entity'; -import { InitDBModule } from './InitDB/InitDB.module'; -import { UserModule } from './user/user.module'; -import { QuizSetModule } from './quiz-set/quiz-set.module'; -import { WaitingRoomModule } from './waiting-room/waiting-room.module'; -import { TimeController } from './time/time.controller'; -import { TimeModule } from './time/time.module'; -import { AuthModule } from './auth/auth.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - envFilePath: '../.env', - isGlobal: true - }), - GameModule, - TypeOrmModule.forRoot({ - type: 'mysql', - host: process.env.DB_HOST || 'localhost', - port: +process.env.DB_PORT || 3306, - username: process.env.DB_USER || 'root', - password: process.env.DB_PASSWD || 'test', - database: process.env.DB_NAME || 'test_db', - entities: [QuizSetModel, QuizModel, QuizChoiceModel, UserModel, UserQuizArchiveModel], - synchronize: process.env.DEV ? true : false, // 개발 모드에서만 활성화 - logging: true, // 모든 쿼리 로깅 - logger: 'advanced-console' - // extra: { - // // 글로벌 batch size 설정 - // maxBatchSize: 100 - // } - }), - RedisModule.forRoot({ - type: 'single', - url: process.env.REDIS_URL || 'redis://localhost:6379' - }), - QuizSetModule, - UserModule, - InitDBModule, - WaitingRoomModule, - TimeModule - AuthModule - ], - controllers: [AppController, TimeController], - providers: [AppService] -}) -export class AppModule {} +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { GameModule } from './game/game.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { ConfigModule } from '@nestjs/config'; +import { QuizSetModel } from './quiz-set/entities/quiz-set.entity'; +import { QuizModel } from './quiz-set/entities/quiz.entity'; +import { QuizChoiceModel } from './quiz-set/entities/quiz-choice.entity'; +import { UserModel } from './user/entities/user.entity'; +import { UserQuizArchiveModel } from './user/entities/user-quiz-archive.entity'; +import { InitDBModule } from './InitDB/InitDB.module'; +import { UserModule } from './user/user.module'; +import { QuizSetModule } from './quiz-set/quiz-set.module'; +import { WaitingRoomModule } from './waiting-room/waiting-room.module'; +import { TimeController } from './time/time.controller'; +import { TimeModule } from './time/time.module'; +import { AuthModule } from './auth/auth.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '../.env', + isGlobal: true + }), + GameModule, + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST || 'localhost', + port: +process.env.DB_PORT || 3306, + username: process.env.DB_USER || 'root', + password: process.env.DB_PASSWD || 'test', + database: process.env.DB_NAME || 'test_db', + entities: [QuizSetModel, QuizModel, QuizChoiceModel, UserModel, UserQuizArchiveModel], + synchronize: process.env.DEV ? true : false, // 개발 모드에서만 활성화 + logging: true, // 모든 쿼리 로깅 + logger: 'advanced-console' + // extra: { + // // 글로벌 batch size 설정 + // maxBatchSize: 100 + // } + }), + RedisModule.forRoot({ + type: 'single', + url: process.env.REDIS_URL || 'redis://localhost:6379' + }), + QuizSetModule, + UserModule, + InitDBModule, + WaitingRoomModule, + TimeModule, + AuthModule + ], + controllers: [AppController, TimeController], + providers: [AppService] +}) +export class AppModule {} diff --git a/BE/src/game/game.module.ts b/BE/src/game/game.module.ts index 87486c1..6e39680 100644 --- a/BE/src/game/game.module.ts +++ b/BE/src/game/game.module.ts @@ -8,11 +8,11 @@ import { GameRoomService } from './service/game.room.service'; import { QuizCacheService } from './service/quiz.cache.service'; import { QuizSetModule } from '../quiz-set/quiz-set.module'; import { QuizSetService } from '../quiz-set/service/quiz-set.service'; -import { ScoringSubscriber } from '../common/redis/subscribers/scoring.subscriber'; -import { TimerSubscriber } from '../common/redis/subscribers/timer.subscriber'; -import { RoomSubscriber } from '../common/redis/subscribers/room.subscriber'; -import { PlayerSubscriber } from '../common/redis/subscribers/player.subscriber'; -import { RedisSubscriberService } from '../common/redis/redis-subscriber.service'; +import { RedisSubscriberService } from './redis/redis-subscriber.service'; +import { ScoringSubscriber } from './redis/subscribers/scoring.subscriber'; +import { TimerSubscriber } from './redis/subscribers/timer.subscriber'; +import { RoomSubscriber } from './redis/subscribers/room.subscriber'; +import { PlayerSubscriber } from './redis/subscribers/player.subscriber'; @Module({ imports: [RedisModule, QuizSetModule], diff --git a/BE/src/common/redis/redis-subscriber.service.ts b/BE/src/game/redis/redis-subscriber.service.ts similarity index 100% rename from BE/src/common/redis/redis-subscriber.service.ts rename to BE/src/game/redis/redis-subscriber.service.ts diff --git a/BE/src/common/redis/subscribers/base.subscriber.ts b/BE/src/game/redis/subscribers/base.subscriber.ts similarity index 95% rename from BE/src/common/redis/subscribers/base.subscriber.ts rename to BE/src/game/redis/subscribers/base.subscriber.ts index 0d7a00b..39ea172 100644 --- a/BE/src/common/redis/subscribers/base.subscriber.ts +++ b/BE/src/game/redis/subscribers/base.subscriber.ts @@ -2,7 +2,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { Logger } from '@nestjs/common'; import { Server } from 'socket.io'; -import { REDIS_KEY } from '../../constants/redis-key.constant'; +import { REDIS_KEY } from '../../../common/constants/redis-key.constant'; export abstract class RedisSubscriber { protected readonly logger: Logger; diff --git a/BE/src/common/redis/subscribers/player.subscriber.ts b/BE/src/game/redis/subscribers/player.subscriber.ts similarity index 97% rename from BE/src/common/redis/subscribers/player.subscriber.ts rename to BE/src/game/redis/subscribers/player.subscriber.ts index ab47b03..f535d1a 100644 --- a/BE/src/common/redis/subscribers/player.subscriber.ts +++ b/BE/src/game/redis/subscribers/player.subscriber.ts @@ -3,7 +3,7 @@ import { RedisSubscriber } from './base.subscriber'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { Server } from 'socket.io'; -import SocketEvents from '../../constants/socket-events'; +import SocketEvents from '../../../common/constants/socket-events'; @Injectable() export class PlayerSubscriber extends RedisSubscriber { diff --git a/BE/src/common/redis/subscribers/room.subscriber.ts b/BE/src/game/redis/subscribers/room.subscriber.ts similarity index 96% rename from BE/src/common/redis/subscribers/room.subscriber.ts rename to BE/src/game/redis/subscribers/room.subscriber.ts index bbf2164..4a5c794 100644 --- a/BE/src/common/redis/subscribers/room.subscriber.ts +++ b/BE/src/game/redis/subscribers/room.subscriber.ts @@ -3,7 +3,7 @@ import { RedisSubscriber } from './base.subscriber'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { Server } from 'socket.io'; -import SocketEvents from '../../constants/socket-events'; +import SocketEvents from '../../../common/constants/socket-events'; @Injectable() export class RoomSubscriber extends RedisSubscriber { diff --git a/BE/src/common/redis/subscribers/scoring.subscriber.ts b/BE/src/game/redis/subscribers/scoring.subscriber.ts similarity index 92% rename from BE/src/common/redis/subscribers/scoring.subscriber.ts rename to BE/src/game/redis/subscribers/scoring.subscriber.ts index f142e47..e47a1d6 100644 --- a/BE/src/common/redis/subscribers/scoring.subscriber.ts +++ b/BE/src/game/redis/subscribers/scoring.subscriber.ts @@ -3,8 +3,8 @@ import { RedisSubscriber } from './base.subscriber'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { Server } from 'socket.io'; -import { REDIS_KEY } from '../../constants/redis-key.constant'; -import SocketEvents from '../../constants/socket-events'; +import { REDIS_KEY } from '../../../common/constants/redis-key.constant'; +import SocketEvents from '../../../common/constants/socket-events'; @Injectable() export class ScoringSubscriber extends RedisSubscriber { diff --git a/BE/src/common/redis/subscribers/timer.subscriber.ts b/BE/src/game/redis/subscribers/timer.subscriber.ts similarity index 96% rename from BE/src/common/redis/subscribers/timer.subscriber.ts rename to BE/src/game/redis/subscribers/timer.subscriber.ts index b2d9fde..cb2dc4b 100644 --- a/BE/src/common/redis/subscribers/timer.subscriber.ts +++ b/BE/src/game/redis/subscribers/timer.subscriber.ts @@ -3,8 +3,8 @@ import { RedisSubscriber } from './base.subscriber'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { Server } from 'socket.io'; -import { REDIS_KEY } from '../../constants/redis-key.constant'; -import SocketEvents from '../../constants/socket-events'; +import { REDIS_KEY } from '../../../common/constants/redis-key.constant'; +import SocketEvents from '../../../common/constants/socket-events'; @Injectable() export class TimerSubscriber extends RedisSubscriber { diff --git a/BE/src/game/service/game.service.ts b/BE/src/game/service/game.service.ts index 394b4cd..0bcd946 100644 --- a/BE/src/game/service/game.service.ts +++ b/BE/src/game/service/game.service.ts @@ -9,7 +9,7 @@ import { StartGameDto } from '../dto/start-game.dto'; import { Server } from 'socket.io'; import { mockQuizData } from '../../../test/mocks/quiz-data.mock'; import { QuizCacheService } from './quiz.cache.service'; -import { RedisSubscriberService } from '../../common/redis/redis-subscriber.service'; +import { RedisSubscriberService } from '../redis/redis-subscriber.service'; @Injectable() export class GameService { diff --git a/BE/test/integration/game.integration.spec.ts b/BE/test/integration/game.integration.spec.ts index 2347ffc..7fe29b5 100644 --- a/BE/test/integration/game.integration.spec.ts +++ b/BE/test/integration/game.integration.spec.ts @@ -27,11 +27,11 @@ import { QuizSetReadService } from '../../src/quiz-set/service/quiz-set-read.ser import { QuizSetUpdateService } from '../../src/quiz-set/service/quiz-set-update.service'; import { QuizSetDeleteService } from '../../src/quiz-set/service/quiz-set-delete.service'; import { ExceptionMessage } from '../../src/common/constants/exception-message'; -import { RedisSubscriberService } from '../../src/common/redis/redis-subscriber.service'; -import { ScoringSubscriber } from '../../src/common/redis/subscribers/scoring.subscriber'; -import { TimerSubscriber } from '../../src/common/redis/subscribers/timer.subscriber'; -import { RoomSubscriber } from '../../src/common/redis/subscribers/room.subscriber'; -import { PlayerSubscriber } from '../../src/common/redis/subscribers/player.subscriber'; +import { RedisSubscriberService } from '../../src/game/redis/redis-subscriber.service'; +import { ScoringSubscriber } from '../../src/game/redis/subscribers/scoring.subscriber'; +import { TimerSubscriber } from '../../src/game/redis/subscribers/timer.subscriber'; +import { RoomSubscriber } from '../../src/game/redis/subscribers/room.subscriber'; +import { PlayerSubscriber } from '../../src/game/redis/subscribers/player.subscriber'; /*disable eslint*/ const mockHttpService = { From 29eb46e4685feabede2db47019c017141a558b04 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Wed, 20 Nov 2024 14:38:55 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20[BE]=20quizset=20chace=20ttl=2030?= =?UTF-8?q?=EB=B6=84=EC=9C=BC=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/game/service/quiz.cache.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BE/src/game/service/quiz.cache.service.ts b/BE/src/game/service/quiz.cache.service.ts index c881b42..644e2ea 100644 --- a/BE/src/game/service/quiz.cache.service.ts +++ b/BE/src/game/service/quiz.cache.service.ts @@ -10,7 +10,7 @@ import { QuizSetService } from '../../quiz-set/service/quiz-set.service'; export class QuizCacheService { private readonly quizCache = new Map(); private readonly logger = new Logger(QuizCacheService.name); - private readonly CACHE_TTL = 1000 * 60 * 30; // 30분 + private readonly CACHE_TTL = 1 * 60 * 30; // 30분 constructor( @InjectRedis() private readonly redis: Redis, From cd2a3f1d76a359e256db63cf0f2bc223ca4a4fba Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Wed, 20 Nov 2024 14:45:04 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20[BE]=20redis=20TTL=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메모리 관리용 --- BE/package-lock.json | 48 ++++++++++ BE/package.json | 1 + BE/src/app.module.ts | 6 +- .../game/redis/game-redis-memory.service.ts | 96 +++++++++++++++++++ BE/src/main.ts | 2 +- 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 BE/src/game/redis/game-redis-memory.service.ts diff --git a/BE/package-lock.json b/BE/package-lock.json index 9f1346b..826a35e 100644 --- a/BE/package-lock.json +++ b/BE/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.6", + "@nestjs/schedule": "^4.1.1", "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", @@ -1872,6 +1873,31 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/schedule": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", + "dependencies": { + "cron": "3.1.7", + "uuid": "10.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/schedule/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -2535,6 +2561,11 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -4396,6 +4427,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "devOptional": true }, + "node_modules/cron": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz", + "integrity": "sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -7466,6 +7506,14 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", diff --git a/BE/package.json b/BE/package.json index e241b1f..63f51fe 100644 --- a/BE/package.json +++ b/BE/package.json @@ -39,6 +39,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.6", + "@nestjs/schedule": "^4.1.1", "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index 8262c80..39f41e0 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -17,6 +17,8 @@ import { WaitingRoomModule } from './waiting-room/waiting-room.module'; import { TimeController } from './time/time.controller'; import { TimeModule } from './time/time.module'; import { AuthModule } from './auth/auth.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { GameRedisMemoryService } from './game/redis/game-redis-memory.service'; @Module({ imports: [ @@ -25,6 +27,8 @@ import { AuthModule } from './auth/auth.module'; isGlobal: true }), GameModule, + // 스케줄러 모듈 추가 (Redis 메모리 관리 서비스의 @Cron 데코레이터 사용을 위해) + ScheduleModule.forRoot(), TypeOrmModule.forRoot({ type: 'mysql', host: process.env.DB_HOST || 'localhost', @@ -53,6 +57,6 @@ import { AuthModule } from './auth/auth.module'; AuthModule ], controllers: [AppController, TimeController], - providers: [AppService] + providers: [AppService, GameRedisMemoryService] }) export class AppModule {} diff --git a/BE/src/game/redis/game-redis-memory.service.ts b/BE/src/game/redis/game-redis-memory.service.ts new file mode 100644 index 0000000..3e975de --- /dev/null +++ b/BE/src/game/redis/game-redis-memory.service.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { REDIS_KEY } from '../../common/constants/redis-key.constant'; + +@Injectable() +export class GameRedisMemoryService { + private readonly logger = new Logger(GameRedisMemoryService.name); + + // TTL 설정값 (초 단위) + private readonly TTL = { + ROOM: 3 * 60 * 60, // 방: 3시간 + PLAYER: 2 * 60 * 60, // 플레이어: 2시간 + QUIZ: 1 * 60 * 60, // 퀴즈: 1시간 + LEADERBOARD: 3 * 60 * 60 // 리더보드: 3시간 + }; + + constructor(@InjectRedis() private readonly redis: Redis) {} + + /** + * 매 10분마다 TTL이 없는 키들을 검사하고 TTL 설정 + */ + @Cron(CronExpression.EVERY_MINUTE) + async manageTTL(): Promise { + try { + // 활성화된 방 목록 조회 + const activeRooms = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS); + + for (const roomId of activeRooms) { + await this.setRoomTTL(roomId); + } + + this.logger.verbose(`TTL 관리 완료: ${activeRooms.length}개 방 처리됨`); + } catch (error) { + this.logger.error('TTL 관리 실패', error?.message); + } + } + + /** + * 특정 방의 모든 관련 키에 TTL 설정 + */ + private async setRoomTTL(roomId: string): Promise { + try { + // 방 관련 키들의 TTL 확인 및 설정 + const keys = [ + // 방 기본 정보 + 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 keys) { + const ttl = await this.redis.ttl(key); + // TTL이 설정되지 않은 경우(-1) 또는 TTL이 없는 경우(-2) + if (ttl < 0) { + await this.redis.expire(key, this.TTL.ROOM); + this.logger.debug(`TTL 설정됨: ${key}`); + } + } + + // 해당 방의 플레이어들 TTL 설정 + const players = await this.redis.smembers(REDIS_KEY.ROOM_PLAYERS(roomId)); + for (const playerId of players) { + const playerKey = REDIS_KEY.PLAYER(playerId); + const ttl = await this.redis.ttl(playerKey); + if (ttl < 0) { + await this.redis.expire(playerKey, this.TTL.PLAYER); + } + } + + // 퀴즈 관련 키들 TTL 설정 + const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(roomId)); + for (const quizId of quizList) { + const quizKeys = [ + REDIS_KEY.ROOM_QUIZ(roomId, quizId), + REDIS_KEY.ROOM_QUIZ_CHOICES(roomId, quizId) + ]; + + for (const key of quizKeys) { + const ttl = await this.redis.ttl(key); + if (ttl < 0) { + await this.redis.expire(key, this.TTL.QUIZ); + } + } + } + } catch (error) { + this.logger.error(`방 ${roomId}의 TTL 설정 실패`, error?.message); + } + } +} diff --git a/BE/src/main.ts b/BE/src/main.ts index abea2b8..e33bb54 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3000; + const port = process.env.WAS_PORT || 3000; await app.listen(port); Logger.log(`Application running on port ${port}`); } From fcbe6a06b1feff98a9681548c22970d23b6f3933 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Wed, 20 Nov 2024 17:22:02 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20[BE]=20TTL=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=B9=84=EB=8F=99=EA=B8=B0,=20batch=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/redis/game-redis-memory.service.ts | 133 +++++++++++------- 1 file changed, 85 insertions(+), 48 deletions(-) diff --git a/BE/src/game/redis/game-redis-memory.service.ts b/BE/src/game/redis/game-redis-memory.service.ts index 3e975de..5f86f46 100644 --- a/BE/src/game/redis/game-redis-memory.service.ts +++ b/BE/src/game/redis/game-redis-memory.service.ts @@ -7,90 +7,127 @@ import { REDIS_KEY } from '../../common/constants/redis-key.constant'; @Injectable() export class GameRedisMemoryService { private readonly logger = new Logger(GameRedisMemoryService.name); + private readonly BATCH_SIZE = 100; // 한 번에 처리할 배치 크기 - // TTL 설정값 (초 단위) private readonly TTL = { - ROOM: 3 * 60 * 60, // 방: 3시간 - PLAYER: 2 * 60 * 60, // 플레이어: 2시간 - QUIZ: 1 * 60 * 60, // 퀴즈: 1시간 - LEADERBOARD: 3 * 60 * 60 // 리더보드: 3시간 + ROOM: 3 * 60 * 60, + PLAYER: 2 * 60 * 60, + QUIZ: 1 * 60 * 60, + LEADERBOARD: 3 * 60 * 60 }; constructor(@InjectRedis() private readonly redis: Redis) {} /** - * 매 10분마다 TTL이 없는 키들을 검사하고 TTL 설정 + * TTL 관리를 위한 스케줄러 + * 배치 처리로 블로킹 최소화 */ @Cron(CronExpression.EVERY_MINUTE) async manageTTL(): Promise { try { - // 활성화된 방 목록 조회 - const activeRooms = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS); + // SCAN으로 활성 방 목록을 배치로 처리 + let cursor = '0'; + do { + const [nextCursor, rooms] = await this.redis.scan( + cursor, + 'MATCH', + 'Room:*', + 'COUNT', + this.BATCH_SIZE + ); + cursor = nextCursor; - for (const roomId of activeRooms) { - await this.setRoomTTL(roomId); - } + if (rooms.length > 0) { + await this.processBatch(rooms); + } + } while (cursor !== '0'); - this.logger.verbose(`TTL 관리 완료: ${activeRooms.length}개 방 처리됨`); + this.logger.verbose('TTL 관리 완료'); } catch (error) { this.logger.error('TTL 관리 실패', error?.message); } } /** - * 특정 방의 모든 관련 키에 TTL 설정 + * 배치 단위로 TTL 설정 + * Pipeline 사용으로 네트워크 요청 최소화 */ - private async setRoomTTL(roomId: string): Promise { - try { - // 방 관련 키들의 TTL 확인 및 설정 - const keys = [ - // 방 기본 정보 + private async processBatch(rooms: string[]): Promise { + 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 keys) { - const ttl = await this.redis.ttl(key); - // TTL이 설정되지 않은 경우(-1) 또는 TTL이 없는 경우(-2) - if (ttl < 0) { - await this.redis.expire(key, this.TTL.ROOM); - this.logger.debug(`TTL 설정됨: ${key}`); - } + // TTL 설정을 파이프라인에 추가 + for (const key of baseKeys) { + pipeline.expire(key, this.TTL.ROOM); } + } - // 해당 방의 플레이어들 TTL 설정 + // 파이프라인 실행 + await pipeline.exec(); + } + + /** + * 플레이어 TTL 설정 + * 비동기로 처리하되 에러는 로깅 + */ + private async setPlayersTTL(roomId: string): Promise { + try { + const pipeline = this.redis.pipeline(); const players = await this.redis.smembers(REDIS_KEY.ROOM_PLAYERS(roomId)); + for (const playerId of players) { - const playerKey = REDIS_KEY.PLAYER(playerId); - const ttl = await this.redis.ttl(playerKey); - if (ttl < 0) { - await this.redis.expire(playerKey, this.TTL.PLAYER); - } + pipeline.expire(REDIS_KEY.PLAYER(playerId), this.TTL.PLAYER); } - // 퀴즈 관련 키들 TTL 설정 + await pipeline.exec(); + } catch (error) { + this.logger.error(`플레이어 TTL 설정 실패 - Room: ${roomId}`, error?.message); + } + } + + /** + * 퀴즈 TTL 설정 + * 비동기로 처리하되 에러는 로깅 + */ + private async setQuizTTL(roomId: string): Promise { + try { + const pipeline = this.redis.pipeline(); const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(roomId)); + for (const quizId of quizList) { - const quizKeys = [ - REDIS_KEY.ROOM_QUIZ(roomId, quizId), - REDIS_KEY.ROOM_QUIZ_CHOICES(roomId, quizId) - ]; - - for (const key of quizKeys) { - const ttl = await this.redis.ttl(key); - if (ttl < 0) { - await this.redis.expire(key, this.TTL.QUIZ); - } - } + pipeline.expire(REDIS_KEY.ROOM_QUIZ(roomId, quizId), this.TTL.QUIZ); + pipeline.expire(REDIS_KEY.ROOM_QUIZ_CHOICES(roomId, quizId), this.TTL.QUIZ); } + + await pipeline.exec(); } catch (error) { - this.logger.error(`방 ${roomId}의 TTL 설정 실패`, error?.message); + this.logger.error(`퀴즈 TTL 설정 실패 - Room: ${roomId}`, error?.message); } } + + /** + * 단일 방에 대한 모든 TTL 설정 + * 비동기 처리로 블로킹 최소화 + */ + public async setRoomTTL(roomId: string): Promise { + // 비동기로 각 작업 실행 + await Promise.allSettled([ + this.processBatch([REDIS_KEY.ROOM(roomId)]), + this.setPlayersTTL(roomId), + this.setQuizTTL(roomId) + ]); + } } From fa0f4607793acf8a941e9065f7cc767e02a6e92d Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Wed, 20 Nov 2024 21:30:45 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20[BE]=20=ED=87=B4=EC=9E=A5=EC=8B=9C?= =?UTF-8?q?=20redis=EC=97=90=EC=84=9C=20Room=20data=20=EC=82=AD=EC=A0=9C,?= =?UTF-8?q?=20gateway=EC=9A=94=EC=B2=AD=EC=8B=9C=20Activity=20update=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인터셉터 이용 --- BE/src/app.module.ts | 12 +++- BE/src/game/game.gateway.ts | 7 +- BE/src/game/game.module.ts | 4 +- .../interceptor/gameActivity.interceptor.ts | 28 ++++++++ BE/src/game/redis/redis-subscriber.service.ts | 12 +++- .../subscribers/room.cleanup.subscriber.ts | 64 +++++++++++++++++++ BE/src/game/service/game.room.service.ts | 57 +++++++++++++++++ BE/src/main.ts | 4 ++ 8 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 BE/src/game/interceptor/gameActivity.interceptor.ts create mode 100644 BE/src/game/redis/subscribers/room.cleanup.subscriber.ts diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index 39f41e0..3fd5aca 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { ClassSerializerInterceptor, Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { GameModule } from './game/game.module'; @@ -19,6 +19,7 @@ import { TimeModule } from './time/time.module'; import { AuthModule } from './auth/auth.module'; import { ScheduleModule } from '@nestjs/schedule'; import { GameRedisMemoryService } from './game/redis/game-redis-memory.service'; +import { APP_INTERCEPTOR } from '@nestjs/core'; @Module({ imports: [ @@ -57,6 +58,13 @@ import { GameRedisMemoryService } from './game/redis/game-redis-memory.service'; AuthModule ], controllers: [AppController, TimeController], - providers: [AppService, GameRedisMemoryService] + providers: [ + AppService, + GameRedisMemoryService, + { + provide: APP_INTERCEPTOR, + useClass: ClassSerializerInterceptor + } + ] }) export class AppModule {} diff --git a/BE/src/game/game.gateway.ts b/BE/src/game/game.gateway.ts index d7c01fb..cf73b2b 100644 --- a/BE/src/game/game.gateway.ts +++ b/BE/src/game/game.gateway.ts @@ -6,7 +6,7 @@ import { WebSocketServer } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { Logger, UseFilters, UsePipes } from '@nestjs/common'; +import { Logger, UseFilters, UseInterceptors, UsePipes } from '@nestjs/common'; import { WsExceptionFilter } from '../common/filters/ws-exception.filter'; import SocketEvents from '../common/constants/socket-events'; import { CreateGameDto } from './dto/create-game.dto'; @@ -20,7 +20,9 @@ import { UpdateRoomOptionDto } from './dto/update-room-option.dto'; import { UpdateRoomQuizsetDto } from './dto/update-room-quizset.dto'; import { GameChatService } from './service/game.chat.service'; import { GameRoomService } from './service/game.room.service'; +import { GameActivityInterceptor } from './interceptor/gameActivity.interceptor'; +@UseInterceptors(GameActivityInterceptor) @UseFilters(new WsExceptionFilter()) @WebSocketGateway({ cors: { @@ -120,9 +122,10 @@ export class GameGateway { this.logger.verbose(`클라이언트가 연결되었어요!: ${client.id}`); } - handleDisconnect(client: Socket) { + async handleDisconnect(client: Socket) { this.logger.verbose(`클라이언트가 연결 해제되었어요!: ${client.id}`); this.gameService.disconnect(client.id); + await this.gameRoomService.handlePlayerExit(client.id); } } diff --git a/BE/src/game/game.module.ts b/BE/src/game/game.module.ts index 6e39680..d043870 100644 --- a/BE/src/game/game.module.ts +++ b/BE/src/game/game.module.ts @@ -13,6 +13,7 @@ import { ScoringSubscriber } from './redis/subscribers/scoring.subscriber'; import { TimerSubscriber } from './redis/subscribers/timer.subscriber'; import { RoomSubscriber } from './redis/subscribers/room.subscriber'; import { PlayerSubscriber } from './redis/subscribers/player.subscriber'; +import { RoomCleanupSubscriber } from './redis/subscribers/room.cleanup.subscriber'; @Module({ imports: [RedisModule, QuizSetModule], @@ -28,7 +29,8 @@ import { PlayerSubscriber } from './redis/subscribers/player.subscriber'; ScoringSubscriber, TimerSubscriber, RoomSubscriber, - PlayerSubscriber + PlayerSubscriber, + RoomCleanupSubscriber ], exports: [GameService] }) diff --git a/BE/src/game/interceptor/gameActivity.interceptor.ts b/BE/src/game/interceptor/gameActivity.interceptor.ts new file mode 100644 index 0000000..5dc57b5 --- /dev/null +++ b/BE/src/game/interceptor/gameActivity.interceptor.ts @@ -0,0 +1,28 @@ +// 활동 시간 업데이트는 비즈니스 로직과 분리 +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(`Activity updated for room ${data.gameId} after ${Date.now() - before}ms`); + } + + return of(result); + } +} diff --git a/BE/src/game/redis/redis-subscriber.service.ts b/BE/src/game/redis/redis-subscriber.service.ts index c1e4f6c..ba697dc 100644 --- a/BE/src/game/redis/redis-subscriber.service.ts +++ b/BE/src/game/redis/redis-subscriber.service.ts @@ -7,6 +7,7 @@ import { TimerSubscriber } from './subscribers/timer.subscriber'; import { RoomSubscriber } from './subscribers/room.subscriber'; import { PlayerSubscriber } from './subscribers/player.subscriber'; import { Server } from 'socket.io'; +import { RoomCleanupSubscriber } from './subscribers/room.cleanup.subscriber'; @Injectable() export class RedisSubscriberService { @@ -18,9 +19,16 @@ export class RedisSubscriberService { private readonly scoringSubscriber: ScoringSubscriber, private readonly timerSubscriber: TimerSubscriber, private readonly roomSubscriber: RoomSubscriber, - private readonly playerSubscriber: PlayerSubscriber + private readonly playerSubscriber: PlayerSubscriber, + private readonly roomCleanupSubscriber: RoomCleanupSubscriber ) { - this.subscribers = [scoringSubscriber, timerSubscriber, roomSubscriber, playerSubscriber]; + this.subscribers = [ + scoringSubscriber, + timerSubscriber, + roomSubscriber, + playerSubscriber, + roomCleanupSubscriber + ]; } async initializeSubscribers(server: Server) { diff --git a/BE/src/game/redis/subscribers/room.cleanup.subscriber.ts b/BE/src/game/redis/subscribers/room.cleanup.subscriber.ts new file mode 100644 index 0000000..67054e4 --- /dev/null +++ b/BE/src/game/redis/subscribers/room.cleanup.subscriber.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { RedisSubscriber } from './base.subscriber'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { Server } from 'socket.io'; +import { REDIS_KEY } from '../../../common/constants/redis-key.constant'; + +@Injectable() +export class RoomCleanupSubscriber extends RedisSubscriber { + constructor(@InjectRedis() redis: Redis) { + super(redis); + } + + /** + * Redis 구독 초기화 및 정리 이벤트 핸들러 등록 + */ + async subscribe(server: Server): Promise { + const subscriber = this.redis.duplicate(); + + // 방 정리 이벤트 구독 + await subscriber.subscribe('room:cleanup'); + + // 일반 메시지 리스너 + subscriber.on('message', async (channel, roomId) => { + if (channel === 'room:cleanup') { + await this.cleanupRoom(roomId); + } + }); + + this.logger.verbose('방 정리 이벤트 구독 시작'); + } + + /** + * 방 및 관련 데이터 정리 + */ + private async cleanupRoom(roomId: string): Promise { + try { + const pipeline = this.redis.pipeline(); + + // 1. 방 관련 기본 데이터 삭제 + pipeline.del(REDIS_KEY.ROOM(roomId)); + pipeline.del(REDIS_KEY.ROOM_PLAYERS(roomId)); + pipeline.del(REDIS_KEY.ROOM_LEADERBOARD(roomId)); + pipeline.del(REDIS_KEY.ROOM_CURRENT_QUIZ(roomId)); + pipeline.del(REDIS_KEY.ROOM_TIMER(roomId)); + + // 2. 퀴즈 데이터 정리 + const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(roomId)); + for (const quizId of quizList) { + pipeline.del(REDIS_KEY.ROOM_QUIZ(roomId, quizId)); + pipeline.del(REDIS_KEY.ROOM_QUIZ_CHOICES(roomId, quizId)); + } + pipeline.del(REDIS_KEY.ROOM_QUIZ_SET(roomId)); + + // 3. 활성 방 목록에서 제거 + pipeline.srem(REDIS_KEY.ACTIVE_ROOMS, roomId); + + await pipeline.exec(); // 네트워크에 한번에 요청보내기 + this.logger.verbose(`방 ${roomId} 정리 완료`); + } catch (error) { + this.logger.error(`방 ${roomId} 정리 실패`, error?.message); + } + } +} diff --git a/BE/src/game/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts index a527384..5a9233d 100644 --- a/BE/src/game/service/game.room.service.ts +++ b/BE/src/game/service/game.room.service.ts @@ -13,6 +13,7 @@ import { UpdateRoomQuizsetDto } from '../dto/update-room-quizset.dto'; @Injectable() export class GameRoomService { private readonly logger = new Logger(GameRoomService.name); + private readonly INACTIVE_THRESHOLD = 30 * 60 * 1000; // 30분 constructor( @InjectRedis() private readonly redis: Redis, @@ -123,4 +124,60 @@ export class GameRoomService { }); this.logger.verbose(`게임방 퀴즈셋 변경: ${gameId}`); } + + /** + * 플레이어 퇴장 처리 + */ + async handlePlayerExit(clientId: string): Promise { + const playerKey = REDIS_KEY.PLAYER(clientId); + const player = await this.redis.hgetall(playerKey); + const roomId = player.gameId; + + const pipeline = this.redis.pipeline(); + + // 플레이어 제거 + pipeline.srem(REDIS_KEY.ROOM_PLAYERS(roomId), clientId); + pipeline.del(REDIS_KEY.PLAYER(clientId)); + + // 남은 플레이어 수 확인 + pipeline.scard(REDIS_KEY.ROOM_PLAYERS(roomId)); + + const results = await pipeline.exec(); + const remainingPlayers = results[2][1] as number; + + if (remainingPlayers === 0) { + // 마지막 플레이어가 나간 경우 + await this.redis.publish('room:cleanup', roomId); + this.logger.log(`마지막 플레이어 퇴장으로 방 ${roomId} 정리 시작`); + } + } + + /** + * 방 활동 업데이트 + */ + async updateRoomActivity(roomId: string): Promise { + const pipeline = this.redis.pipeline(); + + pipeline.hset(REDIS_KEY.ROOM(roomId), 'lastActivityAt', Date.now().toString()); + pipeline.hget(REDIS_KEY.ROOM(roomId), 'lastActivityAt'); + + await pipeline.exec(); + } + + /** + * 비활성 방 체크 (주기적으로 실행) + */ + async checkInactiveRooms(): Promise { + const now = Date.now(); + const rooms = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS); + + for (const roomId of rooms) { + const lastActivity = await this.redis.hget(REDIS_KEY.ROOM(roomId), 'lastActivityAt'); + + if (lastActivity && now - parseInt(lastActivity) > this.INACTIVE_THRESHOLD) { + await this.redis.publish('room:cleanup', roomId); + this.logger.log(`비활성으로 인해 방 ${roomId} 정리 시작`); + } + } + } } diff --git a/BE/src/main.ts b/BE/src/main.ts index e33bb54..bd47c0c 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -1,10 +1,14 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger } from '@nestjs/common'; +import { GameActivityInterceptor } from './game/interceptor/gameActivity.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule); + // 전역 인터셉터로 등록 + app.useGlobalInterceptors(app.get(GameActivityInterceptor)); + const port = process.env.WAS_PORT || 3000; await app.listen(port); Logger.log(`Application running on port ${port}`); From 185a8c17e1656ff24d5dead7cb685efe0f1d4774 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Wed, 20 Nov 2024 22:43:00 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20[BE]=20=EC=97=B0=EA=B2=B0=EB=81=8A?= =?UTF-8?q?=EC=9D=80=20=EC=A6=89=EC=8B=9C=20=EC=82=AD=EC=A0=9C=20->=20?= =?UTF-8?q?=EB=82=98=EA=B0=94=EB=8B=A4=EB=8A=94=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=AA=BB=EB=BF=8C=EB=A0=A4=EC=A3=BC?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 즉시삭제x , ttl로 10초의 정보를 더 가지고 있어야함. --- BE/src/game/redis/subscribers/player.subscriber.ts | 5 ++++- BE/src/game/service/game.room.service.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/BE/src/game/redis/subscribers/player.subscriber.ts b/BE/src/game/redis/subscribers/player.subscriber.ts index f535d1a..6d2b61e 100644 --- a/BE/src/game/redis/subscribers/player.subscriber.ts +++ b/BE/src/game/redis/subscribers/player.subscriber.ts @@ -4,6 +4,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { Server } from 'socket.io'; import SocketEvents from '../../../common/constants/socket-events'; +import { REDIS_KEY } from '../../../common/constants/redis-key.constant'; @Injectable() export class PlayerSubscriber extends RedisSubscriber { @@ -22,6 +23,7 @@ export class PlayerSubscriber extends RedisSubscriber { } const key = `Player:${playerId}`; + await this.handlePlayerChanges(key, playerId, server); }); } @@ -33,7 +35,8 @@ export class PlayerSubscriber extends RedisSubscriber { private async handlePlayerChanges(key: string, playerId: string, server: Server) { const changes = await this.redis.get(`${key}:Changes`); - const playerData = await this.redis.hgetall(key); + const playerKey = REDIS_KEY.PLAYER(playerId); + const playerData = await this.redis.hgetall(playerKey); switch (changes) { case 'Join': diff --git a/BE/src/game/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts index 5a9233d..9de8ec0 100644 --- a/BE/src/game/service/game.room.service.ts +++ b/BE/src/game/service/game.room.service.ts @@ -14,6 +14,7 @@ import { UpdateRoomQuizsetDto } from '../dto/update-room-quizset.dto'; export class GameRoomService { private readonly logger = new Logger(GameRoomService.name); private readonly INACTIVE_THRESHOLD = 30 * 60 * 1000; // 30분 + private readonly PLAYER_GRACE_PERIOD = 10; // 10초 constructor( @InjectRedis() private readonly redis: Redis, @@ -137,7 +138,13 @@ export class GameRoomService { // 플레이어 제거 pipeline.srem(REDIS_KEY.ROOM_PLAYERS(roomId), clientId); - pipeline.del(REDIS_KEY.PLAYER(clientId)); + // pipeline.del(REDIS_KEY.PLAYER(clientId)); + // 1. 플레이어 상태를 'disconnected'로 변경하고 TTL 설정 + pipeline.hmset(REDIS_KEY.PLAYER(clientId), { + disconnected: '1', + disconnectedAt: Date.now().toString() + }); + pipeline.expire(REDIS_KEY.PLAYER(clientId), this.PLAYER_GRACE_PERIOD); // 남은 플레이어 수 확인 pipeline.scard(REDIS_KEY.ROOM_PLAYERS(roomId)); From edd55afa45ac719fc1d9914f5caa991dff634a8d Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Wed, 20 Nov 2024 22:45:35 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore:=20[BE]=20test=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 룸클리너 --- BE/test/integration/game.integration.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BE/test/integration/game.integration.spec.ts b/BE/test/integration/game.integration.spec.ts index 7fe29b5..a9d7fa0 100644 --- a/BE/test/integration/game.integration.spec.ts +++ b/BE/test/integration/game.integration.spec.ts @@ -32,6 +32,7 @@ import { ScoringSubscriber } from '../../src/game/redis/subscribers/scoring.subs import { TimerSubscriber } from '../../src/game/redis/subscribers/timer.subscriber'; import { RoomSubscriber } from '../../src/game/redis/subscribers/room.subscriber'; import { PlayerSubscriber } from '../../src/game/redis/subscribers/player.subscriber'; +import { RoomCleanupSubscriber } from '../../src/game/redis/subscribers/room.cleanup.subscriber'; /*disable eslint*/ const mockHttpService = { @@ -104,6 +105,7 @@ describe('GameGateway (e2e)', () => { TimerSubscriber, RoomSubscriber, PlayerSubscriber, + RoomCleanupSubscriber, { provide: 'default_IORedisModuleConnectionToken', useValue: redisMock From f3a94b5f22dd433c82451543362efe701b984fad Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Wed, 20 Nov 2024 23:49:45 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20[BE]=20player=20=ED=87=B4=EC=9E=A5?= =?UTF-8?q?=EC=8B=9C=20=EA=B4=80=EB=A0=A8=20data=20=EB=AA=A8=EB=91=90?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit player:socketId:* 패턴의 모든 데이터 삭제 --- BE/src/game/game.gateway.ts | 2 +- BE/src/game/service/game.room.service.ts | 27 +++++++++++++++++++++++- BE/src/game/service/game.service.ts | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/BE/src/game/game.gateway.ts b/BE/src/game/game.gateway.ts index cf73b2b..0928527 100644 --- a/BE/src/game/game.gateway.ts +++ b/BE/src/game/game.gateway.ts @@ -125,7 +125,7 @@ export class GameGateway { async handleDisconnect(client: Socket) { this.logger.verbose(`클라이언트가 연결 해제되었어요!: ${client.id}`); - this.gameService.disconnect(client.id); + await this.gameService.disconnect(client.id); await this.gameRoomService.handlePlayerExit(client.id); } } diff --git a/BE/src/game/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts index 9de8ec0..d5788c0 100644 --- a/BE/src/game/service/game.room.service.ts +++ b/BE/src/game/service/game.room.service.ts @@ -150,7 +150,10 @@ export class GameRoomService { pipeline.scard(REDIS_KEY.ROOM_PLAYERS(roomId)); const results = await pipeline.exec(); - const remainingPlayers = results[2][1] as number; + const remainingPlayers = results[3][1] as number; + + // 4. 플레이어 관련 모든 키에 TTL 설정 + await this.setTTLForPlayerKeys(clientId); if (remainingPlayers === 0) { // 마지막 플레이어가 나간 경우 @@ -187,4 +190,26 @@ export class GameRoomService { } } } + + /** + * 플레이어 관련 모든 데이터에 TTL 설정 + */ + private async setTTLForPlayerKeys(clientId: string): Promise { + let cursor = '0'; + const pattern = `Player:${clientId}:*`; + const pipeline = this.redis.pipeline(); + + do { + // SCAN으로 플레이어 관련 키들을 배치로 찾기 + const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + + // 찾은 모든 키에 TTL 설정 + for (const key of keys) { + pipeline.expire(key, this.PLAYER_GRACE_PERIOD); + } + } while (cursor !== '0'); + + await pipeline.exec(); + } } diff --git a/BE/src/game/service/game.service.ts b/BE/src/game/service/game.service.ts index 0bcd946..809ec8e 100644 --- a/BE/src/game/service/game.service.ts +++ b/BE/src/game/service/game.service.ts @@ -148,7 +148,7 @@ export class GameService { host: newHost }); } - await this.redis.set(`${playerKey}:Changes`, 'Disconnect'); + await this.redis.set(`${playerKey}:Changes`, 'Disconnect', 'EX', 600); // 해당플레이어의 변화정보 10분 후에 삭제 await this.redis.hmset(playerKey, { disconnected: '1' }); From 30d6b365a7dd25d240485b8e519fd931cc08100e Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Thu, 21 Nov 2024 01:45:06 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20[BE]=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=20=EB=B0=A9=20=EC=B2=B4=ED=81=AC=20=EC=A3=BC=EA=B8=B0=EC=A0=81?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 방장이 잠수인경우, 명시적 퇴장이 없음 --- BE/src/game/service/game.room.service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/BE/src/game/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts index d5788c0..20e9084 100644 --- a/BE/src/game/service/game.room.service.ts +++ b/BE/src/game/service/game.room.service.ts @@ -9,11 +9,12 @@ import { JoinRoomDto } from '../dto/join-room.dto'; import SocketEvents from '../../common/constants/socket-events'; import { UpdateRoomOptionDto } from '../dto/update-room-option.dto'; import { UpdateRoomQuizsetDto } from '../dto/update-room-quizset.dto'; +import { Cron, CronExpression } from '@nestjs/schedule'; @Injectable() export class GameRoomService { private readonly logger = new Logger(GameRoomService.name); - private readonly INACTIVE_THRESHOLD = 30 * 60 * 1000; // 30분 + private readonly INACTIVE_THRESHOLD = 30 * 60 * 1000; // 30분 30 * 60 * 1000; private readonly PLAYER_GRACE_PERIOD = 10; // 10초 constructor( @@ -177,16 +178,18 @@ export class GameRoomService { /** * 비활성 방 체크 (주기적으로 실행) */ + @Cron(CronExpression.EVERY_MINUTE) async checkInactiveRooms(): Promise { const now = Date.now(); const rooms = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS); + this.logger.verbose(`비활성 방 체크시작 / 활성 방 목록: ${rooms}`); for (const roomId of rooms) { const lastActivity = await this.redis.hget(REDIS_KEY.ROOM(roomId), 'lastActivityAt'); if (lastActivity && now - parseInt(lastActivity) > this.INACTIVE_THRESHOLD) { await this.redis.publish('room:cleanup', roomId); - this.logger.log(`비활성으로 인해 방 ${roomId} 정리 시작`); + this.logger.verbose(`비활성으로 인해 방 ${roomId} 정리 시작`); } } }