From bb03958d0a813e30177621abb444dfd8e37bfbe0 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Mon, 18 Nov 2024 18:19:51 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20[BE]=20=EC=8B=A4=EC=A0=9C=ED=80=B4?= =?UTF-8?q?=EC=A6=88=EC=85=8B=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미설정시 기본값퀴즈로 진행 --- BE/src/game/game.module.ts | 4 ++-- BE/src/game/service/game.room.service.ts | 2 +- BE/src/game/service/game.service.ts | 19 ++++++++----------- BE/test/mocks/quiz-data.mock.ts | 12 ++++++------ 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/BE/src/game/game.module.ts b/BE/src/game/game.module.ts index 07c46ab..7bed98c 100644 --- a/BE/src/game/game.module.ts +++ b/BE/src/game/game.module.ts @@ -3,12 +3,12 @@ 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'; @Module({ - imports: [RedisModule, HttpModule], + imports: [RedisModule, QuizModule], providers: [GameGateway, GameService, GameChatService, GameRoomService, GameValidator] }) 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..b7f775a 100644 --- a/BE/src/game/service/game.room.service.ts +++ b/BE/src/game/service/game.room.service.ts @@ -32,7 +32,7 @@ export class GameRoomService { isPublicGame: gameConfig.isPublicGame ? '1' : '0', isWaiting: '1', lastActivityAt: new Date().getTime().toString(), - quizSetId: '0', + quizSetId: '-1', // 미설정시 기본퀴즈를 진행, -1은 기본 퀴즈셋 quizCount: '2' }); diff --git a/BE/src/game/service/game.service.ts b/BE/src/game/service/game.service.ts index d53c980..9229efa 100644 --- a/BE/src/game/service/game.service.ts +++ b/BE/src/game/service/game.service.ts @@ -7,7 +7,7 @@ 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 { QuizService } from '../../quiz/quiz.service'; import { mockQuizData } from '../../../test/mocks/quiz-data.mock'; @Injectable() @@ -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 quizService: QuizService ) {} async updatePosition(updatePosition: UpdatePositionDto, clientId: string) { @@ -49,14 +49,11 @@ 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 quizset = await this.httpService.axiosRef({ - // url: getQuizsetURL, - // method: 'GET' - // }); - const quizset = mockQuizData; + /** + * 퀴즈셋이 설정되어 있지 않으면 기본 퀴즈셋을 사용 + */ + const quizset = + room.quizSetId === '-1' ? mockQuizData : await this.quizService.findOne(+room.quizSetId); this.gameValidator.validateQuizsetCount( SocketEvents.START_GAME, parseInt(room.quizCount), diff --git a/BE/test/mocks/quiz-data.mock.ts b/BE/test/mocks/quiz-data.mock.ts index f7d1cc7..6748634 100644 --- a/BE/test/mocks/quiz-data.mock.ts +++ b/BE/test/mocks/quiz-data.mock.ts @@ -5,26 +5,26 @@ export const mockQuizData = { 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 } From 6adce768954265593b68f8214202dea407622980 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Mon, 18 Nov 2024 22:10:09 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20[BE]=20startGame=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=EC=97=90=20redis-room=EC=97=90=20=EC=A0=9C=EB=AA=A9?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api를 조회하는 시점에 퀴즈셋 제목 추가 --- BE/src/game/service/game.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/BE/src/game/service/game.service.ts b/BE/src/game/service/game.service.ts index 9229efa..916df21 100644 --- a/BE/src/game/service/game.service.ts +++ b/BE/src/game/service/game.service.ts @@ -54,6 +54,12 @@ export class GameService { */ const quizset = room.quizSetId === '-1' ? mockQuizData : await this.quizService.findOne(+room.quizSetId); + + //roomKey에 해당하는 room에 quizSetTitle을 quizset.title로 설정 + await this.redis.hset(roomKey, { + quizSetTitle: quizset.title + }); + this.gameValidator.validateQuizsetCount( SocketEvents.START_GAME, parseInt(room.quizCount), From fec3f3c43874111bd6ca4b4b87d35b98ec02f51f Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Mon, 18 Nov 2024 23:13:43 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20[BE]=20redis=EC=97=90=20=ED=80=B4?= =?UTF-8?q?=EC=A6=88=EC=85=8B=EC=9D=B4=EC=9E=88=EB=8A=94=EA=B2=BD=EC=9A=B0?= =?UTF-8?q?,=20cache=20=EC=9D=B4=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db접근 최소화 --- BE/src/game/game.module.ts | 10 ++- BE/src/game/service/game.service.ts | 8 ++- BE/src/game/service/quiz.cache.service.ts | 85 +++++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 BE/src/game/service/quiz.cache.service.ts diff --git a/BE/src/game/game.module.ts b/BE/src/game/game.module.ts index 7bed98c..2849bd8 100644 --- a/BE/src/game/game.module.ts +++ b/BE/src/game/game.module.ts @@ -6,9 +6,17 @@ import { GameValidator } from './validations/game.validator'; 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, QuizModule], - providers: [GameGateway, GameService, GameChatService, GameRoomService, GameValidator] + providers: [ + GameGateway, + GameService, + GameChatService, + GameRoomService, + GameValidator, + QuizCacheService + ] }) export class GameModule {} diff --git a/BE/src/game/service/game.service.ts b/BE/src/game/service/game.service.ts index 916df21..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 { QuizService } from '../../quiz/quiz.service'; import { mockQuizData } from '../../../test/mocks/quiz-data.mock'; +import { QuizCacheService } from './quiz.cache.service'; @Injectable() export class GameService { @@ -18,7 +18,7 @@ export class GameService { constructor( @InjectRedis() private readonly redis: Redis, private readonly gameValidator: GameValidator, - private readonly quizService: QuizService + private readonly quizCacheService: QuizCacheService ) {} async updatePosition(updatePosition: UpdatePositionDto, clientId: string) { @@ -53,7 +53,9 @@ export class GameService { * 퀴즈셋이 설정되어 있지 않으면 기본 퀴즈셋을 사용 */ const quizset = - room.quizSetId === '-1' ? mockQuizData : await this.quizService.findOne(+room.quizSetId); + room.quizSetId === '-1' + ? mockQuizData + : await this.quizCacheService.getQuizSet(+room.quizSetId); //roomKey에 해당하는 room에 quizSetTitle을 quizset.title로 설정 await this.redis.hset(roomKey, { 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..8a30317 --- /dev/null +++ b/BE/src/game/service/quiz.cache.service.ts @@ -0,0 +1,85 @@ +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'; + +@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 + ) {} + + /** + * 캐시키 생성 + */ + private getCacheKey(quizSetId: number): string { + return `quizset:${quizSetId}`; + } + + /** + * Redis 캐시에서 퀴즈셋 조회 + */ + private async getFromRedisCache(quizSetId: number) { + const cacheKey = this.getCacheKey(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 = this.getCacheKey(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(this.getCacheKey(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(this.getCacheKey(quizSetId), quizData); + + return quizData; + } + + /** + * 캐시 무효화 + */ + async invalidateCache(quizSetId: number): Promise { + const cacheKey = this.getCacheKey(quizSetId); + this.quizCache.delete(cacheKey); + await this.redis.del(cacheKey); + } +} From 58ae02919767d737deaebaeb298a8db552e87df3 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Mon, 18 Nov 2024 23:14:07 +0900 Subject: [PATCH 4/7] =?UTF-8?q?test:=20[BE]=20test=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gameService의 의존성이 늘어남에따라, test code에도 의존성 주입 --- BE/test/game.e2e-spec.ts | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/BE/test/game.e2e-spec.ts b/BE/test/game.e2e-spec.ts index bf68e1a..82dfb8d 100644 --- a/BE/test/game.e2e-spec.ts +++ b/BE/test/game.e2e-spec.ts @@ -5,7 +5,6 @@ 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 { 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'; @@ -14,6 +13,15 @@ import { HttpService } from '@nestjs/axios'; import { mockQuizData } from './mocks/quiz-data.mock'; import RedisMock from 'ioredis-mock'; 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(() => { @@ -50,10 +58,25 @@ describe('GameGateway (e2e)', () => { const moduleRef = await Test.createTestingModule({ imports: [ - RedisModule.forRoot({ - type: 'single', - url: 'redis://localhost:6379' - }) + // 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 +84,8 @@ describe('GameGateway (e2e)', () => { GameChatService, GameRoomService, GameValidator, + QuizCacheService, + QuizService, { provide: 'default_IORedisModuleConnectionToken', useValue: redisMock From b2d1c78490658abfab1584ef2398e95e5dcbb143 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Tue, 19 Nov 2024 00:06:44 +0900 Subject: [PATCH 5/7] test: [BE] startGameTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 퀴즈셋타이틀추가, 캐시-db조회 --- BE/test/game.e2e-spec.ts | 303 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 297 insertions(+), 6 deletions(-) diff --git a/BE/test/game.e2e-spec.ts b/BE/test/game.e2e-spec.ts index 82dfb8d..fe70a01 100644 --- a/BE/test/game.e2e-spec.ts +++ b/BE/test/game.e2e-spec.ts @@ -1,4 +1,4 @@ -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'; @@ -38,6 +38,8 @@ describe('GameGateway (e2e)', () => { let client2: Socket; let client3: Socket; let redisMock: Redis; + let moduleRef: TestingModule; // 추가 + // let quizCacheService: QuizCacheService; // 추가 const TEST_PORT = 3001; @@ -56,12 +58,8 @@ 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 @@ -100,6 +98,9 @@ describe('GameGateway (e2e)', () => { app = moduleRef.createNestApplication(); app.useWebSocketAdapter(new IoAdapter(app)); await app.listen(TEST_PORT); + + // QuizCacheService 초기화 + // quizCacheService = moduleRef.get(QuizCacheService); }); beforeEach(async () => { @@ -144,6 +145,7 @@ describe('GameGateway (e2e)', () => { client3.disconnect(); } await redisMock.flushall(); + jest.clearAllMocks(); }); afterAll(async () => { @@ -400,5 +402,294 @@ 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).toBeUndefined(); + + // 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 = `quizset:${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(`quizset:${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); + }); }); }); From ddcdca6a42e97bc141615e96702fdbf7ad9736e7 Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Tue, 19 Nov 2024 10:55:06 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20[BE]=20redis=20key=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=EB=8C=80=EC=8B=A0=20=EC=83=81=EC=88=98?= =?UTF-8?q?=EA=B0=92=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/common/constants/redis-key.constant.ts | 1 + BE/src/game/service/game.room.service.ts | 1 + BE/src/game/service/quiz.cache.service.ts | 18 ++++++------------ BE/test/game.e2e-spec.ts | 9 +++++++-- 4 files changed, 15 insertions(+), 14 deletions(-) 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/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts index b7f775a..2ab68cf 100644 --- a/BE/src/game/service/game.room.service.ts +++ b/BE/src/game/service/game.room.service.ts @@ -34,6 +34,7 @@ export class GameRoomService { lastActivityAt: new Date().getTime().toString(), quizSetId: '-1', // 미설정시 기본퀴즈를 진행, -1은 기본 퀴즈셋 quizCount: '2' + //todo : 기본 퀴즈셋 title 설정 }); await this.redis.sadd(REDIS_KEY.ACTIVE_ROOMS, roomId); diff --git a/BE/src/game/service/quiz.cache.service.ts b/BE/src/game/service/quiz.cache.service.ts index 8a30317..0ea1840 100644 --- a/BE/src/game/service/quiz.cache.service.ts +++ b/BE/src/game/service/quiz.cache.service.ts @@ -4,6 +4,7 @@ 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 { @@ -16,18 +17,11 @@ export class QuizCacheService { private readonly quizService: QuizService ) {} - /** - * 캐시키 생성 - */ - private getCacheKey(quizSetId: number): string { - return `quizset:${quizSetId}`; - } - /** * Redis 캐시에서 퀴즈셋 조회 */ private async getFromRedisCache(quizSetId: number) { - const cacheKey = this.getCacheKey(quizSetId); + const cacheKey = REDIS_KEY.QUIZSET_ID(quizSetId); const cachedData = await this.redis.get(cacheKey); if (cachedData) { @@ -40,7 +34,7 @@ export class QuizCacheService { * Redis 캐시에 퀴즈셋 저장 */ private async setToRedisCache(quizSetId: number, data: QuizSetData): Promise { - const cacheKey = this.getCacheKey(quizSetId); + const cacheKey = REDIS_KEY.QUIZSET_ID(quizSetId); await this.redis.set(cacheKey, JSON.stringify(data), 'EX', this.CACHE_TTL); } @@ -60,7 +54,7 @@ export class QuizCacheService { if (redisCached) { this.logger.debug(`Quiz ${quizSetId} found in Redis cache`); // 로컬 캐시에도 저장 - this.quizCache.set(this.getCacheKey(quizSetId), redisCached); + this.quizCache.set(REDIS_KEY.QUIZSET_ID(quizSetId), redisCached); return redisCached; } @@ -69,7 +63,7 @@ export class QuizCacheService { // 4. 캐시에 저장 await this.setToRedisCache(quizSetId, quizData); - this.quizCache.set(this.getCacheKey(quizSetId), quizData); + this.quizCache.set(REDIS_KEY.QUIZSET_ID(quizSetId), quizData); return quizData; } @@ -78,7 +72,7 @@ export class QuizCacheService { * 캐시 무효화 */ async invalidateCache(quizSetId: number): Promise { - const cacheKey = this.getCacheKey(quizSetId); + const cacheKey = REDIS_KEY.QUIZSET_ID(quizSetId); this.quizCache.delete(cacheKey); await this.redis.del(cacheKey); } diff --git a/BE/test/game.e2e-spec.ts b/BE/test/game.e2e-spec.ts index fe70a01..41acefc 100644 --- a/BE/test/game.e2e-spec.ts +++ b/BE/test/game.e2e-spec.ts @@ -463,7 +463,7 @@ describe('GameGateway (e2e)', () => { await redisMock.hset(`Room:${gameId}`, 'quizSetId', testQuizSetId.toString()); // 캐시 키 설정 - const cacheKey = `quizset:${testQuizSetId}`; + const cacheKey = REDIS_KEY.QUIZSET_ID(testQuizSetId); // 초기 상태 확인 - 캐시에 데이터 없어야 함 const initialCache = await redisMock.get(cacheKey); @@ -579,7 +579,12 @@ describe('GameGateway (e2e)', () => { } ] }; - await redisMock.set(`quizset:${testQuizSetId}`, JSON.stringify(cachedQuizSet), 'EX', 1800); + await redisMock.set( + REDIS_KEY.QUIZSET_ID(testQuizSetId), + JSON.stringify(cachedQuizSet), + 'EX', + 1800 + ); // 4. QuizService mock 설정 (호출되지 않아야 함) const quizServiceSpy = jest.spyOn(moduleRef.get(QuizService), 'findOne'); From 30fc87252989be53012ba6687d49e0234b1894ca Mon Sep 17 00:00:00 2001 From: DongHoonYu96 Date: Tue, 19 Nov 2024 13:34:05 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20[BE]=20=EA=B8=B0=EB=B3=B8=ED=80=B4?= =?UTF-8?q?=EC=A6=88=EC=85=8B=20Title=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본퀴즈셋으로 was의 quiz-data.mock을 사용함 --- BE/src/game/service/game.room.service.ts | 3 ++- BE/src/game/service/quiz.cache.service.ts | 2 +- BE/test/game.e2e-spec.ts | 2 +- BE/test/mocks/quiz-data.mock.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/BE/src/game/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts index 2ab68cf..c26a88d 100644 --- a/BE/src/game/service/game.room.service.ts +++ b/BE/src/game/service/game.room.service.ts @@ -33,7 +33,8 @@ export class GameRoomService { isWaiting: '1', lastActivityAt: new Date().getTime().toString(), quizSetId: '-1', // 미설정시 기본퀴즈를 진행, -1은 기본 퀴즈셋 - quizCount: '2' + quizCount: '2', + quizSetTitle: '기본 퀴즈셋' //todo : 기본 퀴즈셋 title 설정 }); diff --git a/BE/src/game/service/quiz.cache.service.ts b/BE/src/game/service/quiz.cache.service.ts index 0ea1840..554c442 100644 --- a/BE/src/game/service/quiz.cache.service.ts +++ b/BE/src/game/service/quiz.cache.service.ts @@ -54,7 +54,7 @@ export class QuizCacheService { if (redisCached) { this.logger.debug(`Quiz ${quizSetId} found in Redis cache`); // 로컬 캐시에도 저장 - this.quizCache.set(REDIS_KEY.QUIZSET_ID(quizSetId), redisCached); + // this.quizCache.set(REDIS_KEY.QUIZSET_ID(quizSetId), redisCached); return redisCached; } diff --git a/BE/test/game.e2e-spec.ts b/BE/test/game.e2e-spec.ts index 41acefc..4ab0c3f 100644 --- a/BE/test/game.e2e-spec.ts +++ b/BE/test/game.e2e-spec.ts @@ -429,7 +429,7 @@ describe('GameGateway (e2e)', () => { // 3. 게임 시작 전 상태 확인 const beforeRoom = await redisMock.hgetall(`Room:${gameId}`); - expect(beforeRoom.quizSetTitle).toBeUndefined(); + expect(beforeRoom.quizSetTitle).toBe(mockQuizData.title); // 4. 게임 시작 await new Promise((resolve) => { diff --git a/BE/test/mocks/quiz-data.mock.ts b/BE/test/mocks/quiz-data.mock.ts index 6748634..b213644 100644 --- a/BE/test/mocks/quiz-data.mock.ts +++ b/BE/test/mocks/quiz-data.mock.ts @@ -1,6 +1,6 @@ export const mockQuizData = { id: '1', - title: '재미있는 상식 퀴즈', + title: '기본 퀴즈셋', category: 'common', quizList: [ {