| name | dotnet-patterns |
| description | 관용적인 C# 및 .NET 패턴, 컨벤션, 의존성 주입(DI), async/await, 견고하고 유지 관리가 용이한 .NET 애플리케이션 구축을 위한 모범 사례입니다. |
| origin | ECC |
.NET 개발 패턴
견고하고 성능이 뛰어나며 유지 관리가 쉬운 애플리케이션을 구축하기 위한 관용적인 C# 및 .NET 패턴입니다.
활성화 시점
- 새로운 C# 코드를 작성할 때
- C# 코드를 리뷰할 때
- 기존 .NET 애플리케이션을 리팩터링할 때
- ASP.NET Core를 사용하여 서비스 아키텍처를 설계할 때
핵심 원칙
1. 불변성(Immutability) 선호
데이터 모델에는 record와 init 전용 속성을 사용하세요. 가변성(Mutability)은 명확하고 정당한 이유가 있을 때만 선택해야 합니다.
public sealed record Money(decimal Amount, string Currency);
public sealed class CreateOrderRequest
{
public required string CustomerId { get; init; }
public required IReadOnlyList<OrderItem> Items { get; init; }
}
public class Order
{
public string CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
}
2. 암시적보다 명시적으로
Null 허용 여부(nullability), 접근 제한자, 그리고 코드의 의도를 명확하게 밝히세요.
public sealed class UserService
{
private readonly IUserRepository _repository;
private readonly ILogger<UserService> _logger;
public UserService(IUserRepository repository, ILogger<UserService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<User?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _repository.FindByIdAsync(id, cancellationToken);
}
}
3. 추상화에 의존
서비스 경계에는 인터페이스를 사용하세요. DI 컨테이너를 통해 등록합니다.
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken);
Task<IReadOnlyList<Order>> FindByCustomerAsync(string customerId, CancellationToken cancellationToken);
Task AddAsync(Order order, CancellationToken cancellationToken);
}
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
Async/Await 패턴
적절한 비동기(Async) 사용
public async Task<OrderSummary> GetOrderSummaryAsync(
Guid orderId,
CancellationToken cancellationToken)
{
var order = await _repository.FindByIdAsync(orderId, cancellationToken)
?? throw new NotFoundException($"Order {orderId} not found");
var customer = await _customerService.GetAsync(order.CustomerId, cancellationToken);
return new OrderSummary(order, customer);
}
public OrderSummary GetOrderSummary(Guid orderId)
{
var order = _repository.FindByIdAsync(orderId, CancellationToken.None).Result;
return new OrderSummary(order);
}
병렬 비동기 작업
public async Task<DashboardData> LoadDashboardAsync(CancellationToken cancellationToken)
{
var ordersTask = _orderService.GetRecentAsync(cancellationToken);
var metricsTask = _metricsService.GetCurrentAsync(cancellationToken);
var alertsTask = _alertService.GetActiveAsync(cancellationToken);
await Task.WhenAll(ordersTask, metricsTask, alertsTask);
return new DashboardData(
Orders: await ordersTask,
Metrics: await metricsTask,
Alerts: await alertsTask);
}
옵션(Options) 패턴
구성(Configuration) 섹션을 강력한 형식의 객체에 바인딩합니다.
public sealed class SmtpOptions
{
public const string SectionName = "Smtp";
public required string Host { get; init; }
public required int Port { get; init; }
public required string Username { get; init; }
public bool UseSsl { get; init; } = true;
}
builder.Services.Configure<SmtpOptions>(
builder.Configuration.GetSection(SmtpOptions.SectionName));
public class EmailService(IOptions<SmtpOptions> options)
{
private readonly SmtpOptions _smtp = options.Value;
}
결과(Result) 패턴
예상되는 실패에 대해서는 예외를 던지는 대신 명시적인 성공/실패 여부를 반환합니다.
public sealed record Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(T value) { IsSuccess = true; Value = value; }
private Result(string error) { IsSuccess = false; Error = error; }
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string error) => new(error);
}
public async Task<Result<Order>> PlaceOrderAsync(CreateOrderRequest request)
{
if (request.Items.Count == 0)
return Result<Order>.Failure("주문에는 최소 하나의 항목이 포함되어야 합니다.");
var order = Order.Create(request);
await _repository.AddAsync(order, CancellationToken.None);
return Result<Order>.Success(order);
}
EF Core를 이용한 저장소(Repository) 패턴
public sealed class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public SqlOrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _db.Orders
.Include(o => o.Items)
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Order>> FindByCustomerAsync(
string customerId,
CancellationToken cancellationToken)
{
return await _db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task AddAsync(Order order, CancellationToken cancellationToken)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
}
}
미들웨어 및 파이프라인
public sealed class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation(
"Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
context.Request.Method,
context.Request.Path,
stopwatch.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
Minimal API 패턴
var orders = app.MapGroup("/api/orders")
.RequireAuthorization()
.WithTags("Orders");
orders.MapGet("/{id:guid}", async (
Guid id,
IOrderRepository repository,
CancellationToken cancellationToken) =>
{
var order = await repository.FindByIdAsync(id, cancellationToken);
return order is not null
? TypedResults.Ok(order)
: TypedResults.NotFound();
});
orders.MapPost("/", async (
CreateOrderRequest request,
IOrderService service,
CancellationToken cancellationToken) =>
{
var result = await service.PlaceOrderAsync(request, cancellationToken);
return result.IsSuccess
? TypedResults.Created($"/api/orders/{result.Value!.Id}", result.Value)
: TypedResults.BadRequest(result.Error);
});
가드 절 (Guard Clauses)
public async Task<ProcessResult> ProcessPaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Amount <= 0)
throw new ArgumentOutOfRangeException(nameof(request.Amount), "금액은 양수여야 합니다.");
if (string.IsNullOrWhiteSpace(request.Currency))
throw new ArgumentException("통화 정보는 필수입니다.", nameof(request.Currency));
var gateway = _gatewayFactory.Create(request.Currency);
return await gateway.ChargeAsync(request, cancellationToken);
}
피해야 할 안티패턴
| 안티패턴 | 해결책 |
|---|
async void 메서드 | Task를 반환하세요 (이벤트 핸들러 제외) |
.Result 또는 .Wait() | await를 사용하세요 |
catch (Exception) { } | 에러를 처리하거나 컨텍스트와 함께 다시 던지세요(rethrow) |
생성자에서 new Service() 호출 | 생성자 주입(DI)을 사용하세요 |
public 필드 | 적절한 접근자를 가진 속성(property)을 사용하세요 |
비즈니스 로직에서의 dynamic | 제네릭이나 명시적인 타입을 사용하세요 |
가변 static 상태 | DI 스코프나 ConcurrentDictionary를 사용하세요 |
루프 안에서의 string.Format | StringBuilder나 보간된 문자열 핸들러를 사용하세요 |