EF Core에서 Repository 패턴을 쓰지 않아도 되는 이유

https://antondevtips.com/blog/why-you-dont-need-a-repository-in-ef-core

많은 경우에 EF Core를 사용할 때 Repository 패턴이 필요 없고, 그럴 때는 EF Core를 어떻게 써야하는지 소개하는 글입니다.

8개의 좋아요

공감합니다.

Repository패턴이 다른 방식보다 DB 변경에 유연하다는 것도 설득력이 떨어지고, 파일과 코드만 주구장창 늘어나버려서 나중에는 정말 감당하기가 어려워지는 것 같습니다. (사용하는 사람에 따라 차이가 있을 수는 있으나..)

그 대안으로 거론되는 것 중에 하나가 본 글에서 언급된 EF Core를 직접 사용하는 방식(DbContext를 Service에 직접 주입)인데, AI Agent를 통해서 코드를 작성하면 거의 이 패턴으로 작성을 해주는 것 같습니다. 제가 그쪽으로 유도를 해서인지도 모르겠지만요..:sweat_smile:

7개의 좋아요

한번이라도, 동일한 UseCase 를 저장소 패턴과 EF Core 로 구현해보면 금방 수긍할 수 있는 부분일 것입니다.

다만 링크의 예제 중 몇 개가 올바르지 않은 것 같습니다.
아시는 분은 금방 눈치채셨겠지만, 혹시라도 이참에 EF core 를 사용하려는 분들을 위해, 예를 들면,

internal sealed class CreateShipmentCommandHandler(
   // ...
   await context.Shipments.AddAsync(shipment, cancellationToken);
   await context.SaveChangesAsync(cancellationToken);
//...

DbContext 의 존재 이유의 한 축은 UnitOfWork 이고, 이를 위한 것이 SaveChanges(Async)입니다.

이 메서드는 transaction 처럼 행동 - 모든 쿼리를 성공하지 못하면 roll back - 합니다.
그 앞에 AddAsync 는 UnitOfWork 를 분절시킵니다.

같은 이유로, 메서드 내부에서, DbContext 로 Transaction 을 생성하는 것도 코드 스멜일 확률이 높습니다.

5개의 좋아요

할 말이 참 많지만…

저는 일단 repository 파 입니다.

여러 이유를 들 수 있겠지만

repository 는 그 자체로 훌륭한 관심사 분리를 수행할 수 있게 해주는 추상화 방법이라서 그렇숩니다.

물론 repository 만 사용하면 포스팅에서 언급된 여러 문제들이 있긴한데

그래도 개인적으로는 repository 에 영속성 동작을 위임하는 형태로 관심사 분리를 하는 것이

아키텍처를 세우고 그에 맞게 구현하는 방법이라고 생각하는 편이라서요.

좀 극단적으로 보자면

handler 안에서 dbContext 를 직접 주입받아 linq 로 코드를 쓰면

SQL 이 handler 들어와서 비즈니스 로직을 수행하는 것과 다름 없다고 보는 편이에요.
(일단 이게 젤 큰 문제라고 생각합니다.)

그리고 이걸 방치하면…

이럴 거면 store procedure전부 넣고 돌리는 게 한 방에 해결되는 거잖아?

로 흘러가더라구요..

구조적으로 봤을 때는 repository 가 좀 더 합리적이라고 생각하는 편입니다.

근데 저는 dapper 위주라 사실 repository 말고는 답이 없…

8개의 좋아요

개인적으로 저는¸¸
최소 두가지 조건이 모두 만족 해야 할때 Repository 추상화를 구현하지 않아도 된다 입장 입니다.

첫번째, ORM 라이브러리나 프레임워크를 정하고, 앞으로도 쭉 변경 되지 않은 것이 보장 되었을때

Repository 추상화 처리를 하지 않고, 도메인 레이어에서(Service Layer) 해당 ORM이 제공하는 DataContext를 직접 처리 하는 경우
EF 를 사용하다가 갑자기 NHibernate 로 변경 한다던지(자바 JPA에서 사용되는 닷넷 버전 입니다.)
대퍼로 변경 한다던지 상황이 발생 된다면 모든 DataContext를 직접 참조하는 코드를 일일히 찾아 변경해 주어야 합니다.

한가지 최악의 상황을 고려한다면(이런 경우는 발생 하지 않겠지만..)
갑자기 EF 버전이 업데이트 되면서 기존에 사용되던 DataContext가 deprecated 되고 새로운 방식으로 처리 되는 경우 등에도 해당 됩니다.

두번째, 프로젝트 규모가 작고 복잡하지 않을때

프로젝트 규모가 커지면 첫번째 이유에 언급했던 상황이 발생될 여지가 커집니다.
설계와 기능이 복잡해 지면서 “아.. 이건 EF 만으로 처리 안되겠는데?” 혹은 “어쩔 수 없이 이 부분은 Raw Query 형태로 처리해야 할거 같아” 등 혼용 되어 사용될 수 있습니다.

이런 상황에서 Repository 추상화로 되어 있다면 Infra Layer에서 새로운 인터페이스를 정의해 구현해 주기만 하면 크게 유지보수를 손볼일 없이 유연한 확장 가능성이 생깁니다.

4개의 좋아요

링크된 글의 서두는 아래와 같습니다.

One of the most common problems is that Repositories tend to grow rapidly as business requirements evolve.
저장소(패턴)의 가장 큰 문제는 비지니스 요구사항이 늘어 날수록 쉽게 비대해진다는 점이다.

이는 아래 격언과 일맥상통합니다.

The more encapsulations, the more responsibilities
캡슐화를 할 수록 책임은 늘어난다.

이 격언은 클래스를 설계할 때, "필드를 공개 여부"가 코드에 어떤 영향을 미치는 지에 관한 것으로,

  • 공개하는 경우
class Wrapper
{
   public Innner Inner {get;}
}

// 클라이언트 코드
var something = wrapper.Inner. // ...
  • 공개하지 않는 경우 : 캡슐화
class Wrapper
{
   private readonly Innner _inner;

   // Wrapper 의 책임
   public string Get() => _inner.// ...
   public void Update() => _inner.// ...
   // ...
}

// 클라이언트 코드
var something = wrapper.Get();
wrapper.Update();

캡슐화를 하기로 한 경우 비지니스 요구 사항이 복잡해질 수록 Wrapper 의 책임도 늘어남을 지적합니다.

"책임이 늘어 난다"는 의미는 "코드가 증가한다"는 의미인데, 저장소 패턴이 전형적인 예 중 하나입니다.

링크 글의 저자는 이점을 예시로 보여주고 있습니다.

public interface IShipmentRepository
{
    Task<ShipmentDto> GetByIdAsync(int id);
    Task<IEnumerable<ShipmentDto>> GetAllAsync();
    // ...
}

public interface IShipmentItemRepository
{
    Task<ShipmentItemDto> GetByIdAsync(int id);
    Task<IEnumerable<ShipmentItemDto>> GetByShipmentIdAsync(int shipmentId);
    // ...
}

public interface IOrderRepository
{
    Task<OrderDto> GetByIdAsync(int id);
    Task<IEnumerable<OrderDto>> GetByUserIdAsync(int userId);
    // ...
}

여기에서 시각을 좀 더 확대해보면, 저장소는 다시 서비스가 소비합니다.

class ShipmentService(IShipmentRepository shipments)
{
   public Task<Shipment> Get(int id) => 
      shipments.GetByIdAsync(id).ToShipment();

   public async Task<IEnumerable<Shipment>> GetAll() => 
      (await shipments.GetAllAsync()).Select(ToShipment);  

 // ...

전체적인 의존 단계는 아래와 같습니다.

클라이언트 코드 → (도메인 모델) Service → (저장 모델) IRepsitory → Repository → Ado.Net, Dapper, EF Core, …

아직 추상화가 부족하다구요?

클라이언트 코드 → (도메인 모델) IService → Service → (저장 모델) IRepsitory → Repository → Ado.Net, Dapper, EF Core, …

클라이언트 코드는 주로 최상단 레이어가 되는데, UI, Api 등 프리젠테이션 레이어가 여기에 해당합니다.

클라이언트 코드가 Shipment 객체 하나를 요구하는 단순한 상황에서도, 위 종속 체인의 모든 객체에 코드를 채워 넣어야 합니다. 코드를 생성할 때 뿐만 아니라, 썼던 코드를 수정 혹은 삭제하는 경우도 마찬가지입니다.

만약 더 복잡한 요구 - 여러 저장소를 사용해야지만 충족되는 요구는 저장소 패턴으로는 맞추기 힘들 수도 있습니다.

작고 단출한 프로젝트 혹은 프로젝트 시작점에서는 저장소 패턴이 그럴 듯 해 보이지만, 비지니스 요구가 많아지고 정교해질 수록 손가락 노동에 현타가 밀려 옵니다.

"캡슐화를 할 수록 더 많은 책임이 따른다"는 격언의 엄묵함에 몸서치리는 순간이 점점 많아집니다.

그런데, 링크 글의 저자는 종속성 최하단에 EF Core 가 있는 경우 라면, 아래와 같이 단출하게 해도 된다고 말합니다.

클라이언트 코드 → (도메인 모델) DbContext

도메인 모델과 저장 모델 (엔티티 클래스)를 분리한 경우,

클라이언트 코드 → (도메인 모델) 서비스 → (저장 모델) DbContext

링크 글과 같이 버티컬 슬라이스를 도입하면,

클라이언트 코드 → Handler → EF Core

단출한 구조로 덕분에, 비지니스가 복잡해지는 상황을 효율적으로 대처할 수 있습니다.

참고로, 이 효율성에는 쿼리를 Linq 로 작성한다는 점도 한 몫합니다.
Linq 작성 시, 컴파일 에러/경고가 없다면, 대부분의 경우 테스트를 할 필요도 없습니다. (Linq 의 타입 안정성이 빛을 발하는 부분입니다.)

뿐만 아니라, DbContext 는 UnitofWork 패턴이라, 복잡한 요구 사항도 매우 쉽게 대처할 수 있고, DB 안정성도 보장 받습니다.

DB 가 변경되면?

DbContext 는 데이터베이스에 대한 추상입니다.

도입할 데이터베이스가 EF core 가 지원하는 데이터 베이스 목록에 있다면, 95% 의 코드를 재사용할 수 있습니다.

재사용 코드는 대부분 쿼리(Linq) 코드이고, 나머지 5%는 데이터 베이스 설정 코드라서, 5% 도 꽤 올려치기 한 숫자입니다.

그러나, 지원하지 않는 DB 로 전환하면 EF Core 는 못 씁니다.
그러나 메이저 RDB 는 전부 지원하고, 아직까지는 RDB 가 건재한 게 현실이기 때문에 비 관계형 데이터 베이스로 변경하는 경우를 상정하는 것은 지나친 기우라할 수 있습니다.

그런데, DB 변경은 문자열 쿼리 기반 프레임워크(Ado.Net, Dapper)를 채택한 경우, 특히, 과거에 DB 전용 쿼리, 함수, 자료형을 쓴 경우, 이를 새로운 DB에 맞게 변경하는 것은 설계 패턴과 상관 없이, 매우 힘든 도전일 확률이 높습니다.

과거의 쿼리가 표준 SQL 을 썼든 쓰지 않았든 다 검수해야 하기 때문입니다.

Raw Query 를 써야 한다면?

EF Core 는 raw query 도 지원합니다.
지원한다는 의미에는 entity 클래스로 맵핑하는 것까지 포함입니다.
그러나, DB View, 저장 프로시저 호출 등은 지원하지 않습니다.

만약, 대부분의 DB 호출에 이들의 사용이 강제되는 상황이라면, EF core 를 사용할 이유가 없고, 결과적으로 링크의 글을 참조할 필요가 없습니다. 사실 개발자 입장에서는 꽤 좋은 업무 환경이라 할 수 있습니다.

6개의 좋아요

일단 Dapper를 사용하면 Repository패턴을 쓰는 것이 좋을것 같고요..

원글에서도 나와있듯 EF Core에서 Repo패턴은.. ‘굳이..? 또..?’ 라는 생각이 많이 들어요.

저도 EF Core에서 써보려고 노력은(?) 해봤으나.. 굳이 이걸 왜 또 추상화하지 하는 생각도 들었고,

개인적으로는 무엇보다 ‘관심사의 분리’가 쉽지가 않았다는 점입니다.

서비스의 성격이나 도메인별로 뭔가 정리를 해야하는데, 이도 저도 아닌 약간 걸쳐있는 애들을 어디에 둘까 고민하는 것도 스트레스였습니다..ㅎㅎ

(이건 Dapper 쓸때도 마찬가지여서 개인적인 호불호인듯)

암튼.. 머 이건 의견이 분분하기 때문에, 목적과 본인의 성향에 맞게 하는 것이 좋아보이네요..

2개의 좋아요

DbContext 한테는 매우 쉬운 문제이지만, 저장소에서는 그렇지가 않은 전형적인 케이스입니다.

뿐만 아니라, EF core 의 진화가 상당 수준에 도달하여, raw 쿼리를 써야만 해결되던 케이스도 점점 줄고 있어, 애정하지 않을 수가 없습니다.

예를 들면, ef core 8 부터 select 와 update 를 동시에 할 수 있게 된 점입니다.
덕분에 아래와 같이 복수의 클라이언트에 의해 동시 다발적으로 호출되어도, 그 중에 딱 하나의 클라이언트만 성공함을 보장하는 코드도 가능해졌습니다.

var marker = Guid.NewGuid().ToString();
var shipmentId = // ...
var updated = // ...

var affected = await db.Shipments
   .Where(s => s.Id == shipmentId && s.Status == ShippingStatus.Waiting)
   .Take(1)
   .ExecuteUpdateAsync(str => str
      .SetProperty(s => EF.Property<string?>(s, "Marker"), marker)
      .SetProperty(s => s.Status, ShippingStatus.Planning)
      .SetProperty(s => EF.Property<DateTimeOffset>(s, "Updated"), updated)
      , ct);

if (affected == 0)
{
    return Result.DbNotFound<Shipment>();
}

var toPlan = await db.Shipments
   .FirstAsync(s => s.Id == shipmentId && s.Marker == marker, ct);

return Result.Sucess(toPlan);

이 몇 줄의 코드로 인해, 클라이언트가 waiting 중인 shipment를 주기적으로 Polling 한다던지, 서버가 클라이언트들이 Idle 상태인 지를 폴링할 필요가 없어졌죠.

2개의 좋아요

이렇게 좋아 지고 있는데… 서버(백엔드) 시장은 점점 자바 가 100% 를 향해 간다는게 서글플뿐

아놔 왜.. 서버 운영체제를 4년마다 단종 하는 건지

누가 쓰겠냐구요… AD 운영 하는거 말곤.. 근디 이제 AD 쪽도 ㅠㅠ

1개의 좋아요

Case 1. .AsNoTracking(...) 으로 불러온 객체를 .SaveChangesAsync(...) 한다던가

Case 2. 반드시 .Include(...) 해야 하는 것을 빼먹고서는 Insert or Update 해야하는 로직을 Insert 로 처리해버린다던가

Case 3. 자주 쓰는 쿼리 (주로 .Any(...) 를 통한 중복확인 로직)를 코드 중복 없이 Repository 내에서 통합 관리하는 케이스

별도의 Repository 레이어로 불필요한 복잡성을 추가하는 꼴이 될 수도 있으나,

꼭 그렇지만은 않은게 모두가 EF Core를 잘 이해하고 사용할거라는 보장이 없는 경우

Repository 를 도입하여 코드 레벨에서 제약을 걸어 일관성 있는 사용을 유도하는 것도 좋습니다.

다만 GET 요청들에 대해 처리할 때는 코드의 유연성이 필요하기 때문에 이런식으로 처리하면 좋습니다,

public class UserRepository
{
    private readonly DbSet<User> _users;
    public UserRepository(DbContext dbContext)
    {
        _users = dbContext.Users;
    }

    public IQueryable<User> AsReadOnly() => _users.AsNoTracking(); // <--- 쿼리 전용

    public async Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
        => await _users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, cancellationToken);

    public Task<bool> ExistsByEmailAsync(string email, CancellationToken cancellationToken = default)
        => _users.AnyAsync(u => u.Email == email, cancellationToken);

    public void Add(User user) => _users.Add(user);
    public void Update(User user) => _users.Update(user);
    public void Remove(User user) => _users.Remove(user);
}

public IQueryable<User> AsReadOnly() => _users.AsNoTracking();
위 메서드를 통해 GET 요청에 대해서는 추후 필요한 쿼리를 유연하게 작성할 수 있도록 합니다.

1개의 좋아요

자바 진영에게 맥을 못 추는 건 윈도우만 고집하는 닷넷 개발자들의 영향도 있지 않을까요?

선입견이 뿌리 깊게 박혀 있어여

유닉스나 리눅스로 갈거면 자바가 정석 이라는…

닷넷도 리눅스에 올라 간다지만 플러터나 마우이 같은 크로스 개념이라 네이티브만 못 할거라는 선입견

서버 단종만 안시켜도 숨좀 쉬겄구만.. 에혀

닷넷이 크로스 플랫폼이 된 지도 꽤 시간이 지났는데, 저 예산 클라이언트들에게 리눅스 옵션이라도 제시하는 노력이 있었다면, 지금처럼 되지는 않았을 것 같다는 생각이 드는 건 어쩔 수 없는 것 같습니다.

AsNoTracking은 기본적인 개념인데, 기본조차 이해하지 못하는 개발자에게 코드를 맡기는 경우까지 가정한다는 게 좀 그렇네요.

닷넷코어 이후로 웹서버는 전부 리눅스 서버 기준으로 제시는

합니다만…

현실적으로 개발인력 구성상 속편하게 자바로 가는 거라고 생각합니다

2개의 좋아요

AsNoTracking 예시는 아주아주 단순한 케이스 중 하나일 뿐입니다 ㅋㅋㅋㅋ

1개의 좋아요

이걸 MS 가 알아야 하는데

아는데 모르는척 하는 건가…

서버 단종 좀 하지 말고 계속 지원 해주소 진심.. 백엔드개발자들 다 죽소.. 한국 개발자만 죽을래나.. 에혀