본문 바로가기
Backend/C# .NET

CQRS 패턴을 적용한 MediatR과 FluentValidation

by SOLYI 2023. 11. 28.

CQRS 패턴이란? CQRS Pattern

 

CQRS 패턴이란? CQRS Pattern

/pages/list /pages/list CQRS Pattern 이 포스트는 출처가 있는 글입니다. 공부를 위해 정리했습니다 애플리케이션에서 읽기와 쓰기를 분리하는걸 CQRS 패턴이라고 한다. CQRS 패턴은 물리적, 논리적으로 나

solyi.kr


CQRS 패턴의 개념에 대해선 위 포스트를 참고해주세요 😄

 

CQRS 패턴을 적용한 MediatR과 FluentValidation 

기본 예제 코드

MediatRIRequest 인터페이스를 사용하여 명령과 쿼리를 모두 나타낸다. 사용 사례에서는 명령과 쿼리에 대해 별도의 추상화를 생성한다.

  • 먼저 ICommand 인터페이스의 정의는 다음과 같다.
using MediatR;

namespace Application.Abstractions.Messaging
{
    public interface ICommand<out TResponse> : IRequest<TResponse>
    {
    }
}
  • IQuery 인터페이스의 정의는 다음과 같다.
using MediatR;
namespace Application.Abstractions.Messaging
{
    public interface IQuery<out TResponse> : IRequest<TResponse>
    {
    }
}

공변적임을 알리는 TResponse키워드를 타입으로 선언한다.
이로 인해 일반 매개변수로 지정하는 것 보다 더 다양한 타입을 사용할 수 있다.

또한 완전성을 위해 명령/쿼리 처리기에 대한 별도의 추상화가 필요하다.

  • Command 처리기의 정의
using MediatR;
namespace Application.Abstractions.Messaging
{
    public interface ICommandHandler<in TCommand, TResponse> : RequestHandler<TCommand, TResponse>
        where TCommand : ICommand<TResponse>
    {
    }
}

 

  • Query 처리기의 정의
using MediatR;
namespace Application.Abstractions.Messaging
{
    public interface IQueryHandler<in TQuery, TResponse> : IRequestHandler<TQuery, TResponse>
        where TQuery : IQuery<TResponse>
    {
    }
}

 

다음은 간단한 샘플 코드다. 모든 명령이 멱등성을 가져야한다. 멱등성이란, 한 번만 실행할 수 있음을 의미한다.

  • ICommand 인터페이스를 확장해서 새로운 IIdempotentCommand 인터페이스를 정의한다.
    public interface IIdempotentCommand<out TResponse> : ICommand<TResponse>
    {
      Guid RequestId { get; set; }
    }

그 다음, 멱등성을 보장하기 위해 몇 가지 논리를 구현 해야한다.

또한, 명령 및 쿼리에 대한 추가 추상화를 사용하면 MediatR 파이프라인 내에서 필터링을 수행하는 기능을 제공하는데 이 두가지에 대해서는 이 포스트에서는 다루지 않는다.

FluentValidation 적용

FluentValidation라이브러리는 클래스에 대한 사용자 정의 유효성 검사를 쉽게 정의할 수 있다. CQRS 패턴을 적용하기로 했으므로 명령에 대한 유효성 검사를 정의하는게 가장 합리적이다.

쿼리에 대한 유효성 검사는 정의하지 않아도 된다. 쿼리에는 데이터를 가져오기만 할뿐 어떤 동작도 포함되어 있지 않기 때문이다.

  • 다음 UpdateUserCommand코드를 살펴보자.
    public sealed record UpdateUserCommand(int UserId, string FirstName, string LastName) : ICommand<Unit>;<br>

이 명령을 사용해서 기존 사용자의 성과 이름을 업데이트 할 수 있다.

자세한 내용은 CQRS and MediatR in ASP.NET Core

  • UpdateUserCommand에 대한 유효성 검사기를 구현하는 샘플 코드
public sealed class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
{
    public UpdateUserCommandValidator()
    {
        RuleFor(x => x.UserId).NotEmpty();

        RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);

        RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
    }
}

 

UpdateUserCommandValidator에서 명령 인수가 비어있지 않은지, 성과 이름의 최대 길이보다 작은지 확인한다.

자세한 설명에 대해선 링크에서 확인할 수 있다.
FluentValidation in ASP.NET Core

MediatR PipelineBehavior를 사용하여 데코레이터 만들기

CQRS 패턴은 명령과 쿼리를 사용해서 정보를 전달하고 응답을 받는다.
본질적으로 Request-Response 파이프라인을 나타낸다.
이를 통해 원래 요청을 실제로 수정하지 않고도 파이프라인을 통과하는 각 요청들에 추가 동작을 쉽게 도입할 수 있게 된다.

데코레이터 패턴이라는 이름의 이 기술은 흔히 사용되고 있다. 데코레이터 패턴을 사용하는 또 다른 예는 ASP.NET Core Middleware 개념이다.

  • MediatR은 미들웨어와 비슷한 개념으로, 다음과 같이 IPipelineBehavior로 정의할 수 있다.
    public interface IPipelineBehavior<in TRequest, TResponse> where TRequest : notnull
    {
      Task<TResponse> Handle(
          TRequest request, 
          CancellationToken cancellationToken, 
          RequestHandlerDelegate<TResponse> next);
    }

파이프라인 동작은 요청 인스턴스를 둘러싼 래퍼이며 구현 방법에 대한 유연성을 제공한다. 파이프라인 동작은 애플리케이션 교차 문제(cross-cutting concerns)에 적합하다. 교차 문제로 좋은 예는 로깅, 캐싱, 유효성 검사가 있다.

유효성 검사 PipelineBehavior 만들기

CQRS 파이프라인에서 유효성 검사를 구현하기 위해 방금 언급한 MediatRIPipelineBehaviorFluentValidation를 사용한다.

  • 다음은 ValidationBehavior 코드의 내용이다.
public sealed class ValidationBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : class, ICommand<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(
        IEnumerable<IValidator<TRequest>> validators) 
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request, 
        CancellationToken cancellationToken, 
        RequestHandlerDelegate<TResponse> next
        )
    {
        if (!_validators.Any())
        {
            return await next();
        }

        var context = new ValidationContext<TRequest>(request);

        var errorsDictionary = _validators
            .Select(x => x.Validate(context))
            .SelectMany(x => x.Errors)
            .Where(x => x != null)
            .GroupBy(
                x => x.PropertyName,
                x => x.ErrorMessage,
                (propertyName, errorMessages) => new
                {
                    Key = propertyName,
                    Values = errorMessages.Distinct().ToArray()
                })
            .ToDictionary(x => x.Key, x => x.Values);

        if (errorsDictionary.Any())
        {
            throw new ValidationException(errorsDictionary);
        }

        return await next();
    }
}

ValidationBehavior 코드의 설명

  1. ICommand 인터페이스를 상속받은 IPipelineBehavior를 구현하기 위해 where절을 어떻게 사용하고 있는지 주목해보자.기본적으로 파이프라인을 통과하는 요청이 명령인 경우에만 이 실행을 허용한다.
  2. 생성자에 IValidator를 주입한다. FluentValidation 라이브러리는 프로젝트에서 특정 유형에 대한 모든 AbstractValidator를 검색해서 런타임에 인스턴스를 제공한다. 이게 프로젝트에서 구현한 실제 유효성 검사기를 적용하는 방법이다.
  3. 유효성 검사 오류가 있는 경우 사전에 정의한 ValidationException을 발생시킨다. 유효성 검사 오류로 인해 예외가 발생하면 파이프라인이 종료되어 더 이상 실행되지 않는다.

이 코드에서 누락된 것은 상위 레이어에서 예외를 처리하고 유저에게 의미있는 응답을 제공하는코드 뿐이다. 이에 대해 다음 파트에서 바로 알아보도록 한다.

유효성 검사 예외 처리

유효성 검사 오류가 발생할때 정의한 ValidationException를 처리하기 위해 ASP.NET Core IMiddleware 인터페이스를 사용할 수 있다.

  • 전역 예외 처리기를 구현해보기로 하자.
internal sealed class ExceptionHandlingMiddleware : IMiddleware
{
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(
        ILogger<ExceptionHandlingMiddleware> logger) 
        => _logger = logger;

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception e)
        {
            _logger.LogError(e, e.Message);

            await HandleExceptionAsync(context, e);
        }
    }

    private static async Task HandleExceptionAsync(HttpContext httpContext, Exception exception)
    {
        var statusCode = GetStatusCode(exception);

        var response = new
        {
            title = GetTitle(exception),
            status = statusCode,
            detail = exception.Message,
            errors = GetErrors(exception)
        };

        httpContext.Response.ContentType = "application/json";

        httpContext.Response.StatusCode = statusCode;

        await httpContext.Response.WriteAsync(
            JsonSerializer.Serialize(response));
    }

    private static int GetStatusCode(Exception exception) =>
        exception switch
        {
            BadRequestException => StatusCodes.Status400BadRequest,
            NotFoundException => StatusCodes.Status404NotFound,
            ValidationException => StatusCodes.Status422UnprocessableEnttity,
            _ => StatusCodes.Status500InternalServerError
        };

    private static string GetTitle(Exception exception) =>
        exception switch
        {
            ApplicationException applicationException => applicationException.Title,
            _ => "Server Error"
        };

    private static IReadOnlyDictionary<string, string[]> GetErrors(Exception exception)
    {
        IReadOnlyDictionary<string, string[]> errors = null;

        if (exception is ValidationException validationException)
        {
            errors = validationException.ErrorsDictionary;
        }

        return errors;
    }
}

전역 에러 처리기에 대해선 Global Error Handling in ASP.NET Core Web API

의존성 주입 설정

애플리케이션을 실행하기 전에 모든 서비스를 DI 컨테이너에 등록했는지 확인해야한다.

builder.Services.AddMediatR(cfg 
    => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

MediatR에 명령과 쿼리를 등록하는 방법.

  • .NET 6 미만 버전
    Startup.csConfigureServices 다음 줄에 아래 코드를 추가한다.
services.AddMediatR(typeof(Application.AssemblyReference).Assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
  • .NET 6 이상 버전 에서는 Program.cs를 수정해야한다.
builder.Services.AddMediatR(typeof(Application.AssemblyReference).Assembly); 
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), 
    typeof(ValidationBehavior<,>));

 

첫번째 메서드 호출은 Application 어셈블리를 모든 명령, 쿼리, 핸들러를 DI 컨테이너에 추가한다.

두번째 메서드 호출은 ValidationBehavior 등록이다. 이 코드가 없으면 유효성 검사 파이프라인이 실행되지 않는다.

다음으로 FluentValidation를 이용한 유효성 검사 추가 부분이다.

 

마지막으로 ConfigureServicesExceptionHandlingMiddleware를 추가한다.

services.AddTransient<ExceptionHandlingMiddleware>();

//or in .NET 6 and above
builder.Services.AddTransient<ExceptionHandlingMiddleware>();

.NET 6 이상 버전 Configure 메서드 혹은 파이프라인 등록 부분은 다음과 같다

app.UseMiddleware<ExceptionHandlingMiddleware>();

유효성 검사 파이프라인 샘플 코드

/// <summary>
/// 유저 컨트롤러
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class UsersController : ControllerBase
{
    private readonly ISender _sender;

    /// <summary>
    /// <see cref="UsersController"/> 클래스의 새 인스턴스를 초기화
    /// </summary>
    /// <param name="sender"></param>
    public UsersController(ISender sender) => _sender = sender;

    /// <summary>
    /// 지정된 요청이 있는 경우 지정된 요청을 기반으로 지정된 식별자로 사용자를 업데이트
    /// </summary>
    /// <param name="userId">유저Id</param>
    /// <param name="request">업데이트 사용자 요청</param>
    /// <param name="cancellationToken">취소 토큰</param>
    /// <returns>No content.</returns>
    [HttpPut("{userId:int}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> UpdateUser(
        int userId, 
        [FromBody] UpdateUserRequest request, 
        CancellationToken cancellationToken
        )
    {
        var command = request.Adapt<UpdateUserCommand>() with
        {
            UserId = userId
        };

        await _sender.Send(command, cancellationToken);

        return NoContent();
    }
}

 

Presentation프로젝트의 Controller폴더에서 UserController를 찾아 볼 수 있다.
이는 전체 코드가 아닌 유저명 업데이트에 대한 API 엔드포인트에 중점을 둔 코드다.

UpdateUser 작업은 매우 심플하다. 경로에서 사용자를 식별하고 request body에서 성과 이름을 수집한다. 그리고 UpdateUserCommand 인스턴스를 생성한다. 그 다음 파이프라인을 통해 명령을 요청한다. 결과는 204 No Content응답을 반환하게 된다.

하지만 우리의 현재 관심사는 ValidationBehavior의 동작이다.
존재하지 않는 사용자의 정보로 잘못된 요청을 요청한다면 404 Not Found를 반환한다.

 

/pages/architecturedesignpattern

반응형