<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jxngmin.log</title>
        <link>https://velog.io/</link>
        <description>Game Client Programmer</description>
        <lastBuildDate>Mon, 08 Dec 2025 14:39:02 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jxngmin.log</title>
            <url>https://velog.velcdn.com/images/jxng-min/profile/d340e8a5-658b-42d7-9467-3c092100e9c4/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jxngmin.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jxng-min" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[트러블 슈팅] 카드 삽입/스왑에 대한 고민]]></title>
            <link>https://velog.io/@jxng-min/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-%EC%B9%B4%EB%93%9C-%EC%82%BD%EC%9E%85%EC%8A%A4%EC%99%91%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EB%AF%BC</link>
            <guid>https://velog.io/@jxng-min/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-%EC%B9%B4%EB%93%9C-%EC%82%BD%EC%9E%85%EC%8A%A4%EC%99%91%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EB%AF%BC</guid>
            <pubDate>Mon, 08 Dec 2025 14:39:02 GMT</pubDate>
            <description><![CDATA[<h3 id="서론">서론</h3>
<p>어쩌다 보니 뒤늦게 기획자분께서 카드 패 내부에서의 카드 스왑이 가능했으면 좋겠다는 의견을 받았다.
카드 스왑은 한 번도 구현해본 경험이 없어서 할 수 있을지 의문이 들었다.</p>
<p>근데 뭐.. 못하면 어쩔건데 마인드로 하드코딩이라도 해볼 생각으로 작업에 들어갔다.</p>
<p><br></br></p>
<h3 id="아이디어">아이디어</h3>
<h4 id="구조에-대한-아이디어">구조에 대한 아이디어</h4>
<p>현재는 카드 UI도 MVP 아키텍처로, 카드 UI들을 관리하는 관리자 UI도 MVP 아키텍처로 작성되어 있는 상황이다.</p>
<p>결국 다음과 같은 프로세스를 따라가야 한다.</p>
<ol>
<li>사용자가 <code>View</code>에서 마우스 상호작용을 통해 카드를 옮긴다.</li>
<li>카드를 옮기면서 레이아웃에 따라 나머지 카드들은 이동해야만 한다.</li>
<li>카드를 옮긴 위치에 따라 <code>Model</code>을 갱신시킨다.</li>
</ol>
<p>뭐 1번과 3번만 생각하면 진짜 머리가 하나도 안아픈 문제였다.
근데 2번이 발목을 잡았다. 결국 레이아웃을 관리하는건 관리자 UI였기 때문이다.</p>
</br>

<p>따라서 나는 <strong>카드 UI에서는 이벤트 감지만! 이벤트 처리는 관리자 UI가!</strong> 라는 생각을 했다.
생각해보면 나쁘지 않다. 결국 <strong>카드 UI는 보여주는 용도지, 따로 로직을 지닐 필요가 없다</strong>.</p>
<p>그래서 관리자 UI의 <code>Presenter</code>가 <code>Model</code>을 갱신시키도록 구현하고자 했다.</p>
<hr>
<h4 id="구현에-대한-아이디어">구현에 대한 아이디어</h4>
<p>이게 직접 스토리텔링을 하려니 글재주가 없어서 단계별로 정리하는 편이 낫다고 생각한다.
나는 다음과 같은 알고리즘을 생각했다.</p>
<pre><code>1. 카드를 드래그 하는 동안 마우스 위치로 레이 히트된 카드 UI를 찾는다.

2. 레이 히트된 카드 UI의 좌표와 현재 마우스의 좌표를 인덱스 기반으로 비교한다.

    2-1. 드래그 중인 카드의 인덱스 &lt; 레이 히트된 카드의 인덱스인 경우
         : X 좌표를 비교하여 마우스 X 좌표가 더 크다면 레이 히트된 카드의 이전 인덱스까지 당긴다.

    2-2. 드래그 중인 카드의 인덱스 &gt; 레이 히트된 카드의 인덱스인 경우
         : X 좌표를 비교하여 마우스 X 좌표가 더 작다면 레이 히트된 카드의 이후 인덱스를 민다.

3. 인덱스를 밀고 당겨서 생긴 하나의 공백의 자리에 프리뷰 카드 오브젝트를 띄운다.

4. 드래그가 끝나면 빈 인덱스 자리에 카드를 삽입한다.</code></pre><p><br></br></p>
<h3 id="구현">구현</h3>
<h4 id="1-카드를-드래그-하는-동안-마우스-위치로-레이-히트된-카드-ui를-찾는다">1. 카드를 드래그 하는 동안 마우스 위치로 레이 히트된 카드 UI를 찾는다.</h4>
<p><strong>드래그 중인 카드가 레이캐스팅을 막는 중이라 이벤트 시스템에 직접 개입</strong>하여 카드를 찾아낸다. </p>
<pre><code class="language-C#">// Hand Card Event Controller의 일부

private RaycastResult? CheckField(out PointerEventData pointer_data)
{
    pointer_data = new PointerEventData(EventSystem.current);
    pointer_data.position = Input.mousePosition;
    pointer_data.pointerDrag = (m_presenter.HoverCard as HandCardView).gameObject;

    var ray_hits = new List&lt;RaycastResult&gt;();
    EventSystem.current.RaycastAll(pointer_data, ray_hits);

    foreach(var hit in ray_hits)
    {
          // 드래그 중인 카드 UI가 아닌 카드 UI를 찾습니다.
        var card_hit = hit.gameObject.GetComponent&lt;IHandCardView&gt;();
        if(card_hit != null &amp;&amp; m_presenter.HoverCard != card_hit)
            return hit;

           ...
    } 

    return null;
}</code></pre>
<hr>
<h4 id="2-레이-히트된-카드-ui의-좌표와-현재-마우스의-좌표를-인덱스-기반으로-비교한다">2. 레이 히트된 카드 UI의 좌표와 현재 마우스의 좌표를 인덱스 기반으로 비교한다.</h4>
<pre><code class="language-C#">// Hand Card Event Controller의 일부

private void SwapInSameField(IHandCardView hand_card, Vector2 position)
{
    var target_card = m_presenter.HoverCard;
    var concrete_card = hand_card as HandCardView;

        // 드래그 중인 카드와 레이 히트된 카드의 인덱스 상의 순서를 비교한다.
    if(m_container.IsPriority(target_card, hand_card))
    {
        // 드래그 중인 카드가 앞서는 경우, 마우스 좌표가 더 크다면 앞당긴다.
        if(position.x &gt;= concrete_card.transform.position.x)
        {
            m_container.Swap(target_card, hand_card);
            m_layout_controller.UpdateLayout(true);
        }
    }
    else
    {
        // 드래그 중인 카드가 뒤서는 경우, 마우스 좌표가 더 작다면 민다.
        if(position.x &lt; concrete_card.transform.position.x)
        {
            m_container.Swap(target_card, hand_card);
            m_layout_controller.UpdateLayout(true);
        }
    }        
}</code></pre>
<hr>
<h4 id="3-인덱스를-밀고-당겨서-생긴-하나의-공백의-자리에-프리뷰-카드-오브젝트를-띄운다">3. 인덱스를 밀고 당겨서 생긴 하나의 공백의 자리에 프리뷰 카드 오브젝트를 띄운다.</h4>
<pre><code class="language-C#">private void CalculatePreviewPosition()
{
    var layout_data = CardLayoutCalculator.CalculatedHandCardTransform(m_container.GetIndex(m_presenter.HoverCard),
                                                                       m_container.Cards.Count,
                                                                       m_designer.Radius,
                                                                       m_designer.Angle,
                                                                       m_designer.Depth);      
    m_preview_object.SetActive(true);

    var preview_rt = m_preview_object.transform as RectTransform;
    preview_rt.anchoredPosition = layout_data.Position;
    preview_rt.rotation = Quaternion.Euler(layout_data.Rotation);
    preview_rt.localScale = layout_data.Scale;
}</code></pre>
<hr>
<ol start="4">
<li>드래그가 끝나면 빈 인덱스 자리에 카드를 삽입한다.</li>
</ol>
<pre><code class="language-C#">private void SwapInSameField(IHandCardView hand_card, Vector2 position)
{
    var target_card = m_presenter.HoverCard;
    var concrete_card = hand_card as HandCardView;

    if(m_container.IsPriority(target_card, hand_card))
    {
        if(position.x &gt;= concrete_card.transform.position.x)
        {
            // 빈 위치에 카드를 삽입한다.
            m_container.Swap(target_card, hand_card);
            m_layout_controller.UpdateLayout(true);
        }
    }
    else
    {
        if(position.x &lt; concrete_card.transform.position.x)
        {
            // 빈 위치에 카드를 삽입한다.
            m_container.Swap(target_card, hand_card);
            m_layout_controller.UpdateLayout(true);
        }
    }        
}</code></pre>
<p><br></br></p>
<h3 id="해결">해결</h3>
<p>다음과 같이 정상적으로 카드의 위치 변경이 가능해졌다.</p>
<img src = "https://velog.velcdn.com/images/jxng-min/post/0bff16dd-271c-4f58-907a-92f843f67ad6/image.gif" width = 800>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블 슈팅] EventSystem을 이용한 Event 강제 호출]]></title>
            <link>https://velog.io/@jxng-min/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-EventSystem%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-Event-%EA%B0%95%EC%A0%9C-%ED%98%B8%EC%B6%9C</link>
            <guid>https://velog.io/@jxng-min/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-EventSystem%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-Event-%EA%B0%95%EC%A0%9C-%ED%98%B8%EC%B6%9C</guid>
            <pubDate>Tue, 18 Nov 2025 13:30:09 GMT</pubDate>
            <description><![CDATA[<h3 id="서론">서론</h3>
<p>타워 오브 가디언즈라는 턴제 덱빌딩 로그라이크 게임을 만들게 되었다.
덱빌딩 장르라 그런건지 세세한 디테일이 많은 기획이라 그런지 꽤나 신경 쓸 부분이 많다.</p>
<p>전투 시스템 UI의 전반적인 구현을 맡게 되면서 카드의 애니메이션과 상호작용을 주로 다루고 있던 도중에 카드를 드래그하여 필드에 드랍하는 과정에서 문제가 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/607edb9f-2ca1-4a02-899e-21782975cbba/image.gif" alt=""></p>
<p>드래그해서 필드에 드랍해도 드랍이 되지 않는다!</p>
<p>단순히 인벤토리를 구현하는 과정과 같이 <code>OnEndDrag</code> 이후 <code>OnDrop</code>이 자연스럽게 호출되어야 했지만 인벤토리와는 달리 <code>Drag Slot</code>과 같이 <strong>중간 다리 역할을 해주는 오브젝트가 없는 것이 발단</strong>이었다.</p>
<p><br></br></p>
<h3 id="아이디어">아이디어</h3>
<p>처음에는 단순히 <code>Drag Slot</code>의 역할을 하는 오브젝트가 있으면 되지 않을까? 이런 생각을 했지만 그렇게 될 걸 머릿 속에서 그려보니 자연스러운 카드의 애니메이션이 나오지 않았다.</p>
</br>

<h4 id="1-onenddrag와-ondrop-사이에서-block-raycasts-타이밍을-처리한다">1. OnEndDrag와 OnDrop 사이에서 Block Raycasts 타이밍을 처리한다!</h4>
<p>그래서 처음 생각한 방법이 다음과 같았다.</p>
<ol>
<li>카드에서 <code>OnEndDrag</code>가 발생할 때 카드의 <code>Block Raycasts</code>를 <code>false</code>로 처리한다.</li>
<li>필드에서 <code>OnDrop</code>이 발생할 때 다시 카드의 <code>Block Raycasts</code>를 <code>true</code>로 처리한다.</li>
</ol>
<p>단순히 타이밍의 문제라고 생각했다. 하지만 늘 트러블 슈팅이 그렇듯 제대로 작동하지 않았다.</p>
<p>왜 그런가를 살펴보니 <code>OnDrop</code>이 호출되는 경우에 한해서는 <code>OnDrop</code>이 <code>OnEndDrag</code>보다 먼저 호출되는 구조를 가지고 있었다.</p>
<p>반대로 <code>OnEndDrag</code>와 <code>OnDrop</code>에서 처리하는 카드의 <code>Block Raycasts</code>를 반대로 변경하면 <code>OnDrop</code>이 아예 호출되지도 않는다. <del>(순환 참조 느낌이다. 정말로.)</del></p>
<p>이 방법은 결과적으로 실패했다.</p>
</br>

<h4 id="2-event-system을-이용하여-강제로-ondrop-이벤트를-발생시킨다">2. Event System을 이용하여 강제로 OnDrop 이벤트를 발생시킨다!</h4>
<p>이 방법은 제일 하기 싫은 방법이었다. 직관적으로 <code>Handler</code>를 구현하고만 싶었지 <code>Event System</code>에 개입하여 해결한다고 하니 사실 영 찝찝하지 않은가.</p>
<p>아이디어는 이랬다.</p>
<ol>
<li>카드에서 <code>OnEndDrag</code>가 발생할 때 카드의 <code>Block Raycasts</code>를 <code>false</code>로 처리한다.</li>
<li>마우스 좌표에 위치하는 모든 UI에 대한 정보를 받아온다.</li>
<li>이 UI들 중 필드가 있다면! 그 필드에 <code>OnDrop</code>을 강제로 발생시킨다.</li>
<li>카드의 <code>Block Raycasts</code>를 다시<code>true</code>로 처리한다.</li>
</ol>
<p>어쨌거나 <code>OnDrop</code>이 호출되지 않더라도 마우스 좌표에 위치한 모든 UI들의 정보를 받아오려면 <code>Block Raycasts</code>를 <code>false</code>로 처리해야만 한다.</p>
<p><br></br></p>
<h3 id="구현">구현</h3>
<h4 id="드래그를-감지하는-card-ui">드래그를 감지하는 Card UI</h4>
<p>내 코드 구조는 다음과 같다.</p>
<blockquote>
<p>Card UI에서 이벤트 감지 → Hand UI에서 이벤트가 발생된 Card를 처리 </p>
</blockquote>
<p>우선적으로 카드에서 이벤트 감지를 하던 부분을 다음과 같이 구현했다.
이는 카드의 <code>Block Raycasts</code>를 단순히 토글하며 이벤트를 발생시킨다.</p>
<pre><code class="language-C#">public class Card : ...
{
    ...

    public void ToggleRaycast(bool active)
        =&gt; m_canvas_group.blocksRaycasts = active;

    ...

    public void OnEndDrag(PointerEventData eventData)
    {
        ToggleRaycast(false);        // 1번 과정
        OnEndDragAction?.Invoke();    // 여기서 2번과 3번을 처리할 예정
        ToggleRaycast(true);        // 4번 과정
    }
}</code></pre>
</br>

<h4 id="손에-든-패를-관리하는-hand-ui">손에 든 패를 관리하는 Hand UI</h4>
<pre><code class="language-C#">public class Hand : ..
{
    ...

    // Card UI에서 발생한 이벤트를 Hand UI에서 처리한다는 점을 말하고 싶었다.
    public IHandCardView InstantiateCardView()
    {
        var card_obj = ObjectPoolManager.Instance.Get(m_card_prefab);
        card_obj.transform.SetParent(transform, false);

        var card_view = card_obj.GetComponent&lt;IHandCardView&gt;();
        card_view.OnPointerEnterAction  += ()           =&gt; { OnPointerEnterInCard(card_view); };
        card_view.OnPointerExitAction   += ()           =&gt; { OnPointerExitFromCard(); };
        card_view.OnBeginDragAction     += ()           =&gt; { OnBeginDragCard(); };
        card_view.OnDragAction          += (position)   =&gt; { OnDragCard(position); };
        card_view.OnEndDragAction       += ()           =&gt; { OnEndDragCard(); };

        return card_view; 
    }    

    ...

    // 3번 과정
    private void OnEndDragCard()
    {
        ...

        var hit = CheckField(out var pointer_data);

        var drop_handler = hit?.gameObject.GetComponent&lt;IDropHandler&gt;();

        // 필드가 존재한다면 필드에 강제로 OnDrop 이벤트를 발생시킨다.
        if(drop_handler != null)
            ExecuteEvents.Execute(hit?.gameObject, pointer_data, ExecuteEvents.dropHandler);

        ...
    }

    // 2번 과정
    private RaycastResult? CheckField(out PointerEventData pointer_data)
    {
        // 현재 마우스 좌표를 기준으로 새로운 이벤트 데이터를 생성한다.
        pointer_data = new PointerEventData(EventSystem.current);
        pointer_data.position = Input.mousePosition;
        pointer_data.pointerDrag = (m_presenter.HoverCard as HandCardView).gameObject;

        // 생성한 이벤트 데이터를 바탕으로 현재 마우스 좌표에 위치한 모든 UI를 가져온다.
        var ray_hits = new List&lt;RaycastResult&gt;();
        EventSystem.current.RaycastAll(pointer_data, ray_hits);

        // 이 UI들에서 IDropHandler를 구현하는 UI가 있다면 그것이 필드다.
        foreach(var hit in ray_hits)
        {
            var drop_handler = hit.gameObject.GetComponent&lt;IDropHandler&gt;();
            if(drop_handler != null)
                return hit; 
        } 

        // 발견하지 못했다면 필드가 없는 것이다.
        return null;
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>위의 코드와 같이 강제로 이벤트를 발생시키면 <code>OnDrop</code>이 호출 됨을 확인했다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/afa0f2a2-6e6a-4d7f-b5c1-cc48d65eb383/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[인벤토리 시스템] 아이템 슬롯 컨텍스트]]></title>
            <link>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%EC%9D%B4%ED%85%9C-%EC%8A%AC%EB%A1%AF-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%EC%9D%B4%ED%85%9C-%EC%8A%AC%EB%A1%AF-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 15 Sep 2025 05:43:42 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>이번 글에서는 아이템 슬롯을 범용적으로 사용할 수 있도록 각 상황에 알맞는 슬롯을 돌려주거나 설정해주는 컨텍스트를 구현해볼 것이다.</p>
<p>글의 마지막에는 이 슬롯 컨텍스트가 라피에서는 어떻게 완성되었는지 전체 코드를 보이도록 하겠다. 시작해보록 하겠다.</p>
<p><br></br></p>
<h3 id="아이디어">아이디어</h3>
<p>처음부터 아이템 슬롯에 컨텍스트를 도입한 것은 아니었다. 라피에서 기능을 확장하면 확장할수록 아이템 슬롯의 <code>Presenter</code> 코드가 너무 비대해지는 것을 느꼈다.</p>
<p>여기서 구조적인 문제가 있다고 생각한 나는 <code>Presenter</code>의 책임을 줄이기 위해서 컨텍스트를 도입하기로 결정했다.</p>
<p>컨텍스트는 <code>SlotType</code>을 받아서 단순히 해당하는 컨텍스트의 <strong>슬롯을 반환하거나 해당 컨텍스트에서의 연산을 지원</strong>한다.</p>
<p><br></br></p>
<h3 id="itemslotcontext">ItemSlotContext</h3>
<p>우선 아이템 슬롯 컨텍스트를 구현하기 전에 확장성을 위해서 인터페이스를 작성했다.</p>
<pre><code class="language-C#">using System;
using InventoryService;

public interface IItemSlotContext
{
    // slot_type과 offset을 이용하여 특정 슬롯에 update_action 이벤트를 등록한다.
    void Register(SlotType slot_type, 
                  Action&lt;int, ItemData&gt; update_action, 
                  int offset = 0, 
                  int count = 0);

    // slot_type을 이용하여 update_action 이벤트를 해제한다.
    void Discard(SlotType slot_type, 
                 Action&lt;int, ItemData&gt; update_action);

    // slot_type과 offset을 이용하여 슬롯의 ItemData를 반환한다.
    ItemData Get(SlotType slot_type, 
                 int offset, 
                 int count = 1);

    // slot_type과 offset을 이용하여 슬롯의 ItemData를 설정한다.
    void Set(SlotType slot_type, 
             int offset, 
             ItemCode code, 
             int count);

    // slot_type과 offset을 이용하여 슬롯의 아이템 개수를 변경한다.
    void Update(SlotType slot_type, 
                int offset, 
                int count);

    // slot_type과 offset을 이용하여 슬롯을 비운다.
    void Clear(SlotType slot_type, 
               int offset);
}</code></pre>
<p>그리고 이 인터페이스에 맞춰서 구체화를 시킨다.</p>
<pre><code class="language-C#">using System;
using EquipmentService;
using InventoryService;
using ShortcutService;
using SkillService;

public class ItemSlotContext : IItemSlotContext
{
    private readonly IItemDataBase m_item_db;

    private readonly IInventoryService m_inventory_service;

    public ItemSlotContext(IItemDataBase item_db)
    {
        m_item_db = item_db;
        m_inventory_service = inventory_service;
    }

    public void Register(SlotType slot_type, Action&lt;int, ItemData&gt; update_action, int offset = 0, int count = 0)
    {
        switch (slot_type)
        {
            case SlotType.Inventory:
                m_inventory_service.OnUpdatedSlot += update_action;
                break;
        }       
    }             

    public void Discard(SlotType slot_type, Action&lt;int, ItemData&gt; update_action)
    {
        switch (slot_type)
        {
            case SlotType.Inventory:
                m_inventory_service.OnUpdatedSlot -= update_action;
                break;
        }        
    }    

    public ItemData Get(SlotType slot_type, int offset, int count = 1)
    {
        return slot_type switch
        {
            SlotType.Inventory              =&gt; m_inventory_service.GetItem(offset),
            _                               =&gt; null,
        };
    }

    public void Set(SlotType slot_type, int offset, ItemCode code, int count = 1)
    {
        var action = slot_type switch
        {
            SlotType.Inventory              =&gt; () =&gt; m_inventory_service.SetItem(offset, code, count),
            _                               =&gt; (Action)(() =&gt; {})
        };

        action();
    }

    public void Update(SlotType slot_type, int offset, int count)
    {
        var action = slot_type switch
        {
            SlotType.Inventory              =&gt; () =&gt; m_inventory_service.UpdateItem(offset, count),
            _                               =&gt; (Action)(() =&gt; {})
        };

        action();
    }

    public void Clear(SlotType slot_type, int offset)
    {
        var action = slot_type switch
        {
            SlotType.Inventory              =&gt; () =&gt; m_inventory_service.Clear(offset),
            _                               =&gt; (Action)(() =&gt; {})
        };

        action();     
    }    
}</code></pre>
<p><br></br></p>
<h3 id="itemslotcontextinstaller">ItemSlotContextInstaller</h3>
<p>위에서 작성한 <code>ItemSlotContext</code>가 정상적으로 동작하기 위해서는 인스톨러가 필요하다.</p>
<pre><code class="language-C#">using EquipmentService;
using InventoryService;
using ShortcutService;
using SkillService;
using UnityEngine;

public class ItemSlotContextInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;아이템 데이터베이스&quot;)]
    [SerializeField] private ItemDataBase m_item_db;

    public void Install()
    {
        var item_slot_context = new ItemSlotContext(m_item_db,
                                                    ServiceLocator.Get&lt;IInventoryService&gt;());
        DIContainer.Register&lt;IItemSlotContext&gt;(item_slot_context);
    }
}</code></pre>
<p><br></br></p>
<h3 id="itemslotcontext에-의한-확장">ItemSlotContext에 의한 확장</h3>
<h4 id="itemslotpresenter-확장">ItemSlotPresenter 확장</h4>
<pre><code class="language-C#">public class ItemSlotPresenter : IDisposable
{
    // 이전과 동일
    private readonly IItemSlotContext m_slot_context;

    public ItemSlotPresenter(IItemSlotView view,
                             IItemDataBase item_db,
                             IItemSlotContext slot_context,        // 확장된 부분
                             SlotType slot_type = SlotType.Inventory,
                             int item_count = 1)
    {
        m_view = view;
        m_item_db = item_db;
        m_slot_context = slot_context;                            // 확장된 부분
        m_offset = offset;
        m_slot_type = slot_type;
        m_item_count = item_count;

        m_slot_context.Register(m_slot_type, UpdateSlot, m_offset, m_item_count);

        m_view.Inject(this);
    }    

    // 이전과 동일

    public void Dispose()
    {
        m_slot_context.Discard(m_slot_type, UpdateSlot);        // 확장된 부분
    }    
}</code></pre>
</br>

<h4 id="itemslotfactory-확장">ItemSlotFactory 확장</h4>
<pre><code class="language-C#">public class ItemSlotFactory
{
    private readonly IItemSlotContext m_slot_context;                // 확장된 부분

    public ItemSlotFactory(IInventoryService inventory_service,
                           IItemSlotContext slot_context,            // 확장된 부분
                           IItemDataBase item_db,
                           ICursorDataBase cursor_db,
                           IItemCooler item_cooler)
    {
        m_inventory_service = inventory_service;

        m_slot_context = slot_context;                                // 확장된 부분

        m_item_db = item_db;
        m_cursor_db = cursor_db;
    }    

    public ItemSlotPresenter Instantiate(IItemSlotView view, 
                                         int offset, 
                                         SlotType slot_type, 
                                         int count = 1)
    {
        return new ItemSlotPresenter(view,
                                     m_item_db,
                                     m_slot_context,                // 확장된 부분
                                     offset,
                                     slot_type,
                                     count);
    }
}</code></pre>
</br>

<h4 id="itemslotfactoryinstaller-확장">ItemSlotFactoryInstaller 확장</h4>
<pre><code class="language-C#">using InventoryService;
using SkillService;
using UnityEngine;

public class ItemSlotFactoryInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;아이템 데이터베이스&quot;)]
    [SerializeField] private ItemDataBase m_item_db;

    [Header(&quot;커서 데이터베이스&quot;)]
    [SerializeField] private CursorDataBase m_cursor_db;

    public void Install()
    {
        var item_slot_factory = new ItemSlotFactory(ServiceLocator.Get&lt;IInventoryService&gt;(),
                                                    DIContainer.Resolve&lt;IItemSlotContext&gt;(),    // 확장된 부분
                                                    m_item_db,
                                                    m_cursor_db);
        DIContainer.Register&lt;ItemSlotFactory&gt;(item_slot_factory);
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>이번 글을 통해서 구현한 컨텍스트 덕분에 아이템 슬롯을 더욱 범용적으로 사용할 수 있게 되었다.</p>
<p>현재로서는 인벤토리 서비스만 존재하기 때문에 컨텍스트가 왜 필요한지 궁금할 수도 있을까봐 다음과 같이 전체 코드를 남기려고 한다.</p>
<pre><code class="language-C#">using System;
using EquipmentService;
using InventoryService;
using ShortcutService;
using SkillService;

public class ItemSlotContext : IItemSlotContext
{
    private readonly IItemDataBase m_item_db;

    private readonly IInventoryService m_inventory_service;
    private readonly IEquipmentService m_equipment_service;
    private readonly ISkillService m_skill_service;
    private readonly IShortcutService m_shortcut_service;

    public ItemSlotContext(IItemDataBase item_db,
                           IInventoryService inventory_service,
                           IEquipmentService equipment_service,
                           ISkillService skill_service,
                           IShortcutService shortcut_service)
    {
        m_item_db = item_db;
        m_inventory_service = inventory_service;
        m_equipment_service = equipment_service;
        m_skill_service = skill_service;
        m_shortcut_service = shortcut_service;
    }

    public void Register(SlotType slot_type, Action&lt;int, ItemData&gt; update_action, int offset = 0, int count = 0)
    {
        switch (slot_type)
        {
            case SlotType.Inventory:
                m_inventory_service.OnUpdatedSlot += update_action;
                break;

            case SlotType.Equipment:
                m_equipment_service.OnUpdatedSlot += update_action;
                break;

            case SlotType.Skill:
                m_skill_service.OnUpdatedSlot += update_action;
                break;

            case SlotType.Shortcut:
                m_shortcut_service.OnUpdatedSlot += update_action;
                break;

            case SlotType.Shop:
            case SlotType.Craft:
                update_action?.Invoke(offset, Get(slot_type, offset, count));
                break;
        }       
    }             

    public void Discard(SlotType slot_type, Action&lt;int, ItemData&gt; update_action)
    {
        switch (slot_type)
        {
            case SlotType.Inventory:
                m_inventory_service.OnUpdatedSlot -= update_action;
                break;

            case SlotType.Equipment:
                m_equipment_service.OnUpdatedSlot -= update_action;
                break;

            case SlotType.Skill:
                m_skill_service.OnUpdatedSlot -= update_action;
                break;

            case SlotType.Shortcut:
                m_shortcut_service.OnUpdatedSlot -= update_action;
                break;
        }        
    }    

    public ItemData Get(SlotType slot_type, int offset, int count = 1)
    {
        return slot_type switch
        {
            SlotType.Inventory              =&gt; m_inventory_service.GetItem(offset),
            SlotType.Equipment              =&gt; m_equipment_service.GetItem(offset),
            SlotType.Skill                  =&gt; m_skill_service.GetSkill(offset),
            SlotType.Shortcut               =&gt; m_shortcut_service.GetItem(offset),
            SlotType.Shop or SlotType.Craft =&gt; new ItemData(m_item_db.GetItem((ItemCode)offset).Code, count),
            _                               =&gt; null,
        };
    }

    public void Set(SlotType slot_type, int offset, ItemCode code, int count = 1)
    {
        var action = slot_type switch
        {
            SlotType.Inventory              =&gt; () =&gt; m_inventory_service.SetItem(offset, code, count),
            SlotType.Equipment              =&gt; () =&gt; m_equipment_service.SetItem(offset, code),
            SlotType.Shortcut               =&gt; () =&gt; m_shortcut_service.SetItem(offset, code),
            _                               =&gt; (Action)(() =&gt; {})
        };

        action();
    }

    public void Update(SlotType slot_type, int offset, int count)
    {
        var action = slot_type switch
        {
            SlotType.Inventory              =&gt; () =&gt; m_inventory_service.UpdateItem(offset, count),
            _                               =&gt; (Action)(() =&gt; {})
        };

        action();
    }

    public void Clear(SlotType slot_type, int offset)
    {
        var action = slot_type switch
        {
            SlotType.Inventory              =&gt; () =&gt; m_inventory_service.Clear(offset),
            SlotType.Equipment              =&gt; () =&gt; m_equipment_service.Clear(offset),
            SlotType.Shortcut               =&gt; () =&gt; m_shortcut_service.Clear(offset),
            _                               =&gt; (Action)(() =&gt; {})
        };

        action();     
    }    
}</code></pre>
<p>다음 글에서는 본격적으로 아이템 슬롯 이벤트와 연산에 대해서 알아볼 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[인벤토리 시스템] 커서 매니저]]></title>
            <link>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%BB%A4%EC%84%9C-%EB%A7%A4%EB%8B%88%EC%A0%80</link>
            <guid>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%BB%A4%EC%84%9C-%EB%A7%A4%EB%8B%88%EC%A0%80</guid>
            <pubDate>Mon, 15 Sep 2025 05:02:26 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>커서 매니저를 인벤토리 시스템에 포함시킬까 말까 고민을 많이 했다. </p>
<p>사실 커서 매니저의 역할은 인벤토리 시스템에 국한되는 것이 아니라 다양한 문맥에서 다양한 이벤트 호출에 의해서 커서를 변경하는데 이 이벤트가 인벤토리 시스템도 존재한다.</p>
<p>그래서 완벽한 인벤토리를 위해서는 필요하다고 생각해서 인벤토리 시스템에 엮었다.
그럼 시작해보자.</p>
<p><br></br></p>
<h3 id="아이디어">아이디어</h3>
<p>커서는 앞서 말했듯이 다양한 문맥과 다양한 이벤트에서 변경된다. 즉, 전역적으로 접근을 할 수 있있으면 편하다는 말이다.</p>
<p>하지만 전역적으로 접근을 하면 의존성 관리 측면에서 피해를 보는 면이 많기 때문에 SO를 사용하여 여러 씬에서 사용할 수 있게끔 하되, SO를 의존성 주입하는 방식을 채택해볼까 했다.</p>
<p><br></br></p>
<h3 id="커서-모드">커서 모드</h3>
<p>우선 커서가 변경될 경우를 생각하여 어떤 이벤트에서 어떤 커서가 될 지에 대한 설계가 우선적으로 필요하다.</p>
<p>내가 생각한 경우는 다음과 같다.</p>
<ol>
<li>기본 커서</li>
<li>아이템을 잡을 수 있는 경우, 아이템을 사용할 수 있는 경우</li>
<li>아이템을 잡은 경우</li>
<li>대기가 걸린 경우</li>
<li>NPC에 커서가 닿았을 경우</li>
<li>적에서 커서가 닿았을 경우</li>
</ol>
<p>따라서 커서 모드를 표현하기 위해 다음과 같이 <code>CursorMode</code>를 정의한다.</p>
<pre><code class="language-C#">public enum CursorMode
{
    NONE = -1,
    DEFAULT = 0,
    CAN_GRAB = 1,
    GRAB = 2,
    WAITING = 3,
    CAN_TALK = 4,
    CAN_ATTACK = 5,
}</code></pre>
<p><br></br></p>
<h3 id="커서-데이터">커서 데이터</h3>
<p>앞서 정의한 CursorMode에 해당하는 동작들을 연결시키기 위해서는 커서의 텍스처와 커서의 핫스팟에 대한 정보가 필요하다.</p>
<p>커서 모드, 커서 텍스처, 커서 핫스팟을 하나로 묶어 커서 데이터로 구성하고 커서 모드를 통해 해당하는 데이터를 불러올 수 있게끔하기 위함이다.</p>
<pre><code class="language-C#">using UnityEngine;

[System.Serializable]            // 인스펙터에 커서 데이터를 노출시키기 위함이다.
public class CursorData
{
    [Header(&quot;커서 모드&quot;)]
    public CursorMode Mode;

    [Header(&quot;커서 텍스처&quot;)]        // 커서 모드에 따라 변경될 텍스처
    public Texture2D Cursor;

    [Header(&quot;커서 핫스팟&quot;)]        // 커서 모드에 따라 변경될 핫스팟
    public Vector2 Hotspot;
}</code></pre>
<p>핫스팟에 대해서 간단하게 설명하자면 클릭될 위치를 말한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/646c2586-35ab-45de-a3f3-6594b68ed7c9/image.png" alt=""></p>
<p>좌상단은 (0, 0)이며 우하단은 (1, 1)의 좌표를 가진다. 이 핫스팟의 위치를 잘 설정해야 위화감이 없이 자연스럽게 클릭된다.</p>
<p>하지만 너무 딱딱 맞추려고 하면 마우스 커서 자체의 피벗이 커서마다 달라지게 되어 그것도 위화감이 생긴다. 따라서 적당한 선에서 타협하는 것이 좋다.</p>
<p><br></br></p>
<h3 id="커서-매니저">커서 매니저</h3>
<p>커서 매니저라는 네이밍도 좋지만 앞선 인벤토리 시스템을 구성하면서 아이템 매니저를 아이템 데이터베이스라는 이름으로 네이밍했기 때문에 통일감을 위해서 커서 데이터베이스라고 칭하겠다.</p>
<p>커서 데이터베이스는 SO로도 구현할 수 있지만 여러 방식으로 구현될 수 있다. 변경의 여지가 다분하다는 점이다. 따라서 나는 커서 데이터베이스를 위한 인터페이스를 정의했다.</p>
<pre><code class="language-C#">public interface ICursorDataBase
{
    // CursorMode에 해당하는 커서 데이터를 이용하여 커서를 변경한다.
    void SetCursor(CursorMode mode);
}</code></pre>
<p>그리고 이를 의도한 대로 SO를 이용하여 구체화한다.</p>
<pre><code class="language-C#">using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = &quot;Cursor DataBase&quot;, menuName = &quot;SO/DB/Cursor DataBase&quot;)]
public class CursorDataBase : ScriptableObject, ICursorDataBase
{
    [Header(&quot;커서 데이터의 목록&quot;)]
    [SerializeField] private List&lt;CursorData&gt; m_cursor_datas;

    private Dictionary&lt;CursorMode, CursorData&gt; m_cursor_dict;
    private CursorMode m_current_mode;

#if UNITY_EDITOR
    private void OnEnable()
    {
        Initialize();
    }
#endif

    private void Initialize()
    {
        if (m_cursor_dict != null)
        {
            return;
        }

        m_current_mode = CursorMode.NONE;
        m_cursor_dict = new();

        if (m_cursor_datas == null || m_cursor_datas.Count == 0)
        {
            return;
        }

        foreach (var data in m_cursor_datas)
        {
            m_cursor_dict.TryAdd(data.Mode, data);
        }
    }

    public void SetCursor(CursorMode mode)
    {
        if (m_cursor_dict == null)
        {
            Initialize();
        }

        if (m_current_mode.Equals(mode))
        {
            return;
        }

        if (m_cursor_dict.TryGetValue(mode, out var data))
        {
            var pivot = new Vector2(data.Cursor.width * data.Hotspot.x, 
                                    data.Cursor.height * data.Hotspot.y);
            Cursor.SetCursor(data.Cursor, pivot, UnityEngine.CursorMode.Auto);

            m_current_mode = mode;
        }
    }
}</code></pre>
<p>설명없이도 충분히 이해할만한 코드라고 생각해서 설명은 생략하겠다.</p>
<p><br></br></p>
<h3 id="커서-데이터로-인한-확장">커서 데이터로 인한 확장</h3>
<h4 id="itemslotview-확장">ItemSlotView 확장</h4>
<p>앞서 ItemSlotView에서 커서 데이터베이스를 <code>SerializeField</code> 어트리뷰트를 이용하여 인스펙터에서 주입받도록 구현했었다.</p>
<p>그리고 이에 대한 확장을 위해서 <code>SetCursor()</code>를 남겨뒀었는데 이를 확장한다. 왜 <code>CursorDataBase</code>를 구현해놓고 굳이 <code>SetCursor()</code>를 또 구현하는지 의문이 들 수도 있다.</p>
<p><code>SetCursor()</code>를 구현하지 않으면 <code>ItemSlotPresenter</code>에서 이벤트 처리를 할 때 직접적으로 <code>CursorDataBase</code>에 의존해야만 한다. </p>
<p>이 상황은 둘 사이의 결합도를 올려 모듈화를 해치는 원인이기 때문에 <code>Presenter</code>로부터 결합도를 낮출 필요가 있다.</p>
<pre><code class="language-C#">// ItemSlotView의 일부에서

public void SetCursor(CursorMode mode)
{
    m_cursor_db.SetCursor(mode);
}</code></pre>
</br>

<h4 id="itemslotfactory-확장">ItemSlotFactory 확장</h4>
<pre><code class="language-C#">using InventoryService;
using SkillService;

public class ItemSlotFactory
{
    private readonly IInventoryService m_inventory_service;

    private readonly IItemDataBase m_item_db;
    private readonly ICursorDataBase m_cursor_db;         // 확장된 부분

    public ItemSlotFactory(IInventoryService inventory_service,
                           IItemDataBase item_db,
                           ICursorDataBase cursor_db)    // 확장된 부분
    {
        m_inventory_service = inventory_service;

        m_item_db = item_db;
        m_cursor_db = cursor_db;                        // 확장된 부분
    }

    // 이전과 동일
}</code></pre>
</br>

<h4 id="itemslotfactoryinstaller-확장">ItemSlotFactoryInstaller 확장</h4>
<pre><code class="language-C#">// ItemSlotFactoryInstaller의 일부에서

public void Install()
{
    var item_slot_factory = new ItemSlotFactory(ServiceLocator.Get&lt;IInventoryService&gt;(),
                                                m_item_db,
                                                m_cursor_db);
    DIContainer.Register&lt;ItemSlotFactory&gt;(item_slot_factory);
}</code></pre>
<p><br></br></p>
<h3 id="커서-데이터베이스-생성">커서 데이터베이스 생성</h3>
<p>확장이 모두 끝났다면 커서 데이터베이스를 생성하고 데이터를 채워넣어야 한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/340b4cf7-b3c7-481b-9a09-546927f67120/image.png" alt=""></p>
<p>위의 사진과 같이 커서 데이터베이스를 생성한다. 그리고 다음과 같이 데이터베이스에 데이터를 채워넣는다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/84460ba9-a607-4814-977d-c8f36cab3797/image.png" alt=""></p>
<p>설명하지 않은 부분이 있는데 커서 데이터에 사용되는 커서 텍스처는 <code>Texture Type</code>을 <code>Cursor</code>로 설정해야만 사용할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/a879bb4a-10c1-48f4-9efd-e0495f5d9d0f/image.png" alt=""></p>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>이번 글에서는 커서 데이터베이스를 생성하여 의존성 주입하는 것을 구현해봤다.</p>
<p>다음 글에서는 아이템 슬롯 컨텍스트를 구현하여 여러 문맥에서 범용적으로 사용할 수 있도록 아이템 슬롯을 디자인할 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[인벤토리 시스템] 아이템 슬롯]]></title>
            <link>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%EC%9D%B4%ED%85%9C-%EC%8A%AC%EB%A1%AF</link>
            <guid>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%EC%9D%B4%ED%85%9C-%EC%8A%AC%EB%A1%AF</guid>
            <pubDate>Mon, 15 Sep 2025 04:14:24 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>인벤토리 UI는 앞선 글에서 설명했듯이 아이템 슬롯을 담는 컨테이너에 불과하다. 아이템 슬롯이 그만큼 중요하며 많은 기능을 담당한다.</p>
<p>많은 기능을 담당하는 만큼 코드가 비대해지므로 이를 어떻게 분리하느냐가 아이템 슬롯 구현의 핵심이라고 할 수 있다.</p>
<p>역시나 마찬가지로 아이템 슬롯 또한 MVP 패턴을 이용하여 구현할 것이다.</p>
<p><br></br></p>
<h3 id="아이디어">아이디어</h3>
<p>우선 인벤토리 슬롯에서 필요한 기능들을 조금 정리할 필요가 있다.</p>
<ol>
<li>획득한 아이템에 해당하는 <strong>스프라이트 이미지와 아이템의 개수를 표시</strong>할 수 있어야 한다.</li>
<li>슬롯에 들어올 수 있는 <strong>아이템 타입을 특정</strong>하여 특정 아이템만을 가질 수 있어야 한다.</li>
<li>마우스 클릭, 드래그를 이용하여 <strong>슬롯 동작(클릭, 드래그, 드랍)</strong>을 할 수 있어야 한다.</li>
</ol>
<p>3번에 해당하는 내용은 이번에 설명할 범위가 아니므로 이 코드만 제외하여 확장하도록 한다.</p>
<p><br></br></p>
<h3 id="view">View</h3>
<p>우선적으로 <code>View</code>의 인터페이스를 정의하기 전에 과연 인터페이스에서 포함해야 할 기능들이 무엇인지 생각을 해야한다.</p>
<p>인벤토리에 아이템이 들어오면 이에 알맞게 슬롯을 업데이트할 수 있어야 하고, 아이템을 사용하거나 잃게 되면 슬롯을 비울 수 있어야 한다.</p>
<p>또한 특정 아이템 타입만을 받을 수 있도록 비트 연산을 통해서 걸러내야 한다.</p>
<pre><code class="language-C#">using UnityEngine;
using UnityEngine.EventSystems;

public interface IItemSlotView : IPointerEnterHandler, IPointerExitHandler, IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler, IPointerClickHandler
{
    void Inject(ItemSlotPresenter presenter);

    void ClearUI();
    void UpdateUI(Sprite item_image, bool stackable, int count);

    bool IsMask(ItemType type);            // 특정 아이템 타입만 걸러낼 때 사용한다.    
    void SetCursor(CursorMode mode);    // 커서의 스프라이트를 변경할 때 사용한다.
}</code></pre>
</br>

<p>이를 토대로 구체화 아이템 슬롯인 <code>ItemSlotView</code>를 작성한다.</p>
<pre><code class="language-C#">using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class ItemSlotView : MonoBehaviour, IItemSlotView
{
    [Header(&quot;커서 데이터베이스&quot;)]
    [SerializeField] private CursorDataBase m_cursor_db;

    [Space(30f)]
    [Header(&quot;UI 관련 트랜스폼&quot;)]
    [Header(&quot;아이템 이미지&quot;)]
    [SerializeField] private Image m_item_image;

    [Header(&quot;아이템 개수&quot;)]
    [SerializeField] private TMP_Text m_count_label;

    [Header(&quot;쿨타임 이미지&quot;)]
    [SerializeField] private Image m_cooldown_image;

    [Header(&quot;슬롯 마스크&quot;)]
    [SerializeField] private ItemType m_slot_type;

    private ItemSlotPresenter m_presenter;

    private void Update() {}

    private void OnDestroy() {}

    public void Inject(ItemSlotPresenter presenter)
    {
        m_presenter = presenter;
    }

    // 아이템 슬롯을 비운다.
    // 1. 아이템 스프라이트 제거
    // 2. 아이템 개수 텍스트 초기화
    // 3. 쿨타임 이미지 초기화
    public void ClearUI()
    {
        m_item_image.sprite = null;
        SetAlpha(0f);

        m_count_label.text = string.Empty;
        m_count_label.gameObject.SetActive(false);

        m_cooldown_image.fillAmount = 0f;
    }

    // 아이템 슬롯에 아이템을 추가한다.
    // 1. 아이템 스프라이트 추가
    // 2. 중첩 아이템이면 아이템 개수 텍스트를 활성화
    // 3. 쿨타임 이미지 초기화
    public void UpdateUI(Sprite item_image, bool stackable, int count)
    {
        m_item_image.sprite = item_image;
        SetAlpha(1f);

        if (stackable)
        {
            m_count_label.gameObject.SetActive(true);
            m_count_label.text = NumberFormatter.FormatNumber(count);
        }
        else
        {
            m_count_label.gameObject.SetActive(false);
        }

        m_cooldown_image.fillAmount = 0f;
    }

    // 슬롯에 넣을 수 있는 아이템 타입인지 확인한다.
    public bool IsMask(ItemType type)
    {
        return ((int)m_slot_type &amp; (int)type) != 0;
    }

    // 아이템 스프라이트를 특정한 알파값으로 설정한다.
    private void SetAlpha(float alpha)
    {
        var color = m_item_image.color;
        color.a = alpha;
        m_item_image.color = color;
    }

    public void SetCursor(CursorMode mode) {}

    public void OnPointerEnter(PointerEventData eventData) {}

    public void OnPointerExit(PointerEventData eventData) {}

    public void OnBeginDrag(PointerEventData eventData) {}

    public void OnDrag(PointerEventData eventData) {}

    public void OnEndDrag(PointerEventData eventData) {}

    public void OnDrop(PointerEventData eventData) {}

    public void OnPointerClick(PointerEventData eventData) {}
}</code></pre>
<p>지금의 글과는 관련이 없기 때문에 커서와 관련된 내용들은 우선 제거한다.</p>
<h4 id="ismask">IsMask</h4>
<p>위의 코드에서 대부분의 내용들은 이해할 수 있으리라 생각한다. <code>IsMask()</code>에 대해서만 이야기를 해보자.
<code>IsMask()</code>는 특정 <code>ItemType</code>의 아이템만을 추리는 역할을 한다.</p>
<p>예를 들어, 앞서 정의한 <code>ItemType</code>에서 <strong>Skill</strong>을 생각해보자. 스킬은 <code>1 &lt;&lt; 3</code>으로 정의되어 있기 때문에 <code>1000</code>이랑 동일하다.</p>
<p>너무 자명한 사실로 스킬 아이템은 인벤토리에 포함되면 안된다. 이를 표현하려면 <code>ItemSloView</code>의 슬롯 마스크를 Skill을 제외하고 모두 체크하면 된다.</p>
<p>그러면 다음과 같은 상황이랑 동일하다.</p>
<pre><code>아이템 슬롯의 비트
11110111

스킬 아이템의 비트
00001000

이 둘을 &amp; 연산을 한다.

    11110111
 &amp;  00001000
-------------
    00000000 =&gt; False</code></pre><p>이 정도면 <code>IsMask()</code>에 대해서 어느정도 이해했으리라 생각한다.</p>
<p><br></br></p>
<h3 id="presenter">Presenter</h3>
<p>이제 <code>View</code>에 연결할 <code>Presenter</code>를 구현해보자.</p>
<pre><code class="language-C#">public class ItemSlotPresenter
{
    private readonly IItemSlotView m_view;
    private readonly IItemDataBase m_item_db;

    private DragSlotPresenter m_drag_slot_presenter;

    private int m_offset;
    private SlotType m_slot_type;
    private int m_item_count;

    // 상점이나 제작소에서 사용되는 슬롯인지의 여부를 확인한다.
    public bool IsShopOrCraft =&gt; m_slot_type == SlotType.Shop || m_slot_type == SlotType.Craft;

    public ItemSlotPresenter(IItemSlotView view,
                             IItemDataBase item_db,
                             int offset,
                             SlotType slot_type = SlotType.Inventory,
                             int item_count = 1)
    {
        m_view = view;
        m_item_db = item_db;
        m_offset = offset;
        m_slot_type = slot_type;
        m_item_count = item_count;

        m_view.Inject(this);
    }

    public void UpdateSlot(int offset, ItemData item_data)
    {
        // 상점이나 제작소에 사용되는 슬롯이거나
        // 오프셋이 다른 경우에는 업데이트하지 않는다.
        if (!IsShopOrCraft &amp;&amp; m_offset != offset)
        {
            return;
        }

        // 아이템 코드가 비어있을 경우, 슬롯이 빈 것으로 판단한다.
        if (item_data.Code == ItemCode.NONE)
        {
            m_view.ClearUI();
            return;
        }

        // 아이템 데이터베이스로부터 SO를 얻어 그 정보로 View를 갱신한다.
        var item = m_item_db.GetItem(item_data.Code);
        m_view.UpdateUI(item.Sprite, item.Stackable, item_data.Count);
    }
}</code></pre>
<p><br></br></p>
<h3 id="아이템-슬롯-팩토리">아이템 슬롯 팩토리</h3>
<h4 id="itemslotfactory">ItemSlotFactory</h4>
<p>아이템 슬롯은 앞선 글에서 말했듯이 범용적으로 사용되며, 아이템 슬롯 자체를 풀링하여 사용하는 UI도 존재한다. 이를테면.. 제작소나 상점이 이에 해당한다.</p>
<p>따라서 손 쉽게 아이템 슬롯을 생성하기 위해 아이템 슬롯 팩토리를 구현한다. 지금까지의 내용으로는 아이템 슬롯 팩토리 역시 완전히 완성시키진 못한다.</p>
<pre><code class="language-C#">using InventoryService;
using SkillService;

public class ItemSlotFactory
{
    private readonly IInventoryService m_inventory_service;
    private readonly IItemDataBase m_item_db;

    public ItemSlotFactory(IInventoryService inventory_service,
                           IItemDataBase item_db,)
    {
        m_inventory_service = inventory_service;

        m_item_db = item_db;
    }

    public ItemSlotPresenter Instantiate(IItemSlotView view, int offset, SlotType slot_type, int count = 1)
    {
        return new ItemSlotPresenter(view,
                                     m_item_db,
                                     offset,
                                     slot_type,
                                     count);
    }
}</code></pre>
</br>

<p>그리고 이 팩토리가 동작하기 위해서는 팩토리 인스톨러가 필요하다.</p>
<pre><code class="language-C#">using InventoryService;
using SkillService;
using UnityEngine;

public class ItemSlotFactoryInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;아이템 데이터베이스&quot;)]
    [SerializeField] private ItemDataBase m_item_db;

    [Header(&quot;커서 데이터베이스&quot;)]
    [SerializeField] private CursorDataBase m_cursor_db;

    public void Install()
    {
        var item_slot_factory = new ItemSlotFactory(ServiceLocator.Get&lt;IInventoryService&gt;(),
                                                    m_item_db);
        DIContainer.Register&lt;ItemSlotFactory&gt;(item_slot_factory);
    }
}</code></pre>
<p>이 팩토리 인스톨러를 인벤토리 UI 인스톨러 이전에 배치하여 아이템 슬롯을 사용하는 UI를 인스톨하기 전에 미리 설치되어 있게 설정해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/a778d701-54cb-4468-802e-47c345d7620f/image.png" alt=""></p>
<p><br></br></p>
<h3 id="inventoryuiinstaller-확장">InventoryUIInstaller 확장</h3>
<p>아이템 슬롯과 팩토리를 추가했으니 인벤토리 UI 인스톨러를 확장해야 한다.</p>
<pre><code class="language-C#">using InventoryService;
using UnityEngine;

public class InventoryUIInstaller : MonoBehaviour, IInstaller
{
    // 이전과 동일

    public void Install()
    {
        DIContainer.Register&lt;IInventoryView&gt;(m_inventory_view);

        DIContainer.Register&lt;IInventoryService&gt;(ServiceLocator.Get&lt;IInventoryService&gt;());

        // ***새롭게 추가된 내용 시작***
        var slot_views = m_item_slot_root.GetComponentsInChildren&lt;IItemSlotView&gt;();

        var item_slot_factory = DIContainer.Resolve&lt;ItemSlotFactory&gt;();

        var slot_presenters = new ItemSlotPresenter[slot_views.Length];
        for (int i = 0; i &lt; slot_presenters.Length; i++)
        {
            slot_presenters[i] = item_slot_factory.Instantiate(slot_views[i], i, SlotType.Inventory);
        }

        var m_inventory_presenter = new InventoryPresenter(m_inventory_view,
                                                           ServiceLocator.Get&lt;IInventoryService&gt;(),
                                                           slot_presenters);
        // ***새롭게 추가된 내용 끝***
        DIContainer.Register&lt;InventoryPresenter&gt;(m_inventory_presenter);

        Inject();
    }

    private void Inject()
    {
        var item_db = DIContainer.Resolve&lt;IItemDataBase&gt;();

        var inventory_model = DIContainer.Resolve&lt;IInventoryService&gt;();
        inventory_model.Inject(item_db);
    }
}</code></pre>
<p><br></br></p>
<h3 id="inventory-presenter-확장">Inventory Presenter 확장</h3>
<p>앞선 글에서는 골드만 관리했던 인벤토리의 <code>Presenter</code>에서 이제 아이템 슬롯이 추가되었으니 아이템 슬롯들도 관리해야 한다.</p>
<p>따라서 우리는 <code>InventoryUIInstaller</code>를 확장한 것에 이어 <code>InventoryPresenter</code>를 맞춰서 작성한다.</p>
<pre><code class="language-C#">using System;
using InventoryService;

public class InventoryPresenter : IDisposable, IPopupPresenter
{
    private readonly IInventoryView m_view;
    private readonly IInventoryService m_model;

    private ItemSlotPresenter[] m_slot_presenters;

    public InventoryPresenter(IInventoryView view, IInventoryService model, ItemSlotPresenter[] slot_presenters)
    {
        m_view = view;
        m_model = model;
        m_slot_presenters = slot_presenters;

        m_model.OnUpdatedGold += m_view.UpdateMoney;
        m_view.Inject(this);
    }

    public void OpenUI() { /*이전과 동일*/ }

    public void CloseUI() { /*이전과 동일*/ }

    public void Initialize()
    {
        m_model.InitializeGold();
        for (int i = 0; i &lt; 30; i++)
        {
            m_model.InitializeSlot(i);
        }
    }

    // 이전과 동일
}</code></pre>
<p><br></br></p>
<h3 id="아이템-슬롯-ui">아이템 슬롯 UI</h3>
<p>이제 기본적인 아이템 슬롯에 관련된 스크립트들을 모두 작성했으니, UI를 생성한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/72de4898-8318-4f3f-8267-6b22cf74668a/image.png" alt=""></p>
<p>그리고 프리펩화하고, 인벤토리 UI 하위의 어딘가에 <code>Grid Layout Group</code>을 이용하여 의도한 인벤토리 개수만큼 이를 배치한다.</p>
<p>동적으로 아이템 슬롯이 늘어나는 인벤토리 구조에는 적합하지 않으니 알아서 이를 참고하여 코드를 고쳐 사용하는 좋다.</p>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>이렇게 기본적인 아이템 슬롯 기능을 완성해봤다. </p>
<p>아마 아직까진 아이템 획득 기능을 따로 구현해서 제대로 작동하는지 확인한다고 해도 제대로 동작하지 않을 것이다. 인벤토리 슬롯의 내용 중 20% 정도만을 설명한 것이기 때문이다.</p>
<p>다음 글에서는 커서 매니저에 대해서 설명해보고자 한다. <del>다음에 봐용.</del></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] 물체의 밑면과 경사면을 일치시키는 방법]]></title>
            <link>https://velog.io/@jxng-min/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EB%AC%BC%EC%B2%B4%EC%9D%98-%EB%B0%91%EB%A9%B4%EA%B3%BC-%EA%B2%BD%EC%82%AC%EB%A9%B4%EC%9D%84-%EC%9D%BC%EC%B9%98%EC%8B%9C%ED%82%A4%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@jxng-min/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EB%AC%BC%EC%B2%B4%EC%9D%98-%EB%B0%91%EB%A9%B4%EA%B3%BC-%EA%B2%BD%EC%82%AC%EB%A9%B4%EC%9D%84-%EC%9D%BC%EC%B9%98%EC%8B%9C%ED%82%A4%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Tue, 09 Sep 2025 09:17:21 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>Cast Away 프로젝트에서 중립 동물 NPC를 구현하는 과정에서 동물 NPC가 경사를 올라가는 도중 동물 NPC의 발과 그림자에서 위화감을 느꼈다.</p>
<p>Scene 뷰를 통하여 관찰한 결과 <code>Box Collider</code> 컴포넌트의 한 모서리만 붙어있는 채로 공중에 떠 있는 동물 NPC를 발견할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/475bd20b-30c9-47cb-9123-9241b792e0ee/image.png" alt=""></p>
<p>이를 해결하기 위해 간단히 <code>Rigidbody</code> 컴포넌트의 X축 회전 고정을 풀면 경사면에 맞춰 밑면이 들리거나 가라 앉을 줄 알았으나 동물이 뒤집혀 버둥버둥 대는 꼴만 보게 되었다..</p>
<p><br></br></p>
<h3 id="아이디어">아이디어</h3>
<p>위에서 얻은 실패 덕분에 많은 고민을 하게 되었다. 내가 생각한 방법은 <strong>지면의 법선 벡터에 동물의 업 벡터를 일치</strong>시키는 방법이었다.</p>
<p>지면의 법선 벡터에 동물의 업 벡터를 일치시키게 되면 동물은 자연스럽게 경사면 위에 있다는 느낌을 받게 되지 않을까 싶었다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/ded15c2c-6ea4-4029-8b26-461ec8d9f685/image.png" alt=""></p>
<p>물론 동물의 업 벡터를 지면의 법선 벡터에 단순히 보간하는 작업이기 때문에 저렇게 딱 알맞게 붙는다기보다 툭 하고 떨어지는 느낌이지만 그 경사가 높지 않아서 자연스러워 보인다.</p>
<p>생각은 단순했지만 <strong>지면의 법선 벡터를 어떻게 하면 얻을 수 있을까?</strong>에 대해서 고민을 했다. 면의 법선 벡터는 레이캐스팅을 통해서 구할 수 있었다.</p>
<p>레이캐스팅의 결과 값으로 사용하는 <code>RaycastHit</code>에는 <code>normal</code>이라는 속성이 존재한다. 
<code>normal</code>은 <strong>충돌한 지점에서 충돌체 면에 수직인 벡터</strong>다. 즉, <strong>법선 벡터</strong>다.</p>
<p><br></br></p>
<h3 id="해결">해결</h3>
<h4 id="quaternionfromtorotation">Quaternion.FromToRotation</h4>
<p>동물의 업 벡터를 지면의 법선 벡터와 일치시키는 회전을 만들기 위해서 사용했다.</p>
<p><code>Quaternion.FromToRotation(from, to)</code>는 <code>from</code> 벡터를 <code>to</code> 벡터로 만들기 위한 회전을 생성한다.
예를 들어, $(0, 1, 0)$을  $(1, 0, 0)$로 회전시킨다고 하면 다음과 같다.
<img src="https://velog.velcdn.com/images/jxng-min/post/9bffda54-ad7d-400d-837b-0799fdf6a30c/image.png" alt=""></p>
<p>Y축을 X축으로 만드는 것과 같기 때문에 Z축으로 90˚ 회전이 발생한다. 회전 쿼터니언을 생성한 것이다.</p>
</br>

<p>이처럼 동물 NPC의 업 벡터를 지면의 법선 벡터에 일치시켜 회전 쿼터니언을 생성할 수 있다.</p>
<pre><code class="language-C#">Quaternion.FromToRotation(transform.up, hit.normal);</code></pre>
<p>그리고 원하는 회전 값을 얻기 위해 현재 회전 값을 곱한다.</p>
<pre><code class="language-C#">var target_rotation = Quaternion.FromToRotation(transform.up, hit.normal) * transform.rotation;</code></pre>
</br>

<h4 id="quaternionslerp">Quaternion.Slerp</h4>
<p>두 벡터의 방향이 너무 다르면 자연스럽고 부드럽게 보간되지 못하기 때문에 <code>Quaternion.Slerp</code>를 이용하여 구면 보간을 한다.</p>
<p>구면 보간의 강도를 조절할 수 있도록 강도를 변수로 둔다.</p>
<pre><code class="language-C#">transform.rotation = Quaternion.Slerp(transform.rotation, target_rotation, Time.deltaTime * m_smoothness);</code></pre>
</br>

<h4 id="전체-코드">전체 코드</h4>
<pre><code class="language-C#">// AnimalMovement.cs의 일부 발췌

public class AnimalMovement
{
    [Header(&quot;레이의 길이&quot;)]
    [SerializeField] private float m_ray_distance;

    [Header(&quot;레이가 감지할 레이어&quot;)]
    [SerializeField] private LayerMask m_ground_mask;

    [Header(&quot;보간 강도&quot;)]
    [SerializeField] private float m_smoothness;

    public InclineInterpolation()
    {
        if(Physics.Raycast(transform.position + Vector3.up, 
                           Vector3.down, 
                           out var hit, 
                           m_ray_distance,
                           m_ground_layer))
        {
            var target_rotation = Quaternion.FromToRotation(transform.up, hit.normal) * tranform.rotation;
            transform.rotation = Quaternion.Slerp(transform.rotation, target_rotation, Time.deltaTime * m_smoothness);
        }
    }
}</code></pre>
<p><br></br></p>
<h3 id="결과">결과</h3>
<p>나름 만족스럽게 경사면에 하단이 붙은 채로 움직이는 것을 확인할 수 있다.</p>
<div align="center">

<img src = https://velog.velcdn.com/images/jxng-min/post/5bc5bd9d-46a4-4d9e-b9c8-66c17e15e239/image.gif width=800>

</div>





]]></description>
        </item>
        <item>
            <title><![CDATA[[인벤토리 시스템] 인벤토리 UI]]></title>
            <link>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-UI</link>
            <guid>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-UI</guid>
            <pubDate>Wed, 13 Aug 2025 15:42:59 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>저번 글에서는 <code>Item</code>과 <code>ItemData</code>를 이용하여 인벤토리 서비스를 구현했었다. 이번 글에서는 인벤토리 서비스를 이용하여 인벤토리 UI를 구현해본다. </p>
<p>인벤토리 시스템에서 인벤토리 UI는 사실 큰 비중을 차지하지 못한다. 단순히 <strong>아이템 슬롯들을 보관해주는 컨테이너의 역할 그 이상 그 이하도 아니다</strong>.</p>
<p>하지만 돈 관리를 인벤토리 서비스로 책임을 위임했기 때문에 <strong>돈 관리는 인벤토리 UI에서</strong> 맡는다. 별로 어려운 내용은 아직 아니다. 그러니 계속 이어가보자.</p>
<p>만약 인벤토리 UI 이미지가 없다면 아래의 이미지를 사용하도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/7abd2e1e-2e84-4abb-ba18-51b0751266c8/image.png" alt=""></p>
<p><br></br></p>
<h3 id="view">View</h3>
<p>인벤토리에서 사용할 <code>View</code>를 인터페이스로 먼저 정의한다.</p>
<pre><code class="language-C#">public interface IInventoryView : IPopupView
{
    void Inject(InventoryPresenter inventory_presenter);

    void OpenUI();
    void CloseUI();

    void UpdateMoney(int amount);
}</code></pre>
<p>인벤토리 UI는 팝업 UI이기도 하기 때문에 <code>IInventoryView</code>는 <code>IPopupView</code>를 구현한다.</p>
<pre><code class="language-C#">using TMPro;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(Animator))]
public class InventoryView : MonoBehaviour, IInventoryView
{
    [Header(&quot;UI 관련 컴포넌트&quot;)]
    [Header(&quot;팝업 UI 매니저&quot;)]
    [SerializeField] private PopupUIManager m_ui_manager;

    [Header(&quot;골드&quot;)]
    [SerializeField] private TMP_Text m_gold_label;

    [Header(&quot;닫기 버튼&quot;)]
    [SerializeField] private Button m_close_button;

    private Animator m_animator;
    private InventoryPresenter m_presenter;

    private void Awake()
    {
        m_animator = GetComponent&lt;Animator&gt;();
    }

    // 객체가 파괴되는 시점에 프레젠터에서 연결된 돈 갱신 이벤트를 해제한다.
    private void OnDestroy()
    {
        m_presenter.Dispose();
    }

    // 인벤토리 프레젠터를 Inject()에서 주입받는다.
    public void Inject(InventoryPresenter inventory_presenter)
    {
        m_presenter = inventory_presenter;

        m_close_button.onClick.AddListener(m_presenter.CloseUI);
        m_close_button.onClick.AddListener(PopupCloseUI);
    }

    public void OpenUI()
    {
        m_animator.SetBool(&quot;Open&quot;, true);
    }

    public void CloseUI()
    {
        m_animator.SetBool(&quot;Open&quot;, false);
    }

    // 돈이 갱신되면 돈 텍스트를 amount만큼 변경한다.
    public void UpdateMoney(int amount)
    {
        m_gold_label.text = NumberFormatter.FormatNumber(amount);
    }

    // 키 바인더 UI와 정확히 동일하기 때문에 설명은 생략한다.
    public void SetDepth()
    {
        (transform as RectTransform).SetAsFirstSibling();
    }

    // 이 부분도 앞으로는 설명을 생략한다.
    public void PopupCloseUI()
    {
        m_ui_manager.RemovePresenter(m_presenter);
    }
}</code></pre>
<p>사실 코드 설명을 할 필요도 없을만큼 간단한게 인벤토리 UI다.</p>
<p><br></br></p>
<h3 id="presenter">Presenter</h3>
<p>이번 글에서 Presenter를 전부 다 완성할 수는 없다. Presenter는 <a href="">여기</a>서 설명한 구조에 의해 <code>ItemSlotPresenter</code>를 관리해야 하지만.. 이번 글에서는 다루지 않는다.</p>
<p>따라서 이번 글에서는 <code>ItemSlotPresenter</code> 관리를 제외한 나머지 부분들만 소개하고 다음 글에서 <code>InventoryPresenter</code>를 확장하도록 한다.</p>
<pre><code class="language-C#">using System;
using InventoryService;

public class InventoryPresenter : IDisposable, IPopupPresenter
{
    private readonly IInventoryView m_view;
    private readonly IInventoryService m_model;

    // 생성자를 통해서 view와 인벤토리 서비스를 주입받는다.
    public InventoryPresenter(IInventoryView view, IInventoryService model)
    {
        m_view = view;
        m_model = model;

        // 인벤토리 서비스의 돈 갱신 델리게이트에 뷰의 돈 갱신 이벤트를 연결한다.
        m_model.OnUpdatedGold += m_view.UpdateMoney;
        m_view.Inject(this);
    }

    // 인벤토리를 열 때
    public void OpenUI()
    {
        // 돈을 현재 보유한 돈만큼 초기화한다.
        // 없어도 무방하지만, 초기화 과정에서의 오류를 방지하기 위함이다.
        Initialize();
        m_view.OpenUI();
    }

    public void CloseUI()
    {
        m_view.CloseUI();
    }

    // 인벤토리 UI를 초기화할 때 사용한다.
    public void Initialize()
    {
        m_model.InitializeGold();
    }

    // 인벤토리 서비스의 델리게이트에 연결된 이벤트를 해제한다.
    public void Dispose()
    {
        m_model.OnUpdatedGold -= m_view.UpdateMoney;
    }

    public void SortDepth()
    {
        m_view.SetDepth();
    }
}</code></pre>
<p><br></br></p>
<h3 id="ui-구성">UI 구성</h3>
<p>위에서 <code>IInventoryView</code>와 <code>InventoryView</code>, <code>InventoryPresenter</code>를 모두 구현했다면 에디터로 돌아와서 아래와 같이 인벤토리 UI를 구성한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/501d90fc-adb3-4d0a-bed9-762f63f3da9f/image.png" alt=""></p>
<p>팝업 UI 매니저는 이전에 구현했던 <code>PopupUIManager</code> 컴포넌트를 인스펙터를 통해서 할당한다.</p>
<p><br></br></p>
<h3 id="인스톨러-등록">인스톨러 등록</h3>
<p>위에서 구성한 인벤토리 UI가 제대로 작동하도록 <code>InventoryUIInstaller</code>를 구현해야 한다. </p>
<pre><code class="language-C#">using InventoryService;
using UnityEngine;

public class InventoryUIInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;아이템 데이터베이스&quot;)]
    [SerializeField] private ItemDataBase m_item_db;

    [Header(&quot;인벤토리 뷰&quot;)]
    [SerializeField] private InventoryView m_inventory_view;

    public void Install()
    {
        DIContainer.Register&lt;IItemDataBase&gt;(m_item_db);
        DIContainer.Register&lt;IInventoryView&gt;(m_inventory_view);

        var m_inventory_presenter = new InventoryPresenter(m_inventory_view,
                                                           ServiceLocator.Get&lt;IInventoryService&gt;());
        DIContainer.Register&lt;InventoryPresenter&gt;(m_inventory_presenter);

        Inject();
    }

    private void Inject()
    {
        var item_db = DIContainer.Resolve&lt;IItemDataBase&gt;();

        var inventory_service = ServiceLocator.Get&lt;IInventoryService&gt;();
        inventory_service.Inject(item_db);
    }
}</code></pre>
<p><code>InventoryUIInstaller</code>도 <strong>아이템 슬롯에 관한 부분은 현재로서는 제거</strong>한 상태다.
앞으로 아이템 슬롯에 관한 내용을 설명하면서 추후에 확장해가도록 하겠다.</p>
<p><br></br></p>
<h3 id="팝업-ui-관리자-인스톨러-확장">팝업 UI 관리자 인스톨러 확장</h3>
<p>인벤토리 UI가 추가되었으니, 인벤토리 UI를 활성화 및 비활성화할 수 있어야 한다. 따라서 팝업 UI 관리자를 확장하기 전에 팝업 UI 관리자 인스톨러를 먼저 확장한다.</p>
<p>이전에 구현했던 <code>PopupUIManagerInstaller</code>에서 다음과 같이 추가한다.</p>
<pre><code class="language-C#">using System.Collections.Generic;
using UnityEngine;

public class PopupUIManagerInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;팝업UI 매니저&quot;)]
    [SerializeField] private PopupUIManager m_popup_manager;
    public void Install()
    {
        var popup_data_list = new List&lt;PopupData&gt;{
            // 이전과 동일
            new(&quot;Inventory&quot;, DIContainer.Resolve&lt;InventoryPresenter&gt;()),    // 추가되었다.
        };

        m_popup_manager.Inject(popup_data_list);
    }
}</code></pre>
<p><br></br></p>
<h3 id="팝업-ui-관리자-확장">팝업 UI 관리자 확장</h3>
<p>팝업 UI 관리자 인스톨러에서 <code>InventoryPresenter</code>를 리스트 목록에 추가했으니 이에 맞춰서 <code>PopupUIManager</code>에서도 <code>InventoryPresenter</code>에 키 입력을 전달해주도록 확장한다.</p>
<pre><code class="language-C#">using System.Collections.Generic;
using KeyService;
using UnityEngine;

public class PopupUIManager : MonoBehaviour
{
    // 이전과 동일

    private void Update()
    {
        // 이전과 동일

        if (GameManager.Instance.Event != GameEventType.SETTING)
        {
            InputToggleKey(&quot;Binder&quot;);
            InputToggleKey(&quot;Inventory&quot;);    // 추가되었다.
        }
    }

    // 이전과 동일
}
</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>잘 따라 왔다면.. 아래와 같이 정상적으로 인벤토리 UI가 활성화 및 비활성화 되는 모습을 볼 수 있다. 아까도 언급했지만 슬롯과 관련된 부분은 아직 설명하지 않아서 당연히 없는 것이 맞다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/59a1bcb2-7e20-488f-b6c7-8dedf83c7d8d/image.gif" alt=""></p>
<p>다음 글에서는 본격적으로 아이템 슬롯에 관한 내용들을 이어갈 것이다. 쉽진 않다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[인벤토리 시스템] 인벤토리 서비스]]></title>
            <link>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%84%9C%EB%B9%84%EC%8A%A4</link>
            <guid>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%84%9C%EB%B9%84%EC%8A%A4</guid>
            <pubDate>Wed, 13 Aug 2025 14:57:01 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>저번 글에서 정의한 <code>Item</code>을 토대로 이번 글에서는 인벤토리 서비스를 구현해보려고 한다. 인벤토리 서비스는 <code>InventoryPresenter</code>와 <code>IItemSlotContext</code>에서 제공할 메서드를 구현하는 데 사용한다.</p>
<p>그럼 이제 인벤토리 서비스에 대한 내용을 시작해보도록 하겠다.</p>
<p><br></br></p>
<h3 id="아이디어">아이디어</h3>
<p>우선 인벤토리에서 제공해야 할 기본적인 기능들에 대해서 정리할 필요가 있다. 개발자마다 인벤토리에서 제공해야 할 기본적인 기능은 다르겠지만 나는 다음과 같이 정리를 했다.</p>
<ol>
<li>아이템 획득</li>
<li>아이템 제거</li>
<li>아이템 설정 또는 스왑</li>
<li>아이템의 총 개수 확인</li>
<li>아이템 보유 여부 확인</li>
<li>우선순위 슬롯 반환</li>
<li>저장 가능한 슬롯 반환</li>
<li>아이템 반환</li>
<li>돈 갱신</li>
</ol>
<p>이를 메서드와 시그니처를 통해서 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>AddItem(ItemCode code, int count)</code></td>
<td>code에 해당하는 아이템을 count만큼 획득한다.</td>
</tr>
<tr>
<td><code>RemoveItem(ItemCode code, int count)</code></td>
<td>code에 해당하는 아이템을 count만큼 제거한다.</td>
</tr>
<tr>
<td><code>SetItem(int offset, ItemCode code, int count)</code></td>
<td>offset 위치에 code에 해당하는 아이템을 count만큼 저장한다.</td>
</tr>
<tr>
<td><code>UpdateItem(int offset, int count)</code></td>
<td>offset 위치의 아이템을 count만큼 변경한다.</td>
</tr>
<tr>
<td><code>Clear(int offset)</code></td>
<td>offset 위치를 비운다.</td>
</tr>
<tr>
<td><code>GetItemCount(ItemCode code)</code></td>
<td>code에 해당하는 아이템의 개수를 반환한다.</td>
</tr>
<tr>
<td><code>GetValidOffset(ItemCode code)</code></td>
<td>code에 해당하는 아이템을 저장할 수 있는 offset을 반환한다.</td>
</tr>
<tr>
<td><code>GetPriorityOffset(ItemCode code)</code></td>
<td>code에 해당하는 아이템을 상대로 우선적으로 사용할 수 있는 offset을 반환한다.</td>
</tr>
<tr>
<td><code>HasItem(ItemCode code)</code></td>
<td>code에 해당하는 아이템을 보유하고 있는지 여부를 반환한다.</td>
</tr>
<tr>
<td><code>GetItem(int offset)</code></td>
<td>offset 위치의 아이템을 반환한다.</td>
</tr>
</tbody></table>
</br>

<p>또한 각 슬롯은 오프셋의 정보를 가진다. <code>offset</code>은 인벤토리에서의 슬롯 위치를 말한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/680d0ade-b691-4093-a0b9-1a668288ab22/image.png" alt=""></p>
<p><br></br></p>
<h3 id="인벤토리-데이터-정의">인벤토리 데이터 정의</h3>
<p>인벤토리 <strong>서비스에서 관리할 데이터를 정의</strong>해야 한다. 우선적으로 이전 글에서 정의한 <code>Item</code>은 Json으로 직렬화할 수 없는 데이터 타입들이 존재한다. 이를 테면.. <code>Sprite</code>다.</p>
<p>그리고 만약 <code>Sprite</code>가 아니었다고 한들.. <code>Item</code> 자체를 직렬화하여 관리하기엔 <code>Item</code>의 크기가 너무나 비대하다. </p>
<p>결국에 필요한 것은 <strong>아이템을 식별할 수 있는 무언가와 그 아이템의 개수</strong>다. 그리고 다행히도 우리는 그것들의 정보를 이미 알 수 있다. <code>ItemCode</code>와 바로 <code>int</code>다.</p>
<p><code>ItemCode</code>는 <strong>아이템을 식별하는 용도로 사용</strong>할 수 있고, <code>int</code>는 <strong>아이템의 개수</strong>로 사용할 수 있다. 즉, 이 두 개의 데이터로 <strong>인벤토리 슬롯 한 칸을 표현</strong>할 수 있다.</p>
<pre><code class="language-C#">[System.Serializable]
public class ItemData
{
    public ItemCode Code;
    public int Count;

    public ItemData(ItemCode code = ItemCode.NONE, int count = 0)
    {
        Code = code;
        Count = count;
    }
}</code></pre>
<p>하지만 인벤토리는 돈도 관리해야 하고 슬롯도 여러 칸을 지니고 있다. 따라서 돈은 <code>int</code>로 슬롯들은 <code>ItemData[]</code>로 관리한다.</p>
<pre><code class="language-C#">[System.Serializable]
public class InventoryData
{
    public int Gold;
    public ItemData[] Items;

    public InventoryData()
    {
        Gold = 0;
        Items = new ItemData[30];    // 내가 구현하려는 인벤토리는 슬롯을 30개 지니고 있다.
    }

    public InventoryData(int gold, ItemData[] items)
    {
        Gold = gold;
        Items = items;
    }
}</code></pre>
<p><br></br></p>
<h3 id="인벤토리-서비스">인벤토리 서비스</h3>
<p>위에서 생각한 아이디어를 토대로 우선적으로 인터페이스를 정의해야 한다.</p>
<pre><code class="language-C#">using System;

namespace InventoryService
{
    public interface IInventoryService
    {
        int Gold { get; }

        void Inject(IItemDataBase item_db);            // 아이템 매니저를 주입받을 메서드

        void InitializeSlot(int offset);            // offset 위치의 슬롯을 초기화하는 메서드
        void InitializeGold();                        // 돈을 초기화하는 메서드

        event Action&lt;int&gt; OnUpdatedGold;            // 돈이 갱신된 경우에 실행될 델리게이트
        void UpdateGold(int amount);

        event Action&lt;int, ItemData&gt; OnUpdatedSlot;    // 슬롯이 갱신된 경우에 실행될 델리게이트
        void AddItem(ItemCode code, int count);
        void RemoveItem(ItemCode code, int count);
        void SetItem(int offset, ItemCode code, int count);
        int UpdateItem(int offset, int count);
        void Clear(int offset);
        int GetItemCount(ItemCode code);
        int GetValidOffset(ItemCode code);
        int GetPriorityOffset(ItemCode code);
        bool HasItem(ItemCode code);
        ItemData GetItem(int offset);
    }
}</code></pre>
<p>그리고 정의한 <code>IInventoryService</code> 인터페이스를 토대로 <code>LocalInventoryService</code>를 구체화한다.</p>
<pre><code class="language-C#">namespace InventoryService
{
    public class LocalInventoryService : ISaveable, IInventoryService
    {
        private IItemDataBase m_item_db;

        private int m_money;
        private ItemData[] m_items;

        public event Action&lt;int&gt; OnUpdatedGold;
        public event Action&lt;int, ItemData&gt; OnUpdatedSlot;

        public int Gold =&gt; m_money;

        public LocalInventoryService()
        {
            m_money = 0;
            m_items = new ItemData[30];
            for (int i = 0; i &lt; m_items.Length; i++)
            {
                m_items[i] = new ItemData();
            }

            // 디렉터리 경로가 없다면 새롭게 생성한다.
            CreateDirectory();
        }

        private void CreateDirectory()
        {
            var directory_path = Path.Combine(Application.persistentDataPath, &quot;Inventory&quot;);

            if (!Directory.Exists(directory_path))
            {
                Directory.CreateDirectory(directory_path);
#if UNITY_EDITOR
                Debug.Log($&quot;&lt;color=cyan&gt;Inventory 디렉터리를 새롭게 생성합니다.&lt;/color&gt;&quot;);
#endif
            }
        }

        // Inject()를 통해서 아이템 매니저를 주입받는다.
        public void Inject(IItemDataBase item_db)
        {
            m_item_db = item_db;
        }

        // offset에 해당하는 슬롯을 향하여 이벤트를 발생시킨다.
        public void InitializeSlot(int offset)
        {
            OnUpdatedSlot?.Invoke(offset, m_items[offset]);
        }

        // 인벤토리를 처음 로드할 때 돈을 갱신하기 위해서 이벤트를 발생시킨다.
        public void InitializeGold()
        {
            UpdateGold(0);
        }

        public void UpdateGold(int amount)
        {
            m_money += amount;
            m_money = Mathf.Clamp(m_money, 0, int.MaxValue);    // 돈은 int 최대 범위를 넘어설 수 없다.

            OnUpdatedGold?.Invoke(m_money);
        }

        // 아이템을 획득할 때 사용한다.
        public void AddItem(ItemCode code, int count)
        {
            var item = m_item_db.GetItem(code);

            // 아이템이 중첩 가능하다면
            if (item.Stackable)
            {
                // 슬롯들을 순회하면서
                for (int i = 0; i &lt; m_items.Length; i++)
                {
                    // 아이템 코드가 일치하면서 99개 이하인 슬롯을 찾는다.
                    if (m_items[i].Code == code &amp;&amp; m_items[i].Count + count &lt;= 99)
                    {
                        m_items[i].Count += count;

                        OnUpdatedSlot?.Invoke(i, m_items[i]);
                        return;
                    }
                }
            }

            // 중첩 아이템이 아니라면
            for (int i = 0; i &lt; m_items.Length; i++)
            {
                // 비어있는 슬롯을 찾는다.
                if (m_items[i].Code == ItemCode.NONE)
                {
                    m_items[i].Code = code;
                    m_items[i].Count = count;

                    OnUpdatedSlot?.Invoke(i, m_items[i]);
                    return;
                }
            }
        }

        // 아이템을 제거할 때 사용한다.
        public void RemoveItem(ItemCode code, int count)
        {
            var item = m_item_db.GetItem(code);

            // 아이템이 중첩 가능하다면
            if (item.Stackable)
            {
                // 아무래도 뒤에서부터 순회해야 99개가 아닐 확률이 높다.
                for (int i = m_items.Length - 1; i &gt;= 0; i--)
                {
                    // 해당 아이템이 들어있는 슬롯을 발견했다면
                    if (m_items[i].Code == code)
                    {
                        // count 만큼 제거할 수 있는지 확인하고
                        if (m_items[i].Count &gt;= count)
                        {
                            // 제거 가능하다면 제거하고
                            m_items[i].Count -= count;

                            // 제거하려는 개수와 같아서 슬롯의 아이템 개수가 0이라면
                            if (m_items[i].Count == 0)
                            {
                                // 슬롯을 비운다.
                                Clear(i);
                            }

                            OnUpdatedSlot?.Invoke(i, m_items[i]);
                            return;
                        }
                        else
                        {
                            // 제거할 수 없다면 제거할 수 있는 만큼만 제거하고 슬롯을 비운다.
                            count -= m_items[i].Count;
                            Clear(i);
                        }
                    }
                }
            }

            // 중첩 아이템이 아니라면
            for (int i = 0; i &lt; m_items.Length; i++)
            {
                // 아이템 코드가 일치하는 슬롯을
                if (m_items[i].Code == code)
                {
                    // 비운다.
                    Clear(i);

                    OnUpdatedSlot?.Invoke(i, m_items[i]);
                    return;
                }
            }
        }

        // 아이템을 원하는 위치에 설정하고 싶을 때 사용한다.
        public void SetItem(int offset, ItemCode code, int count)
        {
            // offset 위치의 슬롯에 code와 count만큼을 채운다.
            m_items[offset].Code = code;
            m_items[offset].Count = count;

            OnUpdatedSlot?.Invoke(offset, m_items[offset]);
        }

        // 원하는 위치의 아이템의 개수를 갱신하고 싶을 때 사용한다.
        public int UpdateItem(int offset, int count)
        {
            // 슬롯의 최대 보관 개수 이하라면 -1을 반환하고,
            if (m_items[offset].Count + count &lt;= 99)
            {
                m_items[offset].Count += count;
                OnUpdatedSlot?.Invoke(offset, m_items[offset]);

                return -1;
            }
            else    // 그게 아니라면 저장할 만큼만 저장한다.
            {
                var remain_count = 99 - m_items[offset].Count;

                m_items[offset].Count = 99;
                OnUpdatedSlot?.Invoke(offset, m_items[offset]);

                return remain_count;
            }
        }

        // 특정 위치의 슬롯을 비운다.
        public void Clear(int offset)
        {
            m_items[offset].Code = ItemCode.NONE;
            m_items[offset].Count = 0;

            OnUpdatedSlot?.Invoke(offset, m_items[offset]);
        }

        // 특정 아이템의 총 개수를 반환한다.
        public int GetItemCount(ItemCode code)
        {
            var total_count = 0;

            foreach (var slot in m_items)
            {
                if (slot.Code == code)
                {
                    total_count += slot.Count;
                }
            }

            return total_count;
        }

        // 아이템을 저장할 수 있는 타당한 위치를 반환한다.
        public int GetValidOffset(ItemCode code)
        {
            for (int offset = 0; offset &lt; m_items.Length; offset++)
            {
                var item = m_item_db.GetItem(code);
                if (item.Stackable)
                {
                    if (m_items[offset].Count &lt; 99)
                    {
                        return offset;
                    }
                }

                if (m_items[offset].Code == ItemCode.NONE)
                {
                    return offset;
                }
            }

            return -1;
        }

        // 아이템을 우선적으로 제거할 우선순위 슬롯을 반환한다.
        public int GetPriorityOffset(ItemCode code)
        {
            for (int offset = m_items.Length - 1; offset &gt;= 0; offset--)
            {
                if (m_items[offset].Code == code)
                {
                    return offset;
                }
            }

            return -1;
        }

        // 아이템 보유 여부를 반환한다.
        public bool HasItem(ItemCode code)
        {
            foreach (var slot in m_items)
            {
                if (slot.Code == code)
                {
                    return true;
                }
            }

            return false;
        }

        // 특정 위치의 아이템 데이터를 반환한다.
        public ItemData GetItem(int offset)
        {
            return m_items[offset];
        }

        public bool Load(int offset)
        {
            var local_data_path = Path.Combine(Application.persistentDataPath, &quot;Inventory&quot;, $&quot;InventoryData{offset}.json&quot;);

            if (File.Exists(local_data_path))
            {
                var json_data = File.ReadAllText(local_data_path);
                var inventory_data = JsonUtility.FromJson&lt;InventoryData&gt;(json_data);

                m_money = inventory_data.Gold;
                m_items = inventory_data.Items;
            }
            else
            {
                return false;
            }

            return true;
        }

        public void Save(int offset)
        {
            var local_data_path = Path.Combine(Application.persistentDataPath, &quot;Inventory&quot;, $&quot;InventoryData{offset}.json&quot;);

            var inventory_data = new InventoryData(m_money, m_items);
            var json_data = JsonUtility.ToJson(inventory_data, true);

            File.WriteAllText(local_data_path, json_data);
        }
    }
}</code></pre>
<p><br></br></p>
<h3 id="서비스-등록">서비스 등록</h3>
<p>위와 같이 인벤토리 서비스를 구현했다면 이를 서비스 로케이터에 등록하는 작업이 필요하다.
따라서 다음과 같이 서비스 로케이터에 등록한다.</p>
<pre><code class="language-C#">// 이전과 동일

public static class ServiceLocator
{
    private static Dictionary&lt;Type, object&gt; m_services = new();

    public static IDictionary&lt;Type, object&gt; Services =&gt; m_services;

    public static void Initialize()
    {
        Register&lt;IEXPService&gt;(new LocalEXPService());
        Register&lt;IUserService&gt;(new LocalUserService());
        Register&lt;IInventoryService&gt;(new LocalInventoryService());
        Register&lt;IKeyService&gt;(new LocalKeyService());
        // 이전과 동일
    }

    public static void Register&lt;T&gt;(T service)
    {
        // 이전과 동일
    }

    public static T Get&lt;T&gt;()
    {
        // 이전과 동일
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>이번 글에서는 <code>Item</code>과 <code>ItemData</code>를 이용하여 인벤토리 서비스를 구현했다. 다음 글에서는 구현한 인벤토리 서비스를 기반으로 인벤토리 UI를 직접 구현해 볼 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[인벤토리 시스템] 아이템]]></title>
            <link>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%EC%9D%B4%ED%85%9C-%EC%A0%95%EC%9D%98</link>
            <guid>https://velog.io/@jxng-min/%EC%9D%B8%EB%B2%A4%ED%86%A0%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%EC%9D%B4%ED%85%9C-%EC%A0%95%EC%9D%98</guid>
            <pubDate>Wed, 13 Aug 2025 14:08:09 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>인벤토리 시스템은 게임에서 가장 중요하다고 말할 수 있는 부분이다. 인벤토리를 통해서 사용자는 자신의 획득한 아이템을 가지고 특별한 연산들을 진행할 수 있다.</p>
<p>또한, 인벤토리와 다른 UI들과의 상호작용을 생각하면 절대적으로 차지하는 비중이 많다. 그러므로 인벤토리 시스템은 추후의 확장을 생각해서 더 구조적인 설계가 필요하다. 그럼 시작해보자.</p>
<p><br></br></p>
<h3 id="설계">설계</h3>
<p>우선 인벤토리를 구현하기 전에 전체 흐름을 살펴보자.</p>
<p>인벤토리에서는 주로 돈과 아이템들 관리하게 된다. 돈은 유저 서비스에 묶일 수도 있다고 생각은 하지만 나는 <strong>인벤토리에서 돈과 아이템을 관리하는 것이 책임 분리에서 더 낫다고 판단</strong>했다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/b1bd2c22-8e79-41fe-8c75-264c1d8188bc/image.png" alt=""></p>
<p><code>InventoryPresenter</code>는 <code>IInventoryService</code>와 <code>IInventoryView</code> 사이에서 돈 갱신 이벤트를 연결해주는 역할과 아이템 슬롯을 관리하는 역할을 한다.</p>
<p><code>ItemSlotPresenter</code>는 포인터, 드래그, 드랍 등의 기능들을 수행하며 <strong>각 컨텍스트에 맞추어 서비스의 인덱스를 참조</strong>하게 된다.</p>
<p>아이템 슬롯은 인벤토리, 장비, 스킬, 퀵슬롯, 상점, 제작소 등 <strong>여러 맥락에서 사용될 수 있기 때문에 매우 범용적으로 구현</strong>해야 한다.</p>
<p>하지만 <strong>범용적으로 구현하는 만큼 SRP를 위배</strong>하게 되고 이를 그나마 보완하기 위해 <strong>컨텍스트와 핸들러를 두어 책임을 위임</strong>한다.</p>
<p><br></br></p>
<h3 id="아이템-정의">아이템 정의</h3>
<p>아이템은 <code>Scriptable Object</code>(이하 SO)를 사용하여 정의한다. 런타임에서 값이 유지되며 전역적으로 참조할 수 있기 때문에 매우 편리하다.</p>
<p>그 다음은 아이템에 포함될 데이터를 생각해야 한다. 개발자마다 포함할 데이터를 판단하는 작업은 다를테지만 나는 다음과 같이 판단을 했다.</p>
<ol>
<li>아이템 코드</li>
<li>아이템 명</li>
<li>아이템 타입</li>
<li>중첩 가능의 여부</li>
<li>아이템 쿨타임</li>
<li>아이템 스프라이트</li>
</ol>
<p>공통적으로 사용되는 아이템은 이 정도를 포함한다고 생각했다. 만약 <strong>더 필요한 데이터가 있다면 그 부분은 그 아이템에 맞게 아이템을 상속받아서 구현</strong>하면 되기 때문이다.</p>
<hr>
<h4 id="아이템-코드-정의">아이템 코드 정의</h4>
<p>우선, 아이템 코드를 <code>enum</code>을 사용해서 정의한다.</p>
<pre><code class="language-C#">public enum ItemCode
{
    NONE = 0,

    // 소비 아이템(1 ~ 200)
    SMALL_HP_POTION = 1, SMALL_MP_POTION = 2, SMALL_POTION = 3,
    MIDDLE_HP_POTION = 4, MIDDLE_MP_POTION = 5, MIDDLE_POTION = 6,

    // 퀘스트 아이템(201 ~ 400)

    // 기타 아이템(401 ~ 600)
    BRANCH = 401, STONE = 402, IRON = 403, SILVER = 404, GOLD = 405, RUBY = 406,

    // 스킬 (601 ~ 700)
    DASH = 601, FIRE_BALL = 602, THUNDER = 603,

    // 장비(1001 ~ 1100)
    OLD_SWORD = 1001, OLD_BOW = 1002, OLD_SHIELD = 1003, OLD_HELMET = 1004, OLD_ARMOR = 1005,
    NINA_SWORD = 1006, NINA_BOW = 1007, NINA_SHILD = 1008, NINA_HELMET = 1009, NINA_ARMOR = 1010,
    SENIOR_SWORD = 1011, SENIOR_BOW = 1012, SENIOR_SHIELD = 1013, SENIOR_HELMET = 1014, SENIOR_ARMOR = 1015,
}</code></pre>
<hr>
<h4 id="아이템-타입-정의">아이템 타입 정의</h4>
<p>아이템 타입은 <code>System</code>의 <code>Flags</code> 어트리뷰트를 사용하여 <strong>여러 개의 비트를 사용</strong>할 수 있도록 한다.예를 들면 퀘스트 아이템이면서 소비 아이템인 경우도 존재할테니 말이다. </p>
<p>지금이랑은 관계가 없는 이야기지만 미리 이야기하면 <code>ItemType</code>을 통해서 <strong>비트 마스크 연산을 하여 슬롯에 들어갈 수 있는 아이템인지 판단</strong>하는 작업을 할 수 있다.</p>
<pre><code class="language-C#">[System.Flags]
public enum ItemType
{
    NONE = 0,

    Consumable = 1 &lt;&lt; 0,
    Quest = 1 &lt;&lt; 1,
    ETC = 1 &lt;&lt; 2,

    Skill = 1 &lt;&lt; 3,

    Equipment_Helmet = 1 &lt;&lt; 4,
    Equipment_Armor = 1 &lt;&lt; 5,
    Equipment_Weapon = 1 &lt;&lt; 6,
    Equipment_Shield = 1 &lt;&lt; 7,
}</code></pre>
<hr>
<h4 id="아이템-정의-1">아이템 정의</h4>
<p>아이템을 정의하기에 필요한 데이터 타입인 <code>ItemCode</code>와 <code>ItemType</code>을 모두 정의했으니 이제는 <code>Item</code>을 구현할 수 있다.</p>
<pre><code class="language-C#">using UnityEngine;

[CreateAssetMenu(fileName = &quot;New Item&quot;, menuName = &quot;SO/Create Generic Item&quot;)]
public class Item : ScriptableObject
{
    [Header(&quot;아이템 기본 정보&quot;)]
    [Header(&quot;아이템 코드&quot;)]
    [SerializeField] private ItemCode m_code;
    public ItemCode Code =&gt; m_code;

    [Header(&quot;아이템 타입&quot;)]
    [SerializeField] private ItemType m_type;
    public ItemType Type =&gt; m_type;

    [Header(&quot;아이템 명&quot;)]
    [SerializeField] private string m_name;
    public string Name =&gt; m_name;

    [Header(&quot;슬롯 중첩 여부&quot;)]
    [SerializeField] private bool m_stackable;
    public bool Stackable =&gt; m_stackable;

    [Header(&quot;아이템 쿨타임&quot;)]
    [SerializeField] private float m_cool = -1f;
    public float Cool =&gt; m_cool;

    [Header(&quot;아이템 이미지&quot;)]
    [SerializeField] private Sprite m_sprite;
    public Sprite Sprite =&gt; m_sprite;
}</code></pre>
<p><br></br></p>
<h3 id="아이템-생성">아이템 생성</h3>
<p>다음과 같이 [Create] → [SO] → [Create Generic Item]을 통해서 아이템 SO를 개발자 취향껏 마음대로 아이템을 생성할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/7b19946c-063f-43da-8111-2afdb07f8ed0/image.png" alt=""></p>
<p>다음은 내가 임의로 체력 포션을 만든 예시다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/cf3f09f4-e186-40c1-8ca1-61fc5d6131a2/image.png" alt=""></p>
<p><br></br></p>
<h3 id="아이템-매니저-정의">아이템 매니저 정의</h3>
<p>이렇게 위의 작업을 반복하여 여러 아이템을 정의하였다면 이 <strong>아이템을 관리하고 접근할 수 있는 객체</strong>가 필요하다. 이를 <strong>아이템 매니저</strong>라고 한다.</p>
<p>아이템 매니저는 <strong>씬을 넘나들면서 참조할 필요성이 있기 때문에 전역적으로 존재</strong>해야 한다.</p>
<p>전역적으로 객체를 존재하게 만들기 위해서는 <code>DontDestroyOnLoad</code>, <code>Singleton</code>, <code>Static</code> 등 다양한 방법을 사용할 수 있다.</p>
<p>하지만 위의 방법을 사용하게 되면 직관적인 편집이 어렵다. 어떤 아이템을 관리해야 하는지 일일히 하드 코딩을 통해야 하며 씬에 종속적이다.</p>
</br>

<p>따라서 아이템 매니저를 SO로 생성한다. SO는 어떤 아이템을 관리해야 하는지의 여부를 비개발 직군도 충분히 결정할 수 있을만큼 직관적이며, 씬에 종속적이지 않기 때문에 장점이 뚜렷하다.</p>
<p>SO로 생성하는 만큼 매니저?라는 이름의 의미가 직관적이진 못한 것 같아서 나는 아이템 데이터베이스라고 정했다.</p>
<p>역시나 SO로 아이템 매니저를 생성하기야 하지만 이는 충분히 <strong>개발하면서 변경될 여지가 다분</strong>하다. 따라서 다른 코드에서의 <strong>관심사를 분리하기 위해 아이템 매니저 인터페이스를 구현</strong>한다.</p>
<pre><code class="language-C#">public interface IItemDataBase
{
    Item GetItem(ItemCode code);
}</code></pre>
<p>그리고 우리는 계획대로 SO를 이용하여 <code>ItemDataBase</code>를 생성한다.</p>
<pre><code class="language-C#">using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = &quot;Item DataBase&quot;, menuName = &quot;SO/DB/Create Item DataBase&quot;)]
public class ItemDataBase : ScriptableObject, IItemDataBase
{
    [Header(&quot;아이템 목록&quot;)]
    [SerializeField] private Item[] m_item_list;

    private Dictionary&lt;ItemCode, Item&gt; m_item_dict;

#if UNITY_EDITOR
    private void OnEnable()
    {
        Initialize();
    }
#endif

    private void Initialize()
    {
        m_item_dict = new();

        // SO의 OnEnable()은 유니티 에디터에서 런타임이 아닌 환경에서도 작동하기 때문에
        // 반드시 리스트의 조건이 널이 아닌 경우에 작동하도록 설정한다.
        if (m_item_list == null)
        {
            return;
        }

        // 인스펙터를 통해 로드한 아이템 리스트를
        // 아이템 코드를 키로 하여 딕셔너리에 저장한다.
        foreach (var item in m_item_list)
        {
            m_item_dict.TryAdd(item.Code, item);
        }    
    }

    public Item GetItem(ItemCode code)
    {
        if(m_item_dict == null)
        {
            Initialize();
        }

        return m_item_dict.TryGetValue(code, out var item) ? item : null;
    }
}</code></pre>
<p><br></br></p>
<h3 id="아이템-매니저-생성">아이템 매니저 생성</h3>
<p>다음과 같이 [Create] → [SO] → [DB] → [Create Item DataBase]로 아이템 매니저를 생성한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/ee26b1a5-67a5-4bf0-b58a-9fb8cb90989f/image.png" alt=""></p>
<p>그리고 생성한 아이템 매니저에 관리할 아이템의 목록을 인스펙터에서 채워 넣는다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/1b1775fe-75d0-46e4-b6ce-6f91fc65d3ed/image.png" alt=""></p>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>아직 인벤토리 시스템의 끝이 멀게만 느껴진다. 이제 아이템을 정의했으니 이를 토대로 다음 글에서는 인벤토리 서비스에 대해서 알아보도록 하겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[키 바인딩 시스템] 팝업 UI 관리자]]></title>
            <link>https://velog.io/@jxng-min/%ED%82%A4-%EB%B0%94%EC%9D%B8%EB%94%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%8C%9D%EC%97%85-UI-%EA%B4%80%EB%A6%AC%EC%9E%90</link>
            <guid>https://velog.io/@jxng-min/%ED%82%A4-%EB%B0%94%EC%9D%B8%EB%94%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%8C%9D%EC%97%85-UI-%EA%B4%80%EB%A6%AC%EC%9E%90</guid>
            <pubDate>Tue, 12 Aug 2025 14:55:24 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>저번 글에서는 키 서비스를 이용하여 키 바인더 UI를 구성하는 과정을 보였다. 하지만 키 바인더 UI가 존재함에도 이를 열지 못했다. <del>이게 무슨 소용인가..</del> </p>
<p>그래서 키 바인더 UI를 포함하여 앞으로 등장할 <strong>팝업 UI들의 모든 활성화와 비활성화의 책임을 가지는 팝업 UI 관리자를 구현</strong>해보고자 한다.</p>
<p>앞서는 <code>PopupUIManager</code>라고 지칭했지만.. 생각해보니 팝업 UI 관리자가 더 직관적일 것 같다.</p>
<p><br></br></p>
<h3 id="아이디어">아이디어</h3>
<p>생각보다 애를 먹은 것은 팝업 UI 관리자에서 사용할 데이터 구조를 결정하는 일이었다. 게임에서의 팝업 UI는 특성 상 <strong>위에 켜져 있는 UI가 먼저 꺼지는 구조</strong>다.</p>
<p>이것을 다른 말로 하면 <strong>나중에 활성화 된 UI가 먼저 비활성화</strong>된다는 말이다. 이러한 특징은 LIFO 구조로 <strong>스택</strong>을 떠올리게끔 한다. 뭐.. 실제로 스택으로도 구현할 수 있는 게임이 존재할지도 모른다. <del>내 게임은 아니었지만.</del></p>
<p>왜냐하면 나중에 활성화된 UI가 먼저 비활성화된다는 특성을 가지고는 있지만 <strong>중간에서 키 입력을 통해서 먼저 활성화된 UI가 비활성화되는 경우도 존재</strong>한다.</p>
<p>이것을 생각하면 <code>List&lt;T&gt;</code>를 생각할 수도 있다. 하지만 <code>List&lt;T&gt;</code>의 중간 요소 삭제는 $O(n)$의 시간 복잡도를 가지기 때문에 정말 최악이다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/711ee1b5-9d08-44da-a579-246da74c47e7/image.png" alt=""></p>
<p>결론부터 말하자면 <code>LinkedList&lt;T&gt;</code>가 정답이다. 스택과 같이 <strong>한 방향으로만 데이터의 흐름을 결정할 수도 있고 중간 요소의 삭제도 매우 멀끔하게 처리</strong>하는 멋진 데이터 구조다.</p>
<p><br></br></p>
<h3 id="팝업-ui-관리자">팝업 UI 관리자</h3>
<p>자. 앞서 설명한 아이디어를 토대로 상상해봤다면 더 이상 이 글을 볼 필요가 없다고 느낄 수도 있다. 심지어 앞서 필요한 인터페이스들을 전부 구현했다.</p>
<p>내가 정답은 분명 아니겠지만 나는 팝업 UI 관리자를 다음과 같이 구현했다.</p>
<pre><code class="language-C#">using System.Collections.Generic;
using KeyService;
using UnityEngine;

public class PopupUIManager : MonoBehaviour
{
    private IKeyService m_key_service;

    private LinkedList&lt;IPopupPresenter&gt; m_active_popup_list;        // 활성화 된 UI 프레젠터를 저장할 연결 리스트
    private Dictionary&lt;string, IPopupPresenter&gt; m_presenter_dict;    // 팝업 UI 프레젠터를 전부 보관하는 딕셔너리

    private void Awake()
    {
        m_key_service = ServiceLocator.Get&lt;IKeyService&gt;();
        m_active_popup_list = new();
    }

    private void Update()
    {
        // &quot;Pause&quot;키는 KeyCode.Escape에 매핑되어 있다.
        // ESC 키를 눌렀을 때 발생하는 행동을 정의한다.
        if (Input.GetKeyDown(m_key_service.GetKeyCode(&quot;Pause&quot;)))
        {
            // 활성화된 팝업 UI가 있다면 이들을 켜진 순서와 반대로 비활성화한다.
            if (m_active_popup_list.Count &gt; 0)
            {
                CloseUI(m_active_popup_list.First.Value);
            }
            else
            {
                // 활성화된 팝업 UI가 없다면 일시정지 UI를 활성화시킨다.
                if (m_presenter_dict.TryGetValue(&quot;Pause&quot;, out var presenter))
                {
                    OpenUI(presenter);
                    GameEventBus.Publish(GameEventType.SETTING); // SETTING 모드로 변경. 지금 주제와는 무관.
                }
            }
        }

        // SETTING일 때는 키 입력을 받지 않는 것이 일반적이다.
        if (GameManager.Instance.Event != GameEventType.SETTING)
        {
            // 각 팝업 UI에 해당하는 문자열을 통하여 키 입력을 대기한다.
            InputToggleKey(&quot;Binder&quot;);
            // ...
        }
    }

    // 팝업 UI 프레젠터의 목록을 Inject()를 통해 주입받는다.
    public void Inject(List&lt;PopupData&gt; popup_data_list)
    {
        m_presenter_dict = new();

        // 팝업 UI 프레젠터의 목록을 이용하여 딕셔너리를 초기화한다.
        foreach (var popup_data in popup_data_list)
        {
            m_presenter_dict.TryAdd(popup_data.Name, popup_data.Presenter);
        }
    }

    // 키 입력을 통하여 활성화/비활성화 여부를 결정한다.
    private void InputToggleKey(string key_name)
    {
        if (Input.GetKeyDown(m_key_service.GetKeyCode(key_name)))
        {
            if (m_presenter_dict.TryGetValue(key_name, out var presenter))
            {
                ToggleUI(presenter);
            }
        }
    }

    private void ToggleUI(IPopupPresenter presenter)
    {
        // 연결 리스트에 프레젠터가 포함되어 있다면 그 팝업 UI는 활성화 상태다.
        if (m_active_popup_list.Contains(presenter))
        {
            CloseUI(presenter);
        }
        else
        {
            OpenUI(presenter);
        }

        SortDepth();
    }

    // 어댑터를 통해서 UI를 활성화시킬 수 없는 경우에 사용한다.
    public void AddPresenter(IPopupPresenter presenter)
    {
        if (m_active_popup_list.Contains(presenter))        // 활성화되어 있다면 우선순위를 최고로 올리고
        {
            m_active_popup_list.Remove(presenter);
        }

        m_active_popup_list.AddFirst(presenter);
        GameEventBus.Publish(GameEventType.INTERACTING);

        SortDepth();                                        // UI 깊이 순서를 재정렬한다.
    }

    // 어댑터를 통해서 UI를 비활성화시킬 수 없는 경우에 사용한다.
    public void RemovePresenter(IPopupPresenter presenter)
    {
        if (m_active_popup_list.Contains(presenter))        // 활성화되어 있다면
        {
            m_active_popup_list.Remove(presenter);            // 비활성화하고
            SortDepth();                                    // UI 깊이 순서를 재정렬한다.

            if (m_active_popup_list.Count == 0)                // 다 꺼져 있다면 PLAYING 모드로 변경한다.
            {
                GameEventBus.Publish(GameEventType.PLAYING);
            }
        }        
    }

    // 연결 리스트에 프레젠터를 추가하고 UI를 활성화한다.
    public void OpenUI(IPopupPresenter presenter)
    {
        m_active_popup_list.AddFirst(presenter);
        presenter.OpenUI();

        GameEventBus.Publish(GameEventType.INTERACTING);
    }

    // 연결 리스트에서 프레젠터를 삭제하고 UI를 비활성화한다.
    public void CloseUI(IPopupPresenter presenter)
    {
        m_active_popup_list.Remove(presenter);
        presenter.CloseUI();

        if (m_active_popup_list.Count == 0)
        {
            GameEventBus.Publish(GameEventType.PLAYING);
        }
    }

    // UI 깊이 순서를 재정렬한다.
    private void SortDepth()
    {
        foreach (var presenter in m_active_popup_list)
        {
            presenter.SortDepth();
        }
    }
}</code></pre>
<p><br></br></p>
<h3 id="인스톨러-등록">인스톨러 등록</h3>
<p>팝업 UI 관리자를 구현했다면 이를 인스톨러에 등록해야 한다. 따라서 <code>PopupUIManagerInstaller</code>를 구현하고 이를 <code>Bootstrapper</code> 하위의 자식 오브젝트로 등록한다.</p>
<pre><code class="language-C#">using System.Collections.Generic;
using UnityEngine;

public class PopupUIManagerInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;팝업UI 매니저&quot;)]
    [SerializeField] private PopupUIManager m_popup_manager;

    public void Install()
    {
        var popup_data_list = new List&lt;PopupData&gt;{
            new(&quot;Binder&quot;, DIContainer.Resolve&lt;KeyBinderPresenter&gt;()),
            // ...
        };

        m_popup_manager.Inject(popup_data_list);
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>당연하겠지만 앞선 키 바인딩 UI에 팝업 UI 관리자를 반드시 등록해야 한다.
놓치는 일이 없으시기를..</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[키 바인딩 시스템] 키 바인더 UI]]></title>
            <link>https://velog.io/@jxng-min/%ED%82%A4-%EB%B0%94%EC%9D%B8%EB%94%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%82%A4-%EB%B0%94%EC%9D%B8%EB%8D%94-UI</link>
            <guid>https://velog.io/@jxng-min/%ED%82%A4-%EB%B0%94%EC%9D%B8%EB%94%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%82%A4-%EB%B0%94%EC%9D%B8%EB%8D%94-UI</guid>
            <pubDate>Tue, 12 Aug 2025 14:23:10 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>앞서 구현한 <strong>키 서비스를 토대로 키 바인더 UI를 제작</strong>할 차례다.
만약 UI로 사용하기에 적절한 이미지가 없다면 아래의 이미지를 사용하도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/1872757b-ef7d-4e5e-81af-3354f9b4a656/image.png" alt=""></p>
<p><a href="">여기</a>에서 말했듯이 키 바인더 UI는 MVP 패턴을 이용한다. 하지만 <strong>키 바인더 UI는 각각의 키 바인더 슬롯을 묶어주는 컨테이너</strong>의 역할이 강하며, 실제로는 <strong>키 바인더 슬롯의 비중이 더 크다</strong>.</p>
<p>키 바인더 슬롯의 비중이 크긴 하지만 이를 또 MVP 패턴을 사용할만큼 복잡하진 않기 때문에 키 바인더 UI만 MVP 패턴을 이용하고 <strong>키 바인더 슬롯은 유니티스럽게 구현</strong>해본다.</p>
<p><br></br></p>
<h3 id="view">View</h3>
<p>키 바인더의 View도 여러 상황에서 다양한 형태로 쓰일 수 있기 때문에 인터페이스를 우선적으로 구현한다.</p>
<pre><code class="language-C#">public interface IPopupView
{
    void SetDepth();
    void PopupCloseUI();
}</code></pre>
<pre><code class="language-C#">public interface IKeyBinderView : IPopupView
{
    void Inject(KeyBinderPresenter presenter);
    void OpenUI();
    void CloseUI();
}</code></pre>
<p><code>IPopupView</code>는 앞서 말했듯이 팝업 UI의 <code>View</code> 자체에서의 비활성화를 <code>PopupUIManager에</code>서의 비활성화와 동기화시키기 위해 필요한 메서드를 포함한다. </p>
<p>키 바인더 UI는 닫기 버튼이 있을 예정이므로 <code>IPopupView</code>를 구현한다.</p>
<pre><code class="language-C#">using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(Animator))]
public class KeyBinderView : MonoBehaviour, IKeyBinderView
{
    [Header(&quot;팝업 UI 매니저&quot;)]
    [SerializeField] private PopupUIManager m_ui_manager;

    [Header(&quot;UI 닫기 버튼&quot;)]
    [SerializeField] private Button m_close_button;

    private Animator m_animator;
    private KeyBinderPresenter m_presenter;

    private void Awake()
    {
        m_animator = GetComponent&lt;Animator&gt;();
    }

    // Inject()를 통해서 프레젠터를 주입받는다.
    public void Inject(KeyBinderPresenter presenter)
    {
        m_presenter = presenter;

        // 닫기 버튼에 PopupUIManager의 상태를 동기화시킬 이벤트들을 등록한다.
        m_close_button.onClick.AddListener(m_presenter.CloseUI);
        m_close_button.onClick.AddListener(PopupCloseUI);
    }

    public void OpenUI()
    {
        m_animator.SetBool(&quot;Open&quot;, true);
    }

    public void CloseUI()
    {
        m_animator.SetBool(&quot;Open&quot;, false);
    }

    // UI의 깊이를 설정한다. 얼마만큼 가려지고 얼마만큼 노출될지를 결정한다.
    public void SetDepth()
    {
        (transform as RectTransform).SetAsFirstSibling();
    }

    // 프레젠터를 링크드 리스트에서 강제로 제거하는 역할을 한다.
    public void PopupCloseUI()
    {
        m_ui_manager.RemovePresenter(m_presenter);
    }
}</code></pre>
<p>그리고 이를 토대로 아래와 같이 키 바인더 UI를 구성한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/8c97605b-f804-48a6-9c8f-109098c9af15/image.png" alt=""></p>
<p><br></br></p>
<h3 id="presenter">Presenter</h3>
<p><code>Presenter</code>는 단순히 <strong><code>View</code>를 활성화 및 비활성화하는 책임</strong>만을 가지기 때문에 비교적 단순하다.</p>
<pre><code class="language-C#">public class KeyBinderPresenter : IPopupPresenter
{
    private readonly IKeyBinderView m_view;

    // 생성자를 통해 View를 주입받는다.
    public KeyBinderPresenter(IKeyBinderView view)
    {
        m_view = view;
        m_view.Inject(this);
    }

    public void OpenUI()
    {
        m_view.OpenUI();
    }

    public void CloseUI()
    {
        m_view.CloseUI();
    }

    // View의 깊이를 설정한다.
    public void SortDepth()
    {
        m_view.SetDepth();
    }
}</code></pre>
<p><br></br></p>
<h3 id="키-바인더-슬롯">키 바인더 슬롯</h3>
<p>키 바인더 슬롯이 사실 상 키 바인딩 시스템의 전부라고 봐도 무방하다. 키 바인더 슬롯의 역할이 그만큼 크며 로직의 대부분을 담고 있다.</p>
<pre><code class="language-C#">using System;
using System.Collections;
using KeyService;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class KeyBinderSlotView : MonoBehaviour
{
    private KeyCode m_origin_key_code;
    private IKeyService m_key_service;

    [Header(&quot;매핑 문자열&quot;)]
    [SerializeField] private string m_key_name;

    [Header(&quot;바인딩 버튼&quot;)]
    [SerializeField] private Button m_binding_button;

    [Header(&quot;버튼 텍스트&quot;)]
    [SerializeField] private TMP_Text m_button_text;

    [Header(&quot;예외 텍스트&quot;)]
    [SerializeField] private TMP_Text m_wrong_text;

    private Coroutine m_wrong_key_coroutine;

    private void Awake()
    {
        // 바인딩 버튼에 키 변경 이벤트를 등록한다.
        m_binding_button.onClick.AddListener(ModifyKey);
    }

    // Inject()를 통해서 키 서비스를 주입받는다.
    public void Inject(IKeyService key_service)
    {
        m_key_service = key_service;

        // 처음의 키 코드는 인스펙터에서 입력받은 문자열을 통해서 얻어낸다.
        m_origin_key_code = m_key_service.GetKeyCode(m_key_name);

        // 그리고 이 키 코드를 이용하여 현재 키를 사용자에게 보여준다.
        m_button_text.text = ((char)m_origin_key_code).ToString().ToUpper();
    }

    // 키를 변경할 때 사용한다.
    public void ModifyKey()
    {
        // 키가 변경 중임을 기존의 키에서 &#39;-&#39;으로 변경하여 나타낸다.
        m_button_text.text = &quot;-&quot;;

        // 실제 키가 변경되는 코루틴이다.
        StartCoroutine(Co_AssignKey());
    }

    private IEnumerator Co_AssignKey()
    {
        // 버튼이 선택되어 있는 동안 발생하며
        while (true)
        {
            // 아무 키를 입력받고
            if (Input.anyKeyDown)
            {
                // 그 키에 해당하는 키 코드가 있는지 확인한다.
                foreach (KeyCode code in Enum.GetValues(typeof(KeyCode)))
                {
                    // 만약 해당하는 키 코드가 있다면
                    if (Input.GetKey(code))
                    {
                        // 변경이 가능한 유효한 키인지의 여부를 확인한다.
                        if (m_key_service.Check(code, m_origin_key_code))
                        {
                            // 유효하다면 그 키를 등록하는 과정을 거친다.
                            m_key_service.Register(code, m_key_name);
                            m_origin_key_code = code;

                            m_button_text.text = ((char)m_origin_key_code).ToString().ToUpper();
                        }
                        else
                        {

                            // 유효하지 않다면 이전의 바인딩된 키로 돌아가는 과정을 거친다.
                            // 추가적으로, 사용자에게 잘못된 입력임을 나타낸다.
                            // 여기서는 코루틴을 활용하여 에러 메시지를 발생시킨다.
                            m_button_text.text = ((char)m_origin_key_code).ToString().ToUpper();

                            if (m_wrong_key_coroutine != null)
                            {
                                StopCoroutine(m_wrong_key_coroutine);
                                m_wrong_key_coroutine = null;
                            }
                            m_wrong_key_coroutine = StartCoroutine(Co_WrongKey());
                        }

                        break;
                    }
                }

                yield break;
            }

            yield return null;
        }
    }

    // 잘못된 키 입력임을 에러 메시지로 발생시키는 코루틴이다.
    private IEnumerator Co_WrongKey()
    {
        float elapsed_time = 0f;
        float target_time = 1f;

        while (elapsed_time &lt; target_time)
        {
            float delta = elapsed_time / target_time;
            SetAlpha(delta);

            elapsed_time += Time.deltaTime;
            yield return null;
        }

        SetAlpha(1f);
        elapsed_time = 0f;

        while (elapsed_time &lt; target_time)
        {
            float delta = elapsed_time / target_time;
            SetAlpha(1 - delta);

            elapsed_time += Time.deltaTime;
            yield return null;
        }

        SetAlpha(0f);
        m_wrong_key_coroutine = null;
    }

    private void SetAlpha(float alpha)
    {
        var color = m_wrong_text.color;
        color.a = alpha;
        m_wrong_text.color = color;
    }
}</code></pre>
<p>이를 토대로 다음과 같이 키 바인더 슬롯 UI를 구성한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/d16d87ba-8151-4782-b14a-10e91dd7c539/image.png" alt=""></p>
<p>이 키 바인더 슬롯 UI를 키 바인더 UI의 Content 하위의 자식으로 복사하여 원하는 개수만큼의 키 바인더 슬롯 UI를 생성하고 설정한다.</p>
<p><br></br></p>
<h3 id="인스톨러-등록">인스톨러 등록</h3>
<p><code>KeyBinderUIInstaller</code>를 생성한 후 다음과 같이 작성을 한다. 그리고 <code>KeyBinderUIInstaller</code>를 <code>Bootstrapper</code> 하위의 자식으로 배치하고 인스펙터에서 알맞게 컴포넌트를 할당한다.</p>
<pre><code class="language-C#">using KeyService;
using UnityEngine;

public class KeyBinderUIInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;키 바인더 뷰&quot;)]
    [SerializeField] private KeyBinderView m_key_binder_view;

    [Header(&quot;키 바인더 슬롯의 부모 트랜스폼&quot;)]
    [SerializeField] private Transform m_key_binder_slot_root;

    public void Install()
    {
        DIContainer.Register&lt;IKeyBinderView&gt;(m_key_binder_view);

        var key_binder_presenter = new KeyBinderPresenter(m_key_binder_view);
        DIContainer.Register&lt;KeyBinderPresenter&gt;(key_binder_presenter);

        var key_binder_slots = m_key_binder_slot_root.GetComponentsInChildren&lt;KeyBinderSlotView&gt;();
        foreach (var slot in key_binder_slots)
        {
            slot.Inject(ServiceLocator.Get&lt;IKeyService&gt;());
        }
    }
}
</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>이 과정을 모두 마무리했다면 다음과 같이 제대로 실행은 될 것이지만, 아쉽게도 지금은 저렇게 불가능하다.</p>
<p>키 바인더 UI를 활성화 및 비활성화해야 하지만 이것은 <code>PopupUIManager</code>가 있어야 가능하기 때문이다. 다음 글에서 <code>PopupUIManager</code>를 구현하고 키 바인딩 시스템을 완성해보자.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/768de96a-5077-4473-8e21-6e3a1aa4244f/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[키 바인딩 시스템] 키 서비스]]></title>
            <link>https://velog.io/@jxng-min/%ED%82%A4-%EB%B0%94%EC%9D%B8%EB%94%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%82%A4-%EC%84%9C%EB%B9%84%EC%8A%A4</link>
            <guid>https://velog.io/@jxng-min/%ED%82%A4-%EB%B0%94%EC%9D%B8%EB%94%A9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%82%A4-%EC%84%9C%EB%B9%84%EC%8A%A4</guid>
            <pubDate>Tue, 12 Aug 2025 13:30:09 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>데스크탑 게임에 있어서 <strong>키 바인딩 시스템은 사용자들의 자유도와 컨트롤을 높이는 데 큰 기여</strong>를 한다. </p>
<p>사람들마다 선호하는 키 조합과 배열이 다르기 때문에 사용자들의 만족도를 높이려면 키보드의 키를 개개인이 바인딩할 수 있어야 한다.</p>
<p>본격적으로 다양한 팝업 UI의 활성화 및 비활성화를 키보드로 입력하기 전에 키 바인딩 시스템을 만들면 어떨까? 해서 키 바인딩 시스템을 설명하고자 한다.</p>
<p><br></br></p>
<h3 id="구조">구조</h3>
<p>새로운 시스템이기 때문에 글을 쓰기에 앞서서 먼저 구조를 조금 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/17840512-b16d-4297-ab2b-19ceb0ba38c6/image.png" alt=""></p>
</br>

<p>키 바인딩 UI을 포함한 <strong>팝업 UI</strong>의 구현에 사용되는 UI들은 <strong><code>PopupUIManager</code>의 관리를 통해서만 UI를 활성화 및 비활성화</strong>할 수 있다.</p>
<p>따라서 <code>PopupUIManager</code>에서 각 팝업 UI들의 프레젠터를 알고 이에 매핑된 키를 통해서 각 팝업 UI Presenter들의 <code>OpenUI()</code> 또는 <code>CloseUI()</code>를 호출할 책임을 가져야 한다. </p>
<p>따라서 각 <strong>팝업 UI들의 프레젠터를 <code>PopupUIManager</code>에서 인식할 수 있도록 <code>IPopupPresenter</code>라는 어댑터 인터페이스를 도입</strong>한다. </p>
</br>

<p>추가적으로 <code>PopupUIManager</code>의 호출로만 비활성화가 가능한 것은 아니다. UI마다 각각의 비활성화 버튼을 지니게 될 수도 있다. </p>
<p>하지만 이를 온전히 <code>PopupUIManager</code>의 책임으로만 물기에는 <code>PopupUIManager</code>의 크기가 비대해진다.</p>
<p>이런 경우에는 UI의 <code>View</code>가 <code>IPopupView</code>를 구현하여 <code>CloseUI()</code>에서 반드시 <code>RemovePresenter()</code>를 호출하도록 해야 한다.</p>
<p><br></br></p>
<h3 id="키-데이터-구성">키 데이터 구성</h3>
<p>우선 서비스에서 관리하고 제공할 데이터의 형태를 먼저 구성해야 한다. 내가 생각한 데이터 관리 구성에서는 <strong><code>string</code>과 <code>UnityEngine.KeyCode</code>를 매핑</strong>해야겠다는 생각을 먼저 했다.</p>
<p>따라서 다음과 같은 구조로 KeyData를 설계했다.</p>
<pre><code class="language-C#">// LocalKeyService.cs의 일부

namespace KeyService
{
    #region Serialization
    [System.Serializable]
    public struct KeyData
    {
        public string Name;        // 키에 매핑될 문자열
        public KeyCode Code;

        public KeyData(string name, KeyCode code)
        {
            Name = name;
            Code = code;
        }
    }

    // Json은 배열을 읽고 쓰기 어렵기 때문에 한 번 더 래핑하는 과정이 필요하다.
    public class DataWrapper
    {
        public KeyData[] Data;

        public DataWrapper(KeyData[] data)
        {
            Data = data;
        }
    }
    #endregion Serialization

    // ...
}</code></pre>
<p>즉, 키 서비스에서는 KeyData를 딕셔너리로 관리하게 될 것이다. 그리고 이 키 서비스를 이용하는 PopupUIManager에서의 동작은 다음과 같을 것이다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/77d9c9ac-3c5f-4d7c-96b2-89ad54999935/image.png" alt=""></p>
<p><br></br></p>
<h3 id="키-서비스">키 서비스</h3>
<p>여느 서비스와 마찬가지로 키 서비스도 확장성과 변경성을 위해 인터페이스를 우선적으로 구현한다.</p>
<pre><code class="language-C#">using System;
using UnityEngine;

namespace KeyService
{
    public interface IKeyService
    {
        // 이전의 키와 달라진 키가 있는 경우에 호출되는 델리게이트다.
        event Action&lt;KeyCode, string&gt; OnUpdatedKey;


        void Initialize();    // 초기화를 위해 OnUpdatedKey를 실행시키는 용도로 사용한다.
        void Reset();        // 키를 기본 설정으로 되돌릴 때 사용한다.

        KeyCode GetKeyCode(string key_name);            // 문자열에 매핑된 키 코드를 반환한다.
        bool Check(KeyCode key, KeyCode current_key);    // 변경하려는 키가 유효한 키인지 확인한다.
        void Register(KeyCode key, string key_name);    // 유효한 키를 토대로 키를 변경한다.
    }
}</code></pre>
<p>그리고 개발하려는 게임에 맞게 인터페이스를 구체화한다. Lapi는 싱글플레이이므로 로컬 키 서비스를 구체화했다.</p>
<pre><code class="language-C#">namespace KeyService
{
    public class LocalKeyService : ISaveable, IKeyService
    {
        private Dictionary&lt;string, KeyCode&gt; m_key_dict;

        public event Action&lt;KeyCode, string&gt; OnUpdatedKey;

        public LocalKeyService()
        {
            m_key_dict = new();

            CreateDirectory();        // 디렉터리 경로가 없는 경우 새롭게 생성한다.
            Reset();                // 기본 설정 키로 초기화한다.
        }

        private void CreateDirectory()
        {
            var local_directory_path = Path.Combine(Application.persistentDataPath, &quot;Key&quot;);

            if (!Directory.Exists(local_directory_path))
            {
                Directory.CreateDirectory(local_directory_path);

#if UNITY_EDITOR
                Debug.Log($&quot;&lt;color=cyan&gt;Key 디렉터리를 새롭게 생성합니다.&lt;/color&gt;&quot;);
#endif
            }
        }

        // 모든 바인딩된 키를 업데이트한다.
        public void Initialize()
        {
            foreach (var pair in m_key_dict)
            {
                OnUpdatedKey?.Invoke(pair.Value, pair.Key);
            }
        }

        // 기본 설정 키로 초기화한다.
        public void Reset()
        {
            m_key_dict.Clear();

            Register(KeyCode.I, &quot;Inventory&quot;);
            Register(KeyCode.U, &quot;Equipment&quot;);
            Register(KeyCode.K, &quot;Skill&quot;);
            Register(KeyCode.T, &quot;Quest&quot;);
            Register(KeyCode.P, &quot;Binder&quot;);
            Register(KeyCode.H, &quot;Shortcut&quot;);

            Register(KeyCode.Alpha1, &quot;Shortcut0&quot;);
            Register(KeyCode.Alpha2, &quot;Shortcut1&quot;);
            Register(KeyCode.Alpha3, &quot;Shortcut2&quot;);
            Register(KeyCode.Alpha4, &quot;Shortcut3&quot;);
            Register(KeyCode.Alpha5, &quot;Shortcut4&quot;);

            Register(KeyCode.Z, &quot;Shortcut5&quot;);
            Register(KeyCode.X, &quot;Shortcut6&quot;);
            Register(KeyCode.C, &quot;Shortcut7&quot;);
            Register(KeyCode.V, &quot;Shortcut8&quot;);
            Register(KeyCode.B, &quot;Shortcut9&quot;);

            Register(KeyCode.Escape, &quot;Pause&quot;);
        }

        // 변경하려는 키가 유효한 키인지 확인한다.
        public bool Check(KeyCode key, KeyCode current_key)
        {
            // 변경하려는 키가 현재 키와 같다면 변경이 가능하다.
            if (current_key == key)
            {
                return true;
            }

            // 키보드 알파벳 자판과 숫자만 가능하다.
            if (KeyCode.A &lt;= key &amp;&amp; key &lt;= KeyCode.Z ||
                KeyCode.Alpha0 &lt;= key &amp;&amp; key &lt;= KeyCode.Alpha9) { }
            else
            {
                return false;
            }

            // WASD는 이동 키로 예약되어 있으므로 이 키는 바인딩이 불가능하다.
            if (key == KeyCode.W ||
                key == KeyCode.A ||
                key == KeyCode.S ||
                key == KeyCode.D)
            {
                return false;
            }

            // 이미 바인딩이 되어있는 키라면 이 키는 바인딩이 불가능하다.
            foreach (var pair in m_key_dict)
            {
                if (key == pair.Value)
                {
                    return false;
                }
            }

            return true;
        }

        // 입력한 키를 주어진 문자열과 매핑하여 바인딩한다.
        public void Register(KeyCode key, string key_name)
        {
            m_key_dict[key_name] = key;

            OnUpdatedKey?.Invoke(key, key_name);
        }

        // 문자열과 매핑된 키 코드를 반환한다.
        public KeyCode GetKeyCode(string key_name)
        {
            return m_key_dict.TryGetValue(key_name, out var code) ? code : KeyCode.None;
        }

        public bool Load(int offset)
        {
            var local_data_path = Path.Combine(Application.persistentDataPath, &quot;Key&quot;, $&quot;KeyData{offset}.json&quot;);

            if (File.Exists(local_data_path))
            {
                m_key_dict.Clear();

                var json_data = File.ReadAllText(local_data_path);
                var wrapped_data = JsonUtility.FromJson&lt;DataWrapper&gt;(json_data);

                foreach (var key_data in wrapped_data.Data)
                {
                    Register(key_data.Code, key_data.Name);
                }
            }
            else
            {
                return false;
            }

            return true;
        }

        public void Save(int offset)
        {
            var local_data_path = Path.Combine(Application.persistentDataPath, &quot;Key&quot;, $&quot;KeyData{offset}.json&quot;);

            var temp_list = new List&lt;KeyData&gt;();
            foreach (var pair in m_key_dict)
            {
                temp_list.Add(new(pair.Key, pair.Value));
            }

            var wrapped_data = new DataWrapper(temp_list.ToArray());
            var json_data = JsonUtility.ToJson(wrapped_data, true);

            File.WriteAllText(local_data_path, json_data);
        }
    }
}</code></pre>
<p><br></br></p>
<h3 id="서비스-등록">서비스 등록</h3>
<p>완성한 로컬 키 서비스를 어디서든 사용할 수 있도록 서비스 로케이터에 등록한다.</p>
<pre><code class="language-C#">// 이전과 동일

public static class ServiceLocator
{
    private static Dictionary&lt;Type, object&gt; m_services = new();

    public static IDictionary&lt;Type, object&gt; Services =&gt; m_services;

    public static void Initialize()
    {
        Register&lt;IEXPService&gt;(new LocalEXPService());
        Register&lt;IUserService&gt;(new LocalUserService());
        Register&lt;IKeyService&gt;(new LocalKeyService());
        // 이전과 동일
    }

    public static void Register&lt;T&gt;(T service)
    {
        // 이전과 동일
    }

    public static T Get&lt;T&gt;()
    {
        // 이전과 동일
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>이번 글에서는 바인딩된 키를 관리하고 키를 새롭게 바인딩할 수 있는 키 서비스를 구현했다. 키 바인딩 시스템에서는 이 키 서비스를 토대로 이야기를 계속 전개해 나갈 예정이다.</p>
<p>다음 글에서는 키 바인딩 시스템을 실제로 사용자가 할 수 있도록 키 바인더 UI를 제작해보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[유저 데이터 시스템] 스테이터스 UI]]></title>
            <link>https://velog.io/@jxng-min/%EC%9C%A0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%8A%A4%ED%85%8C%EC%9D%B4%ED%84%B0%EC%8A%A4-UI</link>
            <guid>https://velog.io/@jxng-min/%EC%9C%A0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%8A%A4%ED%85%8C%EC%9D%B4%ED%84%B0%EC%8A%A4-UI</guid>
            <pubDate>Tue, 12 Aug 2025 09:20:28 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>앞서 구현한 로컬 경험치 서비스와 로컬 유저 서비스를 토대로 스테이터스 UI를 제작할 차례다.
만약 UI로 사용하기에 적절한 이미지가 없다면 아래의 이미지를 사용하도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/6a27be7e-cef1-403c-9afa-1372be54182d/image.png" alt=""></p>
</br>

<p><a href="https://velog.io/@jxng-min/%EC%9C%A0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B2%BD%ED%97%98%EC%B9%98-%EC%84%9C%EB%B9%84%EC%8A%A4">여기</a>에서 말했듯이 UI는 MVP 아키텍처 패턴을 이용해서 제작할 것이다. 그래서 <code>Model</code>, <code>View</code>, <code>Presenter</code>의 순서대로 살펴보도록 하겠다.</p>
<p><br></br></p>
<h3 id="model">Model</h3>
<p>MVP 패턴에서 <code>Model</code>이라는 요소 자체의 의미가 실제 데이터가 존재하는 곳이기 때문에 <code>Model</code>을 새롭게 정의할 필요가 없다. <del>하지만 나는 만들었다.</del></p>
<pre><code class="language-C#">using System;

public class StatusModel
{
    private PlayerStatus m_player_status;

    // 체력에 변동이 있으면 실행될 이벤트
    public Action&lt;float, float&gt; OnUpdatedHP
    {
        get =&gt; m_player_status.OnUpdatedHP;
        set =&gt; m_player_status.OnUpdatedHP = value;
    }

    // 마나에 변동이 있으면 실행될 이벤트
    public Action&lt;float, float&gt; OnUpdateMP
    {
        get =&gt; m_player_status.OnUpdatedMP;
        set =&gt; m_player_status.OnUpdatedMP = value;
    }

    // 생성자 주입을 통해 PlayerStatus를 주입받는다.
    public StatusModel(PlayerStatus player_status)
    {
        m_player_status = player_status;
    }

    // PlayerStatus를 초기화한다.
    public void Initialize()
    {
        m_player_status.Initialize();
    }
}</code></pre>
<p>여기서 <code>PlayerStatus</code>는 <strong>플레이어의 스테이터스 상태에 관한 정보를 실시간으로 조정</strong>하는 클래스다. 이 글의 취지와 맞지 않아 모든 내용을 실을 수는 없지만 요약하면 다음과 같다.</p>
<pre><code class="language-C#">public class PlayerStatus : MonoBehaviour, IStatus
{
    private IUserService m_user_service;
    private PlayerCtrl m_controller;

    public Action&lt;float, float&gt; OnUpdatedHP;
    public Action&lt;float, float&gt; OnUpdatedMP;

    public float HP =&gt; m_user_service.Status.HP;
    public float MP =&gt; m_user_service.Status.MP;
    public float MaxHP =&gt; m_controller.DefaultStatus.HP
                                + (m_user_service.Status.Level - 1) * m_controller.GrowthStatus.HP
                                + m_controller.EquipmentEffect.HP;

    public float MaxMP =&gt; m_controller.DefaultStatus.MP
                                + (m_user_service.Status.Level - 1) * m_controller.GrowthStatus.MP
                                + m_controller.EquipmentEffect.MP;

// 이하 생략</code></pre>
<p>즉, 어떤 형태냐고 설명하자면 다음과 같은 순서대로 진행될 것이다.</p>
<blockquote>
<ol>
<li>플레이어가 몬스터에게 피격 당한다.</li>
<li><code>PlayerStatus</code>의 <code>UpdateHP()</code>가 호출된다.</li>
<li><code>UpdateHP()</code> 내부에서 <code>OnUpdatedHP</code> 델리게이트를 실행한다.</li>
<li>델리게이트에 등록된 모든 이벤트들이 실행된다. </li>
</ol>
</blockquote>
<p>이 모든 이벤트들 중 스테이터스 UI에서의 체력과 마나 갱신이 포함된다.</p>
<p><br></br></p>
<h3 id="view">View</h3>
<p><code>View</code>는 다양한 형태로 다양한 곳에 사용될 수 있다. 따라서 <code>View</code> 자체를 하나의 구체적인 스크립트로 구현하기보다 <code>View</code>를 인터페이스로 정의하는 것이 더 바람직하다.</p>
<pre><code class="language-C#">public interface IStatusView
{
    void Inject(StatusPresenter presenter);
    void UpdateLV(int level, float exp_rate);      // 경험치 갱신에 사용된다.
    void UpdateHP(float hp_rate);                // 체력 갱신에 사용된다.
    void UpdateMP(float mp_rate);                // 마나 갱신에 사용된다.
}</code></pre>
<p>다양한 형태로 사용될 수 있지만 우리는 대표적인 스테이터스 UI로 활용한다. 우선 다음과 같이 UI를 구성한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/f3b1b2e4-d00d-4ce2-bc39-1bb1f3e52892/image.png" alt=""></p>
<p>그리고 스테이터스 UI에 부착할 <code>IStatusView</code>를 구현하는 클래스를 작성한다.</p>
<pre><code class="language-C#">using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class StatusView : MonoBehaviour, IStatusView
{
    [Header(&quot;UI 관련 컴포넌트&quot;)]
    [Header(&quot;레벨&quot;)]
    [SerializeField] private TMP_Text m_level_label;

    [Header(&quot;경험치&quot;)]
    [SerializeField] private Slider m_exp_slider;

    [Header(&quot;체력&quot;)]
    [SerializeField] private Slider m_hp_slider;

    [Header(&quot;마나&quot;)]
    [SerializeField] private Slider m_mp_slider;

    // 코루틴을 경험치, 체력, 마나 별로 두어 코루틴이 동시 실행되는 일이 없도록 한다.
    private Coroutine m_exp_coroutine;
    private Coroutine m_hp_coroutine;
    private Coroutine m_mp_coroutine;

    private StatusPresenter m_presenter;

    // 객체가 파괴되는 시점에서 델리게이트에 등록된 이벤트를 해제한다.
    private void OnDestroy()
    {
        m_presenter.Dispose();
    }

    // Inject()를 통해서 외부에서 프레젠터를 주입받는다.
    public void Inject(StatusPresenter presenter)
    {
        m_presenter = presenter;
    }

    // 레벨과 경험치를 갱신한다.
    public void UpdateLV(int level, float exp_rate)
    {
        m_level_label.text = $&quot;LV.{level}&quot;;

        if (m_exp_coroutine != null)
        {
            StopCoroutine(m_exp_coroutine);
            m_exp_coroutine = null;
        }

        m_exp_coroutine = StartCoroutine(UpdateSlider(m_exp_slider, exp_rate));
    }

    // 체력을 갱신한다.
    public void UpdateHP(float hp_rate)
    {
        if (m_hp_coroutine != null)
        {
            StopCoroutine(m_hp_coroutine);
            m_hp_coroutine = null;
        }

        m_hp_coroutine = StartCoroutine(UpdateSlider(m_hp_slider, hp_rate));
    }

    // 마나를 갱신한다.
    public void UpdateMP(float mp_rate)
    {
        if (m_mp_coroutine != null)
        {
            StopCoroutine(m_mp_coroutine);
            m_mp_coroutine = null;
        }

        m_mp_coroutine = StartCoroutine(UpdateSlider(m_mp_slider, mp_rate));
    }

    // 슬라이더의 값을 부드럽게 보간하여 변경시키는 코루틴이다.
    private IEnumerator UpdateSlider(Slider slider, float rate)
    {
        var elapsed_time = 0f;
        var target_time = 1f;

        while (elapsed_time &lt;= target_time)
        {
            // 없어도 무방하여 생략한다.

            elapsed_time += Time.deltaTime;

            var delta = elapsed_time / target_time;
            slider.value = Mathf.Lerp(slider.value, rate, delta);

            yield return null;
        }

        slider.value = rate;
    }
}</code></pre>
<p><br></br></p>
<h3 id="presenter">Presenter</h3>
<p>마지막으로 Presenter를 작성할 차례다. Presenter는 Model과 View 사이에서 중개하는 역할로 Model과 View에 대한 참조가 모두 필요하다.</p>
<pre><code class="language-C#">using System;
using EXPService;
using SkillService;
using UserService;

public class StatusPresenter : IDisposable
{
    private readonly IStatusView m_view;
    private readonly StatusModel m_model;
    private readonly IEXPService m_exp_service;
    private readonly IUserService m_user_service;
    private readonly ISkillService m_skill_service;

    // 생성자를 통해 필요한 서비스와 model, view를 주입받는다.
    public StatusPresenter(IStatusView view,
                           StatusModel model,
                           IEXPService exp_service,
                           IUserService user_service,
                           ISkillService skill_service)
    {
        m_view = view;
        m_model = model;
        m_exp_service = exp_service;
        m_user_service = user_service;
        m_skill_service = skill_service;

        // 각 서비스와 model에 이벤트를 등록한다.
        m_user_service.OnUpdatedLevel += UpdateLV;
        m_model.OnUpdatedHP += UpdateHP;
        m_model.OnUpdateMP += UpdateMP;

        m_user_service.InitializeLevel();

        m_view.Inject(this);
    }

    // 이벤트를 통해서 받은 정보로 레벨과 경험치 갱신을 한다.
    public void UpdateLV(int level, int current_exp)
    {
        var max_exp = m_exp_service.GetEXP(level);
        while (current_exp &gt;= max_exp)
        {
            current_exp -= max_exp;

            m_user_service.UpdateLevel(-max_exp);
            m_user_service.Status.Level++;
            m_skill_service.UpdatePoint(3);    // 지금은 없어도 무방하다.
            m_model.Initialize();
        }

        m_view.UpdateLV(m_user_service.Status.Level, (float)current_exp / (float)max_exp);
    }

    // 이벤트를 통해서 받은 정보로 체력 갱신을 한다.
    public void UpdateHP(float current_hp, float max_hp)
    {
        m_view.UpdateHP(current_hp / max_hp);
    }

    // 이벤트를 통해서 받은 정보로 마나 갱신을 한다.
    public void UpdateMP(float current_mp, float max_mp)
    {
        m_view.UpdateMP(current_mp / max_mp);
    }

    // 객체가 파괴될 때 이벤트 등록을 해제한다.
    public void Dispose()
    {
        m_user_service.OnUpdatedLevel -= UpdateLV;
        m_model.OnUpdatedHP -= UpdateHP;
        m_model.OnUpdateMP -= UpdateMP;
    }
}</code></pre>
<p><br></br></p>
<h3 id="인스톨러-등록">인스톨러 등록</h3>
<p>스테이터스 UI에 의존성을 주입할 방법을 생성자와 메서드를 통해서 만들긴 했지만 아직 의존성 주입을 하는 곳을 찾지 못했다.</p>
<p>이 의존성을 주입하는 곳이 바로 인스톨러다. 다음과 같이 <code>StatusUIInstaller</code>를 정의하고 하이어라키 뷰에서 <code>Bootstrapper</code> 하위에 배치시킨다.</p>
<pre><code class="language-C#">using EXPService;
using SkillService;
using UnityEngine;
using UserService;

public class StatusUIInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;플레이어 상태 컴포넌트&quot;)]
    [SerializeField] private PlayerStatus m_player_status;

    [Header(&quot;스테이터스 UI 뷰&quot;)]
    [SerializeField] private StatusView m_status_view;

    public void Install()
    {
        DIContainer.Register&lt;PlayerStatus&gt;(m_player_status);
        DIContainer.Register&lt;IStatusView&gt;(m_status_view);

        var status_model = new StatusModel(m_player_status);
        DIContainer.Register&lt;StatusModel&gt;(status_model);

        var status_presenter = new StatusPresenter(m_status_view,
                                                   status_model,
                                                   ServiceLocator.Get&lt;IEXPService&gt;(),
                                                   ServiceLocator.Get&lt;IUserService&gt;(),
                                                   ServiceLocator.Get&lt;ISkillService&gt;());
        DIContainer.Register&lt;StatusPresenter&gt;(status_presenter);
    }
}</code></pre>
<p>그리고 인스펙터로 주입 가능한 컴포넌트들을 인스펙터에서 연결 후 의존성을 주입한다.</p>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>다음과 같이 정상적으로 UI가 작동하는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/7f0dffa5-65c7-439b-9bfd-498a97cadf7e/image.gif" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/83060cf9-6901-456a-8e5a-9d9ad23c0d5e/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[유저 데이터 시스템] 유저 서비스]]></title>
            <link>https://velog.io/@jxng-min/%EC%9C%A0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%9C%A0%EC%A0%80-%EC%84%9C%EB%B9%84%EC%8A%A4</link>
            <guid>https://velog.io/@jxng-min/%EC%9C%A0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%9C%A0%EC%A0%80-%EC%84%9C%EB%B9%84%EC%8A%A4</guid>
            <pubDate>Tue, 12 Aug 2025 08:33:22 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>저번 글에서는 스테이터스 UI에 필요한 요소인 총 경험치를 로드하고 제공하는 경험치 서비스를 구현했었다.</p>
<p>이번 글에서는 스테이터스 UI에 표시하기 위한 나머지 데이터들을 관리하고 제공하는 유저 서비스를 구현해볼 예정이다. 시작해보자.</p>
<p><br></br></p>
<h3 id="유저-데이터-구성">유저 데이터 구성</h3>
<p>우선 서비스에서 제공할 데이터를 먼저 생각해야 한다. 유저 데이터가 포함할 수 있는 정보에는 엄청나게 많은 정보가 있다. 예를 들면 닉네임, 레벨, 경험치, 체력, 마나, 인벤토리, 장비, 스킬 등등이다.</p>
<p>하지만 이를 모두 유저 서비스에서 관리하면 어디까지가 유저 서비스의 책임인지가 불분명해진다. </p>
<p>그래서 과감히 유저 서비스는 <strong>레벨, 경험치, 체력, 마나, 플레이 시간</strong>과 유저의 위치, 카메라의 위치까지만을 관리하기로 결정했다. </p>
<p>카메라의 위치는 일반적이진 않지만 내가 만든 게임에서는 카메라의 위치가 고정되는 형태라 데이터를 로드할 때 반드시 필요한 요소다. 하지만 유저 서비스와는 전혀 무관한 데이터다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/3dd42dbd-54ac-44d0-be7b-446976f5d29e/image.gif" alt=""></p>
</br>

<p>아무튼 이에 맞춰서 <code>StatusData</code>를 먼저 정의한다. <code>StatusData</code>는 <strong>플레이어의 레벨, 경험치, 체력, 마나를 보관</strong>하는 클래스다.</p>
<pre><code class="language-C#">    [System.Serializable]
    public class StatusData
    {
        public int Level;
        public int EXP;
        public float HP;
        public float MP;

        public StatusData(int level = 1, int exp = 0, float hp = 500f, float mp = 300f)
        {
            Level = level;
            EXP = exp;
            HP = hp;
            MP = mp;
        }
    }</code></pre>
<p>그리고 <code>StatusData</code>를 멤버로 가지는 <code>UserData</code>를 정의한다. <code>UserData</code>는 <code>StatusData</code>를 포함하여 <strong>모든 플레이어와 관련된 정보를 보관</strong>하는 클래스다.</p>
<pre><code class="language-C#">    [System.Serializable]
    public class UserData
    {
        public Vector3 Position;
        public Vector3 Camera;
        public float PlayTime;
        public StatusData Status;

        public UserData()
        {
            // 플레이어의 시작 위치와 카메라의 시작 위치를 하드 코딩했다. (무시해도 된다.)
            Position = new Vector3(23f, -27f, 0f);
            Camera = new Vector3(5.5f, -19.5f, -10f);
            PlayTime = 0f;
            Status = new StatusData();
        }

        public UserData(Vector3 position, Vector3 camera, float playtime, StatusData status)
        {
            Position = position;
            Camera = camera;
            PlayTime = playtime;
            Status = status;
        }
    }</code></pre>
<p><br></br></p>
<h3 id="유저-서비스">유저 서비스</h3>
<p>이제 위에서 정의한 <code>UserData</code>를 관리할 유저 서비스를 구현할 차례다. 모든 서비스와 마찬가지로 유저 서비스도 확장과 변경에 용이하도록 인터페이스를 구현한다.</p>
<pre><code class="language-C#">using System;
using UnityEngine;

namespace UserService
{
    public interface IUserService
    {
        Vector3 Position { get; set; }
        Vector3 Camera { get; set; }
        float PlayTime { get; set; }
        StatusData Status { get; set; }

        // 경험치 획득에 따른 이벤트 연결을 위한 델리게이트다.
        event Action&lt;int, int&gt; OnUpdatedLevel;

        void InitializeLevel();
        void UpdateLevel(int exp);
    }
}</code></pre>
<table>
<thead>
<tr>
<th>속성</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>Position</code></td>
<td>플레이어의 위치를 저장하고 반환한다.</td>
</tr>
<tr>
<td><code>Camera</code></td>
<td>카메라의 위치를 저장하고 반환한다.</td>
</tr>
<tr>
<td><code>PlayTime</code></td>
<td>현재까지의 플레이타임을 저장하고 반환한다.</td>
</tr>
<tr>
<td><code>Status</code></td>
<td>플레이어의 기본 정보를 저장하고 반환한다.</td>
</tr>
</tbody></table>
</br>

<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>InitializeLevel()</code></td>
<td>레벨을 초기화한다.</td>
</tr>
<tr>
<td><code>UpdateLevel(int exp)</code></td>
<td>경험치를 exp만큼 획득한다.</td>
</tr>
</tbody></table>
</br>

<p>유저 서비스는 유저의 현재 정보를 저장하고 불러오는 <code>Load()</code>와 <code>Save()</code>도 가능해야 한다. </p>
<p>하지만 넓은 구조로 바라봤을 때 <strong>유저 서비스는 단순히 유저의 정보를 제공하는 서비스</strong>이지 <code>Load()</code>와 <code>Save()</code>를 하는 서비스가 아니다.</p>
<p>따라서 <code>ISaveable</code>이라는 인터페이스를 새롭게 정의하고 이를 유저 서비스가 구현하도록 한다.</p>
<pre><code class="language-C#">// offset은 게임의 특성 상 필요하다. 반드시 필요한 요소가 절대 아니다.
public interface ISaveable
{
    bool Load(int offset);    // 데이터를 불러오는 데 실패한 경우, 예외 처리를 위해 bool을 반환한다.
    void Save(int offset);
}</code></pre>
<p><code>offset</code>은 반드시 필요한 요소가 아니지만, 나는 로드 세이브를 RPG Maker 식으로 제작하고 싶었기 때문에 슬롯을 구별하기 위해 <code>offset</code>을 두었다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/0389c095-f2b1-4998-aa28-db92d03f5ccc/image.png" alt=""></p>
</br>

<pre><code class="language-C#">using System;
using System.IO;
using UnityEngine;
using UserService;

public class LocalUserService : ISaveable, IUserService
{
    private Vector3 m_position;
    private Vector3 m_camera;
    private float m_playtime;
    private StatusData m_status;

    public event Action&lt;int, int&gt; OnUpdatedLevel;

    public Vector3 Position
    {
        get =&gt; m_position;
        set =&gt; m_position = value;
    }

    public Vector3 Camera
    {
        get =&gt; m_camera;
        set =&gt; m_camera = value;
    }

    public float PlayTime
    {
        get =&gt; m_playtime;
        set =&gt; m_playtime = value;
    }

    public StatusData Status
    {
        get =&gt; m_status;
        set =&gt; m_status = value;
    }

    // 처음부터 슬롯이 할당되지 않기 때문에 기본 정보로 세팅한다.
    public LocalUserService()
    {
        var user_data = new UserData();

        m_position = user_data.Position;
        m_camera = user_data.Camera;
        m_playtime = user_data.PlayTime;
        m_status = user_data.Status;

        CreateDirectory();
    }

    // 디렉터리 경로가 없는 경우에는 디렉터리 경로를 생성한다.
    private void CreateDirectory()
    {
        var local_directory = Path.Combine(Application.persistentDataPath, &quot;User&quot;);

        if (!Directory.Exists(local_directory))
        {
            Directory.CreateDirectory(local_directory);
        }
    }

    // 처음에는 경험치 획득이 없기 때문에 강제로 이벤트를 발생시킨다.
    public void InitializeLevel()
    {
        OnUpdatedLevel?.Invoke(m_status.Level, m_status.EXP);
    }

    // 경험치 획득에 사용한다.
    public void UpdateLevel(int exp)
    {
        m_status.EXP += exp;

        OnUpdatedLevel?.Invoke(m_status.Level, m_status.EXP);
    }

    public bool Load(int offset)
    {
        var local_data_path = Path.Combine(Application.persistentDataPath, &quot;User&quot;, $&quot;UserData{offset}.json&quot;);

        if (File.Exists(local_data_path))
        {
            var json_data = File.ReadAllText(local_data_path);
            var user_data = JsonUtility.FromJson&lt;UserData&gt;(json_data);

            m_position = user_data.Position;
            m_camera = user_data.Camera;
            m_playtime = user_data.PlayTime;
            m_status = user_data.Status;

            return true;
        }

        return false;
    }

    public void Save(int offset)
    {
        var local_data_path = Path.Combine(Application.persistentDataPath, &quot;User&quot;, $&quot;UserData{offset}.json&quot;);

        var user_data = new UserData(m_position, m_camera, m_playtime, m_status);
        var json_data = JsonUtility.ToJson(user_data, true);

        File.WriteAllText(local_data_path, json_data);
    }
}</code></pre>
<p><br></br></p>
<h3 id="서비스-등록">서비스 등록</h3>
<p>이렇게 로컬 유저 서비스를 모두 구현했다면 이를 서비스 로케이터에 등록하면 된다.</p>
<pre><code class="language-C#">// 이전과 동일

public static class ServiceLocator
{
    private static Dictionary&lt;Type, object&gt; m_services = new();

    public static IDictionary&lt;Type, object&gt; Services =&gt; m_services;

    public static void Initialize()
    {
        Register&lt;IEXPService&gt;(new LocalEXPService());
        Register&lt;IUserService&gt;(new LocalUserService());
        // 이전과 동일
    }

    public static void Register&lt;T&gt;(T service)
    {
        // 이전과 동일
    }

    public static T Get&lt;T&gt;()
    {
        // 이전과 동일
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>아직까진 눈에 보이는 작업이 없어서 흥미가 없을 수도 있다고 생각한다. 다음 글부터는 UI를 제작하고 연결하는 작업까지니 끝까지 해보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[유저 데이터 시스템] 경험치 서비스]]></title>
            <link>https://velog.io/@jxng-min/%EC%9C%A0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B2%BD%ED%97%98%EC%B9%98-%EC%84%9C%EB%B9%84%EC%8A%A4</link>
            <guid>https://velog.io/@jxng-min/%EC%9C%A0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B2%BD%ED%97%98%EC%B9%98-%EC%84%9C%EB%B9%84%EC%8A%A4</guid>
            <pubDate>Tue, 12 Aug 2025 07:55:21 GMT</pubDate>
            <description><![CDATA[</br>

<h3 id="시작">시작</h3>
<blockquote>
<p>MVP 패턴에 대해서 모르신다면 <a href="https://velog.io/@jxng-min/Unity-MVC-MVP-MVVM">여기</a>를 참고하세요. </p>
</blockquote>
<p>경험치 서비스를 설명하기에 앞서 유저 데이터 시스템의 다이어그램을 먼저 살펴보자.</p>
<p>유저 데이터 시스템은 MVP 구조로 설계했으며, 서비스와 UI를 분리하기로 한다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/7e5ff61d-1fa1-4c0b-ae43-5bf9c183a75f/image.png" alt=""></p>
<p><br></br></p>
<h3 id="경험치-서비스">경험치 서비스</h3>
<p>RPG 장르를 포함한 거의 모든 게임에서는 레벨이라는 요소가 필수적으로 사용된다. 그리고 이 레벨가 뗄레야 뗄 수 없는 요소가 바로 경험치다. <del>경험치가 있어야 레벨이 있으니</del></p>
<p>스테이터스 UI를 완성하기 위해서는 먼저 필요한 요소들을 정리해야 한다. 게임마다 프로그래머마다 다르겠지만 나는 <strong>레벨, 경험치, 체력, 마나 상태만을 포함</strong>하기로 결정했다.</p>
<p>체력과 마나를 제외하여 말하면 스테이터스 UI에 나타날 레벨과 경험치를 경험치 서비스에서 제공한다.</p>
<blockquote>
<p>비교의 대상이 되는 총 경험치는 게임 도중에 변경되는 일이 없어야 한다. → 읽기 전용
<strong>총 경험치</strong>와 <strong>현재 경험치</strong>를 같이 관리하기 보단 따로 관리하는 편이 좋다. → 책임 분리</p>
</blockquote>
</br>

<p>우선 확장하기 쉽도록 총 경험치에 대한 서비스의 인터페이스를 정의한다.</p>
<pre><code class="language-C#">namespace EXPService
{
    public interface IEXPService
    {
        public int GetEXP(int current_level);
        public void Load();
    }
}</code></pre>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>GetEXP(int current_level)</code></td>
<td>현재 레벨을 입력하면 그 <strong>다음 레벨이 되기 위한 총 경험치를 반환</strong>한다.</td>
</tr>
<tr>
<td><code>Load()</code></td>
<td>전체 <strong>경험치 데이터를 모두 로드</strong>한다.</td>
</tr>
</tbody></table>
<p><br></br></p>
<p>그리고 이 인터페이스를 토대로 로컬에서 동작하는 경험치 서비스를 제작해야 한다.</p>
<p>경험치를 로컬에 저장하는 방법은 다양하지만, 나는 <code>Json</code>을 <code>StreamingAssets</code> 폴더 하위에 저장하고 이를 로드하는 방식을 선택했다.</p>
<p>단순히 기본 자료형이기 때문에 <code>Playerprefs</code>를 사용해도 무방하지만, 추후에 소개될 다른 서비스에서는 <strong><code>Playerprefs</code>로 저장할 수 없는 자료형들이 포함</strong>되기 때문에 <strong>일관성을 유지하기 위해서 모든 로컬 데이터는 <code>Json</code>으로 관리</strong>하도록 통일했다.</p>
<pre><code class="language-C#">using System.Collections.Generic;
using System.IO;
using UnityEngine;

namespace EXPService
{
    // 직렬화를 위한 데이터의 구조
    #region Serialization
    [System.Serializable]
    public struct EXPData
    {
        public int Level;  // 레벨과
        public int EXP;    // 레벨에 해당하는 총 경험치를 묶는다.
    }

    [System.Serializable]
    public class DataWrapper
    {
        public EXPData[] List;   // Json은 배열을 읽고 쓰는 데 어려움이 있으므로
    }                            // 한번 더 데이터를 래핑하는 과정을 거친다.
    #endregion Serialization

    public class LocalEXPService : IEXPService
    {
        private Dictionary&lt;int, int&gt; m_exp_dict = new();

        public LocalEXPService()
        {
            Load();
        }

        // StreamingAssets 폴더 하위에서 EXPData.json을 읽어 파싱하여 딕셔너리에 저장한다.
        public void Load()
        {
            var local_data_path = Path.Combine(Application.streamingAssetsPath, &quot;EXPData.json&quot;);

            if (File.Exists(local_data_path))
            {
                var json_data = File.ReadAllText(local_data_path);
                var wrapped_data = JsonUtility.FromJson&lt;DataWrapper&gt;(json_data);

                foreach (var exp_data in wrapped_data.List)
                {
                    m_exp_dict.TryAdd(exp_data.Level, exp_data.EXP);
                }

#if UNITY_EDITOR
                Debug.Log(&quot;&lt;color=cyan&gt;성공적으로 EXP 데이터를 로드하였습니다.&lt;/color&gt;&quot;);
#endif
            }
            else
            {
                // 존재하지 않는다면, 정상적인 게임이 불가능하므로 강제 종료한다.
#if UNITY_EDITOR
                Debug.LogError($&quot;{local_data_path}가 존재하지 않습니다.&quot;);
                UnityEditor.EditorApplication.isPlaying = false;
#else
                Application.Quit();
#endif
            }
        }

        // 딕셔너리를 활용하여 O(1)의 속도로 다음 레벨이 되기 위한 총 경험치를 반환한다.
        public int GetEXP(int current_level)
        {
            return m_exp_dict.TryGetValue(current_level + 1, out var exp) ? exp : 0;
        }
    }
}</code></pre>
<p><br></br></p>
<h3 id="서비스-등록">서비스 등록</h3>
<p>서비스 등록의 시점은 다를 수도 있다. 나는 <code>Title Bootstrapper</code>에 이 책임을 물었다.
로그인 씬에서 이 데이터를 로드하는 것이 자연스럽다고 생각했기 때문이다.</p>
<pre><code class="language-C#">// ...

public static class ServiceLocator
{
    private static Dictionary&lt;Type, object&gt; m_services = new();

    public static IDictionary&lt;Type, object&gt; Services =&gt; m_services;

    public static void Initialize()
    {
        Register&lt;IEXPService&gt;(new LocalEXPService());
        // 이전 글과 동일
    }

    public static void Register&lt;T&gt;(T service)
    {
        // 이전 글과 동일
    }

    public static T Get&lt;T&gt;()
    {
        // 이전 글과 동일
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>이제 이 경험치 서비스는 <code>Title Bootstrapper</code>를 통해서 자동으로 로드되기 때문에 경험치 서비스에 접근하여 <code>GetEXP()</code>를 통해 총 경험치 데이터를 받을 수 있다.</p>
<p>다음 글에서는 이 총 경험치와 현재 경험치를 비교하여 스테이터스 UI에서 경험치를 표시하도록 기반을 다져볼 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기본 구조] DI Container와 Installer]]></title>
            <link>https://velog.io/@jxng-min/Lapi-DI-Container%EC%99%80-Installer</link>
            <guid>https://velog.io/@jxng-min/Lapi-DI-Container%EC%99%80-Installer</guid>
            <pubDate>Tue, 12 Aug 2025 07:03:56 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>서비스 로케이터는 <code>DontDestroyOnLoad</code>와 <code>Singleton</code>으로부터 해방될 수 있는 첫 길잡이 역할을 했었다. 두 번째 길잡이는 DI Container다.</p>
<p>DI Container는 의존성을 주입할 때 편리하게 사용할 수 있는 도구다. DI Container 프레임워크를 사용하면 의존성 주입에 매우 편리하지만 프레임워크에 시간을 투자하기 싫었던 나는 서비스 로케이터와 비슷한 형태로 DI Container는 구현하기로 결정했다.</p>
<p>그리고 DI Container에서 제공하는 <code>Resolve()</code>를 모방하기 위해 <code>Bootstrapper</code>와 <code>Installer</code>의 개념을 도입하였다. 그러면 이제 하나하나 천천히 시작해보자.</p>
<p><br></br></p>
<h3 id="di-container">DI Container</h3>
<p>DI Container는 <strong>의존성을 한 군데로 모아서 보관</strong>하는 컨테이너의 역할을 한다. 여기저기 독립적으로 존재하는 의존성을 한 군데로 모은다면 <strong>관리도 쉽고 제공도 쉽다</strong>.</p>
<p>그리고 한 군데로 모은다는 관점에서 봤을 때 <strong>서비스 로케이터와도 매우 유사</strong>하다.</p>
<pre><code class="language-C#">using System;
using System.Collections.Generic;

public static class DIContainer
{
    private static Dictionary&lt;Type, object&gt; m_instances = new();

    // 의존성을 등록할 때 사용한다.
    public static void Register&lt;T&gt;(object instance)
    {
        m_instances[typeof(T)] = instance;
    }

    // 의존성을 주입할 때 사용한다.
    public static T Resolve&lt;T&gt;()
    {
        if (!IsRegistered&lt;T&gt;())
        {
            throw new Exception($&quot;{typeof(T)}가 DI 컨테이너에 등록되어 있지 않습니다.&quot;);
        }
        return (T)m_instances[typeof(T)];
    }

    // 의존하려는 객체가 존재하는지 확인할 때 사용한다.
    public static bool IsRegistered&lt;T&gt;()
    {
        return m_instances.ContainsKey(typeof(T));
    }

    // 딕셔너리를 지우며 의존성을 모두 제거한다.
    public static void Clear()
    {
        m_instances.Clear();
    }
}</code></pre>
</br>

<p>매우 간단하게 구현했기 때문에 부족한 기능들이 아직 많다. 이 부족한 기능들을 채워줄 도구들이 바로 <code>Bootstrapper</code>와 <code>Installer</code>다.</p>
<p><br></br></p>
<h3 id="installer">Installer</h3>
<p>Installer는 실질적으로 <strong>의존성을 주입하고 초기화</strong>하는 역할을 맡는다. 각 기능을 정말 잘 구현해서 모듈화가 잘 되어 있다면 그 만큼 Installer의 개수도 증가한다.</p>
<p>인스톨러는 여러 개일 가능성이 높으며, 각각의 인스톨러에서 주입할 의존성과 초기화 작업이 모두 다를 것이므로 단순하게 인터페이스만을 제공하며 필요 시, 이를 구현한다.</p>
<pre><code class="language-C#">public interface IInstaller
{
    void Install();
}</code></pre>
<p>예를 들어, 타이틀 씬에 존재하는 로더 UI에 의존성을 주입하는 과정을 살펴보면 다음과 같다.</p>
<pre><code class="language-C#">using EquipmentService;
using InventoryService;
using KeyService;
using QuestService;
using ShortcutService;
using SkillService;
using UnityEngine;
using UserService;

public class LoaderUIInstaller : MonoBehaviour, IInstaller
{
    [Header(&quot;로더 뷰&quot;)]
    [SerializeField] private LoaderView m_loader_view;

    [Header(&quot;로더 슬롯의 부모 트랜스폼&quot;)]
    [SerializeField] private Transform m_loader_slot_root;

    [Header(&quot;로더 = true, 세이버 = false&quot;)]
    [SerializeField] private bool m_is_loader;

    // 인스펙터에서 등록된 객체에 알맞은 의존성을 주입한다.
    public void Install()
    {
        var loader_slot_views = m_loader_slot_root.GetComponentsInChildren&lt;ILoaderSlotView&gt;();

        var loader_slot_presenters = new LoaderSlotPresenter[loader_slot_views.Length + 1];
        for (int i = 0; i &lt; loader_slot_presenters.Length; i++)
        {
                loader_slot_presenters[i] = new LoaderSlotPresenter(i == 4 ? null : loader_slot_views[i],
                                                                    ServiceLocator.Get&lt;IUserService&gt;(),
                                                                    ServiceLocator.Get&lt;IInventoryService&gt;(),
                                                                    ServiceLocator.Get&lt;IEquipmentService&gt;(),
                                                                    ServiceLocator.Get&lt;ISkillService&gt;(),
                                                                    ServiceLocator.Get&lt;IKeyService&gt;(),
                                                                    ServiceLocator.Get&lt;IShortcutService&gt;(),
                                                                    ServiceLocator.Get&lt;IQuestService&gt;(),
                                                                    i,
                                                                    m_is_loader);
        }

        var loader_presenter = new LoaderPresenter(m_loader_view, loader_slot_presenters);
    }
}</code></pre>
<p>이렇게 함으로써 클라이언트는 의존성이 있는 대상과 낮은 결합도를 가지게 된다.</p>
<blockquote>
<p>의존성을 없애는 것이 아니다. 단지, <strong>눈에 보이지 않는 곳으로 의존성을 옮긴다</strong>. 
이를 <strong>IoC</strong>(제어의 역전)이라고 한다.</p>
</blockquote>
<p><br></br></p>
<h3 id="bootstrapper">Bootstrapper</h3>
<p>위에서 구현한 DI Container는 의존성을 보관하고 내보내는 데에는 전혀 문제가 없다. 하지만 이 의존성을 주입할 위치를 결정하는 것이 애매하다.</p>
<p>리플렉션에 대해서 공부하여 이를 구현하기도 애매하기 때문에 의존성을 DI Container에서 제공받아서 필요한 곳에 제공하는 Bootstrapper를 두기로 결정했다.</p>
<p>Bootstrapper는 각 <strong>Installer를 순회하며 필요한 시점에 알맞게 순서대로 의존성을 주입</strong>한다.</p>
<pre><code class="language-C#">using UnityEngine;

public abstract class Bootstrapper : MonoBehaviour
{
    private IInstaller[] m_installers;

    protected virtual void Awake()
    {
        // 하위의 IInstaller를 구현하는 모든 자식 오브젝트를 모은다. 
        m_installers = transform.GetComponentsInChildren&lt;IInstaller&gt;();
    }

    protected virtual void Start()
    {
        // 이들을 순회하며 의존성을 주입한다.
        foreach (var installer in m_installers)
        {
            installer.Install();
        }
    }
}</code></pre>
<p>Bootstrapper를 추상 클래스로 작성한 데에는 Installer를 순회하는 데에 그치지 않고 더 필요한 의존성이 있으면 그것을 주입하기 위한 용도다.</p>
<p>예를 들어, 게임 씬에서 활용할 Bootstrapper는 위의 코드를 그대로 써도 충분하다. 하지만 로그인 씬이나 타이틀 씬에서 활용할 Bootstrapper에서는 더 필요한 의존성이 있을 수 있다.</p>
<pre><code class="language-C#">public class TitleBootstrapper : Bootstrapper
{
    protected override void Start()
    {
        // 서비스 로케이터에 서비스들을 한번에 모두 등록한다.
        ServiceLocator.Initialize();

        base.Start();
    }
}</code></pre>
</br>

<p>이렇게 구조를 다 잡고 나면 다음과 같이 이해를 할 수 있다.
<img src="https://velog.velcdn.com/images/jxng-min/post/d55ad26b-d983-4cb7-b3f3-bbb9ac58aef2/image.png" alt=""></p>
<p>부트스트래퍼가 실질적으로 인스톨러의 순서를 결정하는 것은 유니티의 하이어라키로 결정하며 다음과 같은 구조를 지닌다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/c1a9b8ca-f255-41d9-98b9-fbd73968f265/image.png" alt=""></p>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>잘 이해가 되지 않는 내용이 있다면 반드시 공부해볼 것을 추천한다. 절대 완벽한 내용은 아니지만 그럭저럭 흉내를 내기 위해서 많이 노력을 했다.</p>
<p>이제부터 본격적으로 DI Container와 서비스 로케이터를 활용하여 각 서비스를 설계하고 정의하여 이에 맞도록 UI를 제작해 볼 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기본 구조] 서비스 로케이터]]></title>
            <link>https://velog.io/@jxng-min/Lapi-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A1%9C%EC%BC%80%EC%9D%B4%ED%84%B0</link>
            <guid>https://velog.io/@jxng-min/Lapi-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A1%9C%EC%BC%80%EC%9D%B4%ED%84%B0</guid>
            <pubDate>Tue, 12 Aug 2025 06:30:03 GMT</pubDate>
            <description><![CDATA[<h3 id="시작">시작</h3>
<p>Lapi는 2D RPG를 제작해보고자 하여 시작한 프로젝트다. 다른 RPG와 비교해서 특별한 것을 보여주고 싶은 마음은 없지만, 다른 <strong>RPG에서 제공하는 기본적인 기능들을 구현</strong>해보려고 했다. 그리고 구현한 기능들을 하나하나 정리하면 누군가에게 도움이 되지 않을까라는 마음으로 정리하고자 한다.</p>
<p>Lapi는 인벤토리, 장비, 스킬, 퀘스트 등 다양한 UI를 제공한다. 이들은 모두 사용자가 보고 이해하기 쉽도록 제작해야 한다. 그리고 각각은 <strong>독립적으로 테스트하기 좋게</strong> 만들어져야 하기 때문에 사용자에게 보여지는 <strong>UI와 서비스를 분리하기로 결정</strong>했다.</p>
<p><br></br></p>
<h3 id="서비스-로케이터">서비스 로케이터</h3>
<blockquote>
</blockquote>
<p>서비스 로케이터를 잘 모르겠다면 <a href="https://velog.io/@jxng-min/Unity-Service-Locator-%ED%8C%A8%ED%84%B4">여기</a>를 참고하세요.</p>
<p>Lapi에서는 서비스마다 다르지만 씬을 넘나들면서 필요한 서비스들도 존재한다. </p>
<p>유니티에서는 <code>DontDestroyOnLoad</code>와 <code>Singleton</code>을 활용하면 씬을 넘나들면서 참조해야 하는 서비스를 게임 내내 유지시킬 수 있다. 하지만 <code>Singleton</code>은 나름의 문제점도 많이 가지고 있다.</p>
<p>그래서 Singleton을 덜 사용해보고자 하는 취지에서 서비스 로케이터를 사용했다. 그렇다고 서비스 로케이터가 Singleton보다 압도적으로 좋다는 것은 아니다. </p>
<p>다만 전역적으로 존재하면서 씬을 넘나들면서 사용할 수 있다는 장점 이외에도 서비스 로케이터에서 각 서비스에 인터페이스로 접근하기 때문에 <strong>서비스를 다양한 방법으로 제공</strong>할 수 있다는 장점이 있다.</p>
</br>

<p>서비스 로케이터가 기본적으로 동작하는 방식은 다음과 같다.</p>
<p>예를 들어 로컬 인벤토리를 사용하던 게임에서 클라우드 인벤토리로 변경한다고 하더라도 인터페이스에 의존하는 덕분에 클라이언트의 코드는 변경될 이유가 전혀 없다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/15df9f4f-a3a6-4dcf-8be0-0acb64128bd0/image.png" alt=""></p>
</br>

<pre><code class="language-C#">public static class ServiceLocator
{
    private static Dictionary&lt;Type, object&gt; m_services = new();

    public static IDictionary&lt;Type, object&gt; Services =&gt; m_services;

    // 없어도 무방하지만 동적으로 서비스를 갈아끼우는 구조가 아니라면 괜찮은 것 같다.
    public static void Initialize()
    {
        // 위치시키고 싶은 서비스를 각 구현체에 맞춰 초기화를 할 수 있다.
        // Lapi는 싱글플레이 게임이므로 로컬에서 데이터를 관리한다.
        Register&lt;IEXPService&gt;(new LocalEXPService());
        Register&lt;IUserService&gt;(new LocalUserService());
        Register&lt;IInventoryService&gt;(new LocalInventoryService());
        Register&lt;IItemDataService&gt;(new LocalItemDataService());
        Register&lt;IEquipmentService&gt;(new LocalEquipmentService());
        Register&lt;ISkillService&gt;(new LocalSkillService());
        Register&lt;IKeyService&gt;(new LocalKeyService());
        Register&lt;IShortcutService&gt;(new LocalShortcutService());
        Register&lt;INPCService&gt;(new LocalNPCService());
        Register&lt;IDialogueService&gt;(new LocalDialogueService());
        Register&lt;IQuestService&gt;(new LocalQuestService());
    }

    // 서비스를 등록할 때 사용한다.
    public static void Register&lt;T&gt;(T service)
    {
        if(!Services.ContainsKey(typeof(T)))
        {
            Services.TryAdd(typeof(T), service);
        }
    }

    // 서비스를 참조할 때 사용한다.
    public static T Get&lt;T&gt;()
    {
        if (!Services.TryGetValue(typeof(T), out var service))
        {
            throw new Exception($&quot;{typeof(T)} 서비스가 존재하지 않습니다.&quot;);
        }
        else
        {
            return (T)service;
        }        
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>서비스 로케이터를 통해서 Lapi 및 게임에서 사용할 전반적인 서비스를 모두 관리할 예정이다.
내가 만든 코드가 정답은 아니니 보완할 부분들은 더 보완해서 사용하면 좋을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Unity] 마우스 이벤트]]></title>
            <link>https://velog.io/@jxng-min/Unity-%EB%A7%88%EC%9A%B0%EC%8A%A4-%EC%9D%B4%EB%B2%A4%ED%8A%B8</link>
            <guid>https://velog.io/@jxng-min/Unity-%EB%A7%88%EC%9A%B0%EC%8A%A4-%EC%9D%B4%EB%B2%A4%ED%8A%B8</guid>
            <pubDate>Tue, 05 Aug 2025 15:17:00 GMT</pubDate>
            <description><![CDATA[<h3 id="서론">서론</h3>
<p>내가 제작 중인 <strong>Lapi</strong>라는 게임에서 커서를 NPC나 적에게 갖다대면 커서가 변경되는 것을 구현하고 있었다.</p>
<p>처음에는 <code>Update()</code>에서 마우스 위치로 레이캐스팅을 하며 <strong>레이 히트된 오브젝트가 있다면 분기를 통해서 커서를 변경</strong>하는 방법을 사용했었다.</p>
<p>하지만 이 방법은 감지할 오브젝트가 없어도 <code>Update()</code>를 통해 매 프레임 호출되기 때문에 최적화 측면에서 성능이 나빴다.</p>
<p>그래서 생각한 것이 &#39;<strong>매 프레임 호출되는 것 대신 오브젝트에 들어올 때와 벗어날 때 발생하는 이벤트가 있지 않을까?</strong>&#39;였다.</p>
<p><br></br></p>
<h3 id="마우스-이벤트">마우스 이벤트</h3>
<table>
<thead>
<tr>
<th>이벤트 메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>OnMouseEnter()</code></td>
<td>마우스가 오브젝트에 진입하면 이벤트 발생</td>
</tr>
<tr>
<td><code>OnMouseOver()</code></td>
<td>마우스가 오브젝트에 위치하는 동안 이벤트 발생</td>
</tr>
<tr>
<td><code>OnMouseExit()</code></td>
<td>마우스가 오브젝트에서 벗어나면 이벤트 발생</td>
</tr>
<tr>
<td><code>OnMouseUp()</code></td>
<td>마우스로 오브젝트를 클릭했다가 떼어낼 때 발생</td>
</tr>
<tr>
<td><code>OnMouseDown()</code></td>
<td>마우스로 오브젝트를 클릭할 때 발생</td>
</tr>
</tbody></table>
<p><br></br></p>
<h3 id="적용">적용</h3>
<p>나는 하나의 오브젝트가 아닌 NPC와 적에 모두 적용시키고 싶었다.
그래서 NPC와 적과 같은 구체화된 객체보다 고수준의 모듈을 만들기로 결정했다.</p>
<hr>
<p>먼저 추상 클래스 <code>MouseDetector</code>를 선언하여 커서에 대한 기본 동작과 NPC와 적이 구현해야 할 내용들과 필요할 때 구현할 내용을 정의했다.</p>
<pre><code class="language-C#">using UnityEngine;

public abstract class MouseDetector : MonoBehaviour
{
    [Header(&quot;커서 데이터베이스&quot;)]
    [SerializeField] private CursorDataBase m_cursor_db;

    protected void SetCursor(CursorMode mode)
    {
        m_cursor_db.SetCursor(mode);
    }

    protected abstract void OnMouseEnter();

    protected virtual void OnMouseExit()
    {
        SetCursor(CursorMode.DEFAULT);
    }
}
</code></pre>
<p>그 다음엔 NPC와 적이 각각 부착할 컴포넌트를 구현했다.</p>
<pre><code class="language-C#">using UnityEngine;

public class NPCMouseDetector : MouseDetector
{
    private NPC m_npc;
    private NameTagPresenter m_name_tag_presenter;

    private void Awake()
    {
        m_npc = GetComponent&lt;NPC&gt;();
    }

    public void Inject(NameTagPresenter presenter)
    {
        m_name_tag_presenter = presenter;
    }

    protected override void OnMouseEnter()
    {
        SetCursor(CursorMode.CAN_TALK);

        var target_position = (Vector2)transform.position + Vector2.up * 3f;

        m_name_tag_presenter.OpenUI(m_npc.Code, new System.Numerics.Vector2(target_position.x, target_position.y));
    }

    protected override void OnMouseExit()
    {
        base.OnMouseExit();

        m_name_tag_presenter.CloseUI();
    }

    protected virtual void OnMouseDown()
    {
        m_npc.Interaction();
    }
}</code></pre>
<pre><code class="language-C#">using UnityEngine;

public class EnemyMouseDetector : MouseDetector
{
    protected override void OnMouseEnter()
    {
        SetCursor(CursorMode.CAN_ATTACK);
    }
}</code></pre>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>위의 코드를 DI Container에서 의존성을 주입하는 과정을 거치고 다음과 같은 실행 결과를 얻을 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/86c64360-72b0-415d-b502-530edbb3282f/image.gif" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jxng-min/post/e735e15b-6bae-4cf2-b72c-741e7e1dbf20/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[면접] 2025-07-24 ICT 인턴십 마포구 S사]]></title>
            <link>https://velog.io/@jxng-min/%EB%A9%B4%EC%A0%91-2025-07-24-ICT-%EC%9D%B8%ED%84%B4%EC%8B%AD-%EB%A7%88%ED%8F%AC%EA%B5%AC-S%EC%82%AC</link>
            <guid>https://velog.io/@jxng-min/%EB%A9%B4%EC%A0%91-2025-07-24-ICT-%EC%9D%B8%ED%84%B4%EC%8B%AD-%EB%A7%88%ED%8F%AC%EA%B5%AC-S%EC%82%AC</guid>
            <pubDate>Thu, 24 Jul 2025 12:08:18 GMT</pubDate>
            <description><![CDATA[<h3 id="서론">서론</h3>
<p>서류 검토 기간 중 일주일이 넘어도 연락이 오지 않길래.. 서류 심사에서 불합격을 했구나 생각하던 찰나에 문자로 합격 통보를 받아 급하게 2일 후 면접을 보러 갔다.</p>
<p>ICT 인턴십 사이트에서 기업 팁을 통해 <code>1:2</code> 면접 또는 <code>n:m</code> 면접일 수도 있다고 사전에 알아두고 갔다. 20분 정도 일찍 도착하여 회사 건물 1층 카페에서 모닝 커피를 하고 있었는데 누가봐도 젊게 생긴 분이 오셔서.. 아.. 면접이 <code>n:m</code> 면접이겠구나 직감했다.</p>
<p>그렇게 둘이서 어색하게 엘리베이터를 타고 회사가 위치한 층으로 올라갔다.</p>
<p><br></br></p>
<h3 id="면접">면접</h3>
<p>면접은 학년부터 시작해서 좀 천천히 스몰토크로 들어갔다. 사실 면접이라기엔 애매했다.
<del>동아리 면접 같이</del> 기술 질문이 하나도 없었고.. 인성 면접 + 사적인 질문? 뭐 그런 식이었다.</p>
</br>

<h4 id="1-자기소개">1. 자기소개</h4>
<p>자기소개는 내가 준비했던 대로 막히지 않고 술술 잘 이야기했다.
면접관께서도 두 분 다 FM으로 준비하셨네요! 라고 하셨다.</p>
<hr>
<h4 id="2-다들-공부는-유니티로-하신건가요">2. 다들 공부는 유니티로 하신건가요?</h4>
<p>네. 주로 사용하는 언어와 엔진은 C#과 Unity 엔진이며 이를 이용하여 다양한 게임을 제작한 경험이 있고, 제작하고 싶은 게임을 문제 없이 제작할 수 있습니다.</p>
<p>또한, 저는 C++과 Cocos2d-X 프레임워크를 이용하여 모바일 게임을 제작해본 경험이 있습니다.</p>
<p>라고 대답했다. 면접관께서 Cocos2d-X 공부 경험에 대해서 조금은 궁금하신 점이 있으신거 같았다.</p>
<hr>
<h4 id="3-cocos2d-x는-좀-공부하기-어렵지-않았어요">3. Cocos2d-X는 좀 공부하기 어렵지 않았어요?</h4>
<p>맞습니다. 한국어 문서는 전무한 수준이라 입문하기 어려웠지만, 공식 문서와 중국어, 일본어로 쓰여진 문서를 번역해가며 공부를 했습니다.</p>
<p>이들을 봐도 잘 이해가 가지 않는 부분에 대해서는 프레임워크의 헤더 파일들을 직접 하나씩 열어보며 하나하나 세세히 알아보려고 노력했습니다.</p>
<p>라고 대답했다. 그냥 끄덕끄덕 하셨다.</p>
<hr>
<h4 id="4-자신의-성격의-장단점에-대해서-말해주세요">4. 자신의 성격의 장단점에 대해서 말해주세요.</h4>
<p>제 장점은 집요함과 끈기라고 생각합니다. 아까 말씀드렸듯이 Cocos2d-X를 공부해야겠다는 마음 하나로 중국, 일본 문서를 번역하고 헤더 파일들을 직접 열어보며 공부한 경험이 있으며 이를 통하여 게임을 제작했던 경험이 있습니다.</p>
<p>제 단점은 의욕이 과분하게 넘친다는 점입니다. 의욕이 넘쳐서 이것 저것 일을 좀 많이 벌려놓는 스타일인데 이게 매우 안좋은 단점이라고 생각해서 근래에는 우선 순위에 따라 할 일을 순서대로 정리해두고 있습니다.</p>
<p>라고 대답했다.</p>
<hr>
<h4 id="5-다들-유니티는-잘-해요">5. 다들 유니티는 잘 해요?</h4>
<p>와. 대답하기 어려웠다. 내가 잘하나? 못하나?
잘 다룬다고 해도 현업자 입장에선 그냥 귀요미일 뿐일테니..</p>
<hr>
<h4 id="6-게임-회사가-더-낫지-않겠어요">6. 게임 회사가 더 낫지 않겠어요?</h4>
<p>이 질문에서 &#39;아 떨어지겠구나.&#39; 생각이 들었다.
초등학생용 디지털 교육 컨텐츠 제작 회사다보니 들어온 질문같다.</p>
<p>저는 굳이 게임 회사를 선호하지도 않으며 회사의 크기도 신경쓰지 않습니다.
제가 게임 개발을 지망하게 된 계기는 제가 어렸을 때 게임을 좋아했으며 게임으로부터 얻은 즐거움과 추억을 다른 사람에게도 전해주고 싶어서인데 이 방향성만 일치한다면 어떤 회사든 지원할 것 같습니다.
그런데 제 방향성과 수학과 교육과 게임을 결합한 디지털 컨텐츠를 제작하는 귀사다보니 매우 잘 맞을 것 같고 저와 기업 모두 좋은 방향으로 성장해갈 수 있을 것 같습니다.</p>
<p>라고 대답했다. 어떤 회사든 지원할 것 같다고 한게 문제였나. 모르겠다.
면접관께서 그래도 회사의 규모는 신경써야죠.. 돈 벌려고 하는건데.. 라고 하셨고..
나는 우스갯소리로 &#39;돈 벌려고 게임 업계에 들어오지 않았다.&#39;라고 대답했다.</p>
<hr>
<h4 id="7-회사에-궁금한-점이-있나요">7. 회사에 궁금한 점이 있나요?</h4>
<ol>
<li>제가 입사하면 어떤 프로젝트에 합류하게 될까요?</li>
<li>회사의 분위기가 조용하던데 커뮤니케이션은 어떤 방식으로 진행되나요?</li>
<li>회사의 분위기는 수직적인가요? 수평적인가요?</li>
<li>구내식당이 있나요?</li>
</ol>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>아쉬울 것도 없었고 바라는 것도 없었다.
면접 방식이 썩 맘에 들지 않아서 합격해도 그만 불합격해도 그만이라고 생각했다.</p>
<p>그래서 그런지 불합격했다. 어쩔티비. 더 노력해서 더 좋은 곳으로 가길 바라며 글을 마친다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[면접] 2025-7-23 ICT 인턴십 금천구 C사]]></title>
            <link>https://velog.io/@jxng-min/%EB%A9%B4%EC%A0%91-2025-7-23-ICT-%EC%9D%B8%ED%84%B4%EC%8B%AD-%EA%B8%88%EC%B2%9C%EA%B5%AC-C%EC%82%AC</link>
            <guid>https://velog.io/@jxng-min/%EB%A9%B4%EC%A0%91-2025-7-23-ICT-%EC%9D%B8%ED%84%B4%EC%8B%AD-%EA%B8%88%EC%B2%9C%EA%B5%AC-C%EC%82%AC</guid>
            <pubDate>Wed, 23 Jul 2025 10:59:34 GMT</pubDate>
            <description><![CDATA[<h3 id="서론">서론</h3>
<p>2025 ICT 인턴십을 통해서 운좋게 금천구 C사에 서류 합격을 했다.
부푼 기대와 걱정되는 마음을 가지고 금천구 C사에 면접을 보러 갔다.</p>
<p>회사에 들어갔는데.. 다들 이어폰을 낀 채로 너무 조용해서 회사에서 키보드와 마우스 소리밖에 들리지 않아서 너무 긴장했다. 잘 말할 수 있을까 했는데.. 망했다ㅠㅠ.</p>
<p><br></br></p>
<h3 id="면접">면접</h3>
<p>나는 면접 시간보다 10분 먼저 도착해서 뻘쭘하게 회사 내부에서 서 있었는데 친절하신 선배분께서 면접 장소로 안내를 해주셨다. (물도 주심..ㅠ)</p>
<p>면접은 스몰토크로 회사와 집의 거리와 출퇴근 시간을 이야기하면서 시작되었다.</p>
</br>

<h4 id="1-교내-동아리를-하셨었네요">1. 교내 동아리를 하셨었네요.</h4>
<p>혼자서 공부하는 것이 힘들어서 동아리를 들어가고 싶었는데.. 학교에 동아리가 없다는 것을 깨닫고 컴퓨터학부 사람들끼리 동아리를 만들었다고 했다. 나보다 선배가 있어서 회장을 맡지는 못했고 부회장을 했다고 했다.</p>
<hr>
<h4 id="2-c-stl에-대해서-설명해주세요">2. C++ STL에 대해서 설명해주세요.</h4>
<p>STL은 Standard Template Library의 약자로 대표적으로 자주 사용되는 자료구조, 알고리즘, 함수들을 모아둔 C++ 라이브러리다. STL은 컨테이너, 반복자, 함수자, 알고리즘으로 구성되며 STL의 컨테이너 덕분에 자료구조를 일일히 구현할 필요가 없어서 편리하다.</p>
<p>라고 말하고 싶었지만... 처음 보는 면접에 뇌가 새하얘져서 이상한 대답만 하고 끝났다.</p>
<hr>
<h4 id="3-게임-시스템과-데이터-관리에-so와-json을-사용한-이유를-설명해주세요">3. 게임 시스템과 데이터 관리에 SO와 Json을 사용한 이유를 설명해주세요.</h4>
<p>게임 데이터 중에서는 여러 씬을 넘나들면서 참조할 필요가 있는 데이터가 있다. 나는 이러한 데이터를 여러 씬에서 참조할 목적으로 싱글턴을 사용했다. 하지만 전역 데이터 접근을 위한 싱글턴을 사용하다 보니 결합도가 높아지는 문제가 생겼다.</p>
<p><code>static</code>을 생각했지만, 이러면 전역 데이터 접근이라는 문제가 사라지지 않았다. 그래서 씬을 넘나들 때 데이터를 어떻게 참조할 수 있을까 생각하다 게임 시스템이 유지되는 동안에는 데이터가 사라지지 않는 SO를 선택했다.</p>
<p>또, 데이터의 세이브 로드를 위해서 사용할 수 있는 방법에는 여러가지가 있지만, 내가 만든 게임은 싱글플레이이기 때문에 가장 쉬운 방법이 로컬에 저장하는 방법이었다.</p>
<p>로컬에 저장하는 방법은 크게 <code>Playerprefs</code>를 이용하거나 Json을 이용하는 방식이 있는데 <code>Playerprefs</code>는 저장할 수 있는 자료형의 한계가 분명해서 Json을 선택했다.</p>
<p>라고 말하고 싶었지만.. 이것도 제대로 대답을 못하고 우물쭈물하다가 끝났다 ㅠ..</p>
<hr>
<h4 id="4-a-알고리즘을-c으로-구현한-경험이-있네요-왜-구현하신거에요">4. A* 알고리즘을 C#으로 구현한 경험이 있네요. 왜 구현하신거에요?</h4>
<p>2D 탑다운뷰 게임을 개발하던 도중에 플레이어를 추적하는 몬스터를 구현할 필요가 있었다. 간단하게 <code>Update()</code>를 이용하여 플레이어를 추적하게 할 수도 있었지만, 이렇게 구현할 경우 몬스터가 지형지물에 가로막히는 등 지능적인 부분이 떨어진다는 느낌을 받았다.</p>
<p>그래서 지형지물을 피해다니면서 플레이어를 추적할 수 있는 방법을 생각하다가 출발지와 목적지를 알고 있을 때의 최단 경로를 구해주는 A* 알고리즘을 사용하게 되었다.</p>
<p>라고 말하고 싶었다... 바보 짓을 했다.</p>
<hr>
<h4 id="5-a-알고리즘을-칠판에-그리면서-설명해주실-수-있나요">5. A* 알고리즘을 칠판에 그리면서 설명해주실 수 있나요?</h4>
<p>진심으로 당황했다. 머리 속이 새하얘졌다.</p>
<p>A* 알고리즘은 휴리스틱 코스트를 이용하여 다익스트라 알고리즘의 단점을 보완한 알고리즘이다.
휴리스틱 코스트 = 출발지로부터의 거리값 + 휴리스틱 측정값으로 계산을 한다.</p>
<p>이 때 출발지로부터의 거리값은 유클리드 거리로 $\sqrt{(X_2 - X1)^2 + (Y_2 - Y_1)^2}$ 공식을 사용하여 구할 수 있으며 이때 값은 부동소수점으로 나오게 된다.</p>
<p>부동소수점 연산이 정수 연산보다 비용이 크기 때문에 10을 곱하여 상하좌우 이동에는 10, 대각선 이동에는 14의 값을 부여한다.</p>
<p>휴리스틱 측정값은 맨허튼 거리로 $|(X_2 - X_1) + (Y_2 - Y_1)|$ 공식을 사용하여 구할 수 있다.</p>
<p>그리고 이 휴리스틱 코스트가 최소가 되는 노드를 열린 집합에서 닫힌 집합으로 이동시키며 목적지에 도달하게 된다. 그리고 목적지까지 이동한 경로를 역으로 나열하면 이것이 최단경로가 된다.</p>
<p>라고 말하고 싶었다. 아예 대답도 못하고 안될 것 같습니다.. 라고 했다.</p>
<hr>
<h4 id="6-a-알고리즘의-장단점에-대해서-설명해주세요">6. A* 알고리즘의 장단점에 대해서 설명해주세요.</h4>
<p>A* 알고리즘은 다익스트라 알고리즘이 시점에서 모든 정점까지의 최단 경로를 구하는 데에 비해 시점과 정점을 알고 있기 때문에 단 하나의 최단 경로를 구한다는 점에서 장점이 있다.</p>
<p>하지만 노드의 개수가 많아지거나 지형이 넓어질 경우 메모리 사용량이 늘어나며 CPU에 부하가 생긴다.</p>
<p>또한 휴리스틱 함수에 의존하는 알고리즘이라서 잘못된 휴리스틱 함수인 경우에는 잘못된 최단경로가 계산될 수 있다.</p>
<p>이렇게 말하고 싶었다. 장점은 말했지만 단점은 모바일에서는 발열 문제가 생길 수도 있다고 답했다.</p>
<hr>
<h4 id="7-게임-클라이언트-직무를-생각하면서-백엔드도-학교에서-공부했나요">7. 게임 클라이언트 직무를 생각하면서 백엔드도 학교에서 공부했나요?</h4>
<p>아뇨. 학교에서 관련된 수업을 공부한 적은 없습니다. 단지, 제가 혼자서 멀티플레이 게임을 제작하고 싶은 생각에 <code>Photon</code> 엔진과 자체 서버를 고민했었습니다.</p>
<p><code>Photon</code>이던 자체 서버던 공부해야 하는 것은 마찬가지고, 실무에서는 잘 쓰이지 않는 Photon을 공부하는 것보다 자체 서버를 공부하는 과정에서 얻는 네트워크 지식들이 더 유용하다고 생각하여 자체 서버 모듈 구현을 위하여 TCP/IP와 네트워크에 대해서 공부했습니다.</p>
<p>라고 말하지 못했다. <del>진짜 난 뭘까?</del></p>
<hr>
<h4 id="8-궁금한-점-말씀해주세요">8. 궁금한 점 말씀해주세요.</h4>
<ol>
<li>제가 참여할 프로젝트의 팀 규모가 어떻게 되나요?</li>
<li>회사의 분위기가 몹시 조용하던데 혹시 팀 간의 커뮤니케이션은 어떻게 하나요?</li>
<li>회사에서 개인 장비를 사용해도 되나요?</li>
<li>제가 입사하게 된다면 어떤 일을 하게 될까요?</li>
</ol>
<p>이 정도 질문했던 것 같다.</p>
<p><br></br></p>
<h3 id="마무리">마무리</h3>
<p>너무 떨려서 아무 것도 제대로 대답하지 못한 면접이었다. 애써 시간을 내주셔서 면접을 봐주신 선배님들께도 죄송하고.. 준비를 제대로 못한 것이 너무나도 느껴졌다.</p>
<p>나는 쓸데없이 자기소개, 지원 동기, 성격, 강점 등 이상한 부분들에 대해서만 준비를 했었는데, 기술 면접 위주여서 매우 당황했던 것 같다.</p>
<p>내 자신이 평가해도 나를 합격시키지 않을 만큼 처참한 면접이었지만.. 정말로 배운 점이 많았다.
아.. 면접은 이렇게 진행되는구나. 내가 부족한 점이 이것이구나. 를 정확하게 배웠다.</p>
<p>이를 피드백하여 내일도 있는 면접에 잘 대비해야겠다.ㅠㅠ 합격을 위하여.</p>
]]></description>
        </item>
    </channel>
</rss>