Entity Framework Core (이하 EF Core)는 SQLite나 SQL Server와 같은 관계형 database에 data를 읽고 쓰기 위한 객체-데이터 저장 mapping 기술입니다.
1. Database
Database에는 크게 2가지 종류가 있는데 하나는 RDBMS(Relational Database Management System)으로 SQL Server, PostgreSQL, MySQL, SQLite 등이 있고 다른 하나는 NoSQL로서 Azure Cosmos DB, Redis, MongoDB, Apache Cassandra 등이 있습니다.
관계형 database는 1970년대 개발된 것으로 SQL(Structured Query Language)을 통해 data를 질의합니다. 그 당시 data를 저장하는데는 많은 비용이 소모되었으므로 가능한 한 data의 중복을 최소화해야 했습니다. Data는 행(Row)과 열(Column)로 이루어진 table구조에 저장되었는데 일단 완성되고 나면 refactor 하기에는 까다로운 구조였습니다.
NoSQL은 단순히 SQL이 없음을 의미하지는 않습니다. 다른 뜻으로는 굳이 SQL일 필요가 없다는 의미가 될 수도 있습니다. 2000년대부터 개발되어온 NoSQL은 당시 Internet과 Web이 대중화되고 software에 관한 많은 학습이 이루어지던 시기였으며 또한 막대한 확장성과 고성능을 위해 설계되었고 최대한의 유연성을 제공함으로써 개발자들의 집입장벽을 낮추었습니다. 또한 구조를 강제하지 않음으로써 이들 사이에서는 언제든 schema를 변경할 수 있습니다.
(1) Entity Framework
Entity Framework는 2008년 말 Service pack 1과 함께 .NET Framework 3.5의 일부로 처음 출시되었습니다. 그런 후 Entity Framework는 지금까지 계속해서 발전을 거듭하고 있습니다.
ORM은 mapping정의를 사용하여 class의 속성을 table의 column으로 연결합니다. 그러면 개발자는 관계형 table 혹은 NoSQL data store에 의해 제공된 다른 구조로 값을 어떻게 저장해야 할지 알아야 하는 대신 자신에게 친숙한 방식으로 다른 type 간 개체를 통해 상호작용할 수 있습니다.
.NET Framework에 포함된 EF의 version은 Entity Framework 6 (EF6)로서 성숙되고 안정적이며 복잡한 상속 model뿐 아니라 model을 정의하는 EDMX(XML file)와 그 외 몇 가지 향상된 기능을 지원합니다.
EF 6.3부터는 .NET Framework에서 별도의 package로 분리되어 .NET Core 3.0부터 지원이 가능하게 되었습니다. Web application이나 service와 같은 기존 project에서 cross-platform으로 이식하고 동작할 수 있으나 EF6는 cross-platform에서 동작시 일부 제한적이며 더 이상 새로운 기능이 추가되지 않으므로 legacy technology로 간주되고 있습니다.
● Entity Framework의 사용
Legacy technology인 Entity Framework를 .NET Core 3.0이나 이후 project에서 사용하려면 project file에서 아래와 같이 package 참조를 추가해야 합니다.
<PackageReference Include="EntityFramework" Version="6.4.4" />
예전 WPF app을 migration하는 경우와 같은 상황만 필요하다면 EF6를 사용해야 합니다.
(2) Entity Framework Core
진정한 cross-platform version인 EF Core는 Entity Framework와의 가장 큰 차이라고 할 수 있습니다. 비록 EF Core가 이름이 비슷하기는 하지만 EF6와는 분명히 다른 것입니다. 가장 최신의 EF Core version은 7이며 .NET7과 일치합니다.
EF Core 5부터는 .NET5와 그 이후만 지원하며 그 이하 EF Core 3은 .NET Standard 2.1을 지원하는 platform에서만 동작합니다. EF Core 3부터는 .NET Framework 4.8과 같은 .NET Standard 2.0 platform을 지원하지 않습니다.
EF Core 7은 .NET 6부터의 version을 target으로 합니다. 다시 말해 EF Core 7에서의 모든 새로운 기능은 .NET 6혹은 .NET 7에서만 사용할 수 있습니다. 만약 현재 .NET 6을 사용 중이라면 version 7에서 참조하는 EF Core package를 upgrade 할 것을 권장합니다.
전통적인 RDBMS뿐만 아니라 EF Core는 cloud기반의 비관계형 Database인 Azure Cosmos DB와 MongoDB등도 지원하고 있습니다.
EF Core는 많은 개선점을 가지고 있지만 이 모두를 다루지는 않을 것입니다. 예를 들어 EF Core 7과 함께 도입된 새로운 기능에는 JSON 문서를 저장하는 column을 가질 수 있는 database가 해당 문서를 질의하고 filter와 정렬 표현식에서 문서의 요소를 사용하는 JSON column을 지원하지만 EF Core 7에서 JSON column 기능은 단지 SQL Server를 위해서만 구현됩니다. 향후 EF Core version은 SQLite와 같은 다른 database도 추가될 테고 그 시점에 해당 내용을 다루게 될지도 모르지만 현재는 개발자가 알아야 할 기본적인 사항과 일부 유용한 기능을 살펴보는데만 집중할 것입니다.
(3) Database First와 Code First
EF core를 사용하기 위해서는 아래 2가지 방법을 선택할 수 있습니다.
- Database First : Database가 이미 존재하는 상태에서 구조및 특징과 일치하는 model을 build 합니다.
- Code First : Database가 존재하지 않는다면 model을 build 한 뒤 해당 구조 및 특징과 일치하는 database를 EF Core를 사용해 생성합니다.
(4) EF Core 7에서의 성능 향상
EF Core team은 EF Core의 성능향상을 위해 많은 노력을 기울여 왔습니다. 예를 들어 EF Core 7이 SaveChanges가 호출될 때 database에 대해 단일문만이 실행된다고 식별하는 경우 이전 version과 마찬가지로 명시적인 transaction을 생성하지 않습니다. 이와 같은 방식은 일반적인 경우에서 약 25% 정도의 성능향상을 기대할 수 있습니다.
이와 같이 내부적으로 적용된 최신의 개선사항들은 이들이 어떻게 동작하는가에 대한 이해가 없이도 사용하는 것만으로 그에 대한 혜택을 얻을 수 있습니다. 만약 이러한 세부적인 사항에 관심이 있다면(특히 일부 멋진 SQL Server 기능을 활용한 방식에 대해) EF Core team이 작성한 아래 글을 읽어보시길 바랍니다.
Announcing Entity Framework Core 7 Preview 6: Performance Edition (microsoft.com)
Announcing Entity Framework Core 6.0 Preview 4: Performance Edition (microsoft.com)
(5) EF Core를 사용한 console app 만들기
우선 console app을 만들기 위해 csStudy10 solution을 생성하고 그 안에 WorkingWithEFCore이름의 Console App project를 추가합니다.
(6) Sample database
.NET을 통해 RDBMS를 어떻게 사용할 수 있을지를 알아보기 위해서는 적당한 수의 record와 적당한 수준의 복잡도를 가진 sample database가 필요합니다. 이를 위해 Microsoft는 몇 가지 sample database를 제공하고 있는데 너무 복잡한 것을 제외하고 사용가능한 가장 적합한 database로 1990년대 가장 먼저 생성된 database인 Northwind를 사용할 것입니다.
원격지나 로컬에 MSSQL Server를 설치하고 Northwind Database를 아래 script file로 생성합니다. MSSQL에 관한 제사한 사항은 아래 link를 참고하시기 바랍니다.
[Server/SQL Server] - [MSSQL] MS SQL Server 다운로드 및 설치/설정
참고로 해당 Database의 전체 구조는 아래와 같습니다.
위 구조를 보면 다음과 같은 사항을 알 수 있습니다.
- 각 category는 고유한 식별자, 이름, 설명, 이미지를 갖고 있습니다.
- 각 product는 고유한 식별자, 이름, 단위 단가, 재고 단위 및 기타 field를 갖고 있습니다.
- 각 product는 category의 고유한 식별자를 통해 특정 category에 할당됩니다.
- Category와 product의 관계는 각 category가 product를 0개 이상 가질 수 있는 1대다의 관계입니다.
2. EF Core 설정
EF Core를 사용하기 전 간단히 어떤 EF Core database 공급자를 선택할 수 있는지를 알아보도록 하겠습니다..
(1) EF Core database provider (공급자)
특정한 database의 data에 접근하기 위해서는 어떻게 database와 효휼적으로 data를 주고받을지를 알고 있는 class를 사용해야 합니다.
EF Core database 공급자는 이러한 일련의 class들을 말하며 특정한 database에 최적화되어 있습니다. 이러한 공급자들 중에는 심지어 현재 process의 memory에 data를 저장하는 공급자도 존재하며 특히 외부 system과의 충돌을 피할 수 있기 때문에 고성능 단위 test에서 사용하기 적합합니다.
아래표는 저장소별 NuGet package에 배포된 package를 나타내고 있습니다.
용도 | NuGet Package |
SQL Server 2012 부터 | Microsoft.EntityFrameworkCore.SqlServer |
SQLite 3.7 부터 | Microsoft.EntityFrameworkCore.SQLite |
In-memory | Microsoft.EntityFrameworkCore.InMemory |
Azure Cosmos DB SQL API | Microsoft.EntityFrameworkCore.Cosmos |
MySQL | MySQL.EntityFrameworkCore |
Oracle DB 11.2 | Oracle.EntityFrameworkCore |
PostgreSQL | Npgsql.EntityFrameworkCore.PostgreSQL |
필요하다면 하나의 project에서 다수의 EF Core database 공급자를 설치할 수 있으며 각 package는 공급자별 type뿐만 아니라 필요한 여러 공유 type을 포함하고 있습니다.
(2) Database 연결
Database연결을 위해서는 연결을 위한 연결문자열을 설정해야 합니다. 위에서 MSSQL Server를 설치했다는 가정하에 해당 server IP 및 login 계정 등의 정보를 묶어 하나의 연결문자열을 만들어줍니다.
(3) Northwind database context class 정의
Northwind class는 database를 표현하는 데 사용될 것입니다. 여기에 EF core를 사용하기 위해서 class는 DbContext로부터 상속받아야 합니다. 해당 class는 database와 어떻게 소통할지를 알고 있으며 data의 질의와 수정/삭제등의 조작을 위해 동적으로 SQL문을 생성합니다.
DbContext로 부터 파생된 class는 OnConfiguring라는 이름의 override 된 method를 갖게 되는데 해당 method를 통해 database 연결 문자열을 설정합니다.
우선은 project에서 MSSQL을 위한 EF Core 공급자인 Microsoft.EntityFrameworkCore.SqlServer NuGet package를 설치하고 Northwind.cs 이름의 class file을 추가합니다.
Northwind.cs file에서는 EF Core에 대한 namespace를 import한뒤 Northwind class를 정의하고 DbContext부터 상속받도록 합니다. 그리고 OnConfiguring method에서 MSSQL Server를 사용하기 위한 option builder를 아래와 같이 구성해 줍니다.
public class Northwind : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connection = "Server=localhost;user id=sa;password=!123;Database=Northwind;MultipleActiveResultSets=true";
Console.WriteLine($"Connection: {connection}");
optionsBuilder.UseSqlServer(connection);
}
}
Program.cs에서는 기존의 문을 모두 삭제하고 사용하는 database 공급자를 확인할 수 있는 구문을 아래와 같이 작성합니다.
Northwind db = new();
Console.WriteLine($"Provider: {db.Database.ProviderName}");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다. 이를 통해 database 연결문자열과 사용 중인 database 공급자를 확인할 수 있습니다.
3. EF Core model 정의
EF Core는 규칙과 annotation attribute의 결합 및 Fluent API 문을 사용하여 runtime에서 entity model을 build 합니다. 따라서 class상에서 수행되는 모든 동작은 나중에 자동적으로 실제 database에서 수행되는 동작으로 변환될 수 있습니다. entity class는 table의 구조를 나타내며 class의 instance는 table의 row로서 표현됩니다.
우선 예제를 통해 model을 정의하기 위한 3가지 방식을 검토하고 이러한 기술을 구현하는 몇 가지 class를 만들어볼 것입니다.
(1) model을 정의하기 위한 EF Core 규칙
예제로 작성될 code는 아래 규칙을 사용할 것입니다.
- Table의 이름은 예를 들어 Products와 같이 DbContext class안에 있는 DbSet<T> 속성의 이름과 연결된다고 간주합니다.
- Column의 이름은 예를 들어 ProductId와 같이 entity model class에 있는 속성의 이름과 연결된다고 간주합니다.
- string .NET type은 database의 nvarchar type이 된다고 간주합니다.
- int .NET type은 database의 int type이 된다고 간주합니다.
- Primary key는 Id 혹은 ID이름의 속성이 된다고 간주합니다. 그런데 속성의 이름은 entity model class가 Product로 명명될 때 ProductId 혹은 ProductID로 명명될 수 있습니다. 해당 속성이 integer type 이거나 Guid type이라면 이것은 또한 IDENTITY column(insert시 자동적으로 값이 부여됨)이 될 수 있다고 간주합니다.
이외에 알아야 할 많은 규칙들이 존해하며 자신만의 것을 정의할 수도 있습니다. 이와 관련해서는 아래 link를 참고하시기 바랍니다.
Creating and Configuring a Model - EF Core | Microsoft Learn
(2) Model을 정의하기 위한 EF Core annotation attribute 사용하기
물론 위에서 언급한 규칙만으로는 database개체와 class를 완전히 연결시키에 충분하지 못할 수 있습니다. 이때 model을 더욱 database에 근접시키기 위한 가장 간단한 방법은 annotation attribute를 적용하는 것입니다.
아래 표는 일반적으로 사용되는 몇 가지 attribute를 나타내고 있습니다.
Attribute | Description |
[Required] | 값은 null이 될 수 없습니다. |
[StringLength(50)] | 값은 50문자길이를 넘을 수 없습니다. |
[RegularExpression(표현식)] | 값은 지정한 정규표현식과 일치해야 합니다. |
[Column(TypeName = "money", Name = "UnitPrice")] | Column의 이름(Name)과 Type(TypeName)을 지정합니다. |
예를 들어 database에서 product name의 최대 길이는 40이며 값은 null일 수 없습니다. 이와 같은 사실은 어떻게 Products라는 table이 지정한 data type, key와 기타 제약사항을 가진 column과 함께 생성될지를 정의하는 DDL(Data Definition Language) 문을 통해 알 수 있는데
CREATE TABLE [dbo].[Products](
[ProductId] [int] IDENTITY(1,1) NOT NULL,
[ProductName] [nvarchar](40) NOT NULL,
[SupplierId] [int] NULL,
[CategoryId] [int] NULL,
[QuantityPerUnit] [nvarchar](20) NULL,
[UnitPrice] [money] NULL,
[UnitsInStock] [smallint] NULL,
[UnitsOnOrder] [smallint] NULL,
[ReorderLevel] [smallint] NULL,
[Discontinued] [bit] NOT NULL,
CONSTRAINT [PK_Products] PRIMARY KEY CLUSTERED
(
[ProductId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
...
이러한 사항을 Product class에 반영하려면 아래와 같이 attribute를 적용할 수 있습니다.
[Required]
[StringLength(40)]
public string ProductName { get; set; }
어떤 경우에는 .NET type과 database type사이를 연결시키는 데 사용가능한 attribute가 분명하지 않을 수 있습니다. 예를 들어 database에서 Products의 UnitPrice column type이 money인데 .NET은 money라는 type이 존재하지 않으므로 이런 경우에는 아래와 같이 data범위가 유사한 decimal을 대신 사용하고 Column attribute를 통해 database의 type을 명시합니다.
[Column(TypeName = "money")]
public decimal? UnitPrice { get; set; }
(3) Model을 정의하기 위한 EF Core Fluent API 사용하기
Model을 정의할 수 있는 또 다른 방법은 Fluent API를 사용하는 것입니다. API는 attribute대신 사용할 수 있으며 뿐만 아니라 속성 외에도 사용할 수 있습니다. 예를 들어 ProductName속성을 정의하기 위해 속성에 2개의 attribute를 적용하는 대신 동등한 Fluent API문을 database context class의 OnModelCreating method에 아래와 같이 작성할 수 있습니다.
modelBuilder.Entity<Product>()
.Property(product => product.ProductName)
.IsRequired()
.HasMaxLength(40);
위와 같이 하면 entity model class를 단순하게 유지할 수 있습니다.
● Fluent API를 사용한 data 제공
Fluent API를 사용함으로써 생기는 또 다른 이점은 database를 채우기 위해 초기 data를 제공할 수 있다는 것입니다. EF Core는 반드시 실행되어야 하는 insert, update, delete와 같은 동작을 자동으로 실행합니다.
예를 들어 새로운 database에서 Product table은 최소 하나의 행을 가져야 한다면 HasData method를 아래와 같이 호출할 수 있습니다.
modelBuilder.Entity<Product>().HasData(new Product {
ProductId = 1,
ProductName = "Chai",
UnitPrice = 8.99M
});
예제에서 사용할 model은 이미 data가 존재하는 database와 연결하게 될 것이므로 위와 같은 방법은 사용하지 않을 것입니다.
(4) Northwind table에 대한 EF Core model 구축
위에서 EF Core model을 정의하는 방법을 알게 되었으므로 이를 통해 Northwind database에 대한 2개 table을 표현하는 model을 만들 것입니다.
2개 entity class는 서로를 참조할 것이므로 compiler error를 피하기 위해 처음에는 어떠한 member도 없이 class를 만들 것입니다.
WorkingWithEFCore project에서 Category.cs와 Product.cs이름의 class file을 추가합니다. 그리고 Category.cs에서 Category class를 정의하고 Product.cs에서 Product class를 정의합니다.
namespace WorkingWithEFCore
{
public class Category
{
}
}
namespace WorkingWithEFCore
{
public class Product
{
}
}
● Category와 Product entity class정의하기
Entity model인 Category class는 Categories table의 row를 나타내는 데 사용될 것입니다. 이 table은 아래와 같이 4개의 column을 가지고 있습니다.
CREATE TABLE [dbo].[Categories](
[CategoryId] [int] IDENTITY(1,1) NOT NULL,
[CategoryName] [nvarchar](15) NOT NULL,
[Description] [ntext] NULL,
[Picture] [image] NULL,
CONSTRAINT [PK_Categories] PRIMARY KEY CLUSTERED
(
[CategoryId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
따라서 model을 정의하기 위한 다음의 규칙을 감안해야 합니다.
- 3~4개의 속성 (Picture column은 사용하지 않을 것입니다.)
- Primary key
- Products table과의 1:N 관계
Description column을 database type과 정확히 연결시키기 위해서는 string 속성에 Column attribute에 대한 적용이 필요합니다. 나중에는 Fluent API를 사용하여 CategoryName이 null일 수 없고 최대 15자로 제한됨을 정의할 것입니다.
Category entity model class를 아래와 같이 변경합니다.
using System.ComponentModel.DataAnnotations.Schema; // [Column] attribute
namespace WorkingWithEFCore
{
public class Category
{
public Category()
{
// products를 Cateogry로 추가시킬 수 있도록 하기 위해서는 navigation 속성을 빈 collection으로 초기화 해야 합니다.
Products = new HashSet<Product>();
}
//아래 속성은 database의 column과 일치합니다.
public int CategoryId { get; set; }
public string? CategoryName { get; set; }
[Column(TypeName = "ntext")]
public string? Description { get; set; }
//Table간의 관계를 나타내기 위해 정의하는 navigation속성입니다.
public virtual ICollection<Product> Products { get; set; }
}
}
Product class도 역시 아래와 같이 변경합니다.
using System.ComponentModel.DataAnnotations; // [Required], [StringLength]
using System.ComponentModel.DataAnnotations.Schema; // [Column]
namespace WorkingWithEFCore
{
public class Product
{
public int ProductId { get; set; } // primary key
[Required]
[StringLength(40)]
public string ProductName { get; set; } = null!;
[Column("UnitPrice", TypeName = "money")]
public decimal? Cost { get; set; } // Column attribute를 적용함으로서 여기서 속성의 이름은 column의 이름으로 사용되지 않습니다.
[Column("UnitsInStock")]
public short? Stock { get; set; }
public bool Discontinued { get; set; }
// 이 2개의 속성은 Categories table에 대한 외래key를 정의합니다.
public int CategoryId { get; set; }
public virtual Category Category { get; set; } = null!;
}
}
위 예제에서는 다음 사항에 주목합니다.
- Product class는 Products table의 row를 표현하는 데 사용되며 10개 정도의 column을 가지고 있습니다.
- Table로부터의 모든 column을 class의 속성으로 포함시킬 필요는 없습니다. 예제에서도 단지 ProductId, ProductName, UnitPrice, UnitsInStock, Discontinued, CategoryId에 대한 6개의 속성만을 일치시키고 있습니다.
- 속성으로 일치되지 않는 column은 class를 통해 읽거나 설정할 수 없습니다. 새로운 개체를 생성하는데 class를 사용한다면 table의 새로운 row에서 일치되지 않는 column은 NULL이 되거나 다른 기본값이 됩니다. 누락된 column이 생기는 경우 이들 column은 선택적이거나 database에서 기본값을 설정했거나 runtime에서 예외가 발생하는지를 확인해야 합니다. 지금 예제의 경우에 row는 이미 data값을 가지고 있으며 application에서 해당 값들을 읽을 필요가 없으므로 무시하기로 합니다.
- 예제에서 Cost속성처럼 Column의 이름과는 다른 이름으로 속성을 정의할 수도 있습니다. 이런 경우 속성에 [Column] attribute를 적용하고 여기에 UnitPrice처럼 column의 이름을 지정할 수 있습니다.
- 마지막 속성인 CategoryId는 각 product에서 부모 category와 일치시키는 데 사용되는 Category 속성과 연결됩니다.
2개의 entity를 연결시키는 2개의 속성인 Category.Products와 Product.Category는 둘 다 virtual로 수식되었습니다. 이는 EF Core가 lazy loading과 같이 추가 기능을 제공하기 위해 해당 속성을 상속하고 override 할 수 있도록 합니다.
(5) Northwind database context class에 table추가하기
DbContext로부터 파생된 class에서는 최소 하나의 DbSet<T> type에 대한 속성을 정의해야 합니다. 이 속성은 table을 의미합니다. EF Core에게 table의 가진 각 column이 어떤 것인지를 말해주기 위해 DbSet<T>속성은 generic을 사용하여 table의 row를 나타내는 class를 지정하고 해당 class는 column을 나타내는 속성을 가지게 됩니다.
DbContext 파생 class는 선택적으로 OnModelCreating이름의 override method를 가질 수 있습니다. 여기에 Fluent API를 class에 attribute를 적용하는 대신 작성할 수 있습니다.
Northwind class에서 Categories와 Products에 대한 2개의 table과 OnModelCreating method를 정의하는 문을 아래와 같이 추가합니다.
public class Northwind : DbContext
{
public DbSet<Category>? Categories { get; set; }
public DbSet<Product>? Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connection = "Server=localhost;user id=sa;password=!123;Database=Northwind;MultipleActiveResultSets=true";
Console.WriteLine($"Connection: {connection}");
optionsBuilder.UseSqlServer(connection);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Fluent API를 사용해 CategoryName의 최대 길이를 15자로 제한합니다.
// 이는 attribute를 적용하는 것으로도 가능합니다.
modelBuilder.Entity<Category>()
.Property(category => category.CategoryName)
.IsRequired() // NOT NULL
.HasMaxLength(15);
}
}
(6) dotnet-ef 도구 설정
dotnet이라는 .NET CLI 도구는 EF Core사용에 유용한 기능으로 확장될 수 있습니다. 이 기능은 이전 model에서 새로운 model로 migration을 생성하고 적용하는 것과 같은 design-time작업을 수행할 수 있으며 기존 database로부터 code를 생성할 수도 있습니다.
dotnet ef 명령줄 도구는 자동적으로 설치되지 않으므로 global이나 local도구를 통해 package를 설치해야 합니다. 만약 해당 도구의 이전 version이 이미 설치된 경우라면 기존 version을 모두 삭제해야 합니다.
Terminal에서 우선 dotnet-ef가 global도구로 설치되어 있는지를 확인합니다.
dotnet tool list --global |
위와 같이 이전 version (위 화면에서는 7.0.4인데 이는 비교적 최신 version에 해당합니다.)이 이미 설치되어 있다면 아래와 같은 방식으로 도구를 제거할 수 있습니다.
dotnet tool uninstall --global dotnet-ef |
설치는 uninstall대신 install을 사용합니다.
dotnet tool install --global dotnet-ef |
필요하다면 OS별 지침에 따라 PATH 환경 변수에 dotnet tools directory를 추가할 수 있습니다.
(7) 기존 database를 사용한 Scaffolding model
기존 database에서 model을 표현하기 위한 class를 생성하는 것을 reverse engineering이라고 하며 이때 도구를 사용하는 처리를 Scaffolding이라고 합니다. 좋은 Scaffolding 도구는 자동적으로 생성된 class를 확장하고 확장된 class의 손실 없이 이들 class를 재생성할 수 있도록 합니다.
도구를 사용해 class를 재생성할 일이 없다고 하더라도 원하는 만큼 자동적으로 생성된 class에서 code를 자유롭게 변경할 수 있습니다. 도구에 의해 생성된 code는 그저 최상의 근사치일 뿐입니다.
도구를 사용하지 않고도 최상의 결과를 얻을 수 있다면 굳이 도구사용을 시도할 필요는 없습니다.
위에서 수동적으로 생성했던 것처럼 도구가 같은 model을 생성하는지 확인해 보도록 하겠습니다.
우선 WorkingWithEFCore project에서 최신의 Microsoft.EntityFrameworkCore.Design package를 설치하고 project를 build 합니다. 그런 뒤 terminal에서 WorkingWithEFCore folder(csproj project file이 있는)로 찾아 들어가고 아래 명령을 통해 AutoGenModels라는 folder에 Categories와 Products에 대한 model을 생성하도록 합니다.
dotnet ef dbcontext scaffold "Server=localhost;user id=sa;password=!123;Database=Northwind;TrustServerCertificate=true" Microsoft.EntityFrameworkCore.SqlServer --table Categories --table Products --output-dir AutoGenModels --namespace WorkingWithEFCore.AutoGen --data-annotations --context Northwind |
위 명령줄에 사용된 것 중 server주소와 id 및 암호는 귀하의 server에 따라 다릅니다.
위 명령줄에 사용된 각 항목의 의미는 아래와 같습니다.
- 명령어는 dbcontext scaffold입니다.
- 연결문자열은 Server=localhost;user id=sa;password=!123;Database=Northwind;TrustServerCertificate=true 입니다.
- Database 공급자는 Microsoft.EntityFrameworkCore.SqlServer 입니다.
- Model로 생성하기 위한 table은 --table Categories --table Products로 지정합니다.
- Model은 --output-dir AutoGenModels로 지정한 경로에 생성합니다.
- Model의 namespace는 --namespace WorkingWithEFCore로 지정합니다.
- Fluent API와 data annotation 사용을 위해 --data-annotations를 지정합니다.
- context명칭을 --context Northwind로 지정합니다.
위 명령을 실행하면 정상적으로 처리되는 경우 아래와 같은 결과를 표시하게 됩니다.
생성이 완료되면 AutoGenModels folder를 열어 아래 3개 file이 생성되어 있는지를 확인합니다.
- Northwind.cs
- Product.cs
- Category.cs
이 중에서 Category file을 열어 이전에 수동으로 작성한 file과의 차이점을 확인합니다.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace WorkingWithEFCore.AutoGen;
[Index("CategoryName", Name = "CategoryName")]
public partial class Category
{
[Key]
public int CategoryId { get; set; }
[StringLength(15)]
public string CategoryName { get; set; } = null!;
[Column(TypeName = "ntext")]
public string? Description { get; set; }
[Column(TypeName = "image")]
public byte[]? Picture { get; set; }
[InverseProperty("Category")]
public virtual ICollection<Product> Products { get; set; } = new List<Product>();
}
- Entity class에는 [Index] attribute가 적용되어 있습니다. 이는 EF Core 5.0에서 도입된 것으로 index가 있어야 하는 속성을 나타냅니다. 이전 version에서는 단지 Fluent API를 통해서만 index를 정의할 수 있었습니다. 기존 database로의 작업에서는 이것이 필요하지 않았지만 code를 통해 초기 database를 재생성하고자 한다면 해당 정보가 필요할 것입니다.
- Database에서 table의 이름은 Categories이지만 dotnet-ef 도구는 Humanizer third-party library를 사용하여 class의 이름을 Category로 단수화 하였습니다. 이는 단일 entity를 생성할 때 이름을 더욱 자연스럽게 만들기 위한 것입니다.
- Entity class는 partial keyword를 사용하여 선언되었으므로 여기에 추가적인 code가 필요할 때 일치하는 partial class를 생성할 수 있습니다. 이를 통해 신규로 추가한 code의 손실 없이 도구를 사용해 entity class를 재생성할 수 있습니다.
- CategoryId속성은 [Key] attribute가 적용되었습니다. 이는 해당 entity가 primary key임을 나타냅니다. 속성의 data type은 SQL Server에서의 int입니다.
- Products 속성은 [InverseProperty] attribute를 사용하여 Product entity class상에서 Category 속성으로 외래 key의 관계를 정의하고 있습니다.
Product.cs file도 열어 이전에 수동적으로 생성한 것과의 차이를 확인합니다.
Northwind.cs file도 역시 이전에 수동적으로 생성한 것과의 차이를 확인합니다.
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
namespace WorkingWithEFCore.AutoGen;
public partial class Northwind : DbContext
{
public Northwind()
{
}
public Northwind(DbContextOptions<Northwind> options)
: base(options)
{
}
public virtual DbSet<Category> Categories { get; set; }
public virtual DbSet<Product> Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
=> optionsBuilder.UseSqlServer("Server=localhost;user id=sa;password=!123;Database=Northwind;TrustServerCertificate=true");
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.Property(e => e.ReorderLevel).HasDefaultValueSql("((0))");
entity.Property(e => e.UnitPrice).HasDefaultValueSql("((0))");
entity.Property(e => e.UnitsInStock).HasDefaultValueSql("((0))");
entity.Property(e => e.UnitsOnOrder).HasDefaultValueSql("((0))");
entity.HasOne(d => d.Category).WithMany(p => p.Products).HasConstraintName("FK_Products_Categories");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
- Northwind data context class는 parital이므로 code를 확장하고 이후에 재생성할 수 있습니다.
- 위의 결과를 보면 2개의 생성자를 갖고 있음을 알 수 있는데 매개변수가 없는 기본 생성자와 options 매개변수를 가진 생성자가 그것입니다. 이는 runtime에서 연결문자열을 지정하고자 할 때 유용하게 사용될 수 있습니다.
- 위 결과에는 나타나지 않았지만 Categories와 Products table을 나타내는 2개의 DbSet<T>속성이 null-forgiving연산자값으로 설정될 수 있습니다. 이는 static compiler analysis가 compile time에서 경고가 발생되는 것을 방지하기 위함이며 runtime에서는 영향을 주지 않습니다.
- 위 결과에는 나타나지 않았지만 OnConfiguring method안에서는 options가 생성자에서 지정되지 않은 경우를 대비한 다음과 같은 code가 만들어질 수 있습니다.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
//#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
optionsBuilder.UseSqlServer("Server=localhost;user id=sa;password=!123;Database=Northwind;TrustServerCertificate=true");
}
}
- 또한 연결 문자열에 민감한 정보를 hardcode 하지 말 것을 알리기 위한 compiler 경고를 포함하고 있습니다.
- OnModelCreating method안에서는 Fluent API가 Product에 대한 entity class를 구성하기 위해 사용되었으며 OnModelCreatingPartial이름의 partial method를 호출합니다. 이를 통해 별도의 Fluent API configuration을 추가하기 위해 partial Northwind class를 따로 만들어 partial method method를 구현할 수 있고 model class를 재생성한다고 하더라도 추가한 code에는 영향을 주지 않을 수 있습니다.
(8) Reverse engineering template 사용자 정의하기
EF Core 7에서 새롭게 도입된 기능 중 하나이며 dotnet-ef scaffolding tool에 의해 자동적으로 생성되는 code를 개별적으로 정의할 수 있습니다. 해당 내용에 관해서는 아래 link를 참고하시기 바랍니다.
Custom Reverse Engineering Templates - EF Core | Microsoft Learn
(9) 사전 규칙 model 구성하기
Entity type과 그들의 속성에 의존하는 규칙에서 model이 더 복잡해질수록 이들을 table과 column에 성공적으로 연결시키기는 더욱 어려워집니다. 따라서 model을 분석하고 생성하기 전에 스스로에 대한 규칙을 정의할 수 있다면 해당 기능은 매우 유용하게 사용될 수 있습니다.
예를 들어 모든 string 속성은 최대 문자길이가 기본적으로 50까지만 허용된다거나 사용자 interface를 구현하는 모든 속성은 연결에서 제외되어야 한다는 규칙을 정의하고자 한다면 다음과 같이 구현할 수 있습니다.
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<string>().HaveMaxLength(50);
configurationBuilder.IgnoreAny<IUserInterface>();
}
4. EF Core model 질의하기
위 과정을 통해 Northwind database와 2개의 table에 연결되는 model을 만들게 되었습니다. 이로서 이제는 해당 table에서 data를 가져오기 위해 LINQ query를 작성할 수 있습니다. LINQ query를 작성하는 자세한 사항은 추후에 알아보기로 하고 이번예제에서는 간단하게만 사용해 보도록 하겠습니다.
project에 Queries.cs class file을 추가하고 그 안에 QueryingCategories method와 함께 partial Program class를 아래와 같이 정의합니다.
partial class Program
{
static void QueryingCategories()
{
using (Northwind db = new())
{
IQueryable<Category>? categories = db.Categories?.Include(c => c.Products);
if ((categories is null) || (!categories.Any()))
{
Console.WriteLine("Failed");
return;
}
foreach (Category c in categories)
{
Console.WriteLine($"{c.CategoryName} has {c.Products.Count} products.");
}
}
}
}
예제에서는 다음 사항에 주목합니다.
- 예제에서는 database를 관리할 Northwind class의 instance를 생성하고 있습니다. Database context instance는 작업단위 짧은 생명주기를 위해 설계되었으며 가능한 한 빨리 dispose 되어야 하므로 using문을 사용하였습니다.
- 관련된 projects를 포함한 전체 category를 위한 query를 생성합니다. 여기서 Include는 확장 method이며 사용을 위해서는 Microsoft.EntityFrameworkCore namespace를 import 해야 합니다.
- 가져온 category를 열거하면서 해당 category의 이름과 포함된 project의 수를 표시합니다.
if 문에서 || 사이의 순서는 중요한 부분입니다. categories가 null인지를 우선적으로 확인해야 하는데 만약 그 결과가 true라면 두 번째 문을 실행되지 않을 것이며 따라서 Any() member에 접근할 때 NullReferenceException 예외가 발생하지 않을 것입니다.
Program.cs에서는 Northwind instance를 생성하고 database provider의 이름을 출력하는 두 문을 주석처리하고 QueryingCategories method를 아래와 같이 호출합니다.
QueryingCategories();
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
(1) 포함된 entity filtering
EF Core 5에서는 filtered include를 도입하여 결과에서 반환된 entity를 filter 하기 위해 호출하는 Include method안에서 lambda 식을 명시할 수 있게 되었습니다.
Queries.cs에서 FilteredIncludes method를 아래와 같이 정의하고 아래 작업을 수행하는 문을 추가합니다.
- Database를 다룰 Northwind class의 instance를 생성합니다.
- 사용자에게 재고의 최솟값을 입력받을 수 있도록 요청합니다.
- 재고가 입력된 값보다 같거나 그 보다 많은 product를 가진 Category를 가져오기 위해 query를 생성합니다.
- Category와 Product를 열거하면서 각각에 대한 이름과 재고를 출력합니다.
static void FilteredIncludes()
{
using (Northwind db = new())
{
string? input;
int stock;
do
{
Console.Write("Enter a minimum for units in stock: ");
input = Console.ReadLine();
} while (!int.TryParse(input, out stock));
IQueryable<Category>? categories = db.Categories?.Include(c => c.Products.Where(p => p.Stock >= stock));
if ((categories is null) || (!categories.Any()))
{
Console.WriteLine("No categories found.");
return;
}
foreach (Category c in categories)
{
Console.WriteLine($"{c.CategoryName} has {c.Products.Count} products with a minimum of {stock} units in stock.");
foreach (Product p in c.Products)
{
Console.WriteLine($"{p.ProductName} has {p.Stock} units in stock.");
}
}
}
}
Program.cs에서 위의 FilteredIncludes method를 호출하면 다음과 같은 결과를 표시할 것입니다.
Windows 10 Fall Creators Update이전 Windows version에서 제공하는 console에는 하나의 제한사항이 있습니다. 기본적으로 console은 Unicode문자를 표시할 수 없습니다. 만약 예제를 실행하는 computer가 그렇다면 임시로 character set으로 알려진 code page를 console에서 아래 명령으로 Unicode UTF-8로 변경함으로써 해결할 수 있습니다.(예제를 실행시키기 전에)
chcp 65001 |
(2) Product filtering 하고 sorting 하기
Data를 filtering 하고 sorting 하기 위해 Queries.cs에서 QueryingProducts method를 정의하고 그 안에 아래의 동작을 위한 구문을 추가합니다.
- Database를 다룰 Northwind class의 instance를 생성합니다.
- 사용자에게 products에 대한 가격을 입력하도록 요청합니다.
- LINQ를 통해 입력한 가격보다 더 높은 products를 가져오는 query를 생성합니다.
- 결과를 순회하면서 ID, name, cost(예제에서의 단위는 dollar입니다.)와 재고를 표시하도록 합니다.
static void QueryingProducts()
{
using (Northwind db = new())
{
string? input;
decimal price;
do
{
Console.Write("Enter a product price: ");
input = Console.ReadLine();
} while (!decimal.TryParse(input, out price));
IQueryable<Product>? products = db.Products?.Where(product => product.Cost > price).OrderByDescending(product => product.Cost);
if ((products is null) || (!products.Any()))
{
Console.Write("No products found.");
return;
}
foreach (Product p in products)
{
Console.WriteLine("{0}: {1} costs {2:$#,##0.00} and has {3} in stock.", p.ProductId, p.ProductName, p.Cost, p.Stock);
}
}
}
예제에서의 '!products.Any()'는 'products.Count() == 0'과 같이 count를 확인하는 것보다 더 효휼적으로 작동합니다.
Program.cs에서 QueryingProducts method를 호출하면 사용자에게 price입력을 요청하고 그 결과를 다음과 같이 표시할 것입니다.
만약 위 입력요청에서 price를 500과 같이 입력하면 결과는 다음처음 달라질 수 있습니다.
(3) 생성된 SQL 확인하기
위와 같은 상황에서 C# code가 실제 어떤 SQL문을 생성하는지 궁금할 수 있습니다. EF Core 5부터는 생성된 SQL문을 보기 위한 빠르고 편리한 다음 방법을 도입하였습니다.
FilteredIncludes method에서 foreach문을 사용해 query를 열거하기 전에 생성된 SQL문을 출력하는 다음 문을 추가합니다.
if ((categories is null) || (!categories.Any()))
{
Console.WriteLine("No categories found.");
return;
}
Console.WriteLine($"ToQueryString: {categories.ToQueryString()}");
예제를 실행하면 다음과 같이 생성된 SQL문을 확인해 볼 수 있습니다.
(4) EF Core Logging
EF Core와 database간상호작용을 살펴보기 위해 logging을 사용할 수 있습니다. 이때 logging은 console이나 Debug, Trace 또는 file이 될 수 있습니다.
기본적으로 EF Core logging은 민감한 data는 제외합니다. 만약 이러한 data도 모두 포함하고자 한다면(특히 개발과정 동안에는) EnableSensitiveDataLogging method를 호출할 수 있습니다. 반대로 완성된 Application을 출시하는 경우 해당 기능을 disable 해야 합니다.
Northwind.cs의 OnConfiguring method아래에 console로의 log를 위한 아래 문을 추가합니다.
optionsBuilder.UseSqlServer(connection);
optionsBuilder.LogTo(Console.WriteLine).EnableSensitiveDataLogging();
LogTo는 Action<string> delegate를 필요로 하며 EF core는 각 log message에 대한 문자열값을 전달함으로써 해당 delegate를 호출할 것입니다. 따라서 위 예제처럼 Console class의 WriteLine method를 전달하는 것은 logger에게 각 method를 console에 쓰도록 하는 것입니다.
예제를 실행하면 다음과 같이 각 log를 상세하게 표시할 것입니다.
Log는 사용 중인 database 공급자와 code 편집기 및 향후 EF Core의 개선점에 따라 달라질 수 있습니다. 일반적으로 연결을 열거나 명령 실행과 같은 다른 event마다 다음과 같이 다른 event ID를 가지고 있습니다.
- RelationalEventId.ConnectionOpening[20000] : 원격 server 혹은 file 경로를 포함
- RelationalEventId.ConnectionOpened[20001] : 원격 server 혹은 file 경로를 포함
- RelationalEventId.CommandExecuting[20100] : 실행하는 SQL문 포함
● 공급자별 값을 통한 log filtering
event ID값과 그 의미는 EF Core 공급자에 따라 달라질 수 있습니다. LINQ query가 SQL 문으로 어떻게 변환되고 실행되는지를 알고자 한다면 20100의 Id값을 가진 event ID를 확인하면 됩니다.
이를 위해 아래에서와 같이 Northwind.cs에서 LogTo method를 변경하여 Id가 20100인 event만 출력하도록 합니다.
optionsBuilder.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuting }).EnableSensitiveDataLogging();
예제를 실행하면 다음과 같이 log 된 SQL문을 확인할 수 있습니다.
● Query tag를 통한 logging
LINQ query를 logging 할 때 복잡한 상황에서 log message를 서로 관련시키기가 어려울 수 있습니다. EF Core 2.2에서는 query tag 기능을 도입하여 SQL 문을 log에 추가시키기 한결 편리해졌습니다.
아래와 같이 TagWith method를 사용해 LINQ query에 적용합니다.
IQueryable<Category>? categories = db.Categories?.TagWith("*****Category*****").Include(c => c.Products.Where(p => p.Stock >= stock));
위와 같이 하면 log에 SQL comment를 다음과 같이 추가하게 됩니다.
(5) Like를 사용한 pattern matching
EF Core에서는 pattern matching을 위한 Like와 같이 일반적인 SQL문을 지원하고 있습니다.
Queries.cs에서 다음과 같이 QueryingWithLike이름의 method를 추가합니다.
static void QueryingWithLike()
{
using (Northwind db = new())
{
Console.Write("Enter part of a product name: ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
Console.WriteLine("You did not enter part of a product name.");
return;
}
IQueryable<Product>? products = db.Products?.Where(p => EF.Functions.Like(p.ProductName, $"%{input}%"));
if ((products is null) || (!products.Any()))
{
Console.WriteLine("No products found.");
return;
}
foreach (Product p in products)
{
Console.WriteLine("{0} has {1} units in stock. Discontinued? {2}", p.ProductName, p.Stock, p.Discontinued);
}
}
}
위 예제에서는 logging을 사용하고 있으며 사용자에게 product에 대한 이름 중 일부에 대한 입력을 요구하고 있습니다. 사용자가 입력을 완료하면 EF.Functions.Like method를 사용해 입력된 내용을 ProductName 속성으로 검색을 시도합니다.
여기서 일치하는 product가 존재하면 각 product에 대한 이름과 재고 그리고 생산여부등을 출력합니다.
Program.cs에서 기존의 method 호출을 주석처리하고 위에서 추가한 QueryingWithLike method를 호출하도록 합니다. 그런 뒤 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
(6) Query를 통한 임의 숫자 생성하기
EF Core 6에서는 한 가지 유용한 기능인 EF.Functions.Random을 도입하여 0과 1 사이에 배타적인 의사난수를 반환하는 database기능과 연결할 수 있습니다. 예를 들어 table에서 임의의 row를 선택하기 위해 전체 row 수에서 임의의 숫자를 곱할 수 있습니다.
Queries.cs에서 GetRandomProduct이름의 method를 아래와 같이 추가합니다.
static void GetRandomProduct()
{
using (Northwind db = new())
{
int? rowCount = db.Products?.Count();
if (rowCount == null)
{
Console.WriteLine("Products table is empty.");
return;
}
Product? p = db.Products?.FirstOrDefault(p => p.ProductId == (int)(EF.Functions.Random() * rowCount));
if (p == null)
{
Console.WriteLine("Product not found.");
return;
}
Console.WriteLine($"Random product: {p.ProductId} {p.ProductName}");
}
}
Program.cs에서 GetRandomProduct method를 호출하면 아래와 같은 결과를 표시할 것입니다.
(7) Global filter 정의하기
Northwind의 products, 즉 제품은 단종될 수 있을 것입니다. 따라서 단종된 product는 결과에 반영되지 않도록 하는 것이 합리적일 수 있는데 products를 조회하는 모든 곳에 where를 사용하여 이러한 사항을 filtering 하기보다는 global로 filter를 적용하여 where를 사용하지 않고 해당 filtering을 products를 조회하는 모든 곳에 적용할 수 있습니다.
Northwind.cs의 OnModelCreating method아래에 아래와 같이 단종된(discontinued) product를 제거하는 global filter를 추가합니다.
modelBuilder.Entity<Category>()
.Property(category => category.CategoryName)
.IsRequired() // NOT NULL
.HasMaxLength(15);
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.Discontinued);
Program.cs에서 QueryingWithLike method를 호출하고 product name으로 che를 입력합니다. 다음 결과에서 보듯 'Chef Anton’s Gumbo Mix'는 반영되지 않았습니다. 생성된 SQL문을 확인해 보면 Discontinued column에 대한 filter를 포함하고 있기 때문입니다.
5. EF Core의 loading pattern
EF Core에서 일반적으로 사용되는 3가지 loading pattern이 존재합니다.
- Eager loading : 사전 data load
- Lazy loading : 필요하기 직전에 자동적으로 data load
- Explicit loading : 수동으로 data load
(1) 확장 method 포함을 사용한 eager loading
QueryingCategories method에서 현재 code는 각 category를 순회하기 위해 Categories 속성을 사용하며 category name과 category에 속한 products 수를 출력하고 있습니다.
이것은 이전에 query를 작성할 때 관련된 products에 대한 Include method를 호출함으로써 eager loading를 사용했기 때문입니다.
이때 Include를 사용하지 않으면 어떤 현상이 발생하는지 확인해 보겠습니다.
QueryingCategories method에서 아래와 같이 Include method의 호출 부분을 주석처리합니다.
IQueryable<Category>? categories = db.Categories;//?.Include(c => c.Products);
Program.cs에서 QueryingCategories method를 호출하면 다음과 같은 결과가 표시됩니다.
foreach안에서 각 item은 Products 속성을 갖고 있는 Category class의 instance인데 Products 속성은 category에서의 products list를 의미합니다. 위 예제에서 query는 단지 Categories table만을 select 하고 있으므로 해당 속성은 각 category에서 비어있게 됩니다.
(2) Lazy loading 사용
Lazy loading은 EF Core 2.1에서 도입된 것으로 누락된 관계 data를 자동으로 load 할 수 있습니다. lazy loading을 사용하기 위해서는 우선 proxy를 위한 NuGet package를 참조해야 하며 해당 proxy사용을 위한 lazy loading을 구성해야 합니다.
Visual Studio 2022에서 Microsoft.EntityFrameworkCore.Proxies NuGet package를 찾아 이를 참조합니다. 그리고 Northwind.cs의 OnConfiguring method아래에 UseLazyLoadingProxies 확장 method를 호출하도록 합니다.
optionsBuilder.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuting }).EnableSensitiveDataLogging();
optionsBuilder.UseLazyLoadingProxies();
이렇게 하면 loop를 열거하면서 Products 속성을 읽으려고 시도할 때마다 lazy loading proxy는 data가 load 되었는지를 확인합니다. 그렇지 않다면 SELECT문을 실행하여 현재 category에 대한 products만을 load 하게 됩니다. 다시 말해 속성에 대한 접근이 있으면 그때 data를 database server에서 가져와야 할지를 판단하고 필요하면 그렇게 한다는 것입니다. 이렇게 되면 정확한 count를 출력에 반환할 수 있습니다.
예제를 다시 실행하면 이번에는 정확한 product count가 표시될 것입니다. 다만 모든 data를 가져오기 위해 database server와 여러 번 통신해야 하는 과정이 필요하다는 단점이 있습니다. 예를 들어 모든 categories를 가져온 다음 첫 번째 category인 Beverages에 대한 products를 가져오기 위해 아래와 같이 두 번의 SQL 명령을 실행하고 있습니다.
(3) Load method를 사용해 명시적으로 entity loading 하기
명시적 loading은 lazy loading과 비슷하지만 관계 data가 load 되는 것과 언제 load 될지를 정확히 제어한다는 차이가 있습니다. lazy loading은 해당 속성에 접근하면 처리되지만 explicit loading은 처리시점과 대상을 개발자가 결정합니다.
Queries.cs에서 우선 ChangeTracking namespace를 import 합니다. 해당 namespace는 관계 entity를 수동적으로 load 하도록 하는 CollectionEntry class를 사용하기 위한 것입니다.
using Microsoft.EntityFrameworkCore.ChangeTracking;
QueryingCategories안에서 아래와 같이 lazy loading을 위해 변경한 부분을 주석처리한 다음 사용자에게 eager loading과 explicit loading 중 어느 것을 사용할지를 선택하도록 요청하는 문을 아래와 같이 추가합니다.
IQueryable<Category>? categories;// = db.Categories;//?.Include(c => c.Products);
db.ChangeTracker.LazyLoadingEnabled = false;
Console.Write("Enable eager loading? (Y/N): ");
bool eagerLoading = (Console.ReadKey(intercept: true).Key == ConsoleKey.Y);
bool explicitLoading = false;
Console.WriteLine();
if (eagerLoading)
{
categories = db.Categories?.Include(c => c.Products);
}
else
{
categories = db.Categories;
Console.Write("Enable explicit loading? (Y/N): ");
explicitLoading = (Console.ReadKey(intercept: true).Key == ConsoleKey.Y);
Console.WriteLine();
}
foreach loop안에서는 WriteLine method를 호출하기 전 explicit loading을 사용하는지 확인하고 그렇다면 사용자에게 명시적으로 각각의 개별 category를 명시적으로 load 할지를 결정하도록 합니다.
예제를 실행하고 eager loading를 비활성화하기 위해 N을 누릅니다. 그런 다음 explicit loading을 위해 Y를 누르고 각 category에서 products를 load 할지에 대한 선택을 진행합니다.
위 예제에서는 처음 2건에 대한 products만을 load 하도록 하였습니다.
사용하고자 하는 loading pattern을 선택할 때는 주의해야 합니다. loading pattern에 관해서는 아래 link를 참고하시기 바랍니다.
6. EF Core를 사용한 data변경
EF Core를 사용해 entity를 Insert, update, delete 하는 데는 큰 어려움이 없습니다.
DbContext는 변화에 대한 추적관리를 자동으로 하게 되므로 local entity에 새로운 entity를 추가하거나 기존 entity를 수정하거나 혹은 삭제하는 것을 포함해 추적된 여러 변화를 가질 수 있습니다.
그리고 이러한 변경된 사항들을 database에 반영하려면 SaveChanges method를 호출하기만 하면 되고 경우에 따라 성공적으로 변경된 entity의 수가 반환될 수 있습니다.
(1) Entity Insert
Table에 새로운 row를 추가해 보기 위해 Modifications.cs file을 추가하고 여기에 가장 비싼 순으로 정렬된 각 product에 대한 ID, name, cost, stock, 그리고 discontinued속성을 출력하고 method에 선택적으로 전달된 int값의 array와 일치하는 모든 것을 가종하는 ListProducts이름의 method와 함께 partial Program class를 생성합니다.
partial class Program
{
static void ListProducts(int[]? productIdsToHighlight = null)
{
using (Northwind db = new())
{
if ((db.Products is null) || (!db.Products.Any()))
{
Console.WriteLine("There are no products.");
return;
}
Console.WriteLine("| {0,-3} | {1,-35} | {2,8} | {3,5} | {4} |", "Id", "Product Name", "Cost", "Stock", "Disc.");
foreach (Product p in db.Products)
{
ConsoleColor previousColor = System.Console.ForegroundColor;
if ((productIdsToHighlight is not null) && productIdsToHighlight.Contains(p.ProductId))
{
System.Console.ForegroundColor = ConsoleColor.Green;
}
Console.WriteLine("| {0:000} | {1,-35} | {2,8:$#,##0.00} | {3,5} | {4} |", p.ProductId, p.ProductName, p.Cost, p.Stock, p.Discontinued);
System.Console.ForegroundColor = previousColor;
}
}
}
}
위 예제에서 '{숫자1, 숫자2}'는 숫자 1번째의 인수를 숫자 2만큼의 문자열 넓이로 정렬함을 의미합니다. 이때 숫자 2가 -면 왼쪽 정렬을 양수이면 오른쪽 정렬입니다.
이어서 Modifications.cs에 AddProduct method를 아래와 같이 추가합니다.
static (int affected, int productId) AddProduct(int categoryId, string productName, decimal? price)
{
using (Northwind db = new())
{
if (db.Products is null) return (0, 0);
Product p = new()
{
CategoryId = categoryId,
ProductName = productName,
Cost = price,
Stock = 72
};
// Insert 추가
EntityEntry<Product> entity = db.Products.Add(p);
Console.WriteLine($"State: {entity.State}, ProductId: {p.ProductId}");
// 변경된 추적사항을 database에 반영
int affected = db.SaveChanges();
Console.WriteLine($"State: {entity.State}, ProductId: {p.ProductId}");
return (affected, p.ProductId);
}
}
Program.cs에서는 기존의 문을 모두 주석처리하고 위의 AddProduct와 ListProducts를 호출하는 문을 아래와 같이 추가합니다.
var resultAdd = AddProduct(categoryId: 6, productName: "Apple2 Computer", price: 1200M);
if (resultAdd.affected == 1)
{
Console.WriteLine($"Add product successful with ID: {resultAdd.productId}.");
}
ListProducts(productIdsToHighlight: new[] { resultAdd.productId });
예제를 실행하면 새로운 product를 추가한 다음과 같은 결과를 표시할 것입니다.
새로운 product가 처음 memory에 생성되고 EF Core change tracker에 의해 추적되기 시작하면 Added 상태와 0인 ID를 가지게 됩니다. 그런 후 SaveChanges가 호출되면 상태는 UnChanged로 바뀌게 되고 ID값은 78로 database에 의해 할당됩니다.
(2) Entity Update
기존에 존재하는 table의 row를 변경하기 위해 update 할 product를 product name의 시작을 지정하여 검색한 다음 가장 먼저 일치하는 것만 반환하도록 하는 예제를 작성할 것입니다.(실제로는 ProductId와 같이 고유한 식별자를 사용해 update할 product를 지정하는 것이 일반적입니다.)
Product를 추가할 때는 실제 어떤 product ID가 할당될지 알 수 없습니다. 다만 현재 Northwind database에 'Apple2'로 시작하는 product가 없다는 것만 알고 있을 뿐입니다. name을 사용해 update 할 product를 찾으면 이전에 추가한 product의 ID를 알아내야 하는 것을 피할 수 있습니다. 물론 database에는 77까지의 ID가 존재하므로 새로운 product의 ID가 78이 될 수 있음을 유추할 수 있지만 product를 추가한 뒤 삭제한다고 해도 다음 product의 ID는 79가 될 수 있으며 이때부터 번호순서가 단순 예상에 맞지 않을 수 있습니다.
Modifications.cs에서는 name이 지정된 값으로 시작하는 product에 대해 $20과 같이 지정한 만큼 price를 증가시키는 method를 추가합니다.
static (int affected, int productId) IncreaseProductPrice(string productNameStartsWith, decimal amount)
{
using (Northwind db = new())
{
if (db.Products is null) return (0, 0);
Product updateProduct = db.Products.First(p => p.ProductName.StartsWith(productNameStartsWith));
updateProduct.Cost += amount;
int affected = db.SaveChanges();
return (affected, updateProduct.ProductId);
}
}
Program.cs에서는 IncreaseProductPrice method를 호출한 다음 다시 ListProducts method를 호출하는 문을 아래와 같이 추가합니다.
var resultUpdate = IncreaseProductPrice(productNameStartsWith: "Apple", amount: 30M);
if (resultUpdate.affected == 1)
{
Console.WriteLine("Increase price success for ID: {resultUpdate.productId}.");
}
ListProducts(productIdsToHighlight: new[] { resultUpdate.productId });
예제를 실행하면 다음과 같은 결과를 표시할 것입니다. 이전에 추가한 'Apple2 Computer'에 대한 price가 $30만큼 증가되었습니다.
(3) Entity Delete
각각의 entity들은 Remove method를 사용함으로써 삭제할 수 있습니다. RemoveRange는 여러 entity를 제거하고자 할 때 더 적합한 method입니다.
Modifications.cs에서 지정한 값으로 시작하는 모든 product를 삭제하는 method를 아래와 같이 추가합니다.
static int DeleteProducts(string productNameStartsWith)
{
using (Northwind db = new())
{
IQueryable<Product>? products = db.Products?.Where(p => p.ProductName.StartsWith(productNameStartsWith));
if ((products is null) || (!products.Any()))
{
Console.WriteLine("No products found to delete.");
return 0;
}
else
{
if (db.Products is null) return 0;
db.Products.RemoveRange(products);
}
int affected = db.SaveChanges();
return affected;
}
}
Program.cs에서는 아래와 같이 DeleteProducts method를 호출하는 문을 추가합니다.
Console.WriteLine("About to delete all products whose name starts with Apple.");
Console.Write("Press Enter to continue or any other key to exit: ");
if (Console.ReadKey(intercept: true).Key == ConsoleKey.Enter)
{
int deleted = DeleteProducts(productNameStartsWith: "Apple");
Console.WriteLine($"{deleted} product(s) were deleted.");
}
else
{
Console.WriteLine("Delete was canceled.");
}
예제를 실행하고 Enter key를 누르면 다음과 같은 결과를 표시하게 됩니다.
만약 Apple로 시작하는 product가 여러 건이라면 해당되는 모든 product는 삭제될 것입니다.
(4) 더욱 효율적인 Update와 Delete
지금까지의 방법은 EF Core를 사용한 비교적 전통적인 data수정 방식으로 과정을 다음과 같이 요약할 수 있습니다.
- Database context를 생성합니다. (변경추적은 기본으로 사용됩니다.)
- Insert를 위해 entity class에 대한 instance를 생성하고 Add method의 매개변수로 collection을 db.Products.Add(product) 처럼 전달합니다.
- Update를 위해서는 update 하고자 하는 entity를 가져와 이들에 대한 속성을 변경합니다.
- Delete를 위해서는 delete 하고자 하는 entity를 가져와 Remove 또는 RemoveRange method의 매개변수로 db.Products.Remove(product) 처럼 전달합니다.
- database context의 SaveChanges method를 호출하면 change tracker는 insert, update, delete수행에 필요한 SQL 문을 생성해 실행하고 적용된 entity의 수를 반환합니다.
EF Core 7에서는 사전에 load 해야 할 entity와 이들에 대한 변경점을 필요로 하지 않음으로써 더 효휼적으로 update와 delete를 수행할 수 있는 ExecuteDelete와 ExecuteUpdate(Async method도 동일함) 2개의 method를 도입하였습니다. 이들은 LINQ query상에서 호출되어 query 결과에 대한 entity에 영향을 주게 됩니다. query가 entity를 검색하는 데 사용되지 않으므로 어떠한 entity도 data context로 load 되지 않습니다.
예를 들어 table의 모든 row를 삭제하고자 한다면 ExecuteDelete 혹은 ExecuteDeleteAsync method를 모든 DbSet속성으로 아래와 같이 호출합니다.
await db.Products.ExecuteDeleteAsync();
위의 code는 database에서 다음과 같은 SQL문을 실행할 것입니다.
DELETE FROM Products
만약 50보다 더 높은 UnitPrice를 가진 product를 삭제하고자 한다면 아래와 같이 LINQ를 작성할 수 있습니다.
await db.Products.Where(product => product.Cost > 50).ExecuteDeleteAsync();
위의 code는 database에서 다음과 같은 SQL문을 실행할 것입니다.
DELETE FROM Products p WHERE p.Cost > 50
ExecuteUpdate와 ExecuteDelete는 단지 단일 table상에서만 동작할 수 있습니다. 만약 다수의 table에 걸친 꽤 복잡한 LINQ query를 작성했다고 하더라도 단일 table에서만 update나 delete가 수행됩니다.
또 다른 예로 Discontinued가 false인 모든 product의 UintPrice값을 10% 인상으로 update 하고자 한다면 LINQ는 아래와 같이 작성될 수 있습니다.
await db.Products.Where(product => !product.Discontinued).ExecuteUpdateAsync(s => s.SetProperty(
p => p.Cost, //update할 속성 선택
p => p.Cost * 0.1m)); //속성 update
위 예제와 같은 경우 하나의 LINQ안에서 다수의 속성을 update 하기 위해 같은 query에서 다수의 SetProperty를 호출해 연결할 수 있습니다.
Modifications.cs에서 ExecuteUpdate를 사용해 name이 지정한 값으로 시작하는 모든 product를 update 하는 method를 아래와 같이 추가합니다.
static (int affected, int[]? productIds) IncreaseProductPricesBetter(string productNameStartsWith, decimal amount)
{
using (Northwind db = new())
{
if (db.Products is null)
return (0, null);
IQueryable<Product>? products = db.Products.Where(p => p.ProductName.StartsWith(productNameStartsWith));
int affected = products.ExecuteUpdate(s => s.SetProperty(
p => p.Cost,
p => p.Cost + amount));
int[] productIds = products.Select(p => p.ProductId).ToArray();
return (affected, productIds);
}
}
Program.cs에서는 IncreaseProductPricesBetter method를 호출하는 문을 아래와 같이 추가합니다.
var resultUpdateBetter = IncreaseProductPricesBetter(productNameStartsWith: "Apple", amount: 20M);
if (resultUpdateBetter.affected > 0)
{
Console.WriteLine("Increase product price successful.");
}
ListProducts(productIdsToHighlight: resultUpdateBetter.productIds);
다만 위 예제를 실행하기 전 Apple로 시작하는 product를 이전에 삭제하였으므로 위에서 구현했던 새로운 product를 추가하는 문을 먼저 실행하도록 해야 합니다.
예제를 실행하면 현재 'Apple'로 시작하는 product에 대해 다음과 같이 cost를 변경하였음을 확인할 수 있습니다.
다시 Modifications.cs로 돌아와 ExecuteDelete를 사용해 지정한 값으로 시작되는 모든 product를 삭제하는 method를 아래와 같이 추가합니다.
static int DeleteProductsBetter(string productNameStartsWith)
{
using (Northwind db = new())
{
int affected = 0;
IQueryable<Product>? products = db.Products?.Where(p => p.ProductName.StartsWith(productNameStartsWith));
if ((products is null) || (!products.Any()))
{
Console.WriteLine("No products found to delete.");
return 0;
}
else
{
affected = products.ExecuteDelete();
}
return affected;
}
}
Program.cs에서 DeleteProductsBetter method를 호출하도록 하고 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
Console.WriteLine("About to delete all products whose name starts with Apple.");
Console.Write("Press Enter to continue or any other key to exit: ");
if (Console.ReadKey(intercept: true).Key == ConsoleKey.Enter)
{
int deleted = DeleteProductsBetter(productNameStartsWith: "Apple");
Console.WriteLine($"{deleted} product(s) were deleted.");
}
else
{
Console.WriteLine("Delete was canceled.");
}
만약 기존 방식인 변경 추적과 ExecuteUpdate, ExecuteDelete method를 섞어서 사용하게 되는 경우라면 이들은 서로 동기화되지 않음에 주의해야 합니다. Change tracker는 이들 method를 통해 update 하고 delete 한 사항을 알지 못합니다.
(5) Database context pooling
DbContext class는 일회용이며 단일 작업 단위 원칙에 따라 설계되었습니다. 이전 예제에서는 DbContext에서 파생된 Northwind instance를 using안에서 생성했으므로 Dispose속성은 각 작업에서의 종료시점에 호출됩니다.
EF Core와 관련된 ASP.NET Core의 특징 중 하나는 website와 service를 구축할 때 database context를 pooling 함으로써 code를 더욱 효휼적으로 만든다는 것입니다. 이때 따라 가능한한 더 효휼적으로 원하는 만큼의 DbContext에서 파생된 다수의 개체를 생성하고 소멸할 수 있습니다.
7. Transaction
SaveChanges method를 호출할 때마다 암시적으로 transaction이 시작되므로 무엇인가 잘못된 상황이 발생하면 자동적으로 변경사항이 rollback 됩니다. Transaction이내에서 다수의 변경사항이 성공적으로 수행된다면 transaction은 모든 변경사항을 commit 합니다.
Transaction은 일련의 변경사항들을 처리하는 동안 해당 data에 대한 읽기/쓰기를 방지하지 위해 lock을 적용함으로써 database에 대한 무결성을 관리합니다.
Transaction은 아래 설명에 따른 약자로 ACID라고 말하기도 합니다.
- A는 원자성(atomic)입니다. Transaction에 대한 모든 동작이 commit 되거나 commit 되지 않습니다.
- C는 일관성(consistent)입니다. Transaction전후에 대한 database의 상태는 일관되며 이는 code의 logic에 의존합니다. 예를 들어 은행 계좌 간 돈이 송금될 때 하나의 계정에서 100만 원이 인출되면 다른 계정에서 100만원이 입금되어야 하는데 이를 보장하는 것은 business loginc에 달려있습니다.
- I는 격리성(isolated)입니다. Transaction동안 변경사항은 다른 process로부터는 감춰지는데 여기에 선태가능한 여러 격리 수준이 존재합니다(아래 표 참고). 수준이 높을수록 data의 원자성은 더 나아지지만 더 많은 lock이 적용되고 이는 다른 process에 부정적인 영향을 줄 수 있습니다. Snapshot은 lock을 회피하기 위해 row의 여러 복사본을 생성하므로 좀 더 특별한 경우라고 할 수 있지만 transaction이 발생하는 동안 database의 크기를 증가시킬 수 있습니다.
- D는 지속성(durable)입니다. Transaction이 실패하면 원상태로 복구될 수 있습니다. 이는 대게 2단계 commit과 transaction log로 구현됩니다. 일단 transaction이 commit 되면 후속 오류가 발생하더라도 data의 보존을 보증합니다. 지속성의 반대는 휘발성(volatile)입니다.
(1) 격리 수준을 통한 transaction 제어
Transaction은 격리 수준을 설정함으로써 아래 표의 설명과 같이 제어할 수 있습니다.
격리 수준 | 잠금 | 허용되는 무결성 문제 |
ReadUncommitted | 해당없음 | Dirty read, non-repeatable read와 phantom data |
ReadCommitted | data변경시 data에 접근하는 다른 사용자를 transaction이 종료될때까지 차단하기 위해 읽기 잠금을 적용합니다. | Non-repeatable read와 phantom data |
RepeatableRead | data를 읽을때 data에 접근하는 다른 사용자를 transaction이 종료될때까지 차단하기 위해 변경 잠금을 적용합니다. | Phantom data |
Serializable | Insert나 delete를 포함해 결과에 영향을 줄 수 있는 모든 동작을 차단하기 위해 key-range잠금을 적용합니다. | 해당없음 |
Snapshot | 해당없음 | 해당없음 |
(2) 명시적인 transaction 정의
Database context에는 Database 속성이 있으며 이를 통해 transaction을 명시적으로 실행시킬 수 있습니다.
Modifications.cs에서 아래와 같이 namespace를 import 합니다. 이는 IDbContextTransaction interface를 사용하기 위한 EF Core storage namespace입니다.
using Microsoft.EntityFrameworkCore.Storage;
그리고 DeleteProducts method에서 db 변수의 instance를 생성한 직후 transaction을 명시적으로 시작하고 격리 수준을 출력하는 문을 추가합니다. Method의 끝에서는 transaction을 commit 하도록 합니다.
static int DeleteProducts(string productNameStartsWith)
{
using (Northwind db = new())
{
using (IDbContextTransaction t = db.Database.BeginTransaction())
{
Console.WriteLine("Transaction isolation level: {0}", arg0: t.GetDbTransaction().IsolationLevel);
IQueryable<Product>? products = db.Products?.Where(p => p.ProductName.StartsWith(productNameStartsWith));
if ((products is null) || (!products.Any()))
{
Console.WriteLine("No products found to delete.");
return 0;
}
else
{
if (db.Products is null) return 0;
db.Products.RemoveRange(products);
}
int affected = db.SaveChanges();
t.Commit();
return affected;
}
}
}
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
'.NET > C#' 카테고리의 다른 글
[C# 12와 .NET 8] 2. C# (0) | 2024.01.12 |
---|---|
[C# 11 과 .NET 7] 11. LINQ (0) | 2023.08.18 |
[C# 11 과 .NET 7] 9. File, Streams, Serialization (0) | 2023.07.27 |
[C# 11 과 .NET 7] 8. 공용 .NET Type (0) | 2023.07.16 |
[C# 11 과 .NET 7] 7. .NET Packaging과 배포 (0) | 2023.07.07 |