BurningTimesAi/프로젝트/코어프레임워크/04_Tier1_3종_상호작용_설계_v1.md

14 KiB

Tier 1 3종 상호작용 설계 v1 — Event · Container · Data

작성자: 개발팀장 (PD님 #36 즉시 수행 지시, 2026-04-17) 상태: 초안 → 구현 병행 반영 참조: 01_아키텍처_개요_v1.md §4-2~§4-3 (EventBus·ObservableList 개선안), 02_이전 프로젝트_추출대상_v1.md (Data 추출 대상) 적용 범위: BT.FrameworkBurningTimes.Core.Event·BurningTimes.Core.Container·BurningTimes.Core.Data 3개 모듈의 공개 API 경계와 상호 의존 방향


0. 왜 이 문서가 필요한가 (P18 설계 문서화)

Tier 1 잔여 3종(Data·Event·Container)은 서로 일부 기능이 겹치는 것처럼 보이나 역할이 명확히 다르며, 경계 없이 구현하면 아래 위험이 발생한다.

  • Container의 "변경 이벤트"와 Event의 "타입 안전 이벤트 버스"가 동일 문제를 두 번 푸는 것처럼 보이는 중복 설계
  • Data(마스터 테이블) 로딩 완료를 어디서 통지할지 불분명 → 수신자가 폴링으로 풀게 되는 반패턴
  • 세 모듈 간 의존 방향이 순환하면 assembly definition(asmdef) 분리가 불가능해지고 Tier 2 모듈의 단일 의존 원칙(§ 아키텍처 §2) 붕괴

본 문서는 이 세 모듈의 경계를 확정하여, 이후 Tier 2 모듈(Save/Audio/Economy 등)이 안전하게 Tier 1 위에 얹힐 수 있도록 한다.


1. 각 모듈의 단일 책임 (Single Responsibility)

1-1. BurningTimes.Core.Event — 타입 안전 프로세스 내 이벤트 버스

  • 목적: 서로 모르는 객체들 사이의 1:N 비동기 통지 (느슨한 결합)
  • 스케일: "전역 관심사"를 선언한 쪽이 Publish, 듣고 싶은 쪽이 Subscribe
  • 특징: 이벤트를 타입(struct 또는 class)으로 식별, 문자열 키 아님
  • 비목적: 객체 내부 상태 변경 알림(이건 Container의 책임), 파일 I/O·네트워크(상위 Tier)

1-2. BurningTimes.Core.Container관찰 가능한 자료구조

  • 목적: 하나의 컬렉션의 변경을 구독자가 추적할 수 있는 List/Dictionary/Queue
  • 스케일: 단일 컬렉션 인스턴스의 내부 변경 알림 (로컬)
  • 특징: 변경이 발생한 자기 자신이 이벤트를 직접 발행(인스턴스 이벤트, 전역 버스 아님)
  • 비목적: 전역 이벤트 전파(이건 Event의 책임), 영속화

1-3. BurningTimes.Core.Data마스터 테이블 로더

  • 목적: 기획·밸런싱 정적 데이터(CSV·JSON)를 런타임에서 타입 안전하게 조회
  • 스케일: 애플리케이션 수명 동안 보통 1회 로드, 키 기반 조회 빈번
  • 특징: DataTable<TKey, TRow>는 읽기 전용 뷰 + 인덱스, DataTableSO는 Unity ScriptableObject 래퍼
  • 비목적: 유저 세이브 데이터(Tier 2 Save), 런타임 쓰기(관찰 컬렉션은 Container)

1-4. 경계 판정 기준 (혼동 방지)

상황 어느 모듈을 쓸 것인가
"플레이어 사망"을 UI·사운드·업적이 동시에 반응 EventPlayerDiedEvent struct 발행
인벤토리 리스트가 바뀌면 UI가 슬롯을 다시 그림 ContainerObservableList<ItemStack> 변경 이벤트 구독
몬스터 ID로 MonsterDef 레코드 조회 DataDataTable<int, MonsterDef>.Get(id)
마스터 테이블 로딩이 끝났음을 전역에 알림 Event + Data (Data가 자기 이벤트 struct를 Publish)

2. 의존 방향 (DAG, 순환 금지)

        Data ──────────┐
                       ▼
                   Event
                       ▲
        Container ─────┘
  • Data → Event: Data는 로딩 완료·재로드 통지를 위해 Event를 옵션으로 사용한다. Data 내부 핵심 로직은 Event 없이도 동작해야 한다(테스트 격리성). Event는 "통지 채널"로만 쓰이며 Data의 조회 API에는 개입하지 않는다.
  • Container → Event: Container는 Event의존하지 않는다. 관찰은 인스턴스 이벤트(C# event 키워드) 로 제공. 전역 전파가 필요하면 사용자가 Container 이벤트를 수신한 뒤 직접 EventBus로 다시 Publish한다(분리 원칙).
  • Event ← Container: 역방향 참조 없음.
  • Event → Data / Container: 역방향 참조 없음. Event는 순수 범용 모듈.

결론: Event가 가장 원시(primitive), Data·Container는 동일 Tier이나 Event에만 한방향으로 의존. 순환 없음.

2-1. asmdef 반영 (구현 가이드)

  • BT.Framework.asmdef 단일 asmdef 유지 (현재 구조)
  • 만약 장래에 모듈별 asmdef 분리 시: EventContainer·Data 순서로 의존성 선언

3. 공개 API 경계 (implementation contract)

3-1. EventBus (Event)

namespace BurningTimes.Core.Event
{
    public static class EventBus
    {
        public static void Subscribe<TEvent>(Action<TEvent> handler);
        public static void Unsubscribe<TEvent>(Action<TEvent> handler);
        public static void Publish<TEvent>(TEvent e);
        public static void Clear<TEvent>();       // 테스트·씬 전환용
        public static void ClearAll();             // 테스트·애플리케이션 종료용
    }
}

계약:

  • 스레드 안전은 메인 스레드 전제 (Unity 표준). 멀티스레드는 Tier 3 Network 도입 시 재검토.
  • Publish 중 핸들러가 예외를 던져도 나머지 구독자는 호출 보장(catch-and-log).
  • Publish 중 발생한 Subscribe/Unsubscribe는 다음 Publish에 반영 (iteration snapshot 방식).
  • 이벤트 타입은 struct 권장(박싱 회피). class도 허용.
  • 문자열 키 버전은 BurningTimes.Core.Event.Raw.RawEventBus 하위 네임스페이스에 분리 (특수 용도).

3-2. ObservableList<T> / ObservableDictionary<TKey,TValue> / ObservableQueue<T> (Container)

namespace BurningTimes.Core.Container
{
    public class ObservableList<T> : IList<T>, IReadOnlyList<T>
    {
        public event Action<int, T> Added;        // (index, item)
        public event Action<int, T> Removed;      // (index, item)
        public event Action Reset;                // Clear·대량 교체
        // 표준 IList<T> 멤버...
    }

    public class ObservableDictionary<TKey, TValue> : IDictionary<TKey, TValue>
    {
        public event Action<TKey, TValue> Added;
        public event Action<TKey, TValue> Removed;
        public event Action<TKey, TValue, TValue> Updated;  // (key, oldValue, newValue)
        public event Action Reset;
        // 표준 IDictionary 멤버...
    }

    public class ObservableQueue<T> : IReadOnlyCollection<T>
    {
        public event Action<T> Enqueued;
        public event Action<T> Dequeued;
        public event Action Reset;
        // Enqueue/Dequeue/Peek/Count/Clear
    }
}

계약:

  • 이벤트는 인스턴스 멤버 (전역 아님). 여러 리스트가 있으면 각각 자기 이벤트 발행.
  • 이벤트 핸들러에서 같은 컬렉션을 변경해도 현재 iteration은 깨지지 않음 (iteration snapshot 또는 deferred ops).
  • null 핸들러 안전 (?.Invoke).
  • 기존 UniList·UniEventList·UniObserverList 3종이 본 1종으로 통합 (§01 §4-3).

3-3. DataTable<TKey, TRow> / DataTableSO (Data)

namespace BurningTimes.Core.Data
{
    public interface IDataRow<TKey> { TKey Key { get; } }

    public class DataTable<TKey, TRow> where TRow : IDataRow<TKey>
    {
        public IReadOnlyList<TRow> Rows { get; }
        public bool TryGet(TKey key, out TRow row);
        public TRow Get(TKey key);                // 없으면 예외
        public bool Contains(TKey key);
        public int Count { get; }

        public DataTable(IEnumerable<TRow> rows);
        public static DataTable<TKey, TRow> FromCsv(string csvText, Func<string[], TRow> rowFactory);
        public static DataTable<TKey, TRow> FromJson(string jsonText);
    }

    public abstract class DataTableSO<TKey, TRow> : ScriptableObject
        where TRow : IDataRow<TKey>
    {
        public DataTable<TKey, TRow> Table { get; }
        public void Load();                       // 런타임 초기화 훅
    }

    // 표준 이벤트 (Event 모듈과 연동)
    public readonly struct DataTableLoadedEvent
    {
        public readonly Type TableType;
        public readonly int RowCount;
    }
}

계약:

  • DataTable<TKey,TRow>불변 스냅샷 (로드 후 Rows 변경 불가). 변경이 필요하면 Container 사용.
  • 로딩 완료 통지는 사용자 선택 — 필요 시 EventBus.Publish(new DataTableLoadedEvent { ... }).
  • CSV 파싱 기본은 최소 구현(쉼표·따옴표 이스케이프만). 고도화 필요 시 CustomParser 훅.
  • Editor 측 CSV/Excel 컨버터는 Editor/Data/로 분리(§01 §2 구조). 런타임은 파싱된 결과만 소비.

4. 상호작용 시나리오 (설계 검증)

4-1. 마스터 테이블 로드 후 이벤트 통지

// 부트 코드
var monsterTable = DataTable<int, MonsterDef>.FromJson(File.ReadAllText(path));
DataRegistry.Register(monsterTable);
EventBus.Publish(new DataTableLoadedEvent { TableType = typeof(MonsterDef), RowCount = monsterTable.Count });

// 수신 쪽 (UI·사운드·스폰 등)
EventBus.Subscribe<DataTableLoadedEvent>(evt => {
    if (evt.TableType == typeof(MonsterDef)) RefreshMonsterList();
});
  • Data가 Event를 쓰지만 의존하지 않는다: 위 Publish 코드는 "사용자 코드" 영역. DataTable 자체는 Event 없이도 완결.

4-2. 관찰 컬렉션 변경을 전역에 2차 전파

var inventory = new ObservableList<ItemStack>();
inventory.Added += (idx, item) => EventBus.Publish(new InventoryItemAddedEvent { Item = item });
  • Container는 자기 이벤트만 내고, 사용자가 선택적으로 EventBus에 연결. Container는 Event를 import조차 하지 않음.

4-3. Data는 로드 후 Container로 "뷰"를 제공하지 않는다

  • DataTable은 읽기 전용 정적 데이터용, Container는 런타임 가변 상태용. 서로 역할이 겹치지 않음.
  • 잘못된 예: ObservableList<MonsterDef>로 마스터 테이블을 담는 것 → 마스터는 불변이므로 DataTable이 정답.

5. 검증 방법 (테스트 설계)

5-1. Event

  • Subscribe/Publish/Unsubscribe 기본 플로우
  • 다중 구독자 호출 순서(등록 순)
  • Publish 중 예외 발생해도 나머지 호출 보장
  • Publish 중 Subscribe가 현재 iteration에 포함되지 않음
  • Clear<TEvent>()·ClearAll() 동작

5-2. Container

  • ObservableList: Add/Insert/Remove/RemoveAt/Clear 각 이벤트
  • ObservableDictionary: Add/Remove/[key]=value(Updated)/Clear
  • ObservableQueue: Enqueue/Dequeue/Clear
  • 이벤트 핸들러 내 재변경 안전성 (iteration 깨짐 금지)

5-3. Data

  • DataTable 기본 조회(Get·TryGet·Contains·Count)
  • 불변성 — 생성 후 Rows 길이 고정
  • FromCsv 기본 케이스 (쉼표·따옴표 이스케이프·빈 줄 무시)
  • FromJson 기본 케이스 (Unity JsonUtility 또는 Newtonsoft.Json 선택 — 본 구현은 JsonUtility 기본, 한계 명시)
  • DataTableLoadedEvent 발행 수동 테스트(사용자 코드 패턴 검증)

6. 선택된 방향과 대안 (Trade-off / 기각안 영구 기록)

6-1. Event: 인스턴스 버스 vs 정적 버스

  • 채택: 정적 EventBus (§01 §4-2 예시와 일치)
  • 기각: 인스턴스 EventBus 주입형 (IoC)
  • 기각 이유: 게임 런타임 단일 프로세스 전제에서 정적이 토큰·호출 비용 최저, Unity 환경 광범위 관례. 테스트 격리는 Clear<T>()·ClearAll()로 충분. IoC가 필요한 시점은 Tier 3 Network·멀티 컨텍스트 도입 시 재검토.

6-2. Event: 약한 참조 vs 강한 참조

  • 채택: 강한 참조(Action<TEvent> 직접 보관). 명시적 Unsubscribe 의무.
  • 기각: WeakReference 기반 자동 해제
  • 기각 이유: 약한 참조는 핸들러가 람다 캡처일 때 수명 예측이 어렵고 구독자가 이른 GC로 사라지는 버그가 보고됨(업계 관례). 명시 해제가 단순·예측가능. 구독자 누수는 정적 분석 도구로 잡는 편이 낫다.

6-3. Container: 변경 이벤트 세분화 vs 단일 Changed 이벤트

  • 채택: Added·Removed·Reset (Dictionary는 Updated 추가) 세분화
  • 기각: 단일 Changed(ChangeArgs e)
  • 기각 이유: 세분화하면 구독자가 관심있는 이벤트만 구독해 불필요 dispatch를 줄일 수 있고, 람다 시그니처가 명확해 가독성이 높다. 박싱되는 ChangeArgs 할당도 회피.

6-4. Data: 제네릭 TKey vs int 전용

  • 채택: DataTable<TKey, TRow> 제네릭
  • 기각: DataTable<TRow> (int 키 고정)
  • 기각 이유: 기존 MasterTableBase가 int 키 고정이어서 확장성 부족했음. 문자열 키·enum 키 요구가 기획 쪽에서 자주 발생하므로 제네릭화. 비용은 딕셔너리 1개 분.

6-5. Data: CSV 파서 자체 구현 vs 외부 라이브러리

  • 채택: 최소 자체 구현 (쉼표·따옴표·줄바꿈). 필요 시 CustomParser 훅.
  • 기각: CsvHelper NuGet 패키지 도입
  • 기각 이유: Tier 1은 외부 의존성 최소 원칙(§01). 마스터 테이블 CSV는 포맷이 기획팀 통제하에 있어 최소 파서로 충분. 복잡한 케이스가 발생하면 Editor 측 컨버터가 JSON으로 변환하여 런타임에 전달하는 경로가 더 깨끗하다.

6-6. Data: JSON 파싱 — JsonUtility vs Newtonsoft.Json

  • 채택: Unity JsonUtility 기본, 한계 명시(Dictionary·polymorphism 미지원 등)
  • 기각: Newtonsoft.Json 기본 채택
  • 기각 이유: 기본 Unity 번들 의존성 최소. Newtonsoft는 선택 패키지이며 모든 PC에 설치 보장이 어려움. 고급 케이스는 사용자 측 커스텀 파싱으로 우회 가능. 추후 필요 시 FromJsonNewtonsoft 보조 진입점을 옵션 모듈로 추가.

6-7. 3종의 공통 Assembly vs 모듈별 asmdef

  • 채택: 단일 BT.Framework.asmdef 유지 (현재 구조)
  • 기각: Core.Event.asmdef / Core.Container.asmdef / Core.Data.asmdef 분리
  • 기각 이유: 분리는 순환 차단에 이점이 있으나 Tier 1 총 파일 수가 아직 작고, Unity 패키지 소비자가 단일 참조로 쓰기 편하다. 본 설계의 DAG가 명확하므로 분리 필요성이 낮음. Tier 2 모듈이 확장되어 부분 사용 요구가 커지면 재검토.

7. 변경 이력

일시 변경 주체
2026-04-17 v1 초안 작성 (PD님 #36 즉시 수행 지시) 개발팀장