280 lines
14 KiB
Markdown
280 lines
14 KiB
Markdown
|
|
# 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<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 반영 (구현 가이드)
|
||
|
|
- `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<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도 허용.
|
||
|
|
- 문자열 키 버전은 `NerdNavis.Core.Event.Raw.RawEventBus` 하위 네임스페이스에 분리 (특수 용도).
|
||
|
|
|
||
|
|
### 3-2. `ObservableList<T>` / `ObservableDictionary<TKey,TValue>` / `ObservableQueue<T>` (Container)
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace NerdNavis.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)
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace NerdNavis.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. 마스터 테이블 로드 후 이벤트 통지
|
||
|
|
```csharp
|
||
|
|
// 부트 코드
|
||
|
|
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차 전파
|
||
|
|
```csharp
|
||
|
|
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
|
||
|
|
- **채택**: 단일 `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 즉시 수행 지시) | 개발팀장 |
|