Skip to content

Commit

Permalink
Merge pull request #339 from boostcampwm-2024/dev-be
Browse files Browse the repository at this point in the history
Dev be to release
  • Loading branch information
DongHoonYu96 authored Dec 25, 2024
2 parents e217f38 + fd0d774 commit db614e6
Show file tree
Hide file tree
Showing 11 changed files with 543 additions and 58 deletions.
2 changes: 1 addition & 1 deletion BE/src/InitDB/QUIZ_SET_TEST_DATA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export const QUIZ_SET_TEST_DATA = [
},
{
title: 'μ˜μ–΄ 문법 ν…ŒμŠ€νŠΈ',
category: 'ENGLISH',
category: 'LANGUAGE',
quizList: [
{
quiz: 'λ‹€μŒ 쀑 ν˜„μž¬μ™„λ£Œ μ‹œμ œλŠ”?',
Expand Down
29 changes: 9 additions & 20 deletions BE/src/game/redis/subscribers/player.subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { Namespace } from 'socket.io';
import SocketEvents from '../../../common/constants/socket-events';
import { REDIS_KEY } from '../../../common/constants/redis-key.constant';
import { SurvivalStatus } from '../../../common/constants/game';
import { createBatchProcessor } from '../../service/BatchProcessor';
import { MetricService } from '../../../metric/metric.service';

@Injectable()
export class PlayerSubscriber extends RedisSubscriber {
private positionUpdates: Map<string, any> = new Map(); // Map<gameId, {playerId, playerPosition}[]>
private positionUpdatesForDead: Map<string, any> = new Map(); // Map<gameId, {playerId, playerPosition}[]>
private positionProcessor: ReturnType<typeof createBatchProcessor>;
private positionUpdatesMetrics: Map<string, any> = new Map(); // Map<gameId, {startedAt}[]>

constructor(
Expand All @@ -22,16 +22,20 @@ export class PlayerSubscriber extends RedisSubscriber {
}

async subscribe(server: Namespace): Promise<void> {
// Create batch processor for position updates
this.positionProcessor = createBatchProcessor(server, SocketEvents.UPDATE_POSITION);
this.positionProcessor.startProcessing(100); // Start processing every 100ms

const subscriber = this.redis.duplicate();
await subscriber.psubscribe('__keyspace@0__:Player:*');

subscriber.on('pmessage', async (_pattern, channel, message) => {
const startedAt = process.hrtime();

const playerId = this.extractPlayerId(channel);
if (!playerId || message !== 'hset') {
return;
}

const startedAt = process.hrtime();
const playerKey = REDIS_KEY.PLAYER(playerId);
const gameId = await this.redis.hget(playerKey, 'gameId');
const changes = await this.redis.get(`${playerKey}:Changes`);
Expand All @@ -43,16 +47,7 @@ export class PlayerSubscriber extends RedisSubscriber {

await this.handlePlayerChanges(changes, playerId, server);
});

setInterval(() => {
// 배치 처리
this.positionUpdates.forEach((queue, gameId) => {
if (queue.length > 0) {
const batch = queue.splice(0, queue.length); //O(N)
server.to(gameId).emit(SocketEvents.UPDATE_POSITION, batch);
}
});

// 배치 μ²˜λ¦¬μ— κ΄€ν•œ λ©”νŠΈλ¦­ μΈ‘μ •
this.positionUpdatesMetrics.forEach((metrics) => {
if (metrics.length > 0) {
Expand Down Expand Up @@ -129,13 +124,7 @@ export class PlayerSubscriber extends RedisSubscriber {
const isAlivePlayer = await this.redis.hget(REDIS_KEY.PLAYER(playerId), 'isAlive');

if (isAlivePlayer === SurvivalStatus.ALIVE) {
// 1. Map에 배열을 λ§Œλ“€κ³  set
if (!this.positionUpdates.has(gameId)) {
this.positionUpdates.set(gameId, []); // 빈 λ°°μ—΄λ‘œ μ΄ˆκΈ°ν™”
}
this.positionUpdates.get(gameId).push(updateData);

// server.to(gameId).emit(SocketEvents.UPDATE_POSITION, updateData);
this.positionProcessor.pushData(gameId, updateData);
} else if (isAlivePlayer === SurvivalStatus.DEAD) {
const players = await this.redis.smembers(REDIS_KEY.ROOM_PLAYERS(gameId));
const pipeline = this.redis.pipeline();
Expand Down
77 changes: 77 additions & 0 deletions BE/src/game/service/BatchProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @fileoverview Functional batch processor factory
*/
import { Namespace } from 'socket.io';
import { Logger } from '@nestjs/common';

interface BatchData {
gameId: string;
data: any;
}

/**
* Creates a batch processor instance
*/
export function createBatchProcessor(server: Namespace, eventName: string) {
const logger = new Logger(`BatchProcessor:${eventName}`);
// 이 λ³€μˆ˜λ“€μ€ ν΄λ‘œμ €μ— μ˜ν•΄ 보호됨
const batchMap = new Map<string, any[]>();
let isProcessing = false;

/**
* Pushes data to batch queue
*/
const pushData = (gameId: string, data: any) => {
if (!batchMap.has(gameId)) {
batchMap.set(gameId, []);
}
batchMap.get(gameId).push(data);
};

/**
* Processes and emits all batched data
*/
const processBatch = () => {
if (isProcessing) {
return;
}

isProcessing = true;
try {
batchMap.forEach((queue, gameId) => {
if (queue.length > 0) {
const batch = queue.splice(0, queue.length);
server.to(gameId).emit(eventName, batch);
logger.debug(`Processed ${batch.length} items for game ${gameId}`);
}
});
} catch (error) {
logger.error(`Error processing batch: ${error.message}`);
} finally {
isProcessing = false;
}
};

/**
* Starts automatic batch processing
*/
const startProcessing = (interval: number = 100) => {
setInterval(processBatch, interval);
logger.verbose(`Started batch processor for ${eventName} with ${interval}ms interval`);
};

/**
* Clears all batched data
*/
const clear = () => {
batchMap.clear();
};

// μ™ΈλΆ€μ—μ„œ μ‚¬μš©ν•  수 μžˆλŠ” public λ©”μ„œλ“œλ§Œ λ…ΈμΆœ
return {
pushData,
processBatch,
startProcessing,
clear
};
}
9 changes: 7 additions & 2 deletions BE/src/game/service/game.chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ import { Namespace } from 'socket.io';
import { TraceClass } from '../../common/interceptor/SocketEventLoggerInterceptor';
import { SurvivalStatus } from '../../common/constants/game';
import { MetricService } from '../../metric/metric.service';
import { createBatchProcessor } from './BatchProcessor';

@TraceClass()
@Injectable()
export class GameChatService {
private readonly logger = new Logger(GameChatService.name);
private chatProcessor: ReturnType<typeof createBatchProcessor>;

constructor(
@InjectRedis() private readonly redis: Redis,
private readonly gameValidator: GameValidator,
private metricService: MetricService,
private metricService: MetricService
) {}

async chatMessage(chatMessage: ChatMessageDto, clientId: string) {
Expand Down Expand Up @@ -49,6 +51,9 @@ export class GameChatService {
}

async subscribeChatEvent(server: Namespace) {
this.chatProcessor = createBatchProcessor(server, SocketEvents.CHAT_MESSAGE);
this.chatProcessor.startProcessing(50); // μ±„νŒ…μ€ 더 λΉ λ₯Έ μ—…λ°μ΄νŠΈκ°€ ν•„μš”ν•  수 μžˆμ–΄μ„œ 50ms

const chatSubscriber = this.redis.duplicate();
chatSubscriber.psubscribe('chat:*');

Expand All @@ -63,7 +68,7 @@ export class GameChatService {

// μƒμ‘΄ν•œ μ‚¬λžŒμ΄λΌλ©΄ 전체 λΈŒλ‘œλ“œμΊμŠ€νŒ…
if (isAlivePlayer === SurvivalStatus.ALIVE) {
server.to(gameId).emit(SocketEvents.CHAT_MESSAGE, chatMessage);
this.chatProcessor.pushData(gameId, chatMessage);
} else {
// 죽은 μ‚¬λžŒμ˜ μ±„νŒ…μ€ 죽은 μ‚¬λžŒλΌλ¦¬λ§Œ λ³Ό 수 μžˆλ„λ‘ 처리
const players = await this.redis.smembers(REDIS_KEY.ROOM_PLAYERS(gameId));
Expand Down
4 changes: 1 addition & 3 deletions BE/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { config } from 'dotenv';
import { join } from 'path';
import 'pinpoint-node-agent';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
import { GameActivityInterceptor } from './game/interceptor/gameActivity.interceptor';

// env 뢈러였기
config({ path: join(__dirname, '..', '.env') }); // ../ 경둜의 .env λ‘œλ“œ

async function bootstrap() {
const app = await NestFactory.create(AppModule);
Expand Down
6 changes: 5 additions & 1 deletion BE/src/quiz-set/entities/quiz-choice.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { QuizModel } from './quiz.entity';
import { BaseModel } from '../../common/entity/base.entity';

Expand All @@ -11,6 +11,10 @@ export class QuizChoiceModel extends BaseModel {
isAnswer: boolean;

@Column({ name: 'choice_content', type: 'text' })
@Index({
fulltext: true,
parser: 'ngram'
})
choiceContent: string;

@Column({ name: 'choice_order', type: 'integer' })
Expand Down
4 changes: 4 additions & 0 deletions BE/src/quiz-set/entities/quiz-set.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const CategoriesEnum = Object.freeze({
@Entity('quiz_set')
export class QuizSetModel extends BaseModel {
@Column()
@Index({
fulltext: true,
parser: 'ngram' // ngram νŒŒμ„œ μ‚¬μš©
}) // Full Text Search 인덱슀 μΆ”κ°€
title: string;

@Column({ name: 'user_id' })
Expand Down
5 changes: 5 additions & 0 deletions BE/src/quiz-set/entities/quiz.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
Expand All @@ -22,6 +23,10 @@ export class QuizModel extends BaseModel {
quizSetId: number;

@Column('text')
@Index({
fulltext: true,
parser: 'ngram'
})
quiz: string;

@Column({ name: 'limit_time' })
Expand Down
5 changes: 4 additions & 1 deletion BE/src/quiz-set/quiz-set.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ export class QuizSetController {
@Query('take', new ParseIntOrDefault(10)) take: number,
@Query('search', new DefaultValuePipe('')) search: string
) {
const start = Date.now();
const result = await this.quizService.findAllWithQuizzesAndChoices(
category,
cursor,
take,
search
);
this.logger.verbose(`ν€΄μ¦ˆμ…‹ λͺ©λ‘ 쑰회: ${result}`);
const end = Date.now();
this.logger.verbose(`ν€΄μ¦ˆμ…‹ λͺ©λ‘ 쑰회: ${result}, ${end - start}ms`);
// this.logger.verbose(`ν€΄μ¦ˆμ…‹ λͺ©λ‘ 쑰회: ${result}`);
return result;
}

Expand Down
Loading

0 comments on commit db614e6

Please sign in to comment.