ASP.NET Core로 구현하는 클린 아키텍쳐 (3)

지금까지 Payment 라는 도메인 엔티티와 두개의 Value Object(CardNumber, CardExpiryDate)를 정의하여 도메인 로직을 담았다. 구현을 거듭하면서 도메인과 연관된 내용은 이들 객체에 구현될 것이다. 이제, 애플리케이션 레이어 수준에서 유스케이스를 구현해 보자. Payment를 생성, 저장하고 조회하는 기본적인 요구사항에 대해 다루어보겠다.

애플리케이션 레이어

dependency-flow

애플리케이션 레이어는 도메인 레이어를 감싸면서 도메인의 독립성을 보장한다. 도메인은 솔루션 구조에서 유일하게 종속성이 없는 레이어이고 오직 애플리케이션에 의해서만 접근할 수 있다. 애플리케이션 레이어에서는 다음과 같은 일들을 한다.

  • 인터페이스 정의
  • 유스케이스 구현
  • 커맨드 & 쿼리 (CQRS)
  • 유효성 검사
  • 예외 정의

유스케이스를 구현하면서 필요한 인터페이스는 애플리케이션 레이어에 정의하고, 구현체는 인프라스트럭쳐 레이어에 둔다.

Code to interface, not implementation

예를 들어, 이메일을 보내고자 한다면 애플리케이션에서는 IEmailService 인터페이스 형식의 변수를 생성자 주입하고 이렇게 얻은 인스턴스의 Send()를 메서드를 사용한다. 이를 가능하게 하려면 인프라스트럭쳐에 MailChamp : IEmailService 처럼 실제로 기능하는 객체를 구현한다. 물론, 해당 인터페이스와 함께 종속성으로 등록해야 종속성 주입이 가능하다.

CQRS (Command & Query Responsibility Segregation)

대부분의 업무용 애플리케이션은 데이터 처리에 큰 비중을 둔다. 엔티티별로 입력, 조회, 수정, 삭제(CRUD) 로직을 구현하기 마련인데, 이렇게 하나의 모델에 많은 로직을 구현하다보면 클래스 하나가 커지고 차츰 유지보수가 어려워진다.

차라리 처음부터 조회와 변경을 별도의 모델로 관리하여 유지보수의 용이성과 코드의 가독성을 높이고, 향후에 데이터베이스까지 분리 운영하여 성능을 개선할 수 있다는 장점때문에 CQRS 패턴을 사용한다. 보다 자세한 내용은 나만 모르고 있던 CQRS & EventSourcing 이라는 글에 잘 정리되어 있다.

CQRS 패턴은 종종 호불호가 갈리지만 클린 아키텍쳐에서 정의하고 있는 Interface Adapters 레이어의 역할을 대신하면서도 그 장점들을 활용할 수 있기 때문에 애플리케이션 레이어에서 구현하도록 한다.

오른쪽 다이어그램에는 (이 글에서는 위에) Interface Adapters에 대응하는 레이어가 없다. 레이어간의 경계를 넘는 객체를 매핑하는 레이어인데 CQRS (Command Query Responsibility Segregation)와 DTO를 사용하는 것으로 대치할 수 있기 때문이다.

From ASP.NET Core로 구현하는 클린 아키텍쳐 (2)

유스케이스 구현

이전에도 언급했듯이 애플리케이션 레이어는 유스케이스를 구현하는 장소다. 그리고, 또한가지 명심할 사항은 애플리케이션에 필요한 외부기능은 인터페이스로 정의하고 종속성 주입을 통해 사용해야 한다. SOLID 원칙중 하나인 Dependency Inversion 원칙을 준수하면 외부기능을 업그레이드하거나 대치하고자 할때 최소한의 노력만으로 가능해진다.

첫번째 유스케이스로 결재 요청에 대한 정보를 저장하고자 한다. 먼저 데이터베이스가 떠오른다. 데이터베이스를 결정하고 준비해야 하지 않을까?

클린 아키텍쳐의 대표적인 장점은 테스트 가능하다는 것이다. 데이터베이스 없이 TDD 접근방식으로 유스케이스를 구현해 보자. 이 단계에서 한가지 결정하고 갈 사안은 ORM 사용여부인데, Entity Framework Core를 사용하기로 한다. 이 단계에서는 데이터베이스 컨텍스트를 정의하고 In-memory 데이터베이스를 테스트에 활용하여 물릭적인 데이터베이스 없이 유스케이스를 구현하기로 한다.

인메모리 데이터베이스는 일반 관계형 데이터베이스와 다르게 동작할 수 있다는 단점이 있지만 유닛테스트 용도로 사용하기에는 편리하다. 그 단점에 대해서는 Testing code that uses EF Core에서 확인할 수 있다.

운영에 사용할 데이터베이스가 결정되면 Integration Test를 통해 운영에서 사용하는 것과 똑같은 데이터베이스로 테스트하는 것이 좋은 방법이다. 이 단계에서는 유스케이스를 구현한다는 것에만 중점을 둔다. ORM을 통해 데이터 액세스를 추상화하고 있기 때문에 데이터베이스의 종류나 실재 존재여부에 대해 신경쓰지 않아도 된다. 다만, 데이터베이스를 결정할 때, 선택한 ORM, 여기서는 EF Core가 지원하는지 확인할 필요는 있겠다.

데이터베이스 컨텍스트 정의

인메모리 데이터베이스를 사용할때도 데이터베이스 컨텍스트는 필요하다. 애플리케이션 레이어에 Interfaces라는 폴더는 외부에서 참조할 인터페이스를 모아둘텐데 데이터베이스 컨텍스트가 그 첫번째 인터페이스다.

namespace Application.Interfaces { public interface ICheckoutDbContext { DbSet<Payment> Payments { get; set; } Task<int> CommitAsync(CancellationToken cancellationToken); } }

이 인터페이스는 데이터베이스 테이블에 해당하는 엔티티를 DbSet으로 정의하고 변경내용을 저장하기 위한 CommitAsync 메서드를 포함한다.

데이터베이스 컨텍스트 구현

이제, Persistence 라는 이름의 새 프로젝트를 추가하고 데이터베이스 컨텍스트를 구현한다. 이때, 인터페이스를 정의하고 있는 Application 프로젝트를 참조한다.

ICheckoutDbContextDbSet<Payment> 형식을 사용하기 위해 어쩔 수 없이 EntityFrameworkCore 패키지를 애플리케이션에 설치해야 했다. Persistence 프로젝트에서 Application 프로젝트를 참조하므로 Persistence에서는 묵시적으로 EntityFrameworkCore 관련된 어셈블리를 참조하므로 별도로 패키지를 설치할 필요가 없다. 오히려 명시적으로 패키지를 설치하려면 버전도 동일하게 맞춰야 하는 등 충돌 소지가 생기는데 프로젝트간 참조 방식으로 일관성 있게 패키지를 사용한다.

namespace Persistence { public class CheckoutDbContext : DbContext, ICheckoutDbContext { public DbSet<Payment> Payments { get; set; } public CheckoutDbContext(DbContextOptions<CheckoutDbContext> options) : base(options) { } public Task<int> CommitAsync(CancellationToken cancellationToken) { return base.SaveChangesAsync(cancellationToken); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(CheckoutDbContext).Assembly); } } }

ICheckoutDbContext 인터페이스를 구현하면서 EF Core의 기능을 사용하기 위해 DbContext를 상속한다. 현재까지 특별한 요구사항이 없기 때문에 EF Core가 제공하는 SaveChangesAsysnc() 메서드를 호출하여 변경 내용을 저장하는 것으로 쉽게 인터페이스를 구현했다.

OnModelCreating() 메서드에서는 IEntityTypeConfiguration<T> 인터페이스를 구현하는 클래스를 찾아서 데이터베이스 컨텍스트를 구성하는데 사용한다. 아래의 PaymentConfiguration 클래스가 그 예다. 앞서 설명한 Value Object를 엔티티에 포함하기 위해 OwnsOne 메서드로 지정하고 있다.

namespace Persistence.Configurations { public class PaymentConfiguration : IEntityTypeConfiguration<Payment> { public void Configure(EntityTypeBuilder<Payment> builder) { builder.OwnsOne(p => p.CardNumber); builder.OwnsOne(p => p.CardExpiryDate); } } }

여기까지 데이터를 저장할 준비를 마쳤으니, 이제 유스케이스를 구현해보자.

(유스케이스 #1) 결재 요청에 대한 정보를 저장한다.

구현에 앞서 완벽하지 않더라도 테스트를 먼저 구성하는 것이 좋은 습관이다. 첫번째는 구현하려는 클래스를 사용하는 측면에서 생각하기 때문에 클래스와 메서드명을 좀더 의미있게 작성할 수 있고 메서드 분리 및 구성도 좋아진다. 두번째는 테스트 - 구현 - 리팩토링이라는 사이클을 자주 짧게 반복하여 구현과 테스트의 완성도를 높일 수 있기 때문이다.

public class CreatePaymentTests : CommandTestBase { [Fact] public async Task CreatePayment_GivenValidInput_ShouldSaveAPayment() { var command = new CreatePaymentCommand { MerchantId = 1, CardHolderName = "Jake Ryu", CardNumber = "1111-2222-3333-4444", CardExpiryDate = "05/24", Cvv = "978", Amount = 150 }; var sut = new CreatePaymentCommand.Handler(Context); await sut.Handle(command, CancellationToken.None); var payment = await Context.Payments.FirstAsync(); payment.MerchantId.Should().Be(1); payment.CardHolderName.Should().Be("Jake Ryu"); payment.CardNumber.OriginalValue.Should().Be("1111-2222-3333-4444"); payment.CardExpiryDate.Value.Should().Be(new DateTime(2024, 5, 31)); payment.Cvv.Should().Be("978"); payment.Amount.Should().Be(150); } } public class CommandTestBase : IDisposable { protected readonly CheckoutDbContext Context; public CommandTestBase() { Context = CheckoutDbContextFactory.Create(); } public void Dispose() { CheckoutDbContextFactory.Destroy(Context); } }

위와 같이 테스트를 구성했다. 주목할 점은 Payment 트랜잭션을 처리하기 위한 CreatePaymentCommand 객체를 사용한다는 것, 이 것을 처리하는 핸들러가 nested class로 커맨드 내에 존재한다는 점, 그리고 Handle 메서드가 저장의 핵심이라는 것이다. 따라서, 테스트 대상인 핸들러를 sut(system under test)로 정의했다.

구현 코드는 다음과 같다.

public class CreatePaymentCommand : IRequest { public int MerchantId { get; set; } public string CardHolderName { get; set; } public string CardNumber { get; set; } public string CardExpiryDate { get; set; } public string Cvv { get; set; } public decimal Amount { get; set; } public class Handler : IRequestHandler<CreatePaymentCommand> { private readonly ICheckoutDbContext context; public Handler(ICheckoutDbContext context) { this.context = context; } public async Task<Unit> Handle(CreatePaymentCommand request, CancellationToken cancellationToken) { var payment = new Payment(request.MerchantId, request.CardHolderName, request.CardNumber, request.CardExpiryDate, request.Cvv, request.Amount); await context.Payments.AddAsync(payment, cancellationToken); await context.CommitAsync(cancellationToken); return Unit.Value; } } }

위 코드에서 사용한 IRequest, IRequestHandler 인터페이스는 MediatR 이라는 패키지에 정의되어 있다. MediatR 패키지는 Mediater(중재자) 패턴을 구현하여 클래스 간의 결합을 느슨하게 가져갈 수 있도록 도와준다. 주로, Command & Handler 와 Notification & Subscriber 성격의 클래스를 사용하는 곳에서 접착제와 같은 역할을 하고 아래와 같이 사용한다.

var mediator = new Mediator(); var command = new CreatePaymentCommand() { MerchantId = 1, ... } mediator.Send(command)

MediatR를 통해 커맨드를 보내기만 하면 핸들러가 자동으로 호출된다. 커맨드와 핸들러가 인터페이스 정의방식으로 연결되므로 직접적인 관계를 갖지 않는다. 다만, 이 둘을 같은 파일에 넣는 이유는 코드 읽기가 편하기 때문이다.

유효성 검사

애플리케이션 레이어에 요청되는 조회 또는 변경 요청은 모두 유효성 검사가 필요하다. 특히 저장 요청에 대해서는 철저한 검사를 통해 데이터를 보호해야 한다.

유효성 검사는 프레젠테이션 레이어에 위치하는 API 프로젝트에서 할 수도 있다. 모바일 앱을 프레젠테이션 레이어에 추가한다면 앱의 특성상 항상 API를 사용할 것이기에 아키텍쳐에 문제되지 않는다. 그러나, 제 2의 API 프로젝트를 통해 애플리케이션의 다른 기능을 노출한다거나, 웹 사이트 프로젝트를 통해 다른 채널로 애플리케이션을 사용한다면 유효성 검사 로직은 어디에 두어야 할까?

애플리케이션 레이어에 유효성 검사 로직을 구현하면 프레젠테이션 레이어에 존재하는 모든 프로젝트에서 이를 재사용할 수 있다.

Fluent Validation

Fluent Validation은 강한 형식(strongly-typed)으로 유효성 규칙(rule)을 관리할 수 있는 패키지다. 사용도 쉽지만 특히 코드 가독성이 좋다. 반복적인 if 문장과 복잡한 조건을 읽는 것보다 편리하다.

public class CreatePaymentCommandValidator : AbstractValidator<CreatePaymentCommand> { public CreatePaymentCommandValidator() { RuleFor(x => x.MerchantId).NotEqual(0); RuleFor(x => x.CardHolderName).NotEmpty().MaximumLength(60); RuleFor(x => x.CardNumber).NotEmpty().CreditCard(); RuleFor(x => x.CardExpiryDate).NotEmpty().Must(str => { if (string.IsNullOrEmpty(str)) return false; var regex = new Regex(@"\b[0-1][0-9]/[0-9]{2}\b"); if (!regex.IsMatch(str)) return false; var month = int.Parse(str.Substring(0, 2)); return month >= 1 && month <= 12; }).WithMessage("Card expiration date should be mm/yy format"); RuleFor(x => x.Cvv).NotEmpty().Matches(@"\b[0-9]{3}\b");; RuleFor(x => x.Amount).GreaterThan(0); } }

CreatePaymentCommand의 속성을 검사하는 validator를 위와 같이 구현했다. Fluent Validation 문서에 보다 자세한 내용을 찾아볼 수 있겠지만, 위 코드를 한번 훑어보는 것만으로도 다양한 규칙이 존재하는 걸 알 수 있다.

이렇게 검사 로직을 별도 클래스로 분리한 덕에 핸들러에서는 본연의 임무에 충실할 수 있다. Validator를 사용하기 위해 Handle 메서드에 첫번째 두줄을 추가한다.

public async Task<Unit> Handle(CreatePaymentCommand request, CancellationToken cancellationToken) { var validator = new CreatePaymentCommandValidator(); await validator.ValidateAndThrowAsync(request, cancellationToken: cancellationToken); var payment = new Payment(request.MerchantId, request.CardHolderName, request.CardNumber, request.CardExpiryDate, request.Cvv, request.Amount); await context.Payments.AddAsync(payment, cancellationToken); await context.CommitAsync(cancellationToken); return Unit.Value; }

첫 두줄에서 유효성 검사를 통과하지 못하면 예외를 발생하고 있다. 이 부분은 나중에 MediatR이 제공하는 파이프라인 행동(pipeline behaviour) 패턴으로 자동화할 것이다. 다시 말해, 이 두줄을 모든 핸들러마다 추가하지 않고도 자동으로 검사할 뿐만 아니라 예외처리까지 중앙 집중식으로 구현할 것이다. MediatR의 진면목을 기대하시길 바란다.

이렇게 관심사(유효성 검사)를 분리한 덕에 테스트가 편해졌다. 클린 아키텍쳐를 사용하는 중요한 이유가 테스트인만큼 이를 소홀히할 수 없다.

  • 핸들러에 대해서는 커맨드 속성중 하나라도 문제가 있을때 예외를 발생하는지 테스트한다.
  • 별도 테스트 파일에 유효성 검사와 관련된 모든 내용을 테스트한다.
[Fact] public async Task CreatePayment_GivenInvalidInput_ShouldThrowAValidationException() { var command = new CreatePaymentCommand(); var sut = new CreatePaymentCommand.Handler(Context); await sut.Invoking(async h => await h.Handle(command, CancellationToken.None)) .Should().ThrowAsync<ValidationException>(); }

첫줄에서 CreatePaymentCommand 객체를 생성하면서 속성 지정을 하지 않았다. 따라서, 핸들러에서는 유효성 검사를 하고 예외를 발생할 것이다. 디버깅해서 예외를 확인해 보면 다음과 같이 유효성 검사 규칙에 실패한 모든 에러를 돌려준다.

validation-error

다음으로 CreatePaymentCommandValidator를 테스트하는 것은 상대적으로 쉽다. Validator에 값을 주고 결과를 보면 되는데 만약, 이것을 핸들러와 엮어서 그리고, 경우의 수를 다 들어서 테스트하려면 코드량도 많고 읽기에도 복잡했을 것이다. Seperation of concerns! Single responsibility!

public class CreatePaymentCommandValidatorTests { private readonly CreatePaymentCommandValidator validator = new CreatePaymentCommandValidator(); [Fact] public void MerchantId_ShouldBeNumber() { validator.ShouldHaveValidationErrorFor(x => x.MerchantId, 0); validator.ShouldNotHaveValidationErrorFor(x => x.MerchantId, 1); } [Fact] public void CardHolderName_ShouldNotBeEmpty() { validator.ShouldHaveValidationErrorFor(x => x.CardHolderName, ""); validator.ShouldNotHaveValidationErrorFor(x => x.CardHolderName, "John Smith"); } [Fact] public void CardHolderName_LengthShouldBeLessThanOrEqualTo60() { validator.ShouldHaveValidationErrorFor(x => x.CardHolderName, new string('A', 61)); validator.ShouldNotHaveValidationErrorFor(x => x.CardHolderName, new string('A', 60)); } [Fact] public void CardNumber_ShouldBeInRightFormats() { validator.ShouldHaveValidationErrorFor(x => x.CardNumber, "AAAA-BBBB"); validator.ShouldHaveValidationErrorFor(x => x.CardNumber, "AAAA-BBBB-1111-2222"); validator.ShouldHaveValidationErrorFor(x => x.CardNumber, "11112222333344"); validator.ShouldHaveValidationErrorFor(x => x.CardNumber, "1111-2222-3333-44"); validator.ShouldNotHaveValidationErrorFor(x => x.CardNumber, "1111222233334444"); validator.ShouldNotHaveValidationErrorFor(x => x.CardNumber, "1111-2222-3333-4444"); } [Fact] public void CardExpiryDate_ShouldBeInRightFormats() { validator.ShouldHaveValidationErrorFor(x => x.CardExpiryDate, "lo/lo"); validator.ShouldHaveValidationErrorFor(x => x.CardExpiryDate, "13/00"); validator.ShouldHaveValidationErrorFor(x => x.CardExpiryDate, "1220"); validator.ShouldHaveValidationErrorFor(x => x.CardExpiryDate, "00/00"); validator.ShouldNotHaveValidationErrorFor(x => x.CardExpiryDate, "01/00"); validator.ShouldNotHaveValidationErrorFor(x => x.CardExpiryDate, "12/99"); validator.ShouldNotHaveValidationErrorFor(x => x.CardExpiryDate, "05/20"); } [Fact] public void Cvv_ShouldBeThreeDigitNumber() { validator.ShouldHaveValidationErrorFor(x => x.Cvv, "abc"); validator.ShouldHaveValidationErrorFor(x => x.Cvv, "1bc"); validator.ShouldHaveValidationErrorFor(x => x.Cvv, "12"); validator.ShouldHaveValidationErrorFor(x => x.Cvv, "1234"); validator.ShouldHaveValidationErrorFor(x => x.Cvv, ""); validator.ShouldNotHaveValidationErrorFor(x => x.Cvv, "123"); } [Fact] public void Amount_ShouldBeGreaterThanZero() { validator.ShouldHaveValidationErrorFor(x => x.Amount, 0); validator.ShouldHaveValidationErrorFor(x => x.Amount, -1); validator.ShouldNotHaveValidationErrorFor(x => x.Amount, 1); } }

현재까지 구현된 전체 코드

중간정리

이번 글에서는 애플리케이션 레이어에서 다루는 내용을 시작했다. 애플리케이션 동작에 필요한 인터페이스를 정의하고 CQRS 아키텍쳐 패턴에 기반하여 결재요청을 저장하는 커맨드를 구현했다. 마지막으로 FluentValidation을 사용하여 유효성 검사까지 다루었다.

두번째 유스케이스로 결재내용 조회를 다루고자 했으나 글이 너무 길어졌다. 다음 편에서는 조회 요청을 다루면서 DTO 매핑, 예외 처리등 애플리케이션 레이어에서 다루는 나머지 내용과 MediatR의 고급활용에 대해 알아보겠다.