본문으로 건너뛰기

Deterministic Output

Part 2의 마지막 장입니다. 앞에서 배운 심볼 분석, 타입 추출, 코드 생성 기법을 모두 통합할 때, 하나의 원칙이 전체를 관통합니다 — 동일한 입력에 대해 항상 동일한 출력을 만들어야 한다는 것입니다. 4장에서 다룬 증분 캐싱이 올바르게 작동하려면 이 결정적 출력이 보장되어야 합니다. 이 원칙이 깨지면 증분 빌드, 소스 제어, CI/CD 모두가 영향을 받습니다.

  1. 결정적(Deterministic) 출력의 중요성 이해
    • 증분 빌드, 소스 제어, CI/CD에 미치는 영향
  2. global:: 접두사 사용 이유
    • 사용자 코드와의 네임스페이스 충돌 방지
  3. 일관된 출력을 보장하는 기법
    • 타임스탬프 제외, 컬렉션 정렬, 일관된 포맷팅

결정적(Deterministic) 출력은 동일한 입력에 대해 항상 동일한 출력을 생성하는 것입니다.

결정적 출력
==========
입력 A → 출력 X (항상)
입력 B → 출력 Y (항상)
비결정적 출력
============
입력 A → 출력 X (때때로)
입력 A → 출력 X' (다른 때)

결정적 출력
==========
빌드 1: UserRepository → UserRepositoryObservable.g.cs (내용 X)
빌드 2: UserRepository (변경 없음) → 캐시 사용 (빌드 생략)
비결정적 출력
============
빌드 1: UserRepository → UserRepositoryObservable.g.cs (내용 X)
빌드 2: UserRepository (변경 없음) → 내용 X' (다름) → 다시 빌드
결정적 출력
==========
git status: 변경 없음 (생성된 파일이 동일)
비결정적 출력
============
git status: 파일 변경됨 (내용은 의미적으로 동일하지만 다름)
→ 불필요한 커밋 발생
결정적 출력
==========
어느 환경에서든 동일한 결과
→ CI/CD 신뢰성 향상
비결정적 출력
============
환경에 따라 다른 결과
→ "내 컴퓨터에서는 됐는데..." 문제

// 사용자 코드
namespace MyApp
{
public class System { } // System 이름의 클래스!
}
// 생성된 코드 (global:: 없이)
namespace MyApp
{
public class UserPipeline
{
System.ArgumentNullException.ThrowIfNull(x);
// ❌ 오류: MyApp.System에 ArgumentNullException이 없음!
}
}
// 생성된 코드 (global:: 사용)
namespace MyApp
{
public class UserPipeline
{
global::System.ArgumentNullException.ThrowIfNull(x);
// ✅ 정확히 System 네임스페이스 참조
}
}
// ✅ 모든 외부 타입에 global:: 접두사
sb.AppendLine(" global::System.ArgumentNullException.ThrowIfNull(value);");
sb.AppendLine(" global::System.Diagnostics.Activity.Current = null;");
sb.AppendLine(" return global::LanguageExt.Unit.Default;");
// SymbolDisplayFormats.GlobalQualifiedFormat 사용
string typeName = type.ToDisplayString(SymbolDisplayFormats.GlobalQualifiedFormat);
// → "global::System.Collections.Generic.List<global::MyApp.User>"
// 생성 코드에서 사용
sb.AppendLine($" {typeName} result = ...;");

// ❌ 비결정적
sb.AppendLine($"// Generated at {DateTime.Now}");
// ✅ 결정적
sb.AppendLine("// <auto-generated/>");
// ❌ 비결정적
sb.AppendLine($"// ID: {Guid.NewGuid()}");
// ✅ 결정적
// GUID 불필요 - 제거
// ❌ 비결정적
string path = Environment.GetEnvironmentVariable("PATH");
sb.AppendLine($"// Path: {path}");
// ✅ 결정적
// 환경 변수 사용 금지
// ❌ 비결정적 (순서가 보장되지 않음)
var methods = classSymbol.GetMembers()
.OfType<IMethodSymbol>()
.ToList();
// ✅ 결정적 (순서 정렬)
var methods = classSymbol.GetMembers()
.OfType<IMethodSymbol>()
.OrderBy(m => m.Name) // 이름순 정렬
.ThenBy(m => m.Parameters.Length) // 파라미터 수 정렬
.ToList();

// ❌ 비일관적
sb.Append("public class ");
sb.Append(className); // 때로는 AppendLine 사용할 수도
// ✅ 일관적
sb.Append("public class ")
.Append(className)
.AppendLine()
.AppendLine("{");
// ❌ 혼합 (탭과 스페이스)
sb.AppendLine("\tprivate int _id;"); // 탭
sb.AppendLine(" private string _name;"); // 스페이스
// ✅ 일관적 (스페이스만)
sb.AppendLine(" private int _id;");
sb.AppendLine(" private string _name;");

Terminal window
# 첫 번째 빌드
dotnet build
cp Generated/MyClass.g.cs /tmp/first.cs
# 두 번째 빌드 (클린 없이)
dotnet build
cp Generated/MyClass.g.cs /tmp/second.cs
# 비교
diff /tmp/first.cs /tmp/second.cs
# 차이가 없어야 함
[Fact]
public void Generated_Code_Should_Be_Deterministic()
{
string input = """
[GenerateObservablePort]
public class UserRepository : IObservablePort
{
public FinT<IO, User> GetUserAsync(int id) => throw new();
}
""";
// 두 번 생성
string? output1 = _sut.Generate(input);
string? output2 = _sut.Generate(input);
// 동일해야 함
output1.ShouldBe(output2);
}
[Fact]
public Task Generated_Code_Should_Match_Snapshot()
{
string input = """
[GenerateObservablePort]
public class UserRepository : IObservablePort
{
public FinT<IO, User> GetUserAsync(int id) => throw new();
}
""";
string? actual = _sut.Generate(input);
// 스냅샷과 비교
return Verify(actual);
}
// .verified.txt 파일에 결과 저장
// 이후 변경 시 테스트 실패 → 의도적 변경인지 확인

□ 모든 외부 타입에 global:: 접두사 사용
□ SymbolDisplayFormat.FullyQualifiedFormat 또는 커스텀 포맷 사용
□ 타임스탬프, GUID 등 런타임 값 제외
□ 환경 변수 사용 안 함
□ 컬렉션 순서 정렬
□ 일관된 공백/들여쓰기
□ 두 번 빌드 시 동일한 결과 검증

결정적 출력을 위한 핵심 원칙을 정리합니다.

요소비결정적결정적
타입 참조System.Int32global::System.Int32
메타데이터DateTime.Now제외
컬렉션순서 미정.OrderBy()
공백혼합일관된 규칙
검증없음스냅샷 테스트

Q1: 결정적 출력이 깨지면 어떤 문제가 발생하나요?

섹션 제목: “Q1: 결정적 출력이 깨지면 어떤 문제가 발생하나요?”

A: 세 가지 문제가 연쇄적으로 발생합니다. 첫째, 증분 빌드에서 캐시가 무효화되어 매번 전체 재빌드가 발생합니다. 둘째, 소스 제어에서 의미 없는 diff가 생겨 불필요한 커밋이 발생합니다. 셋째, CI/CD에서 동일한 코드가 다른 결과를 내어 빌드 재현성이 깨집니다.

Q2: .OrderBy()로 정렬하면 성능에 영향이 있나요?

섹션 제목: “Q2: .OrderBy()로 정렬하면 성능에 영향이 있나요?”

A: 소스 생성기는 컴파일 타임에 실행되므로, 정렬 비용은 빌드 시간에 포함됩니다. 대부분의 클래스에서 메서드 수는 수십 개 이하이므로 정렬 비용은 무시할 수 있는 수준입니다. 오히려 정렬하지 않아 비결정적 출력이 발생하면 증분 빌드 캐시가 무효화되어 훨씬 큰 성능 손실을 초래합니다.

Q3: SymbolDisplayFormat과 직접 global:: 접두사를 붙이는 것의 차이는 무엇인가요?

섹션 제목: “Q3: SymbolDisplayFormat과 직접 global:: 접두사를 붙이는 것의 차이는 무엇인가요?”

A: SymbolDisplayFormat.FullyQualifiedFormat을 사용하면 Roslyn이 타입의 전체 경로를 global:: 접두사 포함하여 자동으로 생성합니다. 직접 문자열로 "global::System.Int32" 같이 하드코딩하면 타입 변경 시 누락될 위험이 있습니다. Functorium은 커스텀 SymbolDisplayFormats.GlobalQualifiedFormat을 정의하여 일관성을 보장합니다.


Part 2에서 소스 생성기의 핵심 개념을 모두 다뤘습니다. 다음 Part에서는 Primary Constructor, 제네릭 타입, 컬렉션 타입처럼 실전에서 마주치는 복잡한 케이스를 처리하는 방법을 학습합니다.

Part 3. 고급