diff --git a/공유/PD_지시_트래킹/개발팀_PD_지시_로그.md b/공유/PD_지시_트래킹/개발팀_PD_지시_로그.md
index db36718..4f72e8b 100644
--- a/공유/PD_지시_트래킹/개발팀_PD_지시_로그.md
+++ b/공유/PD_지시_트래킹/개발팀_PD_지시_로그.md
@@ -31,6 +31,7 @@ C3·C13 위반에 해당. **즉시 자진 보고 후 소급 등록**.
| # | 일시 | 지시 요지 | 처리 상태 | 산출물 경로 | 중단 사유 | 사후 조치 |
|---|------|----------|----------|-----------|----------|----------|
+| 27 | 2026-04-16 | NerdNavis.Framework 코어코드를 NerdNavisAi 조직 레포에 통합 — `코어코드/NerdNavis.Framework/`에 복사, git 커밋·푸시, 대화로그·지시로그 기록 | 진행중 | - | - | - |
| 26 | 2026-04-16 | NerdNavis.Framework git 통합 관리 조치 — 저장소 상태 점검(git remote·원격 연결·코드 구조 정합성), 구현 완료/미완료 모듈 목록 정리, 설계 문서와 실제 코드 정합성 확인 후 보고서 작성 | 완료 | `공유/소통/개발팀→PM/2026-04-16_코어코드_git통합_점검_개발팀.md`, `공유/대화로그/코어프레임워크/2026-04-16.md` | - | - |
| 1 | 2026-04-14 | NerdNavisCore 타 회사 소유 전환·담당자 퇴사 사실 통보, 자체 범용 코어 신규 제작 결정 | 진행중 | `개발팀/프로젝트_숙지/수상한잡화점/06_신규코어_설계안_v1.md` (초안), `개발팀/코어_설계/01_아키텍처_개요_v1.md` (v1.2→§4-9 ServiceLocator 신설 추가), `개발팀/코어_설계/02_수상한잡화점_추출대상_v1.md` (13+ 파일 분류표), `개발팀/코어_설계/_skeleton/` (UPM 패키지 스켈레톤), **`D:/NerdNavis/NerdNavis.Framework/` 구현체 — Tier 1 기반 Core 4종 완료 (Log·CoroutineRunner·MonoSingleton·ServiceLocator + 테스트 28건) Gitea push 완료** | - | OI-1(네임스페이스 NerdNavis.*) PD님 확정 반영 완료. **OI-2·3·4·5는 여전히 PD님 판단 대기**. Tier 1+2 MVP 범위 PD님 확정 반영. **Tier 1 잔여 9종(EnumToInt/EnumEx/FormatEx/SafeAreaBorder 등) 대기** |
| 2 | 2026-04-14 | 서버 Critical 보안 3건 보류 | 보류 | `개발팀/프로젝트_숙지/수상한잡화점/05_서버연동_현황_v1.md` | 서버 파트 정비 미완료 (PD님 지시) | 서버팀 가동 시점에 블로커급 재개. 담당: 서버팀장. 재개 트리거: 서버 파트 정비 완료 통보 |
diff --git a/코어코드/NerdNavis.Framework/.gitattributes b/코어코드/NerdNavis.Framework/.gitattributes
new file mode 100644
index 0000000..16361b5
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/.gitattributes
@@ -0,0 +1,52 @@
+# 기본 텍스트 처리: 자동 개행 정규화
+* text=auto eol=lf
+
+# Windows 배치 파일만 CRLF 유지
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# C# / Unity 텍스트
+*.cs text diff=csharp
+*.asmdef text
+*.json text
+*.md text
+*.txt text
+*.xml text
+*.yml text
+*.yaml text
+
+# Unity 에셋 텍스트 포맷 (Force Text 모드 가정)
+*.unity text merge=unityyamlmerge
+*.prefab text merge=unityyamlmerge
+*.asset text merge=unityyamlmerge
+*.mat text merge=unityyamlmerge
+*.anim text merge=unityyamlmerge
+*.controller text merge=unityyamlmerge
+*.meta text merge=unityyamlmerge
+
+# 바이너리
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.psd binary
+*.tga binary
+*.tif binary
+*.tiff binary
+*.mp3 binary
+*.wav binary
+*.ogg binary
+*.mp4 binary
+*.mov binary
+*.fbx binary
+*.obj binary
+*.blend binary
+*.ttf binary
+*.otf binary
+*.dll binary
+*.so binary
+*.dylib binary
+
+# Git LFS 후보 (초기엔 비활성, 대용량 에셋 추가 시 활성화)
+# *.png filter=lfs diff=lfs merge=lfs -text
+# *.fbx filter=lfs diff=lfs merge=lfs -text
diff --git a/코어코드/NerdNavis.Framework/.gitignore b/코어코드/NerdNavis.Framework/.gitignore
new file mode 100644
index 0000000..dd637a6
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/.gitignore
@@ -0,0 +1,44 @@
+# Unity 생성물 (패키지에는 포함되지 않음)
+[Ll]ibrary/
+[Tt]emp/
+[Oo]bj/
+[Bb]uild/
+[Bb]uilds/
+[Ll]ogs/
+[Mm]emoryCaptures/
+
+# Unity 메타 캐시
+*.pidb.meta
+*.pdb.meta
+*.mdb.meta
+
+# Visual Studio / Rider
+.vs/
+.idea/
+*.csproj
+*.sln
+*.suo
+*.user
+*.userprefs
+*.pidb
+*.booproj
+*.svd
+*.pdb
+*.mdb
+*.opendb
+*.VC.db
+
+# OS
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# 빌드 산출물
+*.apk
+*.aab
+*.ipa
+*.unitypackage
+
+# 임시 / 로그
+*.log
+*.tmp
diff --git a/코어코드/NerdNavis.Framework/CHANGELOG.md b/코어코드/NerdNavis.Framework/CHANGELOG.md
new file mode 100644
index 0000000..ccd4abe
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Changelog
+
+이 프로젝트의 모든 주요 변경 사항은 이 파일에 기록한다.
+
+포맷은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르고, 버저닝은 [Semantic Versioning](https://semver.org/lang/ko/)을 따른다.
+
+## [Unreleased]
+
+### Added
+- 패키지 스켈레톤 (폴더 구조, asmdef, package.json)
+
+## [0.1.0] - TBD
+
+최초 릴리즈 예정.
diff --git a/코어코드/NerdNavis.Framework/Documentation~/.gitkeep b/코어코드/NerdNavis.Framework/Documentation~/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/코어코드/NerdNavis.Framework/Editor/NerdNavis.Framework.Editor.asmdef b/코어코드/NerdNavis.Framework/Editor/NerdNavis.Framework.Editor.asmdef
new file mode 100644
index 0000000..703ca01
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Editor/NerdNavis.Framework.Editor.asmdef
@@ -0,0 +1,18 @@
+{
+ "name": "NerdNavis.Framework.Editor",
+ "rootNamespace": "NerdNavis.Editor",
+ "references": [
+ "NerdNavis.Framework"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/코어코드/NerdNavis.Framework/README.md b/코어코드/NerdNavis.Framework/README.md
new file mode 100644
index 0000000..cbdd783
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/README.md
@@ -0,0 +1,52 @@
+# NerdNavis.Framework
+
+너드나비스 자체 범용 Unity 프레임워크.
+
+## 개요
+
+기존 외부 의존 코어(`NerdNavisCore`)가 이전·퇴사로 사용 불가해짐에 따라, 너드나비스가 자체적으로 보유·유지하는 범용 코어를 새로 구축한다. 수상한 잡화점 등 사내 프로젝트에서 반복되는 패턴을 Tier 단위로 흡수하여 차기 프로젝트부터 바로 활용 가능한 형태로 제공한다.
+
+## 설치 (Unity Package Manager)
+
+```
+https://burning.i234.me/NerdNavis/NerdNavis.Framework.git
+```
+
+Unity 에디터 → Package Manager → `+` → **Add package from git URL...** → 위 URL 입력.
+
+특정 버전 고정:
+```
+https://burning.i234.me/NerdNavis/NerdNavis.Framework.git#v0.1.0
+```
+
+## 폴더 구조
+
+```
+Runtime/
+├── Core/
+│ ├── Patterns/ # MonoSingleton 등
+│ ├── Coroutine/ # CoroutineRunner
+│ └── Util/ # ValidationEx, ObjectEx, FormatEx, EnumEx, EnumToInt, Log
+├── UI/
+│ ├── UGUI/ # InfiniteScrollView, SpriteAtlasRegistry, BackKeyHandler
+│ └── Components/ # SafeAreaBorder
+├── Addressable/ # AddressableHandle, AutoReleaseComponent (Tier 2)
+└── Security/ # CryptoUtil, ICryptoProvider (Tier 3)
+
+Editor/ # 에디터 전용 유틸
+Tests/ # Runtime/Editor 테스트
+Documentation~/ # Unity 임포트 제외 (~ 접두)
+```
+
+## 개발 원칙
+
+- **네이밍**: `My*`·`u*` 접두 금지, PascalCase 준수, `FilGoodBandits` → `NerdNavis.*`
+- **의존성 단절**: 프로젝트 특수 enum/테이블 참조 제거
+- **제네릭 우선**: 하드코딩 메서드는 제네릭 팩토리로 재설계
+- **싱글톤 최소화**: 필요 최소 외 DI/이벤트 기반으로 전환
+
+자세한 내용은 `개발실/코어_설계/01_아키텍처_개요_v1.md`, `02_수상한잡화점_추출대상_v1.md` 참조.
+
+## 라이선스
+
+사내 사용. 외부 배포 금지.
diff --git a/코어코드/NerdNavis.Framework/Runtime/Addressable/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/Addressable/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/코어코드/NerdNavis.Framework/Runtime/AssemblyInfo.cs b/코어코드/NerdNavis.Framework/Runtime/AssemblyInfo.cs
new file mode 100644
index 0000000..341c6ef
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/AssemblyInfo.cs
@@ -0,0 +1,9 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// AssemblyInfo.cs — 테스트 어셈블리에 internal 멤버 노출
+// ---------------------------------------------------------------------------
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("NerdNavis.Framework.Tests")]
+[assembly: InternalsVisibleTo("NerdNavis.Framework.Editor")]
+[assembly: InternalsVisibleTo("NerdNavis.Framework.Editor.Tests")]
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineHandle.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineHandle.cs
new file mode 100644
index 0000000..d727db9
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineHandle.cs
@@ -0,0 +1,36 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// CoroutineHandle.cs — CoroutineRunner 반환 핸들
+// ---------------------------------------------------------------------------
+using System;
+
+namespace NerdNavis.Core.Coroutine
+{
+ ///
+ /// 에서 발급하는 코루틴 핸들.
+ ///
+ ///
+ /// ID 기반 참조값이며 값 타입이다. 은 유효하지 않은 핸들을 의미한다.
+ /// 핸들을 보관한 쪽에서 중단·일시정지·재개 등을 요청할 때 사용한다.
+ ///
+ public readonly struct CoroutineHandle : IEquatable
+ {
+ /// 유효하지 않은 핸들.
+ public static readonly CoroutineHandle None = new CoroutineHandle(0);
+
+ /// 내부 식별 ID. 0은 유효하지 않음.
+ public readonly ulong Id;
+
+ internal CoroutineHandle(ulong id) { Id = id; }
+
+ /// 핸들이 유효한지 여부(ID가 0이 아닌지).
+ public bool IsValid => Id != 0;
+
+ public bool Equals(CoroutineHandle other) => Id == other.Id;
+ public override bool Equals(object obj) => obj is CoroutineHandle h && Equals(h);
+ public override int GetHashCode() => Id.GetHashCode();
+ public static bool operator ==(CoroutineHandle a, CoroutineHandle b) => a.Id == b.Id;
+ public static bool operator !=(CoroutineHandle a, CoroutineHandle b) => a.Id != b.Id;
+ public override string ToString() => IsValid ? $"Coroutine#{Id}" : "Coroutine#None";
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineRunner.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineRunner.cs
new file mode 100644
index 0000000..ed05180
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineRunner.cs
@@ -0,0 +1,213 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// CoroutineRunner.cs — 일시정지·재개·키 중복방지를 지원하는 전역 코루틴 러너
+// ---------------------------------------------------------------------------
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+using NerdNavis.Core.Util.Log;
+
+namespace NerdNavis.Core.Coroutine
+{
+ ///
+ /// 전역에서 사용 가능한 코루틴 러너.
+ ///
+ ///
+ /// 기존 NerdNavisCore의 CoroutineHandler와 CoroutineRunner 2종을 하나로 통합했다.
+ /// 수상한 잡화점의 MyCoroutine도 이 러너로 흡수된다.
+ /// 호스트는 내부 로,
+ /// DontDestroyOnLoad 처리된다. 첫 호출 시점에 지연 생성되며, 씬 전환과 무관하게 유지된다.
+ /// 주요 기능:
+ ///
+ /// - 핸들 기반 중단 / 일시정지 / 재개
+ /// - 문자열 키 기반 중복 정책 (Replace/Ignore/Allow)
+ /// - 전체 중단
+ ///
+ ///
+ public static class CoroutineRunner
+ {
+ private const string LogCategory = "Coroutine";
+
+ private static CoroutineHost _host;
+ private static ulong _nextId = 1;
+ private static readonly Dictionary _entries = new Dictionary();
+ private static readonly Dictionary _keyToId = new Dictionary();
+
+ // -----------------------------------------------------------------
+ // 공개 API — 단순 시작
+ // -----------------------------------------------------------------
+
+ /// 코루틴을 시작한다.
+ /// 실행할 .
+ /// 중단·제어에 사용하는 핸들. 이 null이면 .
+ public static CoroutineHandle Start(IEnumerator routine)
+ => StartInternal(routine, key: null);
+
+ /// 문자열 키로 코루틴을 시작한다. 동일 키 실행 중이면 에 따라 처리.
+ public static CoroutineHandle Start(string key, IEnumerator routine,
+ DuplicatePolicy policy = DuplicatePolicy.Replace)
+ {
+ if (routine == null) return CoroutineHandle.None;
+ if (string.IsNullOrEmpty(key)) return StartInternal(routine, key: null);
+
+ if (_keyToId.TryGetValue(key, out var existingId))
+ {
+ switch (policy)
+ {
+ case DuplicatePolicy.Ignore:
+ return new CoroutineHandle(existingId);
+ case DuplicatePolicy.Replace:
+ StopInternal(existingId);
+ break;
+ case DuplicatePolicy.Allow:
+ // 기존 유지, 새 항목은 키 등록하지 않음.
+ return StartInternal(routine, key: null);
+ }
+ }
+ return StartInternal(routine, key);
+ }
+
+ // -----------------------------------------------------------------
+ // 공개 API — 제어
+ // -----------------------------------------------------------------
+
+ /// 핸들로 실행 중인 코루틴을 중단한다.
+ public static void Stop(CoroutineHandle handle)
+ {
+ if (!handle.IsValid) return;
+ StopInternal(handle.Id);
+ }
+
+ /// 키로 실행 중인 코루틴을 중단한다.
+ public static void StopByKey(string key)
+ {
+ if (string.IsNullOrEmpty(key)) return;
+ if (_keyToId.TryGetValue(key, out var id)) StopInternal(id);
+ }
+
+ /// 실행 중인 모든 코루틴을 중단한다.
+ public static void StopAll()
+ {
+ if (_host == null) return;
+ _host.StopAllCoroutines();
+ _entries.Clear();
+ _keyToId.Clear();
+ }
+
+ /// 핸들로 일시정지. 재개 전까지 yield return 이 소비되지 않는다.
+ public static void Pause(CoroutineHandle handle)
+ {
+ if (_entries.TryGetValue(handle.Id, out var e)) e.IsPaused = true;
+ }
+
+ /// 핸들로 재개.
+ public static void Resume(CoroutineHandle handle)
+ {
+ if (_entries.TryGetValue(handle.Id, out var e)) e.IsPaused = false;
+ }
+
+ /// 핸들이 현재 실행 중인지 조회.
+ public static bool IsRunning(CoroutineHandle handle)
+ => handle.IsValid && _entries.ContainsKey(handle.Id);
+
+ /// 키가 현재 실행 중인지 조회.
+ public static bool IsRunningByKey(string key)
+ => !string.IsNullOrEmpty(key) && _keyToId.ContainsKey(key);
+
+ /// 핸들에 해당하는 코루틴의 일시정지 여부.
+ public static bool IsPaused(CoroutineHandle handle)
+ => _entries.TryGetValue(handle.Id, out var e) && e.IsPaused;
+
+ // -----------------------------------------------------------------
+ // 내부
+ // -----------------------------------------------------------------
+
+ private static CoroutineHandle StartInternal(IEnumerator routine, string key)
+ {
+ if (routine == null) return CoroutineHandle.None;
+
+ EnsureHost();
+
+ var id = _nextId++;
+ if (_nextId == 0) _nextId = 1; // 0은 None 예약
+
+ var entry = new Entry { Id = id, Source = routine, Key = key, IsPaused = false };
+ _entries[id] = entry;
+ if (!string.IsNullOrEmpty(key)) _keyToId[key] = id;
+
+ entry.UnityCoroutine = _host.StartCoroutine(Wrap(entry));
+ return new CoroutineHandle(id);
+ }
+
+ private static void StopInternal(ulong id)
+ {
+ if (!_entries.TryGetValue(id, out var entry)) return;
+ if (_host != null && entry.UnityCoroutine != null)
+ _host.StopCoroutine(entry.UnityCoroutine);
+
+ _entries.Remove(id);
+ if (entry.Key != null) _keyToId.Remove(entry.Key);
+ }
+
+ private static IEnumerator Wrap(Entry entry)
+ {
+ var source = entry.Source;
+ while (true)
+ {
+ // 일시정지면 프레임 단위로 대기
+ while (entry.IsPaused) yield return null;
+
+ bool moved;
+ try { moved = source.MoveNext(); }
+ catch (System.Exception ex)
+ {
+ Log.Error(LogCategory, $"coroutine threw (id={entry.Id}, key={entry.Key ?? ""})", ex);
+ break;
+ }
+ if (!moved) break;
+ yield return source.Current;
+ }
+
+ // 정상 종료·예외 종료 공통 정리
+ _entries.Remove(entry.Id);
+ if (entry.Key != null && _keyToId.TryGetValue(entry.Key, out var kid) && kid == entry.Id)
+ _keyToId.Remove(entry.Key);
+ }
+
+ private static void EnsureHost()
+ {
+ if (_host != null) return;
+ var go = new GameObject("[NerdNavis.CoroutineHost]");
+ Object.DontDestroyOnLoad(go);
+ go.hideFlags = HideFlags.HideAndDontSave;
+ _host = go.AddComponent();
+ }
+
+ /// 테스트 전용: 내부 상태 초기화.
+ internal static void ResetForTests()
+ {
+ if (_host != null)
+ {
+ _host.StopAllCoroutines();
+ Object.Destroy(_host.gameObject);
+ _host = null;
+ }
+ _entries.Clear();
+ _keyToId.Clear();
+ _nextId = 1;
+ }
+
+ // 내부 엔트리 — 클래스로 선언하여 Wrap 람다와 dictionary가 같은 참조를 공유하도록 함
+ private sealed class Entry
+ {
+ public ulong Id;
+ public IEnumerator Source;
+ public string Key;
+ public bool IsPaused;
+ public UnityEngine.Coroutine UnityCoroutine;
+ }
+
+ /// 전역 코루틴 호스트.
+ private sealed class CoroutineHost : MonoBehaviour { }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/DuplicatePolicy.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/DuplicatePolicy.cs
new file mode 100644
index 0000000..1341126
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/DuplicatePolicy.cs
@@ -0,0 +1,22 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// DuplicatePolicy.cs — 키 기반 코루틴 중복 정책
+// ---------------------------------------------------------------------------
+namespace NerdNavis.Core.Coroutine
+{
+ ///
+ /// 동일 키로
+ /// 가 호출됐을 때 기존 실행 중인 코루틴을 어떻게 처리할지 정의한다.
+ ///
+ public enum DuplicatePolicy
+ {
+ /// 기존 코루틴을 중단하고 새로 시작한다(기본값).
+ Replace = 0,
+
+ /// 기존 코루틴을 유지하고 새 요청은 무시한다.
+ Ignore = 1,
+
+ /// 기존 코루틴을 유지하고 중복 시작을 허용한다(키 공유, 핸들만 다름).
+ Allow = 2,
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/InitMode.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/InitMode.cs
new file mode 100644
index 0000000..a04fec1
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/InitMode.cs
@@ -0,0 +1,30 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// InitMode.cs — MonoSingleton 초기화 모드
+// ---------------------------------------------------------------------------
+namespace NerdNavis.Core.Patterns
+{
+ ///
+ /// 의 초기화 모드.
+ ///
+ public enum InitMode
+ {
+ ///
+ /// 가 Awake에서 즉시 실행되고
+ /// 는 그 직후 true가 된다(기본값).
+ ///
+ Sync = 0,
+
+ ///
+ /// 가 코루틴으로 실행되며
+ /// 완료 시 가 true로 전환된다.
+ ///
+ Async = 1,
+
+ ///
+ /// 구현체가 를 직접 호출할 때까지
+ /// 는 false를 유지한다.
+ ///
+ ManualReady = 2,
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/MonoSingleton.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/MonoSingleton.cs
new file mode 100644
index 0000000..972efd7
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/MonoSingleton.cs
@@ -0,0 +1,190 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// MonoSingleton.cs — 4종 통합(Sync/Async/Inner/Ready) MonoBehaviour 싱글톤
+// ---------------------------------------------------------------------------
+using System.Collections;
+using UnityEngine;
+using NerdNavis.Core.Coroutine;
+using NerdNavis.Core.Util.Log;
+
+namespace NerdNavis.Core.Patterns
+{
+ ///
+ /// 단일 인스턴스 베이스 클래스.
+ ///
+ ///
+ /// 기존 NerdNavisCore의 4종(Singleton, AsyncSingleton, InnerSingleton,
+ /// ReadySingleton)을 통합했다(설계 문서 §4-1). 옵션은 방식이 아닌
+ /// 가상 프로퍼티로 노출하여 런타임 리플렉션 비용을 제거했다.
+ /// 동작 규칙:
+ ///
+ /// - 는 씬에 있으면 찾고, 없으면 판단 후 생성.
+ /// - 이면 DontDestroyOnLoad 적용.
+ /// - 에 따라 /
+ /// / 중 하나로 준비 상태가 결정된다.
+ /// - 중복 인스턴스가 발견되면 나중 것이 파괴된다( 훅).
+ /// - 애플리케이션 종료 후 접근은 null을 반환한다(Unity destroyed object 회피).
+ ///
+ /// 주의: Unity 메인 스레드에서만 접근한다. 다른 스레드 호출은 지원하지 않는다.
+ ///
+ public abstract class MonoSingleton : MonoBehaviour where T : MonoSingleton
+ {
+ private const string LogCategory = "Singleton";
+
+ private static T _instance;
+ private static bool _applicationIsQuitting;
+ private static readonly object _gate = new object();
+
+ ///
+ /// 싱글톤 인스턴스. 없으면 규칙으로 자동 생성 시도.
+ /// 애플리케이션 종료 중이면 null을 반환한다.
+ ///
+ public static T Instance
+ {
+ get
+ {
+ if (_applicationIsQuitting) return null;
+ if (_instance != null) return _instance;
+
+ lock (_gate)
+ {
+ if (_instance != null) return _instance;
+
+ // 씬 검색
+ var found = FindAnyObjectByType(FindObjectsInactive.Include);
+ if (found != null)
+ {
+ _instance = found;
+ _instance.ApplyPersistence();
+ return _instance;
+ }
+
+ // 자동 생성 판정 — 프로토타입 인스턴스로 AutoCreate 값만 읽음
+ // AutoCreate=false 이면 외부가 GameObject에 붙여야 한다(InnerSingleton 대응)
+ var probe = new GameObject(typeof(T).Name + "_probe").AddComponent();
+ bool autoCreate = probe.AutoCreate;
+ if (!autoCreate)
+ {
+ Destroy(probe.gameObject);
+ Log.Warn(LogCategory,
+ $"{typeof(T).Name}: AutoCreate=false, 씬에 인스턴스가 없어 null 반환");
+ return null;
+ }
+
+ // probe 가 이미 Awake를 통해 _instance 에 등록되었을 것
+ probe.gameObject.name = typeof(T).Name;
+ return _instance;
+ }
+ }
+ }
+
+ /// 싱글톤이 이미 존재하거나 조회 가능한지 확인(자동 생성 트리거 없음).
+ public static bool Exists => !_applicationIsQuitting && _instance != null;
+
+ /// 초기화 완료 여부. 에 따라 결정된다.
+ public static bool IsReady { get; private set; }
+
+ // -----------------------------------------------------------------
+ // 옵션 (파생에서 override)
+ // -----------------------------------------------------------------
+
+ /// 씬 전환에도 파괴되지 않을지 여부. 기본 true.
+ protected virtual bool Persistent => true;
+
+ /// 인스턴스 부재 시 자동 생성 여부. 기본 true.
+ protected virtual bool AutoCreate => true;
+
+ /// 초기화 모드. 기본 .
+ protected virtual InitMode InitMode => InitMode.Sync;
+
+ // -----------------------------------------------------------------
+ // 라이프사이클 훅 (파생에서 override)
+ // -----------------------------------------------------------------
+
+ /// 에서 Awake 시 호출된다.
+ protected virtual void OnInitialized() { }
+
+ /// 에서 코루틴으로 실행된다. 종료 시점에 가 true.
+ protected virtual IEnumerator OnInitializeAsync() { yield break; }
+
+ /// 중복 인스턴스가 파괴될 때 마지막 호출. 리소스 정리 훅.
+ protected virtual void OnDuplicateDestroyed() { }
+
+ /// 에서 구현체가 호출하여 준비 상태로 전환.
+ protected void MarkReady() { IsReady = true; }
+
+ // -----------------------------------------------------------------
+ // Unity 라이프사이클
+ // -----------------------------------------------------------------
+
+ protected virtual void Awake()
+ {
+ if (_instance != null && _instance != this)
+ {
+ Log.Warn(LogCategory,
+ $"{typeof(T).Name}: 중복 인스턴스 감지, 이번 GameObject 파괴");
+ OnDuplicateDestroyed();
+ Destroy(gameObject);
+ return;
+ }
+
+ _instance = (T)this;
+ ApplyPersistence();
+
+ switch (InitMode)
+ {
+ case InitMode.Sync:
+ OnInitialized();
+ IsReady = true;
+ break;
+ case InitMode.Async:
+ CoroutineRunner.Start(InitializeAsyncWrapper());
+ break;
+ case InitMode.ManualReady:
+ // 구현체가 MarkReady 호출할 때까지 대기
+ break;
+ }
+ }
+
+ private IEnumerator InitializeAsyncWrapper()
+ {
+ yield return OnInitializeAsync();
+ IsReady = true;
+ }
+
+ protected virtual void OnDestroy()
+ {
+ if (_instance == this)
+ {
+ _instance = null;
+ IsReady = false;
+ }
+ }
+
+ protected virtual void OnApplicationQuit()
+ {
+ _applicationIsQuitting = true;
+ }
+
+ // -----------------------------------------------------------------
+ // 공용 유틸
+ // -----------------------------------------------------------------
+
+ private void ApplyPersistence()
+ {
+ if (!Persistent) return;
+ if (transform.parent != null) transform.SetParent(null, true); // DontDestroyOnLoad는 루트 객체에만 적용
+ DontDestroyOnLoad(gameObject);
+ }
+
+ /// 테스트 전용: 정적 상태 초기화.
+ internal static void ResetForTests()
+ {
+ if (_instance != null && _instance.gameObject != null)
+ Object.DestroyImmediate(_instance.gameObject);
+ _instance = null;
+ IsReady = false;
+ _applicationIsQuitting = false;
+ }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceLocator.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceLocator.cs
new file mode 100644
index 0000000..9c9dce8
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceLocator.cs
@@ -0,0 +1,161 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// ServiceLocator.cs — 경량 서비스 레지스트리 (순수 C#, MonoBehaviour 비의존)
+// ---------------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using NerdNavis.Core.Util.Log;
+
+namespace NerdNavis.Core.Patterns
+{
+ ///
+ /// 타입 키 기반 경량 서비스 레지스트리.
+ ///
+ ///
+ /// 설계 문서 §4-9 (v1.2 신설)에 따른 구현. MonoBehaviour와 무관한 순수 C# 서비스
+ /// 의 중앙 레지스트리로, MonoSingleton(씬 생명주기 동반)과 EventBus(이벤트 분기)와 역할이
+ /// 분리된 3축의 한 축을 담당한다.
+ /// 용도:
+ ///
+ /// - 인터페이스 기반 느슨한 결합 — ISaveProvider 등으로 등록 후 구현 교체 용이
+ /// - 테스트 시 Mock 주입 — 로 전역 초기화, Mock
+ /// - Lazy 생성 — 로 첫 시 생성
+ ///
+ /// 주의 규칙 (§4-9):
+ ///
+ /// - 인터페이스 타입 등록 권장, 구체 타입 강결합 회피
+ /// - 글로벌 서비스만 등록. 씬 전환 시 스코프가 필요하면 MonoSingleton 사용
+ /// - 실패 시 — silent null 금지
+ /// - 코어 자체는 ServiceLocator에 의존하지 않음(순환 방지)
+ ///
+ ///
+ public static class ServiceLocator
+ {
+ private const string LogCategory = "ServiceLocator";
+
+ private static readonly object _gate = new object();
+ private static readonly Dictionary _instances = new Dictionary();
+ private static readonly Dictionary> _factories = new Dictionary>();
+
+ // -----------------------------------------------------------------
+ // 등록
+ // -----------------------------------------------------------------
+
+ ///
+ /// 서비스 인스턴스를 등록한다. 동일 타입 재등록은 덮어쓴다(경고 로그).
+ ///
+ /// 가 null.
+ public static void Register(T service) where T : class
+ {
+ if (service == null) throw new ArgumentNullException(nameof(service));
+ var type = typeof(T);
+ lock (_gate)
+ {
+ if (_instances.ContainsKey(type) || _factories.ContainsKey(type))
+ Log.Warn(LogCategory, $"Register<{type.Name}>: 기존 바인딩을 덮어쓴다");
+ _instances[type] = service;
+ _factories.Remove(type);
+ }
+ }
+
+ ///
+ /// Lazy 팩토리를 등록한다. 첫 호출 시점에 팩토리가 실행되고,
+ /// 결과 인스턴스가 캐싱된다(이후 호출은 캐시 반환).
+ ///
+ /// 가 null.
+ public static void Register(Func factory) where T : class
+ {
+ if (factory == null) throw new ArgumentNullException(nameof(factory));
+ var type = typeof(T);
+ lock (_gate)
+ {
+ if (_instances.ContainsKey(type) || _factories.ContainsKey(type))
+ Log.Warn(LogCategory, $"Register<{type.Name}>(factory): 기존 바인딩을 덮어쓴다");
+ _instances.Remove(type);
+ _factories[type] = () => factory();
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // 조회
+ // -----------------------------------------------------------------
+
+ /// 등록된 서비스를 조회한다. 미등록이면 예외.
+ /// 해당 타입이 등록되지 않음.
+ public static T Resolve() where T : class
+ {
+ if (TryResolve(out var service)) return service;
+ throw new ServiceNotRegisteredException(typeof(T));
+ }
+
+ /// 등록된 서비스를 조회한다. 미등록이면 false.
+ public static bool TryResolve(out T service) where T : class
+ {
+ var type = typeof(T);
+ lock (_gate)
+ {
+ if (_instances.TryGetValue(type, out var cached))
+ {
+ service = cached as T;
+ return service != null;
+ }
+ if (_factories.TryGetValue(type, out var factory))
+ {
+ object created;
+ try { created = factory(); }
+ catch (Exception ex)
+ {
+ Log.Error(LogCategory, $"factory for {type.Name} threw", ex);
+ service = null;
+ return false;
+ }
+ if (created is T typed)
+ {
+ _instances[type] = typed; // 캐시
+ _factories.Remove(type);
+ service = typed;
+ return true;
+ }
+ Log.Error(LogCategory,
+ $"factory for {type.Name} returned incompatible type {created?.GetType().Name ?? ""}");
+ service = null;
+ return false;
+ }
+ }
+ service = null;
+ return false;
+ }
+
+ /// 해당 타입이 등록되어 있는지 조회(팩토리 포함).
+ public static bool IsRegistered() where T : class
+ {
+ var type = typeof(T);
+ lock (_gate) { return _instances.ContainsKey(type) || _factories.ContainsKey(type); }
+ }
+
+ // -----------------------------------------------------------------
+ // 해제
+ // -----------------------------------------------------------------
+
+ /// 해당 타입의 서비스 등록을 해제한다.
+ public static void Unregister() where T : class
+ {
+ var type = typeof(T);
+ lock (_gate)
+ {
+ _instances.Remove(type);
+ _factories.Remove(type);
+ }
+ }
+
+ /// 모든 등록을 해제한다(테스트 용도).
+ public static void Clear()
+ {
+ lock (_gate)
+ {
+ _instances.Clear();
+ _factories.Clear();
+ }
+ }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceNotRegisteredException.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceNotRegisteredException.cs
new file mode 100644
index 0000000..9798a22
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceNotRegisteredException.cs
@@ -0,0 +1,26 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// ServiceNotRegisteredException.cs — ServiceLocator 조회 실패 예외
+// ---------------------------------------------------------------------------
+using System;
+
+namespace NerdNavis.Core.Patterns
+{
+ ///
+ /// 호출 시 해당 타입이 등록되지 않은 경우 발생.
+ ///
+ ///
+ /// silent null 반환을 금지하는 설계 원칙(§4-9 규칙 3)에 따라 명시적으로 예외를 던진다.
+ /// 실패를 허용하는 호출부는 를 사용한다.
+ ///
+ public sealed class ServiceNotRegisteredException : Exception
+ {
+ public Type ServiceType { get; }
+
+ public ServiceNotRegisteredException(Type serviceType)
+ : base($"Service '{serviceType?.FullName ?? ""}' is not registered in ServiceLocator.")
+ {
+ ServiceType = serviceType;
+ }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Util/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/Core/Util/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/ILogSink.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/ILogSink.cs
new file mode 100644
index 0000000..a17013e
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/ILogSink.cs
@@ -0,0 +1,30 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// ILogSink.cs — 외부 로그 수신자 인터페이스
+// ---------------------------------------------------------------------------
+using System;
+
+namespace NerdNavis.Core.Util.Log
+{
+ ///
+ /// 로그 이벤트를 외부로 전달할 때 사용하는 sink 인터페이스.
+ /// 크래시 리포터·파일 로거·원격 서버 전송 등을 구현체로 등록할 수 있다.
+ ///
+ ///
+ /// 기존 ErrorLogHookManager의 역할을 인터페이스로 추상화한 형태다.
+ /// 는 등록된 모든 sink에 순차적으로 로그를 전달한다.
+ /// sink 구현은 예외를 내부에서 삼켜야 하며(Log 본체로 재귀하지 않도록),
+ /// 무거운 I/O는 비동기로 처리하도록 권장한다.
+ ///
+ public interface ILogSink
+ {
+ ///
+ /// 로그 이벤트가 발생했을 때 호출된다.
+ ///
+ /// 로그 레벨.
+ /// 로그 카테고리(프로젝트가 자유 정의).
+ /// 포맷팅이 끝난 최종 메시지.
+ /// 예외 객체(없으면 null).
+ void Emit(LogLevel level, string category, string message, Exception exception);
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/Log.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/Log.cs
new file mode 100644
index 0000000..c66cf33
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/Log.cs
@@ -0,0 +1,193 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// Log.cs — 카테고리·레벨 필터 지원 중앙 로거
+// ---------------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+using UnityEngine;
+using UnityDebug = UnityEngine.Debug;
+
+namespace NerdNavis.Core.Util.Log
+{
+ ///
+ /// 너드나비스 프레임워크의 중앙 로거.
+ ///
+ ///
+ /// 특징:
+ ///
+ /// - 레벨 필터: 미만은 출력하지 않는다.
+ /// - 카테고리 필터: / 로 제어.
+ /// - 외부 sink: 로 파일·원격·크래시리포터 연동.
+ /// - 릴리즈 스트리핑: / 는
+ /// UNITY_EDITOR·DEVELOPMENT_BUILD·NERDNAVIS_LOG_VERBOSE 중
+ /// 하나라도 정의돼야 호출부에 남는다().
+ ///
+ /// 설계 원칙(C11): 카테고리 문자열은 프로젝트 쪽에서 자유 정의하며
+ /// 코어에는 특정 게임의 카테고리를 하드코딩하지 않는다.
+ ///
+ public static class Log
+ {
+ // -----------------------------------------------------------------
+ // 상태
+ // -----------------------------------------------------------------
+
+ private static readonly object _gate = new object();
+ private static readonly List _sinks = new List();
+ private static readonly HashSet _disabledCategories =
+ new HashSet(StringComparer.Ordinal);
+
+ ///
+ /// 출력되는 최소 레벨. 이 값 미만의 로그는 차단된다.
+ /// 기본값은 에디터/개발빌드에서는 ,
+ /// 릴리즈에서는 .
+ ///
+ public static LogLevel MinLevel { get; set; }
+#if UNITY_EDITOR || DEVELOPMENT_BUILD
+ = LogLevel.Verbose;
+#else
+ = LogLevel.Warn;
+#endif
+
+ ///
+ /// 메시지 앞에 카테고리 태그를 붙일지 여부.
+ ///
+ public static bool PrefixCategory { get; set; } = true;
+
+ // -----------------------------------------------------------------
+ // Sink 등록
+ // -----------------------------------------------------------------
+
+ /// 외부 sink를 등록한다.
+ public static void AddSink(ILogSink sink)
+ {
+ if (sink == null) return;
+ lock (_gate) { if (!_sinks.Contains(sink)) _sinks.Add(sink); }
+ }
+
+ /// 등록된 외부 sink를 제거한다.
+ public static void RemoveSink(ILogSink sink)
+ {
+ if (sink == null) return;
+ lock (_gate) { _sinks.Remove(sink); }
+ }
+
+ /// 모든 외부 sink를 제거한다(테스트 용도).
+ public static void ClearSinks()
+ {
+ lock (_gate) { _sinks.Clear(); }
+ }
+
+ // -----------------------------------------------------------------
+ // 카테고리 필터
+ // -----------------------------------------------------------------
+
+ /// 특정 카테고리 로그 출력을 차단한다.
+ public static void DisableCategory(string category)
+ {
+ if (string.IsNullOrEmpty(category)) return;
+ lock (_gate) { _disabledCategories.Add(category); }
+ }
+
+ /// 특정 카테고리 로그 출력을 허용한다(기본값).
+ public static void EnableCategory(string category)
+ {
+ if (string.IsNullOrEmpty(category)) return;
+ lock (_gate) { _disabledCategories.Remove(category); }
+ }
+
+ /// 모든 카테고리 필터를 초기화한다.
+ public static void ResetCategoryFilter()
+ {
+ lock (_gate) { _disabledCategories.Clear(); }
+ }
+
+ // -----------------------------------------------------------------
+ // 공용 API — Conditional 스트리핑 대상
+ // -----------------------------------------------------------------
+
+ [Conditional("UNITY_EDITOR")]
+ [Conditional("DEVELOPMENT_BUILD")]
+ [Conditional("NERDNAVIS_LOG_VERBOSE")]
+ public static void Verbose(string category, string message)
+ => Emit(LogLevel.Verbose, category, message, null);
+
+ [Conditional("UNITY_EDITOR")]
+ [Conditional("DEVELOPMENT_BUILD")]
+ [Conditional("NERDNAVIS_LOG_VERBOSE")]
+ public static void Info(string category, string message)
+ => Emit(LogLevel.Info, category, message, null);
+
+ // -----------------------------------------------------------------
+ // 공용 API — 릴리즈에서도 살아남는 레벨
+ // -----------------------------------------------------------------
+
+ public static void Warn(string category, string message)
+ => Emit(LogLevel.Warn, category, message, null);
+
+ public static void Error(string category, string message, Exception exception = null)
+ => Emit(LogLevel.Error, category, message, exception);
+
+ public static void Critical(string category, string message, Exception exception = null)
+ => Emit(LogLevel.Critical, category, message, exception);
+
+ // -----------------------------------------------------------------
+ // 내부 공통 처리
+ // -----------------------------------------------------------------
+
+ private static void Emit(LogLevel level, string category, string message, Exception exception)
+ {
+ // 레벨·카테고리 필터
+ if (level < MinLevel) return;
+ lock (_gate)
+ {
+ if (category != null && _disabledCategories.Contains(category)) return;
+ }
+
+ var formatted = Format(category, message);
+
+ // Unity Console 출력
+ switch (level)
+ {
+ case LogLevel.Verbose:
+ case LogLevel.Info:
+ UnityDebug.Log(formatted);
+ break;
+ case LogLevel.Warn:
+ UnityDebug.LogWarning(formatted);
+ break;
+ case LogLevel.Error:
+ case LogLevel.Critical:
+ if (exception != null) UnityDebug.LogException(exception);
+ UnityDebug.LogError(formatted);
+ break;
+ }
+
+ // 외부 sink 전달 (등록된 sink 수만큼 스냅샷 순회)
+ ILogSink[] snapshot;
+ lock (_gate)
+ {
+ if (_sinks.Count == 0) return;
+ snapshot = _sinks.ToArray();
+ }
+ for (int i = 0; i < snapshot.Length; i++)
+ {
+ try { snapshot[i].Emit(level, category, formatted, exception); }
+ catch
+ {
+ // sink 내부 예외는 무시한다(재귀 방지).
+ }
+ }
+ }
+
+ private static string Format(string category, string message)
+ {
+ if (!PrefixCategory || string.IsNullOrEmpty(category)) return message ?? string.Empty;
+ var sb = new StringBuilder(
+ (category?.Length ?? 0) + (message?.Length ?? 0) + 4);
+ sb.Append('[').Append(category).Append("] ").Append(message);
+ return sb.ToString();
+ }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/LogLevel.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/LogLevel.cs
new file mode 100644
index 0000000..a40ba1f
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/LogLevel.cs
@@ -0,0 +1,27 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework
+// LogLevel.cs — 로그 레벨 정의
+// ---------------------------------------------------------------------------
+namespace NerdNavis.Core.Util.Log
+{
+ ///
+ /// 로그 심각도 레벨. 숫자가 클수록 심각하다.
+ ///
+ public enum LogLevel
+ {
+ /// 상세 디버그 정보. 릴리즈에서는 stripping 대상.
+ Verbose = 0,
+
+ /// 일반 정보. 릴리즈에서는 stripping 대상.
+ Info = 1,
+
+ /// 경고. 동작은 지속되나 주의 필요.
+ Warn = 2,
+
+ /// 오류. 동작이 실패했으나 복구 가능.
+ Error = 3,
+
+ /// 치명적 오류. 애플리케이션 상태가 불안정할 수 있음.
+ Critical = 4,
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/NerdNavis.Framework.asmdef b/코어코드/NerdNavis.Framework/Runtime/NerdNavis.Framework.asmdef
new file mode 100644
index 0000000..05b64a3
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Runtime/NerdNavis.Framework.asmdef
@@ -0,0 +1,14 @@
+{
+ "name": "NerdNavis.Framework",
+ "rootNamespace": "NerdNavis",
+ "references": [],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/코어코드/NerdNavis.Framework/Runtime/Security/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/Security/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/코어코드/NerdNavis.Framework/Runtime/UI/Components/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/UI/Components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/코어코드/NerdNavis.Framework/Runtime/UI/UGUI/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/UI/UGUI/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/코어코드/NerdNavis.Framework/Tests/Editor/NerdNavis.Framework.Editor.Tests.asmdef b/코어코드/NerdNavis.Framework/Tests/Editor/NerdNavis.Framework.Editor.Tests.asmdef
new file mode 100644
index 0000000..88001bd
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Tests/Editor/NerdNavis.Framework.Editor.Tests.asmdef
@@ -0,0 +1,25 @@
+{
+ "name": "NerdNavis.Framework.Editor.Tests",
+ "rootNamespace": "NerdNavis.Editor.Tests",
+ "references": [
+ "NerdNavis.Framework",
+ "NerdNavis.Framework.Editor",
+ "UnityEngine.TestRunner",
+ "UnityEditor.TestRunner"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "nunit.framework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS"
+ ],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Coroutine/CoroutineRunnerTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Coroutine/CoroutineRunnerTests.cs
new file mode 100644
index 0000000..5eaaa22
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Coroutine/CoroutineRunnerTests.cs
@@ -0,0 +1,130 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework.Tests
+// CoroutineRunnerTests.cs — CoroutineRunner PlayMode 테스트
+// ---------------------------------------------------------------------------
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using NerdNavis.Core.Coroutine;
+
+namespace NerdNavis.Tests.Core.Coroutine
+{
+ public class CoroutineRunnerTests
+ {
+ [SetUp] public void SetUp() => CoroutineRunner.ResetForTests();
+ [TearDown] public void TearDown() => CoroutineRunner.ResetForTests();
+
+ [UnityTest]
+ public IEnumerator Start_Runs_To_Completion()
+ {
+ int counter = 0;
+ var handle = CoroutineRunner.Start(CountRoutine(3, () => counter++));
+ Assert.IsTrue(handle.IsValid);
+
+ while (CoroutineRunner.IsRunning(handle)) yield return null;
+ Assert.AreEqual(3, counter);
+ }
+
+ [UnityTest]
+ public IEnumerator Stop_Prevents_Completion()
+ {
+ int counter = 0;
+ var handle = CoroutineRunner.Start(CountRoutine(10, () => counter++));
+ yield return null; yield return null;
+ CoroutineRunner.Stop(handle);
+
+ int snapshot = counter;
+ for (int i = 0; i < 5; i++) yield return null;
+
+ Assert.IsFalse(CoroutineRunner.IsRunning(handle));
+ Assert.AreEqual(snapshot, counter, "중단 후에는 counter가 증가하지 않아야 한다");
+ }
+
+ [UnityTest]
+ public IEnumerator Pause_Then_Resume_Continues()
+ {
+ int counter = 0;
+ var handle = CoroutineRunner.Start(CountRoutine(10, () => counter++));
+ yield return null; yield return null;
+
+ CoroutineRunner.Pause(handle);
+ int paused = counter;
+ for (int i = 0; i < 5; i++) yield return null;
+ Assert.AreEqual(paused, counter, "일시정지 중에는 진행이 없어야 한다");
+
+ CoroutineRunner.Resume(handle);
+ while (CoroutineRunner.IsRunning(handle)) yield return null;
+ Assert.AreEqual(10, counter);
+ }
+
+ [UnityTest]
+ public IEnumerator Duplicate_Replace_Stops_Previous()
+ {
+ int a = 0, b = 0;
+ var h1 = CoroutineRunner.Start("k", CountRoutine(20, () => a++));
+ yield return null;
+ var h2 = CoroutineRunner.Start("k", CountRoutine(5, () => b++), DuplicatePolicy.Replace);
+
+ Assert.IsFalse(CoroutineRunner.IsRunning(h1), "첫 핸들은 중단되어야 한다");
+ while (CoroutineRunner.IsRunning(h2)) yield return null;
+ Assert.AreEqual(5, b);
+ Assert.Less(a, 20, "첫 루틴은 20회를 채우지 못했어야 한다");
+ }
+
+ [UnityTest]
+ public IEnumerator Duplicate_Ignore_Keeps_Original()
+ {
+ int a = 0, b = 0;
+ var h1 = CoroutineRunner.Start("k", CountRoutine(5, () => a++));
+ var h2 = CoroutineRunner.Start("k", CountRoutine(5, () => b++), DuplicatePolicy.Ignore);
+
+ Assert.AreEqual(h1.Id, h2.Id, "Ignore는 기존 핸들을 반환해야 한다");
+ while (CoroutineRunner.IsRunning(h1)) yield return null;
+ Assert.AreEqual(5, a);
+ Assert.AreEqual(0, b, "Ignore된 루틴은 실행되지 않아야 한다");
+ }
+
+ [UnityTest]
+ public IEnumerator StopByKey_Works()
+ {
+ int counter = 0;
+ CoroutineRunner.Start("kill-me", CountRoutine(20, () => counter++));
+ yield return null; yield return null;
+ Assert.IsTrue(CoroutineRunner.IsRunningByKey("kill-me"));
+
+ CoroutineRunner.StopByKey("kill-me");
+ Assert.IsFalse(CoroutineRunner.IsRunningByKey("kill-me"));
+ }
+
+ [UnityTest]
+ public IEnumerator Exception_In_Routine_Is_Captured()
+ {
+ LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex(@"\[Coroutine\] coroutine threw"));
+ LogAssert.Expect(LogType.Exception, new System.Text.RegularExpressions.Regex("boom"));
+
+ var handle = CoroutineRunner.Start(ThrowingRoutine());
+ while (CoroutineRunner.IsRunning(handle)) yield return null;
+
+ // 완료(예외 종료) 후 내부 정리가 되어 IsRunning이 false여야 한다
+ Assert.IsFalse(CoroutineRunner.IsRunning(handle));
+ }
+
+ // --------- helper routines ---------
+
+ private static IEnumerator CountRoutine(int times, System.Action onTick)
+ {
+ for (int i = 0; i < times; i++)
+ {
+ onTick?.Invoke();
+ yield return null;
+ }
+ }
+
+ private static IEnumerator ThrowingRoutine()
+ {
+ yield return null;
+ throw new System.InvalidOperationException("boom");
+ }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/MonoSingletonTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/MonoSingletonTests.cs
new file mode 100644
index 0000000..09bbb0a
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/MonoSingletonTests.cs
@@ -0,0 +1,134 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework.Tests
+// MonoSingletonTests.cs — MonoSingleton PlayMode 테스트
+// ---------------------------------------------------------------------------
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using NerdNavis.Core.Patterns;
+
+namespace NerdNavis.Tests.Core.Patterns
+{
+ public class MonoSingletonTests
+ {
+ // 각 테스트 고유의 파생 타입을 사용해 static 공유 이슈를 회피한다.
+
+ // --- SyncCase ---
+ private class SyncCase : MonoSingleton
+ {
+ public int InitCount;
+ protected override void OnInitialized() => InitCount++;
+ }
+
+ // --- NoAutoCreateCase ---
+ private class NoAutoCreateCase : MonoSingleton
+ {
+ protected override bool AutoCreate => false;
+ }
+
+ // --- NonPersistentCase ---
+ private class NonPersistentCase : MonoSingleton
+ {
+ protected override bool Persistent => false;
+ }
+
+ // --- DuplicateCase ---
+ private class DuplicateCase : MonoSingleton
+ {
+ public static int DestroyedCount;
+ protected override void OnDuplicateDestroyed() => DestroyedCount++;
+ }
+
+ // --- ManualReadyCase ---
+ private class ManualReadyCase : MonoSingleton
+ {
+ protected override InitMode InitMode => InitMode.ManualReady;
+ public void Ready() => MarkReady();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ MonoSingleton.ResetForTests();
+ MonoSingleton.ResetForTests();
+ MonoSingleton.ResetForTests();
+ MonoSingleton.ResetForTests();
+ MonoSingleton.ResetForTests();
+ DuplicateCase.DestroyedCount = 0;
+ }
+
+ [UnityTest]
+ public IEnumerator AutoCreate_Instance_On_First_Access()
+ {
+ Assert.IsFalse(MonoSingleton.Exists);
+ var inst = SyncCase.Instance;
+ yield return null;
+
+ Assert.IsNotNull(inst);
+ Assert.IsTrue(MonoSingleton.Exists);
+ Assert.IsTrue(MonoSingleton.IsReady);
+ Assert.AreEqual(1, inst.InitCount, "OnInitialized는 1회 호출");
+ }
+
+ [UnityTest]
+ public IEnumerator NoAutoCreate_Returns_Null_And_Warns()
+ {
+ LogAssert.Expect(LogType.Warning,
+ new System.Text.RegularExpressions.Regex(@"AutoCreate=false"));
+
+ var inst = NoAutoCreateCase.Instance;
+ yield return null;
+ Assert.IsNull(inst);
+ }
+
+ [UnityTest]
+ public IEnumerator NonPersistent_Does_Not_DontDestroyOnLoad()
+ {
+ var inst = NonPersistentCase.Instance;
+ yield return null;
+ // DontDestroyOnLoad 씬의 scene.buildIndex == -1 임을 역으로 이용
+ Assert.AreNotEqual(-1, inst.gameObject.scene.buildIndex,
+ "Persistent=false인데 DontDestroyOnLoad 씬에 들어갔다");
+ }
+
+ [UnityTest]
+ public IEnumerator Persistent_Goes_To_DontDestroyOnLoad()
+ {
+ var inst = SyncCase.Instance;
+ yield return null;
+ Assert.AreEqual("DontDestroyOnLoad", inst.gameObject.scene.name);
+ }
+
+ [UnityTest]
+ public IEnumerator Duplicate_Instance_Is_Destroyed()
+ {
+ LogAssert.Expect(LogType.Warning,
+ new System.Text.RegularExpressions.Regex(@"중복 인스턴스"));
+
+ var first = DuplicateCase.Instance;
+ yield return null;
+
+ var extra = new GameObject("extra").AddComponent();
+ yield return null;
+ // 파괴 완료 대기
+ yield return null;
+
+ Assert.IsTrue(extra == null, "중복 인스턴스는 Destroy 되어야 한다");
+ Assert.AreEqual(1, DuplicateCase.DestroyedCount);
+ Assert.AreSame(first, MonoSingleton.Instance);
+ }
+
+ [UnityTest]
+ public IEnumerator ManualReady_Stays_Not_Ready_Until_Marked()
+ {
+ var inst = ManualReadyCase.Instance;
+ yield return null;
+ Assert.IsNotNull(inst);
+ Assert.IsFalse(MonoSingleton.IsReady);
+
+ inst.Ready();
+ Assert.IsTrue(MonoSingleton.IsReady);
+ }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/ServiceLocatorTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/ServiceLocatorTests.cs
new file mode 100644
index 0000000..afe8808
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/ServiceLocatorTests.cs
@@ -0,0 +1,120 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework.Tests
+// ServiceLocatorTests.cs — ServiceLocator 단위 테스트
+// ---------------------------------------------------------------------------
+using System;
+using NUnit.Framework;
+using NerdNavis.Core.Patterns;
+
+namespace NerdNavis.Tests.Core.Patterns
+{
+ public class ServiceLocatorTests
+ {
+ private interface IGreeter { string Hello(); }
+ private sealed class EnGreeter : IGreeter { public string Hello() => "Hi"; }
+ private sealed class KoGreeter : IGreeter { public string Hello() => "안녕"; }
+
+ private interface ICounter { int Count { get; } }
+ private sealed class Counter : ICounter
+ {
+ public int Count { get; private set; }
+ public Counter() { Count = 1; }
+ }
+
+ [SetUp] public void SetUp() => ServiceLocator.Clear();
+ [TearDown] public void TearDown() => ServiceLocator.Clear();
+
+ [Test]
+ public void Register_Instance_And_Resolve()
+ {
+ var svc = new EnGreeter();
+ ServiceLocator.Register(svc);
+
+ Assert.IsTrue(ServiceLocator.IsRegistered());
+ Assert.AreSame(svc, ServiceLocator.Resolve());
+ }
+
+ [Test]
+ public void Register_Null_Throws()
+ {
+ Assert.Throws(() => ServiceLocator.Register((IGreeter)null));
+ Assert.Throws(() => ServiceLocator.Register((Func)null));
+ }
+
+ [Test]
+ public void Resolve_Missing_Throws_ServiceNotRegistered()
+ {
+ var ex = Assert.Throws(() => ServiceLocator.Resolve());
+ Assert.AreEqual(typeof(IGreeter), ex.ServiceType);
+ }
+
+ [Test]
+ public void TryResolve_Missing_Returns_False()
+ {
+ Assert.IsFalse(ServiceLocator.TryResolve(out var svc));
+ Assert.IsNull(svc);
+ }
+
+ [Test]
+ public void Factory_Is_Invoked_Once_And_Cached()
+ {
+ int factoryCalls = 0;
+ ServiceLocator.Register(() => { factoryCalls++; return new Counter(); });
+
+ Assert.AreEqual(0, factoryCalls, "등록 시점에 factory는 호출되지 않는다");
+
+ var a = ServiceLocator.Resolve();
+ var b = ServiceLocator.Resolve();
+
+ Assert.AreEqual(1, factoryCalls, "factory는 첫 Resolve에서만 1회 호출");
+ Assert.AreSame(a, b, "이후 Resolve는 캐시된 인스턴스를 반환");
+ }
+
+ [Test]
+ public void Register_Overwrites_Existing_Binding()
+ {
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning,
+ new System.Text.RegularExpressions.Regex(@"\[ServiceLocator\] Register"));
+
+ ServiceLocator.Register(new EnGreeter());
+ ServiceLocator.Register(new KoGreeter());
+
+ Assert.AreEqual("안녕", ServiceLocator.Resolve().Hello());
+ }
+
+ [Test]
+ public void Unregister_Removes_Binding()
+ {
+ ServiceLocator.Register(new EnGreeter());
+ ServiceLocator.Unregister();
+
+ Assert.IsFalse(ServiceLocator.IsRegistered());
+ Assert.Throws(() => ServiceLocator.Resolve());
+ }
+
+ [Test]
+ public void Clear_Removes_All()
+ {
+ ServiceLocator.Register(new EnGreeter());
+ ServiceLocator.Register(() => new Counter());
+ ServiceLocator.Clear();
+
+ Assert.IsFalse(ServiceLocator.IsRegistered());
+ Assert.IsFalse(ServiceLocator.IsRegistered());
+ }
+
+ [Test]
+ public void Factory_Exception_Is_Captured_As_False()
+ {
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error,
+ new System.Text.RegularExpressions.Regex(@"factory for IGreeter threw"));
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Exception,
+ new System.Text.RegularExpressions.Regex("boom"));
+
+ ServiceLocator.Register(() => throw new InvalidOperationException("boom"));
+
+ Assert.IsFalse(ServiceLocator.TryResolve(out var svc));
+ Assert.IsNull(svc);
+ }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Util/Log/LogTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Util/Log/LogTests.cs
new file mode 100644
index 0000000..563ca80
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Util/Log/LogTests.cs
@@ -0,0 +1,134 @@
+// ---------------------------------------------------------------------------
+// NerdNavis.Framework.Tests
+// LogTests.cs — Log 중앙 로거 단위 테스트
+// ---------------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using NerdNavis.Core.Util.Log;
+
+namespace NerdNavis.Tests.Core.Util.Log
+{
+ public class LogTests
+ {
+ private sealed class RecordingSink : ILogSink
+ {
+ public readonly List<(LogLevel level, string category, string message, Exception exception)> Events
+ = new List<(LogLevel, string, string, Exception)>();
+
+ public void Emit(LogLevel level, string category, string message, Exception exception)
+ => Events.Add((level, category, message, exception));
+ }
+
+ private RecordingSink _sink;
+ private LogLevel _savedMinLevel;
+ private bool _savedPrefix;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _savedMinLevel = global::NerdNavis.Core.Util.Log.Log.MinLevel;
+ _savedPrefix = global::NerdNavis.Core.Util.Log.Log.PrefixCategory;
+ global::NerdNavis.Core.Util.Log.Log.ClearSinks();
+ global::NerdNavis.Core.Util.Log.Log.ResetCategoryFilter();
+ global::NerdNavis.Core.Util.Log.Log.MinLevel = LogLevel.Verbose;
+ global::NerdNavis.Core.Util.Log.Log.PrefixCategory = true;
+
+ _sink = new RecordingSink();
+ global::NerdNavis.Core.Util.Log.Log.AddSink(_sink);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ global::NerdNavis.Core.Util.Log.Log.ClearSinks();
+ global::NerdNavis.Core.Util.Log.Log.ResetCategoryFilter();
+ global::NerdNavis.Core.Util.Log.Log.MinLevel = _savedMinLevel;
+ global::NerdNavis.Core.Util.Log.Log.PrefixCategory = _savedPrefix;
+ }
+
+ [Test]
+ public void Warn_Sink_Receives_Formatted_Message()
+ {
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[Net] timeout");
+ global::NerdNavis.Core.Util.Log.Log.Warn("Net", "timeout");
+
+ Assert.AreEqual(1, _sink.Events.Count);
+ Assert.AreEqual(LogLevel.Warn, _sink.Events[0].level);
+ Assert.AreEqual("Net", _sink.Events[0].category);
+ Assert.AreEqual("[Net] timeout", _sink.Events[0].message);
+ Assert.IsNull(_sink.Events[0].exception);
+ }
+
+ [Test]
+ public void MinLevel_Filters_Below()
+ {
+ global::NerdNavis.Core.Util.Log.Log.MinLevel = LogLevel.Error;
+
+ global::NerdNavis.Core.Util.Log.Log.Warn("Net", "below");
+
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error, "[Net] above");
+ global::NerdNavis.Core.Util.Log.Log.Error("Net", "above");
+
+ Assert.AreEqual(1, _sink.Events.Count);
+ Assert.AreEqual(LogLevel.Error, _sink.Events[0].level);
+ }
+
+ [Test]
+ public void Disabled_Category_Is_Skipped()
+ {
+ global::NerdNavis.Core.Util.Log.Log.DisableCategory("Net");
+ global::NerdNavis.Core.Util.Log.Log.Warn("Net", "blocked");
+
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[UI] kept");
+ global::NerdNavis.Core.Util.Log.Log.Warn("UI", "kept");
+
+ Assert.AreEqual(1, _sink.Events.Count);
+ Assert.AreEqual("UI", _sink.Events[0].category);
+ }
+
+ [Test]
+ public void PrefixCategory_Off_Omits_Tag()
+ {
+ global::NerdNavis.Core.Util.Log.Log.PrefixCategory = false;
+
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "no-tag");
+ global::NerdNavis.Core.Util.Log.Log.Warn("Net", "no-tag");
+
+ Assert.AreEqual("no-tag", _sink.Events[0].message);
+ }
+
+ [Test]
+ public void Error_With_Exception_Is_Forwarded()
+ {
+ var ex = new InvalidOperationException("boom");
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Exception, "InvalidOperationException: boom");
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error, "[Net] failed");
+
+ global::NerdNavis.Core.Util.Log.Log.Error("Net", "failed", ex);
+
+ Assert.AreEqual(1, _sink.Events.Count);
+ Assert.AreSame(ex, _sink.Events[0].exception);
+ }
+
+ [Test]
+ public void Sink_Exception_Does_Not_Break_Others()
+ {
+ var bad = new ThrowingSink();
+ var good = new RecordingSink();
+ global::NerdNavis.Core.Util.Log.Log.AddSink(bad);
+ global::NerdNavis.Core.Util.Log.Log.AddSink(good);
+
+ UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[Net] ping");
+ global::NerdNavis.Core.Util.Log.Log.Warn("Net", "ping");
+
+ Assert.AreEqual(1, good.Events.Count, "정상 sink는 수신해야 한다");
+ }
+
+ private sealed class ThrowingSink : ILogSink
+ {
+ public void Emit(LogLevel level, string category, string message, Exception exception)
+ => throw new InvalidOperationException("sink failure");
+ }
+ }
+}
diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/NerdNavis.Framework.Tests.asmdef b/코어코드/NerdNavis.Framework/Tests/Runtime/NerdNavis.Framework.Tests.asmdef
new file mode 100644
index 0000000..923964d
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/Tests/Runtime/NerdNavis.Framework.Tests.asmdef
@@ -0,0 +1,22 @@
+{
+ "name": "NerdNavis.Framework.Tests",
+ "rootNamespace": "NerdNavis.Tests",
+ "references": [
+ "NerdNavis.Framework",
+ "UnityEngine.TestRunner",
+ "UnityEditor.TestRunner"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "nunit.framework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS"
+ ],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/코어코드/NerdNavis.Framework/package.json b/코어코드/NerdNavis.Framework/package.json
new file mode 100644
index 0000000..b033b63
--- /dev/null
+++ b/코어코드/NerdNavis.Framework/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "com.nerdnavis.framework",
+ "version": "0.1.0",
+ "displayName": "NerdNavis Framework",
+ "description": "너드나비스 내부 프로젝트용 범용 Unity 프레임워크. 코루틴·싱글톤·UGUI·Addressable·보안 등 재사용 모듈 모음.",
+ "unity": "2022.3",
+ "author": {
+ "name": "NerdNavis",
+ "url": "https://nerdnavis.com"
+ },
+ "keywords": [
+ "framework",
+ "utility",
+ "ugui",
+ "addressable"
+ ],
+ "dependencies": {}
+}