Deterministic Output
Part 2의 마지막 장입니다. 앞에서 배운 심볼 분석, 타입 추출, 코드 생성 기법을 모두 통합할 때, 하나의 원칙이 전체를 관통합니다 — 동일한 입력에 대해 항상 동일한 출력을 만들어야 한다는 것입니다. 4장에서 다룬 증분 캐싱이 올바르게 작동하려면 이 결정적 출력이 보장되어야 합니다. 이 원칙이 깨지면 증분 빌드, 소스 제어, CI/CD 모두가 영향을 받습니다.
학습 목표
섹션 제목: “학습 목표”핵심 학습 목표
섹션 제목: “핵심 학습 목표”- 결정적(Deterministic) 출력의 중요성 이해
- 증분 빌드, 소스 제어, CI/CD에 미치는 영향
- global:: 접두사 사용 이유
- 사용자 코드와의 네임스페이스 충돌 방지
- 일관된 출력을 보장하는 기법
- 타임스탬프 제외, 컬렉션 정렬, 일관된 포맷팅
결정적 출력이란?
섹션 제목: “결정적 출력이란?”결정적(Deterministic) 출력은 동일한 입력에 대해 항상 동일한 출력을 생성하는 것입니다.
결정적 출력==========입력 A → 출력 X (항상)입력 B → 출력 Y (항상)
비결정적 출력============입력 A → 출력 X (때때로)입력 A → 출력 X' (다른 때)왜 결정적 출력이 중요한가?
섹션 제목: “왜 결정적 출력이 중요한가?”1. 증분 빌드
섹션 제목: “1. 증분 빌드”결정적 출력==========빌드 1: UserRepository → UserRepositoryObservable.g.cs (내용 X)빌드 2: UserRepository (변경 없음) → 캐시 사용 (빌드 생략)
비결정적 출력============빌드 1: UserRepository → UserRepositoryObservable.g.cs (내용 X)빌드 2: UserRepository (변경 없음) → 내용 X' (다름) → 다시 빌드2. 소스 제어
섹션 제목: “2. 소스 제어”결정적 출력==========git status: 변경 없음 (생성된 파일이 동일)
비결정적 출력============git status: 파일 변경됨 (내용은 의미적으로 동일하지만 다름)→ 불필요한 커밋 발생3. 빌드 재현성
섹션 제목: “3. 빌드 재현성”결정적 출력==========어느 환경에서든 동일한 결과→ CI/CD 신뢰성 향상
비결정적 출력============환경에 따라 다른 결과→ "내 컴퓨터에서는 됐는데..." 문제global:: 접두사
섹션 제목: “global:: 접두사”왜 필요한가?
섹션 제목: “왜 필요한가?”// 사용자 코드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:: 사용
섹션 제목: “항상 global:: 사용”// ✅ 모든 외부 타입에 global:: 접두사sb.AppendLine(" global::System.ArgumentNullException.ThrowIfNull(value);");sb.AppendLine(" global::System.Diagnostics.Activity.Current = null;");sb.AppendLine(" return global::LanguageExt.Unit.Default;");SymbolDisplayFormat 활용
섹션 제목: “SymbolDisplayFormat 활용”// SymbolDisplayFormats.GlobalQualifiedFormat 사용string typeName = type.ToDisplayString(SymbolDisplayFormats.GlobalQualifiedFormat);// → "global::System.Collections.Generic.List<global::MyApp.User>"
// 생성 코드에서 사용sb.AppendLine($" {typeName} result = ...;");비결정적 요소 제거
섹션 제목: “비결정적 요소 제거”1. 타임스탬프
섹션 제목: “1. 타임스탬프”// ❌ 비결정적sb.AppendLine($"// Generated at {DateTime.Now}");
// ✅ 결정적sb.AppendLine("// <auto-generated/>");2. GUID/랜덤 값
섹션 제목: “2. GUID/랜덤 값”// ❌ 비결정적sb.AppendLine($"// ID: {Guid.NewGuid()}");
// ✅ 결정적// GUID 불필요 - 제거3. 환경 변수
섹션 제목: “3. 환경 변수”// ❌ 비결정적string path = Environment.GetEnvironmentVariable("PATH");sb.AppendLine($"// Path: {path}");
// ✅ 결정적// 환경 변수 사용 금지4. 순서 의존성
섹션 제목: “4. 순서 의존성”// ❌ 비결정적 (순서가 보장되지 않음)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;");검증 방법
섹션 제목: “검증 방법”두 번 빌드하여 비교
섹션 제목: “두 번 빌드하여 비교”# 첫 번째 빌드dotnet buildcp Generated/MyClass.g.cs /tmp/first.cs
# 두 번째 빌드 (클린 없이)dotnet buildcp 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);}Verify 스냅샷 테스트
섹션 제목: “Verify 스냅샷 테스트”[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.Int32 | global::System.Int32 |
| 메타데이터 | DateTime.Now | 제외 |
| 컬렉션 | 순서 미정 | .OrderBy() |
| 공백 | 혼합 | 일관된 규칙 |
| 검증 | 없음 | 스냅샷 테스트 |
FAQ
섹션 제목: “FAQ”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, 제네릭 타입, 컬렉션 타입처럼 실전에서 마주치는 복잡한 케이스를 처리하는 방법을 학습합니다.