ASP.NET Core 5.3 · 5부. 배포와 실전

할일 관리 API 만들기

배운 것을 묶어 하나를 완성합니다.

예상 학습 시간 24분 난이도 실전
개념 설명

조각들을 하나로

여기까지 하나씩 본 것들을 한 프로젝트에 모읍니다. 새로 배우는 것은 없습니다. 어느 단원의 것이 어디에 놓이는지를 보는 것이 이 단원의 일입니다.

단원여기서 맡는 것
1.2 · 1.3Program.cs 의 두 단계와 미들웨어 순서
2.2컨트롤러와 어트리뷰트 라우팅
2.3받는 값의 검사
2.5주소 설계와 상태 코드, ProblemDetails
3.2 · 3.3DbContext 등록과 CRUD
4.2 · 4.3토큰으로 신분을 읽고 401·403 을 가르기
5.1환경에 따라 예외 화면 가르기

만들 것은 사람마다 자기 할일만 다루는 API 입니다. 남의 것은 보이지도 않고, 주소를 알아도 손대지 못합니다.

최소 예제

파일 여섯 개

프로젝트 구조
TodoApi/
├─ Program.cs                      // 등록과 파이프라인
├─ Models/
│  ├─ Todo.cs                      // 담는 형식
│  └─ TodoInput.cs                 // 받는 형식
├─ Data/
│  └─ TodoContext.cs               // 매핑
├─ Controllers/
│  └─ TodosController.cs           // 주소와 처리
└─ appsettings.json                // 연결 문자열과 키
눈여겨볼 곳
Models 가 둘입니다. 담는 형식과 받는 형식을 나눕니다.

이 코드는 데이터베이스와 서버가 있어야 하므로 브라우저에서 실행할 수 없습니다.

형식을 둘로 나눈 것이 이 프로젝트에서 가장 중요한 결정입니다. 2.3에서는 하나로 받았지만, 이제 Owner 가 생겼습니다. 하나로 두면 부르는 쪽이 남의 이름을 적어 보낼 수 있습니다.

Todo — 담는 형식
public int Id { get; set; }
public string? Title { get; set; }
public bool Done { get; set; }
public int Priority { get; set; } = 3;
public DateOnly? Due { get; set; }
public string Owner { get; set; } = "";
TodoInput — 받는 형식
// Id 와 Owner 가 없습니다
public string? Title { get; set; }
public bool Done { get; set; }
public int Priority { get; set; } = 3;
public DateOnly? Due { get; set; }

Owner 는 밖에서 받지 않고 토큰에서 채웁니다. 받지 않는 것은 애초에 담을 자리를 두지 않는 것이 가장 확실합니다.

상세 사용법

Program.cs — 앞과 뒤

Program.cs
var 키글자 = builder.Configuration["Jwt:Key"]
    ?? throw new InvalidOperationException("Jwt:Key 가 없습니다.");
var 키 = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(키글자));

// ① 무엇을 갖출지 (1.2)
builder.Services.AddDbContext<TodoContext>(o =>
    o.UseSqlite(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o => o.TokenValidationParameters = new() {
        ValidIssuer = "todo-api", ValidAudience = "todo-app", IssuerSigningKey = 키,
        ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true
    });

builder.Services.AddAuthorization();
builder.Services.AddControllers();
builder.Services.AddProblemDetails();

var app = builder.Build();

// ② 요청이 지날 길 (1.3) — 순서가 곧 동작입니다
if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage();
else app.UseExceptionHandler();

app.UseStatusCodePages();
app.UseAuthentication();   // 먼저 — 누구인지 (4.2)
app.UseAuthorization();    // 그 다음 — 해도 되는지
app.MapControllers();

app.Run();
앞 단원과 이어지는 곳
· 키를 코드에 적지 않고 설정에서 읽습니다 (4.4) 없으면 기동할 때 막습니다. 빈 값으로 도는 것보다 낫습니다. · 예외 화면을 환경에 따라 구분합니다 (5.1) · UseAuthentication 이 UseAuthorization 보다 앞입니다 (4.2)
상세 사용법

컨트롤러 — 내 것만

클래스에 [Authorize] 를 붙여 모든 메서드가 로그인을 요구하게 합니다. 그리고 질의마다 내 것으로 좁힙니다.

Controllers/TodosController.cs
[ApiController]
[Route("todos")]
[Authorize]
public class TodosController(TodoContext db) : ControllerBase {
    private string 나 => User.Identity!.Name!;

    [HttpGet]
    public async Task<IEnumerable<Todo>> List(bool? done, CancellationToken token) =>
        await db.Todos.AsNoTracking()
            .Where(t => t.Owner == 나 && (done == null || t.Done == done))
            .OrderByDescending(t => t.Priority)
            .ToListAsync(token);

    [HttpGet("{id:int}")]
    public async Task<ActionResult<Todo>> Get(int id, CancellationToken token) {
        if (await db.Todos.AsNoTracking()
                .FirstOrDefaultAsync(t => t.Id == id, token) is not { } found)
            return NotFound();

        return found.Owner == 나 ? found : Forbid();
    }

    [HttpPost]
    public async Task<ActionResult<Todo>> Add(TodoInput input, CancellationToken token) {
        var todo = new Todo {
            Title = input.Title, Done = input.Done,
            Priority = input.Priority, Due = input.Due,
            Owner = 나                    // 밖에서 받지 않습니다
        };

        db.Todos.Add(todo);
        await db.SaveChangesAsync(token);

        return CreatedAtAction(nameof(Get), new { id = todo.Id }, todo);
    }
}
눈여겨볼 곳
Get 은 없으면 404, 남의 것이면 403 입니다. 둘을 나눈 까닭은 아래에서 다시 봅니다.
상세 사용법

차례대로 불러 보면

홍길동과 김철수로 토큰을 받아 순서대로 불렀습니다.

받은 응답
// 토큰 없이
GET /todos                    401  {"title":"Unauthorized", …}

// 홍길동으로 담기
POST /todos                   201  {"id":1,"title":"우유 사기","priority":3,
                                    "due":"2026-09-05","owner":"hong"}
POST /todos                   201  {"id":2,"title":"세차","priority":5, …}

// 잘못된 값 — 제목 1자, 중요도 9
POST /todos                   400  errors:
                                     Title    제목은 2자에서 50자 사이입니다.
                                     Priority 중요도는 1에서 5 사이입니다.

// 목록
GET /todos (홍길동)           200  [세차(5), 우유 사기(3)]
GET /todos (김철수)           200  []

// 남의 것
GET /todos/1 (김철수)         403  {"title":"Forbidden", …}

// 내 것
PUT /todos/1                  204
GET /todos?done=true          200  [{"id":1,"done":true, …}]
DELETE /todos/1               204
GET /todos/1 (지운 뒤)        404
확인된 것
· 로그인하지 않으면 401, 남의 것이면 403 (4.2) · 틀린 곳이 둘이면 둘 다 돌려줍니다 (2.3) · 김철수에게는 홍길동의 것이 목록에 아예 없습니다 · 중요도 내림차순으로 정렬되어 옵니다 · 지운 뒤에는 404 입니다

403 과 404 가운데 무엇을 돌려줄 것인가

GET /todos/1 을 김철수가 부르면 403 을 돌려주었습니다. 그런데 이것은 "1번 할일이 있다" 는 것을 알려 줍니다. 번호를 하나씩 넣어 보면 몇 개가 있는지 셀 수 있습니다.

그래서 404 로 두는 설계도 흔합니다. 남의 것은 없는 것과 같이 다루는 것입니다. 어느 쪽이 맞는지는 무엇을 감춰야 하느냐에 달렸습니다.

  • 403 — 부르는 쪽이 "권한이 없다" 는 것을 알아야 할 때입니다. 함께 쓰는 문서를 다루는 경우가 그렇습니다.
  • 404 — 존재 자체를 감춰야 할 때입니다. 개인의 자료가 대개 이쪽입니다.

이 API 는 개인의 할일이므로 404 가 더 맞습니다. 위 코드에서 Forbid()NotFound() 로 바꾸면 됩니다. 정답이 하나가 아닌 자리이므로 정해서 문서에 적어 두는 것이 중요합니다.

버전 배지

여기서 다루지 않은 것

다음

로그인을 다루지 않았습니다. 이름만 받아 토큰을 냈는데, 실제로는 비밀번호를 확인하고 그 결과로 발급해야 합니다. 갱신 토큰도 함께 필요합니다(4.3).

그 밖에 실제로 올리려면 더 필요한 것들입니다 — 테스트, 마이그레이션으로 표 관리(3.4의 EnsureCreated 대신), 페이징, OpenAPI 문서, 속도 제한. 하나씩 붙여 보십시오.

실습 문제

직접 해보기

1. 404 로 바꾸기 난이도 하

남의 것에 403 대신 404 를 돌려주도록 고쳐보세요. 세 곳을 손대야 합니다.

Get·Update·Remove 셋에 있습니다. 아예 질의에서 걸러 내는 방법도 있습니다.
// 찾을 때 소유자까지 함께 봅니다. 그러면 남의 것은 애초에 // 찾아지지 않아 자연스럽게 404 가 됩니다. if (await db.Todos.FirstOrDefaultAsync( t => t.Id == id && t.Owner == 나, token) is not { } found) return NotFound(); // 소유자 확인이 한 줄로 합쳐지고, 빠뜨릴 자리도 사라집니다.
2. 페이징 더하기 난이도 중

할일이 많아지면 목록이 길어집니다. ?page=2&size=20 으로 나눠 돌려주도록 고쳐보세요. 전체 개수도 함께 알려 줍니다.

3.1에서 본 대로 세는 것도 SQL 이 하게 두어야 합니다. ToList 를 먼저 부르면 안 됩니다.
public async Task<object> List(bool? done, int page = 1, int size = 20, CancellationToken token = default) { var 질의 = db.Todos.AsNoTracking() .Where(t => t.Owner == 나 && (done == null || t.Done == done)); var 전체 = await 질의.CountAsync(token); // SELECT COUNT(*) var 목록 = await 질의 .OrderByDescending(t => t.Priority).ThenBy(t => t.Id) .Skip((page - 1) * size).Take(size) .ToListAsync(token); return new { 전체, page, size, 목록 }; } // 정렬을 하나 더 둔 것에 까닭이 있습니다. 중요도가 같은 것이 // 여럿이면 순서가 정해지지 않아, 쪽을 넘길 때 같은 것이 두 번 // 나오거나 빠질 수 있습니다.
3. 올리기 전에 살펴볼 것 난이도 상

이 API 를 실제로 올린다고 합시다. 지금 이대로 올리면 안 되는 것을 앞의 단원들을 떠올리며 네 가지 이상 찾아보세요.

appsettings.json 을 다시 보십시오. 그리고 /token 주소가 무엇을 확인하고 있습니까.
// 1. /token 이 이름만 받고 토큰을 냅니다. // 아무나 남의 이름을 적어 남의 할일을 볼 수 있습니다. // 비밀번호 확인이 반드시 필요합니다. // 2. Jwt:Key 가 appsettings.json 에 적혀 있습니다 (4.4). // 저장소에 담기므로 새어 나갑니다. 키를 알면 아무 토큰이나 // 만들 수 있습니다. 환경 변수나 사용자 비밀로 옮깁니다. // 3. EnsureCreated 를 사용하고 있습니다 (3.4). // 형식이 바뀌어도 표가 바뀌지 않습니다. 마이그레이션으로 바꿉니다. // 4. 목록에 페이징이 없습니다 (위 2번 문제). // 자료가 쌓이면 한 번에 모두 돌려주게 됩니다. // 5. SQLite 를 사용하고 있습니다. // 배우기에는 좋지만 여러 서버가 함께 쓰기에는 맞지 않습니다. // 6. HTTPS 를 강제하지 않습니다. // 토큰이 머리글에 그대로 실려 가므로 가로채이면 그대로 새어 나갑니다.
요약
  • 새로운 것은 없습니다. 앞의 열아홉 단원이 어디에 놓이는지를 보는 단원입니다.
  • 받는 형식과 담는 형식을 나눕니다. 받지 않을 것은 자리를 두지 않는 것이 가장 확실합니다.
  • 소유자는 밖에서 받지 않고 토큰에서 채웁니다.
  • 남의 것에 403 과 404 가운데 무엇을 돌려줄지는 정답이 하나가 아닙니다. 정해서 적어 두십시오.
  • 올리기 전에 키·마이그레이션·페이징·HTTPS 를 다시 보십시오.