2025년 3월 12일

김동환·2025년 3월 12일

게임 업적 시스템 - 트러블슈팅


문제 인지

  • 게임 서버에서 전달된 게임 업적 데이터가 일부 손실되거나 중복 저장되는 문제가 발생함.
  • 플레이어별 게임 업적 데이터(playerAchievements)를 일반 업적 데이터(JSON 파일의 업적 조건)와 비교하여 사용자 업적을 저장 및 업데이트하는 과정에서 데이터 불일치가 발생할 가능성이 있음.
  • 기존에는 게임 업적을 먼저 저장한 후 이를 기반으로 일반 업적을 처리하려 했으나, 구독 파일에서 데이터를 직접 받아 비교하는 것이 더 효과적임을 발견함.
  • 즉, 게임 업적 데이터를 저장한 후 가져와 비교하는 방식이 아니라, 구독 파일에서 바로 데이터를 받아 비교하면 불필요한 데이터 저장을 줄이고, 중복 업데이트를 방지할 수 있음.

해결 방법 (대응)

구독 파일에서 직접 업적 데이터 비교

  1. 기존 방식:

    • 게임 업적 데이터를 저장한 후, 일반 업적을 저장할 때 다시 가져와 비교
    • 불필요한 데이터 저장 및 조회 발생
    • 중복 저장 및 데이터 불일치 가능성 증가
  2. 새로운 방식:

    • 구독 파일에서 gameAchievements 메시지를 수신하면 바로 일반 업적과 비교하여 처리
    • 게임 업적을 따로 저장할 필요 없이, 게임 ID와 플레이어 업적 데이터만 활용하여 일반 업적과 비교 가능
    • 데이터 처리 속도 증가, 중복 저장 방지, 불필요한 데이터 조회 최소화

해결 과정 (코드 포함)

// 구독 파일에서 게임 업적 메시지를 수신하여 즉시 일반 업적과 비교
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})`);
  }
}

주요 개선 사항

  • 업적 데이터 처리 방식 개선

    • 기존에는 게임 업적을 저장한 후, 이를 기반으로 일반 업적을 비교
    • 새로운 방식에서는 구독 파일에서 데이터를 바로 받아 일반 업적과 비교
    • 불필요한 데이터 저장 없이 즉시 업적을 처리할 수 있도록 개선됨
  • 중복 저장 방지 및 데이터 일관성 유지

    • 기존에는 게임 업적 저장 후 다시 조회하면서 데이터 불일치가 발생할 가능성이 있었음
    • 새로운 방식에서는 구독 파일에서 직접 데이터를 받아 처리하여 중복 저장을 방지하고 데이터 일관성을 유지
  • 처리 속도 개선

    • 불필요한 데이터 조회 및 저장을 줄이고, 직접 비교하여 빠르게 업적을 저장
    • 구독 파일에서 메시지를 받아 즉시 처리하는 구조로 최적화

배운 점

  • 게임 업적 데이터는 반드시 저장해야 하는 것이 아님

    • 기존에는 게임 업적을 저장한 후 비교하는 방식을 사용했지만, 구독 파일에서 직접 데이터를 받아 처리하는 것이 더 효과적임을 발견
    • 불필요한 데이터 저장을 줄이고, 메시지를 즉시 처리할 수 있는 방식이 성능적으로 더 유리함
  • 구독 파일을 활용한 데이터 흐름 최적화

    • 게임 업적을 RDS에 저장한 후 다시 조회할 필요 없이, 메시지를 받아 즉시 처리하는 방식이 성능과 데이터 일관성 측면에서 더 효과적임을 학습함
  • 중복 저장 방지 및 데이터 일관성 유지 방법 개선

    • 기존 방식에서는 게임 업적을 저장한 후, 이를 기반으로 일반 업적을 업데이트하는 방식이었으나
    • 이제는 구독 파일에서 바로 데이터를 비교하여 업적을 처리함으로써 중복 저장을 방지하고 데이터 일관성을 유지

다음 할 일

  • 구독 파일을 활용한 데이터 처리 방식 확대

    • 게임 업적뿐만 아니라, 다른 실시간 데이터도 구독 파일에서 직접 처리할 수 있도록 검토
  • 업적 시스템과 연동한 리더보드 API 개발

    • 새로운 데이터 처리 방식에 맞춰, 리더보드 기능을 개발하여 실시간 순위 반영 가능하도록 개선
  • 업적 데이터 변경에 따른 마이그레이션 및 정합성 검사 추가

    • JSON 파일 기반 일반 업적 데이터가 변경될 경우를 대비하여 마이그레이션 및 데이터 정합성 검증 로직 추가

결론

기존에는 게임 업적을 저장한 후 이를 기반으로 일반 업적을 처리하는 방식이었으나, 구독 파일에서 데이터를 바로 받아 비교하는 것이 더 효과적임을 확인했다.
이를 통해 불필요한 데이터 저장을 줄이고, 성능을 개선하며, 데이터 일관성을 유지할 수 있도록 업적 시스템을 최적화하였다.


게임 종료 및 업적 시스템 - 트러블슈팅


문제 인지

문제 발생 원인

  • 게임 서버에서 endGame 실행 후 gameResultsgameAchievements 채널로 Pub/Sub 전송하지만, API 서버에서 정상적으로 구독되지 않는 문제 발생
  • gameAchievements 채널의 데이터가 일부 손실되거나 중복 저장되는 현상 발견
  • 게임 결과 처리 시 유저 점수 및 머니 업데이트가 일부 적용되지 않는 문제 발생

해결 방법 (대응)

Redis Pub/Sub 메시지 전송 안정화

  1. 게임 서버에서 gameResult:{gameId}gameAchievements:{gameId} 데이터를 저장 후 gameResultsgameAchievements 채널로 Pub/Sub 전송
  2. API 서버에서 GameResultsSubscriber를 통해 해당 채널을 구독하여 메시지를 수신
  3. Redis에서 데이터 존재 여부를 확인한 후 GameResultsService.saveGameResult()GameAchievementsService.saveGameAchievements() 호출

중복 저장 방지 로직 추가

  • 게임 서버에서 endGame 실행 시 lock:endGame:{roomId} 키를 사용하여 중복 실행 방지
  • API 서버에서 gameResult:{gameId}gameAchievements:{gameId} 데이터가 이미 저장되어 있는지 확인 후 중복 저장 방지

유저 보상 및 점수 업데이트 로직 개선

  • updateUserStats()에서 moneyscore 값을 직접 증가시키는 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}).`);
}

주요 개선 사항

  • Redis Pub/Sub 메시지 손실 방지: gameResult:{gameId}gameAchievements:{gameId}를 저장 후 확인하는 로직 추가
  • 데이터 변환 로직 보완: isNaN() 체크 추가 및 기본값 설정
  • 중복 저장 방지: Redis에서 데이터 존재 여부를 확인 후 저장
  • 유저 점수 및 보상 업데이트 로직 개선: updateUserStats()에서 직접 증가시키는 SQL 실행

배운 점

  • Redis Pub/Sub 기반 데이터 흐름을 설계하는 방법

    • 게임 서버가 직접 RDS에 저장하지 않고 Pub/Sub을 활용하여 API 서버로 데이터 전송
    • 이를 통해 게임 서버의 부하를 줄이고, API 서버에서 데이터를 검증 후 저장
  • 유저 보상 및 점수 업데이트 로직 최적화

    • createQueryBuilder().update()를 사용하여 SQL 실행 최적화
    • 유저 ID 리스트가 비어있을 경우 예외 처리를 추가하여 불필요한 실행 방지
  • NestJS에서 순환 의존성 해결 방법

    • GameResultsModuleGameAchievementsModule이 서로 참조하는 문제 해결
    • @Inject(forwardRef(() => ServiceName))을 사용하여 순환 의존성 해결

다음 할 일

  • 게임 업적 데이터를 기반으로 한 리더보드 API 개발
  • 업적 데이터를 시각화하여 플레이어에게 제공하는 기능 추가
  • 유저의 게임 기록을 조회하는 기능 개발
profile
Node.js 7기

0개의 댓글