Provider Pattern
앞 장에서 Initialize 메서드 안에서 파이프라인을 구성한다는 것을 확인했습니다. 그렇다면 이 파이프라인은 구체적으로 어떻게 만들어질까요? LINQ를 사용해 본 적이 있다면 Select, Where, Collect 같은 연산자가 익숙할 것입니다. Provider 패턴은 바로 이 LINQ 스타일의 선언적 연산자를 사용하여 소스 코드에서 필요한 정보를 추출하고 변환하는 데이터 파이프라인을 구성합니다. 우리 프로젝트의 ObservablePortGenerator도 이 패턴으로 [GenerateObservablePort] 속성이 붙은 클래스를 찾아 ObservableClassInfo로 변환합니다.
학습 목표
섹션 제목: “학습 목표”핵심 학습 목표
섹션 제목: “핵심 학습 목표”- IncrementalValuesProvider와 IncrementalValueProvider의 차이를 이해한다
- 복수 값(0..N)과 단일 값(정확히 1개)의 구분
- LINQ 스타일 연산자를 활용한 파이프라인 구성 방법을 습득한다
- Select, Where, Collect, Combine의 역할과 사용 시점
- ObservablePortGenerator의 실제 파이프라인 구조를 분석한다
- ForAttributeWithMetadataName → Where → Collect 흐름
Provider란?
섹션 제목: “Provider란?”Provider는 소스 생성기의 데이터 파이프라인을 구성하는 핵심 요소입니다. LINQ에서 IEnumerable<T>에 Select와 Where를 체이닝하듯, Provider에도 동일한 이름의 연산자를 체이닝하여 소스 코드에서 필요한 정보를 추출하고 변환하는 과정을 선언적으로 표현합니다.
Provider 파이프라인 흐름=======================
소스 코드 │ ▼┌─────────────────────────┐│ SyntaxProvider │ 소스에서 노드 추출│ (ForAttributeWithMeta...)│└───────────┬─────────────┘ │ ▼┌─────────────────────────┐│ Select │ 데이터 변환│ (Syntax → 필요한 정보) │└───────────┬─────────────┘ │ ▼┌─────────────────────────┐│ Where │ 필터링│ (유효한 것만 선택) │└───────────┬─────────────┘ │ ▼┌─────────────────────────┐│ Collect │ 배열로 수집│ (개별 항목 → 배열) │└───────────┬─────────────┘ │ ▼RegisterSourceOutput(코드 생성)두 가지 Provider 타입
섹션 제목: “두 가지 Provider 타입”IncrementalValuesProvider
섹션 제목: “IncrementalValuesProvider”복수(0개 이상)의 값을 나타냅니다:
// 여러 클래스가 [GenerateObservablePort] 속성을 가질 수 있음IncrementalValuesProvider<ObservableClassInfo> provider = context.SyntaxProvider .ForAttributeWithMetadataName(...);
// 0개: 속성이 붙은 클래스 없음// 1개: 하나의 클래스// N개: 여러 클래스IncrementalValueProvider
섹션 제목: “IncrementalValueProvider”정확히 1개의 값을 나타냅니다:
// 컴파일 옵션은 항상 1개IncrementalValueProvider<CompilationOptions> options = context.CompilationOptionsProvider;
// Collect로 변환하면 단일 값이 됨IncrementalValueProvider<ImmutableArray<ObservableClassInfo>> collected = provider.Collect();주요 연산자
섹션 제목: “주요 연산자”각 연산자는 LINQ의 대응 연산자와 동일한 의미를 가집니다. 차이점은 이 연산자들이 컴파일러의 증분 캐싱 시스템과 통합되어, 입력이 변경되지 않으면 이전 결과를 재사용한다는 것입니다.
Select - 데이터 변환
섹션 제목: “Select - 데이터 변환”// SyntaxNode → 클래스 이름var classNames = context.SyntaxProvider .ForAttributeWithMetadataName(...) .Select((ctx, _) => ctx.TargetSymbol.Name);
// ObservableClassInfo → 생성할 코드var codes = provider .Select((info, _) => GenerateCode(info));Where - 필터링
섹션 제목: “Where - 필터링”// 유효한 항목만 선택var validClasses = provider .Where(x => x != ObservableClassInfo.None);
// public 클래스만 선택var publicClasses = provider .Where(x => x.IsPublic);Collect - 배열로 수집
섹션 제목: “Collect - 배열로 수집”// IncrementalValuesProvider<T> → IncrementalValueProvider<ImmutableArray<T>>var collected = provider.Collect();
// 여러 항목을 한 번에 처리할 때 유용context.RegisterSourceOutput(collected, (ctx, items) =>{ foreach (var item in items) { ctx.AddSource(...); }});Combine - 두 Provider 결합
섹션 제목: “Combine - 두 Provider 결합”// 클래스 정보 + 컴파일 옵션 결합var combined = provider.Combine(context.CompilationOptionsProvider);
context.RegisterSourceOutput(combined, (ctx, pair) =>{ var classInfo = pair.Left; var options = pair.Right; // ...});실제 코드: ObservablePortGenerator
섹션 제목: “실제 코드: ObservablePortGenerator”지금까지 개별 연산자를 살펴보았으니, 우리 프로젝트에서 이 연산자들이 어떻게 조합되는지 확인해 봅니다.
private static IncrementalValuesProvider<ObservableClassInfo> RegisterSourceProvider( IncrementalGeneratorInitializationContext context){ // 1단계: 고정 코드 생성 (Attribute 정의) context.RegisterPostInitializationOutput(ctx => ctx.AddSource( hintName: GenerateObservablePortAttributeFileName, sourceText: SourceText.From(GenerateObservablePortAttribute, Encoding.UTF8)));
// 2단계: 파이프라인 구성 return context .SyntaxProvider // [GenerateObservablePort] 속성이 붙은 클래스만 선택 .ForAttributeWithMetadataName( fullyQualifiedMetadataName: FullyQualifiedAttributeName, predicate: IsClass, // Syntax 수준 필터 transform: MapToObservableClassInfo) // Semantic 정보 추출 // 유효하지 않은 항목 제외 .Where(x => x != ObservableClassInfo.None);}파이프라인 구성 패턴
섹션 제목: “파이프라인 구성 패턴”패턴 1: 단순 변환
섹션 제목: “패턴 1: 단순 변환”// 클래스 이름만 추출var classNames = context.SyntaxProvider .ForAttributeWithMetadataName("MyAttribute", ...) .Select((ctx, _) => ctx.TargetSymbol.Name);
context.RegisterSourceOutput(classNames, (ctx, name) =>{ ctx.AddSource($"{name}.g.cs", $"// Generated for {name}");});패턴 2: 복잡한 데이터 구조
섹션 제목: “패턴 2: 복잡한 데이터 구조”// 상세 정보를 레코드로 변환var classInfos = context.SyntaxProvider .ForAttributeWithMetadataName("MyAttribute", ...) .Select((ctx, _) => new ClassInfo( Name: ctx.TargetSymbol.Name, Namespace: ctx.TargetSymbol.ContainingNamespace.ToString(), Methods: GetMethods(ctx.TargetSymbol)));
context.RegisterSourceOutput(classInfos, (ctx, info) =>{ var code = GenerateCode(info); ctx.AddSource($"{info.Name}.g.cs", code);});패턴 3: 일괄 처리
섹션 제목: “패턴 3: 일괄 처리”// 모든 클래스를 한 번에 처리var allClasses = context.SyntaxProvider .ForAttributeWithMetadataName("MyAttribute", ...) .Collect(); // ImmutableArray로 수집
context.RegisterSourceOutput(allClasses, (ctx, classes) =>{ // 요약 파일 생성 var summary = string.Join("\n", classes.Select(c => c.Name)); ctx.AddSource("Summary.g.cs", $"// Generated {classes.Length} classes\n{summary}");
// 각 클래스별 파일 생성 foreach (var cls in classes) { ctx.AddSource($"{cls.Name}.g.cs", GenerateCode(cls)); }});패턴 4: 조건부 결합
섹션 제목: “패턴 4: 조건부 결합”// 컴파일 옵션에 따라 다른 코드 생성var withOptions = provider .Combine(context.CompilationOptionsProvider);
context.RegisterSourceOutput(withOptions, (ctx, pair) =>{ var (classInfo, options) = pair;
string code = options.OptimizationLevel == OptimizationLevel.Debug ? GenerateDebugCode(classInfo) : GenerateReleaseCode(classInfo);
ctx.AddSource($"{classInfo.Name}.g.cs", code);});캐싱과 성능
섹션 제목: “캐싱과 성능”Provider 패턴을 사용해야 하는 가장 중요한 이유는 자동 캐싱입니다. 파이프라인의 각 단계에서 입력이 이전과 동일하면 컴파일러가 해당 단계의 결과를 캐시에서 가져와 처리를 건너뜁니다.
증분 빌드 시 동작================
1. 파일 A 수정됨 │ ▼2. 파이프라인 재실행 - 파일 A: 새로 처리 - 파일 B: 캐시에서 가져옴 (처리 생략) - 파일 C: 캐시에서 가져옴 (처리 생략) │ ▼3. 변경된 파일 A에 대해서만 코드 재생성캐싱을 위한 주의사항
섹션 제목: “캐싱을 위한 주의사항”// ❌ 나쁜 예: 비결정적 데이터.Select((ctx, _) => new ClassInfo( Name: ctx.TargetSymbol.Name, Timestamp: DateTime.Now // 매번 다른 값!))
// ✅ 좋은 예: 결정적 데이터.Select((ctx, _) => new ClassInfo( Name: ctx.TargetSymbol.Name, Namespace: ctx.TargetSymbol.ContainingNamespace.ToString()))데이터 모델 설계
섹션 제목: “데이터 모델 설계”캐싱이 올바르게 작동하려면 데이터 모델이 값 의미론을 가져야 합니다. 내용이 같은 두 객체가 Equals로 동일하게 판정되어야 컴파일러가 “변경 없음”을 인식하고 캐시를 활용할 수 있기 때문입니다. 우리 프로젝트의 ObservableClassInfo가 readonly record struct로 정의된 것도 이 이유입니다.
// ✅ readonly record struct 사용 (값 의미론 + 자동 Equals/GetHashCode)public readonly record struct ObservableClassInfo{ public readonly string Namespace; public readonly string ClassName; public readonly List<MethodInfo> Methods; public readonly List<ParameterInfo> BaseConstructorParameters; public readonly Location? Location;
// None 패턴으로 null 대신 빈 객체 사용 public static readonly ObservableClassInfo None = new( string.Empty, string.Empty, new List<MethodInfo>(), new List<ParameterInfo>(), null);
public ObservableClassInfo( string @namespace, string className, List<MethodInfo> methods, List<ParameterInfo> baseConstructorParameters, Location? location) { Namespace = @namespace; ClassName = className; Methods = methods; BaseConstructorParameters = baseConstructorParameters; Location = location; }}
// 생성자 기반 classpublic class MethodInfo{ public string Name { get; } public List<ParameterInfo> Parameters { get; } public string ReturnType { get; }
public MethodInfo(string name, List<ParameterInfo> parameters, string returnType) { Name = name; Parameters = parameters; ReturnType = returnType; }}
public class ParameterInfo{ public string Name { get; } public string Type { get; } public RefKind RefKind { get; } public bool IsCollection { get; }
public ParameterInfo(string name, string type, RefKind refKind) { Name = name; Type = type; RefKind = refKind; IsCollection = CollectionTypeHelper.IsCollectionType(type); }}한눈에 보는 정리
섹션 제목: “한눈에 보는 정리”Provider 패턴은 LINQ와 동일한 선언적 스타일로 소스 생성 파이프라인을 구성하되, 각 단계에 자동 캐싱을 제공하여 증분 빌드 성능을 보장합니다. 데이터 모델에 값 의미론을 적용하는 것이 캐싱의 핵심 전제 조건입니다.
| Provider 타입 | 값 개수 | 용도 |
|---|---|---|
IncrementalValuesProvider<T> | 0..N개 | 여러 항목 처리 |
IncrementalValueProvider<T> | 정확히 1개 | 단일 값, Collect 결과 |
| 연산자 | 기능 | 반환 |
|---|---|---|
Select | 변환 | 같은 Provider 타입 |
Where | 필터링 | ValuesProvider |
Collect | 배열로 수집 | ValueProvider |
Combine | 결합 | ValueProvider (튜플) |
FAQ
섹션 제목: “FAQ”Q1: IncrementalValuesProvider<T>와 IncrementalValueProvider<T>는 어떻게 다른가요?
섹션 제목: “Q1: IncrementalValuesProvider<T>와 IncrementalValueProvider<T>는 어떻게 다른가요?”A: IncrementalValuesProvider<T>는 0개 이상의 값을 스트림으로 제공하며, Select, Where 등의 연산자를 지원합니다. IncrementalValueProvider<T>는 정확히 1개의 값을 제공하며, Collect()의 결과나 Combine()의 결과가 이 타입입니다. 코드 생성 등록 시 두 타입 모두 RegisterSourceOutput에 전달할 수 있습니다.
Q2: 데이터 모델에 readonly record struct를 사용하면 캐싱 성능이 향상되는 이유는 무엇인가요?
섹션 제목: “Q2: 데이터 모델에 readonly record struct를 사용하면 캐싱 성능이 향상되는 이유는 무엇인가요?”A: record struct는 값 기반 Equals/GetHashCode를 자동 생성합니다. Roslyn은 파이프라인 단계마다 이전 결과와 현재 결과를 비교하여 동일하면 다음 단계를 건너뜁니다. 정확한 값 비교가 보장되어야 캐시 적중률이 높아지고, 불필요한 코드 재생성이 줄어듭니다.
Q3: Combine 연산자는 어떤 상황에서 사용하나요?
섹션 제목: “Q3: Combine 연산자는 어떤 상황에서 사용하나요?”A: 소스 코드에서 추출한 데이터와 컴파일 옵션 같은 외부 정보를 결합해야 할 때 사용합니다. 예를 들어 Debug/Release 모드에 따라 생성 코드를 다르게 하려면, provider.Combine(context.CompilationProvider)로 두 데이터를 합쳐 코드 생성 시 참조할 수 있습니다.
Provider 파이프라인의 전체 흐름을 이해했으니, 다음으로는 파이프라인의 시작점에서 가장 자주 사용되는 API인 ForAttributeWithMetadataName을 살펴봅니다. 이 API가 속성 기반 필터링을 어떻게 최적화하는지, 그리고 직접 구현과 비교했을 때 왜 10~100배 빠른지 확인합니다.