EF Core Change Tracking: 당신이 무심코 만든 버그 공장

발행: (2026년 1월 19일 오후 10:25 GMT+9)
16 min read
원문: Dev.to

Source: Dev.to

죄송하지만, 번역하려는 실제 본문 텍스트가 제공되지 않았습니다. 번역이 필요한 전체 글이나 특정 부분을 여기 채팅에 복사해 주시면, 요청하신 대로 마크다운 서식과 코드 블록을 그대로 유지하면서 한국어로 번역해 드리겠습니다.

Source:

대부분의 프로덕션 EF Core 문제는 깨진 코드에서 시작되지 않는다

코드가 정상적으로 동작한다는 전제에서 시작한다.

쿼리는 데이터를 반환하고, 업데이트는 성공하며, 성능도 괜찮아 보인다. 그런데 트래픽이 증가하고, 메모리 사용량이 급증하고, SQL 로그가 폭발하거나, 더 나아가 아무도 건드리지 않은 곳에서 무작위 기본키 위반 오류가 나타난다. 누군가가 AsNoTracking()을 켜거나 끄면 시스템이 안정되고, 팀은 다음 단계로 넘어간다.

다시 같은 일이 (다른 곳에서) 발생한다.

이 글은 우리가 어떻게 그런 상황에 이르게 되었는지, 왜 그런 버그가 발생하는지, 그리고 트래킹을 패닉 버튼처럼 토글하지 않도록 데이터 접근을 설계하는 방법에 대해 다룬다.


이야기가 보통 시작되는 방식

EF Core는 기본적으로 변경 트래킹을 활성화한다.

그것은 합리적으로 들린다. 엔티티를 쿼리하고, 수정하고, SaveChanges()를 호출한다. EF가 무엇이 바뀌었는지 파악하고 SQL을 생성한다. 간단하다.

그 기본값은 조용히 프로덕션에 스며든다.

  • 모든 쿼리가 엔티티를 트래킹한다.
  • 오래 살아있는 DbContext가 엔티티를 누적한다.
  • 메모리 사용량이 증가한다.
  • GC 압력이 커진다.
  • 지연 시간이 늘어난다.

누군가 애플리케이션을 프로파일링하면서 메모리 안에 아무 일도 하지 않는 수천 개의 트래킹된 엔티티를 발견한다.

수정은 명확해 보인다.

services.AddDbContext(options =>
{
    options.UseSqlServer(conn);
    // 모든 곳에 적용
    options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

성능이 즉시 개선되고, 메모리가 감소한다. 모두가 안도한다.

그런데 첫 번째 관계 업데이트가 중복 키 예외와 함께 실패한다.


여러분이 보고 무시해 온 버그

변경 전에는 다음이 정상적으로 동작했다:

var order = await context.Orders
    .Include(o => o.Customer)
    .FirstAsync(o => o.Id == orderId);

order.Customer = existingCustomer;

await context.SaveChangesAsync();

전역적으로 트래킹을 비활성화한 뒤, 동일한 코드는 다음과 같은 예외를 던진다:

Cannot insert duplicate key row in object 'Customers'
The duplicate key value is (...)

코드 자체는 바뀌지 않았다. 데이터베이스도 바뀌지 않았다. 바뀐 것은 트래킹뿐이다.

이는 EF Core 버그가 아니다. 여러분이 요구한 대로 EF Core가 동작했을 뿐, 기대와는 달랐을 뿐이다.


실제 변경 트래킹이란 무엇인가

변경 트래킹은 EF Core가 책임지는 엔티티들의 내부 그래프를 유지하는 것이다.

트래킹된 엔티티는 다음을 가지고 있다:

  • 알려진 식별자(기본키)
  • 알려진 상태(Unchanged, Modified, Added, Deleted)
  • 스냅샷 또는 프록시 기반 변경 감지

SaveChanges()를 호출하면 EF는:

  1. 트래킹된 엔티티를 원래 상태와 비교한다
  2. 변경된 부분에 대해서만 SQL을 생성한다
  3. 관계 일관성을 자동으로 유지한다

엔티티가 트래킹되지 않으면, EF는 위 과정을 전혀 수행하지 않는다. 상태도, 식별자 맵도, 관계 인식도 없다.


AsNoTracking()이 존재하는 이유

AsNoTracking()은 EF에 다음을 알린다:

“이 데이터는 읽기 전용이다. 메모리나 CPU를 낭비하지 말고 트래킹하지 말라.”

이는 다음과 같은 경우에 정확하고 유용하다:

  • 대용량 결과 집합
  • 읽기 중심 엔드포인트
  • 보고서 생성
  • 절대로 저장되지 않을 프로젝션

예시:

var orders = await context.Orders
    .AsNoTracking()
    .Where(o => o.Status == Status.Open)
    .ToListAsync();

이는 기본 트래킹보다 빠르고, 가볍고, 안전하다. 트래킹을 사용할 때처럼 나중에 사용할 데이터를 메모리에 보관하지 않는다.

문제는 결과를 이해하지 못하고 모든 곳에 무분별하게 적용하는 것이다.


명령과 쿼리는 같은 것이 아니다

쿼리가 원하는 것명령이 원하는 것
속도상태 인식
낮은 메모리 사용량관계 처리
부작용 없음정확한 업데이트

두 경우에 동일한 트래킹 전략을 적용하는 것이 대부분의 시스템이 깨지는 지점이다.


SaveChanges()가 분리된 엔티티에서 아무 일도 하지 않는 이유

var user = await context.Users
    .AsNoTracking()
    .FirstAsync(u => u.Id == id);

user.Name = "New Name";

await context.SaveChangesAsync();

오류도 없고, 업데이트도 없으며, 아무 일도 일어나지 않는다.

왜일까? EF가 user를 트래킹하고 있지 않기 때문이다. EF의 입장에서 user는 **분리(detached)**된 엔티티이므로, SaveChanges()는 이를 무시한다.

Source:

perspective, nothing changed because nothing was being watched. This is a silent failure—the worst kind.


중복 키를 발생시키는 관계 함정

var order = await context.Orders
    .AsNoTracking()
    .FirstAsync(o => o.Id == orderId);

order.Customer = existingCustomer;

context.Orders.Update(order);
await context.SaveChangesAsync();

EF가 보는 것:

  • 추적되지 않은 Order
  • 참조된 Customer 객체
  • 두 객체 모두에 대한 추적 정보가 없음

따라서 두 객체가 모두 새 것으로 간주됩니다.

EF가 생성하는 쿼리:

  • Order에 대한 INSERT
  • Customer에 대한 INSERT

고객이 이미 존재한다면 데이터베이스에서 거부합니다. 이 때문에 전역적으로 추적을 비활성화하면 “성능이 향상된다”는 느낌을 받을 수 있지만, 관계가 깨지는 경우가 많습니다.


Attach vs. Update vs. Already Tracked

이 세 가지는 서로 교환해서 사용할 수 없습니다.

추적된 엔터티 (최선의 경우)

var product = await context.Products.FirstAsync(p => p.Id == id);
product.Price += 10;

await context.SaveChangesAsync();

// Generated SQL:
// UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
  • EF가 변경을 추적합니다
  • 변경된 컬럼만 업데이트됩니다
  • 최소한의 SQL이 실행됩니다

Attach

var product = await context.Products.AsNoTracking().FirstAsync(p => p.Id == id);
context.Attach(product);

product.Price += 10;

await context.SaveChangesAsync();

// Generated SQL:
// UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
  • EF는 엔터티가 이미 존재한다고 가정합니다
  • 변경된 속성만 업데이트됩니다
  • 엔터티가 새 것이 아니라는 것을 확신할 때 안전합니다

Update

var product = await context.Products.AsNoTracking().FirstAsync(p => p.Id == id);
product.Price += 10;

context.Update(product);
await context.SaveChangesAsync();

// Generated SQL:
// UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
  • EF가 전체 엔터티를 Modified 상태로 표시합니다
  • 변경되지 않은 스칼라 속성까지 모두 데이터베이스에 전송됩니다
  • 분리된(detached) 엔터티를 기존 것으로 취급하고 싶지만 변경 감지는 신경 쓰지 않을 때 사용합니다

정리

  1. 쓰기 작업에는 추적을 켜 두세요.
  2. AsNoTracking()은 진정한 읽기 전용 시나리오에만 사용하세요.
  3. 분리된 데이터를 다룰 때는 엔터티를 Attach하거나 Update 중 하나만 명시적으로 사용하고, 둘 다 사용하지 마세요.
  4. 애플리케이션이 읽기 전용임을 확신하지 않는 한 “전역 No‑Tracking” 설정은 피하세요.

EF Core가 내부에서 무엇을 하고 있는지 이해하면 AsNoTracking()을 비상 버튼이 아니라 설계된 성능 도구로 활용할 수 있습니다.

context.Update(product); // 모든 컬럼을 업데이트하도록 강제합니다
await context.SaveChangesAsync();

Update() 호출 시 일어나는 일

  • EF가 모든 속성을 수정된 것으로 표시
  • 전체 행 UPDATE 문을 생성
  • 대용량 SQL 문이 생성
  • 수정하지 않은 컬럼까지 덮어쓰기
UPDATE [Products]
SET [Price] = @p0,
    [Name] = @p1,
    [Description] = @p2,
    [Stock] = @p3,
    [CategoryId] = @p4
WHERE [Id] = @p5;

이미 추적되고 있는 엔터티에 Update()를 호출하는 것은 불필요하고 비효율적입니다—EF는 이미 어떤 것이 변경됐는지 알고 있기 때문입니다.

실용 데모 시나리오

1. 추적된 업데이트 작동

var user = await context.Users.FirstAsync(u => u.Id == id);
user.Email = "new@email.com";
await context.SaveChangesAsync();

결과: 최소한의 SQL로 올바른 업데이트.


2. AsNoTracking 로 인한 무음 무동작

var user = await context.Users
    .AsNoTracking()
    .FirstAsync(u => u.Id == id);

user.Email = "new@email.com";
await context.SaveChangesAsync();

결과: 업데이트가 없으며, 오류나 경고도 발생하지 않음.


3. AsNoTracking + Add 로 인한 중복 키 오류

var role = await context.Roles
    .AsNoTracking()
    .FirstAsync(r => r.Id == roleId);

context.Roles.Add(role);
await context.SaveChangesAsync();

결과: EF가 기존 역할을 삽입하려고 시도 → 중복 키 오류 발생.


4. Attach 로 수정된 컬럼만 업데이트

context.Attach(user);
user.IsActive = false;
await context.SaveChangesAsync();

결과: 의도적으로 사용될 경우 깔끔하고 안전함.


5. Update 로 전체 행 업데이트 강제

context.Update(user);
await context.SaveChangesAsync();

결과: 모든 컬럼이 수정된 것으로 표시 → 더 큰 SQL, 위험도 증가.

The Real Fix: Stop Toggling Tracking

문제는 EF Core 자체가 아니라 읽기와 쓰기 의도를 같은 데이터‑접근 경로에서 혼합하는 것입니다.
해결책은 구조적으로 의도에 따라 리포지토리를 분리하는 것입니다.


Read‑Only Repository

public class OrderReadRepository
{
    private readonly DbContext _context;

    public OrderReadRepository(DbContext context) => _context = context;

    public Task<OrderDto> GetById(Guid id) =>
        _context.Orders
                .AsNoTracking()
                .Where(o => o.Id == id)
                .Select(o => new OrderDto(/* … */))
                .FirstAsync();
}

Characteristics

  • 항상 no‑tracking
  • 설계상 안전함
  • 캐시하기 쉬움
  • 나중에 읽기 복제본에 대비 가능

Read‑Write Repository

public class OrderWriteRepository
{
    private readonly DbContext _context;

    public OrderWriteRepository(DbContext context) => _context = context;

    public Task<Order> GetTracked(Guid id) =>
        _context.Orders.FirstAsync(o => o.Id == id);

    public Task Save() => _context.SaveChangesAsync();
}

Characteristics

  • 트래킹이 예상됨
  • 관계가 올바르게 작동
  • 업데이트가 정확함
  • 플래그도 추측도 없음

중앙 집중식 기본 저장소 (선택 사항)

읽기‑전용 기본

public abstract class ReadOnlyRepository<TEntity> where TEntity : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<TEntity> _dbSet;

    protected ReadOnlyRepository(DbContext context)
    {
        _context = context;
        _dbSet = context.Set<TEntity>();
        _context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
    }

    public virtual IQueryable<TEntity> GetAll() => _dbSet;
    public virtual Task<TEntity> GetByIdAsync(object id) => _dbSet.FindAsync(id).AsTask();
}

읽기‑쓰기 기본

public abstract class ReadWriteRepository<TEntity> where TEntity : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<TEntity> _dbSet;

    protected ReadWriteRepository(DbContext context)
    {
        _context = context;
        _dbSet = context.Set<TEntity>();
        _context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.TrackAll;
    }

    public virtual IQueryable<TEntity> GetAll() => _dbSet;
    public virtual Task<TEntity> GetByIdAsync(object id) => _dbSet.FindAsync(id).AsTask();

    public virtual void Add(TEntity entity) => _dbSet.Add(entity);
    public virtual void Update(TEntity entity) => _dbSet.Update(entity);
    public virtual void Remove(TEntity entity) => _dbSet.Remove(entity);
    public virtual Task SaveChangesAsync() => _context.SaveChangesAsync();
}

이것이 단순히 “클린‑코드 오버헤드”가 아닌 이유

  • 우발적인 추적을 제거합니다
  • 묵시적인 무동작을 방지합니다
  • 중복 키 버그를 방지합니다
  • 성능을 예측 가능하게 합니다
  • 의도를 명시적으로 만듭니다

애플리케이션 레이어 사용

  • GET 엔드포인트는 읽기 전용 리포지토리를 주입합니다.
  • Command 핸들러는 쓰기 리포지토리를 주입합니다.

이 패턴은 자연스럽게 확장됩니다:

  • 후에 읽기 복제본, 캐시 레이어 또는 별도 데이터베이스를 사용할 수 있게 합니다.
  • 전체 CQRS가 필요하지 않습니다; 읽기와 쓰기 의도를 구분하기만 하면 됩니다.

Lessons Learned

  • Tracking은 강력하지만 비용이 많이 듭니다.
  • AsNoTracking읽기 전용일 때만 안전합니다.
  • SaveChanges분리된 엔터티에 대해 아무 작업도 하지 않습니다.
  • Update거친 도구입니다.
  • Attach는 올바르게 사용하면 정밀합니다.
  • Repository 설계는 쿼리와 명령 의도를 반영해야 합니다.

이렇게 생각하기 위해 전체 CQRS가 필요하지 않습니다—읽기와 쓰기가 같은 것이라고 착각하는 것을 멈추세요. EF Core가 문제였던 것이 아니라 기본값이 문제였습니다. 이를 이해하면, 수년간 무시해 온 버그들이 갑자기 의미 있게 보입니다.

Back to Blog

관련 글

더 보기 »

데이터베이스 트랜잭션 누수

소개 우리는 memory leaks에 대해 자주 이야기하지만, backend development에서 또 다른 조용한 성능 저해 요인이 있습니다: Database Transaction Leaks. 나는 최근에 ...