개발 절차
소스 생성기를 처음 만들 때 가장 어려운 부분은 “무엇을 어떤 순서로 해야 하는가”입니다. 프로젝트 설정부터 패키징까지, 각 단계가 이전 단계의 결과물에 의존하기 때문에 순서를 잘못 잡으면 불필요한 삽질이 발생합니다. 이 절에서는 ObservablePortGenerator를 만들며 검증한 7단계 개발 절차를 정리하고, 어떤 소스 생성기에도 적용할 수 있는 표준 워크플로우로 제시합니다.
학습 목표
섹션 제목: “학습 목표”핵심 학습 목표
섹션 제목: “핵심 학습 목표”- 소스 생성기 프로젝트 설정 방법 이해
- csproj 필수 설정과 패키지 참조 구성
- 표준 개발 워크플로우 습득
- 7단계 순서와 각 단계 간 의존 관계
- 테스트 및 디버깅 전략 수립
- Verify 스냅샷 테스트와 체크리스트 기반 검증
개발 절차 개요
섹션 제목: “개발 절차 개요”소스 생성기 개발은 다음 7단계로 진행됩니다. 이 순서는 Part 2에서 ObservablePortGenerator를 구현하며 자연스럽게 형성된 것으로, 각 단계가 다음 단계의 입력을 만들어내는 파이프라인 구조입니다.
┌─────────────────────────────────────────────────────────────┐│ 소스 생성기 개발 절차 │├─────────────────────────────────────────────────────────────┤│ 1. 프로젝트 설정 ││ └── csproj, 패키지 참조 ││ ││ 2. 마커 속성(Attribute) 정의 ││ └── [EntityId], [ValueConverter] 등 ││ ││ 3. 심볼 분석 전략 수립 ││ └── 어떤 정보를 추출할 것인가? ││ ││ 4. 메타데이터 클래스 설계 ││ └── 추출한 정보를 담을 데이터 구조 ││ ││ 5. 코드 생성 템플릿 설계 ││ └── 생성할 코드의 구조 ││ ││ 6. 단위 테스트 작성 ││ └── Verify 스냅샷 테스트 ││ ││ 7. 패키징 및 배포 ││ └── dotnet pack -c Release │└─────────────────────────────────────────────────────────────┘1. 프로젝트 설정
섹션 제목: “1. 프로젝트 설정”모든 소스 생성기 개발은 csproj 설정에서 시작합니다. netstandard2.0 타겟과 Roslyn 패키지 참조는 모든 생성기 프로젝트에 공통으로 필요한 설정입니다.
csproj 기본 구성
섹션 제목: “csproj 기본 구성”<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings>
<!-- 소스 생성기 필수 설정 --> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <IsRoslynComponent>true</IsRoslynComponent>
<!-- 패키지 정보 --> <PackageId>MyCompany.SourceGenerator</PackageId> <Version>1.0.0</Version> <Authors>Your Name</Authors> <Description>Source generator for ...</Description> </PropertyGroup>
<ItemGroup> <!-- Roslyn API --> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" /> </ItemGroup>
<ItemGroup> <!-- 소스 생성기로 패키징 --> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup>
</Project>핵심 설정 설명
섹션 제목: “핵심 설정 설명”| 설정 | 설명 |
|---|---|
TargetFramework | netstandard2.0 - 최대 호환성 |
EnforceExtendedAnalyzerRules | 분석기/생성기 규칙 강제 |
IsRoslynComponent | Roslyn 컴포넌트임을 명시 |
PrivateAssets="all" | 의존성이 소비자에게 전파되지 않음 |
2. 마커 속성 정의
섹션 제목: “2. 마커 속성 정의”프로젝트 설정이 완료되면 사용자가 코드에 붙일 마커 속성을 정의합니다. 이 속성은 소스 생성기가 “이 타입을 처리해야 한다”는 신호로 사용됩니다. Part 2의 [ObservablePort]와 동일한 패턴입니다.
Post-Initialization으로 속성 생성
섹션 제목: “Post-Initialization으로 속성 생성”[Generator(LanguageNames.CSharp)]public class EntityIdGenerator : IIncrementalGenerator{ // 속성 정의 (소스 코드) private const string EntityIdAttribute = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// Entity Id로 생성할 타입에 적용합니다. /// </summary> [global::System.AttributeUsage( global::System.AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")] public sealed class EntityIdAttribute : global::System.Attribute; """;
public void Initialize(IncrementalGeneratorInitializationContext context) { // 1단계: 속성 생성 (컴파일 초기에 한 번) context.RegisterPostInitializationOutput(ctx => ctx.AddSource( hintName: "EntityIdAttribute.g.cs", sourceText: SourceText.From(EntityIdAttribute, Encoding.UTF8)));
// 2단계: 속성이 붙은 타입 처리 // ... }}왜 Post-Initialization인가?
섹션 제목: “왜 Post-Initialization인가?”컴파일 시작 │ ▼┌──────────────────────────────────────┐│ RegisterPostInitializationOutput │ ← 1단계: 속성 정의 생성│ - EntityIdAttribute.g.cs 생성 │└──────────────────────────────────────┘ │ ▼┌──────────────────────────────────────┐│ 사용자 코드 파싱 │ ← 사용자가 [EntityId] 사용 가능│ - [EntityId] 속성 인식 │└──────────────────────────────────────┘ │ ▼┌──────────────────────────────────────┐│ ForAttributeWithMetadataName │ ← 2단계: 속성 대상 코드 생성│ - EntityId 타입 생성 │└──────────────────────────────────────┘ │ ▼컴파일 완료3. 심볼 분석 전략
섹션 제목: “3. 심볼 분석 전략”마커 속성이 준비되면, 그 속성이 붙은 타입에서 어떤 정보를 추출할지 결정합니다. 이 단계의 결정이 4단계 메타데이터 클래스의 형태를 결정하므로, 생성할 코드에 필요한 정보를 빠짐없이 파악해야 합니다.
분석할 정보 결정
섹션 제목: “분석할 정보 결정”// 예: Entity Id 생성기에서 필요한 정보// 입력:[EntityId]public readonly partial record struct ProductId;
// 추출할 정보:// - 타입 이름: ProductId// - 네임스페이스: MyApp.Domain// - 한정자: readonly partial record struct// - 기존 인터페이스: 없음ForAttributeWithMetadataName 사용
섹션 제목: “ForAttributeWithMetadataName 사용”public void Initialize(IncrementalGeneratorInitializationContext context){ // Post-initialization...
var provider = context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: "MyCompany.SourceGenerator.EntityIdAttribute", predicate: static (node, _) => node is StructDeclarationSyntax, transform: static (ctx, _) => MapToEntityIdInfo(ctx)) .Where(static x => x is not null);
context.RegisterSourceOutput(provider, Execute);}
private static EntityIdInfo? MapToEntityIdInfo( GeneratorAttributeSyntaxContext context){ if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) return null;
return new EntityIdInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), IsReadOnly: typeSymbol.IsReadOnly);}4. 메타데이터 클래스 설계
섹션 제목: “4. 메타데이터 클래스 설계”3단계에서 결정한 정보를 담을 데이터 구조를 설계합니다. 이 메타데이터 클래스는 Roslyn의 증분 캐싱에서 동등성 비교에 사용되므로, 반드시 값 동등성을 보장하는 record로 정의해야 합니다.
record로 불변 데이터 구조 정의
섹션 제목: “record로 불변 데이터 구조 정의”// 추출한 정보를 담는 데이터 클래스public sealed record EntityIdInfo( string TypeName, string Namespace, bool IsReadOnly);
// 복잡한 경우 중첩 record 사용public sealed record ValidationInfo( string TypeName, string Namespace, IReadOnlyList<PropertyValidation> Properties);
public sealed record PropertyValidation( string PropertyName, string PropertyType, IReadOnlyList<ValidationRule> Rules);
public sealed record ValidationRule( string RuleName, IReadOnlyDictionary<string, object?> Arguments);왜 record인가?
섹션 제목: “왜 record인가?”// ✅ record 장점// 1. 불변성 - 증분 캐싱에 필수// 2. 값 동등성 - Equals/GetHashCode 자동 생성// 3. 간결함 - 적은 코드
public sealed record EntityIdInfo(string TypeName, string Namespace);
// ❌ class로 구현 시 (장황함)public sealed class EntityIdInfo : IEquatable<EntityIdInfo>{ public string TypeName { get; } public string Namespace { get; }
public EntityIdInfo(string typeName, string @namespace) { TypeName = typeName; Namespace = @namespace; }
public bool Equals(EntityIdInfo? other) { /* ... */ } public override bool Equals(object? obj) { /* ... */ } public override int GetHashCode() { /* ... */ }}5. 코드 생성 템플릿 설계
섹션 제목: “5. 코드 생성 템플릿 설계”메타데이터가 준비되면 실제로 생성할 코드의 형태를 결정합니다. 먼저 “완성된 코드가 어떤 모습이어야 하는지” 목표를 정한 뒤, 그 코드를 StringBuilder로 조립하는 방식으로 구현합니다.
생성할 코드 구조 결정
섹션 제목: “생성할 코드 구조 결정”// 목표: 이런 코드가 생성되어야 함[DebuggerDisplay("{ToString()}")]public readonly partial record struct ProductId : IEntityId<ProductId>, IComparable<ProductId>{ public Ulid Value { get; }
private ProductId(Ulid value) => Value = value;
public static ProductId New() => new(Ulid.NewUlid()); public static ProductId Create(Ulid value) => new(value); public static ProductId Empty => new(Ulid.Empty);
public int CompareTo(ProductId other) => Value.CompareTo(other.Value);
public static bool operator >(ProductId left, ProductId right) => left.CompareTo(right) > 0; // ...
public override string ToString() => Value.ToString();}StringBuilder로 생성
섹션 제목: “StringBuilder로 생성”private static string GenerateEntityIdSource(EntityIdInfo info){ var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine(); sb.AppendLine("using System;"); sb.AppendLine("using System.Diagnostics;"); sb.AppendLine(); sb.AppendLine($"namespace {info.Namespace};"); sb.AppendLine(); sb.AppendLine($"[DebuggerDisplay(\"{{ToString()}}\")]"); sb.AppendLine($"public readonly partial record struct {info.TypeName} : IEntityId<{info.TypeName}>, IComparable<{info.TypeName}>"); sb.AppendLine("{"); sb.AppendLine(" public Ulid Value { get; }"); sb.AppendLine(); sb.AppendLine($" private {info.TypeName}(Ulid value) => Value = value;"); sb.AppendLine(); sb.AppendLine($" public static {info.TypeName} New() => new(Ulid.NewUlid());"); sb.AppendLine($" public static {info.TypeName} Create(Ulid value) => new(value);"); sb.AppendLine($" public static {info.TypeName} Empty => new(Ulid.Empty);"); // ... sb.AppendLine("}");
return sb.ToString();}6. 단위 테스트 작성
섹션 제목: “6. 단위 테스트 작성”코드 생성 로직이 구현되면 Verify 스냅샷 테스트로 생성 결과를 검증합니다. 생성된 코드의 정확성은 수작업으로 확인하기 어렵기 때문에, 스냅샷으로 기대 결과를 고정해 두는 것이 필수적입니다.
테스트 프로젝트 설정
섹션 제목: “테스트 프로젝트 설정”<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <TargetFramework>net9.0</TargetFramework> <IsPackable>false</IsPackable> </PropertyGroup>
<ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" /> <PackageReference Include="xunit" Version="2.9.0" /> <PackageReference Include="Verify.Xunit" Version="26.6.0" /> <PackageReference Include="Shouldly" Version="4.2.1" /> </ItemGroup>
<ItemGroup> <ProjectReference Include="..\MyCompany.SourceGenerator\MyCompany.SourceGenerator.csproj" /> </ItemGroup>
</Project>Verify 스냅샷 테스트
섹션 제목: “Verify 스냅샷 테스트”public sealed class EntityIdGeneratorTests{ private readonly EntityIdGenerator _sut = new();
[Fact] public Task EntityIdGenerator_ShouldGenerate_BasicEntityId() { // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[EntityId] public readonly partial record struct ProductId; """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual); }}테스트 러너 구현
섹션 제목: “테스트 러너 구현”public static class SourceGeneratorTestRunner{ private static readonly Type[] RequiredTypes = [ typeof(object), // System.Runtime ];
public static string? Generate<TGenerator>( this TGenerator generator, string sourceCode) where TGenerator : IIncrementalGenerator, new() { // 1. 구문 트리 생성 var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
// 2. 참조 어셈블리 수집 var references = RequiredTypes .Select(t => t.Assembly.Location) .Distinct() .Select(loc => MetadataReference.CreateFromFile(loc)) .ToList();
// 3. 컴파일레이션 생성 var compilation = CSharpCompilation.Create( assemblyName: "TestAssembly", syntaxTrees: [syntaxTree], references: references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
// 4. 생성기 실행 var driver = CSharpGeneratorDriver.Create(generator); driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( compilation, out var outputCompilation, out var diagnostics);
// 5. 진단 검증 var errors = outputCompilation.GetDiagnostics() .Where(d => d.Severity == DiagnosticSeverity.Error) .ToList();
errors.ShouldBeEmpty();
// 6. 생성된 코드 반환 var result = driver.GetRunResult(); return result.GeneratedTrees .Select(t => t.GetText().ToString()) .LastOrDefault(); // 마지막 생성 파일 (속성 제외) }}7. 패키징 및 배포
섹션 제목: “7. 패키징 및 배포”테스트를 통과하면 소스 생성기를 NuGet 패키지로 만들어 배포합니다. 소스 생성기는 일반 라이브러리와 달리 analyzers/dotnet/cs 경로에 DLL을 포함해야 하므로, csproj에서 이미 설정한 패키징 구성이 여기서 효과를 발휘합니다.
NuGet 패키지 생성
섹션 제목: “NuGet 패키지 생성”# Release 구성으로 빌드 (중요!)dotnet pack -c Release -o ./packages
# 로컬 피드에 추가dotnet nuget push ./packages/MyCompany.SourceGenerator.1.0.0.nupkg \ --source ./local-feed소비자 프로젝트에서 사용
섹션 제목: “소비자 프로젝트에서 사용”<ItemGroup> <!-- 분석기/생성기로 참조 --> <PackageReference Include="MyCompany.SourceGenerator" Version="1.0.0" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /></ItemGroup>개발 체크리스트
섹션 제목: “개발 체크리스트”## 소스 생성기 개발 체크리스트
### 프로젝트 설정- [ ] TargetFramework: netstandard2.0- [ ] EnforceExtendedAnalyzerRules: true- [ ] IsRoslynComponent: true- [ ] Microsoft.CodeAnalysis.CSharp 참조
### 구현- [ ] IIncrementalGenerator 구현- [ ] [Generator] 속성 적용- [ ] 마커 속성 Post-Initialization으로 생성- [ ] ForAttributeWithMetadataName으로 대상 필터링- [ ] 메타데이터 record 클래스 정의- [ ] 코드 생성 템플릿 작성
### 테스트- [ ] Verify 스냅샷 테스트 작성- [ ] 경계 케이스 테스트 추가- [ ] 에러 케이스 테스트 추가
### 품질- [ ] 생성 코드에 // <auto-generated/> 헤더- [ ] #nullable enable 포함- [ ] ExcludeFromCodeCoverage 속성- [ ] 결정적 출력 (동일 입력 → 동일 출력)
### 배포- [ ] dotnet pack -c Release- [ ] 버전 번호 업데이트- [ ] CHANGELOG 작성한눈에 보는 정리
섹션 제목: “한눈에 보는 정리”소스 생성기 개발의 7단계 표준 절차를 정리합니다.
| 단계 | 핵심 작업 |
|---|---|
| 1. 프로젝트 설정 | netstandard2.0, Roslyn 패키지 참조 |
| 2. 속성 정의 | Post-Initialization으로 마커 속성 생성 |
| 3. 심볼 분석 | ForAttributeWithMetadataName으로 대상 필터링 |
| 4. 메타데이터 | record로 불변 데이터 구조 정의 |
| 5. 코드 생성 | StringBuilder + Raw String Literals |
| 6. 테스트 | Verify 스냅샷 테스트 |
| 7. 배포 | dotnet pack -c Release |
이 7단계는 ObservablePortGenerator를 포함한 모든 소스 생성기에 동일하게 적용되는 표준 절차입니다. 다음 절부터는 이 절차를 따라 세 가지 실전 생성기를 구현하면서, 각 단계의 구체적인 구현 방법을 확인합니다.
FAQ
섹션 제목: “FAQ”Q1: 소스 생성기 프로젝트의 TargetFramework가 netstandard2.0이어야 하는 이유는 무엇인가요?
섹션 제목: “Q1: 소스 생성기 프로젝트의 TargetFramework가 netstandard2.0이어야 하는 이유는 무엇인가요?”A: 소스 생성기는 Roslyn 컴파일러 프로세스 내에서 실행되며, 컴파일러는 netstandard2.0 어셈블리만 로드합니다. net8.0이나 net9.0으로 빌드하면 컴파일러가 생성기 어셈블리를 로드하지 못해 “생성기가 실행되지 않는” 현상이 발생합니다.
Q2: RegisterPostInitializationOutput으로 마커 속성을 생성하는 것과 별도 NuGet 패키지로 배포하는 것의 차이는 무엇인가요?
섹션 제목: “Q2: RegisterPostInitializationOutput으로 마커 속성을 생성하는 것과 별도 NuGet 패키지로 배포하는 것의 차이는 무엇인가요?”A: Post-Initialization 방식은 소스 생성기 하나로 속성과 생성 코드를 모두 제공하여 의존성이 단순합니다. 반면 별도 패키지로 분리하면 속성만 참조하는 프로젝트(인터페이스 프로젝트 등)에서 생성기 의존성 없이 속성을 사용할 수 있습니다. ObservablePortGenerator처럼 단일 프로젝트 내에서 사용하는 경우 Post-Initialization이 간편합니다.
Q3: 7단계 워크플로우에서 가장 시간이 많이 걸리는 단계는 어디인가요?
섹션 제목: “Q3: 7단계 워크플로우에서 가장 시간이 많이 걸리는 단계는 어디인가요?”A: 3단계(심볼 분석 전략)와 5단계(코드 생성 템플릿 설계)가 가장 많은 시간을 요구합니다. 어떤 정보를 추출할지 결정하는 것이 이후 모든 단계의 기반이 되고, 생성할 코드의 목표 형태를 먼저 확정해야 구현이 수월해지기 때문입니다. 반면 1단계(프로젝트 설정)와 7단계(패키징)는 템플릿을 재사용하면 빠르게 완료됩니다.
이 절에서 정리한 7단계 워크플로우를 실제로 적용해 봅니다. 다음 절에서는 DDD에서 가장 흔히 필요한 강타입 Entity Id 생성기를 구현합니다.