프로젝트 회고 / / 2025. 12. 2. 23:16

[Dedicated Server] 콜백(Callback) 체인을 활용한 데이터 무결성 확보

반응형

🎮 구현 목표

경기 종료 후 Player Table → Leaderboard Table 순서로 DB 업데이트가 진행되어, 랭킹이 정확하게 반영되도록 합니다.
Player Table에 최신 승수가 저장된 후에야 Leaderboard가 올바르게 갱신될 수 있습니다.

 

 

🚨 문제 상황

1등이 2등으로 표시되는 버그

 

경기 종료 후 순위 계산 흐름:

1. Player Table에 승수 기록 (matchWins += 1)
2. Leaderboard Table에서 상위 10명 갱신

 

그런데 실제로는:

플레이어 A: 5승 → 6승 (우승!)
   ↓
[동시에 발생]
Lambda 1: Player Table에 6승 기록 중... (느림)
Lambda 2: Leaderboard 갱신 (Player Table 조회) → 아직 5승! ❌
   ↓
결과: Leaderboard에 5승으로 기록됨

 

 

 

문제의 핵심: 비동기 작업의 순서 미보장

 

AWS Lambda는 비동기로 실행되므로, 두 Lambda를 동시에 호출하면:

// 게임 종료 시 (잘못된 구현)
void ADS_MatchGameMode::OnMatchEnded()
{
    // 두 요청이 거의 동시에 발생
    GameStatsManager->RecordMatchStats(...);     // Lambda 1
    GameStatsManager->UpdateLeaderboard(...);    // Lambda 2

    // Lambda 2가 Lambda 1보다 먼저 끝날 수 있음!
}

 

실제 테스트 결과:

경기 1: 우승 → 랭킹 1위 ✅
경기 2: 우승 → 랭킹 2위 ❌ (Player Table 갱신 전 Leaderboard 조회)
경기 3: 우승 → 랭킹 1위 ✅

 

간헐적으로 발생하는 이유는 네트워크 지연에 따라 Lambda 실행 순서가 바뀌기 때문입니다.

 

 

 

💭 해결 방법

"Player Table 저장이 끝났다는 걸 어떻게 알지?"

 

비동기 작업의 순서를 보장하려면 "첫 번째 작업 완료 → 두 번째 작업 시작" 구조가 필요했습니다.

 

 

초기 고민: Timer로 지연?

GameStatsManager->RecordMatchStats(...);
GetWorldTimerManager().SetTimer(TimerHandle, [this]() {
    GameStatsManager->UpdateLeaderboard(...);
}, 2.0f, false);  // 2초 후 실행

 

문제:

  • 2초면 충분한가? 3초는? → 정답 없음
  • Lambda가 느려지면 여전히 실패
  • 불필요한 대기 시간

 

 

해결: Delegate 체인

 

"Player Table 저장 성공 시 Delegate 발행 → Leaderboard 갱신" 구조로 변경

 

// GameStatsManager.h
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnAPIRequestSucceeded);

class UGameStatsManager : public UHTTPRequestManager
{
public:
    void RecordMatchStats(const FDSRecordMatchStatsInput& Input);
    void UpdateLeaderboard(const TArray<FString>& PlayerNames);

    UPROPERTY(BlueprintAssignable)
    FOnAPIRequestSucceeded OnUpdatedGameStatsSucceeded;  // 핵심!
};

 

 

Lambda 응답 처리:

// GameStatsManager.cpp
void UGameStatsManager::RecordMatchStats_Response(
    FHttpRequestPtr Request, 
    FHttpResponsePtr Response, 
    bool bWasSuccessful)
{
    if (!bWasSuccessful) return;

    TSharedPtr<FJsonObject> JsonObject;
    TSharedRef<TJsonReader<>> JsonReader = 
        TJsonReaderFactory<>::Create(Response->GetContentAsString());

    if (FJsonSerializer::Deserialize(JsonReader, JsonObject))
    {
        if (ContainsErrors(JsonObject, true)) return;

        // Player Table 저장 성공!
        UE_LOG(LogDedicatedServers, Warning, 
            TEXT("RecordMatchStats_Response Succeeded!"));

        OnUpdatedGameStatsSucceeded.Broadcast();  // Delegate 전파
    }
}

 

 

GameMode에서 Delegate 구독:

// DS_MatchGameMode.cpp
void ADS_MatchGameMode::CreateGameStatsManager()
{
    GameStatsManager = NewObject<UGameStatsManager>(this, GameStatsManagerClass);

    // Delegate 구독 (핵심!)
    GameStatsManager->OnUpdatedGameStatsSucceeded.AddDynamic(
        this, 
        &ADS_MatchGameMode::OnGameStatsUpdated
    );
}

void ADS_MatchGameMode::OnMatchEnded()
{
    // 1. Player Table에 승수 기록만 요청
    EndMatchForPlayerStats();  // RecordMatchStats 호출

    // 2. Leaderboard는 여기서 호출 안 함!
    // (Delegate 콜백에서 호출됨)
}

// Delegate 콜백
void ADS_MatchGameMode::OnGameStatsUpdated()
{
    // Player Table 저장 완료 후 자동 호출됨!
    UE_LOG(LogTemp, Warning, TEXT("OnGameStatsUpdated"));

    // 2. 이제 Leaderboard 갱신
    TArray<FString> LeaderIds;
    if (AMatchGameState* GS = GetGameState<AMatchGameState>())
    {
        TArray<AMatchPlayerState*> Leaders = GS->GetLeaders();
        for (AMatchPlayerState* Leader : Leaders)
        {
            LeaderIds.Add(Leader->GetUsername());
        }
    }

    UpdateLeaderboard(LeaderIds);  // 최신 데이터로 갱신 ✅
}

 

 

개선된 흐름:

1. 경기 종료
   ↓
2. RecordMatchStats() 호출 (Player Table 갱신)
   ↓
3. Lambda 실행... (비동기)
   ↓
4. Lambda 성공 응답
   ↓
5. OnUpdatedGameStatsSucceeded.Broadcast()
   ↓
6. OnGameStatsUpdated() 자동 호출
   ↓
7. UpdateLeaderboard() 호출 (최신 데이터 조회) ✅

 

 

🔧 고려사항

 

왜 서버에서 순위를 계산하지 않았나?

 

고민: "서버가 직접 순위 계산하면 Lambda 2개 필요 없지 않나?"

 

// 고려했던 방법
void ADS_MatchGameMode::OnMatchEnded()
{
    // 서버가 직접 PlayerState 순회하며 순위 계산
    TArray<AMatchPlayerState*> AllPlayers;
    // ... 정렬 로직

    // DB에 한 번에 저장
    GameStatsManager->RecordMatchStatsWithRanking(AllPlayers);
}

 

그러나 DB는 Single Source of Truth여야 한다고 판단:

  • 서버 크래시 시 데이터 소실 위험
  • 여러 게임 세션 간 랭킹 통합 불가
  • Lambda 분리 = 관심사 분리 (Player 기록 vs Leaderboard 갱신)

 

 

✅ 결과

 

순위 정확도 100%: Player Table 저장 후 Leaderboard 갱신 보장
간헐적 버그 해결: 네트워크 지연과 무관하게 순서 보장
확장성: 새로운 통계 추가 시 Delegate 체인 확장 가능

 

 

핵심 교훈:

"비동기 작업의 순서는 코드 순서가 아니라 Delegate로 보장한다"

HTTP 요청, Lambda 호출 같은 비동기 작업은 응답 시점을 예측할 수 없습니다.

 

 

Delegate 패턴:

작업 1 호출 → 응답 대기 → Delegate 전파 → 작업 2 자동 시작

 

이 방식은:

  • 네트워크 지연과 무관하게 순서 보장
  • Lambda 실행 시간이 늘어나도 안전
  • 여러 단계의 비동기 작업 체인 가능

 

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유