ASP.NET Core Minimal API 패턴
Minimal API는 ASP.NET Core 6에서 도입된 경량 웹 API 구축 방식입니다. 컨트롤러 클래스와 어트리뷰트 기반 라우팅 없이 람다 표현식으로 HTTP 엔드포인트를 직접 정의합니다. 마이크로서비스, serverless 함수, 프로토타입에 특히 적합합니다.
1. 최소 구성
섹션 제목: “1. 최소 구성”var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
app.MapGet("/", () => "Hello, World!");
app.Run();2. 기본 라우팅 패턴
섹션 제목: “2. 기본 라우팅 패턴”var builder = WebApplication.CreateBuilder(args);builder.Services.AddEndpointsApiExplorer();builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();app.UseSwaggerUI();
// GETapp.MapGet("/products", () => new[] { "laptop", "phone", "tablet" });
// GET with route parameterapp.MapGet("/products/{id:int}", (int id) => $"Product {id}");
// POST with bodyapp.MapPost("/products", (ProductRequest req) =>{ var product = new Product { Id = 1, Name = req.Name, Price = req.Price }; return Results.Created($"/products/{product.Id}", product);});
// PUTapp.MapPut("/products/{id:int}", (int id, ProductRequest req) =>{ // 업데이트 로직 return Results.NoContent();});
// DELETEapp.MapDelete("/products/{id:int}", (int id) => Results.NoContent());
app.Run();
record ProductRequest(string Name, decimal Price);record Product { public int Id { get; init; } public string Name { get; init; } = ""; public decimal Price { get; init; } }3. 의존성 주입
섹션 제목: “3. 의존성 주입”// 서비스 등록builder.Services.AddScoped<IProductRepository, ProductRepository>();builder.Services.AddScoped<IProductService, ProductService>();
// 엔드포인트에서 DI (매개변수로 자동 주입)app.MapGet("/products/{id}", async ( int id, IProductService service, CancellationToken ct) =>{ var product = await service.GetByIdAsync(id, ct); return product is null ? Results.NotFound() : Results.Ok(product);});
// HttpContext 직접 접근app.MapGet("/me", (HttpContext ctx) => Results.Ok(new { UserAgent = ctx.Request.Headers.UserAgent.ToString() }));4. Results 헬퍼
섹션 제목: “4. Results 헬퍼”// 다양한 HTTP 응답 반환app.MapPost("/login", async (LoginRequest req, IAuthService auth) =>{ if (string.IsNullOrWhiteSpace(req.Username)) return Results.BadRequest("Username is required");
var token = await auth.AuthenticateAsync(req.Username, req.Password); if (token is null) return Results.Unauthorized();
return Results.Ok(new { Token = token });});
// TypedResults (C# 타입 추론, OpenAPI 문서 자동 생성)app.MapGet("/users/{id}", async Task<Results<Ok<User>, NotFound>> (int id, IUserRepo repo) =>{ var user = await repo.FindAsync(id); return user is not null ? TypedResults.Ok(user) : TypedResults.NotFound();});5. 엔드포인트 그룹화 (RouteGroupBuilder)
섹션 제목: “5. 엔드포인트 그룹화 (RouteGroupBuilder)”// 공통 prefix와 미들웨어를 그룹으로 묶기var products = app.MapGroup("/api/products") .WithTags("Products") .RequireAuthorization();
products.MapGet("/", async (IProductService svc) => Results.Ok(await svc.GetAllAsync()));
products.MapGet("/{id}", async (int id, IProductService svc) =>{ var p = await svc.GetByIdAsync(id); return p is null ? Results.NotFound() : Results.Ok(p);});
products.MapPost("/", async (ProductRequest req, IProductService svc) =>{ var created = await svc.CreateAsync(req); return Results.Created($"/api/products/{created.Id}", created);});
// 중첩 그룹var admin = app.MapGroup("/api/admin") .RequireAuthorization("AdminPolicy");
var adminProducts = admin.MapGroup("/products");adminProducts.MapDelete("/{id}", async (int id, IProductService svc) =>{ await svc.DeleteAsync(id); return Results.NoContent();});6. 요청 검증
섹션 제목: “6. 요청 검증”using FluentValidation;
// 검증기 등록builder.Services.AddScoped<IValidator<ProductRequest>, ProductRequestValidator>();
public class ProductRequestValidator : AbstractValidator<ProductRequest>{ public ProductRequestValidator() { RuleFor(x => x.Name).NotEmpty().MaximumLength(100); RuleFor(x => x.Price).GreaterThan(0); }}
// 필터로 자동 검증app.MapPost("/products", async ( ProductRequest req, IValidator<ProductRequest> validator, IProductService svc) =>{ var validation = await validator.ValidateAsync(req); if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary());
var created = await svc.CreateAsync(req); return Results.Created($"/products/{created.Id}", created);});7. 필터 (EndpointFilter)
섹션 제목: “7. 필터 (EndpointFilter)”// 로깅 필터public class LoggingFilter : IEndpointFilter{ private readonly ILogger<LoggingFilter> _logger; public LoggingFilter(ILogger<LoggingFilter> logger) => _logger = logger;
public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var path = context.HttpContext.Request.Path; _logger.LogInformation("Request: {Path}", path); var result = await next(context); _logger.LogInformation("Response: {Path}", path); return result; }}
// 적용app.MapGet("/products", async (IProductService svc) => Results.Ok(await svc.GetAllAsync())) .AddEndpointFilter<LoggingFilter>();8. OpenAPI / Swagger 문서화
섹션 제목: “8. OpenAPI / Swagger 문서화”app.MapGet("/products/{id}", async (int id, IProductService svc) =>{ var p = await svc.GetByIdAsync(id); return p is null ? Results.NotFound() : Results.Ok(p);}).WithName("GetProduct").WithSummary("Get a product by ID").WithDescription("Returns a single product matching the given ID").WithTags("Products").Produces<Product>(200).Produces(404).RequireAuthorization();9. 테스트
섹션 제목: “9. 테스트”using Microsoft.AspNetCore.Mvc.Testing;
public class ProductApiTests : IClassFixture<WebApplicationFactory<Program>>{ private readonly HttpClient _client;
public ProductApiTests(WebApplicationFactory<Program> factory) { _client = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // 테스트용 서비스 교체 services.AddScoped<IProductRepository, InMemoryProductRepository>(); }); }).CreateClient(); }
[Fact] public async Task GetProduct_Returns200_WhenExists() { var response = await _client.GetAsync("/products/1"); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var product = await response.Content.ReadFromJsonAsync<Product>(); Assert.NotNull(product); Assert.Equal(1, product.Id); }
[Fact] public async Task GetProduct_Returns404_WhenNotFound() { var response = await _client.GetAsync("/products/9999"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); }}10. Rate Limiting 통합 (.NET 7+)
섹션 제목: “10. Rate Limiting 통합 (.NET 7+)”using System.Threading.RateLimiting;
builder.Services.AddRateLimiter(options =>{ // 전역 정책 options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>( ctx => RateLimitPartition.GetFixedWindowLimiter( partitionKey: ctx.User.Identity?.Name ?? ctx.Request.Headers.Host.ToString(), factory: _ => new FixedWindowRateLimiterOptions { AutoReplenishment = true, PermitLimit = 100, Window = TimeSpan.FromMinutes(1) }));
// 특정 정책 options.AddPolicy("api", ctx => RateLimitPartition.GetSlidingWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "anonymous", _ => new SlidingWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromSeconds(10), SegmentsPerWindow = 4 }));
options.OnRejected = async (context, ct) => { context.HttpContext.Response.StatusCode = 429; await context.HttpContext.Response.WriteAsJsonAsync( new { error = "요청이 너무 많습니다. 잠시 후 재시도하세요." }, ct); };});
app.UseRateLimiter();
// 엔드포인트에 정책 적용app.MapGet("/api/data", GetData).RequireRateLimiting("api");11. 헬스체크 통합
섹션 제목: “11. 헬스체크 통합”builder.Services.AddHealthChecks() .AddCheck("database", async ct => { try { await using var conn = new SqlConnection(connectionString); await conn.OpenAsync(ct); return HealthCheckResult.Healthy("DB 연결 정상"); } catch (Exception ex) { return HealthCheckResult.Unhealthy("DB 연결 실패", ex); } }) .AddCheck<ExternalApiHealthCheck>("external-api");
app.MapHealthChecks("/health", new HealthCheckOptions{ ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse});app.MapHealthChecks("/health/live", new HealthCheckOptions{ Predicate = _ => false // liveness: 항상 200});Minimal API는 컨트롤러 기반 API보다 낮은 오버헤드와 빠른 시작 시간을 제공합니다. MapGroup으로 엔드포인트를 구조화하고, TypedResults로 OpenAPI 문서를 자동화하며, IEndpointFilter로 횡단 관심사를 처리하면 컨트롤러 API와 동등한 기능성을 유지하면서도 코드가 훨씬 간결해집니다. Rate Limiting과 헬스체크를 통합하면 프로덕션 준비 완료 수준의 API를 최소한의 코드로 구성할 수 있습니다.