Community코딩 & 개발github.com

데이터베이스 성능 패턴

데이터베이스 액세스 패턴을 통한 성능 최적화. 읽기/쓰기 모델 분리, N+1 쿼리 방지, AsNoTracking 사용, 행 제한 적용, 애플리케이션 측 조인 금지. EF Core 및 Dapper와 함께 작동합니다.

데이터베이스 성능 패턴란 무엇인가요?

이 스킬은 데이터 액세스 계층을 설계하고 최적화하는 개발자에게 필수적인 데이터베이스 성능 패턴을 제공합니다. EF Core와 Dapper 모두를 지원하며, 읽기와 쓰기 모델을 분리하는 CQRS 패턴을 강조하여 성능과 유지보수성을 향상시킵니다. 주요 원칙에는 N+1 쿼리를 방지하고, 모든 읽기 쿼리에 AsNoTracking을 적용하며, 구성 가능한 Take/Limit을 통해 행 제한을 항상 적용하고, 조인은 반드시 SQL에서 수행하여 애플리케이션 측에서 절대 수행하지 않는 것이 포함됩니다. 이 스킬은 구체적인 코드 예제와 아키텍처 다이어그램을 통해 제네릭 리포지토리 사용, 카테시안 폭발, 제약 없는 문자열 크기와 같은 안티 패턴을 식별하고 해결하는 방법을 보여줍니다. 결과적으로 개발자는 확장 가능하고 효율적인 데이터 액세스 계층을 구축하는 방법에 대한 명확하고 실행 가능한 지침을 얻을 수 있습니다.

지원 대상~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/Aaronontheweb/dotnet-skills/tree/main/skills/database-performance

Installed? Explore more 코딩 & 개발 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

데이터베이스 성능 패턴은(는) 무엇을 하나요?

이 스킬을 사용해야 할 때

다음과 같은 상황에서 이 스킬을 사용하세요:

  • 데이터 액세스 계층 설계
  • 느린 데이터베이스 쿼리 최적화
  • EF Core와 Dapper 중 선택
  • 일반적인 성능 함정 방지

핵심 원칙

  1. 읽기 및 쓰기 모델 분리 - 두 가지 모두에 동일한 유형을 사용하지 마세요.
  2. 배치 단위로 생각하세요 - N+1 쿼리를 피하세요.
  3. 필요한 것만 검색하세요 - SELECT * 사용 금지.
  4. 행 제한 적용 - 항상 구성 가능한 Take/Limit을 설정하세요.
  5. 조인은 SQL에서 수행 - 절대 애플리케이션 코드에서 하지 마세요.
  6. 읽기에는 AsNoTracking 사용 - EF Core 변경 추적은 비용이 많이 듭니다.

읽기/쓰기 모델 분리 (CQRS 패턴)

읽기 모델과 쓰기 모델은 근본적으로 다릅니다. 모양, 열, 목적이 각각 다릅니다. 단일 "User" 엔터티를 만들어 모든 곳에서 재사용하지 마세요.

  • 읽기 모델은 비정규화되어 쿼리 효율성에 최적화되어 있으며 여러 프로젝션 유형(UserProfile, UserSummary, UserDetailForAdmin)을 반환합니다.
  • 쓰기 모델은 정규화되어 유효성 검사에 중점을 두며 강력한 형식의 명령(CreateUserCommand, UpdateUserCommand)을 허용합니다.

아키텍처

src/
  MyApp.Data/
    Users/
      # 읽기 측 - 다중 최적화 프로젝션
      IUserReadStore.cs
      PostgresUserReadStore.cs

      # 쓰기 측 - 명령 처리기
      IUserWriteStore.cs
      PostgresUserWriteStore.cs

      # 읽기 DTO - 경량, 비정규화됨
      UserProfile.cs
      UserSummary.cs

      # 쓰기 명령 - 유효성 검사 중심
      CreateUserCommand.cs
      UpdateUserCommand.cs
    Orders/
      IOrderReadStore.cs
      IOrderWriteStore.cs
      (유사한 구조...)

읽기 저장소 인터페이스

// 읽기 모델: 다양한 사용 사례에 최적화된 여러 특수 프로젝션
public interface IUserReadStore
{
    // 단일 사용자 보기를 위한 상세 프로필 반환
    Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default);

    // 조회를 위한 경량 정보 반환
    Task<UserProfile?> GetByEmailAsync(EmailAddress email, CancellationToken ct = default);

    // 페이지별 요약 반환 - 목록 보기에 필요한 것만
    Task<IReadOnlyList<UserSummary>> GetAllAsync(int limit, UserId? cursor = null, CancellationToken ct = default);

    // 부울 쿼리 - 엔터티 불필요
    Task<bool> EmailExistsAsync(EmailAddress email, CancellationToken ct = default);
}

쓰기 저장소 인터페이스

// 쓰기 모델: 강력한 형식의 명령을 받고 최소한의 반환 값 제공
public interface IUserWriteStore
{
    // 생성된 ID만 반환 - 호출자는 전체 엔터티가 필요 없음
    Task<UserId> CreateAsync(CreateUserCommand command, CancellationToken ct = default);

    // 업데이트는 명령의 유효성을 검사하고 void를 반환 (성공 또는 예외 발생)
    Task UpdateAsync(UserId id, UpdateUserCommand command, CancellationToken ct = default);

    // 삭제는 간단하고 명시적
    Task DeleteAsync(UserId id, CancellationToken ct = default);
}

설명된 주요 구조적 차이점:

  • 읽기 저장소는 여러 다른 DTO(UserProfile, UserSummary, bool 플래그)를 반환합니다.
  • 쓰기 저장소는 최소한의 데이터(생성 시 UserId만) 또는 void를 반환합니다.
  • 읽기 쿼리는 상태 비저장 프로젝션입니다. 추적이 필요 없습니다.
  • 쓰기 작업은 데이터 검색이 아닌 명령 유효성 검사에 중점을 둡니다.
  • 다른 데이터베이스/테이블이 읽기와 쓰기를 뒷받침할 수 있습니다 (최종 일관성 패턴).

항상 행 제한 적용

바인딩되지 않은 결과 집합을 절대 반환하지 마십시오. 모든 읽기 메서드에는 구성 가능한 제한이 있어야 합니다.

패턴: Limit 매개변수

public interface IOrderReadStore
{
    // Limit은 필수, 선택 사항 아님
    Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
        CustomerId customerId,
        int limit,
        OrderId? cursor = null,
        CancellationToken ct = default);
}

// 구현
public async Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
    CustomerId customerId,
    int limit,
    OrderId? cursor = null,
    CancellationToken ct = default)
{
    await using var connection = await _dataSource.OpenConnectionAsync(ct);

    const string sql = """
        SELECT id, customer_id, total, status, created_at
        FROM orders
        WHERE customer_id = @CustomerId
        AND (@Cursor IS NULL OR created_at < (SELECT created_at FROM orders WHERE id = @Cursor))
        ORDER BY created_at DESC
        LIMIT @Limit
        """;

    var rows = await connection.QueryAsync<OrderRow>(sql, new
    {
        CustomerId = customerId.Value,
        Cursor = cursor?.Value,
        Limit = limit
    });

    return rows.Select(r => r.ToOrderSummary()).ToList();
}

EF Core와 페이지네이션

public async Task<PaginatedList<OrderSummary>> GetOrdersAsync(
    CustomerId customerId,
    Paginator paginator,
    CancellationToken ct = default)
{
    var query = _context.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == customerId.Value)
        .OrderByDescending(o => o.CreatedAt);

    var totalCount = await query.CountAsync(ct);

    var orders = await query
        .Skip((paginator.PageNumber - 1) * paginator.PageSize)
        .Take(paginator.PageSize)  // 항상 제한!
        .Select(o => new OrderSummary(
            new OrderId(o.Id),
            o.Total,
            o.Status,
            o.CreatedAt))
        .ToListAsync(ct);

    return new PaginatedList<OrderSummary>(
        orders,
        totalCount,
        paginator.PageSize,
        paginator.PageNumber);
}

읽기 쿼리에 AsNoTracking 사용

EF Core의 변경 추적은 비용이 많이 듭니다. 읽기 전용 쿼리에서는 비활성화하세요.

// 올바른 예: 읽기에는 추적 비활성화
var users = await _context.Users
    .AsNoTracking()
    .Where(u => u.IsActive)
    .ToListAsync();

// 잘못된 예: 수정하지 않을 엔터티 추적
var users = await _context.Users
    .Where(u => u.IsActive)
    .ToListAsync();  // 변경 추적 활성화 - 낭비

기본 동작 구성

// 읽기 중심 애플리케이션의 경우 DbContext에서 다음을 고려하세요.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}

그런 다음 필요할 때 명시적으로 추적을 활성화합니다:

var user = await _context.Users
    .AsTracking()  // 명시적 - 수정할 의도
    .FirstOrDefaultAsync(u => u.Id == userId);

N+1 쿼리 방지

N+1 문제: 목록을 가져온 다음 각 항목의 관련 데이터에 대해 쿼리를 실행합니다.

문제

// 나쁜 예: N+1 쿼리
var orders = await _context.Orders.ToListAsync();

foreach (var order in orders)
{
    // 각 반복마다 데이터베이스에 접근!
    var items = await _context.OrderItems
        .Where(i => i.OrderId == order.Id)
        .ToListAsync();
}

해결책 1: Include (EF Core)

// 좋은 예: 조인이 포함된 단일 쿼리
var orders = await _context.Orders
    .AsNoTracking()
    .Include(o => o.Items)
    .ToListAsync();

해결책 2: 배치 쿼리 (Dapper)

// 좋은 예: 두 개의 쿼리, N+1 없음
const string sql = """
    SELECT id, customer_id, total FROM orders WHERE customer_id = @CustomerId;
    SELECT oi.* FROM order_items oi
    INNER JOIN orders o ON oi.order_id = o.id
    WHERE o.customer_id = @CustomerId;
    """;

using var multi = await connection.QueryMultipleAsync(sql, new { CustomerId = customerId });
var orders = (await multi.ReadAsync<OrderRow>()).ToList();
var items = (await multi.ReadAsync<OrderItemRow>()).ToList();

// 메모리에서 조인 (허용 가능 - 데이터는 이미 가져옴)
foreach (var order in orders)
{
    order.Items = items.Where(i => i.OrderId == order.Id).ToList();
}

애플리케이션 측 조인 금지

조인은 C#이 아닌 SQL에서 수행되어야 합니다.

// 나쁜 예: 애플리케이션 조인 - 두 개의 쿼리, 메모리 낭비
var customers = await _context.Customers.ToListAsync();
var orders = await _context.Orders.ToListAsync();

var result = customers.Select(c => new
{
    Customer = c,
    Orders = orders.Where(o => o.CustomerId == c.Id).ToList()  // O(n*m) 메모리!
});

// 좋은 예: SQL 조인 - 단일 쿼리
var result = await _context.Customers
    .AsNoTracking()
    .Include(c => c.Orders)
    .ToListAsync();

// 좋은 예: 명시적 조인 (Dapper)
const string sql = """
    SELECT c.id, c.name, COUNT(o.id) as order_count
    FROM customers c
    LEFT JOIN orders o ON c.id = o.customer_id
    GROUP BY c.id, c.name
    """;

카테시안 폭발 방지

여러 개의 Include 호출은 카테시안 곱을 유발할 수 있습니다.

// 위험: 수백만 행으로 폭발할 수 있음
var product = await _context.Products
    .Include(p => p.Reviews)      // 100개의 리뷰
    .Include(p => p.Images)       // 20개의 이미지
    .Include(p => p.Categories)   // 5개의 카테고리
    .FirstOrDefaultAsync(p => p.Id == id);
// 결과: 100 * 20 * 5 = 10,000행 전송!

해결책: 분할 쿼리

// 좋은 예: 여러 쿼리, 카테시안 폭발 없음
var product = await _context.Products
    .AsSplitQuery()
    .Include(p => p.Reviews)
    .Include(p => p.Images)
    .Include(p => p.Categories)
    .FirstOrDefaultAsync(p => p.Id == id);
// 결과: 4개의 개별 쿼리, 총 ~125행

해결책: 명시적 프로젝션

// 최고의 예: 필요한 것만 가져오기
var product = await _context.Products
    .AsNoTracking()
    .Where(p => p.Id == id)
    .Select(p => new ProductDetail(
        p.Id,
        p.Name,
        p.Description,
        p.Reviews.OrderByDescending(r => r.CreatedAt).Take(10).ToList(),
        p.Images.Take(5).ToList(),
        p.Categories.Select(c => c.Name).ToList()))
    .FirstOrDefaultAsync();

열 크기 제한

과도하게 큰 데이터를 방지하려면 EF Core 모델에서 최대 길이를 정의하세요.

public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.Property(u => u.Email)
            .HasMaxLength(254)  // RFC 5321 제한
            .IsRequired();

        builder.Property(u => u.Name)
            .HasMaxLength(100)
            .IsRequired();

        builder.Property(u => u.Bio)
            .HasMaxLength(500);

        // 정말 큰 콘텐츠의 경우 명시적으로 text 유형 사용
        builder.Property(u => u.Notes)
            .HasColumnType("text");
    }
}

제네릭 리포지토리 구축 금지

제네릭 리포지토리는 쿼리 복잡성을 숨기고 최적화를 어렵게 만듭니다.

// 나쁜 예: 제네릭 리포지토리
public interface IRepository<T>
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();  // 제한 없음!
    Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);  // 최적화 불가
}

// 좋은 예: 목적에 맞게 구축된 읽기 저장소
public interface IOrderReadStore
{
    Task<OrderDetail?> GetByIdAsync(OrderId id, CancellationToken ct = default);
    Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(CustomerId id, int limit, CancellationToken ct = default);
    Task<IReadOnlyList<OrderSummary>> GetPendingAsync(int limit, CancellationToken ct = default);
}

제네릭 리포지토리의 문제점:

  • 특정 쿼리를 최적화할 수 없음
  • 제한을 적용할 방법 없음
  • N+1 문제를 숨김
  • 너무 많은 데이터를 쉽게 가져오게 함
  • 데이터 액세스에 대한 게으른 사고를 조장

읽기 중심 워크로드에는 Dapper 사용

복잡한 읽기 쿼리의 경우 명시적 SQL을 사용하는 Dapper가 더 깔끔하고 빠른 경우가 많습니다.

public sealed class PostgresUserReadStore : IUserReadStore
{
    private readonly NpgsqlDataSource _dataSource;

    public PostgresUserReadStore(NpgsqlDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public async Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default)
    {
        await using var connection = await _dataSource.OpenConnectionAsync(ct);

        const string sql = """
            SELECT id, email, name, bio, created_at
            FROM users
            WHERE id = @Id
            """;

        var row = await connection.QuerySingleOrDefaultAsync<UserRow>(
            sql, new { Id = id.Value });

        return row?.ToUserProfile();
    }

    // Dapper 매핑을 위한 내부 행 유형
    private sealed class UserRow
    {
        public Guid id { get; set; }
        public string email { get; set; } = null!;
        public string name { get; set; } = null!;
        public string? bio { get; set; }
        public DateTime created_at { get; set; }

        public UserProfile ToUserProfile() => new(
            Id: new UserId(id),
            Email: new EmailAddress(email),
            Name: new PersonName(name),
            Bio: bio,
            CreatedAt: new DateTimeOffset(created_at, TimeSpan.Zero));
    }
}

EF Core와 Dapper 중 언제 사용할까

시나리오권장 사항
간단한 CRUDEF Core
복잡한 읽기 쿼리Dapper
유효성 검사가 있는 쓰기 작업EF Core
대량 작업Dapper 또는 원시 SQL
보고/분석Dapper
도메인 중심 쓰기 작업EF Core

동일한 프로젝트에서 둘 다 사용할 수 있습니다. EF Core는 쓰기용, Dapper는 읽기용입니다.


빠른 참조

안티 패턴해결책
행 제한 없음모든 읽기 메서드에 limit 매개변수 추가
SELECT *필요한 열만 프로젝션
N+1 쿼리Include 또는 배치 쿼리 사용
애플리케이션 조인SQL에서 조인 수행
카테시안 폭발AsSplitQuery 또는 프로젝션 사용
읽기 전용 데이터 추적AsNoTracking 사용
제네릭 리포지토리목적에 맞게 구축된 읽기/쓰기 저장소
제한 없는 문자열모델에서 MaxLength 구성

리소스

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

akka-hosting-actor-patterns

Patterns for building entity actors with Akka.Hosting - GenericChildPerEntityParent, message extractors, cluster sharding abstraction, akka-reminders, and ITimeProvider. Supports both local testing and clustered production modes.

akka-net-aspire-configuration

Configure Akka.NET with .NET Aspire for local development and production deployments. Covers actor system setup, clustering, persistence, Akka.Management integration, and Aspire orchestration patterns.

akka-net-best-practices

Critical Akka.NET best practices including EventStream vs DistributedPubSub, supervision strategies, error handling, Props vs DependencyResolver, work distribution patterns, and cluster/local mode abstractions for testability.

akka-net-management

Akka.Management for cluster bootstrapping, service discovery (Kubernetes, Azure, Config), health checks, and dynamic cluster formation without static seed nodes.

akka-net-testing-patterns

Write unit and integration tests for Akka.NET actors using modern Akka.Hosting.TestKit patterns. Covers dependency injection, TestProbes, persistence testing, and actor interaction verification. Includes guidance on when to use traditional TestKit.

api-design

Design stable, compatible public APIs using extend-only design principles. Manage API compatibility, wire compatibility, and versioning for NuGet packages and distributed systems.

aspire-configuration

Configure Aspire AppHost to emit explicit app config via environment variables; keep app code free of Aspire clients and service discovery.

aspire-integration-testing

Write integration tests using .NET Aspire

aspire-service-defaults

Create a shared ServiceDefaults project for Aspire applications. Centralizes OpenTelemetry, health checks, resilience, and service discovery configuration across all services.

crap-analysis

Analyze code coverage and CRAP (Change Risk Anti-Patterns) scores to identify high-risk code. Use OpenCover format with ReportGenerator for Risk Hotspots showing cyclomatic complexity and untested code paths.

csharp-concurrency-patterns

Choosing the right concurrency abstraction in .NET - from async/await for I/O to Channels for producer/consumer to Akka.NET for stateful entity management. Avoid locks and manual synchronization unless absolutely necessary.

dependency-injection-patterns

Organize DI registrations using IServiceCollection extension methods. Group related services into composable Add* methods for clean Program.cs and reusable configuration in tests.

dotnet-devcert-trust

Diagnose and fix .NET HTTPS dev certificate trust issues on Linux. Covers the full certificate lifecycle from generation to system CA bundle inclusion, with distro-specific guidance for Ubuntu, Fedora, Arch, and WSL2.

dotnet-local-tools

Managing local .NET tools with dotnet-tools.json for consistent tooling across development environments and CI/CD pipelines.

dotnet-project-structure

Modern .NET project structure including .slnx solution format, Directory.Build.props, central package management, SourceLink, version management with RELEASE_NOTES.md, and SDK pinning with global.json.

dotnet-slopwatch

Use Slopwatch to detect LLM reward hacking in .NET code changes. Run after every code modification to catch disabled tests, suppressed warnings, empty catch blocks, and other shortcuts that mask real problems.

efcore-patterns

Entity Framework Core best practices including NoTracking by default, query splitting for navigation collections, migration management, dedicated migration services, and common pitfalls to avoid.

ilspy-decompile

Understand implementation details of .NET code by decompiling assemblies. Use when you want to see how a .NET API works internally, inspect NuGet package source, view framework implementation, or understand compiled .NET binaries.

mailpit-integration

Test email sending locally using Mailpit with .NET Aspire. Captures all outgoing emails without sending them. View rendered HTML, inspect headers, and verify delivery in integration tests.

marketplace-publishing

Workflow for publishing skills and agents to the dotnet-skills Claude Code marketplace. Covers adding new content, updating plugin.json, validation, and release tagging.

관련 스킬