EF 사용시, Query extension method 패턴...

EF core를 주로 사용하고 있는데, 문득 제가 주로 사용하는 확장메서드 패턴에 대해서
다시 생각해보게 되네요.

EF가 사실상 싱글레포 개념으로 이해가되고, 별도 레포지토리 레이어가 불필요하다고 생각해서 저는 이런식으로 작업을 하는편입니다.

만약 책 이라는 엔티티가 있는데, 이것을 저자 이름으로 찾는 경우가 많다면 이런 쿼리 확장 메서드를 만들어 쓰는데요.

public static class BookQueryExtensions()
{
 public static IQueryable<Book> FindByWriterName(this IQueryable<Book> query. string name)
 {
   return query.Where(b=>b.WriterName.Equals(name));
 }
}

이것을 서비스 레이어에 사용할땐…

// BookService.GetBooksByWriterName(string wrtierName)
... 
 databaseContext.Books.FindByWriterName(writerName).ToList();

 or 

 databaesContext.Books.FindByWriterName(wrtierName).FirstOrDefault();

이런식으로 사용합니다.

응답모델 반환도 마찬가지로 확장 메서드를 통해서

public static IQueryable<BookResponseModel> ToBookResponse(this IQueryable<Book> query)
{
 return query.Select(b=> new BookResponseModel{ Id = b.Id ... });
}

// 사용례
databaseContext.Books.FindByWriterName(writerName).ToBookResponse().ToList();

이렇게 사용합니다.

보통 서비스 레이어에서 DbContext를 직접 주입받고, 이런 패턴이 서비스 로직 변경 및 구현에 유연 하다고 생각하기 때문이죠.

저처럼 이런식으로 사용하시는 분들도 있는지,
다른 좋은 패턴은 어떤것이 있는지 요즘 여러 커뮤니티나 글들을 둘러보게되네요. :thinking:

그리고 이렇게하면 해당 메서드 안에서 Include를 사용해도
리샤퍼가 해당 메서드 사용 외부코드에서 Usage of navigational property can return incomplete data 경고를 띄우는게 좀 꺼림칙한 것도 있습니다. (해당 코드에서 직접 Include를 하지 않아서 그런듯 한데, 이 경고를 꺼야하나…)

1개의 좋아요

자주 사용되는 쿼리를 확장 메서드로 정의하는 것은 작성자 선호의 문제일 것입니다.

그런데, 확장을 정의해서, 호출 코드를 깔끔하게 만드는 것과 어떤 패턴을 연관시키는 것은 다소 무리가 있어 보입니다. 확장 메서드는 저장소 패턴을 사용하든 안 하든 어짜피 사용될 것이기 때문입니다.

예를 들어, 아래와 같이 서비스를 파편화해도,

public record QueryWriterBooks(string Name);
public delegate Task<Result<Book[]>> QueryWriterBooksHandler(QueryWriterBooks rq, Cancellation ct);

// 백엔드 서비스 등록 
services.AddScoped<QueryWriterBooksHandler>(sp =>
{
   var db = sp.GetRequiredService<AppDbContext>();
   return async(rq, ct) => {     
      var books = await db.Books
         .AsNoTracking()
         .Where(b => b.Writer.Name == rq.Name)
         .ToArrayAsync(ct);
      return Result.Success(books);
   };
});

서비스들이 공통적으로 자주 호출하는 필터나 맵이 있다면, 확장 메서드로 정의해 놓고 사용하는 것이 가능은 합니다.
그러나, (버티컬 슬라이스된) 하나의 서비스가 관심사를 일괄 처리하기 때문에, 공통적으로 사용되는 것은 거의 나오지 않는 것 같습니다.

2개의 좋아요

의견 감사합니다.
이제 생각해보니 말씀해주신대로 제 선호에 따른 방식일뿐이지 별도 패턴이라고 하기엔 어려운 것 같네요.

제가 의도한것은
보통 레포지토리 레이어가 따로 존재하는 경우라면, 공통적으로 불러오는 부분이나 각 부분을 레포지토리 서비스의 함수로 나누어둘텐데
EF 사용시엔 DbContext자체를 싱글레포로 취급해서 서비스에서 직접 DI받아 쓰고
별도 레포지토리 함수를 쓰는 대신, 공통되는 쿼리 자체만 별도 분리하여 사용하는 것을 의미하는 것 이였습니다.

1개의 좋아요

DbContext 는 저장소 컨테이너로 볼 수 있습니다.

이 때 개별 저장소는 IQueryable(을 구현한 DbSet) 인데, 이 객체는 상태의 집합이 아닌 행위의 집합으로 정의된 것이죠.

이 저장소의 행위는 IQuerayble extension 으로 고도로 추상화되어 있어, 여기에 또 다른 추상인 저장소 패턴으로 감싸는 것은 추상에 대한 추상이라 사족이라고 하는 것입니다.

IRepository → Repository → IQueryable → IQueryable provider.

실제로도, 저장소 패턴을 도입하면 넘쳐 나는 껍데기 코드에 심한 현타가 오곤 합니다.

interface IBookRepository
{
   IEnumerable<Book> GetByAuthorName(string name);
   IEnumerable<Book> GetByAuthorFamilyName(string name);
   IEnumerable<Book> GetByAuthorPenName(string name);
   IEnumerable<Book> GetByBookName(string name);
   // ....
}
class BookRepository(DbContext db) : IBookRepository
{
   public IEnumerable<Book> GetByAuthorName(string name) => // ...
   public IEnumerable<Book> GetByAuthorFamilyName(string name) => // ...
   public IEnumerable<Book> GetByAuthorPenName(string name) => // ...
   public IEnumerable<Book> GetByBookName(string name) => // ...
   // ....
}

이 저장소의 소비자 객체입니다.

class BookService(IBookRepository books) 
{
   public IEnumerable<Book> GetByAuthorName(string name) => // ...
   public IEnumerable<Book> GetByAuthorFamilyName(string name) => // ...
   public IEnumerable<Book> GetByAuthorPenName(string name) => // ...
   public IEnumerable<Book> GetByBookName(string name) => // ...

소비 객체의 요구가 늘어날 때마다, IBookRepository, BookRepository 를 수정한 후에 IQueryable 을 호출합니다. 거꾸로 말하면, 요구하는 측과 요구에 대응 하는 측 사이에 불필요한 두 개의 레이어가 들어 가 있는 셈이 됩니다. (그래서 프로젝트가 성장할 수록 "이게 맞나?"하는 현타가 씨게 오는 것이죠.)

불필요한 레이어를 없애면 모두가 행복해집니다.

class BookService(IQueryable<Book> books) : IBookService
{
   // ...

그러나, 저장소 사이에 긴밀한 연관 관계가 있는 경우, 연관된 저장소들을 하나에 담(아 Unit of Work로 처리하)는 객체가 필요한데, 그것이 DbContext 입니다.

class BookService(DbContext db) : IBookService
{
   // ...

불필요한 레이어(저장소 패턴 객체들)와 IQueryable 을 연관짓다 보니, 이런 질문을 하게 된 것은 아닌가 하는 생각이 듭니다.

참고로, 실무에서 고민해야 할 것은 IBookService 의 도입 여부일 것입니다.
즉, 최종 소비자가 IBookService 에 의존할 것인지,

partial class MainWindow(IBookService books)

저장소에 바로 의존할 것인지를 결정하는 것이죠.,

partial class MainWindow(DbContext db)

전자를 선택하면, 데이터 소스 변경(db → 웹)에 대한 대응이 용이한 반면, 저장소 패턴과 동일한 현타가 오고,

interface IBookService
{
   // ....
}
class BookService(DbContext db) : IBookService
{
   // ....
}

후자를 선택하면, 데이터 소스 변경을 하지 못하는 단점이 있는 대신, 코드가 간명합니다.

제가 보여드린 커맨드 패턴은 이 둘 사이에 절충점입니다.

서비스의 행위를 파편화해서, 기존의 서비스 코드를 건들지 않(고 필요한 행위만 새로 추상화하)는 것과 데이터 소스 변경에도 대응이 가능하다는 것이 장점입니다. (물론 단점도 있습니다.)

예를 들어, 같은 커맨드라도, 최종 소비자가 달라지는 경우,

class BookSearchControl(QueryWriterBooksHandler queryWriterBooksHandler) 
   : UserControl
{

새로운 핸들러만 구현하면 됩니다.

// 프론트엔드 서비스 등록 
var bookApiPath = "/books";
services.AddScoped<QueryWriterBooksHandler>(sp =>
{
   var proxy = sp.GetKeyedService<HttpClient>("bookApi");
   var path = $"{bookApiPath}?name={rq.Name}";
   return async (rq, ct) => await proxy.GetAsJsonAsync<Result<Book[]>>(path, ct);
});

이는 제가 주로 3-tier 시스템(프론트엔드, 백엔드, DB)으로 구성하기 때문에 선택하는 전략일 뿐, 당분 간 2-tier 시스템(데스크탑 앱 + db 또는 Api + db)에서 변동이 없을 것이라 예상된다면, 굳이 채택할 필요는 없을 것입니다.

2개의 좋아요