# 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 추출 대상) > **적용 범위**: `NerdNavis.Framework` 내 `NerdNavis.Core.Event`·`NerdNavis.Core.Container`·`NerdNavis.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. `NerdNavis.Core.Event` — 타입 안전 **프로세스 내 이벤트 버스** - 목적: 서로 모르는 객체들 사이의 **1:N 비동기 통지** (느슨한 결합) - 스케일: "전역 관심사"를 선언한 쪽이 Publish, 듣고 싶은 쪽이 Subscribe - 특징: 이벤트를 **타입**(struct 또는 class)으로 식별, 문자열 키 아님 - 비목적: 객체 내부 상태 변경 알림(이건 Container의 책임), 파일 I/O·네트워크(상위 Tier) ### 1-2. `NerdNavis.Core.Container` — **관찰 가능한 자료구조** - 목적: **하나의 컬렉션의 변경을 구독자가 추적**할 수 있는 List/Dictionary/Queue - 스케일: 단일 컬렉션 인스턴스의 내부 변경 알림 (로컬) - 특징: 변경이 발생한 **자기 자신**이 이벤트를 직접 발행(인스턴스 이벤트, 전역 버스 아님) - 비목적: 전역 이벤트 전파(이건 Event의 책임), 영속화 ### 1-3. `NerdNavis.Core.Data` — **마스터 테이블 로더** - 목적: 기획·밸런싱 정적 데이터(CSV·JSON)를 런타임에서 **타입 안전하게 조회** - 스케일: 애플리케이션 수명 동안 보통 1회 로드, 키 기반 조회 빈번 - 특징: `DataTable`는 읽기 전용 뷰 + 인덱스, `DataTableSO`는 Unity ScriptableObject 래퍼 - 비목적: 유저 세이브 데이터(Tier 2 Save), 런타임 쓰기(관찰 컬렉션은 Container) ### 1-4. 경계 판정 기준 (혼동 방지) | 상황 | 어느 모듈을 쓸 것인가 | |------|---------------------| | "플레이어 사망"을 UI·사운드·업적이 동시에 반응 | **Event** — `PlayerDiedEvent` struct 발행 | | 인벤토리 리스트가 바뀌면 UI가 슬롯을 다시 그림 | **Container** — `ObservableList` 변경 이벤트 구독 | | 몬스터 ID로 `MonsterDef` 레코드 조회 | **Data** — `DataTable.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 반영 (구현 가이드) - `NerdNavis.Framework.asmdef` 단일 asmdef 유지 (현재 구조) - 만약 장래에 모듈별 asmdef 분리 시: `Event` → `Container`·`Data` 순서로 의존성 선언 --- ## 3. 공개 API 경계 (implementation contract) ### 3-1. `EventBus` (Event) ```csharp namespace NerdNavis.Core.Event { public static class EventBus { public static void Subscribe(Action handler); public static void Unsubscribe(Action handler); public static void Publish(TEvent e); public static void Clear(); // 테스트·씬 전환용 public static void ClearAll(); // 테스트·애플리케이션 종료용 } } ``` **계약**: - 스레드 안전은 **메인 스레드 전제** (Unity 표준). 멀티스레드는 Tier 3 Network 도입 시 재검토. - `Publish` 중 핸들러가 예외를 던져도 나머지 구독자는 호출 보장(catch-and-log). - `Publish` 중 발생한 Subscribe/Unsubscribe는 **다음 Publish에 반영** (iteration snapshot 방식). - 이벤트 타입은 struct 권장(박싱 회피). class도 허용. - 문자열 키 버전은 `NerdNavis.Core.Event.Raw.RawEventBus` 하위 네임스페이스에 분리 (특수 용도). ### 3-2. `ObservableList` / `ObservableDictionary` / `ObservableQueue` (Container) ```csharp namespace NerdNavis.Core.Container { public class ObservableList : IList, IReadOnlyList { public event Action Added; // (index, item) public event Action Removed; // (index, item) public event Action Reset; // Clear·대량 교체 // 표준 IList 멤버... } public class ObservableDictionary : IDictionary { public event Action Added; public event Action Removed; public event Action Updated; // (key, oldValue, newValue) public event Action Reset; // 표준 IDictionary 멤버... } public class ObservableQueue : IReadOnlyCollection { public event Action Enqueued; public event Action 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` / `DataTableSO` (Data) ```csharp namespace NerdNavis.Core.Data { public interface IDataRow { TKey Key { get; } } public class DataTable where TRow : IDataRow { public IReadOnlyList 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 rows); public static DataTable FromCsv(string csvText, Func rowFactory); public static DataTable FromJson(string jsonText); } public abstract class DataTableSO : ScriptableObject where TRow : IDataRow { public DataTable Table { get; } public void Load(); // 런타임 초기화 훅 } // 표준 이벤트 (Event 모듈과 연동) public readonly struct DataTableLoadedEvent { public readonly Type TableType; public readonly int RowCount; } } ``` **계약**: - `DataTable`는 **불변 스냅샷** (로드 후 Rows 변경 불가). 변경이 필요하면 `Container` 사용. - 로딩 완료 통지는 사용자 선택 — 필요 시 `EventBus.Publish(new DataTableLoadedEvent { ... })`. - CSV 파싱 기본은 최소 구현(쉼표·따옴표 이스케이프만). 고도화 필요 시 `CustomParser` 훅. - Editor 측 CSV/Excel 컨버터는 `Editor/Data/`로 분리(§01 §2 구조). 런타임은 파싱된 결과만 소비. --- ## 4. 상호작용 시나리오 (설계 검증) ### 4-1. 마스터 테이블 로드 후 이벤트 통지 ```csharp // 부트 코드 var monsterTable = DataTable.FromJson(File.ReadAllText(path)); DataRegistry.Register(monsterTable); EventBus.Publish(new DataTableLoadedEvent { TableType = typeof(MonsterDef), RowCount = monsterTable.Count }); // 수신 쪽 (UI·사운드·스폰 등) EventBus.Subscribe(evt => { if (evt.TableType == typeof(MonsterDef)) RefreshMonsterList(); }); ``` - Data가 Event를 **쓰지만 의존하지 않는다**: 위 Publish 코드는 "사용자 코드" 영역. DataTable 자체는 Event 없이도 완결. ### 4-2. 관찰 컬렉션 변경을 전역에 2차 전파 ```csharp var inventory = new ObservableList(); inventory.Added += (idx, item) => EventBus.Publish(new InventoryItemAddedEvent { Item = item }); ``` - Container는 자기 이벤트만 내고, **사용자가 선택적으로** EventBus에 연결. Container는 Event를 import조차 하지 않음. ### 4-3. Data는 로드 후 Container로 "뷰"를 제공하지 않는다 - `DataTable`은 읽기 전용 정적 데이터용, `Container`는 런타임 가변 상태용. 서로 역할이 겹치지 않음. - 잘못된 예: `ObservableList`로 마스터 테이블을 담는 것 → 마스터는 불변이므로 `DataTable`이 정답. --- ## 5. 검증 방법 (테스트 설계) ### 5-1. Event - Subscribe/Publish/Unsubscribe 기본 플로우 - 다중 구독자 호출 순서(등록 순) - Publish 중 예외 발생해도 나머지 호출 보장 - Publish 중 Subscribe가 현재 iteration에 포함되지 않음 - `Clear()`·`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()`·`ClearAll()`로 충분. IoC가 필요한 시점은 Tier 3 Network·멀티 컨텍스트 도입 시 재검토. ### 6-2. Event: 약한 참조 vs 강한 참조 - **채택**: 강한 참조(`Action` 직접 보관). 명시적 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` 제네릭 - **기각**: `DataTable` (`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 - **채택**: 단일 `NerdNavis.Framework.asmdef` 유지 (현재 구조) - **기각**: `Core.Event.asmdef` / `Core.Container.asmdef` / `Core.Data.asmdef` 분리 - **기각 이유**: 분리는 순환 차단에 이점이 있으나 Tier 1 총 파일 수가 아직 작고, Unity 패키지 소비자가 단일 참조로 쓰기 편하다. 본 설계의 DAG가 명확하므로 분리 필요성이 낮음. Tier 2 모듈이 확장되어 부분 사용 요구가 커지면 재검토. --- ## 7. 변경 이력 | 일시 | 변경 | 주체 | |------|------|------| | 2026-04-17 | v1 초안 작성 (PD님 #36 즉시 수행 지시) | 개발팀장 |