1~4단계 — 폴더 · 명명 · 유니티 · 참조 어셈블리
1. 폴더 보존과 스테이지 템플릿 — 구현 완료
현재 구조
pre/·global/·shared/·runtime/은 도구가 만들지 않습니다. 도구가 만드는 것은tables/<테이블>.cs(--new-validator),.generated/,Validation.csproj세 가지입니다 (RuleScaffold.cs:63-73,RuleAccessor.cs:88-95).- 폴더가 없으면 말없이 건너뜁니다(
RuleFolders.cs:193-194). 이름이 정해진 6개 밖이면 에러입니다(#접두 폴더는 면제,RuleFolders.cs:160-185). - 스테이지 폴더의
*.cs는 전부 규칙으로 컴파일되고, 엔트리 메서드가 정확히 1개 없으면 에러입니다(RuleCompiler.cs:255-276).shared/만 재귀 스캔이고 나머지는 평평하게만 스캔합니다(RuleFolders.cs:199, 214-225).
설계
| 작업 | 내용 |
|---|---|
| 스테이지별 템플릿 | 각 폴더에 _….cs.template 1개. .template 확장자가 스캔에 걸리지 않는 유일한 안전선입니다 — .cs로 넣으면 엔트리 부재 에러로 실행이 중단됩니다 |
.gitkeep | 불필요해졌습니다. 템플릿 파일이 폴더마다 하나씩 들어가므로 빈 폴더가 그것으로 보존됩니다 |
| 템플릿 헤더 | 그 스테이지의 규약을 기재합니다 — 엔트리 시그니처, using 두 줄, 파일명 규약, 그 스테이지에서 사용 가능한 Context API(pre/는 Tables·Schema 불가, runtime/만 Db·Redis 가능), 복사 후 확장자를 .cs로 바꾸라는 안내, 하위 폴더는 스캔되지 않는다는 사실, --new-validator의 존재 |
템플릿과 --new-validator는 역할이 겹치지만 대상이 다릅니다 — 템플릿은 「도구 없이 복사」,
스캐폴딩은 「도구로 생성」입니다. 검증만 담당하는 사람은 도구 미설치 상태로 시작할 수 있어야
하므로 둘 다 둡니다.
초기화 동작은 두지 않았습니다. 템플릿이 저장소에 있으 므로 도구가 폴더를 만들어 줄 이유가 남지 않습니다 — 새 프로젝트는 검증 폴더를 복사해서 시작합니다.
게이트 — 있습니다. pass 픽스처의 tables/와 shared/에 템플릿을 두었습니다. 스캔되면
Tables.Example 하나로 실행이 멈추므로, 그 픽스처가 통과하는 것이 곧 검사입니다.
2. 명명·엔트리 규약 정비 — 구현 완료
2a. tables/Item.cs → tables/ItemRules.cs
현재 구조. 테이블 매칭의 유일한 키는 파일명입니다 — 파일명 그대로 테이블 존재를
검사합니다(ValidationPipeline.cs:265). 클래스 이름은 호스트가 확인하지 않고, 스캐폴딩이
{테이블}Rules 클래스를 만드는 것은 관례일 뿐입니다(RuleScaffold.cs:85).
바꾸는 근거.
- 현재 IDE 프로젝트 하나에 이름이 같은 파일이 2개 들어갑니다 — 사용자 규칙
tables/Item.cs와 생성된 레코드 타입.generated/tables/Item.cs. - 「파일명 = 주 클래스명」이라는 C# 관례를 스캐폴딩 산출물부터 어기고 있습니다(파일
Item.cs, 클래스ItemRules).
설계. 매칭 지점에서 Rules 접미사를 벗겨 테이블을 조회합니다. 접미사는 필수입니다 —
tables/Item.cs를 만나면 「ItemRules.cs로 변경」을 안내하는 에러를 냅니다. 병용 허용은 같은
테이블에 두 파일이 생기는 상태를 만들 수 있어 채택하지 않습니다. 테이블명이 Rules로 끝나는
극단 사례에도 모호성이 없습니다(ItemRules 테이블이면 파일은 ItemRulesRules.cs).
다른 스테이지 폴더도 「<주제>Rules.cs + 동명 클래스」로 통일합니다 — 현재 샘플이 파일
ShopIntegrity.cs에 클래스 ShopIntegrityRules로 어긋나 있습니다. global/ 등은 매칭이
없으므로 이쪽은 강제가 아니라 스캐폴딩·템플릿·샘플이 보여주는 관례입니다.
변경 지점. 매칭(ValidationPipeline.cs:265)과 불일치 에러 메시지(:282-285), 파일 생성
경로와 클래스 이름(RuleScaffold.cs:63-89), 규약 문서 주석(RuleFolders.cs:43-46), 컴파일 어셈블리
이름(RuleCompiler.cs:199), 문서 3곳, 샘플 3세트와 픽스처, 테스트 단언 2곳
(ValidationPipelineTests.cs:154, 173).
2b. Run(Context) → Validate(Context)
이름 정의는 상수 1곳입니다(RuleCompiler.cs:100의 EntryMethod). 파급은 코드 6곳,
RuleScaffold.cs의 생성 본문 1곳, 문서 10여 곳, 샘플·픽스처 17개 파일, 테스트 1곳, 생성
csproj 헤더 주석입니다.
이중 지원 기간은 두지 않고 일괄 전환합니다 — 외부 사용자가 없는 현재가 적기이고, 두 이름 인정은 「파일당 엔트리 정확히 1개」 검사를 복잡하게 합니다.
2a와 2b는 샘플·픽스처·문서를 전부 만지므로 한 번에 수행합니다.
게이트. 기존 단언 갱신 + 구 명명 파일에 대한 안내 에러 테스트.
3. Unity 어댑터 외부화와 하한 6 — 구현 완료
현재 구조
생성 코드에서 Unity를 아는 부분은 바이트 읽기의 기본 구현뿐입니다 —
csharp-read-bytes.sbn의 DefaultReadAllBytesAsync 분기와 ReadViaWebRequestAsync, 약
70줄. 읽기 자체는 공개 교체 지점으로 선언되어 있고(ReadAllBytesAsync 델리게이트, 문서
주석이 교체를 지원 방식으로 명시), 검증 파이프라인이 실데이터 주입에 이미 이 지점을
씁니다(RuleAccessor.Fill). 나머지 생성물 전체는 BCL만 씁니다.
C# 언어 수준은 Unity 2020.3 때문에 8에 고정되어 있습니다(csharp-table.sbn:119,
csharp-tostring-helper.sbn:12에 우회 구현 명시).
설계
| 작업 | 내용 |
|---|---|
| 코어 중립화 | 리더의 기본 읽기를 순수 BCL(File.ReadAllBytesAsync)로. 코어 생성물에서 전처리 분기 소멸 |
| 어댑터 동봉 | tabbit/TabbitUnityAdapter.cs(가칭) 1장을 항상 소스로 동봉. 파일 전체를 UNITY_5_3_OR_NEWER 게이트로 감싸고 — 이 심볼은 버전 하한이 아니라 「Unity인가」 판별이므로 유지합니다 — [RuntimeInitializeOnLoadMethod]로 시작 시 ReadAllBytesAsync에 자기 구현을 대입합니다. Unity 밖에서는 빈 파일로 컴파일됩니다 |
두지 않았습니다. 유니티 마이너 버전의 심볼 철자가 어긋나면 구버전이 아니라 모든 사용자에게 #error가 납니다. 그리고 구버전은 어차피 netstandard 2.0에 비동기 파일 API가 없어 액세서가 컴파일되지 않으므로, 하한은 문서와 그 자연스러운 실패가 지킵니다. 심볼을 실물로 확인할 수 있게 되면 다시 볼 항목입니다 | |
| 사문 분기 삭제 | csharp-read-bytes.sbn:119-129(구버전 Task.Run 폴백), :153-158(UnityWebRequest 구형 결과 API), TabbitUpdater.cs:370-372(같은 분기). 남는 분기는 UNITY_WEBGL && !UNITY_EDITOR(플랫폼 현실)와 URL 판별 2개 |
| C# 하한 선언 | 8 → 9 (Unity 6의 컴파일러 기준). 실제 기능 채택은 일괄이 아니라 기능 단위 결정이며, netstandard2.1 위의 C# 9는 일부 기능에 폴리필이 필요하므로 채택마다 게이트로 확인합니다. csharp-table.sbn:119·csharp-tostring-helper.sbn의 우회 구현이 정리 후보가 됩니다 |
| 게이트 갱신 | CsGeneratorTests.cs:100-101의 심볼 조합에서 「구 API 레벨」 케이스 삭제, Unity 6·WebGL 조합으로 교체. test/EmittedCodeLanguageCheck의 기준을 「netstandard2.1 + C# 9」로 |
| 문서 | doc/languages/csharp.md:25-29, 160 · doc/languages/readme.md:36 · doc/architecture.md:58-97의 요구사항 표·분기 표·게이트 설명, CHANGELOG의 하한 상향 고지. 「미만 버전은 지원 계획 없음」 정책 문장을 요구사항 표에 명시합니다 |
구현에서 정한 것과 남은 확인 항목
정한 것. 어댑터는 tabbit/TabbitUnityAdapter.cs이고 템플릿은
src/templates/csharp-unity-adapter.sbn입니다. WriteUpdater와 무관하게 항상 씁니다 —
유니티 밖에서는 빈 파일로 컴파일되므로 조건을 둘 이유가 없습니다. 설치는
RuntimeInitializeOnLoadMethod(BeforeSceneLoad)입니다.
실물 유니티로 확인할 항목.
RuntimeInitializeOnLoadMethod가 첫 테이블 읽기보다 앞서는지, 에디터 플레이 모드 재진입 시 재설치되는지. 정적 필드 대입이라 중복 설치는 무해하지만 시점 역전은 읽기 실패가 됩니다.Task.Yield()폴링 루프를await SendWebRequest()로 대체할 수 있는지(Awaitable지원 기준).- 6.x 마이너의 API 호환성 레벨 변동(CoreCLR 전환 관련)이 netstandard 2.1 전제를 흔드는지.
- 하한 강제를 다시 둔다면 그때 쓸 심볼의 정확한 철자.
저장소 안에서 확인할 수 있는 것은 스텁으로 확인합니다 —
test/fixtures/tools/cs-compile-check/UnityStubs.cs에 어댑터가 쓰는 표면이 들어 있고,
CsGeneratorTests가 어댑터를 유니티 심볼로 컴파일하며 액세서에 UNITY가 한 글자도 남지
않았는지까지 검사합니다.
템플릿 변경이므로 골든 재기록 → 전 언어 비교본 재생성 → 샘플 재생성 → 기록 없이 재검증 순서를 지킵니다.
생성 공백 정리 — 같은 골든 주기에 묶는 항목
실측 (2026-08-14, 샘플 산출물 125개 기준).
| 증상 | 규모 | 발생 지점 |
|---|---|---|
| 닫는 중괄호 앞 빈 줄 | 125개 중 116개 — 테이블 파일 전부의 클래스 닫는 } 앞과 } // namespace 앞 | csharp-table.sbn 본문 꼬리 · csharp-accessor.sbn · 동봉 런타임 원본 lib/cs/tabbit/TcbReader.cs 꼬리 1건 |
| 연속 2줄 이상 빈 줄 | 접근자 Tables.cs의 3곳 | csharp-accessor.sbn의 블록 사이 |
규칙 두 가지 를 생성물 전체에 적용합니다 — 닫는 중괄호 앞에 빈 줄을 두지 않고, 연속 빈 줄은 1줄까지만 허용합니다.
방법은 생성 지점의 수정입니다(Scriban 공백 제어와 라이브러리 원본 수정). 중앙 정규화(쓰기 직전에 텍스트를 일괄 접기)는 채택하지 않습니다 — 시트의 문자열 상수가 여러 줄 리터럴로 생성되는 경우 내용이 훼손될 수 있고, 생성 지점이 유한하므로 원인 수정이 가능합니다.
C# 외 언어의 템플릿에 같은 증상이 있는지는 이 항목의 범위 밖이며, 같은 규칙의 후속 적용 후보로만 기록합니다.
4. 참조 어셈블리 임베드 — 구현 완료
현재 구조
Roslyn 컴파일(검증 규칙·접근자)은 프로세스의 어셈블리 경로 목록(TPA)에서 참조를
가져오므로, 배포본(self-contained 단일 파일)에서는 경로가 없어 명시적 에러로
중단됩니다(RuleCompiler.cs:424-451). 같은 이유로 7단계의 dll 출력도 배포본에서 동작할 수
없습니다. 로드맵이 「참조 어셈블리를 임베드하는 것이 답」이라고 이미 지목한 항목입니다.
착수 시 바로잡은 것 — 5a가 이 단계보다 먼저입니다
당초 구도는 4 → 5로 두었으나, 착수해 보니 순서가 반대입니다. 규칙 컴파일에 필요한 참조는 프레임워크 어셈블리 하나가 아니라 둘입니다.
| 필요한 참조 | 단일 파일 배포본에서 |
|---|---|
| 프레임워크 어셈블리 | 경로가 없습니다 → 임베드로 해결됩니다 |
tabbit 자신(typeof(Context).Assembly.Location, RuleCompiler.cs:437) | 경로가 빈 문자열입니다 → 임베드로 해결되지 않습니다 |
두 번째가 막힙니다. 자기 자신의 참조 어셈블리를 자기 리소스로 넣는 것은 한 번의 빌드로 성립하지 않기 때문입니다. 남는 길은 둘뿐이고, 하나는 이미 설계되어 있습니다.
- 계약 어셈블리 분리(5a) — 본체가 참조하는 별도 어셈블리가 되므로 임베드에 순환이 없습니다. 규칙 작성자에게 열리는 표면이 명시된다는 본래의 값도 그대로입니다.
- 2단계 빌드 — 빌드해서 참조 어셈블리를 뽑고 그것을 넣어 다시 빌드합니다. 빌드 체계에 순환을 스크립트로 푸는 부담이 남습니다.
따라서 착수 순서는 5a → 4 → 5b·5c → 7입니다. 스펙 §5a가 「착수 전 첫 작업은 Context와
협력 타입들의 의존 실측」이라고 적어 둔 것이 그대로 이 단계의 선행 작업이 됩니다.
실측 (2026-08-14) — 임베드 비용
| 세트 | 파일 | 크기 |
|---|---|---|
net10 참조 팩 (Microsoft.NETCore.App.Ref 10.0.11) | 238 | 11.1 MB |
netstandard2.1 참조 팩 (NETStandard.Library.Ref 2.1.0) | 121 | 3.2 MB |
현재 tabbit.dll이 2.93 MB이므로 그대로 넣으면 14 MB가 늘어납니다. 참조 어셈블리는 메타데이터
뿐이라 압축률이 높으므로 자체 압축해서 넣는 것을 전제로 합니다 — 배포본이 이미
EnableCompressionInSingleFile을 켜고 있으나, 중간 산출물인 tabbit.dll이 14 MB 커지는 것을
피하는 편이 낫습니다.
netstandard2.1 세트는 7단계로 미룹니다. 소비자 호환이 목적이라 dll 출력이 생기기 전에는 쓰는 곳이 없고, 3.2 MB를 미리 지불할 이유가 없습니다.
설계
참조 어셈블리를 도구에 임베디드 리소스로 내장하고, MetadataReference.CreateFromImage로
공급합니다. 용도가 둘이라 세트도 둘입니다.
| 세트 | 용도 | 내용 |
|---|---|---|
| net10 기본 세트 | 검증 규칙 컴파일 — 규칙은 호스트 프로세스에서 실행됩니다 | net10 ref pack + 규칙 공개 표면의 서드파티(Newtonsoft.Json — context.Json()이 JToken을 반환하므로 필요) |
| netstandard2.1 세트 | 7단계의 코드 생성 dll — 소비자 호환이 목적입니다 | netstandard2.1 ref pack (MIT, 내장 가능) |
결정 — 항상 임베드 세트를 씁니다. 「경로가 있으면 TPA, 없으면 임베드」의 이중 경로는 개발 빌드와 배포본의 컴파일 결과를 다르게 만들 수 있습니다. 어디서 돌려도 같은 규칙이 같은 결과를 내는 것이 검증 도구의 성질에 맞습니다. 이 전환으로 규칙이 쓸 수 있는 API가 「TPA 전체(ASP.NET Core 포함)」에서 「정의된 세트」로 좁혀지는데, 이는 회귀가 아니라 계약의 명시화입니다 — 기존 규칙이 우연히 쓰던 API가 있다면 이 시점에 걸립니다.
실측 항목. 임베드로 인한 실행 파일 크기 증가. 현재 약 60MB에 압축이 켜져 있어 수용 범위로 추정하나 확정은 실측 후입니다.
게이트. 단일 파일 발행 산출물로 검증 파이프라인을 통과시키는 테스트 신설 — 현재는 에러를 내는 것이 정상 동작이라 이 게이트가 없습니다.
범위 밖. Json()의 반환형을 자체 타입으로 바꿔 서드파티를 규칙 표면에서 제거하는 정리.
후보로만 기록합니다.
4-1. 구현 (2026-08-14)
| 무엇 | 어떻게 |
|---|---|
| 담는 것 | 프레임워크(Microsoft.NETCore.App.Ref) · 계약 · Newtonsoft.Json. 169개, 압축 2.9 MB |
| 담지 않는 것 | ASP.NET Core 참조 팩. 도구는 히스토리 서버 때문에 참조하지만 규칙이 웹 프레임워크의 이름을 댈 이유가 없습니다 |
| 만드는 곳 | Tabbit.csproj의 EmbedRuleCompilationReferences. 컴파일러가 실제로 쓴 참조 목록에서 골라 zip으로 묶습니다 |
| 읽는 곳 | RuleCompiler.ReadCarriedReferences — 한 번 읽어 재사용합니다 |
| 실행 파일 크기 | 60 MB 그대로입니다. 중간 산출물 tabbit.dll이 2.93 → 5.86 MB |
하나 걸린 것 — 심볼 형식. 참조가 해결된 뒤 다음 벽이 PDB였습니다. Roslyn은 Windows에서
기본으로 Windows PDB를 쓰는데, 그것을 쓰는 diasymreader.dll은 .NET 설치에 딸린 것이라 자립
배포본에 없습니다. 아무것도 설치하지 않은 환경에서 「버전이 오래되었다」는 오류가 났습니다.
이식 가능한 PDB로 바꿨습니다 — Roslyn 자신이 쓰고, 규칙이 던진 줄 번호는 그대로 나옵니다.
게이트. CI의 자립 배포 잡이 이미 발행하고 변환을 돌리고 있었으므로 거기에
--validate-only를 한 줄 더했습니다. 다른 실행은 전부 디스크에서 참조를 읽으므로 어느 쪽이든
통과하고, 이 형태만이 담고 다니는 것을 실제로 검사합니다.
EmbeddedResource 선언은 타깃 밖에 두어야 했습니다 — 리소스 수집이 타깃보다 먼저 끝나므로,
타깃 안에서 추가한 항목은 아무 데도 들어가지 않습니다. 선언은 정적으로 두고 타깃은 그 파일을
만들기만 합니다.
4-2. 함께 걷 어낸 것
.generated/data/ 의 한 단계. 액세서가 .generated/data/ 아래로 갔는데 .generated/에
그것 말고 들어가는 것이 없어, 한 단계가 아무 일도 하지 않고 있었습니다. 참조하던 곳은
RuleAccessor의 경로 한 줄과 편집기 프로젝트의 Compile Include 한 줄뿐이었습니다.
편집기가 만든 폴더의 거부. .vs가 있으면 「모르는 단계」라며 실행이 멈췄습니다. 자동
완성을 쓰려면 검증 폴더를 편집기로 열라고 안내해 놓고 그렇게 하면 생기는 폴더를 거부하고
있었던 것입니다. bin·obj와 함께 .vs·.vscode·.idea를 면제합니다.