Modern C# 코딩 표준
이 스킬을 사용해야 할 때
다음과 같은 상황에서 이 스킬을 사용하세요:
- 새로운 C# 코드를 작성하거나 기존 코드를 리팩터링할 때
- 라이브러리나 서비스의 공개 API를 설계할 때
- 성능이 중요한 코드 경로를 최적화할 때
- 강력한 타입으로 도메인 모델을 구현할 때
- async/await을 많이 사용하는 애플리케이션을 구축할 때
- 바이너리 데이터, 버퍼 또는 높은 처리량 시나리오를 다룰 때
참고 파일
- value-objects-and-patterns.md: 완전한 값 객체 예제 및 패턴 매칭 코드
- performance-and-api-design.md: Span/Memory 예제 및 API 설계 원칙
- composition-and-error-handling.md: 상속보다는 합성, Result 타입, 테스팅 패턴
- anti-patterns-and-reflection.md: 리플렉션 회피 및 일반적인 안티 패턴
핵심 원칙
- 기본값은 불변성 -
record타입과init전용 속성 사용 - 타입 안전성 - Nullable 참조 타입과 값 객체 활용
- 최신 패턴 매칭 -
switch식과 패턴을 광범위하게 사용 - 모든 곳에서 비동기 - 적절한 취소 지원과 함께 async API 선호
- 제로 할당 패턴 - 성능이 중요한 코드에는
Span<T>와Memory<T>사용 - API 설계 - 추상화는 입력으로 받고, 적절히 구체적인 타입을 반환
- 상속보다는 합성 - 추상 기본 클래스는 피하고 합성 선호
- 값 객체는 구조체로 - 값 객체에는
readonly record struct사용
언어 패턴
불변 데이터를 위한 레코드 (C# 9+)
DTO, 메시지, 이벤트 및 도메인 엔터티에는 record 타입을 사용하세요.
// 간단한 불변 DTO
public record CustomerDto(string Id, string Name, string Email);
// 생성자에 유효성 검사가 있는 레코드
public record EmailAddress
{
public string Value { get; init; }
public EmailAddress(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
throw new ArgumentException("유효하지 않은 이메일 주소", nameof(value));
Value = value;
}
}
// 컬렉션이 있는 레코드 - IReadOnlyList 사용
public record ShoppingCart(
string CartId,
string CustomerId,
IReadOnlyList<CartItem> Items
)
{
public decimal Total => Items.Sum(item => item.Price * item.Quantity);
}
record class vs record struct 사용 시기:
record class(기본값): 참조 타입, 여러 속성을 가진 엔터티, 집계, DTO에 사용record struct: 값 타입, 값 객체에 사용 (다음 섹션 참조)
값 객체로서의 readonly record struct
값 객체는 성능과 값 의미론을 위해 항상 readonly record struct 여야 합니다. 명시적 변환을 사용하고 암시적 연산자는 절대 사용하지 마세요.
public readonly record struct OrderId(string Value)
{
public OrderId(string value) : this(
!string.IsNullOrWhiteSpace(value)
? value
: throw new ArgumentException("OrderId는 비어 있을 수 없습니다", nameof(value)))
{ }
public override string ToString() => Value;
}
public readonly record struct Money(decimal Amount, string Currency);
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
}
다중 값 객체, 팩토리 패턴 및 암시적 변환 금지 규칙을 포함한 전체 예제는 value-objects-and-patterns.md를 참조하세요.
패턴 매칭 (C# 8-12)
더 깔끔한 코드를 위해 switch 식, 속성 패턴, 관계형 패턴 및 리스트 패턴을 사용하세요.
public decimal CalculateDiscount(Order order) => order switch
{
{ Total: > 1000m } => order.Total * 0.15m,
{ Total: > 500m } => order.Total * 0.10m,
{ Total: > 100m } => order.Total * 0.05m,
_ => 0m
};
전체 패턴 매칭 예제는 value-objects-and-patterns.md를 참조하세요.
Nullable 참조 타입 (C# 8+)
프로젝트에서 nullable 참조 타입을 활성화하고 null을 명시적으로 처리하세요.
// .csproj 파일
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
// 명시적 null 허용
public string? FindUserName(string userId)
{
var user = _repository.Find(userId);
return user?.Name;
}
// null 검사가 포함된 패턴 매칭
public decimal GetDiscount(Customer? customer) => customer switch
{
null => 0m,
{ IsVip: true } => 0.20m,
{ OrderCount: > 10 } => 0.10m,
_ => 0.05m
};
// ArgumentNullException.ThrowIfNull을 사용한 가드 절 (C# 11+)
public void ProcessOrder(Order? order)
{
ArgumentNullException.ThrowIfNull(order);
// 이제 이 범위에서 order는 null이 아닙니다.
Console.WriteLine(order.Id);
}
상속보다는 합성
추상 기본 클래스는 피하세요. 인터페이스 + 합성을 사용하세요. 공유 로직에는 정적 헬퍼를 사용하세요. 변형에는 팩토리 메서드가 있는 레코드를 사용하세요.
전체 예제는 composition-and-error-handling.md를 참조하세요.
성능 패턴
Async/Await 모범 사례
// 완전 비동기 - 항상 CancellationToken을 받아들임
public async Task<Order> GetOrderAsync(string orderId, CancellationToken cancellationToken)
{
var order = await _repository.GetAsync(orderId, cancellationToken);
return order;
}
// 자주 호출되며 대부분 동기적인 메서드에는 ValueTask 사용
public ValueTask<Order?> GetCachedOrderAsync(string orderId, CancellationToken cancellationToken)
{
if (_cache.TryGetValue(orderId, out var order))
return ValueTask.FromResult<Order?>(order);
return GetFromDatabaseAsync(orderId, cancellationToken);
}
// 스트리밍을 위한 IAsyncEnumerable
public async IAsyncEnumerable<Order> StreamOrdersAsync(
string customerId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var order in _repository.StreamAllAsync(cancellationToken))
{
if (order.CustomerId == customerId)
yield return order;
}
}
핵심 규칙:
- 항상
= default와 함께CancellationToken을 받아들임 - 라이브러리 코드에서는
ConfigureAwait(false)사용 - 비동기 코드를 절대 차단하지 마세요 (
.Result또는.Wait()사용 금지) - 타임아웃에는 연결된 CancellationTokenSource 사용
Span 및 Memory
동기식 제로 할당 작업에는 Span<T>를, 비동기 작업에는 Memory<T>를, 대규모 임시 버퍼에는 ArrayPool<T>를 사용하세요.
전체 Span/Memory 예제 및 API 설계 섹션은 performance-and-api-design.md를 참조하세요.
오류 처리: Result 타입
예상되는 오류에는 예외 대신 Result<T, TError>를 사용하세요. 예외는 예상치 못한/시스템 오류에만 사용하세요.
전체 Result 타입 구현 및 사용 예제는 composition-and-error-handling.md를 참조하세요.
리플렉션 기반 메타프로그래밍 피하기
금지: AutoMapper, Mapster, ExpressMapper. 대신 명시적 매핑 확장 메서드를 사용하세요. 비공개 멤버에 진정으로 접근해야 하는 경우 UnsafeAccessorAttribute (.NET 8+)를 사용하세요.
전체 지침은 anti-patterns-and-reflection.md를 참조하세요.
코드 구성
// 파일: Domain/Orders/Order.cs
namespace MyApp.Domain.Orders;
// 1. 기본 도메인 타입
public record Order(
OrderId Id,
CustomerId CustomerId,
Money Total,
OrderStatus Status,
IReadOnlyList<OrderItem> Items
)
{
public bool IsCompleted => Status is OrderStatus.Completed;
public Result<Order, OrderError> AddItem(OrderItem item)
{
if (Status is not OrderStatus.Draft)
return Result<Order, OrderError>.Failure(
new OrderError("ORDER_NOT_DRAFT", "초안 상태의 주문에만 항목을 추가할 수 있습니다"));
var newItems = Items.Append(item).ToList();
var newTotal = new Money(
Items.Sum(i => i.Total.Amount) + item.Total.Amount,
Total.Currency);
return Result<Order, OrderError>.Success(
this with { Items = newItems, Total = newTotal });
}
}
// 2. 상태를 위한 열거형
public enum OrderStatus { Draft, Submitted, Processing, Completed, Cancelled }
// 3. 관련 타입
public record OrderItem(ProductId ProductId, Quantity Quantity, Money UnitPrice)
{
public Money Total => new(UnitPrice.Amount * Quantity.Value, UnitPrice.Currency);
}
// 4. 값 객체
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
// 5. 오류
public readonly record struct OrderError(string Code, string Message);
모범 사례 요약
해야 할 것
- DTO, 메시지 및 도메인 엔터티에는
record사용 - 값 객체에는
readonly record struct사용 switch식과 함께 패턴 매칭 활용- nullable 참조 타입 활성화 및 준수
- 모든 I/O 작업에 async/await 사용
- 모든 비동기 메서드에서
CancellationToken받아들이기 - 고성능 시나리오에서는
Span<T>및Memory<T>사용 - 추상화 (
IEnumerable<T>,IReadOnlyList<T>)를 입력으로 받아들이기 - 예상되는 오류에는
Result<T, TError>사용 - 대규모 할당에는
ArrayPool<T>로 버퍼 풀링 - 상속보다는 합성 선호
하지 말아야 할 것
- 레코드가 가능한 경우 가변 클래스 사용 금지
- 값 객체에 클래스 사용 금지 (
readonly record struct사용) - 깊은 상속 계층 구조 생성 금지
- nullable 참조 타입 경고 무시 금지
- 비동기 코드 차단 금지 (
.Result,.Wait()) Span<byte>로 충분할 때byte[]사용 금지CancellationToken매개변수 잊지 않기- API에서 가변 컬렉션 반환 금지
- 예상되는 비즈니스 오류에 예외를 던지지 않기
- 대규모 배열을 반복적으로 할당하지 않기 (
ArrayPool사용)
자세한 안티 패턴 예제는 anti-patterns-and-reflection.md를 참조하세요.
추가 리소스
- C# 언어 사양: https://learn.microsoft.com/ko-kr/dotnet/csharp/
- 패턴 매칭: https://learn.microsoft.com/ko-kr/dotnet/csharp/fundamentals/functional/pattern-matching
- Span 및 Memory: https://learn.microsoft.com/ko-kr/dotnet/standard/memory-and-spans/
- Async 모범 사례: https://learn.microsoft.com/ko-kr/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming
- .NET 성능 팁: https://learn.microsoft.com/ko-kr/dotnet/framework/performance/