<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>개발자 김선호</title>
        <link>https://velog.io/</link>
        <description>프로젝트 진행 과정을 주로 업로드합니다</description>
        <lastBuildDate>Tue, 20 Jan 2026 05:56:49 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>개발자 김선호</title>
            <url>https://velog.velcdn.com/images/dev_sensational/profile/35263486-8437-4a4e-9457-f039e02b5413/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 개발자 김선호. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dev_sensational" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Project Arc] 프로젝트 종료]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A2%85%EB%A3%8C</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A2%85%EB%A3%8C</guid>
            <pubDate>Tue, 20 Jan 2026 05:56:49 GMT</pubDate>
            <description><![CDATA[<p>2025년 10월 28일 부터 시작되어 1월 12일을 끝으로, 2개월 이상 진행한 Project Arc가 종료되었습니다. </p>
<p>완성도와 결과만 놓고 보면 아쉬운 점도 분명 존재하지만, 그럼에도 불구하고 개인적으로는 최선을 다해 임했다고 말할 수 있는 프로젝트였습니다.</p>
<hr>
<h3 id="내가-맡았던-역할과-초기-방향성">내가 맡았던 역할과 초기 방향성</h3>
<p>프로젝트 초반, Project Arc는 <strong>PvPvE 기반의 게임</strong>을 목표로 기획되었고, 저는 다음과 같은 역할을 중심으로 참여할 예정이었습니다.</p>
<ul>
<li>절차적 맵 생성 시스템</li>
<li>반복 플레이를 고려한 시스템 단위 설계</li>
<li>전투 외 콘텐츠 확장을 위한 기반 기능 구현</li>
</ul>
<p>특히 <strong>절차적 맵 생성</strong>은 프로젝트의 핵심 차별점 중 하나로 논의되었고, 저 역시 해당 기능을 중심으로 기술적 기여를 준비하고 있었습니다.</p>
<hr>
<h3 id="기획-변경과-역할-축소">기획 변경과 역할 축소</h3>
<p>그러나 프로젝트 중반, 게임의 방향성이 <strong>PvPvE → 내러티브 중심 PvE</strong>로 크게 변경되면서 상황이 달라졌습니다.</p>
<p>이 변화로 인해:</p>
<ul>
<li>절차적 맵 생성은 더 이상 프로젝트 방향성과 맞지 않게 되었고</li>
<li>시스템 중심의 확장 기능보다는
고정된 레벨과 연출, 스토리 전달이 우선시되었습니다</li>
</ul>
<p>그 결과, 제가 초기에 준비하고 있던 역할과 기여 범위가 축소되어 많은 아쉬움이 남았습니다.</p>
<hr>
<h3 id="구현했지만-사용되지-못한-기능들">구현했지만 사용되지 못한 기능들</h3>
<p>프로젝트 기간의 압박 또한 아쉬움으로 남았습니다.</p>
<ul>
<li>NPC 기반 상호작용 시스템</li>
<li>상점(Shop) 기능</li>
</ul>
<p>위 기능들은 실제로 <strong>구현까지 완료</strong>했지만, 전체 일정과 콘텐츠 우선순위 문제로 인해 최종 빌드에는 포함되지 못했습니다.</p>
<p>기능 자체보다는,</p>
<blockquote>
<p><em>“만들었지만 쓰이지 못했다”</em>
는 점이 개인적으로 가장 아쉬웠던 부분이었습니다.</p>
</blockquote>
<hr>
<h3 id="그럼에도-불구하고-얻은-것들">그럼에도 불구하고 얻은 것들</h3>
<p>다만, 이 프로젝트가 <strong>실패나 손해만 남긴 경험</strong>이었다고 생각하지는 않습니다.</p>
<p>오히려 다음과 같은 점에서 의미 있는 경험이었습니다.</p>
<ul>
<li>한 프로젝트 안에서 <strong>NPC, 상점, 대화, 시스템 구조 설계</strong> 등 다양한 영역을 직접 구현해볼 수 있었던 점</li>
<li>기획 변경이라는 현실적인 상황 속에서 개발자가 어떻게 대응해야 하는지를 체감한 점</li>
<li>“내가 구현한 기능이 왜 필요 없어졌는지”를 감정이 아닌 <strong>기획적 관점</strong>에서 이해하려 노력했던 경험</li>
</ul>
<p>이 경험들은 단기적인 결과보다,
<strong>미래의 나에게 분명히 도움이 될 자산</strong>이라고 생각합니다.</p>
<hr>
<h3 id="향후-개선할-수-있었던-점">향후 개선할 수 있었던 점</h3>
<p>회고를 통해 정리해본, 다음 프로젝트에서 반드시 개선하고 싶은 부분은 다음과 같습니다.</p>
<ol>
<li><strong>기획 변경 리스크를 고려한 역할 설계</strong><ul>
<li>특정 기능 하나에 과도하게 의존하기보다는 변경에 대응 가능한 범용적인 기여 구조를 가져갈 필요성을 느꼈습니다.</li>
</ul>
</li>
</ol>
<ol start="2">
<li><strong>조기 통합 및 우선순위 협의</strong><ul>
<li>“나중에 쓰일 기능”이 아니라 <em>“지금 당장 프로젝트에 들어갈 기능”</em> 위주로
팀과 더 적극적으로 조율했어야 했다고 생각합니다.</li>
</ul>
</li>
</ol>
<ol start="3">
<li><strong>기술 구현의 목적 명확화</strong><ul>
<li>단순히 구현하는 것에서 끝나는 것이 아니라 “이 기능이 프로젝트에 어떤 가치를 주는가”를 더 명확히 설명하고 공유했어야 했습니다.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="마치며">마치며</h3>
<p>Project Arc는 결과만 놓고 보면 아쉬움이 남는 프로젝트였지만, 과정만큼은 분명히 <strong>성장으로 이어진 시간</strong>이었습니다.</p>
<p>기획은 언제든 바뀔 수 있고, 개발자의 역할 역시 그에 따라 변할 수 있다는 현실 속에서 <strong>유연하게 사고하고, 다음을 준비하는 태도</strong>의 중요성을 배웠습니다.</p>
<p>이 프로젝트에서의 경험과 시행착오는 다음 프로젝트에서 더 나은 선택을 하기 위한 밑거름이 될 것이라 믿습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] Seamless Travel 시 ISM과 Raytracing 충돌 문제 해결]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-Seamless-Travel-%EC%8B%9C-ISM%EA%B3%BC-Raytracing-%EC%B6%A9%EB%8F%8C-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-Seamless-Travel-%EC%8B%9C-ISM%EA%B3%BC-Raytracing-%EC%B6%A9%EB%8F%8C-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Mon, 12 Jan 2026 07:13:29 GMT</pubDate>
            <description><![CDATA[<p>프로젝트에 레이트레이싱을 적용한 뒤, <strong>심리스 트래블 시 Instanced Static Mesh(ISM) 때문에 발생하던 크래시</strong>를 추적하고 해결한 과정을 정리해 두려고 합니다. 동일한 구조의 네트워크/멀티플레이 프로젝트에서 레이트레이싱을 켜면 비슷한 문제를 겪을 수 있기 때문에, 원인과 대응 패턴을 기억해 두면 좋겠다는 생각이 들었습니다.</p>
<hr>
<h2 id="문제-상황-정리">문제 상황 정리</h2>
<ul>
<li>증상<ul>
<li>레이트레이싱 옵션을 활성화한 상태에서 <strong>심리스 트래블(SeamlessTravel)</strong> 을 수행하면 간헐적으로 게임이 크래시.</li>
<li>에디터 PIE나 레이트레이싱 OFF 환경에서는 잘 동작.</li>
</ul>
</li>
</ul>
<ul>
<li>크래시 로그 핵심 부분 (요약)<ul>
<li>Assertion 실패:<ul>
<li><code>Array index out of bounds: 1 into an array of size 1</code></li>
</ul>
</li>
<li>콜스택 상 중요한 함수:<ul>
<li><code>FInstancedStaticMeshSceneProxy::GetDynamicRayTracingInstances()</code></li>
<li><code>RayTracing::FDynamicRayTracingInstancesContext::GatherDynamicRayTracingInstances_Internal()</code></li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>로그 일부:</li>
</ul>
<pre><code>```text
Assertion failed: (Index &gt;= 0) &amp; (Index &lt; ArrayNum)
[File:...Array.h] [Line: 1067]
Array index out of bounds: 1 into an array of size 1
...
FInstancedStaticMeshSceneProxy::GetDynamicRayTracingInstances()
RayTracing::FDynamicRayTracingInstancesContext::GatherDynamicRayTracingInstances_Internal()
Crash in runnable thread Foreground Worker #1
```</code></pre><ul>
<li>로그에서 볼 수 있는 사실<ul>
<li>크래시가 <strong>레이트레이싱용 인스턴스 수집 과정</strong>(<code>GatherDynamicRayTracingInstances</code>)에서 발생.</li>
<li>특히 <code>FInstancedStaticMeshSceneProxy::GetDynamicRayTracingInstances()</code> 내부에서 ISM 관련 배열 접근 시 <strong>인덱스 범위가 꼬인 상태</strong>.</li>
<li>즉, <strong>InstancedStaticMesh 의 내부 상태(인스턴스 배열)가 심리스 트래블 전후 과정에서 깨졌을 가능성</strong>이 매우 높음.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<ul>
<li>전제<ul>
<li>InstancedStaticMeshComponent(ISM)는 <strong>각 월드(서버/클라)</strong> 에 존재.</li>
<li>레이트레이싱은 각 클라이언트의 <strong>로컬 월드</strong> 안에 있는 ISM/StaticMesh 를 기준으로 씬을 빌드.</li>
</ul>
</li>
</ul>
<ul>
<li>추정 원인<ul>
<li>심리스 트래블 시, 이전 월드에서 사용하던 ISM 인스턴스들이 <strong>완전히 정리되지 않은 상태</strong>에서<ul>
<li>월드가 전환되거나, 레이트레이싱 씬이 갱신되면서</li>
<li>내부 <code>PerInstanceRenderData</code> / 인스턴스 배열 크기 정보가 어긋난 상태로 참조되는 상황.</li>
</ul>
</li>
<li>그 결과, <code>FInstancedStaticMeshSceneProxy::GetDynamicRayTracingInstances()</code> 가<ul>
<li><code>ArrayNum == 1</code> 인 배열을 <code>Index == 1</code>로 접근하는 식의 <strong>out-of-bounds 접근</strong>을 시도하다가 Assert 에 걸림.</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>왜 심리스 트래블에서만 티가 났는지<ul>
<li>일반적인 레벨 로드(OpenLevel)에서는 월드와 렌더링 관련 리소스가 비교적 깨끗하게 재생성/파괴됨.</li>
<li>심리스 트래블은 <strong>플레이어 관련 객체(Controller, PlayerState 등)를 유지한 채 월드만 교체</strong>하기 때문에<ul>
<li>일부 렌더링/ISM 관련 리소스가 <strong>월드 전환 타이밍에 완전히 정리되지 않고 남는</strong> 경우가 발생할 수 있음.</li>
</ul>
</li>
<li>특히 레이트레이싱은 별도의 동적 인스턴스 수집/관리 경로를 타기 때문에, 이런 내부 상태 꼬임이 Assert 로 바로 드러남.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="해결-전략">해결 전략</h2>
<ul>
<li>목표<ul>
<li><strong>심리스 트래블 직전/직후에 각 월드의 ISM 상태를 확실하게 정리</strong>해서,</li>
<li>레이트레이싱용 인스턴스 수집 시 깨진 데이터를 참조하지 않도록 만들기.</li>
</ul>
</li>
</ul>
<ul>
<li>고려한 방향들<ol>
<li>ISM 을 소유한 액터의 <code>EndPlay</code> / <code>BeginDestroy</code> 에서 개별적으로 정리<ul>
<li>가장 이상적인 구조지만, 현재 프로젝트 규모 상 모든 ISM 사용처를 일일이 추적하기에는 비용이 큼.</li>
</ul>
</li>
<li>월드 단위로 ISM 을 한 번에 정리하는 세이프가드 추가<ul>
<li>심리스 트래블 직후, <strong>각 클라이언트의 PlayerController가 자신이 속한 월드에서 ISM을 한 번 전체 스캔하여 인스턴스를 비우는 방식</strong>.</li>
</ul>
</li>
</ol>
</li>
</ul>
<ul>
<li>최종 선택<ul>
<li><strong>2번 방식</strong>을 우선 적용:<ul>
<li><code>ACMPlayerController::PostSeamlessTravel()</code> 을 오버라이드.</li>
<li>로컬 컨트롤러 기준으로 월드의 모든 <code>UInstancedStaticMeshComponent</code> 를 순회하며 <code>ClearInstances()</code> 호출.</li>
<li>이 작업 이후, 기존에 구현해둔 <strong>캐릭터 재스폰 + 화면/사운드 페이드 인</strong> 로직을 그대로 이어서 실행.</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="구현-상세">구현 상세</h2>
<h3 id="1-playercontroller-에서-postseamlesstravel-오버라이드">1. PlayerController 에서 PostSeamlessTravel 오버라이드</h3>
<ul>
<li>파일 위치<ul>
<li><code>Source/CrimsonMoon/Public/Controllers/CMPlayerController.h</code></li>
<li><code>Source/CrimsonMoon/Private/Controllers/CMPlayerController.cpp</code></li>
</ul>
</li>
</ul>
<ul>
<li><p>헤더에 오버라이드 선언 (이미 추가되어 있음)</p>
<ul>
<li><p><code>CMPlayerController.h</code> (클래스 내부):</p>
<ul>
<li><code>virtual void PostSeamlessTravel() override;</code></li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>cpp 에 구현 (주요 부분만 발췌)</li>
</ul>
<blockquote>
<p>역할: </p>
<ul>
<li>심리스 트래블 완료 후, <strong>로컬 월드의 모든 ISM 인스턴스를 정리</strong></li>
<li>그 다음, 캐릭터 재스폰 및 화면/사운드 페이드 인</li>
</ul>
</blockquote>
<pre><code class="language-cpp">#include &quot;Components/InstancedStaticMeshComponent.h&quot;
#include &quot;EngineUtils.h&quot; // TActorIterator

void ACMPlayerController::PostSeamlessTravel()
{
    Super::PostSeamlessTravel();

    // 로컬 컨트롤러가 아닌 경우 아무 것도 하지 않음 (서버 전용 PC 등 제외)
    if (!IsLocalController())
    {
        return;
    }

    // 1) 로컬 월드의 모든 InstancedStaticMeshComponent 정리
    if (UWorld* World = GetWorld())
    {
        for (TActorIterator&lt;AActor&gt; It(World); It; ++It)
        {
            AActor* Actor = *It;
            if (!IsValid(Actor))
            {
                continue;
            }

            TArray&lt;UInstancedStaticMeshComponent*&gt; ISMComponents;
            Actor-&gt;GetComponents&lt;UInstancedStaticMeshComponent&gt;(ISMComponents);

            for (UInstancedStaticMeshComponent* ISMComp : ISMComponents)
            {
                if (!ISMComp)
                {
                    continue;
                }

                // 레이트레이싱/충돌 문제를 방지하기 위해 모든 인스턴스를 제거
                ISMComp-&gt;ClearInstances();
                ISMComp-&gt;MarkRenderStateDirty();
            }
        }
    }

    // 2) 심리스 트래블 후 캐릭터 재스폰 요청 (기존 로직)
    NotifyServerPlayerReadyWithCharacter();

    // 3) 화면/사운드 페이드 인 (검정 → 정상, 기존 연출)
    if (PlayerCameraManager)
    {
        PlayerCameraManager-&gt;StartCameraFade(
            /*FromAlpha*/ 1.0f,
            /*ToAlpha*/   0.0f,
            /*Duration*/  1.0f,
            /*Color*/     FLinearColor::Black,
            /*bShouldFadeAudio*/ true,
            /*bHoldWhenFinished*/ false
        );
    }
}</code></pre>
<h3 id="2-기존-연출로직과의-연계">2. 기존 연출/로직과의 연계</h3>
<ul>
<li>이미 <code>ACMPlayerController</code> 에서는 심리스 트래블 이후에 다음 작업을 하고 있었음<ul>
<li><code>NotifyServerPlayerReadyWithCharacter()</code><ul>
<li>서버에 선택된 캐릭터 태그를 보내고, 새 Pawn 스폰 요청.</li>
</ul>
</li>
<li><code>StartCameraFade(1 → 0, bShouldFadeAudio=true)</code><ul>
<li>심리스 트래블 이전에 화면/오디오를 페이드 아웃한 것에 대응하여,</li>
<li>새 맵에서 화면과 소리를 다시 자연스럽게 살리는 연출.</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>이번 수정에서는 이 두 기능을 유지한 채, 그 <strong>이전에 ISM 정리 로직을 끼워 넣는 형태</strong>로 구현.</li>
</ul>
<hr>
<h2 id="적용-결과-및-배운-점">적용 결과 및 배운 점</h2>
<p>이번 수정 이후, 레이트레이싱을 활성화한 빌드에서 심리스 트래블을 반복 테스트했을 때,
기존에 발생하던 <code>FInstancedStaticMeshSceneProxy::GetDynamicRayTracingInstances</code> 관련 Assert 크래시는 더 이상 재현되지 않았습니다.</p>
<p>물론 근본적으로는 <strong>ISM 을 소유하는 각 액터가 자신의 라이프사이클에 맞춰 적절히 정리되도록 설계하는 것</strong>이 가장 좋겠지만,
이번과 같이 레이트레이싱 + 심리스 트래블 조합에서 내부 상태 꼬임으로 인한 크래시가 발생할 때에는,</p>
<ul>
<li>월드 단위로 ISM 을 한 번 &quot;강제로&quot; 정리해 주는 세이프가드를 추가하여</li>
<li>RT/Physics 씬에 남아 있는 잘못된 인스턴스 데이터를 제거하는 것만으로도</li>
<li>실질적인 크래시 문제를 빠르게 완화할 수 있다는 점을 다시 확인하게 되었습니다.</li>
</ul>
<p>앞으로는 ISM 을 대량으로 사용하는 시스템을 설계할 때,</p>
<ul>
<li>멀티플레이/심리스 트래블,</li>
<li>레벨 스트리밍,</li>
<li>레이트레이싱/Physics 씬 빌드 타이밍
같은 요소까지 함께 고려해서 <strong>&quot;누가 언제 ISM 을 생성/정리할 것인가&quot;</strong> 를 처음부터 명확히 정리해 두는 것이 중요하다는 것을 배웠습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] 네트워크 환경에서 플레이어 카메라 Activate 문제 해결]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%94%8C%EB%A0%88%EC%9D%B4%EC%96%B4-%EC%B9%B4%EB%A9%94%EB%9D%BC-Activate-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%94%8C%EB%A0%88%EC%9D%B4%EC%96%B4-%EC%B9%B4%EB%A9%94%EB%9D%BC-Activate-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Wed, 07 Jan 2026 12:30:39 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_sensational/post/6c9f5fa7-c534-46a0-9baf-79f964759010/image.webp" alt=""></p>
<p>오늘은 언리얼 멀티플레이 환경에서 <strong>플레이어 카메라 시점이 보이지 않는 문제를 Activate로 해결한 과정</strong>과, 아직 남아 있는 <strong>카메라 회전(Input_Look) 문제</strong>를 원인 중심으로 정리하고자 합니다.</p>
<hr>
<h2 id="문제-상황-요약">문제 상황 요약</h2>
<ul>
<li>환경: UE5, 멀티플레이(Host + Client), C++ 기반 캐릭터/컨트롤러, BP 기반 GameplayCamera 사용</li>
<li>증상 1: Client 입장에서 플레이어 Pawn 은 스폰되고 Possess 도 되지만, <strong>카메라 시점이 비어 있거나 이상한 위치를 비춤</strong></li>
<li>증상 2: Host 에서는 정상 동작하는 것처럼 보이지만, <strong>Client 쪽에서만 카메라 관련 문제 발생</strong></li>
<li>추가 증상: 시점을 보이게 만드는 문제는 해결했지만, <strong>마우스 입력에 따른 카메라 상하좌우 회전은 아직 제대로 동작하지 않음</strong></li>
</ul>
<hr>
<h2 id="원인-분석-카메라-시점activate-문제">원인 분석: 카메라 시점(Activate) 문제</h2>
<h3 id="1-viewcamera-vs-gameplaycamera-사용-불일치">1. ViewCamera vs GameplayCamera 사용 불일치</h3>
<ul>
<li>C++ <code>ACMPlayerCharacterBase</code> 안에는 <code>ViewCamera(UCameraComponent)</code> 와 <code>CameraBoom(USpringArmComponent)</code> 가 존재<ul>
<li><code>CameraBoom-&gt;bUsePawnControlRotation = true;</code></li>
<li><code>ViewCamera-&gt;bAutoActivate = false;</code></li>
</ul>
</li>
<li>하지만 실제 게임에서는 캐릭터 BP 에서 <strong>별도의 GameplayCamera 컴포넌트(UGameplayCameraComponent 파생 BP)</strong> 를 메인 카메라로 사용 중</li>
<li>결과적으로:<ul>
<li>C++에서 <code>ViewCamera-&gt;Activate()</code> 를 호출해도 <strong>실제로 화면에 쓰이는 카메라는 GameplayCamera</strong> 이므로, 시점 문제는 그대로 유지</li>
</ul>
</li>
</ul>
<h3 id="2-네트워크에서-possessbeginplay-타이밍-차이">2. 네트워크에서 Possess/BeginPlay 타이밍 차이</h3>
<ul>
<li>서버:<ul>
<li>GameMode 에서 Pawn 생성 → <code>NewPlayer-&gt;Possess(NewPawn)</code> 실행</li>
<li>서버 기준에서는 Possess 타이밍이 확실</li>
</ul>
</li>
<li>클라이언트:<ul>
<li>Pawn 이 복제되고 <code>BeginPlay</code> 가 먼저 호출된 뒤에,</li>
<li>컨트롤러 소유 정보가 들어오고 <code>PawnClientRestart</code>/<code>OnRep_Controller</code> 등이 호출됨</li>
</ul>
</li>
<li><code>BeginPlay</code> 기준으로 카메라 Setup 을 시도하면:<ul>
<li>Host(리스너 서버)는 우연히 잘 동작할 수 있지만,</li>
<li>순수 Client 에서는 <strong>아직 로컬 컨트롤러가 붙지 않은 상태</strong>일 수 있어서 <code>IsLocallyControlled()</code> 가 false 인 경우가 많음</li>
<li>이 경우, 로컬 전용 카메라 Activate 로직이 실행되지 않음</li>
</ul>
</li>
</ul>
<hr>
<h2 id="해결-전략-pawnclientrestart에서-gameplaycamera-activate">해결 전략: PawnClientRestart에서 GameplayCamera Activate</h2>
<h3 id="선택한-기준-시점">선택한 기준 시점</h3>
<ul>
<li>네트워크 플레이에서 <strong>로컬 클라이언트가 이 Pawn 을 실제로 조종할 준비가 된 시점</strong>은 <code>APawn::PawnClientRestart()</code> 에서 가장 확실</li>
<li>이유:<ul>
<li>컨트롤러/Owner 정보가 세팅된 뒤 호출됨</li>
<li>입력 매핑(Enhanced Input)도 이 시점에 다시 셋업하는 패턴이 일반적</li>
<li>Host / Client 양쪽에서 동일한 타이밍 보장</li>
</ul>
</li>
</ul>
<h3 id="구현-아이디어">구현 아이디어</h3>
<ul>
<li><code>ACMPlayerCharacterBase</code> 에 <strong>카메라 셋업 전용 헬퍼 함수</strong> 추가<ul>
<li><code>UGameplayCameraComponent</code> 를 <code>FindComponentByClass</code> 로 찾아온다.</li>
<li>해당 컴포넌트에 대해 <code>Activate()</code> (또는 <code>SetupCamera()</code>) 를 호출한다.</li>
</ul>
</li>
<li><code>PawnClientRestart()</code> 에서:<ul>
<li>기존 입력 매핑 설정 후</li>
<li>바로 이 헬퍼 함수를 호출하여, 로컬 플레이어의 카메라를 활성화한다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="적용한-헬퍼-함수-소스코드">적용한 헬퍼 함수 소스코드</h2>
<h3 id="1-헤더-선언-acmplayercharacterbaseh">1. 헤더 선언 (ACMPlayerCharacterBase.h)</h3>
<ul>
<li>전방 선언 및 private 헬퍼 함수 선언</li>
</ul>
<pre><code class="language-cpp">class UGameplayCameraComponent;

UCLASS()
class CRIMSONMOON_API ACMPlayerCharacterBase : public ACMCharacterBase
{
    GENERATED_BODY()

    // ...existing code...

private:
    // BeginPlay 이후, 로컬 플레이어 기준으로 GameplayCamera를 셋업하는 헬퍼 함수
    void SetupGameplayCamera_Helper();

    // ...existing code...
};</code></pre>
<h3 id="2-구현부-acmplayercharacterbasecpp">2. 구현부 (ACMPlayerCharacterBase.cpp)</h3>
<pre><code class="language-cpp">void ACMPlayerCharacterBase::SetupGameplayCamera_Helper()
{
    UE_LOG(LogTemp, Warning,
        TEXT(&quot;SetupGameplayCamera_Helper: Called. IsLocallyControlled=%d&quot;),
        IsLocallyControlled() ? 1 : 0);

    // 로컬 플레이어가 소유한 Pawn 에서만 카메라 셋업
    if (!IsLocallyControlled())
    {
        UE_LOG(LogTemp, Warning,
            TEXT(&quot;SetupGameplayCamera_Helper: Aborted - not locally controlled&quot;));
        return;
    }

    // BP 에서 추가된 UGameplayCameraComponent 를 찾아 SetupCamera/Activate 호출
    if (UGameplayCameraComponent* GameplayCameraComp = FindComponentByClass&lt;UGameplayCameraComponent&gt;())
    {
        UE_LOG(LogTemp, Warning,
            TEXT(&quot;SetupGameplayCamera_Helper: Found GameplayCameraComponent on %s, calling SetupCamera&quot;),
            *GetName());

        // 현재는 Activate로 시점을 살려두었음 (필요 시 SetupCamera()로 교체 가능)
        GameplayCameraComp-&gt;Activate();
        // GameplayCameraComp-&gt;SetupCamera();
    }
    else
    {
        UE_LOG(LogTemp, Warning,
            TEXT(&quot;SetupGameplayCamera_Helper: GameplayCameraComponent not found on %s&quot;),
            *GetName());
    }
}</code></pre>
<h4 id="pawnclientrestart에서-호출">PawnClientRestart에서 호출</h4>
<pre><code class="language-cpp">void ACMPlayerCharacterBase::PawnClientRestart()
{
    Super::PawnClientRestart();

    if (const APlayerController* OwningPlayerController = GetController&lt;APlayerController&gt;())
    {
        UEnhancedInputLocalPlayerSubsystem* PlayerSubsystem =
            OwningPlayerController-&gt;GetLocalPlayer()-&gt;GetSubsystem&lt;UEnhancedInputLocalPlayerSubsystem&gt;();

        check(PlayerSubsystem);

        PlayerSubsystem-&gt;RemoveMappingContext(InputConfigDataAsset-&gt;DefaultMappingContext);
        PlayerSubsystem-&gt;AddMappingContext(InputConfigDataAsset-&gt;DefaultMappingContext, 0);
    }

    // 로컬 클라이언트가 이 Pawn 을 다시 사용할 준비가 된 시점에 카메라 셋업 시도
    SetupGameplayCamera_Helper();
}</code></pre>
<hr>
<h2 id="현재-상태-시점-문제는-해결-회전-문제는-미해결">현재 상태: 시점 문제는 해결, 회전 문제는 미해결</h2>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/19725d02-0b07-403c-b24d-382cdcf5fc10/image.webp" alt=""></p>
<h3 id="해결된-부분">해결된 부분</h3>
<ul>
<li>Host / Client 모두에서:<ul>
<li>GameplayCameraComponent 를 <strong>로컬 클라이언트 기준으로 Activate</strong> 하도록 변경</li>
<li>PawnClientRestart 기준으로 실행되기 때문에, 네트워크 타이밍 문제(컨트롤러 미소유 상태)는 피함</li>
</ul>
</li>
<li>결과적으로:<ul>
<li><strong>카메라 시점이 비어 있거나, 이상한 곳을 비추던 문제는 해결</strong>됨</li>
<li>캐릭터를 정상적으로 화면에 비추고, 이동/전투 플레이가 가능해짐</li>
</ul>
</li>
</ul>
<h3 id="아직-해결되지-않은-부분-마우스-입력에-따른-카메라-회전">아직 해결되지 않은 부분: 마우스 입력에 따른 카메라 회전</h3>
<ul>
<li>남아 있는 문제:<ul>
<li>마우스 입력(<code>Input_Look</code>)에 따라 카메라가 <strong>상하좌우로 자연스럽게 회전해야 하는데</strong>,</li>
<li>지금은 시점은 잡히지만, <strong>회전이 작동하지 않음</strong></li>
</ul>
</li>
<li>추정 원인:<ul>
<li>C++ 측에서는 <code>Input_Look</code> 에서 <code>AddControllerYawInput</code>, <code>AddControllerPitchInput</code> 을 호출하고 있음</li>
<li>하지만 실제로 화면에 쓰이는 것은 <strong>BP 기반 GameplayCamera</strong> 이고,</li>
<li>이 GameplayCamera/SpringArm 이 컨트롤러 회전을 제대로 반영하지 않거나,<ul>
<li><code>bUsePawnControlRotation</code> 설정이 비활성화되어 있거나,</li>
<li>자체 로직으로만 회전을 제어하고 있을 가능성이 큼</li>
</ul>
</li>
<li>따라서 <strong>컨트롤러 회전 값은 변하지만, 카메라 컴포넌트가 그 값을 사용하지 않는 상태</strong>일 수 있음</li>
</ul>
</li>
<li>요약:<ul>
<li>&quot;카메라가 안 보인다&quot; 문제는 <code>UGameplayCameraComponent</code> Activate 로 해결</li>
<li>&quot;카메라가 마우스 입력대로 돌지 않는다&quot; 문제는 <strong>GameplayCamera의 회전 설정/로직 조정</strong>이 추가로 필요</li>
</ul>
</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 작업에서는 네트워크 환경에서 플레이어 카메라 시점이 비정상적으로 비춰지는 문제를, <code>PawnClientRestart</code> 시점에 <code>UGameplayCameraComponent</code> 를 찾아 Activate 하는 방식으로 해결하였습니다. 특히 BeginPlay, PossessedBy, PawnClientRestart 간의 호출 시점과 <code>IsLocallyControlled()</code> 여부가 네트워크 환경에서 어떻게 달라지는지 다시 한 번 정리할 수 있었고, 로컬 전용 카메라 로직은 PawnClientRestart 에서 처리하는 것이 안전하다는 점을 확인했습니다.</p>
<p>다만, 아직 마우스 입력에 따른 카메라 상하좌우 회전이 기대한 대로 동작하지 않는 문제가 남아 있습니다. 이는 C++ 단의 Input_Look 처리가 아니라, 실제 뷰를 담당하고 있는 GameplayCamera 컴포넌트의 회전 설정과 로직이 컨트롤러 회전을 어떻게 받아들이는지에 더 가깝기 때문에, 다음 단계에서는 해당 BP/컴포넌트의 <code>bUsePawnControlRotation</code> 설정, SpringArm 사용 여부, ViewTarget 세팅 방식을 집중적으로 점검하고 수정할 예정입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] GameStarter NPC 구현 및 Remote Client 필터링 문제 해결]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-GameStarter-NPC-%EA%B5%AC%ED%98%84-%EB%B0%8F-Remote-Client-%ED%95%84%ED%84%B0%EB%A7%81-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-GameStarter-NPC-%EA%B5%AC%ED%98%84-%EB%B0%8F-Remote-Client-%ED%95%84%ED%84%B0%EB%A7%81-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Tue, 06 Jan 2026 10:29:33 GMT</pubDate>
            <description><![CDATA[<p>이번에는 멀티플레이 환경에서 <strong>게임 시작용 NPC(GameStarter NPC)</strong> 를 구현하고, 발생한 문제를 해결했습니다.</p>
<p>목표는 다음과 같습니다.</p>
<blockquote>
</blockquote>
<ul>
<li>NPC와 상호작용(Interact)했을 때 <strong>서버 인원 전체</strong>를 특정 맵으로 이동시키기</li>
<li>이동 전 <strong>화면이 서서히 어두워지는 연출(페이드 아웃)</strong> 추가</li>
<li>Interact 로직이 <strong>항상 서버(Host)</strong> 에서만 실행되도록 보장</li>
<li>특히, <strong>Remote Client가 조종하는 캐릭터가 Interact를 수행했을 때는 GameStarter 로직이 실행되지 않도록 필터링</strong></li>
</ul>
<p>특히, 게임 시작이 Host에서만 수행되도록 해야 하지만, Interact가 서버를 통해 수행되어 의도와 다르게 수행됐던 문제를 중점적으로 다뤄보고자 합니다.</p>
<hr>
<h2 id="현재-상호작용interact-구조-분석">현재 상호작용(Interact) 구조 분석</h2>
<h3 id="1-입력-처리-흐름-클라이언트">1. 입력 처리 흐름 (클라이언트)</h3>
<ul>
<li>컴포넌트: <code>UCMPickUpComponent</code></li>
<li>역할: 입력 바인딩 및 상호작용 대상 관리</li>
<li>주요 특징<ul>
<li><code>BeginPlay</code>에서 입력 시스템 초기화 시도 (<code>InitializeInputSystem</code>)</li>
<li>로컬 플레이어 컨트롤러가 준비되면 Enhanced Input으로 Interact 액션 바인딩</li>
<li>Tick에서 서버 기준으로 가장 가까운 상호작용 대상(<code>CurrentInteractableActor</code>)을 계산 및 복제</li>
</ul>
</li>
</ul>
<ul>
<li>입력 처리 함수<ul>
<li><code>UCMPickUpComponent::OnPickupInput(const FInputActionValue&amp; Value)</code><ul>
<li>Interact 키(예: <code>2번</code>)를 누르면 호출</li>
<li>직접 트레이스를 하지 않고, <strong>Ability System에 태그를 기반으로 Activiate 요청</strong></li>
<li>코드 요약<ul>
<li><code>AbilitySystemComponent-&gt;TryActivateAbilitiesByTag(InteractAbilityTag, true);</code></li>
</ul>
</li>
<li>이 부분은 <strong>클라이언트 로컬에서만 실행</strong>됨</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3 id="2-gameplay-ability-실행-서버">2. Gameplay Ability 실행 (서버)</h3>
<ul>
<li>클래스: <code>UCMAbility_Interact</code></li>
<li>역할: Interact Ability의 실제 실행 로직</li>
<li>핵심 설정<ul>
<li><code>NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::ServerOnly;</code></li>
<li>의미: Ability의 <code>ActivateAbility</code> 는 <strong>항상 서버에서만 실행</strong></li>
</ul>
</li>
</ul>
<ul>
<li>실행 흐름 요약<ul>
<li><code>AvatarActor</code> (보통 플레이어 캐릭터)를 가져옴</li>
<li><code>AvatarActor</code>에서 <code>UCMPickUpComponent</code>를 찾음</li>
<li><code>PickUpComp-&gt;GetCurrentInteractable()</code>로 상호작용 대상 <code>HitActor</code> 획득</li>
<li><code>HitActor</code>가 <code>UCMInteractableInterface</code> 구현 시:<ul>
<li><code>ICMInteractableInterface::Execute_Interact(HitActor, AvatarActor);</code></li>
</ul>
</li>
<li>어빌리티 종료 (<code>EndAbility</code>)</li>
</ul>
</li>
</ul>
<ul>
<li>시사점<ul>
<li>Interact 입력은 <strong>클라이언트에서 시작</strong>하지만,</li>
<li>실제 Interact 실행(인터페이스 호출)은 <strong>서버에서 수행</strong>됨</li>
</ul>
</li>
</ul>
<h3 id="3-npc-인터페이스-및-gamestarter-npc-역할">3. NPC 인터페이스 및 GameStarter NPC 역할</h3>
<ul>
<li>기본 NPC 클래스: <code>ACMNpcBase</code><ul>
<li><code>ICMInteractableInterface</code> 를 구현</li>
<li>기본적인 상호작용/대화/상점 등 공통 로직 보유</li>
</ul>
</li>
</ul>
<ul>
<li>GameStarter NPC: <code>ACMNpcGameStarter : public ACMNpcBase</code><ul>
<li>역할<ul>
<li>특정 NPC와의 Interact 시, <strong>서버 전체를 지정 맵으로 이동(ServerTravel)</strong></li>
<li>이동 직전, 모든 클라이언트 화면을 <strong>서서히 어둡게(페이드 아웃)</strong> 처리</li>
</ul>
</li>
<li>제약 조건<ul>
<li><strong>Ability 쪽 코드는 수정하지 않음</strong></li>
<li>필터링 로직은 <strong>GameStarter NPC 내부에서만 구현</strong></li>
<li>Remote Client가 조종하는 캐릭터의 Interact는 <strong>무시</strong></li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="gamestarter-npc에서의-remote-client-필터링-설계">GameStarter NPC에서의 Remote Client 필터링 설계</h2>
<h3 id="1-요구사항-정리">1. 요구사항 정리</h3>
<ul>
<li>Interact 구조(입력 → Ability → 서버에서 Interact 호출)는 그대로 유지</li>
<li>단, GameStarter NPC 입장에서 <strong>Interactor가 누구인지 보고</strong> 다음과 같이 처리<ul>
<li>Interactor가 <strong>Remote 클라이언트가 조종하는 Pawn</strong>이면 → GameStarter 로직 실행 X</li>
<li>Interactor가 <strong>리슨 서버(Host)가 조종하는 Pawn</strong> 또는 기타 허용된 주체라면 → GameStarter 로직 실행 O</li>
</ul>
</li>
</ul>
<h3 id="2-서버-관점에서-remote-client-판별-기준">2. 서버 관점에서 Remote Client 판별 기준</h3>
<ul>
<li>서버 기준에서 <code>APawn</code> 의 <code>Controller</code> 를 통해 판별</li>
<li>판별 로직<ul>
<li><code>Controller-&gt;IsPlayerController() == true</code> 이면, 플레이어가 조종하는 Pawn</li>
<li>이 때,<ul>
<li><code>Controller-&gt;IsLocalController() == true</code>  → 서버 자신(리슨 서버) 혹은 서버 로컬 컨트롤러</li>
<li><code>Controller-&gt;IsLocalController() == false</code> → <strong>Remote 클라이언트가 조종 중인 Pawn</strong></li>
</ul>
</li>
</ul>
</li>
<li>따라서 아래 조합으로 Remote 클라이언트를 판별 가능<ul>
<li><code>Controller-&gt;IsPlayerController()</code> &amp;&amp; <code>!Controller-&gt;IsLocalController()</code></li>
</ul>
</li>
</ul>
<p>이를 GameStarter NPC의 <code>Interact_Implementation</code> 안에서 사용하여 필터링을 구현했습니다.</p>
<hr>
<h2 id="acmnpcgamestarter-내부-구현-변화">ACMNpcGameStarter 내부 구현 변화</h2>
<h3 id="1-클래스-인터페이스-확장-헤더">1. 클래스 인터페이스 확장 (헤더)</h3>
<ul>
<li><p>파일: <code>CMNpcGameStarter.h</code></p>
</li>
<li><p>변경 사항</p>
<ul>
<li><code>Interact_Implementation(AActor* Interactor)</code> 오버라이드 선언</li>
<li><code>PerformInteract()</code> 를 <code>override</code> 로 선언해 GameStarter 전용 로직 수행</li>
<li>GameStart에 필요한 설정값을 프로퍼티로 추가</li>
</ul>
</li>
<li><p>주요 멤버 요약</p>
<ul>
<li><code>virtual void Interact_Implementation(AActor* Interactor) override;</code></li>
<li><code>virtual void PerformInteract() override;</code></li>
<li><code>UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = &quot;GameStart&quot;) FString TravelURL;</code></li>
<li><code>UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = &quot;GameStart&quot;) float FadeDuration = 1.0f;</code></li>
<li><code>UFUNCTION(NetMulticast, Reliable) void MulticastStartFadeOut();</code></li>
<li><code>void ServerTravelToConfiguredMap();</code></li>
</ul>
</li>
</ul>
<h3 id="2-interact_implementation에서-remote-client-필터링">2. Interact_Implementation에서 Remote Client 필터링</h3>
<ul>
<li>파일: <code>CMNpcGameStarter.cpp</code></li>
<li>핵심 로직<ul>
<li>오직 <strong>서버(또는 리슨 서버)</strong> 에서만 Interact 처리</li>
<li><code>Interactor</code> 가 Remote 클라가 조종하는 Pawn이면 조용히 <code>return;</code></li>
<li>그 외 Interactor에 대해서만 <code>PerformInteract()</code> 호출</li>
</ul>
</li>
<li>로직 개요<ul>
<li>권한 체크<ul>
<li><code>if (!HasAuthority() &amp;&amp; GetNetMode() != NM_ListenServer) return;</code></li>
</ul>
</li>
<li>Interactor 타입 검사<ul>
<li><code>APawn* Pawn = Cast&lt;APawn&gt;(Interactor);</code></li>
<li><code>AController* Controller = Pawn-&gt;GetController();</code></li>
</ul>
</li>
<li>Remote Client 판별<ul>
<li><code>Controller-&gt;IsPlayerController() &amp;&amp; !Controller-&gt;IsLocalController()</code> → Remote 클라</li>
</ul>
</li>
<li>필터링<ul>
<li>위 조건이면 <code>return;</code> → GameStart 로직 미실행</li>
</ul>
</li>
<li>허용 시<ul>
<li><code>PerformInteract();</code> 호출</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3 id="3-performinteract-서버-전용-gamestart-로직">3. PerformInteract: 서버 전용 GameStart 로직</h3>
<ul>
<li>역할<ul>
<li>서버에서만 실행되는 GameStart 핵심 로직</li>
<li>전체 클라이언트에 페이드 아웃 명령 전파</li>
<li>페이드가 끝난 시점에 ServerTravel 수행</li>
</ul>
</li>
<li>동작 순서<ol>
<li><code>HasAuthority()</code> + <code>NetMode</code> 체크로 서버/리슨 서버에서만 진행</li>
<li><code>UWorld* World = GetWorld();</code> 유효성 확인</li>
<li><code>MulticastStartFadeOut();</code> 호출 → NetMulticast RPC</li>
<li><code>World-&gt;GetTimerManager().SetTimer(..., FadeDuration);</code> 로 <code>ServerTravelToConfiguredMap</code> 예약 호출</li>
</ol>
</li>
<li>이로 인해:<ul>
<li>클라이언트가 Interact 입력을 하더라도</li>
<li>Ability → 서버에서 Interact → GameStarter → <strong>추가 필터링 후 허용된 경우에만</strong> 서버 트래블이 실행됨</li>
</ul>
</li>
</ul>
<h3 id="4-multicaststartfadeout-모든-클라에서-로컬-화면-페이드">4. MulticastStartFadeOut: 모든 클라에서 로컬 화면 페이드</h3>
<ul>
<li>함수: <code>MulticastStartFadeOut_Implementation()</code></li>
<li>특성: <code>NetMulticast, Reliable</code><ul>
<li>서버에서 한 번 호출하면, <strong>서버 + 모든 클라이언트 월드에서 각각 1번씩 실행</strong></li>
</ul>
</li>
<li>동작<ul>
<li><code>UWorld* World = GetWorld();</code></li>
<li><code>APlayerController* PC = World-&gt;GetFirstPlayerController();</code></li>
<li><code>PC-&gt;IsLocalController()</code> 인 경우에만 페이드 적용</li>
<li><code>PC-&gt;PlayerCameraManager-&gt;StartCameraFade(0.0f, 1.0f, FadeDuration, FLinearColor::Black, true, true);</code></li>
</ul>
</li>
<li>결과<ul>
<li>각 클라이언트가 <strong>자기 화면에서만</strong> 0 → 1 알파로 서서히 어두워짐</li>
<li>Remote/로컬 구분 없이, 모든 접속자 화면에서 동일한 페이드 아웃 연출 발생</li>
</ul>
</li>
</ul>
<h3 id="5-servertraveltoconfiguredmap-서버-인원-전체-맵-이동">5. ServerTravelToConfiguredMap: 서버 인원 전체 맵 이동</h3>
<ul>
<li>역할<ul>
<li>에디터에서 설정한 <code>TravelURL</code> 을 기준으로 ServerTravel 실행</li>
</ul>
</li>
<li>동작 요약<ul>
<li>서버/리슨 서버에서만 실행 (<code>HasAuthority()</code> 체크)</li>
<li><code>TravelURL.IsEmpty()</code> 시 로그만 출력하고 종료</li>
<li><code>World-&gt;ServerTravel(TravelURL, /*bAbsolute*/ false);</code></li>
</ul>
</li>
<li>효과<ul>
<li>서버가 새로운 맵으로 트래블하면서,</li>
<li>현재 접속 중인 모든 클라이언트도 해당 맵으로 함께 이동</li>
</ul>
</li>
</ul>
<hr>
<h2 id="5-상호작용-흐름-전체-시각화">5. 상호작용 흐름 전체 시각화</h2>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/1b372dfb-8ac8-4c41-b5a9-b074674b2c59/image.png" alt=""></p>
<hr>
<h2 id="6-얻은-인사이트-및-정리">6. 얻은 인사이트 및 정리</h2>
<p>오늘 작업을 통해 다음과 같은 점을 다시 정리할 수 있었습니다.</p>
<ul>
<li><strong>입력과 실행 주체의 분리</strong><ul>
<li>상호작용 입력은 클라이언트에서 발생하지만,</li>
<li>실제 게임 규칙(Logics)은 ServerOnly Ability와 서버 측 NPC 로직을 통해 <strong>서버에서만 결정</strong>하도록 설계할 수 있음</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Remote Client 필터링은 Interactor 정보를 활용해 GameStart 클래스 내부에서 충분히 처리 가능</strong><ul>
<li>Ability 구조를 건드리지 않고도,</li>
<li><code>Interact_Implementation(AActor* Interactor)</code> 에서 Interactor의 Controller를 검사하여</li>
<li>Remote 클라이언트가 조종하는 Pawn의 Interact만 깔끔하게 차단할 수 있었음</li>
</ul>
</li>
</ul>
<ul>
<li><strong>NetMulticast + 로컬 컨트롤러 검사로 시각 효과를 안전하게 분배</strong><ul>
<li><code>NetMulticast</code> 함수는 월드마다 한 번씩 실행되므로,</li>
<li>함수 내부에서 <code>GetFirstPlayerController()</code> + <code>IsLocalController()</code> 조합을 사용하면</li>
<li>각 클라이언트의 로컬 화면에만 시각 효과(페이드)를 적용할 수 있음</li>
</ul>
</li>
</ul>
<ul>
<li><strong>서버 트래블(ServerTravel)과 연출(페이드)을 분리하여 타이밍 제어</strong><ul>
<li>페이드 아웃 → 타이머 → ServerTravel 순으로 분리함으로써,</li>
<li>연출 타이밍과 실제 맵 전환 타이밍을 명확하게 제어할 수 있었음</li>
</ul>
</li>
</ul>
<p>앞으로는 이 패턴을 바탕으로, 특정 권한(예: GM 전용, 특정 팀 전용)만 사용할 수 있는 상호작용 오브젝트를 설계할 때, Interactor 기반 필터링을 적극적으로 활용할 계획입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] 상점 기능 고도화 (컨텐츠 정보 반영, 개수 증감 버튼 등)]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%EC%83%81%EC%A0%90-%EA%B8%B0%EB%8A%A5-%EA%B3%A0%EB%8F%84%ED%99%94-%EC%BB%A8%ED%85%90%EC%B8%A0-%EC%A0%95%EB%B3%B4-%EB%B0%98%EC%98%81-%EA%B0%9C%EC%88%98-%EC%A6%9D%EA%B0%90-%EB%B2%84%ED%8A%BC-%EB%93%B1</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%EC%83%81%EC%A0%90-%EA%B8%B0%EB%8A%A5-%EA%B3%A0%EB%8F%84%ED%99%94-%EC%BB%A8%ED%85%90%EC%B8%A0-%EC%A0%95%EB%B3%B4-%EB%B0%98%EC%98%81-%EA%B0%9C%EC%88%98-%EC%A6%9D%EA%B0%90-%EB%B2%84%ED%8A%BC-%EB%93%B1</guid>
            <pubDate>Tue, 30 Dec 2025 11:31:07 GMT</pubDate>
            <description><![CDATA[<p>오늘은 프로젝트에서 Shop UI를 구현하고, 특히 <strong>상점 리스트에서 아이템을 선택했을 때 상세 패널에 정보가 반영되는 흐름</strong>과 <strong>아이템 수량(Quantity) 증감 UI</strong>를 중심으로 작업을 진행하였습니다. 또한 상점 UI를 여는 과정에서 <strong>PlayerController–NPC–ShopComponent</strong> 사이의 통신 구조를 다시 정리하고, 이를 코드 레벨에서 점검하는 시간을 가졌습니다.</p>
<p>이번 작업의 핵심은 다음과 같습니다. <strong>CMShopContentElementWidget을 클릭 → CMShopWidget에서 선택 상태 갱신 → 상세 UI에 이름/설명/수량 표시 → 수량 버튼으로 Quantity 증감</strong>까지의 흐름을 자연스럽게 만드는 것이었습니다.</p>
<hr>
<h2 id="오늘-구현정리한-내용">오늘 구현/정리한 내용</h2>
<h3 id="shop-ui-전체-흐름-요약">Shop UI 전체 흐름 요약</h3>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/69f725e2-b153-4e5f-9e1e-475326017a08/image.png" alt=""></p>
<ul>
<li>서버에서 상점 데이터를 가져와서 <strong>클라이언트의 UCMShopWidget</strong>에 전달하는 기존 구조를 재확인하였습니다.</li>
<li><code>CreateShopWidget</code>에서 이미 상점 위젯 인스턴스가 있을 경우에는 <strong>SetShopItems + BuildShopList</strong>만 다시 호출하여 재사용하도록 되어 있음을 확인했습니다.</li>
</ul>
<hr>
<h3 id="cmshopwidget-아이템-선택-및-수량-표시-로직">CMShopWidget: 아이템 선택 및 수량 표시 로직</h3>
<h4 id="주요-멤버-정리">주요 멤버 정리</h4>
<ul>
<li><code>FCMShopItemContent CurrentSelectedItem;</code><ul>
<li>현재 선택된 상점 아이템 정보 구조체</li>
</ul>
</li>
<li><code>int32 SeletedItemQuantity = 0;</code><ul>
<li>현재 선택된 아이템의 수량(Quantity)</li>
</ul>
</li>
<li><code>UTextBlock* SelectedItemNameText;</code><ul>
<li>선택 아이템 이름 표시용 텍스트</li>
</ul>
</li>
<li><code>UTextBlock* SelectedItemQuantityText;</code><ul>
<li>선택 아이템 수량 표시용 텍스트</li>
</ul>
</li>
<li><code>UTextBlock* ItemDescriptionText;</code><ul>
<li>선택 아이템 설명 표시용 텍스트</li>
</ul>
</li>
<li><code>UButton* AddItemQuantityButton;</code><ul>
<li>수량 증가 버튼(+)</li>
</ul>
</li>
<li><code>UButton* SubtractItemQuantityButton;</code><ul>
<li>수량 감소 버튼(-)</li>
</ul>
</li>
</ul>
<h4 id="초기화-및-버튼-바인딩">초기화 및 버튼 바인딩</h4>
<ul>
<li><code>NativeOnInitialized()</code>에서 다음과 같이 버튼과 핸들러를 바인딩<ul>
<li><code>AddItemQuantityButton -&gt; OnClickedAddItemQuantityButton</code></li>
<li><code>SubtractItemQuantityButton -&gt; OnClickedSubtractItemQuantityButton</code></li>
</ul>
</li>
<li>이 핸들러 함수들은 반드시 <code>UFUNCTION()</code>으로 선언해 델리게이트와 호환되도록 처리</li>
</ul>
<h4 id="아이템-선택-처리-handleelementselected">아이템 선택 처리: HandleElementSelected</h4>
<p><code>UCMShopContentElementWidget</code>(리스트 요소)을 클릭했을 때 호출되는 콜백입니다.</p>
<ul>
<li>역할<ul>
<li>리스트에서 클릭된 요소의 <code>FCMShopItemContent</code>를 받아와 <strong>현재 선택 아이템</strong>으로 저장</li>
<li>선택과 동시에 수량을 <strong>1로 초기화</strong></li>
<li>상세 UI 텍스트를 갱신</li>
</ul>
</li>
</ul>
<p>개념적으로는 다음과 같습니다.</p>
<ul>
<li><code>CurrentSelectedItem = ElementWidget-&gt;GetItemContent();</code></li>
<li><code>SeletedItemQuantity = 1;</code></li>
<li><code>UpdateSelectedItemDisplay();</code></li>
</ul>
<h4 id="상세-표시-함수-updateselecteditemdisplay">상세 표시 함수: UpdateSelectedItemDisplay</h4>
<p>선택된 아이템 정보를 실제 UI 텍스트에 반영하는 책임을 가집니다.</p>
<ul>
<li>이름 텍스트<ul>
<li><code>SelectedItemNameText-&gt;SetText(...)</code> 호출</li>
<li><code>FCMShopItemContent</code> 내부의 이름 필드 타입을 고려하여 <code>FName</code> → <code>FText::FromName</code>, 또는 이미 <code>FText</code>라면 그대로 세팅</li>
</ul>
</li>
<li>수량 텍스트<ul>
<li><code>SelectedItemQuantityText-&gt;SetText(FText::AsNumber(SeletedItemQuantity));</code></li>
</ul>
</li>
<li>설명 텍스트<ul>
<li><code>ItemDescriptionText-&gt;SetText(CurrentSelectedItem.ItemDescription);</code></li>
</ul>
</li>
</ul>
<p>에디터에서 바인딩한 텍스트 위젯들이 <code>nullptr</code>일 수 있으므로, 각 항목마다 <code>if (SelectedItemNameText)</code> 같은 <strong>널 체크 후 세팅</strong>하는 패턴으로 작성했습니다.</p>
<h4 id="수량-증감-로직-updateselecteditemquantitydelta">수량 증감 로직: UpdateSelectedItemQuantityDelta</h4>
<p>수량 버튼 양쪽에서 공통으로 사용하는 내부 함수입니다.</p>
<ul>
<li>인자<ul>
<li><code>int32 Delta</code> : +1, -1 등의 증감 값</li>
</ul>
</li>
<li>처리<ul>
<li><code>SeletedItemQuantity = FMath::Clamp(SeletedItemQuantity + Delta, 1, 99);</code><ul>
<li>최소 1, 최대 99로 제한</li>
</ul>
</li>
<li><code>UpdateSelectedItemDisplay();</code> 호출하여 UI를 동기화</li>
</ul>
</li>
</ul>
<p>이렇게 함으로써, 수량이 UI와 항상 <strong>동일한 상태</strong>를 유지하게 했습니다.</p>
<h4 id="수량-버튼-클릭-핸들러">수량 버튼 클릭 핸들러</h4>
<ul>
<li><code>OnClickedAddItemQuantityButton()</code><ul>
<li><code>UpdateSelectedItemQuantityDelta(1);</code></li>
</ul>
</li>
<li><code>OnClickedSubtractItemQuantityButton()</code><ul>
<li><code>UpdateSelectedItemQuantityDelta(-1);</code></li>
</ul>
</li>
</ul>
<p>버튼은 단순히 <strong>증감 방향만 결정</strong>하고, 실제 로직은 <code>UpdateSelectedItemQuantityDelta</code>에 몰아 넣어 중복을 줄였습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/61e6cc62-0935-44ee-a68e-47d355ded6ef/image.png" alt=""></p>
<hr>
<h3 id="cmshopcontentelementwidget과-cmshopwidget의-연결-방식">CMShopContentElementWidget과 CMShopWidget의 연결 방식</h3>
<p><code>UCMShopContentElementWidget</code>은 상점 리스트의 개별 아이템 슬롯입니다. 오늘은 이 위젯이 <strong>어떻게 상위 ShopWidget에 &quot;선택됨&quot;을 알리는지</strong> 흐름을 명확히 이해하는 데 집중했습니다.</p>
<h4 id="데이터-전달-구조-정리">데이터 전달 구조 (정리)</h4>
<ul>
<li><code>CMNpcShopComponent</code>에서 <code>TArray&lt;FCMShopItemContent&gt;</code>를 생성 및 관리</li>
<li><code>ACMPlayerController::Server_RequestShopData_Implementation</code><ul>
<li>NPC를 찾고, 그 NPC의 <code>UCMNpcShopComponent</code>에서 <code>GetShopItemContents</code>로 아이템 배열 획득</li>
<li>ListenServer/클라 구분 후, 최종적으로 <code>CreateShopWidget(ShopItems)</code> 호출</li>
</ul>
</li>
<li><code>UCMShopWidget::BuildShopList()</code><ul>
<li><code>ShopItems</code>를 순회하면서 <code>UCMShopContentElementWidget</code> 인스턴스들을 생성</li>
<li>각 슬롯에 <code>SetItemDisplayData(Item, this)</code>와 같은 방식으로<ul>
<li>표시용 데이터(이름, 가격, 아이콘 등)</li>
<li>콜백 대상(ShopWidget 자기 자신)을 전달</li>
</ul>
</li>
</ul>
</li>
<li><code>UCMShopContentElementWidget</code> 내부에서 OnClicked 등 이벤트 발생 시<ul>
<li>바인딩된 <code>UCMShopWidget::HandleElementSelected(this)</code> 호출</li>
</ul>
</li>
</ul>
<p>이 과정을 통해 <strong>슬롯 → 상점 메인 위젯</strong>으로 자연스럽게 선택 이벤트가 올라오도록 설계되어 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/86e84f1d-6b6f-447f-bf8e-1fd3745587ba/image.png" alt=""></p>
<hr>
<h3 id="네이밍-및-바인딩-정리-count-→-quantity">네이밍 및 바인딩 정리 (Count → Quantity)</h3>
<p>오늘 도중에 발견한 네이밍 혼선을 정리했습니다. 기존에는 일부 버튼/텍스트가 <code>Count</code>라는 이름을 사용하고 있었고, 다른 부분에서는 <code>Quantity</code>를 사용하고 있었습니다. 이를 전부 <code>Quantity</code>로 통일했습니다.</p>
<ul>
<li>버튼<ul>
<li><code>AddItemCountButton</code> → <code>AddItemQuantityButton</code></li>
<li><code>SubtractItemCountButton</code> → <code>SubtractItemQuantityButton</code></li>
</ul>
</li>
<li>텍스트<ul>
<li><code>SelectedItemCountText</code> → <code>SelectedItemQuantityText</code></li>
</ul>
</li>
<li>함수<ul>
<li><code>OnClickedAddItemCountButton</code> → <code>OnClickedAddItemQuantityButton</code></li>
<li><code>OnClickedSubtractItemCountButton</code> → <code>OnClickedSubtractItemQuantityButton</code></li>
</ul>
</li>
<li>UI 바인딩 주의<ul>
<li>C++ 이름을 변경한 후, UMG 디자이너에서 <strong>위젯 변수 이름도 동일하게 변경</strong>해야 바인딩이 끊기지 않음을 재확인했습니다.</li>
</ul>
</li>
</ul>
<p>이 정리 덕분에 코드 가독성과 의도가 보다 명확해졌습니다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>오늘은 Shop UI에서 <strong>아이템 선택 → 상세 정보 반영 → 수량 증감</strong>이라는 한 흐름에 집중해서 구현과 리팩터링을 진행하였습니다. 특히 <code>CMShopContentElementWidget</code>과 <code>CMShopWidget</code> 사이의 이벤트 전달 구조를 확실히 이해하고 정리하면서, 이후 구매/판매 요청(<code>RequestBuyItem</code>, <code>RequestSellItem</code>)을 구현할 때도 같은 패턴을 확장해서 사용할 수 있겠다는 생각이 들었습니다.</p>
<p>또한, 이름을 <code>Count</code>에서 <code>Quantity</code>로 통일하고 버튼/텍스트/함수를 일관되게 정리한 덕분에, 나중에 상점 관련 기능을 확장할 때 혼동이 줄어들 것으로 기대합니다. 다음 단계에서는 오늘 만든 선택/수량 정보를 바탕으로 실제 서버 RPC(<code>Server_RequestBuyItem</code>, <code>Server_RequestSellItem</code>)와 연동하여 아이템 구매/판매 로직을 완성해 볼 계획입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] 서버 권한 상점 컨텐츠 구성 (UI, 캐싱, Data Table, RPC)]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%EC%84%9C%EB%B2%84-%EA%B6%8C%ED%95%9C-%EC%83%81%EC%A0%90-%EC%BB%A8%ED%85%90%EC%B8%A0-%EA%B5%AC%EC%84%B1-UI-%EC%BA%90%EC%8B%B1-Data-Table-RPC</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%EC%84%9C%EB%B2%84-%EA%B6%8C%ED%95%9C-%EC%83%81%EC%A0%90-%EC%BB%A8%ED%85%90%EC%B8%A0-%EA%B5%AC%EC%84%B1-UI-%EC%BA%90%EC%8B%B1-Data-Table-RPC</guid>
            <pubDate>Fri, 26 Dec 2025 16:26:38 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_sensational/post/94459a85-c765-4c68-9c1f-407b6d43be98/image.webp" alt=""></p>
<p>오늘은 프로젝트에 NPC 상점 시스템을 구현하고, <code>NpcWorldSubsystem</code> / <code>NpcShopComponent</code> / <code>PlayerController</code> / <code>Shop UI 위젯</code> 사이의 흐름을 점검하는 작업을 진행하였습니다. </p>
<hr>
<h2 id="상점-ui-생성-전체-플로우">상점 UI 생성 전체 플로우</h2>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/b91a9b3b-b47b-4962-aca5-9d561293766e/image.png" alt=""></p>
<hr>
<h2 id="오늘-작업한-내용-정리">오늘 작업한 내용 정리</h2>
<ul>
<li><strong>데이터 구조 정리</strong><ul>
<li><code>FCMShopItemContent</code><ul>
<li><code>FName ItemID</code> : 아이템 식별용 ID</li>
<li><code>int32 BuyPrice</code> : 구매 가격</li>
<li><code>int32 SellPrice</code> : 판매 가격</li>
<li><code>int32 Quantity</code> : 수량 정보</li>
<li><code>FText ItemName</code> : 표시용 이름</li>
<li><code>UTexture2D* ItemIcon</code> : 아이콘 이미지</li>
</ul>
</li>
<li><code>UDataTable</code> 기반으로 상점 아이템 목록을 관리하도록 설계</li>
<li><code>ACMNpcBase</code> 에 <code>TObjectPtr&lt;UDataTable&gt; ShopItemDataTable</code>, <code>FName NpcId</code> 를 추가하여 NPC 단위로 상점 구성을 다르게 가져갈 수 있도록 준비</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/b265611c-e526-441c-9e23-14cc5122dbb4/image.png" alt=""></p>
<ul>
<li><p><strong>NPC 캐싱 구조 설계 (<code>UCMNpcWorldSubsystem</code>)</strong></p>
<ul>
<li><code>FCMNpcCacheEntry</code><ul>
<li><code>FName NpcId</code></li>
<li><code>TWeakObjectPtr&lt;ACMNpcBase&gt; NpcActor</code></li>
</ul>
</li>
<li><code>TMap&lt;FName, FCMNpcCacheEntry&gt; NpcCacheMap</code> 에 NPC를 ID 기반으로 캐싱</li>
<li><code>RegisterNpc(const FName&amp; NpcId, ACMNpcBase* NpcActor)</code><ul>
<li><code>NpcCacheMap.FindOrAdd(NpcId)</code> 를 이용해 캐시 엔트리 생성/조회 후 값 설정</li>
</ul>
</li>
<li><code>UnregisterNpc(const FName&amp; NpcId)</code><ul>
<li>NPC가 사라질 때 캐시에서 정리</li>
</ul>
</li>
<li><code>GetNpcById(const FName&amp; NpcId) const</code><ul>
<li>캐시에서 ID로 액터를 조회</li>
</ul>
</li>
<li><code>GetAllNpcEntries(TArray&lt;FCMNpcCacheEntry&gt;&amp; OutEntries) const</code><ul>
<li>디버깅 및 툴 제작용 전체 조회 함수 제공</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>NPC에서 Subsystem 등록 흐름</strong></p>
<ul>
<li><code>ACMNpcBase</code><ul>
<li><code>FName NpcId</code> 를 에디터에서 설정 가능하도록 노출</li>
<li>NPC가 BeginPlay 시점에 <code>UCMNpcWorldSubsystem</code> 을 가져와 <code>RegisterNpc(NpcId, this)</code> 를 호출하도록 구현</li>
<li>EndPlay 시점에는 <code>UnregisterNpc(NpcId)</code> 로 정리</li>
</ul>
</li>
<li>이를 통해 서버(호스트)에서 모든 NPC를 ID로 빠르게 찾을 수 있는 구조 확보</li>
</ul>
</li>
<li><p><strong>NPC 상점 컴포넌트 (<code>UCMNpcShopComponent</code>)</strong></p>
<ul>
<li><code>UCMNpcComponentBase</code> 를 상속</li>
<li><code>TArray&lt;FCMShopItemContent&gt; ShopItemContents</code> 를 내부에 보관</li>
<li>인터페이스<ul>
<li><code>virtual void PerformAction() override;</code></li>
<li><code>void SetShopItemContents(const TArray&lt;FCMShopItemContent&gt;&amp; InItems);</code></li>
<li><code>void GetShopItemContents(TArray&lt;FCMShopItemContent&gt;&amp; OutItems) const;</code></li>
</ul>
</li>
<li><code>PerformAction()</code> 에서의 역할<ul>
<li>NPC 오너(<code>ACMNpcBase</code>) 로부터 <code>NpcId</code> 를 가져옴</li>
<li>로컬 플레이어 컨트롤러(<code>ACMPlayerController</code>)를 찾아 <code>RequestOpenShopUI(NpcId)</code> 호출</li>
<li>상점 액션이 트리거되면 컨트롤러를 통해 상점 UI 요청이 이어지도록 연결</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>PlayerController의 상점 UI 흐름 (<code>ACMPlayerController</code>)</strong></p>
<ul>
<li>프로퍼티<ul>
<li><code>TSubclassOf&lt;UCMShopWidget&gt; ShopWidgetClass;</code> : 에디터에서 상점 메인 위젯 클래스 지정</li>
<li><code>UCMShopWidget* ShopWidgetInstance;</code> : 런타임 인스턴스 보관</li>
</ul>
</li>
<li>상점 UI 요청 진입점<ul>
<li><code>UFUNCTION(BlueprintCallable, Category = &quot;Shop|UI&quot;) void RequestOpenShopUI(const FName&amp; NpcId);</code></li>
<li>로컬 컨트롤러인지 확인 후 <code>Server_RequestShopData(NpcId)</code> 호출</li>
</ul>
</li>
<li>서버 RPC<ul>
<li><code>UFUNCTION(Server, Reliable, WithValidation) void Server_RequestShopData(const FName&amp; NpcId);</code></li>
<li>구현부에서<ul>
<li><code>UCMNpcWorldSubsystem</code> 에서 <code>GetNpcById(NpcId)</code> 로 NPC 조회</li>
<li>NPC의 <code>UCMNpcShopComponent</code> 를 찾아 <code>GetShopItemContents()</code> 로 상점 아이템 배열 획득</li>
<li><strong>Host(리슨 서버)</strong> 인 경우: <code>CreateShopWidget(ShopItems)</code> 를 바로 호출하여 서버/클라 겸용 컨트롤러에서 UI 생성</li>
<li><strong>순수 클라이언트</strong> 인 경우: <code>Client_ReceiveShopDataAndOpen(ShopItems)</code> 클라 RPC 호출</li>
</ul>
</li>
</ul>
</li>
<li>클라 RPC<ul>
<li><code>UFUNCTION(Client, Reliable) void Client_ReceiveShopDataAndOpen(const TArray&lt;FCMShopItemContent&gt;&amp; ShopItems);</code></li>
<li>구현부에서 <code>CreateShopWidget(ShopItems);</code> 호출</li>
</ul>
</li>
<li>실제 UI 생성 함수<ul>
<li><code>void CreateShopWidget(const TArray&lt;FCMShopItemContent&gt;&amp; InShopItems);</code></li>
<li><code>IsLocalController()</code> 확인 후<ul>
<li>이미 인스턴스가 있으면 <code>SetShopItems()</code> / <code>BuildShopList()</code> 만 호출하여 갱신</li>
<li>없으면 <code>UIManagerComponent-&gt;PushWidget(ShopWidgetClass)</code> 로 위젯을 스택에 올린 뒤, 데이터 주입 및 리스트 빌드</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>Shop 위젯 구현 (<code>UCMShopWidget</code>)</strong></p>
<ul>
<li>구성 요소<ul>
<li><code>UVerticalBox* ShopItemListBox;</code> (BindWidget)</li>
<li><code>TSubclassOf&lt;UCMShopContentElementWidget&gt; ShopItemWidgetClass;</code></li>
<li><code>TArray&lt;FCMShopItemContent&gt; ShopItems;</code></li>
</ul>
</li>
<li>인터페이스<ul>
<li><code>void SetShopItems(const TArray&lt;FCMShopItemContent&gt;&amp; InItems);</code></li>
<li><code>void BuildShopList();</code></li>
</ul>
</li>
<li><code>BuildShopList()</code> 동작 개요<ul>
<li>기존 자식 위젯 제거</li>
<li><code>ShopItems</code>를 순회하면서 <code>ShopItemWidgetClass</code> 로 <code>UCMShopContentElementWidget</code> 생성</li>
<li>각 엘리먼트에<ul>
<li>아이템 이미지, 이름, 구매가, 판매가, 수량 등의 데이터를 바인딩</li>
<li>구매 버튼 / 판매 버튼 클릭 델리게이트를 바인딩하여 상위 위젯 (<code>UCMShopWidget</code>) 의 <code>HandleElementBuyRequested</code>, <code>HandleElementSellRequested</code> 와 연결</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>개별 상점 아이템 위젯 (<code>UCMShopContentElementWidget</code>)</strong></p>
<ul>
<li>역할<ul>
<li>한 개의 <code>FCMShopItemContent</code> 를 시각화</li>
<li>이미지, 이름, 가격(구매/판매), 수량 등을 표시</li>
<li>&quot;구매&quot; 버튼 / &quot;판매&quot; 버튼 UI 제공</li>
</ul>
</li>
<li>이벤트<ul>
<li>구매 버튼 클릭 → 상위 위젯으로 &quot;이 아이템을 구매하고 싶다&quot; 이벤트 전파</li>
<li>판매 버튼 클릭 → 상위 위젯으로 &quot;이 아이템을 판매하고 싶다&quot; 이벤트 전파</li>
</ul>
</li>
<li>상위 위젯에서 실제 로직(인벤토리 차감, 골드 변경, 서버 검증 등)을 처리할 수 있도록 설계</li>
</ul>
</li>
<li><p><strong>Host(리슨 서버) / 클라이언트 동작 차이 정리</strong></p>
<ul>
<li>공통<ul>
<li><code>UCMNpcShopComponent::PerformAction()</code> → <code>ACMPlayerController::RequestOpenShopUI(NpcId)</code> 호출</li>
</ul>
</li>
<li>Host (ListenServer)<ul>
<li><code>RequestOpenShopUI</code> → <code>Server_RequestShopData</code> 가 같은 프로세스의 서버 컨텍스트에서 실행</li>
<li>서버 내부에서 <code>NpcWorldSubsystem</code> 으로 상점 데이터 조회 후 <code>CreateShopWidget</code> 을 직접 호출</li>
<li>별도의 클라 RPC 없이도 Host 화면에 상점 UI 표시</li>
</ul>
</li>
<li>순수 클라이언트<ul>
<li><code>RequestOpenShopUI</code> → Server RPC 로 서버에 상점 데이터 요청</li>
<li>서버에서 상점 데이터 구성 후 <code>Client_ReceiveShopDataAndOpen</code> 으로 돌려줌</li>
<li>클라이언트에서 <code>CreateShopWidget</code> 을 호출해 UIManager로 Push</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>디버깅용 로그 추가</strong></p>
<ul>
<li><code>CreateShopWidget</code><ul>
<li>인자로 전달된 <code>ShopItems.Num()</code> 과 첫 번째 아이템의 <code>ItemID</code>, <code>ItemName</code>, <code>BuyPrice</code>, <code>SellPrice</code>, <code>Quantity</code> 를 로그로 출력</li>
<li>서버에서 받은 상점 데이터가 실제로 클라이언트까지 전달되었는지 확인하는 데 사용</li>
</ul>
</li>
<li><code>Server_RequestShopData</code><ul>
<li><code>NpcId</code> 와 NPC/ShopComponent 유효성 체크 로그 출력</li>
</ul>
</li>
<li><code>UCMNpcShopComponent::PerformAction</code><ul>
<li>PerformAction 이 실제로 호출되는지, Owner NPC 및 <code>NpcId</code> 가 유효한지 확인하는 로그 출력</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>오늘은 NPC 상점 시스템의 전체 흐름을 정리하고, 특히 <code>NpcWorldSubsystem</code> 기반의 NPC 캐싱과 <code>UCMNpcShopComponent</code> → <code>ACMPlayerController</code> → <code>UCMShopWidget</code> 으로 이어지는 상점 UI 생성 파이프라인을 점검하였습니다. Host(리슨 서버) 환경과 순수 클라이언트 환경에서의 동작 차이를 고려하여 Server RPC 및 Client RPC 분기를 설계한 덕분에, 네트워크 환경에 따라 상점 UI가 일관되게 동작하도록 만들 수 있었습니다. 앞으로는 이 구조 위에 실제 구매/판매 서버 검증 로직과 인벤토리, 골드 연동 로직을 추가하여 완성도 높은 상점 시스템으로 발전시켜 나가고자 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] 상점 UI 설계]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%EC%83%81%EC%A0%90-UI-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%EC%83%81%EC%A0%90-UI-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Tue, 23 Dec 2025 12:01:22 GMT</pubDate>
            <description><![CDATA[<p>오늘은 언리얼 엔진 UMG를 사용해 상점(Shop) UI를 설계·구현한 과정을 정리해 보았습니다. 특히 <strong>아이템별 개별 위젯 구성</strong>, <strong>구매/판매 가격 분리</strong>, <strong>아이템 ID 관리</strong>, <strong>Vertical List 기반 아이템 나열</strong>에 초점을 두고 작업을 진행하였습니다. 본 문서는 추후 상점 기능을 확장하거나 다른 인벤토리/상점 UI를 설계할 때 참고 자료로 활용하기 위해 작성하였습니다.</p>
<hr>
<h2 id="상점-요소-위젯ucmshopcontentelementwidget-설계">상점 요소 위젯(UCMShopContentElementWidget) 설계</h2>
<ul>
<li>목적<ul>
<li>상점 리스트에서 <strong>한 개의 아이템</strong>을 표현하는 전용 위젯</li>
<li>UI 관점에서 재사용 가능한 단위 컴포넌트로 설계</li>
</ul>
</li>
</ul>
<ul>
<li>표시 요소<ul>
<li>아이템 이미지: <code>UImage* ItemImage</code></li>
<li>아이템 이름: <code>UTextBlock* ItemNameText</code></li>
<li>구매 가격: <code>UTextBlock* BuyPriceText</code></li>
<li>판매 가격: <code>UTextBlock* SellPriceText</code></li>
</ul>
</li>
</ul>
<ul>
<li>동작 요소 (버튼)<ul>
<li>구매 버튼: <code>UButton* BuyButton</code></li>
<li>판매 버튼: <code>UButton* SellButton</code></li>
</ul>
</li>
</ul>
<ul>
<li>데이터 필드<ul>
<li>아이템 아이콘: <code>TObjectPtr&lt;UTexture2D&gt; ItemIcon</code></li>
<li>아이템 이름: <code>FText ItemName</code></li>
<li>구매 가격: <code>int32 BuyPrice</code></li>
<li>판매 가격: <code>int32 SellPrice</code></li>
<li>아이템 식별자: <code>int32 ItemId</code> 또는 <code>FName ItemId</code> (프로젝트 상황에 맞게 선택)</li>
</ul>
</li>
</ul>
<ul>
<li>핵심 설정 함수<ul>
<li><code>SetItemDisplayData(UTexture2D* InIcon, const FText&amp; InName, int32 InBuyPrice, int32 InSellPrice, int32 InItemId)</code><ul>
<li>아이콘, 이름, 구매/판매 가격, 아이템 ID를 한 번에 세팅</li>
<li>내부 Transient 필드에 값 저장 후, 바인딩된 위젯에 텍스트/이미지 반영</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>버튼 활성 제어<ul>
<li><code>SetButtonsEnabled(bool bEnableBuy, bool bEnableSell)</code><ul>
<li>상황에 따라 구매만 가능 / 판매만 가능 / 둘 다 비활성 등을 제어</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>버튼 클릭 이벤트 처리 흐름<ul>
<li><code>NativeOnInitialized()</code>에서 버튼 클릭 바인딩<ul>
<li><code>BuyButton-&gt;OnClicked.AddDynamic(this, &amp;UCMShopContentElementWidget::HandleBuyClicked);</code></li>
<li><code>SellButton-&gt;OnClicked.AddDynamic(this, &amp;UCMShopContentElementWidget::HandleSellClicked);</code></li>
</ul>
</li>
<li>내부 핸들러에서 델리게이트 브로드캐스트<ul>
<li><code>OnBuyRequested.Broadcast(this);</code></li>
<li><code>OnSellRequested.Broadcast(this);</code></li>
</ul>
</li>
<li>외부(상점 메인 위젯)가 이 델리게이트에 바인딩해 실제 구매/판매 로직을 처리</li>
</ul>
</li>
</ul>
<ul>
<li>외부에서 참조 가능한 Getter<ul>
<li><code>GetBuyPrice()</code> / <code>GetSellPrice()</code></li>
<li><code>GetItemName()</code> / <code>GetItemIcon()</code></li>
<li><code>GetItemId()</code></li>
<li>상위 위젯이 ElementWidget을 통해 아이템 정보를 재조회할 수 있도록 설계</li>
</ul>
</li>
</ul>
<hr>
<h2 id="상점-데이터-구조fcmshopitem-설계">상점 데이터 구조(FCMShopItem) 설계</h2>
<ul>
<li>목적<ul>
<li>상점에 노출할 아이템 정보를 모아 관리하기 위한 구조체</li>
<li>상점 위젯이 <code>TArray&lt;FCMShopItem&gt;</code>를 기준으로 리스트를 생성하도록 설계</li>
</ul>
</li>
</ul>
<ul>
<li>필드 구성<ul>
<li><code>ItemID</code>: 아이템 고유 식별자 (int32)</li>
<li><code>BuyPrice</code>: 구매 가격</li>
<li><code>SellPrice</code>: 판매 가격</li>
<li><code>Quantity</code>: 수량 (스택 수, 재고 등 다양한 용도로 확장 가능)</li>
<li><code>ItemName</code>: UI 표시용 이름</li>
<li><code>ItemIcon</code>: UI 표시용 아이콘 텍스처</li>
</ul>
</li>
</ul>
<ul>
<li>설계 포인트<ul>
<li>Blueprint에서도 손쉽게 세팅할 수 있도록 <code>BlueprintType</code>, <code>EditAnywhere</code>, <code>BlueprintReadWrite</code>로 설정</li>
<li>실제 게임 데이터(데이터 테이블, 데이터 애셋 등)와 연결하거나, 임시 테스트 데이터로도 사용 가능하게 유연하게 설계</li>
</ul>
</li>
</ul>
<hr>
<h2 id="상점-메인-위젯ucmshopwidget-설계">상점 메인 위젯(UCMShopWidget) 설계</h2>
<ul>
<li>역할<ul>
<li>전체 상점 화면을 담당하는 메인 컨테이너 위젯</li>
<li><code>VerticalBox</code> 안에 여러 <code>UCMShopContentElementWidget</code>을 동적으로 생성하여 리스트를 구성</li>
</ul>
</li>
</ul>
<ul>
<li>주요 멤버<ul>
<li><code>ShopItemListBox</code><ul>
<li>타입: <code>UVerticalBox*</code></li>
<li>역할: 상점 아이템들을 세로로 나열하는 컨테이너</li>
<li>UMG 디자이너에서 <code>BindWidget</code> 이름과 동일하게 배치</li>
</ul>
</li>
<li><code>ShopItemWidgetClass</code><ul>
<li>타입: <code>TSubclassOf&lt;UCMShopContentElementWidget&gt;</code></li>
<li>역할: 각 아이템 엘리먼트로 생성할 위젯 클래스 (BP 기반으로 지정)</li>
</ul>
</li>
<li><code>ShopItems</code><ul>
<li>타입: <code>TArray&lt;FCMShopItem&gt;</code></li>
<li>역할: 실제 상점에 표시할 아이템 데이터 목록</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>초기화 흐름<ul>
<li>생성자: <code>UCMShopWidget(const FObjectInitializer&amp; ObjectInitializer)</code></li>
<li><code>NativeOnInitialized()</code>에서 초기 리스트 빌드 호출<ul>
<li><code>BuildShopList();</code></li>
<li>디자이너에서 <code>ShopItems</code>를 미리 세팅해 둔 경우 바로 UI에 리스트가 생성되도록 구성</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>리스트 빌드 로직 (<code>BuildShopList</code>)<ul>
<li><code>ShopItemListBox</code> 또는 <code>ShopItemWidgetClass</code>가 유효하지 않으면 조기 리턴</li>
<li>기존 자식 위젯 제거: <code>ShopItemListBox-&gt;ClearChildren();</code></li>
<li><code>ShopItems</code> 배열을 순회하며 다음 작업 수행<ul>
<li><code>CreateWidget&lt;UCMShopContentElementWidget&gt;(this, ShopItemWidgetClass)</code>로 엘리먼트 위젯 생성</li>
<li><code>SetItemDisplayData</code>로 이미지/이름/구매·판매 가격/ID 설정</li>
<li><code>OnBuyRequested</code>, <code>OnSellRequested</code> 델리게이트를 메인 위젯의 핸들러에 바인딩</li>
<li><code>ShopItemListBox-&gt;AddChild(ElementWidget)</code>으로 VerticalBox에 추가</li>
<li><code>UVerticalBoxSlot</code>로 캐스팅해 정렬, 패딩 등 레이아웃 세부 설정</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>상점 데이터 갱신 (<code>SetShopItems</code>)<ul>
<li>외부에서 <code>TArray&lt;FCMShopItem&gt;</code>을 넘겨받아 <code>ShopItems</code>를 교체</li>
<li>교체 직후 <code>BuildShopList()</code> 재호출로 UI를 바로 갱신</li>
<li>코드/블루프린트 어디서든 상점 데이터만 갈아끼우면 UI 전체가 새로 그려지는 구조</li>
</ul>
</li>
</ul>
<ul>
<li>버튼 클릭 이벤트 최종 처리<ul>
<li><code>HandleElementBuyRequested(UCMShopContentElementWidget* ElementWidget)</code><ul>
<li><code>ElementWidget-&gt;GetItemId()</code>와 <code>GetBuyPrice()</code>로 어떤 아이템인지, 얼마인지 확인</li>
<li>실제 구매 로직 (골드 차감, 인벤토리 추가 등)과 연결 예정</li>
</ul>
</li>
<li><code>HandleElementSellRequested(UCMShopContentElementWidget* ElementWidget)</code><ul>
<li><code>ElementWidget-&gt;GetItemId()</code>와 <code>GetSellPrice()</code>를 사용</li>
<li>실제 판매 로직 (인벤토리에서 제거, 골드 지급 등)과 연결 예정</li>
</ul>
</li>
<li>상점 위젯은 <strong>UI 이벤트를 게임 로직 레이어로 전달하는 허브</strong> 역할을 담당</li>
</ul>
</li>
</ul>
<hr>
<h2 id="umg-에디터에서의-연결-작업-정리">UMG 에디터에서의 연결 작업 정리</h2>
<ul>
<li>상점 메인 위젯 BP (<code>UCMShopWidget</code> 기반)<ul>
<li>위젯 트리에서 <code>VerticalBox</code> 추가 후 이름을 <code>ShopItemListBox</code>로 지정</li>
<li>클래스 디폴트에서 <code>ShopItemWidgetClass</code>에 <code>UCMShopContentElementWidget</code> 기반 BP를 할당</li>
<li>테스트용으로 <code>ShopItems</code> 배열에 몇 개의 아이템 데이터 직접 입력해 즉시 미리보기 가능하게 설정</li>
</ul>
</li>
</ul>
<ul>
<li>상점 요소 위젯 BP (<code>UCMShopContentElementWidget</code> 기반)<ul>
<li>이미지/텍스트/버튼 위젯 배치 후 이름 매칭<ul>
<li><code>ItemImage</code></li>
<li><code>ItemNameText</code></li>
<li><code>BuyPriceText</code></li>
<li><code>SellPriceText</code></li>
<li><code>BuyButton</code></li>
<li><code>SellButton</code></li>
</ul>
</li>
<li>C++의 <code>BindWidgetOptional</code> 속성과 이름이 일치하도록 관리해 자동 바인딩 확인</li>
</ul>
</li>
</ul>
<hr>
<p>오늘은 언리얼 엔진 UMG를 사용해 상점 UI를 설계하고, 아이템 단위 위젯과 상점 메인 위젯 사이의 책임을 분리하는 구조를 구현해 보았습니다. 아이템 이미지, 이름, 구매/판매 가격, 그리고 식별용 ID까지 한 번에 관리할 수 있도록 설계함으로써 추후 실제 게임 데이터 및 인벤토리 시스템과의 연동이 훨씬 수월해질 것으로 기대하고 있습니다.</p>
<p>향후에는 오늘 설계한 UI 구조를 기반으로 실제 골드, 인벤토리, 툴팁, 필터링/정렬 기능 등을 추가하여 보다 완성도 높은 상점 시스템으로 확장해 나갈 계획입니다. 오늘의 정리가 이후 작업에 도움이 되기를 바랍니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] NPC Dialogue & Action 시스템 1차 구현 완료]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-NPC-Dialogue-Action-%EC%8B%9C%EC%8A%A4%ED%85%9C-1%EC%B0%A8-%EA%B5%AC%ED%98%84-%EC%99%84%EB%A3%8C</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-NPC-Dialogue-Action-%EC%8B%9C%EC%8A%A4%ED%85%9C-1%EC%B0%A8-%EA%B5%AC%ED%98%84-%EC%99%84%EB%A3%8C</guid>
            <pubDate>Fri, 12 Dec 2025 11:42:19 GMT</pubDate>
            <description><![CDATA[<p>이번 작업에서는 CrimsonMoon 프로젝트에 NPC 대화(Npc Dialogue) 시스템을 1차적으로 구현하고, 위젯/액터/게임 인스턴스/로컬 이벤트 매니저 간의 구조를 정리하여 구현하였습니다. 특히, C++과 UMG를 섞어 쓰는 환경에서 <strong>델리게이트 기반 이벤트 흐름</strong>과 <strong>위젯/액터 간 의존성 최소화</strong>를 목표로 진행했습니다.</p>
<hr>
<h2 id="전체-구조-개요">전체 구조 개요</h2>
<ul>
<li>목표<ul>
<li>NPC와 상호작용 시 대화 UI 표시</li>
<li>Next 버튼으로 다음 노드 진행</li>
<li>Action 노드가 있을 경우 선택지 버튼 동적 생성</li>
<li>선택지 클릭 시 해당 액션 타입으로 NPC 컴포넌트 동작 수행</li>
<li>대화 종료 시 델리게이트/상태 정리</li>
</ul>
</li>
<li>핵심 구성 요소<ul>
<li><code>ACMNpcBase</code> : NPC 액터, 대화 트리 데이터/진행 로직 보유</li>
<li><code>UCMDialoagueNode</code> / <code>UCMActionNode</code> : 대화 노드 &amp; 액션 노드 트리 구조</li>
<li><code>UCMLocalEventManager</code> : GameInstanceSubsystem, 대화용 델리게이트 허브</li>
<li><code>UCMGameInstance</code> : GameInstance, 로컬 이벤트 매니저/세션 등 상위 관리</li>
<li><code>UCMNpcDialogueWidget</code> : 대화 전체 UI, 텍스트/Next 버튼/선택지 컨테이너</li>
<li><code>UCMNpcDialogueChoiceElement</code> : 개별 선택지 버튼 위젯</li>
<li><code>UUIManagerComponent</code> : PlayerController에 부착, UI 스택 관리 및 뷰포트 표시</li>
</ul>
</li>
</ul>
<hr>
<h2 id="클래스-구조-mermaid-시각화">클래스 구조 (Mermaid 시각화)</h2>
<h3 id="상위-구조-gameinstance--subsystem--npc--ui">상위 구조: GameInstance &amp; Subsystem &amp; NPC &amp; UI</h3>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/13e7c448-f690-492c-9faf-39fb9778cfc3/image.png" alt=""></p>
<h3 id="ui-구조-dialogue-widget--choice-element">UI 구조: Dialogue Widget &amp; Choice Element</h3>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/1258ba38-ac63-437a-ba8b-91aa2e091f2d/image.png" alt=""></p>
<h3 id="대화-노드-구조-노드-트리--액션-노드">대화 노드 구조: 노드 트리 &amp; 액션 노드</h3>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/a5ece104-7d1c-471b-b621-f78e53542d1d/image.png" alt=""></p>
<hr>
<h2 id="이벤트-흐름-상호작용-→-대화-→-선택지-→-종료">이벤트 흐름 (상호작용 → 대화 → 선택지 → 종료)</h2>
<p>다음 플로우 차트는 이벤트 흐름을 나타냅니다. </p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/7108c26f-f400-4b0e-86d8-a1af4246162a/image.png" alt=""></p>
<hr>
<h2 id="구현-상의-핵심-포인트">구현 상의 핵심 포인트</h2>
<ul>
<li><p>GameInstanceSubsystem 활용</p>
<ul>
<li><code>UCMLocalEventManager</code> 를 <code>UGameInstanceSubsystem</code> 으로 두어, 맵 전환과 무관하게 대화 델리게이트를 공유 가능하게 설계</li>
<li>NPC, UI, 기타 시스템이 서로 직접 참조하지 않고 <strong>이벤트 허브</strong>를 통해 느슨하게 연결</li>
</ul>
</li>
<li><p>델리게이트 기반 UI-로직 분리</p>
<ul>
<li>NPC 쪽은 순수한 대화 트리/액션 처리에 집중</li>
<li>UI 쪽은 단지 델리게이트를 통해 텍스트와 선택지 정보를 받아 그리기만 함</li>
<li>이로 인해 UI 변경(디자인 교체, 애니메이션 추가 등)이 NPC 로직에 영향을 주지 않음</li>
</ul>
</li>
<li><p>액션 노드(선택지) 구조</p>
<ul>
<li>대화 트리는 <code>UCMDialoagueNode</code> 를 기본으로 하되, 선택지가 필요한 경우 파생 클래스 <code>UCMActionNode</code> 로 표현</li>
<li>액션 노드는<ul>
<li><code>DialogueText</code> (선택지에 표시할 텍스트)</li>
<li><code>ActionType</code> (ECMNpcComponentType) 을 가지며,</li>
<li>선택 시 NPC가 어떤 컴포넌트의 어떤 액션을 수행해야 하는지 연결고리 역할을 수행</li>
</ul>
</li>
</ul>
</li>
<li><p>EndDialogue 시 델리게이트 해제 철저</p>
<ul>
<li>대화 한 번 끝난 후에도 델리게이트가 남아있으면<ul>
<li>다음 대화에서 중복 호출, 크래시, 메모리 누수의 원인이 될 수 있음</li>
</ul>
</li>
<li><code>EndDialogue()</code> 에서 NPC ↔ LocalEventManager 간의 모든 바인딩을 명시적으로 해제</li>
</ul>
</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 NPC 대화 시스템 구현은 <strong>데이터 구조(트리 기반 대화 노드)</strong>, <strong>델리게이트 기반 이벤트 흐름</strong>, <strong>UI/로직 분리</strong>, <strong>GameInstanceSubsystem 활용</strong>이라는 네 가지 축을 중심으로 설계하고 구현하였습니다. 그 과정에서 UMG 바인딩, 델리게이트 시그니처, 포인터 관리 등 언리얼 특유의 함정들을 실제로 밟아 보며 정리할 수 있었고, 이를 통해 프로젝트 전반에서 재사용 가능한 패턴을 쌓을 수 있었다고 생각합니다.</p>
<p>앞으로는 이 구조 위에 퀘스트 시스템 연동, 세이브/로드 시 대화 상태 복원, 로컬라이제이션(다국어 지원) 등을 확장해 나가면서 NPC와의 상호작용 경험을 더욱 풍부하게 만들어 볼 계획입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] NPC Dialogue 시스템 구현 중간 정리]]></title>
            <link>https://velog.io/@dev_sensational/NPC-Dialogue-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84-%EC%A4%91%EA%B0%84-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@dev_sensational/NPC-Dialogue-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84-%EC%A4%91%EA%B0%84-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Thu, 11 Dec 2025 14:39:36 GMT</pubDate>
            <description><![CDATA[<h2 id="목표">목표</h2>
<ul>
<li>NPC와 상호작용 시 대화 UI 위젯을 스택 기반으로 표시/제거</li>
<li>대화 노드 트리 기반의 대사 진행, 액션 노드 기반 선택지 버튼 생성</li>
<li>GameInstanceSubsystem(UCMLocalEventManager) 를 통한 델리게이트 브로드캐스트 구조 정립</li>
</ul>
<hr>
<h2 id="대화-데이터-구조-설계-acmnpcbase">대화 데이터 구조 설계 (ACMNpcBase)</h2>
<ul>
<li><code>UCMDialoagueNode</code><ul>
<li>부모 노드: <code>ParentNode</code></li>
<li>자식 노드 배열: <code>TArray&lt;UCMDialoagueNode*&gt; Children</code></li>
<li>대사 텍스트: <code>FText DialogueText</code></li>
</ul>
</li>
<li><code>UCMActionNode : UCMDialoagueNode</code><ul>
<li>액션 타입: <code>ECMNpcComponentType ActionType</code></li>
</ul>
</li>
<li><code>ACMNpcBase</code><ul>
<li>루트 노드: <code>RootDialogueNode</code></li>
<li>전체 노드 리스트: <code>AllDialogueNodes</code></li>
<li>현재 노드: <code>CurrentNode</code></li>
<li>노드 생성 함수들:<ul>
<li><code>CreateDialogueNode(...)</code></li>
<li><code>CreateDialogueNodeWithSettings(...)</code></li>
<li><code>CreateActionNodeWithSettings(...)</code></li>
</ul>
</li>
<li>트리 탐색/디버그<ul>
<li><code>LogAllDialogueNodeTexts()</code> 로 전체 노드 로그</li>
<li><code>MoveToChildNodeByIndex(int32 ChildIndex)</code> 로 자식 인덱스 기반 이동</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="npc-상호작용-흐름-acmnpcbase">NPC 상호작용 흐름 (ACMNpcBase)</h2>
<ul>
<li><code>BeginPlay()</code><ul>
<li>NpcComponents 기반으로 <code>UCMNpcComponentBase</code> 파생 컴포넌트 생성 및 등록</li>
<li>1초 후 <code>LogAllDialogueNodeTexts()</code> 실행 (디버그용)</li>
<li>5초 후 <code>PerformInteract()</code> 자동 호출 (테스트용)</li>
</ul>
</li>
<li><code>PerformInteract()</code><ul>
<li>현재 구현: <code>StartDialogue()</code> 호출</li>
<li>후속 작업: 필요 시 여기서 UI 생성, 대사 초기화 등 추가</li>
</ul>
</li>
<li><code>HandleActionByType(ECMNpcComponentType)</code><ul>
<li>등록된 컴포넌트 맵에서 타입에 해당하는 컴포넌트 찾아 <code>PerformAction()</code> 호출</li>
</ul>
</li>
</ul>
<hr>
<h2 id="gameinstancesubsystem-ucmlocaleventmanager">GameInstanceSubsystem: UCMLocalEventManager</h2>
<ul>
<li>역할: NPC ↔ UI 간 로컬 이벤트 허브</li>
<li>델리게이트 정의<ul>
<li><code>FOnNextDialogueNodeRequested</code><ul>
<li>다음 대화 노드 요청 (예: UI Next 버튼)</li>
</ul>
</li>
<li><code>FOnPrintDialogueNodeText(const FText&amp; DialogueText)</code><ul>
<li>현재 노드 대사 텍스트 브로드캐스트</li>
</ul>
</li>
<li><code>FOnCreateChoiceButton(const FText&amp; DialogueText, ECMNpcComponentType ComponentType)</code><ul>
<li>선택지 버튼 생성 요청 (액션 노드 정보 전달)</li>
</ul>
</li>
<li><code>FOnSelectedDialogueChoice(ECMNpcComponentType ComponentType)</code><ul>
<li>플레이어가 선택지 선택 시 브로드캐스트</li>
</ul>
</li>
</ul>
</li>
<li><code>UPROPERTY(BlueprintAssignable)</code> 로 모두 블루프린트에서 바인딩 가능하도록 노출</li>
</ul>
<hr>
<h2 id="npc와-localeventmanager-연동-acmnpcbase">NPC와 LocalEventManager 연동 (ACMNpcBase)</h2>
<ul>
<li><code>StartDialogue()</code><ul>
<li>GameInstance에서 <code>UCMLocalEventManager</code> 서브시스템 획득 및 캐싱</li>
<li><code>LocalEventManager-&gt;OnNextDialogueNodeRequested.AddDynamic(this, &amp;ACMNpcBase::OnNextDialogueNodeRequested);</code></li>
</ul>
</li>
<li><code>OnNextDialogueNodeRequested()</code> 구현<ul>
<li>전제: <code>CurrentNode</code> 가 설정되어 있어야 함</li>
<li>로직:<ol>
<li><code>CurrentNode</code> 가 nullptr 이면 리턴 + 경고 로그</li>
<li><code>CurrentNode-&gt;Children.Num()</code> 이 <strong>1이 아닐 경우</strong> 이동하지 않고 로그만 출력</li>
<li>자식이 1개일 때만 <code>CurrentNode</code> 를 그 자식 노드로 변경</li>
<li>변경된 노드의 <code>DialogueText</code> 를 <code>LocalEventManager-&gt;OnPrintDialogueNodeText.Broadcast(...)</code> 로 브로드캐스트</li>
<li>새 <code>CurrentNode</code> 의 <code>Children</code> 를 순회하면서 <code>UCMActionNode</code> 인 자식을 탐색<ul>
<li>각 액션 노드에 대해 <code>OnCreateChoiceButton.Broadcast(ActionNode-&gt;DialogueText, ActionNode-&gt;ActionType)</code> 호출</li>
</ul>
</li>
</ol>
</li>
</ul>
</li>
<li>(추가 예정) <code>HandleChoiceSelected(ECMNpcComponentType)</code><ul>
<li><code>FOnSelectedDialogueChoice</code> 를 수신하여 <code>HandleActionByType</code> 와 연동 예정</li>
</ul>
</li>
</ul>
<hr>
<h2 id="npc-대화-ui-위젯-구조-ucmnpcdialoguewidget">NPC 대화 UI 위젯 구조 (UCMNpcDialogueWidget)</h2>
<ul>
<li>상속: <code>UCMWidgetBase</code></li>
<li>바인딩 프로퍼티<ul>
<li><code>FText NpcNameText</code> / <code>NpcNameTextBlock</code></li>
<li><code>FText DialogueText</code> / <code>DialogueTextBlock</code></li>
<li><code>UButton* NextButton</code></li>
<li><code>UVerticalBox* ChoicesContainer</code></li>
<li><code>TArray&lt;UCMNpcDialogueChoiceElement*&gt; ChoiceWidgets</code></li>
<li>에디터에서 설정 가능한 선택지 클래스: <code>TSubclassOf&lt;UCMNpcDialogueChoiceElement&gt; ChoiceElementClass</code></li>
</ul>
</li>
<li>주요 함수<ul>
<li><code>NativeConstruct()</code><ul>
<li><code>NextButton-&gt;OnClicked.AddDynamic(this, &amp;UCMNpcDialogueWidget::OnNextDialogue);</code></li>
<li><code>ClearChoices()</code> 로 기존 선택지 초기화</li>
</ul>
</li>
<li><code>OnNextDialogue()</code><ul>
<li>내부에서 <strong>LocalEventManager의 OnNextDialogueNodeRequested 를 브로드캐스트</strong> 하도록 구현 예정 (현재는 TODO 상태로 비워둠)</li>
</ul>
</li>
<li><code>ClearChoices()</code><ul>
<li><code>ChoicesContainer-&gt;ClearChildren();</code></li>
<li><code>ChoiceWidgets.Empty();</code></li>
</ul>
</li>
<li><code>AddChoice(const FText&amp; InChoiceText, ECMNpcComponentType InComponentType)</code><ul>
<li>사용할 클래스 결정: <code>ChoiceElementClass</code> 가 있으면 그 BP, 없으면 <code>UCMNpcDialogueChoiceElement::StaticClass()</code></li>
<li><code>CreateWidget</code> 로 인스턴스 생성 후 텍스트 세팅, <code>ChoicesContainer</code> 에 AddChild, <code>ChoiceWidgets</code> 에 보관</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="선택지-ui-엘리먼트-ucmnpcdialoguechoiceelement">선택지 UI 엘리먼트 (UCMNpcDialogueChoiceElement)</h2>
<ul>
<li>상속: <code>UCMWidgetBase</code></li>
<li>프로퍼티<ul>
<li><code>FText ChoiceText</code></li>
<li><code>UButton* ChoiceButton</code></li>
<li><code>UTextBlock* ChoiceTextBlock</code></li>
</ul>
</li>
<li>델리게이트<ul>
<li><code>FOnChoiceClicked</code> (BlueprintAssignable)</li>
</ul>
</li>
<li>동작<ul>
<li><code>NativeConstruct()</code> 에서 <code>ChoiceButton-&gt;OnClicked</code> 에 내부 핸들러 바인딩</li>
<li><code>HandleChoiceButtonClicked()</code> → <code>OnChoiceClicked.Broadcast()</code> 호출</li>
<li><code>SetChoiceText(const FText&amp; InText)</code> 로 내부 텍스트와 TextBlock 동기화</li>
</ul>
</li>
</ul>
<hr>
<h2 id="델리게이트-연결-요약">델리게이트 연결 요약</h2>
<ul>
<li><code>UCMLocalEventManager</code><ul>
<li><code>OnNextDialogueNodeRequested</code><ul>
<li>(UI → NPC) Next 버튼으로 다음 노드 요청</li>
</ul>
</li>
<li><code>OnPrintDialogueNodeText</code><ul>
<li>(NPC → UI) 현재 대사 텍스트 전달</li>
</ul>
</li>
<li><code>OnCreateChoiceButton</code><ul>
<li>(NPC → UI) 선택지 버튼 정보 전달</li>
</ul>
</li>
<li><code>OnSelectedDialogueChoice</code><ul>
<li>(UI → NPC) 플레이어가 선택한 액션 타입 전달</li>
</ul>
</li>
</ul>
</li>
<li>현재 상태<ul>
<li>NPC 쪽: <code>StartDialogue()</code> 에서 <code>OnNextDialogueNodeRequested</code> 에 바인딩, <code>OnNextDialogueNodeRequested()</code> 구현 완료</li>
<li>UI 쪽: <code>NextButton</code> → <code>OnNextDialogue()</code> → LocalEventManager 브로드캐스트 부분은 TODO</li>
<li>선택지 클릭 → <code>OnSelectedDialogueChoice</code> 브로드캐스트, NPC의 <code>HandleChoiceSelected</code> 에서 처리하는 구조는 설계만 완료</li>
</ul>
</li>
</ul>
<hr>
<h2 id="요약">요약</h2>
<ul>
<li>UE5에서 <strong>GameInstanceSubsystem(UCMLocalEventManager)</strong> 를 사용해 NPC와 UI 사이의 대화 이벤트를 느슨하게 연결하는 패턴을 정리했다.</li>
<li>NPC 하나가 대화 트리(UCMDialoagueNode/UCMActionNode)를 소유하고, LocalEventManager 델리게이트를 통해 <strong>다음 노드 이동, 대사 텍스트 브로드캐스트, 선택지 생성</strong>까지 책임지도록 설계했다.</li>
<li>UI는 PlayerController 가 가진 <code>UUIManagerComponent</code> 의 스택을 통해 관리하며, <strong>대화 위젯을 푸시/팝</strong>하는 방식으로 게임 입력 모드와도 자연스럽게 연동했다.</li>
<li>아직 남은 TODO는 <strong>Next 버튼에서 OnNextDialogueNodeRequested 브로드캐스트</strong>, <strong>선택지 클릭 → OnSelectedDialogueChoice 브로드캐스트 → NPC HandleActionByType 연동</strong> 이며, 이 부분을 다음 단계에서 마무리할 예정.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] Tree 구조의 Dialogue 시스템 구현]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-Tree-%EA%B5%AC%EC%A1%B0%EC%9D%98-Dialogue-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-Tree-%EA%B5%AC%EC%A1%B0%EC%9D%98-Dialogue-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Tue, 09 Dec 2025 11:35:04 GMT</pubDate>
            <description><![CDATA[<p>이번 기능에서는 NPC가 단순히 한 줄 대사만 출력하는 수준을 넘어서, <strong>트리 형태로 분기되는 대화 시스템</strong>을 직접 설계하고 구현해 보았습니다. 언리얼 엔진의 <code>UObject</code> / <code>UActorComponent</code> / <code>AActor</code> 구조를 활용해, 블루프린트에서도 직관적으로 편집 가능한 형태를 목표로 했습니다. 특히, &quot;대화를 따라가다 특정 노드에서 액션(상점 열기 등)을 실행&quot;하는 흐름까지 테스트 코드로 구축해 본 것이 핵심입니다.</p>
<hr>
<h2 id="전체-구조-개요">전체 구조 개요</h2>
<ul>
<li><p>핵심 타입</p>
<ul>
<li><code>ECMNpcComponentType</code> : NPC 컴포넌트의 종류를 표현하는 enum</li>
<li><code>UCMDialoagueNode</code> : 기본 대화 노드(텍스트, 부모/자식 참조 포함)</li>
<li><code>UCMActionNode</code> : 기본 노드를 상속받아, 추가로 액션 타입을 가지는 노드</li>
<li><code>ACMNpcBase</code> : NPC 액터, 대화 트리 전체를 소유/관리</li>
<li><code>UCMNpcComponentBase</code> : NPC용 공통 컴포넌트 베이스</li>
<li><code>UCMNpcShopComponent</code> : 예시용 상점 컴포넌트(PerformAction 시 화면 메시지 출력)</li>
</ul>
</li>
<li><p>설계 포인트</p>
<ul>
<li><strong>대화 노드는 UObject(UCLASS)</strong> 로 구현 → GC 및 블루프린트 호환성 확보</li>
<li><strong>트리 구조</strong>는 <code>ParentNode</code> + <code>Children</code> 배열로 표현</li>
<li>NPC 액터(<code>ACMNpcBase</code>)가<ul>
<li><code>RootDialogueNode</code> (루트 노드)</li>
<li><code>AllDialogueNodes</code> (생성된 모든 노드)
를 UPROPERTY로 보유 → GC에 안전하고, 순회/디버그에 용이</li>
</ul>
</li>
<li>액션 노드는 별도 타입(<code>UCMActionNode</code>)으로 구분<ul>
<li><code>ActionType: ECMNpcComponentType</code></li>
<li>트리 순회 중 액션 노드를 만나면 <code>HandleActionByType</code> 호출</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="ecmnpccomponenttype-블루프린트에서-쓰는-컴포넌트-타입-enum">ECMNpcComponentType: 블루프린트에서 쓰는 컴포넌트 타입 enum</h2>
<pre><code class="language-cpp">UENUM(BlueprintType)
enum class ECMNpcComponentType: uint8
{
    Default UMETA(DisplayName = &quot;Default&quot;),
    DialogueComponent UMETA(DisplayName = &quot;Dialogue Component&quot;),
    QuestComponent UMETA(DisplayName = &quot;Quest Component&quot;),
    ShopComponent UMETA(DisplayName = &quot;Shop Component&quot;),
};</code></pre>
<ul>
<li>역할<ul>
<li>NPC에 붙는 컴포넌트의 종류(대화, 퀘스트, 상점 등)를 식별하기 위한 enum</li>
<li><code>ACMNpcBase</code>의 <code>RegisteredComponentMap</code> 키로 사용</li>
<li><code>UCMActionNode</code>의 <code>ActionType</code>에도 사용 → 트리 상에서 어느 컴포넌트를 실행할지 지정</li>
</ul>
</li>
</ul>
<ul>
<li>선언 방식<ul>
<li>언리얼 리플렉션 + 블루프린트 노출을 위해 <code>UENUM(BlueprintType)</code> 사용</li>
<li>기본형은 <code>int</code> (언더라이잉 타입 명시 생략)으로 유지</li>
</ul>
</li>
</ul>
<hr>
<h2 id="ucmdialoaguenode-기본-대화-노드-설계">UCMDialoagueNode: 기본 대화 노드 설계</h2>
<ul>
<li>타입<ul>
<li><code>UCLASS(BlueprintType) class UCMDialoagueNode : public UObject</code></li>
</ul>
</li>
</ul>
<pre><code class="language-cpp">UCLASS(BlueprintType)
class UCMDialoagueNode : public UObject
{
    GENERATED_BODY()

public:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category=&quot;Dialogue&quot;)
    TObjectPtr&lt;UCMDialoagueNode&gt; ParentNode = nullptr;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=&quot;Dialogue&quot;)
    TArray&lt;TObjectPtr&lt;UCMDialoagueNode&gt;&gt; Children;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=&quot;Dialogue&quot;)
    FText DialogueText;
};</code></pre>
<ul>
<li><p>주요 필드</p>
<ul>
<li><code>ParentNode : UCMDialoagueNode*</code><ul>
<li>UPROPERTY(VisibleAnywhere, BlueprintReadOnly)</li>
<li>상위 노드 참조 (루트의 경우 nullptr)</li>
</ul>
</li>
<li><code>Children : TArray&lt;UCMDialoagueNode*&gt;</code><ul>
<li>UPROPERTY(EditAnywhere, BlueprintReadWrite)</li>
<li>자식 노드들(다중 분기 지원)</li>
</ul>
</li>
<li><code>DialogueText : FText</code><ul>
<li>UPROPERTY(EditAnywhere, BlueprintReadWrite)</li>
<li>실제로 화면/UI에 보여줄 대사 텍스트</li>
</ul>
</li>
</ul>
</li>
<li><p>특징</p>
<ul>
<li>UObject 기반이라 <strong>언리얼 GC 관리 대상</strong></li>
<li>블루프린트에서<ul>
<li>노드의 텍스트를 수정</li>
<li>Parent/Children를 직접 연결하여 트리 구성 가능</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="ucmactionnode-액션을-수행하는-특수-대화-노드">UCMActionNode: 액션을 수행하는 특수 대화 노드</h2>
<ul>
<li>타입<ul>
<li><code>UCLASS(BlueprintType) class UCMActionNode : public UCMDialoagueNode</code></li>
</ul>
</li>
</ul>
<pre><code class="language-cpp">UCLASS(BlueprintType)
class UCMActionNode : public UCMDialoagueNode
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=&quot;Dialogue&quot;)
    ECMNpcComponentType ActionType;
};</code></pre>
<ul>
<li><p>추가 필드</p>
<ul>
<li><code>ActionType : ECMNpcComponentType</code><ul>
<li>UPROPERTY(EditAnywhere, BlueprintReadWrite)</li>
<li>이 노드에 도달했을 때 어떤 타입의 NPC 컴포넌트를 실행할지 지정</li>
</ul>
</li>
</ul>
</li>
<li><p>동작</p>
<ul>
<li>트리 순회 중 <code>UCMActionNode</code>로 캐스팅에 성공하면<ul>
<li><code>ActionType</code> 값을 읽어</li>
<li><code>ACMNpcBase::HandleActionByType(ActionType)</code> 호출</li>
</ul>
</li>
<li>예: <code>ShopComponent</code> → 상점 열기 컴포넌트의 <code>PerformAction</code> 호출</li>
</ul>
</li>
</ul>
<hr>
<h2 id="acmnpcbase-npc-액터와-대화-트리-관리">ACMNpcBase: NPC 액터와 대화 트리 관리</h2>
<ul>
<li><p>타입</p>
<ul>
<li><code>class ACMNpcBase : public AActor, public ICMNpcHandler</code></li>
</ul>
</li>
<li><p>주요 프로퍼티</p>
<ul>
<li><code>RootDialogueNode : UCMDialoagueNode*</code><ul>
<li>대화 트리의 루트 노드</li>
</ul>
</li>
<li><code>AllDialogueNodes : TArray&lt;UCMDialoagueNode*&gt;</code><ul>
<li>생성된 모든 대화 노드 모음 (GC + 디버그용)</li>
</ul>
</li>
<li><code>CurrentNode : UCMDialoagueNode*</code><ul>
<li>런타임에 현재 플레이어가 위치한 노드(향후 사용 예정)</li>
</ul>
</li>
<li><code>NpcComponents : TArray&lt;TSubclassOf&lt;UCMNpcComponentBase&gt;&gt;</code><ul>
<li>에디터에서 지정하는 NPC용 컴포넌트 클래스 목록</li>
</ul>
</li>
<li><code>ActiveNpcComponents : TArray&lt;UCMNpcComponentBase*&gt;</code><ul>
<li>BeginPlay에서 실제 인스턴스로 생성 후 보관</li>
</ul>
</li>
<li><code>RegisteredComponentMap : TMap&lt;ECMNpcComponentType, UCMNpcComponentBase*&gt;</code><ul>
<li>각 컴포넌트 타입에 해당하는 인스턴스를 등록/맵핑</li>
</ul>
</li>
</ul>
</li>
<li><p>BeginPlay 흐름</p>
<ul>
<li><code>RegisteredComponentMap.Empty()</code> : 초기화</li>
<li><code>NpcComponents</code>를 순회하며<ul>
<li><code>NewObject&lt;UCMNpcComponentBase&gt;(this, ComponentClass)</code> 로 생성</li>
<li><code>RegisterComponent()</code> 호출 → 언리얼 라이프사이클에 등록</li>
<li><code>ActiveNpcComponents</code>에 추가</li>
</ul>
</li>
<li>생성된 각 컴포넌트는 자기 <code>BeginPlay</code>에서<ul>
<li>핸들러(<code>ACMNpcBase</code>)에 <code>RegisterComponent</code>를 호출하여 스스로 등록</li>
</ul>
</li>
<li>마지막에 테스트용:<ul>
<li><code>FTimerHandle</code>을 사용해 BeginPlay + 1초 후 <code>LogAllDialogueNodeTexts()</code> 실행</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="노드-생성-함수-설계-acmnpcbase">노드 생성 함수 설계 (ACMNpcBase)</h2>
<h3 id="createdialoguenode">CreateDialogueNode</h3>
<ul>
<li><p>시그니처</p>
<ul>
<li><code>UCMDialoagueNode* CreateDialogueNode(TSubclassOf&lt;UCMDialoagueNode&gt; NodeClass, const FText&amp; InDialogueText);</code></li>
</ul>
</li>
<li><p>역할</p>
<ul>
<li>특정 <code>NodeClass</code> (기본은 <code>UCMDialoagueNode</code>)로 새 노드를 생성하고 텍스트 설정</li>
<li><code>AllDialogueNodes</code> 배열에 추가</li>
<li>첫 번째 생성 노드는 자동으로 <code>RootDialogueNode</code>로 설정</li>
</ul>
</li>
<li><p>동작 요약</p>
<ul>
<li><code>if (!*NodeClass) NodeClass = UCMDialoagueNode::StaticClass();</code></li>
<li><code>NewObject&lt;UCMDialoagueNode&gt;(this, NodeClass)</code></li>
<li><code>NewNode-&gt;DialogueText = InDialogueText;</code></li>
<li><code>AllDialogueNodes.Add(NewNode);</code></li>
<li><code>RootDialogueNode</code>가 비어 있으면 첫 노드를 루트로 등록</li>
</ul>
</li>
</ul>
<h3 id="createdialoguenodewithsettings">CreateDialogueNodeWithSettings</h3>
<ul>
<li><p>시그니처</p>
<ul>
<li><code>UCMDialoagueNode* CreateDialogueNodeWithSettings(TSubclassOf&lt;UCMDialoagueNode&gt; NodeClass, UCMDialoagueNode* Parent, const FText&amp; InDialogueText);</code></li>
</ul>
</li>
<li><p>역할</p>
<ul>
<li><code>CreateDialogueNode</code>를 호출해 노드를 생성하고</li>
<li>부모/자식 관계를 동시에 설정하는 편의 함수</li>
</ul>
</li>
<li><p>연결 로직</p>
<ul>
<li><code>NewNode-&gt;ParentNode = Parent;</code></li>
<li><code>Parent-&gt;Children.Add(NewNode);</code></li>
</ul>
</li>
</ul>
<h3 id="createactionnodewithsettings">CreateActionNodeWithSettings</h3>
<ul>
<li><p>시그니처</p>
<ul>
<li><code>UCMDialoagueNode* CreateActionNodeWithSettings(TSubclassOf&lt;UCMActionNode&gt; NodeClass, UCMDialoagueNode* Parent, const FText&amp; InDialogueText, ECMNpcComponentType InActionType);</code></li>
</ul>
</li>
<li><p>역할</p>
<ul>
<li><code>UCMActionNode</code> 타입으로 노드를 생성</li>
<li><code>DialogueText</code> + <code>ActionType</code> + 부모/자식 관계를 한 번에 설정</li>
</ul>
</li>
<li><p>동작 요약</p>
<ul>
<li><code>if (!*NodeClass) NodeClass = UCMActionNode::StaticClass();</code></li>
<li><code>NewObject&lt;UCMActionNode&gt;(this, NodeClass)</code> 로 액션 노드 생성</li>
<li><code>NewNode-&gt;DialogueText = InDialogueText;</code></li>
<li><code>NewNode-&gt;ActionType = InActionType;</code></li>
<li><code>Parent</code>가 있으면<ul>
<li><code>NewNode-&gt;ParentNode = Parent;</code></li>
<li><code>Parent-&gt;Children.Add(NewNode);</code></li>
</ul>
</li>
</ul>
</li>
<li><p>블루프린트 사용성</p>
<ul>
<li><code>InActionType</code>가 <code>ECMNpcComponentType</code> 이라 BP 노드에서 드롭다운으로 선택 가능</li>
<li>BP에서 함수를 배치할 때, 트리 상에 어떤 액션을 둘지 직관적으로 설정 가능</li>
</ul>
</li>
</ul>
<hr>
<h2 id="블루프린트에서의-사용-패턴">블루프린트에서의 사용 패턴</h2>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/9c16ca4f-1359-425b-aafd-43c3d0d40c59/image.png" alt=""></p>
<ul>
<li><p>기본 대화 노드 생성</p>
<ul>
<li><code>CreateDialogueNode(UCMDialoagueNode, &quot;대사 텍스트&quot;)</code></li>
<li>또는 <code>CreateDialogueNodeWithSettings(UCMDialoagueNode, ParentNode, &quot;텍스트&quot;)</code></li>
</ul>
</li>
<li><p>액션 노드 생성</p>
<ul>
<li><code>CreateActionNodeWithSettings(UCMActionNode, ParentNode, &quot;텍스트&quot;, ECMNpcComponentType::ShopComponent)</code></li>
<li>분기 지점에 여러 액션 노드를 붙여서 다양한 상호작용 표현</li>
</ul>
</li>
</ul>
<ul>
<li>트리 테스트<ul>
<li>NPC 스폰 → BeginPlay 후 1초 뒤</li>
<li><code>LogAllDialogueNodeTexts()</code> 자동 호출</li>
<li>Output Log에서<ul>
<li>각 노드 텍스트</li>
<li>어떤 액션 타입이 처리되었는지 로그</li>
</ul>
</li>
<li>화면(OnScreenDebug)에서는 상점 액션 등 실제 PerformAction의 효과 확인 가능</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/16dd127c-cf1c-4c15-8efa-863eace0d4b0/image.png" alt=""></p>
<hr>
<h2 id="결론">결론</h2>
<p>이번 NPC Dialogue 시스템 구현에서는 단순한 대사 나열을 넘어서, <strong>트리 구조와 액션 노드를 결합한 대화 흐름</strong>을 구축해 보았습니다. 대화 노드를 <code>UObject</code>로 분리하고, NPC 액터가 트리 전체를 관리하도록 설계함으로써 재사용성과 확장성을 높이고자 하였습니다. 또한, 블루프린트에서 노드를 생성하고 텍스트/액션 타입을 직관적으로 지정할 수 있도록 API를 정리한 덕분에, 디자이너 입장에서의 사용성도 어느 정도 확보할 수 있었습니다.</p>
<p>앞으로는 이 구조를 기반으로 실제 게임 플레이에 필요한 UI 연동, 선택지 표시, 조건부 분기(퀘스트 진행도, 인벤토리 상태 등)에 따른 동적 트리 탐색 등을 추가해 볼 예정입니다. 이번 작업을 통해 언리얼 엔진의 UObject/Actor/Component 구조와 블루프린트 연동 방식에 대한 이해를 한층 더 깊게 할 수 있었던 의미 있는 경험이었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Memory] Image 영역?]]></title>
            <link>https://velog.io/@dev_sensational/Memory-Image-%EC%98%81%EC%97%AD</link>
            <guid>https://velog.io/@dev_sensational/Memory-Image-%EC%98%81%EC%97%AD</guid>
            <pubDate>Mon, 08 Dec 2025 11:27:45 GMT</pubDate>
            <description><![CDATA[<p>메모리 분석 유틸리티 중 하나인 VMmap에 대해 알게되어 사용하게 되었습니다. 그런데 제가 아는 것과는 다른 부분이 몇 가지 존재하여 블로그에 정리하게 되었습니다.</p>
<p>먼저 일반적으로 알려진 메모리 구조는 다음과 같습니다.</p>
<h3 id="주소-↑">주소 ↑</h3>
<table>
<thead>
<tr>
<th>영역 이름</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Kernel space</strong></td>
<td>운영체제가 사용하는 영역. 사용자 프로세스는 접근 불가.</td>
</tr>
<tr>
<td><strong>Heap</strong></td>
<td><code>malloc/new</code>로 사용하는 동적 메모리. 보통 위쪽(주소↑)으로 성장. 단편화 가능.</td>
</tr>
<tr>
<td><em>(Heap과 Stack 사이 빈 공간)</em></td>
<td>힙이 확장되거나 스택이 내려오면서 충돌 여지가 있는 공간.</td>
</tr>
<tr>
<td><strong>Stack</strong></td>
<td>함수 호출 시 스택 프레임이 아래 방향(주소↓)으로 쌓임. 지역변수/리턴주소 저장.</td>
</tr>
<tr>
<td><strong>BSS / Data</strong></td>
<td>BSS: 초기값 0 전역변수, Data: 초기화된 전역변수.</td>
</tr>
<tr>
<td><strong>Text(Code)</strong></td>
<td>실행 코드 영역. 보통 Read + Execute, Write 불가.</td>
</tr>
<tr>
<td>### 주소 ↓</td>
<td></td>
</tr>
</tbody></table>
<p>하지만 VMMap에서의 구성은 다음과 같았습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/8ffb3203-c383-4aac-abe9-ac4ab288707e/image.png" alt=""></p>
<p>여기서 알게 된 점은 Image가 우리가 알고 있던 Code가 저장되는 영역이라는 점입니다.  실제로 더미코드를 아주 많이 추가했을 때 아래와 같은 차이점을 보였습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/74cf103c-ad9e-4c31-9550-5dcb5412b8d7/image.png" alt=""></p>
<p>도움이 되는 유틸리티를 사용할 때, 사용되는 키워드의 차이점을 인지해야 효율적으로 사용할 수 있겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[C++] Symbol]]></title>
            <link>https://velog.io/@dev_sensational/C-Symbol</link>
            <guid>https://velog.io/@dev_sensational/C-Symbol</guid>
            <pubDate>Thu, 04 Dec 2025 13:33:45 GMT</pubDate>
            <description><![CDATA[<p>링크 에러 메세지에서 주로 보게되는 심볼(Symbol)이 문득 정확히 어떻게 사용되는지, 정확히 무엇을 의미하는지가 궁금해져 정리를 하게 되었습니다. 겉핥기로 알고 있던 심볼에 대해 더 공부하고 정리해보겠습니다.</p>
<hr>
<h2 id="심볼symbol이란">심볼(Symbol)이란?</h2>
<p><strong>심볼은 컴파일러·링커가 프로그램 내부 엔티티를 식별하기 위해 생성하는 이름 정보입니다.</strong> 즉, 우리가 작성한 C++ 코드를 <strong>기계어가 이해할 수 있도록 내부적으로 사용하는 식별자</strong>입니다. </p>
<p>C++에서 심볼이 만들어지는 대상은 다음과 같습니다.</p>
<ul>
<li>함수</li>
<li>전역 변수</li>
<li>static 변수</li>
<li>클래스 멤버 함수</li>
<li>템플릿 인스턴스</li>
<li>가상 함수 테이블(vtable)</li>
</ul>
<p>심볼은 실제 바이너리 내부에 기록되며, 링커가 여러 개의 obj 파일을 연결할 때 이 정보를 사용합니다.</p>
<hr>
<h2 id="c-심볼의-실제-형태-name-mangling">C++ 심볼의 실제 형태 (Name Mangling)</h2>
<p>C++ 함수는 오버로딩, 네임스페이스, 클래스 등 구조가 복잡해서 <strong>컴파일하면 내부적으로 이름이 변형됩니다</strong>. 이를 네임 맹글링(Name Mangling) 이라고 합니다.</p>
<blockquote>
</blockquote>
<p>예를 들어:</p>
<blockquote>
</blockquote>
<pre><code class="language-cpp">int Add(int a, int b);</code></pre>
<blockquote>
</blockquote>
<p>이 함수는 컴파일 후 다음과 같은 이름으로 변환될 수 있습니다.</p>
<blockquote>
</blockquote>
<pre><code>?Add@@YAHHH@Z</code></pre><p>이런 형태의 이름이 바로 <strong>심볼(Symbol)</strong> 입니다.</p>
<hr>
<h2 id="3-심볼과-링키지linkage">3. 심볼과 링키지(Linkage)</h2>
<p>심볼에는 &quot;어디까지 보이는가?&quot; 를 나타내는 <strong>링키지(Linkage)</strong> 개념이 붙습니다.</p>
<h3 id="✔-external-linkage">✔ External Linkage</h3>
<p>다른 파일에서도 접근 가능한 심볼
(기본적인 전역 함수/전역 변수)</p>
<pre><code class="language-cpp">int gValue;
void Foo();</code></pre>
<h3 id="✔-internal-linkage">✔ Internal Linkage</h3>
<p>파일 내부에서만 보이는 심볼
<code>static</code> 키워드로 생성됩니다.</p>
<pre><code class="language-cpp">static int localGlobal = 10;</code></pre>
<h3 id="✔-no-linkage">✔ No Linkage</h3>
<p>지역 변수처럼 링커가 관여하지 않는 경우, 심볼 테이블에 올라가지 않음</p>
<hr>
<h2 id="undefined-symbol-미정의-심볼">Undefined Symbol (미정의 심볼)</h2>
<p>C++ 컴파일 시 선언만 있고 정의가 없는 심볼은 링커가 처리해야 합니다.</p>
<blockquote>
</blockquote>
<p>예:</p>
<blockquote>
</blockquote>
<pre><code class="language-cpp">extern int Value;</code></pre>
<blockquote>
</blockquote>
<p><code>Value</code>의 정의가 없다면 링크 단계에서 오류가 발생합니다.</p>
<blockquote>
</blockquote>
<pre><code>undefined reference to `Value`</code></pre><p>즉, 심볼은 <strong>선언 → 참조 → 정의</strong>가 일치해야 linking이 성공합니다.</p>
<hr>
<h2 id="symbol-table-심볼-테이블">Symbol Table (심볼 테이블)</h2>
<p>컴파일러와 링커는 심볼 정보를 다음과 같이 테이블로 관리합니다</p>
<ul>
<li>변수 이름, 타입, 메모리 위치</li>
<li>함수의 주소</li>
<li>템플릿 인스턴스 정보</li>
<li>가상 함수 테이블 정보</li>
<li>접근 범위 및 링크 여부</li>
</ul>
<p><code>.obj</code> 파일을 열면 심볼 테이블을 확인할 수 있습니다.</p>
<p>실제 심볼 테이블을 확인해보기 위해 <code>DUMPBIN</code>을 사용해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/cd661ca1-18bb-4e7f-b680-da6489f202ae/image.png" alt=""></p>
<p>VS2022의 터미널에서 실행했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/b17f9012-e8fa-4d6c-8357-f27e074f8f14/image.png" alt=""></p>
<p>obj 파일에서 심볼 테이블을 확인할 수 있었습니다.</p>
<hr>
<h2 id="왜-중요한가">왜 중요한가?</h2>
<p>심볼은 다음 문제들의 핵심 원인/해결책이 됩니다.</p>
<ul>
<li>함수 중복 정의 오류</li>
<li>undefined reference 링크 오류</li>
<li>static 전역과 일반 전역 변수 차이</li>
<li>템플릿 인스턴스 중복 생성 문제</li>
<li>vtable 관련 링크 에러</li>
</ul>
<p>즉, <strong>C++ 빌드 과정에서 발생하는 대부분의 문제는 결국 심볼에 대한 이해로 해결됩니다.</strong></p>
<hr>
<h2 id="핵심-요약">핵심 요약</h2>
<ul>
<li>심볼은 C++ 코드의 <strong>컴파일·링크 과정에서 생성되는 내부 이름</strong></li>
<li>C++은 오버로딩 때문에 <strong>Name Mangling</strong>이 적용된다.</li>
<li>심볼에는 <strong>External / Internal / No Linkage</strong>가 있다.</li>
<li>정의되지 않은 심볼은 <strong>링크 에러</strong>를 만든다.</li>
<li>obj 파일 내부의 모든 정보는 <strong>Symbol Table</strong>에 기록되어 있다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] NPC 시스템 설계]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-NPC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-NPC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Wed, 03 Dec 2025 10:35:29 GMT</pubDate>
            <description><![CDATA[<p>오늘은 <code>ACMNpcBase</code>를 중심으로, 다양한 NPC 기능(대화, 퀘스트, 상점 등)을 <strong>컴포넌트 기반</strong>으로 확장할 수 있는 구조를 설계하였습니다. 이 구조를 통해 이후 요구사항 변화에 유연하게 대응할 수 있도록 확장성과 유지보수성을 고려한 아키텍처를 목표로 하였습니다.</p>
<hr>
<h2 id="설계-목표">설계 목표</h2>
<ul>
<li>컴포넌트 기반 NPC 확장 구조 설계<ul>
<li>공통된 NPC 베이스 클래스: <code>ACMNpcBase</code></li>
<li>기능 단위의 컴포넌트: <code>UCMNpcComponentBase</code> 파생 클래스들</li>
</ul>
</li>
<li>기능별 책임 분리<ul>
<li>대화(Dialogue), 퀘스트(Quest), 상점(Shop) 기능을 각각 독립된 컴포넌트로 관리</li>
</ul>
</li>
<li>타입 기반 액션 처리<ul>
<li><code>ECMNpcComponentType</code> + <code>HandleActionByType()</code> 조합으로 컴포넌트 접근</li>
</ul>
</li>
<li>향후 기능 추가에 대비한 확장성<ul>
<li>새로운 NPC 기능을 추가할 때 기존 코드 수정을 최소화</li>
</ul>
</li>
</ul>
<hr>
<h2 id="핵심-클래스-및-인터페이스-구조">핵심 클래스 및 인터페이스 구조</h2>
<ul>
<li><code>ACMNpcBase : AActor, ICMNpcHandler</code><ul>
<li>NPC의 공통 베이스 액터</li>
<li><code>ICMNpcHandler</code> 인터페이스를 구현하여 외부에서 통합된 방식으로 NPC에 요청 전달</li>
</ul>
</li>
<li><code>ICMNpcHandler</code><ul>
<li>NPC가 공통으로 가져야 할 행동 인터페이스 정의</li>
<li>예: <code>HandleActionByType(ECMNpcComponentType ComponentType)</code></li>
</ul>
</li>
<li><code>UCMNpcComponentBase</code><ul>
<li>NPC 기능용 베이스 컴포넌트 클래스</li>
<li>Dialogue / Quest / Shop 등 기능 컴포넌트의 부모 타입</li>
</ul>
</li>
<li><code>ECMNpcComponentType</code><ul>
<li>NPC 컴포넌트 타입 식별용 enum</li>
<li><code>Default</code>, <code>DialogueComponent</code>, <code>QuestComponent</code>, <code>ShopComponent</code> 등 정의</li>
</ul>
</li>
</ul>
<hr>
<h2 id="클래스-다이어그램">클래스 다이어그램</h2>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/938aac1a-a10e-416f-965c-be1f28178dbb/image.png" alt=""></p>
<hr>
<h2 id="acmnpcbase의-역할-및-책임-정리">ACMNpcBase의 역할 및 책임 정리</h2>
<ul>
<li>NPC 공통 Actor 베이스<ul>
<li>카메라 컴포넌트: <code>NpcCameraComponent</code></li>
<li>캡슐 콜라이더: <code>CapsuleComponent</code></li>
<li>스켈레탈 메시: <code>MeshComponent</code></li>
</ul>
</li>
<li>NPC 기능 컴포넌트 관리<ul>
<li><code>NpcComponents</code> 배열로 부착된 컴포넌트들을 보관</li>
<li><code>RegisteredComponentMap&lt;ECMNpcComponentType, UCMNpcComponentBase*&gt;</code>로 타입-컴포넌트 매핑</li>
</ul>
</li>
<li>컴포넌트 등록 로직<ul>
<li><code>RegisterComponent(ECMNpcComponentType ComponentType, UCMNpcComponentBase* NewComponent)</code><ul>
<li>외부/컴포넌트에서 자기 자신을 등록하는 공용 인터페이스</li>
</ul>
</li>
<li><code>PerformRegisterComponent(...)</code><ul>
<li>중복 등록 체크, 맵 갱신 등 실제 등록 로직 처리</li>
</ul>
</li>
</ul>
</li>
<li>상호작용 처리<ul>
<li><code>PerformInteract()</code><ul>
<li>플레이어가 NPC와 상호작용할 때 호출되는 Entry Point</li>
<li>적절한 <code>ECMNpcComponentType</code>를 결정 후 <code>HandleActionByType()</code> 호출 가능</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="icmnpchandler-인터페이스-활용">ICMNpcHandler 인터페이스 활용</h2>
<ul>
<li>공통 액션 처리 진입점 제공<ul>
<li><code>HandleActionByType(ECMNpcComponentType ComponentType)</code><ul>
<li>컴포넌트 타입 기반으로 적절한 NPC 컴포넌트를 찾아 액션 실행</li>
</ul>
</li>
</ul>
</li>
<li>컴포넌트 등록 책임의 일원화<ul>
<li><code>RegisterComponent(ECMNpcComponentType ComponentType, UCMNpcComponentBase* NewComponent)</code><ul>
<li>NPC 베이스에서만 타입-컴포넌트 매핑을 관리하도록 강제</li>
</ul>
</li>
</ul>
</li>
<li>장점<ul>
<li>외부에서 NPC의 내부 구조(어떤 컴포넌트가 있는지)를 몰라도, enum 타입과 핸들러 메서드만으로 행동 요청 가능</li>
<li>추후 새로운 컴포넌트 타입이 추가되어도, 인터페이스 시그니처는 유지되므로 기존 호출부 영향 최소화</li>
</ul>
</li>
</ul>
<hr>
<h2 id="컴포넌트-기반-확장성-dialogue--quest--shop">컴포넌트 기반 확장성 (Dialogue / Quest / Shop)</h2>
<ul>
<li>컴포넌트 유형 정의<ul>
<li><code>ECMNpcComponentType::DialogueComponent</code></li>
<li><code>ECMNpcComponentType::QuestComponent</code></li>
<li><code>ECMNpcComponentType::ShopComponent</code></li>
</ul>
</li>
<li>각 기능의 책임 분리<ul>
<li>Dialogue 컴포넌트<ul>
<li>NPC 대사를 관리하고, 트리 기반 대화 흐름 제어</li>
</ul>
</li>
<li>Quest 컴포넌트<ul>
<li>퀘스트 제공, 진행 상태 관리, 완료 조건 체크</li>
</ul>
</li>
<li>Shop 컴포넌트<ul>
<li>상점 인벤토리, 구매/판매 로직 관리</li>
</ul>
</li>
</ul>
</li>
<li>확장성 포인트<ul>
<li>새로운 기능(예: <code>ECMNpcComponentType::TrainingComponent</code>, <code>CraftComponent</code> 등)을 추가할 때<ul>
<li>enum에 타입만 추가</li>
<li>해당 타입을 담당하는 <code>UCMNpcComponentBase</code> 파생 컴포넌트 생성</li>
<li><code>RegisterComponent()</code>를 통해 <code>ACMNpcBase</code>에 등록</li>
</ul>
</li>
<li>기존 <code>HandleActionByType()</code> 호출 구조는 그대로 유지</li>
</ul>
</li>
<li>유지보수성 향상 요소<ul>
<li>기능 단위로 클래스가 나뉘어 있어, 버그 수정 또는 기능 변경 시 해당 컴포넌트에만 집중 가능</li>
<li>공통 인터페이스(<code>ICMNpcHandler</code>, <code>UCMNpcComponentBase</code>)를 통해 일관된 사용법 제공</li>
</ul>
</li>
</ul>
<hr>
<h2 id="8-handleactionbytype-기반-액션-라우팅">8. HandleActionByType() 기반 액션 라우팅</h2>
<ul>
<li>목적<ul>
<li>외부 코드(플레이어 상호작용, 트리거, UI 등)에서 NPC에게 특정 기능을 요청할 때, 컴포넌트를 직접 참조하지 않고 타입 기반으로 접근</li>
</ul>
</li>
<li>처리 흐름<ul>
<li>입력: <code>ECMNpcComponentType ComponentType</code></li>
<li><code>RegisteredComponentMap</code>에서 해당 타입의 컴포넌트 조회</li>
<li>컴포넌트가 존재하면 <code>UCMNpcComponentBase::HandleAction()</code> (또는 유사 메서드) 호출</li>
</ul>
</li>
<li>장점<ul>
<li>NPC 내부 구조 변경(컴포넌트 교체, 리팩토링)에도 외부 인터페이스 유지</li>
<li>다양한 상호작용(예: 대화 / 퀘스트 수락 / 상점 열기)을 <strong>타입 하나로 추상화</strong> 가능</li>
</ul>
</li>
</ul>
<hr>
<h2 id="트리형-dialogue-설계와-handleactionbytype-연계">트리형 Dialogue 설계와 HandleActionByType() 연계</h2>
<ul>
<li>트리형 Dialogue 구조 계획<ul>
<li>노드 기반 대화 구조<ul>
<li>각 노드는 대사 내용, 선택지, 다음 노드에 대한 참조를 가짐</li>
</ul>
</li>
<li>플레이어 선택에 따라 다른 노드로 분기하는 트리(또는 DAG) 형태</li>
</ul>
</li>
<li>Dialogue 컴포넌트 역할<ul>
<li>내부에 대화 트리 데이터 구조 보관</li>
<li><code>StartDialogue()</code>, <code>ProceedToNextNode(ChoiceIndex)</code>, <code>EndDialogue()</code> 등의 메서드 제공</li>
<li><code>HandleAction()</code>에서 초기 진입점(<code>StartDialogue()</code>) 호출</li>
</ul>
</li>
<li>HandleActionByType() 활용 시나리오<ul>
<li>플레이어가 NPC와 상호작용 → <code>ACMNpcBase::PerformInteract()</code> 호출</li>
<li>현재 상황(퀘스트 단계, 게임 모드 등)에 따라 아래 중 하나를 선택<ul>
<li><code>HandleActionByType(ECMNpcComponentType::DialogueComponent)</code></li>
<li><code>HandleActionByType(ECMNpcComponentType::QuestComponent)</code></li>
<li><code>HandleActionByType(ECMNpcComponentType::ShopComponent)</code></li>
</ul>
</li>
<li>Dialogue 선택 시<ul>
<li>등록된 Dialogue 컴포넌트를 조회 → 대화 트리의 루트 노드에서 대화 시작</li>
</ul>
</li>
</ul>
</li>
<li>확장 시 이점<ul>
<li>트리 구조나 대화 데이터 포맷이 바뀌더라도 <code>HandleActionByType(DialogueComponent)</code> 호출부는 변경 없이 유지 가능</li>
<li>대화 로직 복잡도가 커져도, NPC 베이스/외부 호출부와 분리되어 유지보수 부담 감소</li>
</ul>
</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 NPC 설계에서는 <strong>Actor + Interface + Component + Enum</strong>을 조합하여, NPC의 다양한 기능을 느슨하게 결합된 형태로 구성해 보았습니다. 특히 <code>HandleActionByType()</code>과 <code>ECMNpcComponentType</code>을 활용하여 컴포넌트에 접근하는 방식은, 이후 Dialogue, Quest, Shop 컴포넌트가 추가되더라도 외부 인터페이스를 거의 건드리지 않고 확장이 가능하다는 장점이 있었습니다.</p>
<p>앞으로는 실제 트리형 Dialogue 데이터를 설계하고, 이를 처리하는 Dialogue 컴포넌트를 구현하면서, 이번에 정의한 NPC 베이스 구조가 얼마나 유연하게 동작하는지 검증해 볼 예정입니다. 이를 통해 상호작용이 복잡해지는 RPG 스타일 게임에서도 유지보수성과 확장성을 동시에 확보하는 NPC 아키텍처를 완성하는 것을 목표로 삼겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] Steam Online Service를 이용한 세션 생성 및 참여 구현 과정 (v5.6.1)]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-5.6.1-Steam-Online-Service%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%84%B8%EC%85%98-%EC%83%9D%EC%84%B1-%EB%B0%8F-%EC%B0%B8%EC%97%AC-%EA%B5%AC%ED%98%84-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-5.6.1-Steam-Online-Service%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%84%B8%EC%85%98-%EC%83%9D%EC%84%B1-%EB%B0%8F-%EC%B0%B8%EC%97%AC-%EA%B5%AC%ED%98%84-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Fri, 28 Nov 2025 05:23:48 GMT</pubDate>
            <description><![CDATA[<p>Steam Online Subsystem을 사용해 친구 초대 및 멀티플레이 기능을 구현하던 중, 세션 생성과 참가(JoinSession)는 정상적으로 콜백이 오는데 실제로는 접속이 되지 않는 문제가 발생하였습니다. 특히 <code>NetworkDriver</code> 생성 실패, <code>Listen</code> 서버 생성 실패, P2P 연결 타임아웃 등 다양한 로그 메시지가 출력되면서 원인을 추적하는 데 많은 시간이 걸렸습니다.</p>
<p>이 글에서는 문제를 해결하기까지의 시행착오 과정을 정리하여, 비슷한 이슈를 겪는 분들께 참고가 될 만한 내용을 공유드리고자 합니다.</p>
<hr>
<h3 id="11-문제-상황-요약">1.1 문제 상황 요약</h3>
<ul>
<li>Steam 초대 수락 후 <code>JoinSession</code> 까지는 성공 로그가 뜸</li>
<li>하지만 실제로는 호스트에 접속되지 않고, 일정 시간이 지난 후 타임아웃</li>
<li>클라이언트 로그 상 주요 현상:<ul>
<li><code>OnSessionUserInviteAccepted</code> 성공</li>
<li><code>JoinSession</code> 성공</li>
<li><code>Browse: steam.7656.../Game/Maps/MainMenu</code> 까지 진행</li>
<li>이후 <code>Your connection to the host has been lost.</code> 로 실패</li>
</ul>
</li>
</ul>
<h3 id="12-최초-에러-networkdriver-생성-실패">1.2 최초 에러: NetworkDriver 생성 실패</h3>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/e9e53c93-23a5-409c-ba62-4c341aab70eb/image.png" alt=""></p>
<ul>
<li>로그 예시</li>
</ul>
<blockquote>
</blockquote>
<pre><code>Failed to find object &#39;Class /Script/Engine.IpNetDriver&#39;
CreateNamedNetDriver failed to create driver from definition GameNetDriver
Error initializing the pending net driver. Check the configuration of NetDriverDefinitions...</code></pre><ul>
<li>원인 추정<ul>
<li><code>DefaultEngine.ini</code> 에서 <code>GameNetDriver</code> 설정이 제대로 되어 있지 않아서, 엔진이 <code>NetDriver</code> 클래스를 찾지 못함</li>
</ul>
</li>
</ul>
<h4 id="121-netdriver-설정-수정">1.2.1 NetDriver 설정 수정</h4>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/5ec188c6-da20-4227-ba76-adc479dbdc6e/image.png" alt=""></p>
<ul>
<li><code>DefaultEngine.ini</code> 에 다음과 같이 설정<ul>
<li><code>[/Script/Engine.GameEngine]</code></li>
<li><code>!NetDriverDefinitions=ClearArray</code></li>
<li><code>NetDriverDefinitions=(DefName=&quot;GameNetDriver&quot;,DriverClassName=&quot;/Script/OnlineSubsystemSteam.SteamNetDriver&quot;,DriverClassNameFallback=&quot;/Script/OnlineSubsystemUtils.IpNetDriver&quot;)</code></li>
</ul>
</li>
<li>효과<ul>
<li>이후 로그에서 <code>InitBase PendingNetDriver (NetDriverDefinition GameNetDriver)</code> 로 정상 초기화되는 것을 확인</li>
<li>더 이상 <code>IpNetDriver</code> 클래스를 못 찾는 에러는 발생하지 않음</li>
</ul>
</li>
</ul>
<h3 id="13-두-번째-문제-p2p-연결-타임아웃">1.3 두 번째 문제: P2P 연결 타임아웃</h3>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/74ad18f6-834a-446a-a7e0-99125c9b0c6e/image.png" alt=""></p>
<ul>
<li>수정 후 클라이언트 로그<ul>
<li><code>InitBase PendingNetDriver (NetDriverDefinition GameNetDriver)</code></li>
<li><code>Game client on port 7777, rate 100000</code></li>
<li>잠시 대기 후</li>
<li><code>Timed out attempting to connect</code></li>
<li><code>Your connection to the host has been lost.</code></li>
</ul>
</li>
<li>특징<ul>
<li>세션 검색, 초대, JoinSession 까지는 모두 정상 동작</li>
<li>실제 P2P 연결 수립 단계에서 일정 시간 후 타임아웃 발생</li>
</ul>
</li>
</ul>
<h3 id="14-listen-서버-생성-실패-로그-분석">1.4 Listen 서버 생성 실패 로그 분석</h3>
<ul>
<li>호스트 쪽 로그에서 다음 메시지 확인<ul>
<li><code>LoadMap: failed to Listen(/Game/MainStage/L_GenerateMapTest?Name=Player?listen)</code></li>
</ul>
</li>
<li>다른 시점에는<ul>
<li><code>LoadMap: failed to Listen(/Game/Maps/MainMenu?Name=Player?listen)</code></li>
</ul>
</li>
<li>의미<ul>
<li>호스트가 맵을 로드하면서 <code>?listen</code> 옵션으로 Listen 서버를 열려고 시도했으나 실패</li>
<li>즉, 클라이언트 문제 이전에 <strong>호스트가 제대로 Listen 서버를 띄우지 못함</strong></li>
</ul>
</li>
</ul>
<h4 id="141-세션-생성과-travel-순서">1.4.1 세션 생성과 Travel 순서</h4>
<ul>
<li>초기 구현<ul>
<li><code>OnCreateSessionComplete()</code> 안에서 바로 <code>ServerTravel</code> + <code>?listen</code> 수행</li>
<li>또는 세션 생성 직후 약간의 딜레이를 준 뒤 <code>ServerTravel</code> 호출</li>
</ul>
</li>
<li>문제점<ul>
<li>세션 생성 직후 엔진/Steam 쪽 초기화가 완전히 끝나지 않은 상태에서 <code>ServerTravel</code> 을 수행하면 Listen 서버 생성이 실패할 수 있음</li>
<li>다만 이 경우에는 궁극적인 원인은 아니었음 (딜레이를 줘도 근본 문제가 남아 있었음)</li>
</ul>
</li>
</ul>
<h4 id="142-기능-분리">1.4.2 기능 분리</h4>
<ul>
<li>조치 사항<ul>
<li>&quot;Online Session 생성&quot; 과 &quot;ServerTravel(맵 이동 + ?listen)&quot; 을 별도의 함수로 분리</li>
<li>세션 생성 성공 콜백에서는 세션 관련 처리만 담당</li>
<li>맵 이동은 명시적으로 별도 타이밍에 호출하도록 구조 개선</li>
</ul>
</li>
<li>의도<ul>
<li>디버깅 편의성 향상</li>
<li>세션/네트 워킹 문제와 레벨 로딩/게임 로직을 명확하게 분리</li>
</ul>
</li>
</ul>
<h3 id="15-게임-타겟-vs-클라이언트-타겟">1.5 게임 타겟 vs 클라이언트 타겟</h3>
<ul>
<li>핵심 원인<ul>
<li>프로젝트를 <strong>Client 타겟(CrimsonMoonClient)</strong> 으로 빌드하고 실행하고 있었음</li>
<li>Client 타겟은 기본적으로 <strong>Listen 서버를 열 수 없는 구성</strong></li>
</ul>
</li>
<li>결과적으로<ul>
<li>호스트를 Client 타겟 실행 파일로 실행 → <code>?listen</code> 으로 서버를 열려고 해도 계속 실패</li>
<li>따라서 클라이언트 입장에서는 P2P 연결을 시도하나, 실제로는 열려 있는 Listen 서버가 없어서 결국 타임아웃 발생</li>
</ul>
</li>
</ul>
<h4 id="151-해결책-game-타겟으로-빌드">1.5.1 해결책: Game 타겟으로 빌드</h4>
<ul>
<li><p>빌드 대상 변경</p>
<ul>
<li>기존: <code>CrimsonMoonClient.Target.cs</code> 기반 빌드 (클라이언트 전용)</li>
<li>변경: <code>CrimsonMoon.Target.cs</code> 기반 <strong>Game 타겟</strong> 빌드</li>
</ul>
</li>
<li><p>변경 후 현상</p>
<ul>
<li>호스트가 <code>?listen</code> 으로 정상적으로 Listen 서버를 생성</li>
<li>클라이언트에서 Steam 초대 수락 → <code>JoinSession</code> → <code>Browse steam.xxx/Map</code> → 정상 접속</li>
<li>실제로 친구 초대 후 함께 플레이 가능한 상태 확인</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/fd1d43a9-9e69-4399-a009-13b36db6a29f/image.png" alt=""></p>
</li>
</ul>
<h3 id="16-정리-왜-client-타겟으로는-안-되었는가">1.6 정리: 왜 Client 타겟으로는 안 되었는가</h3>
<ul>
<li>Client 타겟의 특징 (언리얼 빌드 관점 가정)<ul>
<li>전용 클라이언트 실행용, 서버 관련 일부 기능이 비활성화/제한될 수 있음</li>
<li>Listen 서버(클라이언트 + 서버 겸용)를 여는 패턴에는 적합하지 않음</li>
</ul>
</li>
<li>Listen 서버 패턴<ul>
<li>한 플레이어가 <strong>호스트이자 클라이언트</strong> 역할을 하면서 <code>?listen</code> 으로 서버를 열고, 나머지는 그 호스트에 접속하는 구조</li>
<li>이 패턴은 Game 타겟(또는 Editor)에서 실행해야 정상 동작</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-배운-점-정리-til">2. 배운 점 정리 (TIL)</h2>
<h3 id="21-steam--ue5-멀티플레이-기본-흐름">2.1 Steam + UE5 멀티플레이 기본 흐름</h3>
<ul>
<li>세션 생성<ul>
<li>Steam Online Subsystem 이용</li>
<li><code>CreateSession</code> → <code>OnCreateSessionComplete</code> 콜백 확인</li>
</ul>
</li>
<li>호스트 측 레벨 이동<ul>
<li>Listen 서버를 사용할 경우: <code>/Game/Map/YourMap?listen</code> 으로 <code>ServerTravel</code></li>
</ul>
</li>
<li>클라이언트 초대 및 Join<ul>
<li>Steam 초대 → <code>OnSessionUserInviteAccepted</code> 콜백</li>
<li><code>JoinSession</code> 호출, 성공 시 <code>GetResolvedConnectString</code> 으로 주소 획득</li>
<li><code>ClientTravel</code> 로 <code>steam.XXX/Map</code> 주소로 이동</li>
</ul>
</li>
</ul>
<h3 id="22-설정코드-레벨에서의-체크리스트">2.2 설정/코드 레벨에서의 체크리스트</h3>
<ul>
<li><code>DefaultEngine.ini</code><ul>
<li><code>[/Script/Engine.GameEngine]</code></li>
<li><code>!NetDriverDefinitions=ClearArray</code></li>
<li><code>NetDriverDefinitions=(DefName=&quot;GameNetDriver&quot;, DriverClassName=&quot;/Script/OnlineSubsystemSteam.SteamNetDriver&quot;, DriverClassNameFallback=&quot;/Script/OnlineSubsystemUtils.IpNetDriver&quot;)</code></li>
</ul>
</li>
<li>세션 생성과 맵 이동 분리<ul>
<li>세션 생성 전용 함수</li>
<li>Listen 서버용 <code>ServerTravel</code> 전용 함수</li>
</ul>
</li>
<li>Host 빌드 타겟<ul>
<li><strong>반드시 Game 타겟(CrimsonMoon.exe)</strong> 로 빌드/실행</li>
<li>Client 전용 타겟(CrimsonMoonClient.exe)으로는 Listen 서버를 열 수 없음</li>
</ul>
</li>
</ul>
<h3 id="23-로그를-볼-때-집중해서-봐야-하는-포인트">2.3 로그를 볼 때 집중해서 봐야 하는 포인트</h3>
<ul>
<li>NetDriver 관련<ul>
<li><code>CreateNamedNetDriver failed to create driver from definition GameNetDriver</code></li>
<li><code>Failed to find object &#39;Class /Script/Engine.IpNetDriver&#39;</code></li>
</ul>
</li>
<li>Listen 서버 관련<ul>
<li><code>LoadMap: failed to Listen( ... ?listen)</code></li>
</ul>
</li>
<li>연결 실패 관련<ul>
<li><code>Timed out attempting to connect</code></li>
<li><code>Your connection to the host has been lost.</code></li>
</ul>
</li>
<li>Steam Sockets 관련 경고<ul>
<li><code>Relay candidates enabled by P2P_Transport_ICE_Enable, but P2P_TURN_ServerList is empty</code></li>
<li>단순 경고일 수 있으며, 치명적인 에러는 아닐 수 있음 → 진짜 실패 원인은 NetDriver/Listen 쪽에 있을 가능성이 높음</li>
</ul>
</li>
</ul>
<h3 id="24-앞으로-비슷한-기능을-구현할-때-주의할-점">2.4 앞으로 비슷한 기능을 구현할 때 주의할 점</h3>
<ul>
<li>먼저 <strong>호스트가 제대로 Listen 서버를 뜨는지</strong> 단독 실행으로 확인</li>
<li>클라이언트/게임 타겟, 서버 타겟 빌드 설정을 명확하게 구분</li>
<li>Online Subsystem 설정(<code>DefaultEngine.ini</code>) 과 NetDriver 설정을 초기 단계에서 정확히 맞춰둘 것</li>
<li>세션 로직과 게임 레벨 로딩/게임플레이 초기화 로직을 분리해서 디버깅 가능하게 설계</li>
</ul>
<hr>
<h2 id="결론">결론</h2>
<p>이번 구현의 핵심은 코드나 Steam API 사용법 자체의 문제라기보다는, <strong>빌드 타겟 선택(Game vs Client)</strong> 과 <strong>엔진 설정(NetDriver, Listen 서버 가능 여부)</strong> 이었습니다. 언리얼 멀티플레이를 Steam과 연동해서 구현할 때는, 에디터에서 잘 되던 것이 패키징 후 동작하지 않을 수 있으므로, 어떤 타겟으로 빌드하고 실행하는지부터 확실히 인지하는 것이 중요하다는 점을 다시 한 번 느꼈습니다.</p>
<p>이 TIL이 이후에 비슷한 문제를 겪을 때 빠르게 원인을 좁히는 데 도움이 되기를 바랍니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] 서버와 클라이언트가 각각 생성될 때 Authority 관련 디버깅 ]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%EC%84%9C%EB%B2%84%EC%99%80-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%EA%B0%80-%EA%B0%81%EA%B0%81-%EC%83%9D%EC%84%B1%EB%90%A0-%EB%95%8C-Authority-%EA%B4%80%EB%A0%A8-%EB%94%94%EB%B2%84%EA%B9%85</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%EC%84%9C%EB%B2%84%EC%99%80-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%EA%B0%80-%EA%B0%81%EA%B0%81-%EC%83%9D%EC%84%B1%EB%90%A0-%EB%95%8C-Authority-%EA%B4%80%EB%A0%A8-%EB%94%94%EB%B2%84%EA%B9%85</guid>
            <pubDate>Mon, 24 Nov 2025 14:45:54 GMT</pubDate>
            <description><![CDATA[<p>리슨 서버/클라이언트 환경에서 <strong>Replicate 해야하는 인스턴스가 클라이언트에서도 생성</strong>되는 문제가 발생했습니다. 특히 <code>bReplicates</code> 를 켜지 않았는데도 클라이언트에서 액터가 보이고, <code>HasAuthority()</code> 로 분기를 나눴다고 생각했는데도 여전히 중복 생성이 일어나는 상황이라, 네트워크 권한(Authority)와 NetMode 개념을 다시 정리할 필요가 있었습니다.</p>
<p>이번 글에서는 <code>ACMProcedualMapGenerator</code> + <code>UCMMapGenerateLogicBaseComponent</code> + <code>ACMRoom</code> 조합으로 작성된 맵 생성 로직에서 어떤 문제가 있었고, 이를 어떻게 디버깅하고 정리했는지 트러블슈팅 과정을 정리합니다.</p>
<hr>
<h2 id="1-상황-정리">1. 상황 정리</h2>
<h3 id="11-관련-클래스-구조">1.1. 관련 클래스 구조</h3>
<ul>
<li><p><code>ACMProcedualMapGenerator</code></p>
<ul>
<li>맵 전체를 생성하는 Actor</li>
<li><code>GenerateMap(int32 InSeed, UCMRoomDataDefinition* InMapData)</code> 에서 맵 생성 컴포넌트들을 동적으로 생성/교체</li>
<li><code>MapGenerateLogicComponent</code> : <code>UCMMapGenerateLogicBaseComponent</code> 기반</li>
<li><code>SpawnPositionSelectorComponent</code> : 스폰 위치 선택 담당</li>
<li>결과를 <code>TMap&lt;FCMRoomPosition, TObjectPtr&lt;ACMRoom&gt;&gt; RoomMap</code> 에 저장</li>
</ul>
</li>
<li><p><code>UCMMapGenerateLogicBaseComponent</code></p>
<ul>
<li>실제 방 배치, 연결, 보스/보물 방 결정 등 <strong>맵 생성 알고리즘 구현</strong></li>
<li>주요 함수<ul>
<li><code>ExecuteMapGenerationLogic</code> : 전체 생성 파이프라인</li>
<li><code>GenerateRoom</code> : 좌표 기반 그래프 생성 및 <code>ACMRoom</code> 스폰</li>
<li><code>PlaceWallsAndEntrances</code> : 각 방 주변에 Entrance/Wall 스폰</li>
</ul>
</li>
</ul>
</li>
<li><p><code>ACMRoom</code></p>
<ul>
<li>한 개의 방을 나타내는 Actor</li>
<li><code>RoomBoundsBox</code>, 4방향 Entrance 포인트, Entrance/Wall 클래스를 보유</li>
<li><code>ExcuteSpawnRoom(int32 DirectionIndex, bool bIsEntrance)</code> 에서 실제로 Entrance/Wall Actor 스폰</li>
</ul>
</li>
</ul>
<h3 id="12-문제가-된-코드-요약">1.2. 문제가 된 코드 (요약)</h3>
<pre><code class="language-cpp">void UCMMapGenerateLogicBaseComponent::PlaceWallsAndEntrances(TMap&lt;FCMRoomPosition, TObjectPtr&lt;ACMRoom&gt;&gt;&amp; OutRoomMap)
{
    ENetMode NetMode = GetWorld() ? GetWorld()-&gt;GetNetMode() : NM_Standalone;
    for (const TPair&lt;FCMRoomPosition, TObjectPtr&lt;ACMRoom&gt;&gt;&amp; Pair : OutRoomMap)
    {
        if (ACMRoom* Room = Pair.Value.Get())
        {
            for (int32 i = 0; i &lt; 4; ++i)
            {
                FCMRoomPosition CurrentPos(Pair.Key.X + DirectionX[i], Pair.Key.Y + DirectionY[i]);
                if (OutRoomMap.Contains(CurrentPos))
                {
                    if (NetMode != NM_Client)
                    {
                        UE_LOG(LogTemp, Log, TEXT(&quot;HasAuthority: %d, NetMode: %d&quot;),
                            GetOwner()-&gt;HasAuthority(),
                            static_cast&lt;int32&gt;(NetMode));
                        Room-&gt;ExcuteSpawnRoom(i, true); // Entrance
                    }
                }
                else
                {
                    Room-&gt;ExcuteSpawnRoom(i, false); // Wall
                }
            }
        }
    }
}</code></pre>
<h3 id="13-증상">1.3. 증상</h3>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/d17f6796-d395-4352-aa0e-5a2acf6b5476/image.png" alt=""></p>
<ul>
<li><code>bReplicates</code> 를 블루프린트에서 켜지 않았는데도, 클라이언트에서 방/Entrance/Wall 이 모두 보임</li>
<li><code>GetOwner()-&gt;HasAuthority()</code> 로 조건을 걸어도, 여전히 <strong>호스트와 클라이언트 양쪽에서 Entrance/Wall 이 각각 생성</strong>됨</li>
<li>로그 상으로는 <code>HasAuthority: 1</code> 이 <strong>서버와 클라이언트 양쪽에서 모두 찍히는 듯한</strong> 결과</li>
</ul>
<hr>
<h2 id="2-개념-정리-hasauthority-netmode-복제-유무">2. 개념 정리: HasAuthority, NetMode, 복제 유무</h2>
<h3 id="21-hasauthority">2.1. HasAuthority()</h3>
<ul>
<li><code>AActor::HasAuthority()</code> 는 내부적으로 <code>Role == ROLE_Authority</code> 를 의미</li>
<li>복제된 Actor 의 경우<ul>
<li>서버에 있는 인스턴스: <code>HasAuthority() == true</code></li>
<li>클라에 있는 인스턴스: <code>HasAuthority() == false</code></li>
</ul>
</li>
<li>하지만 <strong>비복제 Actor</strong> 의 경우<ul>
<li>서버에서 스폰된 인스턴스: <code>HasAuthority() == true</code></li>
<li>클라에서 별도로 스폰된 인스턴스: 여기도 그 월드에서 유일한 Actor 라서 <code>Role = ROLE_Authority</code> → <code>HasAuthority() == true</code></li>
</ul>
</li>
<li>따라서, <strong>&quot;HasAuthority 가 true면 서버&quot;라고 단정하면 안 됨</strong></li>
</ul>
<h3 id="22-netmode">2.2. NetMode</h3>
<ul>
<li><code>UWorld::GetNetMode()</code> 로 현재 월드의 네트워크 모드를 알 수 있음<ul>
<li><code>NM_Standalone</code> (0) : 싱글 플레이</li>
<li><code>NM_DedicatedServer</code> (1) : 디디케이티드 서버</li>
<li><code>NM_ListenServer</code> (2) : 리슨 서버(호스트)</li>
<li><code>NM_Client</code> (3) : 클라이언트</li>
</ul>
</li>
<li><strong>&quot;이 코드가 서버에서 실행 중인가?&quot;</strong> 를 알고 싶다면:<ul>
<li><code>World-&gt;GetNetMode() == NM_Client</code> 이면 클라이언트 → 서버 전용 로직은 실행하지 않도록 Early Return</li>
<li>또는 <code>World-&gt;GetNetMode() != NM_Client</code> 을 서버/호스트 쪽으로 간주해도 됨</li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-실제-원인-분석">3. 실제 원인 분석</h2>
<h3 id="31-mapgenerator-가-서버클라에-각각-존재">3.1. MapGenerator 가 서버/클라에 각각 존재</h3>
<ul>
<li><code>ACMProcedualMapGenerator</code> 는 단순 <code>AActor</code> 이고, <code>bReplicates</code> 세팅이나 서버 전용 제약 없이 월드에 배치/생성되어 있었음</li>
<li>리슨 서버 + 클라이언트 환경에서<ul>
<li>서버 월드에 <code>ACMProcedualMapGenerator</code> 한 개</li>
<li>클라이언트 월드에도 <code>ACMProcedualMapGenerator</code> 한 개</li>
<li>둘 다 자기 월드에서 유일한 존재 → 둘 다 <code>HasAuthority() == true</code></li>
</ul>
</li>
<li><code>GenerateMap()</code> 호출 또한 서버/클라 각각에서 트리거되는 구조였기 때문에,<ul>
<li><code>UCMMapGenerateLogicBaseComponent::ExecuteMapGenerationLogic</code></li>
<li><code>UCMMapGenerateLogicBaseComponent::PlaceWallsAndEntrances</code></li>
<li><code>ACMRoom::ExcuteSpawnRoom</code>
이 세트가 서버와 클라이언트에서 <strong>각각 한 번씩</strong> 실행됨</li>
</ul>
</li>
</ul>
<h3 id="32-placewallsandentrances-의-조건">3.2. PlaceWallsAndEntrances 의 조건</h3>
<ul>
<li>초기 버전에서는 <code>GetOwner()-&gt;HasAuthority()</code> 만 보고 Entrance 를 생성</li>
<li>이후 <code>ENetMode NetMode = GetWorld()-&gt;GetNetMode()</code> 를 도입해 <code>NetMode != NM_Client</code> 인 경우에만 Entrance 를 생성하도록 변경</li>
<li>하지만 여전히 문제가 남았던 부분:<ul>
<li><code>else</code> 분기 (<code>OutRoomMap</code> 에 인접 방이 없을 때)에서 Wall 스폰은 <strong>권한/NetMode 체크 없이 항상 실행</strong></li>
<li><code>ExcuteSpawnRoom</code> 내부에서도 별도의 <code>HasAuthority()</code> 체크가 없으면, 양쪽에서 모두 스폰</li>
</ul>
</li>
<li>최종적으로는:<ul>
<li>서버 MapGenerator → Entrance + Wall 스폰</li>
<li>클라 MapGenerator → (NetMode 체크 추가 후) Entrance 는 안 만들더라도, Wall 은 만들 수 있음</li>
<li>그리고 <code>ACMRoom</code> 이 비복제라면, 서버/클라 각각의 <code>ACMRoom</code> 인스턴스도 자기 기준에서는 <code>HasAuthority() == true</code></li>
</ul>
</li>
</ul>
<h3 id="33-로그가-둘-다-1로-찍히는-이유">3.3. 로그가 둘 다 1로 찍히는 이유</h3>
<ul>
<li><code>UE_LOG(LogTemp, Log, TEXT(&quot;HasAuthority: %d, NetMode: %d&quot;), GetOwner()-&gt;HasAuthority(), (int32)NetMode);</code></li>
<li>위 로그에서:<ul>
<li>서버 월드: <code>HasAuthority = 1</code>, <code>NetMode = 2 (ListenServer)</code> 또는 1 (DedicatedServer)</li>
<li>클라 월드: <code>HasAuthority = 1</code>, <code>NetMode = 3 (Client)</code> 인 케이스가 가능</li>
</ul>
</li>
<li>즉, 단순히 <code>HasAuthority</code> 칼럼만 보면 둘 다 1로 찍히기 때문에,<ul>
<li><strong>&quot;Authority 를 둘 다 갖고 있는 것 같다&quot;</strong> 는 인상을 받게 됨</li>
<li>실제로는 서로 다른 월드에서 각각 Authority 인 비복제 Actor 일 뿐, 네트워크 복제와는 무관</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-해결-전략">4. 해결 전략</h2>
<h3 id="41-서버클라이언트-분기-기준을-명확히">4.1. 서버/클라이언트 분기 기준을 명확히</h3>
<ul>
<li><strong>&quot;맵을 생성하는 로직은 서버/호스트에서만 실행한다&quot;</strong> 를 명확한 정책으로 잡음</li>
<li>이를 위해 다음 기준을 사용<ul>
<li><code>UWorld::GetNetMode()</code> 가 <code>NM_Client</code> 이면 → 맵 생성 로직/스폰 로직은 아예 실행하지 않음</li>
<li><code>HasAuthority()</code> 는 보조적으로 사용하되, 비복제 Actor 도 Authority 일 수 있다는 점을 항상 염두에 둠</li>
</ul>
</li>
</ul>
<hr>
<h2 id="5-최종-정리">5. 최종 정리</h2>
<p>아래는 Entrance 인스턴스가 정상적으로 Replication On/Off 되는 결과물입니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/d2ea2e4f-730b-421d-871c-affa417b1798/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/78f272d1-e817-4f31-98c3-401dfd5494ca/image.png" alt=""></p>
<ul>
<li><p>언리얼 네트워크에서 <code>HasAuthority()</code> 와 <code>NetMode</code> 는 비슷해 보이지만, 서로 다른 레이어의 개념이라는 점을 다시 상기</p>
<ul>
<li><code>HasAuthority()</code> : 이 Actor 인스턴스가 <strong>자기 월드에서 서버 역할인지</strong></li>
<li><code>NetMode</code> : 이 월드가 <strong>DedicatedServer / ListenServer / Client / Standalone</strong> 인지</li>
</ul>
</li>
<li><p><strong>비복제 Actor</strong> 는 서버와 클라이언트에서 각각 스폰되면, 둘 다 <code>HasAuthority() == true</code> 라는 점이 핵심 포인트</p>
</li>
<li><p>따라서, 서버/클라이언트를 분기하고 싶을 때는 다음 우선순위를 지키는 것이 좋다고 정리</p>
</li>
<li><p>우선순위</p>
<ol>
<li><code>UWorld::GetNetMode()</code> 로 <strong>프로세스 레벨(서버/클라) 구분</strong><ul>
<li><code>NM_Client</code> 인 경우 서버 전용 로직을 실행하지 않음</li>
</ul>
</li>
<li><code>AActor::HasAuthority()</code> 는 <strong>복제된 Actor 의 서버/클라 인스턴스 구분</strong>에 사용</li>
<li>스폰 로직이 있는 함수(예: <code>ExcuteSpawnRoom</code>) 내부에도 <code>HasAuthority()</code> 체크를 두어 이중 방어</li>
</ol>
</li>
<li><p>이번 트러블슈팅을 통해 얻은 교훈</p>
<ul>
<li>단순히 &quot;Authority 가 true 니까 서버겠지&quot; 라고 생각하면 쉽게 헷갈린다.</li>
<li>맵 생성처럼 월드 전체에 영향을 주는 로직은 <strong>반드시 서버/호스트 한 곳에서만 실행되도록 구조를 잡는 것</strong>이 중요하다.</li>
<li>디버깅 시에는 <code>HasAuthority</code> 뿐 아니라 <code>NetMode</code> 값도 함께 로그로 남겨야, &quot;어느 월드에서 무슨 역할로&quot; 실행 중인지 명확히 볼 수 있다.</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] 맵 생성 시 Entrance 비정상 생성 문제 해결]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%EB%A7%B5-%EC%83%9D%EC%84%B1-%EC%8B%9C-Entrance-%EB%B9%84%EC%A0%95%EC%83%81-%EC%83%9D%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%EB%A7%B5-%EC%83%9D%EC%84%B1-%EC%8B%9C-Entrance-%EB%B9%84%EC%A0%95%EC%83%81-%EC%83%9D%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Mon, 24 Nov 2025 09:58:46 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 Map Generator를 구현하면서, 방(Room) 경계에 배치되는 Entrance(문/통로)들이 비정상적으로 여러 개씩 생성되거나, 잘못된 방향에 배치되는 문제를 어떻게 추적하고 해결했는지 정리해 보겠습니다.</p>
<hr>
<h2 id="1-문제-상황-정리">1. 문제 상황 정리</h2>
<ul>
<li><p>증상 요약</p>
<ul>
<li>같은 위치(두 방 사이의 경계)에 Entrance(문 같은 Border 액터)가 <strong>겹쳐서 여러 개 생성</strong>됨</li>
</ul>
</li>
<li><p>관련 주요 컴포넌트</p>
<ul>
<li><code>UCMMapGenerateLogicBaseComponent::GenerateRoom()</code><ul>
<li>DFS로 격자 기반 방 위치(<code>FCMRoomPosition</code>)를 생성</li>
<li><code>OutRoomMap: TMap&lt;FCMRoomPosition, ACMRoom*&gt;</code> 에 방 스폰 결과 저장</li>
</ul>
</li>
<li><code>UCMMapGenerateLogicBaseComponent::PlaceWallsAndEntrances()</code><ul>
<li><code>OutRoomMap</code>을 순회하면서 각 방향에 Wall / Entrance 스폰</li>
</ul>
</li>
<li><code>ACMRoom::SpawnBorderElement()</code><ul>
<li>실제 Entrance / Wall 액터를 스폰하고, <code>RoomBorderActors[4]</code> 에 캐시</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>이 세 곳의 로직이 서로 어떻게 엮이는지 이해하는 것이 문제 해결의 핵심이었습니다.</p>
<hr>
<h2 id="2-1차-원인-방향-인덱스상좌하우-불일치">2. 1차 원인: 방향 인덱스(상/좌/하/우) 불일치</h2>
<h3 id="2-1-generateroom과-directionxdirectiony의-불일치">2-1. GenerateRoom과 DirectionX/DirectionY의 불일치</h3>
<ul>
<li><p>GenerateRoom 내부 정의</p>
<ul>
<li>방향 인덱스 체계(0~3)를 다음과 같이 사용하고 있었습니다.<ul>
<li><code>DirOffset[4] = {(0,1), (1,0), (0,-1), (-1,0)}</code></li>
<li><code>DirIndexFromDelta(DX, DY)</code> → 0:Up, 1:Left, 2:Down, 3:Right</li>
<li><code>OppositeDir(Dir)</code> → 0↔2, 1↔3</li>
</ul>
</li>
</ul>
</li>
<li><p>PlaceWallsAndEntrances에서 사용하던 배열 (초기 상태)</p>
<ul>
<li><code>DirectionX[4] = {1, 0, -1, 0};</code>  // 우, 하, 좌, 상</li>
<li><code>DirectionY[4] = {0, -1, 0, 1};</code> // 우, 하, 좌, 상</li>
<li>즉, 0:Right, 1:Down, 2:Left, 3:Up 구조였고, GenerateRoom의 0:Up,1:Left,2:Down,3:Right 와 <strong>정반대 순서</strong>였습니다.</li>
</ul>
</li>
<li><p>이로 인한 문제</p>
<ul>
<li>방의 좌표(<code>FCMRoomPosition</code>)와 그래프(Adjacency, ConnectedRooms)는 <strong>상/좌/하/우</strong> 기준으로 잘 생성됨</li>
<li>하지만 Wall/Entrance 판단은 <strong>우/하/좌/상</strong> 기준으로 검사</li>
<li>결과적으로, 바로 옆에 방이 있어도 &quot;없는 것처럼&quot; 판단하거나, 반대로 다른 방향을 인접한 것으로 취급하는 문제가 발생</li>
<li>눈으로 보면 방 배치는 그럴싸한데, 문과 벽이 이상한 방향에 생성되어 전체가 어그러져 보임</li>
</ul>
</li>
</ul>
<h3 id="2-2-해결-모든-방향-관련-정의를-상좌하우로-통일">2-2. 해결: 모든 방향 관련 정의를 상/좌/하/우로 통일</h3>
<ul>
<li><p>조치</p>
<ul>
<li><code>UCMMapGenerateLogicBaseComponent</code> 헤더에서 <code>DirectionX/DirectionY</code> 를 다음과 같이 수정:<ul>
<li><code>DirectionX[4] = { 0, -1,  0,  1};</code> // 상, 좌, 하, 우</li>
<li><code>DirectionY[4] = { 1,  0, -1,  0};</code> // 상, 좌, 하, 우</li>
</ul>
</li>
<li>이렇게 해서 다음 4개가 모두 동일한 인덱스 체계(0:Up,1:Left,2:Down,3:Right)를 사용하도록 맞추었습니다.<ul>
<li><code>DirOffset</code></li>
<li><code>DirIndexFromDelta</code></li>
<li><code>OppositeDir</code></li>
<li><code>DirectionX/DirectionY</code> 및 <code>ACMRoom::ConnectedRooms[4]</code> / <code>RoomBorderActors[4]</code></li>
</ul>
</li>
</ul>
</li>
<li><p>교훈</p>
<ul>
<li>방향 인덱스/좌표계를 여러 곳에서 정의할 때는 <strong>하나의 기준(예: 상/좌/하/우)</strong> 을 정하고, 전역적으로 사용해야 디버깅이 쉬워집니다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-2차-원인-spawnborderelement의-인덱스-버그로-인해-entrance-다중-스폰">3. 2차 원인: SpawnBorderElement의 인덱스 버그로 인해 Entrance 다중 스폰</h2>
<h3 id="3-1-증상-같은-경계에-entrance가-여러-개-생김">3-1. 증상: 같은 경계에 Entrance가 여러 개 생김</h3>
<ul>
<li>특정 두 방 사이에 문이 한 쌍만 있어야 하는데, 실제로는 같은 위치에 Entrance 액터가 여러 개 겹쳐서 생성됨</li>
<li>처음에는 &quot;PlaceWallsAndEntrances에서 같은 경계를 두 번 처리(A→B, B→A)해서 그런가?&quot; 정도로 추측</li>
</ul>
<h3 id="3-2-spawnborderelement-내부의-중복-방지-로직-분석">3-2. SpawnBorderElement 내부의 중복 방지 로직 분석</h3>
<ul>
<li><p>원래 의도</p>
<ul>
<li><code>ACMRoom::SpawnBorderElement(int32 DirectionIndex, bool bIsEntrance)</code> 는 같은 방향으로 여러 번 호출되더라도 <strong>한 번만 SpawnActor</strong> 하고, 이후에는 캐싱된 액터 포인터를 재사용해야 했습니다.</li>
<li>중복 방지 코드 (초기 상태):<ul>
<li><code>if (RoomBorderActors[DirectionIndex]) return RoomBorderActors[DirectionIndex];</code></li>
</ul>
</li>
</ul>
</li>
<li><p>문제 구간</p>
<ul>
<li>스폰 후 반대편 룸에도 같은 BorderActor를 공유해 주려던 로직:<ul>
<li><code>if (ConnectedRooms[DirectionIndex + 2 % 4])</code></li>
<li><code>ConnectedRooms[DirectionIndex + 2 % 4]-&gt;RoomBorderActors[DirectionIndex] = SpawnedActor;</code></li>
</ul>
</li>
</ul>
</li>
<li><p>버그 1: 연산자 우선순위 실수</p>
<ul>
<li><code>DirectionIndex + 2 % 4</code> 는 <code>DirectionIndex + (2 % 4)</code> 로 계산됨 → <code>DirectionIndex + 2</code></li>
<li>의도는 <code>(DirectionIndex + 2) % 4</code> 로 0↔2, 1↔3 매핑이었지만, 실제로는<ul>
<li>0 → 2 (우연히 맞음)</li>
<li>1 → 3 (우연히 맞음)</li>
<li>2 → 4 (배열 범위 밖)</li>
<li>3 → 5 (배열 범위 밖)</li>
</ul>
</li>
<li>이로 인해 <code>ConnectedRooms</code> 배열 범위를 벗어나 잘못된 메모리를 읽거나 쓰게 되고, <code>RoomBorderActors</code> 중복 체크가 붕괴될 위험이 생겼습니다.</li>
</ul>
</li>
<li><p>버그 2: 반대편 룸에 잘못된 인덱스로 저장</p>
<ul>
<li><code>ConnectedRooms[DirectionIndex + 2 % 4]-&gt;RoomBorderActors[DirectionIndex] = SpawnedActor;</code></li>
<li>반대편 룸에서는 <strong>반대 방향 인덱스(OppDir)</strong> 에 저장해야 하는데, 여전히 <code>DirectionIndex</code> 를 사용하고 있었습니다.</li>
<li>예를 들어, 현재 방에서 Up(0) 방향 Entrance를 만들 때 반대편(Down 방향)의 인덱스는 2가 되어야 하는데, 잘못된 인덱스에 기록될 수 있습니다.</li>
</ul>
</li>
<li><p>결과</p>
<ul>
<li>중복 방지용 <code>RoomBorderActors[DirectionIndex]</code> 또는 이웃 룸의 <code>RoomBorderActors</code> 가 꼬이면서,</li>
<li>같은 경계를 여러 번 처리할 때마다 <code>SpawnActor</code> 가 재호출되어 Entrance가 계속 쌓이는 현상으로 이어졌습니다.</li>
</ul>
</li>
</ul>
<h3 id="3-3-해결-spawnborderelement-로직-수정">3-3. 해결: SpawnBorderElement 로직 수정</h3>
<ul>
<li><p>수정 포인트</p>
<ul>
<li>반대 방향 인덱스를 정확히 계산:<ul>
<li><code>const int32 OppDir = (DirectionIndex + 2) % 4;</code></li>
</ul>
</li>
<li>현재 룸과, 연결된 반대편 룸 모두에 동일 BorderActor를 공유:<ul>
<li>현재 룸:<ul>
<li><code>RoomBorderActors[DirectionIndex] = SpawnedActor;</code></li>
</ul>
</li>
<li>반대편 룸이 이미 연결되어 있다면:<ul>
<li><code>ConnectedRooms[OppDir]-&gt;RoomBorderActors[OppDir] = SpawnedActor;</code></li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><p>핵심 아이디어</p>
<ul>
<li><strong>경계 하나(두 방 사이)에 Border 액터는 하나만 존재</strong>해야 하고,</li>
<li>두 방은 각각 자신의 <code>RoomBorderActors[해당 방향]</code> 에서 동일한 액터 포인터를 가지도록 설계</li>
<li>이렇게 하면, A→B, B→A 어느 쪽에서 먼저 <code>SpawnBorderElement</code> 를 호출해도,<ul>
<li>최초 1회만 SpawnActor가 실행되고,</li>
<li>이후에는 캐싱된 포인터를 재사용하게 됩니다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-3차-원인-쿼드트리-기반-생성-과정에서의-이슈-물리적-인접-room-문제">4. 3차 원인: 쿼드트리 기반 생성 과정에서의 이슈 (물리적 인접 Room 문제)</h2>
<h3 id="4-1-문제-인식">4-1. 문제 인식</h3>
<ul>
<li>맵을 쿼드트리(QuadTree) 기반으로 생성하다 보니, 논리적인 그래프/트리 상에서의 인접 관계와 실제 격자 좌표 상의 물리적 인접 관계가 항상 일치하지 않는 문제가 발생<ul>
<li>예시 상황<ul>
<li>쿼드트리의 어떤 노드가 서브 트리로 분기되면서, 실제로는 (0,0)과 (1,0) 같은 이웃 타일이 물리적으로는 맞닿아 있지만,</li>
<li>트리 구조 상으로는 서로 다른 브랜치에 속해 있어 &quot;인접 노드&quot;로 간주되지 않는 경우가 발생</li>
</ul>
</li>
</ul>
</li>
<li>따라서, Room에서 캐싱한 ConnectedRooms[]으로는 인접한 방이 존재하는지 확인할 수 있는 방법 X</li>
<li>방이나 Border가 없다고 판단되어 벽을 생성하는 문제 발생</li>
</ul>
<h3 id="4-2-해결-fcmroomposition-기반-재캐싱">4-2. 해결: FCMRoomPosition 기반 재캐싱</h3>
<ul>
<li>해결 전략: FCMRoomPosition 기반 재캐싱<ul>
<li>트리/그래프 상의 인접 정보(Adjacency)에만 의존하지 않고, <strong>항상 최종 좌표(FCMRoomPosition)를 기준으로 인접성을 재검증</strong>하도록 설계 방향을 변경</li>
<li>구체적으로는:<ul>
<li>쿼드트리 분기, DFS/스패닝 트리 생성 등을 모두 마친 뒤,</li>
<li>최종적으로 <code>OutRoomMap</code> 에 담긴 <code>(FCMRoomPosition → ACMRoom*)</code> 정보를 다시 순회하면서</li>
<li>각 좌표의 상/좌/하/우 방향에 대해 직접 <code>OutRoomMap.Find(NeighborPos)</code> 를 호출</li>
<li>이렇게 해서 물리적으로 인접한 방들만 <code>ConnectedRooms</code> 로 다시 묶어 주도록 후처리</li>
</ul>
</li>
<li>이 재캐싱 단계 덕분에<ul>
<li>쿼드트리 상으로는 떨어진 브랜치에 있더라도, 실제 그리드 상에서 붙어 있는 방들은 모두 인접 방으로 인식</li>
<li>Entrance/Wall 생성 로직은 오직 <code>FCMRoomPosition</code> 기반 인접성에만 의존하게 되어, 트리 구조에 의해 왜곡되지 않음</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3 id="4-3-효과">4-3: 효과</h3>
<ul>
<li>최종적으로는 <strong>격자 상에서 인접한 방들끼리는 항상 ConnectedRooms가 양방향으로 일관되게 채워짐</strong></li>
<li>쿼드트리/그래프 구조가 어떻게 변하더라도, FCMRoomPosition 기반 재캐싱 과정 덕분에 Entrance/Wall 스폰 로직이 올바른 인접 정보를 참조할 수 있게됨</li>
</ul>
<hr>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/50898fc8-43e3-462e-a6b2-22cee65a5c51/image.png" alt=""></p>
<p>이번 트러블슈팅을 통해, 단순히 &quot;Entrance가 여러 개 생긴다&quot;는 증상을 해결하는 데에도 여러 층위의 문제가 겹쳐 있을 수 있음을 체감했습니다. 방향 인덱스와 좌표계 정의가 조금만 어긋나도 전체 구조가 틀어지고, 작은 연산자 우선순위 실수 하나가 메모리 오염과 중복 스폰으로 이어질 수 있음을 경험했습니다. </p>
<p>앞으로는 방향/인덱스/좌표 체계를 프로젝트 초기에 명확히 문서화하고, 경계(Entrance/Wall)와 같은 공유 리소스는 &quot;한 번만 스폰, 양쪽에서 공유&quot;라는 설계를 기본 원칙으로 삼으려 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] 멀티플레이 환경에서 Dynamic Level Streaming 실패 이유 정리]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%EB%A9%80%ED%8B%B0%ED%94%8C%EB%A0%88%EC%9D%B4-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Dynamic-Level-Streaming-%EC%8B%A4%ED%8C%A8-%EC%9D%B4%EC%9C%A0-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%EB%A9%80%ED%8B%B0%ED%94%8C%EB%A0%88%EC%9D%B4-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Dynamic-Level-Streaming-%EC%8B%A4%ED%8C%A8-%EC%9D%B4%EC%9C%A0-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Thu, 20 Nov 2025 11:24:09 GMT</pubDate>
            <description><![CDATA[<p>Room 단위로 서브 레벨을 스트리밍하여 맵을 동적으로 구성하는 기능을 구현하는 과정에서, 싱글 플레이와 리슨 서버 환경에서는 정상적으로 동작하지만, 두 번째 클라이언트 접속 시 <code>MissingLevelPackage</code> 오류와 함께 접속이 끊어지는 문제가 발생했습니다. 이 TIL에서는 해당 문제가 발생한 원인과, 언리얼 엔진의 레벨 스트리밍·네트워크 동기화 구조를 정리하여, 앞으로 같은 실수를 반복하지 않기 위한 기록을 남기고자 합니다.</p>
<hr>
<h2 id="발생한-현상-요약">발생한 현상 요약</h2>
<ul>
<li><p>리슨 서버에서 <code>StreamingLevelName = L_StreamingTest2</code> 설정 후 <code>LoadLevelInstance</code> 계열 로직으로 레벨 인스턴스 생성</p>
</li>
<li><p>리슨 서버 단독 플레이에서는 정상적으로 방이 원하는 위치에 생성</p>
</li>
<li><p>두 번째 클라이언트가 접속하면 다음과 같은 로그 출력 후 연결 끊김
<img src="https://velog.velcdn.com/images/dev_sensational/post/bfd1cd30-0122-4c40-a1fe-f8ea90ee07ec/image.png" alt=""></p>
<ul>
<li><code>ServerUpdateLevelVisibility() ignored non-existant package</code></li>
<li><code>MissingLevelPackage</code></li>
<li><code>Your connection to the host has been lost.</code></li>
</ul>
</li>
<li><p>결과적으로, 서버 입장에서는 스트리밍이 성공한 것처럼 보이나, 새로 접속한 클라이언트 기준에서는 해당 패키지를 찾지 못해 접속이 강제로 종료되는 상황 발생</p>
</li>
</ul>
<hr>
<h2 id="근본-원인-1-ulevelstreamingdynamic-이름-비결정성">근본 원인 1: ULevelStreamingDynamic 이름 비결정성</h2>
<ul>
<li>문제가 된 코드에서 사용한 것은 <code>ULevelStreamingDynamic</code> / <code>LoadLevelInstance</code> 계열 API</li>
<li><code>LoadLevelInstance</code> 내부 구현<ul>
<li><code>MakeUniqueObjectName(..., TEXT(&quot;_LevelInstance&quot;))</code> 로 스트리밍 레벨 인스턴스 이름 자동 생성</li>
<li><code>_LevelInstance_135</code>, <code>_LevelInstance_396</code> 같이 접미사 숫자가 내부 카운터 기반으로 붙음</li>
</ul>
</li>
<li>서버/클라이언트 각각이 같은 맵 이름으로 <code>LoadLevelInstance</code>를 호출해도<ul>
<li>서버: <code>L_StreamingTest2_LevelInstance_135</code></li>
<li>클라: <code>L_StreamingTest2_LevelInstance_396</code></li>
<li>이런 식으로 서로 다른 이름의 패키지 생성</li>
</ul>
</li>
<li>결과적으로 서버가 가진 스트리밍 패키지 이름과 클라이언트가 가진 패키지 이름이 일치하지 않음<ul>
<li>서버는 <code>..._135</code>를 visibility 업데이트 대상으로 삼지만</li>
<li>클라이언트 월드에는 <code>..._135</code>가 존재하지 않음 → <code>ignored non-existant package</code> 로그 출력</li>
</ul>
</li>
</ul>
<hr>
<h2 id="근본-원인-2-동적-스트리밍-레벨-정보는-replication-되지-않음">근본 원인 2: 동적 스트리밍 레벨 정보는 Replication 되지 않음</h2>
<ul>
<li>언리얼 기본 네트워크 동기화는 <strong>액터 / 컴포넌트 / 프로퍼티</strong>에 초점</li>
<li><code>ULevelStreamingDynamic</code> 으로 런타임에 새 스트리밍 레벨을 로드하는 행위 자체<ul>
<li>서버에서 생성했다고 해서 해당 스트리밍 레벨 정보가 클라이언트로 자동 복제되지 않음</li>
</ul>
</li>
<li>서버 기준 흐름<ul>
<li>서버: Dynamic Streaming Level 생성 및 로드, visible 상태로 전환</li>
<li>서버: <code>ServerUpdateLevelVisibility</code> 에서 해당 패키지를 클라에게도 보여주려고 시도</li>
</ul>
</li>
<li>클라이언트 기준 상황<ul>
<li>클라이언트: 해당 이름의 스트리밍 레벨 인스턴스를 가진 적이 없음</li>
<li>네트워크를 통해 &quot;이 이름의 스트리밍 레벨을 같이 로드하자&quot; 라는 정보가 구조적으로 내려오지 않음</li>
</ul>
</li>
<li>따라서 서버와 클라의 스트리밍 레벨 목록이 서로 일치하지 않는 상태에서 visibility 동기화가 수행됨<ul>
<li>없는 패키지에 대해 visibility 업데이트를 시도 → <code>MissingLevelPackage</code> 로 연결 종료</li>
</ul>
</li>
</ul>
<hr>
<h2 id="근본-원인-3-호출-타이밍·월드-상태의-비결정성">근본 원인 3: 호출 타이밍·월드 상태의 비결정성</h2>
<ul>
<li><code>LoadLevelInstance</code> 는 다음 요소들에 의존하는 비결정적(Non-Deterministic) 특성 존재<ul>
<li>오브젝트 이름 카운터</li>
<li>GC 및 기존 객체 존재 여부</li>
<li>호출 시점, 호출 순서</li>
</ul>
</li>
<li>서버와 클라이언트가 같은 프레임에 같은 코드 경로를 탄다 하더라도<ul>
<li>내부적으로 생성되는 LevelInstance 이름, 로딩 완료 시점, 스트리밍 레벨 배열에 들어가는 순서는 항상 일치한다고 보장할 수 없음</li>
</ul>
</li>
<li>특히 리슨 서버 환경에서<ul>
<li>서버와 첫 번째 클라이언트는 같은 프로세스 내에서 이상 없이 보이지만</li>
<li>두 번째 클라이언트는 이미 월드 상태가 바뀐 뒤 접속하기 때문에, 기존에 서버에서 만든 Dynamic Level과 맞출 수 있는 정보가 더더욱 부족</li>
</ul>
</li>
</ul>
<hr>
<h2 id="멀티플레이에서-dynamic-level-instance를-쓰기-어려운-이유-정리">멀티플레이에서 Dynamic Level Instance를 쓰기 어려운 이유 정리</h2>
<ul>
<li><code>ULevelStreamingDynamic / LoadLevelInstance</code> 기반 구조는 멀티플레이에서 다음 이유로 부적합<ul>
<li>인스턴스 이름이 자동 생성 → 서버/클라 이름 불일치</li>
<li>동적으로 추가한 스트리밍 레벨 목록이 replication 되지 않음</li>
<li>기존 네트워크 레벨 로딩/전환 파이프라인과 분리된 별도 경로로 동작</li>
<li>접속 중인 플레이어별로 월드 상태가 달라질 수 있어, 새로 접속한 플레이어가 기존 동적 레벨 상태를 재현하기 어려움</li>
</ul>
</li>
<li>따라서 &quot;서버가 동적으로 레벨 인스턴스를 만든 뒤, 그걸 그대로 클라이언트에게 복제하는 구조&quot;는 언리얼 기본 기능만으로는 성립하지 않음</li>
</ul>
<hr>
<h2 id="이번-프로젝트에서-드러난-오해-정리">이번 프로젝트에서 드러난 오해 정리</h2>
<ul>
<li>오해 1<ul>
<li>&quot;서버에서만 맵 스트리밍을 하면, 그 지형 정보가 자동으로 클라이언트에게 복제된다&quot;</li>
<li>실제로는 동적 스트리밍 레벨 자체는 복제가 안 되고, 고정된 맵 전환/스트리밍에만 네트워크 지원이 붙어 있음</li>
</ul>
</li>
<li>오해 2<ul>
<li>&quot;서버와 클라이언트가 같은 함수를 호출하면, 같은 LevelInstance 이름으로 생성될 것이다&quot;</li>
<li>실제로는 <code>MakeUniqueObjectName</code> 특성상 이름이 달라질 수밖에 없고, 호출 순서/개수 차이로 완전히 다른 결과가 나옴</li>
</ul>
</li>
<li>오해 3<ul>
<li>&quot;에러 로그에 나오는 패키지 이름만 맞춰주면 된다&quot;</li>
<li>실제로는 이름만의 문제가 아니라, 스트리밍 레벨의 생성·레이아웃·오너십 전체가 네트워크 파이프라인 밖에 있어서 구조적으로 어긋나 있음</li>
</ul>
</li>
</ul>
<hr>
<h2 id="앞으로의-설계-방향-메모">앞으로의 설계 방향 메모</h2>
<ul>
<li>방 단위 맵 생성이 필요할 때 고려할 선택지<ul>
<li>고정 Streaming Level 여러 개를 에디터에 등록한 뒤, 서버/클라 모두 같은 이름으로 Stream In/Out</li>
<li>완전히 동적인 던전은 &quot;레벨 스트리밍&quot; 대신 &quot;액터 스폰 + 랜덤 시드 동기화&quot; 패턴 적용</li>
<li>장기적으로는 World Partition 기반으로 전환하고 Data Layer를 사용해 가시성/로딩 제어</li>
</ul>
</li>
<li>특히 멀티플레이를 고려하는 시스템에서는<ul>
<li>&quot;서버가 만든 레벨이 그대로 클라에 복제된다&quot;는 가정을 버리고</li>
<li>&quot;항상 서버/클라가 같은 규칙으로 월드를 재구성한다&quot;는 관점에서 설계하는 편이 안전함</li>
</ul>
</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 문제는 단순히 코드 버그라기보다, 언리얼 엔진의 레벨 스트리밍과 네트워크 동기화가 어떻게 설계되어 있는지 충분히 이해하지 못한 상태에서 <code>ULevelStreamingDynamic</code>을 멀티플레이 환경에 그대로 적용한 데에서 비롯된 것이었습니다. 서버와 클라이언트가 동일한 레벨 인스턴스를 공유한다고 가정하고 구현했으나, 실제로는 인스턴스 이름 생성, 스트리밍 레벨 목록 관리, 패키지 로딩 흐름이 네트워크와 분리되어 있어, 새로 접속한 클라이언트가 동적으로 생성된 레벨을 찾지 못하는 상황이 발생했습니다.</p>
<p>이 TIL을 통해, 멀티플레이에서 레벨을 다루는 방법은 기존 싱글 플레이/에디터 중심 사고방식과는 다르게 접근해야 한다는 점을 다시 한 번 정리하게 되었습니다. 앞으로는 월드 파티션, 고정 스트리밍 레벨, 액터 기반 동적 생성 등 언리얼이 제공하는 네트워크 친화적 구조를 우선적으로 고려하여 설계하고, 특히 &quot;서버가 만든 것을 클라에 그대로 복제한다&quot;는 직관적인 기대 대신, &quot;서버와 클라가 합의된 규칙에 따라 동일한 월드를 재구성한다&quot;는 관점에서 시스템을 구성하고자 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] 데이터 기반 Procedural Map Generator + 레벨 스트리밍 구현]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B8%B0%EB%B0%98-Procedural-Map-Generator-%EB%A0%88%EB%B2%A8-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B8%B0%EB%B0%98-Procedural-Map-Generator-%EB%A0%88%EB%B2%A8-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Wed, 19 Nov 2025 09:59:10 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 구현한 자동 맵 생성(MapGenerate) 로직과, 생성된 맵 구조에 맞춰 서브 레벨을 동적으로 로드하는 레벨 스트리밍 구조를 정리해 보겠습니다. 특히 어떤 알고리즘으로 방(Room) 그래프를 만들었는지, 그 그래프를 어떻게 실제 월드 좌표에 배치했는지, 그리고 <code>ACMRoom</code>과 스트리밍 레벨을 어떻게 연결했는지 위주로 기술해 보겠습니다.</p>
<hr>
<h2 id="초기-데이터-설정">초기 데이터 설정</h2>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/3a5351f0-1a0a-4867-b5c2-57866de3efc0/image.png" alt=""></p>
<p>데이터 설정은 <code>UCMRoomDataDefinition</code>을 기반으로 결정됩니다. 이 곳에서 넓이, 방의 갯수, 방 종류 등을 셋팅합니다. 또한, 맵 생성 알고리즘과 시작 지점 선택 알고리즘도 컴포넌트화시켜 기획 담당자가 쉽게 변경할 수 있도록 설계했습니다.</p>
<hr>
<h2 id="맵-생성-전반-구조">맵 생성 전반 구조</h2>
<ul>
<li><p>맵 생성의 진입점</p>
<ul>
<li><code>UCMMapGenerateLogicBaseComponent::ExecuteMapGenerationLogic</code></li>
<li>입력<ul>
<li>시드 값 <code>InSeed</code></li>
<li>맵 정의 데이터 <code>UCMRoomDataDefinition* InMapData</code></li>
<li>결과를 담을 <code>TMap&lt;FCMRoomPosition, TObjectPtr&lt;ACMRoom&gt;&gt;&amp; OutRoomMap</code></li>
</ul>
</li>
<li>내부 흐름<ul>
<li><code>GenerateRoom</code>에서 방 그래프 및 방 액터 생성</li>
<li><code>PlaceWallsAndEntrances</code>에서 각 방의 벽과 입구(Entrance) 스폰</li>
</ul>
</li>
</ul>
</li>
<li><p>핵심 데이터 구조</p>
<ul>
<li><code>FCMRoomPosition</code><ul>
<li>정수 좌표 (X, Y)로 방의 격자 상 위치 표현</li>
</ul>
</li>
<li><code>OutRoomMap</code><ul>
<li><code>FCMRoomPosition</code> → <code>ACMRoom*</code> 매핑</li>
<li>맵 생성 알고리즘과 실제 액터 스폰을 연결하는 브리지 역할</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="방-그래프-생성-알고리즘-개요">방 그래프 생성 알고리즘 개요</h2>
<h3 id="1-사용한-알고리즘">1. 사용한 알고리즘</h3>
<ul>
<li><p>전체 알고리즘 성격</p>
<ul>
<li>격자 기반 그래프 위에서 <strong>랜덤 DFS(깊이 우선 탐색)</strong> 를 이용해 스패닝 트리 형태의 맵을 만듭니다</li>
<li>시작점에서 멀리 떨어진 리프 노드를 보스 방으로, 그 외 후보에서 보물 방을 선정하는 구조</li>
</ul>
</li>
<li><p>주요 의도</p>
<ul>
<li>항상 <strong>연결 그래프</strong>를 유지해 플레이어가 어디서든 막히지 않고 진행 가능</li>
<li>랜덤 시드를 사용해 <strong>재현 가능한(random but reproducible)</strong> 맵 구조 생성</li>
<li>시작 방에서 거리가 먼 리프에 보스를 배치해 자연스러운 진행 경로 유도</li>
</ul>
</li>
</ul>
<h3 id="2-좌표계-및-경계-설정">2. 좌표계 및 경계 설정</h3>
<ul>
<li>맵 크기 정의<ul>
<li><code>UCMRoomDataDefinition</code>에 너비/높이/최대 방 수, 보물 방 수, 보스 방 수를 정의</li>
</ul>
</li>
<li>중심 기준 경계 계산<ul>
<li>맵의 중심을 (0, 0)으로 두고</li>
<li>X, Y 축에 대해 <code>MinX, MaxX, MinY, MaxY</code> 계산</li>
<li>생성 중인 방 위치가 이 범위를 벗어나지 않도록 검사</li>
</ul>
</li>
</ul>
<h3 id="3-dfs-기반-방-확장">3. DFS 기반 방 확장</h3>
<ul>
<li><p>시작점 설정</p>
<ul>
<li><code>Start = FCMRoomPosition(0, 0)</code></li>
<li><code>OutRoomMap.Add(Start, nullptr)</code>로 먼저 좌표만 점유</li>
<li>DFS 스택에 시작 노드를 push</li>
</ul>
</li>
<li><p>방향 정의</p>
<ul>
<li>인덱스 0~3에 상, 좌, 하, 우를 고정 매핑</li>
<li><code>DirOffset[4]</code> 배열을 사용해 <code>FCMRoomPosition</code> 델타 계산</li>
</ul>
</li>
<li><p>확장 로직</p>
<ul>
<li>DFS 스택의 top을 현재 노드로 사용</li>
<li>상하좌우 중 아직 점유되지 않았고, 맵 경계 안에 있는 방향만 후보에 추가</li>
<li><code>FRandomStream</code>(시드 고정 랜덤)으로 후보 방향 중 하나를 선택</li>
<li>다음 좌표 <code>Next</code>를 계산하고, <code>OutRoomMap.Add(Next, nullptr)</code>로 점유</li>
<li>인접성 정보 <code>Adjacency</code>에 <code>Curr &lt;-&gt; Next</code> 양방향으로 추가</li>
<li>부모 방향 정보 <code>ParentDirIndex</code>를 기록해 나중에 트리 구조 복원에 사용</li>
<li>더 이상 확장할 방향이 없으면 DFS 스택 pop으로 백트래킹</li>
</ul>
</li>
<li><p>결과</p>
<ul>
<li><code>OutRoomMap.Keys()</code>가 전체 방 좌표 집합</li>
<li><code>Adjacency</code>가 그래프의 엣지 집합</li>
<li>구조적으로 <strong>사이클이 거의 없는 트리 또는 트리 기반 그래프</strong>가 형성</li>
</ul>
</li>
</ul>
<h3 id="4-거리-계산과-리프-노드-분석">4. 거리 계산과 리프 노드 분석</h3>
<ul>
<li><p>거리 계산</p>
<ul>
<li>시작점에서 BFS를 돌며 각 방까지의 최단 거리 <code>Distance</code> 계산</li>
<li><code>Adjacency</code>를 그대로 사용해 그래프 탐색</li>
</ul>
</li>
<li><p>리프 노드 추출</p>
<ul>
<li>각 좌표에 대해 인접 방 개수(차수)가 1인 노드를 리프로 간주</li>
<li>시작점(0, 0)은 예외로 처리</li>
</ul>
</li>
<li><p>거리 기반 정렬</p>
<ul>
<li>리프 리스트를 <code>Distance</code> 내림차순으로 정렬</li>
<li>시작점에서 <strong>멀리 떨어진 리프일수록 우선순위가 높음</strong></li>
</ul>
</li>
<li><p>보스/보물 방 배치</p>
<ul>
<li>보스 방<ul>
<li>정렬된 리프들 중 앞에서부터 <code>DesiredBoss</code> 개수만큼 선택</li>
<li><code>BossPositions</code> 집합에 좌표 저장</li>
</ul>
</li>
<li>보물 방<ul>
<li>시작점과 보스 방을 제외한 모든 좌표를 후보로 수집</li>
<li>랜덤 셔플 후 상위 <code>DesiredTreasure</code>개를 <code>TreasurePositions</code> 집합으로 선택</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="방-액터-스폰과-월드-좌표-매핑">방 액터 스폰과 월드 좌표 매핑</h2>
<h3 id="1-방-크기-측정과-간격-계산">1. 방 크기 측정과 간격 계산</h3>
<ul>
<li><p>방 크기 측정 방법</p>
<ul>
<li><code>ACMRoom</code>은 <code>UCMRoomBoundsBox</code> 컴포넌트를 통해 실제 방의 월드 상 가로, 세로 크기를 노출</li>
<li><code>GetRoomWidth</code>, <code>GetRoomHeight</code>에서 박스 익스텐트를 2배한 값으로 길이 산출</li>
</ul>
</li>
<li><p>대표 간격 계산</p>
<ul>
<li>몬스터/보물/보스 방 클래스들의 CDO를 순회해 최대 폭/높이 측정</li>
<li>값이 너무 작거나 0일 경우, 실제 방을 임시 스폰해 한 번 더 측정</li>
<li>최종적으로 X, Y 방향 <code>SpacingX</code>, <code>SpacingY</code>를 결정</li>
</ul>
</li>
<li><p>좌표 → 월드 위치 매핑</p>
<ul>
<li><code>FVector Location(Pos.X * SpacingX, Pos.Y * SpacingY, 0.f)</code></li>
<li>격자 좌표를 그대로 월드 좌표에 선형 변환</li>
<li>방들 사이에 겹침 없이 일정 간격을 확보하는 구조</li>
</ul>
</li>
</ul>
<h3 id="2-방-타입에-따른-클래스-선택">2. 방 타입에 따른 클래스 선택</h3>
<ul>
<li><p>룸 타입 판정</p>
<ul>
<li>좌표가 <code>BossPositions</code>에 포함되면 보스 방</li>
<li>그 외 <code>TreasurePositions</code>에 포함되면 보물 방</li>
<li>둘 다 아니면 일반 몬스터 방</li>
</ul>
</li>
<li><p>클래스 선택 로직</p>
<ul>
<li><code>UCMRoomDataDefinition</code>에 각 타입별 클래스 배열 보유</li>
<li><code>FRandomStream</code>으로 해당 배열에서 하나를 랜덤 선택</li>
<li>타입별 클래스가 비어 있으면 몬스터 방 클래스로 폴백</li>
</ul>
</li>
<li><p>스폰</p>
<ul>
<li><code>World-&gt;SpawnActor&lt;ACMRoom&gt;(ClassToSpawn, Location, Rotation, SpawnParams)</code></li>
<li>성공 시 <code>OutRoomMap[Pos]</code>를 실제 포인터로 덮어써서 좌표와 액터 인스턴스를 연결</li>
</ul>
</li>
</ul>
<hr>
<h2 id="방-간-연결-정보와-입구벽-배치">방 간 연결 정보와 입구/벽 배치</h2>
<h3 id="1-연결-방향-인덱스-계산">1. 연결 방향 인덱스 계산</h3>
<ul>
<li><p><code>DirIndexFromDelta(DX, DY)</code></p>
<ul>
<li>두 좌표 차이(델타)를 상/좌/하/우 인덱스로 변환</li>
<li>예시<ul>
<li>(0, +1) → 0 (Up)</li>
<li>(-1, 0) → 1 (Left)</li>
<li>(0, -1) → 2 (Down)</li>
<li>(+1, 0) → 3 (Right)</li>
</ul>
</li>
</ul>
</li>
<li><p><code>OppositeDir(Dir)</code></p>
<ul>
<li>상↔하, 좌↔우로 대응되는 반대 방향 인덱스를 반환</li>
</ul>
</li>
</ul>
<h3 id="2-연결-바인딩">2. 연결 바인딩</h3>
<ul>
<li><p>맵 생성 후, 모든 방에 대해 인접 좌표 리스트를 순회</p>
</li>
<li><p>인접 좌표에 해당하는 <code>ACMRoom*</code>를 찾아 상호 연결</p>
<ul>
<li><code>Room-&gt;SetConnectedRoom(Dir, NeighborRoom)</code></li>
<li><code>NeighborRoom-&gt;SetConnectedRoom(OppositeDir(Dir), Room)</code></li>
</ul>
</li>
<li><p>디버그 로그</p>
<ul>
<li>각 연결 설정 시 UE_LOG로 양 끝 좌표와 방향 인덱스를 출력해 검증</li>
</ul>
</li>
</ul>
<h3 id="3-입구와-벽-스폰">3. 입구와 벽 스폰</h3>
<ul>
<li><p><code>PlaceWallsAndEntrances(OutRoomMap)</code> 단계</p>
<ul>
<li>각 <code>ACMRoom</code>에 대해 <code>GetConnectedRooms()</code>로 상하좌우 연결 상태 조회</li>
<li>네 방향에 대해<ul>
<li>연결된 방이 있으면 Entrance 스폰</li>
<li>없으면 Wall 스폰</li>
</ul>
</li>
</ul>
</li>
<li><p><code>ACMRoom::SpawnBorderElement</code></p>
<ul>
<li>각 방향별 <code>USceneComponent</code>(North/South/East/West EntrancePoint)를 기준으로 좌표와 회전값 계산</li>
<li><code>RoomEntranceClass</code> 또는 <code>RoomWallClass</code>를 사용해 액터 스폰</li>
<li>이미 한 번 스폰된 Border는 재사용해 중복 스폰 방지</li>
</ul>
</li>
</ul>
<hr>
<h2 id="레벨-스트리밍-구조">레벨 스트리밍 구조</h2>
<h3 id="1-acmroom과-streaminglevelname">1. ACMRoom과 StreamingLevelName</h3>
<ul>
<li><p><code>ACMRoom</code>에 추가된 프로퍼티</p>
<ul>
<li><code>UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = &quot;Streaming&quot;) FName StreamingLevelName;</code></li>
</ul>
</li>
<li><p>역할</p>
<ul>
<li>각 방이 <strong>어떤 서브 레벨과 연결되는지</strong>를 에디터에서 직접 지정 가능</li>
<li>예: <code>L_StreamingTest1</code>, <code>L_StreamingTest2</code> 같은 이름을 방마다 다르게 설정해 사용</li>
</ul>
</li>
<li><p>Getter/Setter</p>
<ul>
<li><code>FName GetStreamingLevelName() const</code>로 외부에서 조회</li>
<li><code>void SetStreamingLevelName(FName InLevelName)</code>로 런타임 중 변경 가능</li>
</ul>
</li>
</ul>
<h3 id="2-동적-스트리밍-인-함수-streaminglevelstreamin">2. 동적 스트리밍 인 함수: StreamingLevelStreamIn</h3>
<ul>
<li><p>진입점</p>
<ul>
<li><code>void ACMRoom::StreamingLevelStreamIn()</code></li>
<li>방이 활성화될 때, 혹은 입구를 통해 진입할 때 호출하는 것을 상정</li>
</ul>
</li>
<li><p>처리 흐름</p>
<ul>
<li><code>World</code>와 <code>StreamingLevelName</code> 유효성 검사</li>
<li><code>StreamingLevelName</code>을 레벨 애셋 이름으로 보고, 경로 문자열 생성<ul>
<li>현재 구현 예<ul>
<li><code>StreamingLevelName = &quot;L_StreamingTest1&quot;</code></li>
<li><code>LevelPath = &quot;/Game/MainStage/L_StreamingTest1&quot;</code></li>
</ul>
</li>
</ul>
</li>
<li><code>Room</code>의 월드 위치를 가져와 스트리밍 레벨의 기준 위치로 사용<ul>
<li><code>RoomLocation = GetActorLocation()</code></li>
</ul>
</li>
<li><code>ULevelStreamingDynamic::LoadLevelInstance</code> 호출<ul>
<li>매번 새로운 스트리밍 레벨 인스턴스를 <strong>동적으로 생성</strong></li>
<li>인자<ul>
<li><code>World</code></li>
<li><code>LevelPath</code> (패키지 경로)</li>
<li><code>RoomLocation</code></li>
<li><code>FRotator::ZeroRotator</code></li>
<li><code>bSuccess</code> (성공 여부)</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><p>결과</p>
<ul>
<li>호출할 때마다 해당 레벨이 <strong>Room 위치에 복제되듯이 로드</strong></li>
<li>여러 Room에서 같은 <code>StreamingLevelName</code>을 사용하면, 같은 레벨이 여러 위치에 여러 인스턴스로 생성되는 효과</li>
</ul>
</li>
<li><p>성공/실패 로그</p>
<ul>
<li>실패 시<ul>
<li><code>StreamingLevelStreamIn: Failed to dynamically load level &#39;...&#39; for Room ...</code></li>
</ul>
</li>
<li>성공 시<ul>
<li><code>StreamingLevelStreamIn: Dynamically loaded level &#39;...&#39; at Room ... Location=(x, y, z)</code></li>
</ul>
</li>
</ul>
</li>
</ul>
<h3 id="3-room-생성과-스트리밍의-연결">3. Room 생성과 스트리밍의 연결</h3>
<ul>
<li><p>Room 생성 시점</p>
<ul>
<li><code>UCMMapGenerateLogicBaseComponent::GenerateRoom</code> 안에서 방 액터가 스폰됩니다</li>
</ul>
</li>
<li><p>스트리밍 호출 시점</p>
<ul>
<li>현재 코드에서는 예시로 <code>ACMRoom::ExcuteSpawnRoom</code>에서<ul>
<li><code>StreamingLevelStreamIn();</code></li>
<li><code>SpawnBorderElement(DirectionIndex, bIsEntrance);</code></li>
</ul>
</li>
<li>특정 방향으로 출입구를 생성하는 타이밍에 스트리밍을 함께 트리거하는 구조</li>
</ul>
</li>
<li><p>활용 아이디어</p>
<ul>
<li>플레이어가 특정 방에 입장할 때 해당 방의 서브 레벨을 Stream In</li>
<li>반대로 이전 방에서 멀어졌을 때 Stream Out 함수(추가 구현)를 호출해 메모리 사용량 관리</li>
<li>Room 타입(일반/보스/보물)에 따라 서로 다른 서브 레벨을 바인딩해 다양한 룸 구성을 만듦</li>
</ul>
</li>
</ul>
<hr>
<h2 id="구현-결과">구현 결과</h2>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/e74e0a32-8ddb-4396-9313-ec1ac8829845/image.png" alt=""></p>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 글에서는 적용한 맵 생성과 레벨 스트리밍 구조를 정리해 보았습니다. 격자 기반 DFS 스패닝 트리 알고리즘을 이용해 항상 연결된 방 그래프를 만들고, 시작점에서의 거리 정보를 활용해 보스 방과 보물 방을 자연스럽게 배치하였습니다. 이후 방의 크기를 기반으로 월드 좌표를 계산해 액터를 스폰하고, 인접 정보에 따라 입구와 벽을 자동으로 구성하는 과정을 정리해 보았습니다.</p>
<p>또한 각 방에 스트리밍 레벨 이름을 직접 바인딩할 수 있는 구조를 만들고, <code>ULevelStreamingDynamic::LoadLevelInstance</code>를 통해 방 위치에 맞춰 서브 레벨을 동적으로 로드하는 방식을 도입하였습니다. 이를 통해 동일한 레벨 자산을 여러 방에서 재사용하거나, 방 타입에 따라 서로 다른 레벨 레이아웃을 바인딩하는 등 확장성이 높은 구조를 갖추게 되었습니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[C++/CS] Deadlock 문제]]></title>
            <link>https://velog.io/@dev_sensational/CCS-Deadlock-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@dev_sensational/CCS-Deadlock-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Tue, 18 Nov 2025 11:29:13 GMT</pubDate>
            <description><![CDATA[<p>데드락(Deadlock)은 두 개 이상의 프로세스나 스레드가 서로가 보유한 자원을 기다리면서 <strong>영원히 대기 상태에 빠지는 문제</strong>를 의미합니다. 즉, <strong>서로가 서로를 기다리기 때문에 아무도 앞으로 진행하지 못하는 상태</strong>라고 볼 수 있습니다. </p>
<p>멀티 스레드 환경에서 자원 잠금을 잘못 처리하면 쉽게 발생할 수 있는 대표적인 동시성 문제입니다.</p>
<hr>
<h2 id="데드락이-발생하기-위한-4가지-조건-coffman-조건">데드락이 발생하기 위한 4가지 조건 (Coffman 조건)</h2>
<p>데드락은 아래 4가지 조건이 모두 만족해야 발생합니다.</p>
<ol>
<li><p><strong>상호 배제(Mutual Exclusion)</strong></p>
<ul>
<li>자원을 동시에 사용할 수 없습니다. (예: mutex)</li>
</ul>
</li>
<li><p><strong>점유와 대기(Hold and Wait)</strong></p>
<ul>
<li>이미 자원을 보유한 상태에서 다른 자원을 기다립니다.</li>
</ul>
</li>
<li><p><strong>비선점(No Preemption)</strong></p>
<ul>
<li>다른 프로세스가 보유한 자원을 강제로 빼앗을 수 없습니다.</li>
</ul>
</li>
<li><p><strong>순환 대기(Circular Wait)</strong></p>
<ul>
<li>여러 프로세스가 서로가 보유한 자원을 기다리는 순환 구조가 형성됩니다.</li>
</ul>
</li>
</ol>
<p>이 중 <strong>하나라도 깨면 데드락을 방지할 수 있습니다.</strong> (중요)</p>
<hr>
<h2 id="데드락-예시-두-개의-mutex-잠금-순서-충돌">데드락 예시: 두 개의 Mutex 잠금 순서 충돌</h2>
<p>아래 예시는 데드락이 실제로 발생하는 C++ 코드입니다.
ThreadA와 ThreadB가 서로 반대로 mutex를 잠금으로써 데드락이 발생합니다.</p>
<pre><code class="language-cpp">#include &lt;iostream&gt;
#include &lt;thread&gt;
#include &lt;mutex&gt;
#include &lt;chrono&gt;

using namespace std;

mutex m1;
mutex m2;

void ThreadA() {
    cout &lt;&lt; &quot;[ThreadA] Lock m1\n&quot;;
    m1.lock();
    this_thread::sleep_for(chrono::milliseconds(100));

    cout &lt;&lt; &quot;[ThreadA] Try Lock m2\n&quot;;
    m2.lock();  // ThreadB가 m2를 잡고 있으므로 데드락
}

void ThreadB() {
    cout &lt;&lt; &quot;[ThreadB] Lock m2\n&quot;;
    m2.lock();
    this_thread::sleep_for(chrono::milliseconds(100));

    cout &lt;&lt; &quot;[ThreadB] Try Lock m1\n&quot;;
    m1.lock();  // ThreadA가 m1을 잡고 있으므로 데드락
}

int main() {
    thread t1(ThreadA);
    thread t2(ThreadB);

    t1.join();
    t2.join();

    return 0;
}</code></pre>
<h3 id="실행결과">실행결과</h3>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/4bcffb8e-8047-44ff-a8e1-6db481a46fb4/image.png" alt=""></p>
<p>이 상태에서 프로그램이 더 이상 진행되지 않았습니다.</p>
<hr>
<h2 id="데드락을-예방하는-방법">데드락을 예방하는 방법</h2>
<h3 id="1-자원-획득-순서를-통일하기">1) 자원 획득 순서를 통일하기</h3>
<p>모든 스레드가 <strong>항상 같은 순서(A → B)</strong>로 자원을 잠그도록 합니다.
이렇게 하면 순환 대기 조건을 깨뜨릴 수 있습니다.
=&gt; AB/BA 금지</p>
<h3 id="2-stdscoped_lock-사용하기-c17-이상">2) <code>std::scoped_lock</code> 사용하기 (C++17 이상)</h3>
<pre><code class="language-cpp">std::scoped_lock lock(m1, m2);</code></pre>
<p>두 mutex를 데드락 없이 안전하게 한 번에 잠글 수 있습니다.</p>
<h3 id="3-타임아웃-기반-try_lock-사용">3) 타임아웃 기반 <code>try_lock</code> 사용</h3>
<p>자원을 못 획득하면 아예 포기하거나 재시도하여 무한 대기를 피합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project Arc] TMap 키로 사용자 정의 구조체(FCMRoomPosition) 사용 시 필요한 요소와 해결 과정]]></title>
            <link>https://velog.io/@dev_sensational/Project-Arc-TMap-%ED%82%A4%EB%A1%9C-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EC%9D%98-%EA%B5%AC%EC%A1%B0%EC%B2%B4FCMRoomPosition-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%ED%95%84%EC%9A%94%ED%95%9C-%EC%9A%94%EC%86%8C%EC%99%80-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@dev_sensational/Project-Arc-TMap-%ED%82%A4%EB%A1%9C-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EC%9D%98-%EA%B5%AC%EC%A1%B0%EC%B2%B4FCMRoomPosition-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%ED%95%84%EC%9A%94%ED%95%9C-%EC%9A%94%EC%86%8C%EC%99%80-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Wed, 12 Nov 2025 03:42:47 GMT</pubDate>
            <description><![CDATA[<p>절차적 맵 생성 기능을 구현하면서, 격자 좌표를 기반으로 중복 지형 생성을 방지하고 좌표로 즉시 방(Room) 포인터에 접근하기 위해 TMap의 키로 FCMRoomPosition 구조체를 사용하고자 했습니다. 그러나 컴파일 단계에서 TMap 키 타입에 대한 해시 함수와 동등 비교 연산자가 없다는 오류가 발생했고, 이로 인해 빌드가 진행되지 않았습니다. 본 문서에서는 오류가 발생한 원인과 이를 해결하기 위해 적용한 코드 변경 사항을 정리했습니다.</p>
<hr>
<h2 id="증상오류-메시지현상">증상(오류 메시지/현상)</h2>
<p><img src="https://velog.velcdn.com/images/dev_sensational/post/b3716209-0ace-4ab4-bfc6-171a11810fe9/image.png" alt=""></p>
<ul>
<li>TMap&lt;FCMRoomPosition, ...&gt; 사용 시 컴파일 오류 발생</li>
<li>대표 오류 형태<ul>
<li>‘GetTypeHash’: 일치하는 오버로드된 함수가 없습니다 (no matching overloaded function found)</li>
<li>TMap 키 타입에 대해 해시를 찾을 수 없습니다 (no hash function for type ‘FCMRoomPosition’)</li>
</ul>
</li>
<li>문제 구문 예시<ul>
<li>TMap&lt;FCMRoomPosition, TObjectPtr<ACMRoom>&gt; Rooms;</li>
</ul>
</li>
</ul>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<ul>
<li>TMap은 해시 컨테이너로서 키의 해시 함수가 필요함</li>
<li>사용자 정의 USTRUCT 키는 기본 해시가 제공되지 않음</li>
<li>전역 범위의 GetTypeHash(const FCMRoomPosition&amp;) 미구현</li>
<li>키 동등성 판단을 위한 operator== 미구현 또는 const/시그니처 부적합</li>
<li>헤더 분리 시 가시성 문제로 인해 TMap 선언 시점에 GetTypeHash 심볼이 보이지 않는 경우도 발생 가능</li>
</ul>
<hr>
<h2 id="해결">해결</h2>
<ul>
<li>FCMRoomPosition에 const operator== 정의</li>
<li>전역 범위에 FORCEINLINE GetTypeHash(const FCMRoomPosition&amp;) 구현</li>
<li>좌표형 int32에 대해 HashCombine(::GetTypeHash(X), ::GetTypeHash(Y)) 사용</li>
<li>키/해시 정의가 TMap 선언보다 먼저 포함되도록 include 순서 정리</li>
<li>빌드/리빌드로 오류 제거 확인</li>
</ul>
<hr>
<h2 id="적용-코드">적용 코드</h2>
<pre><code class="language-cpp">USTRUCT()
struct FCMRoomPosition
{
    GENERATED_BODY()

    int32 X;
    int32 Y;

    FORCEINLINE FCMRoomPosition() : X(0), Y(0) {}
    FORCEINLINE FCMRoomPosition(int32 InX, int32 InY) : X(InX), Y(InY) {}

    FORCEINLINE bool operator==(const FCMRoomPosition&amp; Other) const
    {
        return X == Other.X &amp;&amp; Y == Other.Y;
    }
};

// 전역 해시 함수
FORCEINLINE uint32 GetTypeHash(const FCMRoomPosition&amp; Pos)
{
    // 두 좌표 해시 결합
    return HashCombine(::GetTypeHash(Pos.X), ::GetTypeHash(Pos.Y));
}</code></pre>
<hr>
<p>이번 이슈는 TMap이 요구하는 키 타입 계약(operator==, GetTypeHash)을 간과해서 발생한 문제였습니다. 사용자 정의 구조체를 컨테이너의 키로 사용하려면 비교와 해시의 정의가 반드시 필요하다는 점을 다시금 확인했습니다. 향후에는 키 타입을 설계할 때 동등성/해시의 일관성과 가시성(선언 순서)까지 함께 점검하여 유사한 빌드 오류를 사전에 방지해야겠습니다.</p>
]]></description>
        </item>
    </channel>
</rss>