본문으로 건너뛰기

Collection Type 처리

관찰 가능성(Observability)에서 “사용자 목록을 조회했다”는 것만으로는 충분하지 않습니다. 실제 운영 환경에서는 “몇 건을 반환했는가”가 성능 분석과 이상 탐지의 핵심 지표가 됩니다. ObservablePortGenerator는 반환 타입이나 파라미터가 컬렉션인 경우 Count 또는 Length 태그를 자동으로 추가합니다. 다만 튜플 내부에 컬렉션이 포함된 경우는 튜플 자체에 Count 속성이 없으므로 예외로 처리해야 합니다.

  1. 컬렉션 타입 감지 방법
    • 패턴 매칭으로 List<T>, Dictionary<K,V>, 배열 등을 식별
  2. Count/Length 필드 자동 생성
    • 컬렉션 종류에 따라 적절한 크기 접근 표현식 생성
  3. 튜플 내 컬렉션 예외 처리
    • 튜플 반환 타입에서 내부 컬렉션을 무시하는 이유와 구현

관찰 가능성 코드에서 컬렉션의 크기 정보는 중요한 메트릭입니다.

// 원본 메서드
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
);

Generators/ObservablePortGenerator/CollectionTypeHelper.cs
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;
}

/// <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

/// <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";
}
/// <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-
intX-
stringX-

CollectionTypeHelper는 컬렉션 감지, 튜플 예외 처리, Count/Length 표현식 생성을 하나의 유틸리티로 통합합니다. 패턴 매칭 방식으로 global:: 접두사를 포함한 Fully Qualified Name도 올바르게 인식하며, 필드명은 request.params.{name}.countresponse.result.count 규칙을 따릅니다.


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개 파라미터 제한과 이를 초과할 때의 폴백 전략을 학습합니다.

04. LoggerMessage.Define 제한