본문으로 건너뛰기

Incremental Caching

개발자가 파일 하나를 수정할 때마다 소스 생성기가 프로젝트 전체를 다시 처리한다면 어떤 일이 벌어질까요? 수백 개의 Repository 클래스가 있는 대규모 프로젝트에서는 키 입력 한 번에 수 초씩 IDE가 멈추게 됩니다. 앞 장에서 배운 ForAttributeWithMetadataName이 대상 탐색을 최적화한다면, 이번 장의 증분 캐싱은 이미 처리된 결과를 재사용하여 불필요한 재처리를 완전히 건너뛰는 메커니즘입니다. 캐싱이 올바르게 작동하려면 데이터 모델과 출력 모두 결정적(Deterministic)이어야 하며, 이를 깨뜨리는 실수를 이해하는 것이 이번 장의 핵심입니다.

  1. 증분 빌드의 동작 원리를 이해한다
    • 입력 변경 감지, 중간 결과 캐싱, 출력 캐싱의 세 단계
  2. 캐싱이 작동하는 조건을 파악한다
    • 값 동등성, 불변 컬렉션, 결정적 출력
  3. 캐시를 무효화시키는 흔한 실수를 식별한다
    • 타임스탬프, 순서 비결정성, 외부 상태 의존

증분 빌드(Incremental Build)는 변경된 부분만 다시 처리하여 빌드 시간을 단축하는 기법입니다.

전체 빌드 (Full Build)
=====================
파일 A 수정됨
모든 파일 다시 처리: A, B, C, D, E
빌드 시간: 10초
증분 빌드 (Incremental Build)
============================
파일 A 수정됨
캐시 확인:
- 파일 A: 변경됨 → 다시 처리
- 파일 B: 변경 없음 → 캐시 사용
- 파일 C: 변경 없음 → 캐시 사용
빌드 시간: 2초

IIncrementalGenerator는 자동으로 증분 캐싱을 지원합니다:

Provider 파이프라인의 캐싱
=========================
1. 입력 변경 감지
- 소스 파일 해시 비교
- 변경된 파일만 파이프라인 재실행
2. 중간 결과 캐싱
- Select, Where 등의 결과 캐시
- 동일한 입력 → 캐시된 결과 반환
3. 출력 캐싱
- 생성된 코드가 동일하면 파일 갱신 생략
첫 번째 빌드
===========
UserRepository.cs → [처리] → UserRepositoryObservable.g.cs
OrderRepository.cs → [처리] → OrderRepositoryObservable.g.cs
두 번째 빌드 (UserRepository.cs만 수정)
======================================
UserRepository.cs → [처리] → UserRepositoryObservable.g.cs (갱신)
OrderRepository.cs → [캐시] → (처리 생략)

캐싱이 올바르게 작동하려면 값 동등성(Value Equality)이 필요합니다.

// ✅ readonly record struct: 값 의미론 + 자동 Equals/GetHashCode
public readonly record struct ObservableClassInfo(
string Namespace,
string ClassName,
List<MethodInfo> Methods,
List<ParameterInfo> BaseConstructorParameters);
// 동일한 내용 → 동일한 해시 → 캐시 히트
// ✅ 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); // 내용이 같아도 다른 인스턴스면 다름
// ❌ 비결정적: 매번 다른 결과
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"

Functorium 프로젝트의 결정적 포맷 정의:

SymbolDisplayFormats.cs
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>"

캐싱이 정상적으로 작동하지 않는 경우 대부분 다음 세 가지 원인 중 하나에 해당합니다. 실수로 빠지기 쉬운 함정이므로 각각의 패턴을 기억해 두는 것이 좋습니다.

// ❌ 타임스탬프 포함
return new ClassInfo(
Name: symbol.Name,
GeneratedAt: DateTime.Now); // 매번 다름!
// ❌ 순서가 보장되지 않음
var methods = symbol.GetMembers()
.OfType<IMethodSymbol>()
.ToList(); // 순서가 다를 수 있음
// ✅ 순서 정렬
var methods = symbol.GetMembers()
.OfType<IMethodSymbol>()
.OrderBy(m => m.Name) // 항상 동일한 순서
.ToList();
// ❌ 환경 변수 의존
var debugMode = Environment.GetEnvironmentVariable("DEBUG");
// ❌ 파일 시스템 접근
var config = File.ReadAllText("config.json");
// 소스 생성기에서 외부 리소스 접근은 제한됨
// AdditionalTexts를 통해서만 파일 접근 가능

캐싱이 작동하는지 확인하는 방법:

Terminal window
# 상세 로그로 빌드
dotnet build -v:diag > build.log
# 소스 생성기 관련 로그 검색
grep -i "generator" build.log | grep -i "cache"
캐시 작동 시
===========
UserRepositoryObservable.g.cs 수정 시간: 10:00:00
(파일 A 수정)
UserRepositoryObservable.g.cs 수정 시간: 10:00:05 (갱신됨)
OrderRepositoryObservable.g.cs 수정 시간: 10:00:00 (변경 없음)
캐시 미작동 시
=============
모든 파일의 수정 시간이 변경됨 → 비결정적 출력 의심
// 개발 중에만 사용
#if DEBUG
.Select((info, _) =>
{
Console.WriteLine($"Processing: {info.ClassName}");
return info;
})
#endif

// ❌ 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, _) => ...)
// ❌ transform에서 코드 생성 (캐싱 비효율)
transform: (ctx, _) => GenerateCode(ctx.TargetSymbol) // 문자열
// ✅ transform에서 데이터만 추출
transform: (ctx, _) => ExtractInfo(ctx.TargetSymbol) // 레코드
// RegisterSourceOutput에서 코드 생성
// ❌ 불필요한 Collect
var provider = ...; // IncrementalValuesProvider
context.RegisterSourceOutput(provider.Collect(), (ctx, items) =>
{
foreach (var item in items)
GenerateForItem(ctx, item);
});
// ✅ 개별 처리 (캐싱 유리)
var provider = ...;
context.RegisterSourceOutput(provider, (ctx, item) =>
{
GenerateForItem(ctx, item);
});

증분 캐싱은 소스 생성기 성능의 핵심입니다. 캐싱이 올바르게 작동하려면 데이터 모델에 값 동등성을 보장하고, 출력에서 비결정적 요소를 제거해야 합니다. 우리 프로젝트에서는 ObservableClassInforeadonly record struct로 정의하고, SymbolDisplayFormats.GlobalQualifiedFormat으로 타입 이름을 일관되게 표현하여 이 조건을 충족합니다.

항목권장 사항
데이터 모델레코드(record) 사용
컬렉션ImmutableArray 또는 정렬된 List
타입 이름SymbolDisplayFormat.FullyQualifiedFormat
필터링predicate에서 최대한
출력결정적(타임스탬프 등 제외)

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을 통해 클래스와 인터페이스의 정보를 어떻게 분석하는지 살펴봅니다.

05. INamedTypeSymbol