모던 웹 애플리케이션 아키텍처
WARNING
안녕하세요. VisualAcademy 박용준 강사입니다. 이 글의 내용은 제가 클린 아키텍처를 접하고 수 백 시간 학습과 고민한 결과에 대한 내용입니다. 다만, 이 내용은 제 주관적인 경험을 바탕으로 하기에 클린 아키텍처를 잘못 이해할 수도 있는 부분이 남겨져 있습니다. 클린 아키텍처에 대한 저의 해석이 궁금하면 의심을 가지면서 계속 읽어 나가면 됩니다. 감사합니다.
참고: 클린 아키텍처 저자 Robert C. Martin 강의
Robert (Bob) Martin은 육각형 아키텍처, 양파 아키텍처 및 소리치는(비명을 지르는; screaming?) 아키텍처를 포함한 일련의 아키텍처를 클린 아키텍처로 분류했습니다. 이 아키텍처는 진화적 아키텍처로서 수정이 용이하고 TDD와 같은 사례를 지원하기 때문에 인기를 얻었습니다.
이 강좌에서는 클린 아키텍처와 그 이점에 대해 설명합니다. 뿐만 아니라 .NET에서 클린 아키텍처를 구현하는 방법을 보여줍니다. 첫 번째 단계부터 작업 코드에 이르기까지 이 접근 방식을 수용하는 데 필요한 움직임을 보여주고 이를 달성하는 데 도움이 될 수 있는 일부 OSS 라이브러리를 소개합니다.
이 문서를 작성하면서 함께 살펴본 원본적인 아티클은 다음 경로에 있습니다.
- https://blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html
- https://blog.cleancoder.com/uncle-bob/2011/11/22/Clean-Architecture.html
IMPORTANT
클린 아키텍처의 목적은 처음 시작하는 사람도 엔터프라이즈 솔루션 개발에 쉽게 참여할 수 있도록 가이드를 제공받을 수 있는 하나의 뼈대입니다.
대부분의 기존 .NET 애플리케이션은 단일 IIS AppDomain에서 실행되는 실행 파일 또는 단일 웹 애플리케이션에 해당하는 단일 단위로 배포됩니다. 이 방법은 가장 간단한 배포 모델이며 다양한 내부 애플리케이션과 작은 퍼블릭 애플리케이션에 매우 적합합니다. 하지만 이 단일 배포 단위를 사용하더라도 대부분의 중요한 업무용 애플리케이션은 여러 레이어로 논리적 분리 시 이점을 얻을 수 있습니다.
모놀리식 애플리케이션
모놀리식 애플리케이션은 그 동작에 있어서 완전히 독립적인 애플리케이션입니다. 작업을 수행하는 과정에서 다른 서비스 또는 데이터 저장소와 상호 작용할 수도 있지만, 동작의 핵심은 자체 프로세스 내에서 실행되고 전체 애플리케이션은 일반적으로 하나의 단위로 배포됩니다. 이러한 애플리케이션을 수평으로 확장해야 하는 경우 일반적으로 전체 애플리케이션이 여러 서버 또는 가상 머신에서 중복됩니다.
올인원 애플리케이션
애플리케이션 아키텍처에 허용되는 최소 프로젝트 수는 하나입니다. 이 아키텍처에서 애플리케이션의 전체 로직은 단일 프로젝트에 포함되고, 단일 어셈블리로 컴파일되고, 단일 단위로 배포됩니다.
새 ASP.NET Core 프로젝트는 Visual Studio에서 만들었든 아니면 명령줄에서 만들었든, 간단한 "올인원" 모놀리스로 시작됩니다. 프레젠테이션, 비즈니스 및 데이터 액세스 논리를 포함하여 애플리케이션의 모든 동작을 포함합니다.
다음 그림은 단일 프로젝트 앱의 파일 구조를 보여줍니다.
그림: 단일 프로젝트 ASP.NET Core 앱.
단일 프로젝트 시나리오에서는 폴더를 사용하여 문제를 격리합니다. 기본 템플릿에는 데이터 및 서비스에 대한 추가 폴더 외에도 Models, Views 및 Controllers의 MVC 패턴 책임에 대한 별도의 폴더가 포함됩니다. 이 정렬에서 프레젠테이션 세부 정보는 되도록이면 Views 폴더로 제한되어야 하고, 데이터 액세스 구현 세부 정보는 Data 폴더에 보관되는 클래스로 제한되어야 합니다. 비즈니스 논리는 Models 폴더 내부의 서비스 및 클래스에 상주해야 합니다.
단일 프로젝트 모놀리식 솔루션은 간단하지만 몇 가지 단점이 있습니다. 프로젝트의 크기와 복잡성이 증가하면 파일 및 폴더의 수도 계속해서 함께 증가합니다. 사전순으로 그룹화되지 않은 여러 폴더에 UI(사용자 인터페이스) 문제(모델, 보기, 컨트롤러)가 있습니다. 이 문제는 Filters 또는 ModelBinders 같은 UI 수준의 구성이 자체 폴더에 추가될 때에만 악화됩니다. 비즈니스 논리는 Models 폴더와 Services 폴더 사이에 흩어져 있으며, 어떤 폴더의 어떤 클래스가 무엇에 의존해야 하는지 명확한 표시가 없습니다. 이와 같이 프로젝트 수준에서 깔끔하게 정리되지 않기 때문에 스파게티 코드(spaghetti code)로 이어지는 경우가 자주 있습니다.
이 문제를 해결하기 위해 종종 애플리케이션은 각 프로젝트가 애플리케이션의 특정 레이어에 상주하는 것으로 간주되는 다중 프로젝트 솔루션으로 발전합니다.
계층(Layers) 소개
증가하는 애플리케이션 복잡성을 관리하는 한 가지 방법은 애플리케이션의 책임이나 문제에 따라 애플리케이션을 나누는 것입니다. 이 방법은 문제 분리 원칙을 따르며, 개발자가 특정 기능이 어디에 구현되는지 쉽게 찾을 수 있도록 증가하는 코드베이스를 정리하는 데 도움이 됩니다. 하지만 계층화 아키텍처는 코드 구성 이상의 여러 가지 장점이 있습니다.
코드를 여러 레이어로 구성하면 공통 하위 수준 기능을 애플리케이션 전체에서 재사용할 수 있습니다. 이러한 재사용은 작성할 코드의 양이 줄어들고 애플리케이션에서 DRY(반복 금지) 원칙에 따라 단일 구현을 표준화할 수 있다는 장점이 있습니다.
계층화 아키텍처를 사용하면 애플리케이션에서 다른 레이어와 통신할 수 있는 레이어를 제한할 수 있습니다. 이 아키텍처는 캡슐화를 달성하는 데 도움이 됩니다. 한 레이어가 변경되거나 대체되면 해당 레이어와 함께 작동하는 레이어만 영향을 받습니다. 어떤 레이어가 어떤 레이어에 종속되는지를 제한하면 단일 변경 내용이 전체 애플리케이션에 영향을 미치지 않도록 변경의 영향을 줄일 수 있습니다.
레이어(및 캡슐화)를 사용하면 애플리케이션 내에서 훨씬 간단하게 기능을 대체할 수 있습니다. 예를 들어 애플리케이션에서 처음에는 지속성을 위해 자체 SQL Server 데이터베이스를 사용하지만, 나중에는 클라우드 기반 지속성 전략 또는 웹 API 뒤에 있는 것을 사용할 수 있습니다. 애플리케이션이 논리적 계층 내에서 지속성 구현을 적절하게 캡슐화한 경우 해당 SQL Server 관련 계층을 동일한 퍼블릭 인터페이스를 구현하는 새 계층으로 바꿀 수 있습니다.
향후 요구 사항의 변화에 따라 구현을 교환할 가능성 외에도, 애플리케이션 레이어를 사용하면 테스트 목적으로 구현을 쉽게 교환할 수 있습니다. 애플리케이션의 실제 데이터 레이어 또는 UI 레이어에 대해 작동하는 테스트를 작성하는 대신, 테스트 시 이러한 레이어를 요청에 대한 알려진 응답을 제공하는 가짜 구현으로 바꿀 수 있습니다. 이 방법을 사용하면 일반적으로 애플리케이션의 실제 인프라에 대해 테스트를 실행할 때보다 테스트를 훨씬 쉽게 작성하고 훨씬 빠르게 실행할 수 있습니다.
논리적 레이어링은 엔터프라이즈 소프트웨어 애플리케이션에서 코드 구성을 향상하는 일반적인 방법이며, 코드를 레이어로 구성할 수 있는 여러 가지 방법이 있습니다.
NOTE
레이어는 애플리케이션 내부의 논리적 분리를 나타냅니다. 애플리케이션 논리가 별도의 서버 또는 프로세스에 물리적으로 분산된 경우 이러한 별도의 실제 배포 대상을 계층이라고 부릅니다. N 레이어 애플리케이션을 단일 계층에 배포할 수 있으며, 이는 매우 일반적입니다.
전통적인 "N-티어"(N-Layer) 아키텍처
다음 그림은 애플리케이션 논리를 레이어로 구성하는 가장 일반적인 예입니다.
그림: 일반적인 애플리케이션 레이어.
이러한 레이어를 종종 줄여서 UI, BLL(비즈니스 논리 레이어) 및 DAL(데이터 액세스 레이어)이라고 합니다. 이 아키텍처를 사용하면 사용자는 BLL하고만 상호 작용하는 UI 레이어를 통해 요청을 만듭니다. 그러면 BLL은 데이터 액세스 요청을 위해 DAL을 호출할 수 있습니다. UI 레이어는 DAL에 대한 요청을 직접 만들면 안 되고, 다른 방법을 통해 지속성과 직접 상호 작용해서도 안 됩니다. 마찬가지로, BLL은 DAL을 통해서만 지속성과 상호 작용해야 합니다. 이러한 방식으로 레이어마다 잘 알려진 책임이 있습니다.
이 기존 레이어링 방식의 단점 중 하나는 컴파일 시간 종속성이 위에서 아래로 흐른다는 점입니다. 즉, UI 레이어가 BLL에 종속되고, BLL은 DAL에 종속됩니다. 다시 말해서, 일반적으로 애플리케이션에서 가장 중요한 논리를 보관하는 BLL이 데이터 액세스 구현 세부 정보(및 존재하는 데이터베이스)에 종속됩니다. 이러한 아키텍처에서 비즈니스 논리를 테스트하기가 어려운 경우가 종종 있으며, 테스트 데이터베이스가 필요합니다. 다음 섹션에서 설명드리겠지만, 종속성 반전 원칙을 사용하여 이 문제를 해결할 수 있습니다.
다음 그림은 애플리케이션을 책임(또는 레이어)에 따라 세 개 프로젝트로 분할하는 예제 솔루션을 보여줍니다.
그림: 세 개 프로젝트가 있는 간단한 모놀리식 애플리케이션
이 애플리케이션은 구성을 목적으로 여러 프로젝트를 사용하지만 여전히 단일 단위로 배포되고 그 클라이언트는 단일 웹앱과 상호 작용합니다. 따라서 배포 프로세스가 매우 간단합니다. 다음 그림은 Azure를 사용하여 이러한 앱을 호스팅하는 방법을 보여 줍니다.
그림: Azure 웹앱의 간단한 배포
애플리케이션 요구 사항이 증가하면 좀 더 복잡하고 강력한 배포 솔루션이 필요할 수 있습니다. 다음 그림은 추가 기능을 지원하는 좀 더 복잡한 배포 계획의 예를 보여줍니다.
그림: Azure App Service에 웹앱 배포
내부적으로 책임을 기반으로 이 프로젝트를 여러 프로젝트로 정리하면 애플리케이션의 유지 관리 용이성이 향상됩니다.
이 단위를 수직 확장 또는 수평 확장하여 클라우드 기반 주문형 확장성의 장점을 누릴 수 있습니다. 수직 확장이란 앱을 호스팅하는 서버에 추가 CPU, 메모리, 디스크 공간 또는 기타 리소스를 추가하는 것을 의미합니다. 수평 확장이란 물리적 서버,가상 머신 또는 컨테이너인지와 상관 없이 서버의 추가 인스턴스를 추가하는 것을 의미합니다. 앱이 여러 인스턴스에서 호스트되는 경우 부하 분산 장치를 사용하여 개별 앱 인스턴스에 요청을 할당합니다.
Azure에서 웹 애플리케이션을 확장하는 가장 간단한 방법은 애플리케이션의 App Service 계획에서 수동으로 확장하는 것입니다. 다음 그림은 앱에 서비스를 제공하는 인스턴스 수를 구성하는 적절한 Azure 대시보드 화면을 보여줍니다.
그림: Azure에서 App Service 계획 크기 조정
클린 아키텍처(Clean Architecture)
이 강의를 작성하기 위해 살펴본 동영상 목록입니다.
종속성 반전 원칙(Dependency Inversion Principle)과 DDD(도메인 주도 설계, 도메인 중심 디자인, Domain-Driven Design) 원칙을 따르는 애플리케이션은 비슷한 아키텍처에 도달하는 경향이 있습니다. 이 아키텍처는 수년 동안 여러 가지 이름으로 불렸습니다. 첫 번째 이름 중 하나는 육각형 아키텍처이고, 그 다음은 포트 및 어댑터(Ports-and-Adapters)였습니다. 최근에는 양파형 아키텍처(Onion Architecture) 또는 클린 아키텍처(Clean Architecture)로 불렸습니다. 두 번째 이름인 클린 아키텍처는 이 강좌에서 이 아키텍처에 대한 이름으로 사용됩니다.
그림: 클린 아키텍처 솔루션
eShopOnWeb 참조 애플리케이션은 프로젝트에 코드를 구성하는 데 클린 아키텍처 접근 방법을 사용합니다. ardalis/cleanarchitecture GitHub 리포지토리에서 자체 ASP.NET Core 솔루션의 시작 지점으로 사용할 수 있는 솔루션 템플릿을 찾거나 NuGet(installing the template from NuGet)에서 템플릿을 설치하면 됩니다.
클린 아키텍처는 비즈니스 논리와 애플리케이션 모델을 애플리케이션의 중심에 놓습니다. 비즈니스 논리가 데이터 액세스 또는 다른 인프라 고려 사항에 따라 달라지는 것이 아니라 이 종속성을 반전하여 인프라 및 구현 세부 사항이 애플리케이션 코어에 따라 달라집니다. 이 기능은 Application Core에서 추상화 또는 인터페이스를 정의하여 달성된 후 인프라 계층에서 정의된 형식에 따라 구현됩니다. 이 아키텍처를 시각화하는 일반적인 방법은 양파와 비슷한 일련의 동심원을 사용하는 것입니다.
다음 그림은 이 아키텍처 표현 스타일의 예를 보여 줍니다.
그림: 클린 아키텍처; 양파형 보기
이 다이어그램에서 종속성은 가장 안쪽에 있는 원을 향해 흐릅니다. Application Core는 이 다이어그램의 중심에 있는 해당 위치에서 해당 이름을 사용합니다. 또한 Application Core가 다른 애플리케이션 계층에 종속되지 않음을 다이어그램에서 확인할 수 있습니다. 정중앙에는 애플리케이션의 엔터티 및 인터페이스가 있습니다. 그 바로 바깥에는(여전히 Application Core 안에 있지만) 일반적으로 내부 원에 정의된 인터페이스를 구현하는 도메인 서비스가 있습니다. Application Core 밖에서는 UI 및 인프라 계층이 Application Core에 종속되지만 서로 종속되지는 않습니다.
다음 그림은 UI와 다른 레이어 간에 종속성을 좀 더 잘 반영하는 더 일반적인 수평 방향 레이어 다이어그램입니다.
그림: 클린 아키텍처; 수평 방향 레이어 보기
실선 화살표는 컴파일 시간 종속성을 나타내고, 파선 화살표는 런타임 전용 종속성을 나타냅니다. 클린 아키텍처를 사용하면 UI 레이어는 컴파일 시 Application Core에서 정의된 인터페이스와 함께 작동하며, 인프라 계층에 정의된 구현 형식에 대해서는 모르는 것이 가장 좋습니다. 하지만 런타임 시 앱이 실행되려면 이러한 구현 형식이 필요하며, 따라서 구현 형식이 있어야 하고 종속성 주입을 통해 Application Core 인터페이스에 연결되어야 합니다.
다음 그림은 이러한 권장 사항에 따라 빌드할 때의 ASP.NET Core 애플리케이션 아키텍처를 자세히 보여줍니다.
그림: 클린 아키텍처를 따르는 ASP.NET Core 아키텍처 다이어그램.
Application Core는 인프라에 종속되지 않기 때문에 이 레이어에 대한 자동화된 단위 테스트를 작성하기가 매우 쉽습니다. 그림 두 그림은 테스트가 이 아키텍처와 얼마나 잘 맞는지를 보여줍니다.
그림: 격리 상태로 Application Core 단위 테스트
그림: 외부 종속성으로 인프라 구현 통합 테스트
UI 레이어는 인프라 프로젝트에 정의된 형식에 조금도 종속되지 않으므로 테스트를 쉽게 하기 위해서든 아니면 애플리케이션 요구 사항의 변화에 대응하기 위해서든 구현을 교체하기가 매우 쉽습니다. ASP.NET Core는 종속성 주입을 기본적으로 사용하고 지원하기 때문에 특수 모놀리식 애플리케이션을 만드는 가장 적합한 방식은 이 아키텍처입니다.
모놀리식 애플리케이션의 경우 Application Core, 인프라 및 UI 프로젝트는 모두 단일 애플리케이션으로 실행됩니다. 런타임 애플리케이션 아키텍처는 그림 5-12처럼 보일 수 있습니다.
Figure 5-12. ASP.NET Core 앱의 런타임 아키텍처 샘플
클린 아키텍처에서 코드 구성
클린 아키텍처 솔루션에서는 프로젝트마다 명확한 책임이 있습니다. 따라서 각 프로젝트에 속하는 특정 형식이 있으며 해당 프로젝트에서 이러한 형식에 해당하는 폴더를 자주 볼 수 있습니다.
애플리케이션 코어(Application Core)
Application Core는 비즈니스 모델을 보관하며, 비즈니스 모델에는 엔터티, 서비스 및 인터페이스가 포함됩니다. 이러한 인터페이스는 데이터 액세스, 파일 시스템 액세스, 네트워크 호출 등의 인프라를 사용하여 수행되는 작업의 추상화를 포함합니다. 경우에 따라 이 레이어에 정의된 서비스 또는 인터페이스가 UI 또는 인프라에 종속되지 않은 비 엔터티 형식과 함께 작동해야 합니다. 이러한 동작은 간단한 DTO(데이터 전송 개체)로 정의할 수 있습니다.
애플리케이션 코어 형식
- Entities(엔터티(유지되는 비즈니스 모델 클래스))
- Aggregates(집계(엔터티 그룹))
- Interfaces(인터페이스)
- Domain Services(도메인 서비스)
- Specifications(사양)
- Custom Exceptions(사용자 지정 예외) 및 Guard Clauses(가드 절)
- Domain Events(도메인 이벤트) 및 Handlers(처리기)
도메인
여기에는 도메인 계층에 특정한 모든 엔터티, 열거형, 예외, 인터페이스, 유형 및 논리가 포함됩니다.
엔터티 또는 엔터티 오브젝트는 비즈니스 오브젝트로도 불립니다.
애플리케이션
이 계층에는 모든 애플리케이션 논리가 포함됩니다. 도메인 계층에 종속되지만 다른 계층이나 프로젝트에 대한 종속성은 없습니다. 이 계층은 외부 계층에 의해 구현되는 인터페이스를 정의합니다. 예를 들어 애플리케이션이 알림 서비스에 액세스해야 하는 경우 애플리케이션에 새 인터페이스가 추가되고 인프라 내에서 구현이 생성됩니다.
Application 계층에서는 사용 사례(Use Case) 기반으로 소프트웨어가 설계됩니다. MVC와 같이 카테고리 관점으로 모아서 관리하지 않고 CreateOrder와 같이 의미기능 기능별로 모아서 관리하는 걸 권장합니다.
사용자 -> 딜리버리 메커니즘 -> 바운더리 -> 작용(Interactor) -> Entity
인프라(Infrastructure)
Infrastructure 프로젝트는 OS 및 Cloud와 밀접하게 관련이 있는 계층입니다.
이 계층에는 파일 시스템, 웹 서비스, SMTP 등과 같은 외부 리소스에 액세스하기 위한 클래스가 포함되어 있습니다. 이러한 클래스는 애플리케이션 계층 내에 정의된 인터페이스를 기반으로 해야 합니다.
인프라 프로젝트는 일반적으로 데이터 액세스 구현을 포함합니다. 일반적인 ASP.NET Core 웹 애플리케이션에서는 EF(Entity Framework) Core DbContext, 정의된 EF Core Migration
개체 및 데이터 액세스 구현 클래스가 해당 구현에 포함됩니다. 데이터 액세스 구현 코드를 추상화하는 가장 일반적인 방법은 리포지토리 디자인 패턴(Repository design pattern)을 사용하는 것입니다.
인프라 프로젝트는 데이터 액세스 구현 외에도 인프라 문제와 상호 작용해야 하는 서비스 구현을 포함해야 합니다. 이러한 서비스는 Application Core에 정의된 인터페이스를 구현해야 하며, 따라서 인프라에 Application Core 프로젝트에 대한 참조가 있어야 합니다.
Persistence 프로젝트는 DB와 밀접하게 관련이 있는 계층입니다.
인프라 형식
- EF Core 형식 (
DbContext
,Migration
) - 데이터 액세스 구현 형식(리포지토리)
- 인프라 관련 서비스(예:
FileLogger
또는SmtpNotifier
)
UI 계층(UI Layer)
ASP.NET Core MVC 애플리케이션의 사용자 인터페이스 레이어는 애플리케이션의 진입점입니다. 이 프로젝트는 Application Core 프로젝트를 참조해야 하고, 그 형식은 Application Core에 정의된 인터페이스를 통해 인프라와 엄격하게 상호 작용해야 합니다. 어느 인프라 계층 형식의 직접 인스턴스화 또는 정적 호출도 UI 레이어에서 허용되어서는 안 됩니다.
UI 계층 형식
- Controllers(컨트롤러)
- Custom Filters(사용자 지정 필터)
- Custom Middleware(사용자 지정 미들웨어)
- Views(뷰)
- ViewModels(뷰모델)
- Program(Startup) 클래스
Startup
클래스 또는 Program.cs 파일은 애플리케이션을 구성하고 구현 형식을 인터페이스에 연결하는 작업을 담당합니다. 이 논리를 수행하는 곳을 앱의 컴퍼지션 루트라고 하며, 이곳에서는 런타임 시 종속성 주입이 정상적으로 작동합니다.
NOTE
앱을 시작하는 동안 종속성 주입을 연결하려면 UI 계층 프로젝트가 인프라 프로젝트를 참조해야 할 수 있습니다. 이 종속성은 제거할 수 있으며, 가장 쉬운 방법은 어셈블리에서의 형식 로드를 기본적으로 지원하는 사용자 지정 DI 컨테이너를 사용하는 것입니다. 이 샘플의 목적에 따른 가장 간단한 방법은 UI 프로젝트가 인프라 프로젝트를 참조하도록 허용하는 것입니다(그러나 개발자는 인프라 프로젝트에서의 형식에 대한 실제 참조를 앱의 컴퍼지션 루트로 제한해야 합니다).
WebUI
이 계층은 Angular 14 및 ASP.NET Core 7을 기반으로 하는 단일 페이지 애플리케이션입니다. 이 계층은 애플리케이션 및 인프라 계층 모두에 종속되지만 인프라에 대한 종속성은 종속성 주입을 지원하기 위한 것뿐입니다. 따라서 Startup.cs 만 인프라를 참조해야 합니다.
기타 웹 애플리케이션 아키텍처 스타일
- Web-Queue-Worker: 이 아키텍처의 핵심 구성 요소는 클라이언트 요청을 제공하는 웹 프런트 엔드 및 리소스 집약적 작업, 장기 실행 워크플로 또는 일괄 작업을 수행하는 작업자입니다. 웹 프런트 엔드는 메시지 큐를 통해 작업자와 통신합니다.
- N-Tier: N 계층 아키텍처는 애플리케이션을 논리적 계층 및 물리적 계층으로 나눕니다.
- Microservice: 마이크로 서비스 아키텍처는 소규모 자율 서비스 컬렉션으로 구성됩니다. 각 서비스는 독립적이며 제한된 컨텍스트 내에서 단일 비즈니스 기능을 구현해야 합니다.
참고 자료
- 엉클 밥의 클린 아키텍처 소개 블로그 아티클
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html - The Onion Architecture
https://jeffreypalermo.com/blog/the-onion-architecture-part-1/ - The Repository Pattern
https://deviq.com/repository-pattern/ - Clean Architecture Solution Template
https://github.com/ardalis/cleanarchitecture - Architecting Microservices e-book
https://aka.ms/MicroservicesEbook
VisualAcademy 솔루션
VisualAcademy 웹 사이트를 기준으로 클린 아키텍처를 적용하는 방법을 단계별로 살펴보겠습니다.
클린 아키텍처 기반으로 구현하기에 앞서서 먼저 다음 영상을 추천합니다. 은탄환 신드롬 이야기입니다. Part 1과 Part 2 모두 보는 걸 추천합니다.
경로: 실버불릿 신드롬
이 세상에는 모든 경우에 최적화된 방법(아키텍처)은 없다라는 결론을 냅니다.
마찬가지로 우리가 적용하는 아키텍처가 가장 좋은 아키텍처는 아니지만, 현재 강의 시점에서는 가장 좋은 아키텍처일 수 있습니다.
이 아키텍처에 우리 코드들을 의지해 보도록 하죠...
그림: TODO 솔루션
src, tests 솔루션 폴더 생성
그림: src, tests 솔루션 폴더 생성
Domain 프로젝트 생성
그림: Domain 프로젝트 생성
그림: 뼈대 폴더 생성
Applicaiton 프로젝트 생성
그림: Applicaiton 프로젝트 생성
MediatR 패키지 설치
MediatR.Extensions.Microsoft.DependencyInjection
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
</ItemGroup>
</Project>
AutoMapper 패키지 설치
AutoMapper.Extensions.Microsoft.DependencyInjection
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
</ItemGroup>
</Project>
Core 솔루션 폴더
Core 이름으로 솔루션 폴더를 만들고 Domain 프로젝트와 Application 프로젝트를 이곳으로 이동합니다.
Domain 프로젝트
Common 폴더와 Entities 폴더에 클래스 생성
AuditableBase 클래스와 이번 강의의 핵심 클래스인 Todo 클래스를 생성합니다.
그림: Common 폴더와 Entities 폴더에 클래스 생성
코드: AuditableBase.cs
namespace Domain.Common;
/// <summary>
/// AuditableBase 클래스: 레코드에 대한 상태 추적을 위한 4개의 속성 제공
/// </summary>
public class AuditableBase
{
/// <summary>
/// 등록자: CreatedBy, Creator
/// </summary>
public string? CreatedBy { get; set; }
/// <summary>
/// 등록일: Created
/// </summary>
public DateTime? Created { get; set; }
/// <summary>
/// 수정자: ModifiedBy, LastModifiedBy
/// </summary>
public string? ModifiedBy { get; set; }
/// <summary>
/// 수정일: Modified, LastModified
/// </summary>
public DateTime? Modified { get; set; }
}
IEntity 인터페이스와 Todo 클래스
코드: IEntity.cs
namespace Domain.Common;
public interface IEntity
{
int Id { get; set; }
}
Domain 프로젝트의 Entities 폴더에 Todo.cs 도메인 클래스를 생성합니다.
코드: Todo.cs
namespace Domain.Entities;
public class Todo : IEntity
{
public int Id { get; set; }
public string? Name { get; set; }
public bool IsComplete { get; set; }
}
다음 그림은 IEntity 인터페이스를 상속하는 Todo 클래스를 보여줍니다.
그림: IEntity 인터페이스를 상속하는 Todo 클래스
Application 프로젝트에서 Domain 프로젝트 참조
Application 프로젝트에 패키지 설치
Microsoft.EntityFrameworkCore
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
</Project>
코드: IApplicationDbContext.cs
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Application.Common.Interfaces;
public interface IApplicationDbContext
{
DbSet<Todo> Todos { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
코드: ICurrentUserService.cs
namespace Application.Common.Interfaces;
public interface ICurrentUserService
{
string? UserId { get; }
}
코드: IDatabaseService.cs
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Application.Common.Interfaces;
public interface IDatabaseService
{
DbSet<Todo> Todos { get; }
void Save();
}
Domain 클래스
테스트를 위한 패키지 3개를 추가합니다. 나중에 테스트 프로젝트로 따로 분리합니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Enums\" />
<Folder Include="Events\" />
<Folder Include="Exceptions\" />
<Folder Include="ValueObjects\" />
</ItemGroup>
</Project>
테스트 프로젝트
현재 강의 시점에 버전 업데이트된 NuGet 패키지 목록은 다음과 같습니다.
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
Todo 테스트
코드: TodoTests.cs
using NUnit.Framework;
namespace Domain.Entities;
[TestFixture]
public class TodoTests
{
private Todo _todo = null!;
private const int Id = 1;
private const string Name = "Test";
[SetUp]
public void SetUp()
{
_todo = new Todo();
}
[Test]
public void TestSetAndGetId()
{
_todo.Id = Id;
Assert.That(_todo.Id, Is.EqualTo(Id));
}
[Test]
public void TestSetAndGetName()
{
_todo.Name = Name;
Assert.That(_todo.Name, Is.EqualTo(Name));
}
}
SetUp
특성으로 테스트 클래스에 필요한 개체를 미리 준비 후 테스트하기
Application 프로젝트
그림: Application과 Domain
IDatabaseService.cs
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Application.Common.Interfaces
{
public interface IDatabaseService
{
DbSet<Todo> Todos { get; }
void Save();
}
}
IDatabaseService 인터페이스와 IApplicationDbContext 인터페이스 추가
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Application.Common.Interfaces
{
public interface IDatabaseService
{
DbSet<Todo> Todos { get; }
void Save();
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Application.Common.Interfaces
{
internal interface IApplicationDbContext
{
}
}
Application.UnitTests 프로젝트 생성
코드: Application.UnitTests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="Moq.AutoMock" Version="3.4.0" />
<PackageReference Include="Moq.EntityFrameworkCore" Version="6.0.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
</ItemGroup>
</Project>
코드: GetTodosQueryTests.cs
using Application.Common.Interfaces;
using Application.Todos.Queries.GetTodos;
using Domain.Entities;
using Moq.AutoMock;
using Moq.EntityFrameworkCore;
using NUnit.Framework;
namespace Application.UnitTests.Todos;
[TestFixture]
public class GetTodosQueryTests
{
private AutoMocker _mocker = null!;
private Todo _todo = null!;
private GetTodosQuery _query = null!;
private const int Id = 1;
private const string Name = "Todo 1";
private const bool IsComplete = false;
[SetUp]
public void SetUp()
{
_mocker = new AutoMocker();
_todo = new Todo()
{
Id = Id,
Name = Name,
IsComplete = IsComplete
};
_mocker.GetMock<IDatabaseService>()
.Setup(x => x.Todos)
.ReturnsDbSet(new List<Todo> { _todo });
_query = _mocker.CreateInstance<GetTodosQuery>();
}
[Test]
public void TestExecuteShouldReturnListOfTodos()
{
var results = _query.Execute();
var result = results.Single();
Assert.That(result.Id, Is.EqualTo(Id));
Assert.That(result.Name, Is.EqualTo(Name));
Assert.That(result.IsComplete, Is.EqualTo(IsComplete));
}
}
UI에서 IDatabaseService.Save 메서드 구현 및 컨트롤러에서 사용하기
CQRS
VisualAcademy\Application\Todos\Queries\GetTodos\TodoModel.cs
namespace Application.Todos.Queries.GetTodos;
public class TodoModel
{
public int Id { get; set; }
public string? Name { get; set; }
public bool IsComplete { get; set; }
}
VisualAcademy\Application\Todos\Queries\GetTodos\IGetTodosQuery.cs
namespace Application.Todos.Queries.GetTodos;
public interface IGetTodosQuery
{
List<TodoModel> Execute();
}
VisualAcademy\Application\Todos\Queries\GetTodos\GetTodosQuery.cs
using Application.Common.Interfaces;
namespace Application.Todos.Queries.GetTodos;
public class GetTodosQuery : IGetTodosQuery
{
private readonly IDatabaseService _db;
public GetTodosQuery(IDatabaseService db) => _db = db;
public List<TodoModel> Execute()
{
var todos = _db.Todos.Select(x => new TodoModel
{
Id = x.Id,
Name = x.Name,
IsComplete = x.IsComplete
});
return todos.ToList();
}
}
VisualAcademy\Application\Todos\Commands\CreateTodo\CreateTodoModel.cs
namespace Application.Todos.Commands.CreateTodo;
public class CreateTodoModel
{
public string? Name { get; set; }
public bool IsComplete { get; set; }
}
VisualAcademy\Application\Todos\Commands\CreateTodo\ICreateTodoCommand.cs
namespace Application.Todos.Commands.CreateTodo;
public interface ICreateTodoCommand
{
void Execute(CreateTodoModel model);
}
VisualAcademy\Application\Todos\Commands\CreateTodo\CreateTodoCommand.cs
using Application.Common.Interfaces;
using Domain.Entities;
namespace Application.Todos.Commands.CreateTodo;
public class CreateTodoCommand : ICreateTodoCommand
{
private readonly IDatabaseService _db;
public CreateTodoCommand(IDatabaseService db) => _db = db;
public void Execute(CreateTodoModel model)
{
var todo = new Todo();
todo.Name = model.Name;
todo.IsComplete = model.IsComplete;
_db.Todos.Add(todo);
_db.Save();
}
}
Infrastructure 프로젝트
IEmailService 인터페이스
VisualAcademy\Application\Common\Interfaces\IEmailService.cs
namespace Application.Common.Interfaces;
public interface IEmailService
{
Task SendEmailAsync(
string email,
string subject,
string message,
bool isBodyHtml = true);
}
AutoMapper
MediatR
참고 동영상
이 문서를 만들기 위해서 참고한 한 번 이상 시청한 동영상 목록입니다.