Validation Generator
DTO에 [Required], [MaxLength] 같은 DataAnnotations를 붙여 놓고, FluentValidation으로 동일한 규칙을 다시 작성하는 것은 비용이 높은 중복입니다. 규칙이 변경되면 두 곳을 동시에 수정해야 하고, 하나라도 놓치면 검증 동작이 불일치합니다. 이 절에서는 DataAnnotations 속성을 분석하여 FluentValidation Validator를 자동으로 생성하는 소스 생성기를 구현하고, 검증 로직의 단일 진실 공급원(Single Source of Truth)을 확보합니다.
학습 목표
섹션 제목: “학습 목표”핵심 학습 목표
섹션 제목: “핵심 학습 목표”- DataAnnotations와 FluentValidation의 관계 이해
- 두 검증 체계의 매핑 규칙과 자동 변환 전략
- Primary Constructor 파라미터의 속성 추출 방법
- record의 생성자 파라미터에서 어트리뷰트를 읽어내는 Roslyn 심볼 분석 기법
- 검증 규칙 매핑 및 코드 생성
- 속성별 FluentValidation 메서드 매핑과 nullable 타입 처리
왜 Validation 생성기인가?
섹션 제목: “왜 Validation 생성기인가?”문제: 중복되는 검증 코드
섹션 제목: “문제: 중복되는 검증 코드”// DTO 정의 (DataAnnotations)public record CreateUserRequest( [Required, MaxLength(100)] string Name, [Required, EmailAddress] string Email, [Range(1, 150)] int Age);
// FluentValidation (수동 작성 필요)public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>{ public CreateUserRequestValidator() { // DataAnnotations와 동일한 내용을 다시 작성해야 함 RuleFor(x => x.Name).NotEmpty().MaximumLength(100); RuleFor(x => x.Email).NotEmpty().EmailAddress(); RuleFor(x => x.Age).InclusiveBetween(1, 150); }}해결: 자동 생성
섹션 제목: “해결: 자동 생성”// DTO 정의만 하면[GenerateValidator]public record CreateUserRequest( [Required, MaxLength(100)] string Name, [Required, EmailAddress] string Email, [Range(1, 150)] int Age);
// Validator가 자동 생성됨!중복 문제를 인식했으니, 생성기가 만들어야 할 코드의 목표를 정의합니다.
목표: 자동 생성할 코드
섹션 제목: “목표: 자동 생성할 코드”using MyCompany.SourceGenerator;using System.ComponentModel.DataAnnotations;
namespace MyApp.Application.Commands;
[GenerateValidator]public record CreateUserRequest( [Required, MaxLength(100)] string Name, [Required, EmailAddress] string Email, [Range(1, 150)] int Age, [Phone] string? PhoneNumber);생성 결과
섹션 제목: “생성 결과”// <auto-generated/>#nullable enable
using FluentValidation;
namespace MyApp.Application.Commands;
/// <summary>/// CreateUserRequest에 대한 FluentValidation Validator/// </summary>[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")]public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>{ public CreateUserRequestValidator() { // Name: Required, MaxLength(100) RuleFor(x => x.Name) .NotEmpty() .MaximumLength(100);
// Email: Required, EmailAddress RuleFor(x => x.Email) .NotEmpty() .EmailAddress();
// Age: Range(1, 150) RuleFor(x => x.Age) .InclusiveBetween(1, 150);
// PhoneNumber: Phone (nullable) RuleFor(x => x.PhoneNumber) .Matches(@"^[\d\-\+\(\)\s]+$") .When(x => x.PhoneNumber is not null); }}DataAnnotations → FluentValidation 매핑
섹션 제목: “DataAnnotations → FluentValidation 매핑”지원하는 속성
섹션 제목: “지원하는 속성”| DataAnnotations | FluentValidation | 설명 |
|---|---|---|
[Required] | .NotEmpty() | 필수 값 |
[MaxLength(n)] | .MaximumLength(n) | 최대 길이 |
[MinLength(n)] | .MinimumLength(n) | 최소 길이 |
[StringLength(max, Min=min)] | .Length(min, max) | 길이 범위 |
[Range(min, max)] | .InclusiveBetween(min, max) | 숫자 범위 |
[EmailAddress] | .EmailAddress() | 이메일 형식 |
[Phone] | .Matches(phoneRegex) | 전화번호 형식 |
[Url] | .Must(Uri.IsWellFormedUriString) | URL 형식 |
[RegularExpression(pattern)] | .Matches(pattern) | 정규식 |
[Compare(otherProperty)] | .Equal(x => x.OtherProperty) | 값 비교 |
매핑 규칙이 정의되었으니 생성기를 구현합니다. 이 생성기의 핵심 난이도는 Primary Constructor 파라미터에 붙은 어트리뷰트를 정확히 추출하는 것입니다.
1. 메타데이터 클래스
섹션 제목: “1. 메타데이터 클래스”namespace MyCompany.SourceGenerator;
/// <summary>/// Validator 생성에 필요한 메타데이터/// </summary>public sealed record ValidationInfo( string TypeName, string Namespace, IReadOnlyList<PropertyValidation> Properties);
public sealed record PropertyValidation( string PropertyName, string PropertyType, bool IsNullable, IReadOnlyList<ValidationRule> Rules);
public sealed record ValidationRule( ValidationRuleType Type, IReadOnlyDictionary<string, object?> Arguments);
public enum ValidationRuleType{ Required, MaxLength, MinLength, StringLength, Range, EmailAddress, Phone, Url, RegularExpression, Compare}2. 마커 속성 정의
섹션 제목: “2. 마커 속성 정의”namespace MyCompany.SourceGenerator;
internal static class GenerateValidatorAttribute{ public const string Source = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// FluentValidation Validator를 자동 생성합니다. /// DataAnnotations 속성을 FluentValidation 규칙으로 변환합니다. /// </summary> [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 GenerateValidatorAttribute : global::System.Attribute; """;
public const string FullyQualifiedName = "MyCompany.SourceGenerator.GenerateValidatorAttribute";}3. 심볼 분석: Primary Constructor 파라미터 속성 추출
섹션 제목: “3. 심볼 분석: Primary Constructor 파라미터 속성 추출”private static IReadOnlyList<PropertyValidation> ExtractPropertyValidations( INamedTypeSymbol typeSymbol){ var properties = new List<PropertyValidation>();
// Primary Constructor 파라미터 분석 var primaryConstructor = typeSymbol.Constructors .FirstOrDefault(c => c.Parameters.Length > 0 && c.DeclaringSyntaxReferences.Any(r => r.GetSyntax() is RecordDeclarationSyntax or ClassDeclarationSyntax { ParameterList: not null }));
if (primaryConstructor is not null) { foreach (var param in primaryConstructor.Parameters) { var rules = ExtractValidationRules(param); if (rules.Count > 0) { properties.Add(new PropertyValidation( PropertyName: ToPascalCase(param.Name), PropertyType: param.Type.ToDisplayString(), IsNullable: param.Type.NullableAnnotation == NullableAnnotation.Annotated, Rules: rules)); } } }
// 일반 프로퍼티 분석 foreach (var property in typeSymbol.GetMembers().OfType<IPropertySymbol>()) { // Primary Constructor로 이미 처리된 경우 스킵 if (properties.Any(p => p.PropertyName == property.Name)) continue;
var rules = ExtractValidationRules(property); if (rules.Count > 0) { properties.Add(new PropertyValidation( PropertyName: property.Name, PropertyType: property.Type.ToDisplayString(), IsNullable: property.Type.NullableAnnotation == NullableAnnotation.Annotated, Rules: rules)); } }
return properties;}
private static IReadOnlyList<ValidationRule> ExtractValidationRules(ISymbol symbol){ var rules = new List<ValidationRule>();
foreach (var attribute in symbol.GetAttributes()) { var attrName = attribute.AttributeClass?.Name;
var rule = attrName switch { "RequiredAttribute" => new ValidationRule( ValidationRuleType.Required, new Dictionary<string, object?>()),
"MaxLengthAttribute" => new ValidationRule( ValidationRuleType.MaxLength, new Dictionary<string, object?> { ["Length"] = attribute.ConstructorArguments[0].Value }),
"MinLengthAttribute" => new ValidationRule( ValidationRuleType.MinLength, new Dictionary<string, object?> { ["Length"] = attribute.ConstructorArguments[0].Value }),
"StringLengthAttribute" => new ValidationRule( ValidationRuleType.StringLength, new Dictionary<string, object?> { ["MaximumLength"] = attribute.ConstructorArguments[0].Value, ["MinimumLength"] = attribute.NamedArguments .FirstOrDefault(a => a.Key == "MinimumLength").Value.Value ?? 0 }),
"RangeAttribute" => new ValidationRule( ValidationRuleType.Range, new Dictionary<string, object?> { ["Minimum"] = attribute.ConstructorArguments[0].Value, ["Maximum"] = attribute.ConstructorArguments[1].Value }),
"EmailAddressAttribute" => new ValidationRule( ValidationRuleType.EmailAddress, new Dictionary<string, object?>()),
"PhoneAttribute" => new ValidationRule( ValidationRuleType.Phone, new Dictionary<string, object?>()),
"UrlAttribute" => new ValidationRule( ValidationRuleType.Url, new Dictionary<string, object?>()),
"RegularExpressionAttribute" => new ValidationRule( ValidationRuleType.RegularExpression, new Dictionary<string, object?> { ["Pattern"] = attribute.ConstructorArguments[0].Value }),
"CompareAttribute" => new ValidationRule( ValidationRuleType.Compare, new Dictionary<string, object?> { ["OtherProperty"] = attribute.ConstructorArguments[0].Value }),
_ => null };
if (rule is not null) rules.Add(rule); }
return rules;}4. 코드 생성
섹션 제목: “4. 코드 생성”private static string GenerateValidatorSource(ValidationInfo info){ var sb = new StringBuilder();
// 헤더 sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine();
// using 문 sb.AppendLine("using FluentValidation;"); sb.AppendLine();
// 네임스페이스 sb.AppendLine($"namespace {info.Namespace};"); sb.AppendLine();
// 클래스 설명 sb.AppendLine($"/// <summary>"); sb.AppendLine($"/// {info.TypeName}에 대한 FluentValidation Validator"); sb.AppendLine($"/// </summary>"); sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage("); sb.AppendLine(" Justification = \"Generated by source generator.\")]");
// 클래스 선언 sb.AppendLine($"public sealed class {info.TypeName}Validator : AbstractValidator<{info.TypeName}>"); sb.AppendLine("{");
// 생성자 sb.AppendLine($" public {info.TypeName}Validator()"); sb.AppendLine(" {");
foreach (var property in info.Properties) { GeneratePropertyRules(sb, property); }
sb.AppendLine(" }"); sb.AppendLine("}");
return sb.ToString();}
private static void GeneratePropertyRules(StringBuilder sb, PropertyValidation property){ // 주석 var ruleNames = string.Join(", ", property.Rules.Select(r => r.Type.ToString())); sb.AppendLine($" // {property.PropertyName}: {ruleNames}");
// RuleFor 시작 sb.Append($" RuleFor(x => x.{property.PropertyName})");
foreach (var rule in property.Rules) { sb.AppendLine(); sb.Append(" "); sb.Append(GenerateRule(rule, property)); }
// Nullable 처리 if (property.IsNullable && !property.Rules.Any(r => r.Type == ValidationRuleType.Required)) { sb.AppendLine(); sb.Append($" .When(x => x.{property.PropertyName} is not null)"); }
sb.AppendLine(";"); sb.AppendLine();}
private static string GenerateRule(ValidationRule rule, PropertyValidation property){ return rule.Type switch { ValidationRuleType.Required => ".NotEmpty()",
ValidationRuleType.MaxLength => $".MaximumLength({rule.Arguments["Length"]})",
ValidationRuleType.MinLength => $".MinimumLength({rule.Arguments["Length"]})",
ValidationRuleType.StringLength => $".Length({rule.Arguments["MinimumLength"]}, {rule.Arguments["MaximumLength"]})",
ValidationRuleType.Range => $".InclusiveBetween({rule.Arguments["Minimum"]}, {rule.Arguments["Maximum"]})",
ValidationRuleType.EmailAddress => ".EmailAddress()",
ValidationRuleType.Phone => ".Matches(@\"^[\\d\\-\\+\\(\\)\\s]+$\").WithMessage(\"Invalid phone number format\")",
ValidationRuleType.Url => ".Must(uri => global::System.Uri.IsWellFormedUriString(uri, global::System.UriKind.Absolute)).WithMessage(\"Invalid URL format\")",
ValidationRuleType.RegularExpression => $".Matches(@\"{rule.Arguments["Pattern"]}\")",
ValidationRuleType.Compare => $".Equal(x => x.{rule.Arguments["OtherProperty"]})",
_ => "" };}기본 검증 규칙 생성이 완성되면, 중첩 객체와 커스텀 규칙 같은 고급 시나리오로 확장할 수 있습니다.
고급 기능
섹션 제목: “고급 기능”중첩 객체 검증
섹션 제목: “중첩 객체 검증”// 입력[GenerateValidator]public record CreateOrderRequest( [Required] OrderId OrderId, [Required] Address ShippingAddress, // 중첩 객체 [Required] List<OrderItem> Items); // 컬렉션
[GenerateValidator]public record Address( [Required, MaxLength(200)] string Street, [Required, MaxLength(100)] string City);
// 생성 결과public sealed class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>{ public CreateOrderRequestValidator() { RuleFor(x => x.OrderId).NotEmpty();
// 중첩 객체는 해당 Validator 사용 RuleFor(x => x.ShippingAddress) .NotEmpty() .SetValidator(new AddressValidator());
// 컬렉션은 각 항목에 대해 검증 RuleFor(x => x.Items) .NotEmpty() .ForEach(item => item.SetValidator(new OrderItemValidator())); }}커스텀 검증 규칙
섹션 제목: “커스텀 검증 규칙”// 커스텀 속성 정의[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]public class FutureDateAttribute : ValidationAttribute{ public override bool IsValid(object? value) { if (value is DateTime date) return date > DateTime.Now; return false; }}
// 사용[GenerateValidator]public record CreateEventRequest( [Required] string Title, [FutureDate] DateTime EventDate);
// 생성 결과 (커스텀 규칙은 Must로 변환)public sealed class CreateEventRequestValidator : AbstractValidator<CreateEventRequest>{ public CreateEventRequestValidator() { RuleFor(x => x.Title).NotEmpty();
RuleFor(x => x.EventDate) .Must(date => date > global::System.DateTime.Now) .WithMessage("Event date must be in the future"); }}기본 속성, Range 속성, nullable 처리 세 가지 시나리오에 대한 스냅샷 테스트로 생성 결과를 검증합니다.
테스트
섹션 제목: “테스트”기본 검증 테스트
섹션 제목: “기본 검증 테스트”[Fact]public Task ValidationGenerator_ShouldGenerate_BasicValidator(){ // Arrange string input = """ using MyCompany.SourceGenerator; using System.ComponentModel.DataAnnotations;
namespace TestNamespace;
[GenerateValidator] public record CreateUserRequest( [Required, MaxLength(100)] string Name, [Required, EmailAddress] string Email); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Range 속성 테스트
섹션 제목: “Range 속성 테스트”[Fact]public Task ValidationGenerator_ShouldGenerate_RangeValidation(){ // Arrange string input = """ using MyCompany.SourceGenerator; using System.ComponentModel.DataAnnotations;
namespace TestNamespace;
[GenerateValidator] public record ProductRequest( [Required] string Name, [Range(0.01, 999999.99)] decimal Price, [Range(0, 10000)] int Stock); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Nullable 속성 테스트
섹션 제목: “Nullable 속성 테스트”[Fact]public Task ValidationGenerator_ShouldGenerate_NullableValidation(){ // Arrange string input = """ using MyCompany.SourceGenerator; using System.ComponentModel.DataAnnotations;
namespace TestNamespace;
[GenerateValidator] public record UpdateUserRequest( [MaxLength(100)] string? Name, [EmailAddress] string? Email, [Phone] string? PhoneNumber); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}사용 예시
섹션 제목: “사용 예시”ASP.NET Core 통합
섹션 제목: “ASP.NET Core 통합”builder.Services.AddValidatorsFromAssemblyContaining<CreateUserRequestValidator>();
// 또는 자동 등록 확장 메서드 사용builder.Services.AddGeneratedValidators();MediatR 파이프라인과 함께 사용
섹션 제목: “MediatR 파이프라인과 함께 사용”public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>{ private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators) { _validators = validators; }
public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { if (_validators.Any()) { var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll( _validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults .SelectMany(r => r.Errors) .Where(f => f != null) .ToList();
if (failures.Count != 0) throw new ValidationException(failures); }
return await next(); }}한눈에 보는 정리
섹션 제목: “한눈에 보는 정리”Validation 생성기의 핵심 설계를 정리합니다.
| 항목 | 설명 |
|---|---|
| 목적 | DataAnnotations → FluentValidation 자동 변환 |
| 대상 | [GenerateValidator] 속성이 붙은 record/class |
| 분석 | Primary Constructor 파라미터 + 프로퍼티 속성 |
| 지원 속성 | Required, MaxLength, MinLength, Range, EmailAddress, Phone, Url, RegularExpression, Compare |
| 고급 기능 | 중첩 객체, 컬렉션, 커스텀 규칙 |
Validation 생성기는 이 장에서 다룬 세 가지 생성기 중 가장 복잡한 심볼 분석을 수행합니다. 어트리뷰트의 생성자 인자와 명명된 인자를 모두 추출하고, 타입별로 다른 FluentValidation 메서드에 매핑하는 패턴은 다양한 메타프로그래밍 시나리오에 응용할 수 있습니다.
FAQ
섹션 제목: “FAQ”Q1: Primary Constructor의 파라미터에 붙은 속성과 프로퍼티에 붙은 속성은 어떻게 구분하나요?
섹션 제목: “Q1: Primary Constructor의 파라미터에 붙은 속성과 프로퍼티에 붙은 속성은 어떻게 구분하나요?”A: C# record의 Primary Constructor 파라미터는 자동으로 프로퍼티를 생성하므로, [Required] 같은 속성이 파라미터와 프로퍼티 양쪽에 모두 나타날 수 있습니다. 생성기는 먼저 Primary Constructor의 IParameterSymbol.GetAttributes()에서 규칙을 추출하고, 이미 처리된 프로퍼티는 건너뛰어 중복을 방지합니다.
Q2: 지원하지 않는 DataAnnotations 속성(예: [CreditCard])이 있으면 어떻게 되나요?
섹션 제목: “Q2: 지원하지 않는 DataAnnotations 속성(예: [CreditCard])이 있으면 어떻게 되나요?”A: ExtractValidationRules() 메서드의 switch 표현식에서 매칭되지 않는 속성은 null을 반환하여 무시됩니다. 해당 프로퍼티에 다른 지원 속성이 없으면 RuleFor 자체가 생성되지 않습니다. 새로운 속성을 지원하려면 switch에 케이스를 추가하고 FluentValidation 메서드를 매핑하면 됩니다.
Q3: nullable 프로퍼티에서 .When(x => x.Property is not null) 조건이 자동으로 추가되는 기준은 무엇인가요?
섹션 제목: “Q3: nullable 프로퍼티에서 .When(x => x.Property is not null) 조건이 자동으로 추가되는 기준은 무엇인가요?”A: 프로퍼티의 NullableAnnotation이 Annotated(즉, string?처럼 ? 접미사가 붙은 경우)이면서 [Required] 속성이 없는 경우에 .When() 조건이 추가됩니다. [Required]가 있으면 null이 허용되지 않으므로 .NotEmpty()가 null 검사를 대신합니다.
Entity Id, ValueConverter, Validation까지 세 가지 실전 생성기를 구현했습니다. 다음 절에서는 이 경험을 바탕으로, 새로운 소스 생성기를 빠르게 시작할 수 있는 프로젝트 템플릿을 제공합니다.