C#LAB
C# 4.7 · 4부. 고급 문법

소스 제너레이터 개념

빌드할 때 코드를 만들어 내는 방식입니다.

예상 학습 시간 22분 난이도 고급
개념 설명

빌드할 때 코드를 만들어 둡니다

4.3에서 형식을 실행 중에 들여다보았습니다. 비용이 들고, 트리밍이 알아보지 못해 배포한 뒤에 무너질 수 있었습니다.

소스 제너레이터는 그 일을 빌드할 때 합니다. 컴파일러가 소스를 읽는 도중에 끼어들어 C# 코드를 더 만들어 넣습니다. 만들어진 것은 평범한 코드라, 그 뒤로는 반사도 트리밍 문제도 없습니다.

리플렉션소스 제너레이터
언제실행할 때마다빌드할 때 한 번
비용부를 때마다 듭니다실행 중에는 없습니다
틀렸을 때실행하다 알게 됩니다빌드가 멈춥니다
트리밍지워진 것을 못 찾습니다코드로 보이므로 남습니다
눈으로볼 것이 없습니다만들어진 파일을 읽을 수 있습니다

이미 사용하고 계실 가능성이 큽니다. [GeneratedRegex]·[LoggerMessage]· System.Text.Json 의 직렬화가 모두 이 방식입니다.

최소 예제

표를 붙이면 코드가 생깁니다

Person 에는 ToString 이 없습니다. 출력은 소스 생성기가 만들어 넣은 것입니다.

Program.cs
using Gen;

var p = new Person { Name = "홍길동", Age = 32 };
Console.WriteLine(p);

// ToString 을 적지 않았습니다. AutoToString 이라는 표만 붙였습니다.
[AutoToString]
partial class Person {
    public string Name { get; set; } = "";
    public int Age { get; set; }
}
출력
Person { Name = 홍길동, Age = 32 }

이 예제는 브라우저에서 실행할 수 없습니다. 소스 생성기는 컴파일하는 동안 다른 프로젝트로서 실행되는데, 이 화면의 실행기는 보이는 파일 하나만 컴파일하기 때문에 생성기가 낄 자리가 없습니다. 결과를 보려면 아래 절차대로 프로젝트를 만들어야 합니다.

빌드할 때 아래 파일이 만들어졌습니다. 손으로 적었을 코드와 똑같습니다.

// <auto-generated/>   Person.ToString.g.cs
partial class Person {
    public override string ToString() => $"Person {{ Name = {Name}, Age = {Age} }}";
}

partial 이 열쇠입니다. 제너레이터는 있는 코드를 고치지 못하고 더 넣기만 합니다. 나머지 반쪽을 만들어 붙이는 것이라, 받는 쪽이 partial 이어야 합니다.

상세 사용법

만들어진 코드를 눈으로 보기

.csproj 에 두 줄을 넣으면 만들어진 파일이 디스크에 남습니다. 무엇이 만들어졌는지 모를 때 가장 먼저 켜 보는 것입니다.

<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>generated</CompilerGeneratedFilesOutputPath>

그대로 두면 다음 빌드가 깨집니다. 내보낸 파일이 프로젝트 폴더에 들어가 소스로 다시 컴파일되므로 같은 형식이 둘이 됩니다.

// error CS0433: 'Phone_0' 형식이 'gen, Version=1.0.0.0, …' 및
// 'gen, Version=1.0.0.0, …'에 모두 있습니다.

한 줄을 더 넣어 막습니다.

<ItemGroup>
    <Compile Remove="generated/**" />
</ItemGroup>

하나 만들어 보기

제너레이터는 따로 프로젝트를 만들어 씁니다. 컴파일러 안에서 도는 것이라 netstandard2.0 이어야 하고, 결과물을 참조하는 것이 아니라 분석기로 붙입니다.

<!-- 제너레이터 쪽 -->
<TargetFramework>netstandard2.0</TargetFramework>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />

<!-- 사용하는 쪽 -->
<ProjectReference Include="..\Gen\Gen.csproj"
    OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

제너레이터 자체는 세 단계입니다.

[Generator]
public class ToStringGenerator : IIncrementalGenerator {
    public void Initialize(IncrementalGeneratorInitializationContext context) {
        // 1) 표를 먼저 만들어 둡니다. 사용하는 쪽이 따로 선언하지 않아도 됩니다.
        context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
            "AutoToStringAttribute.g.cs", /* 특성 선언 */));

        // 2) 표가 붙은 형식만 걸러 냅니다.
        var targets = context.SyntaxProvider.ForAttributeWithMetadataName(
            "Gen.AutoToStringAttribute",
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol);

        // 3) 형식마다 코드를 만들어 냅니다.
        context.RegisterSourceOutput(targets, static (spc, type) => {
            var props = type.GetMembers().OfType<IPropertySymbol>()
                .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
                .Select(p => p.Name).ToArray();

            spc.AddSource(type.Name + ".ToString.g.cs", /* 만들어 낸 코드 */);
        });
    }
}

ForAttributeWithMetadataName 이 중요합니다. 표가 붙은 것만 걸러 주므로 나머지 파일은 아예 보지 않습니다. 모든 형식을 탐색하도록 적으면 파일을 고칠 때마다 편집기가 무거워집니다.

IIncrementalGenerator증분이 그 뜻입니다. 바뀐 것만 다시 만들도록 되어 있어, 걸러 내는 조건을 좁게 적을수록 빨라집니다.

코드를 만드는 코드입니다

적는 것이 문자열이라는 점을 잊기 쉽습니다. 위 예제가 만들어 내는 것은 보간 문자열이고, 거기에 중괄호를 글자로 넣으려면 두 번 적어야 합니다.

한 번 적으면
"$\"" + name + " { " + …

// 만들어진 코드
// $"Person { Name = {Name} }"

// error CS1073: 예기치 않은 토큰 '{'
두 번 적으면
"$\"" + name + " {{ " + …

// 만들어진 코드
// $"Person {{ Name = {Name} }}"

// 됩니다

오류는 만들어진 파일에서 납니다. 제너레이터 코드가 아니라 그것이 뱉은 코드가 틀린 것이라, 위에서 켠 EmitCompilerGeneratedFiles 없이는 무엇이 잘못됐는지 보기 어렵습니다.

이미 사용하고 있는 것들

[GeneratedRegex] 는 정규식을 빌드할 때 C# 코드로 풀어 둡니다. 만들어진 파일에는 패턴을 풀어 쓴 해설까지 영어로 붙습니다.

[GeneratedRegex(@"^\d{3}-\d{4}$")]
private static partial Regex Phone();

// 만들어진 파일에 이렇게 붙습니다
// ○ Match a Unicode digit exactly 3 times.
// ○ Match '-'.
// ○ Match a Unicode digit exactly 4 times.

세 가지를 300만 번 맞춰 보고, 첫 한 번에 걸리는 시간도 함께 쟀습니다.

방법300만 번첫 한 번AOT·트리밍
Regex.IsMatch(정적)236ms0.98ms됩니다
[GeneratedRegex]153ms1.50ms됩니다
RegexOptions.Compiled76ms8.32ms안 됩니다

가장 빠른 것이 언제나 맞는 것은 아닙니다. Compiled 는 실행 중에 기계어를 만들어 두는 방식이라 시작이 여덟 배 넘게 걸리고, AOT 에서는 아예 되지 않습니다. [GeneratedRegex] 는 그 사이를 메웁니다.

4.3의 그 자리

JSON 직렬화가 가장 뚜렷합니다. 같은 프로그램을 트리밍해 게시하고 두 줄을 나란히 두어 보았습니다.

// 반사로 훑는 쪽
JsonSerializer.Serialize(p);

// warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize…'
// which has 'RequiresUnreferencedCode'

// 소스 제너레이터가 만들어 둔 쪽
JsonSerializer.Serialize(p, AppJson.Default.Person);

// 경고 없음

[JsonSerializable(typeof(Person))]
partial class AppJson : JsonSerializerContext { }

경고를 무시하고 게시하면 실행할 때 죽습니다.

// Unhandled exception. System.InvalidOperationException:
// Reflection-based serialization has been disabled for this application.
// Either use the source generator APIs or explicitly configure the
// 'JsonSerializerOptions.TypeInfoResolver' property.

아래쪽만 남기고 게시하면 경고도 없고 그대로 동작합니다.

// {"Name":"홍길동","Age":32}

할 수 없는 것

못 하는 것까닭
있는 코드를 고치기더 넣기만 합니다. 그래서 받는 쪽이 partial 이어야 합니다
다른 제너레이터의 결과 보기같은 단계에서 함께 도므로 서로의 산출물을 볼 수 없습니다
파일 읽기·네트워크빌드할 때 도는 것이라 하지 않는 것이 맞습니다. 필요하면 AdditionalFiles 로 받습니다
오류를 대신 고치기고쳐 주는 것은 분석기와 코드 수정의 몫입니다

모든 반복을 제너레이터로 옮길 일은 아닙니다. 만드는 쪽도 유지해야 하고, 만들어진 코드는 디버깅할 때 한 단계 멀어집니다. 형식마다 똑같은 코드를 손으로 적고 있고 그것이 계속 늘어난다면 그때 생각할 것입니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

7.0
8.0
9.0
10.0
11.0
12.0
14.0
  • 9.0소스 제너레이터(ISourceGenerator)
  • 10.0IIncrementalGenerator · [LoggerMessage]
  • 11.0[GeneratedRegex] · ForAttributeWithMetadataName
C# 9.0+

처음 나온 ISourceGenerator파일을 고칠 때마다 전부 다시 돌아 편집기가 무거워졌습니다. 지금은 IIncrementalGenerator 만 사용합니다. 바뀐 것만 다시 만들도록 짜여 있고, C# 11의 ForAttributeWithMetadataName 이 표가 붙은 형식만 걸러 주면서 걸러 내는 비용까지 줄었습니다. 새로 적으신다면 이 둘로 시작하십시오.

실습 문제

직접 해보기

1. 무엇이 만들어졌는지 보기 난이도 하

[GeneratedRegex] 를 하나 적고, 빌드가 만들어 낸 파일을 찾아 열어보세요. 켜야 할 설정과, 켠 뒤에 빌드가 깨질 때 무엇을 더 넣어야 하는지 적어보세요.

설정 두 줄로 파일이 디스크에 남습니다. 남은 파일은 프로젝트 폴더 안에 있으므로 다음 빌드에서 소스로 잡힙니다.
<!-- 1) 내보내게 합니다 --> <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> <CompilerGeneratedFilesOutputPath>generated</CompilerGeneratedFilesOutputPath> <!-- 2) 내보낸 것을 다시 컴파일하지 않게 합니다 --> <ItemGroup> <Compile Remove="generated/**" /> </ItemGroup> // 2)를 빠뜨리면 // error CS0433: 'Phone_0' 형식이 … 및 …에 모두 있습니다. // 파일은 여기 생깁니다 // generated/System.Text.RegularExpressions.Generator/ // System.Text.RegularExpressions.Generator.RegexGenerator/RegexGenerator.g.cs // 폴더 이름은 "어셈블리 이름/제너레이터 전체 이름" 입니다. // 제너레이터가 여럿이면 여기서 누가 무엇을 만들었는지 갈립니다.
2. 반사를 걷어내기 난이도 중

4.3의 실습 1에서 만든 Validate 는 실행할 때마다 속성을 탐색합니다. 같은 일을 제너레이터로 옮긴다면 무엇을 만들어 내야 할지 적어보세요. 코드를 다 적을 필요는 없고 만들어질 결과물만 적으면 됩니다.

반사가 실행 중에 알아내던 것은 "어느 속성에 표가 붙었는가" 입니다. 그것은 빌드할 때 이미 정해져 있습니다.
// 반사로 하던 것 — 실행할 때마다 속성을 탐색하고 특성을 봅니다 foreach (var prop in obj.GetType().GetProperties()) { if (prop.GetCustomAttribute<RequiredAttribute>() is null) continue; … } // 제너레이터가 만들어 낼 것 — 형식마다 한 벌씩 partial class Member { public List<string> Validate() { var missing = new List<string>(); if (string.IsNullOrWhiteSpace(Name)) missing.Add("Name"); if (string.IsNullOrWhiteSpace(Email)) missing.Add("Email"); return missing; } } // 속성 이름이 문자열이 아니라 코드가 됩니다. 그래서 // - 이름을 바꾸면 빌드가 멈춥니다(실행할 때 null 이 아니라) // - 트리밍이 지울 것이 없습니다 // - 반사 비용이 사라집니다 // 대신 Member 를 partial 로 바꿔야 하고, 검사 규칙이 늘 때마다 // 제너레이터 쪽을 고쳐야 합니다. 형식이 서넛뿐이면 반사 쪽이 // 더 간단합니다.
요약
  • 소스 제너레이터는 빌드할 때 C# 코드를 만들어 넣습니다. 그 뒤로는 평범한 코드입니다.
  • 더 넣기만 하고 고치지는 못합니다. 받는 쪽이 partial 이어야 합니다.
  • 틀리면 빌드가 멈춥니다. 리플렉션은 실행하다 알게 됩니다.
  • 트리밍과 AOT 에서 지워질 것이 없습니다. 4.3에서 본 IL 경고가 사라집니다.
  • EmitCompilerGeneratedFiles 로 만들어진 파일을 봅니다. Compile Remove 를 함께 넣으세요.
  • 새로 적는다면 IIncrementalGeneratorForAttributeWithMetadataName 입니다.
  • 만들어 내는 것도 코드입니다. 중괄호를 글자로 넣으려면 두 번 적습니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.