.NET의 최소 API: 경량 HTTP API 구축
한동안 ASP.NET Core를 사용하여 API를 구축했다면 아마도 컨트롤러 기반 접근 방식에 매우 익숙할 것입니다. 즉, 컨트롤러 클래스를 만들고 특성으로 장식하고 생성자를 통해 서비스를 삽입하고 경로를 연결합니다. 그것은 작동하고 잘 작동합니다. 그러나 때때로 “이 요청을 받고, 무언가를 하고, 응답을 반환"하는 것에 대한 많은 의식을 작성하고 있는 것처럼 느껴질 때가 있습니다.
이것이 바로 Minimal API가 해결하려고 하는 문제입니다. .NET 6에 도입된 Minimal API를 사용하면 매우 적은 상용구를 사용하여 HTTP 엔드포인트를 정의할 수 있습니다. 컨트롤러도 없고, 속성도 없고, 시작 클래스 저글링도 없습니다. 깔끔하고 기능적인 스타일로 경로에서 핸들러까지 직접 매핑만 하면 됩니다.
이 게시물에서는 첫 번째 엔드포인트부터 대규모 애플리케이션 구성, 인증 처리, 유효성 검사, OpenAPI 문서 및 성능 고려 사항에 이르기까지 최소 API에 대해 알아야 할 모든 것을 안내하고 싶습니다. 시작해 봅시다.
왜 최소 API인가?
시작하기 전에 기존 컨트롤러 기반 접근 방식 대신 최소 API를 선택하는 이유에 대해 이야기해 보겠습니다.
컨트롤러는 구조화되고 독선적인 프레임워크가 필요할 때 유용합니다. 이는 모델 바인딩, 필터, 콘텐츠 협상 및 기본적으로 우려 사항을 명확하게 분리하는 기능을 제공합니다. 수십 명의 개발자가 있는 대규모 엔터프라이즈 애플리케이션의 경우 컨트롤러가 적용하는 일관성은 실질적인 이점이 될 수 있습니다.
반면에 최소 API는 원할 때 빛을 발합니다.
- 상용구 감소 — 컨트롤러 클래스 없음,
[ApiController]속성 없음, 별도의 시작 구성 없음. - 빠른 시작 — 부팅 시 반사 기반 작업이 줄어듭니다.
- 더 단순한 정신 모델 — 경로가 핸들러에 직접 매핑됩니다. 그게 다야.
- 마이크로서비스 친화적 — API에 5~10개의 엔드포인트가 있는 경우 전체 컨트롤러 설정이 과잉처럼 느껴질 수 있습니다.
좋은 소식은 이것이 둘 중 하나의 결정이 아니라는 것입니다. 동일한 프로젝트에서 컨트롤러와 최소 API를 혼합할 수 있습니다. 그러나 일단 최소한의 접근 방식에 익숙해지면 예상보다 더 자주 접근하게 될 수도 있습니다.
시작하기
Minimal API를 처음부터 만들어 보겠습니다. .NET SDK가 설치되어 있는 경우 다음과 같이 간단합니다.
dotnet new web -n MyMinimalApi
cd MyMinimalApi
그러면 다음과 같은 Program.cs가 제공됩니다.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
그게 다야. 그것은 작동하는 API입니다. Startup.cs 없음, 컨트롤러 클래스 없음, 라우팅 구성 없음. dotnet run를 실행하고 http://localhost:5000를 누르면 “Hello World!“가 표시됩니다. 뒤쪽에. 단순함이 핵심입니다.
클래식 todo API를 사용하여 좀 더 유용하게 만들어 보겠습니다.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var todos = new List<Todo>();
app.MapGet("/todos", () => todos);
app.MapGet("/todos/{id:int}", (int id) =>
todos.FirstOrDefault(t => t.Id == id) is { } todo
? Results.Ok(todo)
: Results.NotFound());
app.MapPost("/todos", (Todo todo) =>
{
todos.Add(todo);
return Results.Created($"/todos/{todo.Id}", todo);
});
app.MapDelete("/todos/{id:int}", (int id) =>
{
var todo = todos.FirstOrDefault(t => t.Id == id);
if (todo is null) return Results.NotFound();
todos.Remove(todo);
return Results.NoContent();
});
app.Run();
record Todo(int Id, string Title, bool IsComplete);
우리는 30줄 미만의 전체 CRUD를 가지고 있습니다. 이것이 바로 최소한의 접근 방식의 힘입니다.
경로 처리기
경로 핸들러는 요청이 경로와 일치할 때 실행되는 기능입니다. 이를 정의하는 데는 여러 가지 옵션이 있습니다.
람다 표현식
가장 일반적인 접근 방식과 대부분의 예에서 볼 수 있는 내용은 다음과 같습니다.
app.MapGet("/hello", () => "Hello!");
app.MapGet("/hello/{name}", (string name) => $"Hello, {name}!");
메소드 그룹
더 복잡한 논리의 경우 명명된 메서드를 지정할 수 있습니다.
app.MapGet("/products", GetProducts);
app.MapPost("/products", CreateProduct);
static IResult GetProducts(AppDbContext db)
=> Results.Ok(db.Products.ToList());
static IResult CreateProduct(Product product, AppDbContext db)
{
db.Products.Add(product);
db.SaveChanges();
return Results.Created($"/products/{product.Id}", product);
}
```이것은 한 줄 이상의 내용에 대해 내가 선호하는 접근 방식입니다. 경로 매핑 섹션을 깔끔하고 읽기 쉽게 유지합니다. 구현 세부 사항을 살펴보지 않고도 어떤 엔드포인트가 있는지 한눈에 확인할 수 있습니다.
### 로컬 함수
로컬 함수를 사용할 수도 있는데, 이는 핸들러를 경로 정의에 가깝게 유지하려는 경우에 유용합니다.
```csharp
app.MapGet("/health", CheckHealth);
IResult CheckHealth()
{
// Some health check logic
return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow });
}
매개변수 바인딩
Minimal API에 대해 제가 정말 높이 평가하는 점 중 하나는 매개변수 바인딩이 얼마나 직관적인지입니다. 프레임워크는 컨텍스트와 유형을 기반으로 어디에서 값을 가져올지 파악합니다.
경로 매개변수
app.MapGet("/users/{id:int}", (int id) => $"User {id}");
app.MapGet("/files/{*path}", (string path) => $"File: {path}"); // catch-all
쿼리 문자열 매개변수
app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
Results.Ok(new { query, page, pageSize }));
Null 허용 유형은 선택적 매개변수가 됩니다. 기본값은 예상한 대로 정확하게 작동합니다.
요청 본문
app.MapPost("/orders", (Order order) =>
{
// 'order' is automatically deserialized from the JSON body
return Results.Created($"/orders/{order.Id}", order);
});
헤더 및 서비스 바인딩
app.MapGet("/protected", (
[FromHeader(Name = "X-Api-Key")] string apiKey,
[FromServices] ILogger<Program> logger) =>
{
logger.LogInformation("Request with API key: {Key}", apiKey[..4] + "****");
return Results.Ok("Authorized");
});
HttpContext 및 HttpRequest
낮은 수준의 액세스가 필요한 경우:
app.MapGet("/info", (HttpContext context) =>
{
var userAgent = context.Request.Headers.UserAgent.ToString();
var ip = context.Connection.RemoteIpAddress?.ToString();
return Results.Ok(new { userAgent, ip });
});
BindAsync를 사용한 사용자 정의 바인딩
복합 유형의 경우 정적 BindAsync 메소드를 구현할 수 있습니다.
public record PaginationParams(int Page, int PageSize)
{
public static ValueTask<PaginationParams?> BindAsync(HttpContext context)
{
int.TryParse(context.Request.Query["page"], out var page);
int.TryParse(context.Request.Query["pageSize"], out var pageSize);
var result = new PaginationParams(
Page: page > 0 ? page : 1,
PageSize: pageSize > 0 ? Math.Min(pageSize, 100) : 20
);
return ValueTask.FromResult<PaginationParams?>(result);
}
}
app.MapGet("/items", (PaginationParams pagination, AppDbContext db) =>
{
var items = db.Items
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToList();
return Results.Ok(items);
});
이것은 엄청나게 강력합니다. 바인딩 논리를 한 번 정의하면 모든 엔드포인트에서 재사용됩니다.
검증
Minimal API가 컨트롤러에 비해 기본적으로 많은 것을 제공하지 못하는 영역 중 하나는 모델 검증입니다. 자동 [Required] 또는 [StringLength] 시행은 없습니다. 하지만 이를 처리하기 위한 깔끔한 패턴이 있습니다.
수동 검증
가장 간단한 접근 방식은 처리기에서 유효성을 검사하는 것입니다.
app.MapPost("/users", (CreateUserRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Email))
return Results.BadRequest(new { Error = "Email is required" });
if (request.Name?.Length > 100)
return Results.BadRequest(new { Error = "Name must be 100 characters or less" });
// Create user...
return Results.Created($"/users/{request.Email}", request);
});
유효성 검사 라이브러리 사용
중요하지 않은 경우에는 FluentValidation 또는 유사한 라이브러리에 접근하는 것이 좋습니다.
public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Age).InclusiveBetween(0, 150);
}
}
엔드포인트 필터를 통해 이를 엔드포인트에 연결할 수 있으며 다음 섹션으로 넘어갑니다.
엔드포인트 필터
엔드포인트 필터는 Minimal API의 가장 뛰어난 기능 중 하나입니다. 이를 미들웨어로 생각하지만 전체 파이프라인 대신 특정 엔드포인트로 범위가 지정됩니다. 이는 .NET 7에서 도입되었으며 교차 편집 문제에 환상적입니다.
기본 필터
app.MapPost("/todos", (Todo todo) =>
{
// Handle the request
return Results.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter(async (context, next) =>
{
var todo = context.GetArgument<Todo>(0);
if (string.IsNullOrEmpty(todo.Title))
{
return Results.BadRequest(new { Error = "Title is required" });
}
return await next(context);
});
FluentValidation을 사용한 유효성 검사 필터
재사용 가능한 검증 필터가 정말 강력해지는 부분은 다음과 같습니다.
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var model = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (model is null)
return Results.BadRequest(new { Error = "Request body is required" });
var result = await _validator.ValidateAsync(model);
if (!result.IsValid)
{
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return Results.ValidationProblem(errors);
}
return await next(context);
}
}
// Usage
app.MapPost("/users", (CreateUserRequest request) =>
{
// Only reached if validation passes
return Results.Created($"/users/{request.Email}", request);
})
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();
로깅 필터
app.MapGet("/products", (AppDbContext db) => db.Products.ToList())
.AddEndpointFilter(async (context, next) =>
{
var logger = context.HttpContext
.RequestServices.GetRequiredService<ILogger<Program>>();
var sw = Stopwatch.StartNew();
var result = await next(context);
sw.Stop();
logger.LogInformation("Endpoint executed in {Elapsed}ms", sw.ElapsedMilliseconds);
return result;
});
여러 필터를 연결할 수 있으며 미들웨어처럼 추가된 순서대로 실행됩니다.
OpenAPI / Swagger 통합
좋은 API 문서화는 더 이상 선택 사항이 아닙니다. 다행히도 Minimal API는 Microsoft.AspNetCore.OpenApi 패키지를 통해 OpenAPI를 최고 수준으로 지원합니다.
기본 설정
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
app.MapGet("/todos", () => new List<Todo>())
.WithName("GetTodos")
.WithDescription("Retrieves all todo items")
.WithTags("Todos");
app.Run();
풍부한 엔드포인트 메타데이터
엔드포인트에 대한 자세한 정보를 제공할 수 있습니다.
app.MapPost("/todos", (Todo todo) =>
{
return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.WithDescription("Creates a new todo item")
.WithTags("Todos")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.ProducesValidationProblem();
사용자 정의를 위해 WithOpenApi 사용
생성된 OpenAPI 문서를 세부적으로 제어하려면 다음을 수행하세요.
app.MapGet("/todos/{id:int}", (int id) => Results.Ok(new Todo(id, "Sample", false)))
.WithOpenApi(operation =>
{
operation.Summary = "Get a specific todo";
operation.Description = "Retrieves a single todo item by its unique identifier.";
operation.Parameters[0].Description = "The unique identifier of the todo item";
return operation;
});
이를 통해 컨트롤러에서 Swashbuckle XML 주석을 사용하여 얻을 수 있는 것과 동일한 수준의 문서 제어 기능을 제공하지만 보다 명시적인 코드 우선 방식을 사용합니다.
인증 및 승인
최소 API 엔드포인트 보안은 나머지 ASP.NET Core와 동일한 패턴을 따르므로 다르게 적용하면 됩니다.
기본 설정```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = “https://your-identity-provider.com”; options.Audience = “your-api”; });
builder.Services.AddAuthorizationBuilder() .AddPolicy(“AdminOnly”, policy => policy.RequireRole(“Admin”)) .AddPolicy(“PremiumUser”, policy => policy.RequireClaim(“subscription”, “premium”));
var app = builder.Build();
app.UseAuthentication(); app.UseAuthorization();
### 엔드포인트에 권한 부여 적용
```csharp
// Require any authenticated user
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
Name = user.FindFirstValue(ClaimTypes.Name),
Email = user.FindFirstValue(ClaimTypes.Email)
});
}).RequireAuthorization();
// Require a specific policy
app.MapDelete("/admin/users/{id}", (int id) =>
{
// Delete user logic
return Results.NoContent();
}).RequireAuthorization("AdminOnly");
// Allow anonymous access
app.MapGet("/public/health", () => Results.Ok(new { Status = "Healthy" }))
.AllowAnonymous();
핸들러 매개변수에 ClaimsPrincipal를 직접 주입하는 방법에 주목하세요. 나머지는 프레임워크가 처리합니다. 이는 Minimal API를 정말 우아하게 느끼게 만드는 작은 요소 중 하나입니다.
대규모 API 정리
최소 API를 사용하는 방의 코끼리는 조직입니다. Program.cs에 엔드포인트가 50개 있으면 엉망이 됩니다. 다음은 작업을 관리하기 쉽게 유지하기 위해 사용하는 패턴입니다.
경로 그룹
경로 그룹(.NET 7에 도입됨)을 사용하면 관련 엔드포인트 간에 구성을 공유할 수 있습니다.
var todos = app.MapGroup("/todos")
.WithTags("Todos")
.RequireAuthorization();
todos.MapGet("/", (AppDbContext db) => db.Todos.ToListAsync());
todos.MapGet("/{id:int}", (int id, AppDbContext db) => db.Todos.FindAsync(id));
todos.MapPost("/", (Todo todo, AppDbContext db) =>
{
db.Todos.Add(todo);
db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
그룹의 모든 엔드포인트는 /todos 접두사, Todos 태그 및 권한 부여 요구 사항을 공유합니다. 깨끗한.
확장 방법
이것은 실제로 확장되는 패턴입니다. 각 엔드포인트 그룹을 자체 정적 클래스로 이동합니다.
// Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
public static RouteGroupBuilder MapTodoEndpoints(this WebApplication app)
{
var group = app.MapGroup("/todos").WithTags("Todos");
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create);
group.MapPut("/{id:int}", Update);
group.MapDelete("/{id:int}", Delete);
return group;
}
private static async Task<IResult> GetAll(AppDbContext db)
=> Results.Ok(await db.Todos.ToListAsync());
private static async Task<IResult> GetById(int id, AppDbContext db)
=> await db.Todos.FindAsync(id) is { } todo
? Results.Ok(todo)
: Results.NotFound();
private static async Task<IResult> Create(Todo todo, AppDbContext db)
{
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
}
private static async Task<IResult> Update(int id, Todo updated, AppDbContext db)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
todo.Title = updated.Title;
todo.IsComplete = updated.IsComplete;
await db.SaveChangesAsync();
return Results.Ok(todo);
}
private static async Task<IResult> Delete(int id, AppDbContext db)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return Results.NoContent();
}
}
// Program.cs — stays clean
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapTodoEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();
app.Run();
Program.cs는 API의 목차가 됩니다. 각 끝점 그룹은 자체 파일에 있습니다. 이것이 제가 프로덕션 애플리케이션에 권장하는 접근 방식입니다.
카터 도서관
더 많은 구조를 원할 경우 Carter 라이브러리는 모듈 기반 접근 방식을 제공합니다.
public class TodoModule : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/todos", async (AppDbContext db) =>
await db.Todos.ToListAsync());
app.MapPost("/todos", async (Todo todo, AppDbContext db) =>
{
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
}
}
Carter는 모든 모듈을 자동으로 검색하고 등록합니다. 원시 최소 API 접근 방식과 전체 컨트롤러 사이의 좋은 중간 지점입니다.
TypedResults 및 응답 유형
.NET 7부터 형식이 안전한 응답을 위해 Results 대신 TypedResults를 사용할 수 있습니다. 이는 작은 변화처럼 보일 수 있지만 OpenAPI 문서화 및 테스트 가능성에 실질적인 이점이 있습니다.
app.MapGet("/todos/{id:int}", async Task<Results<Ok<Todo>, NotFound>> (int id, AppDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is not null
? TypedResults.Ok(todo)
: TypedResults.NotFound();
});
반환 유형 Results<Ok<Todo>, NotFound>는 이 끝점이 생성할 수 있는 응답 유형을 정확히 프레임워크(및 OpenAPI 문서)에 명시적으로 알려줍니다. 더 이상 추측이나 기본 사례에 대한 수동 Produces<>() 호출이 없습니다.
여러 가능한 결과의 경우:
app.MapPost("/todos",
async Task<Results<Created<Todo>, ValidationProblem, Conflict>>
(Todo todo, AppDbContext db) =>
{
if (string.IsNullOrEmpty(todo.Title))
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
{ "Title", new[] { "Title is required" } }
});
if (await db.Todos.AnyAsync(t => t.Title == todo.Title))
return TypedResults.Conflict();
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
나는 모든 새 프로젝트에서 TypedResults를 사용하기 시작했습니다. 컴파일러는 선언된 반환 유형과 실제로 반환하는 것 사이의 불일치를 포착하여 전체 런타임 놀라움을 제거합니다.
성능 고려 사항
Minimal API의 판매 포인트 중 하나는 성능이며, 왜 더 빠른지 이해할 가치가 있습니다.
시작 오버헤드 감소. 컨트롤러는 엔드포인트를 검색하고, 모델을 바인딩하고, 필터를 적용하기 위해 리플렉션에 크게 의존합니다. 최소 API는 소스 생성기(.NET 7부터 시작)를 사용하여 컴파일 타임에 바인딩 코드를 생성합니다. 이는 시작 시 작업량이 적고 요청당 메모리 할당량이 적다는 것을 의미합니다.
MVC 파이프라인 없음. 컨트롤러 기반 API는 전체 MVC 파이프라인(작업 선택, 모델 바인딩, 작업 필터, 결과 실행)을 거칩니다. 최소 API는 이 모든 것을 건너뛰고 라우팅에서 핸들러로 바로 이동합니다.
RequestDelegate 컴파일. 프레임워크는 람다 표현식을 최적화된 RequestDelegate 인스턴스로 컴파일합니다. 결과 코드는 HttpContext로 직접 작업하는 경우 직접 작성하는 코드와 매우 유사합니다.
성능을 극대화하기 위한 몇 가지 실용적인 팁은 다음과 같습니다.
// Use AsNoTracking for read-only queries
app.MapGet("/products", async (AppDbContext db) =>
await db.Products.AsNoTracking().ToListAsync());
// Return results directly — avoid unnecessary allocations
app.MapGet("/count", async (AppDbContext db) =>
await db.Products.CountAsync());
// Use cancellation tokens for long-running operations
app.MapGet("/report", async (AppDbContext db, CancellationToken ct) =>
await db.Orders
.AsNoTracking()
.GroupBy(o => o.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync(ct));
```컨트롤러와 최소 API 간의 성능 격차가 .NET 릴리스마다 계속해서 줄어들고 있다는 점도 언급할 가치가 있습니다. 대부분의 애플리케이션의 경우 병목 현상이 아니라 데이터베이스 쿼리와 외부 서비스 호출의 차이가 있을 것입니다. 벤치마크가 아닌 개발자 경험과 프로젝트 요구 사항을 기준으로 선택하세요.
## 결론
최소 API는 .NET 6에 도입된 이후 많은 발전을 이루었습니다. "hello world" 데모 기능으로 시작된 것이 프로덕션 API를 위한 합법적인 선택으로 발전했습니다. 엔드포인트 필터, 경로 그룹, 입력된 결과 및 견고한 OpenAPI 지원을 통해 잘 구조화되고 유지 관리 가능한 서비스를 구축하는 데 필요한 모든 것을 갖추고 있습니다.
내 추천? 새로운 API 프로젝트, 특히 마이크로서비스나 집중적인 내부 API를 시작하는 경우 Minimal API를 진지하게 시도해 보세요. 조직에 확장 메서드 패턴을 사용하고, 교차 문제에 대해 끝점 필터를 사용하고, 유형 안전성을 위해 `TypedResults`를 활용합니다.
기존 컨트롤러 기반 프로젝트의 경우 마이그레이션을 서두르지 않습니다. 두 접근 방식 모두 잘 작동하며 나란히 사용할 수도 있습니다. 하지만 다음에 소규모 서비스나 빠른 내부 API를 추가해야 할 경우 컨트롤러를 건너뛰고 최소화하세요. 돌아가지 않을 수도 있습니다.
즐거운 코딩하세요!