Merc 0.2.1
dotnet add package Merc --version 0.2.1
NuGet\Install-Package Merc -Version 0.2.1
<PackageReference Include="Merc" Version="0.2.1" />
<PackageVersion Include="Merc" Version="0.2.1" />
<PackageReference Include="Merc" />
paket add Merc --version 0.2.1
#r "nuget: Merc, 0.2.1"
#:package Merc@0.2.1
#addin nuget:?package=Merc&version=0.2.1
#tool nuget:?package=Merc&version=0.2.1
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만 봅니다. 기반 클래스가 물려주는IdentityDbContext의DbSet<UserRole>등은 대상이 아닙니다. DbContext 가 어느 폴더에 있는지는 상관없습니다.
1-7. 빌드합니다
나머지는 전부 생성됩니다. 엔티티마다 I{E}Repository·{E}Repository 와, 원격이면 계약·엔드포인트·
HTTP 클라이언트 구현이 obj/…/generated/ 에 생깁니다. 엔티티 특화 메서드는 골격 도구가 만들어 둔
Repositories/{E}Repository.cs 에 씁니다.
2. Program.cs — 셋 중 하나를 부릅니다
생성된 등록 확장 세 개는 {어셈블리 이름}.Extensions 네임스페이스에 있습니다. 엔드포인트 매핑 확장
MapRepositoryEndpointGroups 는 Merc.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<T> 선언"]
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}RepositoryEndpointGroup 은 partial 클래스이고 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. 로 시작하면 Results 가 Merc.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 |
IPostRepository → posts |
[RemotableEndpoint("이름")] |
계약 메서드 | 엔드포인트 이름을 바꿉니다 | 메서드 이름에서 Async 를 뗀 것 — GetSummariesAsync → GetSummaries |
GetRowsByBlogAsync → ByBlog |
// 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] 이 없을 때 |
- DbContext 는
bin/·obj/를 뺀 모든*.cs에서DbSet<T>를 직접 선언한 클래스입니다. 폴더는 상관없습니다. 기반 클래스가 물려주는DbSet은 세지 않습니다. - 엔티티 는 그
DbSet<T>의T전부입니다. 다른 어셈블리의 타입이면 Repository 골격은 만들고 엔티티 확장만 건너뜁니다. - 엔드포인트 그룹의 조건은 제네레이터가 엔드포인트를 내는 조건과 같습니다. 특성은 구문의 특성
목록만 보므로 주석 처리된
[RemotableDbContext]는 세지 않습니다..ext.cs의 주석을 풀면 다음 빌드에 생깁니다. - 네임스페이스는
{어셈블리 이름}.Repositories(·.Endpoint)이고, 엔티티 확장과 DbContext 확장은 각각 원본 선언의 네임스페이스를 따릅니다. 어셈블리 이름은 csproj 의<AssemblyName>에서 읽고, 없거나$(...)처럼 평가가 필요하면 csproj 파일 이름을 씁니다.RootNamespace속성은 읽지 않습니다. 제네레이터가 어셈블리 이름으로 손 작성 partial 을 찾으므로 둘은 같아야 합니다.
첫 빌드의 흐름
- 참조를 더하고 빌드합니다. 골격 다섯 종류 중 조건에 맞는 것이 생깁니다. 엔드포인트 그룹은 아직 원격이 꺼져 있어 생기지 않습니다.
- 같은 빌드가
MRC017로 멈춥니다. 공통 기반의 생성자가 비어 있기 때문입니다.EntityExt/의 빈 키 인터페이스마다MRC022경고도 함께 납니다. Repositories/{Db}GenericRepository.cs의 TODO(생성자·GetDbContext구현·계약 멤버)와EntityExt/{E}.cs의 키 프로퍼티를 채웁니다. 빌드가 통과합니다.- 원격이 필요하면
.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>
.editorconfig 의 dotnet_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 | Versions 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. |
-
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.