Unity DOTS 핵앤슬래시 액션 RPG

Unity 6 기반. DOTS/MonoBehaviour 하이브리드 아키텍처, 조합형 스킬 파이프라인, Zero-Allocation Behaviour Tree

2025.12 ~ 현재|팀 프로젝트 (프로그래머 3명, 아키텍처 / 핵심 시스템 설계)
UnityC#DOTS/ECSBurstBehaviour TreeAddressables
1 / 9

개요

Unity DOTS 핵앤슬래시 액션 RPG

Unity 6 기반 핵 앤 슬래시 액션 RPG. DOTS와 MonoBehaviour를 혼합하여 AI, 물리, 궤적 연산은 ECS에서, 게임 로직과 UI는 Mono에서 처리하는 하이브리드 아키텍처.

  • 개발 형태: 팀 프로젝트 (프로그래머 3명)
  • 기간: 2025.12 ~ 현재
  • 담당: 전체 아키텍처 설계, 유닛 전체 설계 및 데이터 시스템 확립, Fragment/Frame 시스템 기획 및 설계, Behaviour Tree 설계 및 구현

동기

디아블로 시리즈의 팬으로, 핵 앤 슬래시 본연의 재미를 구현하면서 그 안에서 발생하는 기술적 문제들을 직접 설계하고 해결하는 완성도 높은 게임을 만들고 싶었다. 수백~수천 마리의 몬스터 AI를 매 프레임 평가하면서 GC 스파이크 없이 유지하는 것, 장비 옵션 하나가 스탯과 스킬 양쪽에 동시에 전파되는 것. 핵앤슬래시의 핵심 루프를 진지하게 구현하려 하면 이런 문제들이 반드시 따라온다.

3명이라는 소규모 팀으로 볼륨 있는 게임을 만들려면, 작업 단계부터 데이터 드리븐 방식이 필수였다. 스킬, 장비, AI, 드롭 테이블 등 복잡한 계층 구조를 코드가 아닌 ScriptableObject와 설정 데이터 중심으로 관리하여, 기획 변경이나 밸런싱을 코드 수정 없이 처리할 수 있는 구조를 지향했다.

MonoBehaviour만으로는 구조적 한계가 있었고, DOTS의 데이터 지향 처리가 필요한 영역이었다. 다만 Unity DOTS는 UI, 애니메이션, 물리 상호작용 등 Mono 생태계에 의존하는 영역이 여전히 많다. 연산 집약적인 AI/궤적/물리만 DOTS로, 나머지는 Mono로 유지하되 양측을 안정적으로 동기화하는 하이브리드 구조가 필요했다.

접근

프로젝트를 5개 레이어로 분리했다.

flowchart TD
    subgraph L1["Presentation"]
        P["UI, HUD, Tooltip, Localization"]
    end
    subgraph L2["Game Logic"]
        GL["Units, Fragments, Frame, Map, Objects"]
    end
    subgraph L3["Runtime Systems"]
        RS["SystemManager, Bridge, Pool, Resource, Data"]
    end
    subgraph L4["DOTS Layer"]
        DL["AI Evaluation, Trajectory, Physics, EntityLink"]
    end
    subgraph L5["Data Layer"]
        DC["BaseData, Config, RangedTypes, Serialization"]
    end
    L1 --> L2 --> L3 --> L4 --> L5

전체 아키텍처와 핵심 시스템은 직접 설계했고, 팀원 3명이 도메인별로 구현을 분담했다. 내가 담당한 핵심 설계 판단은 세 가지였다.

첫째, BridgeSystem으로 DOTS-Mono 경계를 격리. ECS Entity와 MonoBehaviour Unit 사이의 데이터 동기화를 3-phase(Read/Process/Write) 패턴으로 통일했다. AI 커맨드 디스패치, Transform 동기화, 투사체 궤적 반영 모두 이 패턴을 따른다.

둘째, Signal 조합으로 스킬을 구성. Origin(발동), Modulation(변형), Final(효과) 세 단계의 Signal을 Frame(장비)의 슬롯에 배치하는 것만으로 스킬이 조합된다. Shoot + Fan + Homing + Explosion 같은 조합이 코드 변경 없이 데이터만으로 가능하다.

셋째, Behaviour Tree를 DOTS 네이티브로 구현. 재귀 호출 대신 FixedList128Bytes 기반 명시적 스택으로 BT를 순회하여, GC 할당 없이 Burst 컴파일이 가능한 구조를 만들었다.

핵심 구현

영역내용
하이브리드 아키텍처BridgeSystem 3-phase 동기화, EntityLink Mono-DOTS 연결, BatchProcessor 캐싱
스킬 파이프라인Signal 3단계 조합(Origin/Modulation/Final), ExecutionContext 딥클론, Signal 풀링
행동 트리Zero-allocation 스택 기반 BT 평가, Burst 컴파일, Blackboard DOTS 동기화
유닛 시스템Unit 추상 클래스 + 모듈 합성(Stat/Animation/Movement/Model), Pool 라이프사이클
장비 시스템Frame/SignalSlot 구조, Affix 이중 전파(Stat + Signal), 세트 보너스, 품질 캐스케이드
데이터 시스템BaseData/Config 추상화, StatModifier/SignalModifier 일반화, enum 기반 정합성

기술 스택

기술적용 영역
C# / Unity 612개 도메인, ~280 스크립트, 런타임 시스템
Unity DOTS (ECS)AI 평가, 궤적 연산, 물리 충돌, Transform 동기화
Burst CompilerBT 평가 Job, 궤적 시스템
UniTask2-phase 비동기 초기화, 씬 전환, 리소스 로딩
Addressables에셋 라벨 기반 일괄 로딩, ref-counting

문서 구성

문서내용
01-전체-아키텍처-설계5-Layer 구조, 도메인 분리, SystemManager 부트스트랩
02-데이터-드리븐-설계데이터 추상화/일반화, StatModifier/SignalModifier, 정합성 보장
03-런타임-인프라ResourceSystem ref-counting, Spawnable 풀링, LoadingSystem 씬 전환
04-DOTS-하이브리드-아키텍처BridgeSystem 3-phase 동기화, EntityLink 양방향 참조
05-시그널-프래그먼트-파이프라인Signal 조합 구조, ExecutionContext 복제, Modulation 시점 분리
06-행동-트리-시스템Zero-allocation BT 평가기, Burst 컴파일, AIBridge 커맨드 디스패치
07-유닛-시스템-설계Unit 추상 클래스 + 모듈 합성, StatModule 재계산 파이프라인, Monster/Player 확장
08-장비-프레임-시스템Frame/SignalSlot 구조, Affix 이중 전파, Tier/Quality 롤링 파이프라인, 세트 보너스
2 / 9

전체 아키텍처 설계

문제

핵 앤 슬래시 RPG는 시스템 간 의존성이 깊다. 스킬이 발동되면 장비 옵션이 스킬 파라미터를 수정하고, 투사체가 생성되고, AI가 반응하고, 스탯이 변하고, UI가 갱신된다. 3명이 동시에 작업하려면 도메인 간 경계가 명확해야 하고, 한 사람이 스킬 시스템을 수정할 때 장비나 AI 코드를 건드리지 않아도 되는 구조가 필요했다.

동시에 핵앤슬래시의 특성상 콘텐츠 볼륨이 크다. 스킬 수십 종, 장비 수백 종, 몬스터 패턴, 드롭 테이블, 세트 보너스 같은 데이터들을 코드로 관리하면 밸런싱 한 번에 빌드를 다시 해야 한다. 소규모 팀이 이 볼륨을 감당하려면 코드와 데이터가 분리되어야 했다.

접근: 5-Layer 아키텍처

프로젝트를 5개 레이어로 분리하고, 각 레이어 안에서 도메인별로 독립적인 디렉토리 구조를 두었다.

flowchart TD
    subgraph L1["Presentation"]
        P["UI Panels, Elements, Localization, HUD, Tooltip"]
    end
    subgraph L2["Game Logic"]
        GL["Units, Fragments, Frame, Map, Objects, Index"]
    end
    subgraph L3["Runtime Systems"]
        RS["SystemManager, BridgeSystem, PoolSystem, ResourceSystem, DataSystem"]
    end
    subgraph L4["DOTS Layer"]
        DL["AI Evaluation, Trajectory, Physics, EntityLink, BatchProcessor"]
    end
    subgraph L5["Data & Configuration"]
        DC["BaseData, Config, RangedTypes, AssetRef, Serialization"]
    end
    L1 --> L2 --> L3 --> L4 --> L5

상위 레이어는 하위 레이어에 의존하지만, 하위가 상위를 참조하지 않는다. Game Logic 도메인끼리의 의존은 이벤트와 인터페이스로 느슨하게 연결했다. Fragment가 Unit을 직접 참조하지 않고 ExecutionContext.owner로 접근하고, Frame의 Affix 변경은 OnChanged 이벤트로 전파되는 식이다.

도메인 간 의존 구조

flowchart LR
    Fragments -->|"ExecutionContext.owner"| Units
    Fragments -->|"Spawnable 기반 풀링"| SystemPool["System/Pool"]
    Fragments -->|"SignalModifier"| Frame

    Frame -->|"Affix → StatModifier"| UnitsStat["Units/Stat"]

    Units -->|"Entity 등록, Transform 동기화"| SystemBridge["System/Bridge"]
    Units -->|"Spawnable 상속"| SystemPool
    Units -->|"InputModule → ExecutionContext"| Fragments
    Units -->|"EquipmentModule → Frame 장착"| Frame

    Map -->|"Monster Spawn/Despawn"| SystemPool
    Objects -->|"DropTable → Frame/Signal"| Frame

도메인별 진입점이 명확하다. Fragment는 Origin.cs, Frame은 Frame.cs, Units는 Unit.cs, System은 SystemManager.cs에서 시작한다. 새로 합류한 팀원이 한 도메인의 Overview를 읽고 바로 작업에 들어갈 수 있는 구조를 목표로 했다.

SystemManager: 2-Phase 부트스트랩

모든 런타임 시스템은 SystemManager의 자식 GameObject로 생성되며, IInitializable 계약을 따른다.

csharp
public interface IInitializable
{
    bool IsInitialized { get; }
    UniTask Initialize();
}

초기화를 두 단계로 분리한 이유는 의존성 순서 때문이다. PoolSystem이 에셋을 프리웜하려면 ResourceSystem이 필요하고, PlayerSystem이 데이터를 로드하려면 DataSystem이 필요하다.

flowchart TD
    subgraph P1["Phase 1: Data Layer"]
        A["Addressables.InitializeAsync()"]
        RS["ResourceSystem\n(동기, plain C# 클래스)"]
        DS["DataSystem\n(비동기, Config/BaseData SO 일괄 로드)"]
        A --> RS --> DS
    end
    subgraph P2["Phase 2: Runtime Layer"]
        PS["PoolSystem"] --> BS["BridgeSystem\n(동기, ECS World 참조)"]
        BS --> US["UISystem"] --> CS["CameraSystem"] --> PLS["PlayerSystem"] --> IS["InputHandlerSystem"]
    end
    P1 --> P2

Phase 1이 완료되어야 Phase 2가 시작된다. Phase 2 내부에서도 순서가 고정되어 있어, 이후 시스템이 이전 시스템의 초기화 완료를 가정할 수 있다.

Systems 정적 클래스: 전역 접근

Systems partial 정적 클래스가 모든 시스템에 대한 접근점을 제공한다. 각 프로퍼티는 null 체크와 초기화 상태 검증을 포함한다.

csharp
public static partial class Systems
{
    public static BridgeSystem Bridge
    {
        get
        {
            var bridgeSystem = SystemManager.Instance.BridgeSystem;
            if (bridgeSystem == null || !bridgeSystem.IsInitialized)
            {
                Logger.LogError("BridgeSystem is not initialized");
                return null;
            }
            return bridgeSystem;
        }
    }
    // Pool, Data, Assets, UI, Camera, Input 등 동일 패턴
}

씬 전환 직후처럼 시스템 상태가 불확실한 시점에서는 Systems.Check()로 초기화 완료를 대기한 뒤 접근한다. 초기화가 안 된 시스템에 접근하면 null이 반환되면서 에러가 기록되므로, 초기화 순서 위반이 런타임에 즉시 드러난다.

결과

5-Layer 구조와 도메인별 분리로 3명이 Fragment, Frame, Map 등 각자 도메인에서 독립적으로 작업할 수 있었다. 상위→하위 단방향 의존과 이벤트 기반 도메인 간 통신으로, 한 도메인을 수정할 때 다른 도메인의 코드를 건드리지 않는다. SystemManager의 2-phase 부트스트랩으로 시스템 간 초기화 순서가 보장되어, "아직 초기화 안 된 시스템에 접근" 같은 런타임 오류가 구조적으로 방지된다.

3 / 9

데이터 드리븐 설계

문제

핵앤슬래시에서 콘텐츠 볼륨은 코드 볼륨보다 훨씬 빠르게 증가한다. 스킬 하나를 추가할 때마다 클래스를 만들어야 한다면, 콘텐츠 확장이 곧 코드 변경이 되고 빌드 사이클에 묶인다. 3명이 소규모 팀으로 볼륨 있는 게임을 만들려면, "코드는 파이프라인을 정의하고, 데이터가 동작을 결정한다"는 원칙이 필수였다.

각 도메인에서 데이터가 어떻게 동작을 결정하는지 먼저 보면:

스킬 (Signal): Signal SO 자체가 스킬의 동작을 정의한다. Shoot Origin SO에 투사체 수, 속도, 사거리가 데이터로 들어 있고, Fan Modulation SO에 부채꼴 각도와 방향 수가 데이터로 들어 있다. 코드는 Origin/Modulation/Final 파이프라인만 정의하고, 어떤 조합이 어떤 동작을 하는지는 전적으로 SO 데이터가 결정한다. 새 스킬 변형을 추가할 때 코드를 건드리지 않는다.

장비 (FrameData): FrameData SO에 슬롯 구성(SlotPool), 옵션 풀(AffixPool), 최대 옵션 수, 세트 ID가 데이터로 정의된다. Roll()을 호출하면 이 데이터를 기반으로 슬롯과 Affix가 확률적으로 생성된다. 장비 종류를 추가하려면 FrameData SO를 하나 만들고 SlotPool과 AffixPool을 설정하면 된다.

AI (TreeAsset): BT 구조를 TreeAsset SO에서 정의한다. 노드 타입, 조건, 프로퍼티, 자식 구조가 모두 SO 데이터다. 런타임에 BlobAsset으로 빌드되어 ECS에서 소비되지만, 설계 시점의 편집은 SO 에디터에서 완결된다.

몬스터 (MonsterData): 기본 스탯(baseStats: SerializedDictionary<StatType, long>), BT 참조, 드롭 테이블, 애니메이션 데이터가 모두 MonsterData SO에 들어 있다. 새 몬스터를 추가하려면 MonsterData SO를 만들고 스탯/BT/드롭을 설정하면 된다.

드롭 (DropTable): DropTable SO에 테이블 활성화 확률과 엔트리 목록이 정의된다. 각 DropEntry는 SerializeReference로 다형적이어서, SignalDropEntry/FrameDropEntry/CurrencyDropEntry를 에디터에서 자유롭게 조합한다.

이 구조가 동작하려면 데이터 자체의 추상화와 일반화가 뒷받침되어야 한다.

접근: 데이터 계층의 추상화

BaseData / Config 분리

게임 데이터를 런타임 데이터와 시스템 설정 두 계층으로 나눴다.

graph TD
    SO["ScriptableObject"]

    BD["BaseData\n(abstract)\n런타임 소비, Instantiate 복사본"]
    CF["Config\n(abstract)\n읽기 전용, 원본 직접 참조"]

    UD["UnitData\n유닛 스탯, 애니메이션, 레벨"]
    PD["PlayerData\n+ 장비, 인벤토리"]
    MD["MonsterData\n+ BT, 드롭 테이블"]

    SG["Signal\n(abstract)\n스킬 파편"]
    OR["Origin\nShoot, Melee, Beam,\nAura, Summon, Dodge"]
    SM["SpawnModulation\nFan, Curve"]
    RM["RuntimeModulation\nHoming, Bezier,\nSpiral, Control"]
    FN["Final\nDamage, Explosion,\nSplit, Effect"]

    FD["FrameData\n장비 프레임 정의"]
    DT["DropTable\n드롭 확률 테이블"]

    TC["TierConfig\n등급별 스탯 배율, 품질 자격"]
    QC["QualityConfig\n품질별 롤 확률, 수치 범위"]
    SD["SetDatabase\n세트 보너스 정의"]

    SO --- BD
    SO --- CF

    BD --- UD
    BD --- SG
    BD --- FD
    BD --- DT

    UD --- PD
    UD --- MD

    SG --- OR
    SG --- SM
    SG --- RM
    SG --- FN

    CF --- TC
    CF --- QC
    CF --- SD

BaseData는 드롭 시 Instantiate()로 복사된다. 같은 FrameData 템플릿에서 드롭된 장비 두 개가 서로 다른 Affix를 가져야 하므로, 원본은 템플릿이고 런타임 인스턴스는 독립적으로 변형된다. Config는 시스템 동작을 정의하는 읽기 전용 설정이므로 원본을 직접 참조한다.

이 분리의 핵심은 템플릿과 인스턴스의 구분이다. 에디터에서 "기본 검 FrameData"를 하나 만들면, 그것이 드롭될 때마다 복사본이 생성되고 각 복사본에 독립적인 Affix가 롤링된다. Config는 "Tier 3 장비의 스탯 배율은 1.5"처럼 모든 인스턴스에 공통으로 적용되는 규칙이다.

스탯의 일반화: StatType + CalcType + Source

유닛의 모든 스탯을 SerializedDictionary<StatType, long>으로 표현한다. HP, 공격력, 방어력, 이동속도, 원소 저항 등 스탯 종류가 아무리 추가되어도 필드를 추가할 필요 없이 enum 값 하나만 추가하면 된다.

csharp
// UnitData: 모든 스탯이 하나의 딕셔너리
public SerializedDictionary<StatType, long> baseStats;

// StatType: enum으로 스탯 종류 정의
public enum StatType
{
    MAX_HEALTH = 0, CURRENT_HEALTH = 1, DEFENSE = 2,
    MOVE_SPEED = 3, LUCK = 4,
    MAX_SHIELD = 5, CURRENT_SHIELD = 6,
    MEMORY_CAPACITY = 7, MEMORY_REGEN_RATE = 8,
    RESISTANCE_PHYSICAL = 60, RESISTANCE_PLASMA = 61, ...
}

스탯 수정도 일반화된 StatModifier 구조로 통일했다.

csharp
public class StatModifier
{
    public StatType statType;     // 어떤 스탯을
    public CalcType calcType;     // 어떻게 (PLUS / MINUS / PERCENT)
    public long value;            // 얼마나
    public object source;         // 누가 (Frame, SetBonus, Buff ...)
}

장비 Affix, 세트 보너스, 버프, 디버프 모두 동일한 StatModifier를 사용한다. source 필드로 출처를 추적하여, 장비를 해제하면 RemoveFrom(source) 한 줄로 해당 소스의 모든 모디파이어가 정확히 제거된다. 스탯 종류를 추가하거나 새로운 수정 소스가 생겨도, StatModifier 구조 자체는 변하지 않는다.

스킬 옵션의 일반화: SignalID + OptionStat + ModifyType

장비가 스킬 동작을 수정하는 것도 동일한 패턴으로 일반화했다. SignalModifier가 "어떤 Signal의 어떤 파라미터를 어떻게 수정할지"를 데이터로 표현한다.

csharp
public class SignalModifier
{
    public SignalID targetSignalID;    // 어떤 Signal을 (Shoot, Beam, Homing ...)
    public OptionStat stat;            // 어떤 파라미터를 (Cooldown, ProjectileSpeed ...)
    public ModifyType modifyType;      // 어떻게 (Flat / Percent)
    public ValueType valueType;        // 값 타입 (Float / Int / Long)
    public RangedFloat floatValue;     // 범위 값 (Roll() 전: min~max, 후: 확정값)
}

SignalID(23종)와 OptionStat(27종)의 조합이 모든 스킬 옵션 공간을 커버한다. 새 스킬 파라미터가 추가되면 OptionStat enum에 값을 추가하고, 해당 Signal 서브클래스의 ApplyOption()에 case를 추가하면 된다. Affix SO 에디터에서 "이 Affix는 Shoot의 Cooldown을 Flat으로 -0.5~-0.3초 수정한다"를 설정하는 것만으로 장비 옵션이 완성된다.

StatModifier와 SignalModifier가 하나의 Affix에 공존한다는 점이 중요하다.

csharp
public class Affix
{
    public List<StatModifier> unitStats;        // HP +50, 방어력 +20
    public List<SignalModifier> signalOptions;  // 쿨다운 -10%, 투사체 속도 +15
}

Affix 하나가 유닛 스탯과 스킬 옵션을 동시에 수정하는 구조다. 이 이중 채널이 하나의 데이터 구조에 통합되어 있으므로, 장비 장착/해제 시 양쪽을 항상 함께 바인딩/언바인딩할 수 있다.

Signal 데이터의 추상화

스킬 파편(Signal)은 Signal 추상 클래스를 상속하는 ScriptableObject다. 모든 Signal이 공유하는 인터페이스:

csharp
public abstract class Signal : ScriptableObject, IDroppable
{
    public SignalID SignalId;
    public SignalType Type;              // Origin / Modulation / Final
    public ItemData ItemData;            // 인벤토리 아이템으로서의 공통 데이터

    public abstract void Register(ExecutionContext ctx);  // 파이프라인에 자기 등록
    public virtual void ApplyOption(SignalModifier mod);  // 장비 옵션 적용
    public virtual void ResetBonuses();                   // 옵션 초기화
    public void Roll();                                   // IDroppable: 드롭 시 랜덤화
}

모든 Signal이 Register(), ApplyOption(), Roll()을 공유한다. 이 인터페이스 덕분에:

  • Frame.Build(): 슬롯에 꽂힌 Signal이 무엇이든 Register(ctx)를 호출하면 파이프라인이 구성된다
  • EquipmentModule.Bind(): Signal이 무엇이든 ApplyOption(mod)를 호출하면 장비 옵션이 적용된다
  • DropTable.Roll(): Signal이 무엇이든 Roll()을 호출하면 RangedType 필드가 랜덤화된다

코드는 Signal의 구체 타입을 알 필요 없이 추상 인터페이스만으로 동작한다. 새로운 Origin이나 Modulation을 추가해도 기존 파이프라인 코드는 변경되지 않는다.

인벤토리 아이템의 일반화: IInventoryItem

Frame(장비), Signal(스킬 파편), Currency(재화)는 서로 다른 타입이지만, 인벤토리에서는 동일하게 취급되어야 한다. IInventoryItem 인터페이스가 이 공통 계약을 정의한다.

csharp
public interface IInventoryItem
{
    ItemData ItemData { get; }              // 이름, 아이콘, 크기, 카테고리
    TooltipData GetTooltipData();           // UI 툴팁 생성
}

ItemData가 모든 아이템의 공통 메타데이터를 담는다.

csharp
public class ItemData
{
    public Guid id;                         // 인스턴스 고유 식별자
    public ItemCategory itemCategory;       // FRAME / SIGNAL / CURRENCY
    public LocaleKey nameKey;               // 다국어 이름
    public AssetRef<Sprite> icon;           // 아이콘
    public Vector2Int size;                 // 인벤토리 그리드 크기
    public int maxStackSize;                // 최대 중첩 수
    public Tier tier;                       // 등급
}

InventoryGridStorage는 IInventoryItem만 다루므로, Frame이든 Signal이든 Currency든 동일한 배치/이동/스왑 로직으로 처리된다. DropTable도 List<IInventoryItem>을 반환하므로, 드롭 결과가 어떤 타입이든 인벤토리에 바로 들어간다. 새로운 아이템 타입을 추가하려면 IInventoryItem을 구현하고 해당 DropEntry 서브클래스를 만들면 된다.

RangedTypes: 확률적 데이터의 공통 표현

핵앤슬래시에서 대부분의 수치는 고정값이 아니라 범위다. 이 패턴을 RangedFloat, RangedInt, RangedLong 구조체로 일반화했다.

csharp
[Serializable]
public struct RangedFloat
{
    public float min, max, value;

    public bool IsFixed => min == max;
    public float NormalizedValue => (max - min) > 0 ? (value - min) / (max - min) : 1f;
    public float Roll() => value = Random.Range(min, max);
}
위치용도
SignalModifier.floatValue장비 옵션 수치 범위 (쿨다운 -0.5~-0.3초)
DropEntry.quantity드롭 수량 범위 (1~3개)
Signal 파라미터스킬 수치 범위 (데미지 50~80)
AffixDef.CreateInstance()Affix 인스턴스화 시 범위 내 롤링

SO 에디터에서 min/max를 설정하고, 드롭 시점에 Roll()로 확정값을 결정한다. NormalizedValue는 UI 툴팁에서 롤 품질을 색상으로 표현하는 데 사용된다. PropertyDrawer 하나로 모든 곳에서 동일한 min/max 슬라이더를 제공한다.

IDroppable: 드롭 시점의 데이터 확정

모든 드롭 가능한 아이템이 구현하는 인터페이스다.

csharp
public interface IDroppable
{
    void Roll();  // RangedType 필드를 랜덤화
}

Signal과 FrameData 모두 IDroppable을 구현한다. DropTable이 아이템을 생성할 때 Instantiate(template) → Roll() → id = Guid.NewGuid() 순서로 처리하면, 템플릿의 SO 데이터 구조를 기반으로 독립적인 인스턴스가 만들어진다. 템플릿은 "이 아이템이 가질 수 있는 범위"를 정의하고, Roll()이 "이 인스턴스의 확정된 값"을 결정한다.

정합성 보장

데이터가 동작을 결정하는 구조에서는 잘못된 데이터가 런타임 오류로 직결된다. 여러 계층에서 정합성을 검증한다.

타입 매칭: SignalSlot.CanAccept(signal)이 슬롯 타입과 Signal 타입의 일치를 강제한다. Origin 슬롯에 Modulation을 넣을 수 없다. SignalModifier.AppliesTo(SignalId)가 옵션이 대상 Signal에만 적용되도록 보장한다.

중복 방지: StatModifier의 (source, statType, calcType) 동일 조합은 value를 merge한다. Frame당 Split Final은 하나만 허용된다. DataSystem은 동일 CONFIG_TYPE/DataType의 중복 등록 시 경고 로그를 남긴다.

enum 기반 키잉: 스탯(StatType), 설정(CONFIG_TYPE), 데이터(DataType), Signal(SignalID), 옵션(OptionStat) 등 모든 데이터 식별이 enum으로 이루어진다. 문자열 키가 아니라 컴파일 타임에 검증되는 enum이므로, 오타나 존재하지 않는 키 참조가 빌드 시점에 잡힌다.

Source 추적: 모든 StatModifier에 source가 기록되어, 특정 장비나 버프가 제거될 때 정확히 해당 소스의 모디파이어만 걷어낸다. Source 없는 모디파이어는 존재할 수 없다.

결과

콘텐츠 추가가 SO 생성 + 데이터 설정으로 완결되는 구조를 확립했다. 새 스킬은 Signal SO 조합, 새 장비는 FrameData SO + AffixPool 설정, 새 몬스터는 MonsterData SO + BT + DropTable 연결로 추가된다. 데이터 추상화(BaseData/Config, StatModifier, SignalModifier, RangedTypes, IInventoryItem, IDroppable)가 도메인마다 반복되는 패턴을 일반화하여, 코드는 파이프라인 로직에만 집중하고 콘텐츠 확장은 데이터 레이어에서 처리된다.

4 / 9

런타임 인프라

문제

핵앤슬래시 RPG에서 런타임 시스템은 게임 로직의 기반이다. 에셋 로딩, 오브젝트 풀링, 씬 전환은 모든 도메인이 공통으로 의존하는 인프라이며, 이 계층이 불안정하면 상위의 스킬/장비/AI 시스템이 모두 영향을 받는다. 특히 Addressables 기반 비동기 에셋 로딩에서 참조 카운트 관리가 부정확하면 메모리 누수가 발생하고, 풀링 없이 투사체와 이펙트를 매번 생성/파괴하면 GC 스파이크가 빈번해진다.

ResourceSystem: Ref-Counting 에셋 관리

Addressables 에셋의 로드/해제를 ref-counting으로 관리하는 plain C# 클래스(IDisposable)다. MonoBehaviour가 아닌 이유는 초기화 시점에 비동기 대기 없이 즉시 사용 가능해야 하기 때문이다.

csharp
public async UniTask<T> Load<T>(string key)
{
    // 이미 로드된 에셋이면 카운트만 증가
    if (handles.TryGetValue(key, out var handle))
    {
        IncRef(key);
        return handle.Result as T;
    }

    // 신규 로드
    var typedHandle = Addressables.LoadAssetAsync<T>(key);
    T asset = await typedHandle.Task;

    handles[key] = typedHandle;
    keys[asset] = key;      // 에셋 → 키 역방향 매핑
    refs[key] = 1;
    return asset;
}
csharp
public void Release(string key)
{
    if (!DecRef(key)) return;  // 아직 참조 중이면 해제 안 함

    keys.Remove(handles[key].Result);
    Addressables.Release(handles[key]);
    handles.Remove(key);
}

네 개의 딕셔너리가 에셋 상태를 추적한다.

필드타입역할
handlesDictionary<string, AsyncOperationHandle>키 → 로드 핸들
keysDictionary<object, string>에셋 → 키 (역방향)
refsDictionary<string, int>키 → 참조 카운트
instHandlesDictionary<int, AsyncOperationHandle<GameObject>>instanceID → 인스턴스 핸들

에셋 → 키 역방향 매핑으로 에셋 객체만으로 해제가 가능하다. 라벨 기반 일괄 로드(LoadLabel<T>)는 각 에셋의 PrimaryKey로 개별 Load<T>를 호출하므로, 라벨로 로드한 에셋도 동일한 ref-counting 체계에 편입된다. Dispose() 시 모든 핸들과 인스턴스를 일괄 해제한다.

AssetRef: 에셋 참조 래퍼

AssetRef<T>가 Addressable 키만 직렬화하고, 런타임에 LoadAsync() → Asset 프로퍼티로 접근하는 래퍼다. T로의 암시 변환을 지원하여 AssetRef<Mesh>를 Mesh처럼 쓸 수 있다. 에디터에서는 드래그 앤 드롭으로 할당하면 PropertyDrawer가 Addressable 키를 자동 기록한다.

DataSystem: SO 캐싱

Addressables "CONFIG"/"DATA" 라벨이 붙은 ScriptableObject를 초기화 시 일괄 로드하여 타입별 딕셔너리에 캐시한다.

csharp
// Config: 원본 직접 캐시 (읽기 전용)
configCache[config.ConfigType] = config;

// BaseData: Instantiate 복사본 캐시 (런타임 수정 가능)
dataCache[data.DataType] = Instantiate(data);

CONFIG_TYPE/DataType enum으로 타입 안전하게 접근한다. Systems.Data.GetConfig<SetDatabase>(CONFIG_TYPE.SET_DATABASE) 한 줄로 조회하며, 중복 타입 등록 시 경고 로그를 남긴다.

오브젝트 풀링: Spawnable 계약

핵앤슬래시에서 투사체, 이펙트, 몬스터, 드롭 아이템은 대량으로 생성/파괴된다. Spawnable<T> 추상 클래스가 풀 라이프사이클을 정의한다.

csharp
public abstract class Spawnable<T> : MonoBehaviour
{
    public abstract void OnSpawn(T data);   // 풀에서 꺼낼 때
    public abstract void OnDespawn();       // 풀로 반환할 때
}

Unit, Projectile, AuraEffect, WorldDropItem 모두 Spawnable<T>를 상속한다. PoolSystem은 타입에 관계없이 동일한 Spawn/Despawn 인터페이스로 처리하므로, 새 풀링 대상을 추가할 때 Spawnable<T>를 상속하고 OnSpawn/OnDespawn을 구현하면 된다.

Signal도 별도의 Signal 풀을 통해 관리된다. Systems.Pool.GetSignal(template)이 풀에서 인스턴스를 꺼내고 템플릿의 값을 복사하여, ExecutionContext 빌드나 Clone 시 매번 SO를 Instantiate하지 않는다.

씬 전환: LoadingSystem 오케스트레이션

LoadingSystem이 씬 전환 시 로딩 화면 표시, 병렬 태스크 실행, 진행률 집계를 관리한다.

flowchart TD
    S1["1. 로딩 씬 로드 (Single)"]
    S2["2. 대상 씬 로드 (Additive, 비활성)"]
    S3["3. 태스크 팩토리 병렬 실행 (UniTask.WhenAll)"]
    S4["4. 대상 씬 활성화"]
    S5["5. 로딩 씬 언로드"]
    S1 --> S2 --> S3 --> S4 --> S5

태스크는 Func<UniTask> 팩토리로 전달되어 지연 생성된다. 각 태스크에 AsyncLocal<uint> 기반 ID를 부여하여 개별 진행률을 추적하고, LoadingContext가 전체 진행률을 집계한다. 태스크 내부에서 .WithProgress() 확장 메서드로 진행률을 보고하며, 타임아웃 시에도 진행률을 갱신하여 로딩 UI가 멈추지 않도록 보장한다.

결과

ResourceSystem의 ref-counting으로 에셋 생명주기가 명시적으로 관리되고, Spawnable<T> 계약으로 모든 풀링 대상이 동일한 인터페이스를 따른다. LoadingSystem이 씬 전환 시 비동기 태스크를 오케스트레이션하여, 상위 시스템은 초기화 로직만 Func<UniTask>로 전달하면 된다. 런타임 인프라가 안정적으로 동작하므로 상위의 게임 로직 도메인이 에셋 로딩이나 풀링 세부사항을 신경 쓰지 않고 자신의 로직에 집중할 수 있다.

5 / 9

DOTS-Mono 하이브리드 아키텍처

문제

Unity DOTS(ECS)는 대량 연산에 강하지만, UI, 애니메이션, 서드파티 에셋 등 MonoBehaviour 생태계에 의존하는 영역이 여전히 많다. 반대로 MonoBehaviour만으로는 수백 몬스터의 AI 평가, 투사체 궤적 연산을 매 프레임 처리하면서 GC 스파이크를 피하기 어려웠다.

양쪽을 동시에 사용하되, ECS 컴포넌트에 접근하는 MonoBehaviour가 늘어날수록 sync point가 비례해서 증가한다. DOTS의 Job System은 구조 변경(structural change)이나 메인 스레드의 컴포넌트 접근이 발생할 때마다 실행 중인 Job을 완료 대기시킨다. AI, Transform, 투사체 각각이 독립적으로 EntityManager를 조회하면 프레임당 sync point가 N개 발생하고, 그때마다 워커 스레드가 멈춘다. 수백 몬스터가 활동하는 상황에서 이 비용은 DOTS를 도입한 이점을 상쇄한다.

타이밍 문제도 동반된다. Entity의 AI가 결정한 커맨드를 Mono 측 Unit이 실행하는데, 읽기와 쓰기가 같은 프레임에서 순서 없이 일어나면 한 프레임 전의 데이터로 판단하거나, 처리 결과가 씹히는 문제가 생긴다.

접근: 3-Phase Bridge 패턴

sync point를 최소화하는 방법은 ECS 접근을 한 프레임에 한 번, 한 곳에서만 수행하는 것이다. DOTS-Mono 경계의 모든 데이터 흐름을 BridgeSystem이라는 단일 동기화 지점으로 모았다. AI, Transform, 투사체가 각자 EntityManager를 호출하는 대신, BridgeSystem이 LateUpdate에서 ECS 데이터를 일괄 읽고 일괄 쓴다. 프레임당 sync point가 N개에서 1개로 줄어든다.

flowchart LR
    R["Read\nDOTS Entity → NativeHashMap 캐시"]
    P["Process\nMono 측 로직 실행"]
    W["Write\n처리 결과 → DOTS Entity 반영"]
    R --> P --> W

이 패턴을 따르는 Bridge가 세 개다.

BridgeReadProcessWrite
AIBridgeCommand, Blackboard 상태커맨드 핸들러 디스패치, 상태 머신 전이완료/취소 상태 반영
TransformBridgeECS LocalTransform--Mono Transform → ECS 동기화
TrajectoryBridgeProjectileMovementState--투사체 Position/Rotation 반영

BatchProcessor: NativeHashMap 기반 캐싱

ECS 청크를 직접 순회하면서 Mono 로직을 실행하면 청크 레이아웃에 의존하게 된다. BatchProcessor<T>가 Read 단계에서 NativeHashMap<Entity, T>에 데이터를 캐싱하고, Process/Write는 이 캐시를 통해서만 접근한다.

csharp
// Read 단계: ECS 데이터를 NativeHashMap에 일괄 캐싱
commandProcessor.Read();   // Entity → Command 매핑
blackboardProcessor.Read(); // Entity → Blackboard 매핑

// Process 단계: 캐시된 데이터로 Mono 로직 실행
foreach (var (entity, unit) in entityToUnit)
{
    var cmd = commandProcessor.Get(entity);
    if (cmd.IsRequested) StartCommand(unit, cmd);
    else if (cmd.IsInProgress) UpdateCommand(unit, cmd);
}

// Write 단계: 결과를 ECS에 반영
commandProcessor.Write();

Read 단계에서 ECS 청크 순회가 한 번에 끝나므로, Process와 Write 단계에서는 EntityManager에 다시 접근하지 않는다. sync point가 Read 시점에 한 번만 발생하고, 이후 Mono 로직은 NativeHashMap 캐시만 사용한다. ECS 청크 순회와 Mono 로직 실행이 완전히 분리되어, 한쪽의 구조가 바뀌어도 다른 쪽에 영향을 주지 않는다.

AIBridge 커맨드 상태 머신

AI가 ECS 측에서 결정한 커맨드(이동, 공격, 스킬 사용 등)를 Mono 측 Unit이 실행한다. 커맨드의 생명주기를 상태 머신으로 관리하여 Mono-ECS 간 레이스 컨디션을 방지했다.

stateDiagram-v2
    [*] --> Requested
    Requested --> InProgress : StartCommand()
    InProgress --> InProgress : UpdateCommand()
    InProgress --> Finished
    InProgress --> Cancelled
    Finished --> [*] : activeCommands.Remove()\ncommandProcessor.Set()
    Cancelled --> [*] : activeCommands.Remove()\ncommandProcessor.Set()

activeCommands 딕셔너리가 현재 실행 중인 커맨드를 추적한다. ECS 측에서 새 커맨드를 요청해도 기존 커맨드가 완료/취소될 때까지 대기하므로, 한 유닛에서 두 커맨드가 동시에 실행되는 상황을 구조적으로 차단한다.

난관: EntityLink와 양방향 참조

Mono Unit에서 DOTS Entity를 찾는 것은 EntityLink 컴포넌트로 쉽게 해결된다. 문제는 역방향이었다. ECS Job 내부에서 Mono 오브젝트에 접근할 수 없고, BridgeSystem의 entityToUnit 딕셔너리를 매개로 해야 한다.

csharp
// DOTS Entity → Mono Unit 역참조
public static bool TryGetUnit(Entity entity, out Unit unit)
{
    unit = null;
    if (entity == Entity.Null) return false;

    var em = World.DefaultGameObjectInjectionWorld.EntityManager;
    if (em.Exists(entity) && em.HasComponent<EntityLink>(entity))
    {
        var bridge = em.GetComponentObject<EntityLink>(entity);
        unit = bridge.GetComponent<Unit>();
        return unit != null;
    }
    return false;
}

GetComponentObject<EntityLink>()는 관리 객체 접근이라 Burst에서 사용할 수 없다. 따라서 역참조가 필요한 모든 로직은 반드시 BridgeSystem의 Process 단계(메인 스레드, Mono 측)에서만 수행하도록 제약했다. 이 제약 덕분에 "어디서 역참조를 해야 하는가"라는 판단이 명확해진다. Process 단계가 아니면 할 수 없다.

결과

BridgeSystem의 3-phase 패턴으로 프레임당 sync point가 1회로 고정되었다. Bridge가 3개로 늘어나도 sync point 수는 변하지 않는다. Read 단계에서 ECS 데이터를 NativeHashMap에 캐싱한 뒤, 이후 모든 Mono 로직은 캐시만 참조하기 때문이다. 새로운 Bridge 추가 시에도 동일한 Read/Process/Write 구조를 따르면 sync point 증가 없이 DOTS-Mono 경계를 확장할 수 있다.

EntityLink의 역참조 제약이 "어디서 Mono 객체에 접근하는가"에 대한 규칙을 명확하게 만들어, 하이브리드 구조에서 발생하기 쉬운 동기화 타이밍 버그를 구조적으로 차단한다.

6 / 9

Signal Fragment 파이프라인

문제

핵 앤 슬래시 RPG에서 스킬은 고정된 동작이 아니다. 같은 "발사" 스킬이라도 부채꼴로 퍼지거나, 유도탄이 되거나, 착탄 시 폭발하거나, 폭발이 다시 분열하는 등 조합에 따라 전혀 다른 동작을 한다. 스킬 하나마다 클래스를 만들면 조합 수가 곱셈으로 폭발하고, 장비 옵션에 의한 스킬 수정까지 고려하면 유지보수가 불가능해진다.

접근: 3단계 Signal 조합

스킬을 하나의 고정된 동작이 아니라, 조합 가능한 파편(Signal)의 파이프라인으로 설계했다.

flowchart LR
    subgraph Origin["Origin (발동)"]
        O1["Shoot"]
        O2["Melee"]
        O3["Beam"]
        O4["Aura"]
        O5["Summon"]
        O6["Dodge"]
    end
    subgraph Modulation["Modulation (변형)"]
        M1["Fan"]
        M2["Homing"]
        M3["Bezier"]
        M4["Spiral"]
        M5["Knockback"]
        M6["Stun"]
    end
    subgraph Final["Final (효과)"]
        F1["Damage"]
        F2["Explosion"]
        F3["Split"]
        F4["Effect"]
    end
    Origin --> Modulation --> Final

모든 Signal은 ScriptableObject 기반의 Signal 추상 클래스를 상속한다. Frame(장비)에 Signal을 슬롯에 배치하면, Build() 호출 시 각 Signal이 ExecutionContext에 자기 타입에 맞는 위치로 자기 등록한다.

csharp
// Frame.Build(): 슬롯에 꽂힌 Signal만으로 스킬이 구성된다
public ExecutionContext Build()
{
    var ctx = new ExecutionContext();
    foreach (var slot in data.slots.Where(s => s.Filled))
        slot.Signal.Register(ctx);
    return ctx;
}
csharp
// Signal.Register(): 타입별 자기 등록
public void Register(Signal signal)
{
    switch (signal.Type)
    {
        case SignalType.Origin:
            originSignal = Systems.Pool.GetSignal(signal as Origin);
            break;
        case SignalType.Modulation:
            if (signal is SpawnModulation spawn)
                this.spawn[spawn.TargetOrigin].Add(Systems.Pool.GetSignal(spawn));
            if (signal is RuntimeModulation runtime)
                this.runtime[runtime.TargetType].Add(Systems.Pool.GetSignal(runtime));
            break;
        case SignalType.Final:
            final.Add(Systems.Pool.GetSignal(signal as Final));
            break;
    }
}

"Shoot + Fan + Homing + Explosion" 조합이나 "Beam + Spiral + Split + Damage" 조합이 코드 변경 없이 슬롯 배치만으로 만들어진다.

Modulation의 두 시점

Modulation은 적용 시점에 따라 두 종류로 나뉜다.

종류시점역할예시
SpawnModulationOrigin 발동 시 1회투사체 초기 위치/방향 결정Fan(부채꼴 배치)
RuntimeModulation이펙트 존속 중 매 프레임궤적/상태 지속 제어Homing(유도), Spiral(나선)

SpawnModulation은 Origin 타입별로 인덱싱된다. Fan은 Shoot에만 적용되고 Beam에는 적용되지 않는다. RuntimeModulation은 이펙트 타입별로 인덱싱되어, Projectile에는 Homing이, AuraEffect에는 다른 Modulation이 적용되는 식이다.

난관: 분기와 재귀

Split(분열)은 하나의 투사체가 착탄 시 여러 방향으로 갈라지는 Final이다. 문제는 분열된 각 투사체가 원본과 동일한 Modulation/Final 파이프라인을 독립적으로 가져야 한다는 것이었다. 하나의 ExecutionContext를 공유하면 한 분기의 상태 변경이 다른 분기에 전파되어 궤적이 꼬인다.

Explosion도 동일한 문제가 있었다. 범위 내 모든 적에게 Final을 적용할 때, 각 적에 대한 처리가 독립적이어야 한다.

해결: ExecutionContext 딥클론

Clone()이 파이프라인 전체를 풀링된 Signal 인스턴스로 복제한다.

csharp
public ExecutionContext Clone(ExecutionContext ctx)
{
    return new ExecutionContext
    {
        owner = ctx.owner,
        originSignal = Systems.Pool.GetSignal(ctx.originSignal),
        spawn = ctx.CloneSpawnMods(),
        runtime = ctx.CloneRunitmeMods(),
        final = ctx.CloneFinalMods(),
        IgnoreUnit = ctx.CloneIgnoreUnits(),
        splitDepth = ctx.splitDepth,
    };
}

Split은 타겟 수만큼 Clone()하여 각 분기에 독립 파이프라인을 부여한다. splitDepth가 복제되어 무한 분기를 방지한다(Split이 Split을 재귀적으로 트리거하는 상황). Signal은 풀에서 가져와 할당 비용을 최소화했다.

장비 옵션의 Signal 수신

장비(Frame)의 Affix가 Signal 동작을 수정한다. Signal 서브클래스마다 ApplyOption()을 오버라이드하여 자신에게 해당하는 옵션만 처리한다.

csharp
// Origin.ApplyOption(): 타입 안전 매칭
public override void ApplyOption(SignalModifier option)
{
    if (!option.AppliesTo(SignalId)) return;

    switch (option.stat)
    {
        case OptionStat.CastSpeed:
            if (option.modifyType == ModifyType.Flat) flatCastSpeedBonus += option.Value;
            else percentCastSpeedBonus += option.Value;
            break;
        case OptionStat.Cooldown:
            cooldown.value = Mathf.Max(0f, option.modifyType == ModifyType.Flat
                ? cooldown.value + option.Value
                : cooldown.value * (1f + option.Value / 100f));
            break;
    }
}

AppliesTo(SignalId)가 타입 안전 매칭을 보장하여, SplitWidth 옵션이 Shoot에 적용되는 실수를 구조적으로 차단한다. Affix의 이중 채널 구조와 장착/해제 시 바인딩 관리는 08-장비-프레임-시스템에서 다룬다.

Frame의 슬롯 제약

Frame에 Signal을 배치할 때 두 가지 제약이 있다.

첫째, Origin이 설치되어야 나머지 슬롯이 열린다. Origin 없이 Modulation만 있는 스킬은 의미가 없으므로, 슬롯 잠금 상태를 Origin 설치 여부에 연동했다.

둘째, Split Final은 Frame당 하나만 허용된다. 복수의 Split이 동시에 적용되면 분기 수가 기하급수적으로 폭발하므로, AddSignal() 시점에 기존 슬롯에 Split이 있는지 검사하여 중복을 차단한다.

결과

Signal 조합만으로 스킬을 구성하는 구조 덕분에, 새로운 Origin이나 Modulation을 추가할 때 기존 코드를 수정할 필요가 없다. ScriptableObject 하나를 만들고 슬롯에 배치하면 기존의 모든 조합과 호환된다. ExecutionContext 딥클론으로 Split/Explosion 같은 분기 이펙트가 파이프라인 독립성을 유지하면서 동작하고, Affix 이중 전파로 장비 한 개의 옵션이 유닛 스탯과 스킬 동작에 동시에 반영된다.

7 / 9

Behaviour Tree 시스템

문제

핵 앤 슬래시 RPG에서 화면에 수백 마리의 몬스터가 동시에 존재한다. 각 몬스터는 매 프레임 BT(Behaviour Tree)를 평가하여 다음 행동을 결정해야 하는데, 일반적인 재귀 기반 BT 구현은 두 가지 한계가 있었다.

첫째, GC 압력. 재귀 호출마다 콜 스택이 쌓이고, 노드 상태를 추적하는 자료구조가 힙에 할당된다. 몬스터 200마리가 매 프레임 BT를 평가하면 GC 스파이크가 눈에 띄게 발생했다.

둘째, Burst 컴파일 불가. Unity의 Burst Compiler는 관리 객체(managed object) 할당을 허용하지 않는다. 재귀와 힙 할당을 사용하는 BT는 Burst로 컴파일할 수 없어, DOTS의 성능 이점을 활용할 수 없었다.

접근: 스택 기반 반복 순회

재귀를 명시적 스택으로 치환하고, 모든 자료구조를 고정 크기 스택 할당으로 전환했다.

csharp
[BurstCompile]
static NodeStatus Eval(ref TreeData tree, ref Blackboard bb, ref Command cmd,
    ref DynamicBuffer<BTNodeStateBuffer> states, ...)
{
    var stack = new FixedList128Bytes<ushort>();       // 노드 인덱스
    var child = new FixedList128Bytes<byte>();         // 자식 순회 카운터
    var snaps = new FixedList128Bytes<PropSnapshot>(); // 깊이별 프로퍼티 스냅샷

    stack.Add((ushort)tree.rootIdx);
    child.Add(0);
    snaps.Add(new PropSnapshot { speed = bb.speedMult, distance = bb.prefDist });

    while (stack.Length > 0)
    {
        int d = stack.Length - 1;
        int idx = stack[d];
        ref var node = ref tree.nodes[idx];

        byte c = child[d];
        bool first = (c == 0);

        // Condition 게이팅: 첫 진입 시에만 평가
        if (first && attach.HasCond)
        {
            if (!ChkConds(ref tree, ref bb, ref attach, ref states, idx, time))
            {
                result = NodeStatus.Failure;
                Pop(ref stack, ref child, ref snaps);
                continue;
            }
        }

        // Property 스냅샷: 깊이별 저장/복원
        if (first && attach.HasProp)
            SetProps(ref tree, ref bb, ref attach, ref snaps, d);

        if (node.IsAction)
        {
            result = EvalAction(ref tree, ref bb, ref cmd, ref node, idx);
            if (result == NodeStatus.Running)
                rt.runningNode = (ushort)idx;
            Pop(ref stack, ref child, ref snaps);
            continue;
        }
        // Composite: 자식 Push 또는 Finalize ...
    }
    return result;
}

FixedList128Bytes는 Unity의 고정 크기 값 타입 리스트로, 스택에 할당되어 GC를 발생시키지 않는다. 128바이트 안에 BT 깊이만큼의 인덱스를 저장하므로, 일반적인 BT 깊이(10~15 레벨)에서 충분하다.

TreeData: BlobAsset 기반 읽기 전용 트리

BT의 노드 구조는 런타임에 변하지 않는다. TreeAsset(ScriptableObject)에서 에디터 시점에 정의한 트리를 BlobAsset으로 빌드하여, 모든 몬스터가 동일한 트리 데이터를 공유한다.

flowchart LR
    TA["TreeAsset\n(SO, 에디터)"] --> BA["BlobAsset\n(빌드)"] --> TD["TreeData\n(런타임 참조)"]

BlobAsset은 연속 메모리에 배치되는 읽기 전용 데이터로, 캐시 효율이 높고 Entity 간 공유가 가능하다. 노드 인덱스 기반으로 접근하므로 포인터 추적 없이 배열 인덱싱만으로 트리를 순회한다.

PropSnapshot: 깊이별 프로퍼티 저장/복원

BT 서브트리가 Blackboard 값을 일시적으로 변경하는 경우가 있다. "도망" 서브트리에서 이동 속도를 올려놓고, 서브트리가 끝나면 원래 값으로 돌아가야 한다. PropSnapshot이 트리 깊이별로 프로퍼티 값을 저장하고, Pop 시점에 자동 롤백한다.

flowchart LR
    PU["Push(깊이 3)"] --> SP["SetProps\nspeed = 2.0"] --> EX["..."] --> PO["Pop(깊이 3)"] --> RP["ResetProps\nspeed = 원본"]

재귀 구현에서는 함수 스코프가 자연스럽게 이 역할을 했지만, 명시적 스택에서는 직접 관리해야 한다. snaps 리스트가 stack과 동일한 깊이 인덱스를 공유하여 Push/Pop이 동기화된다.

난관: Running 노드 재진입

BT에서 Action 노드가 Running을 반환하면, 다음 프레임에 해당 노드부터 평가를 재개해야 한다. 재귀 구현에서는 코루틴이나 상태 변수로 처리하지만, Burst Job에서는 프레임 간 상태를 유지할 수 없다.

RuntimeState.runningNode에 Running 노드의 인덱스를 저장하고, 다음 프레임 평가 시 루트에서 시작하되 Running 노드에 도달할 때까지 Composite의 자식 선택을 스킵한다. BTNodeStateBuffer(ECS DynamicBuffer)가 각 Composite 노드의 마지막 실행 자식 인덱스를 기억하여, Sequence가 중간부터 재개하거나 Selector가 이전 실패 이후 다음 자식을 평가하는 것이 가능하다.

AIBridge 커맨드 디스패치

BT 평가 결과로 나온 Command는 ECS 컴포넌트에 기록된다. 이것을 Mono 측 Unit이 실행하는 과정은 BridgeSystem의 AIBridge가 담당한다.

flowchart TD
    E["ECS: BT Eval\nCommand type, state: Requested"]
    R["Bridge Read\ncommandProcessor.Get(entity)"]
    P["Bridge Process\nCommandHandler.Start(unit, cmd.type)"]
    IP["cmd.state = InProgress\n매 프레임 Update"]
    F["cmd.state = Finished"]
    W["Bridge Write\ncommandProcessor.Set(entity, cmd)"]
    E --> R --> P --> IP --> F --> W

CommandHandler는 커맨드 타입별로 구현체가 분리되어 있다. Move, Attack, UseSkill, Dodge 등 각 핸들러가 Unit의 모듈(MovementModule, AnimationModule 등)을 조합하여 행동을 실행한다. BT는 "무엇을 할지"만 결정하고, "어떻게 실행할지"는 Mono 측 핸들러가 담당하는 구조다.

결과

BT 평가가 Burst Job으로 컴파일되어 메인 스레드 부하 없이 실행된다. FixedList128Bytes 기반 스택 순회로 프레임당 GC 할당이 0이다. 모든 몬스터가 BlobAsset으로 동일한 트리 구조를 공유하므로 메모리 사용량이 트리 수에 비례하지 않고, BTActive 태그 기반 Dormant 컬링과 결합하여 화면 밖 몬스터의 평가 비용을 완전히 제거한다.

8 / 9

유닛 시스템 설계

문제

액션 RPG에서 Player, Monster, Summon은 서로 다른 행동을 하지만, 스탯 계산, 이동, 애니메이션, 사망 처리 같은 공통 동작을 공유한다. 공통 로직을 상위 클래스에 몰아넣으면 유닛 타입이 추가될 때마다 불필요한 의존이 딸려오고, 각 유닛에서 개별 구현하면 동일한 로직이 분산되어 수정 비용이 곱셈으로 늘어난다.

또한 유닛은 DOTS Entity와 연결되어 있어, Mono 측 생명주기(Spawn/Die/Cleanup)와 ECS 측 컴포넌트 등록/해제가 정확히 동기화되어야 한다.

접근: 추상 클래스 + 모듈 합성

Unit 추상 클래스가 공통 생명주기를 정의하고, 구체적인 동작은 모듈에 위임했다.

graph TD
    U["Unit (abstract)\nextends Spawnable&lt;UnitData&gt;"]
    SM["StatModule\n스탯 계산, 모디파이어, 리젠"]
    MM["MovementModule\nA* 이동, Dodge, LookAt"]
    AM["AnimationModule\nAnimancer 상태 머신, 캐스팅"]
    EL["EntityLink\nDOTS Entity 연결"]

    U --- SM
    U --- MM
    U --- AM
    U --- EL

    P["Player\n+ InputModule, EquipmentModule,\nInventoryModule, ModelModule"]
    M["Monster\n+ UnitAI (BT), DropTable,\nMicroBar (HP UI)"]
    S["Summon\n+ dual-gate 활성화,\nisDying guard"]

    U --> P
    U --> M
    U --> S

Spawnable<T>는 오브젝트 풀 라이프사이클을 정의하는 제네릭 추상 클래스다. OnSpawn(T data)에서 초기화하고, OnDespawn()에서 정리한다. Unit, Projectile, AuraEffect, WorldDropItem 모두 이 계약을 따르므로 PoolSystem이 타입에 관계없이 동일한 방식으로 Spawn/Despawn을 처리한다.

생명주기

flowchart TD
    subgraph Spawn["OnSpawn(UnitData)"]
        S1["SetStatus(Alive)"]
        S2["stat.Initialize(baseStats)"]
        S3["movement.Initialize(moveSpeed)"]
        S4["anim.Initialize(animationData)"]
        S5["EntityLink.Register(entity)"]
        S6["stat.OnDeath += Die"]
        S7["stat.RegenerateLoop()"]
        S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
    end
    subgraph Die["Die()"]
        D1["stat.StopRegen()"]
        D2["SetStatus(Dead)"]
        D1 --> D2
    end
    subgraph Clean["CleanUp()"]
        C1["cts.Cancel()"]
        C2["stat.Cleanup()"]
        C3["Pool.Despawn(this)"]
        C1 --> C2 --> C3
    end
    Spawn --> Die --> Clean

CancellationTokenSource가 유닛 단위로 생성되어, 비동기 작업(리젠 루프, 에셋 로딩 등)이 CleanUp 시 일괄 취소된다. 사망 감지는 stat.OnStatChanged 이벤트에서 CURRENT_HEALTH <= 0을 체크하여 OnDeath를 발화하는 방식이다. 별도의 사망 판정 로직을 두지 않고 스탯 변경 이벤트에 얹는 구조.

UnitStatus 플래그

유닛 상태를 uint 플래그로 관리한다.

csharp
// 비트 플래그 기반 상태 추적
public void SetStatus(UnitStatus status)
{
    UnitStatus newFlags = status & ~unitStatus; // 새로 추가되는 플래그만 추출
    unitStatus |= status;
    // newFlags에 따라 상태 변경 반응 처리
}

CC(군중 제어), 버프/디버프, 이동/캐스팅 상태를 단일 플래그 필드에서 관리한다. 복합 조건은 미리 정의된 마스크(CannotAct, CannotMove, AnyCC 등)로 한 번에 체크한다. Blackboard 동기화 시에도 이 플래그 값을 그대로 전달하여, BT 조건 노드에서 비트 연산만으로 상태를 판별한다.

난관: StatModule의 모디파이어 관리

장비, 버프, 디버프, 세트 보너스 등 여러 소스에서 스탯 모디파이어가 추가되고 제거된다. 동일한 소스에서 같은 타입의 모디파이어가 중복 등록되면 수치가 틀어지고, 소스 제거 시 정확히 해당 소스의 모디파이어만 걷어내야 한다.

해결: Source 기반 추적과 Merge 정책

02-데이터-드리븐-설계에서 다룬 StatModifier(StatType + CalcType + value + source)를 활용한다. 동일한 (source, statType, calcType) 조합이 이미 존재하면 value를 합산(merge)하여 중복 엔트리를 방지한다. 장비 해제 시 RemoveFrom(source) 한 줄로 해당 소스의 모든 모디파이어가 제거된다.

Recalculate 파이프라인

스탯이 변경될 때마다 전체 재계산을 수행한다.

flowchart TD
    B["baseValue\n(UnitData에서 로드)"]
    F["+ flatBonus\nPLUS 합산 - MINUS 합산"]
    CL["Clamp(0, ...)"]
    PC["* (100 + percentBonus) / 100"]
    V["Validate\nHP: 0~MAX, Speed/Defense: >= 0, Shield: 0~MAX"]
    SC["oldValue != newValue\n→ OnStatChanged"]
    OD["CURRENT_HEALTH <= 0\n→ OnDeath"]
    B --> F --> CL --> PC --> V --> SC --> OD

Flat과 Percent를 분리하여 (base + flat) * (1 + percent/100) 공식을 따른다. Flat 적용 후 음수를 Clamp하고, Percent를 곱하는 순서가 고정되어 있어 모디파이어 추가 순서에 관계없이 결과가 일정하다.

리젠 루프

1초 간격으로 Health, Shield, Memory를 회복한다. Memory는 소모 자원이므로 회복이 아닌 감소(MINUS) 방향으로 적용된다.

flowchart LR
    subgraph Regen["리젠 방향"]
        H["Health\nCURRENT_HEALTH += Min(rate, MAX - current)"]
        S["Shield\nCURRENT_SHIELD += Min(rate, MAX - current)"]
        M["Memory (역방향)\nCURRENT_MEMORY -= Min(rate, current)"]
    end

회복량이 최대치를 초과하지 않도록 Min(rate, max - current)으로 클램핑한다. rate <= 0이거나 이미 최대/최소치이면 연산을 스킵한다.

Monster 확장: AI + 드롭 + Dormant

Monster는 Unit 생명주기에 AI, 드롭, Dormant 컬링을 추가한다.

flowchart TD
    subgraph Init["Monster.Initialize()"]
        I1["base.Initialize()"]
        I2["hpBar.Initialize()"]
        I3["ai.Initialize(entity)"]
        I4["ai.SetupBehaviourTree()"]
        I5["cachedRenderers 캐싱"]
        I6["if isDormant:\nPauseBT(), SetRenderers(false)"]
        I1 --> I2 --> I3 --> I4 --> I5 --> I6
    end
    subgraph MDie["Monster.Die()"]
        MD1["ai.Cleanup()"]
        MD2["ProcessDrops().Forget()"]
        MD3["base.Die()"]
        MD1 --> MD2 --> MD3
    end
    Init --> MDie

HandleStatChange에서 HP 변동 시 ai.SetStat()으로 Blackboard에 동기화하고, hpBar에 데미지/힐 애니메이션을 트리거한다. Monster가 사망하면 DropTable.Roll()로 아이템을 생성하고 월드에 드롭한다.

Player 확장: 입력 + 장비 + 모델

Player는 Unit 생명주기에 InputModule, EquipmentModule, InventoryModule, ModelModule을 추가한다.

flowchart TD
    P1["playerData.LoadAssetsAsync()"]
    P2["base.Initialize()"]
    P3["input.Initialize()"]
    P4["equipment.Initialize()"]
    P5["inventory.Initialize()"]
    P6["model.Initialize(equipment)"]
    P7["Systems.Bridge.Register(entity)"]
    P8["Systems.UI.OpenPanel(PlayerHudPanel)"]
    P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7 --> P8

InputModule이 FrameType별로 InputAction을 바인딩한다. 장비(Frame)가 장착되면 Frame.Build() → ExecutionContext 생성 → InputAction에 등록하는 흐름으로, 장비를 바꾸면 스킬 입력 바인딩이 자동으로 갱신된다.

ModelModule은 장비 변경 시 메시를 교체한다. Pre-bake 경로(bakedMesh + BoneType 배열)와 Fallback 경로(meshPrefab 인스턴스화 + 본 리맵)를 분리하여, Pre-bake가 가능한 장비는 런타임 복사 없이 직접 할당하고, 그렇지 않은 경우 메시를 인스턴스화하고 Matrix4x4.Scale로 스케일을 적용한 뒤 본을 리맵한다.

결과

Unit 추상 클래스가 생명주기와 모듈 합성 구조를 정의하고, Player/Monster/Summon이 각자 필요한 모듈만 추가하는 구조다. 새 유닛 타입(NPC 등)을 추가할 때 Unit을 상속하고 필요한 모듈만 조합하면 된다. StatModule의 source 기반 추적으로 장비 교체, 버프 만료, 세트 보너스 변경 시 정확한 모디파이어 관리가 가능하고, Recalculate 파이프라인의 고정된 연산 순서로 모디파이어 추가 순서에 관계없이 결과가 일관된다.

9 / 9

장비 프레임 시스템

문제

핵 앤 슬래시 RPG에서 장비는 단순한 스탯 보너스 이상의 역할을 한다. 장비 하나가 유닛 스탯(HP, 공격력)과 스킬 동작(쿨다운, 투사체 속도)을 동시에 수정하고, 같은 세트의 장비를 여러 개 착용하면 추가 보너스가 활성화된다. 여기에 아이템 등급(Tier)과 품질(Quality)에 따라 옵션 수치가 달라지는 확률 시스템까지 필요했다.

장비 옵션이 유닛 스탯과 스킬 옵션이라는 두 채널로 전파되어야 하므로, 장착/해제/교체 시 양쪽 채널의 바인딩을 정확하게 관리하지 않으면 수치 오류가 발생한다.

접근: Frame + SignalSlot + Affix

장비를 세 계층으로 분리했다.

graph TD
    FR["Frame\n(런타임 장비 객체)"]
    SS["SignalSlot[]\nSignal(Origin/Modulation/Final)을 꽂는 슬롯"]
    AF["Affix[]\n스탯 + 스킬 옵션을 운반하는 접사"]
    FQ["FrameQuality\nNormal / Optimized / Native"]
    SI["SetID\n세트 보너스 식별자"]
    FR --- SS
    FR --- AF
    FR --- FQ
    FR --- SI

Frame은 FrameData(ScriptableObject)에서 인스턴스화된다. FrameData.Roll()이 드롭 시점에 Affix와 슬롯을 확률적으로 생성하고, 런타임에서는 Frame 객체가 이 데이터를 들고 다닌다.

SignalSlot 제약

슬롯에는 두 가지 제약이 있다.

첫째, Origin 잠금. Origin Signal이 설치되어야 나머지 슬롯이 열린다. Origin 없이 Modulation만 있는 스킬은 의미가 없으므로, AddSignal()에서 Origin이 추가되면 data.Lock(false)로 나머지 슬롯을 해제한다.

csharp
public Signal AddSignal(int id, Signal signal, bool replace = true)
{
    var slot = Get(id);

    // Split 중복 방지: Frame당 하나만 허용
    if (signal is Split)
    {
        foreach (var s in data.slots)
            if (s.Filled && s.Signal is Split) return signal;
    }

    if (!slot.CanAccept(signal, replace)) return signal;

    slot.Signal = signal;

    if (signal.Type == SignalType.Origin)
        data.Lock(false);  // 나머지 슬롯 해제

    OnChanged?.Invoke(id, signal);
    return replacedSignal;
}

둘째, Split 단일 제약. Frame당 Split Final은 하나만 허용된다. 복수 Split이 동시에 적용되면 분기 수가 기하급수적으로 폭발하므로, 기존 슬롯에 Split이 있으면 추가를 거부한다.

CanAccept()가 잠금 상태와 타입 매칭을 검증하므로, Origin 슬롯에 Modulation을 넣거나 잠긴 슬롯에 Signal을 넣는 시도가 구조적으로 차단된다.

Affix 롤링 파이프라인

아이템이 드롭될 때 Affix 수치가 결정된다. Tier → Quality → Affix 순으로 배율이 적용되는 3단계 파이프라인이다.

flowchart TD
    T1["1. TierConfig.GetMultiplier(itemTier)\nTier 1~6 기본 배율 결정 → tierMult"]
    T2["2. TierConfig.CanHaveQuality(itemTier)\n일정 Tier 이상만 품질 롤 자격"]
    T3["3. QualityConfig.RollQuality()\nNative → Optimized → Normal 순서 확률 체크\n상위 등급부터 판정, 한 번 성공 시 확정"]
    T4["4. QualityConfig.GetEntry(quality)\nNormal: 100% / Optimized: 115~130% / Native: 130%"]
    T5["5. AffixPool.Roll()\n각 AffixDef 독립 확률 판정\n→ CreateInstance() → maxCount 초과 시 셔플 후 컷"]
    T1 --> T2 --> T3 --> T4 --> T5
csharp
// QualityConfig: 상위 등급부터 캐스케이드 판정
public FrameQuality RollQuality()
{
    for (int i = (int)FrameQuality.Native; i > (int)FrameQuality.Normal; i--)
    {
        var entry = GetEntry((FrameQuality)i);
        if (entry != null && Random.Range(0f, 100f) < entry.rollChance)
            return (FrameQuality)i;
    }
    return FrameQuality.Normal;
}

AffixDef.CreateInstance()에서 tierMult는 unitStats의 value에, minMult/maxMult는 signalOptions의 RangedValue 범위에 적용된다. isPerfectRoll이면 min을 max로 덮어써서 최대치가 확정된다.

난관: 장착/해제 시 이중 채널 바인딩

Affix 하나가 unitStats(HP +50)과 signalOptions(쿨다운 -10%)을 동시에 담고 있다. 장비 장착 시 두 채널을 모두 바인딩해야 하고, 해제 시 정확히 역으로 언바인딩해야 한다. 세트 보너스까지 고려하면 바인딩 경로가 세 가지다.

flowchart LR
    FA["Frame Affix"]
    SA["Set Bonus Affix"]
    SR["Signal 교체"]

    US1["unitStats\nStatModule.Add()"]
    SO1["signalOptions\nSignal.ApplyOption()"]
    US2["unitStats\nStatModule.Add()"]
    SO2["signalOptions\nSignal.ApplyOption()"]
    RB["RebindAll()\n모든 Signal 옵션 재적용"]

    FA --> US1
    FA --> SO1
    SA --> US2
    SA --> SO2
    SR --> RB

해결: EquipmentModule의 Bind/Unbind 구조

EquipmentModule이 장비 장착/해제 시 바인딩을 일관된 패턴으로 처리한다.

csharp
// Equip: Frame의 모든 Affix를 두 채널로 바인딩
void Bind(Frame frame)
{
    // 1. unitStats → StatModule
    foreach (var affix in frame.Data.Affixes)
        foreach (var mod in affix.unitStats)
        {
            mod.source = frame;  // 소스 추적
            player.stat.Add(mod);
        }

    // 2. signalOptions → 각 Signal
    foreach (var slot in frame.All().Where(s => !s.Empty))
        Bind(frame, slot.Signal, reset: false);
}

Signal에 옵션을 적용할 때는 Frame의 Affix뿐 아니라 활성화된 세트 보너스의 Affix도 함께 적용한다. AppliesTo(SignalId) 매칭으로 해당 Signal에 관련된 옵션만 선별한다.

세트 보너스 활성 흐름

같은 SetID를 가진 Frame의 장착 수를 추적하여 세트 보너스를 관리한다.

flowchart TD
    EQ["Equip(frame with SetID.SET_1)"]
    CT["setEquipCounts SET_1 ++"]
    UB["UpdateSetBonus(SET_1, newCount)"]
    GA["SetConfig.GetAllActiveAffixes(newCount)\n2피스: AffixDef A / 4피스: A + B"]
    UN["기존 세트 Affix Unbind"]
    BN["새 세트 Affix Bind"]
    RB["RebindAll()\n모든 Frame의 Signal에 옵션 재적용"]
    EQ --> CT --> UB --> GA --> UN --> BN --> RB

RebindAll()은 모든 장착된 Frame의 모든 Signal에 대해 옵션을 초기화(ResetBonuses)한 뒤 재적용한다. 세트 보너스가 변경되면 기존 Signal의 옵션이 stale 상태가 되므로, 전체 재바인딩으로 무결성을 보장한다. 세트 보너스 Affix도 일반 Affix와 동일한 구조이므로 unitStats/signalOptions 이중 채널이 그대로 적용된다.

Sync: 장비 변경 → 입력 바인딩 갱신

장비가 변경되면 Sync()가 해당 FrameType의 ExecutionContext를 재빌드하고 InputModule에 등록한다.

flowchart TD
    TR["Equip / Signal 변경 / Unequip"]
    SY["Sync(frameType)"]
    BU["frame.Build() → ExecutionContext"]
    IR["input.Register(frameType, context)"]
    DS["data.Save()"]
    TR --> SY --> BU --> IR --> DS

장비를 바꾸거나 Signal을 교체하면 스킬 입력 바인딩이 자동으로 갱신되어, 플레이어가 장비를 바꾼 즉시 새 스킬을 사용할 수 있다.

드롭 시스템

DropTable이 몬스터 사망 시 아이템을 생성한다. 각 DropEntry가 독립 확률로 판정되어, 한 번의 드롭에서 여러 아이템이 나올 수 있다.

csharp
// DropTable: 테이블 활성화 → 엔트리별 독립 판정
public List<IInventoryItem> Roll()
{
    if (Random.Range(0f, 100f) >= dropChance) return results;

    foreach (var entry in entries)
    {
        if (Random.Range(0f, 100f) < entry.dropChance)
            results.AddRange(entry.CreateInstances());
    }
    return results;
}

DropEntry는 SerializeReference로 다형성을 지원한다. SignalDropEntry는 Signal 템플릿을 복제하고 Roll()로 RangedType 필드를 랜덤화하며, FrameDropEntry는 FrameData를 복제하고 Roll()로 Affix/슬롯/품질을 결정한다. 각 인스턴스에 Guid.NewGuid()를 부여하여 인벤토리에서 고유 식별이 가능하다.

결과

Affix가 unitStats와 signalOptions를 하나의 구조에서 운반하고, EquipmentModule의 Bind/Unbind가 두 채널의 바인딩을 일관된 패턴으로 관리한다. Tier → Quality → Affix 롤링 파이프라인으로 아이템 등급과 품질에 따른 수치 차등이 구조적으로 적용되고, 세트 보너스 변경 시 RebindAll()로 전체 Signal 옵션의 무결성을 보장한다.

Unity DOTS 핵앤슬래시 액션 RPG · 2025.12 ~ 현재