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

관계 설정

1:N 과 N:M 을 맺습니다.

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

할일 하나에 딸린 것들

지금까지 할일은 홀로 있었습니다. 실제로는 다른 것과 이어집니다. 분류가 하나 붙고, 태그는 여럿 붙습니다.

  • 1:N — 분류 하나에 할일이 여럿입니다. 할일은 분류를 하나만 가집니다.
  • N:M — 할일 하나에 태그가 여럿이고, 태그 하나가 여러 할일에 붙습니다.

C# 에서는 서로를 가리키는 속성으로 적습니다. 그것을 탐색 속성이라고 부릅니다. EF Core 가 그 모양을 보고 외래 키와 조인 표를 만듭니다.

최소 예제

둘을 한꺼번에

Category.cs · Tag.cs · Todo.cs
public class Category {
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Todo> Todos { get; } = [];      // 1:N 의 N 쪽
}

public class Tag {
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Todo> Todos { get; } = [];      // N:M
}

public class Todo {
    public int Id { get; set; }
    public string Title { get; set; } = "";

    public int CategoryId { get; set; }        // 외래 키
    public Category? Category { get; set; }     // 탐색 속성

    public List<Tag> Tags { get; } = [];        // N:M
}
만들어진 표
CREATE TABLE "Todos" ( "Id" INTEGER NOT NULL … PRIMARY KEY AUTOINCREMENT, "Title" TEXT NOT NULL, "CategoryId" INTEGER NOT NULL, CONSTRAINT "FK_Todos_Categories_CategoryId" FOREIGN KEY ("CategoryId") REFERENCES "Categories" ("Id") ON DELETE CASCADE ); CREATE TABLE "TagTodo" ( "TagsId" INTEGER NOT NULL, "TodosId" INTEGER NOT NULL, CONSTRAINT "PK_TagTodo" PRIMARY KEY ("TagsId", "TodosId"), CONSTRAINT "FK_TagTodo_Tags_TagsId" FOREIGN KEY … ON DELETE CASCADE, CONSTRAINT "FK_TagTodo_Todos_TodosId" FOREIGN KEY … ON DELETE CASCADE ); CREATE INDEX "IX_Todos_CategoryId" ON "Todos" ("CategoryId"); CREATE INDEX "IX_TagTodo_TodosId" ON "TagTodo" ("TodosId");

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

두 가지가 저절로 생겼습니다.

  • 조인 표 — N:M 을 담을 TagTodo 가 만들어졌습니다. 클래스를 적지 않았습니다. 두 키를 묶은 것이 기본 키입니다.
  • 인덱스 — 외래 키마다 붙습니다. 그것으로 찾는 일이 잦기 때문입니다.
상세 사용법

이어진 것은 저절로 따라오지 않습니다

할일을 읽으면 분류도 함께 올 것 같지만 오지 않습니다. Categorynull 입니다.

Include 없이
foreach (var t in db.Todos.ToList())
    Console.WriteLine(t.Category?.Name ?? "(안 읽음)");
출력
(안 읽음) (안 읽음) SQL 1회

이것이 오히려 다행입니다. 저절로 따라온다면 할일 하나를 읽을 때마다 분류를 읽고, 그 분류에 딸린 할일을 또 읽게 됩니다. 어디까지 가져올지 우리가 정하는 편이 낫습니다.

하나씩 물으면 N+1 이 됩니다

없다고 해서 반복문 안에서 하나씩 물으면 할일 수만큼 SQL 이 나갑니다. 목록을 가져온 한 번을 더해 N+1 이라고 부릅니다.

하나씩 물으면
foreach (var t in db.Todos.ToList()) {
    var c = db.Categories
        .First(x => x.Id == t.CategoryId);
    Console.WriteLine(c.Name);
}

// 할일 3개 → SQL 4회
Include 로 함께
foreach (var t in db.Todos
    .Include(t => t.Category).ToList()) {
    Console.WriteLine(t.Category!.Name);
}

// 할일 3개 → SQL 1회

할일이 세 개일 때는 4회와 1회입니다. 천 개면 1001회와 1회입니다. 화면에서는 느려질 뿐 오류가 나지 않아, 자료가 쌓인 뒤에야 드러납니다.

여럿을 함께 가져오려면 이어 붙입니다. 더 깊이 들어갈 때는 ThenInclude 입니다.

db.Todos
  .Include(t => t.Category)
  .Include(t => t.Tags)
  .ToList();

// 분류의 다른 것까지 들어갈 때
db.Todos.Include(t => t.Category).ThenInclude(c => c.Owner);

필요한 것만 가져오는 방법도 있습니다. 화면에 이름만 보일 것이라면 통째로 읽을 까닭이 없습니다.

db.Todos.Select(t => new { t.Title, 분류 = t.Category!.Name }).ToList();

// Include 없이도 조인이 만들어지고, 두 칸만 가져옵니다.
상세 사용법

물음표 하나가 삭제 동작을 바꿉니다

외래 키에 물음표를 붙이느냐에 따라 표의 정의와 지울 때의 동작이 달라집니다. 같은 자료로 분류 하나를 지워 보았습니다.

int CategoryId — 필수
// 표
"CategoryId" INTEGER NOT NULL,
FOREIGN KEY … ON DELETE CASCADE

// 분류를 지우면
할일 3개 → 2개
// 딸린 할일도 함께 사라집니다
int? CategoryId — 선택
// 표
"CategoryId" INTEGER NULL,
FOREIGN KEY …
// CASCADE 가 없습니다

// 분류를 지우면
할일 3개 → 3개
// 남고 CategoryId 가 NULL 이 됩니다

기본값이 이렇게 갈리는 것을 알아 두십시오. 분류를 지웠는데 할일이 함께 사라지는 것은 대개 뜻한 바가 아닙니다. 필수로 두어야 한다면 삭제 동작을 직접 정합니다.

protected override void OnModelCreating(ModelBuilder b) {
    b.Entity<Todo>()
     .HasOne(t => t.Category)
     .WithMany(c => c.Todos)
     .HasForeignKey(t => t.CategoryId)
     .OnDelete(DeleteBehavior.Restrict);   // 딸린 것이 있으면 못 지웁니다
}

HasOne·WithMany 는 규약으로 알아내지 못할 때도 사용합니다. 한 형식이 같은 형식을 두 번 가리키는 경우가 그렇습니다. 그때는 EF Core 가 어느 쪽이 짝인지 알 수 없어 직접 적어야 합니다.

버전 배지

조인 표를 적지 않아도 됩니다

EF Core 5 부터

N:M 을 위해 TagTodo 같은 클래스를 직접 만들고 양쪽에 1:N 을 두 번 적어야 했습니다. 지금은 양쪽에 목록만 두면 조인 표가 저절로 만들어집니다.

조인 표에 값을 더 담아야 한다면(붙인 날짜 따위) 그때는 여전히 클래스를 만듭니다. 저절로 만들어지는 표에는 두 키뿐이라 다른 칸을 넣을 자리가 없습니다.

실습 문제

직접 해보기

1. 분류별로 세기 난이도 하

분류마다 딸린 할일이 몇 개인지 돌려주는 질의를 적어보세요. 할일을 모두 가져오지 않아야 합니다.

Include 를 사용하면 할일이 모두 따라옵니다. Select 안에서 세면 SQL 이 세어 줍니다.
db.Categories .Select(c => new { c.Name, 개수 = c.Todos.Count() }) .ToList(); // SELECT "c"."Name", (SELECT COUNT(*) FROM "Todos" …) // 할일 줄은 하나도 가져오지 않습니다.
2. 조인 표에 값 더하기 난이도 중

태그를 언제 붙였는지도 담고 싶습니다. 어떻게 해야 할지 적어보세요.

저절로 만들어지는 표에는 두 키뿐입니다. 칸을 더하려면 그 표를 우리가 가져야 합니다.
public class TodoTag { public int TodoId { get; set; } public Todo? Todo { get; set; } public int TagId { get; set; } public Tag? Tag { get; set; } public DateTime AddedAt { get; set; } } // 그리고 양쪽 목록을 List<TodoTag> 로 바꿉니다. // 기본 키는 두 키를 묶어 적습니다. b.Entity<TodoTag>().HasKey(x => new { x.TodoId, x.TagId });
3. 느려진 화면 고치기 난이도 상

할일 목록 화면이 자료가 쌓이면서 느려졌습니다. 아래 코드에서 무엇이 문제이고 어떻게 고칠지 적어보세요.

var 목록 = await db.Todos.ToListAsync();

return 목록.Select(t => new TodoView {
    Title = t.Title,
    분류 = db.Categories.Find(t.CategoryId)!.Name,
    태그수 = db.Tags.Count(g => g.Todos.Any(x => x.Id == t.Id))
});
ToListAsync() 뒤의 Select 는 어디에서 도는 것입니까. 그 안에서 db 를 부르면 몇 번 나갑니까.
// 문제 : ToListAsync() 로 이미 가져온 뒤라 Select 는 C# 에서 돕니다. // 그 안에서 db 를 두 번 부르므로 할일 하나마다 SQL 이 두 번 // 나갑니다. 천 개면 2001회입니다. // 고치기 : ToList 를 뒤로 미뤄 SQL 하나로 만듭니다. return await db.Todos .Select(t => new TodoView { Title = t.Title, 분류 = t.Category!.Name, 태그수 = t.Tags.Count() }) .ToListAsync(); // 3.1에서 본 것과 같은 함정입니다. ToList 를 어디서 부르는지가 // SQL 하나와 수천 개를 구분합니다. ToQueryString() 으로 확인하십시오.
요약
  • 서로를 가리키는 탐색 속성을 두면 외래 키와 조인 표가 만들어집니다.
  • N:M 의 조인 표는 클래스를 적지 않아도 만들어집니다. 값을 더 담아야 할 때만 직접 만듭니다.
  • 이어진 것은 저절로 오지 않습니다. Include 로 함께 가져오거나 Select 로 필요한 칸만 가져옵니다.
  • 반복문 안에서 하나씩 물으면 N+1 입니다. 오류가 나지 않아 자료가 쌓인 뒤에 드러납니다.
  • 외래 키의 물음표 하나가 삭제 동작을 바꿉니다. 필수면 함께 지워지고, 선택이면 남아 NULL 이 됩니다.