관계 설정
1:N 과 N:M 을 맺습니다.
할일 하나에 딸린 것들
지금까지 할일은 홀로 있었습니다. 실제로는 다른 것과 이어집니다. 분류가 하나 붙고, 태그는 여럿 붙습니다.
- 1:N — 분류 하나에 할일이 여럿입니다. 할일은 분류를 하나만 가집니다.
- N:M — 할일 하나에 태그가 여럿이고, 태그 하나가 여러 할일에 붙습니다.
C# 에서는 서로를 가리키는 속성으로 적습니다. 그것을 탐색 속성이라고 부릅니다. EF Core 가 그 모양을 보고 외래 키와 조인 표를 만듭니다.
둘을 한꺼번에
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 }
이 코드는 데이터베이스가 있어야 하므로 브라우저에서 실행할 수 없습니다.
두 가지가 저절로 생겼습니다.
- 조인 표 — N:M 을 담을 TagTodo 가 만들어졌습니다. 클래스를 적지 않았습니다. 두 키를 묶은 것이 기본 키입니다.
- 인덱스 — 외래 키마다 붙습니다. 그것으로 찾는 일이 잦기 때문입니다.
이어진 것은 저절로 따라오지 않습니다
할일을 읽으면 분류도 함께 올 것 같지만 오지 않습니다.
Category 는 null 입니다.
foreach (var t in db.Todos.ToList()) Console.WriteLine(t.Category?.Name ?? "(안 읽음)");
이것이 오히려 다행입니다. 저절로 따라온다면 할일 하나를 읽을 때마다 분류를 읽고, 그 분류에 딸린 할일을 또 읽게 됩니다. 어디까지 가져올지 우리가 정하는 편이 낫습니다.
하나씩 물으면 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회
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 없이도 조인이 만들어지고, 두 칸만 가져옵니다.
물음표 하나가 삭제 동작을 바꿉니다
외래 키에 물음표를 붙이느냐에 따라 표의 정의와 지울 때의 동작이 달라집니다. 같은 자료로 분류 하나를 지워 보았습니다.
// 표 "CategoryId" INTEGER NOT NULL, FOREIGN KEY … ON DELETE CASCADE // 분류를 지우면 할일 3개 → 2개 // 딸린 할일도 함께 사라집니다
// 표 "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 가 어느 쪽이 짝인지 알 수 없어 직접 적어야
합니다.
조인 표를 적지 않아도 됩니다
N:M 을 위해 TagTodo 같은 클래스를 직접 만들고 양쪽에 1:N 을 두 번 적어야 했습니다. 지금은 양쪽에 목록만 두면 조인 표가 저절로 만들어집니다.
조인 표에 값을 더 담아야 한다면(붙인 날짜 따위) 그때는 여전히 클래스를 만듭니다. 저절로 만들어지는 표에는 두 키뿐이라 다른 칸을 넣을 자리가 없습니다.
직접 해보기
분류마다 딸린 할일이 몇 개인지 돌려주는 질의를 적어보세요. 할일을 모두 가져오지 않아야 합니다.
태그를 언제 붙였는지도 담고 싶습니다. 어떻게 해야 할지 적어보세요.
할일 목록 화면이 자료가 쌓이면서 느려졌습니다. 아래 코드에서 무엇이 문제이고 어떻게 고칠지 적어보세요.
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 를 부르면 몇 번 나갑니까.- 서로를 가리키는 탐색 속성을 두면 외래 키와 조인 표가 만들어집니다.
- N:M 의 조인 표는 클래스를 적지 않아도 만들어집니다. 값을 더 담아야 할 때만 직접 만듭니다.
- 이어진 것은 저절로 오지 않습니다.
Include로 함께 가져오거나Select로 필요한 칸만 가져옵니다. - 반복문 안에서 하나씩 물으면 N+1 입니다. 오류가 나지 않아 자료가 쌓인 뒤에 드러납니다.
- 외래 키의 물음표 하나가 삭제 동작을 바꿉니다. 필수면 함께 지워지고, 선택이면 남아 NULL 이 됩니다.