할일 관리 API 만들기
배운 것을 묶어 하나를 완성합니다.
조각들을 하나로
여기까지 하나씩 본 것들을 한 프로젝트에 모읍니다. 새로 배우는 것은 없습니다. 어느 단원의 것이 어디에 놓이는지를 보는 것이 이 단원의 일입니다.
| 단원 | 여기서 맡는 것 |
|---|---|
| 1.2 · 1.3 | Program.cs 의 두 단계와 미들웨어 순서 |
| 2.2 | 컨트롤러와 어트리뷰트 라우팅 |
| 2.3 | 받는 값의 검사 |
| 2.5 | 주소 설계와 상태 코드, ProblemDetails |
| 3.2 · 3.3 | DbContext 등록과 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 // 연결 문자열과 키
이 코드는 데이터베이스와 서버가 있어야 하므로 브라우저에서 실행할 수 없습니다.
형식을 둘로 나눈 것이 이 프로젝트에서 가장 중요한 결정입니다. 2.3에서는 하나로 받았지만, 이제 Owner 가 생겼습니다. 하나로 두면 부르는 쪽이 남의 이름을 적어 보낼 수 있습니다.
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; } = "";
// 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 — 앞과 뒤
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();
컨트롤러 — 내 것만
클래스에 [Authorize] 를 붙여 모든 메서드가
로그인을 요구하게 합니다. 그리고 질의마다 내 것으로 좁힙니다.
[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 /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
403 과 404 가운데 무엇을 돌려줄 것인가
GET /todos/1 을 김철수가 부르면 403 을 돌려주었습니다. 그런데 이것은 "1번 할일이 있다" 는 것을 알려 줍니다. 번호를 하나씩 넣어 보면 몇 개가 있는지 셀 수 있습니다.
그래서 404 로 두는 설계도 흔합니다. 남의 것은 없는 것과 같이 다루는 것입니다. 어느 쪽이 맞는지는 무엇을 감춰야 하느냐에 달렸습니다.
- 403 — 부르는 쪽이 "권한이 없다" 는 것을 알아야 할 때입니다. 함께 쓰는 문서를 다루는 경우가 그렇습니다.
- 404 — 존재 자체를 감춰야 할 때입니다. 개인의 자료가 대개 이쪽입니다.
이 API 는 개인의 할일이므로 404 가 더 맞습니다. 위 코드에서
Forbid() 를
NotFound() 로 바꾸면 됩니다. 정답이 하나가 아닌
자리이므로 정해서 문서에 적어 두는 것이 중요합니다.
여기서 다루지 않은 것
로그인을 다루지 않았습니다. 이름만 받아 토큰을 냈는데, 실제로는 비밀번호를 확인하고 그 결과로 발급해야 합니다. 갱신 토큰도 함께 필요합니다(4.3).
그 밖에 실제로 올리려면 더 필요한 것들입니다 — 테스트,
마이그레이션으로 표 관리(3.4의 EnsureCreated
대신), 페이징, OpenAPI 문서,
속도 제한. 하나씩 붙여 보십시오.
직접 해보기
남의 것에 403 대신 404 를 돌려주도록 고쳐보세요. 세 곳을 손대야 합니다.
할일이 많아지면 목록이 길어집니다. ?page=2&size=20 으로 나눠 돌려주도록 고쳐보세요. 전체 개수도 함께 알려 줍니다.
이 API 를 실제로 올린다고 합시다. 지금 이대로 올리면 안 되는 것을 앞의 단원들을 떠올리며 네 가지 이상 찾아보세요.
- 새로운 것은 없습니다. 앞의 열아홉 단원이 어디에 놓이는지를 보는 단원입니다.
- 받는 형식과 담는 형식을 나눕니다. 받지 않을 것은 자리를 두지 않는 것이 가장 확실합니다.
- 소유자는 밖에서 받지 않고 토큰에서 채웁니다.
- 남의 것에 403 과 404 가운데 무엇을 돌려줄지는 정답이 하나가 아닙니다. 정해서 적어 두십시오.
- 올리기 전에 키·마이그레이션·페이징·HTTPS 를 다시 보십시오.