MyBatis-style SQL Mapper for .NET, powered by Roslyn Source Generators.
.NET 기반 통계/지표 서비스를 인수받았을 때, 전임자가 EF Core로 작성한 코드는 대시보드 하나를 여는 데 수십 초가 걸렸다. 쿼리 튜닝으로 조회 성능을 최대 3,600배까지 끌어올렸지만, 메모리 사용량은 구조적 한계에 부딪혔다. Change Tracker가 모든 엔티티 상태를 추적하는 EF Core의 설계 자체가 대용량 조회에서 과도한 할당을 유발했고, OOM으로 서비스가 중단되는 일이 반복됐다.
Dapper는 이 문제에 대한 검증된 해법이지만, SQL과 C# 코드가 뒤섞이는 구조가 수백 개의 복잡한 쿼리를 관리하기에 적합하지 않았다. Java 프로젝트에서 수년간 MyBatis를 사용하며 체득한 XML 기반 SQL 분리 패턴을 .NET으로 옮기기로 했다.
프로토타입으로 서비스 내 가장 무거운 쿼리를 전환한 결과, 속도는 2~3배 향상되고 메모리 사용량은 82% 감소했다. Roslyn Source Generator를 활용해 빌드타임에 매핑 코드를 생성함으로써 Source Generator 경로에서 런타임 리플렉션 없이 동작하며, EF Core가 구조적으로 지원하지 못하는 Native AOT 환경과 WDAC(Windows Defender Application Control) 환경에서도 동작하는 SQL 매퍼를 만들었다.
NuVatis는 Entity Framework의 성능 오버헤드와 인라인 SQL의 유지보수성 문제를 동시에 해결하는 SQL Mapper 프레임워크다.
- SQL은 XML 또는 C# Attribute로 별도 관리
- Roslyn Source Generator가 빌드타임에 매핑 코드를 자동 생성 — 런타임에 동적 코드를 생성하지 않는다
- Source Generator 경로에서 런타임 리플렉션 없음, Native AOT 호환 (.NET 8+, resultMap 사용 시)
resultType폴백 경로는 런타임 리플렉션을 사용하며, AOT 게시 전 resultMap 마이그레이션을 권장한다- WDAC / 코드 서명 정책 환경에서 동작 — EF Core는 런타임 동적 코드 생성(Emit)이 차단되어 실행 불가한 환경에서도 NuVatis는 정상 동작
- ADO.NET 기반 최소 추상화, 최대 성능
- .NET 6 / 7 / 8 / 9 / 10 / 11 동시 지원 (멀티 타겟)
SqlIdentifier타입으로${}문자열 치환 런타임 검증 (SQL Injection 방어)
NuVatis가 모든 상황에 적합하지는 않다. 아래 표를 보고 올바른 도구를 선택하라.
EF Core가 더 적합한 경우:
| 케이스 | 이유 |
|---|---|
| 5개 이상의 optional filter를 동적으로 조합 | EF Core의 IQueryable 체이닝이 압도적으로 편리하다 |
| 단순 CRUD 위주, 복잡한 쿼리 없음 | EF Core + Repository 패턴으로 충분하다 |
| 팀에 SQL 전문가가 없음 | EF Core의 자동 쿼리 생성이 더 안전하다 |
| Code-first 마이그레이션이 워크플로의 중심 | EF Core Migrations가 이를 직접 지원한다 |
Dapper가 더 적합한 경우:
| 케이스 | 이유 |
|---|---|
| 쿼리 수가 적고 XML 관리가 부담 | Dapper는 인라인 SQL을 직접 작성한다 |
| 라이브러리를 최소한으로 유지하고 싶음 | Dapper는 단일 파일 수준의 단순성을 제공한다 |
NuVatis가 강한 케이스:
| 케이스 | 이유 |
|---|---|
| Java에서 .NET으로 이동하는 개발자/팀 | XML 매퍼 문법이 MyBatis와 동일하다. Java 팀과 .NET 팀이 공존하는 조직에서 쿼리 패턴을 통일할 수 있다 |
| 수백 개의 레거시 SQL을 그대로 관리 | SQL을 코드에서 분리하여 버전 관리한다 |
| 동적 SQL이 많지만 타입 안전성 필요 | <if>/<where>/<foreach> + NV004 컴파일 에러 |
| 복잡한 JOIN + 집계 쿼리를 직접 제어 | SQL을 수정하면 즉시 반영된다 |
| Native AOT 환경 (.NET 8+) | <resultMap>과 SG 경로 사용 시 런타임 리플렉션 없이 AOT 안전하다. resultType 폴백은 리플렉션을 사용하므로 AOT 게시 전 resultMap 마이그레이션이 필요하다 |
| WDAC / 코드 서명 정책이 적용된 엔터프라이즈 환경 | EF Core는 런타임에 IL을 Emit하므로 WDAC가 이를 차단한다. NuVatis는 빌드타임 코드 생성이므로 영향 없다 |
TL;DR: EF Core는 동적 쿼리 조합에, NuVatis는 복잡한 정적 SQL 관리에 사용하라. 동일 프로젝트에서 함께 쓰는 하이브리드 패턴도 지원한다. → EF Core + NuVatis 하이브리드 가이드
| Package | Description |
|---|---|
NuVatis.Core |
핵심 런타임 (Session, Executor, Transaction, Mapping, Cache) |
NuVatis.Generators |
Roslyn Source Generator (XML 파싱, 분석, 코드 생성) |
NuVatis.PostgreSql |
PostgreSQL Provider (Npgsql) |
NuVatis.MySql |
MySQL/MariaDB Provider (MySqlConnector) |
NuVatis.SqlServer |
SQL Server/MSSQL Provider (Microsoft.Data.SqlClient) |
NuVatis.Sqlite |
SQLite Provider (Microsoft.Data.Sqlite) |
NuVatis.Oracle |
Oracle Provider (Oracle.ManagedDataAccess.Core, Oracle 12c+) |
NuVatis.QueryBuilder |
jOOQ 스타일 타입 안전 SQL DSL (PostgreSQL · MySQL · SQL Server · Oracle 방언) |
NuVatis.QueryBuilder.Tools |
dotnet global tool — DB 스키마 스캐너 + QueryBuilder 코드 생성기 |
NuVatis.Extensions.DependencyInjection |
Microsoft DI 통합 + Health Check |
NuVatis.Extensions.OpenTelemetry |
OpenTelemetry 분산 추적 (ActivitySource) |
NuVatis.Extensions.EntityFrameworkCore |
EF Core DbContext 커넥션/트랜잭션 공유 |
NuVatis.Extensions.Aspire |
.NET Aspire 통합 (Health Check + OTel 자동 구성) |
NuVatis.Testing |
테스트 지원 (InMemorySqlSession, QueryCapture) |
dotnet add package NuVatis.Core
dotnet add package NuVatis.Generators
dotnet add package NuVatis.PostgreSql
dotnet add package NuVatis.Extensions.DependencyInjectionpublic interface IUserMapper {
User? GetById(int id);
Task<User?> GetByIdAsync(int id, CancellationToken ct = default);
IList<User> Search(UserSearchParam param);
int Insert(User user);
int Update(User user);
int Delete(int id);
}<?xml version="1.0" encoding="utf-8" ?>
<mapper namespace="Sample.Mappers.IUserMapper">
<cache eviction="LRU" flushInterval="600000" size="512" />
<resultMap id="UserResult" type="User">
<id column="id" property="Id" />
<result column="user_name" property="UserName" />
<result column="email" property="Email" />
</resultMap>
<select id="GetById" resultMap="UserResult">
SELECT id, user_name, email FROM users WHERE id = #{Id}
</select>
<select id="Search" resultMap="UserResult">
SELECT id, user_name, email FROM users
<where>
<if test="UserName != null">
AND user_name LIKE #{UserName}
</if>
<foreach collection="Ids" item="id"
open="AND id IN (" separator="," close=")">
#{id}
</foreach>
</where>
</select>
<insert id="Insert">
INSERT INTO users (user_name, email) VALUES (#{UserName}, #{Email})
</insert>
</mapper>public interface IUserMapper {
[Select("SELECT id, user_name, email FROM users WHERE id = #{Id}")]
[ResultMap("UserResult")]
User? GetById(int id);
[Insert("INSERT INTO users (user_name, email) VALUES (#{UserName}, #{Email})")]
int Insert(User user);
}builder.Services.AddNuVatis(options => {
options.ConnectionString = builder.Configuration.GetConnectionString("Default");
options.Provider = new PostgreSqlProvider();
options.RegisterMappers(NuVatisMapperRegistry.RegisterAll);
options.RegisterAttributeStatements(stmts => {
NuVatisMapperRegistry.RegisterAttributeStatements(stmts);
NuVatisMapperRegistry.RegisterXmlStatements(stmts); // XML 매퍼 사용 시
});
});
builder.Services.AddHealthChecks().AddNuVatis();public class UserService {
private readonly IUserMapper _mapper;
public UserService(IUserMapper mapper) {
_mapper = mapper;
}
public async Task<User?> GetUser(int id) {
return await _mapper.GetByIdAsync(id);
}
}using var session = factory.OpenSession();
var mapper = session.GetMapper<IUserMapper>();
var user = mapper.GetById(1);
mapper.Insert(newUser);
session.Commit();BenchmarkDotNet으로 NuVatis, Dapper, EF Core를 60개 시나리오에서 비교 측정했다. 아래는 대표 시나리오의 요약이다.
| 시나리오 | NuVatis | Dapper | EF Core | 비고 |
|---|---|---|---|---|
| PK 단일 조회 (A01) | 153 us / 4.6 KB | 162 us / 4.6 KB | 255 us / 9.4 KB | NuVatis = Dapper, EF Core 대비 1.7x 빠름 |
| WHERE 100 rows (A06) | 364 ms / 109 KB | 373 ms / 110 KB | 674 ms / 123 KB | 대용량 조회에서 EF Core 1.9x 느림 |
| 100회 반복 INSERT (A12) | 1,543 ms / 636 KB | 2,142 ms / 670 KB | 1,643 ms / 202 MB | EF Core 메모리 할당 326x |
| 3-table JOIN (B03) | 352 us / 4.7 KB | 354 us / 4.7 KB | 446 us / 14.2 KB | JOIN에서 EF Core 메모리 3x |
| 6-table JOIN (B08) | 3.5 ms / 39 KB | 3.5 ms / 39 KB | 4.6 ms / 134 KB | 복잡한 JOIN에서 EF Core 메모리 3.4x |
| BULK INSERT 100건 (D01) | 31 ms / 372 KB | 38 ms / 372 KB | 173 ms / 79 MB | EF Core 메모리 218x |
| BULK INSERT 10K건 (D03) | 41 ms / 187 KB | 33 ms / 187 KB | 85 ms / 40 MB | EF Core 메모리 216x |
| 대량 조회 100K rows (E01) | 746 ms / 6.8 MB | 757 ms / 6.7 MB | 1,099 ms / 7.5 MB | 대규모 결과셋에서 EF Core 1.5x 느림 |
| 메모리 압박 테스트 (E05) | 1.9 ms / 485 KB | 2.1 ms / 485 KB | 5.7 ms / 5.8 MB | EF Core 메모리 12x |
전체 60개 시나리오의 상세 결과는 벤치마크 대시보드에서 확인할 수 있다.
실제 동작하는 전체 예제와 성능 벤치마크는 별도 저장소에서 제공한다.
github.com/JinHo-von-Choi/nuvatis-sample
| 디렉토리 | 설명 |
|---|---|
src/NuVatis.Sample.Core/ |
엔티티, 매퍼 인터페이스, XML 매퍼 (상세 주석 포함) |
src/NuVatis.Sample.WebApi/ |
ASP.NET Core Web API + Swagger UI |
src/NuVatis.Sample.Console/ |
콘솔 앱 예제 |
benchmarks/ |
NuVatis vs Dapper vs EF Core 비교 벤치마크 |
database/ |
PostgreSQL 스키마 |
- XML 매퍼 동적 SQL (
<if>,<foreach>,<where>,<choose>) - Association (1:1) / Collection (1:N) / 중첩 매핑
- 원자적 재고 업데이트 (동시성 제어 패턴)
- RESTful API 통합 (Users / Products / Orders)
- 대규모 벤치마크 (18개 시나리오)
git clone https://github.com/JinHo-von-Choi/nuvatis-sample
cd nuvatis-sample
docker-compose up -d # PostgreSQL 시작
dotnet build
dotnet run --project src/NuVatis.Sample.WebApi| Environment | Registration | Lifecycle |
|---|---|---|
| ASP.NET Core (DI) | Scoped | Per HTTP request |
| Generic Host (DI) | Scoped | Per scope |
| Console/Batch | Manual | using block |
autoCommit: false(default) - MyBatis 호환. Commit 없이 Dispose 시 자동 RollbackautoCommit: true- 각 쿼리 후 즉시 커밋- Lazy Connection - 첫 쿼리 시점에 DB 연결 개시
using var session = factory.OpenSession();
var mapper = session.GetMapper<IUserMapper>();
mapper.Insert(user);
mapper.Insert(order);
session.Commit();ExecuteInTransactionAsync:
await session.ExecuteInTransactionAsync(async () => {
await mapper.InsertAsync(user);
await mapper.InsertAsync(order);
});대용량 결과를 메모리에 모두 적재하지 않고 스트리밍으로 소비한다.
await foreach (var row in session.SelectStream<StatRow>("Stats.GetAll")) {
Process(row);
}하나의 SQL에서 반환되는 여러 결과셋을 순차 소비한다.
await using var results = await session.SelectMultipleAsync("Dashboard.Overview", param);
var summary = await results.ReadAsync<DashboardSummary>();
var details = await results.ReadListAsync<DashboardDetail>();
var trends = await results.ReadListAsync<TrendData>();Namespace 단위 LRU 캐시. Select 시 캐시 히트하면 DB를 건너뛴다. Insert/Update/Delete 실행 시 해당 namespace 캐시를 자동 무효화한다.
XML 설정:
<cache eviction="LRU" flushInterval="600000" size="512" />
<select id="GetMonthlyStats" resultMap="StatsResult" useCache="true">
SELECT ... FROM monthly_stats WHERE month = #{Month}
</select>ICacheProvider 인터페이스를 통해 Redis 등 외부 캐시로 교체 가능하다.
EF Core와 동일 트랜잭션 내에서 NuVatis 쿼리를 실행한다. DbConnection/DbTransaction을 자동 공유한다.
builder.Services.AddNuVatis(options => { ... });
builder.Services.AddNuVatisEntityFrameworkCore<AppDbContext>();수동 사용:
await using var nuvatisSession = await dbContext.OpenNuVatisSessionAsync(factory);
var stats = await nuvatisSession.SelectListAsync<MonthlyStats>("Stats.GetMonthly");SQL 실행 전후에 횡단 관심사를 처리한다.
factory.AddInterceptor(new MetricsInterceptor());Meter "NuVatis": nuvatis.query.total, nuvatis.query.duration, nuvatis.query.errors.total
factory.AddInterceptor(new OpenTelemetryInterceptor());ActivitySource "NuVatis.SqlSession" 기반 분산 추적. 태그: db.system, db.statement, db.operation, otel.status_code
builder.Services.AddHealthChecks().AddNuVatis();SELECT 1 핑 쿼리로 DB 연결 상태를 검증한다. __nuvatis_health Statement가 자동 등록된다.
Statement 단위로 SQL 실행 타임아웃을 설정할 수 있다. 우선순위: Statement > Session Default.
<select id="HeavyReport" commandTimeout="120">
SELECT ... FROM large_table ...
</select>| Tag | Description |
|---|---|
<if test="..."> |
조건부 SQL |
<choose>/<when>/<otherwise> |
Switch-case |
<where> |
자동 WHERE 절 처리 |
<set> |
자동 SET 절 처리 |
<foreach> |
컬렉션 반복 |
<bind> |
변수 바인딩 (OGNL 표현식) |
<sql>/<include> |
SQL 프래그먼트 재사용 |
<selectKey> |
INSERT 전/후 키 생성 SQL 실행 — 생성된 PK를 파라미터 객체에 자동 주입 |
${} 문자열 치환은 v2.0.0부터 파라미터 타입이 string이면 NV004 빌드 오류가 발생한다.
동적 테이블명·컬럼명처럼 ${} 가 불가피한 경우 SqlIdentifier 타입을 사용한다.
using NuVatis.Core.Sql;
// 1. enum 기반 (가장 안전)
public enum SortColumn { CreatedAt, UserName, Id }
mapper.GetSorted(new { Column = SqlIdentifier.FromEnum(SortColumn.CreatedAt) });
// 2. 화이트리스트 기반 (사용자 입력 허용)
mapper.GetSorted(new {
Column = SqlIdentifier.FromAllowed(userInput, "id", "created_at", "user_name")
});
// 3. WHERE IN 절 — struct 타입 컬렉션을 안전하게 인라인 (v2.1.0+)
var ids = new List<int> { 1, 2, 3 };
var inClause = SqlIdentifier.JoinTyped(ids); // → "1,2,3"
var sql = $"SELECT * FROM orders WHERE id IN ({inClause})";<select id="GetSorted" resultMap="UserResult">
SELECT * FROM users ORDER BY ${Column}
</select>마이그레이션 가이드: CHANGELOG.md v2.0.0 | SQL Injection Prevention
외부에서 관리하는 DbConnection/DbTransaction을 NuVatis에서 사용할 수 있다. 커넥션/트랜잭션 수명 관리는 외부 호출자에 위임된다.
using var session = factory.FromExistingConnection(connection, transaction);
var data = session.SelectList<Item>("Items.GetAll");var session = new InMemorySqlSession();
session.Setup("UserMapper.GetById", expectedUser);
var result = session.SelectOne<User>("UserMapper.GetById");
Assert.True(QueryCapture.HasQuery(session, "UserMapper.GetById"));
Assert.Equal(1, QueryCapture.QueryCount(session, "UserMapper.GetById"));[NuVatisProvider("CustomDb")]
public class CustomDbProvider : IDbProvider {
public string Name => "CustomDb";
public DbConnection CreateConnection(string connectionString)
=> new CustomDbConnection(connectionString);
public string ParameterPrefix => "@";
public string GetParameterName(int index) => $"@p{index}";
public string WrapIdentifier(string name) => $"\"{name}\"";
}NuVatis.Core 패키지에 XML 스키마 파일이 포함되어 있다. IDE에서 XML 매퍼 작성 시 자동완성 및 유효성 검사에 활용할 수 있다.
<?xml version="1.0" encoding="utf-8" ?>
<mapper namespace="..."
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="schemas/nuvatis-mapper.xsd">| Schema | Description |
|---|---|
schemas/nuvatis-mapper.xsd |
Mapper XML 스키마 (select, insert, update, delete, resultMap, cache, dynamic SQL) |
schemas/nuvatis-config.xsd |
Configuration XML 스키마 |
dotnet build
# 단위 테스트만 (빠름, Docker 불필요)
dotnet test --filter "Category!=Integration"
# 전체 테스트 (통합 테스트 포함, Docker 필요)
dotnet test# net8 단일 타겟 — 빌드/테스트 속도 대폭 단축 (권장)
dotnet test NuVatis.sln /p:FastTest=true -f net8.0
# 전체 타겟 (net6–11) — CI와 동일
dotnet test NuVatis.sln.NET 6/7 런타임이 설치되지 않은 로컬 환경에서는 전체 타겟 테스트 빌드 시 해당 타겟이 실패한다. CI에서는 모든 런타임이 자동 설치된다.
Pack (수동):
dotnet pack --configuration Release --output ./nupkgPack (스크립트):
./pack.sh # Directory.Build.props 버전 사용
./pack.sh 1.0.1 # 버전 지정pack.sh는 빌드, 테스트, 패키징, 14개 패키지 검증을 자동 수행한다.
GitHub Actions 기반 CI/CD 파이프라인:
| Workflow | Trigger | 역할 |
|---|---|---|
ci.yml |
push (main, develop), PR | 빌드, 테스트, 코드 커버리지, 패키지 생성 검증 |
publish.yml |
v* 태그 push |
빌드, 테스트, NuGet.org 배포, GitHub Release 생성 |
benchmark.yml |
push (main), PR | BenchmarkDotNet 성능 벤치마크 실행 및 회귀 감지 |
e2e-testcontainers.yml |
push (main), PR | Testcontainers 기반 PostgreSQL/MySQL 멀티버전 E2E 테스트 |
docs.yml |
push (main, docs/**) | DocFX 문서 빌드 및 GitHub Pages 배포 |
NuGet 배포는 Trusted Publishing (OIDC) 방식을 사용한다. API 키를 저장하지 않고, GitHub Actions가 발급하는 단기 OIDC 토큰으로 NuGet.org 임시 API 키를 획득하여 배포한다.
릴리스 방법:
git tag v2.0.0
git push origin v2.0.0태그 push 시 publish.yml이 자동 실행되어 14개 패키지를 NuGet.org에 배포하고 GitHub Release를 자동 생성한다.
NuVatis.sln
Directory.Build.props # 공통 NuGet 메타데이터 + 버전
pack.sh # NuGet 패키징 스크립트
schemas/
nuvatis-mapper.xsd # Mapper XML 스키마
nuvatis-config.xsd # Config XML 스키마
src/
NuVatis.Core/ # 핵심 런타임
NuVatis.Generators/ # Roslyn Source Generator
NuVatis.PostgreSql/ # PostgreSQL Provider
NuVatis.MySql/ # MySQL/MariaDB Provider
NuVatis.SqlServer/ # SQL Server/MSSQL Provider
NuVatis.Sqlite/ # SQLite Provider
NuVatis.Oracle/ # Oracle Provider (12c+)
NuVatis.QueryBuilder/ # 타입 안전 SQL DSL
NuVatis.QueryBuilder.Tools/ # dotnet tool — 스키마 스캐너 + 코드 생성기
NuVatis.Extensions.DependencyInjection/ # DI + Health Check
NuVatis.Extensions.OpenTelemetry/ # OpenTelemetry 분산 추적
NuVatis.Extensions.EntityFrameworkCore/ # EF Core 통합
NuVatis.Extensions.Aspire/ # .NET Aspire 통합
NuVatis.Testing/ # 테스트 유틸리티
tests/
NuVatis.Tests/ # 단위/통합/E2E 테스트 (300개+)
NuVatis.Generators.Tests/ # Source Generator 테스트 (87개)
NuVatis.QueryBuilder.Tests/ # QueryBuilder 단위/통합 테스트
benchmarks/
NuVatis.Benchmarks/ # 성능 벤치마크
samples/
NuVatis.Sample/ # 사용 예제
- .NET 6.0+ (.NET 6 / 7 / 8 / 9 / 10 / 11 멀티 타겟)
- C# 11+
MIT License. Copyright (c) 2026 Jinho Choi.
