Merc 0.2.1

dotnet add package Merc --version 0.2.1
                    
NuGet\Install-Package Merc -Version 0.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Merc" Version="0.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Merc" Version="0.2.1" />
                    
Directory.Packages.props
<PackageReference Include="Merc" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Merc --version 0.2.1
                    
#r "nuget: Merc, 0.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Merc@0.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Merc&version=0.2.1
                    
Install as a Cake Addin
#tool nuget:?package=Merc&version=0.2.1
                    
Install as a Cake Tool

Merc

Multi-transport Entity-framework Repository Contract — EF Core 의 Repository 패턴을 확장해, 호스트 애플리케이션이 DB 에 직접 접근하는 방식Web API 를 거쳐 접근하는 방식을 모두 지원하는 라이브러리입니다.

// 부르는 쪽은 계약만 압니다. 어느 방식으로 도는지는 모릅니다.
public sealed class BlogService(IBlogRepository repository)
{
    public Task<Blog?> FindAsync(int id) => repository.GetByKeyAsync(id);

    public Task<List<BlogSummary>> ListAsync() => repository.GetSummariesAsync();
}

어느 방식이 될지는 시작 시 등록 한 줄이 정합니다. 호출부는 고치지 않습니다.

호스트가 하는 일 등록 Repository 가 실제로 하는 일 샘플
DB 직접 접근 AddLocalRepositories() DbContext 를 열어 EF 로 질의 samples/Merc.Sample.Local
Web API 접근 AddRepositoryApiClient(baseAddress) HTTP 로 호출 samples/Merc.Sample.Web
API 제공 AddRepositoryApiHost() + MapRepositoryEndpointGroups() 직접 접근 + 엔드포인트 노출 samples/Merc.Sample.Api

계약은 하나입니다. 엔티티마다 계약 인터페이스(IBlogRepository 등)를 한 번 선언하면 로컬 구현·엔드포인트·HTTP 클라이언트 구현 세 가지가 빌드마다 생성됩니다. 손으로 쓰는 것은 계약과 엔티티 특화 메서드뿐이고, 생성물은 커밋되지 않습니다.

요구사항

  • .NET 10 SDK. 소비자 프로젝트는 net10.0 이어야 합니다.
  • Entity Framework Core 10 + ASP.NET Core Minimal API.
  • IDE 는 .NET 10 을 지원하는 버전이면 됩니다. 생성물은 편집기에서도 바로 보입니다.

이 문서의 코드는 전부 samples/ 의 네 프로젝트에서 그대로 가져왔습니다. 세 호스트를 실제로 띄워 볼 수 있습니다.

dotnet run --project samples/Merc.Sample.Local   # 콘솔. 네 줄 출력하고 끝납니다
dotnet run --project samples/Merc.Sample.Api     # http://localhost:5100 — API 제공
dotnet run --project samples/Merc.Sample.Web     # http://localhost:5200 — GET /blogs, GET /blogs/1

1. 빠른 시작

Data 프로젝트(엔티티·DbContext 가 있는 클래스 라이브러리)에 다음 순서로 더합니다. 손으로 쓰는 파일의 빈자리는 골격 도구가 첫 빌드에서 만들어 두므로, 사람은 TODO 를 채우는 것부터 시작합니다.

이 문서의 파일 이름과 네임스페이스에 쓰는 표기는 네 가지입니다. 「예」 열은 샘플 Merc.Sample.Data 의 실제 이름입니다.

표기
{DbContext} DbContext 클래스 이름 그대로 SampleDbContext
{Db} {DbContext} 에서 접미사 DbContext 를 뗀 것 Sample
{E} 엔티티 클래스 이름 Blog
{어셈블리 이름} Data 프로젝트의 AssemblyName Merc.Sample.Data

1-1. 참조

<ItemGroup>
  <PackageReference Include="Merc" Version="0.2.1" />
</ItemGroup>

한 줄입니다. 런타임·소스 제네레이터·골격 도구·빌드 훅이 모두 이 패키지에 들어 있습니다. Data 프로젝트는 일반 Microsoft.NET.Sdk 클래스 라이브러리여도 됩니다. ASP.NET Core 프레임워크 참조는 패키지가 가져옵니다.

1-2. 첫 빌드 — 골격이 생깁니다

DbContext 와 엔티티가 있는 상태에서 한 번 빌드합니다. 빌드 훅이 골격 도구를 불러, 없는 파일을 만듭니다. 이 빌드는 MRC017 오류로 멈추는 것이 정상입니다. 공통 구현의 생성자가 비어 있기 때문이며, 다음 단계에서 채웁니다.

만들어지는 파일 담긴 것
Repositories/{Db}GenericRepository.cs SampleGenericRepository.cs 공통 계약 I{Db}GenericRepository<T> 와 기반 클래스. 생성자·GetDbContext 구현이 TODO
{DbContext 파일과 같은 경로}/{DbContext}.ext.cs SampleDbContext.ext.cs 주석 처리된 [RemotableDbContext]. 원격이 필요할 때 주석을 풉니다
EntityExt/{E}.cs EntityExt/Blog.cs 빈 키 인터페이스 I{E}Key : IEntityKey 와 partial 엔티티. 키 프로퍼티가 TODO
Repositories/{E}Repository.cs BlogRepository.cs 엔티티 특화 메서드를 둘 자리
Repositories/Endpoint/{E}RepositoryEndpointGroup.cs Endpoint/BlogRepositoryEndpointGroup.cs 손 작성 라우트를 둘 자리. 원격을 켠 뒤, 공통 구현의 TODO 를 채운 다음 빌드에 생깁니다

DbContext 와 엔티티는 partial 이어야 합니다. 아니면 그 파일은 만들지 않고 빌드 출력에 「partial 로 바꾸십시오」 라고만 알립니다. 자세한 규칙은 6. 골격 도구에 있습니다.

1-3. 네임스페이스 규약

제네레이터는 손으로 쓴 partial 을 어셈블리 이름으로 만든 네임스페이스에서 찾습니다. 골격 도구가 그 규약대로 만들어 두므로 보통은 신경 쓸 일이 없지만, 파일을 옮기거나 직접 만들 때는 지켜야 합니다.

파일 네임스페이스 샘플에서
Repositories/{Db}GenericRepository.cs {어셈블리 이름}.Repositories Merc.Sample.Data.Repositories
Repositories/{E}Repository.cs {어셈블리 이름}.Repositories Merc.Sample.Data.Repositories
Repositories/Endpoint/{E}RepositoryEndpointGroup.cs {어셈블리 이름}.Repositories.Endpoint Merc.Sample.Data.Repositories.Endpoint
EntityExt/{E}.cs 엔티티와 같은 네임스페이스 Merc.Sample.Data.Entities
{DbContext}.ext.cs DbContext 와 같은 네임스페이스 Merc.Sample.Data.Contexts

표기의 뜻은 1절 첫머리의 표와 같습니다. 어셈블리 이름이 Merc.Sample.Data 면 계약과 기반은 Merc.Sample.Data.Repositories 에 있어야 합니다. 어긋나면 오류 없이 생성물이 0개가 됩니다. RootNamespace 를 어셈블리 이름과 다르게 두었다면 Repositories/ 아래만은 어셈블리 이름을 따르십시오. 골격 도구도 csproj 의 <AssemblyName> 을 루트로 쓰고, 없으면 csproj 파일 이름을 씁니다.

1-4. 공통 계약과 구현 — Repositories/{Db}GenericRepository.cs

한 파일에 계약 인터페이스와 기반 클래스가 함께 있습니다. 제네레이터가 내는 절반은 이름·제약·계약 인터페이스 구현 선언과 GetDbContext·GetDbContextAsync partial 메서드의 선언뿐입니다. 그래서 Merc 는 DbContext 를 어떻게 얻는지 모릅니다. 팩터리든 생성자 주입이든 상관하지 않습니다.

계약. 모든 Repository 가 공유할 메서드를 선언합니다. 여기 선언한 것이 곧 원격 계약이 되므로, 메서드마다 원격으로 나가는지가 갈립니다. 판정 규칙은 4. 원격으로 나가는 것과 나가지 않는 것에 있습니다.

using System.Linq.Expressions;
using Merc.Attributes;
using Merc.Sample.Data.Contexts;
using Microsoft.EntityFrameworkCore;

namespace Merc.Sample.Data.Repositories;

public partial interface ISampleGenericRepository<TEntity>
    where TEntity : class
{
    #region 조회 (Read)

    // 원격 O — 매개변수가 없고 반환이 Task<List<TEntity>> 라 그대로 옮겨집니다.
    Task<List<TEntity>> GetAllAsync();

    // 원격 X (자동 제외) — params object[] 는 타입 정보를 잃어 복원할 수 없습니다.
    Task<TEntity?> GetByIdAsync(params object[] id);

    // 원격 X (자동 제외) — 표현식 트리는 JSON 으로 옮길 수 없습니다.
    Task<TEntity?> GetFirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate);

    // 원격 X (자동 제외) — 표현식 트리.
    Task<List<TEntity>> GetWhereAsync(Expression<Func<TEntity, bool>> predicate);

    // 원격 X (자동 제외) — 제네릭 메서드는 호출 시점에야 타입이 정해지므로 엔드포인트를 만들 수 없습니다.
    Task<List<TProjection>> GetWhereAsync<TProjection>(
        Expression<Func<TEntity, bool>> predicate,
        Expression<Func<TEntity, TProjection>> selector);

    #endregion

    #region 추가 (Create)

    // 원격 O — 엔티티를 요청 본문에 싣습니다.
    Task<TEntity> AddAsync(TEntity entity);

    // 원격 O — 목록도 그대로 옮겨집니다.
    Task AddManyAsync(IEnumerable<TEntity> entities);

    #endregion

    #region 수정 (Update)

    // 원격 O
    Task UpdateAsync(TEntity entity);

    // 원격 X (명시 제외) — 시그니처만 보면 옮길 수 있지만, 일괄 수정은 DB 직접 접근 호스트에서만 허용하기로 정했습니다.
    // [NotRemotable] 이 붙은 메서드는 엔드포인트도 원격 구현도 생성되지 않습니다.
    // 원격 호스트에서 부르면 아래 사유를 담은 NotSupportedException 이 납니다.
    [NotRemotable("일괄 수정은 DB 직접 접근 호스트에서만 허용합니다.")]
    Task UpdateManyAsync(IEnumerable<TEntity> entities);

    #endregion

    #region 삭제 (Delete)

    // 원격 O
    Task DeleteAsync(TEntity entity);

    // 원격 X (명시 제외) — UpdateManyAsync 와 같은 이유입니다.
    [NotRemotable("일괄 삭제는 DB 직접 접근 호스트에서만 허용합니다.")]
    Task<int> DeleteManyAsync(IEnumerable<TEntity> entities);

    // 원격 X (자동 제외) — 표현식 트리.
    Task<int> DeleteWhereAsync(Expression<Func<TEntity, bool>> predicate);

    #endregion

    #region 유틸리티

    // 원격 X (자동 제외) — 표현식 트리.
    Task<bool> ExistsAsync(Expression<Func<TEntity, bool>> predicate);

    // 원격 O
    Task<int> CountAsync();

    // 원격 X (자동 제외) — 표현식 트리. 같은 이름의 무인자 CountAsync 만 원격으로 나갑니다.
    Task<int> CountAsync(Expression<Func<TEntity, bool>> predicate);

    #endregion
}

구현. 같은 파일 아래쪽입니다. 골격이 비워 둔 생성자와 두 partial 메서드를 채우고, 계약의 멤버를 구현합니다. 생성자 매개변수는 엔티티별 Repository(BlogRepository 등)의 생성자로 그대로 전달됩니다. 생성자가 없으면 MRC017, partial 구현이 없으면 CS8795 로 빌드가 멈춥니다.

public abstract partial class SampleGenericRepository<TEntity>
    where TEntity : class
{
    protected SampleGenericRepository(IDbContextFactory<SampleDbContext> contextFactory)
        => ContextFactory = contextFactory;

    /// <summary>타입 있는 컨텍스트가 필요한 엔티티 특화 메서드가 씁니다.</summary>
    protected IDbContextFactory<SampleDbContext> ContextFactory { get; }

    // partial 선언은 제네레이터가 냅니다. 구현을 빠뜨리면 CS8795 로 빌드가 깨집니다.
    protected partial DbContext GetDbContext()
        => ContextFactory.CreateDbContext();

    protected async partial Task<DbContext> GetDbContextAsync()
        => await ContextFactory.CreateDbContextAsync();

    public async Task<List<TEntity>> GetAllAsync()
    {
        await using var context = await GetDbContextAsync();
        return await context.Set<TEntity>().ToListAsync();
    }

    public async Task<TEntity> AddAsync(TEntity entity)
    {
        await using var context = await GetDbContextAsync();
        context.Add(entity);
        await context.SaveChangesAsync();
        return entity;
    }

    // … 나머지 메서드는 samples/Merc.Sample.Data/Repositories/SampleGenericRepository.cs 참고
}

1-5. 엔티티에 키를 답니다 — EntityExt/{E}.cs

골격이 빈 키 인터페이스를 만들어 두었습니다. 키 프로퍼티를 선언하면 GetByKeyAsync 가 생깁니다. 엔티티 클래스는 partial 이어야 하고, 키 인터페이스는 클래스 정의 밖에 internal 로 둡니다.

using Merc.Repositories;

namespace Merc.Sample.Data.Entities;

/// <summary>키를 선언하면 <c>GetByKeyAsync</c> 가 생성됩니다.</summary>
internal interface IBlogKey : IEntityKey
{
    int Id { get; }          // 선언 순서가 곧 GetByKeyAsync 의 매개변수 순서
}

public partial class Blog : IBlogKey;

복합 키는 프로퍼티를 둘 이상 선언하면 됩니다. 따로 표기할 것이 없습니다. 키 인터페이스가 다른 키 인터페이스를 상속하면 기반의 프로퍼티가 먼저, 그다음 파생의 프로퍼티가 선언 순서대로 옵니다.

프로퍼티를 하나도 선언하지 않고 두면 GetByKeyAsync 가 빠지고 MRC022 경고가 납니다. 키가 없는 엔티티라면 인터페이스와 : I{E}Key 를 지우십시오. 그래도 Repository 는 생깁니다.

// samples/Merc.Sample.Data/EntityExt/AuditLog.cs — 키 없음, GetByKeyAsync 없음
public partial class AuditLog;

1-6. 원격이 필요하면 .ext.cs 의 주석을 풉니다

골격이 DbContext 파일과 같은 경로에 만들어 둔 {DbContext}.ext.cs 에서 [RemotableDbContext] 의 주석을 해제합니다. DbContext 본체는 partial 이어야 합니다.

// samples/Merc.Sample.Data/Contexts/SampleDbContext.ext.cs
using Merc.Attributes;

namespace Merc.Sample.Data.Contexts;

[RemotableDbContext]          // 원격 Repository(계약·엔드포인트·HTTP 클라이언트)를 생성합니다
public partial class SampleDbContext
{
}

이 특성이 없으면 로컬 구현까지만 생깁니다. AddRepositoryApiHost·AddRepositoryApiClient 도 생성되지 않으므로, 부르면 컴파일이 깨집니다. 조용히 넘어가지 않습니다. 특성을 켜면 다음 빌드에 Repositories/Endpoint/{E}RepositoryEndpointGroup.cs 골격도 함께 생깁니다.

「직접 선언한」 DbSet 만 봅니다. 기반 클래스가 물려주는 IdentityDbContextDbSet<UserRole> 등은 대상이 아닙니다. DbContext 가 어느 폴더에 있는지는 상관없습니다.

1-7. 빌드합니다

나머지는 전부 생성됩니다. 엔티티마다 I{E}Repository·{E}Repository 와, 원격이면 계약·엔드포인트· HTTP 클라이언트 구현이 obj/…/generated/ 에 생깁니다. 엔티티 특화 메서드는 골격 도구가 만들어 둔 Repositories/{E}Repository.cs 에 씁니다.


2. Program.cs — 셋 중 하나를 부릅니다

생성된 등록 확장 세 개는 {어셈블리 이름}.Extensions 네임스페이스에 있습니다. 엔드포인트 매핑 확장 MapRepositoryEndpointGroupsMerc.Endpoint 에 있습니다.

2-1. DB 직접 접근 — samples/Merc.Sample.Local

using Merc.Sample.Data.Contexts;
using Merc.Sample.Data.Extensions;   // 생성된 AddLocalRepositories 가 사는 곳 — {어셈블리}.Extensions
using Merc.Sample.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddDbContextFactory<SampleDbContext>(options => options.UseInMemoryDatabase("Sample"));
services.AddLocalRepositories();

await using var provider = services.BuildServiceProvider();
var blogs = provider.GetRequiredService<IBlogRepository>();

var added = await blogs.AddAsync(new Blog { Name = "첫 블로그" });
var found = await blogs.GetByKeyAsync(added.Id);

DbContext 팩터리는 소비자가 등록합니다. 공통 구현의 생성자가 받는 것과 같은 것이어야 합니다. [NotRemotable] 로 제외한 메서드도 이 호스트에서는 정상으로 돕니다.

2-2. Web API 접근 — samples/Merc.Sample.Web

using Merc.Sample.Data.Extensions;   // 생성된 AddRepositoryApiClient — {어셈블리}.Extensions
using Merc.Sample.Web.Services;

var builder = WebApplication.CreateBuilder(args);

// 주소는 엔드포인트 접두사(/api/repo)까지 담는다.
// 돌려주는 IHttpClientBuilder 로 원격 구현이 쓰는 HttpClient 에 헤더·핸들러·복원 정책을 더한다.
builder.Services
    .AddRepositoryApiClient(
        builder.Configuration["DataAccess:RemoteRepositoryBaseAddress"]
        ?? throw new InvalidOperationException("'DataAccess:RemoteRepositoryBaseAddress' 설정이 없습니다."))
    .ConfigureHttpClient(client =>
        client.DefaultRequestHeaders.Add("X-Api-Key", builder.Configuration["Merc:ApiKey"]));

builder.Services.AddScoped<BlogService>();

var app = builder.Build();
app.MapGet("/blogs/{id:int}", async (int id, BlogService service) =>
    await service.FindAsync(id) is { } blog ? Results.Ok(blog) : Results.NotFound());
app.Run();
{
  "DataAccess": { "RemoteRepositoryBaseAddress": "http://localhost:5100/api/repo" },
  "Merc": { "ApiKey": "sample-api-key" }
}

이 프로젝트에는 DbContext 도, EF 공급자도, 연결 문자열도 없습니다. Data 프로젝트를 참조하는 것은 계약과 생성된 원격 구현을 얻기 위해서입니다.

2-3. API 제공 — samples/Merc.Sample.Api

2-2 가 부를 API 를 내주는 쪽입니다. DB 에 직접 접근하면서 그 Repository 를 엔드포인트로 엽니다.

using Merc.Endpoint;                 // MapRepositoryEndpointGroups
using Merc.Sample.Api.Authorization;
using Merc.Sample.Data.Contexts;
using Merc.Sample.Data.Extensions;   // 생성된 AddRepositoryApiHost — {어셈블리}.Extensions
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContextFactory<SampleDbContext>(options => options.UseInMemoryDatabase("Sample"));
// 로컬 구현 + 엔드포인트 그룹 + 인가자. 인가자를 생략한 AddRepositoryApiHost() 는 전부 허용한다.
builder.Services.AddRepositoryApiHost<ApiKeyRepositoryAuthorizer>();

var app = builder.Build();
app.MapRepositoryEndpointGroups();   // 기본 접두사 /api/repo 아래로 전부 열린다
app.Run();

기본은 전부 허용입니다. AddRepositoryApiHost() 는 모든 요청을 통과시키는 AllowAnonymousAuthorizer 를 등록합니다. 인가자를 두려면 제네릭 오버로드 AddRepositoryApiHost<TAuthorizer>() 를 부릅니다. 인가자는 호스트 전역이라 Data 어셈블리가 여럿이어도 하나입니다. 기본 인가자는 명시 인가자에 양보하고, 서로 다른 명시 인가자를 둘 등록하려 하면 등록 시점에 예외가 납니다. 한 어셈블리에서만 제네릭 오버로드를 부르고 나머지는 비제네릭을 부르십시오.

인가자는 IRepositoryAuthorizer 하나를 구현합니다. 모든 Repository 엔드포인트에 그룹 단위로 걸리며, RepositoryCallDescriptor 로 어느 계약의 어느 메서드가 불리는지 알 수 있습니다. 샘플에 두 가지 구현이 있습니다.

API 키 헤더 — 샘플이 등록하는 인가자입니다. 헤더 하나를 설정값과 비교합니다.

// samples/Merc.Sample.Api/Authorization/ApiKeyRepositoryAuthorizer.cs
using Merc.Authorization;

public sealed class ApiKeyRepositoryAuthorizer(IConfiguration configuration, ILogger<ApiKeyRepositoryAuthorizer> logger)
    : IRepositoryAuthorizer
{
    private const string HeaderName = "X-Api-Key";

    public ValueTask<bool> AuthorizeAsync(HttpContext httpContext, RepositoryCallDescriptor call)
    {
        var expected = configuration["Merc:ApiKey"];
        var presented = httpContext.Request.Headers[HeaderName].ToString();
        var allowed = !string.IsNullOrEmpty(expected) && presented == expected;

        if (!allowed)
            logger.LogWarning("인가 거부: {Group}/{Endpoint} ({Contract}.{Method})",
                call.EndpointGroupName, call.EndpointName, call.ContractTypeName, call.MethodName);

        return ValueTask.FromResult(allowed);
    }
}

역할 기반 — ASP.NET Core 인증을 거친 사용자를 전제로, RepositoryCallDescriptor 의 메서드 이름으로 정책을 가릅니다. 쓰기 메서드는 Admin 역할을, 나머지는 인증만 요구합니다.

// samples/Merc.Sample.Api/Authorization/RoleRepositoryAuthorizer.cs
using Merc.Authorization;

public sealed class RoleRepositoryAuthorizer : IRepositoryAuthorizer
{
    private const string WriterRole = "Admin";

    private static readonly string[] WritePrefixes = ["Add", "Update", "Delete"];

    public ValueTask<bool> AuthorizeAsync(HttpContext httpContext, RepositoryCallDescriptor call)
    {
        var user = httpContext.User;
        if (user.Identity?.IsAuthenticated != true)
            return ValueTask.FromResult(false);

        var isWrite = WritePrefixes.Any(prefix => call.MethodName.StartsWith(prefix, StringComparison.Ordinal));
        return ValueTask.FromResult(!isWrite || user.IsInRole(WriterRole));
    }
}

바꿔 끼우려면 AddRepositoryApiHost<RoleRepositoryAuthorizer>() 로 등록하고, 인증 미들웨어가 먼저 돌도록 app.UseAuthentication()MapRepositoryEndpointGroups() 앞에 둡니다. 계약 타입 이름 (ContractTypeName)으로 Repository 별 정책을 갈라도 됩니다.

어느 인가자든 거부하면 403 으로 응답합니다.

2-4. 등록 규칙

  • 셋 중 하나만 부릅니다. API 제공과 Web API 접근을 한 호스트에서 함께 부르면 엔드포인트가 자기 자신을 호출하게 되므로 등록 시점에 예외로 막습니다.
  • API 제공·Web API 접근 확장은 [RemotableDbContext] 를 붙인 어셈블리에만 생성됩니다.
  • 인가자는 호스트에 하나입니다. AddRepositoryApiHost<TAuthorizer>() 는 한 Data 어셈블리에서만 부릅니다.
  • 한 호스트가 환경에 따라 방식을 오갈 수도 있습니다. 바뀌는 것은 이 분기뿐입니다.
if (builder.Environment.IsDevelopment())
{
    builder.Services.AddDbContextFactory<SampleDbContext>(…);
    builder.Services.AddLocalRepositories();                  // 개발은 DB 직접 접근
}
else
{
    builder.Services.AddRepositoryApiClient(baseAddress);     // 그 외는 Web API 접근
}

3. 사람이 쓰는 것과 생성되는 것

flowchart TD
    subgraph Hand ["사람이 쓰는 것 (골격은 도구가 만든다)"]
        Ctx["{DbContext}.cs<br/>DbSet&lt;T&gt; 선언"]
        Ext["{DbContext}.ext.cs<br/>[RemotableDbContext] 켜기"]
        Key["EntityExt/{E}.cs<br/>I{E}Key : IEntityKey"]
        Base["Repositories/{Db}GenericRepository.cs<br/>공통 계약 · 공통 구현 · 생성자 · GetDbContext 구현"]
        Skel["Repositories/{E}Repository.cs<br/>엔티티 특화 메서드 (선택)"]
        Routes["Repositories/Endpoint/{E}RepositoryEndpointGroup.cs<br/>손 작성 라우트 (선택)"]
    end

    subgraph Gen ["빌드마다 생성되는 것"]
        GBase["{Db}GenericRepository.g.cs<br/>이름·제약 · partial 메서드 선언"]
        Local["{E}.LocalRepository.g.cs<br/>I{E}Repository · {E}Repository"]
        Contract["{E}.Contracts.g.cs<br/>경로 상수 · 요청/응답 형식"]
        Endpoint["{E}.EndpointGroup.g.cs"]
        Remote["{E}.RemoteRepository.g.cs"]
        Reg["RepositoryRegistrationExtensions.g.cs<br/>등록 확장 셋"]
    end

    Ctx --> Local
    Key --> Local
    Base --> GBase
    Ext -->|"[RemotableDbContext]"| Contract
    Contract --> Endpoint
    Contract --> Remote
    Local --> Reg
    Remote --> Reg
    Skel -.->|partial 병합| Local
    Routes -.->|partial 병합| Endpoint

    Scaffold["merc-scaffold<br/>빌드 전, 없을 때만"] -.-> Ext
    Scaffold -.-> Key
    Scaffold -.-> Base
    Scaffold -.-> Skel
    Scaffold -.-> Routes

생성 조건은 세 가지이며 서로 독립입니다.

무엇이 생기는가 무엇으로 정해지는가
Repository (I{E}Repository · {E}Repository) DbContext 가 직접 선언한 DbSet<T>
GetByKeyAsync 엔티티가 IEntityKey 파생 인터페이스를 구현하는가
계약 · 엔드포인트 · 원격 구현 DbContext 에 [RemotableDbContext] 가 붙었는가

엔티티 특화 메서드 — Repositories/{E}Repository.cs

골격 도구가 만들어 둔 파일에 씁니다. 이후로는 손으로 관리하며, 다시 만들어지지 않습니다.

// samples/Merc.Sample.Data/Repositories/BlogRepository.cs
using Merc.Sample.Data.Dtos;
using Merc.Sample.Data.Entities;

namespace Merc.Sample.Data.Repositories;

public partial interface IBlogRepository
{
    /// <summary>블로그 목록을 게시글 수와 함께 조회합니다. 평탄한 DTO 라 원격으로 그대로 나갑니다.</summary>
    Task<List<BlogSummary>> GetSummariesAsync();

    /// <summary>이름으로 블로그 하나를 찾습니다.</summary>
    Task<Blog?> GetByNameAsync(string name);
}

public partial class BlogRepository
{
    // 공통 계약의 투영 메서드를 재사용합니다. Include 를 쓰지 않으므로 순환이 생기지 않습니다.
    public async Task<List<BlogSummary>> GetSummariesAsync() =>
        await GetWhereAsync(_ => true, x => new BlogSummary(x.Id, x.Name, x.Posts.Count));

    public async Task<Blog?> GetByNameAsync(string name) =>
        await GetFirstOrDefaultAsync(x => x.Name == name);
}

타입 있는 컨텍스트가 필요하면 공통 구현의 ContextFactory 를 그대로 씁니다. GetDbContextAsync() 는 반환이 DbContext 라 생성분 전용입니다.

// samples/Merc.Sample.Data/Repositories/PostRepository.cs — 일부
public async Task<List<PostRow>> GetRowsByBlogAsync(int blogId)
{
    await using var context = await ContextFactory.CreateDbContextAsync();
    return await context.Posts
        .Where(x => x.BlogId == blogId)
        .Select(x => new PostRow(x.Id, x.Title, x.BlogId, x.Blog.Name))
        .ToListAsync();
}

엔드포인트 그룹에 라우트 더하기

생성된 {E}RepositoryEndpointGrouppartial 클래스이고 partial void AddAdditionalRoutes(RouteGroupBuilder) 를 구현하면 같은 그룹에 손 작성 라우트를 더할 수 있습니다. 골격 도구가 원격을 켠 뒤 Repositories/Endpoint/{E}RepositoryEndpointGroup.cs 에 빈 구현을 만들어 두므로 거기에 씁니다. 그룹에는 인가 필터가 걸려 있습니다. 추가 라우트에 RepositoryCallDescriptor 메타데이터나 .AllowAnonymous() 를 붙이지 않으면 필터가 요청 시점에 InvalidOperationException 을 던집니다.

using Merc.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace Merc.Sample.Data.Repositories.Endpoint;

public partial class BlogRepositoryEndpointGroup
{
    partial void AddAdditionalRoutes(RouteGroupBuilder routeGroupBuilder)
    {
        routeGroupBuilder.MapGet("/Ping", () => TypedResults.Ok("pong"))
            .WithMetadata(new RepositoryCallDescriptor(
                EndpointGroupName, "Ping", typeof(IBlogRepository).FullName!, "Ping"));
    }
}

어셈블리 이름이 Merc. 로 시작하면 ResultsMerc.Results 네임스페이스로 해석되므로 TypedResults 를 씁니다.


4. 원격으로 나가는 것과 나가지 않는 것

계약 멤버는 시그니처 모양으로 갈립니다. 이름 규칙은 보지 않습니다.

원격에서 빠지는 이유
LINQ 표현식 트리 GetWhereAsync(Expression<Func<T,bool>>)
델리게이트 GetAllAsync(Func<IQueryable<T>, IQueryable<T>>?)
params object[] GetByIdAsync(params object[] id)
제네릭 메서드 GetWhereAsync<TProjection>(…)
비동기가 아님 int Count()
명시적 제외 [NotRemotable("사유")]

빠진 멤버는 엔드포인트가 생기지 않고, 원격 구현에서는 부르면 사유를 담은 NotSupportedException 을 던지는 스텁이 됩니다. DB 직접 접근 호스트에서는 정상으로 돕니다. 빌드 시 MRC012 정보 진단에 계약별 제외 목록과 사유가 실립니다 (dotnet build -v detailed).

생성을 정하는 특성

특성 붙는 곳 하는 일 샘플
[RemotableDbContext] DbContext 클래스 계약·엔드포인트·원격 구현 생성을 켭니다. 없으면 DB 직접 접근 구현만 생깁니다 SampleDbContext.ext.cs
[NotRemotable("사유")] 계약 메서드 · 계약 인터페이스 원격에서 뺍니다. 범위는 붙이는 곳에 따라 다르며 아래 표에 있습니다. 사유는 진단과 예외 메시지에 실립니다 UpdateManyAsync, DeleteByBlogAsync

[NotRemotable] 은 인터페이스와 메서드에만 붙습니다. 엔티티에는 붙이지 않는 것이 원칙이고 컴파일러가 강제합니다.

[NotRemotable] 을 어디에 붙이느냐에 따라 생성 범위가 다릅니다.

붙이는 곳 영향 범위 생성되는 것 생성되지 않는 것 샘플
공통 계약의 메서드 (I{Db}GenericRepository<T> 안) 모든 엔티티의 그 메서드 DB 직접 접근 구현. 원격 구현에는 사유를 던지는 스텁 그 메서드의 엔드포인트와 요청·응답 형식 ISampleGenericRepository<T>.UpdateManyAsync
엔티티 계약의 메서드 (I{E}Repository 안) 그 엔티티의 그 메서드만 위와 같음 위와 같음 IPostRepository.DeleteByBlogAsync
엔티티 계약 인터페이스 (I{E}Repository 자체) 그 엔티티 전체 DB 직접 접근 구현만 계약·엔드포인트 그룹·원격 구현 파일 전부. AddRepositoryApiClient 에도 등록되지 않으므로 Web API 접근 호스트에서 이 Repository 를 주입받으면 DI 해석에 실패합니다 샘플에 없음
공통 계약 인터페이스 (I{Db}GenericRepository<T> 자체) 없음 전부 그대로 없음 MRC023 경고가 납니다. 메서드에 붙이거나 [RemotableDbContext] 를 떼십시오

이름만 바꾸는 특성 — 선택 사항

둘 다 필수가 아닙니다. 붙이지 않으면 기본 이름으로 엔드포인트가 생깁니다. 샘플에서는 IPostRepository 만 이름을 바꿨고 IBlogRepository·IAuditLogRepository 는 기본 이름을 씁니다.

특성 붙는 곳 하는 일 붙이지 않으면 샘플
[RemotableEndpointGroup("이름")] I{E}Repository 엔드포인트 그룹 이름을 바꿉니다 {Db 소문자}/{E}sample/Blog IPostRepositoryposts
[RemotableEndpoint("이름")] 계약 메서드 엔드포인트 이름을 바꿉니다 메서드 이름에서 Async 를 뗀 것 — GetSummariesAsyncGetSummaries GetRowsByBlogAsyncByBlog
// samples/Merc.Sample.Data/Repositories/PostRepository.cs — 인터페이스 부분
[RemotableEndpointGroup("posts")]
public partial interface IPostRepository
{
    [RemotableEndpoint("ByBlog")]
    Task<List<PostRow>> GetRowsByBlogAsync(int blogId);

    [NotRemotable("블로그 단위 삭제는 DB 직접 접근 호스트에서만 허용합니다.")]
    Task<int> DeleteByBlogAsync(int blogId);
}

5. 엔드포인트와 응답 형식

Merc 클라이언트가 아닌 도구로 API 를 부르거나, 역방향 프록시·인가 규칙을 쓸 때 필요한 정보입니다.

모든 엔드포인트는 POST 이고 경로는 다음 모양입니다.

POST {접두사}/{그룹}/{엔드포인트}

POST /api/repo/sample/Blog/GetByKey     ← 기본 그룹 이름
POST /api/repo/posts/ByBlog             ← [RemotableEndpointGroup]·[RemotableEndpoint] 로 바꾼 이름
  • 접두사 기본값은 /api/repo 입니다. MapRepositoryEndpointGroups("/다른/접두사") 로 바꿀 수 있고, 그러면 클라이언트의 RemoteRepositoryBaseAddress 도 같이 바꿔야 합니다.
  • 요청 본문은 메서드 매개변수를 camelCase 프로퍼티로 담은 JSON 입니다. GetByKeyAsync(int id){"id":1}, AddAsync(Blog entity){"entity":{…}} 입니다.
  • 응답은 항상 다음 형식의 래퍼(OperationResult)입니다. 생성된 클라이언트가 이것을 벗겨 계약의 반환 타입으로 돌려줍니다.
{
  "value": [ { "id": 1, "title": "인사", "blogId": 1, "blogName": "첫 블로그" } ],
  "succeeded": true,
  "errorMessage": null,
  "fieldErrors": null
}

원격 호출이 실패하면 생성된 클라이언트가 예외로 바꿉니다. 둘 다 Merc.Remote 에 있습니다.

예외 언제
RemoteRepositoryValidationException 응답이 fieldErrors 를 담은 검증 실패
RemoteRepositoryException 연결 실패·시간 초과·succeeded: false·값 누락

인가 거부(403)도 RemoteRepositoryException 으로 옵니다.


6. 골격 도구 (merc-scaffold)

사람이 써야 하는 파일의 빈자리를 없을 때만 만드는 도구입니다. 패키지의 빌드 훅이 소비자 빌드 전에 알아서 부릅니다. 이미 있는 파일은 절대 건드리지 않으므로 손으로 쓴 내용이 지워지지 않습니다. 프로젝트를 MSBuild 로 열지 않고 *.cs 를 구문 트리로만 읽으므로 폴더 규약에 묶이지 않습니다.

무엇을 어디에 만드는가

종류 경로 샘플에서 조건
공통 계약·기반 Repositories/{Db}GenericRepository.cs Repositories/SampleGenericRepository.cs DbContext 마다 하나
DbContext 확장 {DbContext 파일과 같은 경로}/{DbContext}.ext.cs Contexts/SampleDbContext.ext.cs DbContext 가 partial 일 때
엔티티 확장 EntityExt/{E}.cs EntityExt/Blog.cs 엔티티 선언이 이 프로젝트에 있고 partial 일 때
엔티티 Repository Repositories/{E}Repository.cs Repositories/BlogRepository.cs 엔티티마다 하나
엔드포인트 그룹 Repositories/Endpoint/{E}RepositoryEndpointGroup.cs Repositories/Endpoint/BlogRepositoryEndpointGroup.cs DbContext 에 [RemotableDbContext] 가 있고 I{E}Repository[NotRemotable] 이 없을 때
  • DbContextbin/·obj/ 를 뺀 모든 *.cs 에서 DbSet<T>직접 선언한 클래스입니다. 폴더는 상관없습니다. 기반 클래스가 물려주는 DbSet 은 세지 않습니다.
  • 엔티티 는 그 DbSet<T>T 전부입니다. 다른 어셈블리의 타입이면 Repository 골격은 만들고 엔티티 확장만 건너뜁니다.
  • 엔드포인트 그룹의 조건은 제네레이터가 엔드포인트를 내는 조건과 같습니다. 특성은 구문의 특성 목록만 보므로 주석 처리된 [RemotableDbContext] 는 세지 않습니다. .ext.cs 의 주석을 풀면 다음 빌드에 생깁니다.
  • 네임스페이스는 {어셈블리 이름}.Repositories.Endpoint)이고, 엔티티 확장과 DbContext 확장은 각각 원본 선언의 네임스페이스를 따릅니다. 어셈블리 이름은 csproj 의 <AssemblyName> 에서 읽고, 없거나 $(...) 처럼 평가가 필요하면 csproj 파일 이름을 씁니다. RootNamespace 속성은 읽지 않습니다. 제네레이터가 어셈블리 이름으로 손 작성 partial 을 찾으므로 둘은 같아야 합니다.

첫 빌드의 흐름

  1. 참조를 더하고 빌드합니다. 골격 다섯 종류 중 조건에 맞는 것이 생깁니다. 엔드포인트 그룹은 아직 원격이 꺼져 있어 생기지 않습니다.
  2. 같은 빌드가 MRC017 로 멈춥니다. 공통 기반의 생성자가 비어 있기 때문입니다. EntityExt/ 의 빈 키 인터페이스마다 MRC022 경고도 함께 납니다.
  3. Repositories/{Db}GenericRepository.cs 의 TODO(생성자·GetDbContext 구현·계약 멤버)와 EntityExt/{E}.cs 의 키 프로퍼티를 채웁니다. 빌드가 통과합니다.
  4. 원격이 필요하면 .ext.cs 의 주석을 풉니다. 다음 빌드에 엔드포인트 그룹 골격이 생깁니다.

DbContext 에 이미 [RemotableDbContext] 가 있는 프로젝트에 처음 적용할 때도 순서는 같습니다. 공통 구현 골격을 처음 만드는 빌드에서는 엔드포인트 그룹 골격을 만들지 않고 「TODO 를 채운 다음 빌드에 만듭니다」 라고만 알립니다. 그 빌드는 어차피 MRC017 로 멈춰 생성물이 없으므로, 엔드포인트 골격이 있으면 CS0759 까지 겹쳐 붉어지기 때문입니다. TODO 를 채운 다음 빌드에 생깁니다.

만들지 않고 알리기만 하는 경우

빌드 출력에 merc-scaffold: 접두사로 나옵니다.

  • DbContext 나 엔티티가 partial 이 아니면 그 확장 골격은 만들지 않습니다. partial 로 바꾸면 다음 빌드에 만들어집니다.
  • 엔드포인트 그룹 골격이 있는데 조건이 사라지면(특성을 도로 주석 처리했거나 계약에 [NotRemotable] 을 붙였거나) 생성물이 없어져 CS0759 가 납니다. 도구는 「더 이상 대상이 아니니 지우십시오」 라고 알리기만 하고 지우지 않습니다.

끄기와 검사

끄려면 프로젝트에 한 줄 둡니다. 골격을 전부 손으로 관리하는 경우입니다.

<PropertyGroup>
  <MercScaffoldEnabled>false</MercScaffoldEnabled>
</PropertyGroup>

CI 에서 만들지 않고 빠진 것만 확인하려면 도구를 직접 부릅니다.

dotnet <패키지 경로>/tools/merc-scaffold.dll --project <Data 프로젝트 디렉터리> --check
종료 코드
0 빠진 골격 없음 (또는 만들었음)
1 --check 에서 빠진 골격 발견
2 인자 오류 — 디렉터리나 .csproj 가 없음

7. DB 직접 접근과 Web API 접근이 다른 점

생성기로 감출 수 없는 차이입니다. 원격으로 돌리기 전에 확인하십시오.

주제 DB 직접 접근 Web API 접근
트랜잭션 여러 Repository 를 묶을 수 있음 불가
엔티티 추적 EF 변경 추적이 살아 있음 사라짐
예외 DbUpdateException 등 EF 예외 그대로 RemoteRepositoryException·RemoteRepositoryValidationException
제외된 메서드 정상 동작 NotSupportedException
지연·실패 사실상 없음 네트워크 왕복. 재시도 없음

8. 진단

코드 심각도 언제
MRC002 경고 엔티티 타입 그래프에 순환이 있음
MRC003 오류 원격 시그니처에 JSON 으로 옮길 수 없는 타입이 있음
MRC004 오류 엔드포인트 이름 오버라이드가 비었거나 {}·/ 를 담음
MRC005 오류 한 계약 안에서 엔드포인트 경로가 겹침
MRC010 오류 Merc.Core 미참조 — 생성 코드가 컴파일되지 않음
MRC012 정보 계약별 원격·제외 목록과 사유 (-v detailed)
MRC017 오류 Repositories/{Db}GenericRepository.cs 의 손 작성 partial 이 없거나 생성자가 비어 있음. 골격 직후의 첫 빌드가 여기서 멈춤
MRC021 오류 IEntityKey 파생 인터페이스를 둘 이상 구현해 키가 모호함
MRC022 경고 키 인터페이스에 프로퍼티가 없어 GetByKeyAsync 를 만들지 않음. 골격의 TODO 를 채우거나 인터페이스를 뗌
MRC023 경고 공통 계약 인터페이스 I{Db}GenericRepository<T> 자체에 붙인 [NotRemotable] 은 효과가 없음. 메서드에 붙이거나 [RemotableDbContext] 를 뗌

MRC002 는 EF 양방향 내비게이션에서 흔히 납니다. 부모가 자식 컬렉션을, 자식이 부모를 가리키면 순환입니다. 내비게이션을 채우지 않으면 문제없지만 Include 한 번이면 직렬화가 순환 지점을 null 로 끊고, 그것을 다시 Repository 에 넘기면 EF 가 컬렉션 안의 null 을 만나 실패합니다. 샘플의 Blog·Post 가 일부러 이 경고를 냅니다. 대응은 둘 중 하나입니다.

  • 내비게이션을 채우지 않는다는 전제를 받아들입니다.
  • 필요한 값만 평탄한 DTO 로 투영합니다. 샘플의 BlogSummary·PostRow 가 그 방법입니다.

판단이 필요한 일이라 경고로 둡니다. 같은 순환은 출발 엔티티가 달라도 한 번만 보고합니다. DTO 로 투영하기로 정했고 경고를 더 보고 싶지 않다면 프로젝트에서 끕니다.

<PropertyGroup>
  <NoWarn>$(NoWarn);MRC002</NoWarn>
</PropertyGroup>

.editorconfigdotnet_diagnostic.MRC002.severity = suggestion 도 같은 효과입니다.


9. 부록 — 패키지 구성과 설계 원칙

패키지 구성

프로젝트 대상 하는 일
Merc netstandard2.0 소스 제네레이터. 컴파일러 안에서 돕니다
Merc.Core net10.0 런타임 — 마커 인터페이스·특성·응답 래퍼·엔드포인트 매핑·HTTP 헬퍼·인가
Merc.Scaffold net10.0 골격 도구 merc-scaffold. 손으로 쓰는 파일 다섯 종류의 빈자리를 만듦

셋은 패키지 하나(Merc)로 배포됩니다. 네임스페이스는 Merc.* 뿌리를 쓰고, 제네레이터 내부(Merc.Generator.*)는 소비자에게 보이지 않습니다.

네임스페이스 담긴 것
Merc.Attributes RemotableDbContext·NotRemotable·RemotableEndpointGroup·RemotableEndpoint
Merc.Repositories IEntityKey, 마커 인터페이스 ILocalRepository<T>·IRemoteRepository<T>
Merc.Endpoint MapRepositoryEndpointGroups, IRepositoryEndpointGroup
Merc.Authorization IRepositoryAuthorizer·RepositoryCallDescriptor·AllowAnonymousAuthorizer(기본 인가자)
Merc.Remote RemoteRepositoryException·RemoteRepositoryValidationException
Merc.Results OperationResult·OperationResult<T>
{어셈블리 이름}.Extensions 생성된 AddLocalRepositories·AddRepositoryApiHost·AddRepositoryApiHost<TAuthorizer>·AddRepositoryApiClient

Merc 가 모르는 것

  • 기반 클래스가 무엇인지, DbContext 를 어떻게 얻는지 모릅니다. partial 메서드 두 개가 그 둘을 사람 쪽에 둡니다. EF 를 쓰지 않는 기반으로 갈아 끼워도 제네레이터는 손댈 것이 없습니다.
  • 이름 규칙이 아니라 구조로 찾습니다. 계약은 「가장 파생된 인터페이스」로, 엔티티는 「직접 선언한 DbSet」으로, 키는 「IEntityKey 파생 인터페이스 사슬의 선언 순서」로, 원격 여부는 「시그니처 모양」으로 정합니다. 손으로 쓴 partial 은 {어셈블리 이름}.Repositories 한 곳에서만 찾습니다.
  • 발견에 쓰는 메타데이터 이름은 여덟 개뿐입니다: DbContext·DbSet<T>·IEntityKey· ILocalRepository<T>·IRemoteRepository<T>·IRepositoryEndpointGroup·RemotableDbContext·NotRemotable. 나머지 특성 둘(RemotableEndpointGroup·RemotableEndpoint)은 선택 사항으로 이름을 바꾸는 데만, BCL 타입 목록(Expression·Task·Stream·CancellationToken 등)은 원격 가능 여부를 판정하는 데만 씁니다.

왜 생성하는가

두 번째 구현을 손으로 쓰면 셋이 갈라집니다. 계약에 메서드를 하나 더하면 로컬 구현도, 엔드포인트도, HTTP 클라이언트도 함께 고쳐야 합니다. 셋 중 하나를 빠뜨려도 컴파일은 통과합니다. DB 직접 접근으로 도는 개발 환경에서는 아무 일도 일어나지 않고, Web API 로 돌리는 순간에야 드러납니다.

Merc 는 셋을 같은 계약에서 같은 빌드에 냅니다. 계약을 고치면 셋이 함께 바뀝니다. 원격으로 옮길 수 없는 시그니처를 쓰면 그 자리에서 진단이 뜨므로, 원격에서만 드러나던 어긋남이 컴파일 시점으로 당겨집니다. 배포 형태는 나중에 바꿀 수 있습니다. DB 직접 접근으로 만들고 나중에 Web API 뒤로 옮기는 데 드는 비용이 등록 한 줄입니다. 반대 방향도 같습니다.

부수적으로, DB 에 직접 붙는 프로세스를 줄일 수 있습니다. Web API 로 접근하는 호스트는 연결 문자열을 들고 있지 않아도 됩니다. 목적이 아니라 따라오는 것입니다.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.1 90 9/3/2026
0.2.0 88 9/3/2026