
멀티플레이 게임의 전체 흐름은 클라이언트 UI가 아니라 서버 상태 머신이 결정한다.
Lobby → Countdown → Playing → Ending → Returning → Lobby
클라이언트는 버튼을 누르거나 화면을 표시할 뿐이고, 실제 참가·시작·승패·종료·복귀 판정은 서버가 책임진다.
1. 게임 사이클의 상태 책임
클래스책임
| GameMode | 서버 전용. 상태 전이, 참가자 관리, 시작 조건, 승패 판정, 종료 처리 |
| GameState | 모두가 봐야 하는 현재 경기 상태를 복제. 현재 단계, 종료 시각, 생존자 수, 최종 결과 |
| PlayerState | 플레이어별 지속 상태. 생존/탈락/관전자 여부, 등수, 팀 |
| PlayerController | 각 플레이어의 입력과 개인 UI. Server RPC 요청 창구, Client RPC 수신 대상 |
| UMG/UI | 로컬 표현 계층. 게임 규칙이나 승패를 결정하지 않음 |
핵심은 다음과 같다.
서버가 원본 상태를 바꾸고, GameState와 PlayerState가 그 결과를 복제하며, 각 클라이언트 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는 무엇인가
- 휴면을 깼는가
- 클라이언트가 복제된 상태를 실제로 받았는가
정리
멀티플레이 구조를 설계할 때는 항상 다음 순서로 생각한다.
- 서버가 결정해야 하는 상태인가?
- 모두가 현재도 알아야 하는 지속 상태인가?
- 특정 한 명에게만 전달할 명령인가?
- 늦게 접속한 사람도 현재 상태를 알아야 하는가?
- 이 액터와 값은 누구에게, 얼마나 자주 보내야 하는가?
게임 사이클은 서버 상태 머신으로 지키고, 복제 최적화는 그 상태를 필요한 대상에게 필요한 비용으로 전달하는 일이다.
'TIL' 카테고리의 다른 글
| [Unreal Engine 멀티플레이] 멀티플레이 핵심 개념 통합 복습 (1) | 2026.08.10 |
|---|---|
| [Unreal Engine 멀티플레이] 패키징과 Dedicated Server 배포 준비 (0) | 2026.08.07 |
| [Unreal Engine 멀티플레이]전투·애니메이션·컴포넌트 동기화 (0) | 2026.08.05 |
| [Unreal Engine 멀티플레이] 남은 시간이 아닌 마감 시각을 복제하는 서버 권위 타이머 (0) | 2026.08.04 |
| [Unreal Engine 멀티플레이] 서버 권위 기반 숫자 야구 구현 (0) | 2026.08.03 |