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

newUserc.UsersAdd했을 때 상태가 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 =  }

탐색 속성인 Todosnull입니다. 질의를 수정해봅시다.

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 패키지 추가
  • DbContextOnConfiguring().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가 생성하는 쿼리를 로그를 통해 확인하는 방법을 살펴보도록 하겠습니다.

6개의 좋아요

와… 정말 도움되는 정보네요! 감사합니다!

1개의 좋아요

EF에서 EF Core 7으로 개발중인데 바뀐게 많네요.
좋은정보 감사합니다.

1개의 좋아요