닷넷관련/ASP.NET CORE 🍔
ASP.NET Core 10 클린 아키텍처 완전 가이드(youtube)
재우니
2025. 11. 30. 02:46
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 접속
테스트 시나리오:
- Contributor 생성:
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("")
);
}
}
통합 테스트 (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 응답
💡 핵심 장점 요약
- 테스트 용이성: Core는 외부 의존성이 없어 순수 단위 테스트 가능
- 유지보수성: Specification 패턴으로 쿼리 로직 재사용
- 타입 안전성: Vogen으로 컴파일 타임 에러 감지
- 아키텍처 강제: NsDepCop으로 의존성 규칙 위반 방지
- 개발 생산성: .NET Aspire로 로컬 환경 자동 구성
이 구조는 초기 학습이 필요하지만, 중대형 프로젝트에서 장기적으로 엄청난 이점을 제공합니다. 궁금한 부분이 있으시면 말씀해주세요!
발췌
https://www.youtube.com/watch?v=rjefnUC9Z90