[팀프로젝트] ProjectP

SmartBear·2026년 4월 12일

Project P

Summary

  • 장르: 로그라이크 슈팅
  • Unity Engine: Unity 6.3+
  • Language: C#
  • Platform: Windows
  • 참여 인원: 총 12인
    • 기획: 7인
    • 프로그래밍: 5인
  • 기간: 2026.03.27 ~ 2026.04.08
  • 담당
    • Enemy 3종 및 Boss 구현
    • Weapon 3종 구현
    • UI 일부
    • Data Communication System
    • 자문위원(?)
  • Key Feature
    • Object Pool
    • State Machine (with xNode)
    • Dependency Injection
    • Data Dispatcher

시연/홍보 영상

아래 링크로 접속 및 확인 부탁 드립니다.
https://youtube.com/shorts/UocXlYL7Y08?si=yI8fvgybkNM3nrSF

주 담당 개발 기술

Object Pool - Enemies, Bullets, Items

배경

  • 적, 총알, 아이템들은 필드내에 자주 생성 및 파괴가 발생 될 우려가 있음.
  • 기존 개인 프로젝트(Project ALD;PPT Slide)에서 확인했던 결과, 잦은 Intantiate/Destory 는 GC Spark 가 발생되어 Frame Drop 이 발생함.

결과

  • 최종 생성 개수가 적어 시스템에는 큰 영향도가 있지 않았음 (Over Engineering)

고찰

  • Object Pool 이 필요한 항목마다 별도의 Manager 용 객체 생성.
  • 유지보수 측면에서 구분된 객체를 관리하는 것에 대해서는 찾기 쉽고 관리도 편했음.
  • Object Pool Manger 객체가 많아질 수록 관리가 어려워 질 수 있고, 코드도 분리되어 있어 유지보수가 점점 힘들어 질 수 있음.
    • 모든 것들을 관리할 수 있는 Object Pool Manager 를 하나를 구현하여 관리 대상 객체가 갖어야할 초기화 혹은 선 설정할 것들에 대해 Interface로 접근하게 하여 Framework 화 시키는 것이 좋을 것 같음.
    • 이것들에 대해 이미 Unity 라이브러리에서 제공 하고 있음. 추후 해당 라이브러리 활용 시도.

State Machine - Enemies

배경

  • 적 및 보스의 경우 행동 양식이 존재.
  • 행동 양식에 대해 State 로 정의하고 이를 관리할 필요가 있음.

상태 설명

  • 몬스터 3종 모두 같은 기술을 통해 구현
  • 상태 머신에 의한 관리 상태
    • Idle; 몬스터가 가만히 있거나 주변을 정찰하는 상태
    • Chasing; 플레이어를 추적하는 상태
    • AttackDelay; 플레이어에게 공격 전 준비 상태
    • Attack; 플레이어에게 공격을 하는 상태
    • Dead; 사망 상태
  • 상태 머신에 속하지 않은 상태 (State-Independent Event)
    • Damaged; 플레이어에게 공격을 받는 상태
    • State-Independent Event 필요 이유
      • 다른 State 를 거치지 않고 즉각 반응이 필요하기 때문.

구현

  • xNode 활용
  • Pure C#; 각 state 에 대한 정의는 Pure C# Class 로 구현
  • Monobehaviour; Object 로써 동작해야 하는 것들에 대한 구현
    • State Machine
    • 몬스터의 실제 동작 (공격, 이동 등)

Script Component 구조

  • 최상위 State Machine 을 중심으로 하단 Agent 존재.
  • Agent 는 Node 에 대한 제어를 하지 않으며, 후술될 Blackboard 데이터 생성 관리 및 Script Component 내 함수 실행에만 책임이 있음.
  • Behavior Script Components
    • Movement; 주변 순찰 및 플레이어 추적을 위한 이동 스크립트.
    • Attack Delay; 주로 "Delay용 Coroutine"으로 되어 있으며, 종료 후 Attack 으로 넘어갈 수 있게 도와줌.
    • Attack; 공격 Action 에 대한 정의. 3종 몬스터가 각자 다른 공격을 하고 있어, 관련 Script Component 또한 별도 존재.
    • Damaged; 플레이어에게 피해를 입었을 때의 행동. State Machine 과는 별도 행동
    • Dead; 사망 관련 동작 정의

Class 구조

  • State Machine 내 xNode에서 제공되는 Graph 클래스가 Pure C# 로 이루어진 Node class 를 모두 지님.

    • 추후 Node 추가 용이.

    • Node 간 통신

      // EnemyNodeIdle.cs
      using XNode;
      
      public class NodeEnemyIdle : EnemyBaseNode
      {
          [Input] public EnemyStateConnection entry;
          [Output] public EnemyStateConnection exitToFollowingPlayer;
          [Output] public EnemyStateConnection exitToDead;
      
          public override string Execute(EnemyBlackboard blackboard)
          {
              blackboard.IsIdle = false;            
              string transitionName;
              transitionName = ToFollowingToPlayer(blackboard);
              if (transitionName != null) return transitionName; // "exitToFollowingPlayer"            
              return null;
          }
      }
      • Input / output 으로 정의된 이름을 활용. (주로 port라는 용어 활용)
      • 해당 함수 Return String 값이 각 port의 이름과 같으면 상태가 전이됨.
        // EnemyStatMachine.cs 의 일부.
        string portName = (_currentNode as EnemyBaseNode)?.Execute(_blackboard);
        if (portName != null) _currentNode = _currentNode.GetOutputPort(portName).Connection.node;   
  • Agent; Blackboard

    • Agent 에서 생성/파괴 됨.
      • INeedBlackboard Interface 를 통해 필요한 Class 에 Blackboard 객체 전달. (Dependency Injection 후술)
    • 생성시 연결된 Scriptable Object 를 Origin 으로 갖고 있음.
    • RunTime 용 데이터(HP 등)는 별도로 복사해서 관리됨.
    • Blackboard 내에 Action으로 연결된 Monobehaviour 클래스가 실제 Enemy 의 Action 을 담당하게됨
      • 추후 관련 Action 연결 용이.

        // EnemyBlackboard.cs 의 일부.
        private bool _isIdle;
        public bool IsIdle
        {
            get { return _isIdle; }
            set
            {
                _isIdle = value;
                if (value) OnIdle?.Invoke();
            }
        }
        public Action OnIdle;
        
        // EnemyAgent.cs 의 일부.
        _blackboard.OnIdle += OnPatrol;
  • Attack; 공격 Action 용 클래스

    • Enemy Type 별로 Action Script 별도 실행.
    • Ranger 의 경우, 투사체를 발사해야하기 때문에 Spawner 에 데이터 전달 (Data Dispatcher 후술)

문제점

  • Over Engineering
    • 많이 복잡하지 않은 구조를 가져도 되는 설계인데, 지나치게 복잡해 결국 디버깅에 어려움이 컸음.
    • 유연한 구조는 맞지만, 고려해야 하는 사항이 많음. 특히 Node 추가시 xNode Graph 설정 필수.
    • 결국, 유지보수가 좋은 편은 아니라는 판단됨.
  • 상태 변화 감지 파편화
    • 상태 변화에 대한 감지가 Node 뿐 아니라 Action 쪽에도 있음.
    • Node 변화에 대한 Trouble Shooting 비용이 높아짐.
    • 이 또한 유지보수에 좋지 않음.

대응책

  • Blackboard 에 연결된 Action 을 모두 Node 로 변경
    • Node 자체는 상태 정의 및 해당 상태에서 동작할 Script 저장 및 실행 담당.
    • Blackboard 의 책임이 줄어듬 -> 유지보수 용이
    • State 변이 감지를 Node 에서 총괄하게 됨 -> Trouble Shooting 포인트 감소

Dependency Injection - Data Communication (Inner)

BlackBoard 주입을 통한 데이터 공유.
주로 객체 내 데이터 공유 역할 담당
Enemy 및 New Weapon 관련 Code 에서 활용 중.

배경

  • 데이터를 관리하는 Class 를 별도로 나눠, 데이터 관리 클래스의 단일 책임을 부여 설계.
  • 객체 내 Script 간 Coupled 를 최소화 하고 해당 Pure C# Class 를 이용하여 데이터 공유 설계.
  • 관련 기술로 DI 에 대해 확인하여 해당 기술을 접목하게 되었음.

Blackboard?

  • 정의: 해당 객체내에서 다뤄야 할 데이터 중, Script 간 데이터 교류가 필요한 데이터에 대한 Container.
  • Pure C# Script
    // EnemyBlackboard.cs 의 일부.
    public class EnemyBlackboard
    {
      public readonly EnemyData origin;  // Scriptable Object 의 Data Class
      public int currentHp;
      
      public EnemyBlackboard(EnemyData origin, EnemyAgent agent)
      {
          this.origin = origin;
          this.agent = agent;
      }
    }

데이터 전달

  • 데이터는 해당 객체의 중심 역할을 하는 Script 에서 Blackboard 에 대한 생성 및 파괴를 담당.

  • 그 외 Script 에서는 해당 Blackboard 를 전달 받아 활용

    • 데이터 열람 및 데이터 수정 진행.
    // EnemyStateMachine.cs 의 일부.
    if (_blackboard == null)
      _blackboard = new EnemyBlackboard(_originData, _agent);
    
    _blackboard.Init();
    _agent.SetBlackBoard(_blackboard);
    
    // EnemyAgent.cs 의 일부
    _damagedScript.SetBlackboard(blackboard);
    
    // EnemyDamaged.cs 의 일부.
    public class EnemyDamaged : MonoBehaviour, IDamageable, INeedEnemyBlackboard
    {
        private EnemyBlackboard blackboard;
    
        public void SetBlackboard(EnemyBlackboard blackboard)
        {
            this.blackboard = blackboard;
        }
    
        public void TakeDamage(DamageType type, int damage)
        { 
            blackboard.currentHp -= damage;
        }
    }

문제점

  • 데이터 생성 위치
    • 현재는 State Machine 에서 생성 -> 단일 책임 위배.
  • 일관적이지 않은 데이터 전달
  • 데이터 내 Field 및 함수 부재 관련 예외 처리

대응책

  • Blackboard 의 생성 파괴 관리 등을 담당하는 표준 Script 개발 필요.
  • Interface 를 통한 일관적인 데이터 전달 표준 성립
  • Field 및 함수 Call 에 대한 예외처리 확립 혹은 Data 설계 단계에서 관련 Convention Rule 확립 필요.

Data Dispatcher - Data Communication (Outer)

객체와 객체간 데이터 통신이 필요한 경우 사용.
플레이어 위치 정보 갱신, Object Pool 과의 통신, 플레이어 및 무기 데이터 업그레이드 등 많은 곳에 사용됨.

배경

  • 기존 싱글톤을 이용하면 데이터를 주고 받을 수 있지만, 너무 많은 싱글톤에 의한 스파게티 코드가 발생할 수 있음.
  • 또한, 지나친 Coupling 으로 인해 유지 보수에서의 어려움이 발생할 여지가 큼.

Idea

  • Web 개발시 많이 활용한 Message Queue 가 포함된 MSA(Micro-service Architecture) 에서 영감을 얻음.
  • Web 에서 각 서비스가 객체로 치환된 형태.

기본 설계

  • 기본적으로 오고가는 데이터 타입을 정하기 어렵게 때문에 Generic 타입으로 선언됨.
  • C#은 Parameter 의 유연성이 비교적 적기 때문에 많은 데이터를 전송하기 위해서는 struct로 메시지를 정의 후 전달하는 것을 권장.
    • 여담으로, 위 컨셉때문에 PostManager라는 클래스 명이 탄생함.
  • 관리되는 Channel 이 아닌 것들은 통신 불가 -> 엄격한 관리
// PostManager.cs 의 일부
public class PostManager : Singleton<PostManager>
{
  // Post Channel
  public void Post<T>(PostMessageKey key, T data) { ... }
  public void Subscribe<T>(PostMessageKey key, Action<T> callback) { ... }  
  public void Unsubscribe<T>(PostMessageKey key, Action<T> callback) { ... }  
  
  // Request Channel
  public TRes Request<TReq, TRes>(PostMessageKey key, TReq data) { ... }
  public void Subscribe<TReq, TRes>(PostMessageKey key, Func<TReq, TRes> callback) { ... }
  public void Unsubscribe<TReq, TRes>(PostMessageKey key, Func<TReq, TRes> callback) { ... }
}
  • 싱글톤인 이유?
    • 다른 팀원들의 코딩 숙련도에 따라, 해당 Class 에 대한 접근성을 높이기 위함.
    • 추후에는 관리 문제가 발생할 수는 있으나 현 프로젝트 규모상은 적당하게 사용할 수 있다고 판단.

Generic 관련 설계 이슈

  • 모든 타입에 대해 받아주기 위해 처음에는 Object Type 으로 선언하려 하였음.
    • 일반적으로는 정상적인 upcasting 가능.
    • struct 의 경우 Boxing 문제 발생 -> 지나치게 많아지므로 object Type 폐기.
  • Dictionary 선언 시 Generic 사용
    • Dictionanry 자체가 Generic을 통한 설계가 되어 있어 선언시 Generic으로 선언하는 것이 불가능
      Dictionary<string, Action<int>> dict01 = new();
       Dictionary<string, Action<T>> dict02 = new();
       // ---------- 실행 결과
       /*
       Build with surface heuristics started at 21:09:41
       Use build tool: C:\Program Files\dotnet\sdk\10.0.104\MSBuild.dll
       CONSOLE: msbuild 버전 18.0.11+80d3e14f5(.NET용)
       CONSOLE: 빌드 시작: 2026-03-26 오후 9:09:41
       CONSOLE: 1 노드의 "C:\Users\mybeang\AppData\Local\Temp\Jatowup.proj" 프로젝트(기본 대상)입니다.
       CONSOLE: ControllerTarget:
       CONSOLE:   Run controller from C:\Program Files\JetBrains\Rider\r2r\2025.3.0R\BCF63A397BE5DD45D53EE49FB08CD49\JetBrains.Platform.MsBuildTask.v17.dll
       0>------- Started building project: Sandbox
       -- skip --
       0>Program.cs(37,35): Error CS0246 : 'T' 형식 또는 네임스페이스 이름을 찾을 수 없습니다. using 지시문 또는 어셈블리 참조가 있는지 확인하세요.
       0>------- Finished building project: Sandbox. Succeeded: False. Errors: 1. Warnings: 0
       Build completed in 00:00:02.636
       */
    • Interface 를 활용한 선언으로 변경
      public interface IPostMessages { }
       class PostMessages<T> : IPostMessages
       {
         public Action<T> actions;
       }
       // 선언시 -------------
       Dictionary<string, Action<int>> dict01 = new();
       // 문제 없음.
       Dictionary<string, Action<IPostMessages>> dict02 = new();

Action

  • C# Action 을 기반으로 1:N 의 통신이 가능하도록 구성.

  • Action 기반이기 때문에 반환 값 없음 (void type)

  • 단방향 통신.

    • Post -> Subscribe 한 함수로 전달.
  • 예시)

    // PlayerController.cs 의 일부
    PostManager.Instance.Post(PostMessageKey.PlayerPosition, transform.position);
    
    // BossMovement.cs 의 일부
    PostManager.Instance.Subscribe<Vector2>(PostMessageKey.PlayerPosition, UpdateMoveDirection);

Func

  • C# Func 을 기반으로 1:1 의 통신이 가능하도록 구성.

  • Func 기반이기 때문에 반환 값 요구

  • 단방향 통신.

    • Request -> Subscribe 한 함수로 전달 -> 값 반환
  • 예시)

    // StatusUIController.cs 의 일부
    float moveSpeed = PostManager.Instance.Request<bool, float>(PostMessageKey.PlayerStatusUIPlayer, true);
    
    // PlayerState.cs
    PostManager.Instance.Subscribe<bool, float>(PostMessageKey.PlayerStatusUIPlayer, PostMoveSpeed);

Channels

  • Enum 으로 관리
  • 한 채널당 Post 혹은 Request 둘 중 하나로만 사용 가능.
public enum PostMessageKey
{
    PlayerPosition,  // Player 의 위치정보를 받기 위한 Position 데이터
    EnemySpawned,  // Enemy 사망 후 Despawn
    // ... 왜 30 개 이상 채널에서 사용 됨.
}

문제점

  • 가장 큰 문제점은 Callback 함수 오류시 Listen 중인 모든 함수에서 확인해봐야함.
    • Error Log 가 Post/Request 함수에서만 발생하여 원인을 찾기가 어려움.
  • 일부 Channel의 경우 굳이 데이터를 넘길 필요 없이, 단순 Event Trigger 로써의 역할 수행을 위해 dummy data를 보낼 필요가 있음.

대응책

  • 현재는 Convention Rule 로써 Listen 을 하는 함수에 Try-Catch 필수 삽입.
  • Event Trigger 용 Channel 추가.
    • PostTrigger, RequestTrigger

추후 발전 설계 방향

  • 단순히 Action / Func 을 통한 Invoke 가 아니라, 특정 callback 함수를 먼저 실행해주는 "Priority" 개념이 들어가는 것도 고려.
  • Async 관련 Logic 고려 -> Request 의 경우 Sync 로 Response 받는 것이 아닌, 별도의 Response 용 Channel 을 두고, 해당 Channel 에서 결과값을 받기위해 대기하는 형태.
  • Queue 등으로 한번에 많은 데이터량이 들어올 경우 이를 buffering 해서 처리하는 방법 고려.
profile
Python Dev with Infra -> Game Programmer

0개의 댓글