Share via


단일 쿼리와 분할 쿼리

단일 쿼리의 성능 문제

관계형 데이터베이스에 대해 작업할 때 EF가 JOIN을 단일 쿼리에 도입하여 관련 엔터티를 로드합니다. JOIN은 SQL을 사용할 때 매우 표준적이지만 부적절하게 사용되는 경우 상당한 성능 문제를 일으킬 수 있습니다. 이 페이지에서는 이러한 성능 문제를 설명하고 해당 문제를 해결하는 관련 엔터티를 로드하는 다른 방법을 보여 줍니다.

카티전 폭발

다음 LINQ 쿼리 및 해당 번역된 SQL에 해당하는 쿼리를 살펴보겠습니다.

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToList();
SELECT [b].[Id], [b].[Name], [p].[Id], [p].[BlogId], [p].[Title], [c].[Id], [c].[BlogId], [c].[FirstName], [c].[LastName]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Contributors] AS [c] ON [b].[Id] = [c].[BlogId]
ORDER BY [b].[Id], [p].[Id]

이 예제에서 PostsContributors은(는) 모두 Blog의 컬렉션 탐색이므로(같은 수준) 관계형 데이터베이스는 교차 제품을 반환합니다. Posts의 각 행은 Contributors의 각 행과 조인됩니다. 즉, 지정된 블로그에 10개의 게시물과 10명의 기여자가 있는 경우 데이터베이스는 해당 단일 블로그에 대해 100개의 행을 반환합니다. 카티전 폭발이라고도 하는 이 현상은 특히 더 많은 형제 JOIN이 쿼리에 추가됨에 따라 의도치 않게 엄청난 양의 데이터가 클라이언트로 전송될 수 있습니다. 이는 데이터베이스 애플리케이션의 주요 성능 문제일 수 있습니다.

두 JOIN이 같은 수준에 있지 않으면 카티전 폭발이 발생하지 않습니다.

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ThenInclude(p => p.Comments)
    .ToList();
SELECT [b].[Id], [b].[Name], [t].[Id], [t].[BlogId], [t].[Title], [t].[Id0], [t].[Content], [t].[PostId]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Comment] AS [c] ON [p].[Id] = [c].[PostId]
ORDER BY [b].[Id], [t].[Id]

이 쿼리에서 Comments은(는) Blog의 컬렉션 탐색이었던 이전 쿼리에서 Contributors와(과) 달리 Post의 컬렉션 탐색입니다. 이 경우 (게시물을 통해) 블로그에 있는 각 댓글에 대해 하나의 행이 반환되고 교차 제품이 발생하지 않습니다.

데이터 중복

JOIN은 다른 유형의 성능 문제를 만들 수 있습니다. 단일 컬렉션 탐색만 로드하는 다음 쿼리를 살펴보겠습니다.

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ToList();
SELECT [b].[Id], [b].[Name], [b].[HugeColumn], [p].[Id], [p].[BlogId], [p].[Title]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
ORDER BY [b].[Id]

프로젝팅된 열에서 검사하는 경우 이 쿼리에서 반환되는 각 행에는 BlogsPosts 테이블의 속성이 모두 포함됩니다. 즉, 블로그에 있는 각 게시물에 대해 블로그 속성이 중복됩니다. 일반적으로 정상이며 문제가 발생하지 않지만 Blogs 테이블에 매우 큰 열(예: 이진 데이터 또는 거대한 텍스트)이 있는 경우 해당 열이 중복되어 클라이언트로 여러 번 다시 전송됩니다. 이렇게 하면 네트워크 트래픽이 크게 증가하고 애플리케이션의 성능에 부정적인 영향을 줄 수 있습니다.

실제로 거대한 열이 필요하지 않은 경우 단순히 쿼리하지 않는 것이 쉽습니다.

var blogs = ctx.Blogs
    .Select(b => new
    {
        b.Id,
        b.Name,
        b.Posts
    })
    .ToList();

프로젝션을 사용하여 원하는 열을 명시적으로 선택하면 큰 열을 생략하고 성능을 향상시킬 수 있습니다. 이는 데이터 중복에 관계없이 좋은 생각이므로 컬렉션 탐색을 로드하지 않는 경우에도 이 작업을 수행하는 것이 좋습니다. 그러나 블로그를 무명 형식으로 프로젝션하므로 블로그는 EF에 의해 추적되지 않으며 변경 내용을 평소와 같이 다시 저장할 수 없습니다.

카티전 폭발과 달리 중복된 데이터 크기는 무시할 수 있으므로 JOIN으로 인한 데이터 중복은 일반적으로 중요하지 않습니다. 이는 일반적으로 주 테이블에 큰 열이 있는 경우에만 걱정할 필요가 있습니다.

분할 쿼리

위에서 설명한 성능 문제를 해결하기 위해 EF를 사용하면 지정된 LINQ 쿼리를 여러 SQL 쿼리로 분할 하도록 지정할 수 있습니다. 분할 쿼리는 포함된 컬렉션 탐색마다 조인 대신 추가 SQL 쿼리를 생성합니다.

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSplitQuery()
        .ToList();
}

그러면 다음 SQL이 생성됩니다.

SELECT [b].[BlogId], [b].[OwnerId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
ORDER BY [b].[BlogId]

SELECT [p].[PostId], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title], [b].[BlogId]
FROM [Blogs] AS [b]
INNER JOIN [Posts] AS [p] ON [b].[BlogId] = [p].[BlogId]
ORDER BY [b].[BlogId]

Warning

Skip/Take와 함께 분할 쿼리를 사용하는 경우 쿼리 순서를 완전히 고유하게 지정하는 데 특히 주의해야 합니다. 이렇게 하지 않으면 잘못된 데이터가 반환될 수 있습니다. 예를 들어 결과가 날짜 기준으로만 정렬되지만 동일한 날짜의 결과가 여러 개 있을 수 있는 경우 각 분할 쿼리가 데이터베이스에서 서로 다른 결과를 가져올 수 있습니다. 날짜 및 ID(또는 다른 고유한 속성 또는 속성 조합) 모두를 기준으로 순서를 지정하면 순서가 완전히 고유해지며 이 문제를 방지할 수 있습니다. 관계형 데이터베이스는 기본 키에서도 기본적으로 순서를 적용하지 않습니다.

참고 항목

일대일 관련 엔터티는 성능에 영향을 주지 않으므로 항상 동일한 쿼리의 조인을 통해 로드됩니다.

분할 쿼리를 전역적으로 사용

분할 쿼리를 애플리케이션 컨텍스트에 대한 기본값으로 구성할 수도 있습니다.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(
            @"Server=(localdb)\mssqllocaldb;Database=EFQuerying;Trusted_Connection=True;ConnectRetryCount=0",
            o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}

분할 쿼리가 기본값으로 구성된 경우에도 특정 쿼리를 단일 쿼리로 실행되도록 구성할 수 있습니다.

using (var context = new SplitQueriesBloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSingleQuery()
        .ToList();
}

구성이 없을 경우 EF Core는 기본적으로 단일 쿼리 모드를 사용합니다. 이로 인해 성능 문제가 발생할 수 있으므로 EF Core는 다음 조건이 충족될 때마다 경고를 생성합니다.

  • 쿼리가 여러 컬렉션을 로드하는 것을 EF Core가 검색합니다.
  • 사용자가 쿼리 분할 모드를 전역적으로 구성하지 않았습니다.
  • 사용자가 쿼리에 AsSingleQuery/AsSplitQuery 연산자를 사용하지 않았습니다.

이 경고를 해제하려면 쿼리 분할 모드를 전역적으로 구성하거나 쿼리 수준에서 적절한 값으로 구성하세요.

분할 쿼리의 특징

분할 쿼리는 조인 및 데카르트 급증과 관련된 성능 문제를 방지하지만 다음과 같은 몇 가지 단점이 있습니다.

  • 대부분의 데이터베이스는 단일 쿼리에 대해 데이터 일관성을 보장하지만 여러 쿼리에는 이러한 보장이 없습니다. 쿼리를 실행할 때 데이터베이스가 동시에 업데이트되는 경우 결과 데이터가 일관되지 않을 수 있습니다. 쿼리를 직렬화 가능 또는 스냅샷 트랜잭션으로 래핑하여 이 문제를 완화할 수 있지만, 그럴 경우 자체 성능 문제가 발생할 수 있습니다. 자세한 내용은 데이터베이스의 설명서를 참조하세요.
  • 각 쿼리는 현재 데이터베이스에 대한 추가 네트워크 왕복을 의미합니다. 네트워크 왕복이 여러 개 있으면, 특히 데이터베이스에 대한 대기 시간이 긴 경우(예: 클라우드 서비스) 성능이 저하될 수 있습니다.
  • 일부 데이터베이스에서는 동시에 여러 쿼리 결과를 사용할 수 있지만(예: MARS, Sqlite를 사용한 SQL Server) 대부분의 데이터베이스에서는 지정된 시점에서 단일 쿼리만 활성화될 수 있습니다. 따라서 이후 쿼리를 실행하기 전에 이전 쿼리의 모든 결과가 애플리케이션의 메모리에 버퍼링되어야 하므로 메모리 요구 사항이 늘어납니다.
  • 참조 탐색 및 컬렉션 탐색을 포함하는 경우 각 분할 쿼리에는 참조 탐색에 대한 조인이 포함됩니다. 이는 특히 참조 탐색이 많은 경우 성능을 저하시킬 수 있습니다. 수정된 내용을 확인하려는 경우 #29182를 호출하세요.

안타깝게도 모든 시나리오에 적합한 관련 엔터티를 로드하는 단 하나의 전략은 없습니다. 단일 쿼리와 분할 쿼리의 장단점을 신중하게 고려하여 요구 사항에 적합한 쿼리를 선택하세요.