이번에는 버그 두 개를 잡았다. 성격이 전혀 다른 버그인데 막힌 이유가 똑같았다.
둘 다 화면에 틀렸지만 그럴듯한 값이 나왔다. 그래서 엉뚱한 곳을 한참 팠다.
| 증상 | 처음 의심한 것 | 실제 원인 |
|---|---|---|
| 호스트 화면에서 다른 플레이어 체온이 호스트와 똑같이 오른다 | 체온 리플리케이션 | operator== 가 PlayerState 를 비교하지 않아 MVVM 이 갱신을 안 함 |
| 해상도를 바꾸고 환경설정을 다시 열면 다른 값이 떠 있다 | Save Settings 누락 |
Bool 이 Int 핀에 꽂혀 true 가 1 로 변환됨 |
인게임 HUD 왼쪽에 플레이어별 체온 게이지가 있다.
좌측 하단 내 체온
좌측 중단 다른 플레이어들의 체온
호스트 화면에서 내가 작업하면 다른 플레이어 게이지도 같이 올랐다. 그것도 「비슷하게」가 아니라 정확히 똑같이 올랐다.
클라이언트 화면은 정상이었다.
「동기화가 안 된다」로 읽고 체온 컴포넌트를 먼저 봤다. 그런데 이미 복제되고 있었다.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Temperature")
float CurrentTemperature = 36.5f;
리플리케이션 문제가 아니었다. 그러면 위젯이 누구를 읽는지가 문제다.
여기서 중요한 걸 놓치고 있었다. 값이 비슷하게 오르는 것과 똑같이 오르는 것은 전혀 다른 증상이다.
비슷하게 오른다 → 복제 지연. 각자 다른 값을 읽고 있다
똑같이 오른다 → 같은 하나를 읽고 있다
복제된 값은 절대 완전히 일치하지 않는다. 한 박자씩 늦는다. 완전히 일치하면 원본을 공유하는 것이다.
체온 게이지는 이렇게 값을 받는다.
CWInGamePlayerViewModel (PlayerState 를 담아 배열로)
└ WBP_RemotePlayerPanel (배열을 Break 해서 엔트리 생성)
└ WBP_PlayerStatusEntry (SetPlayerState)
└ WBP_TemperatureGauge (SetTargetPlayerState)
세 지점에 로그를 넣었다. 여기서 이름 대신 PlayerId 를 비교 기준으로 잡은 게 중요했다.
// CWInGamePlayerViewModel.cpp
const FString PlayerName = PlayerState->GetPlayerName();
if (PlayerName.IsEmpty())
{
DisplayName = FText::Format(... "Player {0}" ..., ValidPlayerIndex);
}
이름은 복제가 늦으면 "Player 2" 같은 임시값이 들어간다. 접속 직후에는 정상인데도 불일치한다. 이름으로 판정하면 오진한다.
PlayerId 는 폴백 없이 그대로 대입되므로 어긋나면 진짜 문제다.
로그 결과는 전부 통과였다.
Remote Entry / StructId=262 / StateId=262 / PSValid=true
Entry 수신 / StateId=262 / PSValid=true
게이지 수신 / StateId=262 / PSValid=true
전달은 완벽했다. 그러면 받은 뒤에 문제가 생기는 것이다.
여기서 시간을 꽤 버렸다. 세터에 로그를 찍고 「정상」을 확인하고 만족했는데, 실제로 화면에 그려지는 값은 게터가 만든다.
프로그레스바의 Percent 가 부르는 함수 안에 로그를 넣었다. 그러자 갈렸다.
참가자 화면
로컬 폴백 / PawnPSId=CWPlayerState_1 ← 자기 게이지
원격 경로 / TargetId=264 ← 상대 게이지, 정상
호스트 화면
로컬 폴백 / PawnPSId=CWPlayerState_2
로컬 폴백 / PawnPSId=CWPlayerState_2 ← 원격 게이지인데 폴백을 탔다
호스트 화면에 「원격 경로」 로그가 아예 없었다.
게이지는 이런 구조였다.
GetTemperatureComponent
→ IsValid(TargetPlayerState)
Valid → TargetPlayerState.GetPawn → TemperatureComponent
Not Valid → Get Owning Player Pawn → TemperatureComponent
TargetPlayerState 가 무효라서 위젯 소유자(호스트)의 폰을 읽고 있었다. 두 게이지가 같은 컴포넌트를 보니 값이 똑같이 움직였다.
받을 때는 유효했는데 읽을 때는 무효였다. 그 사이에 무슨 일이 있었나.
Seamless Travel 이다. 방에서 인게임으로 넘어갈 때 PlayerState 객체가 교체된다. 들고 있던 포인터는 무효가 된다.
그러면 목록이 다시 그려져야 한다. 그런데 안 그려졌다.
// FCWRoomPlayerInfo
bool operator==(const FCWRoomPlayerInfo& Other) const
{
return
PlayerId == Other.PlayerId &&
PlayerName.EqualTo(Other.PlayerName) &&
bIsReady == Other.bIsReady &&
bIsLocalPlayer == Other.bIsLocalPlayer;
// PlayerState 를 비교하지 않는다
}
MVVM 은 배열이 바뀌었는지를 operator== 로 판단한다.
UE_MVVM_SET_PROPERTY_VALUE(RemotePlayers, InRemotePlayers);
PlayerId 와 이름이 그대로면 포인터가 통째로 바뀌었어도 「변경 없음」이다. FieldNotify 가 안 나가고, 패널이 안 다시 그려지고, 엔트리는 낡은 포인터를 영원히 들고 있는다.
이 구조체를 처음 만들 때 PlayerState 를 일부러 비교에서 뺐다. 「표시 정보만 비교한다」는 의도였는데, 그 필드가 표시에 쓰이기 시작한 순간 전제가 깨졌다.
위젯 생성 시점과 PlayerState 교체 시점의 순서가 다르다.
호스트 Travel 이 진행되는 동안 이미 위젯이 살아 있다
→ 교체 전 포인터를 잡는다
참가자 접속과 복제가 끝난 뒤 위젯이 만들어진다
→ 교체 후 포인터를 잡는다
리슨 서버에서 호스트만 증상이 나는 버그를 이 프로젝트에서 벌써 세 번째 겪었다. 한쪽 화면에서만 재현되면 타이밍 차이를 먼저 의심하는 게 맞다.
실제 수정은 operator== 한 줄이다.
// MVVM 배열 변경 검사에서 플레이어 표시 정보와 참조 대상이 같은지 비교
// Seamless Travel로 같은 PlayerId의 PlayerState 객체가 교체되는 경우를 잡는다
bool operator==(const FCWRoomPlayerInfo& Other) const
{
return
PlayerId == Other.PlayerId &&
PlayerName.EqualTo(Other.PlayerName) &&
bIsReady == Other.bIsReady &&
bIsLocalPlayer == Other.bIsLocalPlayer &&
PlayerState == Other.PlayerState;
}
포인터 비교 하나 추가하는 비용으로, 객체 교체를 변경으로 인식하게 된다.
재발 방지로 폴백을 막았다.
GetTemperatureComponent
→ IsValid(TargetPlayerState)
Valid → TargetPlayerState.GetPawn → 반환
Not Valid → Branch(bUseOwningPlayerPawn)
True → Get Owning Player Pawn → 반환
False → 빈 값 반환
bUseOwningPlayerPawn 기본값은 false 다. 원격 슬롯은 아무것도 안 해도 안전하고, 로컬 슬롯만 예외로 켠다.
여기서 UMG 함정을 하나 만났다. 게이지가 두 단계 아래에 있어서 Details 패널로 설정할 수 없었다.
WBP_LocalPlayerStatus
└ WBP_PlayerStatusEntry_Local
└ WBP_TemperatureGauge ← 여기를 부모 Details 에서 못 잡는다
Instance Editable 은 바로 위 부모까지만 노출된다. 손자 위젯은 안 보인다. 중계 함수를 하나 두고 부모가 그걸 호출하게 했다.
그리고 빈 값을 반환하기 시작하면 받는 쪽에서 매 프레임 Accessed None 이 난다. 가드도 같이 넣었다.
GetTemperatureComponent → Is Valid
Valid → GetHeatRatio → 반환
Not Valid → 0 반환
이 버그에서 제일 오래 걸린 부분이 폴백 때문에 증상이 그럴듯해 보인 것이다.
폴백을 막은 순간 원격 게이지가 0 이 됐다. 고쳐진 건 아니었지만 「틀린 값」이 「빈 값」으로 바뀌었다. 그때부터 원인이 보였다.
「값이 없을 때 대신 뭘 보여줄까」는 편의 기능인데, 잘못된 대상을 보여주면 그건 편의가 아니라 은폐다.
환경설정에서 해상도를 바꾸고, 닫고, 다시 열면 엉뚱한 값이 떠 있었다. 그것도 항상 1152 x 864 로 똑같았다.
메인메뉴와 인게임 양쪽에서 동일했다.
「저장이 안 되나」로 의심했는데, 파일을 열어보니 아니었다.
; Saved/Config/WindowsEditor/GameUserSettings.ini
ResolutionSizeX=1680
ResolutionSizeY=1050
고른 값이 정확히 들어 있었다. 적용도 저장도 정상이고, 표시만 틀린 것이다.
이 확인이 범위를 크게 줄였다. 「저장」과 「표시」는 다른 문제고, ini 를 먼저 보면 30초 만에 갈린다.
목록을 만들고 선택값을 정하는 함수에 로그를 넣었다.
[Resolution] Current=1,680 x 1,050 ← GetScreenResolution
[Resolution] Count=23 ← 목록 개수
[Resolution] FoundIndex=13 ← 목록에서 찾은 인덱스
[Resolution] SelectedIndex=1 ← 실제로 선택된 인덱스
[Resolution] SelectedOption=1,152 x 864
여기서 확정됐다.
Find 는 13 을 정확히 돌려줬다
그런데 콤보박스는 1 이 됐다
Find(1680x1050) 이 1 을 돌려줄 수는 없다. 인덱스 1 에는 다른 값이 들어 있으니까. Set Selected Index 에 13 이 안 들어가고 있었다.
그래프를 열어보니 이렇게 되어 있었다.
Find.Return Value (13)
→ >= 0 → true
→ Set Selected Index.Index ← 여기
Index 핀에 Find 결과가 아니라 >= 0 비교 결과가 연결돼 있었다.
블루프린트는 Bool 을 Int 핀에 꽂으면 변환 노드를 조용히 끼워 넣는다. 컴파일 에러도 경고도 없다.
true → 1
false → 0
SelectedIndex=1 은 정확히 그 값이었다. 우연이 아니었다.
그리고 왜 하필 1152 x 864 였는지도 설명된다. 지원 해상도 목록의 두 번째 항목이었을 뿐이다. 특별한 의미가 없었다.
두 가지가 겹쳐서 조용히 넘어갔다.
첫째, 변환이 자동이다. Bool 핀과 Int 핀은 색이 다르지만, 연결하면 엔진이 알아서 붙여준다. 그래프를 훑어봐도 잘못이 눈에 안 띈다.
둘째, 콤보박스가 실패를 안 알려준다.
void UComboBoxString::SetSelectedIndex(const int32 Index)
{
if (Options.IsValidIndex(Index)) // 범위 밖이면 그냥 아무 일도 안 한다
{ ... }
}
범위 밖 인덱스를 넣어도 에러도 경고도 없다. 이번에는 우연히 유효한 인덱스(1)라 조용히 잘못된 값이 선택됐다.
Find.Return Value
├→ >= 0 → Branch.Condition (그대로)
└→ Branch True 쪽 Set Selected Index.Index ← 직접 연결
같은 출력 핀에서 선을 두 개 뽑으면 된다. Find 를 두 번 부를 필요 없다.
그리고 Get Screen Resolution 을 세 번 부르고 있던 것도 정리했다.
함수 진입 → CurrentResolution 에 저장 ← 여기서 한 번만
Find 의 Item 용으로 다시 ← 제거
Add 용으로 또다시 ← 제거
값이 갈릴 여지를 없애는 쪽이 맞다. 로컬 변수를 만들어둔 이유가 이거였는데 정작 안 쓰고 있었다.
첫 번째 열기 Current=1,600 x 900 FoundIndex=11 SelectedIndex=11
해상도 변경 1600x1200
두 번째 열기 Current=1,600 x 1,200 FoundIndex=12 SelectedIndex=12
FoundIndex 와 SelectedIndex 가 일치하면 끝이다.
품질도 같이 확인했다. 재진입 로그에 Applying CVar settings 가 없었다. 「환경설정을 여는 것만으로 품질이 바뀌지 않는다」가 확인된 것이다. 초기화 가드가 제대로 먹고 있었다.
테스트하다가 이상한 걸 발견했다. 에디터를 완전히 껐다 켜면 그래픽 품질이 「중간」으로 돌아가 있었다.
GameUserSettings.ini 를 열어보니 [ScalabilityGroups] 섹션이 아예 없었다. 저장이 안 되는 것처럼 보였다.
엔진 코드를 확인하니 이유가 명확했다.
// Engine/Source/Runtime/Engine/Private/GameUserSettings.cpp
void UGameUserSettings::SaveSettings()
{
// Save the Scalability state to the same ini file as it was loaded from in FEngineLoop::Preinit
if (bEnableScalabilitySettings)
{
Scalability::SaveState(GIsEditor ? GEditorSettingsIni : GGameUserSettingsIni);
}
...
}
에디터에서는 EditorSettings.ini 에 저장한다. 찾아보니 정말 거기 있었다.
AppData/Local/UnrealEngine/5.8/Saved/Config/WindowsEditor/EditorSettings.ini
[ScalabilityGroups]
sg.ResolutionQuality=71
sg.ViewDistanceQuality=1
...
그런데 이 섹션은 에디터 자신의 뷰포트 품질 설정과 같은 자리다. 에디터가 종료할 때 자기 값으로 덮어쓴다. 그래서 다시 켜면 에디터 품질이 읽힌다.
패키징 빌드는 GIsEditor 가 false 라 게임 전용 ini 로 가고, 덮어쓸 주체도 없다. 버그가 아니라 개발 환경의 부작용이었다.
여기서 우회 코드를 넣지 않은 게 중요하다. 출시 빌드에 불필요한 경로를 남기는 대신, 확인 항목으로 미뤘다.
sg.ResolutionQuality=71 도 알아둘 만하다. Set Overall Scalability Level 은 렌더 해상도 배율까지 같이 내린다. 「낮음」을 고르면 창 크기는 그대로인데 화면이 흐려진다. 품질 프리셋의 정상 동작이지만, 모르면 버그로 보인다.
operator== 가 결정한다. 비교하지 않는 필드는 바뀌어도 갱신되지 않는다. 표시에 쓰이는 필드는 반드시 비교에 넣어야 한다.true 는 1 이다. 에러도 경고도 없다.SetSelectedIndex 는 범위 밖 인덱스를 조용히 무시한다. 실패가 안 보이는 API 는 로그로 되읽어서 확인해야 한다.Get Screen Resolution 을 세 번 부르고 있었다.
| 팀프로젝트 15. 도구 표시 정보 데이터 에셋화 (0) | 2026.08.25 |
|---|---|
| 팀프로젝트 14. 화면 동결과 공용 피드백 레이어 (0) | 2026.08.24 |
| 팀프로젝트 12. 환경설정 화면 구현 (0) | 2026.08.19 |
| 팀프로젝트 11. ViewModel 기반 클래스를 생성 (0) | 2026.08.18 |
| 팀프로젝트 10. 환경설정 화면과 인게임 ESC 메뉴 설계 (0) | 2026.08.14 |