콘텐츠 대표 이미지 - Entity Framework Core와 데이터베이스 ORM을 통한 효율적 데이터 액세스

Entity Framework Core와 데이터베이스 ORM을 통한 효율적 데이터 액세스

💻 C# 개발자라면 꼭 알아야 할 EF Core 완벽 가이드 🚀

🎯 ORM이 뭔데? 왜 이렇게 난리야?

요즘 개발하다 보면 데이터베이스 다루는 게 진짜 골치 아프잖아요 ㅋㅋㅋ SQL 쿼리 작성하고, 연결 관리하고, 데이터 매핑하고... 이런 거 반복하다 보면 "아 이거 자동화 안 되나?" 싶은 생각 들죠?

그래서 나온 게 바로 ORM(Object-Relational Mapping)이에요! 쉽게 말하면 객체 지향 프로그래밍 언어의 객체와 관계형 데이터베이스의 테이블을 자동으로 연결해주는 마법 같은 기술이죠 ✨

C# 개발자들에게는 Entity Framework Core(줄여서 EF Core)가 바로 그 마법의 지팡이예요. Microsoft가 직접 만들고 관리하는 오픈소스 ORM 프레임워크인데, 진짜 개발 생산성이 미친 듯이 올라가요 🔥

💡 ORM의 핵심 개념 • 데이터베이스 테이블 → C# 클래스로 표현
• SQL 쿼리 → LINQ(Language Integrated Query)로 작성
• 데이터베이스 연결 관리 → 자동화
• 데이터 변환 → 자동 매핑
ORM의 작동 원리 C# 객체 class User { Id, Name, Email } 데이터베이스 Users 테이블 Id | Name | Email EF Core ORM 자동 매핑 및 변환 LINQ → SQL 쿼리 생산성 향상 코드 간결화 유지보수 용이 타입 안정성 🎯 EF Core의 주요 장점들

🚀 Entity Framework Core 시작하기

자, 이제 본격적으로 EF Core를 시작해볼까요? 설치부터 기본 설정까지 차근차근 알려드릴게요 😊

먼저 EF Core는 NuGet 패키지로 제공되는데요, 사용하려는 데이터베이스에 따라 설치해야 할 패키지가 달라져요. SQL Server 쓸 거면 SQL Server용, PostgreSQL 쓸 거면 PostgreSQL용... 이런 식이죠!

📦 필수 패키지 설치

Visual Studio의 패키지 관리자 콘솔에서 다음 명령어를 실행하면 돼요:

// SQL Server 사용 시
Install-Package Microsoft.EntityFrameworkCore.SqlServer

// PostgreSQL 사용 시
Install-Package Npgsql.EntityFrameworkCore.PostgreSQL

// MySQL 사용 시
Install-Package Pomelo.EntityFrameworkCore.MySql

// 디자인 타임 도구 (마이그레이션 등에 필요)
Install-Package Microsoft.EntityFrameworkCore.Tools

// EF Core 명령줄 도구
dotnet tool install --global dotnet-ef
💡 꿀팁! .NET CLI를 선호한다면 dotnet add package 명령어를 사용할 수도 있어요. 예를 들어:
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

🎨 첫 번째 모델 클래스 만들기

이제 데이터베이스 테이블과 매핑될 C# 클래스를 만들어볼게요. 간단한 블로그 시스템을 예로 들어볼까요? ㅋㅋㅋ

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    
    // 네비게이션 속성 (관계 설정)
    public int AuthorId { get; set; }
    public User Author { get; set; }
    
    public ICollection<Comment> Comments { get; set; }
}

public class User
{
    public int Id { get; set; }
    public string Username { get; set; }
    public string Email { get; set; }
    public DateTime RegisteredAt { get; set; }
    
    // 한 명의 사용자가 여러 게시글을 작성할 수 있음
    public ICollection<Post> Posts { get; set; }
}

public class Comment
{
    public int Id { get; set; }
    public string Content { get; set; }
    public DateTime CreatedAt { get; set; }
    
    public int PostId { get; set; }
    public Post Post { get; set; }
    
    public int UserId { get; set; }
    public User User { get; set; }
}

보시다시피 그냥 평범한 C# 클래스예요! 특별한 어트리뷰트나 상속 없이도 EF Core가 알아서 테이블로 매핑해줘요 👍

여기서 중요한 건 네비게이션 속성이에요. Author, Posts, Comments 같은 속성들이 바로 테이블 간의 관계를 나타내는 거죠!

🔧 DbContext 설정하기

자, 이제 가장 중요한 부분이에요! DbContext는 EF Core의 핵심이라고 할 수 있어요. 데이터베이스와의 모든 상호작용이 이 클래스를 통해 이루어지거든요 🎯

public class BlogDbContext : DbContext
{
    public BlogDbContext(DbContextOptions<BlogDbContext> options)
        : base(options)
    {
    }
    
    // DbSet은 데이터베이스의 테이블을 나타냄
    public DbSet<Post> Posts { get; set; }
    public DbSet<User> Users { get; set; }
    public DbSet<Comment> Comments { get; set; }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        // 세밀한 설정이 필요할 때 여기서 처리
        modelBuilder.Entity<Post>(entity =>
        {
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.Title)
                .IsRequired()
                .HasMaxLength(200);
            
            entity.Property(e => e.Content)
                .IsRequired();
            
            // 관계 설정
            entity.HasOne(e => e.Author)
                .WithMany(u => u.Posts)
                .HasForeignKey(e => e.AuthorId)
                .OnDelete(DeleteBehavior.Cascade);
        });
        
        modelBuilder.Entity<User>(entity =>
        {
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.Username)
                .IsRequired()
                .HasMaxLength(50);
            
            entity.Property(e => e.Email)
                .IsRequired()
                .HasMaxLength(100);
            
            // 인덱스 생성
            entity.HasIndex(e => e.Email)
                .IsUnique();
        });
        
        modelBuilder.Entity<Comment>(entity =>
        {
            entity.HasKey(e => e.Id);
            
            entity.HasOne(e => e.Post)
                .WithMany(p => p.Comments)
                .HasForeignKey(e => e.PostId)
                .OnDelete(DeleteBehavior.Cascade);
            
            entity.HasOne(e => e.User)
                .WithMany()
                .HasForeignKey(e => e.UserId)
                .OnDelete(DeleteBehavior.Restrict);
        });
    }
}

OnModelCreating 메서드에서 테이블 구조를 세밀하게 설정할 수 있어요. 필수 필드, 최대 길이, 인덱스, 관계 등등... 진짜 디테일하게 컨트롤 가능하죠 ㅋㅋㅋ

⚙️ 연결 문자열 설정

ASP.NET Core 프로젝트라면 appsettings.json에 연결 문자열을 추가하고, Program.cs 또는 Startup.cs에서 서비스를 등록해야 해요:

// appsettings.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=BlogDb;User Id=sa;Password=YourPassword123;TrustServerCertificate=True"
  }
}

// Program.cs (.NET 6+)
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<BlogDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();
⚠️ 주의사항! 연결 문자열에 비밀번호 같은 민감한 정보를 직접 넣는 건 위험해요! 실제 프로덕션 환경에서는 Azure Key Vault나 환경 변수를 사용하는 게 좋아요.

📊 마이그레이션으로 데이터베이스 생성하기

모델 클래스와 DbContext를 만들었으면 이제 실제 데이터베이스에 테이블을 생성해야겠죠? EF Core의 마이그레이션(Migration) 기능을 사용하면 코드 기반으로 데이터베이스 스키마를 관리할 수 있어요! 진짜 편해요 🎉

🔨 마이그레이션 생성

패키지 관리자 콘솔에서 다음 명령어를 실행하세요:

// 첫 번째 마이그레이션 생성
Add-Migration InitialCreate

// 또는 .NET CLI 사용
dotnet ef migrations add InitialCreate

그러면 프로젝트에 Migrations 폴더가 생기고, 그 안에 마이그레이션 파일들이 생성돼요. 이 파일들을 열어보면 데이터베이스에 어떤 변경사항이 적용될지 정확히 볼 수 있어요 👀

🚀 데이터베이스 업데이트

// 마이그레이션을 데이터베이스에 적용
Update-Database

// 또는 .NET CLI 사용
dotnet ef database update

이 명령어를 실행하면 실제 데이터베이스에 테이블들이 생성돼요! 마법 같죠? ㅋㅋㅋ

나중에 모델을 수정하면 다시 마이그레이션을 생성하고 업데이트하면 돼요. EF Core가 알아서 변경사항을 추적해서 필요한 SQL 명령어를 생성해줘요 💪

🎯 마이그레이션 주요 명령어 • Add-Migration [이름] - 새 마이그레이션 생성
• Update-Database - 마이그레이션 적용
• Remove-Migration - 마지막 마이그레이션 제거
• Script-Migration - SQL 스크립트 생성
• Get-Migration - 마이그레이션 목록 조회
마이그레이션 워크플로우 1. 모델 클래스 작성 및 수정 2. 마이그레이션 생성 3. 검토 및 수정 4. DB 업데이트 적용 마이그레이션 파일 구조 20240101120000_InitialCreate.cs • Up() 메서드: 변경사항 적용 • Down() 메서드: 변경사항 롤백 • BuildTargetModel(): 모델 스냅샷 ✓

💾 CRUD 작업 마스터하기

자, 이제 본격적으로 데이터를 다뤄볼까요? CRUD는 Create(생성), Read(읽기), Update(수정), Delete(삭제)의 약자인데요, 모든 데이터베이스 작업의 기본이죠! EF Core로 이걸 어떻게 하는지 하나씩 알아볼게요 😎

➕ Create - 데이터 생성

새로운 데이터를 추가하는 건 진짜 간단해요. 그냥 객체 만들고 Add 메서드 호출하고 SaveChanges 하면 끝!

public async Task<Post> CreatePostAsync(string title, string content, int authorId)
{
    using var context = new BlogDbContext();
    
    var post = new Post
    {
        Title = title,
        Content = content,
        AuthorId = authorId,
        CreatedAt = DateTime.UtcNow
    };
    
    // 엔티티를 컨텍스트에 추가
    context.Posts.Add(post);
    
    // 변경사항을 데이터베이스에 저장
    await context.SaveChangesAsync();
    
    // Id가 자동으로 할당됨!
    return post;
}

// 여러 개를 한 번에 추가할 수도 있어요
public async Task CreateMultiplePostsAsync(List<Post> posts)
{
    using var context = new BlogDbContext();
    
    context.Posts.AddRange(posts);
    await context.SaveChangesAsync();
}

SaveChangesAsync를 호출하는 순간 EF Core가 INSERT SQL 문을 생성해서 실행해요. 그리고 자동 증가 ID 같은 건 데이터베이스에서 생성된 값을 다시 객체에 채워줘요 👍

📖 Read - 데이터 조회

데이터 조회는 LINQ를 사용하는데요, 진짜 강력해요! SQL 몰라도 C# 문법으로 쿼리를 작성할 수 있거든요 ㅋㅋㅋ

// 모든 게시글 조회
public async Task<List<Post>> GetAllPostsAsync()
{
    using var context = new BlogDbContext();
    
    return await context.Posts
        .Include(p => p.Author)  // 작성자 정보도 함께 로드
        .Include(p => p.Comments)  // 댓글도 함께 로드
        .ToListAsync();
}

// 특정 게시글 조회
public async Task<Post> GetPostByIdAsync(int id)
{
    using var context = new BlogDbContext();
    
    return await context.Posts
        .Include(p => p.Author)
        .Include(p => p.Comments)
            .ThenInclude(c => c.User)  // 댓글 작성자도 로드
        .FirstOrDefaultAsync(p => p.Id == id);
}

// 조건부 조회
public async Task<List<Post>> GetPostsByAuthorAsync(int authorId)
{
    using var context = new BlogDbContext();
    
    return await context.Posts
        .Where(p => p.AuthorId == authorId)
        .OrderByDescending(p => p.CreatedAt)
        .Take(10)  // 최근 10개만
        .ToListAsync();
}

// 복잡한 쿼리
public async Task<List<Post>> SearchPostsAsync(string keyword)
{
    using var context = new BlogDbContext();
    
    return await context.Posts
        .Where(p => p.Title.Contains(keyword) || 
                    p.Content.Contains(keyword))
        .Include(p => p.Author)
        .Where(p => p.Author.Email.EndsWith("@example.com"))
        .OrderBy(p => p.Title)
        .Skip(0)
        .Take(20)
        .ToListAsync();
}

Include는 관련 데이터를 함께 로드하는 건데요, 이걸 "즉시 로딩(Eager Loading)"이라고 해요. 안 쓰면 나중에 접근할 때 추가 쿼리가 발생할 수 있어요 (N+1 문제라고 하죠).

💡 성능 팁! 필요한 컬럼만 선택하고 싶다면 Select를 사용하세요:

var titles = await context.Posts.Select(p => p.Title).ToListAsync();

이렇게 하면 Title 컬럼만 조회해서 성능이 훨씬 좋아져요!

✏️ Update - 데이터 수정

데이터 수정도 직관적이에요. 객체를 조회하고, 속성을 변경하고, SaveChanges 하면 끝!

public async Task<bool> UpdatePostAsync(int id, string newTitle, string newContent)
{
    using var context = new BlogDbContext();
    
    // 먼저 게시글을 조회
    var post = await context.Posts.FindAsync(id);
    
    if (post == null)
        return false;
    
    // 속성 변경
    post.Title = newTitle;
    post.Content = newContent;
    post.UpdatedAt = DateTime.UtcNow;
    
    // 변경사항 저장
    await context.SaveChangesAsync();
    
    return true;
}

// 조회 없이 바로 수정하기 (더 효율적)
public async Task<bool> UpdatePostDirectAsync(int id, string newTitle)
{
    using var context = new BlogDbContext();
    
    var post = new Post { Id = id };
    context.Posts.Attach(post);
    
    post.Title = newTitle;
    post.UpdatedAt = DateTime.UtcNow;
    
    context.Entry(post).Property(p => p.Title).IsModified = true;
    context.Entry(post).Property(p => p.UpdatedAt).IsModified = true;
    
    await context.SaveChangesAsync();
    
    return true;
}

// 대량 업데이트 (EF Core 7+)
public async Task UpdateAllPostTitlesAsync(string prefix)
{
    using var context = new BlogDbContext();
    
    await context.Posts
        .Where(p => p.CreatedAt < DateTime.UtcNow.AddDays(-30))
        .ExecuteUpdateAsync(setters => setters
            .SetProperty(p => p.Title, p => prefix + p.Title));
}

🗑️ Delete - 데이터 삭제

public async Task<bool> DeletePostAsync(int id)
{
    using var context = new BlogDbContext();
    
    var post = await context.Posts.FindAsync(id);
    
    if (post == null)
        return false;
    
    context.Posts.Remove(post);
    await context.SaveChangesAsync();
    
    return true;
}

// 조회 없이 바로 삭제
public async Task<bool> DeletePostDirectAsync(int id)
{
    using var context = new BlogDbContext();
    
    var post = new Post { Id = id };
    context.Posts.Remove(post);
    
    try
    {
        await context.SaveChangesAsync();
        return true;
    }
    catch
    {
        return false;
    }
}

// 대량 삭제 (EF Core 7+)
public async Task DeleteOldPostsAsync()
{
    using var context = new BlogDbContext();
    
    await context.Posts
        .Where(p => p.CreatedAt < DateTime.UtcNow.AddYears(-1))
        .ExecuteDeleteAsync();
}

EF Core 7부터는 ExecuteUpdateAsync와 ExecuteDeleteAsync가 추가되어서 대량 작업이 훨씬 효율적으로 가능해졌어요! 이전에는 모든 데이터를 메모리에 로드해야 했는데, 이제는 데이터베이스에서 직접 처리할 수 있어요 🚀

🔗 관계(Relationship) 설정 완벽 가이드

데이터베이스에서 테이블 간의 관계는 정말 중요하죠! EF Core는 일대일, 일대다, 다대다 관계를 모두 지원해요. 하나씩 자세히 알아볼게요 💪

1️⃣ 일대다 관계 (One-to-Many)

가장 흔한 관계죠. 한 명의 사용자가 여러 게시글을 작성할 수 있는 경우예요.

public class User
{
    public int Id { get; set; }
    public string Username { get; set; }
    
    // 네비게이션 속성: 한 명의 사용자는 여러 게시글을 가질 수 있음
    public ICollection<Post> Posts { get; set; }
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    
    // 외래 키
    public int AuthorId { get; set; }
    
    // 네비게이션 속성: 각 게시글은 한 명의 작성자를 가짐
    public User Author { get; set; }
}

// DbContext에서 명시적으로 설정
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasOne(p => p.Author)
        .WithMany(u => u.Posts)
        .HasForeignKey(p => p.AuthorId)
        .OnDelete(DeleteBehavior.Cascade);  // 사용자 삭제 시 게시글도 삭제
}

2️⃣ 일대일 관계 (One-to-One)

사용자와 프로필처럼 1:1로 매칭되는 경우예요.

public class User
{
    public int Id { get; set; }
    public string Username { get; set; }
    
    // 일대일 관계
    public UserProfile Profile { get; set; }
}

public class UserProfile
{
    public int Id { get; set; }
    public string Bio { get; set; }
    public string AvatarUrl { get; set; }
    
    // 외래 키 (Primary Key이기도 함)
    public int UserId { get; set; }
    public User User { get; set; }
}

// DbContext 설정
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>()
        .HasOne(u => u.Profile)
        .WithOne(p => p.User)
        .HasForeignKey<UserProfile>(p => p.UserId)
        .OnDelete(DeleteBehavior.Cascade);
}

3️⃣ 다대다 관계 (Many-to-Many)

게시글과 태그처럼 서로 여러 개를 가질 수 있는 경우예요. EF Core 5부터는 중간 테이블을 자동으로 생성해줘요!

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    
    // 다대다 관계
    public ICollection<Tag> Tags { get; set; }
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // 다대다 관계
    public ICollection<Post> Posts { get; set; }
}

// DbContext 설정 (EF Core 5+에서는 자동으로 처리됨)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(t => t.Posts);
    
    // 중간 테이블을 명시적으로 설정하고 싶다면
    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(t => t.Posts)
        .UsingEntity<Dictionary<string, object>>(
            "PostTag",  // 중간 테이블 이름
            j => j.HasOne<Tag>().WithMany().HasForeignKey("TagId"),
            j => j.HasOne<Post>().WithMany().HasForeignKey("PostId")
        );
}

// 또는 중간 엔티티를 직접 만들 수도 있어요
public class PostTag
{
    public int PostId { get; set; }
    public Post Post { get; set; }
    
    public int TagId { get; set; }
    public Tag Tag { get; set; }
    
    // 추가 속성도 넣을 수 있어요
    public DateTime AssignedAt { get; set; }
}
🎯 관계 설정 시 주의사항 • 순환 참조 조심! JSON 직렬화 시 문제가 될 수 있어요
• Lazy Loading vs Eager Loading 성능을 고려해서 선택하세요
• 삭제 동작(DeleteBehavior) 신중하게 설정하세요
• 외래 키 제약조건 데이터 무결성을 위해 중요해요
데이터베이스 관계 유형 일대다 (1:N) User ─── Posts 한 사용자가 여러 게시글 작성 일대일 (1:1) User ─── Profile 한 사용자가 하나의 프로필 보유 다대다 (N:M) Posts ─── Tags 게시글과 태그는 서로 여러 개 가능 관계 설정 코드 예시 // 일대다 설정 HasOne(p => p.Author).WithMany(u => u.Posts) // 일대일 설정 HasOne(u => u.Profile).WithOne(p => p.User) // 다대다 설정 HasMany(p => p.Tags).WithMany(t => t.Posts) * OnDelete, HasForeignKey 등으로 세부 설정 가능

⚡ 성능 최적화 테크닉

EF Core 쓰다 보면 성능 문제가 생길 수 있어요. 특히 대용량 데이터를 다룰 때는 더욱 그렇죠! 여기서는 실전에서 바로 써먹을 수 있는 최적화 기법들을 알려드릴게요 🔥

🎯 AsNoTracking으로 읽기 전용 쿼리 최적화

데이터를 조회만 하고 수정할 계획이 없다면 AsNoTracking()을 사용하세요. 변경 추적 오버헤드가 없어져서 성능이 크게 향상돼요!

// 일반 조회 (변경 추적 O)
var posts = await context.Posts.ToListAsync();

// 읽기 전용 조회 (변경 추적 X) - 더 빠름!
var posts = await context.Posts
    .AsNoTracking()
    .ToListAsync();

// 전역 설정도 가능해요
public class BlogDbContext : DbContext
{
    public BlogDbContext(DbContextOptions options) : base(options)
    {
        // 모든 쿼리에 기본적으로 AsNoTracking 적용
        ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
    }
}

📊 Select로 필요한 데이터만 가져오기

전체 엔티티를 로드하지 말고 필요한 컬럼만 선택하세요. 네트워크 트래픽도 줄고 메모리도 절약돼요 ㅋㅋㅋ

// ❌ 나쁜 예: 모든 컬럼 로드
var posts = await context.Posts
    .Include(p => p.Author)
    .ToListAsync();

// ✅ 좋은 예: 필요한 데이터만 선택
var postSummaries = await context.Posts
    .Select(p => new 
    {
        p.Id,
        p.Title,
        AuthorName = p.Author.Username,
        CommentCount = p.Comments.Count
    })
    .ToListAsync();

// DTO 클래스로 매핑
public class PostSummaryDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string AuthorName { get; set; }
    public int CommentCount { get; set; }
}

var postDtos = await context.Posts
    .Select(p => new PostSummaryDto
    {
        Id = p.Id,
        Title = p.Title,
        AuthorName = p.Author.Username,
        CommentCount = p.Comments.Count
    })
    .ToListAsync();

🔄 Split Query로 카테시안 폭발 방지

여러 컬렉션을 Include할 때 데이터가 중복되어 로드되는 문제(카테시안 폭발)가 있어요. AsSplitQuery()를 사용하면 여러 개의 쿼리로 분리해서 이 문제를 해결할 수 있어요!

// 단일 쿼리 (카테시안 폭발 가능)
var posts = await context.Posts
    .Include(p => p.Comments)
    .Include(p => p.Tags)
    .ToListAsync();

// 분할 쿼리 (더 효율적)
var posts = await context.Posts
    .Include(p => p.Comments)
    .Include(p => p.Tags)
    .AsSplitQuery()
    .ToListAsync();

💾 배치 작업 최적화

대량의 데이터를 처리할 때는 배치로 나눠서 처리하는 게 좋아요. 메모리 부족 문제도 방지하고 성능도 좋아져요!

// ❌ 나쁜 예: 모든 데이터를 한 번에 로드
var allPosts = await context.Posts.ToListAsync();
foreach (var post in allPosts)
{
    // 처리...
}

// ✅ 좋은 예: 배치 단위로 처리
int batchSize = 1000;
int skip = 0;

while (true)
{
    var batch = await context.Posts
        .OrderBy(p => p.Id)
        .Skip(skip)
        .Take(batchSize)
        .AsNoTracking()
        .ToListAsync();
    
    if (!batch.Any())
        break;
    
    foreach (var post in batch)
    {
        // 처리...
    }
    
    skip += batchSize;
}

// 또는 EF Core 7+의 ExecuteUpdate 사용
await context.Posts
    .Where(p => p.CreatedAt < DateTime.UtcNow.AddYears(-1))
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.IsArchived, true));

🗄️ 컴파일된 쿼리 사용

자주 실행되는 쿼리는 미리 컴파일해두면 성능이 향상돼요!

public class BlogQueries
{
    // 컴파일된 쿼리 정의
    private static readonly Func<BlogDbContext, int, Task<Post>> _getPostById =
        EF.CompileAsyncQuery((BlogDbContext context, int id) =>
            context.Posts
                .Include(p => p.Author)
                .FirstOrDefault(p => p.Id == id));
    
    public static Task<Post> GetPostByIdAsync(BlogDbContext context, int id)
    {
        return _getPostById(context, id);
    }
}

// 사용
var post = await BlogQueries.GetPostByIdAsync(context, 123);
⚠️ N+1 쿼리 문제 주의! 가장 흔한 성능 문제 중 하나예요. 반복문 안에서 관련 데이터를 로드하면 쿼리가 폭발적으로 증가해요:

// ❌ N+1 문제 발생
var posts = await context.Posts.ToListAsync();
foreach (var post in posts) {
    var author = post.Author; // 각 게시글마다 쿼리 실행!
}

// ✅ Include로 해결
var posts = await context.Posts.Include(p => p.Author).ToListAsync();

📈 인덱스 활용

자주 검색하는 컬럼에는 인덱스를 추가하세요. 쿼리 속도가 엄청 빨라져요!

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 단일 컬럼 인덱스
    modelBuilder.Entity<Post>()
        .HasIndex(p => p.Title);
    
    // 복합 인덱스
    modelBuilder.Entity<Post>()
        .HasIndex(p => new { p.AuthorId, p.CreatedAt });
    
    // 유니크 인덱스
    modelBuilder.Entity<User>()
        .HasIndex(u => u.Email)
        .IsUnique();
    
    // 필터링된 인덱스 (SQL Server)
    modelBuilder.Entity<Post>()
        .HasIndex(p => p.Title)
        .HasFilter("[IsPublished] = 1");
}

🛡️ 트랜잭션과 동시성 제어

실제 서비스에서는 여러 사용자가 동시에 데이터를 수정할 수 있어요. 이때 데이터 일관성을 유지하는 게 정말 중요하죠! EF Core의 트랜잭션과 동시성 제어 기능을 알아볼게요 🔒

💼 트랜잭션 사용하기

여러 작업을 하나의 단위로 묶어서 모두 성공하거나 모두 실패하도록 만들 수 있어요.

// 기본 트랜잭션
public async Task TransferPointsAsync(int fromUserId, int toUserId, int points)
{
    using var context = new BlogDbContext();
    using var transaction = await context.Database.BeginTransactionAsync();
    
    try
    {
        // 포인트 차감
        var fromUser = await context.Users.FindAsync(fromUserId);
        fromUser.Points -= points;
        
        // 포인트 추가
        var toUser = await context.Users.FindAsync(toUserId);
        toUser.Points += points;
        
        await context.SaveChangesAsync();
        
        // 트랜잭션 커밋
        await transaction.CommitAsync();
    }
    catch
    {
        // 오류 발생 시 롤백
        await transaction.RollbackAsync();
        throw;
    }
}

// 암시적 트랜잭션 (SaveChanges가 자동으로 트랜잭션 처리)
public async Task CreatePostWithTagsAsync(Post post, List<Tag> tags)
{
    using var context = new BlogDbContext();
    
    context.Posts.Add(post);
    post.Tags = tags;
    
    // 이 한 줄이 하나의 트랜잭션으로 실행됨
    await context.SaveChangesAsync();
}

🔐 낙관적 동시성 제어

여러 사용자가 동시에 같은 데이터를 수정하려고 할 때 충돌을 감지하고 처리할 수 있어요.

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    
    // RowVersion으로 동시성 제어
    [Timestamp]
    public byte[] RowVersion { get; set; }
}

// 또는 Fluent API로 설정
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .Property(p => p.RowVersion)
        .IsRowVersion();
}

// 동시성 충돌 처리
public async Task<bool> UpdatePostWithConcurrencyCheckAsync(int id, string newTitle)
{
    using var context = new BlogDbContext();
    
    var post = await context.Posts.FindAsync(id);
    post.Title = newTitle;
    
    try
    {
        await context.SaveChangesAsync();
        return true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        // 동시성 충돌 발생!
        var entry = ex.Entries.Single();
        var databaseValues = await entry.GetDatabaseValuesAsync();
        
        if (databaseValues == null)
        {
            // 다른 사용자가 삭제함
            return false;
        }
        
        // 데이터베이스의 현재 값 가져오기
        var databasePost = (Post)databaseValues.ToObject();
        
        // 충돌 해결 전략 선택
        // 1. 클라이언트 값 우선
        entry.OriginalValues.SetValues(databaseValues);
        await context.SaveChangesAsync();
        
        // 2. 데이터베이스 값 우선
        // entry.CurrentValues.SetValues(databaseValues);
        
        // 3. 사용자에게 선택하게 하기
        // return false; // 충돌 알림
        
        return true;
    }
}

🔒 비관적 동시성 제어 (잠금)

데이터를 읽을 때부터 잠금을 걸어서 다른 사용자가 수정하지 못하게 할 수도 있어요.

// SQL Server의 경우
public async Task<Post> GetPostWithLockAsync(int id)
{
    using var context = new BlogDbContext();
    
    // UPDLOCK, ROWLOCK 힌트 사용
    var post = await context.Posts
        .FromSqlRaw("SELECT * FROM Posts WITH (UPDLOCK, ROWLOCK) WHERE Id = {0}", id)
        .FirstOrDefaultAsync();
    
    return post;
}

// 트랜잭션 격리 수준 설정
public async Task UpdateWithIsolationLevelAsync(int id, string newTitle)
{
    using var context = new BlogDbContext();
    using var transaction = await context.Database.BeginTransactionAsync(
        System.Data.IsolationLevel.Serializable);
    
    try
    {
        var post = await context.Posts.FindAsync(id);
        post.Title = newTitle;
        
        await context.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}
🎯 동시성 제어 전략 선택 가이드 낙관적 동시성 제어 (추천)
• 충돌이 드물게 발생하는 경우
• 읽기가 많고 쓰기가 적은 경우
• 성능이 중요한 경우

비관적 동시성 제어
• 충돌이 자주 발생하는 경우
• 데이터 일관성이 매우 중요한 경우
• 금융 거래 같은 크리티컬한 작업

🎨 고급 쿼리 테크닉

이제 좀 더 고급 기능들을 알아볼까요? 실무에서 정말 유용한 테크닉들이에요! 💪

🔍 원시 SQL 쿼리 실행

LINQ로 표현하기 어려운 복잡한 쿼리는 직접 SQL을 작성할 수 있어요.

// 엔티티 반환
var posts = await context.Posts
    .FromSqlRaw("SELECT * FROM Posts WHERE CreatedAt > {0}", DateTime.UtcNow.AddDays(-7))
    .Include(p => p.Author)
    .ToListAsync();

// 보간된 문자열 사용 (SQL 인젝션 방지)
var keyword = "EF Core";
var posts = await context.Posts
    .FromSqlInterpolated($"SELECT * FROM Posts WHERE Title LIKE '%{keyword}%'")
    .ToListAsync();

// 스토어드 프로시저 실행
var posts = await context.Posts
    .FromSqlRaw("EXEC GetRecentPosts @Days = {0}", 7)
    .ToListAsync();

// 원시 SQL로 데이터 수정
await context.Database.ExecuteSqlRawAsync(
    "UPDATE Posts SET ViewCount = ViewCount + 1 WHERE Id = {0}", postId);

📊 그룹화와 집계

통계나 리포트를 만들 때 필수적인 기능이죠!

// 작성자별 게시글 수
var postCountByAuthor = await context.Posts
    .GroupBy(p => p.AuthorId)
    .Select(g => new
    {
        AuthorId = g.Key,
        PostCount = g.Count(),
        TotalViews = g.Sum(p => p.ViewCount),
        AverageViews = g.Average(p => p.ViewCount),
        LatestPost = g.Max(p => p.CreatedAt)
    })
    .ToListAsync();

// 날짜별 게시글 수
var postsByDate = await context.Posts
    .GroupBy(p => p.CreatedAt.Date)
    .Select(g => new
    {
        Date = g.Key,
        Count = g.Count()
    })
    .OrderByDescending(x => x.Date)
    .ToListAsync();

// Having 절 사용
var activeAuthors = await context.Posts
    .GroupBy(p => p.AuthorId)
    .Where(g => g.Count() >= 10)  // HAVING 절
    .Select(g => new
    {
        AuthorId = g.Key,
        PostCount = g.Count()
    })
    .ToListAsync();

🔗 복잡한 조인

// 명시적 조인
var result = await context.Posts
    .Join(
        context.Users,
        post => post.AuthorId,
        user => user.Id,
        (post, user) => new
        {
            PostTitle = post.Title,
            AuthorName = user.Username,
            AuthorEmail = user.Email
        })
    .ToListAsync();

// Left Join
var result = await context.Posts
    .GroupJoin(
        context.Comments,
        post => post.Id,
        comment => comment.PostId,
        (post, comments) => new
        {
            Post = post,
            Comments = comments
        })
    .SelectMany(
        x => x.Comments.DefaultIfEmpty(),
        (x, comment) => new
        {
            PostTitle = x.Post.Title,
            CommentContent = comment != null ? comment.Content : null
        })
    .ToListAsync();

🎯 동적 쿼리 빌드

검색 조건이 동적으로 변하는 경우에 유용해요!

public async Task<List<Post>> SearchPostsAsync(
    string keyword = null,
    int? authorId = null,
    DateTime? fromDate = null,
    DateTime? toDate = null)
{
    using var context = new BlogDbContext();
    
    var query = context.Posts.AsQueryable();
    
    // 조건부로 Where 절 추가
    if (!string.IsNullOrEmpty(keyword))
    {
        query = query.Where(p => 
            p.Title.Contains(keyword) || 
            p.Content.Contains(keyword));
    }
    
    if (authorId.HasValue)
    {
        query = query.Where(p => p.AuthorId == authorId.Value);
    }
    
    if (fromDate.HasValue)
    {
        query = query.Where(p => p.CreatedAt >= fromDate.Value);
    }
    
    if (toDate.HasValue)
    {
        query = query.Where(p => p.CreatedAt <= toDate.Value);
    }
    
    return await query
        .Include(p => p.Author)
        .OrderByDescending(p => p.CreatedAt)
        .ToListAsync();
}

// Expression을 사용한 더 고급 방법
public static class PredicateBuilder
{
    public static Expression<Func<T, bool>> True<T>() => f => true;
    public static Expression<Func<T, bool>> False<T>() => f => false;
    
    public static Expression<Func<T, bool>> Or<T>(
        this Expression<Func<T, bool>> expr1,
        Expression<Func<T, bool>> expr2)
    {
        var parameter = Expression.Parameter(typeof(T));
        var combined = Expression.OrElse(
            Expression.Invoke(expr1, parameter),
            Expression.Invoke(expr2, parameter));
        return Expression.Lambda<Func<T, bool>>(combined, parameter);
    }
    
    public static Expression<Func<T, bool>> And<T>(
        this Expression<Func<T, bool>> expr1,
        Expression<Func<T, bool>> expr2)
    {
        var parameter = Expression.Parameter(typeof(T));
        var combined = Expression.AndAlso(
            Expression.Invoke(expr1, parameter),
            Expression.Invoke(expr2, parameter));
        return Expression.Lambda<Func<T, bool>>(combined, parameter);
    }
}

// 사용 예
var predicate = PredicateBuilder.True<Post>();

if (!string.IsNullOrEmpty(keyword))
    predicate = predicate.And(p => p.Title.Contains(keyword));

if (authorId.HasValue)
    predicate = predicate.And(p => p.AuthorId == authorId.Value);

var posts = await context.Posts.Where(predicate).ToListAsync();

🏗️ 실전 아키텍처 패턴

EF Core를 실제 프로젝트에서 사용할 때는 적절한 아키텍처 패턴을 적용하는 게 중요해요. 유지보수성과 테스트 용이성이 크게 달라지거든요! 😊

📦 Repository 패턴

데이터 액세스 로직을 캡슐화해서 비즈니스 로직과 분리하는 패턴이에요.

// 제네릭 Repository 인터페이스
public interface IRepository<T> where T : class
{
    Task<T> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();
    Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
    Task AddAsync(T entity);
    Task AddRangeAsync(IEnumerable<T> entities);
    void Update(T entity);
    void Remove(T entity);
    void RemoveRange(IEnumerable<T> entities);
}

// 구현
public class Repository<T> : IRepository<T> where T : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<T> _dbSet;
    
    public Repository(DbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }
    
    public async Task<T> GetByIdAsync(int id)
    {
        return await _dbSet.FindAsync(id);
    }
    
    public async Task<IEnumerable<T>> GetAllAsync()
    {
        return await _dbSet.ToListAsync();
    }
    
    public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
    {
        return await _dbSet.Where(predicate).ToListAsync();
    }
    
    public async Task AddAsync(T entity)
    {
        await _dbSet.AddAsync(entity);
    }
    
    public async Task AddRangeAsync(IEnumerable<T> entities)
    {
        await _dbSet.AddRangeAsync(entities);
    }
    
    public void Update(T entity)
    {
        _dbSet.Update(entity);
    }
    
    public void Remove(T entity)
    {
        _dbSet.Remove(entity);
    }
    
    public void RemoveRange(IEnumerable<T> entities)
    {
        _dbSet.RemoveRange(entities);
    }
}

// 특정 엔티티용 Repository
public interface IPostRepository : IRepository<Post>
{
    Task<IEnumerable<Post>> GetRecentPostsAsync(int count);
    Task<IEnumerable<Post>> GetPostsByAuthorAsync(int authorId);
    Task<Post> GetPostWithCommentsAsync(int id);
}

public class PostRepository : Repository<Post>, IPostRepository
{
    public PostRepository(BlogDbContext context) : base(context)
    {
    }
    
    public async Task<IEnumerable<Post>> GetRecentPostsAsync(int count)
    {
        return await _dbSet
            .Include(p => p.Author)
            .OrderByDescending(p => p.CreatedAt)
            .Take(count)
            .ToListAsync();
    }
    
    public async Task<IEnumerable<Post>> GetPostsByAuthorAsync(int authorId)
    {
        return await _dbSet
            .Where(p => p.AuthorId == authorId)
            .Include(p => p.Comments)
            .ToListAsync();
    }
    
    public async Task<Post> GetPostWithCommentsAsync(int id)
    {
        return await _dbSet
            .Include(p => p.Author)
            .Include(p => p.Comments)
                .ThenInclude(c => c.User)
            .FirstOrDefaultAsync(p => p.Id == id);
    }
}

🎯 Unit of Work 패턴

여러 Repository의 작업을 하나의 트랜잭션으로 묶어서 관리해요.

public interface IUnitOfWork : IDisposable
{
    IPostRepository Posts { get; }
    IUserRepository Users { get; }
    ICommentRepository Comments { get; }
    
    Task<int> SaveChangesAsync();
    Task BeginTransactionAsync();
    Task CommitAsync();
    Task RollbackAsync();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly BlogDbContext _context;
    private IDbContextTransaction _transaction;
    
    public IPostRepository Posts { get; }
    public IUserRepository Users { get; }
    public ICommentRepository Comments { get; }
    
    public UnitOfWork(BlogDbContext context)
    {
        _context = context;
        Posts = new PostRepository(_context);
        Users = new UserRepository(_context);
        Comments = new CommentRepository(_context);
    }
    
    public async Task<int> SaveChangesAsync()
    {
        return await _context.SaveChangesAsync();
    }
    
    public async Task BeginTransactionAsync()
    {
        _transaction = await _context.Database.BeginTransactionAsync();
    }
    
    public async Task CommitAsync()
    {
        try
        {
            await SaveChangesAsync();
            await _transaction?.CommitAsync();
        }
        catch
        {
            await RollbackAsync();
            throw;
        }
        finally
        {
            _transaction?.Dispose();
            _transaction = null;
        }
    }
    
    public async Task RollbackAsync()
    {
        await _transaction?.RollbackAsync();
        _transaction?.Dispose();
        _transaction = null;
    }
    
    public void Dispose()
    {
        _transaction?.Dispose();
        _context?.Dispose();
    }
}

// 사용 예
public class PostService
{
    private readonly IUnitOfWork _unitOfWork;
    
    public PostService(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }
    
    public async Task<Post> CreatePostWithNotificationAsync(Post post)
    {
        await _unitOfWork.BeginTransactionAsync();
        
        try
        {
            // 게시글 생성
            await _unitOfWork.Posts.AddAsync(post);
            await _unitOfWork.SaveChangesAsync();
            
            // 알림 생성 (다른 Repository 사용)
            var notification = new Notification
            {
                UserId = post.AuthorId,
                Message = "새 게시글이 작성되었습니다."
            };
            await _unitOfWork.Notifications.AddAsync(notification);
            
            // 모든 작업 커밋
            await _unitOfWork.CommitAsync();
            
            return post;
        }
        catch
        {
            await _unitOfWork.RollbackAsync();
            throw;
        }
    }
}

🏛️ CQRS 패턴

명령(Command)과 조회(Query)를 분리하는 패턴이에요. 복잡한 시스템에서 유용하죠!

// Command (쓰기 작업)
public class CreatePostCommand
{
    public string Title { get; set; }
    public string Content { get; set; }
    public int AuthorId { get; set; }
}

public class CreatePostCommandHandler
{
    private readonly BlogDbContext _context;
    
    public CreatePostCommandHandler(BlogDbContext context)
    {
        _context = context;
    }
    
    public async Task<int> HandleAsync(CreatePostCommand command)
    {
        var post = new Post
        {
            Title = command.Title,
            Content = command.Content,
            AuthorId = command.AuthorId,
            CreatedAt = DateTime.UtcNow
        };
        
        _context.Posts.Add(post);
        await _context.SaveChangesAsync();
        
        return post.Id;
    }
}

// Query (읽기 작업)
public class GetPostByIdQuery
{
    public int Id { get; set; }
}

public class PostDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public string AuthorName { get; set; }
    public int CommentCount { get; set; }
}

public class GetPostByIdQueryHandler
{
    private readonly BlogDbContext _context;
    
    public GetPostByIdQueryHandler(BlogDbContext context)
    {
        _context = context;
    }
    
    public async Task<PostDto> HandleAsync(GetPostByIdQuery query)
    {
        return await _context.Posts
            .Where(p => p.Id == query.Id)
            .Select(p => new PostDto
            {
                Id = p.Id,
                Title = p.Title,
                Content = p.Content,
                AuthorName = p.Author.Username,
                CommentCount = p.Comments.Count
            })
            .AsNoTracking()
            .FirstOrDefaultAsync();
    }
}
💡 패턴 선택 가이드 Repository 패턴: 중소규모 프로젝트, 테스트 용이성 중요
Unit of Work: 복잡한 트랜잭션 관리 필요
CQRS: 대규모 프로젝트, 읽기/쓰기 성능 최적화 필요

작은 프로젝트에서는 DbContext를 직접 사용하는 것도 괜찮아요! 과도한 추상화는 오히려 복잡도만 높일 수 있어요 ㅋㅋㅋ

🧪 테스트 전략

EF Core를 사용하는 코드를 테스트하는 건 정말 중요해요! 여러 가지 방법이 있는데, 각각의 장단점을 알아볼게요 🎯

💾 InMemory 데이터베이스 사용

가장 간단한 방법이에요. 실제 데이터베이스 없이 메모리에서 테스트할 수 있어요!

// 테스트용 DbContext 생성
public static BlogDbContext CreateInMemoryContext()
{
    var options = new DbContextOptionsBuilder<BlogDbContext>()
        .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
        .Options;
    
    return new BlogDbContext(options);
}

// xUnit 테스트 예제
public class PostRepositoryTests
{
    [Fact]
    public async Task CreatePost_ShouldAddPostToDatabase()
    {
        // Arrange
        using var context = CreateInMemoryContext();
        var repository = new PostRepository(context);
        
        var post = new Post
        {
            Title = "Test Post",
            Content = "Test Content",
            AuthorId = 1,
            CreatedAt = DateTime.UtcNow
        };
        
        // Act
        await repository.AddAsync(post);
        await context.SaveChangesAsync();
        
        // Assert
        var savedPost = await repository.GetByIdAsync(post.Id);
        Assert.NotNull(savedPost);
        Assert.Equal("Test Post", savedPost.Title);
    }
    
    [Fact]
    public async Task GetRecentPosts_ShouldReturnPostsInDescendingOrder()
    {
        // Arrange
        using var context = CreateInMemoryContext();
        var repository = new PostRepository(context);
        
        var posts = new List<Post>
        {
            new Post { Title = "Post 1", CreatedAt = DateTime.UtcNow.AddDays(-2) },
            new Post { Title = "Post 2", CreatedAt = DateTime.UtcNow.AddDays(-1) },
            new Post { Title = "Post 3", CreatedAt = DateTime.UtcNow }
        };
        
        await repository.AddRangeAsync(posts);
        await context.SaveChangesAsync();
        
        // Act
        var recentPosts = await repository.GetRecentPostsAsync(2);
        
        // Assert
        Assert.Equal(2, recentPosts.Count());
        Assert.Equal("Post 3", recentPosts.First().Title);
    }
}
⚠️ InMemory 데이터베이스의 한계 • 관계형 데이터베이스의 모든 기능을 지원하지 않아요
• 외래 키 제약조건이 실제로 검증되지 않아요
• SQL Server 특정 기능(저장 프로시저, 함수 등)을 테스트할 수 없어요
• 실제 데이터베이스와 동작이 다를 수 있어요

🐳 SQLite InMemory 사용

InMemory보다 실제 데이터베이스에 가까운 테스트가 가능해요!

public static BlogDbContext CreateSqliteContext()
{
    var connection = new SqliteConnection("DataSource=:memory:");
    connection.Open();
    
    var options = new DbContextOptionsBuilder<BlogDbContext>()
        .UseSqlite(connection)
        .Options;
    
    var context = new BlogDbContext(options);
    context.Database.EnsureCreated();
    
    return context;
}

public class PostRepositoryTests : IDisposable
{
    private readonly BlogDbContext _context;
    private readonly SqliteConnection _connection;
    
    public PostRepositoryTests()
    {
        _connection = new SqliteConnection("DataSource=:memory:");
        _connection.Open();
        
        var options = new DbContextOptionsBuilder<BlogDbContext>()
            .UseSqlite(_connection)
            .Options;
        
        _context = new BlogDbContext(options);
        _context.Database.EnsureCreated();
    }
    
    [Fact]
    public async Task CreatePost_WithForeignKey_ShouldEnforceConstraints()
    {
        // Arrange
        var repository = new PostRepository(_context);
        
        var post = new Post
        {
            Title = "Test",
            Content = "Content",
            AuthorId = 999  // 존재하지 않는 사용자
        };
        
        // Act & Assert
        await repository.AddAsync(post);
        
        // SQLite는 외래 키 제약조건을 검증함
        await Assert.ThrowsAsync<DbUpdateException>(
            async () => await _context.SaveChangesAsync());
    }
    
    public void Dispose()
    {
        _context?.Dispose();
        _connection?.Dispose();
    }
}

🎭 Mocking 사용

Moq 같은 라이브러리로 DbContext를 모킹할 수도 있어요.

public class PostServiceTests
{
    [Fact]
    public async Task GetPostById_ShouldReturnPost()
    {
        // Arrange
        var mockSet = new Mock<DbSet<Post>>();
        var mockContext = new Mock<BlogDbContext>();
        
        var posts = new List<Post>
        {
            new Post { Id = 1, Title = "Test Post" }
        }.AsQueryable();
        
        mockSet.As<IQueryable<Post>>().Setup(m => m.Provider).Returns(posts.Provider);
        mockSet.As<IQueryable<Post>>().Setup(m => m.Expression).Returns(posts.Expression);
        mockSet.As<IQueryable<Post>>().Setup(m => m.ElementType).Returns(posts.ElementType);
        mockSet.As<IQueryable<Post>>().Setup(m => m.GetEnumerator()).Returns(posts.GetEnumerator());
        
        mockContext.Setup(c => c.Posts).Returns(mockSet.Object);
        
        var service = new PostService(mockContext.Object);
        
        // Act
        var result = await service.GetPostByIdAsync(1);
        
        // Assert
        Assert.NotNull(result);
        Assert.Equal("Test Post", result.Title);
    }
}

🚨 일반적인 실수와 해결 방법

EF Core 사용하다 보면 누구나 한 번쯤 겪는 실수들이 있어요. 미리 알고 있으면 삽질을 줄일 수 있죠! ㅋㅋㅋ

❌ 실수 1: DbContext를 싱글톤으로 등록

// ❌ 절대 이렇게 하지 마세요!
services.AddSingleton<BlogDbContext>();

// ✅ 올바른 방법
services.AddDbContext<BlogDbContext>(options =>
    options.UseSqlServer(connectionString));

// 또는 Scoped로 명시적 등록
services.AddScoped<BlogDbContext>();

이유: DbContext는 스레드 안전하지 않아요! 싱글톤으로 등록하면 동시성 문제가 발생할 수 있어요.

❌ 실수 2: 비동기 메서드를 동기적으로 호출

// ❌ 데드락 위험!
var post = context.Posts.FirstOrDefaultAsync(p => p.Id == id).Result;

// ✅ 올바른 방법
var post = await context.Posts.FirstOrDefaultAsync(p => p.Id == id);

이유: ASP.NET Core에서 .Result나 .Wait()를 사용하면 데드락이 발생할 수 있어요!

❌ 실수 3: 순환 참조 무시

// ❌ JSON 직렬화 시 무한 루프!
public class Post
{
    public User Author { get; set; }
}

public class User
{
    public ICollection<Post> Posts { get; set; }
}

// ✅ 해결 방법 1: ReferenceHandler 설정
services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
    });

// ✅ 해결 방법 2: DTO 사용
public class PostDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string AuthorName { get; set; }
    // Author 객체 전체가 아닌 필요한 정보만
}

❌ 실수 4: 변경 추적 무시하고 업데이트

// ❌ 작동하지 않음
public async Task UpdatePostAsync(Post post)
{
    using var context = new BlogDbContext();
    post.Title = "New Title";
    await context.SaveChangesAsync();  // 아무 일도 일어나지 않음!
}

// ✅ 올바른 방법 1: Attach 후 수정
public async Task UpdatePostAsync(Post post)
{
    using var context = new BlogDbContext();
    context.Posts.Attach(post);
    post.Title = "New Title";
    context.Entry(post).Property(p => p.Title).IsModified = true;
    await context.SaveChangesAsync();
}

// ✅ 올바른 방법 2: Update 메서드 사용
public async Task UpdatePostAsync(Post post)
{
    using var context = new BlogDbContext();
    context.Posts.Update(post);
    await context.SaveChangesAsync();
}

// ✅ 올바른 방법 3: 조회 후 수정
public async Task UpdatePostAsync(int id, string newTitle)
{
    using var context = new BlogDbContext();
    var post = await context.Posts.FindAsync(id);
    post.Title = newTitle;
    await context.SaveChangesAsync();
}

❌ 실수 5: 지연 로딩 문제

// ❌ N+1 쿼리 문제
var posts = await context.Posts.ToListAsync();
foreach (var post in posts)
{
    Console.WriteLine(post.Author.Username);  // 각 게시글마다 쿼리 실행!
}

// ✅ Include로 즉시 로딩
var posts = await context.Posts
    .Include(p => p.Author)
    .ToListAsync();

// ✅ 또는 Select로 필요한 데이터만
var posts = await context.Posts
    .Select(p => new
    {
        p.Title,
        AuthorName = p.Author.Username
    })
    .ToListAsync();

❌ 실수 6: 대용량 데이터를 한 번에 로드

// ❌ 메모리 부족 위험!
var allPosts = await context.Posts.ToListAsync();

// ✅ 페이징 사용
var posts = await context.Posts
    .OrderBy(p => p.Id)
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync();

// ✅ 또는 스트리밍
await foreach (var post in context.Posts.AsAsyncEnumerable())
{
    // 하나씩 처리
    ProcessPost(post);
}
🎯 디버깅 팁! EF Core가 생성하는 SQL을 확인하고 싶다면:

// appsettings.json
"Logging": {
  "LogLevel": {
    "Microsoft.EntityFrameworkCore.Database.Command": "Information"
  }
}


또는 코드에서:
var query = context.Posts.Where(p => p.Id > 10);
var sql = query.ToQueryString();
Console.WriteLine(sql);

🌟 실전 프로젝트 예제

자, 이제 배운 내용을 종합해서 실제 프로젝트에 적용해볼까요? 간단한 블로그 API를 만들어보겠습니다! 💪

📁 프로젝트 구조

BlogApi/
├── Controllers/
│   ├── PostsController.cs
│   └── UsersController.cs
├── Data/
│   ├── BlogDbContext.cs
│   ├── Repositories/
│   │   ├── IRepository.cs
│   │   ├── Repository.cs
│   │   ├── IPostRepository.cs
│   │   └── PostRepository.cs
│   └── UnitOfWork/
│       ├── IUnitOfWork.cs
│       └── UnitOfWork.cs
├── Models/
│   ├── Post.cs
│   ├── User.cs
│   └── Comment.cs
├── DTOs/
│   ├── PostDto.cs
│   ├── CreatePostDto.cs
│   └── UpdatePostDto.cs
└── Services/
    ├── IPostService.cs
    └── PostService.cs

🎨 서비스 레이어 구현

public interface IPostService
{
    Task<PostDto> GetPostByIdAsync(int id);
    Task<IEnumerable<PostDto>> GetAllPostsAsync(int pageIndex, int pageSize);
    Task<PostDto> CreatePostAsync(CreatePostDto dto);
    Task<PostDto> UpdatePostAsync(int id, UpdatePostDto dto);
    Task<bool> DeletePostAsync(int id);
}

public class PostService : IPostService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<PostService> _logger;
    
    public PostService(IUnitOfWork unitOfWork, ILogger<PostService> logger)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
    }
    
    public async Task<PostDto> GetPostByIdAsync(int id)
    {
        var post = await _unitOfWork.Posts.GetPostWithCommentsAsync(id);
        
        if (post == null)
            throw new NotFoundException($"Post with ID {id} not found");
        
        return MapToDto(post);
    }
    
    public async Task<IEnumerable<PostDto>> GetAllPostsAsync(int pageIndex, int pageSize)
    {
        var posts = await _unitOfWork.Posts.FindAsync(
            p => true,
            orderBy: q => q.OrderByDescending(p => p.CreatedAt),
            skip: pageIndex * pageSize,
            take: pageSize,
            includeProperties: "Author");
        
        return posts.Select(MapToDto);
    }
    
    public async Task<PostDto> CreatePostAsync(CreatePostDto dto)
    {
        // 유효성 검증
        var author = await _unitOfWork.Users.GetByIdAsync(dto.AuthorId);
        if (author == null)
            throw new ValidationException("Invalid author ID");
        
        var post = new Post
        {
            Title = dto.Title,
            Content = dto.Content,
            AuthorId = dto.AuthorId,
            CreatedAt = DateTime.UtcNow
        };
        
        await _unitOfWork.Posts.AddAsync(post);
        await _unitOfWork.SaveChangesAsync();
        
        _logger.LogInformation("Post created: {PostId}", post.Id);
        
        return MapToDto(post);
    }
    
    public async Task<PostDto> UpdatePostAsync(int id, UpdatePostDto dto)
    {
        var post = await _unitOfWork.Posts.GetByIdAsync(id);
        
        if (post == null)
            throw new NotFoundException($"Post with ID {id} not found");
        
        post.Title = dto.Title ?? post.Title;
        post.Content = dto.Content ?? post.Content;
        post.UpdatedAt = DateTime.UtcNow;
        
        _unitOfWork.Posts.Update(post);
        await _unitOfWork.SaveChangesAsync();
        
        _logger.LogInformation("Post updated: {PostId}", id);
        
        return MapToDto(post);
    }
    
    public async Task<bool> DeletePostAsync(int id)
    {
        var post = await _unitOfWork.Posts.GetByIdAsync(id);
        
        if (post == null)
            return false;
        
        _unitOfWork.Posts.Remove(post);
        await _unitOfWork.SaveChangesAsync();
        
        _logger.LogInformation("Post deleted: {PostId}", id);
        
        return true;
    }
    
    private PostDto MapToDto(Post post)
    {
        return new PostDto
        {
            Id = post.Id,
            Title = post.Title,
            Content = post.Content,
            AuthorName = post.Author?.Username,
            CreatedAt = post.CreatedAt,
            UpdatedAt = post.UpdatedAt,
            CommentCount = post.Comments?.Count ?? 0
        };
    }
}

🎮 컨트롤러 구현

[ApiController]
[Route("api/[controller]")]
public class PostsController : ControllerBase
{
    private readonly IPostService _postService;
    
    public PostsController(IPostService postService)
    {
        _postService = postService;
    }
    
    [HttpGet]
    public async Task<ActionResult<IEnumerable<PostDto>>> GetPosts(
        [FromQuery] int pageIndex = 0,
        [FromQuery] int pageSize = 10)
    {
        var posts = await _postService.GetAllPostsAsync(pageIndex, pageSize);
        return Ok(posts);
    }
    
    [HttpGet("{id}")]
    public async Task<ActionResult<PostDto>> GetPost(int id)
    {
        try
        {
            var post = await _postService.GetPostByIdAsync(id);
            return Ok(post);
        }
        catch (NotFoundException ex)
        {
            return NotFound(ex.Message);
        }
    }
    
    [HttpPost]
    public async Task<ActionResult<PostDto>> CreatePost(CreatePostDto dto)
    {
        try
        {
            var post = await _postService.CreatePostAsync(dto);
            return CreatedAtAction(nameof(GetPost), new { id = post.Id }, post);
        }
        catch (ValidationException ex)
        {
            return BadRequest(ex.Message);
        }
    }
    
    [HttpPut("{id}")]
    public async Task<ActionResult<PostDto>> UpdatePost(int id, UpdatePostDto dto)
    {
        try
        {
            var post = await _postService.UpdatePostAsync(id, dto);
            return Ok(post);
        }
        catch (NotFoundException ex)
        {
            return NotFound(ex.Message);
        }
    }
    
    [HttpDelete("{id}")]
    public async Task<ActionResult> DeletePost(int id)
    {
        var result = await _postService.DeletePostAsync(id);
        
        if (!result)
            return NotFound();
        
        return NoContent();
    }
}

⚙️ Program.cs 설정

var builder = WebApplication.CreateBuilder(args);

// DbContext 등록
builder.Services.AddDbContext<BlogDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection"),
        sqlOptions =>
        {
            sqlOptions.EnableRetryOnFailure(
                maxRetryCount: 5,
                maxRetryDelay: TimeSpan.FromSeconds(30),
                errorNumbersToAdd: null);
        }));

// Repository 및 UnitOfWork 등록
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IPostRepository, PostRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();

// 서비스 등록
builder.Services.AddScoped<IPostService, PostService>();

// JSON 순환 참조 처리
builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
    });

// Swagger 설정
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// 개발 환경에서 Swagger 사용
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

이렇게 하면 깔끔하고 유지보수하기 좋은 구조가 완성돼요! 각 레이어가 명확하게 분리되어 있어서 테스트하기도 쉽고, 나중에 기능을 추가하거나 수정하기도 편해요 👍

🎓 재능넷에서 더 배우기

EF Core는 정말 방대한 주제예요. 이 글에서 다룬 내용도 많지만, 실제로는 더 많은 고급 기능들이 있죠! 😊

만약 EF Core나 C# 개발에 대해 더 깊이 배우고 싶다면, 재능넷(https://www.jaenung.net)에서 전문가들의 멘토링을 받아보는 것도 좋은 방법이에요. 실무 경험이 풍부한 개발자들이 1:1로 코드 리뷰도 해주고, 프로젝트 설계에 대한 조언도 받을 수 있거든요 ㅋㅋㅋ

특히 처음 EF Core를 배울 때는 혼자 삽질하는 시간이 정말 많은데, 경험 있는 분들에게 배우면 그 시간을 엄청 단축할 수 있어요! 재능넷에는 다양한 분야의 전문가들이 있으니까 자신에게 맞는 멘토를 찾아보세요 💪

🚀 다음 단계로 나아가기

EF Core를 마스터했다면 다음 주제들도 공부해보세요:

📚 추가 학습 주제 • EF Core 고급 기능: Global Query Filters, Shadow Properties, Owned Types
• 성능 모니터링: Application Insights, MiniProfiler
• 마이크로서비스: 분산 트랜잭션, Saga 패턴
• NoSQL 통합: MongoDB, Redis와 함께 사용하기
• GraphQL: Hot Chocolate과 EF Core 통합
• 실시간 기능: SignalR과 EF Core

🎉 마무리

와... 여기까지 읽으셨다면 정말 대단하세요! 👏👏👏

Entity Framework Core는 처음에는 복잡해 보일 수 있지만, 한번 익숙해지면 정말 강력한 도구예요. SQL 쿼리를 직접 작성하는 것보다 훨씬 생산적이고, 타입 안정성도 보장되고, 유지보수도 쉬워지죠!

이 글에서 다룬 내용들을 실제 프로젝트에 적용해보면서 경험을 쌓아가세요. 처음에는 실수도 하고 삽질도 하겠지만, 그게 다 성장의 과정이에요 ㅋㅋㅋ

궁금한 점이 있거나 막히는 부분이 있다면 공식 문서(https://docs.microsoft.com/ef/core/)를 참고하거나, 커뮤니티에 질문해보세요. 그리고 앞서 말했듯이 재능넷에서 전문가의 도움을 받는 것도 좋은 방법이에요!

여러분의 개발 여정에 이 글이 조금이나마 도움이 되었으면 좋겠네요. 화이팅! 🚀✨

💡 핵심 요약

✅ EF Core는 C#의 강력한 ORM 프레임워크
✅ LINQ로 직관적인 데이터 쿼리 작성
✅ 마이그레이션으로 데이터베이스 스키마 관리
✅ 성능 최적화는 필수! (AsNoTracking, Select, Include)
✅ 적절한 아키텍처 패턴 적용으로 유지보수성 향상
✅ 테스트 작성으로 안정성 확보
✅ 실수를 피하고 베스트 프랙티스 따르기

댓글 작성

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

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

아직 댓글이 없습니다.