데이터베이스 성능 패턴은(는) 무엇을 하나요?
이 스킬을 사용해야 할 때
다음과 같은 상황에서 이 스킬을 사용하세요:
- 데이터 액세스 계층 설계
- 느린 데이터베이스 쿼리 최적화
- EF Core와 Dapper 중 선택
- 일반적인 성능 함정 방지
핵심 원칙
- 읽기 및 쓰기 모델 분리 - 두 가지 모두에 동일한 유형을 사용하지 마세요.
- 배치 단위로 생각하세요 - N+1 쿼리를 피하세요.
- 필요한 것만 검색하세요 - SELECT * 사용 금지.
- 행 제한 적용 - 항상 구성 가능한 Take/Limit을 설정하세요.
- 조인은 SQL에서 수행 - 절대 애플리케이션 코드에서 하지 마세요.
- 읽기에는 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 중 언제 사용할까
| 시나리오 | 권장 사항 |
|---|---|
| 간단한 CRUD | EF Core |
| 복잡한 읽기 쿼리 | Dapper |
| 유효성 검사가 있는 쓰기 작업 | EF Core |
| 대량 작업 | Dapper 또는 원시 SQL |
| 보고/분석 | Dapper |
| 도메인 중심 쓰기 작업 | EF Core |
동일한 프로젝트에서 둘 다 사용할 수 있습니다. EF Core는 쓰기용, Dapper는 읽기용입니다.
빠른 참조
| 안티 패턴 | 해결책 |
|---|---|
| 행 제한 없음 | 모든 읽기 메서드에 limit 매개변수 추가 |
| SELECT * | 필요한 열만 프로젝션 |
| N+1 쿼리 | Include 또는 배치 쿼리 사용 |
| 애플리케이션 조인 | SQL에서 조인 수행 |
| 카테시안 폭발 | AsSplitQuery 또는 프로젝션 사용 |
| 읽기 전용 데이터 추적 | AsNoTracking 사용 |
| 제네릭 리포지토리 | 목적에 맞게 구축된 읽기/쓰기 저장소 |
| 제한 없는 문자열 | 모델에서 MaxLength 구성 |