diff --git a/BE/src/common/constants/redis-key.constant.ts b/BE/src/common/constants/redis-key.constant.ts index 9866610..83c6b90 100644 --- a/BE/src/common/constants/redis-key.constant.ts +++ b/BE/src/common/constants/redis-key.constant.ts @@ -8,6 +8,7 @@ export const REDIS_KEY = { ROOM_TIMER: (gameId: string) => `Room:${gameId}:Timer`, ROOM_QUIZ_SET: (gameId: string) => `Room:${gameId}:QuizSet`, PLAYER: (playerId: string) => `Player:${playerId}`, + QUIZSET_ID: (quizSetId: number) => `Quizset:${quizSetId}`, ACTIVE_ROOMS: 'ActiveRooms' // 활성화된 방 목록을 저장하는 Set (핀번호 중복 체크하기 위함) }; diff --git a/BE/src/game/game.module.ts b/BE/src/game/game.module.ts index 07c46ab..2849bd8 100644 --- a/BE/src/game/game.module.ts +++ b/BE/src/game/game.module.ts @@ -3,12 +3,20 @@ import { GameGateway } from './game.gateway'; import { GameService } from './service/game.service'; import { RedisModule } from '@nestjs-modules/ioredis'; import { GameValidator } from './validations/game.validator'; -import { HttpModule } from '@nestjs/axios'; import { GameChatService } from './service/game.chat.service'; import { GameRoomService } from './service/game.room.service'; +import { QuizModule } from '../quiz/quiz.module'; +import { QuizCacheService } from './service/quiz.cache.service'; @Module({ - imports: [RedisModule, HttpModule], - providers: [GameGateway, GameService, GameChatService, GameRoomService, GameValidator] + imports: [RedisModule, QuizModule], + providers: [ + GameGateway, + GameService, + GameChatService, + GameRoomService, + GameValidator, + QuizCacheService + ] }) export class GameModule {} diff --git a/BE/src/game/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts index f38d3d2..c26a88d 100644 --- a/BE/src/game/service/game.room.service.ts +++ b/BE/src/game/service/game.room.service.ts @@ -32,8 +32,10 @@ export class GameRoomService { isPublicGame: gameConfig.isPublicGame ? '1' : '0', isWaiting: '1', lastActivityAt: new Date().getTime().toString(), - quizSetId: '0', - quizCount: '2' + quizSetId: '-1', // 미설정시 기본퀴즈를 진행, -1은 기본 퀴즈셋 + quizCount: '2', + quizSetTitle: '기본 퀴즈셋' + //todo : 기본 퀴즈셋 title 설정 }); await this.redis.sadd(REDIS_KEY.ACTIVE_ROOMS, roomId); diff --git a/BE/src/game/service/game.service.ts b/BE/src/game/service/game.service.ts index 06f1de4..2d1c5ae 100644 --- a/BE/src/game/service/game.service.ts +++ b/BE/src/game/service/game.service.ts @@ -7,8 +7,8 @@ import { GameValidator } from '../validations/game.validator'; import SocketEvents from '../../common/constants/socket-events'; import { StartGameDto } from '../dto/start-game.dto'; import { Server } from 'socket.io'; -import { HttpService } from '@nestjs/axios'; import { mockQuizData } from '../../../test/mocks/quiz-data.mock'; +import { QuizCacheService } from './quiz.cache.service'; @Injectable() export class GameService { @@ -17,8 +17,8 @@ export class GameService { constructor( @InjectRedis() private readonly redis: Redis, - private readonly httpService: HttpService, - private readonly gameValidator: GameValidator + private readonly gameValidator: GameValidator, + private readonly quizCacheService: QuizCacheService ) {} async updatePosition(updatePosition: UpdatePositionDto, clientId: string) { @@ -49,14 +49,19 @@ export class GameService { this.gameValidator.validatePlayerIsHost(SocketEvents.START_GAME, room, clientId); - // const getQuizsetURL = `http://localhost:3000/api/quizset/${room.quizSetId}`; - // - // // REFACTOR: get 대신 Promise를 반환하는 axiosRef를 사용했으나 더 나은 방식이 있는지 확인 - // const quiz-set = await this.httpService.axiosRef({ - // url: getQuizsetURL, - // method: 'GET' - // }); - const quizset = mockQuizData; + /** + * 퀴즈셋이 설정되어 있지 않으면 기본 퀴즈셋을 사용 + */ + const quizset = + room.quizSetId === '-1' + ? mockQuizData + : await this.quizCacheService.getQuizSet(+room.quizSetId); + + //roomKey에 해당하는 room에 quizSetTitle을 quizset.title로 설정 + await this.redis.hset(roomKey, { + quizSetTitle: quizset.title + }); + this.gameValidator.validateQuizsetCount( SocketEvents.START_GAME, parseInt(room.quizCount), diff --git a/BE/src/game/service/quiz.cache.service.ts b/BE/src/game/service/quiz.cache.service.ts new file mode 100644 index 0000000..554c442 --- /dev/null +++ b/BE/src/game/service/quiz.cache.service.ts @@ -0,0 +1,79 @@ +import { QuizSetData } from '../../InitDB/InitDB.Service'; +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Redis } from 'ioredis'; +import { QuizService } from '../../quiz/quiz.service'; +import { mockQuizData } from '../../../test/mocks/quiz-data.mock'; +import { REDIS_KEY } from '../../common/constants/redis-key.constant'; + +@Injectable() +export class QuizCacheService { + private readonly quizCache = new Map(); + private readonly logger = new Logger(QuizCacheService.name); + private readonly CACHE_TTL = 1000 * 60 * 30; // 30분 + + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly quizService: QuizService + ) {} + + /** + * Redis 캐시에서 퀴즈셋 조회 + */ + private async getFromRedisCache(quizSetId: number) { + const cacheKey = REDIS_KEY.QUIZSET_ID(quizSetId); + const cachedData = await this.redis.get(cacheKey); + + if (cachedData) { + return JSON.parse(cachedData); + } + return null; + } + + /** + * Redis 캐시에 퀴즈셋 저장 + */ + private async setToRedisCache(quizSetId: number, data: QuizSetData): Promise { + const cacheKey = REDIS_KEY.QUIZSET_ID(quizSetId); + await this.redis.set(cacheKey, JSON.stringify(data), 'EX', this.CACHE_TTL); + } + + /** + * 퀴즈셋 데이터 조회 (캐시 활용) + */ + async getQuizSet(quizSetId: number) { + // 1. 로컬 메모리 캐시 확인 + // const localCached = this.quizCache.get(this.getCacheKey(quizSetId)); + // if (localCached) { + // this.logger.debug(`Quiz ${quizSetId} found in local cache`); + // return localCached; + // } + + // 2. Redis 캐시 확인 + const redisCached = await this.getFromRedisCache(quizSetId); + if (redisCached) { + this.logger.debug(`Quiz ${quizSetId} found in Redis cache`); + // 로컬 캐시에도 저장 + // this.quizCache.set(REDIS_KEY.QUIZSET_ID(quizSetId), redisCached); + return redisCached; + } + + // 3. DB에서 조회 + const quizData = quizSetId === -1 ? mockQuizData : await this.quizService.findOne(quizSetId); + + // 4. 캐시에 저장 + await this.setToRedisCache(quizSetId, quizData); + this.quizCache.set(REDIS_KEY.QUIZSET_ID(quizSetId), quizData); + + return quizData; + } + + /** + * 캐시 무효화 + */ + async invalidateCache(quizSetId: number): Promise { + const cacheKey = REDIS_KEY.QUIZSET_ID(quizSetId); + this.quizCache.delete(cacheKey); + await this.redis.del(cacheKey); + } +} diff --git a/BE/test/integration/game.integration.spec.ts b/BE/test/integration/game.integration.spec.ts index f09b0c3..b1c9865 100644 --- a/BE/test/integration/game.integration.spec.ts +++ b/BE/test/integration/game.integration.spec.ts @@ -1,19 +1,28 @@ -import { Test } from '@nestjs/testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { IoAdapter } from '@nestjs/platform-socket.io'; import { io, Socket } from 'socket.io-client'; -import { GameGateway } from '../../src/game/game.gateway'; -import { GameService } from '../../src/game/service/game.service'; -import socketEvents from '../../src/common/constants/socket-events'; +import { GameGateway } from '../src/game/game.gateway'; +import { GameService } from '../src/game/service/game.service'; +import socketEvents from '../src/common/constants/socket-events'; import { RedisModule } from '@nestjs-modules/ioredis'; import { Redis } from 'ioredis'; -import { GameValidator } from '../../src/game/validations/game.validator'; -import { GameChatService } from '../../src/game/service/game.chat.service'; -import { GameRoomService } from '../../src/game/service/game.room.service'; +import { GameValidator } from '../src/game/validations/game.validator'; +import { GameChatService } from '../src/game/service/game.chat.service'; +import { GameRoomService } from '../src/game/service/game.room.service'; import { HttpService } from '@nestjs/axios'; -import { mockQuizData } from '../mocks/quiz-data.mock'; +import { mockQuizData } from './mocks/quiz-data.mock'; import RedisMock from 'ioredis-mock'; -import { REDIS_KEY } from '../../src/common/constants/redis-key.constant'; +import { REDIS_KEY } from '../src/common/constants/redis-key.constant'; +import { QuizCacheService } from '../src/game/service/quiz.cache.service'; +import { QuizService } from '../src/quiz/quiz.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { QuizSetModel } from '../src/quiz/entities/quiz-set.entity'; +import { QuizModel } from '../src/quiz/entities/quiz.entity'; +import { QuizChoiceModel } from '../src/quiz/entities/quiz-choice.entity'; +import { UserModel } from '../src/user/entities/user.entity'; +import { UserQuizArchiveModel } from '../src/user/entities/user-quiz-archive.entity'; +import { ConfigModule } from '@nestjs/config'; const mockHttpService = { axiosRef: jest.fn().mockImplementation(() => { @@ -30,6 +39,8 @@ describe('GameGateway (e2e)', () => { let client2: Socket; let client3: Socket; let redisMock: Redis; + let moduleRef: TestingModule; // 추가 + // let quizCacheService: QuizCacheService; // 추가 const TEST_PORT = 3001; @@ -48,12 +59,23 @@ describe('GameGateway (e2e)', () => { return result; }; - const moduleRef = await Test.createTestingModule({ + moduleRef = await Test.createTestingModule({ imports: [ - RedisModule.forRoot({ - type: 'single', - url: 'redis://localhost:6379' - }) + ConfigModule.forRoot({ + envFilePath: '../.env', + isGlobal: true + }), + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST_TEST || process.env.DB_HOST || '127.0.0.1', + port: +process.env.DB_PORT_TEST || +process.env.DB_PORT || 3306, + username: process.env.DB_USER_TEST || process.env.DB_USER || 'root', + password: process.env.DB_PASSWD_TEST || process.env.DB_PASSWD || 'test', + database: process.env.DB_NAME_TEST || process.env.DB_NAME || 'test_db', + entities: [QuizSetModel, QuizModel, QuizChoiceModel, UserModel, UserQuizArchiveModel], + synchronize: true // test모드에서는 항상 활성화 + }), + TypeOrmModule.forFeature([QuizSetModel, QuizModel, QuizChoiceModel, UserModel]) ], providers: [ GameGateway, @@ -61,6 +83,8 @@ describe('GameGateway (e2e)', () => { GameChatService, GameRoomService, GameValidator, + QuizCacheService, + QuizService, { provide: 'default_IORedisModuleConnectionToken', useValue: redisMock @@ -119,6 +143,7 @@ describe('GameGateway (e2e)', () => { client3.disconnect(); } await redisMock.flushall(); + jest.clearAllMocks(); }); afterAll(async () => { @@ -375,5 +400,299 @@ describe('GameGateway (e2e)', () => { expect(quizSetIds.length).toBeGreaterThan(0); // FIX: 추후 더 fit하게 바꾸기 expect(leaderboard).toBeDefined(); }); + + it('게임 시작시 quizSetTitle이 올바르게 설정되어야 한다', async () => { + // 1. 방 생성 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { + client1.once(socketEvents.CREATE_ROOM, resolve); + client1.emit(socketEvents.CREATE_ROOM, { + title: 'Test Room', + gameMode: 'RANKING', + maxPlayerCount: 5, + isPublicGame: true + }); + }); + const gameId = createResponse.gameId; + + // 2. 플레이어 입장 + await Promise.all([ + new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: gameId, + playerName: 'Player1' + }); + }) + ]); + + // 3. 게임 시작 전 상태 확인 + const beforeRoom = await redisMock.hgetall(`Room:${gameId}`); + expect(beforeRoom.quizSetTitle).toBe(mockQuizData.title); + + // 4. 게임 시작 + await new Promise((resolve) => { + client1.once(socketEvents.START_GAME, () => resolve()); + client1.emit(socketEvents.START_GAME, { gameId }); + }); + + // 잠시 대기 (Redis 업데이트 대기) + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 5. 게임 시작 후 상태 확인 + const afterRoom = await redisMock.hgetall(`Room:${gameId}`); + expect(afterRoom.quizSetTitle).toBe(mockQuizData.title); + }); + + it('캐시에 없는 퀴즈셋의 경우 DB에서 조회하고 캐시에 저장해야 한다.', async () => { + // 1. 방 생성 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { + client1.once(socketEvents.CREATE_ROOM, resolve); + client1.emit(socketEvents.CREATE_ROOM, { + title: 'Cache Test Room', + gameMode: 'RANKING', + maxPlayerCount: 5, + isPublicGame: true + }); + }); + const gameId = createResponse.gameId; + + // 퀴즈셋 ID 설정 + const testQuizSetId = 1; + await redisMock.hset(`Room:${gameId}`, 'quizSetId', testQuizSetId.toString()); + + // 캐시 키 설정 + const cacheKey = REDIS_KEY.QUIZSET_ID(testQuizSetId); + + // 초기 상태 확인 - 캐시에 데이터 없어야 함 + const initialCache = await redisMock.get(cacheKey); + expect(initialCache).toBeNull(); + + // 플레이어 입장 (호스트 권한을 위해) + await new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: gameId, + playerName: 'Player1' + }); + }); + + //updateRoomQuizSet + await new Promise((resolve) => { + client1.once(socketEvents.UPDATE_ROOM_QUIZSET, () => resolve()); + client1.emit(socketEvents.UPDATE_ROOM_QUIZSET, { + gameId: gameId, + quizSetId: testQuizSetId, + quizCount: 1 + }); + }); + + // 5. QuizService mock 설정 + const mockQuizSet = { + id: String(testQuizSetId), + title: 'Test Quiz Set', + category: 'Test Category', + quizList: [ + { + id: '1', + quiz: 'Test Question 1', + choiceList: [ + { order: 1, content: 'Choice 1', isAnswer: true }, + { order: 2, content: 'Choice 2', isAnswer: false } + ], + limitTime: 30 + } + ] + }; + const quizServiceSpy = jest + .spyOn(moduleRef.get(QuizService), 'findOne') + .mockResolvedValue(mockQuizSet); + + // 6. 게임 시작 + await new Promise((resolve) => { + client1.once(socketEvents.START_GAME, () => resolve()); + client1.emit(socketEvents.START_GAME, { gameId }); + }); + + // Redis 업데이트 대기 + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 7. 캐시 저장 확인 + const cachedData = await redisMock.get(cacheKey); + expect(cachedData).not.toBeNull(); + expect(JSON.parse(cachedData!)).toEqual(mockQuizSet); + + // QuizService.findOne이 호출되었는지 + expect(quizServiceSpy).toHaveBeenCalled(); + }); + + it('캐시에 있는 퀴즈셋의 경우 DB 조회 없이 캐시에서 가져와야 한다', async () => { + // 1. 방 생성 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { + client1.once(socketEvents.CREATE_ROOM, resolve); + client1.emit(socketEvents.CREATE_ROOM, { + title: 'Cache Hit Test Room', + gameMode: 'RANKING', + maxPlayerCount: 5, + isPublicGame: true + }); + }); + const gameId = createResponse.gameId; + + // 2. 퀴즈셋 ID 설정 + const testQuizSetId = 2; + await redisMock.hset(`Room:${gameId}`, 'quizSetId', testQuizSetId.toString()); + + // 플레이어 입장 (호스트 권한을 위해) + await new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: gameId, + playerName: 'Player1' + }); + }); + + //updateRoomQuizSet + await new Promise((resolve) => { + client1.once(socketEvents.UPDATE_ROOM_QUIZSET, () => resolve()); + client1.emit(socketEvents.UPDATE_ROOM_QUIZSET, { + gameId: gameId, + quizSetId: testQuizSetId, + quizCount: 1 + }); + }); + + // 3. 미리 캐시에 데이터 저장 + const cachedQuizSet = { + id: testQuizSetId, + title: 'Cached Quiz Set', + quizList: [ + { + id: 1, + quiz: 'Cached Question', + choiceList: [ + { order: 1, content: 'Cached Choice 1', isAnswer: true }, + { order: 2, content: 'Cached Choice 2', isAnswer: false } + ], + limitTime: 30 + } + ] + }; + await redisMock.set( + REDIS_KEY.QUIZSET_ID(testQuizSetId), + JSON.stringify(cachedQuizSet), + 'EX', + 1800 + ); + + // 4. QuizService mock 설정 (호출되지 않아야 함) + const quizServiceSpy = jest.spyOn(moduleRef.get(QuizService), 'findOne'); + + // 5. 게임 시작 + await new Promise((resolve) => { + client1.once(socketEvents.START_GAME, () => resolve()); + client1.emit(socketEvents.START_GAME, { gameId }); + }); + + // Redis 업데이트 대기 + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 6. 검증 + // QuizService.findOne이 호출되지 않았는지 확인 + expect(quizServiceSpy).not.toHaveBeenCalled(); + + // Room에 설정된 title이 캐시된 데이터의 title과 일치하는지 확인 + const roomData = await redisMock.hgetall(`Room:${gameId}`); + expect(roomData.quizSetTitle).toBe(cachedQuizSet.title); + }); + + it('캐시가 만료되면 DB에서 다시 조회해야 한다', async () => { + // 1. 방 생성 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { + client1.once(socketEvents.CREATE_ROOM, resolve); + client1.emit(socketEvents.CREATE_ROOM, { + title: 'Expiry Test Room', + gameMode: 'RANKING', + maxPlayerCount: 5, + isPublicGame: true + }); + }); + const gameId = createResponse.gameId; + + // 2. 퀴즈셋 ID 설정 + const testQuizSetId = 3; + await redisMock.hset(`Room:${gameId}`, 'quizSetId', testQuizSetId.toString()); + + // 플레이어 입장 (호스트 권한을 위해) + await new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: gameId, + playerName: 'Player1' + }); + }); + + //updateRoomQuizSet + await new Promise((resolve) => { + client1.once(socketEvents.UPDATE_ROOM_QUIZSET, () => resolve()); + client1.emit(socketEvents.UPDATE_ROOM_QUIZSET, { + gameId: gameId, + quizSetId: testQuizSetId, + quizCount: 1 + }); + }); + + // 3. 캐시에 데이터 저장 (1초 후 만료) + const cachedQuizSet = { + id: testQuizSetId, + title: 'Soon to Expire Quiz Set', + quizList: [ + { + id: 1, + quiz: 'Question', + choiceList: [ + { order: 1, content: 'Choice 1', isAnswer: true }, + { order: 2, content: 'Choice 2', isAnswer: false } + ], + limitTime: 30 + } + ] + }; + await redisMock.set(`quizset:${testQuizSetId}`, JSON.stringify(cachedQuizSet), 'EX', 1); + + // 4. DB에서 가져올 새로운 데이터 설정 + const newQuizSet = { + id: '3', + title: 'New Quiz Set', + category: 'Test Category', + quizList: [ + { + id: '1', + quiz: 'Test Question 1', + choiceList: [ + { order: 1, content: 'Choice 1', isAnswer: true }, + { order: 2, content: 'Choice 2', isAnswer: false } + ], + limitTime: 30 + } + ] + }; + jest.spyOn(moduleRef.get(QuizService), 'findOne').mockResolvedValue(newQuizSet); + + // 5. 캐시 만료 대기 + await new Promise((resolve) => setTimeout(resolve, 1100)); + + // 6. 게임 시작 + await new Promise((resolve) => { + client1.once(socketEvents.START_GAME, () => resolve()); + client1.emit(socketEvents.START_GAME, { gameId }); + }); + + // Redis 업데이트 대기 + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 7. 검증 + const roomData = await redisMock.hgetall(`Room:${gameId}`); + expect(roomData.quizSetTitle).toBe(newQuizSet.title); + }); }); }); diff --git a/BE/test/mocks/quiz-data.mock.ts b/BE/test/mocks/quiz-data.mock.ts index f7d1cc7..b213644 100644 --- a/BE/test/mocks/quiz-data.mock.ts +++ b/BE/test/mocks/quiz-data.mock.ts @@ -1,30 +1,30 @@ export const mockQuizData = { id: '1', - title: '재미있는 상식 퀴즈', + title: '기본 퀴즈셋', category: 'common', quizList: [ { id: '1', - quiz: '다음 중 대한민국의 수도는?', + quiz: '호눅스님과 jk 님은 동갑인가요?', limitTime: 30, choiceList: [ { - content: '서울', + content: 'O', order: 1, isAnswer: true }, { - content: '부산', + content: 'X', order: 2, isAnswer: false }, { - content: '인천', + content: '모르겠다.', order: 3, isAnswer: false }, { - content: '대구', + content: '크롱', order: 4, isAnswer: false } @@ -51,7 +51,7 @@ export const mockQuizData = { isAnswer: false }, { - content: '4', + content: '킹받쥬?', order: 4, isAnswer: false }