Skip to content

JinHo-von-Choi/nuvatis

Repository files navigation

NuVatis

한국어 | English

NuVatis

CI NuGet License: MIT

MyBatis-style SQL Mapper for .NET, powered by Roslyn Source Generators.

Why NuVatis

.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 매퍼를 만들었다.

Overview

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 방어)

When NOT to Use NuVatis

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 하이브리드 가이드

Packages

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)

Quick Start

Installation

dotnet add package NuVatis.Core
dotnet add package NuVatis.Generators
dotnet add package NuVatis.PostgreSql
dotnet add package NuVatis.Extensions.DependencyInjection

Mapper Interface

public 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 Mapper

<?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>

C# Attribute (Static SQL)

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);
}

DI Integration (ASP.NET Core)

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();

Usage

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);
    }
}

Manual Session (Non-DI)

using var session = factory.OpenSession();
var mapper = session.GetMapper<IUserMapper>();

var user = mapper.GetById(1);
mapper.Insert(newUser);
session.Commit();

Benchmark

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개 시나리오의 상세 결과는 벤치마크 대시보드에서 확인할 수 있다.

Sample Repository

실제 동작하는 전체 예제와 성능 벤치마크는 별도 저장소에서 제공한다.

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개 시나리오)

빠른 시작 (Docker 필요)

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

Session Lifecycle

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 시 자동 Rollback
  • autoCommit: true - 각 쿼리 후 즉시 커밋
  • Lazy Connection - 첫 쿼리 시점에 DB 연결 개시

Transaction Management

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);
});

Streaming (IAsyncEnumerable)

대용량 결과를 메모리에 모두 적재하지 않고 스트리밍으로 소비한다.

await foreach (var row in session.SelectStream<StatRow>("Stats.GetAll")) {
    Process(row);
}

Multi-ResultSet

하나의 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>();

Second-Level Cache

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 Integration

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");

Interceptors

SQL 실행 전후에 횡단 관심사를 처리한다.

Prometheus Metrics (내장)

factory.AddInterceptor(new MetricsInterceptor());

Meter "NuVatis": nuvatis.query.total, nuvatis.query.duration, nuvatis.query.errors.total

OpenTelemetry Tracing

factory.AddInterceptor(new OpenTelemetryInterceptor());

ActivitySource "NuVatis.SqlSession" 기반 분산 추적. 태그: db.system, db.statement, db.operation, otel.status_code

Health Check (ASP.NET Core)

builder.Services.AddHealthChecks().AddNuVatis();

SELECT 1 핑 쿼리로 DB 연결 상태를 검증한다. __nuvatis_health Statement가 자동 등록된다.

CommandTimeout

Statement 단위로 SQL 실행 타임아웃을 설정할 수 있다. 우선순위: Statement > Session Default.

<select id="HeavyReport" commandTimeout="120">
    SELECT ... FROM large_table ...
</select>

Dynamic SQL Tags

Tag Description
<if test="..."> 조건부 SQL
<choose>/<when>/<otherwise> Switch-case
<where> 자동 WHERE 절 처리
<set> 자동 SET 절 처리
<foreach> 컬렉션 반복
<bind> 변수 바인딩 (OGNL 표현식)
<sql>/<include> SQL 프래그먼트 재사용
<selectKey> INSERT 전/후 키 생성 SQL 실행 — 생성된 PK를 파라미터 객체에 자동 주입

SQL Injection Defense (SqlIdentifier)

${} 문자열 치환은 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

External Connection Sharing

외부에서 관리하는 DbConnection/DbTransaction을 NuVatis에서 사용할 수 있다. 커넥션/트랜잭션 수명 관리는 외부 호출자에 위임된다.

using var session = factory.FromExistingConnection(connection, transaction);
var data = session.SelectList<Item>("Items.GetAll");

Testing

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"));

Custom DB Provider

[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}\"";
}

XML Schema Validation

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 스키마

Build & Test

dotnet build

# 단위 테스트만 (빠름, Docker 불필요)
dotnet test --filter "Category!=Integration"

# 전체 테스트 (통합 테스트 포함, Docker 필요)
dotnet test

로컬 개발 — 빠른 테스트 (FastTest 프로필)

# 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 ./nupkg

Pack (스크립트):

./pack.sh                       # Directory.Build.props 버전 사용
./pack.sh 1.0.1                 # 버전 지정

pack.sh는 빌드, 테스트, 패키징, 14개 패키지 검증을 자동 수행한다.

CI/CD

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를 자동 생성한다.

Project Structure

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/                    # 사용 예제

Requirements

  • .NET 6.0+ (.NET 6 / 7 / 8 / 9 / 10 / 11 멀티 타겟)
  • C# 11+

License

MIT License. Copyright (c) 2026 Jinho Choi.

About

MyBatis-style SQL Mapper for .NET, powered by Roslyn Source Generators

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors