C#에서 증분 소스 생성기 마스터하기: 예제가 포함된 전체 가이드 | Ali Hamza Ansari


C# 증분 소스 제너레이터 완전 가이드

개요

.NET 6에서 도입된 증분 소스 제너레이터(Incremental Source Generator)는 기존 소스 제너레이터의 성능을 대폭 개선한 고성능 대체재입니다. 캐싱을 통해 불필요한 중복 컴파일 및 코드 생성을 방지하여 성능 오버헤드를 줄입니다.

소스 제너레이터란?

정의

소스 제너레이터는 컴파일 과정에서 소스 코드를 생성할 수 있게 해주는 Roslyn 컴파일러 기능입니다. 반복적이고 오류가 발생하기 쉬운 코드를 줄여 더 빠른 개발과 자동화를 달성할 수 있습니다.

증분 소스 제너레이터의 장점

  • 고성능: 결과를 캐싱하여 성능 오버헤드 감소
  • 효율성: 불필요한 중복 컴파일 방지
  • 속도: 기존 소스 제너레이터보다 빠른 코드 생성

프로젝트 설정

1. 컨슈머 프로젝트 생성

프로젝트 생성

dotnet new console -n ComparativeSourceGenerators

패키지 설치

dotnet add package BenchmarkDotNet

모델 클래스 생성

Blog 모델:

public class Blog
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public string Author { get; set; }
    public DateTime PublishedOn { get; set; }
    public int Views { get; set; }
    public bool IsPublished { get; set; }
    public string[] Tags { get; set; }
}

Course 모델:

public class Course
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Instructor { get; set; }
    public int DurationHours { get; set; }
    public double Rating { get; set; }
    public bool IsActive { get; set; }
    public string Description { get; set; }
}

2. 리플렉션 기반 JSON 직렬화 구현

성능 비교를 위한 리플렉션 기반 직렬화 클래스:

using System.Reflection;
using System.Text;

namespace ComparativeSourceGenerators;

public static class ReflectionJsonSerializer
{
    public static string ToJsonReflection<T>(this T obj)
    {
        var sb = new StringBuilder();
        sb.Append("{");

        var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);

        for (int i = 0; i < props.Length; i++)
        {
            var prop = props[i];
            var value = prop.GetValue(obj);
            var comma = i < props.Length - 1 ? "," : "";
            sb.Append($"\"{prop.Name}\": \"{value}\"{comma}");
        }

        sb.Append("}");
        return sb.ToString();
    }
}

기존 소스 제너레이터 구현

1. 프로젝트 생성 및 설정

dotnet new classlib -n ESourceGenerator

2. 프로젝트 파일 설정(.csproj)

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>12</LangVersion>
        <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
        <OutputItemType>Analyzer</OutputItemType>
        <IncludeBuildOutput>false</IncludeBuildOutput>
        <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="4.14.0"/>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
    </ItemGroup>
</Project>

주요 설정 항목:

  • TargetFramework: netstandard2.0으로 설정
  • EmitCompilerGeneratedFiles: 생성된 코드 검사 가능
  • OutputItemType: Analyzer로 설정하여 Roslyn 분석기/제너레이터로 마킹
  • CompilerGeneratedFilesOutputPath: Generated 디렉토리를 출력 위치로 지정

3. 소스 제너레이터 구현

using System;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace ESourceGenerator;

[Generator]
public class CustomSerializationGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context)
    {
        var serializableAttr = context.Compilation.GetTypeByMetadataName("System.SerializableAttribute");
        if (serializableAttr == null) return;

        foreach (var syntaxTree in context.Compilation.SyntaxTrees)
        {
            var semanticModel = context.Compilation.GetSemanticModel(syntaxTree);
            var classDeclarations = syntaxTree.GetRoot().DescendantNodes()
                .OfType<Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax>();

            foreach (var classDecl in classDeclarations)
            {
                var classSymbol = semanticModel.GetDeclaredSymbol(classDecl) as INamedTypeSymbol;
                if (classSymbol == null) continue;

                if (!classSymbol.GetAttributes().Any(attr =>
                    SymbolEqualityComparer.Default.Equals(attr.AttributeClass, serializableAttr)))
                    continue;

                // 코드 생성 로직
                GenerateExtensionMethod(context, classSymbol);
            }
        }
    }
}

증분 소스 제너레이터 구현

1. 프로젝트 생성 및 설정

dotnet new classlib -n ESourceIncGenerator

2. 증분 제너레이터 구현

using System;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace ESourceIncGenerator;

[Generator]
public class CustomSerializationIncrementalGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1단계: [Serializable] 속성을 가진 모든 클래스 선언 찾기
        var classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (s, _) => IsCandidate(s), // 빠른 필터
                transform: static (ctx, _) => GetSemanticTarget(ctx)) // 심볼 가져오기
            .Where(static m => m is not null)!;

        // 2단계: 일치하는 각 클래스에 대해 코드 생성
        context.RegisterSourceOutput(classDeclarations, static (spc, classSymbol) =>
        {
            GenerateSerializer(spc, classSymbol!);
        });
    }

    private static bool IsCandidate(SyntaxNode node) =>
        node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0;

    private static INamedTypeSymbol? GetSemanticTarget(GeneratorSyntaxContext context)
    {
        var classDecl = (ClassDeclarationSyntax)context.Node;
        var symbol = context.SemanticModel.GetDeclaredSymbol(classDecl) as INamedTypeSymbol;
        if (symbol == null) return null;

        var serializableAttr = context.SemanticModel.Compilation
            .GetTypeByMetadataName("System.SerializableAttribute");

        if (serializableAttr == null) return null;

        // [Serializable]이 있는 클래스만 선택
        return symbol.GetAttributes().Any(a =>
            SymbolEqualityComparer.Default.Equals(a.AttributeClass, serializableAttr))
            ? symbol
            : null;
    }
}

핵심 차이점

IIncrementalGenerator vs ISourceGenerator:

  • IIncrementalGenerator: 새로운 Roslyn 증분 제너레이터 구현
  • Initialize 메서드: Roslyn이 제너레이터 설정 시 호출하는 진입점

효율적인 파이프라인:

var classDeclarations = context.SyntaxProvider
    .CreateSyntaxProvider(
        predicate: static (s, _) => IsCandidate(s), // 빠른 필터
        transform: static (ctx, _) => GetSemanticTarget(ctx)) // 심볼 가져오기
    .Where(static m => m is not null)!;
  • predicate: 모든 구문 노드에서 실행되어 후보를 필터링
  • transform: 후보에 대해 의미론적 분석을 실행하여 유효성 확인
  • 클래스 선언이 변경될 때만 반응하는 효율적인 파이프라인 설정

제너레이터 사용법

1. 제너레이터 분석기 가져오기

컨슈머 프로젝트의 .csproj 파일에 추가:

<ItemGroup>
  <Analyzer Include="..\ESourceGenerator\bin\Debug\netstandard2.0\ESourceGenerator.dll" />
  <Analyzer Include="..\ESourceIncGenerator\bin\Debug\netstandard2.0\ESourceIncGenerator.dll" />
</ItemGroup>

2. 모델에 Serializable 속성 추가

[Serializable]
public class Blog
{
    // 속성들...
}

[Serializable]
public class Course
{
    // 속성들...
}

3. 벤치마킹 클래스 준비

using BenchmarkDotNet.Attributes;
using ComparativeSourceGenerators.Models;
using ComparativeSourceGenerators.Models.Generated;

namespace ComparativeSourceGenerators;

[MemoryDiagnoser] // 메모리 할당 추적
public class SerializationBenchmarks
{
    private readonly Blog _blog;
    private readonly Course _course;

    private const int Iterations = 10000;

    [Benchmark]
    public string Reflection_Blog()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++)
        {
            result = _blog.ToJsonReflection(); // 리플렉션
        }
        return result;
    }

    [Benchmark]
    public string SourceGen_Blog()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++)
        {
            result = _blog.ToJsonSourceGen(); // 소스 제너레이터
        }
        return result;
    }

    [Benchmark]
    public string IncrementalSourceGen_Blog()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++) 
            result = _blog.ToJsonIncremental(); // 증분 소스 제너레이터
        return result;
    }
}

4. 프로그램 실행

using BenchmarkDotNet.Running;
using ComparativeSourceGenerators;

Console.WriteLine("Hello, World!"); 
var summary = BenchmarkRunner.Run<SerializationBenchmarks>();

5. 빌드 및 실행

dotnet build
dotnet run -c Release

성능 분석 결과

벤치마킹 결과에 따르면:

  1. 리플렉션: 가장 낮은 성능, 런타임 실행으로 인해 느림, 하지만 가장 적은 메모리 사용
  2. 소스 제너레이터: 리플렉션보다 우수한 성능
  3. 증분 소스 제너레이터: 효율적인 후보 필터링으로 인해 최고 성능

중요한 특징:

  • 코드 변경 후 후속 시도에서 증분 컴파일이 변경되지 않은 부분에 대해 캐싱을 활용
  • 코드 재생성의 추가 실행을 제거

캐시 친화적인 증분 제너레이터 작성 모범 사례

1. 정보를 일찍 추출하기

구문 트리의 크고 비싼 객체를 운반하는 대신, 관련 없는 노드와 심볼을 필터링하여 데이터 크기를 일찍 줄이세요.

2. 가능한 곳에서 값 타입 사용

증분 제너레이터는 마지막 실행 이후 값이 변경되었는지 감지하는 데 크게 의존합니다. 값 타입과 불변 컬렉션은 저렴하고 예측 가능한 동등성 검사를 제공합니다.

사용 권장: record, struct, tuples, ImmutableArray<T>

3. 파이프라인에서 여러 변환 사용

각 변환은 파이프라인의 체크포인트 역할을 합니다. 하나의 복잡한 Select 표현식 대신 SelectMany, Combine 등을 사용하여 더 작은 단계로 나누세요.

4. 데이터 모델 구축

원시 입력을 모든 곳에서 전달하는 것보다 중간 모델을 사용하는 것이 좋습니다.

나쁜 예시:

context.RegisterSourceOutput(classDeclarations, (spc, classDecl) =>
{
    var symbol = compilation.GetSemanticModel(classDecl.SyntaxTree).GetDeclaredSymbol(classDecl);
    var code = $"public class {symbol.Name}Dto {{ }}";
    spc.AddSource($"{symbol.Name}Dto.g.cs", code);
});

좋은 예시:

record ClassModel(string Name, string Namespace);

var classModels = classDeclarations
    .Select((classDecl, ct) =>
    {
        var symbol = compilation.GetSemanticModel(classDecl.SyntaxTree).GetDeclaredSymbol(classDecl);
        return new ClassModel(symbol.Name, symbol.ContainingNamespace.ToString());
    });

context.RegisterSourceOutput(classModels, (spc, model) =>
{
    var code = $"namespace {model.Namespace} {{ public class {model.Name}Dto {{ }} }}";
    spc.AddSource($"{model.Name}Dto.g.cs", code);
});

5. 결합 순서 고려

Combine() 메서드의 순서가 중요합니다. 불안정한 것(compilation 같은)과 너무 일찍 결합하면 캐시가 무용지물이 됩니다.

나쁜 예시:

var combined = texts.Combine(compilation);

좋은 예시:

var assemblyName = context.CompilationProvider
    .Select((c, _) => c.AssemblyName);

var textModels = context.AdditionalTextsProvider
    .Select((t, _) => t.GetText()?.ToString());

var combined = textModels.Combine(assemblyName);

실용적인 팁과 주의사항

주의사항

  • 증분 소스 제너레이터는 기존 소스 제너레이터의 후속 버전이며 이전 버전은 더 이상 사용되지 않음
  • 리플렉션은 구현이 쉽지만 런타임에서 작동하여 소스 제너레이터보다 느림
  • 모델이 복잡해질수록 성능 차이가 더욱 뚜렷해짐

실용적인 팁

  • 고성능 시나리오에서는 리플렉션을 피하고 소스 제너레이터 사용
  • 프로젝트에서는 증분 제너레이터를 선택하는 것이 좋음
  • 벤치마킹은 반드시 Release 모드에서 실행
  • 생성된 코드는 Generated 폴더에서 확인 가능

결론

소스 제너레이터는 .NET SDK 6의 새로운 추가 기능으로 개발자가 코드 생성을 자동화할 수 있게 해줍니다. 증분 소스 제너레이터는 캐싱을 활용하여 결과를 저장하는 업그레이드된 제너레이터입니다. 증분 소스 제너레이터는 소스 제너레이터의 효율적인 버전일 뿐만 아니라 후속 버전이며, 이전 버전들은 더 이상 사용되지 않습니다.

1개의 좋아요