Upsert
최근에 EF Core(이하 “EF”)보다 Dapper나 AdoNet을 선호하는 이유를
EF는 Upsert 혹은 Merge를 지원하지 않아서
라고 말하는 개발자의 글을 본 적이 있습니다.
물론, EF는 Upsert, InsertOrUpdate, Merge 와 같은 메서드나 유사한 기능을 구현한 API를 제공하지 않는 것은 분명한 사실입니다.
그러나, EF의 raw sql 지원 메서드를 이용한다면, 굳이 못 하는 것도 아닙니다.
요구 사항
상품 api 의 주소에 PK 대신, 제품 식별자를 사용하여 보안성과 SEO를 개선하고 싶다.
변경 전:
GET https://my.eshop.com/products/1
변경 후:GET https://my.eshop.com/products/banana
이 식별자를 "핸들"이라고 칭하겠습니다.
핸들은 "검색어"가 아니라 특정 상품의 고유한 이름으로 사용됩니다.
바나나 => banana
동명의 다른 제품 => banana-2
해결
우선 이 핸들을 저장할 필드를 엔티티에 추가합니다.
public class Product
{
// ...
public string Handle { get; set; } // Unique, Indexing 설정
}
핸들의 관리를 위한 테이블의 매핑 모델입니다.
// Surrogate Key: (Value, Type)
internal record Handle(string Value, string Type, int Occurrence);
엔티티 객체를 ChangeTracking 하지 않는다면,
record타입을 엔티티 모델에 사용하는 것에 아무런 문제가 없습니다.
이 모델은 특정 타입에 동일한 이름의 핸들이 몇 개나 되는 지를 저장하는 아카이브 역할을 합니다.
사실 핸들을 사용하는 타입이 몇 개 안될 때는 엔티티 별로 헨들을 관리하는 것이 좋습니다.
internal record ProductHandle(string Handle, int Occurrence);
이 글은 두 번째 모델을 사용하겠습니다.
구현
구현의 핵심은 ProductHandle 테이블을 통해 중복 개수(Occurrence)를 관리하는 것으로 , 아래의 코드로 나타낼 수 있습니다.
var handle = // ...
using var transaction = await db.Database .BeginTransactionAsync(token);
try
{
_ = await dbContext.Database
.ExecuteSqlInterpolatedAsync(
$@"INSERT INTO ProductHandles (Handle, Occurrence)
VALUES ({handle}, 1)
ON CONFLICT(Handle)
DO UPDATE SET Occurrence = ProductHandles.Occurrence + 1",
token);
var productHandle = await db.Set<ProductHandle>()
.FirstAsync(x => x.Handle == handle, token);
await transaction.CommitAsync(token);
if (productHandle.Occurrence > 1)
{
handle += $"-{productHandle.Occurrence}";
// ...
}
catch // ...
위 구현은 DbContext.Database.ExecuteSqlInterpolatedAsync와 Transaction 을 이용하여 UnitOfWork 는 잘 지켰습니다.
그러나, 확률이 낮기는 하지만 동시성 문제가 완전히 해결되지 않았습니다.
Occurence 를 2 → 3 으로 변경할 때, 다른 커넥션이 3 → 4로 변경하는 사건이 벌어질 수 있고, 그로 인해 두 개의 행이 "banana-4"를 Insert 하고 이는 Product.Handle 의 Unique 제약에 걸립니다.
이뿐만 아니라 두 번의 round trip 이라 비효율적입니다.
이 두가지 문제는 Upsert 를 사용해야 하는 상황이나 요구에 충분하지 않을 수 있습니다.
Returning
만약 DB 가 Returning 명령( 혹은 유사한 명령)을 지원한다면, 앞 코드에 나타난 모든 문제가 해결됩니다.
var handle = // ...
var productHandle = await dbContext.Set<ProductHandle>()
.FromSqlInterpolated(
$@"INSERT INTO ProductHandles (Handle, Occurrence)
VALUES ({handle}, 1)
ON CONFLICT(Handle)
DO UPDATE SET Occurrence = ProductHandles.Occurrence + 1
RETURNNING *")
.SingleAsync(token);
if (productHandle.Occurrence > 1)
handle += $"-{productHandle.Occurrence}";
// ...
IQueryable<T>.FromSqlInterpolated 확장 메서드는 쿼리 결과를 T 객체에 매핑해주는데(IQueryable<T> 반환), 보시다시피 RETURNNING 이 반환하는 데이터에도 동일하게 동작합니다.
RETURNNING 이 T 와 같지 않은 데이터 구조를 반환하면 예외가 발생할 수 있습니다.
위 코드는
- 단일 trip
- 원자적 변경
- 동시성 문제가 없는
안전한 코드입니다.
물론, UnitOfWork 는 지키지 않는데, 단일 행에 대한 조작이라 지켜야 할 이유가 없기 때문입니다.
마치며
스크립트에 의존하면서, 굳이 EF 를 쓸 필요가 있나?
있습니다.!
DB 쿼리, 특히 복잡한 쿼리는 대부분 필터와 매핑인데, 이들을 Linq 만큼 안전하고 정확하게 다루게 해주는 도구는 없습니다. 여기에는 쿼리를 실행하지 않고도 컴파일러가 문법적 오류, 타입 호환성을 확인해주는 것도 포함됩니다.
그런 이유로 초보자도 복잡한 쿼리를 쉽고 안전하게 작성할 수 있는 것이고, 저 또한 Linq 에 테스트를 거의 걸지 않습니다.
이러한 도구의 강력함이 "사용하지 말아야 할 이유"라고 주장하는 분도 계시더군요.
또한 Upsert 는 효율적이기는 하지만 일반적으로 허용하는 것이 과연 안전한 지 의문입니다.
예를 들어, 누군가 아래의 코드를 여기 저기 흩어 놓은 상황을 상상해 보시면, 그 DB의 데이터를 신뢰할 수 있을지 의문입니다.
dbContext.Set<Product>().InsertOrUpdate(product);
dbContext.SaveChanges();
제 생각엔 Upsert 같은 것들은 예외적으로 특별한 통제가 필요하다고 보여집니다.
즉, 일반 쿼리는 Linq 로 안전하고 효율적으로 작성하고, 예외적인 상황을 비 Linq 로 관리하는 것이죠.
예외적으로 관리해야 하는 부분 때문에 전체의 효용 가치를 놓치는 것이 합리적이지는 않을 것입니다.