
Inflearn - '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part8: Entity Framework Core'를 스터디하며 정리한 글입니다.
Configuration : 세부설정
Convention -> Data Annotation -> Fluent API 순서로 우선순위
각종 형식과 이름 등을 정해진 규칙에 맞게 만들면 EF Core에서 알아서 처리
쉽고 빠르지만, 모든 경우를 처리할 수는 없음
ex) 클래스이름Id -> PK로 자동인식
Entity Class 관련
public 접근 한정자로 선언 + non-staticpublic getter를 찾으면서 분석이름, 형식, 크기 관련
PK 관련
<클래스이름>Id 정의된 property -> PK로 인정class / property 등에 Attribute를 붙여 추가 정보
ex) [Table("Item)"] -> Table 이름 "Item"으로 설정
OnModelCreating에서 직접 설정을 정의해서 만드는 '귀찮은' 방식
활용범위는 가장 넒음
ex)
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Item>().HasQueryFilter(i => i.SoftDeleted == false);
}
Q1. DB column type, size, nullable
| Convention | Annotation | Fluent Api | |
|---|---|---|---|
| Nullable | [Required] | .isRequired() | |
| 문자열 길이 | [MaxLength(20)] | .HasMaxLength(2) | |
| 문자 형식 | .IsUnicode(true) |
Q2. PK
| Convention | Annotation | Fluent Api | |
|---|---|---|---|
| PK | <클래스이름>Id | [Key] | .HasKey() |
| 복합키 | [Key][Column(Order=0)],[Key][Column(Order=1)] | .HasKey(x => new{x.Prop1, x.Prop2}) |
Q3. Index
| Convention | Annotation | Fluent Api | |
|---|---|---|---|
| 인덱스 추가 | .HasIndex(p => p.Prop1) | ||
| 복합 인덱스 추가 | .HasIndex(p => p.Prop1, p.Prop2) | ||
| 인덱스 이름 정해서 추가 | .HasIndex(p => p.Prop1).HasName("Index_Name") | ||
| 유니크 인덱스 추가 | .HasIndex(p => p.Prop1).IsUnique() |
Q4. 테이블 이름
| Convention | Annotation | Fluent Api | |
|---|---|---|---|
| Table Name | DBSet<T> property이름 or class 이름 | [Table("Name")] | .ToTable("Name") |
Q5. 칼럼 이름
| Convention | Data Annotation | Fluent Api | |
|---|---|---|---|
| Column Name | 변수 이름 | [Column("Name")] | .HasColumnName("Name") |
Q6. 코드 모델링에서는 사용, DB 모델링에서는 제외 - property/class
| Convention | Data Annotation | Fluent Api |
|---|---|---|
| [NotMapped] | .Ignore() |
Q7. Soft Delete
| Convention | Data Annotation | Fluent Api | |
|---|---|---|---|
| Soft Delete | .HasQueryFilter() |
언제 무엇을 우선으로 사용해야 하나?
1) Convention이 가장 무난
2) Validation과 관련된 부분들은 Data Annotaion - 직관적, SaveChanges 용이
3) 그 외에는 Fluent Api
Convention -> Data Annotaion -> Fluent API
FK & Nullable
1) Required Relationship (Not Null)
2) Optional Relationship (Nullable)
Convention을 이용한 FK 설정
1. <PrincipalKeyName> -> PlayerId - 가장 많이 사용
2. <Class><PrincipalKeyName> -> PlayerPlayerId
3. <NavigarionalPropertyName><PrincipalKeyName> -> OwnerPlayerId | OnwerId
Convention 방식으로 못하는 것들
public int OwnerId { get; set; }
public Player Owner { get; set; } // 아이템 소유자
public int CreatorId { get; set; }
public Player Creator { get; set; } // 아이템 창시자
-> 불가능
[ForeignKey("Prop_Name")]
[ForeignKey("Owner")]
public int OwnerId { get; set; }
public Player Owner { get; set; } // 아이템 소유자
둘 다 가능
public int OwnerId { get; set; }
[ForeignKey("OwnerId")]
public Player Owner { get; set; } // 아이템 소유자
[InverseProperty] -> 다수의 Navigational Property가 같은 클래스를 참조할 때
[InverseProperty("OwnedItem")]
public int OwnerId { get; set; }
public Player Owner { get; set; }
둘 다 가능
[InverseProperty("Owner")]
public Item OwnedItem { get; set;}

Convention으로만 선언해놓은 경우
AppDbContext.OnModelCreating()
builder.Entity<Player>()
.HasMany(p => p.CreatedItems) // Player의 입장
.WithOne(i => i.Creator) // Item의 입장
.HasForeignKey(i => i.CreatorId); // 1:M 경우 반드시 M 쪽에 FK
builder.Entity<Player>()
.HasOne(p => p.OwnedItem)
.WithOne(i => i.Owner)
// 1:1 경우 FK 위치가 정해져 있지 않기 때문에
// 캐스팅으로 지정 필요
.HasForeignKey<Item>(i => i.OwnerId);
생성 -> .Property<T>("Name")
AppDbContext.OnModelCreating
builder.Entity<Item>().Property<DateTime>("RecoveredDate");
Read/Write -> .Property("Name").CurrentValue
db.Entry(items[0]).Property("RecoveredDate").CurrentValue = DateTime.Now;

이전 버전에는 없었음
private property에 하나의 layer를 더 추가하여 public property로 접근
public struct ItemOption
{
public int str;
public int dex;
public int hp;
}
[Table("Item")]
public class Item
{
private string _jsonData;
public string JsonData
{
get { return _jsonData; }
//set { _jsonData = value; }
}
public void SetOption(ItemOption option)
{
_jsonData = JsonConvert.SerializeObject(option);
}
public ItemOption GetOption()
{
return JsonConvert.DeserializeObject<ItemOption>(_jsonData);
}
}
DbCommands.cs
items[0].SetOption(new ItemOption() { dex = 1, hp = 2, str = 3 });
public property의 경우 setter가 반드시 있어야 DB에 들어갈 수 있음
-> 일반적으로 Fluent Api 방식만 사용 : .HasField("Prop_Name")
AppDbContext.OnModelCreating()
// Backing Field
builder.Entity<Item>()
.Property(i => i.JsonData)
.HasField("_jsonData");

Entity Class 하나를 통으로 Read/Write -> 부담
일반 class를 Entity Class에 추가하는 개념
public class ItemOption
{
public int Str { get; set; }
public int Dex { get; set; }
public int Hp { get; set; }
}
-> PK 없이 Item Entity에서 ItemOPtion을 소유하고 싶을 때
public class Item
{
...
public ItemOption Option { get; set; }
...
}
.OwnsOne()AppDbContext.OnModelCreating()
// Owned Type
builder.Entity<Item>()
.OwnsOne(i => i.Option);
Relationship이 아닌 Ownership의 개념이기 때문에
.Include()없이 자동 로딩

장점 : 관리 측면, 효율성 측면 모두 유용함
.OwnsOne().ToTable()AppDbContext.OnModelCreating()
// Owned Type
builder.Entity<Item>()
.OwnsOne(i => i.Option)
.ToTable("ItemOption");

ItemOption 테이블은 ItemId가 PK, FK 겸용
상속 관계의 여러 class를 하나의 테이블에 매핑
AppDbContext.cs
public DbSet<Item> Items { get; set; }
// TPH
public DbSet<EventItem> EventItems { get; set; }

단점 : Item을 접근하고 싶을 때 Items,EventItems 따로 접근해야 함
.HasDiscriminator().HasValue()public enum ItemType
{
NormalItem,
EventItem
}
-> Item.Type
DbCommands.CreateTestData()
new EventItem()
{
TemplateId = 102,
CreatedDate = DateTime.Now,
Owner = faker,
DestoryDate = DateTime.Now,
},
AppDbContext.OnModelCreating()
// TPH
builder.Entity<Item>()
.HasDiscriminator(i => i.Type) // 구분자
.HasValue<Item>(ItemType.NormalItem) // 구분 값
.HasValue<EventItem>(ItemType.EventItem);

.ToTable("Table_Name")public class ItemDetail
{
public int ItemDetailId { get; set; }
public string Description { get; set; }
}
-> Item.Detail
// Convention 만으로 빌드하면 Navigational Property로 다른 Table이 생성됨
DbCommands.CreateTestData()
// Test Table Splitting
items[2].Detail = new ItemDetail(){ Description = "this is detail item" };
AppDbContext.OnModelCreating()
// Table Splitting
builder.Entity<Item>()
.HasOne(i => i.Detail)
.WithOne() // ItemDetail에는 X
.HasForeignKey<ItemDetail>(i => i.ItemDetailId);
// 같은 "Item" 테이블로 몰빵
builder.Entity<Item>().ToTable("Items");
builder.Entity<ItemDetail>().ToTable("Items");
Ownership이 아닌 Relationship의 개념이기 때문에 로딩 시
.Include()필요

Backing Field : private field를 DB에 매핑
Navigational Property에도 사용 가능!
-> private Field로 사용하면서도 DB에 테이블도 생성
DataModel.cs
public class ItemReview
{
public int ItemReviewId { get; set; }
public int Score { get; set; } // 0~5점
}
public class Item
{
public double? AverageScore { get; set; } // 평균 별점
public readonly List<ItemReview> _reviews = new List<ItemReview>();
// public ICollection<ItemReview> Reviews { get; set; }
public IEnumerable<ItemReview> Reviews { get { return _reviews.ToList(); } }
public void AddReview(ItemReview review)
{
_reviews.Add(review);
AverageScore = _reviews.Average(r => r.Score);
}
public void RemoveReview(ItemReview review)
{
_reviews.Remove(review);
// 리뷰가 하나라도 있다면 평균
AverageScore = _reviews.Any() ? _reviews.Average(r => r.Score) : (double?)null;
}
}
readonly : get은 가능 set은 불가능
IEnumerable : 직접적으로 Add 불가능
AppDbContext.OnModelCreaing()
// Backing Field + Relationship
builder.Entity<Item>()
.Metadata
.FindNavigation("Reviews")
.SetPropertyAccessMode(PropertyAccessMode.Field);
DbCommands.CreateTestData()
// Backing Field + Relationship
items[0].AddReview(new ItemReview() { Score = 5 });
items[0].AddReview(new ItemReview() { Score = 4 });
items[0].AddReview(new ItemReview() { Score = 1 });
items[0].AddReview(new ItemReview() { Score = 5 });

User Defined Function (UDF) : 사용자가 직접 만든 SQL을 호출하게 하는 기능
UDF Step
직접 계산하던 ItemReview의 AverageScore -> DB 쪽에서 계산하도록 변경
public clas Item
{
public ICollection<ItemReview> Reviews { get; set; }
}
함수 EF Core 등록 방법
[DbFunction()]HasDbFunction()Program.cs
// Annotation(Attribute)
[DbFunction()]
public static double? GetAverageReviewScore(int itemId)
{
// C#에서 사용하는 용도가 아닌 UDF 용도
throw new NotImplementedException("사용 금지!");
}
OR
// AppDbContext.OnModelCreating()
builder.HasDbFunction(() => Program.GetAverageReviewScore(0));
Database에 사용자 정의 함수 SQL로 전달
DbCommands.InitializeDB()
string command =
@" CREATE FUNCTION GetAverageReviewScore (@itemId INT) RETURN FLOAT
AS
BEGIN
DECLARE @result AS FLOAT
SELECT @result = AVG(CAST([Score] AS FLOAT))
FROM ItemReview As r
WHERE @itemId = r.ItemId
RETURN @result
END
";
db.Database.ExecuteSqlRaw(command);
DbCommands.CreateTestData()
// UDF Test
items[0].Reviews = new List<ItemReview>()
{
new ItemReview(){Score = 5},
new ItemReview(){Score = 3},
new ItemReview(){Score = 2},
};
items[1].Reviews = new List<ItemReview>()
{
new ItemReview(){Score = 1},
new ItemReview(){Score = 1},
new ItemReview(){Score = 0},
};
DbCommands.cs
public static void ClacAverage()
{
using(AppDbContext db = new AppDbContext())
{
// Program.GetAverageReviewScore(itemId) // 오류 발생
foreach(double? average in db.Items.Select(i => Program.GetAverageReviewScore(i.ItemId)))
{
if(average == null)
Console.WriteLine("No Review!");
else
Console.WriteLine($"Average : {average.Value}");
}
}
}


초기값을 설정할 때 주의할 점
-> EF & DB 연동 외에 다른 경로로 DB를 사용한다면, 차이가 날 수 있음
ex) SQL Script
.HasDefaultValue.HasDefaultValueSqlEntity Class 자체의 초기값으로 적용되는 경우
public DateTime CreatedDate { get; set; } = new DateTime(2020, 1, 1);

DB Table 차원에서 초기값으로 적용되는 경우
.HasDefaultValuebuilder.Entity<Item>()
.Property("CreateDate")
.HasDefaultValue(new DateTime(2020, 1, 1));

DateTime.Now와 관련된 부분은 현재 시간이 제대로 들어가지 않음
DB Table 차원에서 초기값으로 적용되는 경우
.HasDefaultValueSql// 임의로 외부에서 직접 할 수 없게 private set으로 제한해야 함
public DateTime CreatedDate { get; private set; }
builder.Entity<Item>()
.Property("CreatedDate")
.HasDefaultValueSql("GETDATE()");

DB Table 차원에서 초기값으로 적용되는 경우
DataModel.cs
public class PlayerNameGenerator : ValueGenerator<string>
{
public override bool GeneratesTemporaryValues => false;
public override string Next(EntityEntry entry)
{
string name = $"Player_{DateTime.Now.ToString("yyyyMMdd")}";
return name;
}
}
builder.Entity<Player>()
.Property(p => p.Name)
.HasValueGenerator((p, e) => new PlayerNameGenerator());
