<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>write76465 님의 블로그</title>
    <link>https://write76465.tistory.com/</link>
    <description>write76465 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Tue, 28 Jul 2026 23:05:31 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>write76465</managingEditor>
    <item>
      <title>비헤이비어 트리로 구현하는 언리얼 엔진 AI</title>
      <link>https://write76465.tistory.com/25</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고 영상: &lt;a href=&quot;https://youtu.be/ZupTZC_VJiA?si=MGamRfwUKLvTLX4w&quot;&gt;&lt;span&gt;&lt;span&gt;비헤이비어 트리를 활용한 언리얼 엔진 AI | Unreal Engine KR&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임에서 적 캐릭터는 단순히 플레이어를 따라가기만 해서는 자연스럽게 보이지 않는다. 평소에는 주변을 순찰하고, 플레이어를 발견하면 추격하며, 시야에서 놓치면 마지막으로 목격한 장소를 조사해야 한다. 상황에 따라 적절한 엄폐 장소를 선택할 수도 있어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 여러 행동을 조건문만으로 구현하면 코드가 빠르게 복잡해진다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;플레이어를 발견했는가?
    발견했다면 추격한다.
    발견하지 못했다면 순찰한다.

추격 중 플레이어를 놓쳤는가?
    놓쳤다면 마지막 위치를 조사한다.

공격받았는가?
    공격받았다면 공격자를 찾는다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;행동이 추가될수록 조건문은 서로 얽히고, 현재 AI가 어떤 판단을 내리고 있는지 확인하기 어려워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 엔진은 이런 문제를 해결하기 위해 다음과 같은 AI 시스템을 제공한다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;AI Perception
      &amp;darr;
Blackboard
      &amp;darr;
Behavior Tree
      &amp;darr;
EQS
      &amp;darr;
이동&amp;middot;추격&amp;middot;공격&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 시스템은 독립적인 기능을 담당하지만 서로 연결되어 하나의 AI를 구성한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 언리얼 엔진 AI의 전체 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 엔진에서 일반적인 적 AI는 다음 요소로 구성한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Character 또는 Pawn&lt;/li&gt;
&lt;li&gt;AI Controller&lt;/li&gt;
&lt;li&gt;Blackboard&lt;/li&gt;
&lt;li&gt;Behavior Tree&lt;/li&gt;
&lt;li&gt;AI Perception&lt;/li&gt;
&lt;li&gt;Environment Query System&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Character는 실제 게임 월드에 존재하는 몸체다. 이동하고 애니메이션을 재생하며 공격을 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI Controller는 Character를 조종하는 두뇌 역할을 한다. 플레이어가 Player Controller를 통해 캐릭터를 조작하는 것처럼 AI 캐릭터는 AI Controller의 제어를 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Behavior Tree는 AI가 어떤 행동을 실행할지 결정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Blackboard는 Behavior Tree가 판단에 사용할 정보를 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI Perception은 시각과 청각 등의 감각 정보를 수집한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EQS는 주변 환경을 검사하여 이동하거나 숨기에 가장 적절한 위치를 선택한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKJJmx/dJMcad3TXaV/drcTdNQq6QSzveKhprkMD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKJJmx/dJMcad3TXaV/drcTdNQq6QSzveKhprkMD1/img.png&quot; data-alt=&quot;감각 정보가 블랙보드에 저장되고, 비헤이비어 트리가 이를 바탕으로 캐릭터의 행동을 결정하는 과정이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKJJmx/dJMcad3TXaV/drcTdNQq6QSzveKhprkMD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKJJmx%2FdJMcad3TXaV%2FdrcTdNQq6QSzveKhprkMD1%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;873&quot; height=&quot;491&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;감각 정보가 블랙보드에 저장되고, 비헤이비어 트리가 이를 바탕으로 캐릭터의 행동을 결정하는 과정이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Player
   &amp;darr; 감지
AI Perception
   &amp;darr; 정보 저장
Blackboard
   &amp;darr; 조건 확인
Behavior Tree
   &amp;darr; 명령
AI Controller
   &amp;darr; 조종
AI Character&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Behavior Tree란 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Behavior Tree는 AI의 행동을 트리 형태로 구성하는 시스템이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 적 AI가 다음 행동을 수행한다고 가정한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;플레이어가 보이면 추격한다.
플레이어가 보이지 않으면 순찰한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 비헤이비어 트리로 표현하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;Root
 └─ Selector
     ├─ Sequence: 플레이어 추격
     │   ├─ 플레이어가 보이는가?
     │   ├─ 플레이어 방향으로 회전
     │   └─ 플레이어에게 이동
     │
     └─ Sequence: 순찰
         ├─ 순찰 위치 선택
         └─ 순찰 위치로 이동&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 엔진의 비헤이비어 트리는 기본적으로 위에서 아래로 내려가며, 같은 높이에서는 왼쪽에서 오른쪽 순서로 실행한다. 따라서 왼쪽에 있는 브랜치가 더 높은 우선순위를 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 엔진의 비헤이비어 트리는 불필요한 조건을 매 프레임 검사하는 방식보다 이벤트 중심으로 동작하도록 설계되어 있다. 블랙보드 값이 변경되면 관련 브랜치를 중단하고 우선순위가 높은 행동으로 전환할 수 있다. &lt;a href=&quot;https://dev.epicgames.com/documentation/en-us/unreal-engine/behavior-tree-in-unreal-engine---overview&quot;&gt;&lt;span&gt;&lt;span&gt;언리얼 엔진 Behavior Tree 개요&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dVBHpR/dJMcacYhSDl/h27Y9KKkqiWkI7dLDMFDPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dVBHpR/dJMcacYhSDl/h27Y9KKkqiWkI7dLDMFDPk/img.png&quot; data-alt=&quot;플레이어 감지 여부에 따라 추격 브랜치와 순찰 브랜치 중 하나를 선택하는 구조다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dVBHpR/dJMcacYhSDl/h27Y9KKkqiWkI7dLDMFDPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdVBHpR%2FdJMcacYhSDl%2Fh27Y9KKkqiWkI7dLDMFDPk%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;873&quot; height=&quot;491&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;플레이어 감지 여부에 따라 추격 브랜치와 순찰 브랜치 중 하나를 선택하는 구조다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;              Root
                &amp;darr;
             Selector
          ↙              ↘
   플레이어 추격          순찰
   HasTarget = true     HasTarget = false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Selector와 Sequence의 차이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비헤이비어 트리에서 가장 중요한 Composite 노드는 Selector와 Sequence다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Selector&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Selector는 여러 행동 중 성공할 수 있는 하나를 선택한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왼쪽 자식부터 실행하며 성공한 브랜치가 발견되면 나머지 브랜치는 실행하지 않는다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Selector
 ├─ 플레이어를 추격할 수 있는가?
 ├─ 마지막 위치를 조사할 수 있는가?
 └─ 순찰한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어를 추격할 수 있다면 첫 번째 브랜치를 실행한다. 추격할 수 없다면 두 번째 브랜치를 검사한다. 두 번째도 실행할 수 없다면 마지막 순찰 브랜치를 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로그래밍의 if, else if, else 구조와 비슷하다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;if (CanChasePlayer)
{
    ChasePlayer();
}
else if (CanInvestigate)
{
    Investigate();
}
else
{
    Patrol();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Sequence&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sequence는 여러 행동을 순서대로 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 자식이 성공해야 Sequence 전체가 성공한다. 중간 행동이 실패하면 이후 행동은 실행하지 않는다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;Sequence: 플레이어 공격
 ├─ 플레이어에게 이동한다.
 ├─ 공격 범위인지 확인한다.
 ├─ 플레이어 방향으로 회전한다.
 └─ 공격한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어에게 이동하지 못하면 공격 범위 확인과 공격 행동은 실행하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Selector가 &amp;ldquo;어떤 행동을 할 것인가&amp;rdquo;를 선택한다면, Sequence는 &amp;ldquo;선택한 행동을 어떤 순서로 수행할 것인가&amp;rdquo;를 나타낸다. &lt;a href=&quot;https://dev.epicgames.com/documentation/en-us/unreal-engine/behavior-tree-in-unreal-engine---quick-start-guide&quot;&gt;&lt;span&gt;&lt;span&gt;Behavior Tree Quick Start Guide&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dptxP3/dJMcaiEdM6M/jQvjsF5SLokbKOWcmGSet0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dptxP3/dJMcaiEdM6M/jQvjsF5SLokbKOWcmGSet0/img.png&quot; data-alt=&quot;Selector는 행동을 선택하고, Sequence는 선택된 행동의 실행 순서를 구성한다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dptxP3/dJMcaiEdM6M/jQvjsF5SLokbKOWcmGSet0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdptxP3%2FdJMcaiEdM6M%2FjQvjsF5SLokbKOWcmGSet0%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;873&quot; height=&quot;491&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Selector는 행동을 선택하고, Sequence는 선택된 행동의 실행 순서를 구성한다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div&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;Selector&lt;/td&gt;
&lt;td&gt;하나가 성공할 때까지 실행한다&lt;/td&gt;
&lt;td&gt;if / else if / else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequence&lt;/td&gt;
&lt;td&gt;하나가 실패할 때까지 순서대로 실행한다&lt;/td&gt;
&lt;td&gt;A &amp;amp;&amp;amp; B &amp;amp;&amp;amp; C&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Task, Decorator, Service&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비헤이비어 트리에는 Composite 외에도 세 가지 중요한 노드가 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Task&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Task는 AI가 실제로 수행할 행동이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Move To
Wait
Play Animation
Run EQS Query
Attack
Find Patrol Location&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Move To는 지정된 액터나 위치로 이동하는 Task다. 공격이나 순찰 위치 선택처럼 게임 고유의 행동은 Blueprint 또는 C++로 커스텀 Task를 만들어 구현할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Decorator&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Decorator는 브랜치가 실행될 수 있는지 검사하는 조건이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;HasTarget == true
CanAttack == true
Health &amp;lt;= 30
IsPlayerVisible == true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Decorator 조건이 거짓이면 해당 브랜치에 진입하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Observer Aborts 설정을 이용하면 블랙보드 값이 변했을 때 현재 실행 중인 브랜치를 중단할 수 있다. 예를 들어 순찰 중 HasTarget이 참으로 변경되면 순찰을 중단하고 추격 브랜치로 전환한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Service&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service는 특정 브랜치가 실행되는 동안 주기적으로 정보를 갱신한다.&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;모든 기능을 Service의 Tick에 넣으면 주기적인 연산이 많아질 수 있다. 감지 결과처럼 이벤트로 받을 수 있는 정보는 AI Perception 이벤트를 이용하고, 반드시 주기적으로 확인해야 하는 값만 Service에서 갱신하는 편이 좋다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJl67u/dJMcahrOYIw/HnR6p0b02bcsgqykjDMdp1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJl67u/dJMcahrOYIw/HnR6p0b02bcsgqykjDMdp1/img.png&quot; data-alt=&quot;비헤이비어 트리를 구성하는 네 가지 노드와 각 노드가 담당하는 역할이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJl67u/dJMcahrOYIw/HnR6p0b02bcsgqykjDMdp1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJl67u%2FdJMcahrOYIw%2FHnR6p0b02bcsgqykjDMdp1%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;873&quot; height=&quot;491&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;비헤이비어 트리를 구성하는 네 가지 노드와 각 노드가 담당하는 역할이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Composite &amp;rarr; 흐름을 제어한다.
Task      &amp;rarr; 행동을 실행한다.
Decorator &amp;rarr; 실행 조건을 검사한다.
Service   &amp;rarr; 정보를 주기적으로 갱신한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Blackboard는 AI의 기억 공간이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Blackboard는 AI가 판단에 사용할 값을 저장하는 데이터 공간이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 Key를 만들 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;Blackboard Key자료형의미
&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;TargetActor&lt;/td&gt;
&lt;td&gt;Object&lt;/td&gt;
&lt;td&gt;현재 추적 중인 플레이어&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HasLineOfSight&lt;/td&gt;
&lt;td&gt;Bool&lt;/td&gt;
&lt;td&gt;플레이어가 시야에 보이는지 여부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LastKnownLocation&lt;/td&gt;
&lt;td&gt;Vector&lt;/td&gt;
&lt;td&gt;마지막으로 확인한 플레이어 위치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PatrolLocation&lt;/td&gt;
&lt;td&gt;Vector&lt;/td&gt;
&lt;td&gt;다음 순찰 위치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CanAttack&lt;/td&gt;
&lt;td&gt;Bool&lt;/td&gt;
&lt;td&gt;현재 공격할 수 있는지 여부&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Behavior Tree는 직접 모든 정보를 찾기보다 Blackboard 값을 읽어 행동을 결정한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;HasLineOfSight == true
        &amp;darr;
TargetActor에게 이동한다.

HasLineOfSight == false
        &amp;darr;
LastKnownLocation으로 이동한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서에서도 Behavior Tree가 실행할 브랜치를 결정하기 위해 Blackboard Key를 사용한다고 설명한다. &lt;a href=&quot;https://dev.epicgames.com/documentation/en-us/unreal-engine/behavior-trees-in-unreal-engine&quot;&gt;&lt;span&gt;&lt;span&gt;언리얼 엔진 Behavior Tree 문서&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Blackboard는 단순한 변수 모음이 아니다. AI Perception, Behavior Tree, EQS 사이에서 정보를 전달하는 공용 저장소에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pt82t/dJMcadpivtt/T2pVldazYlumZticMCJrc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pt82t/dJMcadpivtt/T2pVldazYlumZticMCJrc1/img.png&quot; data-alt=&quot;감지 결과와 환경 탐색 결과가 블랙보드에 저장되고, 비헤이비어 트리가 값을 읽어 행동을 선택한다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pt82t/dJMcadpivtt/T2pVldazYlumZticMCJrc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fpt82t%2FdJMcadpivtt%2FT2pVldazYlumZticMCJrc1%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;873&quot; height=&quot;491&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;감지 결과와 환경 탐색 결과가 블랙보드에 저장되고, 비헤이비어 트리가 값을 읽어 행동을 선택한다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;AI Perception ──쓰기──▶ Blackboard ◀──쓰기── EQS
                              │
                             읽기
                              ▼
                       Behavior Tree&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. AI Perception으로 플레이어를 감지한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI Perception은 AI에게 시각, 청각과 같은 감각 기능을 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 Sense는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI Sight: 시야 안의 액터를 감지한다.&lt;/li&gt;
&lt;li&gt;AI Hearing: 발생한 소리를 감지한다.&lt;/li&gt;
&lt;li&gt;AI Damage: 피해를 준 대상을 감지한다.&lt;/li&gt;
&lt;li&gt;AI Touch: 접촉한 대상을 감지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI Controller에 AI Perception Component를 추가하면 감지 이벤트를 받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어를 시각으로 감지한 경우의 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;플레이어가 시야에 들어온다.
        &amp;darr;
On Target Perception Updated
        &amp;darr;
Successfully Sensed 확인
        &amp;darr;
TargetActor = 플레이어
HasLineOfSight = true
        &amp;darr;
Behavior Tree의 추격 브랜치 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 플레이어를 놓치면 다음처럼 처리할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;Successfully Sensed = false
        &amp;darr;
LastKnownLocation 저장
        &amp;darr;
HasLineOfSight = false
        &amp;darr;
마지막 목격 위치 조사&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI Perception Component는 자극을 수집하고, 감지 결과가 변경되면 이벤트를 통해 Blueprint 로직이나 Behavior Tree가 사용하는 값을 갱신할 수 있다. &lt;a href=&quot;https://dev.epicgames.com/documentation/unreal-engine/ai-perception-in-unreal-engine?lang=en-US&quot;&gt;&lt;span&gt;&lt;span&gt;AI Perception 공식 문서&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r2r4f/dJMcaiYptwV/4ox3dCVvPAKKGpD78R5s50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r2r4f/dJMcaiYptwV/4ox3dCVvPAKKGpD78R5s50/img.png&quot; data-alt=&quot;플레이어가 AI의 시야 범위에 들어오면 감지 이벤트가 발생하고 블랙보드 값이 변경된다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r2r4f/dJMcaiYptwV/4ox3dCVvPAKKGpD78R5s50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr2r4f%2FdJMcaiYptwV%2F4ox3dCVvPAKKGpD78R5s50%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;873&quot; height=&quot;491&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;플레이어가 AI의 시야 범위에 들어오면 감지 이벤트가 발생하고 블랙보드 값이 변경된다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;       AI 시야 범위
      ╱           ╲
   Enemy ───────▶ Player
      │
      └─ On Target Perception Updated
                    &amp;darr;
          HasLineOfSight = true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. EQS로 최적의 위치를 찾는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Behavior Tree가 &amp;ldquo;무엇을 할 것인가&amp;rdquo;를 정한다면, EQS는 &amp;ldquo;어디에서 할 것인가&amp;rdquo;를 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 AI가 엄폐할 위치를 찾는다고 가정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주변에 이동 가능한 여러 후보 지점을 생성한 뒤 다음 조건으로 평가한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;플레이어에게서 너무 가깝지 않은가?
AI가 이동할 수 있는 위치인가?
플레이어의 시야를 차단할 수 있는가?
공격할 수 있는 시야가 확보되는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EQS는 크게 세 요소로 구성한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Generator&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검사할 후보 위치나 액터를 생성한다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;AI 주변의 원형 지점
플레이어 주변의 이동 가능 지점
레벨에 배치된 엄폐물&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Context&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거리나 방향을 계산할 때 기준이 되는 대상이다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;현재 AI
플레이어
마지막 목격 위치
특정 목표 액터&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Test&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;모든 Test를 통과한 후보 중 가장 높은 점수를 얻은 위치를 최종 결과로 선택한다.&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;EQS Query는 Behavior Tree의 Run EQS Query Task에서 실행할 수 있으며, 결과 위치를 Blackboard에 저장한 뒤 Move To Task로 이동할 수 있다. &lt;a href=&quot;https://dev.epicgames.com/documentation/ko-kr/unreal-engine/environment-query-system-in-unreal-engine&quot;&gt;&lt;span&gt;&lt;span&gt;EQS 공식 문서&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWRZRn/dJMcadbJcud/HxLQdM3lHQLhJlosIolBFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWRZRn/dJMcadbJcud/HxLQdM3lHQLhJlosIolBFK/img.png&quot; data-alt=&quot;주변 후보 지점을 생성한 뒤 거리&amp;amp;middot;시야&amp;amp;middot;이동 가능 여부를 평가하여 가장 높은 점수의 위치를 선택한다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWRZRn/dJMcadbJcud/HxLQdM3lHQLhJlosIolBFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWRZRn%2FdJMcadbJcud%2FHxLQdM3lHQLhJlosIolBFK%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;873&quot; height=&quot;491&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;주변 후보 지점을 생성한 뒤 거리&amp;middot;시야&amp;middot;이동 가능 여부를 평가하여 가장 높은 점수의 위치를 선택한다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;     후보 A  20점
후보 B  80점   Player
     AI       후보 C  45점
        후보 D 제외

최종 선택 &amp;rarr; 후보 B&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 잠입 게임 적 AI의 전체 동작&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 살펴본 시스템을 하나로 연결하면 다음과 같은 AI를 만들 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;평상시&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;TargetActor가 없다.
        &amp;darr;
EQS로 순찰 위치를 찾는다.
        &amp;darr;
PatrolLocation에 저장한다.
        &amp;darr;
Move To로 이동한다.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;플레이어 발견&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;AI Perception이 플레이어를 감지한다.
        &amp;darr;
TargetActor와 HasLineOfSight를 갱신한다.
        &amp;darr;
Decorator가 변경을 감지한다.
        &amp;darr;
순찰 브랜치를 중단한다.
        &amp;darr;
플레이어 추격 브랜치를 실행한다.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;플레이어를 놓친 경우&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;HasLineOfSight = false
        &amp;darr;
마지막 목격 위치로 이동한다.
        &amp;darr;
주변을 일정 시간 조사한다.
        &amp;darr;
플레이어를 찾지 못하면 다시 순찰한다.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전투 위치 선택&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;Run EQS Query
        &amp;darr;
플레이어와 거리를 유지하면서
시야가 확보되는 위치를 찾는다.
        &amp;darr;
결과를 Blackboard에 저장한다.
        &amp;darr;
해당 위치로 이동한 뒤 공격한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MK4xO/dJMcaasF4G0/PDSZs6MOnAO6ckZfR5eLG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MK4xO/dJMcaasF4G0/PDSZs6MOnAO6ckZfR5eLG0/img.png&quot; data-alt=&quot;감지 결과에 따라 순찰, 추격, 마지막 위치 조사 상태가 전환되는 과정이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MK4xO/dJMcaasF4G0/PDSZs6MOnAO6ckZfR5eLG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMK4xO%2FdJMcaasF4G0%2FPDSZs6MOnAO6ckZfR5eLG0%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;873&quot; height=&quot;491&quot; data-filename=&quot;download.png&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;감지 결과에 따라 순찰, 추격, 마지막 위치 조사 상태가 전환되는 과정이다.&lt;/figcaption&gt;
&lt;/figure&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;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 구현할 때 주의할 점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Blackboard Key를 지나치게 많이 만들지 않는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 임시 값을 Blackboard에 넣으면 데이터의 의미를 파악하기 어려워진다. 여러 Task와 시스템이 공유해야 하는 정보만 Blackboard에 저장한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Service의 갱신 주기를 조절한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service는 필요한 주기로만 실행한다. 매 프레임 실행할 필요가 없는 거리 계산이나 목표 탐색을 지나치게 자주 수행하면 AI 수가 증가했을 때 성능에 영향을 줄 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왼쪽 브랜치에 높은 우선순위를 배치한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 엔진의 비헤이비어 트리는 왼쪽에서 오른쪽 순서로 실행한다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Selector
 ├─ 사망 처리
 ├─ 피격 대응
 ├─ 전투
 └─ 순찰&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉시 처리해야 하는 행동일수록 왼쪽에 배치하는 것이 자연스럽다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;감지와 판단을 분리한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI Perception은 정보를 감지하고, Blackboard는 정보를 저장하며, Behavior Tree는 행동을 결정하도록 역할을 분리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI Perception 이벤트 안에서 이동과 공격까지 모두 처리하면 시스템 간 책임이 뒤섞이게 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비헤이비어 트리는 AI의 모든 기능을 직접 처리하는 시스템이 아니다. 여러 AI 시스템을 연결하여 행동의 우선순위와 실행 흐름을 관리하는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 관계는 다음과 같이 정리할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;AI Perception
&amp;rarr; 무엇을 보고 들었는지 감지한다.

Blackboard
&amp;rarr; AI가 알고 있는 정보를 저장한다.

Behavior Tree
&amp;rarr; 현재 실행할 행동을 결정한다.

EQS
&amp;rarr; 행동을 수행하기 가장 좋은 위치나 대상을 찾는다.

AI Controller
&amp;rarr; 결정된 행동을 Character에게 실행시킨다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 이해하면 단순한 순찰 AI부터 플레이어를 추적하고 엄폐물을 활용하는 전투 AI까지 단계적으로 확장할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상은 2021년에 공개된 자료이므로 현재 언리얼 엔진 버전에서는 일부 메뉴와 에디터 화면이 달라질 수 있다. 하지만 Behavior Tree, Blackboard, AI Perception, EQS가 연결되는 기본 구조는 현재 공식 문서에서도 동일하게 설명한다.&lt;/p&gt;</description>
      <category>언리얼엔진/개념&amp;middot;이론</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/25</guid>
      <comments>https://write76465.tistory.com/25#entry25comment</comments>
      <pubDate>Mon, 27 Jul 2026 22:02:51 +0900</pubDate>
    </item>
    <item>
      <title>배열의 한계를 넘는 연결 리스트: 노드, 포인터, 삽입과 삭제 원리</title>
      <link>https://write76465.tistory.com/24</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;배열은 같은 자료형의 데이터를 연속된 메모리 공간에 저장한다. 인덱스를 통해 빠르게 접근할 수 있다는 장점이 있지만, 크기가 고정되고 중간 데이터를 삽입하거나 삭제하기 어렵다는 단점도 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 리스트는 각 데이터를 별도의 노드에 저장하고 포인터로 연결한다. 노드들이 메모리에 연속해서 배치될 필요가 없기 때문에 실행 중에 크기를 자유롭게 변경할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 코드조선의 &lt;a href=&quot;https://youtu.be/p0vWlUa5cx8&quot;&gt;&lt;span&gt;&lt;span&gt;Linked List 강의&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;를 바탕으로 연결 리스트의 구조, 동적 메모리 할당, 앞&amp;middot;뒤 삽입, 이중 포인터를 이용한 순회와 삭제, 재귀적인 메모리 해제 원리를 정리한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 배열의 특징과 한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열은 여러 데이터를 연속된 메모리 공간에 저장한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;int Numbers[4] = {10, 20, 30, 40};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리에서는 다음과 비슷한 형태로 배치된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;주소      값
0x1000   10
0x1004   20
0x1008   30
0x100C   40&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;int가 4바이트라면 각 원소가 4바이트 간격으로 붙어 있다. 따라서 시작 주소와 인덱스를 이용해 원하는 원소의 위치를 바로 계산할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;fix&quot;&gt;&lt;code&gt;원소 주소 = 시작 주소 + 인덱스 &amp;times; 자료형 크기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 접근의 시간복잡도는 O(1)이다.&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;printf(&quot;%d\n&quot;, Numbers[2]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 배열은 일반적으로 크기를 미리 결정해야 한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;int Numbers[4];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원소가 4개보다 많아지면 더 큰 배열을 만들고 기존 데이터를 복사해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열 중간에 새로운 값을 삽입할 때도 문제가 생긴다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;삽입 전

10  20  30  40&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;20과 30 사이에 25를 삽입하려면 뒤쪽 값을 이동해야 한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;10  20  25  30  40
            &amp;rarr;   &amp;rarr;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 N개라면 최악의 경우 N개에 가까운 데이터를 이동해야 하므로 삽입과 삭제의 시간복잡도는 O(N)이 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-a02208f3-8804-49bf-ab0e-7498ee256014.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czM3Kb/dJMcai49We1/MOVsHoaN3y9LzOCDDUVZpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czM3Kb/dJMcai49We1/MOVsHoaN3y9LzOCDDUVZpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czM3Kb/dJMcai49We1/MOVsHoaN3y9LzOCDDUVZpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FczM3Kb%2FdJMcai49We1%2FMOVsHoaN3y9LzOCDDUVZpk%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-a02208f3-8804-49bf-ab0e-7498ee256014.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&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;연결 리스트는 데이터를 노드 단위로 저장하는 자료구조이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 노드는 일반적으로 다음 두 가지 정보를 가진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 실제 데이터
2. 다음 노드의 주소&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 그림으로 표현하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;purebasic&quot;&gt;&lt;code&gt;┌─────────────┬─────────────┐
│ Data        │ Next        │
│ 10          │ 다음 주소   │
└─────────────┴─────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 노드를 연결하면 다음과 같은 리스트가 만들어진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head
 &amp;darr;
[10 | 다음] &amp;rarr; [20 | 다음] &amp;rarr; [30 | NULL]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 노드는 다음 노드가 없으므로 NULL을 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 리스트의 노드는 반드시 서로 붙어 있을 필요가 없다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;0x1000                0x3500                0x2200
[10 | 0x3500]   &amp;rarr;    [20 | 0x2200]   &amp;rarr;    [30 | NULL]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 노드가 다음 노드의 메모리 주소를 알고 있으므로 메모리 위치가 떨어져 있어도 하나의 자료구조로 연결할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 연결 리스트의 노드 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C 언어에서는 구조체로 노드를 정의할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;struct Node
{
    int Data;
    struct Node* PtrToNextNode;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Data에는 실제 데이터를 저장한다.&lt;/p&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;int Data;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PtrToNextNode에는 다음 노드의 주소를 저장한다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;struct Node* PtrToNextNode;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 구조체를 반복해서 작성하지 않도록 typedef를 사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;typedef struct Node Node_t;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 코드는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;typedef struct Node
{
    int Data;
    struct Node* PtrToNextNode;
} Node_t;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 다음과 같이 간단하게 노드 자료형을 사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;Node_t Node;
Node_t* PtrToNode;&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-335ad178-62b5-438c-911f-97f21be4cecc.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lWrQy/dJMcaaMVb8u/ypk9nMejZjStMfCxGxcKCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lWrQy/dJMcaaMVb8u/ypk9nMejZjStMfCxGxcKCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lWrQy/dJMcaaMVb8u/ypk9nMejZjStMfCxGxcKCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlWrQy%2FdJMcaaMVb8u%2Fypk9nMejZjStMfCxGxcKCk%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-335ad178-62b5-438c-911f-97f21be4cecc.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Head 포인터의 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 리스트는 첫 번째 노드의 위치를 알아야 전체 노드를 탐색할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 노드를 가리키는 포인터를 일반적으로 Head라고 한다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Node_t* PtrToHeadNode = NULL;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트가 비어 있을 때 Head는 NULL이다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Head &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 노드가 생기면 Head에 첫 번째 노드의 주소를 저장한다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Head &amp;rarr; [10 | NULL]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드가 추가되면 각 노드의 PtrToNextNode를 따라간다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head
 &amp;darr;
[10] &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Head를 잃어버리면 첫 번째 노드에 접근할 방법이 사라진다. 결과적으로 연결된 나머지 노드에도 접근할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Head는 연결 리스트의 시작점을 보관하는 중요한 포인터이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-2885c892-ee7d-4df1-a235-c398626cbea2.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6x5EJ/dJMcaheezSE/RtXGEnRRdakcEYtqrcBUdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6x5EJ/dJMcaheezSE/RtXGEnRRdakcEYtqrcBUdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6x5EJ/dJMcaheezSE/RtXGEnRRdakcEYtqrcBUdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6x5EJ%2FdJMcaheezSE%2FRtXGEnRRdakcEYtqrcBUdK%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-2885c892-ee7d-4df1-a235-c398626cbea2.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 노드는 동적 메모리에 생성한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 리스트는 실행 중에 노드의 개수를 변경하기 위해 동적 메모리를 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 노드는 malloc()으로 생성한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;Node_t* PtrToNewNode =
    (Node_t*)malloc(sizeof(Node_t));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sizeof(Node_t)는 노드 하나를 저장하는 데 필요한 메모리 크기를 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;malloc()이 메모리 할당에 실패하면 NULL을 반환한다. 따라서 결과를 확인해야 한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;assert(PtrToNewNode != NULL);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리를 할당한 뒤에는 노드의 내용을 설정한다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;PtrToNewNode-&amp;gt;Data = InData;
PtrToNewNode-&amp;gt;PtrToNextNode = NULL;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 생성한 노드는 아직 연결 리스트에 포함된 것이 아니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;기존 리스트

Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;새 노드

PtrToNewNode &amp;rarr; [30 | NULL]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 노드를 기존 노드와 연결하거나 Head에 넣어야 비로소 리스트의 일부가 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 리스트 앞에 노드 삽입하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트의 맨 앞에 노드를 삽입하는 함수는 다음과 같이 작성할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void InsertFront(
    Node_t** InPtrToPtrToHeadNode,
    int InData)
{
    Node_t* PtrToNewNode =
        (Node_t*)malloc(sizeof(Node_t));

    assert(PtrToNewNode != NULL);

    PtrToNewNode-&amp;gt;Data = InData;

    PtrToNewNode-&amp;gt;PtrToNextNode =
        *InPtrToPtrToHeadNode;

    *InPtrToPtrToHeadNode =
        PtrToNewNode;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 매개변수로 Node_t**를 받는 이유는 원래 Head 포인터의 값을 변경해야 하기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;InsertFront(&amp;amp;PtrToHeadNode, 10);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;삽입 전&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Head &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; NULL

새 노드 &amp;rarr; [10 | NULL]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 새 노드가 기존 첫 번째 노드를 가리키게 한다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;PtrToNewNode-&amp;gt;PtrToNextNode =
    *InPtrToPtrToHeadNode;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;새 노드 [10] &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 Head가 새 노드를 가리키게 한다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;*InPtrToPtrToHeadNode =
    PtrToNewNode;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 문장의 순서가 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Head를 먼저 새 노드로 변경하면 기존 첫 번째 노드의 주소를 잃어버릴 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에 삽입하는 작업은 기존 노드 개수와 관계없이 포인터 두 개만 수정한다. 따라서 시간복잡도는 O(1)이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-6bb9684b-cae2-4fa0-aaa7-921ae3f6151f.png&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/diFagx/dJMcai49WGt/eDj4yhrqIkgv9BKHwLS9QK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/diFagx/dJMcai49WGt/eDj4yhrqIkgv9BKHwLS9QK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/diFagx/dJMcai49WGt/eDj4yhrqIkgv9BKHwLS9QK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdiFagx%2FdJMcai49WGt%2FeDj4yhrqIkgv9BKHwLS9QK%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;1774&quot; height=&quot;887&quot; data-filename=&quot;exec-6bb9684b-cae2-4fa0-aaa7-921ae3f6151f.png&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;p data-ke-size=&quot;size16&quot;&gt;영상에서는 이중 포인터를 이용해 다음과 같이 구현한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void InsertRear(
    Node_t** InPtrToPtrToHeadNode,
    int InData)
{
    Node_t* PtrToNewNode =
        (Node_t*)malloc(sizeof(Node_t));

    assert(PtrToNewNode != NULL);

    PtrToNewNode-&amp;gt;Data = InData;
    PtrToNewNode-&amp;gt;PtrToNextNode = NULL;

    Node_t** PtrToPtrToLastNode =
        InPtrToPtrToHeadNode;

    while (*PtrToPtrToLastNode != NULL)
    {
        PtrToPtrToLastNode =
            &amp;amp;((*PtrToPtrToLastNode)
                -&amp;gt;PtrToNextNode);
    }

    *PtrToPtrToLastNode =
        PtrToNewNode;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 이해하려면 PtrToPtrToLastNode가 노드를 직접 가리키는 포인터가 아니라는 점을 알아야 한다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;Node_t** PtrToPtrToLastNode;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 변수는 &lt;b&gt;현재 노드를 가리키는 포인터의 주소&lt;/b&gt;를 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Head 포인터의 주소를 가진다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;PtrToPtrToLastNode
        &amp;darr;
     Head 포인터
        &amp;darr;
      첫 노드&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문 안에서는 현재 노드의 PtrToNextNode 주소로 이동한다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;PtrToPtrToLastNode =
    &amp;amp;((*PtrToPtrToLastNode)
        -&amp;gt;PtrToNextNode);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 줄로 풀면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;Node_t* PtrToCurrentNode =
    *PtrToPtrToLastNode;

Node_t** PtrToPtrToNextNode =
    &amp;amp;(PtrToCurrentNode-&amp;gt;PtrToNextNode);

PtrToPtrToLastNode =
    PtrToPtrToNextNode;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트가 다음과 같다고 가정한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복할 때마다 가리키는 위치가 다음과 같이 이동한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head 포인터의 주소
&amp;rarr; 10 노드의 next 주소
&amp;rarr; 20 노드의 next 주소
&amp;rarr; 30 노드의 next 주소&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;30 노드의 next에는 NULL이 들어 있다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;PtrToPtrToLastNode
        &amp;darr;
[30 노드의 next 포인터]
        &amp;darr;
       NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 반복문이 종료된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 그 위치에 새 노드의 주소를 넣는다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;*PtrToPtrToLastNode =
    PtrToNewNode;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; [40] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;리스트가 비어 있어도 동작한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트가 비어 있다면 처음부터 다음 상태이다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;PtrToPtrToLastNode &amp;rarr; Head
*PtrToPtrToLastNode = NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문은 한 번도 실행되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 문장이 Head에 새 노드의 주소를 넣는다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;*PtrToPtrToLastNode = PtrToNewNode;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Head &amp;rarr; [새 노드] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이중 포인터를 사용했기 때문에 빈 리스트와 노드가 존재하는 리스트를 같은 코드로 처리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 노드를 찾기 위해 모든 노드를 지나가므로 시간복잡도는 O(N)이다. 별도의 Tail 포인터를 관리한다면 뒤쪽 삽입을 O(1)로 만들 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-d0c3636b-becc-41c6-9d17-852f11af7f15.png&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eq4X9f/dJMcac4Z4mH/jDY6hV9mXpmP9Aemaff440/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eq4X9f/dJMcac4Z4mH/jDY6hV9mXpmP9Aemaff440/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eq4X9f/dJMcac4Z4mH/jDY6hV9mXpmP9Aemaff440/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Feq4X9f%2FdJMcac4Z4mH%2FjDY6hV9mXpmP9Aemaff440%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;1672&quot; height=&quot;941&quot; data-filename=&quot;exec-d0c3636b-becc-41c6-9d17-852f11af7f15.png&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 연결 리스트 전체를 출력하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 리스트를 출력할 때는 Head부터 시작해 NULL을 만날 때까지 이동한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void Print(Node_t* InPtrToHeadNode)
{
    Node_t* PtrToCurrentNode =
        InPtrToHeadNode;

    while (PtrToCurrentNode != NULL)
    {
        printf(&quot;%d &quot;, PtrToCurrentNode-&amp;gt;Data);

        PtrToCurrentNode =
            PtrToCurrentNode-&amp;gt;PtrToNextNode;
    }

    printf(&quot;\n&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 과정은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;현재 노드: 10 &amp;rarr; 출력
현재 노드: 20 &amp;rarr; 출력
현재 노드: 30 &amp;rarr; 출력
현재 노드: NULL &amp;rarr; 종료&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;10 20 30&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 노드를 확인하므로 시간복잡도는 O(N)이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열은 인덱스를 이용해 특정 위치에 O(1)로 접근할 수 있다. 반면 단일 연결 리스트에서 세 번째 노드를 찾으려면 첫 번째 노드부터 포인터를 따라가야 한다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;Head &amp;rarr; 첫 번째 &amp;rarr; 두 번째 &amp;rarr; 세 번째&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 특정 위치에 접근하는 시간복잡도도 O(N)이다.&lt;/p&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;malloc()으로 할당한 노드는 사용이 끝났을 때 반드시 free()해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상에서는 재귀함수를 사용해 마지막 노드부터 메모리를 해제한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void DestroyRecursively(
    Node_t* InPtrToCurrentNode)
{
    if (InPtrToCurrentNode == NULL)
    {
        return;
    }

    DestroyRecursively(
        InPtrToCurrentNode-&amp;gt;PtrToNextNode);

    printf(
        &quot;%d has been deleted.\n&quot;,
        InPtrToCurrentNode-&amp;gt;Data);

    free(InPtrToCurrentNode);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트가 다음과 같다고 가정한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재귀함수는 먼저 끝까지 이동한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;Destroy(10)
&amp;rarr; Destroy(20)
  &amp;rarr; Destroy(30)
    &amp;rarr; Destroy(NULL)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NULL에 도달하면 호출된 함수들이 반대 순서로 돌아온다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;30 노드 free()
20 노드 free()
10 노드 free()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 노드로 먼저 이동한 뒤 현재 노드를 해제하는 이유가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 노드를 먼저 해제하면 다음 노드의 주소도 읽을 수 없기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잘못된 순서는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;free(InPtrToCurrentNode);

DestroyRecursively(
    InPtrToCurrentNode-&amp;gt;PtrToNextNode);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;free() 이후에 PtrToNextNode에 접근하므로 정의되지 않은 동작이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 다음 노드의 주소를 사용한 뒤 현재 노드를 해제해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. free()와 댕글링 포인터&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;free()는 포인터 변수 자체를 삭제하는 함수가 아니다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;free(InPtrToCurrentNode);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인터가 가리키고 있던 동적 메모리를 반환한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;free 전

포인터 &amp;rarr; 정상적인 노드 메모리&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;free 후

포인터 &amp;rarr; 이미 반환된 메모리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;free() 이후에도 포인터 변수에는 이전 주소가 남아 있다. 이처럼 이미 해제된 메모리를 가리키는 포인터를 댕글링 포인터라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 접근은 위험하다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;free(PtrToNode);

printf(&quot;%d&quot;, PtrToNode-&amp;gt;Data);  // 잘못된 접근&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 해제한 뒤에는 포인터에 NULL을 저장한다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;free(PtrToNode);
PtrToNode = NULL;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 함수 매개변수로 전달된 지역 포인터만 NULL로 바꾸어도 원래 Head는 바뀌지 않는다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;void DestroyRecursively(Node_t* Current)
{
    free(Current);
    Current = NULL;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Current는 함수 내부의 복사본이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 전체 삭제가 끝난 뒤 실제 Head를 직접 NULL로 변경한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void Destroy(
    Node_t** InPtrToPtrToHeadNode)
{
    if (*InPtrToPtrToHeadNode == NULL)
    {
        printf(&quot;Linked list is empty.\n&quot;);
        return;
    }

    DestroyRecursively(
        *InPtrToPtrToHeadNode);

    *InPtrToPtrToHeadNode = NULL;
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-4652450a-eedb-48cc-9d5c-4f3fb495009a.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFgy1e/dJMcahyvpqM/FAXIxMRfAxmAABwckMzrU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFgy1e/dJMcahyvpqM/FAXIxMRfAxmAABwckMzrU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFgy1e/dJMcahyvpqM/FAXIxMRfAxmAABwckMzrU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFgy1e%2FdJMcahyvpqM%2FFAXIxMRfAxmAABwckMzrU0%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-4652450a-eedb-48cc-9d5c-4f3fb495009a.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 특정 데이터를 가진 노드 삭제하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 노드를 삭제하려면 세 가지 작업이 필요하다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 삭제할 노드를 찾는다.
2. 이전 연결을 다음 노드로 변경한다.
3. 삭제할 노드의 메모리를 해제한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 리스트에서 20을 삭제한다고 가정한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 20을 free()하면 안 된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head &amp;rarr; [10] &amp;rarr; 해제된 메모리

              [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10 노드의 next가 여전히 해제된 20 노드의 주소를 가지고 있기 때문이다. 30 노드로 이어지는 연결도 끊어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제하기 전에 10 노드의 next가 30 노드를 가리키도록 변경해야 한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;연결 변경

[10] ─────────&amp;rarr; [30]
         [20]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 20 노드를 해제한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;삭제 완료

Head &amp;rarr; [10] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;pre class=&quot;zephir&quot;&gt;&lt;code&gt;int Remove(
    Node_t** InPtrToPtrToHeadNode,
    int InData)
{
    Node_t** PtrToPtrToCurrentNode =
        InPtrToPtrToHeadNode;

    while (*PtrToPtrToCurrentNode != NULL)
    {
        if ((*PtrToPtrToCurrentNode)-&amp;gt;Data
            == InData)
        {
            Node_t* PtrToDeletedNode =
                *PtrToPtrToCurrentNode;

            *PtrToPtrToCurrentNode =
                PtrToDeletedNode-&amp;gt;PtrToNextNode;

            free(PtrToDeletedNode);
            PtrToDeletedNode = NULL;

            return 1;
        }

        PtrToPtrToCurrentNode =
            &amp;amp;((*PtrToPtrToCurrentNode)
                -&amp;gt;PtrToNextNode);
    }

    return 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PtrToPtrToCurrentNode는 항상 &lt;b&gt;현재 노드를 가리키는 포인터의 주소&lt;/b&gt;를 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 노드가 첫 번째 노드라면 Head 포인터의 주소를 가진다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;PtrToPtrToCurrentNode &amp;rarr; Head &amp;rarr; 첫 번째 노드&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 노드가 두 번째 노드라면 첫 번째 노드의 next 주소를 가진다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;PtrToPtrToCurrentNode
        &amp;darr;
[첫 번째 노드의 next]
        &amp;darr;
     두 번째 노드&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제할 노드를 찾으면 먼저 주소를 보관한다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;Node_t* PtrToDeletedNode =
    *PtrToPtrToCurrentNode;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 현재 노드를 가리키던 포인터에 삭제할 노드의 다음 주소를 넣는다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;*PtrToPtrToCurrentNode =
    PtrToDeletedNode-&amp;gt;PtrToNextNode;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 삭제할 노드의 메모리를 해제한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;free(PtrToDeletedNode);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;첫 번째 노드를 삭제하는 경우&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;삭제 전

Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PtrToPtrToCurrentNode는 Head의 주소를 가진다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;*PtrToPtrToCurrentNode =
    PtrToDeletedNode-&amp;gt;PtrToNextNode;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Head가 바로 20 노드를 가리키게 된다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;삭제 후

Head &amp;rarr; [20] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;중간 노드를 삭제하는 경우&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;삭제 전

Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;20을 찾았을 때 PtrToPtrToCurrentNode는 10 노드의 next 주소를 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 같은 대입문으로 10 노드의 next가 30을 가리키게 된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;삭제 후

Head &amp;rarr; [10] &amp;rarr; [30] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&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;exec-8a0ed40b-e80b-4f2a-9315-727c237425e9.png&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TXAUj/dJMcaf1Hfeb/sagusMhY5tS6zzqaUUZRzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TXAUj/dJMcaf1Hfeb/sagusMhY5tS6zzqaUUZRzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TXAUj/dJMcaf1Hfeb/sagusMhY5tS6zzqaUUZRzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTXAUj%2FdJMcaf1Hfeb%2FsagusMhY5tS6zzqaUUZRzK%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;1774&quot; height=&quot;887&quot; data-filename=&quot;exec-8a0ed40b-e80b-4f2a-9315-727c237425e9.png&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13. 연결 리스트의 시간복잡도&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 연결 리스트의 주요 연산을 정리하면 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&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;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;Head 주변의 포인터만 변경한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;뒤쪽 삽입&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;td&gt;마지막 노드를 찾아야 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;값 탐색&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;td&gt;첫 노드부터 순서대로 확인한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;특정 위치 접근&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;td&gt;포인터를 따라 해당 위치까지 이동한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;값 삭제&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;td&gt;삭제할 노드를 먼저 찾아야 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 출력&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;td&gt;모든 노드를 방문한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 삭제&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;td&gt;모든 노드를 한 번씩 해제한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도의 Tail 포인터를 유지하면 뒤쪽 삽입을 O(1)로 만들 수 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Head &amp;rarr; [10] &amp;rarr; [20] &amp;rarr; [30] &amp;larr; Tail&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 중간 노드를 찾거나 특정 인덱스에 접근하는 작업은 여전히 O(N)이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-7c49606c-7c48-4af9-8712-c2653a98be8a.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TL0Yv/dJMcacYfPEF/OfVn7Bx7ECX6CMNwVVL5Z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TL0Yv/dJMcacYfPEF/OfVn7Bx7ECX6CMNwVVL5Z0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TL0Yv/dJMcacYfPEF/OfVn7Bx7ECX6CMNwVVL5Z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTL0Yv%2FdJMcacYfPEF%2FOfVn7Bx7ECX6CMNwVVL5Z0%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-7c49606c-7c48-4af9-8712-c2653a98be8a.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;14. 배열과 연결 리스트 비교&lt;/h2&gt;
&lt;div&gt;
&lt;div&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;/td&gt;
&lt;td&gt;연속적이다.&lt;/td&gt;
&lt;td&gt;떨어져 있어도 된다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;크기&lt;/td&gt;
&lt;td&gt;일반적으로 미리 결정한다.&lt;/td&gt;
&lt;td&gt;실행 중 변경하기 쉽다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인덱스 접근&lt;/td&gt;
&lt;td&gt;O(1)이다.&lt;/td&gt;
&lt;td&gt;O(N)이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;앞쪽 삽입&lt;/td&gt;
&lt;td&gt;O(N)이다.&lt;/td&gt;
&lt;td&gt;O(1)이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;중간 삭제&lt;/td&gt;
&lt;td&gt;데이터 이동이 필요하다.&lt;/td&gt;
&lt;td&gt;연결 변경이 필요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;추가 메모리&lt;/td&gt;
&lt;td&gt;데이터만 저장한다.&lt;/td&gt;
&lt;td&gt;노드마다 포인터가 필요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;캐시 효율&lt;/td&gt;
&lt;td&gt;비교적 좋다.&lt;/td&gt;
&lt;td&gt;비교적 낮을 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;구현 난이도&lt;/td&gt;
&lt;td&gt;비교적 단순하다.&lt;/td&gt;
&lt;td&gt;포인터 관리가 필요하다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 리스트가 항상 배열보다 좋은 것은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠른 인덱스 접근이 중요하면 배열이 적합하다. 데이터의 앞쪽 삽입과 삭제가 자주 발생하거나 실행 중 크기가 자주 변한다면 연결 리스트가 적합할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;15. 연결 리스트에서 주의할 점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;새 노드를 반드시 기존 리스트에 연결해야 한다&lt;/h3&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;Node_t* NewNode = malloc(sizeof(Node_t));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리만 할당했다고 리스트에 포함되는 것은 아니다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;NewNode-&amp;gt;PtrToNextNode = *Head;
*Head = NewNode;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 작업까지 완료해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;NULL에 역참조를 사용하면 안 된다&lt;/h3&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;Node_t* Ptr = NULL;

printf(&quot;%d&quot;, Ptr-&amp;gt;Data); // 잘못된 접근&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드를 사용하기 전에 포인터가 NULL인지 확인해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;free() 이후에 노드에 접근하면 안 된다&lt;/h3&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;free(PtrToNode);

PtrToNode-&amp;gt;Data = 10; // 잘못된 접근&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해제된 메모리는 더 이상 사용할 수 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;연결을 변경하기 전에 필요한 주소를 보관해야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제할 노드의 다음 주소를 잃어버리면 이후 노드에 접근할 수 없게 된다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;Node_t* Deleted = *CurrentLink;
*CurrentLink = Deleted-&amp;gt;PtrToNextNode;
free(Deleted);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;할당한 노드는 반드시 해제해야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;malloc()으로 생성하고 free()하지 않으면 메모리 누수가 발생한다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;malloc()으로 할당
&amp;rarr; 사용
&amp;rarr; free()로 반환&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 리스트는 각 데이터를 노드에 저장하고 포인터를 이용해 노드들을 연결하는 자료구조이다.&lt;/p&gt;
&lt;pre class=&quot;purebasic&quot;&gt;&lt;code&gt;Head &amp;rarr; [Data | Next] &amp;rarr; [Data | Next] &amp;rarr; NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결 리스트를 이해하기 위한 핵심은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;노드는 데이터와 다음 노드의 주소를 가진다.&lt;/li&gt;
&lt;li&gt;Head는 첫 번째 노드를 가리킨다.&lt;/li&gt;
&lt;li&gt;새 노드는 malloc()으로 동적 할당한다.&lt;/li&gt;
&lt;li&gt;앞쪽 삽입은 새 노드를 기존 Head 앞에 연결한다.&lt;/li&gt;
&lt;li&gt;뒤쪽 삽입은 마지막 NULL 포인터의 위치를 찾는다.&lt;/li&gt;
&lt;li&gt;이중 포인터를 사용하면 Head와 노드의 next를 같은 방식으로 다룰 수 있다.&lt;/li&gt;
&lt;li&gt;삭제할 때는 먼저 연결을 변경한 뒤 free()해야 한다.&lt;/li&gt;
&lt;li&gt;전체 삭제 후에는 Head를 NULL로 변경해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 영상 코드에서 반복해서 등장하는 이중 포인터는 다음 관점으로 이해하면 된다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;Node_t** PtrToPtrToCurrentNode;&lt;/code&gt;&lt;/pre&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;현재 노드를 가리키고 있는 포인터 변수의 주소를 저장한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 관점을 이해하면 다음 코드도 자연스럽게 해석할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;PtrToPtrToCurrentNode =
    &amp;amp;((*PtrToPtrToCurrentNode)
        -&amp;gt;PtrToNextNode);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 노드에서 다음 노드로 단순히 이동하는 것을 넘어, &lt;b&gt;다음 노드를 가리키는 포인터 변수의 위치로 이동한다&lt;/b&gt;는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 덕분에 삽입과 삭제에서 Head 포인터와 각 노드의 next 포인터를 동일한 방식으로 수정할 수 있다.&lt;/p&gt;</description>
      <category>C언어/개념&amp;middot;이론</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/24</guid>
      <comments>https://write76465.tistory.com/24#entry24comment</comments>
      <pubDate>Fri, 24 Jul 2026 14:41:28 +0900</pubDate>
    </item>
    <item>
      <title>HashSet과 HashMap은 어떻게 빠르게 데이터를 찾을까?</title>
      <link>https://write76465.tistory.com/23</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;자료구조를 공부하다 보면 HashSet과 HashMap은 데이터를 평균 O(1)의 시간복잡도로 탐색한다고 배운다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열에서 데이터를 순서대로 찾으면 최악의 경우 모든 데이터를 확인해야 한다. 그런데 해시 자료구조는 데이터가 많아져도 어떻게 원하는 값을 빠르게 찾을 수 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 코드조선의 &lt;a href=&quot;https://youtu.be/BhnXxHBn0ww&quot;&gt;&lt;span&gt;&lt;span&gt;Hash Set과 Hash Map 강의&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;를 바탕으로 해시 함수, 해시 충돌, 선형 탐사, HashSet과 HashMap의 차이를 정리한다. 강의에서는 C 언어로 단순화한 해시 자료구조를 직접 구현하면서 동작 원리를 설명한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 배열 탐색은 왜 느려질까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 문자열 배열이 있다고 가정한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;const char* names[] = {
    &quot;Kim&quot;,
    &quot;Lee&quot;,
    &quot;Park&quot;,
    &quot;Choi&quot;,
    &quot;Jung&quot;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열에서 &quot;Choi&quot;를 찾으려면 앞에서부터 하나씩 비교해야 한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;for (int i = 0; i &amp;lt; 5; i++) {
    if (strcmp(names[i], &quot;Choi&quot;) == 0) {
        printf(&quot;찾았습니다.\n&quot;);
        break;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾으려는 데이터가 배열의 마지막에 있거나 존재하지 않는다면 배열 전체를 확인해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 N개라면 최악의 경우 N번 비교하므로 시간복잡도는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;O(N)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 10개일 때는 큰 문제가 아니다. 하지만 데이터가 수십만 개 이상이라면 매번 처음부터 확인하는 방식은 비효율적이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-f754b154-dd77-4bb0-a7e6-1bcff49d9e45.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bk1vKV/dJMcahZvzfo/xzssktR1QFumn1RmyUoxeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bk1vKV/dJMcahZvzfo/xzssktR1QFumn1RmyUoxeK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bk1vKV/dJMcahZvzfo/xzssktR1QFumn1RmyUoxeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbk1vKV%2FdJMcahZvzfo%2FxzssktR1QFumn1RmyUoxeK%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-f754b154-dd77-4bb0-a7e6-1bcff49d9e45.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&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;해시 자료구조는 데이터를 처음부터 탐색하지 않는다. 대신 데이터가 저장될 위치를 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 사용하는 것이 해시 함수이다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;데이터 &amp;rarr; 해시 함수 &amp;rarr; 해시값 &amp;rarr; 배열 인덱스&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 문자열을 숫자로 변환하는 단순한 해시 함수가 있다고 가정한다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;unsigned int hashString(const char* text) {
    unsigned int hash = 0;

    while (*text != '\0') {
        hash += (unsigned char)*text;
        text++;
    }

    return hash;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문자열의 각 문자를 숫자로 바꿔 모두 더한 값을 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 해시값을 배열의 인덱스로 바로 사용할 수는 없다. 해시값이 배열 크기보다 클 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 배열 크기로 나눈 나머지를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;index = hashString(key) % CAPACITY;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열 크기가 7이고 해시값이 24라면 다음과 같이 계산한다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;24 % 7 = 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 데이터는 배열의 3번 인덱스에 저장된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 찾을 때도 같은 해시 함수를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;저장할 때: key &amp;rarr; 해시 함수 &amp;rarr; 3번 인덱스
찾을 때:   key &amp;rarr; 해시 함수 &amp;rarr; 3번 인덱스&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&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;exec-7ad9e314-e3bc-4550-bf08-37b0a780ed61.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMjpbY/dJMcag0zyLw/ChAA3cqiGRC7z5k3trvv41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMjpbY/dJMcag0zyLw/ChAA3cqiGRC7z5k3trvv41/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMjpbY/dJMcag0zyLw/ChAA3cqiGRC7z5k3trvv41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMjpbY%2FdJMcag0zyLw%2FChAA3cqiGRC7z5k3trvv41%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-7ad9e314-e3bc-4550-bf08-37b0a780ed61.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해시 테이블의 크기를 소수로 정하는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상의 단순화된 구현에서는 해시 테이블의 크기로 7과 같은 소수를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;#define CAPACITY 7&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해시 테이블의 인덱스는 보통 나머지 연산으로 계산한다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;index = hashValue % CAPACITY;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열의 크기가 특정 해시값 패턴과 공약수를 많이 가지면 데이터가 일부 인덱스에 집중될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 해시값이 모두 짝수이고 배열의 크기도 짝수라면 특정 위치가 반복해서 선택될 가능성이 커진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소수 크기를 사용하면 이러한 규칙적인 패턴을 줄이는 데 도움이 된다. 특히 나머지 연산과 선형 탐사를 사용하는 단순한 해시 테이블에서 이해하기 쉬운 선택이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 실제 라이브러리가 반드시 소수 크기만 사용하는 것은 아니다. 구현 방식에 따라 2의 거듭제곱 크기를 사용하고 해시값의 비트를 별도로 섞기도 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 테이블 크기 자체보다 데이터가 가능한 한 여러 인덱스에 고르게 분산되도록 만드는 것이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 서로 다른 데이터의 인덱스가 같다면?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해시 함수를 사용해도 모든 데이터가 서로 다른 인덱스를 가지는 것은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 결과가 나올 수 있다.&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;hash(&quot;Kim&quot;)  % 7 = 3
hash(&quot;Park&quot;) % 7 = 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서로 다른 두 문자열이 같은 인덱스 3을 얻게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 현상을 해시 충돌이라고 한다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Kim  ─┐
      ├─&amp;rarr; 3번 인덱스
Park ─┘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해시 충돌은 해시 함수가 잘못되었다는 의미가 아니다. 저장할 수 있는 데이터의 종류는 매우 많지만 배열의 인덱스 개수는 한정되어 있으므로 충돌을 완전히 피할 수는 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 해시 자료구조에는 충돌을 처리하는 방법이 반드시 필요하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 선형 탐사로 충돌 해결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상의 단순화된 구현에서는 선형 탐사 방식으로 해시 충돌을 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선형 탐사는 원래 위치가 이미 사용 중이라면 바로 다음 칸을 확인하는 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같이 3번 인덱스에 데이터가 이미 저장되어 있다고 가정한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;0   1   2   3   4   5   6
            Kim&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 데이터 &quot;Park&quot;의 인덱스도 3으로 계산되었다면 다음 칸을 확인한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;3번 사용 중 &amp;rarr; 4번 확인 &amp;rarr; 비어 있음 &amp;rarr; 4번에 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;0   1   2   3     4      5   6
            Kim   Park&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 코드로 표현하면 다음과 같은 형태가 된다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;int startIndex = hashString(key) % CAPACITY;
int index = startIndex;

do {
    if (table[index] == NULL) {
        table[index] = key;
        return 1;
    }

    index = (index + 1) % CAPACITY;

} while (index != startIndex);

return 0;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 인덱스를 계산할 때 다시 나머지 연산을 사용한다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;index = (index + 1) % CAPACITY;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 인덱스가 마지막인 6이라면 다음 위치는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;(6 + 1) % 7 = 0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열의 끝에 도달하면 다시 배열의 처음으로 돌아간다. 배열을 원형으로 탐색하는 것과 같은 효과가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 시작했던 인덱스로 다시 돌아왔다면 모든 칸을 확인했다는 의미이므로 탐색을 종료해야 한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-5598447f-e896-4d7a-932e-3349555a4046.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dnMUya/dJMcaccQ8my/qbaguRmcatB4kVHjxjr8Fk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dnMUya/dJMcaccQ8my/qbaguRmcatB4kVHjxjr8Fk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dnMUya/dJMcaccQ8my/qbaguRmcatB4kVHjxjr8Fk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdnMUya%2FdJMcaccQ8my%2FqbaguRmcatB4kVHjxjr8Fk%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-5598447f-e896-4d7a-932e-3349555a4046.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. HashSet이란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashSet은 중복되지 않는 데이터를 저장하는 자료구조이다.&lt;/p&gt;
&lt;pre class=&quot;clojure&quot;&gt;&lt;code&gt;{&quot;Kim&quot;, &quot;Lee&quot;, &quot;Park&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 데이터를 다시 삽입해도 하나만 저장한다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;Kim&quot; 삽입 &amp;rarr; 성공
&quot;Lee&quot; 삽입 &amp;rarr; 성공
&quot;Kim&quot; 삽입 &amp;rarr; 이미 존재하므로 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashSet에 필요한 대표적인 기능은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;Insert(&amp;amp;set, &quot;Kim&quot;);
Contains(&amp;amp;set, &quot;Kim&quot;);
Delete(&amp;amp;set, &quot;Kim&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashSet은 특정 데이터의 존재 여부가 중요할 때 사용한다.&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;방문한 사용자 ID를 저장한다.&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;HashSet에는 데이터 하나만 저장한다. 데이터와 연결된 추가 정보는 저장하지 않는다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;HashSet = {데이터, 데이터, 데이터}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;pre class=&quot;sas&quot;&gt;&lt;code&gt;if (table[index] != NULL &amp;amp;&amp;amp;
    strcmp(table[index], key) == 0) {
    return 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문자열의 해시값이 같다고 해서 반드시 같은 문자열인 것은 아니다. 서로 다른 문자열이 같은 해시값을 가질 수도 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 일반적인 탐색 과정은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;1. key의 해시값을 계산한다.
2. 시작 인덱스를 계산한다.
3. 해당 위치의 실제 데이터와 key를 비교한다.
4. 다르면 충돌 처리 규칙에 따라 다음 위치를 확인한다.
5. 같은 데이터를 찾거나 빈칸을 만나면 탐색을 종료한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해시값은 데이터를 찾을 후보 위치를 결정한다. 최종적으로 같은 데이터인지 확인하려면 실제 값의 비교도 필요하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 선형 탐사에서 삭제할 때 주의할 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선형 탐사를 사용하는 해시 테이블에서는 데이터를 삭제할 때 단순히 빈칸으로 만들면 문제가 발생할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 상태를 가정한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;3번: Kim
4번: Park
5번: Lee&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 데이터가 충돌로 인해 연속된 위치에 저장되었다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 4번의 &quot;Park&quot;를 완전히 빈칸으로 만들면 다음 상태가 된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;3번: Kim
4번: 비어 있음
5번: Lee&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 &quot;Lee&quot;를 탐색할 때 3번부터 확인한다고 가정한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;3번 확인 &amp;rarr; 다른 데이터
4번 확인 &amp;rarr; 비어 있음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;탐색 알고리즘이 빈칸을 보고 종료하면 5번에 있는 &quot;Lee&quot;를 찾지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 선형 탐사 방식에서는 슬롯의 상태를 구분할 필요가 있다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;typedef enum {
    EMPTY,
    OCCUPIED,
    DELETED
} SlotState;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 상태의 의미는 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&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;EMPTY&lt;/td&gt;
&lt;td&gt;처음부터 데이터가 저장된 적이 없는 칸이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCCUPIED&lt;/td&gt;
&lt;td&gt;현재 데이터가 저장된 칸이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DELETED&lt;/td&gt;
&lt;td&gt;데이터가 있었지만 삭제된 칸이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;탐색 중 EMPTY를 만나면 데이터가 없다고 판단할 수 있다. 하지만 DELETED를 만나면 뒤에 데이터가 있을 수 있으므로 계속 탐색해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 삭제 표시를 흔히 삭제 마커 또는 tombstone이라고 한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-02c81374-9e77-493a-b102-76d540aec22f.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m9yO1/dJMcagGpJGf/11laZpmWarrYYKzxAcJqs1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m9yO1/dJMcagGpJGf/11laZpmWarrYYKzxAcJqs1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m9yO1/dJMcagGpJGf/11laZpmWarrYYKzxAcJqs1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm9yO1%2FdJMcagGpJGf%2F11laZpmWarrYYKzxAcJqs1%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-02c81374-9e77-493a-b102-76d540aec22f.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. HashMap이란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashSet이 데이터의 존재 여부를 저장한다면, HashMap은 키와 값을 한 쌍으로 저장한다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;Key &amp;rarr; Value&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 게임의 몬스터 정보를 다음과 같이 저장할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;&quot;Slime&quot;  &amp;rarr; 체력 30
&quot;Goblin&quot; &amp;rarr; 체력 80
&quot;Dragon&quot; &amp;rarr; 체력 1000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에서 몬스터 이름은 Key, 체력은 Value이다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;typedef struct {
    const char* key;
    int value;
} Entry;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashMap은 키에 해시 함수를 적용해 저장 위치를 계산한다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;index = hashString(key) % CAPACITY;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열의 각 칸에는 키만 저장하는 것이 아니라 키와 값이 함께 저장된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;인덱스 0: 비어 있음
인덱스 1: {&quot;Slime&quot;, 30}
인덱스 2: 비어 있음
인덱스 3: {&quot;Dragon&quot;, 1000}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값을 가져올 때는 키를 전달한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;int hp = Get(&amp;amp;monsterMap, &quot;Dragon&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashMap은 다음과 같은 상황에 적합하다.&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;사용자 ID로 사용자 정보를 찾는다.&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. HashSet과 HashMap의 차이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 자료구조 모두 해시 함수를 사용해 저장 위치를 계산한다. 가장 큰 차이는 무엇을 저장하느냐에 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;구분HashSetHashMap
&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;/td&gt;
&lt;td&gt;데이터 하나&lt;/td&gt;
&lt;td&gt;Key&amp;ndash;Value 쌍&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;중복&lt;/td&gt;
&lt;td&gt;같은 데이터 저장 불가&lt;/td&gt;
&lt;td&gt;같은 Key 저장 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주요 목적&lt;/td&gt;
&lt;td&gt;존재 여부 확인&lt;/td&gt;
&lt;td&gt;Key를 이용한 Value 탐색&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;삽입 예시&lt;/td&gt;
&lt;td&gt;add(&quot;Kim&quot;)&lt;/td&gt;
&lt;td&gt;put(&quot;Kim&quot;, 95)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;탐색 결과&lt;/td&gt;
&lt;td&gt;존재 여부&lt;/td&gt;
&lt;td&gt;Key와 연결된 Value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;활용 예시&lt;/td&gt;
&lt;td&gt;중복 제거&lt;/td&gt;
&lt;td&gt;이름별 점수 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashSet은 다음 질문에 답한다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;Kim&quot;이 존재하는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashMap은 다음 질문에 답한다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;Kim&quot;과 연결된 값은 무엇인가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 출석 여부만 확인한다면 HashSet이 적합하다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;출석한 학생 = {&quot;Kim&quot;, &quot;Lee&quot;, &quot;Park&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학생별 점수까지 저장해야 한다면 HashMap이 적합하다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;학생별 점수 = {
    &quot;Kim&quot;: 90,
    &quot;Lee&quot;: 85,
    &quot;Park&quot;: 100
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-e2297a9e-c530-4ac5-bb1e-64cbff1c059e.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/crFnzP/dJMcacxbnsl/MQ4H6Uwm9MqKmYfECaG671/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/crFnzP/dJMcacxbnsl/MQ4H6Uwm9MqKmYfECaG671/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/crFnzP/dJMcacxbnsl/MQ4H6Uwm9MqKmYfECaG671/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcrFnzP%2FdJMcacxbnsl%2FMQ4H6Uwm9MqKmYfECaG671%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-e2297a9e-c530-4ac5-bb1e-64cbff1c059e.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 해시 자료구조의 시간복잡도&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해시 함수가 데이터를 여러 위치에 고르게 분산하고 충돌이 많지 않다면 삽입, 탐색, 삭제는 평균적으로 O(1)에 동작한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; 연산평균 시간복잡도&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; 최악의 시간복잡도
&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;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;탐색&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;삭제&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;O(N)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평균 시간복잡도가 O(1)인 이유는 해시 함수를 이용해 데이터가 있을 것으로 예상되는 위치로 바로 이동하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 모든 데이터의 해시값이 같은 인덱스로 몰리면 충돌이 계속 발생한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&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;최악의 경우 배열의 모든 칸을 확인해야 하므로 O(N)이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 해시 자료구조에서 중요한 것은 단순히 해시 함수를 사용하는 것만이 아니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;해시값을 고르게 분산해야 한다.&lt;/li&gt;
&lt;li&gt;테이블이 지나치게 가득 차지 않도록 관리해야 한다.&lt;/li&gt;
&lt;li&gt;충돌 처리 방법이 필요하다.&lt;/li&gt;
&lt;li&gt;필요하면 테이블 크기를 늘리고 데이터를 다시 배치해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;exec-60871278-a929-47fc-99bb-4830e7d8731f.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/puubv/dJMcacjAyFx/kvW53PiKVvDm37Mvg7TESk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/puubv/dJMcacjAyFx/kvW53PiKVvDm37Mvg7TESk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/puubv/dJMcacjAyFx/kvW53PiKVvDm37Mvg7TESk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fpuubv%2FdJMcacjAyFx%2FkvW53PiKVvDm37Mvg7TESk%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-60871278-a929-47fc-99bb-4830e7d8731f.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;pre class=&quot;fix&quot;&gt;&lt;code&gt;적재율 = 저장된 데이터 수 / 전체 슬롯 수&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬롯이 10개이고 데이터가 7개라면 적재율은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;7 / 10 = 0.7&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블에 빈칸이 많으면 충돌이 발생하더라도 가까운 곳에서 빈칸을 찾을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 테이블이 거의 가득 차면 빈칸을 찾기 위해 여러 칸을 이동해야 한다. 이 상태에서는 평균 O(1)이라는 장점을 유지하기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;리사이징 한 번에는 많은 작업이 필요하지만, 매번 발생하는 작업은 아니므로 전체 연산을 평균 내면 삽입은 일반적으로 상각 O(1)로 설명한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;exec-70d3d726-0c9e-4a50-9435-3922e78e263b.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l5NkP/dJMcaixqaQt/Cgmz0wkeLpg7KKHjNSENY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l5NkP/dJMcaixqaQt/Cgmz0wkeLpg7KKHjNSENY0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l5NkP/dJMcaixqaQt/Cgmz0wkeLpg7KKHjNSENY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl5NkP%2FdJMcaixqaQt%2FCgmz0wkeLpg7KKHjNSENY0%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;exec-70d3d726-0c9e-4a50-9435-3922e78e263b.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13. 좋은 해시 함수의 조건&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;stylus&quot;&gt;&lt;code&gt;hash(&quot;Kim&quot;) &amp;rarr; 123
hash(&quot;Kim&quot;) &amp;rarr; 123&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장할 때와 찾을 때 결과가 달라지면 같은 위치로 이동할 수 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서로 다른 데이터를 고르게 분산해야 한다&lt;/h3&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;hash(&quot;Kim&quot;)  &amp;rarr; 1
hash(&quot;Lee&quot;)  &amp;rarr; 5
hash(&quot;Park&quot;) &amp;rarr; 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 데이터가 같은 인덱스로 몰리지 않아야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;계산 속도가 빨라야 한다&lt;/h3&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;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;Kim
Kang
Kwon&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습용 구현에서는 문자 코드의 합처럼 단순한 함수를 사용할 수 있다. 하지만 실제 프로그램에서는 데이터 분산을 고려한 검증된 해시 함수나 언어의 표준 라이브러리를 사용하는 것이 안전하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;14. 언제 무엇을 선택해야 할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터의 존재 여부만 필요하다면 HashSet을 선택한다.&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;키를 이용해 연결된 값을 찾아야 한다면 HashMap을 선택한다.&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;데이터의 순서를 유지해야 한다면 일반적인 HashSet이나 HashMap만으로는 부족할 수 있다. 사용하는 언어에서 순서를 보장하는 별도의 자료구조를 확인해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 매우 적거나 단순히 한 번만 탐색한다면 배열이나 리스트가 더 간단할 수 있다. HashSet과 HashMap은 빠른 탐색이 반복적으로 필요한 상황에서 특히 유용하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashSet과 HashMap이 빠른 이유는 데이터를 무작정 처음부터 찾지 않기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해시 함수를 사용해 데이터가 저장될 위치를 계산하고, 해당 위치로 바로 이동한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 동작은 다음과 같이 정리할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;Key 입력
&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;HashSet과 HashMap의 차이는 저장하는 데이터의 형태에 있다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;HashSet = 데이터의 존재 여부를 저장한다.
HashMap = Key와 연결된 Value를 저장한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해시 함수가 데이터를 고르게 분산한다면 삽입, 탐색, 삭제는 평균 O(1)에 동작한다. 하지만 충돌이 많거나 테이블이 지나치게 가득 차면 최악의 경우 O(N)까지 느려질 수 있다.&lt;/p&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;좋은 해시 함수, 적절한 테이블 크기, 충돌 처리 방법을 함께 설계해야 빠른 탐색 성능을 얻을 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>C언어/개념&amp;middot;이론</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/23</guid>
      <comments>https://write76465.tistory.com/23#entry23comment</comments>
      <pubDate>Thu, 23 Jul 2026 19:06:45 +0900</pubDate>
    </item>
    <item>
      <title>재귀함수와 반복문의 시간복잡도, 그리고 출력 순서가 뒤집히는 이유</title>
      <link>https://write76465.tistory.com/22</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로그래밍에서 같은 작업을 여러 번 수행하는 대표적인 방법에는 반복문과 재귀함수가 있다. 반복문은 for, while과 같은 문법을 사용하고, 재귀함수는 함수가 자기 자신을 다시 호출하는 방식으로 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 방식은 비슷한 결과를 만들 수 있지만, 실행 과정과 메모리 사용 방식에는 중요한 차이가 있다. 특히 재귀 호출문의 앞과 뒤 중 어디에 출력문을 배치하느냐에 따라 출력 순서가 달라진다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 반복문이란 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문은 주어진 조건이 만족되는 동안 특정 코드를 계속 실행하는 문법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 코드는 3부터 1까지 출력한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;#include &amp;lt;stdio.h&amp;gt;

int main(void) {
    for (int i = 3; i &amp;gt;= 1; i--) {
        printf(&quot;%d &quot;, i);
    }

    return 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 결과는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;3 2 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문은 하나의 함수 안에서 변수 i를 변경하면서 같은 코드를 반복해서 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;생성된 이미지 1.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PlYPr/dJMcahSF1vY/slOJu0ybnFkwcmHyoG8K7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PlYPr/dJMcahSF1vY/slOJu0ybnFkwcmHyoG8K7k/img.png&quot; data-alt=&quot;i 가 3부터 1까지 감소하며 출력되는 과정을 나타낸 이미지이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PlYPr/dJMcahSF1vY/slOJu0ybnFkwcmHyoG8K7k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPlYPr%2FdJMcahSF1vY%2FslOJu0ybnFkwcmHyoG8K7k%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;생성된 이미지 1.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;i 가 3부터 1까지 감소하며 출력되는 과정을 나타낸 이미지이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&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;재귀함수는 함수 내부에서 자기 자신을 다시 호출하는 함수이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 코드는 재귀함수를 이용해 3부터 1까지 출력한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;#include &amp;lt;stdio.h&amp;gt;

void printNumber(int n) {
    if (n == 0) {
        return;
    }

    printf(&quot;%d &quot;, n);
    printNumber(n - 1);
}

int main(void) {
    printNumber(3);
    return 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 내부의 다음 문장이 자기 자신을 다시 호출한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;printNumber(n - 1);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;printNumber(3)을 호출하면 내부적으로 다음과 같이 실행된다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;printNumber(3)
&amp;rarr; printNumber(2)
&amp;rarr; printNumber(1)
&amp;rarr; printNumber(0)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 함수가 다음 함수에 전달하는 값은 이전 값보다 1 작다. 결국 n이 0이 되면 다음 종료 조건에 의해 재귀 호출이 멈춘다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;if (n == 0) {
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재귀함수에는 반드시 종료 조건이 필요하다. 종료 조건이 없다면 함수가 자기 자신을 끝없이 호출한다. 그러면 호출 정보가 메모리에 계속 쌓이고, 결국 스택 오버플로가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재귀함수의 핵심은 다음 두 가지이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;if (n == 0) return;  // 종료 조건
printNumber(n - 1);  // 재귀 호출&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&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;생성된 이미지 2.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mJzVM/dJMcaglYqYq/l0Vc5WxTjEv9ACZoN1GXnK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mJzVM/dJMcaglYqYq/l0Vc5WxTjEv9ACZoN1GXnK/img.png&quot; data-alt=&quot;재귀함수가 자기 자신을 더 작은 값으로 호출하는 모습을 러시아 인형으로 표현한 이미지이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mJzVM/dJMcaglYqYq/l0Vc5WxTjEv9ACZoN1GXnK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmJzVM%2FdJMcaglYqYq%2Fl0Vc5WxTjEv9ACZoN1GXnK%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;생성된 이미지 2.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;재귀함수가 자기 자신을 더 작은 값으로 호출하는 모습을 러시아 인형으로 표현한 이미지이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 재귀함수는 실제로 어떻게 실행되는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재귀함수를 정확히 이해하려면 함수 호출이 끝날 때까지 기존 함수가 기다린다는 사실을 알아야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 코드를 살펴본다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void printNumber(int n) {
    if (n == 0) return;

    printf(&quot;%d &quot;, n);
    printNumber(n - 1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;printNumber(3)을 호출하면 먼저 3을 출력한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 printNumber(2)를 호출한다. 이때 printNumber(3)이 완전히 종료되는 것은 아니다. 자신이 호출한 printNumber(2)가 끝날 때까지 기다린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 과정은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;printNumber(3)
├─ 3 출력
└─ printNumber(2)
   ├─ 2 출력
   └─ printNumber(1)
      ├─ 1 출력
      └─ printNumber(0)
         └─ 종료&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 출력 결과는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;3 2 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;생성된 이미지 3.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bX3n8D/dJMcaiKPGe2/fknpkH5ToKAgskKsqo378K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bX3n8D/dJMcaiKPGe2/fknpkH5ToKAgskKsqo378K/img.png&quot; data-alt=&quot;함수 호출이 스택에 쌓였다가 역순으로 제거되는 과정을 나타낸 이미지이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bX3n8D/dJMcaiKPGe2/fknpkH5ToKAgskKsqo378K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbX3n8D%2FdJMcaiKPGe2%2FfknpkH5ToKAgskKsqo378K%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;생성된 이미지 3.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;함수 호출이 스택에 쌓였다가 역순으로 제거되는 과정을 나타낸 이미지이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&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;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 재귀 호출 전에 출력하면 순차적으로 내려간다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 코드에서는 출력문이 재귀 호출문보다 앞에 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void printNumber(int n) {
    if (n == 0) return;

    printf(&quot;%d &quot;, n);
    printNumber(n - 1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C 언어는 함수를 위에서 아래로 실행한다. 따라서 현재 숫자를 먼저 출력한 다음, 숫자를 1 줄여 자기 자신을 다시 호출한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력이 3이라면 다음과 같이 실행된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;printNumber(3)
1. 3을 출력한다.
2. printNumber(2)를 호출한다.

printNumber(2)
1. 2를 출력한다.
2. printNumber(1)을 호출한다.

printNumber(1)
1. 1을 출력한다.
2. printNumber(0)을 호출한다.

printNumber(0)
1. 종료한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;3 2 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 출력은 재귀 호출이 더 깊어지는 과정에서 발생한다. 즉, 계단을 내려가면서 숫자를 출력한다고 생각할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;3 출력 &amp;rarr; 한 층 내려감
2 출력 &amp;rarr; 한 층 내려감
1 출력 &amp;rarr; 한 층 내려감
0에서 종료&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 재귀 호출 후에 출력하면 역순으로 올라온다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 출력문의 위치를 재귀 호출문 뒤로 옮긴다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void printNumber(int n) {
    if (n == 0) return;

    printNumber(n - 1);
    printf(&quot;%d &quot;, n);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력이 3이라면 printf()를 바로 실행하지 않는다. 먼저 printNumber(n - 1)을 호출한다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;printNumber(3)
&amp;rarr; 출력을 미루고 printNumber(2)를 호출한다.

printNumber(2)
&amp;rarr; 출력을 미루고 printNumber(1)을 호출한다.

printNumber(1)
&amp;rarr; 출력을 미루고 printNumber(0)을 호출한다.

printNumber(0)
&amp;rarr; 종료한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;printNumber(0)이 종료되면 기다리고 있던 함수로 돌아간다. 함수가 돌아가는 순서는 호출된 순서의 반대이다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;printNumber(1)로 돌아감 &amp;rarr; 1 출력
printNumber(2)로 돌아감 &amp;rarr; 2 출력
printNumber(3)으로 돌아감 &amp;rarr; 3 출력&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 결과는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;1 2 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 점은 함수 호출이 끝나도 이전 함수가 사라지지 않았다는 것이다. 이전 함수는 재귀 호출 다음 줄을 실행하기 위해 호출 스택에서 기다리고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 흐름을 코드에 직접 표시하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void printNumber(int n) {
    if (n == 0) return;

    printNumber(n - 1);  // 여기서 기다린다.
    printf(&quot;%d &quot;, n);    // 호출이 끝나면 이곳으로 돌아온다.
}&lt;/code&gt;&lt;/pre&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;&lt;br /&gt;&lt;br /&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;생성된 이미지 4.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/by6Ufb/dJMcabSuUXH/Xqz0RDqnIZeiPz0SngsLxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/by6Ufb/dJMcabSuUXH/Xqz0RDqnIZeiPz0SngsLxk/img.png&quot; data-alt=&quot;재귀 호출이 내려가는 과정과 함수가 다시 돌아오는 과정을 계단으로 표현한 이미지이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/by6Ufb/dJMcabSuUXH/Xqz0RDqnIZeiPz0SngsLxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fby6Ufb%2FdJMcabSuUXH%2FXqz0RDqnIZeiPz0SngsLxk%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;생성된 이미지 4.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;재귀 호출이 내려가는 과정과 함수가 다시 돌아오는 과정을 계단으로 표현한 이미지이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 두 출력 순서 비교하기&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;lisp&quot;&gt;&lt;code&gt;printf(&quot;%d &quot;, n);
printNumber(n - 1);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 값을 먼저 출력하므로 다음 결과가 나온다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;3 2 1&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;재귀 호출 후에 출력하는 경우&lt;/h3&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;printNumber(n - 1);
printf(&quot;%d &quot;, n);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 깊은 호출까지 내려간 다음, 함수가 돌아오면서 출력하므로 다음 결과가 나온다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;1 2 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 간단히 정리하면 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;출력문의 위치실행 시점입력이 3일 때 결과
&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;/td&gt;
&lt;td&gt;호출이 깊어지면서 실행&lt;/td&gt;
&lt;td&gt;3 2 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재귀 호출 후&lt;/td&gt;
&lt;td&gt;함수가 돌아오면서 실행&lt;/td&gt;
&lt;td&gt;1 2 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&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;p data-ke-size=&quot;size16&quot;&gt;시간복잡도는 실제 실행 시간을 초 단위로 측정하는 개념이 아니다. 컴퓨터의 성능이나 실행 환경이 달라져도 알고리즘의 효율을 비교할 수 있도록 연산 횟수의 증가 형태를 표현한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 시간복잡도는 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&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;O(1)&lt;/td&gt;
&lt;td&gt;입력 크기와 관계없이 연산 횟수가 일정하다&lt;/td&gt;
&lt;td&gt;배열의 특정 위치 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;입력이 증가해도 연산 횟수가 천천히 증가한다&lt;/td&gt;
&lt;td&gt;이진 탐색&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;입력에 비례해 연산 횟수가 증가한다&lt;/td&gt;
&lt;td&gt;1부터 N까지 출력&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;O(n&amp;sup2;)&lt;/td&gt;
&lt;td&gt;입력 크기의 제곱에 비례한다&lt;/td&gt;
&lt;td&gt;이중 반복문&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;O(2ⁿ)&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;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;생성된 이미지 5.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NDciT/dJMcaiqy3xH/LKCz9taCBPWWq7jSNexQKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NDciT/dJMcaiqy3xH/LKCz9taCBPWWq7jSNexQKK/img.png&quot; data-alt=&quot;대표적인 시간복잡도별 연산 횟수의 증가 차이를 비교한 그래프이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NDciT/dJMcaiqy3xH/LKCz9taCBPWWq7jSNexQKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNDciT%2FdJMcaiqy3xH%2FLKCz9taCBPWWq7jSNexQKK%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;생성된 이미지 5.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;대표적인 시간복잡도별 연산 횟수의 증가 차이를 비교한 그래프이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 반복문의 시간복잡도&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 반복문은 N번 실행된다.&lt;/p&gt;
&lt;pre class=&quot;matlab&quot;&gt;&lt;code&gt;for (int i = 1; i &amp;lt;= N; i++) {
    printf(&quot;%d &quot;, i);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N이 3이면 출력문을 3번 실행하고, N이 100이면 출력문을 100번 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 크기 N에 비례하여 작업 횟수가 증가하므로 시간복잡도는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;O(N)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 사용하는 추가 변수는 i 정도이다. N이 커져도 필요한 변수의 개수가 증가하지 않는다. 따라서 추가 공간복잡도는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;O(1)&lt;/code&gt;&lt;/pre&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;다음 재귀함수는 n을 1씩 줄이며 총 N번 호출된다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void printNumber(int n) {
    if (n == 0) return;

    printf(&quot;%d &quot;, n);
    printNumber(n - 1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번 호출될 때마다 출력과 비교 같은 일정한 양의 작업을 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 점화식으로 표현하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;T(N) = T(N - 1) + O(1)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 계속 전개하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;T(N)
= T(N - 1) + O(1)
= T(N - 2) + O(1) + O(1)
= T(N - 3) + O(1) + O(1) + O(1)
...
= T(0) + N &amp;times; O(1)
= O(N)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 이 재귀함수의 시간복잡도도 반복문과 동일한 O(N)이다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;반복문: O(N)
재귀함수: O(N)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출력문을 재귀 호출 전이나 후에 배치하더라도 실행 횟수는 달라지지 않는다. 출력 순서만 바뀌므로 두 코드의 시간복잡도는 모두 O(N)이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 시간복잡도가 같아도 메모리 사용량은 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문과 재귀함수가 모두 O(N)의 시간복잡도를 가진다고 해서 완전히 같은 것은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문은 일반적으로 하나의 함수 안에서 변수의 값만 변경한다.&lt;/p&gt;
&lt;pre class=&quot;matlab&quot;&gt;&lt;code&gt;for (int i = N; i &amp;gt;= 1; i--) {
    printf(&quot;%d &quot;, i);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 추가 공간복잡도는 보통 O(1)이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재귀함수는 호출할 때마다 현재 함수의 실행 상태를 호출 스택에 저장한다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;printNumber(3)
printNumber(2)
printNumber(1)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출 깊이가 N까지 증가하므로 호출 스택에 저장되는 정보도 N에 비례하여 증가한다. 따라서 재귀함수의 추가 공간복잡도는 O(N)이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;방식시간복잡도추가 공간복잡도
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 52px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;반복문&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;O(N)&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;재귀함수&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;O(N)&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;O(N)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 단순 반복 작업에서는 반복문이 메모리 면에서 더 효율적이다. 재귀함수는 호출할 때마다 함수 호출에 필요한 추가 작업도 발생하므로 실제 실행 속도에서도 반복문이 유리한 경우가 많다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 재귀함수는 트리 탐색, 그래프 탐색, 분할 정복처럼 문제 자체가 재귀적인 구조를 가질 때 코드를 더 자연스럽고 간결하게 표현할 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;생성된 이미지 6.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beDVBz/dJMcaf1EELz/ZqfkhibKiXK6B8e4EumRrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beDVBz/dJMcaf1EELz/ZqfkhibKiXK6B8e4EumRrk/img.png&quot; data-alt=&quot;반복문과 재귀함수의 메모리 사용량 차이를 비교한 이미지이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beDVBz/dJMcaf1EELz/ZqfkhibKiXK6B8e4EumRrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeDVBz%2FdJMcaf1EELz%2FZqfkhibKiXK6B8e4EumRrk%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;1536&quot; height=&quot;1024&quot; data-filename=&quot;생성된 이미지 6.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;반복문과 재귀함수의 메모리 사용량 차이를 비교한 이미지이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 모든 재귀함수가 O(N)인 것은 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재귀함수를 사용했다고 해서 시간복잡도가 항상 O(N)이 되는 것은 아니다. 한 번의 함수 호출에서 자기 자신을 몇 번 호출하는지와, 입력 크기가 어떻게 감소하는지에 따라 달라진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;한 번씩 호출하는 경우&lt;/h3&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;func(n - 1);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출 횟수가 N에 비례하므로 일반적으로 O(N)이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;입력을 절반씩 줄이는 경우&lt;/h3&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;func(n / 2);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N &amp;rarr; N/2 &amp;rarr; N/4 &amp;rarr; ... &amp;rarr; 1로 감소하므로 일반적으로 O(log N)이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;매번 두 번씩 호출하는 경우&lt;/h3&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;func(n - 1);
func(n - 1);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 호출이 다시 두 개의 호출을 만들기 때문에 호출 횟수가 급격히 증가한다. 일반적으로 O(2ⁿ)이 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 예가 단순 재귀로 작성한 피보나치 함수이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;int fibonacci(int n) {
    if (n &amp;lt;= 1) return n;

    return fibonacci(n - 1) + fibonacci(n - 2);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 값을 여러 번 중복 계산하므로 매우 비효율적이다. 따라서 재귀함수의 시간복잡도는 재귀 호출문의 개수와 입력 감소 방식을 함께 분석해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;pre class=&quot;matlab&quot;&gt;&lt;code&gt;for (int i = 1; i &amp;lt;= N; i++) {
    printf(&quot;%d &quot;, i);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문은 동작이 직관적이고, 호출 스택을 추가로 사용하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 다음과 같이 문제 자체가 작은 문제로 계속 나뉘는 구조라면 재귀함수가 유용하다.&lt;/p&gt;
&lt;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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문과 재귀함수는 모두 반복 작업을 표현할 수 있지만 동작 방식은 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복문은 한 함수 안에서 변수의 값을 변경하며 코드를 반복한다. 재귀함수는 자기 자신을 다시 호출하고, 기존 함수는 호출된 함수가 끝날 때까지 호출 스택에서 기다린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N부터 1까지 한 번씩 처리하는 단순한 코드라면 반복문과 재귀함수의 시간복잡도는 모두 O(N)이다. 하지만 반복문의 추가 공간복잡도는 O(1)이고, 재귀함수는 호출 스택을 사용하므로 O(N)이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재귀함수의 출력 순서는 출력문의 위치에 따라 달라진다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;printf(&quot;%d &quot;, n);
printNumber(n - 1);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 재귀 호출이 깊어지면서 출력하므로 3 2 1이 된다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;printNumber(n - 1);
printf(&quot;%d &quot;, n);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 가장 깊은 호출에서 돌아오면서 출력하므로 1 2 3이 된다.&lt;/p&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;재귀 호출 앞의 코드는 내려가면서 실행하고, 재귀 호출 뒤의 코드는 돌아오면서 실행한다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>C언어/개념&amp;middot;이론</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/22</guid>
      <comments>https://write76465.tistory.com/22#entry22comment</comments>
      <pubDate>Tue, 21 Jul 2026 17:38:01 +0900</pubDate>
    </item>
    <item>
      <title>언리얼 엔진의 GAS란 무엇일까?</title>
      <link>https://write76465.tistory.com/21</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 엔진으로 액션 게임이나 RPG를 개발하다 보면 &lt;b&gt;GAS&lt;/b&gt;라는 용어를 자주 접하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GAS는 &lt;b&gt;Gameplay Ability System&lt;/b&gt;의 약자로, 캐릭터의 스킬과 능력치, 버프 및 디버프 같은 게임플레이 요소를 체계적으로 구현할 수 있도록 언리얼 엔진에서 제공하는 프레임워크입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름만 보면 단순히 캐릭터의 스킬을 만드는 시스템처럼 보이지만, 실제로는 스킬 사용부터 결과 적용까지의 전체적인 흐름을 관리하는 시스템에 가깝습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GAS는 왜 필요할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터가 화염구를 사용하는 상황을 예로 들어보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화염구 스킬 하나를 구현하기 위해서도 생각보다 많은 처리가 필요합니다.&lt;/p&gt;
&lt;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;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;p data-ke-size=&quot;size16&quot;&gt;예를 들어 마나 확인 방식이나 쿨타임 처리 방식을 변경해야 할 때, 관련된 스킬을 하나씩 찾아 수정해야 할 수도 있습니다. 캐릭터의 상태가 늘어날수록 조건문도 계속 복잡해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GAS는 이런 기능들을 일정한 규칙과 구조 안에서 관리하도록 도와줍니다. 덕분에 기능을 재사용하기 쉽고, 프로젝트의 규모가 커졌을 때도 스킬과 상태를 비교적 일관된 방식으로 관리할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GAS의 핵심 구성 요소&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GAS를 처음 접하면 낯선 용어가 많이 등장합니다. 하지만 기본적인 역할을 중심으로 보면 크게 어렵지 않습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Gameplay Ability&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Gameplay Ability&lt;/b&gt;는 캐릭터가 수행할 수 있는 행동을 의미합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 공격, 화염구, 회피, 점프, 회복, 궁극기 등 캐릭터의 능력으로 볼 수 있는 대부분의 행동을 어빌리티로 구현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어빌리티 안에서는 스킬을 사용할 수 있는지 확인하고, 애니메이션을 재생하거나 다른 대상에게 효과를 적용하는 등의 작업을 처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &amp;ldquo;캐릭터가 무엇을 하는가?&amp;rdquo;를 담당하는 부분이라고 볼 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Attribute&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Attribute&lt;/b&gt;는 캐릭터가 가지고 있는 수치형 데이터를 의미합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 다음과 같은 값들이 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 체력과 최대 체력&lt;/li&gt;
&lt;li&gt;현재 마나와 최대 마나&lt;/li&gt;
&lt;li&gt;공격력&lt;/li&gt;
&lt;li&gt;방어력&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;Attribute Set&lt;/b&gt;이라는 단위로 묶어서 관리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 적에게 공격받으면 체력이 감소하고, 회복 스킬을 사용하면 체력이 증가합니다. 장비를 착용해 공격력이 올라가는 경우에도 Attribute가 변경됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Gameplay Effect&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Gameplay Effect&lt;/b&gt;는 캐릭터의 Attribute에 변화를 주거나 특정 상태를 적용하는 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉시 피해를 입히거나 체력을 회복하는 효과뿐만 아니라, 일정 시간 동안 공격력을 증가시키는 버프나 지속적으로 체력을 감소시키는 독 효과도 표현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gameplay Effect는 적용 시간에 따라 대략 다음과 같이 구분할 수 있습니다.&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;/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;4. Gameplay Tag&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Gameplay Tag&lt;/b&gt;는 캐릭터의 상태나 스킬의 분류를 표현하는 계층형 태그입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 다음과 같은 형태로 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;State.Stunned
State.Dead
State.Silenced
Ability.Attack
Ability.Skill.Fire
Effect.Buff.Speed&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터에게 State.Stunned 태그가 있다면 공격이나 이동 능력을 사용할 수 없도록 설정할 수 있습니다. State.Dead 태그가 있다면 대부분의 스킬을 차단할 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문자열이나 여러 개의 불리언 변수로 상태를 관리하는 것보다 일관된 규칙을 만들기 쉽다는 장점이 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. Ability System Component&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Ability System Component&lt;/b&gt;, 줄여서 ASC는 GAS의 주요 기능을 실제 액터와 연결하는 중심 컴포넌트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터가 보유한 어빌리티와 적용된 이펙트, 현재 활성화된 태그 등의 정보를 관리합니다. 플레이어 캐릭터뿐만 아니라 몬스터나 상호작용 가능한 오브젝트에도 필요에 따라 적용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 ASC의 내부 동작까지 모두 이해하기보다, &lt;b&gt;캐릭터가 GAS의 기능을 사용하도록 연결해 주는 중심 관리자&lt;/b&gt; 정도로 알아두면 충분합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GAS는 어떤 흐름으로 동작할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화염구 스킬을 GAS로 구현했다고 가정해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 플레이어가 스킬 버튼을 누르면 화염구에 해당하는 Gameplay Ability의 실행을 요청합니다. 어빌리티는 마나가 충분한지, 쿨타임이 끝났는지, 캐릭터가 스킬을 사용할 수 있는 상태인지 확인합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조건을 만족하면 어빌리티가 실행되고 마나를 소비합니다. 이후 화염구가 적에게 적중하면 피해를 담당하는 Gameplay Effect가 대상에게 적용됩니다. 대상의 방어력 등을 계산한 뒤 체력 Attribute가 감소합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요하다면 화상 효과를 추가하여 일정 시간 동안 지속 피해를 입힐 수도 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;생성된 이미지 1.png&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pSscx/dJMcabZlMhR/DwtFNIBAD6SEhOCVSHwi1k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pSscx/dJMcabZlMhR/DwtFNIBAD6SEhOCVSHwi1k/img.png&quot; data-alt=&quot;언리얼 엔진 GAS의 Gameplay Ability, Gameplay Effect, Attribute, Gameplay Tag와 Ability System Component의 관계를 나타낸 구조도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pSscx/dJMcabZlMhR/DwtFNIBAD6SEhOCVSHwi1k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpSscx%2FdJMcabZlMhR%2FDwtFNIBAD6SEhOCVSHwi1k%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;1672&quot; height=&quot;941&quot; data-filename=&quot;생성된 이미지 1.png&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;언리얼 엔진 GAS의 Gameplay Ability, Gameplay Effect, Attribute, Gameplay Tag와 Ability System Component의 관계를 나타낸 구조도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GAS의 장점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GAS의 가장 큰 장점은 스킬과 상태를 일정한 방식으로 관리할 수 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킬마다 별도의 방식으로 쿨타임과 비용을 구현하지 않아도 되고, 피해나 회복 같은 효과도 재사용할 수 있습니다. Gameplay Tag를 이용하면 캐릭터의 상태와 스킬 사용 조건을 비교적 명확하게 표현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 GAS는 네트워크 환경을 고려하여 설계되어 있습니다. 서버 권한 처리와 클라이언트 예측 같은 멀티플레이 기능을 지원하기 때문에, 온라인 액션 게임을 개발할 때 특히 유용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 GAS가 네트워크 문제를 자동으로 모두 해결해 주는 것은 아닙니다. 하지만 관련 기능을 처음부터 직접 만드는 것보다 훨씬 체계적인 기반 위에서 시작할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모든 프로젝트에 GAS가 필요할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GAS가 강력한 시스템인 것은 맞지만, 모든 프로젝트에서 반드시 사용해야 하는 것은 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킬의 수가 적고 캐릭터의 상태도 단순한 게임이라면 직접 만든 시스템이 더 빠르고 이해하기 쉬울 수 있습니다. 반대로 다양한 캐릭터와 스킬, 능력치, 버프 및 디버프가 등장한다면 GAS를 사용하는 장점이 커집니다.&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;다양한 스킬을 사용하는 액션 RPG&lt;/li&gt;
&lt;li&gt;여러 영웅이 등장하는 MOBA&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;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GAS는 단순한 스킬 제작 기능이라기보다, &lt;b&gt;캐릭터의 행동과 능력치, 효과, 상태를 하나의 규칙 안에서 관리하기 위한 게임플레이 프레임워크&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 접하면 Ability, Effect, Attribute, Tag처럼 알아야 할 개념이 많아 어렵게 느껴질 수 있습니다. 하지만 각 요소의 역할을 구분해서 살펴보면 기본 구조는 생각보다 단순합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 Gameplay Ability는 캐릭터의 행동을 담당하고, Gameplay Effect는 그 행동으로 발생하는 변화를 담당합니다. Attribute는 실제 수치를 저장하며, Gameplay Tag는 상태와 조건을 표현합니다. 그리고 Ability System Component가 이 요소들을 종합적으로 관리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GAS의 세부 기능을 처음부터 모두 공부하기보다는 간단한 공격이나 회복 스킬을 하나 만들어 보면서 각 요소가 어떻게 연결되는지 확인하는 것이 좋은 시작 방법입니다.&lt;/p&gt;</description>
      <category>언리얼엔진/개념&amp;middot;이론</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/21</guid>
      <comments>https://write76465.tistory.com/21#entry21comment</comments>
      <pubDate>Mon, 20 Jul 2026 19:09:24 +0900</pubDate>
    </item>
    <item>
      <title>[UE5] 장비 슬롯과 숫자 단축키를 데이터 기반 무기 교체 시스템으로 연결하기</title>
      <link>https://write76465.tistory.com/20</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 숫자 키를 누르면 블루프린트에 직접 작성한 RowName으로 무기를 교체했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1번 키 &amp;rarr; AK47
2번 키 &amp;rarr; Bubble
3번 키 &amp;rarr; Pistol&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 빠르게 기능을 확인하기에는 좋지만, 장비창의 내용이 바뀌어도 단축키가 계속 기존 무기를 장착한다는 문제가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어가 장비 슬롯 1에 어떤 무기를 넣었는지와 관계없이 1번 키가 항상 AK47을 요청하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서는 숫자 키에 특정 무기를 하드코딩하는 대신 다음과 같은 데이터 흐름을 만들었다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;숫자 키 입력
&amp;rarr; 대응하는 장비 슬롯 선택
&amp;rarr; 슬롯의 ItemRowName 확인
&amp;rarr; DT_Item에서 아이템 정보 조회
&amp;rarr; WeaponRowName 추출
&amp;rarr; 캐릭터에게 무기 교체 요청&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 장비창의 상태와 실제 캐릭터의 장착 무기를 연결했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 기존 구조의 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 캐릭터 블루프린트에 무기 RowName이 직접 작성돼 있었다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;1번 키 &amp;rarr; EquipWeaponByRowName(&quot;AK47&quot;)
2번 키 &amp;rarr; EquipWeaponByRowName(&quot;Bubble&quot;)
3번 키 &amp;rarr; EquipWeaponByRowName(&quot;Pistol&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1194&quot; data-origin-height=&quot;537&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQcX7r/dJMcajweXTH/qsa3Gpkgw5ZX1k5MsmChz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQcX7r/dJMcajweXTH/qsa3Gpkgw5ZX1k5MsmChz0/img.png&quot; data-alt=&quot;기존 블루프린트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQcX7r/dJMcajweXTH/qsa3Gpkgw5ZX1k5MsmChz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQcX7r%2FdJMcajweXTH%2Fqsa3Gpkgw5ZX1k5MsmChz0%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;1194&quot; height=&quot;537&quot; data-origin-width=&quot;1194&quot; data-origin-height=&quot;537&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;기존 블루프린트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 UI 장비 슬롯과 실제 무기 교체가 서로 독립적으로 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 장비 슬롯 1의 무기를 AK47에서 다른 라이플로 교체해도 1번 키는 여전히 AK47을 장착한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;장비 슬롯 1
&amp;rarr; 새로운 라이플

숫자 키 1
&amp;rarr; 여전히 AK47을 장착&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 화면에 표시되는 장비 상태와 캐릭터의 실제 장착 상태가 일치하지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 무기가 추가될 때마다 캐릭터 블루프린트를 수정해야 했다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;신규 무기 추가
&amp;rarr; 데이터 테이블 Row 추가
&amp;rarr; 장비 UI 수정
&amp;rarr; 숫자 키 로직의 RowName 수정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 테이블을 사용하고 있음에도 실제 선택 로직은 하드코딩에 의존하고 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 두 종류의 RowName 구분하기&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;DT_Item은 인벤토리와 UI에 필요한 정보를 관리한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Name
Description
Item Type
Mesh
Icon
Max Stack
Weapon Row Name
Price&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;무기 데이터 테이블&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DT_Weapon은 실제 전투에 필요한 무기 데이터를 관리한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Weapon Type
Weapon Mesh
Damage
Magazine Size
Max Ammo
Fire Rate
Reload Time
Range
Recoil
Muzzle Flash
Tracer Effect&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장비 슬롯에 저장된 값은 DT_Item의 RowName이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어:&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;장비 슬롯의 ItemRowName
&amp;rarr; AK47_Item&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 캐릭터의 EquipWeaponByRowName() 함수가 필요로 하는 값은 DT_Weapon의 RowName이다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;EquipWeaponByRowName에 필요한 값
&amp;rarr; AK47&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 슬롯의 RowName을 캐릭터에게 바로 넘기면 안 된다. 중간에 DT_Item을 조회해 Weapon Row Name을 추출해야 한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;AK47_Item
&amp;rarr; DT_Item 조회
&amp;rarr; Weapon Row Name = AK47
&amp;rarr; DT_Weapon의 AK47 장착&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 관계를 명확히 분리하면 아이템 이름과 무기 데이터 이름이 반드시 같을 필요가 없다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;DT_Item RowName
&amp;rarr; Rifle_AK47_Item

DT_Item.WeaponRowName
&amp;rarr; Weapon_AK47

DT_Weapon RowName
&amp;rarr; Weapon_AK47&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 조건은 다음 하나다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;DT_Item.WeaponRowName
=
DT_Weapon.RowName&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. RowName 변경으로 발생할 수 있는 참조 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 중 권총의 RowName이 변경되면서 기본 무기가 정상적으로 생성되지 않는 문제도 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 권총 장착 함수는 특정 문자열을 직접 사용하지 않고 WeaponDataMap의 Pistol 항목을 조회하고 있었다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void APlayerCharacter::EquipDefaultWeapon()
{
    const FDataTableRowHandle DefaultWeaponData =
        GetWeaponDataRowByType(EWeaponType::Pistol);

    if (DefaultWeaponData.IsNull())
    {
        return;
    }

    EquipWeaponByRowName(DefaultWeaponData.RowName);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에 권총 이름이 하드코딩되어 있지 않더라도 블루프린트의 FDataTableRowHandle은 이전 RowName을 계속 참조할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;DT_Weapon RowName 변경
&amp;rarr; 기존 DataTableRowHandle이 이전 이름을 참조
&amp;rarr; IsNull 또는 Row 조회 실패
&amp;rarr; 기본 권총 생성 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 RowName을 변경할 때는 다음 참조를 함께 갱신해야 한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;BP_PlayerCharacter
└─ WeaponDataMap
   └─ Pistol
      └─ RowName

DT_Item
└─ 권총 아이템
   └─ Weapon Row Name&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 통해 데이터 테이블의 RowName은 단순한 표시 이름이 아니라 시스템 사이를 연결하는 식별자라는 점을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RowName을 변경하는 작업은 사실상 데이터 키를 변경하는 작업이므로 모든 참조 지점을 함께 점검해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 기존 장비 슬롯 구조 활용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WBP_Equipment에는 이미 네 개의 장비 슬롯 위젯이 존재했다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;WBP_EquipSlot_1
WBP_EquipSlot_2
WBP_EquipSlot_3
WBP_EquipSlot_소비&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 슬롯에는 생성 시점에 고유한 EquipSlotIndex가 설정돼 있었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;WBP_EquipSlot_1 &amp;rarr; Index 0
WBP_EquipSlot_2 &amp;rarr; Index 1
WBP_EquipSlot_3 &amp;rarr; Index 2
소비 슬롯       &amp;rarr; Index 3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1204&quot; data-origin-height=&quot;564&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpGg0P/dJMcabZjz1y/qMzyzU4PC6dvPJfmf1XXR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpGg0P/dJMcabZjz1y/qMzyzU4PC6dvPJfmf1XXR0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpGg0P/dJMcabZjz1y/qMzyzU4PC6dvPJfmf1XXR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpGg0P%2FdJMcabZjz1y%2FqMzyzU4PC6dvPJfmf1XXR0%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;1204&quot; height=&quot;564&quot; data-origin-width=&quot;1204&quot; data-origin-height=&quot;564&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 슬롯 배열을 추가하기보다 기존 슬롯 참조와 인덱스를 활용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 요구사항은 다음과 같았다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;숫자 키 1
&amp;rarr; WBP_EquipSlot_1의 무기

숫자 키 2
&amp;rarr; WBP_EquipSlot_2의 무기

숫자 키 3
&amp;rarr; 고정 권총&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 키보드 번호와 내부 슬롯 인덱스를 다음처럼 매핑했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;키보드 1 &amp;rarr; Slot Index 0
키보드 2 &amp;rarr; Slot Index 1&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 슬롯 조회 함수를 별도로 만든 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 키 이벤트마다 데이터 테이블 조회 로직을 작성하면 동일한 노드가 반복된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1번 키
&amp;rarr; Slot 1 ItemRowName
&amp;rarr; DT_Item 조회
&amp;rarr; WeaponRowName
&amp;rarr; 유효성 검사

2번 키
&amp;rarr; Slot 2 ItemRowName
&amp;rarr; DT_Item 조회
&amp;rarr; WeaponRowName
&amp;rarr; 유효성 검사&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 슬롯이 늘어날수록 중복이 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 WBP_Equipment 내부에 슬롯 번호를 받아 무기 RowName을 반환하는 함수를 만들었다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;GetWeaponRowNameBySlotIndex&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력:&lt;/p&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;Slot Index : Integer&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출력:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Weapon Row Name : Name
Is Valid : Boolean&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 UI 내부 구조를 외부에 직접 노출하지 않고, 외부에서는 슬롯 인덱스만 전달하도록 만든다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;외부
&amp;rarr; 0번 슬롯의 무기 RowName을 요청

WBP_Equipment
&amp;rarr; 어떤 위젯이 0번인지 판단
&amp;rarr; DT_Item 조회
&amp;rarr; 최종 WeaponRowName 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터는 장비 슬롯 위젯의 내부 구조를 알 필요가 없다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. Switch on Int로 슬롯 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 입력으로 받은 Slot Index는 Switch on Int에 연결했다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;Slot Index
&amp;rarr; Switch on Int&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분기는 다음처럼 구성했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;0 &amp;rarr; WBP_EquipSlot_1
1 &amp;rarr; WBP_EquipSlot_2
Default &amp;rarr; 유효하지 않은 요청&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 슬롯 위젯에서 Item Row Name을 가져온 뒤 공통 로컬 변수에 저장했다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;SelectedItemRowName&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Switch 0
&amp;rarr; WBP_EquipSlot_1.ItemRowName
&amp;rarr; Set SelectedItemRowName&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Switch 1
&amp;rarr; WBP_EquipSlot_2.ItemRowName
&amp;rarr; Set SelectedItemRowName&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1186&quot; data-origin-height=&quot;546&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ce274c/dJMcadJrPHs/zSO5tjKdoGkcfXg00gT2K0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ce274c/dJMcadJrPHs/zSO5tjKdoGkcfXg00gT2K0/img.png&quot; data-alt=&quot;Switch On Int와 연결준비하는 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ce274c/dJMcadJrPHs/zSO5tjKdoGkcfXg00gT2K0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fce274c%2FdJMcadJrPHs%2FzSO5tjKdoGkcfXg00gT2K0%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;1186&quot; height=&quot;546&quot; data-origin-width=&quot;1186&quot; data-origin-height=&quot;546&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Switch On Int와 연결준비하는 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 슬롯마다 서로 다른 로컬 변수를 만들었지만, 그렇게 하면 이후 데이터 조회 로직을 공통으로 사용할 수 없다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;SelectedItemRowName
SelectedItemRowName01&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 분기가 같은 목적의 값을 선택하는 것이므로 하나의 로컬 변수로 합치는 것이 적절했다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;여러 입력 분기
&amp;rarr; 하나의 SelectedItemRowName
&amp;rarr; 공통 데이터 조회 로직&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. DT_Item에서 실제 WeaponRowName 추출&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선택한 ItemRowName으로 DT_Item을 조회했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Get Data Table Row
Data Table = DT_Item
Row Name = SelectedItemRowName&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회에 성공하면 Break Item Data를 통해 Weapon Row Name을 추출한다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;SelectedItemRowName
&amp;rarr; Get Data Table Row(DT_Item)
&amp;rarr; Break Item Data
&amp;rarr; Weapon Row Name&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1203&quot; data-origin-height=&quot;565&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1TGQn/dJMcadCDBsR/N50hPwmsR7btyIEefoGOs1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1TGQn/dJMcadCDBsR/N50hPwmsR7btyIEefoGOs1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1TGQn/dJMcadCDBsR/N50hPwmsR7btyIEefoGOs1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1TGQn%2FdJMcadCDBsR%2FN50hPwmsR7btyIEefoGOs1%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;1203&quot; height=&quot;565&quot; data-origin-width=&quot;1203&quot; data-origin-height=&quot;565&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정이 필요한 이유는 장비 슬롯이 무기 데이터가 아니라 아이템 데이터를 보관하고 있기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;장비 슬롯
&amp;rarr; 아이템의 정체성 및 UI 정보

DT_Item.WeaponRowName
&amp;rarr; 실제 무기 데이터로 이동하기 위한 참조

DT_Weapon
&amp;rarr; 전투 성능과 무기 Actor 생성 정보&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Weapon Row Name은 아이템 시스템과 무기 시스템 사이를 연결하는 외래 키와 비슷한 역할을 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 성공과 실패를 명시적으로 반환&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬롯이 비어 있거나 데이터 테이블에서 Row를 찾지 못했을 때 무기 교체를 시도하면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 함수 출력에 Is Valid를 추가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공 조건:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;DT_Item Row Found
AND Weapon Row Name != None&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공 시:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Weapon Row Name = 조회된 WeaponRowName
Is Valid = true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패 시:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Weapon Row Name = None
Is Valid = false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패 경로는 다음 세 가지다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;지원하지 않는 Slot Index
DT_Item Row Not Found
Weapon Row Name이 None&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;552&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFWG71/dJMcah6jcuZ/wPbm4ukH1RHyhm5S97zKr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFWG71/dJMcah6jcuZ/wPbm4ukH1RHyhm5S97zKr0/img.png&quot; data-alt=&quot;연결완료된 최종함수&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFWG71/dJMcah6jcuZ/wPbm4ukH1RHyhm5S97zKr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFWG71%2FdJMcah6jcuZ%2FwPbm4ukH1RHyhm5S97zKr0%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;1182&quot; height=&quot;552&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;552&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;연결완료된 최종함수&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 함수 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Slot Index
&amp;rarr; Switch on Int
&amp;rarr; 해당 슬롯의 ItemRowName 선택
&amp;rarr; DT_Item 조회
&amp;rarr; WeaponRowName != None 검사
├─ 성공 &amp;rarr; Return(WeaponRowName, true)
└─ 실패 &amp;rarr; Return(None, false)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Is Valid를 별도로 반환함으로써 호출 측에서는 실패 원인을 다시 추론할 필요 없이 Branch 하나로 장착 가능 여부를 판단할 수 있다.&lt;/p&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;캐릭터 블루프린트에는 인벤토리&amp;middot;장비 UI를 가리키는 InvenRef가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 WBP_Equipment에 접근한 뒤 새로 만든 함수를 호출했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 1:&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;1 Pressed
&amp;rarr; InvenRef
&amp;rarr; WBP_Equipment
&amp;rarr; GetWeaponRowNameBySlotIndex(0)
&amp;rarr; Branch(Is Valid)
&amp;rarr; EquipWeaponByRowName&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 2:&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;2 Pressed
&amp;rarr; InvenRef
&amp;rarr; WBP_Equipment
&amp;rarr; GetWeaponRowNameBySlotIndex(1)
&amp;rarr; Branch(Is Valid)
&amp;rarr; EquipWeaponByRowName&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 3은 고정 권총이므로 기존처럼 권총 RowName을 전달했다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;3 Pressed
&amp;rarr; EquipWeaponByRowName(PistolRowName)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1115&quot; data-origin-height=&quot;609&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dClHS4/dJMcaa0kIeK/XsF39yabK7v1KxvTLhz4k1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dClHS4/dJMcaa0kIeK/XsF39yabK7v1KxvTLhz4k1/img.png&quot; data-alt=&quot;최종 블루프린트 노드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dClHS4/dJMcaa0kIeK/XsF39yabK7v1KxvTLhz4k1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdClHS4%2FdJMcaa0kIeK%2FXsF39yabK7v1KxvTLhz4k1%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;1115&quot; height=&quot;609&quot; data-origin-width=&quot;1115&quot; data-origin-height=&quot;609&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;최종 블루프린트 노드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 연결했을 때 Branch의 Condition이 체크박스 true로 고정되어 있었다.&lt;/p&gt;
&lt;pre class=&quot;julia&quot;&gt;&lt;code&gt;Branch Condition = true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서는 빈 슬롯이거나 조회에 실패해도 무기 교체 함수가 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 함수의 Is Valid 출력과 연결했다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;GetWeaponRowNameBySlotIndex.IsValid
&amp;rarr; Branch.Condition&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 빈 슬롯을 선택하면 아무 작업도 수행하지 않는다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 실제 무기 교체는 기존 서버 로직 활용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블루프린트에서는 최종적으로 EquipWeaponByRowName()만 호출한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;void APlayerCharacter::EquipWeaponByRowName(
    FName WeaponRowName)
{
    if (WeaponRowName.IsNone())
    {
        return;
    }

    if (HasAuthority())
    {
        EquipWeaponByRowNameInternal(WeaponRowName);
    }
    else
    {
        ServerEquipWeaponByRowName(WeaponRowName);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 숫자 키를 눌렀다면 Server RPC를 통해 서버에 교체를 요청한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;로컬 숫자 입력
&amp;rarr; 슬롯에서 WeaponRowName 추출
&amp;rarr; EquipWeaponByRowName
&amp;rarr; ServerEquipWeaponByRowName
&amp;rarr; 서버에서 무기 데이터 검증&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부에서는 전달받은 RowName이 실제 WeaponDataMap에 존재하는지도 다시 확인한다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;if (!FindWeaponByRowName(
    WeaponRowName,
    NewWeaponType,
    WeaponData))
{
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 UI가 반환한 RowName을 곧바로 신뢰하지 않고 서버가 보유한 데이터에서 다시 검증한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 무기 변경을 즉시 처리하지 않고 PendingWeaponRowName에 저장한다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;PendingWeaponRowName = WeaponRowName;
MulticastPlayEquipMontage(NewWeaponType);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무기 교체 몽타주의 ChangeWeapon 노티파이 시점에서 실제 무기를 교체한다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;숫자 키 입력
&amp;rarr; 서버에 RowName 전달
&amp;rarr; 무기 교체 몽타주 재생
&amp;rarr; ChangeWeapon Notify
&amp;rarr; CommitPendingWeaponChange
&amp;rarr; 기존 무기 제거
&amp;rarr; 새 무기 Actor 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 손에 든 무기가 애니메이션 시작과 동시에 갑자기 바뀌지 않고, 몽타주에서 지정한 정확한 프레임에 교체된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 전체 데이터 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 데이터 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;숫자 키 1 입력
        │
        ▼
GetWeaponRowNameBySlotIndex(0)
        │
        ▼
WBP_EquipSlot_1.ItemRowName
        │
        ▼
DT_Item 조회
        │
        ▼
ItemData.WeaponRowName
        │
        ▼
EquipWeaponByRowName
        │
        ▼
Server RPC
        │
        ▼
DT_Weapon 및 WeaponDataMap 검증
        │
        ▼
PendingWeaponRowName
        │
        ▼
무기 교체 몽타주
        │
        ▼
ChangeWeapon Notify
        │
        ▼
새 무기 Actor 생성 및 장착&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI는 &amp;ldquo;어떤 아이템이 장비 슬롯에 들어 있는가&amp;rdquo;를 제공하고, 서버는 &amp;ldquo;해당 이름의 무기를 실제로 장착할 수 있는가&amp;rdquo;를 검증한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. 구현하면서 어려웠던 점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ItemRowName과 WeaponRowName의 혼동&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장비 슬롯에서 바로 얻을 수 있는 값은 DT_Item의 RowName이다. 이를 그대로 무기 장착 함수에 전달하면 DT_Weapon에서 Row를 찾지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 데이터 테이블의 역할과 식별자를 구분하는 것이 중요했다.&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;장비 UI에 아이콘이 표시되는 것과 캐릭터가 해당 무기를 장착하는 것은 별개의 시스템이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 이미지가 슬롯에 들어갔다고 해서 캐릭터의 무기 Actor가 자동으로 변경되지는 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 시스템 사이에 다음과 같은 명시적인 변환 과정이 필요했다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;UI 슬롯
&amp;rarr; 아이템 데이터
&amp;rarr; 무기 데이터 식별자
&amp;rarr; 서버 장착 요청&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실패 경로 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적인 Row만 생각하면 장착 로직은 간단하다. 하지만 실제 플레이에서는 다음 상황이 발생할 수 있다.&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;변경된 RowName&lt;/li&gt;
&lt;li&gt;삭제된 데이터 테이블 Row&lt;/li&gt;
&lt;li&gt;WeaponRowName이 없는 일반 아이템&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공과 실패를 명시적으로 나눠 반환함으로써 잘못된 장착 요청을 차단했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;애니메이션과 실제 Actor 교체 시점 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 즉시 무기를 교체하면 몽타주와 무기 메시 변경 시점이 맞지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &amp;ldquo;교체 요청&amp;rdquo;과 &amp;ldquo;실제 교체&amp;rdquo;를 분리했다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;요청 시점
&amp;rarr; PendingWeaponRowName 기록

연출 시점
&amp;rarr; 교체 몽타주 재생

확정 시점
&amp;rarr; ChangeWeapon Notify에서 Actor 교체&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13. 현재 구조의 한계와 개선 방향&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구현에서는 캐릭터가 InvenRef를 통해 장비 UI의 슬롯 값을 조회한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능적으로는 동작하지만, UI 위젯이 장비 데이터의 실질적인 저장소 역할을 한다는 한계가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 장비창 위젯이 아직 생성되지 않았거나 제거된 상태라면 다음 오류가 발생할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Accessed None trying to read InvenRef&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장기적으로는 장비 상태를 UI가 아닌 별도의 데이터 객체가 보유하는 것이 더 안정적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권장 구조:&lt;/p&gt;
&lt;pre class=&quot;vhdl&quot;&gt;&lt;code&gt;InventoryComponent 또는 EquipmentComponent
├─ EquippedSlot1ItemRowName
├─ EquippedSlot2ItemRowName
└─ FixedPistolRowName

WBP_Equipment
&amp;rarr; Component의 데이터를 표시

숫자 키 입력
&amp;rarr; Component의 데이터를 조회&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 UI를 열지 않았더라도 무기 교체가 가능하고, 서버 복제와 저장&amp;middot;불러오기도 쉬워진다.&lt;/p&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;현재 구조
Character &amp;rarr; Widget &amp;rarr; Slot Data

개선 구조
Character &amp;rarr; EquipmentComponent
Widget    &amp;rarr; EquipmentComponent&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI는 데이터를 소유하지 않고 표현만 담당하는 형태다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서는 기존 구조의 변경 범위를 줄이기 위해 WBP_Equipment의 슬롯 데이터를 활용했지만, 시스템이 확장된다면 장비 상태를 Component 또는 PlayerState로 이동할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업은 숫자 키에 직접 작성된 무기 이름을 장비 슬롯 기반의 동적 선택 구조로 변경하는 작업이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 단순히 1번과 2번 키를 연결하는 것이 아니라, 서로 다른 시스템 사이의 데이터 경계를 정리하는 것이었다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;장비 슬롯
&amp;rarr; ItemRowName 보유

DT_Item
&amp;rarr; WeaponRowName 제공

DT_Weapon
&amp;rarr; 실제 무기 성능 제공

Character
&amp;rarr; 서버에 장착 요청

Anim Notify
&amp;rarr; 실제 무기 변경 시점 확정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 다음 효과를 얻었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;숫자 키의 무기 RowName 하드코딩 제거&lt;/li&gt;
&lt;li&gt;장비 슬롯 변경 내용이 단축키에 즉시 반영&lt;/li&gt;
&lt;li&gt;빈 슬롯 및 잘못된 RowName 방어&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;단순한 단축키 기능처럼 보이지만, UI&amp;middot;데이터 테이블&amp;middot;서버 RPC&amp;middot;Actor 생성&amp;middot;애니메이션 노티파이가 하나의 흐름으로 연결된 작업이었다.&lt;/p&gt;</description>
      <category>언리얼엔진/구현&amp;middot;실습</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/20</guid>
      <comments>https://write76465.tistory.com/20#entry20comment</comments>
      <pubDate>Thu, 16 Jul 2026 18:58:30 +0900</pubDate>
    </item>
    <item>
      <title>[Unreal Engine 네트워크] HasAuthority()와 IsLocallyControlled()는 무엇이 다른가?</title>
      <link>https://write76465.tistory.com/19</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal Engine 멀티플레이를 처음 구현할 때 가장 혼동하기 쉬운 함수가 다음 두 가지다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;HasAuthority()
IsLocallyControlled()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 함수 모두 &amp;ldquo;이 코드를 여기서 실행해도 되는가?&amp;rdquo;를 판단할 때 사용하지만, 확인하는 대상은 완전히 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HasAuthority()는 Actor의 상태를 결정할 권한을 확인한다.&lt;/li&gt;
&lt;li&gt;IsLocallyControlled()는 Pawn을 현재 컴퓨터에서 직접 조종하는지 확인한다.&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;클라이언트에서만 Actor가 사라짐&lt;/li&gt;
&lt;li&gt;호스트에서는 UI가 보이지만 원격 클라이언트에서는 보이지 않음&lt;/li&gt;
&lt;li&gt;모든 플레이어 화면에 동일한 UI가 나타남&lt;/li&gt;
&lt;li&gt;서버와 클라이언트의 Actor 상태가 달라짐&lt;/li&gt;
&lt;li&gt;리슨 서버에서는 정상인데 전용 서버에서 동작하지 않음&lt;/li&gt;
&lt;li&gt;RPC를 호출했지만 기대한 컴퓨터에서 실행되지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 두 함수의 정의뿐만 아니라 Authority, Ownership, Local Control이 각각 무엇을 의미하는지 근본적인 구조부터 정리한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 멀티플레이에서는 Actor가 하나만 존재하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;싱글플레이에서는 월드에 생성된 캐릭터가 하나의 프로그램 안에 하나만 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 네트워크 게임에서는 논리적으로 동일한 캐릭터가 서버와 각 클라이언트에 따로 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어 A와 B가 접속한 게임을 예로 들면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;sqf&quot;&gt;&lt;code&gt;서버
├─ Player A 캐릭터
└─ Player B 캐릭터

클라이언트 A
├─ Player A 캐릭터 복제본
└─ Player B 캐릭터 복제본

클라이언트 B
├─ Player A 캐릭터 복제본
└─ Player B 캐릭터 복제본&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드상으로는 모두 APlayerCharacter일 수 있지만, 실제로는 서로 다른 프로세스 또는 월드에 존재하는 별개의 C++ 객체다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 아래 함수가 실행됐다는 사실만으로는 충분하지 않다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void APlayerCharacter::Die()
{
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 환경에서는 다음 질문이 추가로 필요하다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;어느 컴퓨터에서 실행됐는가?
서버의 Actor인가, 클라이언트의 복제본인가?
이 Actor의 권한은 누구에게 있는가?
현재 컴퓨터가 이 Pawn을 직접 조종하는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이의 어려움은 같은 코드가 서로 다른 역할을 가진 여러 Actor 인스턴스에서 실행될 수 있다는 점에서 시작한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Authority란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Authority는 특정 Actor의 최종 상태를 결정할 권한이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 서버 권한형 멀티플레이 게임에서는 서버에 존재하는 Actor가 Authority를 가진다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;if (HasAuthority())
{
    // 이 Actor의 권한을 가진 인스턴스에서 실행
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HasAuthority()를 단순히 &amp;ldquo;현재 프로그램이 서버인가?&amp;rdquo;라고 이해할 수도 있지만, 더 정확한 질문은 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 이 Actor 인스턴스가 이 Actor의 상태를 결정할 권한을 가지고 있는가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Actor 기준으로 판단한다는 것이 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 복제 Actor라면 다음과 같은 관계가 만들어진다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;서버의 Actor
&amp;rarr; Authority 보유

클라이언트의 Actor 복제본
&amp;rarr; Authority 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 코드로 확인하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;if (HasAuthority())
{
    UE_LOG(LogTemp, Warning, TEXT(&quot;Authority Actor&quot;));
}
else
{
    UE_LOG(LogTemp, Warning, TEXT(&quot;Remote Actor Copy&quot;));
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 왜 서버가 Authority를 가져야 하는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 게임에서 모든 클라이언트가 상태를 마음대로 결정할 수 있다면 동일한 게임 상태를 유지할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 클라이언트가 자신의 체력을 직접 수정할 수 있다고 가정해 보자.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;CurrentHealth = 999999.f;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 검증 없이 이 값이 인정된다면 치팅에 매우 취약해진다. 또한 서로 다른 클라이언트가 각자 다른 값을 가지고 있을 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 중요한 게임 상태는 서버가 결정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 서버 권한 로직은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;피해량 계산&lt;/li&gt;
&lt;li&gt;체력 변경&lt;/li&gt;
&lt;li&gt;사망 판정&lt;/li&gt;
&lt;li&gt;탄약 소비&lt;/li&gt;
&lt;li&gt;아이템 획득&lt;/li&gt;
&lt;li&gt;점수 및 킬 카운트&lt;/li&gt;
&lt;li&gt;Actor 생성과 제거&lt;/li&gt;
&lt;li&gt;충돌 판정&lt;/li&gt;
&lt;li&gt;리스폰&lt;/li&gt;
&lt;li&gt;게임 승패 판정&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;void ACharacter::ApplyDamage(float Damage)
{
    if (!HasAuthority())
    {
        return;
    }

    CurrentHealth -= Damage;

    if (CurrentHealth &amp;lt;= 0.f)
    {
        Die();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. IsLocallyControlled()란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IsLocallyControlled()는 Pawn 또는 Character가 현재 컴퓨터의 로컬 Controller에 의해 조종되고 있는지 확인한다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;if (IsLocallyControlled())
{
    // 이 컴퓨터에서 직접 조종하는 Pawn
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 A에는 자신의 캐릭터뿐만 아니라 다른 플레이어의 캐릭터 복제본도 존재한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;클라이언트 A

Player A 캐릭터
&amp;rarr; 클라이언트 A가 직접 조종
&amp;rarr; IsLocallyControlled() == true

Player B 캐릭터
&amp;rarr; 네트워크를 통해 관찰하는 복제본
&amp;rarr; IsLocallyControlled() == false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Local Control은 서버 권한이 아니라 입력과 화면의 주체를 구분하기 위한 개념이다.&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;HUD 생성&lt;/li&gt;
&lt;li&gt;개인 결과 UI&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;카메라 FOV 변경&lt;/li&gt;
&lt;li&gt;로컬 전용 사운드&lt;/li&gt;
&lt;li&gt;1인칭 전용 메시 표시&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;if (IsLocallyControlled())
{
    ShowCrosshair();
    ApplyCameraRecoil();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 플레이어가 총을 발사했다고 내 화면의 카메라까지 흔들리면 안 되기 때문에 이러한 로컬 구분이 필요하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 두 함수는 반대 조건이 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 흔한 오해는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;HasAuthority() == 서버
IsLocallyControlled() == 클라이언트&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 외우면 리슨 서버에서 문제가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HasAuthority()와 IsLocallyControlled()는 반대되는 개념이 아니다. 서로 다른 기준을 검사한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;HasAuthority()
&amp;rarr; 상태를 결정할 권한이 있는가?

IsLocallyControlled()
&amp;rarr; 현재 컴퓨터에서 직접 조종하는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 두 값이 동시에 참일 수도 있고 동시에 거짓일 수도 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;실행 환경대상 PawnHasAuthority()IsLocallyControlled()
&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;/td&gt;
&lt;td&gt;호스트의 Pawn&lt;/td&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;리슨 서버&lt;/td&gt;
&lt;td&gt;원격 플레이어 Pawn&lt;/td&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 A&lt;/td&gt;
&lt;td&gt;자신의 Pawn&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 A&lt;/td&gt;
&lt;td&gt;다른 플레이어 Pawn&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전용 서버&lt;/td&gt;
&lt;td&gt;모든 플레이어 Pawn&lt;/td&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 표는 Unreal 네트워크 코드를 이해하는 데 매우 중요하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 리슨 서버에서 특히 혼동되는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리슨 서버는 서버 역할을 수행하면서 동시에 로컬 플레이어도 가지고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 호스트의 Pawn은 다음 조건을 모두 만족한다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;HasAuthority() == true
IsLocallyControlled() == true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때문에 잘못된 코드도 호스트에서는 정상처럼 보일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 UI를 Authority 조건 안에서 생성했다고 가정해 보자.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;if (HasAuthority())
{
    CreateResultWidget();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리슨 서버 호스트는 Authority를 가지면서 화면도 존재하므로 UI가 정상적으로 나타난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 원격 클라이언트의 Pawn은 Authority가 없다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;리슨 서버 호스트
HasAuthority = true
&amp;rarr; UI 표시

원격 클라이언트
HasAuthority = false
&amp;rarr; UI 표시 안 됨&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자가 호스트 화면만 확인하면 이 코드가 정상이라고 오해할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전용 서버에서는 화면 자체가 없으므로 Authority 조건 안에서 Widget을 생성해도 사용자에게 표시할 방법이 없다.&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;h3 data-ke-size=&quot;size23&quot;&gt;리슨 서버&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 플레이어의 게임 프로세스가 서버와 클라이언트 역할을 동시에 수행한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;리슨 서버 프로세스
├─ 서버 역할
└─ 호스트 플레이어의 로컬 화면과 입력&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점은 테스트와 소규모 멀티플레이 구성이 간단하다는 것이다. 단점은 Authority와 Local Control이 같은 프로세스에 존재하기 때문에 잘못된 실행 위치를 발견하기 어렵다는 것이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전용 서버&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어의 화면이나 입력 없이 서버 역할만 수행한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;전용 서버
├─ 게임 규칙
├─ Actor 상태
├─ 판정
└─ 복제

화면 없음
로컬 플레이어 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전용 서버의 Pawn은 일반적으로 다음 상태다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;HasAuthority() == true
IsLocallyControlled() == false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 UI와 카메라 코드는 전용 서버에서 실행할 필요가 없다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. Authority, Ownership, Local Control은 서로 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal 네트워크를 이해하기 어려운 가장 큰 이유는 비슷해 보이는 세 개념이 따로 존재하기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Authority&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Actor의 최종 상태를 결정할 권한이다.&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;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ownership&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크상에서 특정 Actor가 어느 Connection에 속하는지를 나타낸다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;이 Actor의 소유 클라이언트는 누구인가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ownership은 RPC 전달 방향과 연관된다. 특히 Client RPC와 Server RPC가 어느 연결을 통해 전달될지 결정할 때 중요하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Local Control&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 컴퓨터에서 해당 Pawn을 직접 조종하는지를 나타낸다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;이 Pawn의 입력과 카메라가 현재 컴퓨터에 있는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 개념을 한 문장으로 정리하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;Authority
&amp;rarr; 결정권

Ownership
&amp;rarr; 네트워크 연결의 소유 관계

Local Control
&amp;rarr; 현재 컴퓨터의 조작 관계&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. Authority와 Ownership이 다른 사례&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 모든 캐릭터에 대한 Authority를 가진다. 하지만 각각의 캐릭터는 서로 다른 클라이언트 Connection에 소유될 수 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;서버
├─ Player A Pawn &amp;mdash; Authority 있음, Client A 소유
└─ Player B Pawn &amp;mdash; Authority 있음, Client B 소유&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 서버가 Player A의 Controller에 Client RPC를 호출하면 Client A에 전달되고, Player B에게 호출하면 Client B에 전달된다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void Client_OpenResultUI();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;PlayerAController-&amp;gt;Client_OpenResultUI();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Authority는 서버에 있지만, RPC가 도착할 클라이언트는 Ownership으로 결정된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. UI에는 왜 IsLocallyControlled()가 적합한가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI는 일반적으로 각 플레이어의 화면에만 존재해야 한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;if (IsLocallyControlled())
{
    OpenInventoryUI();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트의 월드에는 여러 Pawn이 존재하더라도 현재 화면의 UI를 조작해야 하는 것은 로컬 Pawn뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사망 UI를 아무 조건 없이 실행하면 다른 플레이어가 사망할 때도 내 화면에 결과 UI가 뜰 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void ACharacter::OnDeath()
{
    OpenDeathUI(); // 모든 복제 Character에서 실행될 가능성
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 조건을 추가하면 자신의 Pawn에 대해서만 실행된다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ACharacter::OnDeath()
{
    if (IsLocallyControlled())
    {
        OpenDeathUI();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 게임 설계에 따라 서버가 소유 PlayerController에 Client RPC를 보내 UI를 열도록 구성할 수도 있다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;if (HasAuthority())
{
    PlayerController-&amp;gt;Client_OpenDeathUI();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 방식 모두 가능하지만 책임을 섞지 않는 것이 중요하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. Client RPC와 로컬 함수는 구분하는 것이 좋다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 함수가 있다고 가정해 보자.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void Client_OpenResultUI();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client RPC의 가장 명확한 사용 방식은 서버에서 소유 클라이언트를 대상으로 호출하는 것이다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;if (HasAuthority())
{
    PlayerController-&amp;gt;Client_OpenResultUI();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 Widget 생성은 클라이언트의 _Implementation()에서 처리한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void AMyPlayerController::Client_OpenResultUI_Implementation()
{
    CreateResultWidget();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 이미 소유 클라이언트에서 실행 중이고 RPC가 필요 없다면 일반 로컬 함수를 분리하는 편이 의미가 명확하다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void AMyPlayerController::OpenResultUILocal()
{
    CreateResultWidget();
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;if (IsLocallyControlled())
{
    PlayerController-&amp;gt;OpenResultUILocal();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권장 구조는 다음처럼 역할을 분리하는 것이다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Client, Reliable)
void Client_OpenResultUI();

void OpenResultUILocal();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void AMyPlayerController::Client_OpenResultUI_Implementation()
{
    OpenResultUILocal();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 서버는 Client RPC를 호출하고, 클라이언트 내부에서는 로컬 함수를 재사용할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. Actor 제거에는 왜 Authority가 필요한가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복제 Actor를 클라이언트에서만 제거하면 서버에는 여전히 Actor가 존재할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;if (IsLocallyControlled())
{
    Weapon-&amp;gt;Destroy(); // 올바른 네트워크 생명주기가 아님
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서는 무기가 사라진 것처럼 보이더라도 서버와 다른 클라이언트에는 계속 존재할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복제 Actor의 생성과 제거는 서버가 담당해야 한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;if (HasAuthority() &amp;amp;&amp;amp; IsValid(Weapon))
{
    Weapon-&amp;gt;Destroy();
    Weapon = nullptr;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적인 Actor 생명주기는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;서버가 Actor 생성
&amp;rarr; 클라이언트에 생성 복제
&amp;rarr; 서버가 Actor 제거
&amp;rarr; 클라이언트에 제거 복제&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 시각적으로 먼저 감춰야 한다면 로컬 Hide와 서버 Destroy를 함께 사용할 수 있지만, 두 작업의 목적은 다르다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;Hide
&amp;rarr; 즉각적인 시각 처리

Destroy
&amp;rarr; 네트워크 Actor의 실제 생명주기 종료&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13. SetVisibility()와 Destroy()의 차이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 두 코드는 의미가 완전히 다르다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;MeshComponent-&amp;gt;SetVisibility(false);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;Actor-&amp;gt;Destroy();&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SetVisibility(false)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Component가 화면에 렌더링되지 않도록 한다.&lt;/li&gt;
&lt;li&gt;Actor는 여전히 월드에 존재한다.&lt;/li&gt;
&lt;li&gt;Tick, Controller, 네트워크 참조 등이 유지될 수 있다.&lt;/li&gt;
&lt;li&gt;충돌은 별도로 끄지 않으면 유지될 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Destroy()&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Actor의 생명주기를 종료한다.&lt;/li&gt;
&lt;li&gt;서버에서 복제 Actor를 제거하면 클라이언트에도 제거가 전달된다.&lt;/li&gt;
&lt;li&gt;기존 참조는 유효성을 다시 확인해야 한다.&lt;/li&gt;
&lt;li&gt;Pawn을 제거하면 Controller의 Possess 관계에도 영향을 줄 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 사라져 보이게 하는 것과 실제로 제거하는 것은 별개의 설계 결정이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;14. Replication은 함수를 자동으로 실행해 주는 시스템이 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수를 Replicated로 설정하면 서버의 값이 클라이언트에 전달된다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = OnRep_IsDead)
bool bIsDead;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ACharacter::OnRep_IsDead()
{
    if (bIsDead)
    {
        PlayDeathAnimation();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 복제되는 것은 bIsDead라는 상태다. 서버에서 실행된 PlayDeathAnimation() 함수 자체가 자동으로 클라이언트에서 다시 실행되는 것은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 네트워크 코드는 보통 다음과 같이 구성한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;서버가 상태 변경
&amp;rarr; 상태 복제
&amp;rarr; 클라이언트의 OnRep 함수 실행
&amp;rarr; 클라이언트가 상태에 맞는 연출 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 상태 기반 구조는 RPC 한 번에 모든 것을 의존하는 것보다 뒤늦게 접속한 클라이언트나 네트워크 지연 상황에 대응하기 쉽다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;15. OnRep의 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OnRep 함수는 복제 변수가 클라이언트에서 변경됐을 때 후속 처리를 실행하기 위해 사용한다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;UPROPERTY(ReplicatedUsing = OnRep_Health)
float CurrentHealth;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;UFUNCTION()
void OnRep_Health();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ACharacter::OnRep_Health()
{
    UpdateHealthBar();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 사용 사례는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;체력 UI 갱신&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;16. RPC와 Replication의 차이&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Replication&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 상태를 전달한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;현재 체력은 70이다.
현재 사망 상태는 true다.
현재 장착 무기는 Rifle이다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태가 중요한 경우에 적합하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RPC&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 동작 또는 이벤트의 실행을 요청한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;서버에 발사를 요청한다.
소유 클라이언트에게 UI를 열도록 요청한다.
모든 클라이언트에게 폭발 이펙트를 재생하도록 요청한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC 종류는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(Server, Reliable)
void Server_Fire();

UFUNCTION(Client, Reliable)
void Client_OpenUI();

UFUNCTION(NetMulticast, Unreliable)
void Multicast_PlayFireEffect();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단히 정리하면 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&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;Replication&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;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client RPC&lt;/td&gt;
&lt;td&gt;서버가 특정 소유 클라이언트에 전달&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multicast RPC&lt;/td&gt;
&lt;td&gt;서버가 관련 인스턴스 전체에 이벤트 전달&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;17. Reliable을 항상 사용하면 안 되는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPC에 Reliable을 붙이면 반드시 전달돼야 하는 메시지로 처리된다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;UFUNCTION(Server, Reliable)
void Server_ConfirmPurchase();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구매, 리스폰 요청, 중요한 상태 전환처럼 누락되면 안 되는 이벤트에 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 총알 트레이서나 반복 발소리처럼 빈번하게 발생하고 한두 번 누락돼도 게임 상태에 영향이 없는 이벤트에 Reliable을 남용하면 네트워크 큐에 부담이 생긴다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(NetMulticast, Unreliable)
void Multicast_PlayTracerEffect();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기준은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;누락되면 게임 상태가 깨지는가?
&amp;rarr; Reliable 고려

누락돼도 다음 프레임이나 다음 효과로 복구되는가?
&amp;rarr; Unreliable 고려&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;18. 네트워크 디버깅이 어려운 근본적인 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 버그는 단일 실행 흐름으로 재현되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 함수가 서버와 여러 클라이언트에서 각각 실행될 수 있으며, 로그도 서로 다른 프로세스에 출력될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 단순히 아래 로그만 남기면 정보가 부족하다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UE_LOG(LogTemp, Warning, TEXT(&quot;Function Called&quot;));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 정보를 함께 출력하는 것이 좋다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;UE_LOG(
    LogTemp,
    Warning,
    TEXT(
        &quot;Actor=%s | Authority=%d | Local=%d | LocalRole=%d | RemoteRole=%d&quot;
    ),
    *GetName(),
    HasAuthority(),
    IsLocallyControlled(),
    static_cast&amp;lt;int32&amp;gt;(GetLocalRole()),
    static_cast&amp;lt;int32&amp;gt;(GetRemoteRole())
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlayerController나 네트워크 모드를 추가로 출력할 수도 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;UE_LOG(
    LogTemp,
    Warning,
    TEXT(&quot;NetMode=%d | Controller=%s&quot;),
    static_cast&amp;lt;int32&amp;gt;(GetNetMode()),
    *GetNameSafe(GetController())
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 다음을 구분할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버에서 실행된 로그&lt;/li&gt;
&lt;li&gt;소유 클라이언트에서 실행된 로그&lt;/li&gt;
&lt;li&gt;다른 플레이어의 복제본에서 실행된 로그&lt;/li&gt;
&lt;li&gt;리슨 서버 호스트에서 실행된 로그&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;19. 자주 발생하는 실수&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;UI를 Authority 조건으로만 처리&lt;/h3&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;if (HasAuthority())
{
    OpenUI();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리슨 서버 호스트에서는 보일 수 있지만 원격 클라이언트 화면에는 표시되지 않을 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Actor 상태를 Local Control 조건으로 변경&lt;/h3&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;if (IsLocallyControlled())
{
    CurrentHealth = 0.f;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 로컬 값만 변경되고 서버의 권위 상태와 충돌할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Multicast RPC를 클라이언트에서 호출&lt;/h3&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;Multicast_PlayEffect();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NetMulticast RPC는 일반적으로 서버에서 호출해야 관련 클라이언트로 전달된다. 클라이언트에서 호출하면 전체에 전파되지 않고 로컬 실행에 그칠 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;리슨 서버 호스트에서만 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호스트 Pawn은 Authority와 Local Control을 동시에 가지므로 실행 위치가 잘못된 코드를 발견하기 어렵다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;모든 것을 Reliable RPC로 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빈번한 연출 이벤트까지 Reliable로 전송하면 패킷 지연과 RPC 큐 적체를 유발할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;20. 네트워크 로직을 작성할 때 먼저 물어야 할 질문&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;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 값이 게임 결과에 영향을 주는가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 서버 Authority가 결정해야 한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;체력, 피해, 탄약, 아이템, 점수, 사망, 리스폰
&amp;rarr; 서버&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;두 번째 질문&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 플레이어의 화면에만 필요한가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 Local Control 또는 소유 Client RPC가 적합하다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;HUD, 카메라, 입력 모드, 개인 결과 UI
&amp;rarr; 소유 클라이언트&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;세 번째 질문&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 플레이어에게도 보여야 하는 연출인가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복제 변수, OnRep 또는 서버 Multicast RPC를 고려한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;사망 애니메이션, 발사 이펙트, 폭발, 피격 연출
&amp;rarr; 관련 클라이언트&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;네 번째 질문&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 상태인가, 순간 이벤트인가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;상태
&amp;rarr; Replication

순간 이벤트
&amp;rarr; RPC 또는 복제 상태 변경에 대한 OnRep&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;21. 권장 책임 분리 예시&lt;/h2&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void ACharacter::Die()
{
    if (!HasAuthority())
    {
        return;
    }

    bIsDead = true;
    OnRep_IsDead();

    AMyPlayerController* MyController =
        Cast&amp;lt;AMyPlayerController&amp;gt;(GetController());

    if (IsValid(MyController))
    {
        MyController-&amp;gt;Client_OpenDeathUI();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void ACharacter::OnRep_IsDead()
{
    if (bIsDead)
    {
        PlayDeathAnimation();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void AMyPlayerController::Client_OpenDeathUI_Implementation()
{
    OpenDeathUILocal();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 책임이 분명하다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;서버
&amp;rarr; 사망 상태 결정

Replication과 OnRep
&amp;rarr; 모든 관련 화면에 사망 상태 반영

Client RPC
&amp;rarr; 소유 플레이어에게만 UI 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 사망 애니메이션이 끝난 뒤 UI를 열어야 한다면 서버 시간 기준으로 UI를 요청하거나, 소유 클라이언트가 복제 상태를 받은 뒤 로컬 연출 종료 시 UI를 표시하는 구조를 선택할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어느 방식이든 중요한 것은 실행 주체를 의도적으로 결정하는 것이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HasAuthority()와 IsLocallyControlled()는 모두 조건문에 사용되지만 해결하는 문제가 다르다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;HasAuthority()
&amp;rarr; 누가 게임 상태를 결정하는가?

IsLocallyControlled()
&amp;rarr; 누가 이 Pawn을 현재 컴퓨터에서 조종하는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 여기에 Ownership까지 더해진다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;Ownership
&amp;rarr; 이 Actor가 어느 클라이언트 Connection에 속하는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이 개발에서 어려운 점은 문법이 아니라 하나의 기능이 여러 컴퓨터에 걸쳐 실행된다는 사실이다. 같은 Actor 클래스와 같은 함수라도 실행 위치에 따라 역할과 결과가 달라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;이 기준을 명확히 하면 Authority, Local Control, Ownership, RPC, Replication을 개별 문법이 아니라 하나의 네트워크 책임 모델로 이해할 수 있다.&lt;/p&gt;</description>
      <category>언리얼엔진/개념&amp;middot;이론</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/19</guid>
      <comments>https://write76465.tistory.com/19#entry19comment</comments>
      <pubDate>Wed, 15 Jul 2026 20:52:11 +0900</pubDate>
    </item>
    <item>
      <title>[UE5] 애니메이션 문제처럼 보였던 이동 버그를 무기 충돌에서 찾아내기까지</title>
      <link>https://write76465.tistory.com/18</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 캐릭터의 전투 디테일을 개선하면서 다음 기능을 정리했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;권총과 라이플 재장전 애니메이션 분리&lt;/li&gt;
&lt;li&gt;조준 중 달리기 상태 전환&lt;/li&gt;
&lt;li&gt;무기 장착 위치 및 트레이서 이펙트 점검&lt;/li&gt;
&lt;li&gt;이동 중 발생하던 캐릭터 위치 보정 현상 해결&lt;/li&gt;
&lt;li&gt;애니메이션 노티파이를 이용한 발소리 구현&lt;/li&gt;
&lt;li&gt;사망 몽타주 이후 캐릭터 숨김 및 결과 UI 연결&lt;/li&gt;
&lt;li&gt;결과 UI 종료 후 기존 GameMode 리스폰 흐름 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 기능을 추가하는 작업보다, 서로 다른 시스템이 영향을 주면서 발생한 문제의 원인을 추적하는 과정이 핵심이었다.&lt;/p&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;문제 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터가 아래를 조준한 상태에서 전진 입력을 하면 다음 현상이 발생했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;전진 키를 눌렀는데 캐릭터가 뒤로 이동&lt;/li&gt;
&lt;li&gt;일반적인 이동 속도보다 훨씬 빠르게 밀림&lt;/li&gt;
&lt;li&gt;재장전 중 이동하면 원래 위치로 돌아가려는 듯한 보정 발생&lt;/li&gt;
&lt;li&gt;클라이언트에서는 이동이 끊기는 현상까지 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Aim Offset 또는 몽타주의 Root Motion 문제로 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 Aim Pitch 연결을 제거하면 증상이 사라졌기 때문에 애니메이션 그래프가 직접적인 원인처럼 보였다.&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;발사&amp;middot;재장전 몽타주의 Root Motion&lt;/li&gt;
&lt;li&gt;Layered Blend per Bone의 하체 영향 여부&lt;/li&gt;
&lt;li&gt;Aim Offset의 Pitch 범위&lt;/li&gt;
&lt;li&gt;이동 방향 계산 코드&lt;/li&gt;
&lt;li&gt;네트워크 Character Movement 보정&lt;/li&gt;
&lt;li&gt;캐릭터 캡슐과 메시의 이동 여부&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터 이동은 컨트롤러의 Yaw만 사용하고 있었다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void APlayerCharacter::Move(const FInputActionValue&amp;amp; Value)
{
    const FVector2D MovementVector = Value.Get&amp;lt;FVector2D&amp;gt;();

    if (Controller != nullptr)
    {
        const FRotator Rotation = Controller-&amp;gt;GetControlRotation();
        const FRotator YawRotation(0.f, Rotation.Yaw, 0.f);

        const FVector ForwardDirection =
            FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);

        const FVector RightDirection =
            FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);

        AddMovementInput(ForwardDirection, MovementVector.X);
        AddMovementInput(RightDirection, MovementVector.Y);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pitch를 이동 방향에 사용하지 않으므로, 아래를 조준한다고 해서 이동 벡터 자체가 반대로 계산될 가능성은 낮았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 디버깅 결과, 시각적인 캐릭터 메시뿐 아니라 실제 CapsuleComponent까지 함께 끌려가고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서 문제를 단순 애니메이션 왜곡이 아닌, 실제 충돌에 의해 Character Movement가 영향을 받는 문제로 좁힐 수 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 실제 원인은 장착된 무기의 Collision이었다&lt;/h2&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;p data-ke-size=&quot;size16&quot;&gt;무기는 캐릭터 메시의 소켓에 부착되어 Aim Offset과 몽타주에 따라 움직인다. 캐릭터가 아래를 조준하면 무기 메시 역시 아래쪽 또는 캐릭터 캡슐 내부로 크게 회전한다.&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;캐릭터 자신의 CapsuleComponent&lt;/li&gt;
&lt;li&gt;캐릭터 Skeletal Mesh&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;Character Movement는 충돌을 해결하기 위해 캡슐 위치를 보정한다. 따라서 겉으로는 Aim Offset이나 몽타주가 캐릭터를 이동시키는 것처럼 보였지만, 실제 흐름은 다음과 같았다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;Aim Pitch 변경
&amp;rarr; 손과 무기 위치 변경
&amp;rarr; 무기 Collision이 캡슐 또는 월드와 겹침
&amp;rarr; 충돌 해소를 위한 캐릭터 위치 보정
&amp;rarr; 캐릭터가 뒤로 밀리거나 순간 가속&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 환경에서는 서버가 계산한 위치와 클라이언트가 예측한 위치가 달라지기 때문에 이 문제가 끊김과 되감기처럼 나타났다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장착 무기는 전투 판정을 담당하는 물리 객체가 아니므로 메시 충돌과 물리 시뮬레이션을 비활성화했다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;MeshComponent-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);
MeshComponent-&amp;gt;SetCollisionResponseToAllChannels(ECR_Ignore);
MeshComponent-&amp;gt;SetGenerateOverlapEvents(false);
MeshComponent-&amp;gt;SetSimulatePhysics(false);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 ApplyWeaponData()처럼 무기 메시가 확정되는 시점에 적용했다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;void AWeaponBase::ApplyWeaponData()
{
    if (WeaponDataRow.IsNull())
    {
        return;
    }

    const FWeaponData* WeaponData =
        WeaponDataRow.GetRow&amp;lt;FWeaponData&amp;gt;(TEXT(&quot;Weapon&quot;));

    if (WeaponData == nullptr)
    {
        return;
    }

    CurrentWeaponData = *WeaponData;

    if (CurrentWeaponData.WeaponMesh)
    {
        MeshComponent-&amp;gt;SetStaticMesh(CurrentWeaponData.WeaponMesh);
    }

    MeshComponent-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);
    MeshComponent-&amp;gt;SetCollisionResponseToAllChannels(ECR_Ignore);
    MeshComponent-&amp;gt;SetGenerateOverlapEvents(false);
    MeshComponent-&amp;gt;SetSimulatePhysics(false);

    MeshComponent-&amp;gt;SetRelativeLocation(
        CurrentWeaponData.AttachLocationOffset);

    MeshComponent-&amp;gt;SetRelativeRotation(
        CurrentWeaponData.AttachRotationOffset);

    MeshComponent-&amp;gt;SetRelativeScale3D(
        CurrentWeaponData.AttachScale);

    CurrentMagazineAmmo = CurrentWeaponData.MagazineSize;
    CurrentReserveAmmo = CurrentWeaponData.MaxAmmo;
}&lt;/code&gt;&lt;/pre&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;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;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1157&quot; data-origin-height=&quot;667&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dZvlsx/dJMcabLNrMV/jBoDWDOSysuDy28Y1mbbD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dZvlsx/dJMcabLNrMV/jBoDWDOSysuDy28Y1mbbD0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dZvlsx/dJMcabLNrMV/jBoDWDOSysuDy28Y1mbbD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdZvlsx%2FdJMcabLNrMV%2FjBoDWDOSysuDy28Y1mbbD0%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;1157&quot; height=&quot;667&quot; data-origin-width=&quot;1157&quot; data-origin-height=&quot;667&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&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;p data-ke-size=&quot;size16&quot;&gt;Aim Offset은 직접 캐릭터를 이동시키지 않았지만, 무기 충돌 상태를 변화시켜 간접적으로 Character Movement에 영향을 줬다. 원인을 찾을 때는 &amp;ldquo;어떤 기능을 실행했을 때 발생했는가&amp;rdquo;와 &amp;ldquo;어떤 시스템이 실제 위치를 변경했는가&amp;rdquo;를 분리해서 봐야 했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Skeletal Mesh와 Static Mesh 변경으로 발생한 장착 오차&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터의 WeaponSocket에 배치한 무기와 실제 게임에서 생성된 무기의 위치가 서로 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소켓 위치를 수정해도 런타임 결과가 예상대로 바뀌지 않았고, 무기의 피벗과 충돌도 기존과 다른 형태로 동작했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1910&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/burNt1/dJMcabET1Zj/yWgJw5JWfTJK9PHK9CXcp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/burNt1/dJMcabET1Zj/yWgJw5JWfTJK9PHK9CXcp0/img.png&quot; data-alt=&quot;Weaponsocket 프리뷰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/burNt1/dJMcabET1Zj/yWgJw5JWfTJK9PHK9CXcp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FburNt1%2FdJMcabET1Zj%2FyWgJw5JWfTJK9PHK9CXcp0%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;1910&quot; height=&quot;739&quot; data-origin-width=&quot;1910&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Weaponsocket 프리뷰&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 무기 구조는 Skeletal Mesh 기준이었지만, 병합 과정에서 무기가 Static Mesh 기반으로 변경되어 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 메시 타입은 같은 무기 외형을 사용하더라도 다음 정보가 다를 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;에셋 원점과 피벗&lt;/li&gt;
&lt;li&gt;기본 회전축&lt;/li&gt;
&lt;li&gt;임포트 Transform&lt;/li&gt;
&lt;li&gt;Collision 설정&lt;/li&gt;
&lt;li&gt;소켓과 머즐 위치를 표현하는 방식&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 기존 Skeletal Mesh를 기준으로 조정한 소켓과 오프셋이 Static Mesh에 그대로 적용될 것이라고 가정하면 안 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개선 방향&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장착 Transform의 책임을 명확히 분리했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WeaponSocket: 캐릭터 손을 기준으로 한 공통 장착 기준점&lt;/li&gt;
&lt;li&gt;데이터 테이블의 Offset: 무기별 피벗 차이 보정&lt;/li&gt;
&lt;li&gt;Muzzle 위치: 실제 무기 메시 또는 전용 SceneComponent 기준&lt;/li&gt;
&lt;li&gt;Collision: 장착 직후 강제로 비활성화&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;CurrentWeapon-&amp;gt;AttachToComponent(
    GetMesh(),
    FAttachmentTransformRules::SnapToTargetNotIncludingScale,
    TEXT(&quot;WeaponSocket&quot;)
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이후 각 무기 데이터의 상대 Transform을 적용한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;MeshComponent-&amp;gt;SetRelativeLocation(
    CurrentWeaponData.AttachLocationOffset);

MeshComponent-&amp;gt;SetRelativeRotation(
    CurrentWeaponData.AttachRotationOffset);

MeshComponent-&amp;gt;SetRelativeScale3D(
    CurrentWeaponData.AttachScale);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 사용하면 공통 소켓을 유지하면서도 권총과 라이플의 피벗 차이를 데이터로 보정할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 조준 중에는 걷고, 조준 해제 후 다시 달리기&lt;/h2&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;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;단순히 조준 시작 시 StopSprint()를 호출하면 달리기 입력 상태까지 제거되어, 조준을 해제한 뒤 달리기 키를 다시 눌러야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 물리적인 입력 상태와 현재 적용 중인 이동 상태를 분리해야 한다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;bSprintInputHeld
    달리기 키를 현재 누르고 있는가?

bIsAiming
    현재 조준 상태인가?

실제 달리기
    bSprintInputHeld &amp;amp;&amp;amp; !bIsAiming&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조준 시작 시에는 달리기 요청을 지우지 않고 속도만 걷기로 바꾼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조준 종료 시 달리기 키가 여전히 눌려 있다면 다시 달리기 속도를 적용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 입력 의도와 캐릭터 상태를 분리하기 때문에 재장전, 피격, 스태미나 부족 등 새로운 상태가 추가돼도 확장하기 쉽다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 애니메이션 노티파이를 이용한 발소리 처리&lt;/h2&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;p data-ke-size=&quot;size16&quot;&gt;대신 애니메이션에는 &amp;ldquo;발이 바닥에 닿은 시점&amp;rdquo;만 기록하고, 실제 사운드 재생은 AnimBP에서 공통 처리했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;애니메이션 노티파이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 이동 애니메이션에서 기존 L/R 발 접지 타이밍에 다음 Skeleton Notify를 추가했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Footstep_L&lt;/li&gt;
&lt;li&gt;Footstep_R&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;977&quot; data-origin-height=&quot;874&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cWCITr/dJMcadivc6F/i8yD7rImZUTE4PVA4o3jOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cWCITr/dJMcadivc6F/i8yD7rImZUTE4PVA4o3jOK/img.png&quot; data-alt=&quot;발 접지 타이밍에 맞춘 노티파이&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cWCITr/dJMcadivc6F/i8yD7rImZUTE4PVA4o3jOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcWCITr%2FdJMcadivc6F%2Fi8yD7rImZUTE4PVA4o3jOK%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;977&quot; height=&quot;874&quot; data-origin-width=&quot;977&quot; data-origin-height=&quot;874&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;발 접지 타이밍에 맞춘 노티파이&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AnimBP에서는 각 노티파이 이벤트를 받아 발 소켓 위치를 구했다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;AnimNotify_Footstep_L
&amp;rarr; Get Owning Component
&amp;rarr; Get Socket Location(&quot;foot_l&quot;)
&amp;rarr; Spawn Sound at Location&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;AnimNotify_Footstep_R
&amp;rarr; Get Owning Component
&amp;rarr; Get Socket Location(&quot;foot_r&quot;)
&amp;rarr; Spawn Sound at Location&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1036&quot; data-origin-height=&quot;658&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dw8x8K/dJMcaaTyMc0/9WUiCYqTcQKZl0MWv8zA21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dw8x8K/dJMcaaTyMc0/9WUiCYqTcQKZl0MWv8zA21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dw8x8K/dJMcaaTyMc0/9WUiCYqTcQKZl0MWv8zA21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdw8x8K%2FdJMcaaTyMc0%2F9WUiCYqTcQKZl0MWv8zA21%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;1036&quot; height=&quot;658&quot; data-origin-width=&quot;1036&quot; data-origin-height=&quot;658&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;발 위치에서 재생하는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터 Actor Location에서 사운드를 재생하면 양발의 위치 차이가 사라진다. 발 소켓 위치를 사용하면 다음 기능으로 확장하기 쉽다.&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;지면 Physical Material 감지&lt;/li&gt;
&lt;li&gt;바닥 재질별 사운드 변경&lt;/li&gt;
&lt;li&gt;발자국 데칼 및 먼지 이펙트 생성&lt;/li&gt;
&lt;li&gt;네트워크 원격 플레이어의 3D 발소리 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BlendSpace에서는 여러 애니메이션이 동시에 평가될 때 중복 노티파이가 발생할 수 있으므로 Notify Trigger Mode도 함께 확인해야 한다. 대표 애니메이션 하나의 노티파이만 사용하려면 Highest Weighted Animation이 적합하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 사망 몽타주와 결과 UI, 리스폰 흐름 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기존 흐름 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사망 상태는 PlayerState가 관리하고 있었다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;서버에서 피해 처리
&amp;rarr; 체력 0 확인
&amp;rarr; PlayerState의 bIsDead 변경
&amp;rarr; OnRep_bIsDead
&amp;rarr; OnPlayerbIsDeadChanged 델리게이트 Broadcast
&amp;rarr; Character의 OnbIsDeadChanged
&amp;rarr; Die()
&amp;rarr; 사망 몽타주 재생
&amp;rarr; OnDeathMontageEnded()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에는 두 종류의 델리게이트가 사용된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OnPlayerbIsDeadChanged: PlayerState 사망 상태 변경을 전달하는 Dynamic Multicast Delegate&lt;/li&gt;
&lt;li&gt;FOnMontageEnded: 사망 몽타주 종료 시점을 전달하는 몽타주 델리게이트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과 UI는 로컬 플레이어 화면에만 생성해야 하므로 기존 IsLocallyControlled() 조건을 유지했다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;if (IsLocallyControlled())
{
    AMyPlayerController* PlayerControllerES =
        Cast&amp;lt;AMyPlayerController&amp;gt;(GetController());

    if (IsValid(PlayerControllerES))
    {
        PlayerControllerES-&amp;gt;Client_OpenResultUI();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;캐릭터를 즉시 Destroy하면 안 되는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 사망 몽타주 종료 후 캐릭터를 즉시 제거하려고 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 기존 GameMode 리스폰 코드는 결과 UI 입력 이후 현재 Pawn을 가져와 제거하고 새 Pawn을 생성하는 구조였다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;APawn* OldPawn = TargetController-&amp;gt;GetPawn();

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

OldPawn-&amp;gt;Destroy();

RestartPlayerAtTransform(
    TargetController,
    MovePoint
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사망 몽타주 종료 시 캐릭터를 먼저 Destroy()하면, 나중에 GameMode가 GetPawn()을 호출했을 때 유효한 Pawn을 얻지 못해 리스폰 함수가 중단될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 캐릭터는 제거하지 않고 시각적으로만 숨겼다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;void APlayerCharacter::OnDeathMontageEnded(
    UAnimMontage* Montage,
    bool bInterrupted)
{
    if (USkeletalMeshComponent* MeshComp = GetMesh())
    {
        MeshComp-&amp;gt;SetVisibility(false, true);
    }

    if (HasAuthority() &amp;amp;&amp;amp; IsValid(CurrentWeapon))
    {
        CurrentWeapon-&amp;gt;Destroy();
        CurrentWeapon = nullptr;
    }

    if (IsLocallyControlled())
    {
        AMyPlayerController* PlayerControllerES =
            Cast&amp;lt;AMyPlayerController&amp;gt;(GetController());

        if (IsValid(PlayerControllerES))
        {
            PlayerControllerES-&amp;gt;Client_OpenResultUI();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;무기만 서버에서 제거하는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무기는 별도의 복제 Actor이므로 서버에서 제거해야 한다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;if (HasAuthority() &amp;amp;&amp;amp; IsValid(CurrentWeapon))
{
    CurrentWeapon-&amp;gt;Destroy();
    CurrentWeapon = nullptr;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 Destroy()하면 제거 상태가 클라이언트에 복제되므로, 별도로 숨긴 뒤 제거하는 이중 처리는 필수적이지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터는 GameMode의 리스폰 로직이 사용할 수 있도록 유지하고, 무기는 남아 있을 이유가 없으므로 서버에서 제거한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 사망&amp;middot;리스폰 흐름&lt;/h3&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;사망 상태 복제
&amp;rarr; 사망 몽타주 재생
&amp;rarr; 몽타주 종료
&amp;rarr; 캐릭터 메시 숨김
&amp;rarr; 서버에서 장착 무기 제거
&amp;rarr; 로컬 플레이어에게 결과 UI 표시
&amp;rarr; 결과 UI 입력
&amp;rarr; GameMode가 기존 Pawn 제거
&amp;rarr; RestartPlayerAtTransform으로 새 Pawn 생성
&amp;rarr; 새 Pawn은 기본 Visibility로 정상 표시&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 작업에서 가장 큰 교훈은 애니메이션, 충돌, 네트워크 이동 보정이 서로 독립된 문제가 아니라는 점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 아래를 조준할 때 발생한 비정상 이동은 겉으로 보면 Aim Offset 또는 Root Motion 문제처럼 보였다. 하지만 캡슐까지 실제로 움직이는지 확인하고, 입력 벡터와 네트워크 이동 코드를 배제한 뒤 무기 충돌까지 추적하면서 실제 원인을 찾을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 사망 처리에서는 단순히 &amp;ldquo;사라지게 만들기&amp;rdquo;보다 기존 리스폰 시스템이 어떤 Pawn 생명주기를 전제로 하는지 먼저 파악해야 했다. 즉시 제거 대신 가시성만 변경함으로써 기존 GameMode 구조를 깨지 않고 요구사항을 만족시켰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 작업을 통해 다음 기준을 정리했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;장착 무기는 기본적으로 Collision을 사용하지 않는다.&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와 서버 Actor 수명 관리를 명확히 분리한다.&lt;/li&gt;
&lt;li&gt;기존 리스폰 흐름을 분석하기 전에는 Pawn을 조기에 제거하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 기능 구현을 넘어, 여러 시스템 사이의 책임과 실행 순서를 정리한 작업이었다.&lt;/p&gt;</description>
      <category>언리얼엔진/구현&amp;middot;실습</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/18</guid>
      <comments>https://write76465.tistory.com/18#entry18comment</comments>
      <pubDate>Wed, 15 Jul 2026 19:59:18 +0900</pubDate>
    </item>
    <item>
      <title>UE5 멀티플레이 캐릭터 전투 애니메이션과 무기 전환 타이밍 개선</title>
      <link>https://write76465.tistory.com/17</link>
      <description>&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 이미 구현된 데이터 테이블 기반 무기 시스템 위에 캐릭터 전투 애니메이션을 연결하고, 캐릭터 상태와 실제 게임 로직이 불일치하는 문제를 개선했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 작업은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Rifle/Pistol 무기 타입별 발사 몽타주 분리&lt;/li&gt;
&lt;li&gt;조준 상태에서 발사 반동이 사라지는 AnimGraph 문제 해결&lt;/li&gt;
&lt;li&gt;달리기 중 발사를 서버에서 차단&lt;/li&gt;
&lt;li&gt;달리기 중 조준 시 걷기 상태로 전환&lt;/li&gt;
&lt;li&gt;무기 교체 시점을 몽타주 노티파이와 동기화&lt;/li&gt;
&lt;li&gt;Aim Offset의 극단적인 Pitch로 인한 팔 관절 왜곡 방지&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 무기 타입별 발사 몽타주 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 무기 시스템은 FWeaponData의 EWeaponType을 통해 현재 무기가 Rifle인지 Pistol인지 구분할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 무기 클래스에 애니메이션 정보를 중복으로 넣지 않고, 캐릭터가 무기 타입에 맞는 몽타주를 선택하도록 구성했다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void APlayerCharacter::PlayAttackMontage(
    EWeaponType WeaponType)
{
    if (!GetMesh())
    {
        return;
    }

    UAnimInstance* AnimInstance =
        GetMesh()-&amp;gt;GetAnimInstance();

    if (!AnimInstance)
    {
        return;
    }

    UAnimMontage* MontageToPlay = nullptr;

    switch (WeaponType)
    {
    case EWeaponType::Rifle:
        MontageToPlay = RifleAttackMontage;
        break;

    case EWeaponType::Pistol:
        MontageToPlay = PistolAttackMontage;
        break;

    default:
        break;
    }

    if (MontageToPlay)
    {
        AnimInstance-&amp;gt;Montage_Play(MontageToPlay);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무기의 Multicast 발사 이펙트에서 해당 함수를 호출해 모든 클라이언트가 발사 모션을 볼 수 있도록 했다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;PlayerCharacter-&amp;gt;PlayAttackMontage(
    CurrentWeaponData.WeaponType
);&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1911&quot; data-origin-height=&quot;835&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caoG5Q/dJMcagzrBzs/mqUKw1pk76TESdjJaFO2Bk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caoG5Q/dJMcagzrBzs/mqUKw1pk76TESdjJaFO2Bk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caoG5Q/dJMcagzrBzs/mqUKw1pk76TESdjJaFO2Bk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcaoG5Q%2FdJMcagzrBzs%2FmqUKw1pk76TESdjJaFO2Bk%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;1911&quot; height=&quot;835&quot; data-origin-width=&quot;1911&quot; data-origin-height=&quot;835&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 조준 상태에서 발사 몽타주가 사라지는 문제&lt;/h2&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;p data-ke-size=&quot;size16&quot;&gt;처음에는 Rifle 몽타주나 무기 타입 판별 문제로 의심했지만, 실제 원인은 AnimGraph의 분기 구조였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조에서는 IsAiming으로 두 포즈를 나눈 뒤, UpperBody Slot이 비조준 분기에만 배치되어 있었다.&lt;/p&gt;
&lt;pre class=&quot;julia&quot;&gt;&lt;code&gt;비조준 포즈 &amp;rarr; UpperBody Slot
조준 포즈   &amp;rarr; UpperBody Slot 우회
             &amp;darr;
       Blend Poses by Bool&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 조준 상태에서는 몽타주가 정상적으로 재생되더라도 최종 출력 포즈에 반영되지 않았다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방향&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조준 여부에 따른 포즈 결정을 먼저 끝낸 뒤, 최종 포즈에 UpperBody Slot을 공통 적용하도록 구조를 변경했다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Aim False Pose ─┐
                ├&amp;rarr; Blend Poses by Bool
Aim True Pose ──┘
                     &amp;darr;
             Layered Blend Per Bone
                     &amp;uarr;
              Slot &quot;UpperBody&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Layered Blend Per Bone은 하체 이동 애니메이션을 유지하면서 척추 위쪽에만 발사 몽타주가 적용되도록 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권장 설정은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Branch Bone: spine_01 또는 spine_02
Blend Depth: 3~5
Mesh Space Rotation Blend: 활성화&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 문제를 통해 몽타주가 &amp;ldquo;재생되지 않는 것&amp;rdquo;과 &amp;ldquo;재생되지만 최종 포즈에서 제거되는 것&amp;rdquo;은 구분해서 디버깅해야 한다는 점을 확인했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1151&quot; data-origin-height=&quot;674&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uTCrR/dJMcafAzoZP/oIgqIXN7fyy6Rcjuq2Kz60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uTCrR/dJMcafAzoZP/oIgqIXN7fyy6Rcjuq2Kz60/img.png&quot; data-alt=&quot;UpperBody가 False부분만 우회하는 상황&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uTCrR/dJMcafAzoZP/oIgqIXN7fyy6Rcjuq2Kz60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuTCrR%2FdJMcafAzoZP%2FoIgqIXN7fyy6Rcjuq2Kz60%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;1151&quot; height=&quot;674&quot; data-origin-width=&quot;1151&quot; data-origin-height=&quot;674&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UpperBody가 False부분만 우회하는 상황&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1057&quot; data-origin-height=&quot;661&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXhSiI/dJMcahLTkaH/T5UN4ItZsCf3nhBCKuKWT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXhSiI/dJMcahLTkaH/T5UN4ItZsCf3nhBCKuKWT0/img.png&quot; data-alt=&quot;true부분도 추가한 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXhSiI/dJMcahLTkaH/T5UN4ItZsCf3nhBCKuKWT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXhSiI%2FdJMcahLTkaH%2FT5UN4ItZsCf3nhBCKuKWT0%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;1057&quot; height=&quot;661&quot; data-origin-width=&quot;1057&quot; data-origin-height=&quot;661&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;true부분도 추가한 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 달리기 애니메이션과 실제 발사 상태의 불일치&lt;/h2&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;p data-ke-size=&quot;size16&quot;&gt;이 문제는 애니메이션 상태를 곧바로 게임플레이 규칙으로 취급했기 때문에 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총을 내리는 포즈는 시각적 표현일 뿐이며, 서버의 Fire() 함수에는 아무런 영향을 주지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 발사 가능 여부는 무기 로직에서 명시적으로 검사해야 한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;bool AWeaponBase::CanFire() const
{
    if (OwnerCharacter &amp;amp;&amp;amp;
        OwnerCharacter-&amp;gt;IsSprinting())
    {
        return false;
    }

    if (CurrentMagazineAmmo &amp;lt;= 0)
    {
        return false;
    }

    if (bIsReloading)
    {
        return false;
    }

    // 발사 간격 검사 생략

    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터 내부 상태에 직접 접근하지 않고 Getter를 통해 확인하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;bool APlayerCharacter::IsSprinting() const
{
    return bIsSprinting;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;연사 타이머 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fire() 내부에서만 CanFire()를 검사하면 달리는 동안 탄환은 발사되지 않더라도 연사 타이머가 계속 생성될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 달리기가 끝나는 순간, 버튼을 다시 누르지 않았는데도 타이머에 의해 발사가 재개될 가능성이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 막기 위해 StartFire() 진입 시점에도 검사를 추가했다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;void AWeaponBase::StartFire()
{
    if (!CanFire())
    {
        return;
    }

    switch (CurrentWeaponData.FireMode)
    {
    case EFireMode::Single:
        Fire();
        break;

    case EFireMode::Auto:
        Fire();

        GetWorld()-&amp;gt;GetTimerManager().SetTimer(
            FireTimerHandle,
            this,
            &amp;amp;AWeaponBase::Fire,
            CurrentWeaponData.FireRate,
            true
        );
        break;

    case EFireMode::Burst:
        BurstCount = 0;
        BurstFire();
        break;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;달리기 시작 시 기존 연사와 점사 타이머도 종료했다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;void AWeaponBase::StopFire()
{
    if (!GetWorld())
    {
        return;
    }

    GetWorld()-&amp;gt;GetTimerManager().ClearTimer(
        FireTimerHandle
    );

    GetWorld()-&amp;gt;GetTimerManager().ClearTimer(
        BurstTimerHandle
    );

    BurstCount = 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 발사 제한은 단순히 탄환 생성을 막는 데서 끝나지 않고, 발사 상태 머신과 타이머까지 함께 정리해야 했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 달리기 중 조준 입력 처리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;StartAim()에서 로컬 캐릭터와 서버의 달리기 상태를 함께 종료했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void APlayerCharacter::StartAim()
{
    bIsAiming = true;
    bIsSprinting = false;

    if (UCharacterMovementComponent* MovementComp =
        GetCharacterMovement())
    {
        MovementComp-&amp;gt;MaxWalkSpeed = WalkSpeed;
    }

    if (IsLocallyControlled())
    {
        ServerStopSprint();
    }

    SetActorTickEnabled(true);

    if (AimCameraCurve)
    {
        AimCameraTimeline.Play();
    }
    else
    {
        UpdateAimCamera(1.0f);
        SetActorTickEnabled(false);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서 먼저 속도를 변경한 이유는 서버 응답을 기다리지 않고 즉각적인 조작감을 제공하기 위해서다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서는 최종적으로 bIsSprinting과 MaxWalkSpeed를 확정한다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;void APlayerCharacter::ServerStopSprint_Implementation()
{
    bIsSprinting = false;

    if (UCharacterMovementComponent* MovementComp =
        GetCharacterMovement())
    {
        MovementComp-&amp;gt;MaxWalkSpeed = WalkSpeed;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 조준 중 달리기 입력은 무시하도록 처리했다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;void APlayerCharacter::StartSprint(
    const FInputActionValue&amp;amp; Value)
{
    if (bIsAiming)
    {
        return;
    }

    StopFire();

    bIsSprinting = true;

    if (UCharacterMovementComponent* MovementComp =
        GetCharacterMovement())
    {
        MovementComp-&amp;gt;MaxWalkSpeed = 600.0f;
    }

    if (IsLocallyControlled())
    {
        ServerStartSprint();
    }
}&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1284&quot; data-origin-height=&quot;682&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FLODa/dJMcaf8kbcI/y16hYrp3Wtb37QrAexKrZk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FLODa/dJMcaf8kbcI/y16hYrp3Wtb37QrAexKrZk/img.gif&quot; data-alt=&quot;에임을 하면 걷는속도로 돌아가는 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FLODa/dJMcaf8kbcI/y16hYrp3Wtb37QrAexKrZk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/FLODa/dJMcaf8kbcI/y16hYrp3Wtb37QrAexKrZk/img.gif&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;1284&quot; height=&quot;682&quot; data-origin-width=&quot;1284&quot; data-origin-height=&quot;682&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에임을 하면 걷는속도로 돌아가는 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 무기 교체 타이밍을 AnimNotify로 동기화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 무기 교체 코드는 다음 순서로 실행됐다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;기존 무기 제거 및 새 무기 생성
&amp;rarr; 캐릭터 소켓에 Attach
&amp;rarr; 장착 몽타주 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 캐릭터가 기존 총을 내리기도 전에 새 무기로 변경되기 때문에 애니메이션과 실제 무기 Actor의 교체 타이밍이 어긋났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 몽타주 재생 시간을 코드에 하드코딩할 수도 있지만, 애니메이션 길이나 재생 속도가 변경되면 다시 수정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 몽타주에 ChangeWeapon 노티파이를 배치하고, 해당 시점을 실제 무기 교체의 커밋 지점으로 사용했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;978&quot; data-origin-height=&quot;865&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJfgZU/dJMcaiRAzxh/kJQSasxISPETlmKmQ8Xlr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJfgZU/dJMcaiRAzxh/kJQSasxISPETlmKmQ8Xlr1/img.png&quot; data-alt=&quot;ChangeWeapon 노티파이&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJfgZU/dJMcaiRAzxh/kJQSasxISPETlmKmQ8Xlr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJfgZU%2FdJMcaiRAzxh%2FkJQSasxISPETlmKmQ8Xlr1%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;978&quot; height=&quot;865&quot; data-origin-width=&quot;978&quot; data-origin-height=&quot;865&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ChangeWeapon 노티파이&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요청과 적용 단계 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무기 변경 요청이 들어왔을 때 바로 생성하지 않고, 변경할 RowName만 임시 저장한다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;UPROPERTY()
FName PendingWeaponRowName = NAME_None;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;void APlayerCharacter::EquipWeaponByRowNameInternal(
    FName WeaponRowName)
{
    if (!HasAuthority() || WeaponRowName.IsNone())
    {
        return;
    }

    if (CurrentWeaponRowName == WeaponRowName &amp;amp;&amp;amp;
        IsValid(CurrentWeapon))
    {
        return;
    }

    EWeaponType NewWeaponType;
    FDataTableRowHandle WeaponData;

    if (!FindWeaponByRowName(
        WeaponRowName,
        NewWeaponType,
        WeaponData))
    {
        return;
    }

    if (bIsAiming)
    {
        CancelAimInstant();
    }

    PendingWeaponRowName = WeaponRowName;

    MulticastPlayEquipMontage(NewWeaponType);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서는 몽타주만 재생되고 실제 무기는 변경되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ChangeWeapon 노티파이가 호출되면 Pending 데이터를 커밋한다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;void APlayerCharacter::CommitPendingWeaponChange()
{
    if (!HasAuthority() ||
        PendingWeaponRowName.IsNone())
    {
        return;
    }

    const FName WeaponRowName =
        PendingWeaponRowName;

    EWeaponType NewWeaponType;
    FDataTableRowHandle WeaponData;

    if (!FindWeaponByRowName(
        WeaponRowName,
        NewWeaponType,
        WeaponData))
    {
        PendingWeaponRowName = NAME_None;
        return;
    }

    if (!EquipWeaponActorFromRow(WeaponData))
    {
        PendingWeaponRowName = NAME_None;
        return;
    }

    CurrentWeaponType = NewWeaponType;
    CurrentWeaponRowName = WeaponRowName;

    PendingWeaponRowName = NAME_None;

    if (IsLocallyControlled())
    {
        BP_OnWeaponChanged(WeaponData);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AnimBP에서는 다음 흐름으로 캐릭터 함수를 호출한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;AnimNotify_ChangeWeapon
&amp;rarr; Try Get Pawn Owner
&amp;rarr; Cast To BP_PlayerCharacter
&amp;rarr; HandleChangeWeaponNotify&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버와 클라이언트에서 노티파이가 중복 실행될 가능성이 있기 때문에, 실제 변경은 서버에서만 수행하고 Pending 값을 커밋 직후 제거했다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void APlayerCharacter::HandleChangeWeaponNotify()
{
    if (HasAuthority())
    {
        CommitPendingWeaponChange();
        return;
    }

    if (IsLocallyControlled())
    {
        ServerHandleChangeWeaponNotify();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 데이터 변경을 즉시 적용하지 않고, 실제로 적용 가능한 시점까지 보류했다가 커밋한다는 점에서 간단한 트랜잭션과 유사하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;780&quot; data-origin-height=&quot;274&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VpmU6/dJMcafm4TxE/tX2ZonCChnM4k0bNEzSbK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VpmU6/dJMcafm4TxE/tX2ZonCChnM4k0bNEzSbK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VpmU6/dJMcafm4TxE/tX2ZonCChnM4k0bNEzSbK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVpmU6%2FdJMcafm4TxE%2FtX2ZonCChnM4k0bNEzSbK0%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;780&quot; height=&quot;274&quot; data-origin-width=&quot;780&quot; data-origin-height=&quot;274&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;트러블슈팅 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 해결한 문제들은 모두 애니메이션 자체보다 시스템 간 상태 동기화에서 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;문제직접적인 원인해결
&lt;table style=&quot;border-collapse: collapse; width: 99.7674%; height: 122px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;조준 중 발사 몽타주 미출력&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;조준 분기가 UpperBody Slot을 우회&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;조준 포즈 결합 후 Slot 공통 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;달리기 중 발사 가능&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;총을 내리는 애니메이션만 존재&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;서버 CanFire()에서 Sprint 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;달리기 종료 후 연사 재개&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;발사 타이머가 계속 실행&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;StartFire() 선행 검사 및 타이머 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;조준과 달리기 상태 충돌&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;시각 상태와 이동 속도가 별도 관리&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;조준 시 로컬&amp;middot;서버 달리기 동시 해제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;무기 교체 타이밍 불일치&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Actor 교체 후 몽타주 재생&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;Pending Row + AnimNotify Commit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서 가장 중요했던 부분은 애니메이션을 단순한 시각 효과가 아니라 게임플레이 상태 전환의 표현으로 정리한 것이다.&lt;/p&gt;
&lt;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;무기 Actor의 생성과 교체는 서버가 수행한다.&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;결과적으로 데이터 테이블 기반 무기 시스템을 유지하면서도 발사, 이동, 조준, 몽타주, 실제 무기 Actor가 서로 같은 상태 전환 시점을 공유하도록 개선할 수 있었다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>언리얼엔진/구현&amp;middot;실습</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/17</guid>
      <comments>https://write76465.tistory.com/17#entry17comment</comments>
      <pubDate>Tue, 14 Jul 2026 17:25:57 +0900</pubDate>
    </item>
    <item>
      <title>Hitscan 판정은 즉시, 총알은 보이게: UE5 Niagara 기반 네트워크 트레이서 구현기</title>
      <link>https://write76465.tistory.com/16</link>
      <description>&lt;div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제 정의&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 무기 시스템에는 이미 다음 기능이 구현돼 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;카메라 기준 라인트레이스&lt;/li&gt;
&lt;li&gt;총구 기준 보정 트레이스&lt;/li&gt;
&lt;li&gt;서버 권한 데미지 처리&lt;/li&gt;
&lt;li&gt;연사 및 재장전&lt;/li&gt;
&lt;li&gt;데이터테이블 기반 무기 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 발사 판정만 존재하고 시각적인 탄환 이동이 없어, 총을 쏘면 대상이 즉시 피격되는 느낌만 났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 Projectile Actor를 추가하면 구현은 직관적이지만, 기존 Hitscan 판정 구조를 다시 설계해야 하고 네트워크에서 Projectile 위치까지 복제해야 한다. 따라서 판정과 연출을 분리했다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;서버: Hitscan으로 즉시 판정 및 데미지 처리
클라이언트: Niagara로 총알이 이동하는 시각적 트레이서 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 핵심은 Niagara가 실제 판정을 담당하지 않는다는 점이다. 시각적 탄환이 아직 날아가는 중이어도 서버 판정은 이미 끝났으며, 트레이서는 그 결과를 시각적으로 재현한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1530&quot; data-origin-height=&quot;813&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qNGDQ/dJMcaiD3bzg/ImEqi4pGc2E4AiG903ss6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qNGDQ/dJMcaiD3bzg/ImEqi4pGc2E4AiG903ss6K/img.png&quot; data-alt=&quot;이미 팀원이 짜놓은 라인트레이싱 코드이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qNGDQ/dJMcaiD3bzg/ImEqi4pGc2E4AiG903ss6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqNGDQ%2FdJMcaiD3bzg%2FImEqi4pGc2E4AiG903ss6K%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;1530&quot; height=&quot;813&quot; data-origin-width=&quot;1530&quot; data-origin-height=&quot;813&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이미 팀원이 짜놓은 라인트레이싱 코드이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;무기별로 다른 트레이서 이펙트를 사용할 수 있도록 FWeaponData에 Niagara System 참조를 추가했다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;UPROPERTY(
    EditAnywhere,
    BlueprintReadWrite,
    Category = &quot;Effects&quot;
)
TObjectPtr&amp;lt;UNiagaraSystem&amp;gt; TracerEffect = nullptr;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 무기 클래스에 AK47 전용 이펙트를 하드코딩하지 않고 데이터테이블에서 무기별 효과를 교체할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;objectivec&quot;&gt;&lt;code&gt;AK47 &amp;rarr; NS_BulletTracer
Pistol &amp;rarr; NS_PistolTracer
Sniper &amp;rarr; NS_SniperTracer&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;111&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgWvxT/dJMcaditqyr/VzAABAQ1apEnG29hFKuXU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgWvxT/dJMcaditqyr/VzAABAQ1apEnG29hFKuXU1/img.png&quot; data-alt=&quot;무기 데이터테이블에 추가된 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgWvxT/dJMcaditqyr/VzAABAQ1apEnG29hFKuXU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgWvxT%2FdJMcaditqyr%2FVzAABAQ1apEnG29hFKuXU1%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;1900&quot; height=&quot;111&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;111&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무기 데이터테이블에 추가된 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 판정 좌표만 전송하고 이펙트는 각 클라이언트가 생성한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트레이서를 네트워크로 동기화할 때 Niagara Component 자체를 복제하지 않았다. 서버가 확정한 시작점과 끝점만 전달하고, 각 클라이언트가 동일한 Niagara System을 로컬에서 생성한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayFireEffects(
    FVector_NetQuantize MuzzleLocation,
    FVector_NetQuantize TracerEndLocation
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 Unreliable을 선택한 이유는 총구 화염과 트레이서가 일시적인 표현이기 때문이다. 한 번의 이펙트 패킷이 유실되더라도 게임 상태가 깨지지 않으며, 연사 중 Reliable RPC가 누적되는 문제를 피할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 FVector_NetQuantize로 좌표를 전달해 일반 FVector보다 네트워크 전송 비용을 줄였다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;if (HasAuthority())
{
    MulticastPlayFireEffects(
        MuzzleLocation,
        TracerEndLocation
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 충돌 결과를 확정하고 좌표만 Multicast한다. 데미지 처리와 이펙트 재생의 책임이 명확하게 분리된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;349&quot; data-origin-height=&quot;798&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4x7QF/dJMcag7f45S/a12MCH2D75k6aY76WxzvQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4x7QF/dJMcag7f45S/a12MCH2D75k6aY76WxzvQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4x7QF/dJMcag7f45S/a12MCH2D75k6aY76WxzvQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4x7QF%2FdJMcag7f45S%2Fa12MCH2D75k6aY76WxzvQK%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;349&quot; height=&quot;798&quot; data-origin-width=&quot;349&quot; data-origin-height=&quot;798&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 첫 프레임 오염을 막기 위한 Niagara 활성화 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Niagara System을 생성하자마자 자동 실행하면, User Parameter를 넣기 전에 기본값 (0, 0, 0)으로 첫 프레임이 계산될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 막기 위해 bAutoActivate를 false로 설정했다.&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;UNiagaraComponent* TracerComponent =
    UNiagaraFunctionLibrary::SpawnSystemAtLocation(
        GetWorld(),
        CurrentWeaponData.TracerEffect,
        FVector(MuzzleLocation),
        FRotator::ZeroRotator,
        FVector::OneVector,
        true,                   // Auto Destroy
        false,                  // Auto Activate
        ENCPoolMethod::None,
        true
    );&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 좌표를 넣고 수동 활성화한다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;if (TracerComponent)
{
    TracerComponent-&amp;gt;SetVariablePosition(
        TEXT(&quot;User.BeamStart&quot;),
        FVector(MuzzleLocation)
    );

    TracerComponent-&amp;gt;SetVariablePosition(
        TEXT(&quot;User.BeamEnd&quot;),
        FVector(TracerEndLocation)
    );

    TracerComponent-&amp;gt;Activate(true);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 순서는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Niagara 생성
&amp;rarr; User Parameter 설정
&amp;rarr; Activate
&amp;rarr; 첫 Simulation Tick&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 순서를 지키면 원점에서 빔이 번쩍이거나 잘못된 방향으로 한 프레임 표시되는 현상을 방지할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BeamStart와 BeamEnd는 단순 Vector가 아니라 Niagara의 Position 타입으로 생성했기 때문에 C++에서도 SetVariableVec3()가 아닌 SetVariablePosition()을 사용했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 단순 Beam이 레이저처럼 보였던 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 구현에서는 User.BeamStart와 User.BeamEnd를 Beam Emitter Setup에 직접 연결했다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Beam Start = User.BeamStart
Beam End   = User.BeamEnd&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 총구부터 충돌 지점까지 한 번에 생성되는 긴 직선이었다. 판정 표현에는 문제가 없지만, 총알이 이동하는 것이 아니라 레이저가 순간적으로 나타나는 것처럼 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 빔의 시작점과 끝점을 시간에 따라 함께 이동시키는 것이었다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;Head = Lerp(Muzzle, HitPoint, t)

Tail = Lerp(
    Muzzle,
    HitPoint,
    Clamp(t - TrailLength, 0, 1)
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 t는 Emitter.NormalizedLoopAge다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;t = 현재 이미터 시간 / Loop Duration&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 t는 이미터 시작 시 0, 종료 시 1이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빔 앞부분은 현재 시간으로 이동시키고, 뒷부분은 조금 늦은 시간으로 이동시킨다. 그 차이만큼 짧은 빔 조각이 만들어진다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1919&quot; data-origin-height=&quot;1031&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFnFSW/dJMcaasvl3Q/tRyuUqAxxjBroLT9eh5aqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFnFSW/dJMcaasvl3Q/tRyuUqAxxjBroLT9eh5aqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFnFSW/dJMcaasvl3Q/tRyuUqAxxjBroLT9eh5aqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFnFSW%2FdJMcaasvl3Q%2FtRyuUqAxxjBroLT9eh5aqK%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;1919&quot; height=&quot;1031&quot; data-origin-width=&quot;1919&quot; data-origin-height=&quot;1031&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. Scratch Dynamic Input으로 위치 보간 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 보간 로직을 재사용하기 위해 세 개의 입력을 갖는 Scratch Dynamic Input을 구성했다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;StartPosition : Position
EndPosition   : Position
Alpha         : Float&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부 계산은 단순하다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;Lerp(
    A = StartPosition,
    B = EndPosition,
    Alpha
)&lt;/code&gt;&lt;/pre&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1919&quot; data-origin-height=&quot;1029&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/enNUDN/dJMcaiqtHTh/e6NziY7KIXk9uiV9YWx971/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/enNUDN/dJMcaiqtHTh/e6NziY7KIXk9uiV9YWx971/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/enNUDN/dJMcaiqtHTh/e6NziY7KIXk9uiV9YWx971/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FenNUDN%2FdJMcaiqtHTh%2Fe6NziY7KIXk9uiV9YWx971%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;1919&quot; height=&quot;1029&quot; data-origin-width=&quot;1919&quot; data-origin-height=&quot;1029&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Beam End에는 현재 진행도를 그대로 전달했다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;Alpha = Emitter.NormalizedLoopAge&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Beam Start에는 지연값을 적용했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Alpha = Clamp(
    Emitter.NormalizedLoopAge - 0.08,
    0,
    1
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 구조는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;Beam End Alpha
= NormalizedLoopAge

Beam Start Alpha
= Clamp(NormalizedLoopAge - 0.08, 0, 1)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0.08은 시간이 아니라 전체 경로에 대한 비율이다. 값을 키우면 빔이 길어지고, 줄이면 짧아진다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;트러블슈팅&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. User Parameter가 Scratch Pad에서 검색되지 않았다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;증상&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Scratch Dynamic Input 내부의 Map Get에서 다음 파라미터를 검색했지만 나타나지 않았다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;User.BeamStart
User.BeamEnd
Emitter.NormalizedLoopAge&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;잘못된 접근&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완성되지 않은 Scratch Dynamic Input에서 Active Overview를 열어 시스템 파라미터를 직접 가져오려고 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과 Niagara Editor 내부에서 다음 Ensure가 발생했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Encountered '0' parameter map history
when collecting parameters for system parameter panel view mode&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1183&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IQ4KF/dJMcado7Zoq/w5y6w3sphoHq3ZW8nqvOzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IQ4KF/dJMcado7Zoq/w5y6w3sphoHq3ZW8nqvOzk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IQ4KF/dJMcado7Zoq/w5y6w3sphoHq3ZW8nqvOzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIQ4KF%2FdJMcado7Zoq%2Fw5y6w3sphoHq3ZW8nqvOzk%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;1183&quot; height=&quot;506&quot; data-origin-width=&quot;1183&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Scratch Dynamic Input은 당시 아직 출력까지 연결되지 않은 독립 스크립트였다. 그런데 Active Overview는 완성된 System 또는 Emitter의 Parameter Map History를 요구한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그와 재현 조건을 통해 다음 조합이 Ensure의 직접적인 트리거임을 확인했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;미완성 Dynamic Input
+ System Parameter Panel View
+ 유효한 Parameter Map History 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Visual Studio에 표시된 NiagaraEditor.pdb 포함 안 됨은 원인이 아니었다. 엔진 내부 Ensure 위치의 소스를 표시할 디버그 심볼이 없다는 부가 메시지였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Scratch 내부에서 System User Parameter를 직접 찾지 않고, Scratch 자체가 필요한 값을 입력으로 받도록 변경했다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;Scratch Input
├ StartPosition
├ EndPosition
└ Alpha&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바깥 Beam Emitter Setup에서 실제 값을 연결했다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;StartPosition &amp;rarr; User.BeamStart
EndPosition   &amp;rarr; User.BeamEnd
Alpha         &amp;rarr; Emitter.NormalizedLoopAge&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구성하면 Scratch Script가 특정 Niagara System에 강하게 의존하지 않으며 다른 Beam에도 재사용할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 입력을 만들었는데 바깥 Stack에 표시되지 않았다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;증상&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Scratch 그래프에는 StartPosition, EndPosition, Alpha 노드가 존재했지만 Beam Emitter Setup에서는 입력칸이 나타나지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expose Input도 활성화된 상태였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력을 일반 Parameter 노드로 만들었기 때문에 그래프에는 존재했지만 실제 Module Input으로 등록되지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Edit Input Hierarchy를 확인했을 때 다음 항목만 표시됐다.&lt;/p&gt;
&lt;pre class=&quot;hsp&quot;&gt;&lt;code&gt;INPUT Default&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 새로 만든 세 값은 Stack이 인식하는 입력 계층에 포함되지 않은 상태였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1199&quot; data-origin-height=&quot;575&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c3Yops/dJMcabZgObI/YFiEbKbl4SoFXlYKTuoBe1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c3Yops/dJMcabZgObI/YFiEbKbl4SoFXlYKTuoBe1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c3Yops/dJMcabZgObI/YFiEbKbl4SoFXlYKTuoBe1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc3Yops%2FdJMcabZgObI%2FYFiEbKbl4SoFXlYKTuoBe1%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;1199&quot; height=&quot;575&quot; data-origin-width=&quot;1199&quot; data-origin-height=&quot;575&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 Input Parameter 생성 메뉴가 아니라 Parameters 패널의 Module Inputs에서 다시 생성했다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;INPUT StartPosition
INPUT EndPosition
INPUT Alpha&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 Map Get으로 값을 꺼내 Lerp에 연결했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;370&quot; data-origin-height=&quot;449&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4mXmS/dJMb991qULm/4rKsRKiZSUjpkcD8V6BbJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4mXmS/dJMb991qULm/4rKsRKiZSUjpkcD8V6BbJK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4mXmS/dJMb991qULm/4rKsRKiZSUjpkcD8V6BbJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4mXmS%2FdJMb991qULm%2F4rKsRKiZSUjpkcD8V6BbJK%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;370&quot; height=&quot;449&quot; data-origin-width=&quot;370&quot; data-origin-height=&quot;449&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 통해 &amp;ldquo;그래프에 입력 노드가 존재한다&amp;rdquo;와 &amp;ldquo;외부 Stack에서 호출 가능한 Module Input으로 등록됐다&amp;rdquo;는 서로 다른 상태임을 확인했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Loop Duration, Lifetime, Trail Length를 하나의 값처럼 조정하면 안 됐다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트레이서를 튜닝하면서 세 값의 역할을 분리해야 했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&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;Loop Duration&lt;/td&gt;
&lt;td&gt;총구에서 목표까지 이동하는 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Particle Lifetime&lt;/td&gt;
&lt;td&gt;파티클이 살아 있는 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpha 지연값&lt;/td&gt;
&lt;td&gt;빔 조각의 길이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beam Width&lt;/td&gt;
&lt;td&gt;트레이서 굵기&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 Lifetime이 Loop Duration보다 길어 목표 지점에 도착한 빔이 잠시 남는 현상이 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 다음과 같이 기준을 잡았다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Loop Duration = 0.06~0.08
Lifetime      = Loop Duration와 같거나 약간 길게
Trail Offset  = 0.06~0.10&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 연사 간격보다 Loop Duration이 길면 여러 트레이서가 동시에 존재한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;FireRate = 0.1초
Loop Duration = 1.0초

&amp;rarr; 최대 10개의 트레이서가 겹쳐 보일 수 있음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 AK47처럼 연사 간격이 0.1초라면 트레이서 이동 시간은 0.08초 이하로 두는 것이 자연스러웠다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. API 버전 차이와 활성화 순서 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UE 버전에 따라 SpawnSystemAtLocation() 시그니처가 달라 기존 예제를 그대로 사용할 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트에서 요구하는 전체 인자를 확인하고 다음 순서로 호출했다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;SpawnSystemAtLocation(
    WorldContext,
    System,
    Location,
    Rotation,
    Scale,
    bAutoDestroy,
    bAutoActivate,
    PoolingMethod,
    bPreCullCheck
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 컴파일 에러를 맞추는 데서 끝내지 않고 bAutoActivate = false를 의도적으로 사용했다. 파라미터를 넣기 전에 실행되는 첫 프레임을 제거하기 위해서다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 API 오버로드 문제를 해결하면서 Niagara 초기화 순서까지 함께 정리한 사례였다.&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;pre class=&quot;routeros&quot;&gt;&lt;code&gt;클라이언트 발사 입력
&amp;rarr; ServerFire RPC
&amp;rarr; 서버 FireTrace
&amp;rarr; 카메라 기준 조준 지점 계산
&amp;rarr; 총구 기준 2차 트레이스
&amp;rarr; 서버 데미지 적용
&amp;rarr; MuzzleLocation, TracerEndLocation Multicast
&amp;rarr; 각 클라이언트가 Niagara 생성
&amp;rarr; User Parameter 설정
&amp;rarr; Niagara 활성화
&amp;rarr; 짧은 Beam Segment가 목표까지 이동&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 다음 책임이 분리된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버: 명중 판정과 데미지&lt;/li&gt;
&lt;li&gt;데이터테이블: 무기 수치와 표현 리소스&lt;/li&gt;
&lt;li&gt;Niagara: 총알 이동 연출&lt;/li&gt;
&lt;li&gt;Multicast RPC: 연출에 필요한 최소 좌표 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 추가 검증 예정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상하 조준 시 트레이서 방향이 어색하게 보이는 현상이 남아 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드상 트레이서는 다음 데이터테이블 소켓에서 시작한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;MeshComponent-&amp;gt;GetSocketLocation(
    CurrentWeaponData.MuzzleSocketName
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 현재 코드에는 카메라 트레이스와 총구 트레이스를 2초간 표시하는 DrawDebugLine()이 남아 있다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;DrawDebugLine(..., FColor::Red, false, 2.f);
DrawDebugLine(..., FColor::Yellow, false, 2.f);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위아래로 조준하면 카메라와 총구의 시차 때문에 두 디버그 선이 서로 다른 각도를 형성한다. 연사 시 이 선들이 2초간 누적돼 실제 Niagara 방향 문제처럼 보일 가능성이 있다.&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;ndash;총구 시차&lt;/li&gt;
&lt;li&gt;Niagara Local Space 설정&lt;/li&gt;
&lt;li&gt;Absolute Beam Start/End&lt;/li&gt;
&lt;li&gt;실제 트레이서와 디버그 라인의 시각적 구분&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 구현에서 핵심은 &amp;ldquo;총알을 보이게 만들었다&amp;rdquo;가 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Hitscan 구조를 유지하면서 판정과 표현을 분리하고, 네트워크에는 결과 좌표만 전달하며, Niagara에서는 정규화된 시간으로 빔의 앞뒤를 독립적으로 보간했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 Scratch Dynamic Input 문제를 해결하는 과정에서 다음 차이를 확인할 수 있었다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;일반 Parameter &amp;ne; Module Input
그래프에 존재함 &amp;ne; 외부 Stack에서 호출 가능함
Vector &amp;ne; Niagara Position
Emitter Duration &amp;ne; Particle Lifetime
실제 트레이서 &amp;ne; 디버그 시각화&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 무기 로직을 대대적으로 수정하지 않고 데이터 기반&amp;middot;네트워크 친화적인 트레이서 시스템을 추가할 수 있었다.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;</description>
      <category>언리얼엔진/구현&amp;middot;실습</category>
      <author>write76465</author>
      <guid isPermaLink="true">https://write76465.tistory.com/16</guid>
      <comments>https://write76465.tistory.com/16#entry16comment</comments>
      <pubDate>Tue, 14 Jul 2026 00:31:05 +0900</pubDate>
    </item>
  </channel>
</rss>