소스 제너레이터 개념
빌드할 때 코드를 만들어 내는 방식입니다.
빌드할 때 코드를 만들어 둡니다
4.3에서 형식을 실행 중에 들여다보았습니다. 비용이 들고, 트리밍이 알아보지 못해 배포한 뒤에 무너질 수 있었습니다.
소스 제너레이터는 그 일을 빌드할 때 합니다. 컴파일러가 소스를 읽는 도중에 끼어들어 C# 코드를 더 만들어 넣습니다. 만들어진 것은 평범한 코드라, 그 뒤로는 반사도 트리밍 문제도 없습니다.
| 리플렉션 | 소스 제너레이터 | |
|---|---|---|
| 언제 | 실행할 때마다 | 빌드할 때 한 번 |
| 비용 | 부를 때마다 듭니다 | 실행 중에는 없습니다 |
| 틀렸을 때 | 실행하다 알게 됩니다 | 빌드가 멈춥니다 |
| 트리밍 | 지워진 것을 못 찾습니다 | 코드로 보이므로 남습니다 |
| 눈으로 | 볼 것이 없습니다 | 만들어진 파일을 읽을 수 있습니다 |
이미 사용하고 계실 가능성이 큽니다.
[GeneratedRegex]·[LoggerMessage]·
System.Text.Json 의 직렬화가 모두 이 방식입니다.
표를 붙이면 코드가 생깁니다
Person 에는 ToString 이 없습니다. 출력은 소스 생성기가 만들어 넣은 것입니다.
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; } }
이 예제는 브라우저에서 실행할 수 없습니다. 소스 생성기는 컴파일하는 동안 다른 프로젝트로서 실행되는데, 이 화면의 실행기는 보이는 파일 하나만 컴파일하기 때문에 생성기가 낄 자리가 없습니다. 결과를 보려면 아래 절차대로 프로젝트를 만들어야 합니다.
빌드할 때 아래 파일이 만들어졌습니다. 손으로 적었을 코드와 똑같습니다.
// <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(정적) | 236ms | 0.98ms | 됩니다 |
| [GeneratedRegex] | 153ms | 1.50ms | 됩니다 |
| RegexOptions.Compiled | 76ms | 8.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 로 받습니다 |
| 오류를 대신 고치기 | 고쳐 주는 것은 분석기와 코드 수정의 몫입니다 |
모든 반복을 제너레이터로 옮길 일은 아닙니다. 만드는 쪽도 유지해야 하고, 만들어진 코드는 디버깅할 때 한 단계 멀어집니다. 형식마다 똑같은 코드를 손으로 적고 있고 그것이 계속 늘어난다면 그때 생각할 것입니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 9.0소스 제너레이터(ISourceGenerator)
- 10.0IIncrementalGenerator · [LoggerMessage]
- 11.0[GeneratedRegex] · ForAttributeWithMetadataName
처음 나온 ISourceGenerator 는
파일을 고칠 때마다 전부 다시 돌아 편집기가 무거워졌습니다.
지금은 IIncrementalGenerator 만 사용합니다.
바뀐 것만 다시 만들도록 짜여 있고,
C# 11의 ForAttributeWithMetadataName 이 표가 붙은
형식만 걸러 주면서 걸러 내는 비용까지 줄었습니다. 새로 적으신다면
이 둘로 시작하십시오.
직접 해보기
[GeneratedRegex] 를 하나 적고,
빌드가 만들어 낸 파일을 찾아 열어보세요. 켜야 할 설정과, 켠 뒤에
빌드가 깨질 때 무엇을 더 넣어야 하는지 적어보세요.
4.3의 실습 1에서 만든
Validate 는 실행할 때마다 속성을 탐색합니다. 같은 일을
제너레이터로 옮긴다면 무엇을 만들어 내야 할지 적어보세요. 코드를
다 적을 필요는 없고 만들어질 결과물만 적으면 됩니다.
- 소스 제너레이터는 빌드할 때 C# 코드를 만들어 넣습니다. 그 뒤로는 평범한 코드입니다.
- 더 넣기만 하고 고치지는 못합니다. 받는 쪽이
partial이어야 합니다. - 틀리면 빌드가 멈춥니다. 리플렉션은 실행하다 알게 됩니다.
- 트리밍과 AOT 에서 지워질 것이 없습니다. 4.3에서 본 IL 경고가 사라집니다.
EmitCompilerGeneratedFiles로 만들어진 파일을 봅니다.Compile Remove를 함께 넣으세요.- 새로 적는다면
IIncrementalGenerator와ForAttributeWithMetadataName입니다. - 만들어 내는 것도 코드입니다. 중괄호를 글자로 넣으려면 두 번 적습니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.