<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>sehee-jj.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 14 Sep 2026 13:48:58 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>sehee-jj.log</title>
            <url>https://velog.velcdn.com/images/sehee-jj/profile/34fe17d4-e48b-4db3-bd9e-353e1d5d03b7/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. sehee-jj.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sehee-jj" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[26.09.14] :: 고양이 병맛 PVP 게임 02]]></title>
            <link>https://velog.io/@sehee-jj/26.09.14-%EA%B3%A0%EC%96%91%EC%9D%B4-%EB%B3%91%EB%A7%9B-PVP-%EA%B2%8C%EC%9E%84-02</link>
            <guid>https://velog.io/@sehee-jj/26.09.14-%EA%B3%A0%EC%96%91%EC%9D%B4-%EB%B3%91%EB%A7%9B-PVP-%EA%B2%8C%EC%9E%84-02</guid>
            <pubDate>Mon, 14 Sep 2026 13:48:58 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---고양이-캐릭터-컨트롤러--마우스-기준-스프라이트-방향-전환">📝 개발일지 - 고양이 캐릭터 컨트롤러 + 마우스 기준 스프라이트 방향 전환</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p><img src="https://velog.velcdn.com/images/sehee-jj/post/5f71a613-a76f-45fa-94bb-d625fb2dfda7/image.gif" alt=""></p>
<p>1단계 첫 항목인 캐릭터 컨트롤러부터 시작. 이동은 뱀서 프로젝트 때 익힌 Input System 방식을 재사용했고, 중간에 Sprint 버튼이 뗄 때 반응 안 하는 버그를 실측으로 잡음. 마우스 조준 방향을 어떻게 캐릭터에 반영할지 설계를 두 번 갈아엎었고, 최종적으로는 사진 기반 캐릭터라는 제약에 맞춰 좌우 반전 + 조준 방향값 분리 구조로 정리함.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-rigidbody2d-기반-이동">1. Rigidbody2D 기반 이동</h3>
<p>뱀서 프로젝트 노트에 이미 Rigidbody2D 기반 이동 패턴이 정리돼 있어서 그대로 재사용. 다만 뱀서는 생존 게임이라 관성 있는 이동(<code>AddForce</code>/<code>MovePosition</code>)이었고, 우리는 PvP 대전이라 반응성이 더 중요하다고 판단해서 <code>velocity</code> 직접 대입 방식으로 바꿈.</p>
<pre><code class="language-csharp">private void FixedUpdate()
{
    float currentSpeed = isRunning ? runSpeed : walkSpeed;
    rigid.linearVelocity = inputVec * currentSpeed; // 관성 없이 즉시 반응
}</code></pre>
<h3 id="2-sprint-릴리즈-버그--실측으로-원인-뒤집힘">2. Sprint 릴리즈 버그 — 실측으로 원인 뒤집힘</h3>
<p>Shift를 누르면 달리기 상태로 바뀌는데, 떼도 계속 달리기 속도가 유지되는 버그 발생. 처음 세운 가설은 &quot;바인딩에 Press Only 인터랙션이 걸려서 release 이벤트가 안 온다&quot;였는데, 확인해보니 Interactions 필드는 비어있었음.</p>
<p>Input Debugger로 keyboard leftShift raw 입력 자체를 찍어보니 눌림/뗌 둘 다 정상 반응 — 즉 OS/디바이스 레벨은 문제없고, Action Type(Button 확인됨), Player Input Behavior(Send Messages 확인됨), 코드(<code>isRunning = value.isPressed</code> 확인됨)까지 전부 정상으로 보였는데도 버그가 남아있었음.</p>
<p><strong>실측으로 뒤집힌 부분</strong>: 예상과 반대로, 인터랙션이 <strong>아예 없는 상태</strong>가 문제였음. <code>Press</code> 인터랙션을 명시적으로 추가하고 Trigger Behavior를 <code>Press And Release</code>로 지정하니 바로 해결됨. Button 타입 액션이라고 해서 인터랙션 없이도 press/release를 항상 안전하게 보장하진 않는다는 걸 확인함 — 처음 가설과 정반대 결론이라 기록해둠.</p>
<pre><code>Sprint 액션 → Left Shift 바인딩 → Interactions에 Press 추가
→ Trigger Behavior: Press And Release</code></pre><h3 id="3-마우스-조준-설계--4방향-스냅-방식을-짰다가-폐기">3. 마우스 조준 설계 — 4방향 스냅 방식을 짰다가 폐기</h3>
<p>처음엔 마우스 방향을 4방향(상/하/좌/우)으로 스냅해서 Animator의 <code>Direction</code>(Int) 파라미터로 방향별 스프라이트를 전환하는 구조로 짬 (RPG류에서 흔한 방식). 그런데 캐릭터가 <strong>실사 고양이 사진</strong> 기반이라는 걸 다시 짚고 나서 이 방식을 폐기함 — 사진은 방향별로 새로 찍을 수 없으니, 4방향 스프라이트 자체를 준비할 방법이 없었음.</p>
<p>대신 몸은 좌우 반전만 하고, 정밀한 조준 각도는 <strong>투척 아이템(감자/맛동산)이 캐릭터 주변에서 마우스 방향으로 떠 있는 것</strong>으로 표현하기로 설계 변경. 이동 방향과 조준 방향을 완전히 분리해서, 조준 벡터는 <code>AimDirection</code>이라는 public 프로퍼티로 노출해두고 나중에 만들 투척 인디케이터 스크립트가 그대로 가져다 쓰도록 함.</p>
<pre><code class="language-csharp">public Vector2 AimDirection { get; private set; } = Vector2.right;

private void UpdateAimDirectionFromMouse()
{
    Vector3 mouseWorldPos = mainCamera.ScreenToWorldPoint(mouseScreenPos);
    mouseWorldPos.z = transform.position.z;
    Vector2 rawDir = (Vector2)mouseWorldPos - (Vector2)transform.position;
    if (rawDir.sqrMagnitude &lt; 0.0001f) return; // 마우스가 겹치면 이전 방향 유지
    AimDirection = rawDir.normalized;
}</code></pre>
<h3 id="4-좌우-반전-부호가-반대로-나옴--원본-이미지-기준-문제">4. 좌우 반전 부호가 반대로 나옴 — 원본 이미지 기준 문제</h3>
<p>로직 자체는 맞는데 실제로 테스트해보니 고양이가 반대 방향을 보고 있었음. 원인은 원본 고양이 사진이 기본적으로 <strong>왼쪽을 보고 있는 상태</strong>였는데, 코드는 &quot;기본이 오른쪽&quot;이라고 가정하고 짜여있었던 것. 새 이미지를 뒤집어서 다시 넣는 대신, 조건문 부호만 반대로 바꿔서 해결.</p>
<pre><code class="language-csharp">// 원본 사진이 기본적으로 왼쪽을 보고 있어서 조건을 반대로 잡음
spriteRenderer.flipX = AimDirection.x &gt; 0f;</code></pre>
<p>앞으로 스킨(고양이 사진)을 추가할 때마다 전부 같은 기본 방향(왼쪽)으로 통일해서 넣어야 한다는 규칙이 생김. 스킨 수가 늘어나면 캐릭터별 <code>isBaseFacingRight</code> 플래그로 관리하는 방식도 고려 중.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 15]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-15</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-15</guid>
            <pubDate>Sun, 06 Sep 2026 09:47:13 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---코드-정리">📝 개발일지 - 코드 정리</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>기능 구현은 끝났고, 죽은 코드/중복 로직을 정리하는 작업을 함. 시작할 때 &quot;이 정도면 끝나겠지&quot; 싶었던 게, 파다 보니 확신했던 결론을 두 번 세 번 뒤집는 일이 계속 생겼음. 특히 하이라이트 색상 처리 하나 때문에 <code>ButtonComponent</code> API를 실측까지 해가며 파고든 게 오늘의 메인 이벤트.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-uibufficon-타이머--버그라고-확신했다가-철회">1. <code>UIBuffIcon</code> 타이머 — 버그라고 확신했다가 철회</h3>
<p><code>SetTimerRepeat</code>으로 등록한 반복 타이머라, <code>remainingSec</code>이 0이 돼도 타이머 자체는 안 멈추는 게 당연해 보였음. &quot;0이 된 뒤에도 <code>Tick</code>이 계속 불려서 낭비 아닌가?&quot; 싶어서 방어 코드(<code>ClearTimer</code> 명시적 호출)를 제안했는데, 로그 + 브레이크포인트로 직접 찍어보니 <strong><code>remainingSec</code>이 0이 되는 시점엔 <code>Tick</code>이 아예 호출되지 않음</strong>을 확인함.</p>
<p>공식 문서에 있는 문구 때문이었음:</p>
<blockquote>
<p>예약된 액션의 소유자가 파괴될 경우 내부에서 자동으로 <code>ClearTimer()</code> 함수를 시도합니다.</p>
</blockquote>
<p><code>HideBuff</code>가 부르는 <code>icon:Destroy()</code> 시점에 엔진이 타이머 소유자 파괴를 감지해서 알아서 정리해주고 있었던 것. 정황상 의심스러워 보이는 코드도 실측 없이는 &quot;버그&quot;로 단정하면 안 된다는 걸 다시 확인함. 결국 방어 코드는 추가하지 않고 원래 코드 그대로 유지, 오히려 &quot;혹시 몰라서&quot; 넣어뒀던 방어 코드도 도달 불가능한 지점이라 판단해서 뺐음.</p>
<h3 id="2-하이라이트-색상--컬러-상수-공유부터-시작해서-뒤집힌-결론">2. 하이라이트 색상 — 컬러 상수 공유부터 시작해서 뒤집힌 결론</h3>
<p>처음엔 단순하게 &quot;여러 파일에 중복된 하이라이트 색상 상수를 Logic으로 전역화하자&quot;는 생각으로 시작했는데, 파다 보니 완전히 다른 결론에 도달함.</p>
<p><strong>1단계</strong> — <code>ButtonComponent</code>에 <code>SelectedColor</code>가 있다는 걸 실측으로 확인. 클릭 뗀 뒤에도 색이 유지되고, 다른 버튼 누르면 자동으로 꺼짐. 그래서 디버그 패널의 아이템 버튼(<code>UIDebugItemSlot.SetHighlight</code>)은 이걸로 완전히 대체함 — 단, Scale 확대(1.15배) 연출은 <code>ButtonComponent</code>가 못 해주는 커스텀 연출이라 별도로 유지해야 했음.</p>
<p><strong>2단계</strong> — 같은 논리를 인벤토리 카테고리 탭에도 적용하려다가 실측에서 걸림. 최초 진입 시 기본 탭(장비 탭)은 실제 클릭이 발생한 적이 없어서 <code>SelectedColor</code>가 절대 켜지지 않음. &quot;그럼 최초 1회만 수동으로 색을 칠해주면 되지 않나&quot; 싶어서 <code>SpriteGUIRendererComponent.Color</code>를 직접 대입했는데, <strong>장비 탭만 영원히 노란색으로 박제되는 버그</strong>가 남. <code>ButtonComponent</code>가 관리하는 상태와 완전히 별개의 값을 건드린 거라, 나중에 다른 탭을 눌러도 자기가 켠 적 없는 값을 되돌려줄 이유가 없었던 것.</p>
<p><code>ButtonComponent</code>를 코드로 강제 클릭시키는 API도 찾아봤지만 없었음(<code>ButtonComponent는 출력 기능이 없습니다</code> — 입력만 받고 상태를 밖에서 주입하는 경로 자체가 설계에 없음). 결국 카테고리 탭은 원래 방식(매번 전체 탭을 순회하며 다시 칠하는 방식)을 그대로 유지하기로 함. 다만 매직넘버 <code>Color(1.0, 0.85, 0.3, 1.0)</code>는 <code>normalTabImageRUID</code>/<code>selectedTabImageRUID</code>(string) 프로퍼티로 바꿔서, 색상 대신 스프라이트를 교체하는 방식으로 전환 — 디자이너가 인스펙터에서 이미지를 갈아끼울 수 있게 됨.</p>
<pre><code class="language-lua">method void UpdateCategoryTabHighlight()
if self._T.categoryTabEntities == nil then return end

for category, tabEntity in pairs(self._T.categoryTabEntities) do
    if tabEntity.SpriteGUIRendererComponent ~= nil then
        tabEntity.SpriteGUIRendererComponent.ImageRUID =
            (category == self.currentCategory) and self.selectedTabImageRUID or self.normalTabImageRUID
    end
end
end</code></pre>
<h3 id="3-entity로-받아놓고-컴포넌트-하나만-뽑아-쓰던-곳들">3. Entity로 받아놓고 컴포넌트 하나만 뽑아 쓰던 곳들</h3>
<p><code>UIMyInfo</code>의 <code>hpBar</code>/<code>mpBar</code>/<code>hpText</code>/<code>mpText</code> 등이 전부 <code>Entity</code> 타입 프로퍼티였는데, 실제로는 <code>.UITransformComponent</code>나 <code>.TextComponent</code>를 매번 다시 꺼내 쓰는 용도였음. <code>UIItemSlot</code>처럼 스폰되는 템플릿이면 런타임에 자식을 찾아야 하니 어쩔 수 없지만, <code>UIMyInfo</code>는 화면에 고정으로 하나만 배치되는 싱글턴 UI라 애초에 <code>Component</code> 타입 프로퍼티로 선언하고 에디터에서 직접 연결하면 됐음.</p>
<p>같은 패턴이 <code>ExtendPlayerComponent</code>/<code>ExtendMovementComponent</code>/<code>PlayerStats</code>의 <code>statUIEntity</code>에도 있었음 — 셋 다 <code>.UIMyInfo</code>만 꺼내 쓰고 있어서 <code>Component</code> 타입(<code>statUIInfo</code>)으로 통일. <code>Entity</code>로 받아야 할 이유(<code>SetVisible</code> 같은 엔티티 자체 기능 사용)가 있는지 없는지가 판단 기준이 됨.</p>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<ul>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/docs/?postId=205">프로퍼티 — MapleStory Worlds Creator Center</a>
<code>_T</code> 프로퍼티의 정확한 정의(선언 시점, 동기화 불가)를 확인한 문서. <code>_T</code>와 정식 프로퍼티 중 무엇을 쓸지 판단하는 근거가 됨.</p>
</li>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=47">TimerService — MapleStory Worlds Creator Center</a>
반복 타이머가 소유자 파괴 시 자동으로 정리 시도된다는 &quot;유의 사항&quot;을 확인한 문서. <code>UIBuffIcon</code> 버그 오판을 바로잡은 근거.</p>
</li>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=744">기본 UI 컴포넌트 — MapleStory Worlds Creator Center</a>
<code>ButtonComponent</code>가 <code>ButtonState</code>(Normal/Hover/Pressed/Released/Clicked)로만 동작하고 &quot;출력 기능이 없다&quot;는 걸 확인한 문서. 카테고리 탭에 <code>SelectedColor</code>를 못 쓰는 이유의 근거가 됨.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 14]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-14</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-14</guid>
            <pubDate>Wed, 02 Sep 2026 16:07:01 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---아이템-버리기-기능-구현">📝 개발일지 - 아이템 버리기 기능 구현</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>기존에는 임시로 삭제 버튼을 만들어서 사용했었는데, 인벤토리 바깥으로 아이템을 드롭하면 버려지는 기능으로 개선함. 이미 만들어져 있던 <code>UIPopup</code>(범용 확인창)을 재사용할 수 있는지부터 검토하고, 드롭 판정 → 수량 입력 팝업 → 서버 삭제 → 토스트 메시지까지 한 흐름으로 엮었음. 중간에 상속 vs 합성을 두고 한 번 갈아엎었고, 클릭 이벤트 시스템마다 마우스 ID 규칙이 다르다는 것도 실측으로 잡아냄.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-uipopup-재사용-검토--상속-시도했다가-합성으로-되돌림">1. UIPopup 재사용 검토 — 상속 시도했다가 합성으로 되돌림</h3>
<p>기존 <code>UIPopup</code>은 &quot;메시지 + 확인/취소&quot; 전용이라 수량 입력 필드가 없었음. <code>UIPopup</code> 자체를 고치는 건 SRP 위반(범용 확인창이 &quot;수량 파싱&quot; 책임까지 지게 됨)이라, 수량 입력을 담당할 <code>UIQuantityPopup</code>을 별도로 만들기로 함.</p>
<p>처음엔 <strong>상속</strong>(<code>UIQuantityPopup extends UIPopup</code>)으로 시도했음 — 커스텀 Logic이 다른 커스텀 Logic을 상속할 수 있다는 걸 실측으로 확인했고, <code>self:Open(...)</code>도 에러 없이 동작함. 그런데 다시 보니 상속받은 프로퍼티(<code>popupGroup</code>/<code>popup</code>/<code>message</code>/<code>btnOk</code>/<code>btnCancel</code>)가 인스펙터에 노출이 안 돼서 에디터 드래그 연결이 불가능했고, 결국 전용 패널을 하나 더 복사해서 <code>GetChildByName</code>으로 코드에서 직접 배선해야 하는 구조가 됨. <code>isOpen</code>도 인스턴스별로 따로 관리돼서, 원본 패널과 실수로 엔티티를 공유하면 버튼 핸들러가 이중 연결되는 위험까지 있었음.</p>
<p>득보다 실이 커서 <strong>합성</strong>으로 되돌림 — <code>UIQuantityPopup</code>은 <code>Logic</code>으로 그대로 두고, 내부에서 <code>_UIPopup:Open(...)</code>을 전역 호출만 하는 방식. 패널은 하나만 유지되고, 에디터 연결도 그대로 쓸 수 있음. <code>UIPopup</code>·<code>UIQuantityPopup</code> 둘 다 <code>_ItemTooltip</code>, <code>_UIToast</code>처럼 전역 싱글턴 Logic이라 엔티티 참조 없이 <code>_UIQuantityPopup:OpenForDrop(...)</code>로 어디서든 바로 호출 가능.</p>
<pre><code class="language-lua">-- amountInput은 기존 팝업 패널 밑에 추가한 TextInputComponent, 평소엔 Enable=false로 숨겨둠
self.amountInput.Entity.Enable = true
local onOk = function()
    local amount = self:GetValidatedAmount(maxAmount)
    if amount == nil then
        _UIToast:ShowMessage(errorMessage, _UserService.LocalPlayer.PlayerComponent.UserId)
        return false -- 팝업 유지, 재입력 가능
    end
    self.amountInput.Entity.Enable = false
    onConfirm(amount)
end
_UIPopup:Open(message, onOk, onCancel)</code></pre>
<h3 id="2-드롭-감지--좌표-계산-없이-screentouchevent--ispointeroverui">2. 드롭 감지 — 좌표 계산 없이 ScreenTouchEvent + IsPointerOverUI</h3>
<p>인벤토리가 클릭-토글 방식(실제 드래그 아님)이라, &quot;바깥으로 드롭&quot;은 결국 &quot;아이템을 쥔 상태에서 UI가 아닌 곳을 클릭&quot;으로 판정하면 됨. 새 캐처 엔티티를 배치하는 대신 <code>InputService.ScreenTouchEvent</code>(화면 전체에서 발생, UI 유무 무관) + <code>IsPointerOverUI()</code>(UI 위인지 여부) 조합으로 좌표 계산 없이 처리. 아이템을 쥐고 있는 동안만 구독하고, 놓으면 바로 해제해서 상시 리스너를 피함(<code>OnGhostMouseMove</code>와 동일한 패턴).</p>
<p><strong>실측으로 잡은 것</strong>: <code>UITouchDownEvent</code>(슬롯 클릭)는 마우스를 <code>-1</code>로 주는데, <code>ScreenTouchEvent</code>는 마우스를 <code>1</code>로 줌. 처음엔 슬롯 클릭 코드에 있던 <code>-1</code> 규칙을 그대로 가져다 썼다가 드롭이 전혀 안 먹혀서, 디버그 로그로 실제 <code>TouchId</code> 값을 찍어보고 발견함. 공식 문서에도 &quot;마우스 커서의 pointerId는 1&quot;이라고 명시돼 있어서 근거도 확인됨 — 이벤트 시스템마다 규칙이 다를 수 있으니 재사용할 때 값을 그대로 가정하면 안 된다는 교훈.</p>
<pre><code class="language-lua">method void OnScreenTouchWhileHolding(ScreenTouchEvent event)
    local TouchId = event.TouchId
    if TouchId ~= 1 then return end -- 실측: ScreenTouchEvent는 마우스를 1로 준다
    if _InputService:IsPointerOverUI() then return end -- UI 위 클릭은 슬롯/버튼 클릭이 따로 처리
    self:HandleDropOutside()
end</code></pre>
<h3 id="3-수량-입력-검증--조용히-클램프하지-않고-명시적으로-거부">3. 수량 입력 검증 — 조용히 클램프하지 않고 명시적으로 거부</h3>
<p>처음엔 무효 입력(빈 값/0/최대 초과)을 그냥 기본값으로 조용히 대체했는데, 테스트해보니 &quot;0을 입력하면 1개가 버려지고 아무것도 입력 안 하면 전부 버려지는&quot; 게 오히려 헷갈렸음. 그래서 무효 입력은 <code>nil</code>을 반환해서 &quot;이 값으론 진행 못 함&quot;을 명확히 신호하도록 바꾸고, 원인별로 다른 토스트 메시지를 띄우게 함.</p>
<p>문제는 <code>UIPopup.OnClickOk</code>가 <code>onOk()</code>의 반환값과 상관없이 무조건 팝업을 닫아버려서, 검증 실패해도 팝업이 닫혀버렸음. <code>onOk</code>가 <code>false</code>를 반환하면 닫지 않고 유지하도록 계약을 확장 — 기존 사용처는 다 <code>onOk</code>에서 아무것도 반환 안 하므로(<code>nil ~= false</code>) 하위 호환은 그대로 유지됨.</p>
<pre><code class="language-lua">local result = self.onOk()
if result == false then
    return -- onOk가 명시적으로 false를 반환하면 팝업을 닫지 않고 유지(재입력 가능)
end
self.onOk = nil
self:Close()</code></pre>
<p><code>OnClickCancel</code>도 취소로 닫힐 때 <code>self.onOk</code>가 정리 안 되고 남아있던 걸 발견해서 같이 정리함(방어적).</p>
<h3 id="4-enter-키로-제출--textinputsubmitevent">4. Enter 키로 제출 — TextInputSubmitEvent</h3>
<p>공식 문서에서 <code>TextInputSubmitEvent</code>가 &quot;Enter 키를 눌렀을 때 호출되는 이벤트&quot;로 <code>TextInputComponent</code>에 정의돼 있는 걸 확인. <code>amountInput</code>이 평소엔 <code>Enable=false</code>라 입력 자체를 못 받으니, <code>OnBeginPlay</code>에서 한 번만 연결해두면 됨(열고 닫을 때마다 connect/disconnect 안 해도 됨).</p>
<pre><code class="language-lua">self.amountInput.Entity:ConnectEvent(TextInputSubmitEvent, function() _UIPopup:OnClickOk() end)</code></pre>
<p>디버그 패널의 아이템 추가 입력창에도 같은 방식으로 Enter → 추가 버튼 클릭을 연결. <code>OnClickAddButton</code>이 이미 <code>amountInput.Text</code>를 직접 읽는 구조라 버튼 클릭이든 Enter든 같은 메서드 호출만으로 충분했음.</p>
<h3 id="5-서버-deleteitem--토스트--반환값-검증">5. 서버 DeleteItem — 토스트 + 반환값 검증</h3>
<p>기존에 있던 <code>AddItem</code>의 토스트 패턴(<code>_UIToast:ShowMessage(message, userId)</code>)을 <code>DeleteItem</code>에도 그대로 적용. 다만 <code>RemoveItem</code> 호출 후엔 슬롯이 비어(<code>itemId = 0</code>) 아이템 이름을 못 찾으니, 삭제 <strong>전</strong>에 <code>itemId</code>를 스냅샷해둠.</p>
<p>한 번 더 코드를 훑다가 잡은 버그: <code>container:RemoveItem(slotIdx, amount)</code>의 반환값을 안 받고 있어서, 서버 쪽에서 삭제가 실패해도(통신 중 재고가 바뀌었거나 클라이언트 검증을 우회한 경우) 무조건 &quot;N개를 버렸습니다&quot; 토스트가 뜨는 상태였음. 클라이언트 검증만 믿지 않고 서버가 자기 결과를 스스로 확인하도록 고침.</p>
<pre><code class="language-lua">local removed = container:RemoveItem(slotIdx, amount)
self:BroadcastChangedSlots(category, prevSnapshot)
if not removed then return end -- 실패 시 토스트도 안 띄움</code></pre>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<ul>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/apiReference/Services/InputService">InputService — MapleStory Worlds Creator Center</a>
<code>ScreenTouchEvent</code>, <code>IsPointerOverUI(pointerId)</code>의 정확한 동작과 마우스 <code>pointerId</code>가 <code>1</code>이라는 것을 확인한 문서. 드롭 판정 로직의 근거가 됨.</p>
</li>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/en/apiReference/Events/TextInputSubmitEvent">TextInputSubmitEvent — MapleStory Worlds Creator Center</a>
Enter 키 입력을 감지하는 정확한 이벤트명과 발생 시점을 확인. <code>TextInputEndEditEvent</code>(포커스 아웃 포함)와 헷갈리지 않도록 구분하는 근거가 됨.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 13]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-13</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-13</guid>
            <pubDate>Wed, 02 Sep 2026 08:09:50 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---카테고리-필터-구현--드롭-기능-트러블슈팅">📝 개발일지 - 카테고리 필터 구현 + 드롭 기능 트러블슈팅</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p><strong>카테고리 필터</strong>를 카테고리별로 <code>InventoryContainer</code>를 별도 소유하는 방식으로 구현했음. 서버 구조 자체는 이미 검증된 패턴(스냅샷 diff, 이벤트 기반 브로드캐스트)을 카테고리 단위로 나누기만 하면 돼서 설계는 금방 끝났고, 드래그 고스트 잔상 두 종류, 숨겨진 슬롯의 유령 터치 이벤트 문제까지 잡아서 스왑(드래그앤드롭) 기능을 카테고리 구조 위에서 다시 안정화했음. 여기에 아이템 추가 팝업, 카테고리 탭 하이라이트까지 마무리.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-카테고리-완전-분리-방식--서버-구조">1. 카테고리 &quot;완전 분리 방식&quot; — 서버 구조</h3>
<p><code>PlayerInventory</code>가 카테고리별 <code>InventoryContainer</code>를 각각 소유하도록 구조 변경. <code>InventoryContainer</code>(StructType) 자체는 카테고리 개념을 몰라도 되게 애초에 설계해뒀어서(DIP, <code>maxStack</code> 외부 주입) 수정 없이 그대로 재사용됨.</p>
<p><strong>프로퍼티 설계는 한 번 갈아엎음</strong>: 처음엔 <code>equipContainer</code>/<code>consumeContainer</code>/... 카테고리당 프로퍼티를 각각 5개씩(컨테이너용, 용량용) 뒀는데, 이러면 카테고리 문자열 → 프로퍼티 라우팅용 map을 <code>GetContainer</code>/<code>GetCapacity</code>가 호출될 때마다 새로 만들어야 했음. 다시 보니 굳이 그럴 이유가 없어서 <code>containers</code>/<code>capacities</code> table 프로퍼티 하나씩으로 정리 — 인스펙터에서 카테고리별로 개별 확인은 안 되지만, 라우팅 메서드가 한 줄로 줄고 카테고리가 늘어도 코드 수정이 필요 없어짐.</p>
<pre><code class="language-lua">-- GetContainer/GetCapacity 최종 형태
method InventoryContainer GetContainer(string category)
local container = self.containers[category]
if container == nil then
    log_error(&quot;[PlayerInventory] GetContainer: 알 수 없는 카테고리 &quot; .. tostring(category))
end
return container
end</code></pre>
<p><strong>이벤트 재설계</strong>: <code>OnInventoryModified</code>엔 <code>category</code> 필드 추가 — 유저 액션 하나는 항상 카테고리 하나만 건드리므로 &quot;카테고리당 이벤트 하나&quot;는 자연스러운 결과. <code>OnInventoryInitialized</code>(로그인 시 전체 스냅샷)는 처음엔 이것도 카테고리별로 5번 나눠 보내려다가, 5개 컨테이너가 애초에 동시에(하나의 저장 데이터에서) 준비되는데 굳이 나눠 보낼 이유가 없다는 걸 깨닫고 하나의 이벤트로 합침. 다만 중첩 테이블(<code>{category: {slotIdx: slot}}</code>)로 합치려던 1차안은 MSW 공식 튜토리얼 예제가 전부 1차원 데이터만 다루는 걸 확인하고 폐기 — 대신 카테고리별 프로퍼티 5개(<code>equipSlots</code>~<code>cashSlots</code>)를 한 이벤트에 나란히 실어서, &quot;이벤트 하나 = 전체 원자적 전달&quot;이라는 목표는 유지하면서 검증 안 된 구조는 피함.</p>
<p><strong>저장 포맷</strong>도 <code>{slots:[...]}</code> → <code>{categories:{[&quot;소비&quot;]=[...], ...}}</code>로 변경. 모르는 카테고리 키는 조용히 스킵(구버전 세이브 방어).</p>
<p>⚠️ <strong>코드 다시 훑다가 잡은 버그 2개</strong>: <code>RestoreFromSaveData</code>의 파싱 실패 체크가 옛 필드(<code>data.slots</code>) 그대로 남아있어서 저장 데이터 복원이 항상 실패하던 것, <code>capacities</code> 기본값이 빈 테이블이라 모든 카테고리 용량이 0이 되던 것. 둘 다 실행 전에 코드만 봐도 걸리는 버그였는데, 리팩터링 도중엔 놓치기 쉬웠음.</p>
<h3 id="2-카테고리-문자열-전역화--inventoryconstants">2. 카테고리 문자열 전역화 — <code>InventoryConstants</code></h3>
<p><code>&quot;장비&quot;</code>/<code>&quot;소비&quot;</code>/<code>&quot;기타&quot;</code>/<code>&quot;설치&quot;</code>/<code>&quot;캐시&quot;</code> 5개 문자열을 스크립트마다 로컬 상수로 반복 선언하다가(mLua는 최상단 <code>local</code>을 메서드 간에도 공유 안 함), 서버(<code>PlayerInventory</code>)뿐 아니라 클라이언트(<code>UIInventory</code>)도 같은 문자열을 참조해야 하는 시점이 오면서 <code>Logic</code>으로 전역화함(<code>_ItemData</code>와 동일 패턴).</p>
<p>이름은 프로퍼티(<code>property string EQUIP = &quot;장비&quot;</code> 등)로 선언하되, <strong>일부러 <code>UPPER_SNAKE_CASE</code>로 지음</strong> — 컨벤션표 기준으로는 프로퍼티가 원래 camelCase지만, 이 값들은 디자이너가 튜닝할 값이 아니라 &quot;코드가 참조하는 게임 규칙 정의&quot;라 상수 표기가 더 정확한 신호를 줌. 인스펙터에도 노출 안 함(표시 안 함).</p>
<h3 id="3-드래그-고스트-잔상--두-종류를-따로-잡음">3. 드래그 고스트 잔상 — 두 종류를 따로 잡음</h3>
<p><strong>① 위치 잔상</strong>: 아이템을 집을 때 고스트가 &quot;이전 위치&quot;에서 한 틱 보였다가 정상 위치로 튀는 현상. <code>Show()</code>가 <code>Visible=true</code>를 반영하는 시점이 <code>SetScreenPos()</code>의 위치 반영(레이아웃 갱신)보다 먼저 화면에 나타나는 게 원인으로 보였음. 처음엔 <code>_TimerService:SetTimer(self, fn, 0, false, 0)</code>로 한 틱 늦춰서 우회했다가, 최종적으로는 <code>Show()</code> 내부에서 커서 위치 계산 → <code>SetScreenPos</code> → 이미지 세팅 → <code>Visible=true</code> 순서를 전부 통합해서 호출부(<code>UIInventory</code>)가 순서를 신경 쓸 필요 없게 정리함.</p>
<p><strong>② 아이콘 잔상</strong>: 다른 아이템을 연달아 집을 때 이전 아이템 아이콘이 아주 짧게 겹쳐 보이는 현상. <code>Enable</code>만 껐다 켜는 방식으론 <code>ImageRUID</code>가 그대로 남아있어서, 리소스 교체가 반영되는 타이밍에 이전 이미지가 살짝 노출되는 게 원인. <code>Hide()</code>/<code>SetEmpty()</code> 양쪽 다 <code>Enable=false</code> 하기 <strong>전에</strong> <code>ImageRUID=&quot;&quot;</code>로 리소스 자체를 비우는 패턴으로 통일(<code>UIDragGhost</code>, <code>UIItemSlot</code> 둘 다 적용).</p>
<blockquote>
<p><strong>패턴 정리</strong>: 리소스 ID(<code>ImageRUID</code> 등)를 교체할 땐 <code>Enable</code>만 토글하지 말고, 끌 때는 리소스 자체를 비우고, 켤 때는 리소스를 먼저 확정한 뒤 <code>Enable=true</code>를 켜는 순서로 통일.</p>
</blockquote>
<p><strong>③ (별개 방어 코드) 숨겨진 슬롯의 잔여 데이터</strong>: 위 잔상 버그의 원인을 찾던 중 &quot;숨긴 슬롯이 터치 이벤트를 계속 받아서 그런가&quot;라는 가설도 세웠었는데(<code>SetVisible(false)</code>는 렌더링만 끄지 <code>UITouchReceiveComponent</code>의 터치 수신까지는 안 끔), 디버그 로그(<code>TouchEnter slotIdx=12 itemId=0</code>)로 확인해보니 실제 원인은 아니어서 기각함. 다만 &quot;숨겨진 엔티티도 터치는 계속 받는다&quot;는 사실 자체는 실제라 별도로 방어해둘 가치가 있어서, <code>RenderCurrentCategory</code>에서 숨기는 슬롯도 <code>RefreshSlot(slotIdx, 0, 0)</code>으로 캐시를 같이 비우는 코드는 남겨둠(예: 숨겨진 슬롯에서 툴팁이 열리는 것 방지).</p>
<h3 id="4-아이템-추가-팝업--카테고리-탭-하이라이트">4. 아이템 추가 팝업 + 카테고리 탭 하이라이트</h3>
<p><strong>팝업</strong>: 새로 만들지 않고 이미 있던 <code>UIToast</code>(<code>_UIToast:ShowMessage(message, userId)</code>)를 재사용. <code>container:AddItem</code>이 반환하는 <code>InventoryUpdateResult(success, amount)</code>를 활용해 완전 성공/부분 성공(공간 부족)/완전 실패 세 갈래 메시지 분기.</p>
<p><strong>탭 하이라이트</strong>: 이전에 썼던 <code>UIItemSlot.SetHighlight</code>와 같은 패턴(<code>SpriteGUIRendererComponent.Color</code> 틴트)을 탭 버튼에도 적용. 탭 엔티티를 <code>OnBeginPlay</code>에서 연결할 때 <code>self._T.categoryTabEntities</code>에 카테고리별로 캐싱해두고, 탭 전환/최초 접속 시 <code>UpdateCategoryTabHighlight()</code>로 일괄 갱신.</p>
<h3 id="5-그-외-코드-정리한-것들">5. 그 외 코드 정리한 것들</h3>
<ul>
<li>죽은 코드 제거: &quot;슬롯 선택 + 삭제 버튼&quot; 방식은 드래그/드롭 완성으로 대체됐는데 정리가 안 돼있었음(<code>selectedSlotIdx</code>/<code>SelectSlot</code>이 호출되는 곳 자체가 없었음) — 삭제는 나중에 &quot;인벤토리 밖으로 드롭하면 버려짐&quot; 방식으로 재구현 예정이라 일단 통째로 제거.</li>
<li>디버깅 과정에서 남긴 주석 처리된 실험 코드(<code>SetTimer</code> 우회, 초기 <code>SetScreenPos</code> 순서 실험) 정리.</li>
<li><code>UIDragGhost.Hide</code>/<code>SetScreenPos</code>에 <code>@ExecSpace(&quot;ClientOnly&quot;)</code> 명시 누락된 것 — 다른 메서드와 일관성 맞춤.</li>
<li><code>Color(167/255, 167/255, 167/255, 1.0)</code> 형태로 헥스 색상(<code>#A7A7A7</code>)을 표현 — <code>Color</code>는 0<del>255가 아니라 0</del>1 float를 받음.</li>
</ul>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<ul>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/en/apiReference/Services/InputService">InputService — MapleStory Worlds Creator Center</a>
<code>ConnectEvent</code>/<code>DisconnectEvent</code>의 정확한 반환 타입(<code>EventHandlerBase</code>)을 확인한 문서. <code>MouseMoveEvent</code> 리스너 해제 코드를 정상 동작하게 고치는 근거가 됨.</p>
</li>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/docs/?postId=942">인벤토리 스크립트 작성하기 — MapleStory Worlds Creator Center</a>
<code>OnInventoryInitialized</code> 이벤트로 인벤토리 데이터를 통째로 전달하는 공식 예제. <code>SyncTable&lt;V&gt;</code>가 1차원 인덱스 기반 컬렉션이라는 걸 확인하고, 중첩 테이블 대신 카테고리별 명시적 프로퍼티로 이벤트를 설계하는 근거가 됨.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 12]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-12</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-12</guid>
            <pubDate>Mon, 31 Aug 2026 18:09:55 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---스왑드래그앤드롭-기능-구현">📝 개발일지 - 스왑(드래그앤드롭) 기능 구현</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>인벤토리에 <strong>스왑(드래그앤드롭) 기능</strong>을 붙였음. 서버 로직 자체는 기존 <code>MergeSlot</code>/<code>MergeItems</code> 패턴을 거의 그대로 복사해서 금방 끝났는데, 진짜 시간을 잡아먹은 건 <strong>&quot;클릭 한 번으로 아이템을 들고, 다시 클릭하면 놓는&quot; 메이플 특유의 인터랙션</strong>을 재현하는 과정이었음.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-서버-로직--기존-패턴-재사용이라-리스크-낮음">1. 서버 로직 — 기존 패턴 재사용이라 리스크 낮음</h3>
<p><code>InventoryContainer</code>에 <code>SwapSlot(fromIdx, toIdx)</code>, <code>PlayerInventory</code>에 <code>SwapItems</code>(ServerOnly)/<code>RequestSwapItems</code>(Server) 세 개만 추가하면 끝. <code>MergeSlot</code>/<code>MergeItems</code>/<code>RequestMergeItems</code>와 시그니처·검증 흐름이 거의 대칭이라 새로 설계할 게 거의 없었음.</p>
<p>같은 아이템끼리 스왑하는 경우는 <code>SwapItems</code>에서 걸러내고 <code>RequestMergeItems</code>로 넘기도록 유도함 — <code>MergeSlot</code>의 <code>min(fromCnt, maxStack-toCnt)</code> 공식이 &quot;from이 이미 최대치라 사실상 자리만 바뀌는 경우&quot;까지 자동으로 커버해준다는 걸 검증했기 때문에, 별도 예외 분기 없이 하나로 충분했음.</p>
<pre><code class="language-lua">-- PlayerInventory.SwapItems (ServerOnly)
local EMPTY_ITEM_ID = 0
local fromSlot = self.invenContainer.slots[fromIdx]
if fromSlot == nil then return end
local toSlot = self.invenContainer.slots[toIdx]
if toSlot == nil then return end

if fromSlot.itemId ~= EMPTY_ITEM_ID and fromSlot.itemId == toSlot.itemId then
    return -- 같은 아이템은 MergeItems 영역
end

local prevSnapshot = self:SnapshotSlots()
self.invenContainer:SwapSlot(fromIdx, toIdx)
self:BroadcastChangedSlots(prevSnapshot)</code></pre>
<h3 id="2-클릭-토글--더블클릭-충돌-⚠️">2. 클릭 토글 + 더블클릭 충돌 ⚠️</h3>
<p><code>holdingSlotIdx</code> 상태 하나로 집기/놓기를 토글하도록 재설계함. 문제는 기존에 있던 &quot;더블클릭으로 아이템 즉시 사용&quot; 기능과 어떻게 공존시키느냐였음.</p>
<p><strong>1차 시도</strong>: 더블클릭 판정을 <code>UIItemSlot</code>이 자기 타이머로 독자 판단 → 첫 클릭이 이미 &quot;집기&quot;를 실행해버리므로, 두 번째 클릭에서 그 집기를 취소(<code>CancelHold</code>)하고 사용 요청을 보내는 방식으로 맞춤.</p>
<p>여기까지는 동작했는데, 아래와 같은 시나리오에서 실제 기대와 다른 결과가 나온다는 걸 검증 과정에서 발견함:</p>
<pre><code>A 클릭 → 집기 (holdingSlotIdx=A)
B 클릭 (A 쥔 상태) → 놓기(스왑 A↔B)
C 클릭 → 집기 (holdingSlotIdx=C)
B를 빠르게 다시 클릭 (C 쥔 상태) → B의 클릭 타이머가 아직 300ms 안이라 &quot;더블클릭&quot;으로 오판됨
→ 지금 실제로 쥐고 있는 C가 엉뚱하게 취소돼버림</code></pre><p><strong>원인</strong>: &quot;이 클릭이 더블클릭인가&quot;를 슬롯 자신의 타이밍만으로 판단하면, <strong>현재 홀드 상태를 전혀 모른 채</strong> 판정하게 됨. 더블클릭이라는 개념 자체가 사실 &quot;쥔 게 없거나, 쥔 게 지금 클릭한 슬롯 자신일 때&quot;만 의미가 있는 건데, 그 조건을 확인할 수 있는 쪽은 슬롯이 아니라 홀드 상태를 갖고 있는 <code>UIInventory</code>뿐이었음.</p>
<p><strong>최종 규칙</strong></p>
<pre><code>더블클릭 판정은 (isDoubleClick) AND (holdingSlotIdx == nil OR holdingSlotIdx == slotIdx) 일 때만 유효
다른 슬롯을 쥔 채로의 클릭은 무조건 &quot;놓기&quot;로 처리, 그 슬롯의 타이머는 건드리지 않음</code></pre><p>더블클릭 타이머 자체도 <code>UIItemSlot</code>에서 <code>UIInventory</code>(<code>lastClickTimeBySlot</code>)로 옮김. &quot;놓기&quot; 액션은 타이머를 절대 갱신하지 않는다는 대칭 규칙까지 맞추고 나서야 위 시나리오가 &quot;손에 뭔가 쥔 상태로 자연스럽게 끝나는&quot; 정상 동작으로 바뀜.</p>
<h3 id="3-이게-아키텍처적으로-맞는-선택인지-재검토">3. 이게 아키텍처적으로 맞는 선택인지 재검토</h3>
<p>더블클릭 판정 로직을 위젯(<code>UIItemSlot</code>)이 아니라 컨트롤러(<code>UIInventory</code>)에 두는 게 이상하게 느껴질 수 있어서 다시 짚어봄. 결론은 — 이 타이밍 데이터 자체가 이미 &quot;홀드 상태&quot;라는 도메인 지식에 종속돼 있어서, 순수 위젯이 혼자 판단할 수 없는 문제였음. 프로젝트에 이미 있던 <code>selectedSlotIdx</code> 패턴(&quot;슬롯 하나짜리가 아니라 인벤토리 전체 관점의 상태는 <code>UIInventory</code>가 소유한다&quot;)을 더블클릭 판정까지 일관되게 확장한 셈이라, SRP 관점에서도 맞는 방향이라고 판단함.</p>
<h3 id="4-드래그-고스트-ui-구현">4. 드래그 고스트 UI 구현</h3>
<p>커서를 따라다니는 아이템 아이콘을 붙이는 과정에서 두 번 연속으로 잘못 짚음.</p>
<p>처음엔 <code>MouseMoveEvent</code>도 다른 이벤트들처럼 <code>EventName</code>을 정적으로 선언하는 핸들러로 구현했음. 그런데 상시 리스너로 계속 켜놓는 것보다, 툴팁이 이미 쓰고 있던 <strong>&quot;쥐고 있는 동안에만 구독하고, 놓으면 해제하는&quot; 방식(<code>_InputService:ConnectEvent</code>/<code>DisconnectEvent</code>)</strong>이 불필요한 리스너를 안 만든다는 점에서 더 낫다고 판단해서 그 패턴으로 갈아탐.</p>
<p><strong>1차: MouseDelta 누적 방식</strong></p>
<pre><code class="language-lua">self._T.ghostPosition += Vector2(MouseDelta.x, MouseDelta.y)
self.dragGhostEntity.UIDragGhost:SetScreenPos(self._T.ghostPosition)</code></pre>
<p>호출은 되는데 커서 옆에 뜨지도 않고 이동폭도 너무 작았음. <code>MouseDelta</code>의 단위/좌표계와 <code>UITransformComponent.Position</code>이 기대하는 좌표계가 서로 다른 게 원인이었음 — 이번 세션 전에 이미 툴팁 기능을 만들면서 &quot;Screen 좌표와 UI 좌표(<code>anchoredPosition</code>)는 원점이 다르다&quot;는 걸 겪고 해결한 적이 있었는데, 그 교훈을 처음에 반영 안 하고 새로 짜다가 똑같은 함정에 다시 빠짐.</p>
<p><strong>2차: 툴팁 코드 그대로 포팅 — 해결</strong>
이미 검증된 <code>UpdateTooltipPosition</code> 코드를 그대로 가져와서, 델타 누적을 완전히 버리고 <code>_InputService:GetCursorPosition()</code>으로 절대 좌표를 매번 직접 조회 + Anchor 비율 방식으로 교체함.</p>
<pre><code class="language-lua">local screenSize = Vector2(_UILogic.ScreenWidth, _UILogic.ScreenHeight)
local anchorRatio = Vector2(cursorPoint.x / screenSize.x, cursorPoint.y / screenSize.y)

transform.AnchorsMin = anchorRatio
transform.AnchorsMax = anchorRatio
transform.Pivot = Vector2(0.5, 0.5)
transform.anchoredPosition = Vector2.zero</code></pre>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<ul>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=744">기본 UI 컴포넌트 — MSW 공식 크리에이터 센터</a>
<code>UITransformComponent</code>가 Anchor Preset에 따라 요구하는 프로퍼티가 달라진다는 걸 확인한 문서. 좌표 대입 버그를 짚어볼 때 기준으로 참고함</p>
</li>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/docs/?postId=688">월드 좌표와 스크린 좌표 — MSW 공식 크리에이터 센터</a>
Screen 좌표계와 UI 좌표계의 원점이 다르다는 걸 설명하는 문서. 툴팁 때도, 이번 드래그 고스트 때도 같은 함정에 빠진 근본 원인이 여기 있었음</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 11]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-11</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-11</guid>
            <pubDate>Sun, 30 Aug 2026 15:30:04 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---아이템-툴팁-구현하기">📝 개발일지 - 아이템 툴팁 구현하기</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>슬롯에 마우스를 올리면 아이템 이름/설명이 커서 근처에 뜨는 툴팁을 만들었음. 단순히 &quot;보였다 안 보였다&quot;만 만드는 걸로 끝날 줄 알았는데, ①좌표계 변환, ②호버(hover) 감지 방식, ③UI 입력 히트 판정이라는 세 가지 별개의 문제를 순서대로 만났고, 세 번째는 겉으로 드러난 증상(아이콘 위에서만 안 뜸)만 보면 원인을 완전히 잘못 짚기 쉬운 문제였음.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황-파악">1. 문제 상황 파악</h3>
<p>슬롯에 커서를 올리면 그 아이템의 이름/설명이 담긴 패널이 커서 근처에 뜨고, 커서를 움직이면 따라오고, 벗어나면 사라져야 함. 추가로 &quot;동시에 여러 슬롯의 툴팁이 겹쳐 뜨는&quot; 상황은 막아야 했음</p>
<pre><code class="language-lua">-- 최초 접근 (기각)
-- UIItemSlot.Show(itemId, touchPoint)
self.Entity:SetEnable(true)
self.tooltipTransform.anchoredPosition = touchPoint + offset</code></pre>
<p>동작은 했지만 위치가 커서와 계속 어긋났음. 처음엔 <code>offset</code> 값을 조정하면 될 거라 생각했는데, 알고 보니 훨씬 근본적인 문제였음</p>
<h3 id="2-screen-좌표계와-ui-좌표계는-원점이-다르다">2. Screen 좌표계와 UI 좌표계는 원점이 다르다</h3>
<p><code>UITouchEnterEvent</code>가 넘겨주는 <code>TouchPoint</code>(Screen 좌표)를 <code>UITransformComponent.anchoredPosition</code>(UI 좌표)에 그대로 대입하고 있었음. 공식 문서를 다시 확인해보니 이 둘은 애초에 원점이 다른 별개의 좌표계였음</p>
<blockquote>
<p><strong><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=688">월드 좌표와 스크린 좌표 — MSW 공식 문서</a></strong>
UI 좌표계의 원점은 Safe Area 중앙, Screen 좌표계의 원점은 화면 좌측-하단</p>
</blockquote>
<p>즉 &quot;Screen 좌표 값을 UI 좌표에 그대로 꽂아 넣기&quot;는 두 좌표계가 우연히 같은 스케일을 쓰지 않는 이상 애초에 성립할 수 없는 방식이었음. 공식 문서가 권장하는 방식은 커서 위치를 화면 대비 0~1 비율로 변환해서 <code>AnchorsMin</code>/<code>AnchorsMax</code>에 &quot;점 앵커&quot;로 꽂고 <code>anchoredPosition</code>은 <code>Vector2.zero</code>로 두는 것이었음. 화면 가장자리에서 툴팁이 잘리지 않도록 <code>Pivot</code>도 커서 위치에 따라 반전시키는 로직을 추가함</p>
<pre><code class="language-lua">-- UIItemSlot.UpdateTooltipPosition (ExecSpace: ClientOnly)
if self._T.tooltipTransform == nil then return end

local screenSize = Vector2(_UILogic.ScreenWidth, _UILogic.ScreenHeight)
local cursorPoint = _InputService:GetCursorPosition()
local anchorRatio = Vector2(cursorPoint.x / screenSize.x, cursorPoint.y / screenSize.y)

local transform = self._T.tooltipTransform
transform.AnchorsMin = anchorRatio
transform.AnchorsMax = anchorRatio
transform.Pivot = self:GetEdgeAwarePivot(transform, self._T.tooltipCanvasSize)
transform.anchoredPosition = Vector2.zero</code></pre>
<h3 id="3-호버-감지를-어떤-이벤트로-할-것인가">3. 호버 감지를 어떤 이벤트로 할 것인가</h3>
<p><code>UITouchEnterEvent</code>/<code>UITouchExitEvent</code>로 호버를 감지했는데, 가만히 있으면 안 뜨고 움직여야만 뜨는 등 불안정했음. &quot;이 이벤트가 드래그류 이벤트와 묶여 있어서 순수 호버(마우스 눌지 않은 상태)엔 안 맞는 게 아닐까&quot; 의심하고 <code>ButtonComponent</code> + <code>ButtonStateChangeEvent</code>(<code>state == ButtonState.Hover</code>)로 바꿔봄</p>
<pre><code class="language-lua">-- 대안으로 시도 (최종적으로 기각)
if state == ButtonState.Hover then
    self:ShowTooltip()
end</code></pre>
<p>이 과정에서 이름이 비슷한 이벤트 세 개(<code>ButtonStateChangeEvent</code>, <code>ButtonStateChangeEditorEvent</code>, <code>EditorGUIButtonStateEvent</code>)를 혼동해서 API 문서로 구분함 — 실제 런타임에서 쓸 건 <code>ButtonStateChangeEvent</code> 하나뿐이고 나머지는 에디터 전용/무관한 API였음</p>
<p><code>ButtonComponent</code> 방식은 동작은 했지만 위치 정보를 안 주기 때문에 <code>Show()</code> 시점에 커서 위치를 한 번만 스냅샷하는 식이 되어 커서를 계속 따라다니지 못했고, 간헐적으로 호버가 씹히는 현상도 남아있었음. 결국 <code>UITouchReceiveComponent</code>를 버릴 이유가 없었다는 결론으로 원점 회귀함</p>
<p>(<strong>돌아보며 남기는 의문</strong>: 이 시점의 불안정 증상은 시점상 6번에서 찾은 <code>RaycastTarget</code> 문제와 겹침 — 즉 &quot;이벤트 자체가 순수 호버엔 안 맞는다&quot;는 진단이 처음부터 틀렸고, 툴팁 패널의 <code>RaycastTarget</code>만 그때 껐어도 <code>UITouchReceiveComponent</code>만으로 안정적으로 동작해서 <code>ButtonComponent</code>로 갈아탈 필요 자체가 없었을 가능성이 있음. 실제로 되돌아가 재검증하지는 않아서 확정할 수는 없음)</p>
<h3 id="4-재설계--logic-기반-뮤텍스--이벤트-기반-연속-추적">4. 재설계 — Logic 기반 뮤텍스 + 이벤트 기반 연속 추적</h3>
<p>동작이 검증된 참고 구현을 스터디하면서 세 가지를 다시 확인함</p>
<ul>
<li><code>Entity:SetVisible()</code>과 <code>Entity:SetEnable()</code>은 별개다 — 전자는 시각적 표시만, 후자는 로직 활성화까지 끈다. &quot;한 번 뜨고 다시는 안 뜨는&quot; 예전 버그의 원인이 여기 있었을 가능성이 높음</li>
<li>여러 슬롯이 동시에 툴팁을 열려는 상황을 막으려면 툴팁 자체를 전역 싱글턴(Logic)으로 두고 <code>isUsing</code> 플래그로 상호배제하는 게 깔끔함</li>
<li>커서를 계속 따라다니려면 매 프레임 폴링이 아니라 <code>_InputService:ConnectEvent(MouseMoveEvent, ...)</code>로 호버 중에만 구독하고 벗어나면 해제하면 됨</li>
</ul>
<pre><code class="language-lua">-- ItemTooltip (Logic, ExecSpace: ClientOnly)
method CanOpen() -&gt; boolean
    return not self.isUsing
end

method Open(itemId) -&gt; Entity
    if not self:CanOpen() then return nil end
    if not _ItemData.isInitialized then return nil end

    local itemInfo = _ItemData.itemTable[itemId]
    if itemInfo == nil then return nil end

    self.nameText.Text = itemInfo.name
    self.descText.Text = itemInfo.description
    self.tooltipEntity:SetVisible(true)
    self.isUsing = true
    return self.tooltipEntity
end

method Close()
    self.tooltipEntity:SetVisible(false)
    self.isUsing = false
end</code></pre>
<pre><code class="language-lua">-- UIItemSlot.HandleUITouchEnterEvent (Sender: UITouchReceiveComponent, Space: Client)
local EMPTY_ITEM_ID = 0
if self._T.itemId == nil or self._T.itemId == EMPTY_ITEM_ID then return end

local tooltipEntity = _ItemTooltip:Open(self._T.itemId)
if tooltipEntity == nil then return end  -- 다른 슬롯이 사용 중

self._T.tooltipTransform = tooltipEntity.UITransformComponent
self._T.tooltipCanvasSize = tooltipEntity.Parent.UITransformComponent.RectSize
self:UpdateTooltipPosition()

self._T.mouseMoveHandlerId = _InputService:ConnectEvent(MouseMoveEvent, function()
    self:UpdateTooltipPosition()
end)</code></pre>
<p>여기까지 오니 위치도 맞고, 씹힘도 없어졌음. 그런데 곧바로 새로운 증상이 나타남</p>
<h3 id="5-가장자리에서만-뜨고-아이템-이미지-위에서는-안-뜬다">5. 가장자리에서만 뜨고 아이템 이미지 위에서는 안 뜬다</h3>
<p>슬롯의 빈 여백(패딩)에 커서를 두면 툴팁이 뜨는데, 정작 아이템 아이콘(<code>ItemImg</code>) 위로 커서를 옮기면 뜨질 않았음. 처음엔 &quot;자식 엔티티(<code>ItemImg</code>, <code>ItemCnt</code>)가 부모 슬롯의 <code>UITouchReceiveComponent</code>로 가는 터치 입력을 통째로 가로막고 있다&quot;고 판단했음</p>
<p>이 가설을 검증하려던 중, 같은 슬롯에서 더블클릭으로 아이템을 사용하는 기능(<code>UITouchDownEvent</code>)은 아이콘 위에서도 멀쩡히 동작한다는 반례를 만남. 같은 엔티티, 같은 <code>UITouchReceiveComponent</code>인데 <code>Down</code>은 되고 <code>Enter</code>/<code>Exit</code>만 안 되는 상황이라 &quot;자식이 입력을 통째로 막는다&quot;는 가설로는 설명이 안 됐음. &quot;<code>Down</code>은 좌표가 영역 안인지만 보는 단순 판정이고, <code>Enter</code>/<code>Exit</code>는 커서 아래 가장 위에 그려진 요소가 누구인지 매 프레임 추적하는 방식이라 그 차이가 갈린다&quot;는 쪽으로 가설을 수정하고 있던 시점에, 실측으로 진짜 원인을 확인함</p>
<h3 id="6-진짜-원인--raycasttarget">6. 진짜 원인 — <code>RaycastTarget</code></h3>
<p><code>ItemImg</code>, <code>ItemCnt</code>(그리고 툴팁 패널 자신)의 <code>RaycastTarget</code>이 켜져 있었던 게 원인이었음. <code>SpriteGUIRendererComponent</code>/<code>TextComponent</code>가 공통으로 상속하는 <code>ImageComponent</code>에 있는 프로퍼티로, 공식 문서 설명은 다음과 같음</p>
<blockquote>
<p><strong><a href="https://maplestoryworlds-creators.nexon.com/ko/apiReference/Components/ImageComponent">ImageComponent — MSW 공식 API 레퍼런스</a></strong>
RaycastTarget(boolean): &quot;true로 설정할 경우 화면 터치 또는 마우스 클릭 대상이 되며, 뒤에 가려진 UI는 화면 터치와 마우스 클릭 입력을 받지 못합니다.&quot;</p>
</blockquote>
<p>즉 <code>ItemImg</code>가 기본값(<code>true</code>)인 채로 슬롯 위에 그려져 있었으니, 그 영역에서는 <code>ItemImg</code>가 입력을 먼저 가로채고 뒤에 있는 슬롯의 <code>UITouchReceiveComponent</code>까지 전달되지 않았던 것임. <code>ItemImg</code>/<code>ItemCnt</code>/툴팁 패널 세 곳의 <code>RaycastTarget</code>을 전부 끄니 아이콘 위에서도 정상적으로 Enter/Exit가 발생함</p>
<p>이 발견으로 5번의 가설(<code>Down</code>과 <code>Enter</code>/<code>Exit</code>의 판정 방식이 다르다)이 왜 절반만 맞았는지도 설명됨: <code>Down</code>은 원래 <code>RaycastTarget</code>을 켠 자식이 있어도 부모까지 도달하는 반면(혹은 우연히 이번 케이스에서 영향이 없었던 것으로 추정), <code>Enter</code>/<code>Exit</code>는 <code>RaycastTarget=true</code>인 자식에 그대로 가로막힌 것. 그리고 이 원인은 훨씬 이전에 겪었던 &quot;툴팁이 뜨자마자 바로 사라지는&quot; self-occlusion 버그와도 사실상 같은 뿌리였음 — 툴팁 패널 자신의 <code>RaycastTarget</code>이 켜져 있어서, 슬롯 위에 겹쳐 그려지는 순간 슬롯의 Exit를 유발했던 것</p>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<ul>
<li><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=688">월드 좌표와 스크린 좌표 — MSW 공식 크리에이터 센터</a></li>
<li><a href="https://maplestoryworlds-creators.nexon.com/ko/apiReference/Components/ImageComponent">ImageComponent — MSW 공식 API 레퍼런스</a></li>
<li><a href="https://maplestoryworlds-creators.nexon.com/ko/apiReference/Components/UITouchReceiveComponent">UITouchReceiveComponent — MSW 공식 API 레퍼런스</a></li>
<li><a href="https://maplestoryworlds-creators.nexon.com/en/apiReference/Events/ButtonStateChangeEvent">ButtonStateChangeEvent — MSW 공식 API 레퍼런스</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 10]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-10</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-10</guid>
            <pubDate>Mon, 24 Aug 2026 14:14:13 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---아이템-효과-적용하기">📝 개발일지 - 아이템 효과 적용하기</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>오늘은 크게 두 갈래로 진행함 — ① 아이템을 먹어도 아무 효과가 없던 걸 발견해서 실제 효과 적용 로직 구현, ② HP/MP/공격력/이동속도를 화면에 보여주는 스탯 UI 구현. 그 과정에서 &quot;Sync 프로퍼티인데 게임을 켜자마자는 초기값이 화면에 안 뜨는&quot; 버그를 만났고, 원인을 추적하다 MSW 생명주기 함수들의 보장 범위를 다시 제대로 이해하게 됨.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-발견---아이템-효과가-미구현-상태였음">1. 문제 발견 - 아이템 효과가 미구현 상태였음</h3>
<p><code>ItemEffectData</code>의 <code>Apply</code>/<code>Revert</code>가 전부 <code>function(playerEntity, value) end</code>짜리 빈 스텁이었음. 포션을 먹으면 수량은 줄어드는데 HP/MP/이동속도/공격력은 아무것도 안 바뀌는 상태. 추가로 <code>PlayerEffect.ApplyBuff</code>를 다시 보니, 지속시간 안에 같은 버프를 재사용하면 <code>Revert</code> 없이 <code>Apply</code>만 한 번 더 불려서 효과가 영구히 남는 실제 버그도 같이 발견함.</p>
<h3 id="2-설계---mp공격력은-어디에-저장해야-하나">2. 설계 - MP/공격력은 어디에 저장해야 하나</h3>
<p>HP(<code>PlayerComponent.Hp</code>)와 이동속도(<code>MovementComponent.InputSpeed</code>)는 엔진 네이티브 값이라 그대로 쓰면 되는데, MP와 공격력은 MSW에 대응하는 값 자체가 없었음. API 문서를 뒤져봐도 <code>PlayerComponent</code>에 Mp 프로퍼티가 없고, Components 목록에도 스탯 개념의 컴포넌트가 아예 없음 — MSW는 HP 외의 RPG 스탯을 제공하지 않는 범용 플랫폼이라는 걸 확인. 처음엔 기존 컴포넌트에 얹으려다, SRP 위반이라 판단해 <code>PlayerStats</code>라는 새 컴포넌트로 분리함.</p>
<h3 id="3-sync-vs-이벤트">3. Sync vs 이벤트</h3>
<p>MP/공격력 UI 갱신 방식을 두고 &quot;커스텀 이벤트&quot;로 설계했다가, &quot;그냥 Sync만 걸고 바뀐 시점만 알려주면 안 되나?&quot;라는 생각을 함. 처음엔 &quot;인벤토리도 이벤트 방식이니까&quot;로 반박했는데, 다시 확인해보니 인벤토리(<code>InventoryContainer.slots</code>)는 <strong>애초에 <code>table</code> 타입이라 Sync 자체가 불가능한 자료구조</strong>였던 거고, 스칼라 값(HP/MP 등)에는 해당 안 되는 얘기였음. 원칙을 잘못 일반화하고 있었던 것.</p>
<h3 id="4-onsyncproperty-발견">4. <code>OnSyncProperty</code> 발견</h3>
<p>Sync된 값이 바뀌었을 때 신호를 주는 방법을 고민하던 중, 넥슨 강의 스크립트에서 몬스터 체력바 예제를 확인함 — &quot;싱크 프로퍼티&quot;라는 콜백으로 프로퍼티 이름을 받아 분기하는 코드였음. 공식 문서에서 정확한 시그니처(<code>OnSyncProperty(string name, any value)</code>)를 확인하고, 커스텀 이벤트 계획을 폐기하고 이걸로 통일함.</p>
<h3 id="5-playercomponent를-extend할-수-있는가">5. <code>PlayerComponent</code>를 Extend할 수 있는가</h3>
<p>HP UI도 같은 패턴으로 만들려고 <code>PlayerComponent</code>를 Extend하려 했는데, 넥슨 공식 포럼에서 &quot;기본 플레이어 엔티티에 이미 있는 PlayerComponent가 삭제되지 않아 적용이 안 된다&quot;는 글을 발견. 넥슨 자체 기본 템플릿(<code>UIMyInfo</code>)도 <code>OnSyncProperty</code> 대신 <code>OnUpdate</code> 폴링을 쓰는 걸 보고 &quot;Extend가 막혀있어서 그런 것&quot;이라 결론 내리고 폴링 방식으로 설계를 바꿈. 그런데 실제로 에디터에서 시도해보니 <strong>Extend가 되는 걸 확인</strong>해서, 폴링 버전을 버리고 다시 <code>OnSyncProperty</code> + Extend 버전으로 되돌림.</p>
<h3 id="6-새-버그---초기값이-화면에-안-보임">6. 새 버그 - 초기값이 화면에 안 보임</h3>
<p><code>ExtendPlayerComponent</code>/<code>ExtendMovementComponent</code>/<code>PlayerStats</code>까지 다 붙이고 실행했는데, 게임을 막 켰을 때 HP/MP 바가 초기 상태(빈 값)로 안 그려짐. 원인은 <code>OnSyncProperty</code>가 &quot;값이 실제로 바뀌는 순간&quot;에만 호출되는 콜백이라, 최초 상태(기본값)는 애초에 &quot;변경&quot;이 아니라서 한 번도 안 불렸던 것.</p>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<ul>
<li><strong><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=163">MSW 기본 이벤트 함수 — MapleStory Worlds Creator Center</a></strong>
<code>OnSyncProperty(string name, any value)</code>의 정확한 시그니처와 호출 시점(값이 실제로 바뀔 때만)을 확인한 문서. <code>OnBeginPlay</code>가 &quot;존재는 보장하지만 다른 컴포넌트가 계산해 넣은 값은 보장 안 한다&quot;는 차이도 여기서 확인함</li>
<li><strong><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=290">엔티티의 생성과 삭제, 유효성 체크 — MapleStory Worlds Creator Center</a></strong>
<code>isvalid()</code>가 컴포넌트 메서드가 아니라 전역 함수이고, nil까지 안전하게 처리해준다는 걸 확인한 문서</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 09]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-09</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-09</guid>
            <pubDate>Tue, 28 Jul 2026 16:13:51 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---정렬-후-자투리-수량이-맨-앞에-오던-문제">📝 개발일지 - 정렬 후 자투리 수량이 맨 앞에 오던 문제</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>인벤토리 정렬(Sort) 버튼을 눌렀을 때 같은 아이템을 최대한 합치면서 재배치하도록 구현함. 기능 자체는 동작했지만, 실제 플레이 화면 스크린샷을 보니 같은 아이템 중 수량이 적은 자투리 슬롯이 맨 앞에 와 있는 걸 발견함</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황-파악">1. 문제 상황 파악</h3>
<p>파란 포션을 여러 번 사용/획득해 99, 99, 98개로 세 칸에 나뉜 상태에서 정렬 버튼을 눌렀더니, 정렬은 되는데 자투리(98개)가 있는 슬롯이 맨 앞 칸에 오고 꽉 찬 칸(99개)들이 그 뒤에 오는 걸 스크린샷으로 확인함. 기대한 건 꽉 찬 슬롯이 먼저 오고 자투리가 맨 뒤로 가는 것이었음</p>
<h3 id="2-원인이-된-코드">2. 원인이 된 코드</h3>
<p><code>InventoryContainer.SortSlots</code>가 병합 후 마지막에 한 번 더 정렬하는 비교 함수</p>
<pre><code class="language-lua">table.sort(self.slots, function(a, b)
    if a.itemCnt &lt;= 0 then return false end
    if b.itemCnt &lt;= 0 then return true end
    return a.itemId &lt; b.itemId
end)</code></pre>
<h3 id="3-원인-분석">3. 원인 분석</h3>
<p>이 비교 함수는 <code>itemId</code>가 같으면 &quot;동등하다&quot;고만 판단함. 그런데 Lua의 <code>table.sort</code>는 <strong>불안정 정렬(unstable sort)</strong> 이라, 비교 함수가 &quot;동등&quot;하다고 판단한 원소들끼리는 원래 순서가 보장되지 않음. 그래서 같은 파란 포션이라도 99/99/98 세 슬롯 사이의 상대 순서는 정렬 전 배열 상태에 따라 우연히 결정되고 있었던 것 — 지금까지 자투리가 뒤로 갔던 건 우연이었을 뿐, 코드가 그렇게 보장하고 있던 게 아니었음</p>
<h3 id="4-해결">4. 해결</h3>
<p>같은 아이템일 때 수량 내림차순이라는 2차 정렬 기준을 추가해, 비교 함수가 사실상 완전한 순서(total order)를 만들도록 수정함</p>
<pre><code class="language-lua">local function CompareSlot(a, b)
    if a.itemCnt &lt;= 0 then return false end
    if b.itemCnt &lt;= 0 then return true end
    if a.itemId ~= b.itemId then
        return a.itemId &lt; b.itemId
    end
    return a.itemCnt &gt; b.itemCnt -- 같은 아이템이면 꽉 찬 슬롯이 앞, 자투리 수량은 뒤로 밀림
end</code></pre>
<h2 id="📚-성과와-개선-효과">📚 성과와 개선 효과</h2>
<ul>
<li>✅ <strong>불안정 정렬의 함정 이해</strong>: &quot;같다고 판단되는 원소가 있다&quot;는 건 그 원소들 사이의 순서가 미정의라는 뜻이고, 결과를 예측 가능하게 하려면 비교 함수 자체가 사실상 모든 경우의 우선순위를 정해줘야 한다는 걸 체감함</li>
<li>✅ <strong>실제 사용자 화면(스크린샷) 기반 버그 발견</strong>: 로그나 콘솔이 아니라 실제 플레이 결과물을 눈으로 보고서야 잡아낸 버그라, 기능 구현 후 실제 화면으로 검증하는 과정의 중요성을 재확인함</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 08]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-08</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-08</guid>
            <pubDate>Tue, 28 Jul 2026 16:10:44 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---스폰된-버프-아이콘의-onbeginplay-타이밍-버그">📝 개발일지 - 스폰된 버프 아이콘의 OnBeginPlay 타이밍 버그</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>아이템 사용 시 화면에 버프 아이콘(아이콘 + 카운트다운 텍스트)을 띄우는 기능을 구현함. 아이콘 자체는 잘 스폰되고 보였는데, 카운트다운 숫자만 표시가 안 되고 에러가 발생함. 스폰(복제) 직후 코드 실행 순서에 숨어있던 문제였음</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황-파악">1. 문제 상황 파악</h3>
<p><code>UIBuffPanel.ShowBuff</code>에서 버프 아이콘 템플릿을 스폰하고, 곧바로 <code>UIBuffIcon:Init(iconRUID, durationSec)</code>을 호출해 아이콘 이미지와 카운트다운을 세팅하도록 구현함. 실행하니 아이콘은 보이는데 아래 에러가 발생함</p>
<pre><code>[CLIENT] [LEA-2007] AttemptToIndex : &#39;durationText&#39;을 인덱싱할 수 없습니다. &#39;durationText&#39;은 nil입니다.
UIBuffIcon.SetDuration (at MyDesk/UIBuffIcon:32)
UIBuffIcon.Init (at MyDesk/UIBuffIcon:21)
UIBuffPanel.ShowBuff (at MyDesk/UIBuffPanel:36)</code></pre><h3 id="2-원인이-된-코드">2. 원인이 된 코드</h3>
<pre><code class="language-lua">local iconEntity = _SpawnService:SpawnByEntity(self.buffIconTemplateEntity, &quot;BuffIcon&quot;, Vector3.zero, self.buffContentEntity)
iconEntity.UIBuffIcon:Init(itemData.iconRUID, durationSec)  -- ① 먼저 호출
iconEntity:SetEnable(true)                                  -- ② 그 다음 활성화</code></pre>
<p><code>durationText</code>는 <code>UIBuffIcon.OnBeginPlay</code>에서 <code>GetChildByName</code>으로 찾아 채워주는 값인데, 정작 <code>Init</code>(①)이 활성화(②)보다 먼저 호출되고 있었음</p>
<h3 id="3-원인-분석">3. 원인 분석</h3>
<p>이 프로젝트에서 이미 확인해뒀던 사실이 있었음 — &quot;템플릿은 평소 <code>Enable=false</code>라 스폰된 복제본도 비활성 상태로 시작하고, <code>OnBeginPlay</code>는 <code>SetEnable(true)</code>로 활성화된 뒤에야 실행된다.&quot; 즉 ①번 시점엔 <code>OnBeginPlay</code>가 아직 안 돌아서 <code>durationText</code>가 <code>nil</code>인 채로 남아있었고, 그 상태에서 <code>SetDuration</code>이 <code>durationText.Text = ...</code>를 시도하다 터진 것</p>
<h3 id="4-해결">4. 해결</h3>
<p>활성화와 데이터 주입 순서를 맞바꿈</p>
<pre><code class="language-lua">local iconEntity = _SpawnService:SpawnByEntity(self.buffIconTemplateEntity, &quot;BuffIcon&quot;, Vector3.zero, self.buffContentEntity)
iconEntity:SetEnable(true)
iconEntity.UIBuffIcon:Init(itemData.iconRUID, durationSec)</code></pre>
<h2 id="📚-성과와-개선-효과">📚 성과와 개선 효과</h2>
<ul>
<li>✅ <strong>스폰 직후 초기화 패턴 확립</strong>: &quot;템플릿이 비활성 상태로 스폰되고, 활성화 시점에 <code>OnBeginPlay</code>가 실행된다&quot;는 조건이 있는 프로젝트에서는, 스폰 → 활성화 → 데이터 주입 순서를 항상 지켜야 한다는 걸 명문화함</li>
<li>✅ <strong>에러 로그로 호출 스택을 역추적하는 습관</strong>: <code>SetDuration → Init → ShowBuff</code> 순으로 찍힌 스택을 거슬러 올라가며 &quot;어디서 최초로 nil이 만들어졌는가&quot;를 찾는 방식으로 원인을 좁힘</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 07]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-07</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-07</guid>
            <pubDate>Tue, 28 Jul 2026 16:09:33 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---더블클릭으로-아이템-사용이-안-되던-문제">📝 개발일지 - 더블클릭으로 아이템 사용이 안 되던 문제</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>인벤토리 슬롯을 더블클릭하면 아이템을 사용하도록 <code>UITouchDownEvent</code> 기반 더블클릭 판정 로직을 구현함. 코드 자체는 문법 오류를 다 고친 상태였는데도 클릭에 아무 반응이 없었고, 로그 한 줄조차 찍히지 않았음. 여러 층의 가설을 세우고 하나씩 좁혀가다, 이름이 비슷한 컴포넌트를 잘못 붙였다는 사소하지만 결정적인 실수를 마지막에 확인함.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황-파악">1. 문제 상황 파악</h3>
<p><code>UIItemSlot</code>에 <code>HandleUITouchDownEvent</code> 핸들러를 만들고, <code>TouchId == -1</code>일 때 직전 클릭 시각과 비교해 더블클릭이면 <code>RequestUseItem</code>을 호출하도록 구현함. 슬롯을 더블클릭해도 수량 변화도, 에러도, 아무 로그도 없었음</p>
<h3 id="2-1차-가설---실행-공간execspace-문제">2. 1차 가설 - 실행 공간(ExecSpace) 문제</h3>
<p>핸들러에 <code>@ExecSpace(&quot;ClientOnly&quot;)</code>를 명시적으로 붙였는데도 반응이 없어서, 혹시 이 태그 자체가 핸들러엔 안 맞는 문법이거나 조용히 무시되는 게 아닌지 의심함. 핸들러 맨 위에 무조건 찍히는 임시 로그를 추가해 확인하기로 함</p>
<pre><code class="language-lua">handler HandleUITouchDownEvent(UITouchDownEvent event)
log(&quot;[UIItemSlot] 터치 이벤트 도달! TouchId=&quot; .. tostring(event.TouchId))  -- 임시 디버그용
...</code></pre>
<p>이 로그조차 안 찍혀서, 핸들러 코드 문제가 아니라 <strong>이벤트 자체가 이 엔티티까지 안 오고 있다</strong>는 게 확인됨</p>
<h3 id="3-2차-가설---buttoncomponent가-입력을-가로채고-있다">3. 2차 가설 - ButtonComponent가 입력을 가로채고 있다</h3>
<p>슬롯 엔티티에 이미 <code>ButtonComponent</code>가 붙어있었던 걸 확인하고, 이게 터치 입력을 먼저 소비해서 <code>UITouchDownEvent</code> 자체가 발생 안 하는 것으로 의심함. 공식 문서에서 두 컴포넌트의 상호작용에 대한 명시적인 설명은 찾지 못해, <code>ButtonComponent</code>를 아예 떼고 <code>UITouchReceiveComponent</code>만 남기는 방향으로 진행하기로 함</p>
<h3 id="4-결정적-확인---예전에-검증됐던-원본-코드와-대조">4. 결정적 확인 - 예전에 검증됐던 원본 코드와 대조</h3>
<p>이전에 비슷한 더블클릭 로직을 만들어봤던 다른 프로젝트의 원본 스크립트를 다시 찾아보니, 핸들러의 실행 공간 설정이 지금과 다르게 <code>Scope: 0, ExecSpace: 0</code>(사용하지 않음)으로 되어 있었음. <code>UITouchDownEvent</code> 자체가 클라이언트에서만 발생하는 이벤트라 핸들러에 실행 공간을 강제로 태깅할 필요가 없었던 것</p>
<h3 id="5-근본-원인-확정">5. 근본 원인 확정</h3>
<p>여러 가설을 검토하던 중 실제로 확인해보니, <strong>슬롯 엔티티에 붙인 게 <code>UITouchReceiveComponent</code>가 아니라 <code>TouchReceiveComponent</code>(접두사 <code>UI</code> 누락)였음</strong>. 이름이 비슷해서 붙일 때는 맞게 붙인 줄 알았지만, <code>UITouchDownEvent</code>를 발신하는 건 <code>UITouchReceiveComponent</code>뿐이라 다른 컴포넌트를 붙인 이상 이벤트 자체가 발생할 수 없었던 것. <code>ButtonComponent</code> 충돌이나 <code>ExecSpace</code> 설정은 전혀 문제가 아니었음</p>
<h3 id="6-해결">6. 해결</h3>
<p>슬롯 엔티티에 붙어있던 <code>TouchReceiveComponent</code>를 떼고 <code>UITouchReceiveComponent</code>로 교체하자 즉시 더블클릭 감지 및 아이템 사용이 정상 동작함</p>
<h2 id="📚-성과와-개선-효과">📚 성과와 개선 효과</h2>
<ul>
<li>✅ <strong>디버깅 우선순위 교훈</strong>: &quot;에러도 없이 조용히 아무 일도 안 일어난다&quot;는 증상을 마주하면, 복잡한 가설(컴포넌트 간 충돌, 실행 공간 설정 등)로 바로 들어가기 전에 <strong>&quot;이 이벤트를 발신하는 컴포넌트가 정확히 그 이름/타입으로 붙어있는가&quot;</strong> 같은 가장 기본적인 전제부터 확인하는 게 우선순위가 더 높다는 걸 체감함 (이름이 비슷한 컴포넌트끼리는 특히 더)</li>
<li>✅ <strong>격리된 디버그 로그의 가치</strong>: 핸들러 맨 위에 무조건 찍히는 로그 한 줄이 &quot;코드 문제 vs 이벤트 미도달 문제&quot;를 즉시 갈라줌 — 이후 다른 이슈에서도 같은 패턴을 재사용함</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 06]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-06</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-06</guid>
            <pubDate>Tue, 28 Jul 2026 15:44:06 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---디버그-패널아이템-획득-ui-구현-트러블슈팅">📝 개발일지 - 디버그 패널(아이템 획득 UI) 구현 트러블슈팅</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>아이템 획득 방식으로 확정한 &quot;디버그 버튼 패널&quot;을 구현함. 스크롤에 나열된 아이템 아이콘을 클릭해 선택하고, 수량을 입력한 뒤 추가 버튼을 누르면 서버에 RPC 요청을 보내 인벤토리에 반영하는 흐름. 기존에 만들어둔 <code>UIItemSlot</code>(아이콘 렌더링 컴포넌트)을 그대로 재사용하는 구조로 설계했는데, 이 과정에서 엔진 API를 다루는 방식과 리소스 자체의 한계에서 비롯된 문제 세 가지를 순서대로 만남.</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-connectevent는-component가-아니라-entity의-메서드였다">1. ConnectEvent는 Component가 아니라 Entity의 메서드였다</h3>
<p><strong>현상</strong>: 추가 버튼의 클릭 이벤트를 연결하려는데 실행하자마자 에러가 뜸.</p>
<pre><code>AttemptToCall : &#39;ConnectEvent&#39;을 호출할 수 없습니다. &#39;ConnectEvent&#39;은 nil입니다.</code></pre><p>처음엔 버튼을 아래처럼 받고 있었음:</p>
<pre><code class="language-lua">property ButtonComponent addButtonComp = nil
...
self.addButtonComp:ConnectEvent(ButtonClickEvent, function() self:OnClickAddButton() end)</code></pre>
<p><strong>조사 과정</strong>: <code>self.addButtonComp == nil</code> 방어 코드는 이미 통과한 상태라, 인스펙터 연결 자체는 문제가 없었음. 즉 &quot;연결이 안 된 것&quot;이 아니라 &quot;연결된 컴포넌트에 그 메서드가 없는 것&quot;으로 원인을 좁힘. MSW에서는 하나의 오브젝트가 엔티티(Entity) 하나에 여러 컴포넌트가 얹히는 구조인데, 이벤트 구독을 위한 <code>ConnectEvent</code>는 각 컴포넌트가 아니라 <strong>엔티티 자신</strong>에게 있는 메서드라는 걸 확인함. 클릭 이벤트를 발생시키는 주체는 <code>ButtonComponent</code>이지만, 그 이벤트를 &quot;구독&quot;하는 진입점은 엔티티 쪽으로 통일되어 있는 구조였던 것.</p>
<p><strong>해결</strong>: 프로퍼티 타입 자체를 <code>Entity</code>로 바꾸고, 인스펙터에서도 컴포넌트가 아니라 엔티티 자체를 다시 연결함.</p>
<pre><code class="language-lua">property Entity addButtonEntity = nil
...
self.addButtonEntity:ConnectEvent(ButtonClickEvent, function() self:OnClickAddButton() end)</code></pre>
<p>같은 실수가 아이템 버튼을 동적 생성하는 루프(<code>CreateItemButtons</code>) 안에도 있었어서 함께 수정함.</p>
<p><strong>교훈</strong>: 다른 엔진(예: Unity, Unreal)에서 &quot;버튼 컴포넌트에 리스너를 붙인다&quot;는 습관이 있으면, MSW에서도 무의식중에 컴포넌트 타입으로 프로퍼티를 받기 쉬움. 하지만 이 엔진은 이벤트 구독의 주체가 컴포넌트가 아니라 엔티티라는 점이 명확히 달랐음.</p>
<hr>
<h3 id="2-rpc-요청-메서드의-execspace를-server로-지정해야-하는-이유">2. RPC 요청 메서드의 ExecSpace를 Server로 지정해야 하는 이유</h3>
<p><strong>현상</strong>: 서버 검증 로직 자체는 이미 완성해뒀는데, 클라이언트에서 버튼을 눌러 호출하자 다시 nil 에러가 뜸.</p>
<pre><code>AttemptToCall : &#39;RequestAddItemFromButton&#39;을 호출할 수 없습니다. &#39;RequestAddItemFromButton&#39;은 nil입니다.</code></pre><p>당시 이 메서드는 <code>ServerOnly</code>로 선언되어 있었음:</p>
<pre><code class="language-lua">@ExecSpace(&quot;ServerOnly&quot;)
method void RequestAddItemFromButton(integer itemId, integer amount)
    self:AddItem(itemId, amount)
end</code></pre>
<p><strong>조사 과정</strong>: ExecSpace 옵션(<code>사용하지 않음</code>/<code>Client</code>/<code>ClientOnly</code>/<code>Server</code>/<code>ServerOnly</code>/<code>Multicast</code>)의 의미를 다시 짚어봄. <code>ServerOnly</code>는 그 메서드 자체가 서버 공간에만 존재한다는 뜻이라, 클라이언트 스크립트 입장에서는 애초에 그런 이름의 메서드가 없는 것과 같음(그래서 &quot;실행 실패&quot;가 아니라 &quot;이름 자체가 nil&quot;로 나타남). 반면 <code>Server</code>는 클라이언트에서 호출하되 실제 실행은 서버에서 되도록 RPC로 전달해주는 옵션이라는 걸 확인함. 지금 필요한 건 &quot;클라이언트 버튼 클릭 → 서버 실행&quot;이었으므로 <code>Server</code>가 맞는 선택이었음.</p>
<p><strong>해결</strong>:</p>
<pre><code class="language-lua">@ExecSpace(&quot;Server&quot;)
method void RequestAddItemFromButton(integer itemId, integer amount)
    -- 검증 없이 AddItem으로 위임 (검증은 AddItem(ServerOnly)이 전담)
    self:AddItem(itemId, amount)
end</code></pre>
<p><strong>교훈</strong>: &quot;서버에서만 실행되어야 한다&quot;는 요구사항과 &quot;클라이언트에서 호출 가능해야 한다&quot;는 요구사항은 서로 다른 축이라, <code>ServerOnly</code>와 <code>Server</code>를 목적에 맞게 구분해서 써야 함. 클라이언트가 진입점으로 삼는 <code>Request*</code> 계열 메서드는 전부 <code>Server</code>, 실제 검증/처리를 전담하는 내부 메서드는 <code>ServerOnly</code>로 나누는 패턴을 이후 다른 기능(정렬/합치기/삭제)에도 동일하게 적용함.</p>
<hr>
<h3 id="3-장비-아이템-아이콘만-안-뜨는-문제">3. 장비 아이템 아이콘만 안 뜨는 문제</h3>
<p><strong>현상</strong>: 소비 아이템(포션 등)은 아이콘이 정상적으로 표시되는데, 장비 아이템만 아이콘이 안 뜸. 에러나 경고 로그도 전혀 없이 그냥 빈 칸으로 보임.</p>
<p><strong>조사 과정</strong>: 에러가 없다는 게 오히려 단서였음. 먼저 다음 가설들을 하나씩 배제함.</p>
<ul>
<li>*&quot;itemCnt가 0이라 SetEmpty 처리된 게 아닐까?&quot;* → 디버그 버튼은 <code>itemCnt</code>를 무조건 <code>1</code>로 고정해서 넘기는 구조라 배제.</li>
<li>*&quot;장비는 스택이 1개뿐이라 어딘가에서 걸러지는 게 아닐까?&quot;* → 코드 전체를 봐도 스택 관련 필터링 로직 자체가 없어서 배제.</li>
<li>*&quot;RUID 형식이 다른 게 아닐까?&quot;* → 소비 아이템과 장비 아이템의 RUID를 나란히 비교했으나(둘 다 32자리 동일 형식) 형식 차이는 없었음.</li>
<li>직접 테스트: 빈 엔티티에 <code>SpriteGUIRendererComponent</code>를 붙이고 장비 RUID를 인스펙터에서 직접 넣어봐도 안 뜸 → 코드 문제가 아니라 <strong>리소스 자체의 문제</strong>로 좁힘.</li>
</ul>
<p>여기서 Claude를 통해 문제 원인을 찾아보다가 유사한 사례를 다룬 글을 발견함. 다만 그 글을 지금 다시 확인할 수 있는 링크는 아니라서, 확정된 근거로 삼기보다는 <strong>추정</strong>으로만 남김: 장비 아이템만 아이콘이 안 뜨고 에러 로그도 없다는 것, 그리고 리소스 종류(착용용/아이콘용)의 차이가 원인일 가능성이 있다는 것까지가 이번에 확인한 전부임.</p>
<p><strong>해결</strong>: 실제 착용용 리소스 대신, MSW의 &quot;기타 아이템&quot; 카테고리 리소스 중 옷/무기처럼 생긴 이미지를 찾아 아이콘으로 대체 적용함.</p>
<hr>
<h2 id="📚-성과와-개선-효과">📚 성과와 개선 효과</h2>
<ul>
<li>✅ <strong>엔진 API의 책임 소재 구분</strong>: 이벤트 구독(<code>ConnectEvent</code>)은 엔티티, 이벤트를 발생시키는 로직은 컴포넌트라는 역할 분리를 명확히 이해함</li>
<li>✅ <strong>ExecSpace 선택 기준 확립</strong>: &quot;서버 전용 실행&quot;과 &quot;클라이언트에서 호출 가능한 RPC&quot;는 다른 옵션이라는 걸 실제 에러로 체감하고, 이후 <code>Request*</code>/내부 처리 메서드 쌍 패턴을 전 기능에 일관되게 적용</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 05]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-05</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-05</guid>
            <pubDate>Tue, 28 Jul 2026 14:53:21 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---스폰복제-시-component-프로퍼티-참조-오작동-트러블슈팅">📝 개발일지 - 스폰(복제) 시 Component 프로퍼티 참조 오작동 트러블슈팅</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>인벤토리 슬롯 24개를 풀링 방식으로 스폰해 테스트하던 중, 빈 슬롯인데도 텍스트가 계속 표시되는 이상 현상을 발견함. 표면적 증상 뒤에 원인이 두 겹으로 겹쳐 있었고, 하나를 걷어내자 그 아래 진짜 문제가 드러났음. 최종적으로 이 엔진의 스폰(복제) API가 컴포넌트 참조를 다루는 방식 자체에 있는 비직관적인 함정을 찾아냄</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황-파악">1. 문제 상황 파악</h3>
<p><code>inventoryCapacity</code>(24)만큼 슬롯을 미리 스폰해두고 재사용하는 구조로 설계함. 슬롯 템플릿(<code>Scroll_Image</code>)엔 아이콘(<code>ItemImg</code>)과 수량 텍스트(<code>ItemImg/ItemCnt</code>)가 자식으로 있고, <code>UIItemSlot</code> 컴포넌트가 이를 인스펙터에서 <code>Component</code> 타입 프로퍼티로 미리 연결해두는 방식으로 짜여있었음</p>
<p>테스트 결과: 아이템이 하나도 없는 빈 상태인데도 슬롯마다 텍스트가 계속 보임</p>
<h3 id="2-1차-조사---슬롯-1개만-다르다의-원인은-안-꺼진-원본-템플릿">2. 1차 조사 - &quot;슬롯 1개만 다르다&quot;의 원인은 안 꺼진 원본 템플릿</h3>
<p>&quot;첫 번째 슬롯만 텍스트가 안 보이고 나머지는 다 보인다&quot;는 걸 발견하고, 스폰용 원본 템플릿(<code>Scroll_Image</code>)의 <code>Enable</code>이 꺼져있지 않아서 실제로는 24개가 아니라 <strong>25개</strong>(원본 1 + 스폰본 24)가 떠 있는 것 아닌지 의심함. 직접 확인해보니 맞았음 — <code>Scroll_Image</code>를 <code>Enable=false</code>로 끄니 정확히 24개로 줄어듦. 원본은 한 번도 <code>Refresh()</code>를 거친 적이 없어서 에디터에서 마지막으로 남겨뒀던 상태(우연히 텍스트가 꺼져있던 상태)를 그대로 보여주고 있었을 뿐, 실제 스폰된 24개와는 무관한 별개의 원인이었음</p>
<h3 id="3-원본을-제거하니-드러난-진짜-문제---24개-전부-텍스트가-보임">3. 원본을 제거하니 드러난 진짜 문제 - 24개 전부 텍스트가 보임</h3>
<p>원본 템플릿을 끄고 나니, &quot;일부만 이상하다&quot;는 착시가 사라지고 <strong>스폰된 24개 전부</strong>가 똑같이 텍스트를 잘못 보여주고 있다는 게 명확해짐. 처음 발견했던 &quot;슬롯 1개만 다르다&quot;는 현상은 원본 템플릿이라는 별개의 원인 때문이었고, 그걸 걷어내고 나서야 그 아래 숨어있던 진짜 문제(24개 전체에 공통으로 걸린 버그)가 드러난 것</p>
<h3 id="4-결정적-단서---각-슬롯의-참조-경로를-직접-로그로-확인">4. 결정적 단서 - 각 슬롯의 참조 경로를 직접 로그로 확인</h3>
<p>슬롯마다 실제로 어떤 텍스트 컴포넌트를 참조하고 있는지, 그 엔티티 경로를 직접 찍어봄</p>
<pre><code class="language-lua">method void Refresh(integer slotIdx, integer itemId, integer itemCnt)
-- ⚠️ 임시 테스트 코드
local path = &quot;nil&quot;
if self.cntText ~= nil then
    path = self.cntText.Entity.Path
end
log(&quot;[TEST] slotIdx=&quot; .. tostring(slotIdx) .. &quot; cntText 경로=&quot; .. path)
-- ⚠️ 여기까지
...</code></pre>
<p>결과: <code>slotIdx=1</code>인데 경로가 <code>.../Scroll_Layout/Scroll_Image/ItemImg/ItemCnt</code> — <strong>원본 템플릿의 경로 그대로</strong>였음. 스폰된 복제본(<code>Slot_1</code>)의 참조인데, 원본(<code>Scroll_Image</code>)을 가리키고 있었던 것</p>
<h3 id="5-근본-원인-확정">5. 근본 원인 확정</h3>
<p>인스펙터에서 <code>Component</code> 타입 프로퍼티(예: <code>property TextComponent cntText</code>)를 드래그로 미리 연결해두면, 이 값은 &quot;그 특정 컴포넌트 인스턴스&quot;에 대한 하드 레퍼런스로 저장됨. 그런데 <code>_SpawnService:SpawnByEntity</code>로 엔티티를 복제할 때, 이 프로퍼티 값은 <strong>복제본 자신의 새로운 자식으로 재배선되지 않고, 원본이 가리키던 참조를 그대로 복사</strong>해버림</p>
<p>그 결과 스폰된 24개 슬롯의 <code>cntText</code>가 전부 원본 템플릿의 텍스트 컴포넌트 단 하나를 공유하게 됨. 매번 <code>RefreshSlot</code>을 호출할 때마다 사실상 같은 객체를 24번 덮어쓰는 셈이었고(그마저도 원본은 <code>Enable=false</code>라 화면에 안 보임), 정작 화면에 보이는 각 복제본 고유의 텍스트는 한 번도 코드로 제어된 적 없이 에디터에서 마지막으로 넣어둔 플레이스홀더 값 그대로 남아있었던 것</p>
<h3 id="6-해결---인스펙터-배선-폐기-런타임-조회로-전환">6. 해결 - 인스펙터 배선 폐기, 런타임 조회로 전환</h3>
<p><code>OnBeginPlay</code>에서 자기 자신의 하이라키를 <code>GetChildByName</code>으로 직접 탐색해 할당하는 방식으로 변경함. 이러면 복제본마다 각자 자기 자식을 찾으므로 문제가 없음</p>
<pre><code class="language-lua">@ExecSpace(&quot;ClientOnly&quot;)
method void OnBeginPlay()
local iconEntity = self.Entity:GetChildByName(&quot;ItemImg&quot;)
if iconEntity == nil then
    log_error(&quot;[UIItemSlot] ItemImg 자식을 찾을 수 없음&quot;)
    return
end
self.iconImage = iconEntity.SpriteGUIRendererComponent

local cntEntity = iconEntity:GetChildByName(&quot;ItemCnt&quot;)
if cntEntity == nil then
    log_error(&quot;[UIItemSlot] ItemCnt 자식을 찾을 수 없음&quot;)
    return
end
self.cntText = cntEntity.TextComponent
end</code></pre>
<h2 id="📚-성과와-개선-효과">📚 성과와 개선 효과</h2>
<ul>
<li>✅ <strong>비직관적인 엔진 제약 발견</strong>: &quot;인스펙터로 미리 연결해둔 컴포넌트 참조는 스폰 복제 시 재배선되지 않는다&quot;는, 문서화되어 있지 않던 동작을 실측으로 확인</li>
<li>✅ <strong>재사용 가능한 규칙 확립</strong>: 앞으로 스폰되는 모든 프리팹(슬롯, 디버그 버튼 등)에 &quot;자식 컴포넌트 참조가 필요하면 인스펙터 배선 대신 <code>OnBeginPlay</code> + <code>GetChildByName</code> 런타임 조회&quot;라는 원칙을 세워 재사용</li>
</ul>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<p>공식 문서에서 명시적으로 확인된 내용은 아니며, 실측 테스트(스폰된 인스턴스의 컴포넌트 소유 엔티티 경로를 직접 로그로 추적)를 통해 확인한 엔진 동작임. 별도 참고 문서 없음</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 04]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-04</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-04</guid>
            <pubDate>Tue, 28 Jul 2026 14:28:22 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---클라이언트-아이템-데이터-로딩-타이밍-트러블슈팅">📝 개발일지 - 클라이언트 아이템 데이터 로딩 타이밍 트러블슈팅</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>인벤토리 UI(슬롯 렌더링)를 테스트하다가 아이콘이 전혀 표시되지 않는 문제를 발견함. 처음엔 단순 배선 실수인 줄 알았으나, 파고들수록 &quot;서버 권위 원칙을 어디까지 적용해야 하는가&quot;라는 설계 판단 문제와, &quot;서버-클라이언트 간 초기화 순서는 보장되지 않는다&quot;는 구조적 문제, 두 겹으로 얽힌 이슈였음을 확인함</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황-파악">1. 문제 상황 파악</h3>
<p>인벤토리를 열면 슬롯은 정상적으로 생성되는데, 아이템을 넣어도 아이콘이 뜨지 않음. <code>UIItemSlot.SetItem</code>에서 <code>_ItemData.itemTable[itemId]</code>를 조회해 아이콘 리소스를 가져오는 구조인데, 여기서 뭔가 어긋나고 있다고 추정함</p>
<h3 id="2-1차-원인-확인---_itemdata가-클라이언트에서-아예-로드되지-않음">2. 1차 원인 확인 - <code>_ItemData</code>가 클라이언트에서 아예 로드되지 않음</h3>
<p>바로 코드를 고치기 전에, 실제로 클라이언트 쪽 <code>_ItemData</code> 상태가 어떤지부터 로그로 직접 확인함</p>
<pre><code class="language-lua">-- UIInventory.OnBeginPlay에 임시로 삽입
log(&quot;[TEST] 클라 isInitialized = &quot; .. tostring(_ItemData.isInitialized))
log(&quot;[TEST] 클라 itemTable 데이터 있음 = &quot; .. tostring(next(_ItemData.itemTable) ~= nil))</code></pre>
<p>결과: 둘 다 <code>false</code>. <code>ItemData.OnBeginPlay</code>가 <code>ServerOnly</code>로 설정돼있어서, 클라이언트 쪽 <code>_ItemData</code> 인스턴스는 애초에 <code>OnBeginPlay</code> 자체가 실행된 적이 없었고, 그래서 초기값(<code>{}</code>, <code>false</code>)에 계속 머물러 있었던 것</p>
<h3 id="3-설계-판단---마스터-데이터를-클라이언트에서-직접-로드해도-되는가">3. 설계 판단 - 마스터 데이터를 클라이언트에서 직접 로드해도 되는가</h3>
<p><code>ExecSpace</code>를 바꾸기 전에, 이게 서버 권위 원칙을 깨는 건 아닌지부터 짚어봄</p>
<ul>
<li>서버 권위가 지켜야 하는 건 <strong>&quot;플레이어가 실제로 무엇을 얼마나 갖고 있는가&quot;</strong> 같은 개별 상태값 (예: <code>PlayerInventory</code>의 슬롯 데이터)</li>
<li><code>ItemData.itemTable</code>은 <strong>&quot;이 게임에 어떤 아이템이 존재하고, 아이콘/이름/최대스택이 뭔지&quot;</strong> 같은 정적 정의(마스터 데이터)</li>
<li>이건 애초에 숨길 이유가 없는 공개 정보임 — 클라이언트 UI가 아이콘을 그리려면 어차피 클라가 이 정보를 들고 있어야 하고, 서버가 숨긴다 해도 결국 화면에 다 드러나는 정보라 숨길 실익이 없음</li>
</ul>
<p>→ <strong>&quot;숨겨야 하는 상태값&quot; vs &quot;공개돼도 되는 규칙 정의&quot;</strong>를 구분하는 기준을 세우고, <code>ItemData.OnBeginPlay</code>의 <code>ExecSpace</code>를 <code>ServerOnly</code> → <strong>&quot;사용 안 함&quot;</strong>(제한 없음)으로 변경. 서버/클라가 각자 독립적으로 같은 CSV를 읽어 <code>itemTable</code>을 캐싱하게 됨(네트워크 전송이 아니라 각자 로드하는 방식이라 서버 부하나 보안 노출도 없음)</p>
<h3 id="4-그런데-여전히-남아있는-문제---초기화-순서-레이스-컨디션">4. 그런데 여전히 남아있는 문제 - 초기화 순서 레이스 컨디션</h3>
<p><code>ExecSpace</code>를 고쳐도, &quot;서버가 보낸 인벤토리 이벤트가 클라이언트 자신의 <code>_ItemData</code> 로딩 완료보다 먼저 도착할 수 있다&quot;는 문제는 그대로 남음. 서버의 <code>_ItemData</code> 완료 여부와 클라이언트의 <code>_ItemData</code> 완료 여부는 완전히 별개의 비동기 프로세스라서, 어느 쪽이 먼저 끝날지 보장할 방법이 없음</p>
<h3 id="5-해결---서버-쪽에서-이미-쓰던-방어-패턴을-클라이언트에-대칭-적용">5. 해결 - 서버 쪽에서 이미 쓰던 방어 패턴을 클라이언트에 대칭 적용</h3>
<p>사실 이 문제는 처음이 아니었음. <code>PlayerInventory</code>(서버)도 <code>ItemData</code>(Logic)와의 <code>OnBeginPlay</code> 순서가 보장 안 돼서, 이미 아래 패턴으로 방어하고 있었음</p>
<pre><code class="language-lua">-- PlayerInventory.OnBeginPlay (서버, 기존 코드)
if _ItemData.isInitialized then
    self:InitInventory()
end
-- 아직이면 아무것도 안 함 — HandleOnItemDataInitialized가 늦게 온 초기화 완료를 대신 처리</code></pre>
<p>같은 구조를 클라이언트(<code>UIInventory</code>)에도 그대로 대칭 적용함. 다만 서버 쪽은 &quot;초기화를 지연시키는&quot; 방식이었다면, 클라이언트 쪽은 이미 들어온 슬롯 데이터를 버릴 수 없어서 &quot;캐시해두고 나중에 몰아서 그리는&quot; 방식으로 변형함</p>
<pre><code class="language-lua">method void ApplySlotData(integer slotIdx, integer itemId, integer itemCnt)
-- pendingSlots 캐시엔 무조건 기록. _ItemData가 아직 준비 안 됐으면 렌더링은 건너뛰고
-- 나중에 HandleOnItemDataInitialized가 캐시 전체를 몰아서 그림
self._T.pendingSlots = self._T.pendingSlots or {}
self._T.pendingSlots[slotIdx] = { itemId = itemId, itemCnt = itemCnt }

if _ItemData.isInitialized then
    self:RefreshSlot(slotIdx, itemId, itemCnt)
end
end

@EventSender(&quot;Logic&quot;, &quot;ItemData&quot;)
handler HandleOnItemDataInitialized(OnItemDataInitialized event)
-- _ItemData가 인벤토리 이벤트보다 늦게 준비된 경우 대비: 캐시된 전체 슬롯을 한꺼번에 렌더링
if self._T.pendingSlots == nil then return end
for slotIdx, slotData in pairs(self._T.pendingSlots) do
    self:RefreshSlot(slotIdx, slotData.itemId, slotData.itemCnt)
end
end</code></pre>
<h3 id="6-검증-범위">6. 검증 범위</h3>
<p>이 방어 로직이 실제로 &quot;아직 준비 안 된 상태&quot;를 캐치해 렌더링을 미룬 사례를 로그로 직접 관측하진 못함 — 어디까지나 &quot;타이밍이 보장 안 되니 생길 수 있는 상황&quot;이라는 논리적 추론에 따라 미리 방어적으로 설계한 것. 참고로 테스트 도중 <code>OnInventoryModified 이벤트가 Initialized보다 먼저 도착함</code> 경고를 본 적은 있으나, 이건 별개의 원인 — 임시 테스트 코드에서 <code>BroadcastInitialized()</code>보다 <code>AddItem()</code>을 먼저 호출해 생긴 순서 실수였고 <code>_ItemData</code> 로딩 타이밍과는 무관함</p>
<h2 id="📚-성과와-개선-효과">📚 성과와 개선 효과</h2>
<ul>
<li>✅ <strong>원인 특정</strong>: 추측 대신 상태값을 직접 로그로 찍어서 &quot;클라에서 로드가 아예 안 되고 있다&quot;는 사실을 먼저 확정한 뒤 수정에 들어감</li>
<li>✅ <strong>설계 기준 확립</strong>: &quot;서버 권위로 지켜야 할 상태&quot;와 &quot;공개돼도 되는 규칙 정의&quot;를 구분하는 기준을 세워, 이후 비슷한 판단(다른 마스터 데이터 추가 시)에도 재사용 가능</li>
<li>✅ <strong>구조적 일관성</strong>: 서버/클라 양쪽에 동일한 &quot;초기화 순서 미보장 대응 패턴&quot;을 대칭적으로 적용해, 앞으로 이런 종류의 Logic-Component 간 로딩 순서 문제가 생겨도 같은 패턴을 재사용할 수 있음</li>
</ul>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<p>이번 트러블슈팅은 특정 공식 문서보다는, 서버 권위 원칙의 적용 범위(상태값 vs 규칙 정의)를 직접 판단하며 진행함. 별도 참고 문서 없음</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 03]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-03</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-03</guid>
            <pubDate>Tue, 28 Jul 2026 14:04:46 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---인벤토리-정렬-알고리즘-최적화-트러블슈팅-on²-→-on">📝 개발일지 - 인벤토리 정렬 알고리즘 최적화 트러블슈팅 (O(n²) → O(n))</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>인벤토리 정렬(<code>SortSlots</code>) 기능을 검토하다가, 지금 구현이 &quot;슬롯 순서만 바꿀 뿐, 여러 슬롯에 흩어진 같은 아이템을 합쳐주지 않는다&quot;는 걸 뒤늦게 발견함. 이를 보완하는 과정에서 처음 접근(이중 루프)이 비효율적이라는 걸 깨닫고, 알고리즘을 다시 설계해 시간복잡도를 개선함. 더불어 이 로직을 어느 컴포넌트가 소유해야 하는지에 대한 책임 소재 문제까지 같이 정리함</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황-파악">1. 문제 상황 파악</h3>
<p>기존 <code>SortSlots()</code>는 <code>itemId</code> 오름차순으로 슬롯을 정렬하고 빈 슬롯을 뒤로 몰아주는 기능만 있었음</p>
<pre><code class="language-lua">table.sort(self.slots, function(a, b)
    if a.itemCnt &lt;= 0 then return false end
    if b.itemCnt &lt;= 0 then return true end
    return a.itemId &lt; b.itemId
end)</code></pre>
<p>문제는, 같은 아이템이 포션이 3번/7번/15번 슬롯처럼 여러 슬롯에 나뉘어 있는 경우 정렬을 해도 &quot;순서만 옆으로 나란히&quot; 될 뿐, <strong>슬롯 3개를 여전히 차지하고 있다</strong>는 점. 실제 게임에서 &quot;정렬&quot; 버튼을 누르면 합칠 수 있는 건 합쳐서 빈 칸을 확보해주는 게 일반적인 기대 동작인데, 이 부분이 빠져있었음</p>
<h3 id="2-첫-번째-접근---이중-루프-방식-기각">2. 첫 번째 접근 - 이중 루프 방식 (기각)</h3>
<p>처음엔 모든 슬롯 쌍을 비교하며 같은 아이템끼리 합치는 방식으로 접근함</p>
<pre><code class="language-lua">-- i,j 이중 루프로 모든 슬롯 쌍을 비교하며 병합
for i = 1, self.slotCnt do
    local slot = self.slots[i]
    if slot.itemCnt &gt; 0 then
        for j = i + 1, self.slotCnt do
            local otherSlot = self.slots[j]
            if otherSlot.itemId == slot.itemId and otherSlot.itemCnt &gt; 0 then
                self:MergeSlot(j, i, maxStack)
            end
        end
    end
end</code></pre>
<p><strong>기각한 이유</strong></p>
<ul>
<li>슬롯 개수를 <code>n</code>이라 하면 이 방식은 <code>O(n²)</code>. 슬롯 규모(수십 개) 자체는 작아서 실질적 성능 문제는 크지 않을 수 있지만, 굳이 더 간단하고 빠른 방법이 있는데 이걸 쓸 이유가 없음</li>
</ul>
<h3 id="3-두-번째-접근---정렬-후-인접-병합으로-전환-on">3. 두 번째 접근 - 정렬 후 인접 병합으로 전환 (O(n))</h3>
<p>&quot;정렬을 먼저 하면 같은 아이템이 어차피 옆으로 붙는데, 그럼 인접한 것끼리만 한 번 훑으면 되지 않나?&quot;라는 관점으로 다시 설계함</p>
<pre><code class="language-lua">-- 1차 정렬로 같은 아이템을 인접시킨 뒤, writeIdx/readIdx 투 포인터로 한 번만 스캔
local writeIdx = 1
for readIdx = 2, self.slotCnt do
    local readSlot = self.slots[readIdx]
    if readSlot.itemCnt &lt;= 0 then
        break -- 정렬 후 빈 슬롯은 전부 뒤로 몰려있으므로 여기부터는 전부 빈 슬롯
    end

    local writeSlot = self.slots[writeIdx]
    if writeSlot.itemId == readSlot.itemId then
        self:MergeSlot(readIdx, writeIdx, maxStack)
        if readSlot.itemCnt &gt; 0 then
            writeIdx = readIdx -- writeIdx가 가득 차서 남은 수량이 있으면 readIdx가 새 병합 대상
        end
    else
        writeIdx = readIdx -- 다른 아이템 종류 시작
    end
end</code></pre>
<p>정렬(<code>O(n log n)</code>) + 한 번의 선형 스캔(<code>O(n)</code>)으로, 이중 루프 없이 같은 결과를 얻을 수 있게 됨</p>
<h3 id="4-세부-최적화---압축-후-생기는-빈-슬롯-처리">4. 세부 최적화 - 압축 후 생기는 빈 슬롯 처리</h3>
<p>병합을 하면 슬롯 중간중간에 빈 자리가 생김(예: <code>[A:30, 빈칸, 빈칸, B:28, 빈칸]</code>). 이걸 다시 뒤로 밀어야 완전히 압축된 결과가 나오는데, 별도의 O(n) &quot;당기기&quot; 로직을 새로 짜는 대신 <strong>이미 있는 <code>SortSlots</code>를 한 번 더 호출</strong>하는 방식을 채택함</p>
<pre><code class="language-lua">table.sort(self.slots, function(a, b) ... end) -- 1차: 인접시키기
-- ... 압축 루프 ...
table.sort(self.slots, function(a, b) ... end) -- 2차: 압축으로 생긴 빈 슬롯 마무리</code></pre>
<p>정렬이 <code>O(n log n)</code>이라 슬롯 규모(수십 개)에서 두 번 돌아도 비용이 무시할 수준이라, 별도 로직을 추가하는 복잡성보다 이 방식이 더 실용적이라 판단함</p>
<p>추가로, &quot;비어있는지&quot;를 정렬 <strong>전</strong>에 확인해서 아예 정렬 자체를 건너뛰는 최적화도 검토함. 다만 정렬 전에는 &quot;빈 슬롯이 뒤로 몰려있다&quot;는 전제가 아직 성립하지 않아서, 1번 슬롯만 보고 판단할 수 없고 전체를 훑어야(<code>O(n)</code>) 함. 슬롯 규모가 작아 이 전체 스캔을 추가하는 이득이 거의 없다고 보고, 결국 <strong>&quot;정렬 후 1번 슬롯만 확인&quot;하는 원래 방식을 유지</strong>함</p>
<h3 id="5-아키텍처-재조정---이-로직을-누가-가져야-하는가">5. 아키텍처 재조정 - 이 로직을 누가 가져야 하는가</h3>
<p>압축 로직에 필요한 <code>maxStack</code>(아이템별 최대 스택 수)은 <code>Container</code>가 모르는 외부 정보라서, 처음엔 이 로직을 호출부(<code>PlayerInventory</code>)에 직접 구현했음. 그런데 이렇게 두면 <strong>슬롯을 직접 조작하는 로직이 <code>Container</code> 밖으로 새어나가는 셈</strong>이라 SRP에 어긋난다는 걸 깨달음</p>
<p><code>maxStack</code> 하나씩 개별 전달하는 대신, <strong>&quot;이번에 등장한 아이템들의 <code>itemId → maxStack</code> 대응표(<code>maxStackTable</code>)&quot;를 통째로 넘기는 방식</strong>으로 바꿔서, <code>Container</code>가 여전히 아이템 메타데이터의 출처(<code>_ItemData</code>)를 몰라도 전체 정렬+압축 과정을 스스로 소유할 수 있게 재조정함</p>
<pre><code class="language-lua">-- Container가 소유 (PlayerInventory는 maxStackTable만 만들어서 넘김)
method void SortSlots(table maxStackTable)
    -- 정렬 + 압축 + 마무리 정렬 전부 여기서 처리
end</code></pre>
<p>별도 메서드(<code>SortAndCompact</code>)로 새로 만들려고도 했으나, &quot;정렬은 원래 같은 아이템을 최대한 합치면서 재배치한다&quot;는 게 이 장르의 인벤토리에서 &#39;정렬&#39;이 의미하는 바 자체라는 걸 되짚고, 기존 <code>SortSlots</code>에 매개변수 하나(<code>maxStackTable</code>)만 추가해 통합하는 쪽으로 최종 결정함</p>
<p><code>Container</code>가 <code>_ItemData</code>(전역 싱글턴)를 직접 참조하면 안 되는 이유도 이 참에 다시 정리함:</p>
<pre><code class="language-lua">-- 만약 Container가 이렇게 짜여있다면
method void AddItem(itemId, amount)
    local maxStack = _ItemData.itemTable[itemId].maxStack  -- 전역 싱글턴 직접 참조
    -- ...
end</code></pre>
<p>이 코드는 <code>_ItemData</code>라는 이름의 Logic이 그 월드 어딘가에 반드시 존재해야만 실행 가능함. <code>Container</code> 혼자서는 절대 못 돌아감. 왜 문제가 되는지 세 가지로 정리함</p>
<ol>
<li><strong>독립적으로 테스트/재사용할 수 없음</strong> — <code>InventoryContainer()</code>를 만들어서 슬롯 계산 로직만 따로 검증하고 싶어도, <code>_ItemData</code>(전체 아이템 테이블을 CSV에서 로드하는 무거운 싱글턴)가 먼저 초기화돼 있어야만 동작함. &quot;슬롯에 아이템을 넣고 빼고 정렬하는 순수 계산 로직&quot;을 확인하는 데 &quot;게임 전체 아이템 데이터베이스&quot;까지 딸려와야 하는 건 부담스러운 의존성임</li>
<li><strong>서비스 로케이터(Service Locator) 패턴이 됨</strong> — <code>_ItemData</code>처럼 어디서든 접근 가능한 전역 참조를, 코드 내부 아무 데서나 끌어다 쓰는 방식은 의존성이 코드 표면에 안 드러나고 숨어있는 패턴임. <code>AddItem(itemId, amount)</code>만 봐서는 이 메서드가 <code>_ItemData</code>에 의존한다는 걸 알 수 없음 — 함수 몸통을 다 읽어봐야 알 수 있음. 반면 <code>AddItem(itemId, amount, maxStack)</code>처럼 매개변수로 받으면, 함수 시그니처만 봐도 &quot;이 함수가 뭘 필요로 하는지&quot;가 그대로 드러남</li>
<li><strong>데이터 출처가 바뀔 가능성에 대비</strong> — 지금은 <code>_ItemData</code>가 CSV 기반이지만, 나중에 &quot;이벤트 기간 한정 아이템은 다른 테이블에서 온다&quot;거나 &quot;테스트용 목업 데이터로 바꿔서 실행해보고 싶다&quot; 같은 상황이 생기면, <code>Container</code>가 <code>_ItemData</code>를 직접 알고 있으면 그 변경이 <code>Container</code> 코드까지 건드리게 됨. 매개변수로 받으면 <code>Container</code>는 &quot;누가 무슨 값을 주든&quot; 신경 안 써도 되니 이런 변화에 영향을 안 받음</li>
</ol>
<h3 id="6-성과와-개선-효과">6. 성과와 개선 효과</h3>
<ul>
<li>✅ <strong>시간복잡도 개선</strong>: 이중 루프(<code>O(n²)</code>) → 정렬+선형스캔(<code>O(n log n) + O(n)</code>)</li>
<li>✅ <strong>책임 분리(SRP) 재정비</strong>: 슬롯을 직접 조작하는 로직 전부 <code>Container</code>로 이전, <code>PlayerInventory</code>는 필요한 외부 정보(<code>maxStackTable</code>)를 모아 넘기는 오케스트레이션 역할만 남음</li>
<li>✅ <strong>DIP 유지</strong>: <code>Container</code>는 여전히 <code>_ItemData</code>를 직접 모른 채로 정렬+압축을 전부 처리 가능</li>
<li>✅ <strong>개념적 일관성 확보</strong>: &quot;정렬&quot;과 &quot;합치기&quot;를 억지로 나눈 두 개의 API가 아니라, 원래 하나였던 개념(&quot;정렬 = 합치면서 재배치&quot;)에 맞게 기존 API 하나로 통합</li>
</ul>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<p>이번 트러블슈팅은 특정 공식 문서를 근거로 삼기보다, 알고리즘 시간복잡도와 책임 분리 원칙(SRP/DIP)을 직접 검토하며 진행함. 별도 참고 문서 없음</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 02]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-02</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-02</guid>
            <pubDate>Tue, 28 Jul 2026 07:59:42 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---inventorycontainer-다중-반환값-경고-트러블슈팅">📝 개발일지 - InventoryContainer 다중 반환값 경고 트러블슈팅</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>인벤토리 컨테이너(<code>InventoryContainer</code>)의 <code>AddItem</code>/<code>MergeSlot</code> 메서드를 구현하면서, &quot;성공 여부&quot;와 &quot;실제 처리된 수량&quot;을 동시에 반환해야 하는데 에디터가 경고를 띄우는 문제를 발견하고 해결했음</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황---리턴값-두-개를-반환하려다-발생한-경고">1. 문제 상황 - 리턴값 두 개를 반환하려다 발생한 경고</h3>
<p><code>AddItem</code>은 인벤토리가 가득 차서 일부만 추가된 경우를 호출부가 구분할 수 있어야 해서, 처음엔 값을 두 개 반환하도록 짰음</p>
<pre><code class="language-lua">-- AddItem 내부
return isFullySucceeded, totalAdded  -- success, amount 두 값 반환</code></pre>
<p>Lua는 애초에 <code>return a, b</code>처럼 값 여러 개를 한 번에 반환하는 걸 언어 차원에서 기본 지원한다. 그래서 이 문법 자체를 쓰는 데는 아무 문제가 없다고 생각하고 그대로 짰음</p>
<p>문제는 MSW 에디터에서 메서드를 정의할 때, Return 타입 설정란은 <strong><code>boolean</code> 하나만</strong> 선택해둔 상태였다는 것. 매개변수는 &quot;Add Parameter&quot; 버튼으로 여러 개를 자유롭게 추가할 수 있어서, Return도 당연히 같은 방식으로 여러 개 선언할 수 있을 거라 생각했는데, 실제로 확인해보니 Return 쪽에는 그 &quot;추가&quot; 버튼 자체가 없었음</p>
<p>그 상태로 호출부에서 값 두 개를 받으니 에디터가 경고를 띄웠음</p>
<pre><code class="language-lua">local success, addedAmount = self._T.container:AddItem(itemId, amount, maxStack)
-- ⚠️ 리턴값이 1개인데 변수 2개에 할당하려 함</code></pre>
<h3 id="2-해결-과정---경고의-정체를-실측으로-확인-⚠️">2. 해결 과정 - 경고의 정체를 실측으로 확인 ⚠️</h3>
<p>경고가 뜨는 게 &quot;정말 두 번째 값이 유실된다&quot;는 뜻인지, 아니면 &quot;타입 선언과 실제 반환 개수가 안 맞는다&quot;는 정적 경고일 뿐인지가 불확실했음. Lua 언어 자체는 다중 반환을 지원하는 게 맞지만, MSW의 mLua 에디터가 <strong>메서드 하나당 함수 몸통(Code)만 코드로 작성하고, 실제 함수 시그니처(<code>function Name(...)</code>)는 에디터가 Name/Arguments/Return 설정을 조합해 자동으로 생성</strong>하는 구조라, 이 자동 생성된 시그니처가 선언된 Return 타입 개수로 강제되는지가 관건이었음</p>
<p>말로 추측하지 않고 별도의 임시 테스트 컴포넌트를 만들어 직접 실측함</p>
<pre><code class="language-lua">-- TestFunc, Return 타입은 boolean 하나만 선언
return true, 999</code></pre>
<pre><code class="language-lua">-- OnBeginPlay (server only)
local success, extra = self:TestFunc()
log(success)
log(extra)</code></pre>
<p>콘솔에 <code>true</code>와 <code>999</code>가 <strong>둘 다 정상적으로 찍힘</strong>을 확인함. 즉 mLua 자체가 다중 반환을 막는 게 아니라, <strong>에디터의 Return 타입 선언란(UI)이 1개 슬롯만 제공할 뿐</strong>이고, 그 밑에서 실제로 실행되는 Lua 런타임은 선언된 개수와 무관하게 다중 반환을 그대로 통과시킴. 경고는 &quot;타입 선언과 실제 동작이 다르다&quot;는 정적 경고였을 뿐, 실행에는 문제가 없었음</p>
<h3 id="3-최종-해결---결과-전용-structtype으로-감싸기">3. 최종 해결 - 결과 전용 StructType으로 감싸기</h3>
<p>동작 자체는 문제없지만, 선언(<code>boolean</code> 하나)과 실제 반환(두 값)이 다른 채로 두면 나중에 코드를 다시 볼 때(또는 다른 사람이 볼 때) 두 번째 값의 존재를 코드를 직접 열어보기 전엔 알 수 없고, 호출할 때마다 경고도 계속 떠서 정리하기로 함</p>
<p>값 두 개를 각각 반환하는 대신, <code>success</code>/<code>amount</code> 필드를 가진 <code>InventoryUpdateResult</code>라는 StructType 하나를 만들어 통째로 반환하도록 변경함</p>
<pre><code class="language-lua">-- InventoryUpdateResult (StructType)
-- success: boolean, amount: integer

-- AddItem
local result = InventoryUpdateResult()
result.success = (remaining == 0)
result.amount = totalAdded
return result</code></pre>
<p>Return 타입도 <code>InventoryUpdateResult</code> 하나로 선언하니 실제 반환값과 정확히 일치해서 경고가 사라짐. <code>AddItem</code>과 <code>MergeSlot</code>이 둘 다 &quot;성공 여부 + 처리 수량&quot;이라는 같은 모양의 결과가 필요해서, 타입 하나를 공유해 중복도 줄임</p>
<h3 id="4-성과와-개선-효과">4. 성과와 개선 효과</h3>
<ul>
<li>✅ <strong>경고 제거</strong>: Return 타입 선언과 실제 반환값이 일치하게 되어 정적 경고가 사라짐</li>
<li>✅ <strong>인터페이스 명시성 확보</strong>: 메서드 시그니처만 봐도 <code>success</code>/<code>amount</code> 두 값이 반환된다는 게 드러남</li>
<li>✅ <strong>중복 제거</strong>: <code>AddItem</code>, <code>MergeSlot</code>이 같은 결과 타입을 공유해 별도 타입을 두 개 만들 필요가 없어짐</li>
<li>✅ <strong>원인의 정확한 이해</strong>: &quot;Lua 언어가 다중 반환을 지원하는 것&quot;과 &quot;MSW 에디터의 Return 타입 선언 UI가 1개만 지원하는 것&quot;은 서로 다른 레이어의 문제라는 걸 추측이 아니라 실측으로 확인함</li>
</ul>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<ul>
<li><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=172">함수 — MSW 공식 크리에이터 센터</a>
함수의 Return 타입 설정 방법을 다루는 문서. 매개변수와 달리 Return 타입에는 &quot;추가&quot; 절차 자체가 언급되지 않아, 에디터가 1개만 지원한다는 것의 근거로 참고함</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[메이플스토리월드 :: 인벤토리 구현 01]]></title>
            <link>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-01</link>
            <guid>https://velog.io/@sehee-jj/%EB%A9%94%EC%9D%B4%ED%94%8C%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%9B%94%EB%93%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EA%B5%AC%ED%98%84-01</guid>
            <pubDate>Tue, 28 Jul 2026 07:26:23 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-개발일지---itemdata-초기화-순서-트러블슈팅">📝 개발일지 - ItemData 초기화 순서 트러블슈팅</h1>
<h2 id="👨💻-오늘의-개발-작업">👨‍💻 오늘의 개발 작업</h2>
<p>오늘은 인벤토리 시스템의 데이터 계층(<code>ItemData</code>)을 만들면서, 서로 다른 스크립트 간 <code>OnBeginPlay</code> 실행 순서가 보장되지 않는 문제를 발견하고 해결했음</p>
<p><code>PlayerInventory</code>가 <code>ItemData</code>의 아이템 테이블을 참조해야 하는데, 어느 쪽이 먼저 초기화를 끝낼지 확신할 수 없는 상황이었음. 폴링(<code>wait()</code>) 없이, 이벤트 기반으로 안전하게 처리하는 방식으로 마무리함</p>
<hr>
<h2 id="💡-오늘의-기록">💡 오늘의 기록</h2>
<h3 id="1-문제-상황-파악">1. 문제 상황 파악</h3>
<p>아이템 데이터(<code>ItemDataTable</code>)를 매번 셀 단위로 조회하면 성능이 아깝다고 판단해서, 게임 시작 시 1회만 로드해 메모리에 캐싱하는 <code>ItemData</code>(Logic, 전역 싱글턴)를 만들었음</p>
<pre><code class="language-lua">-- ItemData.OnBeginPlay
local itemDataSet = _DataService:GetTable(&quot;ItemDataTable&quot;)
local rowCnt = itemDataSet:GetRowCount()

for i = 1, rowCnt do
    local data = ItemInfo()
    -- ... 필드 채우기
    self.itemTable[id] = data
end</code></pre>
<p>그런데 이 <code>itemTable</code>을 실제로 쓰는 쪽은 플레이어별로 붙는 <code>PlayerInventory</code>(Component)였음. 여기서 곧바로 참조하는 코드를 짜다가 의문이 들었음:</p>
<blockquote>
<p><code>PlayerInventory.OnBeginPlay</code>가 실행되는 시점에, <code>ItemData.OnBeginPlay</code>는 이미 끝나 있다고 확신할 수 있나?</p>
</blockquote>
<h3 id="2-근거-없이-넘겨짚었던-부분">2. 근거 없이 넘겨짚었던 부분</h3>
<p>처음엔 &quot;Logic은 전역 싱글턴이니까 당연히 먼저 초기화되겠지&quot;라고 가정하고 넘어가려 했음. 하지만 이 가정을 뒷받침할 근거가 없다는 걸 스스로 깨닫고, MSW 공식 문서를 다시 확인해봄</p>
<p><strong>확인된 사실 (MSW 공식 문서)</strong></p>
<pre><code>&quot;각 컴포넌트 내 OnBeginPlay의 호출 순서는 보장되지 않기 때문에,
특정 컴포넌트의 OnBeginPlay로 설정이 완료된 값을 다른 컴포넌트가
참조하면 오작동할 가능성이 있다&quot;</code></pre><p>공식 예제 코드(<code>ClearInventoryOnBeginPlay</code>)에서도 다른 컴포넌트(<code>LiteInventory</code>)의 초기화 완료를 기다리기 위해 <code>IsInitialized</code> 플래그를 확인하는 방식을 쓰고 있었음. 즉 &quot;먼저 실행되겠지&quot;라는 가정 자체가 애초에 성립하지 않는 환경이었음</p>
<h3 id="3-첫-번째-접근---폴링-방식-기각">3. 첫 번째 접근 - 폴링 방식 (기각)</h3>
<p>MSW 공식 예제에 나온 방식을 그대로 적용해보려 했음</p>
<pre><code class="language-lua">-- 공식 문서 예제 패턴
local inven = self.Entity.LiteInventory
while not inven.IsInitialized do
    wait(0.5)
end</code></pre>
<p><strong>기각한 이유</strong></p>
<ul>
<li><code>wait()</code>은 코루틴을 점유한 채 일정 주기(0.5초)마다 계속 깨어나 플래그를 재확인하는 방식이라, 대기 중인 시간 동안 불필요한 컨텍스트 스위칭·검사 연산이 반복 발생함</li>
<li>접속자가 늘어날수록 <strong>플레이어 수만큼 폴링 루프가 동시에 돌아가는 구조</strong>라, 서버 부하가 인원수에 비례해서 커짐 — 과제 요구사항인 &quot;서버 최적화&quot;와 정면으로 배치됨</li>
<li>폴링 주기(0.5초) 때문에 실제 초기화가 끝난 시점과 이를 감지하는 시점 사이에 <strong>최대 0.5초의 불필요한 지연</strong>이 생김. 이벤트 방식이면 완료되는 즉시(지연 없이) 콜백이 실행됨</li>
<li>결국 &quot;값이 준비됐는지 반복해서 찔러보는&quot; 방식이라, 값의 변화를 스스로 알려주는(이벤트 발행) 방식보다 근본적으로 비효율적인 구조임</li>
</ul>
<h3 id="4-두-번째-접근---순수-이벤트-방식의-함정-⚠️">4. 두 번째 접근 - 순수 이벤트 방식의 함정 ⚠️</h3>
<p>폴링 대신 이벤트로 완전히 대체하려 했는데, 여기서 진짜 문제를 발견함</p>
<pre><code class="language-lua">-- 문제가 될 수 있는 방식
-- PlayerInventory에서 이벤트 핸들러만 등록해두고 기다림</code></pre>
<p><strong>핵심 문제</strong></p>
<ul>
<li>이벤트는 &quot;구독한 시점 이후에 발생한 것&quot;만 받을 수 있음</li>
<li>만약 <code>ItemData</code>가 <code>PlayerInventory</code>보다 <strong>먼저</strong> 끝나버리면, <code>PlayerInventory</code>가 핸들러를 등록했을 땐 이미 이벤트가 지나가버린 뒤라 <strong>영원히 못 받는 상황</strong>이 생길 수 있음</li>
</ul>
<p>즉 &quot;이벤트 기반이냐 폴링이냐&quot;가 핵심이 아니라, <strong>&quot;이미 끝난 경우를 어떻게 감지하느냐&quot;</strong>가 진짜 문제였음</p>
<h3 id="5-최종-해결---플래그-우선-확인--이벤트-보완">5. 최종 해결 - 플래그 우선 확인 + 이벤트 보완</h3>
<p>폴링 없이, 놓치는 경우도 없이 처리하기 위해 두 가지를 조합함</p>
<p><strong>ItemData 쪽 — 완료 시 플래그 + 이벤트 둘 다 발행</strong></p>
<pre><code class="language-lua">-- ItemData.OnBeginPlay 마지막
self.isInitialized = true
local event = OnItemDataInitialized()
self:SendInitializedEvent(event)</code></pre>
<p><strong>PlayerInventory 쪽 — 플래그 먼저 확인, 아니면 이벤트로 보완</strong></p>
<pre><code class="language-lua">-- PlayerInventory.OnBeginPlay
if _ItemData.isInitialized then
    -- 이미 끝나 있었던 경우: 이벤트 기다릴 필요 없이 즉시 진행
    self:InitInventory()
end
-- OnItemDataInitialized 이벤트 핸들러도 별도 등록해서
-- 늦게 끝나는 경우까지 커버 (else 분기 불필요)</code></pre>
<p>이 방식이면:</p>
<ul>
<li><code>ItemData</code>가 먼저 끝난 경우 → 플래그가 이미 <code>true</code>이니 즉시 진행</li>
<li><code>PlayerInventory</code>가 먼저 끝난 경우 → 아직 <code>false</code>이므로 이벤트 핸들러가 나중에 잡아줌</li>
<li>어느 쪽이든 <code>wait()</code> 반복 확인 없이 처리됨</li>
</ul>
<h3 id="6-성과와-개선-효과">6. 성과와 개선 효과</h3>
<p><strong>개선 결과</strong></p>
<ul>
<li>✅ <strong>레이스 컨디션 제거</strong>: 실행 순서와 무관하게 항상 안전하게 초기화됨</li>
<li>✅ <strong>폴링 없음</strong>: 지침서 원칙(이벤트 기반 우선) 그대로 유지</li>
<li>✅ <strong>이벤트 구멍 없음</strong>: &quot;이미 끝난 경우&quot;까지 플래그로 커버해서 이벤트만 썼을 때의 함정을 회피</li>
<li>✅ <strong>재사용 가능한 패턴 확립</strong>: 앞으로 다른 초기화 순서 문제에도 동일 패턴 적용 예정</li>
</ul>
<hr>
<h2 id="📚-개발-참고">📚 개발 참고</h2>
<ul>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=942">인벤토리 스크립트 작성하기 — MSW 공식 크리에이터 센터</a>
다른 컴포넌트의 초기화 완료를 기다릴 때 <code>IsInitialized</code> 플래그를 확인하는 예제(<code>ClearInventoryOnBeginPlay</code>)가 실려 있음. 이번 문제의 대응 패턴을 잡을 때 기준으로 삼음</p>
</li>
<li><p><a href="https://maplestoryworlds-creators.nexon.com/ko/apiReference/Components/InventoryComponent">InventoryComponent API Reference — MSW 공식 문서</a>
MSW 내장 인벤토리 시스템도 <code>IsInitialized</code> 프로퍼티 + <code>InventoryItemInitEvent</code> 이벤트를 함께 제공한다는 걸 확인. &quot;플래그+이벤트 조합&quot;이 MSW 생태계에서 실제로 쓰이는 패턴이라는 근거로 참고함</p>
</li>
<li><p><a href="https://velog.io/@eunbileeme/%EB%A9%8B%EC%9F%81%EC%9D%B4%EC%82%AC%EC%9E%90%EC%B2%98%EB%9F%BC-X-%EB%84%A5%EC%8A%A8-MOD-Supporters-Hackathon-2%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0">멋쟁이사자처럼 X 넥슨 MOD Supporters Hackathon 2주차 회고</a> / <a href="https://maplestoryworlds-creators.nexon.com/ko/docs?postId=163">MSW 기본 이벤트 함수 — MSW 공식 문서</a>
이번에 겪은 문제와 비슷한 사례를 코드 예시로 다룸. 공식 문서에서 <code>OnInitialize</code>(엔티티/컴포넌트 생성 직후, <code>OnBeginPlay</code> 이전 1회 호출)를 활용해 참조당하는 쪽(A)은 <code>OnInitialize</code>에서 값을 설정하고, 참조하는 쪽(C)은 <code>OnBeginPlay</code>에서 읽으면 항상 값이 채워져 있음을 보장하는 패턴을 제시함.
하지만 Logic인 <code>ItemData</code>에는 <code>OnInitialize</code>가 없어서 이 대안을 적용할 수 없고, 지금 쓴 &quot;isInitialized 플래그 + OnItemDataInitialized 이벤트&quot; 조합이 Logic 타입 기준으로는 사실상 유일하게 성립하는 해법으로 최종 확정됨.</p>
</li>
</ul>
<h3 id="logic-↔-component-간-초기화-시-체크리스트">Logic ↔ Component 간 초기화 시 체크리스트</h3>
<pre><code class="language-lua">-- (완료를 알리는 쪽)
property boolean isInitialized = false

method void OnBeginPlay()
    -- ... 초기화 로직
    self.isInitialized = true
    local event = OnXxxInitialized()
    self:SendXxxInitializedEvent(event)
end

-- (참조하는 쪽)
method void OnBeginPlay()
    if _Xxx.isInitialized then
        self:DoInit()  -- 이미 끝났으면 즉시 진행
    end
    -- 이벤트 핸들러는 별도 등록, else 분기 불필요
end</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[C++] Set/Map]]></title>
            <link>https://velog.io/@sehee-jj/C-SetMap</link>
            <guid>https://velog.io/@sehee-jj/C-SetMap</guid>
            <pubDate>Fri, 30 Jan 2026 15:22:26 GMT</pubDate>
            <description><![CDATA[<h2 id="📚-c-setmap">📚 C++ Set/Map</h2>
<h3 id="🔸-set-집합">🔸 set (집합)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>Red-Black Tree (레드-블랙 트리)</strong>로 구현</li>
<li><strong>중복 불가</strong> - 각 요소는 유일해야 함</li>
<li><strong>자동 정렬</strong> - 삽입 시 자동으로 정렬된 상태 유지</li>
<li><strong>Balanced BST</strong> 특성상 모든 연산이 O(log n) 보장</li>
</ul>
<p><strong>시간 복잡도</strong></p>
<table>
<thead>
<tr>
<th>연산</th>
<th>시간 복잡도</th>
</tr>
</thead>
<tbody><tr>
<td>Insertion (<code>insert</code>)</td>
<td>O(log n)</td>
</tr>
<tr>
<td>Deletion (<code>erase</code>)</td>
<td>O(log n)</td>
</tr>
<tr>
<td>Search (<code>find</code>)</td>
<td>O(log n)</td>
</tr>
<tr>
<td>Access (최소/최대)</td>
<td>O(log n)</td>
</tr>
</tbody></table>
<p><strong>메모리 구조 (Red-Black Tree)</strong></p>
<pre><code>레드-블랙 트리 속성:
1. 모든 노드는 Red 또는 Black
2. 루트는 항상 Black
3. 모든 리프(NULL)는 Black
4. Red 노드의 자식은 모두 Black
5. 모든 경로의 Black 노드 개수는 동일

예시: {10, 20, 30, 40, 50}
         30(B)
        /     \
     20(B)    40(B)
      /         \
   10(R)       50(R)

Stack:
┌──────────────────┐
│ root (pointer)  │ → 루트 노드
│ size            │ → 요소 개수
└──────────────────┘

Heap (각 노드):
┌────────────────────┐
│ color (R/B)       │
│ data              │
│ left (pointer)    │
│ right (pointer)   │
│ parent (pointer)  │
└────────────────────┘</code></pre><p><strong>사용 예시</strong></p>
<pre><code class="language-cpp">#include &lt;set&gt;
#include &lt;iostream&gt;

std::set&lt;int&gt; s;

// ✅ 삽입 - O(log n)
s.insert(30);
s.insert(10);
s.insert(20);
s.insert(10);  // 중복 - 삽입 안 됨!

// ✅ 자동 정렬됨
for (int val : s) {
    std::cout &lt;&lt; val &lt;&lt; &quot; &quot;;  // 10 20 30
}

// ✅ 검색 - O(log n)
auto it = s.find(20);
if (it != s.end()) {
    std::cout &lt;&lt; &quot;Found: &quot; &lt;&lt; *it;
}

// ✅ 삭제 - O(log n)
s.erase(20);

// ✅ 범위 기반 검색
auto lower = s.lower_bound(15);  // &gt;= 15인 첫 요소
auto upper = s.upper_bound(25);  // &gt; 25인 첫 요소</code></pre>
<p><strong>커스텀 비교 함수</strong></p>
<pre><code class="language-cpp">// 내림차순 정렬
std::set&lt;int, std::greater&lt;int&gt;&gt; descSet;

// 커스텀 비교자
struct Person {
    std::string name;
    int age;
};

struct CompareByAge {
    bool operator()(const Person&amp; a, const Person&amp; b) const {
        return a.age &lt; b.age;
    }
};

std::set&lt;Person, CompareByAge&gt; people;</code></pre>
<hr>
<h3 id="🔸-multiset-중복-허용-집합">🔸 multiset (중복 허용 집합)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><code>set</code>과 동일하게 <strong>Red-Black Tree</strong>로 구현</li>
<li><strong>중복 허용</strong> - 동일한 값을 여러 번 삽입 가능</li>
<li><strong>자동 정렬</strong> 유지</li>
<li>시간 복잡도는 <code>set</code>과 동일</li>
</ul>
<p><strong>set vs multiset</strong></p>
<pre><code class="language-cpp">std::set&lt;int&gt; s;
s.insert(10);
s.insert(10);
s.insert(10);
std::cout &lt;&lt; s.size();  // 1 (중복 무시)

std::multiset&lt;int&gt; ms;
ms.insert(10);
ms.insert(10);
ms.insert(10);
std::cout &lt;&lt; ms.size();  // 3 (중복 허용)</code></pre>
<p><strong>중복 요소 처리</strong></p>
<pre><code class="language-cpp">std::multiset&lt;int&gt; ms = {10, 20, 20, 30, 30, 30};

// ✅ 특정 값의 개수 세기 - O(log n + k), k는 중복 개수
int count = ms.count(30);  // 3

// ✅ 특정 값의 범위 찾기
auto range = ms.equal_range(30);
for (auto it = range.first; it != range.second; ++it) {
    std::cout &lt;&lt; *it &lt;&lt; &quot; &quot;;  // 30 30 30
}

// ✅ 특정 값 하나만 삭제
auto it = ms.find(30);
if (it != ms.end()) {
    ms.erase(it);  // 30 하나만 삭제
}

// ✅ 특정 값 모두 삭제
ms.erase(30);  // 30 모두 삭제</code></pre>
<hr>
<h3 id="🔸-map-키-값-쌍">🔸 map (키-값 쌍)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>Red-Black Tree</strong>로 구현</li>
<li><strong>키(Key)는 중복 불가</strong>, 값(Value)은 중복 가능</li>
<li><strong>키 기준으로 자동 정렬</strong></li>
<li><code>set</code>과 동일한 시간 복잡도</li>
</ul>
<p><strong>시간 복잡도</strong></p>
<table>
<thead>
<tr>
<th>연산</th>
<th>시간 복잡도</th>
</tr>
</thead>
<tbody><tr>
<td>Insertion (<code>insert</code>, <code>operator[]</code>)</td>
<td>O(log n)</td>
</tr>
<tr>
<td>Deletion (<code>erase</code>)</td>
<td>O(log n)</td>
</tr>
<tr>
<td>Search (<code>find</code>, <code>operator[]</code>)</td>
<td>O(log n)</td>
</tr>
</tbody></table>
<p><strong>메모리 구조</strong></p>
<pre><code>Heap (각 노드):
┌────────────────────┐
│ color (R/B)       │
│ pair&lt;Key, Value&gt;  │ ← key와 value를 함께 저장
│ left (pointer)    │
│ right (pointer)   │
│ parent (pointer)  │
└────────────────────┘</code></pre><p><strong>사용 예시</strong></p>
<pre><code class="language-cpp">#include &lt;map&gt;
#include &lt;string&gt;

std::map&lt;std::string, int&gt; ages;

// ✅ 삽입 방법 1: operator[] - O(log n)
ages[&quot;Alice&quot;] = 25;
ages[&quot;Bob&quot;] = 30;

// ✅ 삽입 방법 2: insert - O(log n)
ages.insert({&quot;Charlie&quot;, 35});
ages.insert(std::make_pair(&quot;David&quot;, 40));

// ✅ 검색 - O(log n)
auto it = ages.find(&quot;Alice&quot;);
if (it != ages.end()) {
    std::cout &lt;&lt; it-&gt;first &lt;&lt; &quot;: &quot; &lt;&lt; it-&gt;second;  // Alice: 25
}

// ⚠️ operator[]의 주의점
int age = ages[&quot;Eve&quot;];  // 키가 없으면 자동 생성! (value는 0으로 초기화)
std::cout &lt;&lt; ages.size();  // 5 (Eve가 추가됨)

// ✅ 안전한 검색: at() 사용
try {
    int age = ages.at(&quot;Frank&quot;);  // 키가 없으면 예외 발생
} catch (const std::out_of_range&amp; e) {
    std::cout &lt;&lt; &quot;Key not found!&quot;;
}</code></pre>
<p><strong>iterator 활용</strong></p>
<pre><code class="language-cpp">std::map&lt;std::string, int&gt; scores = {
    {&quot;Alice&quot;, 90},
    {&quot;Bob&quot;, 85},
    {&quot;Charlie&quot;, 95}
};

// ✅ 순회 (키 기준 정렬된 순서)
for (const auto&amp; [name, score] : scores) {
    std::cout &lt;&lt; name &lt;&lt; &quot;: &quot; &lt;&lt; score &lt;&lt; &quot;\n&quot;;
}
// Alice: 90
// Bob: 85
// Charlie: 95</code></pre>
<hr>
<h3 id="🔸-multimap-중복-키-허용">🔸 multimap (중복 키 허용)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><code>map</code>과 동일하게 <strong>Red-Black Tree</strong>로 구현</li>
<li><strong>키 중복 허용</strong></li>
<li><code>operator[]</code> 사용 불가 (어떤 값을 반환할지 모호)</li>
</ul>
<p><strong>사용 예시</strong></p>
<pre><code class="language-cpp">std::multimap&lt;std::string, int&gt; scores;

scores.insert({&quot;Alice&quot;, 90});
scores.insert({&quot;Alice&quot;, 85});  // 같은 키에 다른 값
scores.insert({&quot;Bob&quot;, 95});

// ✅ 특정 키의 모든 값 찾기
auto range = scores.equal_range(&quot;Alice&quot;);
for (auto it = range.first; it != range.second; ++it) {
    std::cout &lt;&lt; it-&gt;second &lt;&lt; &quot; &quot;;  // 85 90 (정렬됨)
}</code></pre>
<hr>
<h3 id="🔸-unordered_set-해시-집합">🔸 unordered_set (해시 집합)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>Hash Table</strong>로 구현</li>
<li><strong>중복 불가</strong></li>
<li><strong>정렬되지 않음</strong> (순서 보장 안 됨)</li>
<li>평균 O(1), 최악 O(n) 시간 복잡도</li>
</ul>
<p><strong>시간 복잡도</strong></p>
<table>
<thead>
<tr>
<th>연산</th>
<th>평균</th>
<th>최악</th>
</tr>
</thead>
<tbody><tr>
<td>Insertion</td>
<td>O(1)</td>
<td>O(n)</td>
</tr>
<tr>
<td>Deletion</td>
<td>O(1)</td>
<td>O(n)</td>
</tr>
<tr>
<td>Search</td>
<td>O(1)</td>
<td>O(n)</td>
</tr>
</tbody></table>
<p><strong>메모리 구조 (Hash Table with Separate Chaining)</strong></p>
<pre><code>Hash Table 구조:

해시 함수: hash(value) % bucket_count

Bucket Array (vector):
┌─────┬─────┬─────┬─────┬─────┐
│  0 │  1  │  2  │  3  │  4  │
└──┬──┴──┬──┴──┬──┴──┬──┴──┬──┘
   │     │     │     │     │
   ↓     ↓     ↓     ↓     ↓
  [10]  [21]  NULL [13]  [24]
   ↓     ↓           ↓     ↓
  [30]  NULL        [33]  NULL

각 버킷은 Linked List로 구현 (Separate Chaining)
해시 충돌 발생 시 같은 버킷에 연결

Stack:
┌──────────────────┐
│ buckets (array) │ → 버킷 배열
│ size            │ → 요소 개수
│ bucket_count    │ → 버킷 개수
│ max_load_factor │ → 최대 로드 팩터 (기본 1.0)
└──────────────────┘</code></pre><p><strong>해시 함수와 충돌 처리</strong></p>
<pre><code class="language-cpp">// 해시 값 계산 예시
hash&lt;int&gt; hashFunc;
size_t hashValue = hashFunc(42);

// 버킷 인덱스 = hash(value) % bucket_count
size_t bucketIndex = hashValue % bucket_count;

// ✅ Separate Chaining
// 같은 버킷에 여러 요소가 linked list로 연결됨</code></pre>
<p><strong>Load Factor와 Rehashing</strong></p>
<pre><code class="language-cpp">std::unordered_set&lt;int&gt; us;

// Load Factor = size / bucket_count
// 기본 max_load_factor = 1.0

us.insert(10);
us.insert(20);
us.insert(30);

std::cout &lt;&lt; &quot;Size: &quot; &lt;&lt; us.size() &lt;&lt; &quot;\n&quot;;
std::cout &lt;&lt; &quot;Bucket count: &quot; &lt;&lt; us.bucket_count() &lt;&lt; &quot;\n&quot;;
std::cout &lt;&lt; &quot;Load factor: &quot; &lt;&lt; us.load_factor() &lt;&lt; &quot;\n&quot;;
std::cout &lt;&lt; &quot;Max load factor: &quot; &lt;&lt; us.max_load_factor() &lt;&lt; &quot;\n&quot;;

// ⚠️ load_factor &gt; max_load_factor 되면 Rehashing 발생!
// Rehashing: O(n) - 모든 요소를 새 버킷에 재배치</code></pre>
<p><strong>Rehashing 최적화</strong></p>
<pre><code class="language-cpp">std::unordered_set&lt;int&gt; us;

// ✅ 미리 버킷 예약 - rehashing 방지
us.reserve(1000);  // 최소 1000개 요소를 담을 수 있도록 버킷 확보

// ✅ max_load_factor 조정
us.max_load_factor(0.5);  // 로드 팩터를 낮춰서 충돌 감소 (메모리 trade-off)</code></pre>
<p><strong>사용 예시</strong></p>
<pre><code class="language-cpp">#include &lt;unordered_set&gt;

std::unordered_set&lt;int&gt; us;

// ✅ 삽입 - 평균 O(1)
us.insert(10);
us.insert(20);
us.insert(30);

// ✅ 검색 - 평균 O(1)
if (us.find(20) != us.end()) {
    std::cout &lt;&lt; &quot;Found!&quot;;
}

// ⚠️ 순서 보장 안 됨!
for (int val : us) {
    std::cout &lt;&lt; val &lt;&lt; &quot; &quot;;  // 순서 예측 불가
}</code></pre>
<p><strong>커스텀 타입을 위한 Hash Function 정의</strong></p>
<p>방법 1: <strong>Hash Function Object 생성</strong></p>
<pre><code class="language-cpp">struct Person {
    std::string name;
    int age;

    bool operator==(const Person&amp; other) const {
        return name == other.name &amp;&amp; age == other.age;
    }
};

// 커스텀 해시 함수 객체
struct PersonHash {
    size_t operator()(const Person&amp; p) const {
        // 여러 필드를 조합한 해시 값 계산
        size_t h1 = std::hash&lt;std::string&gt;{}(p.name);
        size_t h2 = std::hash&lt;int&gt;{}(p.age);
        return h1 ^ (h2 &lt;&lt; 1);  // XOR과 shift 조합
    }
};

// 사용
std::unordered_set&lt;Person, PersonHash&gt; people;
people.insert({&quot;Alice&quot;, 25});</code></pre>
<p>방법 2: <strong>std 네임스페이스에 특수화 주입</strong></p>
<pre><code class="language-cpp">struct Person {
    std::string name;
    int age;

    bool operator==(const Person&amp; other) const {
        return name == other.name &amp;&amp; age == other.age;
    }
};

// std::hash 특수화
namespace std {
    template&lt;&gt;
    struct hash&lt;Person&gt; {
        size_t operator()(const Person&amp; p) const {
            size_t h1 = hash&lt;string&gt;{}(p.name);
            size_t h2 = hash&lt;int&gt;{}(p.age);
            return h1 ^ (h2 &lt;&lt; 1);
        }
    };
}

// 사용 (추가 템플릿 인자 불필요)
std::unordered_set&lt;Person&gt; people;
people.insert({&quot;Alice&quot;, 25});</code></pre>
<p><strong>커스텀 Equality Operator</strong></p>
<pre><code class="language-cpp">// 방법 1: 클래스 내부에 operator== 정의 (위 예시 참고)

// 방법 2: 별도의 함수 객체
struct PersonEqual {
    bool operator()(const Person&amp; a, const Person&amp; b) const {
        return a.name == b.name &amp;&amp; a.age == b.age;
    }
};

std::unordered_set&lt;Person, PersonHash, PersonEqual&gt; people;</code></pre>
<hr>
<h3 id="🔸-unordered_map-해시-맵">🔸 unordered_map (해시 맵)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>Hash Table</strong>로 구현</li>
<li><strong>키 중복 불가</strong></li>
<li><code>unordered_set</code>과 동일한 시간 복잡도</li>
<li><strong>실무에서 가장 많이 사용됨</strong></li>
<li><strong>알고리즘 면접에서 자주 출제됨</strong></li>
</ul>
<p><strong>시간 복잡도</strong></p>
<table>
<thead>
<tr>
<th>연산</th>
<th>평균</th>
<th>최악</th>
</tr>
</thead>
<tbody><tr>
<td>Insertion (<code>insert</code>, <code>operator[]</code>)</td>
<td>O(1)</td>
<td>O(n)</td>
</tr>
<tr>
<td>Deletion (<code>erase</code>)</td>
<td>O(1)</td>
<td>O(n)</td>
</tr>
<tr>
<td>Search (<code>find</code>, <code>operator[]</code>)</td>
<td>O(1)</td>
<td>O(n)</td>
</tr>
</tbody></table>
<p><strong>메모리 구조</strong></p>
<pre><code>Bucket Array:
┌──────┬──────┬──────┬──────┐
│  0   │  1  │  2   │  3  │
└───┬──┴──┬───┴──┬───┴──┬───┘
   │     │      │      │
   ↓     ↓      ↓      ↓
 {k1:v1} NULL {k2:v2} NULL
   ↓                   ↓
 {k3:v3}             {k4:v4}

각 노드는 pair&lt;Key, Value&gt; 저장</code></pre><p><strong>사용 예시</strong></p>
<pre><code class="language-cpp">#include &lt;unordered_map&gt;
#include &lt;string&gt;

std::unordered_map&lt;std::string, int&gt; phoneBook;

// ✅ 삽입 - 평균 O(1)
phoneBook[&quot;Alice&quot;] = 1234;
phoneBook[&quot;Bob&quot;] = 5678;
phoneBook.insert({&quot;Charlie&quot;, 9012});

// ✅ 검색 - 평균 O(1)
auto it = phoneBook.find(&quot;Alice&quot;);
if (it != phoneBook.end()) {
    std::cout &lt;&lt; it-&gt;first &lt;&lt; &quot;: &quot; &lt;&lt; it-&gt;second;
}

// ✅ operator[] - 평균 O(1)
int number = phoneBook[&quot;Alice&quot;];  // 1234

// ⚠️ 키가 없으면 자동 생성
int unknown = phoneBook[&quot;David&quot;];  // David 키 생성, value = 0

// ✅ 성능 최적화
phoneBook.reserve(10000);  // 예상 크기 미리 예약</code></pre>
<p><strong>실무 활용 예시</strong></p>
<pre><code class="language-cpp">// 빈도수 카운팅
std::unordered_map&lt;char, int&gt; freq;
std::string text = &quot;hello world&quot;;
for (char c : text) {
    freq[c]++;
}

// 캐싱 (Memoization)
std::unordered_map&lt;int, int&gt; cache;
int fibonacci(int n) {
    if (n &lt;= 1) return n;
    if (cache.find(n) != cache.end()) {
        return cache[n];
    }
    cache[n] = fibonacci(n-1) + fibonacci(n-2);
    return cache[n];
}

// 그래프 인접 리스트
std::unordered_map&lt;int, std::vector&lt;int&gt;&gt; graph;
graph[1] = {2, 3};
graph[2] = {4};</code></pre>
<p><strong>커스텀 타입 예시</strong></p>
<pre><code class="language-cpp">struct Point {
    int x, y;
    bool operator==(const Point&amp; other) const {
        return x == other.x &amp;&amp; y == other.y;
    }
};

struct PointHash {
    size_t operator()(const Point&amp; p) const {
        return std::hash&lt;int&gt;{}(p.x) ^ (std::hash&lt;int&gt;{}(p.y) &lt;&lt; 1);
    }
};

std::unordered_map&lt;Point, std::string, PointHash&gt; locations;
locations[{0, 0}] = &quot;Origin&quot;;
locations[{10, 20}] = &quot;Point A&quot;;</code></pre>
<hr>
<h3 id="🔸-unordered_multiset--unordered_multimap">🔸 unordered_multiset / unordered_multimap</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li>각각 <code>unordered_set</code>, <code>unordered_map</code>의 중복 허용 버전</li>
<li>시간 복잡도 동일</li>
<li><code>unordered_multimap</code>은 <code>operator[]</code> 사용 불가</li>
</ul>
<p><strong>사용 예시</strong></p>
<pre><code class="language-cpp">std::unordered_multiset&lt;int&gt; ums = {10, 20, 20, 30, 30, 30};

// 특정 값의 개수
int count = ums.count(30);  // 3

// 특정 값 모두 찾기
auto range = ums.equal_range(30);
for (auto it = range.first; it != range.second; ++it) {
    std::cout &lt;&lt; *it &lt;&lt; &quot; &quot;;
}</code></pre>
<hr>
<h3 id="🔸-set-vs-unordered_set-비교">🔸 set vs unordered_set 비교</h3>
<p><strong>성능 비교</strong></p>
<table>
<thead>
<tr>
<th>특성</th>
<th>set</th>
<th>unordered_set</th>
</tr>
</thead>
<tbody><tr>
<td><strong>구현</strong></td>
<td>Red-Black Tree</td>
<td>Hash Table</td>
</tr>
<tr>
<td><strong>정렬</strong></td>
<td>✅ 자동 정렬</td>
<td>❌ 정렬 안 됨</td>
</tr>
<tr>
<td><strong>평균 검색</strong></td>
<td>O(log n)</td>
<td>O(1) ✅</td>
</tr>
<tr>
<td><strong>최악 검색</strong></td>
<td>O(log n) ✅</td>
<td>O(n)</td>
</tr>
<tr>
<td><strong>메모리</strong></td>
<td>적음</td>
<td>많음 (버킷 배열)</td>
</tr>
<tr>
<td><strong>순서 보장</strong></td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td><strong>iterator 안정성</strong></td>
<td>높음</td>
<td>낮음 (rehashing 시 무효화)</td>
</tr>
</tbody></table>
<p><strong>선택 가이드</strong></p>
<pre><code class="language-cpp">// ✅ set을 사용해야 하는 경우:
// 1. 정렬된 순서가 필요할 때
std::set&lt;int&gt; sorted = {30, 10, 20};
for (int val : sorted) {
    std::cout &lt;&lt; val &lt;&lt; &quot; &quot;;  // 10 20 30 (정렬됨)
}

// 2. 범위 검색이 필요할 때
auto lower = sorted.lower_bound(15);
auto upper = sorted.upper_bound(25);

// 3. 최악의 경우에도 일정한 성능이 필요할 때 (O(log n) 보장)

// ✅ unordered_set을 사용해야 하는 경우:
// 1. 빠른 검색이 최우선일 때 (평균 O(1))
std::unordered_set&lt;int&gt; fast;

// 2. 순서가 중요하지 않을 때

// 3. 데이터 크기를 미리 알고 reserve 가능할 때
fast.reserve(10000);</code></pre>
<p><strong>map vs unordered_map 비교</strong></p>
<table>
<thead>
<tr>
<th>특성</th>
<th>map</th>
<th>unordered_map</th>
</tr>
</thead>
<tbody><tr>
<td><strong>구현</strong></td>
<td>Red-Black Tree</td>
<td>Hash Table</td>
</tr>
<tr>
<td><strong>키 정렬</strong></td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td><strong>평균 검색</strong></td>
<td>O(log n)</td>
<td>O(1) ✅</td>
</tr>
<tr>
<td><strong>최악 검색</strong></td>
<td>O(log n) ✅</td>
<td>O(n)</td>
</tr>
<tr>
<td><strong>실무 사용 빈도</strong></td>
<td>중간</td>
<td>✅ 매우 높음</td>
</tr>
</tbody></table>
<hr>
<h3 id="🔸-hash-function-설계-원칙">🔸 Hash Function 설계 원칙</h3>
<p><strong>좋은 해시 함수의 조건</strong></p>
<ol>
<li><strong>결정론적 (Deterministic)</strong>: 같은 입력은 항상 같은 해시 값</li>
<li><strong>균등 분포 (Uniform Distribution)</strong>: 해시 값이 고르게 분포</li>
<li><strong>효율성 (Efficient)</strong>: 계산이 빠름 (O(1))</li>
<li><strong>충돌 최소화</strong>: 서로 다른 입력에 대해 다른 해시 값</li>
</ol>
<p><strong>해시 조합 기법</strong></p>
<pre><code class="language-cpp">// ❌ 나쁜 예: 단순 더하기 (충돌 많음)
size_t badHash = hash&lt;int&gt;{}(x) + hash&lt;int&gt;{}(y);

// ✅ 좋은 예 1: XOR + Shift
size_t goodHash1 = hash&lt;int&gt;{}(x) ^ (hash&lt;int&gt;{}(y) &lt;&lt; 1);

// ✅ 좋은 예 2: Boost 스타일
size_t goodHash2 = hash&lt;int&gt;{}(x);
goodHash2 ^= hash&lt;int&gt;{}(y) + 0x9e3779b9 + (goodHash2 &lt;&lt; 6) + (goodHash2 &gt;&gt; 2);

// ✅ 좋은 예 3: 여러 필드 조합
struct Data {
    int a, b, c;
};

size_t hash_value(const Data&amp; d) {
    size_t seed = 0;
    seed ^= hash&lt;int&gt;{}(d.a) + 0x9e3779b9 + (seed &lt;&lt; 6) + (seed &gt;&gt; 2);
    seed ^= hash&lt;int&gt;{}(d.b) + 0x9e3779b9 + (seed &lt;&lt; 6) + (seed &gt;&gt; 2);
    seed ^= hash&lt;int&gt;{}(d.c) + 0x9e3779b9 + (seed &lt;&lt; 6) + (seed &gt;&gt; 2);
    return seed;
}</code></pre>
<hr>
<h3 id="🔸-핵심-정리">🔸 핵심 정리</h3>
<p><strong>컨테이너 선택 가이드</strong></p>
<pre><code>순서가 필요한가?
├─ YES → set / map
│         - 정렬된 순서 보장
│         - 범위 검색 가능
│         - O(log n) 성능 보장
│
└─ NO → unordered_set / unordered_map
          - 평균 O(1) 성능
          - 메모리 사용량 높음
          - rehashing 주의

중복이 필요한가?
├─ YES → multiset / multimap / unordered_multiset / unordered_multimap
└─ NO → set / map / unordered_set / unordered_map

키-값 쌍이 필요한가?
├─ YES → map / unordered_map
└─ NO → set / unordered_set</code></pre><p><strong>성능 최적화 팁</strong></p>
<ol>
<li><p><strong>unordered_* 사용 시</strong>:</p>
<ul>
<li>예상 크기를 아는 경우 <code>reserve()</code> 사용</li>
<li><code>max_load_factor</code> 조정으로 충돌 제어</li>
<li>좋은 해시 함수 설계</li>
</ul>
</li>
<li><p><strong>set/map 사용 시</strong>:</p>
<ul>
<li>커스텀 비교 함수로 정렬 순서 제어</li>
<li><code>lower_bound</code>, <code>upper_bound</code> 활용</li>
</ul>
</li>
<li><p><strong>일반적인 권장사항</strong>:</p>
<ul>
<li>대부분의 경우: <code>unordered_map</code> (실무 표준)</li>
<li>정렬이 필요한 경우: <code>map</code></li>
<li>중복이 필요한 경우: <code>multiset</code> / <code>multimap</code></li>
</ul>
</li>
</ol>
<p><strong>실무에서 자주 쓰이는 패턴</strong></p>
<pre><code class="language-cpp">// 1. 빈도수 카운팅
unordered_map&lt;string, int&gt; wordCount;

// 2. 중복 제거
unordered_set&lt;int&gt; uniqueValues;

// 3. 캐싱/메모이제이션
unordered_map&lt;int, Result&gt; cache;

// 4. 그래프 표현
unordered_map&lt;Node, vector&lt;Node&gt;&gt; adjacencyList;

// 5. 빠른 조회 테이블
unordered_map&lt;string, UserData&gt; userDatabase;</code></pre>
<hr>
<h5 id="참고-자료">&lt;참고 자료&gt;</h5>
<p>코드없는 프로그래밍
<a href="https://en.cppreference.com/w/cpp/container/set">std::set</a>
<a href="https://en.cppreference.com/w/cpp/container/map">std::map</a>
<a href="https://en.cppreference.com/w/cpp/container/unordered_set">std::unordered_set</a>
<a href="https://en.cppreference.com/w/cpp/container/unordered_map">std::unordered_map</a>
<a href="https://en.wikipedia.org/wiki/Red%E2%80%93black_tree">Red-Black Tree</a>
<a href="https://en.wikipedia.org/wiki/Hash_table">Hash Table</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[C++] 컨테이너 (Containers)]]></title>
            <link>https://velog.io/@sehee-jj/C-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-Containers</link>
            <guid>https://velog.io/@sehee-jj/C-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-Containers</guid>
            <pubDate>Wed, 21 Jan 2026 05:39:50 GMT</pubDate>
            <description><![CDATA[<h2 id="📚-c-컨테이너-containers">📚 C++ 컨테이너 (Containers)</h2>
<h3 id="🔸-list-이중-연결-리스트">🔸 list (이중 연결 리스트)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>Doubly-Linked List</strong>로 구현</li>
<li>각 노드는 이전/다음 노드를 가리키는 두 개의 포인터 보유</li>
<li>Insertion/Deletion: <strong>O(1)</strong> (위치를 알고 있을 때)</li>
<li>Random Access: <strong>O(n)</strong> (순차 탐색 필요)</li>
</ul>
<p><strong>메모리 구조</strong></p>
<pre><code>Stack:
┌──────────────────┐
│ first (pointer) │ → 첫 번째 노드
│ last (pointer)  │ → 마지막 노드
│ size            │ → 요소 개수
└──────────────────┘

Heap:
┌────┬────┬────┐    ┌────┬────┬────┐    ┌────┬────┬────┐
│prev│data│next│ ↔ │prev│data│next│ ↔ │prev│data│next│
└────┴────┴────┘    └────┴────┴────┘    └────┴────┴────┘</code></pre><ul>
<li>스택에는 <strong>first 포인터</strong>, <strong>last 포인터</strong>, <strong>size 정보</strong> 저장</li>
<li>실제 데이터는 <strong>힙(Heap)</strong>에 동적 할당</li>
</ul>
<p><strong>정렬</strong></p>
<pre><code class="language-cpp">std::list&lt;int&gt; myList = {3, 1, 4, 1, 5};

// ✅ list 전용 sort() 멤버 함수 사용 (O(n log n))
myList.sort();  

// ❌ std::sort() 사용 불가 (Random Access Iterator 필요)
// std::sort(myList.begin(), myList.end());  // 컴파일 에러!</code></pre>
<p><strong>주요 연산 시간 복잡도</strong></p>
<table>
<thead>
<tr>
<th>연산</th>
<th>시간 복잡도</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>Insertion/Deletion (위치를 알 때)</td>
<td>O(1)</td>
<td>중간 삽입/삭제 효율적</td>
</tr>
<tr>
<td>Random Access (<code>[]</code>, <code>at()</code>)</td>
<td>불가능</td>
<td>순차 접근만 가능</td>
</tr>
<tr>
<td>Search (find)</td>
<td>O(n)</td>
<td>순차 탐색</td>
</tr>
<tr>
<td>Sort</td>
<td>O(n log n)</td>
<td>멤버 함수 <code>sort()</code> 사용</td>
</tr>
</tbody></table>
<hr>
<h3 id="🔸-forward_list-단일-연결-리스트">🔸 forward_list (단일 연결 리스트)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>Singly-Linked List</strong>로 구현</li>
<li>각 노드는 다음 노드만 가리킴 (이전 노드 포인터 없음)</li>
<li><code>list</code>보다 메모리 효율적 (포인터 하나만 필요)</li>
<li>Random Access: <strong>O(n)</strong></li>
</ul>
<p><strong>메모리 구조</strong></p>
<pre><code>Stack:
┌──────────────────┐
│ front (pointer) │ → 첫 번째 노드
└──────────────────┘

Heap:
┌────┬────┐    ┌────┬────┐    ┌────┬────┐
│data│next│ → │data│next│ → │data│next│ → nullptr
└────┴────┘    └────┴────┘    └────┴────┘</code></pre><ul>
<li>스택에는 <strong>front 포인터</strong> 하나만 저장</li>
<li>last 포인터나 size 정보는 저장하지 않음 (메모리 절약)</li>
</ul>
<p><strong>제한 사항</strong></p>
<pre><code class="language-cpp">std::forward_list&lt;int&gt; fList = {1, 2, 3, 4, 5};

// ✅ 가능한 연산
fList.push_front(0);
fList.pop_front();
fList.insert_after(fList.begin(), 10);

// ❌ 불가능한 연산
// fList.push_back(6);   // back 포인터 없음
// fList.size();         // size 저장 안 함 (O(n)으로 계산 가능)
// fList[2];             // Random Access 불가</code></pre>
<hr>
<h3 id="🔸-vector-vs-list-vs-forward_list">🔸 vector vs list vs forward_list</h3>
<p><strong>성능 비교</strong></p>
<table>
<thead>
<tr>
<th>연산</th>
<th>vector</th>
<th>list</th>
<th>forward_list</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Random Access</strong></td>
<td>O(1) ✅</td>
<td>O(n) ❌</td>
<td>O(n) ❌</td>
</tr>
<tr>
<td><strong>Front Insertion/Deletion</strong></td>
<td>O(n)</td>
<td>O(1) ✅</td>
<td>O(1) ✅</td>
</tr>
<tr>
<td><strong>Back Insertion/Deletion</strong></td>
<td>O(1) ✅</td>
<td>O(1) ✅</td>
<td>O(n) ❌</td>
</tr>
<tr>
<td><strong>Middle Insertion/Deletion</strong></td>
<td>O(n)</td>
<td>O(1) ✅</td>
<td>O(1) ✅</td>
</tr>
<tr>
<td><strong>find()</strong></td>
<td>O(n)</td>
<td>O(n)</td>
<td>O(n)</td>
</tr>
<tr>
<td><strong>메모리 연속성</strong></td>
<td>연속 ✅</td>
<td>분산</td>
<td>분산</td>
</tr>
<tr>
<td><strong>캐시 효율</strong></td>
<td>높음 ✅</td>
<td>낮음</td>
<td>낮음</td>
</tr>
</tbody></table>
<p><strong>실제 find() 성능</strong></p>
<pre><code class="language-cpp">// 둘 다 O(n)이지만 실제로는 vector가 훨씬 빠름!
std::vector&lt;int&gt; vec(1000000);
std::list&lt;int&gt; lst(1000000);

// vector의 find: 메모리가 연속적 → 캐시 히트율 높음 ✅
auto it1 = std::find(vec.begin(), vec.end(), 999999);

// list의 find: 메모리가 분산 → 캐시 미스 많음 ❌
auto it2 = std::find(lst.begin(), lst.end(), 999999);</code></pre>
<p><strong>왜 vector가 더 빠를까? (캐시 지역성)</strong></p>
<ul>
<li>CPU는 메모리에서 데이터를 가져올 때 <strong>캐시 라인</strong> 단위로 가져옴</li>
<li><strong>vector</strong>: 메모리가 연속 → 한 번에 여러 요소를 캐시에 로드 ✅</li>
<li><strong>list</strong>: 메모리가 분산 → 각 노드마다 캐시 미스 발생 ❌</li>
</ul>
<p><strong>병렬 프로그래밍에서의 문제</strong></p>
<pre><code class="language-cpp">// ❌ list는 병렬화가 어려움
// 각 노드가 분산되어 있어서 동시 접근 시 동기화 오버헤드 큼
std::list&lt;int&gt; lst;

// ✅ vector는 병렬화가 용이함
// 메모리가 연속적이라 분할하기 쉬움
std::vector&lt;int&gt; vec;
#pragma omp parallel for
for (int i = 0; i &lt; vec.size(); i++) {
    // 병렬 처리
}</code></pre>
<p><strong>선택 가이드</strong></p>
<ul>
<li><strong>대부분의 경우</strong>: <code>vector</code> 사용 ✅</li>
<li><strong>중간 삽입/삭제가 빈번</strong>: <code>list</code> 고려</li>
<li><strong>메모리 최소화 + 단방향만 필요</strong>: <code>forward_list</code></li>
</ul>
<hr>
<h3 id="🔸-stack-스택">🔸 stack (스택)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>LIFO (Last In First Out)</strong> 구조</li>
<li>Container Adapter (다른 컨테이너를 감싸서 구현)</li>
<li>기본 컨테이너: <code>deque</code> (변경 가능)</li>
</ul>
<p><strong>지원 컨테이너</strong></p>
<pre><code class="language-cpp">// ✅ 기본 (deque)
std::stack&lt;int&gt; s1;

// ✅ vector로 구현
std::stack&lt;int, std::vector&lt;int&gt;&gt; s2;

// ✅ list로 구현
std::stack&lt;int, std::list&lt;int&gt;&gt; s3;

// ❌ forward_list는 불가 (back 접근 필요)
// std::stack&lt;int, std::forward_list&lt;int&gt;&gt; s4;  // 컴파일 에러!</code></pre>
<p><strong>주요 연산</strong></p>
<pre><code class="language-cpp">std::stack&lt;int&gt; s;

s.push(10);     // O(1) - 삽입
s.pop();        // O(1) - 제거 (값 반환 안 함!)
int top = s.top();  // O(1) - 최상단 접근
bool empty = s.empty();  // O(1)
size_t sz = s.size();    // O(1)</code></pre>
<p><strong>성능 최적화가 필요한 경우</strong></p>
<pre><code class="language-cpp">// 직접 구현 예시 (vector 기반)
template&lt;typename T&gt;
class FastStack {
private:
    std::vector&lt;T&gt; data;
public:
    void push(const T&amp; value) {
        data.push_back(value);
    }
    void pop() {
        data.pop_back();
    }
    T&amp; top() {
        return data.back();
    }
    // ...
};</code></pre>
<hr>
<h3 id="🔸-queue-큐">🔸 queue (큐)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>FIFO (First In First Out)</strong> 구조</li>
<li>Container Adapter</li>
<li>기본 컨테이너: <code>deque</code> (변경 가능)</li>
</ul>
<p><strong>지원 컨테이너</strong></p>
<pre><code class="language-cpp">// ✅ 기본 (deque)
std::queue&lt;int&gt; q1;

// ✅ list로 구현
std::queue&lt;int, std::list&lt;int&gt;&gt; q2;

// ❌ vector는 불가 (front에서 pop이 O(n))
// std::queue&lt;int, std::vector&lt;int&gt;&gt; q3;  // 컴파일 에러!

// ❌ forward_list는 불가 (back 접근 필요)
// std::queue&lt;int, std::forward_list&lt;int&gt;&gt; q4;  // 컴파일 에러!</code></pre>
<p><strong>주요 연산</strong></p>
<pre><code class="language-cpp">std::queue&lt;int&gt; q;

q.push(10);     // O(1) - 뒤에 삽입
q.pop();        // O(1) - 앞에서 제거
int front = q.front();  // O(1) - 앞 요소 접근
int back = q.back();    // O(1) - 뒤 요소 접근
bool empty = q.empty(); // O(1)
size_t sz = q.size();   // O(1)</code></pre>
<hr>
<h3 id="🔸-priority_queue-우선순위-큐">🔸 priority_queue (우선순위 큐)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><strong>Max Heap</strong>으로 구현 (기본값)</li>
<li>가장 큰 원소가 항상 top에 위치</li>
<li>Container Adapter (기본: <code>vector</code>)</li>
</ul>
<p><strong>시간 복잡도</strong></p>
<table>
<thead>
<tr>
<th>연산</th>
<th>시간 복잡도</th>
</tr>
</thead>
<tbody><tr>
<td>Insertion (<code>push</code>)</td>
<td>O(log n)</td>
</tr>
<tr>
<td>Extraction (<code>pop</code>)</td>
<td>O(log n)</td>
</tr>
<tr>
<td>Top 접근 (<code>top</code>)</td>
<td>O(1) ✅</td>
</tr>
</tbody></table>
<p><strong>힙 구조 (배열 기반)</strong></p>
<pre><code>인덱스:  0   1   2   3   4   5   6
값:    [100, 50, 80, 30, 20, 60, 70]

트리 구조:
           100 (idx=0)
          /   \
        50     80
       /  \   /  \
      30  20 60  70

부모-자식 관계:
- 부모 노드: (idx - 1) / 2
- 왼쪽 자식: 2 * idx + 1
- 오른쪽 자식: 2 * idx + 2</code></pre><p><strong>힙 속성</strong></p>
<ul>
<li><strong>Max Heap</strong>: 부모 노드 ≥ 자식 노드</li>
<li><strong>Min Heap</strong>: 부모 노드 ≤ 자식 노드</li>
</ul>
<p><strong>사용 예시</strong></p>
<pre><code class="language-cpp">#include &lt;queue&gt;

// ✅ Max Heap (기본)
std::priority_queue&lt;int&gt; maxHeap;
maxHeap.push(30);
maxHeap.push(100);
maxHeap.push(50);
std::cout &lt;&lt; maxHeap.top();  // 100

// ✅ Min Heap (greater 사용)
std::priority_queue&lt;int, std::vector&lt;int&gt;, std::greater&lt;int&gt;&gt; minHeap;
minHeap.push(30);
minHeap.push(100);
minHeap.push(50);
std::cout &lt;&lt; minHeap.top();  // 30

// ✅ 커스텀 비교 함수
auto cmp = [](int a, int b) { return a &gt; b; };  // Min Heap
std::priority_queue&lt;int, std::vector&lt;int&gt;, decltype(cmp)&gt; customHeap(cmp);</code></pre>
<p><strong>인덱스 계산 예시</strong></p>
<pre><code class="language-cpp">// 인덱스 3의 부모: (3 - 1) / 2 = 1
// 인덱스 1의 왼쪽 자식: 2 * 1 + 1 = 3
// 인덱스 1의 오른쪽 자식: 2 * 1 + 2 = 4</code></pre>
<hr>
<h3 id="🔸-heap-알고리즘-algorithm-library">🔸 heap 알고리즘 (Algorithm Library)</h3>
<p><strong>기본 특징</strong></p>
<ul>
<li><code>&lt;algorithm&gt;</code> 헤더에 포함된 힙 관련 함수들</li>
<li>기존 컨테이너(주로 <code>vector</code>)를 힙으로 변환/관리</li>
<li><code>priority_queue</code>와 동일한 내부 구조</li>
</ul>
<p><strong>주요 함수</strong></p>
<table>
<thead>
<tr>
<th>함수</th>
<th>시간 복잡도</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>make_heap</code></td>
<td>O(n)</td>
<td>범위를 힙으로 변환</td>
</tr>
<tr>
<td><code>push_heap</code></td>
<td>O(log n)</td>
<td>마지막 요소를 힙에 삽입</td>
</tr>
<tr>
<td><code>pop_heap</code></td>
<td>O(log n)</td>
<td>최대값을 마지막으로 이동</td>
</tr>
</tbody></table>
<p><strong>사용 예시</strong></p>
<pre><code class="language-cpp">#include &lt;algorithm&gt;
#include &lt;vector&gt;

std::vector&lt;int&gt; vec = {30, 100, 50, 20, 80};

// ✅ 힙 생성 - O(n)
std::make_heap(vec.begin(), vec.end());
// vec: [100, 80, 50, 20, 30] (Max Heap)

// ✅ 최대값 확인
std::cout &lt;&lt; vec.front();  // 100 (O(1))

// ✅ 최대값 제거
std::pop_heap(vec.begin(), vec.end());  // O(log n)
// 최대값을 맨 뒤로 이동
// vec: [80, 30, 50, 20, 100]
vec.pop_back();  // 실제 제거 - O(1)
// vec: [80, 30, 50, 20]

// ✅ 새 요소 추가
vec.push_back(90);  // O(1)
std::push_heap(vec.begin(), vec.end());  // O(log n)
// vec: [90, 80, 50, 20, 30]</code></pre>
<p><strong>make_heap이 O(n)인 이유</strong></p>
<pre><code>Naive 방식 (위에서 아래로): O(n log n)
- 각 요소를 하나씩 삽입: n번
- 각 삽입마다 heapify: O(log n)
- 총 시간: O(n log n)

Floyd 알고리즘 (아래에서 위로): O(n) ✅
- 리프 노드는 이미 힙 속성 만족
- 아래에서 위로 올라가며 heapify
- 높이 h인 노드 개수: n / 2^(h+1)
- 각 노드의 작업량: O(h)
- 총 시간: Σ(n / 2^(h+1) * h) = O(n)</code></pre><p><strong>priority_queue vs heap 알고리즘</strong></p>
<pre><code class="language-cpp">// priority_queue: 편리하지만 유연성 낮음
std::priority_queue&lt;int&gt; pq;
pq.push(10);
// 내부 데이터 직접 접근 불가

// heap 알고리즘: 유연하지만 수동 관리 필요
std::vector&lt;int&gt; vec = {10, 20, 30};
std::make_heap(vec.begin(), vec.end());
// 벡터에 직접 접근 가능 ✅
vec[0];  // 최대값 확인</code></pre>
<p><strong>언제 heap 알고리즘을 사용할까?</strong></p>
<ul>
<li>기존 컨테이너를 힙으로 변환해야 할 때</li>
<li>힙 내부 데이터에 직접 접근이 필요할 때</li>
<li>부분 정렬이 필요할 때 (Top K 문제 등)</li>
</ul>
<hr>
<h3 id="🔸-핵심-정리">🔸 핵심 정리</h3>
<p><strong>컨테이너 선택 가이드</strong></p>
<ol>
<li><strong>대부분의 경우</strong>: <code>vector</code> 사용 (캐시 효율, 랜덤 액세스)</li>
<li><strong>중간 삽입/삭제 빈번</strong>: <code>list</code> 고려</li>
<li><strong>메모리 최소화</strong>: <code>forward_list</code></li>
<li><strong>LIFO 필요</strong>: <code>stack</code></li>
<li><strong>FIFO 필요</strong>: <code>queue</code></li>
<li><strong>우선순위 관리</strong>: <code>priority_queue</code></li>
<li><strong>힙 직접 제어</strong>: heap 알고리즘</li>
</ol>
<p><strong>성능 최적화 팁</strong></p>
<ul>
<li>대부분의 경우 <strong>vector가 가장 빠름</strong> (캐시 지역성)</li>
<li>병렬 프로그래밍에서는 <strong>vector</strong> 사용 권장</li>
<li>성능이 매우 중요하면 직접 구현 고려</li>
<li>힙 생성은 <code>make_heap</code>이 삽입보다 빠름 (O(n) vs O(n log n))</li>
</ul>
<hr>
<h5 id="참고-자료">&lt;참고 자료&gt;</h5>
<p>코드없는 프로그래밍
<a href="https://en.cppreference.com/w/cpp/container">C++ Standard Containers</a>
<a href="https://en.cppreference.com/w/cpp/container/list">std::list</a>
<a href="https://en.cppreference.com/w/cpp/container/forward_list">std::forward_list</a>
<a href="https://en.cppreference.com/w/cpp/container/priority_queue">std::priority_queue</a>
<a href="https://en.cppreference.com/w/cpp/algorithm#Heap_operations">Heap Algorithms</a>
<a href="https://en.cppreference.com/w/cpp/container#Container_adaptors">Container Adapters</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[C++] 문자열 (string, string_view)]]></title>
            <link>https://velog.io/@sehee-jj/C-%EB%AC%B8%EC%9E%90%EC%97%B4-string-stringview</link>
            <guid>https://velog.io/@sehee-jj/C-%EB%AC%B8%EC%9E%90%EC%97%B4-string-stringview</guid>
            <pubDate>Sat, 17 Jan 2026 13:45:28 GMT</pubDate>
            <description><![CDATA[<h2 id="📚-c-문자열-string-string_view">📚 C++ 문자열 (string, string_view)</h2>
<h3 id="🔸-c-style-문자열의-메모리-위치">🔸 C-Style 문자열의 메모리 위치</h3>
<p><strong>char 배열 vs char 포인터</strong></p>
<pre><code class="language-cpp">int main()
{
    char a[] = &quot;hello&quot;;      // 스택에 할당
    char* b = &quot;hello&quot;;       // 포인터는 스택, 문자열은 read-only 영역

    // 수정 테스트
    a[0] = &#39;d&#39;;              // ✅ 성공: &quot;dello&quot;
    // b[0] = &#39;d&#39;;           // ❌ Segmentation Fault!

    return 0;
}</code></pre>
<p><strong>메모리 레이아웃</strong></p>
<pre><code>스택 (Stack):
┌──────────────┐
│ a[] 배열     │
│ &#39;h&#39;&#39;e&#39;&#39;l&#39;&#39;l&#39; │
│ &#39;o&#39; &#39;\0&#39;     │
└──────────────┘
  ↑ 수정 가능

스택 (Stack):
┌──────────────┐
│ b (포인터)   │
│ 0x400000 ────┼───→ Read-Only Data Segment:
└──────────────┘     ┌──────────────────┐
                    │ &quot;hello\0&quot;       │
                    └──────────────────┘
                      ↑ 수정 불가능!</code></pre><p>문자열 리터럴은 read-only data segment에 저장되며, 수정 시도 시 segmentation fault 발생</p>
<p><strong>const char*에는 반드시 const 사용</strong></p>
<pre><code class="language-cpp">// ❌ 나쁜 코드: 경고 발생 (C++11 이후 deprecated)
char* b = &quot;hello&quot;;
b[0] = &#39;d&#39;;  // Undefined Behavior (대부분 Segmentation Fault)

// ✅ 좋은 코드: const로 명시
const char* b = &quot;hello&quot;;  // 수정 불가능함을 명시
// b[0] = &#39;d&#39;;  // 컴파일 에러로 실수 방지!</code></pre>
<h3 id="🔸-stdstring의-메모리-할당">🔸 std::string의 메모리 할당</h3>
<p><strong>Small String Optimization (SSO)</strong></p>
<p>std::string은 항상 힙에 할당되는 것이 아니라, <strong>SSO</strong>를 통해 짧은 문자열은 스택에, 긴 문자열만 힙에 저장</p>
<pre><code class="language-cpp">#include &lt;string&gt;

int main()
{
    // 짧은 문자열 (SSO 적용, 보통 15-22자 이하)
    std::string short_str = &quot;hello&quot;;
    // → 스택에 저장 (힙 할당 없음)

    // 긴 문자열 (SSO 한계 초과)
    std::string long_str = &quot;This is a very long string that exceeds SSO&quot;;
    // → 힙에 동적 할당

    return 0;
}</code></pre>
<p><strong>SSO 동작 방식</strong></p>
<pre><code>std::string 객체 (스택):
┌────────────────────────┐
│ 포인터 / 데이터        │ ← SSO 버퍼 (15-22자)
│ 길이                  │
│ 용량 / SSO 플래그      │
└────────────────────────┘

짧은 문자열 (&quot;hello&quot;):
┌─────────────────────────┐
│ &quot;hello\0&quot;... (여유공간)│ ← 스택 내부에 직접 저장
│ 길이: 5                │
│ 플래그: SSO 사용 중     │
└─────────────────────────┘

긴 문자열:
┌───────────────┐
│ 포인터 ───────┼───→ 힙 메모리
│ 길이: 50     │    &quot;This is a very long...&quot;
│ 용량: 64     │
└───────────────┘</code></pre><p>구현에 따라 GCC/MSVC는 15자, Clang은 22자까지 SSO 적용</p>
<h3 id="🔸-stdstring_view-c17">🔸 std::string_view (C++17)</h3>
<p><strong>std::string_view란?</strong></p>
<ul>
<li><code>std::span</code>과 비슷한 개념</li>
<li>포인터 + 길이 정보만 저장 (16 bytes)</li>
<li>소유권 없이 문자열을 참조만 함</li>
</ul>
<p><strong>메모리 구조</strong></p>
<pre><code class="language-cpp">template&lt;typename CharT&gt;
class basic_string_view
{
    const CharT* ptr;   // 데이터 시작 주소 (8 bytes)
    size_t length;      // 문자열 길이 (8 bytes)
};

// sizeof(std::string_view) == 16 bytes</code></pre>
<h3 id="🔸-함수-파라미터로-문자열-전달하기">🔸 함수 파라미터로 문자열 전달하기</h3>
<p><strong>const std::string&amp; 사용 시 문제</strong></p>
<pre><code class="language-cpp">void print(const std::string&amp; str)
{
    std::cout &lt;&lt; str &lt;&lt; std::endl;
}

int main()
{
    const char* c_str = &quot;hello&quot;;

    // ❌ 비효율: 임시 std::string 객체 생성!
    print(c_str);

    // 실제 동작:
    // 1. 임시 std::string tmp(c_str) 생성
    // 2. 힙 메모리 할당 (SSO 초과 시)
    // 3. &quot;hello&quot; 복사
    // 4. print(tmp) 호출
    // 5. tmp 소멸, 메모리 해제

    return 0;
}</code></pre>
<p>const char*나 문자열 리터럴을 const std::string&amp; 파라미터로 전달하면 임시 std::string 객체가 생성되어 메모리 할당과 복사 발생</p>
<p><strong>std::string_view 사용</strong></p>
<pre><code class="language-cpp">void print(std::string_view str)  // ✅ 효율적!
{
    std::cout &lt;&lt; str &lt;&lt; std::endl;
}

int main()
{
    // 모두 임시 객체 생성 없이 전달 가능

    // C 문자열 배열
    char arr[] = &quot;hello&quot;;
    print(arr);  // 포인터 + 길이만 전달

    // const char* 포인터
    const char* c_str = &quot;world&quot;;
    print(c_str);  // 포인터 + 길이만 전달

    // std::string
    std::string cpp_str = &quot;string&quot;;
    print(cpp_str);  // string의 내부 포인터 + 길이만 전달

    return 0;
}</code></pre>
<p>string_view는 포인터와 길이만 저장하므로 임시 객체 생성이나 메모리 할당 없이 전달 가능</p>
<p><strong>성능 차이</strong></p>
<pre><code class="language-cpp">// const std::string&amp; 사용
void old_print(const std::string&amp; str) { }

old_print(&quot;hello&quot;);
// → 임시 std::string 생성
// → 힙 할당 (SSO 초과 시)
// → 메모리 복사
// → 함수 호출
// → 소멸 및 메모리 해제

// std::string_view 사용
void new_print(std::string_view str) { }

new_print(&quot;hello&quot;);
// → 포인터와 길이만 전달 (16 bytes 복사)
// → 끝!</code></pre>
<p>벤치마크 결과 string_view 사용 시 문자열 리터럴 전달에서 100배 이상 성능 향상 가능</p>
<h3 id="🔸-stdstring_view-주의사항">🔸 std::string_view 주의사항</h3>
<p><strong>Dangling Reference</strong></p>
<pre><code class="language-cpp">std::string_view get_view()
{
    std::string temp = &quot;temporary&quot;;
    return std::string_view(temp);  // ❌ 위험!
}  // temp 소멸

int main()
{
    auto view = get_view();
    std::cout &lt;&lt; view &lt;&lt; std::endl;  // ❌ Undefined Behavior

    return 0;
}</code></pre>
<p><strong>std::string 재할당 시 무효화</strong></p>
<pre><code class="language-cpp">std::string str = &quot;short&quot;;
std::string_view view = str;

std::cout &lt;&lt; view &lt;&lt; std::endl;  // ✅ &quot;short&quot;

str = &quot;This is a very long string that causes reallocation&quot;;
// → str의 내부 버퍼가 재할당됨

std::cout &lt;&lt; view &lt;&lt; std::endl;  // ❌ Undefined Behavior
                                 // view는 여전히 옛날 주소를 가리킴</code></pre>
<p><strong>임시 객체 바인딩</strong></p>
<pre><code class="language-cpp">void process(std::string_view view)
{
    // view 사용...
}

int main()
{
    std::string get_string() { return &quot;temp&quot;; }

    // ❌ 위험: 임시 객체의 view
    process(get_string());  // get_string() 반환 후 즉시 소멸

    // ✅ 안전: 수명 연장
    std::string str = get_string();
    process(str);

    return 0;
}</code></pre>
<p>string_view는 임시 객체도 받아들이므로, 수명 관리에 주의 필요</p>
<h3 id="🔸-char-const-char-stdstring-비교">🔸 char[], const char*, std::string 비교</h3>
<table>
<thead>
<tr>
<th>타입</th>
<th>메모리 위치</th>
<th>수정 가능</th>
<th>크기 정보</th>
<th>메모리 관리</th>
</tr>
</thead>
<tbody><tr>
<td><code>char[]</code></td>
<td>스택</td>
<td>✅</td>
<td>컴파일 타임</td>
<td>자동</td>
</tr>
<tr>
<td><code>const char*</code></td>
<td>리터럴: read-only<br>동적: 힙</td>
<td>리터럴: ❌<br>동적: ✅</td>
<td>❌ (strlen 필요)</td>
<td>리터럴: 자동<br>동적: 수동</td>
</tr>
<tr>
<td><code>std::string</code></td>
<td>SSO: 스택<br>긴 문자열: 힙</td>
<td>✅</td>
<td>✅ (O(1))</td>
<td>자동 (RAII)</td>
</tr>
<tr>
<td><code>std::string_view</code></td>
<td>원본 참조</td>
<td>❌ (읽기 전용)</td>
<td>✅ (O(1))</td>
<td>소유권 없음</td>
</tr>
</tbody></table>
<h3 id="🔸-함수-파라미터-선택-가이드">🔸 함수 파라미터 선택 가이드</h3>
<pre><code class="language-cpp">// 읽기 전용, 다양한 타입 받기
void read_only(std::string_view str)  // ✅ 권장 (C++17 이상)
{
    std::cout &lt;&lt; str &lt;&lt; std::endl;
}

// std::string만 받기
void string_only(const std::string&amp; str)
{
    std::cout &lt;&lt; str.size() &lt;&lt; std::endl;
}

// 소유권 획득
void take_ownership(std::string str)  // 복사 또는 이동
{
    // str을 멤버 변수에 저장하거나 수정
}

// 수정이 필요한 경우
void modify(std::string&amp; str)
{
    str += &quot; modified&quot;;
}

// C 라이브러리와 호환 필요
void c_compat(const char* str)
{
    printf(&quot;%s\n&quot;, str);  // C 함수 사용
}</code></pre>
<h3 id="🔸-핵심-정리">🔸 핵심 정리</h3>
<p><strong>char 배열 vs char 포인터</strong></p>
<ul>
<li><code>char a[] = &quot;hello&quot;</code>: 스택에 복사본 생성, 수정 가능</li>
<li><code>const char* b = &quot;hello&quot;</code>: read-only 영역 참조, 수정 불가 (const 필수)</li>
</ul>
<p><strong>std::string의 메모리</strong></p>
<ul>
<li><strong>짧은 문자열 (15-22자)</strong>: 스택 (SSO)</li>
<li><strong>긴 문자열</strong>: 힙 동적 할당</li>
<li>자동 메모리 관리 (RAII)</li>
</ul>
<p><strong>std::string_view</strong></p>
<ul>
<li>포인터 + 길이만 저장 (16 bytes)</li>
<li><code>const char*</code>, <code>std::string</code> 모두 임시 객체 없이 전달</li>
<li>읽기 전용, 소유권 없음</li>
<li><strong>주의</strong>: 원본 수명 관리 필요</li>
</ul>
<p><strong>함수 파라미터 권장사항 (C++17 이상)</strong></p>
<ul>
<li><strong>읽기 전용</strong>: <code>std::string_view</code></li>
<li><strong>수정 필요</strong>: <code>std::string&amp;</code></li>
<li><strong>소유권 획득</strong>: <code>std::string</code> (값 전달)</li>
</ul>
<hr>
<h5 id="참고-자료">&lt;참고 자료&gt;</h5>
<p>코드없는 프로그래밍
<a href="https://shaharmike.com/cpp/std-string/">C++ std::string Implementation</a>
<a href="https://en.cppreference.com/w/cpp/string/basic_string_view">C++ std::string_view</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[C++] 배열과 컨테이너 (Array, Deque, Span)]]></title>
            <link>https://velog.io/@sehee-jj/C-%EB%B0%B0%EC%97%B4%EA%B3%BC-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-Array-Deque-Span</link>
            <guid>https://velog.io/@sehee-jj/C-%EB%B0%B0%EC%97%B4%EA%B3%BC-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-Array-Deque-Span</guid>
            <pubDate>Sat, 17 Jan 2026 13:29:04 GMT</pubDate>
            <description><![CDATA[<h2 id="📚-c-배열과-컨테이너-array-deque-span">📚 C++ 배열과 컨테이너 (Array, Deque, Span)</h2>
<h3 id="🔸-배열과-컨테이너-비교">🔸 배열과 컨테이너 비교</h3>
<p><strong>5가지 메모리 관리 방법 비교</strong></p>
<pre><code class="language-cpp">#include &lt;array&gt;
#include &lt;vector&gt;

int main()
{
    // 1. C 정적 배열
    int cArray[5] = {1, 2, 3, 4, 5};  
    // 스택, 컴파일 타임 크기, 자동 관리

    // 2. C++ 동적 배열 (new/delete)
    int* cppDynArray = new int[5]{1, 2, 3, 4, 5};
    // 힙, 런타임 크기, 수동 할당/해제
    // ✅ 타입 안전
    // ❌ 메모리 누수 위험
    delete[] cppDynArray;

    // 3. std::array
    std::array&lt;int, 5&gt; stdArray{1, 2, 3, 4, 5};  
    // 스택, 컴파일 타임 크기, 자동 관리
    // ✅ 타입 안전, 크기 정보 유지

    // 4. std::vector (권장!)
    std::vector&lt;int&gt; vec{1, 2, 3, 4, 5};  
    // 힙, 런타임 크기, 자동 관리
    // ✅ 동적 크기 조정
    // ✅ 메모리 자동 해제
    // ✅ 타입 안전

    return 0;
}</code></pre>
<p><strong>메모리 할당 위치 정리</strong></p>
<table>
<thead>
<tr>
<th>타입</th>
<th>메모리 위치</th>
<th>크기 결정</th>
<th>관리 방식</th>
<th>안전성</th>
</tr>
</thead>
<tbody><tr>
<td>C 배열</td>
<td>스택</td>
<td>컴파일 타임</td>
<td>자동</td>
<td>⚠️</td>
</tr>
<tr>
<td>C++ 동적 배열 (new)</td>
<td>힙</td>
<td>런타임</td>
<td>수동</td>
<td>⚠️</td>
</tr>
<tr>
<td>std::array</td>
<td>스택</td>
<td>컴파일 타임</td>
<td>자동</td>
<td>✅</td>
</tr>
<tr>
<td>std::vector</td>
<td>힙</td>
<td>런타임</td>
<td>자동</td>
<td>✅</td>
</tr>
</tbody></table>
<p><strong>왜 std::vector를 사용해야 하는가?</strong></p>
<pre><code class="language-cpp">// ❌ C++ 동적 배열의 문제점
int* arr2 = new int[5];
// delete[] 깜빡하면 메모리 누수
// 크기 변경 불가
delete[] arr2;

// ✅ std::vector의 장점
std::vector&lt;int&gt; vec(5);
// 자동 메모리 관리
// 크기 정보 포함 (vec.size())
// 동적 크기 조정 (vec.push_back())
// 타입 안전
// RAII (자동 소멸)</code></pre>
<h3 id="🔸-stdarray">🔸 std::array</h3>
<p><strong>std::array란?</strong></p>
<ul>
<li>컴파일 타임에 크기가 고정된 배열</li>
<li>스택 메모리에 할당</li>
<li>C 스타일 배열을 안전하게 감싼 래퍼</li>
</ul>
<p><strong>std::array의 장점</strong></p>
<pre><code class="language-cpp">std::array&lt;int, 5&gt; arr{1, 2, 3, 4, 5};

// 경계 검사
arr.at(10);  // std::out_of_range 예외 발생

// 크기 확인
std::cout &lt;&lt; arr.size() &lt;&lt; std::endl;  // 5

// 반복자 사용
for (auto it = arr.begin(); it != arr.end(); ++it)
{
    std::cout &lt;&lt; *it &lt;&lt; &quot; &quot;;
}

// Range-based for
for (const auto&amp; elem : arr)
{
    std::cout &lt;&lt; elem &lt;&lt; &quot; &quot;;
}</code></pre>
<p><strong>std::array vs C 배열</strong></p>
<pre><code class="language-cpp">// C 배열
int cArr[5];
// sizeof(cArr) == 20 (5 * 4)
// 함수에 전달 시 포인터로 decay
// 크기 정보 손실

void func(int arr[])  // 실제로는 int*
{
    // sizeof(arr) == 8 (포인터 크기)
}

// std::array
std::array&lt;int, 5&gt; stdArr;
// sizeof(stdArr) == 20 (5 * 4)
// 함수에 전달 시 크기 정보 유지

void func(std::array&lt;int, 5&gt;&amp; arr)
{
    // sizeof(arr) == 20
    // arr.size() == 5
}</code></pre>
<h3 id="🔸-다차원-배열과-벡터">🔸 다차원 배열과 벡터</h3>
<p><strong>메모리 레이아웃 차이</strong></p>
<p><strong>1. 다차원 배열 (스택, 연속된 메모리)</strong></p>
<pre><code class="language-cpp">int arr[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

// 메모리 레이아웃 (연속적):
// [1][2][3][4][5][6][7][8][9][10][11][12]
//  ↑ 스택에 한 번에 할당</code></pre>
<p><strong>2. 다차원 벡터 (힙, 분산된 메모리)</strong></p>
<pre><code class="language-cpp">std::vector&lt;std::vector&lt;int&gt;&gt; vec = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

// 메모리 레이아웃 (비연속적):
// vec (스택):
// [ptr1][ptr2][ptr3]
//   ↓     ↓     ↓
// 힙:
// [1][2][3][4]  (첫 번째 행)
// [5][6][7][8]  (두 번째 행)
// [9][10][11][12]  (세 번째 행)</code></pre>
<p><strong>연속된 메모리를 사용하는 Wrapper</strong></p>
<p>벡터를 사용하되 연속된 메모리를 유지하려면 1차원 벡터로 관리하고 인덱스 변환을 사용:</p>
<pre><code class="language-cpp">class Matrix
{
public:
    Matrix(size_t rows, size_t cols)
        : mRows{rows}, mCols{cols}, mData(rows * cols)
    {
    }

    int&amp; operator()(size_t row, size_t col)
    {
        return mData[row * mCols + col];  // 2D → 1D 인덱스 변환
    }

    const int&amp; operator()(size_t row, size_t col) const
    {
        return mData[row * mCols + col];
    }

private:
    size_t mRows;
    size_t mCols;
    std::vector&lt;int&gt; mData;  // 1차원 벡터로 연속 메모리 보장
};

int main()
{
    Matrix mat(3, 4);

    // 사용
    mat(0, 0) = 1;
    mat(0, 1) = 2;
    mat(1, 0) = 5;

    return 0;
}</code></pre>
<h3 id="🔸-캐시-최적화-cache-hitmiss">🔸 캐시 최적화 (Cache Hit/Miss)</h3>
<p><strong>캐시 라인 (Cache Line)</strong></p>
<ul>
<li>CPU 캐시의 최소 단위 (보통 64 bytes)</li>
<li>메모리를 읽을 때 캐시 라인 단위로 가져옴</li>
<li>연속된 메모리는 캐시에 함께 로드됨</li>
</ul>
<p><strong>잘못된 루프 (Cache Miss 많음)</strong></p>
<pre><code class="language-cpp">const size_t ROWS = 1000;
const size_t COLS = 1000;
int arr[ROWS][COLS];

// ❌ 열 우선 순회 (Column-major)
for (size_t col = 0; col &lt; COLS; col++)
{
    for (size_t row = 0; row &lt; ROWS; row++)
    {
        arr[row][col] = 0;  // 메모리 점프 발생!
    }
}</code></pre>
<p><strong>메모리 접근 패턴:</strong></p>
<pre><code>arr[0][0] → arr[1][0] → arr[2][0] → ...
               ↓         ↓ (COLS * 4 bytes 떨어짐)
            캐시 미스!</code></pre><p><strong>올바른 루프 (Cache Hit 많음)</strong></p>
<pre><code class="language-cpp">// ✅ 행 우선 순회 (Row-major)
for (size_t row = 0; row &lt; ROWS; row++)
{
    for (size_t col = 0; col &lt; COLS; col++)
    {
        arr[row][col] = 0;  // 연속된 메모리 접근!
    }
}</code></pre>
<p><strong>메모리 접근 패턴:</strong></p>
<pre><code>arr[0][0] → arr[0][1] → arr[0][2] → ...
                ↓         ↓ (4 bytes 떨어짐, 같은 캐시 라인)
             캐시 히트!</code></pre><p><strong>핵심 규칙: Inner Loop는 Cache Line에 맞춰라</strong></p>
<ul>
<li>C/C++ 배열은 row-major 순서로 저장됨</li>
<li>Inner loop에서 연속된 메모리를 접근하도록 설계</li>
<li>열(column) 순회를 inner loop에 배치</li>
</ul>
<h3 id="🔸-stddeque-double-ended-queue">🔸 std::deque (Double-Ended Queue)</h3>
<p><strong>std::deque란?</strong></p>
<ul>
<li>양쪽 끝에서 삽입/삭제가 O(1)인 컨테이너</li>
<li>실무에서는 거의 사용하지 않음</li>
<li>Vector와 List의 중간 형태</li>
</ul>
<p><strong>std::deque vs std::vector</strong></p>
<pre><code class="language-cpp">#include &lt;deque&gt;
#include &lt;vector&gt;

std::vector&lt;int&gt; vec;
vec.push_back(1);   // 뒤에 추가: O(1) (amortized)
// vec.push_front(0);  // ❌ vector는 push_front 없음

std::deque&lt;int&gt; deq;
deq.push_back(1);   // 뒤에 추가: O(1)
deq.push_front(0);  // ✅ 앞에 추가: O(1)</code></pre>
<p><strong>deque의 메모리 구조 (Chunk Array)</strong></p>
<pre><code>Deque:
┌─────────────────────┐
│ Chunk 포인터들      │ (중앙 배열)
│ [ptr1][ptr2][ptr3] │
└────┬─────┬─────┬────┘
     ↓     ↓     ↓
   청크   청크   청크 (힙)
[▢▢▢▢][■■■■][▢▢▢▢]
           ↑
         데이터</code></pre><p><strong>Vector의 재할당 vs Deque의 확장</strong></p>
<p><strong>Vector:</strong></p>
<pre><code class="language-cpp">std::vector&lt;int&gt; vec{1, 2, 3, 4};
// 메모리: [1][2][3][4] (capacity: 4)

vec.push_back(5);  // capacity 부족!
// → 새 공간 할당 (capacity: 8)
// → 기존 데이터 전체 복사/이동
// → 기존 공간 해제</code></pre>
<p><strong>Deque:</strong></p>
<pre><code class="language-cpp">std::deque&lt;int&gt; deq{1, 2, 3, 4};
// Chunk1: [1][2][3][4]

deq.push_back(5);  // 현재 청크가 가득 찬 경우
// → 새 청크만 할당
// → 기존 데이터는 그대로!
// Chunk1: [1][2][3][4]
// Chunk2: [5][▢][▢][▢]</code></pre>
<p><strong>Deque의 장점</strong></p>
<ol>
<li><code>push_front()</code> / <code>push_back()</code> 모두 O(1)</li>
<li>재할당 시 기존 데이터를 복사/이동하지 않음</li>
<li>Iterator invalidation이 적음 (중간 삽입 제외)</li>
</ol>
<p><strong>Deque의 단점</strong></p>
<ol>
<li><p><strong>두 번의 포인터 역참조 필요</strong></p>
<pre><code class="language-cpp">deq[i];
// 1. 어느 청크인지 계산
// 2. 청크 포인터 배열에서 청크 주소 읽기
// 3. 청크 내에서 오프셋 계산
// 4. 실제 데이터 접근</code></pre>
</li>
<li><p><strong>캐시 미스 가능성</strong></p>
<ul>
<li>청크들이 메모리상 분산되어 있음</li>
<li>연속된 접근 시 캐시 효율 떨어짐</li>
</ul>
</li>
<li><p><strong>메모리 오버헤드</strong></p>
<ul>
<li>청크 포인터 배열 관리</li>
<li>각 청크마다 약간의 낭비 공간</li>
</ul>
</li>
</ol>
<p><strong>성능 비교</strong></p>
<pre><code class="language-cpp">// 랜덤 접근
vec[1000];   // O(1), 빠름 (한 번의 역참조)
deq[1000];   // O(1), 느림 (두 번의 역참조)

// 앞쪽 삽입
// vec.push_front()  // 없음 (구현하려면 O(n))
deq.push_front(x);   // O(1)

// 뒤쪽 삽입
vec.push_back(x);    // O(1) amortized
deq.push_back(x);    // O(1)</code></pre>
<p><strong>언제 Deque를 사용할까?</strong></p>
<ul>
<li>양쪽 끝에서 삽입/삭제가 빈번할 때</li>
<li>중간 접근은 드물 때</li>
<li>하지만 대부분의 경우 <code>std::vector</code>가 더 나음</li>
</ul>
<h3 id="🔸-push_back-vs-emplace_back">🔸 push_back vs emplace_back</h3>
<p><strong>기본 차이</strong></p>
<pre><code class="language-cpp">struct Student {
    std::string name;
    int age;
    Student(std::string n, int a) : name(n), age(a) {
        std::cout &lt;&lt; &quot;생성자 호출\n&quot;;
    }
    Student(const Student&amp; other) : name(other.name), age(other.age) {
        std::cout &lt;&lt; &quot;복사 생성자 호출\n&quot;;
    }
    Student(Student&amp;&amp; other) : name(std::move(other.name)), age(other.age) {
        std::cout &lt;&lt; &quot;이동 생성자 호출\n&quot;;
    }
};

std::vector&lt;Student&gt; v;

// push_back: 임시 객체 생성 → 이동 → 소멸
v.push_back(Student(&quot;Alice&quot;, 20));
// 출력: 생성자 호출 → 이동 생성자 호출

// emplace_back: 컨테이너 내부에서 직접 생성
v.emplace_back(&quot;Bob&quot;, 21);
// 출력: 생성자 호출 (끝)</code></pre>
<p><strong>언제 emplace_back이 확실히 유리한가?</strong></p>
<p><strong>1. 복사/이동이 비용이 큰 경우</strong></p>
<pre><code class="language-cpp">struct BigData {
    std::vector&lt;int&gt; data;
    BigData(int size) : data(size, 42) {}
};

std::vector&lt;BigData&gt; v;

// push_back: 생성 → 이동 (vector 내부 데이터도 이동)
v.push_back(BigData(10000));

// emplace_back: 바로 생성 (이동 없음)
v.emplace_back(10000);  // ✅ 더 효율적</code></pre>
<p><strong>2. 복사 생성자가 delete된 경우</strong></p>
<pre><code class="language-cpp">struct NonCopyable {
    NonCopyable(int x) {}
    NonCopyable(const NonCopyable&amp;) = delete;
    NonCopyable(NonCopyable&amp;&amp;) = delete;
};

std::vector&lt;NonCopyable&gt; v;

// v.push_back(NonCopyable(42));  // ❌ 컴파일 에러
v.emplace_back(42);               // ✅ 작동</code></pre>
<p><strong>3. 생성자 인자가 여러 개일 때 (코드 간결성)</strong></p>
<pre><code class="language-cpp">struct Point3D {
    double x, y, z;
    Point3D(double x, double y, double z) : x(x), y(y), z(z) {}
};

std::vector&lt;Point3D&gt; points;

// push_back: 임시 객체 명시적 생성
points.push_back(Point3D(1.0, 2.0, 3.0));

// emplace_back: 인자만 전달 (더 간결)
points.emplace_back(1.0, 2.0, 3.0);</code></pre>
<p><strong>C++17 이후의 현실: Copy Elision</strong></p>
<pre><code class="language-cpp">std::vector&lt;Student&gt; v;

// C++17 이후: 컴파일러가 임시 객체 생성을 최적화
v.push_back(Student(&quot;Alice&quot;, 20));
// → 실제로는 vector 내부에 바로 생성될 수 있음 (RVO)

v.emplace_back(&quot;Alice&quot;, 20);
// → 거의 동일한 코드로 컴파일됨</code></pre>
<p><strong>간단한 타입에서는 차이가 거의 없음:</strong></p>
<pre><code class="language-cpp">std::vector&lt;int&gt; v;

v.push_back(42);     // 성능 차이 없음
v.emplace_back(42);  // 성능 차이 없음</code></pre>
<p><strong>주의사항: explicit 생성자</strong></p>
<pre><code class="language-cpp">struct Widget {
    explicit Widget(int x) {}
};

std::vector&lt;Widget&gt; v;

// v.push_back(42);     // ❌ 컴파일 에러 (explicit 생성자)
v.emplace_back(42);     // ✅ 작동 (의도치 않은 변환 가능)</code></pre>
<p><strong>실전 가이드</strong></p>
<pre><code class="language-cpp">// ✅ emplace_back 사용 권장
std::vector&lt;ComplexType&gt; v;
v.emplace_back(arg1, arg2, arg3);  // 생성자 인자 직접 전달

// ✅ push_back도 괜찮음 (C++17 이후)
std::vector&lt;int&gt; numbers;
numbers.push_back(42);  // 간단한 타입은 가독성 우선

// ✅ 이미 생성된 객체는 push_back
Student s(&quot;Alice&quot;, 20);
v.push_back(std::move(s));  // 명시적 의도</code></pre>
<p><strong>결론</strong></p>
<ul>
<li><strong>복잡한 객체나 여러 인자</strong>: <code>emplace_back</code> 사용</li>
<li><strong>간단한 타입이나 가독성 우선</strong>: <code>push_back</code>도 OK</li>
<li><strong>현대 C++에서는 큰 차이 없는 경우 많음</strong></li>
<li><strong>일관성 있게 사용하는 것이 중요</strong></li>
</ul>
<h3 id="🔸-stdspan-c20">🔸 std::span (C++20)</h3>
<p><strong>std::span이란?</strong></p>
<ul>
<li>연속된 메모리 공간을 참조하는 경량 뷰</li>
<li>소유권 없이 데이터를 참조만 함</li>
<li>배열, vector, array 등을 통일된 인터페이스로 다룸</li>
</ul>
<p><strong>기본 사용법</strong></p>
<pre><code class="language-cpp">#include &lt;span&gt;

void print(std::span&lt;int&gt; data)  // 배열, vector, array 모두 받음
{
    for (int val : data)
    {
        std::cout &lt;&lt; val &lt;&lt; &quot; &quot;;
    }
    std::cout &lt;&lt; std::endl;
}

int main()
{
    // C 배열
    int arr[] = {1, 2, 3, 4, 5};
    print(arr);

    // std::array
    std::array&lt;int, 5&gt; stdArr{1, 2, 3, 4, 5};
    print(stdArr);

    // std::vector
    std::vector&lt;int&gt; vec{1, 2, 3, 4, 5};
    print(vec);

    return 0;
}</code></pre>
<p><strong>std::span의 구조</strong></p>
<pre><code class="language-cpp">template&lt;typename T&gt;
class span
{
    T* ptr;      // 데이터 시작 주소 (8 bytes)
    size_t size; // 원소 개수 (8 bytes)
};

// sizeof(std::span&lt;int&gt;) == 16 bytes</code></pre>
<p><strong>메모리 레이아웃</strong></p>
<pre><code>Vector:
┌──────────┐
│ vec      │ (스택)
│ ptr ─────┼───────→ [1][2][3][4][5] (힙)
│ size: 5  │
│ cap: 5   │
└──────────┘

Span:
┌──────────┐
│ span     │ (스택)
│ ptr ─────┼───────→ [1][2][3][4][5] (vec의 데이터를 참조)
│ size: 5  │
└──────────┘
  ↑ 소유권 없음!</code></pre><p><strong>주의사항: Dangling Span</strong></p>
<pre><code class="language-cpp">std::span&lt;int&gt; createSpan()
{
    std::vector&lt;int&gt; vec{1, 2, 3, 4, 5};
    return std::span{vec};  // ❌ 위험!
}  // vec 소멸

int main()
{
    auto sp = createSpan();
    // sp는 이미 소멸된 vec의 메모리를 가리킴 (Dangling)
    std::cout &lt;&lt; sp[0] &lt;&lt; std::endl;  // ❌ Undefined Behavior

    return 0;
}</code></pre>
<p><strong>재할당 시 문제</strong></p>
<pre><code class="language-cpp">std::vector&lt;int&gt; vec{1, 2, 3};
std::span&lt;int&gt; sp{vec};

std::cout &lt;&lt; sp[0] &lt;&lt; std::endl;  // ✅ 1

vec.push_back(4);
vec.push_back(5);  // capacity 부족 → 재할당!

// sp는 여전히 옛날 메모리를 가리킴
std::cout &lt;&lt; sp[0] &lt;&lt; std::endl;  // ❌ Undefined Behavior</code></pre>
<p><strong>안전한 사용법</strong></p>
<pre><code class="language-cpp">void process(std::span&lt;int&gt; data)
{
    // data 사용
    for (int&amp; val : data)
    {
        val *= 2;
    }
}  // span 소멸, 하지만 원본 데이터는 유지

int main()
{
    std::vector&lt;int&gt; vec{1, 2, 3, 4, 5};

    // ✅ 안전: process 내부에서만 사용
    process(vec);

    // vec는 여전히 유효
    for (int val : vec)
    {
        std::cout &lt;&lt; val &lt;&lt; &quot; &quot;;  // 2 4 6 8 10
    }

    return 0;
}</code></pre>
<p><strong>std::span의 장점</strong></p>
<ol>
<li><p><strong>통일된 인터페이스</strong></p>
<ul>
<li>배열, vector, array를 동일하게 처리</li>
<li>함수 오버로딩 불필요</li>
</ul>
</li>
<li><p><strong>효율적</strong></p>
<ul>
<li>복사 비용 없음 (16 bytes만)</li>
<li>소유권 없이 참조만</li>
</ul>
</li>
<li><p><strong>부분 뷰 생성</strong></p>
<pre><code class="language-cpp">std::vector&lt;int&gt; vec{1, 2, 3, 4, 5};

std::span&lt;int&gt; all{vec};                    // 전체
std::span&lt;int&gt; first3 = all.first(3);       // 처음 3개
std::span&lt;int&gt; last2 = all.last(2);         // 마지막 2개
std::span&lt;int&gt; middle = all.subspan(1, 3);  // 중간 3개</code></pre>
</li>
</ol>
<p><strong>std::span vs const T&amp;</strong></p>
<pre><code class="language-cpp">// ❌ 배열은 받을 수 없음
void process(const std::vector&lt;int&gt;&amp; data) { }

int arr[] = {1, 2, 3};
// process(arr);  // 컴파일 에러

// ✅ 배열도 받을 수 있음
void process(std::span&lt;int&gt; data) { }
process(arr);  // OK</code></pre>
<h3 id="🔸-핵심-정리">🔸 핵심 정리</h3>
<p><strong>배열과 컨테이너 선택 가이드</strong></p>
<ul>
<li><strong>C 배열</strong>: 레거시 코드나 성능이 극도로 중요한 경우만</li>
<li><strong>C++ 동적 배열 (new/delete)</strong>: ❌ 사용하지 말 것 (std::vector 사용 권장)</li>
<li><strong>std::array</strong>: 컴파일 타임 고정 크기, 스택 할당 필요시</li>
<li><strong>std::vector</strong>: 대부분의 경우 이것을 사용 (권장)</li>
</ul>
<p><strong>다차원 배열 vs 벡터</strong></p>
<ul>
<li>배열: 연속 메모리 (캐시 효율 좋음)</li>
<li>벡터: 분산 메모리 (유연함)</li>
<li>Wrapper로 연속 메모리 유지 가능</li>
</ul>
<p><strong>캐시 최적화</strong></p>
<ul>
<li>Inner loop는 연속된 메모리 접근</li>
<li>Row-major 순서 (행 우선)</li>
<li>10배 이상 성능 차이 가능</li>
</ul>
<p><strong>std::deque</strong></p>
<ul>
<li>양쪽 끝 O(1) 삽입/삭제</li>
<li>재할당 시 복사 불필요</li>
<li>두 번의 포인터 역참조, 캐시 비효율</li>
<li>실무에서 거의 사용 안 함</li>
</ul>
<p><strong>push_back vs emplace_back</strong></p>
<ul>
<li>복잡한 객체: <code>emplace_back</code> 권장</li>
<li>간단한 타입: 둘 다 OK</li>
<li>C++17 이후 성능 차이 줄어듦</li>
<li>일관성 있게 사용</li>
</ul>
<p><strong>std::span (C++20)</strong></p>
<ul>
<li>연속 메모리 참조 뷰</li>
<li>소유권 없음 (16 bytes)</li>
<li>Dangling 주의</li>
<li>재할당 시 무효화</li>
</ul>
<hr>
<h5 id="참고-자료">&lt;참고 자료&gt;</h5>
<p>코드없는 프로그래밍
<a href="https://en.cppreference.com/w/cpp/container/array">C++ std::array</a>
<a href="https://en.cppreference.com/w/cpp/container/deque">C++ std::deque</a>
<a href="https://en.cppreference.com/w/cpp/container/span">C++ std::span</a></p>
]]></description>
        </item>
    </channel>
</rss>