설치부터 실행까지 단계별로 상세하게 설명드리겠습니다. 특히 새로운 기능들과 실제 작동 원리에 집중하여 정리했습니다.
# Ardalis Clean Architecture 템플릿 설치
dotnet new install Ardalis.CleanArchitecture.Template
# 설치 확인
dotnet new list | grep clean
# 새 프로젝트 생성 (MyCleanApp이라는 폴더에 전체 솔루션 구조 생성)
dotnet new clean-arch -o MyCleanApp
# 생성된 폴더로 이동
cd MyCleanApp
# Visual Studio 또는 Rider로 솔루션 열기
start MyCleanApp.sln # Windows
open MyCleanApp.sln # macOS
MyCleanApp/
├── src/
│ ├── MyCleanApp.Core/ # 도메인 계층 (순수 비즈니스 로직)
│ ├── MyCleanApp.UseCases/ # 애플리케이션 계층 (유스케이스)
│ ├── MyCleanApp.Infrastructure/ # 인프라 계층 (DB, 외부 서비스)
│ └── MyCleanApp.Web/ # 프레젠테이션 계층 (API)
├── tests/
│ ├── MyCleanApp.UnitTests/
│ ├── MyCleanApp.IntegrationTests/
│ └── MyCleanApp.FunctionalTests/
└── MyCleanApp.AppHost/ # .NET Aspire 오케스트레이션
핵심 원칙: 의존성은 항상 안쪽(Core)을 향합니다
Web → Infrastructure → UseCases → Core
↓
Core (의존성 없음)
기존 방식의 문제점:
// ❌ 약한 타입 시스템 - 실수하기 쉬움
public void TransferMoney(int fromAccountId, int toAccountId, decimal amount)
{
// fromAccountId와 toAccountId를 바꿔도 컴파일 에러 없음!
}
새로운 방식 (Strongly Typed ID with Vogen):
// Core/ContributorAggregate/ContributorId.cs
using Vogen;
[ValueObject<int>]
public readonly partial struct ContributorId
{
// Vogen이 자동으로 생성:
// - 암묵적/명시적 변환
// - Equals, GetHashCode
// - JSON serialization
}
엔티티 정의:
// Core/ContributorAggregate/Contributor.cs
using Ardalis.GuardClauses;
using Ardalis.SharedKernel;
public class Contributor : EntityBase, IAggregateRoot
{
// private setter로 캡슐화
public string Name { get; private set; }
public ContributorStatus Status { get; private set; }
// 생성자에서 유효성 검사
public Contributor(string name)
{
Name = Guard.Against.NullOrEmpty(name, nameof(name));
Status = ContributorStatus.Active;
}
// 비즈니스 로직은 엔티티 내부에
public void UpdateName(string newName)
{
Name = Guard.Against.NullOrEmpty(newName, nameof(newName));
// 도메인 이벤트 발행 (새 기능!)
RegisterDomainEvent(new ContributorNameChangedEvent(this));
}
public void Deactivate()
{
Status = ContributorStatus.Inactive;
RegisterDomainEvent(new ContributorDeactivatedEvent(this));
}
}
// 상태를 열거형으로 명확히
public enum ContributorStatus
{
Active,
Inactive,
Suspended
}
기존 리포지토리 방식의 문제:
// ❌ 메서드가 무한정 증가
public interface IContributorRepository
{
Task<Contributor> GetByIdAsync(int id);
Task<Contributor> GetByNameAsync(string name);
Task<List<Contributor>> GetActiveContributorsAsync();
Task<List<Contributor>> GetContributorsByStatusAsync(Status status);
// ... 계속 추가됨
}
Specification 패턴으로 해결:
// Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs
using Ardalis.Specification;
public class ContributorByIdSpec : Specification<Contributor>
{
public ContributorByIdSpec(int contributorId)
{
Query
.Where(c => c.Id == contributorId)
.AsNoTracking(); // 읽기 전용 최적화
}
}
// Core/ContributorAggregate/Specifications/ActiveContributorsSpec.cs
public class ActiveContributorsSpec : Specification<Contributor>
{
public ActiveContributorsSpec()
{
Query
.Where(c => c.Status == ContributorStatus.Active)
.OrderBy(c => c.Name);
}
}
// 복잡한 쿼리도 조합 가능
public class ContributorWithProjectsSpec : Specification<Contributor>
{
public ContributorWithProjectsSpec(int contributorId)
{
Query
.Where(c => c.Id == contributorId)
.Include(c => c.Projects) // Eager loading
.ThenInclude(p => p.Tasks);
}
}
놀라운 점: 제네릭 리포지토리 하나로 모든 엔티티 처리!
// Infrastructure/Data/EfRepository.cs
using Ardalis.Specification.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
public class EfRepository<T> : RepositoryBase<T>, IReadRepository<T>, IRepository<T>
where T : class, IAggregateRoot
{
private readonly AppDbContext _dbContext;
public EfRepository(AppDbContext dbContext) : base(dbContext)
{
_dbContext = dbContext;
}
// Specification 라이브러리가 자동으로 처리:
// - FirstOrDefaultAsync(spec)
// - ListAsync(spec)
// - CountAsync(spec)
// - 모든 LINQ 쿼리를 SQL로 변환
}
DbContext 설정:
// Infrastructure/Data/AppDbContext.cs
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Contributor> Contributors => Set<Contributor>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Value Object 매핑 (Vogen 통합)
modelBuilder.Entity<Contributor>()
.Property(c => c.Id)
.HasConversion(
id => id.Value, // DB에 저장할 때
value => ContributorId.From(value) // 읽을 때
);
}
}
Command (쓰기 작업):
// UseCases/Contributors/Create/CreateContributorCommand.cs
using MediatR;
using Ardalis.Result;
public record CreateContributorCommand(string Name) : IRequest<Result<int>>;
// UseCases/Contributors/Create/CreateContributorHandler.cs
public class CreateContributorHandler : IRequestHandler<CreateContributorCommand, Result<int>>
{
private readonly IRepository<Contributor> _repository;
public CreateContributorHandler(IRepository<Contributor> repository)
{
_repository = repository;
}
public async Task<Result<int>> Handle(
CreateContributorCommand request,
CancellationToken cancellationToken)
{
// 1. 도메인 엔티티 생성
var contributor = new Contributor(request.Name);
// 2. 리포지토리에 저장
var createdContributor = await _repository.AddAsync(contributor, cancellationToken);
// 3. Result 패턴으로 반환
return Result.Success(createdContributor.Id);
}
}
Query (읽기 작업):
// UseCases/Contributors/Get/GetContributorQuery.cs
public record GetContributorQuery(int ContributorId) : IRequest<Result<ContributorDTO>>;
// DTO 정의
public record ContributorDTO(int Id, string Name, string Status);
// UseCases/Contributors/Get/GetContributorHandler.cs
public class GetContributorHandler
: IRequestHandler<GetContributorQuery, Result<ContributorDTO>>
{
private readonly IReadRepository<Contributor> _repository;
public GetContributorHandler(IReadRepository<Contributor> repository)
{
_repository = repository;
}
public async Task<Result<ContributorDTO>> Handle(
GetContributorQuery request,
CancellationToken cancellationToken)
{
// Specification 사용
var spec = new ContributorByIdSpec(request.ContributorId);
var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken);
if (entity == null)
return Result.NotFound($"Contributor with ID {request.ContributorId} not found.");
// 엔티티를 DTO로 변환
var dto = new ContributorDTO(
entity.Id,
entity.Name,
entity.Status.ToString()
);
return Result.Success(dto);
}
}
// Web/Contributors/ContributorEndpoints.cs
using FastEndpoints;
public class CreateContributorEndpoint : Endpoint<CreateContributorRequest, CreateContributorResponse>
{
private readonly IMediator _mediator;
public CreateContributorEndpoint(IMediator mediator)
{
_mediator = mediator;
}
public override void Configure()
{
Post("/api/contributors");
AllowAnonymous(); // 또는 Roles("Admin")
}
public override async Task HandleAsync(
CreateContributorRequest req,
CancellationToken ct)
{
// MediatR로 명령 전송
var command = new CreateContributorCommand(req.Name);
var result = await _mediator.Send(command, ct);
// Result 패턴 처리
if (result.IsSuccess)
{
await SendCreatedAtAsync<GetContributorEndpoint>(
new { id = result.Value },
new CreateContributorResponse(result.Value),
cancellation: ct
);
}
else
{
await SendNotFoundAsync(ct);
}
}
}
// Request/Response 모델
public record CreateContributorRequest(string Name);
public record CreateContributorResponse(int Id);
AppHost 프로젝트 설정:
// MyCleanApp.AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);
// PostgreSQL 컨테이너 추가
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin() // pgAdmin 대시보드 자동 추가
.AddDatabase("cleanarchdb");
// Redis 캐시
var redis = builder.AddRedis("cache");
// SMTP 서버 (이메일 테스트용 Papercut)
var smtp = builder.AddContainer("papercut", "jijiechen/papercut")
.WithHttpEndpoint(port: 37408, targetPort: 80, name: "papercut-ui");
// Web API 프로젝트 추가
builder.AddProject<Projects.MyCleanApp_Web>("webapi")
.WithReference(postgres)
.WithReference(redis)
.WithReference(smtp)
.WithEnvironment("SMTP_HOST", smtp.GetEndpoint("papercut-ui"));
builder.Build().Run();
실행 방법:
# AppHost를 시작 프로젝트로 설정하고 실행
dotnet run --project MyCleanApp.AppHost
# 브라우저에서 Aspire 대시보드 자동 오픈
# http://localhost:15000 (모든 서비스 상태 확인 가능)
Aspire 대시보드에서 확인 가능한 것들:
문제 상황:
// UseCases/Contributors/CreateContributorHandler.cs
public class CreateContributorHandler
{
public async Task Handle()
{
// ❌ UseCases에서 직접 DbContext 참조 - 아키텍처 위반!
using var db = new AppDbContext();
db.Contributors.Add(new Contributor());
}
}
NsDepCop 설정:
<!-- config.nsdepcop 파일 -->
<NsDepCopConfig>
<Allowed From="MyCleanApp.UseCases.*" To="MyCleanApp.Core.*" />
<Allowed From="MyCleanApp.Infrastructure.*" To="MyCleanApp.Core.*" />
<Allowed From="MyCleanApp.Infrastructure.*" To="MyCleanApp.UseCases.*" />
<Allowed From="MyCleanApp.Web.*" To="*" />
<!-- Core는 어디에도 의존하면 안됨 -->
<Disallowed From="MyCleanApp.Core.*" To="MyCleanApp.Infrastructure.*" />
<Disallowed From="MyCleanApp.Core.*" To="MyCleanApp.UseCases.*" />
<!-- UseCases는 Infrastructure에 의존하면 안됨 -->
<Disallowed From="MyCleanApp.UseCases.*" To="MyCleanApp.Infrastructure.*" />
</NsDepCopConfig>
결과:
Build Error: [NsDepCop] Namespace dependency violation detected!
MyCleanApp.UseCases.CreateContributorHandler cannot reference
MyCleanApp.Infrastructure.AppDbContext
설치:
dotnet add package Vogen
사용 예시:
// Core/ValueObjects/EmailAddress.cs
using Vogen;
[ValueObject<string>]
public readonly partial struct EmailAddress
{
// 유효성 검사 로직 추가
private static Validation Validate(string value)
{
if (string.IsNullOrWhiteSpace(value))
return Validation.Invalid("Email cannot be empty");
if (!value.Contains("@"))
return Validation.Invalid("Invalid email format");
return Validation.Ok;
}
}
// Core/ValueObjects/PhoneNumber.cs
[ValueObject<string>]
public readonly partial struct PhoneNumber
{
private static Validation Validate(string value)
{
if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^\d{3}-\d{4}-\d{4}$"))
return Validation.Invalid("Phone must be in format: 010-1234-5678");
return Validation.Ok;
}
}
타입 안전성 확보:
// ❌ 기존 방식 - 실수 가능
public void CreateUser(string email, string phone)
{
// email과 phone을 바꿔도 컴파일 에러 없음!
SendEmail(phone, email);
}
// ✅ Vogen 사용 - 컴파일 타임 에러
public void CreateUser(EmailAddress email, PhoneNumber phone)
{
// 컴파일 에러: Cannot convert PhoneNumber to EmailAddress
SendEmail(phone, email);
}
# Infrastructure 프로젝트에서 마이그레이션 생성
dotnet ef migrations add InitialCreate --project src/MyCleanApp.Infrastructure --startup-project src/MyCleanApp.Web
# 데이터베이스 업데이트
dotnet ef database update --project src/MyCleanApp.Infrastructure --startup-project src/MyCleanApp.Web
# AppHost로 전체 실행 (권장)
dotnet run --project MyCleanApp.AppHost
# 또는 Web만 개별 실행
dotnet run --project src/MyCleanApp.Web
브라우저에서 https://localhost:7001/swagger 접속
테스트 시나리오:
POST /api/contributors
{
"name": "John Doe"
}
Response: 201 Created
{
"id": 1
}
2. **Contributor 조회:**
```json
GET /api/contributors/1
Response: 200 OK
{
"id": 1,
"name": "John Doe",
"status": "Active"
}
PUT /api/contributors/1
{
"name": "Jane Smith"
}
Response: 204 No Content
---
## 🧪 6단계: 테스트 작성
### 단위 테스트 (Core 계층)
```csharp
// tests/MyCleanApp.UnitTests/Core/ContributorTests.cs
using Xunit;
public class ContributorTests
{
[Fact]
public void UpdateName_WithValidName_ShouldUpdateSuccessfully()
{
// Arrange
var contributor = new Contributor("Original Name");
// Act
contributor.UpdateName("New Name");
// Assert
Assert.Equal("New Name", contributor.Name);
}
[Fact]
public void UpdateName_WithEmptyName_ShouldThrowException()
{
// Arrange
var contributor = new Contributor("Original Name");
// Act & Assert
Assert.Throws<ArgumentException>(() =>
contributor.UpdateName("")
);
}
}
// tests/MyCleanApp.IntegrationTests/Data/EfRepositoryTests.cs
public class EfRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public EfRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task AddAsync_ShouldPersistToDatabase()
{
// Arrange
var repository = _fixture.GetRepository<Contributor>();
var contributor = new Contributor("Test User");
// Act
var saved = await repository.AddAsync(contributor);
// Assert
Assert.True(saved.Id > 0);
// Verify persistence
var spec = new ContributorByIdSpec(saved.Id);
var retrieved = await repository.FirstOrDefaultAsync(spec);
Assert.NotNull(retrieved);
Assert.Equal("Test User", retrieved.Name);
}
}
사용자 요청 (HTTP POST /api/contributors)
↓
Web Layer: ContributorEndpoint
↓ (MediatR.Send)
UseCases Layer: CreateContributorHandler
↓ (new Contributor)
Core Layer: Contributor Entity (비즈니스 로직)
↓ (repository.AddAsync)
Infrastructure Layer: EfRepository<Contributor>
↓ (DbContext.SaveChanges)
Database (PostgreSQL/SQL Server)
↓ (Result 반환)
사용자에게 201 Created 응답
이 구조는 초기 학습이 필요하지만, 중대형 프로젝트에서 장기적으로 엄청난 이점을 제공합니다. 궁금한 부분이 있으시면 말씀해주세요!
https://www.youtube.com/watch?v=rjefnUC9Z90
| ASP.NET CORE : Clean Architecture + Mediator 패턴 (MediatR ❌❌❌) (1) | 2025.10.03 |
|---|---|
| AutoMapper · MediatR — 상용화(라이선스 변경) 이슈 정리 (0) | 2025.09.26 |
| ASP.NET Core 8 : AWS SSO 를 활용한 S3 연동 구현하기 (1) | 2025.08.27 |
| ASP.NET Core MVC : [FromBody], [FromForm], [FromQuery] 사용해 보기 (1) | 2025.08.26 |
| ASP.NET CORE : 웹 개발을 위해 꼭 알아야하는 보안 대응 (DOMPurify) (3) | 2025.08.19 |