상세 컨텐츠

본문 제목

팀프로젝트 13. 체온 게이지와 해상도 표시 버그

Unreal C++

by hyunjunstar 2026. 8. 21. 20:47

본문

이번에는 버그 두 개를 잡았다. 성격이 전혀 다른 버그인데 막힌 이유가 똑같았다.

둘 다 화면에 틀렸지만 그럴듯한 값이 나왔다. 그래서 엉뚱한 곳을 한참 팠다.

증상 처음 의심한 것 실제 원인
호스트 화면에서 다른 플레이어 체온이 호스트와 똑같이 오른다 체온 리플리케이션 operator== 가 PlayerState 를 비교하지 않아 MVVM 이 갱신을 안 함
해상도를 바꾸고 환경설정을 다시 열면 다른 값이 떠 있다 Save Settings 누락 Bool 이 Int 핀에 꽂혀 true 가 1 로 변환됨

1. 체온 게이지 — 동기화 문제가 아니었다

1.1 증상

인게임 HUD 왼쪽에 플레이어별 체온 게이지가 있다.

좌측 하단   내 체온
좌측 중단   다른 플레이어들의 체온

호스트 화면에서 내가 작업하면 다른 플레이어 게이지도 같이 올랐다. 그것도 「비슷하게」가 아니라 정확히 똑같이 올랐다.

클라이언트 화면은 정상이었다.

1.2 첫 번째 오진 — 리플리케이션

「동기화가 안 된다」로 읽고 체온 컴포넌트를 먼저 봤다. 그런데 이미 복제되고 있었다.

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Temperature")
float CurrentTemperature = 36.5f;

리플리케이션 문제가 아니었다. 그러면 위젯이 누구를 읽는지가 문제다.

1.3 똑같이가 단서였다

여기서 중요한 걸 놓치고 있었다. 값이 비슷하게 오르는 것과 똑같이 오르는 것은 전혀 다른 증상이다.

비슷하게 오른다   → 복제 지연. 각자 다른 값을 읽고 있다
똑같이 오른다     → 같은 하나를 읽고 있다

복제된 값은 절대 완전히 일치하지 않는다. 한 박자씩 늦는다. 완전히 일치하면 원본을 공유하는 것이다.

1.4 3단계 로그로 전달 경로 확인

체온 게이지는 이렇게 값을 받는다.

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

전달은 완벽했다. 그러면 받은 뒤에 문제가 생기는 것이다.

1.5 세터가 아니라 게터를 봐야 했다

여기서 시간을 꽤 버렸다. 세터에 로그를 찍고 「정상」을 확인하고 만족했는데, 실제로 화면에 그려지는 값은 게터가 만든다.

프로그레스바의 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 가 무효라서 위젯 소유자(호스트)의 폰을 읽고 있었다. 두 게이지가 같은 컴포넌트를 보니 값이 똑같이 움직였다.

1.6 실제 원인 operator== 가 비교하지 않은 한 줄

받을 때는 유효했는데 읽을 때는 무효였다. 그 사이에 무슨 일이 있었나.

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 를 일부러 비교에서 뺐다. 「표시 정보만 비교한다」는 의도였는데, 그 필드가 표시에 쓰이기 시작한 순간 전제가 깨졌다.

1.7 왜 호스트에서만

위젯 생성 시점과 PlayerState 교체 시점의 순서가 다르다.

호스트    Travel 이 진행되는 동안 이미 위젯이 살아 있다
          → 교체 전 포인터를 잡는다
참가자    접속과 복제가 끝난 뒤 위젯이 만들어진다
          → 교체 후 포인터를 잡는다

리슨 서버에서 호스트만 증상이 나는 버그를 이 프로젝트에서 벌써 세 번째 겪었다. 한쪽 화면에서만 재현되면 타이밍 차이를 먼저 의심하는 게 맞다.

1.8 두 가지를 같이 수정

실제 수정은 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 반환

1.9 폴백이 진단을 방해했다

이 버그에서 제일 오래 걸린 부분이 폴백 때문에 증상이 그럴듯해 보인 것이다.

폴백을 막은 순간 원격 게이지가 0 이 됐다. 고쳐진 건 아니었지만 「틀린 값」이 「빈 값」으로 바뀌었다. 그때부터 원인이 보였다.

「값이 없을 때 대신 뭘 보여줄까」는 편의 기능인데, 잘못된 대상을 보여주면 그건 편의가 아니라 은폐다.

2. 해상도 표시 — 저장은 처음부터 정상이었다

2.1 증상

환경설정에서 해상도를 바꾸고, 닫고, 다시 열면 엉뚱한 값이 떠 있었다. 그것도 항상 1152 x 864 로 똑같았다.

메인메뉴와 인게임 양쪽에서 동일했다.

2.2 먼저 ini 를 봤다

「저장이 안 되나」로 의심했는데, 파일을 열어보니 아니었다.

; Saved/Config/WindowsEditor/GameUserSettings.ini
ResolutionSizeX=1680
ResolutionSizeY=1050

고른 값이 정확히 들어 있었다. 적용도 저장도 정상이고, 표시만 틀린 것이다.

이 확인이 범위를 크게 줄였다. 「저장」과 「표시」는 다른 문제고, ini 를 먼저 보면 30초 만에 갈린다.

2.3 로그 다섯 개로 좁히기

목록을 만들고 선택값을 정하는 함수에 로그를 넣었다.

[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 이 안 들어가고 있었다.

2.4 원인 — Bool 이 Int 핀에 꽂혔다

그래프를 열어보니 이렇게 되어 있었다.

Find.Return Value (13)
→ >= 0            → true
→ Set Selected Index.Index      ← 여기

Index 핀에 Find 결과가 아니라 >= 0 비교 결과가 연결돼 있었다.

블루프린트는 Bool 을 Int 핀에 꽂으면 변환 노드를 조용히 끼워 넣는다. 컴파일 에러도 경고도 없다.

true  → 1
false → 0

SelectedIndex=1 은 정확히 그 값이었다. 우연이 아니었다.

그리고 왜 하필 1152 x 864 였는지도 설명된다. 지원 해상도 목록의 두 번째 항목이었을 뿐이다. 특별한 의미가 없었다.

2.5 이 실수가 안 보이는 이유

두 가지가 겹쳐서 조용히 넘어갔다.

첫째, 변환이 자동이다. Bool 핀과 Int 핀은 색이 다르지만, 연결하면 엔진이 알아서 붙여준다. 그래프를 훑어봐도 잘못이 눈에 안 띈다.

둘째, 콤보박스가 실패를 안 알려준다.

void UComboBoxString::SetSelectedIndex(const int32 Index)
{
    if (Options.IsValidIndex(Index))   // 범위 밖이면 그냥 아무 일도 안 한다
    { ... }
}

범위 밖 인덱스를 넣어도 에러도 경고도 없다. 이번에는 우연히 유효한 인덱스(1)라 조용히 잘못된 값이 선택됐다.

2.6 수정

Find.Return Value
├→ >= 0 → Branch.Condition           (그대로)
└→ Branch True 쪽 Set Selected Index.Index   ← 직접 연결

같은 출력 핀에서 선을 두 개 뽑으면 된다. Find 를 두 번 부를 필요 없다.

그리고 Get Screen Resolution 을 세 번 부르고 있던 것도 정리했다.

함수 진입 → CurrentResolution 에 저장     ← 여기서 한 번만
Find 의 Item 용으로 다시                  ← 제거
Add 용으로 또다시                         ← 제거

값이 갈릴 여지를 없애는 쪽이 맞다. 로컬 변수를 만들어둔 이유가 이거였는데 정작 안 쓰고 있었다.

2.7 검증

첫 번째 열기   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 가 없었다. 「환경설정을 여는 것만으로 품질이 바뀌지 않는다」가 확인된 것이다. 초기화 가드가 제대로 먹고 있었다.

3. 덤 — 에디터에서 품질이 유지되지 않는 이유

테스트하다가 이상한 걸 발견했다. 에디터를 완전히 껐다 켜면 그래픽 품질이 「중간」으로 돌아가 있었다.

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 은 렌더 해상도 배율까지 같이 내린다. 「낮음」을 고르면 창 크기는 그대로인데 화면이 흐려진다. 품질 프리셋의 정상 동작이지만, 모르면 버그로 보인다.

4. 정리

  1. 값이 「비슷하게」 다른 것과 「똑같이」 같은 것은 다른 증상이다. 복제된 값은 완전히 일치하지 않는다. 완전히 일치하면 원본을 공유하는 것이다.
  2. 폴백이 잘못된 대상을 보여주면 은폐다. 값이 없을 때는 빈 값을 보여줘야 한다. 이번에 폴백을 막은 순간 원인이 보였다.
  3. MVVM 배열의 갱신 여부는 operator== 가 결정한다. 비교하지 않는 필드는 바뀌어도 갱신되지 않는다. 표시에 쓰이는 필드는 반드시 비교에 넣어야 한다.
  4. 한쪽 화면에서만 재현되면 타이밍을 의심한다. 리슨 서버에서는 호스트와 참가자의 위젯 생성 시점이 다르다.
  5. 세터가 아니라 게터를 봐야 한다. 전달 경로가 정상이어도 화면에 그려지는 값은 게터가 만든다.
  6. 저장이 의심되면 ini 를 먼저 본다. 「저장 실패」와 「표시 오류」는 30초면 갈린다.
  7. 블루프린트는 Bool 을 Int 핀에 조용히 변환한다. true 는 1 이다. 에러도 경고도 없다.
  8. SetSelectedIndex 는 범위 밖 인덱스를 조용히 무시한다. 실패가 안 보이는 API 는 로그로 되읽어서 확인해야 한다.
  9. 같은 값을 두 번 읽지 말고 로컬 변수에 한 번 담는다. 이번에 Get Screen Resolution 을 세 번 부르고 있었다.
  10. 엔진 동작을 우회하는 코드는 넣지 않는다. 에디터에서 품질이 유지되지 않는 건 확인 항목으로 미루는 게 맞다.

 

관련글 더보기