콘텐츠로 이동

ASP.NET Core Minimal API 패턴

Minimal API는 ASP.NET Core 6에서 도입된 경량 웹 API 구축 방식입니다. 컨트롤러 클래스와 어트리뷰트 기반 라우팅 없이 람다 표현식으로 HTTP 엔드포인트를 직접 정의합니다. 마이크로서비스, serverless 함수, 프로토타입에 특히 적합합니다.


Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello, World!");
app.Run();

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
// GET
app.MapGet("/products", () => new[] { "laptop", "phone", "tablet" });
// GET with route parameter
app.MapGet("/products/{id:int}", (int id) => $"Product {id}");
// POST with body
app.MapPost("/products", (ProductRequest req) =>
{
var product = new Product { Id = 1, Name = req.Name, Price = req.Price };
return Results.Created($"/products/{product.Id}", product);
});
// PUT
app.MapPut("/products/{id:int}", (int id, ProductRequest req) =>
{
// 업데이트 로직
return Results.NoContent();
});
// DELETE
app.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; } }

// 서비스 등록
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() })
);

// 다양한 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();
});

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);
});

// 로깅 필터
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>();

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();

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);
}
}

Minimal API는 컨트롤러 기반 API보다 낮은 오버헤드와 빠른 시작 시간을 제공합니다. MapGroup으로 엔드포인트를 구조화하고, TypedResults로 OpenAPI 문서를 자동화하며, IEndpointFilter로 횡단 관심사를 처리하면 컨트롤러 API와 동등한 기능성을 유지하면서도 코드가 훨씬 간결해집니다.