<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>kyu_.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Wed, 01 Jul 2026 11:26:42 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>kyu_.log</title>
            <url>https://velog.velcdn.com/images/kyu_/profile/d8a6e0ba-1e19-4cc0-8662-c2f99d1d59b6/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. kyu_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/kyu_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[TIL - UI 재설계 및 트러블 슈팅]]></title>
            <link>https://velog.io/@kyu_/TIL-UI-%EC%9E%AC%EC%84%A4%EA%B3%84-%EB%B0%8F-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@kyu_/TIL-UI-%EC%9E%AC%EC%84%A4%EA%B3%84-%EB%B0%8F-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85</guid>
            <pubDate>Wed, 01 Jul 2026 11:26:42 GMT</pubDate>
            <description><![CDATA[<h2 id="1-파츠-npc-ui-재설계">1. 파츠 NPC UI 재설계</h2>
<p><img src="https://velog.velcdn.com/images/kyu_/post/071ef15b-d52e-4926-b18d-1d536988a02f/image.png" alt=""></p>
<ul>
<li>참고하던 UI</li>
</ul>
<p><img src="https://velog.velcdn.com/images/kyu_/post/0c7e42d5-1b21-41a2-96d0-8850a2a126bf/image.png" alt=""></p>
<ul>
<li>확정된 UI (2.png)</li>
</ul>
<h3 id="배경">배경</h3>
<p>파츠 NPC 상호작용 UI(<code>NSPartEquipWidget</code>)가 원래 &quot;항목마다 개별 버튼(장착/구매) + hover 툴팁&quot; 방식으로 짜여 있었는데, 기획이 <code>Docs/2.png</code>로 확정되면서 상호작용 모델 자체가 바뀌었다. <strong>항목 클릭 → 중앙에 설명 표시 → 하단 장착 버튼 하나로 확정</strong>하는 선택 모델로 전환 필요</p>
<h3 id="확정한-설계-결정과-이유">확정한 설계 결정과 이유</h3>
<table>
<thead>
<tr>
<th>결정</th>
<th>내용</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 모델</td>
<td>캐릭터당 파츠 1개 장착 유지, 세이브 구조 변경 없음</td>
<td>기획 확인 결과 다중 장착 계획 없음. 2.png의 &quot;장착중 3박스&quot;는 슬롯 <strong>타입 표시</strong>용이지 3개 동시 장착이 아님</td>
</tr>
<tr>
<td>상호작용</td>
<td>개별 버튼+툴팁 → 클릭 선택 모델</td>
<td>2.png가 항목 클릭 후 중앙 패널에서 확인하고 장착하는 흐름을 확정 레이아웃으로 못 박음</td>
</tr>
<tr>
<td>설명 패널</td>
<td>공용 위젯 1개(<code>NSPartDetailWidget</code>), 셋업 메서드만 2개로 분기</td>
<td>카탈로그 후보(정의 정보: 비용/등급별 수치범위/리롤가능)와 장착중인 파츠(인스턴스 정보: 현재 등급/현재 수치)는 데이터 소스가 다르지만, 보여주는 자리(왼쪽/중앙)는 UI상 같은 종류의 패널이라 위젯 중복을 피함</td>
</tr>
<tr>
<td>카탈로그 분할</td>
<td>바디/암/레그 3개 컨테이너로 라우팅</td>
<td><code>FNSPartDefinitionRow::PartSlot</code> 태그 하나로 구분 가능해서 별도 자료구조 없이 태그 매칭만으로 처리</td>
</tr>
</tbody></table>
<h3 id="구현-내용-파일별-역할">구현 내용 (파일별 역할)</h3>
<p><strong>신규 — <code>NSPartDetailWidget.h/.cpp</code></strong>
카탈로그 선택 파츠와 장착중 파츠 양쪽에서 쓰는 상시 설명 패널.</p>
<ul>
<li><code>SetupFromDefinition(Row, Def)</code> — 카탈로그 후보 표시 (비용/슬롯/리롤가능)</li>
<li><code>SetupFromEquipped(SaveData, Def)</code> — 장착중인 파츠 표시 (현재 등급/현재 수치)</li>
<li><code>ClearDetail()</code> — 선택/장착 없음 상태</li>
</ul>
<p><strong>수정 — <code>NSPartCatalogEntryWidget.h/.cpp</code></strong>
개별 <code>ActionButton</code>(장착/구매)과 hover 툴팁(<code>SetToolTip</code>) 제거. 항목 전체를 감싸는 <code>SelectButton</code> 하나로 바뀌고, 클릭 시 <code>Owner-&gt;SelectCatalogPart(Row)</code> 호출. Icon/Name만 남음.</p>
<p><strong>수정 — <code>NSPartEquipWidget.h/.cpp</code></strong></p>
<ul>
<li>카탈로그를 <code>BodyListContainer</code>/<code>ArmListContainer</code>/<code>LegListContainer</code> 3분할로 라우팅 (<code>GetCatalogContainerForSlot</code>)</li>
<li>선택 상태(<code>SelectedRow</code>/<code>bHasSelection</code>) 보관, 카탈로그 클릭 시 중앙 설명·장착버튼 갱신 (<code>SelectCatalogPart</code>, <code>RefreshEquipButton</code>)</li>
<li>장착 버튼 클릭 시 소유 여부에 따라 <code>RequestEquipPart</code>/<code>RequestUnlockPart</code> 분기 (<code>OnEquipButtonClicked</code>)</li>
<li>왼쪽 장착중 3박스 클릭 시 해당 슬롯의 장착품 정보로 왼쪽 설명 갱신 (<code>SelectEquippedSlot</code>) — 클릭한 박스의 슬롯 태그와 실제 장착품의 슬롯이 일치할 때만 표시, 아니면 빈 상태</li>
</ul>
<p><strong>수정 — <code>NSPartUtils.h/.cpp</code></strong>
<code>TSoftObjectPtr&lt;UNSPartDefinition&gt;</code>를 직접 받는 <code>ResolvePartDefinition</code> 오버로드 추가. 기존 <code>FNSPartData</code> 버전은 이 오버로드에 위임하도록 리팩터링.</p>
<ul>
<li><strong>왜 추가했나</strong>: 카탈로그 선택(<code>FNSPartDefinitionRow::Definition</code>)과 장착중 조회(<code>FNSPartSaveData::Definition</code>) 둘 다 &quot;소프트포인터에서 Definition을 캐시 경유로 안전하게 꺼내는&quot; 동일한 로직이 필요했는데, 기존 함수는 <code>FNSPartData</code> 구조체 전용이라 재사용이 안 됐음. 중복 구현 대신 공용화.</li>
</ul>
<p><strong>삭제 — <code>NSPartTooltipWidget.h/.cpp</code></strong>
hover 툴팁 방식 자체가 폐기되면서 참조가 완전히 사라짐. 다른 참조 없음을 확인 후 삭제.</p>
<h3 id="에디터에서-남은-작업">에디터에서 남은 작업</h3>
<ul>
<li><code>WBP_PartTooltip.uasset</code> 삭제 (C++ 부모 클래스 없어짐)</li>
<li><code>WBP_PartEquip.uasset</code> 레이아웃 재구성 (3열 구조, 바인딩 이름 일치)</li>
<li><code>WBP_PartCatalogEntry.uasset</code>을 <code>SelectButton</code> 구조로 재구성</li>
<li>신규 <code>WBP_PartDetail.uasset</code> 생성 (부모: <code>NSPartDetailWidget</code>)</li>
</ul>
<hr>
<h2 id="2-umg-레이아웃-트러블슈팅--anchor와-box-slot-fill-혼동">2. UMG 레이아웃 트러블슈팅 — Anchor와 Box Slot Fill 혼동</h2>
<p>에디터 작업 중 위젯이 전부 왼쪽 위에 몰리는 문제 발생. 계층 구조(Hierarchy)는 올바르게 잡혀 있었는데도 발생한 원인:</p>
<ul>
<li>UMG는 사이즈 규칙이 두 층으로 분리됨<ol>
<li><strong>Canvas Panel Slot (Anchors + Offset)</strong> — Canvas Panel의 직계 자식에만 적용</li>
<li><strong>Box Slot (Size Rule: Auto/Fill)</strong> — Horizontal/Vertical Box의 자식에만 적용</li>
</ol>
</li>
<li>최상위 <code>Horizontal Box</code>(3열 컨테이너)가 Canvas Panel의 자식인데 1번 레이어의 Anchor를 &quot;전체 스트레치&quot;로 안 잡아서, 그 안에서 2번 레이어(Vertical Box Fill)를 아무리 조정해도 애초에 나눠 가질 공간 자체가 없었음.</li>
<li><strong>해결</strong>: 최상위 Horizontal Box의 Anchor를 전체 스트레치(4방향 Offset 0)로 지정 → 그 안의 Vertical Box 3개에 Box Slot Fill 비율 지정.</li>
</ul>
<hr>
<h2 id="3-파츠-데이터-로드-구조를-udevelopersettings에서-nscommondataconfig로-이관">3. 파츠 데이터 로드 구조를 <code>UDeveloperSettings</code>에서 <code>NSCommonDataConfig</code>로 이관</h2>
<h3 id="문제-발견-경위">문제 발견 경위</h3>
<p>사용자가 프로젝트의 데이터 로드 아키텍처를 다시 확인하자고 제안: <code>NSDataSubsystem</code>은 공용(Common) / 아웃런(OutGame) / 인런(Run) 페이즈별로 데이터를 로드하고, 페이즈마다 전용 <code>UPrimaryDataAsset</code>(예: <code>UNSCommonDataConfig</code>)에 DT/에셋 참조를 몰아두는 구조로 이미 정리되어 있는데, 파츠 DT(<code>DT_PartDefinition</code>/<code>DT_PartSlot</code>)만 예외적으로 <code>UNSDataSettings</code>(<code>UDeveloperSettings</code>, 프로젝트 세팅 전역 싱글톤)에 붙어 있었음.</p>
<h3 id="조사로-확인한-사실">조사로 확인한 사실</h3>
<ul>
<li>Common 페이즈는 이미 <code>UNSCommonDataConfig : UPrimaryDataAsset</code>를 통해 <code>AssetBundles=&quot;CommonData&quot;</code> 방식으로 DT를 로드하고 있었음 (예: <code>AbilityBaseStatTable</code>).</li>
<li>OutGame 페이즈용 동급 DA(<code>HubAssetType</code>/<code>&quot;NSHubData&quot;</code>)는 코드에 상수만 선언돼 있고 <strong>실제 클래스도, AssetManager 스캔 등록도 없는 미완성 상태</strong>였음.</li>
<li>파츠는 인런/아웃런 양쪽에서 다 필요한 데이터라, OutGame 전용 DA가 아니라 Common 설정에 들어가는 게 맞다고 결론.</li>
</ul>
<h3 id="변경-내용">변경 내용</h3>
<ul>
<li><code>NSCommonDataConfig.h</code>에 <code>PartsBaseStatTable</code>, <code>PartsSlotBaseStatTable</code> 필드 추가 (<code>AssetBundles=&quot;CommonData&quot;</code>)</li>
<li><code>NSDataSubsystem.cpp</code>:<ul>
<li><code>OnOutGamePrimaryAssetsLoaded</code>/<code>OnOutGameAssetsLoaded</code>에서 파츠 DT 로드·캐시빌드 로직 제거</li>
<li><code>OnCommonAssetsLoaded</code>에 <code>BuildPartRowCache()</code>/<code>BuildSlotRowCache()</code> 호출 추가</li>
<li>두 함수가 <code>GetDefault&lt;UNSDataSettings&gt;()</code> 대신 <code>GetCommonDataConfig()</code>를 조회하도록 변경</li>
</ul>
</li>
<li><code>NSDataSettings.h/.cpp</code> 삭제, <code>Config/DefaultGame.ini</code>의 <code>[/Script/NeoSanctum.NSDataSettings]</code> 섹션 삭제</li>
</ul>
<h3 id="왜-이렇게-했나">왜 이렇게 했나</h3>
<ul>
<li><strong>페이즈 일관성</strong>: 다른 모든 데이터가 &quot;페이즈 전용 PrimaryDataAsset에 소프트 참조를 몰아두고, 해당 페이즈 로드 시 AssetBundle로 같이 끌려오게&quot; 하는 패턴을 쓰는데, 파츠만 <code>UDeveloperSettings</code>라는 별개 메커니즘(프로젝트 세팅, 페이즈 개념 없음)을 쓰고 있어 구조가 어긋나 있었음.</li>
<li><strong>로드 로직 단순화 부수효과</strong>: <code>UDeveloperSettings</code>는 <code>UPrimaryDataAsset</code>이 아니라서 AssetBundle 시스템에 못 들어가고, 그래서 기존 코드는 <code>PartDefinitionTable</code>/<code>PartSlotTable</code> 경로를 수동으로 <code>RequestAsyncLoad</code>하는 별도 코드가 필요했음. <code>NSCommonDataConfig</code>로 옮기면서 <code>AssetBundles=&quot;CommonData&quot;</code> 메타데이터 하나로 <code>LoadPrimaryAssets(..., CommonBundles, ...)</code> 호출에 자동으로 딸려 들어오게 되어, 수동 로드 분기 코드 자체가 통째로 삭제됨.</li>
<li><strong>미완성 OutGame DA(<code>NSHubData</code>)를 새로 만들지 않은 이유</strong>: 파츠가 인런에서도 필요한 이상 OutGame 전용 DA에 넣는 건 애초에 범위가 안 맞았음. 없는 클래스를 새로 만드는 것보다, 이미 있고 정상 작동 중인 Common 패턴에 얹는 게 더 적은 변경으로 올바른 구조에 도달하는 길이었음.</li>
</ul>
<h3 id="로드-→-캐시-흐름-변경-후">로드 → 캐시 흐름 (변경 후)</h3>
<pre><code>LoadCommonData()
  → StartLoadCommon(): CommonDataConfigAssetType를 CommonBundles로 LoadPrimaryAssets
      (PartsBaseStatTable/PartsSlotBaseStatTable가 AssetBundles=&quot;CommonData&quot;라 자동으로 같이 로드됨)
  → OnCommonAssetsLoaded(): CacheLoaded() → BuildPartRowCache() → BuildSlotRowCache()
      → GetCommonDataConfig()로 DA를 찾고 DT를 꺼내 CachedPartRowsByDefId / CachedSlotRowsBySlot에 채움</code></pre><h3 id="에디터에서-남은-작업-1">에디터에서 남은 작업</h3>
<p><code>Content/NeoSanctum/Data/Config/DA_CommonDataConfig.uasset</code>을 열어 <code>PartsBaseStatTable</code> → <code>DT_PartDefinition</code>, <code>PartsSlotBaseStatTable</code> → <code>DT_PartSlot</code> 지정</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 트러블 슈팅]]></title>
            <link>https://velog.io/@kyu_/TIL-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@kyu_/TIL-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85</guid>
            <pubDate>Tue, 30 Jun 2026 12:00:45 GMT</pubDate>
            <description><![CDATA[<h1 id="트러블슈팅-기록">트러블슈팅 기록</h1>
<hr>
<h2 id="1-wbp_partpanel-컴파일-에러--slotbuttoncontainer-not-found">1. WBP_PartPanel 컴파일 에러 — <code>SlotButtonContainer not found</code></h2>
<p><strong>증상</strong><br>WBP_PartPanel을 열면 &quot;SlotButtonContainer를 찾을 수 없습니다&quot; 컴파일 에러.</p>
<p><strong>원인</strong><br><code>NSPartPanelWidget.h</code>에 <code>BindWidget</code>으로 선언된 <code>SlotButtonContainer(UPanelWidget)</code>가 WBP 디자이너에 존재하지 않았음.</p>
<p><strong>수정</strong><br>WBP_PartPanel 디자이너에 WrapBox를 추가하고 이름을 <code>SlotButtonContainer</code>로 지정.</p>
<p><strong>선택 이유</strong><br><code>BindWidget</code>은 WBP에 <strong>정확히 같은 이름</strong>의 위젯이 있어야 컴파일을 통과한다. 이름 불일치가 유일한 원인이므로 코드 수정 없이 WBP에서 해결.</p>
<hr>
<h2 id="2-slotbuttontemplate이-details-패널에서-안-보임">2. <code>SlotButtonTemplate</code>이 Details 패널에서 안 보임</h2>
<p><strong>증상</strong><br>WBP_PartPanel을 선택하고 Details 패널에서 <code>SlotButtonTemplate</code>을 검색해도 나오지 않음.</p>
<p><strong>원인</strong><br><code>SlotButtonTemplate</code>은 <code>UPROPERTY(EditDefaultsOnly)</code>로 선언되어 있어 <strong>Designer 탭 Details가 아니라 Graph 탭 Class Defaults</strong>에만 노출됨.</p>
<p><strong>수정</strong><br>Graph 탭 → Class Defaults에서 <code>SlotButtonTemplate</code>에 WBP_PartsButton 지정.</p>
<p><strong>선택 이유</strong><br><code>EditDefaultsOnly</code>는 인스턴스별 수정이 필요 없는 클래스 수준 설정에 쓰는 지정자. 슬롯 버튼 템플릿은 모든 인스턴스가 같은 WBP를 공유하므로 설계가 맞고, 코드 변경 없이 에디터 탐색 경로를 바꿔 해결.</p>
<hr>
<h2 id="3-tab-키를-눌러도-파츠-패널이-반응하지-않음">3. Tab 키를 눌러도 파츠 패널이 반응하지 않음</h2>
<p><strong>증상</strong><br>인런에서 Tab을 눌러도 파츠 슬롯이 표시되지 않고 아무 반응 없음.</p>
<p><strong>원인</strong><br>Tab → <code>Input.Augment.TogglePanel</code> 태그 → <code>ToggleAugmentationPanel()</code> 함수가 내부에서 <code>IsRunReady()</code> 상태를 확인하는 구조. OutGame(HideOut 맵)에서 테스트했기 때문에 조건이 false여서 아무것도 실행되지 않았음.</p>
<p><strong>수정</strong><br>인런 맵에서 테스트.</p>
<p><strong>선택 이유</strong><br>조건 자체가 올바른 설계(인런에서만 파츠 패널 토글 허용). 구현 버그가 아니라 테스트 환경 오류였으므로 코드 수정 없음.</p>
<hr>
<h2 id="4-인런에서-tab을-눌러도-슬롯-버튼이-나타나지-않음-타이밍-문제">4. 인런에서 Tab을 눌러도 슬롯 버튼이 나타나지 않음 (타이밍 문제)</h2>
<p><strong>증상</strong><br>인런에서 Tab을 누르면 패널 자체는 뜨지만 Arm / Leg / Body 슬롯 버튼이 비어 있음.</p>
<p><strong>원인 (1단계)</strong><br><code>UNSPartPanelWidget::NativeConstruct()</code>에서 <code>BuildSlotButtons()</code>를 호출하는데, 이 시점에 <code>CachedSlotRowsBySlot</code>이 비어 있어 버튼이 생성되지 않았음.</p>
<p><strong>원인 (2단계)</strong><br><code>CachedSlotRowsBySlot</code>이 비어 있는 이유는 <code>BuildSlotRowCache()</code>가 <code>OnOutGameAssetsLoaded()</code> 안에서만 호출되는데, <code>PartSlotTable</code>이 <code>OnOutGamePrimaryAssetsLoaded()</code>의 비동기 로드 목록에 포함되지 않아 DT 자체가 메모리에 없었음.</p>
<p><strong>원인 (3단계, 근본 원인)</strong><br><code>LoadOutGameData()</code>가 HideOut 맵 진입 핸들러에서 호출되지 않고 있었음. OutGame 데이터 로딩 자체가 시작되지 않아 이후 모든 단계가 동작하지 않았음.</p>
<p><strong>수정</strong></p>
<pre><code class="language-cpp">// NSPlayerController.cpp — HideOut 맵 핸들러
if (UNSDataSubsystem* DataSubsystem = UNSDataSubsystem::Get(this))
{
    DataSubsystem-&gt;LoadOutGameData();  // ← 추가
    ...
}</code></pre>
<pre><code class="language-cpp">// NSDataSubsystem.cpp — OnOutGamePrimaryAssetsLoaded
// PartSlotTable을 비동기 로드 목록에 추가
if (!Settings-&gt;PartSlotTable.IsNull() &amp;&amp; Settings-&gt;PartSlotTable.Get() == nullptr)
    PendingLoads.Add(Settings-&gt;PartSlotTable.ToSoftObjectPath());</code></pre>
<pre><code class="language-cpp">// NSDataSubsystem.cpp — OnOutGameAssetsLoaded
void UNSDataSubsystem::OnOutGameAssetsLoaded()
{
    BuildPartRowCache();
    BuildSlotRowCache();  // ← 추가
    SetPhase(ENSDataLoadPhase::OutGameReady);
    OnOutGameDataReady.Broadcast();
}</code></pre>
<pre><code class="language-cpp">// NSPartPanelWidget.cpp — NativeConstruct (타이밍 대응)
if (!DataSS-&gt;GetAllSlotRows().IsEmpty())
{
    BuildSlotButtons();
    ...
}
else
{
    // 아직 로드 전이면 완료 델리게이트 구독
    DataSS-&gt;OnOutGameDataReady.AddDynamic(this, &amp;UNSPartPanelWidget::OnOutGameDataReady);
}</code></pre>
<p><strong>선택 이유</strong></p>
<ul>
<li><code>LoadOutGameData()</code> 미호출은 단순 누락이므로 있어야 할 자리(HideOut 핸들러의 DataSubsystem 블록)에 추가.</li>
<li><code>PartSlotTable</code> 누락도 동일한 이유로 <code>PartDefinitionTable</code>과 같은 방식으로 추가.</li>
<li><code>NativeConstruct</code> 타이밍 문제는 &quot;데이터가 준비됐으면 즉시, 아니면 델리게이트 구독 후 콜백&quot;으로 처리. 위젯 생성 시점과 데이터 준비 시점이 보장되지 않는 구조에서 표준적인 패턴.</li>
<li><code>NativeDestruct</code>에서 <code>RemoveDynamic</code>으로 구독 해제하여 위젯 파괴 후 콜백 호출 방지.</li>
</ul>
<hr>
<h2 id="요약--디버깅-순서와-실제-원인-계층">요약 — 디버깅 순서와 실제 원인 계층</h2>
<pre><code>Tab 눌러도 슬롯 안 나옴
  └─ BuildSlotButtons()에서 슬롯 데이터가 비어 있음
       └─ BuildSlotRowCache()가 실행되지 않았음
            └─ PartSlotTable이 로드되지 않았음
                 └─ LoadOutGameData()가 호출되지 않았음  ← 근본 원인</code></pre><p>증상은 UI에서 보였지만 원인은 PlayerController의 맵 전환 핸들러에 있었음.<br>데이터 로드 → 캐시 빌드 → 위젯 구독 → UI 반영으로 이어지는 의존 체인 전체를 역추적해야 찾을 수 있는 유형의 버그.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 파츠 시스템 전환, 설계 및 구현]]></title>
            <link>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%A0%84%ED%99%98-%EC%84%A4%EA%B3%84-%EB%B0%8F-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%A0%84%ED%99%98-%EC%84%A4%EA%B3%84-%EB%B0%8F-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 29 Jun 2026 12:04:32 GMT</pubDate>
            <description><![CDATA[<h1 id="파츠-슬롯-시스템-전환">파츠 슬롯 시스템 전환</h1>
<hr>
<h2 id="1-처음-구조--enum-기반-슬롯">1. 처음 구조 — enum 기반 슬롯</h2>
<h3 id="어떤-구조였나">어떤 구조였나</h3>
<pre><code class="language-cpp">UENUM(BlueprintType)
enum class ENSPartSlot : uint8
{
    Body,
    Arm,
    Leg
};</code></pre>
<p>슬롯은 C++ enum으로 고정. 파츠 관련 모든 구조체/컴포넌트/UI가 이 enum에 의존하고 있었다.</p>
<pre><code class="language-cpp">// 세이브 데이터
TSet&lt;ENSPartSlot&gt; UnlockedSlots;   // 캐릭터별
TArray&lt;FNSPartSaveData&gt; OwnedParts; // 캐릭터별

// UI
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* BodySlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* ArmSlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* LegSlotButton;

// VisualComponent
for (const ENSPartSlot Slot : { ENSPartSlot::Body, ENSPartSlot::Arm, ENSPartSlot::Leg })</code></pre>
<h3 id="무엇이-문제였나">무엇이 문제였나</h3>
<p>&quot;슬롯을 하나 추가하면 어떻게 되는가?&quot;를 생각했을 때 문제가 명확해졌다:</p>
<ol>
<li><strong>C++ enum에 값 추가</strong> → 리컴파일 필요</li>
<li><strong>switch/if 분기 전부 수정</strong> → 빠뜨리면 런타임 버그</li>
<li><strong>UI 위젯에 버튼 UPROPERTY 추가</strong> → UMG 에디터 열어서 바인딩</li>
<li><strong>VisualComponent 하드코딩 순회 수정</strong> → 까먹으면 새 슬롯 메시 안 붙음</li>
</ol>
<p>슬롯 3개인 지금도 관리하기 불편한데, 슬롯이 늘어날수록 이 비용이 선형으로 증가한다.</p>
<hr>
<h2 id="2-첫-번째-전환-결정--fgameplaytag--datatable">2. 첫 번째 전환 결정 — FGameplayTag + DataTable</h2>
<h3 id="왜-fgameplaytag인가">왜 FGameplayTag인가</h3>
<p>증강 시스템이 이미 <code>FGameplayTag</code>로 모든 카테고리를 구분하고 있었다. 슬롯도 같은 체계를 쓰면:</p>
<ul>
<li><strong>에디터 드롭다운 필터</strong>: <code>meta=(Categories=&quot;Part.Slot&quot;)</code> 한 줄로 <code>Part.Slot.*</code> 네임스페이스만 표시</li>
<li><strong>신규 슬롯 추가 워크플로</strong>: 태그 1줄 등록 + DT 행 1개 추가. C++ 수정 없음</li>
<li><strong>빌드 없이 슬롯 확장 가능</strong></li>
</ul>
<h3 id="datatable은-왜-필요한가">DataTable은 왜 필요한가</h3>
<p>태그만으로는 &quot;이 슬롯의 해금 비용이 얼마인가&quot;, &quot;기본 해금 상태인가&quot;를 알 수 없다.<br><code>DT_PartSlot</code>이 이 수치를 보관하고, 코드는 DataSubsystem을 통해 캐시된 맵으로 조회한다.</p>
<pre><code class="language-cpp">// DT 행 구조체
USTRUCT(BlueprintType)
struct FNSPartSlotRow : public FTableRowBase
{
    FGameplayTag SlotTag;       // 슬롯 정체성
    int64 UnlockCost;           // 해금 비용
    bool bUnlockedByDefault;    // 기본 해금 여부
    bool bEnabled;              // 활성화 여부
};

// DataSubsystem 캐시
TMap&lt;FGameplayTag, FNSPartSlotRow&gt; CachedSlotRowsBySlot;</code></pre>
<h3 id="무엇이-바뀌었나">무엇이 바뀌었나</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody><tr>
<td>슬롯 식별자</td>
<td><code>ENSPartSlot</code></td>
<td><code>FGameplayTag</code></td>
</tr>
<tr>
<td>슬롯 수치(비용 등)</td>
<td>코드 하드코딩</td>
<td><code>DT_PartSlot</code> DataTable</td>
</tr>
<tr>
<td><code>FNSPartSlotRow.Slot</code></td>
<td><code>ENSPartSlot Slot</code></td>
<td><code>FGameplayTag SlotTag</code></td>
</tr>
<tr>
<td><code>FNSPartDefinitionRow.PartSlot</code></td>
<td><code>ENSPartSlot</code></td>
<td><code>FGameplayTag</code></td>
</tr>
<tr>
<td><code>FNSPartData.Slot</code></td>
<td><code>ENSPartSlot</code></td>
<td><code>FGameplayTag</code></td>
</tr>
<tr>
<td><code>UnlockedSlots</code> 세이브 키</td>
<td><code>TSet&lt;ENSPartSlot&gt;</code></td>
<td><code>TSet&lt;FGameplayTag&gt;</code></td>
</tr>
<tr>
<td>UI 슬롯 버튼</td>
<td>3개 하드코딩 UPROPERTY</td>
<td>DT 기반 런타임 동적 생성</td>
</tr>
<tr>
<td>VisualComponent 순회</td>
<td><code>{Body, Arm, Leg}</code> 리터럴</td>
<td><code>DataSS-&gt;GetAllSlotRows()</code></td>
</tr>
</tbody></table>
<h4 id="ui-동적화가-어떤-의미인가">UI 동적화가 어떤 의미인가</h4>
<pre><code class="language-cpp">// 변경 전 — UMG에 버튼 3개 고정 배치, C++에서 이름으로 참조
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* BodySlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* ArmSlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* LegSlotButton;

void HandlePartChanged(ENSPartSlot Slot, ...)
{
    switch(Slot)
    {
        case Body: BodySlotButton-&gt;...; break;
        case Arm:  ArmSlotButton-&gt;...;  break;
        case Leg:  LegSlotButton-&gt;...;  break;
        // 슬롯 추가 시 여기도 수정 필요
    }
}

// 변경 후 — 컨테이너만 UMG에 배치, 버튼은 DT 읽고 런타임 스폰
void BuildSlotButtons()
{
    for (const auto&amp; Pair : DataSS-&gt;GetAllSlotRows())
    {
        UNSPartSlotButton* Btn = CreateWidget&lt;UNSPartSlotButton&gt;(this, SlotButtonTemplate);
        SlotButtonContainer-&gt;AddChild(Btn);
        SlotButtonMap.Add(Pair.Key, Btn);  // 태그 → 버튼 맵
    }
}

void HandlePartChanged(FGameplayTag Slot, ...)
{
    if (auto* Btn = SlotButtonMap.Find(Slot))
        ApplySlot(Slot, *Btn);   // switch 없음, 슬롯 추가해도 수정 불필요
}</code></pre>
<p><strong>&quot;동적화&quot;란</strong>: DT에서 슬롯 행이 늘어나면 버튼도 자동으로 늘어나는 구조.<br>슬롯 추가 = DT에 행 1개 추가가 전부. UI/C++ 수정 없음.</p>
<hr>
<h2 id="3-중간에-발견된-설계-오류--ownedparts-범위-문제">3. 중간에 발견된 설계 오류 — OwnedParts 범위 문제</h2>
<h3 id="무엇이-잘못됐나">무엇이 잘못됐나</h3>
<p>구현 중 <code>OwnedParts(파츠 인벤토리)</code>가 <code>FNSCharacterSaveData</code>(캐릭터별) 안에 있다는 것을 확인했다.</p>
<pre><code class="language-cpp">struct FNSCharacterSaveData
{
    TArray&lt;FNSPartSaveData&gt; OwnedParts;  // ← 캐릭터별로 파츠를 따로 가지고 있었음
    TSet&lt;FGameplayTag&gt; UnlockedSlots;
};</code></pre>
<p>이 구조의 문제: 캐릭터 A로 파츠를 구매하면 캐릭터 B에서는 그 파츠가 없다.<br><strong>원래 의도는 &quot;파츠 인벤토리는 계정 공유&quot;였다.</strong></p>
<p>실제로 <code>OwnedParts</code>는 처음 설계 때 <code>UNSPermanentSaveGame</code>(계정 레벨)에 있었다가,<br>어느 시점에 캐릭터별로 이사 온 것으로 파악됐다. enum→태그 전환 작업 도중 이 사실이 드러났다.</p>
<h3 id="왜-이게-문제인가">왜 이게 문제인가</h3>
<p>게임 설계 의도:</p>
<ul>
<li><strong>파츠 인벤토리</strong>: 계정 공유. 어떤 캐릭터로 구매해도 전체 캐릭터가 사용 가능.</li>
<li><strong>슬롯 언락</strong>: 캐릭터별. 캐릭터마다 독립적으로 슬롯을 해금.</li>
<li><strong>장착 참조</strong>: 캐릭터별. 어떤 파츠를 끼고 있는지는 캐릭터마다 다름.</li>
</ul>
<p><code>OwnedParts</code>가 캐릭터별이면 &quot;파츠 공유&quot; 자체가 불가능해진다.</p>
<h3 id="무엇을-어떻게-변경했나">무엇을 어떻게 변경했나</h3>
<p><strong>세이브 구조:</strong></p>
<pre><code class="language-cpp">// 변경 전
struct FNSCharacterSaveData
{
    TArray&lt;FNSPartSaveData&gt; OwnedParts;  // 캐릭터별 (잘못됨)
    TSet&lt;FGameplayTag&gt; UnlockedSlots;    // 캐릭터별 (유지)
};

// 변경 후
struct FNSCharacterSaveData
{
    // OwnedParts 제거됨
    TSet&lt;FGameplayTag&gt; UnlockedSlots;    // 캐릭터별 (유지)
};

class UNSPermanentSaveGame
{
    TArray&lt;FNSPartSaveData&gt; OwnedParts;  // 계정 공유로 복귀
};</code></pre>
<p><strong>ProgressionSubsystem 함수 시그니처:</strong></p>
<pre><code class="language-cpp">// 변경 전 — CharacterId가 인벤토리 조회에도 쓰임
bool IsPartOwned(FName CharacterId, TSoftObjectPtr&lt;UNSPartDefinition&gt;, ENSPartRarity) const;
const TArray&lt;FNSPartSaveData&gt;&amp; GetOwnedParts(FName CharacterId) const;

// 변경 후 — 인벤토리는 계정 풀, CharacterId 불필요
bool IsPartOwned(TSoftObjectPtr&lt;UNSPartDefinition&gt;, ENSPartRarity) const;
const TArray&lt;FNSPartSaveData&gt;&amp; GetOwnedParts() const;</code></pre>
<p><code>PurchasePart</code>는 CharacterId를 <strong>유지</strong>했다. &quot;이 캐릭터의 슬롯이 해금됐는가&quot;를 검사하기 때문이다. 구매는 캐릭터 슬롯 게이트를 통과해야 하지만, 구매된 파츠는 계정 풀에 쌓인다.</p>
<p><strong>NSPlayerController <code>UploadLocalProgress</code>:</strong></p>
<pre><code class="language-cpp">// 변경 전 — 장착 파츠를 캐릭터 인벤토리에서 찾음
const FNSPartSaveData* Owned = CharacterSlot-&gt;OwnedParts.FindByPredicate(...);

// 변경 후 — 장착 파츠를 계정 인벤토리에서 찾음
const FNSPartSaveData* Owned = PermanentSave-&gt;OwnedParts.FindByPredicate(...);</code></pre>
<hr>
<h2 id="4-최종-구조">4. 최종 구조</h2>
<h3 id="세이브-데이터-책임-분리">세이브 데이터 책임 분리</h3>
<table>
<thead>
<tr>
<th>데이터</th>
<th>저장 위치</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>OwnedParts</code> (파츠 인벤토리)</td>
<td><code>UNSPermanentSaveGame</code> (계정)</td>
<td>계정 공유. 어느 캐릭터로 구매해도 전체 접근 가능</td>
</tr>
<tr>
<td><code>UnlockedSlots</code> (슬롯 해금)</td>
<td><code>FNSCharacterSaveData</code> (캐릭터)</td>
<td>캐릭터마다 독립 슬롯 투자</td>
</tr>
<tr>
<td><code>EquippedPartDefinition/Rarity</code> (장착 참조)</td>
<td><code>FNSCharacterSaveData</code> (캐릭터)</td>
<td>어떤 파츠를 끼고 있는지는 캐릭터별로 다름</td>
</tr>
</tbody></table>
<h3 id="슬롯-추가-워크플로-최종">슬롯 추가 워크플로 (최종)</h3>
<pre><code>1. Config/DefaultGameplayTags.ini에 Part.Slot.Wing 태그 1줄 추가
2. DT_PartSlot에 행 1개 추가 (SlotTag=Part.Slot.Wing, UnlockCost=500, ...)
3. DT_PartDefinition에 해당 슬롯 파츠 행 추가
완료 — C++ 수정/빌드 없음</code></pre><h3 id="수정된-파일-목록">수정된 파일 목록</h3>
<table>
<thead>
<tr>
<th>파일</th>
<th>변경 내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>Data/Part/NSPartTypes.h</code></td>
<td><code>ENSPartSlot</code> enum 제거, 모든 슬롯 필드 → <code>FGameplayTag</code></td>
</tr>
<tr>
<td><code>Progression/Save/NSPermanentSaveGame.h</code></td>
<td><code>OwnedParts</code> → 계정 레벨로 이동, <code>FNSCharacterSaveData</code>에서 제거</td>
</tr>
<tr>
<td><code>Core/.../NSDataSubsystem.h/.cpp</code></td>
<td>슬롯 캐시 키 타입 <code>ENSPartSlot</code> → <code>FGameplayTag</code></td>
</tr>
<tr>
<td><code>Core/.../NSProgressionSubsystem.h/.cpp</code></td>
<td><code>IsPartOwned</code>·<code>GetOwnedParts</code>에서 <code>CharacterId</code> 제거, 계정 풀 조회로 변경</td>
</tr>
<tr>
<td><code>Progression/Part/NSPartEquipComponent.h/.cpp</code></td>
<td>델리게이트·함수 인자 → <code>FGameplayTag</code></td>
</tr>
<tr>
<td><code>Character/Component/NSPartVisualComponent.h/.cpp</code></td>
<td>하드코딩 순회 → <code>GetAllSlotRows()</code> DT 순회</td>
</tr>
<tr>
<td><code>UI/Part/NSPartPanelWidget.h/.cpp</code></td>
<td>명명 버튼 3개 제거 → 동적 생성 구조 (<code>SlotButtonMap</code>)</td>
</tr>
<tr>
<td><code>UI/Interaction/NSPartEquipWidget.cpp</code></td>
<td><code>IsPartOwned</code> 호출에서 <code>CharacterId</code> 인자 제거</td>
</tr>
<tr>
<td><code>Core/PlayerController/NSPlayerController.cpp</code></td>
<td>장착 파츠 조회: <code>CharacterSlot-&gt;OwnedParts</code> → <code>PermanentSave-&gt;OwnedParts</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="5-핵심-교훈-til">5. 핵심 교훈 (TIL)</h2>
<h3 id="enum-→-fgameplaytag-전환이-필요한-시점">enum → FGameplayTag 전환이 필요한 시점</h3>
<p>enum은 집합이 고정될 때 좋다. <strong>&quot;나중에 늘어날 수 있다&quot;는 가능성이 조금이라도 있으면 태그+DT가 낫다.</strong><br>전환 비용(모든 참조 수정)이 크지만, 한 번 전환하면 이후 확장 비용이 0에 가까워진다.</p>
<h3 id="데이터-소유권은-설계-초반에-명확히">데이터 소유권은 설계 초반에 명확히</h3>
<p><code>OwnedParts</code>가 계정 공유인지 캐릭터별인지는 <strong>게임 설계 의도</strong>의 문제다.<br>이걸 코드 구현 중에 발견한 건 설계 문서에 명시되지 않았기 때문이다.<br>세이브 데이터 설계 시 모든 필드에 &quot;계정 단위 / 캐릭터 단위&quot;를 명시하는 습관이 필요하다.</p>
<h3 id="purchasepart의-characterid는-왜-남겼나">PurchasePart의 CharacterId는 왜 남겼나</h3>
<p>&quot;파츠를 살 수 있는가&quot;를 검사할 때 &quot;이 캐릭터의 슬롯이 해금됐는가&quot;가 필요하다.<br>구매 <strong>게이트</strong>는 캐릭터별, 구매된 파츠의 <strong>저장</strong>은 계정 공유.<br>책임이 두 층에 걸쳐 있어서 파라미터가 남아있는 것이 맞다.</p>
<h3 id="ui-동적화는-dt와-세트로-설계해야-한다">UI &quot;동적화&quot;는 DT와 세트로 설계해야 한다</h3>
<p>슬롯 버튼을 UMG에 하드코딩하면 슬롯 추가 때 UI도 수정해야 한다.<br><code>SlotButtonTemplate</code>(버튼 1개짜리 클래스)을 에디터에서 지정하고,<br>런타임에 DT를 읽어서 스폰하는 패턴이 DT 기반 설계의 완결이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 아웃런 파츠 NPC 설계]]></title>
            <link>https://velog.io/@kyu_/TIL-%EC%95%84%EC%9B%83%EB%9F%B0-%ED%8C%8C%EC%B8%A0-NPC-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@kyu_/TIL-%EC%95%84%EC%9B%83%EB%9F%B0-%ED%8C%8C%EC%B8%A0-NPC-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Fri, 26 Jun 2026 11:42:30 GMT</pubDate>
            <description><![CDATA[<h1 id="아웃런-파츠-npc-설계">아웃런 파츠 NPC 설계</h1>
<hr>
<h2 id="1-목표">1. 목표</h2>
<p>인 런 진입 전 거점에서 파츠를 미리 선택·장착할 수 있게 한다.</p>
<ul>
<li>영구 재화(<code>CommonCurrency</code>)를 소모해 개별 파츠를 <strong>언락(구매)</strong></li>
<li>언락한 파츠 중 1부위를 선택해 <strong>장착 저장</strong> → 런 시작 시 Common 등급으로 적용</li>
<li>비용·목록 등 모든 수치는 DataTable로 관리</li>
</ul>
<hr>
<h2 id="2-설계-원칙">2. 설계 원칙</h2>
<ul>
<li><strong>증강 동형 패턴</strong>: DT가 카탈로그·수치를 주도, 얇은 DA가 번들 페이로드(Icon/GE/GA/Mesh) 보유</li>
<li><strong>DA 정체성 유지</strong>: 런타임·세이브 키는 <code>TSoftObjectPtr&lt;UNSPartDefinition&gt;</code> 그대로 → 세이브 마이그레이션 없음</li>
<li><strong>아웃런 = 저장 전용</strong>: 허브에서 GAS 즉시 장착 없음. 런 시작 시 기존 경로(<code>ApplyEquippedPart</code>)로 적용</li>
<li><strong>모든 수치는 DT</strong>: 언락 비용, ValueRange, bCanReroll 전부 DT_PartDefinition에서 읽음</li>
</ul>
<hr>
<h2 id="3-데이터-모델">3. 데이터 모델</h2>
<h3 id="3-1-신설-fnspartdefinitionrow--ftablerowbase-dt_partdefinition">3-1. 신설: <code>FNSPartDefinitionRow : FTableRowBase</code> (DT_PartDefinition)</h3>
<p>카탈로그의 유일한 수치 출처. 파츠 1개 = row 1개.</p>
<table>
<thead>
<tr>
<th>필드</th>
<th>타입</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>Definition</code></td>
<td><code>TSoftObjectPtr&lt;UNSPartDefinition&gt;</code></td>
<td>DA 참조 (정체성 앵커)</td>
</tr>
<tr>
<td><code>PartSlot</code></td>
<td><code>ENSPartSlot</code></td>
<td>Body / Arm / Leg</td>
</tr>
<tr>
<td><code>bCanReroll</code></td>
<td><code>bool</code></td>
<td>레그 파츠는 false</td>
</tr>
<tr>
<td><code>UnlockCost</code></td>
<td><code>int64</code></td>
<td>언락(구매) 영구재화 비용</td>
</tr>
<tr>
<td><code>ValueRange</code></td>
<td><code>TMap&lt;ENSPartRarity, FNSPartValueRange&gt;</code></td>
<td>등급별 수치 범위 (DA에서 이사)</td>
</tr>
<tr>
<td><code>bEnabled</code></td>
<td><code>bool</code></td>
<td>false면 카탈로그에서 제외 (증강 패턴)</td>
</tr>
</tbody></table>
<h3 id="3-2-unspartdefinition-변경-얇아짐">3-2. <code>UNSPartDefinition</code> 변경 (얇아짐)</h3>
<p>DA에서 <strong>DT로 이사하는 필드</strong>: <code>ValueRange</code>, <code>bCanReroll</code>, <code>PartSlot</code></p>
<p>DA에 <strong>남는 필드</strong> (PrimaryAsset·번들 로드 앵커로 유지):</p>
<table>
<thead>
<tr>
<th>필드</th>
<th>번들</th>
</tr>
</thead>
<tbody><tr>
<td><code>PartName</code> (FText)</td>
<td>—</td>
</tr>
<tr>
<td><code>Icon</code> (TSoftObjectPtr&lt;UTexture2D&gt;)</td>
<td>OutRunUI, InRunUI</td>
</tr>
<tr>
<td><code>PartMesh</code> (TSoftObjectPtr&lt;USkeletalMesh&gt;)</td>
<td>온디맨드</td>
</tr>
<tr>
<td><code>EffectClass</code> (TSoftClassPtr&lt;UGameplayEffect&gt;)</td>
<td>InRunData</td>
</tr>
<tr>
<td><code>GrantedAbilities</code> (TArray&lt;TSoftClassPtr&lt;UGameplayAbility&gt;&gt;)</td>
<td>InRunData</td>
</tr>
</tbody></table>
<blockquote>
<p><code>PartIconTool</code>은 DA의 PartMesh → Icon 경로를 그대로 사용하므로 <strong>수정 없음</strong>.</p>
</blockquote>
<hr>
<h2 id="4-해석-인프라-증강-패턴-복제">4. 해석 인프라 (증강 패턴 복제)</h2>
<h3 id="4-1-nsdatasubsystem">4-1. <code>NSDataSubsystem</code></h3>
<pre><code>UPROPERTY(EditDefaultsOnly)
TObjectPtr&lt;UDataTable&gt; PartDefinitionTable;  // DT_PartDefinition 참조

TMap&lt;FPrimaryAssetId, FNSPartDefinitionRow&gt; CachedPartRowsByDefId;  // 로드 시 빌드</code></pre><ul>
<li>허브 로드 시(<code>LoadOutGameAssets</code>) <code>PartAssetType</code> DA 로드 완료 콜백에서 row 캐시 빌드</li>
<li><code>GetPartRow(FPrimaryAssetId) → const FNSPartDefinitionRow*</code> 조회 함수 추가</li>
</ul>
<h3 id="4-2-nspartutils">4-2. <code>NSPartUtils</code></h3>
<p>기존 <code>ResolvePartDefinition(WorldCtx, PartData)</code> 유지.<br>신규 <code>ResolvePartRow(WorldCtx, FPrimaryAssetId) → const FNSPartDefinitionRow*</code> 추가.</p>
<hr>
<h2 id="5-언락구매-흐름">5. 언락(구매) 흐름</h2>
<pre><code>[UI: 언락 버튼 클릭]
  → ProgressionSubsystem::PurchasePart(Def, Common, Cost)
      ← Cost는 UI가 직접 넘기지 않음
      ← NSDataSubsystem::GetPartRow(DefId).UnlockCost 에서 조회 (단일 출처)
      → CommonCurrency 차감
      → OwnedParts 에 FNSPartSaveData 추가 (Value 1회 롤 후 고정)
      → SaveNow()</code></pre><p><strong><code>PurchasePart</code> 수정 포인트</strong>:</p>
<ul>
<li><code>LoadSynchronous</code> 제거 — ValueRange를 DT row에서 읽으므로 DA 로드 불필요</li>
<li>비용 파라미터 제거 또는 row 조회로 대체</li>
</ul>
<hr>
<h2 id="6-장착-흐름-저장-전용-허브-gas-없음">6. 장착 흐름 (저장 전용, 허브 GAS 없음)</h2>
<pre><code>[UI: 장착 버튼 클릭]
  → ProgressionSubsystem::SetEquippedPart(CharacterId, Def, Common)
      → IsPartOwned 검증
      → FNSCharacterSaveData.EquippedPartDefinition / Rarity 갱신
      → SaveNow()</code></pre><ul>
<li>현재 위젯의 <code>Server_RequestEquip</code> (인런 GAS 즉시 장착 경로) <strong>호출 제거</strong></li>
<li>허브에서 비주얼 프리뷰가 필요해지면 별도 추가 (현재 범위 밖)</li>
</ul>
<hr>
<h2 id="7-위젯-재연결-nspartequipwidget">7. 위젯 재연결 (<code>NSPartEquipWidget</code>)</h2>
<table>
<thead>
<tr>
<th>현재 (잘못된 경로)</th>
<th>변경 후 (아웃런 경로)</th>
</tr>
</thead>
<tbody><tr>
<td><code>SelectableParts</code> 정적 배열</td>
<td>DT_PartDefinition rows (bEnabled=true)</td>
</tr>
<tr>
<td><code>Server_RequestEquip</code> (인런 GAS)</td>
<td><code>PurchasePart</code> + <code>SetEquippedPart</code> (영구 저장)</td>
</tr>
<tr>
<td>Common 고정 직접 생성</td>
<td>row의 ValueRange[Common] 범위로 Value 롤</td>
</tr>
</tbody></table>
<p><strong>UI 표시 항목</strong> (row + OwnedParts 상태 결합):</p>
<ul>
<li>파츠 이름 / 슬롯 / 언락 비용</li>
<li>상태: 미언락(비용 표시) / 언락됨(장착 가능) / 장착 중</li>
</ul>
<hr>
<h2 id="8-런-시작-적용">8. 런 시작 적용</h2>
<p>변경 없음. 기존 경로 그대로.</p>
<pre><code>NSPlayerController::Server_UploadProgress
  → OwnedParts에서 EquippedPartDefinition + Rarity로 FNSPartSaveData 해석
  → Payload.EquippedPart 설정
  → Client_SaveProgress / ApplyEquippedPart()</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 증강 트러블 슈팅, NPC상호작용 리팩토링]]></title>
            <link>https://velog.io/@kyu_/TIL-%EC%A6%9D%EA%B0%95-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-NPC%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81</link>
            <guid>https://velog.io/@kyu_/TIL-%EC%A6%9D%EA%B0%95-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-NPC%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81</guid>
            <pubDate>Thu, 25 Jun 2026 11:30:26 GMT</pubDate>
            <description><![CDATA[<h1 id="트러블-슈팅">트러블 슈팅</h1>
<h2 id="증강의-선택가능-횟수가-다음-스테이지로-유지가-안되는-문제">증강의 선택가능 횟수가 다음 스테이지로 유지가 안되는 문제</h2>
<h3 id="증상">증상</h3>
<ul>
<li>스테이지 1에서 증강 추첨횟수가 남아있는 상태로 다음 스테이지로 넘어가면 증강 추첨횟수가 남아있지 않음</li>
<li>고른 증강(인벤토리)은 정상적으로 이관되나, 아직 선택하지 않은 <code>추첨대기횟수(PoolQueue)</code>가 초기화되는 현상</li>
</ul>
<h3 id="관련-구조">관련 구조</h3>
<pre><code>UNSAugmentSelectionComponent (PlayerController에 붙음)
  ├── PoolQueue        : 대기 중인 추첨 풀 태그 목록 (서버 전용)
  ├── PendingCount     : PoolQueue.Num() 복제값 (클라이언트 UI 뱃지용)
  └── OnPendingCountChanged : UI 갱신 델리게이트</code></pre><ul>
<li>PendingCount는 SetPendingCount로만 변경이되고, OnRep_PedingCount로만 클라에 복제되는 형태</li>
</ul>
<hr>
<h2 id="트러블-슈팅-과정">트러블 슈팅 과정</h2>
<h3 id="초기-가설--타이밍-문제">초기 가설 : 타이밍 문제</h3>
<ul>
<li>가설 : OnRep_PendingCount가 위젯 NativeConstruct보다 먼저 발화되어 델리게이트 미바인딩 -&gt; 소실?</li>
<li>검증 방법 : OnRep_PendingCount와 NativeConstruct에 로그 추가</li>
<li>결과 : NativeConstruct에서 이미 GetPendingCount를 델리게이트 구독하여 타이밍 문제가 아님</li>
</ul>
<hr>
<h3 id="2단계--postseamlesstravel-시점-확인">2단계 : PostSeamlessTravel 시점 확인</h3>
<ul>
<li>검증 방법 : PostSeamlessTravel함수를 오버라이드해서 PoolQueue가 살아있는지 확인</li>
<li>결과 : PostSeamlessTravel 시점에 PendingCOunt = 0 즉 데이터손실은 그 이전에 발생</li>
</ul>
<hr>
<h3 id="3단계--데이터-소실-경로-탐색">3단계 : 데이터 소실 경로 탐색</h3>
<p><code>Reset()</code>, <code>Server_Choose</code>, <code>SetPendingCount</code>, <code>CopyRunStateFrom</code>, 생성자에 순차적으로 로그 추가</p>
<table>
<thead>
<tr>
<th>로그</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td><code>[AugmentSelection::Reset]</code></td>
<td><strong>미호출</strong></td>
</tr>
<tr>
<td><code>[Server_Choose]</code></td>
<td><strong>미호출</strong></td>
</tr>
<tr>
<td><code>[SetPendingCount] 0으로 변경됨</code></td>
<td><strong>미호출</strong></td>
</tr>
<tr>
<td><code>[AugmentSelectionComponent::Constructor]</code></td>
<td><strong>스테이지 전환 직후 호출됨</strong></td>
</tr>
</tbody></table>
<ul>
<li>결과 : SetPendingCount(0)이 안불리는데 PendingCount가 0 -&gt; 컴포넌트가 새로 생성되어 기본값(0)으로 시작하는구나</li>
</ul>
<hr>
<h3 id="4단계--포인터-비교로-컴포넌트-재생성-확인">4단계 : 포인터 비교로 컴포넌트 재생성 확인</h3>
<ul>
<li>검증 방법 : <code>EnqueueOffer</code>와 <code>PostSeamlessTravel</code>에 <code>this</code>포인터 로그 추가</li>
</ul>
<pre><code>[EnqueueOffer]       this=000002C9E94F4F80 PoolQueue=4   ← 스테이지1
[Constructor]        this=000002C9E94F3D80               ← 전환 직후 새 컴포넌트 생성
[PostSeamlessTravel] comp=000002C9E94F3D80 PendingCount=0 ← 새 컴포넌트라서 0</code></pre><ul>
<li>결과 : 포인터가 다르다. SeamlessTravel시 새 컴포넌트 (새 PC)가 생성되고 있음이 거의 확실</li>
</ul>
<hr>
<h3 id="5단계--근본-원인-확정--agamemodebase-vs-agamemode">5단계 : 근본 원인 확정 : AGameModeBase vs AGameMode</h3>
<p>엔진소스를 뜯어서 GameModeBase와 GameMode를 보았다.</p>
<p>AGameMode::HandleSeamlessTravelPlayer의 경우</p>
<pre><code class="language-c++">if (PC &amp;&amp; PC-&gt;GetClass() != PCClassToSpawn)
{
    // 클래스가 다를 때만 새 PC spawn
    PC-&gt;SeamlessTravelTo(NewPC);
}
else
{
    // 클래스가 같으면 PC 객체 그대로 유지 ← 데이터 보존
}</code></pre>
<p>클래스가 다를때만 새 PC를 스폰하고 같으면 유지하는 코드가 존재한다.</p>
<p>AGameModeBase::HandleSeamlessTravelPlayer의 경우</p>
<pre><code class="language-cpp">// 무조건 새 PC spawn — 클래스 비교 없음
APlayerController* NewPC = SpawnPlayerControllerCommon(...);
PC-&gt;SeamlessTravelTo(NewPC);  // ← 이전 PC에서 수동 이관해야 함</code></pre>
<p>PC비교가없고 NewPC로 덮어씌움 결국 수동으로 데이터를 이관해주어야 한다</p>
<p>NeoSanctum의 경우 게임모드가 AGameModeBase를 상속받기 때문에 두가지 해결방안을 냈다.</p>
<hr>
<h2 id="해결-방안">해결 방안</h2>
<h3 id="1-playerstate로-이관">1. PlayerState로 이관</h3>
<p>AugmentSelectionComponent는 기존에 PlayerController에 장착되어있다. 결국에는 조작과 관련된 기능들이 있어서 PC에 ActorComponent로 달게 되었었고, SeamlessTravel시 해당문제는 단지 PC는 무조건 끝까지 살아있는 구조로 알고있었어서 이렇게 짠것도 있다. 이쪽 방안이 조금 더 깔끔한 구조라고 생각이 된다.</p>
<h3 id="2-seamlesstravelto-오버라이드-pc유지">2. SeamlessTravelTo 오버라이드 (PC유지)</h3>
<p>기존 PC에 달아놓는 방식에서 SeamlessTravelTo함수를 오버라이드하여 PC에서 복제하는 함수를 하나 만들고 새로운 PC가 생겼을때 데이터를 복제해주는 식으로도 짤 수 있다. 이쪽의 장점은 구현이 간단하고, 현재 다른 기능들은 정상작동하는데 굳이 PC에서 PlayerState로 옮겨야 하는 생각이 있었다. 그래서 결론적으로 이쪽으로 구현하게 되었다.</p>
<h3 id="선택이유">선택이유</h3>
<p>위에서 작성한것처럼 우선 잘 작동하는데 굳이 건드렸다가 다른 버그가 터질수도 있다고 생각하여 PC에 두는 구조를 계속 이어가기로 하였다.</p>
<hr>
<h1 id="리팩토링">리팩토링</h1>
<h2 id="위젯을-npc에서-pc로-옮기는-작업">위젯을 npc에서 pc로 옮기는 작업</h2>
<p>기존구조에서는 npc에서 위젯을 띄워주는 역할을하고 있었는데 프로젝트 구조상 PC에서 UI를 모두 관리하고 있는 구조였다. 그래서 잠재적인 버그 (PC쪽 UI와 겹쳐서 버그발생)을 미연에 방지하기 위해서 PC쪽으로 옮기기로 하였다.</p>
<h3 id="기존-구조">기존 구조</h3>
<p>NPO의 OnInteract함수에서 CreateWidget -&gt; Widget-&gt;OpenForInteractor()로 NPC가 위젯 타입을 직접 알고 생성</p>
<h3 id="변경-구조">변경 구조</h3>
<p>NPC의 GetInteractionWidgetClass -&gt; Type별 WidgetClass 반환, PlayerController에서 OpenInteractionWidget(NPC) -&gt; CreateWidget -&gt; OpenForInteractor</p>
<h3 id="변경-후-이점">변경 후 이점</h3>
<p>PC는 UNSNPCInteractionWidgetBase 타입만 알고, 실제로 UNSPetUpgradeWidget인지 UNSPartEquipWidget인지는 모름
위젯 클래스는 NPC에서 받아오고 열고닫는건 베이스 타입의 가상함수로 처리</p>
<p>덕분에 PC는 ActiveInteractionWidget하나로 어떤 NPC위젯이든 관리할 수 있다.</p>
<h1 id="개념">개념</h1>
<h2 id="seamless-travel에서-살아남는-것-vs-사라지는-것">Seamless Travel에서 살아남는 것 vs 사라지는 것</h2>
<h3 id="사라지는-것새로-생성">사라지는 것(새로 생성)</h3>
<ul>
<li>GameMode : 새 레벨에서 새로 생성</li>
<li>GameState : 새로 생성</li>
<li>Pawn : 새로 생성 (또는 리스폰)</li>
<li>HUD / 위젯 : 새 레벨에서 새로 생성</li>
</ul>
<h3 id="살아남는-것">살아남는 것</h3>
<ul>
<li>PlayerController : 재사용</li>
<li>PlayerState : 재사용<ul>
<li>같은 PlayerState를 재사용하는것이 새 인스턴스를 생성한 뒤 CopyProperties()를 호출해서 데이터를 복사하는 방식을 사용한다.</li>
<li>커스텀 데이터를 유지하고 싶으면 CopyProperties를 사용하는것 (우리가 했던 구조)</li>
</ul>
</li>
<li>PC/PS에 붙어있는 ActorComponent : 재사용</li>
</ul>
<p>으로 정리를 했는데....</p>
<h3 id="변수등장">변수등장</h3>
<p>우선 언리얼 공식문서를 보면 Traveling Actor 목록에서 PC객체 자체가 들어있다고 한다. 근데 이게 <code>AGameModeBase</code>를 상속받냐 <code>AGameMode</code>를 상속받냐에 따라 다른데
왜 다른가 하면 둘의 HandleSeamlessTravelPlayer()의 구조가 조금 다르다.</p>
<h2 id="hud">HUD</h2>
<h3 id="nativeconstruct">NativeConstruct</h3>
<p>위젯이 뷰포트에 추가되는 순간 호출</p>
<p>생성자와의 차이점은</p>
<h4 id="호출-시점">호출 시점</h4>
<ul>
<li>생성자 : 에디터/쿡 타임 포함, 객체 생성 시</li>
<li>NativeConstruct : AddToViewport() / AddToPlayerScreen() 호출 시</li>
</ul>
<h4 id="bp-변수-바인딩">BP 변수 바인딩</h4>
<ul>
<li>생성자 : 아직 없음</li>
<li>NativeConstruct : 완료(BindWidget 변수 사용 가능)</li>
</ul>
<h4 id="소유-pc">소유 PC</h4>
<ul>
<li>생성자 : 없음</li>
<li>NativeConstruct : GetOwningPlayer() 작동</li>
</ul>
<h2 id="추후-작업">추후 작업</h2>
<p>인런재화는 아웃런에 안들어감</p>
<h3 id="인런-상점">인런 상점</h3>
<p>인런에서 먹은 파츠 강화
증강 리롤</p>
<h3 id="아웃런-상점">아웃런 상점</h3>
<ul>
<li>각각 npc가 있는 구조</li>
</ul>
<p>파츠 구매
공용 스킬
드론</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블 슈팅 - 파츠 아이콘 간헐적 미표시 (패키징 전용)]]></title>
            <link>https://velog.io/@kyu_/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-%ED%8C%8C%EC%B8%A0-%EC%95%84%EC%9D%B4%EC%BD%98-%EA%B0%84%ED%97%90%EC%A0%81-%EB%AF%B8%ED%91%9C%EC%8B%9C-%ED%8C%A8%ED%82%A4%EC%A7%95-%EC%A0%84%EC%9A%A9</link>
            <guid>https://velog.io/@kyu_/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-%ED%8C%8C%EC%B8%A0-%EC%95%84%EC%9D%B4%EC%BD%98-%EA%B0%84%ED%97%90%EC%A0%81-%EB%AF%B8%ED%91%9C%EC%8B%9C-%ED%8C%A8%ED%82%A4%EC%A7%95-%EC%A0%84%EC%9A%A9</guid>
            <pubDate>Wed, 24 Jun 2026 11:23:36 GMT</pubDate>
            <description><![CDATA[<h1 id="트러블슈팅">트러블슈팅</h1>
<hr>
<h2 id="파츠-아이콘-간헐적-미표시-패키징-전용">파츠 아이콘 간헐적 미표시 (패키징 전용)</h2>
<h3 id="증상">증상</h3>
<ul>
<li>인런 중 몬스터 드롭 파츠를 줍고 Tab을 여러 번 열고 닫으면 장착 파츠 아이콘이 간헐적으로 사라짐</li>
<li>에디터 PIE에서는 재현 안 됨, 패키징 빌드에서만 발생</li>
<li>양쪽 클라이언트 동시에 사라지는 케이스도 목격됨</li>
</ul>
<hr>
<h3 id="시도-1--서버에서-equippedparts-배열이-초기화되는-경로-의심">시도 1 — 서버에서 EquippedParts 배열이 초기화되는 경로 의심</h3>
<p><strong>단서:</strong> 양쪽 클라이언트 동시에 사라진다 → 클라이언트 개별 문제가 아니라 서버에서 발생하는 문제일 것.</p>
<p><strong>의심 경로:</strong></p>
<pre><code>CommitCharacterSelection (캐릭터 선택 NPC)
→ UploadLocalProgress()
→ Server_UploadProgress_Implementation()
→ ApplyEquippedPart()
    → EquipComp-&gt;ClearAll()   ← 전체 파츠 삭제
    → SaveGame 기준 1개만 재장착</code></pre><p>인런 중 캐릭터 선택 NPC를 건드리면 SaveGame 기준으로 파츠가 리셋되면서 배열이 일시적으로 비워질 수 있다고 판단.</p>
<p><strong>추가한 로그:</strong></p>
<ul>
<li><code>OnRep_EquippedParts</code>에 배열 크기 로그</li>
<li><code>NSPartUtils::ResolvePartDefinition</code>에 DataCache 미스 로그</li>
</ul>
<p><strong>결과:</strong> 양쪽 클라이언트 동시 소멸 케이스는 이 경로가 원인일 수 있으나, 주요 재현 케이스(Tab 여러 번)와는 패턴이 달랐음. 보류.</p>
<hr>
<h3 id="시도-2--비동기-로드-타이밍-문제-의심-메모리-미로드">시도 2 — 비동기 로드 타이밍 문제 의심 (메모리 미로드)</h3>
<p><strong>단서:</strong> 패키징에서만 발생 + &quot;간헐적&quot; 패턴 → 에디터와 패키징의 에셋 메모리 상태 차이 의심.</p>
<p>에디터는 프로젝트 열 때 레퍼런스된 에셋을 모두 메모리에 올려두지만, 패키징 빌드는 명시적으로 로드하기 전까지 메모리에 없음.</p>
<p><code>ApplySlot</code> 흐름:</p>
<pre><code>RefreshEquippedParts → ApplySlot
→ ResolvePartDefinition → Definition 반환 (성공)
→ SlotButton-&gt;SetPart(*PartData, Def)
    → SetBrushFromSoftTexture(Def-&gt;Icon)  ← 여기가 의심</code></pre><p><code>NSPartDefinition::Icon</code>이 <code>TSoftObjectPtr&lt;UTexture2D&gt;</code>. Definition 에셋이 메모리에 로드되어도 내부의 소프트 참조 텍스처는 별도로 로드해야 함.</p>
<p><strong>&quot;있다가 없다가&quot;인 이유:</strong> <code>SetBrushFromSoftTexture</code>는 내부적으로 비동기 로드를 요청하지만 완료 콜백도 없고 핸들도 보관하지 않음. Tab을 열 때 이전 요청이 완료됐으면 텍스처가 메모리에 있어 표시되고, 아직 완료되지 않았으면 빈 이미지로 표시됨 — 순전히 타이밍에 의존.</p>
<p><strong>패키징에서만 터진 이유:</strong> 에디터는 에셋이 항상 메모리에 있어 <code>Icon.Get()</code>이 항상 유효. 패키징은 쿠킹(파일 포함)과 런타임 로드가 분리되어 있어, 쿠킹된 에셋이라도 명시적으로 로드하지 않으면 <code>Get()</code> = null.</p>
<hr>
<h3 id="해결">해결</h3>
<p><code>NSPartSlotButton::SetPart</code>에서 Icon을 명시적 비동기 로드 후 콜백에서 설정하도록 수정.</p>
<p><strong><code>NSPartSlotButton.h</code></strong></p>
<pre><code class="language-cpp">TSharedPtr&lt;FStreamableHandle&gt; IconLoadHandle;</code></pre>
<p><strong><code>NSPartSlotButton::SetPart</code> (Icon 처리 부분)</strong></p>
<pre><code class="language-cpp">if (UTexture2D* LoadedIcon = InPartDefinition-&gt;Icon.Get())
{
    // 이미 메모리에 있으면 즉시 설정
    PartIconImage-&gt;SetBrushFromTexture(LoadedIcon);
    PartIconImage-&gt;SetVisibility(ESlateVisibility::HitTestInvisible);
}
else if (!InPartDefinition-&gt;Icon.IsNull())
{
    // 로드 완료 후 설정, WeakThis로 위젯 소멸 후 크래시 방지
    TWeakObjectPtr&lt;UNSPartSlotButton&gt; WeakThis(this);
    TSoftObjectPtr&lt;UTexture2D&gt; SoftIcon = InPartDefinition-&gt;Icon;
    IconLoadHandle = UAssetManager::GetStreamableManager().RequestAsyncLoad(
        SoftIcon.ToSoftObjectPath(),
        [WeakThis, SoftIcon]()
        {
            if (!WeakThis.IsValid()) return;
            if (UTexture2D* Tex = SoftIcon.Get())
            {
                WeakThis-&gt;PartIconImage-&gt;SetBrushFromTexture(Tex);
                WeakThis-&gt;PartIconImage-&gt;SetVisibility(ESlateVisibility::HitTestInvisible);
            }
            WeakThis-&gt;IconLoadHandle.Reset();
        });
}</code></pre>
<p><strong><code>NSPartSlotButton::ClearPart</code></strong></p>
<pre><code class="language-cpp">if (IconLoadHandle.IsValid())
{
    IconLoadHandle-&gt;CancelHandle();
    IconLoadHandle.Reset();
}</code></pre>
<hr>
<h3 id="참고--증강-쪽과의-비교">참고 — 증강 쪽과의 비교</h3>
<p>증강 아이콘은 카드 표시 전에 모든 아이콘을 일괄 <code>RequestAsyncLoad</code>하고, <code>OnIconsLoaded</code> 콜백 이후에 카드를 채우는 구조라 같은 문제가 없었음. 파츠는 슬롯 버튼 개별로 로드하는 방식으로 수정됨.</p>
<hr>
<h2 id="아웃런-진입-시-증강-삭제">아웃런 진입 시 증강 삭제</h2>
<hr>
<h3 id="증상-1">증상</h3>
<ul>
<li>인런 &gt; 아웃런으로 진입 시 인런에서 고른 증강들을 들고오는 형태</li>
<li>실제로 인런에서도 능력이 적용되어 있음</li>
</ul>
<hr>
<h3 id="시도-1-게임모드에서-증강-clear부분을-추가">시도 1. 게임모드에서 증강 Clear부분을 추가</h3>
<ul>
<li>결과 화면이 보이고 아웃런으로 진입할때 증강들을 청소해주는 함수를 하나 만들고 거기서 증강 삭제</li>
</ul>
<hr>
<h3 id="해결-1">해결</h3>
<pre><code class="language-c++">void ANSRunGameMode::ClearAllAugments()
{
    if (!HasAuthority())
    {
        return;
    }

    ANSRunGameState* NSGameState = GetGameState&lt;ANSRunGameState&gt;();
    if (!NSGameState)
    {
        return;
    }
    for (APlayerState* PlayerState : NSGameState-&gt;PlayerArray)
    {
        ANSPlayerState* PS = Cast&lt;ANSPlayerState&gt;(PlayerState);
        if (!PS)
        {
            continue;
        }
        if (UNSAugmentInventoryComponent* Augment = PS-&gt;GetAugmentInventory())
        {
            Augment-&gt;ClearAll();
        }
    }
}</code></pre>
<p>증강을 다 삭제하는 로직 추가</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 커스텀 플러그인 만들기]]></title>
            <link>https://velog.io/@kyu_/TIL-%EC%BB%A4%EC%8A%A4%ED%85%80-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@kyu_/TIL-%EC%BB%A4%EC%8A%A4%ED%85%80-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Tue, 23 Jun 2026 13:11:58 GMT</pubDate>
            <description><![CDATA[<h1 id="neosanctum-커스텀-플러그인-정리">NeoSanctum 커스텀 플러그인 정리</h1>
<hr>
<h2 id="1-particontool">1. PartIconTool</h2>
<h3 id="왜-만들었나">왜 만들었나</h3>
<p>파츠 줍기 프롬프트 위젯(드롭 파츠 Radius 진입 시 표시)에 파츠 이미지와 이름을 띄우려 했다.
파츠 이미지 소스로 콘텐츠 브라우저의 스켈레탈 메시 썸네일을 활용하고 싶었는데, 썸네일 캡처 API(<code>ThumbnailTools</code>, <code>FObjectThumbnail</code>)가 <strong>에디터 전용 모듈(UnrealEd)</strong> 에만 존재한다.
런타임 게임 모듈에서는 접근 자체가 불가하고, 패키징(쿠킹) 시 썸네일 데이터도 제거된다.</p>
<p>해결 방향은 두 가지였다:</p>
<ul>
<li><strong>방법 A</strong> — 런타임에 <code>SceneCapture</code>로 메시를 실시간 렌더해 RenderTarget을 아이콘으로 사용 → 드롭 프롬프트처럼 자주 뜨고 사라지는 UI에는 메모리/성능 부담이 큼</li>
<li><strong>방법 B</strong> — 에디터에서 썸네일을 텍스처 애셋으로 1회 추출, <code>UNSPartDefinition::Icon</code>에 저장 → 런타임 비용 0, 기존 소프트 레퍼런스 비동기 로드 구조와 자연스럽게 연결됨</li>
</ul>
<p>방법 B를 선택했고, 이 작업을 에디터 플러그인으로 구현했다.</p>
<hr>
<h3 id="왜-플러그인으로-만들었나">왜 플러그인으로 만들었나</h3>
<ul>
<li>썸네일 캡처는 에디터 전용 기능이라 게임 빌드(Shipping)에 포함되면 안 됨</li>
<li>에디터 모듈을 게임 모듈 안에 넣으면 구조가 지저분해지고 유지보수가 어려움</li>
<li>플러그인(<code>Type: Editor</code>)은 에디터 빌드에만 포함되고 패키징 시 자동으로 제외됨</li>
<li>나중에 다른 프로젝트로 이식하거나 팀원과 공유하기 좋음</li>
</ul>
<hr>
<h3 id="구성">구성</h3>
<pre><code>Plugins/PartIconTool/
├── PartIconTool.uplugin                 (Editor 타입, LoadingPhase: PostEngineInit)
└── Source/PartIconTool/
    ├── PartIconTool.Build.cs
    ├── Public/
    │   ├── PartIconTool.h               (모듈 클래스 선언)
    │   └── PartIconToolSettings.h       (UDeveloperSettings — 캡처 파라미터)
    └── Private/
        └── PartIconTool.cpp             (2패스 SceneCapture + 메뉴 등록)</code></pre><p><strong>의존 모듈</strong></p>
<table>
<thead>
<tr>
<th>모듈</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><code>UnrealEd</code></td>
<td><code>FPreviewScene</code>, 에디터 유틸리티</td>
</tr>
<tr>
<td><code>ToolMenus</code></td>
<td>콘텐츠 브라우저 우클릭 메뉴 확장</td>
</tr>
<tr>
<td><code>ContentBrowser</code></td>
<td><code>UContentBrowserAssetContextMenuContext</code></td>
</tr>
<tr>
<td><code>AssetRegistry</code></td>
<td><code>AssetCreated</code> 알림 (콘텐츠 브라우저 즉시 반영)</td>
</tr>
<tr>
<td><code>RenderCore</code></td>
<td><code>FlushRenderingCommands</code></td>
</tr>
<tr>
<td><code>DeveloperSettings</code></td>
<td><code>UPartIconToolSettings</code> 베이스 클래스</td>
</tr>
<tr>
<td><code>NeoSanctum</code></td>
<td><code>UNSPartDefinition</code> 타입 직접 참조</td>
</tr>
</tbody></table>
<hr>
<h3 id="동작-흐름">동작 흐름</h3>
<ol>
<li>콘텐츠 브라우저에서 <code>UNSPartDefinition</code> 에셋 1개 이상 선택 → 우클릭</li>
<li><strong>&quot;Generate Icon from Mesh&quot;</strong> 클릭 (선택 항목 중 파츠 Definition이 없으면 메뉴 비노출)</li>
<li><code>GetDefault&lt;UPartIconToolSettings&gt;()</code>으로 캡처 파라미터 읽기 (해상도/각도/밝기)</li>
<li>각 Definition에 대해 <code>PartMesh.LoadSynchronous()</code>로 스켈레탈 메시 로드</li>
<li><code>Mesh-&gt;WaitForPendingInitOrStreaming()</code> + <code>FlushRenderingCommands()</code> — 렌더 리소스 준비 대기</li>
<li><code>FPreviewScene</code> 생성 — 격리된 씬에 메시 + 라이트 + <code>USceneCaptureComponent2D</code> 배치</li>
<li><code>GShaderCompilingManager-&gt;FinishAllCompilation()</code> — 머티리얼 셰이더 컴파일 완료 대기</li>
<li><strong>1패스</strong> (<code>SCS_FinalColorLDR</code>) — 색상 BGRA8 캡처</li>
<li><strong>2패스</strong> (<code>SCS_SceneColorHDR</code>) — 커버리지 마스크 캡처, <code>1 - alpha</code>로 누끼 알파 생성</li>
<li>두 패스 픽셀 합성 후 <code>UTexture2D</code> 애셋 생성<ul>
<li>저장 경로: Definition과 <strong>같은 폴더</strong>, 이름 <code>T_&lt;DefName&gt;_Icon</code></li>
<li>설정: <code>TC_EditorIcon</code> / <code>TEXTUREGROUP_UI</code> / <code>NeverStream = true</code> / <code>SRGB = true</code></li>
<li>같은 이름 애셋이 이미 있으면 덮어쓰기(재생성)</li>
</ul>
</li>
<li>생성된 텍스처를 <code>Def-&gt;Icon</code>에 지정</li>
<li>텍스처 패키지 + Definition 패키지 둘 다 디스크 저장</li>
<li>성공/건너뜀 개수 알림 팝업 표시</li>
</ol>
<p><code>PartMesh</code>가 비어 있는 Definition은 경고 로그 출력 후 건너뜀.</p>
<p><strong>캡처 파라미터 조절</strong>: <code>Project Settings &gt; Plugins &gt; Part Icon Tool</code>에서 빌드 없이 조절 가능.</p>
<table>
<thead>
<tr>
<th>설정</th>
<th>기본값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>IconResolution</code></td>
<td>256</td>
<td>캡처 해상도 (px)</td>
</tr>
<tr>
<td><code>OrbitPitch</code></td>
<td>-11.25</td>
<td>카메라 상하 각도</td>
</tr>
<tr>
<td><code>OrbitYaw</code></td>
<td>-157.5</td>
<td>카메라 좌우 각도</td>
</tr>
<tr>
<td><code>OrbitZoom</code></td>
<td>0</td>
<td>줌 오프셋</td>
</tr>
<tr>
<td><code>DistanceMultiplier</code></td>
<td>1.15</td>
<td>메시 여백 배율</td>
</tr>
<tr>
<td><code>LightBrightness</code></td>
<td>1.0</td>
<td>정면 라이트 밝기</td>
</tr>
</tbody></table>
<hr>
<h3 id="코드-진화-과정">코드 진화 과정</h3>
<h4 id="요구사항-시작점">요구사항 시작점</h4>
<p>파츠 줍기 프롬프트에 아이콘을 보여주기로 했고, 그 아이콘을 스켈레탈 메시 썸네일처럼 찍어서 쓰고 싶다는 요구였다. 처음엔 <code>ThumbnailTools</code>로 썸네일을 그대로 읽어 텍스처로 저장하는 구조로 시작했다.</p>
<h4 id="1세대--thumbnailtools-기반">1세대 — ThumbnailTools 기반</h4>
<pre><code>GenerateThumbnailForObjectToSaveToDisk(Mesh)
→ FObjectThumbnail → 픽셀 추출 → UTexture2D 저장</code></pre><p>문제: 처음 캡처된 이미지가 완전히 검정이었다. 렌더 리소스가 GPU에 올라가기 전에 캡처되었기 때문. <code>WaitForPendingInitOrStreaming()</code> + <code>FlushRenderingCommands()</code>를 추가했지만 여전히 검정이 나왔다. <code>ThumbnailTools</code> 자체가 에디터 뷰포트 상태에 의존적이라 격리된 환경에서 안정적으로 동작하지 않는다는 결론.</p>
<h4 id="2세대--scenecapture--fpreviewscene-기반-현재">2세대 — SceneCapture + FPreviewScene 기반 (현재)</h4>
<p>썸네일 렌더러에 의존하지 않고 <code>USceneCaptureComponent2D</code>로 직접 메시를 찍는 구조로 전환했다. <code>FPreviewScene</code>으로 격리된 씬을 만들고 메시 컴포넌트와 라이트, 카메라를 직접 배치한다.</p>
<pre><code>FPreviewScene
  └── USkeletalMeshComponent (파츠 메시)
  └── USceneCaptureComponent2D
        → UTextureRenderTarget2D → 픽셀 읽기 → UTexture2D 저장</code></pre><p>이 구조로 전환하자 검정 문제가 사라졌다.</p>
<h4 id="누끼배경-투명화-요구">누끼(배경 투명화) 요구</h4>
<p>아이콘을 위젯 배경에 자연스럽게 녹이고 싶다는 요구가 추가됐다. <code>SCS_FinalColorLDR</code>은 렌더 안 된 영역도 alpha=1(불투명)로 채워서 검정 배경이 생긴다. 해결을 위해 <strong>2패스 캡처</strong>로 전환:</p>
<ul>
<li><strong>1패스</strong> (<code>SCS_FinalColorLDR</code>) — 색상 정확도 높은 BGRA8 캡처</li>
<li><strong>2패스</strong> (<code>SCS_SceneColorHDR</code>) — alpha 채널에 커버리지 마스크 포함 (1 - alpha = 메시가 있는 곳)</li>
</ul>
<p>두 패스 결과를 합쳐서 1패스 색상 + 2패스 커버리지를 alpha로 적용, 배경이 투명한 PNG형 텍스처 생성.</p>
<pre><code class="language-cpp">// 1패스: 색상 픽셀 읽기
Capture-&gt;CaptureSource = SCS_FinalColorLDR;
Capture-&gt;CaptureScene();
RTResource-&gt;ReadPixels(Pixels, ...);

// 2패스: 알파(커버리지) 픽셀 읽기
Capture-&gt;CaptureSource = SCS_SceneColorHDR;
Capture-&gt;CaptureScene();
MaskResource-&gt;ReadLinearColorPixels(MaskPixels);

// 합성
for (int32 i = 0; i &lt; Pixels.Num(); ++i)
{
    const float Coverage = FMath::Clamp(1.f - MaskPixels[i].A, 0.f, 1.f);
    Pixels[i].A = static_cast&lt;uint8&gt;(FMath::RoundToInt(Coverage * 255.f));
}</code></pre>
<h4 id="머티리얼-미로드-문제">머티리얼 미로드 문제</h4>
<p>2세대로 전환 후 메시가 렌더되긴 하는데 머티리얼이 모두 흰색(기본 머티리얼)으로 나오는 문제가 생겼다. 원인은 머티리얼 셰이더가 아직 컴파일 중인 상태에서 캡처했기 때문. <code>GShaderCompilingManager-&gt;FinishAllCompilation()</code> 호출로 해결.</p>
<h4 id="과노출-문제">과노출 문제</h4>
<p>라이트 밝기(<code>LightBrightness=4.0</code>) + 자동 노출(<code>AutoExposure=1.0</code>)이 겹쳐서 메시 전체가 흰색으로 날아가는 문제가 발생했다. <code>LightBrightness</code>를 1.0으로 낮추고 <code>ShowFlags.SetBloom(false)</code>로 블룸 번짐을 차단했다.</p>
<h4 id="캡처-설정-조정-요구-→-udevelopersettings-도입">캡처 설정 조정 요구 → UDeveloperSettings 도입</h4>
<p>각도나 밝기를 코드 수정 없이 바꿀 수 있게 해달라는 요구. <code>UDeveloperSettings</code>(<code>config=Editor</code>)를 상속한 <code>UPartIconToolSettings</code> 클래스를 추가해 <strong>Project Settings &gt; Plugins &gt; Part Icon Tool</strong> 에서 직접 조절할 수 있게 했다. 빌드 없이 설정 변경 후 바로 Generate하면 즉시 반영된다.</p>
<hr>
<h3 id="최종-구조">최종 구조</h3>
<pre><code>Plugins/PartIconTool/
├── PartIconTool.uplugin
└── Source/PartIconTool/
    ├── PartIconTool.Build.cs
    ├── Public/
    │   ├── PartIconTool.h
    │   └── PartIconToolSettings.h       (UDeveloperSettings — 캡처 파라미터)
    └── Private/
        └── PartIconTool.cpp             (2패스 SceneCapture + 메뉴 등록)</code></pre><p><strong>캡처 파이프라인 요약</strong></p>
<pre><code>[콘텐츠 브라우저 우클릭 → Generate Icon from Mesh]
  ↓
PartMesh.LoadSynchronous() + WaitForPendingInitOrStreaming()
  ↓
FPreviewScene 생성 (메시 + 라이트 + SceneCaptureComponent2D)
  ↓
GShaderCompilingManager-&gt;FinishAllCompilation()   ← 셰이더 준비 대기
  ↓
1패스: SCS_FinalColorLDR → Pixels (색상)
2패스: SCS_SceneColorHDR → MaskPixels (커버리지)
  ↓
Pixels[i].A = 1 - MaskPixels[i].A              ← 누끼 합성
  ↓
UTexture2D 생성/갱신 (T_&lt;DefName&gt;_Icon, TC_EditorIcon)
  ↓
Def-&gt;Icon = 생성된 텍스처, 패키지 저장</code></pre><p><strong>의존 모듈</strong></p>
<table>
<thead>
<tr>
<th>모듈</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><code>UnrealEd</code></td>
<td><code>FPreviewScene</code>, 에디터 유틸리티</td>
</tr>
<tr>
<td><code>ToolMenus</code></td>
<td>콘텐츠 브라우저 우클릭 메뉴 확장</td>
</tr>
<tr>
<td><code>ContentBrowser</code></td>
<td><code>UContentBrowserAssetContextMenuContext</code></td>
</tr>
<tr>
<td><code>AssetRegistry</code></td>
<td><code>AssetCreated</code> 알림 (콘텐츠 브라우저 즉시 반영)</td>
</tr>
<tr>
<td><code>RenderCore</code></td>
<td><code>FlushRenderingCommands</code></td>
</tr>
<tr>
<td><code>DeveloperSettings</code></td>
<td><code>UPartIconToolSettings</code> 베이스 클래스</td>
</tr>
<tr>
<td><code>NeoSanctum</code></td>
<td><code>UNSPartDefinition</code> 타입 직접 참조</td>
</tr>
</tbody></table>
<hr>
<h3 id="있었던-문제들-요약">있었던 문제들 (요약)</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>증상</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>캡처 결과가 완전 검정</td>
<td><code>ThumbnailTools</code> 에디터 뷰포트 의존성, 리소스 미준비</td>
<td>SceneCapture + FPreviewScene으로 전환</td>
</tr>
<tr>
<td>2</td>
<td><code>FlushRenderingCommands</code> LNK2019</td>
<td><code>Build.cs</code>에 <code>RenderCore</code> 누락</td>
<td><code>PrivateDependencyModuleNames</code>에 추가</td>
</tr>
<tr>
<td>3</td>
<td>머티리얼이 흰색 기본 머티리얼로 렌더됨</td>
<td>캡처 시 셰이더 아직 컴파일 중</td>
<td><code>GShaderCompilingManager-&gt;FinishAllCompilation()</code> 추가</td>
</tr>
<tr>
<td>4</td>
<td>메시 전체가 흰색으로 날아감 (과노출)</td>
<td><code>LightBrightness=4.0</code> + 자동 노출 중첩</td>
<td><code>LightBrightness=1.0</code> + <code>SetBloom(false)</code></td>
</tr>
<tr>
<td>5</td>
<td>배경이 검정으로 고정됨 (누끼 안 됨)</td>
<td><code>SCS_FinalColorLDR</code>은 alpha 항상 불투명</td>
<td>2패스 캡처: 2패스 alpha로 커버리지 마스크 생성 후 합성</td>
</tr>
</tbody></table>
<hr>
<h3 id="사용-방법">사용 방법</h3>
<ol>
<li>파츠 Definition 에셋(<code>UNSPartDefinition</code>)을 콘텐츠 브라우저에서 선택</li>
<li>우클릭 → <strong>Generate Icon from Mesh</strong></li>
<li>같은 폴더에 <code>T_&lt;DefName&gt;_Icon</code> 텍스처가 생성되고 Icon 필드에 자동 지정됨</li>
<li>이후 드롭 파츠 Radius 진입 시 프롬프트에 파츠 이름 + 아이콘 표시</li>
</ol>
<p>각도나 밝기를 조정하려면 <strong>Project Settings &gt; Plugins &gt; Part Icon Tool</strong> 에서 수치를 바꾼 뒤 다시 Generate하면 된다. 빌드 불필요.</p>
<blockquote>
<p>파츠를 추가할 때마다 이 작업을 1회 실행해야 아이콘이 갱신된다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[상호작용 시스템 트러블슈팅]]></title>
            <link>https://velog.io/@kyu_/%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@kyu_/%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Mon, 22 Jun 2026 10:17:52 GMT</pubDate>
            <description><![CDATA[<h1 id="트러블슈팅--상호작용-시스템--드롭-파츠">트러블슈팅 — 상호작용 시스템 &amp; 드롭 파츠</h1>
<h2 id="1-상호작용-프롬프트가-특정-파츠에서만-안-뜸">1. 상호작용 프롬프트가 특정 파츠에서만 안 뜸</h2>
<p><strong>증상</strong><br>일부 드롭 파츠에 다가가도 프롬프트 위젯이 표시되지 않음. 다른 파츠는 정상.</p>
<p><strong>원인</strong><br><code>NSInteractionComponent::OnSphereBeginOverlap</code>에서 겹침 시점에 <code>CanInteract()</code>를 검사해 후보 목록에서 제외했음.<br>드롭 파츠는 비동기로 데이터(<code>StoredInstance</code>)를 채우기 때문에, 겹침 이벤트가 먼저 도착하면 <code>CanInteract = false</code> → 후보에서 영구 제외.</p>
<p><strong>해결</strong><br><code>OnSphereBeginOverlap</code>에서 <code>CanInteract</code> 검사 제거. 모든 <code>INSInteractable</code> 구현체를 즉시 후보에 추가.<br><code>UpdateActiveTarget</code>(Tick)에서 <code>CanInteract</code>를 매 프레임 재검사해 활성 대상을 결정.</p>
<pre><code class="language-cpp">// NSInteractionComponent.cpp — BeginOverlap
// 이전: CanInteract 통과한 것만 추가
// 수정: 구현체면 무조건 추가, 유효성은 Tick에서 판단
if (OtherActor-&gt;Implements&lt;UNSInteractable&gt;())
{
    Candidates.AddUnique(OtherActor);
}</code></pre>
<hr>
<h2 id="2-프롬프트-위치가-완전히-엉뚱한-곳에-표시됨">2. 프롬프트 위치가 완전히 엉뚱한 곳에 표시됨</h2>
<p><strong>증상</strong><br>프롬프트 위젯이 파츠/NPC 위에 뜨지 않고 화면 한쪽 구석이나 허공에 표시됨.</p>
<p><strong>원인 1 — 바운드 계산이 DetectionSphere 포함</strong><br><code>ShowPromptFor</code>에서 액터 바운드(<code>GetActorBounds</code>)를 기준으로 상단 Z를 계산했는데, <code>USphereComponent</code> 반경이 합산되어 높이가 수백 유닛 이상 튀었음.</p>
<p><strong>원인 2 — 상의(Body) 파츠 메시 피벗 어긋남</strong><br><code>SetupVisual</code>에서 <code>MeshComp</code>를 Z 방향으로 보정해 이동했지만, <code>DetectionCollision</code>과 <code>PromptAnchor</code>는 루트에 고정된 채 그대로여서 보이는 메시와 위치가 달랐음.</p>
<p><strong>해결</strong><br>각 액터에 <code>USceneComponent PromptAnchor</code>를 추가하고, <code>INSInteractable</code> 인터페이스에 <code>GetPromptWorldLocation()</code> 메서드를 추가.<br><code>ShowPromptFor</code>는 바운드 계산 대신 이 메서드 결과를 사용.<br><code>SetupVisual</code>에서 메시 Z 보정 후 <code>DetectionCollision</code>과 <code>PromptAnchor</code>도 함께 재배치.</p>
<pre><code class="language-cpp">// NSDroppedPart.cpp — SetupVisual
const FVector MeshCenter(MeshBounds.Origin.X, MeshBounds.Origin.Y, MeshBounds.BoxExtent.Z);
if (DetectionCollision) { DetectionCollision-&gt;SetRelativeLocation(MeshCenter); }
if (PromptAnchor)       { PromptAnchor-&gt;SetRelativeLocation(MeshCenter + FVector(0, 0, MeshBounds.BoxExtent.Z + 30.f)); }</code></pre>
<hr>
<h2 id="3-f-키를-눌러도-파츠-줍기교체가-안-됨">3. F 키를 눌러도 파츠 줍기/교체가 안 됨</h2>
<p><strong>증상</strong><br>프롬프트는 뜨는데 F를 눌러도 파츠가 장착되지 않음. 아무리 가까이 붙어도 동일.</p>
<p><strong>원인</strong><br><code>TryPickup</code>에서 <code>GetActorLocation()</code>(루트 위치)과 플레이어 위치 사이의 거리를 비교했는데,<br><code>SetupVisual</code>이 <code>MeshComp</code>를 Z 보정해도 루트는 그대로라 실제 메시 위치와 루트 위치가 달랐음. 루트 기준 거리가 항상 임계값 초과.</p>
<p><strong>해결</strong><br>거리 계산 대신 <code>DetectionCollision-&gt;IsOverlappingActor(InstigatorPawn)</code> 사용.<br>프롬프트가 뜨는 조건(DetectionSphere 겹침)과 동일한 기준이므로 데드존 없음.</p>
<pre><code class="language-cpp">// NSDroppedPart.cpp — TryPickup
if (!DetectionCollision || !DetectionCollision-&gt;IsOverlappingActor(InstigatorPawn))
{
    return;
}</code></pre>
<hr>
<h2 id="4-nsinteractioncomponent가-controller에-붙어-있어-상호작용이-항상-실패">4. <code>NSInteractionComponent</code>가 Controller에 붙어 있어 상호작용이 항상 실패</h2>
<p><strong>증상</strong><br><code>TryInteract</code> 호출 시 항상 <code>ActiveTarget</code>이 없어서 아무것도 실행 안 됨.<br><code>OnSphereBeginOverlap</code>도 한 번도 호출 안 됨.</p>
<p><strong>원인</strong><br><code>NSInteractionComponent</code>를 <code>NSPlayerController</code> 생성자에서 만들었는데,<br><code>TryInteract</code>가 Pawn에서 <code>FindComponentByClass</code>를 했고 Controller에 붙은 컴포넌트는 Pawn에서 검색 안 됨.<br>또한 <code>DetectionSphere</code>가 Controller에 붙어 있어 월드에 존재하지 않으므로 겹침 이벤트 자체가 발생하지 않았음.</p>
<p><strong>해결</strong><br><code>NSPlayerCharacterBase</code> 생성자로 이동. Pawn에 붙어야 물리 위치를 가져 DetectionSphere가 정상 작동.</p>
<pre><code class="language-cpp">// NSPlayerCharacterBase.cpp — 생성자
InteractionComp = CreateDefaultSubobject&lt;UNSInteractionComponent&gt;(TEXT(&quot;InteractionComp&quot;));</code></pre>
<hr>
<h2 id="5-logstreamablemanager-requestasyncload-called-with-empty-or-only-null-assets-경고">5. <code>LogStreamableManager: RequestAsyncLoad() called with empty or only null assets!</code> 경고</h2>
<p><strong>증상</strong><br>파츠 줍기(F)를 누를 때마다 로그 창에 경고 1줄씩 출력됨.<br>빈 공간에서 F를 누르면 경고 없음. 파츠 주울 때만 발생.</p>
<p><strong>원인 추적 과정</strong></p>
<ol>
<li>처음에는 <code>GrantAbilities</code>의 빈 배열 <code>RequestAsyncLoad</code> 의심 → 가드 추가했으나 경고 지속</li>
<li><code>[LoadHunt]</code> 로그를 <code>GrantAbilities</code>와 <code>ApplyPartEffect</code>에 추가 → 경로는 유효하게 찍힘</li>
<li>GE/GA를 데이터 에셋에서 비워도 경고 발생 → GE/GA 아님 확인</li>
<li>최종 원인: <code>NSPartSlotButton::SetPart</code>에서 <code>PartIconImage-&gt;SetBrushFromSoftTexture(InPartDefinition-&gt;Icon)</code> 호출 시 <code>Icon</code>이 None(미설정)이어서 내부적으로 빈 경로로 비동기 로드 요청 발생</li>
</ol>
<p><strong>해결</strong><br><code>Icon.IsNull()</code> 가드 추가.</p>
<pre><code class="language-cpp">// NSPartSlotButton.cpp — SetPart
if (!InPartDefinition-&gt;Icon.IsNull())
{
    PartIconImage-&gt;SetBrushFromSoftTexture(InPartDefinition-&gt;Icon);
    PartIconImage-&gt;SetVisibility(ESlateVisibility::HitTestInvisible);
}
else
{
    PartIconImage-&gt;SetBrushFromTexture(nullptr);
    PartIconImage-&gt;SetVisibility(ESlateVisibility::Hidden);
}</code></pre>
<p><strong>교훈</strong><br><code>SetBrushFromSoftTexture</code>는 내부에서 <code>RequestAsyncLoad</code>를 직접 호출함.<br>소프트 레퍼런스를 넘기기 전에 반드시 <code>IsNull()</code> 검사 필요.</p>
<hr>
<h2 id="6-detectionradius-값과-실제-스피어-반경이-에디터에서-동기화-안-됨">6. <code>DetectionRadius</code> 값과 실제 스피어 반경이 에디터에서 동기화 안 됨</h2>
<p><strong>증상</strong><br>에디터 Details 패널에서 <code>DetectionRadius</code>를 바꿔도 스피어 시각화 크기가 즉시 반영되지 않음.</p>
<p><strong>원인</strong><br>생성자에서 <code>SetSphereRadius(InteractRadius)</code> 한 번만 호출. 에디터 수정 시 재호출 없음.</p>
<p><strong>해결</strong><br><code>OnConstruction</code>에서 <code>SetSphereRadius</code> 재호출. 에디터에서 값 변경 시마다 <code>OnConstruction</code>이 재실행됨.</p>
<pre><code class="language-cpp">void ANSDroppedPart::OnConstruction(const FTransform&amp; Transform)
{
    Super::OnConstruction(Transform);
    if (DetectionCollision) { DetectionCollision-&gt;SetSphereRadius(InteractRadius); }
}</code></pre>
<p>NPC 베이스(<code>NSInteractableNPCBase</code>)도 동일 패턴 적용.</p>
<hr>
<h2 id="7-server-rpc가-_implementation-빌드-에러-또는-미호출">7. Server RPC가 <code>_Implementation</code> 빌드 에러 또는 미호출</h2>
<p><strong>증상</strong><br><code>UFUNCTION(Server, Reliable)</code> 선언 후 빌드 에러 또는 런타임에 RPC가 실행 안 됨.</p>
<p><strong>원인</strong><br>UE5에서 <code>Server</code> RPC는 함수명이 반드시 <code>Server_</code>로 시작해야 UHT가 올바르게 처리함.<br><code>ServerRequestEquip</code>처럼 접두사 없이 쓰면 <code>_Implementation</code> 생성이 의도대로 안 될 수 있음.</p>
<p><strong>해결</strong><br><code>Server_RequestEquip</code>, <code>Server_RequestPickup</code> 등 <code>Server_</code> 접두사로 통일.<br><code>Client_</code> RPC도 동일하게 <code>Client_</code> 접두사 사용.</p>
<hr>
<h2 id="9-리슨-서버에서-클라-플레이어-주변의-상호작용-위젯이-호스트-화면에도-표시됨">9. 리슨 서버에서 클라 플레이어 주변의 상호작용 위젯이 호스트 화면에도 표시됨</h2>
<p><strong>증상</strong><br>2인 리슨 서버 환경. 클라이언트 플레이어가 상호작용 액터(NPC 또는 드롭 파츠)에 접근하면,<br>클라이언트 화면에는 정상적으로 프롬프트 위젯이 뜨지만 <strong>호스트 화면에도 동일 위젯이 표시</strong>됨.<br>클라 플레이어가 멀어지면 호스트 화면의 위젯도 사라짐.</p>
<p><img src="https://velog.velcdn.com/images/kyu_/post/4c06c4af-72b7-4606-b8f6-cdaed00daef0/image.png" alt=""></p>
<p><strong>원인</strong><br><code>NSInteractionComponent::BeginPlay</code>에서 <strong>모든 머신의 모든 폰</strong>에 대해 무조건 셋업을 실행했기 때문.</p>
<p>리슨 서버는 모든 플레이어 폰을 서버 프로세스 안에서 관리한다. 문제는 두 가지였다.</p>
<ol>
<li><p><strong>오버랩 델리게이트가 비로컬 폰에도 바인딩됨</strong><br><code>BeginPlay</code>에서 <code>IsLocallyControlled()</code> 검사 없이 <code>DetectionSphere-&gt;OnComponentBeginOverlap.AddDynamic(...)</code> 을 호출해, 호스트 위에서 실행되는 클라 폰의 <code>NSInteractionComponent</code>에도 델리게이트가 붙었다.</p>
</li>
<li><p><strong>DetectionSphere 콜리전이 모든 폰에서 활성 상태</strong><br>생성자에서 <code>SetCollisionProfileName(TEXT(&quot;OverlapAllDynamic&quot;))</code>만 설정하고 콜리전 자체를 끄지 않아, 비로컬 폰의 스피어도 오버랩 이벤트를 발생시켰다.</p>
</li>
</ol>
<p>결과적으로 호스트 위에서 돌아가는 클라 폰이 오버랩을 감지 → <code>Candidates</code>에 추가 → Tick에서 <code>UpdateActiveTarget</code> 실행 → 위젯을 호스트 화면에 표시하는 흐름이 발생했다.</p>
<p><code>UpdateActiveTarget</code> 내부의 <code>GetOwnerController()</code> 가드(<code>if (!PC)</code>)는 컨트롤러 유무만 보는데,<br>리슨 서버에서 원격 플레이어 폰도 서버 입장에서 <code>GetController()</code>가 non-null <code>PlayerController</code>를 반환하므로 이 가드는 원격 폰을 걸러내지 못한다.</p>
<p><strong>해결</strong><br>컴포넌트에 <code>EnableLocalInteraction()</code> 함수를 추가하고, 로컬 전용 셋업을 이 함수 하나로 집중. 비로컬·중복 호출은 즉시 리턴.</p>
<p>변경된 파일과 내용:</p>
<p><strong><code>NSInteractionComponent.h</code></strong></p>
<ul>
<li><code>EnableLocalInteraction()</code> public 함수 추가</li>
<li><code>IsOwnerLocallyControlled()</code> private 헬퍼 추가</li>
<li><code>bLocalInteractionEnabled</code> 중복 방지 플래그 추가</li>
</ul>
<p><strong><code>NSInteractionComponent.cpp</code></strong></p>
<ul>
<li><p>생성자: <code>DetectionSphere-&gt;SetCollisionEnabled(ECollisionEnabled::NoCollision)</code> 추가<br>→ 비로컬 폰의 스피어는 콜리전 쿼리 자체가 발생하지 않음</p>
</li>
<li><p><code>BeginPlay</code>: 오버랩 바인딩 / 위젯 클래스 세팅 제거, <code>EnableLocalInteraction()</code> 호출로 대체<br>→ attach 등 머신 공통 셋업만 남음. 스탠드얼론/리슨 호스트 본인 폰은 이미 possess 돼 있어 여기서 활성화됨</p>
</li>
<li><p><code>EnableLocalInteraction()</code> 구현:</p>
<pre><code class="language-cpp">void UNSInteractionComponent::EnableLocalInteraction()
{
    if (bLocalInteractionEnabled) { return; }
    if (!IsOwnerLocallyControlled()) { return; }

    bLocalInteractionEnabled = true;

    if (PromptWidgetClass) { PromptWidgetComponent-&gt;SetWidgetClass(PromptWidgetClass); }
    DetectionSphere-&gt;OnComponentBeginOverlap.AddDynamic(this, &amp;UNSInteractionComponent::OnSphereBeginOverlap);
    DetectionSphere-&gt;OnComponentEndOverlap.AddDynamic(this, &amp;UNSInteractionComponent::OnSphereEndOverlap);
    DetectionSphere-&gt;SetCollisionEnabled(ECollisionEnabled::QueryOnly);
}</code></pre>
</li>
<li><p><code>IsOwnerLocallyControlled()</code> 구현: <code>APawn::IsLocallyControlled()</code> 사용</p>
</li>
</ul>
<p><strong><code>NSPlayerCharacterBase.h/.cpp</code></strong></p>
<ul>
<li><code>OnRep_Controller()</code> 오버라이드 추가</li>
<li><code>PossessedBy</code>(서버/리슨 호스트 경로)와 <code>OnRep_Controller</code>(클라이언트 본인 폰 — 컨트롤러가 리플리케이션으로 도착하는 시점) 양쪽에서 <code>EnableLocalInteraction()</code> 호출</li>
</ul>
<p><code>OnRep_PlayerState</code>가 아닌 <code>OnRep_Controller</code>를 진입점으로 쓴 이유: <code>IsLocallyControlled()</code>는 내부적으로 <code>Controller</code>에 의존하고, 클라이언트 본인 폰에는 <code>Controller</code>가 리플리케이트된다. <code>BeginPlay</code> 단독으로는 클라이언트에서 컨트롤러가 아직 null일 수 있어 불안정하다.</p>
<p><strong>결과</strong>  </p>
<table>
<thead>
<tr>
<th>경로</th>
<th>활성화 여부</th>
</tr>
</thead>
<tbody><tr>
<td>스탠드얼론 / 리슨 호스트 본인 폰</td>
<td><code>PossessedBy</code> 또는 <code>BeginPlay</code> 폴백에서 활성</td>
</tr>
<tr>
<td>리슨 서버 위의 원격 플레이어 폰</td>
<td><code>IsLocallyControlled() == false</code> → 무시</td>
</tr>
<tr>
<td>클라이언트 본인 폰</td>
<td><code>OnRep_Controller</code>에서 활성</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/kyu_/post/1100a687-931d-4dab-82a9-eb99704cfd4b/image.png" alt=""></p>
<p>비로컬 폰의 <code>DetectionSphere</code>는 <code>NoCollision</code> 상태로 남아 오버랩 이벤트 자체가 발생하지 않으며, 오버랩 델리게이트 바인딩과 위젯 인스턴스 생성도 일어나지 않는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 상호작용 시스템 완료 및 디버깅]]></title>
            <link>https://velog.io/@kyu_/TIL-%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%99%84%EB%A3%8C-%EB%B0%8F-%EB%94%94%EB%B2%84%EA%B9%85</link>
            <guid>https://velog.io/@kyu_/TIL-%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%99%84%EB%A3%8C-%EB%B0%8F-%EB%94%94%EB%B2%84%EA%B9%85</guid>
            <pubDate>Mon, 22 Jun 2026 10:14:55 GMT</pubDate>
            <description><![CDATA[<h1 id="상호작용-시스템-featurenpc-interaction">상호작용 시스템 (feature/NPC-Interaction)</h1>
<blockquote>
<p>기록일: 2026-06-22<br>브랜치: feature/NPC-Interaction<br>관련 파일: <code>NSInteractionComponent</code>, <code>NSInteractable</code>, <code>NSInteractableNPCBase</code>, <code>NSPartsNPC</code>, <code>NSPetNPC</code>, <code>NSPartEquipWidget</code>, <code>NSPetUpgradeWidget</code>, <code>NSPlayerCharacterBase</code></p>
</blockquote>
<hr>
<h2 id="전체-구조-한눈에-보기">전체 구조 한눈에 보기</h2>
<pre><code>[플레이어 폰]
  └── NSInteractionComponent     감지 + 입력 처리 + RPC 담당
        └── DetectionSphere      근접 오버랩 감지
        └── PromptWidgetComponent 화면에 띄우는 상호작용 프롬프트

[NPC 액터]
  └── ANSInteractableNPCBase     NPC 공통 베이스 (ACharacter + INSInteractable)
        └── ANSPartsNPC          파츠 장착 NPC
        └── ANSPetNPC            펫 강화 NPC</code></pre><hr>
<h2 id="상호작용-흐름">상호작용 흐름</h2>
<pre><code>① 플레이어가 NPC에 접근
   DetectionSphere 오버랩 → Candidates 목록에 추가 → Tick 활성화

② Tick마다 UpdateActiveTarget() 실행
   후보 중 가장 가까운 INSInteractable 대상 → ActiveTarget
   해당 NPC의 PromptAnchor 위치에 프롬프트 위젯 표시

③ F키 입력 → TryInteract()
   클라이언트에서 CanInteract 사전 검사 (반응성용)
   → Server_RequestInteract(Target) RPC 전송

④ 서버에서 Server_RequestInteract_Implementation 실행
   CanInteract 재검증 (서버 권위)
   → 통과 시 Client_OnInteractApproved(Target) RPC 전송

⑤ 클라이언트에서 Client_OnInteractApproved_Implementation 실행
   Execute_OnInteract(Target, PC) 호출
   → ANSPartsNPC or ANSPetNPC의 OnInteract_Implementation 실행
   → 해당 UI 위젯 생성 + AddToViewport</code></pre><hr>
<h2 id="인터페이스-insinteractable">인터페이스: INSInteractable</h2>
<p>모든 상호작용 가능한 액터가 구현해야 하는 인터페이스.</p>
<table>
<thead>
<tr>
<th>함수</th>
<th>설명</th>
<th>실행 위치</th>
</tr>
</thead>
<tbody><tr>
<td><code>CanInteract(PC)</code></td>
<td>이 플레이어가 상호작용 가능한지 판단</td>
<td>클라(Tick 표시용) + 서버(검증용)</td>
</tr>
<tr>
<td><code>OnInteract(PC)</code></td>
<td>실제 상호작용 처리 (UI 오픈 등)</td>
<td>클라이언트</td>
</tr>
<tr>
<td><code>GetPromptText()</code></td>
<td>프롬프트에 표시할 텍스트</td>
<td>클라이언트</td>
</tr>
<tr>
<td><code>GetPromptWorldLocation()</code></td>
<td>프롬프트 위젯이 뜰 월드 위치</td>
<td>클라이언트</td>
</tr>
</tbody></table>
<p><code>Execute_OnInteract(Target, PC)</code> 형태로 호출하는 이유: UHT가 자동 생성하는 정적 디스패처로, C++ 구현체든 BP 구현체든 올바른 쪽으로 알아서 디스패치한다.</p>
<hr>
<h2 id="npc-베이스-ansinteractablenpcbase">NPC 베이스: ANSInteractableNPCBase</h2>
<p><code>ACharacter + INSInteractable</code> 조합. 공통 로직 구현.</p>
<ul>
<li><strong><code>CanInteract_Implementation</code></strong>: <code>PlayerState</code>의 <code>NSPlayerProgressComponent</code>에서 <code>IsNPCUnlocked(NPCId)</code> 확인. NPCId는 에디터 Details → NPC 카테고리에서 설정.</li>
<li><strong><code>PromptText</code></strong>: 에디터 Details → NPC 카테고리에서 직접 입력.</li>
<li><strong><code>PromptAnchor</code></strong>: 프롬프트 위젯이 뜰 위치. 에디터에서 드래그로 조정 가능.</li>
<li><strong><code>DetectionCollision</code></strong>: <code>NSInteractionComponent</code>의 <code>DetectionSphere</code>가 오버랩 감지에 사용.</li>
</ul>
<hr>
<h2 id="nsinteractioncomponent-주요-설계-포인트">NSInteractionComponent 주요 설계 포인트</h2>
<h3 id="비로컬-폰-격리-enablelocalinteraction">비로컬 폰 격리 (EnableLocalInteraction)</h3>
<p>리슨 서버에서 서버가 모든 플레이어 폰을 관리하기 때문에, 아무 처리 없이 두면 클라이언트 폰의 상호작용 UI가 호스트 화면에도 표시되는 문제가 생긴다.</p>
<p>해결: 로컬 전용 셋업(오버랩 바인딩 + 콜리전 활성화 + 위젯 클래스)을 <code>EnableLocalInteraction()</code> 하나로 집중하고, 비로컬 폰에서는 아무것도 하지 않는다.</p>
<pre><code>생성자: DetectionSphere 콜리전 = NoCollision (기본 OFF)

EnableLocalInteraction() 호출 시점:
  - BeginPlay      → 스탠드얼론 / 리슨 호스트 폴백
  - PossessedBy    → 서버 / 리슨 호스트 경로  
  - OnRep_Controller → 클라이언트 본인 폰 경로 (컨트롤러 리플리케이션 도착 시)

IsLocallyControlled() == false → 즉시 리턴, 아무것도 안 함</code></pre><table>
<thead>
<tr>
<th>폰</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>스탠드얼론 / 리슨 호스트 본인 폰</td>
<td>활성</td>
</tr>
<tr>
<td>리슨 서버 위의 원격 플레이어 폰</td>
<td>비활성 (콜리전 OFF, 바인딩 없음, 위젯 없음)</td>
</tr>
<tr>
<td>클라이언트 본인 폰</td>
<td>OnRep_Controller에서 활성</td>
</tr>
</tbody></table>
<h3 id="tick-최적화">Tick 최적화</h3>
<p>오버랩 중인 후보가 없으면 Tick을 끈다. 후보가 생기면(BeginOverlap) 켜고, 전부 사라지면(EndOverlap) 다시 끈다.</p>
<h3 id="서버클라-역할-분리">서버/클라 역할 분리</h3>
<table>
<thead>
<tr>
<th>단계</th>
<th>실행 위치</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>오버랩 감지 + 프롬프트 표시</td>
<td>클라이언트 로컬</td>
<td>UI는 로컬에서만</td>
</tr>
<tr>
<td>CanInteract (Tick)</td>
<td>클라이언트 로컬</td>
<td>프롬프트 표시 여부 판단용</td>
</tr>
<tr>
<td>TryInteract → Server RPC</td>
<td>클라→서버</td>
<td>서버에 검증 요청</td>
</tr>
<tr>
<td>CanInteract (서버 검증)</td>
<td>서버</td>
<td>실제 권위 검증</td>
</tr>
<tr>
<td>OnInteract → UI 오픈</td>
<td>클라이언트</td>
<td>UI는 서버에서 열 수 없음</td>
</tr>
</tbody></table>
<hr>
<h2 id="ui-nspartequipwidget--nspetupgradewidget">UI: NSPartEquipWidget / NSPetUpgradeWidget</h2>
<table>
<thead>
<tr>
<th>함수</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>OpenForInteractor(PC)</code></td>
<td>AddToViewport + 마우스 커서 표시 + GameAndUI 입력 모드</td>
</tr>
<tr>
<td><code>RequestClose()</code></td>
<td>변경사항 있으면 저장 확인 다이얼로그, 없으면 즉시 닫기</td>
</tr>
<tr>
<td><code>CloseWidget()</code></td>
<td>RemoveFromParent + 마우스 숨김 + GameOnly 입력 모드 복구</td>
</tr>
</tbody></table>
<p>닫기(ESC) 처리: <code>NativeOnKeyDown</code>에서 Escape 키 감지 → <code>RequestClose()</code> 호출 → <code>FReply::Handled()</code> 반환으로 이벤트 소비. PIE에서는 ESC가 세션 종료로 먼저 잡히므로 테스트 시 BP 닫기 버튼 사용.</p>
<hr>
<h2 id="파일-목록">파일 목록</h2>
<table>
<thead>
<tr>
<th>파일</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>Interaction/Component/NSInteractionComponent.h/.cpp</code></td>
<td>감지 + RPC + 프롬프트</td>
</tr>
<tr>
<td><code>Interaction/Core/NSInteractable.h</code></td>
<td>인터페이스 정의</td>
</tr>
<tr>
<td><code>Interaction/NPC/NSInteractableNPCBase.h/.cpp</code></td>
<td>NPC 공통 베이스</td>
</tr>
<tr>
<td><code>Interaction/NPC/NSPartsNPC.h/.cpp</code></td>
<td>파츠 NPC</td>
</tr>
<tr>
<td><code>Interaction/NPC/NSPetNPC.h/.cpp</code></td>
<td>펫 NPC</td>
</tr>
<tr>
<td><code>UI/Interaction/NSPartEquipWidget.h/.cpp</code></td>
<td>파츠 장착 UI</td>
</tr>
<tr>
<td><code>UI/Interaction/NSPetUpgradeWidget.h/.cpp</code></td>
<td>펫 강화 UI</td>
</tr>
<tr>
<td><code>UI/Interaction/NSInteractionPromptWidget.h/.cpp</code></td>
<td>머리 위 프롬프트 위젯</td>
</tr>
<tr>
<td><code>Character/Player/NSPlayerCharacterBase.h/.cpp</code></td>
<td>InteractionComp 소유, PossessedBy/OnRep_Controller 훅</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL]]></title>
            <link>https://velog.io/@kyu_/TIL-ude44ash</link>
            <guid>https://velog.io/@kyu_/TIL-ude44ash</guid>
            <pubDate>Fri, 19 Jun 2026 11:02:40 GMT</pubDate>
            <description><![CDATA[<h1 id="오늘의-작업">오늘의 작업</h1>
<p>오늘할일 : 아웃런 NPC구현 및 상호작용시 UI띄우기(임시)</p>
<hr>
<h2 id="상호작용-시스템--설계-탐색">상호작용 시스템 — 설계 탐색</h2>
<p>현재 구조는 <code>NSInteractableActor</code>가 있고, 어떤 위젯이 붙는지·어떤 컨트롤러가 붙는지·콜리전이나 상호작용 컴포넌트도 붙는 구조 (상속해서 사용하면 됨).</p>
<p>그런데 NPC는 추후 자기 방을 돌아다니거나 움직이는 로직이 필요하기 때문에 <code>ACharacter</code>를 상속받아야 해서 <code>NSInteractableActor</code> 상속 방식은 부적절하다고 판단.</p>
<p>대안으로 <strong>컴포넌트에 상호작용 책임을 부여</strong>하는 방향을 검토했다. 다만 상호작용 가능한 모든 액터에 컴포넌트를 붙이면 컴포넌트가 주인이 누구인지(NPC인지 파츠인지)를 알아야 하는 문제가 생긴다. 이를 해결하기 위해 <strong>인터페이스</strong> 도입을 고려 — 컴포넌트는 구체적인 타입은 모르고 인터페이스 여부만 확인하면 된다.</p>
<p>역할 분리 초안:</p>
<ul>
<li><strong>컴포넌트</strong> — 감지, 프롬프트 표시, 신호 발생</li>
<li><strong>인터페이스</strong> — 물어보면 답할 수 있게끔 (함수 제공, PlayerController 감지 등)</li>
</ul>
<hr>
<h2 id="상호작용-시스템--최종-설계-확정">상호작용 시스템 — 최종 설계 확정</h2>
<p>위 방식에서 한 번 더 방향을 바꿨다.</p>
<p>상호작용 가능한 액터 각각에 컴포넌트를 붙이는 것보다, <strong>캐릭터에 상호작용 컴포넌트를 달아서 캐릭터가 판단</strong>하는 구조가 더 낫다고 결론 냈다.</p>
<p>이유: 상호작용 가능한 액터가 두 개 있고 둘의 콜리전 안에 동시에 들어가 있을 때, 어떤 액터와 상호작용할지 중재하는 주체가 캐릭터 컴포넌트여야 처리가 깔끔하다.</p>
<p><strong>확정 구조:</strong></p>
<ul>
<li>상호작용 액터: <code>INSInteractable</code> 인터페이스 상속 + Sphere 콜리전만 보유</li>
<li>상호작용 컴포넌트: 캐릭터에 부착, <code>TickComponent</code>로 가장 가까운 액터를 선택<ul>
<li>TickInterval을 크게 설정해서 갱신 횟수 최소화</li>
</ul>
</li>
<li>동시 범위 진입 시: 캐릭터 위치로부터 <strong>가장 가까운</strong> 액터와 상호작용</li>
<li>프롬프트 위젯: World 좌표에 부착 (레퍼런스 게임과 동일 방식). HUD 방식은 추후 대화창 등에서 활용 예정</li>
</ul>
<hr>
<h2 id="구현한-것">구현한 것</h2>
<ul>
<li><code>INSInteractable</code> 인터페이스 추가</li>
<li><code>NSInteractionComponent</code> — 캐릭터에 부착, 범위 내 액터 감지 + 가까운 대상 선택</li>
<li>기존 <code>NSInteractableActor</code> 리팩터링 — 컴포넌트/콜리전 분리, 인터페이스 상속 구조로 정리</li>
<li>NPC에 컴포넌트+인터페이스 조합 적용 가능한 구조 완성</li>
</ul>
<hr>
<h2 id="공부한-것">공부한 것</h2>
<ul>
<li><code>TArray</code>를 인덱스로 순회하면서 <code>RemoveAt</code>이 필요한 경우 → <strong>뒤에서부터 순회</strong>하면 RemoveAt의 메모리 이동 비용을 줄일 수 있다. 단, 권장 패턴은 아니므로 상황에 따라 판단.</li>
<li>언리얼에서는 이터레이터 호환성 때문에 <code>--i</code> (전위 감소 연산자)를 습관화하는 것이 좋다.</li>
</ul>
<h2 id="알고리즘">알고리즘</h2>
<h3 id="비트-연산자">비트 연산자</h3>
<h4 id="xor">XOR</h4>
<p>XOR는 ^로 정의되어있다.</p>
<h4 id="and">AND</h4>
<p>AND는 &amp;로 정의</p>
<h4 id="or">OR</h4>
<p>OR는 |로 정의</p>
<h4 id="not">NOT</h4>
<p>NOT은 ~로 정의되어있다. (비트반전)</p>
<h4 id="">&lt;&lt;</h4>
<p>좌측 시프트, 비트들을 왼쪽으로 이동 (빈자리는 0으로 채움)</p>
<h4 id="-1">&gt;&gt;</h4>
<p>우측 시프트, 비트들을 오른쪽으로 이동</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 드롭 진입점, 알고리즘, 개념공부]]></title>
            <link>https://velog.io/@kyu_/TIL-%EB%93%9C%EB%A1%AD-%EC%A7%84%EC%9E%85%EC%A0%90-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EA%B0%9C%EB%85%90%EA%B3%B5%EB%B6%80</link>
            <guid>https://velog.io/@kyu_/TIL-%EB%93%9C%EB%A1%AD-%EC%A7%84%EC%9E%85%EC%A0%90-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EA%B0%9C%EB%85%90%EA%B3%B5%EB%B6%80</guid>
            <pubDate>Thu, 18 Jun 2026 12:09:40 GMT</pubDate>
            <description><![CDATA[<h1 id="til">TIL</h1>
<h2 id="1-파츠-드롭-스폰-공용-진입점-추출">1. 파츠 드롭 스폰 공용 진입점 추출</h2>
<h3 id="배경">배경</h3>
<p>기존에 바닥에 파츠를 떨어뜨리는 코드는 <code>UNSPartEquipComponent::SpawnDroppedPart</code>(private) 하나에만 있었다.<br>이 함수는 <strong>플레이어가 파츠를 교체할 때 기존 파츠를 발 아래에 떨어뜨리는 경로 전용</strong>으로 설계되었고, <code>UNSPartEquipComponent</code> 자체도 <code>PlayerState</code>에 부착된 컴포넌트다.</p>
<p>문제는 <strong>몬스터 드롭</strong>이다. 몬스터 사망 상황에서는 플레이어가 주체가 아니므로, <code>ANSDroppedPart</code>를 월드에 스폰하는 로직이 별도의 공용 진입점을 필요로 했다.<br>기존 구조를 그대로 쓰려면 아래처럼 어색한 우회가 필요했다.</p>
<pre><code class="language-text">몬스터 사망
  → 임의 플레이어의 PlayerState 획득
  → PlayerState → PartEquipComponent 접근
  → SpawnDroppedPart() 호출</code></pre>
<h3 id="왜-문제가-되는가">왜 문제가 되는가</h3>
<ol>
<li><p><strong>불필요한 의존성</strong><br>월드에 드롭 오브젝트를 스폰하는 행위는 플레이어나 <code>PlayerState</code>와 직접적인 관련이 없다. 그런데 스폰 로직이 <code>UNSPartEquipComponent</code> 안에 묻혀 있어서, 몬스터 드롭 오케스트레이터(<code>GameMode</code>)가 굳이 <code>PlayerState</code>를 경유해야 하는 구조가 된다.</p>
</li>
<li><p><strong>스폰 규약 중복 위험</strong><br><code>ANSDroppedPart</code>는 <code>SpawnActorDeferred → Initialize → FinishSpawning</code> 순서를 반드시 지켜야 한다.<br>특히 <code>Initialize</code> 전에 <code>FinishSpawning</code>을 호출하면 <code>StoredInstance</code>가 비어서 비주얼과 픽업 로직이 모두 깨진다. 이 규약이 여러 군데 흩어지면 한 곳에서 빠뜨릴 가능성이 커진다.</p>
</li>
<li><p><strong>팩토리 함수 위치가 부자연스러움</strong><br>인스턴스 메서드(비-static)로 두면 이 함수를 호출하기 위해 이미 <code>ANSDroppedPart</code> 인스턴스가 하나 있어야 한다. 하지만 이 함수의 역할 자체가 <strong>새로운 <code>ANSDroppedPart</code>를 생성하는 것</strong>이므로, <code>static</code> 팩토리 함수나 외부 매니저에 두는 편이 더 자연스럽다.</p>
</li>
</ol>
<h3 id="해결-방법">해결 방법</h3>
<p><code>ANSDroppedPart</code>에 <strong>static 팩토리 헬퍼 <code>SpawnInWorld</code></strong>를 추가했다.</p>
<pre><code class="language-cpp">// NSDroppedPart.h
static ANSDroppedPart* SpawnInWorld(
    UWorld* World,
    TSubclassOf&lt;ANSDroppedPart&gt; Class,
    const FNSPartData&amp; Part,
    const FVector&amp; Location);</code></pre>
<h4 id="동작-규칙">동작 규칙</h4>
<ul>
<li><code>Class</code>가 <code>nullptr</code>이면 <code>ANSDroppedPart::StaticClass()</code>로 폴백한다.</li>
<li>내부에서 <code>SpawnActorDeferred → Initialize → FinishSpawning</code> 순서를 완전히 처리한다.</li>
<li>실패 시 <code>nullptr</code>를 반환한다.</li>
</ul>
<h3 id="기존-코드-정리">기존 코드 정리</h3>
<p>기존 <code>UNSPartEquipComponent::SpawnDroppedPart</code>는 이제 공용 헬퍼를 호출하는 얇은 래퍼가 되었다.</p>
<pre><code class="language-cpp">// NSPartEquipComponent.cpp
void UNSPartEquipComponent::SpawnDroppedPart(const FNSPartData&amp; Part, const FVector&amp; Location)
{
    ANSDroppedPart::SpawnInWorld(GetWorld(), DroppedPartClass, Part, Location);
}</code></pre>
<h3 id="변경-효과">변경 효과</h3>
<table>
<thead>
<tr>
<th>경로</th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody><tr>
<td>장착 교체 시 드롭</td>
<td><code>EquipComponent::SpawnDroppedPart</code> 직접 구현</td>
<td>내부에서 <code>SpawnInWorld</code> 호출</td>
</tr>
<tr>
<td>몬스터 드롭</td>
<td>별도 경로 없음</td>
<td><code>ANSDroppedPart::SpawnInWorld</code> 직접 호출</td>
</tr>
<tr>
<td>스폰 규약 보장 위치</td>
<td><code>EquipComponent::SpawnDroppedPart</code> 내부</td>
<td><code>SpawnInWorld</code> 한 곳으로 집중</td>
</tr>
</tbody></table>
<p>핵심은 <strong>드롭 스폰의 책임을 PlayerState 기반 컴포넌트에서 월드 오브젝트 팩토리로 이동시킨 것</strong>이다.<br>이렇게 하면 장착 교체 드롭과 몬스터 드롭이 같은 규약을 공유하면서도, 호출 주체는 서로 독립적으로 유지할 수 있다.</p>
<hr>
<h2 id="2-gamemode-재화-multiplier-에디터-노출">2. GameMode 재화 Multiplier 에디터 노출</h2>
<h3 id="배경-1">배경</h3>
<p>런 종료 시 영구 재화(공통 재화 / 스킬 재화)를 확정할 때, 클리어 여부에 따라 획득량 배율을 적용하고 있었다.</p>
<ul>
<li>클리어: 100% → <code>ClearMultiplier = 1.0f</code></li>
<li>전멸(실패): 50% → <code>FailMultiplier = 0.5f</code></li>
</ul>
<p>기존에는 이 값이 코드에 하드코딩되어 있어, 밸런스 조정 때마다 재컴파일이 필요했다.</p>
<h3 id="변경-내용">변경 내용</h3>
<p><code>ANSRunGameMode</code>에 <code>EditDefaultsOnly</code> 프로퍼티를 추가해 에디터에서 직접 조정할 수 있도록 했다.</p>
<pre><code class="language-cpp">// NSRunGameMode.h
UPROPERTY(EditDefaultsOnly, Category = &quot;Currency&quot;)
float ClearMultiplier = 1.0f;

UPROPERTY(EditDefaultsOnly, Category = &quot;Currency&quot;)
float FailMultiplier = 0.5f;</code></pre>
<h3 id="의미">의미</h3>
<ul>
<li>디테일 패널의 <strong>Currency</strong> 카테고리에서 수치를 수정할 수 있다.</li>
<li>코드 수정과 재컴파일 없이 밸런스 조정이 가능하다.</li>
<li>런 보상 정책을 디자이너 친화적으로 바꾼 셈이다.</li>
</ul>
<hr>
<h2 id="3-다음-작업-메모">3. 다음 작업 메모</h2>
<p>몬스터 드롭 오케스트레이터 자체는 아직 미구현이다.<br>다만 연결 지점은 이미 확보되었다.</p>
<h3 id="현재-확보된-지점">현재 확보된 지점</h3>
<ul>
<li><p><strong>진입점</strong>: <code>ANSRunGameMode::NotifyEnemyKilled_Implementation</code> (<code>NSRunGameMode.cpp:127</code>)  </p>
<ul>
<li><code>HasAuthority()</code> 가드가 있어 서버 전용 실행이 보장된다.</li>
<li><code>ACharacter* DeadEnemy</code>로 사망 위치(<code>GetActorLocation()</code>)를 얻을 수 있다.</li>
</ul>
</li>
<li><p><strong>재화 스폰</strong>: <code>UNSCurrencyDropSubsystem::RegisterDrop(...)</code>  </p>
<ul>
<li>재화 드롭 저장과 관리 로직은 이미 완성되어 있다.</li>
</ul>
</li>
<li><p><strong>파츠 스폰</strong>: <code>ANSDroppedPart::SpawnInWorld(...)</code>  </p>
<ul>
<li>이번 작업으로 공용 스폰 진입점이 준비되었다.</li>
</ul>
</li>
</ul>
<h3 id="아직-남은-것">아직 남은 것</h3>
<ul>
<li><code>DroppedPartClass</code>를 <code>GameMode</code>에서 어떻게 참조할지 결정 필요  <ul>
<li>예정안: <code>EditDefaultsOnly UPROPERTY</code>로 노출</li>
</ul>
</li>
<li>드롭 테이블 설계  <ul>
<li>어떤 재화 / 파츠가 어떤 확률로 드롭되는지</li>
</ul>
</li>
<li>위치 분산 로직  <ul>
<li>예: 원형 오프셋 기반 배치</li>
</ul>
</li>
<li><code>FNSPartData</code> 생성 규칙  <ul>
<li>등급, 종류, 옵션 등 실제 드롭 인스턴스 결정 방식</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-재화-드롭-시스템-정리">4. 재화 드롭 시스템 정리</h2>
<h3 id="unscurrencydropsubsystemregisterdrop"><code>UNSCurrencyDropSubsystem::RegisterDrop</code></h3>
<p>몹이 죽었을 때 실제 드롭 정보를 등록하는 함수다.<br>초기에 호출되는 <code>RemoveExpiredDrops()</code>는 만료된 드롭 데이터를 먼저 정리하는 역할을 한다.</p>
<h3 id="ffastarrayserializer"><code>FFastArraySerializer</code></h3>
<p>일반적인 <code>TArray + UPROPERTY(Replicated)</code> 조합은 배열 원소 하나만 바뀌어도 전체 배열을 직렬화해서 전송할 수 있다.<br>반면 <code>FFastArraySerializer</code>는 <strong>변경분(델타)만 전송</strong>하도록 설계된 언리얼 전용 구조다.</p>
<h4 id="특징">특징</h4>
<ul>
<li>원소 추가 / 변경 시 해당 항목만 전송</li>
<li>원소 삭제 시 삭제 마커만 전송</li>
<li>원소 단위 콜백 제공<ul>
<li><code>PostReplicatedAdd</code></li>
<li><code>PostReplicatedChange</code></li>
<li><code>PreReplicatedRemove</code></li>
</ul>
</li>
</ul>
<p>즉, 드롭처럼 <strong>자주 추가/삭제되고 개별 항목 단위 반응이 중요한 데이터</strong>에 잘 맞는다.</p>
<h3 id="오버랩-감지-흐름">오버랩 감지 흐름</h3>
<pre><code class="language-text">ANSCurrencyPickup (오버랩 감지)
  → Server_CollectCurrency RPC [NSPlayerState.cpp:115]
  → DropSubsystem::TryCollect() [NSCurrencyDropSubsystem.cpp:79]</code></pre>
<p>이 흐름은 <strong>클라이언트가 오버랩을 감지해도 실제 수집 확정은 서버에서 처리한다</strong>는 점이 핵심이다.</p>
<hr>
<h2 id="5-알고리즘-메모">5. 알고리즘 메모</h2>
<pre><code class="language-cpp">void convert(string&amp; m, unordered_map&lt;string, string&gt;&amp; words) {
    for (auto&amp; [key, value] : words) {
        auto it = m.find(key);
        while ((it = m.find(key, it)) != string::npos) {
            m.replace(it, key.length(), value);
            it++;
        }
    }
}</code></pre>
<h3 id="동작-방식">동작 방식</h3>
<ul>
<li><code>find</code>로 문자열 <code>m</code> 안에서 <code>key</code>가 등장하는 위치를 찾는다.</li>
<li>찾은 위치 <code>it</code>를 기준으로 <code>replace</code>를 호출해 <code>value</code>로 치환한다.</li>
<li><code>replace</code>는 한 번에 한 위치만 바꾸므로, <code>while</code> 루프를 돌며 끝까지 반복 적용한다.</li>
</ul>
<h3 id="메모">메모</h3>
<p><code>it++</code>를 하는 이유는 방금 치환한 위치 바로 다음부터 다시 찾기 위해서다.<br>다만 치환 문자열 길이나 중첩 패턴에 따라 의도하지 않은 재탐색 이슈가 생길 수 있으므로, 실제 문제에서는 증가 폭을 어떻게 둘지 주의해서 봐야 한다.</p>
<hr>
<h2 id="6-언리얼-엔진-용어-정리">6. 언리얼 엔진 용어 정리</h2>
<h3 id="오브젝트-uobject">오브젝트 (<code>UObject</code>)</h3>
<p>오브젝트는 언리얼 엔진의 가장 기본적인 클래스 계층의 출발점이다.<br>거의 모든 시스템이 <code>UObject</code>를 직접 상속하거나, 그 기능을 기반으로 동작한다.</p>
<h4 id="uobject가-제공하는-것"><code>UObject</code>가 제공하는 것</h4>
<ul>
<li>가비지 컬렉션 지원</li>
<li>에디터 변수 노출을 위한 메타데이터 시스템</li>
<li>로딩 / 저장 / 직렬화 기반 기능</li>
<li>에셋과 런타임 객체의 공통 기반 구조</li>
</ul>
<p>즉, 언리얼에서 오브젝트는 단순한 C++ 클래스가 아니라 <strong>엔진 레벨 관리 기능이 붙은 반사 가능한 객체</strong>라고 보는 것이 맞다.</p>
<h3 id="fsoftobjectpath-와-tsoftobjectptr"><code>FSoftObjectPath</code> 와 <code>TSoftObjectPtr</code></h3>
<h4 id="fsoftobjectpath"><code>FSoftObjectPath</code></h4>
<p>에셋의 전체 경로를 문자열 형태로 들고 있는 가벼운 구조체다.<br>에디터에서는 마치 <code>UObject*</code>를 고르는 프로퍼티처럼 보이지만, 실제로는 경로 기반 참조다.</p>
<h4 id="tsoftobjectptr"><code>TSoftObjectPtr</code></h4>
<p>기본적으로 <code>FSoftObjectPath</code>를 감싸는 형태이며, 특정 클래스 타입으로 제한할 수 있다.<br>참조 대상이 이미 메모리에 로드되어 있으면 <code>Get()</code>으로 바로 접근할 수 있고, 로드되지 않았다면 소프트 경로를 이용해 로드한 뒤 다시 접근한다.</p>
<h4 id="언제-유용한가">언제 유용한가</h4>
<ul>
<li>아티스트 / 디자이너가 에디터에서 에셋 레퍼런스를 직접 세팅할 때</li>
<li>당장 메모리에 올리지 않고 필요 시점에 로드하고 싶을 때</li>
</ul>
<p>반대로, <strong>조건 기반으로 많은 에셋을 탐색해야 하는 경우</strong>에는 소프트 레퍼런스만으로 해결하기보다 에셋 레지스트리나 오브젝트 라이브러리를 고려하는 편이 좋다.</p>
<h3 id="에셋-레지스트리와-오브젝트-라이브러리">에셋 레지스트리와 오브젝트 라이브러리</h3>
<h4 id="에셋-레지스트리">에셋 레지스트리</h4>
<p>에셋 자체를 로드하지 않고도 메타데이터를 조회할 수 있게 해 주는 시스템이다.<br>콘텐츠 브라우저가 에셋 정보를 보여줄 때도 사용되며, 게임플레이 코드에서도 로드되지 않은 에셋의 정보를 질의할 수 있다.</p>
<p>핵심은 <strong>에셋 본체를 메모리에 올리지 않고도 검색과 분류가 가능하다</strong>는 점이다.</p>
<h4 id="오브젝트-라이브러리-objectlibrary">오브젝트 라이브러리 (<code>ObjectLibrary</code>)</h4>
<p>특정 폴더의 에셋들을 한꺼번에 모아 관리하기 쉽게 만들어 주는 도우미 객체다.<br>로드된 오브젝트 목록이나, 아직 로드되지 않은 에셋의 <code>FAssetData</code> 목록을 한데 모아 다룰 수 있다.</p>
<p>즉, 대량 에셋을 폴더 단위로 관리하거나 한 번에 수집해 다루고 싶을 때 유용하다.</p>
<h3 id="fstreamablemanager-와-비동기-로딩"><code>FStreamableManager</code> 와 비동기 로딩</h3>
<p><code>FStreamableManager</code>는 비동기 에셋 로딩을 담당하는 핵심 도구다.<br>비동기 로드를 요청하면, 로드 완료 시점에 콜백을 받아 후속 처리를 수행할 수 있다.</p>
<h4 id="주의할-점">주의할 점</h4>
<ul>
<li>콜백 안에서 <code>.Get()</code>으로 잠깐 사용하고 끝낼 수는 있다.</li>
<li>이후에도 계속 사용할 객체라면, 실제 객체 포인터를 멤버로 저장해 강한 참조를 유지하는 쪽이 안전하다.</li>
</ul>
<p>즉, 비동기 로딩의 핵심은 <strong>로드 시점 제어</strong>뿐 아니라 <strong>로드 후 객체 수명 관리</strong>까지 포함해서 생각하는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 재화 드랍 테스트]]></title>
            <link>https://velog.io/@kyu_/TIL-%EC%9E%AC%ED%99%94-%EB%93%9C%EB%9E%8D-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@kyu_/TIL-%EC%9E%AC%ED%99%94-%EB%93%9C%EB%9E%8D-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Wed, 17 Jun 2026 11:53:41 GMT</pubDate>
            <description><![CDATA[<h1 id="재화-드랍-테스트">재화 드랍 테스트</h1>
<h2 id="목표">목표</h2>
<p>PIE 멀티 환경에서 재화 드랍 시스템을 빠르게 테스트할 수 있는 콘솔 치트명령 구현</p>
<hr>
<h2 id="문제-1-gamemode의-ufunctionexec가-콘솔에서-실행되지-않음">문제 1. GameMode의 UFUNCTION(Exec)가 콘솔에서 실행되지 않음</h2>
<h3 id="현상">현상</h3>
<p><code>NSRunGameMode</code>에 <code>UFUNCTION(Exec)</code> 함수를 만들고 콘솔(<code>~</code>)에서 호출해도 아무일도 일어나지 않음, 기존 로그만 출력되는 현상</p>
<h3 id="원인">원인</h3>
<p>언리얼 콘솔 Exec 명령의 라우팅체인은</p>
<pre><code>LocalPlayer -&gt; PlayerController -&gt; Pawn -&gt; HUD -&gt; PlayerCameraManager -&gt; PlayerInput -&gt; CheatManager -&gt; GameInstance</code></pre><p>콘솔(~) Exec 명령은 GameMode로 라우팅되지 않는다는 사실, GameMode는 이 체인에 없음 그래서 Debug_SpawnCurrency를 쳐도 함수 자체가 호출되지 않는문제였다.</p>
<h3 id="해결방안">해결방안</h3>
<ol>
<li>APlayerController로 옮기기(깔끔)</li>
<li>CheatManager 만들기 (나중에 사용할 수 있지 않을까?)</li>
</ol>
<p>CheatManager쪽으로 가기로 했다. 추후에 다른 테스트 할때도 사용할 수 있지 않을까해서
그래서 CheatManager에서 Exec함수를 정의, <code>NSPlayerController</code> 생성자에서 <code>CheatClass = UNSCheatManager::StaticClass()</code>로 등록</p>
<hr>
<h2 id="문제-2-클라에서-콘솔-입력시-command-not-recognized-오류">문제 2. 클라에서 콘솔 입력시 &quot;Command not recognized&quot; 오류</h2>
<h3 id="현상-1">현상</h3>
<p>호스트 콘솔에서는 <code>Debug_SpawnCurrency</code>가 정상 실행 되지만, 클라 콘솔에서는 명령 자체를 인식 못함.</p>
<h3 id="원인-1">원인</h3>
<p><code>UCheatManager</code>는 엔진 기본 동작상 서버/스탠드얼론에서만 생성됨 (<code>APlayerController::AddCheats</code>가 <code>NetMode != NM_Client</code> 조건으로 가드됨), 순수 클라 PlayerController에는 CheatManager가 아예 생성되지 않아서 Exec 명령을 인식하지 못했음</p>
<h3 id="해결">해결</h3>
<p><code>NSPlayerController::BeginPlay</code>에서 로컬 컨트롤러인 경우 <code>EnabledCheats()</code>를 명시적으로 호출, 이 함수가 내부적으로 <code>AddCheats(true)</code>를 실행하여 클라에도 CheatManager를 강제 생성</p>
<pre><code class="language-c++">void ANSPlayerController::BeginPlay()
{
    ...
    // 테스트용 임시 코드 - 드롭 테이블 연동 후 삭제
    EnableCheats();
    ...
}</code></pre>
<hr>
<h2 id="문제-3-클라에서-치트를-실행해도-호스트-발밑-재화가-생성되지-않음">문제 3. 클라에서 치트를 실행해도 호스트 발밑 재화가 생성되지 않음</h2>
<h3 id="현상-2">현상</h3>
<p>CheatManager가 클라에서도 인식된 뒤, 클라 콘솔에서 <code>Debug_SpawnCurrency</code>를 실행하면 호스트 캐릭터위치에 재화가 생성되지 않음, 클라 캐릭터 위치에만 생성됨</p>
<h3 id="원인-2">원인</h3>
<p><code>RegisterDrop</code>은 <code>HasServerAuthority()</code>가드가 있어 서버 권한에서만 실행됨. CheatManager는 클라 로컬에서 동작하므로 클라의 <code>GetPlayerControllerIterator()</code>는 로컬 컨트롤러만 보고, 드랍도 서버에 반영되지 않았음</p>
<h3 id="해결-1">해결</h3>
<p>CheatManager에서 드랍 로직을 직접 실행하지 않고, ServerRPC를 통해 반드시 서버 권한에서 실행되도록 설계</p>
<pre><code>클라 콘솔 입력
-&gt; UNSCheatManager::Debug_SpawnCurrency() (클라 로컬)
-&gt; ANSPlayerController::Server_DebugSpawnCurrency() (Server RPC -&gt; 서버로 전송)
-&gt; 서버에서 모든 PlayerController순회 -&gt; RegisterDrop
-&gt; 각 CurrencyReplicationProxy -&gt; Client RPC -&gt; 각 클라에서 LocalPickup 스폰</code></pre><hr>
<h2 id="문제-4-공통·스킬재화를-드랍했는데-메시가-안-보임">문제 4. 공통·스킬재화를 드랍했는데 메시가 안 보임</h2>
<h3 id="현상-3">현상</h3>
<p><code>Debug_SpawnCurrency</code> 하나로 셋 다 드랍했더니 임시재화만 메시가 보이고, 공통/스킬은 줍기 로그는 떠도 메시가 안 보임</p>
<h3 id="원인-3">원인</h3>
<p>치트가 셋 다 <code>Grade1</code>로 등록했는데, <code>UNSCurrencyVisualData</code>의 행 조회(<code>FindVisual</code>)는 <strong><code>CurrencyType</code> + <code>Grade</code> 둘 다</strong> 일치해야 함. 설계상 <strong>임시재화만 등급(Grade1~)을 쓰고 공통/스킬은 <code>Grade::None</code></strong> 이라, <code>FindVisual(Common, Grade1)</code>은 매칭 행이 없어 <code>nullptr</code> → 메시 미설정</p>
<h3 id="해결-2">해결</h3>
<p>치트를 타입별로 분리(<code>Debug_SpawnTemp</code> / <code>Debug_SpawnCommon</code> / <code>Debug_SpawnSkill</code>)하고, 공통/스킬은 <code>ENSCurrencyGrade::None</code>으로 등록. → VisualData 조회 키와 일치</p>
<hr>
<h2 id="문제-5-인런-중-획득한-영구재화도-임시재화처럼-hud에-실시간-표시로-변경">문제 5. 인런 중 획득한 영구재화도 임시재화처럼 HUD에 실시간 표시로 변경</h2>
<h3 id="배경">배경</h3>
<p>임시재화는 복제되는 <code>Wallet</code>에 들어가 HUD에 실시간 반영되지만, 공통/스킬재화는 서버 전용 <code>PendingPermanent</code> TMap에만 쌓여 클라가 볼 수 없음</p>
<h3 id="검토한-두-방법">검토한 두 방법</h3>
<ul>
<li><strong>방법 A (Wallet 미러):</strong> <code>AddRunPermanent</code>에서 Pending 누적 + <code>AddToWallet</code>로 복제 경로에도 미러. 진실 원본은 Pending 유지. 커밋 로직 무변경</li>
<li><strong>방법 B (Wallet 일원화):</strong> Pending TMap 제거, 전부 Wallet에 저장. 커밋이 Wallet을 읽고 비움</li>
</ul>
<h3 id="결정-→-방법-a">결정 → 방법 A</h3>
<p><strong>커밋 = 런 종료(게임오버) 시점</strong>이라, A의 단점이던 &quot;커밋 후 Wallet 정리&quot;가 무의미해짐(어차피 런 경계에서 리셋/파괴). Pending·Wallet은 같은 함수에서 같은 양을 더하므로 드리프트 불가, 진실 원본(Pending)을 안 건드리는 게 가장 좋아보였음</p>
<hr>
<h2 id="문제6-아웃런-진입-시-지갑초기화">문제6. 아웃런 진입 시 지갑초기화</h2>
<h3 id="배경-1">배경</h3>
<p>Seamless Travel구조라서 PlayerState가 무조건 초기화된다는 보장이없어서 지갑을 추가적으로 초기화해주는 함수를 만들었다.</p>
<hr>
<h2 id="최종-구조">최종 구조</h2>
<pre><code>[호스트/클라 콘솔] Debug_SpawnCurrency
  → UNSCheatManager::Debug_SpawnCurrency()
  → ANSPlayerController::Server_DebugSpawnCurrency()  ← Server RPC
  → UNSCurrencyDropSubsystem::RegisterDrop() × N명  (서버)
  → ANSCurrencyReplicationProxy::SendSpawnEvent() × N명
  → Client_SpawnCurrency() × N명  ← Client RPC
  → ANSLocalCurrencyPickup 각자 로컬 스폰</code></pre><p><strong>관련 파일:</strong></p>
<table>
<thead>
<tr>
<th>파일</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>Core/Cheat/NSCheatManager.h/.cpp</code></td>
<td>Exec 치트 정의</td>
</tr>
<tr>
<td><code>Core/PlayerController/NSPlayerController.h/.cpp</code></td>
<td>CheatClass 등록, Server RPC 구현, EnableCheats</td>
</tr>
<tr>
<td><code>System/Subsystem/NSCurrencyDropSubsystem.cpp</code></td>
<td>RegisterDrop (서버 권한 전용)</td>
</tr>
<tr>
<td><code>Progression/Currency/NSCurrencyReplicationProxy.cpp</code></td>
<td>Client RPC로 각 클라에 이벤트 전달</td>
</tr>
<tr>
<td><code>Progression/Currency/NSLocalCurrencyPickup.cpp</code></td>
<td>클라 로컬 픽업 액터</td>
</tr>
</tbody></table>
<h2 id="삭제-예정-드롭-테이블-연동-후">삭제 예정 (드롭 테이블 연동 후)</h2>
<ul>
<li><code>Core/Cheat/NSCheatManager.h/.cpp</code> — 전체</li>
<li><code>NSPlayerController</code> — <code>Server_DebugSpawnCurrency</code> 선언/구현, <code>EnableCheats()</code> 호출, 관련 인클루드</li>
<li><code>NSPlayerController::BeginPlay</code> — <code>EnableCheats()</code> 한 줄</li>
</ul>
<hr>
<h2 id="변경-사항">변경 사항</h2>
<ul>
<li>재화(Currency) 시스템 구현 (서버 레지스트리 + 클라 로컬 비주얼)<ul>
<li><code>UNSCurrencyDropSubsystem</code>: 서버 전용 드랍 레지스트리</li>
<li><code>ANSCurrencyReplicationProxy</code>: 플레이어당 1개, owner-only 복제로 Wallet 변경 전달</li>
<li><code>ANSLocalCurrencyPickup</code>: 클라 로컬 비주얼 액터 (서버 비존재)</li>
<li><code>UNSCurrencyWalletComponent</code>: FastArraySerializer 기반 지갑 (키: FGameplayTag)</li>
<li><code>PlayerState</code> ServerRPC: 오버랩 시 서버 Wallet 적립</li>
</ul>
</li>
<li>영구 재화 적립 구조: 런 중 PendingBucket 누적 → 런 종료 시
<code>CommitRunPermanent(Multiplier)</code> (클리어 1.0 / 전멸 0.5)</li>
<li><code>ProgressComponent</code>에 영구 재화 저장 및 HUD 연동</li>
<li><code>GameMode</code>에 Proxy 세팅 연결</li>
<li>디버그용 <code>UNSCheatManager</code> 추가 (재화 강제 지급 콘솔 커맨드)</li>
</ul>
<hr>
<h2 id="테스트-방식">테스트 방식</h2>
<ol>
<li>NSCurrencyVisualData를 상속받는 DA생성</li>
<li>DA 세팅<img width="1978" height="803" alt="스크린샷 2026-06-17 161219" src="https://github.com/user-attachments/assets/31894ff5-f552-4b45-8912-d639af1ddeb3" /></li>
</ol>
<ul>
<li>Currency.Common/ Temp / Skill 존재, Grade는 Temp만 1~3등급으로 지정해주면 됨</li>
<li>Scale은 크기</li>
</ul>
<ol start="3">
<li>NSCurrencyReplicationProxy를 상속받는 BP생성</li>
<li>Currency &gt; Visual Data에 만든 DA 지정<img width="2006" height="245" alt="스크린샷 2026-06-17 161128" src="https://github.com/user-attachments/assets/a164dbe5-171c-43b3-bd0c-ecbd538e1b06" /></li>
<li>Debug 함수 실행<img width="304" height="142" alt="스크린샷 2026-06-17 161600" src="https://github.com/user-attachments/assets/b3482ba9-2697-4fd2-bf42-dc89be671c58" />
</li>
</ol>
<ul>
<li>SpawnTemp : 임시 재화 생성</li>
<li>SpawnSkill : 스킬 재화 생성</li>
<li>SpawnCommon : 공통 재화 생성</li>
<li>Spawn함수들은 각플레이어 앞에 재화 생성</li>
<li>CommitPermanent : 영구재화(스킬/공통)를 아웃런에 적립 (실제로는 런 종료시에 호출, 클리어/전멸)</li>
</ul>
<hr>
<h2 id="참고-사항">참고 사항</h2>
<ul>
<li>투사체 시스템(<code>ProjectileManagerComponent</code> + <code>ReplicationProxy</code> + <code>ProjectileVisual</code>) 패턴을 그대로 미러링한 구조</li>
<li>드랍 확률/수치 밸런스(<code>UNSCurrencyDropTable</code>)는 별도 담당자 작업 예정</li>
<li>런 종료 commit 훅(<code>OpenRunEndVote</code> 경로)은 아직 미연결 — 허브 복귀 확정 시점에 연결 필요</li>
</ul>
<h2 id="추후-구현-사항">추후 구현 사항</h2>
<ul>
<li>드롭 테이블 구현 후 재화 스폰 위치 구현 필요</li>
<li>파츠 쪽도 똑같이 몬스터 사망에서 구현 필요</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 재화 시스템 정리 및 테스트 준비]]></title>
            <link>https://velog.io/@kyu_/TIL-%EC%9E%AC%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%A0%95%EB%A6%AC-%EB%B0%8F-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%80%EB%B9%84</link>
            <guid>https://velog.io/@kyu_/TIL-%EC%9E%AC%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%A0%95%EB%A6%AC-%EB%B0%8F-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%80%EB%B9%84</guid>
            <pubDate>Tue, 16 Jun 2026 11:46:15 GMT</pubDate>
            <description><![CDATA[<h1 id="재화-시스템-정리">재화 시스템 정리</h1>
<h2 id="1-시스템-개요">1. 시스템 개요</h2>
<p>몬스터가 죽으면 재화가 드랍되고, 각 플레이어가 독립적으로 줍는 구조.
A가 줘도 B 화면엔 그대로 남는다. 서버 복제 액터가 0개라 스폰 비용이 낮다.</p>
<p>투사체 시스템(<code>NSProjectileManagerComponent</code> + <code>NSProjectileReplicationProxy</code> + <code>NSProjectileVisual</code>)과
동일한 패턴을 그대로 미러링했다. 차이가 있다면 WorldSubsystem을 활용했다.</p>
<hr>
<h2 id="2-구성-요소">2. 구성 요소</h2>
<table>
<thead>
<tr>
<th>클래스</th>
<th>위치</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>UNSCurrencyDropSubsystem</code></td>
<td>WorldSubsystem (서버)</td>
<td>드랍 레지스트리. DropId 발급, 픽업 검증</td>
</tr>
<tr>
<td><code>ANSCurrencyReplicationProxy</code></td>
<td>Actor, Owner=PlayerController</td>
<td>서버→클라 이벤트 전달 전용 (Client RPC)</td>
</tr>
<tr>
<td><code>ANSLocalCurrencyPickup</code></td>
<td>Actor, bReplicates=false</td>
<td>클라 로컬 비주얼 + 충돌 감지</td>
</tr>
<tr>
<td><code>UNSCurrencyComponent</code></td>
<td>PlayerState 컴포넌트</td>
<td>임시재화 지갑(Wallet) + 런 버킷</td>
</tr>
<tr>
<td><code>UNSPlayerProgressComponent</code></td>
<td>PlayerState 컴포넌트</td>
<td>영구재화 SoT, 세이브 대상</td>
</tr>
<tr>
<td><code>UNSCurrencyVisualData</code></td>
<td>DataAsset</td>
<td>(Type, Grade) → Mesh 매핑</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-동작-흐름">3. 동작 흐름</h2>
<h3 id="드랍-생성">드랍 생성</h3>
<pre><code>몬스터 사망
→ NSRunGameMode::NotifyEnemyKilled (서버)
→ UNSCurrencyDropSubsystem::RegisterDrop(Type, Grade, Amount, Location, Duration)
  → DropId 발급, ActiveDrops에 FNSCurrencyDropEntry 등록
  → 등록된 모든 프록시에 SendSpawnEvent(FNSCurrencySpawnEvent)
    → 각 ANSCurrencyReplicationProxy::Client_SpawnCurrency (Client RPC)
      → 클라: ANSLocalCurrencyPickup 스폰 (Deferred + Initialize)
        → 비동기 메시 로드 (UNSCurrencyVisualData 조회)
        → 만료 타이머 시작 (Duration초 후 Destroy)</code></pre><h3 id="픽업">픽업</h3>
<pre><code>로컬 폰이 픽업 액터에 오버랩
→ SetActorHiddenInGame(true) — 즉각 UX (클라)
→ ANSPlayerState::Server_CollectCurrency(DropId) — 서버 RPC
  → UNSCurrencyDropSubsystem::TryCollect(DropId, PlayerState)
    검증 4단계:
    1. DropId 존재 여부
    2. 만료 시간 초과 여부
    3. 이미 이 플레이어가 주운 적 있는지 (CollectedPlayer)
    4. 서버 기준 거리 (300cm²)
    → 통과 시:
      - Currency.Temp → CurrencyComponent::AddTemp → Wallet 가산
      - Currency.Common/Skill → CurrencyComponent::AddRunPermanent → PendingPermanent 버킷
      - CollectedPlayer.Add
      - SendRemoveEvent(DropId) → 해당 클라 픽업 액터 Destroy
    → 실패 시:
      - SendRestoreEvent(DropId) → 픽업 액터 다시 보이게 복원</code></pre><h3 id="런-종료-영구재화-확정">런 종료 (영구재화 확정)</h3>
<pre><code>NSRunGameMode::OnResultDisplayFinished
→ 각 PlayerState::CurrencyComponent::CommitRunPermanent(Multiplier)
  - 클리어: Multiplier = 1.0
  - 전멸: Multiplier = 0.5
  → PendingPermanent × Multiplier
  → Currency.Common → ProgressComponent::AddCommonCurrency
  → Currency.Skill → ProgressComponent::AddJobCurrency
  → PendingPermanent 비움
→ SaveAllPlayersProgress()
  → ProgressComponent::BuildPayload → Client_SaveProgress RPC
  → 클라 로컬 SaveGame 저장</code></pre><hr>
<h2 id="4-재화-종류별-저장-구조">4. 재화 종류별 저장 구조</h2>
<table>
<thead>
<tr>
<th>재화</th>
<th>런 중 보관</th>
<th>런 종료 후</th>
<th>저장 위치</th>
</tr>
</thead>
<tbody><tr>
<td>Currency.Temp</td>
<td>Wallet (FastArray, 복제)</td>
<td>소멸</td>
<td>저장 안 함</td>
</tr>
<tr>
<td>Currency.Common</td>
<td>PendingPermanent 버킷</td>
<td>ProgressComponent.CommonCurrency</td>
<td>UNSPermanentSaveGame (계정)</td>
</tr>
<tr>
<td>Currency.Skill</td>
<td>PendingPermanent 버킷</td>
<td>ProgressComponent.JobCurrency</td>
<td>FNSCharacterSaveData (캐릭터)</td>
</tr>
</tbody></table>
<p><strong>용어 정리</strong></p>
<ul>
<li><strong>버킷(PendingPermanent)</strong>: 런 중 영구재화를 임시로 쌓아두는 서버 전용 TMap. 런 종료 시 배율 적용 후 ProgressComponent로 이동하고 비워진다.</li>
<li><strong>지갑(Wallet)</strong>: 임시재화 전용. FastArray로 복제돼 HUD에 실시간 표시. 저장 안 됨.</li>
<li><strong>영구 지갑</strong>: ProgressComponent. 세이브 대상. Wallet이 아님.</li>
</ul>
<hr>
<h2 id="5-프록시-생명주기">5. 프록시 생명주기</h2>
<p><code>ANSRunGameMode</code>가 관리. 투사체 프록시와 나란히 처리됨.</p>
<table>
<thead>
<tr>
<th>이벤트</th>
<th>동작</th>
</tr>
</thead>
<tbody><tr>
<td>BeginPlay</td>
<td>이미 접속한 PC 전원 EnsureCurrencyProxy</td>
</tr>
<tr>
<td>PostLogin</td>
<td>새 PC → EnsureCurrencyProxy</td>
</tr>
<tr>
<td>HandleSeamlessTravelPlayer</td>
<td>레벨 이동 후 재등록</td>
</tr>
<tr>
<td>Logout</td>
<td>DestroyCurrencyProxy → UnregisterProxy → Destroy</td>
</tr>
</tbody></table>
<p>프록시는 <code>Owner = PlayerController</code>, <code>bOnlyRelevantToOwner = true</code>라 해당 클라에만 복제됨.
서버가 <code>SendSpawnEvent</code>를 호출하면 그 PC의 클라에만 Client RPC가 날아감.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 재화시스템, 소프트레퍼런스, 데이터에셋, 테이블 관련 고민]]></title>
            <link>https://velog.io/@kyu_/TIL-%EC%9E%AC%ED%99%94%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%86%8C%ED%94%84%ED%8A%B8%EB%A0%88%ED%8D%BC%EB%9F%B0%EC%8A%A4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%97%90%EC%85%8B-%ED%85%8C%EC%9D%B4%EB%B8%94-%EA%B4%80%EB%A0%A8-%EA%B3%A0%EB%AF%BC</link>
            <guid>https://velog.io/@kyu_/TIL-%EC%9E%AC%ED%99%94%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%86%8C%ED%94%84%ED%8A%B8%EB%A0%88%ED%8D%BC%EB%9F%B0%EC%8A%A4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%97%90%EC%85%8B-%ED%85%8C%EC%9D%B4%EB%B8%94-%EA%B4%80%EB%A0%A8-%EA%B3%A0%EB%AF%BC</guid>
            <pubDate>Mon, 15 Jun 2026 12:20:27 GMT</pubDate>
            <description><![CDATA[<h1 id="til-today-i-learned">TIL (Today I Learned)</h1>
<hr>
<h2 id="재화-시스템">재화 시스템</h2>
<p>ANSCurrencyReplicationProxy의 역할이 정확하게 뭐야? 존재는 서버에서 하는건가? 아니면 AActor상속을 받았으니까 복제용인건가?? 얘를 통해서 픽업액터, 재화컴포넌트 이런게 어떤식으로 소통하는거지?</p>
<p>ANSCurrencyReplicationProxy의 역할은 서버에 존재하지만 서버가 특정 한명의 클라이언트에게 명령을 전달하기 위한 통로역할 그래서 Proxy라는 대리자 명칭을 씀
또, Client RPC를 쏘기 위해서 AActor를 사용함, 이 액터는 복제되지만 데이터(프로퍼티)를 복제하려는게 아니고 Client RPC를 쏠 수 있는 자격을 얻기 위해 복제 액터, SceneRoot만 있고 복제 프로퍼티가 하나도 없는게 그 이유</p>
<p>서버에 플레이어 수만큼 생성하고 NSRunGameMode의 PostLogin/Logout에서 생성/소멸 됨
각 클라에는 자기 것 1개만 복제되어 존재, 호스트도 owner 클라 경로로 동일하게 처리</p>
<p>DropSubsystem: 몹 죽음 -&gt; DropId 발급 -&gt; 레지스트리 등록 -&gt; 플레이어의 프록시를 찾아서 ClientRPC 발사(네트워크 경유), 프록시가 드롭 아이디별 픽업을 직접 관리한다.</p>
<hr>
<h3 id="fsoftobjectpath-tsoftobjectptr-tsoftclassptr">FSoftObjectPath, TSoftObjectPtr, TSoftClassPtr</h3>
<h4 id="핵심-개념-하드-참조-vs-소프트-참조">핵심 개념: 하드 참조 vs 소프트 참조</h4>
<table>
<thead>
<tr>
<th>구분</th>
<th>하드 참조 (<code>UObject*</code>, <code>TObjectPtr</code>)</th>
<th>소프트 참조 (<code>TSoftObjectPtr</code>, <code>TSoftClassPtr</code>)</th>
</tr>
</thead>
<tbody><tr>
<td>로드 시점</td>
<td>오브젝트가 생성될 때 <strong>자동으로 같이 로드</strong></td>
<td>명시적으로 로드하기 전까지 <strong>메모리에 없음</strong></td>
</tr>
<tr>
<td>메모리</td>
<td>로드 즉시 점유</td>
<td>경로 문자열만 보관</td>
</tr>
<tr>
<td>사용 시점</td>
<td>항상 유효해야 하는 참조</td>
<td>필요할 때 비동기 로드할 에셋</td>
</tr>
<tr>
<td>위험</td>
<td>순환 의존, 메모리 낭비</td>
<td><code>.Get()</code>이 null일 수 있음 (로드 전)</td>
</tr>
</tbody></table>
<p><strong>중요:</strong> UPROPERTY에 하드 참조를 걸면 그 클래스를 로드할 때 참조된 에셋 전체가 딸려 로드된다. DataAsset 하나가 맵 전체 에셋을 끌고 오는 참사의 원인.</p>
<hr>
<h4 id="fsoftobjectpath">FSoftObjectPath</h4>
<pre><code class="language-cpp">FSoftObjectPath Path = TEXT(&quot;/Game/Assets/SK_Mesh.SK_Mesh&quot;);</code></pre>
<ul>
<li><strong>순수 경로 문자열.</strong> 타입 정보 없음</li>
<li>직렬화, UI 표시, 에셋 레지스트리 조회 등 &quot;경로를 다루는&quot; 로우레벨 작업에 사용</li>
<li><code>RequestAsyncLoad</code>에 직접 넣을 수 있음</li>
<li>Blueprint 노출보다는 C++ 내부 로직에서 경로 조작 시 사용</li>
</ul>
<h4 id="tsoftobjectptrt">TSoftObjectPtr&lt;T&gt;</h4>
<pre><code class="language-cpp">TSoftObjectPtr&lt;UStaticMesh&gt; Mesh;</code></pre>
<ul>
<li><strong>타입이 있는 소프트 참조.</strong> <code>FSoftObjectPath</code>를 감싼 래퍼</li>
<li>에디터에서 드래그&amp;드랍으로 에셋 지정 가능 (타입 필터링 지원)</li>
<li><code>.Get()</code> → 이미 로드되어 있으면 포인터 반환, 없으면 <code>nullptr</code></li>
<li><code>.IsValid()</code> → 로드되어 있고 유효한지</li>
<li><code>.IsNull()</code> → 경로 자체가 비어 있는지</li>
<li>UPROPERTY에 걸어도 참조한 에셋을 자동 로드하지 않음 → <strong>메모리 안전</strong></li>
<li>사용 전 <code>RequestAsyncLoad</code> 또는 <code>LoadSynchronous</code> 필요</li>
</ul>
<pre><code class="language-cpp">// 비동기 로드 (권장)
StreamableManager.RequestAsyncLoad(Mesh.ToSoftObjectPath(), [this]()
{
    UStaticMesh* Loaded = Mesh.Get(); // 이제 유효
});

// 동기 로드 (게임스레드 블로킹 — 금지 원칙)
UStaticMesh* Loaded = Mesh.LoadSynchronous();</code></pre>
<h4 id="tsoftclassptrt">TSoftClassPtr&lt;T&gt;</h4>
<pre><code class="language-cpp">TSoftClassPtr&lt;UGameplayEffect&gt; EffectClass;</code></pre>
<ul>
<li><strong>클래스(UClass) 전용 소프트 참조.</strong> <code>TSoftObjectPtr&lt;UClass&gt;</code>와 개념적으로 동일하지만 타입 안전성 강화</li>
<li><code>TSubclassOf&lt;T&gt;</code> (하드)의 소프트 버전</li>
<li><code>.Get()</code> → <code>TSubclassOf&lt;T&gt;</code> 반환 (로드된 경우)</li>
<li>GE/GA 같은 Blueprint 파생 클래스를 DataAsset에서 소프트 참조로 보관할 때 표준 패턴</li>
</ul>
<pre><code class="language-cpp">// 패턴: DataAsset에 선언 → 필요할 때 비동기 로드 → SpawnActor/ApplyGE
TSoftClassPtr&lt;UGameplayEffect&gt; EffectClass;

// 로드 후 사용
TSubclassOf&lt;UGameplayEffect&gt; LoadedClass = EffectClass.Get();
if (LoadedClass)
{
    ASC-&gt;ApplyGameplayEffectToSelf(LoadedClass-&gt;GetDefaultObject&lt;UGameplayEffect&gt;(), 1.f, EffectContext);
}</code></pre>
<hr>
<h4 id="정리-언제-어떤-걸-쓰나">정리: 언제 어떤 걸 쓰나</h4>
<table>
<thead>
<tr>
<th>상황</th>
<th>타입</th>
</tr>
</thead>
<tbody><tr>
<td>경로를 문자열로 다뤄야 할 때 (레지스트리, 직렬화)</td>
<td><code>FSoftObjectPath</code></td>
</tr>
<tr>
<td>DataAsset에 에셋(메시, 텍스처, 사운드) 참조</td>
<td><code>TSoftObjectPtr&lt;T&gt;</code></td>
</tr>
<tr>
<td>DataAsset에 클래스(GE, GA, Widget) 참조</td>
<td><code>TSoftClassPtr&lt;T&gt;</code></td>
</tr>
<tr>
<td>반드시 항상 메모리에 있어야 하는 참조</td>
<td><code>TObjectPtr&lt;T&gt;</code> (하드)</td>
</tr>
</tbody></table>
<hr>
<h3 id="datatable-vs-dataasset">DataTable vs DataAsset</h3>
<h4 id="udatatable">UDataTable</h4>
<ul>
<li><strong>행(Row) 기반 데이터.</strong> 엑셀/CSV로 일괄 편집 가능</li>
<li>스키마: <code>FTableRowBase</code>를 상속한 USTRUCT</li>
<li>용도: <strong>밸런스 수치, 텍스트 데이터, 반복 패턴</strong> (몬스터 스탯, 드랍률, 보상 테이블)</li>
<li>편집: 에디터 내 테이블 뷰 or CSV 임포트</li>
<li>조회: <code>FindRow&lt;FMyRow&gt;(FName, Context)</code></li>
<li>특징<ul>
<li>데이터가 많아져도 구조가 단순하게 유지됨</li>
<li>기획자가 코드 없이 CSV로 수치 조정 가능</li>
<li>에셋 참조(메시, 클래스 포인터)를 담는 건 가능하지만 어색함</li>
</ul>
</li>
</ul>
<pre><code class="language-cpp">USTRUCT(BlueprintType)
struct FNSEnemyDropRow : public FTableRowBase
{
    GENERATED_BODY()

    UPROPERTY(EditDefaultsOnly)
    float DropChance = 0.3f;

    UPROPERTY(EditDefaultsOnly)
    int32 MinAmount = 10;

    UPROPERTY(EditDefaultsOnly)
    int32 MaxAmount = 50;
};

// 사용
if (const FNSEnemyDropRow* Row = DropTable-&gt;FindRow&lt;FNSEnemyDropRow&gt;(EnemyId, TEXT(&quot;&quot;)))
{
    // Row-&gt;DropChance, Row-&gt;MinAmount ...
}</code></pre>
<h4 id="udataasset--uprimarydataasset">UDataAsset / UPrimaryDataAsset</h4>
<ul>
<li><strong>단일 오브젝트 구조.</strong> 한 에셋 = 하나의 개념적 단위</li>
<li>용도: <strong>구조적 참조, 설정 묶음</strong> (캐릭터 정의, 파츠 정의, 능력치 셋)</li>
<li>특징<ul>
<li>에셋 번들(<code>AssetBundles</code>) 태그로 그룹 로드 제어 가능</li>
<li><code>UPrimaryDataAsset</code>은 에셋 매니저로 ID 기반 조회 가능</li>
<li>메시, 클래스 포인터, 복잡한 중첩 구조체를 담기에 적합</li>
<li>소프트 참조(<code>TSoftObjectPtr</code>)와 함께 쓰면 온디맨드 로드 가능</li>
</ul>
</li>
</ul>
<pre><code class="language-cpp">UCLASS()
class UNSPartDefinition : public UPrimaryDataAsset
{
    UPROPERTY(EditDefaultsOnly)
    TSoftClassPtr&lt;UGameplayEffect&gt; EffectClass;  // 소프트 참조

    UPROPERTY(EditDefaultsOnly)
    TSoftObjectPtr&lt;USkeletalMesh&gt; PartMesh;       // 소프트 참조
};</code></pre>
<h4 id="역할-분리-원칙">역할 분리 원칙</h4>
<pre><code>DataTable  = &quot;이 적이 얼마를 드랍하나?&quot; (수치/밸런스)
DataAsset  = &quot;이 파츠가 어떤 GE 클래스를 쓰나?&quot; (구조/참조)</code></pre><ul>
<li>DataAsset에 float/int 수치가 늘어나기 시작하면 → DataTable로 분리 검토</li>
<li>DataTable에 메시/클래스 포인터가 많아지면 → DataAsset으로 분리 검토</li>
</ul>
<hr>
<h3 id="소프트-참조-비동기-로드-패턴-ue5">소프트 참조 비동기 로드 패턴 (UE5)</h3>
<h4 id="fstreamablemanager--uassetmanager">FStreamableManager / UAssetManager</h4>
<pre><code class="language-cpp">// 단일 에셋
FStreamableManager&amp; Manager = UAssetManager::GetStreamableManager();
Manager.RequestAsyncLoad(
    SoftPtr.ToSoftObjectPath(),
    FStreamableDelegate::CreateUObject(this, &amp;UMyClass::OnLoaded)
);

// 여러 에셋 묶음
TArray&lt;FSoftObjectPath&gt; Paths = { MeshA.ToSoftObjectPath(), MeshB.ToSoftObjectPath() };
Manager.RequestAsyncLoad(Paths, FStreamableDelegate::CreateLambda([this]()
{
    // 모두 로드 완료
}));</code></pre>
<h4 id="로드-핸들-관리">로드 핸들 관리</h4>
<pre><code class="language-cpp">TSharedPtr&lt;FStreamableHandle&gt; LoadHandle;

// 저장해두면 중간에 취소 가능
LoadHandle = Manager.RequestAsyncLoad(Path, Callback);

// EndPlay 등에서 취소
if (LoadHandle.IsValid())
{
    LoadHandle-&gt;CancelHandle();
    LoadHandle.Reset();
}</code></pre>
<h4 id="get의-null-체크-패턴">.Get()의 null 체크 패턴</h4>
<pre><code class="language-cpp">// 잘못된 패턴: 로드 여부 무시
UStaticMesh* Mesh = SoftMesh.Get(); // null일 수 있음
SetStaticMesh(Mesh);                // 크래시 위험

// 올바른 패턴: 로드 확인 후 사용
if (UStaticMesh* Mesh = SoftMesh.Get())
{
    SetStaticMesh(Mesh);
}
else
{
    // 비동기 로드 후 재진입
    LoadHandle = Manager.RequestAsyncLoad(SoftMesh.ToSoftObjectPath(), [this]()
    {
        SetStaticMesh(SoftMesh.Get());
    });
}</code></pre>
<hr>
<h3 id="neosanctum-적용-사례">NeoSanctum 적용 사례</h3>
<table>
<thead>
<tr>
<th>시스템</th>
<th>에셋 타입</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>UNSPartDefinition</code></td>
<td>PrimaryDataAsset</td>
<td>GE 클래스/메시 참조 = 구조 단위</td>
</tr>
<tr>
<td><code>UNSCurrencyVisualData</code></td>
<td>PrimaryDataAsset</td>
<td>타입→메시 매핑 = 소수의 참조 묶음</td>
</tr>
<tr>
<td>몬스터 드랍 수치</td>
<td>DataTable (예정)</td>
<td>드랍률/수량 = 밸런스 수치</td>
</tr>
<tr>
<td>클리어/전멸 배율</td>
<td>DataTable 또는 Config (예정)</td>
<td>런 단위 정산 수치</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 파츠 시스템 테스트, 최적화]]></title>
            <link>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Fri, 12 Jun 2026 12:05:54 GMT</pubDate>
            <description><![CDATA[<h1 id="til-파츠-시스템-테스트-최적화">TIL 파츠 시스템 테스트, 최적화</h1>
<hr>
<h2 id="파츠-시스템-pie-테스트-통합">파츠 시스템 PIE 테스트 통합</h2>
<h3 id="무엇을-했나">무엇을 했나</h3>
<p>파츠 시스템(드랍 → 줍기 → 장착)의 전체 흐름을 PIE에서 실제로 테스트하기 위한 임시 코드를 추가했다. 몬스터 드랍 연동과 상호작용 시스템이 아직 없어서, 두 군데에 우회 경로를 만들었다.</p>
<ol>
<li><p><strong><code>ANSDroppedPart</code> — DebugDefinition 자동 Initialize</strong></p>
<ul>
<li>레벨에 배치한 드랍 파츠 액터의 디테일 패널에서 <code>DebugDefinition</code> / <code>DebugRarity</code> / <code>DebugValue</code>를 지정하면 <code>BeginPlay</code>에서 자동으로 <code>Initialize</code>를 호출하도록 했다.</li>
<li>원래 이 코드는 이전 세션에서 한 번 추가했다가 &quot;테스트 코드 삭제&quot; 커밋(<code>a3054f42</code>)으로 지웠는데, PIE 흐름을 확인하려면 다시 필요해서 재복원했다.</li>
</ul>
</li>
<li><p><strong><code>UNSPartEquipComponent</code> — <code>ServerRequestPickup</code> RPC 추가</strong></p>
<ul>
<li>H키로 픽업 테스트를 하려는데 <code>TryPickup</code>이 서버 전용 함수라 클라이언트 BP에서 직접 호출이 안 됐다.</li>
<li>클라 → 서버로 넘겨주는 Server RPC <code>ServerRequestPickup(ANSDroppedPart*)</code>를 임시로 추가하고, <code>BP_NS_PlayerCharacter</code>에서 H키 이벤트에 바인딩했다.</li>
</ul>
</li>
<li><p><strong><code>NSPlayerCharacterBase</code> — <code>if문 {} 누락</code> 핫픽스</strong></p>
<ul>
<li><code>PossessedBy</code>에서 <code>HasAuthority()</code> 블록에 중괄호가 없어 다음 줄 코드가 항상 실행되는 버그를 발견하고 수정했다. (커밋 <code>4d370a6d</code>)</li>
</ul>
</li>
</ol>
<hr>
<h3 id="어떤-어려움이-있었나">어떤 어려움이 있었나</h3>
<p><strong>1. TryPickup은 서버 전용인데 BP는 클라에서 실행된다</strong></p>
<p>제일 막혔던 부분. <code>ANSDroppedPart::TryPickup</code>은 <code>HasAuthority()</code> 가드가 있어서 클라이언트 컨텍스트에서 호출해도 조용히 무시된다. 처음엔 BP에서 직접 호출하면 되겠지 싶었는데 ListenServer 환경에서 호스트는 되고 클라는 안 돼서 원인 파악에 시간이 걸렸다.</p>
<p><strong>2. DebugDefinition을 지웠는데 테스트가 안 된다</strong></p>
<p>이미 &quot;테스트 코드 삭제&quot;를 커밋해서 없앤 코드인데, 막상 픽업 흐름을 테스트하려니 드랍 파츠에 데이터가 없어서 <code>EquipPart</code>가 early return으로 빠져나갔다. 삭제와 재추가를 두 번 반복한 셈이라 커밋 히스토리가 지저분해졌다.</p>
<p><strong>3. H키 바인딩 위치 오파악</strong></p>
<p>처음에 H키 바인딩이 레벨 BP에 있다고 잘못 안내했다. 실제로는 <code>BP_NS_PlayerCharacter</code>에 있었고, 이 차이로 에디터 작업 안내를 두 번 수정해야 했다.</p>
<hr>
<h3 id="무엇을-배웠나">무엇을 배웠나</h3>
<p><strong>서버 전용 함수는 클라이언트 테스트 경로를 따로 뚫어야 한다</strong></p>
<p><code>HasAuthority()</code> 가드가 붙은 함수는 멀티플레이어 환경에서 클라이언트가 절대 실행할 수 없다. PIE 테스트에서도 마찬가지다. 이 경우 선택지는 두 가지다:</p>
<ul>
<li>Server RPC를 임시로 추가한다 (이번에 한 방식)</li>
<li>테스트를 에디터 전용 호스트(플레이어 0번)로만 한다</li>
</ul>
<p>임시 RPC 방식은 나중에 상호작용 시스템으로 교체할 예정이므로, 반드시 <code>// 테스트용 임시 코드</code> 블록으로 표시해두고 삭제 시점을 메모해둬야 나중에 놓치지 않는다.</p>
<p><strong>테스트 코드는 삭제 시점을 미리 정하고 추가해야 한다</strong></p>
<p>&quot;일단 넣고 나중에 지우자&quot;는 항상 두 번 일하게 만든다. 이번에도 한 번 지웠다가 다시 추가했고, 커밋 히스토리에 흔적이 남았다. 앞으로는:</p>
<ol>
<li>추가할 때 삭제 조건을 주석에 명시 (<code>// 몬스터 드랍 연동 후 삭제</code>)</li>
<li>메모리/작업 큐에 &quot;임시 코드 삭제&quot; 항목을 바로 등록</li>
<li>정식 흐름이 생기는 시점에 묶어서 한 번에 제거</li>
</ol>
<p><strong><code>if (HasAuthority())</code> 단문은 반드시 <code>{}</code>로 감싸야 한다</strong></p>
<p>Unreal 코드베이스에서 <code>HasAuthority()</code> 블록은 한 줄처럼 보여도 나중에 줄이 추가될 가능성이 높다. 단문이어도 <code>{}</code> 없이 쓰면 이번처럼 다음 줄이 무조건 실행되는 버그가 생긴다. 제어문은 항상 <code>{}</code>를 쓰는 스타일을 지킬 것.</p>
<hr>
<h2 id="nspartutils-중복-resolvedefinition-공통-유틸-추출">NSPartUtils: 중복 ResolveDefinition 공통 유틸 추출</h2>
<h3 id="무엇을-했나-1">무엇을 했나</h3>
<p><code>NSPartEquipComponent</code>와 <code>NSPartVisualComponent</code> 두 곳에 완전히 동일한 구현으로 존재하던 <code>ResolveDefinition</code>을 <code>NSPartUtils</code> 네임스페이스 자유 함수로 추출했다. (커밋 <code>7afabc66</code>)</p>
<ul>
<li><strong>추출 전</strong>: <code>UNSPartEquipComponent::ResolveDefinition(const FNSPartData&amp;) const</code> (멤버 함수) / <code>NSPartVisualComponent</code>에도 동일 구현</li>
<li><strong>추출 후</strong>: <code>NSPartUtils::ResolvePartDefinition(const UObject* WorldContextObject, const FNSPartData&amp;)</code> — <code>NSPartUtils.h/.cpp</code> 신규 파일</li>
</ul>
<p>멤버 함수가 아니라 자유 함수로 만든 이유는 <code>UNSDataSubsystem::Get()</code>에 <code>WorldContext</code>가 필요한데, <code>UObject*</code>를 파라미터로 받으면 어느 컴포넌트에서든 호출할 수 있기 때문이다. <code>BlueprintFunctionLibrary</code>는 BP 노출이 필요 없어서 사용하지 않았다.</p>
<p>이 커밋에서 <code>DOREPLIFETIME_CONDITION(..., COND_OwnerOnly)</code> → <code>DOREPLIFETIME</code>으로도 변경됐는데, 파츠 슬롯이 오너 전용으로 복제되면 다른 플레이어 캐릭터에 파츠 비주얼이 표시되지 않는 문제를 해결하기 위해서였다.</p>
<hr>
<h3 id="어떤-어려움이-있었나-1">어떤 어려움이 있었나</h3>
<p><strong>자유 함수 vs 멤버 함수 vs BlueprintFunctionLibrary 선택</strong></p>
<p>처음엔 <code>UNSPartDefinition</code>을 반환하는 함수니까 멤버 함수 형태가 자연스럽게 느껴졌다. 하지만 멤버 함수로 두면 두 컴포넌트 중 어느 쪽에 소속시킬지 결정해야 하고, 한쪽에 의존성이 생기거나 또 중복이 발생한다. <code>BlueprintFunctionLibrary</code>는 BP 노출이 목적인데 이 함수는 서버 로직에서만 쓰이므로 과한 선택이었다.</p>
<p>결론적으로 namespace 자유 함수가 가장 작은 형태였지만, Unreal에서 namespace 함수를 <code>NEOSANCTUM_API</code>로 모듈 외부에 노출하는 패턴이 익숙하지 않아서 헤더 선언 형태를 한 번 고쳐야 했다.</p>
<p><strong>유니티 빌드 이름 충돌 가능성</strong></p>
<p>Unreal은 기본적으로 Unity Build(여러 .cpp를 하나로 묶어 컴파일)를 사용한다. 이 경우 서로 다른 파일에 같은 이름의 namespace나 로컬 심볼이 있으면 충돌이 날 수 있다. <code>NSPartUtils</code>라는 이름 앞에 프로젝트 접두사를 붙인 것도 이 때문이다.</p>
<hr>
<h3 id="무엇을-배웠나-1">무엇을 배웠나</h3>
<p><strong>중복 코드는 &quot;어디에 두느냐&quot;가 핵심이다</strong></p>
<p>중복을 없애는 건 쉬운데, 합친 코드를 어디에 두느냐는 별개의 결정이다. Unreal에서 선택지는 보통 세 가지다:</p>
<table>
<thead>
<tr>
<th>형태</th>
<th>언제</th>
</tr>
</thead>
<tbody><tr>
<td>멤버 함수</td>
<td>특정 클래스의 상태에 의존할 때</td>
</tr>
<tr>
<td><code>BlueprintFunctionLibrary</code></td>
<td>BP에서도 쓸 때</td>
</tr>
<tr>
<td>namespace 자유 함수</td>
<td>상태 의존 없이 순수 로직, C++만 쓸 때</td>
</tr>
</tbody></table>
<p>이번 <code>ResolvePartDefinition</code>은 <code>WorldContext</code>만 있으면 되고 BP 노출도 불필요해서 namespace 자유 함수가 맞았다.</p>
<p><strong><code>COND_OwnerOnly</code>는 비주얼 데이터에 쓰면 안 된다</strong></p>
<p><code>DOREPLIFETIME_CONDITION(..., COND_OwnerOnly)</code>은 오너 클라이언트에게만 복제한다. 파츠 장착 정보(<code>EquippedParts</code>)에 이걸 붙이면 내 화면에서는 장착 비주얼이 보이지만, 다른 플레이어 시점에서는 내 캐릭터가 빈 손으로 보인다. 비주얼에 영향을 주는 복제 프로퍼티는 <code>DOREPLIFETIME</code>(모든 클라이언트)을 써야 한다. 보안/트래픽 이유로 오너만 받아야 하는 건 인벤토리 수량, 자원 같은 private 데이터다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 파츠 테스트]]></title>
            <link>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Thu, 11 Jun 2026 02:16:25 GMT</pubDate>
            <description><![CDATA[<h1 id="파츠">파츠</h1>
<h2 id="복제문제">복제문제</h2>
<ul>
<li><p>서버에서 장착한 파츠가 클라에서 안보임
!youtube[5620Nv7IBfc]</p>
</li>
<li><p>반대는 가능
!youtube[c6yGiKojjrU]</p>
</li>
</ul>
<h3 id="첫번째-예상">첫번째 예상</h3>
<p>비주얼 컴포넌트로 따로 분리를해서 비주얼 컴포넌트쪽을 의심했지만 OnPartChanged 콜백을 받아서 메시를 불러와서 띄워주는 역할을 하기때문에 파츠 컴포넌트쪽 레플리케이션 조건을 바꿔보았다. 기존에는 COND_OwnerOnly로 소유한 클라에게만 복제되게끔 되어있는데 이를 모든 접속자에게 복제하도록 변경해보았다.</p>
<pre><code class="language-c++">void UNSPartEquipComponent::GetLifetimeReplicatedProps(TArray&lt;class FLifetimeProperty&gt;&amp; OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    // 이전 코드
    // DOREPLIFETIME_CONDITION(UNSPartEquipComponent, EquippedParts, COND_OwnerOnly); 
    // 새 코드
    DOREPLIFETIME(UNSPartEquipComponent, EquippedParts);
}</code></pre>
<ul>
<li>결과
!youtube[fo1Bv1otQOM]</li>
</ul>
<p>바로 정상적으로 복제되었다.</p>
<ul>
<li>이유</li>
</ul>
<ol>
<li>호스트가 장착 -&gt; 호스트 PlayerState의 EquipParts가 원격 클라에 복제 안됨(onwer가 아니므로) -&gt; 클라에서 OnRep 안일어남 -&gt; 클라에서 안보임<ol start="2">
<li>클라가 장착 -&gt; 서버는 항상 authority라 데이터를 가지고 있음, EquipPart()가 직접 OnPartChanged.Broadcast 호출 -&gt; 호스트에서 보임</li>
</ol>
</li>
</ol>
<p>파츠는 모든 플레이어에게 보여야 하므로 증강처럼 owner 전용 복제가 잘못된 조건</p>
<h1 id="증강">증강</h1>
<h2 id="복제문제-1">복제문제</h2>
<p>서버에서는 정상적으로 증강이 출력되는데 클라이언트에서 O키(디버그용키)를 눌렀을때 증강이 제대로 출력되지 않던 문제
데이터 서브시스템에서 데이터가 모두 존재하면 EnterRun()함수를 호출해서 데이터가 로드되었다는 것을 Enum으로 설정하고 이를 IsRunReady라는 함수로 확인할 수가 있다. 증강쪽 코드에는 IsRunReady()를 확인해서 데이터가 있는지 확인하고 증강을 띄워준다.
허나 아직 데이터 서브시스템을 달아놓지 않았기 때문에 둘다 동작을 안하거나 둘다 동작해야 맞는데 계속 서버만 동작하게끔 되어있었다. 그래서 디버깅을 계속하다가 문제를 찾게 되었다.
문제는 플레이어 컨트롤러에서 인런으로 넘겨주는 Server_RequestStartRun함수였다.</p>
<pre><code class="language-c++">if (!HasAuthority())
{
    return;
}

AGameModeBase* CurrentGameMode = GetWorld()-&gt;GetAuthGameMode();
if (CurrentGameMode &amp;&amp; CurrentGameMode-&gt;Implements&lt;UNSOutGameInterface&gt;())
{
    INSOutGameInterface::Execute_RequestStartRun(CurrentGameMode);
}

//서버 인런 데이터 로드
if (UNSDataSubsystem* Data = UNSDataSubsystem::Get(this))
{
    Data-&gt;EnterRun();
}

Client_NotifyRunStarted();</code></pre>
<p>이 함수는 서버에서만 동작하고 Client_NotifyRunStarted를 호출한다. 해당 함수에서는</p>
<pre><code class="language-c++">if (UNSDataSubsystem* Data = UNSDataSubsystem::Get(this))
{
    Data-&gt;EnterRun();
}</code></pre>
<p>이렇게 데이터서브시스템이 있으면 EnterRun을 해서 임시로 런으로 넘겨주는 부분이 있는데 이때문에 서버에서는 EnterRun으로 넘어가고 클라에서는 Server_RequestStartRun의 !HasAuthority()를 넘기지 못하고 return되고 있었던 것</p>
<ul>
<li>해결</li>
</ul>
<pre><code class="language-c++">for (FConstPlayerControllerIterator It = GetWorld()-&gt;GetPlayerControllerIterator(); It; ++It)
{
    if (ANSPlayerController* PC = Cast&lt;ANSPlayerController&gt;(It-&gt;Get()))
    {
        PC-&gt;Client_NotifyRunStarted();
    }
}</code></pre>
<ul>
<li>GetPlayerControllerIterator()를 통해서 모든 플레이어컨트롤러를 가져와서 Client_NotifyRunStarted()함수를 호출해서 해결하였다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/kyu_/post/68e34b0b-ec99-40db-bbde-f7fb14be4531/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 파츠 드롭·비주얼 장착, 데디서버/리슨서버, NetMode/NetRole, 정리]]></title>
            <link>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%EB%93%9C%EB%A1%AD%EB%B9%84%EC%A3%BC%EC%96%BC-%EC%9E%A5%EC%B0%A9-%EB%8D%B0%EB%94%94%EC%84%9C%EB%B2%84%EB%A6%AC%EC%8A%A8%EC%84%9C%EB%B2%84-NetModeNetRole-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%EB%93%9C%EB%A1%AD%EB%B9%84%EC%A3%BC%EC%96%BC-%EC%9E%A5%EC%B0%A9-%EB%8D%B0%EB%94%94%EC%84%9C%EB%B2%84%EB%A6%AC%EC%8A%A8%EC%84%9C%EB%B2%84-NetModeNetRole-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 10 Jun 2026 12:37:04 GMT</pubDate>
            <description><![CDATA[<h1 id="til---파츠-드롭·비주얼-장착-데디서버리슨서버-netmodenetrole-정리">TIL - 파츠 드롭·비주얼 장착, 데디서버/리슨서버, NetMode/NetRole, 정리</h1>
<hr>
<h2 id="파츠-드롭-테스트-정리">파츠 드롭 테스트 정리</h2>
<h3 id="현재-확인하려는-것">현재 확인하려는 것</h3>
<p>현재 테스트 목표는 다음 두 가지로 볼 수 있다.</p>
<ul>
<li>바닥에 떨어진 파츠와 현재 장착 중인 파츠가 <strong>정상적으로 교환되는지</strong>.</li>
<li>교환 결과로 GE, GA 테스트용 효과가 <strong>정상 적용되는지</strong>.</li>
</ul>
<p>즉, 이 작업은 단순히 메시를 바꾸는 테스트가 아니라 <strong>데이터 교환 + 시각 교체 + GAS 적용</strong> 이 한 흐름으로 이어지는지 검증하는 테스트다.</p>
<h3 id="드롭-액터에-물리를-켤지-고민한-이유">드롭 액터에 물리를 켤지 고민한 이유</h3>
<p>물리를 켜면 바닥에 자연스럽게 떨어지는 연출은 쉬울 수 있지만, 현재 구조에서는 단점이 더 크다.</p>
<ul>
<li>루트가 구체 콜리전이면 반지름만큼 바닥에서 뜨는 문제가 생길 수 있다.</li>
<li>멀티플레이에서 물리 시뮬레이션 액터는 단순 복제보다 관리 포인트가 늘어난다.</li>
<li>특히 서버 권위 하에서 트랜스폼 복제와 물리 결과 동기화를 신경써야 해서 복잡도가 빠르게 증가한다.</li>
</ul>
<p>그래서 현재 상황에서는 <strong>물리 시뮬레이션을 켜지 않고, 스폰 시 1회 위치 보정으로 해결하는 방식</strong> 이 더 적절하다.</p>
<h3 id="왜-메시가-공중에-뜨는가">왜 메시가 공중에 뜨는가</h3>
<p>문제의 핵심은 파츠 메시의 <strong>피벗이 지면 기준이 아니라 본 위치 기준</strong> 이라는 점이다. 예를 들어 헬멧 메시의 원점은 머리 높이 근처, 각반 메시의 원점은 다리 본 근처에 잡혀 있을 수 있으므로, 드랍 액터 원점을 바닥에 그대로 두면 메시가 그 높이만큼 공중에 떠 보인다.</p>
<p>이건 메시가 잘못된 것이 아니라 <strong>모듈러 장착 기준으로 제작된 메시를 드랍 기준으로 재사용</strong> 하면서 생기는 자연스러운 현상이다.</p>
<h3 id="해결-방법-메시-바운드-기반-z-보정">해결 방법: 메시 바운드 기반 Z 보정</h3>
<p>해결 방법은 드랍 시 물리 대신 <strong>메시 바운드의 밑면을 바닥에 맞추는 오프셋을 한 번 적용</strong> 하는 것이다.</p>
<p>흐름은 다음과 같다.</p>
<ol>
<li>드랍할 메시의 로컬 바운드를 구한다.</li>
<li>바운드의 <code>Min.Z</code> 를 이용해 메시 밑면 위치를 계산한다.</li>
<li>스폰 위치의 Z 에 그 값을 반영해 한 번만 보정한다.</li>
<li>이후에는 물리 없이 고정된 드랍 액터로 유지한다.</li>
</ol>
<p>예시:</p>
<pre><code class="language-cpp">const FBoxSphereBounds Bounds = Mesh-&gt;CalcBounds(FTransform::Identity);
const float BottomZ = Bounds.GetBox().Min.Z;

FVector SpawnLocation = DropLocation;
SpawnLocation.Z -= BottomZ;</code></pre>
<p>이 방식의 장점은 명확하다.</p>
<ul>
<li>루트 콜리전 모양에 덜 의존한다.</li>
<li>멀티플레이 물리 복제 문제를 피할 수 있다.</li>
<li>스폰 시 1회 계산으로 끝나므로 유지비가 낮다.</li>
<li>드랍 메시와 장착 메시를 같은 에셋으로 재사용하기 좋다.</li>
</ul>
<hr>
<h2 id="스폰-관련-개념">스폰 관련 개념</h2>
<h3 id="espawnactorcollisionhandlingmethod">ESpawnActorCollisionHandlingMethod</h3>
<p><code>ESpawnActorCollisionHandlingMethod</code> 는 액터를 스폰할 때, 스폰 위치에서 다른 오브젝트와 <strong>겹침이 발생하면 어떻게 처리할지</strong> 결정하는 열거형이다.</p>
<p>자주 보는 옵션은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>값</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>AlwaysSpawn</code></td>
<td>겹침을 무시하고 무조건 스폰</td>
</tr>
<tr>
<td><code>AdjustIfPossibleButAlwaysSpawn</code></td>
<td>가능하면 위치를 조정하고, 그래도 스폰</td>
</tr>
<tr>
<td><code>AdjustIfPossibleButDontSpawnIfStillColliding</code></td>
<td>조정 후에도 겹치면 스폰하지 않음</td>
</tr>
<tr>
<td><code>DontSpawnIfColliding</code></td>
<td>겹치면 아예 스폰하지 않음</td>
</tr>
</tbody></table>
<p>현재 드랍 액터처럼 “일단 생성은 되어야 하고, 위치는 후처리로 맞출 수 있다”는 상황에서는 <code>AlwaysSpawn</code> 가 단순하고 예측 가능하다.</p>
<h3 id="spawnactordeferred">SpawnActorDeferred</h3>
<p><code>SpawnActorDeferred</code> 는 액터 스폰을 <strong>객체 생성 단계</strong> 와 <strong>최종 초기화 단계</strong> 로 나누는 방식이다.
일반 <code>SpawnActor</code> 는 생성 후 초기화가 즉시 진행되지만, <code>SpawnActorDeferred</code> 는 <code>FinishSpawning()</code> 을 호출하기 전까지 중간 초기화 구간을 확보할 수 있다.</p>
<p>즉, 흐름은 아래처럼 이해하면 된다.</p>
<ol>
<li><p><code>SpawnActorDeferred</code> 로 액터 객체를 만든다.</p>
</li>
<li><p><code>BeginPlay</code> 전에 필요한 값을 세팅한다.</p>
</li>
<li><p><code>FinishSpawning()</code> 으로 스폰을 완료한다.</p>
</li>
</ol>
<p>예시:</p>
<pre><code class="language-cpp">ADroppedPart* DroppedPart = GetWorld()-&gt;SpawnActorDeferred&lt;ADroppedPart&gt;(
    DroppedPartClass,
    SpawnTransform,
    Owner,
    Instigator,
    ESpawnActorCollisionHandlingMethod::AlwaysSpawn
);

DroppedPart-&gt;InitializeFromInstance(StoredInstance);
DroppedPart-&gt;SetDesiredDropMesh(DropMesh);

UGameplayStatics::FinishSpawningActor(DroppedPart, SpawnTransform);</code></pre>
<p>현재 케이스에서 이 방식이 중요한 이유는 <code>DroppedPart</code> 가 시작 시점에 <code>StoredInstance</code> 를 이미 가지고 있어야 하기 때문이다. 일반 <code>SpawnActor</code> 로 만들면 <code>BeginPlay</code> 또는 초기화 로직이 먼저 돌고, 그 뒤에 값을 세팅하는 순서 문제가 생길 수 있다.</p>
<hr>
<h2 id="파츠-비주얼-구조-정리">파츠 비주얼 구조 정리</h2>
<h3 id="역할-분리">역할 분리</h3>
<p>장착 데이터와 시각 표시를 분리한 현재 설계는 방향이 좋다.</p>
<table>
<thead>
<tr>
<th>컴포넌트</th>
<th>부착 위치</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>UNSPartEquipComponent</code></td>
<td><code>PlayerState</code></td>
<td>파츠 데이터, 슬롯 관리, GAS 연동</td>
</tr>
<tr>
<td><code>UNSPartVisualComponent</code></td>
<td><code>PlayerCharacter</code></td>
<td>파츠 메시 표시 전용</td>
</tr>
</tbody></table>
<p>핵심은 <strong>EquipComponent 가 파츠 하나당 붙는 구조가 아니라, 플레이어 하나당 1개 존재하면서 전 슬롯을 관리한다</strong> 는 점이다. 따라서 VisualComponent 는 EquipComponent 의 상태를 구독해서 렌더링만 담당하면 된다.</p>
<h3 id="bindtoequipcomponent-흐름">BindToEquipComponent 흐름</h3>
<pre><code class="language-text">BindToEquipComponent(EquipComp, GetMesh())
 ├── GetMesh() = 캐릭터 본체 스켈레탈 메시(리더)
 ├── EnsureSlotComponents() = 슬롯별 메시 컴포넌트 최초 1회 생성
 └── OnPartChanged 델리게이트 구독</code></pre>
<p>이 구조의 장점은 초기화와 갱신 책임이 깔끔히 분리된다는 점이다. 슬롯용 메시 컴포넌트는 한 번 만들어 두고, 실제 파츠 변경은 이벤트 기반으로 처리하면 된다.</p>
<h3 id="ensureslotcomponents">EnsureSlotComponents</h3>
<p><code>EnsureSlotComponents</code> 는 <strong>슬롯별 비주얼 컴포넌트가 존재하는지 보장하는 초기화 함수</strong> 로 이해하면 된다. 보통 <code>BindToEquipComponent</code> 안에서 한 번만 호출되며, 하는 일은 크게 두 가지다.</p>
<ul>
<li><code>BindToEquipComponent</code> 가 중복되지 않게 준비한다.</li>
<li>슬롯별 <code>USkeletalMeshComponent</code> 를 생성하고 등록한다.</li>
</ul>
<p>즉, 이 함수의 책임은 <strong>그릇 생성</strong> 이다. 아직 어떤 파츠를 보여줄지는 모르더라도, Body/Arm/Leg 슬롯이 사용할 메시 컴포넌트를 준비해 두는 단계다.</p>
<h3 id="updateslotvisual">UpdateSlotVisual</h3>
<p><code>UpdateSlotVisual</code> 은 <strong>실제 파츠 변경이 발생했을 때 해당 슬롯의 메시를 바꾸는 함수</strong> 다. 즉, <code>EnsureSlotComponents</code> 가 그릇을 만드는 단계라면, <code>UpdateSlotVisual</code> 은 그 그릇에 어떤 스켈레탈 메시를 담을지 결정하는 단계다.</p>
<p>파츠가 바뀔 때마다 새 컴포넌트를 생성할 필요는 없다. 이미 만들어 둔 슬롯 컴포넌트에 <code>SetSkeletalMesh()</code> 로 에셋만 교체하면 된다.</p>
<hr>
<h2 id="setleaderposecomponent-와-부착-원리">SetLeaderPoseComponent 와 부착 원리</h2>
<h3 id="파츠가-제자리에-붙는-이유">파츠가 제자리에 붙는 이유</h3>
<p><code>SetLeaderPoseComponent()</code> 가 파츠 메시의 위치를 직접 계산해 주는 것은 아니다. 위치가 맞는 이유는 <strong>파츠 메시 자체가 캐릭터 전신 스켈레톤 기준 위치에 맞춰 모델링되어 있기 때문</strong> 이고, <code>SetLeaderPoseComponent()</code> 는 그 본 변형을 따라가게 해 주는 역할을 한다.</p>
<p>역할을 나누면 아래처럼 정리할 수 있다.</p>
<table>
<thead>
<tr>
<th>역할</th>
<th>담당</th>
</tr>
</thead>
<tbody><tr>
<td>씬 계층상 부모-자식 관계</td>
<td><code>SetupAttachment()</code></td>
</tr>
<tr>
<td>본 애니메이션 동기화</td>
<td><code>SetLeaderPoseComponent()</code></td>
</tr>
<tr>
<td>처음부터 올바른 공간에 존재하는 버텍스 배치</td>
<td>파츠 스켈레탈 메시 에셋 자체</td>
</tr>
</tbody></table>
<p>즉, <strong>올바르게 모델링된 모듈러 메시 + 같은 스켈레톤 + Leader Pose</strong> 조합이 자연스러운 장착 비주얼을 만든다.</p>
<h3 id="ensureslotcomponents-코드-의미">EnsureSlotComponents 코드 의미</h3>
<pre><code class="language-cpp">MeshComp-&gt;SetupAttachment(LeaderMeshComp);</code></pre>
<p>리더 메시를 부모로 붙여서 위치/회전/스케일을 함께 따라가게 만든다.</p>
<pre><code class="language-cpp">MeshComp-&gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);</code></pre>
<p>파츠 시각 메시 자체는 충돌이 필요 없으므로 꺼 두는 편이 합리적이다. 실제 캐릭터 충돌은 보통 캡슐 컴포넌트가 담당한다.</p>
<pre><code class="language-cpp">MeshComp-&gt;RegisterComponent();</code></pre>
<p><code>NewObject</code> 로 만든 컴포넌트는 등록 전까지 월드에서 정상적으로 렌더링·틱되지 않으므로, 수동 등록이 필요하다.</p>
<pre><code class="language-cpp">MeshComp-&gt;SetLeaderPoseComponent(LeaderMeshComp);</code></pre>
<p>리더 메시의 본 포즈를 따라가게 하여, 별도 애니메이션 인스턴스 없이도 파츠가 동일한 애니메이션을 공유하게 만든다.</p>
<hr>
<h2 id="파츠-시각-장착-설계">파츠 시각 장착 설계</h2>
<h3 id="설계-방향">설계 방향</h3>
<p>현재 설계는 <strong>데이터 관리와 시각 표현을 분리하면서, 드랍용 메시를 장착용 메시로도 재사용하는 구조</strong> 다. 이 방향은 구현량과 유지보수 비용 사이 균형이 좋다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>결정</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>장착용 메시 소스</td>
<td><code>DropMesh</code> 재사용</td>
<td>드랍 룩과 장착 룩이 동일하고 필드 추가가 필요 없음</td>
</tr>
<tr>
<td>부착 방식</td>
<td><code>Leader Pose</code></td>
<td>같은 스켈레톤 공유 시 애니메이션 자동 추종 가능</td>
</tr>
<tr>
<td>구조 위치</td>
<td><code>UNSPartVisualComponent</code> 분리</td>
<td>캐릭터 클래스 비대화 방지, 기존 컴포넌트 스타일과 일관성 유지</td>
</tr>
</tbody></table>
<h3 id="구조">구조</h3>
<pre><code class="language-text">ANSPlayerCharacterBase
 └── UNSPartVisualComponent
     ├── BodyPartMeshComp : USkeletalMeshComponent
     ├── ArmPartMeshComp  : USkeletalMeshComponent
     └── LegPartMeshComp  : USkeletalMeshComponent</code></pre>
<p>각 슬롯 메시 컴포넌트는 메인 캐릭터 메시를 리더로 사용한다.</p>
<h3 id="신호-흐름">신호 흐름</h3>
<pre><code class="language-text">UNSPartEquipComponent::OnPartChanged(Slot, PartData)
 → UNSPartVisualComponent 가 구독
 → DefinitionPtr 로 Def 조회
   → 이미 로드됨: 즉시 SetSkeletalMesh()
   → 미로드: RequestAsyncLoad 후 콜백에서 적용
 → 비어 있는 슬롯: SetSkeletalMesh(nullptr)</code></pre>
<p>이 흐름의 핵심은 <strong>EquipComponent 는 상태를 발행하고, VisualComponent 는 그 상태를 구독해서 렌더링만 한다</strong> 는 점이다.</p>
<h3 id="구독-시점">구독 시점</h3>
<p>VisualComponent 는 캐릭터에 붙어 있지만 EquipComponent 는 <code>PlayerState</code> 에 있으므로, <code>BeginPlay</code> 에서 무조건 바로 접근한다고 가정하면 위험할 수 있다. 따라서 <strong><code>PossessedBy()</code> 와 <code>OnRep_PlayerState()</code> 시점 모두에서 바인딩을 보장하는 방식</strong> 이 타당하다.</p>
<p>이 판단은 특히 멀티플레이에서 중요하다. 서버와 클라이언트는 PlayerState 획득 타이밍이 다를 수 있기 때문이다.</p>
<hr>
<h2 id="구현-계획">구현 계획</h2>
<h3 id="1단계---unspartvisualcomponent-신설">1단계 - <code>UNSPartVisualComponent</code> 신설</h3>
<p>파일 위치:</p>
<ul>
<li><code>Source/NeoSanctum/Character/Component/NSPartVisualComponent.h</code></li>
<li><code>Source/NeoSanctum/Character/Component/NSPartVisualComponent.cpp</code></li>
</ul>
<p>구성:</p>
<ul>
<li>슬롯별 <code>USkeletalMeshComponent</code> 3개 보유</li>
<li><code>BindToEquipComponent(UNSPartEquipComponent*, USkeletalMeshComponent* LeaderMesh)</code></li>
<li><code>EnsureSlotComponents()</code></li>
<li><code>UpdateSlotVisual(ENSPartSlot, const FNSPartData&amp;)</code></li>
<li><code>ClearVisual(ENSPartSlot)</code></li>
</ul>
<h3 id="2단계---ansplayercharacterbase-연결">2단계 - <code>ANSPlayerCharacterBase</code> 연결</h3>
<ul>
<li><code>UPROPERTY(VisibleAnywhere)</code> 로 <code>PartVisualComp</code> 추가</li>
<li><code>PossessedBy()</code> 에서 서버 측 바인딩</li>
<li><code>OnRep_PlayerState()</code> 에서 클라이언트 측 바인딩</li>
</ul>
<h3 id="검증-목표">검증 목표</h3>
<pre><code class="language-text">1. 키 입력 → EquipPart → 캐릭터에 파츠 메시 부착
2. 같은 슬롯 드랍 픽업 → 기존 메시 제거 + 새 메시 부착
3. ClearAll 호출 → 모든 슬롯 메시 제거
4. GE 적용 여부 → showdebug abilitysystem 으로 확인</code></pre>
<hr>
<h2 id="비동기-로드-메모">비동기 로드 메모</h2>
<p><code>DropMesh</code> 가 번들 태그 없이 관리된다면, 파츠 비주얼 적용 시점에 온디맨드 비동기 로드를 거는 것이 안전하다. 현재 구조에서는 슬롯별 로드 핸들을 관리하고, <code>ClearVisual()</code> 시 취소하거나 무효화하는 흐름이 적합하다.</p>
<p>특히 장착 시스템에서는 <strong>동기 로드(<code>LoadSynchronous</code>) 를 피하는 것</strong> 이 중요하다. 전투 중 장착 변경이나 드랍 픽업 시 프레임 스톨을 만들 수 있기 때문이다.</p>
<hr>
<h2 id="서버-구조-정리">서버 구조 정리</h2>
<h3 id="데디서버와-리슨서버-차이">데디서버와 리슨서버 차이</h3>
<p>언리얼의 <strong>Dedicated Server</strong> 는 클라이언트와 분리된 별도 서버 프로세스로 동작하며, 시각 표시나 UI 없이 게임 상태 동기화와 규칙 처리만 담당한다.
반면 <strong>Listen Server</strong> 는 한 플레이어의 게임 클라이언트가 서버 역할까지 함께 수행하는 구조라서, 호스트는 직접 플레이하면서 동시에 서버 권한도 가진다.</p>
<hr>
<h2 id="netmode-와-netrole">NetMode 와 NetRole</h2>
<h3 id="netmode">NetMode</h3>
<p><code>NetMode</code> 는 <strong>현재 월드 인스턴스가 어떤 네트워크 실행 모드인지</strong> 나타내는 값이다. 즉, “이 프로그램 인스턴스가 서버인지, 클라이언트인지, 혼자인지”를 구분하는 월드 단위 개념이다.</p>
<table>
<thead>
<tr>
<th>값</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>NM_Standalone</code></td>
<td>네트워크 연결 없이 단독 실행</td>
</tr>
<tr>
<td><code>NM_ListenServer</code></td>
<td>서버이면서 동시에 로컬 플레이어도 존재</td>
</tr>
<tr>
<td><code>NM_DedicatedServer</code></td>
<td>전용 서버, 플레이용 클라이언트 기능 없음</td>
</tr>
<tr>
<td><code>NM_Client</code></td>
<td>서버에 접속한 클라이언트</td>
</tr>
</tbody></table>
<p>정리하면 NetMode 는 <strong>월드 전체의 입장</strong> 이다. 같은 코드라도 어느 머신에서 실행하느냐에 따라 <code>NM_Client</code>, <code>NM_ListenServer</code>, <code>NM_DedicatedServer</code> 로 달라진다.</p>
<h3 id="netrole">NetRole</h3>
<p><code>NetRole</code> 은 <strong>개별 Actor 가 현재 머신에서 어떤 네트워크 역할을 갖는지</strong> 나타낸다. UE5 에서는 예전 <code>Role</code> 직접 접근보다 <code>GetLocalRole()</code> / <code>GetRemoteRole()</code> 형태로 확인하는 방식이 권장된다.</p>
<table>
<thead>
<tr>
<th>값</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>ROLE_Authority</code></td>
<td>이 Actor 의 권한을 가진 원본, 일반적으로 서버 측 인스턴스</td>
</tr>
<tr>
<td><code>ROLE_AutonomousProxy</code></td>
<td>로컬 플레이어가 직접 조작하는 Pawn/Actor</td>
</tr>
<tr>
<td><code>ROLE_SimulatedProxy</code></td>
<td>서버 상태를 복제받아 표시만 하는 원격 Actor</td>
</tr>
<tr>
<td><code>ROLE_None</code></td>
<td>네트워크 역할 없음</td>
</tr>
</tbody></table>
<p>핵심은 <strong>NetMode 는 월드 기준</strong>, <strong>NetRole 은 Actor 기준</strong> 이라는 점이다. 예를 들어 클라이언트 머신의 NetMode 는 항상 <code>NM_Client</code> 여도, 그 안의 Actor 들은 <code>AutonomousProxy</code> 나 <code>SimulatedProxy</code> 처럼 서로 다른 Role 을 가질 수 있다.</p>
<h3 id="실무에서-어떻게-구분할까">실무에서 어떻게 구분할까</h3>
<ul>
<li>“이 코드가 서버에서만 돌아야 하나?” → <code>HasAuthority()</code> 또는 NetMode 를 본다.</li>
<li>“이 Actor 가 내가 조작하는 대상인가?” → <code>GetLocalRole()</code> 과 소유 관계를 본다.</li>
<li>초기화 시점이 아주 이른 경우에는 NetRole 보다 NetMode 확인이 더 안전한 경우가 있다.</li>
</ul>
<hr>
<h2 id="오늘의-핵심-정리">오늘의 핵심 정리</h2>
<ul>
<li>Dedicated Server 는 서버 전용 프로세스, Listen Server 는 플레이어가 서버까지 겸하는 구조다.</li>
<li>NetMode 는 월드 단위 실행 모드, NetRole 은 Actor 단위 네트워크 역할이다.</li>
<li>현재 드랍 파츠 문제는 물리보다 <strong>메시 바운드 기반 Z 보정</strong> 이 더 단순하고 멀티플레이 친화적이다.</li>
<li><code>SpawnActorDeferred</code> 는 <code>StoredInstance</code> 같은 필수 데이터를 <strong>BeginPlay 전</strong> 주입해야 할 때 적합하다.</li>
<li>파츠 시각 장착은 <code>EquipComponent(PlayerState)</code> 와 <code>VisualComponent(Character)</code> 분리 구조가 맞고, 슬롯 메시 컴포넌트는 최초 1회 생성 후 에셋만 교체하면 된다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL: 드랍 파츠 장착/교체 시스템 테스트 구성 및 트러블슈팅]]></title>
            <link>https://velog.io/@kyu_/TIL-%EB%93%9C%EB%9E%8D-%ED%8C%8C%EC%B8%A0-%EC%9E%A5%EC%B0%A9%EA%B5%90%EC%B2%B4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%84%B1-%EB%B0%8F-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@kyu_/TIL-%EB%93%9C%EB%9E%8D-%ED%8C%8C%EC%B8%A0-%EC%9E%A5%EC%B0%A9%EA%B5%90%EC%B2%B4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%84%B1-%EB%B0%8F-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Tue, 09 Jun 2026 12:06:50 GMT</pubDate>
            <description><![CDATA[<h1 id="til-드랍-파츠-장착교체-시스템-테스트-구성-및-트러블슈팅">TIL: 드랍 파츠 장착/교체 시스템 테스트 구성 및 트러블슈팅</h1>
<blockquote>
<p>Date: 2026-06-09
Tags: <code>UnrealEngine5</code> <code>GAS</code> <code>Replication</code> <code>PartEquip</code> <code>TIL</code></p>
</blockquote>
<hr>
<h2 id="배운-것-요약">배운 것 요약</h2>
<p>드랍된 파츠를 줍고, 슬롯에 장착하고, 기존 파츠를 바닥에 다시 스폰하는 흐름을
테스트하면서 마주친 이슈와 해결 방법을 정리한다.</p>
<hr>
<h2 id="테스트-구성-개요">테스트 구성 개요</h2>
<h3 id="임시-테스트-코드-사용-삭제-예정">임시 테스트 코드 사용 (삭제 예정)</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>DebugPartData</code></td>
<td>몬스터 드랍 연동 전 수동으로 파츠 데이터 세팅용</td>
</tr>
<tr>
<td><code>ServerRequestPickupNearest</code></td>
<td>G키로 가장 가까운 드랍 액터를 줍는 임시 RPC</td>
</tr>
<tr>
<td>레벨 블루프린트 G키 바인딩</td>
<td>몬스터 드랍 연동 후 삭제 예정</td>
</tr>
</tbody></table>
<blockquote>
<p>몬스터 드랍 연동 완료 후 위 세 가지 모두 제거할 것.</p>
</blockquote>
<hr>
<h2 id="테스트-절차">테스트 절차</h2>
<h3 id="사전-준비">사전 준비</h3>
<p><strong>DA_TestArmPart 생성 (없는 경우)</strong></p>
<ol>
<li>Content Browser 우클릭 → Miscellaneous &gt; Data Asset</li>
<li>클래스: <code>NSPartDefinition</code> → 이름: <code>DA_TestArmPart</code></li>
<li>내부 설정:<ul>
<li>Part Slot: <code>Arm</code></li>
<li>Part Name: <code>Test Arm</code></li>
<li>Drop Mesh: 아무 스켈레탈 메시</li>
<li>Value Range: Common → Min 10, Max 20</li>
</ul>
</li>
</ol>
<p><strong>BP_DroppedPart 세팅</strong></p>
<p>뷰포트에서 드랍 액터 선택 → Details 패널 → Debug &gt; Debug Part Data:</p>
<ul>
<li>Definition Ptr: <code>DA_TestArmPart</code></li>
<li>Current Rarity: <code>Common</code></li>
<li>Current Value: <code>15.0</code></li>
</ul>
<p>두 번째 드랍 액터는 첫 번째를 Ctrl+D로 복제 후 위치만 이동.</p>
<p><strong>레벨 블루프린트 G키 구성</strong></p>
<p>기존 G키 노드 전부 삭제 후 아래 순서로 재구성:</p>
<ol>
<li>빈 곳 우클릭 → <code>G</code> → Keyboard Events &gt; G</li>
<li>Pressed 핀 → <code>Get Player Controller</code> (Index 0)</li>
<li>→ <code>Get Player State</code></li>
<li>→ <code>Get Component by Class</code> (Component Class: <code>NSPartEquipComponent</code>)</li>
<li>→ <code>Server Request Pickup Nearest</code></li>
<li>G Pressed 실행 핀 → <code>Server Request Pickup Nearest</code> 실행 핀 연결</li>
</ol>
<hr>
<h2 id="테스트-케이스">테스트 케이스</h2>
<h3 id="tc-a-첫-장착-확인">TC-A: 첫 장착 확인</h3>
<p><strong>목표:</strong> G키로 드랍 파츠를 처음 주웠을 때 슬롯에 장착되는지 확인</p>
<p><strong>절차:</strong></p>
<ol>
<li>PIE 실행</li>
<li>첫 번째 드랍 액터 반지름 300 이내로 이동</li>
<li>G키 입력</li>
</ol>
<p><strong>기대 로그:</strong></p>
<pre><code>[PartEquip] 장착 완료 — Slot: 1 | Rarity: 0 | Value: 15.0 | Def: DA_TestArmPart
[PartEquip] GE 없음 — Slot: 1 (EffectClass 미지정)</code></pre><blockquote>
<p><code>GE 없음</code>은 정상이다. DA에 EffectClass를 지정하지 않았기 때문이며,
GE 테스트는 EffectClass 지정 후 별도로 진행한다.</p>
</blockquote>
<hr>
<h3 id="tc-b-교체--기존-파츠-드랍-확인">TC-B: 교체 + 기존 파츠 드랍 확인</h3>
<p><strong>목표:</strong> 이미 파츠가 장착된 상태에서 다른 드랍 액터를 주우면 기존 파츠가 바닥에 스폰되는지 확인</p>
<p><strong>절차:</strong></p>
<ol>
<li>TC-A 완료 상태 유지</li>
<li>두 번째 드랍 액터 근처로 이동</li>
<li>G키 입력</li>
</ol>
<p><strong>기대 로그:</strong></p>
<pre><code>[PartEquip] 기존 파츠 드랍 — Slot: 1 | Rarity: 0 | Value: 15.0
[PartEquip] 장착 완료 — Slot: 1 | Rarity: 0 | Value: 15.0 | Def: DA_TestArmPart</code></pre><p><strong>기대 시각:</strong></p>
<ul>
<li>플레이어 발 근처에 드랍 액터 새로 스폰됨</li>
<li>두 번째 드랍 액터 사라짐</li>
</ul>
<hr>
<h2 id="로그-해석표">로그 해석표</h2>
<table>
<thead>
<tr>
<th>로그</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>장착 완료</code></td>
<td>EquipPart 정상 동작</td>
</tr>
<tr>
<td><code>기존 파츠 드랍</code></td>
<td>DropPartInSlot 정상 동작, 교체 발생</td>
</tr>
<tr>
<td><code>GE 없음</code></td>
<td>EffectClass 미지정 — 정상, GE 테스트는 별도 진행</td>
</tr>
<tr>
<td><code>EquipPart 실패 — Definition 로드 불가</code></td>
<td>DebugPartData.DefinitionPtr 미지정. Details에서 DA 재확인</td>
</tr>
<tr>
<td><code>ServerRequestPickupNearest</code> 무반응</td>
<td>반지름 300 안에 드랍 액터 없음. 더 가까이 이동 필요</td>
</tr>
</tbody></table>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="이슈-1-make-nspartdata-핀이-비어-있음">[이슈 1] Make NSPartData 핀이 비어 있음</h3>
<p><strong>증상</strong></p>
<p>BP에서 <code>Make NSPartData</code> 노드를 꺼냈는데 설정 가능한 핀이 하나도 없음.</p>
<p><strong>원인</strong></p>
<p><code>FNSPartData</code>의 멤버 변수들이 전부 <code>BlueprintReadOnly</code>로 선언되어 있어서,
Make 노드가 쓰기 가능한 핀을 노출하지 않은 것.</p>
<p>Unreal의 Make 노드는 내부적으로 구조체의 각 멤버에 접근해 값을 채우는데,
<code>ReadOnly</code>면 외부에서 쓰기가 불가능하므로 핀 자체가 생성되지 않는다.</p>
<p><strong>해결</strong></p>
<p><code>NSPartTypes.h</code>의 <code>FNSPartData</code> 멤버를 <code>BlueprintReadWrite</code>로 변경
→ 에디터 완전 종료 → VS에서 빌드 → 재시작
→ 기존 Make NSPartData 노드 삭제 후 새로 추가</p>
<hr>
<h3 id="이슈-2-g키trypickup-후-f키initialize-누르면-pending-kill-에러">[이슈 2] G키(TryPickup) 후 F키(Initialize) 누르면 pending kill 에러</h3>
<p><strong>증상</strong></p>
<pre><code>Attempted to access BP_NSDroppedPart_C_1 via property ...
but BP_NSDroppedPart_C_1 is not valid (pending kill or garbage)</code></pre><p><strong>원인</strong></p>
<p>두 가지 문제가 중첩되었다.</p>
<ol>
<li><p>G키(TryPickup)가 액터를 <code>Destroy</code>하고 나면 레벨 블루프린트가 들고 있던
레퍼런스는 이미 무효(pending kill) 상태가 된다.
이 상태에서 F키(Initialize)가 그 레퍼런스에 접근하면 에러가 발생한다.</p>
</li>
<li><p>Initialize 없이 G를 누르면 <code>StoredInstance.DefinitionPtr</code>이 비어 있어
<code>EquipPart 실패 — Definition 로드 불가</code> 로그가 남는다.</p>
</li>
</ol>
<p><strong>해결</strong></p>
<p>반드시 <strong>F(Initialize) → G(TryPickup)</strong> 순서로 실행해야 했으나,
<code>DebugPartData</code> 자동 Initialize 도입으로 F키 자체가 불필요해졌다.
현재는 G키만으로 전체 흐름이 동작하므로 F키 바인딩은 삭제한다.</p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li><code>BlueprintReadOnly</code> vs <code>BlueprintReadWrite</code>는 Make 노드 핀 노출 여부에 직접 영향을 준다.</li>
<li>레벨 블루프린트에서 액터 레퍼런스를 들고 있을 때, 해당 액터가 Destroy되면
레퍼런스는 즉시 무효가 되므로 접근 전 유효성 체크가 필요하다.</li>
<li>테스트용 임시 코드(<code>DebugPartData</code>, G키 바인딩)는 몬스터 드랍 연동 시 반드시 제거할 것.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL | 파츠 시스템 개발]]></title>
            <link>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C</link>
            <guid>https://velog.io/@kyu_/TIL-%ED%8C%8C%EC%B8%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C</guid>
            <pubDate>Thu, 04 Jun 2026 11:44:13 GMT</pubDate>
            <description><![CDATA[<h1 id="파츠-시스템-개발">파츠 시스템 개발</h1>
<h2 id="에러-모음">에러 모음</h2>
<h3 id="tmap과-리플리케이션">TMap과 리플리케이션</h3>
<pre><code class="language-cpp">UPROPERTY(ReplicatedUsing=OnRep_EquippedParts)
TMap&lt;ENSPartSlot, FNSPartData&gt; EquippedParts;</code></pre>
<p>기존의 하나의 파츠만 보유하던 구조에서 여러 파츠를 소유할 수 있는 구조로 확장하는 중
TMap으로 장착된 파츠들을 저장하려고 했는데 아래 에러가 발생했다.</p>
<pre><code>NSPartEquipComponent.h(74): Error : Replicated maps are not supported.</code></pre><p>UE의 프로퍼티 리플리케이션은 TMap을 지원하지 않는다. (포인터, Map, TSet 모두 델타 직렬화 불가)</p>
<p><strong>해결:</strong> TMap에서 슬롯을 구조체 필드로 옮기고, TArray로 전환해 배열이 복제되도록 변경.</p>
<pre><code class="language-cpp">USTRUCT()
struct FNSPartData {
    ENSPartSlot Slot;  // 배열 내 슬롯 식별자로 사용
    ...
};

UPROPERTY(ReplicatedUsing=OnRep_EquippedParts)
TArray&lt;FNSPartData&gt; EquippedParts;</code></pre>
<hr>
<h3 id="isnull--isvalid--ispending--get-구분-tsoftobjectptr-기준">IsNull / IsValid / IsPending / .Get() 구분 (TSoftObjectPtr 기준)</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>의미</th>
<th>로드 유발</th>
</tr>
</thead>
<tbody><tr>
<td><code>IsNull()</code></td>
<td>경로 자체가 비어있음 (아무것도 안 가리킴)</td>
<td>X</td>
</tr>
<tr>
<td><code>IsValid()</code></td>
<td>지금 메모리에 로드되어 있고 유효</td>
<td>X</td>
</tr>
<tr>
<td><code>IsPending()</code></td>
<td>경로는 있는데 아직 로드 안 됨</td>
<td>X</td>
</tr>
<tr>
<td><code>.Get()</code></td>
<td>로드돼 있으면 포인터, 아니면 nullptr</td>
<td>X</td>
</tr>
</tbody></table>
<p><strong>핵심:</strong> 경로가 있는지 체크할 때는 <code>IsNull()</code>, 메모리에 올라와 있는지 체크할 때는 <code>IsValid()</code>.
<code>IsValid()</code>를 guard clause에 쓰면 경로는 있지만 아직 로드 안 된 경우에도 nullptr을 반환하는 버그가 생긴다.</p>
<p><strong>실수 예시:</strong></p>
<pre><code class="language-cpp">// 잘못된 코드 — Definition이 로드돼 있으면 nullptr 반환해버림
if (Part.DefinitionPtr.IsValid())
    return nullptr;

// 올바른 코드 — 경로 자체가 없을 때만 bail
if (Part.DefinitionPtr.IsNull())
    return nullptr;</code></pre>
<hr>
<h3 id="get은-캐시가-없으면-로드-못-한다">.Get()은 캐시가 없으면 로드 못 한다</h3>
<p><code>.Get()</code>은 <strong>로드를 절대 유발하지 않는다.</strong> 지금 메모리에 올라와 있으면 포인터, 아니면 그냥 nullptr.
동기 로드는 <code>.LoadSynchronous()</code>, 비동기 로드는 <code>RequestAsyncLoad</code>가 별도로 존재한다.</p>
<p>NSDataSubsystem이 번들 단위로 PrimaryAsset을 미리 로드해두기 때문에
<code>.Get()</code>이 정상 반환되는 것 — 번들 로드 전이라면 <code>.Get()</code>은 nullptr을 반환하고 조용히 실패한다.</p>
<hr>
<h3 id="fgameplayabilityspec-인자">FGameplayAbilitySpec 인자</h3>
<pre><code class="language-cpp">FGameplayAbilitySpec Spec(Loaded, 1, INDEX_NONE, this);</code></pre>
<table>
<thead>
<tr>
<th>인자</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>Ability</code></td>
<td>부여할 GA 클래스</td>
</tr>
<tr>
<td><code>Level</code></td>
<td>어빌리티 레벨 (스케일링/레벨 기반 효과에 사용)</td>
</tr>
<tr>
<td><code>InputID</code></td>
<td><code>INDEX_NONE</code> = 입력 바인딩 없음 (패시브/내부 트리거용)</td>
</tr>
<tr>
<td><code>SourceObject</code></td>
<td>이 어빌리티를 부여한 주체. 파츠 컴포넌트라면 <code>this</code></td>
</tr>
</tbody></table>
<p><code>SourceObject</code>에 <code>this</code>를 넘기면 나중에 &quot;어느 파츠가 준 GA인지&quot; 추적 및 조건 판정에 사용할 수 있다.
<code>ASC-&gt;GiveAbility(Spec)</code>의 반환 핸들(<code>FGameplayAbilitySpecHandle</code>)을 컴포넌트가 직접 보관해
파츠 제거 시 <code>ClearAbility(Handle)</code>로 정확히 그 GA만 회수하는 GAS 정석 패턴.</p>
<hr>
<h3 id="const-오버로드--왜-같은-함수가-두-개인가">const 오버로드 — 왜 같은 함수가 두 개인가</h3>
<pre><code class="language-cpp">FNSPartData*       FindPart(ENSPartSlot Slot);        // 수정 가능 포인터
const FNSPartData* FindPart(ENSPartSlot Slot) const;  // 읽기 전용 포인터</code></pre>
<p>C++은 호출하는 객체가 const인지 아닌지로 어느 쪽을 쓸지 자동 선택한다.
<code>const</code> 멤버 함수 안에서는 non-const 버전을 호출할 수 없으므로 (const 위반)
외부에서 <code>const UNSPartEquipComponent&amp;</code>로 참조할 경우를 위해 두 버전 모두 필요하다.</p>
<hr>
<h2 id="설계-개념">설계 개념</h2>
<h3 id="playerstate-vs-pawn--데이터-생존-전략">PlayerState vs Pawn — 데이터 생존 전략</h3>
<p>UE 멀티플레이에서 Pawn은 사망 시 파괴되지만 <strong>PlayerState는 인런 내내 살아있다.</strong>
파츠/증강처럼 사망 후에도 유지되어야 하는 데이터는 PlayerState(또는 PlayerState에 붙은 컴포넌트)에 두는 것이 정석.</p>
<table>
<thead>
<tr>
<th>위치</th>
<th>사망 시</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Pawn/Character</td>
<td>파괴됨</td>
<td>비주얼, 입력, 물리</td>
</tr>
<tr>
<td>PlayerState</td>
<td>유지됨</td>
<td>파츠, 증강, 스탯, 진행도</td>
</tr>
</tbody></table>
<p>ASC도 PlayerState에 두면 GE/GA가 사망 후에도 유지되고,
부활 시 <code>InitAbilityActorInfo(PS, newPawn)</code>으로 Avatar만 교체하면 기존 효과가 새 Pawn에 자동 적용된다.</p>
<hr>
<h3 id="서버-권한-액터에-client-rpc를-직접-못-다는-이유">서버 권한 액터에 Client RPC를 직접 못 다는 이유</h3>
<p>World에 스폰된 액터(예: 드랍 파츠)는 <strong>owning connection이 없다.</strong>
클라이언트가 이 액터에 직접 Server RPC를 보내려 해도 라우팅이 안 된다.
<strong>해결:</strong> 플레이어 측(Pawn/PlayerController)에서 Server RPC를 받아,
서버에서 trace 등으로 대상 액터를 찾아 해당 로직을 직접 호출하는 패턴을 사용.</p>
<pre><code class="language-cpp">// 드랍 액터에 ServerRPC X
// 플레이어 컨트롤러에서:
void AMyPlayerController::ServerPickup_Implementation(ANSDroppedPart* Target) {
    Target-&gt;TryPickup(GetPawn());
}</code></pre>
<hr>
<h1 id="알고리즘">알고리즘</h1>
<h2 id="오픈채팅방">오픈채팅방</h2>
<ul>
<li>문제: <a href="https://school.programmers.co.kr/learn/courses/30/lessons/42888?language=cpp">https://school.programmers.co.kr/learn/courses/30/lessons/42888?language=cpp</a></li>
<li>내 풀이: <a href="https://github.com/JongKyuHong/NBC_TIL/blob/main/%EC%BD%94%EB%93%9C%EC%B9%B4%ED%83%80/28%EC%A3%BC%EC%B0%A8/%EB%AA%A9/%EC%98%A4%ED%94%88%EC%B1%84%ED%8C%85%EB%B0%A9.cpp">https://github.com/JongKyuHong/NBC_TIL/blob/main/%EC%BD%94%EB%93%9C%EC%B9%B4%ED%83%80/28%EC%A3%BC%EC%B0%A8/%EB%AA%A9/%EC%98%A4%ED%94%88%EC%B1%84%ED%8C%85%EB%B0%A9.cpp</a></li>
</ul>
<p>id별 최종 닉네임 매핑을 만들어두고, 메시지 순서를 따로 저장한 뒤 마지막에 합치는 방식.</p>
<pre><code class="language-cpp">unordered_map&lt;string, string&gt; matchMap;
vector&lt;pair&lt;string, string&gt;&gt; message;

for (const string&amp; rec : record) {
    string command, uid, nickname;
    // flag로 직접 파싱하는 방식 → 어색하고 비효율적
}</code></pre>
<p><strong>개선:</strong> <code>stringstream</code>으로 공백 기준 자동 분리.</p>
<pre><code class="language-cpp">for (const string&amp; rec : record) {
    stringstream ss(rec);
    string command, uid, nickname;
    ss &gt;&gt; command &gt;&gt; uid &gt;&gt; nickname;

    if (command == &quot;Enter&quot; || command == &quot;Leave&quot;)
        message.push_back({uid, command});
    if (command != &quot;Leave&quot;)
        matchMap[uid] = nickname;
}</code></pre>
<p><code>stringstream</code>은 공백/개행 기준으로 토큰을 자동 분리해준다.
직접 flag를 관리하는 파싱보다 짧고 의도가 명확하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - 증강(Augment) 시스템 구현 정리]]></title>
            <link>https://velog.io/@kyu_/TIL-%EC%A6%9D%EA%B0%95Augment-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@kyu_/TIL-%EC%A6%9D%EA%B0%95Augment-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Tue, 02 Jun 2026 12:02:33 GMT</pubDate>
            <description><![CDATA[<h1 id="til---증강augment-시스템-구현-정리">TIL - 증강(Augment) 시스템 구현 정리</h1>
<p>오늘은 인런 중에 증강 카드를 제시하고, 플레이어가 하나를 골라 캐릭터에 실제 효과를 적용하는 흐름을 전체적으로 붙였다.
처음에는 단순히 &quot;카드 3장 띄우고 하나 선택&quot; 정도로 생각했는데, 막상 구현하다 보니 데이터 정의, 추첨 로직, 적용 방식, 입력 처리, UI 반영까지 전부 이어져 있어야 자연스럽게 동작했다.
그래서 이번 작업은 기능 하나를 추가했다기보다는, 증강 시스템의 <strong>기본 뼈대</strong>를 세운 작업에 가까웠다.</p>
<h2 id="이번에-구현한-흐름">이번에 구현한 흐름</h2>
<p>전체 흐름은 다음처럼 이어진다.</p>
<ol>
<li>서버나 디버그 입력을 통해 증강 오퍼를 큐에 넣는다.</li>
<li>증강 선택 컴포넌트가 풀과 희귀도 가중치를 바탕으로 오퍼를 구성한다.</li>
<li>UI에서 카드 3장을 보여주고, 플레이어는 입력으로 선택하거나 리롤할 수 있다.</li>
<li>선택된 증강은 인벤토리 컴포넌트로 넘어가고, 종류에 따라 GE 또는 GA 형태로 적용된다.</li>
<li>HUD와 증강 패널은 현재 보유 중인 증강 상태를 계속 보여준다.</li>
</ol>
<p>예전에는 증강 관련 입력을 별도 IMC로 스위칭하는 방식이었는데, 이번에는 그 구조를 걷어내고 gameplay 입력 위에 자연스럽게 얹히는 비차단 오버레이 방식으로 바꿨다.
덕분에 &quot;증강 UI를 열었기 때문에 입력 체계를 갈아끼운다&quot;는 느낌보다, 게임 진행 중 필요한 순간에 UI가 올라오는 구조에 더 가까워졌다.</p>
<h2 id="데이터-쪽에서-정리한-내용">데이터 쪽에서 정리한 내용</h2>
<p>이번 작업에서 먼저 잡은 것은 증강을 어떤 형태로 정의하고 뽑을지에 대한 데이터 구조였다.
단순히 카드 이름만 두는 수준이 아니라, 증강의 실제 적용 방식과 추첨 풀의 역할을 분리해서 설계했다.</p>
<ul>
<li><code>NSAugmentTypes</code>: 증강 시스템 전반에서 공통으로 사용하는 타입/분류 성격의 기반</li>
<li><code>NSAugmentDefinition</code>: 개별 증강 하나를 설명하는 정의 데이터, 실제 카드 단위</li>
<li><code>NSAugmentPoolDefinition</code>: 어떤 증강들이 어떤 규칙으로 추첨될지 관리하는 풀 데이터</li>
</ul>
<p><code>NSAugmentPoolDefinition</code>에서는 풀 태그로 이 풀이 일반 풀인지, 고등급 풀인지 구분할 수 있도록 했다.
또 <code>Rarity Weights</code>로 희귀도별 등장 확률을 조정할 수 있게 해서, 같은 시스템 안에서도 상황에 따라 다른 체감이 나오도록 만들었다.
<code>Entries</code>에는 실제 증강 Definition들을 넣고, <code>Legendary Stat Entries</code>에는 레전더리 기믹 변경 제한 이후에 등장할 수 있는 스택형 레전더리 수치 증강을 따로 넣는 구조로 정리했다.</p>
<p>이렇게 분리해 두니까 나중에 &quot;어떤 상황에서 어떤 증강을 보여줄지&quot;를 코드에서 하드코딩하지 않고 데이터로 제어하기 쉬워졌다.
특히 라이브 밸런싱이나 기획 변경이 들어왔을 때 대응하기 훨씬 편해질 것 같다.</p>
<h2 id="적용-방식">적용 방식</h2>
<p>증강을 실제로 보유하고 효과를 적용하는 책임은 PlayerState에 붙인 증강 인벤토리 컴포넌트가 맡도록 했다.
이 컴포넌트는 &quot;플레이어가 지금 어떤 증강을 가지고 있는지&quot;를 관리하면서, 선택된 증강을 실제 Ability System 쪽 효과로 변환해 붙여주는 역할을 한다.</p>
<p>적용 방식은 증강 종류에 따라 나뉜다.</p>
<ul>
<li>수치 강화형 증강은 <code>StackEffectClass</code>를 사용해서 GE를 적용한다.</li>
<li>이때 스택 값은 SetByCaller로 넘겨서 같은 계열의 누적 강화가 가능하게 했다.</li>
<li>레전더리 기믹형 증강은 <code>GrantedAbilityClass</code>를 사용해서 GA를 부여한다.</li>
<li>인런이 끝나면 <code>ClearAll</code>로 한 번에 정리되도록 했다.</li>
</ul>
<p>즉, &quot;능력 자체가 추가되는 증강&quot;과 &quot;기존 능력치를 누적해서 강화하는 증강&quot;을 적용 레벨에서 분리한 셈이다.
이 구분을 해두니 레전더리처럼 구조가 큰 증강과 일반 수치형 증강을 같은 시스템 안에서 무리 없이 다룰 수 있었다.</p>
<h2 id="오퍼-생성과-리롤">오퍼 생성과 리롤</h2>
<p>증강 선택 컴포넌트는 플레이어에게 지금 어떤 카드 3장을 보여줄지 결정하는 쪽에 집중했다.
단순 랜덤이 아니라 희귀도 가중치, 풀 구성, 현재 쌓여 있는 대기 오퍼 수까지 고려해서 오퍼를 적재하는 구조다.</p>
<p>핵심은 <code>PoolQueue</code>와 <code>PendingCount</code>를 기반으로 오퍼를 관리하는 점이다.
그래서 한 번에 다 띄우는 방식이 아니라, 이벤트가 여러 번 발생하면 오퍼가 순서대로 쌓였다가 플레이어가 하나씩 처리할 수 있다.
리롤도 같은 흐름 안에 넣어서, 현재 제시된 카드 구성을 다시 뽑도록 연결했다.</p>
<p>이번 구현으로 플레이어 입장에서는 &quot;증강 기회가 들어오면 대기열에 쌓이고, 필요할 때 확인하고, 마음에 안 들면 다시 뽑을 수 있는&quot; 흐름이 만들어졌다.
단순한 팝업보다 실제 게임 시스템처럼 느껴지게 된 부분이다.</p>
<h2 id="입력-처리-방식-변경">입력 처리 방식 변경</h2>
<p>입력 쪽도 이번에 꽤 크게 바뀌었다.
기존에는 증강 UI 전용 IMC로 전환하는 식이었는데, 이 방식은 순간적으로 맥락이 끊기거나 입력 체계가 갈라지는 느낌이 있었다.
그래서 이번에는 기존 증강 IMC 스위칭을 없애고, Gameplay IMC에 증강 관련 입력을 직접 연결했다.</p>
<p>연결한 입력은 다음과 같다.</p>
<ul>
<li>증강 카드 선택: 1 / 2 / 3</li>
<li>리롤: T</li>
<li>패널 토글: Tab</li>
<li>디버그 오퍼 생성: O</li>
</ul>
<p>이 구조의 장점은 입력 흐름이 더 단순해졌다는 점이다.
UI가 떠 있어도 &quot;지금 입력 컨텍스트가 바뀌었는지&quot;를 따로 추적할 필요가 줄었고, 필요한 경우 UI 열림 여부만 보고 게이팅하면 된다.
특히 <code>IsAugmentationPanelOpen()</code> 같은 함수가 추가되면서 InputBinder 쪽 제어도 더 명확해졌다.</p>
<h2 id="ui에서-바뀐-점">UI에서 바뀐 점</h2>
<p>이번 작업은 로직만 붙인 것이 아니라, 플레이어가 증강 시스템을 실제로 체감할 수 있도록 UI도 함께 정리했다.
증강 카드 위젯, Augmentation 패널, HUD의 보유 증강 아이콘 표시까지 전체적으로 이어 붙였다.</p>
<p>눈에 띄는 변화는 두 가지였다.</p>
<ul>
<li>오퍼가 들어오면 카드 영역만 따로 보여줄 수 있게 했다.</li>
<li>패널 전체와 카드 섹션을 분리해서, 항상 같은 방식으로 화면을 덮지 않도록 했다.</li>
</ul>
<p>이 덕분에 &quot;증강 선택 UI&quot;와 &quot;보유 증강을 확인하는 패널&quot;의 역할이 조금 더 명확해졌다.
카드는 이벤트성 UI에 가깝고, 패널은 현재 상태를 확인하는 UI에 가깝게 분리된 셈이다.</p>
<p>보유 증강 아이콘도 표시 방식이 조금 달라졌다.
<code>NSAugmentationWidget.cpp</code>의 <code>OnOwnedIconsLoaded()</code>에서는 아이콘을 WrapBox에 바로 넣지 않고 <code>USizeBox</code>로 한 번 감싸서 추가하도록 했다.
그래서 원본 텍스처 해상도와 관계없이 항상 일정한 크기로 보이도록 강제할 수 있고, 기본값은 <code>OwnedIconSize</code> 48x48이며 BP별로 <code>EditDefaultsOnly</code>에서 조정 가능하다.</p>
<p>개인적으로는 이 부분이 꽤 중요했다.
아이콘 에셋마다 원본 크기가 다르면 HUD가 쉽게 지저분해지는데, 표시 크기를 강제로 고정해 두면 데이터가 조금 들쭉날쭉해도 화면 품질은 안정적으로 유지되기 때문이다.</p>
<h2 id="아직-연결하지-않은-부분">아직 연결하지 않은 부분</h2>
<p>현재 증강 시스템의 내부 뼈대와 UI 흐름은 붙어 있지만, 실제 게임 이벤트와의 연결은 아직 후속 작업으로 남아 있다.
즉, &quot;증강 시스템 자체는 동작하지만, 언제 이 오퍼를 띄울지&quot;에 대한 실전 트리거는 아직 다 연결된 상태는 아니다.</p>
<p>아직 미연결인 부분은 다음과 같다.</p>
<ul>
<li>레벨업, 엘리트 처치, 보스 처치 시 서버에서 <code>EnqueueOffer</code> 호출</li>
<li>보물상자 상호작용 시 <code>Server_EnqueueOffer</code> 호출</li>
</ul>
<p>지금은 인런 환경에서만 <code>O</code> 키 디버그 함수 <code>Debug_EnqueueAugmentOffer</code>로 오퍼를 띄워 테스트하고 있다.
대신 이 디버그 루트 덕분에 GE 적용, GA 부여, 큐 적재, 리롤, UI 반영까지 핵심 흐름 자체는 먼저 검증할 수 있었다.</p>
<h2 id="에셋-연결-시-주의한-점">에셋 연결 시 주의한 점</h2>
<p>이번 시스템은 코드만 넣는다고 완전히 동작하는 구조는 아니다.
증강 Definition 데이터 에셋에서 실제로 어떤 GE와 GA를 연결할지 에디터에서 지정해 줘야 최종 동작이 완성된다.</p>
<p>정리하면 다음 기준으로 연결하면 된다.</p>
<ul>
<li>기믹형 증강: <code>GrantedAbilityClass</code>만 사용</li>
<li>수치 강화형 증강(C/R/E 및 Legendary 수치형): <code>StackEffectClass</code>만 사용</li>
</ul>
<p>즉, 데이터 에셋 설계와 에디터 세팅이 런타임 로직만큼 중요하다.
이 부분이 빠지면 코드상으로는 선택이 끝나도 실제 효과가 안 붙기 때문에, 테스트할 때 가장 먼저 확인해야 할 포인트 중 하나다.</p>
<h2 id="ui-함수-변경-사항을-조금-더-자연스럽게-정리하면">UI 함수 변경 사항을 조금 더 자연스럽게 정리하면</h2>
<p>이번에는 UI 함수 이름도 전반적으로 손봤다.
기존 이름은 단순히 Show/Hide 중심이라 &quot;정말 패널을 여는 것인지&quot;, &quot;카드 섹션만 다루는 것인지&quot;, &quot;리롤 요청을 어디로 전달하는 것인지&quot;가 덜 드러나는 편이었다.
그래서 함수 이름을 역할 중심으로 정리했다.</p>
<h3 id="nsuimanagersubsystem">NSUIManagerSubsystem</h3>
<table>
<thead>
<tr>
<th>변경 전</th>
<th>변경 후</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>ShowAugmentation()</code></td>
<td><code>OpenAugmentationPanel()</code></td>
<td>증강 패널을 연다. 중복 오픈 방지 플래그도 함께 관리한다.</td>
</tr>
<tr>
<td><code>HideAugmentation()</code></td>
<td><code>CloseAugmentationPanel()</code></td>
<td>증강 패널을 닫는다.</td>
</tr>
<tr>
<td>-</td>
<td><code>IsAugmentationPanelOpen()</code></td>
<td>패널이 현재 열려 있는지 확인한다. 입력 게이팅에 사용한다.</td>
</tr>
<tr>
<td>-</td>
<td><code>RequestRerollAugment()</code></td>
<td>리롤 요청을 HUDWidget 쪽으로 전달한다.</td>
</tr>
</tbody></table>
<h3 id="nshudwidget">NSHUDWidget</h3>
<table>
<thead>
<tr>
<th>변경 전</th>
<th>변경 후</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>ShowAugmentation()</code></td>
<td><code>OpenAugmentationPanel()</code></td>
<td>내부적으로 AugmentationWidget의 <code>OpenPanel()</code> 호출로 연결한다.</td>
</tr>
<tr>
<td><code>HideAugmentation()</code></td>
<td><code>CloseAugmentationPanel()</code></td>
<td>내부적으로 <code>ClosePanel()</code> 호출로 연결한다.</td>
</tr>
<tr>
<td>-</td>
<td><code>RequestRerollAugment()</code></td>
<td>AugmentationWidget의 리롤 요청 함수로 전달한다.</td>
</tr>
</tbody></table>
<h3 id="nsaugmentationwidget">NSAugmentationWidget</h3>
<table>
<thead>
<tr>
<th>변경 전</th>
<th>변경 후</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>ShowAugmentation()</code></td>
<td><code>OpenPanel()</code></td>
<td>패널을 보여주고, 보유 증강 아이콘도 함께 갱신한다.</td>
</tr>
<tr>
<td><code>HideAugmentation()</code></td>
<td><code>ClosePanel()</code></td>
<td>패널을 숨기고, 진행 중이던 비동기 로드도 취소한다.</td>
</tr>
<tr>
<td><code>NativeOnKeyDown()</code></td>
<td>제거</td>
<td>Enhanced Input으로 전환하면서 더 이상 필요 없어졌다.</td>
</tr>
<tr>
<td>-</td>
<td><code>ShowCardSection()</code> / <code>HideCardSection()</code></td>
<td>패널 전체가 아니라 카드 선택 영역만 따로 제어한다.</td>
</tr>
<tr>
<td>-</td>
<td><code>HandleOfferPresented()</code></td>
<td>오퍼가 들어오면 카드 생성과 아이콘 비동기 로드를 시작한다.</td>
</tr>
<tr>
<td>-</td>
<td><code>HandleOfferClosed()</code></td>
<td>오퍼가 끝났을 때 카드 영역을 숨긴다.</td>
</tr>
<tr>
<td>-</td>
<td><code>HandlePendingCountChanged()</code></td>
<td>대기 중인 오퍼 개수 뱃지를 갱신한다.</td>
</tr>
<tr>
<td>-</td>
<td><code>HandleInventoryChanged()</code></td>
<td>보유 증강 변경 시 아이콘 목록을 다시 그린다.</td>
</tr>
<tr>
<td>-</td>
<td><code>PopulateOfferCards()</code></td>
<td>오퍼 ID를 Definition으로 바꿔 카드 이름, 설명, 아이콘을 채운다.</td>
</tr>
<tr>
<td>-</td>
<td><code>OnIconsLoaded()</code> / <code>OnOwnedIconsLoaded()</code></td>
<td>비동기 아이콘 로드 완료 후 실제 UI에 반영한다.</td>
</tr>
<tr>
<td>-</td>
<td><code>NativeDestruct()</code></td>
<td>비동기 핸들 취소와 델리게이트 구독 해제를 맡는다.</td>
</tr>
<tr>
<td><code>ConfirmAugmentSelection()</code></td>
<td>실제 연결 예정</td>
<td>최종적으로 <code>Server_Choose()</code> 호출로 이어지도록 바꿀 예정이다.</td>
</tr>
<tr>
<td><code>RequestRerollAugment()</code></td>
<td>실제 연결 예정</td>
<td>최종적으로 <code>Server_RerollCard()</code> 호출로 이어지도록 바꿀 예정이다.</td>
</tr>
</tbody></table>
<h3 id="nsaugmentcardwidget">NSAugmentCardWidget</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>함수</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>신규</td>
<td><code>SetAugmentIcon()</code></td>
<td>증강 아이콘 텍스처를 세팅하고, 아이콘이 없으면 <code>Collapsed</code> 처리한다.</td>
</tr>
</tbody></table>
<p>이렇게 이름을 바꿔 두니 함수만 봐도 역할이 더 잘 읽힌다.
특히 UI 코드는 나중에 다시 봤을 때 &quot;이 함수가 화면 전체를 여는 건지, 일부 섹션만 다루는 건지&quot;가 안 보이면 유지보수가 힘든데, 이번 정리는 그런 혼란을 줄이는 데 의미가 있었다.</p>
<h2 id="테스트하면서-확인한-것">테스트하면서 확인한 것</h2>
<p>현재 테스트 기준으로는 다음 항목들을 확인했다.</p>
<ul>
<li>인런에서 <code>O</code> 키를 누르면 증강 선택 UI를 띄울 수 있다.</li>
<li><code>O</code>를 여러 번 누르면 오퍼가 큐에 순서대로 적재된다.</li>
<li>왼쪽 위 UI에서 적재 수를 확인할 수 있다.</li>
<li><code>Tab</code>으로 증강 창을 토글하고, 보유 중인 증강 아이콘을 확인할 수 있다.</li>
<li>같은 종류의 증강은 중복 추가되지 않는다.</li>
<li><code>T</code> 키로 리롤이 가능하다.</li>
<li>GE 적용과 GA 부여가 정상 동작하는 것까지 확인했다.</li>
</ul>
<p>아직 실전 트리거는 덜 붙었지만, &quot;오퍼 생성 → 선택 → 적용 → UI 반영&quot;이라는 핵심 루프는 이제 테스트 가능한 상태까지 올라왔다.
그래서 다음 작업은 시스템을 새로 만드는 단계보다는, 실제 게임 이벤트와 자연스럽게 연결하는 단계가 될 것 같다.</p>
<h2 id="작업하면서-느낀-점">작업하면서 느낀 점</h2>
<p>이번 작업은 카드 UI 하나 만든 느낌보다, 인런 보상 시스템의 기반을 깔아 둔 작업에 더 가까웠다.
데이터 정의, 추첨 로직, 적용 책임, 입력, UI가 다 따로 놀지 않고 한 흐름으로 이어지게 만드는 게 핵심이었다.</p>
<p>특히 좋았던 점은 적용 방식을 GE와 GA로 분리한 부분, 그리고 입력을 별도 IMC 전환이 아니라 오버레이 방식으로 정리한 부분이다.
이 두 군데를 먼저 정리해 둔 덕분에 이후 레벨업 보상이나 보스 보상 같은 실제 게임 이벤트를 붙일 때도 구조가 크게 흔들리지 않을 것 같다.</p>
<p>나중에 실제로 적용할때는 실제 인런 이벤트와 <code>EnqueueOffer</code> 연결, 서버 권한 흐름 점검, 그리고 카드 선택/리롤의 최종 RPC 연결까지 마무리하면 증강 시스템이 훨씬 완성도 있게 자리잡을 것 같다.</p>
<h1 id="퀴즈">퀴즈</h1>
<ul>
<li>Overlap과 Block충돌 이벤트의 차이점 : Overlap은 두 오브젝트가 서로 겹쳐도 물리적으로 통과하며, Block은 두 오브젝트가 막혀서 서로 통과할 수 없다.</li>
<li>다형성은 런타임에 동적으로 메서드 호출이 결정되는 특징이 있다.</li>
</ul>
<h1 id="알고리즘">알고리즘</h1>
<h2 id="3차-파일명정렬">[3차] 파일명정렬</h2>
<ul>
<li>문제
<a href="https://school.programmers.co.kr/learn/courses/30/lessons/17686?language=cpp">https://school.programmers.co.kr/learn/courses/30/lessons/17686?language=cpp</a></li>
<li>내 풀이
<a href="https://github.com/JongKyuHong/NBC_TIL/blob/main/%EC%BD%94%EB%93%9C%EC%B9%B4%ED%83%80/28%EC%A3%BC%EC%B0%A8/%ED%99%94/%5B3%EC%B0%A8%5D%ED%8C%8C%EC%9D%BC%EB%AA%85%EC%A0%95%EB%A0%AC.cpp">https://github.com/JongKyuHong/NBC_TIL/blob/main/%EC%BD%94%EB%93%9C%EC%B9%B4%ED%83%80/28%EC%A3%BC%EC%B0%A8/%ED%99%94/%5B3%EC%B0%A8%5D%ED%8C%8C%EC%9D%BC%EB%AA%85%EC%A0%95%EB%A0%AC.cpp</a></li>
</ul>
<p>stable_sort라는 안정 정렬이 필요했다. 그냥 sort는 불완전 정렬이라서 순서 보장이 안될수가 있다. 그래서 순서가 보장되어야 하는 지금같은 문제에서는 stable_sort를 쓰자, 그리고 sort함수안에서 모든 조건을 다 if문으로 분기할 수 있는게 아니면 끝에 return false를 반드시 붙여주자</p>
]]></description>
        </item>
    </channel>
</rss>