클린 아키텍쳐 패턴 연구

그 유명세에 비해서 구체적인 예제를 얻기 힘든 아키텍쳐 중 하나인 것 같습니다.
그래서, 여러 개의 구조로 실험을 해봤습니다.

현재까지는 아래와 같은 방식이 가장 간편하고 안전한 것 같아, 이를 공유하고자 합니다.

재사용 코드

아래는 저장소 패턴을 C#에 맞게 수정한 것입니다.

using Models.Entities;
using System.Linq.Expressions;

namespace UseCases.Plugins;

public interface IDao 
{
    ValueTask<TEntity?> FetchByIdAsync<TEntity>(Guid id, CancellationToken stopToken) where TEntity : EntityBase;
    Task<int> AddAsync<TEntity>(TEntity entity, CancellationToken stopToken) where TEntity : EntityBase;
    Task<int> AddAsync<TEntity>(IEnumerable<TEntity> entities, CancellationToken stopToken) where TEntity : EntityBase;

    Task<int> RemoveAsync<TEntity>(TEntity entity, CancellationToken stopToken) where TEntity : EntityBase;
    Task<int> RemoveAsync<TEntity>(IEnumerable<TEntity> entities, CancellationToken stopToken) where TEntity : EntityBase;

    Task<int> UpdateAsync<TEntity>(TEntity entity, CancellationToken stopToken) where TEntity : EntityBase;
    Task<int> UpdateAsync<TEntity>(IEnumerable<TEntity> entities, CancellationToken stopToken) where TEntity : EntityBase;


    Task<TEntity[]> FetchWhereAsync<TEntity>(
        Expression<Func<TEntity, bool>> predicate,
        CancellationToken stopToken) where TEntity : EntityBase;

    Task<TEntity[]> FetchWhereAsync<TEntity, TProperty>(
        Expression<Func<TEntity, bool>> predicate,
        CancellationToken stopToken,
        params Expression<Func<TEntity, TProperty>>[] includeProperties) where TEntity : EntityBase;

    //Task<object[]> FetchWhereSelectAsync<TEntity>(
    //  Expression<Func<TEntity, bool>> predicate,
    //  Expression<Func<TEntity, object>> generate,
    //  CancellationToken stopToken) where TEntity : EntityBase;
}

IDao는 모든 프로젝트에서 구현의 수정 없이 재사용 가능하도록 가급적 많은 기능을 추가할 예정입니다.

그리고, 개발 중 DB 설계 도구로 EF Core를 사용하기 위해, EF Core 를 기반하는 IDao 구현도 재사용될 수 있는 구조로 정의합니다.

using Microsoft.EntityFrameworkCore;
using Models.Entities;
using UseCases.Plugins;
using System.Linq.Expressions;

namespace Database.PostgreSQL.Plugs;

public class DaoDbContext<TContext> : IDao
    where TContext : DbContext
{
    private readonly TContext _db;

    public DaoDbContext(TContext db)
    {
        _db = db;
    }

    public ValueTask<TEntity?> FetchByIdAsync<TEntity>(Guid id, CancellationToken stopToken)
        where TEntity : EntityBase
    {
        return _db.Set<TEntity>().FindAsync(new object?[] { id }, stopToken);
    }


    public Task<int> AddAsync<TEntity>(TEntity entity, CancellationToken stopToken)
                where TEntity : EntityBase
    {
        var entities = _db.Set<TEntity>();

        if (entity.Id == Guid.Empty)
        {
            entities.Add(entity);
        }
        else
        {
            entities.Update(entity);
        }

        return _db.SaveChangesAsync(stopToken);
    }

    public Task<int> RemoveAsync<TEntity>(TEntity entity, CancellationToken stopToken)
                where TEntity : EntityBase

    {
        _db.Set<TEntity>().Remove(entity);
        return _db.SaveChangesAsync(stopToken);
    }

    public Task<int> UpdateAsync<TEntity>(TEntity entity, CancellationToken stopToken)
                where TEntity : EntityBase

        => AddAsync(entity, stopToken);

    public Task<TEntity[]> FetchWhereAsync<TEntity>(
        Expression<Func<TEntity, bool>> predicate,
        CancellationToken stopToken
        ) where TEntity : EntityBase
    {
        return _db.Set<TEntity>().Where(predicate).ToArrayAsync(stopToken);
    }

    public Task<TEntity[]> FetchWhereAsync<TEntity, TProperty>(
        Expression<Func<TEntity, bool>> predicate,
        CancellationToken stopToken,
        params Expression<Func<TEntity, TProperty>>[] includeProperties)
                where TEntity : EntityBase

    {
        var result = _db.Set<TEntity>().Where(predicate);

        foreach (var property in includeProperties)
        {
            if (stopToken.IsCancellationRequested)
            {
                return Task.FromResult(Array.Empty<TEntity>());
            }

            result = result.Include(property);
        }

        return result.ToArrayAsync(stopToken);
    }

    public Task<int> AddAsync<TEntity>(IEnumerable<TEntity> entities, CancellationToken stopToken)
                where TEntity : EntityBase

    {
        _db.Set<TEntity>()?.AddRange(entities);
        return _db.SaveChangesAsync(stopToken);
    }

    public Task<int> RemoveAsync<TEntity>(IEnumerable<TEntity> entities, CancellationToken stopToken)
                where TEntity : EntityBase

    {
        _db.Set<TEntity>()?.RemoveRange(entities);
        return _db.SaveChangesAsync(stopToken);
    }

    public Task<int> UpdateAsync<TEntity>(IEnumerable<TEntity> entities, CancellationToken stopToken)
                where TEntity : EntityBase

    {
        _db.Set<TEntity>()?.UpdateRange(entities);
        return _db.SaveChangesAsync(stopToken);
    }
}

참고

보통 DbContext 를 사용할 때, 추가 레이어(IRepositry, IDao 등)을 두는 것은 안티 패턴으로 알려져 있습니다.

그럼에도 불구하고, IDao 로 DbContext를 감싼 이유는, DB 설계도구로 EF 코어만큼 편리한 것도 없다는 점과, 개발/테스트 완료 후 생쿼리나 다른 ORM으로 갈아 탈 것에 대한 대비하기 위함입니다.

아직 완성단계는 아니지만, 위의 코드들은 모든 신규 프로젝트에서 재사용될 코드들입니다.

재사용 코드 사용 설정

테스트 Db 를 위해 ApplicationDbContextPsql를 정의하고, 아래와 같이 서비스 등록을 합니다.

//...
builder.Services.AddDbContext<ApplicationDbContextPsql>();
builder.Services.AddDbContext<IDao, DaoDbContext<ApplicationDbContextPsql>>();
//

재사용 코드를 가급적 범용적으로 정의했기 때문에, 저장소 관련 코드가 위 두 줄의 등록 코드와 ApplicationDbContextPsql 로 한정되는 간편함이 있습니다.

SRS to UseCases

SRS(Software Requirement Specifications)는 시스템 요구 명세서라고 하는데, 소프트웨어를 하나의 시스템으로 보고, 이 시스템에 대한 요구사항을 나열한 문서입니다.
간단한 예를 들면,

시스템 요구 사항

팀장은 자신이 팀을 관리한다.

팀 생성, 삭제, 양도, 활성/비활성
팀 생성 시, 고유한 닉네임을 부여하여, 팀원이 찾을 수 있게 함.
닉네임의 전파는 시스템 밖에서 이뤄짐.

팀장은 팀원을 관리한다.

팀원 생성, 삭제, 양도, 활성/비활성
팀원 생성 시, 고유하지 않은 닉네임 혹은 이름을 부여한다.

위의 예에서, "팀장"이라는 Actor 를 식별할 수 있습니다.
또한, 팀장은 시스템 사용자 Actor 이기도 합니다.

이러한 사항을 UseCase 로 정의해보면,

UseCases

시스템 유저가 되기 위해서는 시스템에 회원가입을 해야하는데, 닷넷에서는 이를 Identity 라는 개념으로 처리할 수 있기 때문에 회원가입은 그것으로 처리하는 것으로 합니다.

신원 미상의 접근자가 시스템의 Identity 영역을 통과하면, 유저 정보, 대표적으로 UserId를 보유한 채로 Application 영역으로 진입하고, 이 시점부터 사용자를 User Actor로 취급합니다.

SRS을 바탕으로, User Actor를 위한 UseCase를 아래와 같이 2 개로 정의할 수 있습니다.

  • 팀장으로 등록
  • 팀원으로 등록

이를 UserActorUseCases 라는 클래스 안에, 메서드로 표현해보면 아래와 같습니다.

using Models.Entities;
using UseCases.Plugins;

namespace UseCases;

public class UserActorUseCases : UseCaseBase
{
    public UserActorUseCases(IDao dao) : base(dao)
    {
    }

    // 사용자가 팀장으로 등록
    public async Task<SystemGuide> RegisterAsManagerAsync(Manager manager, string registrationToken, CancellationToken token = default)
    {
        var result = await _dao.AddAsync(manager, token);

        if(result > 0)
        {
            return SystemGuide.Success;
        }

        return SystemGuide.InternalError;
    }
   
    // 사용자는 팀장이 등록한 팀의 닉네임을 바탕으로 팀의 멤버를 검색
    public Task<Worker[]> FetchWorkersByTeamNicknameAsync(string  teamNickName, CancellationToken token = default)
    {
        if (string.IsNullOrWhiteSpace(teamNickName))
            return Task.FromResult(Array.Empty<Shifter>());

        return _dao.FetchWhereAsync<Shifter>( x=> x.Team != null && x.Team.Nickname == teamNickName, token);    
    }

    // 사용자가 검색된 팀 멤버 중 하나를 선택하여, 팀원으로 등록
    public async Task<SystemGuide> RegisterAsWorkerAsync(Guid loginUserId, Worker worker, CancellationToken token = default)
    {
        if (worker.Id == Guid.Empty || worker.Team == null)
            return SystemGuide.UserCantCreateWorker;

        if (worker.LoginUserId != Guid.Empty)
            return SystemGuide.UserCantTransferWorker;

        worker.LoginUserId = loginUserId;
        var result = await _dao.UpdateAsync(worker, token);

        return result > 1 ? SystemGuide.Success : SystemGuide.InternalError;
    }
}

참고로, UseCase 는 키오스크 처럼 동작하도록 만들고 싶어, 이용에 관한 안내 메시지를 반환하도록 설계했습니다.

  • 정상동작 : SystemGuide.Success
  • 실패 : SystemGuide.InternalError

SystemGuide 는 enum 입니다.
string 대신, enum 을 선택한 이유는, 코딩 중간 중간, 멤버를 추가하는 것이 간편하고, 실제 안내될 문자열은 UI 레이에서 관리되어야 하기에, UseCase 에서는 안내의 종류와 각 안내의 의도를 잘 나타내는 이름을 정하는 것이 더 중요하기 때문입니다.

다만, 결과를 반환하는 UseCase에는 못 쓰는 것이 좀 아쉽습니다.

아래와 같이, out 키워드를 사용한 “Try~” 패턴을 비동기 메서드에도 쓸 수 있는 날이 얼른 오기를 희망해봅니다.

Task<bool> Try...Async(..., out Result[] results)

이제, 또 다른 Actor 중 하나인, 팀장 Actor를 위한 UseCase는 아래와 같이 정의할 수 있습니다.

using Models.Entities;
using UseCases.Plugins;
using UseCases.Plugs;

namespace UseCases
{
    public class ManagerActorUseCases : UseCaseBase
    {
        public ManagerActorUseCases(IDao dao) : base(dao)
        {
        }

        public async Task<SystemGuide> RegisterTeam(Guid loginUserId, Team team, CancellationToken token)
        {
            if (team.Manager == null || team.Manager.Id == Guid.Empty)
            {
                return SystemGuide.RegisgeredManagerOnlyCanRegisterTeam;
            }

            if (team.Manager.LoginUserId != loginUserId)
            {
                return SystemGuide.ManagerCanRegisterOwnTeamOnly;
            }

            var teams = await _dao.FetchWhereAsync<Team>(x => x.Nickname == team.Nickname, token);

            if (teams.Any())
                return SystemGuide.TryAgainWithOtherNickname;

            var result = await _dao.AddAsync(team, token);

            return result > 0 ? SystemGuide.Success : SystemGuide.InternalError;
        }

        public async Task<SystemGuide> UnregisterTeam(Guid loginUserId, Team team, bool remove, CancellationToken token)
        {
            if (team.Manager == null || team.Manager.Id == Guid.Empty)
            {
                return SystemGuide.RegisteredManagerOnlyCanUnregisterTeam;
            }

            if (team.Manager.LoginUserId != loginUserId)
            {
                return SystemGuide.ManagerCanUnregisterOwnTeamOnly;
            }

            var record = await _dao.FetchByIdAsync<Team>(team.Id, token);

            if (record == null)
                return SystemGuide.NoRecordFound;

            int result = 0;
            if (remove)
            {
                result = await _dao.RemoveAsync(team, token);
            }
            else
            {
                team.Manager = null;
                result = await _dao.UpdateAsync(team, token);
            }

            return result > 0 ? SystemGuide.Success : SystemGuide.InternalError;
        }
    }
}

시스템 인터페이스

개별 UseCase 클래스는 시스템의 특정 이용자(Actor)의 요구 사항을 나타내고, 이 클래스들의 집합은 곧 소프트웨어 시스템이 됩니다. 다시 말하면, SRS를 코드로 표현한 것입니다.

시스템과 외부 환경은 인터페이스 레이어를 통해 통신하는데, 이 통신의 일방은 시스템이고, 다른 일방은 Actor입니다.
시스템은 사람인 Actor를 위해 UI 레이어를, Api client 를 위해 API 레이어를 제공해야 합니다.

각 레이어를 구현하는 입장에서는, UseCase 클래스와 메서드의 이름은 구현 목적에 관한 힌트를 제공합니다.

각 Actor를 위한 인터페이스를 모두 구현하면, 전체 시스템이 완성되어, 개발이 종료됩니다.

만약, 인터페이스 레이어를 담당할 별도의 인력이 있는 경우, UseCase는 interface 혹은 abstract/virtual class 로 피상적으로 정의하면, 여러 레이어를 동시에 구현할 수 있습니다.

다만, 이 경우, 구현 중간에 아래와 같이 UseCase 정의 자체가 흔들리면, 수정할 레이어도 많아지기 때문에, 정의에 관해 신중해야 할 필요가 있습니다.

interface IUserActorUseCase
{
    // Task<Worker[]> FetchWorkersByTeamNicknameAsync
        // (string  teamNickName, CancellationToken token = default);

    // Task<Worker[]> FetchWorkersByTeamNicknameAsync
        // (Guid userId, string  teamNickName, CancellationToken token = default);

    Task<Worker[]> FetchWorkersByTeamNicknameAsync
        (Guid userId, string  teamNickName, string searchToken, CancellationToken token = default);
}

UseCase 관리

UseCase를 관리하는 방법은 몇 가지가 있습니다.

  1. Actor 의 모든 UseCase를 하나의 형식에 두는 방식
    이 방식은 위의 에제와 동일합니다.
    이 경우, 아래처럼 Actor-Entity 별로 파일을 분리할 수도 있습니다.
// IManagerActorUseCases.Team.cs
partial interface IManagerActorUseCases
{
    ValueTask<Team?> FetchTeamByTeamIdAsync(Guid teamId, CancellationToken stopToken);
    Task<Team[]> FetchTeamsByManagerIdAsync(Guid managerId, CancellationToken stopToken);
}

// IManagerActorUseCases.Worker.cs
partial interface IManagerActorUseCases
{
    Task RegisterWorkerAsync(Worker worker, CancellationToken stopToken);
    Task<Worker[]> FetchWorkersByManagerIdAsync(Guid managerId, CancellationToken stopToken);
}
  1. UseCase 별 형식을 할당.
    개별 UseCase 를 독립 형식으로 정의하는 것입니다.
 public interface IManagerRegisterTeamUseCases
{
    Task ExecuteAsync(Team team, CancellationToken token);
}

1 번의 경우, 관리와 사용 측면에서 효율적입니다.
그러나, 하나의 형식을 통해, Actor 가 관여하는 모든 Entity 에 접근할 수 있기 때문에 통제력이 약하고, 그로 인해 실수의 여지도 많습니다.

2 번의 경우, 관리와 사용이 번거롭지만, 통제력이 강해 실수의 여지가 적습니다.

마치며

클린 아키텍쳐가 기존의 아키텍쳐와 외형적으로 다른 부분 중 하나는, Api 레이어의 지위인 것 같습니다.

기존에는 보통 IRepository 혹은 IDao 의 구현 중 하나였지만, 클린 아키텍쳐에서는 UseCase의 소비자로 격상됩니다.

이 것이 가능한 이유는, UseCase가 의존하는 모델은 도메인 모델이 아니라, Entity 모델 - 저장을 위한 데이터 모델이기 때문입니다.

IDao가 저장소의 모든 자료에 대한 접근을 무제한 허용하는 것처럼 보여도, 실제로는 UseCase에 의해 통제됩니다. 특정 UseCase에서 아래와 같은 호출을 하지 않은 한, 특정 테이블의 모든 레코드를 받을 길은 없다는 의미입니다.

var allWorkers = await _dao.FetchWhereAsync<Worker>( x => true);

물론, 위와 같은 코드는 AdminUseCases 에서는 가능할 수도 있습니다.

이러한 역할로 인해, IDao를 아무런 통제 장치 없이 정의해도 문제가 안되고, 오히려 그렇게 하는 것이 UseCase 정의 시에 편리합니다.

서두에 가급적 많은 기능을 제공하도록 정의한다고 한 이유가 여기에 있습니다.

UseCase 들은 IDao 에 의존하기 때문에 인메모리 저장소를 바탕으로 유닛 테스트가 가능합니다.
또한 DaoDbContext를 정의해 놓았기 때문에 테스트 데이터 베이스를 바탕으로 유닛 테스트도 가능합니다. 저는 후자를 진행합니다.

엔티티 모델이어서 부각되는 부차적인 장점으로는, 도메인 모델과 엔티티 모델의 개념을 분리하는 경우, 미세한 차이점 때문에 하나의 파일로 적을 것인지, 아니면, 각각을 별도로 적을 것인지 결정하는 게 애매한 경우가 있는데, 그러한 고민이 많이 줄어드는 것 같습니다.

10개의 좋아요

dbcontext를 바로 사용하지 않고 의존성을 받고 거기에 Interface까지 따로 정의하셨군요 일관된 사용성이나 Singleton으로 일관된 로직 적용도 가능하게 아마 이렇게 쓰시면 처음 구조 짜기 어려워서 그렇지 앞으로 변경이나 재활용성이 좋으실것 같네요
앞단을 Core개발자가 했으니 팀원들도 DB Layer 관련해서는
부담이 덜하겠고요 Select 할때 AsNotracking 추천합니다.
내용은 좀 틀리지만 제가 Referer 하고있는 Clean Archtecture 소스랑
사상은 상당히 유사합니다.
근데 샘플 소스는 많지 않나요?? 그리고 저는 usecase 의 경우
그냥 mediator c#로 했어요 Library 사용을 좋아해서

3개의 좋아요

좋게 봐주셔서 감사합니다.

현재 진행 중인 프로젝트에서는 Tracking 관 관련한 문제가 거의 없을 것 같습니다.
Admin Actor가 아닌 Actor 레벨은 자신의 정보만 열람하기 때문에, Fetch하는 데이터 량이 많지 않습니다.
물론, 시계열 자료가 있지만, 이는 Paging을 강제하는 방향으로 정의해서, 성능 영향을 줄일 예정입니다.다.

Tracking은 EF 의존적이라, 범용인 IDao 레벨에서는 FetchWhereAsync 와 FetchWhereInclude 로 구분해서, 단일 객체만 flat하게 받을 것인지, 아니면, 그 객체의 속성 객체까지 Include 할 것인지 결정하도록 해놨습니다.

아직은 한계가 많습니다.

2개의 좋아요

async Try~ out 이 안되어서 아쉬우시다면
대신 튜플형 반환 사용하시는것도 방법일거 같습니다.

public async Task<(bool, string)> DoSomthing(int someInt)
{
   try
   {
      if (someInt < 0)
         return (fasle, "파라미터 값이 유효하지 않습니다.")

      // ... Do something  
      return (true, "정상적으로 작동되었습니다.");
   }
   catch
   {
      return (false, "뭔가 잘못되었습니다!");
   }
}
5개의 좋아요

저도 useCase 에서 try catch 를 하는데, 처음엔 튜플형식으로 리턴하다가 Either 이나 Option 으로 반환하니 처리하기 좀더 직관적이고 깔끔해보이더라구요.

2개의 좋아요

튜플을 사용하는 것도 좋은 방법입니다.

그런데, 저의 바램은 Task 내부에서 Exception 이 있었다 혹은 없었다 정도를 bool 로 표현해주는 수단이 있었으면 하는 것이었습니다.

닷넷은 Task 내부의 예외는 내부에서 처리하여 가급적 호출 코드로 전파하지 말라는 권고를 하고 있는데, 이는 메인 로직의 안정성을 위한 것입니다.

그런데, 현재의 Task 구조에서는, 아시다시피, 예외가 await 을 통해 호출 코드로 전파되는데, 이는 아래와 같은 딜레마를 유발합니다.

Task 내부에서 예외를 처리하는 경우: 호출 코드에서 async 의 중복이 나타나 성능 손실이 있습니다
호출 코드에서 예외를 처리하는 경우: 내부에서 처리하라는 권고에 반하는 것입니다.

저의 판단은 상태 머신 레벨에서 Task.Completed 인 경우, Task<bool> (…, out TResult)를 반환하도록 구현 해주면, 이러한 딜레마가 사라지지 않을까입니다.

2개의 좋아요