상세 컨텐츠

본문 제목

팀프로젝트 09. 클리어 후 방 복귀와 연결이 끊긴 뒤의 정리

Unreal C++

by hyunjunstar 2026. 8. 13. 22:06

본문

지난 글에서 클리어 후 Result 화면까지 붙였다. 이번에는 거기서 방 대기실로 돌아가는 흐름을 만들고, 호스트가 방을 종료했을 때 남는 것들을 정리했다. 마지막으로 체온 게이지를 플레이어 슬롯에 붙였다.

주제 문제 해결
방 복귀 어디에 ServerTravel을 둘 것인가 게임 진행 이동은 GameMode
seamless travel 생성자 한 줄이 방 생성을 깨뜨림 호출 직전에만 켜기
연결 종료 EOS 로비가 살아남아 빈 방이 노출 NetworkFailure에서 정리
체온 게이지 매 틱 바뀌는 값 밀어넣지 않고 당겨오기

1. 클리어 후 방으로 돌아가기

1.1 ServerTravel을 어디에 둘 것인가

클리어하면 호스트가 전원을 방 대기실로 데려가야 한다. 처음엔 UCWGameInstance에 넣으려 했다. 이미 맵 경로 설정들이 거기 있었기 때문이다.

그런데 방에서 인게임으로 가는 코드가 이미 있었다.

// ACWRoomGameMode::TryStartGame()
World->ServerTravel(GameplayMapPath);

반대 방향인데 다른 계층에 두면 짝이 안 맞는다. 정리해보니 이 프로젝트의 이동은 이렇게 갈리고 있었다.

세션 이벤트와 함께 일어나는 이동   →  UCWGameInstance
  방 생성 성공 → 방 이동
  세션 파기 성공 → 메뉴 이동

게임 진행에 따라 일어나는 이동     →  GameMode
  준비 완료 → 인게임   (ACWRoomGameMode)
  클리어 → 방          (ACWGameMode)

우연이 아니라 선이 그어져 있었다. 그래서 ACWGameMode에 넣었다.

UFUNCTION(BlueprintCallable, Category = "Crosswalk|Stage")
bool ReturnToRoom();

UPROPERTY(EditDefaultsOnly, Category = "Crosswalk|Stage")
TSoftObjectPtr<UWorld> RoomMap;

부수 효과가 하나 있었다. GameMode에 두니 TSoftObjectPtr<UWorld>를 쓸 수 있었다. GameInstance 쪽의 기존 경로들은 전부 FString인데, 문자열 경로는 에셋을 리네임하면 조용히 깨진다. 컴파일도 통과하고 에디터도 아무 말 안 하다가 런타임에 버튼이 안 먹는 걸로만 드러난다.

TSoftObjectPtr는 리다이렉터를 따라가고, 디테일 패널에서 에셋 선택기로 고른다. 오타로 경로가 틀릴 일 자체가 없다.

1.2 목적지 맵이 GameMode를 정한다

ServerTravel(RoomMap)을 부르면 방 대기실 GameMode로 알아서 바뀔 거라 생각했는데 아니었다.

ServerTravel은 맵만 바꾼다. 그 맵에서 어떤 GameMode가 뜰지는 맵의 World Settings가 정한다.

Dev_Hyunjun_Room.umap   → BP_TestRoomGameMode   ✅
Prototype.umap          → BP_TestInGameMode

다행히 방 맵에는 이미 지정돼 있었지만, RoomMap에 World Settings가 안 잡힌 맵을 넣으면 GlobalDefaultGameMode로 떨어져서 방 UI 없이 게임 맵에 서 있게 된다. C++만 봐서는 안 보이는 의존이라 프로퍼티 주석에 조건을 적어뒀다.

2. 생성자에 넣은 한 줄이 방 생성을 깨뜨렸다

2.1 증상 두 가지

방 → 인게임은 seamless travel을 쓰고 있었다. 왕복인데 한쪽만 seamless인 게 이상해서 ACWGameMode 생성자에 한 줄을 넣었다.

bUseSeamlessTravel = true;

빌드하고 테스트하니 두 가지가 동시에 터졌다.

1. 다른 플레이어가 방에 입장하지 못함
2. 닉네임이 "Player259" 로 표시됨

전혀 다른 증상이라 원인이 두 개인 줄 알았다.

2.2 ?listen은 seamless travel에서 무시된다

로그 첫 줄에 답이 있었다.

LogGameMode: ProcessServerTravel: /Game/CrossWalk/Maps/Dev/Dev_Hyunjun_Room?listen
LogWorld: SeamlessTravel to: /Game/CrossWalk/Maps/Dev/Dev_Hyunjun_Room

?listen이 붙은 travel이 seamless로 갔다. 원래 이 경로는 non-seamless였다.

원인은 상속 구조였다.

BP_TestOutGameMode (메뉴 맵)  → ACWGameMode 상속
BP_TestInGameMode             → ACWGameMode 상속

메뉴 맵의 GameMode도 같은 클래스를 상속하고 있었다. 그래서 방 생성 시 travel까지 seamless로 바뀌었다.

?listenUEngine::LoadMap 경로에서 UWorld::Listen()을 불러 리슨 서버 NetDriver를 만든다. seamless travel은 그 경로를 타지 않는다. 이미 있는 연결을 유지하는 기능이지 연결을 만드는 기능이 아니기 때문이다.

리슨 서버가 아예 안 뜬 것이었다. 로그가 그대로 말하고 있었다.

LogEOSP2P: Received connection invitation request for unknown socket. SocketId=[GameNetDriver]
LogEOSP2P: Removed peer with no connections.

접속하려는 쪽이 GameNetDriver 소켓을 찾는데 그런 소켓이 없다.

닉네임도 같은 뿌리였다. Listen()을 안 거치니 로컬 플레이어가 로그인 옵션(?Name=) 없이 스폰됐고, 그러면 엔진 기본값으로 떨어진다.

// AGameModeBase::InitNewPlayer
InName = FString::Printf(TEXT("%s%i"), *DefaultPlayerName.ToString(), PlayerState->GetPlayerId());
// → "Player" + 259

259는 PlayerId다. AGameMode::NextPlayerID가 256부터 시작한다.

2.3 켜는 위치를 바꿨다

생성자에서 빼고 ServerTravel 직전에만 켰다.

// 방 복귀는 이미 리슨 서버가 떠 있으므로 이 이동에만 seamless를 쓴다
bUseSeamlessTravel = true;

const bool bTravelStarted = World->ServerTravel(RoomMapPath);

GameMode 인스턴스는 맵과 함께 사라지므로 이 이동에만 적용되고 남지 않는다.

ACWRoomGameMode가 생성자에서 켜도 안전했던 건 방 → 인게임 시점에 이미 리슨 서버가 떠 있기 때문이다. ACWGameMode는 메뉴 맵까지 겸하고 있어서 같은 방식이 안 통했다.

?listen이 붙는 최초 travel은 절대 seamless면 안 된다.
연결이 이미 있는 상태의 travel에만 seamless를 쓴다.

3. 연결이 끊긴 뒤에 남는 것

3.1 EOS 로비는 오너가 나가도 살아남는다

호스트가 방을 종료하면 참가자 쪽에서 두 가지가 벌어졌다.

1. 엔진 템플릿 맵(/Engine/Maps/Templates/OpenWorld)으로 떨어짐
2. 서버 없는 방이 목록에 계속 노출되고 입장하면 실패

두 번째가 이해가 안 갔다. 호스트가 DestroySession을 불렀는데 왜 방이 남지.

방 목록의 1/4 가 단서였다. 2인 세션이었으니 검색 캐시 잔재라면 2/4로 보여야 한다. 1명은 실제 현재 멤버 수였다.

호스트 DestroySession
  → 참가자의 UE 연결 끊김
  → 참가자의 EOS 로비 멤버십은 그대로 남음
  → 오너가 떠났으니 오너십이 참가자에게 이전
  → 로비는 살아 있고 뒤에 서버만 없음

EOS 로비와 UE 리슨 서버는 별개다. 튕겨나간 참가자가 아직 로비 멤버로 잡혀 있었고, 아무도 뒷정리를 하지 않았다. 프로젝트 전체를 검색해보니 NetworkFailure 처리가 한 줄도 없었다.

3.2 NetworkFailure를 GameInstance에서 받는 이유

정리해야 하는 쪽은 참가자다. 그런데 참가자는 클라이언트라서 GameMode가 존재하지 않는다. AGameModeBase는 서버에만 생성된다.

거기다 연결이 끊기면 엔진이 곧바로 다른 맵으로 이동시켜서 월드와 액터가 전부 파괴된다. DestroySession은 EOS 왕복이 필요한 비동기 작업이라, 호출한 객체가 응답을 받을 때까지 살아 있어야 한다.

GameMode / GameState / PlayerController   월드와 함께 파괴됨
UGameInstance                             맵 이동을 넘어 생존

조건을 만족하는 건 GameInstance 계열뿐이었다.

void UCWGameInstance::HandleNetworkFailure(...)
{
    // ServerConnection이 있는 인스턴스만 참가자다.
    const bool bIsClient =
        IsValid(NetDriver) && NetDriver->ServerConnection != nullptr;

    if (!bIsClient)
    {
        return;
    }

    // UE 연결이 끊겨도 EOS 로비 멤버십은 남을 수 있다.
    DestroyRoom();
}

맵 이동은 넣지 않았다. 엔진이 이미 GameDefaultMap으로 보낸다. 둘이 겹치면 travel이 두 번 걸린다. 우리는 로비 정리만 하고 이동은 엔진에 맡기면 역할이 하나씩으로 나뉜다.

GameDefaultMap이 엔진 템플릿으로 되어 있던 게 첫 번째 증상의 원인이었다. Title 맵으로 바꾸니 기존에 만들어둔 「로그인 상태면 MainMenu로 자동 전환」이 이어받아 메뉴까지 도달했다.

3.3 PIE에서 이 델리게이트는 전역이다

여기에 함정이 하나 더 있었다.

GEngine->OnNetworkFailure()

엔진 싱글턴의 델리게이트다. 2인 PIE면 GameInstance가 둘인데 둘 다 같은 델리게이트에 구독된다. 참가자 창에서 연결이 끊기면 호스트 창의 핸들러도 호출되고, 넘어오는 NetDriver는 실패한 쪽(참가자)의 것이라 ServerConnection 검사를 그대로 통과한다.

호스트가 자기 방을 파기하게 된다.

// PIE에서는 모든 GameInstance가 엔진의 전역 델리게이트를 구독한다.
// 다른 PIE 창에서 발생한 네트워크 실패는 처리하지 않는다.
if (!IsValid(World) || World->GetGameInstance() != this)
{
    return;
}

패키징 빌드는 프로세스당 GameInstance가 하나라 재현되지 않는다. 테스트는 전부 PIE에서 하므로 실제로는 이쪽이 위험하다. 나중에 누가 「불필요한 방어 코드」로 지울 여지가 있어서 주석에 이유를 적어뒀다.

이전에 EOS 온라인 서브시스템이 PIE 월드마다 따로 만들어져서 겪은 문제와 같은 계열이다. PIE는 한 프로세스에서 여러 클라이언트를 흉내 내기 때문에 전역 상태가 겹친다.

4. 이동 명령을 보내지 않은 이유

「호스트가 나가면 전원을 로비로」를 만들 때, 참가자 전원에게 이동 명령 RPC를 보내는 방식을 먼저 검토했다.

호스트가 방 종료 클릭
→ 참가자 전원에게 이동 명령
→ EOS 세션 삭제
→ 호스트도 이동

순서는 맞는데 여기에 함정이 있었다. 참가자를 끊는 건 DestroySession이 아니라 호스트의 ClientTravel이다. EOS 로비와 UE 리슨 서버는 별개라 세션을 지워도 연결은 살아 있고, 호스트가 자기 서버를 떠나는 순간 끊긴다.

그래서 Reliable RPC를 보내도 참가자가 그걸 처리하기 전에 호스트가 떠나면 패킷이 날아간다. 순서만으로는 해결이 안 되고 호스트가 연결이 다 끊길 때까지 기다려야 한다.

비교해보니 결론이 명확했다.

  NetworkFailure Client RPC
타이밍 레이스 없음 호스트가 먼저 떠나면 유실
호스트 크래시 같은 경로로 처리 처리 안 됨
네트워크 끊김 같은 경로로 처리 처리 안 됨
안내 문구 없음 넣을 자리 있음

RPC는 호스트가 정상적으로 버튼을 눌렀을 때만 동작한다. 크래시하거나 랜선이 빠지면 참가자는 다시 고립되고 좀비 방이 남는다.

NetworkFailure는 연결이 끊긴 모든 경우를 한 경로로 처리한다. 참가자 입장에서는 버튼을 눌렀든 크래시했든 같은 사건이니까.

RPC는 나중에 안내 문구를 띄우기 위해 추가할 것이다. 이동 자체는 계속 NetworkFailure가 담당하는 게 맞다.

5. 참가자에게만 없던 값

5.1 대입이 한 군데뿐이었다

대기실에서 호스트에게만 참여 코드가 뜨고 참가자 화면에는 빈칸이었다.

CurrentRoomCode를 대입하는 곳을 전부 찾아봤다.

CWGameInstance.cpp:496   CurrentRoomCode = RoomCode;   ← 방 생성 경로에만 존재
OnJoinSessionComplete()                                ← 대입 없음

참가자는 처음부터 코드를 가진 적이 없었다. 방 이동 때문에 생긴 문제가 아니라 원래 없던 것을 이제 발견한 것이었다.

게다가 방 목록으로 입장하면 임시 저장 변수조차 비어 있었다. 코드 입력 경로에서만 채우고 있었기 때문이다.

호스트가 방 코드를 세션 설정에 실어 광고하고 있으니, 참여한 세션에서 그대로 읽으면 두 입장 경로를 모두 커버할 수 있었다.

FString JoinedRoomCode;

if (FNamedOnlineSession* NamedSession = SessionInterface->GetNamedSession(SessionName))
{
    NamedSession->SessionSettings.Get(CrosswalkSession::RoomCodeKey, JoinedRoomCode);
}

5.2 순서가 결과를 바꾼다

여기서 한 줄의 위치가 중요했다.

CurrentRoomCode = JoinedRoomCode;

// CurrentRoomCode를 채운 뒤에 알려야 ViewModel이 빈 값을 읽지 않는다
CompleteSessionOperation(ECWSessionOperation::JoinRoom, true, ECWSessionFailure::None);

CompleteSessionOperation이 완료 이벤트를 브로드캐스트하고, ViewModel이 그 안에서 값을 읽어간다.

CurrentRoomCode 대입           ← 반드시 먼저
CompleteSessionOperation       ← 여기서 ViewModel이 읽어감

반대로 두면 빈 문자열을 읽고 끝난다. 컴파일도 되고 로그도 정상이라 원인 찾기가 고약한 종류다.

6. 체온 게이지

6.1 게이지 방향

체온 컴포넌트에 필요한 값이 이미 전부 있었다.

/** 0 = 평상 체온, 1 = 게이지 끝. UI 채움과 머티리얼 스칼라에 그대로 쓴다. */
UFUNCTION(BlueprintPure, Category = "Temperature")
float GetHeatRatio() const;

기본 체온 36.5도에서 0을 반환한다. 그래서 처음엔 비어 있고 체온이 오르면 차오른다.

1 - GetHeatRatio()로 뒤집어서 체력바처럼 줄어드는 게이지로 만드는 안도 검토했는데, 주석에 이미 「UI 채움과 머티리얼 스칼라에 그대로 쓴다」고 적혀 있었다. 연출 쪽에서 캐릭터가 빨개지는 효과를 만들 때 같은 값을 쓸 테니, UI만 반대 방향으로 가면 값이 갈린다. 그대로 쓰기로 했다.

6.2 push하면 무슨 일이 생기나

처음 받은 제안은 ViewModel이 체온을 위젯에 밀어넣는 구조였다.

ViewModel → SetTemperature(HeatRatio) → ProgressBar

체온은 작업 중 매 틱 변한다. 이 구조면 플레이어 목록 배열이 계속 바뀌고, FieldNotify가 초당 수십 번 발화하고, 슬롯 갱신 함수가 그때마다 Entry를 다시 만든다.

이전에 FText 비교를 IdenticalTo로 하는 바람에 겪었던 브로드캐스트 폭풍과 같은 모양이다. 게다가 Entry가 매 프레임 파괴·재생성되면 나중에 애니메이션이나 클릭을 붙일 수도 없다.

Binding으로 당겨오면 그런 게 없다.

ProgressBar Percent Binding
→ TemperatureComponent → GetHeatRatio()

ViewModel을 아예 거치지 않고, 구독·해제 관리도 없다.

자주 바뀌는 값은 Binding으로 당겨온다 (체온, 패링 쿨타임)
드물게 바뀌는 값은 ViewModel로 밀어넣는다 (이름, 준비 상태)

같은 위젯 안에서도 데이터 성격에 따라 방식이 갈린다.

6.3 표현을 갈아끼울 수 있게 분리

게이지를 WBP_PlayerStatusEntry에 직접 넣지 않고 별도 위젯으로 뺐다.

WBP_PlayerStatusEntry   "이 플레이어"만 알려줌. 체온은 모름
WBP_TemperatureGauge    PlayerState로 알아서 찾아 읽음

Entry는 본인 슬롯과 원격 슬롯이 같이 쓰는 위젯이라, 표현을 바꿀 때마다 공용 에셋을 건드리는 게 부담이다. 분리해두면 막대에서 원형으로 바꾸든 아이콘으로 바꾸든 게이지 하나만 열면 된다.

인터페이스도 신경 썼다. Entry가 컴포넌트를 찾아서 넘기면 Entry가 여전히 체온을 알게 되므로, PlayerState만 넘기고 조회는 게이지가 하게 했다.

IsValid(PlayerState) ?
  참    → PlayerState → Get Pawn          (원격 슬롯)
  거짓  → Get Owning Player Pawn          (본인 슬롯)
→ Cast to CWPlayerCharacter
→ TemperatureComponent → GetHeatRatio()

컴포넌트는 캐싱하지 않았다. 매 프레임 다시 찾아야 폰이 늦게 생기거나 리스폰·맵 이동으로 교체돼도 스스로 복구된다. 위젯 네 개가 프레임당 한 번씩 Cast하는 건 비용이 아니다.

원격 플레이어 체온이 보이는 건 복제에 조건이 없기 때문이다.

DOREPLIFETIME(UCWTemperatureComponent, CurrentTemperature);
DOREPLIFETIME(UCWTemperatureComponent, bResting);

7. 정리

  1. ?listen이 붙는 최초 travel은 seamless면 안 된다. seamless travel은 이미 있는 연결을 유지하는 기능이지 연결을 만드는 기능이 아니다. 리슨 서버가 안 뜨고, 그러면 입장 불가와 닉네임 폴백이 동시에 일어난다.
  2. 상속 관계를 확인하고 기본값을 바꾼다. 메뉴 맵 GameMode가 같은 클래스를 상속하고 있었다. 생성자 한 줄이 의도한 곳 말고 다른 곳까지 바꿨다.
  3. ServerTravel은 맵만 바꾼다. 목적지에서 어떤 GameMode가 뜰지는 그 맵의 World Settings가 정한다. 코드만 봐서는 안 보이는 의존이므로 주석으로 남긴다.
  4. 연결이 끊겼을 때 정리할 주체는 GameInstance뿐이다. 클라이언트에는 GameMode가 없고, 나머지는 월드와 함께 파괴된다. 비동기 정리 작업은 맵 이동을 넘어 살아남는 객체가 호출해야 한다.
  5. PIE에서 엔진 전역 델리게이트는 모든 GameInstance가 함께 받는다. 월드 소유 검사가 없으면 다른 창의 사건을 대신 처리한다. 패키징 빌드에서는 재현되지 않아 더 위험하다.
  6. 정상 경로만 처리하는 설계는 크래시를 못 막는다. 버튼을 눌렀을 때만 동작하는 RPC보다, 연결이 끊긴 모든 경우를 한 경로로 처리하는 쪽이 견고하다.
  7. 자주 바뀌는 값은 당겨오고 드물게 바뀌는 값은 밀어넣는다. 매 틱 변하는 값을 이벤트로 밀어넣으면 배열 전체가 재발행되고 위젯이 계속 재생성된다. 같은 화면 안에서도 데이터 성격에 따라 방식이 갈린다.

관련글 더보기