<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>think95592 님의 블로그</title>
    <link>https://think95592.tistory.com/</link>
    <description>think95592 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sat, 15 Aug 2026 10:12:14 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>think95592</managingEditor>
    <item>
      <title>기획 맛보기 #3 &amp;mdash; 게임의 시스템과 콘텐츠는 어떻게 상호작용하는가</title>
      <link>https://think95592.tistory.com/96</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘의 한 문장&lt;/span&gt;&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시스템은 플레이가 작동하는 규칙이고, 콘텐츠는 그 규칙을 이용해 플레이어가 실제로 경험하는 대상이다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. 시스템과 콘텐츠의 차이&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;게임을 구성하는 요소는 크게 &lt;/span&gt;&lt;b&gt;&lt;span&gt;로직과 데이터&lt;/span&gt;&lt;/b&gt;&lt;span&gt;, 또는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;시스템과 콘텐츠&lt;/span&gt;&lt;/b&gt;&lt;span&gt;의 관점으로 나눠볼 수 있다.&lt;/span&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;b&gt;&lt;span&gt;시스템:&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 여러 대상에 반복 적용되는 규칙과 구조&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;콘텐츠:&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 그 규칙을 이용해 만들어진 개별 대상과 경험&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 RPG의 아이템을 생각해보자.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;시스템&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;아이템을 획득할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;인벤토리에 보관할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;같은 아이템은 최대 99개까지 중첩된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;장비 아이템은 지정된 슬롯에 착용한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;소비 아이템은 사용하면 효과가 발동하고 수량이 감소한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;콘텐츠&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;체력을 50 회복하는 하급 물약&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공격력이 20 증가하는 철검&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;화염 저항을 부여하는 붉은 반지&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;사용하면 마을로 이동하는 귀환 주문서&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, 인벤토리와 장착&amp;middot;사용 규칙은 시스템이고, 철검과 물약은 그 시스템을 통해 작동하는 콘텐츠다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Unreal Engine 공식 문서도 게임을 구성하는 요소를 로직과 데이터로 구분한다. 로직은 게임 요소가 따르는 명령과 구조이고, 데이터는 그 로직이 구체적으로 무엇을 수행할지 설명한다. 다만 실제 프로젝트에서는 두 영역이 완전히 분리되지 않고 서로 섞일 수 있다고 설명한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://dev.epicgames.com/documentation/en-us/unreal-engine/balancing-blueprint-and-cplusplus?application_version=4.27&quot;&gt;&lt;span&gt;Epic Games &amp;mdash; Balancing Blueprint and C++&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. 시스템은 가능성을 만들고 콘텐츠는 가능성을 증명한다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시스템과 콘텐츠의 관계는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;시스템이 가능한 행동의 범위를 만든다
                &amp;darr;
콘텐츠가 그 규칙을 구체적인 경험으로 만든다
                &amp;darr;
콘텐츠를 테스트하며 부족한 규칙을 발견한다
                &amp;darr;
시스템을 수정하거나 확장한다
                &amp;darr;
새로운 콘텐츠를 만들 수 있게 된다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시스템이 콘텐츠보다 먼저 존재하는 경우가 많지만, 개발이 한 방향으로만 진행되는 것은 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 아이템 시스템에 다음 필드가 있다고 가정하자.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;이름종류가격효과 값&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;하급 물약&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;소비&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;100&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;체력 +50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;철검&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;장비&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;500&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;공격력 +20&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 구조로는 일반적인 회복 아이템과 장비를 만들 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그런데 콘텐츠 기획자가 다음 아이템을 제안했다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;적에게 던진 뒤 플레이어에게 되돌아오는 부메랑&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 시스템에는 투척, 비행, 충돌, 귀환에 관한 규칙이 없다. 이때 선택지는 두 가지다.&lt;/span&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;span&gt;부메랑 아이디어를 포기하거나 기존 규칙에 맞게 바꾼다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;부메랑을 구현할 가치가 충분하다면 투척 무기 시스템을 확장한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 번째 선택을 하면 부메랑 하나만 만들어지는 것이 아니다. 이후 투척 단검, 폭탄, 회수 가능한 창 같은 새로운 콘텐츠를 만들 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 좋은 콘텐츠 아이디어는 단순히 시스템을 소비하는 데 그치지 않고 &lt;/span&gt;&lt;b&gt;&lt;span&gt;새로운 시스템의 필요성을 발견하는 실험&lt;/span&gt;&lt;/b&gt;&lt;span&gt;이 되기도 한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. 몬스터 AI에서의 상호작용&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;시스템이 먼저 정의하는 것&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;몬스터 AI 시스템은 개별 몬스터가 아니라 공통 행동 규칙을 다룬다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;대기 &amp;rarr; 탐색 &amp;rarr; 추적 &amp;rarr; 공격 &amp;rarr; 피격 &amp;rarr; 사망&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;여기에 다음 규칙이 포함될 수 있다.&lt;/span&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;span&gt;플레이어를 발견하는 거리와 각도&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;소리에 반응하는 범위&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;추적을 포기하는 조건&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공격 가능 거리&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;경로 탐색 방식&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;피격 시 행동 전환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;대상이 사라졌을 때의 처리&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;시스템으로 만드는 콘텐츠&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;공통 AI 시스템에 데이터와 행동을 조합하면 여러 몬스터를 만들 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;몬스터&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;탐지 거리&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;이동&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;주요 행동&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;고블린&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;10m&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;지상 추적&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;접근 후 근접 공격&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;궁수&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;18m&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;거리 유지&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;원거리 공격&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;늑대&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;14m&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;빠른 추적&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;측면 이동 후 돌진&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;같은 탐지&amp;middot;추적 시스템을 사용하지만 수치와 행동 조합에 따라 다른 전투 경험이 만들어진다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;콘텐츠가 시스템을 확장하는 순간&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;콘텐츠 기획자가 &amp;ldquo;천장을 기어 다니다가 플레이어 위로 떨어지는 거미&amp;rdquo;를 제안했다고 가정해보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기존 AI가 지상 이동만 지원한다면 다음 기능이 추가로 필요하다.&lt;/span&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;span&gt;벽과 천장 이동&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;천장 대기 상태&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;낙하 공격&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;지상과 천장 사이의 경로 전환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;카메라 밖 공격을 방지하는 경고&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;거미 콘텐츠가 새로운 AI 기능을 요구했고, 확장된 시스템은 이후 박쥐나 벽을 타는 몬스터에도 재사용할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;4. 아이템에서의 상호작용&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;아이템 시스템&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;획득과 버리기&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;인벤토리 보관&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;중첩과 최대 수량&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;장착 슬롯&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;사용 조건&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;효과 적용&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;강화와 분해&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;아이템 콘텐츠&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;회복 물약&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;무기와 방어구&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;제작 재료&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;퀘스트 아이템&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;버프 음식&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;열쇠와 지도&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Epic Games의 Unreal Engine 아이템 예제에서는 먼저 모든 아이템이 공통으로 가져야 할 ID, 종류, 이름, 설명 등의 데이터 구조를 정의하고, 이후 데이터 테이블의 각 행에 개별 아이템을 입력한다. 아이템마다 별도의 코드를 만드는 방식은 아이템 수가 늘어날수록 비효율적이므로, 공통 구조와 중앙화된 데이터로 관리하는 방식을 제안한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://dev.epicgames.com/documentation/unreal-engine/coder-05-manage-item-and-data-in-an-unreal-engine-game&quot;&gt;&lt;span&gt;Epic Games &amp;mdash; Manage Items and Data&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;콘텐츠가 요구하는 확장&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;ldquo;착용한 상태에서 플레이어의 체력이 낮아지면 공격력이 증가하는 반지&amp;rdquo;를 만든다고 가정해보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기존 시스템이 고정 능력치만 지원한다면 다음 기능이 필요하다.&lt;/span&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;span&gt;체력 변화 감지&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;조건부 효과&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;효과의 활성화와 비활성화&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;여러 효과가 겹칠 때의 계산 순서&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;UI에 현재 활성 상태 표시&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;콘텐츠 하나가 조건부 효과 시스템을 만들게 하고, 이후 다양한 장비 콘텐츠에 재사용할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;5. 퀘스트에서의 상호작용&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;퀘스트 시스템&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;퀘스트 시스템은 이야기의 내용보다 진행 규칙을 정의한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;잠김 &amp;rarr; 수락 가능 &amp;rarr; 진행 중 &amp;rarr; 완료 가능 &amp;rarr; 완료
                         ↘ 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;주요 규칙은 다음과 같다.&lt;/span&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;span&gt;퀘스트 수락 조건&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;목표 달성 판정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;여러 목표의 순서&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;실패 조건&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;보상 지급&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;선행&amp;middot;후행 퀘스트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;저장과 불러오기&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;NPC와 UI의 상태 변화&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;퀘스트 콘텐츠&lt;/span&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;늪에서 실종된 약초꾼을 찾아 마을로 데려온다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;콘텐츠 기획자는 다음을 정한다.&lt;/span&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;span&gt;퀘스트를 주는 NPC&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;약초꾼의 위치&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;조사해야 할 흔적&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;등장하는 적&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;대화와 전투 순서&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;완료 보상&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;콘텐츠가 시스템을 확장하는 순간&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기획자가 다음 선택지를 추가하려고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;약초꾼을 구하거나, 그가 가진 희귀 약초만 빼앗아 돌아올 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 콘텐츠를 구현하려면 퀘스트 시스템이 다음을 지원해야 한다.&lt;/span&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;span&gt;선택 결과 저장&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;상호 배타적인 목표&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;NPC 생존 상태&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;결과에 따라 달라지는 보상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;이후 퀘스트와 대사의 변화&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;단순한 일자형 퀘스트가 분기형 콘텐츠로 발전하면서 시스템에도 선택 기록과 상태 추적 기능이 필요해진다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;6. 전투에서의 상호작용&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;전투 시스템&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;공격 입력과 애니메이션 상태&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공격 범위와 적중 판정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;피해 계산&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;회피 무적 시간&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;방어와 패링&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;상태이상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;사망과 보상 처리&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;전투 콘텐츠&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;무기별 공격 동작&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;개별 스킬&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;몬스터의 공격 패턴&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;보스전 구성&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;전투 공간과 적 배치&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;콘텐츠가 시스템을 확장하는 순간&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;ldquo;보스의 공격을 정확한 순간에 막으면 무기가 부서지고 보스가 그로기 상태가 된다&amp;rdquo;는 패턴을 기획했다고 가정해보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이를 위해서는 다음 기능이 필요하다.&lt;/span&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;span&gt;패링 성공 시간 판정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;보스 무기의 내구도&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;무기 파괴 상태&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;그로기 게이지와 상태 전환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;전용 카메라&amp;middot;이펙트&amp;middot;사운드&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;한 번 파괴된 무기가 다시 나타나지 않도록 하는 저장&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;보스 콘텐츠의 아이디어가 전투 시스템을 확장한다. 그리고 이 기능이 범용적으로 설계되면 다른 보스나 일반 적에게도 무기 파괴 콘텐츠를 적용할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;7. 실제 사례: 《젤다의 전설 브레스 오브 더 와일드》&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;《젤다의 전설 브레스 오브 더 와일드》는 시스템이 다양한 콘텐츠 경험을 만들어내는 사례다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;불, 바람, 전기, 금속, 물리 오브젝트 등이 각 장소에서 별도의 연출로만 작동하는 것이 아니라 비교적 일관된 규칙으로 상호작용한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들면 다음과 같다.&lt;/span&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;span&gt;풀과 나무 오브젝트에 불이 붙는다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;불로 발생한 상승기류를 타고 날아오를 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;금속 물체는 전기와 상호작용한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;물리 오브젝트를 밀거나 굴려 적을 공격할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;각 상황을 개발자가 하나씩 정답으로 제작하지 않아도 시스템의 조합을 통해 여러 해결 방법이 발생한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 게임의 테크니컬 디렉터 Takuhiro Dohta는 개발 목표 중 하나를 &lt;/span&gt;&lt;b&gt;&lt;span&gt;multiplicative gameplay&lt;/span&gt;&lt;/b&gt;&lt;span&gt;라고 설명했다. 통일된 규칙 속에서 플레이어가 자신만의 해결 방법과 재미를 찾도록 하는 방향이다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://www.nintendo.com/en-za/News/2019/April/Discover-how-The-Legend-of-Zelda-Breath-of-the-Wild-works-in-VR-with-Nintendo-Labo--1547803.html&quot;&gt;&lt;span&gt;Nintendo &amp;mdash; Breath of the Wild and &amp;ldquo;Multiplicative Gameplay&amp;rdquo;&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 사례에서 시스템은 단순히 콘텐츠를 담는 그릇이 아니다. 여러 시스템이 서로 반응하면서 개발자가 직접 만들지 않은 플레이 상황까지 발생시키는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;콘텐츠 생성기&lt;/span&gt;&lt;/b&gt;&lt;span&gt;에 가깝다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;8. 실제 사례: 기능에서 콘텐츠의 모습이 결정되다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Nintendo는 《Nintendo Land》 개발 당시 여러 프로토타입을 먼저 만든 뒤, 서로 다른 게임을 하나로 묶을 테마파크 콘셉트를 적용했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;특히 《Donkey Kong&amp;rsquo;s Crash Course》에서는 프로토타입의 삼각형 형태가 플레이하기에 가장 재미있었기 때문에 최종 디자인에도 유지했다. 외형을 먼저 정한 뒤 기능을 끼워 넣은 것이 아니라, 재미있는 작동 방식이 콘텐츠의 형태를 결정한 것이다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://www.nintendo.com/en-za/Iwata-Asks/Iwata-Asks-Nintendo-Land/Nintendo-Land/2-Design-follows-Function/2-Design-follows-Function-692089.html&quot;&gt;&lt;span&gt;Nintendo &amp;mdash; Iwata Asks: Design Follows Function&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;《Super Mario 3D World》의 Cat Mario 역시 벽을 오르고, 적을 할퀴고, 골대 기둥을 올라가는 기능을 중심으로 만들어졌다. 개발진은 기존 균형을 흔들 가능성보다 실제로 재미있는지를 우선해 여러 아이디어를 제작하고 검증했다고 설명한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://iwataasks.nintendo.com/interviews/wiiu/super-mario-3d-world/0/1/&quot;&gt;&lt;span&gt;Nintendo &amp;mdash; Iwata Asks: Super Mario 3D World, &amp;ldquo;Function First&amp;rdquo;&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이처럼 시스템과 기능에서 시작한 아이디어는 이후 캐릭터의 외형, 스테이지 구조, 적 배치 같은 콘텐츠로 확장된다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;9. 시스템을 먼저 만드는 것이 유리한 이유&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;콘텐츠를 반복해서 만들 수 있다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;아이템 사용 규칙을 한 번 만들면 물약, 음식, 주문서 등 여러 콘텐츠를 같은 구조로 제작할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;일관성을 유지할 수 있다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;같은 상태이상인데 몬스터마다 적용 방식이 다르면 플레이어가 규칙을 학습하기 어렵다. 공통 시스템을 사용하면 비슷한 상황에서 같은 결과가 발생한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;수정 비용을 줄일 수 있다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;피해 계산 공식을 하나의 시스템에서 관리하면 전체 무기와 몬스터에 일관되게 반영할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;팀이 병렬로 작업할 수 있다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;프로그래머가 제작 도구와 공통 구조를 준비하면 콘텐츠 기획자와 아티스트가 개별 몬스터, 아이템, 퀘스트를 동시에 만들 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;대량 콘텐츠를 관리하기 쉽다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Unreal Engine은 데이터 테이블과 커브 테이블을 이용하면 외부 스프레드시트에서 수치를 관리하고 다시 게임에 반영할 수 있다고 설명한다. 특히 업데이트가 오래 지속되고 반복적인 밸런스 조정이 필요한 게임의 작업량과 복잡도를 줄이는 데 도움이 된다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://dev.epicgames.com/documentation/ko-kr/unreal-engine/data-driven-gameplay-elements-in-unreal-engine&quot;&gt;&lt;span&gt;Epic Games &amp;mdash; Data Driven Gameplay Elements&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;위험한 의존성을 일찍 발견할 수 있다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;전투가 없는 상태에서 몬스터를 100종 기획하면 공격 판정이나 AI 규칙이 바뀔 때 모든 몬스터를 수정해야 한다. 핵심 시스템과 대표 콘텐츠를 먼저 검증하면 이런 대규모 재작업을 줄일 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;10. 시스템 우선이 항상 정답은 아니다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;ldquo;시스템 먼저&amp;rdquo;는 모든 시스템을 완성한 뒤 콘텐츠를 만들라는 뜻이 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;콘텐츠 없이 시스템만 설계하면 실제 요구를 알 수 없어 지나치게 복잡하거나 사용할 수 없는 구조를 만들 수 있다. 따라서 보통은 다음처럼 진행하는 편이 안전하다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;최소 시스템
    +
대표 콘텐츠 1~3개
    &amp;darr;
직접 플레이
    &amp;darr;
시스템과 콘텐츠를 함께 수정
    &amp;darr;
검증된 구조로 콘텐츠 확장&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;콘텐츠나 경험을 먼저 검증해야 하는 경우&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;독특한 보스전이 게임의 핵심일 때&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;보스 한 명의 전투 경험이 게임의 핵심이라면 범용 전투 시스템 전체보다 대표 보스를 먼저 프로토타이핑하는 편이 낫다. 그 보스를 구현하면서 실제로 필요한 시스템을 발견할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;서사와 선택이 핵심일 때&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;분기형 어드벤처 게임이라면 전투나 인벤토리보다 대화 선택, 결과 저장, 감정 전달이 먼저 검증되어야 한다. 이 경우 대표 에피소드가 필요한 내러티브 시스템을 정의한다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;한 번만 등장하는 연출일 때&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;게임 전체에서 한 번 사용하는 장면을 위해 지나치게 범용적인 시스템을 만들면 개발 비용이 더 커질 수 있다. 재사용 가능성과 구현 비용을 비교해 전용 연출로 처리할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;실험적인 플레이를 찾는 단계일 때&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;아직 무엇이 재미있는지 모르는 상태에서는 시스템 구조를 확정하기보다 빠른 프로토타입으로 여러 행동과 콘텐츠를 시험하는 것이 우선이다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;IP나 콘텐츠 제약이 강할 때&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;원작 캐릭터의 특정 능력, 이야기의 필수 장면, 실제 스포츠 규칙처럼 바꿀 수 없는 콘텐츠가 있다면 시스템이 그 요구사항을 지원하도록 설계해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;11. 실무에서 사용할 판단 기준&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;새로운 콘텐츠 아이디어가 나왔을 때 다음 질문을 확인할 수 있다.&lt;/span&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;span&gt;기존 시스템으로 구현할 수 있는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;구현할 수 없다면 콘텐츠를 조정할 수 있는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;시스템을 확장하면 다른 콘텐츠에도 재사용할 수 있는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;확장 비용에 비해 플레이 경험의 가치가 충분한가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;기존 콘텐츠와 충돌하거나 예외를 지나치게 늘리지 않는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;대표 콘텐츠로 먼저 재미를 검증했는가?&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;특히 &amp;ldquo;재미있어 보인다&amp;rdquo;는 이유만으로 시스템을 계속 확장하면 일정이 무너질 수 있다. 반대로 현재 시스템에 맞지 않는다는 이유로 모든 새로운 아이디어를 거부하면 콘텐츠가 단조로워질 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;중요한 것은 콘텐츠의 가치와 시스템 확장 비용을 함께 판단하는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘 배운 점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시스템과 콘텐츠는 뼈대와 살처럼 구분할 수 있지만 실제 개발에서는 계속 서로 영향을 준다.&lt;/span&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;span&gt;시스템은 콘텐츠가 작동할 수 있는 규칙을 제공한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;콘텐츠는 시스템이 실제로 재미있는지 검증한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;새로운 콘텐츠는 기존 시스템의 한계를 발견한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;확장된 시스템은 다시 더 다양한 콘텐츠를 만들게 한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 가장 현실적인 개발 방식은 모든 시스템이나 모든 콘텐츠 중 하나를 먼저 완성하는 것이 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;핵심 시스템을 최소한으로 만들고, 대표 콘텐츠로 검증한 뒤, 둘을 함께 확장하는 것&lt;/span&gt;&lt;/b&gt;&lt;span&gt;이 중요하다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/96</guid>
      <comments>https://think95592.tistory.com/96#entry96comment</comments>
      <pubDate>Fri, 14 Aug 2026 21:26:36 +0900</pubDate>
    </item>
    <item>
      <title>기획 맛보기 #2 &amp;mdash; 시스템 기획, 콘텐츠 기획, 시나리오 기획의 차이</title>
      <link>https://think95592.tistory.com/95</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘의 한 문장&lt;/span&gt;&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시스템 기획은 게임의 규칙을 만들고, 콘텐츠 기획은 그 규칙으로 플레이할 거리를 만들며, 시나리오 기획은 플레이에 의미와 맥락을 부여한다.&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20260427_170554303.jpg&quot; data-origin-width=&quot;1357&quot; data-origin-height=&quot;1018&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oD8l2/dJMcags1yOc/vwCOh0QRsREdYxXn9zUWGk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oD8l2/dJMcags1yOc/vwCOh0QRsREdYxXn9zUWGk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oD8l2/dJMcags1yOc/vwCOh0QRsREdYxXn9zUWGk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoD8l2%2FdJMcags1yOc%2FvwCOh0QRsREdYxXn9zUWGk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1357&quot; height=&quot;1018&quot; data-filename=&quot;KakaoTalk_20260427_170554303.jpg&quot; data-origin-width=&quot;1357&quot; data-origin-height=&quot;1018&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. 게임 기획은 하나의 직무가 아니다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;공부 자료에서는 게임 기획을 다음과 같이 구분했다.&lt;/span&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;b&gt;&lt;span&gt;시스템 기획:&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 게임의 구조와 규칙을 설계한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;콘텐츠 기획:&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 시스템 위에 플레이할 내용을 채운다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;시나리오 기획:&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 세계관과 이야기, 캐릭터의 서사를 만든다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이를 건물에 비유하면 이해하기 쉽다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;fix&quot;&gt;&lt;code&gt;시스템 기획 = 건물의 구조와 설비
콘텐츠 기획 = 건물 안의 가구와 활동
시나리오 기획 = 그 공간이 존재하는 이유와 이야기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;세 분야는 서로 독립적이지 않다. 시스템이 없으면 콘텐츠가 작동할 수 없고, 콘텐츠가 없으면 시스템을 경험할 수 없다. 시나리오는 플레이어가 그 행동을 해야 하는 이유와 감정적인 맥락을 제공한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Ubisoft도 게임 디자인을 플레이어의 경험을 만드는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;규칙과 시스템&amp;middot;메커니즘의 설계&lt;/span&gt;&lt;/b&gt;&lt;span&gt;로 설명한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://www.ubisoft.com/en-us/company/careers/locations/articles/our-job-roles&quot;&gt;&lt;span&gt;Ubisoft Careers &amp;mdash; Our Job Roles&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. 시스템 기획: 게임의 뼈대를 설계한다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시스템 기획자는 여러 콘텐츠가 공통으로 사용하는 규칙을 만든다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 전투 시스템을 설계한다면 다음을 정의해야 한다.&lt;/span&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;span&gt;공격 입력은 언제 허용되는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공격이 적중했는지는 어떻게 판정하는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;피해량은 어떤 공식으로 계산되는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;치명타, 방어력, 회피는 어떤 순서로 적용되는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;캐릭터가 사망하면 어떤 상태로 전환되는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;예외 상태에서는 어떤 규칙이 우선하는가?&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, &amp;ldquo;불 속성 검을 만든다&amp;rdquo;보다 먼저 &lt;/span&gt;&lt;b&gt;&lt;span&gt;게임에서 속성이 어떻게 작동하는가&lt;/span&gt;&lt;/b&gt;&lt;span&gt;를 정한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;주요 설계 대상&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;전투와 피격 판정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;캐릭터 이동과 조작&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;적 AI&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;스킬과 상태이상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;인벤토리와 장비&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;성장과 레벨업&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;재화의 획득과 소비&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;퀘스트 진행 구조&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;저장과 불러오기&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;멀티플레이 규칙&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;주요 결과물&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;시스템 개요서와 기능 명세&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;플로차트와 상태 전이도&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;계산 공식과 수치 구조&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;입력&amp;middot;조건&amp;middot;처리&amp;middot;결과&amp;middot;예외 규칙&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;UI 흐름과 화면 와이어프레임&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;데이터 구조와 필요한 항목&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;프로토타입과 테스트 조건&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Riot Games의 시스템 디자이너 채용 설명에서도 성장, 건설, 자원 생산과 소비, 기능 해금 같은 구조를 설계하고, 관련 문서와 프로토타입을 만들며 프로그래머&amp;middot;아티스트&amp;middot;UI&amp;middot;수치 담당자와 함께 구현한다고 설명한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://www.riotgames.com/en/work-with-us/job/7921706/game-designer-system-unpublished-r-d-product-contract-guangzhou-china&quot;&gt;&lt;span&gt;Riot Games &amp;mdash; Game Designer, System&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;프로그래머와의 협업&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;세 분야 중 일반적으로 프로그래머와 가장 직접적이고 빈번하게 협업한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시스템 기획자는 &amp;ldquo;무엇이 어떤 규칙으로 작동해야 하는가&amp;rdquo;를 정의하고, 프로그래머는 이를 실제 코드와 도구로 구현한다. 구현 과정에서 기술적 한계나 더 좋은 방법이 발견되면 양쪽이 명세를 다시 조정한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;ScreenSkills도 게임플레이 디자이너를 기술팀과 아트팀을 연결하는 역할로 설명하며, 기술적으로 구현하기 어려운 경우 프로그래머와 가능 범위를 논의해 명세를 수정한다고 소개한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://www.screenskills.com/job-profiles/browse/games/design/gameplay-designer/&quot;&gt;&lt;span&gt;ScreenSkills &amp;mdash; Gameplay Designer&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. 콘텐츠 기획: 시스템 위에 플레이할 거리를 만든다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;콘텐츠 기획자는 시스템이 정한 규칙을 이용해 플레이어가 실제로 만나고 경험할 대상을 만든다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;전투 시스템에 공격, 방어, 속성, 상태이상 규칙이 준비되었다면 콘텐츠 기획자는 이를 조합해 다음과 같은 내용을 만든다.&lt;/span&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;span&gt;몬스터 종류와 배치&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;개별 스킬과 아이템&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;퀘스트와 보상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;보스전 패턴&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;던전과 이벤트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;상점 판매 목록&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;성장 구간별 콘텐츠&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;라이브 서비스 이벤트&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;쉽게 말하면 시스템 기획이 &lt;/span&gt;&lt;b&gt;&lt;span&gt;몬스터를 만들 수 있는 규칙&lt;/span&gt;&lt;/b&gt;&lt;span&gt;을 설계한다면, 콘텐츠 기획은 그 규칙을 사용해 &lt;/span&gt;&lt;b&gt;&lt;span&gt;고블린, 골렘, 드래곤을 실제로 만든다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;주요 결과물&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;몬스터&amp;middot;아이템&amp;middot;스킬 데이터시트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;퀘스트 진행표와 조건표&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;전투 및 보스 패턴 문서&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;콘텐츠 구성표와 해금 순서&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;등장 확률과 보상 테이블&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;에셋 요청 목록&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;게임 엔진에서 설정한 실제 데이터&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;플레이테스트 결과와 밸런스 수정안&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;콘텐츠 기획의 결과물은 문서에만 머물지 않는다. 프로젝트에 따라 기획자가 엔진이나 전용 툴에서 몬스터 수치, 퀘스트 조건, 아이템 효과를 직접 입력하고 테스트하기도 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;프로그래머와의 협업&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;협업의 직접성은 개발 도구가 얼마나 준비되어 있는지에 따라 달라진다.&lt;/span&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;span&gt;필요한 시스템이나 제작 도구가 없다면 프로그래머와 직접 협의한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;기존 시스템과 툴로 콘텐츠를 만들 수 있다면 데이터 입력과 테스트 중심으로 일한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;콘텐츠가 기존 규칙을 벗어나면 시스템 확장을 요청한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;오류가 콘텐츠 데이터 때문인지 시스템 코드 때문인지 함께 확인한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 콘텐츠 기획은 시스템 기획보다 프로그래머와 덜 직접적일 수 있지만, 퀘스트 스크립트나 이벤트 트리거를 직접 다루는 프로젝트에서는 협업 강도가 상당히 높아질 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;4. 시나리오 기획: 플레이에 의미와 맥락을 만든다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시나리오 기획자는 세계관, 사건, 인물 관계와 이야기의 흐름을 설계한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다만 게임의 시나리오는 영화나 소설처럼 정해진 순서대로 보여주는 이야기만을 의미하지 않는다. 플레이어가 이동하거나 선택하고, 전투하고, 퀘스트를 완료하는 과정에서 이야기가 어떻게 전달될지도 함께 고려해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;주요 설계 대상&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;세계관과 시대적 배경&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;주요 사건과 전체 플롯&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;캐릭터의 목표와 관계&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;메인&amp;middot;서브 퀘스트의 서사&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;선택지와 분기&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;대사와 보이스오버&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;환경을 이용한 이야기 전달&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;게임플레이와 서사의 연결&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;주요 결과물&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;세계관 설정집&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;캐릭터 설정서&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;전체 플롯과 에피소드 구성표&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;퀘스트 시놉시스&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;대사 및 보이스오버 대본&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;분기형 대화 플로차트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;컷신&amp;middot;연출 요청서&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;내러티브 트리거와 조건표&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;용어&amp;middot;고유명사&amp;middot;현지화 기준표&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;시나리오 작가와 내러티브 디자이너&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 역할은 겹칠 수 있지만 강조점이 다르다.&lt;/span&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;b&gt;&lt;span&gt;시나리오 작가:&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 이야기, 장면, 대사 자체를 작성한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;내러티브 디자이너:&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 그 이야기가 게임의 규칙과 플레이를 통해 언제, 어떻게 전달되는지를 설계한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Ubisoft의 내러티브 디자이너들은 이 역할을 게임 메커니즘과 서사를 연결하는 직무로 설명한다. 단순히 대사를 작성하는 데 그치지 않고 대화가 시작되는 조건, 전투 중 대사가 끊겼을 때의 처리, 분기와 트리거까지 고려한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://news.ubisoft.com/en-us/article/7m412GLSbfkaT0YheRYLVG/what-is-narrative-design&quot;&gt;&lt;span&gt;Ubisoft &amp;mdash; What Is Narrative Design?&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;프로그래머와의 협업&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시나리오 작문만 담당한다면 프로그래머와 직접 협업하는 비중이 비교적 낮을 수 있다. 하지만 내러티브 디자이너가 다음 요소까지 담당한다면 직접적인 협업이 필요하다.&lt;/span&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;span&gt;대화 발생 조건&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;선택지와 분기 저장&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;컷신 진입과 종료&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;퀘스트 상태에 따른 대사 변화&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;동료의 상황별 반응&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;음성 재생 우선순위&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;전투로 대화가 중단되었을 때의 처리&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 &lt;/span&gt;&lt;b&gt;&lt;span&gt;시나리오 기획은 프로그래머와 거의 협업하지 않는다&lt;/span&gt;&lt;/b&gt;&lt;span&gt;고 단정하기 어렵다. 현대의 내러티브 디자인은 시스템과 구현 도구를 다루는 기술적인 역할까지 포함할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;5. 세 분야 비교&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구분시스템 기획콘텐츠 기획시나리오 기획&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;핵심 질문&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;어떤 규칙으로 작동하는가?&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;그 규칙으로 무엇을 플레이하는가?&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;왜 이것을 하고 어떤 의미를 느끼는가?&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;중심 대상&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;구조, 규칙, 상태, 공식&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;몬스터, 스킬, 아이템, 퀘스트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;세계관, 사건, 인물, 대사&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;성격&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;반복 사용되는 뼈대&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;뼈대를 이용한 개별 사례&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;플레이에 부여되는 맥락&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;대표 산출물&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;명세서, 플로차트, 공식, 데이터 구조&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;데이터시트, 퀘스트표, 패턴, 배치&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;설정집, 플롯, 대사, 분기표&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;프로그래머 협업&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;매우 직접적&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;중간~높음&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;역할에 따라 낮음~높음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;주요 협업 대상&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;프로그래머, UI&amp;middot;UX, QA&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;시스템&amp;middot;레벨 기획, 아트, QA&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;콘텐츠&amp;middot;레벨 기획, 연출, 오디오, 현지화&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;주된 검증 방법&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;기능 테스트와 예외 검증&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;반복 플레이와 밸런스 테스트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;흐름, 맥락, 전달 시점 검증&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 표는 일반적인 경향이지 모든 회사에 적용되는 고정 규칙은 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;6. 같은 소재를 세 분야는 어떻게 다르게 볼까?&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;몬스터&lt;/span&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;분야&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;담당 관점&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;시스템 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;AI 상태, 이동&amp;middot;탐지 규칙, 공격 판정, 피해와 사망 처리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;콘텐츠 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;몬스터 종류, 능력치, 스킬 조합, 출현 위치와 보상&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;시나리오 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;몬스터의 기원, 세력, 행동 목적, 세계관 속 의미&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 &amp;ldquo;독 늪의 고블린&amp;rdquo;을 만든다면 다음처럼 나뉜다.&lt;/span&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;span&gt;시스템: 독 상태는 2초마다 피해를 주며 최대 3회 중첩된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;콘텐츠: 고블린 주술사는 독 투사체와 독 장판을 사용한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;시나리오: 고블린 부족은 오염된 늪을 신성한 땅으로 믿고 지킨다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;스킬&lt;/span&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;분야&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;담당 관점&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;시스템 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;자원 소모, 재사용 대기시간, 판정 방식, 상태이상 우선순위&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;콘텐츠 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;개별 스킬의 범위, 피해량, 연출 콘셉트와 성장 수치&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;시나리오 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;능력의 설정상 기원, 사용자와의 관계, 명칭과 설명문&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 &amp;ldquo;시간 정지&amp;rdquo; 스킬이라면 다음과 같다.&lt;/span&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;span&gt;시스템: 일정 범위의 대상에게 시간 정지 상태를 적용한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;콘텐츠: 지속시간은 3초이며 보스에게는 50%만 적용된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;시나리오: 주인공이 과거의 사고로 얻은 금지된 능력이다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;아이템&lt;/span&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;분야&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;담당 관점&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;시스템 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;획득, 보관, 장착, 사용, 중첩, 삭제 규칙&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;콘텐츠 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;아이템 종류, 능력치, 희귀도, 드롭률, 제작 재료&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;시나리오 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;아이템 이름, 유래, 설명문, 이야기 속 상징성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 &amp;ldquo;왕의 부서진 검&amp;rdquo;이라면 다음과 같다.&lt;/span&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;span&gt;시스템: 양손 무기이며 강화와 분해가 가능하다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;콘텐츠: 공격력 45, 치명타 확률 5%, 보스 드롭률 10%.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;시나리오: 멸망한 왕국의 마지막 왕이 사용한 검의 일부다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;퀘스트&lt;/span&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;분야&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;담당 관점&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;시스템 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;수락&amp;middot;진행&amp;middot;완료&amp;middot;실패 상태와 조건 판정&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;콘텐츠 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;목표, 동선, 적 배치, 난이도, 보상과 소요 시간&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;시나리오 기획&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;사건의 이유, 등장인물의 동기, 대사와 선택 결과&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;ldquo;사라진 약초꾼 찾기&amp;rdquo; 퀘스트를 예로 들면 다음과 같다.&lt;/span&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;span&gt;시스템: NPC와 대화하면 수락되고, 흔적 세 개를 조사하면 다음 단계가 열린다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;콘텐츠: 흔적은 마을에서 늪까지 이어지며 마지막 구간에 독 고블린이 등장한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;시나리오: 약초꾼은 병든 딸을 살리기 위해 금지된 늪에 들어갔고, 플레이어는 그를 구할지 약초를 먼저 가져갈지 선택한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;7. 실제 개발에서는 세 분야가 연결된다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;퀘스트 하나를 만든다고 가정하면 다음과 같은 순환이 일어난다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;시나리오 기획
약초꾼이 늪에서 실종된 사건을 설계
        &amp;darr;
콘텐츠 기획
조사 목표, 이동 동선, 전투와 보상을 구성
        &amp;darr;
시스템 기획
조사&amp;middot;분기&amp;middot;실패 조건을 처리할 퀘스트 규칙을 확인
        &amp;darr;
프로그래머
필요한 기능과 제작 도구를 구현
        &amp;darr;
각 기획자
게임에 입력하고 플레이테스트한 뒤 함께 수정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시나리오에서 &amp;ldquo;전투 중에도 동료와 대화한다&amp;rdquo;는 장면을 요구했는데 대화 시스템이 전투를 지원하지 않을 수 있다. 반대로 프로그래머가 만든 새로운 대화 기능이 더 좋은 서사 표현으로 이어질 수도 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Ubisoft가 소개한 《Bury Me, My Love》 개발 사례에서도 개발자가 제안한 셀카 기능이 서사에 활용되었고, 메시지 입력 표시 기능은 인물의 망설임과 감정을 전달하는 장치가 되었다. 기술과 이야기가 일방향으로 전달된 것이 아니라 서로의 아이디어를 발전시킨 사례다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;또한 《The Division》과 같은 온라인 게임에서는 전투와 다른 플레이어의 대화 때문에 음성 전달이 방해될 수 있어, 내러티브 디자이너가 레벨 디자이너와 협의하여 적이 없는 공간을 마련하기도 한다. 이야기를 제대로 전달하려면 시나리오뿐 아니라 시스템과 공간까지 함께 설계해야 한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://news.ubisoft.com/en-us/article/7m412GLSbfkaT0YheRYLVG/what-is-narrative-design&quot;&gt;&lt;span&gt;Ubisoft &amp;mdash; What Is Narrative Design?&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;8. 회사마다 직무 경계가 다른 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;시스템&amp;middot;콘텐츠&amp;middot;시나리오라는 구분은 이해에 도움이 되지만, 업계 전체가 사용하는 절대적인 분류는 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;직무 범위는 다음 조건에 따라 달라진다.&lt;/span&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;span&gt;회사와 팀의 규모&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;게임의 장르&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;개발 단계&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;자체 제작 도구의 완성도&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;라이브 서비스 여부&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;프로젝트의 서사 비중&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;각 구성원의 전문성&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;소규모 팀&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;한 명의 게임 기획자가 시스템, 콘텐츠, 레벨, UI를 모두 담당할 수 있다. 시나리오 담당자가 세계관부터 대사, 퀘스트 입력까지 맡기도 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;대규모 팀&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;업무가 더 세분화될 수 있다.&lt;/span&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;span&gt;전투 시스템 디자이너&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;경제&amp;middot;성장 디자이너&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;퀘스트 디자이너&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;레벨 디자이너&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;내러티브 디자이너&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;게임 라이터&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;테크니컬 디자이너&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;라이브 콘텐츠 디자이너&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Ubisoft의 현직자 인터뷰에서도 내러티브 디자인의 범위는 아직 완전히 표준화되지 않았으며, 소규모 스튜디오에서는 내러티브 디자인과 작문 업무를 한 사람이 담당한다고 설명한다. 역할의 중첩 정도는 프로젝트, 스튜디오, 팀 규모에 따라 크게 달라진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 채용 공고를 볼 때는 직무명만 확인하면 안 된다. 다음 내용을 구체적으로 살펴봐야 한다.&lt;/span&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;span&gt;어떤 기능과 콘텐츠를 담당하는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;문서 작성과 게임 내 구현 중 어디까지 맡는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;사용하는 엔진과 제작 도구는 무엇인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;주요 협업 대상은 누구인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;요구하는 포트폴리오는 명세, 데이터, 퀘스트, 대본 중 무엇인가?&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘 배운 점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;처음에는 시스템 기획자는 규칙을 만들고, 콘텐츠 기획자는 수치를 채우며, 시나리오 기획자는 글을 쓰는 사람이라고 단순하게 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;하지만 실제로는 세 분야 모두 &lt;/span&gt;&lt;b&gt;&lt;span&gt;플레이어 경험을 서로 다른 관점에서 설계하는 역할&lt;/span&gt;&lt;/b&gt;&lt;span&gt;이다.&lt;/span&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;span&gt;시스템 기획은 플레이가 가능한 규칙을 만든다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;콘텐츠 기획은 규칙을 구체적인 경험으로 바꾼다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;시나리오 기획은 그 경험에 이유와 감정을 부여한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;세 역할의 경계보다 더 중요한 것은 하나의 게임 안에서 서로의 결과물이 자연스럽게 연결되는가이다. 좋은 게임 기획자는 자신의 담당 분야뿐 아니라 앞뒤 분야가 무엇을 필요로 하는지도 이해해야 한다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/95</guid>
      <comments>https://think95592.tistory.com/95#entry95comment</comments>
      <pubDate>Thu, 13 Aug 2026 20:31:17 +0900</pubDate>
    </item>
    <item>
      <title>기획 맛보기 #1 &amp;mdash; 게임 기획이란 무엇인가</title>
      <link>https://think95592.tistory.com/94</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘의 한 문장&lt;/span&gt;&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;게임 기획은 머릿속의 아이디어를 팀이 같은 방향으로 이해하고, 개발자가 실제로 구현하고 테스트할 수 있는 명세로 바꾸는 과정이다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;566&quot; data-origin-height=&quot;543&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjUg8E/dJMcaa7t7en/m7KLZK7qofxICK7tykmsL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjUg8E/dJMcaa7t7en/m7KLZK7qofxICK7tykmsL0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjUg8E/dJMcaa7t7en/m7KLZK7qofxICK7tykmsL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcjUg8E%2FdJMcaa7t7en%2Fm7KLZK7qofxICK7tykmsL0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;566&quot; height=&quot;543&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;566&quot; data-origin-height=&quot;543&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. 아이디어와 기획은 다르다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;ldquo;적을 쓰러뜨리는 재미있는 액션 게임을 만들고 싶다&amp;rdquo;는 아이디어다. 만들고 싶은 방향은 보이지만, 이것만으로는 개발을 시작하기 어렵다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;개발하려면 적어도 다음 질문에 답할 수 있어야 한다.&lt;/span&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;span&gt;플레이어는 어떤 행동을 하는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;승리와 실패는 언제 결정되는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공격이 적에게 적중했는지는 어떻게 판정하는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;적이 죽으면 무엇을 얻는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;획득한 보상은 다음 플레이에 어떤 영향을 주는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;플레이어에게 결과를 어떻게 전달하는가?&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Unity 역시 게임 디자인 문서인 GDD를 콘셉트부터 게임 규칙, 조작법, 시각 요소, 개발 일정까지 담는 개발의 청사진으로 설명한다. 동시에 프로젝트의 규모에 따라 문서 형식과 분량은 달라질 수 있다고 안내한다. 즉, 좋은 기획은 문서가 긴가보다 &lt;/span&gt;&lt;b&gt;&lt;span&gt;개발에 필요한 판단이 들어 있는가&lt;/span&gt;&lt;/b&gt;&lt;span&gt;가 중요하다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://learn.unity.com/tutorial/fill-out-a-game-design-document&quot;&gt;&lt;span&gt;Unity Learn &amp;mdash; Fill out a game design document&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;아이디어를 명세로 바꾸는 예시&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;아이디어:&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;공격하면 적이 시원하게 날아간다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;구현 가능한 명세:&lt;/span&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;span&gt;공격 버튼을 누르면 0.2초 후 공격 판정이 발생한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공격 범위 안의 적에게 20의 피해를 준다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;적이 피해를 받으면 공격 방향으로 3m 밀려난다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;보스와 슈퍼아머 상태의 적은 밀려나지 않는다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;적중 시 타격 이펙트, 효과음, 짧은 화면 흔들림을 출력한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공격이 빗나가면 적중 효과를 출력하지 않는다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 문장은 같은 아이디어를 다루지만, 두 번째 문장에는 입력, 조건, 결과, 예외, 피드백이 있다. 개발자와 아티스트, 사운드 담당자는 이를 바탕으로 작업을 시작할 수 있고, QA 담당자는 정상 작동 여부를 확인할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. 기획자는 Why를, 개발자는 How를 본다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;공부 자료에서 가장 인상적이었던 부분은 다음 구분이었다.&lt;/span&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;span&gt;기획자는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;왜 이런 경험이 필요한가&lt;/span&gt;&lt;/b&gt;&lt;span&gt;를 생각한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;개발자는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;그 경험을 어떻게 작동시킬 것인가&lt;/span&gt;&lt;/b&gt;&lt;span&gt;를 생각한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 기획자가 &amp;ldquo;적의 공격 직전에 붉은 표시를 보여준다&amp;rdquo;고 정했다면, 그 이유는 단순히 화면을 화려하게 만들기 위해서가 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;플레이어가 공격을 예측하고 회피했다는 성취감을 느끼게 하기 위해서다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;개발자는 이 의도를 바탕으로 경고 표시가 나타나는 시점, 공격 상태 전환, 피격 판정, 애니메이션 연동 방식을 설계한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다만 Why와 How를 직군별로 완전히 나누어서는 안 된다. 실제 개발에서는 기획자도 구현 비용과 기술적 제약을 알아야 하고, 개발자도 기능이 만들어야 할 플레이 경험을 이해해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;MDA 프레임워크는 이를 다음과 같이 설명한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;규칙과 수치(Mechanics)
        &amp;darr;
플레이 중 발생하는 행동(Dynamics)
        &amp;darr;
플레이어가 느끼는 경험(Aesthetics)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;디자이너는 규칙에서 경험을 향해 설계하지만, 플레이어는 먼저 경험을 느끼고 그 뒤에 있는 행동과 규칙을 발견한다. 따라서 기획자는 &amp;ldquo;어떤 기능을 넣을까?&amp;rdquo;보다 &amp;ldquo;어떤 경험을 만들고, 어떤 규칙으로 그 경험을 발생시킬까?&amp;rdquo;를 고민해야 한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://www.cs.northwestern.edu/~hunicke/MDA.pdf&quot;&gt;&lt;span&gt;Hunicke&amp;middot;LeBlanc&amp;middot;Zubek &amp;mdash; MDA: A Formal Approach to Game Design and Game Research&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. 좋은 기획서의 조건&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;좋은 기획서는 모든 내용을 길게 설명한 문서가 아니다. &lt;/span&gt;&lt;b&gt;&lt;span&gt;필요한 사람이 필요한 결정을 내릴 수 있게 해주는 문서&lt;/span&gt;&lt;/b&gt;&lt;span&gt;다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;목적이 분명하다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기능이 존재하는 이유와 만들고자 하는 플레이 경험을 먼저 설명한다.&lt;/span&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;span&gt;나쁜 예: 구르기 기능을 추가한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;좋은 예: 근접 전투에서 공격을 피하고 곧바로 반격하는 경험을 제공하기 위해 구르기를 추가한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;동작을 확인할 수 있다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;ldquo;적절하게&amp;rdquo;, &amp;ldquo;자연스럽게&amp;rdquo;, &amp;ldquo;재미있게&amp;rdquo; 같은 표현만으로는 완료 여부를 판단하기 어렵다.&lt;/span&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;span&gt;입력&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;발생 조건&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;처리 결과&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;성공과 실패&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;예외 상황&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;화면&amp;middot;소리 피드백&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;위 항목이 구체적일수록 구현과 테스트가 쉬워진다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;읽는 사람에게 맞는 형태를 사용한다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;복잡한 흐름은 순서도, 수치 비교는 표, 공간 구조는 그림으로 표현하는 편이 좋다. 모든 정보를 긴 문장으로 적을 필요는 없다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;실제로 《SimCity》 개발에 참여한 Stone Librande는 핵심 아이디어를 팀에 전달하기 위해 3년간 100개가 넘는 한 페이지 문서를 사용했다. 이는 무조건 한 장만 써야 한다는 뜻이 아니라, &lt;/span&gt;&lt;b&gt;&lt;span&gt;하나의 주제를 빠르게 이해할 수 있는 단위로 전달했다&lt;/span&gt;&lt;/b&gt;&lt;span&gt;는 점이 중요하다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://www.gdcvault.com/play/1018764/Simulating-a-City-One-Page&quot;&gt;&lt;span&gt;GDC Vault &amp;mdash; Simulating a City, One Page at a Time&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;변경할 수 있어야 한다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기획서는 처음 작성한 내용을 끝까지 지키는 계약서가 아니다. 프로토타입과 테스트 결과에 따라 계속 수정되는 현재의 기준점이다. Unity도 GDD를 프로젝트의 변화에 맞춰 수정하고 확장해야 하는 동적 문서로 설명한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 문서에는 다음 정보가 있으면 좋다.&lt;/span&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;span&gt;작성자와 최근 수정일&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 확정된 내용&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;아직 검증이 필요한 가설&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;변경된 이유&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;관련 문서와 담당자&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;4. 레퍼런스는 어떻게 분석해야 할까?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;레퍼런스를 찾을 때 단순히 &amp;ldquo;재미있는 게임&amp;rdquo;을 고르면 분석 범위가 너무 넓어진다. 내가 만들 게임의 문제를 해결하는 데 도움이 되는 게임을 골라야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 보스전의 공격 예고를 기획한다면 다음처럼 범위를 좁힐 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;공격을 피할 수 있을 만큼 명확하게 알려주면서도 긴장감을 유지하는 게임&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;레퍼런스를 분석할 때는 기능의 외형보다 &lt;/span&gt;&lt;b&gt;&lt;span&gt;기능과 경험 사이의 인과관계&lt;/span&gt;&lt;/b&gt;&lt;span&gt;를 찾아야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;레퍼런스 분석 질문&lt;/span&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;플레이어가 실제로 하는 행동은 무엇인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;게임은 어떤 규칙과 정보를 제공하는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;그 규칙 때문에 어떤 플레이 양상이 발생하는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;플레이어는 어떤 감정을 느끼는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;이 요소가 해결하는 문제는 무엇인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;우리 게임에 적용하려면 무엇을 유지하고 무엇을 바꿔야 하는가?&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;예시&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;관찰:&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;보스가 강한 공격 전에 무기를 크게 들어 올린다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;분석:&lt;/span&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;span&gt;공격 전에 준비 동작을 보여준다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;플레이어가 공격 방향과 시점을 예측할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공격이 강해도 피할 기회가 있기 때문에 부당하다는 느낌이 줄어든다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;회피에 성공하면 자신의 판단과 조작 실력으로 극복했다는 성취감을 느낀다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;적용:&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;보스의 강한 공격에는 최소 0.8초의 고유한 준비 동작과 전용 효과음을 사용한다. 일반 공격과 실루엣이 겹치지 않도록 한다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이렇게 분석해야 레퍼런스의 겉모습을 복사하지 않고, 그 기능이 작동하는 원리를 가져올 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;5. 콘셉트는 한 문장으로 설명할 수 있어야 한다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;콘셉트 문장은 팀이 기능을 선택하거나 제거할 때 사용하는 판단 기준이다. 다음 요소를 넣으면 비교적 명확하게 정리할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;누가 + 무엇을 반복하며 + 어떤 경험을 얻는 게임인가 + 차별점은 무엇인가&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예시:&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;빛을 잃어가는 숲의 수호자가 낮에 자원을 모으고 밤에 거점을 방어하며, 준비와 생존의 긴장감을 경험하는 로그라이트 디펜스 게임.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 문장에서는 다음 내용을 확인할 수 있다.&lt;/span&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;span&gt;플레이어 역할: 숲의 수호자&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;핵심 행동: 자원 수집과 거점 방어&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;핵심 경험: 준비와 생존의 긴장감&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;장르와 구조: 로그라이트 디펜스&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;변형 포인트: 낮과 밤의 역할 변화&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;반대로 다음 문장은 콘셉트로 사용하기 어렵다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;재미있는 전투와 감동적인 스토리가 있는 액션 게임.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;대부분의 게임에 적용할 수 있고, 어떤 기능을 우선해야 하는지 판단할 기준이 없기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Unity의 GDD 학습 자료도 프로젝트 초기에 콘셉트, 장르, 대상 플레이어와 플랫폼을 정리하고, 간단한 게임은 &amp;ldquo;누가 무엇을 모으면서 무엇을 피하는가&amp;rdquo; 같은 문장으로 출발하도록 안내한다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://learn.unity.com/tutorial/fill-out-a-game-design-document&quot;&gt;&lt;span&gt;Unity Learn &amp;mdash; GDD의 콘셉트 작성 예시&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;6. 실제 개발에서 기획자는 무엇을 하는가?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;《Super Mario 3D Land》 개발진 인터뷰에서 기획 리더는 팀에서 나온 아이디어를 조율하고 제품에 반영하는 다리 역할을 했다고 설명한다. 디렉터는 전반적인 사양과 게임의 균형을 조정했고, 디자인 리더는 Mario의 움직임과 연출 등을 담당했다. 하나의 아이디어를 실제 제품으로 만들기 위해 여러 분야의 판단을 연결한 것이다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://iwataasks.nintendo.com/interviews/3ds/super-mario-3d-land/0/0/&quot;&gt;&lt;span&gt;Nintendo &amp;mdash; Iwata Asks: Super Mario 3D Land&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;후속작 《Super Mario 3D World》의 프로듀서는 게임이 전체 콘셉트를 계속 따르고 있는지 확인하는 것이 자신의 주요 역할이었다고 말했다. 당시의 방향은 2D 《New Super Mario》를 좋아하는 사람도 즐길 수 있는 거치형 3D Mario 게임이었다. 콘셉트가 단순한 홍보 문구가 아니라 개발 도중 수많은 선택을 검토하는 기준으로 사용된 사례다.&lt;/span&gt;&lt;br /&gt;&lt;a href=&quot;https://iwataasks.nintendo.com/interviews/wiiu/super-mario-3d-world/0/0/&quot;&gt;&lt;span&gt;Nintendo &amp;mdash; Iwata Asks: Super Mario 3D World&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;결국 실제 게임 개발에서 기획자는 다음을 연결한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;아이디어
  &amp;darr;
플레이어에게 주고 싶은 경험
  &amp;darr;
필요한 규칙과 기능
  &amp;darr;
구현 가능한 명세
  &amp;darr;
프로토타입과 테스트
  &amp;darr;
결과에 따른 수정&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘 배운 점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;게임 기획은 좋은 아이디어를 많이 내는 일만이 아니다. 아이디어의 의도를 설명하고, 필요한 규칙을 정리하고, 여러 직군이 같은 결과를 상상할 수 있도록 전달하는 일이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;좋은 기획서는 모든 답을 처음부터 알고 있는 문서가 아니다. 현재의 의도와 가설을 명확하게 보여주고, 구현과 테스트를 거쳐 더 나은 답으로 수정할 수 있는 문서다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;앞으로 기능을 기획할 때는 다음 네 가지부터 확인해야겠다.&lt;/span&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;span&gt;이 기능은 왜 필요한가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;플레이어에게 어떤 경험을 주려는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;개발자가 바로 구현할 만큼 구체적인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;결과를 플레이해서 검증할 수 있는가?&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/94</guid>
      <comments>https://think95592.tistory.com/94#entry94comment</comments>
      <pubDate>Wed, 12 Aug 2026 18:57:59 +0900</pubDate>
    </item>
    <item>
      <title>[Project 4조참치] 게임 기획 회의를 &amp;lsquo;결정&amp;rsquo;보다 &amp;lsquo;검증 가능한 탐색&amp;rsquo;으로 운영해보기</title>
      <link>https://think95592.tistory.com/93</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;784&quot; data-origin-height=&quot;662&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PhLId/dJMcagT5Z6H/1aPZkboxY6MjxJ8yTqe7y0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PhLId/dJMcagT5Z6H/1aPZkboxY6MjxJ8yTqe7y0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PhLId/dJMcagT5Z6H/1aPZkboxY6MjxJ8yTqe7y0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPhLId%2FdJMcagT5Z6H%2F1aPZkboxY6MjxJ8yTqe7y0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;784&quot; height=&quot;662&quot; data-origin-width=&quot;784&quot; data-origin-height=&quot;662&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;br /&gt;팀 프로젝트로 멀티플레이 게임을 만들기 전, 각자 만들고 싶은 게임을 제안하고 투표했다. 그 결과 표를 많이 받은 기획 세 개를 추렸지만, 지금 단계에서 곧바로 최종 기획 하나를 확정하는 것은 이르다고 느꼈다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;아직 개인 과제와 학습 기간이 남아 있고, 실제 제작은 다음 챕터에서 진행한다. 그래서 이번 팀 시간은 &amp;ldquo;최종 게임을 고르는 회의&amp;rdquo;보다, 팀이 어떤 멀티플레이 경험을 만들고 싶은지 함께 구체화하는 시간으로 운영해보려고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;운영 방식&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;각 기획 제안자가 먼저 아래 항목을 노란 스티키로 작성한다.&lt;/span&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;span&gt;타깃&amp;middot;상황&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;핵심 루프&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;차별점&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;최소 구현&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;폐기 기준&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;가장 큰 위험&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;다음 행동&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다른 팀원은 파란 스티키로 의견을 단다. 단순히 &amp;ldquo;좋다&amp;rdquo;, &amp;ldquo;재밌을 것 같다&amp;rdquo;가 아니라 다음 형식으로 적는다.&lt;/span&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;span&gt;[질문]&lt;/span&gt;&lt;span&gt; 아직 이해되지 않거나 구체화가 필요한 점&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;[반례]&lt;/span&gt;&lt;span&gt; 규칙이나 재미가 깨질 수 있는 상황&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;[보완]&lt;/span&gt;&lt;span&gt; 더 강하게 만들 수 있는 방향&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;[범위]&lt;/span&gt;&lt;span&gt; 첫 프로토타입에서는 빼거나 줄일 요소&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;회의에서 파란 의견을 확인한 뒤에는 세 가지로 처리한다.&lt;/span&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;span&gt;지금 정할 수 있는 내용은 초록 스티키로 합의한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;실제로 만들어 봐야 알 수 있는 내용은 빨간 스티키로 옮기고, 이후 검증할 질문으로 남긴다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;지금 중요하지 않은 내용은 보류한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;멀티플레이 기획에서 추가로 볼 것&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 프로젝트는 멀티플레이 게임을 만드는 것이 전제이므로, 각 기획에 아래 질문도 붙이기로 했다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 게임의 핵심 루프에서 플레이어들은 서로 어떤 방식으로 영향을 주는가?&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;협력, 경쟁, 역할 분담, 방해, 정보 비대칭, 공간 충돌, 관전과 리액션 중 어떤 상호작용이 핵심인지 적는다. 단순히 여러 명이 같은 게임을 하는 것이 아니라, 다른 플레이어의 행동 때문에 내 선택이나 상황이 달라지는 게임을 만들기 위해서다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;현재 단계의 목표&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;8월 24일까지는 개인 학습과 과제를 우선한다. 따라서 지금 팀 회의에서 목표로 삼는 것은 최종 기획 확정이 아니다.&lt;/span&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;span&gt;세 기획을 팀 전체가 이해할 수 있게 구체화하기&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;각 기획의 핵심 멀티플레이 상호작용 찾기&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;최소 구현과 가장 큰 위험 확인하기&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;다음 챕터에서 더 탐구할 주력 후보와 예비 후보 정하기&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 방식이 실제로 잘 작동할지는 아직 검증되지 않았다. 다만 말이 많은 사람의 의견만 남거나, 추상적인 기획을 투표만으로 확정하는 일을 줄일 수 있을 것 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;매일 한 시간의 팀 시간을 활용해 회의 기록을 남기고, 나중에는 이 방식이 실제로 기획 결정과 협업에 도움이 됐는지도 돌아볼 예정이다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/93</guid>
      <comments>https://think95592.tistory.com/93#entry93comment</comments>
      <pubDate>Tue, 11 Aug 2026 19:37:46 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal Engine 멀티플레이] 멀티플레이 핵심 개념 통합 복습</title>
      <link>https://think95592.tistory.com/92</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20260727_193925649.jpg&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;865&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIn5NG/dJMb99UUY11/KjuUd7ZZZHEntXD6zlkUd0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIn5NG/dJMb99UUY11/KjuUd7ZZZHEntXD6zlkUd0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIn5NG/dJMb99UUY11/KjuUd7ZZZHEntXD6zlkUd0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIn5NG%2FdJMb99UUY11%2FKjuUd7ZZZHEntXD6zlkUd0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;865&quot; data-filename=&quot;KakaoTalk_20260727_193925649.jpg&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;865&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;0. 가장 먼저 기억할 한 문장&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 행동의 &lt;b&gt;의도&lt;/b&gt;를 보내고, Ownership이 요청 경로를 정하며, 서버가 권위 있는 원본을 검증&amp;middot;변경한다. 지속 상태는 Property Replication으로, 특정 대상의 순간 피드백은 RPC로 전달하고, 각 클라이언트는 받은 결과를 UI와 로그로 표현한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 야구의 타자 추측 흐름으로 쓰면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Client A 입력
&amp;rarr; Client A가 소유한 PlayerController의 Server RPC
&amp;rarr; 서버 PlayerController
&amp;rarr; 서버 GameMode 검증&amp;middot;판정
&amp;rarr; 서버 원본 상태 변경
├─ GameState Property Replication &amp;rarr; Client A/B OnRep &amp;rarr; 공용 UI&amp;middot;로그
└─ PlayerController Client RPC &amp;rarr; 요청자인 Client A &amp;rarr; 개인 피드백&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;행동은 어느 PC에서 시작되는가?&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;Client A/B의 UI와 로그에서 무엇이 보여야 하는가?&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 네 개의 판단 축&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 실행 PC&lt;/h3&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;일반 함수는 호출한 현재 PC에서 실행된다.&lt;/li&gt;
&lt;li&gt;Client A가 로컬 UI 함수를 호출하면 Client A에서 실행된다.&lt;/li&gt;
&lt;li&gt;Client A가 유효한 Server RPC를 호출하면 RPC 본문은 서버에서 실행된다.&lt;/li&gt;
&lt;li&gt;서버가 A 소유 PlayerController의 Client RPC를 호출하면 Client A에서 실행된다.&lt;/li&gt;
&lt;li&gt;서버가 복제 프로퍼티를 변경하면 그 값은 관련 클라이언트로 전송되고 클라이언트의 OnRep가 실행된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;HasAuthority()&lt;/code&gt;는 이 액터의 현재 인스턴스가 권위 있는 서버 인스턴스인지 묻는다. &lt;code&gt;IsLocalController()&lt;/code&gt;는 이 PlayerController가 현재 컴퓨터의 로컬 플레이어 Controller인지 묻는다. 둘은 서로 바꿔 쓰는 조건이 아니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 원본 데이터&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원본 데이터는 최종 게임 결과를 결정할 때 신뢰하는 값이다. 클라이언트에도 같은 이름의 복제 변수가 있을 수 있지만, 그 값은 서버 원본의 복사본이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Client A의 GameState에 &lt;code&gt;CurrentTryCount == 1&lt;/code&gt;이 보이더라도 Client A가 그 값을 &lt;code&gt;2&lt;/code&gt;로 바꿔 서버의 시도 횟수를 증가시킬 수 있는 것은 아니다. 서버의 GameMode와 서버 GameState가 권위 있는 값을 변경해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 전달 방법&lt;/h3&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;/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;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;Client 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;상황에 맞는 RPC&lt;/td&gt;
&lt;td&gt;과거 사건을 늦은 접속자에게 재생할 필요가 없다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로컬에서만 필요한 표시&amp;middot;임시값&lt;/td&gt;
&lt;td&gt;로컬 함수&amp;middot;변수&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와 Property Replication을 함께 사용할 수 있다. 요청, 판정, 상태, 표현은 서로 다른 문제이기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.4 서버 검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server RPC가 서버에서 실행된다는 사실만으로 안전해지지 않는다. Server RPC의 인자는 클라이언트가 만든 요청이므로 서버가 다시 검사해야 한다.&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;역할&amp;middot;권한: 현재 이 행동을 할 수 있는 플레이어인가?&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;중복&amp;middot;경쟁: 같은 요청이 두 번 적용되거나 오래된 요청이 다음 단계에 적용되지 않는가?&lt;/li&gt;
&lt;li&gt;수명주기: 이미 종료&amp;middot;이탈&amp;middot;리셋 중인 객체와 라운드에 대한 요청은 아닌가?&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 프레임워크 클래스의 존재 범위와 책임&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dedicated Server 1개와 Client A/B를 기준으로 본다.&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;right&quot;&gt;서버&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;소유 Client A&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;다른 Client B&lt;/th&gt;
&lt;th&gt;적합한 책임&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GameMode&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;없음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;없음&lt;/td&gt;
&lt;td&gt;비밀 데이터, 규칙, 승패, 서버 상태 전이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GameState&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td&gt;모두가 알아야 하는 현재 경기 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A의 PlayerState&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td&gt;A의 역할&amp;middot;팀&amp;middot;점수 같은 플레이어 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A의 PlayerController&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;보통 없음&lt;/td&gt;
&lt;td&gt;A의 Server RPC 입구, A 전용 Client RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pawn/Character&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;서버와 관련 클라이언트에 복제 가능&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;있음&lt;/td&gt;
&lt;td&gt;월드 안 플레이어 행동과 표현&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UMG UI&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;없음&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;A의 로컬 UI&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;B의 로컬 UI&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;GameMode&lt;/h3&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;UI에서 읽어야 하는 공개 상태는 GameState나 PlayerState로 옮겨야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GameState&lt;/h3&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;모든 클라이언트에 보인다고 해서 모든 클라이언트가 소유하는 Server RPC 창구인 것은 아니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PlayerState&lt;/h3&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;개인에게만 보여야 하는 비밀 데이터를 무조건 PlayerState에 복제하면 안 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PlayerController&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버에는 각 플레이어의 PlayerController가 있다.&lt;/li&gt;
&lt;li&gt;소유 클라이언트에는 자신의 PlayerController가 있다.&lt;/li&gt;
&lt;li&gt;다른 클라이언트의 PlayerController는 보통 없다.&lt;/li&gt;
&lt;li&gt;그래서 Client A의 요청 입구와 Client A 전용 응답 출구로 적합하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;UI&lt;/h3&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;Dedicated Server에는 UI를 생성하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. &lt;code&gt;MYBaseball&lt;/code&gt; 타자 추측을 처음부터 끝까지 추적하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 로컬 입력&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위젯의 &lt;code&gt;SubmitSelectedZones()&lt;/code&gt;는 로컬에서 선택된 타일을 확인하고 현재 복제된 역할&amp;middot;단계에 따라 요청 함수를 선택한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;타일 OnClicked
&amp;rarr; 위젯의 로컬 SelectedZones 변경
&amp;rarr; 선택 완료 버튼
&amp;rarr; SubmitSelectedZones()
&amp;rarr; PlayerController::SubmitGuessZones()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 &lt;code&gt;SelectedZones&lt;/code&gt;는 아직 승인된 게임 상태가 아니다. 서버에 보내고 싶은 입력 초안이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트의 역할&amp;middot;단계 분기는 잘못된 버튼 사용을 줄이는 UX 방어다. 조작된 클라이언트가 RPC를 직접 호출할 수 있으므로 최종 권한 판정에는 사용할 수 없다.&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;MYBaseball/Source/MYBaseball/UI/MYBaseballGameWidget.cpp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UMYBaseballGameWidget::SubmitSelectedZones()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 소유한 PlayerController에서 Server RPC 호출&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AMYBaseballPlayerController::SubmitGuessZones()&lt;/code&gt;는 &lt;code&gt;IsLocalController()&lt;/code&gt;를 확인한 뒤 &lt;code&gt;ServerSubmitGuessZones()&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;ExpectedTryCount&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ExpectedTryCount&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;code&gt;MYBaseball/Source/MYBaseball/MYBaseballPlayerController.h&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AMYBaseballPlayerController::ServerSubmitGuessZones&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MYBaseball/Source/MYBaseball/MYBaseballPlayerController.cpp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AMYBaseballPlayerController::SubmitGuessZones()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 서버 PlayerController에서 RPC 본문 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client A가 호출했지만 &lt;code&gt;_Implementation()&lt;/code&gt; 본문은 서버에 있는 A의 PlayerController에서 실행된다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;Client A: ServerSubmitGuessZones(...) 호출
                   &amp;darr; 네트워크
Server: A의 PlayerController에서 ServerSubmitGuessZones_Implementation(...) 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 PlayerController는 &lt;code&gt;GetAuthGameMode&amp;lt;AMYBaseballGameMode&amp;gt;()&lt;/code&gt;로 서버 전용 GameMode를 얻어 &lt;code&gt;TrySubmitGuessZones()&lt;/code&gt;에 요청을 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GameMode를 얻지 못했을 때는 서버가 요청자에게 개인 시스템 오류를 돌려준다. 클라이언트가 직접 GameMode를 찾는 구조가 아니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.4 서버 GameMode 검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 &lt;code&gt;TrySubmitGuessZones()&lt;/code&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;요청 PlayerController가 유효한가?&lt;/li&gt;
&lt;li&gt;현재 단계가 &lt;code&gt;BatterGuessing&lt;/code&gt;인가?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ExpectedTryCount&lt;/code&gt;가 서버 &lt;code&gt;CurrentBatterTryCount&lt;/code&gt;와 같은가?&lt;/li&gt;
&lt;li&gt;서버 기준 제한시간이 남아 있는가?&lt;/li&gt;
&lt;li&gt;요청자의 서버 PlayerState 역할이 &lt;code&gt;Batter&lt;/code&gt;인가?&lt;/li&gt;
&lt;li&gt;서버의 비밀 구역이 준비되어 있는가?&lt;/li&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;li&gt;서버 기준 시도 횟수가 세 번 미만인가?&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;code&gt;MYBaseball/Source/MYBaseball/MYBaseballGameMode.cpp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AMYBaseballGameMode::TrySubmitGuessZones()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.5 서버 원본 상태 변경&lt;/h3&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;SecretZones&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버 GameMode&lt;/td&gt;
&lt;td&gt;복제하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;판정용 &lt;code&gt;CurrentBatterTryCount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버 GameMode&lt;/td&gt;
&lt;td&gt;화면용 현재값을 GameState에 반영&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;현재 시도&amp;middot;적중 수&amp;middot;맞힌 구역&lt;/td&gt;
&lt;td&gt;서버 GameState&lt;/td&gt;
&lt;td&gt;Property Replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과&amp;middot;아웃&lt;/td&gt;
&lt;td&gt;서버 GameState&lt;/td&gt;
&lt;td&gt;Property Replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이닝 단계&lt;/td&gt;
&lt;td&gt;서버 GameState&lt;/td&gt;
&lt;td&gt;Property Replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;점수&amp;middot;주자&amp;middot;경기 결과&lt;/td&gt;
&lt;td&gt;서버 GameState&lt;/td&gt;
&lt;td&gt;Property Replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;역할&amp;middot;고정 팀&lt;/td&gt;
&lt;td&gt;서버 PlayerState&lt;/td&gt;
&lt;td&gt;Property Replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;공용 게임 로그&amp;middot;채팅&lt;/td&gt;
&lt;td&gt;서버 GameState&lt;/td&gt;
&lt;td&gt;제한된 배열로 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;요청자 PlayerController의 Client RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비밀 구역은 클라이언트 UI에 필요하지 않으며 공개하면 게임 규칙이 깨진다. 반면 현재 단계와 점수는 모든 참가자와 늦은 접속자가 알아야 하므로 GameState에 둔다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.6 Property Replication으로 Client A/B 갱신&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GameState의 공개 상태는 &lt;code&gt;ReplicatedUsing&lt;/code&gt;으로 선언되어 있고 &lt;code&gt;GetLifetimeReplicatedProps()&lt;/code&gt;에서 &lt;code&gt;DOREPLIFETIME&lt;/code&gt;으로 등록된다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;서버 GameState 값 변경
&amp;rarr; 복제 시스템이 관련 클라이언트에 최신 값 전송
&amp;rarr; Client A GameState 복사본 변경
&amp;rarr; Client A OnRep
&amp;rarr; Client B GameState 복사본 변경
&amp;rarr; Client B OnRep&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Property Replication은 RPC처럼 프로퍼티 변경 줄에서 즉시 원격 함수를 호출하는 구조가 아니다. 네트워크 업데이트 시점에 최신 상태가 전달되며, 중간 값이 합쳐질 수도 있다. 중요한 것은 과거의 모든 변경 과정이 아니라 수신자가 최종적으로 현재 상태에 수렴하는 것이다.&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;MYBaseball/Source/MYBaseball/Game/MYBaseballGameState.h&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MYBaseball/Source/MYBaseball/Game/MYBaseballGameState.cpp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AMYBaseballGameState::GetLifetimeReplicatedProps()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.7 Client RPC로 요청자 개인 피드백 전달&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 거부 이유는 요청한 플레이어에게만 필요하다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;서버 GameMode 검증 실패
&amp;rarr; 서버 A PlayerController::SendPrivateSystemLog()
&amp;rarr; ClientReceivePrivateSystemLog() Client RPC
&amp;rarr; Client A의 로컬 로그 UI&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client B와 관전자에게는 전달하지 않는다. 과거의 개인 오류를 늦게 접속한 플레이어가 받을 필요도 없으므로 Property Replication보다 Client RPC가 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 승인된 공용 판정 상태는 A만의 피드백으로 끝내면 안 된다. Client B와 늦은 접속자도 현재 상태를 알아야 하므로 GameState 복제를 함께 사용한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. RPC와 Property Replication을 구분하는 기준&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Server RPC가 적합한 것&lt;/h3&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;공통점은 클라이언트가 서버에 &amp;ldquo;이 행동을 하고 싶다&amp;rdquo;고 알리는 순간 요청이라는 것이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Property Replication이 적합한 것&lt;/h3&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;p data-ke-size=&quot;size16&quot;&gt;공통점은 과거에 무슨 일이 있었는지보다 &lt;b&gt;지금 값이 무엇인지&lt;/b&gt;가 중요하다는 것이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Client RPC가 적합한 것&lt;/h3&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;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Multicast가 적합할 수 있는 것&lt;/h3&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;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Multicast는 지속 상태의 대체물이 아니다. 늦게 접속한 클라이언트는 과거 Multicast를 받지 못한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로컬 처리가 적합한 것&lt;/h3&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;서버 판정에 영향을 주지 않는 UI 애니메이션&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. OnRep와 UI 수명주기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OnRep의 역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OnRep는 클라이언트의 복제값이 갱신되었을 때 표현 계층에 변화를 알리는 연결점이다. 현재 GameState는 OnRep에서 델리게이트를 Broadcast하고 위젯이 그 델리게이트를 받아 다시 그린다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;서버 Setter
&amp;rarr; 서버 값 변경
&amp;rarr; 서버에서는 Notify 직접 호출
&amp;rarr; Property Replication
&amp;rarr; 클라이언트 OnRep
&amp;rarr; 클라이언트 Notify/Delegate
&amp;rarr; 로컬 위젯 Refresh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 프로퍼티를 직접 변경했을 때 서버의 OnRep 자동 호출에 의존하지 않는다. 현재 코드는 서버 Setter에서 Notify를 직접 호출하고 클라이언트 OnRep에서도 같은 Notify를 호출한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;UI가 늦게 만들어질 수 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 순서가 가능하다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;복제값 도착
&amp;rarr; OnRep 실행
&amp;rarr; 위젯이 아직 없음
&amp;rarr; 위젯 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OnRep 이벤트만 구독하면 초기 화면이 비어 있을 수 있다. 안전한 패턴은 두 가지를 함께 사용하는 것이다.&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;위젯 생성&amp;middot;바인딩 직후 Getter로 현재 상태를 읽어 즉시 한 번 갱신한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 PlayerController는 &lt;code&gt;BeginPlay()&lt;/code&gt;와 &lt;code&gt;OnRep_PlayerState()&lt;/code&gt;에서 위젯 생성 및 상태 바인딩을 시도한다. 개인 Client RPC가 위젯보다 먼저 도착하는 경우를 위해 최대 20개의 개인 시스템 로그 대기 큐도 사용한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;흔한 OnRep 오해&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OnRep가 게임 규칙을 판정해야 한다 &amp;rarr; OnRep는 클라이언트 표현 갱신에 집중한다.&lt;/li&gt;
&lt;li&gt;OnRep는 모든 중간 값을 한 번씩 보장한다 &amp;rarr; Property Replication은 현재 상태 수렴이 목적이다.&lt;/li&gt;
&lt;li&gt;OnRep가 실행되면 UI가 반드시 존재한다 &amp;rarr; 객체 생성&amp;middot;복제&amp;middot;자산 로딩 순서는 다를 수 있다.&lt;/li&gt;
&lt;li&gt;서버와 클라이언트의 OnRep 동작을 완전히 같다고 가정한다 &amp;rarr; 서버는 Setter에서 필요한 Notify를 명시적으로 호출하는 편이 안전하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 접속과 이탈을 같은 체계로 보기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;접속&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 GameMode의 &lt;code&gt;PostLogin()&lt;/code&gt;에서 새 참가자를 등록하고 역할과 팀을 배정한다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;클라이언트 접속
&amp;rarr; 서버 PlayerController/PlayerState 생성
&amp;rarr; 서버 GameMode::PostLogin()
&amp;rarr; 서버 PlayerState 역할&amp;middot;팀 변경
&amp;rarr; PlayerState Property Replication
&amp;rarr; 클라이언트 OnRep
&amp;rarr; 로컬 UI 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;늦게 접속한 클라이언트에게 필요한 현재 상태는 Property Replication으로 복구한다.&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;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;h3 data-ke-size=&quot;size23&quot;&gt;이탈&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 GameMode의 &lt;code&gt;Logout()&lt;/code&gt;에서 참가자와 역할을 정리하고 경기 조건을 다시 평가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI에서 플레이어를 숨기는 것만으로 서버 참가자 목록이 정리되는 것은 아니다. 이탈한 플레이어가 서버의 생존자&amp;middot;턴&amp;middot;준비 목록에 남으면 다음과 같은 버그가 생긴다.&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;접속&amp;middot;이탈 정책도 원본은 서버가 가져야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 복제 비용과 정확성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;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;GameState가 그 마감 시각을 복제한다.&lt;/li&gt;
&lt;li&gt;각 클라이언트는 서버 시간과 마감 시각의 차이를 로컬 UI에 표시한다.&lt;/li&gt;
&lt;li&gt;실제 시간 초과 판정은 서버가 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표시는 로컬에서 부드럽게 계산하면서 판정 권위는 서버에 남고, 매초 복제 트래픽도 줄어든다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;현재 프로젝트의 공용 로그와 채팅&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;게임 로그는 최근 30개를 GameState 배열로 복제한다.&lt;/li&gt;
&lt;li&gt;채팅은 최근 50개를 GameState 배열로 복제한다.&lt;/li&gt;
&lt;li&gt;늦은 접속자가 현재 기록을 받아야 하므로 순간 Multicast보다 상태 복제가 맞다.&lt;/li&gt;
&lt;li&gt;규모가 커지면 배열 전체 갱신 비용을 측정하고 증분 복제를 검토한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;ForceNetUpdate()&lt;/code&gt;의 의미&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForceNetUpdate()&lt;/code&gt;는 원격 UI를 현재 코드 줄에서 동기식으로 즉시 바꾸는 함수가 아니다. 다음 네트워크 업데이트를 앞당기도록 요청한다. 지연과 패킷 전달, 여러 프로퍼티의 갱신 순서는 여전히 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 UI는 여러 OnRep의 고정 순서를 가정하기보다 갱신 신호를 받았을 때 필요한 현재 상태 묶음을 다시 읽는 편이 안전하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Relevancy, Priority, Dormancy의 위치&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Relevancy는 이 연결에 이 액터를 보낼 필요가 있는지 판단한다.&lt;/li&gt;
&lt;li&gt;Priority는 대역폭이 부족할 때 어떤 관련 액터를 먼저 보낼지 조절한다.&lt;/li&gt;
&lt;li&gt;Dormancy는 한동안 변하지 않는 액터의 반복 확인 비용을 줄인다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FlushNetDormancy()&lt;/code&gt;는 휴면 중 상태가 바뀐 액터를 다시 전송 대상으로 깨운다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Priority를 높인다고 관련성이 없는 액터가 보이거나 휴면 상태가 자동 해제되는 것은 아니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 패키징과 Dedicated Server가 최종 검증인 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PIE에서는 서버와 여러 클라이언트가 한 에디터 프로세스 안에 섞여 보여 잘못된 실행 위치가 가려질 수 있다. 패키징된 Dedicated Server와 Client는 프로세스가 분리되므로 구조적 오류가 더 명확하게 드러난다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패키징 환경에서 드러나는 문제&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트 코드가 GameMode에 접근함&lt;/li&gt;
&lt;li&gt;Dedicated Server가 UI를 만들려고 함&lt;/li&gt;
&lt;li&gt;UI Blueprint가 cook에서 빠져 위젯 로드 실패&lt;/li&gt;
&lt;li&gt;클라이언트와 서버가 서로 다른 기본 맵을 실행함&lt;/li&gt;
&lt;li&gt;주소&amp;middot;포트&amp;middot;방화벽 문제로 연결 자체가 실패함&lt;/li&gt;
&lt;li&gt;Client/Server Target 또는 엔진 빌드 환경이 맞지 않음&lt;/li&gt;
&lt;li&gt;로그에 NetMode와 요청자가 없어 실행 위치를 구분할 수 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;현재 &lt;code&gt;MYBaseball&lt;/code&gt;의 배포 결정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Lvl_UIOnly&lt;/code&gt;를 Game/Server/Editor 기본 맵과 cook 대상에 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/Game/UI&lt;/code&gt;를 항상 cook하여 &lt;code&gt;WBP_BaseballGame&lt;/code&gt; 누락을 방지한다.&lt;/li&gt;
&lt;li&gt;렌더링이 필요 없는 Dedicated Server도 같은 빈 게임 월드를 사용할 수 있다.&lt;/li&gt;
&lt;li&gt;현재 기록상 런처 엔진은 Client/Server Target 제약이 있었다.&lt;/li&gt;
&lt;li&gt;한글 프로젝트 경로 때문에 MSVC 중간 파일 생성 실패가 있었으므로 영문 경로의 소스 빌드 환경 검증이 남아 있다.&lt;/li&gt;
&lt;/ul&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;Build/Cook/Stage/Pak/Archive가 성공했는가?&lt;/li&gt;
&lt;li&gt;Dedicated Server가 올바른 맵과 GameMode로 실행되는가?&lt;/li&gt;
&lt;li&gt;Client A/B가 접속해 실제 RPC&amp;middot;복제&amp;middot;UI 흐름을 통과하는가?&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;h3 data-ke-size=&quot;size23&quot;&gt;권장 로그 문맥&lt;/h3&gt;
&lt;pre class=&quot;gherkin&quot;&gt;&lt;code&gt;기능명 | NetMode | 요청자 | 함수명 | 단계 | 요청 번호 | 서버 상태 | 승인/거부 이유&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;[Guess][ClientA][Client] SubmitGuessZones ExpectedTry=0
[Guess][PlayerA][DedicatedServer] ServerSubmitGuessZones ExpectedTry=0
[Guess][PlayerA][DedicatedServer] Accepted Try=1 Hit=2 Result=None
[Guess][ClientA][Client] OnRep_AtBatStatus Try=1 Hit=2
[Guess][ClientB][Client] OnRep_AtBatStatus Try=1 Hit=2
[Guess][ClientA][Client] PrivateFeedback Accepted&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PC별 기대 결과&lt;/h3&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;Dedicated Server&lt;/th&gt;
&lt;th&gt;Client A&lt;/th&gt;
&lt;th&gt;Client B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&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;로컬 Submit 함수&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;Server RPC 본문&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;GameMode 판정&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;GameMode 없음&lt;/td&gt;
&lt;td&gt;GameMode 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GameState 권위 변경&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;GameState OnRep&lt;/td&gt;
&lt;td&gt;서버 Setter에서 별도 Notify&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A 전용 Client RPC&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;UI 갱신&lt;/td&gt;
&lt;td&gt;UI 없음&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;증상에서 원인 좁히기&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;버튼을 눌러도 서버 로그가 없다&lt;/h4&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;IsLocalController()&lt;/code&gt; 조건을 통과했는가?&lt;/li&gt;
&lt;li&gt;Server RPC를 PlayerController 같은 소유 액터에서 호출했는가?&lt;/li&gt;
&lt;li&gt;해당 액터와 연결이 유효한가?&lt;/li&gt;
&lt;li&gt;서버 프로세스 로그를 보고 있는가?&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;서버 판정 로그는 있는데 Client A/B UI가 안 바뀐다&lt;/h4&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;서버가 실제 GameState 프로퍼티를 변경했는가?&lt;/li&gt;
&lt;li&gt;프로퍼티에 &lt;code&gt;ReplicatedUsing&lt;/code&gt;이 있는가?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DOREPLIFETIME&lt;/code&gt; 등록이 있는가?&lt;/li&gt;
&lt;li&gt;액터가 복제되는가?&lt;/li&gt;
&lt;li&gt;OnRep가 실행됐는가?&lt;/li&gt;
&lt;li&gt;위젯이 델리게이트를 구독했는가?&lt;/li&gt;
&lt;li&gt;위젯 생성 직후 Getter 초기화를 했는가?&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Client A만 바뀌고 Client B는 안 바뀐다&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공용 상태를 Client RPC로만 보낸 것은 아닌가?&lt;/li&gt;
&lt;li&gt;GameState 대신 요청자 로컬 UI만 직접 바꾼 것은 아닌가?&lt;/li&gt;
&lt;li&gt;B에게 해당 액터가 관련성이 있는가?&lt;/li&gt;
&lt;li&gt;B의 OnRep와 바인딩 로그가 있는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;늦게 접속한 Client C가 현재 상태를 모른다&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 상태를 과거 Multicast에만 의존한 것은 아닌가?&lt;/li&gt;
&lt;li&gt;공개 상태가 GameState/PlayerState의 복제 프로퍼티인가?&lt;/li&gt;
&lt;li&gt;초기 UI가 현재 Getter 값을 읽는가?&lt;/li&gt;
&lt;li&gt;휴면 액터라면 변경 시 휴면을 해제했는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;제한시간 직전 요청이 다음 시도를 소비한다&lt;/h4&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;시간 초과 처리와 RPC 처리가 같은 서버 원본 상태를 기준으로 한 번만 전이되는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;이탈 후 경기가 끝나지 않는다&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Logout()&lt;/code&gt;에서 서버 참가자 목록을 제거했는가?&lt;/li&gt;
&lt;li&gt;제거 후 종료 조건을 다시 평가했는가?&lt;/li&gt;
&lt;li&gt;이탈한 PlayerState나 Controller 포인터를 목록에 유지하고 있지 않은가?&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 자주 틀리는 정신 모형 교정&lt;/h2&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server RPC면 클라이언트 값을 신뢰해도 된다.&lt;/td&gt;
&lt;td&gt;Server RPC 인자는 요청일 뿐이며 서버가 모두 재검증한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replicated 액터면 아무 클라이언트나 Server RPC를 호출할 수 있다.&lt;/td&gt;
&lt;td&gt;클라이언트&amp;rarr;서버 RPC에는 Ownership 경로가 필요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GameState는 모두에게 있으므로 클라이언트 요청 창구다.&lt;/td&gt;
&lt;td&gt;GameState의 가시성과 클라이언트 소유권은 다르다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI에서 버튼을 막았으니 서버 검증은 필요 없다.&lt;/td&gt;
&lt;td&gt;조작된 클라이언트는 UI를 우회할 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client RPC로 현재 상태를 보내면 복제와 같다.&lt;/td&gt;
&lt;td&gt;늦은 접속자는 과거 Client RPC를 받지 못한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multicast를 Reliable로 하면 영구 상태가 된다.&lt;/td&gt;
&lt;td&gt;Reliable도 과거 사건을 늦은 접속자에게 재생하지 않는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OnRep는 서버와 클라이언트에서 항상 같은 방식으로 호출된다.&lt;/td&gt;
&lt;td&gt;서버는 Setter에서 로컬 Notify가 별도로 필요할 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OnRep가 실행되면 위젯이 준비되어 있다.&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;서버 원본에는 영향이 없고 다음 복제에서 덮일 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ForceNetUpdate()&lt;/code&gt;는 즉시 동기식 UI 갱신이다.&lt;/td&gt;
&lt;td&gt;네트워크 업데이트를 앞당길 뿐 지연과 전달 순서는 남는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PIE에서 되면 Dedicated Server 패키지도 된다.&lt;/td&gt;
&lt;td&gt;맵, cook, Target, 연결, 방화벽, 프로세스 분리가 별도 변수다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 현재 &lt;code&gt;MYBaseball&lt;/code&gt;의 확인 상태&lt;/h2&gt;
&lt;p&gt;&lt;iframe src=&quot;https://www.youtube.com/embed/NwT8_uOhSbs?si=b7BPLdqsKNSTwbsE&quot; width=&quot;560&quot; height=&quot;315&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드에서 확인된 것&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위젯에서 PlayerController로 입력 전달&lt;/li&gt;
&lt;li&gt;소유 PlayerController의 Reliable Server RPC&lt;/li&gt;
&lt;li&gt;서버 GameMode의 역할&amp;middot;단계&amp;middot;값&amp;middot;시간&amp;middot;시도 검증&lt;/li&gt;
&lt;li&gt;GameMode의 비밀 구역 서버 전용 보관&lt;/li&gt;
&lt;li&gt;GameState의 경기 상태 Property Replication&lt;/li&gt;
&lt;li&gt;GameState OnRep와 델리게이트 기반 UI 갱신&lt;/li&gt;
&lt;li&gt;PlayerState 역할&amp;middot;팀 복제&lt;/li&gt;
&lt;li&gt;요청자 전용 개인 시스템 로그 Client RPC&lt;/li&gt;
&lt;li&gt;UI 생성 전 개인 RPC를 위한 대기 큐&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PostLogin()&lt;/code&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;li&gt;UI 전용 기본 맵과 명시적 cook 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;저장된 실행 로그에서 확인된 것&lt;/h3&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;여러 시도와 아웃&amp;middot;홈런 결과&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. 마지막 자가 설명 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타자 추측 제출을 다음 수준으로 설명할 수 있어야 한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client A의 로컬 UI는 추측 의도만 만들고, A가 소유한 PlayerController의 Server RPC를 호출한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC 본문은 서버의 A PlayerController에서 실행되고 서버 전용 GameMode로 요청을 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GameMode는 요청자의 역할, 현재 단계, 서버 마감시간, 시도 번호, 입력 개수&amp;middot;범위&amp;middot;중복, 남은 기회를 검사한 뒤에만 비밀 구역과 비교하고 권위 상태를 변경한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공용 현재 상태는 서버 GameState에서 복제되어 Client A/B의 OnRep와 UI를 갱신한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청자만 알아야 하는 거부 이유는 A의 PlayerController Client RPC로 보낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;늦은 접속자는 과거 RPC가 아니라 GameState와 PlayerState의 현재 복제값으로 화면을 복구한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dedicated Server에는 UI가 없으며, 서버 판정 로그와 Client A/B의 OnRep&amp;middot;개인 피드백 로그를 분리해 전체 흐름을 검증한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설명에서 함수 이름이 바뀌어도 &lt;b&gt;실행 PC&amp;middot;원본 데이터&amp;middot;전달 방법&amp;middot;검증 조건&lt;/b&gt;을 유지할 수 있으면 다른 멀티플레이 기능에도 적용할 수 있다.&lt;/p&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/92</guid>
      <comments>https://think95592.tistory.com/92#entry92comment</comments>
      <pubDate>Mon, 10 Aug 2026 20:44:02 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal Engine 멀티플레이]  패키징과 Dedicated Server 배포 준비</title>
      <link>https://think95592.tistory.com/91</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘의 목표&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;에디터의 PIE에서 실행하는 것과 실제 패키징된 게임의 차이를 이해하고, 일반 Game 빌드와 Dedicated Server 배포에 필요한 준비 사항을 점검했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;추가로 서버가 잘못된 입력을 거부했을 때, 요청한 플레이어에게만 오류를 보여주는 Client RPC 시스템 로그도 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. PIE와 패키징 실행의 차이&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;PIE는 Unreal Editor 내부에서 빠르게 게임을 시험하는 개발 환경이다. 에디터가 콘텐츠와 모듈을 직접 제공하므로, 패키징에서 누락될 자산이나 실행 환경 문제를 발견하지 못할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;반면 패키징 실행은 다음 과정을 거친 독립 프로그램이다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;Build: C++ 코드와 모듈 컴파일&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Cook: 맵, Blueprint, 이미지 등의 자산을 플랫폼용으로 변환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Stage: 실행에 필요한 파일을 임시 배포 폴더에 모음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Package: 실행 파일과 Pak 파일로 묶음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Archive: 최종 배포 폴더로 복사&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 &amp;ldquo;PIE에서 된다&amp;rdquo;는 것이 &amp;ldquo;패키지에서도 된다&amp;rdquo;는 뜻은 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. Unreal의 Build Target&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Unreal에는 실행 목적에 따른 Target이 있다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;Editor: Unreal Editor에서 프로젝트를 실행하기 위한 빌드&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Game: 일반 독립 실행 게임&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Client: 서버 기능을 제외한 전용 클라이언트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Server: 렌더링과 로컬 플레이어가 없는 Dedicated Server&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 프로젝트에는 다음 Target을 준비했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;MYBaseball.Target.cs
MYBaseballEditor.Target.cs
MYBaseballClient.Target.cs
MYBaseballServer.Target.cs&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;일반 Game 빌드는 Listen Server와 클라이언트 역할을 모두 수행할 수 있다. 하지만 진짜 Dedicated Server 실행 파일을 만들려면 Server Target 빌드가 필요하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 설치된 런처판 UE 5.7에서는 Client/Server Target 빌드가 거부됐다. Epic의 공식 Dedicated Server 문서도 소스 빌드 엔진을 전제로 안내한다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://dev.epicgames.com/documentation/unreal-engine/setting-up-dedicated-servers-in-unreal-engine&quot;&gt;&lt;span&gt;Epic 공식 Dedicated Server 문서&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dev.epicgames.com/documentation/unreal-engine/packaging-your-project&quot;&gt;&lt;span&gt;Epic 공식 패키징 문서&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. UI 전용 게임의 맵 설정&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 프로젝트는 캐릭터나 월드 렌더링 없이 UI만 사용한다. 따라서 빈 맵인 &lt;/span&gt;&lt;span&gt;Lvl_UIOnly&lt;/span&gt;&lt;span&gt;를 클라이언트와 서버의 기본 맵으로 지정했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;[/Script/EngineSettings.GameMapsSettings]
GameDefaultMap=/Game/UI/Lvl_UIOnly.Lvl_UIOnly
ServerDefaultMap=/Game/UI/Lvl_UIOnly.Lvl_UIOnly
EditorStartupMap=/Game/UI/Lvl_UIOnly.Lvl_UIOnly&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;패키징할 맵과 UI 폴더도 명시했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;[/Script/UnrealEd.ProjectPackagingSettings]
+MapsToCook=(FilePath=&quot;/Game/UI/Lvl_UIOnly&quot;)
+DirectoriesToAlwaysCook=(Path=&quot;/Game/UI&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;처음에는 &lt;/span&gt;&lt;span&gt;WBP_BaseballGame&lt;/span&gt;&lt;span&gt;이 C++의 Soft Reference로만 연결되어 패키지에서 누락됐다. UI 폴더를 항상 Cook하도록 설정한 뒤 Blueprint UI가 정상적으로 포함됐다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;4. 일반 Game 빌드로 멀티플레이 테스트&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Dedicated Server가 없어도 일반 Game 빌드를 두 개 실행해 기본 네트워크 흐름을 시험할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;첫 번째 게임은 Listen Server로 실행한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;.\MYBaseball.exe &quot;/Game/UI/Lvl_UIOnly?listen&quot; -log&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 번째 게임은 로컬 서버에 접속한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;.\MYBaseball.exe &quot;127.0.0.1:7777&quot; -log&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;여기서 &lt;/span&gt;&lt;span&gt;127.0.0.1&lt;/span&gt;&lt;span&gt;은 현재 컴퓨터 자신을 의미하고, &lt;/span&gt;&lt;span&gt;7777&lt;/span&gt;&lt;span&gt;은 Unreal 게임 서버의 기본 포트다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Listen Server 테스트에서는 두 플레이어의 역할 배정, Server RPC, 상태 복제, Client RPC 등을 확인할 수 있다. 다만 렌더링 없는 전용 서버 환경 자체를 검증하는 것은 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;5. UDP와 RDP의 차이&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;처음에는 UDP와 RDP라는 용어가 헷갈렸다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;UDP: 게임 데이터가 오가는 통신 방식&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;7777/UDP: Unreal 게임 클라이언트가 서버에 접속하는 기본 포트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;RDP: Windows 서버 화면에 원격 접속하기 위한 Remote Desktop Protocol&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;3389/TCP: RDP가 주로 사용하는 포트&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, RDP는 서버 관리용이고 UDP 7777은 실제 게임 접속용이다. 게임 클라이언트가 RDP 포트로 접속하는 것이 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;6. 요청자에게만 보이는 Client RPC 시스템 로그&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;잘못된 입력을 공용 GameState 로그에 기록하면 상대 플레이어와 관전자에게도 보인다. 하지만 입력 거부 사유는 요청한 플레이어에게만 보여야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 다음 흐름으로 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;로컬 UI 입력
&amp;rarr; 소유 PlayerController의 Server RPC
&amp;rarr; 서버 GameMode 검증
&amp;rarr; 거부 시 같은 PlayerController의 Client RPC
&amp;rarr; 요청자 GameLogText에만 [시스템] 로그 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Client RPC는 다음과 같이 선언했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void ClientReceivePrivateSystemLog(const FString&amp;amp; Message);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버는 요청을 거부할 때 해당 PlayerController로만 메시지를 보낸다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;if (!bAccepted)
{
    SendPrivateSystemLog(ErrorMessage);
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트는 받은 메시지를 로컬 배열에 저장한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;PrivateSystemLogEntries.Add(
    FString::Printf(TEXT(&quot;[시스템] %s&quot;), *SanitizedMessage));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;화면의 &lt;/span&gt;&lt;span&gt;GameLogText&lt;/span&gt;&lt;span&gt;에는 공용 경기 로그와 로컬 개인 로그를 합쳐 표시한다. 개인 로그 배열은 복제하지 않으므로 상대 플레이어의 화면에는 나타나지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 시스템 로그는 다음과 같이 한글로 표시된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;[시스템] 구역은 3개까지만 선택할 수 있습니다.
[시스템] 현재 단계에서는 타자가 추측을 제출할 수 없습니다.
[시스템] 제한 시간이 끝난 뒤 추측이 도착했습니다.
[시스템] 채팅 메시지는 120자까지 입력할 수 있습니다.&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;7. 패키징 과정에서 발견한 문제&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;한글 프로젝트 경로&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;프로젝트의 상위 경로에 한글이 포함되어 MSVC가 중간 컴파일 파일을 만들지 못했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;Cannot open compiler intermediate file
Invalid argument&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;영문 임시 경로로 복사하자 같은 코드가 정상적으로 컴파일됐다. 전용 서버 작업 전 프로젝트를 &lt;/span&gt;&lt;span&gt;D:\MYBaseball&lt;/span&gt;&lt;span&gt; 같은 영문 경로로 옮길 필요가 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;런처 엔진의 Target 제한&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;일반 Editor와 Game Target은 성공했지만 Client와 Server Target은 다음 메시지와 함께 실패했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Client targets are not currently supported from this engine distribution.
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;&lt;span&gt;따라서 Dedicated Server 단계에서는 UE 소스 빌드 엔진을 준비해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;Soft Reference 자산 누락&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;첫 패키지에서는 &lt;/span&gt;&lt;span&gt;WBP_BaseballGame&lt;/span&gt;&lt;span&gt;을 찾을 수 없다는 경고가 발생했다. UI 폴더를 &lt;/span&gt;&lt;span&gt;DirectoriesToAlwaysCook&lt;/span&gt;&lt;span&gt;에 추가한 후 해결됐다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;8. 현재까지 검증한 내용&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;UE 5.7 Editor Target 컴파일&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;일반 Game Target 컴파일&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Windows Build, Cook, Stage, Pak, Archive&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;패키지에서 &lt;/span&gt;&lt;span&gt;Lvl_UIOnly&lt;/span&gt;&lt;span&gt; 로드&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;MYBaseballGameMode&lt;/span&gt;&lt;span&gt; 적용&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Blueprint UI 포함&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Client RPC 개인 시스템 로그 구현&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;시스템 로그 한글화&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;아직 U8 전체를 통과한 것은 아니다. 일반 Game 패키징은 완료했지만 Dedicated Server와 외부 접속 시험이 남아 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;9. 남은 작업&lt;/span&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;프로젝트를 영문 경로로 이동&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;UE 소스 빌드 엔진 준비&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Client/Server Target 빌드&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Dedicated Server와 Client A/B 실행&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;개인 시스템 로그가 요청자에게만 보이는지 확인&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;외부 네트워크 또는 클라우드 VM 접속&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;주소, 포트, 방화벽, 프로세스, 맵, 로그 순서로 접속 실패 진단&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;테스트 후 인스턴스 중지&amp;middot;삭제와 비용 확인&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;팀원이 따라 할 수 있는 배포 문서 작성&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘의 핵심 정리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;PIE 성공만으로 배포 가능 상태라고 판단하면 안 된다. 코드뿐 아니라 맵과 UI 자산이 Cook에 포함되는지 실제 패키지를 실행해서 확인해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;또한 공용 상태는 GameState에 복제하지만, 특정 플레이어만 알아야 하는 입력 거부 사유는 소유 PlayerController의 Client RPC로 보내야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;마지막으로 Dedicated Server 배포는 실행 파일만 만드는 작업이 아니다. 빌드 환경, 주소, UDP 포트, 방화벽, 서버 로그, 클라우드 비용과 종료 절차까지 하나의 배포 과정으로 봐야 한다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/91</guid>
      <comments>https://think95592.tistory.com/91#entry91comment</comments>
      <pubDate>Fri, 7 Aug 2026 20:08:06 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal Engine 멀티플레이] 게임 사이클과 복제 최적화</title>
      <link>https://think95592.tistory.com/90</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1050&quot; data-origin-height=&quot;1400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfuuaB/dJMcabL3blj/D6SfJ1KjLvW4KfA6U9JJZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfuuaB/dJMcabL3blj/D6SfJ1KjLvW4KfA6U9JJZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfuuaB/dJMcabL3blj/D6SfJ1KjLvW4KfA6U9JJZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfuuaB%2FdJMcabL3blj%2FD6SfJ1KjLvW4KfA6U9JJZ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1050&quot; height=&quot;1400&quot; data-origin-width=&quot;1050&quot; data-origin-height=&quot;1400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;br /&gt;&lt;br /&gt;멀티플레이 게임의 전체 흐름은 클라이언트 UI가 아니라 &lt;/span&gt;&lt;b&gt;&lt;span&gt;서버 상태 머신&lt;/span&gt;&lt;/b&gt;&lt;span&gt;이 결정한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;Lobby &amp;rarr; Countdown &amp;rarr; Playing &amp;rarr; Ending &amp;rarr; Returning &amp;rarr; Lobby&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트는 버튼을 누르거나 화면을 표시할 뿐이고, 실제 참가&amp;middot;시작&amp;middot;승패&amp;middot;종료&amp;middot;복귀 판정은 서버가 책임진다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. 게임 사이클의 상태 책임&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스책임&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;GameMode&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버 전용. 상태 전이, 참가자 관리, 시작 조건, 승패 판정, 종료 처리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;GameState&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;모두가 봐야 하는 현재 경기 상태를 복제. 현재 단계, 종료 시각, 생존자 수, 최종 결과&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;PlayerState&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;플레이어별 지속 상태. 생존/탈락/관전자 여부, 등수, 팀&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;PlayerController&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;각 플레이어의 입력과 개인 UI. Server RPC 요청 창구, Client RPC 수신 대상&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;UMG/UI&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;로컬 표현 계층. 게임 규칙이나 승패를 결정하지 않음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;핵심은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버가 원본 상태를 바꾸고, &lt;/span&gt;&lt;span&gt;GameState&lt;/span&gt;&lt;span&gt;와 &lt;/span&gt;&lt;span&gt;PlayerState&lt;/span&gt;&lt;span&gt;가 그 결과를 복제하며, 각 클라이언트 UI는 복제값을 화면에 표현한다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Dedicated Server에는 UI를 만들지 않는다. UI는 각 클라이언트의 로컬 &lt;/span&gt;&lt;span&gt;PlayerController&lt;/span&gt;&lt;span&gt;가 생성한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. 접속 이벤트별 책임&lt;/span&gt;&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;이벤트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;역할&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;PreLogin&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;PlayerController&lt;/span&gt;&lt;span&gt; 생성 전 접속 허용 여부 검사. 정원 초과, 차단 등은 &lt;/span&gt;&lt;span&gt;ErrorMessage&lt;/span&gt;&lt;span&gt;로 거부&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;Login&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;PlayerController&lt;/span&gt;&lt;span&gt;를 생성하는 단계&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;PostLogin&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;접속 성공 후 참가자 등록, 역할 배정, 관전자/다음 라운드 대기 처리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;PostNetInit&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;복제 액터의 초기 네트워크 상태가 준비된 뒤 필요한 초기화 확인. 로컬 UI 작업은 로컬 클라이언트인지 별도 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;Logout&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;이탈자를 참가자&amp;middot;생존자 목록에서 제거하고 종료 조건 재검사&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;특히 &lt;/span&gt;&lt;span&gt;Logout&lt;/span&gt;&lt;span&gt;은 단순한 목록 삭제가 아니다. 플레이어가 나가면 &amp;ldquo;남은 생존자가 몇 명인가?&amp;rdquo;가 바뀌므로, 서버는 즉시 승패&amp;middot;종료 조건을 다시 판단해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Logout(B)
&amp;rarr; Registered와 Alive에서 B 제거
&amp;rarr; 종료 조건 재검사
&amp;rarr; A만 생존했다면 Ending 전이
&amp;rarr; 연결 중인 A에게 결과 Client RPC 전송&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;연결이 끊긴 B에게 RPC를 보내려 하면 안 된다. 이미 존재하지 않는 네트워크 연결이기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. UI 전달 방식: 지속 상태와 개인 명령 구분&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;GameState 복제&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 경기 단계, 종료 카운트다운, 생존자 수, 최종 순위처럼 늦게 접속한 사람도 알아야 하는 값은 &lt;/span&gt;&lt;span&gt;GameState&lt;/span&gt;&lt;span&gt;에 복제한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;서버: Phase를 Playing &amp;rarr; Ending으로 변경
&amp;rarr; GameState::Phase 복제
&amp;rarr; Client A/B의 OnRep_Phase 실행
&amp;rarr; 각자 로컬 UI 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;Client RPC&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;ldquo;이 플레이어에게 지금 결과창을 열어라&amp;rdquo;처럼 개인에게 한 번 전달하는 명령은 해당 &lt;/span&gt;&lt;span&gt;PlayerController&lt;/span&gt;&lt;span&gt;의 Client RPC가 적합하다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;서버 GameMode
&amp;rarr; 승자 PlayerController의 Client RPC
&amp;rarr; 승자 클라이언트에서 개인 결과 UI 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;둘은 함께 쓸 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;GameState&lt;/span&gt;&lt;span&gt;: 지금 게임이 &lt;/span&gt;&lt;span&gt;Ending&lt;/span&gt;&lt;span&gt;이라는 공용 사실&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Client RPC: 특정 플레이어에게 결과 UI를 열라는 개인 명령&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;4. 중간 접속자 정책&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Playing&lt;/span&gt;&lt;span&gt; 중에도 접속을 허용할 수 있다. 이 경우 &lt;/span&gt;&lt;span&gt;PostLogin&lt;/span&gt;&lt;span&gt;에서 새 플레이어를 생존자가 아니라 관전자 또는 다음 라운드 대기자로 등록해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;PostLogin(NewPlayer)
&amp;rarr; 현재 Phase 확인
&amp;rarr; Playing이면 Spectator로 등록
&amp;rarr; Alive 목록에는 넣지 않음
&amp;rarr; GameState의 현재 복제값으로 UI 초기화&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;중간 접속자가 과거의 Multicast RPC를 받지 못할 수 있으므로, 현재 경기 상태를 과거 RPC에만 의존하면 안 된다. &lt;/span&gt;&lt;span&gt;GameState&lt;/span&gt;&lt;span&gt;의 현재 복제값으로 UI를 초기화해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;5. 복제 최적화: 하나의 네트워크 예산 문제&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;복제 최적화는 &amp;ldquo;모든 복제를 줄이자&amp;rdquo;가 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;필요한 사람에게, 필요한 상태만, 필요한 빈도로 보내서 서버의 네트워크 예산을 지키는 설계다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;항목&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버가 답하는 질문&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;예시&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;Relevancy&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;이 클라이언트에게 이 액터가 필요한가?&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;멀리 있는 아이템은 보내지 않음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;Dormancy&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;이 액터가 당분간 안 바뀌는가?&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;닫힌 문은 반복 전송 중단&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;NetUpdateFrequency&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;얼마나 자주 갱신 후보로 볼 것인가?&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;플레이어는 자주, 문은 적게&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;NetPriority&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;혼잡할 때 무엇부터 보낼 것인가?&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;플레이어 이동을 장식물보다 우선&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 플레이어: 초당 약 30번 갱신 후보
NetUpdateFrequency = 30.0f;

// 문: 초당 약 2번 갱신 후보
NetUpdateFrequency = 2.0f;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;NetUpdateFrequency = 30.0f&lt;/span&gt;&lt;span&gt;는 &amp;ldquo;3초에 한 번&amp;rdquo;이 아니라 &lt;/span&gt;&lt;b&gt;&lt;span&gt;초당 약 30번&lt;/span&gt;&lt;/b&gt;&lt;span&gt;, 즉 약 0.033초 간격으로 갱신 후보가 된다는 의미다. 실제 전송은 관련성, 휴면 상태, 네트워크 예산에 따라 더 늦어질 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;6. 문&amp;middot;아이템&amp;middot;플레이어 정책&lt;/span&gt;&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;액터&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;정책&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;문&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;근처 플레이어에게만 관련성 유지. 열고 닫는 동안 상태 복제, 멈추면 휴면&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;드롭 아이템&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;가까운 플레이어에게만 전송. 바닥에 멈추면 낮은 빈도 또는 휴면&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;플레이어&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;이동&amp;middot;전투가 중요하므로 문보다 높은 갱신 빈도와 우선순위&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;개인 인벤토리&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;소유자에게만 조건 복제 검토&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;공용 점수&amp;middot;경기 단계&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;GameState&lt;/span&gt;&lt;span&gt;에 필요한 값만 복제&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;문이 닫힌 상태로 오래 멈췄다면 서버는 휴면시킬 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;SetNetDormancy(DORM_DormantAll);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;하지만 휴면 중인 문을 다시 열 때는 서버가 먼저 깨워야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;FlushNetDormancy();
bIsOpen = true;
ForceNetUpdate();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;FlushNetDormancy()&lt;/span&gt;&lt;span&gt; 없이 상태만 변경하면, 서버 로그에는 열린 것으로 보이지만 클라이언트는 계속 닫힌 문을 볼 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;NetPriority&lt;/span&gt;&lt;span&gt;를 높이는 것만으로는 이 문제를 해결할 수 없다. 휴면 또는 관련성 단계에서 이미 제외된 액터는 우선순위를 비교하는 단계까지 가지 못하기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;7. 디버그 로그 예시&lt;/span&gt;&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;[Server] Logout: B removed from Alive. AliveCount 2 -&amp;gt; 1
[Server] EndCheck: AliveCount=1, Winner=A, Phase -&amp;gt; Ending
[Server] Door: FlushDormancy, bIsOpen=true
[Client B] OnRep_DoorState: Open&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;로그에는 최소한 다음을 남기는 것이 좋다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;어떤 서버 상태가 바뀌었는가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;누가 참가/이탈했는가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;생존자 수와 현재 Phase는 무엇인가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;휴면을 깼는가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;클라이언트가 복제된 상태를 실제로 받았는가&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;정리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;멀티플레이 구조를 설계할 때는 항상 다음 순서로 생각한다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;서버가 결정해야 하는 상태인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;모두가 현재도 알아야 하는 지속 상태인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;특정 한 명에게만 전달할 명령인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;늦게 접속한 사람도 현재 상태를 알아야 하는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;이 액터와 값은 누구에게, 얼마나 자주 보내야 하는가?&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;게임 사이클은 서버 상태 머신으로 지키고, 복제 최적화는 그 상태를 필요한 대상에게 필요한 비용으로 전달하는 일이다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/90</guid>
      <comments>https://think95592.tistory.com/90#entry90comment</comments>
      <pubDate>Thu, 6 Aug 2026 19:19:21 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal Engine 멀티플레이]전투&amp;middot;애니메이션&amp;middot;컴포넌트 동기화</title>
      <link>https://think95592.tistory.com/89</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;핵심 원칙&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;전투 기능은 입력, 서버 판정, 애니메이션, 피해 상태, UI를 분리한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;클라이언트 입력
&amp;rarr; Server RPC
&amp;rarr; 서버 검증&amp;middot;명중 판정&amp;middot;피해 적용
&amp;rarr; 지속 상태 복제
&amp;rarr; 각 클라이언트 UI 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트는 공격 의도를 요청하고 화면을 표현한다. 서버는 공격 가능 여부, 명중 대상, 피해량, 사망을 최종 결정한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;RPC와 Property Replication 구분&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;Server RPC&lt;/span&gt;&lt;span&gt;: 클라이언트가 서버에 공격을 요청할 때 사용한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;NetMulticast RPC&lt;/span&gt;&lt;span&gt;: 공격 몽타주, 타격 이펙트, 사운드처럼 한 번 재생하면 끝나는 표현에 사용한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Property Replication&lt;/span&gt;&lt;span&gt;: 체력, 사망, 보호막처럼 늦게 접속한 플레이어도 현재값을 알아야 하는 상태에 사용한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 공격 이펙트는 늦게 접속한 플레이어가 과거분까지 볼 필요가 없지만, 체력 70과 사망 여부는 반드시 현재 상태로 받아야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;서버 권위 공격 설계&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트는 다음처럼 최소 정보만 보낸다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;ServerRequestAttack();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트가 대상, 피해량, 치명타 여부를 보내면 조작할 수 있으므로 서버가 직접 결정한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버 검증 항목:&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;살아 있는가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 공격 가능한 상태인가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;쿨다운이 끝났는가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;무기 상태가 올바른가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버 기준 공격 범위 안에 대상이 있는가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버 Trace 또는 Overlap 결과로 실제 명중했는가&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;Trace와 충돌 옵션&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;LineTraceSingleByChannel&lt;/span&gt;&lt;span&gt;은 서버가 공격자 앞에 있는 대상을 찾는 가상 선 충돌 검사다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;FCollisionQueryParams Params(
    SCENE_QUERY_STAT(MeleeAttack),
    false,
    this);

GetWorld()-&amp;gt;LineTraceSingleByChannel(
    Hit, Start, End, ECC_Pawn, Params);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Params&lt;/span&gt;&lt;span&gt;를 먼저 만드는 이유는 충돌 검사 규칙을 설정하기 위해서다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;: 공격자 자신은 충돌 검사에서 제외한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;: 단순 충돌을 사용한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;ECC_Pawn&lt;/span&gt;&lt;span&gt;: Pawn 충돌 채널을 검사한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;자기 자신을 제외하지 않으면 공격 시작점 근처의 공격자 캡슐을 먼저 맞힐 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;공격 상태와 중복 피해 방어&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버는 공격 상태를 소유한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;Idle &amp;rarr; Windup &amp;rarr; Active &amp;rarr; Recovery &amp;rarr; Idle&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Idle&lt;/span&gt;&lt;span&gt; 상태일 때만 새 공격 요청을 승인한다. 따라서 클라이언트가 공격 요청을 여러 번 보내도 첫 요청 뒤에는 이미 &lt;/span&gt;&lt;span&gt;Windup&lt;/span&gt;&lt;span&gt; 상태이므로 나머지 요청을 거부할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;같은 휘두름 중 같은 대상에게 여러 프레임 피해가 들어가는 문제는 서버 전용 피격 대상 집합으로 막는다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;TSet&amp;lt;TObjectPtr&amp;lt;AActor&amp;gt;&amp;gt; HitActorsThisAttack;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;한 공격에서 이미 맞힌 대상이면 다시 피해를 적용하지 않는다. 이 목록은 UI나 늦은 접속자에게 필요하지 않은 서버 내부 처리이므로 복제하지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;애니메이션과 피해 판정&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;공격 몽타주는 표현이고, 피해 판정은 게임 규칙이다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;몽타주가 보인다고 명중한 것은 아니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;클라이언트 AnimNotify로 피해를 확정하면 안 된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버가 공격 시작 후 정해진 공격 창에서 Trace/Overlap을 실행해 명중을 판정한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;긴 공격&amp;middot;차징처럼 진행 시점이 중요한 몽타주는 서버 시작 시각 또는 공격 상태를 함께 동기화할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;AimOffset 동기화&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;내 캐릭터는 입력 직후 &lt;/span&gt;&lt;span&gt;ControlRotation&lt;/span&gt;&lt;span&gt;을 바꾸고 즉시 AimOffset을 계산해 표현한다. 다른 클라이언트는 서버가 복제한 캐릭터 회전과 필요한 조준 원본을 받아 자기 AnimInstance에서 AimOffset을 계산한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;내 캐릭터: 로컬 입력 &amp;rarr; 즉시 표현 &amp;rarr; 서버 검증
남의 캐릭터: 서버 복제본 수신 &amp;rarr; 로컬 AnimInstance 표현&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;애니메이션 파라미터인 &lt;/span&gt;&lt;span&gt;AimYaw&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;AimPitch&lt;/span&gt;&lt;span&gt;는 보통 복제하지 않고, 각 클라이언트가 회전 원본으로 계산한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;HealthComponent와 UI&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;체력과 사망 상태는 &lt;/span&gt;&lt;span&gt;UHealthComponent&lt;/span&gt;&lt;span&gt;에 둔다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;서버 HealthComponent가 원본 변경
&amp;rarr; CurrentHealth / bIsDead 복제
&amp;rarr; 클라이언트 OnRep 실행
&amp;rarr; UI 체력바 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;UI는 체력을 직접 바꾸거나 판정하지 않는다. 복제된 현재값을 읽어 그리기만 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;또한 UI가 복제 알림보다 늦게 생성될 수 있으므로, 이벤트 구독 후 현재 체력값으로 한 번 초기화해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;디버깅 기준&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;ldquo;공격 애니메이션은 보이는데 체력이 안 깎인다&amp;rdquo;면 다음 순서로 확인한다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;ServerRequestAttack&lt;/span&gt;&lt;span&gt;을 승인했는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버의 공격 상태가 올바르게 전환됐는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버 HitWindow가 시작됐는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버 Trace의 &lt;/span&gt;&lt;span&gt;Start&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;End&lt;/span&gt;&lt;span&gt;, 충돌 채널, 자기 자신 제외 설정이 맞는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;HitActor&lt;/span&gt;&lt;span&gt;가 실제 대상인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;ApplyDamageOnServer&lt;/span&gt;&lt;span&gt;를 호출했는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;CurrentHealth&lt;/span&gt;&lt;span&gt; 복제와 클라이언트 &lt;/span&gt;&lt;span&gt;OnRep&lt;/span&gt;&lt;span&gt;가 실행됐는가?&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;HitActor=None&lt;/span&gt;&lt;span&gt;이면 애니메이션 재생 여부와 별개로 서버 명중 판정이 실패한 것이므로 체력이 줄지 않는 것이 정상이다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/89</guid>
      <comments>https://think95592.tistory.com/89#entry89comment</comments>
      <pubDate>Wed, 5 Aug 2026 17:22:32 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal Engine 멀티플레이] 남은 시간이 아닌 마감 시각을 복제하는 서버 권위 타이머</title>
      <link>https://think95592.tistory.com/88</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘의 학습 목표&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;오늘은 멀티플레이 숫자 야구의 타자 입력 제한 시간을 구현하면서 네트워크 타이머를 동기화하는 방법을 학습했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;핵심은 서버가 &lt;/span&gt;&lt;span&gt;15, 14, 13...&lt;/span&gt;&lt;span&gt;처럼 남은 시간을 계속 보내는 것이 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;타이머가 언제 끝나는지를 나타내는 마감 시각 하나만 복제&lt;/span&gt;&lt;/b&gt;&lt;span&gt;하고, 각 클라이언트가 동기화된 서버 시간과 비교해 남은 시간을 직접 계산한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;fix&quot;&gt;&lt;code&gt;남은 시간 = 마감 서버 시각 - 현재 서버 시각&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 구조를 사용하면 네트워크 복제 횟수를 줄이면서도 모든 클라이언트에서 비슷한 카운트다운을 표시할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. 핵심 아이디어&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 서버 시간이 &lt;/span&gt;&lt;span&gt;100초&lt;/span&gt;&lt;span&gt;이고 제한 시간이 &lt;/span&gt;&lt;span&gt;15초&lt;/span&gt;&lt;span&gt;라고 가정해 보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버는 다음 값을 계산한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;마감 시각 = 100 + 15
          = 115초&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그리고 &lt;/span&gt;&lt;span&gt;115&lt;/span&gt;&lt;span&gt;라는 고정된 마감 시각을 GameState를 통해 클라이언트에 복제한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;각 클라이언트는 매 프레임 다음 계산만 수행한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;남은 시간 = 115 - 현재 서버 시각&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 클라이언트가 추정한 현재 서버 시각이 &lt;/span&gt;&lt;span&gt;108.3초&lt;/span&gt;&lt;span&gt;라면 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;남은 시간 = 115 - 108.3
          = 6.7초&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버가 남은 시간을 매초 복제할 필요 없이 시작할 때 계산한 마감 시각 하나만 전달하면 된다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. 전체 실행 흐름&lt;/span&gt;&lt;/h2&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;서버 GameMode
├─ 현재 서버 시각 확인
├─ 현재 서버 시각 + 제한 시간 계산
├─ GameState에 마감 시각 저장
└─ 실제 시간 초과 판정용 서버 타이머 시작
            &amp;darr;
서버 GameState
└─ 마감 시각을 모든 클라이언트에 복제
            &amp;darr;
각 클라이언트 GameState
└─ RepNotify로 마감 시각 변경 감지
            &amp;darr;
각 클라이언트 UMG
├─ 마감 시각 - 현재 서버 시각 계산
├─ 남은 시간을 정수로 변환
└─ 화면에 카운트다운 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;여기서 화면의 카운트다운과 실제 시간 초과 판정은 서로 다른 책임을 가진다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;클라이언트 UMG
= 남은 시간을 계산해서 보여 주는 역할

서버 GameMode
= 실제 기회를 종료하고 아웃을 판정하는 역할&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트 화면에 &lt;/span&gt;&lt;span&gt;0초&lt;/span&gt;&lt;span&gt;가 표시됐다는 이유만으로 클라이언트가 직접 게임 상태를 변경해서는 안 된다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. 마감 시각을 GameState에 저장하는 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;마감 시각은 모든 참가자가 알아야 하는 현재 경기 상태다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;타자는 자신의 남은 시간을 확인해야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;투수도 타자의 남은 시간을 확인할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;관전자도 현재 카운트다운을 볼 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;늦게 접속한 클라이언트도 현재 남은 시간을 계산해야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameMode는 서버에만 존재하므로 클라이언트가 접근할 수 없다. 반면 GameState는 서버와 모든 클라이언트에 존재하며 공용 상태를 복제하기에 적합하다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing =
    OnRep_BatterGuessDeadlineServerTime)
float BatterGuessDeadlineServerTime = 0.0f;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;0.0f&lt;/span&gt;&lt;span&gt;는 현재 활성화된 타자 입력 타이머가 없다는 의미로 사용한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;4. RepNotify 구성&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;마감 시각이 클라이언트에 복제되면 &lt;/span&gt;&lt;span&gt;OnRep&lt;/span&gt;&lt;span&gt; 함수가 호출된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;UFUNCTION()
void OnRep_BatterGuessDeadlineServerTime();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void AMYBaseballGameState::
    OnRep_BatterGuessDeadlineServerTime()
{
    NotifyBatterGuessDeadlineChanged();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 위젯은 매 프레임 GameState의 값을 확인하지만, RepNotify를 함께 구성하면 다음과 같은 확장도 가능하다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;타이머 시작 애니메이션&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;경고음 재생&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;타이머 패널 표시&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;다른 UI에 타이머 변경 이벤트 전달&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;이벤트 기반 UI 갱신&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;5. DOREPLIFETIME 등록&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;UPROPERTY(ReplicatedUsing = ...)&lt;/span&gt;&lt;span&gt;를 선언하는 것만으로는 프로퍼티 복제가 완성되지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GetLifetimeReplicatedProps()&lt;/span&gt;&lt;span&gt;에 해당 프로퍼티를 등록해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;#include &quot;Net/UnrealNetwork.h&quot;

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

    DOREPLIFETIME(
        ThisClass,
        BatterGuessDeadlineServerTime
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 코드의 역할은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = ...)
= 복제 대상이며 어떤 OnRep 함수를 사용할지 선언

DOREPLIFETIME(...)
= 언리얼의 실제 프로퍼티 복제 목록에 등록&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;둘 중 하나가 누락되면 기대한 대로 복제되지 않을 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;6. 서버에서 마감 시각 생성하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;타자의 입력 시간이 시작되면 GameMode가 마감 시각을 계산하고 실제 판정용 타이머를 설정한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;void AMYBaseballGameMode::StartBatterGuessTimer()
{
    if (!HasAuthority())
    {
        return;
    }

    AMYBaseballGameState* BaseballGameState =
        GetGameState&amp;lt;AMYBaseballGameState&amp;gt;();

    if (!IsValid(BaseballGameState)
        || BaseballGameState-&amp;gt;GetInningPhase()
            != EMYInningPhase::BatterGuessing)
    {
        return;
    }

    GetWorldTimerManager().ClearTimer(
        BatterGuessTimerHandle
    );

    const float DeadlineServerTime =
        BaseballGameState-&amp;gt;GetServerWorldTimeSeconds()
        + BatterGuessTimeLimitSeconds;

    BaseballGameState
        -&amp;gt;SetBatterGuessDeadlineServerTime(
            DeadlineServerTime
        );

    GetWorldTimerManager().SetTimer(
        BatterGuessTimerHandle,
        this,
        &amp;amp;ThisClass::HandleBatterGuessTimeout,
        BatterGuessTimeLimitSeconds,
        false
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;제한 시간은 상수로 관리했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;static constexpr float BatterGuessTimeLimitSeconds =
    15.0f;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 서버 시간이 &lt;/span&gt;&lt;span&gt;203.4초&lt;/span&gt;&lt;span&gt;라면 마감 시각은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;현재 서버 시각 : 203.4
제한 시간      :  15.0
마감 서버 시각 : 218.4&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState에는 남은 시간 &lt;/span&gt;&lt;span&gt;15&lt;/span&gt;&lt;span&gt;가 아니라 마감 시각 &lt;/span&gt;&lt;span&gt;218.4&lt;/span&gt;&lt;span&gt;가 저장되고 복제된다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;7. 기존 타이머를 먼저 제거하는 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;새로운 타이머를 시작하기 전에 기존 타이머를 제거한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;GetWorldTimerManager().ClearTimer(
    BatterGuessTimerHandle
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이전 시도의 타이머가 남아 있으면 다음과 같은 문제가 발생할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;첫 번째 시도의 타이머
두 번째 시도의 타이머&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 타이머가 동시에 살아 있으면 첫 번째 타이머의 콜백이 나중 시도를 시간 초과로 처리할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 같은 용도의 타이머를 다시 시작하기 전에는 기존 TimerHandle을 정리해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;8. 서버 타이머와 마감 시각의 역할&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버에서 생성한 마감 시각은 UI 표시를 위한 기준이지만, 마감 시각만 저장한다고 실제 게임 로직이 실행되지는 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;GetWorldTimerManager().SetTimer(
    BatterGuessTimerHandle,
    this,
    &amp;amp;ThisClass::HandleBatterGuessTimeout,
    BatterGuessTimeLimitSeconds,
    false
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 서버 타이머가 실제로 다음 함수를 호출한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;HandleBatterGuessTimeout();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 장치의 역할은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;복제된 마감 시각
= 각 클라이언트가 화면의 남은 시간을 계산하기 위한 기준

서버 TimerManager
= 제한 시간이 끝났을 때 실제 게임 상태를 변경하는 장치&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;화면 표시와 권위 있는 판정을 분리하는 것이 중요하다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;9. 마감 시각은 서버만 변경한다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState의 Setter에서도 Authority를 검사한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void AMYBaseballGameState::
    SetBatterGuessDeadlineServerTime(
        const float NewDeadlineServerTime)
{
    if (!HasAuthority())
    {
        UE_LOG(
            LogMYBaseball,
            Warning,
            TEXT(
                &quot;SetBatterGuessDeadlineServerTime &quot;
                &quot;rejected without authority.&quot;
            )
        );

        return;
    }

    const float SanitizedDeadline =
        FMath::Max(
            0.0f,
            NewDeadlineServerTime
        );

    if (FMath::IsNearlyEqual(
        BatterGuessDeadlineServerTime,
        SanitizedDeadline))
    {
        return;
    }

    BatterGuessDeadlineServerTime =
        SanitizedDeadline;

    NotifyBatterGuessDeadlineChanged();
    ForceNetUpdate();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;Authority 검사&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트가 자신의 GameState 복제본에서 Setter를 호출해도 서버의 원본은 변경되지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;함수 내부에서도 Authority를 검사하면 잘못된 호출을 명확하게 차단할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;음수 방지&lt;/span&gt;&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;FMath::Max(0.0f, NewDeadlineServerTime);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;음수 마감 시각을 방지한다. 이 구현에서는 &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;을 비활성화 상태로 사용한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;중복 변경 방지&lt;/span&gt;&lt;/h3&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;FMath::IsNearlyEqual(...);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;부동소수점 값을 단순히 &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt;로 비교하지 않고 거의 같은 값이면 불필요한 변경과 UI 알림을 생략한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;빠른 복제 요청&lt;/span&gt;&lt;/h3&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;ForceNetUpdate();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState가 다음 일반 네트워크 업데이트 시점까지 기다리지 않고 변경된 상태를 가능한 한 빨리 복제하도록 요청한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다만 이것은 즉시 전송을 절대적으로 보장하는 명령이 아니라 다음 복제 기회를 앞당기는 요청에 가깝다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버에서는 RepNotify가 자동으로 호출된다고 기대하지 않고 Setter 안에서 알림 함수를 직접 호출했다. 덕분에 Listen Server의 로컬 UI도 변경을 감지할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;10. 클라이언트가 서버 시간을 구하는 방법&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;각 클라이언트의 로컬 &lt;/span&gt;&lt;span&gt;GetTimeSeconds()&lt;/span&gt;&lt;span&gt; 값은 서로 다를 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 단순히 다음과 같이 계산하면 모든 클라이언트의 기준이 일치하지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 클라이언트마다 기준이 다를 수 있다.
GetWorld()-&amp;gt;GetTimeSeconds();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;언리얼의 &lt;/span&gt;&lt;span&gt;AGameStateBase&lt;/span&gt;&lt;span&gt;는 서버 시간 기준의 현재 시각을 구할 수 있는 함수를 제공한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;GetServerWorldTimeSeconds();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;개념적으로는 클라이언트의 로컬 월드 시간에 서버와의 시간 차이를 보정하는 방식이다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;서버에서 받은 시간 - 클라이언트 로컬 시간
= 서버 시간 보정값

클라이언트 로컬 시간 + 서버 시간 보정값
= 추정된 현재 서버 시각&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 Client A와 Client B의 로컬 시간이 달라도 &lt;/span&gt;&lt;span&gt;GetServerWorldTimeSeconds()&lt;/span&gt;&lt;span&gt;를 사용하면 같은 서버 시간축에 가까운 값을 얻을 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;별도의 서버 시간 동기화 RPC를 직접 만들지 않아도 되는 이유는 GameState가 이미 관련 기능을 제공하기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;11. 남은 시간 계산하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState에서는 마감 시각과 현재 서버 시각을 사용해 남은 시간을 계산한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;float AMYBaseballGameState::
    GetBatterGuessTimeRemaining() const
{
    if (BatterGuessDeadlineServerTime &amp;lt;= 0.0f)
    {
        return 0.0f;
    }

    return FMath::Max(
        0.0f,
        BatterGuessDeadlineServerTime
        - GetServerWorldTimeSeconds()
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;공식은 단순하다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;남은 시간
= 마감 서버 시각 - 현재 서버 시각&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;복제된 마감 시각 : 218.4
현재 서버 시각   : 210.1
남은 시간        :   8.3초&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;마감 이후에는 계산 결과가 음수가 될 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;218.4 - 219.2 = -0.8&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;UI에 음수가 표시되지 않도록 최소값을 &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;으로 제한한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;FMath::Max(0.0f, RemainingTime);&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;12. UMG에서는 로컬 계산만 수행한다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;각 클라이언트의 Widget은 &lt;/span&gt;&lt;span&gt;NativeTick()&lt;/span&gt;&lt;span&gt;에서 타이머 표시를 갱신한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void UMYBaseballGameWidget::NativeTick(
    const FGeometry&amp;amp; MyGeometry,
    const float InDeltaTime)
{
    Super::NativeTick(
        MyGeometry,
        InDeltaTime
    );

    RefreshTimerText();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 계산은 클라이언트의 로컬 연산이다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Client A
&amp;rarr; Client A의 UMG에서 남은 시간 계산

Client B
&amp;rarr; Client B의 UMG에서 남은 시간 계산

Dedicated Server
&amp;rarr; UMG가 없으므로 화면 계산 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;매 프레임 실행되더라도 네트워크 패킷을 추가로 전송하지 않는다. 복제된 마감 시각과 동기화된 서버 시간을 읽어 화면만 갱신하기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;13. 타이머 활성 상태 검사&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;카운트다운은 다음 조건을 모두 만족할 때만 표시한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;const bool bTimerIsActive =
    IsValid(BoundGameState)
    &amp;amp;&amp;amp; BoundGameState-&amp;gt;GetInningPhase()
        == EMYInningPhase::BatterGuessing
    &amp;amp;&amp;amp; BoundGameState
        -&amp;gt;GetBatterGuessDeadlineServerTime()
        &amp;gt; 0.0f;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;필요한 조건은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;GameState가 유효하다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 이닝 단계가 타자 추측 단계다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;마감 서버 시각이 &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;보다 크다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;활성 상태라면 남은 시간을 정수로 변환한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const int32 DisplayedSeconds =
    bTimerIsActive
    ? FMath::CeilToInt(
        BoundGameState
            -&amp;gt;GetBatterGuessTimeRemaining()
      )
    : -1;&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;14. CeilToInt를 사용하는 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;화면에 표시할 시간은 올림 처리한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;FMath::CeilToInt(RemainingTime);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;표시 결과는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;14.92초 &amp;rarr; 15
 5.01초 &amp;rarr;  6
 4.98초 &amp;rarr;  5
 0.01초 &amp;rarr;  1
 0.00초 &amp;rarr;  0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;내림 처리하면 타이머가 시작되자마자 실제로는 거의 15초가 남았는데 화면에는 &lt;/span&gt;&lt;span&gt;14초&lt;/span&gt;&lt;span&gt;가 표시될 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;사용자에게 남아 있는 완전한 초 단위를 자연스럽게 보여 주려면 올림 처리가 적합하다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;15. TextBlock은 숫자가 바뀔 때만 갱신한다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;남은 시간 계산은 매 프레임 수행하지만, TextBlock까지 매 프레임 다시 설정할 필요는 없다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;if (DisplayedSeconds
    == LastDisplayedTimerSeconds)
{
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;60FPS 환경에서 한 초 동안 계산 결과가 다음과 같이 변할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;14.95
14.93
14.91
...
14.10&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;모두 올림하면 &lt;/span&gt;&lt;span&gt;15&lt;/span&gt;&lt;span&gt;다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 화면의 정수 값이 이전과 같다면 &lt;/span&gt;&lt;span&gt;SetText()&lt;/span&gt;&lt;span&gt;를 호출하지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 구조는 다음 비용을 줄인다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;불필요한 TextBlock 갱신&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;반복적인 문자열 생성&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Slate 레이아웃 및 페인트 작업&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;불필요한 UI 상태 변경&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;네트워크 복제는 시작 시 마감 시각을 한 번 전달하는 수준으로 유지하면서 UI도 필요한 순간에만 변경할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;16. 실제 타이머 표시&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;타이머가 활성화되지 않았다면 다음과 같이 표시한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;if (!bTimerIsActive)
{
    TimerText-&amp;gt;SetText(
        FText::FromString(
            TEXT(&quot;TIME: --&quot;)
        )
    );

    TimerText-&amp;gt;SetColorAndOpacity(
        FSlateColor(
            FLinearColor::White
        )
    );

    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;활성 상태에서는 두 자리 숫자로 표시한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;TimerText-&amp;gt;SetText(
    FText::FromString(
        FString::Printf(
            TEXT(&quot;TIME: %02d&quot;),
            DisplayedSeconds
        )
    )
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;화면에는 다음과 같이 나타난다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;TIME: 15
TIME: 09
TIME: 00&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;남은 시간이 5초 이하가 되면 색상을 변경해 긴박감을 줄 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const FLinearColor TimerColor =
    DisplayedSeconds &amp;lt;= 5
    ? FLinearColor(
        1.0f,
        0.18f,
        0.12f,
        1.0f
      )
    : FLinearColor::White;

TimerText-&amp;gt;SetColorAndOpacity(
    FSlateColor(TimerColor)
);&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;17. UMG에서 시간 초과를 판정하면 안 되는 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트가 표시하는 시간은 UI용 추정값이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;네트워크 지연과 서버 시간 보정 때문에 클라이언트마다 아주 작은 차이가 발생할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다음과 같이 UMG에서 게임 상태를 변경하면 안 된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 잘못된 구조
if (DisplayedSeconds &amp;lt;= 0)
{
    ++TryCount;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;모든 클라이언트에서 같은 코드가 실행되면 중복 판정이 발생할 수 있다. 악의적인 클라이언트가 코드를 조작할 가능성도 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 책임을 다음과 같이 분리한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;클라이언트 UMG
= TIME: 00 표시

서버 GameMode
= 실제 시간 초과 처리
= 시도 횟수 증가
= 아웃 판정
= 다음 시도 시작&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;18. 서버의 실제 시간 초과 처리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버 타이머가 만료되면 GameMode에서 시간 초과를 처리한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;void AMYBaseballGameMode::
    HandleBatterGuessTimeout()
{
    if (!HasAuthority())
    {
        return;
    }

    ClearBatterGuessTimer();

    ++CurrentBatterTryCount;

    BaseballGameState-&amp;gt;SetAtBatStatus(
        CurrentBatterTryCount,
        0,
        TArray&amp;lt;int32&amp;gt;()
    );

    // 남은 기회가 있다면
    // 다음 15초 타이머 시작
    StartBatterGuessTimer();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버는 다음 작업을 담당한다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;현재 타이머를 종료한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;타자의 시도 횟수를 증가시킨다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;GameState의 타석 상태를 변경한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;아웃 조건을 검사한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;남은 기회가 있다면 새로운 타이머를 시작한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트는 결과를 GameState Replication으로 전달받아 화면에 표시한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;19. RPC와 시간 초과가 거의 동시에 발생하는 경우&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;실제 멀티플레이에서는 타자가 제한 시간 마지막 순간에 입력할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다음 상황이 발생할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;서버의 마감 시각은 이미 지남
서버 TimerManager의 콜백은 아직 대기 중
타자의 입력 RPC가 먼저 서버에 도착&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버 타이머 콜백만 믿으면 마감 이후에 도착한 입력이 정상 입력으로 처리될 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 Server RPC를 처리하는 시점에도 마감 시각을 다시 확인해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;if (BaseballGameState
        -&amp;gt;GetBatterGuessDeadlineServerTime()
        &amp;gt; 0.0f
    &amp;amp;&amp;amp; BaseballGameState
        -&amp;gt;GetBatterGuessTimeRemaining()
        &amp;lt;= 0.0f)
{
    HandleBatterGuessTimeout();

    OutErrorMessage =
        TEXT(
            &quot;The guess arrived &quot;
            &quot;after the time limit.&quot;
        );

    return false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버의 현재 시각을 기준으로 이미 마감됐다면 서버가 직접 시간 초과를 처리하고 해당 RPC를 거절한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;20. 이전 시도의 늦은 RPC 방지&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;네트워크 지연으로 이전 시도에서 보낸 RPC가 다음 시도에 도착할 수도 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이를 방지하기 위해 클라이언트가 자신이 입력한 시도의 번호를 함께 보낼 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;if (ExpectedTryCount
    != CurrentBatterTryCount)
{
    OutErrorMessage =
        TEXT(
            &quot;This guess belongs &quot;
            &quot;to an expired attempt.&quot;
        );

    return false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;클라이언트가 보낸 ExpectedTryCount : 0
서버의 CurrentBatterTryCount       : 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 값이 다르면 이전 시도에서 보낸 오래된 요청으로 판단하고 거절한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 검사가 없다면 첫 번째 시도에서 늦게 도착한 입력이 두 번째 시도의 입력으로 잘못 처리될 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;마지막 순간의 입력은 두 단계로 검증한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 요청의 시도 번호가 현재 시도와 같은가?
2. 서버 기준 마감 시각이 지나지 않았는가?&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;21. 타이머 종료와 상태 초기화&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;타이머를 종료할 때는 TimerHandle만 제거하는 것이 아니라 GameState의 마감 시각도 &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;으로 변경한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void AMYBaseballGameMode::
    ClearBatterGuessTimer()
{
    GetWorldTimerManager().ClearTimer(
        BatterGuessTimerHandle
    );

    if (AMYBaseballGameState* BaseballGameState =
        GetGameState&amp;lt;AMYBaseballGameState&amp;gt;())
    {
        BaseballGameState
            -&amp;gt;SetBatterGuessDeadlineServerTime(
                0.0f
            );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;이 클라이언트에 복제되면 각 UI는 타이머가 비활성화됐다고 판단한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;TIME: --&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;정상적인 입력으로 시도가 끝난 경우에도 기존 타이머를 먼저 제거한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;현재 시도 시작
&amp;rarr; 새로운 마감 시각 복제

현재 시도 종료
&amp;rarr; 서버 타이머 제거
&amp;rarr; 마감 시각 0 복제

다음 시도 시작
&amp;rarr; 새로운 마감 시각 복제&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;정답이 확정되지 않아 다음 기회가 남아 있다면 새로운 15초 타이머를 시작한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;22. 남은 시간을 매초 복제하는 방식과 비교&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버가 남은 시간을 매초 복제한다면 다음 값을 계속 변경해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;15 &amp;rarr; 14 &amp;rarr; 13 &amp;rarr; ... &amp;rarr; 1 &amp;rarr; 0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;15초 타이머 하나에도 최대 16회의 상태 변경이 발생한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;네트워크 상태에 따라 특정 값이 늦게 도착하거나 중간 숫자를 건너뛸 수도 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;마감 시각 방식은 다음 값 하나를 복제한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DeadlineServerTime = 218.4&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;각 클라이언트는 이 고정값과 현재 서버 시각을 사용해 카운트다운을 진행한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;마감 시각 방식의 장점&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;타이머 전용 복제 횟수가 감소한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;모든 클라이언트가 같은 서버 시간축을 사용한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;중간에 접속한 클라이언트도 현재 남은 시간을 계산할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;클라이언트가 매끄럽게 카운트다운을 표시할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;실제 판정은 계속 서버 권위로 유지된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;타이머마다 별도의 시간 동기화 RPC가 필요하지 않다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;언리얼은 GameState를 통해 서버 시간 동기화를 지원한다. 이번 구현은 그 기능 위에 게임 타이머를 구성한 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;23. 실행 위치와 책임 정리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드실행 위치책임&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;StartBatterGuessTimer()&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;마감 시각과 실제 서버 타이머 생성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;SetBatterGuessDeadlineServerTime()&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;권위 있는 마감 시각 변경&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;프로퍼티 Replication&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버 &amp;rarr; 클라이언트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;마감 시각 전달&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;OnRep_BatterGuessDeadlineServerTime()&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;각 클라이언트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;복제 도착 알림&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;GetBatterGuessTimeRemaining()&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;호출한 컴퓨터&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버 시간축으로 남은 시간 계산&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;NativeTick()&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;각 로컬 클라이언트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;카운트다운 UI 갱신&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;HandleBatterGuessTimeout()&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;기회 소진과 아웃 판정&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;ClearBatterGuessTimer()&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;타이머 제거 및 마감 시각 초기화&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;24. 오늘 배운 내용&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;오늘은 멀티플레이 타이머에서 남은 시간을 매초 복제하는 대신, 서버가 고정된 마감 시각 하나를 복제하는 구조를 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameMode는 마감 시각을 생성하고 실제 서버 타이머를 통해 시간 초과를 판정한다. GameState는 마감 시각을 모든 클라이언트에 복제한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;각 클라이언트의 UMG는 다음 공식으로 화면의 남은 시간을 계산한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;남은 시간
= 복제된 마감 서버 시각
- 동기화된 현재 서버 시각&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;UI는 카운트다운을 보여 줄 뿐이며, 실제 시도 횟수 증가와 아웃 판정은 서버만 수행한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;또한 제한 시간 끝에 도착한 입력이 잘못 처리되지 않도록 RPC 처리 시점에 마감 시각과 시도 번호를 다시 검증했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 구조의 핵심을 한 문장으로 정리하면 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;서버는 마감 시각과 실제 판정을 소유하고,
GameState는 마감 시각을 복제하며,
각 클라이언트는 서버 시간축을 기준으로 화면만 갱신한다.&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/88</guid>
      <comments>https://think95592.tistory.com/88#entry88comment</comments>
      <pubDate>Tue, 4 Aug 2026 19:23:43 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal Engine 멀티플레이] 서버 권위 기반 숫자 야구 구현</title>
      <link>https://think95592.tistory.com/87</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;오늘의 학습 목표&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;오늘은 언리얼 엔진의 서버 권위 구조를 기반으로 멀티플레이 숫자 야구를 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;주요 구현 내용은 다음과 같다.&lt;/span&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;span&gt;GameMode와 GameState의 책임 분리&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;PlayerController의 Server RPC를 통한 입력 전달&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버에서 투수의 정답과 타자의 추측 검증&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;GameState Replication을 통한 &lt;/span&gt;&lt;span&gt;TRY&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;HIT&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;OUT&lt;/span&gt;&lt;span&gt; 동기화&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;3아웃 달성 시 공수 교대&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;클라이언트 조작과 잘못된 RPC 요청 방지&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. 숫자 야구의 네트워크 흐름&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 숫자 야구에서는 투수가 정답 숫자를 가지고 타자가 숫자를 추측한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;게임 규칙은 다음과 같이 구성했다.&lt;/span&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;span&gt;서버가 투수의 정답을 관리한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;타자가 숫자를 입력한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;타자의 PlayerController가 Server RPC를 호출한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;서버가 입력 형식과 플레이어 역할을 검증한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;추측이 정답이면 &lt;/span&gt;&lt;span&gt;HIT&lt;/span&gt;&lt;span&gt;를 증가시킨다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;추측이 틀리면 &lt;/span&gt;&lt;span&gt;OUT&lt;/span&gt;&lt;span&gt;을 증가시킨다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;시도할 때마다 &lt;/span&gt;&lt;span&gt;TRY&lt;/span&gt;&lt;span&gt;를 증가시킨다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;OUT&lt;/span&gt;&lt;span&gt;이 3이 되면 투수와 타자의 역할을 교대한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;변경된 결과는 GameState를 통해 모든 클라이언트에 복제한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;전체 흐름을 정리하면 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;타자의 채팅 또는 입력 UI
        &amp;darr;
로컬 PlayerController
        &amp;darr; Server RPC
서버의 PlayerController
        &amp;darr;
서버 GameMode
├─ 현재 타자인지 검사
├─ 입력 형식 검사
├─ 정답과 추측 비교
└─ 3아웃 검사
        &amp;darr;
서버 GameState
├─ TRY 변경
├─ HIT 변경
├─ OUT 변경
└─ 공수 상태 변경
        &amp;darr; Replication
모든 클라이언트 UI 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. 왜 서버 권위 구조가 필요한가?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;멀티플레이 게임에서 클라이언트가 직접 정답 판정이나 점수를 변경하게 만들면 치팅에 매우 취약해진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 클라이언트가 다음 값을 직접 변경할 수 있으면 안 된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;정답 숫자
HIT 횟수
OUT 횟수
현재 투수와 타자
공수 교대 여부&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트는 자신이 입력한 숫자만 서버에 전달해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;클라이언트
&quot;123을 추측했습니다.&quot;

서버
&quot;이 플레이어가 정말 현재 타자인가?&quot;
&quot;123은 유효한 숫자인가?&quot;
&quot;서버에 저장된 정답과 일치하는가?&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;최종 판정과 상태 변경은 모두 서버가 담당한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. GameMode와 GameState의 책임 분리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;멀티플레이 게임을 구현할 때 GameMode와 GameState의 역할을 정확히 구분해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;GameMode의 역할&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameMode는 서버에만 존재한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 외부에 공개되면 안 되는 정보와 게임 규칙을 관리하기에 적합하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 숫자 야구에서 GameMode는 다음 역할을 담당한다.&lt;/span&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;span&gt;투수의 정답 숫자 보관&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;타자의 추측 검증&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 플레이어가 타자인지 검사&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;HIT와 OUT 판정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;3아웃 검사&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;공수 교대 처리&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;새로운 정답 생성&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;투수의 정답은 클라이언트에 복제하면 안 된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;UCLASS()
class ABaseballGameMode : public AGameModeBase
{
    GENERATED_BODY()

private:
    // 서버에만 존재하며 클라이언트에 복제되지 않는다.
    FString SecretNumber;

public:
    void HandleGuess(
        class ABaseballPlayerController* RequestingPlayer,
        const FString&amp;amp; Guess
    );

private:
    bool IsValidGuess(const FString&amp;amp; Guess) const;
    void GenerateSecretNumber();
    void SwitchSides();
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;GameState의 역할&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState는 서버와 모든 클라이언트에 존재한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;전체 플레이어가 알아야 하는 공용 상태를 저장하고 복제하는 데 적합하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 숫자 야구에서 GameState는 다음 정보를 관리한다.&lt;/span&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;span&gt;현재 시도 횟수 &lt;/span&gt;&lt;span&gt;TRY&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 안타 횟수 &lt;/span&gt;&lt;span&gt;HIT&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 아웃 횟수 &lt;/span&gt;&lt;span&gt;OUT&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 투수&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 타자&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;UCLASS()
class ABaseballGameState : public AGameStateBase
{
    GENERATED_BODY()

public:
    UPROPERTY(ReplicatedUsing = OnRep_RoundState)
    int32 TryCount = 0;

    UPROPERTY(ReplicatedUsing = OnRep_RoundState)
    int32 HitCount = 0;

    UPROPERTY(ReplicatedUsing = OnRep_RoundState)
    int32 OutCount = 0;

    UPROPERTY(ReplicatedUsing = OnRep_PlayerRoles)
    TObjectPtr&amp;lt;APlayerState&amp;gt; PitcherPlayerState;

    UPROPERTY(ReplicatedUsing = OnRep_PlayerRoles)
    TObjectPtr&amp;lt;APlayerState&amp;gt; BatterPlayerState;

    UFUNCTION()
    void OnRep_RoundState();

    UFUNCTION()
    void OnRep_PlayerRoles();

    virtual void GetLifetimeReplicatedProps(
        TArray&amp;lt;FLifetimeProperty&amp;gt;&amp;amp; OutLifetimeProps
    ) const override;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 클래스의 책임을 요약하면 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스실행 위치담당 역할&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;GameMode&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버에만 존재&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;정답, 규칙, 판정, 공수 교대&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;GameState&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버와 모든 클라이언트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;TRY, HIT, OUT, 투수&amp;middot;타자 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;PlayerController&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;서버와 소유 클라이언트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;입력 수집, Server RPC 전달&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;Widget&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;각 클라이언트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;입력과 결과 표시&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;4. PlayerController로 입력 전달하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;타자의 숫자 입력은 로컬 UI에서 발생한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;하지만 Widget은 네트워크 액터가 아니기 때문에 Widget에서 직접 RPC를 호출하는 구조는 적합하지 않다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;입력은 로컬 PlayerController에 전달하고, PlayerController가 Server RPC를 호출하도록 구성한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Widget
&amp;rarr; PlayerController
&amp;rarr; Server RPC
&amp;rarr; GameMode&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;PlayerController 헤더&lt;/span&gt;&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UCLASS()
class ABaseballPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    void SubmitGuess(const FString&amp;amp; Guess);

protected:
    UFUNCTION(Server, Reliable)
    void ServerSubmitGuess(const FString&amp;amp; Guess);
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;로컬 입력 처리&lt;/span&gt;&lt;/h3&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ABaseballPlayerController::SubmitGuess(const FString&amp;amp; Guess)
{
    if (!IsLocalController())
    {
        return;
    }

    ServerSubmitGuess(Guess);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;SubmitGuess()&lt;/span&gt;&lt;span&gt;는 자신의 화면에서 조작 중인 로컬 PlayerController만 실행하도록 검사한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;5. Server RPC에서 GameMode 호출하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Server RPC가 서버에 도착하면 서버의 GameMode에 판정을 요청한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;void ABaseballPlayerController::ServerSubmitGuess_Implementation(
    const FString&amp;amp; Guess)
{
    ABaseballGameMode* GameMode =
        GetWorld()-&amp;gt;GetAuthGameMode&amp;lt;ABaseballGameMode&amp;gt;();

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

    GameMode-&amp;gt;HandleGuess(this, Guess);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GetAuthGameMode()&lt;/span&gt;&lt;span&gt;는 Authority가 있는 서버에서 GameMode를 가져올 때 사용한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameMode는 클라이언트에 존재하지 않으므로 클라이언트에서 호출하면 유효한 GameMode를 얻을 수 없다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;PlayerController는 해당 클라이언트가 소유하는 액터이기 때문에 클라이언트에서 서버로 RPC를 전달하는 진입점으로 사용할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;6. 서버에서 입력 검증하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트가 보낸 문자열은 절대 그대로 신뢰하면 안 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버는 최소한 다음 내용을 확인해야 한다.&lt;/span&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;span&gt;요청한 플레이어가 현재 타자인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;숫자의 길이가 올바른가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;모든 문자가 숫자인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;중복된 숫자가 없는가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;현재 게임이 추측 가능한 상태인가?&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;너무 빠르게 입력을 반복하고 있지는 않은가?&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;숫자 형식 검사&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다음 예시는 세 자리 숫자이며 중복 숫자를 허용하지 않는 규칙이다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;bool ABaseballGameMode::IsValidGuess(const FString&amp;amp; Guess) const
{
    constexpr int32 RequiredLength = 3;

    if (Guess.Len() != RequiredLength)
    {
        return false;
    }

    TSet&amp;lt;TCHAR&amp;gt; UsedDigits;

    for (const TCHAR Character : Guess)
    {
        if (!FChar::IsDigit(Character))
        {
            return false;
        }

        if (UsedDigits.Contains(Character))
        {
            return false;
        }

        UsedDigits.Add(Character);
    }

    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트 UI에서도 입력을 검사할 수 있지만, 그것은 사용자의 실수를 빠르게 알려주기 위한 검사일 뿐이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;보안을 위한 실제 검증은 반드시 서버에서 다시 수행해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;7. 서버에서 타자 권한 검사하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Server RPC를 호출할 수 있다는 사실만으로 해당 플레이어가 현재 타자라는 의미는 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버는 RPC를 요청한 PlayerController의 PlayerState와 GameState에 저장된 현재 타자를 비교해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;void ABaseballGameMode::HandleGuess(
    ABaseballPlayerController* RequestingPlayer,
    const FString&amp;amp; Guess)
{
    if (!IsValid(RequestingPlayer))
    {
        return;
    }

    ABaseballGameState* BaseballGameState =
        GetGameState&amp;lt;ABaseballGameState&amp;gt;();

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

    APlayerState* RequestingPlayerState =
        RequestingPlayer-&amp;gt;GetPlayerState&amp;lt;APlayerState&amp;gt;();

    if (RequestingPlayerState !=
        BaseballGameState-&amp;gt;BatterPlayerState)
    {
        // 현재 타자가 아닌 플레이어의 요청
        return;
    }

    if (!IsValidGuess(Guess))
    {
        return;
    }

    const bool bIsHit = Guess == SecretNumber;

    BaseballGameState-&amp;gt;ApplyGuessResult(bIsHit);

    if (BaseballGameState-&amp;gt;OutCount &amp;gt;= 3)
    {
        SwitchSides();
    }
    else if (bIsHit)
    {
        GenerateSecretNumber();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 타자인지는 클라이언트가 Boolean 값으로 보내도록 만들지 않았다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 좋지 않은 구조
ServerSubmitGuess(Guess, true);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트가 &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;를 보내는 것은 아무런 검증 근거가 되지 않는다. 서버가 자신이 보유한 PlayerState를 기준으로 직접 판단해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;8. GameState에서 TRY, HIT, OUT 변경하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState의 상태도 서버만 변경할 수 있도록 함수 안에서 Authority를 검사한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;void ABaseballGameState::ApplyGuessResult(bool bIsHit)
{
    if (!HasAuthority())
    {
        return;
    }

    ++TryCount;

    if (bIsHit)
    {
        ++HitCount;
    }
    else
    {
        ++OutCount;
    }

    // Listen Server의 로컬 UI를 위한 갱신
    OnRoundStateChanged.Broadcast(
        TryCount,
        HitCount,
        OutCount
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState 헤더에 함수를 추가한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
    FOnRoundStateChanged,
    int32, TryCount,
    int32, HitCount,
    int32, OutCount
);

UPROPERTY(BlueprintAssignable)
FOnRoundStateChanged OnRoundStateChanged;

void ApplyGuessResult(bool bIsHit);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버가 값을 변경하면 Replication을 통해 클라이언트에 새로운 상태가 전달된다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;9. Replication 등록하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;UPROPERTY(ReplicatedUsing)&lt;/span&gt;&lt;span&gt;만 작성해서는 값이 복제되지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GetLifetimeReplicatedProps()&lt;/span&gt;&lt;span&gt;에서 각 프로퍼티를 등록해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;#include &quot;Net/UnrealNetwork.h&quot;

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

    DOREPLIFETIME(
        ABaseballGameState,
        TryCount
    );

    DOREPLIFETIME(
        ABaseballGameState,
        HitCount
    );

    DOREPLIFETIME(
        ABaseballGameState,
        OutCount
    );

    DOREPLIFETIME(
        ABaseballGameState,
        PitcherPlayerState
    );

    DOREPLIFETIME(
        ABaseballGameState,
        BatterPlayerState
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState는 기본적으로 Replication을 지원하지만, 커스텀 프로퍼티는 직접 등록해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;10. OnRep로 UI 갱신하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트에서 복제된 값이 변경되면 &lt;/span&gt;&lt;span&gt;OnRep_RoundState()&lt;/span&gt;&lt;span&gt;가 호출된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;void ABaseballGameState::OnRep_RoundState()
{
    OnRoundStateChanged.Broadcast(
        TryCount,
        HitCount,
        OutCount
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Widget은 GameState의 델리게이트에 이벤트를 연결해 UI를 갱신한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;TRY : 4
HIT : 1
OUT : 2&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;복제 변수와 UI를 직접 강하게 연결하기보다 GameState가 델리게이트를 발생시키고 Widget이 이를 구독하도록 만들면 역할을 분리할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;GameState
&amp;rarr; 데이터 보관과 변경 알림

Widget
&amp;rarr; 데이터를 화면에 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;주의할 점은 서버에서 값을 변경했을 때 서버 자신의 &lt;/span&gt;&lt;span&gt;OnRep&lt;/span&gt;&lt;span&gt; 함수가 자동 호출된다고 기대하면 안 된다는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Listen Server의 호스트 UI도 갱신해야 한다면 서버에서 값을 변경한 직후 델리게이트를 직접 호출하거나 별도의 공통 갱신 함수를 사용하는 것이 안전하다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;11. 3아웃 공수 교대 처리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;OUT이 3이 되면 서버의 GameMode가 투수와 타자를 교대한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;void ABaseballGameMode::SwitchSides()
{
    ABaseballGameState* BaseballGameState =
        GetGameState&amp;lt;ABaseballGameState&amp;gt;();

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

    Swap(
        BaseballGameState-&amp;gt;PitcherPlayerState,
        BaseballGameState-&amp;gt;BatterPlayerState
    );

    BaseballGameState-&amp;gt;TryCount = 0;
    BaseballGameState-&amp;gt;OutCount = 0;

    GenerateSecretNumber();

    BaseballGameState-&amp;gt;NotifyRoundStateChanged();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;공수 교대 과정은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;OUT 증가
    &amp;darr;
OUT이 3 이상인가?
    ├─ 아니오: 현재 공격 유지
    └─ 예
        ├─ 투수와 타자 교환
        ├─ TRY 초기화
        ├─ OUT 초기화
        ├─ 새 정답 생성
        └─ 변경 상태 복제&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;HIT&lt;/span&gt;&lt;span&gt;을 초기화할지는 게임 규칙에 따라 달라진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;누적 점수로 사용할 경우 유지하고, 이닝 또는 라운드 점수로 사용할 경우 공수 교대 시 초기화할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;중요한 점은 공수 교대 판단도 클라이언트가 아니라 서버에서 처리해야 한다는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;12. 투수의 정답은 복제하지 않는다&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 구현에서 가장 중요한 보안 요소는 정답을 GameState에 저장하지 않는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState에 정답을 Replicated 프로퍼티로 저장하면 모든 클라이언트가 해당 값을 받을 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 정답이 클라이언트에 노출될 수 있는 구조
UPROPERTY(Replicated)
FString SecretNumber;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;화면에 표시하지 않더라도 클라이언트 메모리나 네트워크 데이터를 분석하면 정답을 확인할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 정답은 서버에만 존재하는 GameMode에 저장한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 서버에만 존재
FString SecretNumber;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트에는 판정 결과만 전달한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;클라이언트가 알아야 하는 정보
├─ TRY
├─ HIT
├─ OUT
├─ 현재 투수
└─ 현재 타자

클라이언트가 알면 안 되는 정보
└─ 서버가 관리하는 정답&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;13. Replication과 RPC의 역할 차이&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 구현에서는 RPC와 Replication을 서로 다른 목적으로 사용했다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;RPC&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;플레이어가 숫자를 입력했다는 일회성 요청을 서버에 전달한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;타자의 추측 입력
&amp;rarr; Server RPC&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;Replication&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버에서 확정된 현재 게임 상태를 모든 클라이언트와 동기화한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;TRY, HIT, OUT
&amp;rarr; GameState Replication&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;정리하면 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;RPC
= 어떤 행동을 요청하거나 이벤트를 전달

Replication
= 서버에서 확정된 상태를 지속적으로 동기화&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;TRY, HIT, OUT을 Multicast RPC로만 전달할 수도 있지만, 늦게 접속한 플레이어는 이전 RPC를 받을 수 없다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState의 Replication을 사용하면 새로 접속한 클라이언트도 서버의 현재 상태를 전달받을 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;14. 구현하면서 주의할 점&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;클라이언트의 판정을 신뢰하지 않는다&lt;/span&gt;&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 클라이언트가 판정 결과까지 보내면 안 된다.
ServerSubmitResult(Guess, true);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트는 추측만 보내고 정답 판정은 서버가 수행해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;타자가 아닌 플레이어의 RPC를 검사한다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;RPC 호출이 서버에 도착했더라도 현재 타자가 아니면 요청을 거절해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;정답을 GameState에 저장하지 않는다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;GameState는 클라이언트에 존재하기 때문에 비밀 데이터를 보관하기에 적합하지 않다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;UI에서 게임 규칙을 처리하지 않는다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Widget은 입력과 출력만 담당한다. 아웃 판정이나 공수 교대는 서버 GameMode가 처리해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;RPC 입력 횟수를 제한한다&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;악의적인 클라이언트가 Server RPC를 빠르게 반복 호출할 수 있으므로 서버에서 입력 간격도 검사해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;15. 오늘 배운 내용&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;오늘은 서버 권위 기반으로 멀티플레이 숫자 야구를 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;클라이언트는 자신의 추측만 PlayerController의 Server RPC로 전달하고, GameMode는 서버에서 정답과 추측을 비교했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;판정 결과인 TRY, HIT, OUT은 GameState에 저장하고 Replication을 통해 모든 클라이언트에 동기화했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;OUT이 3이 되면 서버가 투수와 타자를 교대하고 새로운 정답을 생성하도록 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;전체 클래스의 책임은 다음과 같이 정리할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;PlayerController
= 클라이언트 입력을 Server RPC로 전달

GameMode
= 서버 전용 정답, 입력 검증, 판정, 공수 교대

GameState
= TRY, HIT, OUT과 플레이어 역할 복제

Widget
= 입력을 받고 복제된 결과를 화면에 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 구현에서 가장 중요한 원칙은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;클라이언트는 요청한다.
서버는 검증하고 결정한다.
GameState는 결정된 결과를 공유한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>think95592</author>
      <guid isPermaLink="true">https://think95592.tistory.com/87</guid>
      <comments>https://think95592.tistory.com/87#entry87comment</comments>
      <pubDate>Mon, 3 Aug 2026 20:57:05 +0900</pubDate>
    </item>
  </channel>
</rss>