ASP.NET Core 3.3 · 3부. 데이터 접근

CRUD 구현

담고 읽고 고치고 지웁니다.

예상 학습 시간 20분 난이도 중급
개념 설명

2부의 주소에 3부의 데이터베이스를 붙입니다

2.5에서 정한 주소 설계와 3.1·3.2의 EF Core 를 합칩니다. 달라지는 것은 List<Todo> 자리에 db.Todos 가 들어가는 것뿐입니다.

동작주소EF Core
읽기GET /todosdb.Todos.ToListAsync()
하나 읽기GET /todos/1db.Todos.FindAsync(1)
담기POST /todosdb.Todos.Add(…) + SaveChangesAsync()
고치기PUT /todos/1읽어서 속성을 바꾸고 SaveChangesAsync()
지우기DELETE /todos/1db.Todos.Remove(…) + SaveChangesAsync()

모두 비동기로 적습니다. 데이터베이스를 기다리는 동안 스레드를 붙잡고 있으면 그만큼 다른 요청을 받지 못합니다. C# 4.1에서 본 것이 여기서 쓰입니다.

최소 예제

컨트롤러 하나에 다섯 가지

TodosController.cs
[ApiController]
[Route("todos")]
public class TodosController(TodoContext db) : ControllerBase {

    [HttpGet]
    public async Task<IEnumerable<Todo>> List(CancellationToken token) =>
        await db.Todos.AsNoTracking().ToListAsync(token);

    [HttpGet("{id:int}")]
    public async Task<ActionResult<Todo>> Get(int id) =>
        await db.Todos.FindAsync(id) is { } found ? found : NotFound();

    [HttpPost]
    public async Task<ActionResult<Todo>> Add(Todo todo, CancellationToken token) {
        db.Todos.Add(todo);
        await db.SaveChangesAsync(token);

        return CreatedAtAction(nameof(Get), new { id = todo.Id }, todo);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, Todo input, CancellationToken token) {
        if (await db.Todos.FindAsync([id], token) is not { } found) return NotFound();

        found.Title = input.Title;
        found.Done = input.Done;
        found.Priority = input.Priority;
        await db.SaveChangesAsync(token);

        return NoContent();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Remove(int id, CancellationToken token) {
        if (await db.Todos.FindAsync([id], token) is not { } found) return NotFound();

        db.Todos.Remove(found);
        await db.SaveChangesAsync(token);

        return NoContent();
    }
}
돌려주는 것
GET /todos 200 목록 GET /todos/1 200 하나 GET /todos/99 404 POST /todos 201 Location: /todos/4 PUT /todos/1 204 DELETE /todos/1 204

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

CancellationToken 을 매개 변수로 두면 브라우저가 연결을 끊었을 때 질의도 함께 멈춥니다. 적지 않아도 동작하지만, 사용자가 화면을 닫은 뒤에도 데이터베이스가 계속 일하게 됩니다.

상세 사용법

어떤 SQL 이 나가나

각 동작에서 실제로 나간 SQL 입니다.

나간 SQL
// 담기
INSERT INTO "Todos" ("Done", "Priority", "Title")
VALUES (@p0, @p1, @p2)
RETURNING "Id";

// 고치기 — Done 만 바꾼 뒤
UPDATE "Todos" SET "Done" = @p0
WHERE "Id" = @p1
RETURNING 1;

// 지우기
DELETE FROM "Todos"
WHERE "Id" = @p0
RETURNING 1;
SaveChanges 가 돌려준 값
담기 1 고치기 1 지우기 1

두 가지를 보십시오.

  • INSERTRETURNING "Id" 가 붙었습니다. 데이터베이스가 매긴 번호를 받아 우리 객체에 넣어 줍니다. 그래서 SaveChanges 뒤에 todo.Id 를 사용할 수 있습니다.
  • UPDATE바꾼 컬럼 하나만 있습니다. TitlePriority 는 들어가지 않았습니다.

SaveChanges()바뀐 줄 수를 돌려줍니다. 0이 돌아왔다면 아무것도 바뀌지 않은 것이므로, 고쳤다고 알리기 전에 확인할 자리로 쓸 수 있습니다.

상세 사용법

Find 와 First 는 다릅니다

둘 다 하나를 가져오지만 Find 는 먼저 자기 기억을 봅니다. 이미 읽어 둔 것이면 SQL 을 보내지 않습니다.

새 컨텍스트에서 차례로
await db.Todos.FindAsync(1);
await db.Todos.FindAsync(1);
await db.Todos.FirstAsync(t => t.Id == 1);
await db.Todos.FirstAsync(t => t.Id == 1);
나간 SQL 횟수
첫 Find 1 다시 Find 0 ← 기억에 있어 보내지 않았습니다 First 1 First 다시 1 ← 언제나 보냅니다

그래서 키로 하나를 찾을 때는 Find 가 낫습니다. 다만 기억에 있는 것을 돌려주므로, 그 사이에 다른 곳에서 바뀐 값은 보지 못합니다. 언제나 최신을 읽어야 한다면 First 를 사용하거나 컨텍스트를 새로 받습니다.

읽지 않고 한꺼번에 고치기

"끝나지 않은 것을 모두 끝냄" 처럼 여러 줄을 한 번에 바꿀 때, 위 방식대로 하면 줄을 모두 읽어 온 뒤 하나씩 UPDATE 합니다. 줄이 많으면 그만큼 느립니다.

Program.cs
var 고친수 = await db.Todos
    .Where(t => !t.Done)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Done, true));
나간 SQL
UPDATE "Todos" AS "t" SET "Done" = @p WHERE NOT ("t"."Done") 고친 줄 2개, SQL 1회

SELECT 가 없습니다. 읽지 않고 곧바로 고칩니다. 지우는 쪽은 ExecuteDeleteAsync() 입니다.

다만 이 방식은 지켜보는 기억을 거치지 않습니다. SaveChanges 를 부르지 않고 곧바로 나가며, 이미 읽어 둔 객체가 있어도 그 값은 바뀌지 않습니다. 섞어 사용할 때는 컨텍스트를 새로 받는 편이 헷갈리지 않습니다.

상세 사용법

두 사람이 같은 줄을 고치면

갑과 을이 같은 할일을 동시에 열어 두고 각각 저장했습니다. 결과가 무엇을 고쳤느냐에 따라 다릅니다.

서로 다른 칸을 고쳤을 때
갑 : Title = "갑이 고침"
을 : Priority = 9

// 결과
Title = 갑이 고침
Priority = 9
같은 칸을 고쳤을 때
갑 : Title = "갑이 고침"
을 : Title = "을이 고침"

// 결과
Title = 을이 고침
// 갑의 것은 사라졌습니다

왼쪽이 살아남은 것은 앞에서 본 UPDATE 가 바꾼 컬럼만 담기 때문입니다. 을의 UPDATE 에 Title 이 없어 갑의 것을 덮지 않았습니다.

오른쪽은 나중에 저장한 쪽이 이깁니다. 갑은 자기 것이 사라진 줄도 모릅니다. 이것을 막으려면 "내가 읽은 뒤로 바뀌었는지" 를 함께 보아야 합니다. EF Core 에서는 [Timestamp]IsConcurrencyToken() 으로 그 칸을 두고, 어긋나면 DbUpdateConcurrencyException 이 발생합니다.

버전 배지

ExecuteUpdate 와 ExecuteDelete

EF Core 7 부터

읽지 않고 한꺼번에 고치는 방법이 이때 들어왔습니다. 그 전에는 줄을 모두 읽어 와 하나씩 바꾸거나, SQL 을 직접 적어야 했습니다.

FindAsync 에 취소 토큰을 넘길 때 모양이 다릅니다. 첫 인수가 키 배열이므로 FindAsync([id], token) 입니다. 그냥 FindAsync(id, token) 이라고 적으면 토큰이 두 번째 키 값으로 읽혀 엉뚱한 예외가 발생합니다.

실습 문제

직접 해보기

1. 일부만 고치기 난이도 하

PATCH /todos/1 로 끝냄 여부만 바꾸는 메서드를 적어보세요. 나머지 값은 건드리지 않습니다.

읽어서 그 속성 하나만 바꾸면 UPDATE 에도 그것만 들어갑니다.
[HttpPatch("{id:int}/done")] public async Task<IActionResult> SetDone(int id, bool done, CancellationToken token) { if (await db.Todos.FindAsync([id], token) is not { } found) return NotFound(); found.Done = done; await db.SaveChangesAsync(token); return NoContent(); } // UPDATE "Todos" SET "Done" = @p0 WHERE "Id" = @p1
2. 없는 것을 지울 때 난이도 중

Remove 는 없는 번호에 404를 돌려줍니다. 그런데 2.5에서 DELETE 는 여러 번 불러도 결과가 같아야 한다고 했습니다. 둘이 어긋나는지 생각해보고, 어느 쪽이든 까닭을 적어보세요.

"결과가 같다"는 것은 상태를 말합니까, 응답을 말합니까.
// 어긋나지 않습니다. 여러 번 불러도 "없는 상태"라는 결과는 같습니다. // 상태 코드가 달라지는 것은 상관없습니다. // 다만 설계 선택은 둘입니다. // 404 — 지울 것이 없었음을 알려 줍니다. 잘못된 번호를 잡아냅니다. // 204 — 결과만 봅니다. 다시 보내도 늘 성공이라 재시도가 편합니다. // 어느 쪽이든 문서에 적어 둡니다. 부르는 쪽이 404 를 오류로 다룰지 // 정해야 하기 때문입니다.
3. 사라진 수정 되살리기 난이도 상

갑의 수정이 사라지지 않게 하려 합니다. 고치는 메서드에 무엇을 더해야 을이 저장할 때 막을 수 있을지 적어보세요.

을이 읽은 뒤로 그 줄이 바뀌었다는 것을 알아야 합니다. 무엇을 비교해야 알 수 있습니까.
// 줄이 바뀔 때마다 달라지는 칸을 두고, UPDATE 의 조건에 넣습니다. public class Todo { [Timestamp] public byte[]? Version { get; set; } } // UPDATE … WHERE "Id" = @p1 AND "Version" = @p2 // 갑이 먼저 저장해 Version 이 바뀌었으므로 을의 UPDATE 는 0줄을 // 고칩니다. EF Core 가 그것을 보고 예외를 냅니다. try { await db.SaveChangesAsync(token); } catch (DbUpdateConcurrencyException) { return Conflict(new ProblemDetails { Title = "다른 사람이 먼저 고쳤습니다. 다시 읽어 주십시오.", Status = 409 }); } // 2.5에서 본 409 가 이 자리에 맞습니다.
요약
  • 주소 설계는 2.5 그대로이고 List 자리에 db.Todos 가 들어갑니다. 모두 비동기로 적습니다.
  • UPDATE 에는 바꾼 컬럼만 들어갑니다. INSERT 는 매겨진 번호를 돌려받습니다.
  • Find 는 기억을 먼저 보고, First 는 언제나 SQL 을 보냅니다.
  • 여러 줄을 한꺼번에 고칠 때는 ExecuteUpdateAsync읽지 않고 고칩니다.
  • 같은 칸을 동시에 고치면 나중에 저장한 쪽이 이깁니다. 막으려면 바뀔 때마다 달라지는 칸을 두어야 합니다.