인게임 HUD에 플레이어 닉네임과 패링 쿨타임을 붙이고, 방 나가기까지 연결했다.
작업 순서는 C++ 8개를 전부 쓰고 빌드를 한 번만 돌린 뒤 블루프린트를 붙이는 방식으로 잡았다. 중간에 C++을 고치면 에디터를 껐다 켜는 데만 10~15분씩 날아가기 때문이다. 결과적으로 이 판단이 제일 크게 시간을 아꼈다.
이번에 만난 문제는 다섯 개다.
가장 오래 잡은 문제다.
인게임 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가 안 오는 동안 목록 전체가 안 뜬다. 이렇게 두면 먼저 온 사람부터 보이고 나머지가 다음 프레임에 채워진다.
교훈 — 리슨 서버에서 "로컬 플레이어"를 월드에서 검색하면 안 된다. 서버 월드에는 원격 플레이어의 컨트롤러도 살아 있다. 그리고 복제 프로퍼티는 도착 순서를 가정하면 안 된다.
한 가지 더. 이 버그는 한쪽 창만 봤으면 못 잡았다. 호스트 창만 보면 "본인이 뜨네"고, 클라 창만 보면 "중복이네"다. 원인이 완전히 다른데 증상만 보고는 하나로 묶기 어렵다.
이 프로젝트에서 세 번째로 밟은 지뢰다.
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);
}
방 나가기를 누르면 메뉴 맵으로 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++에도 맵 경로가 남지 않는다.
방 나가기 흐름을 이렇게 설계했다.
나가기 버튼 → RequestDestroyRoom()
→ OnSessionOperationCompleted (DestroyRoom, 성공)
→ ClientTravel(메뉴 맵, TRAVEL_Absolute)
TRAVEL_Relative를 쓰면 호스트에 다시 붙으려 하기 때문에 TRAVEL_Absolute여야 한다.
그런데 검증 항목을 짜다가 모순을 발견했다. 호스트가 DestroySession()을 부르면 참가자는 연결이 끊긴다. 끊긴 뒤에는 OnSessionOperationCompleted가 도착할 수 없다. "호스트가 누르면 양쪽 다 메뉴로 간다"는 흐름이 구조적으로 성립하지 않았다.
프로토 단계라 폴백으로 처리했다. 연결이 끊기면 엔진이 GameDefaultMap으로 보내는 걸 이용했다.
호스트 ClientTravel(TRAVEL_Absolute) → 메뉴 맵
참가자 NetworkFailure 폴백 → GameDefaultMap
GameDefaultMap을 메뉴 맵으로 지정해두면 결과적으로 양쪽이 같은 곳에 도달한다. 우아하진 않지만 동작한다.
"호스트만 나가고 방장을 넘기면 안 되나" 를 검토했는데, 리슨 서버에서는 안 된다.
호스트 = 세션 소유자 = 게임 서버
방장 표시만 넘기는 게 아니라 다른 플레이어가 새 리슨 서버가 되어야 한다. UE는 실행 중에 서버 권한을 이전할 수 없다. 사실상 전원 재접속이다.
다만 알아둘 게 두 가지 있었다.
EOS 로비는 살아남는다. 오너가 나가면 자동으로 다른 참가자에게 오너십이 넘어간다. 지금 방이 사라지는 건 우리가 DestroySession()을 부르기 때문이지 리슨 서버가 죽어서가 아니다. 어려운 건 EOS가 아니라 UE 쪽이다.
방과 인게임은 난이도가 완전히 다르다. 대기실에서 옮길 상태는 이름과 준비 상태뿐이라 재접속하면 다시 만들어진다. 인게임은 도로 상태, 페인트 진행도, 차량 위치와 속도까지 직렬화해야 하고 물리로 움직이는 건 정확한 복원이 사실상 불가능하다.
GetOwningPlayer()를 쓰는 게 맞다.PC->PlayerState와 PlayerArray는 다른 채널이다. RepNotify가 없는 프로퍼티(PlayerId 등)는 늦게 도착해도 알려주지 않으므로, 값을 못 읽었으면 다음 Tick에 재시도하는 경로를 만들어야 한다.FText는 EqualTo로 비교한다. IdenticalTo는 내부 히스토리를 보기 때문에 FText::Format 결과는 항상 다르다고 나온다. MVVM 매크로에 FText 오버로드가 있어도 그게 정답이라는 뜻은 아니다.| 팀프로젝트 09. 클리어 후 방 복귀와 연결이 끊긴 뒤의 정리 (0) | 2026.08.13 |
|---|---|
| 팀프로젝트 08. 결과 화면 전환과 HUD 레이어 정리 (0) | 2026.08.12 |
| 팀프로젝트 06. 리슨 서버 대기실 만들면서 마주친 문제 (0) | 2026.08.10 |
| 팀프로젝트 05. 목표 HUD를 MVVM으로 연결 (0) | 2026.08.07 |
| 팀프로젝트 04. 공개 API를 늘렸을 때 깨지는 전제 (0) | 2026.08.06 |