14 KiB
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.Framework내BurningTimes.Core.Event·BurningTimes.Core.Container·BurningTimes.Core.Data3개 모듈의 공개 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·사운드·업적이 동시에 반응 | Event — PlayerDiedEvent struct 발행 |
| 인벤토리 리스트가 바뀌면 UI가 슬롯을 다시 그림 | Container — ObservableList<ItemStack> 변경 이벤트 구독 |
몬스터 ID로 MonsterDef 레코드 조회 |
Data — DataTable<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 분리 시:
Event→Container·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·UniObserverList3종이 본 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)/ClearObservableQueue: 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훅. - 기각:
CsvHelperNuGet 패키지 도입 - 기각 이유: 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 즉시 수행 지시) | 개발팀장 |