Ver3.0 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 프레임워크인데, 진짜 개발 생산성이 미친 듯이 올라가요 🔥
• SQL 쿼리 → LINQ(Language Integrated Query)로 작성
• 데이터베이스 연결 관리 → 자동화
• 데이터 변환 → 자동 매핑
🚀 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
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();
📊 마이그레이션으로 데이터베이스 생성하기
모델 클래스와 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 명령어를 생성해줘요 💪
• Update-Database - 마이그레이션 적용
• Remove-Migration - 마지막 마이그레이션 제거
• Script-Migration - SQL 스크립트 생성
• Get-Migration - 마이그레이션 목록 조회
💾 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 문제라고 하죠).
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; }
}
• Lazy Loading vs Eager Loading 성능을 고려해서 선택하세요
• 삭제 동작(DeleteBehavior) 신중하게 설정하세요
• 외래 키 제약조건 데이터 무결성을 위해 중요해요
⚡ 성능 최적화 테크닉
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 문제 발생
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();
}
}
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);
}
}
• 외래 키 제약조건이 실제로 검증되지 않아요
• 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);
}
// 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를 마스터했다면 다음 주제들도 공부해보세요:
• 성능 모니터링: 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
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.