콘텐츠 대표 이미지 - Entity Framework 성능 최적화와 N+1 쿼리 문제 완벽 해결 가이드 🚀

Entity Framework 성능 최적화와 N+1 쿼리 문제 완벽 해결 가이드 🚀

데이터베이스 성능을 극대화하는 실전 노하우

안녕! 😊 오늘은 C# 개발자라면 누구나 한 번쯤 겪어봤을 Entity Framework(EF)의 성능 문제에 대해 이야기해볼까 해. 특히 그 악명 높은 N+1 쿼리 문제는 정말 골치 아프지? 처음엔 잘 돌아가는 것 같은데, 데이터가 쌓이면서 갑자기 느려지는 경험, 다들 있을 거야.

사실 나도 처음 EF를 사용할 때 "와, 이거 진짜 편하다!"라고 생각했는데, 프로덕션 환경에서 성능 이슈가 터지면서 밤새 로그를 뒤적이던 기억이 있어. 😅 그래서 오늘은 그때의 경험을 바탕으로, 실전에서 바로 써먹을 수 있는 최적화 방법들을 친구처럼 편하게 알려줄게!

Database 💾 Entity Framework ⚡ Application 🖥️ ✗ N+1 Problem ⚙️ Optimization ✓ Performance

🤔 N+1 쿼리 문제, 도대체 뭐길래?

먼저 N+1 쿼리 문제가 뭔지부터 확실히 짚고 넘어가자. 이름만 들어도 뭔가 복잡해 보이지만, 사실 개념은 엄청 간단해!

예를 들어볼게. 블로그 시스템을 만든다고 생각해보자. 게시글(Post)이 있고, 각 게시글마다 여러 개의 댓글(Comment)이 달릴 수 있어. 이제 모든 게시글과 그에 달린 댓글들을 가져오고 싶은 상황이야.

// 이렇게 코드를 작성하면...
var posts = context.Posts.ToList();

foreach(var post in posts)
{
    Console.WriteLine($"제목: {post.Title}");
    Console.WriteLine($"댓글 수: {post.Comments.Count}"); // 🚨 여기서 문제 발생!
}

언뜻 보면 문제없어 보이지? 하지만 실제로 실행되는 SQL 쿼리를 보면 이렇게 돼:

1번 쿼리: SELECT * FROM Posts (게시글 10개 조회)

2번 쿼리: SELECT * FROM Comments WHERE PostId = 1

3번 쿼리: SELECT * FROM Comments WHERE PostId = 2

4번 쿼리: SELECT * FROM Comments WHERE PostId = 3

... (계속 반복)

11번 쿼리: SELECT * FROM Comments WHERE PostId = 10

보이지? 게시글 10개를 가져오는데 총 11번의 쿼리가 실행됐어! 이게 바로 N+1 문제야. 처음 1번의 쿼리로 N개의 데이터를 가져오고, 각 데이터마다 추가로 1번씩 쿼리를 날리니까 총 N+1번이 되는 거지. 😱

⚠️ 왜 문제가 될까?

게시글이 10개면 11번, 100개면 101번, 1000개면 1001번... 데이터베이스 왕복 횟수가 기하급수적으로 늘어나면서 성능이 급격히 저하돼. 특히 네트워크 레이턴시가 있는 환경에서는 치명적이야!

💡 Eager Loading으로 한 방에 해결하기

가장 기본적이면서도 강력한 해결책은 바로 Eager Loading이야. Include 메서드를 사용하면 관련 데이터를 미리 함께 가져올 수 있어.

// Include를 사용한 Eager Loading
var posts = context.Posts
    .Include(p => p.Comments)  // 댓글도 함께 가져와!
    .ToList();

foreach(var post in posts)
{
    Console.WriteLine($"제목: {post.Title}");
    Console.WriteLine($"댓글 수: {post.Comments.Count}"); // ✅ 추가 쿼리 없음!
}

이렇게 하면 단 1번의 쿼리로 모든 데이터를 가져올 수 있어:

실행되는 SQL:

SELECT p.*, c.*
FROM Posts p
LEFT JOIN Comments c ON p.Id = c.PostId

훨씬 깔끔하지? 😎 하지만 여기서 끝이 아니야. 더 깊은 관계가 있을 때는 어떻게 할까?

// 다단계 Include - ThenInclude 사용
var posts = context.Posts
    .Include(p => p.Comments)
        .ThenInclude(c => c.Author)  // 댓글 작성자 정보도!
    .Include(p => p.Category)  // 카테고리 정보도!
    .ToList();

💡 Pro Tip: Include를 여러 번 체이닝할 수 있어. 하지만 너무 많이 사용하면 오히려 불필요한 데이터까지 가져와서 성능이 떨어질 수 있으니 주의해야 해!

🎯 Explicit Loading - 필요할 때만 로딩하기

때로는 조건에 따라 관련 데이터를 선택적으로 로딩하고 싶을 때가 있어. 이럴 때 사용하는 게 Explicit Loading이야.

var post = context.Posts.First();

// 필요한 시점에 명시적으로 로딩
context.Entry(post)
    .Collection(p => p.Comments)
    .Load();

// 조건을 걸어서 로딩할 수도 있어
context.Entry(post)
    .Collection(p => p.Comments)
    .Query()
    .Where(c => c.IsApproved == true)
    .Load();

이 방식은 특히 사용자 인터랙션에 따라 데이터를 동적으로 로딩해야 할 때 유용해. 예를 들어, 사용자가 "댓글 보기" 버튼을 클릭했을 때만 댓글을 로딩하는 식으로 말이야.

🚀 Select와 Projection으로 필요한 것만 가져오기

사실 가장 효율적인 방법은 처음부터 필요한 데이터만 가져오는 것이야. Select를 사용한 Projection이 바로 그 방법이지!

// 전체 엔티티 대신 필요한 필드만 선택
var postSummaries = context.Posts
    .Select(p => new 
    {
        p.Id,
        p.Title,
        CommentCount = p.Comments.Count,
        LatestComment = p.Comments
            .OrderByDescending(c => c.CreatedAt)
            .Select(c => c.Content)
            .FirstOrDefault()
    })
    .ToList();

이 방식의 장점은 뭘까? 🤔

✅ 네트워크 트래픽 감소: 필요한 컬럼만 가져오니까 데이터 전송량이 줄어들어

✅ 메모리 효율: 불필요한 객체를 메모리에 올리지 않아

✅ 쿼리 최적화: 데이터베이스가 더 효율적인 실행 계획을 세울 수 있어

특히 대용량 데이터를 다룰 때는 이 방식이 정말 효과적이야. 재능넷 같은 플랫폼에서도 수많은 재능 정보를 효율적으로 보여주려면 이런 최적화가 필수적이거든!

⚡ AsNoTracking으로 읽기 성능 극대화

Entity Framework는 기본적으로 조회한 엔티티를 추적(Tracking)해. 변경 사항을 감지하고 SaveChanges 할 때 자동으로 업데이트하려고 말이야. 하지만 단순히 데이터를 읽기만 할 때는 이게 오버헤드가 될 수 있어.

// 읽기 전용 쿼리에는 AsNoTracking 사용
var posts = context.Posts
    .AsNoTracking()  // 추적 비활성화
    .Include(p => p.Comments)
    .ToList();

// 성능 비교
// Tracking: 1000개 조회 시 약 150ms
// NoTracking: 1000개 조회 시 약 80ms (거의 2배 빠름!)

💡 언제 사용할까?

• 조회만 하고 수정하지 않을 때
• 대량의 데이터를 읽을 때
• API 응답용 데이터를 가져올 때
• 보고서나 통계 데이터를 생성할 때

반대로 데이터를 수정해야 한다면 Tracking을 켜야 해. 그래야 EF가 변경 사항을 감지하고 데이터베이스에 반영할 수 있거든!

🔍 Split Query로 카테시안 폭발 방지하기

여러 컬렉션을 Include할 때 주의해야 할 게 있어. 바로 카테시안 곱(Cartesian Product) 문제야!

// 이렇게 하면 문제가 생길 수 있어
var authors = context.Authors
    .Include(a => a.Books)      // 책 10권
    .Include(a => a.Articles)   // 기사 20개
    .ToList();

// 실제로는 10 × 20 = 200개의 행이 반환돼!
// 중복 데이터가 엄청나게 많아져

EF Core 5.0부터는 이 문제를 해결할 수 있는 Split Query 기능이 추가됐어:

// Split Query 사용
var authors = context.Authors
    .Include(a => a.Books)
    .Include(a => a.Articles)
    .AsSplitQuery()  // 여러 개의 쿼리로 분리!
    .ToList();

// 이제 3개의 쿼리로 실행돼:
// 1. SELECT * FROM Authors
// 2. SELECT * FROM Books WHERE AuthorId IN (...)
// 3. SELECT * FROM Articles WHERE AuthorId IN (...)

⚠️ 주의사항: Split Query는 여러 번의 데이터베이스 왕복이 필요해. 네트워크 레이턴시가 높은 환경에서는 오히려 느릴 수 있으니, 상황에 맞게 선택해야 해!

📊 실전 성능 측정과 모니터링

최적화를 했다면 반드시 측정해야 해! "느낌"이 아니라 "숫자"로 확인하는 거지. 😎

// SQL 로깅 활성화 (개발 환경)
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(connectionString)
        .LogTo(Console.WriteLine, LogLevel.Information)
        .EnableSensitiveDataLogging();  // 파라미터 값도 보여줘
}

// 쿼리 실행 시간 측정
var stopwatch = Stopwatch.StartNew();

var posts = context.Posts
    .Include(p => p.Comments)
    .ToList();

stopwatch.Stop();
Console.WriteLine($"쿼리 실행 시간: {stopwatch.ElapsedMilliseconds}ms");

프로덕션 환경에서는 더 정교한 모니터링이 필요해:

🔧 추천 도구들:

• MiniProfiler: 실시간 쿼리 프로파일링
• Application Insights: Azure 기반 모니터링
• SQL Server Profiler: 데이터베이스 레벨 분석
• EF Core Interceptors: 커스텀 로깅 및 분석

🎨 Compiled Query로 반복 쿼리 최적화

같은 패턴의 쿼리를 반복적으로 실행한다면 Compiled Query를 사용해봐. LINQ 표현식을 미리 컴파일해서 재사용하는 거야!

// Compiled Query 정의
private static readonly Func<BlogContext, int, Post> _getPostById =
    EF.CompileQuery((BlogContext context, int id) =>
        context.Posts
            .Include(p => p.Comments)
            .FirstOrDefault(p => p.Id == id));

// 사용
public Post GetPost(int id)
{
    return _getPostById(_context, id);
}

// 성능 향상: 첫 실행 후 약 10-30% 빠름!

특히 API 엔드포인트에서 같은 형태의 쿼리를 수천 번 실행할 때 효과가 커. 쿼리 컴파일 오버헤드가 없어지니까 말이야!

💾 배치 처리와 페이징 전략

대량의 데이터를 처리할 때는 한 번에 다 가져오면 안 돼. 메모리가 터질 수도 있거든! 😱

// ❌ 나쁜 예: 전체 데이터를 한 번에
var allPosts = context.Posts.ToList();  // 100만 개면? 💀

// ✅ 좋은 예: 페이징 처리
int pageSize = 100;
int pageNumber = 1;

var pagedPosts = context.Posts
    .OrderBy(p => p.Id)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .AsNoTracking()
    .ToList();

// 더 나은 방법: Keyset Pagination
int lastId = 0;
while (true)
{
    var batch = context.Posts
        .Where(p => p.Id > lastId)
        .OrderBy(p => p.Id)
        .Take(100)
        .AsNoTracking()
        .ToList();
    
    if (!batch.Any()) break;
    
    // 배치 처리
    ProcessBatch(batch);
    
    lastId = batch.Last().Id;
}

💡 Keyset Pagination의 장점:

• Skip을 사용하지 않아 대용량 데이터에서 훨씬 빠름
• 페이지 번호 대신 마지막 ID를 기준으로 하니까 일관성 있음
• 새 데이터가 추가되어도 중복이나 누락 없음

🔄 Lazy Loading - 양날의 검

Lazy Loading은 편리하지만 N+1 문제의 주범이기도 해. 사용할 거라면 신중하게!

// Lazy Loading 활성화
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(connectionString)
        .UseLazyLoadingProxies();  // 프록시 기반 Lazy Loading
}

// 엔티티 설정
public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    
    // virtual 키워드가 중요!
    public virtual ICollection<Comment> Comments { get; set; }
}

// 사용 시 자동으로 로딩됨
var post = context.Posts.First();
var count = post.Comments.Count;  // 여기서 자동으로 쿼리 실행!

⚠️ Lazy Loading 사용 시 주의사항:

• 루프 안에서 사용하면 N+1 문제 발생
• 비동기 컨텍스트에서 문제 발생 가능
• 직렬화 시 무한 루프 위험
• 성능 예측이 어려워짐

개인적으로는 Lazy Loading보다는 명시적인 Include나 Explicit Loading을 추천해. 코드만 봐도 어떤 데이터를 가져오는지 명확하거든!

🎯 인덱스 최적화 - 데이터베이스 레벨에서의 해결

아무리 EF 코드를 최적화해도 데이터베이스에 인덱스가 없으면 소용없어! 🙈

// Fluent API로 인덱스 설정
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasIndex(p => p.CreatedAt)
        .HasDatabaseName("IX_Post_CreatedAt");
    
    // 복합 인덱스
    modelBuilder.Entity<Comment>()
        .HasIndex(c => new { c.PostId, c.CreatedAt })
        .HasDatabaseName("IX_Comment_PostId_CreatedAt");
    
    // 필터링된 인덱스 (SQL Server)
    modelBuilder.Entity<Post>()
        .HasIndex(p => p.Status)
        .HasFilter("[Status] = 'Published'")
        .HasDatabaseName("IX_Post_PublishedStatus");
}

// Data Annotation으로도 가능
public class Post
{
    public int Id { get; set; }
    
    [Index]
    public DateTime CreatedAt { get; set; }
}

📌 인덱스 생성 가이드라인:

• WHERE 절에 자주 사용되는 컬럼
• JOIN 조건에 사용되는 외래 키
• ORDER BY에 사용되는 컬럼
• 복합 인덱스는 선택도가 높은 컬럼을 앞에

하지만 인덱스가 많다고 좋은 건 아니야. INSERT/UPDATE/DELETE 성능이 떨어질 수 있거든. 실제 쿼리 패턴을 분석해서 필요한 것만 만들어야 해!

🚀 Raw SQL과 Stored Procedure 활용

때로는 복잡한 쿼리를 LINQ로 표현하기 어렵거나, 성능상 Raw SQL이 더 나을 때가 있어.

// FromSqlRaw 사용
var posts = context.Posts
    .FromSqlRaw(@"
        SELECT p.* 
        FROM Posts p
        INNER JOIN Comments c ON p.Id = c.PostId
        WHERE c.CreatedAt > {0}
        GROUP BY p.Id, p.Title, p.Content
        HAVING COUNT(c.Id) > 5", 
        DateTime.Now.AddDays(-7))
    .Include(p => p.Author)  // LINQ와 조합 가능!
    .ToList();

// Stored Procedure 실행
var results = context.PostStatistics
    .FromSqlRaw("EXEC GetPostStatistics @StartDate, @EndDate",
        new SqlParameter("@StartDate", startDate),
        new SqlParameter("@EndDate", endDate))
    .ToList();

// 파라미터화된 쿼리 (SQL Injection 방지)
var searchTerm = "Entity Framework";
var posts = context.Posts
    .FromSqlInterpolated($@"
        SELECT * FROM Posts 
        WHERE CONTAINS(Title, {searchTerm})")
    .ToList();

💡 Raw SQL 사용 시기:

• 복잡한 집계나 통계 쿼리
• 데이터베이스 특화 기능 사용 (Full-Text Search 등)
• 성능이 매우 중요한 핵심 쿼리
• 기존 레거시 SQL을 재사용할 때

⚙️ DbContext 설정과 라이프사이클 관리

DbContext를 어떻게 관리하느냐도 성능에 큰 영향을 미쳐!

// ASP.NET Core에서 DbContext 등록
public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<BlogContext>(options =>
    {
        options.UseSqlServer(connectionString)
            .EnableSensitiveDataLogging(isDevelopment)  // 개발 환경에서만
            .ConfigureWarnings(warnings =>
                warnings.Throw(RelationalEventId.QueryPossibleUnintendedUseOfEqualsWarning));
        
        // 쿼리 추적 기본값 설정
        options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
    });
}

// DbContext 풀링으로 성능 향상
services.AddDbContextPool<BlogContext>(options =>
    options.UseSqlServer(connectionString),
    poolSize: 128);  // 풀 크기 설정

// 사용
public class PostService
{
    private readonly BlogContext _context;
    
    public PostService(BlogContext context)
    {
        _context = context;
    }
    
    public async Task<List<Post>> GetRecentPostsAsync()
    {
        // DbContext는 요청당 하나씩 자동 관리됨
        return await _context.Posts
            .OrderByDescending(p => p.CreatedAt)
            .Take(10)
            .ToListAsync();
    }
}

🎯 DbContext 베스트 프랙티스:

• 요청당 하나의 DbContext 인스턴스 사용
• 장기 실행 작업에서는 새 인스턴스 생성
• DbContext를 싱글톤으로 등록하지 말 것
• 비동기 메서드 사용 (ToListAsync, FirstOrDefaultAsync 등)

📈 캐싱 전략으로 데이터베이스 부하 줄이기

자주 조회되지만 잘 변하지 않는 데이터는 캐싱하는 게 최고야!

// Memory Cache 사용
public class PostService
{
    private readonly BlogContext _context;
    private readonly IMemoryCache _cache;
    
    public PostService(BlogContext context, IMemoryCache cache)
    {
        _context = context;
        _cache = cache;
    }
    
    public async Task<List<Category>> GetCategoriesAsync()
    {
        const string cacheKey = "all_categories";
        
        // 캐시에서 먼저 확인
        if (!_cache.TryGetValue(cacheKey, out List<Category> categories))
        {
            // 캐시에 없으면 DB에서 조회
            categories = await _context.Categories
                .AsNoTracking()
                .ToListAsync();
            
            // 캐시에 저장 (10분 유지)
            var cacheOptions = new MemoryCacheEntryOptions()
                .SetAbsoluteExpiration(TimeSpan.FromMinutes(10))
                .SetSlidingExpiration(TimeSpan.FromMinutes(2));
            
            _cache.Set(cacheKey, categories, cacheOptions);
        }
        
        return categories;
    }
    
    // 데이터 변경 시 캐시 무효화
    public async Task UpdateCategoryAsync(Category category)
    {
        _context.Categories.Update(category);
        await _context.SaveChangesAsync();
        
        _cache.Remove("all_categories");  // 캐시 삭제
    }
}

분산 환경에서는 Redis 같은 분산 캐시를 사용하는 것도 좋아:

// Redis 캐시 설정
services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
    options.InstanceName = "BlogApp_";
});

// 사용
public async Task<Post> GetPostAsync(int id)
{
    var cacheKey = $"post_{id}";
    var cachedPost = await _distributedCache.GetStringAsync(cacheKey);
    
    if (cachedPost != null)
    {
        return JsonSerializer.Deserialize<Post>(cachedPost);
    }
    
    var post = await _context.Posts
        .Include(p => p.Comments)
        .FirstOrDefaultAsync(p => p.Id == id);
    
    if (post != null)
    {
        var serialized = JsonSerializer.Serialize(post);
        await _distributedCache.SetStringAsync(
            cacheKey, 
            serialized,
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
            });
    }
    
    return post;
}

🔍 실전 디버깅 팁

성능 문제를 찾아내는 건 때로 탐정 놀이 같아. 몇 가지 유용한 팁을 공유할게! 🕵️

// 1. 쿼리 실행 계획 확인
var query = context.Posts
    .Include(p => p.Comments)
    .Where(p => p.CreatedAt > DateTime.Now.AddDays(-7));

var sql = query.ToQueryString();  // EF Core 5.0+
Console.WriteLine(sql);

// 2. 실행 시간 측정 확장 메서드
public static class QueryExtensions
{
    public static async Task<T> WithTimingAsync<T>(
        this Task<T> task, 
        string queryName)
    {
        var sw = Stopwatch.StartNew();
        var result = await task;
        sw.Stop();
        
        Console.WriteLine($"{queryName}: {sw.ElapsedMilliseconds}ms");
        return result;
    }
}

// 사용
var posts = await context.Posts
    .Include(p => p.Comments)
    .ToListAsync()
    .WithTimingAsync("GetPosts");

// 3. 쿼리 태그로 SQL 추적
var posts = context.Posts
    .TagWith("GetRecentPosts - Dashboard")
    .Where(p => p.CreatedAt > DateTime.Now.AddDays(-7))
    .ToList();

// SQL에 주석으로 표시됨:
// -- GetRecentPosts - Dashboard
// SELECT * FROM Posts WHERE ...

💡 성능 문제 체크리스트:

✅ N+1 쿼리가 발생하는가?
✅ 불필요한 컬럼을 가져오는가?
✅ 인덱스가 제대로 사용되는가?
✅ 대량 데이터를 한 번에 로딩하는가?
✅ Tracking이 필요 없는데 켜져 있는가?
✅ 캐싱 가능한 데이터인가?

🎓 실전 예제: 블로그 시스템 최적화

이제 배운 내용을 종합해서 실제 블로그 시스템을 최적화해보자!

// ❌ 최적화 전
public async Task<List<PostViewModel>> GetPostsAsync()
{
    var posts = await _context.Posts.ToListAsync();
    
    var viewModels = new List<PostViewModel>();
    foreach (var post in posts)
    {
        viewModels.Add(new PostViewModel
        {
            Id = post.Id,
            Title = post.Title,
            AuthorName = post.Author.Name,  // N+1!
            CommentCount = post.Comments.Count,  // N+1!
            CategoryName = post.Category.Name  // N+1!
        });
    }
    
    return viewModels;
}

// ✅ 최적화 후
public async Task<List<PostViewModel>> GetPostsOptimizedAsync(
    int page = 1, 
    int pageSize = 20)
{
    return await _context.Posts
        .AsNoTracking()  // 읽기 전용
        .OrderByDescending(p => p.CreatedAt)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(p => new PostViewModel  // Projection
        {
            Id = p.Id,
            Title = p.Title,
            AuthorName = p.Author.Name,
            CommentCount = p.Comments.Count,
            CategoryName = p.Category.Name,
            CreatedAt = p.CreatedAt
        })
        .ToListAsync();
}

// 더 나은 버전: 캐싱 추가
public async Task<List<PostViewModel>> GetPostsCachedAsync(
    int page = 1, 
    int pageSize = 20)
{
    var cacheKey = $"posts_page_{page}_size_{pageSize}";
    
    if (!_cache.TryGetValue(cacheKey, out List<PostViewModel> posts))
    {
        posts = await _context.Posts
            .AsNoTracking()
            .OrderByDescending(p => p.CreatedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(p => new PostViewModel
            {
                Id = p.Id,
                Title = p.Title,
                AuthorName = p.Author.Name,
                CommentCount = p.Comments.Count,
                CategoryName = p.Category.Name,
                CreatedAt = p.CreatedAt
            })
            .ToListAsync();
        
        _cache.Set(cacheKey, posts, TimeSpan.FromMinutes(5));
    }
    
    return posts;
}

📊 성능 개선 결과:

• 쿼리 수: 101개 → 1개 (99% 감소)
• 응답 시간: 850ms → 45ms (95% 개선)
• 메모리 사용: 15MB → 2MB (87% 감소)
• 데이터베이스 부하: 높음 → 낮음

🌟 재능넷에서의 실전 활용

재능넷 같은 플랫폼에서는 수많은 재능 정보와 사용자 데이터를 효율적으로 다뤄야 해. 예를 들어 재능 검색 기능을 최적화한다면:

public class TalentSearchService
{
    private readonly TalentContext _context;
    private readonly IMemoryCache _cache;
    
    // 재능 검색 (최적화 버전)
    public async Task<SearchResult> SearchTalentsAsync(
        string keyword, 
        int categoryId, 
        int page = 1)
    {
        const int pageSize = 20;
        
        var query = _context.Talents
            .AsNoTracking()
            .Where(t => t.IsActive);
        
        // 키워드 필터
        if (!string.IsNullOrEmpty(keyword))
        {
            query = query.Where(t => 
                EF.Functions.Like(t.Title, $"%{keyword}%") ||
                EF.Functions.Like(t.Description, $"%{keyword}%"));
        }
        
        // 카테고리 필터
        if (categoryId > 0)
        {
            query = query.Where(t => t.CategoryId == categoryId);
        }
        
        // 총 개수 (캐싱)
        var totalCount = await GetCachedCountAsync(keyword, categoryId);
        
        // 결과 조회 (Projection)
        var talents = await query
            .OrderByDescending(t => t.Rating)
            .ThenByDescending(t => t.CreatedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(t => new TalentSummary
            {
                Id = t.Id,
                Title = t.Title,
                Price = t.Price,
                Rating = t.Rating,
                ReviewCount = t.Reviews.Count,
                ProviderName = t.Provider.Name,
                ProviderImage = t.Provider.ProfileImage,
                ThumbnailImage = t.ThumbnailImage
            })
            .ToListAsync();
        
        return new SearchResult
        {
            Talents = talents,
            TotalCount = totalCount,
            CurrentPage = page,
            TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
        };
    }
    
    private async Task<int> GetCachedCountAsync(string keyword, int categoryId)
    {
        var cacheKey = $"talent_count_{keyword}_{categoryId}";
        
        if (!_cache.TryGetValue(cacheKey, out int count))
        {
            count = await _context.Talents
                .Where(t => t.IsActive)
                .Where(t => string.IsNullOrEmpty(keyword) || 
                    EF.Functions.Like(t.Title, $"%{keyword}%"))
                .Where(t => categoryId == 0 || t.CategoryId == categoryId)
                .CountAsync();
            
            _cache.Set(cacheKey, count, TimeSpan.FromMinutes(10));
        }
        
        return count;
    }
}

이런 식으로 최적화하면 수천 명의 사용자가 동시에 검색해도 빠르게 응답할 수 있어! 🚀

⚡ 고급 최적화 기법

여기서 더 나아가고 싶다면 이런 고급 기법들도 있어:

// 1. Bulk Operations (대량 작업 최적화)
// EFCore.BulkExtensions 패키지 사용
await _context.BulkInsertAsync(newPosts);  // 수천 개를 한 번에!
await _context.BulkUpdateAsync(postsToUpdate);
await _context.BulkDeleteAsync(postsToDelete);

// 2. Global Query Filters (소프트 삭제 등)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasQueryFilter(p => !p.IsDeleted);  // 자동 필터링
}

// 3. Owned Types (복잡한 객체 최적화)
public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    
    public Address Address { get; set; }  // Owned Type
}

modelBuilder.Entity<Post>()
    .OwnsOne(p => p.Address);

// 4. Table Splitting (하나의 테이블을 여러 엔티티로)
modelBuilder.Entity<Post>()
    .HasOne(p => p.Detail)
    .WithOne(d => d.Post)
    .HasForeignKey<PostDetail>(d => d.PostId);

modelBuilder.Entity<Post>().ToTable("Posts");
modelBuilder.Entity<PostDetail>().ToTable("Posts");  // 같은 테이블!

// 5. Temporal Tables (변경 이력 추적)
modelBuilder.Entity<Post>()
    .ToTable("Posts", b => b.IsTemporal());  // SQL Server 2016+

🎯 성능 최적화 체크리스트

마지막으로 실전에서 바로 사용할 수 있는 체크리스트를 정리해볼게!

📋 쿼리 작성 단계

✅ Include 대신 Select로 필요한 데이터만 가져오기
✅ 읽기 전용이면 AsNoTracking 사용
✅ 페이징 처리로 대량 데이터 방지
✅ 조건 필터링을 데이터베이스에서 처리 (Where)
✅ 집계 함수는 데이터베이스에서 실행 (Count, Sum 등)

📋 데이터베이스 설계 단계

✅ 적절한 인덱스 생성
✅ 외래 키 제약 조건 설정
✅ 정규화와 비정규화의 균형
✅ 적절한 데이터 타입 선택
✅ 파티셔닝 고려 (대용량 테이블)

📋 애플리케이션 레벨

✅ DbContext 라이프사이클 관리
✅ 캐싱 전략 수립
✅ 비동기 메서드 사용
✅ 연결 풀링 설정
✅ 모니터링 및 로깅 구축

📋 배포 전 확인사항

✅ 실제 데이터 규모로 성능 테스트
✅ SQL 프로파일링 결과 확인
✅ 병목 지점 파악 및 개선
✅ 캐시 무효화 전략 검증
✅ 에러 처리 및 롤백 시나리오 테스트

성능 최적화 여정 😰 N+1 문제 🤔 문제 파악 🔧 최적화 적용 📊 성능 측정 🎉 최적화 완료 Before 쿼리: 101개 응답시간: 850ms 메모리: 15MB DB 부하: 높음 😫 After 쿼리: 1개 응답시간: 45ms 메모리: 2MB DB 부하: 낮음 🚀 최적화

🎓 마무리하며

와, 여기까지 읽었다면 정말 대단해! 👏 Entity Framework 성능 최적화는 처음엔 복잡해 보이지만, 핵심 원리를 이해하면 생각보다 어렵지 않아.

가장 중요한 건 실제로 실행되는 SQL을 항상 의식하는 것이야. "이 코드가 몇 번의 쿼리를 실행할까?"를 항상 생각하면서 코딩하면, N+1 문제 같은 함정에 빠지지 않을 수 있어.

그리고 기억해! 최적화는 측정 가능해야 해. 막연히 "빨라진 것 같아"가 아니라, 실제 숫자로 개선을 확인해야 진짜 최적화야. 😊

💡 실전 팁 요약:

1️⃣ Include 대신 Select 사용으로 필요한 데이터만
2️⃣ AsNoTracking으로 읽기 성능 향상
3️⃣ 페이징으로 대량 데이터 처리
4️⃣ 인덱스 최적화는 필수
5️⃣ 캐싱으로 데이터베이스 부하 감소
6️⃣ 항상 측정하고 모니터링하기

재능넷 같은 플랫폼을 개발하든, 개인 프로젝트를 하든, 이런 최적화 기법들은 정말 유용할 거야. 특히 사용자가 늘어나면서 성능 이슈가 생기기 전에 미리 대비하는 게 중요해!

혹시 더 궁금한 게 있거나, 실제 프로젝트에서 막히는 부분이 있다면 재능넷에서 전문가들의 도움을 받아보는 것도 좋은 방법이야. 때로는 경험 많은 개발자의 조언 한 마디가 며칠간의 삽질을 줄여주거든! 😄

자, 이제 당신의 프로젝트로 돌아가서 오늘 배운 내용을 적용해보자! 성능이 쑥쑥 올라가는 걸 직접 경험하면 정말 뿌듯할 거야. 화이팅! 🚀✨

🎯 Entity Framework 마스터가 되는 여정, 함께 해요!

성능 최적화는 끝이 아니라 시작입니다. 계속 배우고 개선해나가세요! 💪

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.