상세 컨텐츠

본문 제목

팀프로젝트 06. 리슨 서버 대기실 만들면서 마주친 문제

Unreal C++

by hyunjunstar 2026. 8. 10. 23:50

본문

이번엔 방 생성 → 대기실 → 인게임 흐름을 만들었음.

EOS 세션으로 방을 만들고 코드나 공개 목록으로 참가하는 것까지는 앞에서 끝냈고, 이번 작업은 그 다음 단계임. 참가한 사람들이 모여서 준비를 누르고, 방장이 게임을 시작하면 다 같이 인게임 맵으로 넘어가는 구간.

기능만 보면 단순해 보이는데 서버와 클라이언트가 서로 다른 것을 보고 있다는 점 때문에 계속 걸렸음. 정리해 둠.

만든 것

Title
└ 로그인
  └ MainMenu → MultiplayerMenu
     ├ CreateRoom  ─┐
     ├ JoinRoom     ├→ Room (대기실)
     └ RoomList    ─┘     ├ 방 코드 표시
                          ├ 플레이어 목록 + 준비 상태
                          └ 방장이 게임 시작
                             └ ServerTravel → InGame

클래스 구성은 이렇게 나눴음.

클래스 역할
ACWRoomGameMode 게임 시작 요청 처리, 맵 이동
ACWRoomGameState 플레이어 목록 변경 이벤트, 시작 조건 판정
ACWPlayerState 준비 상태 복제
UCWRoomViewModel GameState를 구독해서 UI용 데이터로 변환
WBP_Room 화면

원칙은 앞 단계와 동일하게 복제 데이터 → ViewModel FieldNotify → Widget 으로 잡았음. 위젯이 GameState나 PlayerState를 직접 뒤지지 않게 함.

1. GameMode는 서버에만 있음

처음엔 게임 시작 조건 판정을 GameMode에 뒀음.

// ACWRoomGameMode
bool CanStartGame() const;   // 최소 인원 + 전원 준비 확인

동작은 함. 방장만 시작 버튼을 누르고, 방장은 곧 서버니까.

문제는 다음 단계에서 터짐. 대기실 화면에 "2명 이상 필요합니다" 같은 안내를 띄우려면 클라이언트도 같은 판정을 해야 하는데, 클라이언트에는 GameMode가 아예 없음.

World->GetAuthGameMode<ACWRoomGameMode>();   // 클라이언트에서는 항상 null

ViewModel은 서버·클라이언트 양쪽에서 도는 물건이라 여기서 HasAuthority() 분기가 생김. 계층도 넘나들게 됨.

OnRoomPlayersChanged (GameState) 구독
  → CanStartGame() (GameMode) 호출
     → 클라이언트면 null 이라 분기 필요

판정에 필요한 재료는 PlayerArray와 bIsReady인데 둘 다 이미 클라이언트에 복제됨. 그러면 GameState에서 계산하면 됨.

// ACWRoomGameState
UFUNCTION(BlueprintPure, Category = "CW|Room")
bool CanStartGame() const;

GameMode는 이걸 호출만 함.

if (!RoomGameState->CanStartGame())
{
    return false;
}

서버 전용 로직은 GameMode, 양쪽이 봐야 하는 상태는 GameState. 이 기준을 잡고 나니 나머지도 자리를 찾아감.

설정값은 GameMode에 두고 주입

MinimumPlayersToStart 같은 설정을 GameState에 EditDefaultsOnly로 두면 값을 바꾸려고 GameState BP를 따로 만들어야 함. GameMode BP와 설정이 두 군데로 갈림.

GameMode에 두고 InitGameState()에서 넘겨주는 쪽이 나았음.

void ACWRoomGameMode::InitGameState()
{
    Super::InitGameState();

    ACWRoomGameState* RoomGameState = GetGameState<ACWRoomGameState>();

    if (!IsValid(RoomGameState))
    {
        return;
    }

    // BP GameMode 의 최소 시작 인원을 복제되는 GameState 에 전달
    RoomGameState->SetMinimumPlayersToStart(MinimumPlayersToStart);
}

InitGameState()는 GameState 스폰 직후·복제 시작 전이라 타이밍이 정확함. BP는 GameMode 하나만 만들면 됨.

2. 리슨 서버는 자기 RepNotify를 못 받음

준비 버튼을 눌렀는데 클라이언트 화면만 바뀌고 호스트 화면은 그대로였음.

bIsReady를 ReplicatedUsing으로 걸어뒀으니 값이 바뀌면 OnRep_IsReady()가 불릴 거라고 생각했는데, RepNotify는 값을 받는 쪽에서만 호출됨. 서버는 자기가 값을 바꿨으니 복제받을 일이 없고, 따라서 콜백도 안 옴.

리슨 서버의 호스트는 서버이자 플레이어라서 여기서 걸림.

void ACWPlayerState::ServerSetReady_Implementation(bool bNewReady)
{
    if (bIsReady == bNewReady)
    {
        return;
    }

    bIsReady = bNewReady;

    // Listen Server 에서는 RepNotify 가 자동 호출되지 않으므로 직접 알림
    NotifyRoomPlayersChanged();

    ForceNetUpdate();
}

서버 경로에서 직접 한 번 불러주면 됨. 클라이언트는 OnRep_IsReady()가 같은 함수를 부름.

로컬 멀티 테스트에서만 드러나는 종류의 버그라, 혼자 PIE 한 창으로 확인했으면 못 잡았을 것 같음.

3. 플레이어 이름은 늦게 옴

목록에 이름이 빈칸으로 뜨는 경우가 있었음.

AGameStateBase::AddPlayerState()는 PlayerState 액터가 클라이언트에 복제되는 시점에 불림. 그런데 이름(PlayerNamePrivate)은 그보다 늦게 도착할 수 있음. 액터가 먼저 오고 프로퍼티가 나중에 오는 구조라서 그럼.

그래서 목록을 갱신하는 계기가 두 개 필요했음.

void ACWPlayerState::OnRep_PlayerName()
{
    Super::OnRep_PlayerName();

    // 이름 복제가 끝난 시점의 최신 플레이어 목록을 UI 에 알림
    NotifyRoomPlayersChanged();
}

AddPlayerState에서 한 번, 이름이 도착하면 한 번 더.

이름이 아직 없을 때를 위해 임시 표시도 넣어뒀음.

if (PlayerName.IsEmpty())
{
    PlayerInfo.PlayerName = FText::Format(
        NSLOCTEXT("CWRoomUI", "DefaultPlayerName", "Player {0}"),
        FText::AsNumber(NewRoomPlayers.Num() + 1)
    );
}

 

4. GameState가 위젯보다 늦게 올 수 있음

ViewModel이 Initialize()에서 GameState를 찾는 구조였는데, 클라이언트에서는 null이 나올 수 있음.

서버       GameState 스폰 → 위젯 생성        (순서 보장)
클라이언트  위젯 생성 → GameState 복제 도착   (역전 가능)

GetGameState()가 null이면 구독에 실패하고 목록이 영영 비어 있게 됨. Tick으로 폴링하는 방법도 있지만 지저분함.

UWorld에 이벤트가 있었음.

GameStateSetHandle = World->GameStateSetEvent.AddUObject(
    this, &UCWRoomViewModel::HandleGameStateSet);

// 이미 존재하면 즉시 구독하고 현재 상태까지 읽음
BindGameState(World->GetGameState<ACWRoomGameState>());

AGameStateBase::PostInitializeComponents()가 World->SetGameState()를 부르면서 이 이벤트가 발화함. 클라이언트에서 복제로 생성될 때도 마찬가지.

이벤트 구독 + 즉시 한 번 읽기를 같이 하니 어느 쪽이 먼저 오든 커버됨. BindGameState()에는 중복 방지를 넣어뒀음.

if (BoundGameState.Get() == InGameState)
{
    return;
}

 

5. PlayerArray 순서는 화면마다 다름

호스트 화면과 참가자 화면에서 플레이어 목록 순서가 반대로 나왔음.

AGameStateBase::PlayerArray는 복제 도착 순서대로 채워짐. 모든 클라이언트가 같은 순서를 본다는 보장이 없음. 복제 오류가 아니라 그냥 정렬 기준이 없는 상태였던 것.

처음엔 이름으로 정렬하려 했는데, 3번 문제와 겹침. 이름이 늦게 도착하면 Player 1, Player 2로 정렬됐다가 실제 이름이 오는 순간 순서가 다시 뒤집힘. 화면에서 목록이 튀는 걸로 보임.

APlayerState::GetPlayerId()를 씀. 서버가 접속 순서대로 부여하고 복제되는 값이라 모든 화면에서 동일함.

// 서버가 부여한 고유 번호. 화면 간 목록 순서를 맞추는 데 사용
UPROPERTY(BlueprintReadOnly, Category = "CW|Room")
int32 PlayerId = 0;
// PlayerArray 복제 순서는 화면마다 다르므로 서버가 부여한 번호로 정렬한다.
// 입장 순서가 그대로 표시 순서가 된다.
NewRoomPlayers.Sort(
    [](const FCWRoomPlayerInfo& Left, const FCWRoomPlayerInfo& Right)
    {
        return Left.PlayerId < Right.PlayerId;
    }
);

덤으로 방장이 항상 맨 위에 오게 됨. 먼저 들어왔으니까.

6. FText는 표시 문자열로 비교되지 않음

준비 인원 문구(준비 완료 1 / 2)를 ViewModel의 FText 프로퍼티로 뒀음. MVVM 매크로를 그대로 썼는데, 문구가 안 바뀌었는데도 계속 갱신 알림이 나갔음.

매크로 안을 열어보니 FText 전용 오버로드가 따로 있었음.

bool SetPropertyValue(FText& Value, const FText& NewValue, ...)
{
    if (Value.IdenticalTo(NewValue))   // EqualTo 가 아님
    {
        return false;
    }
    ...
}

IdenticalTo는 표시 문자열이 아니라 내부 데이터가 같은지를 봄. FText::Format은 호출할 때마다 새 히스토리를 만들기 때문에, 결과 문자열이 똑같아도 항상 다른 값으로 판정됨.

그래서 매크로를 우회했음.

void UCWRoomViewModel::SetReadyCountText(const FText& InText)
{
    // FText 는 내부 히스토리가 아니라 실제 표시 문자열로 비교
    if (ReadyCountText.EqualTo(InText))
    {
        return;
    }

    ReadyCountText = InText;

    UE_MVVM_BROADCAST_FIELD_VALUE_CHANGED(ReadyCountText);
}

사실 이 프로젝트에서 IdenticalTo에 걸린 게 두 번째임. 앞에서도 목표 항목 비교를 IdenticalTo로 했다가 같은 문구가 계속 다른 값으로 잡힌 적이 있었음.

FText 비교는 무조건 EqualTo 로 외워두는 게 나을 듯.

  기준 언제 씀
IdenticalTo 내부 데이터·로컬라이즈 정보까지 동일 거의 안 씀
EqualTo 표시 문자열 비교 대부분 이쪽

7. ServerTravel은 클라이언트를 끊었다가 다시 붙임

게임 시작을 눌렀더니 호스트만 인게임 맵으로 가고 참가자는 엉뚱한 맵으로 튕겼음.

로그를 보니 순서가 이랬음.

LogGameMode: ProcessServerTravel: /Game/.../Test
LogNet: UChannel::ReceivedSequencedBunch: Bunch.bClose == true      ← 클라이언트가 끊음
LogNet:  - Result=ControlChannelClose
LogEOSSDK: LogEOSP2P: Connection established.                        ← 즉시 재접속 시도
LogNet: NotifyAcceptingConnection: Server Dev_Hyunjun_Room refused   ← 거부
LogNet: NotifyAcceptingConnection: Server Dev_Hyunjun_Room refused
LogEngine: Server switch level: /Game/.../Test?listen                ← 이제야 전환

refused가 나오는 조건은 하나뿐이었음.

else if (NextURL != TEXT(""))
{
    // Server is switching levels.
    UE_LOG(LogNet, Log, TEXT("NotifyAcceptingConnection: Server %s refused"), *GetName());
    return EAcceptConnection::Ignore;
}

서버가 트래블 예약 상태일 때 들어온 접속은 무시함. 정리하면 이런 경합이었음.

1. 서버가 클라이언트에 ClientTravel 지시 → NextURL 설정
2. 클라이언트가 끊고 즉시 재접속       ← 맵 로드는 접속 후에 함
3. 서버는 아직 이전 맵 + NextURL 있음 → 거부
4. 서버가 NetDriver 파괴, P2P 소켓 닫음   ← 접속 시도 사망
5. 서버가 새 맵에서 NetDriver 생성
6. 클라이언트는 이미 실패 → 기본 맵으로 폴백

4번이 특히 문제였음. EOS P2P는 소켓을 명시적으로 닫아서 재시도할 대상 자체가 사라짐.

해결은 Seamless Travel. 연결을 유지한 채 맵만 교체함.

ACWRoomGameMode::ACWRoomGameMode()
{
    // 방에서 인게임으로 이동하는 동안 클라이언트 연결을 유지
    bUseSeamlessTravel = true;
    ...
}

로비에서 인게임으로 넘어갈 때 Seamless Travel을 쓰라는 말이 왜 나오는지 이제 이해됨. 그냥 권장이 아니라 끊고 붙이는 구간 자체를 없애는 것이 목적임.

8. PIE는 Seamless Travel을 기본으로 막음

bUseSeamlessTravel = true를 넣고 다시 돌렸는데 증상이 완전히 똑같았음. 코드가 안 먹은 줄 알고 한참 봤는데, 로그 첫 줄에 답이 있었음.

LogGameMode: Warning: ProcessServerTravel: Seamless travel is disabled in PIE,
set net.AllowPIESeamlessTravel=1 to enable.

엔진이 PIE에서는 seamless를 강제로 꺼버림.

#if WITH_EDITOR
    if (World->IsPlayInEditor() && bSeamless)
    {
        if (AllowPIESeamlessTravelCVar->GetInt() == 0)
        {
            UE_LOG(LogGameMode, Warning, TEXT("...set net.AllowPIESeamlessTravel=1 to enable."));
            bSeamless = false;      // ← 여기서 꺼짐
        }
    }
#endif

bSeamless가 false로 덮어써지니 이전과 정확히 같은 경로를 탄 것. 코드는 처음부터 맞았고 패키징 빌드에서는 그냥 됐을 것임.

에디터 콘솔에 이걸 치고 다시 돌리니 통과했음.

net.AllowPIESeamlessTravel 1
LogWorld: SeamlessTravel to: /Game/CrossWalk/Maps/Dev/Test
LogWorld: ----SeamlessTravel finished in 0.57 seconds ------

refused가 한 번도 안 나왔고 두 창 모두 인게임 맵에 들어갔음.

매번 치기 싫으면 Config/DefaultEngine.ini에 넣으면 됨.

[ConsoleVariables]
net.AllowPIESeamlessTravel=1

증상이 그대로면 "고친 게 안 먹은 것"이 아니라 "다른 데서 막힌 것"일 수 있음.
이번엔 경고 로그가 친절하게 알려줬는데, 스크롤을 위로 안 올렸으면 코드를 계속 의심했을 듯.

이름 하나 바꾼 사례

대기실 클래스를 처음엔 Lobby로 지었음. 게임에서 대기 화면은 보통 로비니까.

그런데 EOS도 세션을 Lobby라고 부름.

LogEOSSDK: LogEOSLobby: Created lobby socket.
LogEOSSDK: LogEOSLobby: Lobby search result with Id ... does not have a lobby owner

이러니까 "로비 문제"라고 하면 EOS 세션 얘긴지 우리 대기 화면 얘긴지 매번 물어봐야 했음. 팀원과 얘기할 때도 마찬가지고.

게다가 세션 쪽 용어는 이미 전부 Room이었음. RoomCode, RoomList, CreateRoom. 방을 만들어서 들어간 곳만 로비라고 부르는 셈.

그래서 전부 Room으로 통일했음.

ACWLobbyGameMode   → ACWRoomGameMode
ACWLobbyGameState  → ACWRoomGameState
ECWUIPage::Lobby   → ECWUIPage::Room
WBP_Lobby          → WBP_Room

ECWUIPage는 이미 Blueprint에서 쓰고 있어서 조심했는데, enum 자리를 유지한 채 이름만 바꾸면 안전함. Blueprint의 TMap 키와 프로퍼티 값은 인덱스로 저장되기 때문에 순서만 안 건드리면 값이 유지됨.

바꾸는 비용은 참조가 늘기 전이 제일 쌈. ViewModel과 위젯을 다 만든 뒤였으면 훨씬 귀찮았을 것.

정리

# 증상 원인 해결
1 클라이언트가 시작 조건을 못 읽음 GameMode는 서버 전용 판정을 GameState로 이동
2 호스트 화면만 준비 상태가 안 바뀜 RepNotify는 받는 쪽만 호출 서버 경로에서 직접 알림
3 목록에 이름이 빈칸 이름이 액터보다 늦게 복제됨 OnRep_PlayerName에서 재갱신
4 클라이언트 목록이 계속 비어 있음 GameState가 위젯보다 늦게 옴 GameStateSetEvent 구독 + 즉시 1회 읽기
5 화면마다 목록 순서가 다름 PlayerArray는 복제 순서 PlayerId로 정렬
6 같은 문구인데 계속 갱신 알림 MVVM이 IdenticalTo로 비교 EqualTo로 직접 비교
7 게임 시작 시 참가자만 튕김 트래블 중 재접속이 거부됨 bUseSeamlessTravel = true
8 seamless를 켰는데 증상 동일 PIE가 강제로 끔 net.AllowPIESeamlessTravel=1

이번에 얻은 건 두 가지임.

첫째, 서버와 클라이언트가 보는 세계가 다르다는 걸 코드 배치로 표현해야 함. 서버 전용 로직은 GameMode, 양쪽이 봐야 하는 상태는 GameState. 이 기준 하나로 1번이 풀렸고 이후 판단도 빨라졌음.

둘째, 복제는 순서를 보장하지 않음. 3·4·5번이 전부 같은 뿌리였음. 액터가 먼저 올지 프로퍼티가 먼저 올지, 어느 쪽이 먼저 도착할지 정해져 있지 않다는 것. 그래서 "언제 도착하든 결국 맞는 화면이 되는" 구조로 짜야 했음.

그리고 혼자 PIE 한 창으로는 절반도 못 잡았을 것 같음. 2·5·7번은 두 창을 동시에 봐야만 보이는 문제였음.

관련글 더보기