C#의 타입 안전 컬렉션: NonEmptyList가 런타임 예외를 없애는 방법
I’m ready to translate the article for you, but I’ll need the full text you’d like translated. Could you please provide the content (excluding the source line you’ve already included)? Once I have the article text, I’ll translate it into Korean while preserving the formatting, markdown, and code blocks as requested.
전통적인 방어적 프로그래밍
public async Task CalculateAverageOrderValueAsync(int customerId)
{
var orders = await _repository.GetOrdersAsync(customerId);
return orders.Average(o => o.Total); // Throws if orders is empty
}
if (orders.Any())
{
return orders.Average(o => o.Total);
}
return 0m; // What does zero mean here? No orders? An error?
단점
| Issue | Why it’s a problem |
|---|---|
| Silent failures | Returning a default masks the underlying issue |
| Code duplication | Empty checks are scattered throughout the codebase |
| Semantic ambiguity | Is 0 a valid average or an error state? |
타입‑안전 솔루션: NonEmptyList
컬렉션이 반드시 하나 이상의 요소를 가져야 함을 타입 시그니처에 직접 표현할 수 있다면 어떨까요? 바로 **NonEmptyList**가 제공하는 기능입니다.
public async Task CalculateAverageOrderValueAsync(
NonEmptyList<Order> orders) // 컴파일 타임에 비어 있지 않음이 보장됨
{
return orders.Average(o => o.Total); // 언제나 안전
}
컴파일러가 이제 비즈니스 규칙을 강제합니다. 호출자는 비어 있지 않은 컬렉션을 제공해야 하므로, 검증을 런타임에서 컴파일 타임으로 옮길 수 있습니다.
dotnet add package Masterly.NonEmptyList
.NET 6.0 및 .NET 8.0과 호환됩니다.
NonEmptyList는 함수형 언어(예: Scala의 List)에서 영감을 받았습니다. 세 가지 기본 순회 특성을 제공합니다:
| 속성 | Type | 설명 |
|---|---|---|
| Head | T | 첫 번째 요소(존재가 보장됨) |
| Tail | NonEmptyList? | 나머지 요소들, 단일 요소인 경우 null |
| Init | NonEmptyList? | 마지막을 제외한 모든 요소, 단일 요소인 경우 null |
var numbers = new NonEmptyList(1, 2, 3, 4, 5);
var head = numbers.Head; // 1
var tail = numbers.Tail; // NonEmptyList { 2, 3, 4, 5 }
var init = numbers.Init; // NonEmptyList { 1, 2, 3, 4 }
var last = numbers.Last; // 5
패턴 매칭
var (first, rest) = numbers;
// first = 1, rest = NonEmptyList { 2, 3, 4, 5 }
비어 있지 않음 보장을 유지한 변환
var orders = new NonEmptyList(order1, order2, order3);
// DTO로 매핑
NonEmptyList<OrderDto> dtos = orders.Map(o => new OrderDto(o));
// 중첩 컬렉션 평탄화
NonEmptyList<Item> allItems = orders.FlatMap(o => o.Items);
집계
전통적인 LINQ는 컬렉션이 비어 있을 수 있기 때문에 시드를 요구합니다:
// 빈 컬렉션이면 예외 발생
decimal total = orders.Aggregate(0m, (sum, o) => sum + o.Total);
NonEmptyList를 사용하면 시드 없이 Reduce를 사용할 수 있습니다:
// 항상 안전 – 컬렉션이 비어 있지 않음이 보장됨
decimal total = orders.Reduce((sum, o) => sum + o.Total);
요소 타입과 결과 타입이 다를 때:
var summary = orders.Fold(
new OrderSummary(),
(summary, order) => summary.AddOrder(order)
);
분할 및 그룹화
var (highValue, standard) = orders.Partition(o => o.Total > 1000m);
var byStatus = orders.GroupByNonEmpty(o => o.Status);
// 결과: Dictionary<Status, NonEmptyList<Order>>
단일‑요소와 다중‑요소 경우를 명시적으로 처리
string ProcessOrders(NonEmptyList<Order> orders) =>
orders.Match(
single: order => $"Processing single order: {order.Id}",
multiple: (first, remaining) =>
$"Processing batch: {first.Id} and {remaining.Count} more"
);
이 패턴은 재귀 알고리즘과 배치 처리 로직에서 특히 빛을 발합니다.
비동기 지원
NonEmptyList는 순차 및 병렬 비동기 작업을 모두 제공합니다:
var enrichedOrders = await orders.MapAsync(
async order => await _enrichmentService.EnrichAsync(order)
);
var results = await orders.MapParallelAsync(
async order => await _processor.ProcessAsync(order),
maxDegreeOfParallelism: 4
);
var totalRevenue = await orders.FoldAsync(
0m,
async (sum, order) => sum + await _calculator.CalculateRevenueAsync(order)
);
불변성
var immutable = new ImmutableNonEmptyList(1, 2, 3);
// 모든 연산은 새로운 인스턴스를 반환합니다
var appended = immutable.Append(4); // [1, 2, 3, 4]
var prepended = immutable.Prepend(0); // [0, 1, 2, 3]
// 원본은 변경되지 않음
Console.WriteLine(immutable.Count); // 3
가변 및 불변 변형 간 변환:
var immutable = mutableList.ToImmutable();
var mutable = immutableList.ToMutable();
직렬화
내장 System.Text.Json 컨버터는 (역)직렬화를 자동으로 처리합니다:
var orders = new NonEmptyList(order1, order2);
string json = JsonSerializer.Serialize(orders);
var restored = JsonSerializer.Deserialize<NonEmptyList<Order>>(json);
역직렬화는 비어 있지 않은 제약을 강제합니다:
// 설명이 포함된 메시지와 함께 JsonException을 발생시킵니다
JsonSerializer.Deserialize<NonEmptyList<Order>>("[]");
Entity Framework Core 통합
전용 패키지를 추가하면 데이터베이스 영속성 지원이 포함됩니다:
dotnet add package Masterly.NonEmptyList.EntityFrameworkCore
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Store as JSON column
modelBuilder.Entity<Order>()
.Property(p => p.Tags)
.HasNonEmptyListConversion();
// Configure relationships with non‑empty constraint
modelBuilder.Entity<Order>()
.HasNonEmptyManyWithOne(
o => o.LineItems,
li => li.Order,
li => li.OrderId
);
}
타입을 사용하는 예제 서비스
public class OrderProcessingService
{
public async Task<ProcessingResult> ProcessBatchAsync(
NonEmptyList<Order> orders,
CancellationToken cancellationToken = default)
{
// Validate all orders in parallel
var validationResults = await orders.MapParallelAsync(
async order => await _validator.ValidateAsync(order, cancellationToken),
maxDegreeOfParallelism: 8,
cancellationToken);
// Continue only if every order is valid
if (validationResults.Any(r => !r.IsSuccess))
return ProcessingResult.Failure("One or more orders are invalid.");
// Process the batch
var processingResults = await orders.MapParallelAsync(
async order => await _processor.ProcessAsync(order, cancellationToken),
maxDegreeOfParallelism: 8,
cancellationToken);
return ProcessingResult.Success(processingResults);
}
}
Summary
- Compile‑time guarantee that a collection is never empty.
- Eliminates a whole class of runtime exceptions (
InvalidOperationException,NullReferenceException). - Rich functional API (
Map,FlatMap,Reduce,Fold,Partition, async variants, etc.). - Immutable & mutable variants, seamless conversion.
- First‑class support for JSON serialization and EF Core persistence.
Add the package, replace ordinary IEnumerable/List where a non‑empty guarantee is required, and let the compiler enforce your business rules. 🚀
코드 예제
elAsync(
order => _validator.ValidateAsync(order, cancellationToken),
maxDegreeOfParallelism: 8
);
// Partition by validation result
var (valid, invalid) = orders
.Zip(validationResults)
.Partition(pair => pair.Item2.IsValid);
if (invalid.Any())
{
_logger.LogWarning("Found {Count} invalid orders", invalid.Count);
}
// Process valid orders
var processed = valid switch
{
null => new ProcessingResult(0, 0m),
var validOrders => await ProcessValidOrdersAsync(
validOrders.Map(pair => pair.Item1),
cancellationToken
)
};
return processed;
}
private async Task ProcessValidOrdersAsync(
NonEmptyList<Order> orders,
CancellationToken cancellationToken)
{
// Calculate totals – Reduce is safe, no empty check needed
var totalRevenue = orders.Reduce((sum, o) => sum + o.Total);
// Process with pattern matching
await orders.Match(
single: async order =>
await _processor.ProcessSingleAsync(order, cancellationToken),
multiple: async (priority, remaining) =>
{
await _processor.ProcessPriorityAsync(priority, cancellationToken);
await remaining.ForEachAsync(
order => _processor.ProcessAsync(order, cancellationToken)
);
}
);
return new ProcessingResult(orders.Count, totalRevenue);
}
이상적인 사용 사례
- 빈 컬렉션이 허용되지 않는 도메인 모델 (예: 주문은 반드시 라인 아이템을 가져야 함)
- 최소 하나의 요소가 필요하도록 설계된 API 계약
- 빈 입력이 오류가 되는 집계 연산
- 함수형 프로그래밍 패턴 (head/tail 재귀)
다음과 같은 경우 대안을 고려하세요
- 빈 컬렉션이 도메인에서 유효한 경우.
- 빈 상태에서 컬렉션을 점진적으로 구축해야 할 때.
- 성능이 중요하고 할당 오버헤드가 문제인 경우.
리소스
- GitHub 저장소
- 문서 위키
- NuGet 패키지
- EF Core 패키지
요약
NonEmptyList는 잘 확립된 함수형 프로그래밍 패턴을 C#에 도입하여, 개발자가 도메인 제약을 타입 시스템에 직접 표현할 수 있게 합니다. 검증을 런타임에서 컴파일 타임으로 옮김으로써 전체 버그 카테고리를 제거하고, 코드를 보다 표현력 있게 자체 문서화된 형태로 만들 수 있습니다.
이 라이브러리가 프로젝트에 도움이 된다면, GitHub에서 ⭐️ 를 눌러 주세요.