<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>언리얼엔진 게임개발</title>
    <link>https://uegamedev.tistory.com/</link>
    <description>uegamedev 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sat, 15 Aug 2026 12:55:24 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>김인철_</managingEditor>
    <item>
      <title>UE C++ 언리얼 패키징 관련 (+AWS)</title>
      <link>https://uegamedev.tistory.com/75</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;언리얼 전용 서버 배포: 소스 빌드, Client/Server 패키징, AWS EC2 운영&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터에서 멀티플레이가 동작하는 것과 인터넷에 전용 서버를 배포하는 것은 서로 다른 단계다. 로컬 빌드는 개발 코드를 실행 가능한 바이너리로 만들지만, 다른 컴퓨터에서 게임을 실행하려면 플랫폼에 맞게 변환한 에셋과 설정, 런타임 의존성까지 함께 전달해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 전용 서버 배포는 다음 파이프라인으로 이해할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;엔진 소스 준비
  -&amp;gt; 프로젝트를 소스 빌드 엔진에 연결
  -&amp;gt; Client / Server Target 정의
  -&amp;gt; 코드 Build
  -&amp;gt; 에셋 Cook
  -&amp;gt; 결과물 Stage / Package
  -&amp;gt; 로컬 전용 서버 테스트
  -&amp;gt; 서버 패키지를 EC2로 전송
  -&amp;gt; AWS Security Group과 Windows Firewall 개방
  -&amp;gt; 공인 IP와 UDP 포트로 클라이언트 접속
  -&amp;gt; 로그와 비용을 모니터링하고 자원 정리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;클라이언트 실행 파일과 서버 실행 파일이 같은 프로젝트에서 나오더라도 서로 다른 Target이며, 실행 파일만 복사해서는 패키지가 완성되지 않는다&lt;/b&gt;는 점이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Build, Cook, Stage, Package는 서로 다른 작업이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키징 로그에는 여러 단계가 연속으로 나타난다. 각 단계가 만드는 결과를 구분하면 오류가 코드 문제인지, 에셋 문제인지, 배포 경로 문제인지 빠르게 판단할 수 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;단계&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;처리 대상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;C++ 코드, 모듈, 플러그인&lt;/td&gt;
&lt;td&gt;선택한 Target과 Configuration에 맞는 실행 파일과 바이너리를 만든다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cook&lt;/td&gt;
&lt;td&gt;맵, Blueprint, 텍스처, 머티리얼, 사운드&lt;/td&gt;
&lt;td&gt;에디터용 에셋을 WindowsClient 또는 WindowsServer 같은 런타임 형식으로 변환한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage&lt;/td&gt;
&lt;td&gt;빌드된 코드와 쿠킹된 콘텐츠&lt;/td&gt;
&lt;td&gt;실행에 필요한 파일을 독립된 Staging 디렉터리에 모은다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;Staging 결과&lt;/td&gt;
&lt;td&gt;전달 가능한 실행 파일과 콘텐츠 컨테이너 묶음으로 만든다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;완성된 패키지&lt;/td&gt;
&lt;td&gt;EC2 같은 실행 대상에 업로드한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run&lt;/td&gt;
&lt;td&gt;배포된 패키지&lt;/td&gt;
&lt;td&gt;명령줄 인자를 적용해 서버나 클라이언트를 실행한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 &lt;code&gt;Binaries/Win64&lt;/code&gt;에 생성된 실행 파일은 Build 결과다. 이 파일만 다른 PC에 복사하면 쿠킹된 맵과 에셋이 없어서 정상적으로 실행되지 않는다. 배포 단위는 특정 &lt;code&gt;.exe&lt;/code&gt; 하나가 아니라 Staging 또는 Packaging 결과 폴더 전체다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전용 서버에 소스 빌드 엔진이 필요한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UE 5.5의 Epic Games Launcher 배포판으로 일반 Windows 게임을 패키징할 수는 있지만, 공식 전용 서버 구성 절차는 소스 빌드 엔진과 C++ 프로젝트를 전제로 한다. Launcher 엔진에서 Server Target을 패키징하면 다음과 같은 오류를 만날 수 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Server targets are not currently supported
from this engine distribution.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소스 빌드는 프로젝트 코드를 소스 형태로 만든다는 뜻이 아니다. GitHub에서 언리얼 엔진 소스 전체를 받아 직접 컴파일한 엔진 설치본을 사용하는 것이다. 이렇게 만든 엔진은 &lt;code&gt;TargetType.Server&lt;/code&gt;를 포함한 필요한 타깃을 직접 빌드할 수 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;엔진 배포 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;전용 서버 관점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epic Games Launcher 바이너리 엔진&lt;/td&gt;
&lt;td&gt;설치와 업데이트가 빠르고 일반 게임 개발에 편리하다.&lt;/td&gt;
&lt;td&gt;UE 5.5 공식 전용 서버 빌드 절차에는 적합하지 않다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub 소스 빌드 엔진&lt;/td&gt;
&lt;td&gt;엔진 코드 디버깅, 수정, 다양한 Target 빌드가 가능하다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TargetType.Server&lt;/code&gt; 기반 전용 서버 빌드에 사용한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소스 체크아웃, 중간 산출물, 바이너리, Derived Data Cache가 큰 공간을 사용하지만 모든 환경에 고정된 &lt;code&gt;500GB&lt;/code&gt;가 필수인 것은 아니다. 선택한 브랜치, 플랫폼 의존성, 디버그 심볼, 캐시 상태에 따라 사용량이 달라진다. 시작 전에 충분한 여유 공간을 확보하고, 빌드 중 남은 용량을 모니터링하는 것이 정확하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언리얼 엔진 소스를 준비하는 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Epic Games GitHub 저장소는 계정 연결이 필요하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Epic Games 계정과 GitHub 계정을 만든다.&lt;/li&gt;
&lt;li&gt;Epic Games 계정의 Connections에서 GitHub 계정을 연결한다.&lt;/li&gt;
&lt;li&gt;GitHub로 도착한 EpicGames 조직 초대를 기한 안에 수락한다.&lt;/li&gt;
&lt;li&gt;프로젝트와 같은 버전의 안정된 Release 브랜치 또는 태그를 선택한다.&lt;/li&gt;
&lt;li&gt;저장소를 Clone하거나 ZIP으로 내려받는다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Setup.bat&lt;/code&gt;을 실행해 엔진 바이너리 의존성과 필수 구성요소를 받는다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GenerateProjectFiles.bat&lt;/code&gt;으로 &lt;code&gt;UE5.sln&lt;/code&gt;을 생성한다.&lt;/li&gt;
&lt;li&gt;Visual Studio에서 &lt;code&gt;Development Editor&lt;/code&gt;, &lt;code&gt;Win64&lt;/code&gt;로 UE5 Target을 빌드한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Setup.bat
  -&amp;gt; 엔진이 사용하는 바이너리 의존성과 전제 조건 준비

GenerateProjectFiles.bat
  -&amp;gt; Target.cs와 Module 정보를 분석해 IDE 프로젝트 생성

UE5.sln Build
  -&amp;gt; 실제 엔진 및 에디터 바이너리 컴파일&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Setup.bat&lt;/code&gt;과 &lt;code&gt;GenerateProjectFiles.bat&lt;/code&gt;은 공식 절차상 일반 실행으로 충분하다. 폴더 권한 때문에 실제 오류가 발생한 경우가 아니라면 모든 빌드 도구를 관리자 권한으로 실행할 필요는 없다. 소스 경로는 &lt;code&gt;C:\UE55Src&lt;/code&gt;처럼 짧고 공백이나 특수문자가 적은 위치가 긴 경로 문제를 줄이는 데 유리하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZIP을 사용하면 Windows가 인터넷에서 받은 파일을 차단할 수 있다. 압축 해제 전에 ZIP 파일 속성에서 차단 해제를 확인하거나 Git Clone을 사용한다. 개발 도중 엔진 브랜치를 갱신했다면 &lt;code&gt;Setup.bat&lt;/code&gt;과 프로젝트 파일 생성을 다시 수행해야 할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트를 소스 빌드 엔진에 연결한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소스 엔진을 컴파일해도 기존 &lt;code&gt;.uproject&lt;/code&gt;가 자동으로 새 엔진을 사용하지는 않는다. 프로젝트의 Engine Association을 소스 빌드 엔진으로 변경하고 프로젝트 파일을 다시 생성해야 한다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;NumberBaseball.uproject
  -&amp;gt; Switch Unreal Engine Version
  -&amp;gt; 소스 빌드 엔진 선택
  -&amp;gt; Generate Visual Studio project files
  -&amp;gt; Development Editor / Win64 빌드&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔진 버전이 같더라도 Launcher 빌드와 소스 빌드는 서로 다른 Engine Association이다. 팀 프로젝트라면 어떤 엔진 커밋과 Build ID를 사용했는지 문서화해야 각 개발자와 빌드 머신의 결과가 재현된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Target은 어떤 프로그램을 만들지 정의한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;*.Target.cs&lt;/code&gt;는 Unreal Build Tool이 어떤 형태의 실행 파일을 만들지 판단하는 C# 규칙이다. Target과 Module을 혼동하면 안 된다.&lt;/p&gt;
&lt;table style=&quot;height: 63px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;파일&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;책임&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;NumberBaseball 예시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;*.Target.cs&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Game, Client, Server, Editor 중 어떤 실행 프로그램을 빌드할지 정의한다.&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;NumberBaseballServer.Target.cs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;*.Build.cs&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;프로젝트 Module이 의존하는 엔진 Module과 컴파일 규칙을 정의한다.&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;NumberBaseball.Build.cs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Target 종류는 포함되는 엔진 코드에도 영향을 준다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;TargetType&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;사용처&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Editor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;에디터 기능을 포함한 개발 도구&lt;/td&gt;
&lt;td&gt;에셋 편집과 PIE 테스트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Game&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;쿠킹된 콘텐츠로 실행하는 일반 독립 게임&lt;/td&gt;
&lt;td&gt;싱글플레이 또는 리슨 서버를 포함할 수 있는 게임 빌드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버 전용 코드를 제외한 네트워크 클라이언트&lt;/td&gt;
&lt;td&gt;플레이어에게 배포하는 전용 클라이언트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Server&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;클라이언트와 렌더링 관련 코드를 제외한 전용 서버&lt;/td&gt;
&lt;td&gt;EC2에서 헤드리스로 실행하는 권위 서버&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Server Target 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Editor.Target.cs&lt;/code&gt;를 복사하고 문자열을 일괄 치환하는 방식은 불필요한 Editor 규칙을 가져올 위험이 있다. 기본 &lt;code&gt;Game&lt;/code&gt; Target의 버전 설정을 기준으로 새 파일을 명시적으로 작성하는 편이 안전하다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// Source/NumberBaseballServer.Target.cs

using UnrealBuildTool;
using System.Collections.Generic;

public class NumberBaseballServerTarget : TargetRules
{
    public NumberBaseballServerTarget(TargetInfo Target) : base(Target)
    {
        Type = TargetType.Server;
        DefaultBuildSettings = BuildSettingsVersion.V5;
        IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_5;
        ExtraModuleNames.Add(&quot;NumberBaseball&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일명, Target 클래스명, 생성자명의 대응이 정확해야 한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;파일명      NumberBaseballServer.Target.cs
클래스명    NumberBaseballServerTarget
생성자명    NumberBaseballServerTarget
모듈명      NumberBaseball&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나라도 다르면 Unreal Build Tool이 Target을 발견하지 못하거나 C# 컴파일 오류를 낸다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Client Target 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전용 클라이언트가 필요하면 같은 방식으로 &lt;code&gt;TargetType.Client&lt;/code&gt;를 정의한다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// Source/NumberBaseballClient.Target.cs

using UnrealBuildTool;
using System.Collections.Generic;

public class NumberBaseballClientTarget : TargetRules
{
    public NumberBaseballClientTarget(TargetInfo Target) : base(Target)
    {
        Type = TargetType.Client;
        DefaultBuildSettings = BuildSettingsVersion.V5;
        IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_5;
        ExtraModuleNames.Add(&quot;NumberBaseball&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Game&lt;/code&gt; Target만으로도 네트워크 게임 실행 파일을 만들 수 있지만, &lt;code&gt;Client&lt;/code&gt; Target은 서버 코드를 제외한 명확한 클라이언트 산출물을 만든다. 별도의 클라이언트 패키지를 운영하지 않는 프로젝트라면 Client Target은 선택 사항이고 Server Target은 전용 서버 실행 파일에 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Target 파일을 추가한 뒤에는 &lt;code&gt;.uproject&lt;/code&gt;에서 Visual Studio 프로젝트 파일을 다시 생성한다. 기존에 열려 있던 IDE가 새 Target을 즉시 인식한다고 가정하면 안 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;현재 NumberBaseball의 Target 상태&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트에는 다음 두 Target만 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;현재 파일&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;TargetType&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;상태&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NumberBaseball.Target.cs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Game&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;일반 게임 실행 파일용 Target이 준비되어 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NumberBaseballEditor.Target.cs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Editor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;언리얼 에디터 개발용 Target이 준비되어 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NumberBaseballClient.Target.cs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;아직 존재하지 않는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NumberBaseballServer.Target.cs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Server&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;아직 존재하지 않는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 프로젝트에 실제로 추가한 결과가 아니라, 현재 &lt;code&gt;BuildSettingsVersion.V5&lt;/code&gt;와 &lt;code&gt;Unreal5_5&lt;/code&gt; 설정을 유지하면서 배포 단계에서 추가할 Target 예시다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 코드와 클라이언트 코드를 컴파일 단계에서 나눈다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Target은 렌더링을 하지 않지만 프로젝트 Module에 UI 코드가 포함되어 있으면 그 코드가 무조건 안전해지는 것은 아니다. 빌드 타깃에 따라 필요 없는 의존성이나 코드를 조건부로 제외할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// NumberBaseball.Build.cs

if (Target.Type != TargetType.Server)
{
    PublicDependencyModuleNames.AddRange(
        new string[]
        {
            &quot;UMG&quot;,
            &quot;Slate&quot;,
            &quot;SlateCore&quot;
        }
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;#if !UE_SERVER
void ANBPlayerController::CreateLocalHUD()
{
    // 클라이언트 전용 UI 코드
}
#endif&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 기존 코드가 UMG 타입을 공개 헤더나 공통 클래스 선언에 사용한다면 의존성만 제거했을 때 컴파일이 깨질 수 있다. 먼저 UI 경계를 정리하고 Target별 컴파일을 실제로 통과시키면서 의존성을 줄여야 한다. 최적화를 위해 무작정 Module을 제거하는 것은 배포의 첫 단계가 아니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;맵 설정은 실행 시작점과 쿠킹 범위를 결정한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트와 서버는 같은 프로젝트를 사용하지만 시작 맵의 목적이 다르다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;사용 주체&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;예시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game Default Map&lt;/td&gt;
&lt;td&gt;일반 게임 또는 클라이언트&lt;/td&gt;
&lt;td&gt;서버 주소를 입력하는 Title 맵&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Default Map&lt;/td&gt;
&lt;td&gt;전용 서버&lt;/td&gt;
&lt;td&gt;실제 매치가 실행되는 게임 맵&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List of Maps to Include&lt;/td&gt;
&lt;td&gt;Cook 과정&lt;/td&gt;
&lt;td&gt;패키지에 반드시 포함할 Title과 게임 맵&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 NumberBaseball에는 &lt;code&gt;L_Chatting.umap&lt;/code&gt; 하나가 있고 &lt;code&gt;GameDefaultMap&lt;/code&gt;과 &lt;code&gt;EditorStartupMap&lt;/code&gt;이 이 맵을 가리킨다. &lt;code&gt;ServerDefaultMap&lt;/code&gt;과 명시적인 &lt;code&gt;MapsToCook&lt;/code&gt; 설정은 없다. 현재 구조 그대로라면 서버 실행 명령에 맵을 직접 넘기거나 Server Default Map을 추가해야 시작점이 명확해진다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;[/Script/EngineSettings.GameMapsSettings]
GameDefaultMap=/Game/NumberBaseball/Maps/L_Chatting.L_Chatting
ServerDefaultMap=/Game/NumberBaseball/Maps/L_Chatting.L_Chatting
EditorStartupMap=/Game/NumberBaseball/Maps/L_Chatting.L_Chatting&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맵을 명령줄로 직접 지정할 수도 있다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;NumberBaseballServer.exe /Game/NumberBaseball/Maps/L_Chatting -log&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맵이 패키지에 쿠킹되어 있지 않으면 이름이 정확해도 열리지 않는다. Editor에서 열 수 있다는 사실은 Cook 결과에 포함되었다는 보장이 아니다. 특히 코드나 다른 에셋에서 하드 레퍼런스되지 않는 맵, 문자열로만 열리는 맵, 런타임에 동적으로 로드하는 에셋은 Packaging Settings의 목록이나 Primary Asset 규칙으로 명시해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전용 서버에는 &lt;code&gt;?listen&lt;/code&gt;이 필수가 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리슨 서버는 플레이어 클라이언트가 서버 역할까지 겸하므로 맵 URL에 &lt;code&gt;?listen&lt;/code&gt;을 붙여 서버로 열 수 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;NumberBaseball.exe L_Chatting?listen&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 &lt;code&gt;NumberBaseballServer.exe&lt;/code&gt;는 &lt;code&gt;TargetType.Server&lt;/code&gt;로 빌드된 전용 서버다. 실행 자체가 서버이므로 다음처럼 맵과 포트만 지정하면 된다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;NumberBaseballServer.exe /Game/NumberBaseball/Maps/L_Chatting -log -port=7777&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전용 서버 명령에 &lt;code&gt;?listen&lt;/code&gt;을 붙여도 일부 구성에서 동작할 수 있지만 의미상 중복이다. 리슨 서버 실행법과 전용 서버 실행법을 구분해 두면 배포 스크립트가 명확해진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클라이언트와 서버를 각각 Cook하고 Package한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Project Launcher의 Custom Profile을 사용하면 Build, Cook, Package, Deploy, Run 단계를 세밀하게 설정할 수 있다. 최종 배포 패키지를 만들 때는 자동 실행까지 켜 둘 필요가 없다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;클라이언트 프로필&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;서버 프로필&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Target&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NumberBaseballClient&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NumberBaseballServer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WindowsClient&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WindowsServer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration&lt;/td&gt;
&lt;td&gt;개발 테스트는 Development, 배포 후보는 Shipping 검토&lt;/td&gt;
&lt;td&gt;초기 운영 검증은 Development, 최종 운영은 Shipping 검토&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cook&lt;/td&gt;
&lt;td&gt;클라이언트가 사용하는 맵과 시각&amp;middot;음향 에셋&lt;/td&gt;
&lt;td&gt;서버 게임 규칙에 필요한 맵과 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;패키지 생성만 할 때 Do Not Deploy&lt;/td&gt;
&lt;td&gt;EC2 업로드는 별도 배포 과정으로 처리 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run&lt;/td&gt;
&lt;td&gt;자동 테스트 때만 활성화&lt;/td&gt;
&lt;td&gt;패키징 완료 후 자동 실행이 불필요하면 비활성화&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Project Launcher가 자동 실행까지 수행하도록 설정되어 있으면 패키징 도중 클라이언트나 서버가 뜬다. 실행 창을 닫아야 패키징이 완료되는 정상 규칙은 아니다. 배포 산출물만 필요하다면 Launch 단계에서 실행하지 않도록 Profile을 구성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과 폴더 이름은 엔진 버전과 Profile 설정에 따라 달라질 수 있다. &lt;code&gt;Saved/StagedBuilds/WindowsClient&lt;/code&gt;나 &lt;code&gt;WindowsServer&lt;/code&gt;는 흔한 Staging 경로이지만, Package Project에서 직접 지정한 출력 디렉터리를 사용할 수도 있다. 폴더 이름을 고정 가정하기보다 UAT 로그의 &lt;code&gt;StageDirectory&lt;/code&gt;와 &lt;code&gt;ArchiveDirectory&lt;/code&gt;를 확인하는 편이 정확하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Development와 Shipping의 차이&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 122px;&quot;&gt;&lt;b&gt;Configuration&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 419px;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 311px;&quot;&gt;&lt;b&gt;사용 시점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 122px;&quot;&gt;Development&lt;/td&gt;
&lt;td style=&quot;width: 419px;&quot;&gt;로그와 디버깅 기능을 활용하면서 최적화된 실행을 테스트하기 쉽다.&lt;/td&gt;
&lt;td style=&quot;width: 311px;&quot;&gt;로컬 패키징 검증과 초기 EC2 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 122px;&quot;&gt;Shipping&lt;/td&gt;
&lt;td style=&quot;width: 419px;&quot;&gt;디버깅 기능을 줄인 최종 배포용 구성이다.&lt;/td&gt;
&lt;td style=&quot;width: 311px;&quot;&gt;충분한 테스트와 운영 로깅 정책을 마련한 릴리스&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 Shipping으로만 테스트하면 콘솔과 일부 로그가 제한되어 원인을 찾기 어렵다. Development Server로 네트워크, 맵, 런타임 의존성, 방화벽을 먼저 검증한 뒤 Shipping으로 전환하는 편이 효율적이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;패키징 결과는 폴더 전체로 검증한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키지를 다른 위치에 복사한 뒤 개발 프로젝트 경로와 에디터가 없어도 실행되는지 확인한다. 이 테스트는 우연히 로컬 Cook 결과나 엔진 DLL을 참조하는 문제를 잡아낸다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;WindowsServer/
  NumberBaseballServer.exe 또는 실행용 루트 파일
  Engine/
  NumberBaseball/
    Binaries/
    Content/Paks 또는 IoStore 컨테이너
    Config/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 패키지를 배포할 때는 이 구조를 유지해야 한다. &lt;code&gt;.exe&lt;/code&gt;만 바탕화면으로 꺼내거나 일부 DLL만 선택해 전송하지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로컬에서 먼저 전용 서버를 검증한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라우드에 올리기 전에 같은 PC에서 패키지의 실행과 접속을 검증하면 AWS 문제와 게임 문제를 분리할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;.\NumberBaseballServer.exe `
  /Game/NumberBaseball/Maps/L_Chatting `
  -log `
  -port=7777 `
  -unattended `
  -NoSound&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 터미널에서 클라이언트를 실행한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;.\NumberBaseballClient.exe `
  127.0.0.1:7777 `
  -windowed `
  -ResX=1280 `
  -ResY=720 `
  -log&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그에서 다음 항목을 확인한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;올바른 게임 맵이 로드되었는가?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IpNetDriver&lt;/code&gt;가 의도한 UDP 포트에서 Listen하는가?&lt;/li&gt;
&lt;li&gt;클라이언트 접속 때 &lt;code&gt;PreLogin&lt;/code&gt;, &lt;code&gt;Login&lt;/code&gt;, &lt;code&gt;PostLogin&lt;/code&gt; 흐름이 완료되는가?&lt;/li&gt;
&lt;li&gt;서버에서 Widget이나 LocalPlayer를 찾는 오류가 발생하지 않는가?&lt;/li&gt;
&lt;li&gt;패키지를 다른 경로로 옮겨도 에셋 누락 없이 실행되는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;7777&lt;/code&gt;은 언리얼 전용 서버의 일반적인 기본 포트지만 절대값이 아니다. 동일한 머신에서 여러 서버를 실행하거나 &lt;code&gt;-port&lt;/code&gt;를 지정하면 다른 포트를 사용할 수 있다. 로그에 출력된 실제 포트와 클라이언트 접속 주소를 일치시켜야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AWS EC2 배포 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 Windows 인스턴스에 전용 서버를 배포하면 네트워크 요청은 여러 계층을 통과한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;외부 클라이언트
  -&amp;gt; EC2 Public IPv4 또는 DNS
  -&amp;gt; VPC Route / Internet Gateway
  -&amp;gt; Security Group의 UDP 7777 Inbound Rule
  -&amp;gt; Windows Defender Firewall의 UDP 7777 Inbound Rule
  -&amp;gt; NumberBaseballServer의 IpNetDriver&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Security Group만 열거나 Windows Firewall만 열어서는 접속되지 않는다. 서버 프로세스가 Listen하는 포트와 프로토콜까지 네 요소가 모두 일치해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Region과 Instance Type을 고르는 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Region은 관리자가 편한 위치보다 실제 플레이어와 가까운 위치를 우선한다. 한국 사용자 대상 테스트라면 서울 Region이 합리적이지만, 다른 지역 사용자가 주 대상이면 지연 시간을 측정해 선택해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전용 서버는 렌더링하지 않으므로 일반적으로 GPU 인스턴스가 필요하지 않다. 먼저 작은 범용 인스턴스로 기능을 검증하고, 서버 Tick 시간, CPU 사용률, 메모리, 동시 접속자 수를 측정해 확장한다.&lt;/p&gt;
&lt;table style=&quot;height: 105px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;자원&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;영향을 주는 요소&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;확인 지표&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;vCPU&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;게임 Tick, 물리, AI, RPC 처리, 복제 대상 수&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;프로세스 CPU, Game Thread 시간, Tick 지연&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Memory&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;맵 크기, 로드된 에셋, 플레이어 수, 캐시&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Working Set, Commit Size, 메모리 증가 추세&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Network&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복제 빈도, Actor 수, RPC, 동시 접속자&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Network In/Out, 패킷 손실, 지연 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Storage&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버 패키지, 로그, 크래시 덤프&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;EBS 여유 공간과 로그 증가량&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Security Group은 용도별 최소 포트만 연다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows EC2 테스트에 필요한 대표적인 Inbound Rule은 다음과 같다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;용도&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;프로토콜과 포트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Source 권장값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote Desktop 관리&lt;/td&gt;
&lt;td&gt;TCP 3389&lt;/td&gt;
&lt;td&gt;관리자 PC의 현재 공인 IP 한 개 또는 회사 VPN 대역&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;언리얼 게임 접속&lt;/td&gt;
&lt;td&gt;UDP 7777&lt;/td&gt;
&lt;td&gt;제한 테스트는 참여자 IP, 공개 테스트는 필요한 범위만 허용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDP의 Source를 &lt;code&gt;0.0.0.0/0&lt;/code&gt;으로 열면 인터넷 전체가 로그인 포트에 접근할 수 있다. AWS도 RDP와 SSH는 필요한 특정 IP만 허용할 것을 권장한다. 게임 UDP 포트는 공개 매치 특성상 넓게 열 수 있지만, 관리 포트와 같은 기준을 적용하면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal의 기본 &lt;code&gt;IpNetDriver&lt;/code&gt; 게임 트래픽은 UDP이므로 Custom TCP 7777만 추가해서는 접속되지 않는다. 클라이언트 접속 포트와 서버의 &lt;code&gt;-port&lt;/code&gt; 값을 변경했다면 Security Group도 같은 UDP 포트로 바꾼다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Windows Defender Firewall도 별도로 연다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Security Group을 통과한 패킷도 Windows Guest OS의 Firewall에서 차단될 수 있다. 관리자 PowerShell에서 서버 프로그램 또는 UDP 포트에 대한 Inbound Rule을 만든다.&lt;/p&gt;
&lt;pre class=&quot;haml&quot;&gt;&lt;code&gt;New-NetFirewallRule `
  -DisplayName &quot;NumberBaseball Server UDP 7777&quot; `
  -Direction Inbound `
  -Action Allow `
  -Protocol UDP `
  -LocalPort 7777&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로그램 경로까지 제한하는 규칙은 같은 포트를 다른 프로세스가 사용하는 위험을 줄일 수 있다. 패키지 경로를 바꾸면 프로그램 기반 규칙도 갱신해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방화벽 전체를 비활성화해서 접속을 해결하는 방식은 원인 진단에는 잠깐 도움이 될 수 있어도 운영 설정으로 남겨서는 안 된다. 필요한 프로토콜과 포트만 규칙으로 추가한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Windows 인스턴스에 패키지를 배포한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 실습에서는 RDP의 로컬 드라이브 공유나 압축 파일 복사로 서버 패키지를 전송할 수 있다. 규모가 커지면 S3, Systems Manager, CI/CD Artifact를 사용해 버전과 무결성을 관리하는 편이 낫다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;로컬 WindowsServer 패키지
  -&amp;gt; ZIP 압축
  -&amp;gt; EC2에 전송
  -&amp;gt; 짧고 고정된 경로에 압축 해제
  -&amp;gt; 런타임 설치
  -&amp;gt; 서버 명령 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시 배포 경로는 다음처럼 단순하게 둘 수 있다.&lt;/p&gt;
&lt;pre class=&quot;taggerscript&quot;&gt;&lt;code&gt;C:\GameServer\NumberBaseball\&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 PC에서 만든 바로 가기는 절대 경로를 포함할 수 있어 EC2에서 깨진다. 서버에서는 새 바로 가기나 실행 스크립트를 만들거나 PowerShell에서 직접 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키지가 Visual C++ Runtime 같은 전제 조건을 요구하면 소스 엔진의 다음 설치 프로그램을 신뢰할 수 있는 경로에서 함께 배포할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;taggerscript&quot;&gt;&lt;code&gt;Engine\Extras\Redist\en-us\UEPrereqSetup_x64.exe&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오류 창을 무조건 닫고 진행하기보다 누락된 런타임과 버전을 로그로 확인하고 설치해야 한다. 서버를 재배포할 때마다 설치할 필요는 없지만 새 Windows 인스턴스에는 초기 프로비저닝 단계가 필요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;EC2에서 서버를 실행하고 확인한다&lt;/h2&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;Set-Location 'C:\GameServer\NumberBaseball'

.\NumberBaseballServer.exe `
  /Game/NumberBaseball/Maps/L_Chatting `
  -log `
  -port=7777 `
  -unattended `
  -NoSound&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 실제로 UDP 포트를 열었는지 확인한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Get-NetUDPEndpoint -LocalPort 7777&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 프로세스를 확인한다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;Get-Process NumberBaseballServer&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 로그는 일반적으로 패키지의 프로젝트 디렉터리 아래 &lt;code&gt;Saved\Logs&lt;/code&gt;에서 확인할 수 있다. 콘솔의 마지막 한 줄만 보는 대신 맵 로드, NetDriver 생성, Bind 실패, 패키지 에셋 누락을 검색한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;LogNet: Created socket for bind address
LogNet: GameNetDriver IpNetDriver listening on port 7777&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포트가 이미 사용 중이면 서버가 7778 같은 다음 포트를 임의로 사용할 것이라고 기대하지 말고, 어떤 프로세스가 점유했는지 확인한 뒤 각 서버 인스턴스에 고유한 &lt;code&gt;-port&lt;/code&gt;를 명시한다. Security Group과 클라이언트 주소도 같은 값으로 맞춘다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;외부 클라이언트에서 접속한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 인스턴스의 Public IPv4를 확인한 뒤 클라이언트에 &lt;code&gt;주소:포트&lt;/code&gt;를 전달한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;NumberBaseballClient.exe 203.0.113.10:7777 -windowed -ResX=1280 -ResY=720 -log&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;203.0.113.10&lt;/code&gt;은 문서용 예시 주소이므로 실제 실행 시 EC2의 Public IPv4 또는 DNS로 교체한다. Title UI에서 주소를 입력받는 구조라면 같은 문자열을 &lt;code&gt;ClientTravel()&lt;/code&gt;에 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 접속 실패는 다음 순서로 범위를 좁힌다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 서버 프로세스가 실행 중인가?
2. 올바른 게임 맵을 열었는가?
3. Get-NetUDPEndpoint에 실제 포트가 보이는가?
4. Windows Firewall이 해당 UDP 포트를 허용하는가?
5. EC2 Security Group이 같은 UDP 포트를 허용하는가?
6. 클라이언트가 Private IPv4가 아닌 Public IPv4를 사용하는가?
7. 서버 로그에 접속 시도 또는 Handshake 오류가 남는가?
8. 클라이언트와 서버 빌드의 프로젝트 버전이 호환되는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 &lt;code&gt;127.0.0.1&lt;/code&gt; 접속이 실패하면 AWS 설정이 원인이 아니다. 반대로 EC2 내부에서 서버가 Listen하지만 외부에서만 실패한다면 Security Group, Windows Firewall, Public IP, VPC 경로를 우선 확인한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Stop, Terminate, Public IP와 비용을 구분한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS 자원 정리는 단순히 원격 데스크톱 창을 닫는 것으로 끝나지 않는다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;동작&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;인스턴스 상태&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;데이터와 비용 영향&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reboot&lt;/td&gt;
&lt;td&gt;같은 인스턴스를 재부팅&lt;/td&gt;
&lt;td&gt;컴퓨팅 과금이 계속되고 일반적으로 Public IPv4가 유지된다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop&lt;/td&gt;
&lt;td&gt;나중에 다시 시작 가능&lt;/td&gt;
&lt;td&gt;인스턴스 사용 요금은 멈추지만 EBS와 일부 네트워크 자원 비용은 남을 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start&lt;/td&gt;
&lt;td&gt;중지된 인스턴스를 재개&lt;/td&gt;
&lt;td&gt;일반 Public IPv4는 바뀔 수 있어 클라이언트 주소를 다시 확인해야 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminate&lt;/td&gt;
&lt;td&gt;인스턴스를 영구 삭제&lt;/td&gt;
&lt;td&gt;다시 시작할 수 없으며 Root EBS는 기본 설정상 삭제되지만 별도 Volume은 남을 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;On-Demand 인스턴스는 &lt;code&gt;running&lt;/code&gt; 시간에 컴퓨팅 요금이 발생한다. Stop 상태에서는 인스턴스 사용 요금이 중단되지만 EBS 저장 공간과 연결된 Elastic IP 등은 별도 과금될 수 있다. 현재 AWS는 Public IPv4 주소에도 비용을 부과하므로 Free Tier라는 이유만으로 비용이 항상 0원이라고 가정하면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stop 후 Start하면 자동 할당 Public IPv4가 바뀌는 것이 일반적이다. 주소를 고정하려고 Elastic IP를 사용하면 관리가 편하지만 그 주소 자체의 비용과 미사용 상태를 확인해야 한다. 짧은 실습이라면 재시작 후 새 주소를 확인하는 방식이 더 단순할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Terminate는 복구 가능한 중지가 아니다. 로그나 패키지를 보관해야 한다면 먼저 S3나 로컬 저장소로 복사하고, 연결된 EBS Volume의 &lt;code&gt;Delete on termination&lt;/code&gt; 설정을 확인한다. 인스턴스를 종료한 뒤에도 남은 EBS Volume, Snapshot, Elastic IP가 없는지 각 서비스 화면과 Billing에서 검토한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;운영 환경에서는 RDP로 수동 실행하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDP 세션에서 콘솔 창을 직접 띄우는 방식은 첫 배포 검증에는 편하지만 세션 종료, 재부팅, 프로세스 크래시에 취약하다. 운영 단계에서는 다음 기능을 추가한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Windows Service나 작업 스케줄러로 부팅 시 서버를 시작한다.&lt;/li&gt;
&lt;li&gt;서버 실행 스크립트와 포트를 형상 관리한다.&lt;/li&gt;
&lt;li&gt;프로세스 종료를 감지해 제한적으로 재시작한다.&lt;/li&gt;
&lt;li&gt;로그를 CloudWatch Logs나 중앙 저장소로 전송한다.&lt;/li&gt;
&lt;li&gt;CloudWatch Alarm과 AWS Budgets로 장애와 비용을 알린다.&lt;/li&gt;
&lt;li&gt;패키지 버전, 엔진 커밋, Git 커밋, 배포 시각을 기록한다.&lt;/li&gt;
&lt;li&gt;RDP 대신 Systems Manager Session Manager 사용 가능성을 검토한다.&lt;/li&gt;
&lt;li&gt;IAM 권한과 Security Group을 최소 권한으로 유지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 재시작은 무한 크래시 루프를 만들 수 있으므로 횟수 제한과 Backoff가 필요하다. 새 버전 배포 전에는 이전 패키지를 별도 디렉터리에 보관해 빠르게 Rollback할 수 있게 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포 버전 호환성을 서버가 검사해야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트와 서버가 서로 다른 패키지면 프로퍼티 레이아웃, RPC, 맵, 게임 규칙이 달라질 수 있다. 단순히 접속이 성립한다고 호환된다고 볼 수 없다.&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;Client Build ID
  -&amp;gt; URL Option 또는 초기 Handshake에 포함
  -&amp;gt; Server가 허용 버전과 비교
  -&amp;gt; 불일치 시 PreLogin에서 명시적인 오류로 거절&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 배포에서는 최소한 다음 정보를 로그에 남긴다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Project Version
Git Commit
Engine Version / Source Commit
Target Configuration
Map Name
Listen Port
Deployment Timestamp&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정보가 있어야 클라이언트가 접속하지 못할 때 방화벽 문제와 버전 불일치를 구분할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 배포 체크리스트&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구간&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;검증 항목&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engine&lt;/td&gt;
&lt;td&gt;UE 5.5에 맞는 소스 브랜치, Visual Studio 구성요소, Setup과 GenerateProjectFiles 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project&lt;/td&gt;
&lt;td&gt;소스 엔진 연결, C++ 빌드 성공, Client와 Server Target 발견&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maps&lt;/td&gt;
&lt;td&gt;Game Default Map, Server Default Map, Cook 대상 맵 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;WindowsClient와 WindowsServer 결과를 별도 생성하고 폴더 전체 실행 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Test&lt;/td&gt;
&lt;td&gt;서버 맵 로드, UDP 7777 Listen, 두 클라이언트 접속, 종료와 재접속 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EC2&lt;/td&gt;
&lt;td&gt;플레이어와 가까운 Region, 적절한 Instance Type, 충분한 EBS 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;Security Group UDP 7777, Windows Firewall UDP 7777, RDP TCP 3389 Source 제한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Prerequisite 설치, 명시적인 맵과 포트, Saved/Logs 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;EC2 Public IPv4와 실제 서버 포트로 접속, 버전 호환성 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;자동 시작, 모니터링, 로그 수집, Rollback, 비용 알림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleanup&lt;/td&gt;
&lt;td&gt;Stop과 Terminate 정책 결정, EBS&amp;middot;Snapshot&amp;middot;Elastic IP&amp;middot;Public IPv4 비용 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 발생하는 문제&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;가능한 원인과 확인 지점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Server targets are not currently supported&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;프로젝트가 Launcher 바이너리 엔진을 사용 중인지 확인하고 소스 빌드 엔진으로 전환한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Development Server 구성이 보이지 않는다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Server.Target.cs&lt;/code&gt;의 파일명, 클래스명, 생성자명과 프로젝트 파일 재생성을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 실행 파일은 있지만 맵을 열지 못한다.&lt;/td&gt;
&lt;td&gt;맵 경로, Server Default Map, List of Maps to Include, Cook 로그를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버를 실행하면 아무 창도 보이지 않는다.&lt;/td&gt;
&lt;td&gt;헤드리스 실행은 정상일 수 있다. &lt;code&gt;-log&lt;/code&gt;, 프로세스, Saved/Logs와 UDP Endpoint를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로컬에서는 접속되지만 EC2에서는 실패한다.&lt;/td&gt;
&lt;td&gt;Security Group의 프로토콜이 UDP인지, Windows Firewall, Public IPv4, 실제 Listen 포트를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDP 접속이 되지 않는다.&lt;/td&gt;
&lt;td&gt;인스턴스 상태 검사, Public IPv4, TCP 3389 Rule, Source의 현재 관리자 공인 IP를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EC2 Stop 후 클라이언트가 접속하지 못한다.&lt;/td&gt;
&lt;td&gt;Start 뒤 Public IPv4가 변경되었는지 확인하고 주소 설정을 갱신한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 실행 직후 런타임 DLL 오류가 난다.&lt;/td&gt;
&lt;td&gt;패키지 폴더 일부만 복사했는지와 UE Prerequisite 설치 여부를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;원격 서버에서 UI 또는 LocalPlayer 오류가 난다.&lt;/td&gt;
&lt;td&gt;서버 전용 경로에서 Widget, Viewport, Local Controller 로직이 실행되는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인스턴스를 중지했는데 비용이 계속 보인다.&lt;/td&gt;
&lt;td&gt;EBS, Snapshot, Elastic IP, Public IPv4 등 인스턴스 외 자원을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/75</guid>
      <comments>https://uegamedev.tistory.com/75#entry75comment</comments>
      <pubDate>Wed, 12 Aug 2026 22:03:39 +0900</pubDate>
    </item>
    <item>
      <title>UE C++ 언리얼 멀티플레이 서버 및 생명주기</title>
      <link>https://uegamedev.tistory.com/74</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;언리얼 멀티플레이 매치 생명주기: 서버 접속, 상태 전환, 결과 UI와 리셋&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 게임 한 판은 전투나 점수 계산만으로 완성되지 않는다. 클라이언트가 타이틀 화면에서 서버를 선택하고, 서버가 접속을 승인하며, 필요한 인원이 모이면 게임을 시작하고, 종료 결과를 각 플레이어에게 전달한 뒤 다음 판을 준비해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름에서 가장 중요한 원칙은 &lt;b&gt;서버가 규칙과 상태 전환을 결정하고, 클라이언트는 복제된 상태를 화면에 표현한다&lt;/b&gt;는 것이다. 레벨 이동과 UI는 로컬에서 일어나지만, 누가 참가할 수 있는지, 언제 게임이 시작되고 끝나는지, 누가 몇 등인지는 서버가 확정해야 한다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;Title
  -&amp;gt; 서버 주소 입력
  -&amp;gt; Client Travel
  -&amp;gt; PreLogin에서 참가 가능 여부 검사
  -&amp;gt; PostLogin 이후 PlayerController 사용
  -&amp;gt; Waiting
  -&amp;gt; Playing
  -&amp;gt; Ending
  -&amp;gt; 결과 UI 표시
  -&amp;gt; 클라이언트는 Title로 복귀
  -&amp;gt; 서버는 논리 상태 또는 월드를 초기화&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한 판의 책임을 Gameplay Framework에 나누기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;접속부터 종료까지의 모든 코드를 한 클래스에 넣으면 서버 규칙, 복제 상태, 개인 UI가 뒤섞인다. 언리얼의 Gameplay Framework는 이 책임을 이미 나누어 놓았다.&lt;/p&gt;
&lt;table style=&quot;height: 189px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;클래스&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;네트워크 범위&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;세션 생명주기에서의 책임&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;GameMode&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버에만 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;접속 승인, 시작 조건, 상태 전환, 승패 판정, 서버 리셋을 결정한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;GameState&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;서버에 존재하고 모든 클라이언트에 복제&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;현재 매치 단계, 남은 시간, 생존 인원처럼 모두가 알아야 하는 상태를 전달한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;PlayerController&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;서버와 해당 소유 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;서버 접속 요청, 개인 결과 UI, 강제 복귀처럼 특정 플레이어를 대상으로 한 동작을 처리한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;PlayerState&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;서버에 존재하고 관련 클라이언트에 복제&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;플레이어 이름, 점수, 탈락 여부, 순위처럼 플레이어별 상태를 보관한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;UserWidget&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;각 로컬 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;입력을 받고 복제 상태와 RPC 결과를 화면에 표시한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GameMode는 원격 클라이언트에 복제되지 않는다. 따라서 클라이언트 UI가 GameMode의 변수를 직접 읽는 구조는 데디케이티드 서버 환경에서 성립하지 않는다. 서버 전용 규칙은 GameMode에 두고, 화면에 필요한 결과만 GameState나 PlayerState에 복제해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;타이틀 레벨은 게임 월드와 분리한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타이틀 레벨의 목적은 캐릭터를 스폰하는 것이 아니라 서버 주소를 입력받고 연결을 시작하는 것이다. 따라서 타이틀 전용 GameMode를 만들고 &lt;code&gt;DefaultPawnClass&lt;/code&gt;를 &lt;code&gt;None&lt;/code&gt;으로 지정하면 불필요한 Pawn 생성을 막을 수 있다. 타이틀 전용 PlayerController는 로컬 UI만 생성한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void ANBTitlePlayerController::BeginPlay()
{
    Super::BeginPlay();

    if (!IsLocalController() || !IsValid(TitleWidgetClass))
    {
        return;
    }

    TitleWidget = CreateWidget&amp;lt;UUserWidget&amp;gt;(this, TitleWidgetClass);
    if (!IsValid(TitleWidget))
    {
        return;
    }

    TitleWidget-&amp;gt;AddToViewport();

    FInputModeUIOnly InputMode;
    InputMode.SetWidgetToFocus(TitleWidget-&amp;gt;TakeWidget());
    SetInputMode(InputMode);
    bShowMouseCursor = true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;IsLocalController()&lt;/code&gt; 검사는 서버에 존재하는 원격 플레이어의 Controller 인스턴스에서 화면 코드를 실행하지 않게 한다. 데디케이티드 서버에는 Viewport가 없으므로 Widget 생성은 항상 로컬 Controller 경로로 제한해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;BeginPlay()&lt;/code&gt;를 재정의할 때는 &lt;code&gt;Super::BeginPlay()&lt;/code&gt;를 먼저 호출한다. 입력과 PlayerController 내부 초기화에 의존하는 코드가 있기 때문에 이를 누락하면 레벨 전환 뒤 입력이 동작하지 않는 것처럼 보일 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Widget은 주소를 수집하고 PlayerController가 이동한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Widget 버튼이 직접 네트워크 연결의 세부 구현을 가지게 만들기보다, Widget은 입력한 주소를 PlayerController에 전달하는 역할만 맡는 편이 좋다. 이렇게 하면 UI 교체와 접속 구현 변경이 서로 분리된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void UNBTitleWidget::OnJoinButtonClicked()
{
    ANBTitlePlayerController* PC =
        GetOwningPlayer&amp;lt;ANBTitlePlayerController&amp;gt;();

    if (IsValid(PC) &amp;amp;&amp;amp; IsValid(ServerAddressText))
    {
        PC-&amp;gt;JoinServer(ServerAddressText-&amp;gt;GetText().ToString());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;void ANBTitlePlayerController::JoinServer(const FString&amp;amp; InAddress)
{
    const FString Address = InAddress.TrimStartAndEnd();
    if (Address.IsEmpty())
    {
        return;
    }

    ClientTravel(Address, TRAVEL_Absolute);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 호출한 &lt;code&gt;APlayerController::ClientTravel()&lt;/code&gt;은 IP 주소나 맵 URL로 이동한다. &lt;code&gt;UGameplayStatics::OpenLevel()&lt;/code&gt;에 주소를 전달하는 방법도 첫 연결에서는 동작하지만, &lt;code&gt;ClientTravel()&lt;/code&gt;을 사용하면 &lt;b&gt;현재 클라이언트가 다른 서버로 이동한다&lt;/b&gt;는 의도가 코드에 명확히 드러난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 서비스에서는 자유 입력 문자열을 바로 사용하지 않고 다음 항목을 함께 처리해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;빈 문자열과 앞뒤 공백을 제거한다.&lt;/li&gt;
&lt;li&gt;주소와 포트 형식이 허용 범위인지 검사한다.&lt;/li&gt;
&lt;li&gt;접속 버튼을 연속으로 누르지 못하게 잠근다.&lt;/li&gt;
&lt;li&gt;접속 실패와 네트워크 끊김 Delegate를 받아 타이틀 UI를 복구한다.&lt;/li&gt;
&lt;li&gt;Online Subsystem을 사용한다면 검색 결과의 Connect String을 받아 이동한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;접속 생명주기는 PreLogin과 PostLogin을 구분한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 서버 URL로 이동했다고 곧바로 정상 참가자가 되는 것은 아니다. GameMode에는 접속을 검사하고 Controller를 초기화하는 단계가 따로 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;단계&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;적합한 작업&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreLogin()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PlayerController를 만들기 전 참가를 승인하거나 거절한다.&lt;/td&gt;
&lt;td&gt;정원, 버전, 밴 목록, 진행 중 참가 가능 여부 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Login()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;접속에 사용할 PlayerController를 생성하고 기본 정보를 설정한다.&lt;/td&gt;
&lt;td&gt;일반 게임 로직보다 엔진의 로그인 생성 절차&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostLogin()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;로그인 성공 뒤 호출되며 PlayerController RPC를 안전하게 사용할 수 있다.&lt;/td&gt;
&lt;td&gt;참가자 등록, 초기 상태 설정, 개인 환영 메시지 전달&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Logout()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controller가 게임에서 나가거나 파괴될 때 호출된다.&lt;/td&gt;
&lt;td&gt;배열 제거, 턴과 생존자 정리, 승리 조건 재평가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임이 이미 시작된 뒤 &lt;code&gt;PostLogin()&lt;/code&gt;에서 새 Controller의 수명을 짧게 설정해 제거하는 방식은 접속을 일단 성공시킨 뒤 쫓아내는 우회 방법이다. 참가를 허용하지 않을 규칙이라면 &lt;code&gt;PreLogin()&lt;/code&gt;에서 오류 문자열을 설정해 로그인 자체를 거절하는 편이 정확하다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;void ANBGameModeBase::PreLogin(
    const FString&amp;amp; Options,
    const FString&amp;amp; Address,
    const FUniqueNetIdRepl&amp;amp; UniqueId,
    FString&amp;amp; ErrorMessage)
{
    Super::PreLogin(Options, Address, UniqueId, ErrorMessage);

    if (!ErrorMessage.IsEmpty())
    {
        return;
    }

    const ANBGameStateBase* GS = GetGameState&amp;lt;ANBGameStateBase&amp;gt;();
    if (IsValid(GS) &amp;amp;&amp;amp; GS-&amp;gt;GetSessionPhase() != ENBSessionPhase::Waiting)
    {
        ErrorMessage = TEXT(&quot;The match is already in progress.&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;접속 중 상태가 바뀔 수 있으므로 최종 참가 등록 시점에도 조건을 다시 확인해야 한다. 관전이나 중도 참가를 지원한다면 무조건 거절하는 대신 URL 옵션과 게임 규칙에 따라 &lt;code&gt;Spectator&lt;/code&gt;로 받아들이는 정책을 둘 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 NumberBaseball 프로젝트는 &lt;code&gt;OnPostLogin(AController*)&lt;/code&gt;에서 Controller를 배열에 추가하고 이름과 채팅 색상을 배정한 뒤 &lt;code&gt;ClientRPCSetNotificationText()&lt;/code&gt;를 호출한다. 이 시점에 개인 Client RPC를 사용하는 것은 올바른 방향이다. 반대로 접속 허용 여부는 이 단계보다 앞선 &lt;code&gt;PreLogin()&lt;/code&gt;에서 결정해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PlayerController 배열과 GameState의 PlayerArray는 목적이 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 GameMode가 생존 PlayerController와 탈락 PlayerController를 따로 보관하면 특정 대상에게 Client RPC를 보내기 쉽다. 다만 이 배열은 서버 전용 런타임 인덱스이며, 클라이언트가 전체 참가자 정보를 읽는 복제 저장소는 아니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구조&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;주의점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GameMode의 PlayerController 배열&lt;/td&gt;
&lt;td&gt;서버 규칙 처리와 대상별 Client RPC 호출이 직접적이다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Logout()&lt;/code&gt;에서 반드시 제거하고 유효성을 재검사해야 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GameState의 &lt;code&gt;PlayerArray&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;전체 참가자의 PlayerState 목록을 서버와 클라이언트가 공통으로 조회할 수 있다.&lt;/td&gt;
&lt;td&gt;생존, 탈락, 관전 같은 게임별 분류는 PlayerState 프로퍼티가 추가로 필요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PlayerState의 참가 상태&lt;/td&gt;
&lt;td&gt;플레이어별 상태가 복제되므로 순위표와 관전 UI에 적합하다.&lt;/td&gt;
&lt;td&gt;서버만 값을 변경하도록 제한해야 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순위표를 모든 클라이언트가 지속적으로 봐야 한다면 PlayerState에 &lt;code&gt;bEliminated&lt;/code&gt;, &lt;code&gt;Rank&lt;/code&gt;, &lt;code&gt;Score&lt;/code&gt;를 복제하는 편이 좋다. 결과 화면을 당사자에게 한 번만 띄우는 목적이라면 서버의 Controller 배열과 Client RPC만으로 충분하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;상태 전환은 GameMode, 현재 상태 공개는 GameState&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Waiting&lt;/code&gt;, &lt;code&gt;Playing&lt;/code&gt;, &lt;code&gt;Ending&lt;/code&gt;은 단순한 UI 문자열이 아니라 서버 규칙을 여닫는 상태다. 상태를 GameState에 저장하더라도 전환 결정을 클라이언트가 내려서는 안 된다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;GameMode
  최소 인원 충족 여부 검사
  -&amp;gt; Waiting 종료 결정
  -&amp;gt; Playing 진입
  -&amp;gt; 승리 조건 검사
  -&amp;gt; Ending 진입

GameState
  현재 Phase 복제
  종료 예정 서버 시간 복제
  생존 인원 복제
  -&amp;gt; 각 클라이언트 UI가 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 전용 상태가 필요하면 엔진의 이름과 혼동되지 않게 별도의 enum과 복제 구조체를 둘 수 있다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;UENUM(BlueprintType)
enum class ENBSessionPhase : uint8
{
    Waiting,
    Playing,
    Ending
};

USTRUCT(BlueprintType)
struct FNBSessionState
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly)
    ENBSessionPhase Phase = ENBSessionPhase::Waiting;

    UPROPERTY(BlueprintReadOnly)
    int32 AlivePlayerCount = 0;

    UPROPERTY(BlueprintReadOnly)
    double PhaseEndServerTimeSeconds = 0.0;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = OnRep_SessionState)
FNBSessionState SessionState;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 Setter는 Authority를 확인한 뒤 값을 변경하고, 클라이언트는 RepNotify에서 로컬 Delegate를 발생시켜 Widget을 갱신한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ANBGameStateBase::OnRep_SessionState()
{
    OnSessionStateChanged.Broadcast();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴은 현재 프로젝트의 &lt;code&gt;FNBTurnState&lt;/code&gt;와 같다. &lt;code&gt;ANBGameModeBase&lt;/code&gt;가 턴을 결정하고, &lt;code&gt;ANBGameStateBase&lt;/code&gt;는 &lt;code&gt;TurnState&lt;/code&gt;를 &lt;code&gt;ReplicatedUsing&lt;/code&gt;으로 전달하며, &lt;code&gt;OnRep_TurnState()&lt;/code&gt;가 로컬 Delegate를 통해 턴 UI를 갱신한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AGameModeBase의 커스텀 상태와 AGameMode의 Match State&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼은 &lt;code&gt;AGameMode&lt;/code&gt;와 &lt;code&gt;AGameState&lt;/code&gt; 조합에 표준 매치 상태 머신을 제공한다. &lt;code&gt;AGameModeBase&lt;/code&gt;는 더 단순하므로 프로젝트가 직접 상태를 구성한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;선택&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;적합한 경우&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;주요 특징&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AGameModeBase&lt;/code&gt;와 커스텀 상태&lt;/td&gt;
&lt;td&gt;턴제 미니게임처럼 단계가 단순하거나 고유한 흐름이 필요한 경우&lt;/td&gt;
&lt;td&gt;상태, 전환 조건, 복제 필드를 직접 정의한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AGameMode&lt;/code&gt;와 &lt;code&gt;AGameState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;전형적인 대기, 진행, 종료 매치 흐름을 엔진 훅과 함께 사용할 경우&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WaitingToStart&lt;/code&gt;, &lt;code&gt;InProgress&lt;/code&gt;, &lt;code&gt;WaitingPostMatch&lt;/code&gt; 등의 상태와 시작&amp;middot;종료 훅을 제공한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 &lt;code&gt;AGameModeBase&lt;/code&gt; 기반으로 구현된 NumberBaseball은 단지 표준 상태 이름을 쓰기 위해 부모 클래스를 바꿀 필요가 없다. 현재 턴과 라운드 규칙에 맞는 &lt;code&gt;ENBSessionPhase&lt;/code&gt;를 추가하고, 공개해야 할 상태만 GameState에 복제하는 방식이 더 작은 변경이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Tick 대신 서버 Timer로 상태를 진행한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기 시간이나 종료 유예 시간처럼 초 단위로 변하는 로직을 매 프레임 &lt;code&gt;Tick()&lt;/code&gt;에서 실행할 필요는 없다. 서버의 &lt;code&gt;FTimerManager&lt;/code&gt;를 사용하면 필요한 주기로만 조건을 검사하거나 전환 함수를 호출할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;void ANBGameModeBase::BeginWaitingCountdown()
{
    ANBGameStateBase* GS = GetGameState&amp;lt;ANBGameStateBase&amp;gt;();
    if (!IsValid(GS))
    {
        return;
    }

    const double EndTime =
        GS-&amp;gt;GetServerWorldTimeSeconds() + WaitingDurationSeconds;

    GS-&amp;gt;SetSessionPhase(ENBSessionPhase::Waiting, EndTime);

    GetWorldTimerManager().SetTimer(
        WaitingTimerHandle,
        this,
        &amp;amp;ThisClass::StartMatch,
        WaitingDurationSeconds,
        false);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어 수가 최소 인원보다 다시 작아지면 대기 타이머를 지우고 종료 시각을 초기화한다. 종료 단계에 들어가면 진행 타이머를 지우는 식으로 &lt;b&gt;상태마다 활성 Timer가 무엇인지 명확히 유지&lt;/b&gt;해야 한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;void ANBGameModeBase::CancelWaitingCountdown()
{
    GetWorldTimerManager().ClearTimer(WaitingTimerHandle);

    if (ANBGameStateBase* GS = GetGameState&amp;lt;ANBGameStateBase&amp;gt;())
    {
        GS-&amp;gt;SetSessionPhase(ENBSessionPhase::Waiting, 0.0);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Actor가 파괴되면 그 객체에 연결된 Timer 콜백은 자동으로 취소되지만, 상태 전환이나 &lt;code&gt;EndPlay()&lt;/code&gt;에서 Handle을 명시적으로 정리하면 중복 Timer와 잘못된 단계 진입을 예방하기 쉽다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;남은 정수를 매초 복제하는 대신 종료 시각을 복제할 수 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의형 예제에서는 서버가 &lt;code&gt;15&lt;/code&gt;, &lt;code&gt;14&lt;/code&gt;, &lt;code&gt;13&lt;/code&gt;처럼 남은 시간을 매초 줄이고 복제해도 이해하기 쉽다. 플레이어가 많거나 여러 타이머가 존재하면 종료 예정 서버 시각을 한 번 복제하고 각 클라이언트가 남은 시간을 계산하는 방식이 효율적이다.&lt;/p&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;int32 UNBSessionWidget::GetRemainingSeconds() const
{
    if (!IsValid(CachedGameState))
    {
        return 0;
    }

    const double Remaining =
        CachedGameState-&amp;gt;GetPhaseEndServerTimeSeconds()
        - CachedGameState-&amp;gt;GetServerWorldTimeSeconds();

    return FMath::Max(0, FMath::CeilToInt(Remaining));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AGameStateBase::GetServerWorldTimeSeconds()&lt;/code&gt;는 클라이언트에서도 서버 시간에 맞춰진 값을 제공한다. 서버는 정확한 전환을 Timer로 수행하고, 클라이언트의 표시 타이머는 복제된 종료 시각을 읽어 화면만 갱신한다. 클라이언트 UI의 1초 Timer가 잠시 늦더라도 실제 게임 단계는 서버 상태로 수렴한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;적합한 상황&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;남은 초를 매초 복제&lt;/td&gt;
&lt;td&gt;구현과 디버깅이 단순하다.&lt;/td&gt;
&lt;td&gt;소규모 게임, 낮은 빈도의 단일 타이머&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;종료 서버 시각을 복제&lt;/td&gt;
&lt;td&gt;상태 진입 때 한 번만 보내도 로컬에서 계속 계산할 수 있다.&lt;/td&gt;
&lt;td&gt;플레이어와 동시 타이머가 많거나 네트워크 갱신을 줄이고 싶은 경우&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 NumberBaseball의 턴 타이머는 &lt;code&gt;RemainingTimeSeconds&lt;/code&gt;를 1초마다 변경하고 &lt;code&gt;ForceNetUpdate()&lt;/code&gt;로 즉시 알린다. 작은 턴제 프로젝트에서는 충분히 명확한 선택이다. 이후 타이머 종류가 늘어나면 턴 종료 시각을 복제하는 구조로 확장할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게임플레이 규칙도 현재 매치 단계에서 닫아야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 UI에서 공격 버튼이나 채팅 입력을 비활성화하는 것만으로는 게임 규칙을 보호할 수 없다. 클라이언트는 지연되거나 변조될 수 있으므로 최종 진입점은 서버가 현재 단계를 검사해야 한다.&lt;/p&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;float ANBPlayerCharacter::TakeDamage(
    float DamageAmount,
    const FDamageEvent&amp;amp; DamageEvent,
    AController* EventInstigator,
    AActor* DamageCauser)
{
    if (!HasAuthority())
    {
        return 0.0f;
    }

    const ANBGameStateBase* GS = GetWorld()-&amp;gt;GetGameState&amp;lt;ANBGameStateBase&amp;gt;();
    if (!IsValid(GS) || GS-&amp;gt;GetSessionPhase() != ENBSessionPhase::Playing)
    {
        return 0.0f;
    }

    return Super::TakeDamage(
        DamageAmount,
        DamageEvent,
        EventInstigator,
        DamageCauser);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 원칙으로 NumberBaseball의 숫자 제출도 서버 GameMode에서 &lt;code&gt;bRoundEnding&lt;/code&gt;, 현재 턴, 남은 시간, 제출 횟수를 다시 검사한다. UI 차단은 사용자 경험이고, GameMode 검사는 권위 있는 규칙이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과는 서버가 계산하고 소유 Client RPC로 전달한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인 순위 결과는 모든 클라이언트에서 실행되는 NetMulticast보다 PlayerController의 Client RPC에 잘 맞는다. PlayerController의 Owning Connection이 어느 클라이언트에서 실행할지를 결정하기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void ClientShowMatchResult(int32 Rank, int32 TotalPlayerCount);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;void ANBPlayerController::ClientShowMatchResult_Implementation(
    int32 Rank,
    int32 TotalPlayerCount)
{
    if (!IsLocalController() || !IsValid(ResultWidgetClass))
    {
        return;
    }

    UNBResultWidget* ResultWidget =
        CreateWidget&amp;lt;UNBResultWidget&amp;gt;(this, ResultWidgetClass);

    if (IsValid(ResultWidget))
    {
        ResultWidget-&amp;gt;SetResult(Rank, TotalPlayerCount);
        ResultWidget-&amp;gt;AddToViewport(10);

        FInputModeUIOnly InputMode;
        InputMode.SetWidgetToFocus(ResultWidget-&amp;gt;TakeWidget());
        SetInputMode(InputMode);
        bShowMouseCursor = true;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매치 결과는 유실되면 화면 자체가 뜨지 않으므로 &lt;code&gt;Reliable&lt;/code&gt;을 사용할 근거가 있다. 다만 Reliable RPC도 영구 데이터베이스는 아니다. 재접속 뒤에도 결과를 복구해야 한다면 순위와 결과를 PlayerState나 별도 백엔드에 저장해야 한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;결과 데이터&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;권장 전달 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;이유&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;당사자에게 한 번 띄울 순위 창&lt;/td&gt;
&lt;td&gt;PlayerController의 Reliable Client RPC&lt;/td&gt;
&lt;td&gt;특정 Owning Client에 일회성 UI 명령을 보낼 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모두가 보는 실시간 순위표&lt;/td&gt;
&lt;td&gt;PlayerState 프로퍼티 복제&lt;/td&gt;
&lt;td&gt;늦은 접속과 relevancy 재진입에도 최신 상태를 받을 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;일회성 연출과 사운드&lt;/td&gt;
&lt;td&gt;관련 대상 RPC&lt;/td&gt;
&lt;td&gt;지속 상태가 아니라 순간적인 표현이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;탈락 순위 계산에서는 배열 변경 순서가 규칙이 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생존자가 네 명일 때 한 명이 탈락하면 그 플레이어의 순위는 4위다. 따라서 제거하기 전 생존자 수를 순위로 저장하고, 배열에서 제거한 뒤 남은 인원으로 승자를 판단한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;void ANBGameModeBase::EliminatePlayer(ANBPlayerController* EliminatedPC)
{
    const int32 PlayerIndex = AlivePlayers.IndexOfByKey(EliminatedPC);
    if (PlayerIndex == INDEX_NONE)
    {
        return;
    }

    const int32 EliminatedRank = AlivePlayers.Num();
    EliminatedPC-&amp;gt;ClientShowMatchResult(
        EliminatedRank,
        AlivePlayers.Num() + DeadPlayers.Num());

    AlivePlayers.RemoveAt(PlayerIndex);
    DeadPlayers.Add(EliminatedPC);

    if (AlivePlayers.Num() == 1 &amp;amp;&amp;amp; IsValid(AlivePlayers[0]))
    {
        AlivePlayers[0]-&amp;gt;ClientShowMatchResult(
            1,
            AlivePlayers.Num() + DeadPlayers.Num());
        EnterEndingPhase();
    }
    else if (AlivePlayers.IsEmpty())
    {
        EnterEndingPhase();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AlivePlayers[0]&lt;/code&gt;을 읽기 전에 배열 크기를 검사해야 한다. 마지막 두 플레이어가 거의 동시에 나가거나 서버가 Controller를 정리하는 순간에는 생존자가 0명일 수 있다. 종료와 &lt;code&gt;Logout()&lt;/code&gt;이 겹쳐도 같은 플레이어에게 결과를 두 번 보내지 않도록 탈락 여부나 현재 Phase를 함께 검사하는 것이 안전하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과 UI는 로컬 객체이고 네트워크로 복제하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;UUserWidget&lt;/code&gt;을 복제하는 것이 아니라, 서버가 결과 데이터만 보내고 각 클라이언트가 자신의 Widget을 생성한다. &lt;code&gt;BindWidget&lt;/code&gt; 포인터는 Widget Blueprint의 이름과 타입이 맞아야 하며, 버튼 Delegate는 Widget이 다시 Construct될 가능성을 고려해 중복 바인딩을 막아야 한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;void UNBResultWidget::NativeConstruct()
{
    Super::NativeConstruct();

    if (IsValid(ReturnToTitleButton))
    {
        ReturnToTitleButton-&amp;gt;OnClicked.AddUniqueDynamic(
            this,
            &amp;amp;ThisClass::OnReturnToTitleClicked);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 결과 Widget의 존재 여부를 알 필요가 없다. 서버가 아는 것은 순위와 종료 단계뿐이며, Widget 생성 실패나 화면 배치는 로컬 표현 계층의 문제다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한 클라이언트의 복귀와 서버 전체 이동을 구분한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 이동 함수는 호출 위치에 따라 영향 범위가 달라진다. 이 차이를 놓치면 타이틀 버튼을 눌렀는데 서버까지 맵을 바꾸거나, 서버만 새 맵으로 이동하고 클라이언트가 남는 문제가 생긴다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;이동 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;호출 주체&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ClientTravel()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;클라이언트의 로컬 PlayerController&lt;/td&gt;
&lt;td&gt;해당 클라이언트가 맵 또는 다른 서버 URL로 이동한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ClientTravel()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버가 특정 PlayerController에 호출&lt;/td&gt;
&lt;td&gt;그 Owning Client에게 이동을 지시한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ServerTravel()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;서버가 새 월드로 이동하고 연결된 클라이언트가 함께 따라간다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OpenLevel()&lt;/code&gt; 또는 Browse 성격의 이동&lt;/td&gt;
&lt;td&gt;현재 World&lt;/td&gt;
&lt;td&gt;비연속 이동으로 월드를 다시 열며 서버에서는 기존 연결을 끊는 하드 리셋 성격이 강하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과 화면의 복귀 버튼은 로컬 클라이언트를 타이틀 맵으로 이동시키면 된다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;void UNBResultWidget::OnReturnToTitleClicked()
{
    if (APlayerController* PC = GetOwningPlayer())
    {
        PC-&amp;gt;ClientTravel(
            TEXT(&quot;/Game/NumberBaseball/Maps/Title&quot;),
            TRAVEL_Absolute);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;종료 제한 시간이 지나 서버가 강제로 내보낼 때도 각 PlayerController에 Client Travel을 지시할 수 있다. 반대로 &lt;code&gt;ServerTravel()&lt;/code&gt;로 타이틀 맵을 열면 서버 자신도 타이틀 맵으로 이동하고 모든 연결 클라이언트가 따라간다. 게임 서버는 게임 맵에 남기고 플레이어만 로컬 타이틀로 보낼 목적에는 맞지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 판 준비는 리셋 범위를 먼저 결정한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 초기화에는 하나의 정답이 없다. 게임 규칙만 초기화할지, 같은 연결을 유지한 채 새 맵으로 이동할지, 모든 연결을 끊고 월드를 다시 만들지에 따라 구현이 달라진다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;리셋 정책&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;유지되는 것&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;적합한 경우&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 World에서 논리 상태만 초기화&lt;/td&gt;
&lt;td&gt;연결, Controller, PlayerState, 맵 Actor&lt;/td&gt;
&lt;td&gt;같은 참가자와 빠르게 다음 라운드를 반복하는 게임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Travel&lt;/td&gt;
&lt;td&gt;설정에 따라 연결과 일부 Actor를 유지할 수 있음&lt;/td&gt;
&lt;td&gt;로비에서 게임 맵으로 이동하거나 모든 참가자가 다음 맵을 함께 로드하는 게임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비연속 맵 재로드&lt;/td&gt;
&lt;td&gt;대부분의 현재 World 상태를 유지하지 않음&lt;/td&gt;
&lt;td&gt;참가자를 내보낸 뒤 서버 월드를 완전히 새로 만들고 싶은 경우&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NumberBaseball의 현재 &lt;code&gt;ResetGame()&lt;/code&gt;은 첫 번째 정책을 사용한다. 비밀 숫자, 추측 횟수, 제출 여부, UI 상태를 초기화하고 같은 접속자와 다음 턴을 시작한다. 작은 턴제 게임에서는 맵 재로드 비용과 재접속 문제를 만들지 않는 효율적인 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맵 Actor까지 완전히 초기화해야 하는 액션 게임이라면 Server Travel이나 월드 재로드를 검토한다. 이때 클라이언트 RPC로 타이틀 이동을 요청한 직후 서버 World를 즉시 닫으면 전송과 연결 종료가 경쟁할 수 있다. 종료 유예 시간을 두고 클라이언트 이동을 먼저 지시한 뒤 서버 리셋을 수행하거나, 아예 모든 클라이언트가 함께 이동하는 Server Travel 구조로 정책을 통일하는 편이 예측 가능하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NumberBaseball의 현재 구조에 대입하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트는 이미 세션 생명주기의 핵심 패턴을 상당 부분 사용하고 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;현재 구현&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;세션 단위로 확장할 지점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANBGameModeBase::OnPostLogin()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;접속자 등록, 이름과 색상 배정, 개인 알림 전달&lt;/td&gt;
&lt;td&gt;정원이나 진행 중 참가 거부가 필요하면 &lt;code&gt;PreLogin()&lt;/code&gt; 정책 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AllPlayerControllers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버에서 전체 참가자와 RPC 대상을 관리&lt;/td&gt;
&lt;td&gt;탈락과 관전이 생기면 PlayerState에 지속 상태를 함께 보관&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FNBTurnState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;활성 플레이어, 남은 시간, 입력 가능 상태를 복제&lt;/td&gt;
&lt;td&gt;대기, 진행, 종료를 모두가 봐야 하면 세션 Phase를 GameState에 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TurnTimerHandle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버가 1초 단위로 턴을 진행&lt;/td&gt;
&lt;td&gt;타이머 종류가 늘면 종료 서버 시각 복제로 전송량 감소 검토&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bRoundEnding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버에서 입력과 중복 종료를 차단&lt;/td&gt;
&lt;td&gt;클라이언트 UI도 알아야 하면 공개 상태를 GameState에 복제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ClientRPCSetNotificationText()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;소유 클라이언트에 일회성 알림 전달&lt;/td&gt;
&lt;td&gt;전용 결과 Widget이 필요하면 순위 인자를 가진 Client RPC로 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ResetGame()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;같은 연결을 유지한 논리 라운드 리셋&lt;/td&gt;
&lt;td&gt;타이틀 복귀형 세션과 라운드 반복형 세션 중 제품 정책을 명확히 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 코드는 라운드 종료 중 새 플레이어가 들어오면 접속을 허용하되 채팅 입력을 막고 남은 재시작 시간을 보여 준다. 이것은 잘못이라기보다 &lt;b&gt;다음 라운드 대기 참가를 허용하는 정책&lt;/b&gt;이다. 게임 중 참가를 완전히 막고 싶을 때만 &lt;code&gt;PreLogin()&lt;/code&gt; 거절로 바꿔야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 실행 흐름&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;1. 로컬 Title PlayerController가 Title Widget을 생성한다.
2. 플레이어가 서버 주소를 입력하고 Join 버튼을 누른다.
3. 로컬 PlayerController가 ClientTravel로 서버에 접속한다.
4. 서버 GameMode가 PreLogin에서 참가 가능 여부를 검사한다.
5. 로그인 성공 뒤 PostLogin 계열 훅에서 참가자를 등록한다.
6. GameMode가 최소 인원과 대기 시간을 검사한다.
7. GameMode가 Playing으로 전환하고 GameState가 공개 상태를 복제한다.
8. 클라이언트 UI는 RepNotify와 로컬 Delegate로 상태를 표시한다.
9. 서버가 승패와 순위를 확정한다.
10. 각 PlayerController의 Client RPC가 개인 결과 Widget을 연다.
11. 클라이언트는 직접 또는 제한 시간 뒤 Title로 이동한다.
12. 서버는 논리 리셋, Server Travel, 월드 재로드 중 선택한 정책으로 다음 판을 준비한다.&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디버깅 체크리스트&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;확인할 항목&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타이틀 화면에서 Pawn이 생성된다.&lt;/td&gt;
&lt;td&gt;타이틀 GameMode의 &lt;code&gt;DefaultPawnClass&lt;/code&gt;와 World Settings의 GameMode Override를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데디케이티드 서버에서 Widget 관련 오류가 난다.&lt;/td&gt;
&lt;td&gt;Widget 생성 경로에 &lt;code&gt;IsLocalController()&lt;/code&gt; 검사가 있는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 접속 뒤 이동 입력이 되지 않는다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Super::BeginPlay()&lt;/code&gt;, 게임용 Input Mode, Enhanced Input Mapping Context 등록을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;게임 진행 중에도 새 플레이어가 잠깐 스폰된다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PostLogin()&lt;/code&gt;에서 제거하지 말고 &lt;code&gt;PreLogin()&lt;/code&gt;에서 참가를 거절하는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트가 GameMode 상태를 읽지 못한다.&lt;/td&gt;
&lt;td&gt;GameMode는 서버 전용이므로 공개 상태가 GameState에 복제되는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;남은 시간이 클라이언트마다 다르게 보인다.&lt;/td&gt;
&lt;td&gt;서버만 상태를 변경하는지, 서버 종료 시각과 &lt;code&gt;GetServerWorldTimeSeconds()&lt;/code&gt;를 사용하는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과 UI가 다른 플레이어에게 뜬다.&lt;/td&gt;
&lt;td&gt;Client RPC를 올바른 PlayerController 인스턴스에 호출했는지와 Owning Connection을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;마지막 플레이어가 나갈 때 서버가 크래시한다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AlivePlayers[0]&lt;/code&gt; 접근 전에 배열 크기와 Controller 유효성을 검사한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타이틀 복귀를 눌렀는데 모든 플레이어가 맵을 이동한다.&lt;/td&gt;
&lt;td&gt;개별 Client Travel 대신 Server Travel을 호출한 것은 아닌지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다음 판에 이전 Timer가 다시 실행된다.&lt;/td&gt;
&lt;td&gt;상태 전환, 리셋, &lt;code&gt;EndPlay()&lt;/code&gt;에서 모든 관련 Timer Handle을 정리하는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/74</guid>
      <comments>https://uegamedev.tistory.com/74#entry74comment</comments>
      <pubDate>Tue, 11 Aug 2026 20:01:55 +0900</pubDate>
    </item>
    <item>
      <title>UE C++ 숫자야구</title>
      <link>https://uegamedev.tistory.com/73</link>
      <description>&lt;h1&gt;Number Baseball&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal Engine 5.5와 C++로 제작한 데디케이티드 서버 기반 멀티플레이 숫자 야구 프로젝트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 정답, 턴, 제한 시간, 시도 횟수와 라운드 결과를 관리하고 각 클라이언트는 채팅 형식으로 숫자를 제출합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게임 규칙&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버는 라운드 시작 시 &lt;code&gt;1~9&lt;/code&gt; 중 서로 다른 숫자 세 개로 정답을 생성합니다.&lt;/li&gt;
&lt;li&gt;현재 턴인 플레이어가 정확히 세 자리의 유효한 숫자를 입력하면 숫자 야구 입력으로 처리합니다.&lt;/li&gt;
&lt;li&gt;숫자와 위치가 모두 같으면 &lt;code&gt;Strike&lt;/code&gt;, 숫자만 같으면 &lt;code&gt;Ball&lt;/code&gt;, 일치하는 숫자가 없으면 &lt;code&gt;OUT&lt;/code&gt;입니다.&lt;/li&gt;
&lt;li&gt;판정 결과는 &lt;code&gt;1S1B&lt;/code&gt;, &lt;code&gt;3S0B&lt;/code&gt;, &lt;code&gt;OUT&lt;/code&gt; 형식으로 전체 플레이어에게 전달됩니다.&lt;/li&gt;
&lt;li&gt;각 플레이어에게 한 라운드당 최대 5회의 기회가 주어집니다.&lt;/li&gt;
&lt;li&gt;먼저 &lt;code&gt;3S&lt;/code&gt;를 달성한 플레이어가 승리합니다.&lt;/li&gt;
&lt;li&gt;모든 플레이어가 기회를 소진할 때까지 승자가 없으면 무승부입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;채팅과 숫자 입력 구분&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 조건을 모두 만족하는 입력만 숫자 야구 시도로 인정합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;길이가 정확히 세 글자여야 합니다.&lt;/li&gt;
&lt;li&gt;모든 글자가 &lt;code&gt;1~9&lt;/code&gt; 사이의 숫자여야 합니다.&lt;/li&gt;
&lt;li&gt;같은 숫자가 중복되면 안 됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조건을 만족하지 않는 입력은 오류로 거부하지 않고 일반 채팅으로 전체 플레이어에게 전송합니다. 따라서 &lt;code&gt;12&lt;/code&gt;, &lt;code&gt;1234&lt;/code&gt;, &lt;code&gt;12A&lt;/code&gt;, &lt;code&gt;102&lt;/code&gt;, &lt;code&gt;113&lt;/code&gt;과 같은 입력은 기회를 소진하지 않습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주요 기능&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 권한 기반 정답 생성 및 판정&lt;/li&gt;
&lt;li&gt;플레이어별 시도 횟수 복제&lt;/li&gt;
&lt;li&gt;플레이어 접속 순서 기반 순차 턴&lt;/li&gt;
&lt;li&gt;서버에서 관리하고 &lt;code&gt;GameState&lt;/code&gt;로 복제하는 턴 타이머&lt;/li&gt;
&lt;li&gt;제한 시간 내 미입력 시 기회 1회 소진&lt;/li&gt;
&lt;li&gt;현재 턴이 아니거나 제한 시간이 끝난 숫자 입력 차단&lt;/li&gt;
&lt;li&gt;Enhanced Input을 사용한 &lt;code&gt;Enter&lt;/code&gt; 채팅 활성화&lt;/li&gt;
&lt;li&gt;플레이어별 채팅 색상&lt;/li&gt;
&lt;li&gt;승리&amp;middot;무승부 공지와 5초 재시작 카운트다운&lt;/li&gt;
&lt;li&gt;라운드 종료 중 채팅 및 숫자 입력 차단&lt;/li&gt;
&lt;li&gt;새 라운드 시작 시 채팅, 알림, 시도 횟수 및 턴 상태 초기화&lt;/li&gt;
&lt;li&gt;WBP에서 자유롭게 디자인할 수 있는 중앙 HUD 구조&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;네트워크 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트는 데디케이티드 서버를 기준으로 서버 권한을 분리합니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;클래스&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANBGameModeBase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버 전용 게임 규칙, 정답, 숫자 판정, 채팅 분기, 턴, 타이머, 승패와 라운드 리셋 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANBGameStateBase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;현재 플레이어, 남은 시간과 입력 허용 상태를 모든 클라이언트에 복제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANBPlayerState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;플레이어 이름, 채팅 색상, 현재/최대 시도 횟수와 턴 입력 여부를 복제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANBPlayerController&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;로컬 입력과 HUD 생성, 서버 RPC 요청, 클라이언트 RPC 수신&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNBGameHUDWidget&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;채팅 입력, 채팅 로그, 공지와 타이머 WBP를 한 화면에 모으는 중앙 HUD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 입력은 다음 순서로 처리됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;클라이언트 Enter 입력
  -&amp;gt; 채팅 입력창 활성화
  -&amp;gt; 원본 문자열을 Server RPC로 전송
  -&amp;gt; GameMode가 숫자 야구 입력인지 검사
     -&amp;gt; 유효하지 않음: 일반 채팅으로 전송, 기회 유지
     -&amp;gt; 유효함: 턴/시간/남은 기회 검사
        -&amp;gt; 정답 판정 및 기회 소진
        -&amp;gt; 승리/무승부 검사
        -&amp;gt; 게임이 계속되면 다음 플레이어 턴 시작&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 보내는 플레이어 이름이나 판정 결과를 신뢰하지 않습니다. 서버가 소유한 &lt;code&gt;PlayerState&lt;/code&gt;와 정답을 사용해 표시 문자열과 결과를 생성합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 정답 생성과 입력 판별&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ANBGameModeBase::GenerateSecretNumber&lt;/code&gt;에서 &lt;code&gt;1~9&lt;/code&gt;를 &lt;code&gt;TArray&amp;lt;int32&amp;gt;&lt;/code&gt;에 저장하고, 선택한 숫자를 배열에서 제거하면서 세 자리를 생성합니다. 같은 숫자를 다시 선택할 수 없으므로 항상 중복 없는 정답이 만들어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ANBGameModeBase::IsGuessNumberString&lt;/code&gt;은 문자열 길이, 문자 범위와 &lt;code&gt;TSet&amp;lt;TCHAR&amp;gt;&lt;/code&gt;의 원소 수를 검사합니다. 검증을 통과하지 못한 문자열은 일반 채팅으로 분기합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Strike/Ball/OUT 판정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ANBGameModeBase::JudgeResult&lt;/code&gt;는 정답과 입력을 자리별로 비교합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 위치의 같은 숫자: Strike 증가&lt;/li&gt;
&lt;li&gt;정답에 존재하지만 위치가 다른 숫자: Ball 증가&lt;/li&gt;
&lt;li&gt;Strike와 Ball이 모두 0: &lt;code&gt;OUT&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 시도 횟수와 플레이어 상태&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ANBPlayerState&lt;/code&gt;가 &lt;code&gt;CurrentGuessCount&lt;/code&gt;와 &lt;code&gt;MaxGuessCount&lt;/code&gt;를 관리합니다. 기본 최대 횟수는 5회이며 두 값은 복제되어 클라이언트에서도 &lt;code&gt;[현재 / 최대]&lt;/code&gt; 형식으로 표시할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 채팅은 시도 횟수에 영향을 주지 않습니다. 유효한 세 자리 숫자를 정상적으로 제출하거나 자신의 턴 제한 시간이 끝났을 때만 횟수가 증가합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 턴과 시간 동기화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ANBGameModeBase&lt;/code&gt;의 서버 타이머가 매초 남은 시간을 감소시킵니다. 현재 턴 플레이어와 남은 시간은 &lt;code&gt;FNBTurnState&lt;/code&gt;에 저장되어 &lt;code&gt;ANBGameStateBase&lt;/code&gt;를 통해 복제됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 타이머 위젯은 복제 알림 델리게이트를 구독해 같은 서버 값을 표시합니다. 남은 시간이 0이면 서버의 &lt;code&gt;CanAcceptGuess&lt;/code&gt; 검사도 실패하므로 UI 상태와 관계없이 숫자 입력이 처리되지 않습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 승리, 무승부와 라운드 리셋&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;3S&lt;/code&gt;가 나오면 즉시 승자를 확정합니다. 승자가 없고 모든 플레이어가 최대 시도 횟수에 도달하면 무승부를 확정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라운드가 끝나면 다음 과정이 실행됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;턴 타이머를 중지하고 숫자 및 채팅 입력을 차단합니다.&lt;/li&gt;
&lt;li&gt;승자 또는 무승부 메시지와 5초 카운트다운을 표시합니다.&lt;/li&gt;
&lt;li&gt;카운트다운이 끝나면 새 정답을 생성합니다.&lt;/li&gt;
&lt;li&gt;모든 플레이어의 시도 횟수와 턴 입력 상태를 초기화합니다.&lt;/li&gt;
&lt;li&gt;채팅 로그, 입력창과 공지 UI를 초기화합니다.&lt;/li&gt;
&lt;li&gt;첫 번째로 플레이 가능한 플레이어부터 새 라운드를 시작합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. C++ 공용 라이브러리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NumberBaseballFunctionLibrary&lt;/code&gt;는 화면/로그 출력과 NetMode 문자열 변환처럼 객체 상태가 필요 없는 공용 C++ 기능을 정적 함수로 제공합니다. 이 클래스는 C++ 코드 재사용을 위한 일반 헬퍼이며, 블루프린트 노드 노출이 목적이 아니므로 &lt;code&gt;UBlueprintFunctionLibrary&lt;/code&gt;를 상속하지 않습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;UI 구성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 UI는 &lt;code&gt;WBP_GameHUD&lt;/code&gt;에서 한 번에 배치합니다. C++ 부모 클래스는 동작과 데이터 연결을 담당하고 색상, 폰트, 크기와 위치는 WBP Designer에서 수정합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;중앙 HUD 필수 자식 이름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WBP_GameHUD&lt;/code&gt;는 &lt;code&gt;UNBGameHUDWidget&lt;/code&gt;을 부모로 사용하며 다음 자식 WBP를 정확한 이름으로 배치합니다.&lt;/p&gt;
&lt;table style=&quot;height: 96px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 24px;&quot;&gt;
&lt;th style=&quot;height: 24px;&quot;&gt;자식 WBP&lt;/th&gt;
&lt;th style=&quot;height: 24px;&quot;&gt;&lt;code&gt;WBP_GameHUD&lt;/code&gt; 내부 인스턴스 이름&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;WBP_ChatInput&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;Widget_ChatInput&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;WBP_ChatLog&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;Widget_ChatLog&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;WBP_NotificationText&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;Widget_Notification&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;WBP_TurnTimer&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;Widget_TurnTimer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 자식 WBP의 C++ 연결용 위젯 이름은 다음과 같습니다.&lt;/p&gt;
&lt;table style=&quot;height: 78px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 24px;&quot;&gt;
&lt;th style=&quot;height: 24px;&quot;&gt;WBP&lt;/th&gt;
&lt;th style=&quot;height: 24px;&quot;&gt;부모 클래스&lt;/th&gt;
&lt;th style=&quot;height: 24px;&quot;&gt;연결 위젯 이름&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;WBP_ChatInput&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;UNBChatInput&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;EditableTextBox_ChatInput&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;WBP_ChatLog&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;UNBChatLogWidget&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;ScrollBox_ChatLog&lt;/code&gt;, &lt;code&gt;VerticalBox_ChatLog&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;WBP_TurnTimer&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;UNBTurnTimerWidget&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;TextBlock_TurnTimer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사용한 언리얼 자료구조와 기능&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;TArray&lt;/code&gt;: 접속 플레이어, 숫자 후보, 채팅 메시지와 색상 목록&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TSet&lt;/code&gt;: 입력 숫자 중복 검사&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TObjectPtr&lt;/code&gt;: UObject 및 Actor 참조&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TWeakObjectPtr&lt;/code&gt;: 위젯에서 GameState를 안전하게 참조&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TSubclassOf&lt;/code&gt;: WBP 클래스 지정&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FTimerHandle&lt;/code&gt;: 턴 타이머와 라운드 리셋 카운트다운&lt;/li&gt;
&lt;li&gt;Property Replication과 &lt;code&gt;ReplicatedUsing&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Server, Client, NetMulticast RPC&lt;/li&gt;
&lt;li&gt;Enhanced Input&lt;/li&gt;
&lt;li&gt;UMG &lt;code&gt;BindWidget&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 실행&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Unreal Engine 5.5에서 &lt;code&gt;NumberBaseball.uproject&lt;/code&gt;를 엽니다.&lt;/li&gt;
&lt;li&gt;에디터 시작 및 기본 맵은 &lt;code&gt;/Game/NumberBaseball/Maps/L_Chatting&lt;/code&gt;입니다.&lt;/li&gt;
&lt;li&gt;멀티플레이 테스트 시 플레이어 수를 2명 이상으로 설정합니다.&lt;/li&gt;
&lt;li&gt;데디케이티드 서버 기준 테스트는 Play 설정에서 별도 서버 실행을 활성화하고 클라이언트 모드로 실행합니다.&lt;/li&gt;
&lt;li&gt;게임 화면에서 &lt;code&gt;Enter&lt;/code&gt;를 눌러 채팅창을 활성화하고 메시지 또는 세 자리 숫자를 입력합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NumberBaseball.GameMode.Validation&lt;/code&gt; 자동화 테스트가 다음 항목을 확인합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반복 생성한 정답이 항상 1~9의 중복 없는 세 자리인지 확인&lt;/li&gt;
&lt;li&gt;유효/무효 숫자 입력 구분&lt;/li&gt;
&lt;li&gt;Strike/Ball/OUT 결과&lt;/li&gt;
&lt;li&gt;라운드 종료 카운트다운 문자열&lt;/li&gt;
&lt;li&gt;기본 시도 횟수 5회와 표시 형식&lt;/li&gt;
&lt;li&gt;초기 턴 입력 차단 상태&lt;/li&gt;
&lt;li&gt;타이머 및 현재 턴 표시 형식&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal Editor의 &lt;b&gt;Tools &amp;gt; Test Automation&lt;/b&gt;에서 &lt;code&gt;NumberBaseball.GameMode.Validation&lt;/code&gt;을 검색해 실행할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주요 소스 위치&lt;/h2&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;Source/NumberBaseball/
├─ Game/
│  ├─ NBGameModeBase.*       # 서버 게임 규칙과 라운드 진행
│  └─ NBGameStateBase.*      # 복제되는 턴 상태
├─ Player/
│  ├─ NBPlayerController.*   # 입력, RPC와 HUD 생성
│  └─ NBPlayerState.*        # 플레이어별 복제 상태
├─ UI/
│  ├─ NBGameHUDWidget.*      # 중앙 HUD 컨테이너
│  ├─ NBChatInput.*          # 채팅 입력 동작
│  ├─ NBChatLogWidget.*      # 채팅 기록과 색상
│  └─ NBTurnTimerWidget.*    # 복제 타이머 표시
└─ Tests/
   └─ NBGameModeBaseTest.cpp # 자동화 테스트&lt;/code&gt;&lt;/pre&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/73</guid>
      <comments>https://uegamedev.tistory.com/73#entry73comment</comments>
      <pubDate>Mon, 10 Aug 2026 19:49:17 +0900</pubDate>
    </item>
    <item>
      <title>UE C++ 멀티플레이 동기화</title>
      <link>https://uegamedev.tistory.com/72</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;언리얼 멀티플레이 전투 동기화: Animation, Attack Prediction, ActorComponent Replication&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 캐릭터의 전투를 동기화하려면 애니메이션, 입력, 피격 판정, 체력, UI처럼 수명이 다른 정보를 함께 다뤄야 한다. 이 요소들을 모두 같은 RPC로 전달하거나 모든 변수를 복제하면 구현은 시작하기 쉽지만, 지연과 패킷 손실이 생기는 순간 결과가 어긋난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안정적인 구조를 만들려면 정보를 세 계층으로 나눠야 한다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;표현 계층
  이동 애니메이션, Aim Offset, 공격 몽타주, 파티클

판정 계층
  공격 가능 여부, 공격 시점, 충돌 검사, 데미지

상태 계층
  현재 HP, 최대 HP, 사망 여부, 버프 상태&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;표현은 각 클라이언트가 복제된 상태를 바탕으로 재생한다.&lt;/li&gt;
&lt;li&gt;게임 결과는 서버가 판정한다.&lt;/li&gt;
&lt;li&gt;현재 상태는 Property Replication으로 모든 클라이언트가 수렴한다.&lt;/li&gt;
&lt;li&gt;소유 클라이언트는 필요한 부분을 먼저 예측하고 서버 결과로 보정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;애니메이션을 복제하는 대신 원인이 되는 상태를 복제한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;걷기, 달리기, 점프 같은 로코모션 애니메이션은 매 프레임 애니메이션 이름을 네트워크로 보낼 필요가 없다. &lt;code&gt;ACharacter&lt;/code&gt;와 &lt;code&gt;UCharacterMovementComponent&lt;/code&gt;가 위치, 회전, 속도, Movement Mode를 동기화하면 각 컴퓨터의 AnimInstance가 자신의 로컬 CharacterMovement 상태를 읽어 같은 포즈를 계산할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;소유 클라이언트 입력
  &amp;rarr; CharacterMovement 로컬 예측
  &amp;rarr; 이동 데이터를 서버로 전송
  &amp;rarr; 서버 재현 및 검증
  &amp;rarr; 서버 이동 상태를 Simulated Proxy에 복제
  &amp;rarr; 각 컴퓨터의 AnimInstance가 로코모션 파라미터 계산&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 동기화되는 것은 &lt;code&gt;GroundSpeed&lt;/code&gt;나 &lt;code&gt;bIsFalling&lt;/code&gt;이라는 애니메이션 변수 자체가 아니다. 이동 시스템이 동기화되고, AnimInstance가 그 결과에서 애니메이션 변수를 다시 계산한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;네트워크 역할별 CharacterMovement&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;네트워크 역할&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;존재 위치&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;이동 처리&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autonomous Proxy&lt;/td&gt;
&lt;td&gt;캐릭터를 직접 조작하는 소유 클라이언트&lt;/td&gt;
&lt;td&gt;입력을 즉시 예측하고 이동 기록을 서버에 보낸다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authority&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;클라이언트 이동을 재현하고 검증하며 권위 있는 결과를 만든다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simulated Proxy&lt;/td&gt;
&lt;td&gt;해당 캐릭터를 바라보는 다른 클라이언트&lt;/td&gt;
&lt;td&gt;서버에서 받은 이동 상태를 보간해 표현한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 Actor의 &lt;code&gt;bReplicateMovement&lt;/code&gt;와 CharacterMovement의 네트워크 처리 경로를 혼동하면 안 된다. &lt;code&gt;ACharacter&lt;/code&gt;는 CharacterMovement와 함께 이동 예측, 서버 검증, 오차 보정, Simulated Proxy 보간을 위한 전용 경로를 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Replicate Movement&lt;/code&gt;를 끄면 원격 Character의 위치와 속도가 더 이상 정상적으로 전달되지 않으므로, AnimInstance가 계산하는 로코모션도 자연스럽게 동기화되지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AnimInstance에서 로코모션 상태 계산하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AnimInstance는 초기화 단계에서 소유 Character와 MovementComponent를 캐시하고, 매 업데이트에 필요한 표시용 값을 계산한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void UDXAnimInstanceBase::NativeInitializeAnimation()
{
    Super::NativeInitializeAnimation();

    OwnerCharacter = Cast&amp;lt;ADXPlayerCharacter&amp;gt;(TryGetPawnOwner());
    if (IsValid(OwnerCharacter))
    {
        OwnerMovement = OwnerCharacter-&amp;gt;GetCharacterMovement();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;void UDXAnimInstanceBase::NativeUpdateAnimation(float DeltaSeconds)
{
    Super::NativeUpdateAnimation(DeltaSeconds);

    if (!IsValid(OwnerCharacter) || !IsValid(OwnerMovement))
    {
        return;
    }

    Velocity = OwnerMovement-&amp;gt;Velocity;
    GroundSpeed = Velocity.Size2D();
    bShouldMove = GroundSpeed &amp;gt; 3.0f;
    bIsFalling = OwnerMovement-&amp;gt;IsFalling();
}&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;애니메이션 변수&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;파생 원본&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;별도 복제 필요 여부&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GroundSpeed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CharacterMovement-&amp;gt;Velocity.Size2D()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;일반적으로 불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bIsFalling&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CharacterMovement-&amp;gt;IsFalling()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;일반적으로 불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이동 방향&lt;/td&gt;
&lt;td&gt;Velocity와 Actor Rotation의 차이&lt;/td&gt;
&lt;td&gt;일반적으로 불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;공격 시작&lt;/td&gt;
&lt;td&gt;일회성 게임플레이 이벤트&lt;/td&gt;
&lt;td&gt;RPC 또는 복제된 공격 상태 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사망 상태&lt;/td&gt;
&lt;td&gt;서버가 확정한 영속 상태&lt;/td&gt;
&lt;td&gt;Property Replication 권장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GetCurrentAcceleration()&lt;/code&gt;은 소유 클라이언트의 입력 의도를 표현하는 데 유용하지만, Simulated Proxy에서는 동일한 정밀도로 존재한다고 가정하기 어렵다. 원격 캐릭터의 단순 Idle/Move 전환은 속도 기준으로 만들고, 가속&amp;middot;감속 표현이 꼭 필요할 때만 역할별 데이터를 구분하는 편이 안전하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Aim Offset 동기화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카메라 Pitch는 캐릭터의 Actor Rotation에 그대로 포함되지 않는 경우가 많다. 따라서 원격 캐릭터가 위아래를 바라보는 방향을 Aim Offset에 사용하려면 별도의 시선 정보가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 &lt;code&gt;float AimPitch&lt;/code&gt;를 Tick마다 Server RPC로 보내기 전에 &lt;code&gt;APawn&lt;/code&gt;이 이미 제공하는 기능을 확인하는 것이 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UE 5.5의 &lt;code&gt;APawn&lt;/code&gt;은 서버에서 Controller의 Pitch를 &lt;code&gt;RemoteViewPitch&lt;/code&gt;에 압축해 저장하고, 소유자를 제외한 클라이언트에 복제한다. &lt;code&gt;GetBaseAimRotation()&lt;/code&gt;은 로컬 Pawn에서는 Controller의 시선 회전을, 원격 Pawn에서는 복원된 &lt;code&gt;RemoteViewPitch&lt;/code&gt;를 이용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 기본적인 Aim Pitch는 다음처럼 계산할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;void UDXAnimInstanceBase::UpdateAimOffset()
{
    const FRotator AimRotation =
        OwnerCharacter-&amp;gt;GetBaseAimRotation();

    const FRotator ActorRotation =
        OwnerCharacter-&amp;gt;GetActorRotation();

    const FRotator DeltaRotation =
        (AimRotation - ActorRotation).GetNormalized();

    AimPitch = DeltaRotation.Pitch;
    AimYaw = DeltaRotation.Yaw;
}&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;접근 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;주의점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GetBaseAimRotation()&lt;/code&gt; 활용&lt;/td&gt;
&lt;td&gt;기존 Pawn의 RemoteViewPitch 경로를 재사용한다.&lt;/td&gt;
&lt;td&gt;게임의 시선 규칙이 기본 Pawn 동작과 맞는지 확인해야 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커스텀 Aim 값 복제&lt;/td&gt;
&lt;td&gt;상체 회전, 무기 방향 등 고유 규칙을 표현할 수 있다.&lt;/td&gt;
&lt;td&gt;전송 빈도, 양자화, 소유자 제외 조건과 보간을 직접 설계해야 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;커스텀 Aim 값을 보내야 한다면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 Pitch만으로 부족한 게임에서는 커스텀 데이터를 보낼 수 있다. 하지만 매 Tick마다 &lt;code&gt;float&lt;/code&gt;가 조금이라도 달라졌다는 이유로 RPC를 보내는 방식은 피하는 것이 좋다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각도를 &lt;code&gt;uint8&lt;/code&gt; 또는 &lt;code&gt;uint16&lt;/code&gt; 범위로 양자화한다.&lt;/li&gt;
&lt;li&gt;일정 각도 이상 변했을 때만 전송한다.&lt;/li&gt;
&lt;li&gt;초당 전송 횟수를 제한한다.&lt;/li&gt;
&lt;li&gt;Server RPC는 Unreliable로 보내고 다음 값으로 유실을 보완한다.&lt;/li&gt;
&lt;li&gt;서버에서 허용 범위로 Clamp한다.&lt;/li&gt;
&lt;li&gt;소유 클라이언트는 로컬 값을 사용하고 원격 프록시만 복제값을 사용한다.&lt;/li&gt;
&lt;li&gt;원격 AnimInstance에서는 복제값 사이를 보간한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::UpdateLocalAim(float DeltaSeconds)
{
    if (!IsLocallyControlled())
    {
        return;
    }

    AimSendAccumulator += DeltaSeconds;

    const float NewPitch = FMath::Clamp(
        FRotator::NormalizeAxis(GetControlRotation().Pitch),
        -90.0f,
        90.0f
    );

    const bool bChangedEnough =
        FMath::Abs(NewPitch - LastSentAimPitch) &amp;gt;= 1.0f;

    if (AimSendAccumulator &amp;gt;= 0.05f &amp;amp;&amp;amp; bChangedEnough)
    {
        ServerUpdateAimPitch(NewPitch);
        LastSentAimPitch = NewPitch;
        AimSendAccumulator = 0.0f;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Server, Unreliable)
void ServerUpdateAimPitch(float NewPitch);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::ServerUpdateAimPitch_Implementation(
    float NewPitch
)
{
    ReplicatedAimPitch = FMath::Clamp(
        NewPitch,
        -90.0f,
        90.0f
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소유 클라이언트는 이미 자신의 입력값을 알고 있으므로 커스텀 프로퍼티도 &lt;code&gt;COND_SkipOwner&lt;/code&gt;로 등록할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;DOREPLIFETIME_CONDITION(
    ThisClass,
    ReplicatedAimPitch,
    COND_SkipOwner
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 예시는 기본 개념을 보여 주기 위한 것이다. 실제 트래픽을 줄이려면 RPC 인자부터 복제 프로퍼티까지 압축된 정수 표현을 유지하는 편이 낫다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공격 몽타주는 자동으로 모든 컴퓨터에서 재생되지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이동 애니메이션은 복제된 이동 상태에서 파생할 수 있지만, 근접 공격처럼 특정 순간에 시작하는 Montage는 별도의 시작 신호가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Root Motion Montage의 이동 데이터는 CharacterMovement 복제 경로에서 일부 처리되지만, &lt;code&gt;Montage_Play()&lt;/code&gt; 호출 자체가 모든 컴퓨터에서 자동 실행되는 것은 아니다. 각 인스턴스가 같은 공격을 재생하도록 게임 코드에서 RPC나 복제 상태로 시작을 전달해야 한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;애니메이션 유형&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;동기화 원천&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;대표 구현&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle, Walk, Run, Jump&lt;/td&gt;
&lt;td&gt;복제된 Velocity와 Movement Mode&lt;/td&gt;
&lt;td&gt;Anim Blueprint State Machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;짧은 근접 공격&lt;/td&gt;
&lt;td&gt;공격 시작 사건&lt;/td&gt;
&lt;td&gt;로컬 예측 + Server RPC + Multicast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;긴 채널링 공격&lt;/td&gt;
&lt;td&gt;시작 시각, 단계, 취소 여부&lt;/td&gt;
&lt;td&gt;복제된 공격 상태와 RepNotify&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사망 포즈&lt;/td&gt;
&lt;td&gt;서버가 확정한 사망 상태&lt;/td&gt;
&lt;td&gt;ReplicatedUsing 상태에서 재생&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 응답만 기다리면 입력 지연이 그대로 보인다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 공격 흐름은 클라이언트가 Server RPC를 보내고 서버가 승인한 뒤 Multicast로 몽타주를 재생하는 방식이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;입력
  &amp;rarr; Server RPC
  &amp;rarr; 서버 승인
  &amp;rarr; Multicast
  &amp;rarr; 소유 클라이언트 몽타주 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왕복 지연이 100ms라면 버튼을 누른 뒤 약 100ms 후에 내 캐릭터가 반응할 수 있다. 지연이 커질수록 조작감이 무거워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 줄이기 위해 소유 클라이언트는 공격 몽타주를 즉시 재생하고, 동시에 서버에 공격을 요청한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;소유 클라이언트
  입력 즉시 몽타주 재생 ───────────────┐
  Server RPC 전송                      │
                                      &amp;darr;
서버
  공격 가능 여부 검증 &amp;rarr; 권위 공격 시작 &amp;rarr; 원격 프록시에 재생 신호&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 클라이언트 예측이다. 화면 반응은 즉시 제공하지만 공격 성공과 데미지는 서버가 확정한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;예측을 포함한 공격 시작 구조&lt;/h2&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(Server, Reliable)
void ServerRequestMeleeAttack();

UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayMeleeAttack();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소유 클라이언트는 로컬 조건을 확인한 뒤 먼저 몽타주를 재생한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::HandleMeleeAttackInput()
{
    if (!IsLocallyControlled() || !CanPredictMeleeAttack())
    {
        return;
    }

    PlayMeleeAttackMontage();
    ServerRequestMeleeAttack();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 클라이언트의 &lt;code&gt;bCanAttack&lt;/code&gt;을 믿지 않고 자신의 상태로 다시 검증한다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::ServerRequestMeleeAttack_Implementation()
{
    if (!CanStartMeleeAttackOnServer())
    {
        ClientRejectMeleeAttack();
        return;
    }

    bCanAttack = false;
    PlayMeleeAttackMontage();
    MulticastPlayMeleeAttack();

    GetWorldTimerManager().SetTimer(
        AttackCooldownHandle,
        this,
        &amp;amp;ThisClass::FinishMeleeAttackOnServer,
        MeleeAttackDuration,
        false
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Multicast는 소유 클라이언트와 서버에서 이미 재생한 몽타주를 다시 시작하지 않도록 구분한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::MulticastPlayMeleeAttack_Implementation()
{
    if (HasAuthority() || IsLocallyControlled())
    {
        return;
    }

    PlayMeleeAttackMontage();
}&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;인스턴스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;몽타주 시작 시점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;목적&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;소유 클라이언트&lt;/td&gt;
&lt;td&gt;입력 즉시&lt;/td&gt;
&lt;td&gt;조작 지연을 숨긴다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;Server RPC 승인 후&lt;/td&gt;
&lt;td&gt;권위 있는 공격 타이밍과 Notify를 처리한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다른 클라이언트&lt;/td&gt;
&lt;td&gt;Multicast 수신 후&lt;/td&gt;
&lt;td&gt;원격 캐릭터의 공격을 표현한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;짧은 코스메틱 공격 몽타주는 Unreliable Multicast로도 충분할 수 있다. 한 번 유실되더라도 서버의 데미지와 다음 상태는 유지되기 때문이다. 반면 공격 상태가 여러 초 동안 지속되거나 늦게 relevant해진 플레이어도 현재 단계를 알아야 한다면 &lt;code&gt;AttackState&lt;/code&gt;, 서버 시작 시각, Montage Section을 프로퍼티로 복제해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버가 거절한 예측은 보정해야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 조건과 서버 조건은 지연 때문에 달라질 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트 화면에서는 쿨다운이 끝났지만 서버에서는 아직 끝나지 않았다.&lt;/li&gt;
&lt;li&gt;서버에서 이미 기절 또는 사망 상태가 되었다.&lt;/li&gt;
&lt;li&gt;이동 모드가 공격 불가능한 상태로 바뀌었다.&lt;/li&gt;
&lt;li&gt;입력이 너무 빠르게 반복되었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 공격을 거절했다면 소유 클라이언트의 예측 몽타주를 중단하거나 상태를 되돌려야 한다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Unreliable)
void ClientRejectMeleeAttack();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::ClientRejectMeleeAttack_Implementation()
{
    StopMeleeAttackMontage(0.1f);
    RestorePredictedAttackState();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거절 응답을 Reliable로 만들지는 호출 빈도와 복구 방법에 따라 결정한다. 다음 복제 상태가 곧 잘못된 예측을 덮어쓸 수 있다면 Unreliable도 선택할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공격 시간은 클라이언트가 결정하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GameState-&amp;gt;GetServerWorldTimeSeconds()&lt;/code&gt;는 클라이언트에서도 서버 시간에 가까운 값을 얻는 데 유용하다. 하지만 클라이언트가 보낸 공격 시각을 그대로 신뢰해 쿨다운을 판정해서는 안 된다. 클라이언트는 자신의 메모리와 RPC 인자를 수정할 수 있기 때문이다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;시간 정보 사용처&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;권장 기준&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 몽타주 재생 위치 보정&lt;/td&gt;
&lt;td&gt;동기화된 서버 시간과 서버가 확정한 시작 시각&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;공격 쿨다운 승인&lt;/td&gt;
&lt;td&gt;서버가 기록한 마지막 승인 시각&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;피격 판정&lt;/td&gt;
&lt;td&gt;서버 월드 상태 또는 서버가 관리하는 과거 스냅샷&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;디버그 표시&lt;/td&gt;
&lt;td&gt;서버&amp;middot;클라이언트 시각을 함께 기록&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지연 시간을 쿨다운에서 단순히 빼 주면 높은 지연을 가진 클라이언트가 더 빠르게 공격할 수 있는 규칙이 만들어질 수 있다. 입력 반응성은 로컬 예측으로 해결하고, 공격 빈도 제한은 서버의 승인 기록으로 유지하는 편이 명확하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Anim Notify와 데미지 판정을 분리한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격 Montage의 특정 프레임에 Anim Notify를 배치하면 타격 시점을 애니메이션과 맞출 수 있다. 하지만 같은 Montage는 서버와 여러 클라이언트에서 재생되므로 Notify도 각 인스턴스에서 호출될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데미지 검사는 서버에서만 실행해야 한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void UDXAnimInstanceBase::AnimNotify_CheckMeleeHit()
{
    if (IsValid(OwnerCharacter) &amp;amp;&amp;amp;
        OwnerCharacter-&amp;gt;HasAuthority())
    {
        OwnerCharacter-&amp;gt;CheckMeleeAttackHitOnServer();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Montage Notify의 Tick Type을 &lt;code&gt;Branching Point&lt;/code&gt;로 설정하면 정확한 프레임 타이밍이 필요한 Notify를 더 정밀하게 처리할 수 있다. 다만 Branching Point가 네트워크 동기화를 대신하거나 중복 호출 가능성을 제거하는 것은 아니다. 데미지 함수의 Authority 검사와 공격별 중복 피격 방지는 여전히 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전용 서버에서 Skeletal Mesh 애니메이션 평가를 최적화하면 화면에 보이지 않는 Mesh의 Pose나 Notify가 기대한 빈도로 처리되지 않을 수도 있다. 중요한 판정을 Notify에 연결한다면 서버의 &lt;code&gt;Visibility Based Anim Tick Option&lt;/code&gt;과 Montage 실행 여부를 반드시 검증해야 한다. 더 명시적인 구조가 필요하면 서버가 공격을 승인할 때 권위 있는 타격 타이머를 시작하고, Anim Notify는 로컬 사운드와 Trail 같은 표현 타이밍에만 사용한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 단순하고 안전한 근접 공격 판정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 구현에서는 서버가 공격 Notify 시점에 Sweep을 실행한다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::CheckMeleeAttackHitOnServer()
{
    check(HasAuthority());

    TArray&amp;lt;FHitResult&amp;gt; HitResults;
    TSet&amp;lt;AActor*&amp;gt; UniqueTargets;

    const FVector Forward = GetActorForwardVector();
    const FVector Start =
        GetActorLocation() +
        Forward * GetCapsuleComponent()-&amp;gt;GetScaledCapsuleRadius();

    const FVector End = Start + Forward * MeleeAttackRange;

    FCollisionQueryParams QueryParams;
    QueryParams.AddIgnoredActor(this);

    const bool bHit = GetWorld()-&amp;gt;SweepMultiByChannel(
        HitResults,
        Start,
        End,
        FQuat::Identity,
        ECC_Pawn,
        FCollisionShape::MakeSphere(MeleeAttackRadius),
        QueryParams
    );

    if (!bHit)
    {
        return;
    }

    for (const FHitResult&amp;amp; Hit : HitResults)
    {
        AActor* Target = Hit.GetActor();
        if (!IsValid(Target) || UniqueTargets.Contains(Target))
        {
            continue;
        }

        UniqueTargets.Add(Target);

        UGameplayStatics::ApplyDamage(
            Target,
            MeleeAttackDamage,
            GetController(),
            this,
            UDamageType::StaticClass()
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;TSet&lt;/code&gt;으로 같은 공격에서 동일한 대상이 여러 HitResult에 잡혀도 한 번만 데미지를 적용한다. 공격 콜리전 채널은 &lt;code&gt;ECC_Camera&lt;/code&gt; 같은 임시 채널보다 프로젝트 전용 Trace Channel을 만들어 사용하는 편이 좋다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클라이언트가 보고한 피격 대상은 힌트일 뿐이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 클라이언트에서 Sweep을 실행하면 화면에서 맞았다고 느낀 순간을 빠르게 포착할 수 있다. 그러나 클라이언트가 &lt;code&gt;ServerApplyDamage(Target)&lt;/code&gt;처럼 대상만 보내고 서버가 즉시 데미지를 적용하면 치팅에 취약하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 최소한 다음을 다시 검사해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버가 승인한 공격이 진행 중인가?&lt;/li&gt;
&lt;li&gt;Notify에 대응하는 유효한 공격 구간인가?&lt;/li&gt;
&lt;li&gt;대상이 살아 있고 공격 가능한 타입인가?&lt;/li&gt;
&lt;li&gt;공격자와 대상 사이의 거리가 유효한가?&lt;/li&gt;
&lt;li&gt;공격자의 전방 각도 안에 있는가?&lt;/li&gt;
&lt;li&gt;벽이나 장애물로 시야가 막히지 않았는가?&lt;/li&gt;
&lt;li&gt;같은 Attack ID로 이미 맞힌 대상이 아닌가?&lt;/li&gt;
&lt;li&gt;클라이언트가 보낸 시각이 허용 오차 안에 있는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;판정 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;비용과 위험&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;현재 서버 위치에서 판정&lt;/td&gt;
&lt;td&gt;단순하고 서버 권위가 명확하다.&lt;/td&gt;
&lt;td&gt;지연이 큰 플레이어에게 판정이 늦게 느껴질 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 대상 보고 + 서버 재검증&lt;/td&gt;
&lt;td&gt;클라이언트 체감과 서버 판정을 절충할 수 있다.&lt;/td&gt;
&lt;td&gt;검증 항목이 빠지면 치팅에 취약하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 Rewind&lt;/td&gt;
&lt;td&gt;클라이언트가 공격한 과거 시점의 위치로 판정할 수 있다.&lt;/td&gt;
&lt;td&gt;과거 스냅샷, 시간 동기화, 보간과 보안 설계가 필요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 Rewind는 단순히 클라이언트가 보낸 시간을 빼는 기능이 아니다. 서버가 과거 충돌 상태를 일정 시간 보관하고, 허용 범위의 시각으로 되감아 검사한 뒤 현재 월드에 결과를 적용하는 별도의 시스템이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공격 가능 상태는 서버가 소유한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;bCanAttack&lt;/code&gt;은 서버가 최종 결정해야 하는 게임플레이 상태다. 클라이언트는 로컬 예측용 복사본을 가질 수 있지만 서버의 권위 상태를 변경할 수는 없다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = OnRep_CanAttack)
bool bCanAttack = true;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;OnRep_CanAttack()&lt;/code&gt;에서는 UI와 표시 상태를 갱신하고, 서버에서 값을 변경할 때는 동일한 공통 함수를 직접 호출한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::SetCanAttackOnServer(bool bNewCanAttack)
{
    check(HasAuthority());

    bCanAttack = bNewCanAttack;
    ApplyCanAttackState();
}

void ADXPlayerCharacter::OnRep_CanAttack()
{
    ApplyCanAttackState();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격 중 이동 금지를 구현하려고 &lt;code&gt;MOVE_None&lt;/code&gt;을 무조건 복제된 bool에 연결하면 CharacterMovement의 예측과 서버 보정이 크게 튈 수 있다. 이동 불가가 게임 규칙이라면 서버와 소유 클라이언트의 예측 경로 모두에 같은 규칙을 넣고, 거절 시 보정하는 구조를 고려해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ActorComponent로 상태 시스템 분리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체력, 스태미나, 버프처럼 여러 Actor에서 재사용되는 상태는 &lt;code&gt;UActorComponent&lt;/code&gt;로 분리할 수 있다. ActorComponent는 Owner Actor의 일부인 Subobject로 복제된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트 프로퍼티가 복제되려면 두 단계가 모두 활성화되어야 한다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;ADXPlayerCharacter::ADXPlayerCharacter()
{
    bReplicates = true;

    StatusComponent =
        CreateDefaultSubobject&amp;lt;UDXStatusComponent&amp;gt;(
            TEXT(&quot;StatusComponent&quot;)
        );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;UDXStatusComponent::UDXStatusComponent()
{
    PrimaryComponentTick.bCanEverTick = false;
    SetIsReplicatedByDefault(true);
}&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;height: 84px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;복제 조건&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;설정 위치&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;누락 시 결과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Owner Actor 복제&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;bReplicates = true&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;컴포넌트를 전달할 Actor 채널 자체가 없다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;ActorComponent 복제&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;SetIsReplicatedByDefault(true)&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;컴포넌트의 프로퍼티와 RPC가 복제되지 않는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;프로퍼티 등록&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;GetLifetimeReplicatedProps()&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;UPROPERTY&lt;/code&gt;만 선언해도 값은 전송되지 않는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성자에서는 &lt;code&gt;SetIsReplicatedByDefault()&lt;/code&gt;를 사용한다. 런타임에 동적으로 생성한 컴포넌트의 복제를 켤 때는 서버에서 생성하고 &lt;code&gt;SetIsReplicated(true)&lt;/code&gt;를 사용하는 방식이 필요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;체력 컴포넌트의 서버 권위 구조&lt;/h2&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class UDXStatusComponent : public UActorComponent
{
    GENERATED_BODY()

public:
    UDXStatusComponent();

    virtual void GetLifetimeReplicatedProps(
        TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps
    ) const override;

    float ApplyDamage(float Damage);
    float GetCurrentHealth() const { return CurrentHealth; }
    float GetMaxHealth() const { return MaxHealth; }
    void SetCurrentHealth(float NewCurrentHealth);
    void SetMaxHealth(float NewMaxHealth);

protected:
    UFUNCTION()
    void OnRep_CurrentHealth();

    UFUNCTION()
    void OnRep_MaxHealth();

    void NotifyCurrentHealthChanged();
    void NotifyMaxHealthChanged();

    UPROPERTY(ReplicatedUsing = OnRep_CurrentHealth)
    float CurrentHealth = 100.0f;

    UPROPERTY(ReplicatedUsing = OnRep_MaxHealth)
    float MaxHealth = 100.0f;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void UDXStatusComponent::GetLifetimeReplicatedProps(
    TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps
) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ThisClass, CurrentHealth);
    DOREPLIFETIME(ThisClass, MaxHealth);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 변경 함수는 Owner Actor의 Authority를 확인한다.&lt;/p&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;float UDXStatusComponent::ApplyDamage(float Damage)
{
    AActor* OwnerActor = GetOwner();
    if (!IsValid(OwnerActor) || !OwnerActor-&amp;gt;HasAuthority())
    {
        return 0.0f;
    }

    const float ActualDamage = FMath::Clamp(
        Damage,
        0.0f,
        CurrentHealth
    );

    CurrentHealth = FMath::Clamp(
        CurrentHealth - ActualDamage,
        0.0f,
        MaxHealth
    );

    NotifyCurrentHealthChanged();
    return ActualDamage;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 복제값이 도착했을 때 RepNotify에서 로컬 Delegate를 발생시킨다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void UDXStatusComponent::OnRep_CurrentHealth()
{
    NotifyCurrentHealthChanged();
}

void UDXStatusComponent::OnRep_MaxHealth()
{
    NotifyMaxHealthChanged();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C++ RepNotify가 서버에서도 자동으로 호출된다고 가정하지 않고, 서버의 Setter와 클라이언트의 OnRep가 같은 알림 함수를 호출하도록 만든다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Delegate는 네트워크가 아니라 로컬 알림 통로다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;OnCurrentHealthChanged.Broadcast()&lt;/code&gt;는 다른 컴퓨터로 전송되는 네트워크 Multicast가 아니다. 같은 프로세스 안의 리스너에게 알리는 C++ Delegate다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;서버
  CurrentHealth 변경
  &amp;rarr; Property Replication

클라이언트
  CurrentHealth 수신
  &amp;rarr; OnRep_CurrentHealth()
  &amp;rarr; 로컬 Delegate Broadcast
  &amp;rarr; HP Widget 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;기능&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;범위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;용도&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NetMulticast RPC&lt;/td&gt;
&lt;td&gt;서버와 관련 클라이언트&lt;/td&gt;
&lt;td&gt;네트워크를 통한 일회성 함수 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C++ Multicast Delegate&lt;/td&gt;
&lt;td&gt;현재 프로세스 내부&lt;/td&gt;
&lt;td&gt;상태 변경을 UI와 다른 로컬 시스템에 알림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RepNotify&lt;/td&gt;
&lt;td&gt;복제값을 받은 로컬 인스턴스&lt;/td&gt;
&lt;td&gt;네트워크 상태를 로컬 표현으로 연결&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Widget이 생성되기 전에 OnRep가 먼저 호출될 수 있으므로, Widget 초기화 시 현재값을 한 번 읽고 그다음 Delegate를 구독하는 패턴이 안전하다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;void UDXHealthWidget::InitializeFromStatus(
    UDXStatusComponent* Status
)
{
    UpdateCurrentHealth(Status-&amp;gt;GetCurrentHealth());
    UpdateMaxHealth(Status-&amp;gt;GetMaxHealth());

    Status-&amp;gt;OnCurrentHealthChanged.AddUObject(
        this,
        &amp;amp;ThisClass::UpdateCurrentHealth
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ActorComponent의 네트워크 생명주기&lt;/h2&gt;
&lt;table style=&quot;height: 120px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;함수&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;호출 시점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;적합한 작업&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 36px;&quot;&gt;
&lt;td style=&quot;height: 36px;&quot;&gt;&lt;code&gt;InitializeComponent()&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 36px;&quot;&gt;컴포넌트 등록 후, 게임 시작 전&lt;/td&gt;
&lt;td style=&quot;height: 36px;&quot;&gt;일반적인 내부 상태와 참조 초기화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;&lt;code&gt;ReadyForReplication()&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;초기화 후, 복제 컴포넌트의 Owner가 네트워크 복제 준비를 마친 시점&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;컴포넌트가 보유한 추가 Replicated Subobject 등록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;BeginPlay()&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Owner Actor가 플레이를 시작할 때&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;다른 게임플레이 객체와 상호작용하는 로직&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 &lt;code&gt;UPROPERTY&lt;/code&gt; 복제만 사용하는 상태 컴포넌트라면 &lt;code&gt;GetLifetimeReplicatedProps()&lt;/code&gt;와 복제 활성화로 충분하다. &lt;code&gt;ReadyForReplication()&lt;/code&gt;은 컴포넌트가 다시 별도의 Replicated UObject를 보유하고 등록해야 할 때 특히 유용하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ActorComponent 자체에는 Actor의 LocalRole과 RemoteRole이 없다. 네트워크 로그를 남길 때는 Owner의 역할을 확인해야 한다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;void UDXStatusComponent::LogNetworkState() const
{
    const AActor* OwnerActor = GetOwner();
    if (!IsValid(OwnerActor))
    {
        return;
    }

    UE_LOG(
        LogTemp,
        Log,
        TEXT(&quot;[%s][%s/%s] HP=%.1f&quot;),
        *UEnum::GetValueAsString(GetNetMode()),
        *UEnum::GetValueAsString(OwnerActor-&amp;gt;GetLocalRole()),
        *UEnum::GetValueAsString(OwnerActor-&amp;gt;GetRemoteRole()),
        CurrentHealth
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매크로에서 무조건 &lt;code&gt;GetOwner()-&amp;gt;GetLocalRole()&lt;/code&gt;을 호출하면 초기화나 파괴 시점의 null Owner 때문에 문제가 생길 수 있으므로 유효성 검사를 포함하는 함수가 디버깅에 더 안전하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;조건부 컴포넌트 프로퍼티 복제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트의 모든 상태를 모든 클라이언트에 보낼 필요는 없다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void UDXStatusComponent::GetLifetimeReplicatedProps(
    TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps
) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ThisClass, CurrentHealth);

    DOREPLIFETIME_CONDITION(
        ThisClass,
        MaxHealth,
        COND_OwnerOnly
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;COND_OwnerOnly&lt;/code&gt;의 Owner는 컴포넌트가 아니라 컴포넌트를 소유한 Actor의 Owning Connection을 기준으로 결정된다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;상태&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;가능한 복제 조건&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;판단 기준&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;현재 HP&lt;/td&gt;
&lt;td&gt;모든 relevant 클라이언트&lt;/td&gt;
&lt;td&gt;다른 플레이어의 월드 HP 바에 필요한가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최대 HP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COND_OwnerOnly&lt;/code&gt; 가능&lt;/td&gt;
&lt;td&gt;원격 클라이언트가 HP 비율을 계산하지 않아도 되는가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스태미나&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COND_OwnerOnly&lt;/code&gt; 가능&lt;/td&gt;
&lt;td&gt;본인 UI에만 필요한 정보인가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사망 여부&lt;/td&gt;
&lt;td&gt;모든 relevant 클라이언트&lt;/td&gt;
&lt;td&gt;충돌과 애니메이션을 모두가 알아야 하는가?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원격 HP 바가 &lt;code&gt;CurrentHealth / MaxHealth&lt;/code&gt; 비율을 표시한다면 MaxHealth도 원격 클라이언트에 필요하다. 이 경우 MaxHealth를 숨기면서 비율만 별도 양자화해 복제하거나, 두 값을 모두 보내는 선택을 비교해야 한다. 조건부 복제는 데이터 사용처를 먼저 분석한 뒤 적용해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;월드 UI는 복제하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WidgetComponent와 UserWidget 자체를 네트워크로 복제하는 것이 아니라, 각 클라이언트가 로컬 Widget을 생성하고 복제된 상태 컴포넌트를 표시한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Replicated Character
  └─ Replicated StatusComponent
       └─ OnRep &amp;rarr; Local Delegate
                    └─ Local HP Widget&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;월드 공간 HP 바가 항상 로컬 카메라를 바라보게 하는 회전도 각 렌더링 클라이언트에서 계산해야 한다. 데디케이티드 서버에는 카메라와 화면이 없으므로 실행할 필요가 없다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::UpdateHealthWidgetFacing()
{
    if (GetNetMode() == NM_DedicatedServer ||
        !IsValid(HealthWidgetComponent))
    {
        return;
    }

    APlayerCameraManager* CameraManager =
        UGameplayStatics::GetPlayerCameraManager(this, 0);

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

    const FVector WidgetLocation =
        HealthWidgetComponent-&amp;gt;GetComponentLocation();

    const FRotator LookRotation =
        UKismetMathLibrary::FindLookAtRotation(
            WidgetLocation,
            CameraManager-&amp;gt;GetCameraLocation()
        );

    HealthWidgetComponent-&amp;gt;SetWorldRotation(LookRotation);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Authority 여부로 화면 로직을 제외하면 리슨 서버 호스트의 로컬 화면까지 빠질 수 있다. 렌더링 제외 목적이라면 &lt;code&gt;NM_DedicatedServer&lt;/code&gt;를 구분하는 편이 정확하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;버프 상자도 서버가 소비를 확정한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Overlap 이벤트는 서버와 클라이언트의 각 액터 인스턴스에서 발생할 수 있다. 버프와 아이템 소비는 서버에서 한 번만 확정해야 한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;void ADXBuffBox::OnOverlapBegin(
    UPrimitiveComponent* OverlappedComponent,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComponent,
    int32 OtherBodyIndex,
    bool bFromSweep,
    const FHitResult&amp;amp; SweepResult
)
{
    if (!HasAuthority() || bConsumed)
    {
        return;
    }

    ADXPlayerCharacter* Character =
        Cast&amp;lt;ADXPlayerCharacter&amp;gt;(OtherActor);

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

    bConsumed = true;
    SetActorEnableCollision(false);

    Character-&amp;gt;TakeBuffOnServer(50.0f);
    MulticastPlayPickupEffect();

    SetLifeSpan(1.0f);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::TakeBuffOnServer(float BuffValue)
{
    check(HasAuthority());

    StatusComponent-&amp;gt;SetMaxHealth(
        StatusComponent-&amp;gt;GetMaxHealth() + BuffValue
    );

    StatusComponent-&amp;gt;SetCurrentHealth(
        StatusComponent-&amp;gt;GetCurrentHealth() + BuffValue
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;버프 상자 요소&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;처리 위치&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;동기화 방식&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overlap 승인&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;Authority 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HP와 MaxHP 증가&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;StatusComponent Property Replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;획득 파티클과 사운드&lt;/td&gt;
&lt;td&gt;관련 클라이언트&lt;/td&gt;
&lt;td&gt;NetMulticast RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;상자 제거&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;서버의 Actor Destroy 복제&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 즉시 &lt;code&gt;Destroy()&lt;/code&gt;하면 Unreliable 효과 RPC가 액터 제거와 경쟁할 수 있다. 효과가 꼭 보여야 한다면 제거를 짧게 지연하거나, 독립적인 Effect Actor를 스폰하거나, 소비 상태의 RepNotify에서 로컬 효과를 재생하는 방식을 검토할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 동기화 흐름&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 소유 클라이언트가 공격 버튼을 누른다.
2. 소유 클라이언트는 몽타주를 즉시 예측 재생한다.
3. Server RPC로 공격 의도를 전송한다.
4. 서버는 쿨다운, 상태, 호출 권한을 검증한다.
5. 서버가 권위 있는 공격 상태와 몽타주를 시작한다.
6. 다른 클라이언트는 Multicast 또는 복제 공격 상태로 몽타주를 재생한다.
7. 서버 Anim Notify 시점에 Sweep을 수행한다.
8. 서버가 StatusComponent의 CurrentHealth를 변경한다.
9. 컴포넌트 프로퍼티가 클라이언트로 복제된다.
10. OnRep가 로컬 Delegate를 발생시켜 HP Widget을 갱신한다.&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;데이터&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;권위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;전달 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;복구 방법&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이동 위치와 속도&lt;/td&gt;
&lt;td&gt;서버 검증&lt;/td&gt;
&lt;td&gt;CharacterMovement 복제&lt;/td&gt;
&lt;td&gt;서버 보정과 Simulated Proxy 보간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aim Pitch&lt;/td&gt;
&lt;td&gt;소유 입력, 서버 전달&lt;/td&gt;
&lt;td&gt;RemoteViewPitch 또는 양자화 프로퍼티&lt;/td&gt;
&lt;td&gt;다음 업데이트와 보간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;공격 몽타주&lt;/td&gt;
&lt;td&gt;서버 승인&lt;/td&gt;
&lt;td&gt;로컬 예측과 RPC&lt;/td&gt;
&lt;td&gt;거절 응답 또는 공격 상태 복제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;피격과 데미지&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;서버 로직&lt;/td&gt;
&lt;td&gt;권위 상태가 최종 결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;현재 HP&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;ActorComponent Property Replication&lt;/td&gt;
&lt;td&gt;늦은 접속과 relevancy 재진입 시 최신값 수신&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HP Widget&lt;/td&gt;
&lt;td&gt;각 로컬 클라이언트&lt;/td&gt;
&lt;td&gt;OnRep와 로컬 Delegate&lt;/td&gt;
&lt;td&gt;Widget 초기화 시 현재 상태 재조회&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NumberBaseball 구조와 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 NumberBaseball 프로젝트에는 Character 전투 클래스가 없지만, &lt;code&gt;ANBGameStateBase&lt;/code&gt;의 턴 상태가 같은 설계 원리를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = OnRep_TurnState)
FNBTurnState TurnState;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ANBGameStateBase::OnRep_TurnState()
{
    OnTurnStateChanged.Broadcast();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 &lt;code&gt;TurnState&lt;/code&gt;를 변경하고, 클라이언트는 &lt;code&gt;OnRep_TurnState()&lt;/code&gt;에서 로컬 Delegate를 발생시켜 턴 UI를 갱신한다. StatusComponent의 &lt;code&gt;CurrentHealth &amp;rarr; OnRep &amp;rarr; Delegate &amp;rarr; HP Widget&lt;/code&gt; 흐름과 동일하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임에 체력이나 버프 시스템을 추가한다면 GameState에 모든 플레이어의 세부 상태를 몰아넣기보다, 각 Character 또는 PlayerState에 재사용 가능한 상태 컴포넌트를 두는 구조를 검토할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디버깅 체크리스트&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;확인할 항목&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;원격 캐릭터는 움직이지만 애니메이션이 멈춰 있다.&lt;/td&gt;
&lt;td&gt;AnimInstance의 Owner 캐시, MovementComponent Velocity, State Machine 전환식을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;원격 Aim Offset이 갱신되지 않는다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GetBaseAimRotation()&lt;/code&gt;, RemoteViewPitch, 커스텀 값의 복제 조건과 보간을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;내 공격 몽타주가 지연된다.&lt;/td&gt;
&lt;td&gt;서버 응답 후에만 재생하는지, 소유 클라이언트 예측이 있는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;공격 몽타주가 두 번 시작된다.&lt;/td&gt;
&lt;td&gt;소유 클라이언트가 예측 재생한 뒤 Multicast를 다시 실행하는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;한 번 공격했는데 데미지가 여러 번 적용된다.&lt;/td&gt;
&lt;td&gt;Notify의 Authority 검사, 공격별 피격 대상 Set, Notify 배치와 Montage 재생 횟수를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HP가 서버에서만 변한다.&lt;/td&gt;
&lt;td&gt;Owner Actor와 StatusComponent의 복제 활성화, &lt;code&gt;DOREPLIFETIME&lt;/code&gt; 등록을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HP는 복제되지만 Widget이 갱신되지 않는다.&lt;/td&gt;
&lt;td&gt;RepNotify, Delegate 바인딩 시점, Widget 생성 전 상태 수신 여부를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;리슨 서버 호스트의 월드 UI가 카메라를 보지 않는다.&lt;/td&gt;
&lt;td&gt;표현 로직을 Authority로 제외하지 않았는지, Dedicated Server만 제외했는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;버프가 여러 번 적용된다.&lt;/td&gt;
&lt;td&gt;Overlap Authority 검사, 소비 상태, 충돌 비활성화 순서를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 테스트에서는 정상 환경만 확인하지 말고 패킷 지연과 손실을 적용해야 한다. 로컬 예측이 중복 재생되지 않는지, 서버 거절이 보정되는지, Unreliable 효과를 잃어도 HP와 사망 상태가 정상적으로 수렴하는지를 각각 확인해야 한다.&lt;/p&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/72</guid>
      <comments>https://uegamedev.tistory.com/72#entry72comment</comments>
      <pubDate>Fri, 7 Aug 2026 18:59:37 +0900</pubDate>
    </item>
    <item>
      <title>UE C++ RPC오너십, 프로퍼티 리플리케이션</title>
      <link>https://uegamedev.tistory.com/71</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;언리얼 RPC 설계: Ownership, Reliability, Property Replication&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 멀티플레이에서 RPC(Remote Procedure Call)는 한 컴퓨터에서 호출한 함수를 네트워크를 통해 다른 컴퓨터에서 실행하는 기능이다. 클라이언트 입력을 서버에 전달하거나, 서버가 특정 클라이언트의 UI를 열거나, 폭발 효과를 여러 클라이언트에 재생할 때 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 함수에 &lt;code&gt;Server&lt;/code&gt;, &lt;code&gt;Client&lt;/code&gt;, &lt;code&gt;NetMulticast&lt;/code&gt;를 붙였다고 원하는 곳에서 무조건 실행되는 것은 아니다. 실제 실행 위치는 다음 조건의 영향을 받는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RPC를 호출한 컴퓨터&lt;/li&gt;
&lt;li&gt;RPC가 선언된 액터의 Owner와 Owning Connection&lt;/li&gt;
&lt;li&gt;액터와 컴포넌트의 복제 여부&lt;/li&gt;
&lt;li&gt;각 클라이언트에 대한 액터의 Relevancy&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Reliable&lt;/code&gt; 또는 &lt;code&gt;Unreliable&lt;/code&gt; 신뢰성 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC는 이미 발생한 &lt;b&gt;사건&lt;/b&gt;을 전달하는 데 적합하다. 반면 현재 체력, 문이 열린 상태, 폭발한 지뢰처럼 나중에도 복구되어야 하는 &lt;b&gt;상태&lt;/b&gt;는 Property Replication이 담당해야 한다. 두 기능의 차이를 이해하지 못하고 RPC만으로 상태를 맞추면 거리 기반 relevancy와 늦은 접속에서 쉽게 문제가 발생한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPC는 단방향 메시지다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC는 호출자가 원격 실행 결과를 즉시 돌려받는 일반 함수가 아니다. 네트워크를 통과하는 단방향 호출이므로 반환값을 선언할 수 없다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Server, Reliable)
void ServerRequestSpawnLandMine();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::ServerRequestSpawnLandMine_Implementation()
{
    // 서버에서 실행
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;헤더에는 원래 함수 이름을 선언하고, C++ 구현부에는 &lt;code&gt;_Implementation&lt;/code&gt; 접미사가 붙은 함수를 작성한다. 실제 호출은 일반 함수처럼 원래 이름을 사용한다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;ServerRequestSpawnLandMine();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원격 실행 결과가 필요하다면 다음 중 하나로 별도 응답을 설계해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버가 복제 프로퍼티를 변경하고 클라이언트가 &lt;code&gt;OnRep&lt;/code&gt;로 받는다.&lt;/li&gt;
&lt;li&gt;서버가 요청한 클라이언트에 Client RPC를 보낸다.&lt;/li&gt;
&lt;li&gt;요청 ID를 함께 보내고 나중에 응답과 연결한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPC가 동작하기 위한 기본 조건&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC는 복제 가능한 네트워크 객체를 통로로 사용한다. 일반적인 Actor RPC가 원격으로 전달되려면 최소한 다음 조건을 확인해야 한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;ADXLandMine::ADXLandMine()
{
    bReplicates = true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;확인 항목&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;복제 가능한 Actor 또는 ActorComponent인가?&lt;/td&gt;
&lt;td&gt;RPC는 해당 객체의 네트워크 채널과 복제 경로를 사용한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;액터의 &lt;code&gt;bReplicates&lt;/code&gt;가 활성화되어 있는가?&lt;/td&gt;
&lt;td&gt;클라이언트와 서버가 대응되는 네트워크 액터를 가져야 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;호출 방향에 맞는 Owning Connection이 있는가?&lt;/td&gt;
&lt;td&gt;Server RPC의 호출 권한과 Client RPC의 목적지를 결정한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대상 클라이언트에서 액터가 relevant한가?&lt;/td&gt;
&lt;td&gt;특히 NetMulticast RPC의 수신 범위에 영향을 준다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RPC 인자가 네트워크로 직렬화 가능한가?&lt;/td&gt;
&lt;td&gt;큰 문자열이나 복잡한 데이터를 매번 보내면 대역폭과 직렬화 비용이 커진다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 클래스의 액터가 서버와 클라이언트에 존재한다는 사실만으로 호출 권한이 생기지는 않는다. 어떤 연결이 그 액터를 소유하는지가 중요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Owner와 Owning Connection&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 &lt;code&gt;AActor&lt;/code&gt;는 &lt;code&gt;Owner&lt;/code&gt; 포인터를 가질 수 있다. Owner 체인을 바깥쪽으로 따라갔을 때 특정 &lt;code&gt;PlayerController&lt;/code&gt;에 도달하면, 그 PlayerController의 네트워크 연결이 액터의 Owning Connection이 된다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Client 1의 NetConnection
  ↕
Client 1의 PlayerController
  └─ Possess한 Pawn
      └─ Pawn이 소유한 무기 또는 상호작용 액터&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlayerController는 서버와 해당 소유 클라이언트에만 존재한다. 반면 Pawn은 다른 클라이언트에도 복제될 수 있지만, 그 Pawn의 Owning Connection은 Possess한 PlayerController의 연결 하나다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;개념&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owner&lt;/td&gt;
&lt;td&gt;액터 사이의 소유 관계를 나타내는 &lt;code&gt;AActor*&lt;/code&gt;다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owning Connection&lt;/td&gt;
&lt;td&gt;Owner 체인 끝의 PlayerController에 연결된 네트워크 연결이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authority&lt;/td&gt;
&lt;td&gt;액터의 권위 있는 상태를 관리하는 서버 측 역할이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Control&lt;/td&gt;
&lt;td&gt;현재 컴퓨터가 해당 Controller 또는 Pawn의 입력을 직접 제어하는지 나타낸다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;HasAuthority()&lt;/code&gt;와 &lt;code&gt;IsLocallyControlled()&lt;/code&gt;는 서로 대체할 수 있는 조건이 아니다. 리슨 서버의 호스트 Pawn은 서버 권한을 가지면서 동시에 로컬 제어될 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;세 가지 RPC의 실행 방향&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 게임에서 주로 사용하는 RPC는 Server, Client, NetMulticast 세 종류다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;RPC 종류&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;일반적인 호출 방향&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;실행 위치&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;대표 용도&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server RPC&lt;/td&gt;
&lt;td&gt;Owning Client &amp;rarr; Server&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;입력 요청, 상호작용 요청, 서버 권위 로직 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client RPC&lt;/td&gt;
&lt;td&gt;Server &amp;rarr; Owning Client&lt;/td&gt;
&lt;td&gt;해당 액터를 소유한 클라이언트&lt;/td&gt;
&lt;td&gt;개인 UI, 요청 결과, 소유자 전용 알림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NetMulticast RPC&lt;/td&gt;
&lt;td&gt;Server &amp;rarr; Server와 관련 클라이언트&lt;/td&gt;
&lt;td&gt;서버 및 현재 액터가 relevant한 클라이언트&lt;/td&gt;
&lt;td&gt;폭발, 사운드, 일회성 애니메이션&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UE 5.5에는 연결 반대편에서만 실행하는 &lt;code&gt;Remote&lt;/code&gt; RPC도 있지만, 일반적인 게임플레이 코드는 방향이 분명한 Server와 Client RPC를 주로 사용한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Server RPC: 클라이언트의 요청을 서버 권위 로직으로 전달한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트의 키 입력 함수는 해당 클라이언트에서 실행된다. 그 자리에서 액터를 스폰하면 로컬 월드에만 생기므로 서버와 다른 클라이언트가 알 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복제되어야 하는 지뢰라면 클라이언트는 스폰을 직접 확정하지 않고, 자신이 소유한 Pawn이나 PlayerController를 통해 서버에 요청해야 한다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// DXPlayerCharacter.h
UFUNCTION(Server, Reliable)
void ServerRequestSpawnLandMine();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::HandleLandMineInput(
    const FInputActionValue&amp;amp; InputValue
)
{
    if (IsLocallyControlled())
    {
        ServerRequestSpawnLandMine();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::ServerRequestSpawnLandMine_Implementation()
{
    if (!IsValid(LandMineClass))
    {
        return;
    }

    const FVector SpawnLocation =
        GetActorLocation() + GetActorForwardVector() * 300.0f;

    FActorSpawnParameters SpawnParameters;
    SpawnParameters.Owner = this;

    GetWorld()-&amp;gt;SpawnActor&amp;lt;ADXLandMine&amp;gt;(
        LandMineClass,
        SpawnLocation,
        FRotator::ZeroRotator,
        SpawnParameters
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지뢰 클래스에 &lt;code&gt;bReplicates = true&lt;/code&gt;가 설정되어 있으면 서버에서 생성한 지뢰의 스폰 정보가 관련 클라이언트로 복제된다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;클라이언트 입력
  &amp;rarr; 소유 Pawn의 Server RPC 호출
  &amp;rarr; 서버에서 요청 검증
  &amp;rarr; 서버가 지뢰 스폰
  &amp;rarr; 복제 시스템이 관련 클라이언트에 지뢰 생성&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Server RPC는 소유 클라이언트만 호출할 수 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 호출한 Server RPC가 원격 서버에서 실행되려면 RPC가 선언된 액터를 그 클라이언트가 소유해야 한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;클라이언트가 Server RPC를 호출한 액터&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자신의 PlayerController&lt;/td&gt;
&lt;td&gt;서버에서 실행된다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자신이 Possess한 Pawn&lt;/td&gt;
&lt;td&gt;서버에서 실행된다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자신의 Pawn이 소유하고 Owner 체인이 올바른 액터&lt;/td&gt;
&lt;td&gt;서버에서 실행될 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다른 플레이어가 소유한 Pawn&lt;/td&gt;
&lt;td&gt;서버로 전달되지 않고 버려진다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 소유자가 없는 월드 액터&lt;/td&gt;
&lt;td&gt;클라이언트가 직접 호출한 Server RPC는 버려진다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;월드에 배치된 문이나 상자를 클릭했을 때 그 액터에서 곧바로 Server RPC를 호출하려 하면 소유권 문제를 만나기 쉽다. 이런 경우에는 소유가 확실한 PlayerController 또는 Pawn에서 Server RPC를 호출하고, 서버가 대상 월드 액터를 검증한 뒤 상호작용시키는 구조가 안정적이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버는 요청을 신뢰하지 않고 다시 판정한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server RPC가 실행되었다는 것은 네트워크 호출 권한을 통과했다는 뜻일 뿐, 요청 내용이 게임 규칙상 유효하다는 뜻은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 다음과 같은 &lt;b&gt;의도&lt;/b&gt;만 보내는 편이 좋다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지뢰를 설치하고 싶다.&lt;/li&gt;
&lt;li&gt;이 액터와 상호작용하고 싶다.&lt;/li&gt;
&lt;li&gt;이 방향으로 공격했다.&lt;/li&gt;
&lt;li&gt;이 숫자를 추측값으로 제출한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 다음 정보를 직접 다시 계산하거나 검증한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 게임 상태에서 행동 가능한가?&lt;/li&gt;
&lt;li&gt;요청자가 살아 있고 행동권을 가지고 있는가?&lt;/li&gt;
&lt;li&gt;대상과의 거리가 허용 범위인가?&lt;/li&gt;
&lt;li&gt;쿨다운과 자원 조건을 만족하는가?&lt;/li&gt;
&lt;li&gt;입력 크기와 형식이 정상인가?&lt;/li&gt;
&lt;li&gt;호출 빈도가 비정상적으로 높지 않은가?&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ADXPlayerCharacter::ServerRequestSpawnLandMine_Implementation()
{
    if (!CanPlaceLandMine())
    {
        return;
    }

    const FVector ServerApprovedLocation =
        CalculateLandMineLocation();

    SpawnLandMineAt(ServerApprovedLocation);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 보내 준 월드 위치와 데미지를 그대로 사용하면 치팅과 잘못된 상태를 서버가 승인하게 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WithValidation의 역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UE 5.5에서는 Server RPC에 &lt;code&gt;WithValidation&lt;/code&gt;을 지정할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Server, Reliable, WithValidation)
void ServerRequestSpawnLandMine();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;bool ADXPlayerCharacter::ServerRequestSpawnLandMine_Validate()
{
    return IsValid(LandMineClass);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;_Validate()&lt;/code&gt;가 &lt;code&gt;false&lt;/code&gt;를 반환하면 해당 호출을 보낸 클라이언트는 서버에서 연결 해제된다. 따라서 단순한 게임 규칙 실패와 악의적이거나 구조적으로 잘못된 패킷을 구분해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &amp;ldquo;쿨다운이 아직 끝나지 않았다&amp;rdquo;는 일반적인 요청 거절일 수 있으므로 &lt;code&gt;_Implementation()&lt;/code&gt;에서 무시하거나 결과를 알려 주는 편이 낫다. 반면 허용 범위를 심하게 벗어난 인자나 프로토콜 위반은 &lt;code&gt;_Validate()&lt;/code&gt;에서 차단할 수 있다. &lt;code&gt;_Validate()&lt;/code&gt;가 항상 &lt;code&gt;true&lt;/code&gt;라면 보안 검증을 제공하지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Client RPC: 특정 소유 클라이언트에만 보낸다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client RPC는 서버가 액터의 Owning Connection을 찾아 그 클라이언트에서 함수를 실행한다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void ClientShowInteractionError(const FText&amp;amp; Message);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ANBPlayerController::ClientShowInteractionError_Implementation(
    const FText&amp;amp; Message
)
{
    ShowErrorMessage(Message);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI는 각 클라이언트의 로컬 화면에만 존재한다. 따라서 사망 화면, 개인 오류, 상호작용 실패처럼 한 플레이어만 알아야 하는 정보는 PlayerController의 Client RPC와 잘 맞는다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;적합한 전달 방식&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;ldquo;내 턴이 아닙니다&amp;rdquo;라는 개인 오류 메시지&lt;/td&gt;
&lt;td&gt;해당 PlayerController의 Client RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개인 인벤토리 창 열기&lt;/td&gt;
&lt;td&gt;소유 PlayerController 또는 소유 Pawn의 Client RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모든 플레이어의 현재 점수&lt;/td&gt;
&lt;td&gt;GameState 또는 PlayerState의 Property Replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모든 플레이어가 봐야 하는 폭발 효과&lt;/td&gt;
&lt;td&gt;서버가 호출하는 NetMulticast RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 Client RPC를 소유 연결이 없는 액터에서 호출하면 특정 클라이언트를 찾을 수 없다. 이런 호출은 기대한 원격 클라이언트가 아니라 서버에서 로컬 실행될 수 있으므로, Client RPC는 목적지가 명확한 PlayerController나 그 연결이 소유한 액터에 두는 것이 좋다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NetMulticast RPC: 현재 관련 있는 여러 컴퓨터에서 실행한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NetMulticast RPC를 서버에서 호출하면 서버와 현재 해당 액터가 relevant한 클라이언트에서 실행된다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayExplosionFX();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ADXLandMine::MulticastPlayExplosionFX_Implementation()
{
    if (GetNetMode() == NM_DedicatedServer)
    {
        return;
    }

    Particle-&amp;gt;Activate(true);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데디케이티드 서버는 화면과 오디오 출력이 없으므로 코스메틱 효과를 생략할 수 있다. &lt;code&gt;HasAuthority() == false&lt;/code&gt;로 검사하면 리슨 서버 호스트의 화면에서도 효과가 빠질 수 있으므로, 데디케이티드 서버만 제외하려면 &lt;code&gt;GetNetMode() == NM_DedicatedServer&lt;/code&gt;를 확인하는 편이 정확하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Multicast는 반드시 서버가 호출해야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 NetMulticast RPC를 호출해도 다른 컴퓨터로 전파되지 않는다. 호출한 클라이언트에서 일반 로컬 함수처럼 실행될 뿐이다.&lt;/p&gt;
&lt;table style=&quot;height: 63px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;호출 위치&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;NetMulticast 실행 범위&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버와 현재 액터가 relevant한 모든 클라이언트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;호출한 클라이언트에서만 로컬 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피격과 폭발 판정도 서버를 기준으로 시작해야 한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;void ADXLandMine::OnLandMineBeginOverlap(
    AActor* OverlappedActor,
    AActor* OtherActor
)
{
    if (!HasAuthority())
    {
        return;
    }

    ApplyDamageOnServer(OtherActor);
    MulticastPlayExplosionFX();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서도 Overlap 이벤트가 발생할 수 있지만 데미지와 폭발 여부를 각 클라이언트가 독립적으로 확정하면 결과가 달라질 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Multicast는 과거 사건을 저장하지 않는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NetMulticast RPC는 호출 시점에 연결되어 있고 액터가 relevant한 클라이언트에만 전달된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 클라이언트는 과거 Multicast를 받을 수 없다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RPC가 실행된 뒤 게임에 접속한 클라이언트&lt;/li&gt;
&lt;li&gt;호출 시점에 &lt;code&gt;NetCullDistanceSquared&lt;/code&gt; 밖에 있던 클라이언트&lt;/li&gt;
&lt;li&gt;호출 시점에 다른 relevancy 조건으로 액터를 받지 못한 클라이언트&lt;/li&gt;
&lt;li&gt;Unreliable RPC 패킷을 잃어버린 클라이언트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;bAlwaysRelevant = true&lt;/code&gt;로 바꾸면 현재 연결의 거리 문제는 줄일 수 있지만, 늦은 접속자가 이미 지나간 RPC를 다시 받게 하지는 않는다. 모든 클라이언트에 항상 액터를 복제하는 비용도 추가된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Reliable과 Unreliable&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC는 별도로 지정하지 않으면 Unreliable이다. 신뢰성은 &amp;ldquo;중요해 보이는가&amp;rdquo;만으로 결정하지 않고, 유실되었을 때 게임 상태가 복구 가능한지와 호출 빈도를 함께 고려해야 한다.&lt;/p&gt;
&lt;table style=&quot;height: 105px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;전송 특성&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;적합한 사례&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;주의점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;&lt;code&gt;Reliable&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;수신 확인이 올 때까지 재전송한다.&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;드문 확정 요청, 중요한 개인 알림&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;확인 전까지 뒤따르는 RPC 처리가 막힐 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;&lt;code&gt;Unreliable&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;패킷이 유실되면 다시 보내지 않는다.&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;빈번한 코스메틱 효과, 다음 상태로 보완 가능한 사건&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;실행과 순서를 보장하지 않는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Reliable RPC는 같은 액터에서 보낸 Reliable RPC끼리 호출 순서를 보장한다. 하지만 서로 다른 액터에서 보낸 RPC 사이에는 원래 호출 순서가 보장되지 않는다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;Actor A: ReliableRPC1()
Actor B: ReliableRPC2()
Actor A: ReliableRPC3()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 호출이 원격 컴퓨터에서 반드시 &lt;code&gt;1 &amp;rarr; 2 &amp;rarr; 3&lt;/code&gt; 순서로 실행된다고 가정할 수 없다. 여러 액터에 걸친 순서가 게임 규칙에 중요하다면 하나의 권위 있는 상태나 시퀀스 번호로 순서를 표현해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Reliable RPC를 Tick, 빠른 발사 입력, 연속 위치 갱신처럼 높은 빈도로 호출하면 재전송 대기열과 대역폭을 쉽게 압박한다. &amp;ldquo;중요하므로 모두 Reliable&amp;rdquo;이라는 선택은 오히려 이후의 중요한 메시지까지 늦출 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPC와 Property Replication의 차이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC는 호출 사실을 전달하고, Property Replication은 서버의 현재 상태를 수렴시킨다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;RPC&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Property Replication&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;표현 대상&lt;/td&gt;
&lt;td&gt;순간적인 사건과 명령&lt;/td&gt;
&lt;td&gt;현재 유지되어야 하는 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;늦은 접속&lt;/td&gt;
&lt;td&gt;과거 호출을 받지 못한다.&lt;/td&gt;
&lt;td&gt;액터가 복제될 때 현재값을 받을 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relevancy 밖에 있던 클라이언트&lt;/td&gt;
&lt;td&gt;그때 발생한 RPC를 놓칠 수 있다.&lt;/td&gt;
&lt;td&gt;다시 relevant해지면 서버의 최신 상태로 수렴한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;중간 변화&lt;/td&gt;
&lt;td&gt;호출 단위로 의미를 표현한다.&lt;/td&gt;
&lt;td&gt;모든 중간값보다 최신값이 중요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대표 사례&lt;/td&gt;
&lt;td&gt;폭발음, 피격 연출, 개인 오류 알림&lt;/td&gt;
&lt;td&gt;폭발 여부, 점수, 남은 턴 시간, 문 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RPC만으로 지뢰 상태를 관리하면 생기는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지뢰가 폭발했을 때 Multicast RPC 안에서 로컬 변수만 바꾼다고 가정해 보자.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ADXLandMine::MulticastPlayExplosionFX_Implementation()
{
    if (bIsExploded)
    {
        return;
    }

    Particle-&amp;gt;Activate(true);
    bIsExploded = true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출 당시 멀리 있던 클라이언트는 Multicast를 받지 못하므로 자신의 &lt;code&gt;bIsExploded&lt;/code&gt;가 계속 &lt;code&gt;false&lt;/code&gt;다. 나중에 지뢰가 relevant해져도 과거 RPC는 재생되지 않는다. 늦게 접속한 클라이언트도 같은 문제를 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;bIsExploded&lt;/code&gt;는 사건이 아니라 현재 상태이므로 서버가 복제해야 한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = OnRep_IsExploded)
bool bIsExploded = false;

UFUNCTION()
void OnRep_IsExploded();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ADXLandMine::GetLifetimeReplicatedProps(
    TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps
) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ThisClass, bIsExploded);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXLandMine::OnRep_IsExploded()
{
    ApplyPersistentExplodedState();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 나중에 지뢰를 처음 보거나 다시 relevant해져도 &lt;code&gt;bIsExploded&lt;/code&gt;의 최신값을 받아 폭발한 외형을 복원할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;순간 효과와 영속 상태를 분리한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폭발에는 서로 수명이 다른 두 정보가 섞여 있다.&lt;/p&gt;
&lt;table style=&quot;height: 84px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;정보&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;수명&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;전달 방식&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;폭발 파티클과 사운드&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;발생 순간에만 의미가 있다.&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Unreliable NetMulticast RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;이미 폭발한 지뢰라는 사실&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;액터가 존재하는 동안 유지되어야 한다.&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;ReplicatedUsing 프로퍼티&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;데미지 판정&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;권위 있는 게임 결과다.&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버 로직&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 코드에서도 분리하면 순서 의존성이 줄어든다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void ADXLandMine::ExplodeOnServer(AActor* DamagedActor)
{
    check(HasAuthority());

    if (bIsExploded)
    {
        return;
    }

    bIsExploded = true;
    ApplyPersistentExplodedState();

    ApplyDamageOnServer(DamagedActor);
    MulticastPlayExplosionFX();
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXLandMine::OnRep_IsExploded()
{
    ApplyPersistentExplodedState();
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void ADXLandMine::ApplyPersistentExplodedState()
{
    SetActorEnableCollision(!bIsExploded);

    if (bIsExploded &amp;amp;&amp;amp; IsValid(ExplodedMaterial))
    {
        Mesh-&amp;gt;SetMaterial(0, ExplodedMaterial);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ADXLandMine::MulticastPlayExplosionFX_Implementation()
{
    if (GetNetMode() != NM_DedicatedServer)
    {
        Particle-&amp;gt;Activate(true);
        UGameplayStatics::PlaySoundAtLocation(
            this,
            ExplosionSound,
            GetActorLocation()
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 Multicast는 &lt;code&gt;bIsExploded&lt;/code&gt;를 보고 조기 반환하지 않는다. 순간 효과와 상태 반영이 서로의 실행 순서에 의존하지 않기 때문이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;호출 당시 relevant한 클라이언트는 폭발 효과와 최종 외형을 본다.&lt;/li&gt;
&lt;li&gt;효과 RPC를 잃더라도 다음 프로퍼티 업데이트로 폭발한 상태는 복구된다.&lt;/li&gt;
&lt;li&gt;늦은 접속자는 오래전에 끝난 폭발 효과는 보지 않고 폭발한 외형만 본다.&lt;/li&gt;
&lt;li&gt;서버는 데미지와 중복 폭발 여부를 단독으로 판정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPC와 프로퍼티의 실행 순서를 가정하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC 호출과 프로퍼티 변경을 같은 프레임에 섞으면 코드에 적힌 줄 순서가 원격 컴퓨터에서도 그대로 유지된다고 생각하기 쉽다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;bIsExploded = true;
MulticastPlayExplosionFX();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 복제 시스템은 RPC와 프로퍼티 데이터를 네트워크 bunch에 직렬화하며 종류에 따라 처리 시점이 달라진다. UE 5.5에서 일반적인 비대기 RPC는 프로퍼티보다 먼저 실행되지만, &lt;b&gt;Unreliable NetMulticast RPC는 큐에 들어가 프로퍼티 업데이트 뒤에 실행된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Multicast 안에서 다음 검사를 하면 프로퍼티가 먼저 &lt;code&gt;true&lt;/code&gt;로 반영되어 효과가 생략될 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ADXLandMine::MulticastPlayExplosionFX_Implementation()
{
    if (bIsExploded)
    {
        return; // 실행 순서에 따라 효과가 사라질 수 있다.
    }

    Particle-&amp;gt;Activate(true);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 서로 다른 복제 프로퍼티의 &lt;code&gt;OnRep&lt;/code&gt; 호출 순서도 보장되지 않는다. 두 값이 반드시 함께 해석되어야 한다면 하나의 구조체로 묶어 복제하는 편이 좋다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;USTRUCT()
struct FExplosionState
{
    GENERATED_BODY()

    UPROPERTY()
    bool bIsExploded = false;

    UPROPERTY()
    FVector_NetQuantize ExplosionLocation;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 네트워크 이벤트의 우연한 도착 순서를 게임 상태 머신으로 사용하지 않는 것이다. 상태, 순간 효과, 서버 판정을 분리하면 RPC와 Property Replication의 처리 순서가 달라져도 결과를 유지할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NumberBaseball 프로젝트의 RPC와 상태 복제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 NumberBaseball 프로젝트에는 RPC와 Property Replication의 역할 차이를 보여 주는 사례가 이미 들어 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;채팅 입력: Owning Client에서 Server RPC로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 플레이어가 입력한 문자열은 자신의 PlayerController에서 Server RPC로 전달된다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Server, Reliable)
void ServerRPCPrintChatMessageString(
    const FString&amp;amp; InChatMessageString
);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ANBPlayerController::SetChatMessageString(
    const FString&amp;amp; InChatMessageString
)
{
    if (IsLocalController())
    {
        ServerRPCPrintChatMessageString(InChatMessageString);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlayerController는 연결 소유가 명확하므로 클라이언트에서 서버로 요청하는 통로로 적합하다. 서버의 GameMode는 문자열이 일반 채팅인지 숫자 추측인지 판정하고, 턴과 남은 횟수 같은 서버 상태를 기준으로 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 보낸 문자열에는 추가로 다음 제한을 두는 것이 좋다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최대 길이 제한&lt;/li&gt;
&lt;li&gt;허용 문자와 숫자 형식 검사&lt;/li&gt;
&lt;li&gt;호출 빈도 제한&lt;/li&gt;
&lt;li&gt;빈 문자열과 제어 문자 제거&lt;/li&gt;
&lt;li&gt;실제 턴과 제출 가능 상태 재검사&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Reliable&lt;/code&gt;을 사용하더라도 악의적인 연속 호출까지 막아 주지는 않는다. 신뢰성은 전달 방식이고, 입력 검증과 rate limit은 서버 게임 로직의 책임이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개인 오류와 결과 알림: Client RPC&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 &amp;ldquo;내 턴이 아닙니다&amp;rdquo;, &amp;ldquo;남은 횟수를 모두 사용했습니다&amp;rdquo; 같은 결과를 요청자의 PlayerController에만 돌려준다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void ClientRPCPrintChatMessageString(
    const FString&amp;amp; InChatMessageString
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;승리와 무승부 알림도 Client RPC로 각 PlayerController에 전달한다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void ClientRPCSetNotificationText(
    const FText&amp;amp; InNotificationText
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 알림은 같은 문구가 연속으로 발생해도 호출마다 의미가 있고, 과거 알림을 늦게 접속한 사람에게 복구할 필요도 없다. 따라서 Property Replication보다 RPC의 사건 모델과 잘 맞는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;접속 알림: NetMulticast RPC&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GameState에는 현재 접속한 클라이언트에 로그인 메시지를 전달하는 Multicast가 있다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(NetMulticast, Reliable)
void MulticastRPCBroadcastLoginMessage(
    const FString&amp;amp; InNameString
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GameState는 모든 플레이어가 공유하는 복제 액터이므로 전체 공지의 통로로 사용할 수 있다. 다만 이 호출도 과거 기록은 아니다. 나중에 접속한 플레이어가 이전 접속 메시지까지 봐야 한다면 서버가 채팅 기록을 별도 상태로 관리하고 접속 시 스냅샷을 보내야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;접속 메시지는 현재 플레이어에게만 의미가 있는 일회성 알림이므로, 기록 요구가 없다면 RPC만으로 충분하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;현재 턴: ReplicatedUsing 구조체&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 턴의 남은 시간, 입력 가능 여부, 활성 플레이어는 GameState의 구조체로 복제된다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = OnRep_TurnState)
FNBTurnState TurnState;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ANBGameStateBase::OnRep_TurnState()
{
    OnTurnStateChanged.Broadcast();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;턴 상태는 접속 시점과 관계없이 모든 클라이언트가 현재값을 알아야 한다. RPC로 초 단위 이벤트만 보내는 것보다 서버의 최신 상태를 복제하고 UI가 &lt;code&gt;OnRep&lt;/code&gt;에 반응하는 구조가 적합하다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;프로젝트 정보&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;현재 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;선택 이유&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트의 채팅&amp;middot;숫자 입력&lt;/td&gt;
&lt;td&gt;Server RPC&lt;/td&gt;
&lt;td&gt;클라이언트 의도를 서버 권위 판정으로 전달한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개인 오류와 승패 알림&lt;/td&gt;
&lt;td&gt;Client RPC&lt;/td&gt;
&lt;td&gt;특정 소유 클라이언트의 UI에 발생 시점마다 표시한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;접속 알림&lt;/td&gt;
&lt;td&gt;NetMulticast RPC&lt;/td&gt;
&lt;td&gt;현재 연결된 클라이언트에 일회성 사건을 알린다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;현재 턴과 남은 시간&lt;/td&gt;
&lt;td&gt;Property Replication&lt;/td&gt;
&lt;td&gt;늦은 접속과 갱신 누락 이후에도 최신 상태가 필요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;플레이어 이름과 추측 횟수&lt;/td&gt;
&lt;td&gt;PlayerState Property Replication&lt;/td&gt;
&lt;td&gt;모든 플레이어가 지속적으로 참조하는 현재 상태다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPC 선택 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC 종류부터 고르기보다 전달하려는 정보의 성격부터 판단하면 설계가 단순해진다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;1. 나중에도 복구되어야 하는 현재 상태인가?
   &amp;rarr; Property Replication 또는 ReplicatedUsing

2. 클라이언트가 서버에 게임 행동을 요청하는가?
   &amp;rarr; 소유 Pawn/PlayerController의 Server RPC

3. 서버가 한 플레이어에게만 결과를 알려야 하는가?
   &amp;rarr; Owning Connection이 분명한 액터의 Client RPC

4. 현재 관련된 여러 클라이언트에 순간 효과를 재생하는가?
   &amp;rarr; 서버가 호출하는 NetMulticast RPC

5. 한 번 유실되어도 다음 상태나 다음 효과로 복구 가능한가?
   &amp;rarr; Unreliable 검토

6. 반드시 한 번 전달되어야 하고 호출 빈도가 낮은가?
   &amp;rarr; Reliable 검토&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;잘못된 접근&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;발생 가능한 문제&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;개선 방향&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트가 복제 액터를 직접 스폰한다.&lt;/td&gt;
&lt;td&gt;로컬에만 생성되어 서버와 다른 클라이언트가 모른다.&lt;/td&gt;
&lt;td&gt;Server RPC로 요청하고 서버가 스폰한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;소유권 없는 월드 액터에서 Server RPC를 호출한다.&lt;/td&gt;
&lt;td&gt;호출이 서버로 전달되지 않는다.&lt;/td&gt;
&lt;td&gt;소유 PlayerController 또는 Pawn을 요청 통로로 사용한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트가 NetMulticast를 호출한다.&lt;/td&gt;
&lt;td&gt;다른 컴퓨터로 전파되지 않는다.&lt;/td&gt;
&lt;td&gt;서버 판정 뒤 서버가 Multicast를 호출한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;영속 상태를 Multicast의 로컬 변수로만 관리한다.&lt;/td&gt;
&lt;td&gt;늦은 접속과 relevancy 재진입에서 상태가 사라진다.&lt;/td&gt;
&lt;td&gt;상태는 복제 프로퍼티로 관리한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모든 호출을 Reliable로 만든다.&lt;/td&gt;
&lt;td&gt;재전송 대기와 대역폭 포화로 후속 RPC가 지연된다.&lt;/td&gt;
&lt;td&gt;유실 허용 여부와 호출 빈도로 신뢰성을 결정한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RPC와 OnRep의 실행 순서에 게임 규칙을 의존한다.&lt;/td&gt;
&lt;td&gt;패킷 상태와 RPC 종류에 따라 결과가 달라진다.&lt;/td&gt;
&lt;td&gt;권위 상태, 순간 효과, 표시 로직을 분리한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPC 문제를 추적하는 체크리스트&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;확인할 항목&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server RPC가 서버에서 실행되지 않는다.&lt;/td&gt;
&lt;td&gt;호출 클라이언트가 액터의 Owning Connection인지, 액터가 복제되는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client RPC가 원하는 클라이언트에 도착하지 않는다.&lt;/td&gt;
&lt;td&gt;Owner 체인이 해당 PlayerController로 이어지는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multicast가 호출한 클라이언트에서만 실행된다.&lt;/td&gt;
&lt;td&gt;서버가 호출했는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;멀리 있던 클라이언트가 과거 효과를 모른다.&lt;/td&gt;
&lt;td&gt;Relevancy 범위와 영속 상태의 Property Replication 여부를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;늦게 접속하면 액터 외형이 초기 상태다.&lt;/td&gt;
&lt;td&gt;외형을 Multicast만으로 바꾸지 않았는지, RepNotify로 복원 가능한지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 프레임의 RPC와 프로퍼티 결과가 예상과 다르다.&lt;/td&gt;
&lt;td&gt;신뢰성, Unreliable Multicast 큐 처리, OnRep 순서 의존성을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliable RPC 이후 다른 메시지가 밀린다.&lt;/td&gt;
&lt;td&gt;호출 빈도, 인자 크기, 패킷 손실, Reliable 남용 여부를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC 디버깅에서는 함수에 로그만 넣기보다 &lt;code&gt;NetMode&lt;/code&gt;, &lt;code&gt;LocalRole&lt;/code&gt;, &lt;code&gt;RemoteRole&lt;/code&gt;, Owner, &lt;code&gt;IsLocalController()&lt;/code&gt; 또는 &lt;code&gt;IsLocallyControlled()&lt;/code&gt;를 함께 출력해야 한다. 같은 함수가 서버와 여러 클라이언트에 존재하는 각 액터 인스턴스에서 실행될 수 있기 때문이다.&lt;/p&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/71</guid>
      <comments>https://uegamedev.tistory.com/71#entry71comment</comments>
      <pubDate>Thu, 6 Aug 2026 19:52:00 +0900</pubDate>
    </item>
    <item>
      <title>UE C++ 액터 리플리케이션</title>
      <link>https://uegamedev.tistory.com/70</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;언리얼 액터 복제 최적화: Update Frequency, Relevancy, Priority, Dormancy&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;bReplicates&lt;/code&gt;를 활성화했다고 해서 서버가 액터의 모든 상태를 매 프레임, 모든 클라이언트에 전송하는 것은 아니다. 실제 복제는 서버가 관리하는 스케줄링 과정 안에서 이루어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 먼저 액터가 이번 업데이트에 참여할 수 있는지 확인하고, 연결마다 해당 액터가 필요한지 판단한 뒤, 제한된 대역폭 안에서 어떤 액터를 먼저 보낼지 결정한다. 마지막으로 이전에 보낸 상태와 비교해 변경된 프로퍼티만 직렬화한다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;복제 가능한 액터
  &amp;rarr; Dormancy 및 Update Frequency 검사
  &amp;rarr; 연결별 Relevancy 검사
  &amp;rarr; Priority에 따른 전송 순서 결정
  &amp;rarr; 변경된 프로퍼티 직렬화
  &amp;rarr; 클라이언트 반영 및 RepNotify 호출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 네트워크 최적화는 단순히 프로퍼티의 수를 줄이는 작업이 아니다. &lt;b&gt;언제 검사할지, 누구에게 보낼지, 무엇을 먼저 보낼지, 언제 검사 대상에서 제외할지&lt;/b&gt;를 함께 설계해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;레벨 배치 액터와 런타임 스폰 액터의 복제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NetLoadOnClient&lt;/code&gt;는 레벨에 미리 배치된 액터를 클라이언트가 레벨 로딩 과정에서 생성할 수 있는지를 결정한다. 서버가 플레이 도중 &lt;code&gt;SpawnActor()&lt;/code&gt;로 생성한 동적 액터의 전달 여부를 정하는 옵션은 아니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;레벨 배치 액터&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;런타임 스폰 액터&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;생성 시점&lt;/td&gt;
&lt;td&gt;맵 로딩 과정&lt;/td&gt;
&lt;td&gt;게임 실행 중 서버가 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 생성에 관여하는 설정&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bNetLoadOnClient&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bReplicates&lt;/code&gt;와 서버 권한 스폰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대표 사례&lt;/td&gt;
&lt;td&gt;문, 상자, 레벨 장치&lt;/td&gt;
&lt;td&gt;투사체, 드롭 아이템, 동적 NPC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨에 상자를 배치하고 그 회전값을 서버가 관리한다면 액터 복제와 프로퍼티 복제를 모두 활성화해야 한다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;ADXBox::ADXBox()
{
    bReplicates = true;
    bNetLoadOnClient = true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;bNetLoadOnClient&lt;/code&gt;는 &amp;ldquo;클라이언트가 액터를 가질 수 있는가&amp;rdquo;에 가깝고, 해당 액터의 런타임 상태를 계속 동기화하는 것은 프로퍼티 복제의 역할이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Property Replication은 최신 상태를 동기화한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복제할 프로퍼티는 &lt;code&gt;UPROPERTY(Replicated)&lt;/code&gt; 또는 &lt;code&gt;UPROPERTY(ReplicatedUsing=...)&lt;/code&gt;로 선언하고, &lt;code&gt;GetLifetimeReplicatedProps()&lt;/code&gt;에서 등록한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// DXBox.h
UPROPERTY(ReplicatedUsing = OnRep_ServerRotationYaw)
float ServerRotationYaw = 0.0f;

UFUNCTION()
void OnRep_ServerRotationYaw();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;// DXBox.cpp
void ADXBox::GetLifetimeReplicatedProps(
    TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps
) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ThisClass, ServerRotationYaw);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 권위 있는 값을 변경하고, 클라이언트는 복제된 값이 도착했을 때 &lt;code&gt;OnRep_ServerRotationYaw()&lt;/code&gt;에서 표시 상태를 갱신한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;void ADXBox::OnRep_ServerRotationYaw()
{
    SetActorRotation(FRotator(0.0f, ServerRotationYaw, 0.0f));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 클라이언트 &lt;code&gt;Tick()&lt;/code&gt;에서 매 프레임 값의 변화를 감시하는 것보다 의도가 명확하다. 네트워크 값이 실제로 갱신된 시점에만 반응하므로 불필요한 폴링도 피할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 Property Replication은 값이 거쳐 간 모든 단계를 이벤트처럼 보장하지 않는다. 서버에서 같은 프로퍼티가 짧은 시간 동안 여러 번 변경되면 클라이언트에는 중간값이 생략되고 최신값만 도착할 수 있다.&lt;/p&gt;
&lt;table style=&quot;height: 63px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;전달하려는 정보&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;적합한 방식&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;이유&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;현재 체력, 점수, 남은 횟수&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Property Replication&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;늦게 접속하거나 갱신을 놓친 클라이언트도 최신 상태가 중요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;피격 연출, 알림, 일회성 명령&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;RPC&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;같은 내용이 연속으로 발생하더라도 각각의 호출 자체가 의미를 가진다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 C++의 RepNotify를 서버 측 게임 로직까지 자동으로 실행해 주는 장치로 생각하면 안 된다. 서버와 클라이언트가 동일한 표현 로직을 사용해야 한다면 공통 함수를 분리하고, 서버는 값을 변경한 뒤 그 함수를 직접 호출하는 구조가 안전하다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXBox::SetRotationYawOnServer(float NewYaw)
{
    check(HasAuthority());

    ServerRotationYaw = NewYaw;
    ApplyRotationYaw();
}

void ADXBox::OnRep_ServerRotationYaw()
{
    ApplyRotationYaw();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NetUpdateFrequency: 복제 검사 주기의 상한&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NetUpdateFrequency&lt;/code&gt;는 서버가 액터를 초당 몇 번까지 복제 대상으로 고려할지를 나타낸다. 예를 들어 값이 &lt;code&gt;10.0f&lt;/code&gt;라면 이상적인 검사 간격은 약 0.1초다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;복제 검사 간격 = 1 / NetUpdateFrequency&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;SetNetUpdateFrequency(10.0f);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 표현은 &lt;b&gt;전송 횟수&lt;/b&gt;가 아니라 &lt;b&gt;검사 기회&lt;/b&gt;다. 검사 시점이 왔더라도 다음 조건에 따라 실제 패킷이 전송되지 않을 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복제 프로퍼티가 이전 값과 동일한 경우&lt;/li&gt;
&lt;li&gt;해당 연결에서 액터가 relevant하지 않은 경우&lt;/li&gt;
&lt;li&gt;액터가 dormant 상태인 경우&lt;/li&gt;
&lt;li&gt;연결의 대역폭이 부족해 이번 전송에서 밀린 경우&lt;/li&gt;
&lt;li&gt;서버 틱이나 네트워크 드라이버의 처리 간격이 더 느린 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 값을 크게 설정한다고 네트워크가 더 정확해지는 것도 아니다. 변하지 않는 상태를 자주 검사하면 CPU 비용만 증가할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UE 5.5 프로젝트에서 엔진 생성자 기준으로 확인되는 대표 기본값은 다음과 같다. 기본값은 엔진 버전과 클래스 구현에 따라 바뀔 수 있으므로 다른 버전에서는 클래스 기본 객체나 엔진 소스를 다시 확인해야 한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;클래스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;NetUpdateFrequency&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;NetPriority&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AActor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APawn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APlayerController&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APlayerState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AGameStateBase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UE 5.5에서는 &lt;code&gt;NetUpdateFrequency&lt;/code&gt; 멤버를 직접 수정하기보다 다음 접근자를 사용하는 편이 좋다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;SetNetUpdateFrequency(5.0f);
SetMinNetUpdateFrequency(2.0f);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Adaptive Net Update Frequency가 활성화된 환경에서는 액터의 프로퍼티가 오랫동안 변하지 않을 때 검사 빈도가 &lt;code&gt;MinNetUpdateFrequency&lt;/code&gt; 방향으로 낮아질 수 있다. 다시 변경이 감지되면 필요한 빈도로 올라간다. 자주 움직이지 않는 액터에 높은 고정 빈도를 유지하는 것보다 효율적이지만, 실제 적용 여부와 동작은 프로젝트의 네트워크 설정을 함께 확인해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;낮은 빈도와 화면의 부드러움은 별개 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 업데이트 빈도를 낮추면 스냅샷 사이의 간격이 길어진다. 이 값을 클라이언트 화면에 그대로 적용하면 움직임이 계단처럼 끊겨 보인다. 이때는 클라이언트가 받은 상태 사이를 보간하거나, 속도를 이용해 짧은 구간을 예측한 뒤 다음 서버 값으로 보정한다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;void ADXBox::OnRep_ServerRotationYaw()
{
    InterpolationStartYaw = GetActorRotation().Yaw;
    InterpolationTargetYaw = ServerRotationYaw;
    InterpolationElapsedTime = 0.0f;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복제 빈도는 네트워크 전송 예산이고, 보간은 화면 표현의 품질이다. 둘은 함께 조정해야 하지만 같은 기능은 아니다. 서버의 권위 있는 충돌이나 판정 상태까지 클라이언트 보간값으로 대체해서도 안 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Relevancy: 이 연결에 필요한 액터인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Relevancy는 액터가 특정 클라이언트 연결에 필요한지를 판단한다. 하나의 액터가 클라이언트 A에게는 relevant하고, 같은 시점에 멀리 떨어진 클라이언트 B에게는 irrelevant할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 판단이 연결별로 수행되는 이유는 플레이어마다 위치, 소유 관계, 시야 대상이 다르기 때문이다.&lt;/p&gt;
&lt;table style=&quot;height: 183px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;설정 또는 관계&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;대표 사용 사례&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;bAlwaysRelevant&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;모든 연결에서 항상 relevant하게 취급한다.&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;전체 경기 상태처럼 모두가 알아야 하는 액터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;bOnlyRelevantToOwner&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;소유 연결에만 relevant하다.&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;개인 UI 데이터, 소유자 전용 액터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;&lt;code&gt;bNetUseOwnerRelevancy&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;자신의 판단 대신 Owner의 relevancy와 priority를 따른다.&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;무기, 부착물, 소유 액터와 함께 움직이는 구성 요소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 36px;&quot;&gt;
&lt;td style=&quot;height: 36px;&quot;&gt;&lt;code&gt;NetCullDistanceSquared&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 36px;&quot;&gt;거리 기반 relevancy의 제곱 거리 기준이다.&lt;/td&gt;
&lt;td style=&quot;height: 36px;&quot;&gt;멀리 떨어지면 보낼 필요가 없는 월드 액터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;Owner 또는 Instigator 관계&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;소유자와 행동을 발생시킨 액터를 relevancy 판단에 반영한다.&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;플레이어 소유 오브젝트, 투사체&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NetCullDistanceSquared&lt;/code&gt;는 이름 그대로 거리의 제곱을 저장한다. 거리 5,000 언리얼 유닛을 기준으로 삼으려면 제곱값을 설정한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;SetNetCullDistanceSquared(FMath::Square(5000.0f));&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Viewer와 ViewTarget&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 relevancy 판단은 &lt;code&gt;IsNetRelevantFor()&lt;/code&gt;에서 이루어진다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;virtual bool IsNetRelevantFor(
    const AActor* RealViewer,
    const AActor* ViewTarget,
    const FVector&amp;amp; SrcLocation
) const;&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;인자&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RealViewer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;해당 연결을 대표하는 PlayerController다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ViewTarget&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;현재 그 연결이 바라보는 대상이다. 일반적으로 Possess한 Pawn이지만 관전 상태에서는 달라질 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SrcLocation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;거리 기반 판단에 사용하는 시점 위치다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 판단에는 다음과 같은 규칙이 관여한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;항상 relevant이거나 Viewer, ViewTarget, Owner, Instigator 관계라면 우선 relevant로 취급한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bNetUseOwnerRelevancy&lt;/code&gt;가 활성화되어 있으면 Owner의 판단에 위임한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bOnlyRelevantToOwner&lt;/code&gt;인데 현재 연결이 소유자가 아니면 제외한다.&lt;/li&gt;
&lt;li&gt;부착된 액터라면 부모 또는 베이스 액터와의 관계를 반영한다.&lt;/li&gt;
&lt;li&gt;숨겨져 있고 충돌도 없는 액터는 보낼 필요가 없는 것으로 판단될 수 있다.&lt;/li&gt;
&lt;li&gt;거리 기반 relevancy가 활성화되어 있으면 cull distance를 검사한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlayerController가 모든 클라이언트에 존재하지 않고 자신의 소유 클라이언트에만 복제되는 것도 이 연결별 relevancy와 소유 관계 때문이다. 반면 PlayerState는 다른 플레이어의 이름과 점수처럼 전체가 알아야 하는 정보를 담는 경우가 많아 기본적으로 항상 relevant하게 설계되어 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NetPriority: 대역폭이 부족할 때 무엇을 먼저 보낼 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Relevancy 검사를 통과했다고 해서 모든 액터가 같은 순서로 전송되는 것은 아니다. 한 연결에 보낼 데이터가 대역폭 예산을 초과하면 서버는 상대적인 priority를 이용해 더 중요한 액터부터 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NetPriority&lt;/code&gt;는 고정된 전송 횟수를 뜻하지 않는다. &lt;code&gt;2.0f&lt;/code&gt;로 설정했다고 정확히 초당 두 번 더 전송되는 것이 아니라, 다른 조건이 같을 때 더 높은 상대적 기회를 갖는다는 의미다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;NetPriority = 2.0f;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 액터의 priority를 같은 비율로 올리는 것도 의미가 없다. 중요한 것은 연결 안에서의 상대적인 비율이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 우선순위 계산에는 기본 &lt;code&gt;NetPriority&lt;/code&gt; 외에도 다음 요소가 반영된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;마지막 복제 이후 경과 시간&lt;/li&gt;
&lt;li&gt;Viewer 및 ViewTarget과의 관계&lt;/li&gt;
&lt;li&gt;Owner relevancy를 사용하는 경우 Owner의 priority&lt;/li&gt;
&lt;li&gt;거리와 시선 방향&lt;/li&gt;
&lt;li&gt;Instigator 관계&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 복제 이후 시간이 반영되므로 낮은 priority의 액터가 영원히 전송되지 않는 starvation을 완화할 수 있다. 또한 대역폭이 충분하다면 priority 차이와 무관하게 relevant한 후보들이 모두 전송될 수 있다. 즉 priority 튜닝은 주로 네트워크가 포화되는 상황에서 의미가 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;결정하는 질문&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;적용 범위&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NetUpdateFrequency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;이 액터를 언제 다시 검사할 것인가?&lt;/td&gt;
&lt;td&gt;액터의 시간축&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relevancy&lt;/td&gt;
&lt;td&gt;이 연결에 이 액터가 필요한가?&lt;/td&gt;
&lt;td&gt;액터와 연결의 관계&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NetPriority&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;대역폭이 부족할 때 무엇을 먼저 보낼 것인가?&lt;/td&gt;
&lt;td&gt;연결별 전송 후보의 순서&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Priority만 높이면 relevancy나 dormancy를 무시하고 전송되는 것은 아니다. 앞 단계에서 후보에서 제외된 액터는 우선순위 경쟁에도 참여하지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Net Dormancy: 변하지 않는 액터를 검사 대상에서 제외한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;월드에 배치된 문, 보물 상자, 스위치처럼 대부분의 시간 동안 상태가 변하지 않는 액터도 매 복제 주기마다 변경 여부를 검사하면 서버 CPU를 사용한다. Net Dormancy는 이런 액터를 복제 검사 대상에서 제외하는 최적화다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Dormancy 상태&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DORM_Never&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;휴면 상태를 사용하지 않는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DORM_Awake&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;현재 깨어 있으며 일반적인 복제 검사에 참여한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DORM_DormantAll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;모든 연결에 대해 휴면 상태다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DORM_DormantPartial&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;연결별 부분 휴면을 위한 상태지만 현재는 사용이 권장되지 않는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DORM_Initial&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;맵에 처음부터 배치된 액터가 초기 상태에서 휴면하도록 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DORM_Initial&lt;/code&gt;은 레벨 배치 액터를 위한 상태다. 서버가 런타임에 동적으로 스폰하는 액터의 초기 dormancy로 사용해서는 안 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;한 번만 상태를 갱신할 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;휴면 액터의 복제 프로퍼티를 변경해야 한다면 &lt;b&gt;값을 바꾸기 전에&lt;/b&gt; 먼저 dormancy를 flush해야 한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXBox::SetLightColorOnce(const FLinearColor&amp;amp; NewColor)
{
    check(HasAuthority());

    FlushNetDormancy();
    ServerLightColor = NewColor;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;FlushNetDormancy()&lt;/code&gt;는 적어도 한 번의 복제 업데이트가 가능하도록 액터를 처리하지만, 일반적인 경우 dormancy 상태 자체를 계속 &lt;code&gt;DORM_Awake&lt;/code&gt;로 바꾸지는 않는다. &lt;code&gt;DORM_Initial&lt;/code&gt; 상태에서 flush하면 이후에는 &lt;code&gt;DORM_DormantAll&lt;/code&gt;로 전환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로퍼티를 먼저 변경하고 나중에 flush하는 순서에 의존하면 복제 변경 목록이 올바르게 준비되지 않을 수 있으므로, 항상 깨운 뒤 값을 변경하는 패턴을 유지하는 편이 안전하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;일정 시간 동안 계속 변경할 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문이 열리는 애니메이션처럼 여러 업데이트가 이어진다면 액터를 명시적으로 깨우고, 변경이 끝난 뒤 다시 휴면 상태로 전환한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ADXDoor::BeginOpening()
{
    check(HasAuthority());

    SetNetDormancy(DORM_Awake);
    bIsOpening = true;
}

void ADXDoor::FinishOpening()
{
    check(HasAuthority());

    bIsOpening = false;
    bIsOpen = true;
    ForceNetUpdate();
    SetNetDormancy(DORM_DormantAll);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForceNetUpdate()&lt;/code&gt;는 다음 업데이트를 앞당길 때 사용할 수 있으며, dormant 액터에 호출하면 dormancy flush도 수행한다. 다만 아주 짧은 간격으로 수면과 깨우기를 반복하면 채널과 변경 상태를 다시 준비하는 비용이 커질 수 있다. 자주 변하는 액터라면 계속 awake로 두고 update frequency를 조정하는 편이 나을 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Relevancy와 Dormancy는 결과가 다르다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 기능 모두 전송량을 줄이지만 액터가 클라이언트에서 유지되는 방식은 다르다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Relevancy에서 제외&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Dormancy 진입&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;판단 단위&lt;/td&gt;
&lt;td&gt;연결별&lt;/td&gt;
&lt;td&gt;기본적으로 액터의 휴면 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 액터&lt;/td&gt;
&lt;td&gt;동적 복제 액터는 연결에서 제거될 수 있다.&lt;/td&gt;
&lt;td&gt;기존 액터를 유지한 채 업데이트 검사를 멈춘다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다시 필요할 때&lt;/td&gt;
&lt;td&gt;relevant해지면 다시 복제된다.&lt;/td&gt;
&lt;td&gt;서버가 wake 또는 flush한 뒤 변경값을 보낸다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주요 목적&lt;/td&gt;
&lt;td&gt;해당 플레이어에게 불필요한 액터 제거&lt;/td&gt;
&lt;td&gt;오랫동안 변하지 않는 액터의 서버 검사 비용 절감&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC 호출만으로 dormant 프로퍼티의 최신 상태까지 자동 전송된다고 가정해서는 안 된다. RPC와 함께 복제 상태가 바뀐다면 먼저 액터를 wake 또는 flush하고 프로퍼티를 변경해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Conditional Property Replication: 액터 안에서도 수신자를 나눈다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Relevancy는 액터 전체가 해당 연결에 필요한지를 결정한다. 액터는 필요하지만 특정 프로퍼티만 일부 연결에 보내고 싶다면 조건부 프로퍼티 복제를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ADXBox::GetLifetimeReplicatedProps(
    TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps
) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME_CONDITION(
        ThisClass,
        ServerLightColor,
        COND_InitialOnly
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;조건&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;전송 대상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;대표 사례&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COND_None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;조건 없이 일반 복제&lt;/td&gt;
&lt;td&gt;모든 relevant 연결이 알아야 하는 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COND_InitialOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;액터가 처음 복제될 때만 전송&lt;/td&gt;
&lt;td&gt;생성 후 바뀌지 않는 설정값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COND_OwnerOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;소유 연결에만 전송&lt;/td&gt;
&lt;td&gt;개인 정보나 소유자 전용 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COND_SkipOwner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;소유자를 제외한 연결에 전송&lt;/td&gt;
&lt;td&gt;소유자가 이미 로컬 예측으로 처리한 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COND_SimulatedOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Simulated Proxy에만 전송&lt;/td&gt;
&lt;td&gt;원격 표현에만 필요한 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COND_AutonomousOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Autonomous Proxy에만 전송&lt;/td&gt;
&lt;td&gt;소유 Pawn의 예측과 보정에 필요한 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COND_Custom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;런타임에 활성 여부를 제어&lt;/td&gt;
&lt;td&gt;고정 조건으로 표현하기 어려운 특수 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lifetime Replication에 등록한 프로퍼티는 액터 인스턴스마다 임의로 등록 해제하는 구조가 아니다. 수신 조건이 필요하다면 &lt;code&gt;DOREPLIFETIME_CONDITION&lt;/code&gt; 계열을 사용하고, 복잡한 custom 조건은 평가 비용과 상태 관리 비용까지 고려해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;하나의 복제 파이프라인으로 이해하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 설정이 개입하는 시점을 합치면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. bReplicates가 꺼져 있으면 액터 복제 대상이 아니다.
2. Dormancy 상태면 일반적인 복제 검사에서 제외한다.
3. NetUpdateFrequency에 따라 이번에 검사할 시점인지 판단한다.
4. 각 연결에서 소유 관계와 Relevancy를 검사한다.
5. 전송 후보가 많으면 NetPriority로 처리 순서를 정한다.
6. 액터의 변경된 프로퍼티와 복제 조건을 평가한다.
7. 선택된 데이터를 직렬화해 클라이언트에 보낸다.
8. 클라이언트는 값을 반영하고 필요한 RepNotify를 호출한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 순서를 알면 &amp;ldquo;priority를 높였는데 왜 전송되지 않는가?&amp;rdquo; 같은 문제도 단계별로 추적할 수 있다. 액터가 dormant하거나 해당 연결에서 irrelevant하다면 priority를 평가하는 단계까지 도달하지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;액터 성격에 따른 설정 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 복제 액터에 같은 설정을 적용하는 대신 변화 빈도와 수신 범위를 기준으로 분류하는 편이 좋다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;액터 또는 데이터&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Update Frequency&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Relevancy&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Dormancy 및 조건&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;플레이어 Pawn&lt;/td&gt;
&lt;td&gt;움직임 품질에 맞게 비교적 높게 유지&lt;/td&gt;
&lt;td&gt;거리와 게임 규칙을 반영&lt;/td&gt;
&lt;td&gt;일반 플레이 중 awake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;문, 상자, 스위치&lt;/td&gt;
&lt;td&gt;낮게 설정 가능&lt;/td&gt;
&lt;td&gt;주변 플레이어에게만 필요할 수 있음&lt;/td&gt;
&lt;td&gt;평소 dormant, 상호작용 전에 wake 또는 flush&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;소유자 전용 안내 액터&lt;/td&gt;
&lt;td&gt;변경 빈도에 맞게 설정&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bOnlyRelevantToOwner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;프로퍼티에도 &lt;code&gt;COND_OwnerOnly&lt;/code&gt; 검토&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;생성 후 고정되는 설정값&lt;/td&gt;
&lt;td&gt;액터 특성에 맞게 설정&lt;/td&gt;
&lt;td&gt;필요한 연결에만 relevant&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COND_InitialOnly&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로컬 장식 효과&lt;/td&gt;
&lt;td&gt;해당 없음&lt;/td&gt;
&lt;td&gt;해당 없음&lt;/td&gt;
&lt;td&gt;가능하면 액터 복제 자체를 사용하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 최적화 순서는 다음과 같이 잡을 수 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;네트워크에 필요 없는 액터는 &lt;code&gt;bReplicates&lt;/code&gt;를 끈다.&lt;/li&gt;
&lt;li&gt;자주 변하지 않는 액터는 &lt;code&gt;NetUpdateFrequency&lt;/code&gt;를 낮춘다.&lt;/li&gt;
&lt;li&gt;오랫동안 변하지 않는 액터는 dormancy를 사용한다.&lt;/li&gt;
&lt;li&gt;모든 플레이어에게 필요하지 않다면 relevancy 범위를 줄인다.&lt;/li&gt;
&lt;li&gt;액터는 필요하지만 일부 데이터만 선택적으로 보내려면 replication condition을 사용한다.&lt;/li&gt;
&lt;li&gt;위치, 회전, 실수 값은 허용 가능한 범위에서 양자화와 더 작은 자료형을 검토한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Priority 조정은 이 기본적인 데이터 절감 이후에 진행하는 편이 좋다. 불필요한 데이터를 그대로 둔 채 priority만 바꾸면 전체 비용은 줄지 않고, 포화 시 어떤 액터가 먼저 밀리는지만 바뀐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NumberBaseball 프로젝트에 적용해 보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트의 &lt;code&gt;ANBPlayerState&lt;/code&gt;는 다음 세 값을 일반 Property Replication으로 등록한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;DOREPLIFETIME(ThisClass, PlayerNameString);
DOREPLIFETIME(ThisClass, CurrentGuessCount);
DOREPLIFETIME(ThisClass, MaxGuessCount);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PlayerNameString&lt;/code&gt;과 &lt;code&gt;CurrentGuessCount&lt;/code&gt;는 다른 플레이어의 정보 표시에도 사용될 수 있으므로 PlayerState의 전체 공개 성격과 잘 맞는다. 반면 &lt;code&gt;MaxGuessCount&lt;/code&gt;가 경기 도중 절대 바뀌지 않는 규칙값이라면 설계를 두 방향으로 검토할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;플레이어마다 최대 횟수가 다르다면 PlayerState에 두되 &lt;code&gt;COND_InitialOnly&lt;/code&gt;를 사용한다.&lt;/li&gt;
&lt;li&gt;모든 플레이어가 같은 값을 공유한다면 GameState 같은 경기 공용 상태에 한 번만 보관한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;DOREPLIFETIME_CONDITION(
    ThisClass,
    MaxGuessCount,
    COND_InitialOnly
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 야구처럼 액터 수와 상태 변경 빈도가 낮은 게임에서는 기본 설정만으로도 문제가 드러나지 않을 수 있다. 하지만 연결 수와 월드 액터가 증가하면 &amp;ldquo;누가 가져야 하는 데이터인가&amp;rdquo;와 &amp;ldquo;얼마나 자주 바뀌는가&amp;rdquo;를 먼저 분리한 설계가 확장성을 결정한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;복제가 기대대로 동작하지 않을 때 확인할 항목&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;우선 확인할 항목&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트에 액터가 보이지 않는다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bReplicates&lt;/code&gt;, 서버에서의 스폰 여부, &lt;code&gt;bNetLoadOnClient&lt;/code&gt;, relevancy와 cull distance를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프로퍼티를 바꿨는데 늦게 도착한다.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NetUpdateFrequency&lt;/code&gt;, dormancy, 대역폭 포화, priority를 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dormant 액터의 값이 갱신되지 않는다.&lt;/td&gt;
&lt;td&gt;값을 변경하기 전에 &lt;code&gt;FlushNetDormancy()&lt;/code&gt; 또는 &lt;code&gt;SetNetDormancy(DORM_Awake)&lt;/code&gt;를 호출했는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;특정 클라이언트만 값을 받지 못한다.&lt;/td&gt;
&lt;td&gt;Owner 체인, &lt;code&gt;bOnlyRelevantToOwner&lt;/code&gt;, replication condition을 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;업데이트 빈도는 높은데 화면이 끊긴다.&lt;/td&gt;
&lt;td&gt;패킷 손실뿐 아니라 클라이언트 보간, 예측, 서버 보정 로직을 함께 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;priority를 높여도 전송되지 않는다.&lt;/td&gt;
&lt;td&gt;그 전에 dormancy, update frequency, relevancy 단계에서 제외되지 않았는지 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복제 문제는 한 옵션만 확인해서는 찾기 어렵다. 액터가 생성되었는지, 이번 주기의 후보인지, 해당 연결에서 relevant한지, 대역폭 경쟁에서 선택되었는지, 프로퍼티 조건을 통과했는지를 파이프라인 순서대로 추적해야 한다.&lt;/p&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/70</guid>
      <comments>https://uegamedev.tistory.com/70#entry70comment</comments>
      <pubDate>Wed, 5 Aug 2026 19:26:17 +0900</pubDate>
    </item>
    <item>
      <title>UE C++ 멀티플레이 게임 프레임워크</title>
      <link>https://uegamedev.tistory.com/69</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;언리얼 멀티플레이 게임플레이 프레임워크와 네트워크 생명주기&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 멀티플레이에서 같은 액터 클래스는 서버와 여러 클라이언트에 각각 생성될 수 있다. 생성자나 &lt;code&gt;BeginPlay()&lt;/code&gt;에 로그 한 줄을 추가했는데 여러 번 출력되는 이유도 이 때문이다. 여기에 CDO 생성, PIE의 다중 월드, 별도 서버 프로세스까지 겹치면 함수가 어느 인스턴스에서 왜 호출됐는지 파악하기 어려워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 풀기 위해서는 두 가지 축을 함께 이해해야 한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;GameMode&lt;/code&gt;, &lt;code&gt;GameState&lt;/code&gt;, &lt;code&gt;PlayerState&lt;/code&gt;, &lt;code&gt;PlayerController&lt;/code&gt;, &lt;code&gt;Pawn&lt;/code&gt;이 각각 어느 컴퓨터에 존재하고 무엇을 소유하는가?&lt;/li&gt;
&lt;li&gt;접속, 복제, 게임 시작, Possess 과정에서 어떤 이벤트가 어떤 순서로 호출되는가?&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 게임플레이 프레임워크의 책임, 데디케이티드 서버 PIE 환경, 네트워크 문맥이 포함된 로깅, 로그인부터 &lt;code&gt;BeginPlay()&lt;/code&gt;와 Possess까지 이어지는 생명주기를 하나의 흐름으로 정리한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모듈 설정은 프레임워크 코드의 출발점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 모듈의 &lt;code&gt;.Build.cs&lt;/code&gt; 파일은 C++ 코드가 사용할 엔진 모듈과 헤더 탐색 경로를 정의한다. 현재 NumberBaseball 모듈은 기본 엔진 모듈 외에 Enhanced Input과 UMG 관련 모듈을 사용한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;PublicDependencyModuleNames.AddRange(new string[]
{
    &quot;Core&quot;,
    &quot;CoreUObject&quot;,
    &quot;Engine&quot;,
    &quot;InputCore&quot;,
    &quot;EnhancedInput&quot;,
    &quot;UMG&quot;,
    &quot;Slate&quot;,
    &quot;SlateCore&quot;
});

PublicIncludePaths.AddRange(new string[]
{
    &quot;NumberBaseball&quot;
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;EnhancedInput&lt;/code&gt;은 Input Action과 Input Mapping Context를 사용하는 캐릭터 입력 코드에 필요하다. &lt;code&gt;UMG&lt;/code&gt;, &lt;code&gt;Slate&lt;/code&gt;, &lt;code&gt;SlateCore&lt;/code&gt;는 채팅과 공지 위젯을 구성할 때 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모듈 루트를 include path에 추가하면 다음과 같이 모듈 내부 기준 경로로 헤더를 포함할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;#include &quot;Game/NBGameModeBase.h&quot;
#include &quot;Player/NBPlayerController.h&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존 모듈이 빠지면 타입 선언을 찾지 못하거나 링크 단계에서 심볼을 해결하지 못한다. 반면 사용하지 않는 모듈을 무분별하게 추가하면 컴파일 의존성과 빌드 범위가 커진다. 프레임워크 클래스를 만들기 전에 실제 사용하는 시스템을 기준으로 모듈 의존성을 정리하는 편이 좋다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게임플레이 프레임워크 클래스의 존재 범위&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 설계에서 가장 먼저 확인할 것은 각 클래스가 어디에 존재하는지다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;클래스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;서버&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;소유 클라이언트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;다른 클라이언트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;대표 책임&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GameMode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;서버 규칙, 로그인 승인, 스폰과 승패 판정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GameState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;모든 참가자가 알아야 하는 경기 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlayerState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;플레이어별 공개 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlayerController&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;연결의 대표, 입력과 소유자 RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;replicated &lt;code&gt;Pawn&lt;/code&gt; / &lt;code&gt;Character&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;월드에 표현되는 플레이어 아바타&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AnimInstance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;각 Skeletal Mesh 인스턴스의 애니메이션 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로컬 UI&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;화면 표시와 입력 수집&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 전용 규칙을 &lt;code&gt;GameMode&lt;/code&gt;에 두고, 모든 클라이언트가 읽어야 하는 값을 &lt;code&gt;GameState&lt;/code&gt;와 &lt;code&gt;PlayerState&lt;/code&gt;로 복제하는 이유가 이 존재 범위에 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GameMode&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GameMode&lt;/code&gt;는 서버에만 존재한다. 접속 허용 여부, 사용할 Pawn과 Controller 클래스, 경기 시작과 종료 조건처럼 클라이언트가 결정해서는 안 되는 규칙을 맡는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NumberBaseball 프로젝트의 &lt;code&gt;ANBGameModeBase&lt;/code&gt;도 다음 서버 로직을 소유한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;OnPostLogin()&lt;/code&gt;에서 접속한 플레이어 등록&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Logout()&lt;/code&gt;에서 퇴장한 컨트롤러 제거&lt;/li&gt;
&lt;li&gt;비밀 숫자 생성과 판정&lt;/li&gt;
&lt;li&gt;승리&amp;middot;무승부 검사와 라운드 리셋&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GameState&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GameState&lt;/code&gt;는 서버에서 생성되어 모든 클라이언트로 복제된다. 서버의 경기 상태를 클라이언트에 공개하는 통로다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 시작 과정에서도 중요한 역할을 한다. 서버의 &lt;code&gt;GameMode&lt;/code&gt;는 클라이언트에 존재하지 않으므로 클라이언트 액터에게 직접 게임 시작을 알릴 수 없다. 대신 &lt;code&gt;GameState&lt;/code&gt;의 replicated state가 게임 시작 신호를 전달한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PlayerState&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PlayerState&lt;/code&gt;는 플레이어별 공개 상태다. 다른 클라이언트에도 복제되므로 이름, 점수, 팀, 공개된 시도 횟수처럼 점수판이나 플레이어 목록에 필요한 데이터를 두기 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NumberBaseball 프로젝트에서는 다음 값을 &lt;code&gt;ANBPlayerState&lt;/code&gt;가 소유한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(Replicated)
FString PlayerNameString;

UPROPERTY(Replicated)
int32 CurrentGuessCount;

UPROPERTY(Replicated)
int32 MaxGuessCount;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PlayerController&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PlayerController&lt;/code&gt;는 네트워크 연결과 플레이어 입력을 대표한다. 서버에는 모든 플레이어의 컨트롤러가 있지만, 클라이언트에는 자신의 컨트롤러만 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 특성 때문에 Server RPC의 진입점이나 소유 클라이언트 대상 Client RPC에 적합하다. NumberBaseball의 채팅 입력과 개인 공지도 &lt;code&gt;ANBPlayerController&lt;/code&gt;를 경유한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Pawn과 Character&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Pawn&lt;/code&gt;은 Controller가 Possess할 수 있는 월드 액터다. &lt;code&gt;Character&lt;/code&gt;는 이동 컴포넌트, 캡슐, 메시 등 캐릭터 게임에 자주 필요한 기능을 갖춘 &lt;code&gt;Pawn&lt;/code&gt;의 파생 클래스다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 입력 설정은 모든 복제본에서 수행할 필요가 없다. 입력을 소유한 인스턴스인지 확인한 뒤 로컬 플레이어 서브시스템에 Mapping Context를 추가해야 한다.&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;void AMyPlayerCharacter::BeginPlay()
{
    Super::BeginPlay();

    if (!IsLocallyControlled())
    {
        return;
    }

    APlayerController* PlayerController =
        Cast&amp;lt;APlayerController&amp;gt;(GetController());

    UEnhancedInputLocalPlayerSubsystem* InputSubsystem =
        ULocalPlayer::GetSubsystem&amp;lt;UEnhancedInputLocalPlayerSubsystem&amp;gt;(
            PlayerController-&amp;gt;GetLocalPlayer());

    InputSubsystem-&amp;gt;AddMappingContext(InputMappingContext, 0);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NumberBaseball의 &lt;code&gt;ANBPlayerController::BeginPlay()&lt;/code&gt;도 같은 원칙으로 &lt;code&gt;IsLocalController()&lt;/code&gt;를 검사한 뒤 로컬 채팅과 공지 위젯만 생성한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PIE 멀티플레이 환경을 어떻게 구성할 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PIE의 실행 방식은 로그와 재현성에 직접 영향을 준다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Launch Separate Server&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 서버를 실행하면 플레이 화면이 없는 서버 인스턴스와 클라이언트 인스턴스를 구분할 수 있다. &lt;code&gt;Play As Client&lt;/code&gt;와 함께 사용하면 데디케이티드 서버 구조를 테스트하기 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 서버를 사용하지 않고 &lt;code&gt;Play As Listen Server&lt;/code&gt;로 실행하면 한 인스턴스가 서버와 로컬 플레이어 역할을 동시에 수행한다. 리슨 서버에서만 우연히 동작하는 로컬 UI나 입력 코드가 데디케이티드 서버에서는 실패할 수 있으므로 두 모드를 구분해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Run Under One Process&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;주의점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;활성화&lt;/td&gt;
&lt;td&gt;서버와 클라이언트 로그를 한 Output Log에서 확인하기 쉽고 실행이 빠름&lt;/td&gt;
&lt;td&gt;같은 프로세스와 에디터 자원을 공유하므로 완전한 격리 환경은 아님&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비활성화&lt;/td&gt;
&lt;td&gt;서버와 클라이언트가 별도 프로세스로 실행되어 실제 배포 환경과 유사&lt;/td&gt;
&lt;td&gt;로그 창과 프로세스가 분리되어 추적이 번거롭고 실행 비용이 큼&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 순서를 빠르게 관찰할 때는 One Process가 편리하다. 프로세스 격리, 정적 상태 공유 문제, 실제 접속 동작을 검증할 때는 Separate Process가 더 신뢰할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Number of Players와 Late Join&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다중 클라이언트의 역할과 소유권을 보려면 두 명 이상으로 실행해야 한다. Late Join을 허용하면 실행 중인 서버에 클라이언트를 추가해 다음 항목도 확인할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새 &lt;code&gt;ClientConnection&lt;/code&gt; 생성&lt;/li&gt;
&lt;li&gt;새 &lt;code&gt;PlayerController&lt;/code&gt;와 &lt;code&gt;PlayerState&lt;/code&gt; 등록&lt;/li&gt;
&lt;li&gt;이미 진행 중인 replicated state의 초기 동기화&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PostLogin&lt;/code&gt;과 접속 알림 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;네트워크 문맥을 포함하는 로그&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수명과 메시지만 기록하면 어느 월드에서 나온 로그인지 알 수 없다. 멀티플레이 로그에는 최소한 다음 정보가 필요하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;NetMode&lt;/code&gt;: Server, Client, Standalone&lt;/li&gt;
&lt;li&gt;PIE 인스턴스 번호&lt;/li&gt;
&lt;li&gt;함수명&lt;/li&gt;
&lt;li&gt;필요할 때 Local Role과 Remote Role&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 전용 로그 카테고리를 선언하면 네트워크 로그만 필터링할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;// NumberBaseball.h

DECLARE_LOG_CATEGORY_EXTERN(LogNBNet, Log, All);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// NumberBaseball.cpp

DEFINE_LOG_CATEGORY(LogNBNet);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;액터 멤버 함수 안에서 사용하는 로그 헬퍼는 다음 형태로 구성할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;#define NB_FUNCTION_NAME ANSI_TO_TCHAR(__FUNCTION__)

#define NB_LOCAL_ROLE \
    *UEnum::GetValueAsString( \
        TEXT(&quot;Engine.ENetRole&quot;), GetLocalRole())

#define NB_REMOTE_ROLE \
    *UEnum::GetValueAsString( \
        TEXT(&quot;Engine.ENetRole&quot;), GetRemoteRole())

#define NB_LOG_ROLE(Verbosity, Format, ...) \
    UE_LOG( \
        LogNBNet, \
        Verbosity, \
        TEXT(&quot;[%s][%s/%s] %s %s&quot;), \
        *NumberBaseballFunctionLibrary::GetNetModeString(this), \
        NB_LOCAL_ROLE, \
        NB_REMOTE_ROLE, \
        NB_FUNCTION_NAME, \
        *FString::Printf(Format, ##__VA_ARGS__))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출력은 다음처럼 읽을 수 있다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;[Server][ROLE_Authority/ROLE_AutonomousProxy]
ANBPlayerController::OnPostLogin Begin

[Client01][ROLE_AutonomousProxy/ROLE_Authority]
ANBPlayerController::PostNetInit End&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 포맷을 사용하면 동일한 함수가 서버 원본과 클라이언트 프록시에서 호출됐는지, Possess 전후 Role이 어떻게 달라졌는지 한눈에 비교할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;생성자 로그와 CDO&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UObject 기반 클래스의 생성자는 플레이 인스턴스만을 위해 호출되는 것이 아니다. 에디터가 클래스를 로드할 때 CDO를 생성하거나 Blueprint 파생 클래스를 준비하면서 생성자 로그가 먼저 출력될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 생성자 호출 횟수만 보고 런타임 액터 수를 판단하면 안 된다. 생성자를 분석할 때는 다음 정보를 함께 출력하는 편이 안전하다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;UE_LOG(
    LogNBNet,
    Log,
    TEXT(&quot;Name=%s CDO=%s&quot;),
    *GetName(),
    HasAnyFlags(RF_ClassDefaultObject)
        ? TEXT(&quot;true&quot;)
        : TEXT(&quot;false&quot;));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이 직전에 Output Log를 비우면 에디터 초기화 로그와 PIE 런타임 로그를 구분하기 쉽다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;접속 생명주기: PreLogin에서 PostLogin까지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 접속은 하나의 함수에서 끝나지 않는다. 서버의 로그인 파이프라인과 클라이언트의 네트워크 초기화 이벤트가 이어진다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;sequenceDiagram
    participant Client as &quot;접속 클라이언트&quot;
    participant Server as &quot;서버 NetDriver&quot;
    participant GM as &quot;서버 GameMode&quot;
    participant PC as &quot;PlayerController&quot;

    Client-&amp;gt;&amp;gt;Server: 접속 요청
    Server-&amp;gt;&amp;gt;GM: PreLogin
    alt ErrorMessage가 비어 있지 않음
        GM--&amp;gt;&amp;gt;Client: 접속 거부
    else 접속 허용
        GM-&amp;gt;&amp;gt;GM: Login
        GM-&amp;gt;&amp;gt;PC: PlayerController 생성/연결
        GM-&amp;gt;&amp;gt;GM: PostLogin / OnPostLogin
        PC--&amp;gt;&amp;gt;Client: 초기 복제
        Client-&amp;gt;&amp;gt;PC: PostNetInit
    end&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PreLogin: 접속 승인 이전&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PreLogin()&lt;/code&gt;은 실제 플레이어 등록 전에 접속을 거부할 수 있는 단계다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void AMyGameMode::PreLogin(
    const FString&amp;amp; Options,
    const FString&amp;amp; Address,
    const FUniqueNetIdRepl&amp;amp; UniqueId,
    FString&amp;amp; ErrorMessage)
{
    Super::PreLogin(
        Options,
        Address,
        UniqueId,
        ErrorMessage);

    if (GetNumPlayers() &amp;gt;= MaxPlayerCount)
    {
        ErrorMessage =
            TEXT(&quot;The server is currently full.&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ErrorMessage&lt;/code&gt;를 설정하면 로그인 파이프라인이 중단된다. 서버 정원, 밴 목록, 버전 호환성처럼 Controller를 생성하기 전에 확인해야 하는 조건에 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 위해 항상 에러를 넣었다면 확인 후 반드시 제거해야 한다. 접속이 거부된 창이 Standalone처럼 보일 수 있으므로 NetMode 로그와 서버의 거부 메시지를 함께 확인하는 것이 좋다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Login: PlayerController 생성 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Login()&lt;/code&gt;은 접속할 플레이어의 &lt;code&gt;PlayerController&lt;/code&gt;를 준비하는 단계다. 일반적인 프로젝트는 &lt;code&gt;Super::Login()&lt;/code&gt;이 수행하는 내부 초기화를 유지하고 반환된 컨트롤러를 검사한다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;APlayerController* LoginPlayerController =
    Super::Login(
        NewPlayer,
        InRemoteRole,
        Portal,
        Options,
        UniqueId,
        ErrorMessage);

return LoginPlayerController;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계를 직접 재구현하기보다 필요한 진단 로그를 앞뒤에 추가하는 편이 안전하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostLogin과 OnPostLogin: 접속 완료 이후&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 완료 이후에는 서버의 &lt;code&gt;PlayerController&lt;/code&gt;와 그에 대응하는 &lt;code&gt;ClientConnection&lt;/code&gt;이 준비되어 있다. 플레이어 등록, 초기 서버 상태 설정, 소유 클라이언트 Client RPC에 적합한 단계다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NumberBaseball 프로젝트의 &lt;code&gt;OnPostLogin()&lt;/code&gt;은 다음 작업을 수행한다.&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;void ANBGameModeBase::OnPostLogin(AController* NewPlayer)
{
    Super::OnPostLogin(NewPlayer);

    ANBPlayerController* PlayerController =
        Cast&amp;lt;ANBPlayerController&amp;gt;(NewPlayer);

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

    AllPlayerControllers.Add(PlayerController);
    ++JoinedPlayerCount;

    PlayerController-&amp;gt;ClientRPCSetNotificationText(
        FText::FromString(
            TEXT(&quot;Connected to the game server.&quot;)));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;접속 순번을 현재 배열 길이와 분리한 이유도 생명주기와 관련 있다. 플레이어가 나가면 배열 길이는 줄지만 누적 접속 순번은 되돌아가지 않는다. 별도 카운터를 사용하면 퇴장 후 새 접속자가 기존 이름을 다시 받는 문제를 피할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Logout: 서버 레지스트리 정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;접속한 Controller를 별도 배열에 보관했다면 퇴장 시 반드시 제거해야 한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void ANBGameModeBase::Logout(AController* Exiting)
{
    ANBPlayerController* PlayerController =
        Cast&amp;lt;ANBPlayerController&amp;gt;(Exiting);

    if (IsValid(PlayerController))
    {
        AllPlayerControllers.Remove(PlayerController);
    }

    Super::Logout(Exiting);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하지 않으면 이후 방송이나 게임 리셋이 이미 퇴장한 컨트롤러를 대상으로 시도될 수 있다. &lt;code&gt;UPROPERTY()&lt;/code&gt;가 붙은 &lt;code&gt;TObjectPtr&lt;/code&gt; 배열은 GC 참조도 유지하므로, 접속과 퇴장 양쪽에서 컬렉션의 불변 조건을 관리해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버와 클라이언트의 NetConnection 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;UNetDriver&lt;/code&gt;가 관리하는 연결 구조는 서버와 클라이언트에서 다르다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Server NetDriver
└─ ClientConnections[N]
   ├─ ClientConnection 1
   ├─ ClientConnection 2
   └─ ...

Client NetDriver
└─ ServerConnection&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 로그인 완료 단계에서는 &lt;code&gt;ClientConnections&lt;/code&gt;를 관찰할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;UNetDriver* ServerNetDriver = GetNetDriver();

if (IsValid(ServerNetDriver))
{
    for (UNetConnection* ClientConnection
         : ServerNetDriver-&amp;gt;ClientConnections)
    {
        if (IsValid(ClientConnection))
        {
            UE_LOG(
                LogNBNet,
                Log,
                TEXT(&quot;ClientConnection=%s&quot;),
                *ClientConnection-&amp;gt;GetName());
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서는 replicated &lt;code&gt;PlayerController&lt;/code&gt;의 &lt;code&gt;PostNetInit()&lt;/code&gt; 시점에 자신의 &lt;code&gt;ServerConnection&lt;/code&gt;을 확인할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void AMyPlayerController::PostNetInit()
{
    Super::PostNetInit();

    if (!IsLocalController())
    {
        return;
    }

    UNetDriver* ClientNetDriver = GetNetDriver();
    UNetConnection* ServerConnection =
        IsValid(ClientNetDriver)
            ? ClientNetDriver-&amp;gt;ServerConnection
            : nullptr;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PostNetInit()&lt;/code&gt;은 액터의 초기 네트워크 속성이 클라이언트에 적용된 뒤 호출되는 진단 지점이다. 클라이언트 복제본에서 Owner, Role, Connection 같은 초기 네트워크 문맥을 확인할 때 생성자보다 적합하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Connection, Channel, Bunch, Packet&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 네트워크 로그와 엔진 소스를 읽으려면 데이터 계층을 구분해야 한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;단위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNetConnection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버와 클라이언트 사이의 통신 경로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Channel&lt;/td&gt;
&lt;td&gt;하나의 Connection 안에서 목적별 데이터를 나누는 논리 채널&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actor Channel&lt;/td&gt;
&lt;td&gt;액터 생성, 프로퍼티 복제, RPC 등 액터 관련 데이터 전송&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control Channel&lt;/td&gt;
&lt;td&gt;연결 제어와 로그인 같은 제어 메시지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice Channel&lt;/td&gt;
&lt;td&gt;음성 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bunch&lt;/td&gt;
&lt;td&gt;언리얼이 Channel을 통해 전송하는 데이터 묶음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packet&lt;/td&gt;
&lt;td&gt;네트워크 계층에서 실제로 전달되는 전송 단위&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관계는 다음과 같이 볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;NetConnection
├─ ControlChannel
├─ ActorChannel: PlayerController
├─ ActorChannel: PlayerState
├─ ActorChannel: Pawn
└─ ...
    └─ Bunch
       └─ Packet에 패킹되어 전송&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;OnActorChannelOpen()&lt;/code&gt;은 액터 채널이 열리는 낮은 수준의 이벤트를 관찰할 수 있지만, 일반 게임 로직을 넣기보다는 복제 문제를 진단하는 용도로 제한하는 편이 좋다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게임 시작 신호와 BeginPlay 전파&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에는 &lt;code&gt;GameMode&lt;/code&gt;가 있지만 클라이언트에는 없다. 그런데도 클라이언트 액터의 &lt;code&gt;BeginPlay()&lt;/code&gt;가 시작되는 이유는 &lt;code&gt;GameState&lt;/code&gt;가 게임 시작 상태를 복제하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;flowchart TD
    A[&quot;Server GameMode::StartPlay&quot;] --&amp;gt; B[&quot;GameState::HandleBeginPlay&quot;]
    B --&amp;gt; C[&quot;bReplicatedHasBegunPlay = true&quot;]
    B --&amp;gt; D[&quot;서버 WorldSettings::NotifyBeginPlay&quot;]
    D --&amp;gt; E[&quot;서버 액터 DispatchBeginPlay&quot;]
    E --&amp;gt; F[&quot;서버 액터 BeginPlay&quot;]
    C --&amp;gt; G[&quot;Property Replication&quot;]
    G --&amp;gt; H[&quot;클라이언트 GameState::OnRep_ReplicatedHasBegunPlay&quot;]
    H --&amp;gt; I[&quot;클라이언트 WorldSettings::NotifyBeginPlay&quot;]
    I --&amp;gt; J[&quot;클라이언트 액터 DispatchBeginPlay&quot;]
    J --&amp;gt; K[&quot;클라이언트 액터 BeginPlay&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AGameStateBase&lt;/code&gt;는 게임 시작 여부를 replicated property로 갖는다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(
    Transient,
    ReplicatedUsing = OnRep_ReplicatedHasBegunPlay)
bool bReplicatedHasBegunPlay;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 &lt;code&gt;HandleBeginPlay()&lt;/code&gt;는 값을 &lt;code&gt;true&lt;/code&gt;로 바꾸고 서버 월드의 액터들에게 시작을 알린다. 클라이언트가 이 값을 받으면 &lt;code&gt;OnRep_ReplicatedHasBegunPlay()&lt;/code&gt;가 클라이언트 월드의 액터들에게 시작을 알린다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Super::StartPlay()를 생략하면 생기는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GameMode::StartPlay()&lt;/code&gt;를 오버라이드하면서 &lt;code&gt;Super::StartPlay()&lt;/code&gt;를 호출하지 않으면 기본 게임 시작 파이프라인이 끊어진다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void AMyGameMode::StartPlay()
{
    Super::StartPlay();

    // 프로젝트 고유 시작 로직
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 시작 처리가 진행되지 않으면 &lt;code&gt;GameState::HandleBeginPlay()&lt;/code&gt;와 월드의 &lt;code&gt;NotifyBeginPlay()&lt;/code&gt;로 이어지지 않아 다른 액터의 &lt;code&gt;BeginPlay()&lt;/code&gt;도 실행되지 않을 수 있다. 캐릭터 입력이 설정되지 않거나 게임이 멈춘 것처럼 보일 때 개별 Pawn만 조사하기보다 &lt;code&gt;StartPlay()&lt;/code&gt;의 Super 호출부터 확인해야 하는 이유다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;액터 초기화 이벤트의 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서로 비슷해 보이는 이벤트도 보장하는 준비 상태가 다르다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;이벤트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;주요 의미&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;적합한 진단&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;생성자&lt;/td&gt;
&lt;td&gt;기본 서브오브젝트와 클래스 기본값 구성&lt;/td&gt;
&lt;td&gt;CDO 여부, 컴포넌트 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostInitializeComponents()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;액터 컴포넌트 초기화 완료&lt;/td&gt;
&lt;td&gt;컴포넌트 참조와 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostNetInit()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;클라이언트 복제본의 초기 네트워크 데이터 적용&lt;/td&gt;
&lt;td&gt;Role, Owner, NetConnection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;StartPlay()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버가 경기 시작을 지시&lt;/td&gt;
&lt;td&gt;게임 시작 파이프라인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BeginPlay()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;해당 월드에서 액터 플레이 시작&lt;/td&gt;
&lt;td&gt;입력, 타이머, 런타임 초기화&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 초기화를 생성자나 &lt;code&gt;BeginPlay()&lt;/code&gt; 하나에 몰아넣으면 네트워크 속성이 아직 준비되지 않은 시점에 접근하거나, 서버 복제본에서도 로컬 UI를 만들 수 있다. 필요한 보장에 맞는 이벤트를 선택해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Possess와 Owner 복제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Possess는 단순히 Controller 포인터를 Pawn에 대입하는 작업이 아니다. 네트워크 소유 관계와 RPC 경로를 결정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 Pawn이 Possess될 때 &lt;code&gt;APawn::PossessedBy()&lt;/code&gt;는 Owner를 새 Controller로 설정한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void APawn::PossessedBy(AController* NewController)
{
    SetOwner(NewController);

    // Controller, PlayerState 등 Possess 상태 설정
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Owner 체인을 통해 Pawn은 자신을 소유한 &lt;code&gt;PlayerController&lt;/code&gt;와 &lt;code&gt;UNetConnection&lt;/code&gt;을 찾는다. 클라이언트가 Pawn에서 Server RPC를 호출하거나 서버가 소유 클라이언트 대상으로 Client RPC를 보낼 수 있는 기반이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Possess 해제 시에는 Owner와 Controller, PlayerState 연결이 정리된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void APawn::UnPossessed()
{
    ForceNetUpdate();
    SetPlayerState(nullptr);
    SetOwner(nullptr);
    Controller = nullptr;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버의 PossessedBy와 클라이언트의 OnRep_Owner&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Possess의 권한 있는 처리는 서버에서 실행된다. 클라이언트가 서버의 &lt;code&gt;PossessedBy()&lt;/code&gt;를 똑같이 호출해 소유권을 재현하는 것이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AActor::Owner&lt;/code&gt;는 복제되는 프로퍼티이므로 클라이언트가 새 Owner를 받으면 &lt;code&gt;OnRep_Owner()&lt;/code&gt;가 호출된다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;sequenceDiagram
    participant ServerPC as &quot;서버 PlayerController&quot;
    participant ServerPawn as &quot;서버 Pawn&quot;
    participant ClientPawn as &quot;클라이언트 Pawn 복제본&quot;

    ServerPC-&amp;gt;&amp;gt;ServerPawn: Possess
    ServerPawn-&amp;gt;&amp;gt;ServerPawn: PossessedBy
    ServerPawn-&amp;gt;&amp;gt;ServerPawn: SetOwner(PlayerController)
    ServerPawn--&amp;gt;&amp;gt;ClientPawn: Owner 프로퍼티 복제
    ClientPawn-&amp;gt;&amp;gt;ClientPawn: OnRep_Owner&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 로직은 &lt;code&gt;PossessedBy()&lt;/code&gt;와 &lt;code&gt;OnPossess()&lt;/code&gt;에서 관찰하고, 클라이언트가 소유 관계를 받은 시점은 &lt;code&gt;OnRep_Owner()&lt;/code&gt;에서 관찰한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Possess 전후 Role 변화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 생성된 Pawn은 항상 서버 쪽 Local Role이 Authority지만, 어느 클라이언트가 조종하게 되면 Remote Role이 Autonomous Proxy로 바뀔 수 있다. 소유 클라이언트의 Pawn 복제본은 Local Role이 Autonomous Proxy가 된다. 다른 클라이언트에서는 Simulated Proxy다.&lt;/p&gt;
&lt;table style=&quot;height: 84px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;관찰 위치&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;Local Role&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;Remote Role&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버의 소유된 Pawn&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;Authority&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;AutonomousProxy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Pawn 소유 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;AutonomousProxy&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;Authority&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;다른 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;SimulatedProxy&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;Authority&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Possess 직전과 직후에 Local/Remote Role을 함께 기록하면 입력과 Server RPC가 어느 Pawn에서 유효한지 확인할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제별로 선택할 이벤트 함수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 문제는 증상과 가장 가까운 이벤트를 로깅해야 호출 순서를 좁힐 수 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;확인할 문제&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;우선 로깅할 위치&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버가 접속을 거부하는 이유&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GameMode::PreLogin()&lt;/code&gt;과 &lt;code&gt;ErrorMessage&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로그인 중 Controller 생성 실패&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GameMode::Login()&lt;/code&gt; 반환값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버의 접속자 등록과 ClientConnection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PostLogin()&lt;/code&gt; / &lt;code&gt;OnPostLogin()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;퇴장 후 남은 참조&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GameMode::Logout()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트의 ServerConnection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PlayerController::PostNetInit()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actor Channel 생성&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OnActorChannelOpen()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모든 액터의 &lt;code&gt;BeginPlay()&lt;/code&gt; 미호출&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GameMode::StartPlay()&lt;/code&gt;, &lt;code&gt;GameState::HandleBeginPlay()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트만 &lt;code&gt;BeginPlay()&lt;/code&gt;가 늦거나 없음&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OnRep_ReplicatedHasBegunPlay()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;컴포넌트가 아직 준비되지 않음&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PostInitializeComponents()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pawn 소유권과 RPC 실패&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PossessedBy()&lt;/code&gt;, &lt;code&gt;OnRep_Owner()&lt;/code&gt;, Role 로그&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로컬 입력이 여러 복제본에서 실행&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IsLocallyControlled()&lt;/code&gt; / &lt;code&gt;IsLocalController()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 이름을 외우는 것보다 &lt;b&gt;그 이벤트가 호출될 때 어떤 상태까지 준비되어 있는가&lt;/b&gt;를 기준으로 선택하는 것이 중요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NumberBaseball 프로젝트에서 확인되는 프레임워크 패턴&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트에도 같은 게임플레이 프레임워크 원칙이 적용되어 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;ANBGameModeBase (Server Only)
├─ OnPostLogin / Logout
├─ 접속자 배열
├─ 비밀 숫자와 게임 규칙
└─ 승패 판정

ANBGameStateBase (Server + All Clients)
└─ 접속 알림 NetMulticast RPC

ANBPlayerState (Server Authority -&amp;gt; All Clients)
├─ PlayerNameString
├─ CurrentGuessCount
└─ MaxGuessCount

ANBPlayerController (Server + Owning Client)
├─ 채팅 Server / Client RPC
├─ 소유자 공지 Client RPC
└─ IsLocalController 이후 UI 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 디버깅할 때 함수명만 출력하는 것보다 NetMode, PIE ID, Local/Remote Role을 함께 기록하면 실행 위치와 소유 관계를 훨씬 빠르게 확인할 수 있다. 접속 문제는 로그인 생명주기에서, 게임 시작 문제는 &lt;code&gt;StartPlay()&lt;/code&gt;와 &lt;code&gt;GameState&lt;/code&gt;의 시작 복제에서, 입력과 RPC 문제는 Possess와 Owner 복제에서 추적하는 식으로 조사 범위를 나눌 수 있다.&lt;/p&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/69</guid>
      <comments>https://uegamedev.tistory.com/69#entry69comment</comments>
      <pubDate>Tue, 4 Aug 2026 19:39:42 +0900</pubDate>
    </item>
    <item>
      <title>언리얼C++ 멀티플레이 숫자야구 (판정 및 게임사이클 완성)</title>
      <link>https://uegamedev.tistory.com/68</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;언리얼 서버 권한 숫자 야구&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 채팅에 숫자 야구 규칙을 결합하면 메시지를 전달하는 것만으로는 충분하지 않다. 서버가 비밀 숫자를 관리하고, 클라이언트의 입력을 검증하고, 판정 결과와 플레이어별 시도 횟수를 모든 참가자에게 일관되게 보여줘야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 핵심은 &lt;b&gt;게임 규칙과 상태의 소유자를 명확히 분리하는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비밀 숫자와 승패 판정은 서버 전용 &lt;code&gt;GameMode&lt;/code&gt;가 소유한다.&lt;/li&gt;
&lt;li&gt;플레이어 이름과 시도 횟수는 &lt;code&gt;PlayerState&lt;/code&gt;에 저장해 복제한다.&lt;/li&gt;
&lt;li&gt;로컬 UI 입력은 &lt;code&gt;PlayerController&lt;/code&gt;가 받아 Server RPC로 전달한다.&lt;/li&gt;
&lt;li&gt;판정 결과처럼 즉시 보여줄 사건은 Client RPC로 방송한다.&lt;/li&gt;
&lt;li&gt;승리&amp;middot;무승부 공지는 각 &lt;code&gt;PlayerController&lt;/code&gt;의 replicated property로 전달한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 RPC와 Property Replication의 차이는 이전 글에서 다뤘다. 이번 글에서는 이 요소들이 하나의 서버 권한 게임 루프로 연결되는 과정을 정리한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클래스별 책임을 먼저 나누기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼의 네트워크 프레임워크 클래스는 존재 범위와 복제 특성이 서로 다르다. 기능을 어느 클래스에 둘지는 &amp;ldquo;그 기능을 어느 컴퓨터가 알아야 하는가?&amp;rdquo;로 결정할 수 있다.&lt;/p&gt;
&lt;table style=&quot;height: 129px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; 클래스 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; 존재 범위 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; 숫자 야구에서의 책임 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;GameMode&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버에만 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;비밀 숫자 생성, 입력 검증, S/B 판정, 승패&amp;middot;무승부&amp;middot;리셋&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;GameState&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버와 모든 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;모든 참가자가 공유하는 접속 이벤트 전달&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;PlayerState&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버와 모든 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;플레이어 이름, 현재 시도 횟수, 최대 시도 횟수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;PlayerController&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버와 해당 소유 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;로컬 입력, Server RPC 진입점, 소유 클라이언트 전용 공지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;UserWidget&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;로컬 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;입력 수집과 복제된 값의 화면 표현&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비밀 숫자를 &lt;code&gt;PlayerController&lt;/code&gt;나 &lt;code&gt;PlayerState&lt;/code&gt;에 저장해 복제하면 정답이 클라이언트에 노출될 수 있다. 서버에만 존재하는 &lt;code&gt;GameMode&lt;/code&gt;는 클라이언트가 직접 접근할 수 없으므로 비밀 정보와 최종 판정을 보관하기에 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 플레이어 이름과 시도 횟수는 모든 참가자가 알아야 한다. 서버에만 존재하는 &lt;code&gt;GameMode&lt;/code&gt; 멤버로 두면 클라이언트가 읽을 수 없으므로, 모든 컴퓨터에 복제되는 &lt;code&gt;PlayerState&lt;/code&gt;가 자연스러운 저장 위치다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 실행 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트는 채팅 입력의 마지막 세 글자를 숫자 야구 입력으로 해석한다. 유효한 추측이면 서버가 판정하고, 일반 문자열이면 기존 채팅처럼 방송한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;sequenceDiagram
    participant UI as &quot;로컬 입력 UI&quot;
    participant CPC as &quot;클라이언트 PlayerController&quot;
    participant SPC as &quot;서버 PlayerController&quot;
    participant GM as &quot;서버 GameMode&quot;
    participant PS as &quot;PlayerState&quot;
    participant Clients as &quot;각 소유 클라이언트&quot;

    UI-&amp;gt;&amp;gt;CPC: 문자열 입력
    CPC-&amp;gt;&amp;gt;SPC: Server RPC
    SPC-&amp;gt;&amp;gt;GM: 입력 처리 요청
    GM-&amp;gt;&amp;gt;GM: 마지막 3자리 검증
    alt 유효한 추측
        GM-&amp;gt;&amp;gt;GM: Strike / Ball 판정
        GM-&amp;gt;&amp;gt;PS: CurrentGuessCount 증가
        GM-&amp;gt;&amp;gt;Clients: Client RPC로 판정 결과 방송
        GM-&amp;gt;&amp;gt;GM: 승리 또는 무승부 검사
        PS--&amp;gt;&amp;gt;Clients: 변경된 시도 횟수 복제
    else 일반 채팅
        GM-&amp;gt;&amp;gt;Clients: Client RPC로 원문 방송
    end&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 &lt;b&gt;클라이언트가 입력을 만들지만 결과는 서버가 결정한다&lt;/b&gt;는 것이다. 클라이언트는 비밀 숫자를 알지 못하고, 판정 결과나 남은 시도 횟수를 직접 확정하지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버에서만 비밀 숫자 생성하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임이 시작되면 서버의 &lt;code&gt;GameMode::BeginPlay()&lt;/code&gt;가 비밀 숫자를 생성한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ANBGameModeBase::BeginPlay()
{
    Super::BeginPlay();

    SecretNumberString = GenerateSecretNumber();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비밀 숫자 생성 로직은 1부터 9까지의 후보 중 하나를 고른 뒤 배열에서 제거한다. 같은 숫자를 다시 고를 수 없으므로 서로 다른 세 자리 숫자가 만들어진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;FString ANBGameModeBase::GenerateSecretNumber()
{
    TArray&amp;lt;int32&amp;gt; Numbers;
    for (int32 Number = 1; Number &amp;lt;= 9; ++Number)
    {
        Numbers.Add(Number);
    }

    FString Result;
    for (int32 Index = 0; Index &amp;lt; 3; ++Index)
    {
        const int32 RandomIndex =
            FMath::RandRange(0, Numbers.Num() - 1);

        Result.Append(FString::FromInt(Numbers[RandomIndex]));
        Numbers.RemoveAt(RandomIndex);
    }

    return Result;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SecretNumberString&lt;/code&gt;은 replicated property가 아니다. 복제할 이유가 없을 뿐 아니라 클라이언트에 전달하면 안 되는 서버 전용 상태다. 클라이언트가 알아야 하는 것은 정답 자체가 아니라 서버가 계산한 &lt;code&gt;1S1B&lt;/code&gt;, &lt;code&gt;OUT&lt;/code&gt; 같은 결과다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문자열 입력을 게임 명령으로 검증하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 채팅 전체 문자열에서 마지막 세 글자를 추출한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const int32 Index = InChatMessageString.Len() - 3;
const FString GuessNumberString =
    InChatMessageString.RightChop(Index);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 야구 입력으로 인정하려면 다음 불변 조건이 필요하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;길이가 정확히 3이어야 한다.&lt;/li&gt;
&lt;li&gt;모든 문자가 숫자여야 한다.&lt;/li&gt;
&lt;li&gt;0이 없어야 한다.&lt;/li&gt;
&lt;li&gt;세 숫자가 서로 달라야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;TSet&lt;/code&gt;을 사용하면 중복 여부를 간단히 판정할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;bool ANBGameModeBase::IsGuessNumberString(
    const FString&amp;amp; InNumberString)
{
    if (InNumberString.Len() != 3)
    {
        return false;
    }

    TSet&amp;lt;TCHAR&amp;gt; UniqueDigits;

    for (const TCHAR Digit : InNumberString)
    {
        if (!FChar::IsDigit(Digit) || Digit == TEXT('0'))
        {
            return false;
        }

        UniqueDigits.Add(Digit);
    }

    return UniqueDigits.Num() == 3;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트 코드도 &lt;code&gt;TSet&lt;/code&gt;에 문자를 추가하지만 집합의 크기를 마지막에 검사하지 않는다. 따라서 &lt;code&gt;112&lt;/code&gt;처럼 중복 숫자가 포함된 문자열도 유효한 추측으로 통과할 수 있다. &lt;code&gt;UniqueDigits.Num() == 3&lt;/code&gt;은 단순한 최적화가 아니라 게임 규칙을 완성하는 필수 조건이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증은 반드시 서버에서 다시 수행해야 한다. 정상 클라이언트의 UI가 입력을 제한하더라도 변조된 클라이언트는 Server RPC를 직접 호출할 수 있기 때문이다. 클라이언트 검증은 사용자 경험을 위한 사전 검사이고, 서버 검증은 게임 규칙을 지키는 최종 경계다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Strike와 Ball 판정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증된 세 자리 문자열은 서버가 정답과 비교한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;FString ANBGameModeBase::JudgeResult(
    const FString&amp;amp; InSecretNumberString,
    const FString&amp;amp; InGuessNumberString)
{
    int32 StrikeCount = 0;
    int32 BallCount = 0;

    for (int32 Index = 0; Index &amp;lt; 3; ++Index)
    {
        if (InSecretNumberString[Index] ==
            InGuessNumberString[Index])
        {
            ++StrikeCount;
        }
        else
        {
            const FString GuessDigit = FString::Printf(
                TEXT(&quot;%c&quot;),
                InGuessNumberString[Index]);

            if (InSecretNumberString.Contains(GuessDigit))
            {
                ++BallCount;
            }
        }
    }

    if (StrikeCount == 0 &amp;amp;&amp;amp; BallCount == 0)
    {
        return TEXT(&quot;OUT&quot;);
    }

    return FString::Printf(
        TEXT(&quot;%dS%dB&quot;),
        StrikeCount,
        BallCount);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 위치의 숫자가 같으면 Strike다. 위치는 다르지만 정답에 포함된 숫자라면 Ball이다. 입력과 정답 모두 서로 다른 숫자라는 불변 조건을 만족하므로, 하나의 입력 숫자가 여러 번 Ball로 계산되는 문제도 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 문자열을 반환하지만 게임 로직 내부에서는 구조체를 사용하는 방법도 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;struct FNumberBaseballResult
{
    int32 StrikeCount = 0;
    int32 BallCount = 0;

    bool IsOut() const
    {
        return StrikeCount == 0 &amp;amp;&amp;amp; BallCount == 0;
    }

    bool IsWin() const
    {
        return StrikeCount == 3;
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판정 데이터와 화면 표시 문자열을 분리하면 승리 여부를 확인하기 위해 &lt;code&gt;&quot;3S0B&quot;&lt;/code&gt;의 첫 글자를 다시 파싱할 필요가 없다. 게임 규칙은 정수 필드로 처리하고, 사용자에게 전송할 때만 문자열로 변환할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PlayerState로 플레이어별 상태 복제하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 플레이어가 접속한 순서에 따라 이름을 지정한다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;ANBPlayerState* PlayerState =
    PlayerController-&amp;gt;GetPlayerState&amp;lt;ANBPlayerState&amp;gt;();

if (IsValid(PlayerState))
{
    PlayerState-&amp;gt;PlayerNameString =
        TEXT(&quot;Player&quot;) +
        FString::FromInt(AllPlayerControllers.Num());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 값이 일반 &lt;code&gt;FString&lt;/code&gt; 멤버라면 서버에서 변경되어도 클라이언트 복제본에는 전달되지 않는다. 프로젝트에서는 &lt;code&gt;PlayerNameString&lt;/code&gt;, &lt;code&gt;CurrentGuessCount&lt;/code&gt;, &lt;code&gt;MaxGuessCount&lt;/code&gt;를 replicated property로 등록했다.&lt;/p&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;UCLASS()
class NUMBERBASEBALL_API ANBPlayerState : public APlayerState
{
    GENERATED_BODY()

public:
    ANBPlayerState();

    virtual void GetLifetimeReplicatedProps(
        TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps) const override;

    UPROPERTY(Replicated)
    FString PlayerNameString;

    UPROPERTY(Replicated)
    int32 CurrentGuessCount;

    UPROPERTY(Replicated)
    int32 MaxGuessCount;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;#include &quot;Net/UnrealNetwork.h&quot;

ANBPlayerState::ANBPlayerState()
    : PlayerNameString(TEXT(&quot;None&quot;))
    , CurrentGuessCount(0)
    , MaxGuessCount(3)
{
    bReplicates = true;
}

void ANBPlayerState::GetLifetimeReplicatedProps(
    TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ThisClass, PlayerNameString);
    DOREPLIFETIME(ThisClass, CurrentGuessCount);
    DOREPLIFETIME(ThisClass, MaxGuessCount);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;APlayerState&lt;/code&gt;는 원래 네트워크 게임의 플레이어별 공용 상태를 위해 설계된 프레임워크 액터다. 각 클라이언트는 다른 플레이어의 &lt;code&gt;PlayerController&lt;/code&gt;를 갖지 않지만, &lt;code&gt;PlayerState&lt;/code&gt;는 모든 참가자에게 복제된다. 점수판이나 플레이어 목록처럼 서로의 정보를 읽어야 하는 기능에 적합한 이유다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최대 시도 횟수도 복제해야 하는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;MaxGuessCount&lt;/code&gt;가 항상 3이고 모든 빌드에 동일한 상수라면 각 클라이언트가 같은 기본값을 갖게 할 수도 있다. 이 경우 네트워크로 매번 복제할 실익은 작다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 다음 조건에서는 서버의 값을 복제하는 편이 안전하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;게임 모드나 난이도에 따라 최대 횟수가 달라진다.&lt;/li&gt;
&lt;li&gt;서버 운영 설정으로 런타임에 규칙을 바꿀 수 있다.&lt;/li&gt;
&lt;li&gt;클라이언트 UI가 서버가 확정한 현재 규칙을 표시해야 한다.&lt;/li&gt;
&lt;li&gt;라운드 도중 특정 효과로 최대 횟수가 변경될 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &amp;ldquo;값이 상수인가?&amp;rdquo;보다 &lt;b&gt;서버가 이 규칙의 유일한 소유자인가, 런타임에 달라질 수 있는가?&lt;/b&gt;가 결정 기준이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;복제에는 도착 시간이 존재한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 &lt;code&gt;PlayerNameString&lt;/code&gt;을 변경했다고 해서 같은 프레임에 클라이언트가 즉시 새 값을 읽을 수 있는 것은 아니다. 프로퍼티 복제는 네트워크 업데이트를 거쳐 비동기적으로 도착한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값 도착에 맞춰 UI를 갱신해야 한다면 &lt;code&gt;ReplicatedUsing&lt;/code&gt;과 &lt;code&gt;OnRep&lt;/code&gt; 함수를 사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = OnRep_PlayerInfo)
FString PlayerNameString;

UFUNCTION()
void OnRep_PlayerInfo();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;OnRep_PlayerInfo()&lt;/code&gt;에서 위젯을 갱신하면 Tick에서 값을 계속 확인하거나 복제 타이밍을 추측할 필요가 없다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시도 횟수는 서버가 증가시킨다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유효한 추측이 들어오면 &lt;code&gt;GameMode&lt;/code&gt;가 요청한 플레이어의 &lt;code&gt;PlayerState&lt;/code&gt;를 찾아 시도 횟수를 증가시킨다.&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;void ANBGameModeBase::IncreaseGuessCount(
    ANBPlayerController* InChattingPlayerController)
{
    ANBPlayerState* PlayerState =
        InChattingPlayerController
            -&amp;gt;GetPlayerState&amp;lt;ANBPlayerState&amp;gt;();

    if (IsValid(PlayerState))
    {
        ++PlayerState-&amp;gt;CurrentGuessCount;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 주체는 서버 Authority다. 클라이언트는 &lt;code&gt;CurrentGuessCount&lt;/code&gt;를 직접 올리지 않는다. 서버에서 값이 변경되면 Property Replication이 각 클라이언트의 &lt;code&gt;PlayerState&lt;/code&gt; 복제본을 갱신한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력을 받기 전에는 다음 조건도 서버가 확인해야 한다.&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;const bool bCanGuess =
    PlayerState-&amp;gt;CurrentGuessCount &amp;lt;
    PlayerState-&amp;gt;MaxGuessCount;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시도 횟수를 화면에만 표시하고 서버 입력을 계속 허용하면 제한은 실제 게임 규칙이 아니라 장식에 불과하다. 서버는 이미 최대 횟수에 도달한 플레이어의 추가 추측을 거부해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PlayerController 프로퍼티로 소유자 전용 공지 전달하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 &lt;code&gt;NotificationText&lt;/code&gt;는 &lt;code&gt;PlayerController&lt;/code&gt;에 선언되어 있다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(Replicated, BlueprintReadOnly)
FText NotificationText;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ANBPlayerController::GetLifetimeReplicatedProps(
    TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ThisClass, NotificationText);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PlayerController&lt;/code&gt;는 서버와 해당 컨트롤러를 소유한 클라이언트에만 존재한다. 서버가 각 플레이어의 서버 측 &lt;code&gt;PlayerController::NotificationText&lt;/code&gt;를 변경하면, 그 값은 자연스럽게 해당 소유 클라이언트의 컨트롤러로 전달된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 특성은 두 저장 위치의 차이를 선명하게 보여준다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;저장 위치&lt;/th&gt;
&lt;th&gt;복제 대상&lt;/th&gt;
&lt;th&gt;적합한 데이터&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlayerState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;모든 클라이언트&lt;/td&gt;
&lt;td&gt;이름, 점수, 공개된 시도 횟수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlayerController&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;소유 클라이언트&lt;/td&gt;
&lt;td&gt;개인 공지, 개인 UI 상태, 소유자 전용 정보&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 위젯이 Blueprint 바인딩으로 &lt;code&gt;NotificationText&lt;/code&gt;를 읽는다면 &lt;code&gt;Replicated&lt;/code&gt;만으로도 표시할 수 있다. C++에서 값이 도착하는 순간 명시적으로 UI를 갱신하려면 &lt;code&gt;ReplicatedUsing = OnRep_NotificationText&lt;/code&gt;가 더 직접적인 방식이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;승리, 무승부, 리셋을 하나의 상태 전이로 보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유효한 입력을 판정하고 시도 횟수를 증가시킨 다음 서버는 게임 종료 조건을 검사한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Strike가 3이면 입력한 플레이어가 승리한다.&lt;/li&gt;
&lt;li&gt;승자가 없고 모든 플레이어가 최대 시도 횟수에 도달하면 무승부다.&lt;/li&gt;
&lt;li&gt;승리 또는 무승부가 확정되면 새 비밀 숫자를 만들고 모든 시도 횟수를 0으로 되돌린다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; Playing: 비밀 숫자 생성
    Playing --&amp;gt; Won: Strike == 3
    Playing --&amp;gt; Draw: 모든 플레이어가 시도 소진
    Playing --&amp;gt; Playing: 아직 종료 조건 미충족
    Won --&amp;gt; Playing: 공지 후 ResetGame
    Draw --&amp;gt; Playing: 공지 후 ResetGame&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리셋은 비밀 숫자와 모든 플레이어의 시도 횟수를 함께 변경한다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;void ANBGameModeBase::ResetGame()
{
    SecretNumberString = GenerateSecretNumber();

    for (ANBPlayerController* PlayerController
         : AllPlayerControllers)
    {
        if (!IsValid(PlayerController))
        {
            continue;
        }

        ANBPlayerState* PlayerState =
            PlayerController
                -&amp;gt;GetPlayerState&amp;lt;ANBPlayerState&amp;gt;();

        if (IsValid(PlayerState))
        {
            PlayerState-&amp;gt;CurrentGuessCount = 0;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 서버에서만 실행된다. &lt;code&gt;SecretNumberString&lt;/code&gt;은 서버에만 남고, 초기화된 &lt;code&gt;CurrentGuessCount&lt;/code&gt;는 &lt;code&gt;PlayerState&lt;/code&gt;의 Property Replication을 통해 클라이언트에 전달된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방송 횟수와 게임 판정 횟수를 분리하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과 문자열은 모든 플레이어에게 한 번씩 보내야 하지만, 승패 판정은 입력 한 건당 한 번만 실행해야 한다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;for (TActorIterator&amp;lt;ANBPlayerController&amp;gt; It(GetWorld());
     It;
     ++It)
{
    if (ANBPlayerController* PlayerController = *It)
    {
        PlayerController-&amp;gt;ClientRPCPrintChatMessageString(
            CombinedMessageString);
    }
}

JudgeGame(InChattingPlayerController, StrikeCount);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트에서는 &lt;code&gt;JudgeGame()&lt;/code&gt; 호출이 &lt;code&gt;PlayerController&lt;/code&gt; 방송 루프 안에 있다. 접속자가 두 명이면 같은 입력에 대한 승패 판정도 두 번 실행되고, 승리나 무승부일 때 &lt;code&gt;ResetGame()&lt;/code&gt;이 반복 호출될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 원칙으로 승리&amp;middot;무승부 공지를 각 컨트롤러에 설정하는 루프에서도 &lt;code&gt;ResetGame()&lt;/code&gt;은 루프가 끝난 뒤 한 번만 호출해야 한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;for (ANBPlayerController* PlayerController
     : AllPlayerControllers)
{
    if (IsValid(PlayerController))
    {
        PlayerController-&amp;gt;NotificationText =
            ResultNotification;
    }
}

ResetGame();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;전송 대상 수에 따라 게임 상태 전이 횟수가 달라지지 않게 만드는 것&lt;/b&gt;이 핵심이다. 방송은 N번이어도 판정과 리셋은 한 번이어야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPC와 Property Replication의 역할 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 야구의 한 라운드에는 RPC와 Property Replication이 함께 사용된다.&lt;/p&gt;
&lt;table style=&quot;height: 147px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 데이터 또는 동작 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 방식 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 이유 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;클라이언트의 문자열 입력&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Server RPC&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;클라이언트의 의도를 서버에 전달&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;판정 결과 채팅&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Client RPC&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;현재 접속자에게 즉시 보여줄 일회성 사건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;플레이어 이름&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;PlayerState Property Replication&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;모든 참가자가 계속 알아야 하는 공개 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;현재&amp;middot;최대 시도 횟수&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;PlayerState Property Replication&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;현재 게임 진행 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;승리&amp;middot;무승부 공지&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;PlayerController Property Replication&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;소유자 UI가 읽을 현재 공지 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;비밀 숫자&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복제하지 않음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버만 알아야 하는 권한 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 RPC는 &lt;b&gt;요청과 사건의 이동&lt;/b&gt;, Property Replication은 &lt;b&gt;현재 상태의 수렴&lt;/b&gt;을 담당한다. 클라이언트가 잠시 패킷을 놓치더라도 다음 프로퍼티 업데이트에서 현재 시도 횟수로 수렴할 수 있고, 늦게 들어온 클라이언트도 복제된 현재 플레이어 상태를 받을 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 권한을 끝까지 유지하는 입력 프로토콜&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 &lt;code&gt;PlayerController::SetChatMessageString()&lt;/code&gt;은 클라이언트의 &lt;code&gt;PlayerState&lt;/code&gt;에서 이름과 시도 횟수를 읽어 표시 문자열을 만든 뒤 Server RPC로 전송한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;const FString CombinedMessageString =
    PlayerState-&amp;gt;GetPlayerInfoString() +
    TEXT(&quot;: &quot;) +
    InChatMessageString;

ServerRPCPrintChatMessageString(CombinedMessageString);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습용 흐름은 확인할 수 있지만, 서버 권한 관점에서는 클라이언트가 플레이어 이름과 횟수가 포함된 문자열을 조작할 수 있다. 서버는 이미 RPC를 호출한 &lt;code&gt;PlayerController&lt;/code&gt;를 알고 있으므로 클라이언트가 보낸 표시용 접두사를 신뢰할 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 단단한 프로토콜은 클라이언트가 원본 입력만 보내고 서버가 표시 문자열을 조립하는 방식이다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;// Client -&amp;gt; Server
ServerRPCSubmitInput(RawInput);

// Server
ANBPlayerState* PlayerState =
    GetPlayerState&amp;lt;ANBPlayerState&amp;gt;();

const FString DisplayMessage =
    PlayerState-&amp;gt;GetPlayerInfoString() +
    TEXT(&quot;: &quot;) +
    RawInput;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 입력을 받을 때 함께 확인해야 할 항목은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RPC 호출자가 해당 &lt;code&gt;PlayerController&lt;/code&gt;의 실제 소유자인가&lt;/li&gt;
&lt;li&gt;입력 길이가 서버가 허용하는 최대값 이내인가&lt;/li&gt;
&lt;li&gt;숫자 입력이 세 자리, 1~9, 중복 없음 조건을 만족하는가&lt;/li&gt;
&lt;li&gt;해당 플레이어에게 시도 횟수가 남아 있는가&lt;/li&gt;
&lt;li&gt;너무 짧은 간격으로 반복 호출하고 있지 않은가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Reliable RPC는 전달 신뢰성을 높일 뿐 입력의 진위를 보장하지 않는다. 게임 규칙을 지키는 검증은 항상 서버 로직의 책임이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;데이터 소유권으로 정리한 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 야구 게임의 네트워크 구조는 데이터마다 하나의 권한 소유자를 두는 방식으로 정리할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GameMode (Server Only)
├─ SecretNumberString
├─ 입력 유효성 검사
├─ Strike / Ball 계산
├─ 승리 / 무승부 판정
└─ 라운드 리셋

PlayerState (Server Authority -&amp;gt; All Clients)
├─ PlayerNameString
├─ CurrentGuessCount
└─ MaxGuessCount

PlayerController (Server &amp;lt;-&amp;gt; Owning Client)
├─ Server RPC 입력 경로
├─ Client RPC 메시지 경로
└─ NotificationText&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 전용 정보는 &lt;code&gt;GameMode&lt;/code&gt;에 남기고, 공개된 플레이어 상태는 &lt;code&gt;PlayerState&lt;/code&gt;로 복제하며, 소유자와의 통신은 &lt;code&gt;PlayerController&lt;/code&gt;를 통한다. 이 경계를 유지하면 클라이언트가 입력을 보내더라도 최종 판정과 상태 변경은 항상 서버에서 일어나고, 각 클라이언트는 서버가 확정한 결과만 표현하게 된다.&lt;/p&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/68</guid>
      <comments>https://uegamedev.tistory.com/68#entry68comment</comments>
      <pubDate>Mon, 3 Aug 2026 19:16:19 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal 9기] 2026-07-31 UE 넷모드, 넷드라이브, 리플리케이션</title>
      <link>https://uegamedev.tistory.com/67</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;언리얼 멀티플레이의 실행 위치와 동기화&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 멀티플레이 코드를 작성할 때 가장 헷갈리는 점은 &lt;b&gt;같은 클래스와 같은 함수가 여러 컴퓨터에 각각 존재하고 실행될 수 있다는 것&lt;/b&gt;이다. 싱글플레이에서는 함수를 호출한 프로세스에서 그대로 실행하면 끝이지만, 멀티플레이에서는 다음 질문을 먼저 해결해야 한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;지금 코드는 서버 프로세스에서 실행 중인가, 클라이언트 프로세스에서 실행 중인가?&lt;/li&gt;
&lt;li&gt;지금 보고 있는 액터는 원본 Authority인가, 복제된 Proxy인가?&lt;/li&gt;
&lt;li&gt;이 액터는 어느 연결의 소유이며, RPC를 어느 컴퓨터로 보낼 수 있는가?&lt;/li&gt;
&lt;li&gt;전달하려는 것이 일회성 사건인가, 계속 유지되어야 하는 상태인가?&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 네 질문은 각각 &lt;code&gt;NetMode&lt;/code&gt;, &lt;code&gt;NetRole&lt;/code&gt;, &lt;code&gt;Ownership&lt;/code&gt;, RPC와 Property Replication으로 이어진다. 숫자 야구 프로젝트에서는 이 개념을 이용해 멀티플레이 채팅과 플레이어 접속 알림을 구현했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NetMode: 현재 월드가 맡은 네트워크 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NetMode&lt;/code&gt;는 개별 액터가 아니라 &lt;b&gt;현재 &lt;code&gt;UWorld&lt;/code&gt;가 실행되는 프로세스의 네트워크 역할&lt;/b&gt;을 나타낸다.&lt;/p&gt;
&lt;table style=&quot;height: 108px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; NetMode &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; 의미 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; 로컬 플레이어 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; 원격 연결 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;NM_Standalone&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;네트워크 연결이 없는 단독 실행&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot; align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot; align=&quot;right&quot;&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;NM_ListenServer&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버이면서 직접 플레이에도 참여&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot; align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot; align=&quot;right&quot;&gt;클라이언트 연결을 받음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;NM_DedicatedServer&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;플레이하지 않고 게임 상태만 관리하는 전용 서버&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot; align=&quot;right&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot; align=&quot;right&quot;&gt;클라이언트 연결을 받음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;NM_Client&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;원격 서버에 접속한 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot; align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot; align=&quot;right&quot;&gt;서버 연결 하나를 가짐&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 게임의 핵심 판정은 서버에서 수행해야 한다. 클라이언트가 최종 데미지, 점수, 정답 여부 같은 값을 결정하게 두면 조작된 입력을 그대로 신뢰하게 되기 때문이다. 클라이언트는 의도를 서버에 요청하고, 서버가 규칙을 검증한 뒤 결과를 확정하는 구조가 기본이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 &lt;code&gt;NetMode&lt;/code&gt;는 월드 단위의 정보이므로, 액터 하나의 권한을 판단할 때마다 무조건 &lt;code&gt;GetNetMode()&lt;/code&gt;를 사용할 필요는 없다. 용도를 나누면 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로세스가 클라이언트인지, 리슨 서버인지, 데디케이티드 서버인지 구분: &lt;code&gt;GetNetMode()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;해당 액터 인스턴스가 권한을 가졌는지 확인: &lt;code&gt;HasAuthority()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;해당 컨트롤러가 이 컴퓨터의 로컬 플레이어 것인지 확인: &lt;code&gt;IsLocalController()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;해당 폰이 로컬 입력으로 조종되는지 확인: &lt;code&gt;IsLocallyControlled()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 &lt;code&gt;NumberBaseballFunctionLibrary&lt;/code&gt;는 로그 앞에 실행 위치를 붙일 수 있도록 &lt;code&gt;GetNetMode()&lt;/code&gt;를 문자열로 변환한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;static FString GetNetModeString(const AActor* InWorldContextActor)
{
    FString NetModeString = TEXT(&quot;None&quot;);

    if (IsValid(InWorldContextActor))
    {
        const ENetMode NetMode = InWorldContextActor-&amp;gt;GetNetMode();

        if (NetMode == NM_Client)
        {
            NetModeString = TEXT(&quot;Client&quot;);
        }
        else if (NetMode == NM_Standalone)
        {
            NetModeString = TEXT(&quot;StandAlone&quot;);
        }
        else
        {
            NetModeString = TEXT(&quot;Server&quot;);
        }
    }

    return NetModeString;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 로그는 같은 함수가 여러 PIE 인스턴스에서 호출될 때 특히 유용하다. 함수명만 출력하면 어느 실행 주체의 로그인지 알기 어렵지만, &lt;code&gt;Client&lt;/code&gt;, &lt;code&gt;Server&lt;/code&gt; 같은 문맥을 함께 남기면 RPC 경로를 역추적하기 쉬워진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NetDriver와 NetConnection: 통신이 지나가는 실제 경로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;UNetDriver&lt;/code&gt;는 월드의 네트워크 통신을 관리하고, &lt;code&gt;UNetConnection&lt;/code&gt;은 통신 상대 하나와의 연결을 표현한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버와 클라이언트의 연결 구조는 서로 다르다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Server NetDriver
└─ ClientConnections
   ├─ ClientConnection A
   ├─ ClientConnection B
   └─ ClientConnection C

Client NetDriver
└─ ServerConnection&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버의 &lt;code&gt;UNetDriver&lt;/code&gt;는 접속한 클라이언트 수만큼 &lt;code&gt;ClientConnections&lt;/code&gt;를 관리한다.&lt;/li&gt;
&lt;li&gt;클라이언트의 &lt;code&gt;UNetDriver&lt;/code&gt;는 자신이 접속한 서버를 가리키는 &lt;code&gt;ServerConnection&lt;/code&gt; 하나를 관리한다.&lt;/li&gt;
&lt;li&gt;Standalone 월드에는 네트워크 통신이 필요하지 않으므로 일반적으로 게임용 &lt;code&gt;UNetDriver&lt;/code&gt;가 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 &lt;code&gt;NetMode&lt;/code&gt; 판정과도 연결된다. 클라이언트의 드라이버에는 &lt;code&gt;ServerConnection&lt;/code&gt;이 있지만 서버의 드라이버에는 없기 때문에, 엔진은 이 차이를 이용해 서버와 클라이언트를 구분할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Ownership: RPC의 목적지를 결정하는 소유 관계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC에서 말하는 소유권은 단순히 객체의 수명 관리자가 누구인지 나타내는 개념이 아니다. &lt;b&gt;어느 &lt;code&gt;UNetConnection&lt;/code&gt;으로 RPC를 전달해야 하는지 찾는 네트워크 경로&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 소유 관계는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;ClientConnection
└─ PlayerController
   └─ Pawn
      └─ Weapon Actor&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 각 &lt;code&gt;ClientConnection&lt;/code&gt;은 그 클라이언트의 &lt;code&gt;PlayerController&lt;/code&gt;와 연결된다. 폰의 Owner가 해당 컨트롤러이고 무기 액터의 Owner가 폰이라면, 무기에서도 Owner 체인을 따라 자신의 Owning Connection을 찾을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 관계가 중요한 이유는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 &lt;code&gt;Server RPC&lt;/code&gt;를 호출하려면 일반적으로 그 클라이언트가 소유한 replicated actor에서 호출해야 한다.&lt;/li&gt;
&lt;li&gt;서버가 &lt;code&gt;Client RPC&lt;/code&gt;를 호출하면 해당 액터의 Owning Connection을 따라 소유 클라이언트로 전달된다.&lt;/li&gt;
&lt;li&gt;소유권이 없는 서버 액터에서 호출한 &lt;code&gt;Client RPC&lt;/code&gt;는 보낼 대상 클라이언트를 결정할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PlayerController&lt;/code&gt;는 이 구조를 이해하기 좋은 액터다. 서버에는 모든 플레이어의 &lt;code&gt;PlayerController&lt;/code&gt;가 있지만, 각 클라이언트에는 기본적으로 자신의 &lt;code&gt;PlayerController&lt;/code&gt;만 존재한다. 따라서 서버가 특정 서버 측 &lt;code&gt;PlayerController&lt;/code&gt;에서 Client RPC를 호출하면 그 컨트롤러의 소유 클라이언트에 정확히 도달한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NetRole: 액터 인스턴스의 권한과 복제 형태&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NetMode&lt;/code&gt;가 월드의 역할이라면 &lt;code&gt;NetRole&lt;/code&gt;은 &lt;b&gt;현재 컴퓨터에 존재하는 액터 인스턴스의 역할&lt;/b&gt;이다.&lt;/p&gt;
&lt;table style=&quot;height: 171px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; text-align: center;&quot;&gt;&lt;b&gt; Local Role &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; text-align: center;&quot;&gt;&lt;b&gt; 의미 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; text-align: center;&quot;&gt;&lt;b&gt; 일반적인 예 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;ROLE_Authority&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;해당 액터의 권한을 가진 인스턴스&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버에 스폰된 replicated actor의 서버 원본&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;&lt;code&gt;ROLE_AutonomousProxy&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;서버에서 복제됐지만 로컬 입력을 서버로 보낼 수 있는 프록시&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;자신이 조종하는 Pawn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;ROLE_SimulatedProxy&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버 상태를 받아 표현하는 프록시&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;다른 플레이어의 Pawn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;&lt;code&gt;ROLE_None&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;반대편에 복제 대상이 없거나 네트워크 역할이 없음&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;서버에만 존재하는 비복제 액터의 반대편 역할&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Local Role은 &lt;b&gt;현재 컴퓨터 기준의 역할&lt;/b&gt;, Remote Role은 &lt;b&gt;연결 반대편에서 기대되는 역할&lt;/b&gt;이다. 예를 들어 클라이언트가 조종하는 Pawn은 서버에서 Local Role이 &lt;code&gt;Authority&lt;/code&gt;이고 Remote Role이 &lt;code&gt;AutonomousProxy&lt;/code&gt;가 될 수 있다. 같은 Pawn의 소유 클라이언트 복제본은 Local Role이 &lt;code&gt;AutonomousProxy&lt;/code&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;HasAuthority()&lt;/code&gt;는 내부적으로 Local Role이 &lt;code&gt;ROLE_Authority&lt;/code&gt;인지 확인한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;bool AActor::HasAuthority() const
{
    return GetLocalRole() == ROLE_Authority;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주의할 점은 &lt;code&gt;HasAuthority()&lt;/code&gt;가 문자 그대로 &amp;ldquo;이 프로세스는 서버다&amp;rdquo;를 검사하는 함수는 아니라는 것이다. 클라이언트가 로컬에서만 생성한 비복제 액터도 Authority를 가질 수 있다. 일반적인 &lt;b&gt;서버 스폰 replicated gameplay actor&lt;/b&gt;에서는 서버 원본을 확인하는 용도로 사용할 수 있지만, 프로세스 자체를 구분하려면 &lt;code&gt;NetMode&lt;/code&gt;가 더 명확하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;액터 종류에 따라 존재하는 컴퓨터가 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC를 설계하려면 먼저 해당 액터가 어느 컴퓨터에 존재하는지 확인해야 한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;객체&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;서버&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;소유 클라이언트&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;다른 클라이언트&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GameMode&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GameState&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlayerController&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;replicated &lt;code&gt;Pawn&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로컬 UI&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이는 접속 알림 구현에서 중요하다. &lt;code&gt;GameMode&lt;/code&gt;는 플레이어 접속을 가장 먼저 알 수 있지만 클라이언트에는 존재하지 않는다. 반대로 &lt;code&gt;GameState&lt;/code&gt;는 서버와 모든 클라이언트에 존재한다. 따라서 서버 전용 &lt;code&gt;GameMode&lt;/code&gt;가 접속 이벤트를 감지하고, 모든 클라이언트에 존재하는 &lt;code&gt;GameState&lt;/code&gt;를 통해 알림을 전달하는 구조가 자연스럽다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RPC: 다른 컴퓨터에서 함수를 실행시키는 요청&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC는 함수를 호출한 컴퓨터와 실제 구현부가 실행되는 컴퓨터를 분리한다. 언리얼에서는 &lt;code&gt;UFUNCTION()&lt;/code&gt;에 실행 방향과 신뢰성 지정자를 붙인다.&lt;/p&gt;
&lt;table style=&quot;height: 168px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 42px; width: 229px;&quot;&gt;&lt;b&gt; 선언 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 42px; width: 133px;&quot;&gt;&lt;b&gt; 일반적인 호출 위치 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 42px; width: 256px;&quot;&gt;&lt;b&gt; 실행 위치 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 42px; width: 233px;&quot;&gt;&lt;b&gt; 핵심 조건 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px; width: 229px;&quot;&gt;&lt;code&gt;UFUNCTION(Server, ...)&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 133px;&quot;&gt;소유 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 256px;&quot;&gt;서버&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 233px;&quot;&gt;호출 클라이언트가 액터를 소유해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px; width: 229px;&quot;&gt;&lt;code&gt;UFUNCTION(Client, ...)&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 133px;&quot;&gt;서버&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 256px;&quot;&gt;액터의 소유 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 233px;&quot;&gt;유효한 Owning Connection이 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px; width: 229px;&quot;&gt;&lt;code&gt;UFUNCTION(NetMulticast, ...)&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 133px;&quot;&gt;서버&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 256px;&quot;&gt;서버와 해당 액터가 복제된 관련 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 233px;&quot;&gt;서버에서 호출해야 네트워크로 전파됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Server&lt;/code&gt;, &lt;code&gt;Client&lt;/code&gt;, &lt;code&gt;NetMulticast&lt;/code&gt;는 &amp;ldquo;반드시 저 위치에서 실행된다&amp;rdquo;는 단순한 함수 수식어가 아니다. 액터의 복제 여부, 호출 주체, 소유 관계, relevancy 조건이 맞을 때 지정된 원격 실행이 이루어진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Reliable과 Unreliable&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Reliable&lt;/code&gt; RPC는 연결이 유지되는 동안 전달을 재시도하며 순서를 보장하는 대신, 유실된 패킷이 처리될 때까지 뒤의 Reliable RPC가 영향을 받을 수 있다. 따라서 모든 RPC를 Reliable로 만드는 것은 안전한 선택이 아니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Reliable&lt;/code&gt;: 채팅 전송, 확정된 상호작용 요청처럼 누락되면 기능이 깨지는 낮은 빈도의 이벤트&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Unreliable&lt;/code&gt;: 짧은 간격으로 반복되고 일부 누락을 허용할 수 있는 코스메틱 이벤트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Reliable은 보안 검증을 대신하지 않는다. 클라이언트가 보낸 문자열 길이, 호출 빈도, 게임 규칙상 가능한 요청인지 여부는 서버가 별도로 검사해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트의 멀티플레이 채팅 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 야구 프로젝트의 채팅은 클라이언트끼리 직접 통신하지 않는다. 모든 메시지가 서버를 경유한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;sequenceDiagram
    participant UI as &quot;로컬 채팅 UI&quot;
    participant CPC as &quot;소유 클라이언트의 PlayerController&quot;
    participant SPC as &quot;서버의 PlayerController&quot;
    participant OPC as &quot;각 클라이언트의 PlayerController&quot;

    UI-&amp;gt;&amp;gt;CPC: Enter 입력 / SetChatMessageString
    CPC-&amp;gt;&amp;gt;SPC: ServerRPCPrintChatMessageString
    Note over SPC: 서버가 모든 PlayerController를 순회
    loop 접속한 플레이어마다
        SPC-&amp;gt;&amp;gt;OPC: ClientRPCPrintChatMessageString
        OPC-&amp;gt;&amp;gt;OPC: 화면에 메시지 출력
    end&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC 선언은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void ClientRPCPrintChatMessageString(const FString&amp;amp; InChatMessageString);

UFUNCTION(Server, Reliable)
void ServerRPCPrintChatMessageString(const FString&amp;amp; InChatMessageString);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI에서 Enter가 입력되면 &lt;code&gt;GetOwningPlayer()&lt;/code&gt;로 로컬 &lt;code&gt;PlayerController&lt;/code&gt;를 얻고 &lt;code&gt;SetChatMessageString()&lt;/code&gt;을 호출한다. 여기서 &lt;code&gt;IsLocalController()&lt;/code&gt; 검사는 서버에 존재하는 다른 플레이어의 컨트롤러 복제 문맥이나 비로컬 컨트롤러에서 UI 입력 경로가 실행되는 것을 막는다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;void ANBPlayerController::SetChatMessageString(
    const FString&amp;amp; InChatMessageString)
{
    ChatMessageString = InChatMessageString;

    if (IsLocalController())
    {
        ServerRPCPrintChatMessageString(InChatMessageString);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 자신이 보유한 모든 &lt;code&gt;PlayerController&lt;/code&gt;를 순회한다. 각 서버 측 &lt;code&gt;PlayerController&lt;/code&gt;에서 Client RPC를 호출하면 서로 다른 Owning Connection을 따라 각 클라이언트로 메시지가 전달된다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;void ANBPlayerController::ServerRPCPrintChatMessageString_Implementation(
    const FString&amp;amp; InChatMessageString)
{
    for (TActorIterator&amp;lt;ANBPlayerController&amp;gt; It(GetWorld()); It; ++It)
    {
        ANBPlayerController* PlayerController = *It;

        if (IsValid(PlayerController))
        {
            PlayerController-&amp;gt;ClientRPCPrintChatMessageString(
                InChatMessageString);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PlayerController에서 NetMulticast를 쓰지 않은 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &amp;ldquo;모든 클라이언트에 보내니 NetMulticast가 더 간단하지 않을까?&amp;rdquo;라고 생각할 수 있다. 하지만 각 클라이언트에는 자신의 &lt;code&gt;PlayerController&lt;/code&gt;만 존재한다. 한 플레이어의 &lt;code&gt;PlayerController&lt;/code&gt;에서 Multicast RPC를 호출해도 다른 클라이언트에는 그 액터 자체가 없으므로 전체 채팅 방송 수단이 되지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구현처럼 서버가 모든 서버 측 &lt;code&gt;PlayerController&lt;/code&gt;를 순회하고 각각 Client RPC를 보내면 다음 장점이 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 메시지의 목적지가 Owning Connection으로 명확하다.&lt;/li&gt;
&lt;li&gt;특정 플레이어만 대상으로 하는 귓속말이나 시스템 메시지로 확장하기 쉽다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PlayerController&lt;/code&gt;가 모든 클라이언트에 존재한다고 잘못 가정하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 중요한 점은 &lt;code&gt;ChatMessageString&lt;/code&gt; 멤버 자체는 replicated property가 아니라는 것이다. 이 값에 문자열을 대입했다고 다른 컴퓨터로 전달되는 것이 아니다. 실제 네트워크 전송은 Server RPC와 Client RPC의 매개변수를 통해 이루어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 채팅을 &lt;b&gt;일회성 이벤트&lt;/b&gt;로 다룬다. 접속 중인 클라이언트는 메시지를 받지만, 나중에 접속한 클라이언트가 이전 메시지를 자동으로 복원하지는 않는다. 채팅 기록까지 상태로 유지하려면 서버가 별도의 기록을 소유하고 필요한 범위만 동기화해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;접속 알림: GameMode에서 감지하고 GameState에서 전파하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어가 접속하면 서버의 &lt;code&gt;GameMode::OnPostLogin()&lt;/code&gt;이 호출된다. &lt;code&gt;GameMode&lt;/code&gt;는 서버에만 있으므로 이벤트 감지에는 적합하지만, 그 자체에서 클라이언트 RPC를 실행할 수 있는 복제본은 클라이언트에 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에서는 서버와 클라이언트 모두에 존재하는 &lt;code&gt;GameState&lt;/code&gt;로 역할을 넘긴다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ANBGameModeBase::OnPostLogin(AController* NewPlayer)
{
    Super::OnPostLogin(NewPlayer);

    ANBGameStateBase* GameState = GetGameState&amp;lt;ANBGameStateBase&amp;gt;();

    if (IsValid(GameState))
    {
        GameState-&amp;gt;MulticastRPCBroadcastLoginMessage(TEXT(&quot;XXXXXXX&quot;));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GameState&lt;/code&gt;의 NetMulticast RPC는 서버와 관련 클라이언트에서 실행된다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(NetMulticast, Reliable)
void MulticastRPCBroadcastLoginMessage(
    const FString&amp;amp; InNameString = FString(TEXT(&quot;XXXXXXX&quot;)));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 구현부는 &lt;code&gt;HasAuthority() == false&lt;/code&gt;인 경우에만 로컬 &lt;code&gt;PlayerController&lt;/code&gt;를 찾아 화면에 출력한다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;void ANBGameStateBase::MulticastRPCBroadcastLoginMessage_Implementation(
    const FString&amp;amp; InNameString)
{
    if (!HasAuthority())
    {
        APlayerController* PC =
            UGameplayStatics::GetPlayerController(GetWorld(), 0);

        if (ANBPlayerController* NBPC = Cast&amp;lt;ANBPlayerController&amp;gt;(PC))
        {
            const FString Notification =
                InNameString + TEXT(&quot; has joined the game.&quot;);
            NBPC-&amp;gt;PrintChatMessageString(Notification);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Multicast 구현부는 서버에서도 실행되지만, 실제 화면 UI는 로컬 클라이언트의 관심사다. Authority 검사는 서버 쪽 출력 경로를 제외하고 클라이언트에서만 알림을 표시하도록 만든다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Property Replication: 사건이 아니라 상태를 동기화하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC가 &amp;ldquo;지금 이 함수를 실행하라&amp;rdquo;는 사건 전달이라면, Property Replication은 &amp;ldquo;현재 이 값이 무엇인지&amp;rdquo;를 동기화한다.&lt;/p&gt;
&lt;table style=&quot;height: 129px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; 구분 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; RPC &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt; Property Replication &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;표현 대상&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;일회성 이벤트, 명령, 요청&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;지속되어야 하는 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;전송 형태&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;함수 호출과 매개변수&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버에서 변경된 프로퍼티 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;늦게 접속한 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;과거 호출을 받지 못함&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;현재 상태를 초기 복제로 받을 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;대표 예&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;채팅 알림, 이펙트 재생, 상호작용 요청&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;점수, 남은 기회, 라운드 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;기본 방향&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;RPC 종류와 Ownership에 따라 결정&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버 Authority에서 클라이언트로&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 야구로 예를 들면 &amp;ldquo;정답 제출 버튼을 눌렀다&amp;rdquo;는 Server RPC 요청이고, 서버가 판정한 &amp;ldquo;남은 기회가 7회다&amp;rdquo;는 replicated state에 가깝다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 replicated actor에서 프로퍼티를 동기화하려면 세 가지가 필요하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;액터의 &lt;code&gt;bReplicates&lt;/code&gt;를 &lt;code&gt;true&lt;/code&gt;로 설정한다.&lt;/li&gt;
&lt;li&gt;동기화할 프로퍼티에 &lt;code&gt;Replicated&lt;/code&gt; 또는 &lt;code&gt;ReplicatedUsing&lt;/code&gt;을 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GetLifetimeReplicatedProps()&lt;/code&gt;에서 &lt;code&gt;DOREPLIFETIME&lt;/code&gt;으로 등록한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// NBGameStateBase.h

UCLASS()
class NUMBERBASEBALL_API ANBGameStateBase : public AGameStateBase
{
    GENERATED_BODY()

public:
    virtual void GetLifetimeReplicatedProps(
        TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps) const override;

protected:
    UPROPERTY(ReplicatedUsing = OnRep_RemainingAttempts)
    int32 RemainingAttempts = 0;

    UFUNCTION()
    void OnRep_RemainingAttempts();
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;// NBGameStateBase.cpp

#include &quot;Net/UnrealNetwork.h&quot;

void ANBGameStateBase::GetLifetimeReplicatedProps(
    TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ANBGameStateBase, RemainingAttempts);
}

void ANBGameStateBase::OnRep_RemainingAttempts()
{
    // 클라이언트 UI 갱신 등, 값 변경에 대한 반응을 처리한다.
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AGameStateBase&lt;/code&gt;는 원래 복제를 전제로 한 프레임워크 액터다. 일반 &lt;code&gt;AActor&lt;/code&gt; 기반 클래스로 같은 패턴을 구현할 때는 생성자에서 &lt;code&gt;bReplicates = true&lt;/code&gt; 설정까지 직접 확인해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ReplicatedUsing&lt;/code&gt;의 &lt;code&gt;OnRep&lt;/code&gt; 함수는 클라이언트가 새 값을 받은 뒤 반응해야 할 때 유용하다. 단순히 숫자를 동기화하는 것과 그 숫자에 맞춰 UI를 갱신하는 책임을 분리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 프로퍼티를 직접 변경했을 때 서버 자신의 &lt;code&gt;OnRep&lt;/code&gt;가 클라이언트와 똑같이 자동 호출된다고 가정해서는 안 된다. 서버에서도 같은 후처리가 필요하다면 공통 처리 함수를 두고 서버 변경 경로와 &lt;code&gt;OnRep&lt;/code&gt; 양쪽에서 호출하는 편이 명확하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;클라이언트의 값 변경은 서버 요청이 아니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 로컬 복제본의 replicated property를 직접 바꿔도 그 값이 서버로 역복제되지 않는다. 서버-클라이언트 모델에서 프로퍼티 복제의 기본 방향은 서버에서 클라이언트다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 상태 변경은 보통 다음 경로를 따른다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;flowchart LR
    A[&quot;클라이언트 입력&quot;] --&amp;gt; B[&quot;Server RPC 요청&quot;]
    B --&amp;gt; C[&quot;서버가 요청 검증&quot;]
    C --&amp;gt; D[&quot;서버 Authority가 상태 변경&quot;]
    D --&amp;gt; E[&quot;Property Replication&quot;]
    E --&amp;gt; F[&quot;클라이언트 OnRep / UI 반영&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름은 서버 권한 모델의 핵심이다. 클라이언트는 결과값을 선언하지 않고 의도를 전송한다. 서버는 유효성 검사와 게임 규칙을 적용해 상태를 변경하고, 확정된 상태만 각 클라이언트에 복제한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이벤트와 상태를 구분하는 설계 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC와 Property Replication 중 무엇을 사용할지 애매하다면 &amp;ldquo;나중에 접속한 플레이어도 이것을 알아야 하는가?&amp;rdquo;라고 질문하면 도움이 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;폭발 이펙트를 지금 재생해야 한다: 일회성 사건이므로 RPC가 자연스럽다.&lt;/li&gt;
&lt;li&gt;문이 현재 열려 있다: 지속 상태이므로 replicated property가 자연스럽다.&lt;/li&gt;
&lt;li&gt;플레이어가 숫자를 제출했다: 클라이언트의 의도이므로 Server RPC가 자연스럽다.&lt;/li&gt;
&lt;li&gt;판정 결과와 남은 시도 횟수가 현재 얼마다: 서버가 확정한 상태이므로 Property Replication이 자연스럽다.&lt;/li&gt;
&lt;li&gt;접속한 플레이어에게 즉시 알림을 띄운다: 접속 시점의 사건이므로 RPC가 자연스럽다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실전에서는 두 방식을 함께 사용한다. Server RPC가 상태 변경을 요청하고, 서버가 값을 바꾼 뒤 Property Replication이 최종 상태를 배포하는 조합이 가장 흔하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;네트워크 실행 경로 설계 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 로직을 작성할 때는 함수부터 만들기보다 실행 주체와 데이터 방향을 먼저 결정해야 한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;NetMode&lt;/code&gt;로 현재 월드가 서버인지 클라이언트인지 구분한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NetRole&lt;/code&gt;로 현재 액터가 Authority인지 Proxy인지 구분한다.&lt;/li&gt;
&lt;li&gt;Actor Ownership으로 Server/Client RPC가 이동할 연결을 확인한다.&lt;/li&gt;
&lt;li&gt;일회성 사건은 RPC, 유지되어야 하는 상태는 Property Replication으로 표현한다.&lt;/li&gt;
&lt;li&gt;클라이언트는 의도를 요청하고, 최종 게임 상태는 서버가 결정한다.&lt;/li&gt;
&lt;li&gt;Reliable 여부와 별개로 서버는 입력값과 호출 빈도를 검증한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 야구 프로젝트의 채팅은 이 규칙을 작게 검증한 사례다. UI 입력은 소유 &lt;code&gt;PlayerController&lt;/code&gt;를 통해 Server RPC로 올라가고, 서버는 각 컨트롤러의 Owning Connection을 이용해 Client RPC를 내려보낸다. 접속 알림은 서버 전용 &lt;code&gt;GameMode&lt;/code&gt;가 감지하고, 모든 컴퓨터에 존재하는 &lt;code&gt;GameState&lt;/code&gt;가 Multicast로 전파한다. 게임 진행 상태까지 다룰 때는 Property Replication으로 현재 값을 배포해, 이벤트 전달과 상태 동기화의 책임을 분리하는 구조를 사용한다.&lt;/p&gt;</description>
      <category>UE/멀티플레이</category>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/67</guid>
      <comments>https://uegamedev.tistory.com/67#entry67comment</comments>
      <pubDate>Fri, 31 Jul 2026 18:48:13 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal 9기] 2026-07-30 UE멀티플레이 채팅</title>
      <link>https://uegamedev.tistory.com/66</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Unreal Engine 멀티플레이 채팅&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal Engine에서 채팅 입력창을 만드는 일은 어렵지 않다. &lt;code&gt;EditableTextBox&lt;/code&gt;에 입력된 문자열을 받아 출력하면 로컬 기능은 완성된다. 하지만 같은 기능을 멀티플레이 환경에 올리면 전혀 다른 문제가 생긴다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위젯은 서버와 모든 클라이언트 중 어디에 생성해야 하는가?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PlayerController&lt;/code&gt;는 어느 머신에 존재하는가?&lt;/li&gt;
&lt;li&gt;다른 클라이언트 화면에도 문자열이 출력되었다면 정말 네트워크 통신에 성공한 것인가?&lt;/li&gt;
&lt;li&gt;에디터의 PIE(Play In Editor) 설정은 테스트 결과에 어떤 영향을 주는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 구현 전에 서버 구조부터 구분하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 게임의 실행 구조는 크게 다음 세 가지로 나눌 수 있다.&lt;/p&gt;
&lt;table style=&quot;height: 150px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 구조 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 서버 역할 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 장점 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 주요 제약 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;P2P&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;각 피어가 클라이언트이자 서버&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;별도 서버 비용이 적음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;연결 관리, 보안, 호스트 신뢰 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;Listen Server&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;플레이어 한 명이 호스트이자 클라이언트&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;구현과 소규모 매치 구성이 간단함&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;호스트 이점, 호스트 종료 시 세션 유지 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;Dedicated Server&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;플레이에 참여하지 않는 별도 프로세스가 서버 역할&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;공정성, 권한 집중, 운영과 확장에 유리함&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;별도 빌드와 서버 운영 비용 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Listen Server의 호스트는 서버 권한과 로컬 플레이어를 동시에 가진다. 반면 Dedicated Server는 화면에 게임을 플레이하는 로컬 사용자가 없고, 오직 월드의 권위 있는 상태를 관리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채팅처럼 모든 참가자가 공유하는 기능을 설계할 때도 이 차이가 중요하다. 클라이언트가 자신의 화면에 문자열을 출력하는 것과, 서버가 메시지를 수신하고 다른 클라이언트에 전달하는 것은 서로 다른 단계이기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Dedicated Server에서 월드와 플레이어가 만들어지는 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dedicated Server의 실행 흐름을 단순화하면 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;서버 프로세스가 특정 레벨을 연다.&lt;/li&gt;
&lt;li&gt;외부 접속을 받을 수 있도록 소켓을 열고 대기한다.&lt;/li&gt;
&lt;li&gt;레벨의 &lt;code&gt;WorldSettings&lt;/code&gt;를 기준으로 &lt;code&gt;GameMode&lt;/code&gt;와 &lt;code&gt;GameState&lt;/code&gt;를 생성한다.&lt;/li&gt;
&lt;li&gt;클라이언트가 서버의 IP와 포트로 접속한다.&lt;/li&gt;
&lt;li&gt;클라이언트가 같은 레벨을 로드한다.&lt;/li&gt;
&lt;li&gt;서버가 접속한 플레이어에 필요한 &lt;code&gt;PlayerController&lt;/code&gt;, &lt;code&gt;PlayerState&lt;/code&gt;, Pawn 또는 Character를 생성한다.&lt;/li&gt;
&lt;li&gt;복제가 필요한 상태와 액터가 각 클라이언트에 전달된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 가장 중요한 기준은 &lt;b&gt;서버 권한(Authority)&lt;/b&gt; 과 &lt;b&gt;소유권(Ownership)&lt;/b&gt; 이다.&lt;/p&gt;
&lt;table style=&quot;height: 122px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; text-align: center;&quot;&gt;&lt;b&gt; 클래스 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; text-align: center;&quot;&gt;&lt;b&gt; 서버 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; text-align: center;&quot;&gt;&lt;b&gt; 해당 플레이어의 클라이언트 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; text-align: center;&quot;&gt;&lt;b&gt; 다른 클라이언트 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;GameMode&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;존재하지 않음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;존재하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;GameState&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;원본 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복제본 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복제본 존재&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;PlayerController&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;플레이어마다 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;자신이 소유한 컨트롤러 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;다른 플레이어의 컨트롤러는 일반적으로 존재하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;PlayerState&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;플레이어마다 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복제본 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복제본 존재&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Pawn/Character&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;권위 있는 액터 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;관련성에 따라 복제&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;관련성에 따라 복제&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GameMode&lt;/code&gt;가 서버에만 존재한다는 점은 서버 전용 규칙이나 판정을 배치하는 기준이 된다. 반대로 점수판처럼 모든 클라이언트가 읽어야 하는 상태는 &lt;code&gt;GameState&lt;/code&gt; 또는 &lt;code&gt;PlayerState&lt;/code&gt;가 더 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 일반적인 서버-클라이언트 모델에서 클라이언트끼리 직접 통신하지 않는다. 클라이언트가 보낸 요청은 서버가 받고, 서버의 처리 결과가 다시 필요한 클라이언트로 전달된다. 이후 RPC와 프로퍼티 리플리케이션을 설계할 때도 이 경로가 기본 전제가 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. C++ 기반 클래스와 Blueprint 설정을 분리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실습 프로젝트는 렌더링할 3D 장면이 필요하지 않으므로 빈 &lt;code&gt;Chatting&lt;/code&gt; 레벨을 사용한다. 이 레벨을 &lt;code&gt;Editor Startup Map&lt;/code&gt;과 &lt;code&gt;Game Default Map&lt;/code&gt;으로 지정하고, 다음과 같이 프레임워크 클래스를 구성한다.&lt;/p&gt;
&lt;pre class=&quot;monkey&quot;&gt;&lt;code&gt;ACXGameModeBase
└─ BP_GameModeBase
   └─ PlayerController Class: BP_PlayerController

ACXPlayerController
└─ BP_PlayerController
   └─ Chat Input Widget Class: WBP_ChatInput

UCXChatInput
└─ WBP_ChatInput
   └─ EditableTextBox_ChatInput&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 역할을 다음처럼 나눈다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;C++ 클래스는 위젯 생성, 이벤트 바인딩, 입력 처리 같은 핵심 로직을 담당한다.&lt;/li&gt;
&lt;li&gt;Blueprint 자식 클래스는 실제 위젯 에셋과 클래스 참조를 연결한다.&lt;/li&gt;
&lt;li&gt;레벨의 &lt;code&gt;WorldSettings&lt;/code&gt;는 사용할 &lt;code&gt;GameMode&lt;/code&gt;를 선택한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GameMode&lt;/code&gt;는 해당 게임에서 사용할 &lt;code&gt;PlayerController&lt;/code&gt; 클래스를 선택한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;TSubclassOf&amp;lt;UCXChatInput&amp;gt;&lt;/code&gt;를 &lt;code&gt;EditDefaultsOnly&lt;/code&gt;로 노출하면 C++ 코드가 특정 위젯 Blueprint의 경로를 직접 알 필요가 없다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;UCLASS()
class CHATX_API ACXPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    virtual void BeginPlay() override;

protected:
    UPROPERTY(EditDefaultsOnly, Category = &quot;Chat&quot;)
    TSubclassOf&amp;lt;UCXChatInput&amp;gt; ChatInputWidgetClass;

    UPROPERTY()
    TObjectPtr&amp;lt;UCXChatInput&amp;gt; ChatInputWidgetInstance;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;TSubclassOf&lt;/code&gt;는 지정 가능한 클래스를 &lt;code&gt;UCXChatInput&lt;/code&gt;의 자식으로 제한한다. 생성된 인스턴스는 &lt;code&gt;UPROPERTY&lt;/code&gt;가 붙은 &lt;code&gt;TObjectPtr&lt;/code&gt;로 보관해 Unreal의 객체 추적과 가비지 컬렉션 대상에서 안전하게 참조한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. UMG 위젯을 C++에 바인딩하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;UUserWidget&lt;/code&gt; 기반 클래스를 사용하려면 모듈 의존성에 UI 모듈을 추가해야 한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;PublicDependencyModuleNames.AddRange(new string[]
{
    &quot;Core&quot;,
    &quot;CoreUObject&quot;,
    &quot;Engine&quot;,
    &quot;InputCore&quot;,
    &quot;EnhancedInput&quot;,
    &quot;UMG&quot;,
    &quot;Slate&quot;,
    &quot;SlateCore&quot;
});&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;UMG&lt;/code&gt;는 &lt;code&gt;UUserWidget&lt;/code&gt;, &lt;code&gt;UEditableTextBox&lt;/code&gt; 같은 UMG API를 제공한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Slate&lt;/code&gt;와 &lt;code&gt;SlateCore&lt;/code&gt;는 입력 커밋 타입과 저수준 UI 시스템을 사용할 때 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위젯 클래스에서는 &lt;code&gt;BindWidget&lt;/code&gt;으로 Blueprint 위젯 트리의 요소를 연결한다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;class UEditableTextBox;

UCLASS()
class CHATX_API UCXChatInput : public UUserWidget
{
    GENERATED_BODY()

public:
    virtual void NativeConstruct() override;
    virtual void NativeDestruct() override;

protected:
    UFUNCTION()
    void OnChatInputTextCommitted(
        const FText&amp;amp; Text,
        ETextCommit::Type CommitMethod
    );

    UPROPERTY(meta = (BindWidget))
    TObjectPtr&amp;lt;UEditableTextBox&amp;gt; EditableTextBox_ChatInput;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;BindWidget&lt;/code&gt;은 단순한 이름 검색 편의 기능이 아니라 C++ 클래스와 Widget Blueprint 사이의 계약이다. &lt;code&gt;WBP_ChatInput&lt;/code&gt; 안에 &lt;b&gt;동일한 이름과 호환되는 타입&lt;/b&gt;의 위젯이 있어야 바인딩된다. Blueprint에서 위젯 이름을 변경하면 C++ 코드도 함께 수정해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. UI는 Owning Client에서만 생성해야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PlayerController::BeginPlay()&lt;/code&gt;가 실행되었다고 해서 그 인스턴스가 항상 실제 사용자의 로컬 컨트롤러인 것은 아니다. 서버에는 접속한 플레이어마다 &lt;code&gt;PlayerController&lt;/code&gt;가 존재하며, 클라이언트에는 자신이 소유한 로컬 컨트롤러가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 화면에 표시할 위젯은 &lt;code&gt;IsLocalController()&lt;/code&gt;로 로컬 소유 여부를 먼저 검사한 뒤 생성해야 한다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;void ACXPlayerController::BeginPlay()
{
    Super::BeginPlay();

    if (!IsLocalController())
    {
        return;
    }

    FInputModeUIOnly InputMode;
    SetInputMode(InputMode);

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

    ChatInputWidgetInstance =
        CreateWidget&amp;lt;UCXChatInput&amp;gt;(this, ChatInputWidgetClass);

    if (IsValid(ChatInputWidgetInstance))
    {
        ChatInputWidgetInstance-&amp;gt;AddToViewport();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;this&lt;/code&gt;를 Owning Player로 전달하기 때문에 생성된 위젯은 해당 로컬 &lt;code&gt;PlayerController&lt;/code&gt;와 연결된다. 서버의 원격 컨트롤러나 다른 플레이어의 컨트롤러에 같은 UI를 만들 필요는 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 검사가 없으면 각 네트워크 인스턴스의 &lt;code&gt;BeginPlay()&lt;/code&gt;가 위젯 생성을 시도할 수 있다. 전용 서버에는 표시할 화면 자체가 없으며, 다른 플레이어의 UI는 각자의 로컬 클라이언트가 책임져야 한다. &lt;code&gt;IsLocalController()&lt;/code&gt;는 게임플레이 상태의 복제와 로컬 표현 계층을 분리하는 경계가 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 위젯 생명주기에 맞춰 델리게이트 관리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채팅 입력은 &lt;code&gt;UEditableTextBox::OnTextCommitted&lt;/code&gt; 델리게이트를 통해 처리할 수 있다. Enter 키로 입력을 확정했을 때만 메시지를 넘기도록 커밋 방식을 검사한다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;void UCXChatInput::NativeConstruct()
{
    Super::NativeConstruct();

    if (IsValid(EditableTextBox_ChatInput) &amp;amp;&amp;amp;
        !EditableTextBox_ChatInput-&amp;gt;OnTextCommitted.IsAlreadyBound(
            this,
            &amp;amp;ThisClass::OnChatInputTextCommitted))
    {
        EditableTextBox_ChatInput-&amp;gt;OnTextCommitted.AddDynamic(
            this,
            &amp;amp;ThisClass::OnChatInputTextCommitted);
    }
}

void UCXChatInput::NativeDestruct()
{
    if (IsValid(EditableTextBox_ChatInput) &amp;amp;&amp;amp;
        EditableTextBox_ChatInput-&amp;gt;OnTextCommitted.IsAlreadyBound(
            this,
            &amp;amp;ThisClass::OnChatInputTextCommitted))
    {
        EditableTextBox_ChatInput-&amp;gt;OnTextCommitted.RemoveDynamic(
            this,
            &amp;amp;ThisClass::OnChatInputTextCommitted);
    }

    Super::NativeDestruct();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NativeConstruct()&lt;/code&gt;는 위젯 인스턴스의 생애 동안 한 번만 호출된다고 가정하면 안 된다. 위젯이 다시 구성되었을 때 같은 함수를 중복 바인딩하면 입력 한 번에 콜백이 여러 번 실행될 수 있다. 바인딩 여부를 확인하고, &lt;code&gt;NativeDestruct()&lt;/code&gt;에서 대칭적으로 해제하면 생명주기가 명확해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 콜백에서는 위젯이 소유한 &lt;code&gt;PlayerController&lt;/code&gt;를 얻어 로컬 메시지 처리 함수로 전달한다.&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;void UCXChatInput::OnChatInputTextCommitted(
    const FText&amp;amp; Text,
    ETextCommit::Type CommitMethod)
{
    if (CommitMethod != ETextCommit::OnEnter || Text.IsEmpty())
    {
        return;
    }

    ACXPlayerController* Controller =
        Cast&amp;lt;ACXPlayerController&amp;gt;(GetOwningPlayer());

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

    Controller-&amp;gt;SetChatMessageString(Text.ToString());
    EditableTextBox_ChatInput-&amp;gt;SetText(FText::GetEmpty());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 데이터 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;EditableTextBox
    &amp;rarr; OnTextCommitted
    &amp;rarr; Owning PlayerController
    &amp;rarr; SetChatMessageString()
    &amp;rarr; 로컬 PrintString()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계의 &lt;code&gt;FString ChatMessageString&lt;/code&gt;은 복제 프로퍼티가 아니고, &lt;code&gt;SetChatMessageString()&lt;/code&gt;도 RPC가 아니다. 따라서 여기까지는 &lt;b&gt;로컬 채팅 입력과 출력&lt;/b&gt;만 구현한 상태다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 다른 창에도 출력되었다고 네트워크 통신은 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PIE에서 여러 플레이어 창을 실행했을 때 &lt;code&gt;PrintString()&lt;/code&gt;이 모든 창에 보일 수 있다. 이 현상만 보고 메시지가 서버를 거쳐 복제되었다고 판단하면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Run Under One Process&lt;/code&gt;가 활성화되어 있으면 서버와 여러 클라이언트가 하나의 Unreal Editor 프로세스 안에서 실행된다. 이 환경에서는 에디터 디버그 출력과 일부 공유 상태 때문에 각 인스턴스가 완전히 분리된 것처럼 보이지 않을 수 있다. &lt;code&gt;PrintString()&lt;/code&gt;은 채팅 메시지의 네트워크 전송을 증명하는 도구가 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 기능을 검증할 때는 최소한 다음을 구분해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;함수가 어느 프로세스에서 호출되었는가?&lt;/li&gt;
&lt;li&gt;해당 액터의 &lt;code&gt;HasAuthority()&lt;/code&gt; 결과는 무엇인가?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GetLocalRole()&lt;/code&gt;과 &lt;code&gt;GetRemoteRole()&lt;/code&gt;은 무엇인가?&lt;/li&gt;
&lt;li&gt;실행 중인 컨트롤러가 &lt;code&gt;IsLocalController()&lt;/code&gt;를 만족하는가?&lt;/li&gt;
&lt;li&gt;출력이 RPC 또는 복제 결과인가, 단순 에디터 디버그 출력인가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그에 네트워크 모드, 로컬 역할, 액터 이름을 함께 남기면 실행 위치를 훨씬 정확하게 추적할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;UE_LOG(
    LogTemp,
    Log,
    TEXT(&quot;Controller=%s Local=%d Authority=%d NetMode=%d&quot;),
    *GetNameSafe(this),
    IsLocalController(),
    HasAuthority(),
    static_cast&amp;lt;int32&amp;gt;(GetNetMode())
);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. PIE를 실제 서버-클라이언트 구조에 가깝게 설정하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dedicated Server 동작을 확인할 때는 PIE 옵션도 테스트 조건의 일부로 관리해야 한다.&lt;/p&gt;
&lt;table style=&quot;height: 126px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 옵션 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 권장 설정 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 21px;&quot;&gt;&lt;b&gt; 의미 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Launch Separate Server&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;활성화&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;플레이어 창과 별개의 서버 인스턴스를 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Run Under One Process&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;비활성화&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버와 클라이언트를 서로 다른 프로세스로 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Net Mode&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Play as Client&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;에디터 플레이어 창을 클라이언트로 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Number of Players&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;2 이상&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복수 클라이언트 동작 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Allow Late Joining&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;필요 시 활성화&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;실행 중 클라이언트 추가 접속 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Run Under One Process&lt;/code&gt;는 실행 속도와 자원 사용 면에서는 유리하므로 빠른 반복 테스트에 쓸 수 있다. 다만 네트워크 경계, 프로세스별 전역 상태, 로그와 디버그 출력을 확인하는 단계에서는 비활성화한 테스트가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 두 모드는 용도가 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;빠른 UI 반복 작업: 단일 프로세스 PIE&lt;/li&gt;
&lt;li&gt;네트워크 동작 검증: 프로세스를 분리한 PIE&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 실제 멀티플레이 채팅에 필요한 네트워크 경계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구현을 실제 채팅으로 확장할 때의 기본 경로는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Owning Client
    &amp;rarr; 로컬 UI 입력
    &amp;rarr; Server RPC
Server
    &amp;rarr; 메시지 검증 및 발신자 판별
    &amp;rarr; 필요한 클라이언트에 전달
Clients
    &amp;rarr; 수신한 메시지를 각자의 로컬 UI에 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 설계 원칙은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;UI 객체는 복제하지 않는다.&lt;/b&gt; 각 클라이언트가 자신의 로컬 위젯을 생성하고 관리한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;클라이언트 입력은 서버가 신뢰하지 않는다.&lt;/b&gt; 길이 제한, 빈 문자열, 전송 빈도, 금칙어 같은 검증은 서버에서 수행한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;발신자 정보는 서버 기준으로 결정한다.&lt;/b&gt; 클라이언트가 임의의 닉네임이나 플레이어 ID를 메시지와 함께 보내도록 두지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전송과 표시는 분리한다.&lt;/b&gt; &lt;code&gt;PlayerController&lt;/code&gt;는 소유 클라이언트와의 RPC 경계로 사용할 수 있고, 채팅 로그를 어느 클래스에 저장할지는 수명과 공개 범위에 따라 별도로 결정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전역 채팅과 개인 메시지는 전달 범위가 다르다.&lt;/b&gt; 전역 채팅은 참가자 전체, 귓속말은 특정 소유 클라이언트만 대상으로 삼아야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채팅 한 줄처럼 유실되면 사용자 경험에 직접 영향을 주는 낮은 빈도의 이벤트에는 &lt;code&gt;Reliable&lt;/code&gt; RPC가 자연스러운 선택이 될 수 있다. 다만 신뢰성 있는 RPC도 무제한 호출을 허용한다는 뜻은 아니므로 서버 측 rate limit은 별도로 필요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 핵심 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 채팅의 첫 단계에서 확인해야 할 것은 문자열 처리보다 실행 위치다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;GameMode&lt;/code&gt;는 서버에만 존재하고 게임 규칙의 권위를 가진다.&lt;/li&gt;
&lt;li&gt;클라이언트끼리는 직접 통신하지 않으며 서버가 중계 지점이 된다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PlayerController&lt;/code&gt;는 서버와 해당 Owning Client 사이의 중요한 소유권 경계다.&lt;/li&gt;
&lt;li&gt;화면에 표시할 UMG 위젯은 &lt;code&gt;IsLocalController()&lt;/code&gt;를 확인한 뒤 로컬에만 생성한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BindWidget&lt;/code&gt;은 C++ 위젯 클래스와 Widget Blueprint의 이름 및 타입 계약이다.&lt;/li&gt;
&lt;li&gt;동적 델리게이트는 위젯 생명주기에 맞춰 바인딩하고 해제한다.&lt;/li&gt;
&lt;li&gt;단일 프로세스 PIE의 &lt;code&gt;PrintString()&lt;/code&gt; 결과는 네트워크 복제의 증거가 아니다.&lt;/li&gt;
&lt;li&gt;실제 채팅은 로컬 입력, Server RPC, 서버 검증, 클라이언트 전달, 로컬 UI 표시의 단계를 가져야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경계를 분명히 하면 이후 RPC와 프로퍼티 리플리케이션을 붙일 때도 &amp;ldquo;어떤 객체에서, 어느 방향으로, 누구에게 보낼 것인가&amp;rdquo;를 일관된 기준으로 판단할 수 있다.&lt;/p&gt;</description>
      <author>김인철_</author>
      <guid isPermaLink="true">https://uegamedev.tistory.com/66</guid>
      <comments>https://uegamedev.tistory.com/66#entry66comment</comments>
      <pubDate>Thu, 30 Jul 2026 19:44:30 +0900</pubDate>
    </item>
  </channel>
</rss>