TIL

[Unreal Engine 멀티플레이] 게임 사이클과 복제 최적화

think95592 2026. 8. 6. 19:19



멀티플레이 게임의 전체 흐름은 클라이언트 UI가 아니라
서버 상태 머신이 결정한다.

Lobby → Countdown → Playing → Ending → Returning → Lobby

클라이언트는 버튼을 누르거나 화면을 표시할 뿐이고, 실제 참가·시작·승패·종료·복귀 판정은 서버가 책임진다.

1. 게임 사이클의 상태 책임

클래스책임

GameMode 서버 전용. 상태 전이, 참가자 관리, 시작 조건, 승패 판정, 종료 처리
GameState 모두가 봐야 하는 현재 경기 상태를 복제. 현재 단계, 종료 시각, 생존자 수, 최종 결과
PlayerState 플레이어별 지속 상태. 생존/탈락/관전자 여부, 등수, 팀
PlayerController 각 플레이어의 입력과 개인 UI. Server RPC 요청 창구, Client RPC 수신 대상
UMG/UI 로컬 표현 계층. 게임 규칙이나 승패를 결정하지 않음

핵심은 다음과 같다.

서버가 원본 상태를 바꾸고, GameStatePlayerState가 그 결과를 복제하며, 각 클라이언트 UI는 복제값을 화면에 표현한다.

Dedicated Server에는 UI를 만들지 않는다. UI는 각 클라이언트의 로컬 PlayerController가 생성한다.

2. 접속 이벤트별 책임

이벤트 역할
PreLogin PlayerController 생성 전 접속 허용 여부 검사. 정원 초과, 차단 등은 ErrorMessage로 거부
Login 서버가 PlayerController를 생성하는 단계
PostLogin 접속 성공 후 참가자 등록, 역할 배정, 관전자/다음 라운드 대기 처리
PostNetInit 복제 액터의 초기 네트워크 상태가 준비된 뒤 필요한 초기화 확인. 로컬 UI 작업은 로컬 클라이언트인지 별도 확인
Logout 이탈자를 참가자·생존자 목록에서 제거하고 종료 조건 재검사

특히 Logout은 단순한 목록 삭제가 아니다. 플레이어가 나가면 “남은 생존자가 몇 명인가?”가 바뀌므로, 서버는 즉시 승패·종료 조건을 다시 판단해야 한다.

Logout(B)
→ Registered와 Alive에서 B 제거
→ 종료 조건 재검사
→ A만 생존했다면 Ending 전이
→ 연결 중인 A에게 결과 Client RPC 전송

연결이 끊긴 B에게 RPC를 보내려 하면 안 된다. 이미 존재하지 않는 네트워크 연결이기 때문이다.

3. UI 전달 방식: 지속 상태와 개인 명령 구분

GameState 복제

현재 경기 단계, 종료 카운트다운, 생존자 수, 최종 순위처럼 늦게 접속한 사람도 알아야 하는 값은 GameState에 복제한다.

서버: Phase를 Playing → Ending으로 변경
→ GameState::Phase 복제
→ Client A/B의 OnRep_Phase 실행
→ 각자 로컬 UI 갱신

Client RPC

“이 플레이어에게 지금 결과창을 열어라”처럼 개인에게 한 번 전달하는 명령은 해당 PlayerController의 Client RPC가 적합하다.

서버 GameMode
→ 승자 PlayerController의 Client RPC
→ 승자 클라이언트에서 개인 결과 UI 표시

둘은 함께 쓸 수 있다.

  • GameState: 지금 게임이 Ending이라는 공용 사실
  • Client RPC: 특정 플레이어에게 결과 UI를 열라는 개인 명령

4. 중간 접속자 정책

Playing 중에도 접속을 허용할 수 있다. 이 경우 PostLogin에서 새 플레이어를 생존자가 아니라 관전자 또는 다음 라운드 대기자로 등록해야 한다.

PostLogin(NewPlayer)
→ 현재 Phase 확인
→ Playing이면 Spectator로 등록
→ Alive 목록에는 넣지 않음
→ GameState의 현재 복제값으로 UI 초기화

중간 접속자가 과거의 Multicast RPC를 받지 못할 수 있으므로, 현재 경기 상태를 과거 RPC에만 의존하면 안 된다. GameState의 현재 복제값으로 UI를 초기화해야 한다.

5. 복제 최적화: 하나의 네트워크 예산 문제

복제 최적화는 “모든 복제를 줄이자”가 아니다.

필요한 사람에게, 필요한 상태만, 필요한 빈도로 보내서 서버의 네트워크 예산을 지키는 설계다.

항목 서버가 답하는 질문 예시
Relevancy 이 클라이언트에게 이 액터가 필요한가? 멀리 있는 아이템은 보내지 않음
Dormancy 이 액터가 당분간 안 바뀌는가? 닫힌 문은 반복 전송 중단
NetUpdateFrequency 얼마나 자주 갱신 후보로 볼 것인가? 플레이어는 자주, 문은 적게
NetPriority 혼잡할 때 무엇부터 보낼 것인가? 플레이어 이동을 장식물보다 우선
// 플레이어: 초당 약 30번 갱신 후보
NetUpdateFrequency = 30.0f;

// 문: 초당 약 2번 갱신 후보
NetUpdateFrequency = 2.0f;

NetUpdateFrequency = 30.0f는 “3초에 한 번”이 아니라 초당 약 30번, 즉 약 0.033초 간격으로 갱신 후보가 된다는 의미다. 실제 전송은 관련성, 휴면 상태, 네트워크 예산에 따라 더 늦어질 수 있다.

6. 문·아이템·플레이어 정책

액터 정책
근처 플레이어에게만 관련성 유지. 열고 닫는 동안 상태 복제, 멈추면 휴면
드롭 아이템 가까운 플레이어에게만 전송. 바닥에 멈추면 낮은 빈도 또는 휴면
플레이어 이동·전투가 중요하므로 문보다 높은 갱신 빈도와 우선순위
개인 인벤토리 소유자에게만 조건 복제 검토
공용 점수·경기 단계 GameState에 필요한 값만 복제

문이 닫힌 상태로 오래 멈췄다면 서버는 휴면시킬 수 있다.

SetNetDormancy(DORM_DormantAll);

하지만 휴면 중인 문을 다시 열 때는 서버가 먼저 깨워야 한다.

FlushNetDormancy();
bIsOpen = true;
ForceNetUpdate();

FlushNetDormancy() 없이 상태만 변경하면, 서버 로그에는 열린 것으로 보이지만 클라이언트는 계속 닫힌 문을 볼 수 있다.

NetPriority를 높이는 것만으로는 이 문제를 해결할 수 없다. 휴면 또는 관련성 단계에서 이미 제외된 액터는 우선순위를 비교하는 단계까지 가지 못하기 때문이다.

7. 디버그 로그 예시

[Server] Logout: B removed from Alive. AliveCount 2 -> 1
[Server] EndCheck: AliveCount=1, Winner=A, Phase -> Ending
[Server] Door: FlushDormancy, bIsOpen=true
[Client B] OnRep_DoorState: Open

로그에는 최소한 다음을 남기는 것이 좋다.

  • 어떤 서버 상태가 바뀌었는가
  • 누가 참가/이탈했는가
  • 생존자 수와 현재 Phase는 무엇인가
  • 휴면을 깼는가
  • 클라이언트가 복제된 상태를 실제로 받았는가

정리

멀티플레이 구조를 설계할 때는 항상 다음 순서로 생각한다.

  1. 서버가 결정해야 하는 상태인가?
  2. 모두가 현재도 알아야 하는 지속 상태인가?
  3. 특정 한 명에게만 전달할 명령인가?
  4. 늦게 접속한 사람도 현재 상태를 알아야 하는가?
  5. 이 액터와 값은 누구에게, 얼마나 자주 보내야 하는가?

게임 사이클은 서버 상태 머신으로 지키고, 복제 최적화는 그 상태를 필요한 대상에게 필요한 비용으로 전달하는 일이다.