C# Minimal API: 엔드포인트를 깨끗하게 유지하는 실용적인 방법

발행: (2025년 12월 22일 오후 11:09 GMT+9)
9 min read
원문: Dev.to

Source: Dev.to

Minimal APIs make it tempting to write everything inline, but this quickly becomes unmaintainable.
When endpoints handle validation, business logic, error handling, and response formatting all in one place, they become difficult to test and reuse. The solution is to extract business logic into dedicated handlers, leaving endpoints responsible only for routing.

엔드포인트를 깔끔하게 유지하는 실용적인 방법

Minimal API 엔드포인트를 깔끔하고 유지보수 가능하게 만들려면 비즈니스 로직을 엔드포인트 자체가 아니라 별도의 전용 핸들러 클래스에 구현해야 합니다.

엔드포인트는 다음만 담당해야 합니다:

  • 입력 수신
  • 핸들러에 작업 위임
  • HTTP 응답 반환

기본 핸들러 예시

public record WeatherForecastPayload(string Location, int Days);

public class WeatherForecastRequestHandler
{
    public Task HandleAsync(
        WeatherForecastPayload request,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool",
            "Mild", "Warm", "Balmy", "Hot",
            "Sweltering", "Scorching"
        });
    }
}

핸들러를 사용하는 엔드포인트

app.MapPost("/create-forecast",
    async (
        [FromBody] WeatherForecastPayload payload,
        WeatherForecastRequestHandler handler,
        CancellationToken cancellationToken) =>
    {
        var result = await handler.HandleAsync(payload, cancellationToken);

        if (result is not null)
        {
            return Results.Ok(result);
        }

        return Results.BadRequest();
    });

이렇게 하면 동작하지만, 응답 로직이 복잡해지면 엔드포인트가 커지고 가독성이 떨어지기 시작합니다.

통합 핸들러 추상화 소개

대부분의 핸들러:

  • 요청을 수신한다
  • 응답을 반환한다

공통 인터페이스와 통합 응답 모델을 사용해 이를 형식화할 수 있습니다.

핸들러 인터페이스

public interface IHttpRequestHandler<TRequest, TResponse>
{
    Task<TResponse> HandleAsync(
        TRequest request,
        CancellationToken cancellationToken);
}

통합 응답 모델

public class DataResponse<T>
{
    public T? Data { get; init; }
    public IEnumerable<string> Errors { get; init; } = [];
}

public class HttpDataResponse<T> : DataResponse<T>
{
    [JsonIgnore]
    public HttpStatusCode StatusCode { get; init; }
}

HttpStatusCode는 내부에서 사용되며 API 응답 본문에 노출되지 않습니다; 그래서 HttpDataResponseDataResponse를 상속합니다.

핸들러 기반 응답으로 더 깔끔한 엔드포인트

app.MapPost("/create-forecast",
    async (
        [FromBody] WeatherForecastPayload payload,
        IHttpRequestHandler<WeatherForecastPayload, HttpDataResponse<string[]>> handler,
        CancellationToken cancellationToken) =>
    {
        var response = await handler.HandleAsync(payload, cancellationToken);
        return Results.Json(
            response,
            statusCode: (int)response.StatusCode);
    });

이제 엔드포인트가 더 깔끔해졌지만, 확장 메서드를 사용해 이를 더 진행할 수 있습니다.

확장 메서드로 간소화

public static class HandlerExtensions
{
    public static async Task<IResult> SendAsync<TRequest, TResponse>(
        this IHttpRequestHandler<TRequest, HttpDataResponse<TResponse>> handler,
        TRequest request,
        CancellationToken cancellationToken)
    {
        var response = await handler.HandleAsync(request, cancellationToken);

        return Results.Json(
            response,
            statusCode: (int)response.StatusCode);
    }
}

이 확장 메서드를 사용하면 엔드포인트 로직이 단일 메서드 호출로 축소됩니다. 엔드포인트는 더 이상 응답 구성을 처리하지 않고, 단순히 실행을 위임합니다.

목표 패턴: 사소한 엔드포인트

app.MapPost("/create-forecast",
    async (
        [FromBody] WeatherForecastPayload payload,
        IHttpRequestHandler<WeatherForecastPayload, HttpDataResponse<string[]>> handler,
        CancellationToken cancellationToken)
        => await handler.SendAsync(payload, cancellationToken));

이것이 우리의 목표 패턴 – 엔드포인트가 사소한 한 줄 코드가 됩니다. 이 시점에서 엔드포인트는 비즈니스 로직이나 응답 로직을 전혀 포함하지 않습니다. 하지만 이 구현에서는 IHttpRequestHandler가 항상 요청 타입과 응답 타입을 모두 기대하기 때문에 다음과 같은 경우를 처리해야 합니다:

입력/페이로드가 없는 요청

요청 본문이 없는 엔드포인트의 경우, 빈 레코드와 같은 간단한 마커 타입을 사용할 수 있습니다. 예:

public sealed record EmptyRequest;

빈 응답

동일한 접근 방식을 응답에도 적용할 수 있으며, 이를 통해 SendAsync 확장 메서드가 적절한 HTTP 상태(NoContent, Ok 등)를 결정하도록 할 수 있습니다.

The Trade‑off: HTTP‑Aware Handlers

HttpDataResponse를 사용하면 핸들러가 HTTP‑aware가 됩니다 – 즉 HttpStatusCode를 직접 반환합니다. 이는 핸들러 계층을 HTTP 전송 계층에 결합시킵니다.

많은 애플리케이션에서 이는 실용적이고 충분합니다. 핸들러 계층이 바로 여러분의 HTTP 경계이기 때문입니다. 다음 사항만 지키면 됩니다:

  • IHttpRequestHandler 구현체만 HttpDataResponse를 반환하도록 합니다
  • 하위 계층(도메인 서비스, 비즈니스 로직)은 전송에 의존하지 않도록 유지합니다

이 결합이 걱정된다면, 대안적인 접근 방식이 있습니다.

Alternative: Transport‑Agnostic Handlers

If you need stricter separation (e.g., sharing handlers across gRPC, message queues, REST), you can use transport‑agnostic status codes that get mapped to HTTP status codes at the API layer. This keeps the core handler logic completely independent of any transport concerns.

(Further implementation details omitted for brevity.)

통합 응답 모델 (전송‑비종속)

public enum HandlerStatusCode
{
    Success = 0,
    SuccessWithEmptyResult = 1,
    ValidationError = 2,
    InternalError = 4
}

public class HandlerResponse<T> : DataResponse<T>
{
    [JsonIgnore]
    public HandlerStatusCode StatusCode { get; init; }
}

엄격한 핸들러 인터페이스

IHttpRequestHandler는 이제 HTTP 상태 대신 자체 상태 유형을 가지게 되었기 때문에 **IStatusRequestHandler**로 이름이 변경되었습니다.

public interface IStatusRequestHandler<TRequest, TResponse>
{
    Task<HandlerResponse<TResponse>> HandleAsync(
        TRequest request,
        CancellationToken cancellationToken);
}

확장 메서드에서 상태 코드를 매핑하기

public static class HandlerExtensions
{
    /// <summary>
    /// 요청 핸들러를 실행하고 응답을 적절한 HTTP 결과로 매핑합니다.
    /// </summary>
    public static async Task<IResult> SendAsync<TRequest, TResponse>(
        this IStatusRequestHandler<TRequest, TResponse> requestHandler,
        TRequest request,
        CancellationToken cancellationToken)
    {
        var response = await requestHandler.HandleAsync(request, cancellationToken);

        return response.StatusCode switch
        {
            HandlerStatusCode.SuccessWithEmptyResult => Results.NoContent(),
            HandlerStatusCode.Success => Results.Json(response, statusCode: (int)HttpStatusCode.OK),
            HandlerStatusCode.ValidationError => Results.Json(response, statusCode: (int)HttpStatusCode.BadRequest),
            HandlerStatusCode.InternalError => Results.Json(response, statusCode: (int)HttpStatusCode.InternalServerError),
            _ => throw new InvalidOperationException($"Unknown HandlerStatusCode: {response.StatusCode}"),
        };
    }
}

When to use this approach

  • 여러 전송 레이어(REST, gRPC, 메시지 큐)를 구축하고 있을 때
  • 레이어 간에 엄격한 아키텍처 경계가 있을 때
  • 핸들러를 HTTP와 완전히 분리하고 싶을 때

솔루션 예시

  • HTTP‑인식 핸들러 사용 – HTTP 응답 코드는 핸들러 레이어에서 직접 처리됩니다.
  • 전송‑비종속 핸들러 사용 – 응답 모델은 HTTP와 독립적입니다.

결론

이 패턴은 Minimal API 엔드포인트를 다중 라인 라우팅 함수에서 일관되고 테스트 가능하며 유지 관리가 쉬운 단일 라인 선언으로 변환합니다.

핵심 통찰: 엔드포인트는 구현이 아니라 라우팅을 담당해야 합니다. IHttpRequestHandler/IStatusRequestHandler와 해당 응답 모델을 표준화함으로써 다음을 얻을 수 있습니다:

  • 한 줄에 들어갈 수 있는 사소한 엔드포인트
  • 테스트 가능한 핸들러에 격리된 비즈니스 로직
  • 전체 API에 걸친 일관된 오류 처리 및 응답 포맷

트레이드‑오프: 핸들러가 HTTP‑인식이 됩니다. 대부분의 애플리케이션에서는 핸들러가 HTTP 경계에 위치하기 때문에 이것이 올바른 선택입니다. 전송 방식에 구애받지 않는 핸들러가 필요하다면(gRPC, 메시지 큐 등) 위에서 보여준 HandlerStatusCode 접근 방식을 사용하십시오.

Back to Blog

관련 글

더 보기 »

C# Minimal API: 출력 캐싱

Minimal API: Output Caching은 생성된 응답을 서버에 저장하고 엔드포인트를 다시 실행하지 않고 직접 제공합니다. Microsoft Docs https://learn.m...