재우니 개발자 블로그

ASP.NET Core 10 클린 아키텍처 완전 가이드

 

설치부터 실행까지 단계별로 상세하게 설명드리겠습니다. 특히 새로운 기능들과 실제 작동 원리에 집중하여 정리했습니다.


📦 1단계: 설치 및 프로젝트 생성

템플릿 설치

# 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 오케스트레이션

 

 


🏗️ 2단계: 아키텍처 계층별 상세 분석

의존성 규칙 (Dependency Rule)

핵심 원칙: 의존성은 항상 안쪽(Core)을 향합니다

Web → Infrastructure → UseCases → Core
                          ↓
                        Core (의존성 없음)

 

 


💻 3단계: 코드 레벨 구현 (실제 작동 원리)

A. 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
}

 

 


B. Core 계층: Specification 패턴 (🌟 핵심 기능)

기존 리포지토리 방식의 문제:

// ❌ 메서드가 무한정 증가
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);
    }
}

 

 


C. Infrastructure 계층: 리포지토리 구현

놀라운 점: 제네릭 리포지토리 하나로 모든 엔티티 처리!

// 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)  // 읽을 때
            );
    }
}

 

 


D. UseCases 계층: CQRS with MediatR

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);
    }
}

 

 


E. Web 계층: API Endpoint (Minimal API 스타일)

// 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);

 

 

 


🚀 4단계: 새로운 핵심 기능들

1️⃣ .NET Aspire 통합 (클라우드 네이티브 오케스트레이션)

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 대시보드에서 확인 가능한 것들:

  • 각 컨테이너의 로그 실시간 스트리밍
  • 데이터베이스 연결 상태
  • API 엔드포인트 호출 트레이싱
  • Papercut UI로 발송된 이메일 미리보기

2️⃣ NsDepCop (네임스페이스 의존성 강제)

문제 상황:

// 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

 

 


3️⃣ Vogen (Value Object Generator)

설치:

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);
}

 

 


🔧 5단계: 실제 실행 및 테스트

데이터베이스 마이그레이션

# 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

 

 

 

Swagger로 API 테스트

브라우저에서 https://localhost:7001/swagger 접속

테스트 시나리오:

  1. Contributor 생성:
    POST /api/contributors
    {
    "name": "John Doe"
    }
    
  2.  
Response: 201 Created
{
"id": 1
}

 


2. **Contributor 조회:**
```json
GET /api/contributors/1

Response: 200 OK
{
  "id": 1,
  "name": "John Doe",
  "status": "Active"
}

 

 

  1. 이름 업데이트:
    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("")
        );
    }
}

 

 

통합 테스트 (Repository + Database)

// 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 응답

 

 


💡 핵심 장점 요약

  1. 테스트 용이성: Core는 외부 의존성이 없어 순수 단위 테스트 가능
  2. 유지보수성: Specification 패턴으로 쿼리 로직 재사용
  3. 타입 안전성: Vogen으로 컴파일 타임 에러 감지
  4. 아키텍처 강제: NsDepCop으로 의존성 규칙 위반 방지
  5. 개발 생산성: .NET Aspire로 로컬 환경 자동 구성

 

 

이 구조는 초기 학습이 필요하지만, 중대형 프로젝트에서 장기적으로 엄청난 이점을 제공합니다. 궁금한 부분이 있으시면 말씀해주세요!

 

 


 

발췌

 

https://www.youtube.com/watch?v=rjefnUC9Z90