4. EF Core model 질의하기
이전 과정을 통해 Northwind DB의 Products와 Categories가 2개의 테이블과 일치되는 Model을 가지게 되었고 필요한 데이터를 가져오기 위한 질의를 수행할 수 있게 되었습니다. 그리고 Model에 질의를 수행하는 데는 통상 LINQ를 사용합니다.
우선 Program.cs에서 아래 Namespace를 Import 합니다. 이 Namespace는 Model과 관련된 테이블로부터 데이터를 가져오기 위한 확장 메서드를 사용하기 위한 것입니다.
using Microsoft.EntityFrameworkCore;
그리고 Products테이블에있는 모든 ProductId와 ProductName을 가져오기 위한 아래 질의를 구현합니다.
using myapp;
using (Northwind db = new())
{
IQueryable<Product>? products = db.Products;
if (products is null) {
Console.WriteLine("Product가 존재하지 않습니다.");
return;
}
foreach(Product p in products)
Console.WriteLine($"{p.ProductId} - {p.ProductName}");
}
예제에서는 Nortwind 데이터베이스를 사용하기 위해 DB의 인스턴스를 생성하고 해당 인스턴스를 통해 테이블이 데이터를 가져오도록 하고 있습니다. 이때 인스턴스를 생성하고 사용하는 영역은 using{}안에서 이루어 지므로 질의가 수행된 후에는 빠른 시간 안에 알아서 객체가 소멸할 수 있도록 하였습니다.
(1) 필터링
위의 예제는 Products테이블에 있는 모든 행의 데이터를 가져오도록 한 것입니다. 이때 데이터에 대한 필터링을 적용하면 원하는 데이터만 가져올 수 있도록 구현할 수 있습니다.
필터링은 대게 Where가 사용되며 아래와 같이 사용할 수 있습니다.
Console.Write("확인하고자 하는 Product의 ID를 입력하세요 : ");
int productID = int.Parse(Console.ReadLine() ?? "1");
IQueryable<Product>? products = db.Products.Where(p => p.ProductId == productID);
//확인하고자 하는 Product의 ID를 입력하세요 : 5
//5 - Chef Anton's Gumbo Mix
(2) 정렬
정렬은 OrderBy를 사용합니다. 예를 들어 ProductId로 내림차순 정렬을 수행하려면 다음과 같이 할 수 있습니다.
IQueryable<Product>? products = db.Products.OrderByDescending(p => p.ProductId);
참고로 LINQ는 메서드체인을 사용할 수 있으므로 다수의 메서드를 동시에 사용할 수 있습니다.
//ProductID가 5이상인것을 내림차순으로 정렬
IQueryable<Product>? products = db.Products.Where(p => p.ProductId > 5).OrderByDescending(p => p.ProductId);
(3) Query 확인
Model을 통해 질의를 수행할때는 LINQ를 사용했지만 데이터베이스는 LINQ대신 Query를 사용하므로 LINQ는 곧 Query로 변환돼 서버에 전달됩니다. 이때 정확히 어떤 Query가 전송되는지를 알 수 있는 방법이 있는데 그것은 ToQueryString() 메서드를 사용하는 것입니다. 이 메서드는 .NET 5부터 도입되었습니다.
ToQueryString()은 아래와 같이 실제 데이터를 가져오기 직전에 사용되어야 합니다.
Console.Write("확인하고자 하는 Product의 ID를 입력하세요 : ");
int productID = int.Parse(Console.ReadLine() ?? "1");
IQueryable<Product>? products = db.Products.Where(p => p.ProductId == productID);
...중략
Console.WriteLine(products.ToQueryString());
foreach(Product p in products)
Console.WriteLine($"{p.ProductId} - {p.ProductName}");
// DECLARE @__productID_0 int = 10;
// SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder]
// FROM [Products] AS [p]
// WHERE [p].[ProductId] = @__productID_0
예제에서 표시된 '@__productID_0'은 매개변수를 사용하기 위함이며 이 값은 10으로 설정되었습니다. 또한 객체를 구분하기 위해 대괄호([])가 사용되었는데 이는 SQL Server를 위한 조치입니다. 만약 예제의 코드가 MySQL과 같은 서버에서 실행되는 경우라면 대괄호 대신 쌍따옴표(")가 사용될 수 있습니다. EF-Core의 이점 중 하나는 각각의 DB에 최적화된 Query를 알아서 생성한다는 것입니다.
(4) EF Core Logging
EF Core와 데이터베이스간의 상호작용을 모니터링을 위해서 Logging을 사용할 수 있습니다.
Logging을 구현하기 위해서 EFLogging.cs파일을 추가하고 아래와 같이 2개의 클래스를 정의합니다.
using Microsoft.Extensions.Logging;
namespace myapp;
public class ConsoleLoggerProvider : ILoggerProvider
{
public ILogger CreateLogger(string categoryName)
{
return new ConsoleLogger();
}
public void Dispose() { }
}
public class ConsoleLogger : ILogger
{
public IDisposable BeginScope<TState>(TState state)
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
switch (logLevel)
{
case LogLevel.Trace:
case LogLevel.Information:
case LogLevel.None:
return false;
case LogLevel.Debug:
case LogLevel.Warning:
case LogLevel.Error:
case LogLevel.Critical:
default:
return true;
};
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception, string> formatter)
{
Console.Write($"Level : {logLevel}, EventID : {eventId.Id}");
if (state != null)
{
Console.Write($", State: {state}");
}
if (exception != null)
{
Console.Write($", Exception: {exception.Message}");
}
Console.WriteLine();
}
}
ConsoleLoggerProvider는 ConsoleLogger의 인스턴스를 반환하는데 이 클래스는 어떠한 비관리 리소스도 필요로 하지 않으므로 Dispose() 메서드는 어떠한 동작도 필요로 하지 않습니다. 다만 인터페이스에 따라 Dispose() 메서드 자체는 존재해야 합니다.
ConsoleLogger에서는 IsEnabled()메서드에서 logLevel이 Trace, Information, None인 것을 제외하고 그 외 모든 LogLevel에서 Log가 작동되도록 설정하고 있습니다. 실제 Log처리는 Log() 메서드를 호출함으로써 이루어지는데 Log() 메서드에서는 전달되는 Log를 Console을 통해 출력되도록 구현하였습니다.
위에서 만들어진 클래스를 사용하려면 사용하고자 하는 파일에서 아래 3개의 Namespace를 Import하고
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
Northwind 데이터베이스의 인스턴스를 가져오는 using구문 내부에 아래 구문을 추가합니다.
using (Northwind db = new())
{
ILoggerFactory loggerFactory = db.GetService<ILoggerFactory>();
loggerFactory.AddProvider(new ConsoleLoggerProvider());
기존에 추가했던 ToQueryString() 메서드 부분은 제거하고 그대로 코드를 실행합니다.
확인하고자 하는 Product의 ID를 입력하세요 : 5
Level : Debug, EventID : 10111, State: Compiling query expression: 'DbSet<Product>() .Where(p => p.ProductId == __productID_0)' Level : Debug, EventID : 10107, State: Generated query execution expression: 'queryContext => new SingleQueryingEnumerable<Product>( (RelationalQueryContext)queryContext, RelationalCommandCache.SelectExpression( Projection Mapping: EmptyProjectionMember -> Dictionary<IProperty, int> { [Property: Product.ProductId (int) Required PK AfterSave:Throw ValueGenerated.OnAdd, 0], [Property: Product.CategoryId (int?) FK Index , 1], [Property: Product.Discontinued (bool) Required, 2], [Property: Product.ProductName (string) Required Index MaxLength(40), 3], [Property: Product.QuantityPerUnit (string) MaxLength(20), 4], [Pro perty: Product.ReorderLevel (short?) ValueGenerated.OnAdd, 5], [Property: Product.SupplierId (int?) Index, 6], [Property: Product.UnitPrice (decimal?) ValueGenerated.OnAdd, 7], [Property: Product.Unit sInStock (short?) ValueGenerated.OnAdd, 8], [Property: Product.UnitsOnOrder (short?) ValueGenerated.OnAdd, 9] } SELECT p.ProductId, p.CategoryId, p.Discontinued, p.ProductName, p.QuantityPerUnit, p.ReorderLevel, p.SupplierId, p.UnitPrice, p.UnitsInStock, p.UnitsOnOrder FROM Products AS p WHERE p.ProductId == @__productID_0), Func<QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator, Product>, myapp.Northwind, False, False, True )' Level : Debug, EventID : 20103, State: Creating DbCommand for 'ExecuteReader'. Level : Debug, EventID : 20104, State: Created DbCommand for 'ExecuteReader' (30ms). Level : Debug, EventID : 20000, State: Opening connection to database 'Northwind' on server '.'. Level : Debug, EventID : 20001, State: Opened connection to database 'Northwind' on server '.'. Level : Debug, EventID : 20100, State: Executing DbCommand [Parameters=[@__productID_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30'] SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder] FROM [Products] AS [p] WHERE [p].[ProductId] = @__productID_0 Level : Debug, EventID : 10806, State: Context 'Northwind' started tracking 'Product' entity. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see key values. 5 - Chef Anton's Gumbo Mix Level : Debug, EventID : 20300, State: A data reader was disposed. Level : Debug, EventID : 20002, State: Closing connection to database 'Northwind' on server '.'. Level : Debug, EventID : 20003, State: Closed connection to database 'Northwind' on server '.'. Level : Debug, EventID : 10407, State: 'Northwind' disposed. |
결과를 보면 무수히 많은 로그를 볼 수 있습니다. 이때 특정 Log만을 출력하는 데는 EventID를 특정하여 출력합니다. 예를 들어 이전에 사용했던 ToQueryString() 메서드처럼 전송되는 쿼리만을 보고자 한다면 EventID가 20100인 Log만을 볼 수 있도록 아래와 같이 구현하는 것입니다.
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception, string> formatter)
{
if (eventId == 20100)
{
Console.Write($"Level : {logLevel}, EventID : {eventId.Id}");
if (state != null)
{
Console.Write($", State: {state}");
}
if (exception != null)
{
Console.Write($", Exception: {exception.Message}");
}
Console.WriteLine();
}
}
//Level : Debug, EventID : 20100, State: Executing DbCommand [Parameters=[@__productID_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
//SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder]
//FROM [Products] AS [p]
//WHERE [p].[ProductId] = @__productID_0
//7 - Uncle Bob's Organic Dried Pears
그러나 크고 복잡한 코드를 로깅하는 경우 위와 같은 방식을 통해 너무 많은 쿼리가 로깅되면 정작 내가 원하는 쿼리를 찾아내기란 쉽지 않을 수 있습니다. 이때 TagWith를 사용하여 특정 Tag를 같이 남겨두면 원하는 내용을 찾기가 훨씬 수훨해 질 수 있습니다.
IQueryable<Product>? products = db.Products.Where(p => p.ProductId == productID).TagWith("내가 원하는 제품");
위의 구현은 아래처럼 SQL 주석으로 표현됩니다.
-- 내가 원하는 제품
(5) Like를 사용한 Pattern 매칭
EF Core는 Like를 포함한 일반적인 SQL 구문을 지원합니다.
IQueryable<Product>? products = db.Products.Where(x => EF.Functions.Like(x.ProductName, "%sir%"));
//SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder]
//FROM [Products] AS [p]
//WHERE [p].[ProductName] LIKE N'%sir%'
//20 - Sir Rodney's Marmalade
//21 - Sir Rodney's Scones
//61 - Sirop d'erable
Like 외에 EF.Functions에서는 Random()과 같은 다른 유용한 메서드를 포함하고 있습니다.
(6) Global Filter 적용
Nortwind의 Product 중 일부는 단종된 제품으로 표현되어 있습니다.
만약 제품을 표현할 때 단종된 제품은 어떠한 경우에도 포함되지 않아야 한다면 매번 Product에 대한 질의를 요청할 때마다 (p => !p.Discontinued)과 같은 조건을 붙여줘야 할 것입니다. 하지만 아래와 같이 Global Filter를 적용하게 되면 따로 조건을 붙이지 않아도 Product에 대한 모든 질의에서 해당 필터를 적용할 수 있습니다.
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)
.HasForeignKey(d => d.CategoryId)
.HasConstraintName("FK_Products_Categories");
entity.HasQueryFilter(e => !e.Discontinued); //Global Filter
});
OnModelCreatingPartial(modelBuilder);
}
5. EF Core를 사용한 Pattern 로드
일반적으로 EF Core를 사용해 Pattern을 로드하는 데는 3가지 방법이 존재합니다.
- Eager : 데이터를 사전에 로드합니다.
- Lazy : 데이터가 사용되기 전 자동적으로 데이터를 로드합니다.
- Explicit : 필요할 때 데이터를 수동적으로 로드합니다.
(1) Eager
우선 아래 코드를 보겠습니다.
static void GetCategoy()
{
using (Northwind db = new())
{
IQueryable<Category>? categories = db.Categories?.Include(c => c.Products);
if (categories is null)
{
Console.WriteLine("데이터없음");
return;
}
foreach (Category c in categories)
{
Console.WriteLine($"{c.CategoryName}의 카테고리에는 {c.Products.Count}개의 제품이 존재함.");
}
}
}
Categories 테이블의 모든 데이터를 질의할 때 Include()라는 확장 메서드를 통해 Category테이블과 관련된 Products테이블의 데이터를 같이 포함하도록 하고 있습니다.
Include() 메서드는 질의가 수행되기 전에 Products테이블의 데이터를 불러오도록 즉, Eager방식으로 데이터를 가져오도록 하고 있는 것입니다.
foreach에서 각 Category는 Category클래스의 인스턴스에 해당하며 해당 인스턴스는 Products라는 속성을 가지고 있는데 이 속성은 각 Category에 속한 Products의 리스트를 의미합니다. 그런데 Include() 메서드를 사용하지 않게 되면 온전히 Categories테이블의 데이터만을 포함하게 되므로 Products속성은 비어있게 됩니다.
참고로 Include() 메서드는 .NET Core 5.0에서 포함된 메서드이며 실제 데이터는 아래와 같이 질의됩니다.
SELECT [c].[CategoryId], [c].[CategoryName], [c].[Description], [c].[Picture], [t].[ProductId], [t].[CategoryId], [t].[Discontinued], [t].[ProductName], [t].[QuantityPerUnit], [t].[ReorderLevel], [t].[SupplierId], [t].[UnitPrice], [t].[UnitsInStock], [t].[UnitsOnOrder]
FROM [Categories] AS [c]
LEFT JOIN (
SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder]
FROM [Products] AS [p]
WHERE [p].[Discontinued] = CAST(0 AS bit)
) AS [t] ON [c].[CategoryId] = [t].[CategoryId]
ORDER BY [c].[CategoryId]
(2) Lazy
Lazy는 EF Core 2.1부터 포함된 것으로 자동적으로 테이블에서 데이터를 로드하도록 합니다. 실제 Lazy방식을 사용하기 위해서는 우선 프로젝트 파일(csproj)을 아래와 같이 수정해 NuGet에서 proxies패키지를 참조하도록 합니다.
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="6.0.6" />
</ItemGroup>
그런 다음 OnConfiguring() 메서드의 시작 부분에 Lazy방식을 사용하기 위한 Proxy를 설정합니다.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseLazyLoadingProxies();
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=.;user id=sa;password=1234;Database=Northwind;MultipleActiveResultSets=True;");
}
}
이렇게 하면 실제 특정 테이블에 관한 속성, 예를 들어 Products에 대한 읽기를 시도하거나 Loop를 통해 열거를 시도할 때마다 lazy loading proxy는 Products의 데이터가 로드되었는지를 확인할 것입니다. 만약 데이터가 없다면 현재 Category를 위해 Select 구문을 실행하여 Products의 데이터를 가져오고 해당 Category의 정확한 Products Count를 표시할 것입니다.
Lazy의 방식의 데이터 로드는 실제 아래와 같은 방식으로 질의됩니다.
Level : Debug, EventID : 20100, State: Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [c].[CategoryId], [c].[CategoryName], [c].[Description], [c].[Picture] FROM [Categories] AS [c] Level : Debug, EventID : 20100, State: Executing DbCommand [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30'] SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder] FROM [Products] AS [p] WHERE ([p].[Discontinued] = CAST(0 AS bit)) AND ([p].[CategoryId] = @__p_0) Beverages의 카테고리에는 11개의 제품이 존재함. Level : Debug, EventID : 20100, State: Executing DbCommand [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30'] SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder] FROM [Products] AS [p] WHERE ([p].[Discontinued] = CAST(0 AS bit)) AND ([p].[CategoryId] = @__p_0) Condiments의 카테고리에는 11개의 제품이 존재함. |
보시는 바와 같이 foreach를 통해 Category에서 Products속성의 일기를 시도할 때마다 해당 Category의 Products데이터를 가져오기 위한 질의를 반복적으로 수행함을 알 수 있습니다.
(3) Explicit
말 그대로 데이터를 로드하는데 필요한 데이터와 시간을 명시화하는 방법입니다.
db.ChangeTracker.LazyLoadingEnabled = false;
foreach (Category c in categories)
{
CollectionEntry<Category, Product> products = db.Entry(c).Collection(c2 => c2.Products);
if (c.CategoryName.Trim() == "Beverages")
{
if (!products.IsLoaded)
products.Load();
}
Console.WriteLine($"{c.CategoryName}의 카테고리에는 {c.Products.Count}개의 제품이 존재함.");
}
예제에서는 우선 Explicit방식을 사용하기 위해 LazyLoadingEnabled를 false로 설정하여 이전에 설정한 Lazy방식을 비활성화하였습니다. 그리고 foreach안에서 CategoryName이 Beverages인 경우에만 Products의 데이터가 로드되었는지를 확인하고 그렇지 않다면 Load() 메서드를 호출하여 데이터를 명시적으로 가져오도록 지정하였습니다.
Level : Debug, EventID : 20100, State: Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [c].[CategoryId], [c].[CategoryName], [c].[Description], [c].[Picture] FROM [Categories] AS [c] Level : Debug, EventID : 20100, State: Executing DbCommand [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30'] SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder] FROM [Products] AS [p] WHERE ([p].[Discontinued] = CAST(0 AS bit)) AND ([p].[CategoryId] = @__p_0) Beverages의 카테고리에는 11개의 제품이 존재함. Condiments의 카테고리에는 0개의 제품이 존재함. Confections의 카테고리에는 0개의 제품이 존재함. Dairy Products의 카테고리에는 0개의 제품이 존재함. Grains/Cereals의 카테고리에는 0개의 제품이 존재함. Meat/Poultry의 카테고리에는 0개의 제품이 존재함. Produce의 카테고리에는 0개의 제품이 존재함. Seafood의 카테고리에는 0개의 제품이 존재함. |
선택적으로 데이터를 불러들인다는 것만 빼면 동작 방식은 Lazy와 동일합니다.
'.NET > C#' 카테고리의 다른 글
[C#] Entity Framework Core - 5. Code First Model (0) | 2022.06.24 |
---|---|
[C#] Entity Framework Core - 4. 데이터 조작과 트랜잭션 (0) | 2022.06.24 |
[C#] Entity Framework Core - 2. 모델링(Modeling) (0) | 2022.06.24 |
[C#] Entity Framework Core - 1. 시작/설정하기 (0) | 2022.06.24 |
[C#] 함수(메서드)의 실행과 디버깅및 테스팅 (0) | 2022.05.06 |