본문으로 건너뛰기

Validation Generator

DTO에 [Required], [MaxLength] 같은 DataAnnotations를 붙여 놓고, FluentValidation으로 동일한 규칙을 다시 작성하는 것은 비용이 높은 중복입니다. 규칙이 변경되면 두 곳을 동시에 수정해야 하고, 하나라도 놓치면 검증 동작이 불일치합니다. 이 절에서는 DataAnnotations 속성을 분석하여 FluentValidation Validator를 자동으로 생성하는 소스 생성기를 구현하고, 검증 로직의 단일 진실 공급원(Single Source of Truth)을 확보합니다.

  1. DataAnnotations와 FluentValidation의 관계 이해
    • 두 검증 체계의 매핑 규칙과 자동 변환 전략
  2. Primary Constructor 파라미터의 속성 추출 방법
    • record의 생성자 파라미터에서 어트리뷰트를 읽어내는 Roslyn 심볼 분석 기법
  3. 검증 규칙 매핑 및 코드 생성
    • 속성별 FluentValidation 메서드 매핑과 nullable 타입 처리

// 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 매핑”
DataAnnotationsFluentValidation설명
[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 파라미터에 붙은 어트리뷰트를 정확히 추출하는 것입니다.


ValidationInfo.cs
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
}
GenerateValidatorAttribute.cs
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 파라미터 속성 추출”
ValidationAnalyzer.cs
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;
}
ValidationCodeGenerator.cs
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);
}
[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);
}
[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);
}

Program.cs
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserRequestValidator>();
// 또는 자동 등록 확장 메서드 사용
builder.Services.AddGeneratedValidators();
ValidationBehavior.cs
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 메서드에 매핑하는 패턴은 다양한 메타프로그래밍 시나리오에 응용할 수 있습니다.


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: 프로퍼티의 NullableAnnotationAnnotated(즉, string?처럼 ? 접미사가 붙은 경우)이면서 [Required] 속성이 없는 경우에 .When() 조건이 추가됩니다. [Required]가 있으면 null이 허용되지 않으므로 .NotEmpty()null 검사를 대신합니다.


Entity Id, ValueConverter, Validation까지 세 가지 실전 생성기를 구현했습니다. 다음 절에서는 이 경험을 바탕으로, 새로운 소스 생성기를 빠르게 시작할 수 있는 프로젝트 템플릿을 제공합니다.

05. 커스텀 생성기 템플릿