Value Converter Generator
강타입 Value Object와 Entity Id를 도메인 모델에 도입하면 타입 안전성은 높아지지만, EF Core가 이들을 데이터베이스에 저장하는 방법을 모른다는 새로운 문제가 생깁니다. 매번 ValueConverter<TModel, TProvider>를 수작업으로 작성하는 것은 반복적이고 실수하기 쉬운 작업입니다. 이 절에서는 생성자 파라미터를 분석하여 변환 로직을 자동으로 추론하고, ValueConverter 코드를 생성하는 소스 생성기를 구현합니다.
학습 목표
섹션 제목: “학습 목표”핵심 학습 목표
섹션 제목: “핵심 학습 목표”- EF Core ValueConverter의 역할 이해
- 도메인 타입과 데이터베이스 저장 타입 간의 변환 메커니즘
- Value Object와 Entity Id를 DB에 저장하는 방법 습득
- 단일 파라미터, 다중 파라미터, Ulid 기반 Entity Id 각각의 저장 전략
- 생성자 파라미터 분석을 통한 변환 로직 자동 생성
- Roslyn 심볼 API로 생성자를 분석하고 변환 표현식을 추론하는 기법
EF Core ValueConverter란?
섹션 제목: “EF Core ValueConverter란?”문제: Value Object를 DB에 저장하기
섹션 제목: “문제: Value Object를 DB에 저장하기”// Domain Modelpublic record Email(string Value);public record Money(decimal Amount, string Currency);
public class Customer{ public CustomerId Id { get; set; } public Email Email { get; set; } // ❌ EF Core가 저장 방법을 모름 public Money Balance { get; set; } // ❌ 복합 타입 저장 불가}해결: ValueConverter
섹션 제목: “해결: ValueConverter”// EF Core ValueConverterpublic class EmailValueConverter : ValueConverter<Email, string>{ public EmailValueConverter() : base( v => v.Value, // Email → string (저장) v => new Email(v)) // string → Email (조회) { }}
// DbContext 설정protected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.Entity<Customer>() .Property(c => c.Email) .HasConversion<EmailValueConverter>();}ValueConverter가 해결하는 문제를 이해했으니, 생성기가 만들어야 할 코드의 모습을 Value Object와 Entity Id 두 가지 경우로 나누어 정의합니다.
목표: 자동 생성할 코드
섹션 제목: “목표: 자동 생성할 코드”입력: Value Object
섹션 제목: “입력: Value Object”using MyCompany.SourceGenerator;
namespace MyApp.Domain;
[ValueConverter]public record Email(string Value);생성 결과
섹션 제목: “생성 결과”// <auto-generated/>#nullable enable
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace MyApp.Domain;
/// <summary>/// Email을 string으로 변환하는 EF Core ValueConverter/// </summary>[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")]public sealed class EmailValueConverter : ValueConverter<Email, string>{ public EmailValueConverter() : base( v => v.Value, v => new Email(v)) { }}입력: Entity Id (Ulid 기반)
섹션 제목: “입력: Entity Id (Ulid 기반)”[EntityId][ValueConverter] // ValueConverter도 함께 생성public readonly partial record struct ProductId;생성 결과
섹션 제목: “생성 결과”// <auto-generated/>#nullable enable
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace MyApp.Domain;
/// <summary>/// ProductId를 byte[]로 변환하는 EF Core ValueConverter/// </summary>[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")]public sealed class ProductIdValueConverter : ValueConverter<ProductId, byte[]>{ public ProductIdValueConverter() : base( v => v.Value.ToByteArray(), v => ProductId.Create(new global::Cysharp.Serialization.Ulid(v))) { }}저장 형식 선택
섹션 제목: “저장 형식 선택”Ulid 저장 옵션
섹션 제목: “Ulid 저장 옵션”| 형식 | 크기 | 장점 | 단점 |
|---|---|---|---|
byte[] | 16 bytes | 공간 효율, 빠른 비교 | 읽기 어려움 |
string | 26 chars | 가독성 | 공간 비효율 |
// byte[] 저장 (권장)public class ProductIdValueConverter : ValueConverter<ProductId, byte[]>{ public ProductIdValueConverter() : base( v => v.Value.ToByteArray(), v => ProductId.Create(new Ulid(v))) { }}
// string 저장 (디버깅용)public class ProductIdStringValueConverter : ValueConverter<ProductId, string>{ public ProductIdStringValueConverter() : base( v => v.Value.ToString(), v => ProductId.Create(Ulid.Parse(v))) { }}저장 형식이 결정되었으니, 이전 절의 Entity Id 생성기와 동일한 7단계 워크플로우에 따라 ValueConverter 생성기를 구현합니다. 이 생성기의 핵심은 생성자 파라미터를 분석하여 변환 표현식을 자동으로 추론하는 것입니다.
1. 메타데이터 클래스
섹션 제목: “1. 메타데이터 클래스”namespace MyCompany.SourceGenerator;
/// <summary>/// ValueConverter 생성에 필요한 메타데이터/// </summary>public sealed record ValueConverterInfo( string TypeName, string Namespace, string ProviderType, // DB 저장 타입 (string, byte[], int 등) string ToProviderExpression, // Model → Provider 변환 식 string FromProviderExpression, // Provider → Model 변환 식 bool IsEntityId); // EntityId 여부2. 마커 속성 정의
섹션 제목: “2. 마커 속성 정의”namespace MyCompany.SourceGenerator;
internal static class ValueConverterAttribute{ public const string Source = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// EF Core ValueConverter를 생성할 타입에 적용합니다. /// </summary> /// <example> /// <code> /// [ValueConverter] /// public record Email(string Value); /// </code> /// </example> [global::System.AttributeUsage( global::System.AttributeTargets.Class | global::System.AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")] public sealed class ValueConverterAttribute : global::System.Attribute { /// <summary> /// DB에 저장할 타입입니다. 지정하지 않으면 자동 추론합니다. /// </summary> public global::System.Type? ProviderType { get; set; } } """;
public const string FullyQualifiedName = "MyCompany.SourceGenerator.ValueConverterAttribute";}3. 메인 생성기
섹션 제목: “3. 메인 생성기”using System.Text;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp.Syntax;using Microsoft.CodeAnalysis.Text;
namespace MyCompany.SourceGenerator;
[Generator(LanguageNames.CSharp)]public sealed class ValueConverterGenerator : IIncrementalGenerator{ public void Initialize(IncrementalGeneratorInitializationContext context) { // 1단계: 속성 정의 생성 context.RegisterPostInitializationOutput(ctx => ctx.AddSource( hintName: "ValueConverterAttribute.g.cs", sourceText: SourceText.From(ValueConverterAttribute.Source, Encoding.UTF8)));
// 2단계: [ValueConverter] 속성이 붙은 타입 수집 var provider = RegisterSourceProvider(context);
// 3단계: 코드 생성 context.RegisterSourceOutput(provider, Execute); }
private static IncrementalValuesProvider<ValueConverterInfo> RegisterSourceProvider( IncrementalGeneratorInitializationContext context) { return context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: ValueConverterAttribute.FullyQualifiedName, predicate: IsTypeDeclaration, transform: MapToValueConverterInfo) .Where(static x => x is not null)!; }
private static bool IsTypeDeclaration(SyntaxNode node, CancellationToken _) { return node is TypeDeclarationSyntax; }
private static ValueConverterInfo? MapToValueConverterInfo( GeneratorAttributeSyntaxContext context, CancellationToken _) { if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) return null;
// EntityId 여부 확인 bool isEntityId = typeSymbol.GetAttributes() .Any(a => a.AttributeClass?.Name == "EntityIdAttribute");
// 생성자 파라미터 분석 var constructor = typeSymbol.Constructors .Where(c => c.DeclaredAccessibility == Accessibility.Public) .OrderByDescending(c => c.Parameters.Length) .FirstOrDefault();
if (constructor is null || constructor.Parameters.Length == 0) { // EntityId인 경우 Ulid 기반 처리 if (isEntityId) { return new ValueConverterInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), ProviderType: "byte[]", ToProviderExpression: "v => v.Value.ToByteArray()", FromProviderExpression: $"v => {typeSymbol.Name}.Create(new global::Cysharp.Serialization.Ulid(v))", IsEntityId: true); } return null; }
// 단일 파라미터인 경우 (Value Object) if (constructor.Parameters.Length == 1) { var param = constructor.Parameters[0]; var providerType = GetProviderType(param.Type);
return new ValueConverterInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), ProviderType: providerType, ToProviderExpression: $"v => v.{ToPascalCase(param.Name)}", FromProviderExpression: $"v => new {typeSymbol.Name}(v)", IsEntityId: false); }
// 다중 파라미터인 경우 (JSON 직렬화로 처리) return new ValueConverterInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), ProviderType: "string", ToProviderExpression: "v => global::System.Text.Json.JsonSerializer.Serialize(v)", FromProviderExpression: $"v => global::System.Text.Json.JsonSerializer.Deserialize<{typeSymbol.Name}>(v)!", IsEntityId: false); }
private static string GetProviderType(ITypeSymbol type) { return type.SpecialType switch { SpecialType.System_String => "string", SpecialType.System_Int32 => "int", SpecialType.System_Int64 => "long", SpecialType.System_Decimal => "decimal", SpecialType.System_Double => "double", SpecialType.System_Boolean => "bool", SpecialType.System_DateTime => "global::System.DateTime", _ => type.ToDisplayString() }; }
private static string ToPascalCase(string name) { if (string.IsNullOrEmpty(name)) return name; return char.ToUpper(name[0]) + name.Substring(1); }
private static void Execute( SourceProductionContext context, ValueConverterInfo info) { var source = GenerateValueConverterSource(info); var fileName = $"{info.Namespace.Replace(".", "")}{info.TypeName}ValueConverter.g.cs";
context.AddSource(fileName, SourceText.From(source, Encoding.UTF8)); }
private static string GenerateValueConverterSource(ValueConverterInfo info) { var sb = new StringBuilder();
// 헤더 sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine();
// using 문 sb.AppendLine("using Microsoft.EntityFrameworkCore.Storage.ValueConversion;"); sb.AppendLine();
// 네임스페이스 sb.AppendLine($"namespace {info.Namespace};"); sb.AppendLine();
// 클래스 설명 sb.AppendLine($"/// <summary>"); sb.AppendLine($"/// {info.TypeName}을 {info.ProviderType}로 변환하는 EF Core ValueConverter"); sb.AppendLine($"/// </summary>"); sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage("); sb.AppendLine(" Justification = \"Generated by source generator.\")]");
// 클래스 선언 sb.AppendLine($"public sealed class {info.TypeName}ValueConverter : ValueConverter<{info.TypeName}, {info.ProviderType}>"); sb.AppendLine("{");
// 생성자 sb.AppendLine($" public {info.TypeName}ValueConverter()"); sb.AppendLine(" : base("); sb.AppendLine($" {info.ToProviderExpression},"); sb.AppendLine($" {info.FromProviderExpression})"); sb.AppendLine(" { }");
sb.AppendLine("}");
return sb.ToString(); }}개별 ValueConverter를 생성하는 것만으로는 DbContext에서 하나하나 등록해야 하는 번거로움이 남습니다. 프로젝트 내 모든 ValueConverter를 자동으로 등록하는 확장 메서드를 함께 생성하면 이 문제도 해결됩니다.
DbContext 설정 헬퍼 생성
섹션 제목: “DbContext 설정 헬퍼 생성”자동 등록 확장 메서드
섹션 제목: “자동 등록 확장 메서드”// 입력: 여러 ValueConverter가 있는 프로젝트
// 자동 생성public static class ValueConverterExtensions{ public static void ApplyValueConverters(this ModelBuilder modelBuilder) { // Email modelBuilder.Properties<Email>() .HaveConversion<EmailValueConverter>();
// ProductId modelBuilder.Properties<ProductId>() .HaveConversion<ProductIdValueConverter>();
// CustomerId modelBuilder.Properties<CustomerId>() .HaveConversion<CustomerIdValueConverter>(); }}DbContext에서 사용
섹션 제목: “DbContext에서 사용”public class AppDbContext : DbContext{ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder);
// 한 줄로 모든 ValueConverter 등록 modelBuilder.ApplyValueConverters(); }}생성기 구현이 완료되었으니, Value Object, Entity Id, 복합 타입 세 가지 경우에 대해 생성 결과를 검증합니다.
테스트
섹션 제목: “테스트”Value Object 변환 테스트
섹션 제목: “Value Object 변환 테스트”[Fact]public Task ValueConverterGenerator_ShouldGenerate_ForSimpleValueObject(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[ValueConverter] public record Email(string Value); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Entity Id 변환 테스트
섹션 제목: “Entity Id 변환 테스트”[Fact]public Task ValueConverterGenerator_ShouldGenerate_ForEntityId(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[EntityId] [ValueConverter] public readonly partial record struct ProductId; """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}복합 Value Object 테스트
섹션 제목: “복합 Value Object 테스트”[Fact]public Task ValueConverterGenerator_ShouldGenerate_ForComplexValueObject(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[ValueConverter] public record Money(decimal Amount, string Currency); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}사용 예시
섹션 제목: “사용 예시”도메인 모델
섹션 제목: “도메인 모델”[ValueConverter]public record Email(string Value){ public static Email Create(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Email cannot be empty");
if (!value.Contains('@')) throw new ArgumentException("Invalid email format");
return new Email(value.ToLowerInvariant()); }}
// Domain/ValueObjects/Money.cs[ValueConverter]public record Money(decimal Amount, string Currency){ public static Money Zero(string currency) => new(0m, currency);
public Money Add(Money other) { if (Currency != other.Currency) throw new InvalidOperationException("Cannot add money with different currencies");
return new Money(Amount + other.Amount, Currency); }}Entity
섹션 제목: “Entity”public class Order{ public OrderId Id { get; private set; } public CustomerId CustomerId { get; private set; } public Email CustomerEmail { get; private set; } public Money TotalAmount { get; private set; }}DbContext 설정
섹션 제목: “DbContext 설정”public class AppDbContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { // 자동 생성된 확장 메서드 사용 modelBuilder.ApplyValueConverters();
modelBuilder.Entity<Order>(entity => { entity.HasKey(e => e.Id);
// ValueConverter가 자동 적용됨 entity.Property(e => e.Id); entity.Property(e => e.CustomerId); entity.Property(e => e.CustomerEmail); entity.Property(e => e.TotalAmount); }); }}고급: Dapper 지원
섹션 제목: “고급: Dapper 지원”TypeHandler 생성
섹션 제목: “TypeHandler 생성”// Dapper용 TypeHandler도 함께 생성public sealed class ProductIdTypeHandler : SqlMapper.TypeHandler<ProductId>{ public override ProductId Parse(object value) { return value switch { byte[] bytes => ProductId.Create(new Ulid(bytes)), string str => ProductId.Create(Ulid.Parse(str)), _ => throw new InvalidCastException($"Cannot convert {value.GetType()} to ProductId") }; }
public override void SetValue(IDbDataParameter parameter, ProductId value) { parameter.Value = value.Value.ToByteArray(); parameter.DbType = DbType.Binary; }}한눈에 보는 정리
섹션 제목: “한눈에 보는 정리”EF Core ValueConverter 생성기의 핵심 설계를 정리합니다.
| 항목 | 설명 |
|---|---|
| 목적 | Value Object, Entity Id를 DB에 저장하기 위한 ValueConverter 자동 생성 |
| 대상 | [ValueConverter] 속성이 붙은 record/class |
| 분석 | 생성자 파라미터로 변환 로직 추론 |
| 저장 형식 | 단일 파라미터 → 해당 타입, 다중 → JSON |
| Entity Id | Ulid → byte[] (16 bytes) |
ValueConverter 생성기는 Entity Id 생성기보다 한 단계 복잡한 심볼 분석을 수행합니다. 생성자 파라미터의 개수와 타입을 분석하여 변환 전략을 자동으로 결정하는 패턴은 다양한 생성기에 응용할 수 있습니다.
FAQ
섹션 제목: “FAQ”Q1: 다중 파라미터 Value Object를 JSON으로 직렬화하면 성능 문제가 없나요?
섹션 제목: “Q1: 다중 파라미터 Value Object를 JSON으로 직렬화하면 성능 문제가 없나요?”A: Money(decimal Amount, string Currency)처럼 필드가 적은 경우 JSON 직렬화 비용은 미미합니다. 그러나 대량의 행을 조회하는 쿼리에서는 역직렬화 오버헤드가 누적될 수 있습니다. 성능이 중요한 경우 Amount와 Currency를 별도 컬럼으로 분리하는 OwnsOne 방식을 고려해야 합니다.
Q2: Entity Id의 Ulid를 byte[]로 저장하면 DB에서 직접 조회하기 어렵지 않나요?
섹션 제목: “Q2: Entity Id의 Ulid를 byte[]로 저장하면 DB에서 직접 조회하기 어렵지 않나요?”A: byte[] 저장은 16바이트로 공간 효율이 높고 인덱스 성능이 우수하지만, 사람이 읽기는 어렵습니다. 개발 환경에서는 디버깅 편의를 위해 string 저장(26자)을 사용하고, 운영 환경에서는 byte[]를 사용하는 전략도 가능합니다. [ValueConverter] 속성의 ProviderType 옵션으로 이를 전환할 수 있습니다.
Q3: ApplyValueConverters() 확장 메서드는 어떻게 모든 ValueConverter를 수집하나요?
섹션 제목: “Q3: ApplyValueConverters() 확장 메서드는 어떻게 모든 ValueConverter를 수집하나요?”A: 이 확장 메서드도 소스 생성기가 자동으로 생성합니다. [ValueConverter] 속성이 붙은 모든 타입을 Collect()로 모아 하나의 확장 메서드에 modelBuilder.Properties<T>().HaveConversion<TConverter>()를 순차적으로 등록합니다. 새로운 Value Object를 추가하면 다음 빌드에서 자동으로 반영됩니다.
도메인 모델의 저장 문제를 해결했으니, 이제 애플리케이션 계층의 검증 문제로 넘어갑니다. 다음 절에서는 DataAnnotations를 분석하여 FluentValidation 규칙을 자동으로 생성하는 Validation 생성기를 구현합니다.