HomeCategoriesTags
 
 
 
 
 
 
 
 
 
 
 
 

EF Core에서 분명히 INSERT인데 UPDATE가 나가는 이유

Bybbon

Categories:

  • Blog

요약

  • 증상: 빈 테이블에 새 레코드를 넣는 코드인데, 매번 결정적으로 DbUpdateConcurrencyException("expected to affect 1 row(s), but actually affected 0 row(s)")이 발생했다.
  • 원인: 자식 엔티티의 기본 키가 ValueGeneratedOnAdd()(DB/EF가 생성하는 키)로 설정된 상태에서, 코드가 그 키 값을 직접 채운 뒤 DbContext.Add()가 아니라 부모의 컬렉션 네비게이션 프로퍼티에만 Add() 했다. EF Core는 "키 값이 이미 채워져 있다 = 이미 DB에 존재하는 엔티티"로 오판해 Unchanged 상태로 추적했고, 이후 필드 값을 채우자 Modified로 전이되어 존재하지 않는 행에 UPDATE를 시도했다.
  • 해결: 신규 엔티티 생성 분기에서 DbContext.Add(entity)를 명시적으로 호출해 Added 상태를 강제했다.
  • 핵심 교훈: 이 버그는 컴파일 타임에 절대 잡히지 않는다. dotnet build가 통과해도, 심지어 여러 번의 코드 리뷰를 거쳐도 존재할 수 있다. 검증하려면 ChangeTracker 디버그 뷰나 실제 실행 후 DB 상태를 직접 확인해야 한다.

배경: 문제를 처음 마주한 상황

REST API에서 부모 리소스를 부분 수정(PATCH)하면서, 그 하위에 딸린 여러 개의 자식 레코드(예: 첨부파일, 옵션 목록 같은 1:N 컬렉션)를 "요청 배열과 DB 상태를 맞추는" 방식으로 upsert하는 핸들러가 있었다. 로직은 흔한 패턴이다.

  1. 부모 엔티티를 자식 컬렉션까지 Include해서 EF로 조회한다(추적 활성화 상태).
  2. 요청 배열을 순회하며, id가 있고 그 id가 기존 컬렉션에 있으면 필드를 갱신(update)하고, 없으면 새 엔티티를 만들어 추가(insert)한다.
  3. 요청 배열에 없는 기존 자식은 삭제한다.
  4. SaveChangesAsync()를 한 번 호출해 전부 반영한다.

문제가 된 것은 정확히 이 중 "새 엔티티를 만들어 추가"하는 부분이었다. 처음 겪은 현상은 이랬다.

  • 자식 테이블에 레코드가 하나도 없는 상태에서 PATCH 요청을 보냈다.
  • id 없이(신규로 인식되어야 할) 자식 항목 하나를 배열에 담아 보냈다.
  • 매번 예외 없이 DbUpdateConcurrencyException이 발생했다: "1개 행이 영향받아야 했는데 0개 행이 영향받았다."

잘못된 원인 추론 두 가지 (디버깅 과정 기록)

바로 정답에 도달하지 못했다. 실제로는 두 번의 그럴듯하지만 틀린 결론을 거쳤다. 이 과정 자체가 이런 종류의 버그를 다룰 때 유용한 반면교사라 함께 남긴다.

오판 1: "동시 요청 경합(레이스 컨디션)"일 것이다

자식 엔티티(예: OrderLineItem)에는 concurrency token(RowVersion/Timestamp)이 없었다. 따라서 UPDATE ... WHERE Id=@id가 0행에 매치되는 유일한 논리적 설명은 "그 행을 로드한 시점과 저장한 시점 사이에 행 자체가 사라졌다"는 것처럼 보였다. 같은 리소스에 대해 PATCH 요청이 거의 동시에 두 번 들어와서, 먼저 커밋된 요청이 그 자식 레코드를 삭제하고 뒤늦은 요청이 이미 사라진 레코드를 갱신하려 한 것이라 추론했다.

이 추론에 따라 방어적으로 DbUpdateConcurrencyException을 409 Conflict로 클라이언트에 명시적으로 반환하도록 고쳤다. 이 자체는 나쁜 개선이 아니었지만(클라이언트가 재시도할 근거를 주니까), 근본 원인은 아니었다.

오판 2: "삽입 판단 로직이 아니라 전체 교체 시맨틱 때문"일 것이다

다음으로 의심한 것은 "PATCH 요청의 배열이 id를 포함하지 않은 기존 자식을 자동으로 삭제하는 정책 때문에, 새 항목이 삽입되면서 동시에 기존 항목이 삭제돼 update처럼 보이는 것"이라는 가설이었다. 코드를 라인 단위로 추적한 결과 "삽입/수정 판단 로직 자체는 정상"이라는 결론에 도달했고, 이 결론에 만족하고 넘어갈 뻔했다.

그런데 자식 테이블이 완전히 비어 있는 상태에서도, 그것도 매번 결정적으로 같은 예외가 재현된다는 사실을 추가로 확인하면서 이 결론도 무너졌다. 테이블에 삭제할 기존 행 자체가 없으니 "전체 교체로 인한 부수 효과" 이론은 성립할 수 없었다. 재현이 레이스 컨디션이라면 실행마다 발생 여부가 달라져야 하는데, 100% 결정적으로 재현된다는 점도 오판 1을 무너뜨리는 결정적 반증이었다.

진짜 원인: 생성된 키(Generated Key) + 컬렉션 네비게이션 Add

두 이론 모두 "왜 UPDATE 문이 존재하지 않는 행을 대상으로 실행되는가"라는 질문에 답하지 못했다. 진짜 원인은 EF Core의 상태 추적(Change Tracking) 메커니즘에 있었다.

문제가 되는 코드 패턴 (일반화한 예시)

아래는 실제 도메인 이름을 걷어내고, 동일한 구조적 문제를 재현하는 일반적인 예시다. Order(부모)와 OrderLineItem(자식, GUID 기본 키를 DB/EF가 생성하도록 설정)을 가정한다.

public class OrderLineItem
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public string ProductName { get; set; }
    public int Quantity { get; set; }
}
public class OrderLineItemConfiguration : IEntityTypeConfiguration<OrderLineItem>
{
    public void Configure(EntityTypeBuilder<OrderLineItem> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .IsRequired()
            .ValueGeneratedOnAdd(); // 핵심: "생성된 키"로 설정됨
        // ... 나머지 매핑 생략
    }
}

PATCH/Upsert 핸들러 코드:

// order는 이미 Include(x => x.LineItems)로 조회되어 추적 중인 상태(Unchanged)
var input = ...; // 요청 배열의 한 항목. input.Id는 null (신규 항목)

var entity = input.Id.HasValue
    ? order.LineItems.FirstOrDefault(x => x.Id == input.Id.Value)
    : null;

if (entity == null)
{
    entity = new OrderLineItem
    {
        Id = Guid.NewGuid(),      // ← 키 값을 미리 채운다
        OrderId = order.Id,
    };
    order.LineItems.Add(entity); // ← DbContext.Add()가 아니라 "컬렉션 네비게이션"에만 추가
}

entity.ProductName = input.ProductName;
entity.Quantity = input.Quantity;

await context.SaveChangesAsync(); // 여기서 UPDATE가 나간다 (INSERT가 아니라)

겉보기엔 문제가 없어 보인다. Id를 미리 채우는 것도, 자식 컬렉션에 Add()하는 것도 EF Core를 조금 다뤄본 개발자에게는 흔한 패턴이다. 하지만 이 두 가지가 겹치면 문제가 생긴다.

Microsoft 공식 문서가 명시하는 규칙

EF Core 공식 문서(Changing Foreign Keys and Navigations — Fixup for added or deleted entities)는 이 동작을 정확히 설명한다.

EF Core performs the following actions when it detects that a new dependent/child entity has been added to a collection navigation:

  1. If the entity is not tracked, then it is tracked. (The entity will usually be in the Added state. However, if the entity type is configured to use generated keys and the primary key value is set, then the entity is tracked in the Unchanged state.)

즉, 이미 추적 중인 부모(order)의 컬렉션 네비게이션에 새 자식을 Add()하면 EF Core는 그 시점에 "관계 픽스업(relationship fixup)"을 수행하며 자식의 추적 상태를 결정하는데, 자식의 키가 "생성된 키"로 설정되어 있고 값이 이미 채워져 있으면 Added가 아니라 Unchanged로 추적한다. "키 값이 이미 있다는 것은 DB에서 조회되어 온 기존 엔티티라는 뜻"이라고 EF가 판단하기 때문이다.

이 판단 기준은 Keys 문서에도 명시되어 있다.

Important If a key property has its value generated by the database and a non-default value is specified when an entity is added, then EF will assume that the entity already exists in the database and will try to update it instead of inserting a new one. To avoid this, turn off value generation or see how to specify explicit values for generated properties.

Unchanged로 추적된 직후, 핸들러 코드는 entity.ProductName = ..., entity.Quantity = ...처럼 필드 값을 채운다. EF Core의 변경 감지(change detection)는 이 값 변화를 감지해 Unchanged → Modified로 전이시킨다. 그리고 SaveChangesAsync()가 실행되면, Modified 상태의 엔티티는 UPDATE ... WHERE Id=@id 문으로 저장을 시도한다. 그런데 그 Id는 방금 코드에서 막 생성한 새 GUID이므로 DB에 존재한 적이 없다 — 그래서 0행이 영향받고, EF Core는 이를 낙관적 동시성 충돌(optimistic concurrency conflict)로 해석해 DbUpdateConcurrencyException을 던진다.

이 설명은 처음 관찰했던 모든 증상과 정확히 들어맞는다.

  • 왜 결정적으로(레이스 없이) 매번 재현되는가: 코드 경로 자체가 매번 Unchanged로 시작해 Modified로 전이되는 구조이기 때문에, 동시 요청이 전혀 없어도 항상 발생한다.
  • 왜 테이블이 비어 있어도 발생하는가: 애초에 UPDATE 대상 행이 존재한 적이 없으므로, "누군가 지웠다"는 가정 자체가 필요 없다.
  • id를 명시적으로 보낸 케이스에서도 똑같이 재현됐던 이유: 클라이언트가 보낸 id가 컬렉션에서 못 찾은 것이라면(신규로 처리되어야 하는 경우), 이 함정 코드 경로는 그 클라이언트 제공 id 값을 그대로 키에 채우고 컬렉션에 Add()하므로 동일한 문제가 발생한다.

대조: DbContext.Add()는 다르게 동작한다

같은 문서가 인용하는 Behavior Changes between EF6 and EF Core는 이 함정을 피하는 방법도 함께 알려준다.

This change of behavior only applies to the Attach and Update method groups. Add always puts entities in the Added state, even if the key is set.

즉, DbContext.Add(entity) 또는 DbSet<T>.Add(entity)를 직접 호출하면, 키 값이 이미 채워져 있어도 무조건 Added로 추적한다. 함정은 "컬렉션 네비게이션에만 Add()한 경우"에 한정된다.

해결 방법

가장 낮은 리스크의 수정은 기존 컬렉션 Add() 호출은 그대로 두고(다른 로직이 그 컬렉션 상태에 의존할 수 있으므로), 바로 다음 줄에 DbContext.Add(entity)를 추가해 상태를 명시적으로 Added로 재확정하는 것이다.

if (entity == null)
{
    entity = new OrderLineItem
    {
        Id = Guid.NewGuid(),
        OrderId = order.Id,
    };
    order.LineItems.Add(entity); // 컬렉션 상태 유지를 위해 그대로 둠
    context.Add(entity);         // 상태를 명시적으로 Added로 재확정
}

OrderId(외래 키)가 이미 설정되어 있고 부모(order)가 이미 추적 중이므로, context.Add(entity)가 그래프를 순회하더라도 이미 추적 중인 order는 건드리지 않고 새 자식 엔티티만 Added로 표시한다. 이 부분은 EF Core의 관계 픽스업이 "새로 발견된 엔티티만" 상태를 결정하고 이미 추적 중인 엔티티는 그대로 둔다는 원칙에 따른 것이다.

대안으로는 .ValueGeneratedOnAdd() 자체를 제거하는 방법도 문서에 언급되어 있지만, 이는 모델/마이그레이션 변경을 동반하고 그 키에 의존하는 다른 코드 경로에도 영향을 줄 수 있어 변경 범위가 훨씬 커진다. 이번처럼 국소적인 문제라면 Add() 호출을 명시하는 쪽이 더 안전한 선택이다.

왜 코드 리뷰와 빌드로는 못 잡을까

이 버그의 가장 위험한 특징은 정적으로 관측 불가능하다는 점이다.

  • dotnet build는 타입 체크만 할 뿐, 런타임 엔티티 상태 전이는 전혀 알지 못한다. 이 세션에서도 문제가 있는 코드 상태로 여러 차례 "0 Errors" 빌드를 통과시켰다.
  • 코드만 읽으면 Add()를 호출했으니 "당연히 삽입되겠지"라고 판단하기 쉽다. review.GeneralPublicPhotos.Add(entity)(컬렉션에 Add)와 context.Add(entity)(DbContext에 Add)는 이름과 겉모습이 비슷하지만 완전히 다른 추적 규칙을 따른다는 사실을 모르면 리뷰에서도 놓치기 쉽다.
  • 단위 테스트가 InMemory 프로바이더를 쓴다면 이 문제가 재현되지 않을 수도 있다(프로바이더별 세부 동작 차이가 있을 수 있으므로, 실제 검증은 대상 프로바이더로 직접 해야 한다).

따라서 이런 유형의 버그를 검증하려면 다음이 필요하다.

  1. SaveChangesAsync() 직전에 context.ChangeTracker.DebugView.ShortView(또는 LongView)를 로그로 남겨, 대상 엔티티가 실제로 Added 상태인지 직접 확인한다.
  2. 실제 실행 후 DB에 행이 생성됐는지, 혹은 예상한 개수만큼 upsert/delete가 일어났는지 직접 조회로 확인한다.

체크리스트

GUID/생성된 키를 쓰는 엔티티를 다룰 때 아래에 해당하면 이 함정을 의심해 볼 가치가 있다.

  • 자식 엔티티의 PK가 ValueGeneratedOnAdd()(또는 그에 준하는 생성된 키) 설정인가?
  • 코드가 그 PK 값을 (DB가 아니라) 애플리케이션 코드에서 직접 채우고 있는가(Guid.NewGuid() 등)?
  • 새 엔티티를 context.Add(...)/DbSet.Add(...)가 아니라 이미 추적 중인 부모의 컬렉션 네비게이션에만 .Add(...)하고 있는가?
  • 그 직후 엔티티의 필드 값을 설정하는 코드가 있는가(→ Unchanged → Modified 전이를 유발)?

위 네 가지가 모두 해당한다면, SaveChangesAsync()는 INSERT가 아니라 UPDATE를 시도하다가 대상 행이 없어 DbUpdateConcurrencyException을 던질 가능성이 높다.

참고 자료 (출처)

이 글의 원인 분석은 Microsoft Learn(learn.microsoft.com) 공식 EF Core 문서를 근거로 작성했다(확인일: 2026-08-18).

  • Keys — Key types and values
  • Changing Foreign Keys and Navigations — Fixup for added or deleted entities
  • Behavior Changes between EF6 and EF Core — DbSet.Add/Attach and graph behavior
  • Explicitly Tracking Entities — Attaching existing entities
  • Explicitly Tracking Entities — Inserting new entities
  • Disconnected entities — Identifying new entities
  • Disconnected entities — Saving single entities
  • Identity Resolution in EF Core — Failing to set key values
  • Accessing Tracked Entities — Using DbContext.Entry and EntityEntry instances

검증 환경: .NET 8, EF Core 8.0.4, SQL Server(EF Core SQL Server 프로바이더).


Tags

  • efcore
  • dotnet
  • ms-sql-server

요약

  • 증상: 빈 테이블에 새 레코드를 넣는 코드인데, 매번 결정적으로 DbUpdateConcurrencyException("expected to affect 1 row(s), but actually affected 0 row(s)")이 발생했다.
  • 원인: 자식 엔티티의 기본 키가 ValueGeneratedOnAdd()(DB/EF가 생성하는 키)로 설정된 상태에서, 코드가 그 키 값을 직접 채운 뒤 DbContext.Add()가 아니라 부모의 컬렉션 네비게이션 프로퍼티에만 Add() 했다. EF Core는 "키 값이 이미 채워져 있다 = 이미 DB에 존재하는 엔티티"로 오판해 Unchanged 상태로 추적했고, 이후 필드 값을 채우자 Modified로 전이되어 존재하지 않는 행에 UPDATE를 시도했다.
  • 해결: 신규 엔티티 생성 분기에서 DbContext.Add(entity)를 명시적으로 호출해 Added 상태를 강제했다.
  • 핵심 교훈: 이 버그는 컴파일 타임에 절대 잡히지 않는다. dotnet build가 통과해도, 심지어 여러 번의 코드 리뷰를 거쳐도 존재할 수 있다. 검증하려면 ChangeTracker 디버그 뷰나 실제 실행 후 DB 상태를 직접 확인해야 한다.

배경: 문제를 처음 마주한 상황

REST API에서 부모 리소스를 부분 수정(PATCH)하면서, 그 하위에 딸린 여러 개의 자식 레코드(예: 첨부파일, 옵션 목록 같은 1:N 컬렉션)를 "요청 배열과 DB 상태를 맞추는" 방식으로 upsert하는 핸들러가 있었다. 로직은 흔한 패턴이다.

  1. 부모 엔티티를 자식 컬렉션까지 Include해서 EF로 조회한다(추적 활성화 상태).
  2. 요청 배열을 순회하며, id가 있고 그 id가 기존 컬렉션에 있으면 필드를 갱신(update)하고, 없으면 새 엔티티를 만들어 추가(insert)한다.
  3. 요청 배열에 없는 기존 자식은 삭제한다.
  4. SaveChangesAsync()를 한 번 호출해 전부 반영한다.

문제가 된 것은 정확히 이 중 "새 엔티티를 만들어 추가"하는 부분이었다. 처음 겪은 현상은 이랬다.

  • 자식 테이블에 레코드가 하나도 없는 상태에서 PATCH 요청을 보냈다.
  • id 없이(신규로 인식되어야 할) 자식 항목 하나를 배열에 담아 보냈다.
  • 매번 예외 없이 DbUpdateConcurrencyException이 발생했다: "1개 행이 영향받아야 했는데 0개 행이 영향받았다."

잘못된 원인 추론 두 가지 (디버깅 과정 기록)

바로 정답에 도달하지 못했다. 실제로는 두 번의 그럴듯하지만 틀린 결론을 거쳤다. 이 과정 자체가 이런 종류의 버그를 다룰 때 유용한 반면교사라 함께 남긴다.

오판 1: "동시 요청 경합(레이스 컨디션)"일 것이다

자식 엔티티(예: OrderLineItem)에는 concurrency token(RowVersion/Timestamp)이 없었다. 따라서 UPDATE ... WHERE Id=@id가 0행에 매치되는 유일한 논리적 설명은 "그 행을 로드한 시점과 저장한 시점 사이에 행 자체가 사라졌다"는 것처럼 보였다. 같은 리소스에 대해 PATCH 요청이 거의 동시에 두 번 들어와서, 먼저 커밋된 요청이 그 자식 레코드를 삭제하고 뒤늦은 요청이 이미 사라진 레코드를 갱신하려 한 것이라 추론했다.

이 추론에 따라 방어적으로 DbUpdateConcurrencyException을 409 Conflict로 클라이언트에 명시적으로 반환하도록 고쳤다. 이 자체는 나쁜 개선이 아니었지만(클라이언트가 재시도할 근거를 주니까), 근본 원인은 아니었다.

오판 2: "삽입 판단 로직이 아니라 전체 교체 시맨틱 때문"일 것이다

다음으로 의심한 것은 "PATCH 요청의 배열이 id를 포함하지 않은 기존 자식을 자동으로 삭제하는 정책 때문에, 새 항목이 삽입되면서 동시에 기존 항목이 삭제돼 update처럼 보이는 것"이라는 가설이었다. 코드를 라인 단위로 추적한 결과 "삽입/수정 판단 로직 자체는 정상"이라는 결론에 도달했고, 이 결론에 만족하고 넘어갈 뻔했다.

그런데 자식 테이블이 완전히 비어 있는 상태에서도, 그것도 매번 결정적으로 같은 예외가 재현된다는 사실을 추가로 확인하면서 이 결론도 무너졌다. 테이블에 삭제할 기존 행 자체가 없으니 "전체 교체로 인한 부수 효과" 이론은 성립할 수 없었다. 재현이 레이스 컨디션이라면 실행마다 발생 여부가 달라져야 하는데, 100% 결정적으로 재현된다는 점도 오판 1을 무너뜨리는 결정적 반증이었다.

진짜 원인: 생성된 키(Generated Key) + 컬렉션 네비게이션 Add

두 이론 모두 "왜 UPDATE 문이 존재하지 않는 행을 대상으로 실행되는가"라는 질문에 답하지 못했다. 진짜 원인은 EF Core의 상태 추적(Change Tracking) 메커니즘에 있었다.

문제가 되는 코드 패턴 (일반화한 예시)

아래는 실제 도메인 이름을 걷어내고, 동일한 구조적 문제를 재현하는 일반적인 예시다. Order(부모)와 OrderLineItem(자식, GUID 기본 키를 DB/EF가 생성하도록 설정)을 가정한다.

public class OrderLineItem
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public string ProductName { get; set; }
    public int Quantity { get; set; }
}
public class OrderLineItemConfiguration : IEntityTypeConfiguration<OrderLineItem>
{
    public void Configure(EntityTypeBuilder<OrderLineItem> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .IsRequired()
            .ValueGeneratedOnAdd(); // 핵심: "생성된 키"로 설정됨
        // ... 나머지 매핑 생략
    }
}

PATCH/Upsert 핸들러 코드:

// order는 이미 Include(x => x.LineItems)로 조회되어 추적 중인 상태(Unchanged)
var input = ...; // 요청 배열의 한 항목. input.Id는 null (신규 항목)

var entity = input.Id.HasValue
    ? order.LineItems.FirstOrDefault(x => x.Id == input.Id.Value)
    : null;

if (entity == null)
{
    entity = new OrderLineItem
    {
        Id = Guid.NewGuid(),      // ← 키 값을 미리 채운다
        OrderId = order.Id,
    };
    order.LineItems.Add(entity); // ← DbContext.Add()가 아니라 "컬렉션 네비게이션"에만 추가
}

entity.ProductName = input.ProductName;
entity.Quantity = input.Quantity;

await context.SaveChangesAsync(); // 여기서 UPDATE가 나간다 (INSERT가 아니라)

겉보기엔 문제가 없어 보인다. Id를 미리 채우는 것도, 자식 컬렉션에 Add()하는 것도 EF Core를 조금 다뤄본 개발자에게는 흔한 패턴이다. 하지만 이 두 가지가 겹치면 문제가 생긴다.

Microsoft 공식 문서가 명시하는 규칙

EF Core 공식 문서(Changing Foreign Keys and Navigations — Fixup for added or deleted entities)는 이 동작을 정확히 설명한다.

EF Core performs the following actions when it detects that a new dependent/child entity has been added to a collection navigation:

  1. If the entity is not tracked, then it is tracked. (The entity will usually be in the Added state. However, if the entity type is configured to use generated keys and the primary key value is set, then the entity is tracked in the Unchanged state.)

즉, 이미 추적 중인 부모(order)의 컬렉션 네비게이션에 새 자식을 Add()하면 EF Core는 그 시점에 "관계 픽스업(relationship fixup)"을 수행하며 자식의 추적 상태를 결정하는데, 자식의 키가 "생성된 키"로 설정되어 있고 값이 이미 채워져 있으면 Added가 아니라 Unchanged로 추적한다. "키 값이 이미 있다는 것은 DB에서 조회되어 온 기존 엔티티라는 뜻"이라고 EF가 판단하기 때문이다.

이 판단 기준은 Keys 문서에도 명시되어 있다.

Important If a key property has its value generated by the database and a non-default value is specified when an entity is added, then EF will assume that the entity already exists in the database and will try to update it instead of inserting a new one. To avoid this, turn off value generation or see how to specify explicit values for generated properties.

Unchanged로 추적된 직후, 핸들러 코드는 entity.ProductName = ..., entity.Quantity = ...처럼 필드 값을 채운다. EF Core의 변경 감지(change detection)는 이 값 변화를 감지해 Unchanged → Modified로 전이시킨다. 그리고 SaveChangesAsync()가 실행되면, Modified 상태의 엔티티는 UPDATE ... WHERE Id=@id 문으로 저장을 시도한다. 그런데 그 Id는 방금 코드에서 막 생성한 새 GUID이므로 DB에 존재한 적이 없다 — 그래서 0행이 영향받고, EF Core는 이를 낙관적 동시성 충돌(optimistic concurrency conflict)로 해석해 DbUpdateConcurrencyException을 던진다.

이 설명은 처음 관찰했던 모든 증상과 정확히 들어맞는다.

  • 왜 결정적으로(레이스 없이) 매번 재현되는가: 코드 경로 자체가 매번 Unchanged로 시작해 Modified로 전이되는 구조이기 때문에, 동시 요청이 전혀 없어도 항상 발생한다.
  • 왜 테이블이 비어 있어도 발생하는가: 애초에 UPDATE 대상 행이 존재한 적이 없으므로, "누군가 지웠다"는 가정 자체가 필요 없다.
  • id를 명시적으로 보낸 케이스에서도 똑같이 재현됐던 이유: 클라이언트가 보낸 id가 컬렉션에서 못 찾은 것이라면(신규로 처리되어야 하는 경우), 이 함정 코드 경로는 그 클라이언트 제공 id 값을 그대로 키에 채우고 컬렉션에 Add()하므로 동일한 문제가 발생한다.

대조: DbContext.Add()는 다르게 동작한다

같은 문서가 인용하는 Behavior Changes between EF6 and EF Core는 이 함정을 피하는 방법도 함께 알려준다.

This change of behavior only applies to the Attach and Update method groups. Add always puts entities in the Added state, even if the key is set.

즉, DbContext.Add(entity) 또는 DbSet<T>.Add(entity)를 직접 호출하면, 키 값이 이미 채워져 있어도 무조건 Added로 추적한다. 함정은 "컬렉션 네비게이션에만 Add()한 경우"에 한정된다.

해결 방법

가장 낮은 리스크의 수정은 기존 컬렉션 Add() 호출은 그대로 두고(다른 로직이 그 컬렉션 상태에 의존할 수 있으므로), 바로 다음 줄에 DbContext.Add(entity)를 추가해 상태를 명시적으로 Added로 재확정하는 것이다.

if (entity == null)
{
    entity = new OrderLineItem
    {
        Id = Guid.NewGuid(),
        OrderId = order.Id,
    };
    order.LineItems.Add(entity); // 컬렉션 상태 유지를 위해 그대로 둠
    context.Add(entity);         // 상태를 명시적으로 Added로 재확정
}

OrderId(외래 키)가 이미 설정되어 있고 부모(order)가 이미 추적 중이므로, context.Add(entity)가 그래프를 순회하더라도 이미 추적 중인 order는 건드리지 않고 새 자식 엔티티만 Added로 표시한다. 이 부분은 EF Core의 관계 픽스업이 "새로 발견된 엔티티만" 상태를 결정하고 이미 추적 중인 엔티티는 그대로 둔다는 원칙에 따른 것이다.

대안으로는 .ValueGeneratedOnAdd() 자체를 제거하는 방법도 문서에 언급되어 있지만, 이는 모델/마이그레이션 변경을 동반하고 그 키에 의존하는 다른 코드 경로에도 영향을 줄 수 있어 변경 범위가 훨씬 커진다. 이번처럼 국소적인 문제라면 Add() 호출을 명시하는 쪽이 더 안전한 선택이다.

왜 코드 리뷰와 빌드로는 못 잡을까

이 버그의 가장 위험한 특징은 정적으로 관측 불가능하다는 점이다.

  • dotnet build는 타입 체크만 할 뿐, 런타임 엔티티 상태 전이는 전혀 알지 못한다. 이 세션에서도 문제가 있는 코드 상태로 여러 차례 "0 Errors" 빌드를 통과시켰다.
  • 코드만 읽으면 Add()를 호출했으니 "당연히 삽입되겠지"라고 판단하기 쉽다. review.GeneralPublicPhotos.Add(entity)(컬렉션에 Add)와 context.Add(entity)(DbContext에 Add)는 이름과 겉모습이 비슷하지만 완전히 다른 추적 규칙을 따른다는 사실을 모르면 리뷰에서도 놓치기 쉽다.
  • 단위 테스트가 InMemory 프로바이더를 쓴다면 이 문제가 재현되지 않을 수도 있다(프로바이더별 세부 동작 차이가 있을 수 있으므로, 실제 검증은 대상 프로바이더로 직접 해야 한다).

따라서 이런 유형의 버그를 검증하려면 다음이 필요하다.

  1. SaveChangesAsync() 직전에 context.ChangeTracker.DebugView.ShortView(또는 LongView)를 로그로 남겨, 대상 엔티티가 실제로 Added 상태인지 직접 확인한다.
  2. 실제 실행 후 DB에 행이 생성됐는지, 혹은 예상한 개수만큼 upsert/delete가 일어났는지 직접 조회로 확인한다.

체크리스트

GUID/생성된 키를 쓰는 엔티티를 다룰 때 아래에 해당하면 이 함정을 의심해 볼 가치가 있다.

  • 자식 엔티티의 PK가 ValueGeneratedOnAdd()(또는 그에 준하는 생성된 키) 설정인가?
  • 코드가 그 PK 값을 (DB가 아니라) 애플리케이션 코드에서 직접 채우고 있는가(Guid.NewGuid() 등)?
  • 새 엔티티를 context.Add(...)/DbSet.Add(...)가 아니라 이미 추적 중인 부모의 컬렉션 네비게이션에만 .Add(...)하고 있는가?
  • 그 직후 엔티티의 필드 값을 설정하는 코드가 있는가(→ Unchanged → Modified 전이를 유발)?

위 네 가지가 모두 해당한다면, SaveChangesAsync()는 INSERT가 아니라 UPDATE를 시도하다가 대상 행이 없어 DbUpdateConcurrencyException을 던질 가능성이 높다.

참고 자료 (출처)

이 글의 원인 분석은 Microsoft Learn(learn.microsoft.com) 공식 EF Core 문서를 근거로 작성했다(확인일: 2026-08-18).

  • Keys — Key types and values
  • Changing Foreign Keys and Navigations — Fixup for added or deleted entities
  • Behavior Changes between EF6 and EF Core — DbSet.Add/Attach and graph behavior
  • Explicitly Tracking Entities — Attaching existing entities
  • Explicitly Tracking Entities — Inserting new entities
  • Disconnected entities — Identifying new entities
  • Disconnected entities — Saving single entities
  • Identity Resolution in EF Core — Failing to set key values
  • Accessing Tracked Entities — Using DbContext.Entry and EntityEntry instances

검증 환경: .NET 8, EF Core 8.0.4, SQL Server(EF Core SQL Server 프로바이더).