ASP.NET Core 2.1 · 2부. MVC / Minimal API

Minimal API로 시작하기

가장 적은 코드로 웹 API 를 띄웁니다.

예상 학습 시간 18분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 기초
개념 설명

주소 하나에 함수 하나

1.1에서 본 app.MapGet("/", () => "안녕하세요.") 가 Minimal API 입니다. 주소와 그 주소를 맡을 함수를 곧바로 잇습니다. 클래스를 만들지 않고, 상속받을 것도 없습니다.

이름이 Minimal 인 것은 기능이 적어서가 아니라 적어야 할 것이 적기 때문입니다. 모델 바인딩·JSON 변환·의존성 주입은 그대로 동작합니다. 2.2에서 볼 MVC 와 비교하면 나누어 두는 자리가 없다는 점이 다릅니다.

  • 맞는 자리 — 화면 없이 데이터만 돌려주는 API, 주소가 수십 개를 넘지 않는 규모.
  • 덜 맞는 자리 — 주소가 많아 파일을 나누어야 할 때, 화면(HTML)을 함께 그려야 할 때.

둘 중 하나만 선택해야 하는 것은 아닙니다. 한 프로젝트에 함께 둘 수 있습니다.

최소 예제

할일 API 하나

주소 다섯 개짜리 API 입니다. 데이터베이스 없이 목록 하나로만 다룹니다(3부에서 데이터베이스로 바꿉니다).

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var todos = new List<Todo> {
    new(1, "우유 사기", false),
    new(2, "책 반납", true)
};

app.MapGet("/todos", () => todos);

app.MapGet("/todos/{id:int}", (int id) =>
    todos.FirstOrDefault(t => t.Id == id) is { } found
        ? Results.Ok(found)
        : 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 found = todos.FirstOrDefault(t => t.Id == id);
    if (found is null) return Results.NotFound();
    todos.Remove(found);
    return Results.NoContent();
});

app.Run();

record Todo(int Id, string Title, bool Done);
받은 응답
GET /todos 200 [{"id":1,"title":"우유 사기","done":false},{"id":2,…}] GET /todos/1 200 {"id":1,"title":"우유 사기","done":false} GET /todos/99 404 GET /todos/abc 404 POST /todos 201 Location: /todos/4 DELETE /todos/2 204

이 코드는 서버가 있어야 하므로 브라우저에서 실행할 수 없습니다. 터미널에서 dotnet run 으로 띄운 뒤 아래처럼 불러 보십시오.

curl http://localhost:5227/todos

curl -X POST http://localhost:5227/todos \
  -H "Content-Type: application/json" \
  -d '{"id":4,"title":"세차","done":false}'

/todos/abc400이 아니라 404 인 것이 눈여겨볼 곳입니다. {id:int} 라고 적어 두면 숫자가 아닌 것은 이 주소에 맞지 않는 것으로 봅니다. 함수가 불리지도 않으므로 형식이 틀렸다는 안내를 낼 자리가 없습니다.

상세 사용법

매개 변수는 어디서 오나

함수의 매개 변수를 보고 어디서 가져올지 알아서 정합니다. 따로 적지 않아도 되는 규칙이 있습니다.

매개 변수가져오는 곳
이름이 주소의 자리표와 같음주소 — "/todos/{id:int}"int id
그 밖의 단순 형식질의 문자열 — ?q=우유&page=2
복합 형식(클래스·record)본문(JSON)
등록해 둔 서비스의존성 주입

기본값을 적어 두면 없을 때 그 값이 들어갑니다. 아래는 /search?q=우유 로 부른 것입니다.

Program.cs
app.MapGet("/search", (string? q, int page = 1) => new { q, page });
받은 응답
{"q":"우유","page":1}

돌려준 값이 응답이 됩니다

함수가 돌려준 것을 보고 응답을 만듭니다. 그냥 값을 돌려주면 200과 JSON 이고, 상태 코드를 정하려면 Results 를 사용합니다.

돌려준 것응답
값(객체·목록)200 · JSON
문자열200 · text/plain
Results.Ok(값)200 · JSON
Results.NotFound()404 · 본문 없음
Results.Created(주소, 값)201 · Location 머리글 · JSON
Results.NoContent()204 · 본문 없음

Results 대신 TypedResults 를 사용하면 돌려주는 형식이 그대로 드러납니다. 테스트 코드에서 상태 코드를 형식으로 확인할 수 있고, 문서(OpenAPI)도 정확해집니다.

// 어느 쪽도 동작은 같습니다
Results.Ok(found)                 // 반환 형식은 IResult
TypedResults.Ok(found)            // 반환 형식은 Ok<Todo>
상세 사용법

JSON 으로 바뀔 때의 규칙

C# 의 속성 이름은 Title 인데 응답에는 title 로 나갔습니다. ASP.NET Core 가 첫 글자를 소문자로 바꾸어 내보내기 때문입니다. 받을 때는 반대로 대소문자를 가리지 않습니다.

한글도 그대로 나갑니다. 그런데 아무 설정 없이 직렬화하면 그렇지 않습니다. 아래를 실행해 두 줄을 비교해보십시오.

Program.cs
using System.Text.Json;
using System.Text.Encodings.Web;
using System.Text.Unicode;

var todo = new Todo(1, "우유 사기", false);

// 아무것도 정하지 않았을 때
Console.WriteLine(JsonSerializer.Serialize(todo));

// ASP.NET Core 가 사용하는 것과 같게 맞추었을 때
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web) {
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
};
Console.WriteLine(JsonSerializer.Serialize(todo, web));

record Todo(int Id, string Title, bool Done);
출력
{"Id":1,"Title":"\uC6B0\uC720 \uC0AC\uAE30","Done":false} {"id":1,"title":"우유 사기","done":false}

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

둘째 줄이 서버가 실제로 내보내는 것과 같습니다. 첫째 줄의 \uC6B0\uC720잘못된 JSON 이 아닙니다. 읽는 쪽이 되돌려 읽으므로 담긴 값은 같습니다. 다만 사람이 눈으로 볼 때 읽히지 않아, ASP.NET Core 는 넓은 범위를 그대로 내보내도록 맞춰 두었습니다.

버전 배지

언제부터 있었나

.NET 6 부터

최상위 문과 함께 들어왔습니다. 그 전에는 주소 하나를 잇는 데에도 컨트롤러 클래스가 있어야 했습니다.

이후로도 채워졌습니다. .NET 7에서 필터와 그룹이 들어와 공통 처리를 묶을 수 있게 되었고, .NET 8부터는 미리 컴파일(Native AOT)이 가능해 시작이 빨라졌습니다. dotnet new webapiaot 가 그것입니다.

실습 문제

직접 해보기

1. 고치는 주소 추가하기 난이도 하

MapPut 으로 할일 하나를 통째로 바꾸는 주소를 추가해보세요. 없는 번호면 404, 바꿨으면 204를 돌려줍니다.

주소에서 번호를, 본문에서 바꿀 내용을 받습니다. 매개 변수를 둘 적으면 각각 다른 곳에서 옵니다.
app.MapPut("/todos/{id:int}", (int id, Todo todo) => { var at = todos.FindIndex(t => t.Id == id); if (at < 0) return Results.NotFound(); todos[at] = todo with { Id = id }; return Results.NoContent(); });
2. 끝난 것만 걸러 내기 난이도 중

/todos?done=true 처럼 불렀을 때 그 값만 돌려주고, 아무것도 붙이지 않으면 전부 돌려주도록 고쳐보세요.

주소의 자리표에 없는 단순 형식은 질의 문자열에서 옵니다. 없을 수도 있으니 bool? 로 받습니다.
app.MapGet("/todos", (bool? done) => done is null ? todos : todos.Where(t => t.Done == done));
3. 두 가지 404 구분하기 난이도 상

/todos/99/todos/abc 는 둘 다 404 입니다. 그런데 같은 404가 아닙니다. 무엇이 다른지, 그리고 부르는 쪽에서 어떻게 구분해 줄 수 있을지 적어보세요.

하나는 우리가 적은 함수가 돌려준 것이고, 다른 하나는 함수까지 오지도 못한 것입니다.
// /todos/99 — 함수가 불렸고, 그 안에서 Results.NotFound() 를 돌려줬습니다. // /todos/abc — {id:int} 에 맞지 않아 이 주소로 오지 못했습니다. // 함수는 불리지 않았습니다. // 구분해 주려면 제약을 떼고 직접 봅니다. app.MapGet("/todos/{id}", (string id) => int.TryParse(id, out var n) ? (todos.FirstOrDefault(t => t.Id == n) is { } f ? Results.Ok(f) : Results.NotFound()) : Results.BadRequest("번호는 숫자여야 합니다."));
요약
  • MapGet·MapPost주소와 함수를 곧바로 잇습니다. 클래스가 없습니다.
  • 매개 변수는 이름과 형식을 보고 주소·질의 문자열·본문·서비스 가운데서 옵니다.
  • 값을 돌려주면 200과 JSON 이고, 상태 코드를 정하려면 Results·TypedResults 를 사용합니다.
  • 속성 이름은 첫 글자가 소문자로 바뀌어 나가고, 받을 때는 대소문자를 가리지 않습니다.
  • 주소 제약({id:int})에 맞지 않으면 함수가 불리지도 않고 404 입니다.