playerAchievements)를 일반 업적 데이터(JSON 파일의 업적 조건)와 비교하여 사용자 업적을 저장 및 업데이트하는 과정에서 데이터 불일치가 발생할 가능성이 있음. 기존 방식:
새로운 방식:
gameAchievements 메시지를 수신하면 바로 일반 업적과 비교하여 처리 // 구독 파일에서 게임 업적 메시지를 수신하여 즉시 일반 업적과 비교
private async processGameAchievements(message: string) {
console.log(`게임 업적 수신: ${message}`);
const gameAchievements = JSON.parse(message);
const gameId = gameAchievements?.gameId;
const playerAchievements = gameAchievements?.playerAchievements || {};
if (!gameId || Object.keys(playerAchievements).length === 0) {
console.warn(`잘못된 게임 업적 데이터: gameId 또는 playerAchievements가 없음.`);
return;
}
// 기존 방식: 먼저 게임 업적 저장 후 이를 기반으로 일반 업적 저장
// 새로운 방식: 구독 파일에서 바로 일반 업적과 비교하여 처리
await this.achievementsService.processGameAchievements(gameId, playerAchievements);
console.log(`일반 업적 저장 완료 (gameId: ${gameId}).`);
}
// AchievementsService 내 processGameAchievements 함수
async processGameAchievements(
gameId: string,
playerAchievements: Record<string, Record<string, string>> = {}
) {
// 1. JSON 파일에서 일반 업적 데이터 로드
const jsonFilePath = path.join(
process.cwd(),
'src',
'achievements',
'achievements.list.json'
);
const jsonData = fs.readFileSync(jsonFilePath, 'utf-8');
const { achievements }: { achievements: Achieve[] } = JSON.parse(jsonData);
if (!gameId || Object.keys(playerAchievements).length === 0) {
console.warn(`게임 업적 데이터가 올바르지 않음. 처리 중단 (gameId: ${gameId})`);
return;
}
const userAchievementsToSave: UserAchievements[] = [];
// 2. 구독 파일에서 받은 데이터를 활용하여 일반 업적과 비교
for (const [userId, stats] of Object.entries(playerAchievements)) {
if (!stats || typeof stats !== 'object') continue;
for (const [achievementType, value] of Object.entries(stats)) {
const countValue = isNaN(Number(value)) ? 0 : Number(value);
// 3. 일반 업적 데이터에서 업적 조건과 카운트가 일치하는 항목 검색
const matchedAchievements = achievements.filter(
(achieve) =>
achieve.condition === achievementType &&
achieve.conditionCount === countValue
);
for (const matchedAchievement of matchedAchievements) {
// 4. UserAchievementsRepository를 통해 기존 업적 여부 확인
let existingAchievement =
await this.userAchievementsRepository.findUserAchievementByUserAndAchieve(
Number(userId),
matchedAchievement.id
);
if (!existingAchievement) {
// 기존 업적이 없으면 새로 생성
existingAchievement =
await this.userAchievementsRepository.createUserAchievement({
userId: Number(userId),
achieveId: matchedAchievement.id,
value: matchedAchievement.conditionCount,
achievedAt: new Date(),
});
userAchievementsToSave.push(existingAchievement);
}
}
}
}
// 5. 신규 업적이 존재하면 저장 또는 업데이트 실행
if (userAchievementsToSave.length > 0) {
await this.userAchievementsRepository.saveUserAchievements(userAchievementsToSave);
console.log(`일반 업적 저장 완료 (gameId: ${gameId})`);
}
}
업적 데이터 처리 방식 개선
중복 저장 방지 및 데이터 일관성 유지
처리 속도 개선
게임 업적 데이터는 반드시 저장해야 하는 것이 아님
구독 파일을 활용한 데이터 흐름 최적화
중복 저장 방지 및 데이터 일관성 유지 방법 개선
구독 파일을 활용한 데이터 처리 방식 확대
업적 시스템과 연동한 리더보드 API 개발
업적 데이터 변경에 따른 마이그레이션 및 정합성 검사 추가
기존에는 게임 업적을 저장한 후 이를 기반으로 일반 업적을 처리하는 방식이었으나, 구독 파일에서 데이터를 바로 받아 비교하는 것이 더 효과적임을 확인했다.
이를 통해 불필요한 데이터 저장을 줄이고, 성능을 개선하며, 데이터 일관성을 유지할 수 있도록 업적 시스템을 최적화하였다.
endGame 실행 후 gameResults 및 gameAchievements 채널로 Pub/Sub 전송하지만, API 서버에서 정상적으로 구독되지 않는 문제 발생 gameAchievements 채널의 데이터가 일부 손실되거나 중복 저장되는 현상 발견 gameResult:{gameId} 및 gameAchievements:{gameId} 데이터를 저장 후 gameResults 및 gameAchievements 채널로 Pub/Sub 전송 GameResultsSubscriber를 통해 해당 채널을 구독하여 메시지를 수신 GameResultsService.saveGameResult() 및 GameAchievementsService.saveGameAchievements() 호출 endGame 실행 시 lock:endGame:{roomId} 키를 사용하여 중복 실행 방지 gameResult:{gameId} 및 gameAchievements:{gameId} 데이터가 이미 저장되어 있는지 확인 후 중복 저장 방지 updateUserStats()에서 money와 score 값을 직접 증가시키는 SQL 실행 userIds.length === 0인 경우 업데이트 로직 실행하지 않도록 예외 처리 // 게임 서버 - endGame 실행 시 Redis에 게임 결과 및 업적 데이터 저장 후 Pub/Sub 전송
const achievementsKey = `gameAchievements:${gameId}`;
await redisClient.set(achievementsKey, JSON.stringify(playerAchievements), 'EX', 86400);
await redisClient.publish('gameAchievements', JSON.stringify({ gameId }));
const gameResultKey = `gameResult:${gameId}`;
await redisClient.set(gameResultKey, JSON.stringify(gameResult), 'EX', 86400);
await redisClient.publish('gameResults', JSON.stringify(gameResult));
// API 서버 - Pub/Sub 구독 후 게임 결과 및 업적 처리
redisSubscriber.subscribe('gameResults', 'gameAchievements', (err, count) => {
if (err) {
console.error('Redis 구독 실패:', err);
} else {
console.log(`구독 중인 채널 개수: ${count}`);
}
});
redisSubscriber.on('message', async (channel, message) => {
if (channel === 'gameResults') {
await processGameResult(message);
} else if (channel === 'gameAchievements') {
await processGameAchievements(message);
}
});
// 게임 결과 저장 및 유저 보상 처리
private async processGameResult(message: string) {
console.log(`게임 결과 수신: ${message}`);
const gameResult = JSON.parse(message);
const gameId = gameResult.gameId;
const winningTeam = gameResult.winningTeam;
const players = gameResult.finalState.players;
if (!winningTeam) {
console.warn(`게임 결과에 승리 팀 정보가 없음 (gameId: ${gameId})`);
return;
}
const gameResultKey = `gameResult:${gameId}`;
const isAlreadyStored = await this.redisClient.exists(gameResultKey);
if (!isAlreadyStored) {
console.warn(`게임 결과가 Redis에 없음 (gameId: ${gameId}), 무시.`);
return;
}
await this.gameResultsService.saveGameResult(gameResult);
console.log(`게임 결과가 RDS에 저장됨 (gameId: ${gameId}).`);
const winningPlayers = players
.filter((player) =>
winningTeam === 'mafia' ? player.role === 'mafia' : player.role !== 'mafia',
)
.map((player) => player.userId);
const losingPlayers = players
.filter((player) => !winningPlayers.includes(player.userId))
.map((player) => player.userId);
await this.updateUserStats(winningPlayers, 1000, 100);
await this.updateUserStats(losingPlayers, 0, -100);
console.log(`모든 유저의 보상 및 점수 업데이트 완료 (gameId: ${gameId})`);
}
// 유저 점수 및 머니 업데이트
private async updateUserStats(
userIds: number[],
moneyIncrease: number,
scoreIncrease: number,
) {
if (userIds.length === 0) return;
await this.usersRepository
.createQueryBuilder()
.update(User)
.set({
money: () => `money + ${moneyIncrease}`,
score: () => `score + ${scoreIncrease}`,
})
.where('id IN (:...userIds)', { userIds })
.execute();
console.log(`유저 ID: [${userIds.join(', ')}] - money +${moneyIncrease}, score +${scoreIncrease} 업데이트 완료`);
}
// 게임 업적 처리
private async processGameAchievements(message: string) {
console.log(`게임 업적 수신: ${message}`);
const gameAchievements = JSON.parse(message);
const gameId = gameAchievements?.gameId;
const playerAchievements = gameAchievements?.playerAchievements || {};
if (!gameId || Object.keys(playerAchievements).length === 0) {
console.warn(`잘못된 게임 업적 데이터: gameId 또는 playerAchievements가 없음.`);
return;
}
const gameAchievementsKey = `gameAchievements:${gameId}`;
const isAlreadyStored = await this.redisClient.exists(gameAchievementsKey);
if (!isAlreadyStored) {
console.warn(`게임 업적 데이터가 Redis에 없음 (gameId: ${gameId}), 무시.`);
return;
}
await this.gameAchievementsService.saveGameAchievements(gameAchievements);
console.log(`게임 업적이 RDS에 저장됨 (gameId: ${gameId}).`);
await this.achievementsService.processGameAchievements(gameId, playerAchievements);
console.log(`일반 업적 저장 완료 (gameId: ${gameId}).`);
}
gameResult:{gameId} 및 gameAchievements:{gameId}를 저장 후 확인하는 로직 추가 isNaN() 체크 추가 및 기본값 설정 updateUserStats()에서 직접 증가시키는 SQL 실행 Redis Pub/Sub 기반 데이터 흐름을 설계하는 방법
유저 보상 및 점수 업데이트 로직 최적화
createQueryBuilder().update()를 사용하여 SQL 실행 최적화 NestJS에서 순환 의존성 해결 방법
GameResultsModule과 GameAchievementsModule이 서로 참조하는 문제 해결 @Inject(forwardRef(() => ServiceName))을 사용하여 순환 의존성 해결