EF Core 관계 맵핑 케이스 연구

EF Core 코드 우선 접근법에 따라, 데이터 베이스를 설계하는 방법에 대해 논의합니다.

주의
이 글의 나타난 모든 객체는 하나의 관계에만 참여하는 것을 전제합니다.
만약, 객체가 여러 관계에 참여한다면, 아래의 내용 중 일부는 적용되지 않습니다.

1:1 관계

두 객체가 1:1 관계를 맺는 경우를 살펴 봅니다.

1. (0,1):(1,1)

여행사에서는 고객의 여권을 관리합니다.

손님은 여권이 있을 수도, 없을 수도 있습니다. 왜냐하면, 국내 여행 상품만 이용하는 고객도 있기 때문입니다.
그러나, 여권은 반드시 손님의 것이어야 합니다.

이때, 이 두 엔티티의 관계는 "Has"라고 할 수 있고, 관계 참여도를 살펴보면,

손님 → Has : 일부 참여 (0,1)
여권 → Has : 전체 참여 (1,1)

Naive version

위 ER 을 엔티티 클래스로 표현하면, 아래와 같습니다.

참고
Id 는 PK로, 데이터베이스가 Auto Increment 하도록 설정한 것을 가정합니다.

public int Id { get; private set; }

class Guest
{
   public int Id { get; private set; } // PK
   // 네비게이션
   public Passport? Passport { get; set; }
}

class Passport
{
   public string No { get; init; } // PK
   public DateOnly Validity { get; set; }
   // 네비게이션
   public required Guest Guest { get; set; }
}

두 객체는 네비게이션 속성으로 관계를 나타내고 있는데, 1:1 관계인 경우, FK를 보유하는 측을 명확하게 지정해야 합니다.

Guest 를 Principal 로 Passport 를 Dependent 로 보는 것이 타당하기에, Passport 쪽에 FK 를 넣습니다.

FK를 추가하는 방법은, 아래와 같이 그림자 속성(Shadow property)으로 지정하거나,

// GuestEntityBuilder.cs

builder.HasOne(guest => guest.Passport) 
   .WithOne(passport => passport.Guest)  
   .HasForeignKey<Passport>("GuestId"); // [Passport] 테이블에 "GuestId" 컬럼 추가

명시적으로 속성을 선언하여, EF 의 맵핑 컨벤션이 자동으로 처리하도록 만들 수도 있습니다.

class Passport
{
   // ...
   public int GuestId { get; set; } // FK to Guest.Id
   public required Guest Guest { get; set; }
}

맵핑 컨벤션은 종속 객체의 네비게이션(Passport.Guest)이 non nullable 로 선언되었기에, Cascade Delete 로 설정합니다.

문제점

이 맵핑 설계가 Naive 한 이유는 Passport 에 테이블을 할당해서, Delete Anomaly 의 원인을 제공하기 때문입니다.

var guestId = // ...

var guest = dbContext.Guests.FindAsync(guestId);

if (guest is not null)
{
   dbConext.Guests.Remove(guest) 
   dbContext.SaveChanges(); 
}

Cascade Delete 이 설정되어도, DbContext 가 Passport 를 Tracking 하지 않으면, Delete Anomally(FK 제약 위반)가 발생할 수 있습니다.

이를 막기 위해서는 아래와 같이 Dependent 엔티티가 트랙킹되도록 코드를 적거나,

// FK 제약을 위반하지 않는 코드
var guest = dbContext.Guests
   .Include(g => g.Passport) // Tracking 에 추가
  .FirstOrDefualtAsync(guestId);

Dependent 엔티티를 먼저 삭제한 후에 Principal 을 삭제해야 합니다.

var passportNo // ...
var guestId = // ...

var deletedPassports = dbContext.Passports
   .Where(p => p.No == passportNo)
   .ExecuteDelete();

var deletetedGuests = dbContext.Guests.
   .Where(g => g.Id == guestId)
   .ExecuteDelete();

Anomaly 는 관계형 데이터 베이스가 가진 특징이라, 위 삭제 순서는 EF Core 에 기반한 코드 뿐만 아니라, Raw 쿼리도 지켜야 합니다.

다른 방법

단순 병합

1:1 관계에서, 전체 참여 엔티티는 부분 참여 엔티티에 흡수되어도 무방합니다.

class Guest
{
   public int Id { get; private set; }
   public string? PassportNo { get; init; } // 값
   public DateOnly? PassportValidity { get; set; }
}

이 경우 Passport 테이블이 없기 때문에, 그 만큼 데이터 베이스도 간단해지고 쿼리 효율도 좋아 집니다.

그러나, 장점은 딱 거기까지 입니다.

우선 아래와 같이 데이터 유효성이 깨지는 것을 예방하는 수단이 없습니다.

var guest1 = new Guest
{
   PassportNo = "123"
   // Validity 설정 안됨.
};

var guest2 = new Guest
{
   // PassportNo = 설정 안됨.
   PassportValidity = new(2030, 12, 31);
};

var guest3 = new Guest
{
   // 고유하지 않은 값
   PassportNo = "123";
   PassportValidity = new(2030, 12, 31);
};

문제는 여기에서 끝나지 않습니다.

우리 시스템에서 명백한 의미를 갖는 Passport가 추상화되지 않았기에, 우리 코드는 두고두고 추상화 부족으로 시달리게 됩니다.

이를 만회하기 위해, 코드에서 사용할 Passport 객체를 도입한다면, 이 객체와 Guest 엔티티를 이어 주는 로직이 추가되어야 합니다.

static class GuestExtensions
{
   public static Passport? New(this Guest guest) => 
      guest.PassportNo == null || guest.PassportValidity == null ? null
       : new (guest.PassportNo, guest.PassportValidity);

   public static Guest? SetPassport(this Guest guest, Passport passport) 
   {
      guest.PassportNo = passport.No; 
      guest.PassportValidity = passport.Validity;
      return guest;
   }
}

어떻게든 꾸역 꾸역 대처했지만, 다들 아시다시피, 확장 메서드는 정말 관리하기 힘든 코드입니다.

호출자 모듈에서 즉흥적으로 정의하는 경우가 많아, 나중에는 유사한 확장 메서드가 여기 저기 굴러다니는 지경에 이릅니다.

더군다나, 위의 코드는 엔티티 모델과 도메인 모델의 경계가 모호합니다.

이런 모호한 경계는 시스템 설계가 도메인 모델 우선도 아니고, 데이터 모델 우선도 아닌, 어중간한 상태가 되는데, 스파게티로 발전할 가능성이 큽니다.

이를 막기 위해, 레이어를 구분하려는 노력을 합니다.

namespace Database;
class Guest
{
   public int Id { get; private set; }
   public string? PassportNo { get; init; } // 값
   public DateOnly? PassportValidity { get; set; }
}
namespace Domain;
class Guest
{
   public int Id { get; private set; }
   public Passport? Passport { get; set; }
}

쿼리 효율이 코딩 효율을 좀 먹기 시작합니다.

프로젝트가 성장할 수록 현타에 몸서리칩니다.

“Data 기반으로 다시 쓸까?”
“도메인 기반으로 다시 쓸까?”

OwnedType

개인적으로 EF 코어의 OwnedType 이 이 관계에 매우 잘 맞는 것 같습니다.

class Guest
{
   public int Id { get; private set; }
   // 네비게이션
   public Passport? Passport? { get; set; }
}

[Owned]
class Passport
{
   public required string No { get; init; } // PK
   public required DateOnly Validity { get; set; }
   // 있으나 없으나 상관 없음
   // public required int GuestId { get; init; } // PK
}

Passport 는 여전히 테이블을 갖게 되지만, Cascade Delete 이 자동으로 적용되고, Guest 에 언제나 Include 되기 때문에, Delete Anomally 의 가능성이 적습니다.

코드가 데이터 유효성을 확인하고, PK 가 데이터 정합성을 보장합니다.
도메인 로직에서 이 데이터 모델을 사용해도 아무런 제약이 없습니다.

ComplexType

EF Core 버전 6에 도입되었다가, 버전 7에서 사라졌고, 버전 8에서 돌아온 기능인데, 얼핏 보면, 논의 중인 관계에 잘 부합될 것처럼 보이지만, 절대 아닙니다.

Complex 타입으로 지정된 형식은 반드시 required 키워드나 [Required] 특성을 지정해야 하기 때문에,

class Guest
{
   public int Id { get; private set; }
   public required Passport Passport { get; set; } // required 필수.
}

Guest → Has : (0, 1) 참여도가 훼손되는 게 첫 번째 문제입니다.

이 문제는 아래와 같이 관계 상대방이 해결하도록 만들 수도 있지만,

[ComplexType]
class Passport
{
   public string? No { get; init; } // PK
   public DateOnly? Validity { get; set; }
}

이렇게 되면, 앞서 살펴본 대로, 데이터 유효성과 정합성까지 무너지게 됩니다.

Value Converter

이 방법 또한 테이블을 늘리지 않는 방법 중 하나입니다.

class Guest
{
   public int Id { get; private set; }
   // 네비게이션
   public Passport? Passport { get; set; }
}

record Passport(string No, DateOnly Validity);
// GuestEntityBuilder.cs

builder.Property(x => x.Passport)
   .HasConversion(
      obj => JsonSerializer.Serialize(obj),
      rec => JsonSerializer.Deserialize<Passport>(rec));

EF 는 아래와 같은 테이블을 생성합니다.

Guest
- Id : int
- Passport : Varchar  

이 경우, Passport.No 가 키로서의 역할도 못 할 뿐만 아니라, Passport 를 통한 조건 쿼리가 Equals 로 쪼그라듭니다.

var passport = // ...
var guests = await db.Guests
   .Where(x => x.Passport == pasport)
   // .Where(x => x.Passport != pasport)
   //  .Where(x => x.Passport.Validity < today) // 에러
   .ToListAsync();

다른 비교는, 메모리로 다 가져와서, IEnumerable 에서 처리해야 합니다.

var today = // ...
var guests = (await db.Guests
   .ToListAsync())
   .Where(x => x.Passport.Validity < today);

참고로, Passport 가 엔티티로 정의되면 아래와 같은 쿼리가 얼마든지 가능합니다.

var today = // ...
var guests = await db.Guests
   .Where(x => x.Passport.Validity < today)
   .ToListAsync();

Value Converter 가 의미 있게 사용되는 경우도 있겠지만, (0,1) : (1,1) 관계인 경우는 아닌 것 같습니다.

결론

(0,1) : (1,1) 인 관계에서 쿼리 효율이 가장 좋은 방안은 테이블 병합이지만, 코딩 효율과 안정성을 생각한다면, Owned 타입으로 지정하는 것이 현명한 선택이라 할 수 있습니다.

7 Likes

Dateonly requires attribute 가 아닌 저렇게 타입식으로도 되는군요
Owner 나 생소한 속성이네요 아무래도 관계를 코드퍼스트로 잘안해봐서
이번에 sqlkata 발표를 봤는데 실무에서는 인라인 쿼를 생성하는 방식도 괜찮아보였어요

1 Like

이 글은 EF Core가 제공하는 DB 설계 도구를 이용해서 데이터 베이스를 설계할 때 고려해야 하는 점들을 논의하기 위한 것으로, 엔티티 관계(ER) 별로 하나 씩 따져 보는 것을 목표로 하고 있습니다.

참고로, 데이터베이스가 EF Core 로 생성되었더라도, 데이터베이스 사용에 어떤 영향을 미치는 것은 아닙니다.

사용자는 기호에 따라 (인라인) 쿼리를 직접 작성할 수도 있고, Linq 식이나 메서드를 사용할 수도, 심지어 다른 프레임워크를 사용할 수도 있습니다.

예제에 나온 Linq 식과 DbSet 확장 메서드는 이곳이 닷넷 커뮤니티이기 때문에 사용한 것 뿐입니다. 동일한 효과를 나타내는 쿼리를 직접 작성해도 아무런 문제가 없습니다.

다시 말하면, 예제 코드 중, 설계의 비효율을 드러내는 Linq 코드를 쿼리로 변경한다고 해서 그 비효율이 경감되거나 없어지지 않습니다.

2 Likes

1:1

2. (0,1) : (0,1)

여행사는 부부 또는 커플 고객을 관리하려고 합니다.

이를 위해, Guest 를 세분화(Specialization)합니다.

class Guest
{
   public int Id { get; private set; } // PK
   // 기타 속성
}

class MaleGuest : Guest { }
class FemaleGuest : Guest { }

TPH 맵핑 설정

EF Core 는 상속을 지원하는데, 기반과 파생을 맵핑하는 세 가지 방식이 있습니다.

  • Table Per Hierachy : 모든 패밀리 형식들이 하나의 테이블에 저장됨.
  • Table Per Type : 형식 별 테이블에 저장됨. 부모 속성은 부모 테이블에, 자식 속성은 자식 테이블에. 자식은 부모에 대한 FK를 보유
  • Table Per Concrete : 형식 별 테이블에 저장됨. 형식의 모든 속성을 테이블에.

기본 값은 TPH 이고, 무난한 선택입니다.
다만, 주의할 점은 기반 형식과 파생 형식을 EF Core 에게 명시적으로 인식시켜야 한다는 점인데, 이를 위해 두 가지 방식이 존재합니다.

1. DbContext.DbSet s

// 기존
public DbSet<Guest> Guests => Set<Guest>();

// 추가
public DbSet<MaleGuest> MaleGuests => Set<MaleGuest>();
public DbSet<FemaleGuest> FemaleGuests => Set<FemaleGuest>();

2. Fluent Api

DbSet 을 노출하는 것을 꺼리거나, DbContext 를 수정할 수 없는 경우는 Fluent Api 를 사용할 수 있습니다.

guestEntityBulder
   .HasDiscriminator<string>("guest_type")
   .HasValue<MaleGuest>(nameof(MaleGuest))
   .HasValue<FemaleGuest>(nameof(FemaleGuest));

위 두 방법 중 하나라도 취하지 않으면 예외가 발생할 수 있습니다.

준비가 끝났으니, 관계 참여도를 살펴 봅니다.

MaleGuest → Couple : (0,1)
FemaleGuest → Couple : (0,1)

이 관계 정의가 현실을 제대로 대변하지 않을 수 있습니다.
논의를 위해 (0,1):(0,1) 관계인 상황만을 가정합니다.

Naive Version

이 관계를 엔티티 클래스로 나타내보면 아래와 같습니다.

class MaleGuest : Guest 
{
    public FemaleGuest?  Wife { get; set; }
}
class FemaleGuest : Guest 
{ 
   public MaleGuest? Husband { get; set; }
}

위 정의는 별다른 문제는 없습니다.

변형 1

EF core는 남자 고객 행을 위해, WifeId FK 컬럼을, 여자 고객 행을 위해 HusbandId FK 컬럼을 테이블에 추가합니다.

이 값을 코드로 받기 위해서, 아래와 같이 코드를 변경할 수 있습니다.

class MaleGuest : Guest 
{
    public int?  WifeId { get; set; }
    public FemaleGuest?  Wife { get; set; }
}
class FemaleGuest : Guest 
{ 
   public int? HusbandId { get; set; }
   public MaleGuest? Husband { get; set; }
}

이렇게 변경했을 때 취할 수 있는 장점은 관계를 맺은 채 생성하는 코드가 쉬워집니다.

var maleGuestId = 1;
var newFemaleGuest
{
   HusbandId = maleGuestId,
}

그리고, 직렬화할 때도 간편해집니다.

class MaleGuest : Guest 
{
   public int?  WifeId { get; set; }

   [JsonIgnore]
   public FemaleGuest?  Wife { get; set; }
}
class FemaleGuest : Guest 
{ 
   public int? HusbandId { get; set; }

   [JsonIgnore]
   public MaleGuest? Husband { get; set; }
}

위 정의로 MaleGuestDto 나 FemaleGuestDto 를 정의할 필요도 없고, 직렬화 시에 순환 참조 문제도 사라집니다.

변형 2

그럼 네비게이션 속성을 지우면 어떻게 될까요?

class MaleGuest : Guest 
{
   public int?  WifeId { get; set; }
}
class FemaleGuest : Guest 
{ 
   public int? HusbandId { get; set; }
}

이전 경우와 같은 특징을 보이지만, Linq 가 Raw 해집니다.

var guestId = 1;

var query = dbContext.MaleGuests
    .Where(m => m.Id == guestId)
    .GroupJoin(
        dbContext.FemaleGuests,
        m => m.Id,
        f => f.HusbandId,
        (m, grouping) => new {m, grouping }
    )
    .SelectMany(
        mg => mg.grouping.DefaultIfEmpty(),
        (mg, f) => new { mg.m, f }
    ).FirstOfDefault();

위의 코드는 MaleGuest left Join FemaleGuest 를 수행하는 쿼리를 생성합니다.
그런데, 이는 네비게이션이 있을 때, 아래의 코드가 생성하는 쿼리와 같습니다.

var guestId = 1;
var couple = dbContext.MaleGuests
   .Include(m => m.Wife)
   .FirstOrDefault(m => m.Id == guestId);

변형 3

네비게이션을 한 쪽에만 두는 것은 어떨까요?

class MaleGuest : Guest 
{
   public FemaleGuest?  Wife { get; set; }
}
class FemaleGuest : Guest { }

또는

class MaleGuest : Guest { }
class FemaleGuest : Guest 
{ 
   public MaleGuest? Husband { get; set; }
}

사실 이는 (0,1) : (1:1) 로 설정하는 것이라 적절한 설정이 아니라 할 수 있습니다.

1 Like