Skip to content

옵션 유효성 검사

사용

appsettings.json

json
{
  "Example": {
    "Retries":  -1
  },
}

자료구조

cs
public class ExampleOptions
{
    public const string SectionName = "Example";

    public required int Retries { get; init; }
}

유효성 검사

cs
internal sealed class ExampleOptionsValidator : AbstractValidator<ExampleOptions>
{
    public ExampleOptionsValidator()
    {
        RuleFor(x => x.Retries)
            .InclusiveBetween(1, 9);
    }
}

의존성 등록

cs
services
    .AddConfigureOptions<ExampleOptions, ExampleOptionsValidator>(
        ExampleOptions.SectionName);

구현

의존성 등록

cs
public static class FluentValidationOptionsExtensions
{
    public static OptionsBuilder<TOptions> AddConfigureOptions<TOptions, TValidator>(
        this IServiceCollection services,
        string configurationSectionName)
            where TOptions : class
            where TValidator : class, IValidator<TOptions>
    {
        // TOptions의 IValidator 등록
        services.AddScoped<IValidator<TOptions>, TValidator>();

        // TOptions의 IValidator 검사
        return services.AddOptions<TOptions>()
            .BindConfiguration(configurationSectionName)    // appsettings.json의 Section 이름
            .ValidateFluentValidation()                     // OptionsBuilder<TOptions>
            .ValidateOnStart();
    }
}

옵션과 유효성 검사 매핑

IValidateOptions<TOptions> -> FluentValidationOptions<TOptions>: IValidator<TOptions> 호출
cs
internal static class OptionsBuilderFluentValidationExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
    {
        optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
            provider => new FluentValidationOptions<TOptions>(
                optionsBuilder.Name,
                provider));

        return optionsBuilder;
    }
}

유효성 검사

cs
internal sealed class FluentValidationOptions<TOptions> : IValidateOptions<TOptions> where TOptions : class
{
    private readonly IServiceProvider _serviceProvider;
    private readonly string? _name;

    public FluentValidationOptions(
        string? name,
        IServiceProvider serviceProvider)
    {
        _name = name;
        _serviceProvider = serviceProvider;
    }

    public ValidateOptionsResult Validate(string? name, TOptions options)
    {
        // 유효성 검사 제외: 기본 옵션 값을 사용할 때
        if (_name != null && _name != name)
        {
            return ValidateOptionsResult.Skip;
        }

        ArgumentNullException.ThrowIfNull(options);

        // Scoped 란?
        //  일반적으로 Scoped로 등록된 서비스 는 하나의 HTTP 요청 내에서는 동일한 인스턴스를 공유하지만, 다른 요청에서는 새로운 인스턴스가 생성됩니다.
        //  IServiceProvider에서 직접 Scoped 서비스(예: Validator)를 가져오면 문제가 발생할 수 있습니다(HTTP 요청이 아니기 때문에).
        //  루트 스코프에서 Scoped 서비스에 접근하면, 스코프가 적절히 관리되지 않아 메모리 누수나 예기치 않은 동작이 발생할 수 있습니다.
        //
        // 루트 스코프에서 Scoped 서비스 접근하기
        //  명시적으로 IServiceScopeFactory를 사용하여 새로운 DI 스코프 를 만들고, 그 안에서 Scoped 서비스를 가져와 사용해야 합니다.
        //  (HTTP 요청에 따른 Scoped 서비스 라이프사이클을 직접 관리해야 합니다)
        using IServiceScope scope = _serviceProvider.CreateScope();

        // IValidator<TOptions> 인스턴스를 반환합니다(없을 때 예외 발생: GetRequiredService).
        var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();

        // IValidator TOptions 유효성 검사
        var result = validator.Validate(options);

        // 옵션 유효성 검사 성공일 때
        if (result.IsValid)
        {
            return ValidateOptionsResult.Success;
        }

        // 옵션 유효성 검사 실패일 때
        //
        // Microsoft.Extensions.Options.OptionsValidationException:
        //      'Fluent validation failed for
        //      'ExampleOptions.Retries'                                        // <- {typeName}.{error.PropertyName}
        //          with the error:
        //      'Retries'은(는) 1 이상 9 이하여야 합니다. 입력한 값은 -1입니다.'     // <- {error.ErrorMessage}
        string typeName = options.GetType().Name;
        var errors = result
            .Errors
            .Select(error => $"Fluent validation failed for '{typeName}.{error.PropertyName}' with the error: {error.ErrorMessage}");

        return ValidateOptionsResult.Fail(errors);
    }
}