.NET Web API 개발 해보기

첫 slog 입니다.

요즘 다시 윈폼 개발 중인데 저번 프로젝트 때 경험했던 백엔드를 안하니 기억 저편으로 사라지고 있네요.

백엔드로 포지션 변경도 생각하고 있기에 경험했던 내용을 정리 및 공유 차원에서 작성해 봅니다.

개발 환경

  • .NET8 (.NET6로만 했다가 8은 처음이네요.)
  • VS2022 community
  • Windows 10 Pro 22H2

controller 구성부터 해서 차근차근 작성해 보겠습니다.

아직 경험이 많이 부족해서 내용이 부실할 수도 있는데, 지나가시다가 첨언 부탁드립니다. :innocent:

우선, 저녁 먹고 운동 갔다가 이어서 작성해야겠네요.

좋은 저녁 되세요.

16 Likes

@chanos-dev 우와 기대됩니다! 응원 많이 하고 있겠습니다.

4 Likes

controller와 minimal api를 통해 http 요청을 처리할 수 있는데, 그 중에서 controller를 사용하는 방법을 알아보겠습니다.

API Controller 구성

WebAPI 프로젝트를 생성할 때 컨트롤러 사용을 체크하면 기본 템플릿으로 Controllers/WeatherForecastController.cs를 제공합니다.

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    ...
}

API Controller를 구성하기 위해서는 ControllerBase 클래스를 상속 받아야되고 Route, ApiController attribute를 추가해야 됩니다.

ControllerBase는 http 요청을 처리하기 위한 메서드와 속성을 제공합니다.

MVC의 Controller에서는 Controller 클래스를 상속하고 있지만 내부 구현을 보면 Controller 클래스도 결국 ControllerBase를 상속 받고 있습니다.

Controller 클래스는 MVC에서 View에 대한 처리를 지원하므로 API Controller에는 적합하지 않은 클래스입니다.

public abstract class Controller : ControllerBase, IActionFilter, IAsyncActionFilter, IDisposable
{
    ...
}

Route는 http 요청을 위한 API endpoint를 지정할 수 있습니다.

생성자에 string으로 endpoint를 지정할 수 있으며 프레임워크에서 기본적으로 제공하는 예약어가 존재합니다.

  • controller

    • Controller 클래스 명칭에서 접미사가 Controller로 끝난다면 제거 후 매핑됩니다.
  • action

    • action method의 명칭이 매핑됩니다.
      • action method → Controller에 정의된 http 메서드
  • 등등…

호스트 → http://localhost:5000

[ApiController]
[Route("weather")]
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    public ActionResult Get() => Ok("hello, world");
}

GET http://localhost:5000/weather
[ApiController]
[Route("[controller]")] // -> `WeatherForecastController` -> WeatherForecast 매핑
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    public ActionResult Get() => Ok("hello, world");
}

GET http://localhost:5000/WeatherForecast
[ApiController]
[Route("api/[controller]/[action]")]
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    public ActionResult GetTest() => Ok("hello, world"); // action method 명칭 매핑
}

GET http://localhost:5000/api/WeatherForecast/GetTest

ApiController는 꼭 필요한 attribute는 아닙니다. 해당 attribute를 지우고 실행해도 정상적으로 http 요청을 진행할 수 있습니다.

그렇다면 해당 attribute를 추가하고 안하고는 어떤 차이가 있는 걸까요?

대표적으로 모델 바인딩 검사가 있습니다.

[ApiController]
[Route("api/[controller]")]
public class WeatherForecastController : Controller
{
    ...

    [HttpPost]
    public ActionResult Post(Foo foo)
    {
        return Ok(foo.Name);
    }
}

// [Required] data annotation 추가
public record Foo([Required] string Name);

POST http://localhost:5000/api/WeatherForecast
{
  "name" : ""
}
// response
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": [
      "The Name field is required."
    ]
  },
  "traceId": "00-76a3898ce6118c1a4e186c2f3798fd4c-59a1cdcd7e5e6692-00"
}

위와 같이 POST method를 정의 후 Name을 빈 값으로 요청하게 되면 자동적으로 400에러를 반환합니다.

따로 사용자가 추가 로직을 작성하지 않았지만 ApiController를 추가하면서 모델 바인딩을 검사하는 middleware가 controller 앞단에 추가 됐기 때문입니다.

그렇다면 ApiController를 추가하지 않은 경우 모델 바인딩 검사는 어떻게 진행할까요?

// [ApiController] 제거!
[Route("api/[controller]")]
public class WeatherForecastController : Controller
{
    ...

    [HttpPost]
    public ActionResult Post([FromBody] Foo foo)
    {
        if (!ModelState.IsValid) 
            return BadRequest(ModelState);

        return Ok(foo.Name);
    }
}

// [Required] data annotation 추가
public record Foo([Required] string Name);

POST http://localhost:5000/api/WeatherForecast
{
  "name" : ""
}
// response
{
  "Name": [
    "The Name field is required."
  ]
}

위 예제에서는 Post 메서드 매개변수에 [FromBody]가 추가 됐습니다.
[ApiController]가 없는 경우 데이터를 body로 받기 위해서는 직접적으로 매개변수 바인딩을 명시를 해야합니다.

ApiController가 없는 경우 모델 바인딩을 검사하는 middleware가 제거 되면서 사용자가 직접 ModelState.IsValid를 통해 해당 모델 바인딩 검사를 진행해야 됩니다.

ApiController attribute를 추가하면 여러가지 기능을 제공하기 때문에 추가하는 것이 좋습니다.

  • 모델 바인딩 검사
  • Route attribute 필수 검사
  • binding source 매개변수 유추
  • 등등…

다음은 Controller에 HTTP Method를 구현하는 방법을 알아보겠습니다.

9 Likes

와우 응원합니다.

5 Likes

자세한 내용 좋습니다~~
MS 문서에는 기계적 번역이라 중요사항이 간과되어 있는데 이렇게 ControllerBase 상속 이유와 ApiController 속성 사용 이유를 자세히 결과물로 소개해주시니 좋네요.

7 Likes

Action 매핑 몰라서 Get 메소드 Route명칭이 뭐지 하면서 겁나 찾았던 기억이…

4 Likes

http 요청을 처리할 때 클라이언트와 서버는 처리에 대한 동작을 지정할 수 있습니다.

대표적으로 자원에 대한 조회, 생성, 수정, 삭제를 위한 GET, POST, PUT, PATCH, DELETE 등이 있고, controller에서 이를 처리하기 위한 방법을 알아보겠습니다.

프레임워크에서는 http 요청을 처리하기 위해 여러가지 attribute를 제공하고 있습니다.

HTTP Method 처리를 위한 Attribute

  • [HttpGet]
  • [HttpPost]
  • [HttpPut]
  • [HttpPatch]
  • [HttpDelete]
  • 등등…

HTTP 요청 매개변수 바인딩

  • [FromQuery]
  • [FromRoute]
  • [FromBody]
  • [FromForm]
  • [FromHeader]

controller에서는 위 attribute를 사용하여 요청을 처리할 수 있습니다.

[ApiController]
[Route("api/[controller]")]
public class FooController : Controller
{
    public ActionResult Test(string str)
    {
        return Ok($"response - {str}");
    }
}

하지만 attribute가 없어도 http 요청에 대한 처리를 할 수 있습니다. 실행을 하게 되면 swagger docs에서 에러가 나지만 http 요청을 진행하면 정상적으로 처리되는 모습을 볼 수 있습니다.

GET http://localhost:5000/api/Foo?str=test
// response
response - test

POST http://localhost:5000/api/Foo?str=test
// response
response - test

... 모든 HTTP method에 대해 처리

기본적으로 public 메서드가 존재한다면 모든 요청에 대해 라우팅이 되지만 public 메서드가 여러 개라면 AmbiguousMatchException 예외가 발생하면서 요청을 처리할 수 없게 됩니다.

[ApiController]
[Route("api/[controller]")]
public class FooController : Controller
{
    public ActionResult Test(string str)
    {
        return Ok($"response - {str}");
    }

    public ActionResult Test2(string str)
    {
        return Ok($"response2 - {str}");
    }
}

GET http://localhost:5000/api/Foo?str=test
// response - 500 error
Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException: The request matched multiple endpoints. Matches: 

WebAPITutorial.Controllers.FooController.Test (WebAPITutorial)
WebAPITutorial.Controllers.FooController.Test2 (WebAPITutorial)
   at Microsoft.AspNetCore.Routing.Matching.DefaultEndpointSelector.ReportAmbiguity(Span`1 candidateState)
   at Microsoft.AspNetCore.Routing.Matching.DefaultEndpointSelector.ProcessFinalCandidates(HttpContext httpContext, Span`1 candidateState)
   at Microsoft.AspNetCore.Routing.Matching.DefaultEndpointSelector.Select(HttpContext httpContext, Span`1 candidateState)
   at Microsoft.AspNetCore.Routing.Matching.DfaMatcher.MatchAsync(HttpContext httpContext)
   at Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware.Invoke(HttpContext httpContext)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

HEADERS
=======
Accept: application/xml
Connection: keep-alive
Host: localhost:5000
User-Agent: PostmanRuntime/7.29.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Content-Length: 25
Postman-Token: 748a997b-84fe-47a7-a6cd-adf5596a61b9

위와 같은 상황을 방지하고, 적절한 http 요청을 처리하기 위해 HTTP Method attribute를 메서드 별로 정의해야 합니다.

HttpGet - 자원 조회

[ApiController]
[Route("api/[controller]")]
public class FooController : Controller
{
    private readonly static List<Student> _students =
    [
        new ("Json", 15),
        new ("Mike", 21),
    ];

    [HttpGet]
    public ActionResult Get()
    {
        return Ok(_students);
    }
}

public record Student(string Name, int Age);

GET http://localhost:5000/api/Foo
// response
[
    {
        "name": "Json",
        "age": 15
    },
    {
        "name": "Mike",
        "age": 21
    }
]

POST http://localhost:5000/api/Foo
// response
405 - Method Not Allowed

[HttpGet]을 통해 해당 endpoint로 GET 요청이 들어왔을 때 처리를 진행할 수 있습니다. 정의되지 않은 HTTP method로 요청이 온다면 controller는 405 - Method Not Allowed를 반환하게 됩니다.

action method에는 따로 endpoint를 지정할 수 있습니다. [HttpGet]을 이용하거나, [Route]를 이용할 수 있습니다.

[HttpGet("test1")]
public ActionResult Get()
{
    return Ok(_students);
}

GET http://localhost:5000/api/Foo/test1
[HttpGet("test1")]
[HttpGet("test2")]
public ActionResult Get()
{
    return Ok(_students);
}

GET http://localhost:5000/api/Foo/test1
GET http://localhost:5000/api/Foo/test2
[HttpGet]
[Route("test1")]
public ActionResult Get()
{
    return Ok(_students);
}

GET http://localhost:5000/api/Foo/test1
[HttpGet]
[Route("test1")]
[Route("test2")]
public ActionResult Get()
{
    return Ok(_students);
}

GET http://localhost:5000/api/Foo/test1
GET http://localhost:5000/api/Foo/test2

HttpGet과 Route을 이용하여 endpoint를 지정하면 swagger docs에서 에러가 발생하지만 정상적으로 요청은 가능합니다.

Path Parameter, Query string

보통 GET 요청을 진행할 때 Path parameter와 Query string을 포함하여 요청을 진행하는 경우가 있습니다.

Path parameter

action method에서 Path parameter를 받기 위해서는 [Http{Verb}] attribute의 생성자에 template를 이용하여 지정할 수 있습니다. (Route도 가능합니다.)

[HttpGet("{idx}")]
public ActionResult Get(int idx)
{
    return Ok(_students[idx]);
}

GET http://localhost:5000/api/Foo/1
// response
{
  "name": "Mike",
  "age": 21
}

GET http://localhost:5000/api/Foo/a
// response
{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "errors": {
        "id": [
            "The value 'a' is not valid."
        ]
    },
    "traceId": "00-2f35d9d35192de9991b673a2f88ea276-11f7f0af47f8ed7c-00"
}

template{매개변수 명}를 이용하여 path parameter를 지정할 수 있고, int가 아닌 값이 들어온다면 모델 바인딩 검사로 인해 400 에러가 반환 됩니다.

만약 template에 타입을 명시한다면 404 not found를 반환할 수 있습니다.

[HttpGet("{idx:int}")]
public ActionResult Get(int idx)
{
    return Ok(_students[idx]);
}

GET http://localhost:5000/api/Foo/1
// response
{
  "name": "Mike",
  "age": 21
}

GET http://localhost:5000/api/Foo/a
// response
404 Not Found
Query string

http 요청에서 http://localhost:5000/api/Foo?name=Mike와 같이 ? 뒤에 인자를 받기 위해서는 action method에 매개변수로 추가만 하면 됩니다.

[HttpGet]
public ActionResult Get(string name)
{
    return Ok(_students.FirstOrDefault(s => s.Name == name));
}

GET https://localhost:5000/api/Foo?name=Mike
// response
{
    "name": "Mike",
    "age": 21
}

GET https://localhost:5000/api/Foo?name2=Mike
// response
{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "errors": {
        "name": [
            "The name field is required."
        ]
    },
    "traceId": "00-8c40f209924f043b4b7c60b3b9d1bfee-6c403580a33fbbc2-00"
}

Query string에 담긴 key가 매개변수 명칭에 매핑이 됩니다. 매개변수에 정의한 key가 Query string에 없는 경우 required로 설정되어 있기 때문에 모델 바인딩 에러가 발생합니다.

이 경우는 매개변수를 nullable로 처리하거나, 기본 값을 지정하여 optional로 설정할 수 있습니다.

[HttpGet]
public ActionResult Get(string? name)
{
    return Ok(_students.FirstOrDefault(s => s.Name == name));
}
// response
204 - No Content

[HttpGet]
public ActionResult Get(string name = "Json")
{
    return Ok(_students.FirstOrDefault(s => s.Name == name));
}
GET https://localhost:5000/api/Foo?name2=Mike
// response
{
    "name": "Json",
    "age": 15
}

action method의 매개변수는 [ApiController]를 통해 매개변수 바인딩이 자동으로 이루어지게 됩니다.
기본적으로 아무런 바인딩을 명시하지 않은 경우, int, double과 같은 타입은 [FromQuery]가 기본으로 설정됩니다.

path parameter 또한 template에서 정의한 명칭으로 [FromRoute]로 자동적으로 바인딩이 되게 되는데, 바인딩 같은 경우는 명시를 해주는 것이 유지보수성에 좋다고 생각합니다.

[HttpGet("{id:int}")]
public ActionResult Get([FromRoute] int id, [FromQuery] string filter)
{
    ...
}

내용이 많아 다음 HTTP method (POST, DELETE …)를 이어서 작성하겠습니다.

6 Likes

HttpGet과 마찬가지로 다른 http method에서도 path parameter, query string을 받아올 수 있습니다.
그 외에도 request body도 전달 받을 수 있는데요, 요청 방법을 살펴 보겠습니다.

HttpPost - 자원 생성 / 요청

사실 Post 요청하는 방법까지만 알면 나머지 put, delete 등은 다 비슷비슷 합니다.

json 요청 처리 - application/json

자원에 대한 생성 및 처리 요청을 진행할 때 데이터를 application/json 형식으로 많이 전달하게 됩니다.

json 형식은 key/value 형식으로 객체와도 직렬화를 통해 매핑이 될 수 있는데요, Post 메서드에서 사용자가 정의한 클래스나 구조체를 파라메터로 지정하면 application/json으로 요청을 받을 수 있습니다. 물론 [ApiController]에 의해 [FromBody]가 생략이 될 수 있습니다.

[ApiController]
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    private readonly static List<Boo> _boos =
    [
        new() { Id = 1, Name = "boo1", Type = "boo type1" },
        new() { Id = 2, Name = "boo2", Type = "boo type2" },
    ];

    [HttpPost]
    public ActionResult Post(/*[FromBody]*/ CreateBoo createBoo)
    {
        Boo boo = new()
        {
            Id = _boos.Count + 1,
            Name = createBoo.Name,
            Type = createBoo.Type,
        };

        _boos.Add(boo);

        return Ok(_boos);
    }
}

public class CreateBoo
{
    public string Name { get; set; }
    public string Type { get; set; }
}

public class Boo
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Type { get; set; }
}

---
POST http://localhost:5000/api/Foo
{
  "name": "test1",
  "type": "test2"
}
//response
[
  {
    "id": 1,
    "name": "boo1",
    "type": "boo type1"
  },
  {
    "id": 2,
    "name": "boo2",
    "type": "boo type2"
  },
  {
    "id": 3,
    "name": "test1",
    "type": "test2"
  }
]

특정 자원 작업 요청

[ApiController]
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    ...

    // path parameter 사용
    [HttpPost("{id:int}/task")]
    public ActionResult Post([FromRoute] int id, [FromBody] Task task)
    {
        // do somethings..
        return Ok();
    }
}

public class Task
{
    public string Work { get; set; }
    public DateTime Time { get; set; }
}

...

---
POST https://localhost:5000/api/Foo/1/task
{
  "work": "hash",
  "time": "2024-03-12T13:16:54.773Z"
}

[FromBody] 또한 모델 바인딩 검사를 진행하기 때문에 역직렬화 할 때 형식이 일치하지 않으면 400 Bad Request가 반환 됩니다.

POST https://localhost:5000/api/Foo/1/task
{
  "work": 824,
  "time": "2024-03-12T13:16:54.773Z"
}
// response
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "task": [
      "The task field is required."
    ],
    "$.work": [
      "The JSON value could not be converted to System.String. Path: $.work | LineNumber: 1 | BytePositionInLine: 13."
    ]
  },
  "traceId": "00-ac6160c9c1e18b795ebb702d59a8ca7d-1982a11b78967d5c-00"
}

파일 요청

커뮤니티와 같은 사이트는 이미지 파일을 전달 받아야 할 때가 있는데, 이 때는 IFormFile 또는 IFormFileCollection 인터페이스를 이용하여 파일을 전달 받을 수 있습니다.

두 인터페이스는 multipart/form-data 요청 타입으로 [FromForm]으로 바인딩이 진행 됩니다.

[ApiController]
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    [HttpPost]
    public async Task<ActionResult> Post(/*[FromForm]*/ IFormFile file)
    {
        const string STORAGE_PATH = "/storage";

        if (!Directory.Exists(STORAGE_PATH))
            Directory.CreateDirectory(STORAGE_PATH);

        string filePath = Path.Combine(STORAGE_PATH, file.FileName);

        using FileStream stream = System.IO.File.Create(filePath);
        await file.CopyToAsync(stream);

        return Ok($"{file.FileName} / {file.Length}");
    }
}

image

IFormFileCollection 사용

IFormFileCollection은 파일을 가변적으로 받을 때 사용할 수 있습니다.

[HttpPost]
[RequestSizeLimit(FILE_SIZE)]
public async Task<ActionResult> Post(IFormFileCollection files)
{
    foreach (IFormFile file in files) 
    { 
        // do somethings..
    }

    return Ok();
}

image


기본적으로 controller에 요청할 때 파일 전송 시 파일의 사이즈가 기본 값으로 설정되어 있습니다. 이보다 큰 경우 400 Bad Request를 반환하게 됩니다.
*kestrel 요청 본문 크기

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "": [
      "Failed to read the request form. Request body too large. The max request body size is 30000000 bytes."
    ]
  },
  "traceId": "00-d932b0eec7a5757cc9cb73afae8f8c40-77fb87d2835175fe-00"
}

이 때는 [RequestSizeLimit] attribute를 이용해 파일 요청 시 사이즈를 늘릴 수 있습니다.

[ApiController]
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    // 150MB
    private const long FILE_SIZE = 150 * 1024 * 1024;

    [HttpPost]
    [RequestSizeLimit(FILE_SIZE)]
    public async Task<ActionResult> Post(IFormFile file)
    ...
}

x-www-form-urlencoded

추가적으로 HTML 태그에서 form 태그를 받기 위해 종종 사용되는 형식으로 이 요청 또한 [FromForm]을 이용하여 처리할 수 있습니다.

[ApiController]
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    [HttpPost]
    public async Task<ActionResult> Post([FromForm] string name, [FromForm] string type)
    {            
        return Ok($"{name} / {type}");
    }
}

image

HttpPut, Delete … - 자원 수정/삭제

그 외 HTTP method 처리는 Get 또는 Post 처리를 진행할 때 배웠던 바인딩 항목들로 충분히 처리가 가능하고 다른 내용은 내부 처리 로직만 다를 것 입니다.

[ApiController]
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    private readonly static List<Boo> _boos =
    [
        new() { Id = 1, Name = "boo1", Type = "boo type1", Deleted = false },
        new() { Id = 2, Name = "boo2", Type = "boo type2", Deleted = false },
    ];

    [HttpGet]
    public ActionResult Get() => Ok(_boos);

    [HttpDelete("{id:int}")]
    public ActionResult Delete([FromRoute] int id)
    {
        Boo? boo = _boos.FirstOrDefault(x => x.Id == id);

        if (boo is null)
            return NotFound();

        boo.Deleted = true;

        return Ok(boo);
    }

    [HttpPut("{id:int}")]
    public ActionResult Put([FromRoute] int id, [FromBody] UpdateBoo updateBoo)
    {
        Boo? boo = _boos.FirstOrDefault(x => x.Id == id);

        if (boo is null)
            return NotFound();

        if (boo.Name != updateBoo.Name)
            boo.Name = updateBoo.Name;

        if (boo.Type != updateBoo.Type)
            boo.Type = updateBoo.Type;

        return Ok(boo);
    }
}

public class UpdateBoo
{        
    public string Name { get; set; }
    public string Type { get; set; }
}

public class Boo
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Type { get; set; }
    public bool Deleted { get; set; } 
}

Put, Patch

자원을 업데이트하는 방법에는 Put과 Patch가 있는데 차이점은 자원의 정보를 전체적으로 업데이트하냐, 부분적으로 업데이트하냐의 차이가 있습니다.

보통 Put을 이용해 자원을 업데이트를 진행하는데 API 규약에 따라 특정 필드만 업데이트를 진행해야 하는 Patch를 구현해야 하는경우 구현 방법이 다양하게 존재합니다.

그 중 하나인 Json Patch가 있는데, 해당 내용은 다음 자료를 참고하면 좋을 것 같습니다.

Json Patch MSDN
JsonPatch nuget
JsonPatch.com

[FromHeader]

:blob_gasp: 요청 바인딩 중에 [FromHeader]를 놓쳤었네요.

FromHeader는 http 요청의 header에 있는 key와 매핑이 됩니다.

[ApiController]
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    [HttpGet]
    public ActionResult Get([FromHeader] string auth)
    {
        return Ok(auth);
    }
}

image


다음은 http 응답과 반환 타입을 알아보겠습니다.

5 Likes