상세 컨텐츠

본문 제목

팀프로젝트 07. 리슨 서버에서 "나"를 찾는 법, 인게임 HUD 플레이어 목록 연동

Unreal C++

by hyunjunstar 2026. 8. 11. 23:12

본문

인게임 HUD에 플레이어 닉네임과 패링 쿨타임을 붙이고, 방 나가기까지 연결했다.

작업 순서는 C++ 8개를 전부 쓰고 빌드를 한 번만 돌린 뒤 블루프린트를 붙이는 방식으로 잡았다. 중간에 C++을 고치면 에디터를 껐다 켜는 데만 10~15분씩 날아가기 때문이다. 결과적으로 이 판단이 제일 크게 시간을 아꼈다.

이번에 만난 문제는 다섯 개다.

1. 리슨 서버에서 "나"를 잘못 찾았다

가장 오래 잡은 문제다.

인게임 HUD 좌측에 본인을 제외한 다른 플레이어 목록을 띄우는 게 목표였다. 2인 PIE로 켜보니 이렇게 나왔다.

화면 기대 실제
호스트 창 상대방 한 명 본인 닉네임 한 명
클라이언트 창 상대방 한 명 두 명 다

두 창이 서로 다르게 깨진 게 힌트였다. 원인이 하나가 아니라는 뜻이다.

문제의 코드는 이거였다.

APlayerController* LocalPlayerController =
    World->GetFirstPlayerController();

APlayerState* LocalPlayerState =
    LocalPlayerController->PlayerState;

호스트 창이 깨진 이유

리슨 서버는 호스트가 곧 서버다. 그래서 호스트의 월드에는 접속한 클라이언트의 PlayerController도 함께 존재한다. GetFirstPlayerController()가 호스트 것을 돌려준다는 보장이 없다.

게다가 방 → 인게임은 seamless travel로 넘어간다. AGameModeBase::HandleSeamlessTravelPlayer가 모든 플레이어의 컨트롤러를 새로 스폰하는데, 그 순서가 호스트를 먼저 놓는다는 보장도 없다.

GetFirstPlayerController() → 클라이언트의 PC
→ 클라이언트를 "본인"으로 판정해서 제외
→ 진짜 본인이 원격 목록에 남음

화면에 본인 닉네임 한 칸만 뜬 게 정확히 이 결과다.

클라이언트 창이 깨진 이유

클라이언트 월드에는 PC가 하나뿐이라 컨트롤러는 맞게 찾는다. 문제는 다른 데 있었다.

PC->PlayerState와 GameState->PlayerArray는 서로 다른 채널로 복제된다. PlayerState 액터가 PlayerArray에 먼저 들어오고, PC의 PlayerState 프로퍼티는 나중에 도착할 수 있다.

LocalPlayerState == nullptr
→ 비교가 전부 실패 → 두 명 다 원격 목록에 들어감
→ 이후 PlayerArray 가 안 변하니 갱신 이벤트도 없음
→ 잘못된 상태 그대로 굳음

일시적인 문제가 아니라 영구적으로 굳는다는 게 고약했다. 갱신을 다시 걸어줄 트리거가 없기 때문이다.

해결

둘 다 뿌리가 같다. "본인"을 월드에서 찾고 있었다.

위젯은 자기 소유 플레이어를 이미 알고 있다. ViewModel이 Initialize(self)로 위젯을 받으면서 월드만 꺼내 쓰고 위젯 자체는 버리고 있었던 게 문제였다.

// 위젯이 소유한 로컬 플레이어를 기준으로 본인을 판별한다.
// 리슨 서버의 월드에는 접속한 클라이언트의 PlayerController도 함께
// 존재하고, seamless travel 후에는 순서도 보장되지 않는다.
const UUserWidget* OwningWidget =
    Cast<UUserWidget>(BoundContextObject.Get());

APlayerController* LocalPlayerController =
    IsValid(OwningWidget)
    ? OwningWidget->GetOwningPlayer()
    : nullptr;

복제 타이밍은 재시도로 처리했다.

// PC의 PlayerState 복제가 아직 도착하지 않았다면
// 잘못된 원격 목록을 확정하지 않고 다음 Tick에 재시도
if (!IsValid(LocalPlayerState))
{
    World->GetTimerManager().SetTimerForNextTick(
        this,
        &UCWInGamePlayerViewModel::RefreshPlayers
    );

    return;
}

재밌는 건 PlayerId도 같은 함정을 갖고 있다는 점이다. PlayerId는 그냥 복제 프로퍼티라 RepNotify가 없다. 늦게 도착해도 알려주는 이벤트가 없으니, 이걸로 필터링하면 그 플레이어가 영영 안 뜰 수 있다.

그래서 목록은 먼저 확정하고 나서 재시도를 걸었다.

SetLocalPlayerName(NewLocalPlayerName);
SetRemotePlayers(NewRemotePlayers);

// 목록을 먼저 반영한 뒤, 빠진 플레이어가 있으면 다음 Tick에 다시 채움
if (bHasPendingPlayerId)
{
    World->GetTimerManager().SetTimerForNextTick(...);
}

순서를 반대로 하면 한 명이라도 PlayerId가 안 오는 동안 목록 전체가 안 뜬다. 이렇게 두면 먼저 온 사람부터 보이고 나머지가 다음 프레임에 채워진다.

교훈 — 리슨 서버에서 "로컬 플레이어"를 월드에서 검색하면 안 된다. 서버 월드에는 원격 플레이어의 컨트롤러도 살아 있다. 그리고 복제 프로퍼티는 도착 순서를 가정하면 안 된다.

한 가지 더. 이 버그는 한쪽 창만 봤으면 못 잡았다. 호스트 창만 보면 "본인이 뜨네"고, 클라 창만 보면 "중복이네"다. 원인이 완전히 다른데 증상만 보고는 하나로 묶기 어렵다.

2. FText는 EqualTo로 비교해야 한다 - 세 번째

이 프로젝트에서 세 번째로 밟은 지뢰다.

MVVM ViewModel에서 값을 갱신할 때 UE_MVVM_SET_PROPERTY_VALUE 매크로를 쓰는데, 이게 내부적으로 operator==를 타고 값이 실제로 바뀐 경우에만 알림을 발생시킨다.

목표 목록은 TArray<FCWStageObjective>이므로 TArray::operator== → 구조체의 operator== 순으로 내려간다.

bool operator==(const FCWStageObjective& Other) const
{
    return CompletedCount == Other.CompletedCount
        && TotalCount == Other.TotalCount
        && DisplayName.IdenticalTo(Other.DisplayName);   // 문제
}

FText::IdenticalTo()는 표시 문자열이 아니라 내부 히스토리를 비교한다. 그런데 FText::Format()은 호출할 때마다 새 히스토리 객체를 만든다.

"페인트 정리 5/5"  vs  "페인트 정리 5/5"
→ 표시 문자열은 같음
→ IdenticalTo 는 false
→ 매번 "변경됨"으로 판정 → 브로드캐스트 폭풍

수정은 한 글자다.

&& DisplayName.EqualTo(Other.DisplayName);

다만 여기서 한 번 잘못 짚었다. UE_MVVM_SET_PROPERTY_VALUE가 FText를 지원하지 않는 줄 알았는데, MVVMViewModelBase.h를 열어보니 FText 오버로드가 실제로 있었다. 문제는 그 오버로드가 IdenticalTo를 쓴다는 것이었다. 컴파일이 안 되는 게 아니라 컴파일은 되는데 항상 참이 되는 쪽이라 더 나쁘다.

그래서 ViewModel의 FText setter는 매크로를 쓰지 않고 직접 짰다.

void UCWInGamePlayerViewModel::SetLocalPlayerName(const FText& InPlayerName)
{
    // FText의 내부 히스토리가 아니라 실제 표시 문자열로 비교
    if (LocalPlayerName.EqualTo(InPlayerName))
    {
        return;
    }

    LocalPlayerName = InPlayerName;

    UE_MVVM_BROADCAST_FIELD_VALUE_CHANGED(LocalPlayerName);
}

3. 공용 위젯에 개인 Dev 맵 경로가 들어갈 뻔했다

방 나가기를 누르면 메뉴 맵으로 ClientTravel 해야 하는데, 그러려면 맵 경로가 필요하다.

처음엔 위젯에 그냥 적었다.

GetGameInstance → Cast to CWGameInstance → MenuMapPath

문제가 두 개였다.

첫째, WBP_Room과 WBP_Result는 공용 에셋이다. 여기에 /Game/CrossWalk/Maps/Dev/Dev_Hyunjun_Title을 박으면 팀원이 pull 받았을 때도 내 개인 테스트 맵으로 이동한다.

위젯 변수 하나로 모으는 것도 부족하다. 기본값 자체가 .uasset에 구워지기 때문이다. "커밋 전에 비우기"로 관리하면 새벽에 바이너리 에셋 두 개를 되돌리는 의식이 되고, 잊으면 그대로 나간다.

둘째, 세션 UI에서 지켜온 계층 규칙을 깬다.

Widget → ViewModel → GameInstance

WBP_Room은 세션에 접근할 때 전부 UCWSessionViewModel을 거치고 있었다. 여기에 GameInstance Cast를 하나 넣으면 그 위젯만 두 계층을 동시에 아는 예외가 된다.

결국 설정값을 GameInstance로 올렸다.

public:
    // 방을 나간 뒤 돌아갈 메뉴 레벨 경로를 반환
    UFUNCTION(BlueprintPure, Category = "Crosswalk|Session")
    const FString& GetMenuMapPath() const { return MenuMapPath; }

protected:
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Crosswalk|Session")
    FString MenuMapPath;

여기서 걸린 게 하나 있다. 기존 ListenMapPath를 따라 protected로 뒀는데, UCWSessionViewModel은 UCWGameInstance의 파생 클래스가 아니다. public getter가 없으면 ViewModel이 값을 읽는 경로 자체가 없다. getter가 선택이 아니라 필수였다.

ViewModel은 중계만 한다.

FString UCWSessionViewModel::GetMenuMapPath() const
{
    const UCWGameInstance* GameInstance = BoundGameInstance.Get();

    if (!IsValid(GameInstance))
    {
        return FString();
    }

    // 위젯이 GameInstance를 직접 참조하지 않도록 설정값을 중계
    return GameInstance->GetMenuMapPath();
}

반환형이 값이라는 게 포인트다. 바로 옆의 GetCurrentRoomCode()가 const FString&라 통일하고 싶어지는데, 실패 경로에서 FString()을 돌려주므로 참조로 바꾸면 임시 객체를 가리킨다. 크래시가 즉시 안 나고 나중에 이상하게 터지는 종류다.

개인 경로는 BP_TestGameInstance에만 넣었다. 공용 위젯에도, 공용 C++에도 맵 경로가 남지 않는다.

5. 호스트가 나가면 참가자는 완료 이벤트를 못 받는다

방 나가기 흐름을 이렇게 설계했다.

나가기 버튼 → RequestDestroyRoom()
→ OnSessionOperationCompleted (DestroyRoom, 성공)
→ ClientTravel(메뉴 맵, TRAVEL_Absolute)

TRAVEL_Relative를 쓰면 호스트에 다시 붙으려 하기 때문에 TRAVEL_Absolute여야 한다.

그런데 검증 항목을 짜다가 모순을 발견했다. 호스트가 DestroySession()을 부르면 참가자는 연결이 끊긴다. 끊긴 뒤에는 OnSessionOperationCompleted가 도착할 수 없다. "호스트가 누르면 양쪽 다 메뉴로 간다"는 흐름이 구조적으로 성립하지 않았다.

프로토 단계라 폴백으로 처리했다. 연결이 끊기면 엔진이 GameDefaultMap으로 보내는 걸 이용했다.

호스트   ClientTravel(TRAVEL_Absolute) → 메뉴 맵
참가자   NetworkFailure 폴백           → GameDefaultMap

GameDefaultMap을 메뉴 맵으로 지정해두면 결과적으로 양쪽이 같은 곳에 도달한다. 우아하진 않지만 동작한다.

방장 이전 안한 이유

"호스트만 나가고 방장을 넘기면 안 되나" 를 검토했는데, 리슨 서버에서는 안 된다.

호스트 = 세션 소유자 = 게임 서버

방장 표시만 넘기는 게 아니라 다른 플레이어가 새 리슨 서버가 되어야 한다. UE는 실행 중에 서버 권한을 이전할 수 없다. 사실상 전원 재접속이다.

다만 알아둘 게 두 가지 있었다.

EOS 로비는 살아남는다. 오너가 나가면 자동으로 다른 참가자에게 오너십이 넘어간다. 지금 방이 사라지는 건 우리가 DestroySession()을 부르기 때문이지 리슨 서버가 죽어서가 아니다. 어려운 건 EOS가 아니라 UE 쪽이다.

방과 인게임은 난이도가 완전히 다르다. 대기실에서 옮길 상태는 이름과 준비 상태뿐이라 재접속하면 다시 만들어진다. 인게임은 도로 상태, 페인트 진행도, 차량 위치와 속도까지 직렬화해야 하고 물리로 움직이는 건 정확한 복원이 사실상 불가능하다.

정리

  • 리슨 서버에서 로컬 플레이어를 월드에서 검색하면 안 된다. 서버 월드에는 원격 플레이어의 컨트롤러도 살아 있고, seamless travel 후에는 순서도 보장되지 않는다. 위젯이 이미 알고 있는 GetOwningPlayer()를 쓰는 게 맞다.
  • 복제 프로퍼티는 도착 순서를 가정하면 안 된다. PC->PlayerState와 PlayerArray는 다른 채널이다. RepNotify가 없는 프로퍼티(PlayerId 등)는 늦게 도착해도 알려주지 않으므로, 값을 못 읽었으면 다음 Tick에 재시도하는 경로를 만들어야 한다.
  • FText는 EqualTo로 비교한다. IdenticalTo는 내부 히스토리를 보기 때문에 FText::Format 결과는 항상 다르다고 나온다. MVVM 매크로에 FText 오버로드가 있어도 그게 정답이라는 뜻은 아니다.
  • UI 레이어 순서는 코드가 아니라 위젯 계층이 결정한다. 페이지를 열어도 HUD가 위에 남는다면 레이어 배치를 먼저 확인해야 한다. 그리고 무언가를 숨길 때는 대상이 확실히 떴는지 먼저 확인하고 숨긴다.
  • 공용 에셋에 개인 경로를 넣지 않는다. 위젯 변수로 모아도 기본값이 .uasset에 구워진다. 설정값은 GameInstance 같은 C++ 계층에 두고 개인 값은 테스트용 BP에만 넣으면 커밋 전 원복 단계 자체가 사라진다.
  • 마지막으로 작업 순서. C++을 전부 끝내고 빌드를 한 번만 돌린 뒤 블루프린트를 붙이는 방식이 확실히 빨랐다. 에디터 재시작 비용이 크기 때문에, 중간에 헤더 하나 고치겠다고 빌드를 나눌수록 손해가 누적된다.

관련글 더보기