Incremental Caching
개발자가 파일 하나를 수정할 때마다 소스 생성기가 프로젝트 전체를 다시 처리한다면 어떤 일이 벌어질까요? 수백 개의 Repository 클래스가 있는 대규모 프로젝트에서는 키 입력 한 번에 수 초씩 IDE가 멈추게 됩니다. 앞 장에서 배운 ForAttributeWithMetadataName이 대상 탐색을 최적화한다면, 이번 장의 증분 캐싱은 이미 처리된 결과를 재사용하여 불필요한 재처리를 완전히 건너뛰는 메커니즘입니다. 캐싱이 올바르게 작동하려면 데이터 모델과 출력 모두 결정적(Deterministic)이어야 하며, 이를 깨뜨리는 실수를 이해하는 것이 이번 장의 핵심입니다.
학습 목표
섹션 제목: “학습 목표”핵심 학습 목표
섹션 제목: “핵심 학습 목표”- 증분 빌드의 동작 원리를 이해한다
- 입력 변경 감지, 중간 결과 캐싱, 출력 캐싱의 세 단계
- 캐싱이 작동하는 조건을 파악한다
- 값 동등성, 불변 컬렉션, 결정적 출력
- 캐시를 무효화시키는 흔한 실수를 식별한다
- 타임스탬프, 순서 비결정성, 외부 상태 의존
증분 빌드란?
섹션 제목: “증분 빌드란?”증분 빌드(Incremental Build)는 변경된 부분만 다시 처리하여 빌드 시간을 단축하는 기법입니다.
전체 빌드 (Full Build)=====================파일 A 수정됨 ↓모든 파일 다시 처리: A, B, C, D, E ↓빌드 시간: 10초
증분 빌드 (Incremental Build)============================파일 A 수정됨 ↓캐시 확인:- 파일 A: 변경됨 → 다시 처리- 파일 B: 변경 없음 → 캐시 사용- 파일 C: 변경 없음 → 캐시 사용 ↓빌드 시간: 2초IIncrementalGenerator의 캐싱
섹션 제목: “IIncrementalGenerator의 캐싱”IIncrementalGenerator는 자동으로 증분 캐싱을 지원합니다:
Provider 파이프라인의 캐싱=========================
1. 입력 변경 감지 - 소스 파일 해시 비교 - 변경된 파일만 파이프라인 재실행
2. 중간 결과 캐싱 - Select, Where 등의 결과 캐시 - 동일한 입력 → 캐시된 결과 반환
3. 출력 캐싱 - 생성된 코드가 동일하면 파일 갱신 생략캐싱 흐름
섹션 제목: “캐싱 흐름”첫 번째 빌드===========UserRepository.cs → [처리] → UserRepositoryObservable.g.csOrderRepository.cs → [처리] → OrderRepositoryObservable.g.cs
두 번째 빌드 (UserRepository.cs만 수정)======================================UserRepository.cs → [처리] → UserRepositoryObservable.g.cs (갱신)OrderRepository.cs → [캐시] → (처리 생략)캐싱이 작동하려면
섹션 제목: “캐싱이 작동하려면”캐싱이 올바르게 작동하려면 값 동등성(Value Equality)이 필요합니다.
1. 레코드(Record) 사용
섹션 제목: “1. 레코드(Record) 사용”// ✅ readonly record struct: 값 의미론 + 자동 Equals/GetHashCodepublic readonly record struct ObservableClassInfo( string Namespace, string ClassName, List<MethodInfo> Methods, List<ParameterInfo> BaseConstructorParameters);
// 동일한 내용 → 동일한 해시 → 캐시 히트2. 불변(Immutable) 컬렉션 사용
섹션 제목: “2. 불변(Immutable) 컬렉션 사용”// ✅ ImmutableArray 사용public readonly record struct ObservableClassInfo( string Namespace, string ClassName, ImmutableArray<MethodInfo> Methods); // 불변
// ⚠️ List는 참조 비교만 함public readonly record struct ObservableClassInfo( string Namespace, string ClassName, List<MethodInfo> Methods); // 내용이 같아도 다른 인스턴스면 다름3. 결정적(Deterministic) 출력
섹션 제목: “3. 결정적(Deterministic) 출력”// ❌ 비결정적: 매번 다른 결과var code = $""" // Generated at {DateTime.Now} public class {className}Pipeline {{ }} """;
// ✅ 결정적: 동일 입력 → 동일 출력var code = $""" // <auto-generated/> public class {className}Pipeline {{ }} """;결정적 코드 생성
섹션 제목: “결정적 코드 생성”타입 이름의 결정성
섹션 제목: “타입 이름의 결정성”동일한 타입이 다르게 표현되면 캐시가 무효화됩니다:
// ❌ 비결정적: 동일 타입이 다르게 표현될 수 있음string typeName = type.Name; // "User" vs "User" (별칭에 따라 다름)
string typeName = type.ToDisplayString();// "User" vs "MyApp.User" (컨텍스트에 따라 다름)
// ✅ 결정적: 항상 동일한 형식string typeName = type.ToDisplayString( SymbolDisplayFormat.FullyQualifiedFormat);// 항상 "global::MyApp.Models.User"SymbolDisplayFormats 클래스
섹션 제목: “SymbolDisplayFormats 클래스”Functorium 프로젝트의 결정적 포맷 정의:
namespace Functorium.SourceGenerators.Generators.ObservablePortGenerator;
public static class SymbolDisplayFormats{ /// <summary> /// 결정적 코드 생성을 위한 전역 한정 포맷 /// </summary> public static readonly SymbolDisplayFormat GlobalQualifiedFormat = new( globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);}
// 사용 예string typeName = type.ToDisplayString(SymbolDisplayFormats.GlobalQualifiedFormat);// "global::System.Collections.Generic.List<global::MyApp.Models.User>"캐시 무효화 원인
섹션 제목: “캐시 무효화 원인”캐싱이 정상적으로 작동하지 않는 경우 대부분 다음 세 가지 원인 중 하나에 해당합니다. 실수로 빠지기 쉬운 함정이므로 각각의 패턴을 기억해 두는 것이 좋습니다.
1. 비결정적 데이터
섹션 제목: “1. 비결정적 데이터”// ❌ 타임스탬프 포함return new ClassInfo( Name: symbol.Name, GeneratedAt: DateTime.Now); // 매번 다름!2. 순서 의존성
섹션 제목: “2. 순서 의존성”// ❌ 순서가 보장되지 않음var methods = symbol.GetMembers() .OfType<IMethodSymbol>() .ToList(); // 순서가 다를 수 있음
// ✅ 순서 정렬var methods = symbol.GetMembers() .OfType<IMethodSymbol>() .OrderBy(m => m.Name) // 항상 동일한 순서 .ToList();3. 외부 상태 의존
섹션 제목: “3. 외부 상태 의존”// ❌ 환경 변수 의존var debugMode = Environment.GetEnvironmentVariable("DEBUG");
// ❌ 파일 시스템 접근var config = File.ReadAllText("config.json");
// 소스 생성기에서 외부 리소스 접근은 제한됨// AdditionalTexts를 통해서만 파일 접근 가능캐싱 디버깅
섹션 제목: “캐싱 디버깅”캐싱이 작동하는지 확인하는 방법:
1. 빌드 로그 확인
섹션 제목: “1. 빌드 로그 확인”# 상세 로그로 빌드dotnet build -v:diag > build.log
# 소스 생성기 관련 로그 검색grep -i "generator" build.log | grep -i "cache"2. 생성된 파일 타임스탬프
섹션 제목: “2. 생성된 파일 타임스탬프”캐시 작동 시===========UserRepositoryObservable.g.cs 수정 시간: 10:00:00(파일 A 수정)UserRepositoryObservable.g.cs 수정 시간: 10:00:05 (갱신됨)OrderRepositoryObservable.g.cs 수정 시간: 10:00:00 (변경 없음)
캐시 미작동 시=============모든 파일의 수정 시간이 변경됨 → 비결정적 출력 의심3. 디버깅 코드 삽입
섹션 제목: “3. 디버깅 코드 삽입”// 개발 중에만 사용#if DEBUG.Select((info, _) =>{ Console.WriteLine($"Processing: {info.ClassName}"); return info;})#endif성능 최적화 팁
섹션 제목: “성능 최적화 팁”1. predicate에서 최대한 필터링
섹션 제목: “1. predicate에서 최대한 필터링”// ❌ transform에서 필터링 (느림).ForAttributeWithMetadataName( "MyAttribute", predicate: (_, _) => true, // 모든 노드 통과 transform: (ctx, _) => { if (ctx.TargetNode is not ClassDeclarationSyntax) return null; // 여기서 필터링 ... })
// ✅ predicate에서 필터링 (빠름).ForAttributeWithMetadataName( "MyAttribute", predicate: (node, _) => node is ClassDeclarationSyntax, // 빠른 필터 transform: (ctx, _) => ...)2. transform 결과 단순화
섹션 제목: “2. transform 결과 단순화”// ❌ transform에서 코드 생성 (캐싱 비효율)transform: (ctx, _) => GenerateCode(ctx.TargetSymbol) // 문자열
// ✅ transform에서 데이터만 추출transform: (ctx, _) => ExtractInfo(ctx.TargetSymbol) // 레코드// RegisterSourceOutput에서 코드 생성3. Collect 사용 최소화
섹션 제목: “3. Collect 사용 최소화”// ❌ 불필요한 Collectvar provider = ...; // IncrementalValuesProvidercontext.RegisterSourceOutput(provider.Collect(), (ctx, items) =>{ foreach (var item in items) GenerateForItem(ctx, item);});
// ✅ 개별 처리 (캐싱 유리)var provider = ...;context.RegisterSourceOutput(provider, (ctx, item) =>{ GenerateForItem(ctx, item);});한눈에 보는 정리
섹션 제목: “한눈에 보는 정리”증분 캐싱은 소스 생성기 성능의 핵심입니다. 캐싱이 올바르게 작동하려면 데이터 모델에 값 동등성을 보장하고, 출력에서 비결정적 요소를 제거해야 합니다. 우리 프로젝트에서는 ObservableClassInfo를 readonly record struct로 정의하고, SymbolDisplayFormats.GlobalQualifiedFormat으로 타입 이름을 일관되게 표현하여 이 조건을 충족합니다.
| 항목 | 권장 사항 |
|---|---|
| 데이터 모델 | 레코드(record) 사용 |
| 컬렉션 | ImmutableArray 또는 정렬된 List |
| 타입 이름 | SymbolDisplayFormat.FullyQualifiedFormat |
| 필터링 | predicate에서 최대한 |
| 출력 | 결정적(타임스탬프 등 제외) |
FAQ
섹션 제목: “FAQ”Q1: 증분 캐싱이 무효화되는 가장 흔한 원인은 무엇인가요?
섹션 제목: “Q1: 증분 캐싱이 무효화되는 가장 흔한 원인은 무엇인가요?”A: DateTime.Now, Guid.NewGuid() 같은 비결정적 값을 데이터 모델에 포함하거나, 컬렉션의 정렬 순서를 보장하지 않는 것이 가장 흔한 원인입니다. 매 빌드마다 데이터 모델의 Equals 비교가 false를 반환하여, 실제 변경이 없어도 코드가 재생성됩니다.
Q2: predicate에서 최대한 필터링해야 하는 이유는 무엇인가요?
섹션 제목: “Q2: predicate에서 최대한 필터링해야 하는 이유는 무엇인가요?”A: predicate는 Syntax 수준에서 동작하여 비용이 매우 낮습니다. 반면 transform은 Semantic Model 접근이 필요하여 비용이 높습니다. predicate에서 후보를 최대한 줄이면 transform의 실행 횟수가 감소하고, 결과적으로 전체 파이프라인 성능이 향상됩니다.
Q3: ObservableClassInfo에서 List<MethodInfo> 대신 ImmutableArray<MethodInfo>를 사용하면 더 좋지 않나요?
섹션 제목: “Q3: ObservableClassInfo에서 List<MethodInfo> 대신 ImmutableArray<MethodInfo>를 사용하면 더 좋지 않나요?”A: 이론적으로는 ImmutableArray가 불변성을 보장하여 더 안전합니다. 다만 Functorium에서는 readonly record struct의 값 동등성과 MethodInfo의 내용 기반 비교로 캐싱이 올바르게 작동하며, 구현 단순성을 위해 List를 사용하고 있습니다.
증분 캐싱의 원리를 이해했으니, 이제 파이프라인에서 실제로 데이터를 추출하는 핵심 도구인 심볼 API로 넘어갑니다. 다음 장에서는 INamedTypeSymbol을 통해 클래스와 인터페이스의 정보를 어떻게 분석하는지 살펴봅니다.