EF Core 6 배우기 - 5. 변경 추적 기능
EF Core는 변경 내용을 추적하는 기능을 제공 합니다. 이 기능을 이용해서 DbContext의 SaveChanges가 호출되었을 때 변경된 내용만 데이터베이스에 반영되게 됩니다.
엔터티를 추적하는 방법
EF Core가 변경을 추적하는 유형은 다음과 같습니다.
- 데이터베이스 쿼리 반환
Add,Attach,Update또는 유사한 메서드를 통해 명시적으로 DbContext에 연결- 추적된 기존 엔터티에 연결된 엔터티로 검색할 경우
다음은 추적이 종료되는 경우 입니다.
- DbContext 삭제
- 변경 내용 추적기가 제거된 경우
- 엔터티가 명시적으로 분리된 경우
DbContext는 일시적인 작업 단위로 설계되었으므로 엔터티 추적을 종료하는 가장 일반적인 방법은 DbContext를 삭제하는 것입니다. DbContext의 수명은 일반적으로 다음과 같습니다.
- DbContext 인스턴스 생성
- 엔터티 추적
- 엔터티 변경
- SaveChanges를 통해 엔터티 추적 변경 내용 반영
- DbContext 인스턴스 삭제
엔터티 상태
엔터티의 변경을 추적하기 위해 엔터티는 상태를 가집니다. EntityState를 통해 엔터티의 상태가 관리됩니다.
Detached는 추적되지 않습니다.Added는 삽입되어야 할 추적 대상이 있음을 의미합니다.Unchanged는 쿼리 시점 이후에 변경되지 않았음을 의미합니다.Modified는 쿼리 후 변경되었음을 의미합니다. 따라서SaveChanged시 변경된 내용이 업데이트 됩니다.Deleted는 데이터베이스에는 존재하지만 추적에 의해 대상이 삭제되었음을 의미하며SaveChanged호출 시 삭제됩니다.
동작에 따른 상태 변화
변경 내용 추적은 쿼리 시점과 반영 시점이 동일한 DbContext 인스턴스를 사용할 때 가장 이상적으로 동작합니다. 변경 내용 추적은 DbContext가 삭제될 때 같이 소멸되기 때문입니다. 쿼리 및 쿼리 대상을 수정하여 업데이트 하는 것 뿐만 아니라 쿼리 후 삽입, 업데이트, 삭제를 순차적으로 했을 경우에도 해당합니다. 엔터티는 내부적으로 EntityState를 이용해 순차적으로 추적 내용을 업데이트 하며 최종 상태를 이용해 SaveChanges시점에서 데이터베이스에 변경 내용을 업데이트 한 후 상태를 Unchanged로 변경합니다.
이를 기존 프로젝트를 이용해 확인해보도록 합시다.
using TodoApp.DbContexts;
using var c = new TodoContext();
var result = c.SaveChanges();
Console.WriteLine(result);
| 결과
0
DBContext 인스턴스를 생성하고 어떠한 작업 없이 SaveChanges()를 호출하면 변경 추적 내용이 없으므로 결과로 0을 반환합니다.
새로운 사용자를 추가해 봅시다.
var newUser = new UserInfo
{
UserId = "test",
UserName = "test"
};
newUser는 아직은 DbContext에 연결된 인스턴스가 아니므로 상태를 확인했을 때 Detached임을 알 수 있습니다.
using TodoApp.DbContexts;
using TodoApp.Entities;
using var c = new TodoContext();
var newUser = new UserInfo
{
UserId = "test",
UserName = "test"
};
Console.WriteLine(c.Entry(newUser).State);
var result = c.SaveChanges();
Console.WriteLine(result);
| 결과
Detached
0
newUser를 c.Users에 Add했을 때 상태가 Added로 변경됩니다.
c.Users.Add(newUser);
Console.WriteLine(c.Entry(newUser).State);
| 결과
Added
이제 SaveChanges를 호출해서 상태가 어떻게 변하는지를 확인해 봅시다.
using TodoApp.DbContexts;
using TodoApp.Entities;
using var c = new TodoContext();
var newUser = new UserInfo
{
UserId = "test",
UserName = "test"
};
c.Users.Add(newUser);
Console.WriteLine(c.Entry(newUser).State);
var result = c.SaveChanges();
Console.WriteLine(result);
Console.WriteLine(c.Entry(newUser).State);
| 결과
Added
1
Unchanged
SaveChanges 호출에 의해 데이터베이스에 업데이트 되고 반영된 갯수가 1임을 확인할 수 있었고 상태가 Unchanged로 변경되었음을 확인할 수 있습니다.

이제 저장되어 있는 test 사용자를 찾아 이름을 테스트로 변경해봅시다.
var testUser = c.Users.Find("test")!;
Console.WriteLine(testUser);
Console.WriteLine(c.Entry(testUser).State);
testUser.UserName = "테스트";
Console.WriteLine(c.Entry(testUser).State);
var result = c.SaveChanges();
Console.WriteLine(result);
Console.WriteLine(c.Entry(testUser).State);
| 결과
UserInfo { UserId = test, UserName = test, Todos = }
Unchanged
Modified
1
Unchanged
UserInfo { UserId = test, UserName = 테스트, Todos = }

이제 추가된 사용자를 삭제해봅시다.
var testUser = c.Users.Find("test")!;
Console.WriteLine(c.Entry(testUser).State);
c.Users.Remove(testUser);
Console.WriteLine(c.Entry(testUser).State);
var result = c.SaveChanges();
Console.WriteLine(result);
Console.WriteLine(c.Entry(testUser).State);
testUser = c.Users.Find("test")!;
Console.WriteLine(testUser);
| 결과
Unchanged
Deleted
1
Detached

각각의 동작에 따라 상태가 Detached, 'Unchanged', 'Added, 'Modified', 'Deleted' 됨을 확인할 수 있습니다.
탐색 속성의 상태 변경 추적
변경에 대한 추적은 일반 엔터티 및 일반 속성에만 해당되지 않습니다. test라는 사용자를 추가 한 후 다음의 코드를 실행해봅시다.
var testUser = c.Users.Find("test")!;
Console.WriteLine(testUser);
Console.WriteLine(testUser.Todos);
| 결과
UserInfo { UserId = test, UserName = 테스트, Todos = }
탐색 속성인 Todos는 null입니다. 질의를 수정해봅시다.
var testUser = c.Users.Include(x => x.Todos).First(x => x.UserId == "test");
Console.WriteLine(testUser);
Console.WriteLine(testUser.Todos);
| 결과
UserInfo { UserId = test, UserName = 테스트, Todos = System.Collections.Generic.HashSet`1[TodoApp.Entities.TodoInfo] }
System.Collections.Generic.HashSet`1[TodoApp.Entities.TodoInfo]
이제 결과가 다르게 나옵니다. Include를 이용해 Join 질의를 했으며 이렇게 탐색 속성과 함께 질의를 했을 때 탐색 속성도 상태 변경 추적의 대상이 됩니다.
할 일과 태그를 동시에 업데이트 하기
다음의 코드를 통해 test사용자의 할 일과 할 일에 태그를 기록해서 데이터베이스에 업데이트 해봅시다.
var testUser = c.Users.Include(x => x.Todos).First(x => x.UserId == "test");
var newTodo = new TodoInfo
{
//UserId = "test",
TodoDate = DateOnly.FromDateTime(DateTime.Now),
IsComplete = false,
IsDel = false,
Memo = "탐색 속성 변경 추적 확인",
Tags = new TodoTagInfo[] { new() { TagId = "dev", Descption = "개발" }, new() { TagId = "test", Descption = "테스트" } }
};
testUser.Todos.Add(newTodo);
var result = c.SaveChanges();
Console.WriteLine(result);
| 결과
5
| TodoInfo 테이블

| TodoTagInfo 테이블

| TodoInfoTodoTagInfo 테이블 (다대다 탐색 속성에 의한 자동 생성 테이블)

코드는 단지 하나의 TodoInfo를 삽입했을 뿐인데 총 5개의 업데이트가 이루어졌는데요, 이것은 탐색 속성 또한 상태 추적의 대상이 되기 때문입니다.
잘 업데이트 되었는지 다음의 코드를 통해 확인해봅시다.
var testUser = c.Users.Include(x => x.Todos).First(x => x.UserId == "test");
foreach (var testTodos in testUser.Todos)
{
Console.WriteLine(testTodos);
}
| 결과
TodoInfo { Seq = 2, TodoDate = 2022-06-27, CompleteDate = , IsComplete = False, Memo = 탐색 속성 변경 추적 확인, IsDel = False, UserId = test, User = UserInfo { UserId = test, UserName = 테스트, Todos = System.Collections.Generic.HashSet`1[TodoApp.Entities.TodoInfo] }, Histories = , Tags = }
기대하는 Tags에 아무런 값이 없는데요, Include할 대상을 알아서 넣어주지는 않습니다. 다음처럼 변경해봅시다.
var testUser = c.Users.Include(x => x.Todos).ThenInclude(y => y.Tags).First(x => x.UserId == "test");
foreach (var testTodos in testUser.Todos)
{
Console.WriteLine(testTodos.Memo);
foreach (var tag in testTodos.Tags)
Console.WriteLine($"Tags : {tag.TagId} - {tag.Descption}");
}
데이터 지연 로드
질의 시 필요한 데이터를 모두 질의 하는 방법도 있지만 지연로드를 이용해 필요한 시점에서 (속성이 읽혀지는 시점에서) 추가 질의를 통해 데이터를 가져올 수도 있습니다. 이를 활성화 하려면 아래의 지침을 따릅니다.
Microsoft.EntityFrameworkCore.Proxies패키지 추가DbContext의OnConfiguring()에.UserLazyLoadingProxies()추가protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder .UseLazyLoadingProxies() .UseSqlServer(myConnectionString);- 또는 AddDbContext를 사용하는 경우:
이를 통해.AddDbContext<BloggingContext>( b => b.UseLazyLoadingProxies() .UseSqlServer(myConnectionString));virtual탐색 속성은 지연된 로드를 사용하게 됩니다. 또는ILazyLoader서비스 참조를 통해 지연로드를 수동으로 사용할 수 도 있습니다.
연결되지 않은 엔터티 추적
DbContext 인스턴스를 생성해서 수행하는 추적 기능은 상당히 유용하지만 대부분의 HTTP 요청 웹 서비스의 경우 서버와 클라이언트의 데이터의 주고 받음에 의해 DbContext와의 연결이 끊어진 상태가 됩니다. 이때 끊어진 추적을 시작하려면 다음과 같이 수행합니다.
context.Attach(
new Blog { Id = 1, Name = ".NET Blog", });
위의 예시는 단순성을 위해
new를 사용하였지만 실제로는 클라이언트에서 수신된 데이터를 이용하게 됩니다.
변경 내용 추적기 디버깅
변경 내용에 대한 추적 내용은 ChangeTracker을 통해 얻을 수 있습니다. Tracked 이벤트를 통해 추적된 내용이 변경되었을 때 이벤트 수신을 통해 내용을 확인할 수 있습니다. 또한 디버그 모드에 유용한 ChangeTracker.DebugView를 통해 추적된 이력을 확인할 수 있습니다.
정리
오늘은 EF Core의 변경 내용 추적에 대해 살펴보았습니다. EF Core 문서 변경 내용 추적을 통해 좀 더 상세한 내용을 살펴보실 수 있습니다. 다음 시간에는 EF Core가 생성하는 쿼리를 로그를 통해 확인하는 방법을 살펴보도록 하겠습니다.
https://github.com/dimohy/efcore-learning/tree/main/5.%20%EB%B3%80%EA%B2%BD%20%EC%B6%94%EC%A0%81%20%EA%B8%B0%EB%8A%A5/TodoApp