Collection Type 처리
관찰 가능성(Observability)에서 “사용자 목록을 조회했다”는 것만으로는 충분하지 않습니다. 실제 운영 환경에서는 “몇 건을 반환했는가”가 성능 분석과 이상 탐지의 핵심 지표가 됩니다. ObservablePortGenerator는 반환 타입이나 파라미터가 컬렉션인 경우 Count 또는 Length 태그를 자동으로 추가합니다. 다만 튜플 내부에 컬렉션이 포함된 경우는 튜플 자체에 Count 속성이 없으므로 예외로 처리해야 합니다.
학습 목표
섹션 제목: “학습 목표”핵심 학습 목표
섹션 제목: “핵심 학습 목표”- 컬렉션 타입 감지 방법
- 패턴 매칭으로
List<T>,Dictionary<K,V>, 배열 등을 식별
- 패턴 매칭으로
- Count/Length 필드 자동 생성
- 컬렉션 종류에 따라 적절한 크기 접근 표현식 생성
- 튜플 내 컬렉션 예외 처리
- 튜플 반환 타입에서 내부 컬렉션을 무시하는 이유와 구현
컬렉션 타입 처리의 필요성
섹션 제목: “컬렉션 타입 처리의 필요성”관찰 가능성 코드에서 컬렉션의 크기 정보는 중요한 메트릭입니다.
// 원본 메서드public virtual FinT<IO, List<User>> GetUsersAsync() => ...;
// 생성된 Pipeline 코드public override FinT<IO, List<User>> GetUsersAsync() => FinT.lift<IO, List<User>>( // ... from __ in IO.lift(() => { // ← 컬렉션 크기를 태그로 기록 activityContext?.SetTag("response.result.count", result?.Count ?? 0); activityContext?.Dispose(); return Unit.Default; }) select result );CollectionTypeHelper 구현
섹션 제목: “CollectionTypeHelper 구현”컬렉션 패턴 정의
섹션 제목: “컬렉션 패턴 정의”namespace Functorium.SourceGenerators.Generators.ObservablePortGenerator;
/// <summary>/// 컬렉션 타입 여부를 확인하는 헬퍼 클래스/// </summary>public static class CollectionTypeHelper{ private static readonly string[] CollectionTypePatterns = [ // 일반 네임스페이스 "System.Collections.Generic.List<", "System.Collections.Generic.IList<", "System.Collections.Generic.ICollection<", "System.Collections.Generic.IEnumerable<", "System.Collections.Generic.IReadOnlyList<", "System.Collections.Generic.IReadOnlyCollection<", "System.Collections.Generic.HashSet<", "System.Collections.Generic.Dictionary<", "System.Collections.Generic.IDictionary<", "System.Collections.Generic.IReadOnlyDictionary<", "System.Collections.Generic.Queue<", "System.Collections.Generic.Stack<",
// global:: 접두사 버전 "global::System.Collections.Generic.List<", "global::System.Collections.Generic.IList<", // ... (동일 패턴) ];}컬렉션 타입 확인
섹션 제목: “컬렉션 타입 확인”/// <summary>/// 타입이 Count 속성을 가진 컬렉션인지 확인합니다./// 튜플 타입은 내부에 컬렉션이 있더라도 컬렉션으로 취급하지 않습니다./// </summary>public static bool IsCollectionType(string typeFullName){ if (string.IsNullOrEmpty(typeFullName)) return false;
// 튜플 타입은 컬렉션으로 취급하지 않음 if (IsTupleType(typeFullName)) return false;
// 배열 타입 확인 (예: int[], string[]) if (typeFullName.Contains("[]")) return true;
// 컬렉션 타입 패턴 확인 return CollectionTypePatterns.Any(pattern => typeFullName.Contains(pattern));}튜플 예외 처리
섹션 제목: “튜플 예외 처리”왜 튜플을 제외하는가?
섹션 제목: “왜 튜플을 제외하는가?”튜플 내부에 컬렉션이 있어도 튜플 자체의 Count를 기록하는 것은 의미가 없습니다.
// 반환 타입: (int Id, List<string> Tags)
// ❌ 잘못된 처리 - 튜플을 컬렉션으로 인식result?.Count // 튜플에는 Count가 없음!
// ✅ 올바른 처리 - 튜플은 Count 생성 안 함// Count 필드 미생성튜플 타입 확인
섹션 제목: “튜플 타입 확인”/// <summary>/// 타입이 튜플인지 확인합니다./// </summary>public static bool IsTupleType(string typeFullName){ if (string.IsNullOrEmpty(typeFullName)) return false;
// C# 튜플 구문: (int Id, string Name) if (typeFullName.StartsWith("(") && typeFullName.EndsWith(")")) return true;
// ValueTuple 타입 if (typeFullName.Contains("System.ValueTuple") || typeFullName.Contains("global::System.ValueTuple")) return true;
return false;}Count 표현식 생성
섹션 제목: “Count 표현식 생성”Count vs Length
섹션 제목: “Count vs Length”/// <summary>/// 컬렉션 타입에 대한 Count 접근 표현식을 생성합니다./// 배열은 Length, 나머지는 Count를 사용합니다./// </summary>public static string? GetCountExpression(string variableName, string typeFullName){ if (string.IsNullOrEmpty(variableName) || string.IsNullOrEmpty(typeFullName)) return null;
if (!IsCollectionType(typeFullName)) return null;
// 배열은 Length 사용 if (typeFullName.Contains("[]")) return $"{variableName}?.Length ?? 0";
// 나머지 컬렉션은 Count 사용 return $"{variableName}?.Count ?? 0";}표현식 결과 예시
섹션 제목: “표현식 결과 예시”| 타입 | 표현식 |
|---|---|
List<User> | result?.Count ?? 0 |
string[] | result?.Length ?? 0 |
Dictionary<K, V> | result?.Count ?? 0 |
IEnumerable<T> | result?.Count ?? 0 |
필드명 생성
섹션 제목: “필드명 생성”Request 파라미터 필드
섹션 제목: “Request 파라미터 필드”/// <summary>/// Request 파라미터에 대한 필드 이름을 생성합니다./// 예: "ms" -> "request.params.ms", "name" -> "request.params.name"/// 동적 필드는 request.params.{name} 형식으로 정적 필드와 구분됩니다./// </summary>public static string GetRequestFieldName(string parameterName){ if (string.IsNullOrEmpty(parameterName)) return parameterName;
// 소문자로 변환하여 snake_case + dot 형식 사용 return $"request.params.{parameterName.ToLowerInvariant()}";}
/// <summary>/// Request 파라미터에 대한 Count 필드 이름을 생성합니다./// 예: "orders" -> "request.params.orders.count"/// </summary>/// <returns>Count 필드 이름. parameterName이 비어있으면 null</returns>public static string? GetRequestCountFieldName(string parameterName){ if (string.IsNullOrEmpty(parameterName)) return null;
// 소문자로 변환하여 snake_case + dot 형식 사용 return $"request.params.{parameterName.ToLowerInvariant()}.count";}Response 필드
섹션 제목: “Response 필드”/// <summary>/// Response 결과에 대한 필드 이름을 생성합니다./// 반환값: "response.result"/// </summary>public static string GetResponseFieldName(){ return "response.result";}
/// <summary>/// Response 결과에 대한 Count 필드 이름을 생성합니다./// 반환값: "response.result.count"/// </summary>public static string GetResponseCountFieldName(){ return "response.result.count";}코드 생성에서 활용
섹션 제목: “코드 생성에서 활용”반환 타입 처리
섹션 제목: “반환 타입 처리”private static void AppendResultTagging( StringBuilder sb, string innerType){ if (CollectionTypeHelper.IsCollectionType(innerType)) { string? countExpr = CollectionTypeHelper.GetCountExpression("result", innerType); string countField = CollectionTypeHelper.GetResponseCountFieldName();
sb.AppendLine($" activityContext?.SetTag(\"{countField}\", {countExpr});"); }
sb.AppendLine(" activityContext?.Dispose();");}파라미터 처리
섹션 제목: “파라미터 처리”private static void AppendParameterTags( StringBuilder sb, IMethodSymbol method){ foreach (var param in method.Parameters) { string paramType = param.Type.ToDisplayString( SymbolDisplayFormats.GlobalQualifiedFormat);
string fieldName = CollectionTypeHelper.GetRequestFieldName(param.Name); sb.AppendLine($" activityContext?.SetTag(\"{fieldName}\", {param.Name});");
// 컬렉션 파라미터의 경우 Count 태그 추가 if (CollectionTypeHelper.IsCollectionType(paramType)) { string? countField = CollectionTypeHelper.GetRequestCountFieldName(param.Name); string? countExpr = CollectionTypeHelper.GetCountExpression(param.Name, paramType);
if (countField is not null && countExpr is not null) { sb.AppendLine($" activityContext?.SetTag(\"{countField}\", {countExpr});"); } } }}생성 결과 예시
섹션 제목: “생성 결과 예시”컬렉션 파라미터
섹션 제목: “컬렉션 파라미터”// 원본public virtual FinT<IO, int> ProcessItems(List<string> items) => ...;
// 생성된 코드public override FinT<IO, int> ProcessItems(List<string> items) => FinT.lift<IO, int>( from activityContext in IO.lift(() => CreateActivity("ProcessItems")) from _ in IO.lift(() => { activityContext?.SetTag("request.params.items", items); activityContext?.SetTag("request.params.items.count", items?.Count ?? 0); // ← Count 태그 StartActivity(activityContext); return Unit.Default; }) from result in FinTToIO(base.ProcessItems(items)) from __ in IO.lift(() => { activityContext?.Dispose(); return Unit.Default; }) select result );컬렉션 반환 타입
섹션 제목: “컬렉션 반환 타입”// 원본public virtual FinT<IO, List<User>> GetUsers() => ...;
// 생성된 코드public override FinT<IO, List<User>> GetUsers() => FinT.lift<IO, List<User>>( // ... from __ in IO.lift(() => { activityContext?.SetTag("response.result.count", result?.Count ?? 0); // ← Count 태그 activityContext?.Dispose(); return Unit.Default; }) select result );배열 반환 타입
섹션 제목: “배열 반환 타입”// 원본public virtual FinT<IO, string[]> GetNames() => ...;
// 생성된 코드// ...activityContext?.SetTag("response.result.count", result?.Length ?? 0); // ← Length 사용테스트 시나리오
섹션 제목: “테스트 시나리오”컬렉션 파라미터 테스트
섹션 제목: “컬렉션 파라미터 테스트”[Fact]public Task Should_Generate_CollectionCountFields_WithCollectionParameters(){ string input = """ [GenerateObservablePort] public class DataRepository : IObservablePort { public virtual FinT<IO, int> ProcessItems(List<string> items) => FinT<IO, int>.Succ(items?.Count ?? 0); } """;
string? actual = _sut.Generate(input);
// request.params.items.count 필드 확인 actual.ShouldContain("request.params.items.count"); actual.ShouldContain("items?.Count ?? 0");
return Verify(actual);}튜플 반환 타입 테스트
섹션 제목: “튜플 반환 타입 테스트”[Fact]public Task Should_Not_Generate_Count_ForTupleContainingCollection(){ // 튜플 내부에 컬렉션이 있어도 Count 미생성 string input = """ [GenerateObservablePort] public class UserRepository : IObservablePort { public virtual FinT<IO, (int Id, List<string> Tags)> GetUserWithTags() => FinT<IO, (int Id, List<string> Tags)>.Succ((1, new List<string>())); } """;
string? actual = _sut.Generate(input);
// response.result.count 미생성 확인 actual.ShouldNotContain("response.result.count");
return Verify(actual);}배열 포함 튜플 테스트
섹션 제목: “배열 포함 튜플 테스트”[Fact]public Task Should_Not_Generate_Length_ForTupleContainingArray(){ string input = """ [GenerateObservablePort] public class StudentRepository : IObservablePort { public virtual FinT<IO, (string Name, int[] Scores)> GetStudentScores() => FinT<IO, (string Name, int[] Scores)>.Succ(("Student", new[] { 90, 85 })); } """;
string? actual = _sut.Generate(input);
// response.result.count (Length) 미생성 확인 actual.ShouldNotContain("response.result.count");
return Verify(actual);}타입별 동작 요약
섹션 제목: “타입별 동작 요약”| 반환 타입 | Count/Length 생성 | 표현식 |
|---|---|---|
List<T> | O | ?.Count ?? 0 |
T[] | O | ?.Length ?? 0 |
Dictionary<K, V> | O | ?.Count ?? 0 |
(int, string) | X | - |
(int, List<T>) | X | - |
(T, T[]) | X | - |
int | X | - |
string | X | - |
한눈에 보는 정리
섹션 제목: “한눈에 보는 정리”CollectionTypeHelper는 컬렉션 감지, 튜플 예외 처리, Count/Length 표현식 생성을 하나의 유틸리티로 통합합니다. 패턴 매칭 방식으로 global:: 접두사를 포함한 Fully Qualified Name도 올바르게 인식하며, 필드명은 request.params.{name}.count와 response.result.count 규칙을 따릅니다.
FAQ
섹션 제목: “FAQ”Q1: IEnumerable<T>도 컬렉션으로 인식하는데, Count() 호출 시 전체 열거가 발생하지 않나요?
섹션 제목: “Q1: IEnumerable<T>도 컬렉션으로 인식하는데, Count() 호출 시 전체 열거가 발생하지 않나요?”A: CollectionTypeHelper는 패턴 매칭으로 타입을 감지하지만, 실제 생성 코드에서는 ?.Count ?? 0 표현식을 사용합니다. 이는 ICollection<T>.Count 속성(O(1))을 호출하는 것이지 LINQ의 Count() 확장 메서드(O(n))가 아닙니다. 다만 순수한 IEnumerable<T>만 구현한 타입에서는 Count 속성이 없어 컴파일 오류가 발생할 수 있으므로, 실무에서는 구체적인 컬렉션 타입 사용을 권장합니다.
Q2: 튜플 내부에 컬렉션이 있을 때 Count를 생성하지 않는 이유는 무엇인가요?
섹션 제목: “Q2: 튜플 내부에 컬렉션이 있을 때 Count를 생성하지 않는 이유는 무엇인가요?”A: 튜플 자체에는 Count 속성이 없으므로, result?.Count와 같은 표현식이 컴파일 오류를 발생시킵니다. 튜플 내부의 개별 요소에 접근하려면 result.Item2?.Count처럼 요소별로 분해해야 하는데, 이는 생성기의 복잡도를 크게 높이는 반면 관찰 가능성 측면에서의 가치는 제한적입니다.
Q3: CollectionTypePatterns 배열에 global:: 접두사 버전을 별도로 추가하는 이유는 무엇인가요?
섹션 제목: “Q3: CollectionTypePatterns 배열에 global:: 접두사 버전을 별도로 추가하는 이유는 무엇인가요?”A: Roslyn의 SymbolDisplayFormat에 따라 타입 문자열이 List<T> 또는 global::System.Collections.Generic.List<T> 두 가지 형태로 나올 수 있기 때문입니다. Contains() 패턴 매칭이 두 경우 모두 올바르게 동작하려면 양쪽 패턴을 모두 포함해야 합니다.
컬렉션 Count 필드가 추가되면 로깅에 필요한 총 파라미터 수가 늘어납니다. 다음 섹션에서는 .NET LoggerMessage.Define의 6개 파라미터 제한과 이를 초과할 때의 폴백 전략을 학습합니다.