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하는 핸들러가 있었다. 로직은 흔한 패턴이다.
Include해서 EF로 조회한다(추적 활성화 상태).id가 있고 그 id가 기존 컬렉션에 있으면 필드를 갱신(update)하고, 없으면 새 엔티티를 만들어 추가(insert)한다.SaveChangesAsync()를 한 번 호출해 전부 반영한다.문제가 된 것은 정확히 이 중 "새 엔티티를 만들어 추가"하는 부분이었다. 처음 겪은 현상은 이랬다.
id 없이(신규로 인식되어야 할) 자식 항목 하나를 배열에 담아 보냈다.DbUpdateConcurrencyException이 발생했다: "1개 행이 영향받아야 했는데 0개 행이 영향받았다."바로 정답에 도달하지 못했다. 실제로는 두 번의 그럴듯하지만 틀린 결론을 거쳤다. 이 과정 자체가 이런 종류의 버그를 다룰 때 유용한 반면교사라 함께 남긴다.
자식 엔티티(예: OrderLineItem)에는 concurrency token(RowVersion/Timestamp)이 없었다. 따라서 UPDATE ... WHERE Id=@id가 0행에 매치되는 유일한 논리적 설명은 "그 행을 로드한 시점과 저장한 시점 사이에 행 자체가 사라졌다"는 것처럼 보였다. 같은 리소스에 대해 PATCH 요청이 거의 동시에 두 번 들어와서, 먼저 커밋된 요청이 그 자식 레코드를 삭제하고 뒤늦은 요청이 이미 사라진 레코드를 갱신하려 한 것이라 추론했다.
이 추론에 따라 방어적으로 DbUpdateConcurrencyException을 409 Conflict로 클라이언트에 명시적으로 반환하도록 고쳤다. 이 자체는 나쁜 개선이 아니었지만(클라이언트가 재시도할 근거를 주니까), 근본 원인은 아니었다.
다음으로 의심한 것은 "PATCH 요청의 배열이 id를 포함하지 않은 기존 자식을 자동으로 삭제하는 정책 때문에, 새 항목이 삽입되면서 동시에 기존 항목이 삭제돼 update처럼 보이는 것"이라는 가설이었다. 코드를 라인 단위로 추적한 결과 "삽입/수정 판단 로직 자체는 정상"이라는 결론에 도달했고, 이 결론에 만족하고 넘어갈 뻔했다.
그런데 자식 테이블이 완전히 비어 있는 상태에서도, 그것도 매번 결정적으로 같은 예외가 재현된다는 사실을 추가로 확인하면서 이 결론도 무너졌다. 테이블에 삭제할 기존 행 자체가 없으니 "전체 교체로 인한 부수 효과" 이론은 성립할 수 없었다. 재현이 레이스 컨디션이라면 실행마다 발생 여부가 달라져야 하는데, 100% 결정적으로 재현된다는 점도 오판 1을 무너뜨리는 결정적 반증이었다.
두 이론 모두 "왜 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를 조금 다뤄본 개발자에게는 흔한 패턴이다. 하지만 이 두 가지가 겹치면 문제가 생긴다.
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:
- If the entity is not tracked, then it is tracked. (The entity will usually be in the
Addedstate. However, if the entity type is configured to use generated keys and the primary key value is set, then the entity is tracked in theUnchangedstate.)
즉, 이미 추적 중인 부모(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로 전이되는 구조이기 때문에, 동시 요청이 전혀 없어도 항상 발생한다.id를 명시적으로 보낸 케이스에서도 똑같이 재현됐던 이유: 클라이언트가 보낸 id가 컬렉션에서 못 찾은 것이라면(신규로 처리되어야 하는 경우), 이 함정 코드 경로는 그 클라이언트 제공 id 값을 그대로 키에 채우고 컬렉션에 Add()하므로 동일한 문제가 발생한다.DbContext.Add()는 다르게 동작한다같은 문서가 인용하는 Behavior Changes between EF6 and EF Core는 이 함정을 피하는 방법도 함께 알려준다.
This change of behavior only applies to the
AttachandUpdatemethod groups.Addalways puts entities in theAddedstate, 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)는 이름과 겉모습이 비슷하지만 완전히 다른 추적 규칙을 따른다는 사실을 모르면 리뷰에서도 놓치기 쉽다.따라서 이런 유형의 버그를 검증하려면 다음이 필요하다.
SaveChangesAsync() 직전에 context.ChangeTracker.DebugView.ShortView(또는 LongView)를 로그로 남겨, 대상 엔티티가 실제로 Added 상태인지 직접 확인한다.GUID/생성된 키를 쓰는 엔티티를 다룰 때 아래에 해당하면 이 함정을 의심해 볼 가치가 있다.
ValueGeneratedOnAdd()(또는 그에 준하는 생성된 키) 설정인가?Guid.NewGuid() 등)?context.Add(...)/DbSet.Add(...)가 아니라 이미 추적 중인 부모의 컬렉션 네비게이션에만 .Add(...)하고 있는가?Unchanged → Modified 전이를 유발)?위 네 가지가 모두 해당한다면, SaveChangesAsync()는 INSERT가 아니라 UPDATE를 시도하다가 대상 행이 없어 DbUpdateConcurrencyException을 던질 가능성이 높다.
이 글의 원인 분석은 Microsoft Learn(learn.microsoft.com) 공식 EF Core 문서를 근거로 작성했다(확인일: 2026-08-18).
검증 환경: .NET 8, EF Core 8.0.4, SQL Server(EF Core 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하는 핸들러가 있었다. 로직은 흔한 패턴이다.
Include해서 EF로 조회한다(추적 활성화 상태).id가 있고 그 id가 기존 컬렉션에 있으면 필드를 갱신(update)하고, 없으면 새 엔티티를 만들어 추가(insert)한다.SaveChangesAsync()를 한 번 호출해 전부 반영한다.문제가 된 것은 정확히 이 중 "새 엔티티를 만들어 추가"하는 부분이었다. 처음 겪은 현상은 이랬다.
id 없이(신규로 인식되어야 할) 자식 항목 하나를 배열에 담아 보냈다.DbUpdateConcurrencyException이 발생했다: "1개 행이 영향받아야 했는데 0개 행이 영향받았다."바로 정답에 도달하지 못했다. 실제로는 두 번의 그럴듯하지만 틀린 결론을 거쳤다. 이 과정 자체가 이런 종류의 버그를 다룰 때 유용한 반면교사라 함께 남긴다.
자식 엔티티(예: OrderLineItem)에는 concurrency token(RowVersion/Timestamp)이 없었다. 따라서 UPDATE ... WHERE Id=@id가 0행에 매치되는 유일한 논리적 설명은 "그 행을 로드한 시점과 저장한 시점 사이에 행 자체가 사라졌다"는 것처럼 보였다. 같은 리소스에 대해 PATCH 요청이 거의 동시에 두 번 들어와서, 먼저 커밋된 요청이 그 자식 레코드를 삭제하고 뒤늦은 요청이 이미 사라진 레코드를 갱신하려 한 것이라 추론했다.
이 추론에 따라 방어적으로 DbUpdateConcurrencyException을 409 Conflict로 클라이언트에 명시적으로 반환하도록 고쳤다. 이 자체는 나쁜 개선이 아니었지만(클라이언트가 재시도할 근거를 주니까), 근본 원인은 아니었다.
다음으로 의심한 것은 "PATCH 요청의 배열이 id를 포함하지 않은 기존 자식을 자동으로 삭제하는 정책 때문에, 새 항목이 삽입되면서 동시에 기존 항목이 삭제돼 update처럼 보이는 것"이라는 가설이었다. 코드를 라인 단위로 추적한 결과 "삽입/수정 판단 로직 자체는 정상"이라는 결론에 도달했고, 이 결론에 만족하고 넘어갈 뻔했다.
그런데 자식 테이블이 완전히 비어 있는 상태에서도, 그것도 매번 결정적으로 같은 예외가 재현된다는 사실을 추가로 확인하면서 이 결론도 무너졌다. 테이블에 삭제할 기존 행 자체가 없으니 "전체 교체로 인한 부수 효과" 이론은 성립할 수 없었다. 재현이 레이스 컨디션이라면 실행마다 발생 여부가 달라져야 하는데, 100% 결정적으로 재현된다는 점도 오판 1을 무너뜨리는 결정적 반증이었다.
두 이론 모두 "왜 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를 조금 다뤄본 개발자에게는 흔한 패턴이다. 하지만 이 두 가지가 겹치면 문제가 생긴다.
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:
- If the entity is not tracked, then it is tracked. (The entity will usually be in the
Addedstate. However, if the entity type is configured to use generated keys and the primary key value is set, then the entity is tracked in theUnchangedstate.)
즉, 이미 추적 중인 부모(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로 전이되는 구조이기 때문에, 동시 요청이 전혀 없어도 항상 발생한다.id를 명시적으로 보낸 케이스에서도 똑같이 재현됐던 이유: 클라이언트가 보낸 id가 컬렉션에서 못 찾은 것이라면(신규로 처리되어야 하는 경우), 이 함정 코드 경로는 그 클라이언트 제공 id 값을 그대로 키에 채우고 컬렉션에 Add()하므로 동일한 문제가 발생한다.DbContext.Add()는 다르게 동작한다같은 문서가 인용하는 Behavior Changes between EF6 and EF Core는 이 함정을 피하는 방법도 함께 알려준다.
This change of behavior only applies to the
AttachandUpdatemethod groups.Addalways puts entities in theAddedstate, 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)는 이름과 겉모습이 비슷하지만 완전히 다른 추적 규칙을 따른다는 사실을 모르면 리뷰에서도 놓치기 쉽다.따라서 이런 유형의 버그를 검증하려면 다음이 필요하다.
SaveChangesAsync() 직전에 context.ChangeTracker.DebugView.ShortView(또는 LongView)를 로그로 남겨, 대상 엔티티가 실제로 Added 상태인지 직접 확인한다.GUID/생성된 키를 쓰는 엔티티를 다룰 때 아래에 해당하면 이 함정을 의심해 볼 가치가 있다.
ValueGeneratedOnAdd()(또는 그에 준하는 생성된 키) 설정인가?Guid.NewGuid() 등)?context.Add(...)/DbSet.Add(...)가 아니라 이미 추적 중인 부모의 컬렉션 네비게이션에만 .Add(...)하고 있는가?Unchanged → Modified 전이를 유발)?위 네 가지가 모두 해당한다면, SaveChangesAsync()는 INSERT가 아니라 UPDATE를 시도하다가 대상 행이 없어 DbUpdateConcurrencyException을 던질 가능성이 높다.
이 글의 원인 분석은 Microsoft Learn(learn.microsoft.com) 공식 EF Core 문서를 근거로 작성했다(확인일: 2026-08-18).
검증 환경: .NET 8, EF Core 8.0.4, SQL Server(EF Core SQL Server 프로바이더).