<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://hon454.github.io/</id>
  <title type="text">Steady Spiral</title>
  <subtitle type="text">개발과 일상을 기록하는 공간</subtitle>
  <updated>2026-11-09T00:00:00.000Z</updated>
  <author><name>Jihoon Jeon</name></author>
  <link rel="alternate" href="https://hon454.github.io/"/>
  <link rel="self" href="https://hon454.github.io/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.9</generator>
    <entry>
      <id>https://hon454.github.io/posts/hermes-agent-secondhand-bump/</id>
      <title type="text">Hermes Agent로 중고 판매글 끌어올리고 판매 완료까지 마무리하기</title>
      <published>2026-11-09T00:00:00.000Z</published>
      <updated>2026-11-09T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/hermes-agent-secondhand-bump/"/>
      <summary type="text">중고 판매글을 올린 뒤에는 에이전트 없이 도는 크론으로 매일 한 번 끌어올리고, 번개장터에서 22만원에 판매가 끝난 뒤에는 같은 물건의 중고나라 글을 이중 판매 전에 정리하게 만든 과정이다. 플랫폼별 끌어올리기 규칙, 글마다 시각을 흩뜨리는 방식, 판매 후 장부 정리 순서를 PS4 Pro 판매 한 건으로 정리한다.</summary>
      <content type="html"><![CDATA[<p><a href="/posts/hermes-agent-secondhand-listing/">전편</a>에서 Merchant 프로필로 PS4 Pro 판매글을 중고나라와 번개장터에 올렸다. 이번에는 올린 뒤를 맡겼다. 판매 중인 글을 매일 끌어올리고, 팔리면 남은 글을 정리한다. 같은 PS4 Pro가 올린 지 이틀 만에 번개장터에서 22만원에 팔렸고, 그 사이에 돌린 일을 정리한다.</p>
<p></p><figure><img alt="번개장터 판매완료 화면" loading="lazy" width="900" height="1250" src="/_astro/bunjang-sold.QmGwne0u_Jl1iw.webp" /><figcaption>번개장터 판매완료 화면</figcaption></figure><p></p>
<section><h2>올린 글을 장부로 다시 관리한다<a href="#올린-글을-장부로-다시-관리한다"><span>#</span></a></h2><p>전편에서는 올린 뒤에는 추적하지 않는 쪽으로 범위를 줄였다. 이번에는 판매 중인 글을 계속 끌어올리고 싶다는 요구가 생겨서 장부를 다시 뒀다. 스크립트가 모델 없이 돌아야 해서 판매 중인 글 목록을 스크립트가 직접 알아야 했기 때문이다.</p><ul>
<li><code>listings.json</code>: 글마다 플랫폼, URL, 제목, 가격, 상태, 끌어올리기 이력과 가격 변경 이력을 적는다.</li>
<li><code>listing-sync</code> 스킬: 두 플랫폼의 내 상품 페이지를 읽기 전용으로 열어 장부와 비교하고, 새 글 추가·종료 처리·가격 변경 반영을 한다.</li>
<li><code>price-adjust</code> 스킬: 가격을 바꾸고 플랫폼 목록에서 다시 읽어 확인한 뒤에만 장부에 기록한다. 이번 22만원은 채팅 흥정으로 정해져서 이 스킬을 쓰지 않았다.</li>
</ul><p><code>listings.json</code>은 <code>data/</code> 아래에 있어서 설정 백업 저장소에는 올라가지 않는다. 글 URL과 가격 같은 거래 정보를 올리지 않으려는 선택이다.</p></section>
<section><h2>끌어올리기 규칙부터 확인했다<a href="#끌어올리기-규칙부터-확인했다"><span>#</span></a></h2><p>횟수 제한과 비용은 플랫폼마다 다르다. 구현 전에 직접 확인했다.</p><ul>
<li>중고나라: 글 상세 페이지의 <code>위로 올리기</code> 버튼을 누르면 확인창이 뜬다. 상품당 하루 2회, 최대 20번이라고 적혀 있다. 총 한도가 20번이라 아무 때나 누르면 금방 소진돼서 하루 1회로 잡았다. 하루 1번이면 글 하나로 20일 정도 쓴다.</li>
<li>번개장터: 공식 고객센터 FAQ 기준으로 무료 UP체험권이 매일 5개 충전되고, 같은 상품에는 하루 한 번만 쓸 수 있다. 영상 광고를 보는 UP과 유료 UP은 자동화하지 않는다.</li>
</ul><p>끌어올리기가 실제로 됐는지는 알림 문구가 아니라 상대 시간 표시로 확인한다. 올리기 전 “17분 전”이던 글이 올린 뒤 “4초 전”으로 바뀐 것을 읽어야 성공으로 기록한다.</p></section>
<section><h2>모델 없이 크론으로 돌린다<a href="#모델-없이-크론으로-돌린다"><span>#</span></a></h2><p>끌어올리기는 가격이나 문구를 바꾸지 않고 같은 글을 다시 노출하는 일이다. 매번 판단할 것이 없어서 에이전트를 부르지 않는 <code>no_agent</code> 크론으로 만들었다.</p><div><figure><figcaption><span>cron job listing-bump</span></figcaption><pre><code><div><div><div>1</div></div><div><span>schedule: */10 20-22 * * *  (Asia/Seoul)</span></div></div><div><div><div>2</div></div><div><span>no_agent: true</span></div></div><div><div><div>3</div></div><div><span>script:   listing_bump.py → listing-bump/scripts/bump_listings.py</span></div></div><div><div><div>4</div></div><div><span>deliver:  #flea-market</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="끌어올리기 크론 한 번의 흐름" loading="lazy" width="960" height="330" src="/_astro/bump-flow.BTsj4_Mc_Z1OWsRN.svg" /><figcaption>끌어올리기 크론 한 번의 흐름</figcaption></figure><p></p><ul>
<li>글과 플랫폼마다 날짜별 목표 시각이 있다. 날짜, 플랫폼, 글 ID를 해시해서 오후 8시대 안에서 정한다. 한꺼번에 몰아 누르지 않으려는 방식이다.</li>
<li>목표 시각이 지났고 오늘 아직 누르지 않은 글만 대상이 된다. 오후 11시까지 못 누르면 그날은 포기하고 <code>놓침</code>으로 알린다.</li>
<li>그날 첫 대상 실행에서 <code>listing-sync</code>를 한 번 돌려 판매됐거나 삭제된 글을 먼저 <code>ended</code>로 바꾼다. 팔린 글을 누르지 않기 위해서다.</li>
<li>글은 하나씩, 4–8초 간격으로 누른다. 실패하면 하루 최대 3번 다시 시도한다.</li>
<li>간격, 하루 횟수, 중고나라 총 한도는 <code>data/bump_config.json</code>으로 코드 수정 없이 바꾼다. 글마다 <code>bump.enabled</code>로 끌 수도 있다.</li>
<li>Slack에는 결과가 있을 때만 올린다. 아무것도 하지 않은 실행은 출력이 비어서 메시지가 없다.</li>
</ul><p></p><figure><img alt="크론이 올린 끌어올리기 완료 알림" loading="lazy" width="2700" height="500" src="/_astro/slack-bump-done.cE6d6zlG_ZMV1bN.webp" /><figcaption>크론이 올린 끌어올리기 완료 알림</figcaption></figure><p></p><p><code>누적 3/20</code>은 중고나라가 센 이 글의 끌어올리기 횟수다.</p></section>
<section><h2>팔린 뒤에는 남은 글이 위험하다<a href="#팔린-뒤에는-남은-글이-위험하다"><span>#</span></a></h2><p>판매는 11월 8일 오후에 끝났다. 번개장터에서 22만원에 팔렸다고 Slack에 적었더니 Merchant가 이렇게 답했다.</p><p></p><figure><img alt="판매 보고에 대한 Merchant의 답변" loading="lazy" width="1600" height="392" src="/_astro/slack-sold-report.B9bYV1gU_1wphu5.webp" /><figcaption>판매 보고에 대한 Merchant의 답변</figcaption></figure><p></p><p>번개장터 글은 장부에서 <code>sold</code>로 바뀌고 판매가 22만원이 기록됐고, 끌어올리기도 꺼졌다.</p><p>남은 문제는 같은 물건을 올려 둔 중고나라 글이었다. 아직 판매중이라 이중 판매 위험이 있었고, 그날 저녁 끌어올리기 크론이 이 글을 올릴 예정이었다. 그래서 이 글의 끌어올리기를 먼저 끄고, 글을 내리거나 판매완료로 바꾸는 일은 승인 대상이라 내게 물었다.</p><p></p><figure><img alt="판매 보고 뒤 정리 순서" loading="lazy" width="960" height="420" src="/_astro/sold-cleanup.BlWjGULi_2dkETN.svg" /><figcaption>판매 보고 뒤 정리 순서</figcaption></figure><p></p><p>중고나라 글은 내가 판매완료로 바꾸고 다시 확인을 시켰다. 판매중 목록에서 글이 빠진 것이 확인돼 장부에 <code>ended</code>로 기록됐고, 그날 저녁 크론은 두 글 모두 아무것도 누르지 않았다.</p></section>
<section><h2>마치며<a href="#마치며"><span>#</span></a></h2><p>사진 한 장과 한 줄로 시작한 판매글이 끌어올리기를 거쳐 실제 판매까지 이어졌다. 자동화를 만들고 그 결과로 물건이 실제로 팔리는 것까지 보니 신기했고, AI 자동화가 일하는 방식을 바꾸고 있다는 것을 처음으로 몸으로 느꼈다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/firefly-upstream-sync-6-16-9/</id>
      <title type="text">Firefly 업스트림 반영: 6.16.9</title>
      <published>2026-11-06T00:00:00.000Z</published>
      <updated>2026-11-06T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/firefly-upstream-sync-6-16-9/"/>
      <summary type="text">Firefly 6.16.9와 이후 af8e98f까지의 모바일 메뉴 드로어, 날짜 타임존 처리, 음악 플레이어, MDX 이미지 그리드 변경과 의존성 업데이트.</summary>
      <content type="html"><![CDATA[<p>Firefly 6.16.9와 이후 <code>af8e98f</code>까지의 업스트림 변경을 반영했다. <a href="/posts/firefly-upstream-sync-6-16-8-cd920b0/">지난 반영</a> 기준은 <code>cd920b035</code>이며 10개 커밋, 17개 파일이 바뀌었다. 6.16.9 릴리스 이후 커밋은 아래에서 따로 표시했다.</p>
<section><h2>모바일 메뉴 드로어<a href="#모바일-메뉴-드로어"><span>#</span></a></h2><ul>
<li>모바일 드로어를 열면 배경이 회색으로 어두워지던 문제와 오버레이 계층 문제를 고쳤다.</li>
<li>드로어와 오버레이를 <code>fixed</code> 대신 <code>absolute</code> 기준으로 옮기고, 열 때의 문서 위치와 높이를 CSS 변수(<code>--mobile-menu-document-top</code>, <code>--mobile-menu-paint-height</code>)로 잡는다. Safari의 하단 툴바 애니메이션으로 그리기 영역이 바뀌는 것을 막으려는 변경이다.</li>
<li>검은 <code>scrim</code> 대신 드로어와 같은 유리 질감 배경을 사용하고, 닫힐 때는 슬라이드가 끝난 뒤(0.3초) 숨겨 화면 가장자리에 그림자가 남지 않게 했다.</li>
<li><code>BrowserChrome.astro</code>를 추가해 브라우저 UI 색(<code>theme-color</code>)을 현재 라이트·다크 테마 배경에 맞춘다. <code>viewport-fit=cover</code>도 함께 설정된다.</li>
</ul></section>
<section><h2>날짜 처리<a href="#날짜-처리"><span>#</span></a></h2><p>릴리스 이후 변경(#700)이다. 날짜 포맷과 아카이브 그룹핑이 <code>siteConfig.timezone</code>을 따르도록 바뀌었다.</p><ul>
<li><code>formatDateToYYYYMMDD</code>가 <code>toISOString()</code> 대신 설정된 타임존으로 날짜를 만든다.</li>
<li><code>2026-11-06</code>처럼 시간 없이 적은 날짜(UTC 자정으로 해석되는 값)는 입력한 날짜를 그대로 유지한다.</li>
<li><code>allPostMeta.json</code>에 <code>publishedDate</code>가 추가되고, 추천 글 위젯이 이 값을 사용한다.</li>
</ul><p>이 블로그의 <code>published</code> 중 시간이 들어간 값은 <code>+09:00</code> 기준 오전 9시, 오후 2시, 오후 5시대라서 <code>siteConfig.timezone</code>(<code>Asia/Shanghai</code>)으로 바꿔도 날짜가 달라지지 않는다.</p></section>
<section><h2>음악 플레이어<a href="#음악-플레이어"><span>#</span></a></h2><p>릴리스 이후 변경(#697)이다.</p><ul>
<li>키보드 미디어 키와 시스템 재생 컨트롤을 플레이어 상태와 동기화한다.</li>
<li>진행 슬라이더를 드래그할 수 있다.</li>
<li>접근성 포커스 링이 잘리던 문제를 고쳤다.</li>
</ul></section>
<section><h2>갤러리와 MDX<a href="#갤러리와-mdx"><span>#</span></a></h2><ul>
<li>릴리스 이후 변경(#699): 모바일 앨범 상세에서 뒤로가기 버튼이 제목에 가려져 눌리지 않던 문제를 고쳤다.</li>
<li>릴리스 이후 변경(#672): <code>remark-image-grid</code>가 MDX 컴포넌트(<code>&lt;TimelineItem&gt;</code>, <code>&lt;StepItem&gt;</code>) 안의 <code>[grid]</code> 블록을 처리한다. <code>[grid]</code> 앞뒤 줄에 본문이 붙어 있어도 문단을 분리해 마커를 인식한다. 이 블로그에는 <code>.mdx</code> 글이 없어 영향받는 글은 없다.</li>
</ul></section>
<section><h2>빌드와 도구<a href="#빌드와-도구"><span>#</span></a></h2><ul>
<li>빌드 스크립트의 <code>astro build</code>가 <code>astro build --force</code>로 바뀌었다(#679).</li>
<li>Biome을 2.5.12에서 2.6.3으로 올렸고 <code>biome.json</code>에서 Markdown 콘텐츠 파일을 제외했다(#680).</li>
<li><code>biome.yml</code> 워크플로의 <code>biomejs/setup-biome</code>에서 <code>version</code> 고정을 제거했다. 이 저장소가 고정해 두었던 <code>2.5.7</code>도 함께 사라지므로 <code>package.json</code>의 Biome 버전을 따른다.</li>
</ul></section>
<section><h2>의존성<a href="#의존성"><span>#</span></a></h2><p>패치 업데이트 23개(#696) 중 주요 항목이다.</p><ul>
<li><code>katex</code>: 0.19.0 → 0.19.1</li>
<li><code>marked</code>: 18.1.0 → 18.1.1</li>
<li><code>sanitize-html</code>: 2.18.0 → 2.18.1</li>
<li><code>sharp</code>: 0.35.4 → 0.35.5</li>
<li><code>svelte</code>: 5.57.0 → 5.57.2</li>
<li><code>@astrojs/check</code>: 0.9.10 → 0.9.12</li>
<li><code>@astrojs/markdown-remark</code>: 7.3.1 → 7.3.4</li>
</ul></section>
<section><h2>검증<a href="#검증"><span>#</span></a></h2><ul>
<li><code>pnpm check</code>: 266개 파일, 오류·경고·힌트 0개.</li>
<li><code>pnpm type-check</code>, <code>pnpm build</code>: 통과. 정적 페이지 124개를 생성하고 Pagefind에 95개 페이지를 인덱싱했다.</li>
<li><code>biome ci ./src</code>: 323개 파일 검사, 수정 사항 없음.</li>
</ul><p><a href="https://github.com/CuteLeaf/Firefly/compare/cd920b035b475fe9a6cd3a88afb42cf9440bb8a9...af8e98fd6a622441b19239bf2f5ea6416af309c2" target="_blank">업스트림 변경 비교</a></p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/hermes-agent-secondhand-listing/</id>
      <title type="text">Hermes Agent로 중고 판매글 작성부터 등록까지 자동화하기</title>
      <published>2026-11-06T00:00:00.000Z</published>
      <updated>2026-11-06T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/hermes-agent-secondhand-listing/"/>
      <summary type="text">사진과 한 줄 요청을 Slack 스레드에 올리면 Hermes Agent의 Merchant 프로필이 시세를 조사해 제목·가격·본문 초안을 쓰고, 승인한 뒤에만 중고나라와 번개장터 폼을 채워 등록하게 만든 과정이다. 승인 게이트, 본문 사실의 출처 구분, 플랫폼별 스킬 분리를 PS4 Pro 판매 한 건으로 정리한다.</summary>
      <content type="html"><![CDATA[<p>Hermes Agent의 별도 프로필 Merchant에 중고 판매글 작성부터 등록까지를 맡겼다. Slack <code>#flea-market</code> 채널에서 스레드 하나가 물건 하나다. 사진과 한 줄 요청을 올리면 Merchant가 사진을 보고 모자란 정보를 묻고, 시세를 조사해 제목·가격·본문 초안을 올린다. 내가 정확한 내용을 승인하면 그때 브라우저로 폼을 채워 등록한다.</p>
<p>2016년 11월 출시 직후에 산 PS4 Pro 1TB 한 대로 이 흐름을 처음 끝까지 돌렸고, 중고나라와 번개장터에 같은 글이 올라갔다. 게임잡 알림은 스크립트만 도는 <code>no_agent</code> 크론이었지만 이 작업은 사진을 읽고 글을 쓰고 가격을 제안해야 해서 에이전트가 직접 돈다. 대신 등록 단계는 승인으로 막아 두었다.</p>
<p></p><figure><img alt="판매글 한 건의 흐름" loading="lazy" width="960" height="430" src="/_astro/sell-flow.CmA7yksE_99TFS.svg" /><figcaption>판매글 한 건의 흐름</figcaption></figure><p></p>
<section><h2>감시를 빼고 올리는 일만 남겼다<a href="#감시를-빼고-올리는-일만-남겼다"><span>#</span></a></h2><p>처음에는 중고나라에 테스트 글을 올려 보는 것에서 시작했다. 사진을 첨부하지 않으면 <code>판매하기</code>가 거부되고(<code>사진/동영상을 1개 이상 첨부해 주세요</code>), 사진을 올리면 설명 칸에 사이트가 추천 문구를 끼워 넣는다는 것을 이때 알았다. 테스트 글은 <code>[테스트]</code> 제목으로 올리고 바로 지웠다.</p><p>그다음에는 게임잡 즐겨찾기처럼 올린 글을 장부로 관리하고, 크론이 상태를 확인해 알려 주고, 시세를 보고 가격 조정을 제안하고, 원하는 물품을 찾아 주는 쪽까지 구상했다. 장부를 git으로 추적할지, 로그인이 풀리면 어떻게 할지를 논의하면서 <code>data/market/listings.json</code> 하나만 추적하는 정책까지 정했다.</p><p>그러다 범위를 줄였다. 장부와 캐시, 가격 조정 이력이 필요한 이유는 모두 감시와 시세 기능이었다. 이 기능을 빼면 저장할 데이터가 없다. 남긴 것은 판매글 작성, 가격 제안, 등록이고, 올린 뒤에는 추적하지 않는다. 장부용으로 추가했던 <code>.gitignore</code> 규칙은 되돌렸고, 프로필의 스킬·<code>config.yaml</code>·<code>memories</code>는 기본 프로필과 같은 비공개 저장소로 백업한다.</p><p></p><figure><img alt="처음 구상과 남긴 범위" loading="lazy" width="960" height="440" src="/_astro/scope-cut.Dh5DQMGX_Z1U0Px2.svg" /><figcaption>처음 구상과 남긴 범위</figcaption></figure><p></p><p>구매 쪽 기능은 나중에 별도로 붙일 예정이라 프로필 설명과 <code>SOUL.md</code>는 판매 전용이나 중고나라 전용으로 쓰지 않았다. 지금 지원하는 기능만 “현재 지원”으로 따로 적는다. 사진은 내가 직접 주기 때문에 Merchant가 맡을 일은 내용 작성, 가격 제안, 실제 등록 과정이다.</p></section>
<section><h2>사진과 한 줄로 시작해 모자란 것만 묻는다<a href="#사진과-한-줄로-시작해-모자란-것만-묻는다"><span>#</span></a></h2><p>스킬이 호출되면 아래 템플릿을 코드 블록으로 먼저 올리고, 채워 온 답에서 비어 있거나 모호한 필수 항목만 한 번에 되묻는다.</p><div><figure><figcaption><span>references/template.md</span></figcaption><pre><code><div><div><div>1</div></div><div><span>[중고 판매 정보]</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>• 상품 (브랜드/모델):</span></div></div><div><div><div>4</div></div><div><span>• 올릴 곳 (중고나라 / 번개장터 / 둘 다):</span></div></div><div><div><div>5</div></div><div><span>• 상태 (중고 / 새상품):</span></div></div><div><div><div>6</div></div><div><span>• 구매 시기 · 사용 기간:</span></div></div><div><div><div>7</div></div><div><span>• 하자/흠집 (없으면 "없음"):</span></div></div><div><div><div>8</div></div><div><span>• 구성품 (전체 / 일부 / 없음 + 내용):</span></div></div><div><div><div>9</div></div><div><span>• 희망가 (비우면 시세 보고 제안):</span></div></div><div><div><div>10</div></div><div><span>• 최저가 (있으면):</span></div></div><div><div><div>11</div></div><div><span>• 거래 방법 (직거래 지역 / 택배 배송비 포함·별도 금액):</span></div></div><div><div><div>12</div></div><div><span>• 기타 전달사항:</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>사진은 이 스레드에 첨부해 주세요.</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>PS4 Pro는 템플릿 없이 사진 4장과 “출시할 때 바로 산 건데 중고나라에 팔려고 한다”는 한 줄로 시작했다. Merchant는 사진에서 읽은 것(본체, 컨트롤러 2개, 후면 포트, 스틱 고무 커버)을 먼저 적고, 모자란 항목 다섯 가지를 번호를 매겨 물었다. 모델과 용량, 하자, 구성품, 거래 방법, 가격이다. 나는 번호대로 한 번에 답했다.</p><p></p><figure><img alt="사진을 읽고 모자란 항목을 묻는 답변" loading="lazy" width="976" height="1115" src="/_astro/slack-questions.DfZ-r3Iv_Zi8dsz.webp" /><figcaption>사진을 읽고 모자란 항목을 묻는 답변</figcaption></figure><p></p></section>
<section><h2>시세를 조사하고 근거 링크와 함께 가격을 제안한다<a href="#시세를-조사하고-근거-링크와-함께-가격을-제안한다"><span>#</span></a></h2><p>가격은 직접 정하지 않고 조사해서 제안하게 했고, 근거가 되는 링크를 함께 보여 달라고 했다. 조사는 공개 페이지를 <code>curl</code>로 읽는다. 중고나라는 검색 결과 HTML에, 당근은 페이지에 들어 있는 JSON에 목록이 있어서 읽을 수 있었다. 번개장터는 공개 API가 막혀 있었고 네이버 플리마켓은 로그인 없이 볼 수 없었다. 그래서 초안에도 중고나라와 당근만 근거로 삼았다고 적혔다.</p><p>한정판, VR, 게임이 다수 포함된 글, 부품용, 중복 게시를 걸러 1TB 일반 블랙 21건이 남았다. 중앙값은 23만원, 중간 50% 구간은 20만–25만원, 전체 범위는 18만–30만원이었다. 모두 판매 희망가라서 실제로 팔리는 가격은 이보다 낮을 수 있다는 단서도 같이 왔다.</p><p></p><figure><img alt="시세 조사 결과와 가격 제안 초안" loading="lazy" width="976" height="1140" src="/_astro/slack-price.CkfE7pn5_qRfr1.webp" /><figcaption>시세 조사 결과와 가격 제안 초안</figcaption></figure><p></p><p>첫 제안은 희망가 24만원, 빠른 판매가 21만원이었다. 이후 사용 횟수나 펌웨어처럼 정보가 바뀔 때마다 가격을 다시 제안했고, 승인하기 전에는 바꾸지 않았다. 중고나라에는 빠른 판매가 22만원으로 등록한 뒤 26만원으로 고쳤고, 초기화 문구를 본문에 넣으면서 24만원으로 내렸다. 번개장터에도 같은 24만원으로 올렸다.</p><p></p><figure><img alt="PS4 Pro 판매가가 바뀐 순서" loading="lazy" width="960" height="400" src="/_astro/price-steps.DFKTcXXA_1gu2xH.svg" /><figcaption>PS4 Pro 판매가가 바뀐 순서</figcaption></figure><p></p></section>
<section><h2>사진은 원본을 두고 복사본을 가공한다<a href="#사진은-원본을-두고-복사본을-가공한다"><span>#</span></a></h2><p>사진은 내가 올리고 Merchant가 모든 사진을 확대해 읽는다. 구동 확인용으로 찍은 시스템 정보 화면에는 MAC 주소와 계정명이 보였다. 그대로 올리지 않고 복사본에 검은 박스를 덮었다. 펌웨어 8.50은 읽히게 남겼고, 본문에도 개인정보를 가렸다고 적었다.</p><p>컨트롤러 사진에는 오른쪽 그립에 잔기스가 있었다. 본문에는 흠집이 없다고 적어 둔 상태였는데, Merchant가 사진과 어긋난다고 먼저 짚어서 <code>[상태]</code>에 잔기스를 한 줄 넣었다. 나는 거기에 사진에도 붉은 원으로 표시해 달라고 요청했다. 원도 복사본에 그렸고 원본 파일은 건드리지 않았다.</p><p></p><figure><img alt="가린 시스템 정보와 잔기스 표시" loading="lazy" width="1520" height="720" src="/_astro/photo-prep.CeOQ9xUh_Z1ttSYM.webp" /><figcaption>가린 시스템 정보와 잔기스 표시</figcaption></figure><p></p></section>
<section><h2>본문의 사실은 출처를 구분한다<a href="#본문의-사실은-출처를-구분한다"><span>#</span></a></h2><p>본문은 가능한 한 자세한 스펙과 구성품을 빠짐없이 적게 했다. 대신 적는 사실의 출처를 세 곳으로 제한했다. 내가 쓴 내용, 내가 준 사진, 모델이 특정되는 범위의 제조사 공식 자료다. 세 곳에 없는 정보는 쓰지 않고, 중요한데 확인이 안 되면 한 번에 되묻거나 뺀다. Slack 초안에서는 스펙 묶음마다 <code>사용자 제공 / 사진 확인 / 공식 스펙 기준</code>을 표시하고, 게시 문구에는 넣지 않는다.</p><p>PS4 Pro에서는 이렇게 나뉘었다. 모델명 <code>CUH-7017B</code>는 처음에는 “같아”라고 적었다가, 직접 확인한 값이 맞다고 답했다. 펌웨어와 잔기스는 사진에서, 1TB 단일 구성은 소니 공식 자료에서, 4K 업스케일링과 HDR은 공식 스펙에서 가져왔다. 한국 출시 모델의 세부 번호는 찾지 못해서 처음에는 <code>CUH-7000 시리즈</code>까지만 적었고, 내가 확인한 번호를 받은 뒤 <code>CUH-7017B</code>로 바꿨다.</p><p></p><figure><img alt="본문에 들어간 말과 출처" loading="lazy" width="960" height="470" src="/_astro/fact-sources.CW9-vcC7_22Qap5.svg" /><figcaption>본문에 들어간 말과 출처</figcaption></figure><p></p><p>이 규칙은 Merchant가 스스로 어긴 일에서 나왔다. 번개장터 폼을 채운 뒤 보고하면서 <code>가격 제안 받기</code>가 기본으로 체크돼 있고 예상 정산액이 225,600원이라고 썼는데, 그 시점에는 둘 다 폼에서 읽은 적이 없었다. 정산액은 이후 폼에서 읽은 값과 같았지만 말한 시점에는 근거가 없었다. Merchant가 다음 메시지에서 먼저 정정했다. 체크 여부는 확인하지 못했고, 정산액은 본 적 없는 숫자라는 내용이다. 이 일 이후 스킬 규칙에 도구 출력으로 확인하지 않은 숫자나 체크 상태는 말하지 않는다는 항목이 들어갔다.</p><p></p><figure><img alt="확인하지 않은 내용을 스스로 정정한 메시지" loading="lazy" width="976" height="470" src="/_astro/slack-self-correct.D5ezjyd8_Z11HaV8.webp" /><figcaption>확인하지 않은 내용을 스스로 정정한 메시지</figcaption></figure><p></p></section>
<section><h2>승인한 뒤에만 등록하고 공개 페이지로 대조한다<a href="#승인한-뒤에만-등록하고-공개-페이지로-대조한다"><span>#</span></a></h2><p>폼을 다 채운 뒤에도 <code>판매하기</code>는 누르지 않고 입력된 내용과 확인할 점을 스레드에 올리게 했다. 아래 보고에는 사이트가 본문 맨 위에 <code>슬림 디스크 버전</code>이라는 문구를 자동으로 넣었다가 지우고 승인본으로 되돌렸다는 내용이 있다. 승인 없이는 등록하지 않는다는 규칙은 판매글뿐 아니라 가격 변경과 메시지 전송까지 적용한다.</p><p></p><figure><img alt="폼 입력 결과와 확인할 점" loading="lazy" width="976" height="1020" src="/_astro/slack-form-check.D6TwajlT_fHLSO.webp" /><figcaption>폼 입력 결과와 확인할 점</figcaption></figure><p></p><p>중고나라 폼에서 걸렸던 부분은 스킬의 <code>references/form-recipe.md</code>에 적어 두었다.</p><ul>
<li>제목은 64자까지만 들어가고 넘치면 조용히 잘린다. 펌웨어를 제목에 넣을 때 <code>제트블랙</code>을 빼서 63자로 맞췄다. 색상은 본문에 있다.</li>
<li>사진을 추가할 때마다 사이트가 설명 맨 위에 추천 문구를 넣는다. 마지막 사진 변경 뒤에 설명을 지우고 다시 입력하고, <code>textarea.value</code>가 승인본과 같은지 확인한다.</li>
<li>카테고리는 기본값을 믿지 않는다. 한 번은 <code>게임 &gt; 기타 게임</code>으로 등록돼서 등록 직후 수정 폼에서 <code>게임 &gt; 플레이스테이션</code>으로 바꿨다. 지금은 제목을 입력하면 나오는 추천 칩을 눌러 고르고, 등록 뒤 칩을 다시 확인한다.</li>
<li>가격은 <code>fill</code>로 넣어도 반영되지 않는다. 필드가 옆 패널을 열기 때문에 입력창을 클릭해 키를 입력하고 <code>입력 완료</code>를 눌러야 한다.</li>
</ul><p>등록 뒤에는 공개 페이지를 <code>curl</code>로 읽어 제목, 가격, 본문의 핵심 줄, 카테고리를 승인본과 대조한 뒤 URL을 보고한다. 수정 폼에서 저장하면 탭이 새 탭으로 넘어가 화면으로는 결과를 볼 수 없는 경우가 있어서, 수정 후에도 공개 페이지로 확인한다.</p><p></p><figure><img alt="등록된 중고나라 글" loading="lazy" width="1200" height="748" src="/_astro/joongna-listing.BzVWB3QI_iPLya.webp" /><figcaption>등록된 중고나라 글</figcaption></figure><p></p></section>
<section><h2>번개장터를 붙이면서 스킬을 나눴다<a href="#번개장터를-붙이면서-스킬을-나눴다"><span>#</span></a></h2><p>번개장터는 내가 <code>ego-browser</code>에 로그인해 둔 계정으로 같은 흐름을 돌렸다. 폼은 중고나라와 달랐다.</p><ul>
<li>처음 열면 브라우저 알림 권한 팝업이 뜬다. 에이전트는 이 팝업을 처리하지 않고 작업 공간을 넘기며, 내가 누른 뒤 같은 작업 공간에서 이어 간다.</li>
<li>직거래 기본값은 <code>불가</code>다. <code>가능</code>으로 바꾼 뒤 희망 장소를 넣는다.</li>
<li>화면 아래 고정 바가 입력창을 가려서 요소를 가운데로 스크롤한 뒤 좌표로 클릭한다.</li>
<li>상품 페이지는 브라우저에서 렌더링되어 <code>curl</code>로는 내용이 읽히지 않는다. 그래서 등록 직후 브라우저에 뜬 페이지에서 제목, 가격, 본문 앞부분까지만 확인했다.</li>
</ul><p>첫 등록이라 이번에는 폼을 채운 뒤 등록 직전에서 멈추고 요약을 받았다. <code>가격 제안 받기</code>는 유지하고 가격은 24만원 그대로 올리라고 답했다.</p><p></p><figure><img alt="등록된 번개장터 글" loading="lazy" width="1200" height="621" src="/_astro/bunjang-listing.uHhrT3g3_1KqUXF.webp" /><figcaption>등록된 번개장터 글</figcaption></figure><p></p><p>번개장터를 한 스킬에 계속 이어 붙이면 <code>joongna-posting</code>이 두 플랫폼의 절차와 공통 규칙을 함께 들게 된다. 본문, 가격, 사진 쪽은 두 곳이 같고 폼만 다르다는 점을 근거로 구조를 나눴다. 공통 부분은 메타 스킬 <code>secondhand-sell-desk</code>에 올리고, 플랫폼별 폼 절차는 각 스킬에 둔다.</p><p></p><figure><img alt="판매 스킬 구조" loading="lazy" width="960" height="430" src="/_astro/skill-structure.CfK8wTAv_99TFS.svg" /><figcaption>판매 스킬 구조</figcaption></figure><p></p><p>채널이 새 메타 스킬로 시작하게 하려면 <code>config.yaml</code>의 채널 바인딩을 바꿔야 한다. 이 파일은 에이전트가 쓰지 못하게 막혀 있어서 Merchant는 우회하지 않고 바꿀 줄을 알려 줬고, 내가 직접 고친 뒤 게이트웨이를 재시작했다. <code>joongna-posting</code>에 “연결 변경됨”이라고 적어 놓았던 것도 사실이 아니라서 Merchant가 되돌렸다. 설정 파일에 반영된 것은 확인했다.</p><div><figure><figcaption><span>~/.hermes/profiles/merchant/config.yaml</span></figcaption><pre><code><div><div><div>1</div></div><div><span>slack</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>channel_skill_bindings</span><span>:</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>- </span><span>id</span><span>: </span><span>&lt;#flea-market 채널 ID&gt;</span></div></div><div><div><div>4</div></div><div><span>      </span><span>skills</span><span>:</span></div></div><div><div><div>5</div></div><div><span><span>        </span></span><span>- </span><span>secondhand-sell-desk</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이제 <code>#flea-market</code>에 새 스레드를 열면 <code>secondhand-sell-desk</code>가 템플릿부터 시작하고, 올릴 곳에 따라 <code>joongna-posting</code>이나 <code>bunjang-posting</code>의 폼 절차를 부른다. 다른 플랫폼을 붙일 때는 폼 절차 스킬 하나만 더하면 된다. 구매 쪽 기능도 이 판매 스킬에 섞지 않고 별도 스킬로 붙일 생각이다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/interview-review-net-relevancy/</id>
      <title type="text">면접 오답노트: 말하지 못한 주요 개념, Net Relevancy</title>
      <published>2026-11-05T00:00:00.000Z</published>
      <updated>2026-11-05T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/interview-review-net-relevancy/"/>
      <summary type="text">면접에서 제대로 답하지 못한 Net Relevancy를 다시 정리한다. AActor::IsNetRelevantFor의 판정 순서와 문서와 다른 부분, Owner가 판정을 바꾸는 방식, 연관성을 잃고 되찾을 때 RelevantTimeout과 액터 파괴, BeginPlay, OnRep, RPC에 생기는 일, ForceNetRelevant와 Replication Graph, Iris에서의 주의점, Dormancy, Priority와의 차이를 다룬다.</summary>
      <content type="html"><![CDATA[<p>면접에서 RPC의 Reliable을 설명한 직후 “Net Relevancy는 무엇인가요?”라는 질문을 받았다. 연관성이 있느냐에 따라 RPC를 호출할 수 있는지가 갈린다고 답하자 꼬리 질문이 이어졌다.</p>
<blockquote><p>그럼 액터가 Relevant해야 할 때와 아니어야 할 때는 각각 언제인가요? 코드에서는 결국 그걸 정해 줘야 하잖아요.</p></blockquote>
<p>이 질문에는 답하지 못했고 면접은 다음 질문으로 넘어갔다. 돌이켜보면 처음 답부터 헛다리였다. 질문은 서버가 어떤 액터를 어떤 클라이언트에 보내느냐였는데, 나는 RPC를 호출할 수 있느냐를 말했다. 그것은 Relevancy가 아니라 Ownership이 정하는 문제다.</p>
<p>답하지 못한 것이 꽤 충격이었다. Relevancy는 서버가 무엇을 누구에게 보낼지 정하는, 언리얼 네트워크의 근간이 되는 개념이다. Night of the Dead에서 <a href="/posts/unreal-engine-replication-graph/">Replication Graph</a>를 다루며 늘 만지던 것이기도 하다. 실마리 하나만 잡혔어도 설명을 이어 갈 수 있었을 텐데, 그 첫 문장을 꺼내지 못해 뒤의 답변 전체가 막혔다는 점이 가장 치명적이었다. 중요한 개념이라는 인식은 있었지만 그 자리에서 어떻게 설명해야 할지 떠오르지 않았고, 개념이 부족했다고 생각한다. 그래서 다시 공부하고 정리해 보려 한다.</p>
<section><h2>Relevancy가 정하는 것<a href="#relevancy가-정하는-것"><span>#</span></a></h2><p>면접관의 첫 질문은 Relevancy가 무엇이냐였다. 레벨에는 액터가 많지만 한 플레이어에게 영향을 주는 것은 일부다. 서버는 연결(connection)마다 그 클라이언트에 의미 있는 액터 집합을 정하고 그 집합의 액터만 복제한다. 이 집합에 드는 액터가 그 연결에 Relevant하다. 판정 단위는 액터가 아니라 액터와 연결의 쌍이라서, 같은 액터가 A 클라이언트에는 연관 있고 B 클라이언트에는 연관 없을 수 있다.</p><p></p><figure><img alt="연결마다 다른 Relevant 판정" loading="lazy" width="1000" height="600" src="/_astro/relevancy-per-connection.Cce95-wY_2nhfzh.svg" /><figcaption>연결마다 다른 Relevant 판정</figcaption></figure><p></p><p>서버에서 Relevancy는 Actor channel을 열고 닫는 기준이다.</p><ul>
<li>채널이 없는 액터는 연관 없으면 보내지 않는다. 연관 있으면 채널을 열고 처음 복제한다.</li>
<li>채널이 있는 액터는 연관성을 잃어도 바로 닫지 않고 유예 뒤에 닫는다(아래 <a href="#%EC%97%B0%EA%B4%80%EC%84%B1%EC%9D%84-%EC%9E%83%EA%B3%A0-%EB%90%98%EC%B0%BE%EC%9D%84-%EB%95%8C">연관성을 잃고 되찾을 때</a>).</li>
<li>Multicast RPC도 연관 있는 연결에만 보낸다.</li>
</ul><p>Relevancy가 RPC에 관여하는 것은 이 세 번째 항목 정도다. 꼬리 질문인 “언제 Relevant해야 하나”의 답은 아래의 판정 순서와, 그 순서를 바꾸는 플래그에 있다.</p></section>
<section><h2><code>IsNetRelevantFor</code>의 판정 순서<a href="#isnetrelevantfor의-판정-순서"><span>#</span></a></h2><p></p><figure><img alt="IsNetRelevantFor의 판정 순서" loading="lazy" width="960" height="930" src="/_astro/is-net-relevant-for-order.CenWoW83_Z5Cr6x.svg" /><figcaption>IsNetRelevantFor의 판정 순서</figcaption></figure><p></p><p><code>AActor</code>의 기본 구현이다. 위에서부터 검사해 처음 걸린 조건으로 끝난다.</p><div><div><div><figure><figcaption><span>Engine/Private/ActorReplication.cpp</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>bool</span><span><span> </span><span>AActor</span><span>::</span></span><span>IsNetRelevantFor</span><span>(</span><span>const</span><span> </span><span>AActor</span><span>*</span><span><span> </span><span>RealViewer</span><span>, </span></span><span>const</span><span> </span><span>AActor</span><span>*</span><span><span> </span><span>ViewTarget</span><span>, </span></span><span>const</span><span> </span><span>FVector</span><span>&amp;</span><span><span> </span><span>SrcLocation</span><span>) </span></span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> (</span><span>bAlwaysRelevant</span><span> </span><span>||</span><span> </span><span>IsOwnedBy</span><span>(</span><span>ViewTarget</span><span>) </span><span>||</span><span> </span><span>IsOwnedBy</span><span>(</span><span>RealViewer</span><span>) </span><span>||</span><span> </span><span>this</span><span> </span><span>==</span><span> </span><span>ViewTarget</span><span> </span><span>||</span><span> </span><span>ViewTarget</span><span> </span><span>==</span><span> </span><span>GetInstigator</span><span>())</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>7</div></div><div><span>  </span><span>else</span><span> </span><span>if</span><span> (</span><span>bNetUseOwnerRelevancy</span><span> </span><span>&amp;&amp;</span><span> </span><span>Owner</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>return</span><span> </span><span>Owner</span><span>-&gt;</span><span>IsNetRelevantFor</span><span>(</span><span>RealViewer</span><span>, </span><span>ViewTarget</span><span>, </span><span>SrcLocation</span><span>);</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>11</div></div><div><span>  </span><span>else</span><span> </span><span>if</span><span> (</span><span>bOnlyRelevantToOwner</span><span>)</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>13</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>15</div></div><div><span>  </span><span>else</span><span> </span><span>if</span><span> (</span><span>RootComponent</span><span> </span><span>&amp;&amp;</span><span> </span><span>RootComponent</span><span>-&gt;</span><span>GetAttachParent</span><span>() </span><span>&amp;&amp;</span><span> </span><span>RootComponent</span><span>-&gt;</span><span>GetAttachParent</span><span>()-&gt;</span><span>GetOwner</span><span>() </span><span>&amp;&amp;</span><span> (</span><span>Cast</span><span>&lt;</span><span>USkeletalMeshComponent</span><span>&gt;(</span><span>RootComponent</span><span>-&gt;</span><span>GetAttachParent</span><span>()) </span><span>||</span><span> (</span><span>RootComponent</span><span>-&gt;</span><span>GetAttachParent</span><span>()-&gt;</span><span>GetOwner</span><span>() </span><span>==</span><span> </span><span>Owner</span><span>)))</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>17</div></div><div><span>    </span><span>return</span><span> </span><span>RootComponent</span><span>-&gt;</span><span>GetAttachParent</span><span>()-&gt;</span><span>GetOwner</span><span>()-&gt;</span><span>IsNetRelevantFor</span><span>(</span><span>RealViewer</span><span>, </span><span>ViewTarget</span><span>, </span><span>SrcLocation</span><span>);</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>19</div></div><div><span>  </span><span>else</span><span> </span><span>if</span><span>(</span><span>IsHidden</span><span>() </span><span>&amp;&amp;</span><span> (</span><span>!</span><span>RootComponent</span><span> </span><span>||</span><span> </span><span>!</span><span>RootComponent</span><span>-&gt;</span><span>IsCollisionEnabled</span><span>()))</span></div></div><div><div><div>20</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>21</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span>RootComponent</span><span>)</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>26</div></div><div><span>    </span><span>UE_LOGF</span><span>(</span><span>LogNet</span><span>, </span><span>Warning</span><span>, </span><span>"Actor </span><span>%ls</span><span> / </span><span>%ls</span><span> has no root component in AActor::IsNetRelevantFor. (Make bAlwaysRelevant=true?)"</span><span>, </span><span>*</span><span>GetClass</span><span>()-&gt;</span><span>GetName</span><span>(), </span><span>*</span><span>GetName</span><span>() );</span></div></div><div><div><div>27</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>28</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span>  </span><span>return</span><span> </span><span>!</span><span>GetDefault</span><span>&lt;</span><span>AGameNetworkManager</span><span>&gt;()-&gt;</span><span>bUseDistanceBasedRelevancy</span><span> </span><span>||</span></div></div><div><div><div>31</div></div><div><span>      </span><span>IsWithinNetRelevancyDistance</span><span>(</span><span>SrcLocation</span><span>);</span></div></div><div><div><div>32</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>세 인자는 연결마다 정해진다.</p>

<table><thead><tr><th>인자</th><th>값</th></tr></thead><tbody><tr><td><code>RealViewer</code></td><td>연결의 PlayerController</td></tr><tr><td><code>ViewTarget</code></td><td>연결의 ViewTarget. 보통 조종 중인 Pawn</td></tr><tr><td><code>SrcLocation</code></td><td>그 연결의 카메라 시점 위치</td></tr></tbody></table><p>단계별로 보면 다음과 같다. 번호는 위 그림과 같다.</p><ol>
<li>이 연결의 것인지 먼저 본다. <code>bAlwaysRelevant</code>이거나, Owner 체인에 그 연결의 PlayerController나 Pawn이 있거나, 그 Pawn이 Instigator면 숨김이나 거리와 관계없이 연관 있다.</li>
<li><code>bNetUseOwnerRelevancy</code>면 Owner의 판정을 그대로 쓴다.</li>
<li><code>bOnlyRelevantToOwner</code>면 연관 없다. 소유자는 이미 1번에서 통과했으므로, 여기까지 온 것은 소유자가 아닌 연결뿐이다.</li>
<li>다른 액터에 붙어 있으면 부모 액터의 판정을 따른다. Epic 문서는 스켈레탈 메시에 붙은 경우만 설명하지만, 부모 액터의 Owner가 내 Owner와 같을 때도 부모를 따른다.</li>
<li>숨기기만 해서는 빠지지 않고, 루트 충돌까지 꺼져 있어야 연관 없음이 된다. 반대로 렌더링이 필요 없다고 충돌 없는 액터를 서버에서 <code>SetHidden(true)</code>로 스폰하면 아무에게도 복제되지 않는다. <a href="/posts/unreal-engine-replication-graph/">Replication Graph를 측정</a>할 때 테스트 액터 5,000개가 이 조건에 걸려 기본 드라이버로는 하나도 복제되지 않았다.</li>
<li>루트 컴포넌트가 없으면 경고 로그를 남기고 연관 없음이 된다. 루트 없이 데이터만 나르는 액터는 1–3번에서 결론이 나도록 플래그를 정해 둔다.</li>
<li>마지막이 거리다. 기준점은 Pawn이 아니라 카메라 시점이라서, 3인칭 카메라가 폰 뒤로 빠져 있으면 기준점도 그만큼 뒤에 있다. <code>NetCullDistanceSquared</code>는 이름대로 제곱값이고 기본값 <code>225000000</code>은 150 m다.</li>
</ol><section><h3>클래스별 오버라이드<a href="#클래스별-오버라이드"><span>#</span></a></h3><p>기본 구현은 <code>AActor</code>의 것이고 엔진 클래스 몇 개는 판정이나 기본값이 다르다.</p>

<table><thead><tr><th>클래스</th><th>판정 또는 기본값</th></tr></thead><tbody><tr><td><code>APawn</code></td><td>자신의 Controller와 서로 밟고 선 액터에는 항상 연관 있다. <code>bNetUseOwnerRelevancy</code>는 보지 않는다</td></tr><tr><td><code>APlayerController</code></td><td>자기 연결에만 연관 있다</td></tr><tr><td><code>AController</code></td><td>기본으로 <code>bOnlyRelevantToOwner</code></td></tr><tr><td><code>AGameStateBase</code>, <code>APlayerState</code></td><td>기본으로 <code>bAlwaysRelevant</code></td></tr></tbody></table><p><code>bOnlyRelevantToOwner</code>는 NetDriver가 한 번 더 걸러서, 오버라이드가 플래그를 무시해도 소유 연결 밖으로는 가지 않는다.</p></section></section>
<section><h2>Owner가 판정을 바꾸는 방식<a href="#owner가-판정을-바꾸는-방식"><span>#</span></a></h2><p>Owner는 판정 순서에서 세 번 쓰인다.</p><ol>
<li>소유 체인. <code>IsOwnedBy</code>는 Owner를 끝까지 따라 올라간다. 무기의 Owner가 Pawn이고 Pawn의 Owner가 PlayerController면(빙의하면 Pawn의 Owner는 PlayerController가 된다) 무기는 거리와 관계없이 그 플레이어에게 항상 연관 있다.</li>
<li><code>bOnlyRelevantToOwner</code>. 소유자 외에는 연관 없다. Owner가 없으면 아무에게도 가지 않는다.</li>
<li><code>bNetUseOwnerRelevancy</code>. 판정을 Owner에게 넘긴다. 폰이 들고 있는 무기나 장비는 폰이 보이는 클라이언트에게 같이 보여야 하므로 이 플래그를 켜고 Owner를 폰으로 둔다.</li>
</ol><p>1번 조건에는 Instigator도 있다. 내 Pawn이 Instigator인 투사체는 거리와 관계없이 나에게 연관 있다.</p><p>판정은 서버에서 돌기 때문에 Owner도 서버에서 정한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 예시: 폰이 들고 다니는 무기</span></div></div><div><div><div>2</div></div><div><span><span>AMyWeapon</span><span>::</span></span><span>AMyWeapon</span><span>()</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>bReplicates</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>5</div></div><div><span>  </span><span>bNetUseOwnerRelevancy</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span><span> // 폰이 연관 있는 연결에만 연관 있다</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>// 서버에서 스폰할 때 Owner와 Instigator를 지정한다</span></div></div><div><div><div>9</div></div><div><span>FActorSpawnParameters</span><span> </span><span>Params</span><span>;</span></div></div><div><div><div>10</div></div><div><span>Params</span><span>.</span><span>Owner</span><span> </span><span>=</span><span> </span><span>OwnerPawn</span><span>;</span></div></div><div><div><div>11</div></div><div><span>Params</span><span>.</span><span>Instigator</span><span> </span><span>=</span><span> </span><span>OwnerPawn</span><span>;</span></div></div><div><div><div>12</div></div><div><span>GetWorld</span><span>()-&gt;</span><span>SpawnActor</span><span>&lt;</span><span>AMyWeapon</span><span>&gt;</span><span>(</span><span>WeaponClass</span><span>, </span><span>SpawnTransform</span><span>, </span><span>Params</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>정리하면 꼬리 질문의 답은 다음 표다.</p>

<table><thead><tr><th>액터</th><th>설정</th></tr></thead><tbody><tr><td>모두가 알아야 하는 전역 상태(GameState, PlayerState)</td><td><code>bAlwaysRelevant</code></td></tr><tr><td>소유자만 알면 되는 것(PlayerController)</td><td><code>bOnlyRelevantToOwner</code>와 Owner</td></tr><tr><td>들고 있는 폰을 따라가야 하는 것(무기, 장비)</td><td><code>bNetUseOwnerRelevancy</code>와 Owner</td></tr><tr><td>보이는 거리만 다른 것</td><td><code>NetCullDistanceSquared</code></td></tr><tr><td>팀원에게만 보이는 것처럼 규칙이 특수한 것</td><td><code>IsNetRelevantFor</code> 오버라이드</td></tr></tbody></table><section><h3>관련 함수와 프로퍼티<a href="#관련-함수와-프로퍼티"><span>#</span></a></h3>

<table><thead><tr><th>이름</th><th>하는 일</th></tr></thead><tbody><tr><td><code>IsNetRelevantFor</code></td><td>특정 연결에 연관 있는지 판정한다. NetDriver가 자동으로 부르며 오버라이드할 수 있다</td></tr><tr><td><code>IsWithinNetRelevancyDistance</code></td><td>시점 위치와의 거리 제곱이 <code>NetCullDistanceSquared</code>보다 작은지 확인한다</td></tr><tr><td><code>IsRelevancyOwnerFor</code></td><td><code>bOnlyRelevantToOwner</code> 액터의 소유자 판정에 쓴다</td></tr><tr><td><code>IsReplayRelevantFor</code></td><td>리플레이 녹화에 넣을지 판정한다</td></tr><tr><td><code>ForceNetRelevant</code></td><td>강제로 연관 있게 한다고 설명되지만 조건이 있다(아래 <a href="#forcenetrelevant%EB%8A%94-%EC%97%B0%EA%B4%80%EC%84%B1%EC%9D%84-%EC%9C%A0%EC%A7%80%ED%95%B4-%EC%A3%BC%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4">주의할 점</a>)</td></tr><tr><td><code>bAlwaysRelevant</code></td><td>모든 연결에 연관 있다. <code>bOnlyRelevantToOwner</code>보다 우선한다</td></tr><tr><td><code>bOnlyRelevantToOwner</code></td><td>소유자에게만 연관 있다</td></tr><tr><td><code>bNetUseOwnerRelevancy</code></td><td>Owner가 있으면 Owner의 <code>IsNetRelevantFor</code>와 <code>GetNetPriority</code>를 쓴다</td></tr><tr><td><code>bRelevantForNetworkReplays</code></td><td>리플레이에 복제할지 정한다. 기본값 true</td></tr><tr><td><code>NetCullDistanceSquared</code></td><td>연관 있는 최대 거리의 제곱. 기본 150 m</td></tr><tr><td><code>Owner</code></td><td><code>bOnlyRelevantToOwner</code>와 <code>bNetUseOwnerRelevancy</code>가 참조하는 소유자</td></tr></tbody></table></section></section>
<section><h2>연관성을 잃고 되찾을 때<a href="#연관성을-잃고-되찾을-때"><span>#</span></a></h2><p>여기부터는 면접에서 이어서 받을 만한 질문을 정리했다. 먼저 “연관성을 잃었다가 되찾으면 클라이언트에서는 무슨 일이 생기나요?”다.</p><p></p><figure><img alt="연관성을 잃고 되찾는 동안의 변화" loading="lazy" width="1000" height="620" src="/_astro/relevancy-lost-and-regained.BiJdY0w1_1gjRSI.svg" /><figcaption>연관성을 잃고 되찾는 동안의 변화</figcaption></figure><p></p><ul>
<li>연관성을 잃어도 바로 끊지 않는다. 마지막으로 Relevant했던 때부터 <code>RelevantTimeout</code>(기본 5초)이 지날 때까지는 계속 복제한다. 경계를 오가는 액터가 생겼다 사라지기를 반복하지 않게 하는 장치로 보인다.</li>
<li>유예가 끝나면 동적으로 스폰한 액터는 채널이 닫혀 클라이언트에서 파괴되고 <code>EndPlay</code>가 불린다. 맵에 배치한 액터는 채널이 남아 사라지지 않고 복제만 멈춘다.</li>
<li>다시 Relevant해지면 동적 액터는 새로 스폰되어 <code>BeginPlay</code>가 다시 불리고, 기본값과 다른 프로퍼티의 OnRep이 온다. 맵에 배치한 액터는 복제가 재개되어 바뀐 값의 OnRep이 온다. 처음부터 범위 밖에서 스폰된 액터도 Relevant해질 때까지는 클라이언트에 나타나지 않는다.</li>
</ul><p>RPC는 프로퍼티와 다르게 다룬다. 연관 없는 연결에도 채널이 열려 있으면 Reliable Multicast는 간다. Unreliable Multicast는 연관 없는 연결에는 가지 않는다. 그래서 유예 중인 액터나 채널이 남아 있는 맵 배치 액터는 범위 밖에서도 Reliable Multicast를 받는다. 채널이 닫힌 동적 액터는 아무것도 받지 못하고, 다시 Relevant해져도 그사이 RPC는 재전송되지 않는다.</p><p>액터를 짤 때는 두 가지를 전제로 둔다.</p><ul>
<li>동적 액터의 클라이언트 <code>BeginPlay</code>와 <code>EndPlay</code>는 여러 번 불릴 수 있다. <code>BeginPlay</code>에서 다른 객체에 등록했다면 <code>EndPlay</code>에서 해제해, 다시 불려도 문제없게 한다.</li>
<li>늦게 보게 된 클라이언트도 알아야 하는 상태는 RPC가 아니라 복제 프로퍼티에 두고, 현재 프로퍼티 값만으로 상태를 다시 맞출 수 있게 한다. RPC는 그 순간의 연출 같은 이벤트에 쓴다.</li>
</ul></section>
<section><h2>바꿀 때 주의할 점<a href="#바꿀-때-주의할-점"><span>#</span></a></h2><p>“연관성을 직접 바꿀 때 주의할 점은요?”라는 질문에는 세 가지를 답할 수 있다.</p><section><h3><code>ForceNetRelevant</code>는 연관성을 유지해 주지 않는다<a href="#forcenetrelevant는-연관성을-유지해-주지-않는다"><span>#</span></a></h3><p>Epic 문서는 <code>ForceNetRelevant</code>를 “기본적으로 연관 없는 액터를 강제로 연관 있게 한다”고 설명하지만 실제 동작은 다르다. <code>bAlwaysRelevant</code>를 켜는 것은 복제하지 않던 액터뿐이다. 이미 복제 중인 액터에는 <code>ForceNetUpdate</code>만 부르는데, <code>ForceNetUpdate</code>는 다음 복제 검토에 넣을 뿐 연관성 검사는 그대로 거친다. 범위 밖의 복제 액터를 보이게 하려면 <code>bAlwaysRelevant</code>나 Owner 계열 플래그, 오버라이드 중 하나를 쓴다.</p></section><section><h3>오버라이드는 싸야 한다<a href="#오버라이드는-싸야-한다"><span>#</span></a></h3><p>기본 드라이버는 복제 검토 대상인 액터 수 × 연결 수만큼 <code>IsNetRelevantFor</code>를 부른다. 오버라이드에서 트레이스나 컨테이너 탐색을 하면 액터와 플레이어가 늘수록 서버 비용이 곱으로 커진다. Epic 문서도 복제 시스템을 잘 모르면 의도치 않은 결과가 생길 수 있다며 오버라이드에 주의를 당부한다.</p></section><section><h3>Replication Graph와 Iris는 <code>IsNetRelevantFor</code>를 부르지 않는다<a href="#replication-graph와-iris는-isnetrelevantfor를-부르지-않는다"><span>#</span></a></h3><p><a href="/posts/unreal-engine-replication-graph/">Replication Graph</a>는 클래스의 CDO 플래그를 보고 액터를 노드(항상 연관, 연결별, Grid)로 라우팅하고, 노드가 Relevancy 판정을 대신한다. Iris도 CDO의 <code>bAlwaysRelevant</code>, <code>bOnlyRelevantToOwner</code>, <code>bNetUseOwnerRelevancy</code>로 공간 필터를 쓸지 정하고, <code>bOnlyRelevantToOwner</code> 액터에는 소유자 필터를 건다. 두 경로 모두 <code>IsNetRelevantFor</code> 오버라이드는 무시되므로 특수한 규칙은 노드나 필터로 다시 구현해야 한다.</p></section></section>
<section><h2>Relevancy, Dormancy, Priority<a href="#relevancy-dormancy-priority"><span>#</span></a></h2><p>Relevancy를 설명하면 Dormancy, Priority와는 무엇이 다르냐는 질문도 나오기 쉽다. 셋 다 “덜 보내기” 위한 장치지만 묻는 질문이 다르다.</p>

<table><thead><tr><th></th><th>Relevancy</th><th>Dormancy</th><th>Priority</th></tr></thead><tbody><tr><td>묻는 것</td><td>이 연결에 이 액터를 보낼 것인가</td><td>바뀐 게 없으니 검사를 쉬어도 되는가</td><td>대역폭이 모자랄 때 무엇을 먼저 보낼 것인가</td></tr><tr><td>단위</td><td>액터 × 연결</td><td>정책은 액터, 휴면 상태는 연결별</td><td>액터 × 연결</td></tr><tr><td>걸렸을 때 클라이언트</td><td>동적 액터는 사라진다</td><td>액터가 남고 마지막 값을 유지한다</td><td>갱신이 늦어진다</td></tr><tr><td>관련 API</td><td><code>bAlwaysRelevant</code>, <code>NetCullDistanceSquared</code>, <code>IsNetRelevantFor</code></td><td><code>NetDormancy</code>, <code>FlushNetDormancy</code></td><td><code>NetPriority</code>, <code>GetNetPriority</code></td></tr></tbody></table><p>Priority는 연관 있는 액터 사이의 순서다. <a href="https://dev.epicgames.com/documentation/unreal-engine/actor-priority-in-unreal-engine" target="_blank">Actor Priority 문서</a>에 따르면 연결이 포화됐을 때 무엇을 먼저 보낼지 정하는 상대 비율이다. Dormancy는 Relevancy와 다르다. 휴면 액터는 클라이언트에 남은 채 정해 둔 시점까지 업데이트만 멈춘다. 자세한 내용은 <a href="/posts/unreal-engine-waking-dormant-actors/">휴면 액터 깨우기</a>에 정리했다.</p></section>
<section><h2>다시 답한다면<a href="#다시-답한다면"><span>#</span></a></h2><p>같은 질문을 다시 받는다면 위 내용을 줄여 이렇게 답하고 싶다. 질문별로 나눠 답했다.</p><section><h3>Relevancy는 무엇이고, 잃으면 어떻게 되나요<a href="#relevancy는-무엇이고-잃으면-어떻게-되나요"><span>#</span></a></h3><blockquote><p>두 가지로 말씀드리겠습니다. 첫째, Relevancy는 서버가 클라이언트 연결마다 어떤 액터를 그 클라이언트에 복제할지 정하는 판정입니다. 그래서 같은 액터가 어떤 클라이언트에는 보이고 어떤 클라이언트에는 보이지 않을 수 있고, 그 클라이언트와 상관없는 액터를 빼서 대역폭을 아낍니다. 둘째, 연관성을 잃어도 기본 5초 동안은 계속 보내다가, 그 뒤 동적으로 스폰한 액터는 채널이 닫혀 그 클라이언트에서 파괴되고, 맵에 배치한 액터는 남은 채로 복제만 멈춥니다. 동적 액터는 다시 Relevant해지면 새로 스폰되면서 현재 상태를 받고 그사이 RPC는 오지 않으니, 늦게 본 클라이언트도 알아야 하는 상태는 복제 프로퍼티에 둡니다.</p></blockquote></section><section><h3>액터가 Relevant해야 할 때와 아니어야 할 때는 언제인가요<a href="#액터가-relevant해야-할-때와-아니어야-할-때는-언제인가요"><span>#</span></a></h3><blockquote><p>기본은 거리로 정하고, 거리와 상관없이 정해야 하는 액터만 플래그로 따로 정합니다. 거리 기준은 <code>NetCullDistanceSquared</code>이고 기본 150 m라서, 멀리서도 보여야 하는 큰 액터는 클래스에서 이 값을 키웁니다. 모든 클라이언트가 거리와 상관없이 알아야 하는 GameState나 지도 마커는 <code>bAlwaysRelevant</code>로 항상 Relevant하게 합니다. 반대로 PlayerController처럼 그 플레이어만 알면 되는 액터는 <code>bOnlyRelevantToOwner</code>로 소유자에게만 보내는데, 그러려면 Owner를 그 플레이어로 지정해 둬야 합니다. 무기처럼 든 폰과 함께 보이고 사라져야 하는 액터는 <code>bNetUseOwnerRelevancy</code>로 소유자의 판정을 따르게 합니다. 같은 팀에게만 보여야 하는 것처럼 플래그로 표현할 수 없는 규칙은 <code>IsNetRelevantFor</code>를 오버라이드하는데, 액터 수 × 연결 수만큼 불리니 가볍게 짭니다. Night of the Dead에서는 Replication Graph가 이 판정을 노드로 대신해서, 지도 마커와 에어드롭은 항상 연관 노드에 두고 장비와 부착물은 소유자의 의존 액터로 묶었습니다.</p></blockquote></section></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/actor-relevancy-in-unreal-engine" target="_blank">Actor Relevancy in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/actor-priority-in-unreal-engine" target="_blank">Actor Priority in Unreal Engine</a></li>
<li><a href="https://www.youtube.com/watch?v=acDHLHQgwwo" target="_blank">Net Relevancy Explained; Actor Relevancy Explained - UE C++ Tutorial, enigma tutorials</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/gamejob-watch-favorites/</id>
      <title type="text">게임잡 공고 알림에 즐겨찾기 추가하기</title>
      <published>2026-11-03T00:00:00.000Z</published>
      <updated>2026-11-03T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/gamejob-watch-favorites/"/>
      <summary type="text">매일 Slack으로 받는 게임잡 공고 목록에 즐겨찾기를 붙인 과정이다. 저장 위치와 표시 규칙, 대충 말한 공고를 에이전트가 찾아 등록하는 역할 분담, 신규 표시를 건드리지 않는 재전송 크론을 정리하고, 같은 날 점검에서 찾은 필터 오탐 수정을 적는다.</summary>
      <content type="html"><![CDATA[<p><a href="/posts/hermes-agent-gamejob-watch/">이전 글</a>에서 만든 게임잡 공고 알림은 매일 오전 9시(KST)에 서울·경기 언리얼·C++ 경력 공고 40여 건을 Slack <code>#job-board</code> 채널로 보낸다. 목록을 며칠 받아 보니 지원을 고민할 공고를 따로 표시해 둘 곳이 필요했다. 매일 같은 목록에서 같은 공고를 다시 찾는 일이 반복됐기 때문이다.</p>
<p>원하는 사용 방식은 정해져 있었다. 회사명이나 제목 일부, 링크 같은 대략적인 설명을 Hermes에 보내면 그 공고를 즐겨찾기에 넣고, 바로 채널에 목록을 다시 보내서 적용된 모습을 보여 주는 것이다.</p>
<section><h2>즐겨찾기를 어디에 두고 어떻게 보여 줄지<a href="#즐겨찾기를-어디에-두고-어떻게-보여-줄지"><span>#</span></a></h2><p>저장 위치는 세 가지를 비교했다.</p><ul>
<li><code>config.json</code>의 watch 안에 <code>favorites</code> 목록을 두는 방식</li>
<li>별도 파일 <code>favorites.json</code>에 두는 방식</li>
<li>Notion이나 Obsidian에 동기화하는 방식</li>
</ul><p>첫 번째는 관리할 파일이 늘지 않지만 검색 조건과 내 기록이 한 파일에 섞인다. 세 번째는 나중에 붙여도 되는 기능이라 원본으로 삼을 이유가 없었다. 별도 파일로 정했다. 즐겨찾기한 공고는 공고 번호(<code>GI_No</code>)만이 아니라 회사, 제목, 경력, 지역, 마감일, 추가한 시각과 메모까지 스냅샷으로 저장한다. 게임잡에서 공고가 내려가도 어떤 공고였는지 남기려는 목적이다.</p><div><div><div><figure><figcaption><span>~/.hermes/data/gamejob-watch/favorites.json (1건)</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"favorites"</span><span>: {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"286506"</span><span>: {</span></div></div><div><div><div>4</div></div><div><span>      </span><span>"id"</span><span>: </span><span>"286506"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"company"</span><span>: </span><span>"㈜매드엔진"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>      </span><span>"title"</span><span>: </span><span>"[매드엔진] [AI 기술 센터] Senior Unreal Engineer"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>      </span><span>"url"</span><span>: </span><span>"https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=286506"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>      </span><span>"career"</span><span>: </span><span>"경력5년↑"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>      </span><span>"location"</span><span>: </span><span>"경기 &gt; 성남시"</span><span>,</span></div></div><div><div><div>10</div></div><div><span>      </span><span>"employ"</span><span>: </span><span>"정규직"</span><span>,</span></div></div><div><div><div>11</div></div><div><span>      </span><span>"deadline_raw"</span><span>: </span><span>"~11/29"</span><span>,</span></div></div><div><div><div>12</div></div><div><span>      </span><span>"added_at"</span><span>: </span><span>"2026-11-03T14:56:31+09:00"</span><span>,</span></div></div><div><div><div>13</div></div><div><span>      </span><span>"note"</span><span>: </span><span>""</span><span>,</span></div></div><div><div><div>14</div></div><div><span>      </span><span>"last_live"</span><span>: </span><span>"2026-11-03"</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>last_live</code>는 마지막으로 게임잡 목록에서 확인된 날짜다. 공고가 목록에 있는 동안은 실행할 때마다 스냅샷을 새 값으로 갱신한다.</p><p>표시 규칙은 다음과 같다.</p><ul>
<li>즐겨찾기 구간 <code>⭐ 즐겨찾기 (N)</code>을 언리얼 구간 위에 둔다. 즐겨찾기한 공고는 언리얼·C++ 구간에서 빠지고, 그 구간의 건수도 그만큼 줄어든다. 같은 공고가 두 번 나오지 않게 하려는 것이다.</li>
<li>즐겨찾기한 공고는 필터를 통과하지 못해도 표시한다. 필터는 후보를 고르는 기준이고, 즐겨찾기는 내가 이미 고른 결과이기 때문이다.</li>
<li>즐겨찾기한 공고에는 신규 표시(🆕)를 붙이지 않는다. 필터 밖의 공고를 즐겨찾기했을 때 가짜 신규가 생기지 않게 하려는 것이다.</li>
<li>목록에서 사라진 공고는 자동으로 지우지 않는다. 스냅샷으로 표시하면서 <code>🔚 게시 종료</code>를 붙이고 구간 맨 아래로 내린다.</li>
<li>메모가 있으면 공고 줄 끝에 <code>· 📝 1차 지원 예정</code>처럼 메모 내용을 붙인다.</li>
</ul><p></p><figure><img alt="⭐ 즐겨찾기 8건을 넣고 재전송한 목록" loading="lazy" width="2800" height="1200" src="/_astro/slack-favorites-8.YU0rQLd6_ZCJvT4.webp" /><figcaption>⭐ 즐겨찾기 8건을 넣고 재전송한 목록</figcaption></figure><p></p><p>8건을 한 번에 넣고 재전송한 목록이다. 같은 회사의 공고는 다른 구간처럼 회사명 아래에 묶인다.</p><p>즐겨찾기가 하나도 없을 때도 구간은 남기고, 등록 방법을 인용 블록 한 줄로 안내한다. 공고 목록과 섞이지 않게 글머리 기호 대신 인용 블록을 썼다.</p><p></p><figure><img alt="즐겨찾기 0건일 때의 안내 문구 (처음 보낸 문구)" loading="lazy" width="2960" height="336" src="/_astro/slack-favorites-empty.CNoK9CFm_Zv3vH7.webp" /><figcaption>즐겨찾기 0건일 때의 안내 문구 (처음 보낸 문구)</figcaption></figure><p></p><p>지금은 예시에 특정 회사 대신 <code>"[회사명] [공고 키워드] 즐겨찾기해줘"</code>라는 자리표시자를 쓴다.</p></section>
<section><h2>대충 말해도 찾는 일은 에이전트가 맡는다<a href="#대충-말해도-찾는-일은-에이전트가-맡는다"><span>#</span></a></h2><p>“크래프톤 팰월드 모바일 전투 쪽 즐겨찾기해줘” 같은 요청을 공고 번호로 바꾸는 일은 스크립트에 넣지 않았다. 이 매칭은 에이전트가 하고, 스크립트는 정확한 공고 번호나 링크만 받는다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>S</span><span>=</span><span>~/.hermes/scripts/gamejob_watch.sh</span></div></div><div><div><div>2</div></div><div><span>$S</span><span> --fav-add 286506 279893 ...           </span><span># 공고 번호나 링크, 여러 건 가능</span></div></div><div><div><div>3</div></div><div><span>$S</span><span> --fav-remove 286506                   </span><span># 해제</span></div></div><div><div><div>4</div></div><div><span>$S</span><span> --fav-list                            </span><span># 목록</span></div></div><div><div><div>5</div></div><div><span>$S</span><span> --fav-note 286506 </span><span>"1차 지원 예정"</span><span>      </span><span># 메모, 빈 문자열이면 삭제</span></div></div><div><div><div>6</div></div><div><span>$S</span><span> --resend                              </span><span># 신규 표시 상태를 바꾸지 않고 다시 출력</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>에이전트는 상세 페이지 캐시(<code>seen.json</code>)와 현재 목록에서 회사명과 제목을 맞춰 본다. 후보가 하나로 좁혀지면 바로 등록하고, 여러 개면 짧은 선택지로 되묻는다. 찾지 못하면 링크를 달라고 한다. 이렇게 나누면 사람 말을 해석하는 부분에만 모델이 쓰이고, 매일 오전 9시의 크론은 이전처럼 LLM 호출 없이 스크립트만 실행한다.</p><p>처음 등록할 때는 채널에 올라온 목록에서 8줄을 그대로 복사해 보냈다. 회사명이 빠진 하위 줄도 섞여 있었지만 줄마다 공고 링크가 들어 있어서 번호를 바로 얻었다. 매칭 판단이 필요한 것은 회사명이나 제목 일부만 보낼 때다.</p></section>
<section><h2>신규 표시를 건드리지 않는 재전송<a href="#신규-표시를-건드리지-않는-재전송"><span>#</span></a></h2><p>즐겨찾기를 넣은 뒤 바로 채널에 다시 보내는 부분에서 걸리는 점이 있었다. 기존 일일 크론을 수동 실행(<code>cronjob run</code>)하면 실제 실행이라서 본 공고 기록(<code>seen.json</code>)과 직전 실행 목록이 갱신된다. 즐겨찾기할 때마다 이렇게 보내면 다음 날 아침의 신규 표시와 “사라진 공고” 집계가 어긋난다.</p><p>그래서 스크립트에 <code>--resend</code> 옵션을 추가했다. 미리보기(<code>--dry-run</code>)처럼 신규 판정 기준인 본 공고 기록과 직전 실행 목록을 바꾸지 않고, 제목 옆에 <code>🔁 재전송</code>을 붙인다. 속도를 위해 이미지 본문 OCR은 건너뛰어서 한 번에 20초 정도 걸린다. 이 옵션을 실행하는 재전송 크론을 따로 등록하고 일시정지해 두었다. 크론 작업에는 일정이 필요해서 일일 크론과 같은 매일 오전 9시로 등록했다.</p><div><figure><figcaption><span>gamejob-watch-resend</span></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"id"</span><span>: </span><span>"4d47295f5480"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"name"</span><span>: </span><span>"gamejob-watch-resend"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"script"</span><span>: </span><span>"gamejob_watch_resend.sh"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"no_agent"</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>"schedule"</span><span>: { </span><span>"kind"</span><span>: </span><span>"cron"</span><span>, </span><span>"expr"</span><span>: </span><span>"0 9 * * *"</span><span> },</span></div></div><div><div><div>7</div></div><div><span>  </span><span>"enabled"</span><span>: </span><span>false</span><span>,</span></div></div><div><div><div>8</div></div><div><span>  </span><span>"state"</span><span>: </span><span>"paused"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>  </span><span>"deliver"</span><span>: </span><span>"slack:C0CE49Y6VKK"</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>gamejob_watch_resend.sh</code>는 일일 크론의 래퍼와 같은 스크립트를 <code>--resend</code>로 실행한다. 스크립트 출력을 채널에 보내는 일은 크론 전송을 거쳐야 해서, 재전송도 크론으로 만들었다.</p><p>일시정지한 크론은 수동 실행도 거부된다. 그래서 재전송할 때는 크론을 재개하고, 실행하고, 바로 다시 일시정지한다. 실행은 백그라운드에서 이어지므로 일시정지해도 전송은 끝난다. 재개한 채로 두면 다음 날 오전 9시에 정식 목록과 재전송 목록이 함께 올라가기 때문에, 이 순서를 스킬 문서에 적어 두었다.</p><p></p><figure><img alt="즐겨찾기 등록과 재전송 흐름" loading="lazy" width="960" height="480" src="/_astro/favorites-flow.Vj4-0kyE_Z1jzNP.svg" /><figcaption>즐겨찾기 등록과 재전송 흐름</figcaption></figure><p></p><p>검증은 실제 데이터를 복사한 폴더에서 먼저 했다. 즐겨찾기 3건(일부러 만든 게시 종료 공고 1건 포함)을 넣었을 때 언리얼 구간이 40건에서 38건으로 줄었고, 게시 종료 공고에는 마감일 대신 <code>🔚 게시 종료</code>가 붙었다. 재전송 전후로 본 공고 기록과 직전 실행 목록은 그대로였다. 정식 실행에서는 게시 종료된 즐겨찾기 공고가 직전 실행 목록(<code>last_ids</code>)에 들어가지 않는 것도 확인했다. 들어가면 다음 실행마다 “사라진 공고”로 잡힌다.</p></section>
<section><h2>점검에서 나온 필터 오탐<a href="#점검에서-나온-필터-오탐"><span>#</span></a></h2><p>즐겨찾기를 붙인 뒤 스크립트와 설정을 한 번 점검했다. 정규식은 대소문자를 구분하지 않고 공고 제목과 본문에 적용되는데, 단어 일부에 걸리거나 범위가 너무 넓은 항목이 나왔다. 경력 조건과 설정 검증도 하나씩 손봤다.</p><p>유니티 판정 정규식의 <code>unity</code>는 <code>community</code>나 <code>opportunity</code> 안에서도 걸렸다. 영어 공고에 흔한 단어라서, 언리얼 공고가 유니티 언급이 더 많다고 판정돼 빠질 수 있었다. 이번 목록에서 이 때문에 빠진 공고는 없었지만 단어 단위로만 맞추게 바꿨다.</p><div><figure><figcaption><span>other_engine_regex</span></figcaption><pre><code><div><div><div>1</div></div><div><span>before: 유니티|unity|cocos|…</span></div></div><div><div><div>2</div></div><div><span>after:  유니티|\bunity(?![a-z])|cocos|…</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>직무 포함 정규식의 <code>엔진</code>은 엔진 프로그래머를 받으려고 넣은 단어인데, 제목에 <code>언리얼엔진</code>이 들어간 아트 공고까지 통과시켰다. 드래곤플라이의 <code>[스페셜포스 리마스터] 언리얼엔진 3D 그래픽 모집</code>이 그런 경우였다. 제목에 <code>아트</code>가 없어서 제외 정규식에도 걸리지 않았다. 앞에 <code>언리얼</code>이 붙은 <code>엔진</code>은 맞추지 않도록 부정형 lookbehind를 넣었다. 블랙스톰의 <code>언리얼5 RPG 게임 프로젝트 신규 개발자 채용 (이펙트)</code>는 <code>개발자</code>로 통과하고 있어서, 제외 정규식에 <code>이펙트</code>를 추가했다.</p><div><figure><figcaption><span>role_include_regex</span></figcaption><pre><code><div><div><div>1</div></div><div><span>before: …|서버|엔진|네트워크|…</span></div></div><div><div><div>2</div></div><div><span>after:  …|서버|(?&lt;!언리얼)(?&lt;!언리얼 )엔진|네트워크|…</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>반대 방향의 오탐도 있었다. 제외 정규식의 <code>보안</code>이 네오플의 <code>최적화/보안 클라이언트 프로그래머</code>를 직무 단계에서 빼고 있었다. 보안 직군만 빠지도록 범위를 좁혔다. 이 공고는 본문에 Unity가 적혀 있어서 고친 뒤에도 엔진 단계에서 빠졌고, 최종 목록은 바뀌지 않았다.</p><div><figure><figcaption><span>role_exclude_regex</span></figcaption><pre><code><div><div><div>1</div></div><div><span>before: …|인프라|보안|강사|…</span></div></div><div><div><div>2</div></div><div><span>after:  …|인프라|보안 ?(?:엔지니어|관제|담당자|운영)|정보보안|강사|…</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>경력 조건에도 하나가 있었다. 하이퍼센트의 <code>[PC 게임] 언리얼 개발자 신입 채용</code>은 게임잡 경력 필드가 <code>경력무관</code>이라 통과하고 있었다. <code>career_min_years</code>가 설정되어 신입을 제외하는 경우에는 제목에 <code>신입</code>만 있고 <code>경력</code>이 없는 공고는 빼도록 했다. <code>서버(신입/경력)</code>처럼 둘 다 적힌 공고는 남는다.</p><p>정규식 변경은 캐시된 상세 페이지에 다시 적용하면 되기 때문에, 바꾸기 전과 후의 결과를 공고 번호로 비교했다. 최종 47건에서 아트·이펙트 공고 2건이 빠져 45건, 신입 공고 1건이 더 빠져 44건이 됐다.</p><p>마지막으로 조건 변경 명령(<code>--set</code>)의 검증 범위를 넓혔다. 이전에는 일부 정규식만 저장 전에 검사해서, 엔진 판정 정규식에 오타가 있으면 그대로 저장되고 다음 날 크론이 오류로 끝날 수 있었다. 지금은 이름이 <code>_regex</code>로 끝나는 항목을 모두 저장 전에 컴파일해 본다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/hermes-agent-gamejob-watch/</id>
      <title type="text">Hermes Agent로 게임잡 공고 확인 자동화하기</title>
      <published>2026-11-02T00:00:00.000Z</published>
      <updated>2026-11-02T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/hermes-agent-gamejob-watch/"/>
      <summary type="text">서울·경기 언리얼 경력 공고를 매일 Slack으로 받기 위해 Hermes Agent와 함께 게임잡 확인을 스크립트와 크론으로 옮긴 과정이다. no_agent 크론 구조, 오분류를 잡아 가며 고친 엔진 판정 기준, Slack 출력 수정과 최종 설정을 정리하고 이어서 하려는 작업을 적는다.</summary>
      <content type="html"><![CDATA[<p>언리얼 클라이언트 개발 공고를 게임잡에서 매일 직접 찾아보는 일을 Hermes Agent에 맡겼다. 지금은 서울·경기 언리얼 경력 공고가 매일 오전 9시(KST)에 Slack <code>#job-board</code> 채널로 도착하고, 이 크론은 실행할 때 LLM을 호출하지 않는다.</p>
<p></p><figure><img alt="게임잡 공고 알림의 전체 흐름" loading="lazy" width="960" height="440" src="/_astro/flow.DOchTSSr_Z1U0Px2.svg" /><figcaption>게임잡 공고 알림의 전체 흐름</figcaption></figure><p></p>
<section><h2>에이전트가 아니라 스크립트로 돌린다<a href="#에이전트가-아니라-스크립트로-돌린다"><span>#</span></a></h2><p>처음 정한 조건은 단순했다. 하루 한 번 오전 9시에 실행하고, 신규 공고만이 아니라 현재 진행 중인 전체 목록을 보여 준다. 새로 올라온 공고에는 신규 표시를 붙인다. 이전 공고를 계속 보여 주지 않으면 놓칠 수 있다는 이유였다. 알림은 DM이 아니라 별도 채널에 받기로 했다.</p><p>실행 방식으로는 두 가지를 비교했다. 에이전트가 매번 수집하고 비교하고 포맷하는 방식과, 이 단계를 스크립트로 고정하고 크론이 스크립트만 실행하는 방식이다. 이 작업은 요청, 파싱, 목록 비교, 포맷팅이 대부분이라 높은 추론이 필요하지 않다. 스크립트는 결과가 매일 같은 규칙으로 나오고 비용이 거의 없다. 대신 사이트 구조가 바뀌거나 차단되면 스크립트가 실패하고, 실패하면 알림이 간다. 스크립트 방식으로 정했다.</p><p></p><figure><img alt="에이전트 실행과 스크립트 실행 구조 비교" loading="lazy" width="960" height="330" src="/_astro/structures.YYHwOCwk_Z1OWsRN.svg" /><figcaption>에이전트 실행과 스크립트 실행 구조 비교</figcaption></figure><p></p><p>크론은 <code>no_agent</code> 모드로 등록했다. 이 모드에서는 스케줄러가 스크립트를 실행하고 stdout을 그대로 채널에 보낸다. 수동으로 실행한 결과에도 <code>API calls: 0</code>과 <code>Mode: no_agent (script)</code>가 찍혔다.</p><p>처음에는 언리얼 전용 스킬 <code>gamejob-unreal-watch</code>로 시작했다. 이후 검색 조건을 직접 정하고 바꿀 수 있게 스킬 이름을 <code>gamejob-watch</code>로 바꾸고, 조건을 <code>config.json</code>의 watch 단위로 분리했다. 지역, 직무, 경력, 엔진 규칙이 모두 이 파일에 있고, 변경은 스크립트 옵션으로 한다.</p></section>
<section><h2>키워드 검색으로는 놓친다<a href="#키워드-검색으로는-놓친다"><span>#</span></a></h2><p>처음 확인할 건 게임잡 검색이었다. 스크립트에서 <code>GET /Recruit/joblist?searchstring=...</code>을 보내면 서버가 키워드를 무시하고 모든 공고를 돌려줬다. 엉터리 검색어를 넣어도 마찬가지였다. 브라우저에서 같은 주소로 검색이 되는 것은 페이지가 목록을 따로 불러오기 때문으로 보인다. 스크립트에서 키워드가 실제로 적용되는 쪽은 <code>POST /Recruit/_GI_Job_List/</code>였다.</p><p>그런데 키워드가 적용돼도 문제가 남았다. 일부 공고에는 <code>언리얼</code>이라는 단어가 없어서 검색에 걸리지 않았다. 키워드에 <code>C++, GAS</code>만 적힌 공고가 그런 경우다. 그래서 키워드 검색을 버리고 서울·경기 공고를 전부 수집한 뒤, 직무와 본문을 읽어 판정하는 구조로 바꿨다. 한 번 실행에 수집하는 공고는 1,000건이 넘는다(최근 실행에서 1,322건).</p></section>
<section><h2>오분류를 추적하며 규칙을 고친 과정<a href="#오분류를-추적하며-규칙을-고친-과정"><span>#</span></a></h2><p>판정 규칙은 Slack에 실제로 보낸 결과를 내가 보고 짚는 대로 고쳤다. 큰 수정은 세 가지였다.</p><p>첫째는 엔진이 적히지 않은 후보를 기기 종류로 좁히려던 시도다. 새 필터로 처음 보낸 전송은 전체 78건 가운데 엔진 미명시 후보가 45건이었고, 개발 PM과 유니티 프로젝트가 섞여 있었다. 금지 목록을 늘리지 않고 들어오는 조건을 좁히기로 했다. 직무 포함 목록에서 PM 관련 단어를 빼고, 엔진 미명시 후보는 게임잡에 PC·콘솔 대상으로 등록된 공고만 받았다. 모바일 프로젝트는 대개 유니티라는 가정이었다. 후보는 23건, 전체는 55건이 됐다.</p><p></p><figure><img alt="18 전송의 엔진 미명시 후보 45건" loading="lazy" width="1560" height="400" src="/_astro/slack-engine-section-first.C3bkQM4C_ZzsnJG.webp" /><figcaption>18 전송의 엔진 미명시 후보 45건</figcaption></figure><p></p><p></p><figure><img alt="19 전송, PC·콘솔로 좁혀 23건" loading="lazy" width="1560" height="400" src="/_astro/slack-engine-section-second.Cxn96eeU_1ETWoi.webp" /><figcaption>19 전송, PC·콘솔로 좁혀 23건</figcaption></figure><p></p><p>그런데 유니티 공고인 니케 공고가 여전히 후보에 있었다. 본문에 엔진 이름이 없었고 등록된 디바이스는 <code>모바일게임, 멀티플랫폼게임</code>이었다. 후보 조건이 PC·콘솔·멀티플랫폼 중 하나만 있어도 통과였는데, 이 공고처럼 <code>멀티플랫폼게임</code>이 모바일 위주 게임에도 붙기 때문에 걸러지지 않았다. 디바이스가 전부 PC나 콘솔일 때만 통과시키자 후보가 17건에서 9건으로 줄었다. 하지만 모바일과 멀티플랫폼 공고도 받아야 하는데, 기기 종류로 유니티를 가려내는 방식은 근거가 약하다고 보고 접었다.</p><p>둘째는 본문을 읽는 위치가 틀렸다는 점이다. 니케 공고를 다시 확인하다가 본문을 읽지 못하고 있었다는 것을 알았다. 실제 담당업무와 자격요건은 View 페이지가 아니라 iframe 두 개(<code>GI_Read_Comt_Ifrm</code>, <code>GI_Read_GI_Comment_Ifrm</code>)에 있는데, 이 iframe을 댓글 영역으로 오해해서 읽지 않았다. 그때까지의 엔진 판정은 제목, 키워드, 모집분야, 디바이스만 보고 한 것이었다. 본문을 읽기 시작하자 “Unity 엔진 … 능숙하신 분” 같은 문장이 잡혔고, 본문에 유니티가 있던 후보는 빠졌다. 반대로 본문에 언리얼이 적혀 있던 공고는 언리얼 구간으로 올라왔다.</p><p>본문을 읽으니 유니티 필수에 언리얼 우대인 공고, <code>Unreal Engine 또는 Unity</code>로 적힌 공고가 언리얼 구간에 들어와 있다는 것도 보였다. 그래서 언리얼 언급이 유니티 언급보다 많을 때만 언리얼 공고로 인정하기로 했다. 이 규칙이 생기면서 니케 공고를 번호로 제외하던 수동 설정을 걷어 냈다.</p><p>셋째는 본문이 이미지 한 장인 공고다. 언리얼 공고인데 빠져 있던 공고(<a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=285987" target="_blank">GI_No=285987</a>)는 <code>Tong_....png</code> 한 장이 본문이었다. 이미지 안에는 Unreal Engine, GAS, Unreal Editor가 적혀 있었지만 텍스트로는 한 글자도 읽을 수 없었다.</p><p></p><figure><img alt="이미지 본문 공고의 모집분야 표 (일부)" loading="lazy" width="920" height="584" src="/_astro/image-only-posting-crop.CeXVfgGp_lhuHG.webp" /><figcaption>이미지 본문 공고의 모집분야 표 (일부)</figcaption></figure><p></p><p>위 이미지는 공고 이미지에서 이 표만 잘라 낸 것이다.</p><p>직무 필터를 통과한 136건 가운데 26건(약 19%)이 이미지 본문이었다. 공고마다 <code>include_ids</code>로 고정하는 방법도 있었지만 이미지 공고가 계속 올라오기 때문에 자동화하는 쪽을 택했다. macOS의 Swift로 Apple Vision의 한국어 OCR을 스크립트에 붙였다. OCR은 로컬에서 실행되므로 모델 호출이 없다. 이미지는 한 번 읽어 캐시에 저장하고, 실행마다 처리하는 장수에 상한을 둬서 크론이 길어지지 않게 했다. 이미지 주소에 한글이 들어 있는 공고에서 OCR이 실패하는 오류도 이때 고쳤다.</p></section>
<section><h2>엔진 판정 기준을 정리하기<a href="#엔진-판정-기준을-정리하기"><span>#</span></a></h2><p>한동안은 엔진 미명시 후보를 PC·콘솔 후보와 모바일·멀티플랫폼 후보 두 구간으로 나눠 보여 줬다. 후보를 둘로 나눌 필요는 없다고 판단해서 하나로 합쳤고, 기기 구분 설정(<code>candidate_platform_regex</code>)은 비워 뒀다. 이어서 구간 이름도 “언리얼 명시”는 “언리얼”, “엔진 미명시”는 “C++“로 바꿨다. 이름만 바꾼 게 아니라 규칙으로 만들었다. 엔진 언급이 없는 공고 중 C++가 언급된 공고만 후보가 된다. 모바일 유니티 프로젝트는 대개 C#이라 이 조건에서 걸러지는 효과도 있다. 후보 7건은 모두 이 조건을 만족해서 목록은 그대로였다.</p><p>최종 기준은 두 구간이다. 언리얼 구간은 본문이나 이미지에 Unreal, UE4/5나 간접 단서가 있고, 그 언급이 다른 엔진보다 많은 공고다. C++ 구간은 엔진 언급이 전혀 없고 C++가 언급되며 직무가 클라이언트·엔진 계열인 공고다. 다른 엔진 언급이 더 많거나 후보 조건에 맞지 않는 공고는 어느 구간에도 들어가지 않는다.</p><p></p><figure><img alt="엔진 판정 순서" loading="lazy" width="960" height="500" src="/_astro/judgement.BTDF5kwq_Z1OzHGx.svg" /><figcaption>엔진 판정 순서</figcaption></figure><p></p><p>간접 단서도 함께 정했다. 처음 필터 안내에는 <code>Unreal·UE4/5·GAS·블루프린트</code>만 적혀 있었는데, 목록을 넓혔다. 이제 GAS, 블루프린트, UMG, Dedicated Server, Niagara, Nanite, <code>UCLASS</code>와 <code>UPROPERTY</code> 같은 매크로, Gameplay Ability System, World Partition, Enhanced Input, Control Rig, MetaSound를 쓴다. Slate, Sequencer, Chaos, Lumen, Mass, PCG, Epic Games, Lyra는 너무 일반적이라 넣지 않았다. Dedicated Server는 유니티 프로젝트에서도 쓰는 말이지만, 언리얼보다 유니티 언급이 많은 공고는 언리얼 구간에 넣지 않는 규칙이 이를 걸러 준다. 이 단서들을 추가했을 때 기존 캐시에서 판정이 바뀐 공고는 없었다.</p><p>필수와 우대를 나눠서 우대에만 언리얼이 있는 공고를 따로 표시하는 방안도 검토했다. 이미지 본문의 OCR 텍스트는 순서를 믿을 수 없어서 파싱 비용에 비해 얻는 게 적다고 보고 하지 않기로 했다. 해당하는 공고는 당시 언리얼 구간 40건 중 1건 정도였다.</p></section>
<section><h2>Slack에서 읽기 편하게 고치기<a href="#slack에서-읽기-편하게-고치기"><span>#</span></a></h2><p>첫 전송 결과에서 바로 고칠 것이 나왔다. 가장 먼저 필터 내용을 항상 최상단에 두기로 했다. 제목도 <code>게임잡 언리얼 개발 공고</code>에서 <code>게임잡 공고</code>로 바꿨다. 조건이 바뀔 때마다 제목이 어긋나므로 제목에는 조건을 넣지 않고 필터 블록에 적는다. 한 줄로 길게 이어지던 필터는 항목별 목록으로 나눴다.</p><p></p><figure><img alt="최종 전송의 상단" loading="lazy" width="1560" height="880" src="/_astro/slack-final-header.CgOsnqdU_18govw.webp" /><figcaption>최종 전송의 상단</figcaption></figure><p></p><p>들여쓰기는 여러 번 고쳤다. 같은 회사의 공고가 여러 개면 회사명 아래에 하위 항목으로 묶어 보여 주는데, 처음에는 한 회사에서 회사명 줄의 글머리 기호가 사라졌다. 여러 공고가 있는 회사의 이름 앞에 <code>•</code>를 붙이고 아래 항목을 들여써서 고쳤다.</p><p>다음 문제는 메시지가 길어서 나뉘는 지점에서 하위 항목의 들여쓰기가 사라지는 것이었다.</p><p></p><figure><img alt="나뉜 지점에서 들여쓰기를 잃은 하위 공고" loading="lazy" width="1560" height="430" src="/_astro/slack-split-indent-break.gPuYcgVd_ZGhVKQ.webp" /><figcaption>나뉜 지점에서 들여쓰기를 잃은 하위 공고</figcaption></figure><p></p><p>소스를 확인해 보니 Slack 어댑터가 긴 메시지를 나눈 뒤 다음 조각을 <code>lstrip()</code>한다. 새 조각의 첫 줄 앞 공백이 지워지는 이유다. 처음에는 일반 공백 대신 공백으로 취급되지 않는 점자 빈 칸(U+2800)으로 들여써서 막았다. 하지만 회사명 줄과 하위 공고가 서로 다른 조각으로 갈라지는 문제는 그대로였다. 그래서 나뉠 것 같으면 다음 블록에서 시작하게 바꿨다. 회사 묶음은 쪼개지 않고, 블록이 2,400자 가까이 되면 다음 묶음부터 새 블록에서 시작한다. 이렇게 하면 블록이 하위 항목으로 시작하는 일이 없다. 이때 점자 빈 칸은 걷어 내고 일반 공백 들여쓰기로 되돌렸다.</p><p>그런데 블록 중간에 있는 묶음에서도 공백 들여쓰기가 사라지는 경우가 나왔다. 블록 경계만의 문제가 아니었다. 그래서 공백 들여쓰기를 포기하고 하위 항목의 줄 머리를 점자 빈 칸 두 개와 <code>↳</code>로 바꿨다. 공백이 사라져도 <code>↳</code>만으로 하위 항목임을 알 수 있다. 블록을 나누는 방식은 그대로 두었다.</p><p>같은 확인 과정에서 Hermes의 Slack 어댑터가 <code>*텍스트*</code>를 기울임으로 바꾼다는 것도 알게 됐다. 굵게 하려던 회사명이 기울임으로 나오고 있어서 굵은 글씨는 <code>**텍스트**</code>로 쓴다.</p><p>마지막으로 메시지 맨 아래에 실행 정보를 붙였다. 소요 시간, 수집에서 최종까지의 건수, 직전 실행 대비 신규와 사라진 공고 수, OCR 처리 현황을 보여 준다. 소요 시간은 스크립트 실행 시간만 잰다.</p><p></p><figure><img alt="최종 전송 하단의 실행 정보" loading="lazy" width="1560" height="340" src="/_astro/slack-final-footer.n8ybZwHi_29m8a.webp" /><figcaption>최종 전송 하단의 실행 정보</figcaption></figure><p></p></section>
<section><h2>최종 구성<a href="#최종-구성"><span>#</span></a></h2><p>스킬 <code>gamejob-watch</code>는 스크립트, OCR 도우미, 설명 문서로 이루어진다. 조건과 상태는 스킬 밖에 둬서 스킬을 수정해도 남는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>~/.hermes/skills/productivity/gamejob-watch/</span></div></div><div><div><div>2</div></div><div><span>├── SKILL.md              # 조건 변경 절차, 필터 원칙, 함정 기록</span></div></div><div><div><div>3</div></div><div><span>└── scripts/</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>├── gamejob_watch.py  # 수집·판정·포맷 (표준 라이브러리만 사용)</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>└── ocr.swift         # Apple Vision OCR (필요할 때 컴파일)</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>~/.hermes/scripts/gamejob_watch.sh        # 크론이 실행하는 래퍼</span></div></div><div><div><div>8</div></div><div><span>~/.hermes/data/gamejob-watch/config.json  # 조건</span></div></div><div><div><div>9</div></div><div><span>~/.hermes/data/gamejob-watch/seen.json    # 본 공고 기록, 상세 페이지 캐시</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>크론은 래퍼 스크립트 하나를 실행한다.</p><div><figure><figcaption><span>~/.hermes/scripts/gamejob_watch.sh</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#!/bin/bash</span></div></div><div><div><div>2</div></div><div><span>exec</span><span> </span><span>/usr/bin/python3</span><span> </span><span>-I</span><span> </span><span>"</span><span>$HOME</span><span>/.hermes/skills/productivity/gamejob-watch/scripts/gamejob_watch.py"</span><span> </span><span>"</span><span>$@</span><span>"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>config.json</code>은 watch 이름 아래에 조건을 둔다. 정규식이 길어서 일부는 <code>…</code>로 줄였다.</p><div><div><div><figure><figcaption><span>~/.hermes/data/gamejob-watch/config.json (일부)</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"watches"</span><span>: {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"unreal-dev"</span><span>: {</span></div></div><div><div><div>4</div></div><div><span>      </span><span>"title"</span><span>: </span><span>"게임잡 공고"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"local_codes"</span><span>: [</span><span>"I000"</span><span>, </span><span>"B000"</span><span>],</span></div></div><div><div><div>6</div></div><div><span>      </span><span>"location_regex"</span><span>: </span><span>"서울|경기"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>      </span><span>"role_include_regex"</span><span>: </span><span>"프로그래|개발자|엔지니어|클라이언트|서버|엔진|…"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>      </span><span>"role_exclude_regex"</span><span>: </span><span>"아트|</span><span>\\</span><span>bTA</span><span>\\</span><span>b|테크니컬|…|QA|기획|…"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>      </span><span>"candidate_role_regex"</span><span>: </span><span>"클라이언트|클라</span><span>\\</span><span>b|게임플레이|엔진|…"</span><span>,</span></div></div><div><div><div>10</div></div><div><span>      </span><span>"engine_regex"</span><span>: </span><span>"언리얼|unreal|</span><span>\\</span><span>bUE[45]</span><span>\\</span><span>b|</span><span>\\</span><span>bGAS</span><span>\\</span><span>b|블루프린트|…"</span><span>,</span></div></div><div><div><div>11</div></div><div><span>      </span><span>"other_engine_regex"</span><span>: </span><span>"유니티|unity|cocos|코코스|godot|자체 ?엔진|in-?house engine"</span><span>,</span></div></div><div><div><div>12</div></div><div><span>      </span><span>"candidate_require_regex"</span><span>: </span><span>"C</span><span>\\</span><span>+</span><span>\\</span><span>+"</span><span>,</span></div></div><div><div><div>13</div></div><div><span>      </span><span>"career_min_years"</span><span>: </span><span>1</span><span>,</span></div></div><div><div><div>14</div></div><div><span>      </span><span>"career_max_years"</span><span>: </span><span>7</span><span>,</span></div></div><div><div><div>15</div></div><div><span>      </span><span>"career_any_ok"</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>16</div></div><div><span>      </span><span>"imminent_days"</span><span>: </span><span>3</span><span>,</span></div></div><div><div><div>17</div></div><div><span>      </span><span>"baseline_first_run"</span><span>: </span><span>true</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>20</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>경력 조건은 요구 경력이 1년에서 7년인 공고와 경력무관만 받고 신입은 제외하는 설정이다. 경력이 7–8년 수준이라 요구 경력의 하한이 7년 이하인 공고만 보기로 했다. 필터는 제외 목록을 늘리는 대신 포함 조건으로 정하는 것이 원칙이다. 직무는 제목이 포함 정규식에 맞아야 하고, <code>role_exclude_regex</code>는 남은 것을 정리하는 용도로만 쓴다. PM 직군은 포함 정규식에 PM 관련 단어를 넣지 않아서 빠지고, 유니티 프로젝트는 본문의 엔진 언급을 비교해서 걸러진다.</p><p>조건은 스크립트 옵션으로 바꾼다. 변경한 뒤에는 <code>--dry-run</code>으로 결과를 확인한다. 이 옵션은 본 공고 기록을 갱신하지 않는다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>S</span><span>=</span><span>~/.hermes/scripts/gamejob_watch.sh</span></div></div><div><div><div>2</div></div><div><span>$S</span><span> --show                                  </span><span># watch 목록과 설정</span></div></div><div><div><div>3</div></div><div><span>$S</span><span> --set unreal-dev career_max_years 7     </span><span># 값은 JSON 또는 문자열</span></div></div><div><div><div>4</div></div><div><span>$S</span><span> --dry-run --watch unreal-dev            </span><span># 미리보기</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>크론 작업은 <code>no_agent</code>로 등록되어 있다. 아래는 저장된 작업 정의에서 실행에 쓰이는 항목이다.</p><div><figure><figcaption><span>gamejob-watch-daily</span></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"id"</span><span>: </span><span>"b46eb024f9ff"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"name"</span><span>: </span><span>"gamejob-watch-daily"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"script"</span><span>: </span><span>"gamejob_watch.sh"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"no_agent"</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>"schedule"</span><span>: { </span><span>"kind"</span><span>: </span><span>"cron"</span><span>, </span><span>"expr"</span><span>: </span><span>"0 9 * * *"</span><span> },</span></div></div><div><div><div>7</div></div><div><span>  </span><span>"deliver"</span><span>: </span><span>"slack:C0CE49Y6VKK"</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>스크립트가 0이 아닌 코드로 끝나면 오류 알림이 간다. 채널 메시지 맨 위의 <code>Cronjob Response</code>와 <code>job_id</code>는 크론 전송이 붙이는 머리글이다.</p><p><code>cronjob run</code>으로 수동 실행하면 실제 전송이고 본 공고 기록도 바뀐다. 필터를 크게 바꾼 뒤에는 신규 표시가 한꺼번에 붙을 수 있으므로, 채널에 보내지 않고 기준만 갱신할 때는 래퍼를 직접 실행한다.</p></section>
<section><h2>다음에 하려는 일<a href="#다음에-하려는-일"><span>#</span></a></h2><p>지금 이 파이프라인은 조건에 맞는 후보를 모아서 보여 주는 데까지만 한다. 그 뒤는 두 가지로 이어 갈 계획이다.</p><ul>
<li>후보 추천도: 모은 후보를 세컨드 브레인(이력, 경력, 포트폴리오 같은 내 자료를 정리해 둔 곳)의 기준 자료와 비교해서 공고마다 추천도를 붙인다.</li>
<li>지원서 사전 준비: 내가 직접 고른 일부 후보에 한해서 에이전트가 다룰 수 있는 브라우저인 ego-browser로 지원 양식을 확보하고, 세컨드 브레인 자료를 바탕으로 그 양식에 맞춰 지원서를 미리 준비한다.</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/firefly-upstream-sync-6-16-8-cd920b0/</id>
      <title type="text">Firefly 업스트림 반영: 6.16.8 (cd920b035)</title>
      <published>2026-10-22T00:00:00.000Z</published>
      <updated>2026-10-22T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/firefly-upstream-sync-6-16-8-cd920b0/"/>
      <summary type="text">Firefly 6.16.8 이후 cd920b035까지의 GitHub Actions Node.js 24 전환, Dependabot 설정과 KaTeX 등 의존성 업데이트.</summary>
      <content type="html"><![CDATA[<p>Firefly 6.16.8 이후 <code>cd920b035</code>까지의 업스트림 변경을 반영했다. <a href="/posts/firefly-upstream-sync-6-16-8/">지난 반영</a> 기준인 <code>6d82554bf</code> 이후 2개 커밋이며, 테마 버전은 6.16.8 그대로다. 화면이나 설정 변경은 없고 CI 워크플로와 의존성만 바뀌었다.</p>
<section><h2>GitHub Actions<a href="#github-actions"><span>#</span></a></h2><p>GitHub Actions 러너에서 Node.js 20 기반 액션이 deprecated되면서 워크플로마다 경고가 나오던 것을 각 액션의 Node.js 24 릴리스로 올렸다. 이 변경은 직접 업스트림에 보낸 <a href="https://github.com/CuteLeaf/Firefly/pull/676" target="_blank">CuteLeaf/Firefly#676</a>이다.</p>

<table><thead><tr><th>액션</th><th>이전</th><th>이후</th></tr></thead><tbody><tr><td><code>actions/checkout</code></td><td>v4 / v4.2.2</td><td>v7 / v7.0.1</td></tr><tr><td><code>actions/setup-node</code></td><td>v4 / v4.3.0</td><td>v7 / v7.1.0</td></tr><tr><td><code>pnpm/action-setup</code></td><td>v4 / v4.1.0</td><td>v6 / v6.1.0</td></tr><tr><td><code>biomejs/setup-biome</code></td><td>v2.5.0</td><td>v2.7.1</td></tr><tr><td><code>actions/upload-pages-artifact</code></td><td>v3</td><td>v5</td></tr><tr><td><code>actions/deploy-pages</code></td><td>v4</td><td>v5</td></tr></tbody></table><ul>
<li>SHA로 고정된 액션은 SHA와 버전 주석을 함께 갱신하고, 태그로 참조하던 액션은 태그를 유지했다.</li>
<li><code>upload-pages-artifact</code>는 v4부터 숨김 파일을 기본으로 제외한다. <code>deploy.yml</code>에 <code>include-hidden-files: true</code>를 추가해 <code>dist/.nojekyll</code>이 계속 업로드되도록 했다.</li>
<li><code>dependabot.yml</code>에 주 단위로 액션 업데이트를 묶어 올리는 <code>github-actions</code> 항목을 추가했다. 기존 npm 항목은 그대로다.</li>
</ul><p>이 블로그도 GitHub Pages 배포에 같은 <code>deploy.yml</code>을 사용한다.</p></section>
<section><h2>의존성<a href="#의존성"><span>#</span></a></h2><ul>
<li><code>katex</code>: 0.18.7 → 0.19.0. 폰트에 문자 메트릭이 없는 경우를 <code>strict</code> 옵션으로 보고하도록 바뀌었고(<code>symbolNotInFont</code>), <code>strict</code> 콜백이 <code>undefined</code>나 <code>null</code>을 반환하면 무시하지 않고 경고한다.</li>
<li><code>marked</code>: 18.0.12 → 18.1.0</li>
<li><code>sanitize-html</code>: 2.17.7 → 2.18.0, <code>@types/sanitize-html</code>: 2.16.1 → 2.16.2</li>
<li><code>takumi-js</code>: 2.13.7 → 2.14.0</li>
<li><code>wrangler</code>: 4.127.1 → 4.147.0</li>
<li><code>subset-font</code>: 2.7.0 → 2.9.0</li>
</ul></section>
<section><h2>검증<a href="#검증"><span>#</span></a></h2><ul>
<li><code>pnpm check</code>: 265개 파일, 오류·경고·힌트 0개.</li>
<li><code>pnpm type-check</code>, <code>pnpm build</code>: 통과. 정적 페이지 119개를 생성하고 Pagefind에 91개 페이지를 인덱싱했다.</li>
<li>수식이 들어간 <a href="/posts/firefly-blog-guide/">블로그 가이드</a>가 KaTeX 0.19.0에서 <code>katex-error</code> 없이 렌더링되는 것을 확인했다.</li>
</ul><p><a href="https://github.com/CuteLeaf/Firefly/compare/6d82554bfe1cb3d4b43adb0969dad1d43ac6dee3...cd920b035b475fe9a6cd3a88afb42cf9440bb8a9" target="_blank">업스트림 변경 비교</a></p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-waking-dormant-actors/</id>
      <title type="text">휴면 액터 깨우기: FlushNetDormancy, ForceNetUpdate, SetNetDormancy</title>
      <published>2026-10-22T00:00:00.000Z</published>
      <updated>2026-10-22T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-waking-dormant-actors/"/>
      <summary type="text">UE 5.8.3 엔진 소스 기준으로 FlushNetDormancy, ForceNetUpdate, SetNetDormancy가 각각 무엇을 바꾸는지 비교한다. 휴면 정책과 휴면 상태의 차이, ForceNetUpdate가 덤으로 하는 flush가 빠지는 조건, 값을 바꾼 뒤 깨우면 FastArray 변경이 사라지는 문제, 이를 막는 setter 패턴과 Push Model을 함께 쓸 때의 주의점을 정리한다.</summary>
      <content type="html"><![CDATA[<p>휴면(dormant) 상태인 액터의 replicated property를 바꾸면 그 변경은 클라이언트로 가지 않는다. 휴면을 다룰 때는 두 가지를 구분해야 한다.</p>
<ul>
<li>휴면 정책: 액터의 <code>NetDormancy</code> 값. <code>DORM_Awake</code> 이하면 깨어 있고, 그보다 크면(<code>DORM_DormantAll</code> 등) 휴면하려 한다.</li>
<li>휴면 상태: 연결마다 지금 실제로 휴면 중인지. 휴면 중인 연결에는 복제 검토(NetDriver가 보낼 액터를 고르고 값을 비교하는 단계)도 하지 않는다.</li>
</ul>
<p>이 글에서 액터를 깨운다는 것은 휴면 상태를 푸는 것을 말한다. 휴면 정책을 그대로 둔 채 깨우면, 액터는 변경을 보낸 뒤 다시 휴면한다. <code>FlushNetDormancy</code>는 휴면 상태를, <code>SetNetDormancy</code>는 휴면 정책을 바꾼다. <code>ForceNetUpdate</code>는 복제 시점을 바꾸고, 휴면 중이면 조건부로 flush도 덤으로 한다.</p>
<section><h2>세 함수 비교<a href="#세-함수-비교"><span>#</span></a></h2>

<table><thead><tr><th></th><th><code>FlushNetDormancy</code></th><th><code>ForceNetUpdate</code></th><th><code>SetNetDormancy</code></th></tr></thead><tbody><tr><td>바꾸는 것</td><td>휴면 상태</td><td>복제 시점</td><td>휴면 정책</td></tr><tr><td>휴면 정책</td><td>유지</td><td>유지</td><td>지정한 값</td></tr><tr><td>휴면 상태</td><td>잠깐 깨운다</td><td>휴면 중이면 덤으로 flush(조건부)</td><td><code>DORM_Awake</code> 이하면 깨워 둔다</td></tr><tr><td>다시 휴면하는 때</td><td>다음 검토와 ack를 거친 뒤</td><td>flush했다면 다음 검토와 ack를 거친 뒤</td><td><code>DORM_DormantAll</code>로 되돌린 뒤</td></tr><tr><td>복제 시점</td><td>앞당기지 않는다(Iris 제외)</td><td>다음 복제 검토에 바로 넣는다</td><td>앞당기지 않는다</td></tr><tr><td><code>DORM_Initial</code> 액터</td><td><code>DORM_DormantAll</code>로 바뀐다</td><td>게임 NetDriver가 있을 때만 바뀐다</td><td>지정한 값</td></tr><tr><td>클라이언트에서 호출</td><td>무시</td><td>게임 NetDriver에는 무시</td><td>무시</td></tr><tr><td>Blueprint</td><td><code>BlueprintAuthorityOnly</code></td><td><code>BlueprintCallable</code></td><td><code>BlueprintAuthorityOnly</code></td></tr></tbody></table></section>
<section><h2>각 함수가 하는 일<a href="#각-함수가-하는-일"><span>#</span></a></h2><section><h3><code>FlushNetDormancy</code><a href="#flushnetdormancy"><span>#</span></a></h3><p>휴면 정책은 그대로 두고 휴면 상태만 잠깐 푼다. 휴면을 끄는 것이 아니라, 바뀐 값을 보내는 동안만 깨우는 것이다. 정책이 여전히 휴면이므로 액터는 변경을 보낸 뒤 스스로 다시 휴면한다. 계속 깨워 두려면 정책 자체를 바꾸는 <code>SetNetDormancy</code>를 쓴다.</p><p><code>DORM_Initial</code>이면 <code>DORM_DormantAll</code>로 바꾸는데, 둘 다 휴면하는 값이라 휴면은 이어진다. 이 변경은 게임 NetDriver가 없어도 일어난다. 그다음 액터를 복제하는 모든 NetDriver에서 휴면 상태를 푼다.</p><p>레거시 복제와 Replication Graph는 휴면 중이던 연결마다 replicator를 현재 상태로 다시 만든다(<code>Engine/Private/NetConnection.cpp</code>). 복제 시점은 건드리지 않고, 연결마다 다음 과정을 거쳐 다시 휴면한다.</p><ol>
<li>다음 복제 검토에서 flush 뒤에 바뀐 값을 보낸다.</li>
<li>그다음 검토에서 다시 비교해 바뀐 값이 없음을 확인한다.</li>
<li>1에서 보낸 것이 ack되면 Actor channel을 닫고 휴면한다.</li>
</ol><p>데이터가 나가는 것은 보통 한 번이다. 기본 설정에서 다시 휴면하는 시점은 두 번째 검토와 ack 중 늦은 쪽이고, 그 사이 값이 또 바뀌면 검토마다 보내고 다시 기다린다. Iris는 channel을 닫지 않는다. flush하면 다음 업데이트에 한 번 폴링한 뒤 바로 폴링 대상에서 빠진다.</p></section><section><h3><code>ForceNetUpdate</code><a href="#forcenetupdate"><span>#</span></a></h3><p>복제 시점을 다루는 함수다. <code>NetUpdateFrequency</code> 주기를 기다리지 않고 다음 복제 검토에 넣는다. Replication Graph는 우선순위도 올리지만 레거시 복제는 올리지 않는다.</p><p>보장하는 것은 검토 시점뿐이다. 관련성 검사, 대역폭 포화, 프로퍼티 비교는 그대로 거치고, Push Model의 마킹을 대신하지 않는다.</p><p>깨우는 것은 덤이다. 휴면 액터면 <code>FlushNetDormancy</code>도 부르는데, 휴면 중인 연결은 복제 검토에서 빠져 시점만 앞당겨서는 보낼 수 없기 때문으로 보인다. 이 flush에는 조건이 있다(아래 자주 하는 실수).</p></section><section><h3><code>SetNetDormancy</code><a href="#setnetdormancy"><span>#</span></a></h3><p>휴면 정책 자체를 바꾼다. <code>DORM_Awake</code> 이하로 바꾸면 각 NetDriver에서 휴면 상태도 푼다. 정책이 깨어 있으므로 다시 휴면하지 않고, 따로 <code>FlushNetDormancy</code>를 부를 필요도 없다. <code>DORM_DormantAll</code>로 되돌리면 보낸 것이 모두 ack됐고 바뀐 값이 없을 때 다음 검토 직후 휴면한다.</p></section></section>
<section><h2>언제 무엇을 부르나<a href="#언제-무엇을-부르나"><span>#</span></a></h2>

<table><thead><tr><th>상황</th><th>호출</th><th>그 뒤 액터</th></tr></thead><tbody><tr><td>휴면 액터의 값을 가끔 바꾼다</td><td>값을 바꾸기 전에 <code>FlushNetDormancy</code></td><td>보낸 뒤 다시 휴면</td></tr><tr><td>그 변경을 다음 복제 주기보다 빨리 보내야 한다</td><td><code>FlushNetDormancy</code> → 값 변경 → <code>ForceNetUpdate</code></td><td>보낸 뒤 다시 휴면</td></tr><tr><td>깨어 있는 액터의 변경을 빨리 보내야 한다</td><td>값 변경과 함께 <code>ForceNetUpdate</code></td><td>깨어 있음</td></tr><tr><td>한동안 값이 자주 바뀐다</td><td>시작할 때 <code>SetNetDormancy(DORM_Awake)</code>, 끝나면 <code>SetNetDormancy(DORM_DormantAll)</code></td><td>끝낼 때까지 깨어 있음</td></tr></tbody></table><p><code>ForceNetUpdate</code>의 flush는 조건부 덤이므로, 휴면 액터를 깨우는 일은 <code>FlushNetDormancy</code>에 맡긴다. 둘을 함께 쓰면 flush가 두 번 일어나지만, 두 번째 flush는 replicator를 다시 만들지 않아 비용이 거의 없다.</p></section>
<section><h2>자주 하는 실수<a href="#자주-하는-실수"><span>#</span></a></h2><section><h3><code>ForceNetUpdate</code>가 flush하지 않는 경우<a href="#forcenetupdate가-flush하지-않는-경우"><span>#</span></a></h3><p><code>ForceNetUpdate</code>는 authority이고, 게임 NetDriver가 있고, 그 NetDriver가 서버일 때만 덤으로 flush한다.</p><div><div><div><figure><figcaption><span>Engine/Private/Actor.cpp</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AActor</span><span>::</span></span><span>ForceNetUpdate</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>UNetDriver</span><span>*</span><span> </span><span>NetDriver</span><span> </span><span>=</span><span> </span><span>GetNetDriver</span><span>();</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>GetLocalRole</span><span>() </span><span>==</span><span> </span><span>ROLE_Authority</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>if</span><span> (</span><span>NetDriver</span><span> </span><span>&amp;&amp;</span><span> </span><span>NetDriver</span><span>-&gt;</span><span>GetNetMode</span><span>() </span><span>&lt;</span><span> ENetMode::</span><span>NM_Client</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>      </span><span>NetDriver</span><span>-&gt;</span><span>ForceNetUpdate</span><span>(</span><span>this</span><span>);</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>      </span><span>if</span><span> (</span><span>NetDormancy</span><span> </span><span>&gt;</span><span> </span><span>DORM_Awake</span><span>)</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>13</div></div><div><span>        </span><span>FlushNetDormancy</span><span>();</span></div></div><div><div><div>14</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>// (생략) 게임 NetDriver가 아닌 NetDriver에는 ForceNetUpdate만 전달한다.</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>데디케이티드 서버와 맵을 리슨 서버로 연 경우는 문제가 없다. <code>UEngine::LoadMap</code>이 <code>Listen</code>을 액터 초기화보다 먼저 호출하기 때문이다.</p><p>문제는 게임 NetDriver가 없는 월드다. 혼자 플레이하던 호스트가 맵을 다시 열지 않고 <code>UWorld::Listen</code>으로 친구를 받는 협동 게임에서, 맵에 배치된 문(<code>DORM_Initial</code>)을 연다고 하자.</p><p><img alt="" loading="lazy" width="960" height="620" src="/_astro/forcenetupdate-before-listen.D2F8Z4T3_16nief.svg" /></p><p><code>Listen</code> 전에는 게임 NetDriver가 없어 <code>ForceNetUpdate</code>가 아무것도 하지 않는다. 결과가 갈리는 것은 <code>DORM_Initial</code>과 <code>DORM_DormantAll</code>이 둘 다 휴면이지만 새로 접속한 연결을 다르게 다루기 때문이다.</p>

<table><thead><tr><th></th><th><code>DORM_Initial</code></th><th><code>DORM_DormantAll</code></th></tr></thead><tbody><tr><td>휴면인 연결</td><td>처음부터 모든 연결. 나중에 접속한 연결도 포함</td><td>그 연결에 한 번 복제한 뒤부터</td></tr><tr><td>새 연결이 받는 값</td><td>없음. 레벨 패키지의 기본값을 쓴다</td><td>접속 시점의 서버 값</td></tr></tbody></table><p><code>DORM_Initial</code> 배치 액터는 클라이언트도 레벨에서 이미 갖고 있다고 보고 복제 검토에서 아예 뺀다. 그래서 친구는 레벨에 저장된 닫힌 문을 보고, 다음 flush 전까지 상태가 어긋난다.</p><p>Standalone에서 리플레이를 녹화할 때도 같은 분기를 탄다.</p><p><code>FlushNetDormancy</code>는 게임 NetDriver가 없어도 <code>DORM_Initial</code>을 <code>DORM_DormantAll</code>로 바꾸고, 데모 NetDriver를 포함해 액터를 복제하는 모든 NetDriver에서 휴면 상태를 푼다.</p></section><section><h3>값을 바꾼 뒤 깨우면 FastArray 변경이 사라진다<a href="#값을-바꾼-뒤-깨우면-fastarray-변경이-사라진다"><span>#</span></a></h3><p>레거시 복제와 Replication Graph 기준이다. 휴면 액터의 FastArray에 항목을 추가한 뒤 깨우면 그 항목은 클라이언트로 가지 않는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AChestActor</span><span>::</span></span><span>AddItem</span><span>(</span><span>const</span><span> </span><span>FChestItem</span><span>&amp;</span><span><span> </span><span>NewItem</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>FChestItem</span><span>&amp;</span><span> </span><span>Item</span><span> </span><span>=</span><span> </span><span>Items</span><span>.</span><span>Items</span><span>.</span><span>Add_GetRef</span><span>(</span><span>NewItem</span><span>);</span></div></div><div><div><div>4</div></div><div><span>  </span><span>Items</span><span>.</span><span>MarkItemDirty</span><span>(</span><span>Item</span><span>);</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>// 휴면 중이었다면 위 항목은 전달되지 않는다.</span></div></div><div><div><div>7</div></div><div><span>  </span><span>ForceNetUpdate</span><span>();</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>결과는 서버와 클라이언트의 배열 불일치다. 이후 같은 순서로 추가한 항목도 전달되지 않고, Push Model로 등록하고 마킹한 FastArray도 같다.</p><p><code>ForceNetUpdate</code>가 덤으로 부르는 <code>FlushNetDormancy</code>도 값을 바꾼 뒤라 “변경 → flush” 순서가 된다. 레거시 복제와 Replication Graph는 기본 설정에서 flush할 때 휴면 중이던 연결마다 replicator를 현재 상태를 기준선으로 다시 만든다. 그래서 flush 전에 추가한 항목은 이미 보낸 것으로 취급되는 것으로 보인다.</p><p>일반 프로퍼티는 값을 바꾼 뒤 flush해도 지금은 전달되지만, Epic은 이를 기대면 안 되는 구현 세부로 본다(<a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-network-dormancy-in-unreal-engine" target="_blank">Actor Network Dormancy</a>).</p><p>값을 바꾸기 전에 깨우면 된다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AChestActor</span><span>::</span></span><span>AddItem</span><span>(</span><span>const</span><span> </span><span>FChestItem</span><span>&amp;</span><span><span> </span><span>NewItem</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>FlushNetDormancy</span><span>();</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>FChestItem</span><span>&amp;</span><span> </span><span>Item</span><span> </span><span>=</span><span> </span><span>Items</span><span>.</span><span>Items</span><span>.</span><span>Add_GetRef</span><span>(</span><span>NewItem</span><span>);</span></div></div><div><div><div>6</div></div><div><span>  </span><span>Items</span><span>.</span><span>MarkItemDirty</span><span>(</span><span>Item</span><span>);</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 뒤에 <code>ForceNetUpdate</code>를 더해도 변경은 그대로 전달된다.</p></section></section>
<section><h2>실수를 막는 방법<a href="#실수를-막는-방법"><span>#</span></a></h2><section><h3>Setter 패턴<a href="#setter-패턴"><span>#</span></a></h3><p>위 두 문제는 휴면할 수 있는 액터의 replicated property를 setter로만 바꾸면 막을 수 있다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>ADoorActor</span><span>::</span></span><span>SetOpen</span><span>(</span><span>bool</span><span><span> </span><span>bNewOpen</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>check</span><span>(</span><span>HasAuthority</span><span>());</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>bOpen</span><span> </span><span>==</span><span> </span><span>bNewOpen</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>  </span><span>FlushNetDormancy</span><span>();</span></div></div><div><div><div>11</div></div><div><span>  </span><span>bOpen</span><span> </span><span>=</span><span> </span><span>bNewOpen</span><span>;</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>// 바로 보내야 하는 변경일 때만</span></div></div><div><div><div>14</div></div><div><span>  </span><span>ForceNetUpdate</span><span>();</span></div></div><div><div><div>15</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><ul>
<li>같은 값이면 반환한다. 값이 그대로여도 flush는 휴면 중이던 연결을 깨우고 replicator를 다시 만든다. 값이 한동안 자주 바뀌면 변경마다 flush하지 말고 <code>SetNetDormancy</code>로 깨워 둔다.</li>
<li><code>FlushNetDormancy</code>를 직접, 값을 바꾸기 전에 부른다. 게임 NetDriver가 없는 월드와 FastArray 순서 문제를 함께 막는다.</li>
<li>프로퍼티를 <code>private</code>로 두고 setter만 공개한다.</li>
</ul></section><section><h3>Push Model과 함께 쓸 때<a href="#push-model과-함께-쓸-때"><span>#</span></a></h3><p>마킹도 setter 안에서 값을 바꾼 직후에 한다. 마킹하지 않은 Push Model 프로퍼티는 flush해도 전달되지 않는다. 반대로 마킹은 dirty 표시일 뿐 액터를 깨우지 않으므로, 마킹했더라도 flush는 그대로 필요하다.</p><p>Push Model은 값이 그대로인 flush의 비용을 줄여 주지 않는다. 건너뛰는 것은 dirty가 아닌 프로퍼티의 비교뿐이고, 깨우고 다시 재우는 비용은 setter의 early-return으로 막는다. 이 패턴에서 Push Model의 역할은 비용보다 강제에 가깝다. 마킹 없이는 변경이 전달되지 않으니 값을 바꾸는 경로가 setter로 모이고, 그 setter가 flush 순서까지 고정한다.</p></section></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-network-dormancy-in-unreal-engine" target="_blank">Epic Games: Actor Network Dormancy</a></li>
<li><a href="/posts/unreal-engine-push-model-networking/">Unreal Engine - Push Model Networking</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-skills-for-claude-code/</id>
      <title type="text">[노트] Unreal Engine Skills for Claude Code</title>
      <published>2026-10-20T00:00:00.000Z</published>
      <updated>2026-10-20T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-skills-for-claude-code/"/>
      <summary type="text">Epic Games가 공개한 Claude Code 플러그인 Unreal Engine Skills for Claude Code의 스킬 3개와 SessionStart 훅을 읽고, 스킬의 트리거 설계와 문서화 규칙, 엔진 쪽 Agent Skill, 보안 경고의 범위, MCP와 스킬의 역할 분담, Claude Code용만 나온 점에 대한 생각을 정리한다.</summary>
      <content type="html"><![CDATA[<p>Epic Games의 Claude Code용 플러그인 <a href="https://github.com/EpicGames/unreal-engine-skills-for-claude-code-plugin" target="_blank">Unreal Engine Skills for Claude Code</a>를 10월 19일에 처음 봤다. 언리얼 엔진 5.8의 실험 기능인 Unreal MCP를 Claude Code에서 쓰기 위한 스킬과 훅을 담은 플러그인이다. <a href="/posts/unreal-engine-5-8-mcp-and-agentic-game-development/">이전 글</a>에서 Unreal MCP를 직접 써 봤다면, 이번에는 Epic이 Claude에게 Unreal MCP를 어떻게 쓰라고 가르치는지 레포를 읽으며 정리했다.</p>
<p>처음에는 Epic이 공식 스킬을 새로 배포한 줄 알았다. 확인해 보니 5.8.0 릴리스 무렵부터 GitHub에 공개돼 있던 레포였다.</p>
<p>설정 방법은 이전 글과 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-mcp-in-unreal-editor" target="_blank">공식 문서</a>로 넘기고 이 글은 스킬 자체를 다룬다.</p>
<section><h2>한눈에 보기<a href="#한눈에-보기"><span>#</span></a></h2><p></p><figure><img alt="GitHub의 EpicGames/unreal-engine-skills-for-claude-code-plugin 레포 화면" loading="lazy" width="1400" height="1180" src="/_astro/github-repo.CjKIgF1i_Z2mB5rM.webp" /><figcaption>GitHub의 EpicGames/unreal-engine-skills-for-claude-code-plugin 레포 화면</figcaption></figure><p></p><p>파일은 12개뿐이고 실행 로직이 있는 코드는 SessionStart 훅 스크립트 하나다.</p>

<table><thead><tr><th>파일</th><th>역할</th></tr></thead><tbody><tr><td><code>skills/unreal-mcp/</code></td><td>에디터 조작</td></tr><tr><td><code>skills/create-toolset/</code></td><td>새 도구(툴셋) 작성</td></tr><tr><td><code>skills/unreal-skill/</code></td><td>엔진 쪽 Agent Skill 작성</td></tr><tr><td><code>hooks/unreal-context.sh</code></td><td>UE 프로젝트 맥락 주입</td></tr><tr><td><code>plugin.json</code>, <code>README.md</code> 등</td><td>메타데이터, MIT 라이선스</td></tr></tbody></table><ul>
<li>작성자는 Epic Games 개발자들이다.</li>
<li>원래 이름은 <code>unreal-mcp</code>였다. 개발 중에 “as per legal request”라는 사유로 지금 이름으로 바뀌었다.</li>
<li>스킬을 크게 고친 커밋 여러 개에 <code>Co-Authored-By: Claude</code> 트레일러가 붙어 있다. 스킬을 Claude와 함께 다듬어 왔다.</li>
<li>Anthropic 공식 마켓플레이스 <code>claude-plugins-official</code>에 등록돼 있다. 마켓플레이스에서는 <code>Unreal Engine MCP</code>라는 이름으로 보인다.</li>
</ul><p></p><figure><img alt="Claude 마켓플레이스의 Unreal Engine MCP 페이지" loading="lazy" width="1400" height="560" src="/_astro/claude-marketplace.LVPSmk9z_ZEBBn2.webp" /><figcaption>Claude 마켓플레이스의 Unreal Engine MCP 페이지</figcaption></figure><p></p><p>실제 기능은 엔진에 있다. 레포는 Claude가 그 기능을 쓰는 방법을 적은 문서 묶음에 가깝다.</p><p></p><figure><img alt="Claude Code 플러그인과 Unreal Editor 안의 MCP 구성 요소" loading="lazy" width="1600" height="847" src="/_astro/plugin-engine-structure.Bn9TBgQN_ZsL61o.webp" /><figcaption>Claude Code 플러그인과 Unreal Editor 안의 MCP 구성 요소</figcaption></figure><p></p><ul>
<li>연결 주소는 기본값 <code>http://127.0.0.1:8000/mcp</code>다.</li>
<li>플러그인은 <code>.mcp.json</code>을 포함하지 않는다. 에디터 콘솔에서 <code>ModelContextProtocol.GenerateClientConfig ClaudeCode</code>로 만든다.</li>
<li><code>ModelContextProtocol</code>만 켜면 서버는 뜨지만 도구가 하나도 없다. 도구는 <code>AllToolsets</code>가 켜는 툴셋 플러그인들이 제공한다.</li>
</ul><section><h3>언제부터 있었나<a href="#언제부터-있었나"><span>#</span></a></h3><ul>
<li>개발은 5.8 프리뷰가 나오기 전부터 Epic 내부에서 시작한 것으로 보인다. 커밋 히스토리가 GitHub 레포 생성 시점보다 몇 달 앞선다.</li>
<li>GitHub에는 5.8.0 정식 릴리스와 비슷한 시기에 공개됐고 그 직후부터 외부 사용자의 포크와 PR이 보인다.</li>
<li>엔진의 Unreal MCP와 함께 준비해 정식 출시에 맞춰 내놓은 동반 플러그인으로 볼 수 있다.</li>
</ul><p>그런데도 새로 나온 것처럼 보였던 건 10월 19일에 관심이 갑자기 몰렸기 때문이다. 하루 사이에 스타가 수백 개 붙었는데, Threads와 X에서 이 레포를 소개하는 글이 바이럴된 영향으로 보인다. 나도 Threads에서 보고 처음 알았고, X에도 비슷한 영문 글이 올라와 있었다.</p></section></section>
<section><h2><code>unreal-mcp</code>: 에디터를 조작하는 스킬<a href="#unreal-mcp-에디터를-조작하는-스킬"><span>#</span></a></h2><section><h3>description은 트리거 명세다<a href="#description은-트리거-명세다"><span>#</span></a></h3><p>description은 요약문 대신 언제 불릴지를 정하는 명세로 쓰였다. 원문에 붙은 세 구역의 라벨을 그대로 살려 줄을 나눴다.</p><div><figure><figcaption><span>unreal-mcp/SKILL.md description (발췌, 줄바꿈 추가)</span></figcaption><pre><code><div><div><div>1</div></div><div><span>Concrete triggers</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>spawn/move/duplicate/transform actors,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>create or edit a Blueprint/Widget/Material/...,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>read or write properties on actors</span></div></div><div><div><div>5</div></div><div><span>  </span><span>(e.g. `bIsLocked` on `BP_DoorActor`), ...</span></div></div><div><div><div>6</div></div><div><span>Treat as Unreal context even without</span></div></div><div><div><div>7</div></div><div><span>the word "Unreal"</span><span>:</span></div></div><div><div><div>8</div></div><div><span>  </span><span>asset prefixes `BP_`, `WBP_`, `M_`, `SK_`, ...;</span></div></div><div><div><div>9</div></div><div><span>  </span><span>UE C++ types/macros; `.uproject`;</span></div></div><div><div><div>10</div></div><div><span>  </span><span>Content Browser; Outliner; PlayerStart; ...</span></div></div><div><div><div>11</div></div><div><span>Skip for</span><span>:</span></div></div><div><div><div>12</div></div><div><span>  </span><span>pure conceptual/docs questions,</span></div></div><div><div><div>13</div></div><div><span>  </span><span>Unity, Godot, or unrelated uses of</span></div></div><div><div><div>14</div></div><div><span>  </span><span>"blueprint"/"sequencer"/"widget".</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li>긍정 트리거, 암묵 신호, 부정 트리거로 나뉜다.</li>
<li>암묵 신호가 특징적이다. 사용자가 “언리얼”이라고 말하지 않아도 <code>BP_</code> 같은 에셋 접두사나 <code>UPROPERTY</code>가 보이면 UE 작업으로 본다.</li>
<li>부정 트리거는 개념 질문과 다른 엔진, 일반 명사로서의 “blueprint”를 제외한다.</li>
</ul><p>description은 처음부터 이렇지는 않았다.</p><ol>
<li>처음에는 “Control Unreal Editor via MCP: create actors, edit Blueprints, …”처럼 기능을 나열했다.</li>
<li>다음에는 “Use this skill whenever the user mentions Unreal Engine … even if they don’t explicitly name the skill.”로 넓게 불리게 했다.</li>
<li>지금은 위의 세 구역 구조로 정확하게 불리게 했다.</li>
</ol><p>더 자주 불리게 했다가 필요할 때만 불리게 고친 셈이다. 넓게 잡은 description이 UE 개념 질문에도 에디터 조작 스킬을 불렀던 것으로 보인다.</p><p><code>.gitignore</code>에는 <code>skills/*-workspace/</code>를 무시하는 규칙이 있고 주석에 skill-creator의 description 최적화 루프와 eval 작업 공간이라고 적혀 있다. 트리거 정확도를 측정하면서 다듬은 것 같다.</p><div><div><div></div><div>내 생각</div></div><div><p>description에 이렇게 많은 신호를 넣는 것이 과해 보일 수도 있지만, 사용자가 이름을 부르지 않아도 알아서 불려야 하는 스킬이니 나쁘지 않다고 생각한다. 트리거를 긍정, 암묵 신호, 부정 세 종류로 명확하게 나눠 적은 점이 흥미롭다.</p></div></div></section><section><h3>본문: 설명서에서 지시문으로<a href="#본문-설명서에서-지시문으로"><span>#</span></a></h3><p>본문도 같은 무렵 지시문 형태로 다시 썼다.</p><ul>
<li>이전 본문은 “Unreal MCP exposes hundreds of tools…”로 시작하는 제품 설명이었다.</li>
<li>지금은 “You are wired into a live Unreal Editor…”로 시작하는 2인칭 지시문이다.</li>
<li>설치와 장애 대응을 <code>references/setup.md</code>, <code>references/operations.md</code>로 옮겨 본문이 12KB에서 7KB로 줄었다. 처음부터 읽을 내용과 문제가 생겼을 때 읽을 내용을 나눴다.</li>
</ul><p>현재 본문의 구성은 다음과 같다.</p>

<table><thead><tr><th>절</th><th>내용</th></tr></thead><tbody><tr><td>도입</td><td>라이브 에디터 연결 상황</td></tr><tr><td>First step every time</td><td>도구 탐색과 호출 순서</td></tr><tr><td>Safety rules</td><td>안전 규칙 5개</td></tr><tr><td>Project skills</td><td>엔진 Agent Skill 우선</td></tr><tr><td>Reference files</td><td>설치, 장애 대응 문서</td></tr><tr><td>Companion skills</td><td>다른 두 스킬로 안내</td></tr></tbody></table></section><section><h3>도구 검색: 메타 도구 3개<a href="#도구-검색-메타-도구-3개"><span>#</span></a></h3><ul>
<li>Unreal MCP 서버는 기본 설정(<code>bEnableToolSearch = true</code>)에서 <code>list_toolsets</code>, <code>describe_toolset</code>, <code>call_tool</code> 세 도구만 노출한다. 수백 개의 실제 도구는 <code>call_tool</code>의 인자로 이름을 넘겨 서버 안에서 실행된다.</li>
<li>레포 문서에는 컨텍스트 축소와 프롬프트 캐시 유지가 이유로 적혀 있다. 세션 중에 도구 목록이 바뀌지 않으니 캐시가 깨지지 않는다.</li>
<li>v3.0.0 이전에는 <code>load_toolset</code>으로 툴셋을 네이티브 도구로 등록했고, v3.0.0에서 지금의 <code>call_tool</code> 방식으로 바꿨다.</li>
</ul><div><div><div></div><div>내 생각</div></div><div><p>메타 도구 3개만 노출하는 이유로 레포는 컨텍스트 축소와 캐시 유지를 함께 들지만, 나는 그중에서도 일부 툴셋이 설명만 읽어도 토큰이 굉장히 많이 든다는 점이 더 컸다고 본다. <a href="/posts/unreal-engine-5-8-mcp-and-agentic-game-development/">이전 글</a>에서 정리한 Epic 세션에서도 툴셋 설명을 읽어 오는 데만 머티리얼은 약 2만 6천 토큰, 나이아가라는 25만 토큰 가까이 든다고 했으니, 모든 스키마를 처음부터 실으면 그만큼 컨텍스트가 찬다. 그래서 도구가 수백 개여도 호출 창구를 <code>call_tool</code> 하나로 모으는 편이 더 직관적인 설계라고 본 것 같다.</p></div></div></section><section><h3>Safety rules가 이 스킬의 핵심이다<a href="#safety-rules가-이-스킬의-핵심이다"><span>#</span></a></h3><ul>
<li>대량 변경 전후로 저장한다. MCP로 한 편집은 특히 컴파일 경계를 넘으면 되돌릴 수 없을 수 있다.</li>
<li>C++이나 셰이더 컴파일 중에는 도구 호출이 멈추거나 이상하게 실패한다. C++ 재빌드는 <code>LiveCodingToolset.CompileLiveCoding</code>으로 하고 결과를 기다린다.</li>
<li>서로 의존하는 호출과 같은 에셋을 건드리는 변경은 순서대로 보낸다.</li>
<li>많은 도구가 예외 없이 status 값으로 성공과 실패를 돌려준다. 명시적인 성공이 아니면 멈춘다.</li>
<li>PIE 중에는 에셋 생성 같은 에디터 전용 도구가 다르게 동작한다.</li>
</ul><p>모두 도구 스키마를 읽어서는 알 수 없는 운영 지식이다. 뒤에서 볼 <code>unreal-skill</code>의 Novel 원칙(도구를 써서 알 수 있는 것은 쓰지 마라)에 가장 잘 맞는 부분이기도 하다.</p></section><section><h3>공식 문서와 겹치는 부분, 겹치지 않는 부분<a href="#공식-문서와-겹치는-부분-겹치지-않는-부분"><span>#</span></a></h3>

<table><thead><tr><th>내용</th><th>문서</th><th>스킬</th></tr></thead><tbody><tr><td>플러그인 설정, <code>.mcp.json</code></td><td>O</td><td>O</td></tr><tr><td>도구 검색</td><td>O</td><td>O</td></tr><tr><td>툴셋 작성</td><td>O</td><td>O</td></tr><tr><td>저장·PIE·컴파일 수칙</td><td>X</td><td>O</td></tr><tr><td>Agent Skill 확인</td><td>X</td><td>O</td></tr><tr><td>proxy 복구</td><td>X</td><td>O</td></tr></tbody></table><p>동시 호출은 둘의 내용이 다르다. 공식 문서는 도구를 게임 스레드에서 순서대로 실행하므로 “clients should not issue overlapping Tool calls”라고 한다. 스킬은 최근 “MCP accepts concurrent requests”로 바뀌었다. 스킬이 문서보다 앞서 바뀌는 구간도 있다.</p><p>공식 문서가 이 플러그인을 언급하는 곳은 툴셋 작성 절의 한 문장뿐이다. Claude Code 사용자는 “the <code>unreal-mcp</code> Claude Code plugin”의 <code>create-toolset</code> 스킬로 툴셋을 만들 수 있다고 적혀 있다. 이름은 개명 전 그대로이고 레포 링크나 설치 방법은 없다. 문서만 읽어서는 이 플러그인이 GitHub와 공식 마켓플레이스에 있다는 것을 알기 어렵다.</p></section></section>
<section><h2><code>create-toolset</code>: Claude가 쓸 도구를 Claude가 만들게 하는 스킬<a href="#create-toolset-claude가-쓸-도구를-claude가-만들게-하는-스킬"><span>#</span></a></h2><p>에디터에 새 도구를 추가하는 방법을 다룬다. 레포에서 가장 큰 스킬이다.</p><section><h3>쓰기 전에 확인할 것<a href="#쓰기-전에-확인할-것"><span>#</span></a></h3><p>스킬은 코드를 쓰기 전에 다음을 순서대로 따지게 한다.</p><ol>
<li>이미 있는 기능인가? 있으면 만들지 말고 해당 도구를 알려 준다.</li>
<li>요청이 생각보다 일반적인가? “Blueprint 에셋 메타데이터 읽기”는 모든 에셋에 해당하므로 <code>BlueprintTools</code>가 아니라 <code>AssetTools</code>에 둔다.</li>
<li>같은 도메인의 툴셋이 이미 있는가?</li>
<li>새 플러그인이 필요한가? 분명하지 않으면 사용자에게 묻는다.</li>
<li>Python과 C++ 중 무엇으로 만들 것인가? 사용자가 정할 문제다. 양쪽에서 쓸 수 있는 API를 요약해 보여 주고 결정을 기다린다. 둘 다 API가 없으면 우회하지 말고 멈춘다.</li>
</ol><p>이후에는 공통 규약(실제 타입 사용, 실패는 반환값이 아니라 raise, 모든 도구는 static, 테스트 필수)과 C++/Python 작성법이 이어진다. 마지막은 테스트 루프로, MCP로 에디터에 붙어 만든 도구를 <code>AutomationTestToolset</code>으로 테스트한다.</p></section><section><h3>문서화 규칙은 AI의 버릇을 겨냥한다<a href="#문서화-규칙은-ai의-버릇을-겨냥한다"><span>#</span></a></h3><p>문서화 지침은 나중에 간결한 쪽으로 한 번 더 강화됐다. 해당 커밋 메시지에 따르면 원래 지침이 “too abstract to counter the default bias toward over-documenting”이었다고 한다. 추가된 내용은 다음과 같다.</p><ul>
<li>판정 기준: 시그니처, 이름, 타입만 보고 유능한 독자가 추론할 수 있는 줄이면 지운다.</li>
<li>“LLMs over-document by default; resist it.”</li>
<li>구현 사유(왜 이렇게 짰는지)는 쓰지 않고 계약(무엇을 하는지)만 쓴다.</li>
<li>남길 것: 단위, 범위, 특이한 인코딩, 빈 결과의 의미.</li>
<li>before/after 예시:</li>
</ul><div><figure><figcaption><span>skills/create-toolset/SKILL.md</span></figcaption><pre><code><div><div><div>1</div></div><div><span>// Over-documented: every line restates the signature or narrates usage.</span></div></div><div><div><div>2</div></div><div><span>/**</span></div></div><div><div><div>3</div></div><div><span><span> </span></span><span>* Runs a cheat. </span><span>@param</span><span> </span><span>CheatName</span><span> The cheat name (case-insensitive), pass it to run the cheat.</span></div></div><div><div><div>4</div></div><div><span><span> </span></span><span>* </span><span>@param</span><span> </span><span>Args</span><span> Optional arguments; fill in the slots from the Args hint (e.g. "&lt;float F&gt;" -&gt; "2.0",</span></div></div><div><div><div>5</div></div><div><span><span> </span></span><span>*             "&lt;float A&gt; &lt;float B&gt;" -&gt; "1 2"). Empty string for no arguments.</span></div></div><div><div><div>6</div></div><div><span><span> </span></span><span>*/</span></div></div><div><div><div>7</div></div><div><span>// Trimmed: only the non-inferable facts remain.</span></div></div><div><div><div>8</div></div><div><span>/**</span></div></div><div><div><div>9</div></div><div><span><span> </span></span><span>* Runs a cheat on the local player, as if typed into the console.</span></div></div><div><div><div>10</div></div><div><span><span> </span></span><span>* </span><span>@param</span><span> </span><span>CheatName</span><span> The cheat command name.</span></div></div><div><div><div>11</div></div><div><span><span> </span></span><span>* </span><span>@param</span><span> </span><span>Args</span><span> Arguments as a single space-separated string. Empty for none.</span></div></div><div><div><div>12</div></div><div><span><span> </span></span><span>*/</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 수정에도 <code>Co-Authored-By: Claude</code> 트레일러가 붙어 있다. Claude가 말이 많아지는 버릇을 막는 규칙을 Claude와 함께 썼다.</p><p>엔진 쪽 구조 때문에 이 규칙이 중요하다. 여기서 쓴 주석은 그대로 AI가 보는 도구 설명이 된다. <code>list_toolsets</code>는 툴셋마다 이름과 설명 한 줄만 돌려주므로 스킬도 “An LLM will often see only the toolset name and this description”이라고 적고 툴셋 설명이 혼자서 이해되게 쓰라고 한다. 장황한 문서는 다음 에이전트의 컨텍스트를 잡아먹는다.</p><div><div><div></div><div>내 생각</div></div><div><p>“LLMs over-document by default; resist it.”처럼 AI의 버릇을 직접 겨냥한 지침은 흥미롭다. 이런 한 줄이 실제로 효과가 있는지는 의문이지만, 효과가 있다면 내 스킬에도 써 보고 싶다.</p><p>LLM의 과잉 문서화와 과한 텍스트는 AI 산출물의 가장 큰 문제라고 생각한다. 툴셋이나 스킬에서 가장 중요한 것은 간단명료함인데, LLM으로 만들 때 한 번에 그것을 지켜서 잘 만들어지는 경우는 거의 본 적이 없다.</p></div></div></section><section><h3>반환값 규칙과 엔진 코드가 어긋난다<a href="#반환값-규칙과-엔진-코드가-어긋난다"><span>#</span></a></h3><ul>
<li>공통 규약의 제목은 “Return values carry data, not status”다. 이어지는 문장은 “Never return a boolean, error string, or result wrapper to communicate an error”라고 한다.</li>
<li>두 문장이 막는 범위가 다르다. 제목대로면 상태를 알리는 반환값은 모두 안 되고, 본문대로면 에러를 알리는 <code>bool</code>만 안 된다. 실패하면 에러를 올리고 성공하면 <code>true</code>를 돌려주는 도구는 그 사이에 걸린다.</li>
<li>엔진의 <code>UAgentSkillToolset::CreateSkill</code>은 실패하면 <code>RaiseScriptError</code>로 에러를 올린 뒤 빈 문자열을 반환한다. C++ 에러 처리 절이 시키는 방식 그대로다.</li>
<li>같은 클래스의 <code>UpdateSkill</code>은 실패하면 에러를 올리고 <code>false</code>를, 성공하면 <code>true</code>를 반환한다. <code>CreateSkill</code>은 이 반환값을 받아 실패하면 방금 만든 에셋을 지운다.</li>
<li>AI는 이 <code>false</code>를 받지 않는다. 에러가 올라오면 툴셋 레지스트리가 반환값을 버리고 에러 메시지만 넘긴다(<code>FunctionLibraryToolset.cpp:177-183</code>). 성공하면 <code>{"returnValue":true}</code>가 간다.</li>
<li><code>UpdateSkill</code>만 이런 것도 아니다. 5.8.3의 <code>Engine/Plugins</code>에서 <code>AICallable</code> 함수 중 <code>bool</code>을 반환하는 것은 40개가 넘고, <code>IsPIERunning</code>처럼 질문에 답하는 것을 빼도 30개 넘게 같은 모양이다. 이름이 같은 도구도 갈린다. PCG의 <code>UpdateNode</code>는 <code>bool</code>, Dataflow의 <code>UpdateNode</code>는 <code>void</code>를 반환한다.</li>
<li><code>ConfigSettingsToolset::SaveSection</code>은 마지막에 <code>return Section-&gt;Save();</code>를 한다. 저장이 실패해도 에러 없이 <code>false</code>가 정상 응답으로 간다. 규칙이 막으려는 경우가 이쪽이다.</li>
<li>도구를 쓰는 쪽 스킬은 반대 전제를 둔다. <code>unreal-mcp</code>는 많은 도구가 예외 없이 성공과 실패 상태를 돌려준다며 응답을 꼭 확인하라고 한다.</li>
</ul><div><div><div></div><div>내 생각</div></div><div><p>처음에는 <code>UpdateSkill</code>의 <code>bool</code> 반환을 실수로 보고 엔진에 PR을 올려 볼 생각이었다. 다시 확인해 보니 한 함수의 실수가 아니라 엔진 전반의 관례였고, <code>void</code>로 바꿔도 AI가 받는 응답은 <code>true</code>가 <code>null</code>로 바뀌는 것뿐이었다.</p><p>그래서 엔진 PR 대신 규칙 문장이 두 가지로 읽힌다는 점을 플러그인 레포에 <a href="https://github.com/EpicGames/unreal-engine-skills-for-claude-code-plugin/issues/11" target="_blank">이슈</a>로 남겼다. 동작만 하는 도구는 <code>void</code>를 반환하고 <code>bool</code>은 질문에 답할 때만 쓰라는 대체 문장도 함께 제안했다. 엔진 PR은 Epic이 그 방향을 원하면 보낼 생각이다.</p></div></div></section></section>
<section><h2><code>unreal-skill</code>: 스킬을 만드는 스킬<a href="#unreal-skill-스킬을-만드는-스킬"><span>#</span></a></h2><section><h3>스킬이 두 층이다<a href="#스킬이-두-층이다"><span>#</span></a></h3><p>이 스킬은 엔진 쪽 Agent Skill을 작성하는 방법을 다룬다. Claude Code 스킬과는 이름만 같고 다른 물건이다.</p>

<table><thead><tr><th></th><th>Claude Code 스킬</th><th>Agent Skill</th></tr></thead><tbody><tr><td>정체</td><td><code>SKILL.md</code> 파일</td><td><code>UAgentSkill</code> 파생 UClass</td></tr><tr><td>위치</td><td><code>.claude/skills/</code> 등</td><td>플러그인 코드, Content 에셋</td></tr><tr><td>전달</td><td>하네스가 파일을 읽음</td><td>에디터가 MCP로 반환</td></tr><tr><td>호출</td><td>자동</td><td><code>ListSkills</code> 직접 호출</td></tr><tr><td>클라이언트</td><td>Claude Code</td><td>MCP 클라이언트 전부</td></tr></tbody></table><p>Agent Skill은 플러그인의 C++/Python 클래스로 정의하거나 프로젝트 Content에 블루프린트 클래스 에셋으로 만든다. 어느 쪽이든 에디터가 로드한 클래스를 MCP 도구가 텍스트로 돌려준다.</p><p></p><figure><img alt="에이전트가 ListSkills와 GetSkills로 Agent Skill을 불러오는 순서" loading="lazy" width="1600" height="820" src="/_astro/agent-skill-flow.DYQ4Bn_O_Z1zEgxA.webp" /><figcaption>에이전트가 ListSkills와 GetSkills로 Agent Skill을 불러오는 순서</figcaption></figure><p></p><ul>
<li><code>ListSkills</code>는 <code>UAgentSkill</code>의 파생 클래스를 모두 찾아 경로와 Description을 돌려준다. 프로젝트 설정의 허용/차단 정규식(<code>AgentSkillAllowedNames</code>, <code>AgentSkillBlockedNames</code>)으로 노출할 스킬을 거른다.</li>
<li>자동 호출이 없기 때문에 <code>unreal-mcp</code> 스킬이 “낯선 작업을 시작할 때마다 <code>ListSkills</code>를 확인하라”고 지시한다. Claude Code에서는 하네스가 하는 일을 여기서는 지시문이 대신한다.</li>
</ul></section><section><h3>Agent Skill 에셋의 정체: Data-only Blueprint<a href="#agent-skill-에셋의-정체-data-only-blueprint"><span>#</span></a></h3><p><code>CreateSkill</code>이 만드는 것은 블루프린트다.</p><ul>
<li><code>UBlueprintFactory</code>의 부모 클래스를 <code>UAgentSkill</code>로 지정해 블루프린트 에셋을 만든다.</li>
<li>생성된 클래스의 CDO(Class Default Object)에 Description과 Instructions를 쓴다.</li>
<li>패키지를 dirty로 표시할 뿐 저장하지는 않는다.</li>
<li><code>UpdateSkill</code>은 C++/Python으로 정의한 스킬은 수정하지 않는다. 저장할 에셋이 없어서 재시작하면 바뀐 내용이 사라지기 때문이다.</li>
</ul><p>형식은 그래프 없이 Class Defaults만 채우는 Data-only Blueprint다. GameplayEffect를 블루프린트로 만들어 값만 채우는 것과 같은 방식이다. 다만 채우는 내용이 AI에게 주는 자연어 지시문이다.</p></section><section><h3>지시문이 코드처럼 동작하는 예: PCG<a href="#지시문이-코드처럼-동작하는-예-pcg"><span>#</span></a></h3><p>5.8.3에는 이미 Agent Skill이 몇 개 들어 있다. PCG 툴셋의 스킬은 <code>GeneratePrompt</code>를 오버라이드해 지시문의 자리표시자를 에디터에서 조회한 값으로 채운다.</p><p>경로는 <code>Engine/Plugins/Experimental/Toolsets/PCGToolset/Source/PCGToolset/Public/PCGToolsetSkills.h</code>다.</p><div><div><div><figure><figcaption><span>PCGToolsetSkills.h (UE 5.8.3, 발췌)</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>2</div></div><div><span>class</span><span> </span><span>UPCGGraphGenerationSkill</span><span> : </span><span>public</span><span> </span><span>UAgentSkill</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>protected:</span></div></div><div><div><div>7</div></div><div><span>  </span><span>virtual</span><span> </span><span>FString</span><span> </span><span>GeneratePrompt_Implementation</span><span>(</span><span>const</span><span> </span><span>FString</span><span>&amp;</span><span><span> </span><span>InitialInstructions</span><span>) </span></span><span>const</span><span> </span><span>override</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>FString</span><span> </span><span>CustomInstructions</span><span> </span><span>=</span><span> </span><span>InitialInstructions</span><span>;</span></div></div><div><div><div>10</div></div><div><span>    </span><span>CustomInstructions</span><span>.</span><span>ReplaceInline</span><span>(</span><span>TEXT</span><span>(</span><span>"{Subgraphs}"</span><span>),</span></div></div><div><div><div>11</div></div><div><span>      </span><span>*</span><span><span>FString</span><span>::</span></span><span>Join</span><span><span>(</span><span>UPCGToolset</span><span>::</span></span><span>ListAvailableSubgraphs</span><span>(), </span><span>TEXT</span><span>(</span><span>", "</span><span>)));</span></div></div><div><div><div>12</div></div><div><span>    </span><span>CustomInstructions</span><span>.</span><span>ReplaceInline</span><span>(</span><span>TEXT</span><span>(</span><span>"{Nodes}"</span><span>),</span></div></div><div><div><div>13</div></div><div><span>      </span><span>*</span><span><span>FString</span><span>::</span></span><span>Join</span><span><span>(</span><span>UPCGToolset</span><span>::</span></span><span>ListNativeNodes</span><span>(</span><span>true</span><span>), </span><span>TEXT</span><span>(</span><span>", "</span><span>)));</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>// {Examples}, {Instant Graphs}도 같은 방식으로 채운다.</span></div></div><div><div><div>15</div></div><div><span>    </span><span>return</span><span> </span><span>CustomInstructions</span><span>;</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>17</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>스킬을 불러오는 순간 현재 프로젝트의 PCG 노드 목록과 서브그래프가 지시문에 들어간다. 정적인 <code>SKILL.md</code>로는 할 수 없는 일이다.</p></section><section><h3>작성 원칙 6개<a href="#작성-원칙-6개"><span>#</span></a></h3><p><code>unreal-skill</code>이 제시하는 원칙은 다음과 같다.</p><ul>
<li>Novel: 도구를 써 보면 알 수 있는 것은 쓰지 않는다.</li>
<li>Collegial: 문서가 아니라 유능한 동료에게 브리핑하듯 쓴다.</li>
<li>Flexible: 개념 설명이든 절차든 가장 명확한 형태를 고른다.</li>
<li>Durable: 프로퍼티나 도구 이름처럼 바뀌는 세부를 넣지 않는다.</li>
<li>Agnostic: 오케스트레이션, 역할 이름, 모델 이름을 언급하지 않는다.</li>
<li>Parsimonious: 모든 토큰은 비용이다. 없어도 아쉽지 않을 문장은 지운다.</li>
</ul><p>Agnostic이 눈에 띈다. Claude Code용 플러그인이 엔진 Agent Skill에는 Claude조차 언급하지 말라고 한다. Agent Skill은 MCP로 전달되고 <code>GenerateClientConfig</code>는 Claude Code 외에 Cursor, VS Code, Gemini, Codex 설정도 만든다. 클라이언트와 무관한 지식은 엔진 쪽에 두고 이 플러그인은 Claude Code에서 그 지식에 닿는 입구 역할을 맡는 것으로 보인다.</p><p>Durable은 레포 스스로도 적용했다. <code>unreal-mcp</code>의 <code>## Workflows</code> 절을 “names that drift”라는 이유로 지웠다. 정확한 <code>Toolset.tool</code> 이름에 묶인 절차였다.</p><div><div><div></div><div>내 생각</div></div><div><p><code>unreal-skill</code>을 읽으며 가장 독특하다고 느낀 점은 AI 지시문을 블루프린트 에셋으로 관리한다는 것이다. 기법 자체는 익숙한데 쓰임새가 새롭다.</p><p>그렇다면 네이밍 규칙이나 폴더 구조 같은 팀의 프로젝트 노하우는 어디에 둘까. 나라면 Claude Code 스킬 쪽에 둘 것 같다. 언리얼 Agent Skill은 PCG 예시처럼 에디터 기능을 활용한 특정 프로세스를 구축할 때 쓰는 편이 맞아 보인다.</p></div></div></section></section>
<section><h2>SessionStart 훅<a href="#sessionstart-훅"><span>#</span></a></h2><ul>
<li>세션이 시작될 때(<code>startup</code>, <code>resume</code>, <code>clear</code>) 현재 디렉터리에서 위로 올라가며 UE 프로젝트 루트를 찾는다. <code>Source/</code>나 <code>Content/</code> 안에서 세션을 열어도 찾는다.</li>
<li>루트 판정은 <code>*.uproject</code> 또는 <code>GenerateProjectFiles.*</code>(엔진 소스 트리)로 한다. <code>Engine</code> 폴더는 일부러 기준에서 뺐다.</li>
</ul><div><figure><figcaption><span>hooks/unreal-context.sh (발췌)</span></figcaption><pre><code><div><div><div>1</div></div><div><span>is_project_root</span><span>() {</span></div></div><div><div><div>2</div></div><div><span>  </span><span># Reliable top-level markers only. A bare `Engine` directory is NOT a marker:</span></div></div><div><div><div>3</div></div><div><span>  </span><span># the Unreal source tree contains an `Engine` module at</span></div></div><div><div><div>4</div></div><div><span>  </span><span># `Engine/Source/Runtime/Engine`, so walking up from a Runtime module would</span></div></div><div><div><div>5</div></div><div><span>  </span><span># otherwise short-circuit at `Source/Runtime` and mis-identify it as the root.</span></div></div><div><div><div>6</div></div><div><span>  </span><span>local</span><span> </span><span>directory</span><span>=</span><span>"</span><span>$1</span><span>"</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>[ </span><span>-f</span><span> </span><span>"</span><span>$directory</span><span>/GenerateProjectFiles.bat"</span><span> ] &amp;&amp; </span><span>return</span><span> </span><span>0</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>[ </span><span>-f</span><span> </span><span>"</span><span>$directory</span><span>/GenerateProjectFiles.sh"</span><span> ] &amp;&amp; </span><span>return</span><span> </span><span>0</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>[ </span><span>-f</span><span> </span><span>"</span><span>$directory</span><span>/GenerateProjectFiles.command"</span><span> ] &amp;&amp; </span><span>return</span><span> </span><span>0</span></div></div><div><div><div>10</div></div><div><span>  </span><span>for</span><span> </span><span>candidate</span><span> </span><span>in</span><span> </span><span>"</span><span>$directory</span><span>"/*.uproject</span><span>; </span><span>do</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>[ </span><span>-e</span><span> </span><span>"</span><span>$candidate</span><span>"</span><span> ] &amp;&amp; </span><span>return</span><span> </span><span>0</span></div></div><div><div><div>12</div></div><div><span>  </span><span>done</span></div></div><div><div><div>13</div></div><div><span>  </span><span>return</span><span> </span><span>1</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li>찾으면 다음 내용을 컨텍스트에 넣는다. UE 프로젝트(또는 엔진 소스 트리)라는 사실, C++/UObject·Slate·UHT 관례를 따르라는 안내, 에디터 조작에는 <code>unreal-mcp</code> 스킬을 쓰라는 안내, <code>.mcp.json</code>이 없으면 생성 명령.</li>
<li>UE 프로젝트가 아니면 아무것도 출력하지 않는다.</li>
<li>스킬의 자동 호출은 description에 기대므로 확률적이다. 훅은 이 폴더가 UE 프로젝트이고 쓸 스킬이 있다는 사실을 세션 처음에 확정적으로 알린다.</li>
<li>bash 스크립트라서 Windows에서는 Git Bash나 WSL이 필요하다. 없으면 훅만 빠지고 MCP 도구는 동작한다. README에도 “There is no separate PowerShell companion script today”라고 적혀 있다.</li>
</ul><div><div><div></div><div>내 생각</div></div><div><p>Windows에서 Git Bash가 필요하다는 점은 크게 문제되지 않을 것 같다. Git을 쓰는 개발 환경이라면 Git Bash도 대개 함께 깔려 있다. PowerShell용 스크립트를 따로 두지 않은 것도, 멀티 플랫폼을 지향하는 엔진이라 플랫폼별로 나누면 코드만 늘어서인 것 같다.</p></div></div></section>
<section><h2>보안: 경고는 글로, 강제는 어디서<a href="#보안-경고는-글로-강제는-어디서"><span>#</span></a></h2><p>README의 Security 절은 네 가지를 경고한다.</p><ol>
<li>“Localhost is not a trust boundary.” Origin 검증은 브라우저 탭을 막을 뿐이고 같은 사용자로 도는 프로세스는 모두 서버에 붙을 수 있다. 공식 문서도 인증 계층이 없다고 밝힌다.</li>
<li><code>ProgrammaticToolset.execute_tool_script</code>는 에디터 프로세스 안에서 임의의 Python을 실행한다.</li>
<li><code>--dangerously-skip-permissions</code>와 함께 쓰지 않는다. 쓴다면 범위를 좁히고 버려도 되는 프로젝트에서 쓴다.</li>
<li>긴 세션 전에는 저장하고 커밋해 둔다. 도구 호출 한 번이 버전 관리 대상 에셋을 옮기거나 지울 수 있다.</li>
</ol><p>스킬과 도구 설명도 같은 내용을 모델에게 말한다. 엔진의 <code>CreateSkill</code>/<code>UpdateSkill</code> 설명에는 “This should ONLY be called after getting explicit direction or permission from the user.”라고 적혀 있다. 사람이 읽는 README의 경고를 모델 쪽에서는 스킬과 도구 설명이 맡는다.</p><div><div><div></div><div>내 생각</div></div><div><p>경고를 글로만 두지 말고 훅으로 막으면 좋겠다는 생각도 들었다. 그런데 README의 네 가지 경고 중에는 훅으로 막을 수 없어 보이는 것도 있었다.</p></div></div><p>그렇다면 플러그인이 훅으로 강제할 수 있었을까. 네 가지를 하나씩 보면 대부분 Claude Code 훅의 범위 밖이다.</p>

<table><thead><tr><th>경고</th><th>훅으로</th><th>이유</th></tr></thead><tbody><tr><td>localhost</td><td>불가</td><td>다른 프로세스의 접속</td></tr><tr><td><code>execute_tool_script</code></td><td>가능</td><td><code>call_tool</code> 인자 검사</td></tr><tr><td>skip-permissions</td><td>대상 아님</td><td>사용자가 고르는 모드</td></tr><tr><td>저장과 커밋</td><td>경고만</td><td>미저장 에셋은 안 보임</td></tr></tbody></table><ul>
<li>localhost 문제의 위협 주체는 Claude Code가 아닌 다른 프로세스다. 서버 인증, 즉 엔진의 문제다.</li>
<li><code>execute_tool_script</code>는 <code>PreToolUse</code> 훅이 <code>call_tool</code>의 <code>toolset_name</code>과 <code>tool_name</code>을 보고 확인을 요구하거나 거부할 수 있다.</li>
<li>에디터의 미저장 에셋은 셸 훅이 볼 수 없다. 커밋 안 된 변경을 알려 주는 정도만 가능하다.</li>
</ul><p>결국 훅으로 다룰 수 있는 경고는 <code>execute_tool_script</code> 하나다. 도구 검색 모드에서는 모든 호출이 <code>call_tool</code> 하나로 들어오므로, 도구 이름 기반 권한 규칙으로는 읽기 도구와 Python 실행을 나눠 허용할 수 없다. 훅이라면 나눌 수 있다. 다만 훅에 <code>ProgrammaticToolset.execute_tool_script</code>라는 이름을 넣으면 레포가 지키려는 Durable 원칙과 부딪힌다. 이 레포의 위험은 대부분 엔진 서버와 사용자의 운영 습관 쪽에 있어서 플러그인이 할 수 있는 일은 원래 좁다.</p></section>
<section><h2>MCP만으로는 안 되나<a href="#mcp만으로는-안-되나"><span>#</span></a></h2><div><div><div></div><div>내 생각</div></div><div><p>MCP만으로도 에디터는 조작할 수 있는데, 트리거용 스킬을 따로 두는 것이 패턴으로서 더 나은지 궁금했다. 이렇게 MCP와 스킬을 함께 묶는 구성을 몇 번 본 것 같기는 하다.</p></div></div><p>MCP 서버도 연결 직후(initialize 응답)에 <code>instructions</code> 문자열로 사용법을 보낼 수 있고 Claude Code는 이를 시스템 프롬프트에 넣는다. Unreal MCP의 응답 구조체에도 <code>Instructions</code> 필드가 있지만(<code>ModelContextProtocolSession.h</code>) 5.8.3 소스에서 값을 채우는 코드는 찾지 못했다. 지금은 스킬이 이 자리를 대신하고 있다.</p><p><code>instructions</code>를 채운다고 해도 스킬이 맡는 일은 남는다.</p>

<table><thead><tr><th>상황</th><th>MCP만</th><th>스킬 함께</th></tr></thead><tbody><tr><td>언제 쓸지 판단</td><td>메타 도구 3개뿐</td><td>에셋 이름으로도 트리거</td></tr><tr><td>서버가 꺼졌거나 설정 전</td><td>정보 없음</td><td>설치·복구 안내</td></tr><tr><td>MCP 밖의 작업</td><td>해당 없음</td><td>툴셋 코드 작성</td></tr><tr><td>상주 컨텍스트</td><td><code>instructions</code> 전체</td><td>description만</td></tr></tbody></table><ul>
<li>도구 검색 모드에서 모델이 보는 것은 범용 메타 도구 3개뿐이다. 스킬은 <code>BP_DoorActor</code> 같은 표현만으로도 불린다.</li>
<li>MCP는 연결되지 않으면 아무 정보도 보내지 못한다. 스킬은 <code>setup.md</code>, <code>operations.md</code>로 서버를 켜는 법과 복구법을 안내한다.</li>
<li>툴셋을 C++/Python으로 작성하는 일은 MCP 호출 밖의 코드 작업이라 <code>create-toolset</code>이 맡는다.</li>
</ul><p>MCP는 연결된 뒤에만 말할 수 있고 스킬은 연결되기 전에도 말할 수 있다. 대신 스킬은 Claude Code에서만 동작하고 앞의 동시 호출 항목처럼 문서나 서버와 따로 바뀔 수 있다.</p><div><div><div></div><div>내 생각</div></div><div><p>그래서 이 레포는 Unreal MCP 공식 문서 내용에 운영 지식을 더해 스킬로 만들어, Unreal MCP를 적극적으로 활용할 수 있게 해 주는 역할이 크다고 생각한다.</p></div></div></section>
<section><h2>Claude Code용만 있다<a href="#claude-code용만-있다"><span>#</span></a></h2><ul>
<li>엔진의 Unreal MCP 서버는 특정 클라이언트에 묶여 있지 않다. <code>ModelContextProtocol.GenerateClientConfig</code>는 Claude Code, Cursor, VS Code, Gemini CLI, Codex CLI 다섯 가지 설정 파일을 만든다.</li>
<li>그런데 Epic이 함께 내놓은 스킬과 훅은 Claude Code 플러그인 하나뿐이다. EpicGames GitHub 조직에 공개된 AI 클라이언트용 플러그인은 이 레포뿐이고, 공식 문서도 이 플러그인만 언급한다.</li>
<li>설정 파일도 대우가 조금 다르다. Claude Code용 <code>.mcp.json</code>은 다시 생성하면 기존 내용에 병합되지만, Codex용 <code>.codex/config.toml</code>은 이미 있으면 덮어쓰지 않아서 손으로 고쳐야 한다.</li>
<li>Codex 같은 다른 클라이언트도 MCP 도구와 엔진 쪽 Agent Skill은 그대로 쓸 수 있다. 빠지는 것은 이 레포의 스킬이 주는 트리거, 안전 수칙, 설치·복구 안내와 툴셋 작성 가이드다.</li>
</ul><div><div><div></div><div>내 생각</div></div><div><p>요즘 다음 결제를 Codex로 할지 Claude로 할지 고민하고 있다. 이 플러그인이 공개된 지 4개월이 넘었는데도 여전히 Claude Code용만 있는 것을 보면, 언리얼도 기본적으로는 Claude를 우선 지원하는 방향으로 가는 것 같다. 그래서 Claude를 계속 쓰는 쪽으로 생각이 더 기운다.</p></div></div></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://github.com/EpicGames/unreal-engine-skills-for-claude-code-plugin" target="_blank">EpicGames/unreal-engine-skills-for-claude-code-plugin</a></li>
<li><a href="https://github.com/EpicGames/unreal-engine-skills-for-claude-code-plugin/issues/11" target="_blank">create-toolset 반환값 규칙에 관한 이슈 #11</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-mcp-in-unreal-editor" target="_blank">Epic Games: Unreal MCP in Unreal Editor</a></li>
<li><a href="https://claude.com/plugins/unreal-engine-skills-for-claude-code" target="_blank">Claude 마켓플레이스: Unreal Engine MCP</a></li>
<li><a href="/posts/unreal-engine-5-8-mcp-and-agentic-game-development/">언리얼 엔진 5.8 MCP로 본 게임 개발과 에이전틱 코딩의 거리</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/claude-code-desktop-october-update/</id>
      <title type="text">Claude Code 데스크톱 앱 업데이트: 사이드 채팅 분리, 하위 세션, 작업 목록</title>
      <published>2026-10-16T00:00:00.000Z</published>
      <updated>2026-10-16T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/claude-code-desktop-october-update/"/>
      <summary type="text">2026년 10월 14일 Claude Code 데스크톱 앱 업데이트로 사이드 채팅을 여러 개 열고 세션으로 분리할 수 있게 됐다. 세션이 띄운 세션은 부모 아래 묶이고, 작업 목록과 요약 카드가 기본으로 보인다. Codex 사용자 입장에서 달라진 점을 정리한다.</summary>
      <content type="html"><![CDATA[<p><a href="/posts/codex-to-claude-code-desktop/">지난 글</a>에서 적었듯이 ChatGPT Pro 200달러 플랜 가입이 막혀 사용량이 부족했던 탓에 이번 달은 Claude Code 데스크톱 앱을 쓰고 있다. 200달러 플랜은 바로 다음 날 다시 열렸기 때문에 한 달이 지나면 Codex로 돌아갈 생각이었다.</p>
<p>그런데 10월 14일(미국 시간)에 <a href="https://x.com/ClaudeDevs/status/2110445844675838148" target="_blank">ClaudeDevs가 데스크톱 앱 업데이트를 발표</a>했다. 여러 세션을 나란히 돌리는 사용 방식에 맞춰 세션을 더 빨리 시작하고 일을 여러 세션으로 나누고 각 세션이 무엇을 하는지 볼 수 있게 했다는 내용이다. 지난 글에서 아쉬운 점으로 적은 사이드 채팅도 이번에 바뀌었다.</p>
<div><div><div></div><div>확인 환경</div></div><div><p>Windows 11, Claude 데스크톱 앱 <code>2.37337.1</code>에서 2026년 10월 15일에 확인했다. 공식 문서는 아직 이번 업데이트 내용을 반영하지 않은 부분이 있어서 사이드 채팅과 하위 세션은 직접 확인한 동작을 기준으로 적었다.</p></div></div>
<section><h2>사이드 채팅을 여러 개 열고 세션으로 분리할 수 있다<a href="#사이드-채팅을-여러-개-열고-세션으로-분리할-수-있다"><span>#</span></a></h2><p>지난 글을 쓸 때는 사이드 채팅을 하나만 열 수 있었다. 이제는 <code>Ctrl+;</code>나 <code>/btw</code>로 연 사이드 채팅이 오른쪽 패널에 탭으로 쌓인다. 탭마다 따로 대화하고 각 탭 모두 메인 세션의 맥락을 읽는다.</p><p></p><figure><img alt="오른쪽 패널에 사이드 채팅 탭 두 개가 열려 있는 화면" loading="lazy" width="595" height="290" src="/_astro/side-chat-tabs.DtAqs01W_Z1gDb0k.webp" /><figcaption>오른쪽 패널에 사이드 채팅 탭 두 개가 열려 있는 화면</figcaption></figure><p></p><p>기록이 쌓이는 방식도 달라졌다. 예전에는 사이드 채팅이 하나뿐이라 닫았다가 다시 열어도 이전 사이드 채팅 기록이 계속 이어서 쌓였다. 지금은 열 때마다 새 사이드 채팅이 만들어지는 것 같다. 질문마다 깨끗한 사이드 채팅에서 시작할 수 있어서 앞선 질문의 대화가 다음 질문에 섞이지 않는다.</p><p>탭 메뉴에는 <code>최상위 레벨로 분리</code>와 <code>사이드 채팅 닫기</code>가 있다.</p><p></p><figure><img alt="사이드 채팅 탭 메뉴의 최상위 레벨로 분리 항목" loading="lazy" width="595" height="290" src="/_astro/side-chat-detach-menu.B5F5LQm5_ocD07.webp" /><figcaption>사이드 채팅 탭 메뉴의 최상위 레벨로 분리 항목</figcaption></figure><p></p><p><code>최상위 레벨로 분리</code>를 누르면 사이드바에 <code>btw:</code>로 시작하는 일반 세션이 생기고 패널에는 나머지 탭만 남는다. 분리한 세션에서도 그대로 대화를 이어갈 수 있다.</p><p></p><figure><img alt="분리한 사이드 채팅이 사이드바에 btw 세션으로 올라오고 패널에는 첫 번째 탭만 남은 화면" loading="lazy" width="844" height="290" src="/_astro/side-chat-detached.BvxNvKUL_1V8pSV.webp" /><figcaption>분리한 사이드 채팅이 사이드바에 btw 세션으로 올라오고 패널에는 첫 번째 탭만 남은 화면</figcaption></figure><p></p><p>써보니 사이드 채팅을 세션 포크처럼 쓰는 기능에 가깝다. 실제로 Claude에게 세션 정보를 조회하게 해보면 분리한 세션은 원래 세션을 부모로 기록하고 있고 분리된 상태(<code>detached</code>)로 표시된다. 지난 글에서 Codex의 사이드 채팅을 포크처럼 쓸 수 있어서 더 편했다고 적었는데, 이제는 Claude Code에서도 비슷하게 쓸 수 있다.</p><p>공식 문서는 아직 사이드 채팅을 앱을 닫으면 다시 돌아갈 수 없는 대화로 설명한다. 남겨 둘 대화라면 분리해서 세션으로 만들어 두면 된다.</p></section>
<section><h2>세션이 띄운 세션은 부모 아래에 묶인다<a href="#세션이-띄운-세션은-부모-아래에-묶인다"><span>#</span></a></h2><p>Claude가 작업 도중 다른 세션을 시작하면 새 세션은 사이드바에서 원래 세션 아래에 묶여 표시된다. 하위 세션이 턴을 마치면 결과 요약이 부모 세션으로 돌아온다. 9월 중순에도 사이드바를 <a href="https://github.com/anthropics/claude-code/issues/95195" target="_blank">부모/자식 트리로 보여 달라는 요청</a>이 올라왔을 만큼 이전에는 세션 사이의 관계가 사이드바에 드러나지 않았다.</p><p>이 글을 준비하면서도 Codex 쪽 동작을 조사하는 일을 하위 세션으로 맡겼다. Claude가 조사 세션을 카드로 제안했고 카드를 눌러 시작하자 이 세션 아래에 붙었다. 조사가 끝나자 결과가 원래 세션으로 전달됐다.</p><p></p><figure><img alt="사이드바에서 원래 세션 아래에 조사용 하위 세션이 묶여 표시된 화면" loading="lazy" width="225" height="248" src="/_astro/child-session-sidebar.sibzFG9C_ZaeA20.webp" /><figcaption>사이드바에서 원래 세션 아래에 조사용 하위 세션이 묶여 표시된 화면</figcaption></figure><p></p><p>Codex에서는 조율하는 작업이 별도 작업(백그라운드 스레드)을 만들어 일을 나누는 식으로 써왔다. 비교하려고 Codex에서도 작업 하나에 별도 작업 두 개를 만들게 해봤다.</p><p></p><figure><img alt="Codex에서 만든 별도 작업 두 개가 사이드바에 원래 작업과 같은 레벨로 나열되고 오른쪽 패널에 생성된 작업으로 표시된 화면" loading="lazy" width="1095" height="485" src="/_astro/codex-created-tasks.DQKMet1Y_Z2x2nGQ.webp" /><figcaption>Codex에서 만든 별도 작업 두 개가 사이드바에 원래 작업과 같은 레벨로 나열되고 오른쪽 패널에 생성된 작업으로 표시된 화면</figcaption></figure><p></p><p>만들어진 작업은 사이드바에 원래 작업과 같은 레벨로 나열된다. 원래 작업의 오른쪽 패널에 <code>생성된 작업</code>으로 표시되기는 하지만 사이드바는 트리 구조가 아니다. 생성된 작업 쪽에 들어가 있으면 어느 작업이 만들었는지 알기 어려울 것 같다. 실제로 작업이 늘어나면 어느 대화에서 시작된 작업인지 헷갈렸다. Claude Code에서는 시작한 세션 아래에 묶여 있어서 이 관계가 바로 보인다.</p><p>Codex도 <a href="https://learn.chatgpt.com/docs/agent-configuration/subagents" target="_blank">서브에이전트</a>는 각자의 스레드를 열어 진행 상황을 볼 수 있고 부모 아래 묶어 보여주는 쪽이 의도된 동작으로 보인다. 다만 8월 이후 일부 데스크톱 빌드에서 서브에이전트 스레드가 최상위 목록에 따로 뜬다는 <a href="https://github.com/openai/codex/issues/38780" target="_blank">이슈</a>가 올라와 있다. 내가 불편했던 쪽은 서브에이전트가 아니라 별도 작업이었다.</p></section>
<section><h2>작업 과정이 기본으로 보인다<a href="#작업-과정이-기본으로-보인다"><span>#</span></a></h2><p>이제 Claude가 여러 단계의 일을 하면 마지막 메시지 아래에 작업 목록이 붙는다. 진행 중인 단계와 남은 단계가 표시되고 계획이 바뀌면 목록도 함께 바뀐다.</p><p></p><figure><img alt="메시지 아래에 진행 중인 단계와 남은 단계가 표시된 작업 목록" loading="lazy" width="909" height="228" src="/_astro/task-list.B3dRZxW-_Z2nlqaS.webp" /><figcaption>메시지 아래에 진행 중인 단계와 남은 단계가 표시된 작업 목록</figcaption></figure><p></p><p>예전에는 진행 상황을 보려고 ledger나 체크포인트 문서 같은 외부 파일을 따로 만들고 세션이 그 파일을 확인하면서 단계가 끝날 때마다 보고하게 했다. 이제는 그런 파일 없이도 지금 어느 단계인지 화면에서 바로 보인다.</p><p>턴이 끝나면 요약 카드도 뜬다. 무엇이 바뀌었고 어디까지 됐는지를 두세 문장으로 정리하고 사용자가 정해야 할 일이 있으면 질문과 선택 버튼을 함께 보여준다. 업데이트 소개 기사에 따르면 사이드바에서도 작업 중인 세션과 입력을 기다리는 세션이 구분된다.</p><p></p><figure><img alt="턴 끝에 요약과 질문, 선택 버튼이 표시된 요약 카드" loading="lazy" width="610" height="112" src="/_astro/recap-card.DycxwVvy_ZhykFz.webp" /><figcaption>턴 끝에 요약과 질문, 선택 버튼이 표시된 요약 카드</figcaption></figure><p></p></section>
<section><h2>새 세션 화면과 환경 저장<a href="#새-세션-화면과-환경-저장"><span>#</span></a></h2><p>새 세션 화면도 바뀌었다. <code>새로 생성</code>을 누르면 화면 가운데에 최근에 쓴 폴더 목록과 <code>환경 선택</code> 메뉴가 뜬다. 환경 선택에서는 로컬, 클라우드, Remote Control, WSL을 고를 수 있고 <code>새 환경</code>으로 구성을 추가할 수 있다. 로컬 환경에는 <code>Alt+1</code>, <code>Alt+2</code> 같은 단축키가 붙는다. CLI에서 시작한 세션을 가져오는 버튼도 같은 화면에 있다.</p><p></p><figure><img alt="새 세션 화면의 최근 폴더 목록과 CLI 세션 가져오기 버튼, 펼친 환경 선택 메뉴" loading="lazy" width="560" height="330" src="/_astro/new-session-screen.BwTgc82V_Z1PLujw.webp" /><figcaption>새 세션 화면의 최근 폴더 목록과 CLI 세션 가져오기 버튼, 펼친 환경 선택 메뉴</figcaption></figure><p></p><p><a href="https://alphasignal.ai/news/anthropic-s-claude-code-now-runs-nested-sessions-and-saves-dev-environments" target="_blank">업데이트 소개 기사</a>에 따르면 저장한 환경에는 여러 폴더와 SSH 호스트나 클라우드 대상, 폴더별 worktree 사용 여부가 들어간다.</p><p>지난 글에서 여러 폴더를 묶는 과정이 어색하다고 적었는데, 환경 저장이 그 부분을 겨냥한 기능으로 보인다. 아직 직접 써보지는 않아서 Codex의 프로젝트처럼 쓸 수 있는지는 확인하지 못했다.</p></section>
<section><h2>정리<a href="#정리"><span>#</span></a></h2><p>한 달이 지나면 Codex로 돌아갈 생각이었다. 그런데 지난 글에서 아쉬운 점으로 적은 사이드 채팅이 2주도 안 돼서 바뀌었고, 세션이 띄운 세션을 묶어 보여주는 방식은 Codex보다 낫다고 느꼈다. 돌아갈지는 조금 더 써보고 정하려 한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://x.com/ClaudeDevs/status/2110445844675838148" target="_blank">ClaudeDevs: Claude Code 데스크톱 앱 업데이트 발표</a></li>
<li><a href="https://alphasignal.ai/news/anthropic-s-claude-code-now-runs-nested-sessions-and-saves-dev-environments" target="_blank">AlphaSignal: Anthropic’s Claude Code Now Runs Nested Sessions and Saves Dev Environments</a></li>
<li><a href="https://code.claude.com/docs/en/desktop" target="_blank">Claude Code Docs: Desktop application</a></li>
<li><a href="https://learn.chatgpt.com/docs/agent-configuration/subagents" target="_blank">Codex Docs: Subagents</a></li>
<li><a href="https://github.com/anthropics/claude-code/issues/95195" target="_blank">anthropics/claude-code#95195: Desktop sidebar: show sessions as a parent/child tree</a></li>
<li><a href="https://github.com/openai/codex/issues/38780" target="_blank">openai/codex#38780</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-replication-graph/</id>
      <title type="text">Unreal Engine - Replication Graph</title>
      <published>2026-10-10T00:00:00.000Z</published>
      <updated>2026-10-13T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-replication-graph/"/>
      <summary type="text">UE 5.8.3 소스와 테스트 프로젝트 실측으로 Replication Graph가 기본 드라이버의 무엇을 줄이고 무엇을 줄이지 않는지 확인한다. 액터 5,000개와 클라이언트 16개까지의 측정, 효과가 있는 설정값과 이름과 반대로 동작하는 설정값, 커스텀 그래프의 라우팅과 Team Node, 5.8.3에서 재현한 버그 다섯 가지, 도입 기준과 Iris와의 관계를 다룬다.</summary>
      <content type="html"><![CDATA[<div><div><div></div><div>TL;DR</div></div><div><ul>
<li>리플리케이션 그래프는 기본 드라이버가 매 프레임 모든 액터 × 모든 연결에 대해 하는 relevancy 검토를, 프레임을 넘어 유지되는 노드 리스트로 바꾼다. 어느 연결에 무엇을 보낼지가 같다면 송신량도 같고 보내는 액터 하나당 채널 밖 비용은 기본 드라이버보다 0.5 µs쯤 더 든다. 송신량은 frequency bucket처럼 보내는 횟수를 낮추는 도구를 썼을 때만 줄어든다.</li>
<li>액터 5,000개, 클라이언트 2개에서 프레임당 <code>ServerReplicateActors</code>는 맵 전체에 흩어진 조건에서 기본 2.54 ms, 리플리케이션 그래프 0.35 ms이고 모두 한곳에 몰린 조건에서 8.09 ms와 4.48 ms다. 밀집 조건의 차이는 frequency bucket이 dynamic 액터를 세 프레임에 한 번만 후보로 올려서 생겼다. 보내는 횟수를 맞추면 리플리케이션 그래프가 22% 느리다. 기본 드라이버도 <code>NetUpdateFrequency</code>를 10 Hz로 낮추면 3.06 ms로 떨어진다.</li>
<li>액터 수와 연결 수에 기본 드라이버는 선형(액터당 0.46 µs, 연결당 0.78 ms)이고 리플리케이션 그래프의 연결별 비용은 viewer 근처 액터 수를 따른다.</li>
<li>기본 드라이버에서 잠든 액터는 모든 연결에서 잠든 뒤에야 consider list에서 빠진다. 넓은 맵에 흩어진 잠든 소품은 매 프레임 검토되고 플레이어마다 자기 주변에만 액터가 있는 조건에서 밀집 조건에 가까운 6.47 ms가 나온다.</li>
<li>설정값 중 측정에서 효과가 컸던 것은 버킷 수와 <code>NetUpdateFrequency</code>뿐이다. <code>DistancePriorityScale</code> 0은 거리 컬링을 끄고 <code>ActorChannelFrameTimeout</code> 0은 채널을 닫지 않는다. 대역폭이 포화되면 <code>StarvationPriorityScale</code>이 공평성을 정한다. 0이면 가까운 액터만 갱신되고 접속 후 120초가 지나도 액터의 62%만 받는다. <code>NetUpdateFrequency</code>는 서버 프레임 레이트보다 낮을 때만 뜻이 있다.</li>
<li>엔진에는 액터가 움직이는지 판별하는 코드가 없다. <code>UBasicReplicationGraph</code>는 모든 액터를 dormancy 경로로 넣는다. 그래도 OpenWorld 그래프와 같은 자릿수(Uniform 0.40 ms, Cluster 3.44 ms)가 나온다. Team Node와 owner 변경이 필요 없으면 거기서 멈춰도 된다.</li>
<li>5.8.3에서 재현한 것: 잠재운 직후의 reliable multicast가 지연이 있을 때 리플리케이션 그래프에서만 유실된다. static으로 넣은 액터는 옮겨도 옛 Cell에 남는다. <code>bNetTemporary</code> 발사체는 ensure가 나고 매 버킷 차례마다 채널을 다시 연다. 끊긴 연결의 dormancy 사본은 기본 설정에서 지워지지 않는다. 멀어진 클라이언트에 있는 잠든 액터를 파괴하면 그 클라이언트가 돌아올 때까지 남는다.</li>
<li><code>stat game</code>에서 consider와 prioritize 시간이 <code>ReplicateActor</code> 시간보다 크면 리플리케이션 그래프가 그 몫을 줄인다. Iris와는 양자택일이다. Epic은 deprecate 계획이 없다고 하지만 릴리스 노트에 5.6 이후 항목이 없다.</li>
</ul></div></div>
<p><img alt="" loading="lazy" width="1600" height="1160" src="/_astro/diagram-notd-graph.C53yXxRT_ZrFgD2.webp" /></p>
<p><a href="/projects/night-of-the-dead/">Night of the Dead</a>는 밤마다 좀비가 몰려오는 오픈월드 생존 게임이고 Dedicated Server 멀티플레이를 지원한다. 2021년부터 2024년 6월까지 멀티플레이 동기화를 맡으면서 리플리케이션 그래프를 UE4에서 실험적으로 도입했다. UE5로 옮기면서 Lyra의 구성을 따라 위 그림처럼 개편했다. 리플리케이션 그래프는 켜면 서버가 가벼워진다고 알려져 있지만 무엇이 줄고 무엇이 그대로인지는 설명이 드물다. Iris를 공부하기 전에 리플리케이션 그래프를 최신 기준으로 더 완전히 이해하려고 5.8.3 소스와 측정으로 다시 확인했다.</p>
<p>테스트 프로젝트에서 기본 드라이버, <code>UBasicReplicationGraph</code>, 커스텀 그래프를 같은 조건으로 측정했다. 소스 경로는 UE 5.8.3 기준이다. 엔진 파일은 <code>Engine/Source/Runtime/</code>을, 플러그인 파일은 <code>Engine/Plugins/Runtime/ReplicationGraph/Source/</code>를 생략했다.</p>
<section><h2>기본 드라이버가 하는 일과 리플리케이션 그래프가 바꾸는 것<a href="#기본-드라이버가-하는-일과-리플리케이션-그래프가-바꾸는-것"><span>#</span></a></h2><p><code>UNetDriver::ServerReplicateActors</code>는 매 네트워크 틱마다 세 단계를 거친다(<code>Engine/Private/NetDriver.cpp</code>).</p><ol>
<li><strong>Consider</strong>: <code>FNetworkObjectList</code>의 활성 액터를 순회해 <code>NetUpdateFrequency</code>로 계산한 갱신 시각이 지난 액터를 모은다. dormant 액터는 별도 목록에 있어 이 순회에서 빠진다.</li>
<li><strong>Prioritize</strong>: 연결마다 consider list의 액터 하나하나에 <code>AActor::IsNetRelevantFor</code>와 <code>GetNetPriority</code>를 호출해 정렬한다.</li>
<li><strong>Replicate</strong>: 정렬된 순서로 채널을 열어 <code>UActorChannel::ReplicateActor</code>를 호출한다.</li>
</ol><p>두 번째 단계에 비용이 집중된다. <code>IsNetRelevantFor</code>와 <code>GetNetPriority</code>는 가상 함수라 엔진은 답이 언제 바뀌는지 알 수 없다. 그래서 매 프레임 모든 액터 × 모든 연결에 대해 다시 부른다. relevancy 검사를 통과한 액터만 세 번째 단계에 들어가므로 넓은 맵에 액터가 흩어진 게임일수록 두 번째 단계의 비용이 실제 송신 작업보다 커진다. 액터가 모두 한곳에 몰려 모든 연결에 관련 있으면 두 번째 단계는 어차피 다 통과하고 세 번째 단계가 지배한다.</p><p>리플리케이션 그래프는 <code>UReplicationDriver</code>를 구현한 <code>UReplicationGraph</code>다. 서버의 <code>UNetDriver</code>에 리플리케이션 드라이버가 설정돼 있으면 <code>ServerReplicateActors</code>는 stat·CSV 범위를 열고 메트릭을 리셋한 뒤 바로 <code>ReplicationDriver-&gt;ServerReplicateActors</code>로 넘긴다. consider list 구성과 우선순위 계산은 돌지 않는다. 플러그인 안에서 <code>IsNetRelevantFor</code>를 부르는 곳은 디버그 액터의 오버라이드뿐이다. 프로퍼티 비교와 직렬화는 그대로 <code>UActorChannel::ReplicateActor</code>가 한다.</p><p>리플리케이션 그래프는 프레임을 세 단계로 처리한다.</p><ol>
<li><strong>Gather</strong>: 연결마다 전역 노드와 그 연결의 노드에 <code>GatherActorListsForConnection</code>을 호출해 후보 리스트를 모은다. 노드는 대부분 프레임을 넘어 유지되는 리스트를 돌려주므로 어떤 액터를 고려할지를 다시 계산하지 않는다. 기본 드라이버의 consider list 구성에 해당하는 단계다.</li>
<li><strong>Prioritize</strong>: 모은 리스트의 액터마다 dormancy, 리플리케이션 주기, 거리 컬링을 적용하고 우선순위 점수를 매겨 정렬한다.</li>
<li><strong>Replicate</strong>: 정렬된 순서로 <code>ReplicateSingleActor</code>를 호출하고 연결이 포화되면 멈춘다.</li>
</ol><p><img alt="" loading="lazy" width="1600" height="1328" src="/_astro/diagram-frame.BgVMMLCE_17fmjp.webp" /></p><p>공간 분할 노드인 Grid(<code>UReplicationGraphNode_GridSpatialization2D</code>)는 거리 검사를 대체하지 않는다. Grid는 맵을 정사각형 Cell로 나누고 viewer가 서 있는 Cell의 액터 리스트를 돌려줄 뿐이다. Cell에는 Net Cull Distance(액터의 <code>NetCullDistanceSquared</code>, 이하 Cull Distance) 범위가 Cell과 겹치는 모든 액터가 들어 있으므로 반경 밖 액터도 섞여 있다. 실제 거리 검사는 2단계에서 연결별 <code>CullDistanceSquared</code>로 따로 한다. 2018년 Epic 라이브스트림의 표현으로는 Grid가 broad phase, 거리 검사가 narrow phase다.</p><p>노드도 매 틱 조건을 검사하지 않는다. 노드의 역할은 조건을 한 번 평가해 리스트에 넣어 두고 바뀔 때만 옮기는 것이다. 매 틱 돌아가는 액터별 검사는 2단계에 모여 있다.</p><p>리플리케이션 그래프는 서버 CPU를 줄인다. 어떤 액터를 어느 연결에 보낼지가 같다면 보내는 바이트도 같다. 다만 리플리케이션 그래프 쪽에는 기본 드라이버에 없는 송신 측 도구가 둘 있다. dynamic 액터를 <code>NumBuckets</code>개 버킷에 나눠 두고 <code>ReplicationFrameNum % NumBuckets</code>번 버킷만 돌려주는 frequency bucket 노드와, 이동 상태를 한 번 직렬화해 모든 연결에 재사용하는 Fast Shared Path다.</p><p>Fast Shared Path는 액터 쪽에 <code>FastSharedReplicationFunc</code>로 등록할 unreliable multicast 함수를 써야 하고 그 프레임에는 일반 프로퍼티 리플리케이션이 생략되는 경로다. 그래서 측정에서는 켜지 않았다(<a href="https://dev.epicgames.com/community/learning/knowledge-base/rm2x/unreal-engine-replication-graph-fast-shared-path-starting-guide" target="_blank">Epic의 Fast Shared Path 가이드</a>에 설정 절차가 있다). 테스트 액터에는 이동 상태가 없다. 효과도 Player Pawn 수 × 연결 수에서 나오므로 클라이언트 16개로는 보여 줄 폭이 작다.</p></section>
<section><h2>측정: 액터 5,000개, 클라이언트 16개까지<a href="#측정-액터-5000개-클라이언트-16개까지"><span>#</span></a></h2><section><h3>실험 구성<a href="#실험-구성"><span>#</span></a></h3><p>측정 환경은 <a href="/posts/unreal-engine-push-model-networking/">Push Model 포스트</a>와 같은 테스트 프로젝트에 측정 모듈을 더한 것이다.</p><ul>
<li>맵: 4 km × 4 km의 평평한 바닥에 PlayerStart 하나만 있는 측정 전용 레벨</li>
<li>프로세스: 에디터 바이너리를 <code>-server</code>로 띄운 Dedicated Server와 <code>-game -nullrhi -nosound</code>로 띄운 헤드리스 클라이언트. 한 PC(Ryzen 9 5950X)에서 서버와 클라이언트를 서로 다른 CCD에 고정</li>
<li>지표: 서버 프레임은 약 46 ms(초당 21 프레임)이고 그중 약 41 ms가 틱 레이트 대기라 비교는 프레임당 <code>ServerReplicateActors</code> 배타 시간으로 한다</li>
<li>대역폭: 클라이언트 rate 제한을 풀어 CPU만 잰다. 포화 조건 절에서만 30 KB/s로 제한한다</li>
<li>엔진: <code>++UE5+Release-5.8-CL-58210709</code></li>
</ul><p>테스트 프로젝트의 서브시스템은 커맨드라인으로 조건을 받아 다음 순서로 돈다.</p><ol>
<li>클라이언트 N개가 붙으면 Player Pawn을 배치하고 둘씩 같은 팀으로 묶는다. 플레이어마다 owner 전용 인벤토리 액터를 하나 스폰한다.</li>
<li>테스트 액터 5,000개를 소품(노란 상자) 60%, 루트(loot, 초록 구) 20%, AI(빨간 기둥) 15%, 탈것(파란 상자) 5%로 나눠 스폰한다. 탈것마다 dependent actor인 무기(하늘색 원뿔)를 하나 더 붙인다. 전역 액터 3개가 추가된다. 무기와 Pawn, 인벤토리까지 합치면 리플리케이션 액터는 5,265개다. 모든 테스트 액터는 리플리케이션 프로퍼티 16개를 push 기반으로 등록한다.</li>
<li>60초 warmup. 절반 지점에서 소품의 90%를 <code>DORM_DormantAll</code>로 보낸다. 루트는 처음부터 dormant다.</li>
<li>60초 측정. CSV 프로파일러 캡처와 Insights 트레이스 리전을 연다. 깨어 있는 액터의 1%가 초마다 프로퍼티 하나씩을 바꾸고 루트의 0.1%가 초마다 flush 후 쓰기를 한다. AI는 초당 3 m로 직선 이동하며 영역 경계에서 튕긴다.</li>
</ol><p><img alt="" loading="lazy" width="1600" height="800" src="/_astro/lab-client-view.Dybahbvh_2sjz88.webp" /></p><p>클라이언트에서 본 테스트 액터다. 색은 위의 종류 구분이고 마네킹이 Player Pawn이다.</p><p>공간 분포는 세 가지다. Cull Distance는 모든 테스트 클래스에서 150 m(<code>NetCullDistanceSquared</code> 기본값)다.</p>

<table><thead><tr><th>분포</th><th>액터 위치</th><th>플레이어 위치</th><th>상황</th></tr></thead><tbody><tr><td>Uniform</td><td>2 km × 2 km에 균일</td><td>스쿼드별로 흩어짐</td><td>게임 중반. Cull Distance 안에 액터의 약 2%</td></tr><tr><td>Cluster</td><td>PlayerStart 중심 Cull Distance의 80% 안</td><td>모두 PlayerStart</td><td>핫드롭. 모든 액터가 모든 연결에 관련</td></tr><tr><td>NearPlayers</td><td>플레이어마다 자기 주변 Cull Distance의 80% 안</td><td>맵 곳곳에 흩어짐</td><td>중간 지점. 플레이어마다 자기 몫만 보이고, 2인이면 각자 절반</td></tr></tbody></table><p><img alt="" loading="lazy" width="1600" height="880" src="/_astro/diagram-distributions.BKFOfjHS_Z1MUPdQ.webp" /></p><p>드라이버는 셋이다.</p><ul>
<li>기본 드라이버(<code>Generic</code>)</li>
<li>플러그인의 <code>UBasicReplicationGraph</code>. 모든 액터를 dormancy 경로로 넣는다</li>
<li>뒤의 커스텀 그래프 절에서 만드는 <code>UOpenWorldReplicationGraph</code>(이하 OpenWorld 그래프). 소품을 static, 루트를 dormancy, AI와 탈것을 dynamic 경로로 Grid에 넣고 dynamic 액터에 frequency bucket 3개를 쓴다</li>
</ul><p>주 비교 조건은 5회, 나머지는 3회 반복해 평균을 쓴다. 실행마다 프레임 시간이 흔들리므로 프레임당 ms와 함께 Insights의 호출 수(<code>Prioritize_InnerLoop</code>가 검토한 액터 수, <code>Replicate Actor Time</code>의 호출 수)도 본다. 밀집 조건에는 보내는 횟수를 맞춘 대조군 둘을 더했다. OpenWorld 그래프의 버킷을 1개로 줄인 것과, 기본 드라이버의 <code>NetUpdateFrequency</code>를 10으로 낮춘 것이다.</p><p>Unreal Insights에는 리플리케이션 그래프 전용 트랙이 없지만 <code>-statnamedevents</code>로 띄우면 타이머가 보인다. 아래는 OpenWorld 그래프의 Cluster 런에서 서버 프레임 하나를 확대한 것이다.</p><p><img alt="" loading="lazy" width="1600" height="206" src="/_astro/insights-frame-timeline.BCVYcD5z_HVVCn.webp" /></p><p>① <code>UReplicationGraph_ServerReplicateActors</code> 4.56 ms 아래에 <code>NET_PrepareReplication</code>(Grid의 dynamic 액터 Cell 갱신이 여기)이 한 번 온다. 그 뒤에 연결마다 <code>NET_ReplicateActors_GatherForConnection</code>과 <code>NET_ReplicateActors_ProcessGatheredLists</code>(안에 <code>PrioritizeForConnection</code>과 <code>ReplicateActorsForConnection</code>), <code>NET_ReplicateActors_LookForNonRelevantChannels</code>가 이어진다. ② ③은 두 연결의 <code>ProcessGatheredLists</code>로 2.69 ms와 1.61 ms다. 프레임의 나머지 약 41 ms는 틱 레이트 대기다.</p><p>이 타이머 중 <code>RG_QUICK_SCOPE_CYCLE_COUNTER</code>로 선언된 <code>Prioritize_InnerLoop*</code>, <code>NET_ReplicateActors_ReplicateSingleActor</code> 등은 Test·Shipping에서 컴파일되지 않는다.</p></section><section><h3>드라이버와 분포<a href="#드라이버와-분포"><span>#</span></a></h3><p>아래는 프레임당 <code>ServerReplicateActors</code> ms의 5회 평균과 표준편차다.</p><p><img alt="" loading="lazy" width="1600" height="800" src="/_astro/chart-drivers.D5DOKaGX_Z6kvVY.webp" /></p>

<table><thead><tr><th>분포</th><th>기본 드라이버</th><th>Basic 그래프</th><th>OpenWorld 그래프</th></tr></thead><tbody><tr><td>Cluster</td><td>8.09 ± 0.09</td><td>3.44 ± 0.03</td><td>4.48 ± 0.04</td></tr><tr><td>NearPlayers</td><td>6.47 ± 0.47</td><td>2.25 ± 0.02</td><td>2.84 ± 0.02</td></tr><tr><td>Uniform</td><td>2.54 ± 0.20</td><td>0.40 ± 0.01</td><td>0.35 ± 0.02</td></tr></tbody></table><p>기본 드라이버의 NearPlayers와 Uniform은 런마다 서버 프레임 시간이 41–47 ms로 흔들려 편차가 ±8%이고 나머지 조건은 ±5% 안이다. 같은 기본 드라이버 Uniform 조건을 다른 측정 세트에서 다시 돌리면 2.05–2.23 ms가 나온다. 표의 2.54는 가장 높은 세트의 값이다. 아래 단계별 분해는 위의 Insights 타이머로 했다. 트레이스가 있는 첫 번째 런 하나의 값이라 표의 평균과 조금 다르다.</p><p>Uniform에서 리플리케이션 그래프는 기본 드라이버의 7분의 1이다. 단계별로 나누면 다음과 같다.</p>

<table><thead><tr><th>단계</th><th>기본 드라이버 (ms)</th><th>OpenWorld 그래프 (ms)</th></tr></thead><tbody><tr><td>Consider / Gather</td><td>consider list 구성 1.7</td><td><code>PrepareForReplication</code> 0.12, gather 0.01</td></tr><tr><td>Prioritize</td><td>두 연결 0.9</td><td>0.04 (검토 항목 71개)</td></tr><tr><td>Replicate</td><td>0.2 (프레임당 67회)</td><td>0.13</td></tr><tr><td>합계</td><td>2.8</td><td>0.3</td></tr></tbody></table><p>기본 드라이버는 5,265개 중 연결당 30개 남짓만 보내는데 비용의 90% 이상이 보낼지 말지를 정하는 데 든다. OpenWorld 그래프는 Grid가 viewer Cell의 리스트만 돌려주므로 나머지 5,100개는 검토 대상이 아니다.</p><p>모든 액터가 모든 연결에 관련 있는 Cluster에서도 OpenWorld 그래프가 기본 드라이버의 55%, Basic이 43%다. 다만 이 차이는 판별 비용이 아닌 보내는 횟수에서 온다.</p>

<table><thead><tr><th>드라이버</th><th>연결당 <code>ReplicateActor</code> 호출</th><th>구성</th></tr></thead><tbody><tr><td>기본 드라이버</td><td>1,561</td><td>깨어 있는 액터 전부를 매 프레임</td></tr><tr><td>Basic 그래프</td><td>524</td><td>깨어 있는 1,580개 전부가 버킷 3개에 나뉘어 세 프레임에 한 번</td></tr><tr><td>OpenWorld 그래프</td><td>735</td><td>AI·탈것 1,000개가 버킷에서 333회, 깨어 있는 소품 315개가 static 리스트에서 매 프레임, dependent 무기가 탈것에 묻어 83회</td></tr></tbody></table><p>보내는 횟수를 맞춘 대조군 둘의 결과다.</p><p><img alt="" loading="lazy" width="1600" height="800" src="/_astro/chart-cluster-breakdown.Kwfl4zCl_1L2ste.webp" /></p><p>기본 드라이버의 <code>NetUpdateFrequency</code>를 10으로 낮추면 호출이 연결당 501회로 줄고 3.06 ms가 된다. 서버가 초당 21–23 프레임이라 10 Hz는 세 프레임에 한 번꼴이다.</p><p>반대로 OpenWorld의 버킷을 1개로 줄이면 호출 수가 1,555회로 100 Hz 기본 드라이버의 1,561회와 거의 같아진다. 이때 리플리케이션 그래프는 9.89 ms로 기본 드라이버보다 22% 느리다.</p><p>네 조건의 호출당 비용은 2.6–3.2 µs로 같은 자릿수다. 밀집 조건의 비용은 보내는 횟수가 정하고 그 횟수는 어느 드라이버든 설정으로 바꿀 수 있다.</p><p>아래 두 그림은 Cluster 조건에서 측정 구간 60초의 Callees 트리를 펼친 것이다.</p><p></p><figure><img alt="Callees 트리: 기본 드라이버" loading="lazy" width="1510" height="240" src="/_astro/insights-callees-default.CDy69CHA_1GzyrE.webp" /><figcaption>Callees 트리: 기본 드라이버</figcaption></figure><p></p><p></p><figure><img alt="Callees 트리: 리플리케이션 그래프, 버킷 1개" loading="lazy" width="1510" height="352" src="/_astro/insights-callees-buckets1.BTFs4kqT_Z8yuMy.webp" /><figcaption>Callees 트리: 리플리케이션 그래프, 버킷 1개</figcaption></figure><p></p><p>① <code>UActorChannel::ReplicateActor</code> 안의 시간은 7.51 s와 7.22 s, 프레임당 5.80 ms와 5.55 ms로 같고 호출당 약 1.8 µs다. 후보를 고르는 단계도 비슷하다. 첫 번째 그림의 기본 드라이버는 ② 두 연결의 우선순위 1.04 s와 ③ consider list 894.94 ms(프레임당 0.69 ms)를 합쳐 프레임당 1.49 ms다. 두 번째 그림의 리플리케이션 그래프는 prepare와 gather 0.16 ms에 ③ 우선순위 계산 1.68 s를 합쳐 1.45 ms다.</p><p><code>NET_ReplicateActors_ReplicateSingleActor</code>가 채널 밖에서 하는 일에서 차이가 난다. 두 번째 그림의 ② <code>DependentActors</code> 1.72 s와 <code>CallPreReplication</code> 1.1 s가 그 일부다. <code>DependentActors</code>는 inclusive라 그 안에서 재귀 호출되는 무기 액터의 채널 시간이 대부분이고 순회 자체(Excl 열)는 0.24 s다.</p>

<table><thead><tr><th>리플리케이션 그래프의 채널 밖 작업</th><th>ms</th></tr></thead><tbody><tr><td><code>ReplicateSingleActor</code> 자체. dependent 목록 수집, 연결 정보의 프레임 번호 갱신, 채널 조회</td><td>1.31</td></tr><tr><td>액터마다 <code>PreReplication</code> 호출(<code>CallPreReplication</code>)</td><td>0.85</td></tr><tr><td>dependent actor 순회와 그 채널 닫힘 프레임 갱신(<code>DependentActors</code> 자체)</td><td>0.19</td></tr><tr><td>연결별 리스트 순회와 채널 정리(<code>ReplicateActorsForConnection</code> 잔여, <code>LookForNonRelevantChannels</code>, 후처리)</td><td>0.30</td></tr><tr><td>합계</td><td>2.65</td></tr></tbody></table><p>기본 드라이버에서 <code>ReplicateActor</code> 밖에 남는 리플리케이션 루프 시간은 0.93 ms다. <code>PreReplication</code>은 consider list를 만들면서 부르므로 앞의 consider list 0.69 ms 안에 들어 있다. 리플리케이션한 액터 하나당 채널 밖 비용은 기본 0.30 µs, 리플리케이션 그래프 0.85 µs다. 영속 리스트가 줄이는 것은 검토할 액터 수뿐이고 보내는 액터마다 붙는 부가 비용은 기본 드라이버보다 크다. 보낼 액터가 많을수록 그 차이가 쌓인다.</p><p>NearPlayers는 둘의 중간이어야 하는데 기본 드라이버는 6.47 ms로 Cluster에 가깝다. 보내는 양은 연결당 770회로 Cluster의 절반이다. 그런데 consider list 구성이 1.38 ms로 Cluster의 두 배이고 우선순위 계산이 1.97 ms다.</p><p><img alt="" loading="lazy" width="1600" height="984" src="/_astro/diagram-legacy-dormancy.BK-sBUMe_Z1c5mJx.webp" /></p><p>테스트 프로젝트는 소품의 90%와 루트 전부를 <code>DORM_DormantAll</code>로 둔다. 그런데 <code>FNetworkObjectList</code>는 액터가 모든 연결에서 dormant가 됐을 때만 active 목록에서 뺀다(<code>Engine/Private/NetworkObjectList.cpp</code>, <code>MarkDormant</code>의 <code>DormantConnections.Num() == NumConnections</code>). Cluster에서는 두 클라이언트가 모든 액터를 받으므로 3,685개가 빠져 active가 1,580개다. 흩어진 분포에서는 각 소품을 한 클라이언트만 받으므로 영영 active에 남아 매 프레임 5,265개 전부가 consider list를 거친다. Uniform의 1.7 ms도 같은 원인이다.</p><p>CSV의 <code>NumberOfFullyDormantActors</code>는 Cluster에서 3,685, NearPlayers에서 0, Uniform에서 60 남짓(두 플레이어가 3 m 거리라 같은 소품 일부를 본다)이다. 일부 연결에서만 dormant인 액터는 그 연결의 우선순위 계산만 건너뛰고 consider list 구성 비용은 그대로 낸다. 목록에서는 모든 연결이 그 액터를 한 번씩 본 뒤에야 빠진다. 넓은 맵에서는 그런 일이 잘 일어나지 않는다. 리플리케이션 그래프는 dormant 액터를 연결별 <code>bDormantOnConnection</code>으로 걸러서 이 문제가 없다.</p></section><section><h3>액터 수와 연결 수<a href="#액터-수와-연결-수"><span>#</span></a></h3><p>아래는 Uniform에서 액터 수를 1,000에서 20,000으로 늘린 결과다.</p><p><img alt="" loading="lazy" width="1600" height="800" src="/_astro/chart-actors.DaC52K0m_ZSBWht.webp" /></p><p>기본 드라이버는 0.59에서 9.69 ms로 거의 선형으로 는다. 액터 하나당 프레임당 0.46 µs다. 20,000개 런의 분해는 consider list 5.2 ms, 우선순위 3.8 ms, 리플리케이션 0.5 ms다.</p><p>OpenWorld 그래프는 0.18에서 0.82 ms로 4.5배 늘고 그 증가분은 열린 채널 수(52 → 254)에 비례한다. 연결별 gather·prioritize·리플리케이션 비용은 맵의 액터 수와 상관없이 viewer 근처의 액터 수를 따른다. 전역 <code>PrepareForReplication</code>만 dynamic 액터 전체의 위치를 매 프레임 읽으므로 그 몫은 액터 수에 비례해 남는다.</p><p>연결 수는 2에서 16으로 늘렸다.</p><p><img alt="" loading="lazy" width="1600" height="800" src="/_astro/chart-connections.DwA_Bf3y_Z28gXFq.webp" /></p><p>연결 수에서는 둘 다 선형이지만 기울기가 6배 다르다. 기본 드라이버는 연결당 0.78 ms, OpenWorld는 0.13 ms다. 연결 16개에서 기본 드라이버의 13.0 ms 중 9.2 ms가 16번의 우선순위 계산이다. 이 숫자는 액터 5,265개 × 연결 16개의 relevancy 검토에서 나온다. 리플리케이션 그래프의 2.14 ms는 리플리케이션 0.9 ms와 나머지 1.2 ms이고 연결마다 자기 Cell의 리스트만 본다.</p></section><section><h3>dormant 비율과 Push Model<a href="#dormant-비율과-push-model"><span>#</span></a></h3><p>소품의 dormant 비율과 Push Model 설정을 바꾼 결과다.</p><p><img alt="" loading="lazy" width="1600" height="800" src="/_astro/chart-dormancy-push.s2ehKhzd_1Jhcou.webp" /></p><p>소품을 모두 깨어 있게 두면 기본 드라이버는 이 세트의 기준값 2.13 ms에서 2.56 ms로, OpenWorld는 0.36에서 0.63 ms로 늘고 기본 드라이버의 채널 수가 73에서 167로 는다. 깨어 있는 소품은 값이 바뀔 때마다 보내지고 채널이 열린 채로 유지된다. 기본 드라이버의 consider list는 앞서 본 대로 어차피 5,265개 전부라 변화는 리플리케이션 단계에만 나타난다.</p><p>Push Model은 리플리케이션 그래프 위에서 그대로 동작하지만 이 부하에서는 잴 수 있는 차이가 없다. 차트의 Push Model 두 조건(끔 2.20/0.39 ms, 켜고 skip 2.39/0.42 ms)과 기준(Push Model 켬, skip 끔)의 차이는 런 편차 안이다.</p><p><code>net.PushModelSkipUndirtiedReplication</code>은 <code>UActorChannel::ReplicateActor</code>가 부르는 <code>FObjectReplicator</code> 안에서 판정된다. 리플리케이션 그래프의 <code>ReplicateSingleActor</code>도 같은 함수를 부르므로 리플리케이션 그래프 쪽에는 Push Model을 의식하는 코드가 없다. 프레임당 리플리케이션이 41건이고 변경률이 1%라 프로퍼티 비교 시간이 0.025 ms에서 0.013 ms로 줄어도 <code>ServerReplicateActors</code>의 3–6%다.</p><p>다만 리플리케이션 그래프는 채널에 들어가기 전에 주기를 소비하므로(프레임당 검사 절) Push Model이 비교를 건너뛰어 0바이트로 끝나도 <code>NumReplicatedCleanActors</code>로 집계된다. 두 최적화는 서로 다른 층을 줄이므로 함께 켜도 손해가 없다.</p></section></section>
<section><h2>수치가 나오는 구조: 세 층, Grid, 프레임당 검사<a href="#수치가-나오는-구조-세-층-grid-프레임당-검사"><span>#</span></a></h2><section><h3>세 층의 상태<a href="#세-층의-상태"><span>#</span></a></h3><p>리플리케이션 그래프는 상태를 세 층으로 나눠 둔다(<code>Public/ReplicationGraphTypes.h</code>).</p>

<table><thead><tr><th>층</th><th>구조체</th><th>만들어지는 때</th></tr></thead><tbody><tr><td>클래스</td><td><code>FClassReplicationInfo</code></td><td>리플리케이션 그래프 초기화의 <code>InitGlobalActorClassSettings</code>. 미등록 클래스는 첫 조회 때 lazy init</td></tr><tr><td>액터</td><td><code>FGlobalActorReplicationInfo</code></td><td><code>AddNetworkActor</code>에서 클래스 정보를 <code>Settings</code>에 복사해 생성</td></tr><tr><td>연결×액터</td><td><code>FConnectionReplicationActorInfo</code></td><td>그 연결이 그 액터를 처음 만질 때 <code>FindOrAdd</code>로 생성. 액터 정보에서 주기와 Cull Distance를 복사</td></tr></tbody></table><p><img alt="" loading="lazy" width="1600" height="964" src="/_astro/diagram-three-layers.WY-CPYUm_Z2aexU2.webp" /></p><p>복사이므로 나중에 윗층을 바꿔도 아래층에는 전파되지 않는다. 클래스 정보를 조회하면 <code>TClassMap::Get</code>이 부모 클래스를 거슬러 올라가 값을 찾은 뒤 요청한 클래스 이름으로 복사해 캐시한다. 그래서 자식 클래스가 한 번이라도 조회된 뒤에 부모 클래스에 <code>SetClassInfo</code>를 해도 그 자식에는 반영되지 않는다. 런타임에 Cull Distance를 바꾸려면 클래스, 액터, 모든 연결의 정보를 따로 고쳐야 한다.</p><ul>
<li>엔진의 <code>SetAllCullDistanceSettingsForActor</code>는 액터와 모든 연결 두 층만 쓰고 클래스는 둔다.</li>
<li>디버그 명령 <code>Net.RepGraph.SetClassCullDistance</code>는 클래스와 액터, 그리고 명령을 보낸 연결 하나만 고친다.</li>
<li>공간화된 액터는 거기에 더해 Grid의 <code>NotifyActorCullDistChange</code>로 Cell 등록을 다시 해야 넓어진 반경의 연결에서 후보가 된다.</li>
</ul><p>뒤의 설정값 절에서 런타임 <code>SetNetUpdateFrequency</code>가 반영되지 않는 것도 같은 구조 때문이다. 거리 검사와 Cell 계산은 액터 정보에 캐시된 <code>WorldLocation</code>을 쓴다.</p></section><section><h3>Grid와 Cell<a href="#grid와-cell"><span>#</span></a></h3><p><code>UReplicationGraphNode_GridSpatialization2D</code>가 공간 분할, 즉 거리 relevancy의 broad phase를 맡는다. 설정은 <code>CellSize</code>, <code>SpatialBias</code>, <code>ConnectionMaxZ</code> 셋이고 모두 생성자에서 0 또는 월드 최대로 초기화되므로 프로젝트가 채워야 한다. 액터는 세 경로로 들어온다.</p>

<table><thead><tr><th>경로</th><th>Cell에 들어가는 때</th><th>위치 갱신</th><th>언제 쓰는가</th></tr></thead><tbody><tr><td><code>AddActor_Static</code></td><td>추가 시 한 번</td><td>없음</td><td>스폰 뒤 안 움직이는 액터</td></tr><tr><td><code>AddActor_Dynamic</code></td><td>매 프레임 <code>PrepareForReplication</code>에서</td><td>매 프레임 <code>GetActorLocation</code></td><td>움직이는 액터</td></tr><tr><td><code>AddActor_Dormancy</code></td><td><code>bWantsToBeDormant</code>면 static, 아니면 dynamic으로 넣고 dormancy 변경 이벤트를 구독</td><td>dormant일 때 없음</td><td>잠들 때는 안 움직이고 깨면 움직이는 액터</td></tr></tbody></table><p>액터 하나가 여러 Cell에 들어간다. <code>GetCellInfoForActor</code>는 위치를 중심으로 Cull Distance만큼의 정사각형을 만들어 그 사각형이 겹치는 모든 Cell에 넣는다. Cull Distance 150 m에 Cell 100 m면 사각형 한 변이 300 m라 한 액터가 4×4 Cell에 들어간다(Grid 가장자리에서만 줄어든다).</p><p>대신 gather는 viewer가 서 있는 Cell 하나만 본다. 이웃 Cell을 찾아다니지 않고 액터 쪽이 미리 퍼져 있는 구조다. 여기에 쓰이는 Cull Distance는 액터 정보의 <code>Settings.GetCullDistance()</code>에서 온다. 연결별 값은 쓰지 않는다.</p><p><img alt="" loading="lazy" width="1600" height="1016" src="/_astro/diagram-grid-cells.C69d-3lw_Z1DojH6.webp" /></p><p>static 액터는 추가될 때의 위치로 Cell이 정해지고 다시 계산되지 않는다. dynamic 액터만 매 프레임 <code>PrepareForReplication</code>에서 위치를 읽어 Cell을 옮긴다. <code>SetBiasAndGridBounds</code>로 경계를 정하지 않은 그래프에서는 <code>SpatialBias</code>보다 작은 좌표에 액터가 나타나면 Grid를 통째로 다시 만든다. 그래서 맵 밖으로 날아가는 발사체 클래스는 <code>AddToClassRebuildDenyList</code>에 넣어 가장자리 Cell로 클램프되게 한다. Lyra는 <code>Lyra.RepGraph.DisableSpatialRebuilds</code>(기본 1)로 <code>AActor</code> 자체를 목록에 넣어 재구축을 끈다.</p><p>Cell 노드는 자기 리스트가 곧 static 리스트다. dynamic 액터는 frequency bucket 노드로, dormant 액터는 dormancy 노드로 간다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GridSpatialization2D</span></div></div><div><div><div>2</div></div><div><span>└── GridCell (Cell마다)</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>├── static 리스트 (자기 ReplicationActorList)</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>├── DynamicNode: ActorListFrequencyBuckets 또는 DynamicSpatialFrequency</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>└── DormancyNode</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>└── ConnectionDormancyNode (연결마다, 마스터 리스트의 사본)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>static 액터가 잠들면 Cell이 자기 리스트에서 빼서 dormancy 노드로 옮기고 깨면 다시 돌려보낸다. <code>AddActor_Dormancy</code>로 들어온 액터는 깨어나면 dynamic 액터가 되어 위치가 갱신된다. <code>AddActor_Static</code>으로 들어온 액터는 깨어나도 원래 Cell에 그대로 있다. Dormancy 노드는 연결마다 마스터 리스트의 사본을 두고 gather 때마다 <code>bDormantOnConnection</code>인 액터를 뺀다. 그래서 사본은 그 연결이 아직 받지 못한 dormant 액터 목록이다. Cell에 dormant 액터가 많으면 새 연결마다 그만큼 복사한다. 연결이 끊겨도 사본은 저절로 사라지지 않는다.</p></section><section><h3>프레임당 검사와 점수<a href="#프레임당-검사와-점수"><span>#</span></a></h3><p>gather가 끝나면 <code>ReplicateActorListsForConnections_Default</code>가 리스트를 순회해 <code>FPrioritizedRepList</code>를 만든다(<code>Private/ReplicationGraph.cpp</code>). 아래 그림은 액터 하나가 거치는 검사 순서와 각 단계를 정하는 설정이다.</p><p><img alt="" loading="lazy" width="1600" height="1208" src="/_astro/diagram-prioritize-checks.E11IUADf_Z2rMaub.webp" /></p><ul>
<li>dormant 검사가 전역 정보 조회보다 앞에 있어서 잠든 액터의 프레임당 비용은 맵 조회 하나다.</li>
<li>거리 컬링은 <code>DistancePriorityScale &gt; 0</code>인 클래스만 거친다. <code>UReplicationGraph</code> 기본 구현(이하 베이스 그래프)은 <code>AInfo</code>와 <code>APlayerController</code>를 0으로 두지만 Basic과 Lyra식 초기화가 모든 클래스를 1로 덮어쓴다. 그래서 실제로는 Cull Distance 0이 이 액터들을 지킨다(0이면 검사를 건너뛴다). <code>bForceCullDistanceToZero</code>가 켜진 액터도 빠지지 않는다.</li>
<li>채널 닫힘 프레임은 리플리케이션이 아니라 거리 검사를 통과한 시점에 갱신된다.</li>
</ul><p>점수는 낮을수록 먼저 리플리케이션된다. 정렬이 오름차순이고 중요도 보정이 모두 뺄셈이기 때문이다.</p>

<table><thead><tr><th>항목</th><th>계산</th><th>기본값에서의 범위</th></tr></thead><tbody><tr><td>기준</td><td><code>AccumulatedNetPriorityBias</code></td><td>0</td></tr><tr><td>거리</td><td><code>Clamp(최단 거리² / MaxDistanceScaling, 0, 1) × DistancePriorityScale</code></td><td>0–1. <code>MaxDistanceScaling</code>은 <code>60000²</code>, 즉 600 m</td></tr><tr><td>starvation</td><td><code>1 - Clamp((FrameNum - LastRepFrameNum) × StarvationPriorityScale / MaxFramesSinceLastRep, 0, 1)</code></td><td>0–1. <code>MaxFramesSinceLastRep</code>는 20. 오래 못 받을수록 0에 가까움</td></tr><tr><td>잠들려는 액터</td><td><code>bWantsToBeDormant</code>이고 한 번 이상 리플리케이션됐으면</td><td>−1.5</td></tr><tr><td><code>ForceNetUpdate</code></td><td><code>ForceNetUpdateFrame &gt; LastRepFrameNum</code></td><td>−1</td></tr><tr><td>본인 viewer·ViewTarget</td><td><code>Net.RepGraph.ForceConnectionViewerPriority</code>(기본 1)면</td><td>−MAX_FLT, 아니면 −10</td></tr></tbody></table><ul>
<li><code>AccumulatedNetPriorityBias</code>는 클수록 덜 중요하다. 이름만 보고 양수를 넣으면 반대로 동작한다.</li>
<li>잠들려는 액터의 −1.5는 주석에 이유가 있다. dormancy는 마지막 리플리케이션이 ack돼야 완료되므로 새로 스폰된 액터들에 계속 밀리면 영영 잠들지 못한다.</li>
<li>정렬은 <code>TArray::Sort</code>, 즉 introsort라 같은 점수의 액터는 프레임마다 순서가 바뀔 수 있다. Epic이 포럼에서 우선순위는 고려 순서만 바꾸며 액터 간 순서는 보장되지 않는다고 답한 것과 맞는다.</li>
</ul><p>정렬된 리스트는 앞에서부터 <code>ReplicateSingleActor</code>로 보낸다.</p><ul>
<li>채널에 들어가기 전에 <code>LastRepFrameNum</code>과 <code>NextReplicationFrameNum</code>을 쓰므로 0바이트를 보내도 주기는 소비된다.</li>
<li><code>PreReplication</code>은 연결이 몇 개든 액터당 프레임당 한 번만 부른다.</li>
<li><code>bWantsToBeDormant</code>면 리플리케이션할 때마다 <code>StartBecomingDormant</code>를 다시 건다.</li>
<li>dependent actor는 부모 직후에 재귀적으로 리플리케이션한다.</li>
</ul><p>채널은 프레임 수로 닫힌다. <code>UpdateActorChannelCloseFrameNum</code>은 <code>FrameNum + ReplicationPeriodFrame + ActorChannelFrameTimeout + GlobalActorChannelFrameNumTimeout</code>을 계산해 기존 값보다 크면 갱신한다. 기본값은 주기 1, 타임아웃 4, 전역 여유 2이므로 마지막으로 gather된 뒤 7프레임이 지나면 닫힌다.</p><p><code>LookForNonRelevantChannels</code>가 매 프레임 연결의 채널 맵을 돌며 지난 채널을 <code>Close(Relevancy)</code>한다. startup 액터(<code>IsNetStartupActor</code>)의 채널은 이 경로로 닫지 않는다. 기본 드라이버의 <code>RelevantTimeout</code>이 초 단위인 것과 달리 전부 프레임 단위다.</p></section></section>
<section><h2>설정값이 실제로 바꾸는 것<a href="#설정값이-실제로-바꾸는-것"><span>#</span></a></h2><p>Cell 크기, 버킷 수, <code>NetUpdateFrequency</code>, 우선순위 스케일, 채널 타임아웃이 각각 무엇을 바꾸는지 소스에서 확인하고 OpenWorld 그래프로 하나씩 바꿔 가며 쟀다. 조건은 Uniform 분포, 액터 5,000개, 클라이언트 2개이고 기준값은 Cell 100 m, 버킷 3개, <code>NetUpdateFrequency</code> 100, <code>DistancePriorityScale</code> 1, <code>StarvationPriorityScale</code> 1, <code>ActorChannelFrameTimeout</code> 4다. 각 3회 평균이고 단위는 프레임당 <code>ServerReplicateActors</code> ms, 호출은 연결당 <code>ReplicateActor</code> 횟수, 채널은 두 연결에 열린 채널 수의 합(<code>Replication/NumOpenChannels</code>, 프레임 평균), 검토는 <code>Prioritize_InnerLoop</code>가 프레임에 본 항목 수다.</p>

<table><thead><tr><th>설정</th><th>값</th><th>ms</th><th>호출</th><th>채널</th><th>검토</th></tr></thead><tbody><tr><td>기준</td><td></td><td>0.35</td><td>21</td><td>76</td><td>79</td></tr><tr><td><code>CellSize</code></td><td>25 m</td><td>0.38</td><td>23</td><td>80</td><td>65</td></tr><tr><td></td><td>400 m</td><td>0.40</td><td>23</td><td>80</td><td>181</td></tr><tr><td>버킷 수</td><td>1</td><td>0.42</td><td>33</td><td>78</td><td>146</td></tr><tr><td></td><td>6</td><td>0.34</td><td>20</td><td>80</td><td>68</td></tr><tr><td><code>NetUpdateFrequency</code></td><td>10</td><td>0.32</td><td>14</td><td>74</td><td>80</td></tr><tr><td></td><td>2</td><td>0.28</td><td>8</td><td>74</td><td>80</td></tr><tr><td><code>DynamicSpatialFrequency</code></td><td>버킷 대신</td><td>0.40</td><td>16</td><td>76</td><td>48</td></tr><tr><td><code>DistancePriorityScale</code></td><td>0</td><td>0.41</td><td>34</td><td>153</td><td>71</td></tr><tr><td><code>StarvationPriorityScale</code></td><td>0</td><td>0.34</td><td>21</td><td>76</td><td>79</td></tr><tr><td><code>ActorChannelFrameTimeout</code></td><td>0</td><td>0.35</td><td>21</td><td>212</td><td>79</td></tr></tbody></table><p>시간을 크게 바꾸는 설정은 없다. 이 밀도에서 설정값은 보내는 횟수와 열린 채널 수를 바꾼다. 그 효과는 Cull Distance 안의 액터가 많을수록 커져 Cluster에서 두드러진다.</p><p><code>CellSize</code>는 액터 하나가 등록되는 Cell 수(Cull Distance 150 m에 25 m Cell이면 13×13), dynamic 액터의 Cell 갱신 빈도, viewer Cell에 섞이는 Cull Distance 밖 액터 수를 정한다. 측정에서는 25 m와 400 m 모두 0.05 ms 안이고 400 m에서 검토 항목만 두 배다. Cull Distance와 같은 자릿수로 두고 <code>NET_PrepareReplication</code>과 검토 항목 수를 보며 조정한다.</p><p>버킷 수는 frequency bucket 노드가 Cell의 dynamic 액터를 나누는 개수이고 dynamic 액터 주기의 하한이다. <code>ReplicationPeriodFrame</code>과 겹치면 주기를 버킷 수의 배수로 올림한 값이 실제 간격이다(버킷 3에 주기 4면 6프레임). 1개면 호출 33회이고 Cluster에서는 4.48 ms와 9.89 ms를 가른다. Cull Distance 안에 dynamic 액터가 많을 때 가장 먼저 만진다. 단 30 Hz에 3개면 dynamic 액터가 10 Hz가 되니 이동 보간이 감당하는지 클라이언트에서 확인한다.</p><p><code>NetUpdateFrequency</code>는 초기화 때 CDO 값으로 <code>RoundToInt(NetServerMaxTickRate / NetUpdateFrequency)</code>를 <code>ReplicationPeriodFrame</code>에 쓰고 그 뒤로는 프레임 수로만 센다. 런타임 <code>SetNetUpdateFrequency</code>는 반영되지 않고(<code>Net.RepGraph.SetPeriodFrame</code>은 클래스·액터와 명령을 보낸 연결의 값을 고친다) <code>MinNetUpdateFrequency</code>의 적응형 주기도 없다. 100·10·2에서 호출은 21·14·8회, 시간은 0.35·0.32·0.28 ms로 호출보다 완만하게 준다. 클래스별 송신 횟수를 정하는 값이고 서버 프레임 레이트보다 낮을 때만 뜻이 있다.</p><p><code>DynamicSpatialFrequency</code>는 거리와 시야 방향 존마다 주기를 보간해 gather 안에서 직접 리플리케이션한다. prioritize 단계를 거치지 않아 <code>DistancePriorityScale</code>과 <code>StarvationPriorityScale</code>이 효과가 없다. 기본 존은 <code>constexpr</code> 30 Hz 가정이다. <code>Net.RepGraph.DyanmicSpatialization.Reinit</code>(오타가 실제 이름)도 같은 30 Hz 기본값을 다시 만들 뿐이어서 틱이 다르면 프로젝트가 존과 비트 예산을 직접 구성한다. 측정에서 호출은 16회로 줄지만 gather가 0.01에서 0.07 ms로 늘어 0.40 ms다. Cull Distance 안의 dynamic 액터가 수백 개일 때 송신량을 줄이는 용도이고 CPU는 늘어난다. 존 표의 함정은 커스텀 그래프 절에 있다.</p><p><code>DistancePriorityScale</code>은 거리 점수의 가중치다. 거리 검사가 <code>if (DistancePriorityScale &gt; 0)</code> 안에 있어 0이면 거리 컬링이 꺼진다. 측정에서 채널이 76에서 153으로, 호출이 34회로 늘었다. 거리 가중치만 끄려면 아주 작은 양수를 쓴다.</p><p><code>StarvationPriorityScale</code>은 오래 못 받은 액터를 앞으로 당기는 가중치다. 위 표에서는 포화가 없어서 0으로 둬도 변화가 없었다. 포화돼서 뒤의 액터가 밀릴 때만 뜻이 있고 <code>Net.RepGraph.StarvedList</code>(마지막 리플리케이션 이후 프레임 수 순)로 확인한다. 포화 조건은 아래에서 따로 쟀다.</p><p><code>ActorChannelFrameTimeout</code>은 마지막 gather 뒤 채널을 닫기까지의 프레임이다. 전역 여유 2를 더해 기본 7프레임이다. 0이면 relevancy로는 채널을 닫지 않는다(dormancy·tear-off·파괴로는 닫힌다). 0에서 채널이 212개가 되고 AI가 Cull Distance 경계를 드나드는 동안 계속 는다. Cull Distance 경계에서 채널이 자주 열리고 닫히면 올린다. 열린 채널은 양쪽 메모리를 쓰고 클라이언트에 액터가 남는다.</p><section><h3>포화 조건<a href="#포화-조건"><span>#</span></a></h3><p>위 측정은 클라이언트 rate 제한을 풀고 CPU만 쟀다. 포화가 나면 무엇이 달라지는지 보려고 AI와 탈것이 이동을 리플리케이션하게 해 Cluster에서 연결당 약 90 KB/s가 나가게 만들고 클라이언트 rate를 30 KB/s로 제한했다(엔진 기본값은 100 KB/s). 각 3회다. “5초 안 갱신”은 클라이언트에 있는 AI 중 최근 5초 안에 갱신을 받은 비율이다. “전체 수신”은 접속 뒤 액터를 다 받기까지(클라이언트가 세는 5,250여 개 중 5,200개 기준) 걸린 시간이다.</p>

<table><thead><tr><th>조건</th><th>ms</th><th>호출</th><th>송신 KB/s</th><th>5초 안 갱신</th><th>전체 수신</th></tr></thead><tbody><tr><td>OpenWorld, 제한 없음</td><td>6.76</td><td>735</td><td>91.5</td><td>전부</td><td>1초</td></tr><tr><td>OpenWorld, 30 KB/s</td><td>2.60</td><td>129</td><td>22.6</td><td>전부</td><td>12초</td></tr><tr><td>OpenWorld, 30 KB/s, <code>StarvationPriorityScale</code> 0</td><td>5.37</td><td>196</td><td>23.9</td><td>약 40%</td><td>120초 뒤에도 62%</td></tr><tr><td>OpenWorld, 30 KB/s, <code>ActorDiscoveryBudget</code> 10 KB/s</td><td>2.53</td><td>129</td><td>22.6</td><td>전부</td><td>8초</td></tr><tr><td>기본 드라이버, 제한 없음</td><td>12.22</td><td>1,492</td><td>266</td><td>전부</td><td>1초</td></tr><tr><td>기본 드라이버, 30 KB/s</td><td>4.44</td><td>125</td><td>21.9</td><td>전부</td><td>120초 뒤에도 약 80%</td></tr></tbody></table><p>이동을 리플리케이션하면 호출당 비용이 늘어 제한 없는 조건에서도 6.76 ms가 걸린다. 앞 표에서는 4.48 ms였다. 포화되면 서버는 오히려 덜 일한다. 연결이 포화되면 <code>ReplicateActorsForConnection</code>이 정렬된 리스트의 앞부분만 보내고 멈추므로 호출이 735회에서 129회로, 시간이 6.76 ms에서 2.60 ms로 줄었다. 대신 클라이언트가 액터를 다 받기까지 1초에서 12초로 늘었다.</p><p><code>StarvationPriorityScale</code>을 0으로 두면 가까운 액터가 매 프레임 앞자리를 차지해 나머지는 영영 밀린다. 120초 뒤에도 클라이언트는 액터의 62%만 받았고 받은 AI 중 5초 안에 갱신된 것은 약 40%였다. 1이면 12초 안에 전부 받고 모든 AI가 5초 안에 갱신된다. 포화가 있는 서버에서 이 값은 성능보다 공평성을 정한다.</p><p><code>Net.RepGraph.ActorDiscoveryBudget &lt;KB/s&gt;</code>는 아직 클라이언트가 스폰을 ack하지 않은 채널의 트래픽을 프레임당 그 양까지 연결의 송신량에서 빼 준다. 그만큼은 포화 판정에서 면제되고 넘치는 몫은 일반 트래픽으로 센다. 상한을 거는 값은 아니다. 콘솔 명령이고 기본값 0은 끔이다. 10 KB/s를 주자 접속 직후 전체 액터를 받기까지의 시간이 12초에서 8초로 줄었고 정상 상태는 같았다. 접속 직후나 핫드롭처럼 수천 채널이 한꺼번에 열릴 때 쓰는 값이다.</p><p>기본 드라이버는 같은 제한에서 받은 AI는 거의 전부 갱신하지만 접속 뒤 120초가 지나도 액터의 80%쯤만 받았다. 새 채널을 열어야 하는 액터가 뒤로 밀리는 것인데 원인은 소스에서 따로 확인하지 않았다.</p></section></section>
<section><h2>커스텀 그래프: 라우팅 테이블, Team Node, 걸리는 것<a href="#커스텀-그래프-라우팅-테이블-team-node-걸리는-것"><span>#</span></a></h2><p>커스텀 그래프의 출발점은 플러그인에 들어 있는 <code>UBasicReplicationGraph</code>다. 플러그인을 켜고 <code>ReplicationDriverClassName</code>에 <code>/Script/ReplicationGraph.BasicReplicationGraph</code>를 적으면 켜진다. Basic의 라우팅은 셋뿐이다.</p><ul>
<li><code>bAlwaysRelevant</code>면 전역 <code>ActorList</code></li>
<li><code>bOnlyRelevantToOwner</code>면 연결별 노드</li>
<li>나머지는 전부 Grid의 <code>AddActor_Dormancy</code></li>
</ul><p><code>RouteAddNetworkActorToNodes</code>에는 주석이 하나 달려 있다. 엔진에는 액터가 움직이는지 판별하는 코드가 없고 <code>AActor</code>에 그 정보가 없으므로 깨어 있으면 움직일 수 있다는 가정으로 넣는다는 내용이다. 안 움직이는 소품 수천 개를 깨운 채로 두면 Basic은 그만큼의 <code>PrepareForReplication</code> 비용을 낸다.</p><p>static과 dynamic을 가르려면 프로젝트가 클래스 라우팅 테이블을 만들어야 하고, 이것이 커스텀 그래프를 쓰는 첫 번째 이유다. Basic에는 owner 변경의 재라우팅, PlayerState 빈도 제한, 팀 같은 범주도 없다. PlayerState는 <code>bAlwaysRelevant</code>라 전역 리스트로 가긴 한다.</p><section><h3>액터 범주와 라우팅<a href="#액터-범주와-라우팅"><span>#</span></a></h3><p>리플리케이션 그래프를 어떻게 구성할지는 게임의 액터가 어떤 범주로 나뉘는지에 달려 있다. 아래 표는 공식 문서의 예시와 Lyra 5.8.3의 <code>ULyraReplicationGraph</code>, 2018년 라이브스트림의 Fortnite 그래프에서 오픈월드 멀티플레이어의 범주를 추린 것이다. 테스트 액터 종류도 이 범주를 따른다.</p>

<table><thead><tr><th>범주</th><th>예와 특성</th><th>리플리케이션 그래프에서 가는 곳</th></tr></thead><tbody><tr><td>월드 소품</td><td>건물 조각, 나무. 수만 개, 스폰 후 안 움직임</td><td>Grid의 static 리스트. 피해를 받을 때만 깨어남</td></tr><tr><td>루트·픽업</td><td>바닥의 무기, 상자. 수천 개, 집히기 전까지 안 변함</td><td>Grid의 dormancy 리스트. 집히면 거리 안의 연결에만 파괴 정보 전송(부록 A)</td></tr><tr><td>Player Pawn</td><td>100명. 계속 움직이고 매 프레임 바뀜</td><td>Grid의 dynamic 리스트. 이동은 Fast Shared Path</td></tr><tr><td>AI Pawn</td><td>좀비, 야생동물. 수백 개, 움직임</td><td>Grid의 dynamic 리스트. 거리별 빈도 조절</td></tr><tr><td>탈것과 부속 무기</td><td>탈것과 그 위의 무기</td><td>탈것은 dynamic, 무기는 dependent actor</td></tr><tr><td>발사체</td><td>총알, 수류탄. 짧게 살고 많이 생김</td><td>dynamic 리스트, Cell 재구축 금지 목록</td></tr><tr><td>전역 상태</td><td>GameState, 안전지대. 모두에게 항상 필요</td><td>AlwaysRelevant 노드</td></tr><tr><td>스쿼드</td><td>4인 스쿼드. 거리와 무관하게 서로 보여야 함</td><td>Team Node</td></tr><tr><td>본인 소유</td><td>인벤토리, PlayerController. owner에게만</td><td>연결별 AlwaysRelevant 노드</td></tr><tr><td>PlayerState</td><td>100명분. 모두에게 필요하지만 느려도 됨</td><td>빈도 제한 노드</td></tr></tbody></table><p>측정에 쓴 <code>UOpenWorldReplicationGraph</code>는 Lyra 5.8.3의 구조를 바탕으로 이 표를 구현한 것이다. Lyra 자체는 <code>ULyraReplicationGraphSettings</code>의 <code>bDisableReplicationGraph</code>가 기본 true라 샘플을 켜도 리플리케이션 그래프는 꺼져 있다. 켜면 Cell 100 m, 버킷 3개, 파괴 정보 거리 300 m, Fast Shared Path 켬(10 KB/s, Cull Distance의 80%)이 기본값이다. 클래스마다 <code>Spatialize_Static</code>, <code>Spatialize_Dynamic</code>, <code>Spatialize_Dormancy</code>, <code>RelevantAllConnections</code>, <code>RelevantOwnerConnection</code>, <code>NotRouted</code> 중 하나를 정한다. 레거시 플래그에서 기본값을 유도한 뒤 프로젝트 설정으로 덮어쓴다.</p><ul>
<li><code>bAlwaysRelevant</code>면 <code>RelevantAllConnections</code></li>
<li>Lyra는 <code>bOnlyRelevantToOwner</code>와 <code>bNetUseOwnerRelevancy</code> 클래스를 <code>NotRouted</code>로 두고 연결별 노드가 PlayerController와 Pawn, PlayerState만 직접 끌어온다. OpenWorld 그래프는 그 대신 <code>RelevantOwnerConnection</code> 라우팅을 두었다</li>
<li>나머지 리플리케이션 액터는 <code>Spatialize_Dynamic</code></li>
</ul><p>아래 코드는 위 라우팅과 노드 구성을 공식 문서의 일반 예제에 맞춰 5.8.3에서 컴파일되게 다시 쓴 것이다. OpenWorld 그래프에 있는 스트리밍 레벨 리스트, Team Node, owner가 아직 없는 액터의 재시도 같은 게임 특화 부분은 뺐다.</p><div><div><div><figure><figcaption><span>ExampleReplicationGraph.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>UENUM</span><span>()</span></div></div><div><div><div>2</div></div><div><span>enum</span><span> </span><span>class</span><span> </span><span>EClassRepNodeMapping</span><span> : </span><span>uint8</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>NotRouted</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>RelevantAllConnections</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>RelevantOwnerConnection</span><span>,</span></div></div><div><div><div>7</div></div><div><span>  </span><span>Spatialize_Static</span><span>,</span></div></div><div><div><div>8</div></div><div><span>  </span><span>Spatialize_Dynamic</span><span>,</span></div></div><div><div><div>9</div></div><div><span>  </span><span>Spatialize_Dormancy</span><span>,</span></div></div><div><div><div>10</div></div><div><span>};</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>static</span><span> </span><span>bool</span><span> </span><span>IsSpatialized</span><span>(</span><span>EClassRepNodeMapping</span><span><span> </span><span>Mapping</span><span>) { </span></span><span>return</span><span> </span><span>Mapping</span><span> </span><span>&gt;=</span><span> EClassRepNodeMapping::</span><span>Spatialize_Static</span><span>; }</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>15</div></div><div><span>class</span><span> </span><span>UExampleReplicationGraph</span><span> : </span><span>public</span><span> </span><span>UReplicationGraph</span></div></div><div><div><div>16</div></div><div><span>{</span></div></div><div><div><div>17</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>public:</span></div></div><div><div><div>20</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>InitGlobalActorClassSettings</span><span>() </span><span>override</span><span>;</span></div></div><div><div><div>21</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>InitGlobalGraphNodes</span><span>() </span><span>override</span><span>;</span></div></div><div><div><div>22</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>InitConnectionGraphNodes</span><span>(</span><span>UNetReplicationGraphConnection</span><span>*</span><span><span> </span><span>RepGraphConnection</span><span>) </span></span><span>override</span><span>;</span></div></div><div><div><div>23</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>RouteAddNetworkActorToNodes</span><span>(</span><span>const</span><span> </span><span>FNewReplicatedActorInfo</span><span>&amp;</span><span><span> </span><span>ActorInfo</span><span>, </span></span><span>FGlobalActorReplicationInfo</span><span>&amp;</span><span><span> </span><span>GlobalInfo</span><span>) </span></span><span>override</span><span>;</span></div></div><div><div><div>24</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>RouteRemoveNetworkActorToNodes</span><span>(</span><span>const</span><span> </span><span>FNewReplicatedActorInfo</span><span>&amp;</span><span><span> </span><span>ActorInfo</span><span>) </span></span><span>override</span><span>;</span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span>  </span><span>EClassRepNodeMapping</span><span> </span><span>GetClassNodeMapping</span><span>(</span><span>UClass</span><span>*</span><span><span> </span><span>Class</span><span>) </span></span><span>const</span><span>;</span></div></div><div><div><div>27</div></div><div><span>  </span><span>void</span><span> </span><span>InitClassReplicationInfo</span><span>(</span><span>FClassReplicationInfo</span><span>&amp;</span><span><span> </span><span>Info</span><span>, </span></span><span>UClass</span><span>*</span><span><span> </span><span>Class</span><span>, </span></span><span>bool</span><span><span> </span><span>bSpatialize</span><span>) </span></span><span>const</span><span>;</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span>  </span><span>UPROPERTY</span><span>() </span><span>TObjectPtr</span><span>&lt;</span><span>UReplicationGraphNode_GridSpatialization2D</span><span>&gt;</span><span> </span><span>GridNode</span><span>;</span></div></div><div><div><div>30</div></div><div><span>  </span><span>UPROPERTY</span><span>() </span><span>TObjectPtr</span><span>&lt;</span><span>UReplicationGraphNode_ActorList</span><span>&gt;</span><span> </span><span>AlwaysRelevantNode</span><span>;</span></div></div><div><div><div>31</div></div><div><span>  </span><span>TClassMap</span><span>&lt;</span><span>EClassRepNodeMapping</span><span>&gt; </span><span>ClassRepNodePolicies</span><span>;</span></div></div><div><div><div>32</div></div><div><span>  </span><span>TMap</span><span>&lt;</span><span>TWeakObjectPtr</span><span>&lt;</span><span>UNetConnection</span><span>&gt;, </span><span>TObjectPtr</span><span>&lt;</span><span>UReplicationGraphNode_AlwaysRelevant_ForConnection</span><span>&gt;&gt; </span><span>ConnectionNodes</span><span>;</span></div></div><div><div><div>33</div></div><div><span>};</span></div></div><div><div><div>34</div></div><div>
</div></div><div><div><div>35</div></div><div><span>// 모든 PlayerState를 프레임당 TargetActorsPerFrame개씩 돌려 보내는 노드 (공식 문서의 PlayerStateFrequencyLimiter)</span></div></div><div><div><div>36</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>37</div></div><div><span>class</span><span> </span><span>UExampleNode_PlayerStateFrequencyLimiter</span><span> : </span><span>public</span><span> </span><span>UReplicationGraphNode</span></div></div><div><div><div>38</div></div><div><span>{</span></div></div><div><div><div>39</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>40</div></div><div>
</div></div><div><div><div>41</div></div><div><span>public:</span></div></div><div><div><div>42</div></div><div><span><span>  </span></span><span>// 이 플래그가 없으면 InitNode가 PrepareForReplication 목록에 넣지 않아 노드가 아무것도 돌려주지 않는다 (Team Node 절 참고)</span></div></div><div><div><div>43</div></div><div><span>  </span><span>UExampleNode_PlayerStateFrequencyLimiter</span><span>() { </span><span>bRequiresPrepareForReplicationCall</span><span> </span><span>=</span><span> </span><span>true</span><span>; }</span></div></div><div><div><div>44</div></div><div>
</div></div><div><div><div>45</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>NotifyAddNetworkActor</span><span>(</span><span>const</span><span> </span><span>FNewReplicatedActorInfo</span><span>&amp;</span><span><span> </span><span>Actor</span><span>) </span></span><span>override</span><span> {}</span></div></div><div><div><div>46</div></div><div><span>  </span><span>virtual</span><span> </span><span>bool</span><span> </span><span>NotifyRemoveNetworkActor</span><span>(</span><span>const</span><span> </span><span>FNewReplicatedActorInfo</span><span>&amp;</span><span><span> </span><span>Actor</span><span>, </span></span><span>bool</span><span><span> </span><span>bWarnIfNotFound</span><span> </span></span><span>=</span><span> </span><span>true</span><span>) </span><span>override</span><span> { </span><span>return</span><span> </span><span>false</span><span>; }</span></div></div><div><div><div>47</div></div><div><span>  </span><span>virtual</span><span> </span><span>bool</span><span> </span><span>NotifyActorRenamed</span><span>(</span><span>const</span><span> </span><span>FRenamedReplicatedActorInfo</span><span>&amp;</span><span><span> </span><span>Actor</span><span>, </span></span><span>bool</span><span><span> </span><span>bWarnIfNotFound</span><span> </span></span><span>=</span><span> </span><span>true</span><span>) </span><span>override</span><span> { </span><span>return</span><span> </span><span>false</span><span>; }</span></div></div><div><div><div>48</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>PrepareForReplication</span><span>() </span><span>override</span><span>;</span></div></div><div><div><div>49</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>GatherActorListsForConnection</span><span>(</span><span>const</span><span> </span><span>FConnectionGatherActorListParameters</span><span>&amp;</span><span><span> </span><span>Params</span><span>) </span></span><span>override</span><span>;</span></div></div><div><div><div>50</div></div><div>
</div></div><div><div><div>51</div></div><div><span>  </span><span>int32</span><span> </span><span>TargetActorsPerFrame</span><span> </span><span>=</span><span> </span><span>2</span><span>;</span></div></div><div><div><div>52</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>FActorRepListRefView</span><span>&gt; </span><span>ReplicationActorLists</span><span>;</span></div></div><div><div><div>53</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><div><div><figure><figcaption><span>ExampleReplicationGraph.cpp</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>EClassRepNodeMapping</span><span><span> </span><span>UExampleReplicationGraph</span><span>::</span></span><span>GetClassNodeMapping</span><span>(</span><span>UClass</span><span>*</span><span><span> </span><span>Class</span><span>) </span></span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span>Class</span><span>) </span><span>return</span><span> EClassRepNodeMapping::</span><span>NotRouted</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>// 명시적으로 정한 클래스는 그대로</span></div></div><div><div><div>6</div></div><div><span>  </span><span>if</span><span> (</span><span>const</span><span> </span><span>EClassRepNodeMapping</span><span>*</span><span> </span><span>Explicit</span><span> </span><span>=</span><span> </span><span>ClassRepNodePolicies</span><span>.</span><span>FindWithoutClassRecursion</span><span>(</span><span>Class</span><span>)) </span><span>return</span><span> </span><span>*</span><span>Explicit</span><span>;</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>  </span><span>const</span><span> </span><span>AActor</span><span>*</span><span> </span><span>CDO</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>AActor</span><span>&gt;(</span><span>Class</span><span>-&gt;</span><span>GetDefaultObject</span><span>());</span></div></div><div><div><div>9</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span>CDO</span><span> </span><span>||</span><span> </span><span>!</span><span>CDO</span><span>-&gt;</span><span>GetIsReplicated</span><span>()) </span><span>return</span><span> EClassRepNodeMapping::</span><span>NotRouted</span><span>;</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>// 부모와 플래그가 같으면 부모의 매핑을 따른다. NotRouted로 둔 PlayerState의 서브클래스도 여기로 온다</span></div></div><div><div><div>12</div></div><div><span>  </span><span>if</span><span> (</span><span>const</span><span> </span><span>AActor</span><span>*</span><span> </span><span>SuperCDO</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>AActor</span><span>&gt;(</span><span>Class</span><span>-&gt;</span><span>GetSuperClass</span><span>()-&gt;</span><span>GetDefaultObject</span><span>()))</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>14</div></div><div><span>    </span><span>if</span><span> (</span><span>SuperCDO</span><span>-&gt;</span><span>GetIsReplicated</span><span>() </span><span>==</span><span> </span><span>CDO</span><span>-&gt;</span><span>GetIsReplicated</span><span>()</span></div></div><div><div><div>15</div></div><div><span>      </span><span>&amp;&amp;</span><span> </span><span>SuperCDO</span><span>-&gt;</span><span>bAlwaysRelevant</span><span> </span><span>==</span><span> </span><span>CDO</span><span>-&gt;</span><span>bAlwaysRelevant</span></div></div><div><div><div>16</div></div><div><span>      </span><span>&amp;&amp;</span><span> </span><span>SuperCDO</span><span>-&gt;</span><span>bOnlyRelevantToOwner</span><span> </span><span>==</span><span> </span><span>CDO</span><span>-&gt;</span><span>bOnlyRelevantToOwner</span></div></div><div><div><div>17</div></div><div><span>      </span><span>&amp;&amp;</span><span> </span><span>SuperCDO</span><span>-&gt;</span><span>bNetUseOwnerRelevancy</span><span> </span><span>==</span><span> </span><span>CDO</span><span>-&gt;</span><span>bNetUseOwnerRelevancy</span><span>)</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>19</div></div><div><span>      </span><span>return</span><span> </span><span>GetClassNodeMapping</span><span>(</span><span>Class</span><span>-&gt;</span><span>GetSuperClass</span><span>());</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>// 레거시 플래그에서 기본값을 유도한다. 프로젝트 설정으로 덮어쓰는 부분은 생략</span></div></div><div><div><div>24</div></div><div><span>  </span><span>if</span><span> (</span><span>CDO</span><span>-&gt;</span><span>bAlwaysRelevant</span><span> </span><span>&amp;&amp;</span><span> </span><span>!</span><span>CDO</span><span>-&gt;</span><span>bOnlyRelevantToOwner</span><span>) </span><span>return</span><span> EClassRepNodeMapping::</span><span>RelevantAllConnections</span><span>;</span></div></div><div><div><div>25</div></div><div><span>  </span><span>if</span><span> (</span><span>CDO</span><span>-&gt;</span><span>bOnlyRelevantToOwner</span><span>) </span><span>return</span><span> EClassRepNodeMapping::</span><span>RelevantOwnerConnection</span><span>;</span></div></div><div><div><div>26</div></div><div><span>  </span><span>return</span><span> EClassRepNodeMapping::</span><span>Spatialize_Dynamic</span><span>;</span></div></div><div><div><div>27</div></div><div><span>}</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span>void</span><span><span> </span><span>UExampleReplicationGraph</span><span>::</span></span><span>InitClassReplicationInfo</span><span>(</span><span>FClassReplicationInfo</span><span>&amp;</span><span><span> </span><span>Info</span><span>, </span></span><span>UClass</span><span>*</span><span><span> </span><span>Class</span><span>, </span></span><span>bool</span><span><span> </span><span>bSpatialize</span><span>) </span></span><span>const</span></div></div><div><div><div>30</div></div><div><span>{</span></div></div><div><div><div>31</div></div><div><span>  </span><span>const</span><span> </span><span>AActor</span><span>*</span><span> </span><span>CDO</span><span> </span><span>=</span><span> </span><span>Class</span><span>-&gt;</span><span>GetDefaultObject</span><span>&lt;</span><span>AActor</span><span>&gt;</span><span>();</span></div></div><div><div><div>32</div></div><div><span>  </span><span>Info</span><span>.</span><span>ReplicationPeriodFrame</span><span> </span><span>=</span><span> </span><span>GetReplicationPeriodFrameForFrequency</span><span>(</span><span>CDO</span><span>-&gt;</span><span>GetNetUpdateFrequency</span><span>());</span></div></div><div><div><div>33</div></div><div><span><span>  </span></span><span>// Cull Distance는 Grid에 들어가는 클래스에만 준다. AlwaysRelevant·owner 전용 액터는 WorldLocation이 원점에 고정이라</span></div></div><div><div><div>34</div></div><div><span><span>  </span></span><span>// Cull Distance가 있으면 viewer가 원점에서 그만큼 멀어지는 순간 거리 컬링에 걸린다</span></div></div><div><div><div>35</div></div><div><span>  </span><span>if</span><span> (</span><span>bSpatialize</span><span>) </span><span>Info</span><span>.</span><span>SetCullDistanceSquared</span><span>(</span><span>CDO</span><span>-&gt;</span><span>GetNetCullDistanceSquared</span><span>());</span></div></div><div><div><div>36</div></div><div><span>}</span></div></div><div><div><div>37</div></div><div>
</div></div><div><div><div>38</div></div><div><span>void</span><span><span> </span><span>UExampleReplicationGraph</span><span>::</span></span><span>InitGlobalActorClassSettings</span><span>()</span></div></div><div><div><div>39</div></div><div><span>{</span></div></div><div><div><div>40</div></div><div><span><span>  </span></span><span>Super::</span><span>InitGlobalActorClassSettings</span><span>();</span></div></div><div><div><div>41</div></div><div>
</div></div><div><div><div>42</div></div><div><span><span>  </span></span><span>// 전용 노드가 있는 엔진 클래스는 라우팅하지 않는다</span></div></div><div><div><div>43</div></div><div><span>  </span><span>ClassRepNodePolicies</span><span>.</span><span>Set</span><span><span>(</span><span>APlayerState</span><span>::</span></span><span>StaticClass</span><span>(), EClassRepNodeMapping::</span><span>NotRouted</span><span>);</span></div></div><div><div><div>44</div></div><div><span>  </span><span>ClassRepNodePolicies</span><span>.</span><span>Set</span><span><span>(</span><span>ALevelScriptActor</span><span>::</span></span><span>StaticClass</span><span>(), EClassRepNodeMapping::</span><span>NotRouted</span><span>);</span></div></div><div><div><div>45</div></div><div><span>  </span><span>ClassRepNodePolicies</span><span>.</span><span>Set</span><span><span>(</span><span>AReplicationGraphDebugActor</span><span>::</span></span><span>StaticClass</span><span>(), EClassRepNodeMapping::</span><span>NotRouted</span><span>);</span></div></div><div><div><div>46</div></div><div>
</div></div><div><div><div>47</div></div><div><span><span>  </span></span><span>// 초기화 뒤에 로드되는 Blueprint 서브클래스는 첫 조회 때 이 두 람다로 온다</span></div></div><div><div><div>48</div></div><div><span>  </span><span>ClassRepNodePolicies</span><span>.</span><span>InitNewElement</span><span> </span><span>=</span><span> [</span><span>this</span><span>](</span><span>UClass</span><span>*</span><span><span> </span><span>Class</span><span>, </span></span><span>EClassRepNodeMapping</span><span>&amp;</span><span><span> </span><span>Mapping</span><span>)</span></span></div></div><div><div><div>49</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>50</div></div><div><span>    </span><span>Mapping</span><span> </span><span>=</span><span> </span><span>GetClassNodeMapping</span><span>(</span><span>Class</span><span>);</span></div></div><div><div><div>51</div></div><div><span>    </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>52</div></div><div><span><span>  </span></span><span>};</span></div></div><div><div><div>53</div></div><div><span>  </span><span>GlobalActorReplicationInfoMap</span><span>.</span><span>SetInitClassInfoFunc</span><span>([</span><span>this</span><span>](</span><span>UClass</span><span>*</span><span><span> </span><span>Class</span><span>, </span></span><span>FClassReplicationInfo</span><span>&amp;</span><span><span> </span><span>Info</span><span>)</span></span></div></div><div><div><div>54</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>55</div></div><div><span>    </span><span>InitClassReplicationInfo</span><span>(</span><span>Info</span><span>, </span><span>Class</span><span>, </span><span>IsSpatialized</span><span>(</span><span>ClassRepNodePolicies</span><span>.</span><span>GetChecked</span><span>(</span><span>Class</span><span>)));</span></div></div><div><div><div>56</div></div><div><span>    </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>57</div></div><div><span><span>  </span></span><span>});</span></div></div><div><div><div>58</div></div><div>
</div></div><div><div><div>59</div></div><div><span>  </span><span>for</span><span> (</span><span>TObjectIterator</span><span>&lt;</span><span>UClass</span><span>&gt;</span><span> </span><span>It</span><span>; </span><span>It</span><span>; </span><span>++</span><span>It</span><span>)</span></div></div><div><div><div>60</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>61</div></div><div><span>    </span><span>UClass</span><span>*</span><span> </span><span>Class</span><span> </span><span>=</span><span> </span><span>*</span><span>It</span><span>;</span></div></div><div><div><div>62</div></div><div><span>    </span><span>const</span><span> </span><span>AActor</span><span>*</span><span> </span><span>CDO</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>AActor</span><span>&gt;(</span><span>Class</span><span>-&gt;</span><span>GetDefaultObject</span><span>());</span></div></div><div><div><div>63</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>CDO</span><span> </span><span>||</span><span> </span><span>!</span><span>CDO</span><span>-&gt;</span><span>GetIsReplicated</span><span>()) </span><span>continue</span><span>;</span></div></div><div><div><div>64</div></div><div><span>    </span><span>if</span><span> (</span><span>Class</span><span>-&gt;</span><span>GetName</span><span>().</span><span>StartsWith</span><span>(</span><span>TEXT</span><span>(</span><span>"SKEL_"</span><span>)) </span><span>||</span><span> </span><span>Class</span><span>-&gt;</span><span>GetName</span><span>().</span><span>StartsWith</span><span>(</span><span>TEXT</span><span>(</span><span>"REINST_"</span><span>))) </span><span>continue</span><span>;</span></div></div><div><div><div>65</div></div><div>
</div></div><div><div><div>66</div></div><div><span><span>    </span></span><span>// TClassMap::Get은 부모 값을 자식 키로 복사해 돌려주므로 "없을 때만 Set"이라는 가드를 쓰면 자식이 부모 라우팅을 물려받는다. 무조건 쓴다</span></div></div><div><div><div>67</div></div><div><span>    </span><span>const</span><span> </span><span>EClassRepNodeMapping</span><span> </span><span>Mapping</span><span> </span><span>=</span><span> </span><span>GetClassNodeMapping</span><span>(</span><span>Class</span><span>);</span></div></div><div><div><div>68</div></div><div><span>    </span><span>ClassRepNodePolicies</span><span>.</span><span>Set</span><span>(</span><span>Class</span><span>, </span><span>Mapping</span><span>);</span></div></div><div><div><div>69</div></div><div>
</div></div><div><div><div>70</div></div><div><span>    </span><span>FClassReplicationInfo</span><span> </span><span>Info</span><span>;</span></div></div><div><div><div>71</div></div><div><span>    </span><span>InitClassReplicationInfo</span><span>(</span><span>Info</span><span>, </span><span>Class</span><span>, </span><span>IsSpatialized</span><span>(</span><span>Mapping</span><span>));</span></div></div><div><div><div>72</div></div><div><span>    </span><span>GlobalActorReplicationInfoMap</span><span>.</span><span>SetClassInfo</span><span>(</span><span>Class</span><span>, </span><span>Info</span><span>);</span></div></div><div><div><div>73</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>74</div></div><div>
</div></div><div><div><div>75</div></div><div><span><span>  </span></span><span>UReplicationGraphNode_ActorListFrequencyBuckets::</span><span>DefaultSettings</span><span>.</span><span>NumBuckets</span><span> </span><span>=</span><span> </span><span>3</span><span>;</span></div></div><div><div><div>76</div></div><div><span><span>  </span></span><span>UReplicationGraphNode_ActorListFrequencyBuckets::</span><span>DefaultSettings</span><span>.</span><span>BucketThresholds</span><span>.</span><span>Reset</span><span>();</span></div></div><div><div><div>77</div></div><div><span>}</span></div></div><div><div><div>78</div></div><div>
</div></div><div><div><div>79</div></div><div><span>void</span><span><span> </span><span>UExampleReplicationGraph</span><span>::</span></span><span>InitGlobalGraphNodes</span><span>()</span></div></div><div><div><div>80</div></div><div><span>{</span></div></div><div><div><div>81</div></div><div><span>  </span><span>GridNode</span><span> </span><span>=</span><span> </span><span>CreateNewNode</span><span>&lt;</span><span>UReplicationGraphNode_GridSpatialization2D</span><span>&gt;();</span></div></div><div><div><div>82</div></div><div><span>  </span><span>GridNode</span><span>-&gt;</span><span>CellSize</span><span> </span><span>=</span><span> </span><span><span>10000.</span><span>f</span></span><span>;</span><span>                           // 100 m</span></div></div><div><div><div>83</div></div><div><span>  </span><span>GridNode</span><span>-&gt;</span><span>SpatialBias</span><span> </span><span>=</span><span> </span><span>FVector2D</span><span>(</span><span>-</span><span><span>150000.</span><span>f</span></span><span>, </span><span>-</span><span><span>200000.</span><span>f</span></span><span>);</span><span> // 맵의 최소 좌표</span></div></div><div><div><div>84</div></div><div><span>  </span><span>GridNode</span><span>-&gt;</span><span>AddToClassRebuildDenyList</span><span><span>(</span><span>AActor</span><span>::</span></span><span>StaticClass</span><span>());</span></div></div><div><div><div>85</div></div><div><span>  </span><span>AddGlobalGraphNode</span><span>(</span><span>GridNode</span><span>);</span></div></div><div><div><div>86</div></div><div>
</div></div><div><div><div>87</div></div><div><span>  </span><span>AlwaysRelevantNode</span><span> </span><span>=</span><span> </span><span>CreateNewNode</span><span>&lt;</span><span>UReplicationGraphNode_ActorList</span><span>&gt;();</span></div></div><div><div><div>88</div></div><div><span>  </span><span>AddGlobalGraphNode</span><span>(</span><span>AlwaysRelevantNode</span><span>);</span></div></div><div><div><div>89</div></div><div>
</div></div><div><div><div>90</div></div><div><span>  </span><span>AddGlobalGraphNode</span><span>(</span><span>CreateNewNode</span><span>&lt;</span><span>UExampleNode_PlayerStateFrequencyLimiter</span><span>&gt;());</span></div></div><div><div><div>91</div></div><div><span>}</span></div></div><div><div><div>92</div></div><div>
</div></div><div><div><div>93</div></div><div><span>void</span><span><span> </span><span>UExampleReplicationGraph</span><span>::</span></span><span>InitConnectionGraphNodes</span><span>(</span><span>UNetReplicationGraphConnection</span><span>*</span><span><span> </span><span>RepGraphConnection</span><span>)</span></span></div></div><div><div><div>94</div></div><div><span>{</span></div></div><div><div><div>95</div></div><div><span><span>  </span></span><span>Super::</span><span>InitConnectionGraphNodes</span><span>(</span><span>RepGraphConnection</span><span>);</span></div></div><div><div><div>96</div></div><div><span><span>  </span></span><span>// viewer(PlayerController)와 ViewTarget을 매 프레임 다시 모으는 엔진 노드. owner 전용 액터도 여기에 넣는다</span></div></div><div><div><div>97</div></div><div><span>  </span><span>UReplicationGraphNode_AlwaysRelevant_ForConnection</span><span>*</span><span> </span><span>Node</span><span> </span><span>=</span><span> </span><span>CreateNewNode</span><span>&lt;</span><span>UReplicationGraphNode_AlwaysRelevant_ForConnection</span><span>&gt;();</span></div></div><div><div><div>98</div></div><div><span>  </span><span>AddConnectionGraphNode</span><span>(</span><span>Node</span><span>, </span><span>RepGraphConnection</span><span>);</span></div></div><div><div><div>99</div></div><div><span>  </span><span>ConnectionNodes</span><span>.</span><span>Add</span><span>(</span><span>RepGraphConnection</span><span>-&gt;</span><span>NetConnection</span><span>, </span><span>Node</span><span>);</span></div></div><div><div><div>100</div></div><div><span>}</span></div></div><div><div><div>101</div></div><div>
</div></div><div><div><div>102</div></div><div><span>void</span><span><span> </span><span>UExampleReplicationGraph</span><span>::</span></span><span>RouteAddNetworkActorToNodes</span><span>(</span><span>const</span><span> </span><span>FNewReplicatedActorInfo</span><span>&amp;</span><span><span> </span><span>ActorInfo</span><span>, </span></span><span>FGlobalActorReplicationInfo</span><span>&amp;</span><span><span> </span><span>GlobalInfo</span><span>)</span></span></div></div><div><div><div>103</div></div><div><span>{</span></div></div><div><div><div>104</div></div><div><span>  </span><span>switch</span><span> (</span><span>ClassRepNodePolicies</span><span>.</span><span>GetChecked</span><span>(</span><span>ActorInfo</span><span>.</span><span>Class</span><span>))</span></div></div><div><div><div>105</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>106</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>NotRouted</span><span>:              </span><span>break</span><span>;</span></div></div><div><div><div>107</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>RelevantAllConnections</span><span>: </span><span>AlwaysRelevantNode</span><span>-&gt;</span><span>NotifyAddNetworkActor</span><span>(</span><span>ActorInfo</span><span>); </span><span>break</span><span>;</span></div></div><div><div><div>108</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>RelevantOwnerConnection</span><span>:</span></div></div><div><div><div>109</div></div><div><span>    </span><span>if</span><span> (</span><span>UNetConnection</span><span>*</span><span> </span><span>Connection</span><span> </span><span>=</span><span> </span><span>ActorInfo</span><span>.</span><span>Actor</span><span>-&gt;</span><span>GetNetConnection</span><span>())</span></div></div><div><div><div>110</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>111</div></div><div><span>      </span><span>if</span><span> (</span><span>TObjectPtr</span><span>&lt;</span><span>UReplicationGraphNode_AlwaysRelevant_ForConnection</span><span>&gt;*</span><span> </span><span>Node</span><span> </span><span>=</span><span> </span><span>ConnectionNodes</span><span>.</span><span>Find</span><span>(</span><span>Connection</span><span>))</span></div></div><div><div><div>112</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>113</div></div><div><span><span>        </span></span><span>(</span><span>*</span><span>Node</span><span>)-&gt;</span><span>NotifyAddNetworkActor</span><span>(</span><span>ActorInfo</span><span>);</span></div></div><div><div><div>114</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>115</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>116</div></div><div><span>    </span><span>break</span><span>;</span><span> // owner가 아직 없으면 다음 프레임에 다시 시도하는 목록이 따로 필요하다</span></div></div><div><div><div>117</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>Spatialize_Static</span><span>:      </span><span>GridNode</span><span>-&gt;</span><span>AddActor_Static</span><span>(</span><span>ActorInfo</span><span>, </span><span>GlobalInfo</span><span>);   </span><span>break</span><span>;</span></div></div><div><div><div>118</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>Spatialize_Dynamic</span><span>:     </span><span>GridNode</span><span>-&gt;</span><span>AddActor_Dynamic</span><span>(</span><span>ActorInfo</span><span>, </span><span>GlobalInfo</span><span>);  </span><span>break</span><span>;</span></div></div><div><div><div>119</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>Spatialize_Dormancy</span><span>:    </span><span>GridNode</span><span>-&gt;</span><span>AddActor_Dormancy</span><span>(</span><span>ActorInfo</span><span>, </span><span>GlobalInfo</span><span>); </span><span>break</span><span>;</span></div></div><div><div><div>120</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>121</div></div><div><span>}</span></div></div><div><div><div>122</div></div><div>
</div></div><div><div><div>123</div></div><div><span>void</span><span><span> </span><span>UExampleReplicationGraph</span><span>::</span></span><span>RouteRemoveNetworkActorToNodes</span><span>(</span><span>const</span><span> </span><span>FNewReplicatedActorInfo</span><span>&amp;</span><span><span> </span><span>ActorInfo</span><span>)</span></span></div></div><div><div><div>124</div></div><div><span>{</span></div></div><div><div><div>125</div></div><div><span>  </span><span>switch</span><span> (</span><span>ClassRepNodePolicies</span><span>.</span><span>GetChecked</span><span>(</span><span>ActorInfo</span><span>.</span><span>Class</span><span>))</span></div></div><div><div><div>126</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>127</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>NotRouted</span><span>:              </span><span>break</span><span>;</span></div></div><div><div><div>128</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>RelevantAllConnections</span><span>: </span><span>AlwaysRelevantNode</span><span>-&gt;</span><span>NotifyRemoveNetworkActor</span><span>(</span><span>ActorInfo</span><span>); </span><span>break</span><span>;</span></div></div><div><div><div>129</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>RelevantOwnerConnection</span><span>:</span></div></div><div><div><div>130</div></div><div><span>    </span><span>if</span><span> (</span><span>UNetConnection</span><span>*</span><span> </span><span>Connection</span><span> </span><span>=</span><span> </span><span>ActorInfo</span><span>.</span><span>Actor</span><span>-&gt;</span><span>GetNetConnection</span><span>())</span></div></div><div><div><div>131</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>132</div></div><div><span>      </span><span>if</span><span> (</span><span>TObjectPtr</span><span>&lt;</span><span>UReplicationGraphNode_AlwaysRelevant_ForConnection</span><span>&gt;*</span><span> </span><span>Node</span><span> </span><span>=</span><span> </span><span>ConnectionNodes</span><span>.</span><span>Find</span><span>(</span><span>Connection</span><span>))</span></div></div><div><div><div>133</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>134</div></div><div><span><span>        </span></span><span>(</span><span>*</span><span>Node</span><span>)-&gt;</span><span>NotifyRemoveNetworkActor</span><span>(</span><span>ActorInfo</span><span>, </span><span>false</span><span>);</span></div></div><div><div><div>135</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>136</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>137</div></div><div><span>    </span><span>break</span><span>;</span></div></div><div><div><div>138</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>Spatialize_Static</span><span>:      </span><span>GridNode</span><span>-&gt;</span><span>RemoveActor_Static</span><span>(</span><span>ActorInfo</span><span>);   </span><span>break</span><span>;</span></div></div><div><div><div>139</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>Spatialize_Dynamic</span><span>:     </span><span>GridNode</span><span>-&gt;</span><span>RemoveActor_Dynamic</span><span>(</span><span>ActorInfo</span><span>);  </span><span>break</span><span>;</span></div></div><div><div><div>140</div></div><div><span>  </span><span>case</span><span> EClassRepNodeMapping::</span><span>Spatialize_Dormancy</span><span>:    </span><span>GridNode</span><span>-&gt;</span><span>RemoveActor_Dormancy</span><span>(</span><span>ActorInfo</span><span>); </span><span>break</span><span>;</span></div></div><div><div><div>141</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>142</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// PlayerState를 프레임당 TargetActorsPerFrame개씩 나눠 돌려 보낸다</span></div></div><div><div><div>2</div></div><div><span>void</span><span><span> </span><span>UExampleNode_PlayerStateFrequencyLimiter</span><span>::</span></span><span>PrepareForReplication</span><span>()</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>ReplicationActorLists</span><span>.</span><span>Reset</span><span>();</span></div></div><div><div><div>5</div></div><div><span>  </span><span>ReplicationActorLists</span><span>.</span><span>AddDefaulted</span><span>();</span></div></div><div><div><div>6</div></div><div><span>  </span><span>for</span><span> (</span><span>TActorIterator</span><span>&lt;</span><span>APlayerState</span><span>&gt; </span><span>It</span><span>(</span><span>GetWorld</span><span>()); </span><span>It</span><span>; </span><span>++</span><span>It</span><span>)</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>IsActorValidForReplicationGather</span><span>(</span><span>*</span><span>It</span><span>)) </span><span>continue</span><span>;</span></div></div><div><div><div>9</div></div><div><span>    </span><span>if</span><span> (</span><span>ReplicationActorLists</span><span>.</span><span>Last</span><span>().</span><span>Num</span><span>() </span><span>&gt;=</span><span> </span><span>TargetActorsPerFrame</span><span>) </span><span>ReplicationActorLists</span><span>.</span><span>AddDefaulted</span><span>();</span></div></div><div><div><div>10</div></div><div><span>    </span><span>ReplicationActorLists</span><span>.</span><span>Last</span><span>().</span><span>Add</span><span>(</span><span>*</span><span>It</span><span>);</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>void</span><span><span> </span><span>UExampleNode_PlayerStateFrequencyLimiter</span><span>::</span></span><span>GatherActorListsForConnection</span><span>(</span><span>const</span><span> </span><span>FConnectionGatherActorListParameters</span><span>&amp;</span><span><span> </span><span>Params</span><span>)</span></span></div></div><div><div><div>15</div></div><div><span>{</span></div></div><div><div><div>16</div></div><div><span>  </span><span>if</span><span> (</span><span>ReplicationActorLists</span><span>.</span><span>Num</span><span>() </span><span>==</span><span> </span><span>0</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>17</div></div><div><span>  </span><span>Params</span><span>.</span><span>OutGatheredReplicationLists</span><span>.</span><span>AddReplicationActorList</span><span>(</span><span>ReplicationActorLists</span><span>[</span><span>Params</span><span>.</span><span>ReplicationFrameNum</span><span> </span><span>%</span><span> </span><span>ReplicationActorLists</span><span>.</span><span>Num</span><span>()]);</span></div></div><div><div><div>18</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>라우팅 표는 <code>RouteAddNetworkActorToNodes</code>의 <code>switch</code>에 들어 있다. 노드 넷(Grid, AlwaysRelevant, 연결별 AlwaysRelevant, PlayerState 빈도 제한)은 글 첫머리 그림과 같은 구성이다. 그래프는 <code>UReplicationDriver::CreateReplicationDriverDelegate</code>나 ini의 <code>ReplicationDriverClassName</code>으로 지정한다. 노드는 전역·연결 노드면 <code>CreateNewNode</code>, 노드의 자식이면 <code>CreateChildNode</code>로만 만든다.</p><p>기본값을 dynamic으로 두는 것은 잘못 넣었을 때의 결과가 달라서다. 움직이는 액터를 static으로 넣으면 보이지 않지만 안 움직이는 액터를 dynamic으로 넣으면 비용만 든다. static과 dormancy는 클래스를 알 때만 명시한다.</p><p>탈것의 무기처럼 부모가 리플리케이션될 때만 함께 가면 되는 액터도 있다. 이런 액터는 노드에 넣지 않고 <code>AddDependentActor</code>로 부모에 묶는다. 기본 드라이버에서 <code>bNetUseOwnerRelevancy</code>로 owner를 따라가던 장비·무기 클래스가 여기 해당한다. Lyra 규칙으로는 <code>NotRouted</code>가 되어 그래프로 옮기면 조용히 보이지 않게 된다. 그래서 부모에 묶거나 dynamic으로 명시해야 한다.</p><p>부모와 같은 프레임에 고려된다는 것까지만 보장되고 같은 패킷에 들어간다는 보장은 없다. dependent actor 목록은 노드가 아니라 액터 정보의 배열이라 PrintAll의 노드 트리에는 나오지 않는다.</p></section><section><h3>Team Node와 Cull Distance 0<a href="#team-node와-cull-distance-0"><span>#</span></a></h3><p>팀원을 거리와 무관하게 보여 주는 노드는 리스트만 돌려줘서는 동작하지 않는다. prioritize 단계의 거리 컬링이 리스트 출처를 가리지 않기 때문이다. 포럼에서 본 “팀원을 리스트에 직접 넣었는데도 거리 컬링된다”는 보고가 이 경우로 보인다. 노드는 gather 때 그 연결의 액터 정보에 <code>bForceCullDistanceToZero</code>를 세우고 팀에서 빠진 액터는 플래그를 풀어야 한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// OpenWorldReplicationGraph.cpp, UOpenWorldReplicationGraphNode_Team::GatherActorListsForConnection</span></div></div><div><div><div>2</div></div><div><span>for</span><span> (</span><span>FActorRepListType</span><span> </span><span>Actor</span><span> : </span><span>TeamActorList</span><span>)</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>FConnectionReplicationActorInfo</span><span>&amp;</span><span> </span><span>Info</span><span> </span><span>=</span><span> </span><span>Params</span><span>.</span><span>ConnectionManager</span><span>.</span><span>ActorInfoMap</span><span>.</span><span>FindOrAdd</span><span>(</span><span>Actor</span><span>);</span></div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span>Info</span><span>.</span><span>bForceCullDistanceToZero</span><span>) { </span><span>Info</span><span>.</span><span>bForceCullDistanceToZero</span><span> </span><span>=</span><span> </span><span>1</span><span>; </span><span>Forced</span><span>.</span><span>Add</span><span>(</span><span>Actor</span><span>); }</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div><div><div><div>7</div></div><div><span>Params</span><span>.</span><span>OutGatheredReplicationLists</span><span>.</span><span>AddReplicationActorList</span><span>(</span><span>TeamActorList</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Team Node는 같은 팀의 연결들이 공유하는 노드라 전역 노드 목록에는 없다. 그래도 <code>CreateNewNode</code>로 만들고 <code>bRequiresPrepareForReplicationCall</code>을 켜 두면 <code>InitNode</code>가 <code>PrepareForReplicationNodes</code>에 넣어 준다. <code>CreateChildNode</code>로 만든 노드만 직접 넣어야 한다. 둘 다 하면 <code>PrepareForReplication</code>이 프레임마다 두 번 돈다. 팀원의 Pawn은 Grid에도 있어 가까운 팀원은 두 리스트에서 돌아오지만 같은 프레임의 중복은 <code>LastRepFrameNum == FrameNum</code>으로 걸러진다.</p></section><section><h3>만들 때 걸리는 것<a href="#만들-때-걸리는-것"><span>#</span></a></h3><ul>
<li><strong>Blueprint 서브클래스의 설정.</strong> <code>InitGlobalActorClassSettings</code>에서 <code>TObjectIterator&lt;UClass&gt;</code>로 클래스를 돌 때 <code>SKEL_</code>, <code>REINST_</code>로 시작하는 컴파일 중간 클래스는 건너뛰어야 한다. <code>GlobalActorReplicationInfoMap.SetInitClassInfoFunc</code>로 lazy init도 걸어야 한다. 리플리케이션 그래프는 <code>BeginPlay</code> 전에 초기화되므로 그 시점에 로드되지 않은 Blueprint 서브클래스는 순회에 나오지 않는다. lazy init이 없으면 부모 클래스 값이 복사돼 동작은 하지만 그 서브클래스에 설정을 줄 길이 없어진다.</li>
<li><strong>설정은 엔진 초기화 전에.</strong> 같은 이유로 클래스 설정은 CDO에서만 읽을 수 있다. 게임 코드에서 CVar를 바꾸는 것도 맵 로드 뒤에 실행되는 <code>-ExecCmds</code>도 늦다. <code>UDeveloperSettingsBackedByCVars</code>로 둔 값은 <code>-dpcvars="OpenWorld.RepGraph.DynamicActorFrequencyBuckets=1"</code> 형식으로 줘야 초기화 때 읽힌다. 설정값 절의 측정은 이 방식으로 조건을 바꿨다.</li>
<li><strong>드라이버 지정.</strong> ini의 <code>ReplicationDriverClassName</code>과 <code>UReplicationDriver::CreateReplicationDriverDelegate</code> 둘 다 공식 문서에 있다. 델리게이트가 ini보다 우선하고 <code>nullptr</code>를 돌려주면 기본 드라이버로 간다. 어느 쪽이든 <code>NetDriverName</code>이 <code>NAME_GameNetDriver</code>인지 확인해야 한다. <code>InitBase</code>를 지나는 서버 드라이버가 모두 같은 델리게이트를 부르므로 DemoNetDriver(리플레이)와 BeaconNetDriver도 해당된다.</li>
<li><strong><code>DynamicSpatialFrequency</code> 존 표.</strong> 헤더의 <code>MinDotProduct</code> 주석은 이 값 이상이어야 그 존이라고 적지만 <code>CalcFrequencyForActor</code>는 <code>DotP &lt;= MinDotProduct</code>인 첫 존을 고른다. 주석대로 <code>{0.8 앞쪽}, {-1.0 나머지}</code> 순으로 적으면 정면의 액터가 어느 존에도 들어가지 않아 리플리케이션에서 빠진다. 액터마다 프레임마다 “was not placed in any valid zone” 경고가 찍혀 측정 60초에 약 12,900줄, 프레임당 1.1 ms의 로그 비용이 Insights 측정값에 들어갔다. 설정값 절의 0.40 ms는 존을 고친 뒤 다시 잰 값이다. 존은 오름차순으로 적고 마지막을 1.0으로 끝낸다.</li>
<li><strong>스트리밍 레벨.</strong> 노드의 리스트는 레벨별로 나뉘어 있고 gather는 클라이언트가 그 레벨을 로드했을 때만(<code>ClientVisibleLevelNamesRef</code>) 돌려준다. 로드하지 않은 레벨의 액터는 <code>LogReplicationGraph</code> Verbose에 “Level Not Loaded”로 남는다. 스트리밍 레벨의 <code>bAlwaysRelevant</code> 액터는 전역 노드 대신 Lyra의 <code>AlwaysRelevantStreamingLevelActors</code>처럼 레벨별 리스트에 둔다. 액터의 outer가 바뀌는 프로젝트는 5.5 업그레이드 노트대로 <code>Net.RepGraph.HandleDynamicActorRename</code>(기본 0)을 켠다.</li>
<li><strong>viewer는 카메라다.</strong> 리플리케이션 그래프의 viewer는 Pawn이 아니라 <code>GetPlayerViewPoint</code>가 돌려주는 위치다. 거리 검사도 이 위치 기준이라 Cull Distance 150 m보다 높이 띄운 관전 카메라는 바닥의 액터를 하나도 받지 못한다. 관전 카메라가 있는 게임은 viewer가 어디인지부터 확인한다.</li>
</ul></section></section>
<section><h2>도입 기준과 Iris<a href="#도입-기준과-iris"><span>#</span></a></h2><p>기준은 서버의 <code>ServerReplicateActors</code>에서 보낼지 말지를 정하는 비용과 실제로 보내는 비용의 비율이다. 기본 드라이버에서 <code>stat game</code>의 <code>Consider Actors Time</code>과 <code>Prioritize Actors Time</code>을 합친 값이 <code>Replicate Actor Time</code>보다 크면 리플리케이션 그래프가 그 몫을 줄인다. 둘이 비슷하거나 리플리케이션 쪽이 크면 리플리케이션 그래프는 액터당 0.5 µs쯤을 더 쓰는 쪽이다. 측정에서는 Insights의 consider list와 prioritize 타이머로 같은 비율을 봤다.</p><p>그 비율은 리플리케이션 액터 수와 연결 수의 곱에 따라 커진다. 맵이 넓고 액터가 흩어져 있을수록, 연결이 많을수록 차이가 커진다. 반대로 액터 대부분이 모든 플레이어에게 관련 있는 게임(작은 아레나, 소규모 협동)에서는 리플리케이션 그래프가 줄일 검토가 없다. 보내는 양을 줄이려면 기본 드라이버의 <code>NetUpdateFrequency</code>를 서버 프레임 레이트보다 낮게 잡으면 충분하다. Cluster에서 10 Hz로 낮춘 기본 드라이버는 3.06 ms로, 버킷 3개의 리플리케이션 그래프 4.48 ms보다 쌌다.</p><p>도입하기로 했다면 순서는 다음과 같다.</p><ol>
<li>리플리케이션 그래프 없이 <code>stat game</code>이나 Insights로 검토 비용과 리플리케이션 비용의 비율을 잰다. 이 수치가 없으면 도입 뒤에 무엇이 나아졌는지 말할 수 없다.</li>
<li><code>UBasicReplicationGraph</code>로 켜고 같은 조건을 다시 잰다.</li>
<li>커스텀 그래프는 클래스 라우팅 테이블부터 만든다. 앞의 범주 표대로 나누고 모르는 클래스는 dynamic으로 둔다.</li>
<li>버킷 수와 <code>NetUpdateFrequency</code>로 보내는 횟수를 정한다. <code>Net.RepGraph.PrintAll</code>로 Passed 목록이 기대한 액터로만 차 있는지 확인한다. Cell 크기와 우선순위 스케일은 측정 없이 만지지 않는다.</li>
<li>부록 A의 버그를 피한다. dormant 직후의 reliable multicast, Seamless Travel 중의 multicast, 옮기는 static 액터, <code>bNetTemporary</code>, 끊긴 연결의 dormancy 사본, 멀어진 뒤 파괴된 dormant 액터다.</li>
</ol><p>플러그인은 <code>IsBetaVersion: true</code>로 선언돼 있고 Iris와 양립하지 않는다. 클래스 라우팅은 액터 클래스가 늘 때마다 유지보수 대상이 되고 잘못 라우팅된 액터는 조용히 보이지 않는다. 그래서 2번에서 멈춰도 된다. Basic 그래프는 측정에서 OpenWorld 그래프와 같은 자릿수였다. Team Node와 owner 변경이 필요 없는 게임이라면 그것으로 충분하다.</p><p>커뮤니티에는 리플리케이션 그래프가 “deprecated”됐다는 말이 돌지만 Epic의 공개 발언은 다르다. 2026년 1월 포럼 글 “5.7 Iris Strategy”에서 Epic의 Alex Koumandarakis는 레거시 리플리케이션 시스템과 리플리케이션 그래프를 deprecate할 계획이 없고 세 시스템이 나란히 지원되도록 계획돼 있다고 답했다. 다만 릴리스 노트의 리플리케이션 그래프 항목은 5.4의 수정과 5.5 업그레이드 노트의 <code>HandleDynamicActorRename</code> 권장이 마지막이고 5.6부터 5.8까지는 없다. 유지보수는 되고 개발은 멈춘 상태로 보인다.</p><p>Iris와는 양자택일이다. 5.8 문서 “Migrate to Iris”는 Iris가 리플리케이션 그래프를 지원하지 않으며 Iris의 Filter와 Prioritizer가 리플리케이션 그래프 기능의 대체물이라고 적고 있다. 소스에서는 Iris를 쓰도록 설정된 NetDriver에 <code>SetReplicationDriver</code>로 리플리케이션 드라이버를 붙이려 하면 <code>checkf</code>로 멈춘다.</p><p>Fortnite는 2024년 4월에 Iris로 전환했다고 Unreal Fest Orlando 2025에서 Petter Rangström이 말했다. 같은 강연에서 리플리케이션 그래프가 “Fortnite가 요구하는 액터·연결 수는 감당했지만 경쟁 게임 같은 고밀도 액션에서는 여전히 버거웠다”는 것이 Iris를 만든 동기로 나온다. 강연의 “Porting” 슬라이드(20분 30초)에 나온 대응 관계는 다음과 같다. 일부는 소스에서 확인해 덧붙였다.</p>

<table><thead><tr><th>리플리케이션 그래프</th><th>Iris</th></tr></thead><tbody><tr><td><code>AlwaysRelevant</code> 노드, <code>NotRouted</code></td><td>ini의 클래스별 always/never relevant 설정</td></tr><tr><td><code>GridSpatialization2D</code></td><td><code>UNetObjectGridFilter</code>와 거리 기반 Prioritizer</td></tr><tr><td><code>AlwaysRelevant_ForConnection</code></td><td>never relevant로 두고 inclusion group에 추가</td></tr><tr><td>frequency bucket, <code>DynamicSpatialFrequency</code>, <code>ReplicationPeriodFrame</code></td><td>Prioritizer. 누적 priority가 1을 넘을 때 리플리케이션하고 1 미만의 priority로 빈도를 낮춘다</td></tr><tr><td>연결별 Cull Distance override, dependent actor</td><td><code>ReplicationSystemUtil</code></td></tr><tr><td>dormancy 노드, Fast Shared Path, TearOff 노드</td><td>강연에서 언급 없음. 공식 “Migrate to Iris” 문서는 dormancy를 바뀌지 않는 기능으로 적는다</td></tr></tbody></table><p><code>NetUpdateFrequency</code>의 의미가 바뀐다는 점이 가장 큰 차이다. Iris에서는 폴링 주기일 뿐이고 언제 보낼지는 누적 priority가 정한다. 리플리케이션 그래프의 <code>ReplicationPeriodFrame</code>을 그대로 옮길 자리는 없다. 5.8.3 기준으로 리플리케이션 그래프는 플러그인 하나와 C++ 약 1,200줄(OpenWorld 그래프의 헤더, 구현, 설정 합계)로 켜진다. Fortnite가 2024년까지 이 경로를 썼다. Iris는 custom <code>NetSerialize</code>와 서브오브젝트 등록 같은 호환성 점검이 필요하고 리플레이가 아직 레거시 경로를 쓴다.</p></section>
<section><h2>부록 A. 5.8.3에서 확인한 버그와 함정<a href="#부록-a-583에서-확인한-버그와-함정"><span>#</span></a></h2><p>커뮤니티에 보고된 문제 중 5.8.3 소스에서 확인한 것이다. 다섯은 테스트 프로젝트에서 재현했고 Seamless Travel 하나는 소스로만 확인했다. 소스 분석과 재현 절차는 접힌 블록에 있다.</p><section><h3>dormant 직후의 reliable multicast가 유실된다<a href="#dormant-직후의-reliable-multicast가-유실된다"><span>#</span></a></h3><ul>
<li>현상: 액터를 <code>DORM_DormantAll</code>로 보낸 직후 reliable multicast를 보내면 네트워크 지연이 있을 때 일부 클라이언트가 받지 못한다. Epic 포럼에 2026년 5월 올라온 보고(UE-378758)다. Epic이 재현을 확인했고 2026년 7월 기준 미수정이다. 테스트 프로젝트에서는 <code>PktLag=200</code>에서 잠재운 지 4프레임 뒤에 보낸 reliable RPC 50개가 전부 사라졌고 기본 드라이버는 하나도 잃지 않았다.</li>
<li>원인: 잠재운 액터의 채널이 <code>Closing</code>인 동안 리플리케이션 그래프는 close bunch의 ack가 올 때까지 연결 정보의 채널을 비우지 않는다. multicast 경로는 그 연결을 건너뛰고 새로 열지도 않는다.</li>
<li>대처: reliable RPC를 먼저 보내고 잠재운다. 잠든 액터에 보내야 하면 그 연결의 채널이 정리된 뒤(<code>NotifyActorChannelCleanedUp</code>)로 미룬다.</li>
</ul>
소스 분석과 재현 절차<p>기본 드라이버와 리플리케이션 그래프는 닫힌 채널을 다르게 처리한다.</p><ol>
<li><code>SetNetDormancy</code>를 받은 액터는 다음 리플리케이션에서 <code>StartBecomingDormant</code>가 걸리고 ack 안 된 변경이 없으면(<code>FObjectReplicator::ReadyForDormancy</code>, <code>net.DormancyHysteresis</code> 기본 0) 바로 다음 틱에 <code>Close(Dormancy)</code>가 된다. 한동안 값이 바뀌지 않은 액터는 잠재우자마자 채널이 닫히는 셈이다.</li>
<li><code>UActorChannel::Close</code>는 <code>Closing</code>을 세우고 엔진의 <code>ActorChannels</code> 맵에서 액터를 뺀다. 기본 드라이버는 그 순간부터 채널이 없다고 보고 다음 틱에 새 채널을 열 수 있다.</li>
<li>리플리케이션 그래프는 같은 시점에 호출되는 <code>NotifyActorChannelRemoved</code>가 비어 있다(주석: “we’re still waiting for the close bunch to be acked”). close bunch의 ack가 와서 <code>CleanUp</code>될 때의 <code>NotifyActorChannelCleanedUp</code>에서야 연결 정보의 <code>Channel</code>을 비운다.</li>
<li>그 사이 <code>ReplicateSingleActor</code>는 <code>Closing</code> 채널을 만나면 0을 돌려주고 multicast RPC 경로는 그 연결을 <code>continue</code>로 건너뛰며 새로 열지도 않는다. reliable이어도 재시도는 없다.</li>
</ol><p><img alt="" loading="lazy" width="1600" height="1008" src="/_astro/diagram-closing-window.k4az4yGP_27yMG4.webp" /></p><p>AI 50개를 같은 프레임에 <code>DORM_DormantAll</code>로 보내고 N프레임 뒤 액터마다 reliable multicast를 하나씩 보낸 뒤 두 클라이언트가 받은 수를 셌다. Cluster 1,000개 조건이라 50개 모두 두 클라이언트의 Cull Distance 안이다(움직이다 벗어나는 한두 개는 제외). 서버 송신에는 <code>PktLag=200</code>을 걸었다. 표는 두 클라이언트가 각각 받은 RPC 수다.</p>

<table><thead><tr><th>RPC까지의 지연</th><th>OpenWorld 그래프</th><th>기본 드라이버</th></tr></thead><tbody><tr><td>4프레임 (190 ms)</td><td>0 / 0</td><td>48 / 49</td></tr><tr><td>8프레임 (380 ms)</td><td>32 / 32</td><td>측정 안 함</td></tr><tr><td>12프레임</td><td>49 / 49</td><td>49 / 49</td></tr><tr><td>16프레임</td><td>48 / 49</td><td>측정 안 함</td></tr><tr><td>24프레임</td><td>49 / 49</td><td>49 / 49</td></tr></tbody></table><p>리플리케이션 그래프에서는 4프레임 뒤에 보낸 reliable RPC 50개가 전부 사라졌고 8프레임 뒤에는 18개가 사라졌다. 기본 드라이버는 같은 지연에서 잃지 않는다.</p><p>잠재우고 두어 프레임 뒤에 채널이 닫힌다. close bunch가 200 ms 뒤에 도착하고 그 ack가 돌아올 때까지 채널은 <code>Closing</code>이다. 잠재운 시점부터 재면 창은 8프레임(약 370 ms)에 가깝다. ack가 돌아와 채널이 정리된 연결부터 새 채널이 열리며 RPC가 다시 가므로 8프레임에서는 일부만 도착했고 12프레임부터는 전부 도착했다. 지연이 없으면 창이 로컬 ack 한 번의 길이뿐이라 어느 드라이버도 잃지 않는다.</p></section><section><h3>Seamless Travel 중의 multicast<a href="#seamless-travel-중의-multicast"><span>#</span></a></h3><ul>
<li>현상: 클라이언트가 Seamless Travel을 끝내기 전에 서버가 Persistent Level 액터(GameState 등)에 reliable multicast를 보내면 클라이언트는 도착 후 그 액터를 파괴해 버린다. Epic이 알려진 문제(UE-361443)로 확인했고 2026년 7월 기준 미수정이다. 테스트 프로젝트에서 재현하지는 않았고 소스로만 확인했다.</li>
<li>원인: owner RPC 경로는 채널을 열기 전에 <code>NetDriver-&gt;IsLevelInitializedForActor</code>를 확인하지만 multicast 경로는 확인하지 않는다.</li>
<li>대처: <code>ProcessRemoteFunction</code>을 오버라이드해 multicast에서도 같은 검사를 넣는다.</li>
</ul></section><section><h3>static으로 넣은 액터를 옮기면 보이지 않는다<a href="#static으로-넣은-액터를-옮기면-보이지-않는다"><span>#</span></a></h3><ul>
<li>현상: 스폰 직후 위치를 바꾸는 액터, 플레이어가 집어서 옮기는 소품을 static으로 두면 옛 Cell에 남아 끝까지 보이지 않는다. 포럼과 블로그에서 본 “static 액터가 뭉텅이로 사라진다”는 보고가 이 경우로 보인다.</li>
<li>원인: <code>AddActor_Static</code>은 추가 시점의 위치로 Cell을 정하고 다시 계산하지 않는다. 깨워도 소용없다. Grid는 <code>AddActor_Dormancy</code>로 들어온 액터의 dormancy 변화만 구독한다. <code>Spatialize_Dormancy</code>로 넣은 액터는 <code>SetNetDormancy(DORM_Awake)</code>로 깨우면 dynamic 리스트로 옮겨져 다음 <code>PrepareForReplication</code>에서 새 Cell에 들어간다. <code>Spatialize_Static</code>으로 넣은 액터는 깨워도 옛 Cell에 남는다. 액터 정보에 캐시된 <code>WorldLocation</code>도 갱신되지 않아 거리 검사까지 옛 위치로 한다. <code>FlushNetDormancy</code>는 연결별 dormancy 사본만 고치고 Cell은 옮기지 않는다.</li>
<li>대처: 옮길 수 있는 소품은 <code>Spatialize_Dormancy</code>로 두고 집을 때 깨웠다가 놓을 때 다시 재운다. 이미 static으로 등록한 액터를 옮겨야 하면 노드에서 빼고 다시 넣는다.</li>
</ul><p><img alt="" loading="lazy" width="1600" height="888" src="/_astro/diagram-static-move.BS0gvBDj_2qX4GG.webp" /></p>
재현 절차<p>맵 반대편(2 km 밖)의 소품 5개를 플레이어 0의 Pawn에서 3–5 m 옆으로 옮기고 값을 하나 바꾼 뒤 그 클라이언트가 10 m 안에서 보는 소품 수를 30초 동안 셌다.</p>

<table><thead><tr><th>소품 상태와 조작</th><th>기본 드라이버</th><th>Basic 그래프 (dormancy 경로)</th><th>OpenWorld 그래프 (static 라우팅)</th></tr></thead><tbody><tr><td>깨어 있는 소품을 옮김</td><td>5</td><td>5</td><td>0</td></tr><tr><td>잠든 소품을 옮기고 <code>FlushNetDormancy</code></td><td>측정 안 함</td><td>0</td><td>측정 안 함</td></tr><tr><td>잠든 소품을 <code>DORM_Awake</code>로 깨우고 옮김</td><td>측정 안 함</td><td>5</td><td>0</td></tr></tbody></table><p>static으로 넣은 소품은 깨어 있든 깨우든 옛 Cell에 남아 끝까지 보이지 않는다. dormancy 경로도 잠든 채로 옮기면 결과가 같다. flush는 연결별 dormancy 사본에 액터를 다시 넣을 뿐 Cell을 옮기지 않는다. <code>DORM_Awake</code>로 깨워서 옮겼을 때만 다음 <code>PrepareForReplication</code>부터 5개가 보였다.</p><p>4.24 포럼 보고에 있던 “<code>Spatialize_Static</code> 액터를 파괴하면 ensure가 난다”는 문제는 5.8.3에서 측정 중에 소품 10개를 <code>Destroy</code>해도 재현되지 않았다. 그 뒤 수정된 것으로 보인다.</p></section><section><h3>bNetTemporary는 지원되지 않는다<a href="#bnettemporary는-지원되지-않는다"><span>#</span></a></h3><ul>
<li>현상: <code>UReplicationGraph::AddNetworkActor</code>는 <code>bNetTemporary</code> 액터에 대해 ensure를 내고(5.4 릴리스 노트의 “Ensure when an actor is replicated with bNetTemporary set”), 그 뒤에는 2019년 포럼의 관찰대로 <code>bNetTemporary</code>를 무시하고 수백 번 리플리케이션한다.</li>
<li>원인: <code>bNetTemporary</code> 액터의 첫 번치는 <code>bClose</code>가 켜져 있어 채널을 열면서 닫는다. 기본 드라이버는 <code>SentTemporaries</code>로 그 액터를 다시 고려하지 않는다(<code>Engine/Private/DataChannel.cpp</code>, <code>NetDriver.cpp</code>). 리플리케이션 그래프는 이 목록을 보지 않으므로 닫힌 채널이 정리되면 다음 버킷 차례에 채널을 새로 열어 다시 보낸다.</li>
<li>대처: 발사체는 <code>bNetTemporary</code> 대신 짧은 수명의 dynamic 액터로 두고 Cell 재구축 금지 목록에 넣는다.</li>
</ul>
재현 절차<p>테스트 프로젝트의 발사체는 <code>bNetTemporary</code>에 <code>InitialLifeSpan</code> 1.5초다. Uniform 5,000개 조건에서 초당 5개를 플레이어 주변에 스폰했다. 리플리케이션 그래프 서버 로그에는 ensure가 한 번 찍힌다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Ensure condition failed: !Actor-&gt;bNetTemporary [File: ReplicationGraph.cpp] [Line: 790]</span></div></div><div><div><div>2</div></div><div><span>ReplicationGraph does not support bNetTemporary. Actor: RepGraphLabProjectile_0 has bNetTemporary set.</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>측정에서 프레임당 채널 열기가 0.02회에서 5.1회가 됐고 <code>ServerReplicateActors</code>는 0.35에서 0.59 ms로 늘었다. 계산상 살아 있는 발사체가 7개 안팎(초당 5개 × 1.5초)일 때의 값이다. 기본 드라이버는 같은 조건에서 2.51 ms로 발사체가 없을 때와 같다.</p></section><section><h3>연결이 끊겨도 남는 dormancy 사본<a href="#연결이-끊겨도-남는-dormancy-사본"><span>#</span></a></h3><ul>
<li>현상: 연결이 끊겨도 Cell의 dormancy 노드에는 그 연결의 사본이 남아 있다. 테스트 프로젝트에서는 재접속 8번에 사본이 3개에서 17개로 늘었고 정리를 켜면 6개까지 줄되 톱니 모양으로 오르내렸다.</li>
<li>원인: 기본값 <code>Net.RepGraph.DormancyNodeObsoleteBehavior</code> 0은 사본을 지우지 않는다. Epic 지원 포럼의 답변은 그 이유로 멀리 간 클라이언트의 add/remove 반복이 만드는 프레임 스파이크를 든다. 정리를 켜도 즉시는 아니다. 떼어내는 코드는 <code>CallFunctionOnValidConnectionNodes</code> 안에만 있어 그 Cell의 dormancy 노드에 액터가 추가·제거되거나 flush될 때만 돌고 gather 때는 돌지 않는다.</li>
<li>대처: <code>DormancyNodeObsoleteBehavior</code>를 1로 두면 연결 객체가 사라진 사본을 지운다. <code>SetNumFramesUntilObsolete</code>를 양수로 두면 살아 있지만 오래 gather되지 않은 연결의 사본까지 지운다. 접속·해제가 잦은 서버라면 두 설정을 같이 켜고 Cell마다 dormant 액터가 많다면 사본의 메모리까지 같이 본다.</li>
</ul>
재현 절차<p>Uniform 5,000개 조건으로 클라이언트 2개를 붙였다. 그 뒤 120초 동안 10초마다 두 번째 클라이언트를 죽이고 다시 띄우기를 8번 반복하면서 살아 있는 <code>UReplicationGraphNode_ConnectionDormancyNode</code> 수를 5초마다 셌다.</p>

<table><thead><tr><th>설정</th><th>시작</th><th>8회 재접속 뒤</th></tr></thead><tbody><tr><td>기본값 (<code>DormancyNodeObsoleteBehavior</code> 0)</td><td>3</td><td>17</td></tr><tr><td><code>DormancyNodeObsoleteBehavior</code> 1, <code>SetNumFramesUntilObsolete(100)</code></td><td>2</td><td>6 (9까지 올랐다가 2로 떨어지는 톱니)</td></tr></tbody></table><p><img alt="" loading="lazy" width="1600" height="800" src="/_astro/chart-dormancy-nodes.CaV8Ts_d_Z2hNiPW.webp" /></p><p>기본값에서는 재접속마다 2개씩 늘고 하나도 줄지 않는다. 정리 옵션을 켜면 조용한 Cell의 사본이 다음 액터 변화가 있을 때까지 남아 노드 수가 9에서 5로, 7에서 2로 떨어졌다가 다시 쌓이는 톱니 모양이 됐다.</p></section><section><h3>멀어진 뒤 파괴된 dormant 액터가 클라이언트에 남는다<a href="#멀어진-뒤-파괴된-dormant-액터가-클라이언트에-남는다"><span>#</span></a></h3><ul>
<li>현상: 클라이언트가 한 번 받은 액터(바닥 아이템)가 잠든 상태에서 그 클라이언트가 멀리 간 뒤 서버가 액터를 파괴하면 클라이언트에는 그 액터가 그대로 남는다. 테스트 프로젝트에서 플레이어를 500 m 밖으로 옮기고 원래 자리의 잠든 루트 100개를 파괴하자 리플리케이션 그래프에서는 클라이언트의 루트가 500개 그대로였고 기본 드라이버에서는 400개가 됐다. Basic 그래프도 남는다.</li>
<li>원인: 채널이 닫힐 때 relevancy 사유면 클라이언트가 액터를 지우지만 dormancy 사유면 남겨 둔다(<code>UActorChannel::CleanUp</code>). 그래서 잠든 액터는 채널 없이 클라이언트에 남아 있다. 그 액터가 파괴되면 기본 드라이버는 거리와 무관하게 파괴 정보를 보낸다(<code>NotifyActorDestroyed</code>의 <code>DormantConnections</code> 검사). 리플리케이션 그래프는 <code>DestructInfoMaxDistanceSquared</code>(기본 150 m, Lyra와 OpenWorld 그래프는 300 m, 높이를 뺀 2D 거리) 안의 viewer에게만 보낸다. 범위 밖이면 <code>OutOfRangeDestroyedActors</code>에 두었다가 viewer가 충분히 움직인 뒤 다시 검사해 범위 안이면 보낸다.</li>
<li>대처: 돌아오면 지워진다. 플레이어를 되돌리자 100개가 사라졌다. 멀리서도 지워져야 하는 액터는 <code>DestructInfoMaxDistanceSquared</code>를 늘리거나(OpenWorld 그래프의 <code>OpenWorld.RepGraph.DestructInfo.MaxDist</code>를 1 km로 두자 바로 사라졌다) 액터마다 <code>SetActorDestructionInfoToIgnoreDistanceCulling</code>을 건다. dynamic 액터가 잠든 채 Cell을 벗어나면 그 정리는 다른 경로(<code>Net.RepGraph.DormantDynamicActorsDestruction</code>, 기본 0)를 타므로 따로 켜야 한다.</li>
</ul>
재현 절차<p>NearPlayers 분포, 플레이어 2명을 떨어뜨린 조건에서 플레이어 0의 Pawn을 측정 5초에 500 m 옮기고 10초에 원래 자리 150 m 안의 잠든 루트 100개를 서버에서 파괴한 뒤, 그 클라이언트에 있는 루트 수를 2초마다 셌다. 각 2회.</p>

<table><thead><tr><th>조건</th><th>파괴 전</th><th>파괴 뒤</th></tr></thead><tbody><tr><td>기본 드라이버</td><td>500</td><td>400</td></tr><tr><td>Basic 그래프 (파괴 정보 거리 기본 150 m)</td><td>500</td><td>500</td></tr><tr><td>OpenWorld 그래프 (300 m)</td><td>500</td><td>500</td></tr><tr><td>OpenWorld, 15초에 플레이어를 되돌림</td><td>500</td><td>돌아온 뒤 400</td></tr><tr><td>OpenWorld, <code>OpenWorld.RepGraph.DestructInfo.MaxDist</code> 1 km</td><td>500</td><td>400</td></tr></tbody></table></section></section>
<section><h2>부록 B. 디버깅: 끄고 비교하기, PrintAll<a href="#부록-b-디버깅-끄고-비교하기-printall"><span>#</span></a></h2><p>리플리케이션 그래프 문제가 생기면 첫 단계로 그래프를 끈다. ini의 <code>ReplicationDriverClassName</code>으로 켠 경우 <code>Net.RepDriver.Enable 0</code>이나 커맨드라인 <code>-RepDriverDisable</code>로 설정을 두고도 기본 드라이버로 다시 시작해 같은 현상이 나는지 본다. 둘 다 드라이버 생성 시점에만 읽힌다. Lyra처럼 <code>CreateReplicationDriverDelegate</code>로 만드는 프로젝트에서는 델리게이트가 먼저 반환하므로 그쪽의 끄기 설정(Lyra는 <code>bDisableReplicationGraph</code>)을 쓴다.</p><p>테스트 프로젝트에서는 이 비교로 두 드라이버의 차이를 하나 찾았다. 서버에서는 렌더링이 필요 없다는 이유로 테스트 액터 5,000개를 <code>SetHidden(true)</code>로 스폰했다. 기본 드라이버가 부르는 <code>AActor::IsNetRelevantFor</code>는 숨겨져 있고 충돌이 없는 액터를 관련 없음으로 판정하므로 테스트 액터가 하나도 리플리케이션되지 않았다. 이 함수를 부르지 않는 리플리케이션 그래프에서는 멀쩡했다. 아래는 Uniform 조건의 런 하나에서 두 연결에 열린 액터 채널 수의 합(프레임 평균)이다.</p>

<table><thead><tr><th>액터 상태</th><th>기본 드라이버</th><th>OpenWorld 그래프</th></tr></thead><tbody><tr><td>보임</td><td>73.4</td><td>62.1</td></tr><tr><td><code>SetHidden(true)</code></td><td>28.0 (Pawn, PlayerState, 전역 액터뿐)</td><td>78.7</td></tr></tbody></table><p>리플리케이션 그래프 문제로 좁혀지면 서버에서 <code>Net.RepGraph.PrintAll &lt;프레임 수&gt; &lt;연결 인덱스&gt;</code>를 찍는다. 노드 트리와 각 리스트의 액터, 그리고 연결 하나의 우선순위 결과가 나온다. 아래는 테스트 프로젝트의 OpenWorld 그래프에서 Uniform 조건으로 찍은 출력을 줄인 것이다.</p>
PrintAll 출력 예<div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Replication Frame 897</span></div></div><div><div><div>2</div></div><div><span>ReplicationGraphNode_GridSpatialization2D_0</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Tracking 1002 dynamic actors:</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>ReplicationGraphNode_GridCell_0</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>Static</span></div></div><div><div><div>6</div></div><div><span><span>      </span></span><span>World [17 Actors] RepGraphLabProp_219 RepGraphLabProp_357 RepGraphLabProp_449 ...</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>Dynamic</span></div></div><div><div><div>8</div></div><div><span><span>      </span></span><span>World Bucket 1 [13 Actors] RepGraphLabAI_6 RepGraphLabAI_238 RepGraphLabAI_365 ...</span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>World Bucket 2 [13 Actors] RepGraphLabAI_428 RepGraphLabAI_481 RepGraphLabVehicle_20 ...</span></div></div><div><div><div>10</div></div><div><span><span>      </span></span><span>World Bucket 3 [13 Actors] RepGraphLabAI_21 RepGraphLabAI_183 RepGraphLabAI_332 ...</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>Dormant</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>World [158 Actors] RepGraphLabLoot_51 RepGraphLabLoot_63 RepGraphLabLoot_77 ...</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>ReplicationGraphNode_GridCell_1</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>...</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>ReplicationGraphNode_GridCell_6</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>...</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>Dormant</span></div></div><div><div><div>18</div></div><div><span><span>      </span></span><span>World [158 Actors] RepGraphLabLoot_4 RepGraphLabLoot_12 RepGraphLabLoot_51 ...</span></div></div><div><div><div>19</div></div><div><span><span>        </span></span><span>Child: ReplicationGraphNode_ConnectionDormancyNode_0</span></div></div><div><div><div>20</div></div><div><span><span>          </span></span><span>World [158 Actors] RepGraphLabLoot_4 RepGraphLabLoot_12 RepGraphLabLoot_51 ...</span></div></div><div><div><div>21</div></div><div><span>ReplicationGraphNode_ActorList_0</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>World [5 Actors] WorldSettings GameStateBase_0 RepGraphLabGlobal_0 RepGraphLabGlobal_1 RepGraphLabGlobal_2</span></div></div><div><div><div>23</div></div><div><span>OpenWorldReplicationGraphNode_PlayerStateFrequencyLimiter_0</span></div></div><div><div><div>24</div></div><div><span><span>  </span></span><span>Bucket[0] [2 Actors] PlayerState_0 PlayerState_1</span></div></div><div><div><div>25</div></div><div><span>Connection: NULL</span></div></div><div><div><div>26</div></div><div><span><span>  </span></span><span>OpenWorldReplicationGraphNode_AlwaysRelevant_ForConnection_0</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>World [2 Actors] GameplayDebuggerCategoryReplicator_0 RepGraphLabInventory_0</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>Rebuilt [3 Actors] BP_ThirdPersonPlayerController_C_0 BP_ThirdPersonCharacter_C_0 PlayerState_0</span></div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>Team0_0 (Team 0)</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>Members [2 Actors] BP_ThirdPersonCharacter_C_0 BP_ThirdPersonCharacter_C_1</span></div></div><div><div><div>31</div></div><div><span>[18 Skipped Actors]</span></div></div><div><div><div>32</div></div><div><span>RepGraphLabProp_624                      Dist Culled 18108.84</span></div></div><div><div><div>33</div></div><div><span>RepGraphLabAI_108                        Dist Culled 17347.25</span></div></div><div><div><div>34</div></div><div><span>WorldSettings                            Not ready (2 frames left)</span></div></div><div><div><div>35</div></div><div><span>PlayerState_1                            Not ready (15 frames left)</span></div></div><div><div><div>36</div></div><div><span><span> </span></span><span>Culled Classes: [RepGraphLabProp, 7] [RepGraphLabAI, 5] [RepGraphLabVehicle, 3]</span></div></div><div><div><div>37</div></div><div><span>[23 Passed Actors]</span></div></div><div><div><div>38</div></div><div><span>BP_ThirdPersonCharacter_C_0              -340282346638528859811704183484516925440.0000 (Dist: 399.78 | 0.00) (FramesLastRep: 1 | 0.95)</span></div></div><div><div><div>39</div></div><div><span>BP_ThirdPersonCharacter_C_0              -340282346638528859811704183484516925440.0000 (Dist: 399.78 | 0.00) (FramesLastRep: 1 | 0.95)</span></div></div><div><div><div>40</div></div><div><span>BP_ThirdPersonPlayerController_C_0       -340282346638528859811704183484516925440.0000 (Dist: 77347.03 | 1.00) (FramesLastRep: 1 | 0.95)</span></div></div><div><div><div>41</div></div><div><span>BP_ThirdPersonCharacter_C_0              -340282346638528859811704183484516925440.0000 (Dist: 399.78 | 0.00) (FramesLastRep: 1 | 0.95)</span></div></div><div><div><div>42</div></div><div><span>RepGraphLabAI_593                        0.8515 (Dist: 2309.59 | 0.00) (FramesLastRep: 3 | 0.85)</span></div></div><div><div><div>43</div></div><div><span>RepGraphLabAI_685                        0.8697 (Dist: 8419.37 | 0.02) (FramesLastRep: 3 | 0.85)</span></div></div><div><div><div>44</div></div><div><span>BP_ThirdPersonCharacter_C_1              0.9501 (Dist: 699.78 | 0.00) (FramesLastRep: 1 | 0.95)</span></div></div><div><div><div>45</div></div><div><span>RepGraphLabProp_1923                     0.9762 (Dist: 9709.84 | 0.03) (FramesLastRep: 1 | 0.95)</span></div></div><div><div><div>46</div></div><div><span>RepGraphLabGlobal_0                      1.9500 (Dist: 77347.03 | 1.00) (FramesLastRep: 1 | 0.95)</span></div></div><div><div><div>47</div></div><div><span>PlayerState_0                            1.9500 (Dist: 77347.03 | 1.00) (FramesLastRep: 1 | 0.95)</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>읽는 법은 다음과 같다.</p><ul>
<li>한 액터가 여러 Cell에 나와도 정상이다.</li>
<li>Cell의 dormancy 사본이 마스터와 같은 수라면 그 사본에서 아직 빠진 액터가 없다는 뜻이다(새 연결, flush 직후, dormancy 완료 전).</li>
<li><code>[Skipped Actors]</code>에는 거리나 주기 같은 사유가 붙는다. 거리·프레임 상세는 개발 빌드에서만 나온다.</li>
<li><code>[Passed Actors]</code>는 점수 오름차순이다. 본인 Pawn은 viewer 규칙으로 <code>-MAX_FLT</code>이다. Grid, Team Node, 연결 노드가 각각 돌려주기 때문에 같은 Pawn이 여러 번 나온다.</li>
<li>Passed 목록이 기대한 액터로만 차 있는지 보면 라우팅을 검증할 수 있다.</li>
</ul><p>그 밖의 명령은 다음과 같다.</p><ul>
<li><code>Net.RepGraph.PrintAllActorInfo &lt;문자열&gt;</code>: 이름이 문자열을 포함하는 액터의 클래스·전역·연결 정보를 찍어 세 층이 어떻게 복사됐는지 보여 준다.</li>
<li><code>Net.RepGraph.StarvedList</code>: 연결의 모든 액터를 마지막 리플리케이션 이후 프레임 수로 정렬해 HUD에 띄운다.</li>
<li><code>Net.RepGraph.SetClassCullDistance</code>, <code>Net.RepGraph.SetPeriodFrame</code>: 클래스·액터와 명령을 보낸 연결의 값을 바꾼다.</li>
</ul><p>플러그인의 CVar와 명령은 50–60개로 <code>Net.RepGraph.</code> 접두사를 쓰고 대부분 <code>ReplicationGraph.cpp</code>와 <code>ReplicationGraphDebugging.cpp</code>에 있다. 로그 카테고리는 <code>LogReplicationGraph</code>다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li>Unreal Engine 5.8.3 소스
<ul>
<li><code>Engine/Plugins/Runtime/ReplicationGraph/Source/</code>: <code>ReplicationGraph.h</code>, <code>ReplicationGraphTypes.h</code>, <code>ReplicationGraph.cpp</code>, <code>ReplicationGraphDebugging.cpp</code>, <code>BasicReplicationGraph.cpp</code></li>
<li><code>Engine/Source/Runtime/Engine/Private/</code>: <code>NetDriver.cpp</code>, <code>DataChannel.cpp</code>, <code>DataReplication.cpp</code>, <code>NetworkObjectList.cpp</code>, <code>Actor.cpp</code></li>
<li><code>Samples/Games/Lyra/Source/LyraGame/System/</code>: <code>LyraReplicationGraph.h</code>, <code>LyraReplicationGraph.cpp</code>, <code>LyraReplicationGraphSettings.h</code></li>
</ul>
</li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/replication-graph-in-unreal-engine" target="_blank">Replication Graph in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/community/learning/knowledge-base/rm2x/unreal-engine-replication-graph-fast-shared-path-starting-guide" target="_blank">Replication Graph Fast Shared Path Starting Guide</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/migrate-to-iris-in-unreal-engine" target="_blank">Migrate to Iris in Unreal Engine</a></li>
<li><a href="https://www.youtube.com/watch?v=CDnNAAzgltw" target="_blank">Networking in 4.20: The Replication Graph, Unreal Engine Livestream, 2018</a></li>
<li><a href="https://www.youtube.com/watch?v=K472O2rVvG0" target="_blank">Developing and Launching a New Replication System, Unreal Fest Orlando 2025</a></li>
<li><a href="https://forums.unrealengine.com/t/5-7-iris-strategy/2699685" target="_blank">5.7 Iris Strategy, Epic Developer Community Forums</a></li>
<li><a href="https://forums.unrealengine.com/t/reliable-rpcs-get-dropped-on-replication-graph-if-actor-gets-set-dormant-shortly-before-the-reliable-rpc-gets-called/2726900" target="_blank">Reliable RPCs get dropped on Replication Graph if Actor gets set dormant shortly before the reliable RPC gets called</a></li>
<li><a href="https://forums.unrealengine.com/t/clients-destroy-vital-actors-when-seamless-traveling-and-server-broadcasts-a-multicast-rpc-using-replication-graph/2743133" target="_blank">Clients destroy vital actors when seamless traveling and server broadcasts a multicast RPC using Replication Graph</a></li>
<li><a href="https://forums.unrealengine.com/t/guarantee-replication-order/2511085" target="_blank">Guarantee replication order</a></li>
<li><a href="https://forums.unrealengine.com/t/replication-graph-ignores-bnettemporary/441733" target="_blank">Replication Graph ignores bNetTemporary</a></li>
<li><a href="https://forums.unrealengine.com/t/replicationgraph-node-teardown/2600456" target="_blank">ReplicationGraph node teardown</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-push-model-networking/</id>
      <title type="text">Unreal Engine - Push Model Networking</title>
      <published>2026-10-08T00:00:00.000Z</published>
      <updated>2026-10-08T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-push-model-networking/"/>
      <summary type="text">UE 5.8.3 기준으로 레거시 복제의 Push Model이 켜지는 조건, dirty 비트가 소비되는 경로, net.PushModelSkipUndirtiedReplication이 건너뛰는 범위를 엔진 소스로 확인하고, ThirdPerson 템플릿에 액터 1000개를 띄워 Unreal Insights, Networking Insights, Network Profiler로 CPU 시간, 대역폭, 비교 횟수를 측정한다. 같은 조건에서 Dormancy와도 비교한다.</summary>
      <content type="html"><![CDATA[<div><div><div></div><div>TL;DR</div></div><div><ul>
<li>UE 5.8.3에서 Push Model은 기본으로 꺼져 있다. 컴파일 스위치 <code>bWithPushModel</code>, 런타임 CVar <code>Net.IsPushModelEnabled</code>, 프로퍼티별 <code>bIsPushBased</code> 등록이 모두 필요하다. <code>bWithPushModel</code>은 Editor 타깃에서만 기본으로 켜져서 패키지 빌드는 Target.cs에 넣지 않으면 조용히 폴링으로 돈다.</li>
<li><code>DOREPLIFETIME</code>과 <code>DOREPLIFETIME_CONDITION</code>으로는 push로 등록할 수 없고, 등록을 빠뜨린 프로퍼티도 push가 아닌 설정으로 자동 등록된다. 둘 다 경고가 없다.</li>
<li>마킹은 “보내라”가 아니라 “비교해 봐라”는 표시다. dirty가 아닌 push 기반 프로퍼티의 비교만 건너뛰고, 언제 복제할지는 바꾸지 않는다.</li>
<li>액터 1000개, 클라이언트 2개, 초당 1%의 액터만 값이 바뀌는 조건에서 <code>ServerReplicateActors</code>는 10회 평균으로 Push Model만 켜면 13%, skip CVar까지 켜면 26% 줄었다. 송신 바이트와 Network Profiler의 Waste는 그대로였고, 줄어든 것은 비교 횟수와 서버 CPU다.</li>
<li>skip CVar는 모든 복제 프로퍼티가 push인 클래스에서만 동작한다. <code>ACharacter</code>를 상속한 클래스는 받지 못하고, unreliable multicast를 한 번 보낸 액터는 그 연결에서 영구히 받지 못한다. skip을 노리고 상태를 컴포넌트로 나누면 오히려 35% 느려졌다.</li>
<li>값이 드물게 바뀌는 액터는 Dormancy가 훨씬 싸다(0.17 ms). 액터마다 평균 초당 한 번 바뀌는 조건에서는 Push + Skip이 더 빨랐다.</li>
<li>측정한 조건 안에서는 Push Model을 켜서 느려진 경우가 없었다. 새 프로젝트에서는 직접 관리하는 복제 프로퍼티를 push로 등록하는 것을 기본 선택으로 삼는다. 다만 마킹을 빠뜨려도 오류가 나지 않으니 값을 바꾸는 경로를 setter로 모으고 검증 CVar로 확인한다.</li>
</ul></div></div>
<p>레거시 복제는 복제할 때마다 프로퍼티의 현재 값을 마지막으로 비교한 값과 비교해 바뀐 것을 찾는다. Push Model은 값을 바꾼 코드가 직접 표시하게 해서 이 비교를 건너뛴다. 그래서 “켜면 복제가 빨라진다”고 알려져 있지만, 줄어드는 것은 서버 CPU이고 송신량은 그대로다. 얼마나 줄어드는지도 클래스 구성과 별도 CVar에 따라 크게 달라진다.</p>
<p>이 글은 레거시 복제 시스템(Iris가 아닌 기본 복제)의 Push Model을 엔진 소스와 측정으로 정리한다. ThirdPerson 템플릿 프로젝트에 복제 프로퍼티 16개짜리 액터 1000개를 띄우고, 데디케이티드 서버의 <code>ServerReplicateActors</code> 시간을 CSV 프로파일러와 Unreal Insights로 쟀다. 클라이언트 2개, 초당 1%의 액터만 값이 바뀌는 조건에서 결과는 다음과 같았다.</p>

<table><thead><tr><th>조건</th><th><code>ServerReplicateActors</code> 프레임당 평균 (10회)</th><th>비율</th></tr></thead><tbody><tr><td>Push 끔</td><td>4.57 ms</td><td>100</td></tr><tr><td>Push 켬</td><td>3.97 ms</td><td>87</td></tr><tr><td>Push + Skip</td><td>3.36 ms</td><td>74</td></tr></tbody></table>
<p>Push + Skip은 Push Model과 <code>net.PushModelSkipUndirtiedReplication</code>(이하 skip CVar)을 함께 켠 조건이다. Push Model만 켜면 프로퍼티 비교 비용이 줄고, skip CVar까지 켜면 연결별 작업의 일부가 줄어든다. 아래에서 각 단계를 소스 위치와 Insights 화면으로 확인하고, 같은 조건에서 <a href="#dormancy%EC%99%80-%EB%B9%84%EA%B5%90%ED%95%98%EB%A9%B4">Dormancy와도 비교</a>한다. 소스 경로는 UE 5.8.3 기준이며 <code>Engine/Source/Runtime/</code>을 생략했다.</p>
<section><h2>폴링 복제가 매 프레임 하는 일<a href="#폴링-복제가-매-프레임-하는-일"><span>#</span></a></h2><p>기본 복제는 프로퍼티 값이 바뀌었는지를 엔진이 직접 확인한다. 서버는 복제 대상 오브젝트마다 마지막으로 비교했을 때의 값 사본(shadow state)을 둔다. 복제를 검토할 때는 현재 값과 shadow state를 비교해 다른 프로퍼티를 changelist에 기록하고, 달라진 값을 shadow state에 덮어쓴다. 이 비교가 <code>FRepLayout::CompareProperties</code>다. shadow state는 비교할 때 갱신되므로 “마지막으로 보낸 값”이 아니다. 어느 연결에 무엇을 보냈는지는 뒤에서 볼 연결별 전송 이력이 따로 관리한다.</p><p>비교는 연결마다 하지 않는다. 비교 결과는 오브젝트의 <code>FRepChangelistState</code>에 쌓이고, 같은 프레임에 같은 오브젝트를 복제하는 다른 연결은 이 결과를 재사용한다(<code>Engine/Private/RepLayout.cpp</code>). 연결별로 하는 일은 그다음이다. 각 연결의 replicator가 공유 changelist에서 아직 보내지 않은 변경을 골라 조건(<code>COND_OwnerOnly</code> 등)을 적용하고 직렬화한다.</p><p>폴링 복제의 비용은 두 층으로 나뉜다.</p><ul>
<li>오브젝트당 한 번: 모든 복제 프로퍼티를 shadow state와 비교한다.</li>
<li>연결마다: changelist를 확인하고, 보낼 것이 있으면 직렬화한다. 보낼 것이 없어도 확인은 한다.</li>
</ul><p>Push Model은 첫째 층을, skip CVar는 둘째 층의 일부를 줄인다.</p><p></p><figure><img alt="폴링 복제 비용의 두 층: 오브젝트당 한 번 하는 프로퍼티 비교와 연결마다 하는 changelist 확인·직렬화. Push Model은 첫째 층을, skip CVar는 둘째 층의 일부를 줄인다" loading="lazy" width="1600" height="1200" src="/_astro/diagram-cost-layers.C1g0hnDi_1UhJk4.webp" /><figcaption>폴링 복제 비용의 두 층: 오브젝트당 한 번 하는 프로퍼티 비교와 연결마다 하는 changelist 확인·직렬화. Push Model은 첫째 층을, skip CVar는 둘째 층의 일부를 줄인다</figcaption></figure><p></p></section>
<section><h2>Push Model을 켜는 세 개의 스위치<a href="#push-model을-켜는-세-개의-스위치"><span>#</span></a></h2><p>Push Model이 동작하려면 세 조건이 모두 맞아야 한다. 하나라도 빠지면 컴파일도 되고 복제도 되지만 폴링으로 동작한다. 실패해도 오류가 나지 않는다.</p><section><h3>1. 컴파일 스위치 <code>bWithPushModel</code><a href="#1-컴파일-스위치-bwithpushmodel"><span>#</span></a></h3><p><code>WITH_PUSH_MODEL</code> 매크로는 Target.cs의 <code>bWithPushModel</code>로 정해진다. 기본값은 Editor 타깃일 때만 <code>true</code>다.</p><div><figure><figcaption><span>Programs/UnrealBuildTool/Configuration/Rules/TargetRules.cs</span></figcaption><pre><code><div><div><div>1</div></div><div><span>[</span><span>RequiresUniqueBuildEnvironment</span><span>]</span></div></div><div><div><div>2</div></div><div><span>public</span><span> </span><span>bool</span><span><span> </span><span>bWithPushModel</span></span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>get</span><span> =&gt; </span><span>bWithPushModelOverride</span><span> ?? (</span><span>Type</span><span> </span><span>==</span><span> </span><span>TargetType</span><span>.</span><span>Editor</span><span>);</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>set</span><span> =&gt; </span><span>bWithPushModelOverride</span><span> </span><span>=</span><span> </span><span>value</span><span>;</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>PIE와 에디터 바이너리로 띄운 서버에서는 Push Model 코드가 컴파일돼 있지만, 패키지한 Game이나 Server 타깃에서는 기본적으로 빠진다. 이때 <code>MARK_PROPERTY_DIRTY</code> 계열 매크로는 빈 매크로가 되고 <code>Net.IsPushModelEnabled</code> CVar도 존재하지 않는다. PIE에서 확인한 Push Model 동작이 패키지 빌드에서는 조용히 폴링으로 바뀌는 셈이다. 패키지 빌드에서 쓰려면 해당 타깃에 직접 켠다.</p><div><figure><figcaption><span>MyGameServer.Target.cs</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>bWithPushModel</span><span> </span><span>=</span><span> </span></span><span>true</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 프로퍼티에는 <code>[RequiresUniqueBuildEnvironment]</code>가 붙어 있어서 Unique Build Environment를 쓰는 타깃에서만 바꿀 수 있다. 그리고 Unique Build Environment 타깃은 런처판(설치형) 엔진에서 빌드할 수 없다(<code>Programs/UnrealBuildTool/Configuration/Rules/RulesAssembly.cs</code>). 데디케이티드 서버 타깃은 원래 소스 엔진에서 빌드하므로 서버 쪽에서는 큰 제약이 아니다. 리슨 서버처럼 Game 타깃이 서버 역할을 하는 구성이라면 Game 타깃에도 같은 설정이 필요하다.</p></section><section><h3>2. 런타임 CVar <code>Net.IsPushModelEnabled</code><a href="#2-런타임-cvar-netispushmodelenabled"><span>#</span></a></h3><div><figure><figcaption><span>Net/Core/Private/Net/Core/PushModel/PushModel.cpp</span></figcaption><pre><code><div><div><div>1</div></div><div><span>bool</span><span> </span><span>bIsPushModelEnabled</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>2</div></div><div><span>FAutoConsoleVariableRef</span><span> </span><span>CVarIsPushModelEnabled</span><span>(</span></div></div><div><div><div>3</div></div><div><span>  </span><span>TEXT</span><span>(</span><span>"Net.IsPushModelEnabled"</span><span>),</span></div></div><div><div><div>4</div></div><div><span>  </span><span>bIsPushModelEnabled</span><span>, ...);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>기본값이 <code>false</code>이고 엔진 기본 ini에서도 켜지 않는다. 보통 <code>DefaultEngine.ini</code>에 넣는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[SystemSettings]</span></div></div><div><div><div>2</div></div><div><span>net.IsPushModelEnabled</span><span><span>=</span><span>1</span></span></div></div><div><div><div>3</div></div><div><span>net.PushModelSkipUndirtiedReplication</span><span><span>=</span><span>1</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>두 번째 줄의 <code>net.PushModelSkipUndirtiedReplication</code>은 Push Model을 켜는 스위치가 아니라, 켠 뒤에 쓸 수 있는 별도 최적화다. 무엇을 건너뛰는지는 <a href="#%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%ED%86%B5%EC%A7%B8%EB%A1%9C-%EA%B1%B4%EB%84%88%EB%9B%B0%EB%8A%94-netpushmodelskipundirtiedreplication">아래 절</a>에서 다룬다.</p><p><code>Net.IsPushModelEnabled</code> 값은 클래스의 <code>FRepLayout</code>을 만들 때 읽힌다. <code>FRepLayout</code>은 NetDriver별, 클래스별로 한 번 만들어 캐시하므로 세션 도중에 콘솔로 바꿔도 이미 복제 중인 클래스에는 반영되지 않는다. 실험에서는 조건마다 서버 프로세스를 새로 띄웠다.</p></section><section><h3>3. 프로퍼티 등록 <code>bIsPushBased</code><a href="#3-프로퍼티-등록-bispushbased"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyActor</span><span>::</span></span><span>GetLifetimeReplicatedProps</span><span>(</span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>GetLifetimeReplicatedProps</span><span>(</span><span>OutLifetimeProps</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>FDoRepLifetimeParams</span><span> </span><span>Params</span><span>;</span></div></div><div><div><div>6</div></div><div><span>  </span><span>Params</span><span>.</span><span>bIsPushBased</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>7</div></div><div><span>  </span><span>DOREPLIFETIME_WITH_PARAMS_FAST</span><span>(</span><span>AMyActor</span><span>, </span><span>Health</span><span>, </span><span>Params</span><span>);</span></div></div><div><div><div>8</div></div><div><span>  </span><span>DOREPLIFETIME_WITH_PARAMS_FAST</span><span>(</span><span>AMyActor</span><span>, </span><span>Ammo</span><span>, </span><span>Params</span><span>);</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>MARK_PROPERTY_DIRTY</code> 계열 매크로는 <code>NetCore</code> 모듈에 있으므로 Build.cs에 의존성을 추가한다. 빠뜨리면 링크 오류가 난다.</p><p>등록 매크로는 이름이 비슷하지만 push로 등록할 수 있는 것은 <code>FDoRepLifetimeParams</code>를 받는 계열뿐이다(<code>Engine/Public/Net/UnrealNetwork.h</code>).</p>

<table><thead><tr><th>매크로</th><th>RepIndex를 구하는 방식</th><th>push 등록</th></tr></thead><tbody><tr><td><code>DOREPLIFETIME</code></td><td>실행 중에 이름으로 검색</td><td>불가</td></tr><tr><td><code>DOREPLIFETIME_CONDITION</code>, <code>DOREPLIFETIME_CONDITION_NOTIFY</code></td><td>실행 중에 이름으로 검색</td><td>불가</td></tr><tr><td><code>DOREPLIFETIME_WITH_PARAMS</code></td><td>실행 중에 이름으로 검색</td><td>가능</td></tr><tr><td><code>DOREPLIFETIME_WITH_PARAMS_FAST</code></td><td>UHT가 만든 <code>ENetFields_Private</code>에서 컴파일 시간에 읽음</td><td>가능</td></tr><tr><td><code>DOREPLIFETIME_WITH_PARAMS_FAST_STATIC_ARRAY</code></td><td>위와 같음. 정적 배열 전용</td><td>가능</td></tr></tbody></table><p><code>DOREPLIFETIME_CONDITION</code>과 <code>DOREPLIFETIME_CONDITION_NOTIFY</code>는 조건을 받지만 내부에서 <code>FDoRepLifetimeParams</code>를 새로 만들어 조건만 채우므로 <code>bIsPushBased</code>는 <code>false</code>로 남는다. 기존 코드를 push로 옮길 때는 이 매크로를 <code>DOREPLIFETIME_WITH_PARAMS_FAST</code>로 바꾸고 <code>Params.Condition</code>과 <code>Params.RepNotifyCondition</code>을 직접 채운다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>FDoRepLifetimeParams</span><span> </span><span>Params</span><span>;</span></div></div><div><div><div>2</div></div><div><span>Params</span><span>.</span><span>bIsPushBased</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>3</div></div><div><span>Params</span><span>.</span><span>Condition</span><span> </span><span>=</span><span> </span><span>COND_OwnerOnly</span><span>;</span></div></div><div><div><div>4</div></div><div><span>DOREPLIFETIME_WITH_PARAMS_FAST</span><span>(</span><span>AMyActor</span><span>, </span><span>Ammo</span><span>, </span><span>Params</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>_FAST</code>라는 이름 때문에 복제가 빨라질 것 같지만 차이는 등록할 때만 난다. <code>GetLifetimeReplicatedProps</code>는 <code>FRepLayout</code>을 만들 때 CDO에서 불리므로(<code>RepLayout.cpp</code>의 <code>FRepLayout::InitFromClass</code>) NetDriver별, 클래스별로 한 번 실행된다. 실제로 갈리는 것은 실수가 드러나는 시점이다. <code>_FAST</code>는 RepIndex를 <code>ENetFields_Private</code> enum에서 읽는데, 이 enum에는 복제 프로퍼티만 들어 있어서 <code>Replicated</code>가 빠진 프로퍼티를 넘기면 컴파일 오류가 난다. <code>_FAST</code>가 없는 쪽은 실행 중에 이름으로 프로퍼티를 찾고, <code>Replicated</code>가 아니면 Fatal 로그를 남기고 멈춘다. 이 검사는 Shipping과 Test 빌드에서는 빠진다.</p><p>정적 배열은 <code>_FAST</code>로 등록하면 컴파일되지 않는다. UHT가 정적 배열에는 <code>Name_STATIC_ARRAY</code>와 <code>Name_STATIC_ARRAY_END</code> 항목만 만들기 때문이다. 그래서 <code>DOREPLIFETIME_WITH_PARAMS_FAST_STATIC_ARRAY</code>를 써야 한다. <code>_FAST</code>가 없는 매크로는 프로퍼티의 <code>ArrayDim</code>을 읽어 배열 원소 전체를 알아서 등록한다.</p><p>부모 클래스가 등록한 프로퍼티를 자식 클래스에서 다른 파라미터로 다시 등록하면 개발 빌드에서 check에 걸린다. 같은 RepIndex로 두 번 등록할 때 조건과 <code>bIsPushBased</code>가 같아야 하기 때문이다(<code>CoreUObject/Public/UObject/CoreNet.h</code>의 <code>FLifetimeProperty::operator==</code>). 등록을 바꾸려면 <code>RESET_REPLIFETIME_WITH_PARAMS</code>를 쓴다. 다만 이 매크로로 <code>ACharacter</code>의 프로퍼티를 push로 바꾸는 것은 캐릭터를 Full 클래스로 만드는 방법이 되지 못한다. <code>ACharacter</code>의 프로퍼티를 쓰는 엔진 코드에는 <code>MARK_PROPERTY_DIRTY</code> 호출이 없으므로, push로 바꾼 프로퍼티는 초기 복제 이후의 변경을 보내지 않게 된다.</p><p><code>bIsPushBased</code>는 프로퍼티별 설정이지만 그 효과는 클래스 단위로도 갈린다. <code>FRepLayout</code>은 부모 클래스에서 물려받은 것까지 포함해 모든 복제 프로퍼티가 push 기반인 클래스에 <code>ERepLayoutFlags::FullPushProperties</code>를 붙이고, 일부만 push 기반이면 <code>PartialPushSupport</code>를 붙인다.</p><p>이 글에서는 앞의 경우를 <strong>Full 클래스</strong>, 뒤의 경우를 <strong>Partial 클래스</strong>라고 부른다. push가 아닌 프로퍼티가 하나만 섞여도 Partial 클래스다. 뒤에서 볼 skip CVar는 Full 클래스에서만 동작한다.</p><p><code>GetLifetimeReplicatedProps</code>에서 등록을 빠뜨린 복제 프로퍼티도 Partial의 원인이 된다. 엔진은 빠진 프로퍼티를 기본 설정으로 자동 등록하는데, 기본 설정의 <code>bIsPushBased</code>는 <code>false</code>다(<code>CoreUObject/Public/UObject/CoreNet.h</code>의 <code>FLifetimeProperty</code>, <code>RepLayout.cpp</code>의 <code>DefaultLifetimeProp</code>). 기본 설정에서는 경고도 없다.</p><p>빠뜨린 프로퍼티를 찾을 때는 CVar 두 개를 함께 켠다(<code>Net/Core/Private/Net/Core/Misc/NetCVars.cpp</code>).</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[SystemSettings]</span></div></div><div><div><div>2</div></div><div><span>; 등록하지 않은 복제 프로퍼티를 자동 등록하지 않는다 (기본값 1)</span></div></div><div><div><div>3</div></div><div><span>Net.AutoRegisterReplicatedProperties</span><span><span>=</span><span>0</span></span></div></div><div><div><div>4</div></div><div><span>; 등록하지 않은 복제 프로퍼티마다 ensure를 띄운다 (기본값 0)</span></div></div><div><div><div>5</div></div><div><span>Net.EnsureOnMissingReplicatedPropertiesRegister</span><span><span>=</span><span>1</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>ensure는 자동 등록을 끈 경우에만 뜨고, 그 상태에서 빠진 프로퍼티는 <code>COND_Never</code>가 되어 복제되지 않는다. 진단할 때만 잠깐 켜는 설정이다.</p></section><section><h3>켜졌는지 확인하기<a href="#켜졌는지-확인하기"><span>#</span></a></h3><p>스위치가 빠지면 마킹하지 않은 값도 복제된다. 폴링으로 돌고 있으니 당연한 결과지만, 코드만 보면 Push Model이 고장 난 것처럼 보인다. 포럼에도 <code>bIsPushBased</code>로 등록하고 마킹했는데 마킹과 상관없이 복제된다는 질문이 있었고, 원인은 <code>net.IsPushModelEnabled</code>를 켜지 않은 것이었다(<a href="https://forums.unrealengine.com/t/push-model-networking/510684" target="_blank">포럼 글</a>). 같은 스레드에는 패키지한 데디케이티드 서버에서 Target.cs의 <code>bWithPushModel = true</code>도 필요했다는 보고가 이어진다. 같은 증상은 초기 복제에서도 나오는데, 이 경우는 <a href="#%EB%A7%88%ED%82%B9%EC%9D%84-%EB%B9%A0%EB%9C%A8%EB%A6%AC%EB%A9%B4">마킹을 빠뜨리면</a>에서 다룬다.</p><p>앞의 두 스위치는 <code>IS_PUSH_MODEL_ENABLED()</code> 하나로 확인할 수 있다. <code>WITH_PUSH_MODEL</code>이 꺼진 빌드에서는 <code>false</code>로 정의되고, 켜진 빌드에서는 <code>Net.IsPushModelEnabled</code> 값을 돌려준다(<code>Net/Core/Public/Net/Core/PushModel/PushModel.h</code>). 서버가 시작할 때 로그로 남겨 두면 패키지 빌드가 조용히 폴링으로 도는 것을 바로 알 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UE_LOG</span><span>(</span><span>LogTemp</span><span>, </span><span>Log</span><span>, </span><span>TEXT</span><span>(</span><span>"Push Model: </span><span>%s</span><span>"</span><span>), </span><span>IS_PUSH_MODEL_ENABLED</span><span>() </span><span>?</span><span> </span><span>TEXT</span><span>(</span><span>"enabled"</span><span>) </span><span>:</span><span> </span><span>TEXT</span><span>(</span><span>"disabled"</span><span>));</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>콘솔에서는 <code>Net.IsPushModelEnabled</code>를 값 없이 입력하면 현재 값이 나온다. <code>bWithPushModel</code>이 꺼진 빌드에서는 CVar 자체가 등록되지 않으므로 알 수 없는 명령으로 처리된다. 둘 다 지금의 CVar 값을 보여 줄 뿐이라, 세션 도중에 바꿨다면 이미 만들어진 <code>FRepLayout</code>과 다를 수 있다.</p><p>세 번째 스위치인 프로퍼티별 등록은 이 방법으로 확인되지 않는다. 프로퍼티가 실제로 push로 동작하는지는 Network Profiler의 프로퍼티별 비교 횟수로 볼 수 있다(<a href="#network-profiler%EB%A1%9C-%EB%B9%84%EA%B5%90-%ED%9A%9F%EC%88%98-%EC%84%B8%EA%B8%B0">아래 절</a>). push 기반이 아닌 프로퍼티는 값이 바뀌지 않아도 매번 비교된다.</p></section></section>
<section><h2>dirty 비트의 라이프사이클<a href="#dirty-비트의-라이프사이클"><span>#</span></a></h2><p>마킹한 비트가 비교에서 소비되고 지워지기까지의 흐름은 다음과 같다. 각 단계의 소스는 아래 절에서 확인한다.</p><p></p><figure><img alt="dirty 비트의 라이프사이클: 마킹으로 비트를 세우고, NetDriver별 사본으로 옮긴 뒤, 비교 단계에서 소비하고 지운다" loading="lazy" width="1600" height="1044" src="/_astro/diagram-dirty-lifecycle.DVUVZkZe_ZSBWX5.webp" /><figcaption>dirty 비트의 라이프사이클: 마킹으로 비트를 세우고, NetDriver별 사본으로 옮긴 뒤, 비교 단계에서 소비하고 지운다</figcaption></figure><p></p><section><h3>마킹은 비트 하나를 세운다<a href="#마킹은-비트-하나를-세운다"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyActor</span><span>::</span></span><span>SetHealth</span><span>(</span><span>float</span><span><span> </span><span>NewHealth</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY</span><span>(</span><span>AMyActor</span><span>, </span><span>Health</span><span>, </span><span>NewHealth</span><span>, </span><span>this</span><span>);</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>MARK_PROPERTY_DIRTY_FROM_NAME</code>은 UHT가 생성한 <code>ENetFields_Private</code>로 프로퍼티의 RepIndex를 컴파일 시간에 구하고, 오브젝트의 push 상태에 비트 하나를 세운다(<code>Net/Core/Private/Net/Core/PushModel/PushModel.cpp</code>의 <code>MarkPropertyDirty</code>). 값을 읽는 것은 복제 시점이므로 대입과 마킹의 순서는 상관없다. 같은 프레임에 여러 번 마킹해도 결과는 같다. 비교하는 값도 복제 시점의 마지막 값이라 복제 주기 사이의 중간 값은 클라이언트에 가지 않는다. <code>COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY</code>는 새 값이 기존 값과 다를 때만 대입하고 마킹한다. 엔진의 <code>AController::SetPawn_Direct</code>도 이 매크로를 쓴다.</p><p><code>int32 Values[4]</code> 같은 고정 크기 배열은 원소마다 RepIndex가 따로 있다. 원소 하나는 <code>MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY_INDEX</code>로, 배열 전체는 <code>MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY</code>로 마킹한다. 뒤의 매크로는 <code>WITH_PUSH_MODEL</code>이 꺼진 빌드에서 인자 수가 달라진다. 켜진 쪽은 <code>(ClassName, PropertyName, Object)</code> 3개, 꺼진 쪽의 빈 매크로는 <code>ArrayIndex</code>가 낀 4개다(<code>PushModel.h</code>). 에디터에서 문제없던 호출이 <code>bWithPushModel</code>이 꺼진 타깃에서는 인자 수가 맞지 않게 된다. 이 때문에 컴파일이 실패한다는 보고가 2026년 3월 수정 PR과 함께 포럼에 올라와 Epic이 접수했고(<a href="https://forums.unrealengine.com/t/discrepancy-in-mark-property-dirty-from-name-static-array-causes-compile-failures/2715469" target="_blank">포럼 글</a>), 5.8.3 소스에는 아직 그대로 남아 있다. 원소 수만큼 <code>_STATIC_ARRAY_INDEX</code>를 부르면 피할 수 있다.</p><p>오브젝트가 아직 복제 등록 전이면 마킹은 아무 일도 하지 않는다. 대신 처음 등록될 때 모든 프로퍼티가 dirty인 상태로 시작한다(<code>Net/Core/Private/Net/Core/PushModel/Types/PushModelPerObjectState.h</code>의 생성자가 <code>DirtiedThisFrame</code>을 <code>true</code>로 채운다). 그래서 스폰 직후에 설정한 값은 마킹이 없어도 첫 복제에 실린다.</p></section><section><h3>비교 단계에서 dirty가 아닌 push 프로퍼티만 건너뛴다<a href="#비교-단계에서-dirty가-아닌-push-프로퍼티만-건너뛴다"><span>#</span></a></h3><div><figure><figcaption><span>Engine/Private/RepLayout.cpp</span></figcaption><pre><code><div><div><div>1</div></div><div><span>static</span><span> </span><span>bool</span><span> </span><span>IsPropertyDirty</span><span>(...)</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>return</span><span> </span><span>SharedParams</span><span>.</span><span>bForceCompareProperties</span><span> </span><span>||</span></div></div><div><div><div>4</div></div><div><span>    </span><span>!</span><span>(</span><span>*</span><span>SharedParams</span><span>.</span><span>PushModelProperties</span><span>)[</span><span>ParentIndex</span><span>] </span><span>||</span><span> // non-push model properties are always considered dirty</span></div></div><div><div><div>5</div></div><div><span>    </span><span>SharedParams</span><span>.</span><span>PushModelState</span><span>-&gt;</span><span>IsPropertyDirty</span><span>(</span><span>ParentIndex</span><span>) </span><span>||</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>(</span><span>bRecentlyCollectedGarbage</span><span> </span><span>&amp;&amp;</span></div></div><div><div><div>7</div></div><div><span>      </span><span>EnumHasAnyFlags</span><span>(</span><span>SharedParams</span><span>.</span><span>Parents</span><span>[</span><span>ParentIndex</span><span>].</span><span>Flags</span><span>, ERepParentFlags::</span><span>HasObjectProperties</span><span> </span><span>|</span><span> ERepParentFlags::</span><span>IsNetSerialize</span><span>));</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>push 기반이 아닌 프로퍼티는 항상 dirty로 취급되어 매번 비교한다. push 기반 프로퍼티는 마킹됐을 때만 비교한다. 예외는 GC 직후다. 오브젝트 참조를 가지거나 <code>NetSerialize</code>를 쓰는 프로퍼티는 GC가 끝난 다음 비교에서 마킹과 상관없이 비교한다.</p><p>Full 클래스는 이 판정조차 프로퍼티마다 하지 않는다. dirty 비트가 켜진 인덱스만 순회한다(<code>RepLayout.cpp</code>의 <code>CompareParentProperties</code>, <code>TConstSetBitIterator</code>). 바뀐 것이 없으면 비교 루프가 거의 돌지 않는다.</p></section><section><h3>dirty여도 비교는 한다<a href="#dirty여도-비교는-한다"><span>#</span></a></h3><p>마킹은 “보내라”가 아니라 “비교해 봐라”는 표시다. dirty 프로퍼티도 <code>PropertiesAreIdentical</code>로 shadow state와 비교하고 같으면 changelist에 넣지 않는다. 같은 값으로 마킹하면 비교 비용만 들고 아무것도 전송되지 않는다.</p><p>이 때문에 <code>REPNOTIFY_Always</code>도 Push Model과 무관하게 동작한다. 엔진은 이 값을 다음과 같이 정의한다.</p><blockquote><p><code>REPNOTIFY_Always</code>: Always Call the property’s RepNotify function when it is received from the server</p><p><code>CoreUObject/Public/UObject/CoreNetTypes.h</code></p></blockquote><p>조건은 “서버에서 받았을 때”다. 받은 값이 클라이언트의 로컬 값과 같아도 RepNotify를 부른다는 수신 쪽 규칙이고(<code>RepLayout.cpp</code>의 <code>ReceivePropertyHelper</code>), 서버가 무엇을 보낼지에는 관여하지 않는다. 서버가 같은 값을 다시 마킹해도 보낸 것이 없으므로 클라이언트에서 RepNotify는 불리지 않는다. 처음에 나는 이 경우 RepNotify가 불릴 거라고 생각했는데, 보내는 쪽과 받는 쪽의 규칙을 섞어서 본 것이었다.</p><p>비교가 끝나면 그 오브젝트의 dirty 비트는 지워진다. 비교는 오브젝트당 한 번이고 결과는 연결들이 공유한다. 그래서 dirty 비트도 연결마다 따로 두지 않는다. 조건부 프로퍼티는 비교 단계에서 조건을 보지 않고 모두 비교한 뒤 연결별로 걸러 낸다.</p><p>대신 NetDriver마다는 따로 둔다. 마킹은 오브젝트의 비트 배열에 먼저 쌓이고, NetDriver가 그 오브젝트를 읽을 때 NetDriver별 사본으로 옮겨진다(<code>PushModelPerObjectState.h</code>의 <code>PushDirtyStateToNetDrivers</code>). 리플레이를 녹화하는 DemoNetDriver가 게임 NetDriver와 함께 돌아도 각자 자기 사본을 비교하고 지우므로 한쪽이 다른 쪽의 변경을 지우지 않는다.</p></section><section><h3>언제 복제할지는 바꾸지 않는다<a href="#언제-복제할지는-바꾸지-않는다"><span>#</span></a></h3><p>Push Model은 무엇을 비교할지만 바꾼다. 액터를 언제 복제 검토 대상으로 볼지는 <code>NetUpdateFrequency</code>, <code>ForceNetUpdate</code>, Dormancy, 관련성 검사가 그대로 정한다. 마킹은 <code>ForceNetUpdate</code>를 부르지 않으므로 마킹한 값도 다음 복제 주기를 기다린다. 바로 보내야 하는 변경이라면 마킹과 함께 <code>ForceNetUpdate</code>를 부른다. <code>ForceNetUpdate</code>는 잠든 액터의 Dormancy도 flush한다(<code>Engine/Private/Actor.cpp</code>). Dormancy와 Push Model을 같은 조건에서 비교한 결과는 <a href="#dormancy%EC%99%80-%EB%B9%84%EA%B5%90%ED%95%98%EB%A9%B4">측정 절</a>에 있다.</p></section></section>
<section><h2>오브젝트를 통째로 건너뛰는 <code>net.PushModelSkipUndirtiedReplication</code><a href="#오브젝트를-통째로-건너뛰는-netpushmodelskipundirtiedreplication"><span>#</span></a></h2><p>Push Model만 켜면 연결별 작업은 그대로 남는다. 바뀐 것이 없어도 각 연결의 replicator는 changelist를 확인한다. <code>net.PushModelSkipUndirtiedReplication</code>(기본값 <code>false</code>)을 켜면 replicator가 이 확인을 건너뛸 수 있다. 판정은 <code>FObjectReplicator::CanSkipUpdate</code>(<code>Engine/Private/DataReplication.cpp</code>)가 한다.</p><p>skip 대상이 되려면 먼저 replicator를 초기화할 때 클래스가 <code>FullPushProperties</code>여야 하고 FastArray 같은 CustomDelta 프로퍼티가 없어야 한다. FastArray가 있는 클래스는 FastArray 프로퍼티도 push로 등록하고 CVar를 하나 더 켜야 한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[SystemSettings]</span></div></div><div><div><div>2</div></div><div><span>net.PushModelSkipUndirtiedReplication</span><span><span>=</span><span>1</span></span></div></div><div><div><div>3</div></div><div><span>; FastArray가 있는 클래스도 skip 대상에 넣는다 (기본값 0)</span></div></div><div><div><div>4</div></div><div><span>net.PushModelSkipUndirtiedFastArrays</span><span><span>=</span><span>1</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>그다음 매번 다음 조건을 모두 통과해야 한다.</p><ul>
<li>이 연결로 초기 복제(NetInitial) 중이 아니다.</li>
<li>RepFlags가 지난번과 같다.</li>
<li>NAK이 없고, changelist history가 모두 ACK됐고, 이 연결에서 이미 두 번 이상 비교했다.</li>
<li>아직 보내지 않은 changelist가 없다.</li>
<li>리플레이 재전송 중이 아니고, 채널에 강제 비교가 걸려 있지 않다.</li>
<li>CustomDelta 변경이 없다.</li>
<li>dirty 프로퍼티가 없다.</li>
<li>큐에 쌓인 RPC가 없다.</li>
</ul><p>마지막 조건에 함정이 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// Engine/Private/DataReplication.cpp, FObjectReplicator::CanSkipUpdate</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>bool</span><span> </span><span>bHasRPCQueued</span><span> </span><span>=</span><span> </span><span>RemoteFunctions</span><span> </span><span>&amp;&amp;</span><span> </span><span>RemoteFunctions</span><span>-&gt;</span><span>GetNumBits</span><span>() </span><span>&gt;=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>3</div></div><div><span>if</span><span> (</span><span>bHasRPCQueued</span><span>)</span></div></div><div><div><div>4</div></div><div><span>{</span></div></div><div><div><div>5</div></div><div><span>  </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>unreliable multicast RPC는 바로 보내지지 않고 replicator의 <code>RemoteFunctions</code>에 쌓였다가 다음 프로퍼티 복제 때 함께 나간다(<code>Engine/Private/NetDriver.cpp</code>의 <code>ProcessRemoteFunctionForChannelPrivate</code>). <code>RemoteFunctions</code>는 처음 쌓을 때 할당되고 전송한 뒤에는 <code>Reset()</code>만 하고 해제하지 않는다. 비트 수가 0이 되어도 <code>GetNumBits() &gt;= 0</code>은 여전히 참이다. 그래서 unreliable multicast를 한 번이라도 보낸 액터는 그 연결에서 다시는 skip되지 않는다. 아래 실험에서 그대로 재현됐다.</p><p>skip CVar가 건너뛰는 범위도 생각보다 좁다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// Engine/Private/DataChannel.cpp, UActorChannel::ReplicateActor</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>bool</span><span> </span><span>bCanSkipUpdate</span><span> </span><span>=</span><span> </span><span>ActorReplicator</span><span>-&gt;</span><span>CanSkipUpdate</span><span>(</span><span>RepFlags</span><span>);</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>if</span><span> (UE::Net::</span><span>bPushModelValidateSkipUpdate</span><span> </span><span>||</span><span> </span><span>!</span><span>bCanSkipUpdate</span><span>)</span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>  </span><span>bWroteSomethingImportant</span><span> </span><span><span>|</span><span>=</span></span><span> </span><span>ActorReplicator</span><span>-&gt;</span><span>ReplicateProperties</span><span>(</span><span>Bunch</span><span>, </span><span>RepFlags</span><span>);</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>bWroteSomethingImportant</span><span> </span><span><span>|</span><span>=</span></span><span> </span><span>DoSubObjectReplication</span><span>(</span><span>Bunch</span><span>, </span><span>RepFlags</span><span>);</span></div></div><div><div><div>10</div></div><div><span>// ...</span></div></div><div><div><div>11</div></div><div><span>bWroteSomethingImportant</span><span> </span><span><span>|</span><span>=</span></span><span> </span><span>UpdateDeletedSubObjects</span><span>(</span><span>Bunch</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>skip CVar는 액터 본체의 <code>ReplicateProperties</code> 호출 하나만 건너뛴다. <code>ReplicateActor</code> 호출 자체, 서브오브젝트 복제, 삭제된 서브오브젝트 확인은 매번 돈다. 반면 서브오브젝트(복제 컴포넌트 등)는 각자의 replicator에서 같은 판정을 하고 skip되면 그 서브오브젝트의 처리를 일찍 끝낸다(<code>DataChannel.cpp</code>의 <code>UActorChannel::WriteSubObjectInBunch</code>).</p><p></p><figure><img alt="skip CVar가 건너뛰는 범위: ReplicateActor 안에서 액터 본체의 ReplicateProperties 호출 하나만 건너뛰고, 서브오브젝트 복제와 삭제된 서브오브젝트 확인은 매번 돈다" loading="lazy" width="1600" height="1112" src="/_astro/diagram-skip-scope.B18Eu0fX_1TojsR.webp" /><figcaption>skip CVar가 건너뛰는 범위: ReplicateActor 안에서 액터 본체의 ReplicateProperties 호출 하나만 건너뛰고, 서브오브젝트 복제와 삭제된 서브오브젝트 확인은 매번 돈다</figcaption></figure><p></p><p>FastArray가 있는 클래스를 skip 대상에 넣을 때 FastArray는 따로 마킹하지 않아도 된다. <code>MarkItemDirty</code>와 <code>MarkArrayDirty</code>가 <code>IncrementArrayReplicationKey</code>에서 FastArray 프로퍼티를 마킹한다(<code>Net/Core/Classes/Net/Serialization/FastArraySerializer.h</code>). 마킹할 오브젝트와 RepIndex는 처음 직렬화할 때 기억하므로(<code>RepLayout.cpp</code>의 <code>CachePushModelState</code>) 첫 복제 전의 호출은 마킹하지 않는다. 첫 복제는 앞에서 본 대로 모든 비트가 dirty인 상태라 문제가 없다.</p><p>레거시 복제에서 FastArray는 마킹과 상관없이 skip되지 않은 갱신마다 delta 직렬화를 거친다. 마킹은 skip 판정에만 쓰인다. GAS의 <code>UAbilitySystemComponent::GetLifetimeReplicatedProps</code>에는 FastArray가 push를 쓰지 않으니 플래그는 무시된다는 주석이 남아 있는데, 비교 단계만 보면 맞는 말이다.</p></section>
<section><h2>엔진 클래스는 이미 push 기반인가<a href="#엔진-클래스는-이미-push-기반인가"><span>#</span></a></h2><p>skip CVar는 Full 클래스에서만 동작한다. Full 클래스가 되려면 부모 클래스에서 물려받은 복제 프로퍼티까지 모두 push 기반이어야 하므로 어떤 엔진 클래스를 상속하느냐가 중요하다. 5.8.3에서 엔진 클래스의 상태는 다음과 같다.</p>

<table><thead><tr><th>클래스</th><th>복제 프로퍼티의 push 등록</th><th>이 클래스만 놓고 본 분류</th></tr></thead><tbody><tr><td><code>AActor</code></td><td>10개 모두 push (<code>Engine/Private/ActorReplication.cpp</code>)</td><td>Full</td></tr><tr><td><code>UActorComponent</code>, <code>USceneComponent</code></td><td>모두 push</td><td>Full</td></tr><tr><td><code>APlayerState</code></td><td>모두 push</td><td>Full</td></tr><tr><td><code>APawn</code></td><td><code>PlayerState</code>, <code>Controller</code> 등을 <code>DOREPLIFETIME</code>으로 등록</td><td>Partial</td></tr><tr><td><code>ACharacter</code></td><td>모두 <code>DOREPLIFETIME_CONDITION</code></td><td>Partial</td></tr><tr><td><code>AGameStateBase</code></td><td>push 파라미터를 만들고도 <code>DOREPLIFETIME</code>으로 등록</td><td>Partial</td></tr><tr><td><code>AController</code>, <code>APlayerController</code></td><td>아래 CVar를 켤 때만 push</td><td>기본 Partial</td></tr></tbody></table><p>컨트롤러는 CVar로 push 등록을 켤 수 있다. 등록은 <code>GetLifetimeReplicatedProps</code>에서 하므로 시작 전에 설정한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[SystemSettings]</span></div></div><div><div><div>2</div></div><div><span>; AController의 복제 프로퍼티를 push로 등록한다 (기본값 0)</span></div></div><div><div><div>3</div></div><div><span>Controller.IsPushBased</span><span><span>=</span><span>1</span></span></div></div><div><div><div>4</div></div><div><span>; APlayerController의 복제 프로퍼티를 push로 등록한다 (기본값 0)</span></div></div><div><div><div>5</div></div><div><span>PlayerController.IsPushBased</span><span><span>=</span><span>1</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>AActor</code>를 직접 상속한 액터는 자기 프로퍼티를 모두 push로 등록하면 Full이 된다. <code>ACharacter</code>를 상속한 캐릭터는 무엇을 추가해도 Full이 될 수 없다. 추가한 push 프로퍼티의 비교 비용은 줄지만 skip은 받지 못한다.</p><p>GAS의 <code>UAbilitySystemComponent</code>는 부모인 <code>UGameplayTasksComponent</code>까지 포함해 복제 프로퍼티를 모두 push로 등록하고, 값을 바꿀 때는 <code>GetRepAnimMontageInfo_Mutable()</code>처럼 마킹하고 참조를 돌려주는 getter를 쓴다. AttributeSet은 프로젝트가 정의하므로 push 등록 여부도 프로젝트가 정한다. GAS가 attribute 값을 쓰는 경로(<code>FGameplayAttribute::SetNumericValueChecked</code>)가 <code>MARK_PROPERTY_DIRTY</code>를 부르므로 attribute를 <code>bIsPushBased</code>로 등록해도 GameplayEffect로 바꾼 값은 전달된다. 다만 <code>GAMEPLAYATTRIBUTE_VALUE_INITTER</code>가 만드는 <code>InitHealth</code> 같은 함수는 값을 직접 쓰고 마킹하지 않는다.</p><p>Epic의 Lyra 샘플도 <code>ALyraPlayerState</code>의 프로퍼티 대부분을 push로 등록해 두었다. 하지만 Target.cs에 <code>bWithPushModel</code>이 없고 <code>DefaultEngine.ini</code>에 <code>net.IsPushModelEnabled</code>도 없다. 그대로 빌드하면 에디터와 패키지 빌드 모두 폴링으로 돈다. 코드가 Push Model을 쓰는 모양이라고 해서 프로젝트에서 켜져 있다는 뜻은 아니다.</p><p><code>AActor::ReplicatedMovement</code>는 push 기반이지만, 물리 시뮬레이션이 아닌 액터에서는 <code>GatherCurrentMovement</code>가 값이 같아도 매번 마킹한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// Engine/Private/ActorReplication.cpp, AActor::GatherCurrentMovement</span></div></div><div><div><div>2</div></div><div><span>// Technically, the values might have stayed the same, but we'll just assume they've changed.</span></div></div><div><div><div>3</div></div><div><span>bWasRepMovementModified</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>4</div></div><div><span>// ...</span></div></div><div><div><div>5</div></div><div><span>if</span><span> (</span><span>bWasRepMovementModified</span><span>)</span></div></div><div><div><div>6</div></div><div><span>{</span></div></div><div><div><div>7</div></div><div><span>  </span><span>MARK_PROPERTY_DIRTY_FROM_NAME</span><span>(</span><span>AActor</span><span>, </span><span>ReplicatedMovement</span><span>, </span><span>this</span><span>);</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>움직임을 복제하는 액터는 Push Model을 켜도 <code>ReplicatedMovement</code>를 매번 비교한다. 템플릿 캐릭터만 있는 상태로도 재 봤다. <code>ServerReplicateActors</code>는 Push + Skip일 때와 모두 껐을 때 둘 다 0.09 ms로 차이가 없었다.</p></section>
<section><h2>Blueprint 복제 변수<a href="#blueprint-복제-변수"><span>#</span></a></h2><p>Blueprint에서 선언한 복제 변수는 체크박스 없이 Push Model 대상이 된다. <code>UBlueprintGeneratedClass</code>가 lifetime 프로퍼티를 만들 때 <code>PUSH_MAKE_BP_PROPERTIES_PUSH_MODEL()</code>을 넘긴다. 이 매크로는 <code>Net.IsPushModelEnabled</code>와 <code>Net.MakeBpPropertiesPushModel</code>(기본값 <code>true</code>)이 모두 켜져 있으면 참이다. Blueprint 컴파일러는 Set 노드와 참조로 넘기는 함수 호출 뒤에 <code>MarkPropertyDirtyFromRepIndex</code> 호출을 자동으로 넣는다.</p><p>이 자동 마킹은 Blueprint에서 선언한 변수에만 붙지 않는다. Set 노드는 프로퍼티에 <code>CPF_Net</code> 플래그가 있는지만 보므로(<code>Engine/Source/Editor/BlueprintGraph/Private/VariableSetHandler.cpp</code>, <code>UK2Node_VariableSet::IsNetProperty</code>) C++에서 <code>BlueprintReadWrite</code>로 연 push 프로퍼티도 Set 노드로 바꾸면 마킹된다. 반대로 Blueprint에서 C++의 <code>BlueprintCallable</code> setter를 부를 때는 그 C++ 함수 안에서 마킹해야 한다.</p><p>다만 참조로 값을 바꾸는 Set by-ref 계열 노드는 마킹하지 않는다는 이슈가 등록돼 있다(<a href="https://issues.unrealengine.com/issue/UE-194745" target="_blank">UE-194745</a>). Blueprint 변수가 클라이언트에 반영되지 않는다면 이 경로를 먼저 의심할 만하다.</p></section>
<section><h2>ThirdPerson 템플릿으로 측정하기<a href="#thirdperson-템플릿으로-측정하기"><span>#</span></a></h2><section><h3>실험 구성<a href="#실험-구성"><span>#</span></a></h3><p>측정 환경은 다음과 같다.</p>

<table><thead><tr><th>항목</th><th>값</th></tr></thead><tbody><tr><td>엔진</td><td>UE 5.8.3 런처판(CL 58210709), Development</td></tr><tr><td>OS</td><td>Windows 11 26H2</td></tr><tr><td>CPU, 메모리</td><td>Ryzen 9 5950X(16코어 32스레드), 64 GB</td></tr><tr><td>서버</td><td>에디터 바이너리 <code>-server</code>, uncooked, 논리 코어 0~15 고정</td></tr><tr><td>클라이언트</td><td>2개, <code>-game -nullrhi -nosound</code>, 같은 PC에서 127.0.0.1로 접속, 논리 코어 16~31 고정</td></tr><tr><td>서버 최대 틱</td><td>30 Hz</td></tr><tr><td>연결 대역폭 제한</td><td>100 MB/s</td></tr><tr><td>측정 구간</td><td>클라이언트가 모두 접속한 뒤 20초 워밍업, 30초 측정</td></tr></tbody></table><p>템플릿 캐릭터만으로는 측정할 대상이 거의 없어서 별도 액터를 추가했다. 대표 코드는 다음과 같다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>2</div></div><div><span>class</span><span> </span><span>APushLabActor</span><span> : </span><span>public</span><span> </span><span>AActor</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>public:</span></div></div><div><div><div>7</div></div><div><span>  </span><span>APushLabActor</span><span>()</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>bReplicates</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>10</div></div><div><span>    </span><span>bAlwaysRelevant</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>11</div></div><div><span>    </span><span>SetReplicatingMovement</span><span>(</span><span>false</span><span>);</span></div></div><div><div><div>12</div></div><div><span>    </span><span>SetNetUpdateFrequency</span><span>(</span><span><span>100.0</span><span>f</span></span><span>);</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>GetLifetimeReplicatedProps</span><span>(</span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span><span> </span><span>override</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>Super::</span><span>GetLifetimeReplicatedProps</span><span>(</span><span>OutLifetimeProps</span><span>);</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>    </span><span>FDoRepLifetimeParams</span><span> </span><span>Params</span><span>;</span></div></div><div><div><div>20</div></div><div><span>    </span><span>Params</span><span>.</span><span>bIsPushBased</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>21</div></div><div><span>    </span><span>DOREPLIFETIME_WITH_PARAMS_FAST</span><span>(</span><span>APushLabActor</span><span>, </span><span>Int00</span><span>, </span><span>Params</span><span>);</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>// ... int32 8개, float 4개, FVector 4개</span></div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>24</div></div><div>
</div></div><div><div><div>25</div></div><div><span>  </span><span>UFUNCTION</span><span>(</span><span>NetMulticast</span><span>, </span><span>Unreliable</span><span>)</span></div></div><div><div><div>26</div></div><div><span>  </span><span>void</span><span> </span><span>MulticastPing</span><span>();</span></div></div><div><div><div>27</div></div><div>
</div></div><div><div><div>28</div></div><div><span>protected:</span></div></div><div><div><div>29</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>) </span><span>int32</span><span> </span><span>Int00</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>30</div></div><div><span><span>  </span></span><span>// ...</span></div></div><div><div><div>31</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><ul>
<li>액터 클래스는 세 가지다. 16개를 모두 push로 등록한 Full 클래스, 여기에 바뀌지 않는 폴링 프로퍼티 하나를 더한 Partial 클래스, 그리고 Carrier 클래스다. Carrier 클래스는 액터 본체에 폴링 프로퍼티 하나만 두고, 16개 프로퍼티는 Full 컴포넌트에 담아 붙였다.</li>
<li>서버는 액터 1000개를 스폰하고, 매 프레임 “초당 변경 비율 × 액터 수 × DeltaTime”을 누적해 1이 넘을 때마다 무작위 액터의 무작위 프로퍼티 하나에 새 값을 쓰고 마킹한다. 액터는 중복을 허용해 고르므로 변경 비율은 “액터당 평균 초당 쓰기 횟수”다. 1%면 액터 하나가 평균 100초에 한 번, 3000%면 평균 초당 30번 바뀐다. 난수 시드는 고정했다.</li>
<li>측정 구간에는 CSV 캡처와 Insights 리전 <code>PushLab.Measure</code>를 건다.</li>
<li>서버를 에디터 바이너리로 띄운 것은 런처판 5.8에서 Push Model이 켜진 Server 타깃을 빌드할 수 없어서다.</li>
<li>같은 조건을 반복해도 실행마다 결과가 ±10%가량 흔들려서, 서버와 클라이언트를 서로 다른 코어에 고정해 간섭을 줄였다.</li>
<li>대역폭 제한을 기본값(100 KB/s)으로 두면 3000% 조건에서 연결이 포화되어 연결당 프레임마다 처리하는 액터가 1000개에서 24개로 줄었다. 그 상태에서는 CPU가 아니라 대역폭 한계를 재게 된다.</li>
<li>서버 프레임은 실행에 따라 33~47 ms(약 21~30 Hz)였고, 그에 따라 연결당 프레임마다 처리한 액터 수도 774~1,005개로 달라졌다. 프레임당 시간으로 나눠도 프레임마다 한 일의 양까지 같아지지는 않으므로, 메인 조건은 <code>ReplicateActor</code> 1회당 시간으로도 함께 비교했다.</li>
</ul><p>서버와 클라이언트는 다음처럼 띄웠다. 실험용 인자(액터 수, 변경 비율 등)는 뺐다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 데디케이티드 서버: 에디터 바이너리로 uncooked 실행</span></div></div><div><div><div>2</div></div><div><span>UnrealEditor.exe</span><span><span> ThirdPerson.uproject </span><span>/</span><span>Game</span><span>/</span><span>ThirdPerson</span><span>/</span><span>Lvl_ThirdPerson </span><span>-</span><span>server </span><span>-</span><span>log </span><span>-</span><span>NoVerifyGC</span></span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># 클라이언트: 렌더링과 사운드 없이 접속</span></div></div><div><div><div>5</div></div><div><span>UnrealEditor.exe</span><span> ThirdPerson.uproject </span><span>127.0</span><span>.</span><span>0.1</span><span><span> </span><span>-</span><span>game </span><span>-</span><span>nullrhi </span><span>-</span><span>nosound </span><span>-</span><span>log</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>대역폭 제한 설정은 다음과 같다. 클라이언트가 요청하는 속도와 서버가 허용하는 속도를 모두 올려야 하므로 서버와 클라이언트 양쪽에 적용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[/Script/Engine.Player]</span></div></div><div><div><div>2</div></div><div><span>; 클라이언트가 요청하는 연결 속도 (bytes/s, 기본값 100000)</span></div></div><div><div><div>3</div></div><div><span>ConfiguredInternetSpeed</span><span><span>=</span><span>100000000</span></span></div></div><div><div><div>4</div></div><div><span>ConfiguredLanSpeed</span><span><span>=</span><span>100000000</span></span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>[/Script/OnlineSubsystemUtils.IpNetDriver]</span></div></div><div><div><div>7</div></div><div><span>; 서버가 연결마다 허용하는 최대 속도 (bytes/s, 기본값 100000)</span></div></div><div><div><div>8</div></div><div><span>MaxClientRate</span><span><span>=</span><span>100000000</span></span></div></div><div><div><div>9</div></div><div><span>MaxInternetClientRate</span><span><span>=</span><span>100000000</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>조건마다 서버를 새로 띄우고 <code>Net.IsPushModelEnabled</code>와 <code>net.PushModelSkipUndirtiedReplication</code>만 바꿨다. 같은 바이너리를 쓰므로 빌드 차이는 결과에 섞이지 않는다.</p></section><section><h3>측정 전 예측<a href="#측정-전-예측"><span>#</span></a></h3><p>측정 전에 나는 Push 끔, Push 켬, Push + Skip의 비율을 100 : 95 : 90 정도로 예상했다. 프로퍼티 비교는 원래 싼 작업이고, 복제 비용의 대부분은 관련성 검사, 우선순위 계산, 직렬화 같은 다른 곳에서 나올 거라고 봤다.</p></section><section><h3>메인 조건의 측정 결과<a href="#메인-조건의-측정-결과"><span>#</span></a></h3><p>메인 조건(Full 클래스 1000개, 초당 1% 변경, 클라이언트 2개)을 조건당 5회씩 번갈아 실행하는 세트를 두 번 돌렸다. 첫 세트의 프레임 시간이 실행마다 33~47 ms로 흔들려서 둘째 세트를 추가했는데, 둘째 세트도 42~47 ms로 흔들렸다. 표의 평균은 10회 평균이다.</p>

<table><thead><tr><th>조건</th><th>1차 5회 <code>ServerReplicateActors</code> (ms)</th><th>2차 5회 (ms)</th><th>10회 평균</th><th>비율</th><th><code>ReplicateActor</code> 1회당 (10회)</th><th>비율</th></tr></thead><tbody><tr><td>Push 끔</td><td>4.91 / 4.17 / 4.05 / 4.77 / 4.97</td><td>4.80 / 5.28 / 4.42 / 4.22 / 4.16</td><td>4.57</td><td>100</td><td>2.36 µs</td><td>100</td></tr><tr><td>Push 켬</td><td>4.19 / 3.66 / 3.76 / 3.96 / 4.32</td><td>4.52 / 4.04 / 3.94 / 3.82 / 3.43</td><td>3.97</td><td>87</td><td>2.08 µs</td><td>88</td></tr><tr><td>Push + Skip</td><td>3.31 / 3.02 / 3.62 / 3.72 / 3.27</td><td>4.06 / 3.29 / 3.12 / 3.43 / 2.81</td><td>3.36</td><td>74</td><td>1.71 µs</td><td>73</td></tr></tbody></table><p>프레임당 시간으로는 100 : 87 : 74로, 예상한 100 : 95 : 90보다 이득이 컸다. 프레임 시간이 흔들리면 프레임마다 처리한 액터 수도 달라지므로 <code>ReplicateActor</code> 1회당 시간으로도 비교했는데, 10회 평균의 비율은 100 : 88 : 73으로 거의 같았다. 다만 5회 세트 하나만 보면 Push 켬의 1회당 비율은 93과 83 사이를 오갔다. Push Model만 켰을 때의 13%는 ±5포인트쯤의 해상도로 읽어야 한다. skip CVar까지 켠 쪽은 두 세트와 두 지표에서 모두 71~74 사이였다. 다만 “비교는 원래 싸다”는 판단 자체는 맞았다. 아래 Insights 시간 분해에서 비교는 전체의 14%였다. 차이는 skip CVar가 비교가 아니라 연결별 작업을 줄이는 데서 나왔다.</p></section><section><h3>Unreal Insights로 시간 분해하기<a href="#unreal-insights로-시간-분해하기"><span>#</span></a></h3><p>서버를 다음 인자로 띄워 트레이스를 기록하고 Unreal Insights로 열었다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>UnrealEditor.exe</span><span><span> ThirdPerson.uproject </span><span>/</span><span>Game</span><span>/</span><span>ThirdPerson</span><span>/</span><span>Lvl_ThirdPerson </span><span>-</span><span>server </span><span>-</span><span>log `</span></span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>-</span><span>trace</span><span>=</span><span>cpu,frame,bookmark,region,log `</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>-</span><span>statnamedevents `</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>-</span><span>tracefile</span><span>=</span><span>"D:/Traces/main_off.utrace"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>-statnamedevents</code>가 있어야 <code>STAT_</code> 계열 스코프가 Timing 뷰에 이벤트로 나온다. <code>region</code> 채널은 서버 코드에서 <code>TRACE_BEGIN_REGION(TEXT("PushLab.Measure"))</code>로 건 측정 구간을 기록한다.</p><p></p><figure><img alt="Timing Insights 타임라인과 측정 구간 선택" loading="lazy" width="1600" height="212" src="/_astro/insights-timeline-measure-region.DzSajv42_Z2ukNLy.webp" /><figcaption>Timing Insights 타임라인과 측정 구간 선택</figcaption></figure><p></p><p>① 서버 코드에서 건 <code>PushLab.Measure</code> 리전이 Timing Regions 트랙에 30초 막대로 나온다. ② 눈금자에서 이 막대의 양끝을 드래그해 같은 범위를 선택하면 Timers와 Callees 패널이 선택 구간만 집계한다. 타이머 검색창에 <code>Replicat</code>을 넣고 <code>ServerReplicateActors Time</code>을 고르면 Callees 패널에 하위 호출 트리가 나온다.</p><p>Push Model을 끈 상태다.</p><p></p><figure><img alt="Callees 트리: Push 끔" loading="lazy" width="1345" height="427" src="/_astro/insights-callees-push-off.pR-OFIIQ_Z12twoo.webp" /><figcaption>Callees 트리: Push 끔</figcaption></figure><p></p><ul>
<li>① <code>Replicate Actor Time</code>은 30초 동안 1,283,628회 불렸다. 액터 1000개 × 연결 2개 × 프레임 640개와 거의 같다.</li>
<li>② <code>Dynamic Property Compare Time</code>은 638,526회로 그 절반이다. 연결이 2개여도 비교는 오브젝트당 한 번이라는 앞의 설명이 호출 수로 확인된다. 합계는 362.6 ms다.</li>
<li>③ <code>STAT_NetDeletedSubObjects</code>가 1,277,549회, 218.64 ms다. <code>ReplicateActor</code>마다 삭제된 서브오브젝트를 확인하는 연결별 작업으로, 비교 시간의 60%에 이른다.</li>
</ul><p>Push Model을 켰다.</p><p></p><figure><img alt="Callees 트리: Push 켬" loading="lazy" width="1345" height="427" src="/_astro/insights-callees-push-on.JfDe_cxz_ABJt5.webp" /><figcaption>Callees 트리: Push 켬</figcaption></figure><p></p><p>① 비교 호출 수는 637,682회로 그대로이고 합계가 81.9 ms로 줄었다. 호출은 오브젝트마다 하지만 dirty 비트가 없으면 바로 빠져나온다. 나머지 행은 거의 변하지 않았다.</p><p>skip CVar까지 켰다.</p><p></p><figure><img alt="Callees 트리: Push + Skip" loading="lazy" width="1345" height="427" src="/_astro/insights-callees-push-skip.DAA7oR2W_Z1KyrbN.webp" /><figcaption>Callees 트리: Push + Skip</figcaption></figure><p></p><ul>
<li>① <code>Replicate Actor Time</code> 호출은 1,278,753회로 그대로 남았다. skip은 이 함수 안에서 일어난다.</li>
<li>② <code>STAT_NetDeletedSubObjects</code>도 209.42 ms로 남았다. 앞의 소스대로 skip과 상관없이 매번 실행된다.</li>
<li>③ 비교는 599회로 줄었다. 실제로 값이 바뀐 액터만 비교한다. 직렬화(<code>Dynamic Property Rep Time</code>)도 1,198회, 2.49 ms로 거의 사라졌다.</li>
</ul><p>같은 트레이스에서 타이머별 exclusive 시간을 CSV로 내보내 프레임당 값으로 나눴다. Insights는 UI 없이 트레이스를 분석한 뒤 명령을 실행할 수 있고, <code>-region</code>과 <code>-threads</code>로 측정 구간과 스레드를 지정할 수 있다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>UnrealInsights.exe</span><span><span> </span><span>-</span><span>OpenTraceFile</span><span>=</span></span><span>"D:/Traces/main_off.utrace"</span><span><span> </span><span>-</span><span>AutoQuit </span><span>-</span><span>NoUI `</span></span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>-</span><span>ExecOnAnalysisCompleteCmd</span><span>=</span><span>"TimingInsights.ExportTimerStatistics D:/Traces/main_off.timers.csv -region=PushLab.Measure -threads=GameThread"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>결과는 다음과 같다. 트레이스를 기록한 1회 실행의 값이라 앞의 CSV 10회 평균과 합계가 조금 다르다.</p><p></p><figure><img alt="ServerReplicateActors 시간 분해 막대 차트: Push 끔, Push 켬, Push + Skip 세 조건의 프레임당 ms를 후보 수집, 연결별 액터 처리, 서브오브젝트 삭제 확인, 프로퍼티 비교, 프로퍼티 직렬화로 나눈 것" loading="lazy" width="1600" height="620" src="/_astro/chart-breakdown.BePN9BtY_1zDKoH.webp" /><figcaption>ServerReplicateActors 시간 분해 막대 차트: Push 끔, Push 켬, Push + Skip 세 조건의 프레임당 ms를 후보 수집, 연결별 액터 처리, 서브오브젝트 삭제 확인, 프로퍼티 비교, 프로퍼티 직렬화로 나눈 것</figcaption></figure><p></p>

<table><thead><tr><th>항목 (ms/프레임)</th><th>Push 끔</th><th>Push 켬</th><th>Push + Skip</th></tr></thead><tbody><tr><td>후보 수집·우선순위 (<code>Consider</code>, <code>Prioritize</code>)</td><td>0.60</td><td>0.58</td><td>0.62</td></tr><tr><td>연결별 액터 처리 (<code>ReplicateActor</code>, 클래스 스코프, <code>Process Prioritized</code> exclusive)</td><td>2.45</td><td>2.46</td><td>2.13</td></tr><tr><td>서브오브젝트 삭제 확인</td><td>0.35</td><td>0.36</td><td>0.33</td></tr><tr><td>프로퍼티 비교</td><td>0.58</td><td>0.14</td><td>0.01</td></tr><tr><td>프로퍼티 직렬화</td><td>0.24</td><td>0.23</td><td>0.01</td></tr><tr><td>합계</td><td>4.28</td><td>3.84</td><td>3.16</td></tr></tbody></table><p>합계는 <code>ServerReplicateActors</code> 전체 시간이고, 위 다섯 항목에 넣지 않은 작은 스코프가 0.06 ms가량 더 들어 있다.</p><p>Push Model은 비교 칸을 0.58 ms에서 0.14 ms로 줄였다. 비교는 처음부터 전체의 14%였으므로 Push Model 단독으로 줄일 수 있는 폭도 그 정도다. skip CVar는 직렬화 칸과 연결별 액터 처리 칸의 일부를 줄였다. 가장 큰 칸인 연결별 액터 처리는 skip CVar를 켜도 2.13 ms가 남는다.</p><blockquote><p><code>-statnamedevents</code>는 µs 단위 스코프마다 이벤트를 기록하므로 측정값을 부풀릴 수 있다. 같은 조건에서 추적 없이 CSV만 기록한 10회 평균은 4.57 ms, 추적한 실행 1회는 4.28 ms였다. 추적 실행이 1회뿐이고 그 값이 추적 없는 실행의 범위(4.05~5.28 ms) 안에 있어서, 이번 비교로는 추적 비용과 실행 간 편차를 분리하지 못했다. 분해 표는 세 조건의 상대 비교에만 쓴다.</p></blockquote><p>호출 하나하나의 길이도 봤다. <code>TimingInsights.ExportTimingEvents</code>로 이벤트를 내보내 분포를 만들었다. 명령은 response file(<code>main_off.events.rsp</code>)에 쓰고 <code>@=</code> 접두사로 넘긴다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>TimingInsights.ExportTimingEvents "D:/Traces/main_off.events.csv" -columns="TimerName,Duration" -threads="GameThread" -timers="Replicate Actor Time,Dynamic Property Compare Time" -region="PushLab.Measure"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>UnrealInsights.exe</span><span><span> </span><span>-</span><span>OpenTraceFile</span><span>=</span></span><span>"D:/Traces/main_off.utrace"</span><span><span> </span><span>-</span><span>AutoQuit </span><span>-</span><span>NoUI `</span></span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>-</span><span>ExecOnAnalysisCompleteCmd</span><span>=</span><span>"@=D:/Traces/main_off.events.rsp"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p>타이머 이름처럼 공백이 든 인자는 명령줄에서 따옴표가 겹치므로 response file로 넘긴다. 파일 안의 경로는 슬래시로 쓴다. 역슬래시는 이스케이프로 처리되어 사라진다.</p></blockquote>

<table><thead><tr><th>조건</th><th><code>Replicate Actor Time</code> p50 / p90 / p99</th><th>비교 호출 수 (GameThread 전체)</th><th>비교 p50</th></tr></thead><tbody><tr><td>Push 끔</td><td>1.5 / 2.4 / 3.6 µs</td><td>642,335</td><td>0.5 µs</td></tr><tr><td>Push 켬</td><td>1.2 / 2.0 / 2.9 µs</td><td>641,992</td><td>0.1 µs</td></tr><tr><td>Push + Skip</td><td>0.9 / 1.5 / 2.1 µs</td><td>4,906</td><td>1.2 µs</td></tr></tbody></table><p>skip CVar를 켜면 <code>ReplicateActor</code>의 61%가 1 µs 미만에 모인다. 비교 호출 수에는 캐릭터와 컨트롤러 같은 다른 액터도 포함된다. Push + Skip 조건에서 남은 비교는 실제로 바뀐 오브젝트의 비교라서 1회당 시간은 오히려 길다.</p></section><section><h3>값을 바꾸는 빈도에 따른 차이<a href="#값을-바꾸는-빈도에-따른-차이"><span>#</span></a></h3><p></p><figure><img alt="값을 바꾸는 빈도별 ServerReplicateActors 시간 막대 차트: 변경 비율 0, 1%, 10%, 100%, 3000%에서 Push 끔, Push 켬, Push + Skip의 프레임당 ms" loading="lazy" width="1600" height="800" src="/_astro/chart-change-rate.BLkvp99G_jHwBq.webp" /><figcaption>값을 바꾸는 빈도별 ServerReplicateActors 시간 막대 차트: 변경 비율 0, 1%, 10%, 100%, 3000%에서 Push 끔, Push 켬, Push + Skip의 프레임당 ms</figcaption></figure><p></p>

<table><thead><tr><th>초당 변경 비율</th><th>Push 끔</th><th>Push 켬</th><th>Push + Skip</th><th>skip된 replicator/프레임</th></tr></thead><tbody><tr><td>0</td><td>4.72</td><td>3.68</td><td>2.95</td><td>2,000</td></tr><tr><td>1% (10회 평균)</td><td>4.57</td><td>3.97</td><td>3.36</td><td>1,998</td></tr><tr><td>10%</td><td>4.21</td><td>3.73</td><td>3.07</td><td>1,978</td></tr><tr><td>100%</td><td>4.35</td><td>4.03</td><td>3.38</td><td>1,820</td></tr><tr><td>3000% (액터당 초당 30회)</td><td>8.55</td><td>8.06</td><td>8.43</td><td>118</td></tr></tbody></table><p>1%를 뺀 나머지는 1회씩 측정했으므로 ±10% 안의 차이는 잡음으로 본다. 100%까지는 Push + Skip이 꾸준히 25% 안팎 앞섰다. 액터당 초당 30회 바뀌는 조건에서는 한 프레임에 액터 대부분이 한 번 이상 바뀌어 skip할 replicator가 거의 없고, skip CVar의 효과가 사라졌다. 이때도 Push Model을 켠 쪽이 끈 쪽보다 느리지는 않았다.</p></section><section><h3>연결 수에 따른 차이<a href="#연결-수에-따른-차이"><span>#</span></a></h3><p></p><figure><img alt="클라이언트 수별 ServerReplicateActors 시간 꺾은선 차트: 클라이언트 1, 2, 4, 8개에서 Push 끔과 Push + Skip의 프레임당 ms" loading="lazy" width="1600" height="800" src="/_astro/chart-connections.CrMsLhFN_1IQrwa.webp" /><figcaption>클라이언트 수별 ServerReplicateActors 시간 꺾은선 차트: 클라이언트 1, 2, 4, 8개에서 Push 끔과 Push + Skip의 프레임당 ms</figcaption></figure><p></p>

<table><thead><tr><th>클라이언트</th><th>Push 끔</th><th>Push + Skip</th><th>감소</th></tr></thead><tbody><tr><td>1</td><td>2.30 ms</td><td>1.63 ms</td><td>29%</td></tr><tr><td>2</td><td>4.57 ms</td><td>3.36 ms</td><td>26%</td></tr><tr><td>4</td><td>7.62 ms</td><td>5.99 ms</td><td>21%</td></tr><tr><td>8</td><td>16.60 ms</td><td>13.18 ms</td><td>21%</td></tr></tbody></table><p>비교를 연결들이 공유하는데도 전체 시간은 연결 수에 거의 비례해 늘었다. 복제 비용의 대부분이 연결별 작업이라는 뜻이다. skip CVar가 줄이는 몫도 그 연결별 작업의 일부다. 연결이 늘수록 감소율이 조금 낮아진 것은 skip CVar가 건드리지 않는 연결별 작업이 함께 늘기 때문으로 보인다.</p></section><section><h3>Partial 클래스와 컴포넌트로 옮기기<a href="#partial-클래스와-컴포넌트로-옮기기"><span>#</span></a></h3>

<table><thead><tr><th>조건</th><th><code>ServerReplicateActors</code></th><th>skip된 replicator/프레임</th></tr></thead><tbody><tr><td>Full, Push + Skip</td><td>3.36 ms</td><td>1,998</td></tr><tr><td>Partial, Push 켬</td><td>3.80 ms</td><td>0</td></tr><tr><td>Partial, Push + Skip</td><td>3.79 ms</td><td>0.1</td></tr><tr><td>Carrier, Push 끔</td><td>6.24 ms</td><td>0</td></tr><tr><td>Carrier, Push 켬</td><td>5.80 ms</td><td>0</td></tr><tr><td>Carrier, Push + Skip</td><td>5.10 ms</td><td>1,998</td></tr></tbody></table><p>폴링 프로퍼티 하나가 섞인 Partial 클래스는 skip CVar를 켜도 skip되지 않았다. <code>ACharacter</code>를 상속한 캐릭터가 이 경우다.</p><p>그래서 캐릭터처럼 Partial일 수밖에 없는 액터라면 자주 바뀌지 않는 상태를 Full 컴포넌트로 옮겨 그 컴포넌트만이라도 skip을 받게 하면 되지 않을까 생각했다. Carrier 클래스가 그 구성이다. 컴포넌트는 실제로 skip됐지만(프레임당 1,998회) 전체는 5.10 ms로 Partial(3.79 ms)보다 35% 느렸다. 액터마다 replicator가 하나 더 생기고 서브오브젝트 처리 비용이 붙는 것이 skip으로 아끼는 몫보다 컸다. 이 측정에서는 skip을 노리고 상태를 쪼개는 것이 손해였다.</p></section><section><h3>unreliable multicast 한 번이 skip을 끈다<a href="#unreliable-multicast-한-번이-skip을-끈다"><span>#</span></a></h3><p>워밍업 중간에 모든 액터에서 <code>MulticastPing()</code>을 한 번씩 호출한 뒤 같은 조건으로 측정했다.</p><p></p><figure><img alt="Callees 트리: unreliable multicast 이후의 Push + Skip" loading="lazy" width="1345" height="427" src="/_astro/insights-callees-rpc-trap.DTWwjptn_SHH5T.webp" /><figcaption>Callees 트리: unreliable multicast 이후의 Push + Skip</figcaption></figure><p></p><p>skip CVar를 켰는데도 ① 직렬화가 1,273,410회 돌고 ② 비교도 636,705회 돈다. skip된 replicator는 프레임당 1,998개에서 0.1개로 떨어졌고 <code>ServerReplicateActors</code>는 3.74 ms로 Push Model만 켠 수준이 됐다. 앞에서 본 <code>GetNumBits() &gt;= 0</code> 조건 그대로다. multicast를 보낸 것은 측정 시작 10초 전이었고, 그 뒤로는 RPC를 보내지 않았다.</p></section><section><h3>대역폭은 바뀌지 않는다<a href="#대역폭은-바뀌지-않는다"><span>#</span></a></h3><p>Push Model이 네트워크 트래픽을 줄인다는 설명도 있어서 Networking Insights로 확인했다. 서버를 다음처럼 띄우면 패킷과 프로퍼티 단위의 비트 수가 기록된다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>UnrealEditor.exe</span><span><span> ThirdPerson.uproject </span><span>/</span><span>Game</span><span>/</span><span>ThirdPerson</span><span>/</span><span>Lvl_ThirdPerson </span><span>-</span><span>server </span><span>-</span><span>log `</span></span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>-</span><span>trace</span><span>=</span><span>net,frame,bookmark,region,log `</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>-</span><span>NetTrace</span><span>=</span><span>2</span><span> `</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>-</span><span>tracefile</span><span>=</span><span>"D:/Traces/rate1_off_net.utrace"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>-NetTrace</code>는 net trace의 상세 수준이다(0 꺼짐, 1 Trace, 2 Verbose, 3 VeryVerbose). net trace는 기록량이 많아서 이 실행은 CPU 수치 비교에는 쓰지 않았다.</p><p></p><figure><img alt="Networking Insights 송신 패킷 선택" loading="lazy" width="1600" height="499" src="/_astro/networking-insights-packets.B1SLPEAJ_Z1chzbt.webp" /><figcaption>Networking Insights 송신 패킷 선택</figcaption></figure><p></p><p>① 서버 인스턴스의 Connection 0에서 방향을 Outgoing으로 바꾸고, ② 패킷 막대를 클릭한 뒤 Shift-클릭해 초기 복제 이후의 패킷 985개를 골랐다. 초당 100%의 액터 값이 바뀌는 조건이다. 시드가 고정되어 있어서 두 실행은 같은 순서로 값을 쓴다.</p><p></p><figure><img alt="Net Stats: Push 끔" loading="lazy" width="1345" height="664" src="/_astro/networking-insights-push-off.fg5Qu5c8_jOK2J.webp" /><figcaption>Net Stats: Push 끔</figcaption></figure><p></p><p></p><figure><img alt="Net Stats: Push 켬" loading="lazy" width="1345" height="664" src="/_astro/networking-insights-push-on.DilKwkem_Z1fNnS4.webp" /><figcaption>Net Stats: Push 켬</figcaption></figure><p></p>

<table><thead><tr><th></th><th>① <code>PushLabActor</code> 업데이트</th><th>비트 합계</th><th>업데이트당 평균</th><th>② 패킷</th></tr></thead><tbody><tr><td>Push 끔</td><td>45,265</td><td>4,676,627</td><td>103 bits</td><td>985</td></tr><tr><td>Push 켬</td><td>45,240</td><td>4,677,280</td><td>103 bits</td><td>985</td></tr></tbody></table><p>폴링이든 push든 보내는 것은 바뀐 프로퍼티뿐이므로 송신 내용은 같다. <code>FVector</code> 프로퍼티는 200 bits, <code>int32</code>와 <code>float</code>은 40 bits로 두 실행이 같았다. 3000% 조건의 CSV에서도 프레임당 송신량은 37.26 KB, 37.27 KB, 37.24 KB로 같았다. Push Model이 줄이는 것은 서버 CPU다.</p></section><section><h3>Network Profiler로 비교 횟수 세기<a href="#network-profiler로-비교-횟수-세기"><span>#</span></a></h3><p>Networking Insights 이전부터 있던 Network Profiler도 아직 남아 있다. Shipping과 Test가 아닌 빌드에서 동작하고(<code>USE_NETWORK_PROFILER</code>), 서버 명령줄에 <code>networkprofiler=true</code>를 주거나 콘솔에서 <code>netprofile enable</code>을 실행하면 <code>Saved/Profiling</code>에 <code>.nprof</code> 파일이 기록된다. 파일은 <code>Engine/Binaries/DotNET/NetworkProfiler.exe</code>로 연다. 런처로 설치한 5.8에는 이 실행 파일이 없어서 소스 빌드에서 만든 것을 썼다.</p><p>이 도구에는 Insights에 없는 값이 두 가지 있다. 하나는 오브젝트와 프로퍼티별 비교 횟수이고, 다른 하나는 액터별 Waste다. 비교 횟수는 <code>Net.ProfilerUseComparisonTracking</code>(기본값 0)을 켜야 기록된다. 이번에는 메인 조건(액터 1000개, 클라이언트 2개, 초당 1%)에서 30초 측정 구간에만 기록이 걸리도록 하네스가 다음 콘솔 명령을 실행했다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Net.ProfilerUseComparisonTracking 1</span></div></div><div><div><div>2</div></div><div><span>netprofile enable</span></div></div><div><div><div>3</div></div><div><span>; 30초 측정</span></div></div><div><div><div>4</div></div><div><span>netprofile disable</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>프로파일러는 복제할 때마다 기록을 남기므로 이 실행의 시간 값에는 기록 비용이 들어 있다. 시간은 세 조건끼리만 비교하고 앞의 CPU 측정과는 섞지 않는다.</p><p></p><figure><img alt="Network Profiler All Objects: Push 끔" loading="lazy" width="1600" height="727" src="/_astro/network-profiler-objects-push-off.CXVk6cuW_Z245YVi.webp" /><figcaption>Network Profiler All Objects: Push 끔</figcaption></figure><p></p><p>All Objects 탭의 왼쪽은 클래스별 합계, 오른쪽은 선택한 클래스의 프로퍼티별 횟수다. Push Model을 끄면 ① <code>PushLabActor</code>의 비교가 640,366회, 비교 시간이 517.5 ms다. 프레임마다 액터 1000개를 한 번씩 비교한 횟수로, 클라이언트가 2개여도 두 배가 되지 않는다. 비교는 오브젝트당 한 번이고 결과를 연결들이 공유한다는 앞의 설명과 맞는다. ② 프로퍼티도 모두 640,366회씩 비교했지만 실제로 바뀐 것은 프로퍼티마다 11~26회였다.</p><p></p><figure><img alt="Network Profiler All Objects: Push 켬" loading="lazy" width="1600" height="727" src="/_astro/network-profiler-objects-push-on.rWVBRWEz_1PAAtH.webp" /><figcaption>Network Profiler All Objects: Push 켬</figcaption></figure><p></p><p>Push Model을 켜도 ① 오브젝트 단위 비교 호출은 639,368회로 거의 같다. Full 클래스라도 skip CVar가 없으면 매 프레임 비교 함수에 들어가기 때문이다. 대신 비교 시간은 139.0 ms로 줄었다. ② 프로퍼티별 비교 횟수가 바뀐 횟수와 같아졌고, <code>AActor</code>에서 물려받은 프로퍼티는 한 번도 비교하지 않았다. dirty가 아닌 push 프로퍼티를 건너뛰는 동작이 이 숫자로 그대로 보인다.</p>

<table><thead><tr><th>조건</th><th>① <code>PushLabActor</code> 비교 호출</th><th>비교 시간</th><th>② <code>Int00</code> 비교</th><th><code>Int00</code> 변경</th></tr></thead><tbody><tr><td>Push 끔</td><td>640,366</td><td>517.5 ms</td><td>640,366</td><td>21</td></tr><tr><td>Push 켬</td><td>639,368</td><td>139.0 ms</td><td>21</td><td>21</td></tr><tr><td>Push + Skip</td><td>601</td><td>0.5 ms</td><td>21</td><td>21</td></tr></tbody></table><p>skip CVar까지 켜면 오브젝트 단위 비교 호출이 601회로 줄어든다. dirty가 없는 액터는 <code>ReplicateProperties</code>에 들어가지 않으므로 비교 함수도 부르지 않는다. 시드가 고정되어 있어서 세 실행의 프로퍼티별 변경 횟수는 같았다.</p><p></p><figure><img alt="Network Profiler Actors: Push + Skip" loading="lazy" width="1200" height="300" src="/_astro/network-profiler-actors-push-skip.DXrX02mF_ZabeUR.webp" /><figcaption>Network Profiler Actors: Push + Skip</figcaption></figure><p></p><p>Waste는 <code>ReplicateActor</code>를 부른 횟수 중 아무것도 보내지 않은 비율이다. 뷰어는 <code>100 - Rep HZ / Update HZ × 100</code>으로 계산한다(<code>Engine/Source/Programs/NetworkProfiler/NetworkProfiler/PartialNetworkStream.cs</code>). ① Push + Skip에서 <code>PushLabActor</code>는 초당 43,724회 처리되었고 그중 20.07회만 무언가를 보냈다. Waste는 99.95다.</p>

<table><thead><tr><th>조건</th><th>MS</th><th>Update HZ</th><th>Rep HZ</th><th>Waste</th></tr></thead><tbody><tr><td>Push 끔</td><td>3,766.40</td><td>42,698.61</td><td>20.07</td><td>99.95</td></tr><tr><td>Push 켬</td><td>3,473.96</td><td>42,660.81</td><td>20.08</td><td>99.95</td></tr><tr><td>Push + Skip</td><td>677.53</td><td>43,724.04</td><td>20.07</td><td>99.95</td></tr></tbody></table><p>Waste는 세 조건에서 같았다. Push Model은 보낼지 판단하는 방법을 바꿀 뿐 보내는 내용을 바꾸지 않는다. skip으로 건너뛴 호출도 <code>TrackReplicateActor</code>로 기록되므로(<code>Engine/Private/DataChannel.cpp</code>의 <code>ReplicateActor</code>) 분모에서 빠지지 않는다. 그래서 Waste가 그대로라고 Push Model이 효과가 없다고 읽으면 안 된다. 효과는 <code>ReplicateActor</code>에 쓴 시간인 MS 열에서 보인다. Push Model만 켜면 8% 줄었고 skip CVar까지 켜면 82% 줄었다. 앞의 CPU 측정보다 감소율이 훨씬 큰 데는 두 가지 이유가 있다. MS 열은 <code>ServerReplicateActors</code> 전체가 아니라 <code>ReplicateActor</code> 안쪽만 재므로 skip CVar가 건드리지 않는 후보 수집과 우선순위 계산이 빠져 있고, 비교와 직렬화마다 프로파일러가 기록을 남기므로 skip으로 건너뛴 호출에서는 그 기록 비용까지 함께 사라진다. 이 열은 세 조건의 상대 비교에만 쓴다.</p></section><section><h3>Dormancy와 비교하면<a href="#dormancy와-비교하면"><span>#</span></a></h3><p>시간 분해에서 가장 큰 칸은 연결별 액터 처리였고, Push + Skip에서도 2.13 ms가 남았다. 이 칸은 액터가 <code>ReplicateActor</code>에 들어가는 한 남는다. 액터를 복제 검토 대상에서 아예 빼는 기능은 Dormancy다. <code>DORM_DormantAll</code>인 액터는 모든 연결에서 채널이 닫히면 활성 네트워크 오브젝트 목록에서 빠지고, <code>FlushNetDormancy</code>나 <code>ForceNetUpdate</code>로 깨울 때만 다시 복제된다.</p><p>같은 실험에 Dormancy 조건을 더했다. 워밍업 5초 시점에 액터 1000개를 모두 <code>DORM_DormantAll</code>로 바꾸고, 값을 쓸 때마다 먼저 깨운 뒤 값을 바꾸고 마킹했다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 깨운 다음에 바꾼다</span></div></div><div><div><div>2</div></div><div><span>Actor</span><span>-&gt;</span><span>FlushNetDormancy</span><span>();</span></div></div><div><div><div>3</div></div><div><span>Actor</span><span>-&gt;</span><span>SetHealth</span><span>(</span><span>NewHealth</span><span>);</span><span> // 내부에서 마킹한다</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>메인 조건(초당 1% 변경)을 조건당 3회 측정한 평균은 다음과 같다.</p>

<table><thead><tr><th>조건</th><th><code>ServerReplicateActors</code></th></tr></thead><tbody><tr><td>깨어 있음, Push + Skip (앞의 10회 평균)</td><td>3.36 ms</td></tr><tr><td>Dormancy, Push 끔</td><td>0.16 ms</td></tr><tr><td>Dormancy, Push 켬</td><td>0.16 ms</td></tr><tr><td>Dormancy, Push + Skip</td><td>0.17 ms</td></tr></tbody></table><p>Push + Skip의 약 5%다. 측정 중 활성 네트워크 오브젝트는 프레임당 평균 10.9개였다. 잠든 액터는 비교도 직렬화도 하지 않으므로 Push Model과 skip CVar 설정에 따른 차이도 사라졌다.</p><p>값을 바꾸는 빈도를 올리면 결과가 뒤집힌다.</p><p></p><figure><img alt="Dormancy와 변경 빈도별 ServerReplicateActors 시간 막대 차트: 변경 비율 1%, 10%, 100%, 3000%에서 깨어 있는 Push 끔, 깨어 있는 Push + Skip, Dormancy Push + Skip의 프레임당 ms" loading="lazy" width="1600" height="800" src="/_astro/chart-dormancy.DlAhYyNX_Z1BPFss.webp" /><figcaption>Dormancy와 변경 빈도별 ServerReplicateActors 시간 막대 차트: 변경 비율 1%, 10%, 100%, 3000%에서 깨어 있는 Push 끔, 깨어 있는 Push + Skip, Dormancy Push + Skip의 프레임당 ms</figcaption></figure><p></p>

<table><thead><tr><th>초당 변경 비율</th><th>깨어 있음, Push 끔</th><th>깨어 있음, Push + Skip</th><th>Dormancy, Push + Skip</th><th>활성 오브젝트/프레임</th></tr></thead><tbody><tr><td>1%</td><td>4.57</td><td>3.36</td><td>0.17</td><td>10.9</td></tr><tr><td>10%</td><td>4.21</td><td>3.07</td><td>0.42</td><td>19.3</td></tr><tr><td>100%</td><td>4.35</td><td>3.38</td><td>4.01</td><td>97.8</td></tr><tr><td>3000% (액터당 초당 30회)</td><td>8.55</td><td>8.43</td><td>24.23</td><td>833.7</td></tr></tbody></table><p>활성 오브젝트는 Dormancy 조건의 값이다. Push Model을 끈 Dormancy는 0.16, 0.42, 4.14, 24.91 ms로 Push + Skip과 비슷했다.</p><p>액터가 평균 10초에 한 번 바뀌는(10%) 조건까지는 Dormancy가 크게 앞섰다. 1초에 한 번(100%)이면 Push + Skip이 앞섰고, 초당 30회(3000%)면 Dormancy가 약 3배 느렸다.</p><p>같은 100% 조건의 트레이스에서 차이가 난 곳은 직렬화였다. 깨어 있는 Push + Skip에서 <code>Dynamic Property Rep Time</code>은 호출당 0.75 µs, 프레임당 0.12 ms였다. Dormancy에서는 호출당 9.84 µs, 프레임당 2.09 ms였다. 깨어난 액터는 채널을 새로 열고(채널 생성이 프레임당 85.6회), 호출당 시간이 13배인 것으로 보아 바뀐 프로퍼티 하나가 아니라 상태 전체를 다시 직렬화하는 것으로 보인다. 송신 패킷도 프레임당 2.0개에서 17.4개로 늘었고, 3000% 조건의 프레임당 송신량은 37.2 KB에서 80.1 KB가 됐다.</p><p>표의 수치 밖에서 드는 비용도 있다. <code>ServerReplicateActors</code> 다음에 도는 <code>UNetConnection::Tick</code>의 채널 Tick(<code>STAT_NetConnection_TickChannels</code>)이 깨어 있을 때 0.004 ms에서 0.92 ms로 늘었고, 게임 코드에서 부른 <code>FlushNetDormancy</code>에 0.25 ms가 더 들었다.</p><p>깨우는 순서는 Epic의 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-network-dormancy-in-unreal-engine" target="_blank">Actor Network Dormancy</a> 문서를 따랐다. 문서는 깨울 때 shadow state를 현재 값으로 다시 만들기 때문에 깨운 다음에 바꾸라고 한다. 순서를 바꿔 값을 먼저 바꾸고 flush하는 probe도 돌려 봤는데, 이번 실험의 일반 프로퍼티는 1000개 모두 전달됐다. 문서는 이 동작을 기대면 안 되는 구현 세부로 보고, FastArray는 이 순서에서 변경이 전달되지 않을 수 있다고 적는다. 다음 절에서 볼 <a href="https://issues.unrealengine.com/issue/UE-226689" target="_blank">UE-226689</a>도 Dormancy 해제와 마킹이 엮인 문제다.</p></section><section><h3>다른 측정과 비교하면<a href="#다른-측정과-비교하면"><span>#</span></a></h3><p>영어권에 공개된 측정으로는 Kieran Newland의 글이 있다(<a href="https://www.kierannewland.co.uk/push-model-networking-unreal-engine/" target="_blank">Push Model Networking</a>, UE 5.3.2). 그 측정에서는 Push Model만 켰을 때 -17%, skip CVar까지 켰을 때 -54%였다. 이번 측정의 -13%와 -26%보다 skip CVar의 효과가 훨씬 크다.</p><p>그 측정은 X와 Y로 20칸씩 도는 격자에 액터를 스폰하고(글의 <code>TotalRowsCols</code> 값이 20이라 20×20으로 보인다), 액터마다 컴포넌트 20개를 붙여 컴포넌트마다 float 하나를 복제하는 구성이다. 클라이언트는 4개이고, 값은 약 200 ms 구간의 <code>NetBroadcastTickTime</code>이다. 복제 대상 대부분이 서브오브젝트다. 서브오브젝트는 skip되면 그 서브오브젝트의 처리를 일찍 끝내지만, 액터 본체는 <code>ReplicateProperties</code> 한 줄만 건너뛰고 나머지 연결별 작업이 남는다. 이번 실험은 프로퍼티가 액터 본체에 있는 구성이라 남는 몫이 컸다. 같은 CVar의 효과가 -54%와 -26%로 갈린 것도 그 때문으로 보인다. 두 숫자 중 어느 한쪽이 맞다기보다, skip CVar의 효과는 상태가 액터 본체에 있는지 서브오브젝트에 있는지에 따라 달라진다고 본다.</p></section></section>
<section><h2>마킹을 빠뜨리면<a href="#마킹을-빠뜨리면"><span>#</span></a></h2><p>push 프로퍼티를 바꾸고 마킹을 빠뜨리면 아무 오류 없이 값이 전달되지 않는다. 측정 중 5초 시점에 서버에서 1000개 액터의 <code>Int00</code>을 777로 바꾸되 마킹하지 않고, 15초 시점에 같은 액터들의 <code>Int01</code>을 바꾸고 마킹했다. 클라이언트는 2초마다 <code>Int00 == 777</code>인 액터 수를 로그로 남겼다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// Push Model 켬: 30초 동안 매번 같은 결과</span></div></div><div><div><div>2</div></div><div><span>LogPushLab: Display: Client probe: 0 / 1000 lab actors have Int00=777</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>// Push Model 끔: 다음 확인에서 모두 반영</span></div></div><div><div><div>5</div></div><div><span>LogPushLab: Display: Client probe: 0 / 1000 lab actors have Int00=777</span></div></div><div><div><div>6</div></div><div><span>LogPushLab: Display: Client probe: 1000 / 1000 lab actors have Int00=777</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>같은 액터의 다른 프로퍼티가 마킹되어 액터가 복제돼도 <code>Int00</code>은 따라가지 않는다. dirty가 아닌 push 프로퍼티는 비교 대상에서 빠지기 때문이다. Dormancy와 겹치면 더 나빠진다. 마킹하지 않은 값이 Dormancy 해제 때 전송은 되지만 shadow state에는 기록되지 않아서, 나중에 값을 원래대로 되돌리면 그 변경을 감지하지 못한다. 이 이슈는 아직 열려 있다(<a href="https://issues.unrealengine.com/issue/UE-226689" target="_blank">UE-226689</a>).</p><p>이 확인은 초기 복제가 끝난 뒤에 해야 한다. 스폰 직후에 바꾼 값은 모든 비트가 dirty인 상태에서 첫 복제에 실리므로 마킹을 빠뜨려도 전달된다. 포럼의 “마킹하지 않았는데 복제된다”는 질문도 원인이 이것이었다(<a href="https://forums.unrealengine.com/t/properties-using-push-model-replicate-without-being-manually-marked-as-dirty/483647" target="_blank">포럼 글</a>). 이 실험의 probe도 측정 시작 5초 뒤에 값을 바꿨다.</p><section><h3>마킹 누락을 막는 구조<a href="#마킹-누락을-막는-구조"><span>#</span></a></h3><p>복제 프로퍼티를 private으로 두고 값을 바꾸는 경로를 setter로 모으면 마킹을 빠뜨릴 자리가 setter 안으로 줄어든다. <code>FInventorySlot</code>은 <code>ItemId</code>와 <code>Count</code>를 가진 <code>USTRUCT</code>다. <code>Health</code>와 <code>Slots</code>는 앞에서 본 것처럼 <code>bIsPushBased</code>로 등록했다고 가정한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>2</div></div><div><span>class</span><span> </span><span>AInventoryActor</span><span> : </span><span>public</span><span> </span><span>AActor</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>public:</span></div></div><div><div><div>7</div></div><div><span>  </span><span>void</span><span> </span><span>SetHealth</span><span>(</span><span>float</span><span><span> </span><span>NewHealth</span><span>)</span></span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>// 값이 다를 때만 대입하고 마킹한다</span></div></div><div><div><div>10</div></div><div><span>    </span><span>COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY</span><span>(</span><span>AInventoryActor</span><span>, </span><span>Health</span><span>, </span><span>NewHealth</span><span>, </span><span>this</span><span>);</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>  </span><span>void</span><span> </span><span>SetSlotCount</span><span>(</span><span>int32</span><span><span> </span><span>SlotIndex</span><span>, </span></span><span>int32</span><span><span> </span><span>NewCount</span><span>)</span></span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>// 배열 원소는 직접 비교하고, 바뀌었으면 배열 프로퍼티 전체를 마킹한다</span></div></div><div><div><div>16</div></div><div><span>    </span><span>FInventorySlot</span><span>&amp;</span><span> </span><span>Slot</span><span> </span><span>=</span><span> </span><span>Slots</span><span>[</span><span>SlotIndex</span><span>];</span></div></div><div><div><div>17</div></div><div><span>    </span><span>if</span><span> (</span><span>Slot</span><span>.</span><span>Count</span><span> </span><span>!=</span><span> </span><span>NewCount</span><span>)</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>19</div></div><div><span>      </span><span>Slot</span><span>.</span><span>Count</span><span> </span><span>=</span><span> </span><span>NewCount</span><span>;</span></div></div><div><div><div>20</div></div><div><span>      </span><span>MARK_PROPERTY_DIRTY_FROM_NAME</span><span>(</span><span>AInventoryActor</span><span>, </span><span>Slots</span><span>, </span><span>this</span><span>);</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span><span>  </span></span><span>// 여러 원소를 한 번에 고쳐야 할 때는 참조를 넘기는 순간 마킹한다</span></div></div><div><div><div>25</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>FInventorySlot</span><span>&gt;</span><span>&amp;</span><span> </span><span>GetSlots_Mutable</span><span>()</span></div></div><div><div><div>26</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>27</div></div><div><span>    </span><span>MARK_PROPERTY_DIRTY_FROM_NAME</span><span>(</span><span>AInventoryActor</span><span>, </span><span>Slots</span><span>, </span><span>this</span><span>);</span></div></div><div><div><div>28</div></div><div><span>    </span><span>return</span><span> </span><span>Slots</span><span>;</span></div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span>private:</span></div></div><div><div><div>32</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>) </span><span>float</span><span> </span><span>Health</span><span> </span><span>=</span><span> </span><span><span>100.0</span><span>f</span></span><span>;</span></div></div><div><div><div>33</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>) </span><span>TArray</span><span>&lt;</span><span>FInventorySlot</span><span>&gt;</span><span> </span><span>Slots</span><span>;</span></div></div><div><div><div>34</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>Push Model에서는 프로퍼티 단위로 마킹하므로 배열 원소 하나를 바꿔도 배열 프로퍼티 전체가 다음 비교 대상이 된다. 실제로 보내는 원소는 비교에서 달라진 것뿐이다. <code>GetSlots_Mutable()</code>처럼 참조를 넘기면서 마킹하는 방식은 엔진도 쓴다. <code>AActor::GetReplicatedMovement_Mutable()</code>이 <code>ReplicatedMovement</code>를 마킹한 뒤 참조를 돌려준다(<code>Engine/Private/Actor.cpp</code>). 값을 바꾸지 않고 마킹만 해도 비교 비용만 들고 전송은 없다는 것은 앞에서 확인했다.</p><p>클래스 이름과 <code>this</code>를 매번 적는 게 번거롭다면 프로젝트 공용 헤더에 래퍼 매크로를 둘 수 있다. <code>ThisClass</code>는 <code>GENERATED_BODY()</code>가 클래스마다 선언하는 별칭이다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#define</span><span> </span><span>MARK_DIRTY</span><span><span>(</span><span>PropertyName</span><span>) </span></span><span>MARK_PROPERTY_DIRTY_FROM_NAME</span><span>(</span><span>ThisClass</span><span>, </span><span>PropertyName</span><span>, </span><span>this</span><span>)</span></div></div><div><div><div>2</div></div><div><span>#define</span><span> </span><span>SET_AND_MARK</span><span><span>(</span><span>PropertyName</span><span>, </span><span>NewValue</span><span>) </span></span><span>COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY</span><span>(</span><span>ThisClass</span><span>, </span><span>PropertyName</span><span>, </span><span>NewValue</span><span>, </span><span>this</span><span>)</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>// 앞의 AInventoryActor::SetHealth를 매크로로 줄이면 다음과 같다</span></div></div><div><div><div>5</div></div><div><span>void</span><span> </span><span>SetHealth</span><span>(</span><span>float</span><span><span> </span><span>NewHealth</span><span>)</span></span></div></div><div><div><div>6</div></div><div><span>{</span></div></div><div><div><div>7</div></div><div><span>  </span><span>SET_AND_MARK</span><span>(</span><span>Health</span><span>, </span><span>NewHealth</span><span>);</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 매크로는 부모 클래스에서 선언한 프로퍼티에 쓸 때 함정이 있다. 마킹 매크로는 RepIndex를 <code>ThisClass::ENetFields_Private</code>에서 읽는데, UHT는 복제 프로퍼티가 있는 클래스마다 자기 프로퍼티만 담은 <code>ENetFields_Private</code>를 만든다. 자식 클래스에 복제 프로퍼티가 없으면 enum이 없으니 부모의 enum을 찾아가서 컴파일된다. 자식에 복제 프로퍼티를 하나라도 추가하는 순간 자식의 enum이 부모의 enum을 가리고, 멀쩡하던 마킹 코드가 컴파일 오류로 바뀐다. ThirdPerson 프로젝트에서 두 경우를 빌드해 확인했다. 부모 프로퍼티는 선언한 클래스 이름을 직접 적거나 부모의 setter를 거쳐 마킹한다.</p><p>다만 <code>GetSlots_Mutable()</code>로 받은 참조를 보관했다가 다음 프레임 이후에 고치는 경우는 이 구조로도 막지 못한다. 마킹은 이미 비교에서 소비된 뒤라 변경이 전달되지 않는다. 이런 경로는 검증용 CVar로 찾는다.</p></section><section><h3>검증 CVar<a href="#검증-cvar"><span>#</span></a></h3><p>개발 중에는 켜 두고, 성능을 잴 때는 끈다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[SystemSettings]</span></div></div><div><div><div>2</div></div><div><span>; 모든 push 프로퍼티를 비교해서 마킹되지 않은 변경이 있으면 경고한다 (기본값 0)</span></div></div><div><div><div>3</div></div><div><span>; 모든 프로퍼티를 비교하므로 켠 상태로는 Push Model의 이득이 사라진다</span></div></div><div><div><div>4</div></div><div><span>net.PushModelValidateProperties</span><span><span>=</span><span>1</span></span></div></div><div><div><div>5</div></div><div><span>; skip할 수 있다고 판단한 오브젝트가 실제로 데이터를 썼는지 검사한다 (기본값 0)</span></div></div><div><div><div>6</div></div><div><span>; 검사하려고 skip하지 않고 복제하므로 이것도 측정 때는 끈다</span></div></div><div><div><div>7</div></div><div><span>net.PushModelValidateSkipUpdate</span><span><span>=</span><span>1</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>net.PushModelValidateProperties</code>는 Shipping과 Test 빌드에는 들어가지 않는다(<code>Net/Core/Public/Net/Core/PushModel/PushModelMacros.h</code>의 <code>WITH_PUSH_VALIDATION_SUPPORT</code>).</p></section></section>
<section><h2>Iris: push를 더 적극적으로 쓰는 차세대 복제 시스템<a href="#iris-push를-더-적극적으로-쓰는-차세대-복제-시스템"><span>#</span></a></h2><p>Iris(<code>net.Iris.UseIrisReplication</code>, 5.8.3 기본값 0)는 push 정보를 레거시보다 적극적으로 쓰도록 설계됐다. <code>net.Iris.PushModelMode</code>의 기본값이 2(켜짐)라서, <code>Net.IsPushModelEnabled</code>와 <code>WITH_PUSH_MODEL</code>만 켜져 있으면 Iris는 기본으로 Push Model을 쓴다(<code>Net/Iris/Private/Iris/ReplicationSystem/LegacyPushModel.h</code>의 <code>IsIrisPushModelEnabled</code>). 다만 Push Model이 필수는 아니다. 두 스위치가 꺼져 있으면 모든 오브젝트의 상태 전체를 폴링하고(<code>FObjectPoller::ForcePollObject</code>), 켜져 있어도 push 기반이 아닌 멤버는 매번 폴링한다.</p><p>Iris에서 Push Model이 동작하는 데 필요한 스위치만 추리면 다음과 같다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[SystemSettings]</span></div></div><div><div><div>2</div></div><div><span>; Iris 사용 (기본값 0)</span></div></div><div><div><div>3</div></div><div><span>net.Iris.UseIrisReplication</span><span><span>=</span><span>1</span></span></div></div><div><div><div>4</div></div><div><span>; 레거시와 같은 Push Model 스위치. 꺼져 있으면 Iris는 모든 상태를 폴링한다</span></div></div><div><div><div>5</div></div><div><span>net.IsPushModelEnabled</span><span><span>=</span><span>1</span></span></div></div><div><div><div>6</div></div><div><span>; 기본값 2(켜짐, 런타임에 바꿀 수 있음). 적지 않아도 된다</span></div></div><div><div><div>7</div></div><div><span>; net.Iris.PushModelMode=2</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>패키지 빌드라면 <a href="#1-%EC%BB%B4%ED%8C%8C%EC%9D%BC-%EC%8A%A4%EC%9C%84%EC%B9%98-bwithpushmodel">앞에서 본 것처럼</a> Target.cs의 <code>bWithPushModel = true</code>도 필요하다. 이 예시는 push 관련 스위치만 모은 것이다. Iris 자체를 켜려면 Iris 플러그인과 모듈의 <code>SetupIrisSupport(Target)</code> 같은 준비가 더 필요하므로 Epic의 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/migrate-to-iris-in-unreal-engine" target="_blank">Migrate to Iris</a> 문서를 따른다. 이번 실험은 Iris를 켜고 측정하지 않았다.</p><p>레거시와 가장 다른 점은 push 정보를 쓰는 단계다. Iris는 복제 전에 오브젝트 값을 내부 상태로 복사하는 폴링 단계(<code>FObjectPoller</code>)를 거친다. Full push 오브젝트(모든 멤버가 push 기반인 오브젝트)는 dirty가 아니고 GC의 영향도 받지 않았다면 폴링 루프에 들어가기 전에 목록에서 빠진다(<code>ObjectPoller.cpp</code>, <code>net.Iris.Poll.FilterOutNonDirtyPushBasedObjects</code> 기본값 <code>true</code>). 레거시가 비교 단계에서 프로퍼티를 건너뛴다면, Iris는 그보다 앞에서 오브젝트 단위로 걸러 낸다. 기본 설정에서는 마킹된 오브젝트가 <code>NetUpdateFrequency</code>로 정해진 폴링 주기를 기다리지 않고 그 프레임에 폴링된다는 점도 다르다. Epic도 Iris 개발 과정을 다룬 <a href="https://www.youtube.com/watch?v=K472O2rVvG0" target="_blank">Unreal Fest Orlando 2025 발표</a>의 성능 팁에서 프로퍼티에 Push Model을 쓰라고 권했다. 특히 폴링 단계에서 시간을 많이 아낀다는 설명이었다.</p><p>엔진 설정에는 Iris에서 Full push를 유지해야 하는 클래스 목록이 있다(<code>Config/BaseEngine.ini</code>의 <code>EnsureFullyPushModelClassNames</code>). 목록에는 <code>SceneComponent</code>, <code>StaticMeshComponent</code>, <code>CapsuleComponent</code> 같은 컴포넌트와 <code>WorldDataLayers</code>만 있고 Actor, Pawn, Character는 없다. Iris로 옮겨도 캐릭터는 Partial로 남는다.</p></section>
<section><h2>Push Model 적용 기준<a href="#push-model-적용-기준"><span>#</span></a></h2><p>새 프로젝트에서 기본으로 하는 것은 세 가지다.</p><ul>
<li>직접 관리하는 복제 프로퍼티는 push로 등록하고 skip CVar를 켠다. 이번에 측정한 조건에서는 Push Model을 켠 쪽이 실행 간 편차를 넘어 느려진 경우가 없었다. 다만 측정한 것은 수치형 프로퍼티 16개짜리 액터이고, 엔진 헤더가 적어 둔 메모리 비용(<code>TargetRules.cs</code>의 <code>bWithPushModel</code> 주석)은 재지 않았다. skip CVar는 Full 클래스에서만 동작하므로 한 클래스 안에서 섞어 쓸 이유는 없다. 값을 바꾸는 경로를 통제하기 어려운 코드는 따로 검토한다.</li>
<li>값을 바꾸는 경로는 setter로 모은다(<a href="#%EB%A7%88%ED%82%B9-%EB%88%84%EB%9D%BD%EC%9D%84-%EB%A7%89%EB%8A%94-%EA%B5%AC%EC%A1%B0">마킹 누락을 막는 구조</a>).</li>
<li>패키지 빌드의 Target.cs에 <code>bWithPushModel = true</code>가 있는지 확인한다. 없으면 PIE에서 본 동작과 다르게 조용히 폴링으로 돈다.</li>
</ul><p>Push Model로 이득을 기대하기 전에는 다음을 확인한다.</p><ul>
<li>unreliable multicast를 보내는 액터인가. 한 번이라도 보내면 그 연결에서 skip이 영구히 꺼진다. 자주 skip되어야 하는 액터라면 그 RPC를 다른 액터로 옮길지 따져 본다.</li>
<li>엔진 부모 클래스 때문에 Partial 클래스인가. 캐릭터가 그렇다. 이때는 비교 비용 감소까지만 기대한다.</li>
<li>skip을 노리고 상태를 컴포넌트로 쪼개려는가. 이번 측정에서는 replicator가 늘어나는 비용이 더 커서 오히려 35% 느려졌다.</li>
</ul><p>Push Model은 Dormancy를 대신하지 않는다. 값이 몇 초에 한 번 바뀔까 말까 한 액터라면 Dormancy를 먼저 검토하고, Push Model은 깨어 있는 액터의 비교 비용을 줄이는 데 쓴다. 이번 측정에서는 액터마다 초당 한 번 바뀌는 지점에서 둘의 순서가 뒤집혔다. 그 경계는 액터의 프로퍼티 수와 연결 수에 따라 달라질 것이므로 프로젝트에서 직접 재 보는 편이 낫다.</p><p>엔진 헤더도 이 기능의 한계를 적어 두었다.</p><blockquote><p>While the theoretical gains for Push Model are good, in practice the gains seen haven’t been as good as expected.</p><p><code>Net/Core/Public/Net/Core/PushModel/PushModel.h</code></p></blockquote><p>이번 측정의 26%도 그 문장과 크게 다르지 않다. 그래도 측정한 조건 안에서는 손해 보는 경우를 찾지 못했고, 마킹을 강제하는 구조가 값을 바꾸는 경로를 한곳으로 모으는 효과도 있다. 수치형 프로퍼티 위주의 실험이라 다른 구성에서는 결과가 다를 수 있지만, 새 프로젝트에서 직접 관리하는 프로퍼티를 push로 등록하는 것을 기본 선택으로 삼기에는 충분하다고 본다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li>Unreal Engine 5.8.3 소스: <code>Net/Core/Public/Net/Core/PushModel/PushModel.h</code>, <code>Net/Core/Private/Net/Core/PushModel/PushModel.cpp</code>, <code>Engine/Private/RepLayout.cpp</code>, <code>Engine/Private/DataReplication.cpp</code>, <code>Engine/Private/DataChannel.cpp</code>, <code>Engine/Private/ActorReplication.cpp</code>, <code>Engine/Private/NetConnection.cpp</code>, <code>Net/Core/Classes/Net/Serialization/FastArraySerializer.h</code>, <code>Engine/Public/Net/UnrealNetwork.h</code>, <code>CoreUObject/Public/UObject/CoreNet.h</code>, <code>Net/Core/Private/Net/Core/Misc/NetCVars.cpp</code>, <code>Engine/Private/NetworkProfiler.cpp</code>, <code>Engine/Source/Editor/BlueprintGraph/Private/VariableSetHandler.cpp</code>, <code>Engine/Source/Programs/NetworkProfiler/NetworkProfiler/PartialNetworkStream.cs</code>, <code>Programs/UnrealBuildTool/Configuration/Rules/TargetRules.cs</code></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/replicating-uobjects-in-unreal-engine" target="_blank">Replicating UObjects in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-network-dormancy-in-unreal-engine" target="_blank">Actor Network Dormancy in Unreal Engine</a></li>
<li><a href="https://www.youtube.com/watch?v=K472O2rVvG0" target="_blank">Developing and Launching a New Replication System, Unreal Fest Orlando 2025</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/migrate-to-iris-in-unreal-engine" target="_blank">Migrate to Iris in Unreal Engine</a></li>
<li><a href="https://www.kierannewland.co.uk/push-model-networking-unreal-engine/" target="_blank">Kieran Newland, Push Model Networking</a></li>
<li><a href="https://notes.hzfishy.fr/Unreal-Engine/Networking/Core/Push-Model" target="_blank">hzFishy’s Game Dev Notes, Push Model</a></li>
<li><a href="https://www.youtube.com/watch?v=hDIU4I8k-28" target="_blank">Sneaky Kitty Game Dev, Unreal Engine Networking: Push Model</a></li>
<li><a href="https://www.youtube.com/watch?v=NWAdK2ndYWc" target="_blank">enigma tutorials, UPROPERTY Replication (Normal &amp; Push)</a></li>
<li><a href="https://issues.unrealengine.com/issue/UE-194745" target="_blank">UE-194745</a>, <a href="https://issues.unrealengine.com/issue/UE-226689" target="_blank">UE-226689</a></li>
<li><a href="https://forums.unrealengine.com/t/push-model-networking/510684" target="_blank">Push Model Networking, Epic Developer Community Forums</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-5-8-mcp-and-agentic-game-development/</id>
      <title type="text">언리얼 엔진 5.8 MCP로 본 게임 개발과 에이전틱 코딩의 거리</title>
      <published>2026-10-06T08:30:43.000Z</published>
      <updated>2026-10-19T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-5-8-mcp-and-agentic-game-development/"/>
      <summary type="text">언리얼 엔진 5.8 MCP를 Claude Code와 Codex로 써 보고 Epic 세션을 보며, All Toolsets에 이미 포함된 Editor Toolset, 일괄 작업과 비용, 에디터 의존성, 프로젝트 구조 지식의 필요성에 대해 생각한 점을 정리한다.</summary>
      <content type="html"><![CDATA[<p>언리얼 엔진 5.8에 실험 단계 기능으로 MCP(Model Context Protocol) 플러그인이 들어왔다. 에디터 안에서 MCP 서버를 띄우고, Claude Code나 Codex 같은 에이전트가 그 서버를 통해 에디터를 조작하는 구조다.</p>
<p>Epic 에듀케이션 오피스 아워 세션을 한국어 자막으로 올린 <a href="https://www.youtube.com/watch?v=-Bol0qjz3rw" target="_blank">언리얼 엔진 5.8 MCP 시작하기</a>를 보고, 삼인칭 템플릿에서 Claude Code와 Codex로 프로퍼티 일괄 수정을 시켜 봤다. 여러 프로퍼티를 자연어로 한 번에 다룰 수 있다는 점은 고무적이었다. 동시에 언리얼 개발이 에디터에 묶여 있다는 사실이 에이전틱 개발에서 어떤 제약이 되는지도 다시 보게 됐다.</p>
<section><h2><code>All Toolsets</code>에 포함된 <code>Editor Toolset</code><a href="#all-toolsets에-포함된-editor-toolset"><span>#</span></a></h2><p>공식 문서는 <code>Unreal MCP</code>와 <code>All Toolsets</code> 두 플러그인을 켜라고 안내한다. 영상에서는 <code>Editor Toolset</code>까지 세 개를 켜야 하고, 온라인 가이드에는 <code>EditorToolset</code>이 빠져 있다고 정정한다. <code>Editor Toolset</code>이 꺼져 있으면 에이전트가 서버에 연결은 되지만 사용할 툴이 없어서 에디터를 조작하지 못한다는 설명이다.</p><p></p><figure><img alt="영상의 설정 슬라이드. 1단계 아래에 EditorToolset 정정이 붙어 있다" loading="lazy" width="1600" height="900" src="/_astro/setup-steps.6izcNR3x_Z19igUz.webp" /><figcaption>영상의 설정 슬라이드. 1단계 아래에 EditorToolset 정정이 붙어 있다</figcaption></figure><p></p><p>처음 이 글을 쓸 때는 영상의 설명을 따라 이 플러그인을 따로 켜야 한다고 적었다. 그런데 엔진 소스를 보면 <code>All Toolsets</code>가 이미 <code>EditorToolset</code>에 의존한다. <code>All Toolsets</code>는 툴세트 플러그인을 묶는 집계 플러그인이고, 의존 플러그인 목록에 <code>EditorToolset</code>이 들어 있다.</p><div><figure><figcaption><span>Engine/Plugins/Experimental/Toolsets/AllToolsets/AllToolsets.uplugin (발췌)</span></figcaption><pre><code><div><div><div>1</div></div><div><span>"FriendlyName"</span><span>: </span><span>"All Toolsets"</span><span>,</span></div></div><div><div><div>2</div></div><div><span>"Description"</span><span>: </span><span>"Aggregator plugin that depends on all Toolsets plugins."</span><span>,</span></div></div><div><div><div>3</div></div><div><span>// ...</span></div></div><div><div><div>4</div></div><div><span>"Plugins"</span><span>: [</span></div></div><div><div><div>5</div></div><div><span>  </span><span>// ...</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>"Name"</span><span>: </span><span>"EditorToolset"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>    </span><span>"Enabled"</span><span>: </span><span>true</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>5.8.0-release</code>, <code>5.8.1-release</code>, <code>5.8.2-release</code>, <code>5.8.3-release</code> 태그 모두 같다. 그래서 공식 문서대로 <code>All Toolsets</code>를 켜면 <code>Editor Toolset</code>도 의존 플러그인으로 함께 켜진다. 에디터에서 플러그인을 켜 보며 확인한 것이 아니라 소스에서 확인한 내용이다.</p><p>설정은 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-mcp-in-unreal-editor" target="_blank">공식 문서</a>를 그대로 따르면 된다.</p></section>
<section><h2>일괄 작업에는 맞고, 단일 수정에는 맞지 않는다<a href="#일괄-작업에는-맞고-단일-수정에는-맞지-않는다"><span>#</span></a></h2><p>여러 프로퍼티를 다루는 작업을 자연어로 처리할 수 있다는 점이 가장 고무적이었다. 하나하나 클릭해서 바꾸던 값을 조건으로 묶어 한 번에 맡길 수 있다.</p><p>에이전트가 한 작업을 <code>Ctrl+Z</code>로 되돌릴 수 있다는 점은 직관적이다. 에디터에서 하던 방식 그대로 되돌리면 된다. 에이전트에게 왜 그렇게 설정했는지 물어보며 에디터 사용법을 배울 수 있다는 점도 좋았다.</p><p>반대로 프로퍼티 하나를 바꾸는 일은 사람이 디테일 패널에서 직접 하는 편이 빠르다. MCP를 거치면 토큰 사용량도 비교적 많고, 직접 수정하는 것보다 시간이 더 걸리는 경우도 많다. 영상에서도 툴세트 설명을 읽어 오는 데만 머티리얼은 약 2만 6천 토큰, 나이아가라는 25만 토큰 가까이 든다고 한다. 그래서 지금은 작업을 골라서 맡겨야 한다.</p></section>
<section><h2>프롬프트는 단순하고 구체적으로<a href="#프롬프트는-단순하고-구체적으로"><span>#</span></a></h2><p>영상에서 권하는 사용법은 크게 세 가지다.</p><ul>
<li>에이전트는 한 번에 한 가지 작업만 할 수 있으니 프롬프트를 최대한 단순하게 유지한다.</li>
<li>명령이 모호하면 문제가 생기기 쉬우니 대상을 구체적으로 지정한다.</li>
<li>AI 에이전트는 가정을 하고 일을 진행하는 경우가 잦으니, 사용자와 같은 이해를 갖고 있는지 먼저 확인한다.</li>
</ul><p></p><figure><img alt="발표자가 권하는 프롬프트 습관 네 가지" loading="lazy" width="1600" height="900" src="/_astro/prompt-habits.CunLGYAK_Z1okWJ9.webp" /><figcaption>발표자가 권하는 프롬프트 습관 네 가지</figcaption></figure><p></p></section>
<section><h2>현재 한계<a href="#현재-한계"><span>#</span></a></h2><p>영상에서 밝힌 한계 중 눈에 띈 것은 두 가지다.</p><ul>
<li>로컬 머신에서만 동작한다. 인증 레이어가 없고 루프백 요청만 허용하므로 다른 머신의 에디터에 작업을 시킬 수 없다.</li>
<li>에디터 안에서만 쓸 수 있다. 출시된 게임 런타임에서 쓰는 용도가 아니다.</li>
</ul><p></p><figure><img alt="영상에서 정리한 현재 한계" loading="lazy" width="1600" height="900" src="/_astro/limitations.DZK87dxm_KDPEf.webp" /><figcaption>영상에서 정리한 현재 한계</figcaption></figure><p></p></section>
<section><h2>AI가 게임을 다 만들어 주지는 않는다<a href="#ai가-게임을-다-만들어-주지는-않는다"><span>#</span></a></h2><p>영상 후반에는 발표자가 만든 로컬 네트워크 멀티플레이어 요리 게임이 나온다. 발표자는 이 게임을 Claude Code로 만들었다고 한다. 머티리얼, 애니메이션, 블루프린트 등 게임 제작에 쓰는 기능 대부분을 제대로 활용할 수 있는 것으로 보여서 고무적이었다.</p><p></p><figure><img alt="VS Code의 Claude Code와 함께 띄운 요리 게임 프로젝트" loading="lazy" width="1600" height="900" src="/_astro/diner-game-editor.DWvF-rWi_Z2shDH7.webp" /><figcaption>VS Code의 Claude Code와 함께 띄운 요리 게임 프로젝트</figcaption></figure><p></p><p>그렇다고 “게임 만드는 법을 몰라도 AI가 다 만들어 준다”는 이야기가 맞는 것은 아니다. 영상의 답은 다음과 같다.</p><ul>
<li>AI는 많은 작업에서 90%까지는 훌륭하게 데려다준다.</li>
<li>하지만 프로젝트를 어떻게 구성하고 싶은지에 대한 기초 지식은 반드시 필요하다.</li>
<li>기능들이 어떻게 맞물려 돌아가는지 알아야 AI에게 프로젝트 구조를 제대로 지시할 수 있다.</li>
<li>머티리얼은 어떻게 만들지, 블루프린트는 어디에 둘지, 게임이 어떻게 동작해야 하는지를 시스템 지침으로 넣어 준다. 컨텍스트는 늘어나지만 원하는 결과를 얻기가 훨씬 쉬워진다.</li>
</ul><p>발표자의 빌드 파이프라인이 좋은 예다. 발표자는 에이전트가 Event Tick을 쓰면 빌드가 에러를 내도록 검사를 넣었다. 그 설정 방법은 몰라서 Claude에게 안내받았다고 한다. 무엇을 막을지는 사람이 정했고, 어떻게 막을지는 에이전트의 도움을 받은 것이다.</p><p>언리얼 MCP를 가장 잘 쓰는 방법은 게임을 통째로 만들게 하는 것이 아니라 프로젝트를 올바른 구조로 잡는 데 도움을 받는 것이다. 에이전트가 만든 결과를 판단하려면 결국 그 구조를 이해하고 있어야 한다.</p></section>
<section><h2>게임 개발과 에이전틱 코딩의 거리<a href="#게임-개발과-에이전틱-코딩의-거리"><span>#</span></a></h2><p>나는 게임 개발이 웹 개발보다 AI 에이전틱 개발을 받아들이기 어려운 분야라고 생각한다. 언리얼 MCP를 써 보며 그 이유 중 하나가 에디터 의존성이라는 점이 더 분명해졌다.</p><p>MCP 서버는 에디터 프로세스 안에서 돌고 에디터마다 포트를 하나씩 차지한다. 기본 포트는 8000번이고, 여러 프로젝트나 에이전트를 동시에 띄우려면 포트를 바꿔야 한다. 에이전트를 여러 워크트리에서 병렬로 돌리려면 워크트리마다 에디터를 띄우고 포트를 따로 맞춰야 한다는 뜻이다. 에셋도 마찬가지다. <code>.uasset</code>과 <code>.umap</code>은 바이너리라서 텍스트로 병합할 수 없고, 두 워크트리에서 같은 에셋을 고치면 한쪽 결과를 버려야 한다.</p><p>웹 개발에서는 워크트리를 나누고 각자 테스트를 돌리면 에이전트 여러 개가 독립적으로 일할 수 있다. 언리얼 개발은 에셋, 레벨, 블루프린트처럼 에디터를 거쳐야 확인할 수 있는 작업이 많다. MCP가 에이전트에게 에디터를 열어 줬지만, 그 에디터가 작업 단위마다 하나씩 필요하다는 구조는 그대로다.</p><p>다만 이것이 게임이라는 분야 자체의 한계라기보다는 에디터와 바이너리 에셋 중심의 작업 방식에서 오는 제약이라고 본다. 언리얼 MCP는 그 간극을 에디터 쪽에서 메우려는 시도다. 속도가 충분히 빨라지고 토큰 비용이 충분히 줄어들면, 앞으로의 게임 개발은 이런 에디터 내장 MCP를 활용하는 방식으로 바뀔 것이라고 본다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://www.youtube.com/watch?v=-Bol0qjz3rw" target="_blank">Unreal Engine KR: 언리얼 엔진 5.8 MCP 시작하기</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-mcp-in-unreal-editor" target="_blank">Epic Games: Unreal MCP in Unreal Editor</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-pie-client-server-value-logging/</id>
      <title type="text">Unreal Engine PIE에서 클라이언트와 서버 값을 한 줄로 비교하기</title>
      <published>2026-10-02T05:36:34.000Z</published>
      <updated>2026-10-02T05:36:34.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-pie-client-server-value-logging/"/>
      <summary type="text">PIE에서 클라이언트 코드가 같은 프로세스의 서버 월드와 서버 측 PlayerController를 찾아 위치·속도를 한 줄로 비교하는 에디터 전용 디버그 코드와, 네트워크 에뮬레이션으로 확인한 측정 결과를 정리합니다.</summary>
      <content type="html"><![CDATA[<p>멀티플레이 디버깅을 하다 보면 클라이언트가 가진 값과 서버가 가진 값을 같은 시점에 비교해야 할 때가 있다. 서버와 클라이언트에서 각각 로그를 남기면 출력이 섞이고 두 줄이 같은 시점의 값이라는 보장도 없다.</p>
<p>PIE에서 <code>Run Under One Process</code>가 켜져 있으면 서버 월드와 클라이언트 월드가 한 프로세스 안에 같이 있다. 그래서 에디터 빌드에서는 클라이언트 코드가 서버 월드에 있는 같은 플레이어의 객체를 직접 읽을 수 있고 두 값을 한 줄에 같이 찍을 수 있다. Lyra 샘플의 <code>LyraDevelopmentStatics</code>에도 PIE의 권한 월드를 찾는 같은 방식의 함수가 있다.</p>
<p>이 글은 Third Person C++ 템플릿에서 이 방식을 직접 구현하고 네트워크 에뮬레이션으로 차이를 만들어 숫자로 확인한 기록이다.</p>
<p></p><figure><img alt="지연 200ms에서 Client 1 창에 표시된 클라이언트와 서버 위치 비교" loading="lazy" width="1289" height="780" src="/_astro/pie-client-lag-200ms.0szsii3n_2vV5Y1.webp" /><figcaption>지연 200ms에서 Client 1 창에 표시된 클라이언트와 서버 위치 비교</figcaption></figure><p></p>
<section><h2>환경<a href="#환경"><span>#</span></a></h2><ul>
<li>검증 버전: Unreal Engine 5.8, Third Person C++ 템플릿</li>
<li>PIE <code>Net Mode</code>: Play As Client(같은 프로세스 안의 Dedicated Server), <code>Number of Players</code> 2</li>
<li><code>Run Under One Process</code> 켜짐, <code>Launch Separate Server</code> 꺼짐</li>
</ul></section>
<section><h2>서버 월드 찾기<a href="#서버-월드-찾기"><span>#</span></a></h2><p>PIE 월드는 <code>GEngine-&gt;GetWorldContexts()</code>에 <code>EWorldType::PIE</code>로 들어 있다. 한 PIE 세션 안에서 <code>NM_Client</code>가 아닌 월드는 Dedicated Server, Listen Server, Standalone 중 하나뿐이므로 그 월드를 찾으면 된다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UWorld</span><span>*</span><span><span> </span><span>PIEDebug</span><span>::</span></span><span>FindAuthorityWorld</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>#if</span><span> </span><span>WITH_EDITOR</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span> (</span><span>GEngine</span><span> </span><span>==</span><span> </span><span>nullptr</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>// A PIE session owns at most one world that is not a client: the dedicated or listen server, or a standalone world</span></div></div><div><div><div>10</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> </span><span>FWorldContext</span><span>&amp;</span><span> </span><span>Context</span><span> : </span><span>GEngine</span><span>-&gt;</span><span>GetWorldContexts</span><span>())</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>12</div></div><div><span>    </span><span>UWorld</span><span>*</span><span> </span><span>World</span><span> </span><span>=</span><span> </span><span>Context</span><span>.</span><span>World</span><span>();</span></div></div><div><div><div>13</div></div><div><span>    </span><span>if</span><span> (</span><span>Context</span><span>.</span><span>WorldType</span><span> </span><span>==</span><span> EWorldType::</span><span>PIE</span><span> </span><span>&amp;&amp;</span><span> </span><span>World</span><span> </span><span>!=</span><span> </span><span>nullptr</span><span> </span><span>&amp;&amp;</span><span> </span><span>World</span><span>-&gt;</span><span>GetNetMode</span><span>() </span><span>!=</span><span> </span><span>NM_Client</span><span>)</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>      </span><span>return</span><span> </span><span>World</span><span>;</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>18</div></div><div><span>#endif</span></div></div><div><div><div>19</div></div><div><span>  </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>20</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>FWorldContext::RunAsDedicated</code>로 Dedicated Server를 먼저 고르는 방법도 있지만 여기서는 “클라이언트가 아닌 PIE 월드”라는 조건 하나로 줄였다. 서버 월드가 다른 프로세스에 있으면 찾을 월드가 없으므로 <code>nullptr</code>이 반환된다.</p></section>
<section><h2>같은 플레이어의 서버 측 PlayerController 찾기<a href="#같은-플레이어의-서버-측-playercontroller-찾기"><span>#</span></a></h2><p>서버 월드를 찾았으면 그 안에서 클라이언트 PlayerController와 같은 플레이어를 찾아야 한다. 객체 이름으로는 비교할 수 없다. 아래는 PIE 월드와 PlayerController를 덤프하는 콘솔 명령(<code>tp.Debug.DumpPIEPlayers</code>)의 출력이다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>LogPIEDebug: Authority world: Dedicated Server</span></div></div><div><div><div>2</div></div><div><span>LogPIEDebug: [Dedicated Server] PIEInstance=0 RunAsDedicated=1</span></div></div><div><div><div>3</div></div><div><span>LogPIEDebug:     BP_ThirdPersonPlayerController_C_0 (PlayerId=256, UniqueId=NULL:DESKTOP-MH1K636-C7AC..., Pawn=BP_ThirdPersonCharacter_C_0)</span></div></div><div><div><div>4</div></div><div><span>LogPIEDebug:     BP_ThirdPersonPlayerController_C_1 (PlayerId=257, UniqueId=NULL:DESKTOP-MH1K636-CFD2..., Pawn=BP_ThirdPersonCharacter_C_1)</span></div></div><div><div><div>5</div></div><div><span>LogPIEDebug: [Client 1] PIEInstance=1 RunAsDedicated=0</span></div></div><div><div><div>6</div></div><div><span>LogPIEDebug:     BP_ThirdPersonPlayerController_C_0 (PlayerId=256, ...)</span></div></div><div><div><div>7</div></div><div><span>LogPIEDebug:         match by PlayerId -&gt; BP_ThirdPersonPlayerController_C_0 (PlayerId=256, ...)</span></div></div><div><div><div>8</div></div><div><span>LogPIEDebug:         match by UniqueId -&gt; BP_ThirdPersonPlayerController_C_0 (PlayerId=256, ...)</span></div></div><div><div><div>9</div></div><div><span>LogPIEDebug: [Client 2] PIEInstance=2 RunAsDedicated=0</span></div></div><div><div><div>10</div></div><div><span>LogPIEDebug:     BP_ThirdPersonPlayerController_C_0 (PlayerId=257, ...)</span></div></div><div><div><div>11</div></div><div><span>LogPIEDebug:         match by PlayerId -&gt; BP_ThirdPersonPlayerController_C_1 (PlayerId=257, ...)</span></div></div><div><div><div>12</div></div><div><span>LogPIEDebug:         match by UniqueId -&gt; BP_ThirdPersonPlayerController_C_1 (PlayerId=257, ...)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Client 2의 PlayerController 이름은 <code>_C_0</code>이지만 서버에서 대응하는 객체는 <code>_C_1</code>이다. 각 클라이언트 월드에는 자기 PlayerController 하나만 있기 때문이다.</p><p>대신 <code>PlayerState</code>의 값으로 비교한다. <code>PlayerId</code>는 서버가 할당해 클라이언트로 복제하므로 같은 플레이어의 두 사본은 값이 같다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>APlayerController</span><span>*</span><span><span> </span><span>PIEDebug</span><span>::</span></span><span>FindAuthorityPlayerController</span><span>(</span><span>const</span><span> </span><span>APlayerController</span><span>*</span><span><span> </span><span>ClientController</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>#if</span><span> </span><span>WITH_EDITOR</span></div></div><div><div><div>4</div></div><div><span>  </span><span>UWorld</span><span>*</span><span> </span><span>ServerWorld</span><span> </span><span>=</span><span> </span><span>FindAuthorityWorld</span><span>();</span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>APlayerState</span><span>*</span><span> </span><span>ClientPlayerState</span><span> </span><span>=</span><span> </span><span>ClientController</span><span> </span><span>?</span><span> </span><span>ClientController</span><span>-&gt;</span><span>PlayerState</span><span>.</span><span>Get</span><span>() </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>6</div></div><div><span>  </span><span>if</span><span> (</span><span>ServerWorld</span><span> </span><span>==</span><span> </span><span>nullptr</span><span> </span><span>||</span><span> </span><span>ClientPlayerState</span><span> </span><span>==</span><span> </span><span>nullptr</span><span>)</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>  </span><span>for</span><span> (</span><span>FConstPlayerControllerIterator</span><span> </span><span>It</span><span> </span><span>=</span><span> </span><span>ServerWorld</span><span>-&gt;</span><span>GetPlayerControllerIterator</span><span>(); </span><span>It</span><span>; </span><span>++</span><span>It</span><span>)</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>13</div></div><div><span>    </span><span>APlayerController</span><span>*</span><span> </span><span>ServerController</span><span> </span><span>=</span><span> </span><span>It</span><span>-&gt;</span><span>Get</span><span>();</span></div></div><div><div><div>14</div></div><div><span>    </span><span>const</span><span> </span><span>APlayerState</span><span>*</span><span> </span><span>ServerPlayerState</span><span> </span><span>=</span><span> </span><span>ServerController</span><span> </span><span>?</span><span> </span><span>ServerController</span><span>-&gt;</span><span>PlayerState</span><span>.</span><span>Get</span><span>() </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>// PlayerId is assigned by the server and replicated to clients, so both copies of the same player share it</span></div></div><div><div><div>17</div></div><div><span>    </span><span>if</span><span> (</span><span>ServerPlayerState</span><span> </span><span>&amp;&amp;</span><span> </span><span>ServerPlayerState</span><span>-&gt;</span><span>GetPlayerId</span><span>() </span><span>==</span><span> </span><span>ClientPlayerState</span><span>-&gt;</span><span>GetPlayerId</span><span>())</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>19</div></div><div><span>      </span><span>return</span><span> </span><span>ServerController</span><span>;</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>22</div></div><div><span>#endif</span></div></div><div><div><div>23</div></div><div><span>  </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>24</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>PlayerState-&gt;GetUniqueId()</code>로 비교할 수도 있다. 위 덤프처럼 이 환경에서는 Null OSS가 <code>NULL:</code> ID를 채워 주기 때문에 두 방식의 결과가 같았다.</p><p>다만 <code>FUniqueNetIdWrapper</code>의 <code>operator==</code>는 두 ID가 모두 invalid이면 <code>true</code>를 반환한다(<code>CoreOnline.h</code>). 서버와 클라이언트 양쪽 UniqueId가 비어 있다면 모든 클라이언트가 서버 월드의 첫 번째 PlayerController로 매칭된다. <code>PlayerId</code> 비교에는 이 문제가 없어서 이쪽을 기본으로 두었다.</p><p>클라이언트에 <code>PlayerState</code>가 아직 복제되지 않은 시점도 있어서 null 검사를 앞에 둔다. Pawn은 서버 PlayerController에서 꺼낸다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>template</span><span> &lt;</span><span>typename</span><span> </span><span>T</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>T</span><span>*</span><span><span> </span><span>PIEDebug</span><span>::</span></span><span>FindAuthorityPawn</span><span>(</span><span>const</span><span> </span><span>APawn</span><span>*</span><span><span> </span><span>ClientPawn</span><span>)</span></span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>APlayerController</span><span>*</span><span> </span><span>ClientController</span><span> </span><span>=</span><span> </span><span>ClientPawn</span><span> </span><span>?</span><span> </span><span>Cast</span><span>&lt;</span><span>APlayerController</span><span>&gt;(</span><span>ClientPawn</span><span>-&gt;</span><span>GetController</span><span>()) </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>APlayerController</span><span>*</span><span> </span><span>ServerController</span><span> </span><span>=</span><span> </span><span>FindAuthorityPlayerController</span><span>(</span><span>ClientController</span><span>);</span></div></div><div><div><div>6</div></div><div><span>  </span><span>return</span><span> </span><span>ServerController</span><span> </span><span>?</span><span> </span><span>Cast</span><span>&lt;</span><span>T</span><span>&gt;(</span><span>ServerController</span><span>-&gt;</span><span>GetPawn</span><span>()) </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>어느 월드의 로그인지 붙이기<a href="#어느-월드의-로그인지-붙이기"><span>#</span></a></h2><p>PIE 로그는 서버와 클라이언트의 출력이 한 Output Log에 섞인다. <code>GetDebugStringForWorld()</code>는 월드의 NetMode와 PIE 인스턴스 번호로 <code>Dedicated Server</code>, <code>Listen Server</code>, <code>Client 1</code> 같은 문자열을 만들어 준다. 선언은 <code>UnrealEngine.h</code>에 있고 <code>WITH_EDITOR</code>와 상관없이 정의되어 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#define</span><span> </span><span>PIE_LOG</span><span><span>(</span><span>WorldContextObject</span><span>, </span><span>Verbosity</span><span>, </span><span>Format</span><span>, ...) </span></span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>UE_LOG</span><span>(</span><span>LogPIEDebug</span><span>, </span><span>Verbosity</span><span>, </span><span>TEXT</span><span>(</span><span>"[</span><span>%s</span><span>] "</span><span>) </span><span>Format</span><span>, </span><span>*</span><span>GetDebugStringForWorld</span><span>((</span><span>WorldContextObject</span><span>)-&gt;</span><span>GetWorld</span><span>()), </span><span>##__VA_ARGS__</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>클라이언트와 서버 값 비교<a href="#클라이언트와-서버-값-비교"><span>#</span></a></h2><p>로컬 클라이언트 캐릭터의 위치·속도와 서버 측 사본의 값을 한 줄로 만든다. 화면 메시지는 매 프레임 갱신하고 로그는 0.5초 간격으로 남긴다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>PIEDebug</span><span>::</span></span><span>CompareWithAuthority</span><span>(</span><span>const</span><span> </span><span>ACharacter</span><span>*</span><span><span> </span><span>ClientCharacter</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>#if</span><span> </span><span>WITH_EDITOR</span></div></div><div><div><div>4</div></div><div><span>  </span><span>using</span><span> </span><span>namespace</span><span><span> PIEDebug::</span><span>Private</span><span>;</span></span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span>CVarCompareWithAuthority</span><span>.</span><span>GetValueOnGameThread</span><span>() </span><span>||</span><span> </span><span>ClientCharacter</span><span> </span><span>==</span><span> </span><span>nullptr</span></div></div><div><div><div>7</div></div><div><span>    </span><span>||</span><span> </span><span>!</span><span>ClientCharacter</span><span>-&gt;</span><span>IsLocallyControlled</span><span>() </span><span>||</span><span> </span><span>ClientCharacter</span><span>-&gt;</span><span>GetNetMode</span><span>() </span><span>!=</span><span> </span><span>NM_Client</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>  </span><span>const</span><span> </span><span>ACharacter</span><span>*</span><span> </span><span>ServerCharacter</span><span> </span><span>=</span><span> </span><span>FindAuthorityPawn</span><span>&lt;</span><span>ACharacter</span><span>&gt;(</span><span>ClientCharacter</span><span>);</span></div></div><div><div><div>13</div></div><div><span>  </span><span>if</span><span> (</span><span>ServerCharacter</span><span> </span><span>==</span><span> </span><span>nullptr</span><span>)</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>  </span><span>const</span><span> </span><span>UWorld</span><span>*</span><span> </span><span>ClientWorld</span><span> </span><span>=</span><span> </span><span>ClientCharacter</span><span>-&gt;</span><span>GetWorld</span><span>();</span></div></div><div><div><div>19</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>PIEInstance</span><span> </span><span>=</span><span> </span><span>GetPIEInstance</span><span>(</span><span>ClientWorld</span><span>);</span></div></div><div><div><div>20</div></div><div><span>  </span><span>const</span><span> </span><span>FVector</span><span> </span><span>ClientLocation</span><span> </span><span>=</span><span> </span><span>ClientCharacter</span><span>-&gt;</span><span>GetActorLocation</span><span>();</span></div></div><div><div><div>21</div></div><div><span>  </span><span>const</span><span> </span><span>FVector</span><span> </span><span>ServerLocation</span><span> </span><span>=</span><span> </span><span>ServerCharacter</span><span>-&gt;</span><span>GetActorLocation</span><span>();</span></div></div><div><div><div>22</div></div><div><span>  </span><span>const</span><span> </span><span>FString</span><span> </span><span>Comparison</span><span> </span><span>=</span><span> FString::</span><span>Printf</span><span>(</span><span>TEXT</span><span>(</span><span>"Delta=</span><span>%5.1f</span><span> cm | Client Loc=(</span><span>%7.1f</span><span>, </span><span>%7.1f</span><span>) Speed=</span><span>%5.1f</span><span> | Server Loc=(</span><span>%7.1f</span><span>, </span><span>%7.1f</span><span>) Speed=</span><span>%5.1f</span><span>"</span><span>),</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>FVector::</span><span>Dist</span><span>(</span><span>ClientLocation</span><span>, </span><span>ServerLocation</span><span>),</span></div></div><div><div><div>24</div></div><div><span>    </span><span>ClientLocation</span><span>.</span><span>X</span><span>, </span><span>ClientLocation</span><span>.</span><span>Y</span><span>, </span><span>ClientCharacter</span><span>-&gt;</span><span>GetVelocity</span><span>().</span><span>Size2D</span><span>(),</span></div></div><div><div><div>25</div></div><div><span>    </span><span>ServerLocation</span><span>.</span><span>X</span><span>, </span><span>ServerLocation</span><span>.</span><span>Y</span><span>, </span><span>ServerCharacter</span><span>-&gt;</span><span>GetVelocity</span><span>().</span><span>Size2D</span><span>());</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>// One keyed line per client, refreshed every frame. On-screen messages are shared by every PIE viewport</span></div></div><div><div><div>28</div></div><div><span>  </span><span>GEngine</span><span>-&gt;</span><span>AddOnScreenDebugMessage</span><span>(</span><span><span>static_cast</span><span>&lt;</span></span><span>uint64</span><span>&gt;</span><span>(</span><span>7000</span><span> </span><span>+</span><span> </span><span>PIEInstance</span><span>), </span><span><span>1.</span><span>f</span></span><span>, FColor::</span><span>Yellow</span><span>,</span></div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>FString::</span><span>Printf</span><span>(</span><span>TEXT</span><span>(</span><span>"[</span><span>%s</span><span>] </span><span>%s</span><span>"</span><span>), </span><span>*</span><span>GetDebugStringForWorld</span><span>(</span><span>ClientWorld</span><span>), </span><span>*</span><span>Comparison</span><span>));</span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span><span>  </span></span><span>// Log at most once per interval for each client</span></div></div><div><div><div>32</div></div><div><span>  </span><span>static</span><span> </span><span>TMap</span><span>&lt;</span><span>int32</span><span>, </span><span>double</span><span>&gt; </span><span>LastLogTimes</span><span>;</span></div></div><div><div><div>33</div></div><div><span>  </span><span>double&amp;</span><span> </span><span>LastLogTime</span><span> </span><span>=</span><span> </span><span>LastLogTimes</span><span>.</span><span>FindOrAdd</span><span>(</span><span>PIEInstance</span><span>, </span><span>0.0</span><span>);</span></div></div><div><div><div>34</div></div><div><span>  </span><span>const</span><span> </span><span>double</span><span> </span><span>Now</span><span> </span><span>=</span><span> </span><span>ClientWorld</span><span>-&gt;</span><span>GetRealTimeSeconds</span><span>();</span></div></div><div><div><div>35</div></div><div><span>  </span><span>if</span><span> (</span><span>Now</span><span> </span><span>&lt;</span><span> </span><span>LastLogTime</span><span> </span><span>||</span><span> </span><span>Now</span><span> </span><span>-</span><span> </span><span>LastLogTime</span><span> </span><span>&gt;=</span><span> </span><span>CVarCompareLogInterval</span><span>.</span><span>GetValueOnGameThread</span><span>())</span></div></div><div><div><div>36</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>37</div></div><div><span>    </span><span>LastLogTime</span><span> </span><span>=</span><span> </span><span>Now</span><span>;</span></div></div><div><div><div>38</div></div><div><span>    </span><span>PIE_LOG</span><span>(</span><span>ClientCharacter</span><span>, </span><span>Log</span><span>, </span><span>TEXT</span><span>(</span><span>"</span><span>%s</span><span>"</span><span>), </span><span>*</span><span>Comparison</span><span>);</span></div></div><div><div><div>39</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>40</div></div><div><span>#endif</span></div></div><div><div><div>41</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>AddOnScreenDebugMessage</code>의 메시지는 모든 PIE 뷰포트가 공유한다. 그래서 키를 PIE 인스턴스마다 다르게 주면 한 창에서 Client 1과 Client 2의 줄을 함께 볼 수 있다.</p><p>호출은 캐릭터 <code>Tick</code>에서 한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AThirdPersonCharacter</span><span>::</span></span><span>Tick</span><span>(</span><span>float</span><span><span> </span><span>DeltaSeconds</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>Tick</span><span>(</span><span>DeltaSeconds</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>// editor-only: compare the predicted client movement against the server-side copy of this character</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>PIEDebug::</span><span>ApplyAutoMove</span><span>(</span><span>this</span><span>);</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>PIEDebug::</span><span>CompareWithAuthority</span><span>(</span><span>this</span><span>);</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>ApplyAutoMove</code>는 측정용 헬퍼다. <code>tp.Debug.AutoMove 60</code>을 주면 로컬 캐릭터가 초당 60도씩 방향을 바꾸며 최고 속도(500cm/s)로 원을 그린다. 손으로 조작하지 않아도 같은 조건에서 반복해 측정할 수 있다.</p></section>
<section><h2>차이를 만들어 확인하기<a href="#차이를-만들어-확인하기"><span>#</span></a></h2><p>PIE의 <code>Network Emulation</code> 설정으로 지연을 주고 등속 구간(양쪽 <code>Speed=500.0</code>)의 <code>Delta</code>만 모았다. 조건마다 약 10초, 샘플은 26~29개다.</p>

<table><thead><tr><th>조건</th><th>평균 Delta</th><th>범위</th></tr></thead><tbody><tr><td>에뮬레이션 없음</td><td>9.5 cm</td><td>5.8 ~ 15.1 cm</td></tr><tr><td>Clients Only, Outgoing 100ms</td><td>63.2 cm</td><td>56.9 ~ 73.2 cm</td></tr><tr><td>Clients Only, Outgoing 200ms</td><td>116.2 cm</td><td>106.5 ~ 144.9 cm</td></tr><tr><td>Server Only, Outgoing 100ms</td><td>11.1 cm</td><td>5.6 ~ 34.4 cm</td></tr></tbody></table><p>200ms와 Server Only 조건의 최댓값은 이동을 시작한 직후의 샘플이다.</p><p></p><figure><img alt="에뮬레이션 없음" loading="lazy" width="1289" height="780" src="/_astro/pie-baseline.Bg4s4SLu_Z1wj1We.webp" /><figcaption>에뮬레이션 없음</figcaption></figure><p></p><p></p><figure><img alt="클라이언트 송신 지연 100ms" loading="lazy" width="1289" height="780" src="/_astro/pie-client-lag-100ms.CUPzVVAK_Z1bmQCo.webp" /><figcaption>클라이언트 송신 지연 100ms</figcaption></figure><p></p><p></p><figure><img alt="서버 송신 지연 100ms" loading="lazy" width="1289" height="780" src="/_astro/pie-server-lag-100ms.S7GtKsoM_2ifcNp.webp" /><figcaption>서버 송신 지연 100ms</figcaption></figure><p></p><p>클라이언트 송신 지연을 100ms 늘릴 때마다 평균 차이가 약 50cm씩 늘었다. 500cm/s × 0.1s = 50cm이므로 이동 속도와 지연을 곱한 값과 맞는다.</p><p>CharacterMovement는 클라이언트가 먼저 이동하고 서버는 클라이언트가 보낸 이동을 받은 뒤에 같은 이동을 재현한다. 그래서 서버 측 위치는 클라이언트→서버 지연만큼 뒤처진다. 같은 100ms를 서버 송신 쪽에 주었을 때는 기준선과 거의 같았다. 서버→클라이언트 지연은 서버 측 사본이 갱신되는 시점과 관계가 없기 때문으로 보인다.</p><p>에뮬레이션이 없을 때 남는 10cm 안팎은 측정 당시 약 80fps에서 500cm/s로 1~2프레임 이동한 거리로 보인다.</p><p>에디터가 백그라운드에 있으면 <code>Editor Preferences &gt; General &gt; Performance &gt; Use Less CPU when in Background</code> 때문에 PIE가 초당 몇 프레임으로 떨어진다. 이 상태에서는 프레임 시간이 차이에 그대로 섞인다. 처음 측정할 때 에뮬레이션 없이도 62cm가 나와서 이 설정을 끄고 다시 측정했다.</p></section>
<section><h2>제약<a href="#제약"><span>#</span></a></h2><ul>
<li>모든 코드는 <code>#if WITH_EDITOR</code> 안에 있다. 에디터가 아닌 게임 타깃(<code>ThirdPerson Win64 Development</code>)도 빌드해서 컴파일되는 것을 확인했고 이 빌드에서는 함수가 <code>nullptr</code>을 반환하거나 아무것도 하지 않는다.</li>
<li>서버와 클라이언트 월드가 같은 프로세스에 있을 때만 동작한다. <code>Run Under One Process</code>를 끄거나 <code>Launch Separate Server</code>를 켜면 이 전제가 깨진다.</li>
<li>클라이언트 코드가 리플리케이션을 거치지 않고 서버 객체를 읽는 방식이다. 값을 읽어 비교하는 용도로만 쓰고 서버 객체의 상태를 바꾸는 데 쓰지 않는다.</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/codex-to-claude-code-desktop/</id>
      <title type="text">Codex와 Claude Code의 데스크톱 앱을 비교하며 느낀 장단점</title>
      <published>2026-10-02T00:00:00.000Z</published>
      <updated>2026-10-02T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/codex-to-claude-code-desktop/"/>
      <summary type="text">ChatGPT Pro 200달러 가입 중단과 GPT-6 Astra의 사용량 압박으로 Claude Max로 옮긴 다음 날 200달러 플랜이 다시 열렸다. Codex 데스크톱 앱과 비교해 Claude Code 데스크톱 앱에서 느낀 장단점을 정리한다.</summary>
      <content type="html"><![CDATA[<p>9월 29일부터 Claude Code 데스크톱 앱을 다시 쓰고 있다. 원래는 Codex를 더 선호했지만, 개인 계정으로 쓰기 시작한 뒤 사용량이 부족해서 Claude Max로 옮겼다.</p>
<p>Claude Code CLI와 데스크톱 앱은 예전에도 써봤다. 다만 한동안 Codex에 익숙해져 있었기 때문에 다시 쓰니 편한 점과 아쉬운 점이 눈에 들어왔다. 이번 글에서는 Codex 데스크톱 앱과 비교하며 느낀 장단점을 적어 둔다.</p>
<p>8월 31일에 회사를 퇴사하고 9월 중순쯤 개인 계정으로 Codex를 쓰려고 가입하려 했는데 200달러 플랜을 고를 수 없었다. OpenAI가 9월 10일(미국 시간)부터 <a href="https://www.cio.com/article/4221092/openai-pauses-200-pro-tier-as-astra-demand-strains-capacity.html" target="_blank">GPT-6 Astra 수요로 용량이 부족하다며 200달러 Pro 플랜의 신규 가입과 업그레이드를 멈춘</a> 상태였다. 새로 가입하는 사람이 고를 수 있는 가장 높은 플랜은 100달러 플랜이었다.</p>
<p>울며 겨자 먹기로 100달러 플랜을 썼지만 역시나 사용량이 부족했다. 게다가 Astra는 토큰을 너무 빨리 썼다. 나만 그런 건 아닌지 Codex 저장소에도 <a href="https://github.com/openai/codex/issues/48765" target="_blank">Astra의 토큰 사용량이 비정상적으로 많다는 이슈</a>가 올라와 있었다. 사용량 압박이 너무 심해서 9월 29일에 Claude Max 20x(200달러)를 결제했다.</p>
<p>그런데 다음 날 새벽, OpenAI DevDay에 맞춰 200달러 플랜 가입이 다시 열렸다. 호구가 되어버렸다..</p>
<p><img alt="" loading="lazy" width="1197" height="1005" src="/_astro/pro-plan-reopening-meme.7o3n2Pqo_Z1G35wn.webp" /></p>
<p>다만 다시 열린 200달러 플랜은 예전 그대로가 아니었다. <a href="https://www.unite.ai/openai-reopens-chatgpt-pro-200-sign-ups-with-new-usage-calculation/" target="_blank">새로 가입하면 API 사용 금액 기준으로 기존 200달러 플랜의 절반 정도를 쓸 수 있고</a>, 대신 5시간 사용 제한은 다시 도입하지 않는다고 한다.</p>
<p><img alt="" loading="lazy" width="1672" height="941" src="/_astro/plan-timeline.DDYx3ho6_ZqdCPb.webp" /></p>
<p>어쨌든 한 달은 Claude Code를 써야 한다. 한동안 Codex에 익숙해져 있었기 때문에 다시 적응하는 중이다. Codex 데스크톱 앱은 지금은 ChatGPT 데스크톱 앱의 Codex 모드로 통합되어 있지만 이 글에서는 편의상 Codex 데스크톱 앱이라고 부른다.</p>
<section><h2>좋았던 점<a href="#좋았던-점"><span>#</span></a></h2><section><h3>모델 성능과 토큰 소모량<a href="#모델-성능과-토큰-소모량"><span>#</span></a></h3><p>Max 20x 플랜에서 Sonnet 5.5와 Opus 5.5를 쓰고 있는데 성능과 토큰 소모량 모두 만족스럽다. 직전에 쓰던 Astra는 토큰 소모가 너무 빨랐기 때문에 차이가 더 크게 느껴진다.</p><p>Anthropic도 <a href="https://www.anthropic.com/claude-opus-5-5" target="_blank">Opus 5.5가 일반적인 작업에서 Opus 5보다 비용이 40% 적게 든다</a>고 발표했다. 다만 Opus 5.5는 9월 22일, Sonnet 5.5는 9월 28일에 나온 모델이라 아직 써본 기간은 짧다.</p></section><section><h3>앱이 더 빠르게 느껴진다<a href="#앱이-더-빠르게-느껴진다"><span>#</span></a></h3><p>Claude Code 데스크톱 앱을 쓰면 Codex 데스크톱 앱을 쓸 때보다 모든 것이 좀 더 빠르게 반응한다. 실제 작업도 더 빨리 끝나는 느낌이다. 모델 성능과는 별개로 앱 자체에서 받는 인상이다.</p></section></section>
<section><h2>아쉬운 점<a href="#아쉬운-점"><span>#</span></a></h2><section><h3>단축키를 바꿀 수 없다<a href="#단축키를-바꿀-수-없다"><span>#</span></a></h3><p>Codex 데스크톱 앱은 설정에서 단축키를 바꿀 수 있다. Claude Code CLI도 <code>~/.claude/keybindings.json</code>으로 대부분의 단축키를 바꿀 수 있다. 그런데 Claude Code 데스크톱 앱은 정해진 단축키만 쓸 수 있다. 데스크톱 앱에서 단축키 변경을 지원해 달라는 <a href="https://github.com/anthropics/claude-code/issues/33034" target="_blank">이슈</a>는 4월에 not planned로 닫혔다.</p><p>이 간단한 걸 왜..?</p></section><section><h3>추론 수준을 바로 올리고 내릴 수 없다<a href="#추론-수준을-바로-올리고-내릴-수-없다"><span>#</span></a></h3><p>Codex에서는 단축키로 추론 수준을 한 단계씩 바로 올리고 내릴 수 있다. Claude Code 데스크톱 앱에서는 <code>Ctrl+Shift+E</code>로 추론 수준 메뉴를 연 다음 골라야 한다.</p><p>CLI에서는 단축키로 됐던 것으로 기억하는데, 현재 문서 기준으로는 CLI도 <code>Alt+P</code>로 모델 선택 창을 연 뒤 좌우 화살표로 조절하는 방식이다. 어느 쪽이든 창을 한 번 거쳐야 해서 Codex처럼 바로 바꾸는 것보다 번거롭다.</p><p><img alt="" loading="lazy" width="1672" height="941" src="/_astro/effort-switching.CSZ6tv5E_5NmmE.webp" /></p></section><section><h3>이미지 생성 모델이 없다<a href="#이미지-생성-모델이-없다"><span>#</span></a></h3><p>Codex에서는 <a href="https://learn.chatgpt.com/docs/image-generation" target="_blank"><code>gpt-image-2</code>를 이용한 이미지 생성</a>을 앱 안에서 바로 쓸 수 있다. Claude는 이미지를 이해할 수는 있지만 생성하지는 못한다. SVG나 HTML로 다이어그램을 그리는 정도는 가능하다.</p><p>그래서 블로그 글 작성 같은 작업은 Codex가 더 유리하다고 생각한다. 생성형 이미지를 활용할 수 있으니까 말이다.</p></section><section><h3>사이드 채팅이 하나뿐이다<a href="#사이드-채팅이-하나뿐이다"><span>#</span></a></h3><p><a href="/posts/eli5-skill-and-my-codex-workflow/">ELI5 스킬 글</a>에서 적었듯이 Codex에서는 작업 스레드 옆에 사이드 채팅을 열어 PR이나 리뷰 내용을 이해하는 데 자주 썼다. Codex의 사이드 채팅은 현재 대화의 맥락을 이어받으면서 모델과 추론 수준을 따로 고를 수 있다. 여러 개를 열 수도 있어서 일종의 포크처럼 쓸 수 있다.</p><p>Claude Code 데스크톱 앱에도 <code>Ctrl+;</code>나 <code>/btw</code>로 여는 사이드 채팅이 있다. 하지만 하나만 열 수 있고 사이드 채팅 안에서 모델이나 추론 수준을 바꿀 수 없다. 포크처럼 쓰던 Codex 쪽이 더 편했다.</p><p><img alt="" loading="lazy" width="1672" height="941" src="/_astro/side-chat.C-oQwkrB_1nQN7d.webp" /></p></section><section><h3>여러 폴더를 묶는 과정이 어색하다<a href="#여러-폴더를-묶는-과정이-어색하다"><span>#</span></a></h3><p>Codex는 <a href="https://learn.chatgpt.com/docs/projects" target="_blank">프로젝트</a>를 먼저 만들고 거기에 여러 폴더를 붙이는 흐름으로 UX가 잡혀 있다. 기본 폴더를 정해 두면 새 채팅이 그 폴더에서 시작하고 나머지 폴더도 함께 읽고 수정할 수 있다.</p><p>Claude Code에서도 여러 폴더를 다루는 것이 불가능하지는 않지만 이 과정이 살짝 어색하다. Claude Code에도 Projects라는 기능이 있지만 이름만 같고 성격이 다르다. 관련된 작업을 하나의 대화로 맡기면 Claude가 클라우드 세션 스레드로 나눠 병렬로 실행하는 기능이고 아직 퍼블릭 베타다. 로컬 폴더 여러 개를 묶어 두고 작업하는 Codex의 프로젝트를 대신하지는 않는다.</p></section><section><h3>외부 도구를 들여오는 속도가 느리게 느껴진다<a href="#외부-도구를-들여오는-속도가-느리게-느껴진다"><span>#</span></a></h3><p>OpenAI는 9월 29일 DevDay에서 <a href="https://techcrunch.com/2026/09/29/openai-launches-dots-its-bubbly-agentic-avatar/" target="_blank">Dots</a>와 <a href="https://techcrunch.com/2026/09/29/openai-takes-on-microsoft-with-the-launch-of-what-feels-a-whole-lot-like-chatgpts-own-office-suite/" target="_blank">ChatGPT Space</a>를 발표했다. Dots는 백그라운드에서 계속 일하는 에이전트로 Codex에서도 실행할 수 있다. Space는 페이지와 파일을 함께 두고 협업하는 작업 공간이다. 둘 다 Codex만의 기능은 아니고 ChatGPT 전반에 들어오는 기능이다.</p><p>아직 발표만 봤지만 Codex 쪽은 외부 도구와 업무 흐름을 자기 안으로 빠르게 끌어들이기 시작한 것 같다. 그에 비해 Claude Code는 한 발짝씩 늦어지는 느낌이다.</p><p>일단 한 달은 Claude Code를 쓰면서 다시 적응해 보려 한다.</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/notd-multiplayer-redesign/</id>
      <title type="text">Night of the Dead 멀티플레이를 지금 다시 설계한다면</title>
      <published>2026-10-02T00:00:00.000Z</published>
      <updated>2026-10-02T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/notd-multiplayer-redesign/"/>
      <summary type="text">Night of the Dead의 멀티플레이 최적화를 새 프로젝트에서 처음부터 설계한다면 바꿀 부분을 좀비의 Mass 전환, Push Model과 Fast Array와 연결별 서브오브젝트 필터를 쓰는 복제, 애플리케이션 계층의 전송 흐름 제어로 나눠 기존 코드와 비교한다.</summary>
      <content type="html"><![CDATA[<p>앞의 글들은 <a href="/projects/night-of-the-dead/">Night of the Dead</a>에서 실제로 적용한 최적화를 정리했다. 대부분은 엔진의 기본 구조를 그대로 두고 그 위에 덧붙인 것이다. 좀비는 <code>ACharacter</code>로 두고 거리 순위로 비용을 깎았고, 복제는 Replication Graph와 휴면으로 대상을 줄였고, 대용량 전송은 액터 채널의 Reliable 버퍼를 보면서 나눠 보냈다.</p>
<p>같은 게임을 지금 새 프로젝트로 시작한다면 덧붙이는 대신 처음부터 구조로 가져갈 부분이 있다. 말 그대로 제로부터다.</p>
<p><img alt="" loading="lazy" width="1422" height="800" src="/_astro/from-zero-meme.DLbsh0NA_OCka1.webp" /></p>
<p>다만 전부를 제로부터 다시 만들자는 이야기는 아니다. 바꿀 부분은 다음과 같고, 그대로 가져갈 것은 글 끝에 따로 적었다.</p>

<table><thead><tr><th>대상</th><th>프로젝트에서 한 것</th><th>다시 설계한다면</th></tr></thead><tbody><tr><td>좀비</td><td><code>ACharacter</code>에 거리 순위 LOD를 덧붙임</td><td>Mass 엔티티로 두고 가까운 것만 액터</td></tr><tr><td>복제 시스템</td><td>기존 복제 시스템과 Replication Graph</td><td>Iris</td></tr><tr><td>프로퍼티 복제</td><td>값을 바꾼 곳마다 <code>ForceNetUpdate()</code></td><td>Push Model</td></tr><tr><td>인벤토리</td><td>매 틱 복제용 배열 전체를 다시 채움</td><td>Fast Array</td></tr><tr><td>보관함 내용물</td><td>공유 배열을 거리로 채우고 비움</td><td>연결별 서브오브젝트 필터</td></tr><tr><td>대용량 전송</td><td>액터 채널의 <code>NumOutRec</code>으로 흐름 제어</td><td>수신 확인과 바이트 예산</td></tr></tbody></table>
<section><h2>좀비: 액터에서 Mass 엔티티로<a href="#좀비-액터에서-mass-엔티티로"><span>#</span></a></h2><section><h3>손으로 만든 것<a href="#손으로-만든-것"><span>#</span></a></h3><p>좀비 한 마리는 <code>ACharacter</code>를 상속한 액터이고 <code>UCharacterMovementComponent</code>, Behavior Tree와 위치 동기화 컴포넌트가 붙는다. 웨이브의 수백 마리를 감당하기 위해 그 위에 다음을 만들었다.</p><ul>
<li>가장 가까운 플레이어와의 거리로 정하는 LOD와 전체 좀비의 거리 순위</li>
<li>거리 계산을 프레임당 정해진 수만 처리하는 매니저</li>
<li>스포너의 좀비를 정수, 구조체, 풀링 액터로 오가게 하는 단계</li>
<li>스켈레탈 메시를 버텍스 애니메이션 스태틱 메시로 바꿔 그리는 컴포넌트</li>
</ul><p>마지막 항목은 <a href="/posts/notd-zombie-horde-cost/">좀비 글</a>에서 다루지 않았다. 좀비에는 스켈레탈 메시와 별도로 버텍스 애니메이션용 스태틱 메시 컴포넌트가 있고, 조건이 맞으면 스켈레탈 메시를 숨기고 이쪽을 보인다. 공격 중이거나 죽었거나 피격 애니메이션을 재생 중이거나 랙돌 상태면 전환하지 않는다.</p></section><section><h3>Mass<a href="#mass"><span>#</span></a></h3><p>Mass는 다수의 개체를 다루기 위한 엔진의 데이터 지향 프레임워크다. 개체는 액터가 아니라 엔티티이고 엔티티 자체는 ID일 뿐이다. 데이터는 프래그먼트라는 구조체에 담는다. 같은 프래그먼트 조합을 가진 엔티티는 한 아키타입으로 묶여 청크 단위의 연속된 메모리에 저장된다. 로직은 프로세서에 있다. 프로세서는 필요한 프래그먼트를 쿼리로 선언하고 조건에 맞는 청크를 순회하며 한꺼번에 처리한다.</p><p>액터는 한 마리마다 컴포넌트와 틱 함수, 복제 채널을 가진다. 웨이브 좀비 200마리 중 플레이어와 싸우는 것은 일부이고, 나머지는 위치와 목적지 정도만 있으면 되는데도 같은 비용을 낸다. 프로젝트에서는 그 비용을 거리에 따라 하나씩 껐다. Mass에서는 먼 좀비에 처음부터 그 비용이 없다.</p><p>위에서 만든 네 가지도 Mass가 기본으로 다루는 문제와 겹친다. 가장 가까운 뷰어와의 거리로 LOD를 정하고 LOD에 따라 표현을 액터와 메시 인스턴스 사이에서 바꾼다. UE 5.8 소스의 <code>EMassRepresentationType</code>에는 <code>HighResSpawnedActor</code>, <code>LowResSpawnedActor</code>, <code>SkinnedMeshInstance</code>, <code>StaticMeshInstance</code>가 있다. 프로젝트에서는 이미 액터로 만든 좀비의 비용을 단계적으로 낮췄다. Mass에서는 모든 좀비가 엔티티로 있고 필요한 것만 액터로 올린다. 방향이 반대다.</p></section><section><h3>기존: 매니저가 거리를 계산해 좀비에 넣어 준다<a href="#기존-매니저가-거리를-계산해-좀비에-넣어-준다"><span>#</span></a></h3><p>좀비는 틱에서 매니저에 갱신을 요청하고, 매니저는 프레임당 정해진 수만큼 모든 플레이어와의 거리를 계산한다. 한 바퀴가 끝나면 정렬해서 순위를 좀비의 컴포넌트에 기록한다. 컴포넌트는 그 값으로 이동 동기화의 전송 등급을 정한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UZombieOptimizeManager</span><span>::</span></span><span>UpdateDistanceRanks</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>NumZombies</span><span> </span><span>=</span><span> </span><span>Zombies</span><span>.</span><span>Num</span><span>();</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>// 프레임당 정해진 수만큼만 거리를 계산한다</span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>NumToProcess</span><span> </span><span>=</span><span> FMath::</span><span>Min</span><span>(</span><span>MaxDistanceUpdatesPerFrame</span><span>, </span><span>NumZombies</span><span> </span><span>-</span><span> </span><span>Cursor</span><span>);</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>  </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Step</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Step</span><span> </span><span>&lt;</span><span> </span><span>NumToProcess</span><span>; </span><span>++</span><span>Step</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>FZombieEntry</span><span>&amp;</span><span> </span><span>Entry</span><span> </span><span>=</span><span> </span><span>Zombies</span><span>[</span><span>Cursor</span><span>++</span><span>];</span></div></div><div><div><div>10</div></div><div><span>    </span><span>Entry</span><span>.</span><span>DistSq</span><span> </span><span>=</span><span> </span><span>GetMinDistSqToViewers</span><span>(</span><span>Entry</span><span>.</span><span>Location</span><span>);</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>// 한 바퀴가 끝났을 때만 정렬하고 순위를 기록한다</span></div></div><div><div><div>14</div></div><div><span>  </span><span>if</span><span> (</span><span>Cursor</span><span> </span><span>&gt;=</span><span> </span><span>NumZombies</span><span>)</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>16</div></div><div><span>    </span><span>Zombies</span><span>.</span><span>Sort</span><span>([](</span><span>const</span><span> </span><span>FZombieEntry</span><span>&amp;</span><span><span> </span><span>A</span><span>, </span></span><span>const</span><span> </span><span>FZombieEntry</span><span>&amp;</span><span><span> </span><span>B</span><span>) { </span></span><span>return</span><span> </span><span>A</span><span>.</span><span>DistSq</span><span> </span><span>&lt;</span><span> </span><span>B</span><span>.</span><span>DistSq</span><span>; });</span></div></div><div><div><div>17</div></div><div><span>    </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Rank</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Rank</span><span> </span><span>&lt;</span><span> </span><span>NumZombies</span><span>; </span><span>++</span><span>Rank</span><span>)</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>19</div></div><div><span>      </span><span>Zombies</span><span>[</span><span>Rank</span><span>].</span><span>Component</span><span>-&gt;</span><span>SetDistanceRank</span><span>(</span><span>Rank</span><span>);</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>21</div></div><div><span>    </span><span>Cursor</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>23</div></div><div><span>}</span></div></div><div><div><div>24</div></div><div>
</div></div><div><div><div>25</div></div><div><span>int32</span><span><span> </span><span>UZombieLODComponent</span><span>::</span></span><span>CalcSendRateGrade</span><span>() </span><span>const</span></div></div><div><div><div>26</div></div><div><span>{</span></div></div><div><div><div>27</div></div><div><span>  </span><span>int32</span><span> </span><span>Grade</span><span> </span><span>=</span><span> </span><span><span>static_cast</span><span>&lt;</span></span><span>int32</span><span>&gt;</span><span>(</span><span>LOD</span><span>);</span></div></div><div><div><div>28</div></div><div><span><span>  </span></span><span>// 가까운 30마리는 등급 값을 2 낮춘다</span></div></div><div><div><div>29</div></div><div><span>  </span><span>if</span><span> (</span><span>DistanceRank</span><span> </span><span>&lt;=</span><span> </span><span>30</span><span>)</span></div></div><div><div><div>30</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>31</div></div><div><span>    </span><span>Grade</span><span> </span><span>=</span><span> FMath::</span><span>Max</span><span>(</span><span>Grade</span><span> </span><span>-</span><span> </span><span>2</span><span>, </span><span>0</span><span>);</span></div></div><div><div><div>32</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>33</div></div><div><span>  </span><span>return</span><span> </span><span>Grade</span><span>;</span></div></div><div><div><div>34</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>좀비 목록, 커서, 등록과 해제, 풀 반환 시의 정리를 모두 매니저가 직접 관리한다. 좀비마다 컴포넌트가 하나씩 더 붙고 값은 매니저에서 컴포넌트로 복사된다.</p></section><section><h3>변경: 프로세서가 프래그먼트를 읽는다<a href="#변경-프로세서가-프래그먼트를-읽는다"><span>#</span></a></h3><p>Mass의 LOD 수집 프로세서는 <code>FMassViewerInfoFragment</code>에 가장 가까운 뷰어와의 거리를 채운다. 전송 등급을 정하는 프로세서는 그 값을 읽기만 하면 된다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FZombieSyncFragment</span><span> : </span><span>public</span><span> </span><span>FMassFragment</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>uint8</span><span> </span><span>SendRateGrade</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>7</div></div><div><span>};</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span><span>UZombieSyncGradeProcessor</span><span>::</span></span><span>UZombieSyncGradeProcessor</span><span>()</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>: </span><span>EntityQuery</span><span>(</span><span>*</span><span>this</span><span>)</span></div></div><div><div><div>11</div></div><div><span>{</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>// 서버에서만 실행한다</span></div></div><div><div><div>13</div></div><div><span>  </span><span>ExecutionFlags</span><span> </span><span>=</span><span> </span><span><span>static_cast</span><span>&lt;</span></span><span>int32</span><span>&gt;</span><span>(EProcessorExecutionFlags::</span><span>Server</span><span>);</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>void</span><span><span> </span><span>UZombieSyncGradeProcessor</span><span>::</span></span><span>ConfigureQueries</span><span>(</span><span>const</span><span> </span><span>TSharedRef</span><span>&lt;</span><span>FMassEntityManager</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>EntityManager</span><span>)</span></span></div></div><div><div><div>17</div></div><div><span>{</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>// LOD 수집 프로세서가 채운 값을 읽기만 한다</span></div></div><div><div><div>19</div></div><div><span>  </span><span>EntityQuery</span><span>.</span><span>AddRequirement</span><span>&lt;</span><span>FMassViewerInfoFragment</span><span>&gt;</span><span>(EMassFragmentAccess::</span><span>ReadOnly</span><span>);</span></div></div><div><div><div>20</div></div><div><span>  </span><span>EntityQuery</span><span>.</span><span>AddRequirement</span><span>&lt;</span><span>FZombieSyncFragment</span><span>&gt;</span><span>(EMassFragmentAccess::</span><span>ReadWrite</span><span>);</span></div></div><div><div><div>21</div></div><div><span>}</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>void</span><span><span> </span><span>UZombieSyncGradeProcessor</span><span>::</span></span><span>Execute</span><span>(</span><span>FMassEntityManager</span><span>&amp;</span><span><span> </span><span>EntityManager</span><span>, </span></span><span>FMassExecutionContext</span><span>&amp;</span><span><span> </span><span>Context</span><span>)</span></span></div></div><div><div><div>24</div></div><div><span>{</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>// 요구 조건에 맞는 엔티티를 청크 단위로 받는다</span></div></div><div><div><div>26</div></div><div><span>  </span><span>EntityQuery</span><span>.</span><span>ForEachEntityChunk</span><span>(</span><span>Context</span><span>, [</span><span>this</span><span>](</span><span>FMassExecutionContext</span><span>&amp;</span><span><span> </span><span>Context</span><span>)</span></span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>28</div></div><div><span>    </span><span>const</span><span> </span><span>TConstArrayView</span><span>&lt;</span><span>FMassViewerInfoFragment</span><span>&gt; </span><span>ViewerInfos</span><span> </span><span>=</span><span> </span><span>Context</span><span>.</span><span>GetFragmentView</span><span>&lt;</span><span>FMassViewerInfoFragment</span><span>&gt;</span><span>();</span></div></div><div><div><div>29</div></div><div><span>    </span><span>const</span><span> </span><span>TArrayView</span><span>&lt;</span><span>FZombieSyncFragment</span><span>&gt; </span><span>Syncs</span><span> </span><span>=</span><span> </span><span>Context</span><span>.</span><span>GetMutableFragmentView</span><span>&lt;</span><span>FZombieSyncFragment</span><span>&gt;</span><span>();</span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span>    </span><span>for</span><span> (FMassExecutionContext::</span><span>FEntityIterator</span><span> </span><span>EntityIt</span><span> </span><span>=</span><span> </span><span>Context</span><span>.</span><span>CreateEntityIterator</span><span>(); </span><span>EntityIt</span><span>; </span><span>++</span><span>EntityIt</span><span>)</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>33</div></div><div><span>      </span><span>Syncs</span><span>[</span><span>EntityIt</span><span>].</span><span>SendRateGrade</span><span> </span><span>=</span><span> </span><span>CalcGradeByDistanceSq</span><span>(</span><span>ViewerInfos</span><span>[</span><span>EntityIt</span><span>].</span><span>ClosestViewerDistanceSq</span><span>);</span></div></div><div><div><div>34</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>35</div></div><div><span><span>  </span></span><span>});</span></div></div><div><div><div>36</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>목록과 커서가 없어진다. 어떤 엔티티를 순회할지는 쿼리의 요구 조건이 정하고, 좀비가 생기고 사라질 때 따로 등록하거나 해제하지 않는다.</p><p>거리 순위는 이 예시에 없다. 프로젝트에서 순위를 쓴 이유는 웨이브가 기지 앞에 몰리면 거리만으로는 대부분이 최고 단계가 되기 때문이었다. <code>TMassLODCalculator</code>는 LOD마다 최대 수(<code>LODMaxCount</code>)를 받는다.</p></section><section><h3>액터로 남아야 하는 좀비<a href="#액터로-남아야-하는-좀비"><span>#</span></a></h3><p>전부를 엔티티로만 둘 수는 없다. 프로젝트의 좀비에는 액터에 묶인 처리가 있다.</p><ul>
<li>근접 공격은 서버가 애니메이션 포즈에 맞춰 판정한다. 공격 범위에 들어온 좀비는 스켈레탈 메시와 포즈 갱신이 필요하다.</li>
<li>피격 반응, 부위 파괴와 랙돌은 스켈레탈 메시를 전제로 한다. 버텍스 애니메이션 전환을 막던 조건과 같다.</li>
<li>건물을 공격할 대상을 고르는 EQS와 내비메시 길찾기는 액터와 AI 컨트롤러 위에서 돈다.</li>
</ul><p>그래서 경계는 “플레이어나 기지와 전투할 수 있는 거리”가 된다. 그 안의 좀비는 지금처럼 액터이고 밖의 좀비는 엔티티로 웨이브 경로를 따라 움직인다. 프로젝트에서도 웨이브 좀비는 웨이포인트를 따라 이동했으므로 먼 좀비는 내비메시 없이 경로 데이터만으로 옮길 수 있을 것으로 보인다. 엔티티가 액터로 바뀔 때 위치와 체력 같은 상태를 넘겨야 하는데, 스포너에서 구조체를 액터로 되돌릴 때는 클래스와 트랜스폼만 보존했다. 전투 중인 좀비가 경계를 넘나드는 경우에는 넘길 상태가 더 많다.</p><p>복제도 따로 설계해야 한다. Mass 엔티티는 액터가 아니므로 액터 복제 경로를 타지 않는다. 엔진에 MassReplication 모듈이 있지만, 프로젝트에서 쓰던 위치 동기화 컴포넌트와 연결별 피격 전파는 그 위에 새로 만들어야 한다.</p></section></section>
<section><h2>복제 시스템: Iris<a href="#복제-시스템-iris"><span>#</span></a></h2><p>프로젝트의 복제는 엔진의 기존 복제 시스템 위에서 돌았다. 이 시스템은 연결마다 액터 채널을 열고 채널이 액터의 복제 프로퍼티를 그 연결에 마지막으로 보낸 값과 비교해 바뀐 것을 보낸다. 어떤 액터를 어느 연결에서 검토할지는 Replication Graph로 정했다.</p><p>Iris는 UE5에서 추가된 새 복제 시스템이다. 복제할 오브젝트의 상태를 프레임마다 한 번 복사해 두고 여러 연결이 그 복사본을 함께 쓴다. 누구에게 보낼지를 정하는 필터링과 무엇을 먼저 보낼지를 정하는 우선순위는 별도의 단계로 분리돼 있고, 오브젝트를 그룹으로 묶어 연결별로 허용 여부를 바꿀 수 있다.</p><p>새 프로젝트에서 Iris를 고르는 이유는 두 가지다. 프로젝트의 서버는 플레이어 수보다 오브젝트 수가 많은 구조였고, 변경을 찾는 작업을 연결마다 반복하지 않는 쪽이 이 구조에 맞다. 그리고 Replication Graph에서는 “이 연결에는 보내고 저 연결에는 보내지 않는다”를 노드를 직접 작성해 해결했다. Iris에서는 이를 필터와 그룹으로 표현할 수 있다. 프로젝트의 2D 그리드, Always Relevant, 빈도 제한과 Dependent Actor 구성을 Iris의 필터와 그룹, 우선순위로 옮기는 작업은 여기서 다루지 않는다.</p><p>아래의 Push Model, Fast Array, 서브오브젝트 조건은 Iris 전용이 아니다. 기존 복제 시스템에서도 쓸 수 있고 프로젝트에서 쓰지 않았던 것들이다. Iris로 옮길 때도 그대로 유지된다.</p></section>
<section><h2>프로퍼티 복제: 호출 대신 표시<a href="#프로퍼티-복제-호출-대신-표시"><span>#</span></a></h2><section><h3>기존: 값을 바꾼 곳마다 ForceNetUpdate<a href="#기존-값을-바꾼-곳마다-forcenetupdate"><span>#</span></a></h3><p><code>ForceNetUpdate()</code>는 액터를 다음 복제 시점에 바로 검토하게 하는 호출이다. 프로젝트에서는 값을 바꾼 곳마다 이 호출을 넣었다. 소스에는 76개 파일에 270곳가량 나오고, 아이템, 전기 설비, 트랩과 인벤토리 쪽에 몰려 있다. Push Model은 쓰지 않았다. <code>MARK_PROPERTY_DIRTY</code> 계열 호출이 소스에 없다.</p><p>건물의 충돌 프로파일을 바꾸는 코드가 전형적인 형태다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>APlayerBuilding</span><span>::</span></span><span>GetLifetimeReplicatedProps</span><span>(</span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>GetLifetimeReplicatedProps</span><span>(</span><span>OutLifetimeProps</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>// 액터를 검토할 때마다 이 프로퍼티를 이전에 보낸 값과 비교한다</span></div></div><div><div><div>6</div></div><div><span>  </span><span>DOREPLIFETIME</span><span>(</span><span>APlayerBuilding</span><span>, </span><span>CollisionProfile</span><span>);</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>void</span><span><span> </span><span>APlayerBuilding</span><span>::</span></span><span>ServerSetCollisionProfile_Implementation</span><span>(</span><span>const</span><span> </span><span>FName</span><span>&amp;</span><span><span> </span><span>NewProfileName</span><span>)</span></span></div></div><div><div><div>10</div></div><div><span>{</span></div></div><div><div><div>11</div></div><div><span>  </span><span>CollisionProfile</span><span> </span><span>=</span><span> </span><span>NewProfileName</span><span>;</span></div></div><div><div><div>12</div></div><div><span>  </span><span>ApplyCollisionProfile</span><span>();</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>// 무엇이 바뀌었는지는 알리지 않고 액터를 바로 검토하게만 한다</span></div></div><div><div><div>14</div></div><div><span>  </span><span>ForceNetUpdate</span><span>();</span></div></div><div><div><div>15</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>값을 바꾼 함수가 “이 액터를 지금 검토하라”고 직접 호출한다. 무엇이 바뀌었는지는 전달하지 않으므로 엔진은 그 액터의 복제 프로퍼티를 이전 값과 비교해서 찾는다. 호출을 빠뜨리면 휴면 중인 건물에서는 변경이 전달되지 않는다.</p></section><section><h3>변경: Push Model<a href="#변경-push-model"><span>#</span></a></h3><p>기본 복제는 액터를 검토할 때마다 모든 복제 프로퍼티를 이전에 보낸 값과 비교한다. 바뀐 것이 없어도 비교는 한다. Push Model은 이 관계를 뒤집는다. 프로퍼티를 Push 기반으로 등록해 두면 엔진은 코드가 변경을 표시한 프로퍼티만 비교한다.</p><p>건물과 설비는 수가 많고 상태는 드물게 바뀐다. 검토될 때마다 전체를 비교하는 비용이 가장 아까운 대상이다. 그리고 Push Model 없이도 프로젝트는 이미 값을 바꾼 곳마다 호출을 넣고 있었다. 같은 자리에 “지금 검토하라” 대신 “이 프로퍼티가 바뀌었다”를 넣는 것이므로 코드를 쓰는 부담은 비슷하고 전달하는 정보는 더 많다.</p><p>새 프로젝트라면 복제 프로퍼티를 처음부터 Push Model로 등록한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>APlayerBuilding</span><span>::</span></span><span>GetLifetimeReplicatedProps</span><span>(</span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>GetLifetimeReplicatedProps</span><span>(</span><span>OutLifetimeProps</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>FDoRepLifetimeParams</span><span> </span><span>Params</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>// 변경을 표시한 프로퍼티만 비교한다</span></div></div><div><div><div>7</div></div><div><span>  </span><span>Params</span><span>.</span><span>bIsPushBased</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>8</div></div><div><span>  </span><span>DOREPLIFETIME_WITH_PARAMS_FAST</span><span>(</span><span>APlayerBuilding</span><span>, </span><span>CollisionProfile</span><span>, </span><span>Params</span><span>);</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>void</span><span><span> </span><span>APlayerBuilding</span><span>::</span></span><span>SetCollisionProfile</span><span>(</span><span>const</span><span> </span><span>FName</span><span>&amp;</span><span><span> </span><span>NewProfileName</span><span>)</span></span></div></div><div><div><div>12</div></div><div><span>{</span></div></div><div><div><div>13</div></div><div><span>  </span><span>if</span><span> (</span><span>CollisionProfile</span><span> </span><span>!=</span><span> </span><span>NewProfileName</span><span>)</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>// 값을 수정하기 전에 휴면을 해제한다</span></div></div><div><div><div>16</div></div><div><span>    </span><span>FlushNetDormancy</span><span>();</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>    </span><span>CollisionProfile</span><span> </span><span>=</span><span> </span><span>NewProfileName</span><span>;</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>// 이 프로퍼티가 바뀌었다고 엔진에 알린다</span></div></div><div><div><div>20</div></div><div><span>    </span><span>MARK_PROPERTY_DIRTY_FROM_NAME</span><span>(</span><span>APlayerBuilding</span><span>, </span><span>CollisionProfile</span><span>, </span><span>this</span><span>);</span></div></div><div><div><div>21</div></div><div><span>    </span><span>ApplyCollisionProfile</span><span>();</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>23</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>Push Model로 등록한 프로퍼티는 표시가 없으면 비교하지 않는다. 휴면을 해제하거나 갱신 시점을 앞당기는 호출까지 없어지는 것은 아니다. 그 호출이 놓이는 위치가 달라진다. 값을 바꾸는 경로가 setter 하나로 모이면 휴면 해제도 그 안에서 값을 수정하기 전에 한 번만 하면 된다. <a href="/posts/notd-world-objects-as-data/">오브젝트 글</a>에는 복제용 배열을 수정한 뒤에 휴면을 해제하던 순서 문제를 적었다. 이 문제는 호출이 여러 곳에 흩어져 있어서 생겼다.</p><p>이 제약은 처음부터 지키는 편이 낫다. 270곳의 호출을 나중에 setter로 모으기는 어렵다.</p></section></section>
<section><h2>인벤토리: 배열 전체 대신 항목 단위로<a href="#인벤토리-배열-전체-대신-항목-단위로"><span>#</span></a></h2><section><h3>기존: 매 틱 복제용 배열을 다시 채운다<a href="#기존-매-틱-복제용-배열을-다시-채운다"><span>#</span></a></h3><p>인벤토리는 원본 배열과 복제용 배열을 따로 둔다. 원본의 원소에는 데이터 테이블 행이 통째로 들어 있고 복제용 구조체는 ID와 수량만 가진다. 서버는 컴포넌트의 틱에서 원본을 복제용 배열로 옮긴다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UInventoryComponent</span><span>::</span></span><span>TickComponent</span><span>(</span><span>float</span><span><span> </span><span>DeltaTime</span><span>, </span></span><span>ELevelTick</span><span><span> </span><span>TickType</span><span>, </span></span><span>FActorComponentTickFunction</span><span>*</span><span><span> </span><span>ThisTickFunction</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>TickComponent</span><span>(</span><span>DeltaTime</span><span>, </span><span>TickType</span><span>, </span><span>ThisTickFunction</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>GetOwner</span><span>()-&gt;</span><span>HasAuthority</span><span>())</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>// 바뀐 것이 없어도 매 틱 서버에서 배열 전체를 복사한다</span></div></div><div><div><div>8</div></div><div><span>    </span><span>ReplicatedItems</span><span>.</span><span>SetNum</span><span>(</span><span>Items</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>9</div></div><div><span>    </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Index</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Index</span><span> </span><span>&lt;</span><span> </span><span>Items</span><span>.</span><span>Num</span><span>(); </span><span>++</span><span>Index</span><span>)</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>      </span><span>ReplicatedItems</span><span>[</span><span>Index</span><span>] </span><span>=</span><span> </span><span>FReplicatedItem</span><span>(</span><span>Items</span><span>[</span><span>Index</span><span>]);</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>void</span><span><span> </span><span>UInventoryComponent</span><span>::</span></span><span>OnRep_ReplicatedItems</span><span>()</span></div></div><div><div><div>17</div></div><div><span>{</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>// 어느 칸이 바뀌었는지 전달되지 않아 원본과 수량 집계를 전부 다시 만든다</span></div></div><div><div><div>19</div></div><div><span>  </span><span>Items</span><span>.</span><span>Reset</span><span>(</span><span>ReplicatedItems</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>20</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> </span><span>FReplicatedItem</span><span>&amp;</span><span> </span><span>Replicated</span><span> : </span><span>ReplicatedItems</span><span>)</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>22</div></div><div><span>    </span><span>Items</span><span>.</span><span>Add</span><span>(</span><span>FInventoryItem</span><span>(</span><span>FindEntityData</span><span>(</span><span>Replicated</span><span>.</span><span>EntityId</span><span>), </span><span>Replicated</span><span>.</span><span>Amount</span><span>, </span><span>Replicated</span><span>.</span><span>ItemId</span><span>));</span></div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>24</div></div><div>
</div></div><div><div><div>25</div></div><div><span>  </span><span>RebuildAmountView</span><span>();</span></div></div><div><div><div>26</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>아이템이 바뀌지 않은 프레임에도 서버는 플레이어마다 배열 전체를 복사한다. 클라이언트는 한 칸이 바뀌어도 <code>OnRep</code>에서 원본 배열과 종류별 수량 집계를 처음부터 다시 만든다. 어느 칸이 바뀌었는지는 <code>OnRep</code>에 전달되지 않기 때문이다.</p></section><section><h3>변경: Fast Array<a href="#변경-fast-array"><span>#</span></a></h3><p>Fast Array(<code>FFastArraySerializer</code>)는 구조체 배열을 항목 단위로 복제하는 엔진의 직렬화 방식이다. 일반 <code>TArray</code> 복제는 배열을 인덱스 순서로 비교한다. Fast Array는 항목마다 복제 ID와 변경 키를 두고 코드가 변경을 표시한 항목만 보낸다. 클라이언트에서는 추가, 변경, 삭제된 항목마다 콜백이 불린다.</p><p>인벤토리는 이 방식에 맞는 데이터다. 한 번에 한두 칸이 바뀌고, 클라이언트는 바뀐 칸의 UI와 수량 집계만 고치면 된다. 기존 구조는 서버와 클라이언트 양쪽에서 이 정보를 버리고 전체를 다시 만들었다. 프로젝트 소스에서 Fast Array를 쓴 곳은 예제 컴포넌트 하나뿐이었다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>struct</span><span> </span><span>FInventoryList</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>4</div></div><div><span>struct</span><span> </span><span>FInventoryEntry</span><span> : </span><span>public</span><span> </span><span>FFastArraySerializerItem</span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>// 클라이언트에서 항목 순서가 보장되지 않으므로 칸 번호를 항목에 둔다</span></div></div><div><div><div>10</div></div><div><span>  </span><span>int32</span><span> </span><span>SlotIndex</span><span> </span><span>=</span><span> </span><span>INDEX_NONE</span><span>;</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>13</div></div><div><span>  </span><span>int32</span><span> </span><span>EntityId</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>16</div></div><div><span>  </span><span>int32</span><span> </span><span>Amount</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>19</div></div><div><span>  </span><span>FGuid</span><span> </span><span>ItemId</span><span>;</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>// 추가, 변경, 삭제된 항목마다 불리는 콜백</span></div></div><div><div><div>22</div></div><div><span>  </span><span>void</span><span> </span><span>PostReplicatedAdd</span><span>(</span><span>const</span><span> </span><span>FInventoryList</span><span>&amp;</span><span><span> </span><span>List</span><span>);</span></span></div></div><div><div><div>23</div></div><div><span>  </span><span>void</span><span> </span><span>PostReplicatedChange</span><span>(</span><span>const</span><span> </span><span>FInventoryList</span><span>&amp;</span><span><span> </span><span>List</span><span>);</span></span></div></div><div><div><div>24</div></div><div><span>  </span><span>void</span><span> </span><span>PreReplicatedRemove</span><span>(</span><span>const</span><span> </span><span>FInventoryList</span><span>&amp;</span><span><span> </span><span>List</span><span>);</span></span></div></div><div><div><div>25</div></div><div><span>};</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>28</div></div><div><span>struct</span><span> </span><span>FInventoryList</span><span> : </span><span>public</span><span> </span><span>FFastArraySerializer</span></div></div><div><div><div>29</div></div><div><span>{</span></div></div><div><div><div>30</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>33</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>FInventoryEntry</span><span>&gt;</span><span> </span><span>Entries</span><span>;</span></div></div><div><div><div>34</div></div><div>
</div></div><div><div><div>35</div></div><div><span><span>  </span></span><span>// 콜백에서 쓰는 참조이며 복제하지 않는다</span></div></div><div><div><div>36</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>NotReplicated</span><span>)</span></div></div><div><div><div>37</div></div><div><span>  </span><span>TObjectPtr</span><span>&lt;</span><span>UInventoryComponent</span><span>&gt;</span><span> </span><span>Owner</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>38</div></div><div>
</div></div><div><div><div>39</div></div><div><span>  </span><span>bool</span><span> </span><span>NetDeltaSerialize</span><span>(</span><span>FNetDeltaSerializeInfo</span><span>&amp;</span><span><span> </span><span>DeltaParams</span><span>)</span></span></div></div><div><div><div>40</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>41</div></div><div><span><span>    </span></span><span>// 항목 단위 델타 직렬화에 맡긴다</span></div></div><div><div><div>42</div></div><div><span>    </span><span>return</span><span> FFastArraySerializer::</span><span>FastArrayDeltaSerialize</span><span>&lt;</span><span>FInventoryEntry</span><span>, </span><span>FInventoryList</span><span>&gt;(</span><span>Entries</span><span>, </span><span>DeltaParams</span><span>, </span><span>*</span><span>this</span><span>);</span></div></div><div><div><div>43</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>44</div></div><div><span>};</span></div></div><div><div><div>45</div></div><div>
</div></div><div><div><div>46</div></div><div><span>template</span><span>&lt;&gt;</span></div></div><div><div><div>47</div></div><div><span>struct</span><span> </span><span>TStructOpsTypeTraits</span><span>&lt;</span><span>FInventoryList</span><span>&gt; : </span><span>public</span><span> </span><span>TStructOpsTypeTraitsBase2</span><span>&lt;</span><span>FInventoryList</span><span>&gt;</span></div></div><div><div><div>48</div></div><div><span>{</span></div></div><div><div><div>49</div></div><div><span>  </span><span>enum</span></div></div><div><div><div>50</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>51</div></div><div><span><span>    </span></span><span>// 이 구조체가 NetDeltaSerialize를 쓴다고 엔진에 알린다</span></div></div><div><div><div>52</div></div><div><span>    </span><span>WithNetDeltaSerializer</span><span> </span><span>=</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>53</div></div><div><span><span>  </span></span><span>};</span></div></div><div><div><div>54</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>서버는 틱에서 복사하지 않고 수량을 바꾼 곳에서 그 항목만 표시한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UInventoryComponent</span><span>::</span></span><span>SetAmount</span><span>(</span><span>int32</span><span><span> </span><span>EntryIndex</span><span>, </span></span><span>int32</span><span><span> </span><span>NewAmount</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>FInventoryEntry</span><span>&amp;</span><span> </span><span>Entry</span><span> </span><span>=</span><span> </span><span>Inventory</span><span>.</span><span>Entries</span><span>[</span><span>EntryIndex</span><span>];</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span> (</span><span>Entry</span><span>.</span><span>Amount</span><span> </span><span>!=</span><span> </span><span>NewAmount</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>Entry</span><span>.</span><span>Amount</span><span> </span><span>=</span><span> </span><span>NewAmount</span><span>;</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>// 바뀐 항목만 복제 대상으로 표시한다</span></div></div><div><div><div>8</div></div><div><span>    </span><span>Inventory</span><span>.</span><span>MarkItemDirty</span><span>(</span><span>Entry</span><span>);</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>void</span><span><span> </span><span>FInventoryEntry</span><span>::</span></span><span>PostReplicatedChange</span><span>(</span><span>const</span><span> </span><span>FInventoryList</span><span>&amp;</span><span><span> </span><span>List</span><span>)</span></span></div></div><div><div><div>13</div></div><div><span>{</span></div></div><div><div><div>14</div></div><div><span>  </span><span>List</span><span>.</span><span>Owner</span><span>-&gt;</span><span>OnSlotChanged</span><span>(</span><span>SlotIndex</span><span>, </span><span>EntityId</span><span>, </span><span>Amount</span><span>);</span></div></div><div><div><div>15</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>클라이언트의 <code>OnSlotChanged()</code>는 해당 칸의 위젯과 그 종류의 수량 집계만 갱신한다.</p><p>Fast Array는 클라이언트에서 항목의 순서를 보장하지 않는다. 기존 배열은 인덱스가 곧 인벤토리 칸이었으므로 칸 번호를 <code>SlotIndex</code>로 항목에 넣어야 한다. 항목을 지울 때는 <code>MarkArrayDirty()</code>를 호출해야 한다.</p><p>ID만 보내고 클라이언트가 테이블을 조회하는 규칙은 그대로다. 이제는 배열 전체 대신 항목이 복제와 통지의 단위가 된다.</p></section></section>
<section><h2>보관함 내용물: 가까운 연결에만<a href="#보관함-내용물-가까운-연결에만"><span>#</span></a></h2><section><h3>기존: 공유 배열을 채우고 비운다<a href="#기존-공유-배열을-채우고-비운다"><span>#</span></a></h3><p>보관함도 같은 복제용 배열을 쓴다. 다만 근처에 플레이어가 없으면 서버가 배열을 비운다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>FItemContainer</span><span>::</span></span><span>RefreshReplicatedItems</span><span>(</span><span>const</span><span> </span><span>AActor</span><span>*</span><span><span> </span><span>Owner</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>IDistanceGatedReplication</span><span>*</span><span> </span><span>Gate</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>IDistanceGatedReplication</span><span>&gt;(</span><span>Owner</span><span>);</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span> (</span><span>Gate</span><span> </span><span>&amp;&amp;</span><span> </span><span>IsAnyPlayerWithin</span><span>(</span><span>Owner</span><span>-&gt;</span><span>GetActorLocation</span><span>(), </span><span>Gate</span><span>-&gt;</span><span>GetReplicationDistance</span><span>()) </span><span>==</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>// 근처에 아무도 없으면 비운다. 이 배열은 액터를 복제받는 모든 연결이 공유한다</span></div></div><div><div><div>7</div></div><div><span>    </span><span>ReplicatedItems</span><span>.</span><span>Empty</span><span>();</span></div></div><div><div><div>8</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>  </span><span>ReplicatedItems</span><span>.</span><span>SetNum</span><span>(</span><span>Items</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>12</div></div><div><span>  </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Index</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Index</span><span> </span><span>&lt;</span><span> </span><span>Items</span><span>.</span><span>Num</span><span>(); </span><span>++</span><span>Index</span><span>)</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>14</div></div><div><span>    </span><span>ReplicatedItems</span><span>[</span><span>Index</span><span>] </span><span>=</span><span> </span><span>FReplicatedItem</span><span>(</span><span>Items</span><span>[</span><span>Index</span><span>]);</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>16</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>배열은 액터가 공유하는 상태다. 판정은 “누구든 가까이 있는가”이고, 한 명이 다가가면 그 건물이 복제되는 모든 연결에 내용물이 간다.</p></section><section><h3>변경: 서브오브젝트와 COND_NetGroup<a href="#변경-서브오브젝트와-cond_netgroup"><span>#</span></a></h3><p>서브오브젝트는 액터에 딸려 함께 복제되는 <code>UObject</code>다. 액터의 서브오브젝트 목록에 등록하며 등록할 때 복제 조건을 줄 수 있다. 프로퍼티의 <code>COND_OwnerOnly</code>처럼 서브오브젝트 단위로 “어느 연결에 보낼지”를 정하는 것이다. <code>COND_NetGroup</code>은 그중 하나로, 서브오브젝트를 이름이 있는 그룹에 넣고 그 그룹에 속한 PlayerController의 연결에만 복제한다.</p><p>기존 방식이 연결별로 거르지 못한 이유는 걸러야 할 대상이 액터의 프로퍼티였기 때문이다. 프로퍼티 조건에는 “가까이 있는 연결”을 표현할 방법이 없어서 배열 자체를 비웠다. 내용물을 서브오브젝트로 분리하면 액터는 그대로 모두에게 복제하고 내용물만 연결마다 따로 정할 수 있다. 서브오브젝트 안의 내용물은 위의 Fast Array로 둔다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AStorageBuilding</span><span>::</span></span><span>BeginPlay</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>BeginPlay</span><span>();</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>HasAuthority</span><span>())</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>Contents</span><span> </span><span>=</span><span> </span><span>NewObject</span><span>&lt;</span><span>UStorageContents</span><span>&gt;(</span><span>this</span><span>);</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>// 그룹에 속한 PlayerController의 연결에만 복제한다</span></div></div><div><div><div>9</div></div><div><span>    </span><span>AddReplicatedSubObject</span><span>(</span><span>Contents</span><span>, </span><span>COND_NetGroup</span><span>);</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>    </span><span>UNetworkSubsystem</span><span>*</span><span> </span><span>NetSubsystem</span><span> </span><span>=</span><span> </span><span>GetWorld</span><span>()-&gt;</span><span>GetSubsystem</span><span>&lt;</span><span>UNetworkSubsystem</span><span>&gt;</span><span>();</span></div></div><div><div><div>12</div></div><div><span>    </span><span>NetSubsystem</span><span>-&gt;</span><span>GetNetConditionGroupManager</span><span>().</span><span>RegisterSubObjectInGroup</span><span>(</span><span>Contents</span><span>, </span><span>ContentsGroupName</span><span>);</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>void</span><span><span> </span><span>AStorageBuilding</span><span>::</span></span><span>RefreshViewers</span><span>()</span></div></div><div><div><div>17</div></div><div><span>{</span></div></div><div><div><div>18</div></div><div><span>  </span><span>for</span><span> (</span><span>FConstPlayerControllerIterator</span><span> </span><span>It</span><span> </span><span>=</span><span> </span><span>GetWorld</span><span>()-&gt;</span><span>GetPlayerControllerIterator</span><span>(); </span><span>It</span><span>; </span><span>++</span><span>It</span><span>)</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>20</div></div><div><span>    </span><span>APlayerController</span><span>*</span><span> </span><span>PlayerController</span><span> </span><span>=</span><span> </span><span>It</span><span>-&gt;</span><span>Get</span><span>();</span></div></div><div><div><div>21</div></div><div><span>    </span><span>if</span><span> (</span><span>PlayerController</span><span> </span><span>==</span><span> </span><span>nullptr</span><span>)</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>23</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span><span>    </span></span><span>// 가까워지면 그룹에 넣고 멀어지면 뺀다. 내용물 데이터는 건드리지 않는다</span></div></div><div><div><div>27</div></div><div><span>    </span><span>const</span><span> </span><span>bool</span><span> </span><span>bIsNear</span><span> </span><span>=</span><span> </span><span>IsWithinContentsDistance</span><span>(</span><span>PlayerController</span><span>-&gt;</span><span>GetPawn</span><span>());</span></div></div><div><div><div>28</div></div><div><span>    </span><span>const</span><span> </span><span>bool</span><span> </span><span>bIsMember</span><span> </span><span>=</span><span> </span><span>PlayerController</span><span>-&gt;</span><span>IsMemberOfNetConditionGroup</span><span>(</span><span>ContentsGroupName</span><span>);</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span>    </span><span>if</span><span> (</span><span>bIsNear</span><span> </span><span>&amp;&amp;</span><span> </span><span>bIsMember</span><span> </span><span>==</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>31</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>32</div></div><div><span>      </span><span>PlayerController</span><span>-&gt;</span><span>IncludeInNetConditionGroup</span><span>(</span><span>ContentsGroupName</span><span>);</span></div></div><div><div><div>33</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>34</div></div><div><span>    </span><span>else</span><span> </span><span>if</span><span> (</span><span>bIsNear</span><span> </span><span>==</span><span> </span><span>false</span><span> </span><span>&amp;&amp;</span><span> </span><span>bIsMember</span><span>)</span></div></div><div><div><div>35</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>36</div></div><div><span>      </span><span>PlayerController</span><span>-&gt;</span><span>RemoveFromNetConditionGroup</span><span>(</span><span>ContentsGroupName</span><span>);</span></div></div><div><div><div>37</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>38</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>39</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>거리 검사는 기존과 같은 주기의 타이머로 돌린다. 검사 결과를 쓰는 곳이 달라졌다. 기존에는 서버의 배열을 바꿨고, 여기서는 연결의 그룹 소속을 바꾼다. 서버의 내용물 데이터는 건드리지 않는다.</p><p><code>AddReplicatedSubObject()</code>는 액터의 <code>bReplicateUsingRegisteredSubObjectList</code>가 켜져 있어야 쓸 수 있다. Iris를 쓰면 이 그룹이 서브오브젝트 필터 그룹으로 만들어지고 연결마다 허용 여부가 설정된다.</p><p>그룹 수에는 한도가 있다. <code>UReplicationSystem</code>의 <code>MaxNetObjectGroupCount</code> 기본값은 2048이다. 보관함마다 그룹을 하나씩 만들면 건물이 많은 서버에서 한도에 닿을 수 있다. 기지나 구역 단위로 그룹을 묶거나 한도를 올려야 한다. 묶는 단위가 커질수록 필요 없는 연결에 가는 내용물이 다시 늘어난다.</p></section></section>
<section><h2>대용량 전송: 엔진의 버퍼 대신 직접 확인한다<a href="#대용량-전송-엔진의-버퍼-대신-직접-확인한다"><span>#</span></a></h2><section><h3>기존: NumOutRec을 보고 다음 조각을 보낸다<a href="#기존-numoutrec을-보고-다음-조각을-보낸다"><span>#</span></a></h3><p><a href="/posts/notd-reliable-rpc-data-streaming/">스트리밍 라우터</a>는 레코드를 100개씩 묶어 Reliable RPC로 보내고, 액터 채널에서 수신 확인을 받지 못한 번치 수가 한도 아래일 때만 다음 조각을 보냈다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UStreamRouterComponent</span><span>::</span></span><span>SendPendingChunks</span><span>(</span><span>const</span><span> </span><span>UActorChannel</span><span>*</span><span><span> </span><span>Channel</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>ChunkSize</span><span> </span><span>=</span><span> </span><span>100</span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>// 수신 확인을 받지 못한 번치가 이 수보다 적을 때만 다음 조각을 보낸다</span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>OutRecLimit</span><span> </span><span>=</span><span> </span><span>RELIABLE_BUFFER</span><span> </span><span>/</span><span> </span><span>4</span><span>;</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>  </span><span>for</span><span> (</span><span>FStreamRequest</span><span>&amp;</span><span> </span><span>Request</span><span> : </span><span>Requests</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>const</span><span> </span><span>int32</span><span> </span><span>NumChunks</span><span> </span><span>=</span><span> </span><span>Request</span><span>.</span><span>GetNumChunks</span><span>(</span><span>ChunkSize</span><span>);</span></div></div><div><div><div>10</div></div><div><span>    </span><span>while</span><span> (</span><span>Request</span><span>.</span><span>ChunksSent</span><span> </span><span>&lt;</span><span> </span><span>NumChunks</span><span> </span><span>&amp;&amp;</span><span> </span><span>Channel</span><span>-&gt;</span><span>NumOutRec</span><span> </span><span>&lt;</span><span> </span><span>OutRecLimit</span><span>)</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>12</div></div><div><span>      </span><span>const</span><span> </span><span>int32</span><span> </span><span>Start</span><span> </span><span>=</span><span> </span><span>Request</span><span>.</span><span>ChunksSent</span><span> </span><span>*</span><span> </span><span>ChunkSize</span><span>;</span></div></div><div><div><div>13</div></div><div><span>      </span><span>const</span><span> </span><span>int32</span><span> </span><span>Count</span><span> </span><span>=</span><span> FMath::</span><span>Min</span><span>(</span><span>ChunkSize</span><span>, </span><span>Request</span><span>.</span><span>Records</span><span>.</span><span>Num</span><span>() </span><span>-</span><span> </span><span>Start</span><span>);</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>      </span><span>ClientReceiveChunk</span><span>(</span><span>Request</span><span>.</span><span>Tag</span><span>, </span><span>TArray</span><span>&lt;</span><span>FStreamRecord</span><span>&gt;(</span><span>Request</span><span>.</span><span>Records</span><span>.</span><span>GetData</span><span>() </span><span>+</span><span> </span><span>Start</span><span>, </span><span>Count</span><span>));</span></div></div><div><div><div>16</div></div><div><span>      </span><span>++</span><span>Request</span><span>.</span><span>ChunksSent</span><span>;</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>별도의 ack 없이 엔진이 이미 아는 값을 쓴다는 것이 장점이었다. 이전 글에 적은 제약은 다음과 같다.</p><ul>
<li>조각의 크기를 레코드 수로만 정한다. 큰 레코드가 섞이면 조각 하나가 버퍼를 채울 수 있다.</li>
<li><code>NumOutRec</code>은 네트워크 ACK만 반영한다. 클라이언트가 조각을 처리했는지는 알 수 없다.</li>
<li>클라이언트는 태그에 등록된 소켓이 없으면 조각과 완료 알림을 버린다.</li>
</ul></section><section><h3>Iris에서는 기준으로 삼던 값이 달라진다<a href="#iris에서는-기준으로-삼던-값이-달라진다"><span>#</span></a></h3><p>Iris에서는 RPC가 액터 채널의 이 경로로 나가지 않는다. UE 5.8 소스를 보면 <code>UReplicationSystem::SendRPC()</code>가 RPC를 오브젝트별 attachment 큐에 넣는다. Reliable 쪽은 수신 확인 전의 블롭을 1024개까지 담는 전송 창이 있고 창이 차면 그 앞의 대기 큐에 쌓는다. 대기 큐의 기본 한도는 <code>net.ReliableRPCQueueSize</code>의 4096개이고, 넘으면 <code>ensure</code>가 발생하며 그 RPC는 큐에 들어가지 않는다. 따라서 Iris로 가면 <code>NumOutRec</code>은 스트리밍의 밀린 정도를 알려 주지 않을 것으로 보인다.</p><p>복제 방식을 바꾸면 흐름 제어의 기준도 함께 바꿔야 한다. 엔진 내부의 큐 상태에 기대는 대신 라우터가 직접 수신 확인을 받는 편이 복제 시스템과 무관하게 유지된다.</p></section><section><h3>변경: 바이트 예산과 수신 확인<a href="#변경-바이트-예산과-수신-확인"><span>#</span></a></h3><p>조각을 레코드 수가 아니라 바이트로 자르고 확인받지 못한 조각의 수로 전송을 멈춘다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UStreamRouterComponent</span><span>::</span></span><span>SendPendingChunks</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>// 확인받지 못한 조각이 한도에 닿으면 멈춘다</span></div></div><div><div><div>4</div></div><div><span>  </span><span>while</span><span> (</span><span>Requests</span><span>.</span><span>IsEmpty</span><span>() </span><span>==</span><span> </span><span>false</span><span> </span><span>&amp;&amp;</span><span> </span><span>NextChunkId</span><span> </span><span>-</span><span> </span><span>LastAckedChunkId</span><span> </span><span>&lt;</span><span> </span><span>MaxUnackedChunks</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>FStreamRequest</span><span>&amp;</span><span> </span><span>Request</span><span> </span><span>=</span><span> </span><span>Requests</span><span>[</span><span>0</span><span>];</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>FStreamRecord</span><span>&gt; </span><span>Chunk</span><span>;</span></div></div><div><div><div>9</div></div><div><span>    </span><span>int32</span><span> </span><span>ChunkBytes</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>10</div></div><div><span>    </span><span>while</span><span> (</span><span>Request</span><span>.</span><span>NextRecord</span><span> </span><span>&lt;</span><span> </span><span>Request</span><span>.</span><span>Records</span><span>.</span><span>Num</span><span>())</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>12</div></div><div><span>      </span><span>const</span><span> </span><span>FStreamRecord</span><span>&amp;</span><span> </span><span>Record</span><span> </span><span>=</span><span> </span><span>Request</span><span>.</span><span>Records</span><span>[</span><span>Request</span><span>.</span><span>NextRecord</span><span>];</span></div></div><div><div><div>13</div></div><div><span><span>      </span></span><span>// 바이트 한도를 넘기기 직전까지 담는다. 첫 레코드는 한도를 넘어도 담는다</span></div></div><div><div><div>14</div></div><div><span>      </span><span>if</span><span> (</span><span>Chunk</span><span>.</span><span>Num</span><span>() </span><span>&gt;</span><span> </span><span>0</span><span> </span><span>&amp;&amp;</span><span> </span><span>ChunkBytes</span><span> </span><span>+</span><span> </span><span>Record</span><span>.</span><span>Bytes</span><span>.</span><span>Num</span><span>() </span><span>&gt;</span><span> </span><span>MaxChunkBytes</span><span>)</span></div></div><div><div><div>15</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>16</div></div><div><span>        </span><span>break</span><span>;</span></div></div><div><div><div>17</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>      </span><span>ChunkBytes</span><span> </span><span>+=</span><span> </span><span>Record</span><span>.</span><span>Bytes</span><span>.</span><span>Num</span><span>();</span></div></div><div><div><div>20</div></div><div><span>      </span><span>Chunk</span><span>.</span><span>Add</span><span>(</span><span>Record</span><span>);</span></div></div><div><div><div>21</div></div><div><span>      </span><span>++</span><span>Request</span><span>.</span><span>NextRecord</span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>    </span><span>const</span><span> </span><span>bool</span><span> </span><span>bIsLastChunk</span><span> </span><span>=</span><span> </span><span>Request</span><span>.</span><span>NextRecord</span><span> </span><span>&gt;=</span><span> </span><span>Request</span><span>.</span><span>Records</span><span>.</span><span>Num</span><span>();</span></div></div><div><div><div>25</div></div><div><span>    </span><span>ClientReceiveChunk</span><span>(</span><span>NextChunkId</span><span>++</span><span>, </span><span>Request</span><span>.</span><span>Tag</span><span>, </span><span>Chunk</span><span>, </span><span>bIsLastChunk</span><span>);</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>    </span><span>if</span><span> (</span><span>bIsLastChunk</span><span>)</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>29</div></div><div><span>      </span><span>Requests</span><span>.</span><span>RemoveAt</span><span>(</span><span>0</span><span>);</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>31</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>32</div></div><div><span>}</span></div></div><div><div><div>33</div></div><div>
</div></div><div><div><div>34</div></div><div><span>void</span><span><span> </span><span>UStreamRouterComponent</span><span>::</span></span><span>ServerAckChunk_Implementation</span><span>(</span><span>uint32</span><span><span> </span><span>ChunkId</span><span>)</span></span></div></div><div><div><div>35</div></div><div><span>{</span></div></div><div><div><div>36</div></div><div><span><span>  </span></span><span>// 클라이언트가 조각을 처리한 뒤 호출한다</span></div></div><div><div><div>37</div></div><div><span>  </span><span>LastAckedChunkId</span><span> </span><span>=</span><span> FMath::</span><span>Max</span><span>(</span><span>LastAckedChunkId</span><span>, </span><span>ChunkId</span><span>);</span></div></div><div><div><div>38</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>NextChunkId</code>는 다음에 보낼 조각의 번호이고 <code>LastAckedChunkId</code>는 클라이언트가 확인한 마지막 번호다. 채널을 인자로 받지 않는다. 클라이언트는 조각을 소켓에 넘긴 뒤에 <code>ServerAckChunk()</code>를 호출하므로 이 수신 확인은 클라이언트가 조각을 실제로 처리했다는 뜻이 된다.</p><p>레코드 하나가 <code>MaxChunkBytes</code>보다 크면 그 레코드만으로 조각 하나를 만든다. 큰 레코드를 다시 쪼개는 처리는 이 예시에 없다.</p><p>클라이언트 쪽에서는 두 가지를 바꾼다.</p><ul>
<li>태그에 등록된 소켓이 없으면 조각을 버리지 않고 보관했다가 소켓이 등록될 때 넘긴다. 수신 확인은 넘긴 뒤에 보낸다.</li>
<li>완료 알림을 별도 RPC로 두지 않고 마지막 조각의 플래그로 보낸다.</li>
</ul><p>개수만 비교하던 검증은 항목 ID의 해시를 비교하도록 바꿀 수 있다. 다만 수신 확인이 처리 완료를 뜻하게 되면 검증이 잡아야 할 경우가 줄어든다. 검증을 남길지는 그 뒤에 정해도 된다.</p><p>수신 확인 RPC가 조각마다 하나씩 추가된다. 접속 시의 일괄 전송에서는 조각 수만큼 클라이언트에서 서버로 가는 Reliable RPC가 늘어난다.</p><p>피격 전파의 신뢰성 선택도 <code>NumOutRec</code>에 기대고 있었다. 이쪽은 대체할 기준이 따로 필요하다.</p></section></section>
<section><h2>그대로 가져갈 것<a href="#그대로-가져갈-것"><span>#</span></a></h2><p>구조를 바꿔도 방법은 남는다. <a href="/posts/notd-multiplayer-optimization-overview/">개요 글</a>의 공통 방법 중 다음은 새 설계에서도 그대로 쓴다.</p><ul>
<li>서버와 클라이언트가 같은 데이터를 가지고 있으면 그것을 가리키는 값만 보낸다. 데이터 테이블 ID, 레벨 코드가 여기에 해당한다.</li>
<li>자연물은 인스턴스로 두고 타격받은 것만 액터로 바꾼다. 파괴 이력은 구조체 목록으로 유지한다.</li>
<li>한 프레임에 처리하는 수를 정해 두고 나눠 처리한다. Mass로 옮긴 부분은 프로세서가 이 역할을 맡지만, 리스폰 검사와 폴리지 정리는 그대로다.</li>
</ul><p>“엔진이 이미 알고 있는 값을 기준으로 삼는다”는 방법은 조건이 붙는다. <code>NumOutRec</code>처럼 특정 복제 구현의 내부 상태는 그 구현을 바꾸면 쓸 수 없다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/iris-replication-system-in-unreal-engine" target="_blank">Iris Replication System in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/mass-entity-in-unreal-engine" target="_blank">Mass Entity in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/replicating-uobjects-in-unreal-engine" target="_blank">Replicating UObjects in Unreal Engine</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/notd-reliable-rpc-data-streaming/</id>
      <title type="text">Reliable 버퍼 상태를 보고 대용량 데이터 나눠 보내기</title>
      <published>2026-09-29T00:00:00.000Z</published>
      <updated>2026-09-29T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/notd-reliable-rpc-data-streaming/"/>
      <summary type="text">Night of the Dead에서 퀘스트 진행도, 지도 마커처럼 플레이어마다 쌓이는 데이터를 Reliable RPC로 나눠 보낸 스트리밍 라우터의 구조와, 같은 버퍼 기준으로 피격 전파의 신뢰성을 연결별로 고른 방법을 정리한다.</summary>
      <content type="html"><![CDATA[<p><a href="/projects/night-of-the-dead/">Night of the Dead</a>에는 플레이어 한 명에게만 필요하고 플레이할수록 계속 쌓이는 데이터가 있었다. 퀘스트 진행도, 해금한 건물, 지도에 찍은 마커, 걷어 낸 지도 안개 같은 것들이다. 접속할 때는 누적된 내용을 한꺼번에 받아야 하고 이후에는 조금씩 바뀐다.</p>
<p><img alt="" loading="lazy" width="1280" height="720" src="/_astro/map-markers.C71MXO6Z_qf84F.webp" /></p>
<p>이 데이터를 Reliable RPC 하나에 담아 보내면 문제가 생긴다. 엔진은 액터 채널마다 아직 수신 확인을 받지 못한 Reliable 번치를 정해진 개수(<code>RELIABLE_BUFFER</code>)까지만 쌓아 둔다. 큰 RPC는 여러 개의 부분 번치로 쪼개져 이 버퍼를 차지하고, 버퍼가 넘치면 엔진은 복구하지 않고 연결을 닫는다.</p>
<p>그래서 데이터를 잘게 나누고, 버퍼에 여유가 있을 때 다음 조각을 보내는 전송 경로를 만들었다. 누적 데이터의 크기보다 한 번에 쌓이는 전송량을 제어하는 데 초점을 뒀다.</p>
<section><h2>구조<a href="#구조"><span>#</span></a></h2><p>구성 요소는 셋이다.</p><ul>
<li>라우터: 플레이어 캐릭터에 붙는 컴포넌트다. 서버에서는 전송 요청을 큐에 쌓아 나눠 보내고, 클라이언트에서는 받은 조각을 태그에 맞는 소켓으로 넘긴다.</li>
<li>소켓: 데이터를 실제로 가진 컴포넌트가 구현하는 인터페이스다. 자기 데이터를 바이트 배열로 인코딩하고, 받은 바이트 배열을 디코딩한다.</li>
<li>레코드: 항목 하나를 직렬화한 바이트 배열이다. 라우터는 내용을 해석하지 않는다.</li>
</ul><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FStreamRecord</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>7</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>uint8</span><span>&gt;</span><span> </span><span>Bytes</span><span>;</span></div></div><div><div><div>8</div></div><div><span>};</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>class</span><span> </span><span>IStreamSocket</span></div></div><div><div><div>11</div></div><div><span>{</span></div></div><div><div><div>12</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>public:</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>// 이 소켓이 처리하는 태그 목록</span></div></div><div><div><div>16</div></div><div><span>  </span><span>virtual</span><span> </span><span>const</span><span> </span><span>TArray</span><span>&lt;</span><span>FGameplayTag</span><span>&gt;</span><span>&amp;</span><span> </span><span>GetStreamTags</span><span>() </span><span>const</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>// 클라이언트: 레코드 묶음을 받아 자기 상태에 반영한다.</span></div></div><div><div><div>19</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>DecodeRecords</span><span>(</span><span>const</span><span> </span><span>FGameplayTag</span><span>&amp;</span><span><span> </span><span>Tag</span><span>, </span></span><span>const</span><span> </span><span>TArray</span><span>&lt;</span><span>FStreamRecord</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>Records</span><span>) </span></span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>// 클라이언트: 해당 태그의 전송이 끝났다.</span></div></div><div><div><div>22</div></div><div><span>  </span><span>virtual</span><span> </span><span>void</span><span> </span><span>OnStreamCompleted</span><span>(</span><span>const</span><span> </span><span>FGameplayTag</span><span>&amp;</span><span><span> </span><span>Tag</span><span>) </span></span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>23</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>라우팅 키는 <code>FGameplayTag</code>다. 퀘스트 컴포넌트는 진행, 완료, 포기 태그를, 마커 컴포넌트는 추가, 수정, 삭제, 초기화 태그를 등록한다. 태그가 곧 “이 레코드를 어떻게 해석할 것인가”를 정하므로 레코드 안에 종류 구분을 넣을 필요가 없다.</p><p>소켓은 소유자 역할이 Authority이거나 Autonomous Proxy일 때만 라우터에 등록한다. 다른 플레이어의 캐릭터에서는 등록하지 않으므로 이 경로는 서버에서 소유 클라이언트로만 흐른다.</p><div><div><div>소켓 (클라이언트)라우터 (클라이언트)라우터 (서버)소켓 (서버)소켓 (클라이언트)라우터 (클라이언트)라우터 (서버)소켓 (서버)loop[0.1초마다, 버퍼에 여유가 있는 동안]Enqueue(Tag, Records)ClientReceiveChunk(Tag, 최대 레코드 100개)DecodeRecords(Tag, Records)ClientNotifyCompleted(Tag)OnStreamCompleted(Tag)
</div><div>소켓 (클라이언트)라우터 (클라이언트)라우터 (서버)소켓 (서버)소켓 (클라이언트)라우터 (클라이언트)라우터 (서버)소켓 (서버)loop[0.1초마다, 버퍼에 여유가 있는 동안]Enqueue(Tag, Records)ClientReceiveChunk(Tag, 최대 레코드 100개)DecodeRecords(Tag, Records)ClientNotifyCompleted(Tag)OnStreamCompleted(Tag)
</div></div></div></section>
<section><h2>전송 루프<a href="#전송-루프"><span>#</span></a></h2><p>서버의 라우터는 요청마다 “몇 번째 조각까지 보냈는가”만 기억한다. 프로젝트 코드의 기본 설정은 0.1초 간격의 틱과 조각당 최대 100개 레코드다. 틱에서 큐를 돌며 보낼 수 있는 만큼 보낸다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>struct</span><span> </span><span>FStreamRequest</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>FGameplayTag</span><span> </span><span>Tag</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>FStreamRecord</span><span>&gt; </span><span>Records</span><span>;</span></div></div><div><div><div>5</div></div><div><span>  </span><span>int32</span><span> </span><span>ChunksSent</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>  </span><span>int32</span><span> </span><span>GetNumChunks</span><span>(</span><span>int32</span><span><span> </span><span>ChunkSize</span><span>) </span></span><span>const</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>return</span><span> FMath::</span><span>DivideAndRoundUp</span><span>(</span><span>Records</span><span>.</span><span>Num</span><span>(), </span><span>ChunkSize</span><span>);</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>11</div></div><div><span>};</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>void</span><span><span> </span><span>UStreamRouterComponent</span><span>::</span></span><span>TickComponent</span><span>(</span><span>float</span><span><span> </span><span>DeltaTime</span><span>, </span></span><span>ELevelTick</span><span><span> </span><span>TickType</span><span>, </span></span><span>FActorComponentTickFunction</span><span>*</span><span><span> </span><span>ThisTickFunction</span><span>)</span></span></div></div><div><div><div>14</div></div><div><span>{</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>Super::</span><span>TickComponent</span><span>(</span><span>DeltaTime</span><span>, </span><span>TickType</span><span>, </span><span>ThisTickFunction</span><span>);</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>  </span><span>if</span><span> (</span><span>GetOwnerRole</span><span>() </span><span>!=</span><span> </span><span>ROLE_Authority</span><span>)</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>19</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>20</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>  </span><span>UNetConnection</span><span>*</span><span> </span><span>Connection</span><span> </span><span>=</span><span> </span><span>GetOwner</span><span>()-&gt;</span><span>GetNetConnection</span><span>();</span></div></div><div><div><div>23</div></div><div><span>  </span><span>const</span><span> </span><span>UActorChannel</span><span>*</span><span> </span><span>Channel</span><span> </span><span>=</span><span> </span><span>Connection</span><span> </span><span>?</span><span> </span><span>Connection</span><span>-&gt;</span><span>FindActorChannelRef</span><span>(</span><span>GetOwner</span><span>()) </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>24</div></div><div><span>  </span><span>if</span><span> (</span><span>Channel</span><span> </span><span>==</span><span> </span><span>nullptr</span><span>)</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>26</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>ChunkSize</span><span> </span><span>=</span><span> </span><span>100</span><span>;</span></div></div><div><div><div>30</div></div><div><span><span>  </span></span><span>// 같은 채널의 다른 Reliable RPC가 들어올 여유를 남긴다.</span></div></div><div><div><div>31</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>OutRecLimit</span><span> </span><span>=</span><span> </span><span>RELIABLE_BUFFER</span><span> </span><span>/</span><span> </span><span>4</span><span>;</span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span>  </span><span>for</span><span> (</span><span>FStreamRequest</span><span>&amp;</span><span> </span><span>Request</span><span> : </span><span>Requests</span><span>)</span></div></div><div><div><div>34</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>35</div></div><div><span>    </span><span>const</span><span> </span><span>int32</span><span> </span><span>NumChunks</span><span> </span><span>=</span><span> </span><span>Request</span><span>.</span><span>GetNumChunks</span><span>(</span><span>ChunkSize</span><span>);</span></div></div><div><div><div>36</div></div><div><span><span>    </span></span><span>// NumOutRec은 보냈지만 아직 ACK를 받지 못한 Reliable 번치 수다.</span></div></div><div><div><div>37</div></div><div><span>    </span><span>while</span><span> (</span><span>Request</span><span>.</span><span>ChunksSent</span><span> </span><span>&lt;</span><span> </span><span>NumChunks</span><span> </span><span>&amp;&amp;</span><span> </span><span>Channel</span><span>-&gt;</span><span>NumOutRec</span><span> </span><span>&lt;</span><span> </span><span>OutRecLimit</span><span>)</span></div></div><div><div><div>38</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>39</div></div><div><span>      </span><span>const</span><span> </span><span>int32</span><span> </span><span>Start</span><span> </span><span>=</span><span> </span><span>Request</span><span>.</span><span>ChunksSent</span><span> </span><span>*</span><span> </span><span>ChunkSize</span><span>;</span></div></div><div><div><div>40</div></div><div><span>      </span><span>const</span><span> </span><span>int32</span><span> </span><span>Count</span><span> </span><span>=</span><span> FMath::</span><span>Min</span><span>(</span><span>ChunkSize</span><span>, </span><span>Request</span><span>.</span><span>Records</span><span>.</span><span>Num</span><span>() </span><span>-</span><span> </span><span>Start</span><span>);</span></div></div><div><div><div>41</div></div><div>
</div></div><div><div><div>42</div></div><div><span>      </span><span>ClientReceiveChunk</span><span>(</span><span>Request</span><span>.</span><span>Tag</span><span>, </span><span>TArray</span><span>&lt;</span><span>FStreamRecord</span><span>&gt;(</span><span>Request</span><span>.</span><span>Records</span><span>.</span><span>GetData</span><span>() </span><span>+</span><span> </span><span>Start</span><span>, </span><span>Count</span><span>));</span></div></div><div><div><div>43</div></div><div><span>      </span><span>++</span><span>Request</span><span>.</span><span>ChunksSent</span><span>;</span></div></div><div><div><div>44</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>45</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>46</div></div><div>
</div></div><div><div><div>47</div></div><div><span><span>  </span></span><span>// 완료 알림도 Reliable RPC이므로 같은 기준으로 막는다.</span></div></div><div><div><div>48</div></div><div><span>  </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Index</span><span> </span><span>=</span><span> </span><span>Requests</span><span>.</span><span>Num</span><span>() </span><span>-</span><span> </span><span>1</span><span>; </span><span>Index</span><span> </span><span>&gt;=</span><span> </span><span>0</span><span>; </span><span>--</span><span>Index</span><span>)</span></div></div><div><div><div>49</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>50</div></div><div><span>    </span><span>if</span><span> (</span><span>Channel</span><span>-&gt;</span><span>NumOutRec</span><span> </span><span>&gt;=</span><span> </span><span>OutRecLimit</span><span>)</span></div></div><div><div><div>51</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>52</div></div><div><span>      </span><span>break</span><span>;</span></div></div><div><div><div>53</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>54</div></div><div>
</div></div><div><div><div>55</div></div><div><span>    </span><span>if</span><span> (</span><span>Requests</span><span>[</span><span>Index</span><span>].</span><span>ChunksSent</span><span> </span><span>&gt;=</span><span> </span><span>Requests</span><span>[</span><span>Index</span><span>].</span><span>GetNumChunks</span><span>(</span><span>ChunkSize</span><span>))</span></div></div><div><div><div>56</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>57</div></div><div><span>      </span><span>ClientNotifyCompleted</span><span>(</span><span>Requests</span><span>[</span><span>Index</span><span>].</span><span>Tag</span><span>);</span></div></div><div><div><div>58</div></div><div><span>      </span><span>Requests</span><span>.</span><span>RemoveAt</span><span>(</span><span>Index</span><span>);</span></div></div><div><div><div>59</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>60</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>61</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>ClientReceiveChunk</code>와 <code>ClientNotifyCompleted</code>는 라우터 컴포넌트의 Reliable Client RPC다. 컴포넌트의 RPC는 소유 액터의 채널로 나가므로 라우터가 확인하는 채널과 실제로 번치가 쌓이는 채널이 같다.</p><section><h3>흐름 제어에 쓰는 값<a href="#흐름-제어에-쓰는-값"><span>#</span></a></h3><p><code>UActorChannel::NumOutRec</code>은 그 채널에서 보냈지만 아직 수신 확인을 받지 못한 Reliable 번치의 수다. 네트워크 ACK를 받으면 줄어든다. 별도의 ack RPC 없이 전송 큐의 밀린 정도를 볼 수 있지만, 클라이언트가 레코드를 디코딩하거나 게임 상태에 반영했는지는 알 수 없다.</p><p>전송을 멈추는 임계값은 버퍼 전체가 아니라 4분의 1로 잡았다. 같은 채널로 캐릭터의 다른 Reliable RPC도 나가기 때문이다. 스트리밍이 버퍼를 다 채우면 그 순간 발생한 게임플레이 RPC가 연결을 끊는 원인이 된다. 낮은 임계값은 다른 RPC가 들어올 여유를 두기 위한 선택이며 나머지 4분의 3을 예약하는 기능은 아니다.</p><p>ACK가 늦게 오는 연결에서는 <code>NumOutRec</code>이 천천히 줄고 그만큼 다음 조각도 늦게 나간다. 고정된 틱 간격과 조각 크기 안에서 전송을 계속할지를 채널 상태로 판단한다.</p><p>다만 확인하는 것은 이미 큐에 쌓인 번치 수다. 다음 RPC가 몇 바이트이고 몇 개 번치로 나뉠지는 계산하지 않는다. 큰 조각 하나가 임계값을 넘어 버퍼를 채울 수도 있으므로 이 방식만으로 오버플로를 완전히 막을 수는 없다. 작은 레코드를 묶어 보내는 조건에서 순간적인 전송 집중을 완화하는 흐름 제어다.</p><p><img alt="" loading="lazy" width="1536" height="1046" src="/_astro/rpc-buffer-flow.Cjlh_Wcu_ZTGf2f.webp" /></p></section><section><h3>전송 자체는 엔진에 맡긴다<a href="#전송-자체는-엔진에-맡긴다"><span>#</span></a></h3><p>이 구조에는 시퀀스 번호, 재조립 버퍼, 재전송 타이머가 없다. 같은 액터 채널의 Reliable RPC 순서와 재전송은 엔진에 맡기고 라우터는 “언제 보낼 것인가”를 정한다. 조각 하나는 최대 레코드 100개를 그대로 담은 RPC 한 번이라 클라이언트는 조각이 도착하는 대로 바로 디코딩할 수 있다. 나눠 보내는 것만으로 총 전송량이 줄어드는 것은 아니며 RPC 호출마다 전송 부가 정보도 붙는다.</p><p>접속 시의 일괄 전송과 플레이 중의 변경도 같은 경로를 쓴다. 소켓은 바뀐 항목을 버퍼에 모아 두었다가 틱에서 라우터에 넘긴다. 접속 시에는 버퍼에 전체가 들어 있고 플레이 중에는 방금 바뀐 몇 개만 들어 있다는 차이뿐이다.</p></section></section>
<section><h2>소켓 쪽 인코딩<a href="#소켓-쪽-인코딩"><span>#</span></a></h2><p>레코드에는 항목을 복원하는 데 필요한 최소한만 넣는다. 퀘스트 진행도라면 퀘스트 ID, 목표 ID, 진행 수치다. 구조체의 <code>NetSerialize</code>를 <code>FMemoryWriter</code>에 대고 호출해 바이트 배열을 만든다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>TArray</span><span>&lt;</span><span>FStreamRecord</span><span><span>&gt; </span><span>UQuestComponent</span><span>::</span></span><span>EncodePendingProgress</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>FStreamRecord</span><span>&gt; </span><span>Records</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>Records</span><span>.</span><span>Reserve</span><span>(</span><span>PendingProgress</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>for</span><span> (</span><span>FQuestProgressNet</span><span>&amp;</span><span> </span><span>Progress</span><span> : </span><span>PendingProgress</span><span>)</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>FStreamRecord</span><span>&amp;</span><span> </span><span>Record</span><span> </span><span>=</span><span> </span><span>Records</span><span>.</span><span>AddDefaulted_GetRef</span><span>();</span></div></div><div><div><div>9</div></div><div><span>    </span><span>FMemoryWriter</span><span> </span><span>Writer</span><span>(</span><span>Record</span><span>.</span><span>Bytes</span><span>);</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>    </span><span>bool</span><span> </span><span>bSuccess</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>12</div></div><div><span>    </span><span>Progress</span><span>.</span><span>NetSerialize</span><span>(</span><span>Writer</span><span>, </span><span>nullptr</span><span>, </span><span>bSuccess</span><span>);</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>  </span><span>PendingProgress</span><span>.</span><span>Reset</span><span>();</span></div></div><div><div><div>16</div></div><div><span>  </span><span>return</span><span> </span><span>Records</span><span>;</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>클라이언트의 <code>DecodeRecords()</code>는 같은 구조체를 <code>FMemoryReader</code>로 읽어 조회용 맵에 반영한다. 지도 마커는 원래 복제 프로퍼티 배열이었는데 이 경로로 옮기면서 추가, 수정, 삭제를 각각의 태그로 나눴다.</p></section>
<section><h2>개수로 검증하고 다시 보내기<a href="#개수로-검증하고-다시-보내기"><span>#</span></a></h2><p>Reliable RPC의 재전송은 도착한 조각을 애플리케이션이 반영하는 것까지 보장하지는 않는다. 클라이언트의 라우터는 태그에 등록된 소켓이 없으면 조각을 버린다. 소켓은 <code>BeginPlay()</code>에서 등록되므로, 등록 전에 도착한 조각은 사라진다. 완료 알림도 같은 방식으로 버린다.</p><p>퀘스트 소켓은 완료 알림을 받은 뒤 다음과 같이 개수를 확인한다.</p><ol>
<li>클라이언트는 <code>OnStreamCompleted()</code>를 받으면 30초 뒤 항목 수를 서버에 보고하도록 타이머를 등록한다.</li>
<li>서버는 자신의 항목 수와 비교한다.</li>
<li>다르면 전체 항목을 다시 버퍼에 채운다. 다음 틱에 같은 경로로 재전송된다.</li>
</ol><p>내용을 비교하지 않고 개수만 본다. 해시나 항목별 비교에 비해 검출력은 낮지만, 조각이 빠져 항목 수가 달라진 경우는 검출할 수 있다. 폴리지 리스폰 포인트 소켓은 추가·삭제 태그의 완료 알림을 받으면 지연 없이 개수를 보고하고, 불일치하면 서버가 전체 새로고침 데이터를 보낸다. 반면 지도 마커 소켓의 완료 콜백은 비어 있고, 지도 안개 소켓은 받은 내용을 그리는 데 쓴다. 검증은 라우터의 공통 기능이 아니라 소켓별 처리다.</p><p>소켓이 등록되기 전에 일부 조각을 잃었더라도 완료 알림이 등록 후에 도착하면 이 검증에 진입할 수 있다. 완료 알림까지 등록 전에 버려지면 콜백이 실행되지 않으므로 이 경로만으로 복구되지 않는다.</p><p><img alt="" loading="lazy" width="1536" height="1024" src="/_astro/rpc-registration-recovery.C82SlmVi_28aASM.webp" /></p></section>
<section><h2>같은 기준으로 피격 전파 고르기<a href="#같은-기준으로-피격-전파-고르기"><span>#</span></a></h2><p><img alt="" loading="lazy" width="1280" height="720" src="/_astro/wave-base-defense.Bm5v2c-q_2gyXTK.webp" /></p><p><code>NumOutRec</code>을 읽는 방식은 스트리밍 밖에서도 썼다. 좀비가 피격될 때의 연출 전파다.</p><p>웨이브에서는 수많은 좀비가 동시에 맞는다. 이것을 Reliable Multicast로 보내면 모든 클라이언트의 Reliable 버퍼가 피격 이벤트로 채워진다. 전부 Unreliable로 보내면 바로 앞에서 때린 좀비의 피격 반응이 빠질 수 있다.</p><p>그래서 Multicast 대신 서버가 플레이어를 순회하며 거리와 채널 상태로 연결마다 따로 판단했다. 아래 표의 거리와 임계값은 프로젝트 코드의 기본 설정이다. 예시는 RPC가 나가는 수신자 캐릭터의 채널을 확인하도록 작성했다.</p>

<table><thead><tr><th>조건</th><th>처리</th></tr></thead><tbody><tr><td>피격 대상과 300m 이상 떨어져 있음</td><td>보내지 않음</td></tr><tr><td>150m 미만이고 수신자 채널의 <code>NumOutRec</code>이 버퍼의 4분의 1 이하</td><td>Reliable</td></tr><tr><td>그 외</td><td>Unreliable</td></tr></tbody></table><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>BroadcastHit</span><span>(</span><span>const</span><span> </span><span>FHitInfoNet</span><span>&amp;</span><span><span> </span><span>HitInfo</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>OutRecLimit</span><span> </span><span>=</span><span> </span><span>RELIABLE_BUFFER</span><span> </span><span>/</span><span> </span><span>4</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>double</span><span> </span><span>ReliableDistSq</span><span> </span><span>=</span><span> FMath::</span><span>Square</span><span>(</span><span>15000.0</span><span>);</span><span> // 150m. 언리얼 단위는 cm다.</span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>double</span><span> </span><span>SkipDistSq</span><span> </span><span>=</span><span> FMath::</span><span>Square</span><span>(</span><span>30000.0</span><span>);</span><span> // 300m</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>  </span><span>for</span><span> (</span><span>FConstPlayerControllerIterator</span><span> </span><span>It</span><span> </span><span>=</span><span> </span><span>GetWorld</span><span>()-&gt;</span><span>GetPlayerControllerIterator</span><span>(); </span><span>It</span><span>; </span><span>++</span><span>It</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>APlayerController</span><span>*</span><span> </span><span>PlayerController</span><span> </span><span>=</span><span> </span><span>It</span><span>-&gt;</span><span>Get</span><span>();</span></div></div><div><div><div>10</div></div><div><span>    </span><span>AMyCharacter</span><span>*</span><span> </span><span>Receiver</span><span> </span><span>=</span><span> </span><span>PlayerController</span><span> </span><span>?</span><span> </span><span>Cast</span><span>&lt;</span><span>AMyCharacter</span><span>&gt;(</span><span>PlayerController</span><span>-&gt;</span><span>GetPawn</span><span>()) </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>11</div></div><div><span>    </span><span>UNetConnection</span><span>*</span><span> </span><span>Connection</span><span> </span><span>=</span><span> </span><span>PlayerController</span><span> </span><span>?</span><span> </span><span>PlayerController</span><span>-&gt;</span><span>GetNetConnection</span><span>() </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>12</div></div><div><span>    </span><span>if</span><span> (</span><span>Receiver</span><span> </span><span>==</span><span> </span><span>nullptr</span><span> </span><span>||</span><span> </span><span>Connection</span><span> </span><span>==</span><span> </span><span>nullptr</span><span>)</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>14</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>    </span><span>const</span><span> </span><span>double</span><span> </span><span>DistSq</span><span> </span><span>=</span><span> FVector::</span><span>DistSquared</span><span>(</span><span>Receiver</span><span>-&gt;</span><span>GetActorLocation</span><span>(), </span><span>GetActorLocation</span><span>());</span></div></div><div><div><div>18</div></div><div><span>    </span><span>if</span><span> (</span><span>DistSq</span><span> </span><span>&gt;=</span><span> </span><span>SkipDistSq</span><span>)</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>20</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>// RPC가 실제로 쌓이는 수신자 캐릭터의 채널을 확인한다.</span></div></div><div><div><div>24</div></div><div><span>    </span><span>const</span><span> </span><span>UActorChannel</span><span>*</span><span> </span><span>Channel</span><span> </span><span>=</span><span> </span><span>Connection</span><span>-&gt;</span><span>FindActorChannelRef</span><span>(</span><span>Receiver</span><span>);</span></div></div><div><div><div>25</div></div><div><span>    </span><span>const</span><span> </span><span>bool</span><span> </span><span>bHasRoom</span><span> </span><span>=</span><span> </span><span>Channel</span><span> </span><span>&amp;&amp;</span><span> </span><span>Channel</span><span>-&gt;</span><span>NumOutRec</span><span> </span><span>&lt;=</span><span> </span><span>OutRecLimit</span><span>;</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>    </span><span>if</span><span> (</span><span>DistSq</span><span> </span><span>&lt;</span><span> </span><span>ReliableDistSq</span><span> </span><span>&amp;&amp;</span><span> </span><span>bHasRoom</span><span>)</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>29</div></div><div><span>      </span><span>Receiver</span><span>-&gt;</span><span>ClientHandleHit_Reliable</span><span>(</span><span>this</span><span>, </span><span>HitInfo</span><span>);</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>31</div></div><div><span>    </span><span>else</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>33</div></div><div><span>      </span><span>Receiver</span><span>-&gt;</span><span>ClientHandleHit_Unreliable</span><span>(</span><span>this</span><span>, </span><span>HitInfo</span><span>);</span></div></div><div><div><div>34</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>35</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>36</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>RPC는 피격된 좀비가 아니라 수신자 자신의 캐릭터에서 호출한다. Client RPC는 소유 연결로만 가므로, 수신자가 소유한 액터를 통해야 연결마다 다른 신뢰성을 고를 수 있다. 피격 대상은 RPC의 인자로 넘긴다.</p><p>프로젝트 원본은 <code>PlayerController</code>의 채널을 확인하고 RPC는 <code>PlayerCharacter</code>에서 호출한다. 두 액터의 채널은 다르므로 원본의 값이 실제 피격 RPC가 쌓이는 큐를 나타내지는 않는다. 위 예시에서 <code>Receiver</code>의 채널을 확인한 것은 이 차이를 바로잡은 설명용 수정이며 프로젝트에 적용한 변경으로 제시하는 것은 아니다.</p><p>선택 기준은 가까운 피격을 가능한 한 Reliable로 보내되, 검사한 채널의 버퍼가 차오르면 Unreliable로 낮추는 것이다. 피격 연출의 일부 유실을 허용하고 Reliable 전송이 계속 쌓이는 것을 피하려 했다. 이 연결별 Client RPC 경로는 몽타주 재생 경로와도 다르다. 몽타주의 일부 Multicast 경로는 가까운 플레이어가 한 명이라도 있으면 전체 호출을 Reliable로 고른다.</p></section>
<section><h2>남은 제약<a href="#남은-제약"><span>#</span></a></h2><ul>
<li>조각 크기를 레코드 개수로만 정한다. 퀘스트처럼 ID와 수치를 담는 작은 레코드에서는 다루기 쉽지만, 큰 레코드까지 지원하려면 조각의 바이트 크기와 다음 RPC가 사용할 번치 수를 고려해야 한다.</li>
<li>큐 앞쪽의 요청이 막히면 뒤쪽도 함께 기다린다. 태그별 우선순위가 없다.</li>
<li>검증은 개수만 본다. 개수가 같고 내용이 다른 경우는 잡지 못한다.</li>
<li>소켓 등록 전에 도착한 조각과 완료 알림을 잠시 보관했다가 등록 시점에 넘기면 이 등록 순서로 인한 유실을 피할 수 있다. 개수 검증과 별도로 보완할 부분이다.</li>
</ul></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/remote-procedure-calls-in-unreal-engine" target="_blank">Remote Procedure Calls in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/replicated-object-execution-order-in-unreal-engine" target="_blank">Replicated Object Execution Order in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UChannel" target="_blank">UChannel API</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/notd-zombie-horde-cost/</id>
      <title type="text">다수 좀비의 서버 비용과 전송량 줄이기</title>
      <published>2026-09-28T00:00:00.000Z</published>
      <updated>2026-09-28T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/notd-zombie-horde-cost/"/>
      <summary type="text">Night of the Dead에서 웨이브의 좀비 수백 마리를 Dedicated Server와 클라이언트가 감당하도록, 거리 순위 기반 LOD로 이동 시뮬레이션과 전송률을 조절하고 애니메이션, 길찾기, 상태 복제 비용을 줄인 방법을 정리한다.</summary>
      <content type="html"><![CDATA[<p><a href="/projects/night-of-the-dead/">Night of the Dead</a>의 웨이브에서는 기본 설정으로 좀비가 최대 200마리까지 동시에 기지로 몰려온다. 이 상한은 난이도 설정으로 바꿀 수 있고, 넘치는 좀비는 대기열에서 자리가 나기를 기다린다. 좀비 한 마리에는 이동 시뮬레이션, 애니메이션, AI 질의, 길찾기, 이동 동기화가 붙고 서버는 이것을 플레이어 수와 무관하게 전부 계산한다.</p>
<p><img alt="" loading="lazy" width="1280" height="720" src="/_astro/zombies-on-wall.kzC1N9uC_1KkNPC.webp" /></p>
<p>모든 좀비를 같은 품질로 처리할 수는 없었다. 방향은 하나였다. 플레이어에게 중요한 좀비를 골라 그쪽에 비용을 쓰고 나머지는 단계적으로 낮춘다. 이 글은 프로젝트가 그 기준을 어떻게 정하고 어디에 적용했는지를 정리한다.</p>
<section><h2>기준: 거리 LOD와 거리 순위<a href="#기준-거리-lod와-거리-순위"><span>#</span></a></h2><p>중요도의 기준은 두 가지다.</p><ul>
<li>거리 LOD: 가장 가까운 플레이어와의 거리로 LOD0부터 LOD3, 그리고 Invisible까지 다섯 단계를 정한다. 소스의 기본 경계는 6m, 12m, 30m, 100m다.</li>
<li>거리 순위: 전체 좀비를 가까운 순으로 정렬했을 때의 순번이다.</li>
</ul><p>거리만 쓰면 웨이브가 기지 앞에 몰렸을 때 대부분의 좀비가 LOD0이 된다. 그 상황이 가장 비용이 큰 순간인데 기준이 아무것도 걸러 내지 못한다. 순위는 “가장 가까운 30마리”처럼 고품질 대상의 수를 고정해 준다.</p><section><h3>서버와 클라이언트의 차이<a href="#서버와-클라이언트의-차이"><span>#</span></a></h3><p>같은 계산이 서버와 클라이언트 양쪽에서 돌지만 “가깝다”의 기준이 다르다.</p><ul>
<li>클라이언트는 로컬 플레이어와의 거리를 쓴다. 카메라 방향과 FOV로 화면 밖이라고 판정하고 LOD1 거리보다 멀면 Invisible로 본다.</li>
<li>Dedicated Server에는 카메라가 없다. 모든 플레이어 폰과의 거리 중 최솟값을 쓴다. 한 명에게라도 가까우면 그 좀비는 높은 단계를 유지한다.</li>
</ul></section><section><h3>한 프레임에 계산하는 수를 고정<a href="#한-프레임에-계산하는-수를-고정"><span>#</span></a></h3><p>좀비마다 틱에서 모든 플레이어와의 거리를 계산하면 좀비 수와 플레이어 수의 곱만큼 비용이 든다. 계산을 GameState에 붙인 매니저 한 곳으로 모으고 한 프레임에 처리하는 수를 고정했다. 좀비는 틱에서 매니저에 갱신 요청만 등록한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UZombieOptimizeManager</span><span>::</span></span><span>UpdateDistanceRanks</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>NumZombies</span><span> </span><span>=</span><span> </span><span>Zombies</span><span>.</span><span>Num</span><span>();</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>NumToProcess</span><span> </span><span>=</span><span> FMath::</span><span>Min</span><span>(</span><span>MaxDistanceUpdatesPerFrame</span><span>, </span><span>NumZombies</span><span> </span><span>-</span><span> </span><span>Cursor</span><span>);</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Step</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Step</span><span> </span><span>&lt;</span><span> </span><span>NumToProcess</span><span>; </span><span>++</span><span>Step</span><span>)</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>FZombieEntry</span><span>&amp;</span><span> </span><span>Entry</span><span> </span><span>=</span><span> </span><span>Zombies</span><span>[</span><span>Cursor</span><span>++</span><span>];</span></div></div><div><div><div>9</div></div><div><span>    </span><span>Entry</span><span>.</span><span>DistSq</span><span> </span><span>=</span><span> </span><span>GetMinDistSqToViewers</span><span>(</span><span>Entry</span><span>.</span><span>Location</span><span>);</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>// 한 바퀴를 다 돌았을 때만 정렬하고 순위를 기록한다.</span></div></div><div><div><div>13</div></div><div><span>  </span><span>if</span><span> (</span><span>Cursor</span><span> </span><span>&gt;=</span><span> </span><span>NumZombies</span><span>)</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>    </span><span>Zombies</span><span>.</span><span>Sort</span><span>([](</span><span>const</span><span> </span><span>FZombieEntry</span><span>&amp;</span><span><span> </span><span>A</span><span>, </span></span><span>const</span><span> </span><span>FZombieEntry</span><span>&amp;</span><span><span> </span><span>B</span><span>) { </span></span><span>return</span><span> </span><span>A</span><span>.</span><span>DistSq</span><span> </span><span>&lt;</span><span> </span><span>B</span><span>.</span><span>DistSq</span><span>; });</span></div></div><div><div><div>16</div></div><div><span>    </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Rank</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Rank</span><span> </span><span>&lt;</span><span> </span><span>NumZombies</span><span>; </span><span>++</span><span>Rank</span><span>)</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>18</div></div><div><span>      </span><span>Zombies</span><span>[</span><span>Rank</span><span>].</span><span>Component</span><span>-&gt;</span><span>SetDistanceRank</span><span>(</span><span>Rank</span><span>);</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>20</div></div><div><span>    </span><span>Cursor</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>22</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>소스의 거리 계산 한도는 프레임당 64마리, 이동 관련 값 갱신 한도는 프레임당 50마리다. 대상 200마리가 유지되면 거리 계산 한 바퀴에 네 프레임이 걸린다. 매 프레임 최신 순위를 얻는 대신 거리 계산을 여러 프레임에 나누는 선택이다.</p><p>전체 대상을 정렬하고 순위를 기록하는 작업은 한 바퀴가 끝난 프레임에 남는다. 서버가 한 바퀴 동안 계산하는 거리의 총량도 좀비 수와 플레이어 수의 곱에 비례한다. 따라서 64라는 한도는 거리 계산을 분산하는 기준이며, 매니저 전체의 프레임 비용이 일정하다는 뜻은 아니다.</p></section></section>
<section><h2>LOD가 바꾸는 것<a href="#lod가-바꾸는-것"><span>#</span></a></h2><p>LOD가 바뀔 때 한 곳에서 다음을 조절한다.</p>

<table><thead><tr><th>단계</th><th>이동 시뮬레이션 최대 반복 수</th><th>메시</th><th>그림자</th><th>캡슐 Hit 이벤트</th></tr></thead><tbody><tr><td>LOD0</td><td>4</td><td>표시</td><td>켬</td><td>켬</td></tr><tr><td>LOD1</td><td>2</td><td>표시</td><td>끔</td><td>켬</td></tr><tr><td>LOD2</td><td>2</td><td>표시</td><td>끔</td><td>끔</td></tr><tr><td>LOD3</td><td>1</td><td>표시</td><td>끔</td><td>끔</td></tr><tr><td>Invisible</td><td>2 (틱 간격 1초)</td><td>숨김, 본의 물리 갱신 생략</td><td>끔</td><td>끔</td></tr></tbody></table><p>표는 소스에 지정한 단계별 설정이다. 이동 시뮬레이션 최대 반복 수는 <code>UCharacterMovementComponent::MaxSimulationIterations</code>로, 서버에서 한 틱의 이동을 몇 번까지 나눠 계산할지 정한다. Invisible 단계는 이동 컴포넌트의 틱 간격 자체를 1초로 늘린다. 반복 한도를 줄이면 긴 틱을 처리할 때 이동과 충돌 계산이 거칠어질 수 있으므로, 가까운 좀비에는 더 큰 한도를 둔다.</p><p>부드러운 회전은 순위와 프레임률을 함께 본다. 순위가 30 미만이고 프레임률이 30을 넘을 때만 보간한다.</p><section><h3>이동 동기화 전송률<a href="#이동-동기화-전송률"><span>#</span></a></h3><p>좀비의 위치는 서버가 주기적으로 클라이언트에 보낸다. 이 전송률도 같은 LOD와 순위로 정한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>int32</span><span><span> </span><span>UZombieLODComponent</span><span>::</span></span><span>CalcSendRateGrade</span><span>() </span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> (</span><span>LOD</span><span> </span><span>==</span><span> EZombieLOD::</span><span>Invisible</span><span>)</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>return</span><span> </span><span>InvisibleGrade</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>// 등급 숫자가 작을수록 자주 보낸다. LOD0~3이 등급 0~3에 대응한다.</span></div></div><div><div><div>9</div></div><div><span>  </span><span>int32</span><span> </span><span>Grade</span><span> </span><span>=</span><span> </span><span><span>static_cast</span><span>&lt;</span></span><span>int32</span><span>&gt;</span><span>(</span><span>LOD</span><span>);</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>// 거리 순위가 30 이하인 좀비는 두 등급 올린다.</span></div></div><div><div><div>12</div></div><div><span>  </span><span>if</span><span> (</span><span>DistanceRank</span><span> </span><span>&lt;=</span><span> </span><span>30</span><span>)</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>14</div></div><div><span>    </span><span>Grade</span><span> </span><span>=</span><span> FMath::</span><span>Max</span><span>(</span><span>Grade</span><span> </span><span>-</span><span> </span><span>2</span><span>, </span><span>0</span><span>);</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>// 공격 중인 좀비는 등급 숫자를 2 이하로 제한한다.</span></div></div><div><div><div>18</div></div><div><span>  </span><span>if</span><span> (</span><span>Zombie</span><span>-&gt;</span><span>IsAttacking</span><span>())</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>20</div></div><div><span>    </span><span>Grade</span><span> </span><span>=</span><span> FMath::</span><span>Min</span><span>(</span><span>Grade</span><span>, </span><span>2</span><span>);</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>  </span><span>return</span><span> </span><span>Grade</span><span>;</span></div></div><div><div><div>24</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>소스의 등급별 기본 전송률은 초당 12, 8, 6, 4, 3회이고 Invisible은 1회다. LOD0–3은 등급 0–3에 대응하므로 이 경로에서 사용하는 기본 전송률은 12, 8, 6, 4회다. 배열의 3회 설정은 이 LOD 매핑으로 선택되지 않는다. 공격 중에는 등급을 2 이하로 제한하지만 Invisible은 그대로 1회다.</p><p><img alt="" loading="lazy" width="1536" height="1024" src="/_astro/zombie-cost-routing.DQ221NVL_ZsVyKL.webp" /></p></section></section>
<section><h2>보내지 않는 상태<a href="#보내지-않는-상태"><span>#</span></a></h2><p>전송률을 낮추는 것보다 확실한 방법은 보내지 않는 것이다.</p><section><h3>좀비의 체력과 상태<a href="#좀비의-체력과-상태"><span>#</span></a></h3><p>기본 상태 컴포넌트의 <code>CharacterHealth</code>, <code>CharacterStatus</code>, <code>EquipmentStatusArray_Net</code>은 <code>COND_OwnerOnly</code>로 등록했다. 플레이어의 상태 컴포넌트는 서브클래스에서 이 프로퍼티들의 복제 조건을 다시 지정했다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UPlayerStatusComponent</span><span>::</span></span><span>GetLifetimeReplicatedProps</span><span>(</span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>GetLifetimeReplicatedProps</span><span>(</span><span>OutLifetimeProps</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>// 기본 클래스의 COND_OwnerOnly를 풀어 다른 플레이어에게도 보낸다.</span></div></div><div><div><div>6</div></div><div><span>  </span><span>RESET_REPLIFETIME_CONDITION</span><span>(</span><span>UCharacterStatusComponent</span><span>, </span><span>CharacterHealth</span><span>, </span><span>COND_None</span><span>);</span></div></div><div><div><div>7</div></div><div><span>  </span><span>RESET_REPLIFETIME_CONDITION</span><span>(</span><span>UCharacterStatusComponent</span><span>, </span><span>CharacterStatus</span><span>, </span><span>COND_None</span><span>);</span></div></div><div><div><div>8</div></div><div><span>  </span><span>RESET_REPLIFETIME_CONDITION</span><span>(</span><span>UCharacterStatusComponent</span><span>, </span><span>EquipmentStatusArray_Net</span><span>, </span><span>COND_None</span><span>);</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>좀비에는 소유 연결이 없으므로 이 프로퍼티들은 클라이언트에 복제되지 않는다. 클라이언트의 피격 반응은 피격 이벤트로 전달되는 정보로 처리한다. 사망은 별도로 복제하는 <code>DeathInfo</code>와 <code>OnRep_DeathInfo</code>로 처리한다. 체력 프로퍼티를 보내지 않는 것과 사망 상태까지 보내지 않는 것은 다르다.</p></section><section><h3>애니메이션 몽타주<a href="#애니메이션-몽타주"><span>#</span></a></h3><p>엔진은 루트 모션 몽타주의 재생 상태를 <code>RepRootMotion</code>으로 복제한다. 이것을 기본 캐릭터에서 끄고 플레이어에서만 다시 켰다.</p><p>좀비의 공격과 피격 몽타주는 별도의 재생 이벤트로 보낸다. 공격 컴포넌트의 경로는 <code>UAnimMontage*</code> 에셋 참조와 재생 속도를 보낸다. 피격 컴포넌트는 <code>FHitAnimPlaybackData</code>를 보내고 그 안의 난수 시드로 양쪽에서 같은 변형을 고른다.</p><p><code>LFMontageManagerComponent</code>에는 Gameplay Tag와 인덱스로 몽타주 묶음의 항목을 골라 보내는 Unreliable Multicast도 있다. 이 경로와 위의 공격·피격 컴포넌트 경로는 구분해야 한다.</p><p>공격·피격 몽타주의 Multicast는 플레이어 한 명이라도 가까우면 Reliable을 선택한다. 공격의 기본 기준 거리는 2m다. 수신 연결마다 고르는 방식이 아니라 해당 Multicast 전체의 신뢰성을 고른다. 연결별로 거리와 채널 상태를 확인하는 피격 정보 전파는 별도 경로이며, 그 내용은 <a href="/posts/notd-reliable-rpc-data-streaming/">Reliable 버퍼 상태를 보고 대용량 데이터 나눠 보내기</a>에 적었다.</p><p>이벤트 방식의 한계는 재생 도중에 복제 범위에 들어온 클라이언트가 그 몽타주를 보지 못한다는 것이다. 이 경우를 위한 별도 처리는 두지 않았다.</p></section></section>
<section><h2>애니메이션 비용<a href="#애니메이션-비용"><span>#</span></a></h2><section><h3>서버의 포즈 틱<a href="#서버의-포즈-틱"><span>#</span></a></h3><p>Dedicated Server는 렌더링을 하지 않으므로 포즈를 계산할 이유가 없어 보인다. 하지만 좀비의 근접 공격은 애니메이션에 맞춰 판정한다. 서버가 포즈를 전혀 갱신하지 않으면 판정할 수 없다.</p><p>그래서 서버의 포즈 틱을 전투 중에만 켠다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AZombie</span><span>::</span></span><span>UpdateAnimTickOption</span><span>(</span><span>float</span><span><span> </span><span>DeltaSeconds</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>bool</span><span> </span><span>bInGracePeriod</span><span> </span><span>=</span><span> </span><span>AnimTickGraceElapsed</span><span> </span><span>&lt;</span><span> </span><span>AnimTickGraceDuration</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>IsInCombat</span><span>() </span><span>||</span><span> </span><span>bInGracePeriod</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>AnimTickGraceElapsed</span><span> </span><span>+=</span><span> </span><span>DeltaSeconds</span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>// 근접 공격 판정에 본 위치가 필요하므로 서버에서도 포즈와 본을 갱신한다.</span></div></div><div><div><div>9</div></div><div><span>    </span><span>GetMesh</span><span>()-&gt;</span><span>VisibilityBasedAnimTickOption</span><span> </span><span>=</span><span> EVisibilityBasedAnimTickOption::</span><span>AlwaysTickPoseAndRefreshBones</span><span>;</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>11</div></div><div><span>  </span><span>else</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>13</div></div><div><span>    </span><span>GetMesh</span><span>()-&gt;</span><span>VisibilityBasedAnimTickOption</span><span> </span><span>=</span><span> EVisibilityBasedAnimTickOption::</span><span>OnlyTickMontagesWhenNotRendered</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>15</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>평소에는 몽타주의 진행만 틱하고 본은 갱신하지 않는다. 유예 시간은 전투가 끝난 직후나 특정 동작 직전에 본 갱신이 필요한 경우를 위해 외부에서 요청할 수 있게 둔 것이다.</p></section><section><h3>클라이언트: Significance와 애니메이션 예산<a href="#클라이언트-significance와-애니메이션-예산"><span>#</span></a></h3><p>클라이언트에서는 엔진의 Significance Manager에 좀비를 등록하고, 계산한 값을 Animation Budget Allocator용 메시 컴포넌트(<code>USkeletalMeshComponentBudgeted</code>)에 넘겼다. Animation Budget Allocator는 이 값이 높은 메시부터 애니메이션 평가를 배정하고 예산이 부족하면 낮은 쪽의 갱신 빈도를 줄인다.</p><p>좀비의 Significance는 거리 구간을 따로 두지 않고 위의 LOD를 재사용해 계산한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>float</span><span><span> </span><span>AZombie</span><span>::</span></span><span>CalcSignificance</span><span>(</span><span>const</span><span> </span><span>FTransform</span><span>&amp;</span><span><span> </span><span>ViewTransform</span><span>) </span></span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>float</span><span> </span><span>Value</span><span> </span><span>=</span><span> </span><span>BaseSignificanceByLOD</span><span>[</span><span>LOD</span><span>];</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>float</span><span> </span><span>MinValue</span><span> </span><span>=</span><span> </span><span>MinSignificanceByLOD</span><span>[</span><span>LOD</span><span>];</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>// 리슨 서버와 스탠드얼론에서는 거리 순위도 반영한다.</span></div></div><div><div><div>7</div></div><div><span>  </span><span>if</span><span> (</span><span>GetNetMode</span><span>() </span><span>!=</span><span> </span><span>NM_Client</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>Value</span><span> </span><span>*=</span><span> </span><span><span>1.</span><span>f</span></span><span> </span><span>-</span><span> </span><span>GetNormalizedDistanceRank</span><span>();</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>11</div></div><div><span>  </span><span>Value</span><span> </span><span>=</span><span> FMath::</span><span>Max</span><span>(</span><span>Value</span><span>, </span><span>MinValue</span><span>);</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>// 최소값 보정 다음에 카메라 방향에 따른 보정을 적용한다.</span></div></div><div><div><div>14</div></div><div><span>  </span><span>if</span><span> (</span><span>const</span><span> </span><span>APlayerCameraManager</span><span>*</span><span> </span><span>Camera</span><span> </span><span>=</span><span> </span><span>GetLocalCameraManager</span><span>())</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>16</div></div><div><span>    </span><span>Value</span><span> </span><span>=</span><span> FMath::</span><span>Max</span><span>(</span><span>Value</span><span> </span><span>-</span><span> </span><span>CalcViewPenalty</span><span>(</span><span>Camera</span><span>), </span><span><span>0.</span><span>f</span></span><span>);</span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>  </span><span>if</span><span> (</span><span>IsAttacking</span><span>())</span></div></div><div><div><div>20</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>21</div></div><div><span>    </span><span>Value</span><span> </span><span>+=</span><span> </span><span><span>2.</span><span>f</span></span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>  </span><span>if</span><span> (</span><span>IsGiant</span><span>())</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>26</div></div><div><span>    </span><span>Value</span><span> </span><span>+=</span><span> </span><span><span>1.</span><span>f</span></span><span>;</span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>28</div></div><div><span>  </span><span>return</span><span> </span><span>Value</span><span>;</span></div></div><div><div><div>29</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>최소값 보정은 카메라 보정보다 먼저 적용하므로 최종 값이 LOD별 최소값보다 낮아질 수 있다. 공격 중인 좀비와 거대 좀비에는 추가 점수를 준다. Significance 등록은 Dedicated Server에서 건너뛴다. 서버의 기준은 앞의 LOD이고 Significance는 화면에 그리는 쪽의 기준이다.</p><p>Update Rate Optimization은 별도 컴포넌트로 감쌌다. 약 1초 간격의 타이머로 조건을 다시 평가하며 평균 프레임률이 기준보다 높으면 URO를 끈다. 프레임에 여유가 있을 때는 품질을 낮출 이유가 없기 때문이다. 탑승이나 특수 연출처럼 애니메이션을 덮어쓰는 동안에도 끈다.</p><p>Animation Budget Allocator에 등록한 메시는 엔진이 URO를 비활성화한다. 두 기법은 적용 대상에 따라 구분해야 한다. 프로젝트의 URO 컴포넌트는 소유 액터의 스킨드 메시 전체를 대상으로 설정하며 Animation Budget Allocator에 등록된 메시를 제외하는 검사는 없다. 대신 이 컴포넌트는 수족관 물고기와 캐릭터 전시물처럼 여럿 배치되는 장식 개체에만 붙였고, 좀비는 Animation Budget Allocator만 사용해 두 기법이 같은 메시에 겹치지 않았다.</p></section></section>
<section><h2>길찾기 비용<a href="#길찾기-비용"><span>#</span></a></h2><section><h3>네비게이션 인보커<a href="#네비게이션-인보커"><span>#</span></a></h3><p>오픈월드라 내비메시는 인보커 주변에만 동적으로 생성했다. 인보커가 많고 넓을수록 서버가 내비메시 타일을 만드는 비용이 커진다. 모든 AI의 인보커를 기본으로 꺼 두고, 켜는 조건을 대상별로 정했다.</p>

<table><thead><tr><th>대상</th><th>켜는 조건</th></tr></thead><tbody><tr><td>웨이브 좀비</td><td>이동 태스크가 시작되고, 바닥에 서 있을 때</td></tr><tr><td>플레이어 건물</td><td>웨이브가 진행 중이고 500m 안에 플레이어가 있을 때</td></tr><tr><td>스포너</td><td>스폰이 가능한 상태이고 플레이어가 확인 거리 안에 있을 때</td></tr><tr><td>웨이브 경로</td><td>좀비가 지나갈 웨이포인트에 인보커 전용 액터를 배치</td></tr></tbody></table><p>웨이브 좀비는 스폰 직후에는 인보커가 꺼져 있고, 이동을 시작할 때 켠다. 좀비가 지나갈 웨이포인트에는 인보커 전용 액터를 미리 놓아 경로 주변의 내비메시를 먼저 만들어 둔다. 틱이 없고 복제되지 않는 액터다. 지나간 웨이포인트의 인보커는 제거하고 웨이브가 끝나면 전부 없앤다.</p><p>건물의 조건은 1초 타이머로 확인한다. 첫 실행을 난수만큼 늦춰 건물들이 같은 프레임에 검사하지 않게 했다.</p></section><section><h3>웨이브 좀비의 인지<a href="#웨이브-좀비의-인지"><span>#</span></a></h3><p>웨이브 좀비는 스폰될 때 AI Perception 컴포넌트를 제거한다. 웨이브 좀비는 주변을 살펴 대상을 찾을 필요가 없다. 대신 AI 컨트롤러가 0.5초 간격의 틱에서 가장 가까운 플레이어나 추종자를 대상으로 넣어 준다.</p></section><section><h3>EQS 결과 공유<a href="#eqs-결과-공유"><span>#</span></a></h3><p>웨이브의 좀비들은 같은 기지를 향해 거의 같은 EQS 질의를 반복한다. 어느 건물을 공격할지, 대상에게 어떻게 접근할지를 묻는 질의다. 바로 옆의 좀비가 방금 얻은 답은 대부분 나에게도 맞다.</p><p>좀비가 질의를 실행하기 전에 주변 좀비의 최근 결과를 먼저 찾는다. 다음 조건을 만족하는 결과가 있으면 질의를 건너뛰고 그 결과를 쓴다.</p><ul>
<li>같은 EQS 질의의 결과이며, 결과를 가진 좀비가 설정한 거리 안에 있다.</li>
<li>거대 좀비 여부와 공격 방식(근접, 원거리)이 같다.</li>
<li>질의의 옵션에 따라 같은 주 대상·대체 대상을 요구하거나, 같은 대상을 향하는 방향과 앞뒤 위치를 확인한다.</li>
</ul><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>bool</span><span><span> </span><span>UZombieQueryCache</span><span>::</span></span><span>TryReuse</span><span>(</span><span>const</span><span> </span><span>AZombie</span><span>*</span><span><span> </span><span>Self</span><span>, </span></span><span>const</span><span> </span><span>UEnvQuery</span><span>*</span><span><span> </span><span>Query</span><span>, </span></span><span>const</span><span> </span><span>FQueryReuseParams</span><span>&amp;</span><span><span> </span><span>Params</span><span>, </span></span><span>FQueryResult</span><span>&amp;</span><span><span> </span><span>OutResult</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>float</span><span> </span><span>MaxDistSq</span><span> </span><span>=</span><span> FMath::</span><span>Square</span><span>(</span><span>Params</span><span>.</span><span>bWideRange</span><span> </span><span>?</span><span> </span><span>WideRange</span><span> </span><span>:</span><span> </span><span>NarrowRange</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> </span><span>AZombie</span><span>*</span><span> </span><span>Other</span><span> : </span><span>Zombies</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>if</span><span> (</span><span>Other</span><span> </span><span>==</span><span> </span><span>Self</span><span> </span><span>||</span><span> </span><span>Other</span><span>-&gt;</span><span>IsGiant</span><span>() </span><span>!=</span><span> </span><span>Self</span><span>-&gt;</span><span>IsGiant</span><span>() </span><span>||</span><span> </span><span>Other</span><span>-&gt;</span><span>IsMelee</span><span>() </span><span>!=</span><span> </span><span>Self</span><span>-&gt;</span><span>IsMelee</span><span>())</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>// 질의 옵션에 따라 같은 대상을 요구하거나, 같은 대상을 비슷한 방향에서 뒤따르는지 확인한다.</span></div></div><div><div><div>13</div></div><div><span>    </span><span>if</span><span> (</span><span>Params</span><span>.</span><span>bRequireSameTarget</span><span> </span><span>&amp;&amp;</span><span> (</span><span>Self</span><span>-&gt;</span><span>GetTarget</span><span>() </span><span>==</span><span> </span><span>nullptr</span><span> </span><span>||</span><span> </span><span>Self</span><span>-&gt;</span><span>GetTarget</span><span>() </span><span>!=</span><span> </span><span>Other</span><span>-&gt;</span><span>GetTarget</span><span>()))</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>17</div></div><div><span>    </span><span>if</span><span> (</span><span>Params</span><span>.</span><span>bRequireSimilarApproach</span><span> </span><span>&amp;&amp;</span><span> </span><span>IsFollowingFromBehind</span><span>(</span><span>Self</span><span>, </span><span>Other</span><span>) </span><span>==</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>19</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>    </span><span>if</span><span> (</span><span>Self</span><span>-&gt;</span><span>GetSquaredDistanceTo</span><span>(</span><span>Other</span><span>) </span><span>&gt;=</span><span> </span><span>MaxDistSq</span><span>)</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>24</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>    </span><span>if</span><span> (</span><span>const</span><span> </span><span>FQueryResult</span><span>*</span><span> </span><span>Cached</span><span> </span><span>=</span><span> </span><span>FindResult</span><span>(</span><span>Other</span><span>, </span><span>Query</span><span>))</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>29</div></div><div><span>      </span><span>OutResult</span><span> </span><span>=</span><span> </span><span>*</span><span>Cached</span><span>;</span></div></div><div><div><div>30</div></div><div><span>      </span><span>RecordHit</span><span>(</span><span>Query</span><span>);</span></div></div><div><div><div>31</div></div><div><span>      </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>33</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>34</div></div><div><span>  </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>35</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>방향 조건에서는 두 좀비가 대상을 향하는 방향이 비슷하고, 질의하는 좀비가 결과를 가진 좀비보다 대상에서 더 멀 때만 재사용한다. 앞에서 먼저 질의한 좀비의 결과를 뒤따르는 좀비가 쓰는 구조다.</p><p>캐시를 남긴 좀비가 없으면 결과를 지운다. 액터 결과는 대상이 없거나 공격 가능한 대상이 아니게 되면 지우고 위치 같은 비액터 결과에는 기본 30초의 수명을 둔다. 모든 결과를 30초 뒤에 지우는 것은 아니다.</p><p><img alt="" loading="lazy" width="1536" height="1038" src="/_astro/eqs-reuse-conditions.BhstZ0p8_Zh6RTi.webp" /></p><p>캐시 사용 여부와 좀비 수 제한은 각각 설정으로 제어한다. 수 제한을 켠 경우 기본 기준은 100마리다. 적은 수에서도 무조건 공유하기보다 질의가 많이 겹치는 상황에 재사용 범위를 제한하는 선택이다.</p><p>적용 효과를 확인할 수 있도록 질의 종류별로 캐시 적중과 실패 횟수, 평균 시간을 기록하고 치트 명령으로 출력하게 했다.</p></section></section>
<section><h2>정리<a href="#정리"><span>#</span></a></h2>

<table><thead><tr><th>대상</th><th>기준</th><th>줄인 것</th></tr></thead><tbody><tr><td>이동 시뮬레이션</td><td>LOD</td><td>최대 반복 수, 틱 간격</td></tr><tr><td>이동 동기화</td><td>LOD, 순위, 공격 여부</td><td>전송률</td></tr><tr><td>체력과 상태</td><td>소유 연결 없음</td><td>지정한 상태 프로퍼티의 복제</td></tr><tr><td>몽타주</td><td>거리</td><td>루트 모션 복제 제외, 재생 이벤트의 신뢰성</td></tr><tr><td>서버 포즈</td><td>전투 여부</td><td>본 갱신</td></tr><tr><td>클라이언트 애니메이션</td><td>Significance</td><td>평가 빈도</td></tr><tr><td>내비메시</td><td>웨이브, 플레이어 거리</td><td>인보커 수</td></tr><tr><td>인지</td><td>웨이브 좀비 여부</td><td>Perception</td></tr><tr><td>EQS</td><td>좀비 수, 주변 결과</td><td>질의 횟수</td></tr></tbody></table><p>LOD와 순위는 이동 시뮬레이션, 이동 동기화와 애니메이션에서 재사용했다. 전투 판정에 필요한 서버 포즈와 사망 상태는 별도 경로로 유지했다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-budget-allocator-in-unreal-engine" target="_blank">Animation Budget Allocator in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/significance-manager-in-unreal-engine" target="_blank">Significance Manager in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/using-navigation-invokers-in-unreal-engine" target="_blank">Using Navigation Invokers in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/environment-query-system-in-unreal-engine" target="_blank">Environment Query System in Unreal Engine</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/notd-level-streaming-status-rpc/</id>
      <title type="text">접속 시 레벨 스트리밍 상태 RPC 줄이기</title>
      <published>2026-09-27T00:00:00.000Z</published>
      <updated>2026-09-27T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/notd-level-streaming-status-rpc/"/>
      <summary type="text">Night of the Dead에서 접속한 클라이언트에 스트리밍 레벨 상태를 보내는 RPC를 줄이기 위해 패키지 이름을 테이블 기반 코드로 바꾸고 플래그를 비트로 묶은 방법과, 그 앞뒤에 놓인 클라이언트 준비 게이트와 에셋 사전 로딩을 정리한다.</summary>
      <content type="html"><![CDATA[<p>클라이언트가 서버에 접속하면 엔진은 현재 스트리밍 레벨들의 상태를 한 번에 알려 준다. <code>AGameModeBase::ReplicateStreamingStatus()</code>가 월드의 모든 스트리밍 레벨을 순회하며 상태 배열을 만들고 <code>ClientUpdateMultipleLevelsStreamingStatus()</code>라는 Reliable Client RPC로 보낸다.</p>
<p>배열의 원소는 <code>FUpdateLevelStreamingLevelStatus</code>다. LOD 인덱스 하나, 로드 여부와 표시 여부 같은 bool 네 개, 그리고 레벨의 패키지 이름(<code>FName</code>)이 들어 있다. 숫자와 플래그는 몇 바이트에 불과하지만 패키지 이름은 <code>/Game/Maps/...</code>로 시작하는 경로 전체다. 엔진은 하드코딩된 이름이 아닌 <code>FName</code>을 문자열로 직렬화하므로, 원소 크기의 대부분은 이 경로 문자열이 차지한다.</p>
<p><a href="/projects/night-of-the-dead/">Night of the Dead</a>는 오픈월드라 스트리밍 레벨 수가 많았다. 접속할 때마다 <code>레벨 수 × 경로 길이</code>만큼의 문자열이 Reliable RPC 하나에 실렸다. 이 RPC가 여러 번치로 쪼개져 접속 직후의 Reliable 버퍼를 차지하면서 실제로 버퍼가 넘쳤다. 원소 크기를 키우는 것은 길이가 긴 패키지 이름이었다. 내용 대부분은 서버와 클라이언트가 이미 똑같이 알고 있는 문자열이어서 반복해서 보내는 경로를 코드로 대체했다.</p>
<section><h2>경로 대신 코드를 보낸다<a href="#경로-대신-코드를-보낸다"><span>#</span></a></h2><p>스트리밍 레벨의 목록은 빌드에 고정돼 있다. 양쪽이 같은 표를 가지고 있으면 경로 대신 표의 번호만 보내면 된다.</p><p>레벨 에셋을 한 행에 하나씩 담은 DataTable을 만들고 게임 인스턴스 서브시스템이 시작할 때 이 표를 읽어 양방향 맵을 만든다. 코드는 행 순서에 1을 더한 값이다. 0은 “코드 없음”으로 남겨 둔다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>ULevelStatusCoderSubsystem</span><span>::</span></span><span>BuildCodeMaps</span><span>(</span><span>const</span><span> </span><span>UDataTable</span><span>*</span><span><span> </span><span>Table</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>FLevelCodeRow</span><span>*</span><span>&gt; </span><span>Rows</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>Table</span><span>-&gt;</span><span>GetAllRows</span><span>(</span><span>FString</span><span>(), </span><span>Rows</span><span>);</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>NameToCode</span><span>.</span><span>Empty</span><span>(</span><span>Rows</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>7</div></div><div><span>  </span><span>CodeToName</span><span>.</span><span>Empty</span><span>(</span><span>Rows</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Index</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Index</span><span> </span><span>&lt;</span><span> </span><span>Rows</span><span>.</span><span>Num</span><span>(); </span><span>++</span><span>Index</span><span>)</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>if</span><span> (</span><span>Rows</span><span>[</span><span>Index</span><span>]-&gt;</span><span>Level</span><span>.</span><span>IsNull</span><span>())</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>13</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>    </span><span>const</span><span> </span><span>int16</span><span> </span><span>Code</span><span> </span><span>=</span><span> </span><span><span>static_cast</span><span>&lt;</span></span><span>int16</span><span>&gt;</span><span>(</span><span>Index</span><span> </span><span>+</span><span> </span><span>1</span><span>);</span></div></div><div><div><div>17</div></div><div><span>    </span><span>const</span><span> </span><span>FName</span><span> </span><span>PackageName</span><span>(</span><span>*</span><span>Rows</span><span>[</span><span>Index</span><span>]-&gt;</span><span>Level</span><span>.</span><span>GetLongPackageName</span><span>());</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>    </span><span>NameToCode</span><span>.</span><span>Add</span><span>(</span><span>PackageName</span><span>, </span><span>Code</span><span>);</span></div></div><div><div><div>20</div></div><div><span>    </span><span>CodeToName</span><span>.</span><span>Add</span><span>(</span><span>Code</span><span>, </span><span>PackageName</span><span>);</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>22</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div></section>
<section><h2>전송용 구조체<a href="#전송용-구조체"><span>#</span></a></h2><p>엔진의 구조체를 직접 고칠 수는 없으므로 같은 내용을 담는 전송용 구조체를 따로 두고 커스텀 <code>NetSerialize</code>를 붙였다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FCodedLevelStatus</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>7</div></div><div><span>  </span><span>FName</span><span> </span><span>PackageName</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>10</div></div><div><span>  </span><span>int16</span><span> </span><span>Code</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span><span> // 0은 표에 없는 레벨이다.</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>13</div></div><div><span>  </span><span>int8</span><span> </span><span>LODIndex</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span><span> // 엔진은 int32다. Encode()에서 MAX_int8로 제한한다.</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>16</div></div><div><span>  </span><span>bool</span><span> </span><span>bShouldBeLoaded</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>19</div></div><div><span>  </span><span>bool</span><span> </span><span>bShouldBeVisible</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>22</div></div><div><span>  </span><span>bool</span><span> </span><span>bShouldBlockOnLoad</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>25</div></div><div><span>  </span><span>bool</span><span> </span><span>bShouldBlockOnUnload</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>  </span><span>bool</span><span> </span><span>NetSerialize</span><span>(</span><span>FArchive</span><span>&amp;</span><span><span> </span><span>Ar</span><span>, </span></span><span>UPackageMap</span><span>*</span><span><span> </span><span>Map</span><span>, </span></span><span>bool&amp;</span><span><span> </span><span>bOutSuccess</span><span>)</span></span></div></div><div><div><div>28</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>// 받을 때는 이 값 대신 아래에서 읽은 다섯 번째 비트를 쓴다.</span></div></div><div><div><div>30</div></div><div><span>    </span><span>const</span><span> </span><span>bool</span><span> </span><span>bHasCode</span><span> </span><span>=</span><span> </span><span>Ar</span><span>.</span><span>IsSaving</span><span>() </span><span>&amp;&amp;</span><span> </span><span>Code</span><span> </span><span>!=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>// 보낼 때는 필드 값으로 비트를 채워 쓰고, 받을 때는 SerializeBits()가 읽은 값으로 덮어쓴다.</span></div></div><div><div><div>33</div></div><div><span>    </span><span>uint8</span><span> </span><span>Flags</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>34</div></div><div><span>    </span><span>Flags</span><span> </span><span><span>|</span><span>=</span></span><span> </span><span>bShouldBeLoaded</span><span> </span><span>&lt;&lt;</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>35</div></div><div><span>    </span><span>Flags</span><span> </span><span><span>|</span><span>=</span></span><span> </span><span>bShouldBeVisible</span><span> </span><span>&lt;&lt;</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>36</div></div><div><span>    </span><span>Flags</span><span> </span><span><span>|</span><span>=</span></span><span> </span><span>bShouldBlockOnLoad</span><span> </span><span>&lt;&lt;</span><span> </span><span>2</span><span>;</span></div></div><div><div><div>37</div></div><div><span>    </span><span>Flags</span><span> </span><span><span>|</span><span>=</span></span><span> </span><span>bShouldBlockOnUnload</span><span> </span><span>&lt;&lt;</span><span> </span><span>3</span><span>;</span></div></div><div><div><div>38</div></div><div><span>    </span><span>Flags</span><span> </span><span><span>|</span><span>=</span></span><span> </span><span>bHasCode</span><span> </span><span>&lt;&lt;</span><span> </span><span>4</span><span>;</span></div></div><div><div><div>39</div></div><div><span>    </span><span>Ar</span><span>.</span><span>SerializeBits</span><span>(</span><span>&amp;</span><span>Flags</span><span>, </span><span>5</span><span>);</span></div></div><div><div><div>40</div></div><div>
</div></div><div><div><div>41</div></div><div><span><span>    </span></span><span>// 받는 쪽은 읽은 비트로 필드를 복원한다.</span></div></div><div><div><div>42</div></div><div><span>    </span><span>bShouldBeLoaded</span><span> </span><span>=</span><span> (</span><span>Flags</span><span> </span><span>&amp;</span><span> (</span><span>1</span><span> </span><span>&lt;&lt;</span><span> </span><span>0</span><span>)) </span><span>!=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>43</div></div><div><span>    </span><span>bShouldBeVisible</span><span> </span><span>=</span><span> (</span><span>Flags</span><span> </span><span>&amp;</span><span> (</span><span>1</span><span> </span><span>&lt;&lt;</span><span> </span><span>1</span><span>)) </span><span>!=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>44</div></div><div><span>    </span><span>bShouldBlockOnLoad</span><span> </span><span>=</span><span> (</span><span>Flags</span><span> </span><span>&amp;</span><span> (</span><span>1</span><span> </span><span>&lt;&lt;</span><span> </span><span>2</span><span>)) </span><span>!=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>45</div></div><div><span>    </span><span>bShouldBlockOnUnload</span><span> </span><span>=</span><span> (</span><span>Flags</span><span> </span><span>&amp;</span><span> (</span><span>1</span><span> </span><span>&lt;&lt;</span><span> </span><span>3</span><span>)) </span><span>!=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>46</div></div><div>
</div></div><div><div><div>47</div></div><div><span>    </span><span>Ar</span><span> </span><span>&lt;&lt;</span><span> </span><span>LODIndex</span><span>;</span></div></div><div><div><div>48</div></div><div>
</div></div><div><div><div>49</div></div><div><span><span>    </span></span><span>// 표에 있는 레벨은 16비트 코드를, 없는 레벨은 경로 문자열을 보낸다.</span></div></div><div><div><div>50</div></div><div><span>    </span><span>if</span><span> ((</span><span>Flags</span><span> </span><span>&amp;</span><span> (</span><span>1</span><span> </span><span>&lt;&lt;</span><span> </span><span>4</span><span>)) </span><span>!=</span><span> </span><span>0</span><span>)</span></div></div><div><div><div>51</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>52</div></div><div><span>      </span><span>Ar</span><span> </span><span>&lt;&lt;</span><span> </span><span>Code</span><span>;</span></div></div><div><div><div>53</div></div><div><span>      </span><span>if</span><span> (</span><span>Ar</span><span>.</span><span>IsLoading</span><span>())</span></div></div><div><div><div>54</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>55</div></div><div><span>        </span><span>PackageName</span><span> </span><span>=</span><span> </span><span>NAME_None</span><span>;</span><span> // 이름은 Decode()에서 코드로 복원한다.</span></div></div><div><div><div>56</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>57</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>58</div></div><div><span>    </span><span>else</span></div></div><div><div><div>59</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>60</div></div><div><span>      </span><span>Ar</span><span> </span><span>&lt;&lt;</span><span> </span><span>PackageName</span><span>;</span></div></div><div><div><div>61</div></div><div><span>      </span><span>if</span><span> (</span><span>Ar</span><span>.</span><span>IsLoading</span><span>())</span></div></div><div><div><div>62</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>63</div></div><div><span>        </span><span>Code</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>64</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>65</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>66</div></div><div>
</div></div><div><div><div>67</div></div><div><span>    </span><span>bOutSuccess</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>68</div></div><div><span>    </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>69</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>70</div></div><div><span>};</span></div></div><div><div><div>71</div></div><div>
</div></div><div><div><div>72</div></div><div><span>// 기본 프로퍼티 직렬화 대신 위의 NetSerialize()를 쓰게 한다.</span></div></div><div><div><div>73</div></div><div><span>template</span><span>&lt;&gt;</span></div></div><div><div><div>74</div></div><div><span>struct</span><span> </span><span>TStructOpsTypeTraits</span><span>&lt;</span><span>FCodedLevelStatus</span><span>&gt; : </span><span>public</span><span> </span><span>TStructOpsTypeTraitsBase2</span><span>&lt;</span><span>FCodedLevelStatus</span><span>&gt;</span></div></div><div><div><div>75</div></div><div><span>{</span></div></div><div><div><div>76</div></div><div><span>  </span><span>enum</span></div></div><div><div><div>77</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>78</div></div><div><span>    </span><span>WithNetSerializer</span><span> </span><span>=</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>79</div></div><div><span><span>  </span></span><span>};</span></div></div><div><div><div>80</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>원소 하나의 <code>NetSerialize</code>가 쓰는 내용은 다음과 같다.</p>

<table><thead><tr><th>필드</th><th>크기</th></tr></thead><tbody><tr><td>플래그 넷과 “코드 사용” 비트</td><td>5비트</td></tr><tr><td>LOD 인덱스</td><td>8비트</td></tr><tr><td>코드가 있을 때: 레벨 코드</td><td>16비트</td></tr><tr><td>코드가 없을 때: 패키지 이름</td><td>문자열</td></tr></tbody></table><p>코드가 있는 원소의 페이로드는 29비트다. 배열 길이와 RPC, 번치, 패킷의 헤더는 제외한 값이며 전체 전송량이나 측정된 절감률을 뜻하지 않는다. 바뀐 부분은 각 원소에서 긴 경로 문자열을 16비트 코드로 대체한 것이다.</p><p><img alt="" loading="lazy" width="1536" height="1024" src="/_astro/level-code-payload.C4gxdfUG_NIisz.webp" /></p><p>엔진의 LOD 인덱스는 <code>int32</code>지만 전송용 구조체에서는 <code>int8</code>로 줄였다. 실제 <code>Encode()</code>는 상한을 넘는 값을 <code>MAX_int8</code>로 제한한다. 원래 값을 그대로 보존하려면 사용하는 LOD 값이 <code>int8</code> 범위 안에 있어야 한다.</p><section><h3>표에 없는 레벨은 그대로 보낸다<a href="#표에-없는-레벨은-그대로-보낸다"><span>#</span></a></h3><p>다섯 번째 비트는 표에 없는 레벨을 처리하기 위한 분기다. 표에 등록되지 않은 레벨은 코드가 0이고 이때는 원래대로 패키지 이름을 보낸다. 레벨을 추가하고 표 갱신을 잊으면 그 레벨만 압축되지 않는다. 서버는 코드가 없는 레벨을 만나면 경고 로그를 남겨 누락을 알 수 있게 했다. 이 폴백은 표에 없는 이름을 처리할 뿐, 양쪽 표에서 같은 코드가 서로 다른 레벨을 가리키는 문제까지 해결하지는 않는다.</p><p>표를 갖추지 않은 맵으로 테스트할 때를 위해 이 경고를 끄는 설정도 두었다.</p></section></section>
<section><h2>엔진 함수 교체<a href="#엔진-함수-교체"><span>#</span></a></h2><p><code>ReplicateStreamingStatus()</code>는 가상 함수다. 엔진 구현을 그대로 옮긴 뒤 RPC를 호출하는 부분만 바꿨다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyGameMode</span><span>::</span></span><span>ReplicateStreamingStatus</span><span>(</span><span>APlayerController</span><span>*</span><span><span> </span><span>PC</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>// ... 엔진 구현과 동일하게 LevelStatuses를 채운다 ...</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>AMyPlayerController</span><span>*</span><span> </span><span>MyPC</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>AMyPlayerController</span><span>&gt;(</span><span>PC</span><span>))</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>FCodedLevelStatus</span><span>&gt; </span><span>Coded</span><span>;</span></div></div><div><div><div>8</div></div><div><span>    </span><span>Coded</span><span>.</span><span>Reserve</span><span>(</span><span>LevelStatuses</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>9</div></div><div><span>    </span><span>for</span><span> (</span><span>const</span><span> </span><span>FUpdateLevelStreamingLevelStatus</span><span>&amp;</span><span> </span><span>Status</span><span> : </span><span>LevelStatuses</span><span>)</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>      </span><span>Coded</span><span>.</span><span>Add</span><span>(</span><span>Coder</span><span>-&gt;</span><span>Encode</span><span>(</span><span>Status</span><span>));</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>    </span><span>MyPC</span><span>-&gt;</span><span>ClientUpdateCodedLevelStatuses</span><span>(</span><span>Coded</span><span>);</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>16</div></div><div><span>  </span><span>else</span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>18</div></div><div><span>    </span><span>PC</span><span>-&gt;</span><span>ClientUpdateMultipleLevelsStreamingStatus</span><span>(</span><span>LevelStatuses</span><span>);</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>  </span><span>PC</span><span>-&gt;</span><span>ClientFlushLevelStreaming</span><span>();</span></div></div><div><div><div>22</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>클라이언트는 받은 배열을 엔진의 구조체로 되돌린 뒤 엔진의 처리 함수를 그대로 호출한다. 레벨 스트리밍 로직 자체는 건드리지 않는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyPlayerController</span><span>::</span></span><span>ClientUpdateCodedLevelStatuses_Implementation</span><span>(</span><span>const</span><span> </span><span>TArray</span><span>&lt;</span><span>FCodedLevelStatus</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>Coded</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>FUpdateLevelStreamingLevelStatus</span><span>&gt; </span><span>Decoded</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>Decoded</span><span>.</span><span>Reserve</span><span>(</span><span>Coded</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>5</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> </span><span>FCodedLevelStatus</span><span>&amp;</span><span> </span><span>Status</span><span> : </span><span>Coded</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>Decoded</span><span>.</span><span>Add</span><span>(</span><span>Coder</span><span>-&gt;</span><span>Decode</span><span>(</span><span>Status</span><span>));</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>  </span><span>ClientUpdateMultipleLevelsStreamingStatus_Implementation</span><span>(</span><span>Decoded</span><span>);</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>바꾼 범위는 접속 시의 일괄 전송 하나다. 플레이 중 레벨 하나의 상태가 바뀔 때 가는 RPC는 엔진 경로를 그대로 쓴다.</p></section>
<section><h2>주의할 점<a href="#주의할-점"><span>#</span></a></h2><ul>
<li>코드는 DataTable의 행 순서에 따라 정해진다. 같은 표와 같은 행 순서를 양쪽 빌드에 포함해야 한다. 표가 다르면 같은 코드로 다른 레벨을 복원할 수 있으므로, 호환되지 않는 빌드의 접속은 별도로 막아야 한다.</li>
<li>엔진 함수를 통째로 옮겼기 때문에 엔진을 올릴 때 원본의 변경을 직접 따라가야 한다. <code>FUpdateLevelStreamingLevelStatus</code>에 필드가 추가되면 전송용 구조체에도 반영해야 한다.</li>
<li>코드의 타입은 <code>int16</code>이다. 1부터 시작하는 양수 코드를 유지하려면 표의 행 수는 32,767 이하이어야 한다. 원본은 <code>int32</code>로 만든 코드를 전송용 필드에 대입하므로, 이 범위는 표를 검증할 때 확인해야 한다.</li>
</ul></section>
<section><h2>접속 과정에서의 위치<a href="#접속-과정에서의-위치"><span>#</span></a></h2><p>이 RPC는 접속 과정의 한 단계일 뿐이다. 클라이언트가 실제로 플레이할 수 있게 되기까지는 다음 조건을 순서대로 통과해야 했다. PlayerController가 매 틱 플래그를 확인하고 앞 단계가 끝나지 않았으면 뒤 단계로 넘어가지 않는다.</p><ol>
<li>에셋 사전 로딩 완료</li>
<li>상주 서브레벨 로드 완료</li>
<li>클라이언트 초기화: Playing 상태와 서버 초기화 플래그를 확인하고 필요한 경우 서버 로그인 완료를 기다린다.</li>
<li>인증·초기 동기화 과정 시작과 완료 대기</li>
<li><code>OnClientInitialized()</code>에서 입력 허용과 로딩 화면 해제</li>
<li>HUD 초기화</li>
</ol><p>로딩 화면은 PlayerController가 만들어지는 시점부터 띄우고 5번에서 내린다. 뷰 위치를 무효한 값으로 돌려주는 기간은 이보다 짧다. 게임용 PlayerController에서 <code>LoginAuthComponent-&gt;IsProcessBegun()</code>이 참이 될 때까지 무효한 위치를 반환하고 인증·초기 동기화가 시작되면 정상 뷰 위치를 사용한다. 임시 스폰 위치를 기준으로 주변 레벨이 스트리밍되거나 주변 액터가 복제 대상으로 잡히는 일을 막기 위한 처리다.</p><p><img alt="" loading="lazy" width="1448" height="1086" src="/_astro/client-ready-gates.Cdasf787_Zer4JM.webp" /></p><section><h3>에셋 사전 로딩<a href="#에셋-사전-로딩"><span>#</span></a></h3><p>1번 게이트는 플레이 중 첫 사용 시점에 생기던 로딩을 접속 시점으로 옮기는 단계다. 소프트 참조로 둔 애니메이션과 이펙트를 좀비가 처음 등장하거나 처음 피격되는 순간에 로드하면 끊김이 생긴다.</p><p><img alt="" loading="lazy" width="1672" height="941" src="/_astro/preload-cost-illustration.AwvLwT44_Z2bJHpK.webp" /></p><p>레벨에 배치한 프리로더 액터가 네 단계를 비동기로 이어서 처리한다.</p><ol>
<li>데이터 테이블에서 수집한 이펙트, 사운드, 플레이어 애니메이션</li>
<li><code>UPreloadDataAsset</code>에 명시한 에셋과 블루프린트 클래스. 목록 에셋을 하나씩 순서대로 처리하되, 각 목록 안의 에셋과 클래스는 <code>LoadAssetList()</code>로 함께 요청한다.</li>
<li>2번에서 로드된 에셋이 다시 참조하는 2차 에셋. 해당 에셋 타입이 “내가 필요로 하는 에셋 목록”을 돌려주는 인터페이스를 구현한다.</li>
<li>2번에서 로드된 클래스의 CDO가 지연 로딩하는 에셋. 클래스를 하나씩 처리하고 각 CDO가 필요한 에셋 목록을 비동기로 로드한다. 좀비 클래스가 자기 공격, 피격 애니메이션 목록을 직접 알려 주는 식이다.</li>
</ol><p>1·3단계는 수집한 경로 목록을 묶어 비동기 로드를 요청한다. 로드된 객체는 프리로더가 하드 참조로 쥐고 있어 GC에 수거되지 않는다. 첫 사용 시점의 로딩을 줄이는 대신 접속 대기 시간과 상주 메모리를 부담하는 선택이며 총 로딩 작업량을 줄이는 방법은 아니다.</p><p>Dedicated Server는 2단계에 별도의 목록을 쓴다. 서버에 필요한 목록을 분리해 클라이언트용 에셋이 그대로 로드되는 일을 줄일 수 있다. 다만 1단계의 이펙트·사운드·플레이어 애니메이션 수동 수집은 별도 설정으로 제어하므로, 서버용 목록을 쓴다는 이유만으로 모든 시각·음향 에셋이 제외되지는 않는다. 서버는 GameMode의 <code>BeginPlay()</code>에서 프리로더를 돌린 뒤 게임을 시작한다.</p><p>클라이언트에는 사전 로딩 수준 옵션이 있다. <code>UPreloadDataAsset</code>에 지정한 <code>MinPreloadLevel</code>보다 옵션이 낮으면 해당 목록 전체를 건너뛴다. 개별 에셋마다 적용하는 필터는 아니며 Dedicated Server는 이 수준 필터를 적용하지 않는다.</p><p>2단계의 진행률은 완료한 목록 수를 전체 목록 수로 나눈 값이다. 목록 하나가 끝날 때마다 로딩 화면을 갱신하지만 그 안의 개별 에셋 크기나 로딩 시간을 반영한 진행률은 아니다.</p></section></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/level-streaming-in-unreal-engine" target="_blank">Level Streaming in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/asynchronous-asset-loading-in-unreal-engine" target="_blank">Asynchronous Asset Loading in Unreal Engine</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/notd-world-objects-as-data/</id>
      <title type="text">오픈월드의 오브젝트를 액터 대신 데이터로 두기</title>
      <published>2026-09-26T00:00:00.000Z</published>
      <updated>2026-09-26T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/notd-world-objects-as-data/"/>
      <summary type="text">Night of the Dead의 Dedicated Server에서 자연물, 스포너의 좀비, 보관함 내용물을 필요한 순간에만 액터나 복제 데이터로 만들고 평소에는 인스턴스와 구조체로 유지한 방법을 정리한다.</summary>
      <content type="html"><![CDATA[<p><a href="/projects/night-of-the-dead/">Night of the Dead</a>의 맵에는 캘 수 있는 나무와 바위, 곳곳의 좀비 스포너, 플레이어가 지은 보관함과 농장이 있다. 이것들을 모두 복제되는 액터로 두면 서버의 액터 수는 플레이어 수와 무관하게 맵 크기에 비례해 늘어난다. 액터마다 틱, 복제 대상 검사, 채널 비용이 붙는다.</p>
<p>실제로 플레이어가 상호작용하는 것은 그중 일부다. 그래서 세 종류 모두 같은 방향으로 처리했다. 평소에는 가장 싼 표현으로 두고, 플레이어가 가까이 오거나 건드릴 때만 액터나 복제 데이터로 올린다.</p>

<table><thead><tr><th>대상</th><th>평소</th><th>필요할 때</th></tr></thead><tbody><tr><td>자연물</td><td>인스턴스드 폴리지의 인스턴스</td><td>타격받은 것만 액터</td></tr><tr><td>파괴된 자연물</td><td>매니저 맵의 구조체</td><td>리스폰 시 다시 액터</td></tr><tr><td>스포너의 좀비</td><td>정수 카운터, 구조체</td><td>플레이어 근처에서만 풀링 액터</td></tr><tr><td>보관함 내용물</td><td>서버에만 있는 배열</td><td>플레이어 근처에서만 복제용 배열</td></tr></tbody></table>
<section><h2>자연물: 인스턴스, 액터, 구조체<a href="#자연물-인스턴스-액터-구조체"><span>#</span></a></h2><p><img alt="" loading="lazy" width="1280" height="720" src="/_astro/forest-airdrop.DZiJInJW_Z1hYuAH.webp" /></p><section><h3>타격받은 것만 액터로<a href="#타격받은-것만-액터로"><span>#</span></a></h3><p>나무와 바위는 인스턴스드 폴리지로 배치했다. 인스턴스는 액터가 아니므로 서버에 복제 비용이 없고, 클라이언트는 레벨을 로드하면서 같은 인스턴스를 이미 가지고 있다.</p><p>서버에서 인스턴스가 타격을 받으면 그 인스턴스를 제거하고 같은 트랜스폼에 파괴 가능한 액터를 스폰한다. 교체는 타격이 확정된 뒤에 한다. 조준한 대상이 상호작용 가능한지 판정할 때는 액터가 필요 없다. 맞은 컴포넌트의 스태틱 메시가 교체 대상인지만 보고 포커스 UI를 띄운다. 근접 공격, 투사체, 기계 장비, 파트너의 자동 채집은 모두 같은 교체 함수로 모인다.</p><p>채집형 폴리지는 예외다. 상호작용 대상을 고르는 트레이스는 상호작용 인터페이스를 구현한 액터만 후보로 삼기 때문에 인스턴스 상태로는 주울 수 없다. 그래서 이 트레이스에 채집형 인스턴스가 걸리고 플레이어와의 거리가 일정 범위 안이면, 타격 없이 서버에 교체를 요청한다. 코드의 기본 거리는 2.5m다.</p></section><section><h3>서버의 교체<a href="#서버의-교체"><span>#</span></a></h3><p>어떤 스태틱 메시가 어떤 액터 클래스로 바뀌는지는 데이터 테이블에서 읽는다. 행마다 스태틱 메시, 파괴 가능 액터 클래스, 클라이언트용 더미 클래스와 폴리지 ID가 있다. 서브시스템은 초기화 때 이를 메시에서 액터 클래스로, 액터 클래스에서 더미 클래스로, 폴리지 ID에서 메시로 찾는 맵으로 만들어 둔다. 폴리지 ID는 행 이름의 해시로 정해지며, 뒤에 나오는 미러링 목록에서 폴리지 종류를 가리키는 값으로도 쓴다.</p><p>클라이언트가 교체를 요청할 때는 맞은 컴포넌트와 인스턴스 인덱스, 그리고 자기 화면에서 본 인스턴스의 위치를 보낸다. 인덱스만으로는 서버와 같은 인스턴스라는 보장이 없다. 인스턴스를 제거하면 다른 인스턴스의 인덱스가 바뀔 수 있는데, 서버와 클라이언트는 제거 순서가 다를 수 있다. 클라이언트는 타격 즉시 자기 인스턴스를 지우고, 파괴된 자연물의 인스턴스는 여러 프레임에 나눠 지운다. 그래서 서버는 인덱스가 가리키는 인스턴스가 클라이언트가 본 위치에 있는지 먼저 확인하고, 다르면 그 위치 근처에서 인스턴스를 다시 찾는다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ADestructibleFoliage</span><span>*</span><span><span> </span><span>UFoliageSwapSubsystem</span><span>::</span></span><span>SwapToActor</span><span>(</span></div></div><div><div><div>2</div></div><div><span>  </span><span>UFoliageInstancedStaticMeshComponent</span><span>*</span><span><span> </span><span>Component</span><span>,</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>int32</span><span><span> </span><span>InstanceIndex</span><span>,</span></span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>FVector</span><span>&amp;</span><span><span> </span><span>ClientInstanceLocation</span><span>)</span></span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>  </span><span>const</span><span> </span><span>TSubclassOf</span><span>&lt;</span><span>ADestructibleFoliage</span><span>&gt;</span><span>*</span><span> </span><span>ActorClass</span><span> </span><span>=</span><span> </span><span>ActorClassByMesh</span><span>.</span><span>Find</span><span>(</span><span>Component</span><span>-&gt;</span><span>GetStaticMesh</span><span>());</span></div></div><div><div><div>7</div></div><div><span>  </span><span>if</span><span> (</span><span>ActorClass</span><span> </span><span>==</span><span> </span><span>nullptr</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>  </span><span>FTransform</span><span> </span><span>InstanceTransform</span><span>;</span></div></div><div><div><div>13</div></div><div><span>  </span><span>const</span><span> </span><span>bool</span><span> </span><span>bSameInstance</span><span> </span><span>=</span><span> </span><span>Component</span><span>-&gt;</span><span>GetInstanceTransform</span><span>(</span><span>InstanceIndex</span><span>, </span><span>InstanceTransform</span><span>,</span><span> /*bWorldSpace=*/</span><span>true</span><span>)</span></div></div><div><div><div>14</div></div><div><span>    </span><span>&amp;&amp;</span><span> </span><span>InstanceTransform</span><span>.</span><span>GetLocation</span><span>().</span><span>Equals</span><span>(</span><span>ClientInstanceLocation</span><span>, </span><span>MatchTolerance</span><span>);</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>  </span><span>if</span><span> (</span><span>bSameInstance</span><span> </span><span>==</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>18</div></div><div><span>    </span><span>InstanceIndex</span><span> </span><span>=</span><span> </span><span>FindNearestInstance</span><span>(</span><span>Component</span><span>, </span><span>ClientInstanceLocation</span><span>);</span></div></div><div><div><div>19</div></div><div><span>    </span><span>if</span><span> (</span><span>InstanceIndex</span><span> </span><span>==</span><span> </span><span>INDEX_NONE</span><span>)</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>21</div></div><div><span>      </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>    </span><span>Component</span><span>-&gt;</span><span>GetInstanceTransform</span><span>(</span><span>InstanceIndex</span><span>, </span><span>InstanceTransform</span><span>, </span><span>true</span><span>);</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>  </span><span>Component</span><span>-&gt;</span><span>RemoveInstance</span><span>(</span><span>InstanceIndex</span><span>);</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>// 인스턴스가 있던 트랜스폼에 그대로 세운다. 충돌 때문에 위치를 옮기거나 스폰을 포기하지 않는다.</span></div></div><div><div><div>30</div></div><div><span>  </span><span>FActorSpawnParameters</span><span> </span><span>SpawnParams</span><span>;</span></div></div><div><div><div>31</div></div><div><span>  </span><span>SpawnParams</span><span>.</span><span>SpawnCollisionHandlingOverride</span><span> </span><span>=</span><span> ESpawnActorCollisionHandlingMethod::</span><span>AlwaysSpawn</span><span>;</span></div></div><div><div><div>32</div></div><div><span>  </span><span>return</span><span> </span><span>GetWorld</span><span>()-&gt;</span><span>SpawnActor</span><span>&lt;</span><span>ADestructibleFoliage</span><span>&gt;</span><span>(</span><span>*</span><span>ActorClass</span><span>, </span><span>InstanceTransform</span><span>, </span><span>SpawnParams</span><span>);</span></div></div><div><div><div>33</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>FindNearestInstance()</code>는 <code>GetInstancesOverlappingSphere()</code>로 후보를 받아 허용 거리 안에서 가장 가까운 인스턴스를 고른다. 폴리지 컴포넌트는 메시 종류마다 따로 있으므로 같은 컴포넌트 안에서만 찾으면 메시도 자연히 같다. 이미 다른 요청으로 교체된 인스턴스라면 후보가 없으므로 아무것도 하지 않는다.</p><p>교체된 액터는 복제되지만 틱이 없고 <code>DORM_DormantAll</code>로 시작한다. 체력과 부분 파괴 상태는 이때부터 이 액터가 가진다.</p></section><section><h3>클라이언트의 더미<a href="#클라이언트의-더미"><span>#</span></a></h3><p>클라이언트에서 타격 판정이 나면 서버에 교체를 요청하는 동시에 자기 인스턴스를 지우고, 같은 트랜스폼에 로컬 더미 액터를 세운다. 더미는 복제되지 않으며 같은 스태틱 메시와 타격 이펙트, 데칼만 가지고 2.5초 뒤에 스스로 사라진다. 서버의 액터가 도착하기 전까지 타격 반응이 비지 않게 하는 용도다. 한 번의 공격 판정에서 요청이 중복되지 않도록, 이미 처리한 폴리지 컴포넌트는 기억해 두고 건너뛴다.</p><p>서버의 액터가 복제되어 오면 <code>BeginPlay()</code>에서 남은 인스턴스를 정리한다. 직접 타격하지 않은 다른 클라이언트에는 그 자리에 인스턴스가 아직 있으므로, 액터와 같은 위치에 있는 같은 메시의 인스턴스를 찾아 지운다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>ADestructibleFoliage</span><span>::</span></span><span>BeginPlay</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>BeginPlay</span><span>();</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>RemoveOverlappedInstance</span><span>(</span><span>TraceFromPivot</span><span>()) </span><span>==</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>RemoveOverlappedInstance</span><span>(</span><span>TraceBounds</span><span>());</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>  </span><span>if</span><span> (</span><span>GetNetMode</span><span>() </span><span>==</span><span> </span><span>NM_Client</span><span>)</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>12</div></div><div><span>    </span><span>TakeOverLocalDummy</span><span>();</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>bool</span><span><span> </span><span>ADestructibleFoliage</span><span>::</span></span><span>RemoveOverlappedInstance</span><span>(</span><span>const</span><span> </span><span>TArray</span><span>&lt;</span><span>FHitResult</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>Hits</span><span>)</span></span></div></div><div><div><div>17</div></div><div><span>{</span></div></div><div><div><div>18</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> </span><span>FHitResult</span><span>&amp;</span><span> </span><span>Hit</span><span> : </span><span>Hits</span><span>)</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>20</div></div><div><span>    </span><span>auto*</span><span> </span><span>Component</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>UFoliageInstancedStaticMeshComponent</span><span>&gt;(</span><span>Hit</span><span>.</span><span>GetComponent</span><span>());</span></div></div><div><div><div>21</div></div><div><span>    </span><span>if</span><span> (</span><span>Component</span><span> </span><span>==</span><span> </span><span>nullptr</span><span> </span><span>||</span><span> </span><span>Component</span><span>-&gt;</span><span>GetStaticMesh</span><span>() </span><span>!=</span><span> </span><span>MeshComponent</span><span>-&gt;</span><span>GetStaticMesh</span><span>())</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>23</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span><span>    </span></span><span>// 인스턴스드 메시 컴포넌트에서 Hit.Item은 인스턴스 인덱스다.</span></div></div><div><div><div>27</div></div><div><span>    </span><span>FTransform</span><span> </span><span>InstanceTransform</span><span>;</span></div></div><div><div><div>28</div></div><div><span>    </span><span>Component</span><span>-&gt;</span><span>GetInstanceTransform</span><span>(</span><span>Hit</span><span>.</span><span>Item</span><span>, </span><span>InstanceTransform</span><span>, </span><span>true</span><span>);</span></div></div><div><div><div>29</div></div><div><span>    </span><span>if</span><span> (</span><span>InstanceTransform</span><span>.</span><span>GetLocation</span><span>().</span><span>Equals</span><span>(</span><span>GetActorLocation</span><span>(), </span><span>MatchTolerance</span><span>))</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>31</div></div><div><span>      </span><span>Component</span><span>-&gt;</span><span>RemoveInstance</span><span>(</span><span>Hit</span><span>.</span><span>Item</span><span>);</span></div></div><div><div><div>32</div></div><div><span>      </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>33</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>34</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>35</div></div><div>
</div></div><div><div><div>36</div></div><div><span>  </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>37</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>먼저 피벗에서 위로 짧은 라인 트레이스를 쏘고, 찾지 못했을 때만 바운딩 박스 전체로 박스 트레이스를 한다. <code>TakeOverLocalDummy()</code>는 같은 자리의 더미에 붙어 있던 데칼을 액터로 옮긴 뒤 더미를 파괴한다. 폴리지 레벨이 스트리밍으로 나중에 로드되면, 그 레벨 범위에 있는 액터가 같은 정리를 다시 한다.</p><p></p><figure><img alt="타격 시 로컬 더미로 즉시 반응하고 서버에서 교체한 액터가 복제되면 데칼을 인계한다. 교체 성공 경로를 표시했다." loading="lazy" width="1672" height="941" src="/_astro/foliage-instance-actor-swap.CIcMufsO_ZnJ3oM.webp" /><figcaption>타격 시 로컬 더미로 즉시 반응하고 서버에서 교체한 액터가 복제되면 데칼을 인계한다. 교체 성공 경로를 표시했다.</figcaption></figure><p></p></section><section><h3>다시 인스턴스로<a href="#다시-인스턴스로"><span>#</span></a></h3><p>반대 방향도 있다. 서버에서 폴리지 레벨이 스트리밍 아웃되어 GC되면, 다음에 그 레벨을 로드할 때 인스턴스가 다시 만들어진다. 그래서 서버는 GC 시점에 로드된 폴리지 레벨 어디에도 속하지 않는 파괴 가능 액터를 파괴한다. 남은 체력과 부분 파괴 상태는 이때 버려지고, 자연물은 온전한 인스턴스로 돌아온다. 세이브 로드에서도 액터의 상태를 되살리지 않는다. 체력이 0인 액터만 리스폰 레코드로 넘기고 나머지는 파괴한다.</p></section><section><h3>파괴된 것은 구조체 하나로<a href="#파괴된-것은-구조체-하나로"><span>#</span></a></h3><p>완전히 파괴된 액터는 리스폰 매니저에 자신을 등록하고 사라진다. 매니저에는 구조체 하나만 남는다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FRespawnRecord</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>7</div></div><div><span>  </span><span>FGuid</span><span> </span><span>RespawnId</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>10</div></div><div><span>  </span><span>TSoftClassPtr</span><span>&lt;</span><span>AActor</span><span>&gt;</span><span> </span><span>ActorClass</span><span>;</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>13</div></div><div><span>  </span><span>FGameplayTag</span><span> </span><span>RespawnTag</span><span>;</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>16</div></div><div><span>  </span><span>FTransform</span><span> </span><span>Transform</span><span>;</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>19</div></div><div><span>  </span><span>double</span><span> </span><span>RequestedGameTime</span><span> </span><span>=</span><span> </span><span>0.0</span><span>;</span></div></div><div><div><div>20</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>리스폰 조건은 경과 시간과 조건 에셋이다. 조건 에셋은 난이도 옵션과, 리스폰 위치에 플레이어 건물이 겹치는지를 박스 트레이스로 확인한다. 트레이스가 들어가므로 등록된 레코드 전체를 매 프레임 검사할 수 없다. 매니저는 서버에서만 틱하고, 한 프레임에 정해진 수만 돌아가며 검사한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>ARespawnManager</span><span>::</span></span><span>Tick</span><span>(</span><span>float</span><span><span> </span><span>DeltaSeconds</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Super::</span><span>Tick</span><span>(</span><span>DeltaSeconds</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>NumRecords</span><span> </span><span>=</span><span> </span><span>RecordIds</span><span>.</span><span>Num</span><span>();</span></div></div><div><div><div>6</div></div><div><span>  </span><span>const</span><span> </span><span>int32</span><span> </span><span>NumToProcess</span><span> </span><span>=</span><span> FMath::</span><span>Min</span><span>(</span><span>MaxProcessPerTick</span><span>, </span><span>NumRecords</span><span>);</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>  </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Step</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Step</span><span> </span><span>&lt;</span><span> </span><span>NumToProcess</span><span>; </span><span>++</span><span>Step</span><span>)</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>// Cursor는 틱 사이에 유지되므로 다음 틱은 이어서 검사한다.</span></div></div><div><div><div>11</div></div><div><span>    </span><span>Cursor</span><span> </span><span>=</span><span> (</span><span>Cursor</span><span> </span><span>+</span><span> </span><span>1</span><span>) </span><span>%</span><span> </span><span>NumRecords</span><span>;</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>    </span><span>const</span><span> </span><span>FRespawnRecord</span><span>&amp;</span><span> </span><span>Record</span><span> </span><span>=</span><span> </span><span>Records</span><span>[</span><span>RecordIds</span><span>[</span><span>Cursor</span><span>]];</span></div></div><div><div><div>14</div></div><div><span>    </span><span>if</span><span> (</span><span>IsTimeElapsed</span><span>(</span><span>Record</span><span>) </span><span>&amp;&amp;</span><span> </span><span>CanRespawn</span><span>(</span><span>Record</span><span>))</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>16</div></div><div><span>      </span><span>PendingRespawns</span><span>.</span><span>Add</span><span>(</span><span>Record</span><span>.</span><span>RespawnId</span><span>);</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span><span>  </span></span><span>// 순회 중에는 레코드를 지우지 않고 모아 두었다가 한 번에 리스폰한다.</span></div></div><div><div><div>21</div></div><div><span>  </span><span>FlushPendingRespawns</span><span>();</span></div></div><div><div><div>22</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이 방식은 레코드 수가 많아질수록 각 항목의 검사 주기가 길어진다. 즉시 반응해야 하는 전투 판정과 달리, 자연물의 리스폰은 검사 지연을 허용할 수 있어 작업을 나눴다. 매니저의 맵은 세이브에도 들어간다.</p></section><section><h3>클라이언트의 인스턴스 정리<a href="#클라이언트의-인스턴스-정리"><span>#</span></a></h3><p>액터가 살아 있는 동안에는 복제된 액터가 클라이언트의 인스턴스를 지운다. 완전히 파괴되어 액터가 사라지면 이 정리를 맡을 대상이 없다. 인스턴스드 폴리지 자체는 복제되지 않으므로, 따로 알려 주지 않으면 나중에 접속했거나 그 레벨을 아직 로드하지 않았던 클라이언트는 이미 파괴된 나무를 그대로 보게 된다.</p><p>그래서 서버의 리스폰 레코드 집합을 클라이언트에 미러링했다. 클라이언트는 <code>(리스폰 ID, 폴리지 종류 ID, 트랜스폼)</code> 목록을 받아 해당 위치의 로컬 인스턴스를 제거한다. 전송은 <a href="/posts/notd-reliable-rpc-data-streaming/">스트리밍 라우터</a>를 통한다. 접속 시에는 누적된 전체가, 플레이 중에는 변경분이 간다.</p><p>이 경로에는 세 가지 처리가 더 붙었다.</p><p>서버는 등록과 해제를 바로 보내지 않고 추가 버퍼와 삭제 버퍼에 모았다가 1초마다 내보낸다. 버퍼에 있는 동안 같은 레코드의 추가와 삭제가 만나면 서로 지운다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UFoliageMirrorComponent</span><span>::</span></span><span>OnRecordRegistered</span><span>(</span><span>const</span><span> </span><span>FRespawnRecord</span><span>&amp;</span><span><span> </span><span>Record</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>// 아직 보내지 않은 삭제가 있으면 둘 다 버리고, 없으면 추가로 쌓는다.</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span> (</span><span>PendingRemovals</span><span>.</span><span>Remove</span><span>(</span><span>Record</span><span>.</span><span>RespawnId</span><span>) </span><span>==</span><span> </span><span>0</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>PendingAdditions</span><span>.</span><span>Add</span><span>(</span><span>Record</span><span>.</span><span>RespawnId</span><span>, </span><span>MakeMirrorEntry</span><span>(</span><span>Record</span><span>));</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>void</span><span><span> </span><span>UFoliageMirrorComponent</span><span>::</span></span><span>OnRecordUnregistered</span><span>(</span><span>const</span><span> </span><span>FGuid</span><span>&amp;</span><span><span> </span><span>RespawnId</span><span>)</span></span></div></div><div><div><div>11</div></div><div><span>{</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>// 아직 보내지 않은 추가가 있으면 둘 다 버리고, 없으면 삭제로 쌓는다.</span></div></div><div><div><div>13</div></div><div><span>  </span><span>if</span><span> (</span><span>PendingAdditions</span><span>.</span><span>Remove</span><span>(</span><span>RespawnId</span><span>) </span><span>==</span><span> </span><span>0</span><span>)</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>    </span><span>PendingRemovals</span><span>.</span><span>Add</span><span>(</span><span>RespawnId</span><span>);</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>클라이언트는 받은 목록을 한 번에 처리하지 않는다. 인스턴스 제거는 위치로 인스턴스를 찾는 작업이라 접속 직후의 전체 목록을 한 프레임에 처리하면 끊긴다. 작업 큐에 넣고 프레임당 정해진 수만 처리한다.</p><p>폴리지가 들어 있는 레벨은 스트리밍으로 나중에 로드될 수 있다. 목록을 받은 시점에 해당 레벨이 없으면 지울 인스턴스도 없다. 클라이언트는 목록을 버리지 않고 맵으로 유지하다가, 폴리지 레벨이 추가될 때마다 그 레벨의 범위에 들어가는 항목을 다시 작업 큐에 넣는다.</p><p>이 폴리지 경로에서는 추가·삭제 전송의 완료 통지를 받으면 클라이언트가 가진 항목 수를 서버에 보고한다. 서버에 아직 보낼 변경분이 없는데도 수가 다르면 전체를 다시 받는다. 이 재전송 경로를 시험하기 위해 클라이언트가 일부러 0을 보고하게 하는 개발용 설정을 두었다. 개수 비교이므로 내용까지 같은지는 확인하지 않는다. 수신 컴포넌트가 등록되기 전에 완료 통지까지 버려진 경우에는 이 검사도 실행되지 않는다.</p><p><img alt="" loading="lazy" width="1536" height="1024" src="/_astro/foliage-mirror-queue.ATakkKim_1zEivc.webp" /></p></section></section>
<section><h2>스포너의 좀비: 숫자, 구조체, 액터<a href="#스포너의-좀비-숫자-구조체-액터"><span>#</span></a></h2><p>맵 곳곳의 스포너는 서버에만 있다. 클라이언트에서는 <code>BeginPlay()</code>에서 스스로 파괴한다. 스포너가 관리하는 좀비는 플레이어와의 거리를 기준으로 생성 단계를 나눈다.</p>

<table><thead><tr><th>단계</th><th>형태</th><th>조건</th></tr></thead><tbody><tr><td>1</td><td>스폰해야 할 수(정수)</td><td>스포너 활성 거리 안</td></tr><tr><td>2</td><td>클래스와 트랜스폼을 가진 구조체</td><td>데이터 생성 거리 안</td></tr><tr><td>3</td><td>오브젝트 풀에서 꺼낸 액터</td><td>액터화 거리 안</td></tr></tbody></table><p>1단계에서는 위치조차 정하지 않는다. 2단계에서 스폰 위치를 탐색해 구조체로 확정하고, 3단계에서 풀의 액터를 사용한다. 액터화 거리가 가장 짧으므로 맵 전체의 좀비를 항상 활성 액터로 유지할 필요가 없다.</p><p>반대 방향도 있다. 다만 멀어졌다는 이유만으로 곧바로 반환하지는 않는다. 좀비의 <code>CanDespawn()</code>은 추격 중이면 반환을 막고, 기본 AI 판정은 플레이어와의 거리, 시야, 원래 위치로 복귀했는지를 확인한다. 스포너 활성 범위를 벗어난 액터를 강제로 반환하는 경로도 별도로 있다.</p><p>다음 예시는 액터를 구조체로 되돌리는 경로다. 살아 있고 일시적인 대상이 아니며 스포너 활성 범위 안에 있는 경우만 다룬다. 원본에서는 범위를 벗어났거나 일시적인 대상이면 위치를 남기는 대신 스폰할 수만 증가시키고, 죽은 대상은 수를 복원하지 않는다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AZombieSpawner</span><span>::</span></span><span>DemoteEligibleCharacters</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>// RemoveAtSwap()으로 지우므로 뒤에서부터 순회한다.</span></div></div><div><div><div>4</div></div><div><span>  </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Index</span><span> </span><span>=</span><span> </span><span>SpawnedCharacters</span><span>.</span><span>Num</span><span>() </span><span>-</span><span> </span><span>1</span><span>; </span><span>Index</span><span> </span><span>&gt;=</span><span> </span><span>0</span><span>; </span><span>--</span><span>Index</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>ACharacter</span><span>*</span><span> </span><span>Character</span><span> </span><span>=</span><span> </span><span>SpawnedCharacters</span><span>[</span><span>Index</span><span>];</span></div></div><div><div><div>7</div></div><div><span>    </span><span>ISpawnableAI</span><span>*</span><span> </span><span>Spawnable</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>ISpawnableAI</span><span>&gt;(</span><span>Character</span><span>);</span></div></div><div><div><div>8</div></div><div><span>    </span><span>if</span><span> (</span><span>Spawnable</span><span> </span><span>==</span><span> </span><span>nullptr</span><span> </span><span>||</span><span> </span><span>Spawnable</span><span>-&gt;</span><span>CanDespawn</span><span>() </span><span>==</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>10</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>// 원본에서는 따로 처리하는 경우다. 이 예시에서는 생략했다.</span></div></div><div><div><div>14</div></div><div><span>    </span><span>if</span><span> (</span><span>Spawnable</span><span>-&gt;</span><span>IsAlive</span><span>() </span><span>==</span><span> </span><span>false</span><span> </span><span>||</span><span> </span><span>Spawnable</span><span>-&gt;</span><span>IsTransient</span><span>()</span></div></div><div><div><div>15</div></div><div><span>      </span><span>||</span><span> </span><span>IsOutsideSpawnerActivationRange</span><span>(</span><span>Character</span><span>))</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>17</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span>    </span><span>SpawnedData</span><span>.</span><span>Add</span><span>(</span><span>FSpawnedZombieData</span><span>(</span><span>Character</span><span>-&gt;</span><span>GetClass</span><span>(), </span><span>Character</span><span>-&gt;</span><span>GetActorTransform</span><span>()));</span></div></div><div><div><div>21</div></div><div><span>    </span><span>Spawnable</span><span>-&gt;</span><span>OnDespawned</span><span>();</span></div></div><div><div><div>22</div></div><div><span>    </span><span>SpawnedCharacters</span><span>.</span><span>RemoveAtSwap</span><span>(</span><span>Index</span><span>);</span></div></div><div><div><div>23</div></div><div><span>    </span><span>Pool</span><span>-&gt;</span><span>ReturnActor</span><span>(</span><span>Character</span><span>);</span></div></div><div><div><div>24</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>25</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이 구조체가 보존하는 것은 클래스와 트랜스폼이다. 다시 액터화할 위치는 남지만 체력이나 진행 중인 전투 상태까지 저장한 스냅샷은 아니다. 풀 반환 역시 액터의 메모리가 해제됐다는 뜻은 아니다. 반환된 객체를 풀에 유지한다면 메모리는 남으므로, 활성 액터의 AI 처리와 복제 비용을 줄이는 것과 전체 메모리 사용량을 줄이는 것은 구분해야 한다.</p><p><img alt="" loading="lazy" width="1536" height="1024" src="/_astro/spawner-state-transitions.C0gkIWjv_256wI2.webp" /></p><p>비용을 한 프레임에 몰지 않기 위한 처리도 함께 있다. 아래 처리 수와 주기는 코드의 기본값이다.</p><ul>
<li>거리 검사는 틱이 아니라 1초 타이머로 돌린다. 첫 실행을 0~2초 사이의 난수만큼 늦춰 스포너들이 같은 프레임에 깨어나지 않게 한다.</li>
<li>구조체 생성은 한 번에 5개까지만 하고 나머지는 0.1초 뒤에 이어서 처리한다. 스폰 위치 탐색이 연달아 실패하면 간격을 열 배로 늘린다.</li>
<li>스폰할 클래스는 <code>BeginPlay()</code>에서 비동기로 미리 로드해 둔다.</li>
</ul><p>스포너가 속한 레벨이 스트리밍으로 언로드되면 스포너 액터도 사라진다. 이때 카운터와 구조체는 별도 매니저의 맵으로 옮겨 보관하고, 레벨이 다시 로드되면 돌려받는다. 스폰된 좀비는 스포너의 레벨이 아니라 퍼시스턴트 레벨에 속하게 해서 스포너와 수명이 엮이지 않게 했다.</p></section>
<section><h2>보관함 내용물: 가까울 때만 복제<a href="#보관함-내용물-가까울-때만-복제"><span>#</span></a></h2><p><img alt="" loading="lazy" width="1280" height="720" src="/_astro/electric-facilities.BT2YZD_C_Z2gNO3b.webp" /></p><p>보관함, 농장, 전기 설비 같은 건물은 액터 자체가 컬 거리 안에서 계속 복제 대상이다. 하지만 내용물 배열은 상자를 열 만큼 가까이 있을 때만 필요하다. 플레이어 기지에는 이런 건물이 많이 모이고, 기지에 들어서는 순간 모든 내용물이 한꺼번에 내려가면 그 자체로 부담이다.</p><section><h3>복제용 배열을 분리<a href="#복제용-배열을-분리"><span>#</span></a></h3><p>서버는 원본 배열과 복제용 배열을 따로 가진다. 복제용 배열만 복제된다. 원본에서 복제용으로 옮길 때 근처에 플레이어가 있는지 확인하고, 없으면 복제용 배열을 비운다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>FItemContainer</span><span>::</span></span><span>RefreshReplicatedItems</span><span>(</span><span>const</span><span> </span><span>AActor</span><span>*</span><span><span> </span><span>Owner</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>// 거리 제한 인터페이스를 구현하지 않은 소유자는 항상 복제한다.</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>IDistanceGatedReplication</span><span>*</span><span> </span><span>Gate</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>IDistanceGatedReplication</span><span>&gt;(</span><span>Owner</span><span>);</span></div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>Gate</span><span> </span><span>&amp;&amp;</span><span> </span><span>IsAnyPlayerWithin</span><span>(</span><span>Owner</span><span>-&gt;</span><span>GetActorLocation</span><span>(), </span><span>Gate</span><span>-&gt;</span><span>GetReplicationDistance</span><span>()) </span><span>==</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>ReplicatedItems</span><span>.</span><span>Empty</span><span>();</span></div></div><div><div><div>8</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>// 행 전체 대신 ID와 수량만 옮긴다.</span></div></div><div><div><div>12</div></div><div><span>  </span><span>ReplicatedItems</span><span>.</span><span>SetNum</span><span>(</span><span>Items</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>13</div></div><div><span>  </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Index</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Index</span><span> </span><span>&lt;</span><span> </span><span>Items</span><span>.</span><span>Num</span><span>(); </span><span>++</span><span>Index</span><span>)</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>    </span><span>ReplicatedItems</span><span>[</span><span>Index</span><span>] </span><span>=</span><span> </span><span>FReplicatedItem</span><span>(</span><span>Items</span><span>[</span><span>Index</span><span>]);</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>코드의 기본 거리는 보관함과 농장이 10m, 상점이 50m다. 전기 설비는 조건이 하나 더 있어서 근처의 플레이어가 전선 연결 도구를 들고 있을 때만 연결 목록을 채운다.</p><p>이 건물들은 평소 휴면 상태다. 보관함과 농장의 거리 검사 기본 주기는 1초이고, 전기 설비는 0.5초다. 농장은 복제용 배열에 내용이 있을 때 <code>FlushNetDormancy()</code>를 호출해 업데이트를 허용한다. 보관함 컴포넌트도 <code>FlushNetDormancy()</code>와 <code>ForceNetUpdate()</code>를 감싼 함수에서 배열을 갱신한다.</p><p>여기에는 원본 구현과 현재 권장 사용법의 차이가 있다. 원본은 복제용 배열을 수정한 뒤 휴면을 해제하지만, <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-network-dormancy-in-unreal-engine" target="_blank">Epic의 Dormancy 문서</a>는 복제 프로퍼티를 수정하기 전에 소유 액터에 <code>FlushNetDormancy()</code> 등을 호출하도록 안내한다. 수정 후 호출이 동작하는 것은 구현 세부에 의존하며, Fast Array에서는 변경분이 전달되지 않는 경우도 있다. 같은 구조를 적용할 때는 갱신할 필요를 먼저 판정하고, 소유 액터의 휴면을 해제한 다음 복제용 배열을 수정해야 한다.</p></section><section><h3>행 전체 대신 ID<a href="#행-전체-대신-id"><span>#</span></a></h3><p>원본 배열의 원소는 데이터 테이블 행을 통째로 가지고 있다. 이름, 설명 텍스트, 에셋 경로가 들어 있다. 클라이언트에도 같은 테이블이 있으므로 이것을 보낼 이유가 없다. 복제용 구조체에는 테이블을 조회할 ID와 수량, 개체 ID만 둔다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FReplicatedItem</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>7</div></div><div><span>  </span><span>int32</span><span> </span><span>EntityId</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span><span> // 데이터 테이블을 조회할 ID다.</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>10</div></div><div><span>  </span><span>int32</span><span> </span><span>Amount</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>  </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>13</div></div><div><span>  </span><span>FGuid</span><span> </span><span>ItemId</span><span>;</span><span> // 아이템 개체를 구분하는 ID다.</span></div></div><div><div><div>14</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>클라이언트는 <code>OnRep</code>에서 ID로 테이블을 조회해 원본과 같은 형태의 배열을 다시 만든다. 이 규칙은 인벤토리뿐 아니라 퀘스트, 탄창, 전기 소켓 연결처럼 복제되는 구조체 전반에 같은 접미사를 붙여 적용했다.</p></section><section><h3>한계<a href="#한계"><span>#</span></a></h3><p>거리 판정은 연결별이 아니다. 한 명이 보관함에 다가가면 그 건물이 복제되는 모든 연결에 내용물이 전송된다. 서버 쪽 배열 자체를 비우고 채우는 방식이라 구현은 단순하지만, 같은 기지에 여러 명이 있을 때는 필요 없는 연결에도 내용물이 간다.</p><p><img alt="" loading="lazy" width="1536" height="1024" src="/_astro/container-replication-scope.ttuSynTY_Z1GwBVV.webp" /></p><p>원본은 복제용 배열을 비울 때 휴면을 해제하지 않는다. 다른 업데이트가 없다면 멀어진 클라이언트에는 마지막으로 받은 내용이 남는다. 하지만 다른 경로에서 액터의 휴면이 해제되면 빈 배열이 전달될 수도 있으므로, 이 캐시가 항상 유지된다고 가정할 수는 없다. 다시 가까워졌을 때 서버의 원본 배열을 기준으로 갱신해야 한다.</p></section></section>
<section><h2>공통점<a href="#공통점"><span>#</span></a></h2><p>세 경우 모두 서버가 원본 상태를 가장 가벼운 형태로 쥐고 있고, 액터나 복제 데이터는 그 상태를 일시적으로 펼친 것이다. 이렇게 두면 다음이 함께 풀린다.</p><ul>
<li>저장: 자연물과 스포너는 세이브에 넣을 것이 구조체 맵이다.</li>
<li>레벨 스트리밍: 액터가 언로드돼도 상태는 매니저에 남는다.</li>
<li>늦은 접속: 파괴된 자연물의 이력은 목록이므로 일괄 전송 경로를 그대로 쓸 수 있다.</li>
</ul><p>대신 형태를 바꾸는 지점마다 비용이 생긴다. 인스턴스를 액터로 바꿀 때는 스폰이, 구조체를 액터로 되돌릴 때는 풀 처리가, 거리 검사에는 주기적인 플레이어 순회가 따른다. 위에서 본 프레임당 처리 수 제한과 타이머 분산은 모두 이 비용을 한 프레임에 몰지 않기 위한 것이다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/foliage-mode-in-unreal-engine" target="_blank">Foliage Mode in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-network-dormancy-in-unreal-engine" target="_blank">Actor Network Dormancy in Unreal Engine</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/notd-multiplayer-optimization-overview/</id>
      <title type="text">Night of the Dead 멀티플레이 최적화 돌아보기</title>
      <published>2026-09-25T00:00:00.000Z</published>
      <updated>2026-09-25T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/notd-multiplayer-optimization-overview/"/>
      <summary type="text">Night of the Dead의 Dedicated Server 멀티플레이에 적용한 네트워크와 실행 비용 최적화를 복제 대상, 전송 데이터, 전송 경로, 다수 개체 처리로 나눠 정리하고 각 주제의 상세 글로 연결한다.</summary>
      <content type="html"><![CDATA[<p><a href="/projects/night-of-the-dead/">Night of the Dead</a>는 Unreal Engine으로 만든 오픈월드 생존 게임이고 Dedicated Server 멀티플레이를 지원한다. 넓은 맵에 자연물과 건물이 많고 밤마다 좀비 수백 마리가 플레이어 기지로 몰려온다. 서버가 다뤄야 할 오브젝트 수가 플레이어 수보다 맵과 웨이브 규모에 따라 정해지는 게임이다.</p>
<p><img alt="" loading="lazy" width="1280" height="720" src="/_astro/wave-giants-night.xVRu7KtU_Z1inbwX.webp" /></p>
<p>같은 데이터를 더 빨리 보내기 전에 어느 연결에 어떤 상태가 필요한지부터 구분했다. 서버가 유지할 상태, 클라이언트에서 복원할 데이터와 가까운 개체에 배정할 계산을 나누는 것이 공통 방향이었다. 이 프로젝트의 멀티플레이 최적화를 네 갈래로 나눠 정리했다. 각 항목의 자세한 내용은 연결된 글에 있다.</p>

<table><thead><tr><th>갈래</th><th>질문</th><th>적용한 것</th></tr></thead><tbody><tr><td>복제 대상</td><td>누구에게 보낼 것인가</td><td>Replication Graph, Dependent Actor, Net Dormancy, 거리 기반 내용물 복제</td></tr><tr><td>전송 데이터</td><td>무엇을 얼마나 작게 보낼 것인가</td><td>ID 기반 복제용 구조체, 커스텀 <code>NetSerialize</code>, 보내지 않는 상태</td></tr><tr><td>전송 경로</td><td>어떻게 나눠 보낼 것인가</td><td>RPC 스트리밍, 연결별 신뢰성 선택</td></tr><tr><td>다수 개체</td><td>서버가 얼마나 계산할 것인가</td><td>액터 대신 데이터, 거리 순위 LOD, 애니메이션과 길찾기 비용 조절</td></tr></tbody></table>
<section><h2>복제 대상<a href="#복제-대상"><span>#</span></a></h2><section><h3>Replication Graph<a href="#replication-graph"><span>#</span></a></h3><p>UE5로 이전하면서 Replication Graph를 Lyra 샘플의 구성에 맞춰 다시 작성했다. 위치가 있는 액터는 2D 그리드 노드에, 전역 매니저는 Always Relevant 노드에 두고, PlayerState는 프레임마다 일부만 반환하는 빈도 제한 노드로 처리했다.</p><p>거리만으로 판단하면 안 되는 관계는 Dependent Actor로 묶었다. 장착 장비는 장비를 든 캐릭터에, 추종자는 고용한 플레이어에, 탑승물 위의 건물은 탑승물에 종속시켜 부모가 복제되는 연결에서 함께 검토되게 했다.</p><p>Replication Graph의 동작 자체는 <a href="/posts/unreal-engine-replication-graph/">Unreal Engine - Replication Graph</a>에 정리했다.</p></section><section><h3>Net Dormancy<a href="#net-dormancy"><span>#</span></a></h3><p>상태가 드물게 바뀌는 액터는 휴면을 기본으로 하고 변화가 있을 때만 깨웠다.</p><ul>
<li>바닥에 놓인 아이템은 휴면 상태로 두고 던져져 움직이는 동안에만 주기적으로 갱신한다.</li>
<li>레벨에 배치된 문은 <code>DORM_Initial</code>로 시작한다. 열리거나 닫히는 동안에만 깨어 있고, 닫히면 다시 휴면한다.</li>
<li>풀링하는 좀비는 풀에 반환할 때 휴면시키고 다시 꺼낼 때 깨운다. 풀링과 휴면을 함께 쓰면 상태를 바꾸는 지점마다 휴면 여부를 확인해야 한다.</li>
</ul><p>휴면 상태와 깨우는 방법은 <a href="/posts/unreal-engine-network-dormancy-and-server-optimization/">UE4 Net Dormancy와 서버 리플리케이션 최적화</a>에 정리했다.</p></section><section><h3>거리 기반 내용물 복제<a href="#거리-기반-내용물-복제"><span>#</span></a></h3><p>보관함, 농장, 전기 설비는 액터가 복제되는 범위보다 내용물이 필요한 범위가 훨씬 좁다. 서버가 원본 배열과 복제용 배열을 따로 두고, 플레이어가 가까이 있을 때만 복제용 배열을 채웠다.</p><p>복제용 배열은 액터가 공유하는 상태다. 플레이어 한 명이 가까이 있으면 배열이 채워지고 다른 복제 대상 연결에도 전달될 수 있으므로, 연결마다 내용물을 따로 거르는 방식은 아니다.</p><p>상세: <a href="/posts/notd-world-objects-as-data/">오픈월드의 오브젝트를 액터 대신 데이터로 두기</a></p></section></section>
<section><h2>전송 데이터<a href="#전송-데이터"><span>#</span></a></h2><section><h3>ID만 보내고 클라이언트에서 복원<a href="#id만-보내고-클라이언트에서-복원"><span>#</span></a></h3><p>아이템, 퀘스트, 탄창, 전기 소켓 연결처럼 데이터 테이블에서 온 값을 가진 구조체는 복제용 구조체를 따로 두었다. 테이블 행 전체 대신 ID와 수량만 보내고 클라이언트가 <code>OnRep</code>에서 테이블을 조회해 복원한다.</p></section><section><h3>커스텀 NetSerialize<a href="#커스텀-netserialize"><span>#</span></a></h3><p>접속할 때 서버가 보내는 스트리밍 레벨 상태 목록은 레벨마다 패키지 경로 문자열이 들어 있었다. 경로를 DataTable 기반의 <code>int16</code> 코드로 바꾸고 플래그를 비트로 묶었다.</p><p>상세: <a href="/posts/notd-level-streaming-status-rpc/">접속 시 레벨 스트리밍 상태 RPC 줄이기</a></p></section><section><h3>보내지 않는 상태<a href="#보내지-않는-상태"><span>#</span></a></h3><p>기본 상태 컴포넌트에서 체력 등의 상태 프로퍼티를 소유자 전용으로 두고, 플레이어의 상태 컴포넌트에서는 복제 조건을 다시 지정했다. 좀비는 소유 연결이 없으므로 그 프로퍼티들이 전송되지 않는다. 사망 상태인 <code>DeathInfo</code>는 별도로 복제한다.</p><p>루트 모션 몽타주의 <code>RepRootMotion</code> 복제는 기본 캐릭터에서 끄고 플레이어에서만 다시 켰다. 좀비의 공격 재생 이벤트는 몽타주 에셋 참조와 속도를, 피격 재생 이벤트는 선택 정보와 난수 시드를 보낸다. 몽타주 매니저의 Gameplay Tag와 인덱스 전송은 이 공격·피격 컴포넌트와 별도의 경로다.</p><p>상세: <a href="/posts/notd-zombie-horde-cost/">다수 좀비의 서버 비용과 전송량 줄이기</a></p></section></section>
<section><h2>전송 경로<a href="#전송-경로"><span>#</span></a></h2><section><h3>RPC 스트리밍<a href="#rpc-스트리밍"><span>#</span></a></h3><p>퀘스트 진행도, 지도 마커, 지도 안개처럼 플레이어마다 쌓이는 데이터는 접속 시 전체를 보내야 한다. 한 번에 Reliable RPC로 보내면 버퍼를 넘길 수 있어서 레코드 단위로 나누고 채널의 ACK를 받지 못한 Reliable 번치 수를 보면서 다음 조각을 보냈다. 조각의 바이트 크기까지 제한하는 구조는 아니므로, 레코드 수 제한만으로 버퍼 초과를 항상 막는 것은 아니다.</p></section><section><h3>연결별 신뢰성 선택<a href="#연결별-신뢰성-선택"><span>#</span></a></h3><p>좀비의 피격 전파는 Multicast 대신 연결마다 거리와 버퍼 상태를 보고 Reliable, Unreliable, 생략 중에서 골랐다.</p><p>상세: <a href="/posts/notd-reliable-rpc-data-streaming/">Reliable 버퍼 상태를 보고 대용량 데이터 나눠 보내기</a></p><p>RPC와 프로퍼티 복제의 순서 문제는 <a href="/posts/unreal-engine-replication-rpc-ordering/">언리얼 엔진의 리플리케이션 및 RPC 순서 보장</a>에 따로 정리했다.</p></section></section>
<section><h2>다수 개체<a href="#다수-개체"><span>#</span></a></h2><p><img alt="" loading="lazy" width="1280" height="720" src="/_astro/open-world-city.DOwGQEz7_Z2ozOi9.webp" /></p><section><h3>액터 대신 데이터<a href="#액터-대신-데이터"><span>#</span></a></h3><p>자연물은 인스턴스드 폴리지로 두고 타격받은 것만 액터로 바꿨다. 파괴된 자연물은 리스폰 매니저의 구조체로만 남는다. 스포너의 좀비는 정수 카운터, 구조체, 풀링 액터 사이를 오간다. 전환할 때는 거리뿐 아니라 추적 중인지, 플레이어 시야 안에 있는지, 스폰 위치로 돌아왔는지도 확인한다. 풀에 남는 메모리와 별개로 월드에서 시뮬레이션하고 복제할 액터 수를 줄이는 방식이다.</p><p>상세: <a href="/posts/notd-world-objects-as-data/">오픈월드의 오브젝트를 액터 대신 데이터로 두기</a></p></section><section><h3>좀비<a href="#좀비"><span>#</span></a></h3><p>웨이브의 좀비는 가장 가까운 플레이어와의 거리와 거리 순위로 단계를 나눠 이동 시뮬레이션의 최대 반복 수와 이동 동기화 전송률을 조절했다. 서버의 포즈 갱신은 전투와 유예 시간을 기준으로 켜고, 클라이언트에서는 Significance 값을 Animation Budget Allocator에 넘겼다. 네비게이션 인보커는 기본으로 끄고 조건에 따라 켰으며, 웨이브 좀비의 AI Perception을 제거하고 EQS 결과를 주변 좀비와 공유했다.</p><p>상세: <a href="/posts/notd-zombie-horde-cost/">다수 좀비의 서버 비용과 전송량 줄이기</a></p></section><section><h3>접속 과정<a href="#접속-과정"><span>#</span></a></h3><p>플레이 중 처음 사용할 에셋 일부를 접속 시점에 미리 읽도록 했다. Dedicated Server는 별도의 사전 로딩 목록을 쓴다. 최초 사용 시 로딩을 기다리는 대신 접속 시 로딩 시간과 메모리 유지 비용을 부담하는 선택이다.</p><p>상세: <a href="/posts/notd-level-streaming-status-rpc/">접속 시 레벨 스트리밍 상태 RPC 줄이기</a></p></section></section>
<section><h2>공통으로 쓴 방법<a href="#공통으로-쓴-방법"><span>#</span></a></h2><p>주제는 달라도 반복해서 쓴 방법이 몇 가지 있다.</p><ul>
<li>한 프레임에 처리하는 수를 고정한다. 리스폰 검사, 좀비 거리 계산, 클라이언트의 폴리지 정리는 프레임당 처리 수를 정해 두고 나눠 처리한다. 거리 순위의 전체 정렬과 순위 기록은 한 바퀴가 끝난 프레임에 남는다. 스폰 데이터 생성은 한 번에 처리하는 수를 제한하고 타이머로 다음 작업을 이어 간다.</li>
<li>주기 검사는 틱 대신 타이머로 돌리고 첫 실행을 난수만큼 늦춘다. 같은 종류의 액터가 한 프레임에 몰려 검사하지 않게 한다.</li>
<li>서버와 클라이언트가 같은 데이터를 이미 가지고 있으면 그것을 가리키는 값만 보낸다. 데이터 테이블의 ID와 레벨 코드, 몽타주 매니저의 태그와 인덱스 전송 경로가 여기에 해당한다.</li>
<li>엔진이 이미 알고 있는 값을 기준으로 삼는다. 전송 속도 조절과 신뢰성 선택은 따로 측정값을 만들지 않고 채널의 미확인 번치 수로 판단했다.</li>
</ul><p>같은 게임을 새 프로젝트에서 처음부터 설계한다면 바꿀 부분은 <a href="/posts/notd-multiplayer-redesign/">Night of the Dead 멀티플레이를 지금 다시 설계한다면</a>에 따로 정리했다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/replication-graph-in-unreal-engine" target="_blank">Replication Graph in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/networking-overview-for-unreal-engine" target="_blank">Networking Overview for Unreal Engine</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-replication-rpc-ordering/</id>
      <title type="text">언리얼 엔진의 리플리케이션 및 RPC 순서 보장</title>
      <published>2026-09-24T00:00:00.000Z</published>
      <updated>2026-09-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-replication-rpc-ordering/"/>
      <summary type="text">프로퍼티 복제와 OnRep, RPC의 순서 보장 범위를 살펴보고, 일시적인 상태 불일치와 액터 간 준비 순서를 처리하는 방법을 예시로 정리한다.</summary>
      <content type="html"><![CDATA[<p>언리얼 엔진에서 네트워크 프로그래밍을 하다 보면 리플리케이션이나 RPC가 작성한 코드 순서대로 처리되지 않는 상황을 만나게 된다. 서버에서 체력을 <code>0</code>으로 바꾼 뒤 사망 연출 RPC를 호출했는데 클라이언트의 RPC 안에서는 체력이 아직 <code>100</code>인 식이다.</p>
<p>이때 단순히 “네트워크에서는 순서가 보장되지 않는다”고 이해하면 혼란스러울 수 있다. 실제로는 순서가 보장되는 경우도 있고 같은 액터인지 다른 액터인지에 따라 보장 범위가 달라지기도 한다.</p>
<p>프로퍼티의 값이 반영되는 순서, <code>OnRep</code>가 실행되는 순서, RPC가 실행되는 순서를 나누어 살펴보자.</p>
<section><h2>프로퍼티는 모든 변경 과정을 전달하지 않는다<a href="#프로퍼티는-모든-변경-과정을-전달하지-않는다"><span>#</span></a></h2><p>프로퍼티 리플리케이션은 서버의 현재 상태를 클라이언트에 전달한다. 서버에서 값이 바뀐 모든 과정을 재현하지는 않는다.</p><p>서버의 탄약 수가 빠르게 바뀌었다고 하자.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>서버:        30 → 29 → 28 → 27</span></div></div><div><div><div>2</div></div><div><span>클라이언트:  30 ──────────→ 27</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>클라이언트는 <code>29</code>, <code>28</code>을 받지 못하고 <code>27</code>만 받을 수 있다. 액터가 매번 변경 직후 복제되는 것은 아니고 전송한 패킷이 손실될 수도 있기 때문이다.</p><p>프로퍼티 리플리케이션의 신뢰성은 서버의 최종 값에 대한 보장이다. 복제가 정상적으로 이어지면 클라이언트의 값도 서버의 최종 값으로 맞춰지지만 그 사이의 각 변경까지 빠짐없이 전달되지는 않는다.</p><p>값이 복제되기 전에 원래대로 돌아오면 변경 자체를 관찰하지 못할 수도 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 서버에서 실행한다.</span></div></div><div><div><div>2</div></div><div><span>// 두 대입 사이에 복제가 일어나지 않았다고 가정한다.</span></div></div><div><div><div>3</div></div><div><span>bIsReloading</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>4</div></div><div><span>bIsReloading</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>클라이언트의 기존 값도 <code>false</code>였다면, 이 변수만으로 장전이 발생했다는 사실을 알아낼 수 없다.</p><p><code>NetUpdateFrequency</code>를 높여도 모든 변경을 전달한다는 보장은 생기지 않는다. 따라서 현재 탄약 수를 표시하는 일과 발사할 때마다 효과음을 재생하는 일을 같은 방식으로 처리하면 안 된다.</p></section>
<section><h2>서로 다른 프로퍼티는 잠시 어긋날 수 있다<a href="#서로-다른-프로퍼티는-잠시-어긋날-수-있다"><span>#</span></a></h2><p>상자를 여는 액터에 다음 세 변수가 있다고 하자. 각각 독립적으로 복제되는 프로퍼티이며 초기값은 모두 <code>false</code>다.</p><p>이 글의 코드는 설명에 필요한 부분만 발췌한 예시다. 클래스 전체 선언, 액터의 복제 활성화, <code>GetLifetimeReplicatedProps()</code>의 등록 코드는 생략했다. Unreal 프로젝트에서 컴파일·실행 검증한 코드는 아니다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>)</span></div></div><div><div><div>2</div></div><div><span>bool</span><span> </span><span>bHasKey</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>)</span></div></div><div><div><div>5</div></div><div><span>bool</span><span> </span><span>bIsUnlocked</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>UPROPERTY</span><span>(</span><span>ReplicatedUsing</span><span> </span><span>=</span><span> </span><span>OnRep_IsOpen</span><span>)</span></div></div><div><div><div>8</div></div><div><span>bool</span><span> </span><span>bIsOpen</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>UFUNCTION</span><span>()</span></div></div><div><div><div>11</div></div><div><span>void</span><span> </span><span>OnRep_IsOpen</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 변수들이 클라이언트에서 일시적으로 어긋나는 상황을 이해하려면, 변경들이 같은 복제 업데이트에 포함됐는지부터 구분해야 한다.</p><section><h3>같은 복제 업데이트에 포함됐다면<a href="#같은-복제-업데이트에-포함됐다면"><span>#</span></a></h3><p>서버에서 세 값을 바꾸고 그 변경들이 모두 같은 액터의 한 복제 업데이트에 포함됐다고 하자.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AChest</span><span>::</span></span><span>OpenImmediately</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>HasAuthority</span><span>())</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>    </span><span>bHasKey</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>9</div></div><div><span>    </span><span>bIsUnlocked</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>10</div></div><div><span>    </span><span>bIsOpen</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 예시의 단순한 불리언 세 개는 함께 전달된다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>서버                                  클라이언트</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>세 값을 모두 true로 변경</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>│</span></div></div><div><div><div>5</div></div><div><span>복제 업데이트 #1 전송</span></div></div><div><div><div>6</div></div><div><span>[HasKey, IsUnlocked, IsOpen] ─────────→ #1 수신</span></div></div><div><div><div>7</div></div><div><span><span>                                      </span></span><span>세 값 반영</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 업데이트가 담긴 패킷이 손실됐다고 해서 <code>bIsUnlocked</code>만 빠지고 나머지 두 값이 적용되는 상황은 아니다.</p><p>다만 값들이 함께 전달되는 것과 서로 다른 <code>OnRep</code>의 실행 순서는 별개다. 세 프로퍼티에 각각 <code>OnRep</code>를 붙였더라도, 그 콜백들이 선언 순서대로 실행된다고 가정하면 안 된다. 아직 해석되지 않은 객체 참조는 이 단순한 불리언 예시와 구분해서 다뤄야 한다.</p></section><section><h3>서로 다른 복제 업데이트로 전송됐다면<a href="#서로-다른-복제-업데이트로-전송됐다면"><span>#</span></a></h3><p>이번에는 열쇠 확보, 잠금 해제, 상자 열기가 서로 다른 게임 이벤트에서 발생한다고 하자.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AChest</span><span>::</span></span><span>OnKeyObtained</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span> (</span><span>HasAuthority</span><span>())</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>        </span><span>bHasKey</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>void</span><span><span> </span><span>AChest</span><span>::</span></span><span>OnUnlockCompleted</span><span>()</span></div></div><div><div><div>10</div></div><div><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>if</span><span> (</span><span>HasAuthority</span><span>() </span><span>&amp;&amp;</span><span> </span><span>bHasKey</span><span>)</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>13</div></div><div><span>        </span><span>bIsUnlocked</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>15</div></div><div><span>}</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>void</span><span><span> </span><span>AChest</span><span>::</span></span><span>OnOpenRequested</span><span>()</span></div></div><div><div><div>18</div></div><div><span>{</span></div></div><div><div><div>19</div></div><div><span>    </span><span>if</span><span> (</span><span>HasAuthority</span><span>() </span><span>&amp;&amp;</span><span> </span><span>bIsUnlocked</span><span>)</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>21</div></div><div><span>        </span><span>bIsOpen</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>23</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>함수가 다르다는 사실만으로 변경들이 따로 전송되는 것은 아니다. 서로 다른 서버 프레임에서 호출됐더라도, 액터의 다음 복제 시점에 함께 전송될 수 있다.</p><p>여기서는 각 이벤트 사이에 실제 복제 업데이트가 있었고 세 변경이 각각 업데이트 #1, #2, #3으로 전송됐다고 가정한다. 또한 #2의 손실이 보완되기 전에 #3이 전송·수신되는 상황을 가정한다.</p><p></p><figure><img alt="업데이트 #2가 손실되고 #3이 먼저 도착해 OnRep_IsOpen이 종료된다. 이후 bIsUnlocked가 보완돼도 애니메이션은 다시 실행되지 않는다." loading="lazy" width="3000" height="1830" src="/_astro/replication-rpc-lost-update.D4uTnJQN_1qS8az.webp" /><figcaption>업데이트 #2가 손실되고 #3이 먼저 도착해 OnRep_IsOpen이 종료된다. 이후 bIsUnlocked가 보완돼도 애니메이션은 다시 실행되지 않는다.</figcaption></figure><p></p><p>도식에는 설명에 필요한 변경만 표시했다. 실제 업데이트에 항상 프로퍼티 하나만 들어간다는 의미는 아니다.</p><p>클라이언트가 관찰하는 값은 다음과 같다.</p>

<table><thead><tr><th>수신 시점</th><th><code>bHasKey</code></th><th><code>bIsUnlocked</code></th><th><code>bIsOpen</code></th></tr></thead><tbody><tr><td>초기 상태</td><td>false</td><td>false</td><td>false</td></tr><tr><td>#1 수신 후</td><td>true</td><td>false</td><td>false</td></tr><tr><td>#3 수신 후</td><td>true</td><td>false</td><td>true</td></tr><tr><td>보완 업데이트 수신 후</td><td>true</td><td>true</td><td>true</td></tr></tbody></table><p>#3을 받은 직후에는 “잠겨 있는데 열린 상자”가 된다. 서버에는 없었던 상태 조합이다.</p></section><section><h3>최종 값은 맞아져도 후속 처리는 누락될 수 있다<a href="#최종-값은-맞아져도-후속-처리는-누락될-수-있다"><span>#</span></a></h3><p>클라이언트의 상자 열림 처리가 다음과 같다고 하자.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AChest</span><span>::</span></span><span>OnRep_IsOpen</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>bIsOpen</span><span> </span><span>||</span><span> </span><span>!</span><span>bIsUnlocked</span><span>)</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>    </span><span>PlayOpenAnimation</span><span>();</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>#3을 받으면 <code>bIsOpen</code>은 <code>true</code>가 되고 <code>OnRep_IsOpen()</code>이 실행된다. 하지만 <code>bIsUnlocked</code>는 아직 <code>false</code>이므로 함수가 그대로 종료된다.</p><p>이후 보완 업데이트를 받으면 <code>bIsUnlocked</code>도 <code>true</code>가 된다. 그렇다고 <code>OnRep_IsOpen()</code>이 다시 호출되지는 않는다. 이 함수는 <code>bIsOpen</code>의 RepNotify이지, 다른 프로퍼티의 변경까지 감시하는 함수가 아니기 때문이다.</p><p>따라서 두 문제를 구분해야 한다.</p><ul>
<li>프로퍼티 값은 복제가 정상적으로 이어지면 누락된 상태가 보완되어 서버의 최종 값과 맞아진다.</li>
<li>게임 코드의 후속 처리는 그렇지 않다. 이전 값을 보고 건너뛴 애니메이션은 자동으로 다시 실행되지 않는다.</li>
</ul><p>이 예시에서는 <code>bIsUnlocked</code>가 최종 값에 도달해도 상자를 여는 애니메이션 처리는 영구적으로 누락된다.</p><p>두 값이 모두 필요하다면 어느 값이 나중에 도착해도 다시 확인하도록 만들 수 있다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// bIsUnlocked도 ReplicatedUsing = OnRep_IsUnlocked로 변경한다.</span></div></div><div><div><div>2</div></div><div><span>// 두 OnRep는 UFUNCTION으로 선언한다.</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>void</span><span><span> </span><span>AChest</span><span>::</span></span><span>OnRep_IsUnlocked</span><span>()</span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>TryPlayOpenAnimation</span><span>();</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>void</span><span><span> </span><span>AChest</span><span>::</span></span><span>OnRep_IsOpen</span><span>()</span></div></div><div><div><div>10</div></div><div><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>TryPlayOpenAnimation</span><span>();</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>void</span><span><span> </span><span>AChest</span><span>::</span></span><span>TryPlayOpenAnimation</span><span>()</span></div></div><div><div><div>15</div></div><div><span>{</span></div></div><div><div><div>16</div></div><div><span>    </span><span>if</span><span> (</span><span>bOpenAnimationPlayed</span><span> </span><span>||</span><span> </span><span>!</span><span>bIsOpen</span><span> </span><span>||</span><span> </span><span>!</span><span>bIsUnlocked</span><span>)</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>18</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>    </span><span>bOpenAnimationPlayed</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>22</div></div><div><span>    </span><span>PlayOpenAnimation</span><span>();</span></div></div><div><div><div>23</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>bOpenAnimationPlayed</code>는 <code>false</code>로 초기화한 클라이언트 로컬 플래그다. 여러 콜백에서 재확인하더라도 애니메이션을 중복 재생하지 않게 한다.</p><p>이 코드는 한 번만 열리는 상자를 가정한다. 다시 닫히는 상자나 늦게 접속한 플레이어에게는, 과거의 열림 연출을 재생할지 현재 열린 모습만 표시할지도 별도로 정해야 한다.</p><p>게임 규칙상 열쇠 확보, 잠금 해제, 열림이 항상 하나의 진행 단계라면, 여러 불리언 대신 <code>Locked</code>, <code>Unlocked</code>, <code>Open</code> 같은 하나의 상태 값으로 표현하는 설계도 고려할 수 있다.</p></section></section>
<section><h2>OnRep의 실행 순서도 따로 생각해야 한다<a href="#onrep의-실행-순서도-따로-생각해야-한다"><span>#</span></a></h2><p>앞의 예시는 값이 서로 다른 시점에 도착하는 문제였다. 값이 같은 업데이트로 도착해도, 서로 다른 프로퍼티의 <code>OnRep</code> 호출 순서에는 의존하면 안 된다.</p><p>무기 종류와 탄약 수를 각각 복제한다고 하자.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>OnRep_WeaponId</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>// 새 무기 UI를 만들며 탄약 표시는 0으로 초기화한다.</span></div></div><div><div><div>4</div></div><div><span>    </span><span>RecreateWeaponUI</span><span>(</span><span>WeaponId</span><span>);</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>OnRep_Ammo</span><span>()</span></div></div><div><div><div>8</div></div><div><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>SetAmmoText</span><span>(</span><span>Ammo</span><span>);</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 코드는 다음 순서를 기대한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>무기 UI 생성 → 탄약 표시 갱신</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>반대로 실행되면 탄약 표시가 다시 초기화된다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>기존 UI의 탄약 갱신 → 새 무기 UI 생성 → 탄약 표시가 0</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>한 콜백이 만든 UI를 다른 콜백이 사용하므로 두 콜백의 실행 순서에 의존한다. 변수 선언 순서나 서버의 대입 순서를 바꿔도 이 의존성이 해결되지는 않는다.</p><section><h3>함께 사용하는 값은 구조체로 묶는다<a href="#함께-사용하는-값은-구조체로-묶는다"><span>#</span></a></h3><p>무기 종류와 탄약을 하나의 상태로 묶으면 하나의 <code>OnRep</code>에서 처리할 수 있다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FWeaponState</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>7</div></div><div><span>    </span><span>FName</span><span> </span><span>WeaponId</span><span> </span><span>=</span><span> </span><span>NAME_None</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>10</div></div><div><span>    </span><span>int32</span><span> </span><span>Ammo</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>11</div></div><div><span>};</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>// AMyCharacter 클래스 내부</span></div></div><div><div><div>14</div></div><div><span>UPROPERTY</span><span>(</span><span>ReplicatedUsing</span><span> </span><span>=</span><span> </span><span>OnRep_WeaponState</span><span>)</span></div></div><div><div><div>15</div></div><div><span>FWeaponState</span><span> </span><span>WeaponState</span><span>;</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>UFUNCTION</span><span>()</span></div></div><div><div><div>18</div></div><div><span>void</span><span> </span><span>OnRep_WeaponState</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>OnRep_WeaponState</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>// 무기가 달라졌을 때만 UI를 교체한다.</span></div></div><div><div><div>4</div></div><div><span>    </span><span>EnsureWeaponUI</span><span>(</span><span>WeaponState</span><span>.</span><span>WeaponId</span><span>);</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>    </span><span>SetAmmoText</span><span>(</span><span>WeaponState</span><span>.</span><span>Ammo</span><span>);</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이제 처리 순서는 함수 안에 직접 적혀 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>OnRep_WeaponState()</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>└─ 무기 UI 준비</span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>└─ 탄약 표시 갱신</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>구조체로 묶으면 두 콜백 사이에 있던 순서 의존성을 하나의 함수 안으로 옮길 수 있다. 네트워크의 도착 순서는 그대로다.</p><p>이렇게 콜백의 처리 순서를 정해도 모든 중간 변경이 전달되지는 않는다. 여러 업데이트에 걸친 값들이 자동으로 하나의 완성된 상태가 되지도 않는다.</p></section><section><h3>변수를 분리해 둬야 한다면 PostRepNotifies를 사용한다<a href="#변수를-분리해-둬야-한다면-postrepnotifies를-사용한다"><span>#</span></a></h3><p>무기와 탄약을 별도 프로퍼티로 유지하면서 UI 갱신만 모으고 싶을 수도 있다. 이때는 각 <code>OnRep</code>에서 갱신이 필요하다는 표시만 남기고 <code>PostRepNotifies()</code>에서 실제 작업을 수행할 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// AMyCharacter 클래스 내부</span></div></div><div><div><div>2</div></div><div><span>bool</span><span> </span><span>bWeaponUIDirty</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>virtual</span><span> </span><span>void</span><span> </span><span>PostRepNotifies</span><span>() </span><span>override</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>OnRep_WeaponId</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>bWeaponUIDirty</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>OnRep_Ammo</span><span>()</span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>bWeaponUIDirty</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>PostRepNotifies</span><span>()</span></div></div><div><div><div>12</div></div><div><span>{</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>Super::</span><span>PostRepNotifies</span><span>();</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>bWeaponUIDirty</span><span>)</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>17</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span>    </span><span>bWeaponUIDirty</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>    </span><span>EnsureWeaponUI</span><span>(</span><span>WeaponId</span><span>);</span></div></div><div><div><div>23</div></div><div><span>    </span><span>SetAmmoText</span><span>(</span><span>Ammo</span><span>);</span></div></div><div><div><div>24</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>PostRepNotifies()</code>는 해당 객체의 <code>OnRep</code> 호출들이 끝난 뒤 실행된다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>OnRep_Ammo()      ─┐</span></div></div><div><div><div>2</div></div><div><span><span>                  </span></span><span>├─ 갱신 필요 표시</span></div></div><div><div><div>3</div></div><div><span>OnRep_WeaponId()  ─┘</span></div></div><div><div><div>4</div></div><div><span><span>                          </span></span><span>↓</span></div></div><div><div><div>5</div></div><div><span><span>                  </span></span><span>PostRepNotifies()</span></div></div><div><div><div>6</div></div><div><span><span>                          </span></span><span>↓</span></div></div><div><div><div>7</div></div><div><span><span>                  </span></span><span>무기 UI 준비 → 탄약 표시</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>두 <code>OnRep</code> 중 무엇이 먼저 실행돼도 실제 UI 처리 순서는 같아진다.</p><p>다만 이 함수가 손실된 업데이트나 다른 액터의 수신까지 기다려주는 것은 아니다. 무기 종류만 도착했다면, 그때의 탄약 값으로 갱신한다.</p><p><code>PostRepNotifies()</code>가 구조체보다 일반적으로 더 권장되는 방식은 아니다. 공식 문서는 여러 값을 함께 다뤄야 할 때 구조체로 묶는 방법을 권장하고 별도 <code>OnRep</code>의 후속 처리를 모으는 방법으로 <code>PostRepNotifies()</code>를 소개한다.</p><ul>
<li>하나의 상태로 함께 사용하는 값이라면 구조체와 하나의 <code>OnRep</code>로 묶는다.</li>
<li>프로퍼티는 분리하되 공통 후속 처리를 모아야 한다면 <code>PostRepNotifies()</code>를 사용한다.</li>
</ul></section></section>
<section><h2>Reliable RPC는 어디까지 순서를 보장할까<a href="#reliable-rpc는-어디까지-순서를-보장할까"><span>#</span></a></h2><p>RPC는 원격 컴퓨터에서 함수를 실행하도록 요청하는 기능이다. 프로퍼티가 현재 상태를 전달한다면, RPC는 함수 호출을 전달한다.</p>

<table><thead><tr><th>종류</th><th>패킷이 손실되었을 때</th></tr></thead><tbody><tr><td>Reliable</td><td>수신 확인을 받을 때까지 재전송한다</td></tr><tr><td>Unreliable</td><td>손실된 호출을 재전송하지 않는다</td></tr></tbody></table><p>같은 액터에서 같은 수신 대상으로 보내는 Reliable RPC끼리는 호출 순서가 유지된다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 모두 Reliable Client RPC라고 가정한다.</span></div></div><div><div><div>2</div></div><div><span>Weapon</span><span>-&gt;</span><span>ClientStartReload</span><span>();</span></div></div><div><div><div>3</div></div><div><span>Weapon</span><span>-&gt;</span><span>ClientFinishReload</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>클라이언트에서는 장전 시작을 처리한 뒤 장전 완료를 처리한다. 재전송 때문에 늦어질 수는 있어도 완료가 시작보다 먼저 실행되지는 않는다.</p><p>같은 액터에 속한 복제 서브오브젝트의 Reliable RPC도 이 순서 범위에 포함된다. 반면 서로 다른 액터 사이에는 보장이 없다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ActorA</span><span>-&gt;</span><span>ClientReliableFirst</span><span>();</span></div></div><div><div><div>2</div></div><div><span>ActorB</span><span>-&gt;</span><span>ClientReliableSecond</span><span>();</span></div></div><div><div><div>3</div></div><div><span>ActorA</span><span>-&gt;</span><span>ClientReliableThird</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>세 호출이 같은 클라이언트로 전달되더라도 보장되는 것은 <code>First → Third</code>다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>가능: First → Second → Third</span></div></div><div><div><div>2</div></div><div><span>가능: Second → First → Third</span></div></div><div><div><div>3</div></div><div><span>가능: First → Third → Second</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>Reliable</code>은 여러 액터의 호출을 하나의 전역 순서로 정렬하는 설정이 아니다.</p><section><h3>Reliable과 Unreliable을 섞으면<a href="#reliable과-unreliable을-섞으면"><span>#</span></a></h3><p>같은 액터에서 다음 순서로 호출했다고 하자.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>R1: Reliable</span></div></div><div><div><div>2</div></div><div><span>U2: Unreliable Unicast</span></div></div><div><div><div>3</div></div><div><span>R3: Reliable</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>호출 순서: R1 → U2 → R3</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>U2</code>의 패킷이 손실되면 해당 호출은 실행되지 않는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>실행 순서: R1 → R3</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>R1</code>의 패킷이 손실되어 재전송을 기다리는 동안 <code>U2</code>가 먼저 실행될 수도 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>실행 순서: U2 → R1 → R3</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Reliable끼리의 <code>R1 → R3</code>는 유지되지만 세 호출 전체의 순서는 보장되지 않는다.</p></section><section><h3>Multicast도 호출 순서대로 처리되는 것은 아니다<a href="#multicast도-호출-순서대로-처리되는-것은-아니다"><span>#</span></a></h3><p>Unicast RPC는 특정 원격 대상으로, Multicast RPC는 여러 대상으로 호출을 전달한다. 같은 액터에서 같은 수신 대상으로 보내고 모두 Reliable RPC라면 Multicast와 Unicast를 섞어도 순서가 유지된다.</p><p>Unreliable Multicast는 다르다. 기본 전송 정책에서는 잠시 대기했다가 뒤에 묶여 전송되므로, 먼저 호출했어도 다른 RPC보다 나중에 실행될 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>호출: Unreliable Multicast → Reliable Client RPC</span></div></div><div><div><div>2</div></div><div><span>실행: Reliable Client RPC → Unreliable Multicast</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>전송 정책을 <code>ForceSend</code>나 <code>ForceQueue</code>로 바꾸면 전송 시점도 달라진다. 이 설정은 각각 지원되는 조건에서 전송을 앞당기거나 뒤로 미루는 데 사용되며 서로 다른 액터나 프로퍼티까지 포함한 전체 순서를 보장해주지는 않는다.</p></section></section>
<section><h2>프로퍼티를 바꾼 뒤 RPC를 호출하면<a href="#프로퍼티를-바꾼-뒤-rpc를-호출하면"><span>#</span></a></h2><p>다음 코드에서는 탄약을 채운 뒤 장전 완료를 알린다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 서버</span></div></div><div><div><div>2</div></div><div><span>Ammo</span><span> </span><span>=</span><span> </span><span>30</span><span>;</span></div></div><div><div><div>3</div></div><div><span>ClientReliableReloadFinished</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>클라이언트의 <code>ClientReliableReloadFinished()</code>가 실행될 때도 <code>Ammo</code>가 반드시 <code>30</code>일까?</p><p>그렇지 않다. RPC의 Reliable 설정은 앞서 변경한 프로퍼티의 적용까지 기다리게 하지 않는다.</p><p>일반적으로 RPC가 대기 없이 전송되고 프로퍼티 업데이트도 함께 전송되면 RPC가 먼저 처리될 수 있다. 기본 Unreliable Multicast처럼 뒤로 대기하는 RPC는 프로퍼티보다 뒤에 처리되는 경우도 있다. 패킷 손실까지 고려하면 이 순서를 게임 로직의 고정된 전제로 사용해서는 안 된다.</p><section><h3>현재 상태를 맞추려면 프로퍼티와 OnRep를 사용한다<a href="#현재-상태를-맞추려면-프로퍼티와-onrep를-사용한다"><span>#</span></a></h3><p>남은 탄약, 현재 무기, 장전 중인지 여부는 현재 상태다. 나중에 접속하거나 액터가 뒤늦게 보이더라도 현재 상태에 맞는 표시가 필요하다.</p><p>예를 들어 지속되는 장전 표시를 제어하려면 <code>bIsReloading</code> 같은 상태를 복제하고 <code>OnRep</code>에서 표시를 켜거나 끌 수 있다.</p><p>다만 클라이언트의 <code>OnRep</code>가 게임 규칙상의 장전을 완료하는 것은 아니다. 서버가 장전 완료를 판단하고 탄약과 장전 상태를 변경하면, 클라이언트가 그 결과를 받아 표현한다.</p><p>또한 탄약이 증가했다는 이유만으로 장전 완료라고 판단하면 탄약 획득 등 다른 변경과 혼동할 수 있다. 장전 상태 자체가 필요하다면 이를 명시적으로 표현하는 편이 낫다.</p></section><section><h3>그 순간의 연출을 전달하려면 RPC를 고려한다<a href="#그-순간의-연출을-전달하려면-rpc를-고려한다"><span>#</span></a></h3><p>장전 완료음이나 짧은 불꽃처럼, 그 순간의 수신자에게만 보여줄 연출은 RPC가 적합할 수 있다. 뒤늦게 접속한 플레이어에게 과거의 완료음을 재생할 필요는 없기 때문이다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 소유 클라이언트에게만 전달하는 일회성 연출 예시다.</span></div></div><div><div><div>2</div></div><div><span>UFUNCTION</span><span>(</span><span>Client</span><span>, </span><span>Unreliable</span><span>)</span></div></div><div><div><div>3</div></div><div><span>void</span><span> </span><span>ClientPlayReloadFinishedEffect</span><span>(</span><span>int32</span><span><span> </span><span>AmmoAtCompletion</span><span>);</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 서버가 장전 완료를 처리하는 코드</span></div></div><div><div><div>2</div></div><div><span>Ammo</span><span> </span><span>=</span><span> </span><span>30</span><span>;</span></div></div><div><div><div>3</div></div><div><span>bIsReloading</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>ClientPlayReloadFinishedEffect</span><span>(</span><span>Ammo</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>RPC 구현에서는 이벤트 당시의 값인 <code>AmmoAtCompletion</code>을 사용한다. 복제 프로퍼티 <code>Ammo</code>가 먼저 갱신됐다고 가정할 필요가 없다. 이후 발사가 진행됐다면 이 인자는 현재 탄약 수와 다를 수 있다.</p><p>이 예시의 연출은 가끔 누락돼도 게임 상태에는 영향이 없다는 전제로 <code>Unreliable</code>을 사용했다. 반드시 전달해야 하는 호출이라면 Reliable을 검토하되, 잦은 호출의 재전송 비용도 고려해야 한다.</p>

<table><thead><tr><th>필요한 동작</th><th>선택 기준</th></tr></thead><tbody><tr><td>현재 탄약 표시</td><td>프로퍼티와 <code>OnRep</code></td></tr><tr><td>장전 중인 동안 유지되는 표시</td><td>장전 상태를 복제하고 <code>OnRep</code>로 갱신</td></tr><tr><td>순간적인 장전 완료음</td><td>RPC 고려</td></tr><tr><td>장전 완료에 따른 실제 탄약 지급</td><td>서버의 게임 로직에서 처리</td></tr></tbody></table><p>이펙트라도 불타는 상태처럼 지속되고 늦게 도착한 클라이언트에도 보여야 한다면 프로퍼티 기반 처리가 적합하다. 반대로 모든 일회성 사건을 받아야 한다면, 중간 변경을 건너뛸 수 있는 <code>OnRep</code>만으로 처리해서는 안 된다.</p></section></section>
<section><h2>다른 액터의 준비 여부는 직접 확인한다<a href="#다른-액터의-준비-여부는-직접-확인한다"><span>#</span></a></h2><p>캐릭터와 인벤토리가 별도 액터라면, 캐릭터의 인벤토리 참조와 인벤토리의 데이터가 어느 순서로 준비될지 가정해서는 안 된다.</p><p>간단한 예로, 인벤토리의 슬롯 수를 받아 UI를 만든다고 하자. 이 예시에서는 슬롯 수가 <code>INDEX_NONE</code>이면 아직 준비되지 않았고 <code>0</code> 이상이면 준비된 것으로 정한다. 빈 인벤토리도 유효하므로 <code>0</code>을 미준비 상태로 취급하지 않는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// AInventory 클래스의 공개 멤버 발췌</span></div></div><div><div><div>2</div></div><div><span>DECLARE_MULTICAST_DELEGATE</span><span>(</span><span>FOnSlotsChanged</span><span>);</span></div></div><div><div><div>3</div></div><div><span>FOnSlotsChanged</span><span> </span><span>OnSlotsChanged</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>UPROPERTY</span><span>(</span><span>ReplicatedUsing</span><span> </span><span>=</span><span> </span><span>OnRep_NumSlots</span><span>)</span></div></div><div><div><div>6</div></div><div><span>int32</span><span> </span><span>NumSlots</span><span> </span><span>=</span><span> </span><span>INDEX_NONE</span><span>;</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>UFUNCTION</span><span>()</span></div></div><div><div><div>9</div></div><div><span>void</span><span> </span><span>OnRep_NumSlots</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AInventory</span><span>::</span></span><span>OnRep_NumSlots</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>OnSlotsChanged</span><span>.</span><span>Broadcast</span><span>();</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>여기서 <code>OnSlotsChanged</code>는 클라이언트 안에서 객체 사이에 알림을 전달하는 C++ 델리게이트다. 앞에서 다룬 네트워크 Multicast RPC와는 다르다.</p><p>캐릭터는 인벤토리 참조를 받으면 데이터 변경 알림을 구독한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// AMyCharacter 클래스 내부</span></div></div><div><div><div>2</div></div><div><span>UPROPERTY</span><span>(</span><span>ReplicatedUsing</span><span> </span><span>=</span><span> </span><span>OnRep_Inventory</span><span>)</span></div></div><div><div><div>3</div></div><div><span>TObjectPtr</span><span>&lt;</span><span>AInventory</span><span>&gt;</span><span> </span><span>Inventory</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>TWeakObjectPtr</span><span>&lt;</span><span>AInventory</span><span>&gt;</span><span> </span><span>ObservedInventory</span><span>;</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>UFUNCTION</span><span>()</span></div></div><div><div><div>8</div></div><div><span>void</span><span> </span><span>OnRep_Inventory</span><span>();</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>void</span><span> </span><span>TryRefreshInventoryUI</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>OnRep_Inventory</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span> (</span><span>AInventory</span><span>*</span><span> </span><span>Previous</span><span> </span><span>=</span><span> </span><span>ObservedInventory</span><span>.</span><span>Get</span><span>())</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>        </span><span>Previous</span><span>-&gt;</span><span>OnSlotsChanged</span><span>.</span><span>RemoveAll</span><span>(</span><span>this</span><span>);</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>    </span><span>ObservedInventory</span><span> </span><span>=</span><span> </span><span>Inventory</span><span>.</span><span>Get</span><span>();</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>    </span><span>if</span><span> (</span><span>IsValid</span><span>(</span><span>Inventory</span><span>.</span><span>Get</span><span>()))</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>12</div></div><div><span>        </span><span>Inventory</span><span>-&gt;</span><span>OnSlotsChanged</span><span>.</span><span>AddUObject</span><span>(</span></div></div><div><div><div>13</div></div><div><span>            </span><span>this</span><span>,</span></div></div><div><div><div>14</div></div><div><span>            </span><span>&amp;</span><span>AMyCharacter::</span><span>TryRefreshInventoryUI</span><span>);</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>// 알림이 이미 발생했을 수도 있으므로 지금 상태도 확인한다.</span></div></div><div><div><div>18</div></div><div><span>    </span><span>TryRefreshInventoryUI</span><span>();</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>void</span><span><span> </span><span>AMyCharacter</span><span>::</span></span><span>TryRefreshInventoryUI</span><span>()</span></div></div><div><div><div>22</div></div><div><span>{</span></div></div><div><div><div>23</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>IsValid</span><span>(</span><span>Inventory</span><span>.</span><span>Get</span><span>()) </span><span>||</span><span> </span><span>Inventory</span><span>-&gt;</span><span>NumSlots</span><span> </span><span>==</span><span> </span><span>INDEX_NONE</span><span>)</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>25</div></div><div><span>        </span><span>HideInventoryUI</span><span>();</span></div></div><div><div><div>26</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>// 기존 UI를 현재 슬롯 수에 맞춘다.</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>// 반복 호출해도 위젯이 중복 생성되지 않게 구현한다.</span></div></div><div><div><div>31</div></div><div><span>    </span><span>RefreshInventorySlots</span><span>(</span><span>Inventory</span><span>-&gt;</span><span>NumSlots</span><span>);</span></div></div><div><div><div>32</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이 코드는 두 순서 모두 처리한다.</p><p></p><figure><img alt="참조가 먼저 도착하면 변경 알림을 기다려 UI를 갱신하고, 슬롯 수가 먼저 도착하면 구독 직후 현재 상태를 확인해 즉시 표시한다." loading="lazy" width="3000" height="1830" src="/_astro/replication-rpc-inventory-ready.dogNlnfB_Fqtnf.webp" /><figcaption>참조가 먼저 도착하면 변경 알림을 기다려 UI를 갱신하고, 슬롯 수가 먼저 도착하면 구독 직후 현재 상태를 확인해 즉시 표시한다.</figcaption></figure><p></p><p>이렇게 나중의 변경을 구독하면서 이미 준비된 상태도 즉시 확인한다. 알림만 기다리면 두 번째 경우에서 놓친 알림을 계속 기다리게 된다.</p><p><code>HideInventoryUI()</code>와 <code>RefreshInventorySlots()</code>는 프로젝트에서 구현할 UI 함수다. 실제 코드에서는 캐릭터의 <code>EndPlay()</code>에서도 구독을 정리하고 UI 자체의 준비가 늦어질 수 있다면 UI 준비 시점에도 <code>TryRefreshInventoryUI()</code>를 호출해야 한다.</p><p>이 예시는 슬롯 수 하나의 준비 여부만 다룬다. 아이템 목록 등 여러 데이터가 모두 필요하다면 <code>NumSlots</code> 하나를 전체 인벤토리의 준비 완료 신호로 사용해서는 안 된다.</p><p>Replication Graph의 의존성 설정을 사용하더라도 수신 순서를 가정하는 대신, 이러한 준비 조건을 확인해야 한다.</p></section>
<section><h2>지연과 패킷 손실이 있는 환경에서 테스트한다<a href="#지연과-패킷-손실이-있는-환경에서-테스트한다"><span>#</span></a></h2><p>네트워크 지연이나 손실이 거의 없는 로컬 환경에서는 잘못된 순서 가정이 드러나지 않을 수 있다.</p><p>Network Emulation으로 지연과 패킷 손실을 적용하면, 필요한 데이터가 늦게 도착하거나 Unreliable RPC가 누락되는 상황을 테스트할 수 있다.</p><p>이때는 서버와 클라이언트의 최종 값이 일치하는지만 확인해서는 안 된다. 데이터가 준비되기 전에 실행된 코드가 잘못된 UI를 표시하지 않는지, 준비될 때까지 미뤄둔 처리가 데이터 수신 후 다시 실행되는지도 확인해야 한다. 여러 콜백에서 같은 처리를 재시도한다면 UI나 연출이 중복으로 생성되지 않는지도 함께 살펴본다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/replicated-object-execution-order-in-unreal-engine" target="_blank">Replicated Object Execution Order</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/using-network-emulation-in-unreal-engine" target="_blank">Network Emulation</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/firefly-upstream-sync-6-16-8/</id>
      <title type="text">Firefly 업스트림 반영: 6.16.8 (6d82554bf)</title>
      <published>2026-09-22T00:00:00.000Z</published>
      <updated>2026-09-22T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/firefly-upstream-sync-6-16-8/"/>
      <summary type="text">Firefly 6.16.8과 이후 6d82554bf까지의 구독 기능, 프로젝트 표시, 사이드바와 페이지 스타일 변경 사항.</summary>
      <content type="html"><![CDATA[<p>Firefly 6.16.8과 이후 <code>6d82554bf</code>까지의 업스트림 변경을 반영했다. <a href="/posts/firefly-upstream-sync-6-16-7/">지난 반영</a> 기준인 <code>083603e4c</code> 이후 17개 커밋이며, Astro는 7.3.2로 올렸다.</p>
<section><h2>구독과 사이트 안내<a href="#구독과-사이트-안내"><span>#</span></a></h2><ul>
<li>기존 RSS와 함께 <a href="/atom.xml">Atom 피드</a>를 제공한다. 푸터에 Atom 링크를 추가하고, <a href="/atom/">구독 안내 페이지</a>에서 피드 주소를 복사할 수 있다.</li>
<li>RSS와 Atom의 본문 생성 로직을 통합했다. <code>siteConfig.feed.contentMode</code>를 <code>full</code> 또는 <code>summary</code>로 지정할 수 있으며, 이 블로그는 전문을 제공하는 <code>full</code>을 사용한다.</li>
<li><a href="/llms.txt">llms.txt</a>에 사이트 설명, 활성화한 주요 페이지, 최근 글 20개의 링크와 설명을 제공한다.</li>
</ul></section>
<section><h2>프로젝트와 페이지 표시<a href="#프로젝트와-페이지-표시"><span>#</span></a></h2><ul>
<li>프로젝트 카드의 상태 배지를 표지 오른쪽 위로 옮기고 날짜·태그 표시를 조정했다.</li>
<li>글 메타데이터의 태그 줄바꿈과 아이콘 정렬을 보완했다.</li>
<li>앨범 목록 카드 스타일을 변경했다.</li>
<li>페이지별 스타일을 분리하고 프로젝트·갤러리 등 주요 페이지의 머리글 스타일을 통일했다.</li>
</ul></section>
<section><h2>사이드바와 푸터<a href="#사이드바와-푸터"><span>#</span></a></h2><ul>
<li>실제 표시할 위젯이 있는지에 따라 사이드바 열과 본문 너비를 계산하도록 메인 그리드를 변경했다.</li>
<li><code>showBothSidebarsOnPostPage</code> 옵션을 제거했다. 사이드바가 없는 페이지의 본문 너비는 새 옵션 <code>noSidebarContentWidth</code>로 설정한다.</li>
<li>푸터를 새 레이아웃 구조에 맞춰 배치했다. <code>footerConfig.enable</code> 옵션을 제거했으며, 추가 내용은 <code>src/config/FooterConfig.html</code>을 직접 편집한다.</li>
</ul></section>
<section><h2>화면 동작과 의존성<a href="#화면-동작과-의존성"><span>#</span></a></h2><ul>
<li>태블릿에서 떠 있는 목차의 하단 항목이 잘리는 문제를 수정했다.</li>
<li>전체화면 레이아웃 전환을 허용하지 않을 때는 저장된 캐시 대신 사이트 설정을 사용하도록 수정했다.</li>
<li>Waline의 이모지 패널 잘림과 분류 전환 시 흔들림 수정 사항을 포함했다. 이 블로그의 댓글은 Giscus를 사용한다.</li>
<li>Astro를 7.2.10에서 7.3.2로, <code>@astrojs/mdx</code>를 7.0.8에서 8.0.1로 올렸다.</li>
<li>OG 이미지 생성 시 Google Fonts를 요청하는 대신 패키지에 포함된 로컬 폰트를 읽도록 변경했다. 이 블로그의 OG 이미지 자동 생성은 비활성화 상태다.</li>
</ul></section>
<section><h2>검증과 반영 범위<a href="#검증과-반영-범위"><span>#</span></a></h2><ul>
<li><code>pnpm check</code>: 261개 파일, 오류·경고·힌트 0개.</li>
<li><code>pnpm type-check</code>, <code>pnpm build</code>: 통과. 정적 페이지 98개를 생성하고 Pagefind에 72개 페이지를 인덱싱했다.</li>
<li>RSS와 Atom이 각각 글 50개를 포함하는 유효한 XML인지 확인했다.</li>
<li>프로젝트 검색·상세 이동, 모바일 상세와 Atom 링크 복사, 태블릿 목차, 데스크톱 몰입 읽기 진입·종료를 확인했다.</li>
</ul><p><a href="https://github.com/CuteLeaf/Firefly/compare/083603e4cbbb456198797ce36d5a10e8c3b21066...6d82554bfe1cb3d4b43adb0969dad1d43ac6dee3" target="_blank">업스트림 변경 비교</a></p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/firefly-upstream-sync-6-16-7/</id>
      <title type="text">Firefly 업스트림 반영: 6.16.7</title>
      <published>2026-09-08T00:00:00.000Z</published>
      <updated>2026-09-08T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/firefly-upstream-sync-6-16-7/"/>
      <summary type="text">Firefly 6.16.7과 이후 083603e4c까지의 프로젝트 페이지, 몰입 읽기, 메타데이터 및 화면 동작 변경 사항.</summary>
      <content type="html"><![CDATA[<p>Firefly 6.16.7과 이후 <code>083603e4c</code>까지의 업스트림 변경을 반영했다. <a href="/posts/firefly-upstream-sync-6-16-6/">지난 반영</a> 기준은 <code>0f2fab602</code>이며, Astro 버전은 7.2.10이다.</p>
<section><h2>프로젝트 페이지<a href="#프로젝트-페이지"><span>#</span></a></h2><ul>
<li>블로그 글과 별도 컬렉션인 <a href="/projects/">프로젝트 목록</a>과 상세 페이지를 추가했다. 내비게이션의 ‘내 공간’에서 접근할 수 있다.</li>
<li>이름·설명·태그 검색과 상태 필터를 지원한다. <code>order</code> 내림차순으로 정렬하고, 같은 값이면 <code>published</code>가 최신인 순서로 표시한다.</li>
<li>프로젝트 작성 가이드를 한국어로 옮겨 <a href="/posts/firefly-blog-guide/#%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0">블로그 가이드</a>에 통합했다. 기존 작성 가이드 주소도 해당 절로 연결된다.</li>
<li><a href="https://github.com/hon454/hon454.github.io/blob/a2d9847037d0472ac617d2814028cd165b601f64/src/content/projects/firefly.md" target="_blank">Firefly 테마 예제</a>를 포함했다. 예제 본문은 중국어이며, 상태와 검색 안내는 한국어로 표시된다.</li>
</ul></section>
<section><h2>몰입 읽기<a href="#몰입-읽기"><span>#</span></a></h2><ul>
<li>데스크톱의 글·프로젝트 상세에 본문과 전용 목차를 표시하는 몰입 읽기를 추가했다. 오른쪽 아래 버튼으로 진입한다.</li>
<li>목차의 접기·펼치기를 지원하며, <code>Esc</code>로 종료하면 진입 전 스크롤 위치로 돌아간다.</li>
<li><code>src/config/siteConfig.ts</code>의 <code>post.immersiveReading</code>에서 설정한다. 현재 기능은 활성화했으며, 기본 진입 상태는 꺼짐, 목차 위치는 왼쪽이다.</li>
</ul></section>
<section><h2>글 하단 설정과 메타데이터<a href="#글-하단-설정과-메타데이터"><span>#</span></a></h2><ul>
<li>공유 설정 이름을 <code>post.sharePoster</code>에서 <code>post.share</code>로 변경했다.</li>
<li>이전·다음 글 이동(<code>postNavigation</code>), 관련 글(<code>relatedPosts</code>), 무작위 글(<code>randomPosts</code>)의 표시 여부를 개별 설정할 수 있다. 현재는 모두 활성화했다.</li>
<li>글 표지 또는 사이트 로고를 사용하는 OG 메타데이터를 보완하고, OG 언어와 이미지 설명을 추가했다. OG 이미지 자동 생성은 비활성화 상태다.</li>
<li>아카이브와 검색 페이지의 canonical에서 필터 쿼리를 제거했다.</li>
</ul></section>
<section><h2>배경과 페이지 이동<a href="#배경과-페이지-이동"><span>#</span></a></h2><ul>
<li>배경 모드 전환 처리와 텍스트 잔상 관련 CSS 수정 사항을 반영했다.</li>
<li>페이지 전환 중 본문 전체에 지속적으로 적용하던 <code>will-change: transform</code>을 제거했다.</li>
<li>모바일 메뉴에서 링크를 누르면 패널이 닫히도록 변경했다.</li>
</ul></section>
<section><h2>반영 기준과 검증<a href="#반영-기준과-검증"><span>#</span></a></h2><ul>
<li>반영 범위: <a href="https://github.com/CuteLeaf/Firefly/compare/0f2fab602407eb577cdcca066049fe947563ae0f...083603e4cbbb456198797ce36d5a10e8c3b21066" target="_blank"><code>0f2fab602…083603e4c</code></a></li>
<li>정적 검사·빌드: <code>pnpm check</code>, <code>pnpm type-check</code>, <code>pnpm build</code> 통과</li>
<li>동작 확인: 프로젝트 목록·상세·표지·검색·상태 필터, 모바일 메뉴 이동, 배경 모드 전환, 글 표지의 목록·격자 표시, 몰입 읽기 진입·목차 이동·접기·종료</li>
<li>성능 벤치마크는 별도로 실행하지 않았다.</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/ai-tools-workflow-and-sharing/</id>
      <title type="text">AI 도구를 쓰다가 만든 것들, 그리고 동료와 나눈 것들</title>
      <published>2026-09-04T00:00:00.000Z</published>
      <updated>2026-09-04T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/ai-tools-workflow-and-sharing/"/>
      <summary type="text">IDE 컨텍스트 복사 플러그인, 작업 추천 스킬, 검색 도구 비교와 사내 세션 코치 공유 기록을 통해 AI를 업무에 적용해 온 과정을 돌아본다.</summary>
      <content type="html"><![CDATA[<p>사내에서 AI와 자동화 이야기를 나누던 채널을 아카이빙한다는 공지가 올라왔다. 새 도구를 발견하면 링크를 올리고 써보다 막히면 질문하던 곳이었다. 내가 만든 작은 도구를 공유하기도 했다.</p>
<p><img alt="" loading="lazy" width="727" height="489" src="/_astro/worksmart-archive-thread.BcFg142F_1rqLfy.webp" /></p>
<p>기록을 다시 보면서 내가 AI를 어떻게 활용해 왔는지 정리해 보고 싶어졌다. 새 모델 소식이나 사용량 이야기 사이에는 내가 해결하려던 문제들도 남아 있었다. IDE에서 보고 있는 코드를 에이전트에게 전달하는 일, 여러 작업 중 다음에 할 일을 고르는 일, 새 도구가 실제로 도움이 되는지 판단하는 일이었다.</p>
<section><h2>IDE에서 보던 코드를 에이전트에게 전달하기<a href="#ide에서-보던-코드를-에이전트에게-전달하기"><span>#</span></a></h2><p>2026년 2월에는 JetBrains IDE에서 선택한 코드의 위치를 터미널 쪽으로 쉽게 전달하고 싶었다. 코드를 가리키려면 파일 경로와 줄 번호가 필요했다. 이를 한 번에 복사하는 플러그인 <a href="https://github.com/hon454/copy-selection-context" target="_blank">Copy Selection Context</a>를 만들고 채널에 공유했다.</p><blockquote><p>연휴 마지막날에 Jetbrains IDE에서 사용가능한 플러그인 하나 말아봤습니다.</p><p>IDE에서 TUI 쪽으로 선택 라인에 대한 컨텍스트 쉽게 전달하고 싶어서 만들어 봤어요.</p></blockquote><p>— 2026년 2월 18일, Slack 메시지 발췌</p><p>플러그인은 파일 경로와 선택한 줄 범위를 클립보드에 넣는다. 필요하면 선택한 코드도 함께 담을 수 있다. 예를 들어 파일 경로와 줄 범위를 넘기면 에이전트에게 어느 부분을 보고 있는지 설명할 수 있다. 현재 공개 저장소에는 출력 형식과 사용법도 정리되어 있다.</p></section>
<section><h2>반복하던 요청을 스킬로 만들기<a href="#반복하던-요청을-스킬로-만들기"><span>#</span></a></h2><p>코드의 위치를 전달하는 일 외에도 도구 사이에서 반복하던 요청이 있었다. Codex에서 계획을 세우고 Claude Code에 검토를 맡기는 일이었다. 3월에는 이 흐름을 스킬로 만들어 공유했다. 이후 도구 비교를 요청받았을 때도 이 사용 경험을 설명했다.</p><p>두 도구를 같이 쓴다고 연결이 저절로 매끄러워지지는 않았다. 당시에도 이런 조합을 쓰려면 연계를 위한 스킬이나 별도의 작업 구성이 필요하다고 적었다. 모델별 결과에 대한 체감과 함께 실제로 연결해 쓰는 데 필요한 구성도 선택 기준이었다.</p><p>5월에는 다른 반복 작업을 스킬로 묶었다. 작업하다가 다음에 무엇을 해야 할지 확인하는 일이었다. 현재 브랜치와 로컬 변경, 최근 커밋, PR과 이슈를 함께 살펴보는 <a href="https://github.com/hon454/grimoire/blob/53a719e2bfdabe7c49523a6ec0237811a239ff4a/plugins/grimoire/skills/now-what/SKILL.md" target="_blank"><code>now-what</code></a>을 만들었다.</p><blockquote><p>최근 만들고 애용하는 <code>now-what</code> 스킬을 공유드립니다.</p><p>작업하다가 “근데 이제 뭐함?” 싶은 순간에 쓰려고 만든 스킬입니다.
현재 작업 중인 브랜치, 로컬 변경사항, 최근 커밋, 연결된 PR/이슈 등을 확인해서 지금 다음에 무엇을 하면 좋을지 추천 해줍니다.</p></blockquote><p>— 2026년 5월 28일, Slack 메시지 발췌</p><p>공유할 때는 내가 사용하던 출력 예시도 붙였다. 진행 중인 작업과 다른 작업을 막고 있는 의존 관계, 이미 끝난 PR의 상태를 보고 이어갈 작업과 먼저 확인할 항목을 제안하는 예시였다.</p><p>추천 문장보다 근거가 중요했다. 지금 작업 중인 브랜치에 변경이 쌓여 있는지, 연결된 PR이 이미 병합됐는지에 따라 다음 행동이 달라졌다. 스킬에는 그런 상태를 확인하도록 반복 요청을 정리해 넣었다.</p><p>동료도 자신의 업무에 필요하겠다는 반응을 남겼다.</p></section>
<section><h2>검색이 빠르다는 말도 나눠서 확인하기<a href="#검색이-빠르다는-말도-나눠서-확인하기"><span>#</span></a></h2><p>직접 만든 도구 외에 기존 도구를 작업 환경에 붙여 쓰는 방법도 공유했다. 6월에 소개한 CodeGraph는 에이전트가 코드를 탐색할 때 사용하는 도구였다. 새 worktree에 인덱스가 없어 초기화가 필요한 경우를 설명하고 worktree 설정 스크립트와 탐색 지침을 함께 정리했다.</p><p>설정 방법과 함께 기존에 쓰던 <code>rg</code>와의 비교 보고서도 첨부했다. 한 worktree에서 심볼을 찾는 여덟 가지 과제를 비교했다. 각 쿼리는 한 번 준비 실행하고 아홉 번 측정했으며 CLI를 시작하는 시간도 포함했다.</p>

<table><thead><tr><th>비교 항목</th><th>CodeGraph</th><th><code>rg</code></th></tr></thead><tbody><tr><td>과제별 지연시간 중앙값의 중앙값</td><td>144.98 ms</td><td>14.37 ms</td></tr><tr><td>기대한 파일이 첫 번째에 나온 과제</td><td>8 / 8</td><td>4 / 8</td></tr><tr><td>기대한 파일이 세 번째 이내에 나온 과제</td><td>8 / 8</td><td>6 / 8</td></tr></tbody></table><p>명령 실행 시간은 <code>rg</code>가 짧았다. CodeGraph는 기대한 구현 파일을 결과의 앞부분에 놓았다. 정확한 문자열이 드문 경우에는 <code>rg</code>도 바로 원하는 파일을 찾았고, 흔한 이름을 검색할 때는 구현 위치와 관계 정보를 함께 보는 쪽에 이점이 있었다.</p><p>기대 파일을 미리 선정한 여덟 과제에서 원하는 파일을 얼마나 먼저 찾는지 비교했다. <code>rg</code>의 출력 순서는 의미상 관련도를 매긴 순위가 아니며, 전체 에이전트 작업 완료 시간은 측정하지 않았다.</p><p>당시 공유한 작업 방식에서도 둘을 함께 쓰도록 했다. CodeGraph로 관련 심볼과 호출 관계, 변경 영향을 살펴본 뒤 텍스트 검색과 직접 읽기로 확인한다. 동작은 타입 검사와 테스트로 검증한다. 탐색 결과만으로 수정이 맞다고 판단하지 않도록 지침에도 적었다.</p></section>
<section><h2>사내 논의를 함께 쓸 수 있는 도구로 옮기기<a href="#사내-논의를-함께-쓸-수-있는-도구로-옮기기"><span>#</span></a></h2><p>채널에서는 각자 쓰는 도구를 소개하다가 회사에서 함께 활용할 방법도 이야기했다. 그해 4월에는 채널에서 활발히 활동하던 사람들과 사내 AI 활용 방식을 논의하자는 요청을 받았다.</p><p>회의 다음 날에는 이야기했던 세션 코치를 만들어 공유했다. 에이전트가 만든 코드의 품질보다 사용자가 에이전트에게 일을 맡기는 방식을 돌아보는 도구였다. 작업 전에는 프로젝트의 지침과 설정을 확인하고 작업 후에는 프롬프트, 작업 분해, 검증 습관과 대화 흐름을 살펴보도록 구성했다.</p><blockquote><p>어제 이야기한 세션 코치 만들어 봤어요</p></blockquote><p>— 2026년 4월 14일, Slack 메시지 발췌</p><p>동료가 만든 사내 마켓에도 올려 달라는 요청을 받아 같은 날 플러그인과 두 스킬을 등록했다.</p><p>등록 과정에는 내 실수도 있었다. PR로 올려 달라는 안내에서 별도 리뷰어가 필요 없다는 부분만 읽고 <code>main</code>에 직접 넣었다가 동료에게 지적받았다. 빨리 만들어 올렸어도 공유 저장소의 절차는 지켰어야 했다.</p></section>
<section><h2>공유한 설명을 다시 고친 기록<a href="#공유한-설명을-다시-고친-기록"><span>#</span></a></h2><p>공유한 도구뿐 아니라 사용법을 설명한 글에도 후속 대화가 이어졌다. 내 이해가 틀린 경우도 있었고, 새 기능이 나오면서 전에 적은 설명을 고쳐야 할 때도 있었다.</p><p>7월 13일에는 서브에이전트에 모델과 추론 수준을 지정하는 방식을 잘못 이해하고 있었다는 글을 올렸다. 프롬프트에 원하는 모델 이름을 쓰는 것과 실제 실행 인자로 전달하는 것을 구분해 설명했다.</p><blockquote><p>서브에이전트 모델 라우팅과 관련해 제가 잘못 이해하고 있던 부분이 있어 공유합니다.</p><p>저는 프롬프트에서 특정 모델과 추론 수준을 지정하면 <code>spawn_agent</code>가 해당 조건의 서브에이전트를 동적으로 생성한다고 생각했습니다.</p></blockquote><p>— 2026년 7월 13일, Slack 메시지 발췌</p><p>다음 날에는 별도 작업을 생성하는 방식까지 포함해 내용을 보완했다. 서브에이전트를 만드는 호출과 앱에서 독립 작업을 만드는 호출을 나눠 설명했다.</p><blockquote><p>다만 Codex 앱 전체로 범위를 넓히면 create_thread라는 별도 선택지가 있습니다.</p></blockquote><p>— 2026년 7월 14일, Slack 메시지 발췌</p><p>7월 24일에는 「Codex 서브에이전트 모델 라우팅 업데이트 — 7/13 공유 내용 정정」이라는 글을 올렸다. 기존 글을 연결하고 새 버전에서 달라진 내용을 덧붙였다.</p><blockquote><p>최신 Codex 0.145.0 / Multi-agent V2 기준으로는 이 결론이 바뀌었습니다. 이제 Custom Agent 파일 없이도 개별 spawn_agent 호출마다 model과 reasoning_effort를 지정할 수 있습니다.</p></blockquote><p>— 2026년 7월 24일, Slack 메시지 발췌</p><p>이런 수정은 새 버전이 나왔을 때만 하지는 않았다. 동료가 비슷해 보이는 채팅 기능의 차이를 물으면 각 기능에서 다루는 작업 맥락을 나눠 설명했다. 나도 그 대화에서 모르던 단축키나 설정을 배웠다. 사용해 본 내용을 올리고 질문을 받으면 다시 확인한 내용을 같은 대화에 덧붙였다.</p><p>아카이빙을 앞두고 채널을 다시 읽으니 새 도구 소식 사이에 이런 대화들이 남아 있었다. 처음에는 내가 쓰다가 불편했던 일에서 출발했는데, 동료와 나누는 동안 함께 써볼 도구와 다시 확인할 내용이 생겼다. 이 채널에서 내가 배우고 만든 것들은 그렇게 쌓였다.</p></section>
<section><h2>덧붙임: 활동 분석과 사례별 기록<a href="#덧붙임-활동-분석과-사례별-기록"><span>#</span></a></h2>
활동 분석 보고서 펼쳐보기<p>Slack 기록과 공개 구현물을 바탕으로 AI가 정리한 분석의 공개용 발췌다. 집계 기준일은 2026년 9월 4일이다.</p><p><strong>활동에서 드러난 역할</strong></p><p>수집한 사례에서는 업무 중 발견한 문제를 도구로 만들고, 사용 경험과 비교 자료를 공유하는 활동이 반복됐다. Copy Selection Context와 now-what은 공개 구현물이 남아 있고, 세션 코치는 사내 공용 마켓에 추가한 커밋이 있다. CodeGraph 비교에서는 지연시간과 검색 결과의 노출 순서를 구분했다.</p><p>도구 비교 의견을 요청받거나 AI 활용 논의에 초대된 기록은 동료들이 사용 경험을 참고했다는 근거가 된다. 설정 안내 뒤 인식이 달라졌다는 답변, 재시작 제안 뒤 정상 동작했다는 답변도 있었다. 질문에 답하는 것과 함께 동료에게 사용법을 배우고 설명을 보완한 과정도 보인다.</p><p>이 자료에서 확인되는 역할은 작업 흐름 구성, 동료의 선택·탐색 지원, 공동 학습 참여다. 전사 AI 도입을 주도했다거나 동료들의 생산성을 정량적으로 높였다는 결론까지 뒷받침하지는 않는다.</p><p><strong>수집 범위</strong></p>

<table><thead><tr><th>항목</th><th>결과</th></tr></thead><tbody><tr><td>메시지 관찰 기간</td><td>2025-07-30~2026-09-04</td></tr><tr><td>내가 작성한 메시지</td><td>363건</td></tr><tr><td>시작글 / 답글</td><td>119 / 244건</td></tr><tr><td>메시지를 남긴 날짜</td><td>109일</td></tr><tr><td>연결된 대화 묶음</td><td>166개</td></tr><tr><td>대화 전문 메시지</td><td>777건: 내 메시지 363 + 다른 작성자 414</td></tr><tr><td>동료 답글이 있는 내 시작글</td><td>49 / 119개, 약 41.2%</td></tr><tr><td>내 시작글에 답한 다른 작성자</td><td>18명</td></tr><tr><td>이모지 반응이 기록된 내 메시지</td><td>120건</td></tr><tr><td>검색 결과에서 텍스트 본문이 빈 메시지</td><td>19건</td></tr><tr><td>별도로 검색한 멘션 결과</td><td>13건</td></tr></tbody></table><p>메시지 검색 19페이지를 끝까지 조회한 뒤 연결된 대화와 대조했다. 검색된 내 메시지 363건과 대화 전문에서 찾은 내 메시지의 ID가 일치했다. 대화 묶음에는 단독 시작글도 포함되며, 추가 멘션 검색에는 중복이 있어 777건에 가산하지 않았다.</p><p><strong>월별 활동</strong></p>

<table><thead><tr><th>월 (KST)</th><th>메시지</th><th>시작글</th><th>답글</th></tr></thead><tbody><tr><td>2025-07</td><td>3</td><td>0</td><td>3</td></tr><tr><td>2025-08</td><td>0</td><td>0</td><td>0</td></tr><tr><td>2025-09</td><td>15</td><td>1</td><td>14</td></tr><tr><td>2025-10</td><td>5</td><td>1</td><td>4</td></tr><tr><td>2025-11</td><td>9</td><td>2</td><td>7</td></tr><tr><td>2025-12</td><td>14</td><td>6</td><td>8</td></tr><tr><td>2026-01</td><td>15</td><td>9</td><td>6</td></tr><tr><td>2026-02</td><td>75</td><td>11</td><td>64</td></tr><tr><td>2026-03</td><td>45</td><td>8</td><td>37</td></tr><tr><td>2026-04</td><td>53</td><td>19</td><td>34</td></tr><tr><td>2026-05</td><td>29</td><td>17</td><td>12</td></tr><tr><td>2026-06</td><td>33</td><td>15</td><td>18</td></tr><tr><td>2026-07</td><td>53</td><td>19</td><td>34</td></tr><tr><td>2026-08</td><td>9</td><td>8</td><td>1</td></tr><tr><td>2026-09</td><td>5</td><td>3</td><td>2</td></tr></tbody></table><p>2026년 2월과 3월에는 답글 중심의 참여가 많았고, 4월부터 7월까지는 매달 15~19개의 시작글이 있었다. 플러그인 제작, 사용 후기, 구성 방법과 비교 보고서 등 공유 내용도 다양했다. 다만 짧은 답글을 여러 개로 나눠 쓴 대화가 있어 메시지 수를 기여의 크기나 역량 성장으로 해석하지 않았다. 2026년 9월은 4일까지의 기록이다.</p><p><strong>수치를 읽을 때의 범위</strong></p><ul>
<li>관리자 내보내기가 아닌 연결 도구의 검색·대화 조회 결과다. 삭제되거나 접근할 수 없는 메시지는 포함하지 못했고, 수정 이력 전체를 확보하지는 않았다.</li>
<li>잡담·링크·첨부 메시지도 집계에 포함된다. 반응 120건은 반응을 받은 메시지 수이며, 이를 읽은 사람 수가 아니다. 답글 비율도 유익함이나 도입률을 뜻하지 않는다.</li>
<li>전체 채널의 메시지와 구성원별 활동을 수집하지 않아 기여 점유율·순위·네트워크 중심성은 계산하지 않았다.</li>
<li>CodeGraph 보고서의 텍스트는 읽었지만 이미지와 ZIP을 일괄 판독·실행하지는 않았다. AI 답변을 옮기거나 ChatGPT로 게시한 메시지도 포함돼 있어 문장이 상세하다는 이유만으로 직접 조사·검증한 지식으로 간주하지 않았다.</li>
<li>설치·사용 사례, 추천 이후의 작업 결과, 비교 실험의 원시 로그가 추가된다면 실제 활용 효과를 더 구체적으로 설명할 수 있다. 현재 분석은 제작·공유·등록과 대화에서 확인되는 후속 반응까지를 다룬다.</li>
</ul>
14개 사례와 Slack 원문 발췌 펼쳐보기<p>각 사례의 행동과 후속 기록을 함께 정리했다. 인용은 내가 당시 남긴 메시지에서 발췌했으며, 당시 도구의 사용법을 현재 버전의 안내로 옮긴 것은 아니다. 동료 개인정보와 내부 업무 식별자를 포함한 원본 파일은 첨부하지 않았다.</p><p><strong>01. IDE 코드 위치 전달 플러그인 제작</strong></p><p>2026-02-18 · 업무 문제의 도구화</p><p>IDE에서 선택한 코드의 맥락을 터미널의 AI 도구에 전달하기 위한 플러그인을 제작하고 공개했다.</p><blockquote><p>IDE에서 TUI 쪽으로 선택 라인에 대한 컨텍스트 쉽게 전달하고 싶어서 만들어 봤어요.</p></blockquote><p>— 2026-02-18, Slack 메시지 발췌</p><p>공개 저장소에서 파일 경로·줄 번호·선택 코드 복사 기능과 사용법을 확인했다. 동료의 설치 여부나 시간 절약은 측정하지 않았다.</p><p><a href="https://github.com/hon454/copy-selection-context" target="_blank">Copy Selection Context 공개 저장소</a></p><hr /><p><strong>02. Codex 계획을 Claude Code가 검토하는 스킬</strong></p><p>2026-03-06 · 작업 흐름 설계</p><p>서로 다른 AI 도구를 연결해 계획을 검토하는 스킬을 만들어 공유하고 이후 비교 의견에서도 활용 경험을 설명했다.</p><blockquote><p>코덱스에서 클로드 코드에게 플랜 리뷰를 맡기는 스킬 만들어 봤습니다.</p></blockquote><p>— 2026-03-06, Slack 메시지 발췌</p><p>스킬 ZIP 첨부와 이후 사용 경험 설명이 남아 있다. 첨부 코드를 실행하거나 리뷰 품질 개선을 측정한 자료는 아니다.</p><hr /><p><strong>03. CTO 요청에 도구 선택 기준 제공</strong></p><p>2026-03-30 · 의사결정 지원</p><p>CTO의 비교 요청에 비용, 사용자 특성, 작업 연계와 결과의 체감을 나눠 설명했다.</p><blockquote><p>섞어서 사용할 때도 있었는데 저는 스킬을 활용해서 codex에서 구축한 plan을 claude code가 검토하게 하도록 했습니다. 다만 이 경우 연계가 부드럽게 되려면 일반적인 스킬보다는 고수준의 스킬을 구축해야 합니다.</p></blockquote><p>— 2026-03-30, Slack 메시지 발췌</p><p>양쪽 도구를 사용한 경험자로 비교 의견을 요청받았고, 다른 동료가 답변의 일부를 인용했다. 당시의 주관적 사용 경험이며 최종 구매·도입 결정에 미친 영향은 확인하지 못했다.</p><hr /><p><strong>04. AI 활용 논의 초대와 세션 코치 사내 등록</strong></p><p>2026-04-09~14 · 조직의 공용 도구 기여</p><p>활발한 채널 참여자로 사내 AI 활용 논의에 초대됐고, 회의 다음 날 세션 코치를 공유해 요청에 따라 사내 마켓에 등록했다.</p><blockquote><p>어제 이야기한 세션 코치 만들어 봤어요</p></blockquote><p>— 2026-04-14, Slack 메시지 발췌</p><p>4월 9일 논의 초대, 14일 오전 구현물 공유, 같은 날 사내 마켓 추가 커밋과 등록 메시지가 이어졌다. 마켓의 기획·운영은 동료의 기여다. 등록 때 PR 절차를 놓쳤으며 설치·사용 효과는 측정하지 않았다.</p><hr /><p><strong>05. 영어 프롬프트 코칭 스킬 제작·업데이트</strong></p><p>2026-04-30 · 활용 방법의 도구화</p><p>동료의 메시지를 계기로 프롬프트를 검토하는 스킬을 만들어 배포하고 업데이트 위치를 안내했다.</p><blockquote><p>영어로 Claude에게 프롬프트 쓸 때 문법 + 명확성 + 구조 + 출력 포맷을 한 번에 검수해주는 도구입니다.</p></blockquote><p>— 2026-04-30, Slack 메시지 발췌</p><p>입출력 예시와 ZIP을 공유했고, 파일 위치를 묻는 질문에 업데이트 버전을 댓글에 올렸다고 답했다. 영어가 항상 더 좋은 결과를 낸다는 비교 실험이나 사용 효과 측정은 아니다.</p><hr /><p><strong>06. now-what 스킬과 실제 업무 출력 공유</strong></p><p>2026-05-28 · 맥락 확인과 다음 작업 선택</p><p>작업 상태를 종합해 다음 행동을 추천하는 스킬을 만들어 사용하고 업무 예시와 함께 공유했다.</p><blockquote><p>최근 만들고 애용하는 <code>now-what</code> 스킬을 공유드립니다.</p><p>작업하다가 “근데 이제 뭐함?” 싶은 순간에 쓰려고 만든 스킬입니다.</p></blockquote><p>— 2026-05-28, Slack 메시지 발췌</p><p>업무 출력 예시와 동료의 필요 공감이 있었고, 공개 스킬에서 브랜치·변경·PR·이슈를 확인하는 구성을 확인했다. 추천의 정확성이나 동료의 실제 도입은 측정하지 않았다.</p><p><a href="https://github.com/hon454/grimoire/blob/53a719e2bfdabe7c49523a6ec0237811a239ff4a/plugins/grimoire/skills/now-what/SKILL.md" target="_blank">now-what 공개 소스</a></p><hr /><p><strong>07. CodeGraph 환경 통합과 한계를 포함한 비교</strong></p><p>2026-06-04 · 검증과 작업 환경 구성</p><p>새 worktree의 인덱스 초기화 방법과 탐색 지침을 공유하고, 검색 지연시간·결과 노출 순서를 비교한 보고서를 함께 제공했다.</p><blockquote><p>추가로 제 로컬 AGENTS.md에 아래와 같은 지침을 추가했습니다.</p></blockquote><p>— 2026-06-04, Slack 메시지 발췌</p><p>초기화 스크립트와 탐색 지침, 비교 보고서를 함께 공유했다. 보고서는 8과제에서 준비 실행 1회와 측정 9회를 진행했다. CLI 지연시간 중앙값의 중앙값은 CodeGraph 144.98 ms, rg 14.37 ms였다. 기대 파일의 첫 번째 노출은 8/8 대 4/8이었다. 원시 실행 로그와 재실행 결과를 확보한 것은 아니며, 개발 시간이나 토큰 절감 실험도 아니다.</p><hr /><p><strong>08. 모델 라우팅 설명 보완·정정</strong></p><p>2026-07-13~24 · 공유 정보 유지·수정</p><p>자신의 오해와 설명 범위를 공개하고 후속 설명과 변경된 기능에 대한 정정 글을 남겼다.</p><blockquote><p>Codex 서브에이전트 모델 라우팅 업데이트 — 7/13 공유 내용 정정</p></blockquote><p>— 2026-07-24, Slack 메시지 발췌</p><p>7월 13일 오해 설명, 14일 독립 작업 생성 방식 보완, 24일 이전 글을 연결한 업데이트가 남아 있다. 이 사례는 설명을 유지·수정한 과정에 관한 것이다. 과거 환경을 재현하거나 메시지의 모든 기술 설명을 독립 검증하지는 않았다.</p><hr /><p><strong>09. 동료에게 필요한 설정 정보 전달</strong></p><p>2026-05-15 · 동료의 탐색 지원</p><p>특정 동료에게 Windows 원격 연결 설정 안내를 전달했고, 동료는 지원을 기다리며 포기하고 있었다고 응답했다.</p><blockquote><p>codex 모바일 신규 지원되는데 윈도우는 설정 켜야 한다고 하네요.</p></blockquote><p>— 2026-05-15, Slack 메시지 발췌</p><p>설정 안내와 공식 문서 링크를 전달한 뒤, 동료가 지원을 기다리며 포기하고 있었다고 답했다. 실제 연결 성공까지 확인된 사례는 아니다.</p><hr /><p><strong>10. 실행 장애 질문에 대응</strong></p><p>2026-07-14 · 동료 문제 대응</p><p>동료가 요청이 가지 않는다고 하자 재시작 등 확인 방법을 제안했고, 동료는 앱을 껐다 켜니 동작한다고 답했다.</p><blockquote><p>codex가 고장나면 해결 방법</p><ol>
<li>코덱스 껏다 키기</li>
</ol></blockquote><p>— 2026-07-14, Slack 메시지 발췌</p><p>14&lt;03&gt; 질문, 14&lt;04&gt; 재시작 제안, 14&lt;10&gt; 앱을 껐다 켜니 동작한다는 답변이 이어졌다. 재시작 뒤 정상 동작했다는 작은 범위의 결과이며 근본 원인 분석은 아니다.</p><hr /><p><strong>11. 비슷한 채팅 기능을 구분해 설명</strong></p><p>2026-07-29 · 사용 경험 해설·공동 학습</p><p>기능 팁을 공유하고 동료의 질문에 대화 맥락 공유 여부를 기준으로 차이를 설명했다.</p><blockquote><p>ChatGPT 사이드 바에 열어줘
• 이건 사이드 챗이 아니라 인앱 브라우저를 사이드 패널에 띄우고 ChatGPT를 웹 접속 하는 것</p></blockquote><p>— 2026-07-29, Slack 메시지 발췌</p><p>기능 팁을 공유한 뒤 질문에 답했고, 동료들도 추가 조사와 설명을 보탰다. 같은 대화에서 나온 모든 설명을 한 사람의 기여로 합치지 않았다.</p><hr /><p><strong>12. 도구 개발자에게 전달할 구체적 질문 구성</strong></p><p>2026-02-26~03-03 · 학습 의제 제시</p><p>서브에이전트 활용과 스킬 관리에 대한 질문을 제시했고, 동료는 관련 두 주제에 대해 받아온 답변을 채널에 공유했다.</p><blockquote><p>Q2. Skills의 목록과 설명은 항상 로드되는 구조로 알고 있는데, Skills 수가 많아질 경우 어떻게 대응하시나요? 프로젝트별로 Skills를 분리해서 운용하시는지, 혹은 필요한 시점에 지연 로딩(lazy loading)하는 방식을 사용하시는지 궁금합니다(애초에 이게 가능한건지?). 아니면 Skills 목록/설명 로드 부하는 실용적으로 크게 신경 쓰지 않아도 되는 수준인가요?</p></blockquote><p>— 2026-02-26, Slack 메시지 발췌</p><p>질문 목록에는 서브에이전트 활용과 스킬 관리에 관한 세 가지 질문이 있었다. 이후 동료가 관련 두 주제에 대해 받아온 답변을 공유했다. 질문 현장은 확인하지 않았으며 답변을 가져온 역할은 동료에게 있다.</p><hr /><p><strong>13. 요청받은 펫 제공과 후속 공유 대화</strong></p><p>2026-05-14~06-10 · 참여·교류 지원</p><p>동료의 요청에 맞춰 펫 파일을 제공했고, 같은 스레드에서 다른 동료들의 요청과 자체 제작물 공유가 이어졌다.</p><blockquote><p>요청해주신 도로롱 코덱스 펫입니다.</p></blockquote><p>— 2026-05-14, Slack 메시지 발췌</p><p>ZIP 제공 뒤 사용해 보겠다는 답변과 다른 동료들의 요청·제작물 공유가 이어졌다. 이미지로 설치 성공을 판독한 것은 아니며 다른 사람이 만든 펫은 해당 동료의 결과물이다.</p><hr /><p><strong>14. 플러그인 탐색 뒤 사용 후기 회수</strong></p><p>2026-06-26~27 · 실험과 경험 공유</p><p>새 플러그인을 써보겠다고 공유한 뒤 같은 날 개인 리뷰 흐름에 사용한 후기를 남겼다.</p><blockquote><p>특히 리뷰 기능은 바로 개인 리뷰 프로세스에 병합하여 사용했을때 이질감 없고 굉장히 조언 느낌도 좋은 것 같습니다.</p></blockquote><p>— 2026-06-26, Slack 메시지 발췌</p><p>써보겠다는 공유 뒤 같은 날 개인 리뷰 흐름에 사용한 후기를 남겼고, 다음 날에도 리뷰용으로 사용한다고 설명했다. 주관적 사용 후기이며 리뷰 품질 향상을 입증하는 비교는 아니다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/firefly-upstream-sync-6-16-6/</id>
      <title type="text">Firefly 업스트림 반영: 6.16.6</title>
      <published>2026-09-02T00:00:00.000Z</published>
      <updated>2026-09-02T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/firefly-upstream-sync-6-16-6/"/>
      <summary type="text">Firefly 6.16.6과 이후 0f2fab602까지의 내비게이션, 시리즈, 미디어 필터, 메타데이터 및 배경 변경 사항.</summary>
      <content type="html"><![CDATA[<p><a href="https://github.com/CuteLeaf/Firefly" target="_blank">Firefly</a> 6.16.3 이후부터 6.16.6과 그 이후의 <code>0f2fab602</code>까지 업스트림 변경을 반영했다.</p>
<section><h2>화면과 탐색<a href="#화면과-탐색"><span>#</span></a></h2><ul>
<li>내비게이션을 <code>dynamic</code> 모드로 변경했다. 아래로 스크롤하면 숨고 위로 스크롤하면 다시 나타난다.</li>
<li>배너·전체 화면 배경의 내비게이션 투명 모드를 새 설정 구조에 맞췄다.</li>
<li>모바일 내비게이션 패널이 화면 전체를 덮도록 구조를 수정하고, 밝은·어두운 테마 전환 버튼의 동작과 표시를 정리했다.</li>
</ul></section>
<section><h2>시리즈<a href="#시리즈"><span>#</span></a></h2><ul>
<li><code>series</code>와 <code>seriesOrder</code>로 여러 글을 묶는 기능을 추가했다.</li>
<li>시리즈를 지정한 글에 같은 시리즈의 글 목록을 표시하며, <code>/series/</code>에서 전체 시리즈를 제공한다.</li>
<li>설정 방법을 <a href="/posts/firefly-blog-guide/">Firefly 블로그 가이드</a>에 추가했다.</li>
</ul></section>
<section><h2>미디어 목록<a href="#미디어-목록"><span>#</span></a></h2><ul>
<li>Bangumi, VNDB, MyAnimeList의 NSFW 처리를 공통 설정으로 통합했다.</li>
<li>현재 Bangumi와 MyAnimeList의 해당 항목은 숨기고, VNDB 표지는 흐리게 표시한다.</li>
</ul></section>
<section><h2>OG 이미지와 구조화 데이터<a href="#og-이미지와-구조화-데이터"><span>#</span></a></h2><ul>
<li>OG 이미지 생성 구현을 Satori에서 Takumi로 교체했다. 이 블로그의 자동 생성 기능은 비활성화 상태다.</li>
<li>글과 프로필의 구조화 데이터를 추가했다.</li>
<li>게시자 로고, 프로필 이미지와 글 표지가 실제 배포 주소를 사용하도록 이미지 경로 처리를 수정했다.</li>
</ul></section>
<section><h2>카테고리 가로 스크롤<a href="#카테고리-가로-스크롤"><span>#</span></a></h2><ul>
<li>카테고리 바를 더 움직일 수 있을 때만 세로 휠 입력을 가로 스크롤로 변환한다. 양 끝에서는 세로 페이지 스크롤이 이어지도록 수정했다.</li>
<li>트랙패드 가로 입력과 <code>Ctrl</code> 확대·축소 입력은 가로채지 않는다.</li>
<li>부드러운 스크롤을 제거해 휠 입력에 즉시 반응하도록 변경했다.</li>
</ul></section>
<section><h2>전체 화면 배경<a href="#전체-화면-배경"><span>#</span></a></h2><ul>
<li><code>classic</code>과 <code>hero</code> 레이아웃을 추가했다. <code>classic</code>은 배경을 문서 흐름 안에 배치하고, <code>hero</code>는 첫 화면 배경을 고정한다. 현재는 업스트림 기본값인 <code>classic</code>을 사용한다.</li>
<li>물결 효과와 그라데이션을 배너·전체 화면 배경의 공통 설정으로 옮겼다.</li>
<li>표시 설정을 활성화하면 두 전체 화면 레이아웃을 전환할 수 있다.</li>
</ul></section>
<section><h2>카테고리 접근성<a href="#카테고리-접근성"><span>#</span></a></h2><ul>
<li>카테고리가 없는 글의 링크 설명에 현재 언어의 ‘미분류’ 문구를 사용하도록 수정했다.</li>
</ul></section>
<section><h2>반영 기준<a href="#반영-기준"><span>#</span></a></h2><ul>
<li>6.16.6 기준: <a href="https://github.com/CuteLeaf/Firefly/commit/f7c93c932c3241239f6f0d330e0b5c1bc701ce45" target="_blank"><code>f7c93c932</code></a></li>
<li>이후 변경을 포함한 최종 기준: <a href="https://github.com/CuteLeaf/Firefly/commit/0f2fab602407eb577cdcca066049fe947563ae0f" target="_blank"><code>0f2fab602</code></a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/progressive-disclosure-ai-agent/</id>
      <title type="text">AI 에이전트 지침에 점진적 노출 적용하기</title>
      <published>2026-08-27T00:00:00.000Z</published>
      <updated>2026-08-27T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/progressive-disclosure-ai-agent/"/>
      <summary type="text">CINEV Studio의 커진 지침을 나누고, Shotloom에서는 처음부터 문서 체계를 구성했다. 컨텍스트 부담과 문서 관리, 결정 근거를 기억하는 방식이 달라진 경험을 정리한다.</summary>
      <content type="html"><![CDATA[<p>점진적 노출(Progressive Disclosure)은 필요한 정보를 처음부터 전부 보여주지 않고 필요해지는 시점에 더 자세히 제공하는 방식이다. AI 코딩 에이전트에게 적용하면, 모든 프로젝트 지침을 한꺼번에 읽히는 대신 현재 작업에 필요한 문서를 찾아 읽게 하는 것이다.</p>
<p>예를 들어 커밋할 때는 커밋 규칙을, 특정 모듈을 수정할 때는 그 모듈의 설계와 주의사항을 읽게 한다. 처음에는 무엇이 어디 있는지 알 수 있는 안내를 제공한다.</p>
<p>요즘 주로 사용하는 Codex의 <a href="https://learn.chatgpt.com/guides/best-practices#make-guidance-reusable-with-agentsmd" target="_blank">공식 가이드</a>에서도 <code>AGENTS.md</code>가 커지면 본문은 간결하게 유지하고 작업별 상세 지침은 별도 Markdown 파일로 분리해 참조하라고 권한다.</p>
<div><div><div>작업에 따라 필요한 지침 찾아 읽기공통 규칙과 문서 안내를 먼저 읽고, 커밋할 때는 커밋 규칙을, 모듈 수정 시에는 해당 모듈 문서를, 결정 이유가 필요할 때는 관련 ADR을 읽는다.<div><span><p>커밋할 때</p></span></div><div><span><p>모듈 수정 시</p></span></div><div><span><p>결정 근거 확인 시</p></span></div><div><span><p>먼저 읽는 지침<br /><br />공통 규칙 + 문서 안내</p></span></div><div><span><p>커밋 규칙</p></span></div><div><span><p>모듈 지침</p></span></div><div><span><p>관련 ADR</p></span></div>
</div><div>작업에 따라 필요한 지침 찾아 읽기공통 규칙과 문서 안내를 먼저 읽고, 커밋할 때는 커밋 규칙을, 모듈 수정 시에는 해당 모듈 문서를, 결정 이유가 필요할 때는 관련 ADR을 읽는다.<div><span><p>커밋할 때</p></span></div><div><span><p>모듈 수정 시</p></span></div><div><span><p>결정 근거 확인 시</p></span></div><div><span><p>먼저 읽는 지침<br /><br />공통 규칙 + 문서 안내</p></span></div><div><span><p>커밋 규칙</p></span></div><div><span><p>모듈 지침</p></span></div><div><span><p>관련 ADR</p></span></div>
</div></div></div>
<blockquote><p>커밋 메시지 하나 쓰는데 프로젝트의 역사를 전부 읽을 필요는 없다.</p></blockquote>
<p>이 표현은 Anthropic이나 OpenAI의 에이전트 지침 작성 안내에서 처음 접한 것 같다. 이후 GeekNews에서도 관련 팁을 자주 봤다. 참고할 만한 글로 <a href="https://news.hada.io/topic?id=24744" target="_blank">「좋은 Claude.md 작성법」</a>이 있다. 이 글의 <a href="https://www.humanlayer.dev/blog/writing-a-good-claude-md" target="_blank">원문</a>은 세부 지침을 별도 파일로 나누고 루트 지침에는 설명과 읽을 경로를 남기는 구성을 제안한다.</p>
<section><h2>CINEV Studio: 지침이 너무 커졌다<a href="#cinev-studio-지침이-너무-커졌다"><span>#</span></a></h2><p>CINEV Studio는 내가 Cinamon에서 개발에 참여했던 Unreal Engine 기반의 시네마틱 편집 도구다.</p><p>CINEV Studio에 처음 <code>AGENTS.md</code>나 <code>CLAUDE.md</code> 같은 지침 파일을 적용했을 때는 얼마 지나지 않아 내용이 크게 늘었다. 특정 모듈의 기록과 결정사항까지 한 파일에 쌓이니 읽기도, 수정하기도 어려웠다. 그 내용이 매 세션 로드되면서 컨텍스트 부담도 생겼다.</p><p>에이전트에게 일을 잘 시키려고 쓴 지침인데, 지침 관리도 슬슬 일이 되고 있었다.</p><p>그래서 가끔 필요한 모듈별 기록과 결정사항을 별도 문서로 옮겼다. 원래 지침에는 “이 상황에서는 이 파일을 읽어라”라는 매핑을 남겼다.</p><p><code>AGENTS.md</code>의 참조 규칙을 발췌·번역했다. 경로는 설명용으로 단순화했다.</p><div><figure><figcaption><span>CINEV Studio — AGENTS.md 일부</span></figcaption><pre><code><div><div><div>1</div></div><div><span>## 외부 지침은 필요할 때 읽기</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>참조 문서를 미리 전부 읽지 않는다.</span></div></div><div><div><div>4</div></div><div><span>현재 작업에 필요한 문서만 읽고 해당 지침을 따른다.</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span><span>-</span><span> Unreal Engine 코딩 규칙 → standards/coding.md</span></span></div></div><div><div><div>7</div></div><div><span><span>-</span><span> 커밋 메시지 지침 → standards/commit.md</span></span></div></div><div><div><div>8</div></div><div><span><span>-</span><span> Merge Request 지침 → standards/merge-request.md</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>항상 지켜야 할 핵심 규칙은 원래 지침에 남겼다. 가끔 찾아볼 문서를 나누는 것과 놓치면 안 되는 규칙을 남기는 것은 별개의 일이었다.</p><p>바꾸고 나서 가장 먼저 체감한 것은 눈에 보이는 컨텍스트 길이였다. 문서를 작성하는 방식도 편해졌다. 거대한 파일 전체를 신경 쓰는 대신, 위키 글을 쓰듯 문서 하나의 내용에 집중할 수 있었다.</p></section>
<section><h2>Shotloom: 처음부터 찾아 읽을 수 있게<a href="#shotloom-처음부터-찾아-읽을-수-있게"><span>#</span></a></h2><p>CINEV Studio의 후속 프로젝트인 Shotloom은 브라우저에서 장면과 샷을 편집하는 도구다.</p><p>이 프로젝트는 시작부터 AI 에이전트와 코딩할 것을 전제로 구조를 잡았다. CINEV Studio에서 커진 지침을 나누는 데 집중했다면, Shotloom에서는 무엇을 어디에 기록하고 어떻게 다시 찾을지까지 정했다.</p><p>장기적으로 유지하는 프로젝트 문서도 역할을 나눴다. 작업이 끝나도 필요한 지식과 결정 근거를 남겨 다시 참조할 수 있게 했다.</p><ul>
<li>Spec에는 어떤 동작을 기대하는지 남긴다.</li>
<li>ADR에는 무엇을 결정했고 왜 그렇게 결정했는지 남긴다.</li>
<li>TechDebt에는 알고도 남겨둔 구조적 문제와 정리할 조건을 남긴다.</li>
</ul><p><code>AGENTS.md</code>는 공통 규칙과 진입점을 안내하고 <code>MAP.md</code>는 질문을 해당 문서에 연결한다. 문서 작성 지침에는 기록을 남길 위치와 코드 변경에 맞춰 문서를 갱신하는 기준을 정했다. 공통으로 읽어야 할 문서도 있으므로, 파일이 분리되어 있다는 이유만으로 모두 선택 사항은 아니다.</p><p>두 파일에서 문서를 찾아가는 부분을 발췌·번역하면 다음과 같다.</p><div><figure><figcaption><span>Shotloom — AGENTS.md → MAP.md</span></figcaption><pre><code><div><div><div>1</div></div><div><span># AGENTS.md</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>-</span><span> 문서 탐색 인덱스 → MAP.md</span></span></div></div><div><div><div>4</div></div><div><span><span>-</span><span> 기여·커밋 규칙 → CONTRIBUTING.md</span></span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span># MAP.md</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span><span>-</span><span> 제품 스펙은 어디에 있나? → docs/specs/</span></span></div></div><div><div><div>9</div></div><div><span><span>-</span><span> 설계 결정 기록은 어디에 있나? → docs/adr/</span></span></div></div><div><div><div>10</div></div><div><span><span>-</span><span> 기술 부채 기록은 어디에 있나? → docs/tech-debt/</span></span></div></div><div><div><div>11</div></div><div><span><span>-</span><span> 문서 작성 기준은 어디에 있나? → docs/guidelines/documentation-standard.md</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 구조는 에이전트뿐 아니라 나에게도 도움이 됐다. 다른 스펙을 쓰거나 이전 결정의 근거가 필요할 때, 기억을 더듬는 대신 기록을 다시 확인할 수 있었다. 평소에는 그 내용을 전부 기억하고 있을 필요가 없었다.</p><p>내 기억력이 좋아진 것은 아니다. 잊어도 되는 범위가 넓어졌을 뿐이다.</p></section>
<section><h2>스킬에도 같은 방식이 쓰인다<a href="#스킬에도-같은-방식이-쓰인다"><span>#</span></a></h2><p>점진적 노출은 <code>AGENTS.md</code>나 <code>CLAUDE.md</code>에만 쓰는 기법은 아니다. <a href="https://learn.chatgpt.com/docs/customization/overview#skills" target="_blank">Codex 공식 문서</a>에 따르면 스킬의 이름과 설명이 먼저 제공된다. 사용할 스킬이 선택되면 <code>SKILL.md</code> 전체를 읽고 추가 참조 문서는 필요할 때 읽는다.</p><p>스킬을 작성할 때도 <code>SKILL.md</code>에는 핵심 절차와 참조 조건을 두고 긴 예시나 세부 규칙은 별도 문서로 나눌 수 있다. 본문에 전부 넣어두면 스킬을 선택한 순간 다시 많은 내용을 읽게 된다.</p></section>
<section><h2>파일만 나눈다고 끝나지는 않는다<a href="#파일만-나눈다고-끝나지는-않는다"><span>#</span></a></h2><p>예전에는 에이전트가 필요한 문서를 놓치는 경우도 있었다. 중요한 매핑의 우선순위를 높여 <code>AGENTS.md</code> 상단으로 옮긴 기억도 있다. 요즘은 꽤 나아진 것 같고 모델 개선의 영향도 있어 보이지만 이 부분은 내 체감이다.</p><p>문서 경로만 나열하는 것으로는 부족하다. 어떤 내용이 들어 있고 언제 읽어야 하는지도 함께 안내해야 한다. 현재 두 프로젝트 모두 문서나 코드 위치가 바뀌면 관련 안내를 함께 갱신하도록 정하고 있다. 찾아갈 주소가 틀리면 문서를 잘 써둔 보람이 없다.</p><p>프로젝트의 모든 사정을 매번 펼쳐놓을 필요는 없었다. 필요한 순간에 다시 찾을 수 있으면 됐다. 덕분에 나도 평소에는 좀 잊고 지낸다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/tdd-game-development-rust-ai-agent/</id>
      <title type="text">게임 개발자가 Rust와 AI 에이전틱 코딩에서 다시 본 TDD</title>
      <published>2026-08-25T00:00:00.000Z</published>
      <updated>2026-08-25T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/tdd-game-development-rust-ai-agent/"/>
      <summary type="text">Unreal Engine 중심의 게임 개발자가 Shotloom에서 Rust의 Red-Green-Refactor 흐름을 경험하며 확인한 TDD의 장점, 한계와 이후 게임 개발에 적용할 방법을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>나는 Unreal Engine을 중심으로 게임과 실시간 3D 애플리케이션을 개발해 왔고 Unity 프로젝트도 경험했다. 보통 기능부터 구현하고, 에디터나 게임을 실행해 입력을 직접 재현한 뒤 화면과 로그를 확인했다.</p>
<p><a href="/posts/from-cinev-studio-to-shotloom/">CineV Studio에서 Shotloom으로 전환</a>하면서 Rust와 Bevy를 본격적으로 사용하게 됐다. 언어와 엔진이 바뀐 것보다 더 낯설었던 건 테스트를 대하는 순서였다.</p>
<p>시작은 실패하는 테스트였다. 성공해야 할 입력과 실패해야 할 입력, 각각의 출력을 먼저 정했다. 내부 구현은 블랙박스로 둔 채 테스트가 통과할 때까지 코드를 작성했다. 구현 뒤 테스트를 보태는 대신 외부에서 관찰할 동작을 먼저 정하고 구현을 채웠다.</p>
<p>게임 개발 방식 전체를 비교하려는 글은 아니다. Unreal Engine을 오래 사용한 내가 Shotloom을 만들며 TDD(Test-Driven Development)를 다시 이해한 과정과 Rust, AI 에이전트가 그 방식을 실용적으로 만든 이유를 정리한다. 이 경험을 이후 게임 개발에 어디까지 적용할지도 적어 둔다.</p>
<section><h2>테스트가 없었던 것이 아니라 시작점이 달랐다<a href="#테스트가-없었던-것이-아니라-시작점이-달랐다"><span>#</span></a></h2><p>물론 게임 엔진에도 자동화 테스트가 있다. Unreal Engine은 단위·기능·콘텐츠 스트레스 테스트를 지원하고 Unity에도 Edit Mode와 Play Mode 테스트를 실행하는 Test Framework가 있다.</p><p>다만 내가 주로 경험한 Unreal Engine 중심의 개발에서는 테스트가 일상적인 구현의 출발점이라기보다 필요할 때 별도의 실행 문맥을 준비해 사용하는 도구에 가까웠다.</p><p>Unreal Engine의 Automation Test는 엔진 시스템과 함께 다양한 수준의 테스트를 지원한다. 일부 테스트는 관련 플러그인을 활성화한 뒤 에디터를 다시 시작해야 한다. 엔진 시스템에 의존하는 테스트는 순수한 단위 테스트보다 실행 문맥이 크다. 자세한 분류와 실행 방법은 <a href="https://dev.epicgames.com/documentation/unreal-engine/automation-test-framework-in-unreal-engine?lang=en-US" target="_blank">Unreal Engine Automation Test Framework</a>에 정리되어 있다.</p><p>Unity의 Test Framework도 테스트를 Edit Mode와 Play Mode로 구분한다. Edit Mode는 에디터의 업데이트 루프에서 실행되고 Play Mode는 실제 런타임에 가까운 환경에서 게임 코드를 실행한다. 실행 위치부터 나뉘는 이유는 게임 코드가 씬, 에디터와 프레임 업데이트에 밀접하게 연결되어 있기 때문이다. 자세한 내용은 <a href="https://docs.unity3d.com/kr/Packages/com.unity.test-framework%402.0/manual/edit-mode-vs-play-mode-tests.html" target="_blank">Unity Test Framework의 Edit Mode와 Play Mode 설명</a>에서 확인할 수 있다.</p><p>Godot, 자체 엔진이나 데이터 중심 런타임을 사용하는 팀은 테스트 흐름이 다를 수 있다. 같은 Unreal Engine과 Unity 안에서도 프로젝트 구조와 팀 문화에 따라 자동화 수준은 달라진다. 아래에서는 엔진이나 업계 전체의 우열이 아니라 내가 경험한 에디터 중심 개발과 Shotloom의 개발 흐름에서 느낀 차이만 다룬다.</p><p>내 작업 순서는 대체로 이랬다.</p><ol>
<li>기능을 구현한다.</li>
<li>에디터를 실행하거나 Play 모드에 진입한다.</li>
<li>필요한 맵과 오브젝트를 준비한다.</li>
<li>입력을 직접 재현한다.</li>
<li>화면, 로그와 상태를 확인한다.</li>
</ol><p>이 반복은 충분히 빨랐고, 시각적 결과를 계속 조정해야 하는 엔진 기반 개발에도 잘 맞았다. 다만 확인 결과가 자동화된 계약으로 남지 않으면 다음 변경에서도 같은 동작을 다시 수동으로 재현해야 한다.</p><p>Rust에서는 함수에 <code>#[test]</code>를 붙이고 <code>cargo test</code>를 실행하는 경로가 공식 도구 체계 안에 들어 있다. Cargo가 테스트용 실행 파일의 빌드와 실행, 성공·실패 보고까지 담당한다. 별도의 테스트 실행기를 고르는 일보다 원하는 동작을 함수로 표현하는 일에 바로 집중할 수 있었다. 자세한 사용법은 <a href="https://doc.rust-lang.org/stable/book/ch11-01-writing-tests.html" target="_blank">The Rust Programming Language의 테스트 작성 안내</a>에 정리되어 있다.</p><p>웹 개발에서도 비슷했다. 예를 들어 Node.js는 자체 테스트 실행기인 <a href="https://nodejs.org/api/test.html" target="_blank"><code>node:test</code></a>를 제공한다. 물론 모든 Rust나 웹 프로젝트가 TDD를 사용한다는 뜻은 아니다. 내가 체감한 차이는 TDD의 보편성보다 테스트를 시작하기까지의 거리에 있었다.</p>

<table><thead><tr><th>관점</th><th>내가 경험한 엔진 중심 개발</th><th>Rust·웹 개발에서의 경험</th></tr></thead><tbody><tr><td>피드백의 시작점</td><td>에디터·게임 실행</td><td>테스트 함수·명령 실행</td></tr><tr><td>준비할 문맥</td><td>맵, 오브젝트, 에셋, 프레임 상태</td><td>분리된 입력과 상태</td></tr><tr><td>결과 확인</td><td>화면, 로그, 디버거</td><td>자동화된 성공·실패</td></tr><tr><td>재현 방법</td><td>같은 조작을 다시 수행</td><td>같은 테스트를 다시 실행</td></tr><tr><td>회귀 확인</td><td>이전 동작을 기억하고 다시 확인</td><td>기존 테스트가 계약을 보존</td></tr><tr><td>구현 순서</td><td>구현한 뒤 동작 확인</td><td>동작을 정의한 뒤 구현</td></tr></tbody></table><p>게임 로직도 엔진 의존성을 분리하면 충분히 단위 테스트가 가능하다. 반대로 웹 UI나 비동기 시스템도 브라우저와 외부 서비스가 결합되면 테스트 비용이 커진다. 두 분야를 테스트가 어려운 환경과 쉬운 환경으로 단순히 나누기는 어렵다.</p><p>내가 Rust에서 체감한 차이는 테스트가 구현과 같은 위치에서 바로 시작되는 기본 도구라는 점이었다. 그래서 구현 뒤 검증보다 구현 전 설계에 사용하기가 자연스러웠다.</p></section>
<section><h2>Red-Green-Refactor는 구현 순서를 바꾼다<a href="#red-green-refactor는-구현-순서를-바꾼다"><span>#</span></a></h2><p>TDD를 설명할 때 흔히 쓰는 흐름이 Red-Green-Refactor다.</p><div><div><div><div><span></span></div><div><span></span></div><div><span><p>다음 동작</p></span></div><div><span><p>Red<br /><br />원하는 동작을 테스트로 작성하고<br /><br />의도한 이유로 실패하는지 확인</p></span></div><div><span><p>Green<br /><br />테스트를 통과시키는<br /><br />최소한의 구현 작성</p></span></div><div><span><p>Refactor<br /><br />동작을 유지하면서<br /><br />구조와 표현 개선</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span><p>다음 동작</p></span></div><div><span><p>Red<br /><br />원하는 동작을 테스트로 작성하고<br /><br />의도한 이유로 실패하는지 확인</p></span></div><div><span><p>Green<br /><br />테스트를 통과시키는<br /><br />최소한의 구현 작성</p></span></div><div><span><p>Refactor<br /><br />동작을 유지하면서<br /><br />구조와 표현 개선</p></span></div>
</div></div></div><section><h3>Red: 원하는 동작을 먼저 실패시킨다<a href="#red-원하는-동작을-먼저-실패시킨다"><span>#</span></a></h3><p>아직 구현되지 않은 동작을 테스트로 작성한다. 테스트가 단순히 실패하는지만 봐서는 안 된다. 내가 추가하려는 동작이 없어서 실패하는지 확인해야 한다. 잘못된 설정이나 무관한 오류로 실패한다면 유효한 Red 상태가 아니다.</p></section><section><h3>Green: 계약을 만족하는 최소 구현을 만든다<a href="#green-계약을-만족하는-최소-구현을-만든다"><span>#</span></a></h3><p>테스트를 통과시키는 데 필요한 만큼만 구현한다. 구조를 미리 다듬기보다 앞에서 정한 동작을 성립시키는 것이 우선이다.</p></section><section><h3>Refactor: 동작을 유지한 채 구조를 정리한다<a href="#refactor-동작을-유지한-채-구조를-정리한다"><span>#</span></a></h3><p>테스트가 통과한 상태에서 중복을 제거하고 이름과 책임을 다듬는다. 기존 테스트로 외부 동작이 유지되는지 확인한 뒤 다음 Red 단계로 넘어간다.</p></section></section>
<section><h2>명령 처리의 입력과 출력을 먼저 정하기<a href="#명령-처리의-입력과-출력을-먼저-정하기"><span>#</span></a></h2><p>Shotloom에서는 명령 처리처럼 입력, 결과와 상태 변화가 분명한 영역에서 TDD 주기를 자주 경험했다. 아래 코드는 실제 구현이 아니라 당시 구조를 단순화한 예시다.</p><p>편집기의 재생 위치를 변경하는 명령이 있다고 가정한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#[derive(</span><span>Default</span><span>)]</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>EditorState</span><span> {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>playhead_frame</span><span>: </span><span>i32</span><span>,</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>enum</span><span> </span><span>Command</span><span> {</span></div></div><div><div><div>7</div></div><div><span>    </span><span>SetPlayhead</span><span> { </span><span>frame</span><span>: </span><span>i32</span><span> },</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>#[derive(</span><span>Debug</span><span>, </span><span>PartialEq</span><span>)]</span></div></div><div><div><div>11</div></div><div><span>enum</span><span> </span><span>CommandError</span><span> {</span></div></div><div><div><div>12</div></div><div><span>    </span><span>NegativeFrame</span><span>(</span><span>i32</span><span>),</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>fn</span><span> </span><span>execute</span><span>(</span></div></div><div><div><div>16</div></div><div><span>    </span><span>state</span><span>: &amp;</span><span>mut</span><span> </span><span>EditorState</span><span>,</span></div></div><div><div><div>17</div></div><div><span>    </span><span>command</span><span>: </span><span>Command</span><span>,</span></div></div><div><div><div>18</div></div><div><span>) -&gt; </span><span>Result</span><span>&lt;(), </span><span>CommandError</span><span>&gt; {</span></div></div><div><div><div>19</div></div><div><span>    </span><span>todo!</span><span>(</span><span>"아직 명령 처리를 구현하지 않았다"</span><span>)</span></div></div><div><div><div>20</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>정상 입력과 실패 입력의 동작부터 테스트로 정의한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#[cfg(test)]</span></div></div><div><div><div>2</div></div><div><span>mod</span><span> tests {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>use</span><span> </span><span>super</span><span>::*;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>#[test]</span></div></div><div><div><div>6</div></div><div><span>    </span><span>fn</span><span> </span><span>set_playhead_updates_current_frame</span><span>() {</span></div></div><div><div><div>7</div></div><div><span>        </span><span>let</span><span> </span><span>mut</span><span> </span><span>state</span><span><span> </span><span>=</span><span> </span></span><span>EditorState</span><span>::</span><span>default</span><span>();</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>        </span><span>let</span><span> </span><span>result</span><span><span> </span><span>=</span><span> </span></span><span>execute</span><span>(</span></div></div><div><div><div>10</div></div><div><span><span>            </span></span><span>&amp;</span><span>mut</span><span> </span><span>state</span><span>,</span></div></div><div><div><div>11</div></div><div><span><span>            </span></span><span>Command</span><span>::</span><span>SetPlayhead</span><span> { </span><span>frame</span><span>: </span><span>120</span><span> },</span></div></div><div><div><div>12</div></div><div><span><span>        </span></span><span>);</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>        </span><span>assert_eq!</span><span>(</span><span>result</span><span>, </span><span>Ok</span><span>(()));</span></div></div><div><div><div>15</div></div><div><span>        </span><span>assert_eq!</span><span>(</span><span>state</span><span>.playhead_frame, </span><span>120</span><span>);</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>#[test]</span></div></div><div><div><div>19</div></div><div><span>    </span><span>fn</span><span> </span><span>negative_frame_is_rejected_without_changing_state</span><span>() {</span></div></div><div><div><div>20</div></div><div><span>        </span><span>let</span><span> </span><span>mut</span><span> </span><span>state</span><span><span> </span><span>=</span><span> </span></span><span>EditorState</span><span>::</span><span>default</span><span>();</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>        </span><span>let</span><span> </span><span>result</span><span><span> </span><span>=</span><span> </span></span><span>execute</span><span>(</span></div></div><div><div><div>23</div></div><div><span><span>            </span></span><span>&amp;</span><span>mut</span><span> </span><span>state</span><span>,</span></div></div><div><div><div>24</div></div><div><span><span>            </span></span><span>Command</span><span>::</span><span>SetPlayhead</span><span> { </span><span>frame</span><span>: -</span><span>1</span><span> },</span></div></div><div><div><div>25</div></div><div><span><span>        </span></span><span>);</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>        </span><span>assert_eq!</span><span>(</span><span>result</span><span>, </span><span>Err</span><span>(</span><span>CommandError</span><span>::</span><span>NegativeFrame</span><span>(-</span><span>1</span><span>)));</span></div></div><div><div><div>28</div></div><div><span>        </span><span>assert_eq!</span><span>(</span><span>state</span><span>.playhead_frame, </span><span>0</span><span>);</span></div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>30</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>테스트는 내부 구현 방법을 지정하지 않는다. 외부에서 관찰할 수 있는 계약만 정한다.</p><ul>
<li><code>120</code>을 입력하면 명령이 성공하고 현재 프레임이 변경된다.</li>
<li><code>-1</code>을 입력하면 오류가 반환된다.</li>
<li>실패한 명령은 기존 상태를 변경하지 않는다.</li>
</ul><p>이 상태에서 테스트를 실행하면 <code>todo!()</code>에 도달해 실패한다. 추가하려는 동작이 아직 없어서 실패하는 것을 확인했으므로 Red 상태다.</p><p>Green 단계에서는 이 계약을 만족하는 최소 구현을 추가한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>fn</span><span> </span><span>execute</span><span>(</span></div></div><div><div><div>2</div></div><div><span>    </span><span>state</span><span>: &amp;</span><span>mut</span><span> </span><span>EditorState</span><span>,</span></div></div><div><div><div>3</div></div><div><span>    </span><span>command</span><span>: </span><span>Command</span><span>,</span></div></div><div><div><div>4</div></div><div><span>) -&gt; </span><span>Result</span><span>&lt;(), </span><span>CommandError</span><span>&gt; {</span></div></div><div><div><div>5</div></div><div><span>    </span><span>match</span><span> </span><span>command</span><span> {</span></div></div><div><div><div>6</div></div><div><span>        </span><span>Command</span><span>::</span><span>SetPlayhead</span><span> { </span><span>frame</span><span> } </span><span>if</span><span> </span><span>frame</span><span><span> </span><span>&lt;</span><span> </span></span><span>0</span><span> =&gt; {</span></div></div><div><div><div>7</div></div><div><span>            </span><span>Err</span><span>(</span><span>CommandError</span><span>::</span><span>NegativeFrame</span><span>(</span><span>frame</span><span>))</span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>9</div></div><div><span>        </span><span>Command</span><span>::</span><span>SetPlayhead</span><span> { </span><span>frame</span><span> } =&gt; {</span></div></div><div><div><div>10</div></div><div><span>            </span><span>state</span><span><span>.playhead_frame </span><span>=</span><span> </span></span><span>frame</span><span>;</span></div></div><div><div><div>11</div></div><div><span>            </span><span>Ok</span><span>(())</span></div></div><div><div><div>12</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이후 검증 로직을 별도 타입이나 함수로 분리해도 기존 테스트의 입력과 기대 결과는 유지된다. 테스트가 바라보는 것은 <code>match</code>문의 구조가 아니라 명령 실행 결과와 상태 변화다. 내부 구현을 블랙박스로 취급한다는 말은 이런 의미다.</p></section>
<section><h2>AI 에이전트가 TDD의 비용을 바꿨다<a href="#ai-에이전트가-tdd의-비용을-바꿨다"><span>#</span></a></h2><p>AI 에이전틱 코딩을 본격적으로 사용하기 전에는 TDD가 일을 두 번 하는 절차처럼 보였다. 실제 코드보다 테스트를 먼저 만들고 실패를 확인한 뒤 구현으로 돌아가는 과정이 부담스러웠다.</p><p>에디터에서 결과를 바로 확인하는 데 익숙해, 아직 없는 기능의 입력과 출력을 먼저 정하는 일도 구현을 늦추는 것처럼 느꼈다.</p><p>AI 에이전트와 함께 구현하면서 이 순서가 다르게 보이기 시작했다.</p><p>AI 에이전트가 구현과 반복 수정을 맡자 나는 입력·출력·실패 조건을 정하는 데 시간을 더 쓸 수 있었다. 테스트가 있으면 코드가 빠르게 늘어나도 완료 조건에서 벗어났는지 바로 확인할 수 있었다. 이전에는 추가 작업처럼 보였던 테스트 설계가 이때부터 안전장치로 느껴졌다.</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>실패</p></span></div><div><span><p>통과</p></span></div><div><span></span></div><div><span></span></div><div><span><p>사람<br /><br />입력·출력·실패 조건 정의</p></span></div><div><span><p>실패하는 테스트<br /><br />실행 가능한 계약</p></span></div><div><span><p>AI 에이전트<br /><br />내부 구현</p></span></div><div><span><p>테스트 결과</p></span></div><div><span><p>사람의 검토<br /><br />계약과 설계 확인</p></span></div><div><span><p>리팩터링 또는<br /><br />다음 동작 정의</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>실패</p></span></div><div><span><p>통과</p></span></div><div><span></span></div><div><span></span></div><div><span><p>사람<br /><br />입력·출력·실패 조건 정의</p></span></div><div><span><p>실패하는 테스트<br /><br />실행 가능한 계약</p></span></div><div><span><p>AI 에이전트<br /><br />내부 구현</p></span></div><div><span><p>테스트 결과</p></span></div><div><span><p>사람의 검토<br /><br />계약과 설계 확인</p></span></div><div><span><p>리팩터링 또는<br /><br />다음 동작 정의</p></span></div>
</div></div></div><p>사람과 AI의 역할도 비교적 명확해졌다.</p><ul>
<li>사람은 허용할 입력을 정한다.</li>
<li>성공했을 때의 결과를 정한다.</li>
<li>잘못된 입력을 처리할 방법을 정한다.</li>
<li>상태가 변경돼도 되는 조건과 유지돼야 하는 조건을 정한다.</li>
<li>AI 에이전트는 이 계약을 만족하는 구현을 만들고 테스트 결과에 따라 수정한다.</li>
</ul><p>자연어로만 “이 명령을 처리해줘”라고 요청하면 완료 조건이 모호하다. 테스트가 있으면 AI 에이전트는 성공과 실패를 직접 확인하며 수정할 수 있다.</p><p>테스트는 사람과 AI 사이의 실행 가능한 계약이 된다. AI가 내부 구현을 선택하더라도 외부에서 관찰되는 동작은 테스트로 정해 둔다.</p><p>코드가 빨리 쌓일수록 이 계약의 가치도 커졌다. 모든 코드를 매번 처음부터 읽지 않아도 합의한 동작이 유지되는지는 테스트로 먼저 확인할 수 있었다.</p><p>내 경우 AI 에이전트를 쓰면서 TDD가 덜 필요해진 것이 아니라 오히려 더 실용적으로 느껴졌다.</p></section>
<section><h2>명령 계약이 단단해진다는 장점<a href="#명령-계약이-단단해진다는-장점"><span>#</span></a></h2><p>Shotloom에서 가장 크게 와닿은 변화는 명령의 입력과 출력 계약이 분명해진 점이었다.</p><p>명령을 구현하기 전에 이런 질문을 피할 수 없었다.</p><ul>
<li>어떤 명령과 입력을 받을 것인가?</li>
<li>성공하면 어떤 상태가 변경되는가?</li>
<li>실패하면 어떤 오류를 반환하는가?</li>
<li>실패한 명령은 기존 상태를 그대로 유지하는가?</li>
<li>같은 입력은 반복 가능한 결과를 만드는가?</li>
</ul><p>이 질문에 답하다 보면 코드를 쓰기 전부터 기능의 경계와 예외가 드러났다. 테스트는 이미 정한 요구사항을 확인하면서 아직 결정하지 않은 부분도 찾는 설계 도구가 됐다.</p><p>명령 계약은 Rust 런타임 내부에만 머물지 않았다. Shotloom처럼 React·TypeScript UI와 Rust·Bevy 런타임 사이에 명령 경계가 있으면 허용하는 입력과 실패 결과가 다른 계층과 공유하는 API가 된다. 명령 테스트는 함수 하나의 정확성뿐 아니라 계층 사이의 책임까지 고정했다.</p></section>
<section><h2>모든 결과를 <code>assert_eq!</code>로 표현할 수는 없다<a href="#모든-결과를-assert_eq로-표현할-수는-없다"><span>#</span></a></h2><p>명령 처리는 TDD와 잘 맞지만 게임과 실시간 3D 애플리케이션의 모든 기능이 같은 형태는 아니다. 렌더링, 애니메이션과 기즈모 조작처럼 결과가 시각적이거나 연속적인 기능은 무엇을 성공으로 볼지 단일 값으로 정의하기 어렵다.</p><section><h3>렌더링<a href="#렌더링"><span>#</span></a></h3><p>렌더링 결과는 픽셀로 나타나므로 기준 이미지와 비교할 수 있다. 하지만 픽셀이 일치해도 장면이 의도한 미적 결과를 만들었다고 단정하기는 어렵다.</p><p>반대로 GPU, 드라이버, 렌더링 백엔드와 부동소수점 계산의 차이로 일부 픽셀이 달라졌다고 기능이 잘못됐다고 보기도 어렵다. 허용 오차를 적용한 스크린샷 비교는 회귀를 찾는 데 유용하지만 최종 품질까지 판단하지는 못한다.</p></section><section><h3>애니메이션<a href="#애니메이션"><span>#</span></a></h3><p>애니메이션은 시간에 따라 값이 연속적으로 변한다. 특정 프레임에서 본의 위치와 회전이 기대 범위에 들어오는지는 테스트할 수 있지만, 동작이 자연스러운지, 발이 미끄러져 보이지 않는지, 캐릭터의 무게감이 적절한지는 숫자만으로 판단하기 어렵다.</p><p>자동화가 가능한 범위는 분명했다.</p><ul>
<li>같은 시간 입력에 같은 포즈가 계산되는가</li>
<li>애니메이션 시작과 종료 상태가 올바른가</li>
<li>블렌딩 가중치가 유효 범위를 벗어나지 않는가</li>
<li>존재하지 않는 클립이나 본 입력을 안전하게 거부하는가</li>
</ul><p>하지만 “움직임이 자연스러운가”는 여전히 눈으로 확인해야 한다.</p></section><section><h3>기즈모<a href="#기즈모"><span>#</span></a></h3><p>기즈모는 화면의 포인터 입력을 카메라와 월드 좌표로 변환한다. 이어 축을 선택하고 오브젝트 상태를 바꾼 뒤 결과를 다시 렌더링한다.</p><p>기즈모 전체의 사용감을 하나의 단위 테스트로 검증하기는 어렵다. 그래도 내부 계약은 분리 가능하다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#[test]</span></div></div><div><div><div>2</div></div><div><span>fn</span><span> </span><span>dragging_x_axis_changes_only_x_position</span><span>() {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>let</span><span> </span><span>before</span><span><span> </span><span>=</span><span> </span></span><span>Vec3</span><span>::</span><span>new</span><span>(</span><span>1.0</span><span>, </span><span>2.0</span><span>, </span><span>3.0</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>    </span><span>let</span><span> </span><span>after</span><span><span> </span><span>=</span><span> </span></span><span>apply_axis_drag</span><span>(</span></div></div><div><div><div>6</div></div><div><span>        </span><span>before</span><span>,</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>Axis</span><span>::</span><span>X</span><span>,</span></div></div><div><div><div>8</div></div><div><span>        </span><span>4.0</span><span>,</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>);</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>    </span><span>assert_eq!</span><span>(</span><span>after</span><span>, </span><span>Vec3</span><span>::</span><span>new</span><span>(</span><span>5.0</span><span>, </span><span>2.0</span><span>, </span><span>3.0</span><span>));</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 테스트는 기즈모가 화면에서 보기 좋은지 확인하지 않는다. X축을 드래그했을 때 Y축과 Z축이 변경되지 않아야 한다는 계약만 고정한다.</p>

<table><thead><tr><th>검증 대상</th><th>적합한 방법</th></tr></thead><tbody><tr><td>포인터와 월드 좌표 사이의 계산</td><td>단위 테스트</td></tr><tr><td>선택한 축에 따른 이동 제한</td><td>단위 테스트</td></tr><tr><td>명령 생성과 상태 변경</td><td>명령·통합 테스트</td></tr><tr><td>Undo·Redo 기록</td><td>상태 전이 테스트</td></tr><tr><td>핸들의 표시와 선택 상태</td><td>스크린샷·통합 테스트</td></tr><tr><td>드래그 감도와 조작감</td><td>사람의 직접 검토</td></tr></tbody></table><p>이런 영역에서는 TDD만으로 품질을 판단하기 어렵다. 시각적 완성도와 조작감을 억지로 숫자로 고정하면 실제 품질을 놓치고 테스트 유지 비용만 늘 수 있다.</p><p>그래서 시각적 기능 전체보다 주변의 결정적인 경계를 검증했다.</p><ul>
<li>렌더링 결과 전체보다 렌더러에 전달되는 장면 데이터</li>
<li>애니메이션의 자연스러움보다 특정 시간의 포즈 계산</li>
<li>기즈모의 조작감보다 축 제한과 명령 생성</li>
<li>최종 화면 전체보다 상태 변경과 이벤트 계약</li>
</ul></section></section>
<section><h2>AI와 TDD를 함께 사용할 때의 주의점<a href="#ai와-tdd를-함께-사용할-때의-주의점"><span>#</span></a></h2><p>테스트가 통과한다고 구현이 반드시 올바른 것은 아니다. 사람이 잘못된 계약을 정의하면 AI는 그 잘못된 계약을 충실하게 구현할 가능성이 있다. 테스트에 적힌 사례만 통과하도록 과도하게 맞추거나, 테스트가 확인하지 않는 경로에서 잘못된 동작을 만들기도 한다.</p><p>AI 에이전트에게 테스트 실행을 맡기더라도 사람은 아래 내용을 검토해야 한다.</p><ul>
<li>테스트가 실제 요구사항을 표현하는가</li>
<li>정상 사례뿐 아니라 실패와 경계 조건을 포함하는가</li>
<li>내부 구현에 지나치게 결합되지 않았는가</li>
<li>테스트를 통과하기 위한 하드코딩이나 우회가 없는가</li>
<li>단위 테스트 밖의 통합·시각적 검증이 필요한가</li>
</ul><p>AI 에이전트에게 구현을 맡겨도 계약과 경계를 정하고 결과를 검토하는 일은 사람이 해야 한다.</p></section>
<section><h2>게임 개발에 TDD를 적용하며 정한 기준<a href="#게임-개발에-tdd를-적용하며-정한-기준"><span>#</span></a></h2><p>Shotloom에서 효과를 본 방식을 게임 개발에 옮긴다면 모든 기능에 TDD를 강제하기보다, 입력과 출력이 분명한 경계부터 시작하는 편이 현실적이다.</p><ol>
<li>명령, 상태 전이와 직렬화처럼 입력과 출력이 분명한 로직부터 시작한다.</li>
<li>내부 함수 호출 순서보다 외부에서 관찰할 동작을 테스트한다.</li>
<li>성공 사례와 함께 실패할 입력과 상태 불변 조건을 정한다.</li>
<li>AI 에이전트가 구현 전후로 관련 테스트를 직접 실행하게 한다.</li>
<li>엔진 통합은 기능 테스트, 시각적 결과는 스크린샷과 사람의 검토를 조합한다.</li>
<li>프로토타이핑 단계에서는 빠른 탐색을 허용하고 계약이 확정되는 시점에 테스트로 고정한다.</li>
</ol><p>이 목록은 TDD를 강제하는 규칙이 아니다. 어디에 적용할 때 가장 값어치가 큰지 판단하는 기준이다.</p></section>
<section><h2>다시 게임을 개발한다면<a href="#다시-게임을-개발한다면"><span>#</span></a></h2><p>Shotloom의 경험은 Rust 프로젝트에서만 유효한 개발 방식으로 남지 않았다. 앞으로 어떤 엔진이나 자체 런타임으로 게임을 만들더라도 무엇을 런타임 안에 두고 무엇을 독립적으로 검증할지 먼저 생각할 것 같다.</p><p>과거에는 엔진이 제공하는 오브젝트와 컴포넌트부터 만들고 프레임 생명주기 안에서 기능을 확장하는 방식이 자연스러웠다. Unreal Engine의 Actor·Component나 Unity의 GameObject·MonoBehaviour가 대표적인 예다. 화면에 결과를 빨리 띄우기에는 좋지만 게임 규칙이 프레임 업데이트, 씬 오브젝트와 에디터 상태에 섞이기 시작하면 간단한 동작 하나를 확인할 때도 전체 실행 문맥이 필요하다.</p><p>다음 게임 프로젝트에서는 모든 코드를 엔진에서 떼어내기보다 반복해서 검증해야 하는 규칙부터 엔진 독립적인 경계로 꺼내고 싶다.</p><p>후보는 여럿이다.</p><ul>
<li>아이템 사용, 장착과 소모 조건</li>
<li>체력, 피해와 상태 이상 계산</li>
<li>스킬 재사용 대기시간과 자원 소비</li>
<li>퀘스트와 게임 진행 상태 전이</li>
<li>턴, 라운드와 승패 조건</li>
<li>고정된 시간 입력에 대한 결정적 시뮬레이션 결과</li>
<li>저장 데이터의 검증과 마이그레이션</li>
<li>네트워크 요청에 대한 서버 권한 규칙</li>
</ul><p>이 영역에서는 화면 표시보다 어떤 입력에서 어떤 상태로 바뀌는지가 중요하다. 엔진은 사용자 입력, 물리, 렌더링과 생명주기를 맡고 게임 규칙은 명령을 받아 상태와 이벤트를 반환하도록 나눌 수 있다.</p><section><h3>기능보다 명령 계약부터 생각하기<a href="#기능보다-명령-계약부터-생각하기"><span>#</span></a></h3><p>새 기능을 만들 때도 “어떤 엔진 오브젝트를 만들어야 하는가?”에 앞서 몇 가지를 물어볼 수 있다.</p><ul>
<li>플레이어가 전달하려는 의도는 어떤 명령인가?</li>
<li>명령을 실행하기 위해 필요한 입력은 무엇인가?</li>
<li>성공하면 어떤 상태와 이벤트가 만들어지는가?</li>
<li>거부해야 하는 입력과 실패 이유는 무엇인가?</li>
<li>클라이언트, 서버와 저장 데이터가 공유해야 할 규칙은 무엇인가?</li>
</ul><p>아이템 사용 기능이라면 UI 버튼이나 애니메이션보다 <code>UseItem</code> 명령의 입력과 성공 결과, 수량 부족이나 잘못된 대상 같은 실패 조건부터 정의한다. 이 계약을 테스트로 고정한 뒤 AI 에이전트와 구현하면 UI, 네트워크와 엔진 표현이 달라져도 핵심 규칙은 같은 기준으로 검증된다.</p><p>Shotloom에서 배운 것은 특정 테스트 프레임워크의 사용법보다 이런 질문을 구현 전에 던지는 습관이었다.</p></section><section><h3>프로토타이핑과 계약 확정을 구분하기<a href="#프로토타이핑과-계약-확정을-구분하기"><span>#</span></a></h3><p>게임 기능 중에는 직접 만져봐야 요구사항이 드러나는 것도 많다. 캐릭터 이동, 카메라, 전투 감각과 애니메이션 연출을 처음 탐색하는 단계에서 모든 동작을 테스트로 고정하면 오히려 변경을 방해한다.</p><p>프로토타입 단계에서는 빠른 구현과 수동 검증을 허용하되, 동작이 제품의 규칙으로 굳어지는 시점을 구분하려 한다. 저장, 네트워크, 다른 시스템이나 여러 UI가 같은 규칙에 의존하기 시작하면 테스트 가능한 계약으로 옮긴다.</p><p>여기서 TDD는 아이디어를 발견하는 도구보다 발견한 규칙을 잃지 않는 도구에 가깝다.</p></section><section><h3>AI에는 구현보다 먼저 완료 조건을 전달하기<a href="#ai에는-구현보다-먼저-완료-조건을-전달하기"><span>#</span></a></h3><p>AI 에이전트와 게임 기능을 구현할 때도 자연어 요청만 전달하지 않고 최소한 세 가지를 먼저 정한다.</p><ol>
<li>성공해야 하는 대표 시나리오</li>
<li>실패해야 하는 경계 입력</li>
<li>실패해도 유지돼야 하는 상태</li>
</ol><p>이 조건을 테스트로 표현한 뒤 구현과 테스트 실행, 수정은 AI 에이전트가 반복하도록 한다. 사람은 테스트가 실제 게임 규칙을 표현하는지, 구현이 엔진 구조와 성능 제약에 맞는지 검토한다.</p><p>AI가 작성하는 코드가 늘어나면 각 줄을 직접 썼는지보다 변경이 따라야 할 계약이 있는지가 더 중요해진다. Shotloom에서 겪은 TDD는 이후 게임 개발에서도 AI의 속도를 통제하는 현실적인 방법이 될 수 있다고 느꼈다.</p></section><section><h3>검증 방법도 기능의 성격에 맞게 나누기<a href="#검증-방법도-기능의-성격에-맞게-나누기"><span>#</span></a></h3><p>다음 게임에서도 검증 방법은 기능의 성격에 따라 나눌 생각이다. 순수한 게임 규칙은 단위 테스트로, 엔진과의 연결은 기능·통합 테스트로 확인한다. 렌더링과 조작감은 캡처 비교와 직접 플레이로 본다.</p><p>결과를 결정적으로 표현할 수 있고 실패 비용이 큰 영역은 자동화한다. 감각과 맥락이 필요한 영역은 직접 검토한다.</p><p>모든 게임 기능에 TDD를 적용하려는 것은 아니다. 게임 규칙과 엔진 표현을 나누고, 테스트로 정한 계약 안에서 AI 에이전트가 구현하도록 하는 방식만 가져가려 한다.</p></section></section>
<section><h2>이후에도 가져갈 기준<a href="#이후에도-가져갈-기준"><span>#</span></a></h2><p>게임 개발에서 익숙했던 빠른 에디터 피드백과 수동 검증은 여전히 중요하다. 렌더링 품질, 애니메이션의 자연스러움과 기즈모 조작감은 테스트만으로 판단할 수 없다.</p><p>Shotloom에서는 명령과 상태 경계를 테스트로 먼저 정한 뒤 AI 에이전트가 구현하도록 했다. 입력, 출력과 실패 조건이 남으니 구현 속도가 빨라져도 확인 기준을 잃지 않았다. 이 경험은 이후 게임 구조를 어떻게 나눌 것인지에도 영향을 줬다.</p><p>예전에는 테스트를 먼저 쓰는 시간이 구현을 늦추는 비용처럼 보였다. 지금은 구현 전에 계약을 정하는 데 쓰는 시간으로 본다.</p><p>TDD가 게임 개발 전체를 대신하지는 않는다. 그래도 결정적인 로직과 명령 계약을 보호하고, 사람과 AI가 같은 완료 조건을 공유하는 방법으로는 이전보다 훨씬 실용적으로 느껴졌다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/eli5-skill-and-my-codex-workflow/</id>
      <title type="text">충격적으로 짧은 ELI5 스킬을 보고, 내가 쓰던 워크플로우를 다시 생각했다</title>
      <published>2026-08-24T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/eli5-skill-and-my-codex-workflow/"/>
      <summary type="text">Claude용 ELI5 Agent Skill을 보고 평소 Codex 사이드 챗에서 반복하던 설명 방식을 떠올렸다. 단순한 스킬과 모델의 재량 사이에서 어떤 규칙을 남길지 고민한 기록.</summary>
      <content type="html"><![CDATA[<p>최근 주목받던 Agent Skill 가운데 <code>eli5</code>가 눈에 들어왔다. <a href="https://x.com/trq212/status/2090884854590382515?s=20" target="_blank">Claude Code 팀의 Thariq Shihipar가 X에 올린 소개 글</a>을 보고 알게 됐다. 그는 Anthropic 내부에서 요즘 이 스킬을 많이 쓴다고 적었다. 이름은 익숙했다. Explain Like I’m Five라는 말 그대로 아무것도 모르는 사람에게 설명하듯 쉽게 풀어 달라는 뜻이다.</p>
<p>관심이 간 건 스킬 설명이었다. 맥락이나 배경지식이 없는 사람을 위해 큰 그림과 적은 글로 HTML 설명 자료를 만들어 준다고 적혀 있었다.</p>
<p>처음 든 생각은 단순했다.</p>
<blockquote><p>이거 내가 평소에 하던 방식과 비슷한데?</p></blockquote>
<p>내 코드에 달린 수정 요청을 검토하거나 다른 사람의 PR을 이해해야 할 때면 바로 판단하기 어려웠다. 특히 익숙하지 않은 도메인이나 기술이 섞인 PR은 본문과 diff를 읽어도 전체 변경 내용을 파악하기 어려웠다.</p>
<p>그럴 때면 작업 스레드 옆에 사이드 챗을 하나 열고 대체로 이렇게 요청했다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>이 내용을 주니어 개발자도 이해할 수 있게 설명해 줘.</span></div></div><div><div><div>2</div></div><div><span>필요하면 $visualize를 활용해.</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p>정교하게 다듬은 프롬프트는 아니었고 표현도 그때그때 조금씩 달랐다. 그래도 복잡한 호출 관계는 Mermaid로 정리됐고, 상태나 선택지를 살펴봐야 할 때는 대화 안에서 직접 조작할 수 있는 설명이 나왔다. 별도 문서를 열지 않고 작업하던 채팅에서 바로 볼 수 있어 마음에 들었다.</p>
<p>이 습관은 <a href="/posts/cognitive-surrender/">AI가 짠 코드를 나는 정말 이해하고 있었을까</a>에서 적었던 고민과도 이어진다. 승인하거나 수정할 내용을 적어도 내 말로 설명할 수 있게 되고 싶었다. 사이드 챗에 판단을 맡기려던 건 아니었다. 판단에 필요한 내용을 먼저 이해하려고 썼다.</p>
<section><h2>반가움 다음에 찾아온 충격<a href="#반가움-다음에-찾아온-충격"><span>#</span></a></h2><p>출처를 따라가 보니 내가 본 <code>eli5</code>는 <a href="https://github.com/anthropics/claude-plugins-community/tree/main/eli5" target="_blank"><code>anthropics/claude-plugins-community</code></a>에 등록된 Claude용 커뮤니티 플러그인이었다. manifest에 표시된 작성자는 Thariq Shihipar이고 최초 커밋 날짜는 2026년 8월 21일이다.</p><p>Thariq가 후속 글에서 든 사용 예도 낯설지 않았다. 그는 설명 자료를 만들거나 문제를 깊게 파고들 때 <code>eli5</code>를 쓴다며 모듈의 작동 방식, 트레이드오프를 택한 이유, 장애 원인을 묻는 예를 들었다. 내가 PR과 리뷰 요청을 이해하려고 사이드 챗을 쓰던 방식과 비슷했다.</p><p>이 저장소는 이름에 <code>anthropics</code>가 들어가지만 Anthropic이 직접 유지하는 공식 플러그인 모음은 아니다. README는 이곳을 심사를 통과한 커뮤니티 플러그인의 읽기 전용 미러라고 설명하며 공식 플러그인 저장소를 따로 안내한다. 그래서 이 글에서는 <code>eli5</code>를 Anthropic 공식 스킬이라고 부르지 않고 Claude용 커뮤니티 스킬로 표현한다.</p><p>막상 <a href="https://github.com/anthropics/claude-plugins-community/blob/main/eli5/skills/eli5/SKILL.md" target="_blank"><code>SKILL.md</code></a>를 열어 보니 생각보다 훨씬 짧았다. 한국어로 요약하면 이 정도다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>이 주제를 아무것도 모르는 사람에게 설명한다.</span></div></div><div><div><div>2</div></div><div><span>큰 그림과 적은 글을 사용한 HTML 설명 자료로 만든다.</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>원본에는 내가 스킬을 만들려고 생각했던 단계와 규칙이 거의 없었다. 용어를 소개할 순서나 시각화할 대상, 설명이 이해하기 쉬운지 판단할 기준도 정하지 않았다. 나라면 예외와 출력 형식을 더 붙였을 것 같았다.</p><p>그러자 질문이 생겼다.</p><blockquote><p>요즘 모델이라면 이렇게 짧게 의도만 전달하는 편이 더 나은 걸까?</p></blockquote></section>
<section><h2>Claude용 스킬을 Codex에서 실행해 보니<a href="#claude용-스킬을-codex에서-실행해-보니"><span>#</span></a></h2><p>직접 Codex에서 <code>eli5</code>를 실행해 보니 채팅 내부에 인터랙티브 HTML 설명 페이지가 렌더됐다. 버튼을 누르면 서로 다른 선택지와 설명이 나타났다.</p><p>실행 과정에서는 Codex에 번들로 포함된 <code>visualize</code> 스킬이 함께 선택됐다. 다만 원본 <code>eli5</code>가 <code>visualize</code>를 직접 호출한다는 근거는 없다. 원본은 Claude용으로 작성됐고 지시문에도 <code>visualize</code>라는 이름은 없다. 내가 확인한 건 Codex에서 HTML 설명 자료를 요구했을 때 시각화 기능이 사용됐다는 것뿐이다.</p><p><code>HTML artifact</code>라는 표현이 Codex의 시각화 기능과 맞아떨어진 것 같다. 실행 결과를 보고 추측한 것이며, Claude에서도 같은 구성과 상호작용이 만들어지는지는 비교하지 않았다.</p><p>이 점을 보고 <code>eli5</code>가 짧은 이유를 다시 생각했다.</p><p>처음에는 최신 모델이 알아서 잘하니 지시가 짧다고 생각했다. 그런데 내가 본 실행에서는 스킬이 세부사항을 모두 정하지 않아도 실행 환경이 상당 부분을 맡고 있었다.</p><p><code>eli5</code>는 설명 대상과 결과의 방향만 정한다. Codex에서는 번들 스킬이 시각화의 구체적인 구성과 상호작용을 맡았다. 짧은 지침이 실행 환경의 기존 기능을 사용하도록 연결한 셈이다.</p><p>같은 <code>SKILL.md</code>라도 실행하는 모델과 호스트, 설치된 도구와 스킬에 따라 결과가 달라질 수 있겠다고 생각했다.</p></section>
<section><h2>그렇다면 내 워크플로우에는 무엇을 남겨야 할까<a href="#그렇다면-내-워크플로우에는-무엇을-남겨야-할까"><span>#</span></a></h2><p>처음에는 설명에 현재 상황, 문제, 해결 방법과 영향을 항상 넣고 싶었다. PR과 리뷰 코멘트를 이해할 때 자주 확인하던 항목이기 때문이다.</p><p>코드 리뷰에만 쓰려던 건 아니었다. 기술 문서와 기사, 논문, 정책이나 기획 문서에도 쓰고 싶었다. 모든 글에 문제와 해결 방법이 있는 건 아니다. 목차를 고정하면 모델이 원문의 구조보다 틀을 채우는 데 맞춰 설명하거나, 원문에 없는 해결책을 만들어낼 수 있다.</p><p>하나씩 걷어낸 끝에 세 가지만 남겼다.</p><ol>
<li>독자에게 관련 맥락과 배경지식이 없다고 전제한다.</li>
<li>전문 용어가 등장하면 본문보다 먼저 짧은 용어집을 보여 준다.</li>
<li>시각화가 실제로 이해를 쉽게 만들 때 Codex의 <code>visualize</code>를 사용한다.</li>
</ol><p><code>eli5</code>와 내가 생각한 첫 버전을 나란히 놓으면 차이가 보인다.</p>

<table><thead><tr><th>구분</th><th><code>eli5</code></th><th>내가 만들려는 첫 버전</th></tr></thead><tbody><tr><td>대상</td><td>설명할 주제</td><td>사용자가 이해하려는 다양한 글</td></tr><tr><td>독자 전제</td><td>주제를 전혀 모름</td><td>맥락과 배경지식이 없음</td></tr><tr><td>전문 용어</td><td>모델의 설명에 맡김</td><td>필요하면 용어집을 먼저 제시</td></tr><tr><td>시각화</td><td>HTML 설명 자료를 기본으로 요구</td><td>글보다 쉬워질 때만 <code>visualize</code> 사용</td></tr><tr><td>실행 환경</td><td>Claude용으로 배포</td><td>Codex 전용</td></tr><tr><td>이해 확인</td><td>별도 절차 없음</td><td>별도 절차 없음</td></tr></tbody></table><p>퀴즈나 확인 질문까지 강제할 생각은 없었다. 주어진 글을 이해하기 쉽게 설명하고 끝나면 충분하다. 사용자가 아는 내용을 장기적으로 기억하거나 다음 실행에서 자동으로 생략하는 기능도 첫 버전에는 필요하지 않다.</p><p>이렇게 줄이고 나니 지침은 <code>eli5</code>에 가까울 만큼 짧아졌다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>주어진 글을 배경지식이 없는 사람도 이해할 수 있게 설명한다.</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>전문 용어가 나오면 본문에 앞서 짧은 용어집을 제공한다.</span></div></div><div><div><div>4</div></div><div><span>시각화가 이해를 실질적으로 개선할 때는 $visualize를 사용한다.</span></div></div><div><div><div>5</div></div><div><span>설명이 끝난 뒤 사용자에게 별도의 이해 확인을 요구하지 않는다.</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>시각화의 개수나 출력 목차는 고정하지 않는다. 설명에 필요한 단어를 먼저 알려 주고, 글만으로 전달하기 어려운 관계를 보여 주면 된다. 비유와 설명 순서, Mermaid와 인터랙티브 HTML 중 무엇을 선택할지는 모델과 <code>visualize</code>에 맡긴다.</p></section>
<section><h2>긴 지침보다 evaluation으로 키우기<a href="#긴-지침보다-evaluation으로-키우기"><span>#</span></a></h2><p>사이드 챗에 프롬프트를 매번 다시 쓰면서 입력을 줄이는 것만으로는 부족하다고 느꼈다. 표현이 조금만 달라져도 설명의 깊이가 달라졌기 때문이다. 반복해서 좋은 결과를 낸 지침을 한곳에 모으고, 평가하면서 고쳐 나가고 싶었다.</p><p>가능한 실패를 처음부터 모두 예상해 규칙으로 적다 보면 프롬프트는 다시 길어진다. 작은 버전으로 시작해 실제 사례에서 반복되는 실패만 지침에 반영하는 편이 낫다고 봤다.</p><p>먼저 내가 자주 마주치는 입력을 평가 사례로 삼는다.</p><ul>
<li>맥락이 압축된 PR 본문</li>
<li>한 문장 안에 도메인 용어가 몰린 리뷰 코멘트</li>
<li>구조와 상태 변화가 복잡한 기술 문서</li>
<li>시각화가 필요하지 않은 일반적인 글</li>
<li>그림이나 상호작용이 글보다 이해를 크게 돕는 설명</li>
</ul><p>결과를 볼 때는 어려운 용어를 먼저 설명했는지부터 확인한다. 필요한 시각화가 빠졌는지, 단순한 글에 시각화를 억지로 붙였는지도 살핀다. 원문에 없는 맥락을 사실처럼 설명하지 않았는지도 확인한다.</p><p>실패가 반복될 때만 규칙을 추가한다. 배경 설명이 자주 빠지면 어떤 내용을 설명해야 하는지 구체화하고, 시각화를 남용하면 사용 조건을 좁힌다. 모든 상황에 대비하기보다 실제로 확인한 실패가 되풀이되지 않도록 지침을 고치려 한다.</p><p><a href="https://learn.chatgpt.com/docs/build-skills" target="_blank">OpenAI 공식 스킬 문서</a>도 하나의 작업에 집중하라고 안내한다. 동작이 항상 일정해야 하거나 외부 도구가 필요한 경우를 제외하면 스크립트보다 지침을 우선하고, 프롬프트가 스킬 설명에 맞게 호출을 유도하는지 시험하라는 조언도 있다. 작은 워크플로우를 만들고 검증하면서 다듬으려는 방향과 맞는다.</p></section>
<section><h2>단순함은 생략이 아니라 위임일지도 모른다<a href="#단순함은-생략이-아니라-위임일지도-모른다"><span>#</span></a></h2><p>나만의 임시방편이라고 생각했던 사이드 챗 사용법이 다른 사람에게는 이미 스킬로 정리되어 있어 반가웠다.</p><p>중요하다고 생각한 규칙이 거의 없어 당황했지만, Codex에서는 그 짧은 지침으로도 내가 쓰던 대화 안의 인터랙티브 설명이 나왔다.</p><p>스킬에서 모든 과정을 일일이 지시할 필요는 없을 것 같다. 모델과 실행 환경에 맡겨도 되는 부분과 실패가 반복되지 않도록 규칙을 남길 부분을 구분하면 되지 않을까.</p><p>아직 내 스킬을 완성하지는 않았다. 일단 배경지식이 없는 독자라는 전제, 선행 용어집, 선택적 시각화만 남긴 버전으로 시작하려고 한다. 나머지는 실제 글과 PR, 리뷰 코멘트를 설명해 보고 평가하면서 결정할 생각이다.</p><p><code>eli5</code>를 그대로 옮기거나 예전처럼 모든 절차를 먼저 적지는 않으려 한다.</p><blockquote><p>단순하게 시작하되, 내가 중요하게 여기는 이해의 조건까지 모델의 우연에 맡기지는 않는다.</p></blockquote></section>
<section><h2>참고<a href="#참고"><span>#</span></a></h2><ul>
<li><a href="https://x.com/trq212/status/2090884854590382515?s=20" target="_blank">Thariq Shihipar의 ELI5 소개 X 게시물</a></li>
<li><a href="https://github.com/anthropics/claude-plugins-community/tree/main/eli5" target="_blank">Claude Plugins Community의 <code>eli5</code></a></li>
<li><a href="https://learn.chatgpt.com/docs/build-skills" target="_blank">OpenAI 공식 문서: Build skills</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/firefly-blog-guide/</id>
      <title type="text">Firefly 블로그 가이드</title>
      <published>2026-08-24T00:00:00.000Z</published>
      <updated>2026-09-08T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/firefly-blog-guide/"/>
      <summary type="text">Firefly 기본 예제 글을 정리하면서 실제로 사용하는 글과 프로젝트 작성법, Markdown 확장, 다이어그램, 내부 링크, 암호화와 레이아웃 설정을 한곳에 모았습니다.</summary>
      <content type="html"><![CDATA[<p>이 블로그는 Astro 기반 테마인 <a href="https://github.com/CuteLeaf/Firefly" target="_blank">Firefly</a>를 사용한다. 처음에는 테마 기능을 확인하기 위한 예제 글을 그대로 두었지만, 개인 글보다 예제 글이 아카이브의 대부분을 차지하는 모습이 점점 어색해졌다.</p>
<p>예제를 전부 없애면 나중에 문법과 설정을 다시 찾아야 하고, 그대로 두면 블로그의 성격이 흐려진다. 그래서 실제로 참고할 내용만 이 글 하나에 모으고 나머지 기본 예제는 정리하기로 했다. 상세한 최신 설정은 <a href="https://docs-firefly.cuteleaf.cn" target="_blank">Firefly 공식 문서</a>를 기준으로 하고, 이 글은 내가 이 저장소에서 자주 확인할 내용을 위한 빠른 안내서로 사용한다.</p>
<a href="https://github.com/CuteLeaf/Firefly" target="_blank"><div><div><div><div></div><div>CuteLeaf</div></div><div>/</div><div>Firefly</div></div><div></div></div><div>🍀Firefly, fresh and aesthetic Astro blog theme template. </div><div><div>—</div><div>—</div><div>MIT</div><span>Astro</span></div></a>
<section><h2>새 글 만들기<a href="#새-글-만들기"><span>#</span></a></h2><p>글은 <code>src/content/posts/</code> 아래에 Markdown 또는 MDX 파일로 작성한다. 스크립트를 사용하면 기본 Frontmatter가 들어간 파일을 만들 수 있다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>new-post</span><span> </span><span>my-new-post</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>직접 파일을 만들 때는 다음 정도로 시작하면 충분하다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>---</span></div></div><div><div><div>2</div></div><div><span>title</span><span>: </span><span>새 글 제목</span></div></div><div><div><div>3</div></div><div><span>published</span><span>: </span><span>2026-08-24</span></div></div><div><div><div>4</div></div><div><span>description</span><span>: </span><span>글 목록과 검색 결과에 표시할 짧은 설명</span></div></div><div><div><div>5</div></div><div><span>image</span><span>: </span><span>./images/cover.avif</span></div></div><div><div><div>6</div></div><div><span>tags</span><span>: [</span><span>개발</span><span>, </span><span>기록</span><span>]</span></div></div><div><div><div>7</div></div><div><span>category</span><span>: </span><span>개발</span></div></div><div><div><div>8</div></div><div><span>draft</span><span>: </span><span>false</span></div></div><div><div><div>9</div></div><div><span>slug</span><span>: </span><span>my-new-post</span></div></div><div><div><div>10</div></div><div><span>---</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>자주 사용하는 필드는 다음과 같다.</p>

<table><thead><tr><th>필드</th><th>용도</th></tr></thead><tbody><tr><td><code>title</code></td><td>글 제목</td></tr><tr><td><code>published</code></td><td>최초 발행일</td></tr><tr><td><code>updated</code></td><td>내용을 수정한 날짜</td></tr><tr><td><code>description</code></td><td>목록 카드와 메타데이터에 사용할 요약</td></tr><tr><td><code>image</code></td><td>원격 URL, <code>public</code> 절대 경로 또는 글 기준 상대 경로의 표지 이미지</td></tr><tr><td><code>tags</code></td><td>여러 글을 주제별로 연결하는 태그</td></tr><tr><td><code>category</code></td><td>글의 대표 분류</td></tr><tr><td><code>pinned</code></td><td>글을 일반적인 날짜 정렬보다 위에 고정</td></tr><tr><td><code>draft</code></td><td><code>true</code>이면 프로덕션에서 비공개</td></tr><tr><td><code>comment</code></td><td>글별 댓글 사용 여부</td></tr><tr><td><code>slug</code></td><td>파일 이름과 다른 공개 URL이 필요할 때 지정</td></tr><tr><td><code>password</code></td><td>빌드 시 글 내용을 암호화할 비밀번호</td></tr><tr><td><code>passwordHint</code></td><td>비밀번호 입력 화면에 표시할 힌트</td></tr><tr><td><code>series</code></td><td>여러 글을 같은 시리즈로 묶는 이름</td></tr><tr><td><code>seriesOrder</code></td><td>시리즈 안에서 글을 표시할 순서</td></tr></tbody></table><p><code>slug</code>는 한 번 공개한 뒤 바꾸면 기존 링크가 깨질 수 있으므로 짧은 영문과 하이픈 조합으로 처음부터 정해 두는 편이 좋다.</p></section>
<section><h2>글을 시리즈로 묶기<a href="#글을-시리즈로-묶기"><span>#</span></a></h2><p>연속해서 읽을 글은 같은 <code>series</code> 이름을 지정하고 <code>seriesOrder</code>로 순서를 정한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>series</span><span>: </span><span>Unreal Engine 네트워킹</span></div></div><div><div><div>2</div></div><div><span>seriesOrder</span><span>: </span><span>1</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>시리즈가 지정된 글에는 같은 시리즈의 글 목록이 표시된다. <code>/series/</code>에서는 전체 시리즈를 글 수가 많은 순서로 보여 주며, 각 시리즈 안에서는 <code>seriesOrder</code>가 작은 글부터 정렬한다. <code>seriesOrder</code>를 생략한 글은 순서를 지정한 글 뒤에 배치된다.</p></section>
<section><h2>프로젝트 작성하기<a href="#프로젝트-작성하기"><span>#</span></a></h2><p>프로젝트는 블로그 글과 별도로 <code>src/content/projects/</code> 아래에 <code>.md</code> 또는 <code>.mdx</code> 파일로 작성한다. <a href="/projects/">프로젝트 목록</a>의 카드와 상세 상단은 Frontmatter를 사용하고, 상세 하단에는 Markdown 본문을 README처럼 표시한다. 페이지 사용 여부는 <code>src/config/siteConfig.ts</code>의 <code>pages.projects</code>로 정한다.</p><p>이 절은 upstream의 프로젝트 작성 예제를 한국어로 옮겨 통합한 내용이다. 실제 파일 구성은 <a href="/projects/copy-selection-context/">Copy Selection Context</a>의 <code>src/content/projects/copy-selection-context.md</code>와 <code>src/content/projects/images/copy-selection-context.webp</code>를 참고하면 된다.</p><section><h3>프로젝트 Frontmatter<a href="#프로젝트-frontmatter"><span>#</span></a></h3>

<table><thead><tr><th>필드</th><th>타입</th><th>용도</th></tr></thead><tbody><tr><td><code>title</code></td><td>string</td><td>필수. 프로젝트 이름</td></tr><tr><td><code>slug</code></td><td>string</td><td>파일 이름과 다른 공개 URL 지정</td></tr><tr><td><code>published</code></td><td>date</td><td>필수. 날짜와 정렬 기준</td></tr><tr><td><code>draft</code></td><td>boolean</td><td>프로덕션에서 숨길지 여부</td></tr><tr><td><code>order</code></td><td>number</td><td>수동 정렬 순서</td></tr><tr><td><code>description</code></td><td>string</td><td>카드·상세·메타데이터의 설명</td></tr><tr><td><code>image</code></td><td>string</td><td>목록과 상세의 표지 이미지</td></tr><tr><td><code>tags</code></td><td>string[]</td><td>목록과 상세에 표시할 태그</td></tr><tr><td><code>link</code></td><td>array</td><td>외부 링크 버튼 목록</td></tr><tr><td><code>status</code></td><td>string</td><td>프로젝트 상태</td></tr><tr><td><code>lang</code></td><td>string</td><td>페이지 언어</td></tr></tbody></table><p><code>title</code>과 <code>published</code>를 제외한 필드는 선택 사항이다. <code>slug</code>는 글과 마찬가지로 파일 이름과 다른 공개 URL이 필요할 때 지정한다. 날짜는 <code>2025-10-01</code> 형식으로 쓴다.</p><p><code>draft</code>의 기본값은 <code>false</code>다. <code>true</code>로 바꾸면 프로덕션 목록과 상세에서 제외되며, <code>pnpm dev</code>에서는 확인할 수 있다.</p><p><code>order</code>는 값이 클수록 먼저 표시한다. 생략한 프로젝트는 값을 지정한 프로젝트 뒤에 배치한다. 값이 같거나 둘 다 없으면 <code>published</code>가 최신인 순서로, 날짜까지 같으면 제목으로 정렬한다. <code>order: 0</code>도 순서를 지정한 값으로 취급한다.</p><p><code>image</code>는 전체 URL, <code>public</code> 루트 경로(<code>/images/cover.png</code>), 프로젝트 파일 기준 상대 경로(<code>images/cover.png</code>)를 지원한다. 비워 두면 목록은 기본 아이콘을 표시하고 상세는 표지를 생략한다.</p><p><code>link</code>의 각 항목은 <code>{ label, icon, value }</code> 형식이다. <code>label</code>은 표시 이름, <code>value</code>는 연결할 URL이다. <code>icon</code>에는 <code>fa7-brands:github</code> 같은 astro-icon 이름이나 이미지 URL을 넣는다. 비워 두면 <code>label</code>의 첫 글자를 아이콘 대신 표시한다.</p><p><code>status</code>는 아래의 표준 키를 사용하며 생략하면 상태를 표시하지 않는다. <code>lang</code>에는 <code>ko</code>, <code>zh_CN</code> 같은 언어 코드를 넣고, 비우면 사이트 언어를 사용한다.</p></section><section><h3>프로젝트 파일 예시<a href="#프로젝트-파일-예시"><span>#</span></a></h3><p>다음은 Copy Selection Context 프로젝트의 주요 Frontmatter다. 본문은 마지막 <code>---</code> 아래에 이어서 작성한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>---</span></div></div><div><div><div>2</div></div><div><span>title</span><span>: </span><span>"Copy Selection Context"</span></div></div><div><div><div>3</div></div><div><span>published</span><span>: </span><span>2026-02-18</span></div></div><div><div><div>4</div></div><div><span>draft</span><span>: </span><span>false</span></div></div><div><div><div>5</div></div><div><span>description</span><span>: </span><span>"JetBrains IDE에서 파일 경로, 줄 번호와 코드를 함께 복사하는 플러그인. AI에 전달할 코드 문맥을 준비할 때 사용한다."</span></div></div><div><div><div>6</div></div><div><span>image</span><span>: </span><span>"images/copy-selection-context.webp"</span></div></div><div><div><div>7</div></div><div><span>lang</span><span>: </span><span>ko</span></div></div><div><div><div>8</div></div><div><span>tags</span><span>:</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>- </span><span>jetbrains-plugin</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>- </span><span>developer-tools</span></div></div><div><div><div>11</div></div><div><span>link</span><span>:</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>- </span><span>label</span><span>: </span><span>"GitHub"</span></div></div><div><div><div>13</div></div><div><span>    </span><span>icon</span><span>: </span><span>"fa7-brands:github"</span></div></div><div><div><div>14</div></div><div><span>    </span><span>value</span><span>: </span><span>"https://github.com/hon454/copy-selection-context"</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>- </span><span>label</span><span>: </span><span>"JetBrains Marketplace"</span></div></div><div><div><div>16</div></div><div><span>    </span><span>value</span><span>: </span><span>"https://plugins.jetbrains.com/plugin/30262-copy-selection-context"</span></div></div><div><div><div>17</div></div><div><span>---</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div></section><section><h3>프로젝트 상태와 검색<a href="#프로젝트-상태와-검색"><span>#</span></a></h3><p><code>status</code>는 번역한 문구 대신 표준 키를 넣는다. 화면에는 사이트 언어에 맞는 이름과 상태별 색상·아이콘이 표시된다.</p>

<table><thead><tr><th>키</th><th>한국어 표시</th><th>의미</th></tr></thead><tbody><tr><td><code>planning</code></td><td>계획 중</td><td>구상하거나 준비하는 프로젝트</td></tr><tr><td><code>developing</code></td><td>개발 중</td><td>개발을 진행하는 프로젝트</td></tr><tr><td><code>published</code></td><td>출시됨</td><td>공개한 프로젝트</td></tr><tr><td><code>archived</code></td><td>보관됨</td><td>보관 상태로 전환한 프로젝트</td></tr></tbody></table><p>예를 들어 공개한 프로젝트는 <code>status: "published"</code>로 작성한다. 표준 키가 아닌 문자열은 회색으로 원문 그대로 표시되며, 해당 문자열에 대응하는 상태 필터는 생성되지 않는다. ‘전체’ 목록에서는 계속 볼 수 있다.</p><p>목록 상단에서는 상태별로 필터링하고 이름·설명·태그로 검색할 수 있다. 상태와 검색어를 함께 지정하면 두 조건에 모두 맞는 프로젝트만 표시한다.</p></section></section>
<section><h2>Markdown과 MDX<a href="#markdown과-mdx"><span>#</span></a></h2><p>대부분의 글은 <code>.md</code>로 충분하다. 제목, 목록, 인용문, 표, 링크, 이미지와 코드 블록 같은 일반적인 Markdown 문법을 그대로 사용할 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>## 소제목</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>-</span><span> 첫 번째 항목</span></span></div></div><div><div><div>4</div></div><div><span><span>-</span><span> 두 번째 항목</span></span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>&gt; 인용문</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>[</span><span>링크</span><span>]</span><span><span>(</span><span>https://example.com</span><span>)</span></span></div></div><div><div><div>9</div></div><div><span>![</span><span>대체 텍스트</span><span>]</span><span><span>(</span><span>./images/example.avif</span><span>)</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Astro나 Svelte 컴포넌트를 글 안에서 직접 사용해야 할 때만 <code>.mdx</code>를 선택한다. MDX 파일에서는 컴포넌트를 가져온 뒤 JSX 형태로 배치할 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> </span><span>Badge</span><span> </span><span>from</span><span> </span><span>"@components/common/Badge.astro"</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>&lt;</span><span>Badge</span><span>&gt;MDX에서 렌더링한 배지&lt;/</span><span>Badge</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Note</div></div><div><p>단순한 글을 MDX로 만들면 작성과 유지보수 비용만 늘어날 수 있다. 인터랙션이나 재사용 컴포넌트가 필요한 경우에만 MDX를 사용한다.</p></div></div></section>
<section><h2>알림 상자와 확장 요소<a href="#알림-상자와-확장-요소"><span>#</span></a></h2><p>중요한 설명은 Admonition 문법으로 강조할 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>:::tip[작은 팁]</span></div></div><div><div><div>2</div></div><div><span>독자가 바로 실행할 수 있는 내용을 짧게 적는다.</span></div></div><div><div><div>3</div></div><div><span>:::</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>:::warning</span></div></div><div><div><div>6</div></div><div><span>되돌리기 어렵거나 주의가 필요한 내용을 적는다.</span></div></div><div><div><div>7</div></div><div><span>:::</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>note</code>, <code>tip</code>, <code>info</code>, <code>warning</code>, <code>danger</code> 유형을 사용할 수 있다. 모양은 <code>src/config/siteConfig.ts</code>의 Markdown 관련 설정에서 GitHub, Obsidian, VitePress, Docusaurus 테마 중 하나로 선택한다.</p><p>GitHub 저장소 카드는 저장소 주소를 반복해서 설명하는 대신 다음처럼 넣는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>::github{repo="CuteLeaf/Firefly"}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>긴 설명을 접어 둘 때는 HTML의 <code>details</code> 요소도 유용하다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>details</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>summary</span><span>&gt;자세히 보기&lt;/</span><span>summary</span><span>&gt;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>접혀 있을 내용</span></div></div><div><div><div>5</div></div><div><span>&lt;/</span><span>details</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>코드 블록<a href="#코드-블록"><span>#</span></a></h2><p>Firefly의 코드 블록은 Expressive Code를 기반으로 한다. 언어 이름만 붙여도 구문 강조가 적용되고, 제목·줄 번호·강조·추가와 삭제 표시를 함께 사용할 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>```ts title="example.ts" showLineNumbers {2} ins={3}</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>message</span><span><span> </span><span>=</span><span> </span></span><span>"Firefly"</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>console</span><span>.</span></span><span>log</span><span><span>(</span><span>message</span><span>);</span></span></div></div><div><div><div>4</div></div><div><span>export</span><span> </span><span>default</span><span> </span><span>message</span><span>;</span></div></div><div><div><div>5</div></div><div><span>```</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>긴 코드의 일부는 <code>collapse</code>로 접을 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>```ts collapse={1-5, 20-30}</span></div></div><div><div><div>2</div></div><div><span>// 긴 예제 코드</span></div></div><div><div><div>3</div></div><div><span>```</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>여러 언어 또는 패키지 관리자 명령을 나란히 보여 줄 때는 MDX의 탭 컴포넌트를 사용할 수 있지만, 한두 개의 짧은 명령이라면 일반 코드 블록이 읽기 쉽다.</p></section>
<section><h2>수식<a href="#수식"><span>#</span></a></h2><p>KaTeX 문법으로 인라인 수식과 블록 수식을 작성할 수 있다. 문장 안에서는 <code>$E = mc^2$</code>처럼 쓰고, 독립된 식은 <code>$$</code>로 감싼다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>원의 넓이는 $A = \pi r^2$이다.</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>$$</span></div></div><div><div><div>4</div></div><div><span>\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}</span></div></div><div><div><div>5</div></div><div><span>$$</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>실제 렌더링 결과는 다음과 같다.</p><p>원의 넓이는 <span><span>A=πr2A = \pi r^2</span><span><span><span></span><span>A</span><span></span><span>=</span><span></span></span><span><span></span><span>π</span><span><span>r</span><span><span><span><span><span><span></span><span><span>2</span></span></span></span></span></span></span></span></span></span></span>이다.</p><span><span><span>∫−∞∞e−x2dx=π\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}</span><span><span><span></span><span><span>∫</span><span><span><span><span><span><span></span><span><span><span>−</span><span>∞</span></span></span></span><span><span></span><span><span><span>∞</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span><span>e</span><span><span><span><span><span><span></span><span><span><span>−</span><span><span>x</span><span><span><span><span><span><span></span><span><span>2</span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span><span>d</span><span>x</span><span></span><span>=</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span>π</span></span></span><span><span></span><span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span></section>
<section><h2>Mermaid와 PlantUML 다이어그램<a href="#mermaid와-plantuml-다이어그램"><span>#</span></a></h2><p>간단한 흐름도나 문서에 가까운 다이어그램은 Mermaid가 편하다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>```mermaid</span></div></div><div><div><div>2</div></div><div><span>flowchart LR</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>Draft[초안 작성] --&gt; Review[검토]</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>Review --&gt; Build[빌드]</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>Build --&gt; Publish[배포]</span></div></div><div><div><div>6</div></div><div><span>```</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>초안 작성</p></span></div><div><span><p>검토</p></span></div><div><span><p>빌드</p></span></div><div><span><p>배포</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>초안 작성</p></span></div><div><span><p>검토</p></span></div><div><span><p>빌드</p></span></div><div><span><p>배포</p></span></div>
</div></div></div><p>UML 표기나 복잡한 소프트웨어 구조를 표현할 때는 PlantUML을 사용할 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>```plantuml</span></div></div><div><div><div>2</div></div><div><span>@startuml</span></div></div><div><div><div>3</div></div><div><span>actor Writer</span></div></div><div><div><div>4</div></div><div><span>participant Firefly</span></div></div><div><div><div>5</div></div><div><span>participant Astro</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>Writer -&gt; Firefly: Markdown 작성</span></div></div><div><div><div>8</div></div><div><span>Firefly -&gt; Astro: 콘텐츠 빌드</span></div></div><div><div><div>9</div></div><div><span>Astro --&gt; Writer: 정적 사이트 생성</span></div></div><div><div><div>10</div></div><div><span>@enduml</span></div></div><div><div><div>11</div></div><div><span>```</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>다이어그램은 한 글에 너무 많이 넣으면 빌드 시간과 가독성이 모두 나빠질 수 있다. 본문을 보조하는 한두 개의 핵심 그림에 사용하는 편이 좋다.</p></section>
<section><h2>Wiki Link로 글 연결하기<a href="#wiki-link로-글-연결하기"><span>#</span></a></h2><p>Obsidian에서 글을 관리한다면 Wiki Link 문법을 그대로 사용할 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[</span><span>[</span><span>cognitive-surrender</span><span>]</span><span>]</span></div></div><div><div><div>2</div></div><div><span>[[cognitive-surrender|AI와 함께 코딩하며 느낀 점]]</span></div></div><div><div><div>3</div></div><div><span>[[cognitive-surrender#압도되는-것과-항복하는-것|관련 문단으로 이동]]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>파일 경로나 파일 이름을 기준으로 연결하며, 표시 제목과 제목 앵커도 지정할 수 있다. 현재 페이지 안의 제목으로 이동할 때는 <code>[[#제목|표시 문구]]</code> 형태를 사용한다.</p><p>이미지 첨부를 뜻하는 Obsidian의 <code>![[image.png]]</code> 문법은 지원 대상이 아니므로 일반 Markdown 이미지 문법을 사용한다.</p></section>
<section><h2>이미지와 동영상<a href="#이미지와-동영상"><span>#</span></a></h2><p>글과 함께 관리하는 이미지는 글 파일을 기준으로 상대 경로를 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>![</span><span>이미지 설명</span><span>]</span><span><span>(</span><span>./images/example.avif</span><span>)</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>public</code> 아래의 파일은 <code>/images/example.avif</code>처럼 루트 경로로 참조한다. 중요한 글자나 로고가 있는 표지는 목록과 그리드 양쪽에서 잘리지 않도록 중앙에 충분한 여백을 둔다.</p><p>YouTube처럼 임베드 코드를 제공하는 서비스는 HTML을 Markdown에 직접 넣을 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>iframe</span></div></div><div><div><div>2</div></div><div><span>  </span><span>width</span><span>=</span><span>"100%"</span></div></div><div><div><div>3</div></div><div><span>  </span><span>height</span><span>=</span><span>"468"</span></div></div><div><div><div>4</div></div><div><span>  </span><span>src</span><span>=</span><span>"https://www.youtube.com/embed/VIDEO_ID"</span></div></div><div><div><div>5</div></div><div><span>  </span><span>title</span><span>=</span><span>"YouTube video player"</span></div></div><div><div><div>6</div></div><div><span>  </span><span>allowfullscreen</span></div></div><div><div><div>7</div></div><div><span>&gt;&lt;/</span><span>iframe</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>외부 플레이어는 개인정보 보호, 자동 재생, 모바일 크기와 로딩 비용을 함께 확인해야 한다.</p></section>
<section><h2>비밀번호로 글 보호하기<a href="#비밀번호로-글-보호하기"><span>#</span></a></h2><p>Frontmatter에 <code>password</code>를 넣으면 Firefly가 빌드 과정에서 본문을 암호화한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>password</span><span>: </span><span>"긴 비밀번호"</span></div></div><div><div><div>2</div></div><div><span>passwordHint</span><span>: </span><span>"나만 알아볼 수 있는 힌트"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>방문자가 올바른 비밀번호를 입력하면 브라우저에서 내용을 복호화한다. 비밀번호 자체를 저장소에 커밋하는 구조이므로 진짜 비밀이나 민감한 자료를 보관하는 용도로 생각해서는 안 된다. 제한적으로 공유할 글을 가볍게 가리는 기능에 가깝다.</p></section>
<section><h2>레이아웃 설정<a href="#레이아웃-설정"><span>#</span></a></h2><p>사이트 전체 설정은 주로 <code>src/config</code>에서 관리한다.</p><ul>
<li><code>siteConfig.ts</code>: 사이트 정보, 글 목록, 내비게이션과 페이지 기능</li>
<li><code>sidebarConfig.ts</code>: 사이드바 위치와 위젯 구성</li>
<li><code>backgroundWallpaper.ts</code>: 배경 이미지와 전체 화면 효과</li>
<li><code>commentConfig.ts</code>: 댓글 제공자와 글별 댓글 동작</li>
<li><code>fontConfig.ts</code>: 글꼴과 로딩 방식</li>
</ul><p>사이드바는 왼쪽, 오른쪽 또는 양쪽에 둘 수 있다.</p><div><figure><figcaption><span>src/config/sidebarConfig.ts</span></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>sidebarLayoutConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>enable</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>position</span><span>:</span><span> </span><span>"left"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>noSidebarContentWidth</span><span>:</span><span> </span><span>0.6</span><span>,</span></div></div><div><div><div>5</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>표시할 위젯이 없는 사이드바 열은 접힌다. <code>noSidebarContentWidth</code>는 사이드바가 없는 페이지의 본문 너비 비율이며, 생략하거나 1 이상으로 설정하면 전체 너비를 사용한다.</p><p>글 목록은 목록형과 그리드형을 선택할 수 있다.</p><div><figure><figcaption><span>src/config/siteConfig.ts</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>postListLayout</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>defaultMode</span><span>: </span><span>"grid"</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>mobileDefaultMode</span><span>: </span><span>"list"</span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>coverPosition</span><span>: </span><span>"right"</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>grid</span><span>: {</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>masonry</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>columnWidth</span><span>: </span><span>320</span><span>,</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>9</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>설정을 바꿀 때는 타입 정의와 실제 설정 모듈을 함께 확인한다. 스크롤이나 전체 화면 배경 효과는 모바일 성능에 직접 영향을 주므로 시각적인 변화만 보고 값을 크게 올리지 않는다.</p></section>
<section><h2>발행 전 확인<a href="#발행-전-확인"><span>#</span></a></h2><p>글을 추가하거나 설정을 바꾼 뒤에는 다음 명령을 순서대로 실행한다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>check</span></div></div><div><div><div>2</div></div><div><span>pnpm</span><span> </span><span>type-check</span></div></div><div><div><div>3</div></div><div><span>pnpm</span><span> </span><span>build</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>pnpm dev</code>로 목록형과 그리드형 카드, 모바일 너비, 글 상세 페이지의 목차와 이미지 잘림도 확인한다. 글 표지나 생성 이미지가 바뀌었다면 <code>pnpm lqips</code>가 갱신한 <code>src/constants/lqips.json</code>도 함께 검토한다.</p></section>
<section><h2>정리<a href="#정리"><span>#</span></a></h2><p>이 글은 Firefly의 모든 기능을 복제한 문서가 아니다. 내 블로그에서 실제로 사용하는 작성 흐름과 잊기 쉬운 문법을 한곳에 모은 개인 메모다. 테마가 업데이트되면서 세부 옵션이 달라질 수 있으므로 다음 순서로 확인한다.</p><ol>
<li>현재 저장소의 <code>src/config</code>와 타입 정의</li>
<li><a href="https://docs-firefly.cuteleaf.cn" target="_blank">Firefly 공식 문서</a></li>
<li><a href="https://github.com/CuteLeaf/Firefly" target="_blank">Firefly GitHub 저장소</a></li>
<li>Astro 자체 동작은 <a href="https://docs.astro.build" target="_blank">Astro 공식 문서</a></li>
</ol><p>예제 글 여러 개가 아카이브를 채우는 대신, 앞으로는 이 글을 실제 사용 경험에 맞춰 조금씩 고쳐 나갈 생각이다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/github-trending-ip-as-logo-steady-spiral/</id>
      <title type="text">GitHub Trending에서 발견한 ip-as-logo-skill로 블로그를 리브랜딩한 과정</title>
      <published>2026-08-24T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/github-trending-ip-as-logo-steady-spiral/"/>
      <summary type="text">GitHub에서 빠르게 주목받은 ip-as-logo Agent Skill로 달팽이 로고 후보를 만들고, Steady Spiral의 favicon과 라이트·다크 모드 로고로 적용한 과정을 기록합니다.</summary>
      <content type="html"><![CDATA[<p>블로그 이름을 <strong>Steady Spiral</strong>로 바꿨다. 홈 화면의 문구는 <strong>Just Keep Pedaling</strong>로 남겼다.</p>
<p>둘은 같은 방향을 바라본다. 빠르지 않아도 계속 움직이는 것, 한 바퀴를 돌 때마다 조금씩 앞으로 나아가는 것이다. 새 이름을 정하고 나니 기존의 Firefly 로고 대신 이 의미를 담은 작은 상징이 필요해졌다.</p>
<p>그때 GitHub에서 <a href="https://github.com/s1dashu/ip-as-logo-skill" target="_blank"><code>s1dashu/ip-as-logo-skill</code></a>을 발견했다. 2026년 8월 18일 공개된 저장소인데, 8월 24일 확인했을 때 이미 3,869 stars와 186 forks를 기록하고 있었다. 단순히 로고 이미지를 모아 둔 프로젝트가 아니라 AI 에이전트가 단순한 IP 캐릭터를 생성하도록 제약을 제공하는 <strong>Agent Skill</strong>이었다.</p>
<p>마침 블로그 로고를 바꾸려던 시점이었다. 트렌딩 저장소를 구경하는 데서 끝내지 않고 실제 블로그에 적용해 보기로 했다.</p>
<section><h2>Agent Skill로 취향을 전달한다는 것<a href="#agent-skill로-취향을-전달한다는-것"><span>#</span></a></h2><p>이미지 모델에 “귀여운 달팽이 로고를 만들어 줘”라고 말하는 것만으로도 이미지는 나온다. 하지만 귀엽다는 말의 범위는 너무 넓고, 로고로 사용하기 어려울 만큼 디테일이 많아질 수도 있다.</p><p><code>ip-as-logo-skill</code>은 이 선택지를 의도적으로 좁힌다.</p><ul>
<li>32×32에서도 알아볼 수 있는 단순한 실루엣</li>
<li>4~7개의 크고 둥근 기본 형태</li>
<li>캐릭터 두 색과 배경 한 색</li>
<li>왼쪽 또는 오른쪽 아래에서 등장하는 구도</li>
<li>세 가지 방향을 먼저 정하고 여섯 개 후보를 독립 생성</li>
<li>텍스트, 워터마크, 장식과 지나친 3D 표현 제외</li>
</ul><p>코드를 실행하는 도구라기보다 이미지 모델에 전달할 <strong>디자인 판단의 묶음</strong>에 가깝다. 설치는 Agent Skills CLI를 사용했다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>skills@latest</span><span> </span><span>add</span><span> </span><span>s1dashu/ip-as-logo-skill</span><span> </span><span>--global</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>설치 후 에이전트에게 블로그의 이름과 문구, 현재 Firefly 테마, 달팽이라는 소재를 전달했다. 색상은 나중에 바꿀 수 있으므로 특정 팔레트에 고정하지 않았다.</p></section>
<section><h2>달팽이를 선택한 이유<a href="#달팽이를-선택한-이유"><span>#</span></a></h2><p>달팽이는 빠름과는 거리가 멀다. 그래서 오히려 <strong>Steady Spiral</strong>과 잘 맞았다.</p><p>껍질의 나선은 블로그 이름을 직접 보여 주고, 느리지만 멈추지 않는 움직임은 <code>Just Keep Pedaling</code>과 연결된다. 개발과 일상에서 배운 것을 꾸준히 남기려는 블로그의 태도도 설명할 수 있었다.</p><p>요구사항은 다음처럼 정리했다.</p><ul>
<li>소재는 달팽이로 고정한다.</li>
<li>껍질의 나선이 작은 크기에서도 보여야 한다.</li>
<li>복잡한 일러스트보다 둥글고 단순한 실루엣을 우선한다.</li>
<li>후보는 정사각형 이미지로 생성한다.</li>
<li>최종 결과는 32px favicon과 28px 네비게이션 로고로 사용한다.</li>
<li>라이트 모드와 다크 모드에서 모두 형태가 무너지지 않아야 한다.</li>
</ul></section>
<section><h2>세 가지 방향과 여섯 번의 독립 생성<a href="#세-가지-방향과-여섯-번의-독립-생성"><span>#</span></a></h2><p>스킬은 달팽이라는 같은 소재를 세 가지 방식으로 나눴다.</p>

<table><thead><tr><th>방향</th><th>연결한 의미</th><th>시각적 특징</th></tr></thead><tbody><tr><td>A · Steady Shell</td><td>느리더라도 쌓이는 꾸준함</td><td>크고 안정적인 껍질과 차분한 표정</td></tr><tr><td>B · Pedaling Spiral</td><td>계속 앞으로 가는 리듬</td><td>바퀴처럼 읽히는 둥근 껍질과 전진하는 자세</td></tr><tr><td>C · Verdant Firefly</td><td>기존 Firefly 테마와의 연결</td><td>밝은 몸체, 초록 껍질과 친근한 표정</td></tr></tbody></table><p>각 방향은 왼쪽 아래와 오른쪽 아래 구도로 한 장씩 생성했다. 하나의 이미지에 여섯 후보를 합치는 방식이 아니라, 모두 별도의 정사각형 이미지로 생성했다.</p><section><h3>A · Steady Shell<a href="#a--steady-shell"><span>#</span></a></h3><p></p><figure><img alt="A1 Steady Shell 왼쪽 구도" loading="lazy" width="960" height="960" src="/_astro/a1-steady-shell.C36wtVx1_Z2aNH3G.webp" /><figcaption>A1 Steady Shell 왼쪽 구도</figcaption></figure><p></p><p></p><figure><img alt="A2 Steady Shell 오른쪽 구도" loading="lazy" width="960" height="960" src="/_astro/a2-steady-shell.CREZb0jc_6yzL5.webp" /><figcaption>A2 Steady Shell 오른쪽 구도</figcaption></figure><p></p><p>짙은 초록 몸체와 아이보리 껍질을 사용했다. 커다란 껍질이 안정감을 주고, 두꺼운 더듬이와 작은 얼굴이 32px에서도 비교적 쉽게 남을 수 있는 방향이었다. 다만 황금색 배경까지 함께 보면 블로그보다는 독립적인 캐릭터 브랜드에 가까운 인상이 강했다.</p></section><section><h3>B · Pedaling Spiral<a href="#b--pedaling-spiral"><span>#</span></a></h3><p></p><figure><img alt="B1 Pedaling Spiral 왼쪽 구도" loading="lazy" width="960" height="960" src="/_astro/b1-pedaling-spiral.C1mwSK0c_gBeQX.webp" /><figcaption>B1 Pedaling Spiral 왼쪽 구도</figcaption></figure><p></p><p></p><figure><img alt="B2 Pedaling Spiral 오른쪽 구도" loading="lazy" width="960" height="960" src="/_astro/b2-pedaling-spiral.CO5sunCv_1XiQ64.webp" /><figcaption>B2 Pedaling Spiral 오른쪽 구도</figcaption></figure><p></p><p>낮고 길게 뻗은 몸체와 라임색 껍질로 움직임을 강조했다. 껍질을 바퀴처럼 읽을 수 있어 <code>Just Keep Pedaling</code>과 가장 직접적으로 연결됐다. 짙은 청록 배경과 밝은 껍질의 대비도 분명했다.</p></section><section><h3>C · Verdant Firefly<a href="#c--verdant-firefly"><span>#</span></a></h3><p></p><figure><img alt="C1 Verdant Firefly 왼쪽 구도" loading="lazy" width="960" height="960" src="/_astro/c1-verdant-firefly.5EP8qSF8_29PoPv.webp" /><figcaption>C1 Verdant Firefly 왼쪽 구도</figcaption></figure><p></p><p></p><figure><img alt="C2 Verdant Firefly 오른쪽 구도" loading="lazy" width="960" height="960" src="/_astro/c2-verdant-firefly._mwt2SUG_O5756.webp" /><figcaption>C2 Verdant Firefly 오른쪽 구도</figcaption></figure><p></p><p>기존 Firefly 테마와 가장 자연스럽게 이어진 방향이다. 밝은 몸체와 초록 껍질을 사용해 어두운 배경에서도 형태가 쉽게 분리됐다. 모서리에서 얼굴을 내미는 구도도 개인 블로그의 작은 마스코트로 쓰기에 부담이 적었다.</p><p>생성 결과는 모두 1254×1254 RGB PNG였다. 결과를 비교한 뒤에는 C 방향을 유지하면서 껍질의 나선, 얼굴과 더듬이가 favicon 크기에서도 남는 결과를 최종안으로 골랐다.</p></section></section>
<section><h2>C1 Verdant Firefly를 최종안으로<a href="#c1-verdant-firefly를-최종안으로"><span>#</span></a></h2><p></p><figure><img alt="최종 선택한 Steady Spiral 컬러 로고" loading="lazy" width="512" height="512" src="/_astro/steady-spiral-final.PwLG-j8F_JRNaC.webp" /><figcaption>최종 선택한 Steady Spiral 컬러 로고</figcaption></figure><p></p><p>최종 선택 기준은 큰 화면에서 얼마나 화려한지가 아니었다. 실제 사용 크기에서 무엇이 남는지를 봤다.</p><ul>
<li>검은 실루엣만으로도 달팽이임을 알아볼 수 있는가?</li>
<li>32px에서 껍질의 나선이 뭉개지지 않는가?</li>
<li>28px에서 얼굴이 점처럼 사라지지 않는가?</li>
<li>배경을 제거해도 외곽선이 자연스러운가?</li>
<li>단색으로 바꿔도 형태가 유지되는가?</li>
</ul><p>선택한 이미지는 <strong>C1 Verdant Firefly</strong>라고 이름 붙였다. 생성 이미지의 배경을 제거하고 투명 PNG로 정리한 다음 용도별 자산을 만들었다.</p>

<table><thead><tr><th>용도</th><th>처리</th></tr></thead><tbody><tr><td>컬러 로고</td><td>Verdant Firefly 색상 유지</td></tr><tr><td>라이트 모드 네비게이션</td><td>M1 Near Black 단색 버전</td></tr><tr><td>다크 모드 네비게이션</td><td>M2 Soft White 버전</td></tr><tr><td>favicon</td><td>32·128·180·192px PNG</td></tr></tbody></table></section>
<section><h2>Astro와 Firefly에 적용하기<a href="#astro와-firefly에-적용하기"><span>#</span></a></h2><p>Firefly의 사이트 설정에는 favicon 배열과 네비게이션 로고의 라이트·다크 경로를 지정할 수 있다. 최종 자산은 다음처럼 연결했다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>favicon</span><span>: [</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>{ </span><span>src</span><span>:</span><span> </span><span>"/favicon/steady-spiral-192.png"</span><span>, </span><span>sizes</span><span>:</span><span> </span><span>"192x192"</span><span> },</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>{ </span><span>src</span><span>:</span><span> </span><span>"/favicon/steady-spiral-180.png"</span><span>, </span><span>sizes</span><span>:</span><span> </span><span>"180x180"</span><span> },</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>{ </span><span>src</span><span>:</span><span> </span><span>"/favicon/steady-spiral-128.png"</span><span>, </span><span>sizes</span><span>:</span><span> </span><span>"128x128"</span><span> },</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{ </span><span>src</span><span>:</span><span> </span><span>"/favicon/steady-spiral-32.png"</span><span>, </span><span>sizes</span><span>:</span><span> </span><span>"32x32"</span><span> },</span></div></div><div><div><div>6</div></div><div><span>],</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span><span>navbar</span><span>: {</span></span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>logo</span><span>: {</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>type</span><span>: </span><span>"image"</span><span>,</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>value</span><span>: </span><span>"/assets/images/logo/steady-spiral-light.png"</span><span>,</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>valueDark</span><span>: </span><span>"/assets/images/logo/steady-spiral-dark.png"</span><span>,</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>alt</span><span>: </span><span>"Steady Spiral logo"</span><span>,</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>title</span><span>: </span><span>"Steady Spiral"</span><span>,</span></div></div><div><div><div>16</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>사이트 제목과 설명도 함께 변경했다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>title</span><span>: </span></span><span>"Steady Spiral"</span><span>,</span></div></div><div><div><div>2</div></div><div><span><span>description</span><span>:</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>"느리더라도 멈추지 않고, 개발과 일상의 배움을 꾸준히 기록하는 공간입니다."</span><span>,</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>홈 대문의 <code>Just Keep Pedaling</code>과 두 줄의 부제목은 그대로 남겼다.</p><blockquote><p>Life is like riding a bicycle.<br />
To keep your balance, you must keep moving.</p></blockquote></section>
<section><h2>라이트 모드와 다크 모드 확인<a href="#라이트-모드와-다크-모드-확인"><span>#</span></a></h2><p></p><figure><img alt="M1 Near Black을 적용한 라이트 모드 네비게이션" loading="lazy" width="781" height="52" src="/_astro/navbar-light.bt3yKIUl_Z2otwh7.webp" /><figcaption>M1 Near Black을 적용한 라이트 모드 네비게이션</figcaption></figure><p></p><p>라이트 모드에서는 M1 Near Black을 사용했다. 컬러 로고를 그대로 축소하는 것보다 텍스트 및 네비게이션 아이콘과 시각적 무게가 잘 맞았다.</p><p></p><figure><img alt="M2 Soft White를 적용한 다크 모드 네비게이션" loading="lazy" width="781" height="52" src="/_astro/navbar-dark.c--4NAvl_64BgL.webp" /><figcaption>M2 Soft White를 적용한 다크 모드 네비게이션</figcaption></figure><p></p><p>다크 모드에서는 검은 로고를 CSS로 단순 반전하지 않고 M2 Soft White 이미지를 별도로 사용했다. 몸체는 흰색에 가깝게, 껍질은 부드러운 회색으로 남겨 형태를 구분했다.</p><p>마지막으로 브라우저에서 28px 네비게이션 표시와 32px favicon을 확인하고 다음 검증을 실행했다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>check</span></div></div><div><div><div>2</div></div><div><span>pnpm</span><span> </span><span>type-check</span></div></div><div><div><div>3</div></div><div><span>pnpm</span><span> </span><span>build</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>생성보다 선택과 적용에 더 많은 판단이 필요했다<a href="#생성보다-선택과-적용에-더-많은-판단이-필요했다"><span>#</span></a></h2><p>Agent Skill을 사용하면 결과가 자동으로 브랜드가 되는 것은 아니다. 여섯 장을 만드는 일보다 어떤 방향이 블로그의 이름과 태도를 설명하는지 고르는 일이 더 중요했다. 선택한 뒤에도 배경 제거, 색상 변형, 작은 크기 검증, 테마별 자산 분리와 실제 코드 적용이 남아 있었다.</p><p>그럼에도 스킬은 출발점을 빠르게 만들어 줬다. 막연히 “귀여운 달팽이”를 요청하는 대신 크기, 형태, 색상 수와 구도를 제한했고, 비교할 수 있는 후보를 같은 규칙 안에서 얻었다. 프롬프트 한 번의 우연한 결과보다 반복 가능한 제작 과정에 가까웠다.</p><p>새 로고가 거창한 선언을 하지는 않는다. 작고 느린 달팽이 한 마리가 네비게이션 왼쪽에 있을 뿐이다. 그래도 블로그를 열 때마다 내가 정한 이름을 다시 보여 준다.</p><p>느리더라도 멈추지 말 것. 한 바퀴씩 계속 나아갈 것.</p><p><strong>Just Keep Pedaling.</strong></p></section>
<section><h2>참고<a href="#참고"><span>#</span></a></h2><ul>
<li><a href="https://github.com/s1dashu/ip-as-logo-skill" target="_blank">s1dashu/ip-as-logo-skill</a></li>
<li><a href="https://github.com/s1dashu/ip-as-logo-skill/blob/main/LICENSE" target="_blank">ip-as-logo-skill MIT License</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/cognitive-surrender/</id>
      <title type="text">AI가 짠 코드를 나는 정말 이해하고 있었을까</title>
      <published>2026-08-22T00:00:00.000Z</published>
      <updated>2026-08-22T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/cognitive-surrender/"/>
      <summary type="text">AI가 만드는 코드의 속도를 따라가지 못하면서 느낀 인지적 압도와 인지적 항복, 그리고 코드를 승인하는 사람의 책임에 대한 고민.</summary>
      <content type="html"><![CDATA[<p><code>인지적 압도(Cognitive Overload)</code>와 <code>인지적 항복(Cognitive Surrender)</code>이라는 말을 함께 알게 됐다.</p>
<p>두 단어의 정확한 정의를 설명해보라고 하면 아직 자신이 없다. 그런데 처음 접했을 때, 이 두 표현이 AI 에이전틱 코딩을 하며 느낀 불편함을 꽤 정확하게 짚는다고 생각했다.</p>
<p>나는 언리얼 엔진과 유니티에서 C++과 C#을 주로 다뤄온 게임 개발자다. 그런데 마지막으로 담당했던 프로젝트는 Rust와 Bevy, React와 TypeScript 기반이었다.</p>
<p>내게 익숙한 게임 개발과 맞닿은 부분도 있었지만, 낯선 기술 영역이 많았다. AI가 코드를 수정할 때마다 변경 내용을 이해하려면 시간이 필요했다. 특히 프론트엔드 변경은 수정량도 많았고, 내가 잘 모르는 도메인의 전문 코드도 한꺼번에 추가됐다.</p>
<p>처음에는 하나씩 이해해보려고 했다. 하지만 변경은 계속 쌓였다. 내가 하나를 이해하는 동안 AI는 그보다 훨씬 많은 코드를 만들어냈다.</p>
<p>어느 순간부터는 따라잡을 수 없다는 생각이 들었다.</p>
<p>그리고 그냥 승인했다.</p>
<p>이해할 수 없는 코드를 승인하는 일은 불안하고 불쾌했다. 무엇을 승인하는지 제대로 설명할 수 없는데도 코드를 관리할 책임은 여전히 내게 있다고 느꼈기 때문이다.</p>
<p>AI가 코드를 작성했다고 해서 책임까지 AI에게 넘어가지는 않는다. 결국 그 코드를 프로젝트에 받아들인 사람은 나였다.</p>
<section><h2>압도되는 것과 항복하는 것<a href="#압도되는-것과-항복하는-것"><span>#</span></a></h2><p>두 단어를 내 경험에 대입하면, 변경량과 낯선 코드 앞에서 따라잡을 수 없다고 느낀 상태는 ‘인지적 압도’였던 것 같다.</p><p>“이걸 언제 다 이해하지?”</p><p>그런 생각이 반복되자 이해하려는 시도 자체를 포기한 채 승인했다. 그게 내게는 ‘인지적 항복’에 가까웠다. 적어도 내게 압도는 상황이었고, 항복은 체념 끝에 취한 태도였다.</p><p>내 경험을 순서대로 놓아보면 이랬다.</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>쌓이는 AI 변경</p></span></div><div><span><p>따라잡기 어려움</p></span></div><div><span><p>인지적 압도</p></span></div><div><span><p>체념</p></span></div><div><span><p>이해하지 못한 채 승인</p></span></div><div><span><p>인지적 항복</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>쌓이는 AI 변경</p></span></div><div><span><p>따라잡기 어려움</p></span></div><div><span><p>인지적 압도</p></span></div><div><span><p>체념</p></span></div><div><span><p>이해하지 못한 채 승인</p></span></div><div><span><p>인지적 항복</p></span></div>
</div></div></div><p>물론 모든 코드를 이해해야 한다는 이야기는 아니다. 현대의 소프트웨어는 한 사람이 전부 이해하기 어려울 만큼 복잡하다. 나 역시 내부 구현을 전부 알지 못하는 라이브러리와 프레임워크를 사용한다. 다른 사람이 작성한 코드도 어느 정도는 신뢰해야 한다.</p><p>AI가 작성한 코드라도 테스트가 통과하고 원하는 대로 동작하면 충분하지 않느냐는 말에도 어느 정도 공감한다.</p><p>그래서 더 어려운 질문이 남는다.</p><p>우리는 코드를 어디까지 이해해야 하는가?</p><p>솔직히 아직 답을 찾지 못했다. 모든 줄을 설명해야 한다는 기준은 지나치다. 그렇다고 실행만 되면 충분하다고 생각하기도 어렵다.</p><p>지금 세운 잠정적인 기준은 이 정도다.</p><p>코드를 승인하기 전에 적어도 다음 세 가지는 내 말로 설명해야 하지 않을까.</p><ul>
<li>현재 어떤 상황이나 문제가 있는가?</li>
<li>코드는 무엇을 수정하는가?</li>
<li>그 수정은 어떤 영향을 주는가?</li>
</ul><p>이 정도도 설명하지 못한다면, 나는 그 코드를 정말 승인한 것일까? 아니면 그저 AI가 만든 변경에 통과 도장을 찍은 것일까?</p><p>다만 앞으로 AI가 만든 방대한 변경을 마주하면, 승인하기 전에 한 번은 스스로에게 물어보려고 한다.</p><blockquote><p>나는 현재 문제와 변경 내용, 그리고 그 영향을 설명할 수 있는가?</p></blockquote><p>다음에는 적어도 이 질문을 그냥 넘기지 않으려고 한다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/how-ai-saw-me-at-cinamon/</id>
      <title type="text">AI를 통해 바라본 시나몬에서의 나</title>
      <published>2026-08-21T00:00:00.000Z</published>
      <updated>2026-08-21T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/how-ai-saw-me-at-cinamon/"/>
      <summary type="text">시나몬을 떠나며 Slack, Linear와 개발 기록을 AI에게 읽혀 봤습니다. 내가 기억하던 모습과는 조금 다른 이야기가 나왔습니다.</summary>
      <content type="html"><![CDATA[<p>회사를 떠나게 됐다. 2024년 6월에 시나몬에 입사했으니 2년 조금 넘게 다녔다.</p>
<p>회고를 써 보려고 빈 문서를 열었는데, 제일 먼저 떠오른 건 프로젝트 이름과 기능 목록이었다. 뭘 만들었고 어떤 문제를 고쳤는지는 비교적 쉽게 기억났다. 그런데 그걸 쭉 적고 보니 이력서와 크게 다르지 않았다. 내가 그곳에서 어떻게 일했는지는 잘 보이지 않았다.</p>
<p>기억만 믿고 쓰면 아무래도 나한테 유리한 장면만 골라낼 것 같았다. 그래서 조금 다른 방법을 써 봤다. 입사한 2024년 6월부터 남아 있는 Slack 대화와 Linear 이슈, GitLab·GitHub 기록을 AI에게 보여 주고 이렇게 물었다.</p>
<blockquote><p>이 기록 속에서 나는 어떤 모습으로 보이는가?</p></blockquote>
<p>분석에 사용한 프롬프트는 아래와 같다.</p>

같은 분석을 재현하는 프롬프트 펼쳐보기
<div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>나는 한 회사에서의 나를 기억이나 자기평가가 아니라 업무 기록으로 돌아보고 싶다.</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>[배경]</span></div></div><div><div><div>4</div></div><div><span>- 분석 대상 기간: 2024년 6월 입사 시점부터 퇴사일까지</span></div></div><div><div><div>5</div></div><div><span>- 회사는 닉네임으로 소통했고, 나의 닉네임은 Van이다.</span></div></div><div><div><div>6</div></div><div><span>- 사용할 수 있는 자료: Slack 공개·비공개 업무 채널, Linear 이슈, GitLab/GitHub 커밋·MR·PR·리뷰 기록</span></div></div><div><div><div>7</div></div><div><span>- 목적은 성과평가나 직급 추정이 아니라, 내가 반복해서 맡은 역할과 일하는 방식, 그 방식의 강점과 그림자를 발견하는 것이다.</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>[자료 조사]</span></div></div><div><div><div>10</div></div><div><span>1. Slack은 특정 프로젝트나 칭찬 표현만 검색하지 말고 기간을 분기 또는 반기 단위로 나눠 표본을 고르게 수집한다.</span></div></div><div><div><div>11</div></div><div><span>2. Van이 작성한 메시지와 스레드뿐 아니라, 동료가 Van을 호출하거나 질문·리뷰·문제 해결을 요청한 스레드도 함께 본다.</span></div></div><div><div><div>12</div></div><div><span>3. 검색 결과만 읽지 말고 대표 스레드는 부모 메시지부터 마지막 답변까지 확인한다.</span></div></div><div><div><div>13</div></div><div><span>4. 개발, 기획, QA, 아트·애니메이션, TA, 인프라 등 서로 다른 직군과의 대화를 포함한다.</span></div></div><div><div><div>14</div></div><div><span>5. Linear에서는 할당된 이슈, 직접 만든 이슈, 다른 사람이 만들어 맡긴 이슈, 상태 변화와 프로젝트 이동을 본다.</span></div></div><div><div><div>15</div></div><div><span>6. 개발 기록은 GitLab에서 GitHub로 저장소가 이동한 시점을 고려하고, 작성한 변경과 동료 변경에 남긴 리뷰를 구분한다.</span></div></div><div><div><div>16</div></div><div><span>7. 메시지가 많은 시기나 기록이 잘 남는 종류의 일이 과대 대표될 수 있으므로, 조용히 처리된 일과 기록되지 않은 기여는 판단할 수 없다고 전제한다.</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>[분석 원칙]</span></div></div><div><div><div>19</div></div><div><span>1. 관찰한 사실과 해석을 분리한다. 모든 해석에는 서로 다른 시기나 프로젝트에서 반복된 근거를 붙인다.</span></div></div><div><div><div>20</div></div><div><span>2. 제품명과 기술 목록보다 반복되는 행동을 찾는다. 예: 모호한 문제를 구조화하는가, 시스템 사이의 계약을 확인하는가, 사람 사이의 언어를 번역하는가, 해결을 도구·문서·자동화로 남기는가.</span></div></div><div><div><div>21</div></div><div><span>3. 일이 Van에게 들어오는 방식도 본다. 질문, 장애, 리뷰, 조율 요청이 어떤 경계에서 반복되는지 확인한다.</span></div></div><div><div><div>22</div></div><div><span>4. 하나의 행동을 긍정적으로만 해석하지 않는다. 신뢰와 의존성, 책임감과 버스 팩터, 근본 해결과 과한 설계, 빠른 실행과 팀의 대기열처럼 같은 행동의 앞뒷면을 함께 제시한다.</span></div></div><div><div><div>23</div></div><div><span>5. 처음과 마지막 시기의 소통 방식을 비교해 변화가 있었는지 확인한다.</span></div></div><div><div><div>24</div></div><div><span>6. 숫자는 우열이나 생산성 점수가 아니라 작업을 나누고 기록하고 협업한 습관을 읽는 보조 근거로만 사용한다.</span></div></div><div><div><div>25</div></div><div><span>7. 공식 직급, 인사평가, 동료 대비 순위처럼 자료로 확인할 수 없는 내용은 추정하지 않는다.</span></div></div><div><div><div>26</div></div><div><span>8. 결론과 맞지 않는 반례를 먼저 찾고, 반례를 설명하지 못하면 결론을 수정한다.</span></div></div><div><div><div>27</div></div><div>
</div></div><div><div><div>28</div></div><div><span>[원하는 결과]</span></div></div><div><div><div>29</div></div><div><span>- 먼저 기록 속 Van을 한 문장으로 묘사한다.</span></div></div><div><div><div>30</div></div><div><span>- 시기별로 역할과 기술이 어떻게 이동했는지 서술하되, 이력서처럼 프로젝트를 나열하지 않는다.</span></div></div><div><div><div>31</div></div><div><span>- 각 시기에는 구체적인 장면과 그 장면에서 반복된 행동을 함께 쓴다.</span></div></div><div><div><div>32</div></div><div><span>- AI가 바라본 모습, 내가 기억하는 나와의 차이, 그 차이에서 얻은 깨달음을 하나의 회고 서사로 연결한다.</span></div></div><div><div><div>33</div></div><div><span>- 잘한 일을 자랑하는 글이나 분석 보고서가 아니라, 강점과 그림자가 같은 뿌리에서 나왔음을 보여 주는 개인적인 글로 쓴다.</span></div></div><div><div><div>34</div></div><div><span>- 마지막에는 다음 조직에서 무엇을 다르게 남기고 싶은지 한 문장으로 여운을 남긴다.</span></div></div><div><div><div>35</div></div><div><span>- 공개 글에는 Slack 링크, 동료 실명, 비공개 이슈의 식별자와 민감한 세부 내용을 싣지 않는다.</span></div></div><div><div><div>36</div></div><div><span>- 중요한 판단마다 근거가 된 기록의 종류와 시기를 내부 메모로 남겨, 나중에 사실관계를 다시 확인할 수 있게 한다.</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div>

<p>이 프롬프트를 별도 AI 세션에 넣고, Slack과 Linear, 개발 기록을 다시 훑게 했다.</p>

AI의 분석 결과 펼쳐보기
<blockquote><p><strong>기록 속 Van은 정해진 기능의 소유자라기보다, 사람·데이터·도구가 맞닿는 경계에서 모호한 문제를 실행 단위와 계약으로 바꾸고 실제 사용까지 이어지게 만드는 엔지니어였다.</strong></p></blockquote>
<p>입사 초기의 기록에는 빌드와 패키징 문제가 자주 등장한다. 눈에 띄는 것은 빌드 자체보다 그 결과가 필요한 사람에게 도착하는 과정이다. Van은 최신 빌드를 매번 개발자에게 요청하지 않아도 되도록 다운로드 도구와 안내 문서를 만들었다. 첫 안내가 모든 환경을 포괄하지 못했을 때에는 직접 문제를 재현하고 예외를 고쳤다.</p>
<p>기록에서 확인되는 사실은 도구와 문서를 만들고 남은 예외까지 따라갔다는 점이다. 기능을 만드는 것만으로 전달이 끝났다고 보지 않은 성향도 읽힌다. 다만 이 장면은 해결을 완전히 셀프서비스로 바꾼 성공담만은 아니다. 도구를 만든 뒤에도 한동안 Van의 직접 지원이 필요했다. 개인의 친절을 팀의 경로로 옮기려는 시도와 그 경로가 아직 한 사람에게 의존했던 상태가 함께 있었다.</p>
<p>캐릭터 행동과 프랍 제작 영역으로 들어간 뒤에도 비슷한 질문이 반복됐다. 개별 에셋의 위치를 하나씩 고치기보다 여러 에셋이 공유하는 기준을 확인했고, 제작 규칙이 불분명할 때에는 변경 범위를 넓히기 전에 아트·애니메이션·TA 담당자와 규칙부터 다시 맞췄다. 처음 세운 가설을 새 자료가 나오면 수정했고, 기술적으로 고칠 수 있는 부분과 시각적 판단이 필요한 부분도 구분하려 했다.</p>
<p>이 시기의 Van을 단순히 “근본 원인을 잘 찾는 사람”이라고만 부르기는 어렵다. 더 정확히는 한 직군 안에서 닫히지 않는 문제를 발견하면, 각자의 언어로 흩어진 조건을 하나의 실행 가능한 문제로 바꾸려는 사람이었다. 반면 공통 원인을 찾으려는 성향은 아직 필요한 범위보다 큰 모델이나 장치를 먼저 생각하게 만들기도 했다.</p>
<p>2025년에는 경계가 코드와 에셋 사이에서 사람과 개념 사이로 넓어졌다. 같은 단어가 엔진, 기존 제품, 새 제품에서 서로 다른 뜻으로 쓰이던 논의에서 Van은 개념을 비교하고 기획·UI·개발이 함께 결정할 자리를 만들었다. 한편 자동화를 시도하다 제작 규칙 자체가 합의되지 않았음을 발견했을 때에는 구현을 밀어붙이지 않고 작업을 미뤘다. 초기 가설이 동료의 검증과 맞지 않으면 원인 설명도 바꿨다.</p>
<p>이 장면들은 Van이 언제나 정답을 알고 있었다는 증거가 아니다. 오히려 사실과 추정을 나누고, 틀릴 수 있는 형태로 의견을 내놓았다는 흔적에 가깝다. 팀의 기술적 맥락을 이어 갈 사람 가운데 한 명으로 지목된 기록도 있었지만, 그것이 공식적인 지위나 평가를 뜻하지는 않는다. 확인되는 변화는 직접 구현하는 일에 더해, 다른 사람이 같은 문제를 다룰 수 있도록 개념과 작업 조건을 정리하는 일이 늘어났다는 정도다.</p>
<p>그림자도 이 시기의 기록에서 선명하다. 생각하는 과정을 짧은 Slack 댓글 여러 개로 공유한 스레드는 함께 문제를 풀 때에는 빨랐지만, 뒤늦게 참여한 사람이 결론을 다시 조립해야 했다. 회의에서 해결된 것으로 보이지만 결정이 글로 남지 않은 대화도 있었다. 맥락을 연결하는 사람이었지만, 연결한 맥락을 언제나 오래 남는 형태로 바꾼 것은 아니었다.</p>
<p>2026년에는 주된 기술이 Unreal과 C++에서 Rust, WebAssembly, WebGPU, React와 데스크톱·웹 런타임의 조합으로 크게 달라졌다. 그런데 기록에서 반복되는 질문은 이전과 비슷했다. 데이터의 식별자는 누가 소유하는가, 만드는 쪽과 읽는 쪽이 같은 의미를 사용하고 있는가, 실패했을 때 어느 계층이 상태를 복구하는가, 로컬에서 성공한 결과가 실제 서비스와 제품까지 도착했는가를 확인했다.</p>
<p>리뷰에서도 코드 스타일보다 의미의 불일치, 상태 적용 순서, 손상된 저장 데이터의 복구, 테스트 전용 통로가 제품에 노출되는 문제, 문서와 구현의 차이를 오래 살폈다. 진행률이나 성공 로그만으로 완료를 선언하지 않고, 공유 환경에서 실제 결과가 만들어지고 다시 소비되는 증거를 따로 요구한 장면도 반복됐다. 기술 스택은 달라졌지만 Van이 서 있던 자리는 여전히 시스템과 시스템 사이였다.</p>
<p>후기의 메시지는 초기보다 확인된 사실, 남은 가정, 담당 경계와 완료 조건을 한 번에 정리하는 경우가 많았다. 다만 그중 상당수는 AI 도구의 도움을 받아 작성됐다. 문장의 정돈 자체를 Van의 능력으로 평가하기보다는, 무엇을 확인하고 어떤 위험과 증거를 남길지 결정한 방식에서 변화를 읽는 편이 타당하다.</p>
<p>Linear에는 Van이 스스로 작업을 나누어 기록한 흔적이 많았고, 동료가 만든 이슈도 여러 프로젝트와 직군에서 지속적으로 들어왔다. Slack과 개발 기록에도 질문, 장애 대응과 리뷰 요청이 반복된다. 이는 모호한 문제를 맡길 수 있다는 신뢰와 양립하는 모습이다. 그러나 기록만으로 신뢰를 증명할 수는 없다. 담당 범위가 넓었거나, 주인이 불분명한 일이 Van에게 흘러들었을 가능성도 있다.</p>
<p>중요한 것은 어느 해석을 택하더라도 같은 위험이 남는다는 점이다. 문제를 끝까지 따라가는 사람에게 일이 계속 모이면 책임감은 버스 팩터가 된다. 질문에 곧바로 답하는 사람은 어느 순간 사람으로 된 API가 된다. 작은 단위로 빠르게 변경을 쌓는 습관도 팀이 이해하고 검토하는 속도를 넘어서면 개인의 생산성이 조직의 대기열로 바뀐다.</p>
<p>그래서 기록 속 Van에게 가장 가까운 이름은 특정 기능의 전문가보다 <strong>경계와 통합을 맡는 glue engineer</strong>다. 이것은 직급이나 공식 평가가 아니라, 서로 다른 시기와 제품에서 반복된 행동을 설명하기 위한 이름이다.</p>
<p>Van 자신은 아마 만들었던 기능들을 기억했을 것이다. 기록은 그보다 기능이 다음 사람과 시스템으로 넘어가는 순간을 더 많이 보여 줬다. 기술이 달라져도 반복된 것은 “무엇을 만들었는가”보다 “누가 이 데이터를 소유하는가”, “다음 단계까지 실제로 도착했는가”, “이 해결이 나 없이도 다시 사용될 수 있는가”라는 질문이었다.</p>
<p>그 차이를 보며 강점과 약점을 따로 떼어 고칠 수 없다는 사실을 깨달았다. 끝까지 책임지는 태도를 버리는 대신 책임이 한 사람에게 남지 않게 해야 한다. 공통 원인을 찾되 지금 필요한 범위를 넘지 않아야 한다. 직접 해결한 문제의 수보다 다음 사람이 스스로 닫을 수 있게 만든 문제를 더 중요하게 볼 필요가 있다.</p>
<blockquote><p>다음 기록에는 “Van이 답했다”보다 “Van이 없어도 같은 질문이 되풀이되지 않았다”가 더 많이 남기를 바란다.</p></blockquote>

<p>결과는 생각보다 길고 꽤 진지했다. AI는 기록 속의 나를 기능과 직군, 시스템 사이의 빈칸을 연결하는 사람이라고 했다. 거기에 붙인 이름은 <code>glue engineer</code>였다.</p>
<p>처음에는 조금 낯간지러웠다. 그리고 바로 든 생각은 이거였다.</p>
<blockquote><p>내가 정말 이런 사람이었나?</p></blockquote>
<p>나는 내가 만든 기능을 기억하고 있었는데, AI는 그 기능들 사이를 돌아다닌 흔적을 보고 있었다.</p>
<section><h2>나는 기능 이름부터 떠올렸는데<a href="#나는-기능-이름부터-떠올렸는데"><span>#</span></a></h2><p>입사 초기에 GitLab 빌드 아티팩트를 만들고 내려받는 흐름을 정리한 적이 있다. 기획자가 최신 빌드를 확인할 때마다 개발자에게 부탁하지 않아도 되도록 다운로드 도구와 README도 만들었다.</p><p>나는 그냥 ‘빌드 관련 작업’으로 기억하고 있었다. 그런데 AI는 이걸 빌드 결과와 그 결과가 필요한 사람 사이에 길을 만든 일로 봤다. 이렇게 말하고 나니 거창해 보이지만, 사실 당시에는 매번 파일을 전달하는 게 번거로워서 시작한 일이었다.</p><p>캐릭터와 프랍을 다룰 때도 비슷한 일이 있었다. 프랍마다 오프셋을 하나씩 고치는 대신 여러 캐릭터가 같이 쓰는 기준 소켓부터 확인했다. 아트 에셋의 축과 구조가 런타임에서 어떻게 쓰이는지도 아트·애니메이션 쪽과 계속 맞춰 봤다. 애니메이션 도구에서 잡은 위치 정보가 제작 과정 중간에 사라지는 문제를 발견했을 때는 그 정보를 Unreal까지 가져가는 방법을 고민했다.</p><p>그때는 전부 별개의 문제라고 생각했다. 지금 한 줄로 이어 놓고 보니 공통점이 보였다. 한 사람의 작업이 다음 사람에게 넘어갈 때 정보가 빠지거나, 서로 당연하다고 생각한 기준이 어긋나는 지점을 자주 들여다봤다.</p><p>2025년에는 <code>Shot</code>이라는 단어를 두고 한동안 이야기를 나눴다. Unreal의 Shot, 기존 제품의 StoryShot, 새 제품에서 말하는 Shot이 모두 조금씩 다른데 같은 이름으로 불리고 있었다. 차이를 문서로 적고 기획, UI, 개발 담당자가 같이 이야기할 자리를 만들었다. 내 기억에는 ‘회의를 잡고 문서를 만든 일’이었는데, AI가 보기에는 서로 다른 직군이 쓰는 말을 맞추는 일이었다.</p><p>2026년에는 주로 쓰는 기술이 Unreal과 C++에서 Rust와 웹 기술로 크게 바뀌었다. 처음에는 완전히 다른 일을 한다고 생각했다. 그래도 기록에서 내가 오래 붙잡고 있던 건 비슷했다. JSON 버전과 호환성, 깨진 저장 데이터 복구, 데이터를 만드는 쪽과 쓰는 쪽의 스키마 차이 같은 것들이다. 외부 서비스에서는 성공했다고 하는데 그 결과가 실제 제품까지 오지 않는 문제도 끝까지 확인하곤 했다.</p><p>이 대목은 낯설었지만 꽤 그럴듯했다. 나는 한 기술을 오래 파는 식으로 일관성을 쌓은 사람은 아니었다. 대신 도구가 바뀌어도 이상하게 비슷한 빈칸을 찾아다녔다.</p></section>
<section><h2>칭찬인 줄 알았는데 꼭 그렇지만은 않았다<a href="#칭찬인-줄-알았는데-꼭-그렇지만은-않았다"><span>#</span></a></h2><p>AI는 여러 직군과 저장소에서 질문이나 리뷰 요청이 들어온 걸 ‘신뢰의 흔적’이라고 해석했다. 솔직히 처음 읽었을 때는 기분이 좋았다. 누군가 막혔을 때 떠올리는 사람, 애매한 문제도 일단 맡길 수 있는 사람이었다는 뜻 같았다.</p><p>그런데 바로 다음 문장에서 같은 기록을 ‘의존성’이라고 불렀다. 거기서 잠깐 멈췄다. 이것도 틀린 말은 아니었다.</p><p>누군가 막혔을 때 내가 바로 들어가서 고치면 당장은 빨리 끝난다. 나도 해결했다는 기분을 얻는다. 하지만 다음번에도 비슷한 문제가 생기면 또 나를 찾게 된다. 질문에 빠르게 답하는 동안 어느 순간 ‘사람으로 된 API’가 되어 있었을지도 모른다.</p><p>초기 Slack 기록을 보면 생각나는 대로 짧은 댓글을 여러 개 남긴 경우도 많았다. 같이 실시간으로 문제를 풀 때는 빨랐겠지만, 나중에 스레드에 들어온 사람은 조각난 맥락을 다시 맞춰야 했을 것이다.</p><p>시간이 지나면서 결정해야 할 것, 남은 의존성, 완료 조건을 한 번에 적는 메시지가 조금씩 늘었다. 완전히 달라졌다고 하기는 어렵다. 그래도 문제를 푸는 것과, 내가 푼 문제를 다른 사람에게 넘기는 건 별개의 일이라는 걸 배워 가고 있었다.</p></section>
<section><h2>잘하던 방식이 늘 좋은 건 아니었다<a href="#잘하던-방식이-늘-좋은-건-아니었다"><span>#</span></a></h2><p><code>glue engineer</code>라는 이름보다 더 오래 남은 건, 장점과 단점이 사실 같은 행동의 앞뒷면이라는 이야기였다.</p><p>문제를 끝까지 따라가는 건 내 장점이라고 생각했다. 하지만 그 끝에 늘 내가 있으면 팀은 나에게 의존하게 된다. 근본 원인을 찾는 습관도 좋지만, 가끔은 작은 문제에 너무 큰 해법을 떠올리게 했다. 이슈와 PR을 빠르게 쌓는 동안에는 동료가 그만큼 읽고 리뷰해야 한다는 사실도 놓치기 쉬웠다. 내가 빨리 가는 것과 팀이 빨리 가는 건 같은 일이 아니었다.</p><p>예전에는 이런 걸 그냥 ‘고쳐야 할 단점 목록’으로 생각했다. 기록을 모아 보니 그렇게 간단하지 않았다. 끝까지 따라가는 성향 자체를 버릴 수는 없고, 그럴 필요도 없다. 대신 이번 문제를 내가 바로 닫을지, 아니면 다음부터는 다른 사람도 닫을 수 있게 길을 만들지 조금 더 자주 생각해야 한다.</p><p>공통 원인을 찾더라도 지금 필요한 만큼만 만들고, 손이 빨라질수록 오히려 무엇을 만들지 않을지는 천천히 정하는 것. 말로 적고 보니 당연한데, 일할 때는 자주 잊었다.</p><p>물론 AI가 나를 제대로 이해했다고 생각하지는 않는다. 메시지를 많이 남긴 일은 크게 보였을 테고, 말없이 지나간 일이나 기록에 남지 않은 동료의 도움은 거의 보지 못했을 것이다.</p><p>그래도 재미있는 점은 있었다. 내가 남긴 기록을 내가 해 본 적 없는 순서로 다시 늘어놓아 줬다. 덕분에 잘했다고 생각한 일과 다음에는 다르게 해 보고 싶은 일이 사실 같은 곳에서 시작됐다는 걸 볼 수 있었다.</p></section>
<section><h2>그래서 다음에는<a href="#그래서-다음에는"><span>#</span></a></h2><p>시나몬을 떠나며 굳이 <code>glue engineer</code>라는 이름까지 챙겨 가고 싶지는 않다. 대신 애매한 문제를 하나씩 풀어 보고, 사람과 시스템 사이에서 자꾸 생기는 빈틈을 메워 본 경험은 가져가고 싶다.</p><p>다음에는 내가 연결한 것들이 나 없이도 조금 더 오래 굴러가게 만들고 싶다. 내가 몇 개의 문제를 직접 해결했는지보다, 다음번에는 팀이 나를 찾지 않고도 해결할 수 있는지를 더 중요하게 보고 싶다.</p><blockquote><p>Van은 닉네임으로 소통하던 시나몬에서 내가 사용한 이름이었다.</p></blockquote><p>언젠가 다음 회사의 기록도 다시 AI에게 읽혀 볼 날이 올지 모르겠다. 그때는 “모든 질문이 Van에게 모였다”보다는 “Van이 지나간 뒤에는 같은 질문이 다시 돌아오지 않았다”는 이야기가 나오면 좋겠다.</p><p>지금은 그 정도면 꽤 괜찮은 다음 목표라고 생각한다.</p><blockquote><p>이 글은 2024년 6월 입사부터 퇴사까지의 Slack 업무 대화, Linear 이슈, GitLab과 GitHub의 개발 기록을 바탕으로 작성했다.</p></blockquote></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/from-cinev-studio-to-shotloom/</id>
      <title type="text">CineV Studio에서 Shotloom으로 전환한 이유</title>
      <published>2026-08-20T00:00:00.000Z</published>
      <updated>2026-08-20T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/from-cinev-studio-to-shotloom/"/>
      <summary type="text">Unreal Engine 기반 시네마틱 편집기를 웹 우선 샷 제어 시스템으로 재정의하고 Rust, Bevy, WASM과 WebGPU를 선택한 배경을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>CineV Studio는 Unreal Engine 기반의 시네마틱 편집 도구였다. 높은 수준의 그래픽스와 애니메이션 기능을 활용해 캐릭터, 카메라와 타임라인을 직접 편집할 수 있었지만, 제품이 웹·생성형 AI 중심의 제작 환경으로 확장되면서 기존 구조의 운영 비용과 확장 한계가 커졌다.</p>
<p>Shotloom은 CineV Studio의 핵심 가치였던 <strong>제어 가능한 시네마틱 제작</strong>을 계승하면서 이를 브라우저 기반의 샷 편집 시스템으로 재구성한 프로젝트다. 전환의 목적은 Unreal Engine의 기능을 다른 엔진으로 그대로 옮기는 것이 아니라, 제품이 해결할 문제와 각 계층의 책임을 다시 정의하는 것이었다.</p>

<table><thead><tr><th>항목</th><th>CineV Studio</th><th>Shotloom</th></tr></thead><tbody><tr><td>제품 역할</td><td>네이티브 3D 시네마틱 편집기</td><td>생성 파이프라인을 위한 샷 제어 레이어</td></tr><tr><td>기본 실행 환경</td><td>Unreal Engine·Pixel Streaming</td><td>Browser·WASM·WebGPU</td></tr><tr><td>편집 UI</td><td>엔진 내부 UI</td><td>React·TypeScript</td></tr><tr><td>씬 런타임</td><td>Unreal Engine</td><td>Rust·Bevy</td></tr><tr><td>콘텐츠 모델</td><td>사전 제작 에셋 중심</td><td>개방형 포맷·생성·점진적 개선</td></tr><tr><td>고부하 작업</td><td>편집 세션과 같은 엔진 경로</td><td>Native Service·CLI로 분리</td></tr></tbody></table>
<section><h2>이 글에서 다루는 나의 역할<a href="#이-글에서-다루는-나의-역할"><span>#</span></a></h2><p>나는 CineV Studio에서 Action·Prop 데이터 규칙과 Sequencer·MovieScene 기반 편집·재생, 생성 모션 연동과 headless 자동화를 담당했다. 이후 Shotloom 초기 개발에서는 Rust 코어, Bevy 런타임, React·Tauri 편집기 사이의 명령·상태 경계와 외부 AI 서비스 통합을 구현했다.</p><p>이 글은 완성된 두 기술 스택을 사후 비교한 기록이 아니다. 두 경로를 직접 개발하면서 어떤 책임이 제품의 핵심이고, 어떤 책임이 엔진과 인프라에 불필요하게 묶여 있었는지를 정리한 기술적 회고다.</p></section>
<section><h2>기존 구조에서 확인한 문제<a href="#기존-구조에서-확인한-문제"><span>#</span></a></h2><section><h3>콘텐츠 확장 비용<a href="#콘텐츠-확장-비용"><span>#</span></a></h3><p>CineV Studio에서는 스테이지, 캐릭터 리그, 소품과 모션을 높은 완성도로 제작한 뒤 엔진에 통합해야 했다. 콘텐츠 라이브러리를 확장할 때마다 아티스트와 엔진 개발자의 선행 작업이 필요했고, 사용 가능한 콘텐츠의 범위가 제작 인력과 일정에 직접 종속됐다.</p><p>생성형 AI 기반 제작에서는 상세 에셋을 모두 준비한 뒤 작업을 시작하는 방식보다, 레이아웃과 샷 구조를 먼저 만들고 필요한 콘텐츠를 이후에 생성·보강하는 방식이 더 적합했다.</p></section><section><h3>Pixel Streaming 운영 부담<a href="#pixel-streaming-운영-부담"><span>#</span></a></h3><p>웹 사용자가 CineV Studio를 이용하려면 원격 Windows GPU 인스턴스에서 Unreal Engine을 실행하고 화면을 스트리밍해야 했다. 이에 따라 편집 기능과 직접 관련이 없는 다음 요소가 운영 범위에 포함됐다.</p><ul>
<li>GPU 인스턴스와 동시 사용자 수 관리</li>
<li>스트리밍 세션 생성, 종료와 재접속</li>
<li>네트워크 지연과 화면 품질 대응</li>
<li>네이티브 워커의 배포와 장애 복구</li>
<li>클라우드별 인프라와 비용 관리</li>
</ul><p>사용자의 편집 세션이 서버 GPU 자원과 일대일에 가깝게 연결되는 구조는 제품 확장성과 비용 효율 측면에서 제약이 됐다.</p></section><section><h3>웹 기반 생성 서비스와의 결합<a href="#웹-기반-생성-서비스와의-결합"><span>#</span></a></h3><p>CineV의 제작 흐름은 Story-to-Movie(S2M), 모션·음성 생성, 이미지·비디오 생성과 후처리 서비스로 확장됐다. 이들 서비스는 HTTP API, 비동기 작업, 파일 전송과 상태 관리가 중심이다.</p><p>네이티브 엔진 애플리케이션 내부에서 이러한 책임까지 소유하면 편집 UI, 씬 런타임과 서비스 오케스트레이션의 경계가 불명확해진다. 서비스 통합과 사용자 흐름은 웹 애플리케이션이 소유하고, 3D 런타임은 장면 평가와 렌더링에 집중할 필요가 있었다.</p></section><section><h3>전환 판단을 만든 경험<a href="#전환-판단을-만든-경험"><span>#</span></a></h3><p>CineV Studio에서도 headless Commandlet과 Remote Control API를 이용해 S2M 입력으로 프로젝트를 만들고 샷을 편집하는 경로를 자동화할 수 있었다. 비동기 모션 생성 결과를 받아 자체 스켈레톤에 적용하는 흐름도 구축했다.</p><p>개별 기능은 구현할 수 있었지만 제작 파이프라인이 확장될 때마다 네이티브 워커, 패키징 설정, 프로젝트 파일과 실행 중인 엔진 상태까지 함께 관리해야 했다. 문제는 Unreal Engine으로 구현할 수 있느냐가 아니라, 새로운 제작 흐름을 추가할 때마다 사용자의 편집 세션이 네이티브 엔진의 실행 문맥을 계속 부담해야 하느냐였다.</p><p>이 경험을 겪고 기술 평가 질문을 바꿨다. 최종 렌더링 품질이 필요한 작업과 샷을 구성·수정하는 작업을 같은 실행 환경에서 제공할 필요가 있는가? 기본 편집과 프리뷰는 브라우저가 담당하고, 네이티브 실행 환경은 실제로 고부하 렌더링과 자동화가 필요할 때만 사용하는 편이 제품 구조에 더 적합했다.</p></section><section><h3>생성 결과의 제어 가능성<a href="#생성-결과의-제어-가능성"><span>#</span></a></h3><p>생성 모델은 높은 품질의 결과를 만들 수 있지만 다음 요구를 안정적으로 해결하지는 못한다.</p><ul>
<li>특정 카메라 구도와 공간 관계 유지</li>
<li>프레임 단위의 포즈와 타이밍 조정</li>
<li>장면 상태를 유지한 부분 수정</li>
<li>동일한 입력에 대한 반복 가능한 프리뷰</li>
</ul><p>제품의 경쟁력은 자체 최종 렌더러보다 생성 전후에 사용자가 의도를 구조화하고 수정할 수 있는 편집 계층에 있다고 판단했다.</p></section></section>
<section><h2>기술 선택보다 먼저 정한 기준<a href="#기술-선택보다-먼저-정한-기준"><span>#</span></a></h2>

<table><thead><tr><th>판단 기준</th><th>우선한 가치</th></tr></thead><tbody><tr><td>사용자 가치</td><td>최종 렌더링 충실도보다 반복 가능한 제어와 부분 수정</td></tr><tr><td>실행 환경</td><td>별도 설치와 상시 서버 GPU 세션 없이 접근 가능한 편집 흐름</td></tr><tr><td>상태 일관성</td><td>Web·Native·CLI가 동일하게 해석하는 버전형 프로젝트 모델</td></tr><tr><td>확장 비용</td><td>생성 서비스가 추가돼도 편집 UI와 3D 런타임의 책임이 섞이지 않는 구조</td></tr></tbody></table><p>Rust, Bevy, WASM과 React는 이 기준을 만족시키기 위한 결과였다. 기술을 먼저 정하고 제품을 맞춘 것이 아니라 제품의 제약을 정한 뒤 각 책임에 필요한 기술을 배치했다.</p></section>
<section><h2>Shotloom의 제품 범위<a href="#shotloom의-제품-범위"><span>#</span></a></h2><p>Shotloom의 역할을 범용 DCC나 Unreal Engine 대체재가 아닌 <strong>샷 단위의 제어 및 수정 시스템</strong>으로 제한했다.</p><p>핵심 책임은 이렇다.</p><ul>
<li>캐릭터와 카메라 배치</li>
<li>포즈, 모션과 대사 타이밍 구성</li>
<li>프레임 기반 타임라인 평가</li>
<li>결정론적 재생과 스크러빙</li>
<li>편집 가능한 씬 상태의 저장과 복구</li>
<li>생성·렌더 파이프라인에 전달할 구조화된 번들 생성</li>
</ul><p>반대로 범용 메시·리깅 저작, Unreal Engine 수준의 최종 렌더링, 구형 브라우저를 포함한 범용 호환성은 기본 범위에서 제외했다. CineV Studio 프로젝트 파일을 직접 호환하는 대신 필요한 에셋을 glTF와 VRM 등 개방형 포맷으로 가져오는 경로를 선택했다.</p><p>범위를 이렇게 정하니 렌더링 기능의 폭보다 카메라, 타임라인, 저장과 서비스 연동의 신뢰성에 우선순위를 둘 수 있었다.</p></section>
<section><h2>아키텍처와 책임 경계<a href="#아키텍처와-책임-경계"><span>#</span></a></h2><div><div><div><div><span><p>브라우저 편집 경로</p></span></div><div><span><p>사용자 명령 · 확정 이벤트</p></span></div><div><span></span></div><div><span><p>동일한 제품 모델</p></span></div><div><span><p>동일한 제품 모델</p></span></div><div><span><p>비동기 작업 · 결과</p></span></div><div><span><p>React + TypeScript<br /><br />편집 UI · 프로젝트 흐름</p></span></div><div><span><p>Versioned Command / Event Bridge</p></span></div><div><span><p>Rust + Bevy WASM Runtime<br /><br />씬 · 카메라 · 재생 · 뷰포트</p></span></div><div><span><p>공유 Rust Core<br /><br />도메인 모델 · 타임라인 · 번들 · 검증</p></span></div><div><span><p>Native Service / CLI<br /><br />고부하 렌더 · 검증 · 자동화</p></span></div><div><span><p>외부 생성 서비스<br /><br />모션 · 음성 · 이미지 · 비디오</p></span></div>
</div><div><div><span><p>브라우저 편집 경로</p></span></div><div><span><p>사용자 명령 · 확정 이벤트</p></span></div><div><span></span></div><div><span><p>동일한 제품 모델</p></span></div><div><span><p>동일한 제품 모델</p></span></div><div><span><p>비동기 작업 · 결과</p></span></div><div><span><p>React + TypeScript<br /><br />편집 UI · 프로젝트 흐름</p></span></div><div><span><p>Versioned Command / Event Bridge</p></span></div><div><span><p>Rust + Bevy WASM Runtime<br /><br />씬 · 카메라 · 재생 · 뷰포트</p></span></div><div><span><p>공유 Rust Core<br /><br />도메인 모델 · 타임라인 · 번들 · 검증</p></span></div><div><span><p>Native Service / CLI<br /><br />고부하 렌더 · 검증 · 자동화</p></span></div><div><span><p>외부 생성 서비스<br /><br />모션 · 음성 · 이미지 · 비디오</p></span></div>
</div></div></div><p>React는 타임라인, 인스펙터, 아웃라이너, 에셋 브라우저와 프로젝트 흐름을 담당한다. Bevy는 3D 씬, 카메라, 애니메이션, picking, gizmo와 렌더링을 담당한다.</p><p>React가 ECS 상태를 직접 수정하지 않도록 두 계층 사이에는 버전형 명령·이벤트 계약을 뒀다. 사용자의 의도를 명령으로 전달하고, Rust 런타임이 이를 검증·적용한 뒤 확정된 결과를 이벤트로 반환한다.</p><p>저장 포맷은 Bevy ECS를 그대로 직렬화하지 않고 제품이 소유하는 버전형 번들로 정의했다. 덕분에 엔진 버전과 사용자 데이터의 수명 주기를 분리했다.</p></section>
<section><h2>Rust를 선택한 이유<a href="#rust를-선택한-이유"><span>#</span></a></h2><p>Rust는 브라우저와 네이티브 실행 경로에서 동일한 도메인·타임라인 코어를 공유하기 위해 선택했다.</p><ul>
<li>WASM과 Native를 지원하는 단일 코드베이스</li>
<li>장면과 타임라인 평가 결과의 차이 최소화</li>
<li>타입·메모리 안전성과 명시적 오류 처리</li>
<li>크레이트 의존성으로 강제되는 아키텍처 경계</li>
<li>서비스, CLI와 테스트 하네스에서 동일한 제품 모델 재사용</li>
</ul><p>브라우저 프리뷰와 네이티브 렌더러가 서로 다른 구현을 사용하면 같은 번들을 다르게 해석하거나 프레임 평가가 달라질 가능성이 커진다. Rust 코어를 공유해 이 차이를 줄였다.</p><p>Rust의 학습 곡선과 컴파일 비용을 고려해 UI까지 Rust로 구현하지 않고 런타임과 제품 코어에 적용 범위를 제한했다.</p></section>
<section><h2>Bevy를 선택한 이유<a href="#bevy를-선택한-이유"><span>#</span></a></h2><p>Bevy는 Rust 기반 3D 런타임에 필요한 ECS, wgpu 렌더링, glTF 로딩, 애니메이션, 카메라와 picking 기반을 제공한다.</p><p>Raw wgpu와 자체 ECS를 구축하는 방식은 제어력은 높지만 제품 차별성과 무관한 기반 구현 비용이 크다. Bevy를 사용해 런타임 기반을 재사용하고 샷 편집, 타임라인, 번들과 서비스 경계에 개발 역량을 집중했다.</p><p>Bevy가 1.0 이전이라는 위험은 다음 방식으로 제한했다.</p><ul>
<li>도메인 모델에서 Bevy 의존성 제거</li>
<li>엔진 특화 코드의 별도 크레이트 격리</li>
<li>제품 번들과 raw ECS 상태 분리</li>
<li>React와 ECS 사이의 직접 결합 차단</li>
</ul></section>
<section><h2>WASM과 WebGPU를 선택한 이유<a href="#wasm과-webgpu를-선택한-이유"><span>#</span></a></h2><p>WASM으로 Rust 런타임을 브라우저에서 직접 실행한다. 장면과 타임라인 로직을 JavaScript로 별도 구현하지 않고 Web과 Native가 동일한 제품 코어를 사용한다.</p><p>WebGPU는 사용자 기기의 GPU에서 브라우저 3D 프리뷰를 실행하기 위해 선택했다. wgpu는 WebGPU와 Native의 Vulkan, Metal, D3D 계열 백엔드를 같은 렌더링 기반으로 연결한다.</p><p>초기 범위에서는 WebGL2 이중 지원보다 WebGPU 경로의 안정화에 집중했다. 최신 브라우저, HTTPS, WASM 로딩 크기와 브라우저 메모리 한계는 명시적인 제약으로 수용했다.</p><p>브라우저가 모든 작업을 담당하도록 하지는 않았다. 브라우저는 편집과 프리뷰를 담당하고, 대규모 렌더링이나 자동화처럼 고부하·장시간 실행이 필요한 작업은 네이티브 서비스와 CLI가 담당한다.</p></section>
<section><h2>React와 TypeScript를 선택한 이유<a href="#react와-typescript를-선택한-이유"><span>#</span></a></h2><p>Shotloom의 비뷰포트 UI는 게임 HUD보다 생산성 편집 애플리케이션에 가깝다. 타임라인, 인스펙터, 검색, 에셋 브라우저와 프로젝트 흐름은 React 생태계와 DOM 기반 테스트를 활용하는 편이 효율적이었다.</p><p>React는 사용자 입력과 화면 구성을, Bevy는 3D 런타임 상태를 소유하도록 분리했다. 새로운 상호작용을 추가할 때마다 브리지 계약을 함께 관리해야 하는 비용이 생기지만, UI와 엔진을 독립적으로 발전시키고 상태의 최종 소유자를 명확히 할 수 있었다.</p></section>
<section><h2>AI 에이전트 코딩의 발전으로 더 적합해진 스택<a href="#ai-에이전트-코딩의-발전으로-더-적합해진-스택"><span>#</span></a></h2><p>AI 에이전트 활용은 Shotloom의 기술 스택을 처음 선택한 이유는 아니었다. 그러나 프로젝트를 시작하고 실제 기능을 구현하는 동안 에이전트가 코드를 탐색하고, 여러 파일에 걸친 변경을 계획하며, 빌드와 테스트 결과를 바탕으로 스스로 수정하는 능력은 빠르게 발전했다. 그 변화 속에서 Rust·Bevy·React·TypeScript로 구성한 스택은 처음 예상했던 것보다 에이전트 주도 개발에 훨씬 더 적합한 선택이 됐다.</p><p>그 적합성은 특정 언어나 프레임워크의 유행보다 에이전트가 <strong>문맥을 읽고, 변경하고, 결과를 검증하는 전체 루프</strong>에서 나왔다.</p>

<table><thead><tr><th>에이전트 개발에 필요한 조건</th><th>Shotloom에서 제공한 근거</th><th>실제 이점</th></tr></thead><tbody><tr><td>저장소에서 읽을 수 있는 문맥</td><td>Rust·TypeScript 소스, JSON 스키마, OpenAPI 계약, ADR과 명세를 텍스트로 관리</td><td>검색만으로 데이터 구조, 설계 의도와 영향 범위를 함께 파악할 수 있다.</td></tr><tr><td>빠르고 구체적인 실패 피드백</td><td><code>cargo check</code>·테스트·Clippy, TypeScript 타입 검사, Biome·Vitest, WASM 빌드와 브라우저 스모크 테스트</td><td>잘못된 타입, 누락된 분기, 계약 불일치와 브라우저 통합 실패를 파일·명령 단위로 확인하고 수정할 수 있다.</td></tr><tr><td>기계적으로 강제되는 경계</td><td>Cargo 크레이트, Rust 타입, 버전형 command/event DTO, 제품 소유 번들과 외부 서비스 스키마</td><td>변경이 경계를 벗어나면 컴파일이나 계약 검증에서 드러나므로 넓은 수정의 누락을 줄일 수 있다.</td></tr><tr><td>비대화형 실행 경로</td><td>빌드, 번들 검증, 변환, 렌더 자동화를 CLI와 스크립트로 제공</td><td>GUI 조작에 의존하지 않고 로컬과 CI에서 같은 절차를 반복할 수 있다.</td></tr><tr><td>중복이 적은 기준 구현</td><td>Web·Native·CLI가 같은 Rust 코어와 프로젝트 모델을 공유</td><td>브라우저와 네이티브 구현을 따로 수정하며 의미가 어긋날 가능성과 에이전트가 읽어야 할 문맥을 줄인다.</td></tr><tr><td>작게 나눌 수 있는 책임</td><td>React UI, command/event 브리지, Rust 코어, Bevy 런타임과 서비스 어댑터를 분리</td><td>하나의 작업을 제한된 계층과 검증 명령에 연결해 계획하고 리뷰하기 쉽다.</td></tr></tbody></table><p>Rust의 장점은 단순히 안전한 코드를 생성해 준다는 데 있지 않았다. 에이전트가 여러 타입과 호출부를 함께 바꿀 때 컴파일러가 빠뜨린 경로를 구체적으로 열거하고, exhaustive match와 명시적인 오류 타입이 미완성 변경을 드러냈다. React·TypeScript 계층에서도 DOM 기반 단위 테스트와 타입 검사가 UI 변경을 3D 런타임 실행 전부터 검증했다. command/event 브리지와 제품 번들은 두 언어 사이에서 무엇을 주고받는지 명시해, 에이전트가 한쪽의 내부 상태를 추측해 직접 수정하지 않도록 제한했다.</p><p>CineV Studio에서도 Commandlet과 자동화 테스트를 만들 수 있었고 AI 에이전트가 Unreal C++ 코드를 다루는 것 자체가 불가능한 것은 아니었다. 차이는 자동화의 기본 단위였다. Unreal 경로에서는 일부 중요한 문맥이 실행 중인 에디터, 패키징 설정과 바이너리 에셋에 남고 검증에 엔진 프로세스가 필요한 경우가 많았다. Shotloom에서는 제품 모델과 계약을 텍스트로 읽을 수 있고 대부분의 검증을 컴파일러, 테스트, CLI와 브라우저 자동화에서 재현할 수 있었다. 자동화를 별도로 덧붙이지 않아도 저장소의 기본 개발 경로가 곧 에이전트가 사용할 수 있는 경로였다.</p><p>물론 이 스택이 자동으로 에이전트 친화적인 것은 아니다. Bevy의 빠른 API 변화와 Rust의 컴파일 시간은 여전히 비용이고, ECS 실행 순서나 시각적 품질처럼 타입 검사만으로 확인할 수 없는 문제도 남는다. 제품 범위, 데이터 소유권, 계층 간 계약과 최종 리뷰는 사람이 책임져야 한다. 그럼에도 코드와 명세에 문맥이 드러나고 검증 루프를 비대화형으로 실행할 수 있다는 특성 덕분에, Shotloom의 기술 전환은 웹 배포와 런타임 비용뿐 아니라 급속히 발전한 AI 에이전트의 구현 능력을 활용하는 데도 적합한 기반이 됐다.</p></section>
<section><h2>트레이드오프<a href="#트레이드오프"><span>#</span></a></h2>

<table><thead><tr><th>결정</th><th>이점</th><th>비용</th><th>대응</th></tr></thead><tbody><tr><td>웹 우선 편집</td><td>설치 장벽과 서버 GPU 세션 의존 제거</td><td>브라우저·GPU 호환성 편차</td><td>지원 환경 제한, Native 경로 유지</td></tr><tr><td>Rust 코어</td><td>Web·Native 공유와 강한 검증</td><td>학습·컴파일 비용</td><td>UI는 React·TypeScript 유지</td></tr><tr><td>Bevy</td><td>3D 런타임 기반 재사용</td><td>pre-1.0 API 변경</td><td>엔진 격리, 제품 모델 독립</td></tr><tr><td>WASM</td><td>동일한 Rust 코어의 브라우저 실행</td><td>바이너리 크기와 메모리 상한</td><td>프리뷰 중심, 고부하 작업 분리</td></tr><tr><td>WebGPU</td><td>현대 GPU와 Native 렌더 구조 정렬</td><td>최신 브라우저·HTTPS 요구</td><td>범용 호환을 초기 범위에서 제외</td></tr><tr><td>UI·엔진 분리</td><td>독립적인 개발과 명확한 상태 소유권</td><td>브리지 계약 관리</td><td>좁고 버전형인 DTO 유지</td></tr><tr><td>낮은 프리뷰 충실도 수용</td><td>제어 기능과 반복 속도에 집중</td><td>Unreal 대비 시각 품질 감소</td><td>최종 품질은 생성·후처리 단계 담당</td></tr></tbody></table><p>이 아키텍처는 모든 지표를 개선하려는 선택이 아니다. 최종 렌더링 충실도와 범용 브라우저 지원을 일부 포기하고 웹 접근성, 부분 수정, 결정론과 서비스 통합을 우선했다.</p></section>
<section><h2>전환과 검증<a href="#전환과-검증"><span>#</span></a></h2><p>기존 Unreal 경로를 먼저 제거하지 않고 대체 경로를 단계적으로 검증했다.</p><ol>
<li>
<p><strong>브라우저 골든 패스 검증</strong>
캐릭터 배치, 모션 연결, 타임라인 스크럽과 카메라 프리뷰를 브라우저에서 구현했다.</p>
</li>
<li>
<p><strong>제품 계약 안정화</strong>
번들, 저장·복구, 브리지, 에셋과 렌더링 경계를 독립적인 계약으로 정리했다.</p>
</li>
<li>
<p><strong>Native·Service 실행 경로 검증</strong>
같은 제품 코어를 사용해 번들을 검증하고 카메라 결과물을 생성하는 자동화 경로를 구성했다.</p>
</li>
<li>
<p><strong>CineV 연동 경계 분리</strong>
사용자 제작 흐름과 Shotloom 실행 책임을 분리하고 파일·작업 단위로 입력과 결과를 전달하도록 구성했다.</p>
</li>
<li>
<p><strong>레거시 신규 진입 차단</strong>
대체 경로가 동작한 뒤 신규 Unreal·Pixel Streaming 요청을 차단했다. 기존 사용자 데이터와 역사적 작업을 위한 호환 경로는 별도로 보존했다.</p>
</li>
</ol><p>이 순서로 레거시 제거 자체를 목표로 삼지 않고 실제로 사용할 수 있는 대체 흐름부터 확보했다.</p></section>
<section><h2>결과<a href="#결과"><span>#</span></a></h2><ul>
<li>별도 설치나 Pixel Streaming 세션 없이 브라우저에서 캐릭터, 모션, 타임라인과 카메라를 편집하는 기본 경로를 확보했다.</li>
<li><code>.shotloom</code> 번들을 단일 기준으로 두고 Web·Native·CLI가 같은 Rust 모델과 변환 규칙을 사용하도록 구성했다.</li>
<li>비동기 편집 명령이 거절되거나 시간 초과·취소돼도 UI와 ECS 상태가 어긋나지 않도록 버전형 command/event 계약과 트랜잭션 복구 경계를 마련했다.</li>
<li>프레임률 기반 타임라인과 카메라 키·보간 규칙을 제품 모델에 두어 브라우저 프리뷰와 네이티브 실행 경로가 같은 장면 상태를 평가하도록 했다.</li>
<li>외부 생성 결과를 검증한 뒤 동일한 번들 revision에 원자적으로 적용해 편집·렌더·아카이브가 같은 입력을 사용하도록 했다.</li>
<li>고부하 렌더링과 자동화를 브라우저 편집 세션에서 분리하고, 신규 Unreal·Pixel Streaming 경로를 단계적으로 종료할 근거를 확보했다.</li>
</ul><p>운영 관점의 변화는 단순한 비용 절감 주장보다 자원의 사용 위치가 달라졌다는 데 있다. 이전에는 기본 편집에도 GPU 인스턴스와 스트리밍 세션이 필요했지만, 전환 후에는 사용자 기기가 편집과 프리뷰를 담당하고 네이티브 자원을 실제 렌더링과 자동화 작업에 집중할 수 있게 됐다.</p></section>
<section><h2>이 전환에서 배운 점<a href="#이-전환에서-배운-점"><span>#</span></a></h2><p>가장 큰 결정은 Unreal Engine 대신 Bevy를 선택한 것이 아니었다. Shotloom이 최종 렌더러일 필요가 없다고 정의한 것이었다. 이 범위를 제외하고 나서야 편집 UI, 장면 런타임, 프로젝트 데이터와 생성 서비스의 책임을 각각 분리할 수 있었다.</p><p>Unreal Engine은 성숙도와 렌더링 기능에서 Bevy보다 강력하다. 그러나 목표가 서버에서 실행되는 고품질 네이티브 편집기에서 브라우저 기반의 샷 제어 시스템으로 바뀌면 기술 평가 기준도 달라진다.</p><p>두 경로를 개발하면서 네 가지를 배웠다. 엔진의 기능표보다 제품이 사용자에게 제공할 핵심 가치를 먼저 정의해야 한다. 엔진 내부 상태보다 오래 살아야 하는 데이터와 계약은 제품이 직접 소유해야 한다. 레거시 전환은 기존 경로를 제거하는 일보다 좁은 골든 패스를 대체 환경에서 끝까지 검증하는 일에서 시작해야 한다. 코드와 상태의 경계를 명시하고 자동으로 검증할 수 있는 구조는 사람뿐 아니라 빠르게 발전하는 AI 에이전트와 협업할 때도 장기적인 개발 자산이 된다.</p><p>Shotloom은 CineV Studio가 증명한 제어의 가치를 유지하되, 그 가치를 Unreal Engine의 실행 문맥과 분리했다. 그 결과 생성형 AI와 웹 중심의 제작 환경에서 반복 가능한 샷 제어를 제공하는 구조로 제품을 재구성할 수 있었다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/gamejob-unreal-unity-client-programmer-development-pm-market-2026/</id>
      <title type="text">이직 준비: 2026.08 Unreal·Unity 클라이언트와 개발 PM 채용 분석</title>
      <published>2026-08-20T00:00:00.000Z</published>
      <updated>2026-08-21T18:14:04.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/gamejob-unreal-unity-client-programmer-development-pm-market-2026/"/>
      <summary type="text">2026년 8월 22일 게임잡의 Unreal·Unity 클라이언트 및 엔진 개발, 개발 PM 공고를 다시 조사하고 반복 요구사항을 공개 이력서와 비교해 지원 트랙과 준비 우선순위를 정리합니다.</summary>
      <content type="html"><![CDATA[<p>이직 준비를 시작하면서 내가 해 온 일을 늘어놓기 전에, 지금 채용 시장이 무엇을 증거로 보는지부터 확인했다. 2026년 8월 22일 게임잡에서 Unreal·Unity 클라이언트 및 엔진 개발, 개발 PM 공고를 다시 찾았다. 상세 업무와 자격 요건은 <a href="/resume">공개 이력서</a>에 하나씩 대조했다.</p>
<p>클라이언트 개발에서 눈에 띈 것은 엔진 사용 기간보다 출시된 제품에서 맡은 시스템과 해결한 문제였다. Unreal 공고는 전투·온라인·엔진 전문성에 따라 갈렸고 Dedicated Server, 프로파일링, Low Level 경험을 구체적으로 물었다. Unity 공고의 공통분모는 C#·객체지향, 상용 출시, 콘텐츠·UI·툴, 성능 분석이었다. 모바일 포지션으로 갈수록 네이티브 SDK·빌드·라이브 경험을 한층 깊게 확인했다.</p>
<p>개발 PM은 보는 지점이 달랐다. 일정과 마일스톤, 부서 간 의존성, 위험과 이슈, 개발 프로세스를 실제로 운영해 봤는지가 기준이었다. 내 경력에서 이에 가까운 대목은 모호한 요구를 구현 단위로 바꾸고 기획·아트·TA·QA·서비스 사이를 조율한 경험이다. 빌드·배포·QA 흐름을 기술적으로 지원한 일도 옮겨 쓸 만하다. 하지만 프로젝트 일정·우선순위·예산·인력을 직접 소유했다는 공개 근거는 없다. 지금은 경력 개발 PM보다 기술 이해도가 높은 개발 PM 전환 후보라고 보는 쪽이 정확하다.</p>
<section><h2>조사 범위와 방법<a href="#조사-범위와-방법"><span>#</span></a></h2><p>조사는 8월 22일 오전에 했다. 게임잡 검색 결과를 모은 다음 각 상세 페이지를 다시 열었다. Unreal 쪽에서는 <a href="https://www.gamejob.co.kr/Recruit/joblist?menucode=searchtot&amp;searchtype=all&amp;searchstring=%EC%96%B8%EB%A6%AC%EC%96%BC" target="_blank">‘언리얼’ 66건</a>, <a href="https://www.gamejob.co.kr/Recruit/joblist?menucode=searchtot&amp;searchtype=all&amp;searchstring=Unreal" target="_blank">‘Unreal’ 88건</a>, <a href="https://www.gamejob.co.kr/Recruit/joblist?menucode=searchtot&amp;searchtype=all&amp;searchstring=UE5" target="_blank">‘UE5’ 33건</a>이 잡혔다. 같은 공고 번호를 빼고 남은 결과는 141건이다. Unity 쪽은 <a href="https://www.gamejob.co.kr/Recruit/joblist?menucode=searchtot&amp;searchtype=all&amp;searchstring=%EC%9C%A0%EB%8B%88%ED%8B%B0" target="_blank">‘유니티’ 30건</a>과 <a href="https://www.gamejob.co.kr/Recruit/joblist?menucode=searchtot&amp;searchtype=all&amp;searchstring=Unity" target="_blank">‘Unity’ 79건</a>을 합친 뒤 중복을 빼 92건으로 좁혔다.</p><p>개발 PM은 붙여 쓴 <a href="https://www.gamejob.co.kr/Recruit/joblist?menucode=searchtot&amp;searchtype=all&amp;searchstring=%EA%B0%9C%EB%B0%9CPM" target="_blank">‘개발PM’ 검색 결과 20건</a>을 출발점으로 삼았다. ‘개발 PM’처럼 띄어 쓰면 177건이 나오지만 두 단어가 넓게 매칭돼 사업·서비스 PM과 다른 직군까지 끼어든다. 제목과 본문이 개발 PM 업무를 직접 가리키는지 살펴본 뒤 묶음 채용, 헤드헌터 중복을 제외했다.</p>

<table><thead><tr><th>조사 축</th><th>중복 제거 원시 결과</th><th>조건을 통과한 직접 공고</th><th>회사 수</th><th>이번 글에서 상세 코딩한 표본</th></tr></thead><tbody><tr><td>Unreal 클라이언트·엔진</td><td>141건</td><td>29건</td><td>13곳</td><td>12건</td></tr><tr><td>Unity 클라이언트·엔진</td><td>92건</td><td>31건</td><td>22곳</td><td>12건</td></tr><tr><td>개발 PM</td><td>20건</td><td>17건</td><td>14곳</td><td>14건</td></tr></tbody></table><p>클라이언트 개발 공고에는 다음 조건을 똑같이 적용했다.</p><ul>
<li>조사 시점에 지원 가능하며 정규직 지원 경로가 있는 공고</li>
<li>Unreal Engine 또는 Unity 기반 게임 클라이언트·엔진 프로그래밍 역할</li>
<li>담당 업무와 자격·우대 요건이 구체적으로 공개된 공고</li>
<li>전투·콘텐츠·온라인·플랫폼·엔진 가운데 책임 범위가 드러나는 직무</li>
</ul><p>전체 규모를 센 뒤에는 역할을 자세히 비교하기 좋은 Unreal 12건과 Unity 12건을 목적 표본으로 골랐다. Unity 표본에는 이력서와 잘 맞는 공고만 담지 않았다. 콘텐츠·모바일 라이브·플랫폼 SDK·엔진 최적화가 고르게 보이도록 구성했다. 개발 PM 전용 공고 17건은 모두 목록에 넣었고 요구 신호는 상세 업무를 텍스트로 충분히 읽은 14건에서만 셌다.</p><p>숫자를 읽을 때 주의할 점이 있다. 게임 업계 전체 통계가 아니라 검색어와 조사 시점, 상세 페이지의 공개 범위에 따라 달라지는 결과다. 같은 프로젝트의 세부 직무가 여러 건 들어갈 수도 있다. 모집 인원도 대부분 <code>O명</code>이라 실제 채용 좌석 대신 서로 다른 역할 공고 수를 셌다. 게임잡과 회사 원문이 다를 때는 회사 원문을 따랐다. 지원 직전에는 공고가 바뀌거나 끝났는지 다시 확인한다.</p></section>
<section><h2>Unreal Engine 클라이언트·엔진 공고<a href="#unreal-engine-클라이언트엔진-공고"><span>#</span></a></h2><p>엄격 조건을 통과한 공고는 13개 회사 29건이다. 경력직 28건에 신입은 1건뿐이었다. 지역도 서울 16건·성남 13건으로 모두 수도권에 몰렸다. 게임플레이·콘텐츠·일반 클라이언트가 17건, 엔진·그래픽스·물리가 12건이었다.</p><section><h3>상세 비교 표본 12건<a href="#상세-비교-표본-12건"><span>#</span></a></h3>

<table><thead><tr><th>회사·포지션</th><th><span>경력</span></th><th>공고에서 확인한 핵심 요구</th><th><span>마감</span></th></tr></thead><tbody><tr><td>NX3GAMES · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283693" target="_blank">OUTANT 클라이언트 전투</a> (<a href="https://nx3games.career.greetinghr.com/ko/o/232535" target="_blank">회사 상세</a>)</td><td><span>5년 이상</span></td><td>UE5 멀티플레이 ARPG 전투, Unreal 클라이언트, C++·객체지향, 협업. Unreal 출시와 Dedicated Server 멀티플레이 경험 우대</td><td><span>상시</span></td></tr><tr><td>NX3GAMES · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283691" target="_blank">OUTANT 클라이언트 콘텐츠</a> (<a href="https://nx3games.career.greetinghr.com/ko/o/232531" target="_blank">회사 상세</a>)</td><td><span>5년 이상</span></td><td>액션·RPG 시스템 콘텐츠, Unreal 클라이언트, C++·객체지향, 협업. Unreal 출시와 Dedicated Server 멀티플레이 경험 우대</td><td><span>상시</span></td></tr><tr><td>스마일게이트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283315" target="_blank">LOST ARK Mobile 전투 콘텐츠</a> (<a href="https://careers.smilegate.com/apply/announce/view?seq=3354" target="_blank">회사 상세</a>)</td><td><span>3년 이상</span></td><td>모바일 전투·연출, UE5, C++·객체지향과 협업. 액션 전투·카메라 연출 경험 우대</td><td><span>8월 31일</span></td></tr><tr><td>스마일게이트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283318" target="_blank">LOST ARK Mobile 콘텐츠</a> (<a href="https://careers.smilegate.com/apply/announce/view?seq=3179" target="_blank">회사 상세</a>)</td><td><span>5년 이상</span></td><td>게임 콘텐츠·UI·서버 연동·개발 도구, 모바일 개발과 라이브. UE4/5, C++와 Unreal Slate UI 이해 우대</td><td><span>8월 31일</span></td></tr><tr><td>스마일게이트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282521" target="_blank">LOST ARK Mobile 레벨 콘텐츠</a> (<a href="https://careers.smilegate.com/apply/announce/view?seq=6136" target="_blank">회사 상세</a>)</td><td><span>3년 이상</span></td><td>퀘스트·카메라·시퀀스, C/C++, UE5, 객체지향과 협업. 모바일 라이브·MMORPG 경험 우대, 포트폴리오 필수</td><td><span>8월 31일</span></td></tr><tr><td>스마일게이트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283239" target="_blank">차세대 게임 UE5 엔진 클라이언트</a> (<a href="https://careers.smilegate.com/apply/announce/view?seq=5912" target="_blank">회사 상세</a>)</td><td><span>5년 이상</span></td><td>오픈월드 멀티플레이, UE5 Dedicated Server 개발·최적화, 네트워크 동기화와 TCP/UDP. 다중 플랫폼 프로파일링·출시 경험 우대</td><td><span>8월 31일</span></td></tr><tr><td>크로노스튜디오 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282341" target="_blank">Chrono Odyssey 전투</a> (<a href="https://chronostudio.career.greetinghr.com/ko/o/226429" target="_blank">회사 상세</a>)</td><td><span>6년 이상</span></td><td>MMORPG 전투, 상태 동기화·지연 보정·대규모 전투, Unreal 애니메이션·멀티스레딩·최적화. PC·콘솔 경험 우대</td><td><span>채용시</span></td></tr><tr><td>크로노스튜디오 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281910" target="_blank">Chrono Odyssey 콘텐츠</a> (<a href="https://chronostudio.career.greetinghr.com/ko/o/159545" target="_blank">회사 상세</a>)</td><td><span>3년 이상</span></td><td>비전투 콘텐츠, UI/UX, Blueprint와 C++, 커스텀 에디터·파이프라인, 변경에 유연한 구조와 협업</td><td><span>채용시</span></td></tr><tr><td>크로노스튜디오 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282976" target="_blank">Chrono Odyssey 엔진</a> (<a href="https://chronostudio.career.greetinghr.com/ko/o/213431" target="_blank">회사 상세</a>)</td><td><span>3년 이상</span></td><td>UE4/5 엔진 수정, 그래픽 API, CPU/GPU 하드웨어 이해, Deep Profiling, RHI Thread·RDG. 콘솔·셰이더·DCC 자동화 우대</td><td><span>9월 20일</span></td></tr><tr><td>빅파이어게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=280913" target="_blank">클라이언트 프로그래머</a> (<a href="https://bigfiregames.career.greetinghr.com/ko/o/148319" target="_blank">회사 상세</a>)</td><td><span>2년 이상</span></td><td>C/C++, 3D 그래픽스·선형대수, Unreal 시스템·콘텐츠·UI와 유지보수. FPS/TPS·Unreal 출시·UMG 우대</td><td><span>채용시</span></td></tr><tr><td>크래프톤 PUBG STUDIOS · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281930" target="_blank">Sr. Engine Engineer</a></td><td><span>게임잡 7년 이상</span></td><td>UE4/5 Core·Memory Allocator·File System, 로딩·메모리·런타임 최적화, C++와 H/W·OS. 엔진 소스·콘솔·PIX·RenderDoc·VTune 우대</td><td><span>채용시</span></td></tr><tr><td>넥슨게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281193" target="_blank">Woochi the Wayfarer 엔진 프로그래머</a></td><td><span>10년 이상</span></td><td>UE5 엔진·플러그인·툴, PC·콘솔 최적화, 요구 분석·설계·구현과 협업. 엔진 소스·렌더링 파이프라인·상용화 경험 우대</td><td><span>채용시</span></td></tr></tbody></table><blockquote><p>크래프톤 공고는 게임잡 상단에 경력 7년 이상으로 표시되지만 상세 본문은 엔진 개발 또는 상용 엔진 사용 5년 이상을 요구한다.</p><p>마감 상태는 2026년 8월 22일 확인 기준이다. 스마일게이트 네 공고는 회사 채용 페이지의 8월 31일을 따랐다.</p></blockquote><p>표본 밖에서는 8월 21일 등록된 니오스트림의 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=284075" target="_blank">Little Devil Inside 그래픽스 엔지니어</a>가 새로 눈에 들어왔다. UE5·C++·HLSL과 Nanite·Lumen·Ray Tracing, 최적화·프로파일링을 함께 요구하는 자리다. 표본 수를 유지하려고 기존 공고와 바꾸지는 않았다. 다만 엔진·그래픽스가 별도 전문 트랙이라는 흐름은 여기서도 확인된다.</p></section></section>
<section><h2>Unity 클라이언트·엔진 공고<a href="#unity-클라이언트엔진-공고"><span>#</span></a></h2><p>검색 결과 안에서 엄격 조건을 통과한 공고는 22개 회사 31건이다. 경력 하한을 둔 자리가 22건, 경력 무관이 9건이었으며 신입 전용 공고는 없었다. 29/31이 수도권에 있었다. 서울 22건, 경기 6건, 수도권 복수 지역 1건이고 대구와 대전은 각각 1건이다. 일반 클라이언트·게임 로직·콘텐츠가 26건으로 대부분을 차지했다. 엔진·엔진 인터페이스는 3건, 빌드·툴·시스템·SDK는 2건이다.</p><p>검색 합집합에서 빠진 활성 공고도 있었다. 아래 표본에 든 네오플·에피드게임즈·시프트업 공고는 검색 결과에 나타나지 않았지만 상세 페이지가 열려 있었고 정규직 지원도 가능했다. 31건은 이 검색 조건에서 재현되는 활성 역할 수다. Unity 시장의 완전한 총량으로 읽을 수는 없다.</p><section><h3>상세 비교 표본 12건<a href="#상세-비교-표본-12건-1"><span>#</span></a></h3>

<table><thead><tr><th>회사·포지션</th><th><span>경력</span></th><th>공고에서 확인한 핵심 요구</th><th><span>마감</span></th></tr></thead><tbody><tr><td>네오플 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282745" target="_blank">[프로젝트 DL] UI 콘텐츠 클라이언트</a></td><td><span>3년 이상</span></td><td>Unity·C#·UGUI, 이벤트·상점·보상 UI, 리소스·애니메이션·인터랙션, 유지보수와 최적화. 모바일 출시·라이브 경험 우대</td><td><span>채용시</span></td></tr><tr><td>네오위즈 ASTRA9 Studio · <a href="https://jobs.lever.co/neowiz/c3704f2e-5921-49f2-a9c2-8857696d846f" target="_blank">내러티브 중심 RPG 신작 클라이언트</a></td><td><span>5년 이상</span></td><td>글로벌 PC·콘솔 RPG의 Unity 기능·콘텐츠, Unity 3D·Git, 분석적 문제 해결과 협업. Unity 3D 상용 출시 경험 우대, 포트폴리오 필수</td><td><span>채용시</span></td></tr><tr><td>기어세컨드 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283215" target="_blank">[프린세스 메이커] 클라이언트</a> (<a href="https://gear2.recruit.roundhr.com/c/HSfYtc6rRQ" target="_blank">회사 상세</a>)</td><td><span>1~5년</span></td><td>Steam Unity 클라이언트의 UI·콘텐츠·툴과 타 플랫폼 포팅, C#·UGUI, 자료구조·알고리즘·객체지향, Steam 개발·라이브와 협업</td><td><span>상시</span></td></tr><tr><td>베이글코드 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=269652" target="_blank">[Platform] 게임 플랫폼 클라이언트 SDK</a> (<a href="https://career.bagelcode.com/ko/career" target="_blank">회사 상세</a>)</td><td><span>5년 이상</span></td><td>Unity·Android·iOS SDK와 네이티브 연동, 아키텍처, 통합 빌드·스토어 배포, 샘플·검증 자동화. SDK 라이브 적용과 팀 리딩·협업</td><td><span>상시</span></td></tr><tr><td>111퍼센트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283957" target="_blank">게임 엔진 개발자—Unity &amp; Interface</a> (<a href="https://111percent.career.greetinghr.com/ko/o/233499" target="_blank">회사 상세</a>)</td><td><span>5년 이상</span></td><td>C++ 코어와 C#·Unity Bridge/SDK/API, 멀티플레이 동기화, GC·CPU·frame-time 프로파일링, Editor·디버깅 툴과 문서화. DOTS·Burst·네이티브 플러그인 우대</td><td><span>채용시</span></td></tr><tr><td>겜프스엔 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283723" target="_blank">[브라운더스트2] Unity 클라이언트</a></td><td><span>10년 이상</span></td><td>Unity·C#·UGUI, 모바일 최적화, 클라이언트-서버 구조·네트워크 동기화, Addressables와 협업. Timeline·URP·셰이더·비동기 프로그래밍 우대</td><td><span>채용시</span></td></tr><tr><td>위메이드커넥트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283104" target="_blank">신규 프로젝트 Unity 클라이언트</a></td><td><span>3~5년</span></td><td>Cocos2d→Unity 포팅·구조 개선, 시스템·콘텐츠·라이브 이슈, Unity·C# 출시, 자료구조·알고리즘·객체지향과 협업. 모바일 SDK·빌드·최적화와 Steam 경험 우대</td><td><span>채용시</span></td></tr><tr><td>에피드게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281993" target="_blank">[트릭컬 파티마] 콘텐츠 클라이언트</a></td><td><span>5년 이상</span></td><td>Unity 6.3 콘텐츠·UI·내부 툴, 상용 Unity 출시, C#·객체지향·소켓·UGUI. AssetBundle·SRP·Profiler·빌드 파이프라인과 모바일 네이티브 플러그인</td><td><span>채용시</span></td></tr><tr><td>신지게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282496" target="_blank">Unity 클라이언트 시니어</a></td><td><span>7년 이상</span></td><td>모바일·멀티플랫폼 RPG의 시니어 Unity 클라이언트, C#·C++, iOS·Android 대응과 팀 리딩. 이력서·경력기술서·포트폴리오 제출</td><td><span>채용시</span></td></tr><tr><td>슈퍼센트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282501" target="_blank">클라이언트 개발자</a> (<a href="https://career.rememberapp.co.kr/job/posting/327045" target="_blank">상세 요건</a>)</td><td><span>5년 이상</span></td><td>글로벌 모바일 게임의 핵심 시스템·플레이 로직·확장 가능한 구조, Unity·C#, 성능 최적화·디버깅과 AI 도구 활용. 대형 모바일·라이브와 아키텍처 경험 우대</td><td><span>채용시</span></td></tr><tr><td>시프트업 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282062" target="_blank">[승리의 여신: 니케] 엔진 최적화</a> (<a href="https://career.shiftup.co.kr/ko/o/155988" target="_blank">회사 상세</a>)</td><td><span>5년 이상</span></td><td>런타임 애셋 로딩·메모리·성능 분석, 엔진 툴·플랫폼·빌드 개선, Unity·C#, 렌더링·셰이더·멀티스레딩. C/C++·모바일 네이티브·Unreal 우대</td><td><span>채용시</span></td></tr><tr><td>에이시티게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283792" target="_blank">헬로키티 마이 드림 스토어 클라이언트</a></td><td><span>5년 이상</span></td><td>모바일 캐주얼·SNG 클라이언트, Unity 실무, UGUI·Spine·외부 SDK·Git. 이력서·경력기술서와 포트폴리오 제출</td><td><span>채용시</span></td></tr></tbody></table><blockquote><p>12건 모두 2026년 8월 22일에 지원 가능한 상태를 다시 확인했다. 네오위즈 공고는 기존 ROUND8 표기가 아니라 현재 회사 원문의 ASTRA9 Studio 명칭을 반영했다.</p></blockquote></section></section>
<section><h2>개발 PM 공고<a href="#개발-pm-공고"><span>#</span></a></h2><p>‘개발PM’ 검색 결과 20건에서 묶음 공고와 헤드헌터·중복 게시물을 빼자 14개 회사의 전용 공고 17건이 남았다. 13건은 경력을 요구했고 4건은 경력 하한이 없었다. 그중 3건에는 신입 지원이 명시돼 있었다. 최소 경력의 분포는 1년 이상 2건, 2년 이상 1건, 3년 이상 6건, 4년 이상 2건, 5년 이상 2건이다. 지역은 서울 10건과 성남 7건으로 전부 수도권이었다. 15건은 정규직만 뽑았고 나머지 2건은 계약직 또는 인턴도 열어 뒀다. 8월 신규 등록은 3건, 나머지는 5~7월부터 이어진 재고다.</p><section><h3>확인한 전용 공고 17건<a href="#확인한-전용-공고-17건"><span>#</span></a></h3>

<table><thead><tr><th>회사·포지션</th><th><span>경력</span></th><th>공고에서 확인한 핵심 업무·신호</th><th><span>마감·고용</span></th></tr></thead><tbody><tr><td>에이블게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283164" target="_blank">크레센트 개발 PM</a></td><td><span>3년 이상</span></td><td>WBS·세부 일정, 마일스톤 진행, 협업, 프로세스 개선, 위험 식별·대응</td><td><span>채용시·정규직</span></td></tr><tr><td>기어세컨드 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282107" target="_blank">개발 PM</a></td><td><span>1년 이상</span></td><td>프로젝트 일정·이슈와 협업 운영</td><td><span>채용시·정규직</span></td></tr><tr><td>엔씨소프트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=284001" target="_blank">AION2 개발 PM</a></td><td><span>3년 이상</span></td><td>일정·프로세스·리소스, 빌드 스펙과 산출물 관리</td><td><span>9월 17일·정규직</span></td></tr><tr><td>에이버튼 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283864" target="_blank">Project EA 개발 프로젝트 매니저</a></td><td><span>3년 이상</span></td><td>PC·콘솔 Unreal 프로젝트 일정·진척·위험, 외부 파트너, Jira·Confluence</td><td><span>채용시·정규직</span></td></tr><tr><td>크래프톤 PUBG STUDIOS · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283773" target="_blank">PM</a></td><td><span>5년 이상</span></td><td>Unreal 프로젝트의 제작 일정·품질·위험, 협업 도구와 영어</td><td><span>채용시·정규직</span></td></tr><tr><td>넷마블네오 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281427" target="_blank">개발 3본부 PM</a></td><td><span>3년 이상</span></td><td>개발 PM 실무와 포트폴리오 제출</td><td><span>채용시·정규직</span></td></tr><tr><td>스마일게이트 슈퍼크리에이티브 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283203" target="_blank">카오스 제로 나이트메어 개발 PM</a></td><td><span>5년 이상</span></td><td>개발 일정·위험·프로세스, 중국 퍼블리셔와 협업</td><td><span>채용시·정규직</span></td></tr><tr><td>웹젠 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282345" target="_blank">개발 PM</a></td><td><span>2년 이상</span></td><td>PC 라이브·신규 프로젝트 이슈와 위험, 일정·프로세스 개선</td><td><span>채용시·정규직</span></td></tr><tr><td>시프트업 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281666" target="_blank">스텔라 블레이드 차기작 개발 PM</a></td><td><span>신입 또는 5년 이하</span></td><td>킥오프부터 마일스톤까지 일정, 결정·액션·우선순위, 부서 간 이슈와 AI 자동화</td><td><span>채용시·정규직</span></td></tr><tr><td>시프트업 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281606" target="_blank">프로젝트 스피릿 개발 PM</a></td><td><span>4년 이상</span></td><td>로드맵·일정·리소스·병목, 마일스톤과 위험, 협업 도구·AI 활용</td><td><span>채용시·정규직</span></td></tr><tr><td>스마일게이트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281298" target="_blank">LOST ARK Mobile 개발 PM</a></td><td><span>4년 이상</span></td><td>일정, 부서와 해외 파트너 조율, MMORPG·대규모 조직·개발 프로세스</td><td><span>채용시·정규직</span></td></tr><tr><td>스마일게이트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281325" target="_blank">LOST ARK 개발 PM</a></td><td><span>3년 이상</span></td><td>콘텐츠·라이브 통합 일정, 이슈와 외부 협업, Jira·Confluence, 빌드·브랜치·배포</td><td><span>채용시·정규직</span></td></tr><tr><td>시프트업 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=280653" target="_blank">승리의 여신: 니케 개발 PM</a></td><td><span>신입 또는 2년 이하</span></td><td>일정·프로세스, 부서 간 문제 해결·정보 공유, 버전·이슈·협업 도구</td><td><span>채용시·정규직</span></td></tr><tr><td>엑소게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282930" target="_blank">개발 PM</a></td><td><span>3년 이상</span></td><td>스프린트·마일스톤, 빌드 QA·배포 검토, 위험과 우선순위, 문서·프로세스</td><td><span>9월 21일·계약 후 전환 검토</span></td></tr><tr><td>티쓰리엔터테인먼트 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281830" target="_blank">글로벌 개발 PM</a></td><td><span>경력 무관</span></td><td>글로벌 PC 게임 마일스톤, 개발 프로세스·일정·테이블·테스트</td><td><span>채용시·정규직</span></td></tr><tr><td>프로젝트클라우드게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282012" target="_blank">스튜디오 매니저·개발 PM</a></td><td><span>신입 가능</span></td><td>일정·마일스톤·업무, 이슈·버그·위험, 퍼블리셔·외부 협업과 스튜디오 운영</td><td><span>채용시·고용형태 혼합</span></td></tr><tr><td>블루포션게임즈 · <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=280715" target="_blank">에오스 블랙 개발 PM</a></td><td><span>1년 이상</span></td><td>라이브 이슈·위험, 개발 프로세스와 일정, 문서, Redmine·Git·Jenkins</td><td><span>채용시·정규직</span></td></tr></tbody></table><p>에이블게임즈와 기어세컨드는 핵심 요건을 이미지로 올렸다. 넷마블네오의 공개 텍스트는 포트폴리오 안내가 중심이었다. 세 공고를 빼고 업무·자격 요건을 충분히 읽을 수 있었던 14건만 아래 빈도표의 분모로 삼았다.</p>

<table><thead><tr><th>개발 PM 요구 신호</th><th>확인 빈도</th><th>읽히는 업무</th></tr></thead><tbody><tr><td>일정·마일스톤 관리</td><td>14/14</td><td>로드맵에서 WBS·스프린트·릴리스까지 작업 순서와 진척을 이어서 본다.</td></tr><tr><td>부서·외부 파트너 협업</td><td>14/14</td><td>기획·개발·아트·QA·퍼블리셔의 의존성을 풀고 필요한 결정을 잇는 일이다.</td></tr><tr><td>프로세스·파이프라인 개선</td><td>13/14</td><td>반복되는 병목이나 전달 누락을 찾아 작업 방식을 손본다.</td></tr><tr><td>위험·이슈 관리</td><td>11/14</td><td>일정 충돌과 품질·개발 위험을 일찍 꺼내 놓고 대응이 끝날 때까지 쫓는다.</td></tr><tr><td>Jira·Confluence·Slack·Redmine·Notion 등 도구</td><td>11/14</td><td>업무와 결정, 문서를 나중에도 팀이 찾을 수 있게 남긴다.</td></tr><tr><td>빌드·버전 관리·CI/CD·배포</td><td>8/14</td><td>직접 코드를 짜기보다는 빌드 상태와 브랜치·버전·QA·릴리스 흐름을 맞춘다.</td></tr><tr><td>Unreal·UE5 직접 언급</td><td>3/14</td><td>기술 프로젝트에서는 엔진 이해가 유리하지만 모든 공고가 요구하지는 않았다.</td></tr></tbody></table></section></section>
<section><h2>세 직무군에서 반복된 신호<a href="#세-직무군에서-반복된-신호"><span>#</span></a></h2><section><h3>클라이언트 개발은 역할 전문성으로 갈린다<a href="#클라이언트-개발은-역할-전문성으로-갈린다"><span>#</span></a></h3><p>클라이언트 표본은 담당 업무·필수·우대에 신호가 명시된 경우만 공고당 한 번 셌다. 엔진을 쓴다는 이유만으로 당연히 알 것이라 추론한 기술은 제외했다. 한 공고가 여러 항목에 들어가는 것은 허용했다.</p>

<table><thead><tr><th>공통 요구 신호</th><th>Unreal 표본</th><th>Unity 표본</th><th>해석</th></tr></thead><tbody><tr><td>경력 하한 3년 이상</td><td>11/12</td><td>11/12</td><td>튜토리얼을 끝냈다는 말로는 부족하다. 실제 프로젝트에서 맡은 책임과 해결 사례를 전제로 한다.</td></tr><tr><td>경력 하한 5년 이상</td><td>7/12</td><td>9/12</td><td>이번 Unity 표본에는 시니어·리드와 엔진·SDK 역할이 특히 많이 들어갔다.</td></tr><tr><td>협업·커뮤니케이션·문서화</td><td>10/12</td><td>11/12</td><td>기획·아트·서버·플랫폼 팀의 요구를 어떻게 맞췄는지도 기술 증거로 취급한다.</td></tr><tr><td>출시·상용화·라이브 경험</td><td>9/12</td><td>9/12</td><td>구현에 그치지 않고 최적화·장애·빌드·플랫폼 문제까지 마무리한 경험을 찾는다.</td></tr><tr><td>성능·메모리·프로파일링·최적화</td><td>5/12</td><td>9/12</td><td>Unreal 엔진 전담 직무와 이번 Unity 표본의 모바일·SDK·엔진 직무에서 자주 나왔다.</td></tr><tr><td>툴·에디터·SDK·빌드·자동화</td><td>6/12</td><td>8/12</td><td>콘텐츠를 만드는 과정과 배포 생산성도 개발자의 책임 범위에 들어간다.</td></tr><tr><td>멀티플랫폼·콘솔·포팅·기기 대응</td><td>6/12</td><td>7/12</td><td>PC 한 환경에서 기능을 만든 경험만으로 시니어 플랫폼 역할을 설명하기는 어렵다.</td></tr><tr><td>서버·네트워크·멀티플레이·동기화</td><td>5/12</td><td>4/12</td><td>구현법은 엔진마다 달라도 상태 소유권과 동기화 비용을 설명해야 하는 독립 트랙이다.</td></tr><tr><td>UI 프레임워크·UI 콘텐츠</td><td>3/12</td><td>5/12</td><td>콘텐츠 직무는 엔진 언어뿐 아니라 UMG·Slate 또는 UGUI 실무를 구체적으로 묻는다.</td></tr></tbody></table><p>Unreal 실무가 핵심인 공고는 Unreal 표본의 9/12였고 C/C++·객체지향을 필수 또는 핵심으로 둔 공고는 8/12였다. Dedicated Server·Replication과 엔진 소스·RHI·RDG가 역할을 가르는 신호였다. Unity 표본에서는 9/12가 C#을 본문에 명시했다. 구조 설계·자료구조·알고리즘·객체지향 또는 엔진 내부 구조를 적은 공고는 9/12였다. UGUI만 보는 것도 아니었다. Addressables·AssetBundle·URP, 모바일 네이티브 연동, SDK·스토어 배포, DOTS·Burst가 역할에 따라 따로 등장했다.</p><p>이 차이에는 표본 구성도 한몫했다. Unreal 쪽에는 한 대형 프로젝트의 전투·콘텐츠·레벨 직무를 나눠 담은 반면, Unity 쪽에는 모바일 라이브, PC·Steam, 플랫폼 SDK와 엔진 최적화 역할을 넓게 배치했다. 그래서 <code>9/12 대 5/12</code> 같은 차이를 엔진의 시장점유율이나 절대 수요로 해석해서는 안 된다.</p><p>역할로 다시 묶으면 클라이언트 개발은 네 갈래다.</p><ol>
<li><strong>게임플레이·콘텐츠·UI</strong>: 전투, 퀘스트, 플레이 로직, 카메라·시퀀스, UI와 제작 도구를 맡는다. 경험은 Unreal의 C++·Blueprint·UMG, Unity의 C#·UGUI와 데이터·에셋 흐름으로 드러난다.</li>
<li><strong>온라인·네트워크</strong>: 서버 권한, 상태 동기화, 지연과 대규모 부하가 주된 문제다. Unreal의 Dedicated Server·Replication과 Unity의 소켓·멀티플레이 SDK는 기술 이름만 다를 뿐 같은 시스템 질문으로 이어진다.</li>
<li><strong>플랫폼·출시·SDK</strong>: 빌드, 스토어, 네이티브 연동, 포팅과 자동화를 다룬다. 이번 Unity 표본에서는 모바일 SDK와 Steam·다중 플랫폼 경험이 별도 역할로 또렷하게 나타났다.</li>
<li><strong>엔진·성능</strong>: 메모리·로딩·렌더링, 프로파일링, 엔진 소스 또는 Bridge/API의 영역이다. 몇 개의 기능을 만들었는지보다 원인을 어떻게 분석했고 여러 팀이 재사용할 해결책을 남겼는지가 중요하다.</li>
</ol><p>직무명은 모두 클라이언트 프로그래머에 가깝다. 그래도 면접에서 확인하는 증거는 서로 다르다. 모든 기술을 한 장에 같은 비중으로 늘어놓지 말고 지원 트랙에 맞춰 대표 사례의 순서를 바꾸는 편이 낫다.</p></section><section><h3>개발 PM은 기술보다 소유 범위를 묻는다<a href="#개발-pm은-기술보다-소유-범위를-묻는다"><span>#</span></a></h3><p>개발 PM 공고에서 엔진 이름이 직접 나온 경우는 3/14에 그쳤다. 일정과 협업은 14/14, 프로세스는 13/14였다. 개발 지식이 있으면 회의에서 기술 제약을 알아듣고 빌드·QA·배포 흐름을 파악하기 좋다. 그렇다고 코딩 경력이 그대로 PM 경력이 되지는 않는다. 일정 충돌을 누가 드러냈는지, 의존성을 어떻게 정리했는지, 판단을 어디에 기록하고 끝까지 추적했는지가 핵심이다.</p><p>신입 가능 공고가 3건이어서 전환 경로가 완전히 닫힌 것은 아니다. 다만 17건 중 13건이 경력을 요구했고 8월 신규 등록도 3건뿐이었다. 개발 PM을 개발자의 우회 지원처로 여길 수 없는 이유다. 지금까지 맡은 조율과 운영 범위를 먼저 검증한 뒤 별도 이력서로 접근해야 한다.</p></section><section><h3>출시 경험은 결과보다 문제의 범위를 보여 준다<a href="#출시-경험은-결과보다-문제의-범위를-보여-준다"><span>#</span></a></h3><p>Unreal·Unity 표본에서 출시·라이브 경험은 각각 9/12에 등장했다. 단순히 출시작이 있는지를 묻는다기보다 어떤 범위의 문제를 풀어 봤는지 확인하는 신호에 가깝다. 개발 막바지에는 메모리와 프레임타임, 패키징, 플랫폼 SDK, 데이터 호환성, 콘텐츠 회귀와 일정 사이의 선택이 한꺼번에 생긴다. 같은 구간을 개발 PM은 마일스톤, 부서 간 의존성, 위험과 배포 판단의 문제로 다룬다.</p><p>개발 포트폴리오는 기능 목록 대신 문제 → 제약 → 분석 → 구현 → 측정 → 결과의 흐름으로 쓰는 편이 낫다. PM 전환 사례는 요청 → 의존성 → 일정·위험 → 결정 → QA·배포 순서가 알맞다. 어느 쪽이든 실제로 맡지 않은 소유권을 보태면 안 된다.</p></section></section>
<section><h2>내 이력서와 시장 요구의 비교<a href="#내-이력서와-시장-요구의-비교"><span>#</span></a></h2><p>현재 <a href="/resume">이력서</a>에서 확인되는 UE4/5·C++ 경력은 2021년 1월부터 2026년 4월까지 5년 이상이다. 실시간 클라이언트 개발은 2016년에 시작했다. Unity 역시 학습 이력만 있는 것이 아니라 VR 정규직 프로젝트와 출시된 PC·macOS 게임이 근거로 남아 있다. PM 적합도를 볼 때는 개발 경력을 직함으로 바꾸지 않고 공개된 업무 증거만 비교했다.</p>

<table><thead><tr><th>시장 요구</th><th>현재 이력서의 근거</th><th>판단과 보완점</th></tr></thead><tbody><tr><td>UE4/5·C++</td><td>Night of the Dead와 CINEVStudio, UE4→UE5 마이그레이션</td><td><strong>강함.</strong> Unreal 게임플레이·온라인에 바로 가져갈 수 있는 근거다.</td></tr><tr><td>Unity·C#</td><td>Clicked VR, Vapor World, 길고양이 이야기 2의 Unity·C# 개발</td><td><strong>강함.</strong> 다만 이력서에 적힌 마지막 Unity 프로젝트가 2023년에 끝나 최신 버전의 최근성은 보완 대상이다.</td></tr><tr><td>게임플레이·콘텐츠·UI</td><td>UE 전투·AI·던전·UMG, Unity 입력·이동·전투·퀘스트·대화·컷신·UI</td><td><strong>강함.</strong> 양쪽 엔진의 콘텐츠 직무에 곧바로 연결된다.</td></tr><tr><td>멀티플레이·실시간·XR</td><td>UE 최대 16인과 Replication Graph·Fast TArray·RPC·EOS, Unity VR 소켓 스트리밍·6DoF 정합·Humanoid 리타기팅</td><td><strong>강함.</strong> 별도 근거가 약한 부분은 Dedicated Server 운영, 지연 보정·예측, 치팅 대응이다.</td></tr><tr><td>최적화·프로파일링·툴</td><td>replication 비용 개선, 200개체 AI·애니메이션 최적화, Sequencer·MovieScene·Commandlet·Remote Control API</td><td><strong>강함.</strong> 측정 도구와 하드웨어·부하 시나리오까지 붙이면 다른 엔진에도 옮겨 갈 증거가 된다.</td></tr><tr><td>출시·빌드·상점·플랫폼</td><td>Night of the Dead 1.0, 길고양이 이야기 2의 Windows·macOS 출시, Steamworks·Stove·EOS, TeamCity·GitLab CI/CD와 패키징 해결</td><td><strong>강함.</strong> 세 종류 컨트롤러 대응은 분명한 강점이다. 콘솔 출시 경험으로 표현해서는 안 된다.</td></tr><tr><td>모바일·라이브·네이티브 SDK</td><td>짧은 Android 프로젝트와 GameSparks 보상 시스템</td><td><strong>중간 이하.</strong> 최근 iOS·Android 빌드와 장기 라이브 운영, 네이티브 플러그인 리딩이 핵심인 Unity 공고와는 간극이 크다.</td></tr><tr><td>엔진 Low Level·최신 Unity 전문기술</td><td>UE 마이그레이션·빌드·성능 문제 해결</td><td><strong>보완 필요.</strong> RHI·엔진 메모리/파일/로딩·콘솔, Unity 6·Addressables·DOTS/Burst·네이티브 플러그인은 공개 근거가 부족하다.</td></tr><tr><td>요구 구조화·직군 간 조율</td><td>모호한 요구를 구현 단위와 데이터 계약으로 바꾸고 기획·아트·TA·QA·서비스 사이의 기능·데이터 경계를 조율</td><td><strong>강함.</strong> 개발 PM 전환과 가장 가까운 근거다. 충돌한 요구와 결정 기준, 후속 결과를 한 사례로 묶을 수 있다.</td></tr><tr><td>문서·개발 프로세스</td><td>Linear 이슈·명세·ADR·테스트·PR, 절차와 인수인계 문서</td><td><strong>강함.</strong> 문서의 양보다는 누가 어떤 결정을 내렸고 다음 행동으로 어떻게 이어졌는지를 보여 줘야 한다.</td></tr><tr><td>일정·마일스톤·의존성·리소스</td><td>공개 이력서에 직접 소유했다는 사례 없음</td><td><strong>핵심 간극.</strong> 협업과 기술 리딩을 프로젝트 일정·인력 운영 경험으로 바꿔 적어서는 안 된다.</td></tr><tr><td>위험·이슈 관리</td><td>빌드·배포·QA 문제 추적과 기술적 해결</td><td><strong>전이 가능.</strong> 일정 영향 평가부터 위험 등록부, 에스컬레이션, 종료까지 PM으로 소유한 근거는 부족하다.</td></tr><tr><td>글로벌 이해관계자 협업</td><td>5개 언어 출시 참여, OPIc IM2·JLPT N4</td><td><strong>보완 필요.</strong> 공개 이력서에서는 해외 퍼블리셔·스튜디오와 직접 합의하고 전달한 사례가 보이지 않는다.</td></tr><tr><td>최근성·공개 증거</td><td>2026년의 최신 공개 프로젝트는 Rust·Bevy·WebGPU 기반 Shotloom</td><td><strong>보완 필요.</strong> 먼저 기존 출시작 사례를 공개 가능한 형태로 정리한다. 작은 최신 엔진 샘플은 지원 트랙에 필요할 때만 추가한다.</td></tr></tbody></table><section><h3>가장 가까운 지원 트랙<a href="#가장-가까운-지원-트랙"><span>#</span></a></h3><ol>
<li><strong>Unreal 게임플레이·온라인</strong>: UE5·C++, 전투·AI·UI, 16인 replication과 최적화가 직접 근거로 쓰인다. Dedicated Server·예측이 필수라면 지원 전에 간극부터 확인한다.</li>
<li><strong>Unity PC·2D 게임플레이·콘텐츠·플랫폼</strong>: 가장 가까운 경험은 출시된 Unity 게임의 핵심 시스템·UI다. Steamworks·Stove, Windows·macOS와 컨트롤러 대응도 바로 이어진다.</li>
<li><strong>Unity XR·실시간·애니메이션</strong>: VR 네트워크 스트리밍과 다중 사용자 좌표 정합을 직접 다뤘다. 트래커와 Humanoid 리타기팅은 흔치 않은 경험이기도 하다.</li>
<li><strong>개발 PM 전환</strong>: 요구 구조화와 다직군 조율, 문서화, 빌드·QA 흐름이 출발점이다. 일정·마일스톤·의존성·위험을 소유한 증거가 없으므로 경력 PM이 아닌 전환형 공고로 범위를 좁힌다.</li>
<li><strong>Unity 모바일·라이브·SDK</strong>: Unity·C#과 출시 경험은 옮겨 쓸 수 있다. 최근 모바일 스택의 증거가 얕아서 필수요건이 낮은 공고부터 골라야 한다.</li>
<li><strong>Unreal 엔진 Low Level·Unity 렌더링/DOTS</strong>: 성능 문제를 해결한 경험이 출발점이다. 현재 이력서만으로는 전문 역할의 핵심 증거가 부족해 도전 트랙으로 남긴다.</li>
</ol></section></section>
<section><h2>이력서 기준 지원 추천<a href="#이력서-기준-지원-추천"><span>#</span></a></h2><p>아래 추천은 시장 표본을 먼저 고른 다음 이력서의 직접 근거와 간극을 대조해 정했다. 기술 적합도만 반영했으므로 직급·보상·근무 조건은 따로 확인해야 한다.</p>

<table><thead><tr><th>우선순위</th><th>공고</th><th>맞는 근거</th><th>지원 전 확인할 점</th></tr></thead><tbody><tr><td>우선</td><td>LOST ARK Mobile <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283315" target="_blank">전투</a>·<a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283318" target="_blank">콘텐츠</a>·<a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282521" target="_blank">레벨</a>, Chrono Odyssey <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281910" target="_blank">콘텐츠</a>, <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=280913" target="_blank">빅파이어게임즈</a></td><td>UE5·C++, 전투·AI·UI·카메라·시퀀스, 서버 연동과 제작 도구</td><td>MMORPG·모바일 라이브가 필수인지 우대인지 공고별로 구분해 이력서 순서를 조정한다.</td></tr><tr><td>우선</td><td>NX3 OUTANT <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283693" target="_blank">전투</a>·<a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283691" target="_blank">콘텐츠</a>, 스마일게이트 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283239" target="_blank">차세대 게임 UE5 엔진 클라이언트</a></td><td>16인 멀티플레이, replication 구조와 비용 개선, C++ 게임플레이·최적화</td><td>Dedicated Server 운영·부하 테스트·지연 대응을 어디까지 직접 소유했는지 분명히 한다.</td></tr><tr><td>우선</td><td>네오위즈 ASTRA9 Studio <a href="https://jobs.lever.co/neowiz/c3704f2e-5921-49f2-a9c2-8857696d846f" target="_blank">내러티브 RPG 신작</a>, 네오플 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282745" target="_blank">프로젝트 DL UI 콘텐츠</a></td><td>Unity·C# 상용 프로젝트, 게임 시스템·UI·툴, PC 출시와 다직군 협업</td><td>최신 Unity 최근성, 네오위즈의 PC·콘솔 타깃과 Unity 상용 출시 우대를 어떤 증거로 보완할지 정한다.</td></tr><tr><td>우선·직급 확인</td><td>기어세컨드 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283215" target="_blank">프린세스 메이커 클라이언트</a></td><td>Unity PC·Steam 출시, UI·콘텐츠·툴·포팅과 C#·객체지향이 이력서에 가장 직접적으로 겹친다.</td><td>공고 경력 범위가 1~5년이므로 전체 경력에 맞는 직급·역할·보상인지 먼저 확인한다.</td></tr><tr><td>조건 확인</td><td>표본 밖의 Devs United Games <a href="https://www.devsunitedgames.com/en/career-client-developer" target="_blank">Client Developer</a></td><td>Unity VR/XR, 네트워크, Quest·Rift·SteamVR 등 멀티플랫폼 요구가 과거 VR 실무와 강하게 맞는다.</td><td>현재 회사 페이지에 경력 연차·고용형태·마감이 명시되지 않아 지원 전에 조건을 확인한다.</td></tr><tr><td>조건부</td><td>111퍼센트 <a href="https://111percent.career.greetinghr.com/ko/o/233499" target="_blank">Unity &amp; Interface</a>, 베이글코드 <a href="https://career.bagelcode.com/ko/career" target="_blank">플랫폼 클라이언트 SDK</a></td><td>Unity·C#과 C++, 실시간 네트워크, 프로파일링·툴·API, 빌드·CI 경험이 교차한다.</td><td>DOTS·Burst, Unity Package·네이티브 플러그인, 모바일 SDK를 제품 수준에서 소유한 증거는 약하다.</td></tr><tr><td>조건부</td><td>에피드게임즈 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281993" target="_blank">트릭컬 파티마</a>, 위메이드커넥트 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283104" target="_blank">신규 프로젝트</a></td><td>Unity 콘텐츠·UI·툴·출시와 C# 구조 설계, Steam·PC 경험</td><td>Unity 6·AssetBundle·SRP·모바일 네이티브, 최근 라이브와 Cocos2d 포팅 간극을 솔직히 분리한다.</td></tr><tr><td>전환 조건부</td><td>시프트업 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=281666" target="_blank">스텔라 블레이드 차기작</a>·<a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=280653" target="_blank">승리의 여신: 니케 개발 PM</a>, 기어세컨드 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282107" target="_blank">개발 PM</a>, 블루포션게임즈 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=280715" target="_blank">개발 PM</a></td><td>요구 구조화, 다직군 조율, 문서, 빌드·QA·배포의 기술적 이해</td><td>신입·저연차 범위가 전체 경력과 맞는지 확인하고 일정·위험 소유 경험이 없다는 간극을 숨기지 않는다.</td></tr><tr><td>도전</td><td>에이버튼 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283864" target="_blank">Project EA</a>, 엔씨소프트 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=284001" target="_blank">AION2</a>, 크래프톤 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283773" target="_blank">PUBG STUDIOS PM</a></td><td>Unreal·PC·콘솔 개발 제약, 빌드·QA 흐름을 빠르게 이해할 기술 배경</td><td>모두 직접적인 개발 PM 경력을 요구한다. 엔진 경력을 PM 경력처럼 포장하지 말고 전환 가능성을 먼저 문의하는 편이 안전하다.</td></tr><tr><td>도전</td><td>겜프스엔 <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283723" target="_blank">브라운더스트2</a>, <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282501" target="_blank">슈퍼센트</a>, <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=282496" target="_blank">신지게임즈</a>, 시프트업 <a href="https://career.shiftup.co.kr/ko/o/155988" target="_blank">엔진 최적화</a>, <a href="https://www.gamejob.co.kr/Recruit/GI_Read/View?GI_No=283792" target="_blank">에이시티게임즈</a></td><td>전체 클라이언트 연차와 Unity·C#, 네트워크·최적화·리딩의 전이 가능성</td><td>최근 모바일 대형 라이브, Addressables·네이티브·렌더링 전문성처럼 공고 핵심과 겹치지 않는 부분이 더 크다.</td></tr></tbody></table></section>
<section><h2>이직 준비 우선순위<a href="#이직-준비-우선순위"><span>#</span></a></h2><section><h3>P0 — 지원 전에 바로 정리할 것<a href="#p0--지원-전에-바로-정리할-것"><span>#</span></a></h3><ol>
<li><strong>이력서 첫 화면을 세 벌로 나눈다.</strong> Unreal 버전은 UE5·C++, replication·AI 최적화, UE4→UE5와 Night of the Dead 1.0으로 시작한다. Unity 버전의 첫 사례는 C#·Unity로 만든 PC·macOS 게임의 핵심 시스템·UI다. 개발 PM 전환 버전에는 요구 구조화, 부서 간 결정, 문서, 빌드·QA 흐름을 앞세운다.</li>
<li><strong>PM 이력서에는 맡았던 범위까지만 쓴다.</strong> 기술 리딩과 조율을 일정·예산·인력 관리로 바꾸지 않는다. 내가 내린 결정과 참여자, 충돌한 제약, 후속 행동과 결과를 한 사례로 엮는다.</li>
<li><strong>새 대형 샘플을 만들기 전에 기존 출시·VR 사례부터 정리한다.</strong> 문제 → 제약 → 설계 → 구현 → 측정 → 결과 순서로 다시 쓰되 회사 코드·데이터는 뺀다. 화면·다이어그램·수치도 공개 가능한 것만 쓴다.</li>
<li><strong>성능 수치에는 측정 문맥을 붙인다.</strong> 실제로 사용한 도구와 테스트 맵·개체 수·하드웨어·빌드 설정을 함께 기록한다. 정확성과 공개 가능성을 확인한 범위까지만 적는다.</li>
<li><strong>공통 면접 기반도 보강한다.</strong> C++의 수명·소유권·멀티스레딩, C#의 GC·비동기·객체 설계, 자료구조·알고리즘을 암기 항목으로 두지 말고 프로젝트의 결정과 연결해 설명한다. PM 전환 면접에서는 같은 프로젝트의 의존성·위험·우선순위 질문까지 준비한다.</li>
</ol></section><section><h3>P1 — 필요한 증거만 작게 보강할 것<a href="#p1--필요한-증거만-작게-보강할-것"><span>#</span></a></h3><ul>
<li>Unreal 온라인 역할에 집중할 때는 작은 UE5 Dedicated Server 샘플 하나면 된다. 상태 소유권, relevancy·dormancy, 지연·부하 테스트, Unreal Insights와 Automation Test를 한데 묶는다.</li>
<li>Unity 공고에서 최근성이 계속 걸리면 작은 Unity 6 샘플을 만든다. Addressables, Profiler·Memory Profiler, Edit/Play Mode Test와 한 플랫폼 빌드까지 보여 준다.</li>
<li>개발 PM 전환에는 새 앱보다 공개 가능한 운영 사례가 먼저다. 실제 프로젝트 하나를 요청 → 의존성 → 마일스톤·위험 → 결정 기록 → QA·배포 순으로 정리한다. 직접 소유한 범위와 지원한 범위는 분명히 가른다.</li>
</ul><p>세 가지를 한꺼번에 만들 필요는 없다. 실제 지원 목록의 필수요건에서 반복되며 면접에서 바로 검증될 증거 하나부터 끝낸다.</p></section><section><h3>P2 — 지원 트랙별로 선택할 것<a href="#p2--지원-트랙별로-선택할-것"><span>#</span></a></h3><ul>
<li>게임플레이·콘텐츠: Blueprint와 C++의 책임 경계, UMG·Slate, 에디터 확장과 데이터 검증</li>
<li>온라인·네트워크: prediction·lag compensation, 네트워크 프로파일링, 보안·치팅 대응</li>
<li>Unity 콘텐츠·플랫폼: UGUI·UI Toolkit, Addressables, IL2CPP·네이티브 플러그인, 모바일·스토어 빌드</li>
<li>엔진·플랫폼: UObject·GC·Asset Manager·비동기 로딩, Unreal RHI·엔진 소스 분석, DOTS·Burst·Jobs, PIX·RenderDoc·VTune</li>
<li>개발 PM: WBS·마일스톤·의존성·위험 기록, Jira·Confluence·Notion, 빌드·브랜치·QA·배포 흐름</li>
<li>대형 스튜디오 개발 환경: 보유한 GitLab·TeamCity·Linear 경험을 Perforce·Jira·Jenkins 흐름에 어떻게 옮길지 설명</li>
</ul><p>공백을 한 번에 전부 채우지 않는다. 실제 지원 공고의 필수요건에서 두 번 이상 반복되는 항목부터 고른다.</p></section></section>
<section><h2>주변 신호로서의 AI 개발 도구<a href="#주변-신호로서의-ai-개발-도구"><span>#</span></a></h2><p>AI 코딩 도구는 아직 게임 클라이언트나 개발 PM 시장의 공통 필수요건이 아니다. Unity 표본에서는 베이글코드·위메이드커넥트·신지게임즈·슈퍼센트 4/12가 AI 보조 개발이나 업무 효율화를 명시했다. Unreal 쪽에서는 Chrono Odyssey 엔진 공고가 AI를 활용한 개발 효율화를 우대했다. 개발 PM 공고 중에는 시프트업의 두 자리와 엑소게임즈 등이 자동화나 AI 도구 활용을 언급했다.</p><p>내 이력서의 AI 에이전트 워크플로와 오픈소스 경험은 분석·검증·문서화, 반복 작업의 품질을 높였다는 보조 증거로 쓸 수 있다. 다만 UE5·C++와 게임 시스템 전문성도, 일정과 위험을 소유한 PM 경험도 대신할 수 없다.</p></section>
<section><h2>결론<a href="#결론"><span>#</span></a></h2><p>8월 22일에 다시 살펴본 시장은 세 갈래로 나뉘었다. Unreal·Unity 클라이언트 개발은 출시된 제품에서 시스템과 문제를 끝까지 맡은 증거를 요구한다. 같은 제작 현장을 보는 개발 PM 공고는 일정·의존성·위험·프로세스를 누가 소유했는지 묻는다.</p><p>내 경력에서 바로 지원권에 가까운 축은 Unreal 게임플레이·온라인과 Unity PC·콘텐츠·XR이다. Unity 모바일·SDK와 두 엔진의 Low Level 전문 역할은 최근 스택의 직접 증거가 약하므로 선별해서 지원하는 편이 맞다. 개발 PM 쪽에는 요구 구조화와 다직군 조율, 빌드·QA를 이해하는 강점이 있다. 빈칸은 일정·마일스톤을 소유한 근거다. 이 간극을 숨기지 않고 전환형 공고만 따로 보는 편이 낫다.</p><p>세 직무를 한 이력서에 우겨 넣을 생각은 없다. 지원 트랙마다 첫 화면을 바꾸고 기존 출시·VR·최적화·협업 사례에 공개 가능한 측정과 결정의 문맥을 되살린다. 그런 다음 실제 필수요건에 필요한 증거 하나만 보강한다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/why-i-chose-astro-and-firefly/</id>
      <title type="text">Jekyll에서 Astro 기반 블로그로, 그리고 Firefly를 선택한 이유</title>
      <published>2026-06-24T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/why-i-chose-astro-and-firefly/"/>
      <summary type="text">Jekyll로 운영하던 GitHub Pages 블로그를 Astro 기반으로 옮긴 이유와, 콘텐츠 중심 구조·Svelte 인터랙션·개인화 기능을 갖춘 Firefly를 최종 테마로 선택한 기준을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>GitHub Pages 블로그의 기반을 Jekyll에서 <a href="https://astro.build/" target="_blank">Astro</a>로 옮기고 최종적으로 <a href="https://github.com/CuteLeaf/Firefly" target="_blank">Firefly</a>를 채택했다. Jekyll이 더 이상 쓸 수 없는 도구여서가 아니다. Markdown으로 글을 쓰고 Git으로 이력을 관리한 뒤 정적 페이지로 배포하는 흐름은 여전히 단순하고 견고하다. 다만 블로그를 오래 운영하다 보니 글을 담는 방식만큼 사이트를 고치고 확장하는 방식도 중요해졌다.</p>
<p>이번 선택은 테마 하나만 바꾸는 일이 아니었다. 콘텐츠가 특정 서비스에 묶이지 않는다는 원칙은 그대로 두고, 익숙한 개발 환경에서 화면과 기능을 다룰 수 있는 기반으로 옮기려 했다.</p>
<section><h2>Jekyll을 떠나게 된 배경<a href="#jekyll을-떠나게-된-배경"><span>#</span></a></h2><p>Jekyll의 가장 큰 장점은 GitHub Pages와 자연스럽게 연결된다는 점이다. Markdown 파일을 저장소에 올리면 정적 페이지로 배포되는 구조는 개인 블로그에 충분했다. 하지만 오래 운영할수록 글을 쓰는 일보다 테마를 원하는 모습으로 바꾸는 일이 더 부담스러워졌다.</p><p>레이아웃을 수정하려면 Liquid 템플릿과 테마의 규칙부터 이해해야 했고, 로컬에서는 Ruby와 gem 버전도 맞춰야 했다. 작은 화면 구성을 바꾸거나 새 기능을 추가할 때조차 평소 사용하는 TypeScript 중심의 개발 환경과 다른 문맥을 오갔다. 글만 발행한다면 감수할 수 있지만, 블로그 자체를 계속 손보려면 익숙한 도구를 쓰는 편이 낫다고 판단했다.</p><p>블로그의 역할도 달라졌다. 단순한 글 목록을 넘어 프로젝트 경험, 기술적 판단, 회고와 짧은 기록을 서로 연결하고 싶었다. 카테고리와 태그, 검색, RSS, SEO 메타데이터와 반응형 화면을 기본으로 유지하면서도 필요한 기능을 컴포넌트 단위로 확장할 수 있는 구조가 필요했다.</p></section>
<section><h2>Astro를 기반으로 선택한 이유<a href="#astro를-기반으로-선택한-이유"><span>#</span></a></h2><p>Astro를 쓰면 기존의 정적 사이트 운영 방식은 유지하면서 개발 환경을 TypeScript와 컴포넌트 중심으로 옮길 수 있다. 글은 계속 Markdown과 MDX 파일로 관리하고 빌드 결과도 정적 파일로 만든다. GitHub Pages에 배포하는 단순한 흐름을 포기할 필요가 없었다.</p><p>콘텐츠 중심 페이지에 필요한 JavaScript만 전달하는 구조도 블로그와 잘 맞았다. 대부분의 페이지는 글을 읽는 데 집중하고, 검색이나 설정처럼 상호작용이 필요한 부분만 클라이언트에서 실행한다. 기능을 하나 추가했다고 모든 페이지의 실행 비용까지 커지지는 않는다는 점이 마음에 들었다.</p><p>Astro의 콘텐츠 컬렉션에서는 글의 메타데이터를 스키마로 관리한다. 제목, 발행일, 설명, 이미지, 태그, 카테고리 같은 필드를 빌드할 때 검증하므로 오래된 글과 새 글이 같은 규칙을 따르는지 확인하기 쉽다. Markdown 파일은 직접 소유하되 데이터 구조를 느슨하게 방치하지 않는 절충안이었다.</p></section>
<section><h2>Firefly를 선택한 기준<a href="#firefly를-선택한-기준"><span>#</span></a></h2><p>Astro는 좋은 기반이지만 블로그에 필요한 기능을 모두 직접 조립하려면 적지 않은 시간이 든다. Firefly에는 콘텐츠 중심 블로그를 운영하는 데 필요한 구조가 이미 갖춰져 있었고, 내가 원하는 확장 방향과도 잘 맞았다.</p><a href="https://github.com/CuteLeaf/Firefly" target="_blank"><div><div><div><div></div><div>CuteLeaf</div></div><div>/</div><div>Firefly</div></div><div></div></div><div>🍀Firefly, fresh and aesthetic Astro blog theme template. </div><div><div>—</div><div>—</div><div>MIT</div><span>Astro</span></div></a><p>처음 눈에 들어온 건 이전에 사용하던 Jekyll 테마 Chirpy와 닮은 점이 많아서였다. 글을 중심에 두고 사이드바와 카테고리, 태그, 검색으로 콘텐츠를 탐색하는 방식이 익숙했다. 기반 기술을 바꾸더라도 독자가 글을 찾고 읽는 경험까지 완전히 낯설게 만들 필요는 없었다.</p><p>Firefly라는 이름에 맞춘 애니메이션 콘셉트도 마음에 들었다. 페이지 전환과 카드, 배경과 여러 인터랙션이 움직이지만 글 읽기를 방해하는 장식처럼 느껴지지는 않았다. 정적인 기술 블로그에 약간의 생동감과 개성을 더하는 분위기가 내가 만들고 싶은 블로그와 맞았다.</p><p>저장소 오너가 꾸준히 개발을 이어 간다는 점도 중요했다. 테마는 처음 설치할 때의 완성도 못지않게 Astro와 의존성 변화에 대응하고 문제를 계속 고쳐 나가는 유지보수 가능성이 중요하다. 커밋과 릴리스가 이어지고 이슈 대응도 활발해서 장기적으로 함께 발전시킬 기반으로 삼기 좋다고 판단했다.</p><p>정적인 문서와 레이아웃은 Astro 컴포넌트로 구성하고, 검색·설정·페이지네이션처럼 상호작용이 필요한 UI는 Svelte island로 분리한다. 페이지 전체를 무거운 클라이언트 애플리케이션으로 만들지 않고도 필요한 곳에는 충분한 인터랙션을 넣을 수 있다.</p><p>글 목록과 상세 페이지, 카테고리와 태그, RSS, 검색, 다국어 콘텐츠, 다크 모드와 개인화 설정도 한 구조 안에 정리되어 있다. 레이아웃과 사이드바, 댓글, 분석, 글꼴 같은 기능은 TypeScript 설정 모듈로 조정할 수 있어 테마 코드를 매번 뒤지는 수고가 줄었다. Markdown 확장 문법과 Mermaid, 미디어 콘텐츠를 함께 다룬다는 점도 기존 기술 글을 옮길 때 중요했다.</p><p>Firefly를 고른 결정적인 이유는 Chirpy에서 이어지는 익숙한 독서 경험, 마음에 든 애니메이션 콘셉트, 활발한 저장소 운영이었다. 글은 콘텐츠 컬렉션에, 정적 화면은 Astro에, 상호작용은 Svelte에, 사이트별 선택은 설정 모듈에 두는 분명한 기술적 경계도 장기 운영에 대한 확신을 더했다.</p></section>
<section><h2>전환하면서 유지한 것과 바꾼 것<a href="#전환하면서-유지한-것과-바꾼-것"><span>#</span></a></h2><p>플랫폼은 바뀌었지만 콘텐츠를 Markdown 파일로 저장하고 Git으로 이력을 관리하는 원칙은 유지했다. 별도의 CMS를 두지 않고 <code>main</code> 브랜치의 변경을 자동화된 빌드와 배포로 연결한다. 도구가 바뀌어도 글이 특정 서비스에 묶이지 않게 하려는 선택이다.</p><p>기존 글의 발행일과 저자, 카테고리와 태그도 가능한 한 보존한다. 테마마다 이름이 다른 frontmatter 필드는 Firefly의 콘텐츠 스키마에 맞춰 통일하고, 이미지와 내부 링크는 새 디렉터리 구조에 맞게 정리한다. 예제나 이전 테마의 운영 기록처럼 지금 블로그의 맥락과 맞지 않는 콘텐츠는 그대로 옮기지 않는다.</p><p>전환에도 비용은 든다. Node.js 의존성과 빌드 파이프라인을 관리해야 하고, Astro와 Svelte 업데이트도 따라가야 한다. Firefly를 많이 수정할수록 업스트림 변경을 받아들이는 일도 어려워질 수 있다. 설정으로 해결할 항목과 로컬 코드를 바꿀 항목을 구분하고, 변경 후에는 타입 검사와 정적 빌드를 함께 확인하며 운영할 생각이다.</p></section>
<section><h2>지금의 결론<a href="#지금의-결론"><span>#</span></a></h2><p>Jekyll은 글을 빠르게 공개하는 단순한 GitHub Pages 블로그에 여전히 좋은 선택이다. 하지만 지금 내 블로그에는 익숙한 프런트엔드 생태계, 컴포넌트 단위의 수정, 필요한 기능만 실행하는 정적 사이트 구조와 콘텐츠 확장성이 더 중요하다.</p><p>Astro는 그 기반을 제공하고 Firefly는 블로그로서 필요한 기능과 구조를 채워 준다. 이번 전환의 목표는 새 기술을 사용하는 것 자체가 아니다. 글을 쓰는 흐름은 단순하게 유지하면서 사이트를 고치고 확장하는 일을 더 예측 가능하게 만드는 것, 그리고 앞으로 쌓일 기록을 오래 관리할 수 있는 형태로 두는 것이다.</p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/garbage-collection-in-unreal-engine/</id>
      <title type="text">Unreal Engine UObject 가비지 컬렉션: 참조 그래프와 안전한 수명 관리</title>
      <published>2025-09-14T00:00:00.000Z</published>
      <updated>2025-09-14T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/garbage-collection-in-unreal-engine/"/>
      <summary type="text">UObject 참조 그래프, 강한·약한·소프트 참조, 소멸 단계와 클러스터, 점진적 GC의 제약을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Unreal Engine의 UObject garbage collection을 이해하는 핵심은 “pointer가 몇 개인가”가 아니라 <strong>Root Set에서 GC가 아는 strong reference를 따라 대상에 도달할 수 있는가</strong>이다. <code>Outer</code>, C++ smart pointer, 파괴 요청, 강제 GC의 역할을 이 참조 그래프와 구분해야 한다.</p>
<section><h2>C++ 수명 관리와 UObject GC는 함께 존재한다<a href="#c-수명-관리와-uobject-gc는-함께-존재한다"><span>#</span></a></h2><p>ISO C++에는 UObject 같은 tracing GC가 기본 제공되지 않는다. 대신 stack object, RAII, value type, <code>std::unique_ptr</code> 같은 deterministic ownership을 중심으로 설계한다. Unreal project도 이 원칙을 버리지 않는다.</p>

<table><thead><tr><th>대상</th><th>수명 관리</th><th>비고</th></tr></thead><tbody><tr><td><code>UObject</code>와 파생 class</td><td>Unreal reachability GC</td><td><code>AActor</code>, <code>UActorComponent</code>, asset도 UObject 계열</td></tr><tr><td>plain C++ class</td><td>RAII, value, unique/shared ownership</td><td>UObject smart pointer를 섞지 않음</td></tr><tr><td><code>UStruct</code> value 자체</td><td>owner의 C++/UObject lifetime</td><td>Enclosing field까지 reflected되거나 명시적으로 report될 때 내부 UObject field를 순회</td></tr><tr><td>stack local과 container value</td><td>C++ scope/owner</td><td>UObject GC가 value의 destructor를 대신하지 않음</td></tr></tbody></table><p><code>U</code>나 <code>A</code> prefix는 Unreal naming convention이다. prefix 자체가 GC 대상을 만드는 것이 아니라 실제 <code>UObject</code> inheritance와 reflection 정보가 중요하다.</p></section>
<section><h2>Reference graph와 collection lifecycle<a href="#reference-graph와-collection-lifecycle"><span>#</span></a></h2><p>GC는 root와 engine-known reference에서 시작해 strong edge를 추적한다. 도달한 object는 이번 collection에서 유지되고, 도달하지 못한 object는 unreachable이 되어 destruction lifecycle로 들어간다.</p><div><div><div><div><span></span></div><div><span><p>UPROPERTY TObjectPtr</p></span></div><div><span><p>static AddReferencedObjects</p></span></div><div><span><p>GC가 아는 external strong reference</p></span></div><div><span><p>수명 유지 안 함</p></span></div><div><span></span></div><div><span><p>예</p></span></div><div><span><p>아니오</p></span></div><div><span></span></div><div><span></span></div><div><span><p>Root Set / engine roots</p></span></div><div><span><p>도달 가능한 UObject</p></span></div><div><span><p>참조 대상 UObject</p></span></div><div><span><p>FGCObject / TStrongObjectPtr</p></span></div><div><span><p>unreflected raw T* / TWeakObjectPtr / TSoftObjectPtr</p></span></div><div><span><p>Root에서 strong path가 있는가?</p></span></div><div><span><p>이번 collection에서 유지</p></span></div><div><span><p>Unreachable</p></span></div><div><span><p>BeginDestroy</p></span></div><div><span><p>FinishDestroy / memory release</p></span></div>
</div><div><div><span></span></div><div><span><p>UPROPERTY TObjectPtr</p></span></div><div><span><p>static AddReferencedObjects</p></span></div><div><span><p>GC가 아는 external strong reference</p></span></div><div><span><p>수명 유지 안 함</p></span></div><div><span></span></div><div><span><p>예</p></span></div><div><span><p>아니오</p></span></div><div><span></span></div><div><span></span></div><div><span><p>Root Set / engine roots</p></span></div><div><span><p>도달 가능한 UObject</p></span></div><div><span><p>참조 대상 UObject</p></span></div><div><span><p>FGCObject / TStrongObjectPtr</p></span></div><div><span><p>unreflected raw T* / TWeakObjectPtr / TSoftObjectPtr</p></span></div><div><span><p>Root에서 strong path가 있는가?</p></span></div><div><span><p>이번 collection에서 유지</p></span></div><div><span><p>Unreachable</p></span></div><div><span><p>BeginDestroy</p></span></div><div><span><p>FinishDestroy / memory release</p></span></div>
</div></div></div><p>Reference count와 달리 서로를 가리키는 cycle도 root에서 끊겨 있으면 함께 수거할 수 있다. 반대로 아무 gameplay 가치가 없어도 root나 long-lived owner에서 strong path가 하나 남아 있으면 계속 유지된다. GC가 memory leak을 “항상 막아 준다”는 표현보다 <strong>unintended retention도 graph 문제</strong>라고 이해하는 편이 정확하다.</p><p>일반 collection은 reachability를 계산하는 동안 UObject processing/gameplay를 일시 중단할 수 있지만, mark work 자체는 <code>gc.AllowParallelGC</code>로 collector worker에 나눌 수 있다. Parallel GC와 여러 frame에 걸쳐 mark하는 incremental reachability는 서로 다른 기능이다.</p></section>
<section><h2>UObject를 올바르게 생성하기<a href="#uobject를-올바르게-생성하기"><span>#</span></a></h2><p>UObject는 <code>new</code>/<code>delete</code>로 생성·해제하지 않는다.</p>

<table><thead><tr><th>상황</th><th>API</th></tr></thead><tbody><tr><td>UObject constructor에서 default subobject</td><td><code>CreateDefaultSubobject&lt;T&gt;()</code></td></tr><tr><td>Runtime의 일반 UObject</td><td><code>NewObject&lt;T&gt;()</code></td></tr><tr><td>Runtime Actor</td><td><code>UWorld::SpawnActor&lt;T&gt;()</code> 또는 deferred spawn</td></tr><tr><td>기존 object 복제</td><td><code>DuplicateObject&lt;T&gt;()</code></td></tr></tbody></table><p><code>CreateDefaultSubobject</code>는 constructor-only API다. Runtime에 임의 UObject를 만드는 <code>NewObject</code>의 대체가 아니다.</p><section><h3><code>Outer</code>와 lifetime owner를 구분한다<a href="#outer와-lifetime-owner를-구분한다"><span>#</span></a></h3><p><code>NewObject&lt;T&gt;(Outer)</code>의 <code>Outer</code>는 object name path, containment, serialization context에 중요하다. 그러나 Outer가 임의 child를 자동으로 strong-reference하는 일반 lifetime-owner 계약은 아니다.</p><div><div><div><figure><figcaption><span>GcOwner.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"UObject/Object.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"UObject/ObjectPtr.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"UObject/UObjectGlobals.h"</span></div></div><div><div><div>6</div></div><div><span>#include</span><span> </span><span>"GcOwner.generated.h"</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>9</div></div><div><span>class</span><span> MYGAME_API </span><span>UGcChild</span><span> </span><span>final</span><span> : </span><span>public</span><span> </span><span>UObject</span></div></div><div><div><div>10</div></div><div><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>12</div></div><div><span>};</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>15</div></div><div><span>class</span><span> MYGAME_API </span><span>UGcOwner</span><span> </span><span>final</span><span> : </span><span>public</span><span> </span><span>UObject</span></div></div><div><div><div>16</div></div><div><span>{</span></div></div><div><div><div>17</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>public:</span></div></div><div><div><div>20</div></div><div><span>    </span><span>void</span><span> </span><span>CreateChild</span><span>()</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>22</div></div><div><span>        </span><span>Child</span><span> </span><span>=</span><span> </span><span>NewObject</span><span>&lt;</span><span>UGcChild</span><span>&gt;(</span><span>this</span><span>);</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>24</div></div><div>
</div></div><div><div><div>25</div></div><div><span>    </span><span>void</span><span> </span><span>ReleaseChild</span><span>()</span></div></div><div><div><div>26</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>27</div></div><div><span>        </span><span>Child</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span>private:</span></div></div><div><div><div>31</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>Transient</span><span>)</span></div></div><div><div><div>32</div></div><div><span>    </span><span>TObjectPtr</span><span>&lt;</span><span>UGcChild</span><span>&gt;</span><span> </span><span>Child</span><span>;</span></div></div><div><div><div>33</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>this</code>를 Outer로 정하는 것과 <code>Child</code>를 살리는 strong edge는 별개다. 위에서는 도달 가능한 owner의 reflected <code>Child</code> property가 target을 유지한다. <code>Child = nullptr</code> 뒤 다른 strong path가 없다면 target은 다음 collection에서 수거 대상이 될 수 있다.</p></section></section>
<section><h2>UObject pointer 결정표<a href="#uobject-pointer-결정표"><span>#</span></a></h2><p>Persistent reference와 function-local view를 같은 type으로 강제하지 않는다.</p>

<table><thead><tr><th>의도</th><th>선택</th><th>Keepalive</th><th>주의</th></tr></thead><tbody><tr><td>parameter, return, 짧은 local</td><td>raw <code>T*</code></td><td>없음</td><td>lifetime invariant가 scope 안에서 명확해야 함</td></tr><tr><td>reflected persistent strong field</td><td><code>UPROPERTY() TObjectPtr&lt;T&gt;</code></td><td>있음</td><td>owner도 reachable이어야 함</td></tr><tr><td>장기 non-owning cache</td><td><code>TWeakObjectPtr&lt;T&gt;</code></td><td>없음</td><td>매 사용 전에 <code>Get</code>/<code>IsValid</code></td></tr><tr><td>path 기반 on-demand asset</td><td><code>TSoftObjectPtr&lt;T&gt;</code></td><td>없음</td><td>load 완료 뒤 별도 strong ownership 필요</td></tr><tr><td>non-UObject owner나 짧은 pin</td><td><code>TStrongObjectPtr&lt;T&gt;</code></td><td>있음</td><td>항상 strong, hidden retention과 cycle 주의</td></tr><tr><td>UObject의 special unreflected edge</td><td>static <code>AddReferencedObjects</code></td><td>조건부</td><td>owner가 reachable할 때 outgoing edge를 report</td></tr><tr><td>non-UObject native owner</td><td><code>FGCObject</code></td><td>있음</td><td>global registration과 주소 안정성 필요</td></tr><tr><td>정말 전역인 manual root</td><td><code>AddToRoot</code> / <code>RemoveFromRoot</code></td><td>있음</td><td>remove 누락은 사실상 permanent retention</td></tr></tbody></table><p>Persistent reflected field를 왜 <code>TObjectPtr</code>로 쓰는지는 <a href="/posts/tobjectptr-in-unreal-engine/"><code>TObjectPtr</code> 글</a>에서 UFUNCTION, container, serialization과 함께 설명했다.</p><section><h3>Bare <code>TObjectPtr</code>는 자동 strong reference가 아니다<a href="#bare-tobjectptr는-자동-strong-reference가-아니다"><span>#</span></a></h3><p><code>TObjectPtr</code> wrapper만 member로 선언해서는 GC가 자동으로 찾지 못한다. 도달 가능한 <code>UCLASS</code>/<code>USTRUCT</code>의 <code>UPROPERTY</code> graph에 있거나, custom reference collector가 명시적으로 report해야 한다. Container도 element뿐 아니라 container member 자체가 reflected되어야 한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UPROPERTY</span><span>()</span></div></div><div><div><div>2</div></div><div><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;</span><span> </span><span>Current</span><span>;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>UPROPERTY</span><span>()</span></div></div><div><div><div>5</div></div><div><span>TArray</span><span>&lt;</span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;&gt;</span><span> </span><span>History</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>UE 5.4부터 experimental incremental reachability는 <code>TObjectPtr</code> assignment write barrier를 사용한다. 그래서 incremental mode에서는 non-editor build라고 wrapper가 raw pointer와 완전히 같은 assignment cost와 type property를 가진다고 단정할 수 없다.</p></section></section>
<section><h2>Weak, soft, strong pointer의 역할<a href="#weak-soft-strong-pointer의-역할"><span>#</span></a></h2><section><h3><code>TWeakObjectPtr</code><a href="#tweakobjectptr"><span>#</span></a></h3><p>Weak pointer는 target을 살리지 않는다. Target이 수거되거나 destruction 상태가 되면 <code>Get()</code>이 null을 반환할 수 있으므로 사용할 때마다 확인한다.</p><p>Game thread의 짧은 use라면 다음처럼 쓴다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> (</span><span>UObject</span><span>*</span><span> </span><span>Object</span><span> </span><span>=</span><span> </span><span>WeakObject</span><span>.</span><span>Get</span><span>())</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>UseObject</span><span>(</span><span>Object</span><span>);</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>UE 5.5+에서 lifetime을 잠시 고정해야 한다면 <code>Pin()</code>으로 strong guard를 얻을 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"UObject/StrongObjectPtrTemplates.h"</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>"UObject/WeakObjectPtrTemplates.h"</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>if</span><span> (</span><span>TStrongObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;</span><span> </span><span>Pinned</span><span> </span><span>=</span><span> </span><span>WeakObject</span><span>.</span><span>Pin</span><span>())</span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>// Pinned가 살아 있는 동안 GC lifetime은 유지된다.</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>// UObject API 자체의 thread safety는 별도 계약이다.</span></div></div><div><div><div>8</div></div><div><span>    </span><span>UseObject</span><span>(</span><span>Pinned</span><span>.</span><span>Get</span><span>());</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>Pin</code>이 object method를 worker-thread-safe하게 만들지는 않는다.</p></section><section><h3><code>TSoftObjectPtr</code><a href="#tsoftobjectptr"><span>#</span></a></h3><p>Soft pointer는 asset path와 weak semantics를 제공한다. Sync/async load 시점을 선택할 수 있지만 load가 끝난 object를 계속 살려 두지는 않는다. <code>FStreamableManager</code>가 completion callback까지 hard reference를 잡더라도, 이후에는 도달 가능한 <code>UPROPERTY() TObjectPtr&lt;T&gt;</code>나 명시적으로 관리되는 handle에 넘겨야 한다.</p></section><section><h3><code>TStrongObjectPtr</code><a href="#tstrongobjectptr"><span>#</span></a></h3><p><code>TStrongObjectPtr</code>는 UPROPERTY 없이 GC가 아는 strong reference를 만들 수 있다. 비-UObject owner나 짧은 pin에는 유용하지만 persistent reflected member의 기본 선택은 아니다.</p><ul>
<li>생성·해제와 reference tracking 비용이 있다.</li>
<li>GC graph/debugging에서 source가 일반 UPROPERTY보다 덜 명확할 수 있다.</li>
<li>UObject가 자기 자신이나 서로를 strong pointer로 잡는 cycle은 wrapper destructor에 도달하지 못해 영구 retention이 될 수 있다.</li>
<li><code>TStrongObjectPtr</code>는 <code>UPROPERTY</code> type이 아니다.</li>
</ul></section></section>
<section><h2>Native owner에서 reference를 report하기<a href="#native-owner에서-reference를-report하기"><span>#</span></a></h2><section><h3>Non-UObject owner는 <code>FGCObject</code><a href="#non-uobject-owner는-fgcobject"><span>#</span></a></h3><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"UObject/GCObject.h"</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>"UObject/Object.h"</span></div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"UObject/ObjectPtr.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"UObject/UObjectGlobals.h"</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>class</span><span> </span><span>FNativeOwner</span><span> </span><span>final</span><span> : </span><span>public</span><span> </span><span>FGCObject</span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>public:</span></div></div><div><div><div>9</div></div><div><span>    </span><span>void</span><span> </span><span>SetObject</span><span>(</span><span>UObject</span><span>*</span><span><span> </span><span>InObject</span><span>)</span></span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>        </span><span>Object</span><span> </span><span>=</span><span> </span><span>InObject</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>    </span><span>void</span><span> </span><span>AddReferencedObjects</span><span>(</span><span>FReferenceCollector</span><span>&amp;</span><span><span> </span><span>Collector</span><span>) </span></span><span>override</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>16</div></div><div><span>        </span><span>Collector</span><span>.</span><span>AddReferencedObject</span><span>(</span><span>Object</span><span>);</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>    </span><span>FString</span><span> </span><span>GetReferencerName</span><span>() </span><span>const</span><span> </span><span>override</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>21</div></div><div><span>        </span><span>return</span><span> </span><span>TEXT</span><span>(</span><span>"FNativeOwner"</span><span>);</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>private:</span></div></div><div><div><div>25</div></div><div><span>    </span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt; </span><span>Object</span><span>;</span></div></div><div><div><div>26</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>FGCObject</code>는 non-UObject native owner용이다. UObject와 동시에 derive하거나, 주소가 이동할 수 있는 <code>TArray&lt;FNativeOwner&gt;</code> 같은 value container에 넣지 않는다. 필요하면 <code>TUniquePtr&lt;FNativeOwner&gt;</code>로 native wrapper의 주소를 안정적으로 소유한다. 이 <code>TUniquePtr</code>는 UObject가 아니라 wrapper의 C++ lifetime을 관리한다.</p></section><section><h3>UObject의 특별한 unreflected edge는 static collector<a href="#uobject의-특별한-unreflected-edge는-static-collector"><span>#</span></a></h3><p>일반 field는 UPROPERTY가 가장 읽기 쉽다. Engine-level special case에서만 UObject의 static <code>AddReferencedObjects</code>를 사용한다. 이 함수는 owner를 독립적으로 root에 올리지 않는다. Owner가 이미 reachable해서 collector가 방문할 때, owner에서 나가는 추가 edge를 report한다.</p><div><div><div><figure><figcaption><span>ManualReferenceOwner.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"UObject/Object.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"UObject/ObjectPtr.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"ManualReferenceOwner.generated.h"</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>8</div></div><div><span>class</span><span> MYGAME_API </span><span>UManualReferenceOwner</span><span> </span><span>final</span><span> : </span><span>public</span><span> </span><span>UObject</span></div></div><div><div><div>9</div></div><div><span>{</span></div></div><div><div><div>10</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>public:</span></div></div><div><div><div>13</div></div><div><span>    </span><span>static</span><span> </span><span>void</span><span> </span><span>AddReferencedObjects</span><span>(</span></div></div><div><div><div>14</div></div><div><span>        </span><span>UObject</span><span>*</span><span><span> </span><span>InThis</span><span>,</span></span></div></div><div><div><div>15</div></div><div><span>        </span><span>FReferenceCollector</span><span>&amp;</span><span><span> </span><span>Collector</span><span>);</span></span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>    </span><span>void</span><span> </span><span>SetReference</span><span>(</span><span>UObject</span><span>*</span><span><span> </span><span>InObject</span><span>)</span></span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>19</div></div><div><span>        </span><span>NativeReference</span><span> </span><span>=</span><span> </span><span>InObject</span><span>;</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>private:</span></div></div><div><div><div>23</div></div><div><span>    </span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt; </span><span>NativeReference</span><span>;</span><span> // intentionally unreflected</span></div></div><div><div><div>24</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><figure><figcaption><span>ManualReferenceOwner.cpp</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"ManualReferenceOwner.h"</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"UObject/UObjectGlobals.h"</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>void</span><span><span> </span><span>UManualReferenceOwner</span><span>::</span></span><span>AddReferencedObjects</span><span>(</span></div></div><div><div><div>6</div></div><div><span>    </span><span>UObject</span><span>*</span><span><span> </span><span>InThis</span><span>,</span></span></div></div><div><div><div>7</div></div><div><span>    </span><span>FReferenceCollector</span><span>&amp;</span><span><span> </span><span>Collector</span><span>)</span></span></div></div><div><div><div>8</div></div><div><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>UManualReferenceOwner</span><span>*</span><span> </span><span>This</span><span> </span><span>=</span></div></div><div><div><div>10</div></div><div><span>        </span><span>CastChecked</span><span>&lt;</span><span>UManualReferenceOwner</span><span>&gt;(</span><span>InThis</span><span>);</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>    </span><span>Collector</span><span>.</span><span>AddReferencedObject</span><span>(</span><span>This</span><span>-&gt;</span><span>NativeReference</span><span>, </span><span>This</span><span>);</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>Super::</span><span>AddReferencedObjects</span><span>(</span><span>InThis</span><span>, </span><span>Collector</span><span>);</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Reference 누락은 조기 수거를, <code>Super</code> 누락은 base-class reference 파손을 만든다. Incremental reachability write barrier 때문에 manually reported storage도 <code>TObjectPtr</code>를 사용한다.</p></section></section>
<section><h2>파괴를 요청하는 API와 GC destruction을 구분한다<a href="#파괴를-요청하는-api와-gc-destruction을-구분한다"><span>#</span></a></h2><p>일반 UObject를 더 이상 쓰지 않는다면 strong reference를 해제하고 GC에 맡긴다. <code>ConditionalBeginDestroy()</code>를 gameplay destruction이나 “GC 힌트”로 직접 호출하지 않는다. 이 함수는 GC가 이미 파괴하기로 결정한 object의 destruction lifecycle을 진행하는 내부 경계다.</p>

<table><thead><tr><th>대상</th><th>정상적인 외부 요청</th><th>실제 memory release</th></tr></thead><tbody><tr><td>일반 <code>UObject</code></td><td>strong reference 해제</td><td>이후 GC lifecycle</td></tr><tr><td><code>AActor</code></td><td><code>Destroy()</code></td><td>end-of-tick 처리 후 GC 가능 상태, 이후 GC</td></tr><tr><td><code>UActorComponent</code></td><td><code>DestroyComponent()</code></td><td>unregister/cleanup 뒤 GC 가능 상태, 이후 GC</td></tr></tbody></table><p><code>AActor::Destroy()</code>는 latent이며 보통 tick 끝에 처리된다. Gameplay 연결 해제는 <code>EndPlay</code>, native resource release는 <code>BeginDestroy</code>/<code>FinishDestroy</code> override의 contract에 맞춘다. 모든 override에서 필요한 <code>Super</code> 호출을 보존한다.</p><p>Null test와 UObject validity도 구분한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> (</span><span>IsValid</span><span>(</span><span>Object</span><span>))</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>UseObject</span><span>(</span><span>Object</span><span>);</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>IsValid</code>는 null과 garbage/pending-destruction 상태를 함께 확인할 때 유용하다. 그러나 이미 수명이 끝난 untracked raw address를 safe handle로 바꾸지는 않는다. Long-lived non-owning field에는 <code>TWeakObjectPtr</code>를 사용해야 한다.</p></section>
<section><h2>GC cluster는 root가 아니다<a href="#gc-cluster는-root가-아니다"><span>#</span></a></h2><p>Cluster는 여러 object를 reachability unit으로 묶어 per-object scanning 비용을 줄이는 고급 최적화다. Cluster root가 global Root Set이라는 뜻은 아니다.</p><ul>
<li>Cluster root 자격은 <code>CanBeClusterRoot</code>, member 자격은 <code>CanBeInCluster</code>, 실제 구성은 <code>CreateCluster</code>와 project setting에 달려 있다.</li>
<li>Cluster member 하나가 외부 strong reference로 reachable하면 cluster 전체가 유지될 수 있다.</li>
<li>큰 cluster나 merged cluster는 over-retention과 일괄 unhash/destruction hitch를 만들 수 있다.</li>
<li>“같은 level”이나 “subobject”라는 사실만으로 무조건 자동 cluster가 된다고 가정하지 않는다.</li>
</ul><p>먼저 Unreal Insights와 GC analytics로 root traversal, cluster, destruction 비용을 측정하고, 실제 object graph와 lifetime이 함께 움직이는 대상을 묶는다.</p><section><h3><code>DisregardForGC</code>는 gameplay opt-in이 아니다<a href="#disregardforgc는-gameplay-opt-in이-아니다"><span>#</span></a></h3><p>Disregard pool은 초기 load 단계의 engine/bootstrap object를 cooked-build permanent pool로 다루는 최적화다. <code>CloseDisregardForGC()</code> 뒤 생성되는 일반 gameplay object를 “평생 쓰는 Blueprint”라는 이유로 임의 등록하는 일상적인 수명 관리 API가 아니다.</p><p>일반적인 게임플레이 코드에서는 root, UPROPERTY, weak/soft reference를 기준으로 객체 수명을 먼저 설계하고, <code>MaxObjectsNotConsideredByGC</code> 같은 permanent-pool 설정은 엔진 시작 단계의 메모리 검사가 필요한 고급 조정으로 분리한다.</p></section></section>
<section><h2>Parallel, incremental, destruction 설정을 분리한다<a href="#parallel-incremental-destruction-설정을-분리한다"><span>#</span></a></h2><p>이 설정들은 모두 pause와 관련 있어 보이지만 서로 다른 단계를 제어한다.</p>

<table><thead><tr><th>Setting</th><th>나누는 작업</th></tr></thead><tbody><tr><td><code>gc.AllowParallelGC</code></td><td>한 collection의 reachability work를 여러 collector thread로 분할</td></tr><tr><td><code>gc.AllowIncrementalReachability</code></td><td>mark/reachability를 여러 frame으로 분할</td></tr><tr><td><code>gc.AllowIncrementalGather</code></td><td>unreachable object gather를 여러 frame으로 분할</td></tr><tr><td><code>gc.IncrementalBeginDestroyEnabled</code></td><td><code>BeginDestroy</code> processing을 time slice</td></tr><tr><td><code>gc.MultithreadedDestructionEnabled</code></td><td>지원되는 object memory free를 worker thread에서 실행</td></tr></tbody></table><p>하나를 켰다고 나머지가 자동으로 켜지거나 thread-safe해지는 것은 아니다.</p></section>
<section><h2>점진적 도달 가능성 분석은 Experimental 기능이다<a href="#점진적-도달-가능성-분석은-experimental-기능이다"><span>#</span></a></h2><p>UE 5.4는 incremental reachability와 incremental gather를 Experimental로 도입했다. mark와 unreachable object gather를 여러 frame으로 나눠 긴 pause를 줄이는 대신, write barrier와 target별 검증이 필요하다.</p><p>공식 예제는 아래처럼 설정한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[ConsoleVariables]</span></div></div><div><div><div>2</div></div><div><span>gc.AllowIncrementalReachability</span><span><span>=</span><span>1</span></span></div></div><div><div><div>3</div></div><div><span>gc.AllowIncrementalGather</span><span><span>=</span><span>1</span></span></div></div><div><div><div>4</div></div><div><span>gc.IncrementalReachabilityTimeLimit</span><span><span>=</span><span>0.002</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>0.002</code>는 2 ms의 예시 budget일 뿐 보편적인 권장값이나 보장된 hard limit가 아니다. 실제 frame 비용을 측정해 project와 target에 맞게 조정한다.</p><p>활성화하려면 아래 조건부터 만족해야 한다.</p><ol>
<li>GC-exposed <code>UPROPERTY</code> raw UObject pointer를 <code>TObjectPtr</code>로 전환한다.</li>
<li>UObject static <code>AddReferencedObjects</code>와 <code>FGCObject</code> storage도 <code>TObjectPtr</code>로 바꾼다.</li>
<li>Async/task code의 object lifetime과 API thread safety를 별도로 검사한다.</li>
<li>PIE뿐 아니라 target server/client build에서 stress test한다.</li>
<li>GC verification과 reference tracking으로 barrier 누락을 찾는다.</li>
</ol></section>
<section><h2>Manual GC는 마지막 수단이다<a href="#manual-gc는-마지막-수단이다"><span>#</span></a></h2><p>Automatic GC를 기본으로 둔다.</p><ul>
<li><code>gc.CollectGarbageEveryFrame N</code>은 매 N frame마다 collection/full-purge request를 넣는 debug stress command다. Production optimization이 아니다.</li>
<li><code>GEngine-&gt;ForceGarbageCollection(true)</code>는 그 line에서 즉시 memory를 비우지 않는다. 다음 가능한 시점에 full purge를 요청한다.</li>
<li><code>CollectGarbage(...)</code> 직접 호출은 다른 thread의 UObject lock을 기다리고 큰 hitch를 만들 수 있다.</li>
</ul><p>Manual collection이 정당한 경우는 loading screen처럼 hitch를 숨길 수 있거나, 측정 결과 page swapping/OOM 위험 직전에 collection하는 편이 낫다는 증거가 있을 때다. Routine gameplay tick에서 강제하지 않는다.</p><p>성능 개선 순서는 다음이 안전하다.</p><ol>
<li>Unreal Insights와 <code>gc.DumpAnalyticsToLog</code>로 mark, gather, purge, cluster 비용을 구분한다.</li>
<li>불필요한 persistent strong edge와 root를 제거한다.</li>
<li>짧게 쏟아지는 transient UObject 생성 churn을 줄이거나 적절히 pool한다.</li>
<li>UObject가 제공하는 reflection, serialization, replication, editor 기능이 필요 없는 data만 value/plain C++ type으로 옮긴다.</li>
<li>Cluster와 incremental setting은 target별 측정 뒤 적용한다.</li>
</ol><p>“Blueprint macro가 function보다 runtime UObject를 항상 더 많이 만든다”거나 “C++로 옮기면 GC object가 자동으로 줄어든다”는 식의 일반화는 editor graph node와 cooked runtime object를 혼동한다. 실제 runtime allocation을 profile한다.</p></section>
<section><h2>최종 점검표<a href="#최종-점검표"><span>#</span></a></h2><ul>
<li>대상이 UObject인지 plain C++ value인지 먼저 구분했는가?</li>
<li>Root에서 target까지 의도한 strong path를 그릴 수 있는가?</li>
<li>Persistent strong field는 <code>UPROPERTY() TObjectPtr&lt;T&gt;</code>인가?</li>
<li><code>Outer</code>를 owner-to-child keepalive로 오해하지 않는가?</li>
<li>Long-lived non-owning field는 <code>TWeakObjectPtr</code>인가?</li>
<li>Soft load 완료 뒤 필요한 strong ownership을 넘겼는가?</li>
<li><code>TStrongObjectPtr</code>의 hidden retention과 cycle을 검토했는가?</li>
<li>Custom <code>AddReferencedObjects</code>가 모든 edge와 <code>Super</code>를 처리하는가?</li>
<li>일반 UObject에 <code>ConditionalBeginDestroy()</code>를 직접 호출하지 않는가?</li>
<li>Actor와 Component는 각각 public <code>Destroy</code> API를 쓰는가?</li>
<li>Cluster를 root나 자동 subobject group으로 오해하지 않는가?</li>
<li>Disregard pool을 gameplay opt-in으로 사용하지 않는가?</li>
<li>Parallel mark, incremental mark, BeginDestroy slicing, memory free를 구분했는가?</li>
<li>Incremental mode 전 모든 GC-reported pointer를 <code>TObjectPtr</code>로 전환했는가?</li>
<li>Forced GC는 측정된 loading/memory boundary에만 있는가?</li>
</ul></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/object-pointers-in-unreal-engine" target="_blank">Epic Games: Object Pointers</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/incremental-garbage-collection-in-unreal-engine" target="_blank">Epic Games: Incremental Garbage Collection</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-object-handling-in-unreal-engine" target="_blank">Epic Games: Unreal Object Handling</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/objects-in-unreal-engine" target="_blank">Epic Games: Objects</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/creating-objects-in-unreal-engine" target="_blank">Epic Games: Creating Objects</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/garbage-collection-settings-in-the-unreal-engine-project-settings" target="_blank">Epic Games: Garbage Collection Settings</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-console-variables-reference" target="_blank">Epic Games: Console Variables Reference</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/common-memory-and-cpu-performance-considerations-in-unreal-engine" target="_blank">Epic Games: Memory and CPU Performance Considerations</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/FGCObject" target="_blank">Epic Games: <code>FGCObject</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/FReferenceCollector/AddReferencedObject" target="_blank">Epic Games: <code>FReferenceCollector::AddReferencedObject</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/UObjectBaseUtility/AddToRoot" target="_blank">Epic Games: <code>AddToRoot</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/AActor/Destroy" target="_blank">Epic Games: <code>AActor::Destroy</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-actor-lifecycle" target="_blank">Epic Games: Actor Lifecycle</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/CollectGarbage" target="_blank">Epic Games: <code>CollectGarbage</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UEngine" target="_blank">Epic Games: <code>UEngine::ForceGarbageCollection</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/asynchronous-asset-loading-in-unreal-engine" target="_blank">Epic Games: Async Asset Loading</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/smart-pointers-in-unreal-engine" target="_blank">Epic Games: Smart Pointers for plain C++ objects</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-5-4-release-notes?application_version=5.4" target="_blank">Epic Games: UE 5.4 Release Notes</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-5-5-release-notes?application_version=5.5" target="_blank">Epic Games: UE 5.5 Release Notes</a></li>
<li><a href="https://youtu.be/Pjad5TVfWrU?si=E7mOJ_pNfSQ4hqx2" target="_blank">Garbage collection in Unity and Unreal Engine</a></li>
<li><a href="https://koreanfoodie.me/1041" target="_blank">언리얼 가비지 컬렉터(GC) 심화 정리</a></li>
<li><a href="https://dev.epicgames.com/community/learning/knowledge-base/dPae/unreal-engine-how-to-register-disregard-for-gc-objects" target="_blank">How to register disregard for GC objects</a></li>
<li><a href="https://scahp.tistory.com/114" target="_blank">[UE5] Garbage Collection</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/tobjectptr-in-unreal-engine/</id>
      <title type="text">언리얼 엔진 TObjectPtr 이해하기: UPROPERTY, GC와 안전한 API 설계</title>
      <published>2025-01-26T00:00:00.000Z</published>
      <updated>2025-01-26T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/tobjectptr-in-unreal-engine/"/>
      <summary type="text">UE5의 TObjectPtr가 raw UObject 포인터와 어떻게 다르며, UPROPERTY·가비지 컬렉션·직렬화·컨테이너·함수 API에서 어떻게 사용하는지 정리합니다.</summary>
      <content type="html"><![CDATA[<p>UE5는 raw UObject pointer member의 대체재로 <code>TObjectPtr</code>를 도입했다. 사용할 때 먼저 구분할 점은 두 가지다.</p>
<ol>
<li><code>TObjectPtr</code>라는 wrapper만 사용한다고 대상 UObject가 자동으로 살아남지는 않는다.</li>
<li>Editor의 optional late resolution은 <code>TSoftObjectPtr</code>처럼 asset을 요청할 때 load하는 public lazy-loading API가 아니다.</li>
</ol>
<p>핵심부터 말하면 이렇다.</p>
<blockquote><p>오래 보관하는 reflected UObject member에는 <code>UPROPERTY() TObjectPtr&lt;T&gt;</code>를 기본으로 사용하고, 함수의 borrowed parameter·return과 짧은 local에는 raw <code>T*</code>를 사용한다.</p></blockquote>
<section><h2>먼저 포인터의 목적을 고른다<a href="#먼저-포인터의-목적을-고른다"><span>#</span></a></h2><p><code>TObjectPtr</code>는 모든 UObject pointer를 대체하는 단일 정답이 아니다.</p>

<table><thead><tr><th>상황</th><th>기본 선택</th><th>대상을 GC에서 살려 두는가?</th><th>asset on-demand load</th></tr></thead><tbody><tr><td>함수 parameter·return, 짧은 local</td><td><code>T*</code></td><td>그 자체로는 아님</td><td>아님</td></tr><tr><td>도달 가능한 <code>UCLASS</code>/<code>USTRUCT</code>의 지속 strong field</td><td><code>UPROPERTY() TObjectPtr&lt;T&gt;</code></td><td>예</td><td>아님</td></tr><tr><td>오래 보관하는 non-owning cache</td><td><code>TWeakObjectPtr&lt;T&gt;</code></td><td>아니오</td><td>아님</td></tr><tr><td>경로로 asset을 참조하고 필요할 때 load</td><td><code>TSoftObjectPtr&lt;T&gt;</code></td><td>load 뒤에도 그 자체로는 아님</td><td>예</td></tr><tr><td>non-UObject owner나 짧은 lifetime pin</td><td><code>TStrongObjectPtr&lt;T&gt;</code></td><td>예</td><td>아님</td></tr><tr><td>plain C++ object의 소유권</td><td><code>TUniquePtr</code>, <code>TSharedPtr</code> 등</td><td>UObject GC와 별개</td><td>아님</td></tr></tbody></table><p><code>TSharedPtr&lt;UObject&gt;</code>나 <code>TUniquePtr&lt;UObject&gt;</code>로 UObject 수명을 소유하면 안 된다. Unreal의 reference-counted smart pointer library는 plain C++ object용이고, UObject는 reflection 기반 reachability graph에서 관리한다.</p></section>
<section><h2><code>TObjectPtr</code>가 해결하는 문제<a href="#tobjectptr가-해결하는-문제"><span>#</span></a></h2><p><code>TObjectPtr&lt;T&gt;</code>는 <code>UObject</code> 계열 object pointer를 담는 64-bit-sized wrapper다. 평범한 읽기와 대입에서는 raw pointer처럼 사용할 수 있지만, engine이 다음 기능을 끼워 넣을 자리를 제공한다.</p><ul>
<li>Editor의 object-handle access tracking과 optional late resolution</li>
<li>cook-time dependency tracking</li>
<li>reflected property serialization과 network serialization</li>
<li>UE 5.4부터 experimental incremental reachability를 위한 assignment write barrier</li>
</ul><p>내부 표현을 “주소 또는 table index라는 정확히 두 상태”로 영구 계약처럼 다루면 안 된다. build configuration과 engine version에 따라 handle 기능이 달라질 수 있고, application code가 의존해야 할 것은 <code>Get()</code>, 대입, 비교, reflected property라는 public contract다.</p><p><code>TObjectPtr</code>는 raw pointer member의 실용적인 교체재이지 다음 기능은 아니다.</p><ul>
<li>reference counting owner</li>
<li>weak reference</li>
<li>soft asset path</li>
<li>thread-safety guard</li>
<li><code>UPROPERTY</code>를 대신하는 GC registration</li>
</ul></section>
<section><h2><code>UPROPERTY</code>가 strong reference를 만든다<a href="#uproperty가-strong-reference를-만든다"><span>#</span></a></h2><p>도달 가능한 owner의 reflected property라야 GC가 reference graph를 자동 순회한다. 아래에서 <code>Current</code>와 <code>Entries</code>는 strong reference지만, 같은 <code>TObjectPtr</code>를 <code>UPROPERTY</code> 없이 member로 두면 자동 순회 대상이 아니다.</p><div><div><div><div><span></span></div><div><span><p>UPROPERTY TObjectPtr</p></span></div><div><span><p>write barrier</p></span></div><div><span><p>자동 순회하지 않음</p></span></div><div><span><p>Root Set / engine roots</p></span></div><div><span><p>도달 가능한 UObject owner</p></span></div><div><span><p>참조 대상 UObject</p></span></div><div><span><p>incremental GC 중 새 값 대입</p></span></div><div><span><p>non-UPROPERTY TObjectPtr</p></span></div>
</div><div><div><span></span></div><div><span><p>UPROPERTY TObjectPtr</p></span></div><div><span><p>write barrier</p></span></div><div><span><p>자동 순회하지 않음</p></span></div><div><span><p>Root Set / engine roots</p></span></div><div><span><p>도달 가능한 UObject owner</p></span></div><div><span><p>참조 대상 UObject</p></span></div><div><span><p>incremental GC 중 새 값 대입</p></span></div><div><span><p>non-UPROPERTY TObjectPtr</p></span></div>
</div></div></div><p>점선은 그 경로만으로 수명이 유지되지 않음을 뜻한다. 대상은 다른 strong path가 있으면 계속 살아 있을 수 있다.</p></section>
<section><h2>지속 member와 함수 경계를 함께 설계하기<a href="#지속-member와-함수-경계를-함께-설계하기"><span>#</span></a></h2><p>다음 class는 저장에는 <code>TObjectPtr</code>, reflected API에는 raw pointer를 사용한다.</p><div><div><div><figure><figcaption><span>TargetRegistry.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"UObject/Object.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"UObject/ObjectPtr.h"</span></div></div><div><div><div>6</div></div><div><span>#include</span><span> </span><span>"TargetRegistry.generated.h"</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>9</div></div><div><span>class</span><span> MYGAME_API </span><span>UTargetRegistry</span><span> </span><span>final</span><span> : </span><span>public</span><span> </span><span>UObject</span></div></div><div><div><div>10</div></div><div><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>public:</span></div></div><div><div><div>14</div></div><div><span>    </span><span>UFUNCTION</span><span>(</span><span>BlueprintCallable</span><span>)</span></div></div><div><div><div>15</div></div><div><span>    </span><span>void</span><span> </span><span>SetCurrent</span><span>(</span><span>UObject</span><span>*</span><span><span> </span><span>InObject</span><span>);</span></span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>    </span><span>UFUNCTION</span><span>(</span><span>BlueprintPure</span><span>)</span></div></div><div><div><div>18</div></div><div><span>    </span><span>UObject</span><span>*</span><span> </span><span>GetCurrent</span><span>() </span><span>const</span><span> { </span><span>return</span><span> </span><span>Current</span><span>.</span><span>Get</span><span>(); }</span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span>    </span><span>UObject</span><span>*</span><span> </span><span>Find</span><span>(</span><span>FName</span><span><span> </span><span>Key</span><span>) </span></span><span>const</span><span>;</span></div></div><div><div><div>21</div></div><div><span>    </span><span>UObject</span><span>*</span><span> </span><span>FindFirstValidStoredObject</span><span>() </span><span>const</span><span>;</span></div></div><div><div><div>22</div></div><div><span>    </span><span>void</span><span> </span><span>VisitStoredObjects</span><span>() </span><span>const</span><span>;</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>private:</span></div></div><div><div><div>25</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>26</div></div><div><span>    </span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;</span><span> </span><span>Current</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>27</div></div><div>
</div></div><div><div><div>28</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>29</div></div><div><span>    </span><span>TMap</span><span>&lt;</span><span>FName</span><span>, </span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;&gt;</span><span> </span><span>Entries</span><span>;</span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>32</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;&gt;</span><span> </span><span>StoredObjects</span><span>;</span></div></div><div><div><div>33</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><figure><figcaption><span>TargetRegistry.cpp</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"TargetRegistry.h"</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>void</span><span><span> </span><span>UTargetRegistry</span><span>::</span></span><span>SetCurrent</span><span>(</span><span>UObject</span><span>*</span><span><span> </span><span>InObject</span><span>)</span></span></div></div><div><div><div>4</div></div><div><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>Current</span><span> </span><span>=</span><span> </span><span>InObject</span><span>;</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>UObject</span><span>*</span><span><span> </span><span>UTargetRegistry</span><span>::</span></span><span>Find</span><span>(</span><span>FName</span><span><span> </span><span>Key</span><span>) </span></span><span>const</span></div></div><div><div><div>9</div></div><div><span>{</span></div></div><div><div><div>10</div></div><div><span>    </span><span>const</span><span> </span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;</span><span>*</span><span> </span><span>Found</span><span> </span><span>=</span><span> </span><span>Entries</span><span>.</span><span>Find</span><span>(</span><span>Key</span><span>);</span></div></div><div><div><div>11</div></div><div><span>    </span><span>return</span><span> </span><span>Found</span><span> </span><span>?</span><span> </span><span>Found</span><span>-&gt;</span><span>Get</span><span>() </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>컨테이너 안 element만 wrapper라고 끝이 아니다. <code>Entries</code> container member 자체도 <code>UPROPERTY</code>여야 자동 GC traversal과 property serialization의 대상이 된다. 단, plain replicated <code>TMap</code>은 지원되지 않으므로 network state에는 <code>TArray</code>/Fast Array 또는 검증된 custom representation을 사용한다.</p><section><h3>왜 <code>UFUNCTION</code>은 raw pointer를 쓰는가<a href="#왜-ufunction은-raw-pointer를-쓰는가"><span>#</span></a></h3><p>UHT는 <code>TObjectPtr</code>를 reflected function parameter와 return value로 허용하지 않는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UFUNCTION</span><span>(</span><span>BlueprintCallable</span><span>)</span></div></div><div><div><div>2</div></div><div><span>void</span><span> </span><span>SetTarget</span><span>(</span><span>UObject</span><span>*</span><span><span> </span><span>InTarget</span><span>);</span></span><span> // supported</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>UFUNCTION</span><span>(</span><span>BlueprintPure</span><span>)</span></div></div><div><div><div>5</div></div><div><span>UObject</span><span>*</span><span> </span><span>GetTarget</span><span>() </span><span>const</span><span>;</span><span> // supported</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>// UFUNCTION()</span></div></div><div><div><div>8</div></div><div><span>// void SetTarget(TObjectPtr&lt;UObject&gt; InTarget); // UHT error</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>// UFUNCTION()</span></div></div><div><div><div>11</div></div><div><span>// TObjectPtr&lt;UObject&gt; GetTarget() const; // UHT error</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>일반 C++ 함수에는 언어 차원에서 <code>TObjectPtr</code> parameter를 사용할 수 있다. 그러나 borrowed object를 받거나 돌려주는 API는 raw pointer가 storage implementation을 숨기고 UFUNCTION과도 일관되며, 불필요한 wrapper copy를 피한다.</p><p>implicit conversion이 template deduction, conditional operator, delegate signature 같은 경계에서 맞지 않으면 의도를 명시한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UseObject</span><span>(</span><span>Current</span><span>.</span><span>Get</span><span>());</span></div></div><div><div><div>2</div></div><div><span>UObject</span><span>*</span><span> </span><span>Raw</span><span> </span><span>=</span><span> </span><span>ToRawPtr</span><span>(</span><span>Current</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>Get()</code>과 <code>ToRawPtr()</code>는 lifetime을 늘리지 않는다. wrapper가 가리키는 raw address를 잠시 꺼낼 뿐이다.</p></section></section>
<section><h2>Container는 raw container와 다른 type이다<a href="#container는-raw-container와-다른-type이다"><span>#</span></a></h2><p><code>TArray&lt;TObjectPtr&lt;UObject&gt;&gt;</code>는 <code>TArray&lt;UObject*&gt;</code>가 아니다. element access 결과와 algorithm signature를 wrapper type에 맞춘다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UObject</span><span>*</span><span><span> </span><span>UTargetRegistry</span><span>::</span></span><span>FindFirstValidStoredObject</span><span>() </span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>const</span><span> </span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;</span><span>*</span><span> </span><span>Found</span><span> </span><span>=</span></div></div><div><div><div>4</div></div><div><span>        </span><span>StoredObjects</span><span>.</span><span>FindByPredicate</span><span>(</span></div></div><div><div><div>5</div></div><div><span><span>            </span></span><span>[](</span><span>const</span><span> </span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>Candidate</span><span>)</span></span></div></div><div><div><div>6</div></div><div><span><span>            </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>                </span><span>return</span><span> </span><span>IsValid</span><span>(</span><span>Candidate</span><span>.</span><span>Get</span><span>());</span></div></div><div><div><div>8</div></div><div><span><span>            </span></span><span>});</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>    </span><span>return</span><span> </span><span>Found</span><span> </span><span>?</span><span> </span><span>Found</span><span>-&gt;</span><span>Get</span><span>() </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>void</span><span><span> </span><span>UTargetRegistry</span><span>::</span></span><span>VisitStoredObjects</span><span>() </span><span>const</span></div></div><div><div><div>14</div></div><div><span>{</span></div></div><div><div><div>15</div></div><div><span>    </span><span>for</span><span> (</span><span>const</span><span> </span><span>TObjectPtr</span><span>&lt;</span><span>UObject</span><span>&gt;</span><span>&amp;</span><span> </span><span>Stored</span><span> : </span><span>StoredObjects</span><span>)</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>17</div></div><div><span>        </span><span>if</span><span> (</span><span>UObject</span><span>*</span><span> </span><span>Object</span><span> </span><span>=</span><span> </span><span>Stored</span><span>.</span><span>Get</span><span>(); </span><span>IsValid</span><span>(</span><span>Object</span><span>))</span></div></div><div><div><div>18</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>19</div></div><div><span>            </span><span>UseObject</span><span>(</span><span>Object</span><span>);</span></div></div><div><div><div>20</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>22</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>위 함수는 const receiver를 사용하므로 <code>FindByPredicate</code>가 <code>const TObjectPtr&lt;UObject&gt;*</code>를 반환한다. Non-const receiver에서는 <code>TObjectPtr&lt;UObject&gt;*</code>다. <code>TMap::Find</code>도 receiver constness를 따르면서 value element pointer를 반환한다.</p><p>기존 API가 raw-pointer container만 받는 경우에는 adapter를 경계에서 짧게 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span> </span><span>ReadObjects</span><span>(</span><span>const</span><span> </span><span>TArray</span><span>&lt;</span><span>UObject</span><span>*</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>Objects</span><span>);</span></span></div></div><div><div><div>2</div></div><div><span>void</span><span> </span><span>ReplaceObjects</span><span>(</span><span>TArray</span><span>&lt;</span><span>UObject</span><span>*</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>Objects</span><span>);</span></span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>ReadObjects</span><span>(</span><span>ObjectPtrDecay</span><span>(</span><span>StoredObjects</span><span>));</span></div></div><div><div><div>5</div></div><div><span>ReplaceObjects</span><span>(</span><span>MutableView</span><span>(</span><span>StoredObjects</span><span>));</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>ObjectPtrDecay</code>는 const access용이다.</li>
<li><code>MutableView</code>는 호출이 끝날 때 wrapper semantics를 복구하고, object-pointer GC barrier가 활성화되어 incremental reachability가 진행 중이면 필요한 barrier action도 수행하는 scoped adapter다.</li>
<li>callee가 raw reference나 pointer를 호출 뒤까지 보관하면 안 된다.</li>
<li>deprecated <code>ToRawPtrTArrayUnsafe</code> 계열로 mutable storage를 노출하지 않는다.</li>
</ul><p>가능하면 callee에 wrapper-aware overload를 추가해 adapter 경계를 줄인다. 단, API의 mutation·ownership contract를 먼저 확인해야 한다.</p></section>
<section><h2>Hard reference와 asset loading은 다른 문제다<a href="#hard-reference와-asset-loading은-다른-문제다"><span>#</span></a></h2><p><code>TObjectPtr</code>의 editor late resolution을 보고 “필요할 때 asset을 load하는 pointer”라고 설명하면 <code>TSoftObjectPtr</code>와 혼동된다. <code>UPROPERTY</code>로 추적·serialize되는 <code>TObjectPtr</code>는 hard object reference이고 사용자 제어 on-demand loading은 지원하지 않는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"UObject/ObjectPtr.h"</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>"UObject/SoftObjectPtr.h"</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>class</span><span> </span><span>UTexture2D</span><span>;</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"UI"</span><span>)</span></div></div><div><div><div>7</div></div><div><span>TSoftObjectPtr</span><span>&lt;</span><span>UTexture2D</span><span>&gt;</span><span> </span><span>IconAsset</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>UPROPERTY</span><span>(</span><span>Transient</span><span>)</span></div></div><div><div><div>10</div></div><div><span>TObjectPtr</span><span>&lt;</span><span>UTexture2D</span><span>&gt;</span><span> </span><span>LoadedIcon</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Header에서는 <code>UTexture2D</code>를 forward-declare할 수 있고, texture method나 object creation이 필요한 <code>.cpp</code>에서는 <code>Engine/Texture2D.h</code>를 include한다.</p><p><code>IconAsset</code>은 soft path로 load 시점을 선택한다. async load가 끝난 뒤 <code>LoadedIcon = IconAsset.Get()</code>처럼 도달 가능한 reflected strong field로 옮기면 owner가 살아 있는 동안 target도 유지된다. soft pointer 자체는 load가 끝난 object를 계속 살려 두지 않는다.</p><p>Hard reference는 cook dependency와 package loading에도 영향을 줄 수 있다. 단지 C++ 표기만 바뀌는 문제가 아니라 content dependency graph의 선택이다.</p></section>
<section><h2>Forward declaration과 interface<a href="#forward-declaration과-interface"><span>#</span></a></h2><p>UObject type은 persistent member의 template argument로 forward declaration할 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>class</span><span> </span><span>UMyAsset</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>UPROPERTY</span><span>()</span></div></div><div><div><div>4</div></div><div><span>TObjectPtr</span><span>&lt;</span><span>UMyAsset</span><span>&gt;</span><span> </span><span>Asset</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>method를 호출하거나 object를 생성하는 <code>.cpp</code>에서는 complete definition header를 include한다. <code>TObjectPtr&lt;int&gt;</code>처럼 UObject가 아닌 type이나 <code>TObjectPtr&lt;IMyInterface&gt;</code> 같은 Unreal interface pointer는 올바른 대상이 아니다. Reflected interface property에는 <code>TScriptInterface&lt;IMyInterface&gt;</code>를 사용하고, Blueprint-only implementation까지 호출할 때는 <code>ImplementsInterface</code>와 generated <code>Execute_</code> wrapper를 사용한다.</p></section>
<section><h2>Null, validity, resolution은 서로 다르다<a href="#null-validity-resolution은-서로-다르다"><span>#</span></a></h2><p>세 질문을 섞지 않는다.</p>

<table><thead><tr><th>질문</th><th>예시</th><th>의미</th></tr></thead><tbody><tr><td>pointer가 null인가?</td><td><code>Ptr == nullptr</code></td><td>reference가 설정됐는가</td></tr><tr><td>UObject가 사용 가능한가?</td><td><code>IsValid(Ptr.Get())</code></td><td>null이 아니고 garbage/pending-kill 상태가 아닌가</td></tr><tr><td>object handle이 resolve됐는가?</td><td><code>Ptr.IsResolved()</code></td><td>internal handle resolution 상태</td></tr></tbody></table><p><code>IsResolved()</code>는 null/validity test가 아니다. unresolved hard handle도 유효한 reference일 수 있다. 반대로 이미 dangling이 된 untracked raw address에 <code>IsValid</code>를 호출한다고 안전한 weak handle로 바뀌지는 않는다. 장기 non-owning field에는 <code>TWeakObjectPtr</code>를 사용한다.</p><p><code>TObjectPtr</code>도 object의 method와 data를 thread-safe하게 만들지 않는다. worker thread에서는 object lifetime과 object access 자체의 thread contract를 별도로 지켜야 한다.</p></section>
<section><h2>Serialization, replication, SaveGame<a href="#serialization-replication-savegame"><span>#</span></a></h2><p>Reflected <code>TObjectPtr</code> property는 raw UObject property와 같은 object-reference serialization 경로를 사용하지만, 실제 동작은 여전히 <code>UPROPERTY</code> specifier와 owner에 의해 결정된다.</p><ul>
<li><code>Transient</code>, <code>SaveGame</code>, <code>Replicated</code>, <code>ReplicatedUsing</code> 같은 specifier는 그대로 중요하다.</li>
<li>raw <code>UPROPERTY UObject*</code>를 <code>TObjectPtr&lt;UObject&gt;</code>로 바꿀 때 property 이름을 동시에 바꾸지 않는다.</li>
<li>기존 asset load/save, PIE duplication, cook/package, network replication을 각각 검증한다.</li>
<li>object reference가 replicated되어도 target object 자체의 class, networking support, relevancy가 자동으로 해결되는 것은 아니다.</li>
</ul><p>Unreal의 property serialization과 GC가 wrapper를 이해한다는 뜻이지, 모든 저장·network policy가 자동으로 선택된다는 뜻은 아니다.</p></section>
<section><h2>Raw pointer member 전환 순서<a href="#raw-pointer-member-전환-순서"><span>#</span></a></h2><p>대규모 전환은 단순 search-and-replace보다 다음 순서가 안전하다.</p><ol>
<li>Persistent reflected UObject member를 식별한다.</li>
<li><code>UnrealObjectPtrTool</code>은 먼저 preview mode로 실행하고 diff를 review한다.</li>
<li>함수 parameter·return, local, third-party ABI는 raw pointer로 유지한다.</li>
<li>Container lookup과 range loop의 deduced type을 고친다.</li>
<li>Legacy raw-container API에는 <code>ObjectPtrDecay</code> 또는 scoped <code>MutableView</code>를 둔다.</li>
<li>UFUNCTION/RPC signature에 wrapper가 남지 않았는지 UHT build로 확인한다.</li>
<li>Editor asset load/save, cook, packaged build, replication을 검증한다.</li>
<li>Incremental GC를 사용한다면 모든 GC-reported reference path가 <code>TObjectPtr</code>인지 별도로 검사한다.</li>
</ol><p>Tool이 자동으로 바꾼 code도 ownership intent를 결정해 주지는 않는다. 특히 unreflected member, static storage, <code>FGCObject</code>, custom <code>AddReferencedObjects</code>, container adapter는 사람이 review해야 한다.</p></section>
<section><h2>최종 점검표<a href="#최종-점검표"><span>#</span></a></h2><ul>
<li>Persistent strong member는 <code>UPROPERTY() TObjectPtr&lt;T&gt;</code>인가?</li>
<li>Container member 자체에도 <code>UPROPERTY</code>가 있는가?</li>
<li>함수의 borrowed parameter와 return은 raw pointer인가?</li>
<li>UFUNCTION/RPC에 <code>TObjectPtr</code> parameter나 return이 남지 않았는가?</li>
<li>Hard reference가 필요한지, soft on-demand load가 필요한지 구분했는가?</li>
<li>Long-lived non-owning cache는 <code>TWeakObjectPtr</code>인가?</li>
<li><code>IsResolved</code>를 validity test로 오용하지 않는가?</li>
<li>Worker thread에서 wrapper를 thread-safety 보증처럼 사용하지 않는가?</li>
<li>Container lookup 결과를 <code>TObjectPtr&lt;T&gt;*</code>로 받는가?</li>
<li>Mutable raw-container adapter의 lifetime이 한 호출 안으로 제한되는가?</li>
<li>Property 이름과 specifier를 보존하고 기존 asset/cook/network를 검사했는가?</li>
<li>Incremental GC configuration에서 write-barrier path를 검증했는가?</li>
</ul></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/object-pointers-in-unreal-engine" target="_blank">Epic Games: Object Pointers</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-5-migration-guide#c++objectpointerproperties" target="_blank">Epic Games: UE5 Migration Guide - C++ Object Pointer Properties</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/incremental-garbage-collection-in-unreal-engine?lang=en-US" target="_blank">Epic Games: Incremental Garbage Collection</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-5-4-release-notes?application_version=5.4" target="_blank">Epic Games: UE 5.4 Release Notes</a></li>
<li><a href="https://forums.unrealengine.com/t/ufunctions-cannot-take-a-tobjectptr-as-a-parameter-why/241174" target="_blank">Epic Developer Community: UFunctions cannot take a TObjectPtr as a parameter. Why?</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-animation-metadata/</id>
      <title type="text">Unreal Engine 애니메이션 메타데이터: UAnimMetaData와 Metadata Curve</title>
      <published>2024-11-20T00:00:00.000Z</published>
      <updated>2024-11-20T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-animation-metadata/"/>
      <summary type="text">UAnimMetaData를 애니메이션 에셋에 저장해 런타임에서 조회하고 에디터 자동화로 관리하며, Metadata Curve·Notify·Animation Attribute·Asset Metadata와 구분하는 기준을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>애니메이션에 부가 데이터를 붙일 때 가장 중요한 설계 질문은 <strong>어떤 범위와 시점에서 어떤 데이터를 소비할 것인가</strong>이다.</p>
<p>Unreal에는 이름이 비슷한 metadata system이 여러 개 있다.</p>

<table><thead><tr><th>system</th><th>저장 단위</th><th>적합한 데이터</th><th>runtime/평가 경계</th></tr></thead><tbody><tr><td><code>UAnimMetaData</code></td><td>Animation Asset 또는 Montage section의 UObject subobject</td><td>구조화된 asset-level 설정</td><td>Asset이 load된 뒤 game-thread code에서 조회</td></tr><tr><td>Metadata Curve</td><td>source animation의 constant float curve</td><td>pose와 함께 blend되는 tag-like 값</td><td>AnimGraph 평가 결과에서 curve 값 조회</td></tr><tr><td>Anim Notify / Notify State</td><td>timeline의 순간 또는 구간</td><td>특정 시점의 event와 state window</td><td>animation playback 중 event 발생</td></tr><tr><td>Animation Attribute</td><td>animation sample의 typed, keyed data</td><td>bone/시간에 연결된 custom 값</td><td>pose evaluation과 blending pipeline</td></tr><tr><td>Asset Metadata</td><td>Asset Registry/editor key-value</td><td>검색, import, content pipeline tag</td><td>runtime gameplay 직접 조회용이 아님</td></tr></tbody></table>
<p>“애니메이션 전체에 root-motion 권장 속도 1.15를 붙인다”면 <code>UAnimMetaData</code>, “현재 blend된 pose가 traversal animation 성격을 얼마나 갖는지 AnimGraph에서 읽는다”면 Metadata Curve가 더 자연스럽다. 공격 판정이 시작되는 정확한 구간이라면 Notify State가 맞다.</p>
<section><h2><code>UAnimMetaData</code>는 Animation Asset에 저장되는 UObject다<a href="#uanimmetadata는-animation-asset에-저장되는-uobject다"><span>#</span></a></h2><p><code>UAnimationAsset</code>은 <code>UAnimMetaData</code> instance 배열을 보관한다. metadata object는 animation asset의 subobject로 저장되므로 class property를 사용해 구조화된 data를 만들 수 있다.</p><p>주로 아래 용도로 쓴다.</p><ul>
<li>locomotion clip의 gameplay category와 권장 play rate</li>
<li>특정 animation을 처리하는 custom pipeline option</li>
<li>export/import source 정보와 project-specific validation flag</li>
<li>animation selection system이 asset load 뒤 읽을 작은 configuration</li>
</ul><p>Asset metadata를 매 frame 찾기보다 animation을 선택하거나 state를 초기화하는 시점에 읽고, 자주 쓰는 primitive 값을 consumer에 cache하는 편이 명확하다.</p></section>
<section><h2>Project 전용 metadata class 만들기<a href="#project-전용-metadata-class-만들기"><span>#</span></a></h2><div><div><div><figure><figcaption><span>TraversalAnimMetaData.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"Animation/AnimMetaData.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"GameplayTagContainer.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"TraversalAnimMetaData.generated.h"</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>UCLASS</span><span>(</span><span>BlueprintType</span><span>, </span><span>Blueprintable</span><span>, </span><span>EditInlineNew</span><span>)</span></div></div><div><div><div>8</div></div><div><span>class</span><span> MYGAME_API </span><span>UTraversalAnimMetaData</span><span> </span><span>final</span><span> : </span><span>public</span><span> </span><span>UAnimMetaData</span></div></div><div><div><div>9</div></div><div><span>{</span></div></div><div><div><div>10</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>public:</span></div></div><div><div><div>13</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>BlueprintReadOnly</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Traversal"</span><span>)</span></div></div><div><div><div>14</div></div><div><span>    </span><span>bool</span><span> </span><span>bSupportsRootMotion</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span></div></div><div><div><div>17</div></div><div><span>        </span><span>EditAnywhere</span><span>,</span></div></div><div><div><div>18</div></div><div><span>        </span><span>BlueprintReadOnly</span><span>,</span></div></div><div><div><div>19</div></div><div><span>        </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Traversal"</span><span>,</span></div></div><div><div><div>20</div></div><div><span><span>        </span></span><span>meta</span><span> </span><span>=</span><span> (</span><span>ClampMin</span><span> </span><span>=</span><span> </span><span>"0.0"</span><span>))</span></div></div><div><div><div>21</div></div><div><span>    </span><span>float</span><span> </span><span>RecommendedPlayRate</span><span> </span><span>=</span><span> </span><span><span>1.0</span><span>f</span></span><span>;</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>BlueprintReadOnly</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Traversal"</span><span>)</span></div></div><div><div><div>24</div></div><div><span>    </span><span>FGameplayTag</span><span> </span><span>TraversalType</span><span>;</span></div></div><div><div><div>25</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>FGameplayTag</code>를 사용하므로 이 예제 module은 <code>GameplayTags</code> dependency도 선언한다. Public header에 type이 value로 노출되므로 Public dependency다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>PublicDependencyModuleNames</span><span>.</span></span><span>AddRange</span><span>(</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>new[]</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>4</div></div><div><span>        </span><span>"Core"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>        </span><span>"CoreUObject"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>        </span><span>"Engine"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>        </span><span>"GameplayTags"</span><span>,</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>9</div></div><div><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>단순 bool과 float만 필요하면 GameplayTags 부분은 제거할 수 있다.</p></section>
<section><h2>Runtime에서 null-safe하게 조회하기<a href="#runtime에서-null-safe하게-조회하기"><span>#</span></a></h2><div><figure><figcaption><span>TraversalAnimMetaData.cpp</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"TraversalAnimMetaData.h"</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"Animation/AnimationAsset.h"</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>const</span><span> </span><span>UTraversalAnimMetaData</span><span>*</span><span> </span><span>FindTraversalMetaData</span><span>(</span></div></div><div><div><div>6</div></div><div><span>    </span><span>const</span><span> </span><span>UAnimationAsset</span><span>*</span><span><span> </span><span>AnimationAsset</span><span>)</span></span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>return</span><span> </span><span>AnimationAsset</span></div></div><div><div><div>9</div></div><div><span>        </span><span>?</span><span> </span><span>AnimationAsset</span><span>-&gt;</span><span>FindMetaDataByClass</span><span>&lt;</span><span>UTraversalAnimMetaData</span><span>&gt;</span><span>()</span></div></div><div><div><div>10</div></div><div><span>        </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>사용자는 metadata가 없는 Asset을 정상 입력으로 처리해야 한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>float</span><span> </span><span>GetRecommendedPlayRate</span><span>(</span><span>const</span><span> </span><span>UAnimationAsset</span><span>*</span><span><span> </span><span>AnimationAsset</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>const</span><span> </span><span>UTraversalAnimMetaData</span><span>*</span><span> </span><span>MetaData</span><span> </span><span>=</span></div></div><div><div><div>4</div></div><div><span>        </span><span>FindTraversalMetaData</span><span>(</span><span>AnimationAsset</span><span>);</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>    </span><span>return</span><span> </span><span>MetaData</span></div></div><div><div><div>7</div></div><div><span>        </span><span>?</span><span> FMath::</span><span>Max</span><span>(</span><span><span>0.0</span><span>f</span></span><span>, </span><span>MetaData</span><span>-&gt;</span><span>RecommendedPlayRate</span><span>)</span></div></div><div><div><div>8</div></div><div><span>        </span><span>:</span><span> </span><span><span>1.0</span><span>f</span></span><span>;</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UAnimMetaData" target="_blank"><code>UAnimMetaData</code></a>와 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UAnimationAsset" target="_blank"><code>UAnimationAsset</code></a>은 runtime <code>Engine</code> module API다. 그러나 object 접근이 가능하다고 모든 thread에서 안전한 것은 아니다.</p><section><h3>AnimGraph 평가 중 직접 찾지 않기<a href="#animgraph-평가-중-직접-찾지-않기"><span>#</span></a></h3><p><code>UAnimationAsset::FindMetaDataByClass</code>를 animation worker thread에서 안전하게 호출할 수 있다고 가정하지 않는다. UObject metadata를 AnimGraph 평가 중 매번 직접 찾기보다 경계를 아래처럼 분리한다.</p><ol>
<li>game thread의 asset 선택·initialization 단계에서 metadata를 읽는다.</li>
<li>필요한 bool, float, enum/tag를 animation instance/proxy의 thread-safe state로 복사한다.</li>
<li>worker-thread evaluation에서는 cache된 value만 사용한다.</li>
</ol><p>이는 성능 최적화보다 object access와 animation evaluation의 thread boundary를 명시하는 설계다.</p></section></section>
<section><h2>같은 class의 metadata는 여러 개일 수 있다<a href="#같은-class의-metadata는-여러-개일-수-있다"><span>#</span></a></h2><p>Blueprint의 <code>FindMetaDataByClass</code>가 단일 object를 반환하더라도 저장 구조와 editor API가 class별 singleton을 보장하는 것은 아니다.</p><ul>
<li><code>UAnimationAsset::MetaData</code>는 array다.</li>
<li><code>FindMetaDataByClass</code>는 일치하는 <strong>첫 instance</strong>를 반환한다.</li>
<li>editor의 <code>GetMetaDataOfClass</code>는 일치하는 instance <strong>array</strong>를 반환한다.</li>
</ul><p>duplicate도 표현할 수 있다. 첫 항목만 쓰는 gameplay code가 duplicate 입력을 받으면 어떤 object가 선택되는지 content ordering에 의존한다.</p><p>Project가 class별 singleton을 원한다면 명시적으로 정책을 만든다.</p><ul>
<li>Editor add tool에서 기존 instance를 먼저 검색해 추가를 거부한다.</li>
<li>Data Validation에서 같은 class count가 1을 넘으면 error로 보고한다.</li>
<li>duplicate를 모두 허용한다면 first-match API 대신 array를 순회하고 ordering/merge rule을 정의한다.</li>
</ul><p>“API 이름이 단수라서 하나일 것”이라는 추론을 serialization invariant로 사용하지 않는다.</p></section>
<section><h2>Editor에서 추가·삭제·검증하기<a href="#editor에서-추가삭제검증하기"><span>#</span></a></h2><p><code>UAnimationBlueprintLibrary</code>는 editor automation API다. Editor Utility Widget, Animation Modifier, commandlet/editor module에서 metadata를 일괄 추가·삭제·검사할 수 있다.</p><p>runtime module이 아니라 별도 Editor module에 dependency를 둔다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>PrivateDependencyModuleNames</span><span>.</span></span><span>AddRange</span><span>(</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>new[]</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>4</div></div><div><span>        </span><span>"Core"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>        </span><span>"CoreUObject"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>        </span><span>"Engine"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>        </span><span>"AnimationBlueprintLibrary"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>        </span><span>"MyGame"</span><span>, </span><span>// metadata class를 선언한 runtime module 이름으로 교체</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>10</div></div><div><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>주로 아래 operation을 쓴다.</p>

<table><thead><tr><th>목적</th><th>Editor API</th></tr></thead><tbody><tr><td>class를 지정해 instance 생성·추가</td><td><code>AddMetaData</code></td></tr><tr><td>이미 만든 object 추가</td><td><code>AddMetaDataObject</code></td></tr><tr><td>전체 조회</td><td><code>GetMetaData</code></td></tr><tr><td>class별 전체 조회</td><td><code>GetMetaDataOfClass</code></td></tr><tr><td>class 존재 확인</td><td><code>ContainsMetaDataOfClass</code></td></tr><tr><td>instance 또는 class별 제거</td><td><code>RemoveMetaData</code>, <code>RemoveMetaDataOfClass</code></td></tr><tr><td>전체 제거</td><td><code>RemoveAllMetaData</code></td></tr></tbody></table><p>직접 작성한 C++ editor tool의 기본 흐름은 이렇다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// Editor module only</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>"AnimationBlueprintLibrary.h"</span></div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"Animation/AnimationAsset.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"TraversalAnimMetaData.h"</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>UAnimMetaData</span><span>*</span><span> </span><span>AddTraversalMetaDataIfMissing</span><span>(</span></div></div><div><div><div>7</div></div><div><span>    </span><span>UAnimationAsset</span><span>*</span><span><span> </span><span>AnimationAsset</span><span>)</span></span></div></div><div><div><div>8</div></div><div><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>AnimationAsset</span><span>)</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>        </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>    </span><span>if</span><span> (</span><span>UTraversalAnimMetaData</span><span>*</span><span> </span><span>Existing</span><span> </span><span>=</span></div></div><div><div><div>15</div></div><div><span>            </span><span>AnimationAsset</span></div></div><div><div><div>16</div></div><div><span>                </span><span>-&gt;</span><span>FindMetaDataByClass</span><span>&lt;</span><span>UTraversalAnimMetaData</span><span>&gt;())</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>18</div></div><div><span>        </span><span>return</span><span> </span><span>Existing</span><span>;</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>    </span><span>AnimationAsset</span><span>-&gt;</span><span>Modify</span><span>();</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>    </span><span>UAnimMetaData</span><span>*</span><span> </span><span>Added</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>UAnimationBlueprintLibrary::</span><span>AddMetaData</span><span>(</span></div></div><div><div><div>25</div></div><div><span>        </span><span>AnimationAsset</span><span>,</span></div></div><div><div><div>26</div></div><div><span><span>        </span></span><span>UTraversalAnimMetaData::</span><span>StaticClass</span><span>(),</span></div></div><div><div><div>27</div></div><div><span>        </span><span>Added</span><span>);</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span>    </span><span>if</span><span> (</span><span>Added</span><span>)</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>31</div></div><div><span>        </span><span>AnimationAsset</span><span>-&gt;</span><span>MarkPackageDirty</span><span>();</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>33</div></div><div>
</div></div><div><div><div>34</div></div><div><span>    </span><span>return</span><span> </span><span>Added</span><span>;</span></div></div><div><div><div>35</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>실제 batch tool에서는 다음도 처리한다.</p><ul>
<li><code>FScopedTransaction</code>과 undo/redo</li>
<li>duplicate class 정책</li>
<li>read-only/source-control checkout</li>
<li>dirty package 목록과 명시적인 save</li>
<li>실패 log와 idempotent 재실행</li>
<li>Animation Modifier를 반복 적용할 때 versioning과 reapply 정책</li>
</ul><p><code>MarkPackageDirty</code>는 file save가 아니다. Editor Utility나 commandlet이 변경을 디스크에 기록할지 별도로 결정해야 한다.</p></section>
<section><h2>Metadata Curve는 constant source curve다<a href="#metadata-curve는-constant-source-curve다"><span>#</span></a></h2><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-curves-in-unreal-engine#metadatacurves" target="_blank">Animation Curves 문서</a>의 Metadata Curve는 source animation에서 모든 key가 <code>1.0</code>인 read-only curve type이다. curve가 없으면 해당 source에는 flag가 없고, 있으면 source clip은 1.0을 제공한다.</p><p></p><figure><img alt="Animation Asset Editor의 Metadata Curve track" loading="lazy" width="732" height="265" src="/_astro/metadata-curve-track.5pYyUogx_48bqn.webp" /><figcaption>Animation Asset Editor의 Metadata Curve track</figcaption></figure><p></p><p><em>Metadata Curve의 constant track 예시다. UI 배치는 engine version에 따라 달라질 수 있다.</em></p><p>하지만 최종 AnimGraph 값이 항상 정확히 0 또는 1이라고 가정하면 안 된다. animation pose와 curve가 blend되면 weight와 curve blend option에 따라 중간값이나 override 결과가 나올 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>float</span><span> </span><span>TraversalWeight</span><span> </span><span>=</span></div></div><div><div><div>2</div></div><div><span>    </span><span>AnimInstance</span><span>-&gt;</span><span>GetCurveValue</span><span>(</span><span>TEXT</span><span>(</span><span>"Traversal"</span><span>));</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>const</span><span> </span><span>bool</span><span> </span><span>bTraversalActive</span><span> </span><span>=</span><span> </span><span>TraversalWeight</span><span> </span><span>&gt;=</span><span> </span><span><span>0.5</span><span>f</span></span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>0.5f</code>는 engine의 보편 규칙이 아니라 이 gameplay system이 정한 threshold다. 연속 weight가 유용하면 bool로 바꾸지 않고 그대로 사용한다. curve가 없을 때의 fallback도 gameplay code에서 명시한다.</p><p>아래 상황에는 Metadata Curve가 잘 맞는다.</p><ul>
<li>state machine transition이나 AnimGraph branch가 현재 pose mix의 성격을 읽음</li>
<li>animation layer가 특정 curve flag를 downstream graph로 전달함</li>
<li>event 시점이 아니라 clip 전체에 걸친 tag-like float가 필요함</li>
</ul><p>반대로 gameplay에서 구조화된 여러 field를 읽거나 Asset을 평가하지 않은 상태에서도 설정이 필요하면 <code>UAnimMetaData</code>가 낫다.</p></section>
<section><h2>Notify, Attribute, Asset Metadata와 구분하기<a href="#notify-attribute-asset-metadata와-구분하기"><span>#</span></a></h2><section><h3>Anim Notify와 Notify State<a href="#anim-notify와-notify-state"><span>#</span></a></h3><p>특정 frame의 footstep, projectile spawn, combo window처럼 <strong>언제</strong>가 핵심이면 Notify를 사용한다. clip 전체에 붙은 bool metadata를 Notify 하나로 흉내 내면 seek, loop, blend, skipped event 정책이 불필요하게 복잡해진다.</p></section><section><h3>Animation Attribute<a href="#animation-attribute"><span>#</span></a></h3><p>FBX custom attribute나 project-defined typed value를 bone·keyframe과 연결하고 pose evaluation/blending에 참여시키려면 Animation Attribute가 맞다. Asset-level UObject config와 목적이 다르다.</p></section><section><h3>일반 Asset Metadata<a href="#일반-asset-metadata"><span>#</span></a></h3><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/asset-metadata-in-unreal-engine" target="_blank">Asset Metadata</a>는 import pipeline, Asset Registry 검색, editor scripting을 위한 key-value tag다. 문서는 cooked gameplay runtime에서 직접 접근하는 data store로 사용하지 말라고 구분한다. gameplay가 읽어야 하는 animation-specific 구조화 data라면 <code>UAnimMetaData</code>, curve, Data Asset 등을 사용한다.</p></section></section>
<section><h2>Montage section metadata<a href="#montage-section-metadata"><span>#</span></a></h2><p>Animation Asset 전체가 아니라 Montage section별로 다른 설정이 필요할 수도 있다. <code>FCompositeSection</code>은 section metadata 배열을 가질 수 있다.</p><p>예를 들어 한 Montage 안의 <code>Windup</code>, <code>Attack</code>, <code>Recover</code> section에 서로 다른 traversal/interrupt rule을 붙일 수 있다. 이 경우도 같은 class duplicate와 first-match policy를 section 단위로 정의해야 한다. Section index/name이 유효한지 확인하고, Montage가 수정될 때 metadata mapping validation을 수행한다.</p><p>Section metadata와 Anim Notify State도 구분한다.</p><ul>
<li>Section 전체의 구조화된 설정: section <code>UAnimMetaData</code></li>
<li>timeline 안 정확한 활성 구간과 event: Notify State</li>
</ul></section>
<section><h2>Runtime과 Editor 경계 요약<a href="#runtime과-editor-경계-요약"><span>#</span></a></h2>

<table><thead><tr><th>작업</th><th>Runtime module</th><th>Editor module</th></tr></thead><tbody><tr><td><code>FindMetaDataByClass</code>로 읽기</td><td>가능</td><td>가능</td></tr><tr><td>metadata object의 field 사용</td><td>가능</td><td>가능</td></tr><tr><td><code>UAnimationBlueprintLibrary</code> batch 수정</td><td>불가</td><td>가능</td></tr><tr><td>Editor Utility Widget / Animation Modifier</td><td>불가</td><td>가능</td></tr><tr><td>transaction, package dirty, save</td><td>gameplay 책임 아님</td><td>tool이 명시적으로 관리</td></tr><tr><td><code>GetCurveValue</code>로 평가 curve 읽기</td><td>가능</td><td>preview에서도 가능</td></tr></tbody></table><p>Editor API header를 runtime module의 Public header에 include하거나 Shipping dependency에 넣지 않는다. Runtime에서 필요한 data와 editor authoring tool을 module로 분리한다.</p></section>
<section><h2>검증 체크리스트<a href="#검증-체크리스트"><span>#</span></a></h2><ul>
<li>metadata가 Asset 전체인지 Montage section 단위인지 결정했는가?</li>
<li>같은 metadata class를 여러 개 허용할지 정책을 정했는가?</li>
<li>first-match API에 duplicate ordering을 맡기지 않았는가?</li>
<li>runtime lookup에서 null과 invalid field range를 처리했는가?</li>
<li>worker-thread AnimGraph에서 UObject metadata를 직접 조회하지 않는가?</li>
<li>Editor automation을 Editor module에 격리했는가?</li>
<li>transaction, source-control, dirty package, save를 처리했는가?</li>
<li>Metadata Curve의 final 값이 blend될 수 있음을 고려했는가?</li>
<li>bool threshold 또는 continuous weight policy를 명시했는가?</li>
<li>time event라면 Notify, keyed typed data라면 Attribute를 검토했는가?</li>
<li>일반 Asset Metadata를 runtime gameplay data로 사용하지 않았는가?</li>
<li>cook과 packaged build에서 필요한 metadata class/module이 포함되는가?</li>
</ul></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UAnimMetaData" target="_blank">Epic Games: <code>UAnimMetaData</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UAnimationAsset" target="_blank">Epic Games: <code>UAnimationAsset</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/BlueprintAPI/Animation/FindMetaDatabyClass" target="_blank">Epic Games: Find Meta Data by Class</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Editor/AnimationBlueprintLibrary/UAnimationBlueprintLibrary" target="_blank">Epic Games: <code>UAnimationBlueprintLibrary</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/BlueprintAPI/AnimationBlueprintLibrary/MetaData/AddMetaData" target="_blank">Epic Games: Add Meta Data</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/BlueprintAPI/AnimationBlueprintLibrary/MetaData/GetMetaDataOfClass" target="_blank">Epic Games: Get Meta Data Of Class</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-modifiers-in-unreal-engine" target="_blank">Epic Games: Animation Modifiers</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-curves-in-unreal-engine#metadatacurves" target="_blank">Epic Games: Animation Curves and Metadata Curves</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UAnimInstance/GetCurveValue" target="_blank">Epic Games: <code>UAnimInstance::GetCurveValue</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-blueprint-blend-nodes-in-unreal-engine" target="_blank">Epic Games: Animation Blend Nodes</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UAnimMontage" target="_blank">Epic Games: <code>UAnimMontage</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/FCompositeSection" target="_blank">Epic Games: <code>FCompositeSection</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-attributes-in-unreal-engine" target="_blank">Epic Games: Animation Attributes</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-notifies-in-unreal-engine" target="_blank">Epic Games: Animation Notifies</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/asset-metadata-in-unreal-engine" target="_blank">Epic Games: Asset Metadata</a></li>
<li><a href="https://www.youtube.com/watch?v=Mmz3-Oz9z20" target="_blank">UE5 How to hold data in Animation Assets (Anim Meta Data)</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-instanced-properties/</id>
      <title type="text">UE 5.3의 Instanced UObject와 Instanced Struct</title>
      <published>2024-11-18T00:00:00.000Z</published>
      <updated>2024-11-18T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-instanced-properties/"/>
      <summary type="text">UE 5.3에서 owner별로 편집 가능한 다형 데이터를 만들 때 Instanced UObject와 FInstancedStruct·TInstancedStruct를 고르는 기준, 설정 방법과 직렬화 경계를 설명합니다.</summary>
      <content type="html"><![CDATA[<p>한 Asset 안에서 서로 다른 파생 type의 설정 조각을 추가하고 Details 패널에서 바로 편집하고 싶을 때가 있다. 예를 들면 아이템마다 <code>StackLimit</code>, <code>Cooldown</code>, <code>GrantedAbility</code> 규칙을 원하는 조합으로 갖게 하는 경우다.</p>
<p>이 글에서 사용한 Unreal Engine 5.3에서는 크게 두 계열을 선택할 수 있다.</p>
<ul>
<li><strong>Instanced UObject</strong>: object identity, Outer, virtual/UFUNCTION, interface, delegate, GC object가 필요할 때</li>
<li><strong>Instanced Struct</strong>: 여러 <code>USTRUCT</code> type 중 하나를 value payload로 저장하고 복사·직렬화·DataTable에서 쓰고 싶을 때</li>
</ul>
<section><h2>무엇을 고를까<a href="#무엇을-고를까"><span>#</span></a></h2>

<table><thead><tr><th>요구</th><th>권장 type</th></tr></thead><tbody><tr><td>고정 schema의 단순 data</td><td>일반 <code>USTRUCT</code></td></tr><tr><td>여러 native USTRUCT 파생 payload, value semantics, DataTable</td><td><code>TInstancedStruct&lt;Base&gt;</code></td></tr><tr><td>compile time에 base를 고정할 수 없는 generic property</td><td><code>FInstancedStruct</code> + <code>BaseStruct</code> metadata</td></tr><tr><td>object identity, virtual function, UFUNCTION, interface, delegate</td><td>Instanced UObject</td></tr><tr><td>여러 owner가 공유하는 독립 Asset</td><td><code>UDataAsset</code> 또는 <code>UPrimaryDataAsset</code> hard/soft reference</td></tr></tbody></table><p>“UObject는 무겁고 struct는 가볍다”만으로 결정하지 않는다. 객체 행동과 identity가 모델의 일부인지, 아니면 data value의 type만 달라지는지가 먼저다.</p></section>
<section><h2>Ownership, GC, 복사의 관계<a href="#ownership-gc-복사의-관계"><span>#</span></a></h2><div><div><div><div><span></span></div><div><span><p>UPROPERTY(Instanced): GC가 순회하는 hard reference</p></span></div><div><span><p>Outer: name·path·subobject context</p></span></div><div><span><p>value property</p></span></div><div><span><p>UScriptStruct type + 소유 payload allocation</p></span></div><div><span><p>DuplicateObject + instancing graph</p></span></div><div><span><p>새 property reference</p></span></div><div><span><p>새 Outer</p></span></div><div><span><p>Package 또는 root-reachable object</p></span></div><div><span><p>Owner UObject</p></span></div><div><span><p>Inline child UObject</p></span></div><div><span><p>F/TInstancedStruct</p></span></div><div><span><p>USTRUCT payload</p></span></div><div><span><p>Owner copy</p></span></div><div><span><p>복제된 child UObject</p></span></div>
</div><div><div><span></span></div><div><span><p>UPROPERTY(Instanced): GC가 순회하는 hard reference</p></span></div><div><span><p>Outer: name·path·subobject context</p></span></div><div><span><p>value property</p></span></div><div><span><p>UScriptStruct type + 소유 payload allocation</p></span></div><div><span><p>DuplicateObject + instancing graph</p></span></div><div><span><p>새 property reference</p></span></div><div><span><p>새 Outer</p></span></div><div><span><p>Package 또는 root-reachable object</p></span></div><div><span><p>Owner UObject</p></span></div><div><span><p>Inline child UObject</p></span></div><div><span><p>F/TInstancedStruct</p></span></div><div><span><p>USTRUCT payload</p></span></div><div><span><p>Owner copy</p></span></div><div><span><p>복제된 child UObject</p></span></div>
</div></div></div><p>가장 중요한 화살표는 <code>Owner → UPROPERTY → Child</code>다. <code>Outer</code>는 이름, path, logical containment, duplication context를 정하지만 C++ RAII owner나 GC strong reference의 대체물이 아니다. child의 Outer를 owner로 설정했더라도 owner가 reflected hard reference로 child를 가리키지 않으면 그 관계만으로 child를 살려 두지 못한다.</p><p><code>FInstancedStruct</code>는 UObject pointer가 아니라 value wrapper다. 선택된 <code>UScriptStruct</code> type과 정렬된 payload allocation을 소유하고 copy할 때 payload를 deep copy한다. payload 안의 reflected UObject reference는 wrapper가 reflected owner graph 안에 있을 때 serializer와 reference collector가 처리한다.</p></section>
<section><h2>Instanced UObject의 specifier<a href="#instanced-uobject의-specifier"><span>#</span></a></h2>

<table><thead><tr><th>이름</th><th>선언 위치</th><th>역할</th></tr></thead><tbody><tr><td><code>EditInlineNew</code></td><td><code>UCLASS</code></td><td>Details 패널에서 해당 class instance를 inline 생성할 수 있게 함</td></tr><tr><td><code>DefaultToInstanced</code></td><td><code>UCLASS</code></td><td>이 class type property를 기본적으로 instanced reference로 취급하며 subclass에 상속</td></tr><tr><td><code>Instanced</code></td><td><code>UPROPERTY</code></td><td>persistent instance, export object, instanced reference와 EditInline metadata를 설정</td></tr><tr><td><code>Export</code></td><td><code>UPROPERTY</code></td><td>text export/복사에서 reference만이 아니라 subobject block을 함께 export. <code>Instanced</code>가 이미 암시</td></tr><tr><td><code>Outer</code></td><td>UObject 관계</td><td>object name/path와 context를 정의. GC ownership 자체는 아님</td></tr></tbody></table><p><code>EditInlineNew</code>만으로 저장, GC, 복제까지 보장되지 않는다. 반대로 <code>Instanced</code>를 명시하면 property contract가 분명해진다. 특히 array나 map 같은 container에서는 <code>DefaultToInstanced</code>만 기대하기보다 property에 <code>Instanced</code>를 직접 붙이는 편이 Details authoring까지 안전하다.</p></section>
<section><h2>Instanced UObject 완전 예제<a href="#instanced-uobject-완전-예제"><span>#</span></a></h2><div><div><div><figure><figcaption><span>ItemRuleObjects.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"Engine/DataAsset.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"ItemRuleObjects.generated.h"</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>UCLASS</span><span>(</span></div></div><div><div><div>8</div></div><div><span>    </span><span>Abstract</span><span>,</span></div></div><div><div><div>9</div></div><div><span>    </span><span>BlueprintType</span><span>,</span></div></div><div><div><div>10</div></div><div><span>    </span><span>Blueprintable</span><span>,</span></div></div><div><div><div>11</div></div><div><span>    </span><span>EditInlineNew</span><span>,</span></div></div><div><div><div>12</div></div><div><span>    </span><span>DefaultToInstanced</span><span>)</span></div></div><div><div><div>13</div></div><div><span>class</span><span> MYGAME_API </span><span>UItemRule</span><span> : </span><span>public</span><span> </span><span>UObject</span></div></div><div><div><div>14</div></div><div><span>{</span></div></div><div><div><div>15</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>public:</span></div></div><div><div><div>18</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>BlueprintReadWrite</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Rule"</span><span>)</span></div></div><div><div><div>19</div></div><div><span>    </span><span>FName</span><span> </span><span>Label</span><span>;</span></div></div><div><div><div>20</div></div><div><span>};</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>UCLASS</span><span>(</span><span>meta</span><span> </span><span>=</span><span> (</span><span>DisplayName</span><span> </span><span>=</span><span> </span><span>"Stack Limit"</span><span>))</span></div></div><div><div><div>23</div></div><div><span>class</span><span> MYGAME_API </span><span>UStackLimitRule</span><span> </span><span>final</span><span> : </span><span>public</span><span> </span><span>UItemRule</span></div></div><div><div><div>24</div></div><div><span>{</span></div></div><div><div><div>25</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>public:</span></div></div><div><div><div>28</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span></div></div><div><div><div>29</div></div><div><span>        </span><span>EditAnywhere</span><span>,</span></div></div><div><div><div>30</div></div><div><span>        </span><span>BlueprintReadWrite</span><span>,</span></div></div><div><div><div>31</div></div><div><span>        </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Rule"</span><span>,</span></div></div><div><div><div>32</div></div><div><span>        </span><span>meta</span><span> </span><span>=</span><span> (</span><span>ClampMin</span><span> </span><span>=</span><span> </span><span>"1"</span><span>))</span></div></div><div><div><div>33</div></div><div><span>    </span><span>int32</span><span> </span><span>MaxStack</span><span> </span><span>=</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>34</div></div><div><span>};</span></div></div><div><div><div>35</div></div><div>
</div></div><div><div><div>36</div></div><div><span>UCLASS</span><span>(</span><span>BlueprintType</span><span>)</span></div></div><div><div><div>37</div></div><div><span>class</span><span> MYGAME_API </span><span>UItemDefinition</span><span> </span><span>final</span><span> : </span><span>public</span><span> </span><span>UDataAsset</span></div></div><div><div><div>38</div></div><div><span>{</span></div></div><div><div><div>39</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>40</div></div><div>
</div></div><div><div><div>41</div></div><div><span>public:</span></div></div><div><div><div>42</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>BlueprintReadOnly</span><span>, </span><span>Instanced</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Rules"</span><span>)</span></div></div><div><div><div>43</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>TObjectPtr</span><span>&lt;</span><span>UItemRule</span><span>&gt;&gt;</span><span> </span><span>Rules</span><span>;</span></div></div><div><div><div>44</div></div><div>
</div></div><div><div><div>45</div></div><div><span>    </span><span>UItemRule</span><span>*</span><span> </span><span>AddRule</span><span>(</span><span>TSubclassOf</span><span>&lt;</span><span>UItemRule</span><span><span>&gt; </span><span>RuleClass</span><span>);</span></span></div></div><div><div><div>46</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><figure><figcaption><span>ItemRuleObjects.cpp</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"ItemRuleObjects.h"</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>UItemRule</span><span>*</span><span><span> </span><span>UItemDefinition</span><span>::</span></span><span>AddRule</span><span>(</span><span>TSubclassOf</span><span>&lt;</span><span>UItemRule</span><span><span>&gt; </span><span>RuleClass</span><span>)</span></span></div></div><div><div><div>4</div></div><div><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>UClass</span><span>*</span><span> </span><span>ChosenClass</span><span> </span><span>=</span><span> </span><span>RuleClass</span><span>.</span><span>Get</span><span>();</span></div></div><div><div><div>6</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>ChosenClass</span><span> </span><span>||</span><span> </span><span>ChosenClass</span><span>-&gt;</span><span>HasAnyClassFlags</span><span>(</span><span>CLASS_Abstract</span><span>))</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>8</div></div><div><span>        </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>    </span><span>UItemRule</span><span>*</span><span> </span><span>NewRule</span><span> </span><span>=</span><span> </span><span>NewObject</span><span>&lt;</span><span>UItemRule</span><span>&gt;(</span><span>this</span><span>, </span><span>ChosenClass</span><span>);</span></div></div><div><div><div>12</div></div><div><span>    </span><span>Rules</span><span>.</span><span>Add</span><span>(</span><span>NewRule</span><span>);</span></div></div><div><div><div>13</div></div><div><span>    </span><span>return</span><span> </span><span>NewRule</span><span>;</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>runtime에 동적 type을 만들 때는 <code>NewObject&lt;UItemRule&gt;(this, ChosenClass)</code>처럼 owner를 Outer로 전달한다. 생성자에서 class가 고정된 default subobject를 만들 때는 <code>CreateDefaultSubobject</code>가 맞다.</p><p></p><figure><img alt="Details 패널에서 Instanced UObject 파생 규칙을 추가하는 모습" loading="lazy" width="745" height="220" src="/_astro/instanced-uobject-type-picker.DnJTwkK8_1oiGMd.webp" /><figcaption>Details 패널에서 Instanced UObject 파생 규칙을 추가하는 모습</figcaption></figure><p></p><p><em>UE 5.3 Details 패널에서 inline object type을 고르는 모습.</em></p><section><h3>GC를 살리는 것은 reflected reference다<a href="#gc를-살리는-것은-reflected-reference다"><span>#</span></a></h3><p>예제에서 child가 유지되는 핵심은 <code>Rules</code>가 <code>UPROPERTY</code>인 점이다. 같은 <code>TArray&lt;TObjectPtr&lt;UItemRule&gt;&gt;</code>를 일반 C++ member로만 선언하면 <code>TObjectPtr</code>이라는 wrapper 이름만으로 GC가 자동 순회하지 않는다. 필요한 경우 <code>UPROPERTY</code>, <code>FGCObject</code>, <code>AddReferencedObjects</code> 중 맞는 reference collection path를 제공한다.</p></section><section><h3>Save, load, duplication<a href="#save-load-duplication"><span>#</span></a></h3><p>inline subobject는 owner package에 함께 저장되고 owner와 함께 load될 수 있다. 그러나 “child 안의 모든 reference가 완전히 load된다”는 뜻은 아니다.</p><ul>
<li><code>Transient</code> property는 저장되지 않는다.</li>
<li><code>TSoftObjectPtr</code>·<code>TSoftClassPtr</code> target은 soft reference 정책을 따른다.</li>
<li>외부 Asset reference는 별도 package object다.</li>
<li>잘못된 Outer나 instancing flag는 copy/paste·duplication 결과를 깨뜨릴 수 있다.</li>
</ul><p><code>Instanced</code> contract가 올바르면 <code>DuplicateObject</code>는 instancing graph를 통해 owner별 child copy를 만들 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UItemDefinition</span><span>*</span><span> </span><span>Copy</span><span> </span><span>=</span><span> </span><span>DuplicateObject</span><span>&lt;</span><span>UItemDefinition</span><span>&gt;(</span></div></div><div><div><div>2</div></div><div><span>    </span><span>Source</span><span>,</span></div></div><div><div><div>3</div></div><div><span>    </span><span>GetTransientPackage</span><span>());</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>check</span><span>(</span><span>Copy</span><span>);</span></div></div><div><div><div>6</div></div><div><span>check</span><span>(</span><span>Copy</span><span>-&gt;</span><span>Rules</span><span>.</span><span>Num</span><span>() </span><span>==</span><span> </span><span>Source</span><span>-&gt;</span><span>Rules</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>if</span><span> (</span><span>!</span><span>Copy</span><span>-&gt;</span><span>Rules</span><span>.</span><span>IsEmpty</span><span>())</span></div></div><div><div><div>9</div></div><div><span>{</span></div></div><div><div><div>10</div></div><div><span>    </span><span>check</span><span>(</span><span>Copy</span><span>-&gt;</span><span>Rules</span><span>[</span><span>0</span><span>] </span><span>!=</span><span> </span><span>Source</span><span>-&gt;</span><span>Rules</span><span>[</span><span>0</span><span>]);</span></div></div><div><div><div>11</div></div><div><span>    </span><span>check</span><span>(</span><span>Copy</span><span>-&gt;</span><span>Rules</span><span>[</span><span>0</span><span>]-&gt;</span><span>GetOuter</span><span>() </span><span>==</span><span> </span><span>Copy</span><span>);</span></div></div><div><div><div>12</div></div><div><span>    </span><span>check</span><span>(</span><span>Copy</span><span>-&gt;</span><span>Rules</span><span>[</span><span>0</span><span>]-&gt;</span><span>Label</span><span> </span><span>==</span><span> </span><span>Source</span><span>-&gt;</span><span>Rules</span><span>[</span><span>0</span><span>]-&gt;</span><span>Label</span><span>);</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Editor tool에서 array를 code로 수정한다면 undo/redo를 위한 <code>Modify()</code>, transactional flag, package dirty 표시와 실제 save를 별도로 처리한다.</p><p>Save serialization과 network replication도 같은 기능이 아니다. replicated Actor의 inline UObject를 client에 보내려면 pointer property 표시만이 아니라 <code>ReplicateSubobjects</code> 같은 subobject replication 설계가 필요하다.</p></section></section>
<section><h2>Instanced UObject container<a href="#instanced-uobject-container"><span>#</span></a></h2><p>배열과 map을 쓸 때는 property 자체에 <code>Instanced</code>를 명시해 inline object라는 계약을 드러낸다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>Instanced</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Rules"</span><span>)</span></div></div><div><div><div>2</div></div><div><span>TArray</span><span>&lt;</span><span>TObjectPtr</span><span>&lt;</span><span>UItemRule</span><span>&gt;&gt;</span><span> </span><span>OrderedRules</span><span>;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>Instanced</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Rules"</span><span>)</span></div></div><div><div><div>5</div></div><div><span>TMap</span><span>&lt;</span><span>FName</span><span>, </span><span>TObjectPtr</span><span>&lt;</span><span>UItemRule</span><span>&gt;&gt;</span><span> </span><span>RulesByName</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li>순서가 의미 있으면 <code>TArray</code>.</li>
<li>lookup key가 필요하면 <code>FName</code>이나 enum 같은 stable scalar key와 object value map.</li>
<li>UObject map key와 <code>TSet&lt;UObject*&gt;</code>는 content equality가 아니라 pointer identity이며 Details authoring도 불편하다.</li>
<li>soft, weak, class reference는 owner별 inline child object가 아니므로 <code>Instanced</code> 용도가 아니다.</li>
<li>복잡한 nested container는 UHT 제한과 편집 UI를 확인하고 필요하면 중간 USTRUCT wrapper를 둔다.</li>
</ul></section>
<section><h2>Instanced Struct setup (UE 5.3)<a href="#instanced-struct-setup-ue-53"><span>#</span></a></h2><p>먼저 Plugins 창에서 experimental <strong>Struct Utils</strong> plugin을 활성화한다. 사용하는 module의 <code>{ModuleName}.Build.cs</code>에도 <code>StructUtils</code> dependency를 추가한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>PublicDependencyModuleNames</span><span>.</span></span><span>AddRange</span><span>(</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>new[]</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>4</div></div><div><span>        </span><span>"Core"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>        </span><span>"CoreUObject"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>        </span><span>"Engine"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>        </span><span>"StructUtils"</span><span>,</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>9</div></div><div><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>header는 다음 경로로 include한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"StructUtils/InstancedStruct.h"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>FInstancedStruct</code> 자체는 <code>BlueprintType</code>이며 UE 5.1부터 Blueprint Graph와 전용 library 지원이 확대됐다. 다만 native <code>USTRUCT</code> inheritance hierarchy와 <code>TInstancedStruct&lt;Base&gt;</code> property type을 선언하는 작업은 C++ 영역이다.</p></section>
<section><h2><code>TInstancedStruct&lt;Base&gt;</code>와 DataTable 예제<a href="#tinstancedstructbase와-datatable-예제"><span>#</span></a></h2><div><div><div><figure><figcaption><span>ItemRuleStructs.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"Engine/DataTable.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"StructUtils/InstancedStruct.h"</span></div></div><div><div><div>6</div></div><div><span>#include</span><span> </span><span>"ItemRuleStructs.generated.h"</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>USTRUCT</span><span>(</span><span>BlueprintType</span><span>)</span></div></div><div><div><div>9</div></div><div><span>struct</span><span> MYGAME_API </span><span>FItemRuleData</span></div></div><div><div><div>10</div></div><div><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>BlueprintReadWrite</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Rule"</span><span>)</span></div></div><div><div><div>14</div></div><div><span>    </span><span>FName</span><span> </span><span>Label</span><span>;</span></div></div><div><div><div>15</div></div><div><span>};</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>USTRUCT</span><span>(</span><span>BlueprintType</span><span>)</span></div></div><div><div><div>18</div></div><div><span>struct</span><span> MYGAME_API </span><span>FStackLimitRuleData</span><span> : </span><span>public</span><span> </span><span>FItemRuleData</span></div></div><div><div><div>19</div></div><div><span>{</span></div></div><div><div><div>20</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span></div></div><div><div><div>23</div></div><div><span>        </span><span>EditAnywhere</span><span>,</span></div></div><div><div><div>24</div></div><div><span>        </span><span>BlueprintReadWrite</span><span>,</span></div></div><div><div><div>25</div></div><div><span>        </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Rule"</span><span>,</span></div></div><div><div><div>26</div></div><div><span>        </span><span>meta</span><span> </span><span>=</span><span> (</span><span>ClampMin</span><span> </span><span>=</span><span> </span><span>"1"</span><span>))</span></div></div><div><div><div>27</div></div><div><span>    </span><span>int32</span><span> </span><span>MaxStack</span><span> </span><span>=</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>28</div></div><div><span>};</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span>USTRUCT</span><span>(</span><span>BlueprintType</span><span>)</span></div></div><div><div><div>31</div></div><div><span>struct</span><span> MYGAME_API </span><span>FItemDataRow</span><span> : </span><span>public</span><span> </span><span>FTableRowBase</span></div></div><div><div><div>32</div></div><div><span>{</span></div></div><div><div><div>33</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>34</div></div><div>
</div></div><div><div><div>35</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span></div></div><div><div><div>36</div></div><div><span>        </span><span>EditAnywhere</span><span>,</span></div></div><div><div><div>37</div></div><div><span>        </span><span>BlueprintReadWrite</span><span>,</span></div></div><div><div><div>38</div></div><div><span>        </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Rules"</span><span>,</span></div></div><div><div><div>39</div></div><div><span>        </span><span>meta</span><span> </span><span>=</span><span> (</span></div></div><div><div><div>40</div></div><div><span>            </span><span>BaseStruct</span><span> </span><span>=</span><span> </span><span>"ItemRuleData"</span><span>,</span></div></div><div><div><div>41</div></div><div><span>            </span><span>ExcludeBaseStruct</span><span>,</span></div></div><div><div><div>42</div></div><div><span>            </span><span>ShowTreeView</span><span>))</span></div></div><div><div><div>43</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>TInstancedStruct</span><span>&lt;</span><span>FItemRuleData</span><span>&gt;&gt;</span><span> </span><span>Rules</span><span>;</span></div></div><div><div><div>44</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>TInstancedStruct&lt;FItemRuleData&gt;</code>의 template argument가 picker의 base struct를 정한다. 다만 UE 5.3에서 array나 map에 넣었을 때 picker가 base를 찾지 못하는 경우에는 예제처럼 <code>BaseStruct</code> metadata를 함께 지정한다. direct property에서는 template argument만으로 충분하다.</p><p></p><figure><img alt="Instanced Struct와 배열에서 파생 payload를 선택하는 모습" loading="lazy" width="702" height="246" src="/_astro/instanced-struct-type-picker.CgM6PsIg_ShP0.webp" /><figcaption>Instanced Struct와 배열에서 파생 payload를 선택하는 모습</figcaption></figure><p></p><p><em>UE 5.3에서 array element의 파생 struct type을 고르는 모습.</em></p><p>DataTable 전체의 row type은 여전히 하나의 <code>FItemDataRow</code>로 고정된다. 각 row가 완전히 다른 struct가 되는 것이 아니라 <code>Rules</code> column 안의 payload type만 <code>FStackLimitRuleData</code> 같은 descendant로 달라진다.</p><p>DataTable에서 사용할 때는 저장한 뒤 editor를 다시 열어 payload type과 값이 유지되는지 확인하고, CSV/JSON export가 필요한 workflow라면 round trip도 따로 검증한다.</p></section>
<section><h2>생성하고 안전하게 읽기<a href="#생성하고-안전하게-읽기"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>TInstancedStruct</span><span>&lt;</span><span>FItemRuleData</span><span>&gt;</span><span> </span><span>Rule</span><span> </span><span>=</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>TInstancedStruct&lt;</span><span>FItemRuleData</span><span>&gt;::</span><span>Make</span><span>&lt;</span><span>FStackLimitRuleData</span><span>&gt;();</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>FStackLimitRuleData</span><span>&amp;</span><span> </span><span>MutableRule</span><span> </span><span>=</span></div></div><div><div><div>5</div></div><div><span>    </span><span>Rule</span><span>.</span><span>GetMutable</span><span>&lt;</span><span>FStackLimitRuleData</span><span>&gt;</span><span>();</span></div></div><div><div><div>6</div></div><div><span>MutableRule</span><span>.</span><span>MaxStack</span><span> </span><span>=</span><span> </span><span>20</span><span>;</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>if</span><span> (</span><span>const</span><span> </span><span>FStackLimitRuleData</span><span>*</span><span> </span><span>StackRule</span><span> </span><span>=</span></div></div><div><div><div>9</div></div><div><span>        </span><span>Rule</span><span>.</span><span>GetPtr</span><span>&lt;</span><span>FStackLimitRuleData</span><span>&gt;</span><span>())</span></div></div><div><div><div>10</div></div><div><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>UE_LOG</span><span>(</span></div></div><div><div><div>12</div></div><div><span>        </span><span>LogTemp</span><span>,</span></div></div><div><div><div>13</div></div><div><span>        </span><span>Log</span><span>,</span></div></div><div><div><div>14</div></div><div><span>        </span><span>TEXT</span><span>(</span><span>"MaxStack=</span><span>%d</span><span>"</span><span>),</span></div></div><div><div><div>15</div></div><div><span>        </span><span>StackRule</span><span>-&gt;</span><span>MaxStack</span><span>);</span></div></div><div><div><div>16</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>API의 실패 contract를 구분한다.</p>

<table><thead><tr><th>API</th><th>반환</th><th>type이 호환되지 않을 때</th></tr></thead><tbody><tr><td><code>GetScriptStruct()</code></td><td>저장된 <code>UScriptStruct*</code></td><td>비어 있으면 null 가능</td></tr><tr><td><code>Get&lt;T&gt;()</code></td><td><code>const T&amp;</code></td><td>check/assert contract</td></tr><tr><td><code>GetPtr&lt;T&gt;()</code></td><td><code>const T*</code></td><td>null</td></tr><tr><td><code>GetMutable&lt;T&gt;()</code></td><td><code>T&amp;</code></td><td>check/assert contract</td></tr><tr><td><code>GetMutablePtr&lt;T&gt;()</code></td><td><code>T*</code></td><td>null</td></tr></tbody></table><p>mutable reference가 필요하면 <code>GetMutable&lt;T&gt;()</code>를 <code>T&amp;</code>로 받고, 실패 가능한 pointer가 필요하면 <code>GetMutablePtr&lt;T&gt;()</code>를 쓴다.</p><p><code>USTRUCT</code> 안에도 일반 C++ member function을 정의할 수 있다. 다만 <code>UFUNCTION</code>은 USTRUCT member에 선언할 수 없다. Blueprint-callable operation이 필요하면 <code>UBlueprintFunctionLibrary</code>나 별도 UObject API로 노출한다.</p></section>
<section><h2>Untyped <code>FInstancedStruct</code><a href="#untyped-finstancedstruct"><span>#</span></a></h2><p>generic tool처럼 compile-time base type을 정할 수 없을 때만 untyped wrapper와 metadata를 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UPROPERTY</span><span>(</span></div></div><div><div><div>2</div></div><div><span>    </span><span>EditAnywhere</span><span>,</span></div></div><div><div><div>3</div></div><div><span>    </span><span>meta</span><span> </span><span>=</span><span> (</span></div></div><div><div><div>4</div></div><div><span>        </span><span>BaseStruct</span><span> </span><span>=</span><span> </span><span>"/Script/MyGame.ItemRuleData"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>        </span><span>ExcludeBaseStruct</span><span>,</span></div></div><div><div><div>6</div></div><div><span>        </span><span>ShowTreeView</span><span>))</span></div></div><div><div><div>7</div></div><div><span>FInstancedStruct</span><span> </span><span>Rule</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>정확한 path 형식은 <code>"/Script/ModuleName.StructName"</code>이다. reflected USTRUCT name에는 C++ <code>F</code> prefix가 들어가지 않는다. module 이름의 철자와 <code>Module.Struct</code> 구분자를 정확히 써야 한다.</p><p>UE 5.3에서 picker를 구성할 때는 주로 아래 metadata를 쓴다.</p><ul>
<li><code>BaseStruct</code>: 선택 가능한 최상위 struct 지정</li>
<li><code>ExcludeBaseStruct</code>: 최상위 struct 자체는 선택지에서 제외</li>
<li><code>ShowTreeView</code>: 파생 관계를 tree로 표시</li>
</ul><p>UE 5.3에서 확인한 Details 패널에서는 <code>InstancedStruct</code> property에 둔 <code>EditCondition</code>이 기대대로 동작하지 않았다. 조건부 편집에 의존한다면 실제 property 형태와 editor 화면에서 먼저 검증한다.</p></section>
<section><h2>Struct container 경계<a href="#struct-container-경계"><span>#</span></a></h2><ul>
<li>direct property와 <code>TArray</code>는 first-class editor path다.</li>
<li><code>TMap&lt;FName, TInstancedStruct&lt;FItemRuleData&gt;&gt;</code>처럼 scalar key와 instanced value를 사용할 수 있다.</li>
<li>기본 <code>GetTypeHash</code>가 없으므로 <code>F/TInstancedStruct</code>를 <code>TSet</code> element나 <code>TMap</code> key로 쓰지 않는다.</li>
<li>많은 runtime payload를 조밀하게 관리하고 Details customization이 필요 없다면 <code>FInstancedStructContainer</code>를 검토한다.</li>
<li>UI에서 편집할 heterogeneous sequence에는 <code>TArray&lt;FInstancedStruct&gt;</code>가 더 직접적이다.</li>
</ul><p>Blueprint User Defined Struct는 native USTRUCT inheritance hierarchy를 만들지 못한다. base-filtered <code>TInstancedStruct&lt;NativeBase&gt;</code>와 Blueprint graph 지원을 “Blueprint에서 임의의 struct 상속 tree를 만든다”는 뜻으로 해석하면 안 된다. base가 없는 <code>FInstancedStruct</code>는 User Defined Struct를 담을 수 있지만 compile-time type safety와 base filtering을 잃는다.</p></section>
<section><h2>Memory와 성능을 고정 byte 수로 비교하지 않기<a href="#memory와-성능을-고정-byte-수로-비교하지-않기"><span>#</span></a></h2><p>UObject의 overhead를 고정된 byte 수로 단정할 수는 없다. 크기는 platform, build configuration, engine version에 따라 달라진다. <code>FInstancedStruct</code>도 payload bytes만 inline 저장하는 zero-overhead type이 아니다. wrapper는 script type과 payload pointer를 유지하고 payload를 정렬된 별도 allocation에 소유한다.</p><p>실제로 비교할 비용은 아래와 같다.</p>

<table><thead><tr><th>Instanced UObject</th><th>Instanced Struct</th></tr></thead><tbody><tr><td>UObject allocation, identity, Outer, GC traversal</td><td>payload allocation, UScriptStruct metadata, deep copy</td></tr><tr><td>virtual/UFUNCTION/interface/delegate 가능</td><td>값 중심, 일반 C++ member function 가능</td></tr><tr><td>per-object flags, subobject serialization/duplication</td><td>struct serializer, text import/export, NetSerialize 경로</td></tr><tr><td>pointer graph와 object lifetime 관리</td><td>copy/move 빈도와 payload size 관리</td></tr></tbody></table><p>규칙 수, payload 크기, copy 빈도, editor transaction, load/cook time을 실제 project data로 profile한다. 동작 없는 작은 data라도 수천 개를 매 frame copy하면 struct가 자동으로 빠른 것은 아니며, behavior-rich object를 억지로 struct와 type switch로 바꾸면 유지보수 비용이 커질 수 있다.</p></section>
<section><h2>적용 체크리스트<a href="#적용-체크리스트"><span>#</span></a></h2><ul>
<li>object identity·behavior가 필요한지, heterogeneous value만 필요한지 먼저 결정했는가?</li>
<li>inline UObject class에 <code>EditInlineNew</code>/<code>DefaultToInstanced</code>, property에 명시적 <code>Instanced</code>가 있는가?</li>
<li>child의 Outer뿐 아니라 owner의 reflected hard reference가 있는가?</li>
<li>soft/weak/class reference를 inline ownership으로 오해하지 않았는가?</li>
<li>save/load, duplicate, copy/paste, undo/redo를 실제 Asset로 검증했는가?</li>
<li>network subobject라면 serialization과 별도로 replication path를 구현했는가?</li>
<li>UE 5.3 project에서 Struct Utils plugin과 Build.cs dependency를 설정했는가?</li>
<li>container의 <code>TInstancedStruct&lt;Base&gt;</code> picker가 base를 찾지 못하면 <code>BaseStruct</code> metadata를 지정했는가?</li>
<li>untyped base path가 <code>"/Script/Module.Struct"</code> 형식인가?</li>
<li>DataTable의 fixed row schema와 heterogeneous field payload를 구분했는가?</li>
<li>JSON/CSV round trip, cook, reload, packaged build를 test했는가?</li>
<li>exact byte 수 대신 allocation, copy, GC, authoring 비용을 profile했는가?</li>
</ul></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-uproperties" target="_blank">Epic Games: UPROPERTY specifiers</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/class-specifiers" target="_blank">Epic Games: Class specifiers</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/object-pointers-in-unreal-engine" target="_blank">Epic Games: Object pointers and GC</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-object-handling-in-unreal-engine" target="_blank">Epic Games: Unreal Object Handling</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/NewObject" target="_blank">Epic Games: <code>NewObject</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/DuplicateObject" target="_blank">Epic Games: <code>DuplicateObject</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/FObjectInstancingGraph" target="_blank">Epic Games: <code>FObjectInstancingGraph</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/FInstancedStruct" target="_blank">Epic Games: <code>FInstancedStruct</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/TInstancedStruct" target="_blank">Epic Games: <code>TInstancedStruct</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/FInstancedStructContainer" target="_blank">Epic Games: <code>FInstancedStructContainer</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UDataTable" target="_blank">Epic Games: <code>UDataTable</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-5.1-release-notes?application_version=5.1" target="_blank">Epic Games UE 5.1 release notes</a></li>
<li><a href="https://dev.epicgames.com/community/learning/tutorials/Gp9j/working-with-data-in-unreal-engine-data-tables-data-assets-uproperty-specifiers-and-more" target="_blank">Working with Data in Unreal Engine</a></li>
<li><a href="https://slowburn.dev/dataconfig/Extra/InstancedStruct.html#instancedstruct" target="_blank">DataConfig: InstancedStruct</a></li>
<li><a href="https://github.com/mattyman174/GenericItemization#instanced-structs" target="_blank">GenericItemization: Instanced Structs</a></li>
<li><a href="https://slowburn.dev/blog/polymorphic-serialization-in-unreal-engine/" target="_blank">Polymorphic Serialization in Unreal Engine</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/recastnavmesh-export-load-flags/</id>
      <title type="text">RecastNavMesh export/load flags 크래시 진단</title>
      <published>2024-05-18T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/recastnavmesh-export-load-flags/"/>
      <summary type="text">특정 저장 데이터를 불러올 때 RecastNavMesh의 export와 load flags가 일치하지 않아 발생한 크래시를 지도 내 내비게이션 데이터 관점에서 진단합니다.</summary>
      <content type="html"><![CDATA[<p>특정 저장 데이터를 불러오다 아래 오류가 발생하며 게임이 종료됐다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>RecastNavMesh /Game/Maps/&lt;MapName&gt;.&lt;MapName&gt;:PersistentLevel.RecastNavMesh-Default</span></div></div><div><div><div>2</div></div><div><span>was found in memory and is an export but does not have all load flags.</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>모든 지도나 새 게임에서 발생한 것은 아니며 저장 데이터가 특정 지도를 참조할 때만 재현됐다. 오류 문자열을 보면 메모리에 이미 존재하는 <code>RecastNavMesh</code> 객체가 패키지 export로도 발견됐지만, 현재 로드가 요구하는 flags를 갖추지 못한 상태다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>대상은 Unreal Engine에서 Recast 기반 내비게이션을 사용하는 경우다. 오류 경로에는 지도 패키지의 <code>RecastNavMesh-Default</code>가 직접 나타난다. 같은 load flags 오류라도 지목된 객체가 다르면 그 객체의 생성·저장 경로부터 별도로 조사해야 한다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>문제가 된 지도에는 <code>Nav Mesh Bounds Volume</code>이 없었다. 이 상태가 저장된 내비게이션 객체와 맞물렸다. Unreal은 유효한 내비게이션 영역을 기준으로 Recast 내비게이션 데이터를 생성한다. 지도의 bounds를 제거하거나 내비게이션 데이터를 다시 저장하는 과정에서 패키지 안의 export와 런타임 생성 상태가 어긋난 것으로 판단했다.</p><p>다만 오류 한 줄만으로 원인을 일반화해서는 안 된다. stale cooked asset이나 서로 다른 엔진 버전에서 만든 저장 데이터, 지도 redirect, 패키징 결과가 섞인 경우에도 비슷한 로드 불일치가 생기기도 한다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>오류에 표시된 지도와 객체 경로를 기록한다.</li>
<li>해당 지도를 editor에서 열고 <code>Nav Mesh Bounds Volume</code>과 <code>RecastNavMesh-Default</code>의 존재를 확인한다.</li>
<li><code>P</code> 키로 내비게이션 영역을 표시해 실제 이동 영역이 생성되는지 확인한다.</li>
<li>저장 데이터 없이 지도를 직접 열 때와 저장 데이터를 복원할 때를 나눠 재현한다.</li>
<li>패키징 빌드라면 기존 cooked output을 섞지 않은 깨끗한 빌드에서도 확인한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>문제가 된 지도에 <code>Nav Mesh Bounds Volume</code>을 추가해 이동 가능한 영역을 포함했다. 지도를 저장한 뒤 내비게이션 데이터를 다시 생성했다. 이후 새 빌드와 새 프로세스에서 같은 저장 데이터를 불러오자 크래시가 사라졌다.</p><p>이 수정은 해당 지도에 내비게이션이 실제로 필요한 경우에만 적절하다. 내비게이션을 사용하지 않는 지도라면 bounds를 억지로 추가하기 전에 저장 데이터가 그 지도의 <code>RecastNavMesh</code>를 참조하는 이유부터 확인해야 한다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>editor와 패키징 빌드에서 각각 새 게임과 기존 저장 데이터 로드를 반복한다.</li>
<li>server travel이나 level streaming을 쓴다면 최초 진입과 재진입을 모두 확인한다.</li>
<li><code>RecastNavMesh-Default</code>가 지도 저장 후 의도치 않게 다시 사라지지 않는지 source control diff로 확인한다.</li>
<li>내비게이션 경로 탐색과 AI 이동도 함께 검사한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p><code>Nav Mesh Bounds Volume</code>을 다시 제거하면 저장 과정에서 생성된 내비게이션 객체가 달라지기도 한다. 오류를 숨기려고 load flags 검사를 우회하거나 저장 데이터를 무조건 폐기하면 패키지 상태 불일치의 원인이 남는다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/NavigationSystem/ARecastNavMesh" target="_blank">Epic Games: ARecastNavMesh API</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/basic-navigation-in-unreal-engine?lang=en-US" target="_blank">Epic Games: Basic Navigation</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/how-unreal-engine-cast-works/</id>
      <title type="text">Unreal Engine의 C++ Cast&lt;T&gt; 함수는 어떻게 동작하는가?</title>
      <published>2024-01-29T00:00:00.000Z</published>
      <updated>2024-01-29T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/how-unreal-engine-cast-works/"/>
      <summary type="text">Unreal의 Cast&lt;T&gt;가 C++ RTTI 대신 UClass reflection, cast flags, IsA와 IsChildOf를 사용하는 경로와 API별 선택 기준, Blueprint interface 예외를 설명합니다.</summary>
      <content type="html"><![CDATA[<p>Unreal C++에서 <code>UObject*</code>를 구체적인 type으로 확인할 때는 보통 <code>dynamic_cast</code>가 아니라 <code>Cast&lt;T&gt;</code>를 사용한다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UObject</span><span>*</span><span> </span><span>Candidate</span><span> </span><span>=</span><span> </span><span>GetCandidateObject</span><span>();</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>if</span><span> (</span><span>AMyActor</span><span>*</span><span> </span><span>Actor</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>AMyActor</span><span>&gt;(</span><span>Candidate</span><span>))</span></div></div><div><div><div>4</div></div><div><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>Actor</span><span>-&gt;</span><span>ActivateFeature</span><span>();</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p>둘 다 실패 가능한 runtime cast처럼 보이지만 type information의 출처가 다르다.</p>
<ul>
<li>표준 <code>dynamic_cast</code>: compiler가 생성한 C++ RTTI와 polymorphic class hierarchy 사용</li>
<li>Unreal <code>Cast&lt;T&gt;</code>: <code>UClass</code>, <code>UStruct</code>, interface table 같은 Unreal reflection metadata 사용</li>
</ul>
<p>그래서 native class와 Blueprint-generated subclass를 같은 UObject hierarchy로 판별하면서도 C++ RTTI를 요구하지 않는다. 다만 <code>Cast</code>는 type 관계만 검사한다. raw pointer가 pending kill인지, GC lifetime이 유효한지, gameplay state가 사용할 수 있는지는 별도 문제다.</p>
<p>아래 구현 설명은 Unreal Engine 5의 <code>Casts.h</code>를 기준으로 한다. Unreal Engine 4의 내부 dispatcher는 다르므로 사용하는 engine branch의 source와 함께 읽어야 한다.</p>
<section><h2>먼저 API를 고르는 기준<a href="#먼저-api를-고르는-기준"><span>#</span></a></h2>

<table><thead><tr><th>의도</th><th>API</th><th>실패 의미와 주의점</th></tr></thead><tbody><tr><td>compile time에 보장된 derived→base upcast</td><td>일반 implicit conversion</td><td>cast가 필요 없음</td></tr><tr><td>mismatch가 정상적으로 가능한 UObject 입력</td><td><code>Cast&lt;T&gt;</code> + null 검사</td><td>null 입력이나 불일치면 null</td></tr><tr><td>성공이 programmer invariant</td><td><code>CastChecked&lt;T&gt;</code></td><td>check 설정에 따라 type 검사가 제거될 수 있어 외부 입력 검증용이 아님</td></tr><tr><td>subclass를 허용하지 않고 정확한 runtime <code>UClass</code>만</td><td><code>ExactCast&lt;T&gt;</code></td><td>subclass면 null</td></tr><tr><td>pointer가 필요 없고 관계만 확인</td><td><code>Object &amp;&amp; Object-&gt;IsA&lt;T&gt;()</code></td><td>raw null object에서 member를 호출하지 않음</td></tr><tr><td>Blueprint 구현까지 가능한 Unreal interface 호출</td><td><code>ImplementsInterface</code> + <code>I...::Execute_...</code></td><td>Blueprint-only 구현은 native interface pointer가 없음</td></tr><tr><td>native C++ interface pointer가 반드시 필요</td><td><code>Cast&lt;I...&gt;</code></td><td>Blueprint-only 구현에는 null일 수 있음</td></tr><tr><td><code>FField</code>·<code>FProperty</code> hierarchy</td><td><code>CastField&lt;T&gt;</code></td><td>UObject <code>Cast</code>와 다른 hierarchy</td></tr><tr><td>UObject가 아닌 native C++ hierarchy</td><td>표준 C++ cast</td><td>checked downcast에는 RTTI/ABI 조건 필요</td></tr></tbody></table><p>raw pointer가 gameplay 관점에서도 살아 있어야 한다면 먼저 <code>IsValid(Object)</code>를 확인한다. <code>Cast</code>가 non-null을 반환했다는 사실만으로 destroy 예정 object까지 사용할 수 있다는 뜻은 아니다.</p></section>
<section><h2>UE5 <code>Cast&lt;T&gt;</code>의 큰 흐름<a href="#ue5-castt의-큰-흐름"><span>#</span></a></h2><p><code>Cast&lt;T&gt;</code>는 source와 destination이 완전한 type인지 compile time에 확인한 뒤, null과 Unreal interface 여부, cast flag 사용 가능 여부를 차례로 나눈다.</p><div><div><div><div><span></span></div><div><span><p>예</p></span></div><div><span><p>아니요</p></span></div><div><span><p>예</p></span></div><div><span><p>object 없음</p></span></div><div><span><p>object 있음</p></span></div><div><span><p>예</p></span></div><div><span><p>아니요</p></span></div><div><span><p>아니요</p></span></div><div><span><p>예</p></span></div><div><span><p>아니요</p></span></div><div><span><p>예</p></span></div><div><span><p>아니요</p></span></div><div><span><p>성공</p></span></div><div><span><p>성공</p></span></div><div><span><p>성공</p></span></div><div><span><p>성공</p></span></div><div><span><p>실패</p></span></div><div><span><p>실패</p></span></div><div><span><p>실패</p></span></div><div><span><p>실패</p></span></div><div><span><p>Cast(Src)</p></span></div><div><span><p>Src가 null인가?</p></span></div><div><span><p>nullptr 반환</p></span></div><div><span><p>From이 IInterface인가?</p></span></div><div><span><p>_getUObject()</p></span></div><div><span><p>To가 interface인가?</p></span></div><div><span><p>GetInterfaceAddress</p></span></div><div><span><p>IsA 검사</p></span></div><div><span><p>cast flag 경로가 활성인가?</p></span></div><div><span><p>HasAnyCastFlag 검사</p></span></div><div><span><p>To가 interface인가?</p></span></div><div><span><p>IsA 검사</p></span></div><div><span><p>target pointer 반환</p></span></div>
</div><div><div><span></span></div><div><span><p>예</p></span></div><div><span><p>아니요</p></span></div><div><span><p>예</p></span></div><div><span><p>object 없음</p></span></div><div><span><p>object 있음</p></span></div><div><span><p>예</p></span></div><div><span><p>아니요</p></span></div><div><span><p>아니요</p></span></div><div><span><p>예</p></span></div><div><span><p>아니요</p></span></div><div><span><p>예</p></span></div><div><span><p>아니요</p></span></div><div><span><p>성공</p></span></div><div><span><p>성공</p></span></div><div><span><p>성공</p></span></div><div><span><p>성공</p></span></div><div><span><p>실패</p></span></div><div><span><p>실패</p></span></div><div><span><p>실패</p></span></div><div><span><p>실패</p></span></div><div><span><p>Cast(Src)</p></span></div><div><span><p>Src가 null인가?</p></span></div><div><span><p>nullptr 반환</p></span></div><div><span><p>From이 IInterface인가?</p></span></div><div><span><p>_getUObject()</p></span></div><div><span><p>To가 interface인가?</p></span></div><div><span><p>GetInterfaceAddress</p></span></div><div><span><p>IsA 검사</p></span></div><div><span><p>cast flag 경로가 활성인가?</p></span></div><div><span><p>HasAnyCastFlag 검사</p></span></div><div><span><p>To가 interface인가?</p></span></div><div><span><p>IsA 검사</p></span></div><div><span><p>target pointer 반환</p></span></div>
</div></div></div><section><h3>Source가 Unreal interface인 경우<a href="#source가-unreal-interface인-경우"><span>#</span></a></h3><p><code>From</code>이 <code>IInterface</code> 계열이면 먼저 <code>_getUObject()</code>로 owning UObject를 찾는다.</p><ul>
<li><code>To</code>도 interface면 <code>GetInterfaceAddress</code>로 native interface 주소를 찾는다.</li>
<li>UObject target이면 <code>IsA&lt;To&gt;()</code>로 class 관계를 검사한다.</li>
</ul><p>owning object를 얻지 못하거나 관계가 맞지 않으면 null이다.</p></section><section><h3>Source가 UObject 계열인 경우<a href="#source가-uobject-계열인-경우"><span>#</span></a></h3><p>UObject 계열에서는 다음 세 경로 중 compile-time 설정과 target type에 맞는 경로를 사용한다.</p><ul>
<li>common engine type에 대한 cast flag bit test</li>
<li>Unreal interface table에서 native interface 주소 검색</li>
<li><code>IsA&lt;To&gt;()</code>를 통한 <code>UClass</code>/<code>UStruct</code> hierarchy 검사</li>
</ul><p>public <code>Cast&lt;T&gt;</code>의 의미는 같아도 이 분기를 구성하는 helper와 macro는 engine branch에 따라 달라질 수 있다.</p></section></section>
<section><h2><code>EClassCastFlags</code>는 모든 class의 고유 ID가 아니다<a href="#eclasscastflags는-모든-class의-고유-id가-아니다"><span>#</span></a></h2><p><code>UClass::ClassCastFlags</code>는 common engine type cast를 빠르게 판별하기 위한 <code>EClassCastFlags</code> bit mask다. <code>AActor</code>, <code>USceneComponent</code>, <code>UBlueprint</code> 같은 제한된 engine category에 flag가 정의되어 있고 superclass의 flag가 subclass로 상속된다.</p><p>중요한 한계가 있다.</p><ul>
<li>64-bit mask의 모든 bit를 임의의 game class마다 하나씩 배정하지 않는다.</li>
<li><code>TCastFlags_V&lt;T&gt;</code>의 기본은 <code>CASTCLASS_None</code>이다.</li>
<li>custom game type 대부분은 target-specific bit가 없어 <code>IsA</code> path를 사용한다.</li>
<li>flag가 있다고 정확한 class equality를 뜻하지 않는다. 해당 category 또는 descendant 관계를 나타낸다.</li>
</ul><p>application code가 이 내부 flag를 직접 선택하거나 확장하기보다 <code>Cast</code>, <code>IsA</code>, <code>ExactCast</code>의 공개 의미에 의존해야 한다.</p></section>
<section><h2><code>IsA</code>는 raw UObject와 <code>FObjectPtr</code>를 구분해서 읽어야 한다<a href="#isa는-raw-uobject와-fobjectptr를-구분해서-읽어야-한다"><span>#</span></a></h2><p>raw UObject 계열의 canonical path는 <code>UObjectBaseUtility::IsA</code>다. 개념적으로 다음 관계를 확인한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Object</span><span>-&gt;</span><span>GetClass</span><span>()-&gt;</span><span>IsChildOf</span><span>(</span><span>TargetClass</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>target <code>UClass*</code>가 null이면 의미 있는 class 관계를 검사할 수 없다. <code>Object</code>가 null인 상태에서 member function을 호출하는 것도 C++ undefined behavior이므로 다음처럼 guard한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>bool</span><span> </span><span>IsMyActor</span><span>(</span><span>const</span><span> </span><span>UObject</span><span>*</span><span><span> </span><span>Object</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>return</span><span> </span><span>Object</span><span> </span><span>&amp;&amp;</span><span> </span><span>Object</span><span>-&gt;</span><span>IsA</span><span>&lt;</span><span>AMyActor</span><span>&gt;</span><span>();</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>ObjectPtr.cpp</code>의 <code>FObjectPtr::IsA</code>는 raw UObject의 member 구현과 구분해야 한다. <code>FObjectPtr</code> path는 handle resolve와 access tracking을 포함할 수 있는 별도 overload다. 결과의 class 관계는 같아도 raw pointer 비용을 분석할 때 해당 구현을 그대로 대입하면 안 된다.</p></section>
<section><h2><code>IsChildOf</code>의 두 구현<a href="#ischildof의-두-구현"><span>#</span></a></h2><p><code>UStruct::IsChildOf</code>는 자신이 target <code>UStruct</code>와 같거나 descendant인지 판단한다. engine의 compile-time 설정에 따라 두 구현 중 하나를 사용할 수 있다.</p>

<table><thead><tr><th>구현</th><th>query 비용</th><th>추가 비용·특징</th></tr></thead><tbody><tr><td>outer walk</td><td>O(h)</td><td>자기 자신부터 super chain을 순회</td></tr><tr><td>struct array</td><td>O(1)</td><td>type마다 ancestor chain을 O(h) 공간으로 저장·초기화</td></tr></tbody></table><p>여기서 <code>h</code>는 inheritance depth다. 어떤 build가 어느 구현과 cast flag 경로를 선택하는지는 해당 engine branch의 macro 설정을 확인한다. editor 여부만으로 모든 branch의 값을 단정하지 않는다.</p></section>
<section><h2>경로별 복잡도<a href="#경로별-복잡도"><span>#</span></a></h2><p><code>Cast</code> 전체를 무조건 O(1) 또는 무조건 비싸다고 설명할 수 없다.</p>

<table><thead><tr><th>경로</th><th>대략적인 query 비용</th><th>비고</th></tr></thead><tbody><tr><td><code>HasAnyCastFlag</code></td><td>O(1)</td><td>common flagged engine type</td></tr><tr><td>outer walk <code>IsChildOf</code></td><td>O(h)</td><td>inheritance depth에 비례</td></tr><tr><td>struct-array <code>IsChildOf</code></td><td>O(1)</td><td>type당 O(h) ancestor storage/initialization</td></tr><tr><td>raw <code>ExactCast</code></td><td>O(1)</td><td>exact <code>UClass*</code> equality만 비교</td></tr><tr><td>interface address lookup</td><td>class depth와 interface 목록에 비례</td><td>Blueprint/native 구현 여부 분기 포함</td></tr><tr><td><code>FObjectPtr</code> overload</td><td>class check 외 handle 비용 가능</td><td>raw pointer path와 별도 측정</td></tr></tbody></table><p>interface search나 object handle resolve까지 포함해 “모든 Unreal cast는 bit test 하나”라고 일반화하면 안 된다. 반대로 game hierarchy 깊이는 보통 제한적이므로 cast 몇 번만 보고 architecture를 바꿀 이유도 없다. hot path는 Unreal Insights나 platform profiler로 실제 빈도와 target을 측정한다.</p></section>
<section><h2><code>Cast</code>, <code>CastChecked</code>, <code>ExactCast</code>, <code>IsA</code><a href="#cast-castchecked-exactcast-isa"><span>#</span></a></h2><section><h3>실패가 정상이라면 <code>Cast</code><a href="#실패가-정상이라면-cast"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span> </span><span>TryActivate</span><span>(</span><span>UObject</span><span>*</span><span><span> </span><span>Candidate</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>IsValid</span><span>(</span><span>Candidate</span><span>))</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>    </span><span>if</span><span> (</span><span>AMyActor</span><span>*</span><span> </span><span>Actor</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>AMyActor</span><span>&gt;(</span><span>Candidate</span><span>))</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>10</div></div><div><span>        </span><span>Actor</span><span>-&gt;</span><span>ActivateFeature</span><span>();</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>subclass도 허용하며 mismatch는 null이다. network payload, user selection, optional component처럼 실제 type이 달라질 수 있는 입력에 적합하다.</p></section><section><h3>실패가 programmer bug라면 <code>CastChecked</code><a href="#실패가-programmer-bug라면-castchecked"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>AMyActor</span><span>*</span><span> </span><span>Actor</span><span> </span><span>=</span><span> </span><span>CastChecked</span><span>&lt;</span><span>AMyActor</span><span>&gt;(</span><span>KnownActorObject</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>check가 활성화된 build에서는 null 또는 mismatch를 진단한다. <code>ECastCheckedType::NullAllowed</code>를 사용하면 null만 허용할 수 있지만 non-null mismatch는 여전히 invariant 위반이다.</p><p>check가 compile out되는 build에서는 <code>CastChecked</code>의 진단도 같은 형태로 남는다고 기대하면 안 된다. 그래서 untrusted runtime input의 보안·유효성 검사로 사용하지 않는다. 실패가 정상일 수 있다면 <code>Cast</code>와 null branch를 쓴다.</p></section><section><h3>정확한 class만 허용한다면 <code>ExactCast</code><a href="#정확한-class만-허용한다면-exactcast"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> (</span><span>UMyExactAsset</span><span>*</span><span> </span><span>Asset</span><span> </span><span>=</span><span> </span><span>ExactCast</span><span>&lt;</span><span>UMyExactAsset</span><span>&gt;(</span><span>Object</span><span>))</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>// UMyExactAsset의 subclass는 여기 들어오지 않는다.</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>raw pointer 구현은 <code>Object-&gt;GetClass() == UMyExactAsset::StaticClass()</code> 형태다. O(1)이지만 “Cast보다 빠르다”는 이유만으로 선택하지 않는다. subclass를 의도적으로 거부하는 semantics가 필요할 때만 쓴다.</p></section><section><h3>bool만 필요하면 <code>IsA</code><a href="#bool만-필요하면-isa"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> (</span><span>Object</span><span> </span><span>&amp;&amp;</span><span> </span><span>Object</span><span>-&gt;</span><span>IsA</span><span>&lt;</span><span>UMyAsset</span><span>&gt;</span><span>())</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>// type relation만 확인</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>그 뒤 같은 pointer를 다시 cast해서 사용한다면 <code>Cast</code> 한 번으로 pointer와 branch를 함께 얻는 편이 낫다.</p></section></section>
<section><h2>Unreal interface의 Blueprint-only 함정<a href="#unreal-interface의-blueprint-only-함정"><span>#</span></a></h2><p>Blueprint가 C++로 선언된 Unreal interface를 구현해도 native C++ <code>IInterface</code> subobject가 생기는 것은 아니다. Blueprint-only 구현에는 object pointer만 있고 native interface pointer는 없을 수 있다.</p><p>이 때문에 다음 코드는 Blueprint-only implementation에서 null일 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> (</span><span>IInteractable</span><span>*</span><span> </span><span>NativeInterface</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>IInteractable</span><span>&gt;(</span><span>Object</span><span>))</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>NativeInterface</span><span>-&gt;</span><span>InteractNativeOnly</span><span>();</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Blueprint 구현과 override까지 호출해야 한다면 reflection path를 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span> </span><span>TryInteract</span><span>(</span><span>UObject</span><span>*</span><span><span> </span><span>Object</span><span>, </span></span><span>AActor</span><span>*</span><span><span> </span><span>Instigator</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>IsValid</span><span>(</span><span>Object</span><span>) </span><span>||</span></div></div><div><div><div>4</div></div><div><span>        </span><span>!</span><span>Object</span><span>-&gt;</span><span>GetClass</span><span>()-&gt;</span><span>ImplementsInterface</span><span>(</span></div></div><div><div><div>5</div></div><div><span><span>            </span></span><span>UInteractable::</span><span>StaticClass</span><span>()))</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>IInteractable::</span><span>Execute_Interact</span><span>(</span><span>Object</span><span>, </span><span>Instigator</span><span>);</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><a href="https://dev.epicgames.com/documentation/unreal-engine/interfaces-in-unreal-engine?lang=en-US" target="_blank"><code>Interfaces in Unreal Engine</code></a>는 <code>Execute_</code> wrapper가 C++ 구현과 Blueprint override를 모두 호출하기 위한 path라고 설명한다. native interface pointer 자체가 필요한 API에서만 <code>Cast&lt;IInteractable&gt;</code>을 사용한다.</p></section>
<section><h2>Blueprint Cast node는 C++ template 호출과 동일하지 않다<a href="#blueprint-cast-node는-c-template-호출과-동일하지-않다"><span>#</span></a></h2><p>Blueprint의 <strong>Cast To</strong> node도 Unreal reflection 관계를 검사하지만 C++ <code>Cast&lt;T&gt;</code> template을 그대로 instantiate하는 것은 아니다.</p><ol>
<li>Kismet compiler가 class→class, interface→class, class→interface, interface→interface 조합에 맞는 cast statement를 만든다.</li>
<li>class cast bytecode에는 target <code>UClass</code> literal이 들어간다.</li>
<li>VM은 class target이면 <code>IsA</code>, interface target이면 <code>ImplementsInterface</code>와 script-interface path를 사용한다.</li>
</ol><p>Blueprint node에는 VM dispatch 비용이 더해지고 target class reference가 asset dependency에 참여할 수 있다. 이는 class hierarchy query 자체의 비용과 별도 축이다. cast node 하나의 CPU 비용, 반복 횟수, target Blueprint의 load dependency를 섞어서 “Cast To는 항상 비싸다”라고 결론 내리지 않는다.</p></section>
<section><h2>C++ RTTI와의 경계<a href="#c-rtti와의-경계"><span>#</span></a></h2><p>Unreal <code>Cast</code>는 <code>UObjectBase</code> 또는 Unreal interface 계열을 대상으로 한다. plain C++ class hierarchy에는 <code>UClass</code>가 없으므로 사용할 수 없다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>struct</span><span> </span><span>INativeService</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>virtual</span><span> </span><span>~INativeService</span><span>() </span><span>=</span><span> </span><span>default</span><span>;</span></div></div><div><div><div>4</div></div><div><span>};</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>struct</span><span> </span><span>FConcreteService</span><span> </span><span>final</span><span> : INativeService</span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 hierarchy의 checked downcast는 표준 <code>dynamic_cast</code>와 native RTTI 영역이다. UE module에서 필요하면 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/module-properties-in-unreal-engine" target="_blank">Module Properties</a>의 <code>bUseRTTI</code>와 platform ABI를 검토한다. RTTI를 끈 build가 <code>dynamic_cast</code>를 <code>static_cast</code>로 바꾸어 주지는 않는다. 표준 cast의 자세한 전제는 <a href="/posts/cpp-cast-operators-explained/">C++ 캐스팅 글</a>에서 다룬다.</p><p><code>FField</code>와 <code>FProperty</code>도 UObject hierarchy가 아니므로 <code>CastField&lt;T&gt;</code>를 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> (</span><span>FStructProperty</span><span>*</span><span> </span><span>StructProperty</span><span> </span><span>=</span></div></div><div><div><div>2</div></div><div><span>        </span><span>CastField</span><span>&lt;</span><span>FStructProperty</span><span>&gt;(</span><span>Property</span><span>))</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>const</span><span> </span><span>UScriptStruct</span><span>*</span><span> </span><span>Struct</span><span> </span><span>=</span><span> </span><span>StructProperty</span><span>-&gt;</span><span>Struct</span><span>;</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>결론<a href="#결론"><span>#</span></a></h2><p><code>Cast&lt;T&gt;</code>는 C++ RTTI의 값싼 복제품이 아니다. Unreal reflection이 이미 유지하는 <code>UClass</code> hierarchy와 interface metadata를 이용하는 UObject 전용 cast다. source와 target의 종류에 따라 cast flag, interface address, <code>IsA</code> 중 맞는 경로를 선택한다.</p><p>성능은 선택된 경로와 <code>IsChildOf</code> 구현 방식에 따라 다르다. outer walk는 inheritance depth에 비례하고, struct-array query와 cast flag, exact class 비교는 O(1)이다. interface와 object pointer는 추가 비용이 있을 수 있다. 그래서 API는 micro-optimization보다 의미로 고른다.</p><ul>
<li>mismatch가 정상: <code>Cast</code>와 null 검사</li>
<li>mismatch가 invariant 위반: <code>CastChecked</code></li>
<li>exact runtime class만: <code>ExactCast</code></li>
<li>bool만: guarded <code>IsA</code></li>
<li>Blueprint-capable interface: <code>ImplementsInterface</code> + <code>Execute_</code></li>
<li>non-UObject: 표준 C++ type system과 RTTI 정책</li>
</ul><p><code>Cast</code> 성공은 object lifetime, ownership, network authority, gameplay readiness를 보장하지 않는다. type check와 object validity를 분리해서 검증해야 한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/Cast?lang=en-US" target="_blank">Epic Games: <code>Cast</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/CastChecked?lang=en-US" target="_blank">Epic Games: <code>CastChecked</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/ExactCast" target="_blank">Epic Games: <code>ExactCast</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/UObjectBaseUtility/IsA" target="_blank">Epic Games: <code>UObjectBaseUtility::IsA</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/UStruct/IsChildOf?lang=en-US" target="_blank">Epic Games: <code>UStruct::IsChildOf</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/EClassCastFlags?lang=en-US" target="_blank">Epic Games: <code>EClassCastFlags</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/interfaces-in-unreal-engine?lang=en-US" target="_blank">Epic Games: Interfaces in Unreal Engine</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/FScriptInterface?lang=en-US" target="_blank">Epic Games: <code>FScriptInterface</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/CastField?lang=en-US" target="_blank">Epic Games: <code>CastField</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/module-properties-in-unreal-engine" target="_blank">Epic Games: Module Properties</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/cpp-cast-operators-explained/</id>
      <title type="text">C++ 캐스팅 안전하게 고르기: static_cast, dynamic_cast, const_cast, reinterpret_cast</title>
      <published>2024-01-28T00:00:00.000Z</published>
      <updated>2024-01-28T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/cpp-cast-operators-explained/"/>
      <summary type="text">C++23 규칙을 기준으로 네 가지 named cast와 C-style cast의 실제 의미, 숫자·상속·cv·비트 변환의 실패 조건과 undefined behavior, Unreal Engine의 RTTI 경계를 정리합니다.</summary>
      <content type="html"><![CDATA[<p>C++의 cast는 “값의 type을 바꾼다”는 한 문장으로 묶기 어렵다. 숫자 값을 변환할 수도 있고, 상속 계층 안에서 pointer를 조정할 수도 있으며, cv qualification만 바꾸거나 같은 storage를 다른 type처럼 해석하려 할 수도 있다. 목적과 전제가 전혀 다르다.</p>
<p>cast를 고를 때는 몇 가지 오해를 자주 접한다.</p>
<ul>
<li>C-style cast는 <code>dynamic_cast</code>를 선택할 수 있다.</li>
<li>RTTI를 끄면 <code>dynamic_cast</code>가 <code>static_cast</code>처럼 동작한다.</li>
<li><code>reinterpret_cast</code>는 bit representation을 그대로 유지한다.</li>
<li>64-bit pointer는 <code>unsigned int</code>에 저장할 수 있다.</li>
<li>floating-point 값을 integer로 바꾸면 단순히 반올림 오차만 생긴다.</li>
</ul>
<p>이 명제들은 일반 C++ 규칙이 아니다. 공개된 C++23 working draft를 기준으로 허용 조건, runtime 검사, 실패 결과, undefined behavior를 분리하고, Unreal Engine의 <code>Cast&lt;T&gt;</code>와 native RTTI 경계도 따로 정리한다.</p>
<p><code>static_cast</code>, <code>dynamic_cast</code>, <code>const_cast</code>, <code>reinterpret_cast</code> 네 named cast는 모두 최초 ISO C++ 표준인 C++98부터 함께 존재한다. <code>dynamic_cast</code>만 나중에 덧붙은 기능은 아니다.</p>
<section><h2>먼저 고르는 표<a href="#먼저-고르는-표"><span>#</span></a></h2>

<table><thead><tr><th>의도</th><th>선택</th><th>반드시 확인할 전제</th><th>실패하거나 잘못됐을 때</th></tr></thead><tbody><tr><td>명시적 숫자 변환</td><td><code>static_cast</code></td><td>범위, 유한성, 정밀도 정책</td><td>손실 가능, 일부 범위 밖 변환은 UB</td></tr><tr><td><code>Derived*</code> → <code>Base*</code></td><td>보통 implicit conversion</td><td>접근 가능하고 모호하지 않은 base</td><td>잘못된 계층이면 compile error</td></tr><tr><td>실제 dynamic type이 확실한 <code>Base*</code> → <code>Derived*</code></td><td><code>static_cast</code></td><td>accessible·unambiguous·non-virtual base, 실제 객체 보장</td><td>전제가 틀리면 cast 자체가 UB</td></tr><tr><td>실제 dynamic type이 불확실한 class pointer/reference</td><td><code>dynamic_cast</code></td><td>polymorphic source와 RTTI 지원</td><td>pointer는 null, reference는 <code>std::bad_cast</code></td></tr><tr><td>cv access path만 조정</td><td><code>const_cast</code></td><td>underlying type이 similar하고 실제 const 객체를 수정하지 않음</td><td>실제 const 수정 또는 volatile 우회는 UB</td></tr><tr><td>같은 크기의 trivially-copyable 값 bit 복사</td><td><code>std::bit_cast</code></td><td>두 type의 크기가 같고 trivially copyable</td><td>목적 type의 유효 representation 조건 확인</td></tr><tr><td>object representation의 byte 관찰</td><td><code>std::as_bytes</code>, <code>memcpy</code>, <code>char</code>·<code>unsigned char</code>·<code>std::byte</code></td><td>object lifetime이 유효함</td><td>unrelated typed lvalue로 읽으면 aliasing UB</td></tr><tr><td>pointer를 같은 process의 임시 integer token으로 왕복</td><td><code>reinterpret_cast&lt;std::uintptr_t&gt;</code></td><td><code>uintptr_t</code>가 존재하고 같은 pointer type으로 복원</td><td>mapping은 implementation-defined, 영속 ID로 사용 불가</td></tr><tr><td>unrelated pointer로 저수준 ABI 연결</td><td>가능하면 typed wrapper</td><td>alignment, lifetime, type accessibility를 모두 증명</td><td>cast 성공과 유효한 access는 별개</td></tr><tr><td>새 C++ 코드의 <code>(T)value</code></td><td>사용하지 않음</td><td>C ABI나 macro 경계처럼 피할 수 없는 경우만</td><td>어떤 위험한 변환이 선택됐는지 숨김</td></tr><tr><td>Unreal <code>UObject</code> 계층</td><td><code>Cast&lt;T&gt;</code></td><td>UE reflection 대상 type</td><td>불일치하면 null</td></tr><tr><td>Unreal이 아닌 native C++ 계층</td><td>RTTI 설정에 맞는 표준 cast</td><td>module·target의 RTTI와 ABI 검토</td><td>RTTI off가 unchecked cast로 바뀌지 않음</td></tr></tbody></table><p>cast를 쓰기 전에 implicit conversion, 생성자, virtual dispatch, variant, visitor, type-safe wrapper처럼 cast 자체를 없앨 수 있는지도 먼저 본다.</p></section>
<section><h2>암시적 변환도 항상 안전하지 않다<a href="#암시적-변환도-항상-안전하지-않다"><span>#</span></a></h2><p>compiler가 자동으로 수행한다고 값이 보존되는 것은 아니다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>double</span><span> </span><span>Ratio</span><span> </span><span>=</span><span> </span><span>1.75</span><span>;</span></div></div><div><div><div>2</div></div><div><span>int</span><span> </span><span>Count</span><span> </span><span>=</span><span> </span><span>Ratio</span><span>;</span><span> // 허용되지만 1로 변환된다.</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>brace initialization은 일부 narrowing을 compile time에 거부한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// int Count{Ratio}; // compile error: narrowing conversion</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>상속 계층의 안전한 upcast에는 cast 표기조차 필요하지 않다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>struct</span><span> </span><span>Animal</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>virtual</span><span> </span><span>~Animal</span><span>() </span><span>=</span><span> </span><span>default</span><span>;</span></div></div><div><div><div>4</div></div><div><span>};</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>struct</span><span> </span><span>Cat</span><span> </span><span>final</span><span> : Animal</span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>};</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>Cat</span><span>*</span><span> </span><span>CatPointer</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>11</div></div><div><span>Animal</span><span>*</span><span> </span><span>AnimalPointer</span><span> </span><span>=</span><span> </span><span>CatPointer</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>명시적 cast는 의도를 드러낼 때 유용하지만, compiler가 이미 안전하게 표현할 수 있는 변환에 습관적으로 붙일 필요는 없다.</p></section>
<section><h2><code>static_cast</code>: compile-time에 허용되는 명시적 변환<a href="#static_cast-compile-time에-허용되는-명시적-변환"><span>#</span></a></h2><p><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.static.cast" target="_blank"><code>static_cast</code> 규칙</a>은 숫자 변환 외에도 explicit constructor·conversion function을 쓰는 direct initialization, 상속 계층 변환, <code>void*</code>에서 object pointer로의 변환, enum 변환, value를 <code>void</code>로 버리기 등 여러 범주를 다룬다. 가장 자주 마주치는 숫자와 downcast를 따로 보자.</p><section><h3>숫자 변환은 조합마다 규칙이 다르다<a href="#숫자-변환은-조합마다-규칙이-다르다"><span>#</span></a></h3>

<table><thead><tr><th>source → destination</th><th>C++23의 핵심 규칙</th></tr></thead><tbody><tr><td>floating → integer</td><td>소수부를 버려 0 방향으로 절삭. 절삭 결과가 표현 범위 밖이면 UB</td></tr><tr><td>integer → integer</td><td>destination width에 맞는 합동 값. signedness와 width가 바뀌면 원래 값이 보존되지 않을 수 있음</td></tr><tr><td>integer → floating</td><td>정확히 표현하지 못하면 인접 값 중 하나. 범위 밖이면 UB</td></tr><tr><td>floating → floating</td><td>정확하지 않으면 허용된 인접 값 중 하나. destination 범위 밖이면 UB</td></tr></tbody></table><p>특히 <code>NaN</code>, <code>+∞</code>, <code>-∞</code>는 integer로 표현할 수 없으므로 floating→integer cast의 안전한 입력이 아니다. 사용자 입력이나 network 값을 바로 cast하지 말고 먼저 유한성과 범위를 검사한다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>&lt;cmath&gt;</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>&lt;cstdint&gt;</span></div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>&lt;limits&gt;</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>&lt;optional&gt;</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>std::</span><span>optional</span><span>&lt;std::</span><span>int32_t</span><span>&gt; </span><span>ToInt32</span><span>(</span><span>double</span><span><span> </span><span>Value</span><span>)</span></span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span><span>std</span><span>::</span></span><span>isfinite</span><span>(</span><span>Value</span><span>))</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>10</div></div><div><span>        </span><span>return</span><span> std::</span><span>nullopt</span><span>;</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>    </span><span>const</span><span> </span><span>double</span><span> </span><span>Truncated</span><span> </span><span>=</span><span> std::</span><span>trunc</span><span>(</span><span>Value</span><span>);</span></div></div><div><div><div>14</div></div><div><span>    </span><span>const</span><span> </span><span>double</span><span> </span><span>Minimum</span><span> </span><span>=</span><span> </span><span><span>static_cast</span><span>&lt;double&gt;</span></span><span>(</span></div></div><div><div><div>15</div></div><div><span><span>        </span></span><span>std::</span><span>numeric_limits</span><span>&lt;std::</span><span>int32_t</span><span>&gt;::</span><span>min</span><span>());</span></div></div><div><div><div>16</div></div><div><span>    </span><span>const</span><span> </span><span>double</span><span> </span><span>Maximum</span><span> </span><span>=</span><span> </span><span><span>static_cast</span><span>&lt;double&gt;</span></span><span>(</span></div></div><div><div><div>17</div></div><div><span><span>        </span></span><span>std::</span><span>numeric_limits</span><span>&lt;std::</span><span>int32_t</span><span>&gt;::</span><span>max</span><span>());</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>    </span><span>if</span><span> (</span><span>Truncated</span><span> </span><span>&lt;</span><span> </span><span>Minimum</span><span> </span><span>||</span><span> </span><span>Truncated</span><span> </span><span>&gt;</span><span> </span><span>Maximum</span><span>)</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>21</div></div><div><span>        </span><span>return</span><span> std::</span><span>nullopt</span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>    </span><span>return</span><span> </span><span><span>static_cast</span><span>&lt;</span></span><span>std::</span><span>int32_t&gt;</span><span>(</span><span>Truncated</span><span>);</span></div></div><div><div><div>25</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이 함수는 “소수부를 0 방향으로 버린다”는 정책을 이름과 구현으로 고정한다. 반올림이 목적이라면 <code>std::round</code>, banker’s rounding, saturation 등 원하는 정책을 별도로 정한 뒤 범위를 검사해야 한다.</p></section><section><h3><code>static_cast</code> downcast는 runtime type check가 아니다<a href="#static_cast-downcast는-runtime-type-check가-아니다"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>struct</span><span> </span><span>Animal</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>virtual</span><span> </span><span>~Animal</span><span>() </span><span>=</span><span> </span><span>default</span><span>;</span></div></div><div><div><div>4</div></div><div><span>};</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>struct</span><span> </span><span>Cat</span><span> </span><span>final</span><span> : Animal</span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>void</span><span> </span><span>Meow</span><span>();</span></div></div><div><div><div>9</div></div><div><span>};</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>Cat</span><span>*</span><span> </span><span>AssumeCat</span><span>(</span><span>Animal</span><span>*</span><span><span> </span><span>Value</span><span>)</span></span></div></div><div><div><div>12</div></div><div><span>{</span></div></div><div><div><div>13</div></div><div><span>    </span><span>return</span><span> </span><span><span>static_cast</span><span>&lt;</span></span><span>Cat</span><span>*&gt;</span><span>(</span><span>Value</span><span>);</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 코드는 <code>Animal</code>과 <code>Cat</code>의 문법적 관계는 검사하지만 <code>Value</code>가 실제로 <code>Cat</code> object의 <code>Animal</code> subobject를 가리키는지는 검사하지 않는다. 실제 object가 <code>Cat</code>이 아니면 <a href="https://timsong-cpp.github.io/cppwp/n4950/expr.static.cast" target="_blank">규칙상 변환 자체의 behavior가 undefined</a>다.</p><p>null pointer 입력은 null <code>Cat*</code>로 유지된다. non-null pointer가 실제로 어떤 object를 가리키는지가 위험의 핵심이다.</p><p>이 downcast가 허용되려면 destination class가 complete type이고 base가 accessible, unambiguous, non-virtual이어야 한다. virtual base에서 이 방식으로 내려가는 것은 ill-formed다. 외부 입력이나 변할 수 있는 상태라면 <code>dynamic_cast</code> 같은 checked path를 사용한다.</p><p><code>static_cast&lt;T*&gt;(VoidPointer)</code>도 object를 새로 만들거나 lifetime을 시작하지 않는다. 주소 alignment와 실제 object type이 맞는 storage라는 별도 증거가 필요하다.</p></section></section>
<section><h2><code>dynamic_cast</code>: polymorphic 계층의 runtime 검사<a href="#dynamic_cast-polymorphic-계층의-runtime-검사"><span>#</span></a></h2><p><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.dynamic.cast" target="_blank"><code>dynamic_cast</code> 규칙</a>은 class pointer/reference와 <code>pointer to cv void</code>에 적용된다. checked downcast와 cross-cast의 source는 polymorphic class여야 한다. polymorphic이란 virtual member function이 하나라도 있는 class다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>struct</span><span> </span><span>Animal</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>virtual</span><span> </span><span>~Animal</span><span>() </span><span>=</span><span> </span><span>default</span><span>;</span></div></div><div><div><div>4</div></div><div><span>};</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>struct</span><span> </span><span>Cat</span><span> </span><span>final</span><span> : Animal</span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>void</span><span> </span><span>Meow</span><span>();</span></div></div><div><div><div>9</div></div><div><span>};</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>void</span><span> </span><span>TryMeow</span><span>(</span><span>Animal</span><span>*</span><span><span> </span><span>Value</span><span>)</span></span></div></div><div><div><div>12</div></div><div><span>{</span></div></div><div><div><div>13</div></div><div><span>    </span><span>if</span><span> (</span><span>Cat</span><span>*</span><span> </span><span>CatValue</span><span> </span><span>=</span><span> </span><span><span>dynamic_cast</span><span>&lt;</span></span><span>Cat</span><span>*&gt;</span><span>(</span><span>Value</span><span>))</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>        </span><span>CatValue</span><span>-&gt;</span><span>Meow</span><span>();</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>실패 결과는 destination 형태에 따라 다르다.</p><ul>
<li>pointer cast 실패: null</li>
<li>reference cast 실패: <code>std::bad_cast</code></li>
<li>null pointer 입력: null</li>
<li><code>dynamic_cast&lt;void*&gt;(Pointer)</code>: polymorphic object의 most-derived object 주소</li>
</ul><p>virtual destructor 자체가 <code>dynamic_cast</code>만의 특별한 요건은 아니다. 어떤 virtual function이든 polymorphic 조건을 만든다. 다만 base pointer로 object를 <code>delete</code>할 설계라면 별개의 lifetime 이유로 virtual destructor가 필요하다.</p><p>RTTI를 끈 것은 ISO C++에서 새로운 cast 의미를 정의하지 않는다. compiler가 일부 형태를 거부하거나 제한적으로 지원할 수 있지만 checked downcast를 <code>static_cast</code>로 바꾸어 주지 않는다. 실패할 수 있는 type 검사를 unchecked cast로 대체하고 싶다면 source code에서 직접 위험을 선택해야 하며, build flag가 조용히 대신해 주는 규칙은 없다.</p></section>
<section><h2><code>const_cast</code>: cv qualification만 조정<a href="#const_cast-cv-qualification만-조정"><span>#</span></a></h2><p><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.const.cast" target="_blank"><code>const_cast</code> 규칙</a>은 similar underlying type 사이에서 <code>const</code>와 <code>volatile</code> qualification을 조정한다. unrelated type을 바꾸는 도구가 아니며, cv를 제거할 수 있는 유일한 named cast다.</p><p>중요한 구분은 <strong>access path가 const인가</strong>와 <strong>실제 object가 const로 정의됐는가</strong>이다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>int</span><span> </span><span>MutableValue</span><span> </span><span>=</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>int*</span><span> </span><span>ReadView</span><span> </span><span>=</span><span> </span><span>&amp;</span><span>MutableValue</span><span>;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>*</span><span>const_cast</span><span>&lt;int*&gt;</span></span><span>(</span><span>ReadView</span><span>) </span><span>=</span><span> </span><span>2</span><span>;</span><span> // defined: 실제 object는 non-const</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>const</span><span> </span><span>int</span><span> </span><span>FixedValue</span><span> </span><span>=</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>7</div></div><div><span>// *const_cast&lt;int*&gt;(&amp;FixedValue) = 2; // UB: 실제 object가 const</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>실제 const complete object나 subobject를 수정하면 UB다. 실제 volatile object를 non-volatile glvalue를 통해 access하는 것도 UB다. cast 표현이 compile됐다는 사실은 object의 원래 선언을 바꾸지 않는다.</p><p>pointer뿐 아니라 object reference, rvalue reference, data-member pointer 관련 형태에도 사용할 수 있다. 반대로 function pointer나 member-function pointer에서 임의의 qualification을 바꾸는 도구는 아니다.</p><p>잘못 설계된 C API가 mutable pointer를 요구하지만 계약상 읽기만 하고 보관하지 않는다는 사실이 검증된 경우, 좁은 adapter boundary에서 사용할 수 있다. “외부 library라서 괜찮다”가 아니라 <strong>실제 object가 non-const이고 호출 계약이 수정하지 않는다는 증거</strong>가 기준이다.</p></section>
<section><h2><code>reinterpret_cast</code>: cast와 유효한 access는 다르다<a href="#reinterpret_cast-cast와-유효한-access는-다르다"><span>#</span></a></h2><p><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.reinterpret.cast" target="_blank"><code>reinterpret_cast</code> 규칙</a>은 저수준 pointer·integer·reference 변환을 허용하지만 “bit를 그대로 둔 채 type 이름만 바꾼다”는 일반 보장은 하지 않는다. 표준은 mapping이 representation을 바꿀 수도, 바꾸지 않을 수도 있게 둔다.</p><section><h3>Pointer와 integer<a href="#pointer와-integer"><span>#</span></a></h3><p>pointer는 그 pointer type의 모든 값을 담을 만큼 큰 integer type으로 명시적으로 변환할 수 있고 mapping은 implementation-defined다. 64-bit pointer를 32-bit인 경우가 많은 <code>unsigned int</code>에 넣는 예제는 이 조건을 만족하지 않는다.</p><p><code>std::uintptr_t</code>가 implementation에 존재한다면 pointer 값을 담을 수 있는 unsigned integer type이다. 그래도 용도는 같은 process에서 object lifetime이 유지되는 동안 같은 pointer type으로 왕복하는 임시 token 정도로 제한한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>&lt;cstdint&gt;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>struct</span><span> </span><span>Widget</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>std::</span><span>uintptr_t</span><span> </span><span>ToTransientToken</span><span>(</span><span>Widget</span><span>*</span><span><span> </span><span>Pointer</span><span>)</span></span></div></div><div><div><div>6</div></div><div><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>return</span><span> </span><span><span>reinterpret_cast</span><span>&lt;</span></span><span>std::</span><span>uintptr_t&gt;</span><span>(</span><span>Pointer</span><span>);</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>Widget</span><span>*</span><span> </span><span>FromTransientToken</span><span>(std::</span><span>uintptr_t</span><span><span> </span><span>Token</span><span>)</span></span></div></div><div><div><div>11</div></div><div><span>{</span></div></div><div><div><div>12</div></div><div><span>    </span><span>return</span><span> </span><span><span>reinterpret_cast</span><span>&lt;</span></span><span>Widget</span><span>*&gt;</span><span>(</span><span>Token</span><span>);</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>std::uintptr_t</code>는 표준에서 optional이다. 지원되지 않는 implementation에는 이 code 자체를 제공하지 않아야 한다. 지원되더라도 token을 file, database, save game, network packet에 저장하면 안 된다. process 재시작, ASLR, object destruction, allocator 재사용 뒤에는 의미 있는 identity가 아니다.</p><p>충분히 큰 integer로 갔다가 <strong>같은 pointer type</strong>으로 돌아오는 round trip은 원래 pointer value를 보장한다. 모든 integer가 유효한 pointer가 되는 것, integer→pointer→integer의 일반적인 역변환, object가 계속 살아 있는 것은 보장하지 않는다.</p></section><section><h3>Unrelated object pointer<a href="#unrelated-object-pointer"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>float</span><span> </span><span>Value</span><span> </span><span>=</span><span> </span><span><span>1.0</span><span>f</span></span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>// 다음 pointer cast 자체가 목적 object를 만들지는 않는다.</span></div></div><div><div><div>4</div></div><div><span>const</span><span> std::</span><span>uint32_t*</span><span> </span><span>Bits</span><span> </span><span>=</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span><span>reinterpret_cast</span><span>&lt;const</span></span><span> std::</span><span>uint32_t*&gt;</span><span>(</span><span>&amp;</span><span>Value</span><span>);</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>// const std::uint32_t BadRead = *Bits; // aliasing/type-access UB</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>pointer를 얻었다고 dereference가 합법이 되는 것은 아니다. 최소 세 조건을 각각 증명해야 한다.</p><ol>
<li>주소가 destination type의 alignment를 만족한다.</li>
<li>그 위치에서 destination type object의 lifetime이 시작되어 있다.</li>
<li>해당 glvalue type을 통한 access가 type-accessible하다.</li>
</ol><p>object representation을 읽는 예외는 <code>char</code>, <code>unsigned char</code>, <code>std::byte</code>다. <code>signed char</code>까지 같은 예외라고 일반화하지 않는다. unrelated numeric pointer를 만들어 dereference하는 전통적인 type punning은 이 규칙을 위반할 수 있다.</p></section><section><h3>값의 bit pattern이 필요하면 <code>std::bit_cast</code><a href="#값의-bit-pattern이-필요하면-stdbit_cast"><span>#</span></a></h3><p>같은 크기의 trivially-copyable type 사이에서 value representation을 복사하려면 C++20의 <a href="https://timsong-cpp.github.io/cppwp/n4950/bit.cast" target="_blank"><code>std::bit_cast</code></a>를 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>&lt;bit&gt;</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>&lt;cstdint&gt;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>static_assert</span><span>(</span><span>sizeof</span><span>(</span><span>float</span><span>) </span><span>==</span><span> </span><span>sizeof</span><span>(std::</span><span>uint32_t</span><span>));</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>float</span><span> </span><span>Value</span><span> </span><span>=</span><span> </span><span><span>1.0</span><span>f</span></span><span>;</span></div></div><div><div><div>7</div></div><div><span>const</span><span> std::</span><span>uint32_t</span><span> </span><span>Bits</span><span> </span><span>=</span><span> std::</span><span>bit_cast</span><span>&lt;std::</span><span>uint32_t</span><span>&gt;(</span><span>Value</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>byte sequence를 복사하려면 <code>std::memcpy</code>, 읽기 전용 byte view가 필요하면 <code>std::as_bytes</code>를 사용한다. raw storage에 object를 만들려면 alignment를 보장하고 placement new나 <code>std::construct_at</code>으로 lifetime을 시작한다. <code>reinterpret_cast</code> 하나가 이 세 작업을 대신하지 않는다.</p><p>function pointer를 다른 function pointer type으로 바꿨다가 원래 type으로 돌리는 round trip과, 다른 signature로 실제 호출하는 것은 별개다. 정의와 호환되지 않는 function type으로 호출하면 UB다. object pointer와 function pointer 사이의 지원 여부도 implementation 조건에 따라 확인한다.</p></section></section>
<section><h2>C-style cast는 <code>dynamic_cast</code>를 하지 않는다<a href="#c-style-cast는-dynamic_cast를-하지-않는다"><span>#</span></a></h2><p>C++의 <code>(T)Expression</code>과 <code>T(Expression)</code> explicit conversion은 문맥에 따라 다음 해석을 순서대로 시도하고 처음 허용되는 것을 선택한다.</p><ol>
<li><code>const_cast</code></li>
<li><code>static_cast</code></li>
<li><code>static_cast</code> 후 <code>const_cast</code></li>
<li><code>reinterpret_cast</code></li>
<li><code>reinterpret_cast</code> 후 <code>const_cast</code></li>
</ol><p><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.cast" target="_blank"><code>explicit cast notation</code> 규칙</a>에 <code>dynamic_cast</code>는 없다. 그래서 C-style cast는 runtime type check를 자동으로 골라 주지 않는다. 오히려 cv 제거와 위험한 pointer 변환을 한 표기 안에 숨길 수 있고, 일부 base conversion에서는 named <code>static_cast</code>보다 access control을 우회하는 예외도 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>Animal</span><span>*</span><span> </span><span>Value</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>// 무엇을 결합했는지 한눈에 드러나지 않는다.</span></div></div><div><div><div>4</div></div><div><span>Cat</span><span>*</span><span> </span><span>Hidden</span><span> </span><span>=</span><span> (</span><span>Cat</span><span>*</span><span>)</span><span>Value</span><span>;</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>// 위험하더라도 의도가 분리되어 review할 수 있다.</span></div></div><div><div><div>7</div></div><div><span>Animal</span><span>*</span><span> </span><span>Mutable</span><span> </span><span>=</span><span> </span><span><span>const_cast</span><span>&lt;</span></span><span>Animal</span><span>*&gt;</span><span>(</span><span>Value</span><span>);</span></div></div><div><div><div>8</div></div><div><span>Cat</span><span>*</span><span> </span><span>Assumed</span><span> </span><span>=</span><span> </span><span><span>static_cast</span><span>&lt;</span></span><span>Cat</span><span>*&gt;</span><span>(</span><span>Mutable</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>두 표현이 우연히 같은 semantics를 선택하고 compiler가 같은 machine code를 만들 수는 있다. 그러나 ISO C++가 모든 build에서 동일 code generation이나 성능을 보장하는 것은 아니며, C-style cast가 checked cast의 비용을 대신 지불하는 일도 없다.</p></section>
<section><h2>Unreal Engine에서는 두 type system을 구분한다<a href="#unreal-engine에서는-두-type-system을-구분한다"><span>#</span></a></h2><section><h3><code>UObject</code> 계층: <code>Cast&lt;T&gt;</code><a href="#uobject-계층-castt"><span>#</span></a></h3><p><code>UObject</code>, <code>AActor</code>, <code>UActorComponent</code>, Unreal interface 같은 reflection type에는 Unreal의 <code>Cast&lt;T&gt;</code>를 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UObject</span><span>*</span><span> </span><span>Object</span><span> </span><span>=</span><span> </span><span>GetCandidateObject</span><span>();</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>if</span><span> (</span><span>AMyActor</span><span>*</span><span> </span><span>Actor</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>AMyActor</span><span>&gt;(</span><span>Object</span><span>))</span></div></div><div><div><div>4</div></div><div><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>Actor</span><span>-&gt;</span><span>ActivateFeature</span><span>();</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-object-handling-in-unreal-engine" target="_blank"><code>Unreal Object Handling</code></a>은 UObject가 자신의 <code>UClass</code> 정보를 가지고 있으며, <code>Cast</code>가 실패하면 null을 반환한다고 설명한다. 이 path는 native C++ RTTI가 아니라 Unreal reflection을 사용하므로 Blueprint-generated subclass도 class 관계에 포함된다.</p><p>불일치가 정상적으로 가능한 입력은 <code>Cast</code>와 null 검사로 처리한다. <code>CastChecked</code>는 성공이 programmer invariant인 곳의 assertion 용도이지, 외부 입력을 항상 안전하게 검증하는 대체 API가 아니다. Unreal <code>Cast</code>의 내부 분기와 <code>ExactCast</code>, interface, Blueprint 경계는 다음 글에서 별도로 다룬다.</p></section><section><h3>Plain native C++ 계층: RTTI 설정 확인<a href="#plain-native-c-계층-rtti-설정-확인"><span>#</span></a></h3><p>UObject가 아닌 일반 polymorphic C++ class에는 Unreal reflection이 없다. checked <code>dynamic_cast</code>가 필요하면 build가 native RTTI를 지원하도록 설정해야 한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>public</span><span> </span><span>MyModule</span><span><span>(</span><span>ReadOnlyTargetRules</span><span> </span><span>Target</span><span>) : </span></span><span>base</span><span><span>(</span><span>Target</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>bUseRTTI</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Unreal Build Tool은 module별 <code>bUseRTTI</code> 설정을 제공한다. Unreal module의 기본과 일반 Visual C++ project의 기본을 섞지 않는다. Microsoft의 <a href="https://learn.microsoft.com/en-us/cpp/build/reference/gr-enable-run-time-type-information?view=msvc-170" target="_blank"><code>/GR</code> 문서</a>는 일반 MSVC project에서 RTTI가 기본 활성이라고 설명하지만, Unreal Build Tool은 자체 규칙으로 compiler flag를 결정한다.</p><p>RTTI가 필요한 third-party ABI나 native hierarchy가 있다면 한 module에서 flag만 켜기 전에 platform, linked object의 flag, exception/ABI 정책을 함께 검토한다. Epic의 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/integrating-third-party-libraries-into-unreal-engine" target="_blank">third-party library integration 안내</a>는 RTTI 설정이 다른 binary를 섞을 때의 platform 제약을 별도로 설명한다.</p><p>RTTI를 켜기 어렵다면 <code>static_cast</code>로 type check를 없애는 것이 자동 해법은 아니다. virtual interface, tagged union, visitor, explicit type identifier처럼 type 관계를 설계에 표현하거나, 실제 dynamic type이 외부 invariant로 보장되는 매우 좁은 boundary에서만 unchecked cast를 사용한다.</p></section></section>
<section><h2>검토 체크리스트<a href="#검토-체크리스트"><span>#</span></a></h2><ul>
<li>implicit conversion이나 typed API로 cast를 제거할 수 있는가?</li>
<li>숫자 변환 전에 유한성·범위·절삭/반올림 정책을 검사했는가?</li>
<li>downcast의 실제 dynamic type을 누가 보장하는가?</li>
<li>mismatch가 정상 흐름이면 null 또는 exception을 처리하는 checked cast인가?</li>
<li><code>const_cast</code> 뒤에 실제 const object를 수정하거나 volatile access를 우회하지 않는가?</li>
<li><code>reinterpret_cast</code> 뒤 alignment, object lifetime, type accessibility가 모두 성립하는가?</li>
<li>bit copy라면 <code>std::bit_cast</code>나 <code>memcpy</code>가 더 정확한가?</li>
<li>pointer integer를 영속 ID나 network 값으로 오해하지 않는가?</li>
<li>C-style cast가 cv 제거와 pointer 변환을 한 번에 숨기지 않는가?</li>
<li>UE code라면 UObject reflection 대상과 plain native C++ type을 구분했는가?</li>
<li>native <code>dynamic_cast</code>가 필요하다면 module·target RTTI와 platform ABI를 검증했는가?</li>
</ul><p>좋은 cast 선택은 “가장 빠른 연산자”를 외우는 일이 아니다. compile-time 관계, runtime type, object의 원래 cv 상태, storage lifetime과 representation 중 무엇을 바꾸려는지부터 밝힌 뒤 그 전제를 코드와 검사로 남겨야 한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.type.conv" target="_blank">C++23 working draft: explicit type conversion</a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.cast" target="_blank">C++23 working draft: explicit cast notation</a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.static.cast" target="_blank">C++23 working draft: <code>static_cast</code></a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.dynamic.cast" target="_blank">C++23 working draft: <code>dynamic_cast</code></a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.const.cast" target="_blank">C++23 working draft: <code>const_cast</code></a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/expr.reinterpret.cast" target="_blank">C++23 working draft: <code>reinterpret_cast</code></a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/conv.fpint" target="_blank">C++23 working draft: floating-integral conversions</a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/conv.integral" target="_blank">C++23 working draft: integral conversions</a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/basic.lval" target="_blank">C++23 working draft: type accessibility</a></li>
<li><a href="https://timsong-cpp.github.io/cppwp/n4950/bit.cast" target="_blank">C++23 working draft: <code>std::bit_cast</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-object-handling-in-unreal-engine" target="_blank">Epic Games: Unreal Object Handling</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/module-properties-in-unreal-engine" target="_blank">Epic Games: Module Properties</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/integrating-third-party-libraries-into-unreal-engine" target="_blank">Epic Games: Integrating Third-Party Libraries</a></li>
<li><a href="https://learn.microsoft.com/en-us/cpp/build/reference/gr-enable-run-time-type-information?view=msvc-170" target="_blank">Microsoft: <code>/GR</code> runtime type information</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-editor-fullscreen-foreachattachedactors-crash/</id>
      <title type="text">전체화면 PIE 종료 시 ForEachAttachedActors 재귀 크래시</title>
      <published>2024-01-17T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-editor-fullscreen-foreachattachedactors-crash/"/>
      <summary type="text">전체화면 PIE를 종료할 때 AActor::ForEachAttachedActors가 재귀하며 스택 오버플로를 일으킨 사례를 컴포넌트 소유권과 부착 계층 관점에서 정리합니다.</summary>
      <content type="html"><![CDATA[<p>저장 데이터를 불러온 뒤 F11로 전체화면 PIE를 종료하면 editor가 <code>AActor::ForEachAttachedActors</code> 안에서 스택 오버플로를 일으키며 종료됐다.</p>
<p></p><figure><img alt="전체화면 PIE에서 문제를 재현한 게임 화면" loading="lazy" width="1928" height="1048" src="/_astro/pie-fullscreen-reproduction.CHtd6G0c_Z1vYKBI.webp" /><figcaption>전체화면 PIE에서 문제를 재현한 게임 화면</figcaption></figure><p></p>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>크래시는 전체화면 전환 때 나타났지만 실제 call stack에서는 attached actor를 순회하는 동일한 프레임이 반복됐다.</p><p></p><figure><img alt="ForEachAttachedActors가 반복된 스택 오버플로 call stack" loading="lazy" width="1920" height="1040" src="/_astro/foreach-attached-actors-stack-overflow.ZkOzrMa7_Z19gsK0.webp" /><figcaption>ForEachAttachedActors가 반복된 스택 오버플로 call stack</figcaption></figure><p></p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>대상은 런타임에 <code>NewObject</code>로 scene component를 만드는 UE 프로젝트다. 이를 다른 actor가 소유한 component나 socket에 부착하는 코드가 있었다. 종료·월드 정리 시점에만 크래시가 보인다면 생성 당시의 owner와 attachment 관계도 함께 확인할 가치가 있다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>문제가 된 코드는 새 <code>UStaticMeshComponent</code>의 Outer를 현재 객체로 지정했다. 그런 다음 별도의 전시 actor에 owned component로 추가했다. 이 component를 또 다른 actor가 소유한 mesh에 붙이면서 UObject 소유권, actor의 owned component 목록, scene attachment 계층이 서로 다른 대상을 가리켰다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UStaticMeshComponent</span><span>*</span><span> </span><span>DisplayComponent</span><span> </span><span>=</span></div></div><div><div><div>2</div></div><div><span>    </span><span>NewObject</span><span>&lt;</span><span>UStaticMeshComponent</span><span>&gt;(</span><span>this</span><span>, </span><span>TEXT</span><span>(</span><span>"RuntimeDisplayMesh"</span><span>));</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>DisplayActor</span><span>-&gt;</span><span>AddOwnedComponent</span><span>(</span><span>DisplayComponent</span><span>);</span></div></div><div><div><div>5</div></div><div><span>DisplayComponent</span><span>-&gt;</span><span>AttachToComponent</span><span>(</span></div></div><div><div><div>6</div></div><div><span>    </span><span>ItemActor</span><span>-&gt;</span><span>GetMesh</span><span>(),</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>FAttachmentTransformRules::</span><span>SnapToTargetNotIncludingScale</span><span>,</span></div></div><div><div><div>8</div></div><div><span>    </span><span>SocketName</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 불일치는 월드를 정리하면서 attached actor를 순회할 때 순환 관계처럼 드러났다. F11은 문제의 원인이 아니다. 숨어 있던 잘못된 계층이 종료 경로에서 노출된 계기였다.</p></section>
<section><h2>재현 조건<a href="#재현-조건"><span>#</span></a></h2><ol>
<li>저장 데이터에서 전시 actor와 부착 대상 actor를 복원한다.</li>
<li>런타임 component를 생성하고 대상 socket에 부착한다.</li>
<li>PIE를 전체화면으로 전환한 뒤 종료한다.</li>
<li>call stack에서 <code>ForEachAttachedActors</code>가 같은 actor들을 반복하는지 확인한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>런타임 component의 Outer와 owner를 실제로 수명 관리할 actor로 일치시켰다. 부착 전에는 대상 component와 socket이 유효한지도 검사했다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> (</span><span>!</span><span>IsValid</span><span>(</span><span>DisplayActor</span><span>) </span><span>||</span><span> </span><span>!</span><span>IsValid</span><span>(</span><span>ItemActor</span><span>) </span><span>||</span></div></div><div><div><div>2</div></div><div><span>    </span><span>!</span><span>IsValid</span><span>(</span><span>ItemActor</span><span>-&gt;</span><span>GetMesh</span><span>()) </span><span>||</span></div></div><div><div><div>3</div></div><div><span>    </span><span>!</span><span>ItemActor</span><span>-&gt;</span><span>GetMesh</span><span>()-&gt;</span><span>DoesSocketExist</span><span>(</span><span>SocketName</span><span>))</span></div></div><div><div><div>4</div></div><div><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>UStaticMeshComponent</span><span>*</span><span> </span><span>DisplayComponent</span><span> </span><span>=</span></div></div><div><div><div>9</div></div><div><span>    </span><span>NewObject</span><span>&lt;</span><span>UStaticMeshComponent</span><span>&gt;(</span><span>DisplayActor</span><span>, </span><span>TEXT</span><span>(</span><span>"RuntimeDisplayMesh"</span><span>));</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>DisplayActor</span><span>-&gt;</span><span>AddInstanceComponent</span><span>(</span><span>DisplayComponent</span><span>);</span></div></div><div><div><div>12</div></div><div><span>DisplayComponent</span><span>-&gt;</span><span>RegisterComponent</span><span>();</span></div></div><div><div><div>13</div></div><div><span>DisplayComponent</span><span>-&gt;</span><span>AttachToComponent</span><span>(</span></div></div><div><div><div>14</div></div><div><span>    </span><span>ItemActor</span><span>-&gt;</span><span>GetMesh</span><span>(),</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>FAttachmentTransformRules::</span><span>SnapToTargetNotIncludingScale</span><span>,</span></div></div><div><div><div>16</div></div><div><span>    </span><span>SocketName</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>특정 API 이름보다 component를 소유하고 파괴할 주체를 명확히 정하는 일이 중요하다. component를 <code>DisplayActor</code>가 소유할지 <code>ItemActor</code>가 소유할지는 실제 생명주기에 맞춰 하나로 정해야 한다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>저장 데이터 로드 전후에 owner, Outer, attach parent와 owning actor를 로그로 비교한다.</li>
<li>PIE 일반 창·전체화면 종료를 여러 번 반복한다.</li>
<li>level transition, actor destroy, 저장 데이터 재로드에서도 component가 중복 등록되지 않는지 확인한다.</li>
<li><code>EndPlay</code>에서 attachment를 명시적으로 끊어야 하는 설계인지 검토한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p><code>AddOwnedComponent</code>, <code>AddInstanceComponent</code>, <code>RegisterComponent</code>는 서로 완전히 같은 역할이 아니다. 생성 방식과 editor instance component로서의 저장 필요 여부에 맞게 선택해야 한다. 종료 전에 component를 강제 삭제해 call stack만 없애면 잘못된 소유 관계가 다른 경로에서 다시 나타날 여지가 있다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/Engine/AActor/ForEachAttachedActors" target="_blank">Epic Games: AActor::ForEachAttachedActors</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-interface-limit-static-assert/</id>
      <title type="text">Unreal C++ Interface 64개 제한과 C2607 진단</title>
      <published>2023-12-19T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-interface-limit-static-assert/"/>
      <summary type="text">UnrealHeaderTool 생성 코드에서 interface 배열 개수 제한 static_assert와 C2607이 발생할 때 상속된 interface까지 포함해 원인을 찾는 방법을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>많은 Unreal Interface를 구현한 클래스에서는 generated code를 컴파일할 때 아래와 비슷한 오류가 발생하기도 한다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>error C2607: static assertion failed</span></div></div><div><div><div>2</div></div><div><span>UE_ARRAY_COUNT(Z_Construct_UClass_..._Statics::InterfaceParams) &lt; 64</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>오류는 직접 작성한 <code>.cpp</code>가 아니라 <code>.gen.cpp</code>의 <code>FImplementedInterfaceParams</code> 배열과 <code>FClassParams</code> 초기화 부근에서 난다. 처음에는 UHT 생성 오류처럼 보이지만 실제 입력은 클래스가 구현한다고 선언한 reflected interface 집합이다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>이 제한은 오류를 만든 정확한 엔진 버전의 생성 코드와 <code>FClassParams</code> 표현에 종속된다. 원문 사례에서는 구현 interface 개수를 담는 필드가 6비트로 표현됐고 generated code가 64 미만을 요구했다. 최신 버전에서도 같은 숫자라고 가정해서는 안 된다. 설치한 엔진의 generated code와 <code>Class.h</code>를 확인해야 한다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>하나의 <code>UCLASS</code>가 직접 또는 부모 클래스를 통해 구현하는 reflected Unreal Interface의 총수가 해당 버전의 표현 한계를 넘었다. C++ 다중 상속 목록만 세면 부족하다. 부모 클래스가 구현한 interface와 Blueprint 계층에서 추가된 interface도 최종 class metadata에 포함되기도 한다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>오류가 난 <code>.gen.cpp</code>에서 <code>InterfaceParams</code> 배열 항목을 센다.</li>
<li>직접 구현한 interface와 상속받은 interface를 분리해 목록화한다.</li>
<li>Blueprint 자식이 아니라 native class 생성 중 실패했는지 확인한다.</li>
<li>설치한 엔진 source에서 static assertion과 개수 필드의 정의를 확인한다.</li>
<li>최근 추가한 interface를 제거했을 때 경계 아래로 내려가는지 최소 재현한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>가장 안전하게 해결하려면 한 클래스가 구현하는 interface 수를 줄인다.</p><ul>
<li>기능별로 component를 두고 interface 구현을 component로 이동한다.</li>
<li>매우 작은 interface 여러 개가 항상 함께 쓰인다면 응집도 높은 하나의 계약으로 합친다.</li>
<li>단순 질의는 gameplay tag, capability registry 또는 명시적 component 조회로 대체한다.</li>
<li>상속 계층의 중복 interface 선언을 제거한다.</li>
</ul><p>엔진의 bit field나 assertion을 넓히려면 engine fork 전체에서 reflection ABI와 serialization에 미치는 영향을 검토해야 한다. 프로젝트 코드만 컴파일되게 숫자를 바꾸는 임시 수정으로 취급해서는 안 된다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>clean UHT/build에서 generated code가 다시 생성되는지 확인한다.</li>
<li>C++와 Blueprint 양쪽에서 <code>ImplementsInterface</code>와 interface call이 정상인지 검사한다.</li>
<li>cooked build에서 class load와 Blueprint compile을 확인한다.</li>
<li>interface를 component로 옮겼다면 복제·저장·수명 주기 동작도 회귀 테스트한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>이 문제는 “C++는 최대 64개만 상속할 수 있다”는 일반 언어 제한이 아니다. Unreal reflection metadata에 구현 interface 목록을 저장하는 특정 엔진 구현의 제한이다. 정확한 버전의 근거를 확보하기 전에는 제한값을 보편 규칙처럼 문서화하지 않는 편이 안전하다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/interfaces-in-unreal-engine" target="_blank">Epic Games: Interfaces in Unreal Engine</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/ue5-root-motion-ik-retargeting/</id>
      <title type="text">UE5 IK Retargeter에서 Root Motion이 앞으로 이동하지 않을 때</title>
      <published>2023-12-11T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/ue5-root-motion-ik-retargeting/"/>
      <summary type="text">UE5 IK Retargeter 결과에서 수직 Root Motion은 보이지만 전진 이동이 사라질 때 Root chain과 translation 설정을 점검하는 방법을 설명합니다.</summary>
      <content type="html"><![CDATA[<p>UE5 IK Retargeter로 애니메이션을 변환한 뒤 root의 상하 움직임은 남았지만 전진 이동은 사라졌다.</p>
<p></p><figure><img alt="Root Motion 전진 이동이 사라진 리타기팅 결과" loading="lazy" width="1105" height="626" src="/_astro/root-motion-retargeting-before.Cn-PtxVE_Z2owpm3.webp" /><figcaption>Root Motion 전진 이동이 사라진 리타기팅 결과</figcaption></figure><p></p>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>원본 애니메이션에는 root bone의 전진 변위가 있지만 retarget 결과는 제자리에서 재생됐다. pelvis 동작은 대체로 정상이어서 animation asset의 Root Motion 설정만 바꿔서는 문제가 해결되지 않았다.</p></section>
<section><h2>적용 버전<a href="#적용-버전"><span>#</span></a></h2><p>원문은 UE5 초기 IK Rig/IK Retargeter UI를 기준으로 한다. UE 5.6 이후에는 Retarget Root, root settings, operation stack 표현이 버전에 따라 달라지므로 이름보다는 역할을 기준으로 확인한다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>source와 target rig에 root를 담당하는 chain이 없거나 Retargeter의 chain mapping에서 root 변위가 전달되지 않았다. pelvis를 root 이동 전달용 chain처럼 별도로 매핑하면 실제 root translation과 충돌하는 경우도 있었다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>source animation에서 root bone의 월드 기준 전진 변위가 실제로 있는지 확인한다.</li>
<li>source와 target IK Rig의 Retarget Root가 올바른 pelvis 또는 root 기준점인지 확인한다.</li>
<li>두 rig에 root bone을 포함하는 chain을 만들고 Retargeter에서 서로 매핑한다.</li>
<li>root translation mode와 scale 보정 결과를 preview에서 비교한다.</li>
<li>animation asset의 Root Motion 추출 설정과 retarget 결과를 별도로 검사한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>원문 사례에서는 source와 target에 Root chain을 추가하고 서로 매핑했다. root chain의 translation mode를 <code>Globally Scaled</code>로 설정해 전진 변위를 복원했다. 중복으로 만들었던 Pelvis chain과 그 mapping은 제거했다.</p><p></p><figure><img alt="Root chain을 매핑한 IK Retargeter 설정" loading="lazy" width="1529" height="627" src="/_astro/root-chain-mapping-after.BmircAle_ZSmBVM.webp" /><figcaption>Root chain을 매핑한 IK Retargeter 설정</figcaption></figure><p></p><p><code>Globally Scaled</code>는 source와 target의 전체 크기 차이를 고려해 translation을 전달할 때 유용하다. 스켈레톤 비율과 원하는 이동 방식에 따라 <code>Absolute</code>나 현재 버전의 root translation 설정이 더 적절한 경우도 있다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>preview에서 source와 target root 궤적을 함께 표시한다.</li>
<li>전진, 후진, 회전, 수직 이동이 포함된 애니메이션을 각각 시험한다.</li>
<li>retarget 결과를 export한 뒤 Root Motion 추출을 켠 animation montage에서도 이동량을 확인한다.</li>
<li>서로 다른 신장의 target skeleton에서 이동 거리가 기대대로 보정되는지 비교한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>Pelvis chain 제거는 모든 rig의 일반 해법이 아니다. 현재 retarget operation이 pelvis motion을 별도로 요구한다면 유지해야 한다. root 이동을 전달하는 chain과 pelvis 자세 보정이 같은 bone을 중복으로 제어하지 않도록 설정을 이해해야 한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/retargeting-bipeds-with-ik-rig-in-unreal-engine" target="_blank">Epic Games: Retargeting Bipeds with IK Rig</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/retargeting-operation-stack-in-unreal-engine-5-8" target="_blank">Epic Games: Retargeting Operation Stack</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/root-motion-in-unreal-engine" target="_blank">Epic Games: Root Motion</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-particle-bad-position-scale/</id>
      <title type="text">FParticleSystemSceneProxy의 잘못된 위치·스케일 로그 진단</title>
      <published>2023-11-15T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-particle-bad-position-scale/"/>
      <summary type="text">ParticleSystemSceneProxy에서 projection W가 0이거나 NDC 값이 무한대로 기록될 때 transform과 MacroUV 설정을 점검하는 방법을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>파티클을 렌더링할 때 다음과 같이 projection 결과가 유효하지 않다는 로그가 반복됐다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ObjectPostProjectionPositionWithW ... W: 0</span></div></div><div><div><div>2</div></div><div><span>ObjectNDCPosition ... inf</span></div></div><div><div><div>3</div></div><div><span>FParticleSystemSceneProxy::GetObjectPositionAndScale</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>scene proxy가 particle system 위치에 view projection을 적용한 뒤 perspective divide에 사용할 <code>W</code>가 0이 됐다. 그 결과 normalized device coordinate가 <code>inf</code>가 되어 MacroUV 위치와 scale 계산이 유효하지 않았다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>Cascade particle system과 MacroUV 또는 object position/scale 계산 경로에서 동일한 로그가 나타나는 UE 프로젝트를 다룬다. 원문에는 exact engine version과 최소 재현 asset이 없어 원인을 확정하지 않은 초안이다.</p></section>
<section><h2>가능한 원인<a href="#가능한-원인"><span>#</span></a></h2><ul>
<li>component transform에 NaN, Inf 또는 비정상적인 scale이 들어간다.</li>
<li>parent attachment가 파괴되거나 갱신되는 frame에 잘못된 world transform을 사용한다.</li>
<li>view/projection의 경계 조건에서 object position의 clip-space <code>W</code>가 0에 가까워진다.</li>
<li>MacroUV position 또는 radius가 effect 크기와 맞지 않는다.</li>
<li>engine version별 particle proxy 계산 버그가 있다.</li>
</ul><p>원문 프로젝트에서는 component의 world Z가 정확히 0일 때 재현됐고 작은 non-zero offset을 주자 로그가 사라졌다. 다만 이는 상관관계이자 진단용 workaround일 뿐, 모든 파티클에서 Z=0이 잘못이라는 뜻은 아니다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>로그 직전 component location, rotation, scale과 parent transform을 기록해 둔다.</li>
<li>각 float에 <code>FMath::IsFinite</code> 검사를 넣어 최초 invalid 값이 생기는 지점을 찾아낸다.</li>
<li>world origin, Z=0, camera near plane과 큰 좌표에서 각각 비교해 본다.</li>
<li>MacroUV를 끄거나 position/radius를 바꿔 해당 경로와의 관련성을 확인한다.</li>
<li>같은 asset을 새 level의 독립 component로 재현해 본다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>invalid transform이 발견되면 값을 만드는 gameplay/attachment 코드를 고친다. MacroUV 설정이 실제 effect와 맞지 않다면 position과 radius를 유효한 범위로 조정한다. 작은 Z offset만으로 회피된다면 workaround의 근거와 적용 범위를 주석으로 남기고 exact engine revision의 <code>GetObjectPositionAndScale</code> 구현과 관련 issue를 계속 확인한다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>원점과 원점 밖, 여러 camera angle과 FOV에서 로그를 확인해 본다.</li>
<li>component attach/detach, pooling과 level transition을 반복한다.</li>
<li>Development와 Shipping에 가까운 render 설정에서 시각 결과를 서로 비교한다.</li>
<li>로그가 없어졌을 뿐 아니라 particle의 크기·MacroUV가 올바른지도 살핀다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>좌표에 임의의 epsilon을 더하면 잘못된 transform이나 projection 문제를 숨길 위험이 있다. <code>W == 0</code> 검사를 제거하거나 무조건 1로 바꾸면 화면 위치와 크기가 왜곡되므로 엔진을 수정하기 전에 입력값부터 추적한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/particle-system-class?application_version=4.27" target="_blank">Epic Games: Particle System Class</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/steam-eos-private-sandbox-web-api-auth-ticket/</id>
      <title type="text">Steam 인증 티켓과 EOS Private Sandbox 권한 확인</title>
      <published>2023-11-13T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/steam-eos-private-sandbox-web-api-auth-ticket/"/>
      <summary type="text">Steam Web API 인증 티켓을 사용한 EOS 로그인 실패가 Private Sandbox의 Player Group과 Deployment 권한 누락에서 발생한 사례를 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Steam 계정으로 Epic Online Services 로그인을 시도하면 Private Sandbox에서만 실패했다. 티켓은 아래 코드로 요청했다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>SteamAPICall_t</span><span> </span><span>Call</span><span> </span><span>=</span><span> </span><span>SteamUser</span><span>()-&gt;</span><span>GetAuthTicketForWebApi</span><span>(</span><span>"epiconlineservices"</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>개발 환경에서는 Steam Web API ticket 발급까지 성공했다. 그러나 EOS 인증에 넘기면 특정 private sandbox에 로그인하지 못했다. 처음에는 <code>GetAuthTicketForWebApi</code>가 반환한 handle이나 ticket 생성 자체를 원인으로 의심했다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>Steam external credential을 EOS Connect 또는 Auth 흐름에 연결하는 경우다. Epic Developer Portal에서 공개되지 않은 sandbox와 deployment를 운영할 때 적용된다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>Epic Developer Portal에서 해당 계정은 Player Group에 속해 있었고 target sandbox도 허용된 상태였다. 하지만 그 안에서 실제로 사용하는 deployment가 빠져 있었다. Private Sandbox는 sandbox 이름만 맞춰서는 접근하지 못하며 계정 그룹이 필요한 deployment까지 사용할 수 있어야 했다.</p><p></p><figure><img alt="Player Group에 등록된 테스트 계정과 제품 접근 설정" loading="lazy" width="1904" height="935" src="/_astro/eos-player-group-accounts.BkQPZPoF_ZvTmFj.webp" /><figcaption>Player Group에 등록된 테스트 계정과 제품 접근 설정</figcaption></figure><p></p><p></p><figure><img alt="Player Group에 Sandbox와 Deployment를 함께 허용한 설정" loading="lazy" width="1916" height="930" src="/_astro/eos-player-group-sandbox-deployments._0IVPEhn_Z2b2zXH.webp" /><figcaption>Player Group에 Sandbox와 Deployment를 함께 허용한 설정</figcaption></figure><p></p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>Steam API call handle과 <code>GetTicketForWebApiResponse_t</code> callback을 구분해 기록한다.</li>
<li>callback의 result, ticket byte length와 service identity 문자열을 점검한다.</li>
<li>EOS 로그를 보고 credential 검증 실패인지 sandbox/deployment 접근 거부인지 구분한다.</li>
<li>Product Settings의 Player Group에 테스트 계정이 포함됐는지 살핀다.</li>
<li>그룹의 sandbox뿐 아니라 실제 deployment 권한도 대조한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>target sandbox와 deployment를 테스트 계정의 Player Group에 모두 추가했다. 권한 반영을 기다린 다음 새 ticket으로 로그인 흐름을 다시 시작하자 인증이 완료됐다.</p><p>Steam의 <code>GetAuthTicketForWebApi</code>는 비동기 요청 handle을 반환한다. 이 값만으로 ticket 유효성을 판정하지 않는다. callback 결과와 실제 ticket payload까지 확인해야 한다. 이번 사례는 Steam ticket 생성 코드가 아니라 EOS 접근 제어 설정을 고쳐 해결했다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>권한이 있는 계정과 없는 계정으로 각각 로그인해 접근 제어가 의도대로 동작하는지 확인한다.</li>
<li>다른 sandbox/deployment 조합으로 바꿔 교차 시험한다.</li>
<li>ticket 재사용이나 만료를 피하려면 매 시도에서 새 ticket을 발급한다.</li>
<li>client 로그에는 ticket 본문, Steam ID, product secret을 남기지 않는다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>Player Group에 모든 사용자를 넣으면 문제는 가려지지만 production 접근 범위가 넓어진다. 테스트 그룹에는 최소 권한만 부여한다. 스크린샷과 로그를 공유하기 전에는 계정·제품명·식별자를 제거한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://partner.steamgames.com/doc/api/ISteamUser?language=english" target="_blank">Steamworks: ISteamUser::GetAuthTicketForWebApi</a></li>
<li><a href="https://partner.steamgames.com/doc/webapi/isteamuserauth" target="_blank">Steamworks Web API: AuthenticateUserTicket</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/ue5-async-loading-recursion-edl-migration/</id>
      <title type="text">UE4→UE5 전환 후 AsyncLoading 재귀 assertion 진단</title>
      <published>2023-11-03T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/ue5-async-loading-recursion-edl-migration/"/>
      <summary type="text">UE4 프로젝트를 UE5로 옮긴 뒤 패키징 빌드에서 AsyncLoadingThread RecursionNotAllowed assertion이 발생할 때 재진입 로드를 찾는 절차를 정리합니다.</summary>
      <content type="html"><![CDATA[<p>UE4 프로젝트를 UE5로 전환한 뒤 editor에서는 동작하지만 패키징 빌드 로드 중 다음 assertion으로 종료되는 문제가 있었다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>AsyncLoadingThread.RecursionNotAllowed.Increment() == 1</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>특정 map 또는 asset 묶음을 비동기로 불러올 때 AsyncLoading thread가 처리 중이던 로드 구간에 다시 진입해 assertion이 발생했다. UE4에서 선택적으로 쓰던 Event Driven Loader 설정과 UE5의 기본 로드 경로 차이 때문에 전환 직후 드러나기도 한다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>UE4에서 UE5로 asset과 custom C++ serialization 코드를 함께 이관한 뒤 cooked build에서만 재현되는 경우를 다룬다. 원문에는 최종 offending asset이 남아 있지 않으므로 확정 해법이 아닌 진단 초안으로 유지한다.</p></section>
<section><h2>가능한 원인<a href="#가능한-원인"><span>#</span></a></h2><ul>
<li><code>Serialize</code>, <code>PostLoad</code> 또는 UObject constructor에서 동기 asset load를 호출한다.</li>
<li>async load completion callback이 같은 package를 다시 동기로 요청한다.</li>
<li>custom archive/loader가 thread-safe하지 않은 전역 상태를 사용한다.</li>
<li>오래된 cooked output이나 redirect가 다른 package graph를 만든다.</li>
<li>custom engine 수정이 UE5 AsyncLoading2 경로와 맞지 않는다.</li>
</ul></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>assertion 전 전체 call stack과 마지막으로 로드한 package 이름을 수집해 둔다.</li>
<li><code>LoadObject</code>, <code>StaticLoadObject</code>, <code>TryLoad</code>, synchronous streamable handle 호출을 검색한다.</li>
<li>이 호출이 constructor, <code>Serialize</code>, <code>PostLoad</code>와 async callback 안에서 실행되는지 나눠 본다.</li>
<li>문제가 되는 map의 asset dependency를 줄여 최소 재현 package를 찾아낸다.</li>
<li>stale cooked output을 배제한 clean cook에서 다시 확인해 본다.</li>
<li>custom engine이라면 exact upstream revision과 AsyncLoading 관련 diff도 비교한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>로드 도중 다시 진입하는 동기 load를 제거하고 dependency를 soft reference와 상위 단계의 명시적 async request로 옮긴다. callback에서는 이미 완료된 object를 사용하고 같은 package graph를 다시 로드하지 않는다.</p><p>잘못된 asset reference나 redirect가 원인이라면 해당 asset을 새 버전에서 다시 저장하고 redirect를 정리한 뒤 전체 recook한다. assertion 자체를 끄거나 EDL 동작을 억지로 과거 방식으로 되돌리면 상태 손상을 숨길 우려가 있어 해결책으로 보지 않는다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>editor, standalone과 packaged build에서 동일한 map 순서를 반복해 본다.</li>
<li>cold start와 이미 asset cache가 채워진 두 상황을 시험한다.</li>
<li>여러 번의 level transition과 비동기 취소 경로를 확인해 둔다.</li>
<li>package 이름을 포함한 로드 로그에서 같은 package의 재진입이 사라졌는지 살펴본다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>call stack만으로 “UE5에서는 EDL이 항상 켜져서 생긴다”고 단정할 수 없다. UE5의 async loading 구현도 minor version마다 바뀌므로 exact engine source를 확인하며 조사해야 한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/asynchronous-asset-loading-in-unreal-engine" target="_blank">Epic Games: Asynchronous Asset Loading</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/asynchronous-level-loading-in-unreal-engine?lang=en-US" target="_blank">Epic Games: Asynchronous Level Loading</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-public-vs-private-module-dependencies/</id>
      <title type="text">Unreal Build.cs 모듈 의존성: Public, Private, Include-only, Dynamic</title>
      <published>2023-10-22T00:00:00.000Z</published>
      <updated>2023-10-22T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-public-vs-private-module-dependencies/"/>
      <summary type="text">Public 헤더와 Private cpp라는 단순 규칙을 넘어, Unreal Build Tool의 다섯 가지 모듈 목록이 compile 환경·link·runtime load를 어떻게 전파하는지 설명합니다.</summary>
      <content type="html"><![CDATA[<p>Unreal Engine 모듈에서 다른 모듈을 사용하려면 <code>[ModuleName].Build.cs</code>에 의존성을 선언한다. 가장 많이 쓰는 목록은 두 가지다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>PublicDependencyModuleNames</span><span>.</span></span><span>Add</span><span>(</span><span>"SharedTypes"</span><span>);</span></div></div><div><div><div>2</div></div><div><span><span>PrivateDependencyModuleNames</span><span>.</span></span><span>Add</span><span>(</span><span>"Json"</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p>흔히 이를 “헤더에서 쓰면 Public, cpp에서만 쓰면 Private”라고 외운다. 첫 판단에는 도움이 되지만 정확한 기준은 파일 확장자가 아니라 <strong>현재 모듈의 공개 인터페이스를 compile하는 데 그 의존성이 필요한가</strong>이다.</p>
<ul>
<li><code>Public/</code>의 헤더가 다른 모듈의 타입 정의·compile definition·link symbol을 요구하면 Public dependency다.</li>
<li><code>Private/</code>의 header와 cpp에서만 필요하면 Private dependency다.</li>
<li>Public 헤더가 타입을 전방 선언만 하고 정의를 요구하지 않으면 구현 dependency를 Private로 유지한다.</li>
<li>header를 compile할 수 있다는 것, symbol을 link한다는 것, module을 runtime에 load한다는 것, DLL symbol을 export한다는 것은 서로 다른 문제다.</li>
</ul>
<p>여기에 <code>PublicIncludePathModuleNames</code>, <code>PrivateIncludePathModuleNames</code>, <code>DynamicallyLoadedModuleNames</code>, IWYU, API macro, plugin descriptor까지 더하면 서로 다른 의존성 경계를 분리해서 볼 수 있다.</p>
<section><h2>먼저 구분할 네 가지 축<a href="#먼저-구분할-네-가지-축"><span>#</span></a></h2><p><code>Public</code>이라는 단어가 여러 위치에서 등장하지만 모두 같은 기능은 아니다.</p>

<table><thead><tr><th>축</th><th>질문</th><th>대표 설정</th></tr></thead><tbody><tr><td>source 공개 범위</td><td>다른 module이 이 header를 include할 수 있는가?</td><td><code>Public/</code>, <code>Private/</code> directory</td></tr><tr><td>compile·link 의존성</td><td>어느 module의 public compile 환경과 import/link 관계가 전파되는가?</td><td><code>PublicDependencyModuleNames</code>, <code>PrivateDependencyModuleNames</code></td></tr><tr><td>runtime load</td><td>module을 언제, 어떤 조건으로 memory에 load하는가?</td><td>descriptor의 <code>LoadingPhase</code>, <code>FModuleManager</code>, <code>DynamicallyLoadedModuleNames</code></td></tr><tr><td>binary symbol 공개</td><td>modular build에서 class/function/data symbol을 DLL 밖으로 export하는가?</td><td><code>MYMODULE_API</code>, <code>MinimalAPI</code></td></tr></tbody></table><p>예를 들어 header를 <code>Public/</code>에 두었다고 그 class의 non-inline function이 자동으로 DLL 밖에 export되지는 않는다. 반대로 <code>PublicDependencyModuleNames</code>에 module을 추가해도 해당 module의 header가 source에 자동으로 <code>#include</code>되지는 않는다.</p></section>
<section><h2>권장 module directory 구조<a href="#권장-module-directory-구조"><span>#</span></a></h2><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-modules" target="_blank">Unreal Engine Modules 문서</a>는 일반 C++ module을 다음처럼 나누도록 안내한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Source/</span></div></div><div><div><div>2</div></div><div><span>└─ Feature/</span></div></div><div><div><div>3</div></div><div><span><span>   </span></span><span>├─ Feature.Build.cs</span></div></div><div><div><div>4</div></div><div><span><span>   </span></span><span>├─ Public/</span></div></div><div><div><div>5</div></div><div><span><span>   </span></span><span>│  └─ Feature/</span></div></div><div><div><div>6</div></div><div><span><span>   </span></span><span>│     └─ FeatureService.h</span></div></div><div><div><div>7</div></div><div><span><span>   </span></span><span>└─ Private/</span></div></div><div><div><div>8</div></div><div><span><span>      </span></span><span>├─ FeatureModule.cpp</span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>└─ Feature/</span></div></div><div><div><div>10</div></div><div><span><span>         </span></span><span>└─ FeatureService.cpp</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>Public/</code>: 이 module에 의존하는 다른 module이 include할 공개 header</li>
<li><code>Private/</code>: 현재 module 안에서만 사용할 header와 모든 cpp</li>
<li><code>Feature.Build.cs</code>: UBT가 target graph를 만들 때 읽는 compile·link 규칙</li>
</ul><p>이는 C++ class member의 <code>public</code>, <code>private</code>, <code>protected</code>와 무관하다. <code>Private/</code> header에 public class가 있어도 다른 module의 include surface에는 노출되지 않는다.</p></section>
<section><h2>다섯 가지 ModuleRules 목록<a href="#다섯-가지-modulerules-목록"><span>#</span></a></h2><p><code>ModuleRules</code>에서는 아래 목록을 주로 사용한다.</p>

<table><thead><tr><th>목록</th><th>현재 module에 public compile 환경 제공</th><th>정상 import/link 관계</th><th>downstream consumer로 전파</th><th>module을 실제 runtime load</th></tr></thead><tbody><tr><td><code>PublicDependencyModuleNames</code></td><td>예</td><td>예</td><td>예</td><td>별도 load 정책에 따름</td></tr><tr><td><code>PrivateDependencyModuleNames</code></td><td>예</td><td>예</td><td>아니요</td><td>별도 load 정책에 따름</td></tr><tr><td><code>PublicIncludePathModuleNames</code></td><td>예</td><td>아니요</td><td>예</td><td>아니요</td></tr><tr><td><code>PrivateIncludePathModuleNames</code></td><td>예</td><td>아니요</td><td>아니요</td><td>아니요</td></tr><tr><td><code>DynamicallyLoadedModuleNames</code></td><td>아니요</td><td>아니요</td><td>아니요</td><td><strong>목록만으로는 아니요</strong></td></tr></tbody></table><p>여기서 “public compile 환경”은 단순 include directory 하나가 아니다. 대상 module이 공개한 include path, system include, public definition 같은 compile 정보를 포함한다. <code>DependencyModuleNames</code>는 이 compile 환경까지 이미 가져오므로 같은 module을 <code>IncludePathModuleNames</code>에도 중복해서 넣지 않는다.</p><section><h3><code>PublicDependencyModuleNames</code><a href="#publicdependencymodulenames"><span>#</span></a></h3><p>현재 module의 <strong>Public API를 compile하고 link하는 데 필요한</strong> 정상 의존성이다. 이 관계는 현재 module을 사용하는 consumer가 공개 header를 compile할 수 있도록 전파된다.</p><p>대표적인 경우를 살펴보자.</p><ul>
<li>Public class가 다른 module class를 상속한다.</li>
<li>Public struct가 다른 module type을 value member로 가진다.</li>
<li>inline/template 구현에서 다른 module symbol을 사용한다.</li>
<li>UHT가 Public header의 reflected declaration을 처리하려면 해당 type 정의가 필요하다.</li>
</ul><p>Public dependency는 consumer에게 부담을 전파하므로 “혹시 필요할 것 같아서” 추가하지 않는다. 그러나 공개 계약에 정말 필요하다면 compile 시간을 이유로 억지로 Private에 두어서도 안 된다.</p></section><section><h3><code>PrivateDependencyModuleNames</code><a href="#privatedependencymodulenames"><span>#</span></a></h3><p>현재 module의 구현을 compile·link할 때만 필요한 정상 의존성이다. 다른 module이 현재 module의 Public header를 include할 때 이 dependency의 compile 환경을 상속하지 않는다.</p><ul>
<li>cpp에서만 사용하는 <code>Json</code>, <code>Slate</code>, platform implementation</li>
<li><code>Private/</code> header에서만 사용하는 type</li>
<li>Public header에서는 전방 선언만 하고 cpp에서 정의를 include하는 type</li>
</ul><p>Private가 기본 선택인 이유는 “link하지 않는다”가 아니라 <strong>불필요한 public 전파를 막는다</strong>는 데 있다. 현재 module 자체는 해당 dependency와 정상적으로 compile·link한다.</p></section><section><h3><code>PublicIncludePathModuleNames</code>와 <code>PrivateIncludePathModuleNames</code><a href="#publicincludepathmodulenames와-privateincludepathmodulenames"><span>#</span></a></h3><p>대상 module의 public header와 compile 환경은 필요하지만 정상 import/link dependency는 만들지 않는 compile-only 관계다. 흔한 사용처는 runtime에 선택적으로 load할 module의 interface header다.</p><ul>
<li><code>PublicIncludePathModuleNames</code>: 현재 module의 Public header에서 interface가 필요해 consumer에도 compile 환경을 전파</li>
<li><code>PrivateIncludePathModuleNames</code>: 현재 module의 Private 구현에서만 interface가 필요</li>
</ul><p>일반적인 class/function symbol을 직접 호출한다면 대부분 <code>DependencyModuleNames</code>가 맞다. Include-only 목록을 link error를 피하는 임시 처방으로 사용하면 안 된다.</p></section><section><h3><code>DynamicallyLoadedModuleNames</code><a href="#dynamicallyloadedmodulenames"><span>#</span></a></h3><p>현재 module이 runtime에 추가 module을 필요로 할 수 있다고 UBT에 알리지만, 다음 작업은 하지 않는다.</p><ul>
<li>header include path를 제공하지 않는다.</li>
<li>정상 import library/link dependency를 만들지 않는다.</li>
<li><code>StartupModule</code>을 호출하거나 module을 즉시 load하지 않는다.</li>
<li>plugin을 project에서 enable하지 않는다.</li>
</ul><p>실제 load는 <code>.uplugin</code>·<code>.uproject</code> descriptor의 <code>LoadingPhase</code> 또는 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Core/FModuleManager/LoadModulePtr" target="_blank"><code>FModuleManager::LoadModulePtr</code></a> 같은 API가 담당한다. 같은 module을 정상 dependency와 dynamic 목록 양쪽에 넣으면 의도가 서로 충돌한다.</p></section></section>
<section><h2>세 module로 보는 전파 범위<a href="#세-module로-보는-전파-범위"><span>#</span></a></h2><p>다음 graph에서 <code>Feature</code>는 공개 data contract에는 <code>SharedTypes</code>를 노출하고, 구현에서만 <code>Json</code>을 쓴다. 선택 backend는 interface만 compile하고 runtime에 load한다.</p><div><div><div><div><span><p>Feature 의존</p></span></div><div><span><p>PublicDependency</p></span></div><div><span><p>PrivateDependency</p></span></div><div><span><p>IncludePath + Dynamic load</p></span></div><div><span><p>Consumer</p></span></div><div><span><p>Feature</p></span></div><div><span><p>SharedTypes</p></span></div><div><span><p>Json</p></span></div><div><span><p>OptionalBackend</p></span></div>
</div><div><div><span><p>Feature 의존</p></span></div><div><span><p>PublicDependency</p></span></div><div><span><p>PrivateDependency</p></span></div><div><span><p>IncludePath + Dynamic load</p></span></div><div><span><p>Consumer</p></span></div><div><span><p>Feature</p></span></div><div><span><p>SharedTypes</p></span></div><div><span><p>Json</p></span></div><div><span><p>OptionalBackend</p></span></div>
</div></div></div><p><code>Consumer</code>가 <code>Feature</code>의 Public header를 compile할 때 <code>SharedTypes</code>의 public compile 환경은 도달하지만 <code>Json</code>은 전파되지 않는다. <code>OptionalBackend</code>와는 정상 link edge가 없고, 실제 사용 시점의 load가 별도로 필요하다.</p><section><h3>Public type을 value로 노출하는 경우<a href="#public-type을-value로-노출하는-경우"><span>#</span></a></h3><div><figure><figcaption><span>Feature/Public/Feature/FeatureService.h</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"SharedTypes/SharedFeatureOptions.h"</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>class</span><span> FEATURE_API </span><span>FFeatureService</span></div></div><div><div><div>6</div></div><div><span>{</span></div></div><div><div><div>7</div></div><div><span>public:</span></div></div><div><div><div>8</div></div><div><span>    </span><span>explicit</span><span> </span><span>FFeatureService</span><span>(</span><span>FSharedFeatureOptions</span><span><span> </span><span>InOptions</span><span>);</span></span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>private:</span></div></div><div><div><div>11</div></div><div><span>    </span><span>FSharedFeatureOptions</span><span> </span><span>Options</span><span>;</span></div></div><div><div><div>12</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>FSharedFeatureOptions</code>는 data member의 complete type이어야 한다. <code>Feature.Build.cs</code>는 <code>SharedTypes</code>를 Public dependency로 선언한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>PublicDependencyModuleNames</span><span>.</span></span><span>AddRange</span><span>(</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>new[]</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>4</div></div><div><span>        </span><span>"Core"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>        </span><span>"SharedTypes"</span><span>,</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>7</div></div><div><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>구현에서만 사용하는 경우<a href="#구현에서만-사용하는-경우"><span>#</span></a></h3><div><figure><figcaption><span>Feature/Public/Feature/FeatureParser.h</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"Containers/UnrealString.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"Containers/StringView.h"</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>class</span><span> FEATURE_API </span><span>FFeatureParser</span></div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span>public:</span></div></div><div><div><div>9</div></div><div><span>    </span><span>bool</span><span> </span><span>Parse</span><span>(</span><span>FStringView</span><span><span> </span><span>Text</span><span>, </span></span><span>FString</span><span>&amp;</span><span><span> </span><span>OutError</span><span>);</span></span></div></div><div><div><div>10</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption><span>Feature/Private/Feature/FeatureParser.cpp</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"Feature/FeatureParser.h"</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"Dom/JsonObject.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"Serialization/JsonReader.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"Serialization/JsonSerializer.h"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Public header에는 <code>Json</code> type이 없으므로 다음은 Private dependency면 충분하다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>PrivateDependencyModuleNames</span><span>.</span></span><span>Add</span><span>(</span><span>"Json"</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>전방 선언으로도 dependency를 Private에 남길 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>class</span><span> </span><span>FJsonObject</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>class</span><span> FEATURE_API </span><span>FFeatureInspector</span></div></div><div><div><div>4</div></div><div><span>{</span></div></div><div><div><div>5</div></div><div><span>public:</span></div></div><div><div><div>6</div></div><div><span>    </span><span>void</span><span> </span><span>Inspect</span><span>(</span><span>const</span><span> </span><span>FJsonObject</span><span>&amp;</span><span><span> </span><span>Object</span><span>);</span></span></div></div><div><div><div>7</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>pointer나 reference declaration은 incomplete type으로 가능한 경우가 많다. 다만 inheritance, value member, <code>sizeof</code>, 일부 template·inline body, UHT가 완전한 reflected type을 요구하는 위치에서는 정의가 필요하다. 이 public signature가 <code>Json</code> 개념을 노출한다는 architectural coupling도 사라지지 않는다. consumer가 실제 <code>FJsonObject</code>를 만들고 사용한다면 consumer도 자신의 <code>Json</code> dependency를 직접 선언해야 한다.</p></section></section>
<section><h2>현실적인 Build.cs 예제<a href="#현실적인-buildcs-예제"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>using</span><span> </span><span>UnrealBuildTool</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>public</span><span> </span><span>class</span><span> </span><span>Feature</span><span> : </span><span>ModuleRules</span></div></div><div><div><div>4</div></div><div><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>public</span><span> </span><span>Feature</span><span>(</span><span>ReadOnlyTargetRules</span><span><span> </span><span>Target</span><span>) : </span></span><span>base</span><span><span>(</span><span>Target</span><span>)</span></span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>PCHUsage</span><span> </span><span>=</span><span> </span><span>PCHUsageMode</span><span>.</span><span>UseExplicitOrSharedPCHs</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span><span>        </span></span><span>PublicDependencyModuleNames</span><span>.</span><span>AddRange</span><span>(</span></div></div><div><div><div>10</div></div><div><span><span>            </span></span><span>new[]</span></div></div><div><div><div>11</div></div><div><span><span>            </span></span><span>{</span></div></div><div><div><div>12</div></div><div><span>                </span><span>"Core"</span><span>,</span></div></div><div><div><div>13</div></div><div><span>                </span><span>"SharedTypes"</span><span>,</span></div></div><div><div><div>14</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>15</div></div><div><span><span>        </span></span><span>);</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span><span>        </span></span><span>PrivateDependencyModuleNames</span><span>.</span><span>AddRange</span><span>(</span></div></div><div><div><div>18</div></div><div><span><span>            </span></span><span>new[]</span></div></div><div><div><div>19</div></div><div><span><span>            </span></span><span>{</span></div></div><div><div><div>20</div></div><div><span>                </span><span>"CoreUObject"</span><span>,</span></div></div><div><div><div>21</div></div><div><span>                </span><span>"Engine"</span><span>,</span></div></div><div><div><div>22</div></div><div><span>                </span><span>"Json"</span><span>,</span></div></div><div><div><div>23</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>24</div></div><div><span><span>        </span></span><span>);</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>26</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이 예제의 <code>CoreUObject</code>와 <code>Engine</code>이 항상 Private라는 뜻은 아니다. <code>Feature</code>의 Public class가 <code>UObject</code>나 <code>AActor</code>를 상속하거나 Public header의 reflected declaration이 해당 module을 요구한다면 Public로 이동해야 한다. Build.cs snippet을 복사하기보다 실제 Public include surface를 기준으로 분류한다.</p></section>
<section><h2>선택 module을 runtime에 load하기<a href="#선택-module을-runtime에-load하기"><span>#</span></a></h2><p><code>OptionalBackend</code>의 interface header만 Private 구현에서 compile하고, backend가 있을 때 load하려면 두 목록을 함께 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>PrivateIncludePathModuleNames</span><span>.</span></span><span>Add</span><span>(</span><span>"OptionalBackend"</span><span>);</span></div></div><div><div><div>2</div></div><div><span><span>DynamicallyLoadedModuleNames</span><span>.</span></span><span>Add</span><span>(</span><span>"OptionalBackend"</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"IOptionalBackendModule.h"</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>"Modules/ModuleManager.h"</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>IOptionalBackendModule</span><span>*</span><span> </span><span>TryLoadOptionalBackend</span><span>()</span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>return</span><span> FModuleManager::</span><span>LoadModulePtr</span><span>&lt;</span><span>IOptionalBackendModule</span><span>&gt;(</span></div></div><div><div><div>7</div></div><div><span>        </span><span>FName</span><span>(</span><span>TEXT</span><span>(</span><span>"OptionalBackend"</span><span>))</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>);</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>LoadModulePtr</code>은 module을 찾아 load하고 <code>StartupModule</code>을 호출하며, 찾지 못하면 <code>nullptr</code>을 반환한다. 필수 backend라면 <code>LoadModuleChecked</code>로 invariant를 표현할 수 있지만, 사용자 설치나 platform에 따라 없을 수 있는 선택 기능에는 null을 처리한다.</p><p>이 pattern에는 추가 조건이 있다.</p><ol>
<li>interface header가 link symbol 없이 사용할 수 있도록 설계되어야 한다.</li>
<li>target이 해당 module을 build할 수 있어야 한다.</li>
<li>plugin module이라면 <code>.uplugin</code> 또는 <code>.uproject</code>에서 plugin dependency가 enable되어야 한다.</li>
<li>descriptor의 <code>Type</code>, platform allow/deny 조건, <code>LoadingPhase</code>가 현재 target과 맞아야 한다.</li>
<li>unload를 지원한다면 가져온 pointer와 delegate lifetime을 module보다 길게 보관하지 않는다.</li>
</ol><p><code>DynamicallyLoadedModuleNames</code>는 “항상 별도 DLL”이라는 뜻도 아니다. monolithic target에서는 module code가 하나의 executable에 compile될 수 있지만 <code>FModuleManager</code>가 정적으로 등록된 initializer로 module lifetime을 관리한다.</p><p>외부 vendor DLL의 delay load와 packaging은 다른 기능이다. 일반적으로 <code>PublicAdditionalLibraries</code>, <code>PublicDelayLoadDLLs</code>, <code>RuntimeDependencies</code>를 검토하며, Unreal module용 <code>DynamicallyLoadedModuleNames</code>로 third-party binary staging을 대신하지 않는다.</p></section>
<section><h2>Public folder와 <code>MYMODULE_API</code>는 별개다<a href="#public-folder와-mymodule_api는-별개다"><span>#</span></a></h2><p>다른 module이 header를 찾더라도 modular build에서 구현 symbol이 export되지 않으면 unresolved external이 발생한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>class</span><span> FEATURE_API </span><span>FFeatureService</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>public:</span></div></div><div><div><div>4</div></div><div><span>    </span><span>void</span><span> </span><span>Start</span><span>();</span></div></div><div><div><div>5</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><a href="https://dev.epicgames.com/documentation/unreal-engine/module-api-specifiers-in-unreal-engine" target="_blank"><code>Module API Specifiers</code></a>는 <code>FEATURE_API</code> 같은 macro를 build 상황에 따라 다음처럼 바꾼다.</p><ul>
<li>module 자체를 modular mode로 compile할 때: export</li>
<li>다른 module이 import할 때: import</li>
<li>monolithic build일 때: 빈 macro</li>
</ul><p>그래서 Shipping monolithic build만 성공하고 Editor DLL build에서 link error가 나는 코드가 생길 수 있다. Public folder, Public dependency, API macro를 각각 확인해야 한다.</p><p><code>UCLASS(MinimalAPI)</code>는 class type 정보를 제한적으로 export하는 선택지다. class가 다른 module에서 보인다고 non-inline method가 모두 외부 호출 가능해지는 것은 아니다. 외부 호출이 필요한 method에는 적절한 module API export가 필요하다.</p></section>
<section><h2>IWYU와 self-contained header<a href="#iwyu와-self-contained-header"><span>#</span></a></h2><p>Private dependency를 늘리거나 줄이는 것만으로 include hygiene가 완성되지는 않는다. IWYU에서는 아래 원칙을 따른다.</p><ol>
<li>모든 header는 자신이 필요한 header를 직접 include한다.</li>
<li>cpp는 대응하는 자신의 header를 먼저 include한다.</li>
<li>source에서 PCH header를 직접 include하지 않는다.</li>
<li>monolithic <code>Engine.h</code>, <code>UnrealEd.h</code> 대신 필요한 header를 include한다.</li>
</ol><p><code>PCHUsageMode.UseExplicitOrSharedPCHs</code>는 PCH를 최적화 layer로 유지하면서 source가 우연히 PCH의 transitive include에 기대지 않도록 돕는다. 그러나 unity build와 shared PCH에서는 누락된 dependency가 가려질 수 있다.</p><p>변경 뒤에는 최소한 다음을 검증한다.</p><ul>
<li>consumer test module이 대상 Public header 하나만 include해도 compile되는가?</li>
<li>cpp의 첫 include가 대응 header일 때 compile되는가?</li>
<li>non-unity, PCH 비활성 test에서도 누락된 direct include가 드러나지 않는가?</li>
<li>제거한 Public dependency의 type이나 definition이 Public header에 남아 있지 않은가?</li>
<li>modular Editor build와 monolithic target에서 모두 symbol export가 올바른가?</li>
</ul><p>UE 5.2에는 Clang 기반 IWYU 도구 연동도 추가됐지만, tool을 실행하는 것과 IWYU 방식으로 header를 작성하는 것은 구분한다. UE 4.27에서도 같은 self-contained header 원칙과 non-unity/PCH-off 검증이 유효하다.</p></section>
<section><h2>Runtime module과 Editor module의 방향<a href="#runtime-module과-editor-module의-방향"><span>#</span></a></h2><p><code>.Build.cs</code>는 compile·link edge를 정의하고, <code>.uplugin</code> 또는 <code>.uproject</code> descriptor는 module의 activation과 host type을 정의한다. 둘 중 하나만 맞춰서는 충분하지 않다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>InventoryEditor  ──depends on──▶  InventoryRuntime</span></div></div><div><div><div>2</div></div><div><span>InventoryRuntime ──must not───▶  InventoryEditor</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/setting-up-editor-modules-for-customizing-the-editor-in-unreal-engine" target="_blank">Editor Modules 안내</a>는 runtime 기능과 editor customization을 별도 module로 나누고 Editor→Runtime 방향으로 의존하도록 권장한다. Runtime module이 <code>UnrealEd</code>, details customization 같은 Editor module을 참조하면 Shipping target에서 build할 수 없다.</p><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/plugins-in-unreal-engine" target="_blank">Plugins 문서</a>의 계층 규칙도 지켜야 한다. project module은 Engine module을 사용할 수 있지만 Engine plugin/module이 특정 project module에 아래 방향으로 의존할 수는 없다. plugin enable 여부와 module <code>Type</code>·<code>LoadingPhase</code>는 dependency 목록으로 우회할 수 없다.</p></section>
<section><h2>순환 의존성은 Private로 바꿔도 해결되지 않는다<a href="#순환-의존성은-private로-바꿔도-해결되지-않는다"><span>#</span></a></h2><p><code>A</code>가 <code>B</code>를 Private로, <code>B</code>가 <code>A</code>를 Private로 선언해도 link graph의 cycle은 그대로다. <code>CircularlyReferencedDependentModules</code>는 legacy 용도이므로 새 의존성에 사용하지 않는다.</p><p>새 cycle을 발견하면 다음 순서로 구조를 바꾼다.</p><ul>
<li>양쪽이 공유하는 DTO·interface를 더 낮은 <code>SharedTypes</code> module로 추출한다.</li>
<li>Public header의 concrete type을 forward declaration이나 작은 interface로 줄인다.</li>
<li>직접 호출을 delegate, event, subsystem 또는 <code>IModularFeatures</code> 같은 registration boundary로 바꾼다.</li>
<li>선택 backend를 include-only interface와 runtime dynamic load 뒤로 이동한다.</li>
<li>Runtime과 Editor 구현을 별도 module로 분리해 dependency 방향을 한쪽으로 만든다.</li>
</ul></section>
<section><h2>결정 순서<a href="#결정-순서"><span>#</span></a></h2><p>새 dependency를 추가할 때 다음 질문을 위에서부터 확인하면 된다.</p><ol>
<li>대상 type이 현재 module의 Public header를 compile하는 데 필요한가?
<ul>
<li>필요하면 <code>PublicDependencyModuleNames</code>.</li>
</ul>
</li>
<li>Private header 또는 cpp에서만 compile·link하면 되는가?
<ul>
<li>그렇다면 <code>PrivateDependencyModuleNames</code>.</li>
</ul>
</li>
<li>정상 import/link 없이 interface header의 compile 환경만 필요한가?
<ul>
<li>공개 interface면 <code>PublicIncludePathModuleNames</code>, 구현 전용이면 <code>PrivateIncludePathModuleNames</code>.</li>
</ul>
</li>
<li>runtime에 명시적으로 load할 Unreal module인가?
<ul>
<li><code>DynamicallyLoadedModuleNames</code>와 descriptor/<code>FModuleManager</code> load를 함께 설계.</li>
</ul>
</li>
<li>다른 DLL에서 non-inline symbol을 호출하는가?
<ul>
<li>Public folder와 별도로 <code>MYMODULE_API</code> export 확인.</li>
</ul>
</li>
<li>plugin activation, module type, loading phase, platform 조건이 맞는가?</li>
<li>consumer module, non-unity/PCH-off, modular/monolithic target에서 검증했는가?</li>
</ol><p>핵심은 “header냐 cpp냐”보다 <strong>누가 이 compile·link 계약을 소비하는가</strong>이다. 공개 계약에 필요한 dependency만 Public로 전파하고 구현 세부는 Private에 가둔다. compile-only와 dynamic load가 정말 필요한 드문 경우에는 두 단계가 자동으로 연결되지 않는다는 점을 명시적으로 처리한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-modules" target="_blank">Epic Games: Unreal Engine Modules</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/module-properties-in-unreal-engine" target="_blank">Epic Games: Module Properties</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/include-what-you-use-iwyu-for-unreal-engine-programming?lang=en-US" target="_blank">Epic Games: Include What You Use</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/iwyu?application_version=4.27" target="_blank">Epic Games UE 4.27: Include What You Use</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/module-api-specifiers-in-unreal-engine" target="_blank">Epic Games: Module API Specifiers</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/plugins-in-unreal-engine" target="_blank">Epic Games: Plugins</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/setting-up-editor-modules-for-customizing-the-editor-in-unreal-engine" target="_blank">Epic Games: Setting up Editor Modules</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Core/FModuleManager" target="_blank">Epic Games: <code>FModuleManager</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Core/FModuleManager/LoadModulePtr" target="_blank">Epic Games: <code>FModuleManager::LoadModulePtr</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-5.2-release-notes?application_version=5.2" target="_blank">Epic Games UE 5.2 release notes</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/fix-empty-engine-version-warning-in-unreal-engine/</id>
      <title type="text">Unreal Engine “Asset has been saved with empty engine version” 경고 안전하게 해결하기</title>
      <published>2023-10-20T00:00:00.000Z</published>
      <updated>2023-10-20T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/fix-empty-engine-version-warning-in-unreal-engine/"/>
      <summary type="text">Asset package의 SavedByEngineVersion changelist가 0일 때 발생하는 경고의 정확한 의미와, nonzero-CL 엔진에서 ResavePackages로 metadata를 복구하고 결과를 검증하는 안전 절차를 설명합니다.</summary>
      <content type="html"><![CDATA[<p>UE 4.27-Plus 사내 project를 UE 5.3.1로 옮길 때 많은 Asset에서 다음 경고가 나타났다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;ASSET_PATH&gt;: Asset has been saved with empty engine version.</span></div></div><div><div><div>2</div></div><div><span>The asset will be loaded but may be incompatible.</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p><code>ResavePackages -OnlyUnversioned</code>를 실행하는 것과 <code>ZeroEngineVersionWarning</code>을 끄는 것은 결과가 전혀 다르다.</p>
<ul>
<li>target engine에서 package를 다시 저장하면 <code>SavedByEngineVersion</code> metadata를 <strong>복구</strong>할 수 있다.</li>
<li><code>ZeroEngineVersionWarning=0</code>은 log를 <strong>숨길 뿐</strong> package를 바꾸지 않는다.</li>
</ul>
<p>대량 resave는 <code>.uasset</code>과 <code>.umap</code>을 실제로 load/save하고 migration conversion까지 적용하기도 한다. 그래서 명령 한 줄을 바로 실행하기보다 경고의 조건, UE 5.3.1 build의 changelist, source-control 범위, 결과 검증을 먼저 준비해야 한다.</p>
<section><h2><code>empty engine version</code>의 정확한 뜻<a href="#empty-engine-version의-정확한-뜻"><span>#</span></a></h2><p>경고 문구만 보면 major, minor, patch가 모두 비어 있다고 생각하기 쉽다. 실제 engine 분기는 <code>SavedByEngineVersion</code>이 유효한 <strong>changelist</strong>를 가지고 있는지에 초점을 둔다. 동작을 단순화하면 아래 조건으로 정리된다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>NeedsEngineVersionChecks</span></div></div><div><div><div>2</div></div><div><span>    </span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>FPlatformProperties</span><span>::</span></span><span>RequiresCookedData</span><span>()</span></div></div><div><div><div>3</div></div><div><span>    </span><span>&amp;&amp;</span><span> </span><span>!</span><span>Summary</span><span>.</span><span>SavedByEngineVersion</span><span>.</span><span>HasChangelist</span><span>()</span></div></div><div><div><div>4</div></div><div><span>    </span><span>&amp;&amp;</span><span> FEngineVersion::</span><span>Current</span><span>().</span><span>HasChangelist</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>즉 일반적인 uncooked/editor package load에서 다음이 모두 맞을 때 경고한다.</p><ol>
<li>이 loader가 engine-version 검사를 수행하는 경로다.</li>
<li>package summary의 <code>SavedByEngineVersion</code> changelist가 없거나 0이다.</li>
<li>target Editor build에는 nonzero changelist가 있다.</li>
</ol><p>Asset이 즉시 손상됐다는 판정은 아니다. Engine은 package를 load하되 저장 engine의 정확한 호환성을 판단할 근거가 부족하므로 경고한다.</p></section>
<section><h2>Package summary에 저장되는 버전 정보<a href="#package-summary에-저장되는-버전-정보"><span>#</span></a></h2><p><code>.uasset</code>과 <code>.umap</code> 앞부분의 <a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/FPackageFileSummary" target="_blank"><code>FPackageFileSummary</code></a>는 package 내용을 읽기 위한 목차와 여러 version field를 가진다.</p>

<table><thead><tr><th>field</th><th>의미</th></tr></thead><tbody><tr><td><code>SavedByEngineVersion</code></td><td>이 source package를 마지막으로 저장한 engine version</td></tr><tr><td><code>CompatibleWithEngineVersion</code></td><td>binary compatibility를 유지한다고 기록한 가장 이른 engine version</td></tr><tr><td>Object/File version</td><td>serialization format 변화 판정</td></tr><tr><td>Custom versions</td><td>project, plugin, engine system별 serialization 변화 판정</td></tr></tbody></table><p><code>FEngineVersion</code>에는 major, minor, patch, changelist, branch가 포함된다. 이 경고의 핵심은 <code>SavedByEngineVersion.GetChangelist() == 0</code>이다.</p><p>CL0 package는 engine-version 호환성 비교에서 매우 관대하게 취급될 수 있다. 그렇다고 Object Version과 Custom Version 검사까지 모두 사라지는 것은 아니다. “문제없이 load됐으니 완전히 호환된다”도, “경고가 있으니 파일이 깨졌다”도 정확한 해석이 아니다.</p></section>
<section><h2>왜 changelist 0으로 저장되는가<a href="#왜-changelist-0으로-저장되는가"><span>#</span></a></h2><p>주로 아래와 같은 이유로 발생한다.</p><ul>
<li><code>Engine/Build/Build.version</code>의 <code>Changelist</code>가 0인 source build로 source Asset을 저장했다.</li>
<li>오래된 package가 완전한 engine-version field를 갖기 전에 만들어졌다.</li>
<li>별도 branch나 custom fork에서 version metadata를 만들지 않은 build를 사용했다.</li>
<li>CL0 build에서 저장한 콘텐츠를 나중에 Launcher 또는 promoted build로 열었다.</li>
</ul><p>“UE 5.3.1 migration이 경고를 만들었다”고 바로 결론 내리면 안 된다. 4.27-Plus build가 이미 CL0 package를 만들었고, nonzero-CL UE 5.3.1이 이를 처음 경고했을 가능성도 있다.</p><p>먼저 warning log의 package를 하나 골라 이전 build와 target build의 <code>Build.version</code>, source control history, package 저장 시점을 확인한다.</p></section>
<section><h2>두 방법은 같은 해결책이 아니다<a href="#두-방법은-같은-해결책이-아니다"><span>#</span></a></h2>

<table><thead><tr><th>방법</th><th>package metadata 변경</th><th>경고 제거 가능</th><th>호환성 근거 복구</th><th>적합한 경우</th></tr></thead><tbody><tr><td>target Editor로 package 직접 열고 Save</td><td>예</td><td>예</td><td>예</td><td>먼저 canary Asset 한두 개로 원인과 결과 확인</td></tr><tr><td><code>ResavePackages -OnlyUnversioned</code></td><td>예</td><td>예</td><td>예</td><td>검증된 target build에서 project의 많은 CL0 package를 일괄 처리</td></tr><tr><td><code>ZeroEngineVersionWarning=0</code></td><td>아니요</td><td>log만 숨김</td><td>아니요</td><td>의도적인 CL0 pipeline의 위험을 별도로 승인한 특수한 경우</td></tr><tr><td>아무 처리 없이 계속 사용</td><td>아니요</td><td>아니요</td><td>아니요</td><td>제3자 read-only content 등을 조사하는 동안의 임시 상태</td></tr></tbody></table><p>일반적인 migration의 목표는 경고 문구를 없애는 것이 아니라 <strong>실제 배포에 사용할 target engine으로 package를 다시 저장하고 결과를 검증하는 것</strong>이다.</p></section>
<section><h2>대량 resave 전에 확인할 것<a href="#대량-resave-전에-확인할-것"><span>#</span></a></h2><section><h3>1. 원본과 분리된 복사본 또는 깨끗한 source-control 상태<a href="#1-원본과-분리된-복사본-또는-깨끗한-source-control-상태"><span>#</span></a></h3><p>새 engine에서 저장한 Asset은 이전 engine에서 다시 열 수 있다고 기대하면 안 된다. Epic의 <a href="https://dev.epicgames.com/documentation/unreal-engine/updating-projects-to-newer-versions-of-unreal-engine" target="_blank">project update 안내</a>는 새 version으로 변환할 때 원본의 복사본을 만들 것을 권장한다.</p><ul>
<li>별도 branch, Perforce changelist 또는 완전한 project 복사본을 만든다.</li>
<li>작업 전 uncommitted Asset 변경이 없어야 한다.</li>
<li>binary diff를 되돌릴 수 있는 source control 또는 backup을 준비한다.</li>
<li>Editor와 commandlet이 같은 파일을 동시에 저장하지 않도록 Editor를 닫는다.</li>
</ul></section><section><h3>2. target build의 changelist가 nonzero인지 확인<a href="#2-target-build의-changelist가-nonzero인지-확인"><span>#</span></a></h3><p>CL0 Editor로 다시 저장하면 새 package에도 CL0가 기록되므로 수리가 되지 않는다. Launcher build, promoted internal build 또는 조직의 versioning 정책에 따라 올바르게 생성된 nonzero-CL build를 사용한다. 숫자만 임의로 바꿔 호환성을 가장하지 않는다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$ueRoot</span><span><span> </span><span>=</span><span> </span></span><span>'C:\Program Files\Epic Games\UE_5.3'</span></div></div><div><div><div>2</div></div><div><span>$buildVersionFile</span><span><span> </span><span>=</span><span> </span></span><span>Join-Path</span><span> </span><span>$ueRoot</span><span> </span><span>'Engine\Build\Build.version'</span></div></div><div><div><div>3</div></div><div><span>$buildVersion</span><span><span> </span><span>=</span><span> </span></span><span>Get-Content</span><span><span> </span><span>-</span><span>LiteralPath </span></span><span>$buildVersionFile</span><span><span> </span><span>-</span><span>Raw |</span></span></div></div><div><div><div>4</div></div><div><span>    </span><span>ConvertFrom-Json</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>$buildVersion</span><span> |</span></div></div><div><div><div>7</div></div><div><span>    </span><span>Select-Object</span><span> MajorVersion, MinorVersion, PatchVersion,</span></div></div><div><div><div>8</div></div><div><span><span>                  </span></span><span>Changelist, CompatibleChangelist,</span></div></div><div><div><div>9</div></div><div><span><span>                  </span></span><span>IsLicenseeVersion, IsPromotedBuild</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>if</span><span> ([</span><span>int64</span><span>]</span><span>$buildVersion.Changelist</span><span><span> </span><span>-eq</span><span> </span></span><span>0</span><span>) {</span></div></div><div><div><div>12</div></div><div><span>    </span><span>throw</span><span> </span><span>'Target engine changelist is 0. Resaving will not repair the metadata.'</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3. Asset 하나로 canary test<a href="#3-asset-하나로-canary-test"><span>#</span></a></h3><p>warning에 나온 Asset 하나를 target Editor에서 열고 저장한다. Editor를 다시 시작하거나 package를 unload/reload한 뒤 warning이 사라지는지 확인한다. source-control diff의 범위와 Asset 동작이 예상과 같을 때만 일괄 작업으로 넓힌다.</p></section></section>
<section><h2>안전한 <code>ResavePackages</code> 명령<a href="#안전한-resavepackages-명령"><span>#</span></a></h2><p>다음 PowerShell 예시는 실행 파일과 project가 실제로 존재하는지, target changelist가 nonzero인지 확인한 뒤 project 범위의 CL0 package만 다시 저장한다.</p><div><div><div><figure><figcaption><span></span><span>Terminal window</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$ueRoot</span><span><span> </span><span>=</span><span> </span></span><span>'C:\Program Files\Epic Games\UE_5.3'</span></div></div><div><div><div>2</div></div><div><span>$projectFile</span><span><span> </span><span>=</span><span> </span></span><span>'D:\Projects\MyGame\MyGame.uproject'</span></div></div><div><div><div>3</div></div><div><span>$editorCmd</span><span><span> </span><span>=</span><span> </span></span><span>Join-Path</span><span> </span><span>$ueRoot</span><span> </span><span>'Engine\Binaries\Win64\UnrealEditor-Cmd.exe'</span></div></div><div><div><div>4</div></div><div><span>$buildVersionFile</span><span><span> </span><span>=</span><span> </span></span><span>Join-Path</span><span> </span><span>$ueRoot</span><span> </span><span>'Engine\Build\Build.version'</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>if</span><span><span> (</span><span>-not</span><span> (</span></span><span>Test-Path</span><span><span> </span><span>-</span><span>LiteralPath </span></span><span>$editorCmd</span><span>)) {</span></div></div><div><div><div>7</div></div><div><span>    </span><span>throw</span><span> </span><span><span>"UnrealEditor-Cmd.exe not found: </span><span>$</span></span><span>editorCmd</span><span>"</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>if</span><span><span> (</span><span>-not</span><span> (</span></span><span>Test-Path</span><span><span> </span><span>-</span><span>LiteralPath </span></span><span>$projectFile</span><span>)) {</span></div></div><div><div><div>11</div></div><div><span>    </span><span>throw</span><span> </span><span><span>"uproject not found: </span><span>$</span></span><span>projectFile</span><span>"</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>$buildVersion</span><span><span> </span><span>=</span><span> </span></span><span>Get-Content</span><span><span> </span><span>-</span><span>LiteralPath </span></span><span>$buildVersionFile</span><span><span> </span><span>-</span><span>Raw |</span></span></div></div><div><div><div>15</div></div><div><span>    </span><span>ConvertFrom-Json</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>if</span><span> ([</span><span>int64</span><span>]</span><span>$buildVersion.Changelist</span><span><span> </span><span>-eq</span><span> </span></span><span>0</span><span>) {</span></div></div><div><div><div>18</div></div><div><span>    </span><span>throw</span><span> </span><span>'Target engine changelist is 0. Resaving will not repair the metadata.'</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>&amp; </span><span>$editorCmd</span><span> </span><span>$projectFile</span><span> `</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>-</span><span>run</span><span>=</span><span>ResavePackages `</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>-</span><span>OnlyUnversioned `</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>-</span><span>ProjectOnly `</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>-</span><span>Unattended `</span></div></div><div><div><div>26</div></div><div><span><span>    </span></span><span>-</span><span>UTF8Output</span></div></div><div><div><div>27</div></div><div>
</div></div><div><div><div>28</div></div><div><span>if</span><span><span> ($LASTEXITCODE </span><span>-ne</span><span> </span></span><span>0</span><span>) {</span></div></div><div><div><div>29</div></div><div><span>    </span><span>throw</span><span> </span><span>"ResavePackages failed with exit code $LASTEXITCODE"</span></div></div><div><div><div>30</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>실행 파일과 <code>.uproject</code> 경로는 PowerShell argument로 전달하므로 공백이 있어도 안전하다. <code>cmd.exe</code>나 batch file로 옮긴다면 각각을 명시적으로 quote해야 한다.</p><p>UE 4.27에서는 실행 파일 이름만 다르다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$editorCmd</span><span><span> </span><span>=</span><span> </span></span><span>Join-Path</span><span> </span><span>$ueRoot</span><span> </span><span>'Engine\Binaries\Win64\UE4Editor-Cmd.exe'</span></div></div></code></pre><div><div></div><div></div></div></figure></div><section><h3>각 flag의 의미<a href="#각-flag의-의미"><span>#</span></a></h3>

<table><thead><tr><th>flag</th><th>이 작업에서의 역할</th></tr></thead><tbody><tr><td><code>-run=ResavePackages</code></td><td>package를 순회해 load/save하는 commandlet 실행</td></tr><tr><td><code>-OnlyUnversioned</code></td><td><code>SavedByEngineVersion.GetChangelist() == 0</code>인 package만 저장 후보로 선택</td></tr><tr><td><code>-ProjectOnly</code></td><td>Engine directory package를 제외하고 project 범위로 제한. project plugin content는 포함될 수 있음</td></tr><tr><td><code>-Unattended</code></td><td>dialog가 필요한 상태에서 automation이 멈추는 위험 감소</td></tr><tr><td><code>-UTF8Output</code></td><td>redirected log의 encoding을 일관되게 유지</td></tr></tbody></table><p><code>-OnlyUnversioned</code>는 “version이라는 이름이 붙은 모든 상태가 없는 Asset”이라는 뜻이 아니다. 정확히 이 commandlet에서 SavedBy changelist가 0인 package를 고르는 filter다.</p><p>Perforce에서 Asset이 read-only라면 팀과 대상 범위를 합의한 뒤 <code>-AutoCheckOut</code>을 추가할 수 있다. 자동 제출 옵션인 <code>-AutoCheckIn</code> 또는 <code>-AutoSubmit</code>은 이 migration에 넣지 않는다. commandlet이 끝나도 사람이 binary 변경과 test 결과를 검토한 뒤 별도로 제출한다.</p></section></section>
<section><h2>Cook의 <code>-UnVersioned</code>와 혼동하지 않기<a href="#cook의--unversioned와-혼동하지-않기"><span>#</span></a></h2><p>이름이 비슷한 기능은 목적이 다르다.</p>

<table><thead><tr><th>이름</th><th>대상</th><th>의미</th></tr></thead><tbody><tr><td><code>ResavePackages -OnlyUnversioned</code></td><td>editor source package</td><td>SavedBy changelist가 0인 기존 package만 골라 target Editor로 다시 저장</td></tr><tr><td>Cook <code>-UnVersioned</code></td><td>cooked output</td><td>배포용 cooked package의 version 정보를 의도적으로 생략하는 cook option</td></tr><tr><td><code>FPackageFileSummary::bUnversioned</code> 등</td><td>serialization 내부</td><td>property/object versioning의 다른 경로이며 이 경고 filter와 동일한 이름이 아님</td></tr></tbody></table><p><a href="https://dev.epicgames.com/documentation/unreal-engine/cooking-content-in-unreal-engine" target="_blank">Cooking Content</a>은 cook option의 별도 목적을 설명한다. source Asset 경고를 고치려고 cook의 <code>-UnVersioned</code>를 바꾸지 않는다.</p></section>
<section><h2>실행 뒤 반드시 검증하기<a href="#실행-뒤-반드시-검증하기"><span>#</span></a></h2><p>commandlet exit code 0만으로 migration이 끝난 것은 아니다.</p><ol>
<li><code>Saved/Logs</code>에서 <code>LogLinker</code>, <code>LogContentCommandlet</code>, <code>Skipping</code>, <code>Warning</code>, <code>Error</code>, <code>[REPORT]</code>를 확인한다.</li>
<li>source control에서 예상한 <code>.uasset</code>과 <code>.umap</code>만 바뀌었는지 본다.</li>
<li>예상보다 많은 binary가 바뀌었다면 target engine conversion, redirector, custom version이 함께 적용됐는지 조사한다.</li>
<li>같은 명령을 한 번 더 실행해 추가 resave 대상이 남지 않는지 확인한다.</li>
<li>주요 map을 열고 Blueprint compile, Asset validation, gameplay smoke test를 수행한다.</li>
<li>clean cook와 package build를 실행한다.</li>
<li>원래 warning이 사라졌는지, 새 serialization·load warning이 생기지 않았는지 비교한다.</li>
<li>결과를 engine migration 전용 commit 또는 changelist로 분리한다.</li>
</ol><p><code>ResavePackages</code>는 header byte 몇 개만 patch하는 도구가 아니다. 각 package를 실제로 load하고 저장하므로 target engine의 serialization conversion과 editor-side save 동작이 적용된다. binary diff가 넓을 수 있는 이유다.</p></section>
<section><h2><code>ZeroEngineVersionWarning</code>은 언제 쓰는가<a href="#zeroengineversionwarning은-언제-쓰는가"><span>#</span></a></h2><p><code>False</code>와 <code>0</code>은 bool parser에서 같은 warning toggle 값으로 동작한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>; Config/DefaultEngine.ini</span></div></div><div><div><div>2</div></div><div><span>[Core.System]</span></div></div><div><div><div>3</div></div><div><span>ZeroEngineVersionWarning</span><span><span>=</span><span>False</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>어느 표기를 쓰든 다음은 변하지 않는다.</p><ul>
<li>package의 <code>SavedByEngineVersion</code>을 수정하지 않는다.</li>
<li>호환성을 검증하거나 복구하지 않는다.</li>
<li>cook output과 source Asset을 고치지 않는다.</li>
<li>process의 log message만 억제한다.</li>
</ul><p>값은 process에서 일찍 읽힐 수 있으므로 설정을 바꾼 뒤 Editor와 commandlet을 다시 시작한다. PC 재부팅은 필요 없다.</p><p>조직이 의도적으로 CL0 source build를 사용하고, 별도 Object/Custom Version과 test matrix로 위험을 관리하며, 이 warning이 예상된 noise라는 결정을 내린 경우에는 억제를 고려할 수 있다. 일반 project migration에서 log를 깨끗하게 보이게 하려고 사용하는 해결책은 아니다.</p></section>
<section><h2>Cook과 compatibility에 미치는 영향<a href="#cook과-compatibility에-미치는-영향"><span>#</span></a></h2><p>warning은 기본적으로 warning이므로 항상 cook을 중단시키지는 않는다. CI가 warning을 error로 승격하면 project policy 때문에 실패할 수 있다. 더 중요한 위험은 package가 어느 nonzero changelist build에서 저장됐는지 근거가 없어 engine-version compatibility 검사가 관대해진다는 점이다.</p><ul>
<li>warning을 숨겨도 compatibility risk는 그대로다.</li>
<li>target UE5에서 resave한 Asset이 이전 UE4.27에서 다시 열린다고 기대하지 않는다.</li>
<li>standard UE4.27→UE5 forward migration 가능성과 4.27-Plus custom fork의 serializer 호환성은 별개다.</li>
<li>plugin의 Custom Version이 있다면 target version의 upgrade path를 따로 test한다.</li>
<li>third-party content를 수정할 권한이 없으면 vendor update나 별도 migration copy를 검토한다.</li>
</ul><p>UE5의 <a href="https://dev.epicgames.com/documentation/unreal-engine/unreal-engine-5-migration-guide" target="_blank">Migration Guide</a>는 UE4.27 project를 UE5로 가져올 수 있지만 새 engine에서 저장한 Asset을 이전 engine으로 되돌리는 workflow를 보장하지 않는다고 경고한다.</p></section>
<section><h2>흔한 실수<a href="#흔한-실수"><span>#</span></a></h2><ul>
<li>경로에 공백이 있는데 실행 파일과 <code>.uproject</code>를 하나의 문자열 명령으로 조립함</li>
<li><code>-ProjectOnly</code> 없이 mounted Engine content까지 넓게 순회함</li>
<li>Editor를 열어 둔 채 external commandlet로 같은 package를 저장함</li>
<li>target <code>Build.version</code>의 changelist가 0인데 resave를 반복함</li>
<li>dirty working tree에서 실행해 기존 Asset 변경과 migration diff를 섞음</li>
<li>Perforce 자동 checkout과 자동 submit을 검토 없이 켬</li>
<li><code>ZeroEngineVersionWarning=0</code>을 metadata repair로 오해함</li>
<li>Cook <code>-UnVersioned</code>를 source Asset 경고의 반대 option으로 오해함</li>
<li>UE5에서 저장한 Asset을 UE4 branch에 다시 복사함</li>
<li>warning 수만 줄고 새 load/cook error가 생겼는지 확인하지 않음</li>
</ul></section>
<section><h2>결론<a href="#결론"><span>#</span></a></h2><p><code>Asset has been saved with empty engine version</code>은 대개 source package의 <code>SavedByEngineVersion</code> changelist가 0인데 target Editor는 nonzero changelist를 가진 상황을 뜻한다. Asset은 load되지만 정확한 저장 engine을 근거로 한 compatibility 판단이 느슨해진다.</p><p>안전하게 해결하려면 target engine의 changelist부터 확인하고 canary Asset을 수동 저장해 원인을 검증한다. 그런 다음 깨끗한 복사본이나 source-control changelist에서 <code>ResavePackages -OnlyUnversioned -ProjectOnly</code>를 실행하고, binary diff, log, map load, Blueprint compile, clean cook와 package를 모두 확인한다.</p><p><code>ZeroEngineVersionWarning=0</code>은 의도적인 CL0 pipeline에서 noise를 억제하는 설정일 뿐이다. package metadata를 복구하지 않으므로 일반 migration의 두 번째 해결책으로 나열해서는 안 된다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/versioning-of-assets-and-packages-in-unreal-engine" target="_blank">Epic Games: Versioning of Assets and Packages</a></li>
<li><a href="https://dev.epicgames.com/documentation/ko-kr/unreal-engine/versioning-of-assets-and-packages?application_version=4.27" target="_blank">Epic Games UE 4.27: Versioning of Assets and Packages</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/FPackageFileSummary" target="_blank">Epic Games: <code>FPackageFileSummary</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/Core/FEngineVersionBase" target="_blank">Epic Games: <code>FEngineVersionBase</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/Core/FBuildVersion" target="_blank">Epic Games: <code>FBuildVersion</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Editor/UnrealEd/UResavePackagesCommandlet" target="_blank">Epic Games: <code>UResavePackagesCommandlet</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/updating-projects-to-newer-versions-of-unreal-engine" target="_blank">Epic Games: Updating projects to newer versions</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/cooking-content-in-unreal-engine" target="_blank">Epic Games: Content Cooking</a></li>
<li><a href="https://forums.unrealengine.com/t/asset-has-been-saved-with-empty-engine-version/323372" target="_blank">Epic Developer Community: empty engine version forum answer</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/jetbrains-space-docker-wsl2-vhdx-disk-full/</id>
      <title type="text">Docker Desktop WSL2 VHDX 용량 부족 복구</title>
      <published>2023-09-26T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/jetbrains-space-docker-wsl2-vhdx-disk-full/"/>
      <summary type="text">JetBrains Space On-Premises 서비스에서 No space left on device가 발생한 사례를 통해 Docker Desktop의 WSL2 VHDX 용량을 진단하고 확장하는 방법을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Docker Desktop의 WSL2 backend에서 운영하던 서비스의 PostgreSQL과 Elasticsearch가 <code>No space left on device</code>로 중단됐다. host drive에는 여유 공간이 있었지만 Linux filesystem이 들어 있는 VHDX의 한계에 도달한 상태였다.</p>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>container를 재시작해도 회복되지 않았고 database write와 index allocation은 계속 실패했다. Windows 탐색기의 여유 공간만 봐서는 원인을 놓치기 쉽다.</p><p></p><figure><img alt="Docker Desktop 데이터 VHDX가 용량 한계에 도달한 화면" loading="lazy" width="421" height="580" src="/_astro/docker-vhdx-capacity.C50GhCdf_2jo8o.webp" /><figcaption>Docker Desktop 데이터 VHDX가 용량 한계에 도달한 화면</figcaption></figure><p></p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>Docker Desktop이 WSL2 distribution의 virtual disk에 container data를 저장하는 Windows 환경을 다룬다. 처음 문제를 발견한 곳은 JetBrains Space On-Premises였지만 다른 Docker workload도 원리는 같다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>Windows host volume의 남은 공간과 WSL2 ext4 filesystem의 크기는 별개다. 오래된 환경에서는 virtual disk의 최대 크기 또는 partition/filesystem 크기가 먼저 한계에 닿기도 한다. 이미지, volume, database WAL과 search index가 계속 늘면서 Linux 쪽 사용률이 100%가 됐다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><p>PowerShell과 WSL에서 각기 다른 층을 확인한다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>wsl </span><span>--</span><span>list </span><span>--</span><span>verbose</span></span></div></div><div><div><div>2</div></div><div><span><span>wsl </span><span>--</span><span>system </span><span>-</span><span>d &lt;distribution&gt; df </span><span>-</span><span>h</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Docker가 사용하는 distribution 이름과 data location은 Docker Desktop 버전에 따라 달라지기도 한다. VHDX 경로를 추측해 수정하지 말고 Docker Desktop 설정과 <code>wsl --list</code>로 먼저 식별한다.</p></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>최근 WSL에서는 지원되는 resize 명령부터 사용한다.</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>wsl </span><span>--</span><span>shutdown</span></span></div></div><div><div><div>2</div></div><div><span><span>wsl </span><span>--</span><span>manage &lt;distribution&gt; </span><span>--</span><span>resize </span></span><span>1536</span><span>GB</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>명령이 지원되지 않는 구버전은 WSL을 업데이트하는 편이 우선이다. 불가피하게 수동으로 처리할 때는 backup을 만든 뒤 VHDX를 확장하고 내부 partition과 ext4 filesystem까지 별도로 늘려야 한다.</p><p></p><figure><img alt="Hyper-V 도구에서 VHDX 확장 단계를 연 화면" loading="lazy" width="704" height="533" src="/_astro/hyper-v-vhdx-expand.xZrJD2Pv_Z1opVn4.webp" /><figcaption>Hyper-V 도구에서 VHDX 확장 단계를 연 화면</figcaption></figure><p></p><p></p><figure><img alt="Docker Desktop의 WSL integration 설정" loading="lazy" width="1920" height="1040" src="/_astro/docker-desktop-wsl-integration.C47IQ-jH_5GGES.webp" /><figcaption>Docker Desktop의 WSL integration 설정</figcaption></figure><p></p><p></p><figure><img alt="WSL에서 Docker data block device를 확인한 화면" loading="lazy" width="847" height="240" src="/_astro/wsl-docker-data-block-device.Cwb9blGp_Z21y3Es.webp" /><figcaption>WSL에서 Docker data block device를 확인한 화면</figcaption></figure><p></p><p>디스크를 늘린 다음에는 사용하지 않는 image·build cache·volume을 찾아 보존 정책에 맞게 정리한다. database volume은 서비스 backup을 확보하기 전에 삭제하지 않는다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>WSL의 <code>df -h</code>와 block device 크기가 모두 늘었는지 확인해 본다.</li>
<li>Docker Desktop과 대상 container를 시작해 database recovery log를 점검한다.</li>
<li>쓰기·index 생성과 backup 복구 시험을 수행해 본다.</li>
<li>며칠간 disk growth와 alert threshold를 모니터링한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>VHDX 확장은 host filesystem, virtual disk, partition과 ext4 filesystem을 함께 다루는 작업이다. 중간에 전원을 끄거나 잘못된 distribution의 disk를 수정하면 데이터가 손상될 위험이 있다. 확장만 반복하지 말고 log·artifact·volume retention도 함께 고친다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://learn.microsoft.com/en-us/windows/wsl/disk-space" target="_blank">Microsoft Learn: How to manage WSL disk space</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-functional-test-ecomparisonmethod-shipping/</id>
      <title type="text">Shipping 빌드에서 EComparisonMethod를 사용하지 않는 이유</title>
      <published>2023-09-22T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-functional-test-ecomparisonmethod-shipping/"/>
      <summary type="text">FunctionalTesting 모듈의 EComparisonMethod를 런타임 코드에서 사용해 Shipping 빌드가 깨질 때 프로젝트 전용 enum으로 의존성을 분리하는 방법을 설명합니다.</summary>
      <content type="html"><![CDATA[<p>개발 빌드에서는 정상이던 코드가 Shipping target에서 <code>EComparisonMethod</code>를 찾지 못했다. <code>FunctionalTesting</code> 모듈 의존성 때문에 컴파일·링크에 실패하는 경우도 있었다.</p>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>게임 런타임 로직에서 <code>FunctionalTest.h</code>에 선언된 비교 enum을 편의상 재사용한 것이 시작이었다. Editor나 Development 구성에는 FunctionalTesting 모듈이 들어와 문제가 없었다. 하지만 Shipping 구성에서 developer용 테스트 코드가 빠지자 모듈 의존성 문제가 드러났다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>게임의 Runtime 모듈 또는 Shipping에 포함되는 public header에서 <code>FunctionalTesting</code> 모듈의 타입을 참조하는 프로젝트에 해당한다. 정확한 모듈 포함 여부는 사용 중인 엔진 버전과 target rules를 기준으로 확인한다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p><code>EComparisonMethod</code>는 일반 수학 유틸리티가 아니라 <code>FunctionalTesting</code> developer 모듈의 API다. 게임 런타임 규칙이 테스트 framework 타입에 의존하면 Shipping target은 불필요한 developer 모듈까지 끌어온다. 해당 모듈이 없는 구성에서는 빌드가 실패한다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>Shipping target의 첫 컴파일 오류에서 포함 경로와 참조 타입을 확인한다.</li>
<li><code>.Build.cs</code>에서 <code>FunctionalTesting</code>을 Runtime 모듈이 public dependency로 요구하는지 찾아본다.</li>
<li>public header가 <code>FunctionalTest.h</code>를 include하는지 검색해 둔다.</li>
<li>Editor target에서 우연히 transitive dependency로 해결된 것은 아닌지 clean build로 검증한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>게임 실행에 필요한 의미만 남겨 프로젝트 소유의 runtime enum으로 따로 정의한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UENUM</span><span>(</span><span>BlueprintType</span><span>)</span></div></div><div><div><div>2</div></div><div><span>enum</span><span> </span><span>class</span><span> </span><span>EGameComparisonMethod</span><span> : </span><span>uint8</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>Equal</span><span>,</span></div></div><div><div><div>5</div></div><div><span>    </span><span>NotEqual</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>Less</span><span>,</span></div></div><div><div><div>7</div></div><div><span>    </span><span>LessOrEqual</span><span>,</span></div></div><div><div><div>8</div></div><div><span>    </span><span>Greater</span><span>,</span></div></div><div><div><div>9</div></div><div><span>    </span><span>GreaterOrEqual</span><span>,</span></div></div><div><div><div>10</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>테스트 코드에서 두 enum을 연결해야 할 때는 별도 test/editor 모듈에 변환 함수를 둔다. 이 모듈은 FunctionalTesting에 의존해도 된다. Shipping에 테스트 기능 자체가 필요 없다면 target/configuration gate에서 관련 코드 전체를 제외한다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>Development Editor, Development Client/Server, Shipping target을 각각 clean build한다.</li>
<li>Runtime 모듈의 dependency graph에서 <code>FunctionalTesting</code>이 제거됐는지 살핀다.</li>
<li>비교 연산별 경계값과 부동소수점 tolerance 동작을 시험한다.</li>
<li>Blueprint에 노출된 기존 자산이 있다면 enum 교체 후 redirect 또는 재저장 필요성을 점검한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>두 enum의 이름과 항목을 복사해도 같은 ABI나 직렬화 값이 영구히 유지되지는 않는다. 저장 데이터나 network protocol에 enum 값을 기록한다면 명시적인 숫자와 versioning을 설계한다. enum 분리와 별개로 부동소수점 <code>Equal</code> 비교에는 tolerance가 필요할 수 있다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Developer/FunctionalTesting?lang=en-US" target="_blank">Epic Games: FunctionalTesting module</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Developer/FunctionalTesting/EComparisonMethod" target="_blank">Epic Games: EComparisonMethod</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-particle-bounds-culling/</id>
      <title type="text">Particle Bounding Box와 화면 각도별 컬링 문제</title>
      <published>2023-09-12T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-particle-bounds-culling/"/>
      <summary type="text">Unreal Engine Cascade 파티클이 카메라 각도에 따라 사라질 때 bounds 시각화와 Fixed Relative Bounding Box로 컬링 범위를 교정하는 방법을 설명합니다.</summary>
      <content type="html"><![CDATA[<p>파티클이 정상 재생되다가 카메라를 특정 각도로 돌리면 갑자기 사라지는 문제가 있었다.</p>

<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>emitter의 입자는 화면 안에 남아 있었다. 하지만 component bounds가 view frustum 밖이라고 판단되는 순간 전체 particle system이 컬링됐다. 반투명·긴 trail·넓게 퍼지는 effect에서는 실제 시각 범위와 계산된 bounds가 다를 때 이 문제가 쉽게 드러난다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>UE4/UE5 프로젝트에서 Cascade <code>UParticleSystem</code>을 사용할 때 해당한다. Niagara는 bounds 설정 위치와 계산 방식이 다르다. system/emitter properties의 bounds를 별도로 확인한다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>renderer는 모든 particle을 매 frame 개별 확인하지 않는다. component의 bounding box로 먼저 표시 여부를 판단한다. bounds가 실제 effect보다 작거나 origin에서 벗어나 있으면 카메라 각도에 따라 effect 전체가 사라진다.</p><p></p><figure><img alt="Cascade에서 파티클 bounds를 시각화한 화면" loading="lazy" width="1154" height="1085" src="/_astro/cascade-bounds-visualization.DnC6o9k5_Zip13c.webp" /><figcaption>Cascade에서 파티클 bounds를 시각화한 화면</figcaption></figure><p></p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>Cascade viewport에서 bounds 표시를 켠다.</li>
<li>effect의 최대 수명 동안 입자·trail이 box 밖으로 나가는지 확인한다.</li>
<li>level viewport에서는 카메라를 돌리며 사라지는 순간의 bounds를 비교해 본다.</li>
<li>component scale과 parent transform이 bounds에 미치는 영향도 함께 살핀다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>Particle System의 Fixed Relative Bounding Box를 켠 뒤 effect가 실제로 도달하는 최대 범위를 포함하도록 box를 설정한다.</p><p></p><figure><img alt="Fixed Relative Bounding Box를 사용하지 않은 설정" loading="lazy" width="1131" height="1950" src="/_astro/cascade-fixed-bounds-disabled.CKu3L9MF_1kwB7i.webp" /><figcaption>Fixed Relative Bounding Box를 사용하지 않은 설정</figcaption></figure><p></p><p></p><figure><img alt="Fixed Relative Bounding Box를 켜고 범위를 조정한 설정" loading="lazy" width="1144" height="1953" src="/_astro/cascade-fixed-bounds-enabled.Bs9DS9kA_2gGUvb.webp" /><figcaption>Fixed Relative Bounding Box를 켜고 범위를 조정한 설정</figcaption></figure><p></p><p>가장 과격한 emitter 상태, 이동하는 parent와 LOD까지 포함해 필요한 범위를 측정한다. 값은 임의로 매우 크게 잡지 않는다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>가까운 거리와 먼 거리, 넓은 FOV와 좁은 FOV에서 카메라를 360도 회전한다.</li>
<li>effect lifetime 전체와 spawn burst 순간을 확인한다.</li>
<li>list/grid가 아닌 실제 게임의 component scale과 attachment 상태에서 시험한다.</li>
<li><code>stat initviews</code> 등으로 컬링과 primitive 수가 과도하게 늘지 않는지도 살핀다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>너무 작은 bounds는 시각 오류를 만든다. 지나치게 큰 bounds는 보이지 않는 effect까지 렌더링 후보로 남겨 성능을 해친다. 원점이나 scale이 잘못된 effect를 bounds 확대만으로 숨기지 않는다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/particle-system-class?application_version=4.27" target="_blank">Epic Games: Particle System Class—Fixed Relative Bounding Box</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/root-motion-replication-simulated-proxy/</id>
      <title type="text">Root Motion 중 이동 리플리케이션이 멈춘 것처럼 보일 때</title>
      <published>2023-08-23T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/root-motion-replication-simulated-proxy/"/>
      <summary type="text">네트워크 Root Motion Montage가 재생되는 동안 simulated proxy의 일반 ReplicatedMovement 보정 경로가 달라지는 이유와 안전한 점검 순서를 설명합니다.</summary>
      <content type="html"><![CDATA[<p>Root Motion Montage 재생 중 다른 client에서 character 위치가 갱신되지 않거나 montage가 끝난 뒤 갑자기 보정되는 현상이 있었다.</p>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>server와 owning client에서는 이동이 자연스러웠다. 하지만 simulated proxy에서는 일반 actor transform replication이 멈춘 듯 보였다. <code>ACharacter::OnRep_ReplicatedMovement</code>를 따라가면 networked root motion montage가 활성화된 동안 일반 보정과 다른 경로를 사용한다는 사실이 확인된다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p><code>ACharacter</code>, <code>CharacterMovementComponent</code>, animation montage의 Root Motion을 network play에서 쓰는 프로젝트에 해당한다. custom movement나 Gameplay Ability System이 별도 prediction을 제공한다면 해당 계층도 함께 살펴본다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>Unreal의 networked Root Motion Montage는 server transform만 단순히 복제하지 않는다. montage 위치, root motion source와 server correction을 결합해 proxy를 갱신한다. montage trigger나 position이 올바르게 전달되지 않으면 proxy는 root motion 전용 경로에 들어가도 적용할 animation 상태가 부족하다. 이때 보정이 멈춘 것처럼 보인다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>server, autonomous proxy, simulated proxy에서 <code>LocalRole</code>, montage 이름과 position을 기록한다.</li>
<li><code>IsPlayingNetworkedRootMotionMontage</code>, <code>RepRootMotion</code>과 movement mode를 서로 비교한다.</li>
<li>montage 시작 RPC가 server 권한을 거쳐 관련 client에 전달되는지 확인한다.</li>
<li>AnimInstance의 Root Motion Mode가 역할별로 예상한 값인지도 살핀다.</li>
<li>packet loss와 latency 조건에서 montage 시작·중단·blend out을 반복한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>일반적인 구성에서는 server가 montage 시작을 권한 있게 결정한 뒤 필요한 client에 동일한 montage 상태를 전달한다. animation blueprint는 network play에 권장되는 <code>Root Motion from Montages Only</code>를 사용하며 character movement의 기본 root motion replication 경로를 유지한다.</p><p>과거 사례에서는 simulated proxy의 Root Motion Mode를 <code>Ignore Root Motion</code>으로 바꾸자 server transform replication이 다시 보였다. 이 수정은 모든 프로젝트에 적용할 수 없다. server transform만을 단일 진실로 삼고 animation root delta를 절대 적용하지 않도록 설계한 경우에만 역할별 설정을 검토한다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>listen server와 dedicated server에서 각각 두 client 이상으로 시험한다.</li>
<li>montage 시작·중단·연속 재생과 late join을 확인한다.</li>
<li>높은 latency와 packet loss에서는 위치 오차, montage position과 correction 횟수를 측정한다.</li>
<li>root motion이 없는 montage와 같은 이동을 비교한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>simulated proxy에서 root motion을 무조건 끄면 발 미끄러짐, montage 위치 불일치와 collision 결과 차이가 생기기도 한다. <code>OnRep_ReplicatedMovement</code>의 한 branch만 우회하기 전에 montage replication과 권한 구조부터 고친다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/root-motion-in-unreal-engine" target="_blank">Epic Games: Root Motion</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-montage-in-unreal-engine" target="_blank">Epic Games: Animation Montage</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/understanding-networked-movement-in-the-character-movement-component-for-unreal-engine" target="_blank">Epic Games: Understanding Networked Movement</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-role-and-remote-role-in-unreal-engine" target="_blank">Epic Games: Actor Role and Remote Role</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-fast-array-replication-update-limits/</id>
      <title type="text">FastArraySerializer 변경·삭제 개수 제한 경고 다루기</title>
      <published>2023-06-21T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-fast-array-replication-update-limits/"/>
      <summary type="text">FFastArraySerializer가 한 번의 업데이트에서 허용된 변경·삭제 개수를 넘었다고 경고할 때 dirty 표시와 대량 변경 설계를 점검하는 방법을 정리합니다.</summary>
      <content type="html"><![CDATA[<p><code>FFastArraySerializer</code> 배열에서 한 frame에 많은 원소를 바꾸거나 지우면 아래 경고가 나타난다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>NumDeletes &gt; GetMaxNumberOfAllowedDeletionsPerUpdate()</span></div></div><div><div><div>2</div></div><div><span>Header.NumChanged &gt; GetMaxNumberOfAllowedChangesPerUpdate()</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>서버의 배열 최종 상태가 맞더라도 client가 일부 변경을 받지 못하는 경우가 있다. replication 중 제한 초과 경고가 반복되기도 한다. inventory 초기화, 대규모 reset, reconnect snapshot처럼 많은 원소를 한꺼번에 처리하는 경로에서 주로 드러난다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>Unreal 프로젝트에서 <code>FFastArraySerializer</code> 또는 Iris의 fast array 지원을 사용할 때 해당한다. 기본 허용 개수와 조정 방법은 엔진 버전에 따라 달라진다. 설치한 source에서 <code>GetMaxNumberOfAllowedChangesPerUpdate</code>와 <code>GetMaxNumberOfAllowedDeletionsPerUpdate</code> 구현을 확인한다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>Fast Array delta는 변경을 한 update에 무제한으로 싣지 않는다. 지나치게 큰 packet, 잘못된 ID 또는 악의적인 payload를 막으려고 개수에 제한을 둔다. 배열 전체 재생성이나 불필요한 dirty 표시는 이 제한을 빠르게 소진한다.</p><ul>
<li>배열 전체를 지우고 같은 내용을 다시 추가한다.</li>
<li>매 tick 같은 원소를 불필요하게 <code>MarkItemDirty</code>한다.</li>
<li>원소 추가·수정 후 dirty 표시를 빠뜨렸다가 한 번에 몰아서 처리한다.</li>
<li>대량 snapshot과 작은 gameplay delta를 같은 replication 경로에 싣는다.</li>
</ul></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>update 직전의 추가·수정·삭제 개수와 배열 길이를 로그에 기록한다.</li>
<li>원소별 <code>ReplicationID</code>가 안정적으로 유지되는지 확인한다.</li>
<li>추가·수정 시 <code>MarkItemDirty</code>, 구조 변경 시 필요한 <code>MarkArrayDirty</code>가 정확히 호출되는지 살핀다.</li>
<li>clear-and-rebuild를 실제 delta update로 바꾸는 방안도 비교해 본다.</li>
<li>제한값은 exact engine revision의 source와 console variable을 열어 직접 찾아본다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>대량 변경은 여러 update로 나누며 실제로 바뀐 원소만 표시한다. gameplay상 원자적으로 보여야 한다면 client가 batch ID와 완료 표시를 받아 마지막 조각까지 임시 상태에 모아 둔다. 모든 조각을 받은 뒤 한 번에 적용한다.</p><p>초기 전체 snapshot이 일반 delta와 다르다면 chunked RPC, 별도 replicated object 또는 versioned snapshot 전송을 검토한다. 어느 방법이 맞는지는 전송량과 재전송·late join 요구사항을 먼저 측정한 뒤 정한다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>batch 크기를 제한값 바로 아래와 위로 나눠 시험한다.</li>
<li>packet loss와 latency를 켠 dedicated server/client에서 최종 배열을 대조한다.</li>
<li>추가, 수정, 삭제, 전체 초기화, late join은 조건별로 테스트해 본다.</li>
<li>경고 유무만 보지 말고 server/client의 item ID와 payload checksum까지 비교한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>제한값을 크게 올리는 방법은 마지막에 검토한다. 전송 크기와 CPU 비용이 늘고 근본적인 dirty 처리 오류까지 숨기기 때문이다. Fast Array callback의 순서만으로 gameplay event의 정확한 전역 순서를 보장한다고 가정하면 안 된다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/NetCore/FFastArraySerializer?lang=en-US" target="_blank">Epic Games: FFastArraySerializer</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/NetCore/TFastArrayTypeHelper" target="_blank">Epic Games: TFastArrayTypeHelper</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-move-directly-toward-without-navmesh/</id>
      <title type="text">MoveDirectlyToward가 NavMesh 없이 멈출 때 확인할 것</title>
      <published>2023-05-30T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-move-directly-toward-without-navmesh/"/>
      <summary type="text">Unreal Engine의 MoveDirectlyToward는 내비게이션을 우회하도록 설계됐지만 이동하지 않을 때가 있습니다. API 계약과 pawn·path following 설정을 기준으로 진단합니다.</summary>
      <content type="html"><![CDATA[<p>Behavior Tree에서 <code>MoveDirectlyToward</code>를 사용했지만 NavMesh가 없는 level에서는 pawn이 움직이지 않았다. 당시에는 “이 task도 NavMesh가 필요하다”고 결론 내렸지만 현재 공식 API 설명과는 맞지 않는다.</p>
<section><h2>확인된 계약<a href="#확인된-계약"><span>#</span></a></h2><p>Epic의 <code>UBTTask_MoveDirectlyToward</code> 문서는 이 task가 navigation system을 고려하지 않고 goal로 직접 이동한다고 명시한다. NavMesh를 추가했더니 우연히 동작했다는 관찰만으로 NavMesh가 필수라고 일반화하면 안 된다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p><code>AIController</code>, Behavior Tree와 path following으로 <code>Character</code> 또는 custom pawn을 움직이는 UE 프로젝트에 해당한다. 엔진 버전에 따른 task 구현 차이는 exact source revision에서 확인한다.</p></section>
<section><h2>가능한 원인<a href="#가능한-원인"><span>#</span></a></h2><ul>
<li>AIController가 pawn을 possess하지 않았거나 Behavior Tree 자체가 실행되지 않는다.</li>
<li>blackboard key가 actor/vector 기대 타입과 다르거나 값이 유효하지 않다.</li>
<li>pawn의 movement component가 path following 요청을 지원하지 않는다.</li>
<li>이동 mode, max speed 또는 input/acceleration 설정이 0이다.</li>
<li>acceptance radius와 reach test 때문에 시작 즉시 성공 처리된다.</li>
<li>collision이나 movement constraint가 직선 이동을 막는다.</li>
<li>특정 엔진 버전에서는 task 또는 custom movement component가 navigation data 존재를 간접 전제로 삼는다.</li>
</ul></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>Behavior Tree debugger에서 task 진입·종료 상태와 blackboard 값을 살펴본다.</li>
<li>AIController, pawn, movement component와 path following component가 유효한지는 로그로 남긴다.</li>
<li><code>MoveTo</code>와 <code>MoveDirectlyToward</code>에는 같은 goal을 넣어 비교한다.</li>
<li>NavMesh 유무만 바꾸고 나머지 actor·collision 설정은 고정한다.</li>
<li>task source를 열어 실제 이동 요청과 failure 조건을 직접 확인해 본다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>먼저 possess, blackboard key와 movement component 계약을 고친다. custom pawn은 path following이 요구하는 이동 interface를 구현한다. navigation/path following을 전혀 쓰지 않는 설계라면 custom BT task에서 방향을 계산하고 pawn의 movement/input API를 명시적으로 호출한다.</p><p>일반 <code>MoveTo</code>도 함께 필요하거나 level의 AI 이동 공간을 실제로 정의해야 할 때 NavMesh를 추가한다. <code>MoveDirectlyToward</code> 하나를 살리는 필수 조건으로 취급하지 않는다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>빈 level과 NavMesh level에 같은 pawn을 두고 시험한다.</li>
<li>actor goal과 vector goal, 가까운 거리와 먼 거리를 비교한다.</li>
<li>success, failure, abort 결과와 실제 이동 거리는 함께 기록해 둔다.</li>
<li>dedicated server에서는 authoritative movement도 확인한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>직선 이동은 obstacle을 우회하지 않는다. NavMesh를 사용하지 않아도 collision이나 낙하 위험이 자동으로 처리되는 것은 아니다. 현재 초안은 과거 관찰을 바로잡은 진단 가이드다. 당시 프로젝트의 정확한 custom movement 구현이 없어 하나의 확정 원인으로 좁히지는 않았다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/AIModule/UBTTask_MoveDirectlyToward" target="_blank">Epic Games: UBTTask_MoveDirectlyToward</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/notd-custom-destructible/</id>
      <title type="text">엔진 Destructible 대신 조각 메시로 파괴 오브젝트 처리하기</title>
      <published>2023-05-08T00:00:00.000Z</published>
      <updated>2026-11-01T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/notd-custom-destructible/"/>
      <summary type="text">Night of the Dead에서 APEX Destruction 대신 Blender에서 미리 나눈 조각 StaticMesh로 파괴 오브젝트를 처리한 구조를 정리한다. 당시 환경을 재현한 실험 프로젝트에서 교체 전 구성(교체형 APEX)과 성능을 비교하고, 동시 파괴 스파이크를 프레임당 조각 예산으로 낮춘 결과를 다룬다.</summary>
      <content type="html"><![CDATA[<p><a href="/projects/night-of-the-dead/">Night of the Dead</a>의 월드에는 부술 수 있는 오브젝트가 많다. 나무와 덤불 같은 식생, 필드에 놓인 오브젝트, 던전의 구조물이 무기나 차량에 맞아 부서진다. 차량과 일부 아이템도 부서질 때 파편을 남긴다.</p>
<p>UE4에서는 PhysX 기반의 APEX Destruction으로 이 오브젝트들을 처리했는데, 원인을 알 수 없는 에러가 너무 많이 발생했고 크래시로 이어졌다. 시각 효과와 성능은 거의 같을 거라고 보고, 우리가 직접 제어할 수 있도록 필요한 기능만 최소한으로 구현한 파괴 시스템으로 바꿨다(<a href="https://steamcommunity.com/games/1377380/announcements/detail/3711572693139781185" target="_blank">개발 업데이트 #10</a>).</p>
<p>이후 <a href="https://store.steampowered.com/news/app/1377380/view/3888357282609115394" target="_blank">개발 업데이트 #15</a>에서 UE5로 이전하면서 물리 엔진이 PhysX에서 Chaos로 바뀌었다. UE5에는 PhysX와 함께 APEX Destruction도 빠졌고, 엔진의 파괴 기능은 Chaos Destruction이 맡는다. 하지만 당시 Chaos Destruction은 다루기 어려웠고 성능 문제도 있었다. 그래서 Chaos Destruction으로 옮기지 않고 UE4에서 만든 시스템을 그대로 가져갔다. 이 시스템의 조각은 일반 <code>UStaticMeshComponent</code>이고 코드가 APEX나 PhysX API를 직접 호출하지 않아서, 물리 엔진이 바뀌어도 파괴 로직을 다시 짤 필요가 없었다.</p>
<p>이 글은 엔진 Destructible과 커스텀 시스템의 구조를 일반화해 비교하고, 당시 환경을 재현한 실험 프로젝트에서 교체 전 구성과 성능을 비교한 결과를 정리한다. 겉모습은 교체 전 구성과 거의 같았고, 오브젝트 하나를 부순 프레임의 스파이크는 27% 낮았다. 여러 오브젝트를 한 프레임에 부수는 부하 시험에서는 병목을 찾았고, 재현 환경에서 프레임당 조각 예산을 시험해 100개 동시 파괴의 스파이크를 교체 전 구성보다 낮췄다.</p>
<div>
<p>한눈에 보기: UE 4.27 재현 환경</p>
<div>
<div><p>−27%</p><p>단일 파괴 스파이크</p><p>교체형 APEX 3.68ms → 커스텀 2.67ms</p></div>
<div><p>5.24ms</p><p>순차 파괴 p95</p><p>50개, 60fps 예산 16.7ms 안</p></div>
<div><p>1/10.8</p><p>대기 프레임 시간</p><p>2,000개, 상주형 APEX 대비. 교체형과 같은 구조</p></div>
<div><p>−82%</p><p>동시 파괴 스파이크</p><p>100개, 예산 전 138ms → 25ms (교체형 32ms)</p></div>
</div>
<p>조합별 3회 실행의 중앙값이다. 동시 파괴는 프레임당 조각 예산을 적용한 결과다. 측정 조건은 <a href="#재현-환경에서-측정하기">재현 환경에서 측정하기</a>에 있다.</p>
</div>
<section><h2>엔진 Destructible과 커스텀 시스템<a href="#엔진-destructible과-커스텀-시스템"><span>#</span></a></h2><p>APEX Destruction과 Chaos Destruction은 구현이 다르지만 큰 흐름은 비슷하다. 원본 메시를 에디터에서 미리 분할해 파괴 전용 자산을 만든다. UE4는 Destructible Mesh, UE5는 Geometry Collection이다. 조각들은 이 자산과 전용 컴포넌트 하나에 묶여 배치되고, 어떤 조각이 언제 떨어질지는 물리 엔진의 파괴 모듈이 정한다.</p><p>Chaos Destruction의 경우 Geometry Collection을 처음에 하나의 강체 클러스터로 시뮬레이션한다. 조각 사이에는 연결 그래프와 strain 값이 있고, 충돌이나 Physics Field가 연결 한계를 넘는 충격량을 가하면 연결이 끊어진다. APEX Destruction은 컴포넌트에 가한 피해가 자산에 설정한 피해 한계를 넘으면 조각을 분리한다.</p><p>커스텀 시스템은 이 역할을 게임 코드로 가져왔다.</p>

<table><thead><tr><th>항목</th><th>엔진 Destructible (APEX, Chaos)</th><th>커스텀 시스템</th></tr></thead><tbody><tr><td>조각 자산</td><td>에디터에서 분할한 전용 자산 하나</td><td>Blender에서 나눈 조각 StaticMesh 목록</td></tr><tr><td>파괴 전 배치</td><td>조각 구조를 가진 전용 컴포넌트(상주형) 또는 통짜 메시 뒤 파괴 시 교체(교체형)</td><td>원본 <code>UStaticMeshComponent</code> 하나</td></tr><tr><td>조각 분리 판단</td><td>파괴 모듈 (피해 한계, 연결 strain)</td><td>게임 코드 (조각별 체력과 거리 감쇠)</td></tr><tr><td>조각의 물리</td><td>파괴 모듈이 관리하는 강체</td><td><code>UStaticMeshComponent</code>의 물리 시뮬레이션</td></tr><tr><td>파괴 후 조각</td><td>자산과 컴포넌트 설정에 따른다</td><td>일정 시간 뒤 조각 컴포넌트 삭제</td></tr><tr><td>물리 엔진 교체</td><td>파괴 모듈 자체가 바뀐다 (APEX에서 Chaos로)</td><td>조각 코드는 그대로</td></tr></tbody></table><p>엔진 방식은 분할과 분리 판단을 엔진에 맡기는 대신, 문제가 생기면 원인이 엔진 모듈 안에 있을 수 있다. 커스텀 시스템은 필요한 기능만 두고, 조각이 생기고 움직이고 사라지는 시점을 모두 게임 코드에서 정한다.</p><p></p><figure><img alt="엔진 Destructible과 커스텀 시스템을 파괴 전, 파괴 순간, 파괴 후 단계로 나눠 비교한 도식" loading="lazy" width="800" height="720" src="/_astro/engine-vs-custom-lifecycle.BAgGUOQm_1OYR1c.svg" /><figcaption>엔진 Destructible과 커스텀 시스템을 파괴 전, 파괴 순간, 파괴 후 단계로 나눠 비교한 도식</figcaption></figure><p></p></section>
<section><h2>조각 데이터<a href="#조각-데이터"><span>#</span></a></h2><p>조각은 Blender에서 원본 메시를 미리 잘라 만든 StaticMesh다. 조각마다 다음 정보를 둔다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FFragmentData</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>  </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>)</span></div></div><div><div><div>7</div></div><div><span>  </span><span>TSoftObjectPtr</span><span>&lt;</span><span>UStaticMesh</span><span>&gt;</span><span> </span><span>Mesh</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>// 부분 파괴에서 이 체력이 0이 되면 조각이 떨어져 나간다.</span></div></div><div><div><div>10</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>)</span></div></div><div><div><div>11</div></div><div><span>  </span><span>float</span><span> </span><span>MaxHealth</span><span> </span><span>=</span><span> </span><span><span>20.</span><span>f</span></span><span>;</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>// 피해와 충격을 받지 않고 제자리에 남는 조각</span></div></div><div><div><div>14</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>)</span></div></div><div><div><div>15</div></div><div><span>  </span><span>bool</span><span> </span><span>bIgnoreDamage</span><span> </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>// 부분 파괴에서는 움직이지 않고 완전 파괴 때만 시뮬레이션하는 조각</span></div></div><div><div><div>18</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>)</span></div></div><div><div><div>19</div></div><div><span>  </span><span>bool</span><span> </span><span>bSimulateOnlyOnFullDestruction</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>  </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>)</span></div></div><div><div><div>22</div></div><div><span>  </span><span>float</span><span> </span><span>MassScale</span><span> </span><span>=</span><span> </span><span><span>1.</span><span>f</span></span><span>;</span></div></div><div><div><div>23</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>조각 메시는 소프트 레퍼런스로 두고, 액터가 월드에 들어오면 높은 우선순위로 비동기 로드한다. 파괴되는 순간에는 메시가 이미 메모리에 있다.</p></section>
<section><h2>완전 파괴<a href="#완전-파괴"><span>#</span></a></h2><p>부분 파괴를 켜지 않은 오브젝트는 체력이 다 닳았을 때 한 번에 부서진다. 이런 오브젝트는 파괴 전에 조각 컴포넌트를 만들지 않는다. 월드에 있는 동안은 원본 <code>UStaticMeshComponent</code> 하나다.</p><p>체력이 0이 되면 그때 조각 데이터로 <code>UStaticMeshComponent</code>를 만들어 붙이고, 원본 메시의 표시와 충돌을 끈다. 이후 조각에 물리 시뮬레이션을 켜고 조각 전체의 바운드 중심에서 방사형 충격을 준다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UFragmentDestructionComponent</span><span>::</span></span><span>DestructAll</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> (</span><span>bDestructed</span><span>)</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>7</div></div><div><span>  </span><span>bDestructed</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>for</span><span> (</span><span>UFragmentMeshComponent</span><span>*</span><span> </span><span>Fragment</span><span> : </span><span>Fragments</span><span>)</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>Fragment</span><span>-&gt;</span><span>SetMobility</span><span>(EComponentMobility::</span><span>Movable</span><span>);</span></div></div><div><div><div>12</div></div><div><span>    </span><span>Fragment</span><span>-&gt;</span><span>SetVisibility</span><span>(</span><span>true</span><span>);</span></div></div><div><div><div>13</div></div><div><span>    </span><span>Fragment</span><span>-&gt;</span><span>SetCollisionEnabled</span><span>(ECollisionEnabled::</span><span>QueryAndPhysics</span><span>);</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>// 캐릭터는 조각에 막히지 않는다.</span></div></div><div><div><div>15</div></div><div><span>    </span><span>Fragment</span><span>-&gt;</span><span>SetCollisionResponseToChannel</span><span>(</span><span>ECC_Pawn</span><span>, </span><span>ECR_Ignore</span><span>);</span></div></div><div><div><div>16</div></div><div><span>    </span><span>Fragment</span><span>-&gt;</span><span>SetCullDistance</span><span>(</span><span>FragmentCullDistance</span><span>);</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>Fragment</span><span>-&gt;</span><span>IgnoresDamage</span><span>())</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>20</div></div><div><span>      </span><span>Fragment</span><span>-&gt;</span><span>SetSimulatePhysics</span><span>(</span><span>true</span><span>);</span></div></div><div><div><div>21</div></div><div><span>      </span><span>Fragment</span><span>-&gt;</span><span>AddRadialImpulse</span><span>(</span><span>ImpulseOrigin</span><span>, </span><span>ImpulseRadius</span><span>, </span><span>ImpulseStrength</span><span>, </span><span>RIF_Linear</span><span>);</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>24</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>캐릭터는 조각과 충돌하지 않는다. 오브젝트에 따라 조각끼리의 충돌도 끌 수 있고, 조각의 컬링 거리도 따로 지정할 수 있다.</p><p>조각은 기본 10초 동안 남는다. 그 뒤 서버가 모든 클라이언트에 조각 삭제를 멀티캐스트하고, 복제되는 파괴 상태 값을 켠다. 그 뒤에 이 오브젝트를 처음 받는 클라이언트는 이 값만 보고 원본 메시를 숨기며, 조각은 만들지 않는다.</p><p>조각 컴포넌트는 복제되지 않는다. 서버는 파괴 시점과 충격 위치만 알리고, 각 클라이언트가 자기 조각을 따로 시뮬레이션한다.</p></section>
<section><h2>부분 파괴<a href="#부분-파괴"><span>#</span></a></h2><p>맞은 부분만 떨어져 나가야 하는 오브젝트는 부분 파괴를 켠다. 이 경우에는 메시를 로드한 직후 조각 컴포넌트를 미리 만들고 원본 메시를 숨긴다. 조각은 <code>Static</code> 모빌리티에 물리 시뮬레이션이 꺼진 상태로 시작한다.</p><p>서버가 피해를 받으면 맞은 위치와 법선, 피해량을 멀티캐스트한다. 각 머신은 그 위치에서 <code>ECC_Destructible</code> 채널로 구 트레이스를 한 번 하고, 걸린 조각 중 이 오브젝트의 조각에만 피해를 나눈다.</p><p></p><figure><img alt="맞은 지점을 중심으로 구 트레이스 범위 안의 조각에만 거리에 따라 줄어드는 피해가 들어가고, 체력이 0이 된 조각만 떨어져 나가는 부분 파괴 도식" loading="lazy" width="800" height="480" src="/_astro/partial-destruction.Bx8iJxDI_Z1gOoAz.svg" /><figcaption>맞은 지점을 중심으로 구 트레이스 범위 안의 조각에만 거리에 따라 줄어드는 피해가 들어가고, 체력이 0이 된 조각만 떨어져 나가는 부분 파괴 도식</figcaption></figure><p></p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UFragmentDestructionComponent</span><span>::</span></span><span>DestructPartially</span><span>(</span><span>const</span><span> </span><span>FVector</span><span>&amp;</span><span><span> </span><span>HitPoint</span><span>, </span></span><span>const</span><span> </span><span>FVector</span><span>&amp;</span><span><span> </span><span>HitNormal</span><span>, </span></span><span>float</span><span><span> </span><span>Damage</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>  </span><span>TArray</span><span>&lt;</span><span>FHitResult</span><span>&gt; </span><span>Hits</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>SweepFragments</span><span>(</span><span>HitPoint</span><span>, </span><span>HitNormal</span><span>, </span><span>PartialRadius</span><span>, </span><span>Hits</span><span>);</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> </span><span>FHitResult</span><span>&amp;</span><span> </span><span>Hit</span><span> : </span><span>Hits</span><span>)</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>8</div></div><div><span>    </span><span>auto*</span><span> </span><span>Fragment</span><span> </span><span>=</span><span> </span><span>Cast</span><span>&lt;</span><span>UFragmentMeshComponent</span><span>&gt;(</span><span>Hit</span><span>.</span><span>GetComponent</span><span>());</span></div></div><div><div><div>9</div></div><div><span>    </span><span>if</span><span> (</span><span>Fragment</span><span> </span><span>==</span><span> </span><span>nullptr</span><span> </span><span>||</span><span> </span><span>Hit</span><span>.</span><span>GetActor</span><span>() </span><span>!=</span><span> </span><span>GetOwner</span><span>())</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>// 맞은 지점에서 멀수록 피해가 선형으로 줄어든다.</span></div></div><div><div><div>15</div></div><div><span>    </span><span>const</span><span> </span><span>float</span><span> </span><span>Falloff</span><span> </span><span>=</span><span> </span><span><span>1.</span><span>f</span></span><span> </span><span>-</span><span> FVector::</span><span>Dist</span><span>(</span><span>Hit</span><span>.</span><span>ImpactPoint</span><span>, </span><span>HitPoint</span><span>) </span><span>/</span><span> </span><span>PartialRadius</span><span>;</span></div></div><div><div><div>16</div></div><div><span>    </span><span>const</span><span> </span><span>float</span><span> </span><span>FragmentDamage</span><span> </span><span>=</span><span> </span><span>Damage</span><span> </span><span>*</span><span> </span><span>Falloff</span><span>;</span></div></div><div><div><div>17</div></div><div><span>    </span><span>Fragment</span><span>-&gt;</span><span>ApplyDamage</span><span>(</span><span>FragmentDamage</span><span>);</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>// 체력이 0이 된 조각만 떨어져 나간다.</span></div></div><div><div><div>20</div></div><div><span>    </span><span>if</span><span> (</span><span>Fragment</span><span>-&gt;</span><span>CanDetach</span><span>())</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>22</div></div><div><span>      </span><span>Fragment</span><span>-&gt;</span><span>SetMobility</span><span>(EComponentMobility::</span><span>Movable</span><span>);</span></div></div><div><div><div>23</div></div><div><span>      </span><span>Fragment</span><span>-&gt;</span><span>SetSimulatePhysics</span><span>(</span><span>true</span><span>);</span></div></div><div><div><div>24</div></div><div>
</div></div><div><div><div>25</div></div><div><span>      </span><span>const</span><span> </span><span>float</span><span> </span><span>Strength</span><span> </span><span>=</span><span> FMath::</span><span>Min</span><span>(</span><span>FragmentDamage</span><span> </span><span>*</span><span> </span><span>DamageToImpulse</span><span>, </span><span>MaxPartialImpulse</span><span>);</span></div></div><div><div><div>26</div></div><div><span>      </span><span>Fragment</span><span>-&gt;</span><span>AddRadialImpulse</span><span>(</span><span>ImpulseOrigin</span><span>, </span><span>ImpulseRadius</span><span>, </span><span>Strength</span><span>, </span><span>RIF_Constant</span><span>);</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>28</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>29</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>CanDetach()</code>는 피해를 무시하는 조각, 완전 파괴 때만 움직이는 조각, 체력이 남은 조각을 걸러 낸다. 충격의 세기는 피해량에 비례하되 상한을 둔다. 조각 컴포넌트는 <code>AddImpulse()</code>도 재정의해서, 속도가 기준(기본 초속 26m) 이상인 조각에는 그 경로로 들어오는 충격을 더하지 않는다.</p></section>
<section><h2>연출 전용 파편<a href="#연출-전용-파편"><span>#</span></a></h2><p>플레이어가 지은 건물과 차량, 부서지는 아이템은 같은 조각 컴포넌트를 연출 전용 경로로 쓴다. 이 경로는 클라이언트에서만 동작한다.</p><ul>
<li>건물이 무너지거나 아이템이 부서졌다는 이벤트를 받거나 차량이 파괴되면, 클라이언트가 로컬에서 복제되지 않는 파편 액터를 스폰한다.</li>
<li>파편 액터가 조각 컴포넌트를 만들고 바로 완전 파괴를 실행한다.</li>
<li>Dedicated Server와 렌더링 디테일이 낮음인 클라이언트에서는 파편을 만들지 않는다.</li>
<li>조각 메시를 미리 비동기 로드해 두고, 파괴 시점까지 로드가 끝나지 않았으면 설정에 따라 동기 로드하거나 연출을 건너뛴다.</li>
<li>건물과 문은 HP가 70% 이하로 떨어지면 서버가 클라이언트들에 조각 메시 로드를 알려, 무너지기 전에 로드를 시작한다.</li>
</ul><p>파편이 남는 시간은 게임 상황을 보고 정한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>float</span><span><span> </span><span>ACosmeticDebrisActor</span><span>::</span></span><span>GetLifeSpanFor</span><span>(</span><span>const</span><span> </span><span>FOptimizeStatus</span><span>&amp;</span><span><span> </span><span>Status</span><span>, </span></span><span>float</span><span><span> </span><span>DefaultLifeSpan</span><span>) </span></span><span>const</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>// 프레임이 크게 떨어졌거나 웨이브 규모가 크면 파편을 만들지 않는다.</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span> (</span><span>Status</span><span>.</span><span>FPS</span><span> </span><span>&lt;=</span><span> </span><span>15</span><span> </span><span>||</span><span> </span><span>IsLargeWave</span><span>(</span><span>Status</span><span>))</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>return</span><span> </span><span><span>0.</span><span>f</span></span><span>;</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>return</span><span> </span><span>DefaultLifeSpan</span><span>;</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>결과가 0이면 조각을 만들지 않고 파편 액터를 바로 제거한다. 그렇지 않으면 기본 10초의 수명을 두고 조각을 만든다.</p></section>
<section><h2>재현 환경에서 측정하기<a href="#재현-환경에서-측정하기"><span>#</span></a></h2><p>교체할 때는 시각 효과와 성능이 APEX와 거의 같을 거라고 보고 안정성을 기준으로 판단했다. 이 판단을 수치로 확인하려고 당시 환경을 별도 실험 프로젝트에 재현했다. UE 4.27(PhysX)에는 커스텀 방식과 교체 전 구성인 교체형 APEX를, 이전 후 엔진인 UE 5.3(Chaos)에는 커스텀 방식과 Chaos Geometry Collection을 같은 조각과 같은 충격 조건으로 구현했다. 회사의 코드와 에셋은 쓸 수 없어서 조각 생성기와 각 방식을 새로 만들고, 재질은 엔진의 Starter Content만 썼다. 크래시 빈도는 재현할 수 없어 측정 대상에서 뺐다.</p><section><h3>비교 조건<a href="#비교-조건"><span>#</span></a></h3><p>NOTD가 커스텀 시스템 전에 쓴 APEX는 처음부터 Destructible 컴포넌트를 배치하는 방식이 아니었다. 평소에는 통짜 StaticMesh를 두고, 파괴할 때 Destructible 컴포넌트를 만들어 교체했다. 그래서 이 교체형을 주 비교 대상으로 두고, 처음부터 전용 컴포넌트를 들고 있는 상주형은 참고값으로만 쟀다.</p>

<table><thead><tr><th>엔진</th><th>방식</th><th>구성</th></tr></thead><tbody><tr><td>UE 4.27.2 (PhysX)</td><td>Custom</td><td>통짜 StaticMesh, 파괴 시 조각 <code>UStaticMeshComponent</code> 생성</td></tr><tr><td>UE 4.27.2 (PhysX)</td><td>ApexSwap</td><td>통짜 StaticMesh, 파괴 시 <code>UDestructibleComponent</code> 생성 (NOTD의 커스텀 도입 전 구성)</td></tr><tr><td>UE 5.3.2 (Chaos)</td><td>Custom</td><td>위와 같은 코드</td></tr><tr><td>UE 5.3.2 (Chaos)</td><td>ChaosSwap</td><td>통짜 StaticMesh, 파괴 시 <code>UGeometryCollectionComponent</code> 생성</td></tr><tr><td>두 엔진</td><td>Apex, Chaos (상주형)</td><td>처음부터 전용 컴포넌트. 참고값</td></tr></tbody></table><ul>
<li>조각은 모든 방식이 같다. 100×100×200cm 기둥을 보로노이 셀 24개로 나눴고, 겉면과 단면 재질 슬롯을 따로 뒀다.</li>
<li>NOTD처럼 조각 메시와 Destructible 에셋은 미리 로드한 상태에서 쟀다. 파괴 순간의 비용에 에셋 로딩은 들어가지 않는다.</li>
<li>충격은 모든 방식에 속도 변화 600cm/s, 반경 300cm, 선형 감소로 줬다. NOTD 원본은 <code>bVelChange=false</code>였지만, 5.3의 <code>UGeometryCollectionComponent::AddRadialImpulse</code>는 이 값에서 강체에 효과가 없어서 속도 변화로 맞췄다.</li>
<li>에디터 빌드를 쿠킹 없이 <code>-game -benchmark -fps=60</code>으로 실행했다. 1280×720 창, VSync 끔. 조합마다 3회 실행해 중앙값을 썼다.</li>
<li>측정 PC는 Ryzen 9 5950X, RTX 4090, 메모리 64GB다.</li>
<li>렌더러와 엔진 버전이 달라서 ms는 같은 엔진 안에서만 비교한다.</li>
</ul></section><section><h3>겉모습<a href="#겉모습"><span>#</span></a></h3><p></p><figure><img alt="UE 4.27에서 같은 벽과 기둥, 상자를 커스텀 방식과 교체형 APEX로 부순 장면" loading="lazy" width="1120" height="316" src="/_astro/ue4-custom-vs-apexswap.BJiCrvjh_2lvPIm.webp" /><figcaption>UE 4.27에서 같은 벽과 기둥, 상자를 커스텀 방식과 교체형 APEX로 부순 장면</figcaption></figure><p></p><p>같은 엔진에서 커스텀 방식(왼쪽)과 교체형 APEX(오른쪽)로 같은 장면을 부쉈다. 조각이 같아서 깨지는 모양과 흩어지는 범위는 거의 같다. APEX는 피해를 준 다음 프레임에도 청크가 아직 Dynamic이 아니라서 충격을 2프레임 뒤에 줬고, 그만큼 조각 위치는 조금씩 다르다.</p><p></p><figure><img alt="UE 5.3의 커스텀 방식과 Chaos Geometry Collection, UE 4.27의 커스텀 방식과 APEX로 같은 장면을 부순 2×2 비교" loading="lazy" width="1600" height="900" src="/_astro/four-methods-2x2.C_N1DKLn_JcCkg.webp" /><figcaption>UE 5.3의 커스텀 방식과 Chaos Geometry Collection, UE 4.27의 커스텀 방식과 APEX로 같은 장면을 부순 2×2 비교</figcaption></figure><p></p><p>UE 5.3에서도 커스텀 방식과 Chaos Geometry Collection의 깨지는 모양은 비슷했다.</p></section><section><h3>부수기 전: 대기 비용<a href="#부수기-전-대기-비용"><span>#</span></a></h3>

<table><thead><tr><th>엔진</th><th>오브젝트 수</th><th>Custom</th><th>상주형</th></tr></thead><tbody><tr><td>UE 4.27</td><td>1,000</td><td>1.46ms, 32MB</td><td>Apex 8.34ms, 129MB</td></tr><tr><td>UE 4.27</td><td>2,000</td><td>1.57ms, 52MB</td><td>Apex 17.01ms, 242MB</td></tr><tr><td>UE 5.3</td><td>1,000</td><td>2.87ms, 11MB</td><td>Chaos 6.26ms, 76MB</td></tr><tr><td>UE 5.3</td><td>2,000</td><td>3.22ms, 14MB</td><td>Chaos 11.43ms, 144MB</td></tr></tbody></table><p>부서지지 않은 오브젝트를 세워 두기만 했을 때의 프레임 시간과 스폰 메모리다. 2,000개에서 커스텀은 상주형보다 프레임 시간이 UE4에서 1/10.8, UE5에서 1/3.6이었고 메모리도 크게 적었다. 교체형은 대기 상태가 커스텀과 같은 통짜 StaticMesh라서 따로 재지 않았다. NOTD처럼 파괴 오브젝트를 월드에 많이 두는 게임에서는 부서지기 전까지 일반 StaticMesh로 두는 쪽이 대기 비용에서 유리하다.</p></section><section><h3>하나씩 부술 때<a href="#하나씩-부술-때"><span>#</span></a></h3><p>오브젝트 하나를 부순 프레임의 스파이크는 UE4에서 Custom 2.67ms, ApexSwap 3.68ms로 커스텀이 27% 낮았다. UE5에서는 Custom 4.50ms, ChaosSwap 4.46ms로 같은 수준이었다.</p><p>실제 플레이에 가까운 경우로 50개를 0.2초마다 하나씩 부쉈다. 첫 파괴부터 마지막 조각이 사라질 때까지의 프레임 시간이다.</p>

<table><thead><tr><th>엔진</th><th>방식</th><th>평균</th><th>p95</th></tr></thead><tbody><tr><td>UE 4.27</td><td>Custom</td><td>3.13ms</td><td>5.24ms</td></tr><tr><td>UE 4.27</td><td>ApexSwap</td><td>1.81ms</td><td>2.59ms</td></tr><tr><td>UE 5.3</td><td>Custom</td><td>6.40ms</td><td>11.51ms</td></tr><tr><td>UE 5.3</td><td>ChaosSwap</td><td>3.90ms</td><td>6.40ms</td></tr></tbody></table><p>평균은 커스텀이 1.6~1.7배 높지만, p95가 UE4 5.24ms, UE5 11.51ms로 두 엔진 모두 60fps 예산(16.7ms) 안이다. UE4 일부 실행에는 방식과 관계없이 GPU 시간 33ms 프레임이 몇 개 섞여 있었다. 게임 스레드는 1ms 미만이었으므로 측정 PC 환경 탓으로 보고, 최댓값은 결론에 쓰지 않았다.</p></section><section><h3>부하 시험: 한꺼번에 부술 때<a href="#부하-시험-한꺼번에-부술-때"><span>#</span></a></h3><p>웨이브 중 폭발처럼 여러 오브젝트가 한 프레임에 부서지는 경우를 보려고 동시에 부수는 수를 늘렸다. N개를 한 프레임에 부쉈을 때 0.5초 안의 가장 긴 프레임이다.</p>

<table><thead><tr><th>N</th><th>UE4 Custom</th><th>UE4 ApexSwap</th><th>UE5 Custom</th><th>UE5 ChaosSwap</th></tr></thead><tbody><tr><td>1</td><td>2.67ms</td><td>3.68ms</td><td>4.50ms</td><td>4.46ms</td></tr><tr><td>10</td><td>13.69ms</td><td>5.79ms</td><td>22.50ms</td><td>7.30ms</td></tr><tr><td>50</td><td>67.82ms</td><td>15.75ms</td><td>106.34ms</td><td>24.46ms</td></tr><tr><td>100</td><td>138.12ms</td><td>31.83ms</td><td>191.23ms</td><td>43.67ms</td></tr></tbody></table><p>동시에 부수는 수가 늘수록 커스텀의 스파이크가 교체형보다 빠르게 커졌다. 100개에서는 UE4 138ms, UE5 191ms였고(교체형 32ms, 44ms), 조각을 64개로 늘린 세트에서는 차이가 더 벌어졌다. 이 구간이 개선할 지점이었다.</p><p>100개 동시 파괴를 Unreal Insights로 따로 기록해 파괴 프레임을 나눠 봤다. 트레이스 실행은 위 표의 수치와 별도다.</p>

<table><thead><tr><th>엔진</th><th>방식</th><th>파괴 처리</th><th>렌더 씬 추가 (<code>FScene_AddPrimitive</code>)</th></tr></thead><tbody><tr><td>UE 4.27</td><td>Custom</td><td>107.9ms</td><td>2,400회, 23.4ms</td></tr><tr><td>UE 4.27</td><td>ApexSwap</td><td>18.3ms</td><td>100회, 0.8ms</td></tr><tr><td>UE 5.3</td><td>Custom</td><td>142.7ms</td><td>2,400회, 33.7ms</td></tr><tr><td>UE 5.3</td><td>ChaosSwap</td><td>12.2ms</td><td>100회, 0.3ms</td></tr></tbody></table><p></p><figure><img alt="UE 4.27 100개 동시 파괴의 GameThread 타임라인: 커스텀 방식과 교체형 APEX" loading="lazy" width="1600" height="490" src="/_astro/insights-ue4-destruct-timeline.BHEcEAt5_Gq30y.webp" /><figcaption>UE 4.27 100개 동시 파괴의 GameThread 타임라인: 커스텀 방식과 교체형 APEX</figcaption></figure><p></p><p>UE4 트레이스의 GameThread를 같은 시간 폭으로 잘라 위에 커스텀 방식, 아래에 교체형 APEX를 놓았다. ① 커스텀의 <code>Bench_DestructAll</code>은 107.9ms, ② 교체형은 18.3ms다. 그 뒤로는 커스텀의 바로 다음 프레임이 49.1ms, 교체형에서 충격을 주는 2프레임 뒤의 프레임이 34.7ms다.</p><p></p><figure><img alt="UE 4.27 100개 동시 파괴의 Callees 트리: 커스텀 방식과 교체형 APEX" loading="lazy" width="882" height="362" src="/_astro/insights-ue4-destruct-callees.EYTzHESL_JQ3UG.webp" /><figcaption>UE 4.27 100개 동시 파괴의 Callees 트리: 커스텀 방식과 교체형 APEX</figcaption></figure><p></p><p>같은 구간에서 <code>Bench_DestructAll</code> 아래의 호출이다. ① 커스텀은 <code>Bench_CustomDestruct</code>가 100회 불렸고 자체 시간이 84.2ms다. ② 그 안의 <code>FScene_AddPrimitive</code>는 2,400회, 23.4ms다. ③ 교체형의 <code>FScene_AddPrimitive</code>는 100회, 836.7µs다.</p><p>교체형은 오브젝트 하나에 컴포넌트 하나를 만들고 조각은 그 안에서 처리한다. 커스텀은 조각마다 <code>UStaticMeshComponent</code>를 만들어 등록하므로 100개 × 24조각 = 2,400개의 컴포넌트가 한 프레임에 생긴다. UE5에서는 물리 씬에 바디를 넣는 <code>FChaosScene::AddActorsToScene_AssumesLocked</code>도 2,400회(7.9ms) 불렸다. UE4 트레이스에는 ①의 자체 시간을 나눈 스코프가 없어 그 내역은 확인하지 못했다.</p><p>NOTD는 이런 상황에 대비해 연출 전용 파편에 프레임이 15fps 이하로 떨어지거나 웨이브가 크면 조각을 만들지 않는 조건을 두었다. 측정으로 비용이 어디서 나오는지 확인했으니, 조각을 생략하지 않고 비용을 여러 프레임에 나누는 방법을 시험했다.</p></section></section>
<section><h2>프레임당 조각 예산 두기<a href="#프레임당-조각-예산-두기"><span>#</span></a></h2><p>스파이크의 원인이 한 프레임에 몰리는 컴포넌트 생성이라면, 생성을 여러 프레임에 나누면 된다. 개선안에서는 한 프레임에 만들 수 있는 조각 컴포넌트 수에 예산을 두고(24조각 오브젝트 10개분인 240개), 예산을 넘는 파괴 요청은 줄을 세워 다음 프레임에 처리했다. 오브젝트 하나는 언제나 한 프레임에 통째로 부서지고, 차례를 기다리는 동안에는 통짜 메시가 그대로 보인다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>bool</span><span><span> </span><span>UFragmentBudgetSubsystem</span><span>::</span></span><span>TrySpend</span><span>(</span><span>int32</span><span><span> </span><span>Cost</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>// 조각이 예산보다 많은 오브젝트도 부서져야 하므로, 이번 프레임에 아직 만든 조각이 없으면 허용한다.</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span> (</span><span>SpentThisFrame</span><span> </span><span>==</span><span> </span><span>0</span><span> </span><span>||</span><span> </span><span>SpentThisFrame</span><span> </span><span>+</span><span> </span><span>Cost</span><span> </span><span>&lt;=</span><span> </span><span>BudgetPerFrame</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>SpentThisFrame</span><span> </span><span>+=</span><span> </span><span>Cost</span><span>;</span></div></div><div><div><div>7</div></div><div><span>    </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>9</div></div><div><span>  </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>void</span><span><span> </span><span>UFragmentBudgetSubsystem</span><span>::</span></span><span>RequestDestruct</span><span>(</span><span>AFragmentDestructible</span><span>*</span><span><span> </span><span>Object</span><span>, </span></span><span>const</span><span> </span><span>FVector</span><span>&amp;</span><span><span> </span><span>ImpactPoint</span><span>)</span></span></div></div><div><div><div>13</div></div><div><span>{</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>// 앞선 요청이 기다리는 중이면 순서를 지키려고 뒤에 선다.</span></div></div><div><div><div>15</div></div><div><span>  </span><span>if</span><span> (</span><span>Pending</span><span>.</span><span>Num</span><span>() </span><span>==</span><span> </span><span>0</span><span> </span><span>&amp;&amp;</span><span> </span><span>TrySpend</span><span>(</span><span>Object</span><span>-&gt;</span><span>GetFragmentCount</span><span>()))</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>17</div></div><div><span>    </span><span>Object</span><span>-&gt;</span><span>BreakNow</span><span>(</span><span>ImpactPoint</span><span>);</span></div></div><div><div><div>18</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>20</div></div><div><span>  </span><span>Pending</span><span>.</span><span>Add</span><span>({ </span><span>Object</span><span>, </span><span>ImpactPoint</span><span> });</span></div></div><div><div><div>21</div></div><div><span>}</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>void</span><span><span> </span><span>UFragmentBudgetSubsystem</span><span>::</span></span><span>Tick</span><span>(</span><span>float</span><span><span> </span><span>DeltaTime</span><span>)</span></span></div></div><div><div><div>24</div></div><div><span>{</span></div></div><div><div><div>25</div></div><div><span>  </span><span>int32</span><span> </span><span>Processed</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>26</div></div><div><span>  </span><span>for</span><span> (; </span><span>Processed</span><span> </span><span>&lt;</span><span> </span><span>Pending</span><span>.</span><span>Num</span><span>(); </span><span>++</span><span>Processed</span><span>)</span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>28</div></div><div><span>    </span><span>AFragmentDestructible</span><span>*</span><span> </span><span>Object</span><span> </span><span>=</span><span> </span><span>Pending</span><span>[</span><span>Processed</span><span>].</span><span>Object</span><span>.</span><span>Get</span><span>();</span></div></div><div><div><div>29</div></div><div><span>    </span><span>if</span><span> (</span><span>Object</span><span> </span><span>==</span><span> </span><span>nullptr</span><span>)</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>31</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>33</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>TrySpend</span><span>(</span><span>Object</span><span>-&gt;</span><span>GetFragmentCount</span><span>()))</span></div></div><div><div><div>34</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>35</div></div><div><span>      </span><span>break</span><span>;</span></div></div><div><div><div>36</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>37</div></div><div><span>    </span><span>Object</span><span>-&gt;</span><span>BreakNow</span><span>(</span><span>Pending</span><span>[</span><span>Processed</span><span>].</span><span>ImpactPoint</span><span>);</span></div></div><div><div><div>38</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>39</div></div><div><span>  </span><span>Pending</span><span>.</span><span>RemoveAt</span><span>(</span><span>0</span><span>, </span><span>Processed</span><span>);</span></div></div><div><div><div>40</div></div><div><span>  </span><span>SpentThisFrame</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>41</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div>

<table><thead><tr><th>엔진</th><th>N</th><th>Custom</th><th>예산 적용</th><th>교체형</th><th>다 부서지기까지</th></tr></thead><tbody><tr><td>UE 4.27</td><td>50</td><td>67.82ms</td><td>17.90ms</td><td>15.75ms</td><td>5프레임</td></tr><tr><td>UE 4.27</td><td>100</td><td>138.12ms</td><td>25.22ms</td><td>31.83ms</td><td>10프레임</td></tr><tr><td>UE 5.3</td><td>50</td><td>106.34ms</td><td>28.60ms</td><td>24.46ms</td><td>5프레임</td></tr><tr><td>UE 5.3</td><td>100</td><td>191.23ms</td><td>37.31ms</td><td>43.67ms</td><td>10프레임</td></tr></tbody></table><p>100개 동시 파괴의 스파이크가 UE4에서 138ms에서 25ms로(82% 감소), UE5에서 191ms에서 37ms로(80% 감소) 내려가 교체형보다 낮아졌다. 조각 64개 세트의 50개 동시 파괴도 UE4에서 192.78ms에서 28.11ms가 됐다. 100개가 다 부서지기까지는 10프레임이 걸리고(이 측정의 실제 경과 시간은 UE4 약 0.19초, UE5 약 0.28초), 그동안 차례를 기다리는 오브젝트는 통짜 메시로 서 있다. 10개 이하는 예산 안이라 모두 같은 프레임에 부서진다.</p></section>
<section><h2>남은 비용과 측정 범위<a href="#남은-비용과-측정-범위"><span>#</span></a></h2><p>예산은 파괴 순간의 비용을 나눈다. 조각이 움직이는 동안의 비용은 조각마다 컴포넌트와 강체를 갱신하는 데서 나오는 것으로 보이며, 100개 동시 파괴에서 UE4 평균 11.29ms로 교체형(2.66ms)보다 높다. 더 줄이려면 멈춘 조각을 Instanced Static Mesh로 옮기는 식으로 조각별 컴포넌트 수 자체를 줄여야 한다.</p><p>측정 범위는 다음과 같다.</p><ul>
<li>네트워크, 오목 메시, Nanite, 비동기 물리는 다루지 않았다.</li>
<li>교체형은 같은 액터에 컴포넌트를 만들었다. NOTD는 별도 액터를 스폰했을 수 있다.</li>
<li>NOTD의 APEX 에셋이 남아 있지 않아 원래 Destructible Mesh 설정값은 알 수 없다.</li>
<li>측정 PC 한 대의 결과다.</li>
</ul></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/destruction-overview" target="_blank">Destruction Overview in Unreal Engine</a></li>
<li><a href="https://kidswithsticks.com/creating-a-destructible-object-in-unreal-engine-4-blender/" target="_blank">Creating a Destructible Object in Unreal Engine 4 &amp; Blender - Kids With Sticks</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/rider-large-file-code-analysis-limit/</id>
      <title type="text">Rider 대용량 파일 코드 분석 제한 조정하기</title>
      <published>2023-04-10T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/rider-large-file-code-analysis-limit/"/>
      <summary type="text">JetBrains Rider가 큰 소스 파일의 code insight를 끌 때 파일별 분석 수준과 전역 크기 제한을 구분해 조정하는 방법을 설명합니다.</summary>
      <content type="html"><![CDATA[<p>Rider에서 큰 소스 파일을 열면 syntax highlight만 일부 동작한다. code inspection, completion 또는 error analysis는 비활성화되기도 한다.</p>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>editor 상단에는 파일이 너무 커서 code insight를 제공할 수 없다는 알림이 나타난다. 작은 파일에서는 보이는 오류가 큰 파일에서 표시되지 않기도 한다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>Rider의 IntelliJ platform editor limit 또는 ReSharper backend analysis limit에 걸린 파일에 해당한다. 두 제한은 서로 다른 설정이며 제품 버전별 기본값도 다르다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>IDE는 parsing, index와 inspection 비용이 큰 대용량 파일 분석을 제한해 성능을 보호한다. 과거 기록에서는 이를 하나의 “기본 300KB”로 설명하기도 했다. 현재 JetBrains 안내는 platform의 <code>idea.max.intellisense.filesize</code> 기본값이 약 2.5MB이고 C#의 ReSharper analysis에는 별도의 300,000-byte threshold가 있을 수 있다고 구분한다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>알림 문구를 보고 file size limit인지 inspection severity 문제인지 구분한다.</li>
<li>상태 표시줄에서 Highlighting level이 <code>None</code>, <code>Syntax</code>, <code>All Problems</code> 중 무엇인지 살펴본다.</li>
<li>현재 제한 설정은 <code>Help | Diagnostic Tools | Special Files and Folders</code> 또는 custom properties에서 확인한다.</li>
<li>파일 byte 수와 generated/minified file 여부도 함께 점검해 둔다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>한 파일만 일시적으로 분석할 때는 editor 오른쪽 위의 highlighting widget에서 <code>All Problems</code>를 선택한다. 전역 제한은 JetBrains가 안내하는 custom properties에서 바꾸되 현재 제품 버전에 맞는 key와 byte 단위를 사용한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>idea.max.intellisense.filesize</span><span><span>=</span><span>5000</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 값은 예시일 뿐이다. ReSharper backend 제한에 걸렸다면 공식 지원 문서가 안내하는 별도 property를 사용해야 한다. 장기적으로는 generated file을 분석 대상에서 제외하거나 거대한 hand-written source를 책임 단위로 분리한다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>IDE를 재시작한 뒤 문제 파일에서 completion과 inspection이 복구되는지 확인한다.</li>
<li>CPU, memory와 indexing 시간도 함께 살펴본다.</li>
<li>작은 파일의 분석 성능이 악화되지 않았는지 점검해 둔다.</li>
<li>command-line build 결과와 Rider 표시도 서로 비교한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>제한을 크게 올리면 editor가 멈추거나 전체 solution 분석이 느려지기도 한다. IDE 분석은 compiler를 대체하지 않는다. 큰 파일에서 highlighting을 켰더라도 CI build와 static analysis를 유지한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://rider-support.jetbrains.com/hc/en-us/articles/360010996600-Rider-Cannot-Analyze-a-Source-File-Because-It-s-Too-Large" target="_blank">JetBrains Support: Rider Cannot Analyze a Source File Because It Is Too Large</a></li>
<li><a href="https://youtrack.jetbrains.com/articles/SUPPORT-A-4391/How-to-fix-The-file-size-exceeds-the-configured-limit-2.56-MB.-Code-insight-features-are-not-available" target="_blank">JetBrains Support: The file size exceeds the configured limit</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/teamcity-buildagent-environment-variable-precedence/</id>
      <title type="text">TeamCity 환경 변수와 buildAgent.properties 우선순위</title>
      <published>2023-03-31T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/teamcity-buildagent-environment-variable-precedence/"/>
      <summary type="text">TeamCity 프로젝트에 같은 이름의 환경 변수가 있을 때 agent의 buildAgent.properties 값이 무시되는 이유와 안전한 설정 분리 방법을 설명합니다.</summary>
      <content type="html"><![CDATA[<p>TeamCity agent마다 다른 값을 넣으려고 <code>buildAgent.properties</code>를 수정했다. 그런데 빌드에서는 모든 agent가 같은 값을 받았다.</p>
<section><h2>문제 현상<a href="#문제-현상"><span>#</span></a></h2><p>agent 설정에는 machine별 값이 있었다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>env.TOOLCHAIN_ROOT</span><span><span>=</span><span>/opt/toolchains/agent-specific</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>build step에서 <code>%env.TOOLCHAIN_ROOT%</code> 값을 출력했다. 결과는 TeamCity Web UI의 프로젝트 수준에 등록된 값으로 고정돼 있었다.</p></section>
<section><h2>적용 범위<a href="#적용-범위"><span>#</span></a></h2><p>agent configuration parameter와 project/build configuration parameter를 함께 쓰는 TeamCity 구성에 해당한다. <code>env.</code> parameter를 섞어 쓸 때도 같은 우선순위를 확인해야 한다.</p></section>
<section><h2>원인<a href="#원인"><span>#</span></a></h2><p>같은 이름의 parameter가 여러 수준에 정의돼 있었다. TeamCity는 빌드 구성에 더 가까운 project/build parameter를 agent의 값보다 우선 적용했다. <code>buildAgent.properties</code>를 읽었지만 higher-priority definition이 그 값을 가렸다.</p></section>
<section><h2>재현 및 진단<a href="#재현-및-진단"><span>#</span></a></h2><ol>
<li>실패한 build의 Parameters 탭에서 최종 resolved value와 source를 확인한다.</li>
<li>build configuration, template, project와 parent project에서 같은 이름을 찾아본다.</li>
<li>agent의 <code>buildAgent.properties</code>에서 <code>env.</code>와 <code>system.</code> 접두사가 올바른지 살핀다.</li>
<li>agent 재시작 또는 configuration reload 뒤 agent Parameters 화면에 값이 보이는지 점검한다.</li>
</ol></section>
<section><h2>해결 방법<a href="#해결-방법"><span>#</span></a></h2><p>agent마다 달라야 하는 값은 project에 정의된 같은 이름의 parameter를 지운 뒤 agent 값을 쓴다. 프로젝트에서 논리적 이름을 유지하려면 agent마다 고유한 configuration parameter를 두고 build parameter가 이를 참조하도록 매핑한다.</p><div><figure><figcaption><span>buildAgent.properties</span></figcaption><pre><code><div><div><div>1</div></div><div><span>agent.toolchain.root</span><span><span>=</span><span>/opt/toolchains/agent-specific</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># TeamCity build parameter</span></div></div><div><div><div>2</div></div><div><span>env.TOOLCHAIN_ROOT = %agent.toolchain.root%</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>agent requirement도 함께 설정한다. 필요한 capability나 값이 없는 agent에는 build가 배정되지 않도록 한다.</p></section>
<section><h2>검증 방법<a href="#검증-방법"><span>#</span></a></h2><ul>
<li>서로 다른 두 agent에서 parameter source와 최종 환경 변수를 나란히 비교한다.</li>
<li>build log에는 secret이 아닌 값만 골라 출력한다.</li>
<li>template을 상속하는 build configuration도 동일하게 해석되는지 살핀다.</li>
<li>agent requirement가 값이 없는 agent를 실제로 제외하는지 별도로 시험한다.</li>
</ul></section>
<section><h2>주의점<a href="#주의점"><span>#</span></a></h2><p>TeamCity의 password parameter나 token은 일반 환경 변수로 출력해서 검증하지 않는다. 운영체제 환경 변수, Java system property와 TeamCity configuration parameter는 이름이 비슷해도 주입 경로가 다르다. 접두사와 최종 build parameter 화면을 기준으로 구분한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://www.jetbrains.com/help/teamcity/configure-agent-installation.html" target="_blank">JetBrains: Configure Agent Installation</a></li>
<li><a href="https://www.jetbrains.com/help/teamcity/configuring-build-parameters.html" target="_blank">JetBrains: Configuring Build Parameters</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/restore-solution-configuration-in-rider-new-ui/</id>
      <title type="text">Rider 2022.3 New UI에서 Solution Configuration 선택기가 보이지 않을 때</title>
      <published>2023-02-11T00:00:00.000Z</published>
      <updated>2023-02-11T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/restore-solution-configuration-in-rider-new-ui/"/>
      <summary type="text">Rider 2022.3 New UI에서 사라진 Solution Configuration 선택기를 ActiveConfiguration action으로 복원하는 방법과 Unreal 빌드 구성의 의미를 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Rider 2022.3의 <strong>New UI Preview</strong>를 켜면 VS Code처럼 가벼운 텍스트 에디터에 가까운 인상을 준다. 처음에는 다소 가벼워 보였지만, 사용하다 보니 UI가 아이콘 위주로 큼직하게 배치돼 필요한 기능이 더 눈에 띄는 장점도 있었다.</p>
<p>문제는 New UI에서 <code>Development Editor | Win64</code>, <code>DebugGame Editor | Win64</code> 같은 <strong>Solution Configuration 선택기</strong>가 보이지 않는 것이었다. <code>Edit Configurations</code> 창에서 설정을 바꿔도 원하는 solution configuration은 바뀌지 않았다. 개발할 target과 state를 선택할 수 없으므로 New UI를 계속 쓰려면 선택기를 복구해야 했다.</p>
<p>이 현상은 <a href="https://youtrack.jetbrains.com/issue/RIDER-83004/No-Edit-Solution-Configuration-and-Build-button-in-new-UI" target="_blank">RIDER-83004</a>로 등록된 Rider 2022.3 New UI의 문제였다.</p>
<section><h2>Run/Debug Configuration과 Solution Configuration은 다르다<a href="#rundebug-configuration과-solution-configuration은-다르다"><span>#</span></a></h2><p>New UI의 Run widget에서 여는 <strong>Edit Configurations</strong>와 solution build configuration 선택기는 책임이 다르다.</p>

<table><thead><tr><th>구분</th><th>결정하는 것</th><th>Unreal 예시</th></tr></thead><tbody><tr><td>Solution build configuration</td><td>어떤 state, target, platform 조합으로 compile할지</td><td><code>Development Editor | Win64</code>, <code>DebugGame Editor | Win64</code></td></tr><tr><td>Run/Debug configuration</td><td>어떤 executable을 어떤 인수·환경으로 실행하고, 실행 전에 어떤 task를 수행할지</td><td>Unreal Editor 실행, standalone game, native executable, attach</td></tr></tbody></table><p>Run/Debug Configuration의 <strong>Before launch</strong>에 build task를 넣을 수는 있다. 그렇다고 Run widget의 설정을 바꾸면 active solution build configuration도 자동으로 같은 값이 되는 것은 아니다.</p></section>
<section><h2>Rider 2022.3 New UI에서 선택기 복원하기<a href="#rider-20223-new-ui에서-선택기-복원하기"><span>#</span></a></h2><ol>
<li>오른쪽 위 main toolbar를 우클릭하고 **Customize Toolbar…**를 연다.</li>
<li><code>ExecutionTargetsToolbarGroup</code>을 찾는다.</li>
<li><strong>Add Action</strong>에서 Main Menu → Build의 <code>ActiveConfiguration</code> action을 추가한다.</li>
<li>변경을 적용하고 toolbar를 확인한다.</li>
</ol><p></p><figure><img alt="Rider 2022.3 New UI의 Customize Main Toolbar에서 ActiveConfiguration action을 추가하는 화면" loading="lazy" width="1731" height="924" src="/_astro/rider-2022-3-add-active-configuration-action.DFE-ahNb_Exoj0.webp" /><figcaption>Rider 2022.3 New UI의 Customize Main Toolbar에서 ActiveConfiguration action을 추가하는 화면</figcaption></figure><p></p><p><em><code>ExecutionTargetsToolbarGroup</code> 아래에 <code>ActiveConfiguration</code> action을 추가한다.</em></p><p>일부 2022.3 build에서는 build icon dropdown의 <strong>Show Configuration on Toolbar</strong>도 함께 켜야 했다. 적용하면 toolbar에 configuration, target, platform selector가 다시 나타난다.</p><p></p><figure><img alt="Rider 2022.3 New UI에 수동으로 복원된 Solution Configuration 선택기" loading="lazy" width="1389" height="549" src="/_astro/rider-2022-3-restored-solution-configuration-selector.5pDASHJu_2pMfWa.webp" /><figcaption>Rider 2022.3 New UI에 수동으로 복원된 Solution Configuration 선택기</figcaption></figure><p></p><p><em><code>DebugGame Editor | Win64</code> 같은 Unreal 조합을 다시 선택할 수 있다.</em></p><p>이 방법은 IDE의 누락 문제 자체를 고치는 것이 아니라 toolbar customization으로 선택기를 다시 표시하는 우회법이다.</p></section>
<section><h2>Unreal configuration, target, platform 읽기<a href="#unreal-configuration-target-platform-읽기"><span>#</span></a></h2><p>Rider selector는 UnrealBuildTool이 이해하는 state, target, platform 조합을 보여 준다. 이름을 단순한 Debug/Release dropdown으로 생각하면 잘못된 binary를 만들기 쉽다.</p>

<table><thead><tr><th>예시</th><th>의미</th><th>일반적인 용도</th></tr></thead><tbody><tr><td><code>Development Editor | Win64</code></td><td>대부분 최적화된 Development state + Editor target + Windows 64-bit</td><td>Unreal Editor에서 일상적인 개발과 PIE</td></tr><tr><td><code>DebugGame Editor | Win64</code></td><td>engine은 일반적으로 최적화 상태, game module은 debugging하기 쉬운 state + Editor target</td><td>Editor에서 project game code를 집중적으로 debug</td></tr><tr><td><code>Development Game | Win64</code></td><td>Development state + standalone Game target</td><td>cooked content를 전제로 한 standalone game test</td></tr><tr><td><code>Shipping Game | Win64</code></td><td>최대 최적화와 shipping 제한 + Game target</td><td>최종 배포 후보</td></tr></tbody></table><p><code>Shipping Editor</code>를 일반 조합처럼 찾지 않는다. Editor와 Game은 서로 다른 target이며, 사용할 수 있는 target은 project의 <code>.Target.cs</code>와 Rider project model이 결정한다. <code>Win64</code>는 산출물의 target platform이다.</p></section>
<section><h2>선택기는 보이는데 target이 없을 때<a href="#선택기는-보이는데-target이-없을-때"><span>#</span></a></h2><p>이 경우는 toolbar 문제가 아니다.</p><ol>
<li>project의 <code>Source/&lt;ProjectName&gt;.Target.cs</code>와 <code>&lt;ProjectName&gt;Editor.Target.cs</code>가 존재하고 compile되는지 확인한다.</li>
<li>Rider가 <code>.uproject</code>를 직접 연 것인지 generated <code>.sln</code>을 연 것인지 확인한다.</li>
<li>Unreal Editor와 Rider를 닫고 project files 또는 Rider project model을 다시 생성한다.</li>
<li>command line 또는 Unreal Editor에서 같은 target이 실제로 build되는지 확인한다.</li>
</ol><p>UI widget을 여러 번 추가해도 project model에 없는 target은 생기지 않는다. 먼저 선택기 자체가 사라진 문제와 필요한 target만 목록에 없는 문제를 구분한다.</p></section>
<section><h2>Rider 2022.3에서 발생한 이유<a href="#rider-20223에서-발생한-이유"><span>#</span></a></h2>

<table><thead><tr><th>시기</th><th>변화</th></tr></thead><tbody><tr><td>Rider 2022.2 EAP</td><td>solution configuration을 build icon dropdown으로 옮기고 <code>Show Configuration on Toolbar</code> 제공</td></tr><tr><td>Rider 2022.3 EAP2</td><td>Rider의 New UI Preview 공개</td></tr><tr><td>Rider 2022.3</td><td>New UI에서 Build button과 Edit Solution Configuration이 누락되는 RIDER-83004 발생</td></tr></tbody></table><p>New UI Preview가 공개된 경계는 Rider 2022.3 EAP 계열이다. 이 글에서는 Rider 2022.3 New UI와 <code>ActiveConfiguration</code> action을 기준으로 문제를 다룬다.</p></section>
<section><h2>체크리스트<a href="#체크리스트"><span>#</span></a></h2><ul>
<li>Run/Debug Configuration과 solution build configuration 중 무엇이 사라졌는가?</li>
<li>main toolbar의 <code>ExecutionTargetsToolbarGroup</code>에 <code>ActiveConfiguration</code>을 추가했는가?</li>
<li>필요한 경우 <strong>Show Configuration on Toolbar</strong>를 켰는가?</li>
<li>selector가 나타난 뒤 state, target, platform 조합이 올바른가?</li>
<li>selector는 있지만 target이 없다면 <code>.Target.cs</code>와 project model을 확인했는가?</li>
</ul><p>Rider 2022.3 New UI에서 Solution Configuration 선택기가 사라진 것은 사용자 설정의 의미를 잘못 이해해서가 아니라 확인된 IDE 문제였다. toolbar에 <code>ActiveConfiguration</code> action을 추가하면 기존 UI처럼 solution configuration을 다시 바꿀 수 있다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://youtrack.jetbrains.com/issue/RIDER-83004/No-Edit-Solution-Configuration-and-Build-button-in-new-UI" target="_blank">JetBrains YouTrack: RIDER-83004</a></li>
<li><a href="https://blog.jetbrains.com/dotnet/2022/05/19/rider-2022-2-eap/" target="_blank">JetBrains Rider 2022.2 EAP changes</a></li>
<li><a href="https://blog.jetbrains.com/dotnet/2022/10/07/rider-2022-3-eap-2/" target="_blank">JetBrains Rider 2022.3 EAP 2 New UI</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/winning-a-one-year-jetbrains-rider-license/</id>
      <title type="text">JetBrains 발표 세션 퀴즈를 맞히고 Rider 1년권을 얻었다!</title>
      <published>2023-02-05T00:00:00.000Z</published>
      <updated>2023-02-05T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/winning-a-one-year-jetbrains-rider-license/"/>
      <summary type="text">JetBrains의 Unreal Engine용 Rider 웨비나를 보다가 예상 밖의 PlayStation 퀴즈를 맞히고 Rider 1년 이용권을 받은 경험을 기록합니다.</summary>
      <content type="html"><![CDATA[<p>Unity를 사용할 때부터 Rider를 써 왔다. Unreal Engine에서도 별다른 학습 없이 그때그때 필요한 기능을 찾아 주먹구구식으로 사용하고 있었는데, 마침 JetBrains에서 <a href="https://www.youtube.com/watch?v=sUSyxJGyrho" target="_blank">Unreal Engine으로 게임 제작하기: Rider로 아이디어를 더 쉽게 구현</a> 라이브 세션을 열어 퇴근 후 시청했다.</p>
<p>내용의 90% 정도는 이미 알고 있던 기능이라 세션 자체가 크게 도움이 되지는 않았다. 대신 시청자에게 주는 쿠폰에 관심이 갔다. 방송에서는 신규 사용자에게 Rider 3개월 이용권을, 기존 고객에게 25% 할인 쿠폰을 제공한다고 안내했다.</p>
<p>학생 때를 생각하면 IDE가 제공하는 가치에 비해 가격이 비싼 편이 아니어도 구매를 망설이게 된다. 그래서 실제로 쓰는 Rider의 이용권이라는 점이 반가웠다.</p>
<section><h2>첫 번째 퀴즈를 놓치다<a href="#첫-번째-퀴즈를-놓치다"><span>#</span></a></h2><p>세션 중간에는 선착순 정답자 한 명에게 Rider 1년 이용권을 주는 퀴즈도 열렸다. 첫 번째 문제는 Rider 출시일에 관한 것이었지만 아직 퇴근길이라 참여하지 못했다.</p><p>세션이 마무리될 무렵에는 집에 도착해 두 번째 문제를 기다리고 있었다. 그런데 나온 문제는 Rider와 전혀 관계없는 내용이었다.</p><p></p><figure><img alt="JetBrains Rider 웨비나에 나온 초기 PlayStation 개발 키트 풋 페달 퀴즈" loading="lazy" width="1920" height="1080" src="/_astro/playstation-foot-pedal-quiz.BdhTnll1_ZWGB6B.webp" /><figcaption>JetBrains Rider 웨비나에 나온 초기 PlayStation 개발 키트 풋 페달 퀴즈</figcaption></figure><p></p><p><em>아니, 이건 Rider와 관련된 문제가 아니잖소? 거기에 이렇게 소수만 알 법한 문제를?</em></p><p>질문은 초기 PlayStation 개발 키트에서 풋 페달의 기본 기능이 무엇이었는지 묻고 있었다. Rider와는 하나도 관계없는 문제라 당황했다. 채팅에는 문제가 나오자마자 다른 선택지를 고른 답이 쏟아졌고, 처음에는 그쪽이 정답인가 싶었다. 하지만 시간이 지나도 진행자가 정답이라고 하지 않아 다시 생각해 봤다.</p><p>풋 페달에서 자동차 브레이크가 떠올랐고, 브레이크에서 실행을 끊는 interrupt가 연상됐다. 그래서 <code>가. 인터럽트를 트리거하는 기능</code>이라고 답했는데 정답이었다.</p><p>공식 녹화본에서 <a href="https://www.youtube.com/watch?v=sUSyxJGyrho&amp;t=3866s" target="_blank">문제는 1:04&lt;26&gt;</a>, <a href="https://www.youtube.com/watch?v=sUSyxJGyrho&amp;t=3904s" target="_blank">정답 발표는 1:05&lt;04&gt; 부근</a>에 다시 볼 수 있다.</p></section>
<section><h2>실제 사용하는 도구의 1년 이용권<a href="#실제-사용하는-도구의-1년-이용권"><span>#</span></a></h2><p>얼떨결에 Rider 1년 이용권을 얻었다. 실제로 사용하는 프로그램의 이용권이라 기쁨이 몇 배로 컸다. 초기 PlayStation 개발 키트 풋 페달의 기본 기능이라는 희귀한 지식도 하나 얻어 여러모로 재미있는 이벤트였다.</p></section>
<section><h2>출처<a href="#출처"><span>#</span></a></h2><ul>
<li><a href="https://www.youtube.com/watch?v=sUSyxJGyrho" target="_blank">Unreal Engine으로 게임 제작하기: Rider로 아이디어를 더 쉽게 구현</a></li>
<li><a href="https://blog.jetbrains.com/ko/dotnet/2022/12/14/jetbrains-three-webinars-on-game-development-live-streams/" target="_blank">JetBrains 한국어 게임 개발 웨비나 사전 공지</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/making-better-blueprints-unreal-fest-2022/</id>
      <title type="text">효율적으로 Blueprint 만들기: Unreal Fest 2022 세션 요약</title>
      <published>2023-01-31T00:00:00.000Z</published>
      <updated>2023-01-31T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/making-better-blueprints-unreal-fest-2022/"/>
      <summary type="text">Unreal Fest 2022의 Making Better Blueprints 세션에서 다룬 그래프 정리, Pure 함수, Tick, 참조, 제작 도구, 재사용과 Actor 통신 원칙을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Unreal Fest 2022의 <a href="https://www.youtube.com/watch?v=Od8rzSWv-iE" target="_blank">Making Better Blueprints</a> 세션은 Blueprint 작업을 <strong>Cleanliness, Creation, Classes &amp; Sharing, Communication</strong>이라는 네 가지 관점으로 정리했다.</p>
<p>그래프 정리, Pure 함수의 반복 평가, Tick 남용, hard reference, editor tooling, 통신 결합도를 점검하는 것이 핵심이다. 다만 Tick과 cast를 무조건 피하라는 식의 규칙보다 각 도구의 비용과 책임을 구분해야 한다.</p>
<p>발표 내용을 <strong>4C 순서</strong>로 정리하되, 이를 Epic의 공식 코딩 표준처럼 취급하지는 않는다.</p>
<section><h2>발표 요점과 적용 시 주의점<a href="#발표-요점과-적용-시-주의점"><span>#</span></a></h2>

<table><thead><tr><th>주제</th><th>적용 범위·주의점</th></tr></thead><tbody><tr><td>Pure 반환값 caching</td><td>자동 memoization을 기대하지 말고 비싼 계산은 한 번 실행해 변수에 저장</td></tr><tr><td>Pure 함수와 <code>For Each</code></td><td>비싼 Pure 결과를 loop에 직접 물리지 말고 <strong>loop 전에</strong> 계산해 저장</td></tr><tr><td>Tick 사용</td><td>event로 표현할 수 있는 polling은 줄이되 연속 motion·보간은 Tick이 자연스러울 수 있음</td></tr><tr><td>Actor Tick 활성화 API</td><td>API 이름은 <code>SetActorTickEnabled</code></td></tr><tr><td><code>SetTickWhenOffscreen</code>의 적용 범위</td><td><code>UWidgetComponent</code>의 기능이며 모든 Actor에 적용되는 설정이 아님</td></tr><tr><td>Timeline 비용</td><td>활성 Timeline도 update 작업을 하므로 측정 없이 “무료 대안”으로 볼 수 없음</td></tr><tr><td>큰 Data Table의 reference</td><td>선택적으로 늦게 읽을 큰 asset에는 적합하지만 항상 필요한 작은 asset은 hard reference도 정당</td></tr><tr><td>Blueprint Header View</td><td>당시 새 editor 도구이며 <code>.cpp</code> 구현을 자동 생성하는 기능은 아님</td></tr><tr><td>Construction Script 재실행</td><td>runtime Tick이 아니라 editor transform/property 변화 과정에서 반복 재실행될 수 있음</td></tr><tr><td>Actor Tag와 Interface</td><td>Tag는 분류, Interface는 호출 계약이며 Interface가 target을 찾아 주지는 않음</td></tr></tbody></table></section>
<section><h2>1. Cleanliness: 읽기 쉬운 그래프보다 예측 가능한 그래프<a href="#1-cleanliness-읽기-쉬운-그래프보다-예측-가능한-그래프"><span>#</span></a></h2><section><h3>정리의 목적은 장식이 아니라 변경 비용 줄이기<a href="#정리의-목적은-장식이-아니라-변경-비용-줄이기"><span>#</span></a></h3><p>색상, comment box, node comment, Blueprint bookmark, reroute node는 큰 그래프의 지역과 의도를 빠르게 찾게 해 준다. 팀에서는 색상 자체보다 <strong>같은 의미에 같은 규칙을 쓰는가</strong>가 중요하다.</p><ul>
<li>variable과 function 이름에 단위와 책임을 드러낸다. <code>Speed</code>보다 <code>WalkSpeedCmPerSec</code>가 의도를 잘 전달한다.</li>
<li>comment는 node가 무엇을 하는지 번역하기보다 왜 그 규칙이 필요한지 기록한다.</li>
<li>멀리 뻗는 execution wire와 data wire를 줄이고 흐름이 한 방향으로 읽히게 배치한다.</li>
<li>자주 돌아오거나 debug할 영역은 bookmark로 남긴다.</li>
<li>선택한 node를 무작정 collapse하지 말고 function, macro, collapsed graph 중 의미에 맞는 경계를 고른다.</li>
</ul><p>Math Expression node는 짧은 수식을 한 덩어리로 표현할 때 유용하지만, 복잡한 gameplay 규칙을 숨기는 용도로 쓰면 debug가 어려워진다. 계산의 이름이 필요하거나 여러 곳에서 재사용한다면 function으로 빼는 편이 낫다.</p></section><section><h3>Function, Macro, Collapsed Graph 고르기<a href="#function-macro-collapsed-graph-고르기"><span>#</span></a></h3>

<table><thead><tr><th>도구</th><th>적합한 경우</th><th>제한과 주의</th></tr></thead><tbody><tr><td>Function</td><td>명확한 입력·출력, 재사용, local variable, override, 작은 단위 test</td><td>일반 Blueprint function은 latent node를 담지 못함</td></tr><tr><td>Macro</td><td>여러 execution 출구, wildcard pin, latent flow처럼 function으로 표현하기 어려움</td><td>compile 때 graph가 확장되므로 큰 macro를 많이 쓰면 generated graph와 debug가 복잡해짐</td></tr><tr><td>Collapsed Graph</td><td>현재 graph만 읽기 좋게 묶고 외부 재사용이 필요 없을 때</td><td>진짜 API 경계가 아니므로 이름만 보고 책임을 과대평가하지 않기</td></tr><tr><td>Function Library</td><td>instance state가 필요 없는 공용 계산</td><td>static 성격이므로 World·owner state를 숨은 global처럼 찾지 않기</td></tr><tr><td>Macro Library</td><td>여러 Blueprint에서 반복되는 graph pattern</td><td>parent class 범위와 expansion 비용을 이해하고 작은 패턴에 한정</td></tr></tbody></table><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/functions-in-unreal-engine" target="_blank">Blueprint Functions</a>와 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/macros-in-unreal-engine" target="_blank">Blueprint Macros</a>의 차이를 먼저 보고 선택한다.</p></section><section><h3>Pure 함수는 값이 아니라 계산이다<a href="#pure-함수는-값이-아니라-계산이다"><span>#</span></a></h3><p>Pure node에는 execution pin이 없고, 소비하는 impure node가 input을 필요로 할 때 평가된다. 이것을 “한 번 계산된 상수”나 자동 cache처럼 취급하면 안 된다. 같은 결과가 여러 소비자에 연결되거나 loop macro가 매 iteration에 값을 요구하면 비싼 Pure 함수가 반복 호출될 수 있다.</p><p>다음 패턴이 특히 위험하다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>비싼 Pure 함수 ──&gt; For Each Loop의 Array</span></div></div><div><div><div>2</div></div><div><span><span>                     </span></span><span>└─ loop 내부에서도 같은 Pure 결과를 다시 참조</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>배열 생성, component 검색, 복잡한 query처럼 비용 있는 계산은 loop 전에 한 번 실행해 local variable에 저장한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>비싼 계산 ──&gt; Local Array 변수 ──&gt; For Each Loop</span></div></div><div><div><div>2</div></div><div><span><span>                        </span></span><span>└─────────&gt; 다른 소비자</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>반대로 단순 getter와 작은 수학 연산까지 모두 impure로 만들 필요는 없다. Pure 함수는 side effect가 없고 호출 비용이 작다는 계약을 지킬 때 그래프를 읽기 좋게 한다. 공식 <a href="https://dev.epicgames.com/documentation/unreal-engine/ufunctions-in-unreal-engine" target="_blank">UFunctions 문서</a>는 Pure 함수의 결과가 cache되지 않으며 연결에 따라 여러 번 호출될 수 있음을 경고한다.</p><p><strong>Validated Get</strong>은 object reference getter와 validity branch를 한 node로 합쳐 null 검사 흐름을 줄여 준다. 그러나 reference가 왜 유효해야 하는지, 누가 lifetime을 소유하는지, network에서 언제 도착하는지까지 해결해 주는 기능은 아니다. 필수 dependency라면 초기화 시점에 검증하고, 선택적 reference라면 invalid 경로가 정상 상태가 되도록 처리한다.</p></section></section>
<section><h2>Tick: 없애는 것이 아니라 책임을 고르기<a href="#tick-없애는-것이-아니라-책임을-고르기"><span>#</span></a></h2><p>Tick이 문제인 이유는 호출 자체가 금지돼서가 아니다. 많은 instance가 매 frame 불필요한 polling, search, allocation을 수행하면 총비용이 커지고, 순서 dependency까지 숨기기 쉽기 때문이다.</p>

<table><thead><tr><th>요구</th><th>우선 검토할 수단</th></tr></thead><tbody><tr><td>매 frame 연속 이동·카메라 보간</td><td>Tick 또는 적절한 movement/component update</td></tr><tr><td>상태가 바뀌었을 때 한 번 반응</td><td>event, delegate, dispatcher</td></tr><tr><td>일정 간격으로 반복</td><td>Timer</td></tr><tr><td>짧은 시간 동안 curve 기반 보간</td><td>Timeline. 활성 중 update 비용은 여전히 존재</td></tr><tr><td>animation·physics 앞뒤의 정확한 실행 순서</td><td>Tick group과 prerequisite</td></tr><tr><td>일정 시간 뒤 Actor 제거</td><td><code>SetLifeSpan</code>. Tick 제어가 아니라 lifetime 관리</td></tr><tr><td>UI가 화면 밖일 때 update 여부</td><td>Widget Component의 <code>SetTickWhenOffscreen</code> 범위에서만</td></tr></tbody></table><p>Actor Tick을 runtime에 켜고 끌 때 API 이름은 <code>SetActorTickEnabled</code>다. <code>SetActorTickInterval</code>은 매 frame보다 느린 주기가 충분할 때 쓸 수 있지만 정밀한 scheduler나 wall-clock timer를 대신하지 않는다. <code>SetTickGroup</code>, <code>AddTickPrerequisiteActor</code>, <code>RemoveTickPrerequisiteActor</code>는 순서를 명시할 때 유용하지만 dependency cycle과 지나친 직렬화를 만들지 않게 제한한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>dumpticks</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>console의 <code>dumpticks</code>는 현재 tick 중인 object와 설정을 파악하는 출발점이다. Unreal Insights, <code>stat game</code>, Blueprint profiler와 함께 실제 instance 수와 함수 비용을 측정한다. “Actor가 많으니 Tick을 모두 금지”하거나 “간격을 늘렸으니 최적화 완료”라고 결론 내리지 않는다.</p><p>Delay를 다시 호출하는 무한 loop는 Timer보다 수명·취소·오류 처리가 불명확해지기 쉽다. 발표에서도 가능한 선택지로 언급했지만 권장 패턴은 아니다. <a href="https://dev.epicgames.com/documentation/unreal-engine/gameplay-timers-in-unreal-engine" target="_blank">Gameplay Timers</a>와 <a href="https://dev.epicgames.com/documentation/unreal-engine/actor-ticking-in-unreal-engine" target="_blank">Actor Ticking</a>을 기준으로 선택한다.</p><section><h3>Blueprint lifetime은 <code>SetLifeSpan</code>으로 명시할 수 있다<a href="#blueprint-lifetime은-setlifespan으로-명시할-수-있다"><span>#</span></a></h3><p>투사체나 일시적 effect Actor는 spawn할 때 수명을 설정해 별도 polling 없이 제거할 수 있다. <code>SetLifeSpan(0)</code>은 기존 timer를 지우며 무한 수명으로 돌아간다는 점, destruction은 authority와 replication 수명주기의 영향을 받는다는 점을 함께 고려한다. gameplay state를 가진 Actor를 단순 시간 초과로 없애기 전에는 owner, component, delegate binding 정리 계약을 확인한다.</p></section></section>
<section><h2>Hard reference와 Soft reference는 로딩 정책이다<a href="#hard-reference와-soft-reference는-로딩-정책이다"><span>#</span></a></h2><p>Blueprint가 다른 Blueprint class, texture, mesh, sound를 hard reference하면 owner를 load할 때 dependency도 함께 load될 수 있다. 참조가 이어지면 작은 gameplay Blueprint 하나가 예상보다 큰 asset tree를 끌어올 수 있다.</p><p>Soft object/class reference는 asset을 즉시 가리키는 object pointer 대신 경로를 보관한다. 실제 사용 시 동기 또는 async load를 명시하고, 완료 전·실패·취소·해제 시점을 처리해야 한다.</p>

<table><thead><tr><th>질문</th><th>Hard reference가 자연스러운 경우</th><th>Soft reference가 자연스러운 경우</th></tr></thead><tbody><tr><td>owner와 함께 항상 필요한가?</td><td>기본 mesh, 필수 animation, 작은 공통 data</td><td>선택 skin, 먼 단계 content, 대형 variation</td></tr><tr><td>load 지연을 허용할 수 있는가?</td><td>허용할 수 없고 크기가 작음</td><td>loading UI나 async 준비 단계를 둘 수 있음</td></tr><tr><td>참조 선택지가 매우 많은가?</td><td>작은 고정 집합</td><td>큰 Data Table이나 catalog가 선택적으로 asset을 가리킴</td></tr><tr><td>Asset Manager의 bundle·Primary Asset 정책이 필요한가?</td><td>반드시 필요하지 않음</td><td><code>PrimaryAssetId</code>와 bundle 기반 load가 적합</td></tr></tbody></table><p>“큰 Data Table이면 무조건 soft reference”가 아니라, table을 load할 때 어떤 dependency가 함께 들어오는지 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/reference-viewer-in-unreal-engine" target="_blank">Reference Viewer</a>, Size Map, Asset Audit과 trace로 확인한다. 항상 필요한 작은 icon까지 async로 나누면 코드와 loading state만 복잡해질 수 있다. <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/referencing-assets-in-unreal-engine" target="_blank">Referencing Assets</a>와 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/asynchronous-asset-loading-in-unreal-engine" target="_blank">Asynchronous Asset Loading</a>의 lifetime 책임도 함께 적용한다.</p></section>
<section><h2>Debugger와 Header View를 실제 작업 흐름에 넣기<a href="#debugger와-header-view를-실제-작업-흐름에-넣기"><span>#</span></a></h2><p>Print String만으로 상태를 추적하면 여러 instance와 latent flow에서 호출 순서를 놓치기 쉽다. Blueprint Debugger는 다음 기능을 제공한다.</p><ul>
<li>breakpoint와 조건부 중단</li>
<li>variable watch</li>
<li>Blueprint call stack</li>
<li>execution trace와 현재 실행 node 표시</li>
<li>debug 대상 instance 선택</li>
</ul><p>PIE에서 올바른 instance를 선택했는지 먼저 확인하고, breakpoint에서 input, owner, authority, latent state를 본다. network PIE라면 server와 client instance를 구분한다. 공식 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/blueprint-debugger-in-unreal-engine" target="_blank">Blueprint Debugger</a> 문서에서 각 기능을 확인할 수 있다.</p><p>Blueprint Header View는 선택한 Blueprint를 동등한 C++ 선언 형태로 보여 주어 class, property, function signature와 metadata를 학습하는 데 유용하다. 하지만 graph body를 production-ready <code>.cpp</code>로 자동 변환하거나 성능을 개선하는 nativizer가 아니다. generated declaration을 출발점으로 사용하되 ownership, include, module API, implementation을 C++ 설계에 맞게 다시 작성한다.</p></section>
<section><h2>2. Creation: 반복 가능한 제작 도구 만들기<a href="#2-creation-반복-가능한-제작-도구-만들기"><span>#</span></a></h2><section><h3>Construction Script는 빠르고 멱등하게<a href="#construction-script는-빠르고-멱등하게"><span>#</span></a></h3><p>Construction Script는 runtime Tick이 아니다. 다만 editor에서 instance property와 transform을 바꾸거나 Blueprint를 compile하고 reconstruction할 때 반복 실행될 수 있다. <strong>Run Construction Script on Drag</strong>가 켜져 있으면 이동 중 여러 번 실행될 수 있다.</p><p>같은 입력으로 다시 실행해도 같은 결과가 나오도록 만들고 아래 동작은 피한다.</p><ul>
<li>이전 결과를 정리하지 않는 누적 spawn·append</li>
<li>asset 저장, 파일 I/O, network 요청</li>
<li>World 전체 Actor 검색</li>
<li>seed 없는 random</li>
<li>다른 Actor property를 양방향으로 변경</li>
<li>수백·수천 component를 편집할 때마다 파괴·재생성</li>
</ul><p>Spline을 따라 fence, road, cable을 미리보는 것처럼 editor feedback이 중요한 작업에는 잘 맞는다. 무거운 bake는 <code>CallInEditor</code> function이나 Editor Utility로 명시적으로 실행한다. 자세한 lifecycle과 C++ <code>OnConstruction</code> 경계는 <a href="/posts/unreal-engine-construction-script-best-practices/">Construction Script를 안전하게 사용하는 법</a>에 별도로 정리했다.</p></section><section><h3>Call in Editor, Editor Utility, Geometry Script<a href="#call-in-editor-editor-utility-geometry-script"><span>#</span></a></h3>

<table><thead><tr><th>도구</th><th>적합한 범위</th><th>주의사항</th></tr></thead><tbody><tr><td><code>CallInEditor</code> function</td><td>선택한 Actor의 명시적 rebuild·validate·bake button</td><td>Details button용 function은 input parameter 없이 명확한 transaction 필요</td></tr><tr><td>Editor Utility Blueprint</td><td>여러 asset·Actor의 batch 처리, custom editor workflow</td><td>packaged runtime 기능이 아니며 editor-only dependency를 분리</td></tr><tr><td>Spline Component</td><td>path 기반 배치·보간·미리보기</td><td>construction 재실행 비용과 generated component 수 측정</td></tr><tr><td>Geometry Script</td><td>dynamic mesh 분석·생성·편집</td><td>Beta 기능이며 function과 플랫폼별 runtime/editor 지원 확인</td></tr></tbody></table><p>Geometry Script를 쓴다고 모든 작업이 runtime-safe인 것은 아니다. 특정 함수가 editor subsystem, asset modification, platform feature에 의존하는지 API별로 확인한다. <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/introduction-to-geometry-scripting-in-unreal-engine" target="_blank">Geometry Scripting 소개</a>가 범위와 plugin 상태를 설명한다.</p></section></section>
<section><h2>3. Classes &amp; Sharing: 상속 전에 수명과 소유권 정하기<a href="#3-classes--sharing-상속-전에-수명과-소유권-정하기"><span>#</span></a></h2><section><h3>상속은 “is-a”, Component는 재사용 가능한 행동<a href="#상속은-is-a-component는-재사용-가능한-행동"><span>#</span></a></h3><p>parent Blueprint의 상태와 virtual event를 파생 class가 특화하는 진짜 <strong>is-a</strong> 관계에는 상속과 override가 적합하다. 그러나 서로 다른 Actor 종류에 “체력”, “상호작용”, “inventory” 행동을 재사용하려고 깊은 상속 tree를 만들면 불필요한 state와 hard dependency가 따라온다.</p><p>Actor Component는 owner Actor의 수명 안에서 재사용 가능한 state와 behavior를 묶는다. network에서 component state를 복제하려면 owner Actor가 replicate되고 component도 replication 대상으로 설정돼야 한다. Component를 붙였다는 사실만으로 client에 state가 자동 복제되지는 않는다.</p></section><section><h3>Data Table, Data Asset, Primary Data Asset<a href="#data-table-data-asset-primary-data-asset"><span>#</span></a></h3>

<table><thead><tr><th>데이터 형태</th><th>선택 기준</th></tr></thead><tbody><tr><td>Data Table</td><td>하나의 row struct로 표현되는 많은 record, CSV/JSON·spreadsheet 기반 편집</td></tr><tr><td>Data Asset</td><td>개별 이름과 type을 가진 UObject asset, editor에서 복잡한 property와 참조 편집</td></tr><tr><td>Primary Data Asset</td><td>Asset Manager가 Primary Asset ID, bundle, scan/load policy로 관리해야 하는 content</td></tr><tr><td>Config / SaveGame 등</td><td>build 설정 또는 사용자 runtime 저장처럼 asset catalog와 다른 수명</td></tr></tbody></table><p>Data Table을 “모든 공유 데이터의 database”로 만들지 않는다. row가 서로 다른 수명과 대형 dependency를 가진다면 Data Asset과 soft reference가 더 자연스러울 수 있다. 반대로 수백 개의 단순 수치 row를 각각 UObject asset으로 만들면 관리 비용이 커진다. <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/data-driven-gameplay-elements-in-unreal-engine" target="_blank">Data-driven Gameplay</a>와 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/data-assets-in-unreal-engine" target="_blank">Data Assets</a>를 기준으로 고른다.</p></section><section><h3>Subsystem은 수명 범위가 명확한 service<a href="#subsystem은-수명-범위가-명확한-service"><span>#</span></a></h3><p>UE5에서 공용 service가 필요하면 무조건 GameInstance에 변수를 몰아넣기보다 Engine, Editor, GameInstance, World, LocalPlayer Subsystem의 수명을 비교한다.</p><ul>
<li>World마다 달라야 하는 runtime service는 World Subsystem 후보</li>
<li>local player마다 다른 input·UI state는 LocalPlayer Subsystem 후보</li>
<li>process의 game session 동안 공유하는 service는 GameInstance Subsystem 후보</li>
<li>editor workflow만 담당하면 Editor Subsystem 후보</li>
</ul><p>Subsystem은 global 잡동사니 보관소나 Actor별 mutable state를 대신하지 않는다. network state도 자동 복제되지 않는다. server authoritative state는 replicated Actor/Component 등 network lifetime이 있는 객체에 두고 subsystem은 조정·조회 service로 제한한다. <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/programming-subsystems-in-unreal-engine" target="_blank">Programming Subsystems</a>가 수명 종류를 설명한다.</p></section></section>
<section><h2>4. Communication: 대상을 찾는 방법과 대화하는 방법 분리하기<a href="#4-communication-대상을-찾는-방법과-대화하는-방법-분리하기"><span>#</span></a></h2><p>Blueprint 통신을 고를 때 먼저 두 질문을 나눈다.</p><ol>
<li><strong>대상을 어떻게 발견하고 유효한 reference를 얻는가?</strong></li>
<li><strong>그 reference와 어떤 계약으로 대화하는가?</strong></li>
</ol><p>Tag와 trace는 주로 첫 번째 질문에 답하고, direct call, cast, interface, dispatcher는 두 번째 질문에 답한다.</p>

<table><thead><tr><th>방식</th><th>적합한 관계</th><th>장점</th><th>주의점</th></tr></thead><tbody><tr><td>Direct reference</td><td>owner가 target을 알고 수명이 명확함</td><td>가장 단순하고 type-safe</td><td>concrete Blueprint reference가 불필요한 hard asset dependency를 만들 수 있음</td></tr><tr><td>Cast</td><td>이미 가진 object가 특정 구체 type인지 확인하고 그 API가 필요함</td><td>분기와 typed API 접근이 명확</td><td>cast가 target을 찾지는 않음. 매 frame global search 뒤 cast하는 구조를 피함</td></tr><tr><td>Blueprint Interface</td><td>서로 다른 class가 같은 작은 호출 계약을 구현함</td><td>caller가 구체 class를 몰라도 됨</td><td>reference는 별도로 구해야 하며 state·default implementation을 담지 않음</td></tr><tr><td>Event Dispatcher</td><td>publisher 하나의 사건을 여러 listener가 구독</td><td>publisher가 listener type을 몰라도 됨</td><td>binding owner와 unbind 시점, duplicate binding, listener lifetime 관리</td></tr><tr><td>Actor/GamePlay Tag</td><td>object 분류·filter·rule data</td><td>data-driven grouping</td><td>callable API 계약이 아니며 tag 검색 자체가 reference discovery 비용을 가짐</td></tr><tr><td>Trace/Overlap</td><td>공간 query로 현재 대상 발견</td><td>world 상태에 맞는 명시적 query</td><td>무엇을 발견한 뒤 cast/interface/direct call 중 하나를 다시 선택</td></tr></tbody></table><p>Cast는 그 자체로 나쁜 node가 아니다. 이미 overlap result나 stored reference가 있고 구체 type의 기능이 필요하다면 자연스럽다. 문제는 <code>Get All Actors Of Class</code> 같은 World scan을 자주 수행한 뒤 cast하는 구조, 또는 native parent/interface면 충분한데 concrete Blueprint class를 참조해 거대한 dependency를 만드는 구조다. <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/BlueprintAPI/Actor/GetAllActorsOfClass" target="_blank"><code>Get All Actors Of Class</code></a> 문서도 느린 작업이므로 주의하라고 명시한다.</p><p>Interface 역시 target을 찾아 주지 않는다. “Tag 검색 대신 Interface”라는 비교는 잘못됐다. spawn 반환값, owner/instigator, component lookup, overlap/trace result, initialization injection, subsystem registry처럼 상황에 맞는 방식으로 reference를 얻고, 그다음 interface contract를 호출한다.</p><p>Event Dispatcher는 “무슨 일이 일어났는지” broadcast할 때 적합하다. listener가 publisher보다 먼저 사라질 수 있다면 binding의 수명과 unbind 책임을 명확히 한다. 한 번의 direct command를 보내려고 dispatcher를 쓰거나, 결과가 필요한 synchronous query를 broadcast로 표현하면 흐름이 더 어려워진다. <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-communication-in-unreal-engine" target="_blank">Actor Communication</a>, <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/blueprint-interface-in-unreal-engine" target="_blank">Blueprint Interface</a>, <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/event-dispatchers-in-unreal-engine" target="_blank">Event Dispatchers</a>가 각 계약을 비교한다.</p><section><h3>Multiplayer에서는 호출 구조와 replication을 별도로 설계하기<a href="#multiplayer에서는-호출-구조와-replication을-별도로-설계하기"><span>#</span></a></h3><p>Blueprint Interface call이나 dispatcher broadcast가 server state를 client로 전송하지 않는다. gameplay state는 server authority에서 변경하고 replicated property 또는 RPC로 동기화한다. local reference가 client에도 존재하는지, owner connection이 누구인지, call이 어느 machine에서 실행되는지를 따로 확인한다.</p><ul>
<li>client input → 소유 Actor의 validated Server RPC</li>
<li>server gameplay state 변경 → replicated property와 RepNotify</li>
<li>client-only UI 갱신 → local dispatcher 또는 view model</li>
<li>모든 peer가 같은 object를 가질 것이라는 가정 → NetGUID, relevancy, spawn timing 검증</li>
</ul><p>통신 node를 깔끔하게 골라도 network authority가 잘못되면 correctness는 확보되지 않는다.</p></section></section>
<section><h2>UE5에서 달라진 두 가지<a href="#ue5에서-달라진-두-가지"><span>#</span></a></h2><section><h3>Blueprint Header View는 변환기가 아니다<a href="#blueprint-header-view는-변환기가-아니다"><span>#</span></a></h3><p>UE 5.1의 Header View는 C++ 선언 학습과 전환할 class의 형태를 살피는 데 유용하지만, generated output을 복사해 build한다고 Blueprint가 자동 전환되는 것은 아니다. module dependency, export macro, include, UObject lifetime, implementation과 test를 수동으로 설계한다.</p></section><section><h3>Blueprint Nativization은 제거됐다<a href="#blueprint-nativization은-제거됐다"><span>#</span></a></h3><p>UE4의 Blueprint Nativization framework는 UE 5.0에서 제거됐다. Header View는 이를 대체하지 않는다. “모든 Blueprint를 자동 C++ 변환”하기보다 다음 순서로 필요한 부분을 고른다.</p><ol>
<li>Blueprint profiler와 Unreal Insights로 hot path를 찾는다.</li>
<li>불필요한 Tick, search, allocation, hard load를 먼저 줄인다.</li>
<li>stable API와 designer iteration이 필요한 경계를 정한다.</li>
<li>실제 병목인 계산이나 core system만 C++로 옮긴다.</li>
<li>Blueprint child와 data asset이 사용할 작고 명확한 API를 노출한다.</li>
</ol><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/coding-in-unreal-engine-blueprint-vs-cplusplus" target="_blank">Blueprint와 C++ 비교</a>는 둘을 섞어 각 장점을 쓰는 방식을 권장한다. “Blueprint라서 느리다”보다 어느 function이 얼마나 자주 무엇을 하는지 측정하는 것이 먼저다.</p></section></section>
<section><h2>실전 점검표<a href="#실전-점검표"><span>#</span></a></h2><section><h3>Graph와 계산<a href="#graph와-계산"><span>#</span></a></h3><ul>
<li>이름과 comment가 구현이 아니라 의도와 단위를 설명하는가?</li>
<li>비싼 Pure 함수가 여러 consumer나 loop에서 반복 평가되지 않는가?</li>
<li>function, macro, collapsed graph가 실제 책임과 재사용 범위에 맞는가?</li>
<li>invalid reference가 정상 분기인지 초기화 실패인지 구분했는가?</li>
</ul></section><section><h3>Performance와 reference<a href="#performance와-reference"><span>#</span></a></h3><ul>
<li>Tick이 연속 update에 필요한가, event·delegate·timer가 더 자연스러운가?</li>
<li><code>dumpticks</code>, profiler, Insights로 instance 수와 실제 비용을 측정했는가?</li>
<li>hard dependency tree를 Reference Viewer와 Size Map으로 확인했는가?</li>
<li>soft load의 loading, failure, cancellation, lifetime을 처리했는가?</li>
</ul></section><section><h3>Creation과 architecture<a href="#creation과-architecture"><span>#</span></a></h3><ul>
<li>Construction Script가 반복 실행돼도 같은 결과를 만드는가?</li>
<li>무거운 editor 작업은 명시적 command로 분리했는가?</li>
<li>상속, Actor Component, Data Table, Data Asset, Subsystem의 소유권과 수명이 맞는가?</li>
<li>Geometry Script의 Beta 상태와 function별 runtime 지원을 확인했는가?</li>
</ul></section><section><h3>Communication<a href="#communication"><span>#</span></a></h3><ul>
<li>target discovery와 호출 contract를 분리했는가?</li>
<li>cast/interface/dispatcher/tag가 서로 다른 문제를 푸는 도구임을 구분했는가?</li>
<li>World scan을 매 frame 수행하지 않는가?</li>
<li>binding과 cached reference의 해제·invalid 시점을 처리했는가?</li>
<li>multiplayer authority, ownership, relevancy, replication을 별도로 설계했는가?</li>
</ul></section></section>
<section><h2>결론<a href="#결론"><span>#</span></a></h2><p>더 나은 Blueprint는 wire가 반듯한 Blueprint만을 뜻하지 않는다. 계산 횟수가 예측 가능하고, Tick과 loading policy가 의도적이며, construction이 반복 가능하고, 재사용 단위의 수명과 통신 계약이 명확한 Blueprint가 유지보수하기 쉽다.</p><p>Unreal Fest 2022 발표의 4C는 이 질문을 시작하기 좋은 review 틀이다. 다만 “Tick 금지”, “soft reference 필수”, “cast 대신 interface”처럼 도구 하나를 절대 규칙으로 만들지 않는다. <strong>왜 이 object가 존재하고, 언제 load되며, 누가 reference를 가지고, 어느 machine에서 어떤 빈도로 호출하는가</strong>를 답할 수 있어야 한다.</p><p>그래프를 정리한 다음 실제 profile을 보고, data와 behavior의 수명을 정하고, 가장 작은 결합으로 target과 통신한다. 그 순서가 Blueprint를 빠르게 만드는 것과 팀이 오래 이해할 수 있게 만드는 일을 함께 해결한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://www.youtube.com/watch?v=Od8rzSWv-iE" target="_blank">Epic Games: Making Better Blueprints, Unreal Fest 2022</a></li>
<li><a href="https://dev.epicgames.com/community/learning/talks-and-demos/76WD/unreal-engine-making-better-blueprints-unreal-fest-2022" target="_blank">Epic Developer Community: Making Better Blueprints</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/blueprint-best-practices-in-unreal-engine" target="_blank">Epic Games: Blueprint Best Practices</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/ufunctions-in-unreal-engine" target="_blank">Epic Games: UFunctions</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-ticking-in-unreal-engine" target="_blank">Epic Games: Actor Ticking</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/gameplay-timers-in-unreal-engine" target="_blank">Epic Games: Gameplay Timers</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/referencing-assets-in-unreal-engine" target="_blank">Epic Games: Referencing Assets</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/asynchronous-asset-loading-in-unreal-engine" target="_blank">Epic Games: Asynchronous Asset Loading</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/blueprint-debugger-in-unreal-engine" target="_blank">Epic Games: Blueprint Debugger</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/an-overview-of-the-blueprint-header-view-in-unreal-engine" target="_blank">Epic Games: Blueprint Header View</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/construction-script-in-unreal-engine" target="_blank">Epic Games: Construction Script</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/actor-communication-in-unreal-engine" target="_blank">Epic Games: Actor Communication</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/data-assets-in-unreal-engine" target="_blank">Epic Games: Data Assets</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/programming-subsystems-in-unreal-engine" target="_blank">Epic Games: Programming Subsystems</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/unreal-engine-5.0-release-notes?application_version=5.0" target="_blank">Epic Games: UE 5.0 release notes</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/fix-unreal-engine-struct-replication-crash/</id>
      <title type="text">Unreal Engine replicated USTRUCT 배열 assertion을 정확히 고치기</title>
      <published>2023-01-21T00:00:00.000Z</published>
      <updated>2023-01-21T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/fix-unreal-engine-struct-replication-crash/"/>
      <summary type="text">반사 가능한 내부 필드가 없는 USTRUCT 배열을 복제할 때 발생한 FRepLayout assertion의 원인과, UPROPERTY·NotReplicated·custom NetSerialize의 정확한 경계를 설명합니다.</summary>
      <content type="html"><![CDATA[<p><code>USTRUCT</code> 배열을 replicated property로 사용한 뒤 첫 원소를 추가했을 때, <code>FRepLayout</code> 내부의 다음 assertion에서 실행이 멈추기도 한다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>check</span><span>(</span></div></div><div><div><div>2</div></div><div><span>    </span><span>ArrayNum</span><span> </span><span>&lt;</span><span> </span><span>ShadowArrayNum</span><span> </span><span>||</span></div></div><div><div><div>3</div></div><div><span>    </span><span>SharedParams</span><span>.</span><span>Cmds</span><span>[</span><span>CmdIndex</span><span> </span><span>+</span><span> </span><span>1</span><span>].</span><span>Type</span><span> </span><span>==</span><span> ERepLayoutCmdType::</span><span>DynamicArray</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p>이 문제는 구조체 내부에서 전송할 멤버에 <code>UPROPERTY()</code>를 붙여 해결한다. 규칙은 간단하다.</p>
<blockquote><p>기본 reflection 기반 복제로 클라이언트에 보낼 <code>USTRUCT</code> 내부 필드는 모두 <code>UPROPERTY()</code>로 반사되어야 한다. 로컬 전용 C++ 필드까지 무조건 <code>UPROPERTY</code>여야 하는 것은 아니며 custom serializer는 또 다른 계약을 사용한다.</p></blockquote>
<p>이 특정 assertion은 구조체 하나가 아니라 <strong>동적 배열 비교 경로</strong>에서 나온다. 실제 선언과 call stack이 <code>UPROPERTY(Replicated) TArray&lt;FMagazine...&gt;</code>처럼 빈 replication layout의 구조체 배열이 커진 상황과 일치하는지도 함께 확인해야 한다.</p>
<section><h2>문제가 된 구조체<a href="#문제가-된-구조체"><span>#</span></a></h2><p>문제가 된 구조체는 아래 형태였다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FMagazine_NetQuantize</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>    </span><span>FGuid</span><span> </span><span>MagazineId</span><span>;</span></div></div><div><div><div>7</div></div><div><span>    </span><span>int32</span><span> </span><span>AmmoId</span><span> </span><span>=</span><span> </span><span>INDEX_NONE</span><span>;</span></div></div><div><div><div>8</div></div><div><span>    </span><span>int32</span><span> </span><span>AmmoAmount</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>9</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>GENERATED_BODY()</code>가 있으므로 <code>FMagazine_NetQuantize</code>라는 script struct 타입 자체는 Unreal reflection system에 등록된다. 하지만 세 멤버는 일반 C++ 필드일 뿐 <code>FProperty</code>로 등록되지 않는다. reflection이 보는 내부 구조는 비어 있다.</p><p>이 타입이 다음처럼 Actor의 replicated array에 들어갔다고 가정해 보자.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>)</span></div></div><div><div><div>2</div></div><div><span>TArray</span><span>&lt;</span><span>FMagazine_NetQuantize</span><span>&gt;</span><span> </span><span>Magazines</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>replication은 배열 값과 shadow state를 비교하면서 내부 reflected property를 따라 change handle을 만든다. 실제 배열이 shadow보다 커졌는데 원소 내부에서 만들 수 있는 handle이 하나도 없으면, 배열 command가 기대한 형태와 맞지 않아 위 assertion에 도달할 수 있다.</p><p>원인은 C++ 구조체의 byte 수가 0이어서가 아니다. C++에는 세 필드가 있지만 <strong>기본 복제 스키마가 보는 필드 수가 0개</strong>라는 불일치가 핵심이다.</p></section>
<section><h2>최소 재현: 구조체 하나에서도 같은 계약<a href="#최소-재현-구조체-하나에서도-같은-계약"><span>#</span></a></h2><p>같은 문제는 이름이나 실제 데이터 종류와 무관하게 더 작은 구조체에서도 만들 수 있다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FReplicatedPayload</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>uint8</span><span>&gt;</span><span> </span><span>Data</span><span>;</span></div></div><div><div><div>7</div></div><div><span>};</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>10</div></div><div><span>class</span><span> </span><span>APayloadOwner</span><span> : </span><span>public</span><span> </span><span>AActor</span></div></div><div><div><div>11</div></div><div><span>{</span></div></div><div><div><div>12</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>)</span></div></div><div><div><div>15</div></div><div><span>    </span><span>FReplicatedPayload</span><span> </span><span>Payload</span><span>;</span></div></div><div><div><div>16</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>바깥 <code>Payload</code> property가 replicated라고 해도 내부 <code>Data</code>는 reflection에 등록되지 않는다. Actor가 <strong>구조체를 전송 대상으로 등록하는 것</strong>과 구조체가 <strong>전송할 내부 field를 제공하는 것</strong>은 서로 다른 계약이다. 기본 serializer를 사용한다면 내부 선언도 다음처럼 반사해야 한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>()</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FReplicatedPayload</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>7</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>uint8</span><span>&gt;</span><span> </span><span>Data</span><span>;</span></div></div><div><div><div>8</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 최소 사례는 배열 assertion이 아니더라도 “바깥 property에 <code>Replicated</code>를 붙였으니 구조체 내부 일반 C++ field도 자동 전송된다”는 오해를 판별하는 데 유용하다. 실제 오류가 <code>TArray&lt;FReplicatedPayload&gt;</code>에서 발생했다면 아래의 동적 배열 진단과 함께 적용한다.</p></section>
<section><h2>기본 reflection 복제에 맞는 수정<a href="#기본-reflection-복제에-맞는-수정"><span>#</span></a></h2><p>클라이언트로 보내야 하는 필드를 모두 반사하고 Actor의 바깥 property를 replicated property로 등록한다.</p><div><div><div><figure><figcaption><span>MagazineNetQuantize.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"MagazineNetQuantize.generated.h"</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>USTRUCT</span><span>(</span><span>BlueprintType</span><span>)</span></div></div><div><div><div>7</div></div><div><span>struct</span><span> </span><span>FMagazine_NetQuantize</span></div></div><div><div><div>8</div></div><div><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>12</div></div><div><span>    </span><span>FGuid</span><span> </span><span>MagazineId</span><span>;</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>15</div></div><div><span>    </span><span>int32</span><span> </span><span>AmmoId</span><span> </span><span>=</span><span> </span><span>INDEX_NONE</span><span>;</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>18</div></div><div><span>    </span><span>int32</span><span> </span><span>AmmoAmount</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>19</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>구조체 내부 필드에는 다시 <code>Replicated</code>를 붙이지 않는다. Actor의 <code>Magazines</code>가 replication 단위이며 기본 struct serializer가 그 안의 reflected field를 순회하기 때문이다.</p><div><div><div><figure><figcaption><span>MagazineOwner.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"GameFramework/Actor.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"MagazineNetQuantize.h"</span></div></div><div><div><div>6</div></div><div><span>#include</span><span> </span><span>"MagazineOwner.generated.h"</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>9</div></div><div><span>class</span><span> YOURGAME_API </span><span>AMagazineOwner</span><span> : </span><span>public</span><span> </span><span>AActor</span></div></div><div><div><div>10</div></div><div><span>{</span></div></div><div><div><div>11</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>public:</span></div></div><div><div><div>14</div></div><div><span>    </span><span>AMagazineOwner</span><span>();</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>    </span><span>virtual</span><span> </span><span>void</span><span> </span><span>GetLifetimeReplicatedProps</span><span>(</span></div></div><div><div><div>17</div></div><div><span>        </span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span><span> </span><span>override</span><span>;</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>protected:</span></div></div><div><div><div>20</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>ReplicatedUsing</span><span> </span><span>=</span><span> </span><span>OnRep_Magazines</span><span>)</span></div></div><div><div><div>21</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>FMagazine_NetQuantize</span><span>&gt;</span><span> </span><span>Magazines</span><span>;</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>    </span><span>UFUNCTION</span><span>()</span></div></div><div><div><div>24</div></div><div><span>    </span><span>void</span><span> </span><span>OnRep_Magazines</span><span>();</span></div></div><div><div><div>25</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><div><div><figure><figcaption><span>MagazineOwner.cpp</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"MagazineOwner.h"</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>"Net/UnrealNetwork.h"</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>AMagazineOwner</span><span>::</span></span><span>AMagazineOwner</span><span>()</span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>bReplicates</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>void</span><span><span> </span><span>AMagazineOwner</span><span>::</span></span><span>GetLifetimeReplicatedProps</span><span>(</span></div></div><div><div><div>10</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span></div></div><div><div><div>11</div></div><div><span>{</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>Super::</span><span>GetLifetimeReplicatedProps</span><span>(</span><span>OutLifetimeProps</span><span>);</span></div></div><div><div><div>13</div></div><div><span>    </span><span>DOREPLIFETIME</span><span>(</span><span>AMagazineOwner</span><span>, </span><span>Magazines</span><span>);</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>void</span><span><span> </span><span>AMagazineOwner</span><span>::</span></span><span>OnRep_Magazines</span><span>()</span></div></div><div><div><div>17</div></div><div><span>{</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>// 클라이언트 표시나 조회 캐시를 새 최종 상태로 갱신한다.</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이 구성은 네 층의 계약을 모두 갖춰야 한다.</p><ol>
<li><code>AMagazineOwner</code>가 실제로 복제되도록 <code>bReplicates = true</code>다.</li>
<li>바깥 property에 <code>Replicated</code> 또는 <code>ReplicatedUsing</code>이 있다.</li>
<li><code>GetLifetimeReplicatedProps</code>가 <code>Super</code>를 호출하고 <code>DOREPLIFETIME</code>으로 property를 등록한다.</li>
<li>기본 serializer로 보낼 struct field에 <code>UPROPERTY()</code>가 있다.</li>
</ol><p>하나라도 빠지면 assertion이 사라졌더라도 원하는 데이터가 전송된다는 보장은 없다.</p></section>
<section><h2>“모든 멤버”가 아니라 “보낼 멤버”<a href="#모든-멤버가-아니라-보낼-멤버"><span>#</span></a></h2><p>구조체 안에는 서버나 클라이언트가 로컬로만 계산하는 캐시가 있기도 한다. 그 값은 복제 스키마에서 명시적으로 제외하면 된다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USTRUCT</span><span>(</span><span>BlueprintType</span><span>)</span></div></div><div><div><div>2</div></div><div><span>struct</span><span> </span><span>FMagazine_NetQuantize</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>7</div></div><div><span>    </span><span>FGuid</span><span> </span><span>MagazineId</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>10</div></div><div><span>    </span><span>int32</span><span> </span><span>AmmoId</span><span> </span><span>=</span><span> </span><span>INDEX_NONE</span><span>;</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>13</div></div><div><span>    </span><span>int32</span><span> </span><span>AmmoAmount</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>NotReplicated</span><span>, </span><span>Transient</span><span>)</span></div></div><div><div><div>16</div></div><div><span>    </span><span>int32</span><span> </span><span>LocalDisplaySortKey</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>17</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-uproperties" target="_blank">Unreal Engine property 문서</a>는 <code>NotReplicated</code>가 struct member의 기본 복제를 건너뛰는 specifier라고 설명한다. 이렇게 선언하면 필드는 reflection, editor tooling, 복사나 다른 serialization에 남겨 두되 네트워크 스키마에서는 제외한다는 의도가 분명해진다. <code>Transient</code>는 일반 asset/save serialization에서도 유지하지 않겠다는 별도 선택이다.</p><p>평범한 non-<code>UPROPERTY</code> C++ 필드를 로컬 캐시로 두는 방법도 있다. 다만 아래 차이는 감수해야 한다.</p><ul>
<li>reflection 기반 복제, 저장, editor 노출과 property 비교가 그 필드를 보지 못한다.</li>
<li>reflection이 수행하는 struct 복사나 초기화 경로에서 기대한 의미가 유지되는지 별도로 검증해야 한다.</li>
<li><code>UObject</code> 참조를 추적해야 하는 필드를 무심코 non-<code>UPROPERTY</code>로 두면 garbage collection과 수명 관리가 깨질 위험이 있다.</li>
<li>복제 상태와 로컬 캐시가 함께 복사되거나 reset되는 시점을 코드가 직접 책임져야 한다.</li>
</ul><p>따라서 단순 POD 파생 캐시가 아니라 엔진 수명과 serialization에 참여해야 하는 값이라면 <code>UPROPERTY(NotReplicated)</code>로 의도를 표현하는 편이 안전하다.</p></section>
<section><h2>custom <code>NetSerialize</code>는 별도의 예외다<a href="#custom-netserialize는-별도의-예외다"><span>#</span></a></h2><p>기본 reflected serializer 대신 구조체가 직접 bitstream을 정의할 수도 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>bool</span><span> </span><span>NetSerialize</span><span>(</span><span>FArchive</span><span>&amp;</span><span><span> </span><span>Ar</span><span>, </span></span><span>UPackageMap</span><span>*</span><span><span> </span><span>Map</span><span>, </span></span><span>bool&amp;</span><span><span> </span><span>bOutSuccess</span><span>);</span></span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>template</span><span> &lt;&gt;</span></div></div><div><div><div>4</div></div><div><span>struct</span><span> </span><span>TStructOpsTypeTraits</span><span>&lt;</span><span>FCompressedMagazineSnapshot</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>: </span><span>public</span><span> </span><span>TStructOpsTypeTraitsBase2</span><span>&lt;</span><span>FCompressedMagazineSnapshot</span><span>&gt;</span></div></div><div><div><div>6</div></div><div><span>{</span></div></div><div><div><div>7</div></div><div><span>    </span><span>enum</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span>        </span><span>WithNetSerializer</span><span> </span><span>=</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>};</span></div></div><div><div><div>11</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>WithNetSerializer = true</code>가 올바르게 등록되면 <code>FStructProperty::NetSerializeItem</code>은 해당 native 함수를 호출한다. 이 경우 함수가 일반 C++ field를 직접 encoding하므로 “모든 field는 <code>UPROPERTY</code>여야 한다”는 기본 serializer 규칙의 예외가 된다.</p><p>그러나 <code>WithNetSerializer</code>는 전송 형식만 바꾼다. non-<code>UPROPERTY</code> 상태의 <strong>변경 감지</strong>까지 자동으로 해결하지는 않는다. 기본 <code>UScriptStruct</code> 비교는 reflected property를 사용하므로 custom payload만 바뀌었을 때 새 값이 dirty로 잡히지 않는 경우가 있다. 이런 상태를 직접 직렬화한다면 정확한 <code>operator==</code>와 <code>WithIdenticalViaEquality</code>, 또는 <code>Identical</code> 구현과 <code>WithIdentical</code> trait까지 함께 설계해야 한다. 배열에 첫 원소를 추가할 때는 전송되지만 기존 원소의 native field만 바꾸면 누락되는 식의 교묘한 버그가 생기기도 한다.</p><p>custom serializer를 선택하면 더 큰 책임이 따른다.</p><ul>
<li>송신과 수신은 정확히 같은 field 순서와 bit 수를 사용해야 한다.</li>
<li>enum, index, count, string 길이와 정수 범위는 역직렬화 전에 제한한다.</li>
<li><code>UObject</code> 참조는 raw address가 아니라 <code>UPackageMap</code>을 통한 network reference로 처리해야 한다.</li>
<li>이전 클라이언트와 protocol version 호환성을 설계해 둔다.</li>
<li>equality/change detection과 실패 시 <code>bOutSuccess</code> 의미를 검증해 둔다.</li>
</ul><p>구조체 이름에 <code>_NetQuantize</code>를 붙이는 것만으로 quantization이나 <code>NetSerialize</code>가 생기지 않는다. 엔진의 <code>FVector_NetQuantize</code> 같은 타입은 이름 때문이 아니라 실제 serializer와 traits가 구현되어 있어 동작한다.</p></section>
<section><h2>assertion을 만났을 때의 진단 순서<a href="#assertion을-만났을-때의-진단-순서"><span>#</span></a></h2><section><h3>1. assertion 줄보다 전체 call stack을 본다<a href="#1-assertion-줄보다-전체-call-stack을-본다"><span>#</span></a></h3><p>같은 <code>FRepLayout</code> 함수라도 배열 비교, shadow state, custom delta serialization 등 진입 경로가 서로 다르기도 한다. 이 진단은 call stack이 <code>CompareProperties_Array_r</code> 계열로 이어지고 실제 property가 <code>TArray&lt;USTRUCT&gt;</code>이며 inner reflected field가 0개일 때 직접 적용된다.</p></section><section><h3>2. 최소 스키마를 적는다<a href="#2-최소-스키마를-적는다"><span>#</span></a></h3><p>다음 네 가지를 한 화면에 놓고 확인한다.</p><ul>
<li>바깥 Actor/Component가 복제되는가?</li>
<li>바깥 property가 <code>UPROPERTY(Replicated...)</code>인가?</li>
<li><code>DOREPLIFETIME</code> 또는 해당 등록 macro가 있는가?</li>
<li>중첩 struct에서 전송할 field가 <code>UPROPERTY()</code>인가?</li>
</ul><p>중첩 타입에 기본 복제가 지원되지 않는 Map/Set이나 delegate가 들어 있지 않은지도 확인한다. Map/Set은 <a href="/posts/unreal-engine-networking-containers-and-deferred-spawn/">배열 대리 표현이나 Fast Array로 바꾸는 편</a>이 기본 해법이다.</p></section><section><h3>3. reflected layout 변경 뒤 프로세스를 새로 시작한다<a href="#3-reflected-layout-변경-뒤-프로세스를-새로-시작한다"><span>#</span></a></h3><p><code>USTRUCT</code>, <code>UPROPERTY</code>, container type을 바꾸면 UHT가 만드는 타입 정보와 이미 생성된 replication layout, Actor channel의 shadow state가 모두 영향을 받는다. Live Coding의 object reinstancing이 많은 변경을 처리하더라도, 실행 중인 멀티플레이 세션의 기존 layout까지 안전하게 교체됐다고 가정하지 않는다.</p><ol>
<li>PIE, standalone client와 server를 모두 종료한다.</li>
<li>editor도 닫아 둔다.</li>
<li>정상적인 전체 target build를 수행한다.</li>
<li>새 editor/process에서 다시 재현해 본다.</li>
</ol><p>반사 변경 직후에만 문제가 남는다면 stale binary와 generated code도 조사한다. 무작정 <code>Intermediate</code> 전체를 지우기 전에 source control 상태와 정확한 build target을 먼저 확인한다.</p></section><section><h3>4. 빈 배열만 보지 말고 생명주기 전체를 테스트한다<a href="#4-빈-배열만-보지-말고-생명주기-전체를-테스트한다"><span>#</span></a></h3><p>다음 순서를 dedicated server 또는 별도 server/client process에서 확인한다.</p><ol>
<li>초기 빈 배열로 접속한다.</li>
<li>서버에서 첫 원소를 추가해 본다.</li>
<li>기존 원소의 각 field를 하나씩 변경한다.</li>
<li>두 번째 원소를 추가하고 첫 원소는 제거한다.</li>
<li>late join client가 최종 배열 전체를 받는지 확인해 본다.</li>
<li>클라이언트 <code>OnRep</code>가 event log가 아니라 최종 상태 적용으로 동작하는지도 살핀다.</li>
</ol><p>이 assertion은 특히 “0개에서 1개로 증가”하는 순간에 드러나기도 한다. 초기 값만 비교하는 테스트로는 놓치기 쉽다.</p></section></section>
<section><h2>빠른 판별표<a href="#빠른-판별표"><span>#</span></a></h2>

<table><thead><tr><th>증상</th><th>우선 확인할 것</th></tr></thead><tbody><tr><td>첫 struct array 항목 추가 시 assertion</td><td>inner struct에 reflected field가 0개인지 확인</td></tr><tr><td>crash는 없지만 값이 전혀 안 옴</td><td>전송할 field에 <code>UPROPERTY</code>가 있는지 확인</td></tr><tr><td>일부 field만 안 옴</td><td>그 field의 <code>UPROPERTY</code> 또는 <code>NotReplicated</code> 여부 확인</td></tr><tr><td>Actor property 전체가 안 옴</td><td><code>bReplicates</code>, outer specifier, <code>DOREPLIFETIME</code>, relevancy 확인</td></tr><tr><td>수정 후에만 이상한 layout crash</td><td>PIE 종료, full rebuild, process restart 후 재현</td></tr><tr><td><code>_NetQuantize</code> 이름인데 압축되지 않음</td><td>실제 <code>NetSerialize</code>와 traits 구현 여부 확인</td></tr></tbody></table><p>핵심은 단순히 macro를 더 붙이는 데 있지 않다. C++ memory layout, Unreal reflection layout, network serialization layout은 서로 다르다. 기본 복제를 사용할 때는 <strong>바깥 property와 전송할 내부 field를 모두 reflection에 명시하고</strong>, 제외할 field는 의도적으로 표시한다. custom serializer를 선택한 순간부터는 wire format과 변경 감지를 직접 책임져야 한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/replicate-actor-properties-in-unreal-engine" target="_blank">Epic Games: Replicate Actor Properties</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-uproperties" target="_blank">Epic Games: Unreal Engine UProperties</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/UScriptStruct/ICppStructOps/NetSerialize" target="_blank">Epic Games: <code>UScriptStruct::ICppStructOps::NetSerialize</code></a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/make-jekyll-blog-searchable-on-google/</id>
      <title type="text">Jekyll 블로그를 Google Search Console에 연결하는 방법</title>
      <published>2023-01-21T00:00:00.000Z</published>
      <updated>2023-01-21T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/make-jekyll-blog-searchable-on-google/"/>
      <summary type="text">Jekyll·Chirpy 블로그를 URL-prefix 속성에 추가하고 HTML meta tag로 소유권을 확인한 뒤 sitemap과 색인 상태를 점검하는 순서를 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Jekyll과 Chirpy로 만든 블로그를 Google Search Console의 <strong>URL-prefix 속성</strong>에 추가하고 HTML meta tag로 소유권을 확인하는 과정을 정리한다.</p>
<p>소유권 확인은 검색 노출 완료와 다르다. Search Console을 사용할 권한을 증명할 뿐이며, Google이 URL을 발견하고 크롤링하고 색인한 다음 검색 결과에 보여 주는 과정은 각각 별개다. sitemap 제출과 색인 생성 요청도 다음 단계를 보장하지 않는다.</p>
<div><div><div><div><span><p>다음 단계 보장 없음</p></span></div><div><span><p>요청일 뿐</p></span></div><div><span></span></div><div><span></span></div><div><span><p>소유권 확인<br /><br />Search Console 접근 권한</p></span></div><div><span><p>sitemap 제출·URL 검사<br /><br />발견과 재크롤 힌트</p></span></div><div><span><p>Googlebot 크롤링</p></span></div><div><span><p>색인 여부 판단</p></span></div><div><span><p>질의별 검색 노출·순위</p></span></div>
</div><div><div><span><p>다음 단계 보장 없음</p></span></div><div><span><p>요청일 뿐</p></span></div><div><span></span></div><div><span></span></div><div><span><p>소유권 확인<br /><br />Search Console 접근 권한</p></span></div><div><span><p>sitemap 제출·URL 검사<br /><br />발견과 재크롤 힌트</p></span></div><div><span><p>Googlebot 크롤링</p></span></div><div><span><p>색인 여부 판단</p></span></div><div><span><p>질의별 검색 노출·순위</p></span></div>
</div></div></div>
<section><h2>먼저 공개 상태 확인하기<a href="#먼저-공개-상태-확인하기"><span>#</span></a></h2><p>Search Console은 잘못된 배포를 고쳐 주지 않는다. 실제 공개 URL에서 다음을 먼저 확인한다.</p>

<table><thead><tr><th>점검 항목</th><th>정상 상태</th><th>흔한 실패</th></tr></thead><tbody><tr><td>HTTP 응답</td><td>공개할 페이지가 로그인 없이 <code>200 OK</code></td><td>잘못된 GitHub Pages 경로로 404, redirect loop, 인증 필요</td></tr><tr><td>robots meta</td><td>색인할 페이지가 <code>index, follow</code></td><td>theme이나 frontmatter가 의도치 않게 <code>noindex</code></td></tr><tr><td><code>robots.txt</code></td><td>Googlebot이 필요한 HTML·CSS·JS를 읽을 수 있음</td><td>필요한 페이지나 리소스를 차단</td></tr><tr><td>canonical</td><td>실제 공개 URL과 일치하는 absolute self-canonical</td><td>localhost, 잘못된 domain·base path·trailing slash</td></tr><tr><td>sitemap</td><td>공개할 canonical URL을 포함</td><td>404, redirect, 중복 URL을 함께 제출</td></tr><tr><td>HTTPS와 host 일관성</td><td>내부 링크·canonical·sitemap이 같은 HTTPS host를 사용</td><td>custom domain과 <code>github.io</code> 주소가 섞임</td></tr><tr><td>본문 접근성</td><td>핵심 정보가 처음 받은 HTML 또는 렌더링 가능한 페이지에 들어 있음</td><td>상호작용 뒤에만 핵심 내용을 표시</td></tr></tbody></table><p><code>robots.txt</code>는 크롤링 제어 파일이다. 검색 결과에서 확실히 제외하려면 crawler가 페이지를 읽을 수 있는 상태에서 <code>noindex</code>를 사용한다.</p></section>
<section><h2>URL-prefix 속성 만들기<a href="#url-prefix-속성-만들기"><span>#</span></a></h2><ol>
<li><a href="https://search.google.com/search-console" target="_blank">Google Search Console</a>에 로그인한다.</li>
<li><strong>속성 추가 → URL 접두어</strong>를 선택한다.</li>
<li>블로그의 최종 공개 주소를 입력한다.</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://example.github.io/</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>http</code>와 <code>https</code>, <code>www</code> 유무, subdomain, path가 다르면 다른 URL-prefix 범위다. GitHub project site라면 <code>/repository-name/</code>까지 실제 배포 주소와 일치시킨다.</p><p>DNS를 직접 관리하는 custom domain이라면 Domain 속성과 DNS TXT 확인을 사용할 수도 있다. <code>github.io</code> 주소처럼 host의 DNS를 관리할 수 없다면 URL-prefix와 HTML tag 또는 HTML file 방식이 알맞다.</p></section>
<section><h2>HTML tag로 소유권 확인하기<a href="#html-tag로-소유권-확인하기"><span>#</span></a></h2><section><h3>1. meta tag의 <code>content</code> 값 복사<a href="#1-meta-tag의-content-값-복사"><span>#</span></a></h3><p>소유권 확인 창에서 <strong>HTML 태그</strong>를 선택하면 다음 형태의 meta tag를 받는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>meta</span><span> </span><span>name</span><span>=</span><span>"google-site-verification"</span><span> </span><span>content</span><span>=</span><span>"SEARCH_CONSOLE에서_받은_값"</span><span> /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>전체 tag가 아니라 <code>content</code> 속성의 값만 복사한다. verification token은 공개 HTML에 들어가는 값이지만, 다른 계정 정보나 화면 전체를 함께 공유할 필요는 없다.</p></section><section><h3>2. Jekyll 설정에 token 추가<a href="#2-jekyll-설정에-token-추가"><span>#</span></a></h3><p>이 글에서 사용한 Chirpy 설정은 <code>_config.yml</code>의 <code>google_site_verification</code> 값을 <code>&lt;head&gt;</code>의 meta tag로 출력한다.</p><div><figure><figcaption><span>_config.yml</span></figcaption><pre><code><div><div><div>1</div></div><div><span>google_site_verification</span><span>: </span><span>'SEARCH_CONSOLE에서_받은_값'</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>theme마다 설정 이름이 다를 수 있으므로, 사용 중인 layout이 최종 HTML에 다음 tag를 만드는지 확인한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>meta</span><span> </span><span>name</span><span>=</span><span>"google-site-verification"</span><span> </span><span>content</span><span>=</span><span>"SEARCH_CONSOLE에서_받은_값"</span><span> /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3. 배포 후 페이지 소스 확인<a href="#3-배포-후-페이지-소스-확인"><span>#</span></a></h3><p>설정을 push하고 GitHub Pages 배포가 끝날 때까지 기다린다. 그다음 블로그 홈페이지의 <strong>페이지 소스 보기</strong>에서 <code>google-site-verification</code>을 검색한다.</p><p>로컬 파일이나 브라우저 extension이 바꾼 DOM이 아니라, Google이 접근할 공개 URL의 <code>&lt;head&gt;</code>에 tag가 있어야 한다. URL-prefix가 redirect된다면 최종 도착 페이지에서도 tag를 읽을 수 있어야 한다.</p></section><section><h3>4. Search Console에서 확인<a href="#4-search-console에서-확인"><span>#</span></a></h3><p>배포된 tag를 확인한 뒤 소유권 확인 창의 <strong>확인</strong>을 누른다. 확인에 성공한 뒤에도 meta tag를 지우지 않는다. Google이 token을 다시 확인할 수 있기 때문이다.</p></section></section>
<section><h2>canonical과 sitemap 점검하기<a href="#canonical과-sitemap-점검하기"><span>#</span></a></h2><p>Jekyll의 공개 URL은 <code>_config.yml</code>의 <code>url</code>, <code>baseurl</code>, permalink 설정에 영향을 받는다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>url</span><span>: </span><span>'https://example.github.io'</span></div></div><div><div><div>2</div></div><div><span>baseurl</span><span>: </span><span>''</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>GitHub user site는 보통 root <code>/</code>를 사용한다. <code>https://example.github.io/repository/</code> 형태의 project site라면 <code>baseurl</code>과 내부 링크가 project 경로를 포함해야 한다.</p><p>Chirpy와 <code>jekyll-sitemap</code>을 사용하는 사이트라면 배포 뒤 다음 URL을 직접 연다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://example.github.io/robots.txt</span></div></div><div><div><div>2</div></div><div><span>https://example.github.io/sitemap.xml</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>아래 항목을 확인한다.</p><ul>
<li>두 URL이 <code>200 OK</code>로 열리는가?</li>
<li>sitemap 안의 host와 path가 실제 공개 URL과 일치하는가?</li>
<li>각 URL이 공개 페이지로 열리고 올바른 canonical을 갖는가?</li>
<li>localhost나 이전 domain, 잘못된 base path가 남아 있지 않은가?</li>
<li><code>robots.txt</code>가 색인할 페이지를 막지 않는가?</li>
</ul><p>Search Console의 <strong>Sitemaps</strong> 화면에는 <code>sitemap.xml</code>을 제출한다. sitemap은 Google에 URL 목록을 알려 주는 힌트이며 다운로드, 크롤링, 색인을 보장하지 않는다.</p></section>
<section><h2>URL Inspection으로 글 확인하기<a href="#url-inspection으로-글-확인하기"><span>#</span></a></h2><p>새 글을 배포한 다음 Search Console 상단의 <strong>URL 검사</strong>에 canonical URL을 입력한다.</p><ol>
<li>Google이 알고 있는 색인 상태를 확인한다.</li>
<li>user-declared canonical과 Google-selected canonical이 같은지 본다.</li>
<li><strong>실제 URL 테스트</strong>로 검사 도구가 페이지에 접근할 수 있는지 확인한다.</li>
<li>중요한 새 페이지나 크게 바꾼 페이지라면 <strong>색인 생성 요청</strong>을 한 번 사용한다.</li>
</ol><p>실제 URL 테스트 성공은 접근 가능하다는 뜻일 뿐, 중복 선택과 content quality를 포함한 모든 색인 조건을 통과했다는 뜻은 아니다. 같은 URL을 반복 제출한다고 더 빨라지는 것도 아니다. URL이 많다면 하나씩 요청하지 말고 sitemap을 사용한다.</p></section>
<section><h2>검색 노출 확인하기<a href="#검색-노출-확인하기"><span>#</span></a></h2><p><code>site:example.github.io</code> 검색 결과 개수만으로 전체 색인 상태를 판단하지 않는다. Search Console의 각 report가 답하는 질문을 구분한다.</p>

<table><thead><tr><th>질문</th><th>Search Console 화면</th><th>확인할 값</th></tr></thead><tbody><tr><td>Google이 sitemap을 읽었는가?</td><td>Sitemaps</td><td>fetch 상태, 마지막 읽기, 발견한 URL 수</td></tr><tr><td>특정 URL이 색인됐는가?</td><td>URL Inspection</td><td>index 상태, crawl 시각, user/Google canonical</td></tr><tr><td>전체 URL의 색인 추세는 어떤가?</td><td>Page indexing</td><td>indexed/not indexed 이유와 추세</td></tr><tr><td>실제 검색 결과에 나타났는가?</td><td>Performance → Search results</td><td>impressions, clicks, CTR, query, page, country, device</td></tr><tr><td>방문 뒤 사용자는 무엇을 했는가?</td><td>Google Analytics 등 별도 도구</td><td>session과 site 내부 행동. Search Console의 역할이 아님</td></tr></tbody></table><p>새 속성은 report data가 쌓이는 데 시간이 걸릴 수 있다. Search Console report의 수치와 <code>site:</code> 결과가 다르다고 바로 색인 장애로 결론 내리지 않는다.</p></section>
<section><h2>문제별 진단표<a href="#문제별-진단표"><span>#</span></a></h2>

<table><thead><tr><th>증상</th><th>먼저 확인할 것</th></tr></thead><tbody><tr><td>HTML tag 확인 실패</td><td>정확한 URL-prefix, 공개 <code>&lt;head&gt;</code>, 200 응답, redirect 최종 페이지, token 오타, 배포 완료 여부</td></tr><tr><td>sitemap을 가져오지 못함</td><td>absolute URL, 200 응답, XML 문법, robots, 잘못된 base path, 이전 domain</td></tr><tr><td>Crawled - currently not indexed</td><td>중복·얇은 내용·canonical·soft 404·내부 링크</td></tr><tr><td>Discovered - currently not indexed</td><td>server 안정성, 내부 링크, sitemap 상태</td></tr><tr><td>Google canonical이 다른 URL</td><td>redirect, 내부 링크, canonical, sitemap 등 신호 불일치</td></tr><tr><td>색인됐지만 impression이 없음</td><td>실제 검색 수요, query 의도, 제목·본문의 관련성, 경쟁도</td></tr></tbody></table></section>
<section><h2>체크리스트<a href="#체크리스트"><span>#</span></a></h2><ul>
<li>공개 URL이 로그인 없이 200으로 열리는가?</li>
<li>색인할 페이지가 <code>index, follow</code>이고 <code>robots.txt</code>에 막히지 않았는가?</li>
<li>canonical, 내부 링크, sitemap의 protocol·host·path가 일치하는가?</li>
<li>GitHub user site와 project site의 base path 차이를 반영했는가?</li>
<li>관리하는 주소에 맞춰 URL-prefix 또는 Domain 속성을 선택했는가?</li>
<li>verification meta tag를 확인 뒤에도 유지하는가?</li>
<li>sitemap을 제출하고 fetch 상태를 확인했는가?</li>
<li>중요한 URL만 실제 URL 테스트와 색인 생성 요청을 사용했는가?</li>
<li>Page indexing과 Performance를 구분해 측정하는가?</li>
</ul><p>소유권 확인만으로 끝나지 않는다. 실제 검색 상태를 관리하려면 crawl 가능한 페이지, 일관된 canonical, 올바른 sitemap과 robots 정책, URL Inspection과 Page indexing report를 함께 봐야 한다. 검색 결과에 실제로 나타났는지는 Performance의 impression과 query로 확인한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://www.irgroup.org/posts/jekyll-google-search/" target="_blank">Jekyll 테마에서 구글 검색 노출 등록하기</a></li>
<li><a href="https://search.google.com/search-console" target="_blank">Google Search Console</a></li>
<li><a href="https://support.google.com/webmasters/answer/9008080" target="_blank">Search Console 소유권 확인</a></li>
<li><a href="https://support.google.com/webmasters/answer/7451001" target="_blank">Search Console Sitemaps report</a></li>
<li><a href="https://support.google.com/webmasters/answer/9012289" target="_blank">Search Console URL Inspection</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-construction-script-best-practices/</id>
      <title type="text">Unreal Engine Construction Script를 안전하게 사용하는 법</title>
      <published>2023-01-19T00:00:00.000Z</published>
      <updated>2023-01-19T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-construction-script-best-practices/"/>
      <summary type="text">Construction Script와 C++ OnConstruction의 정확한 호출 시점, 에디터 재실행 비용과 순환 변경 위험, 멱등하고 빠른 제작 도구로 설계하는 방법을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Blueprint Construction Script는 레벨에 배치한 Actor의 파라미터를 바꾸는 즉시 결과를 보여 주는 강력한 제작 도구다. 바닥을 추적해 메시를 고르거나, 구간 수에 맞춰 울타리를 배치하거나, spline에서 미리보기 컴포넌트를 만드는 작업에 잘 맞는다.</p>
<p>문제는 이것을 생성 시 한 번만 실행되는 초기화 함수로 오해할 때 생긴다. Construction Script는 에디터에서 Actor를 옮기거나 프로퍼티를 바꿀 때 반복 실행될 수 있고, 재실행 과정에서 자동 생성 컴포넌트를 파괴하고 다시 만든다. 다른 Actor까지 변경하는 스크립트가 서로를 다시 구성하면 편집기가 멈추거나 결과가 실행 순서에 따라 달라질 수도 있다.</p>
<p>Construction Script를 안전하게 쓰려면 잦은 호출의 성능 비용과 순환 호출을 함께 피해야 한다. 핵심은 이를 <strong>빠르고 결정적이며 멱등인 파생 상태 생성기</strong>로 설계하는 것이다.</p>
<section><h2>Construction Script와 <code>OnConstruction</code>의 관계<a href="#construction-script와-onconstruction의-관계"><span>#</span></a></h2><p>Blueprint Class에는 <code>UserConstructionScript</code>, 즉 편집기의 <strong>Construction Script</strong> 그래프가 있다. C++ Actor는 비슷한 목적의 virtual hook인 <code>OnConstruction</code>을 override할 수 있다.</p><div><figure><figcaption><span>MyProceduralActor.h</span></figcaption><pre><code><div><div><div>1</div></div><div><span>virtual</span><span> </span><span>void</span><span> </span><span>OnConstruction</span><span>(</span><span>const</span><span> </span><span>FTransform</span><span>&amp;</span><span><span> </span><span>Transform</span><span>) </span></span><span>override</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption><span>MyProceduralActor.cpp</span></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AMyProceduralActor</span><span>::</span></span><span>OnConstruction</span><span>(</span><span>const</span><span> </span><span>FTransform</span><span>&amp;</span><span><span> </span><span>Transform</span><span>)</span></span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>Super::</span><span>OnConstruction</span><span>(</span><span>Transform</span><span>);</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>// 편집 가능한 입력으로부터 파생된 컴포넌트 상태를 재구성한다.</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>둘을 완전히 같은 단계라고 부르면 세부 순서를 놓친다. <code>ExecuteConstruction</code>은 대략 아래 순서로 흐른다.</p><ol>
<li>native component와 Blueprint의 Simple Construction Script 컴포넌트를 준비한다.</li>
<li>Blueprint <code>UserConstructionScript</code>를 실행한다.</li>
<li>Blueprint로 만든 컴포넌트까지 생성·등록한 뒤 virtual <code>OnConstruction</code>을 호출한다.</li>
</ol><p>C++ <code>OnConstruction</code>은 Blueprint Construction Script가 끝난 뒤 실행되는 마지막 알림에 가깝다. C++로 override할 때도 <code>Super::OnConstruction</code>을 호출하고, Blueprint 파생 클래스가 추가한 컴포넌트나 값을 어느 단계에서 읽는지 실제 클래스 계층으로 검증해야 한다.</p></section>
<section><h2>생성자, Construction, <code>BeginPlay</code>의 역할 분리<a href="#생성자-construction-beginplay의-역할-분리"><span>#</span></a></h2><p>“Construction Script를 쓰면 생성자나 <code>BeginPlay</code>를 쓰면 안 된다”는 식의 규칙은 정확하지 않다. 세 단계는 함께 사용하되 책임을 분리해야 한다.</p>

<table><thead><tr><th>단계</th><th>적합한 책임</th><th>피해야 할 전제</th></tr></thead><tbody><tr><td>C++ constructor</td><td>기본값, <code>CreateDefaultSubobject</code>, CDO에 안전한 설정</td><td>유효한 World, instance editable 값, 다른 Actor의 존재</td></tr><tr><td><code>PostLoad</code></td><td>디스크에서 읽은 직렬화 데이터의 버전 변환과 fixup</td><td>새로 spawn된 Actor에도 호출된다는 가정</td></tr><tr><td><code>PostActorCreated</code></td><td>새로 생성·spawn된 Actor의 construction 직전 처리</td><td>레벨에서 load된 Actor에도 호출된다는 가정</td></tr><tr><td>Construction Script / <code>OnConstruction</code></td><td>편집 가능한 입력에서 빠르게 다시 만들 수 있는 파생 상태와 미리보기</td><td>한 번만 실행됨, 외부 상태가 안정적임, gameplay가 시작됨</td></tr><tr><td><code>PostInitializeComponents</code> / <code>BeginPlay</code></td><td>컴포넌트 초기화 뒤 실제 gameplay 시작과 runtime 의존성 연결</td><td>에디터 미리보기에서도 호출됨</td></tr></tbody></table><p><code>PostLoad</code>와 <code>PostActorCreated</code>는 서로 배타적인 생성 경로다. 저장된 레벨의 Actor는 load 경로를 사용하고, <code>SpawnActor</code>로 만든 Actor는 creation 경로를 사용한다. 이 구분은 Construction Script의 런타임 동작을 이해하는 데 중요하다.</p></section>
<section><h2>언제 다시 실행되는가<a href="#언제-다시-실행되는가"><span>#</span></a></h2><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/blueprint-foundations" target="_blank">Blueprint Foundations</a>는 Blueprint Actor가 생성될 때 컴포넌트를 만든 다음 Construction Script를 실행하고, 에디터에서 transform이나 프로퍼티를 바꿀 때도 즉시 결과를 갱신한다고 설명한다. 엔진에는 그 밖에도 Blueprint compile/reinstance, undo/redo, component 편집, duplicate와 paste, 일부 level·sequencer 작업처럼 construction을 재실행하는 경로가 있다.</p>

<table><thead><tr><th>상황</th><th>일반적인 동작</th><th>설계 시 의미</th></tr></thead><tbody><tr><td>에디터에 Actor 배치</td><td>instance 생성과 함께 실행</td><td>저장 전부터 결과가 레벨 데이터에 반영될 수 있다</td></tr><tr><td>Details 프로퍼티 변경</td><td><code>PostEditChangeProperty</code> 경로에서 재실행 가능</td><td>한 글자를 바꾸는 동안에도 무거운 작업을 반복하지 않는다</td></tr><tr><td>이동·회전·크기 조정</td><td><code>PostEditMove</code> 경로에서 재실행 가능</td><td>drag 중 실행 설정이 켜져 있으면 여러 번 호출된다</td></tr><tr><td>Blueprint compile, undo/redo, duplicate</td><td>재구성될 수 있음</td><td>호출 횟수와 순서를 계약으로 삼지 않는다</td></tr><tr><td>gameplay 중 동적 <code>SpawnActor</code></td><td><code>ExecuteConstruction</code>을 거쳐 실행</td><td>월드가 시작됐다면 이어서 <code>BeginPlay</code>까지 갈 수 있다</td></tr><tr><td>일반적인 cooked level의 배치 Actor load</td><td>저장·cook된 결과를 읽고 <code>PostLoad</code> 경로 사용</td><td>게임 시작 때 Construction Script가 다시 실행된다고 의존하지 않는다</td></tr><tr><td>PIE의 기존 배치 Actor</td><td>editor world의 Actor를 복제해 초기화</td><td>새 spawn과 같은 construction 경로라고 가정하지 않는다</td></tr></tbody></table><p>Blueprint의 <strong>Run Construction Script on Drag</strong>가 켜져 있으면 Actor를 끌어 움직이는 동안 계속 재실행될 수 있다. 끄더라도 drag가 끝날 때 한 번은 실행된다. 위치를 따라 즉시 바뀌는 미리보기가 꼭 필요하지 않다면 이 설정을 끄는 것만으로도 편집기 hitch를 크게 줄일 수 있다.</p><p>배치 Actor가 패키지 실행 시 다시 구성되지 않는다는 사실은 특히 중요하다. 에디터나 cook 과정에서 만든 결과가 레벨에 직렬화되는 일반 경로에서는, 런타임에만 존재하는 subsystem 등록, 네트워크 권한 검사, 타이머 시작 같은 동작을 Construction Script에 넣으면 실행 여부가 생성 경로에 따라 달라진다. 그런 동작은 <code>BeginPlay</code>나 적합한 gameplay 초기화 단계로 옮긴다.</p><p>동적 spawn에서 Construction 전에 값을 넣어야 한다면 일반 <code>SpawnActor</code> 반환 뒤에 값을 대입하지 말고 <a href="/posts/unreal-engine-networking-containers-and-deferred-spawn/"><code>SpawnActorDeferred</code>와 <code>FinishSpawning</code> 사이에서 설정</a>한다.</p></section>
<section><h2>재실행 가능한 코드의 첫 번째 조건: 멱등성<a href="#재실행-가능한-코드의-첫-번째-조건-멱등성"><span>#</span></a></h2><p>멱등인 construction은 같은 입력으로 여러 번 실행해도 같은 결과를 만든다. “이전 결과에 하나 더 추가”하는 방식이 아니라, 자신이 관리하는 파생 상태를 초기화한 뒤 현재 입력으로 다시 구성한다.</p><p>다음은 한 native <code>UHierarchicalInstancedStaticMeshComponent</code>에 울타리 미리보기를 다시 채우는 예다. 입력 수에는 상한을 두고, 재실행 때 이전 instance를 먼저 지운다.</p><div><div><div><figure><figcaption><span>ProceduralFence.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"GameFramework/Actor.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"ProceduralFence.generated.h"</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>class</span><span> </span><span>UHierarchicalInstancedStaticMeshComponent</span><span>;</span></div></div><div><div><div>8</div></div><div><span>class</span><span> </span><span>UStaticMesh</span><span>;</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>11</div></div><div><span>class</span><span> YOURGAME_API </span><span>AProceduralFence</span><span> : </span><span>public</span><span> </span><span>AActor</span></div></div><div><div><div>12</div></div><div><span>{</span></div></div><div><div><div>13</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>public:</span></div></div><div><div><div>16</div></div><div><span>    </span><span>AProceduralFence</span><span>();</span></div></div><div><div><div>17</div></div><div><span>    </span><span>virtual</span><span> </span><span>void</span><span> </span><span>OnConstruction</span><span>(</span><span>const</span><span> </span><span>FTransform</span><span>&amp;</span><span><span> </span><span>Transform</span><span>) </span></span><span>override</span><span>;</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>private:</span></div></div><div><div><div>20</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>VisibleAnywhere</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Fence"</span><span>)</span></div></div><div><div><div>21</div></div><div><span>    </span><span>UHierarchicalInstancedStaticMeshComponent</span><span>*</span><span> </span><span>PreviewInstances</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Fence"</span><span>)</span></div></div><div><div><div>24</div></div><div><span>    </span><span>UStaticMesh</span><span>*</span><span> </span><span>SegmentMesh</span><span> </span><span>=</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Fence"</span><span>, </span><span>meta</span><span> </span><span>=</span><span> (</span><span>ClampMin</span><span> </span><span>=</span><span> </span><span>"0"</span><span>, </span><span>ClampMax</span><span> </span><span>=</span><span> </span><span>"1000"</span><span>))</span></div></div><div><div><div>27</div></div><div><span>    </span><span>int32</span><span> </span><span>SegmentCount</span><span> </span><span>=</span><span> </span><span>4</span><span>;</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>EditAnywhere</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Fence"</span><span>, </span><span>meta</span><span> </span><span>=</span><span> (</span><span>ClampMin</span><span> </span><span>=</span><span> </span><span>"1.0"</span><span>))</span></div></div><div><div><div>30</div></div><div><span>    </span><span>float</span><span> </span><span>SegmentSpacing</span><span> </span><span>=</span><span> </span><span><span>100.0</span><span>f</span></span><span>;</span></div></div><div><div><div>31</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><div><div><figure><figcaption><span>ProceduralFence.cpp</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"ProceduralFence.h"</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>"Components/HierarchicalInstancedStaticMeshComponent.h"</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>AProceduralFence</span><span>::</span></span><span>AProceduralFence</span><span>()</span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>PreviewInstances</span><span> </span><span>=</span></div></div><div><div><div>7</div></div><div><span>        </span><span>CreateDefaultSubobject</span><span>&lt;</span><span>UHierarchicalInstancedStaticMeshComponent</span><span>&gt;(</span></div></div><div><div><div>8</div></div><div><span>            </span><span>TEXT</span><span>(</span><span>"PreviewInstances"</span><span>));</span></div></div><div><div><div>9</div></div><div><span>    </span><span>SetRootComponent</span><span>(</span><span>PreviewInstances</span><span>);</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>void</span><span><span> </span><span>AProceduralFence</span><span>::</span></span><span>OnConstruction</span><span>(</span><span>const</span><span> </span><span>FTransform</span><span>&amp;</span><span><span> </span><span>Transform</span><span>)</span></span></div></div><div><div><div>13</div></div><div><span>{</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>Super::</span><span>OnConstruction</span><span>(</span><span>Transform</span><span>);</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>    </span><span>PreviewInstances</span><span>-&gt;</span><span>ClearInstances</span><span>();</span></div></div><div><div><div>17</div></div><div><span>    </span><span>PreviewInstances</span><span>-&gt;</span><span>SetStaticMesh</span><span>(</span><span>SegmentMesh</span><span>);</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>SegmentMesh</span><span>)</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>21</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>    </span><span>const</span><span> </span><span>int32</span><span> </span><span>SafeCount</span><span> </span><span>=</span><span> FMath::</span><span>Clamp</span><span>(</span><span>SegmentCount</span><span>, </span><span>0</span><span>, </span><span>1000</span><span>);</span></div></div><div><div><div>25</div></div><div><span>    </span><span>const</span><span> </span><span>float</span><span> </span><span>SafeSpacing</span><span> </span><span>=</span><span> FMath::</span><span>Max</span><span>(</span><span>SegmentSpacing</span><span>, </span><span><span>1.0</span><span>f</span></span><span>);</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>    </span><span>for</span><span> (</span><span>int32</span><span> </span><span>Index</span><span> </span><span>=</span><span> </span><span>0</span><span>; </span><span>Index</span><span> </span><span>&lt;</span><span> </span><span>SafeCount</span><span>; </span><span>++</span><span>Index</span><span>)</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>29</div></div><div><span>        </span><span>const</span><span> </span><span>FVector</span><span> </span><span>LocalLocation</span><span>(</span><span>Index</span><span> </span><span>*</span><span> </span><span>SafeSpacing</span><span>, </span><span><span>0.0</span><span>f</span></span><span>, </span><span><span>0.0</span><span>f</span></span><span>);</span></div></div><div><div><div>30</div></div><div><span>        </span><span>PreviewInstances</span><span>-&gt;</span><span>AddInstance</span><span>(</span><span>FTransform</span><span>(</span><span>LocalLocation</span><span>));</span></div></div><div><div><div>31</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>32</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이 패턴에는 의도적인 경계가 있다.</p><ul>
<li>생성자에서는 native default subobject만 만든다.</li>
<li><code>OnConstruction</code>은 instance editable 입력만 읽고 자기 컴포넌트만 갱신한다.</li>
<li>실행 횟수에 관계없이 같은 입력은 같은 instance 집합을 만든다.</li>
<li>수천 개의 별도 component나 Actor 대신 instancing을 사용한다.</li>
<li>잘못된 수치가 에디터를 멈추지 않도록 상한을 둔다.</li>
</ul><p>실제 도구에서는 메시의 bounds와 spline 길이 등을 사용할 수 있지만 원칙은 같다. 파일 I/O, 전체 World 검색, asset 저장, blocking load, 복잡한 pathfinding처럼 비용과 부작용이 큰 작업은 자동 construction 경로에서 분리한다.</p></section>
<section><h2>재실행은 컴포넌트의 수명도 바꾼다<a href="#재실행은-컴포넌트의-수명도-바꾼다"><span>#</span></a></h2><p><code>RerunConstructionScripts</code>는 construction에서 자동 생성한 컴포넌트를 파괴하고 다시 만든 뒤, 보존 가능한 instance data를 복원하려 한다. Construction Script에서 추가한 component의 raw pointer나 외부 Actor가 보관한 참조가 다음 재실행 뒤에도 같은 객체를 가리킨다고 가정하면 안 된다.</p><p>아래 방법 중 하나를 선택한다.</p><ul>
<li>고정된 핵심 컴포넌트는 가능하면 C++ constructor 또는 Blueprint Components 패널에서 만든다.</li>
<li>가변적인 다수 요소는 ISM/HISM, spline mesh처럼 한 owner가 명확히 관리하는 표현을 사용한다.</li>
<li>다시 찾아도 되는 컴포넌트는 안정적인 이름, tag, 명시적 <code>UPROPERTY</code>와 소유 관계로 식별한다.</li>
<li>async 결과가 돌아올 때는 대상의 weak reference와 construction revision을 확인한다. 가능하면 async 작업 자체를 explicit editor command로 옮긴다.</li>
</ul><p>Construction Script 안에서 다른 Actor를 spawn해 영구 배치하거나 asset을 저장하는 동작은 재실행과 transaction 경계 때문에 특히 위험하다. Actor가 관리하는 자식이 필요하면 Child Actor Component 등 수명이 owner의 reconstruction과 결합된 기능을 우선 검토하고, 대규모 생성·베이크는 별도 도구로 만든다.</p></section>
<section><h2>다른 Actor를 변경하면 순환 재구성이 생긴다<a href="#다른-actor를-변경하면-순환-재구성이-생긴다"><span>#</span></a></h2><p>대표적인 사례는 A Actor의 Construction Script가 B의 editable property를 바꾸고, B의 Construction Script가 다시 A를 바꾸는 구조다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>A Construction → B property 변경 → B reconstruction</span></div></div><div><div><div>2</div></div><div><span><span>       </span></span><span>↑                              ↓</span></div></div><div><div><div>3</div></div><div><span><span>       </span></span><span>└──────── A property 변경 ─────┘</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>엔진에 같은 Actor의 construction 재진입을 막는 처리와 Child Actor 재구성을 다루는 경로가 있어도, 서로 다른 Actor 사이의 모든 ping-pong과 간접 부작용까지 안전하게 해결해 주는 것은 아니다. 반복 재실행, 비결정적인 최종값, 심한 hitch, ensure나 stack overflow, crash로 이어질 수 있다.</p><p>다른 Actor와 협력해야 한다면 양방향 mutation을 없앤다.</p><ol>
<li>두 Actor가 읽기만 하는 하나의 Data Asset이나 설정 객체를 둔다.</li>
<li>한쪽만 owner가 되어 자식의 입력을 단방향으로 계산한다.</li>
<li>여러 Actor를 함께 변경하는 작업은 explicit <code>CallInEditor</code> 함수나 Editor Utility로 옮긴다.</li>
<li>변경 전에 값이 실제로 달라졌는지 비교하더라도, 그것만으로 잘못된 순환 설계가 안전해진다고 보지 않는다.</li>
</ol><p>특히 construction에서 무작위 값을 만들 때는 비시드 random을 피한다. 디자이너가 편집할 수 있는 seed와 <code>Random Stream</code>을 사용해 같은 입력이 같은 결과를 만들게 하거나, 확정된 결과를 명시적으로 bake한다.</p></section>
<section><h2>무거운 작업은 명시적 에디터 명령으로 바꾸기<a href="#무거운-작업은-명시적-에디터-명령으로-바꾸기"><span>#</span></a></h2><p>프로퍼티를 바꿀 때마다 자동으로 실행할 필요가 없는 작업이라면 Details 패널 버튼으로 실행 시점을 사용자에게 돌려줄 수 있다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UFUNCTION</span><span>(</span><span>CallInEditor</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>"Fence"</span><span>)</span></div></div><div><div><div>2</div></div><div><span>void</span><span> </span><span>RebuildBakedFence</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/ufunctions-in-unreal-engine" target="_blank">UFunction 문서</a>는 <code>CallInEditor</code> 함수를 선택한 instance의 Details 패널에서 호출할 수 있다고 설명한다. 여러 asset이나 Actor를 일괄 처리하거나 별도 UI와 진행률, 취소가 필요하다면 <a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/scripting-the-unreal-editor-using-blueprints" target="_blank">Editor Utility Blueprint와 Scripted Action</a>이 더 적합하다.</p><p>에디터 전용 API를 C++에서 사용할 때는 모듈 경계와 전처리 조건도 구분한다.</p><ul>
<li><code>#if WITH_EDITOR</code>: <code>PostEditChangeProperty</code>, <code>PostEditMove</code>와 editor-only 실행 코드를 감싼다.</li>
<li><code>#if WITH_EDITORONLY_DATA</code>: cooked build에서 제거할 수 있는 editor-only 데이터 멤버에 사용한다.</li>
<li>runtime에서 spawn되는 Actor에도 필요한 <code>OnConstruction</code> 전체를 <code>WITH_EDITOR</code>로 감싸지 않는다.</li>
<li>asset 편집과 저장을 하는 코드는 가능하면 runtime 모듈이 아닌 Editor 모듈에 둔다.</li>
</ul></section>
<section><h2>멀티플레이에서 Construction Script를 상태 복제로 쓰지 않기<a href="#멀티플레이에서-construction-script를-상태-복제로-쓰지-않기"><span>#</span></a></h2><p>Construction Script는 네트워크 동기화 메커니즘이 아니다. 서버에서 실행한 Construction Script의 부작용이 클라이언트에 그대로 재생되거나 복제된다고 보장되지 않는다. 클라이언트에 복제로 생성된 Actor는 초기 replicated property를 받기 전에 construction 단계를 지날 수 있다.</p><p>책임은 아래처럼 나눈다.</p><ul>
<li>서버가 authoritative gameplay 값을 정한다.</li>
<li>동기화할 값은 <code>Replicated</code> 또는 <code>ReplicatedUsing</code>으로 선언한다.</li>
<li>클라이언트의 표시 상태는 <code>OnRep</code>에서 적용하고 필요하면 <code>BeginPlay</code>에서 최종 상태를 확인한다.</li>
<li>Construction Script는 그 값의 editor preview나 서버/로컬 spawn 시 파생 구성에만 사용한다.</li>
</ul><p>Construction Script가 빠르고 결정적이어야 하는 이유는 성능만이 아니다. editor, standalone, listen server, dedicated server, client proxy처럼 실행 환경이 달라도 gameplay 정본은 replication 계약으로 수렴해야 한다.</p></section>
<section><h2>실전 점검표<a href="#실전-점검표"><span>#</span></a></h2><section><h3>이 작업이 Construction Script에 맞는가<a href="#이-작업이-construction-script에-맞는가"><span>#</span></a></h3><ul>
<li>instance editable 입력에 따라 Actor 자신의 시각적·구조적 파생 상태를 보여 주는가?</li>
<li>editor에서 즉시 미리보는 가치가 있는가?</li>
<li>동일한 입력으로 여러 번 실행해도 결과가 같은가?</li>
<li>짧은 시간에 수십 번 호출돼도 편집기를 멈추지 않는가?</li>
<li>자동 생성 요소의 소유자와 정리 책임이 이 Actor 하나로 명확한가?</li>
</ul></section><section><h3>다음 중 하나라면 다른 단계가 낫다<a href="#다음-중-하나라면-다른-단계가-낫다"><span>#</span></a></h3><ul>
<li>gameplay subsystem 등록, timer, AI, 입력, 네트워크 권한이 필요함 → <code>BeginPlay</code> 또는 gameplay 초기화</li>
<li>저장 데이터의 버전 변환이 필요함 → <code>PostLoad</code></li>
<li>값비싼 asset 생성·저장이나 대규모 일괄 변경 → <code>CallInEditor</code> 또는 Editor Utility</li>
<li>여러 Actor의 상태를 양방향으로 바꿈 → 단방향 owner나 별도 coordinator</li>
<li>호출마다 누적되는 spawn, append, random, 외부 I/O → 명시적인 reset/rebuild 또는 별도 명령</li>
</ul><p>Construction Script는 “아무 초기화나 넣을 수 있다”는 생각으로 쓰는 곳이 아니다. <strong>편집 가능한 입력을 즉시 확인 가능한 파생 결과로 바꾸고, 필요할 때 얼마든지 버리고 다시 만드는 도구</strong>다. 이 계약을 지키면 Construction Script는 에디터를 불안정하게 만드는 숨은 이벤트가 아니라 디자이너가 신뢰할 수 있는 작은 절차적 제작 도구가 된다.</p></section></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/construction-script-in-unreal-engine" target="_blank">Epic Games: Construction Script</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/blueprint-foundations" target="_blank">Epic Games: Blueprint Foundations</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-actor-lifecycle" target="_blank">Epic Games: Actor Lifecycle</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/AActor" target="_blank">Epic Games: <code>AActor</code> initialization order</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/ufunctions-in-unreal-engine" target="_blank">Epic Games: UFunctions and <code>CallInEditor</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/scripting-the-unreal-editor-using-blueprints" target="_blank">Epic Games: Scripting the Editor using Blueprints</a></li>
<li><a href="https://isaratech.com/ue4-be-careful-with-the-construction-script/" target="_blank">UE4 – Be careful with the Construction Script</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-networking-containers-and-deferred-spawn/</id>
      <title type="text">Unreal Engine 네트워크 코드의 두 가지 함정: 컨테이너 복제와 Deferred Spawn</title>
      <published>2023-01-16T00:00:00.000Z</published>
      <updated>2023-01-16T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-networking-containers-and-deferred-spawn/"/>
      <summary type="text">TMap과 TSet을 직접 복제할 수 없는 이유와 안전한 배열·Fast Array 대안, 그리고 Actor가 BeginPlay에 들어가기 전에 초기값을 전달하는 Deferred Spawn의 정확한 생명주기를 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Unreal Engine 네트워크 코드를 작성하다 보면 서로 다른 두 문제에서 비슷한 함정에 빠진다. 첫째, 게임 상태를 표현하기 좋은 <code>TMap</code>과 <code>TSet</code>을 그대로 복제하려 한다. 둘째, 동적으로 생성한 Actor에 필요한 값을 <code>BeginPlay</code> 전에 넣으려다가 이미 초기화가 끝난 뒤 값을 전달한다.</p>
<p>두 문제의 기본 해법은 “<code>TArray</code>를 복제한 뒤 <code>TMap</code>/<code>TSet</code>을 재구성한다”와 “<code>SpawnActorDeferred</code>와 <code>FinishSpawning</code> 사이에서 값을 설정한다”이다. 다만 적용 범위와 생명주기를 정확히 모르면 새로운 문제가 생긴다.</p>
<ul>
<li><code>TMap</code>을 배열로 바꿨지만 중복 키와 순서 정책이 없다.</li>
<li>큰 배열을 매번 복제해 불필요한 대역폭을 쓴다.</li>
<li>클라이언트가 서버의 정본 상태를 직접 바꾸려 한다.</li>
<li>Deferred Spawn으로 받은 Actor를 완성된 객체처럼 사용한다.</li>
<li><code>FinishSpawning</code>을 빠뜨리거나, 그 호출 안에서 <code>BeginPlay</code>까지 실행될 수 있다는 사실을 놓친다.</li>
</ul>
<p>네트워크 복제 구현은 엔진 버전과 프로젝트 설정의 영향을 크게 받으므로, 적용할 프로젝트의 최소 재현 환경에서 컴파일하고 패킷 손실 조건까지 검증해야 한다.</p>
<section><h2>1. <code>TMap</code>과 <code>TSet</code>은 무엇이 지원되지 않는가<a href="#1-tmap과-tset은-무엇이-지원되지-않는가"><span>#</span></a></h2><p>정확히 말하면 이렇다.</p><blockquote><p>일반 <code>UPROPERTY(Replicated)</code> 또는 <code>ReplicatedUsing</code>으로 선언한 <code>TMap</code>·<code>TSet</code>, 그리고 일반 RPC의 Map/Set 인자는 지원되지 않는다. 이것은 컨테이너의 데이터를 네트워크로 표현할 방법이 전혀 없다는 뜻은 아니다.</p></blockquote><p>UnrealHeaderTool은 복제 프로퍼티와 RPC에서 Map/Set을 거부한다. 런타임의 <code>FMapProperty::NetSerializeItem</code>과 <code>FSetProperty::NetSerializeItem</code>도 이름만 보고 지원한다고 판단해서는 안 된다. 해당 구현은 지원하지 않는다는 오류를 기록한다. Blueprint Map/Set 변수도 예외가 아니다.</p><p><a href="https://issues.unrealengine.com/issue/UE-137102" target="_blank">Epic의 UE-137102 이슈</a>는 이를 우회하려면 컨테이너를 <code>USTRUCT</code>로 감싸고 사용자 정의 <code>NetSerialize</code>를 구현해야 한다고 설명한다. 그러나 이것은 고급 사용자 정의 프로토콜이며, 아래처럼 평범한 선언이 자동으로 동작한다는 뜻이 아니다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 일반 property replication에서는 지원되지 않는다.</span></div></div><div><div><div>2</div></div><div><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>)</span></div></div><div><div><div>3</div></div><div><span>TMap</span><span>&lt;</span><span>FName</span><span>, </span><span>int32</span><span>&gt;</span><span> </span><span>ScoreByPlayer</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>UPROPERTY</span><span>(</span><span>Replicated</span><span>)</span></div></div><div><div><div>6</div></div><div><span>TSet</span><span>&lt;</span><span>FName</span><span>&gt;</span><span> </span><span>ReadyPlayers</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>반대로 <code>TArray</code>와 Fast Array는 복제할 수 있다. 가장 단순한 기본 해법은 <strong>네트워크 표현과 런타임 조회 표현을 분리</strong>하는 것이다.</p></section>
<section><h2>작은 상태: <code>TArray&lt;FEntry&gt;</code>를 복제하고 <code>OnRep</code>에서 재구성하기<a href="#작은-상태-tarrayfentry를-복제하고-onrep에서-재구성하기"><span>#</span></a></h2><p>점수표처럼 항목 수가 작고 변경 빈도가 낮다면 키와 값을 가진 구조체 배열을 복제한다. 서버는 배열을 정본으로 관리하고, 서버와 각 클라이언트는 빠른 조회를 위한 <code>TMap</code> 캐시를 로컬에서 만든다.</p><div><div><div><figure><figcaption><span>ReplicatedScoreboard.h</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"GameFramework/Actor.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"ReplicatedScoreboard.generated.h"</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>USTRUCT</span><span>(</span><span>BlueprintType</span><span>)</span></div></div><div><div><div>8</div></div><div><span>struct</span><span> </span><span>FReplicatedScoreEntry</span></div></div><div><div><div>9</div></div><div><span>{</span></div></div><div><div><div>10</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>13</div></div><div><span>    </span><span>FName</span><span> </span><span>PlayerId</span><span> </span><span>=</span><span> </span><span>NAME_None</span><span>;</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>    </span><span>UPROPERTY</span><span>()</span></div></div><div><div><div>16</div></div><div><span>    </span><span>int32</span><span> </span><span>Score</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>17</div></div><div><span>};</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>UCLASS</span><span>()</span></div></div><div><div><div>20</div></div><div><span>class</span><span> YOURGAME_API </span><span>AReplicatedScoreboard</span><span> : </span><span>public</span><span> </span><span>AActor</span></div></div><div><div><div>21</div></div><div><span>{</span></div></div><div><div><div>22</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>public:</span></div></div><div><div><div>25</div></div><div><span>    </span><span>AReplicatedScoreboard</span><span>();</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>    </span><span>void</span><span> </span><span>SetScore</span><span>(</span><span>FName</span><span><span> </span><span>PlayerId</span><span>, </span></span><span>int32</span><span><span> </span><span>NewScore</span><span>);</span></span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span>    </span><span>virtual</span><span> </span><span>void</span><span> </span><span>GetLifetimeReplicatedProps</span><span>(</span></div></div><div><div><div>30</div></div><div><span>        </span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span><span> </span><span>override</span><span>;</span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div><span>protected:</span></div></div><div><div><div>33</div></div><div><span>    </span><span>UPROPERTY</span><span>(</span><span>ReplicatedUsing</span><span> </span><span>=</span><span> </span><span>OnRep_ScoreEntries</span><span>)</span></div></div><div><div><div>34</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>FReplicatedScoreEntry</span><span>&gt;</span><span> </span><span>ScoreEntries</span><span>;</span></div></div><div><div><div>35</div></div><div>
</div></div><div><div><div>36</div></div><div><span>    </span><span>UFUNCTION</span><span>()</span></div></div><div><div><div>37</div></div><div><span>    </span><span>void</span><span> </span><span>OnRep_ScoreEntries</span><span>();</span></div></div><div><div><div>38</div></div><div>
</div></div><div><div><div>39</div></div><div><span>private:</span></div></div><div><div><div>40</div></div><div><span><span>    </span></span><span>// 네트워크로 보내지 않는 로컬 조회 캐시다.</span></div></div><div><div><div>41</div></div><div><span>    </span><span>TMap</span><span>&lt;</span><span>FName</span><span>, </span><span>int32</span><span>&gt; </span><span>ScoreByPlayer</span><span>;</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>    </span><span>void</span><span> </span><span>RebuildScoreCache</span><span>();</span></div></div><div><div><div>44</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><div><div><div><figure><figcaption><span>ReplicatedScoreboard.cpp</span><span>더 보기</span><span>간략히 보기</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#include</span><span> </span><span>"ReplicatedScoreboard.h"</span></div></div><div><div><div>2</div></div><div><span>#include</span><span> </span><span>"Net/UnrealNetwork.h"</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>AReplicatedScoreboard</span><span>::</span></span><span>AReplicatedScoreboard</span><span>()</span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>bReplicates</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>void</span><span><span> </span><span>AReplicatedScoreboard</span><span>::</span></span><span>GetLifetimeReplicatedProps</span><span>(</span></div></div><div><div><div>10</div></div><div><span>    </span><span>TArray</span><span>&lt;</span><span>FLifetimeProperty</span><span>&gt;</span><span>&amp;</span><span><span> </span><span>OutLifetimeProps</span><span>) </span></span><span>const</span></div></div><div><div><div>11</div></div><div><span>{</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>Super::</span><span>GetLifetimeReplicatedProps</span><span>(</span><span>OutLifetimeProps</span><span>);</span></div></div><div><div><div>13</div></div><div><span>    </span><span>DOREPLIFETIME</span><span>(</span><span>AReplicatedScoreboard</span><span>, </span><span>ScoreEntries</span><span>);</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>void</span><span><span> </span><span>AReplicatedScoreboard</span><span>::</span></span><span>SetScore</span><span>(</span><span>FName</span><span><span> </span><span>PlayerId</span><span>, </span></span><span>int32</span><span><span> </span><span>NewScore</span><span>)</span></span></div></div><div><div><div>17</div></div><div><span>{</span></div></div><div><div><div>18</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>HasAuthority</span><span>() </span><span>||</span><span> </span><span>PlayerId</span><span>.</span><span>IsNone</span><span>())</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>20</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>    </span><span>FReplicatedScoreEntry</span><span>*</span><span> </span><span>Entry</span><span> </span><span>=</span><span> </span><span>ScoreEntries</span><span>.</span><span>FindByPredicate</span><span>(</span></div></div><div><div><div>24</div></div><div><span><span>        </span></span><span>[</span><span>PlayerId</span><span>](</span><span>const</span><span> </span><span>FReplicatedScoreEntry</span><span>&amp;</span><span><span> </span><span>Candidate</span><span>)</span></span></div></div><div><div><div>25</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>26</div></div><div><span>            </span><span>return</span><span> </span><span>Candidate</span><span>.</span><span>PlayerId</span><span> </span><span>==</span><span> </span><span>PlayerId</span><span>;</span></div></div><div><div><div>27</div></div><div><span><span>        </span></span><span>});</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span>    </span><span>if</span><span> (</span><span>Entry</span><span>)</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>31</div></div><div><span>        </span><span>Entry</span><span>-&gt;</span><span>Score</span><span> </span><span>=</span><span> </span><span>NewScore</span><span>;</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>33</div></div><div><span>    </span><span>else</span></div></div><div><div><div>34</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>35</div></div><div><span>        </span><span>ScoreEntries</span><span>.</span><span>Add</span><span>({</span><span>PlayerId</span><span>, </span><span>NewScore</span><span>});</span></div></div><div><div><div>36</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>37</div></div><div>
</div></div><div><div><div>38</div></div><div><span><span>    </span></span><span>// RepNotify는 서버에서 자동 호출되지 않으므로 서버 캐시도 직접 갱신한다.</span></div></div><div><div><div>39</div></div><div><span>    </span><span>RebuildScoreCache</span><span>();</span></div></div><div><div><div>40</div></div><div><span>    </span><span>ForceNetUpdate</span><span>();</span></div></div><div><div><div>41</div></div><div><span>}</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>void</span><span><span> </span><span>AReplicatedScoreboard</span><span>::</span></span><span>OnRep_ScoreEntries</span><span>()</span></div></div><div><div><div>44</div></div><div><span>{</span></div></div><div><div><div>45</div></div><div><span>    </span><span>RebuildScoreCache</span><span>();</span></div></div><div><div><div>46</div></div><div><span>}</span></div></div><div><div><div>47</div></div><div>
</div></div><div><div><div>48</div></div><div><span>void</span><span><span> </span><span>AReplicatedScoreboard</span><span>::</span></span><span>RebuildScoreCache</span><span>()</span></div></div><div><div><div>49</div></div><div><span>{</span></div></div><div><div><div>50</div></div><div><span>    </span><span>ScoreByPlayer</span><span>.</span><span>Reset</span><span>(</span><span>ScoreEntries</span><span>.</span><span>Num</span><span>());</span></div></div><div><div><div>51</div></div><div>
</div></div><div><div><div>52</div></div><div><span>    </span><span>for</span><span> (</span><span>const</span><span> </span><span>FReplicatedScoreEntry</span><span>&amp;</span><span> </span><span>Entry</span><span> : </span><span>ScoreEntries</span><span>)</span></div></div><div><div><div>53</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>54</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span>Entry</span><span>.</span><span>PlayerId</span><span>.</span><span>IsNone</span><span>())</span></div></div><div><div><div>55</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>56</div></div><div><span><span>            </span></span><span>// 이 예제의 중복 정책은 last-wins다.</span></div></div><div><div><div>57</div></div><div><span>            </span><span>ScoreByPlayer</span><span>.</span><span>Add</span><span>(</span><span>Entry</span><span>.</span><span>PlayerId</span><span>, </span><span>Entry</span><span>.</span><span>Score</span><span>);</span></div></div><div><div><div>58</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>59</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>60</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이 예제에서 중요한 것은 문법보다 데이터 계약이다.</p><ul>
<li><code>ScoreEntries</code>만 복제되는 정본이다. <code>ScoreByPlayer</code>는 파생 캐시다.</li>
<li>서버만 정본을 변경한다. 클라이언트의 요청이 필요하면 Server RPC로 보내고 서버에서 소유권, 범위, 빈도를 검증한다.</li>
<li>같은 키가 두 번 들어왔을 때 이 예제는 마지막 값을 사용한다. 데이터에 따라 첫 값을 유지하거나 전체 업데이트를 거부할 수도 있다.</li>
<li><code>TMap</code>과 <code>TSet</code>의 순회 순서는 안정적인 표시 순서가 아니다. 순서가 의미 있으면 명시적인 정렬 키를 복제한 뒤 정렬한다.</li>
<li>프로퍼티 복제는 <strong>최종 상태를 수렴</strong>시킨다. 모든 중간 변경이나 이벤트 순서를 보장하지 않는다.</li>
</ul><p>여러 항목이 동시에 바뀌어야만 유효한 불변식이 있다면 수신 중간 상태를 그대로 적용하지 말고 revision을 함께 보내거나 하나의 항목 구조체로 묶어 검증한 뒤 캐시를 교체한다. ordered event stream이 필요하다면 상태 복제만으로 대신하지 않는다.</p></section>
<section><h2>변경이 잦거나 큰 상태: Fast Array<a href="#변경이-잦거나-큰-상태-fast-array"><span>#</span></a></h2><p>항목이 많고 추가·수정·삭제가 자주 일어나면 일반 배열 스냅샷보다 <code>FFastArraySerializer</code>를 고려한다. Fast Array는 바뀐 항목을 추적하는 복제 패턴을 제공한다.</p><p>다만 이름만 바꾸면 자동으로 효율적이 되는 기능은 아니다.</p><ul>
<li>항목 타입은 <code>FFastArraySerializerItem</code>을 상속하고 컬렉션은 <code>FFastArraySerializer</code>를 상속한다.</li>
<li>항목을 추가하거나 수정한 뒤 <code>MarkItemDirty</code>를 호출한다.</li>
<li>삭제처럼 배열 구조 전체의 변경을 알릴 때는 올바른 API와 <code>MarkArrayDirty</code>를 사용한다.</li>
<li>서버와 클라이언트의 배열 인덱스나 순서가 같다고 가정하지 않는다. 게임 도메인의 안정적인 키를 별도 필드로 둔다.</li>
<li>수신 콜백에서는 추가, 변경, 삭제에 따른 로컬 캐시와 UI를 명시적으로 갱신한다.</li>
</ul><p>아래 기준으로 선택한다.</p>

<table><thead><tr><th>요구 사항</th><th>우선 검토할 표현</th><th>이유</th></tr></thead><tbody><tr><td>작고 드물게 바뀌는 전체 상태</td><td><code>TArray&lt;FEntry&gt;</code> + <code>OnRep</code></td><td>구현과 디버깅이 가장 단순하다</td></tr><tr><td>크고 항목별 변경이 잦은 상태</td><td>Fast Array</td><td>항목 단위 변경 추적과 콜백을 제공한다</td></tr><tr><td>일회성 순서 이벤트</td><td>검증된 RPC</td><td>상태가 아니라 사건의 의미와 순서를 전달한다</td></tr><tr><td>특수 압축·비트 프로토콜</td><td>사용자 정의 serializer</td><td>성능 이득이 복잡성과 버전 비용을 정당화할 때만 사용한다</td></tr></tbody></table><p>사용자 정의 <code>NetSerialize</code>도 가능하지만 기본 해법으로 삼기 어렵다. 항목 수 상한, 중복 키, 잘못된 enum과 object reference, 정수 overflow를 역직렬화 전에 검증해야 하며 사용하는 엔진 버전과의 호환성도 직접 확인해야 한다.</p></section>
<section><h2>2. <code>BeginPlay</code> 전에 값을 전달하는 Deferred Spawn<a href="#2-beginplay-전에-값을-전달하는-deferred-spawn"><span>#</span></a></h2><p>일반 <code>SpawnActor</code>는 생성과 Construction Script, 컴포넌트 초기화까지 한 호출 흐름에서 진행한다. 이미 플레이 중인 월드라면 호출이 반환되기 전에 <code>BeginPlay</code>까지 실행될 수 있으므로, 반환된 포인터에 초기값을 넣는 것은 늦다.</p><p>Deferred Spawn은 <strong>생성자를 미루는 API가 아니다.</strong> <code>SpawnActorDeferred</code>가 반환할 때 네이티브 생성자와 기본 서브오브젝트 생성, <code>PostSpawnInitialize</code>, <code>PostActorCreated</code>까지 진행됐다. 대신 Blueprint Construction Script와 <code>OnConstruction</code>, 이후 컴포넌트 초기화와 <code>BeginPlay</code>로 가는 단계를 멈춘다. 반환값은 유효하지만 아직 완성되지 않은 Actor다.</p><div><div><div>Deferred ActorUWorld서버 게임플레이 코드Deferred ActorUWorld서버 게임플레이 코드SpawnActorDeferred&lt;T&gt;()네이티브 생성자와 기본 서브오브젝트PostSpawnInitialize / PostActorCreated유효하지만 불완전한 ActorConstruction 입력값 설정FinishSpawning(Transform)Construction Script / OnConstruction컴포넌트 초기화BeginPlay (월드가 이미 플레이 중이면)
</div><div>Deferred ActorUWorld서버 게임플레이 코드Deferred ActorUWorld서버 게임플레이 코드SpawnActorDeferred&lt;T&gt;()네이티브 생성자와 기본 서브오브젝트PostSpawnInitialize / PostActorCreated유효하지만 불완전한 ActorConstruction 입력값 설정FinishSpawning(Transform)Construction Script / OnConstruction컴포넌트 초기화BeginPlay (월드가 이미 플레이 중이면)
</div></div></div><section><h3>일반 C++에서는 타입이 있는 World API 사용하기<a href="#일반-c에서는-타입이-있는-world-api-사용하기"><span>#</span></a></h3><p><code>UGameplayStatics::BeginDeferredActorSpawnFromClass</code>와 <code>FinishSpawningActor</code> 쌍도 사용할 수 있지만, 일반 C++에서는 타입 안전하고 <code>Owner</code>와 <code>Instigator</code>를 명시할 수 있는 <code>UWorld::SpawnActorDeferred&lt;T&gt;</code>가 더 분명하다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>AConfiguredProjectile</span><span>*</span><span> </span><span>SpawnConfiguredProjectile</span><span>(</span></div></div><div><div><div>2</div></div><div><span>    </span><span>UWorld</span><span>*</span><span><span> </span><span>World</span><span>,</span></span></div></div><div><div><div>3</div></div><div><span>    </span><span>TSubclassOf</span><span>&lt;</span><span>AConfiguredProjectile</span><span><span>&gt; </span><span>ProjectileClass</span><span>,</span></span></div></div><div><div><div>4</div></div><div><span>    </span><span>const</span><span> </span><span>FTransform</span><span>&amp;</span><span><span> </span><span>SpawnTransform</span><span>,</span></span></div></div><div><div><div>5</div></div><div><span>    </span><span>AActor</span><span>*</span><span><span> </span><span>Owner</span><span>,</span></span></div></div><div><div><div>6</div></div><div><span>    </span><span>APawn</span><span>*</span><span><span> </span><span>Instigator</span><span>,</span></span></div></div><div><div><div>7</div></div><div><span>    </span><span>const</span><span> </span><span>FProjectileSpawnConfig</span><span>&amp;</span><span><span> </span><span>InitialConfig</span><span>)</span></span></div></div><div><div><div>8</div></div><div><span>{</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>// 복제되는 authoritative Actor는 서버에서만 생성한다.</span></div></div><div><div><div>10</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>World</span><span> </span><span>||</span><span> </span><span>World</span><span>-&gt;</span><span>GetNetMode</span><span>() </span><span>==</span><span> </span><span>NM_Client</span><span> </span><span>||</span><span> </span><span><span>!</span><span>*</span></span><span>ProjectileClass</span><span> </span><span>||</span></div></div><div><div><div>11</div></div><div><span>        </span><span>SpawnTransform</span><span>.</span><span>ContainsNaN</span><span>())</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>13</div></div><div><span>        </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>    </span><span>AConfiguredProjectile</span><span>*</span><span> </span><span>Projectile</span><span> </span><span>=</span></div></div><div><div><div>17</div></div><div><span>        </span><span>World</span><span>-&gt;</span><span>SpawnActorDeferred</span><span>&lt;</span><span>AConfiguredProjectile</span><span>&gt;</span><span>(</span></div></div><div><div><div>18</div></div><div><span>            </span><span>ProjectileClass</span><span>,</span></div></div><div><div><div>19</div></div><div><span>            </span><span>SpawnTransform</span><span>,</span></div></div><div><div><div>20</div></div><div><span>            </span><span>Owner</span><span>,</span></div></div><div><div><div>21</div></div><div><span>            </span><span>Instigator</span><span>,</span></div></div><div><div><div>22</div></div><div><span><span>            </span></span><span>ESpawnActorCollisionHandlingMethod::</span><span>AdjustIfPossibleButDontSpawnIfColliding</span><span>);</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>Projectile</span><span>)</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>26</div></div><div><span>        </span><span>return</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>// OnConstruction과 BeginPlay가 읽어야 하는 필수 값을 여기서 넣는다.</span></div></div><div><div><div>30</div></div><div><span>    </span><span>Projectile</span><span>-&gt;</span><span>SetSpawnConfigBeforeFinish</span><span>(</span><span>InitialConfig</span><span>);</span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div><span>    </span><span>Projectile</span><span>-&gt;</span><span>FinishSpawning</span><span>(</span><span>SpawnTransform</span><span>);</span></div></div><div><div><div>33</div></div><div>
</div></div><div><div><div>34</div></div><div><span><span>    </span></span><span>// 선택한 충돌 정책에 따라 finish 중 파괴될 가능성까지 확인한다.</span></div></div><div><div><div>35</div></div><div><span>    </span><span>return</span><span> </span><span>IsValid</span><span>(</span><span>Projectile</span><span>) </span><span>?</span><span> </span><span>Projectile</span><span> </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>36</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>SpawnActorDeferred</code>의 인자와 overload는 엔진 버전에 따라 다를 수 있다. 위 예제처럼 프로젝트가 사용하는 버전의 선언을 확인하고, begin과 finish에 같은 <code>SpawnTransform</code>을 전달한다.</p></section><section><h3>Deferred 구간에서 해도 되는 일과 피해야 할 일<a href="#deferred-구간에서-해도-되는-일과-피해야-할-일"><span>#</span></a></h3>

<table><thead><tr><th>Deferred 구간에서 적합</th><th>아직 피해야 함</th></tr></thead><tbody><tr><td>단순 데이터 검증과 필수 설정값 대입</td><td>Blueprint Construction Script가 만든 컴포넌트 접근</td></tr><tr><td><code>Owner</code>, <code>Instigator</code>, 팀·스킨·크기 같은 construction 입력 설정</td><td><code>BeginPlay</code>나 초기화 완료를 전제로 한 게임플레이 호출</td></tr><tr><td><code>OnConstruction</code>이 읽을 replicated 초기 프로퍼티 설정</td><td>타이머·AI·충돌 이벤트 등 완성된 Actor 동작 시작</td></tr><tr><td>실패 시 즉시 안전하게 정리하고 반환</td><td>포인터를 장기간 보관한 채 <code>FinishSpawning</code>을 나중으로 미루기</td></tr></tbody></table><p>모든 성공 경로에서 <code>FinishSpawning</code>은 정확히 한 번 호출해야 한다. 이 호출은 단순히 플래그 하나를 바꾸는 것이 아니라 <code>ExecuteConstruction</code>, Blueprint Construction Script, <code>OnConstruction</code>, 컴포넌트 초기화를 이어서 실행한다. 월드가 이미 플레이 중이라면 같은 호출 스택에서 <code>BeginPlay</code>까지 도달할 수 있다. <strong>마지막 필수 설정은 반드시 그 호출보다 앞에 있어야 한다.</strong></p><p>반대로 월드가 아직 BeginPlay 전이라면 Actor의 <code>BeginPlay</code>도 월드 시작까지 기다린다. 클라이언트에 복제로 생성되는 proxy Actor는 또 다른 특수 경로를 사용하며, 초기 replicated state를 역직렬화한 뒤 <code>PostNetInit</code>을 거쳐 <code>BeginPlay</code>에 들어간다.</p></section></section>
<section><h2><code>ExposeOnSpawn</code>과 replication은 별개의 기능이다<a href="#exposeonspawn과-replication은-별개의-기능이다"><span>#</span></a></h2><p>다음 metadata는 Blueprint의 Spawn Actor 노드에 입력 핀을 만든다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UPROPERTY</span><span>(</span></div></div><div><div><div>2</div></div><div><span>    </span><span>EditAnywhere</span><span>,</span></div></div><div><div><div>3</div></div><div><span>    </span><span>BlueprintReadWrite</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>ReplicatedUsing</span><span> </span><span>=</span><span> </span><span>OnRep_SpawnConfig</span><span>,</span></div></div><div><div><div>5</div></div><div><span>    </span><span>meta</span><span> </span><span>=</span><span> (</span><span>ExposeOnSpawn</span><span> </span><span>=</span><span> </span><span>"true"</span><span>))</span></div></div><div><div><div>6</div></div><div><span>FProjectileSpawnConfig</span><span> </span><span>SpawnConfig</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Blueprint의 Spawn Actor 노드는 내부적으로 Begin Deferred Spawn, 노출된 프로퍼티 대입, Finish Spawning 순으로 확장되므로 Construction Script에서 핀 값을 읽을 수 있다. 그러나 <code>ExposeOnSpawn</code>은 editor metadata일 뿐이다.</p><ul>
<li>C++ <code>SpawnActorDeferred</code>가 값을 자동으로 채워 주지 않는다.</li>
<li>프로퍼티를 자동으로 복제하지 않는다. <code>Replicated</code> 또는 <code>ReplicatedUsing</code> 선언과 <code>DOREPLIFETIME</code>이 별도로 필요하다.</li>
<li>생성자는 spawn 시 전달된 값을 볼 수 없다. 생성자는 deferred 반환 전에 이미 실행됐다.</li>
</ul><p>네트워크 Actor에서는 Construction Script의 부작용 자체가 복제된다고 가정해서도 안 된다. 서버에서 deferred 구간에 값을 넣으면 서버의 <code>OnConstruction</code>은 그 값을 보지만, 클라이언트 proxy의 Construction Script는 초기 replicated property가 적용되기 전에 실행될 수 있다. 런타임 게임 상태는 <code>ReplicatedUsing</code>과 <code>OnRep</code>에서 다시 적용하고, 초기값이 CDO와 같아 알림이 생략되는 경우까지 고려한다면 <code>BeginPlay</code>에서도 최종 상태를 확인한다. 서버에서는 RepNotify가 자동 호출되지 않으므로 명시적인 적용 함수나 <code>OnConstruction</code>을 함께 사용한다.</p></section>
<section><h2>네트워크와 생명주기 체크리스트<a href="#네트워크와-생명주기-체크리스트"><span>#</span></a></h2><section><h3>컨테이너 상태<a href="#컨테이너-상태"><span>#</span></a></h3><ul>
<li>일반 replicated <code>TMap</code>/<code>TSet</code> 대신 배열 대리 표현이나 Fast Array를 선택했는가?</li>
<li>서버가 정본을 변경하고 클라이언트 입력은 검증된 Server RPC로 전달하는가?</li>
<li>중복 키, 최대 항목 수, 잘못된 값, 정렬 정책을 명시했는가?</li>
<li>인덱스나 컨테이너 순회 순서를 네트워크 ID로 오해하지 않았는가?</li>
<li>모든 중간 변경이 도착한다는 전제 없이 최종 상태를 적용하는가?</li>
</ul></section><section><h3>Deferred Spawn<a href="#deferred-spawn"><span>#</span></a></h3><ul>
<li>복제 Actor를 서버에서 생성하고 생성자/CDO에서 <code>bReplicates = true</code>로 설정했는가?</li>
<li>begin 반환값의 null 여부와 finish 뒤 <code>IsValid</code>를 모두 확인하는가?</li>
<li>Construction Script가 만든 컴포넌트를 finish 전에 사용하지 않는가?</li>
<li>모든 필수 값을 <code>FinishSpawning</code> 전에 넣었는가?</li>
<li>모든 성공 경로에서 같은 흐름 안에 <code>FinishSpawning</code>을 정확히 한 번 호출하는가?</li>
<li><code>FinishSpawning</code> 안에서 Construction과 <code>BeginPlay</code>가 동기 실행될 수 있다고 가정했는가?</li>
<li>클라이언트의 초기 상태 적용을 Construction Script 부작용이 아니라 replication/RepNotify로 설계했는가?</li>
</ul><p>두 문제는 <strong>표현과 생명주기의 경계를 명시</strong>해 해결한다. <code>TMap</code>은 게임 코드의 조회 표현으로 남기고 네트워크에는 검증 가능한 배열 표현을 사용한다. Actor는 deferred 구간에서 필수 데이터만 받은 뒤 즉시 완성하고, 완성 이후의 게임플레이 상태는 복제로 동기화한다.</p></section></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://issues.unrealengine.com/issue/UE-137102" target="_blank">Epic Games: UE-137102 — TMap properties cannot be replicated</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-actor-lifecycle" target="_blank">Epic Games: Actor Lifecycle</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/UWorld/SpawnActorDeferred" target="_blank">Epic Games: <code>UWorld::SpawnActorDeferred</code></a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/metadata-specifiers-in-unreal-engine" target="_blank">Epic Games: Actor Property Metadata Specifiers</a></li>
<li><a href="https://cedric-neukirchen.net/Downloads/Compendium/UE4_Network_Compendium_by_Cedric_eXi_Neukirchen.pdf" target="_blank">Unreal Engine 4 Network Compendium</a></li>
<li><a href="https://youtu.be/o8TgvFPhUgE?si=BbKFPqcgEoVGXosG" target="_blank">프로그래머를 괴롭히는 Unreal Engine 4의 함정들</a></li>
<li><a href="https://sites.google.com/site/techaht/trans/unreal-net-arch?pli=1" target="_blank">언리얼 네트워킹 아키텍처</a></li>
</ul><p><em>Epic Games의 Unreal Engine GitHub 소스 링크는 Epic 계정과 연결된 GitHub 로그인이 필요할 수 있다.</em></p></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/fix-unreal-engine-debugger-attach-symbol-loading/</id>
      <title type="text">Unreal Engine 멀티프로세스 디버깅에서 중단점이 비활성화될 때</title>
      <published>2023-01-15T00:00:00.000Z</published>
      <updated>2023-01-15T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/fix-unreal-engine-debugger-attach-symbol-loading/"/>
      <summary type="text">실행 중인 Unreal Editor나 멀티플레이 클라이언트에 디버거를 연결했지만 중단점이 활성화되지 않을 때, 심볼 상태를 진단하고 Windows의 attach 시 모듈 제한을 안전하게 조정하는 방법을 정리합니다.</summary>
      <content type="html"><![CDATA[<p>Unreal Engine 프로젝트에서 PIE 멀티플레이나 별도 클라이언트를 실행한 뒤 IDE의 <strong>Attach to Process</strong>로 새 프로세스에 연결하면, 디버거 연결은 성공했는데 프로젝트 코드의 중단점은 비활성 상태로 남을 때가 있다. 특히 프로세스가 시작된 직후에는 정상이고 시간이 지난 뒤 연결할 때만 실패한다면 Windows의 attach 시 모듈 제한을 의심한다.</p>
<p>이 문제는 아래 환경에서 확인했다.</p>

<table><thead><tr><th>항목</th><th>확인한 환경</th></tr></thead><tbody><tr><td>운영체제</td><td>Windows 11 22H2</td></tr><tr><td>엔진</td><td>Unreal Engine 4.27.2</td></tr><tr><td>IDE</td><td>JetBrains Rider 2022.3.1, Visual Studio</td></tr><tr><td>빌드 구성</td><td>DebugGame Editor</td></tr><tr><td>상황</td><td>Listen Server와 Client 등 여러 프로세스를 실행한 뒤 추가 프로세스에 연결</td></tr></tbody></table>
<p>Windows의 attach 모듈 제한과 <code>DebuggerMaxModuleMsgs</code> 설정은 <a href="https://forums.unrealengine.com/t/increase-the-number-of-modules-loaded-when-attaching-the-debugger/661624" target="_blank">Epic Games 포럼의 안내</a>에도 정리돼 있다. 다만 비활성 중단점의 원인이 항상 이 제한인 것은 아니다. 레지스트리를 바꾸기 전에 아래 순서로 심볼 문제인지부터 확인해야 한다.</p>
<section><h2>증상 재현<a href="#증상-재현"><span>#</span></a></h2><ol>
<li>Rider 또는 Visual Studio에서 <code>DebugGame Editor</code> 구성으로 프로젝트를 빌드하고 Unreal Editor를 실행한다.</li>
<li>Listen Server와 별도 Client처럼 추가 프로세스가 생기는 멀티플레이 환경을 실행한다.</li>
<li>프로젝트 모듈의 실제 실행 경로에 중단점을 둔다.</li>
<li>새 프로세스의 PID와 명령줄을 확인하고 네이티브 디버거로 연결한다.</li>
<li>IDE는 연결됐지만 중단점에 “심볼이 로드되지 않았다”는 안내가 나타나는지 확인한다.</li>
</ol><p>여러 프로세스의 이름이 같다면 PID만 보지 말고 실행 명령줄이나 창 제목도 확인한다. <a href="https://learn.microsoft.com/en-us/visualstudio/debugger/attach-to-running-processes-with-the-visual-studio-debugger?view=visualstudio" target="_blank">Visual Studio의 Attach to Process 문서</a>는 코드 유형이 맞는지 확인하고, 동일한 이름의 프로세스는 Command Line 열로 구분하도록 안내한다. Rider에서도 Attach to Process 창의 트리 보기와 디버거 선택기를 이용할 수 있다.</p></section>
<section><h2>레지스트리를 바꾸기 전에 심볼부터 확인하기<a href="#레지스트리를-바꾸기-전에-심볼부터-확인하기"><span>#</span></a></h2><p>중단점은 소스 파일만 있다고 활성화되지 않는다. 실행 중인 DLL과 정확히 일치하는 PDB가 로드되어야 한다. 다음 항목을 먼저 확인한다.</p><section><h3>Visual Studio<a href="#visual-studio"><span>#</span></a></h3><p>디버깅을 일시 중지한 뒤 <strong>Debug &gt; Windows &gt; Modules</strong>를 연다. 프로젝트나 플러그인 DLL을 찾아 <code>Symbol Status</code>를 확인한다. <a href="https://learn.microsoft.com/en-us/visualstudio/debugger/how-to-use-the-modules-window?view=visualstudio" target="_blank">Microsoft의 Modules 창 문서</a>는 다음 기능을 제공한다.</p><ul>
<li><code>Symbol Load Information</code>: PDB 검색 경로와 실패 이유 확인</li>
<li><code>Load Symbols</code>: 특정 모듈의 PDB를 직접 선택</li>
<li><code>Symbol Settings</code>: 심볼 서버와 자동 로드 포함·제외 규칙 확인</li>
</ul></section><section><h3>Rider<a href="#rider"><span>#</span></a></h3><p>Debug 창의 Modules 보기에서 대상 DLL과 Symbols 열을 확인한다. <a href="https://www.jetbrains.com/help/rider/Modules_view.html" target="_blank">Rider의 loaded modules 문서</a>에 따르면 <code>Load Symbols</code>로 DLL 옆이나 설정된 심볼 서버의 PDB를 찾고, 필요하면 파일 위치를 직접 지정할 수 있다.</p><p>다음 조건이 모두 맞을수록 500개 모듈 제한일 가능성이 높다.</p><ul>
<li>IDE에서 프로세스를 처음부터 디버그 실행하면 같은 중단점이 동작한다.</li>
<li>프로세스가 시작된 직후 연결하면 동작하지만, 플러그인과 DLL이 더 로드된 뒤에는 실패한다.</li>
<li>실행 중인 프로젝트 또는 플러그인 DLL이 Modules 목록에서 누락되거나 해당 PDB를 로드할 기회 자체가 없다.</li>
<li>PDB와 DLL은 같은 빌드 결과이고, 올바른 프로세스와 네이티브 코드 유형을 선택했다.</li>
</ul><p>반대로 DLL은 보이지만 PDB 경로가 틀리거나 PDB가 DLL과 일치하지 않는다면 레지스트리보다 빌드 산출물과 심볼 설정을 고쳐야 한다.</p></section></section>
<section><h2>늦게 연결할 때만 실패하는 이유<a href="#늦게-연결할-때만-실패하는-이유"><span>#</span></a></h2><p>JetBrains와 Epic의 지식 문서는 Windows에서 실행 중인 프로세스에 디버거를 붙일 때 기본적으로 처리할 수 있는 모듈 수가 500개로 제한된다고 설명한다. Unreal Engine은 엔진, 프로젝트, 플러그인 모듈을 다수 로드하므로 이 경계를 넘을 수 있다. 제한 뒤에 로드된 프로젝트 DLL의 정보가 attach 과정에서 디버거에 전달되지 않으면 IDE는 대응하는 PDB를 찾지 못하고 중단점을 활성화하지 못한다.</p><p>프로세스를 IDE에서 처음부터 디버그 실행하거나 아주 일찍 연결할 때 정상인 것도 이 차이로 설명된다. 디버거가 처음부터 붙어 있으면 이후 모듈 로드를 계속 관찰하지만, 이미 많은 모듈이 로드된 프로세스에 뒤늦게 연결할 때는 attach 시 열거 제한의 영향을 받을 수 있다.</p></section>
<section><h2><code>DebuggerMaxModuleMsgs</code> 설정하기<a href="#debuggermaxmodulemsgs-설정하기"><span>#</span></a></h2><p>이 설정은 <code>HKEY_LOCAL_MACHINE</code> 아래의 시스템 전체 값이며 관리자 권한과 재부팅이 필요하다. 잘못된 레지스트리 변경은 Windows 동작에 영향을 줄 수 있으므로 먼저 <a href="https://support.microsoft.com/en-us/windows/experience/backup-recovery/how-to-back-up-and-restore-the-registry-in-windows" target="_blank">Microsoft의 레지스트리 백업 절차</a>에 따라 <code>Session Manager</code> 키를 내보낸다.</p><ol>
<li>
<p>모든 작업을 저장하고 관리자 권한으로 <strong>Registry Editor</strong>를 실행한다.</p>
</li>
<li>
<p>다음 키로 이동한다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
</li>
<li>
<p>오른쪽 영역에서 <strong>New &gt; DWORD (32-bit) Value</strong>를 선택한다. 64비트 Windows에서도 값 형식은 <code>DWORD (32-bit)</code>이다.</p>
</li>
<li>
<p>이름을 정확히 <code>DebuggerMaxModuleMsgs</code>로 지정한다.</p>
</li>
<li>
<p>값을 편집할 때 <strong>Decimal</strong>을 선택하고 <code>2048</code>을 입력한다.</p>
</li>
<li>
<p>Registry Editor를 닫고 Windows를 재부팅한다.</p>
</li>
</ol><p>이 환경에서는 2048로 해결됐지만 모든 프로젝트에 같은 값이 충분하다는 뜻은 아니다. 처음부터 무작정 큰 수를 정하기보다 프로젝트가 실제로 로드하는 모듈 수를 감당할 값으로 시작한다.</p></section>
<section><h2>변경 결과 확인하기<a href="#변경-결과-확인하기"><span>#</span></a></h2><p>재부팅 뒤 같은 조건으로 다시 테스트한다.</p><ol>
<li>Unreal Editor와 추가 클라이언트를 이전과 같은 구성으로 실행한다.</li>
<li>문제가 발생하던 시점까지 기다린 뒤 올바른 프로세스에 연결한다.</li>
<li>Modules 보기에서 프로젝트 DLL과 그 DLL에 일치하는 PDB가 로드됐는지 확인한다.</li>
<li>중단점이 활성화되고 실제 코드 경로에서 멈추는지 확인한다.</li>
</ol><p>값을 높여도 대상 DLL이 목록에 없거나 PDB가 로드되지 않는다면 변경을 더 키우기 전에 다른 원인을 조사한다. 되돌릴 때는 백업한 키를 복원하거나 새로 만든 <code>DebuggerMaxModuleMsgs</code> 값만 제거한 뒤 다시 재부팅한다.</p></section>
<section><h2>이 방법으로 해결되지 않는 경우<a href="#이-방법으로-해결되지-않는-경우"><span>#</span></a></h2>

<table><thead><tr><th>증상</th><th>먼저 확인할 항목</th></tr></thead><tbody><tr><td>프로젝트 DLL은 보이지만 PDB가 로드되지 않음</td><td>DLL과 PDB가 같은 빌드인지, PDB 검색 경로가 맞는지 확인</td></tr><tr><td>일부 줄만 중단점으로 잡히지 않음</td><td>최적화로 코드가 제거·병합됐는지, 실제 실행 가능한 줄인지 확인</td></tr><tr><td>연결했지만 프로젝트 코드 전체가 보이지 않음</td><td>올바른 Client/Server PID와 Native 코드 디버거를 선택했는지 확인</td></tr><tr><td>모듈이 아직 목록에 없음</td><td>해당 플러그인이나 게임 기능이 실제로 모듈을 로드했는지 확인</td></tr><tr><td>IDE에서 직접 실행할 때도 동일하게 실패</td><td>모듈 제한보다 빌드 구성, 오래된 바이너리, PDB 불일치를 먼저 조사</td></tr><tr><td>엔진 코드만 디버깅되지 않음</td><td>Launcher 설치 옵션의 엔진 디버깅 심볼 유무를 별도로 확인</td></tr></tbody></table><p>비활성 중단점을 곧바로 레지스트리 문제로 단정하지 않는 것이 핵심이다. 실행 중인 DLL, PDB, 소스, 프로세스와 코드 유형이 일치하는지 확인한 뒤, <strong>늦게 attach할 때 특정 후반 모듈이 누락되는 패턴</strong>이 확인될 때만 <code>DebuggerMaxModuleMsgs</code>를 적용한다. 이 순서를 지키면 시스템 전체 설정을 불필요하게 바꾸지 않으면서 Unreal 프로젝트의 많은 모듈 때문에 생기는 실제 attach 문제를 구분할 수 있다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://forums.unrealengine.com/t/increase-the-number-of-modules-loaded-when-attaching-the-debugger/661624" target="_blank">Epic Developer Community: Increase the Number of Modules Loaded When Attaching the Debugger</a></li>
<li><a href="https://learn.microsoft.com/en-us/visualstudio/debugger/how-to-use-the-modules-window?view=visualstudio" target="_blank">Microsoft: View DLLs and executables in the Modules window</a></li>
<li><a href="https://learn.microsoft.com/en-us/visualstudio/debugger/attach-to-running-processes-with-the-visual-studio-debugger?view=visualstudio" target="_blank">Microsoft: Attach to running processes with the Visual Studio debugger</a></li>
<li><a href="https://www.jetbrains.com/help/rider/Modules_view.html" target="_blank">JetBrains: View loaded modules</a></li>
<li><a href="https://support.microsoft.com/en-us/windows/experience/backup-recovery/how-to-back-up-and-restore-the-registry-in-windows" target="_blank">Microsoft: How to back up and restore the registry in Windows</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/blueprint-callable-const-function-exec-pins/</id>
      <title type="text">UE4 BlueprintCallable 함수에 실행 핀이 보이지 않는 이유</title>
      <published>2021-12-22T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/blueprint-callable-const-function-exec-pins/"/>
      <summary type="text">UE 4.27에서 const BlueprintCallable 함수가 자동으로 Pure 노드가 되는 조건과 BlueprintPure=false로 실행 핀을 복원하는 방법을 설명합니다.</summary>
      <content type="html"><![CDATA[<blockquote><p>이 글은 2021년 12월의 <strong>Unreal Engine 4.27</strong> 동작을 기준으로 원문을 교정해 옮겼다. 당시 UHT 소스에서 실제로 실행 핀을 결정하던 조건을 기준으로 설명한다.</p></blockquote>
<p><code>BlueprintCallable</code>을 붙인 C++ 함수를 Blueprint에 배치했지만 흰색 실행 핀이 나타나지 않았다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UFUNCTION</span><span>(</span><span>BlueprintCallable</span><span>)</span></div></div><div><div><div>2</div></div><div><span>UMyItem</span><span>*</span><span> </span><span>FindItem</span><span>() </span><span>const</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p><code>BlueprintCallable</code>이면 반드시 실행 핀이 생기고, <code>BlueprintPure</code>일 때만 실행 핀이 사라진다고 생각하기 쉽다. 그러나 UE 4.27에서는 <code>const</code> 함수에도 자동으로 적용되는 규칙이 하나 더 있었다.</p>
<section><h2>UE 4.27 UHT가 Pure로 판단하던 조건<a href="#ue-427-uht가-pure로-판단하던-조건"><span>#</span></a></h2><p>당시 UnrealHeaderTool은 아래 네 조건을 모두 만족하는 함수를 자동으로 <code>BlueprintPure</code>로 표시했다.</p><ol>
<li>함수가 <code>const</code>다.</li>
<li><code>BlueprintCallable</code>이다.</li>
<li>반환값이나 output parameter가 있다.</li>
<li><code>BlueprintPure = false</code>로 자동 변환을 막지 않았다.</li>
</ol><p>엔진이 함수 본문을 분석해 실제 side effect가 있는지 판정한 것은 아니다. C++ 선언의 <code>const</code>와 Blueprint로 내보낼 output의 존재만 보고 적용한 규칙이었다.</p><p><code>const</code>는 해당 객체의 일반 멤버를 변경하지 않는다는 C++ 계약이지만, 함수가 항상 가볍거나 외부 상태를 전혀 건드리지 않는다는 뜻은 아니다. 로그를 남기거나, 전역·subsystem 상태를 읽고 쓰거나, 비용이 큰 검색을 수행할 수도 있다.</p></section>
<section><h2>실행 핀을 명시적으로 복원한다<a href="#실행-핀을-명시적으로-복원한다"><span>#</span></a></h2><p>호출 순서를 눈에 보이게 만들고 싶다면 <code>BlueprintPure = false</code>를 지정한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UFUNCTION</span><span>(</span><span>BlueprintCallable</span><span>, </span><span>BlueprintPure</span><span> </span><span>=</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>2</div></div><div><span>UMyItem</span><span>*</span><span> </span><span>FindItem</span><span>() </span><span>const</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>const</code>를 제거해서 해결할 수도 있다. 다만 함수가 객체 상태를 변경하지 않는다면 C++ 계약까지 약하게 만들 이유는 없다. <code>const</code>는 유지하고 Blueprint 노드의 실행 의미만 명시하는 쪽이 의도를 더 잘 드러낸다.</p></section>
<section><h2>언제 Pure가 적합한가<a href="#언제-pure가-적합한가"><span>#</span></a></h2><p>Pure 노드는 데이터가 필요한 지점에서 Blueprint compiler가 평가한다. 실행선이 없고 한 번만 호출된다는 보장도 없다. 따라서 아래 조건을 만족하는 함수에 잘 맞는다.</p><ul>
<li>빠르고 예측 가능한 getter다.</li>
<li>여러 번 평가되어도 결과와 상태가 달라지지 않는다.</li>
<li>호출 순서를 다른 노드와 조정할 필요가 없다.</li>
<li>외부에 관찰 가능한 side effect가 없다.</li>
</ul><p>반대로 호출 순서나 횟수를 통제해야 하는 함수에는 실행 핀이 있는 편이 안전하다.</p><ul>
<li>큰 배열이나 월드의 Actor를 검색한다.</li>
<li>호출 횟수가 성능에 직접 영향을 준다.</li>
<li>cache, log, 통계나 subsystem 상태를 변경한다.</li>
<li>Blueprint 작성자가 호출 순서를 명확히 제어해야 한다.</li>
</ul></section>
<section><h2>원문의 표현에서 교정한 부분<a href="#원문의-표현에서-교정한-부분"><span>#</span></a></h2><p>원문은 “side effect를 보유했느냐에 따라 엔진이 실행 핀을 정한다”고 설명했다. API 설계 기준으로는 맞는 방향이지만, <strong>UE 4.27 UHT가 함수 본문을 분석한 것은 아니다.</strong> 자동 Pure 처리는 <code>const</code>, output, <code>BlueprintCallable</code>, <code>BlueprintPure=false</code> 여부만 확인했다.</p><p>또한 <code>void</code> 함수에 입력만 있는 최소 예제는 당시 UHT의 자동 Pure 조건을 완전히 보여 주지 못한다. 이 글에서는 실제 조건이 드러나도록 반환값이 있는 예제로 바꿨다. 핵심 해결책인 <code>BlueprintPure = false</code>는 그대로다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/ufunctions?application_version=4.27" target="_blank">Epic Games: UFunctions — UE 4.27</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/blueprint-function-libraries?application_version=4.27" target="_blank">Epic Games: Blueprint Function Libraries — UE 4.27</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-network-dormancy-and-server-optimization/</id>
      <title type="text">UE4 Net Dormancy와 서버 리플리케이션 최적화</title>
      <published>2021-09-29T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-network-dormancy-and-server-optimization/"/>
      <summary type="text">UE 4.26~4.27의 Net Dormancy 상태, FlushNetDormancy와 ForceNetUpdate의 차이, Adaptive NetUpdateFrequency, RPC와 프로퍼티 복제 최적화 기준을 정리합니다.</summary>
      <content type="html"><![CDATA[<blockquote><p>이 글은 2021년 8–9월에 작성한 <code>NetDormancy 이해</code>와 <code>Networking in UE4: Server Optimizations</code> 메모를 <strong>Unreal Engine 4.26–4.27</strong> 기준으로 합쳐 옮긴 글이다. 당시 잘못 이해했던 <code>DORM_Initial</code>, <code>NetUpdateFrequency</code>, Multicast RPC 부분은 같은 시점의 엔진 소스와 문서에 맞춰 교정했다.</p></blockquote>
<p>UE4 서버 리플리케이션 최적화는 payload 몇 byte를 줄이기 전에 불필요한 Actor부터 복제 후보에서 빼야 한다. 당시 Epic 문서도 아래 순서로 최적화하길 권했다.</p>
<ol>
<li>필요 없는 Actor의 replication을 끈다.</li>
<li><code>NetUpdateFrequency</code>를 가능한 낮춘다.</li>
<li>Dormancy를 사용한다.</li>
<li>Relevancy 범위를 줄인다.</li>
<li>그 뒤에 프로퍼티와 RPC payload를 줄인다.</li>
</ol>
<section><h2><code>ENetDormancy</code> 상태<a href="#enetdormancy-상태"><span>#</span></a></h2><p>UE 4.26~4.27의 <code>ENetDormancy</code>는 다음 상태를 제공했다.</p>

<table><thead><tr><th>상태</th><th>당시 의미</th></tr></thead><tbody><tr><td><code>DORM_Never</code></td><td>이 Actor를 dormancy 대상으로 사용하지 않는다.</td></tr><tr><td><code>DORM_Awake</code></td><td>현재 깨어 있으며, 게임 코드가 나중에 dormant 상태로 바꿀 수 있다.</td></tr><tr><td><code>DORM_DormantAll</code></td><td>모든 connection에 대해 dormant 상태가 되기를 원한다.</td></tr><tr><td><code>DORM_DormantPartial</code></td><td><code>GetNetDormancy()</code> 결과에 따라 connection별로 dormancy를 결정한다.</td></tr><tr><td><code>DORM_Initial</code></td><td>맵에 배치된 startup Actor가 모든 connection에 대해 처음부터 dormant다.</td></tr></tbody></table><p>Dormant Actor는 일반적인 property replication 비교와 전송 대상에서 빠진다. 나무, 문, 고정 건물처럼 평소에는 변하지 않고 특정 사건에만 상태가 바뀌는 Actor에 적합하다.</p></section>
<section><h2><code>DORM_Initial</code>은 “한 번 보낸 뒤 잠든다”가 아니다<a href="#dorm_initial은-한-번-보낸-뒤-잠든다가-아니다"><span>#</span></a></h2><p>원문에서는 <code>DORM_Initial</code>을 “맵에 배치될 때 한 번 복제한 뒤 <code>DORM_DormantAll</code>로 전환”한다고 설명했다. UE 4.27의 detailed replication flow는 <code>DORM_Initial</code> Actor를 초기 고려 단계에서 바로 건너뛴다.</p><p>맵에 함께 포함된 Actor와 기본 상태는 client도 level package를 통해 이미 알고 있다. 서버에서 runtime에 값을 변경했다면 dormancy를 flush해야 그 변경이 전송된다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>ATreeActor</span><span>::</span></span><span>ATreeActor</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>bReplicates</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>4</div></div><div><span>    </span><span>NetDormancy</span><span> </span><span>=</span><span> </span><span>DORM_Initial</span><span>;</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>void</span><span><span> </span><span>ATreeActor</span><span>::</span></span><span>ApplyDamage</span><span>(</span><span>int32</span><span><span> </span><span>Damage</span><span>)</span></span></div></div><div><div><div>8</div></div><div><span>{</span></div></div><div><div><div>9</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>HasAuthority</span><span>())</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>// 변경 전에 dormant connection을 다시 전송 가능 상태로 만든다.</span></div></div><div><div><div>15</div></div><div><span>    </span><span>FlushNetDormancy</span><span>();</span></div></div><div><div><div>16</div></div><div><span>    </span><span>Health</span><span> </span><span>=</span><span> FMath::</span><span>Max</span><span>(</span><span>0</span><span>, </span><span>Health</span><span> </span><span>-</span><span> </span><span>Damage</span><span>);</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>UE 4.27의 <code>FlushNetDormancy</code>는 <code>DORM_Initial</code>을 <code>DORM_DormantAll</code>로 바꾸고, 각 connection의 dormant 목록에서 Actor를 제거한다. Actor 자체의 최종 정책은 dormant 상태로 남으므로 적어도 한 번 더 업데이트한 뒤 다시 잠들 수 있다.</p></section>
<section><h2><code>FlushNetDormancy</code>와 <code>ForceNetUpdate</code><a href="#flushnetdormancy와-forcenetupdate"><span>#</span></a></h2><p>두 함수는 비슷해 보이지만 쓰임이 다르다.</p><ul>
<li><code>FlushNetDormancy()</code>: dormant Actor의 변경사항을 다시 전송할 수 있게 한다.</li>
<li><code>ForceNetUpdate()</code>: 다음 정규 update 간격을 기다리지 않고 Actor를 replication 대상으로 당긴다.</li>
</ul><p>UE 4.27의 <code>AActor::ForceNetUpdate()</code>는 Actor가 <code>DORM_Awake</code>보다 높은 dormancy 상태라면 내부에서 <code>FlushNetDormancy()</code>도 호출했다. 따라서 dormant Actor에서 <code>ForceNetUpdate</code>가 결과적으로 flush를 수행한다는 원문의 메모는 맞았다.</p><p>코드에서 의도를 드러내려면 용도에 맞춰 고르는 편이 좋다.</p><ul>
<li>“이 dormant Actor의 상태가 바뀌었다” → <code>FlushNetDormancy</code></li>
<li>“깨어 있는 Actor를 지금 한 번 더 빨리 보내고 싶다” → <code>ForceNetUpdate</code></li>
</ul></section>
<section><h2>Multicast RPC가 자동으로 깨워 준다고 가정하지 않는다<a href="#multicast-rpc가-자동으로-깨워-준다고-가정하지-않는다"><span>#</span></a></h2><p>원문은 Multicast RPC가 <code>FlushNetDormancy</code>를 강제로 호출한다고 적었다. 하지만 UE 4.27의 기본 <code>UNetDriver::ProcessRemoteFunction</code> 경로에는 Multicast 호출만으로 Actor의 dormancy를 flush하는 처리가 없다. 당시 구현도 connection에 대한 relevancy와 Actor channel 상태를 전제로 RPC를 보냈다.</p><p>Dormant Actor에서 중요한 상태가 바뀐다면 아래 순서로 처리한다.</p><ol>
<li>서버에서 <code>FlushNetDormancy()</code>를 호출한다.</li>
<li>replicated property를 변경한다.</li>
<li>일시적인 연출이 필요할 때만 Multicast RPC를 추가한다.</li>
</ol><p>지속되어야 하는 게임 상태를 RPC만으로 표현하면 join-in-progress client가 과거 이벤트를 받지 못한다. 상태는 replicated property로, 소리·파티클처럼 순간적인 사건은 RPC로 나누는 편이 안전하다.</p></section>
<section><h2><code>NetUpdateFrequency</code>는 무조건 크게 두지 않는다<a href="#netupdatefrequency는-무조건-크게-두지-않는다"><span>#</span></a></h2><p>기본 동작에서 <code>NetUpdateFrequency</code>는 초당 최대 update 시도 횟수다. 값이 높을수록 더 자주 후보가 되므로, 중요하지 않거나 천천히 변하는 Actor는 값을 낮추는 것이 기본 최적화다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>NetUpdateFrequency</span><span> </span><span>=</span><span> </span><span><span>5.</span><span>f</span></span><span>;</span></div></div><div><div><div>2</div></div><div><span>MinNetUpdateFrequency</span><span> </span><span>=</span><span> </span><span><span>1.</span><span>f</span></span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>net.UseAdaptiveNetUpdateFrequency=1</code>을 켜면 의미가 조금 달라진다.</p><ul>
<li><code>NetUpdateFrequency</code>: adaptive 범위의 최대 빈도</li>
<li><code>MinNetUpdateFrequency</code>: adaptive 범위의 최소 빈도</li>
</ul><p>변화가 잦으면 최대치 쪽으로, 의미 있는 변화가 오랫동안 없으면 최소치 쪽으로 update 빈도를 조절한다. 원문의 “<code>NetUpdateFrequency</code>를 크게 하고 <code>ForceNetUpdate</code>를 사용”이라는 메모는 adaptive 범위의 최대치를 뜻했다면 설명이 된다. 하지만 일반 규칙으로 적용하면 오히려 비용을 늘린다.</p></section>
<section><h2>전송량을 줄이는 당시의 실전 항목<a href="#전송량을-줄이는-당시의-실전-항목"><span>#</span></a></h2><section><h3>복제 자체를 줄인다<a href="#복제-자체를-줄인다"><span>#</span></a></h3><ul>
<li>cosmetic 전용 Actor와 Component는 replication을 끈다.</li>
<li>클라이언트에서 파생할 수 있는 값은 별도 property로 보내지 않는다.</li>
<li>상태 변경 주기가 전혀 다른 책임은 별도 Actor나 subobject로 나누는 것을 검토한다.</li>
<li>정적인 배치 Actor는 <code>DORM_Initial</code> 또는 <code>DORM_DormantAll</code>을 검토한다.</li>
</ul></section><section><h3>데이터 표현을 줄인다<a href="#데이터-표현을-줄인다"><span>#</span></a></h3><ul>
<li>위치·벡터 정밀도가 충분하다면 <code>FVector_NetQuantize</code> 계열을 사용한다.</li>
<li>자주 보내는 문자열 대신 enum이나 작은 ID를 검토한다.</li>
<li>UE 4.27에서 RPC의 <code>FName</code>은 일반적으로 압축되지 않아 호출마다 문자열 비용이 생길 수 있었다.</li>
<li>자주 변하는 배열은 <code>FFastArraySerializer</code>로 항목 단위 delta를 보내는 방식을 검토한다.</li>
</ul></section><section><h3>RPC를 상태 저장소로 사용하지 않는다<a href="#rpc를-상태-저장소로-사용하지-않는다"><span>#</span></a></h3><ul>
<li>매 Tick reliable RPC를 보내지 않는다. reliable buffer가 밀리면 지연이 커지고 overflow 시 연결이 끊길 수 있다.</li>
<li>모든 중간값이 필요한 것이 아니라 최종 상태가 중요하다면 replicated property를 사용한다.</li>
<li>unreliable RPC가 유실되어도 다음 상태 동기화로 복구되는 구조를 만든다.</li>
<li>Multicast는 연결 수만큼 트래픽을 만들므로 일시적인 사건에 제한한다.</li>
</ul></section></section>
<section><h2>Network Profiler로 먼저 측정한다<a href="#network-profiler로-먼저-측정한다"><span>#</span></a></h2><p>UE 4.27에서는 다음 명령으로 <code>.nprof</code> 기록을 제어할 수 있었다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>netprofile enable</span></div></div><div><div><div>2</div></div><div><span>netprofile disable</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>파일은 프로젝트의 <code>Saved/Profiling</code>에 저장되며, 당시 standalone 도구는 다음 경로에 있었다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Engine/Binaries/DotNET/NetworkProfiler.exe</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Actor 수, RPC 호출 횟수, property payload를 측정한 뒤 replication off, update frequency, dormancy, relevancy, payload 순서로 줄인다. 추측으로 bit flag부터 만들기보다 큰 비용이 어디에서 발생하는지 확인하는 일이 먼저다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://velog.io/@hon454/NetDormancy-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%ED%9C%B4%EB%A9%B4-%EC%97%AC%EB%B6%80-%EC%9D%B4%ED%95%B4" target="_blank">원문: NetDormancy 이해</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/performance-and-bandwidth-tips?application_version=4.27" target="_blank">Epic Games: Performance and Bandwidth Tips — UE 4.27</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/property-replication?application_version=4.27" target="_blank">Epic Games: Property Replication — UE 4.27</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/detailed-actor-replication-flow?application_version=4.27" target="_blank">Epic Games: Detailed Actor Replication Flow — UE 4.27</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/rpcs?application_version=4.27" target="_blank">Epic Games: RPCs — UE 4.27</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/network-profiler?application_version=4.27" target="_blank">Epic Games: Network Profiler — UE 4.27</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/fix-unreal-interface-linker-error-across-modules/</id>
      <title type="text">UE4 다른 모듈의 UInterface 캐스팅에서 LNK1120이 발생할 때</title>
      <published>2021-07-19T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/fix-unreal-interface-linker-error-across-modules/"/>
      <summary type="text">UE 4.26에서 다른 모듈의 UInterface를 사용할 때 GetPrivateStaticClass 링크 오류가 발생한 원인과 두 인터페이스 타입에 모듈 API 매크로를 적용하는 방법을 정리합니다.</summary>
      <content type="html"><![CDATA[<blockquote><p>이 글은 2021년 7월, <strong>Unreal Engine 4.26.2</strong>에서 겪은 문제를 당시 엔진 구조를 기준으로 다시 검토해 옮긴 기록이다. 이후 엔진 버전의 동작을 소급해서 적용하지 않았다.</p></blockquote>
<p>Rider의 Unreal 클래스 생성 기능으로 <code>UInterface</code>를 만든 뒤, 그 인터페이스를 다른 게임 모듈에서 사용했다. 헤더 include와 컴파일은 정상이었지만 링크 단계에서 다음 오류가 발생했다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>unresolved external symbol</span></div></div><div><div><div>2</div></div><div><span>"private: static class UClass * __cdecl UDefaultAI::GetPrivateStaticClass(void)"</span></div></div><div><div><div>3</div></div><div><span>referenced in function ...</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>LNK1120: 1 unresolved externals</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p><code>Build.cs</code>의 모듈 의존성은 이미 설정되어 있었다. 의존 모듈을 찾지 못한 것이 아니라, <code>UDefaultAI</code>가 들어 있는 DLL에서 reflection용 심볼을 외부로 내보내지 않은 탓이었다.</p>
<section><h2>문제가 된 선언<a href="#문제가-된-선언"><span>#</span></a></h2><p><code>UInterface</code>를 만들면 실제로는 두 C++ 타입이 생긴다.</p><ul>
<li><code>UDefaultAI</code>: Unreal reflection에 등록되는 <code>UObject</code> 쪽 타입</li>
<li><code>IDefaultAI</code>: 게임플레이 인터페이스를 선언하는 native C++ 타입</li>
</ul><p>문제가 된 코드는 모듈 API 매크로가 <code>IDefaultAI</code>에만 붙어 있었다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"UObject/Interface.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"DefaultAI.generated.h"</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>UINTERFACE</span><span>()</span></div></div><div><div><div>8</div></div><div><span>class</span><span> </span><span>UDefaultAI</span><span> : </span><span>public</span><span> </span><span>UInterface</span></div></div><div><div><div>9</div></div><div><span>{</span></div></div><div><div><div>10</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>11</div></div><div><span>};</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>class</span><span> AI_API </span><span>IDefaultAI</span></div></div><div><div><div>14</div></div><div><span>{</span></div></div><div><div><div>15</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>public:</span></div></div><div><div><div>18</div></div><div><span>    </span><span>virtual</span><span> </span><span>class</span><span> </span><span>AAIController</span><span>*</span><span> </span><span>GetAIController</span><span>() </span><span>const</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>19</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>다른 모듈에서 <code>Cast&lt;IDefaultAI&gt;(Object)</code> 같은 reflection 기반 경로를 사용하려면 <code>UDefaultAI::StaticClass()</code>와 그 내부의 <code>GetPrivateStaticClass()</code> 심볼도 필요하다. 그러나 <code>UDefaultAI</code>에는 <code>AI_API</code>가 없었고, modular build의 다른 DLL은 해당 심볼을 가져올 수 없었다.</p></section>
<section><h2>두 타입 모두 export한다<a href="#두-타입-모두-export한다"><span>#</span></a></h2><p><code>UDefaultAI</code>와 <code>IDefaultAI</code> 양쪽에 인터페이스가 정의된 모듈의 API 매크로를 붙인다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#pragma</span><span> </span><span>once</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>#include</span><span> </span><span>"CoreMinimal.h"</span></div></div><div><div><div>4</div></div><div><span>#include</span><span> </span><span>"UObject/Interface.h"</span></div></div><div><div><div>5</div></div><div><span>#include</span><span> </span><span>"DefaultAI.generated.h"</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>UINTERFACE</span><span>()</span></div></div><div><div><div>8</div></div><div><span>class</span><span> AI_API </span><span>UDefaultAI</span><span> : </span><span>public</span><span> </span><span>UInterface</span></div></div><div><div><div>9</div></div><div><span>{</span></div></div><div><div><div>10</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>11</div></div><div><span>};</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>class</span><span> AI_API </span><span>IDefaultAI</span></div></div><div><div><div>14</div></div><div><span>{</span></div></div><div><div><div>15</div></div><div><span>    </span><span>GENERATED_BODY</span><span>()</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>public:</span></div></div><div><div><div>18</div></div><div><span>    </span><span>virtual</span><span> </span><span>class</span><span> </span><span>AAIController</span><span>*</span><span> </span><span>GetAIController</span><span>() </span><span>const</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>19</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>당시 UE4의 모듈 API 매크로는 modular build의 빌드 대상에 따라 바뀌었다.</p><ul>
<li>타입을 정의하는 모듈을 빌드할 때: <code>__declspec(dllexport)</code></li>
<li>타입을 사용하는 모듈을 빌드할 때: <code>__declspec(dllimport)</code></li>
<li>monolithic build일 때: 빈 매크로</li>
</ul><p>이 때문에 오류는 Editor와 같은 DLL 구성에서만 드러나고, monolithic target에서는 우연히 통과할 수 있다. 특정 빌드에서 통과했다는 사실만으로 export 선언이 올바르다고 판단하면 안 된다.</p></section>
<section><h2><code>Build.cs</code> 의존성도 별도로 필요하다<a href="#buildcs-의존성도-별도로-필요하다"><span>#</span></a></h2><p>API 매크로와 모듈 의존성은 서로 대체 관계가 아니다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>PublicDependencyModuleNames</span><span>.</span></span><span>AddRange</span><span>(new[]</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"Core"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>"CoreUObject"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>    </span><span>"Engine"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>"AI"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>Build.cs</code>는 어떤 모듈의 include·link 환경을 사용할지 정한다.</li>
<li><code>AI_API</code>는 그 DLL에서 어떤 클래스와 함수를 외부에 공개할지 정한다.</li>
</ul><p>헤더 경로가 보이는데 <code>GetPrivateStaticClass</code>, vtable 또는 함수 구현에서 unresolved external이 발생한다면 아래 순서로 확인한다.</p><ol>
<li>사용하는 모듈의 <code>Build.cs</code>에 정의 모듈이 들어 있는가?</li>
<li>외부에서 쓰는 클래스·함수에 <code>*_API</code> 매크로가 있는가?</li>
<li><code>UInterface</code>라면 <code>U...</code> 타입과 <code>I...</code> 타입을 모두 확인했는가?</li>
<li>선언과 구현의 함수 signature가 정확히 같은가?</li>
<li>Editor DLL build와 packaged monolithic build의 차이에 가려진 문제는 아닌가?</li>
</ol><p>이 사례에서는 3번이 원인이었다. 자동 생성된 코드라도 모듈 경계를 넘는 타입이라면 export 범위를 직접 검토해야 한다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/module-api-specifiers?application_version=4.27" target="_blank">Epic Games: Module API Specifiers — UE 4.27</a></li>
<li><a href="https://dev.epicgames.com/documentation/unreal-engine/programming-basics?application_version=4.27" target="_blank">Epic Games: Programming Basics — UE 4.27</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/unreal-engine-play-anim-montage-return-value/</id>
      <title type="text">UE4 ACharacter::PlayAnimMontage 반환값이 실제 재생시간과 다른 이유</title>
      <published>2021-05-18T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/unreal-engine-play-anim-montage-return-value/"/>
      <summary type="text">UE 4.26의 ACharacter::PlayAnimMontage가 Play Rate와 Rate Scale이 반영되지 않은 원본 몽타주 길이를 반환하던 이유와 안전한 대안을 설명합니다.</summary>
      <content type="html"><![CDATA[<blockquote><p>이 글은 2021년 5월의 <strong>Unreal Engine 4.26</strong> 동작을 기준으로 검토해 옮겼다. UE 4.27.2 소스에서도 같은 호출 경로가 유지되는 것을 함께 확인했다.</p></blockquote>
<p>Character에서 Animation Montage를 재생할 때 흔히 <code>ACharacter::PlayAnimMontage</code>를 사용한다.</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/**</span></div></div><div><div><div>2</div></div><div><span><span> </span></span><span>* Play Animation Montage on the character mesh.</span></div></div><div><div><div>3</div></div><div><span><span> </span></span><span>* Returns the length of the animation montage in seconds,</span></div></div><div><div><div>4</div></div><div><span><span> </span></span><span>* or 0.f if failed to play.</span></div></div><div><div><div>5</div></div><div><span><span> </span></span><span>*/</span></div></div><div><div><div>6</div></div><div><span>UFUNCTION</span><span>(</span><span>BlueprintCallable</span><span>, </span><span>Category</span><span> </span><span>=</span><span> </span><span>Animation</span><span>)</span></div></div><div><div><div>7</div></div><div><span>virtual</span><span> </span><span>float</span><span> </span><span>PlayAnimMontage</span><span>(</span></div></div><div><div><div>8</div></div><div><span>    </span><span>class</span><span> </span><span>UAnimMontage</span><span>*</span><span><span> </span><span>AnimMontage</span><span>,</span></span></div></div><div><div><div>9</div></div><div><span>    </span><span>float</span><span><span> </span><span>InPlayRate</span><span> </span></span><span>=</span><span> </span><span><span>1.</span><span>f</span></span><span>,</span></div></div><div><div><div>10</div></div><div><span>    </span><span>FName</span><span><span> </span><span>StartSectionName</span><span> </span></span><span>=</span><span> </span><span>NAME_None</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p>이 반환값은 “현재 설정으로 실제 재생되는 시간”이라고 해석하기 쉽다. 하지만 UE 4.26에서 이 함수는 <code>InPlayRate</code>, Montage의 <code>RateScale</code>, <code>StartSectionName</code>이 반영된 남은 시간을 반환하지 않았다.</p>
<section><h2>내부에서는 기본 반환 형식을 사용한다<a href="#내부에서는-기본-반환-형식을-사용한다"><span>#</span></a></h2><p>당시 구현은 <code>UAnimInstance::Montage_Play</code>를 아래와 같이 호출했다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>float</span><span><span> </span><span>ACharacter</span><span>::</span></span><span>PlayAnimMontage</span><span>(</span></div></div><div><div><div>2</div></div><div><span>    </span><span>UAnimMontage</span><span>*</span><span><span> </span><span>AnimMontage</span><span>,</span></span></div></div><div><div><div>3</div></div><div><span>    </span><span>float</span><span><span> </span><span>InPlayRate</span><span>,</span></span></div></div><div><div><div>4</div></div><div><span>    </span><span>FName</span><span><span> </span><span>StartSectionName</span><span>)</span></span></div></div><div><div><div>5</div></div><div><span>{</span></div></div><div><div><div>6</div></div><div><span>    </span><span>UAnimInstance</span><span>*</span><span> </span><span>AnimInstance</span><span> </span><span>=</span><span> </span><span>Mesh</span><span> </span><span>?</span><span> </span><span>Mesh</span><span>-&gt;</span><span>GetAnimInstance</span><span>() </span><span>:</span><span> </span><span>nullptr</span><span>;</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>    </span><span>if</span><span> (</span><span>AnimMontage</span><span> </span><span>&amp;&amp;</span><span> </span><span>AnimInstance</span><span>)</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>10</div></div><div><span>        </span><span>const</span><span> </span><span>float</span><span> </span><span>Duration</span><span> </span><span>=</span><span> </span><span>AnimInstance</span><span>-&gt;</span><span>Montage_Play</span><span>(</span></div></div><div><div><div>11</div></div><div><span>            </span><span>AnimMontage</span><span>,</span></div></div><div><div><div>12</div></div><div><span>            </span><span>InPlayRate</span><span>);</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>        </span><span>if</span><span> (</span><span>Duration</span><span> </span><span>&gt;</span><span> </span><span><span>0.</span><span>f</span></span><span>)</span></div></div><div><div><div>15</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>16</div></div><div><span>            </span><span>if</span><span> (</span><span>StartSectionName</span><span> </span><span>!=</span><span> </span><span>NAME_None</span><span>)</span></div></div><div><div><div>17</div></div><div><span><span>            </span></span><span>{</span></div></div><div><div><div>18</div></div><div><span>                </span><span>AnimInstance</span><span>-&gt;</span><span>Montage_JumpToSection</span><span>(</span></div></div><div><div><div>19</div></div><div><span>                    </span><span>StartSectionName</span><span>,</span></div></div><div><div><div>20</div></div><div><span>                    </span><span>AnimMontage</span><span>);</span></div></div><div><div><div>21</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>            </span><span>return</span><span> </span><span>Duration</span><span>;</span></div></div><div><div><div>24</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>    </span><span>return</span><span> </span><span><span>0.</span><span>f</span></span><span>;</span></div></div><div><div><div>28</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p><code>Montage_Play</code>의 세 번째 인자는 아래 enum이며, 기본값은 <code>MontageLength</code>였다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>enum</span><span> </span><span>class</span><span> </span><span>EMontagePlayReturnType</span><span> : </span><span>uint8</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>MontageLength</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>Duration</span><span>,</span></div></div><div><div><div>5</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>ACharacter::PlayAnimMontage</code>는 이 기본값을 그대로 사용해 <code>MontageToPlay-&gt;SequenceLength</code>를 반환한다. 재생 속도를 바꾸더라도 반환값은 원본 전체 길이다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// UE 4.27.2의 Montage_Play 반환 부분을 단순화한 형태</span></div></div><div><div><div>2</div></div><div><span>return</span><span> </span><span>ReturnValueType</span><span> </span><span>==</span><span> EMontagePlayReturnType::</span><span>MontageLength</span></div></div><div><div><div>3</div></div><div><span>    </span><span>?</span><span> </span><span>MontageLength</span></div></div><div><div><div>4</div></div><div><span>    </span><span>:</span><span> </span><span>MontageLength</span><span> </span><span>/</span><span> (</span><span>InPlayRate</span><span> </span><span>*</span><span> </span><span>MontageToPlay</span><span>-&gt;</span><span>RateScale</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>StartSectionName</code>도 반환값을 계산한 뒤 <code>Montage_JumpToSection</code>으로 적용되므로, 특정 Section에서 시작해도 반환값은 전체 Montage 길이 그대로다.</p></section>
<section><h2>속도가 반영된 전체 길이가 필요할 때<a href="#속도가-반영된-전체-길이가-필요할-때"><span>#</span></a></h2><p>Character wrapper 대신 Anim Instance의 함수를 직접 호출하고 반환 형식을 <code>Duration</code>으로 지정할 수 있다.</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UAnimInstance</span><span>*</span><span> </span><span>AnimInstance</span><span> </span><span>=</span><span> </span><span>GetMesh</span><span>()-&gt;</span><span>GetAnimInstance</span><span>();</span></div></div><div><div><div>2</div></div><div><span>if</span><span> (</span><span>!</span><span>AnimInstance</span><span> </span><span>||</span><span> </span><span>!</span><span>AnimMontage</span><span>)</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>return</span><span> </span><span><span>0.</span><span>f</span></span><span>;</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>const</span><span> </span><span>float</span><span> </span><span>Duration</span><span> </span><span>=</span><span> </span><span>AnimInstance</span><span>-&gt;</span><span>Montage_Play</span><span>(</span></div></div><div><div><div>8</div></div><div><span>    </span><span>AnimMontage</span><span>,</span></div></div><div><div><div>9</div></div><div><span>    </span><span>InPlayRate</span><span>,</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>EMontagePlayReturnType::</span><span>Duration</span><span>);</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>if</span><span> (</span><span>Duration</span><span> </span><span>&gt;</span><span> </span><span><span>0.</span><span>f</span></span><span> </span><span>&amp;&amp;</span><span> </span><span>StartSectionName</span><span> </span><span>!=</span><span> </span><span>NAME_None</span><span>)</span></div></div><div><div><div>13</div></div><div><span>{</span></div></div><div><div><div>14</div></div><div><span>    </span><span>AnimInstance</span><span>-&gt;</span><span>Montage_JumpToSection</span><span>(</span></div></div><div><div><div>15</div></div><div><span>        </span><span>StartSectionName</span><span>,</span></div></div><div><div><div>16</div></div><div><span>        </span><span>AnimMontage</span><span>);</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>더 보기</span><span>간략히 보기</span></div></div><p>이때 얻는 <code>Duration</code>도 <strong>전체 Montage를 처음부터 재생한다고 가정한 속도 보정 길이</strong>다. 시작 Section, Section 연결 관계, 반복 Section, runtime의 <code>Montage_SetNextSection</code>, blend out과 interruption까지 포함한 실제 종료 시점은 표현하지 못한다.</p></section>
<section><h2>원문의 수식은 제한적으로만 맞는다<a href="#원문의-수식은-제한적으로만-맞는다"><span>#</span></a></h2><p>원문에서는 다음 계산을 제안했다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>float</span><span> </span><span>Duration</span><span> </span><span>=</span></div></div><div><div><div>2</div></div><div><span>    </span><span>PlayAnimMontage</span><span>(</span><span>AnimMontage</span><span>, </span><span>InPlayRate</span><span>) </span><span>/</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>(</span><span>InPlayRate</span><span> </span><span>*</span><span> </span><span>AnimMontage</span><span>-&gt;</span><span>RateScale</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>UE 4.26의 <code>Montage_Play(..., EMontagePlayReturnType::Duration)</code> 내부 계산과 같은 형태이므로, <strong>전체 Montage가 순서대로 한 번 재생되는 단순한 경우</strong>에는 맞는다. 다만 아래 상황에서는 실제 종료시간이 아니다.</p><ul>
<li><code>StartSectionName</code>으로 중간에서 시작한다.</li>
<li>Section이 반복되거나 runtime에 다음 Section을 바꾼다.</li>
<li>다른 Montage가 재생을 중단한다.</li>
<li>blend out이나 notify 시점을 기다리는 것이 목적이다.</li>
<li>0 또는 비정상적인 Play Rate를 전달한다.</li>
</ul><p>게임플레이 로직을 정확한 종료 시점에 연결하려면 float 길이로 timer를 예약하기보다 Montage 종료 delegate, Blueprint의 <code>On Completed</code>·<code>On Blend Out</code>·<code>On Interrupted</code>, Anim Notify를 사용한다. 길이 반환값은 재생 성공 여부와 단순한 예상 시간을 얻는 용도로만 쓰는 편이 안전하다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/using-animation-montages?application_version=4.27" target="_blank">Epic Games: Using Animation Montages — UE 4.27</a></li>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/editing-an-animation-montage?application_version=4.27" target="_blank">Epic Games: Editing an Animation Montage — UE 4.27</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://hon454.github.io/posts/fix-skeletal-mesh-scale-physics-state/</id>
      <title type="text">UE4 Skeletal Mesh Scale 복원 후 래그돌 물리가 깨질 때</title>
      <published>2021-05-17T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>Jihoon Jeon</name></author>
      <link rel="alternate" href="https://hon454.github.io/posts/fix-skeletal-mesh-scale-physics-state/"/>
      <summary type="text">UE 4.26에서 Skeletal Mesh를 거의 0까지 축소했다가 복원한 뒤 래그돌 Physics State가 잘못된 크기로 남는 문제와 RecreatePhysicsState를 이용한 해결 방법을 기록합니다.</summary>
      <content type="html"><![CDATA[<blockquote><p>이 글은 2021년 5월의 <strong>Unreal Engine 4.26·PhysX</strong> 프로젝트에서 관찰한 문제를 당시 기준으로 옮긴 기록이다. 모든 scale 변경에서 발생하는 일반 법칙이 아니라, 거의 0까지 축소했던 Skeletal Mesh를 복원한 뒤 physics simulation을 켜는 특정 흐름의 해결 사례다.</p></blockquote>
<p>Zombie가 땅에서 기어 나오는 애니메이션에서 한 frame이 튀는 현상을 가리기 위해, spawn 직후 Actor의 Skeletal Mesh scale을 <code>0.001</code>로 줄였다가 약 0.5초 뒤 원래 크기로 복원했다.</p>
<p>그 뒤 사망 처리에서 ragdoll을 활성화하면 physics body가 비정상적으로 충돌하며 시체가 심하게 떨거나 뒤틀렸다.</p>
<p></p><figure><img alt="Skeletal Mesh scale 복원 뒤 래그돌이 비정상적으로 충돌하는 모습" loading="lazy" width="960" height="561" src="/_astro/skeletal-mesh-scale-physics-failure.9RdlHksu_Z1NAUPB.webp" /><figcaption>Skeletal Mesh scale 복원 뒤 래그돌이 비정상적으로 충돌하는 모습</figcaption></figure><p></p>
<section><h2>문제의 조건<a href="#문제의-조건"><span>#</span></a></h2><p>당시에는 아래 순서로 문제가 재현됐다.</p><ol>
<li>Skeletal Mesh scale을 거의 0에 가깝게 줄인다.</li>
<li>잠시 뒤 시각적 scale을 원래 크기로 복원한다.</li>
<li>사망 시 <code>SetAllBodiesSimulatePhysics(true)</code>로 ragdoll을 켠다.</li>
<li>physics body가 복원된 mesh 크기와 맞지 않는 듯한 충돌이 발생한다.</li>
</ol><p>Scale을 <code>KINDA_SMALL_NUMBER</code>, 즉 <code>0.0001</code> 부근 이하로 설정하면 warning도 확인됐다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Scale3D is (nearly) zero</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>이 warning의 정확한 경계에 의존해서 코드를 작성하기보다, physics와 collision이 있는 Component를 거의 0으로 축소하는 흐름 자체를 피하는 편이 안전하다.</p></section>
<section><h2>Physics State를 다시 만든다<a href="#physics-state를-다시-만든다"><span>#</span></a></h2><p>당시에는 scale을 복원하고 simulation을 시작하기 전에 Skeletal Mesh Component의 physics state를 다시 만들어 문제를 해결했다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>USkeletalMeshComponent</span><span>*</span><span> </span><span>Mesh</span><span> </span><span>=</span><span> </span><span>GetMesh</span><span>();</span></div></div><div><div><div>2</div></div><div><span>if</span><span> (</span><span>!</span><span>Mesh</span><span>)</span></div></div><div><div><div>3</div></div><div><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>Mesh</span><span>-&gt;</span><span>SetRelativeScale3D</span><span>(</span><span>OriginalScale</span><span>);</span></div></div><div><div><div>8</div></div><div><span>Mesh</span><span>-&gt;</span><span>RecreatePhysicsState</span><span>();</span></div></div><div><div><div>9</div></div><div><span>Mesh</span><span>-&gt;</span><span>SetAllBodiesSimulatePhysics</span><span>(</span><span>true</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>UE 4.27의 <code>UActorComponent::RecreatePhysicsState()</code> 구현은 아래와 같았다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>UActorComponent</span><span>::</span></span><span>RecreatePhysicsState</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>DestroyPhysicsState</span><span>();</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>    </span><span>if</span><span> (</span><span>IsRegistered</span><span>())</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>        </span><span>CreatePhysicsState</span><span>();</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>기존 PhysX state를 제거한 뒤, Component가 등록된 상태라면 현재 Component 설정과 transform을 기준으로 다시 생성하는 구조다. 이 사례에서는 축소된 시점의 body state가 남아 있던 문제를 scale 복원 이후의 상태로 재구성했다.</p></section>
<section><h2>호출 순서<a href="#호출-순서"><span>#</span></a></h2><p>원래 scale로 돌린 <strong>뒤</strong>, physics simulation을 켜기 <strong>전</strong>에 호출해야 한다.</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>void</span><span><span> </span><span>AZombieCharacter</span><span>::</span></span><span>EnableRagdoll</span><span>()</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>USkeletalMeshComponent</span><span>*</span><span> </span><span>Mesh</span><span> </span><span>=</span><span> </span><span>GetMesh</span><span>();</span></div></div><div><div><div>4</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>Mesh</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>6</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>    </span><span>Mesh</span><span>-&gt;</span><span>SetRelativeScale3D</span><span>(</span><span>OriginalMeshScale</span><span>);</span></div></div><div><div><div>10</div></div><div><span>    </span><span>Mesh</span><span>-&gt;</span><span>SetCollisionProfileName</span><span>(</span><span>TEXT</span><span>(</span><span>"Ragdoll"</span><span>));</span></div></div><div><div><div>11</div></div><div><span>    </span><span>Mesh</span><span>-&gt;</span><span>RecreatePhysicsState</span><span>();</span></div></div><div><div><div>12</div></div><div><span>    </span><span>Mesh</span><span>-&gt;</span><span>SetAllBodiesSimulatePhysics</span><span>(</span><span>true</span><span>);</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>최종 scale과 collision 설정이 반영된 state를 만든 뒤 simulation을 시작해야 한다.</strong></p></section>
<section><h2>주의할 점<a href="#주의할-점"><span>#</span></a></h2><p><code>RecreatePhysicsState()</code>는 가벼운 transform update가 아니다. body를 파괴하고 다시 만들기 때문에 사용 범위를 좁혀야 한다.</p><ul>
<li>Tick마다 호출하지 않는다.</li>
<li>기존 velocity, constraint와 접촉 상태를 이어 가야 하는 도중에는 사용하지 않는다.</li>
<li>Component가 등록되지 않았다면 함수는 state를 다시 만들지 않는다.</li>
<li>multiplayer라면 server와 client 중 어느 쪽에서 physics를 실제로 simulation하는지 구분한다.</li>
<li>scale 변경만으로 문제가 재현되지 않는다면 무조건 이 함수를 추가하지 않는다.</li>
</ul><p>이 해결책은 이미 어긋난 physics state를 복구하는 방법이다. 원래 문제였던 frame popping은 scale을 거의 0으로 만들기보다 animation pose·visibility·spawn 연출을 조정해 해결하는 편이 근본적이다. 다만 당시 프로젝트처럼 해당 흐름을 즉시 바꾸기 어려운 상황에서는 simulation 직전의 <code>RecreatePhysicsState()</code>가 유효한 우회책이었다.</p></section>
<section><h2>참고 자료<a href="#참고-자료"><span>#</span></a></h2><ul>
<li><a href="https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Engine/Components/UActorComponent/RecreatePhysicsState?application_version=4.27" target="_blank">Epic Games: <code>UActorComponent::RecreatePhysicsState</code> — UE 4.27</a></li>
</ul></section>]]></content>
    </entry>
</feed>
