Skip to content

Commit a173416

Browse files
authored
[Refactor] Redis로 세션/캐싱/상태 관리 (#363)
* refactor: redis 캐시에서 battleState 저장 및 로드 #353 * fix: 배틀 종료 시 캐시에서 배틀 정보 삭제 #353 * refactor: refreshToken redis 세션 관리 및 rtr 적용 #353 * refactor: gemini 다중화 사용량 측정 redis로 전환 #353 * test: oauth 테스트코드 변경된 로직으로 변경 #353 * refactor: redis polling으로 타이머 관리 #353 * chore: console.log 제거 #353
1 parent 0818d84 commit a173416

19 files changed

+665
-416
lines changed

backend/src/battles/adapters/out/state/battleStateRepository.adapter.ts

Lines changed: 224 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'
22
import { Prisma } from 'generated/prisma/client'
33
import { type Battle as PrismaBattle } from 'generated/prisma/client'
44
import { PrismaService } from '../../../../prisma/prisma.service'
5+
import { RedisRepository } from '../../../../redis/redis.repository'
56
import {
67
ActiveBattleState,
78
BattleTeam,
@@ -33,64 +34,63 @@ interface BattleChatSnapshot extends Omit<BattleChat, 'createdAt'> {
3334
createdAt: string
3435
}
3536

37+
interface SerializedBattleState {
38+
battleId: string
39+
all: {
40+
roomId: string
41+
chats: BattleChatSnapshot[]
42+
attacks: (BattleDiscussion | null)[]
43+
defenses: (BattleDefense | null)[]
44+
}
45+
teamA: {
46+
roomId: string
47+
chats: BattleChatSnapshot[]
48+
attacks: (BattleDiscussion | null)[]
49+
defenses: (BattleDefense | null)[]
50+
users: string[]
51+
}
52+
teamB: {
53+
roomId: string
54+
chats: BattleChatSnapshot[]
55+
attacks: (BattleDiscussion | null)[]
56+
defenses: (BattleDefense | null)[]
57+
users: string[]
58+
}
59+
participants: [string, BattleTeam][]
60+
teamVotes: [string, BattleTeam][]
61+
userInfoMap: [string, string][]
62+
opinionHistory: BattleDiscussion[]
63+
skipState: string[]
64+
round: number
65+
topics: string[]
66+
totalRounds: number
67+
phase: BattlePhaseName
68+
phaseCount: number
69+
startedAt: number | null
70+
expiredAt: number | null
71+
}
72+
3673
@Injectable()
3774
export class BattleStateRepositoryAdapter implements BattleStatePort {
38-
constructor(private readonly prisma: PrismaService) {}
75+
private readonly cachePrefix = 'battle:state:'
76+
constructor(
77+
private readonly prisma: PrismaService,
78+
private readonly redis: RedisRepository,
79+
) {}
3980

4081
async loadBattleState(battleId: string): Promise<{ battle: PrismaBattle; state: ActiveBattleState }> {
82+
const cachedState = await this.loadStateFromCache(battleId)
83+
4184
const battle = await this.prisma.battle.findUnique({ where: { id: battleId } })
4285
if (!battle) throw new NotFoundException('배틀이 존재하지 않습니다.')
4386

44-
const participants = this.parseParticipantsState(battle.participantsState)
45-
const teamVotes = this.parseTeamVotesState(battle.teamVotesState)
46-
const userInfoMap = this.parseUserInfoState(battle.userInfoState)
47-
const attackState = this.parseAttackState(battle.attacksState)
48-
const defenseState = this.parseDefenseState(battle.defensesState)
49-
const opinionHistory = this.parseOpinionHistoryState(battle.opinionHistoryState)
50-
const skipState = new Set<string>(Array.isArray(battle.skipState) ? battle.skipState : [])
51-
52-
const playTime = BATTLE_PLAYTIME[battle.playTime as keyof typeof BATTLE_PLAYTIME]
53-
if (!playTime) {
54-
throw new NotFoundException('올바르지 않은 배틀 진행 시간입니다.')
55-
}
56-
57-
const state: ActiveBattleState = {
58-
battleId,
59-
all: {
60-
roomId: this.getBattleRoomId(battleId),
61-
chats: this.parseChatState(battle.chatsAllState),
62-
attacks: attackState.all,
63-
defenses: defenseState.all,
64-
},
65-
teamA: {
66-
roomId: this.getBattleRoomId(battleId, BATTLE_TEAM.A),
67-
users: [],
68-
chats: this.parseChatState(battle.chatsTeamAState),
69-
attacks: attackState.teamA,
70-
defenses: defenseState.teamA,
71-
},
72-
teamB: {
73-
roomId: this.getBattleRoomId(battleId, BATTLE_TEAM.B),
74-
users: [],
75-
chats: this.parseChatState(battle.chatsTeamBState),
76-
attacks: attackState.teamB,
77-
defenses: defenseState.teamB,
78-
},
79-
participants,
80-
teamVotes,
81-
userInfoMap,
82-
opinionHistory,
83-
skipState,
84-
round: battle.currentRound ?? 1,
85-
topics: battle.topics,
86-
totalRounds: playTime.rounds,
87-
phase: (battle.currentPhase ?? BATTLE_PHASE.PENDING.name) as BattlePhaseName,
88-
phaseCount: battle.phaseCount ?? 1,
89-
startedAt: battle.startedAt ? battle.startedAt.getTime() : null,
90-
expiredAt: battle.expiredAt ? battle.expiredAt.getTime() : null,
87+
//캐시에서 가져온 데이터가 있으면 바로 반환
88+
if (cachedState) {
89+
return { battle, state: cachedState }
9190
}
9291

93-
this.rebuildTeamUsers(state)
92+
const state = this.buildStateFromBattle(battle)
93+
await this.persistStateCache(state)
9494
return { battle, state }
9595
}
9696

@@ -124,6 +124,7 @@ export class BattleStateRepositoryAdapter implements BattleStatePort {
124124
updatedAt: new Date(),
125125
},
126126
})
127+
await this.persistStateCache(state)
127128
}
128129

129130
async updateSkipState(battleId: string, skipList: Set<string>): Promise<void> {
@@ -134,6 +135,11 @@ export class BattleStateRepositoryAdapter implements BattleStatePort {
134135
updatedAt: new Date(),
135136
},
136137
})
138+
await this.patchSkipCache(battleId, skipList)
139+
}
140+
141+
async clearCache(battleId: string): Promise<void> {
142+
await this.redis.del(this.getCacheKey(battleId))
137143
}
138144

139145
parseMvpsState(value: unknown): Mvp[] {
@@ -257,6 +263,171 @@ export class BattleStateRepositoryAdapter implements BattleStatePort {
257263
return value.filter(item => item && typeof item === 'object') as BattleDiscussion[]
258264
}
259265

266+
//캐시에서 가져오기
267+
private async loadStateFromCache(battleId: string): Promise<ActiveBattleState | null> {
268+
try {
269+
const payload = await this.redis.get(this.getCacheKey(battleId))
270+
if (!payload) return null
271+
return this.deserializeState(JSON.parse(payload) as SerializedBattleState)
272+
} catch {
273+
return null
274+
}
275+
}
276+
277+
//캐시에 저장하기
278+
private async persistStateCache(state: ActiveBattleState): Promise<void> {
279+
const payload = this.serializeState(state)
280+
await this.redis.set(this.getCacheKey(state.battleId), JSON.stringify(payload))
281+
}
282+
283+
//skipState 업데이트 시 캐시 업데이트
284+
private async patchSkipCache(battleId: string, skipList: Set<string>): Promise<void> {
285+
const cachedState = await this.loadStateFromCache(battleId)
286+
if (!cachedState) return
287+
cachedState.skipState = new Set(skipList)
288+
await this.persistStateCache(cachedState)
289+
}
290+
291+
//캐시에 저장할 데이터 직렬화
292+
private serializeState(state: ActiveBattleState): SerializedBattleState {
293+
return {
294+
battleId: state.battleId,
295+
all: {
296+
roomId: state.all.roomId,
297+
chats: this.serializeChatState(state.all.chats),
298+
attacks: state.all.attacks,
299+
defenses: state.all.defenses,
300+
},
301+
teamA: {
302+
roomId: state.teamA.roomId,
303+
chats: this.serializeChatState(state.teamA.chats),
304+
attacks: state.teamA.attacks,
305+
defenses: state.teamA.defenses,
306+
users: [...state.teamA.users],
307+
},
308+
teamB: {
309+
roomId: state.teamB.roomId,
310+
chats: this.serializeChatState(state.teamB.chats),
311+
attacks: state.teamB.attacks,
312+
defenses: state.teamB.defenses,
313+
users: [...state.teamB.users],
314+
},
315+
participants: [...state.participants.entries()],
316+
teamVotes: [...state.teamVotes.entries()],
317+
userInfoMap: [...state.userInfoMap.entries()],
318+
opinionHistory: state.opinionHistory,
319+
skipState: Array.from(state.skipState),
320+
round: state.round,
321+
topics: state.topics,
322+
totalRounds: state.totalRounds,
323+
phase: state.phase,
324+
phaseCount: state.phaseCount,
325+
startedAt: state.startedAt,
326+
expiredAt: state.expiredAt,
327+
}
328+
}
329+
330+
//캐시에서 가져온 데이터 역직렬화
331+
private deserializeState(payload: SerializedBattleState): ActiveBattleState {
332+
const participants = new Map<string, BattleTeam>(payload.participants ?? [])
333+
const teamVotes = new Map<string, BattleTeam>(payload.teamVotes ?? [])
334+
const userInfoMap = new Map<string, string>(payload.userInfoMap ?? [])
335+
336+
const state: ActiveBattleState = {
337+
battleId: payload.battleId,
338+
all: {
339+
roomId: payload.all.roomId,
340+
chats: this.parseChatState(payload.all.chats),
341+
attacks: payload.all.attacks ?? [],
342+
defenses: payload.all.defenses ?? [],
343+
},
344+
teamA: {
345+
roomId: payload.teamA.roomId,
346+
chats: this.parseChatState(payload.teamA.chats),
347+
attacks: payload.teamA.attacks ?? [],
348+
defenses: payload.teamA.defenses ?? [],
349+
users: payload.teamA.users ?? [],
350+
},
351+
teamB: {
352+
roomId: payload.teamB.roomId,
353+
chats: this.parseChatState(payload.teamB.chats),
354+
attacks: payload.teamB.attacks ?? [],
355+
defenses: payload.teamB.defenses ?? [],
356+
users: payload.teamB.users ?? [],
357+
},
358+
participants,
359+
teamVotes,
360+
userInfoMap,
361+
opinionHistory: payload.opinionHistory ?? [],
362+
skipState: new Set(payload.skipState ?? []),
363+
round: payload.round,
364+
topics: payload.topics,
365+
totalRounds: payload.totalRounds,
366+
phase: payload.phase,
367+
phaseCount: payload.phaseCount,
368+
startedAt: payload.startedAt,
369+
expiredAt: payload.expiredAt,
370+
}
371+
372+
this.rebuildTeamUsers(state)
373+
return state
374+
}
375+
376+
//데이터베이스에서 가져온 데이터 빌드
377+
private buildStateFromBattle(battle: PrismaBattle): ActiveBattleState {
378+
const participants = this.parseParticipantsState(battle.participantsState)
379+
const teamVotes = this.parseTeamVotesState(battle.teamVotesState)
380+
const userInfoMap = this.parseUserInfoState(battle.userInfoState)
381+
const attackState = this.parseAttackState(battle.attacksState)
382+
const defenseState = this.parseDefenseState(battle.defensesState)
383+
const opinionHistory = this.parseOpinionHistoryState(battle.opinionHistoryState)
384+
const skipState = new Set<string>(Array.isArray(battle.skipState) ? battle.skipState : [])
385+
386+
const playTime = BATTLE_PLAYTIME[battle.playTime as keyof typeof BATTLE_PLAYTIME]
387+
if (!playTime) {
388+
throw new NotFoundException('올바르지 않은 배틀 진행 시간입니다.')
389+
}
390+
391+
const state: ActiveBattleState = {
392+
battleId: battle.id,
393+
all: {
394+
roomId: this.getBattleRoomId(battle.id),
395+
chats: this.parseChatState(battle.chatsAllState),
396+
attacks: attackState.all,
397+
defenses: defenseState.all,
398+
},
399+
teamA: {
400+
roomId: this.getBattleRoomId(battle.id, BATTLE_TEAM.A),
401+
users: [],
402+
chats: this.parseChatState(battle.chatsTeamAState),
403+
attacks: attackState.teamA,
404+
defenses: defenseState.teamA,
405+
},
406+
teamB: {
407+
roomId: this.getBattleRoomId(battle.id, BATTLE_TEAM.B),
408+
users: [],
409+
chats: this.parseChatState(battle.chatsTeamBState),
410+
attacks: attackState.teamB,
411+
defenses: defenseState.teamB,
412+
},
413+
participants,
414+
teamVotes,
415+
userInfoMap,
416+
opinionHistory,
417+
skipState,
418+
round: battle.currentRound ?? 1,
419+
topics: battle.topics,
420+
totalRounds: playTime.rounds,
421+
phase: (battle.currentPhase ?? BATTLE_PHASE.PENDING.name) as BattlePhaseName,
422+
phaseCount: battle.phaseCount ?? 1,
423+
startedAt: battle.startedAt ? battle.startedAt.getTime() : null,
424+
expiredAt: battle.expiredAt ? battle.expiredAt.getTime() : null,
425+
}
426+
427+
this.rebuildTeamUsers(state)
428+
return state
429+
}
430+
260431
private rebuildTeamUsers(state: ActiveBattleState): void {
261432
state.teamA.users = []
262433
state.teamB.users = []
@@ -267,6 +438,10 @@ export class BattleStateRepositoryAdapter implements BattleStatePort {
267438
}
268439
}
269440

441+
private getCacheKey(battleId: string): string {
442+
return `${this.cachePrefix}${battleId}`
443+
}
444+
270445
private getBattleRoomId(battleId: string, team?: BattleTeam): string {
271446
return team ? `battle:${battleId}:${team}` : `battle:${battleId}`
272447
}
Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,44 @@
11
import { Injectable } from '@nestjs/common'
22
import { ActiveBattleState } from '../../../domains/models/types/battle.types'
33
import { BattleTimerPort } from '../../../application/ports/out/battleTimer.port'
4+
import { RedisRepository } from '../../../../redis/redis.repository'
45

56
@Injectable()
67
export class BattleTimerAdapter implements BattleTimerPort {
7-
private battleTimers: Map<string, NodeJS.Timeout> = new Map()
8+
private readonly TIMERS_KEY = 'battle:timers'
9+
10+
constructor(private readonly redisRepository: RedisRepository) {}
811

912
//배틀 타이머 스케줄러
10-
schedule(battleId: string, state: ActiveBattleState, updatePhase: (battleId: string) => Promise<void>): void {
13+
schedule(battleId: string, state: ActiveBattleState): void {
1114
if (!state.expiredAt) return
1215

13-
const prevTimer = this.battleTimers.get(battleId)
14-
if (prevTimer) clearTimeout(prevTimer)
16+
// Redis Sorted Set에 저장
17+
void this.redisRepository.zadd(this.TIMERS_KEY, state.expiredAt, battleId)
18+
}
1519

16-
const remaining = Math.max(state.expiredAt - Date.now(), 0)
20+
//배틀 타이머 취소
21+
cancel(battleId: string): void {
22+
void this.redisRepository.zrem(this.TIMERS_KEY, battleId)
23+
}
1724

18-
const battleTimer = setTimeout(() => {
19-
void updatePhase(battleId)
20-
}, remaining)
25+
//만료된 배틀 ID 목록 조회
26+
async getExpiredBattles(): Promise<string[]> {
27+
const now = Date.now()
2128

22-
this.battleTimers.set(battleId, battleTimer)
29+
// 현재 시간보다 작은 score를 가진 모든 member 조회
30+
return await this.redisRepository.zrangebyscore(this.TIMERS_KEY, '-inf', now)
2331
}
2432

25-
//배틀 타이머 취소
26-
cancel(battleId: string): void {
27-
const battleTimer = this.battleTimers.get(battleId)
28-
if (battleTimer) {
29-
clearTimeout(battleTimer)
30-
this.battleTimers.delete(battleId)
31-
}
33+
//만료된 배틀 제거
34+
async removeExpiredBattles(battleIds: string[]): Promise<void> {
35+
if (battleIds.length === 0) return
36+
37+
await this.redisRepository.zrem(this.TIMERS_KEY, ...battleIds)
3238
}
3339

3440
//모든 배틀 타이머 취소 (테스트용)
3541
clear(): void {
36-
this.battleTimers.forEach(timer => clearTimeout(timer))
37-
this.battleTimers.clear()
42+
void this.redisRepository.del(this.TIMERS_KEY)
3843
}
3944
}

backend/src/battles/application/ports/out/battleState.port.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export interface BattleStatePort {
77
loadBattleState(battleId: string): Promise<{ battle: PrismaBattle; state: ActiveBattleState }>
88
//배틀 상태 저장
99
saveBattleState(battleId: string, state: ActiveBattleState): Promise<void>
10+
//캐시 삭제
11+
clearCache(battleId: string): Promise<void>
1012
//페이즈 스킵 상태 업데이트
1113
updateSkipState(battleId: string, skipList: Set<string>): Promise<void>
1214
//MVP 상태 파싱

0 commit comments

Comments
 (0)