Angular의 클린 아키텍처: 확장 가능하고 유지 관리 가능한 구조
La 클린 아키텍처Robert C. Martin(Bob 삼촌)이 제안한 는 모델을 정의합니다. 비즈니스 규칙이 구현 세부 사항과 분리된 코드 구성입니다. Angular에 적용된 이 아키텍처를 사용하면 시간이 지나도 변함없는 애플리케이션을 구축할 수 있습니다. 테스트를 용이하게 하고 별도의 비용 없이 프레임워크, 라이브러리 및 인프라를 교체할 수 있습니다. 도메인 로직을 다시 작성하십시오.
이 글에서는 Clean 원칙에 따라 Angular 프로젝트를 구성하는 방법을 살펴보겠습니다. 별도의 레이어가 있는 아키텍처 도메인, 애플리케이션, 하부 구조 e 프레젠테이션. 종속성 규칙을 다루겠습니다. 종속성 반전 및 사용 사례, 리포지토리 및 매퍼와 같은 구체적인 패턴.
무엇을 배울 것인가
- 클린 아키텍처의 기본 원칙
- Angular 프로젝트에서 폴더를 구성하는 방법
- 사용 사례/인터랙터 패턴
- 데이터 액세스를 위한 저장소 패턴
- 도메인 모델과 DTO 간의 매핑
- 종속성 주입 및 종속성 반전
- SOLID 원칙의 실제 적용
기본 원칙
클린 아키텍처는 다음과 같은 핵심 개념을 기반으로 합니다. 중독은 항상 지적한다 안쪽으로. 외부 레이어(UI, 데이터베이스, API)는 내부 레이어에 따라 달라집니다. (비즈니스 로직, 도메인 엔터티), 절대 그 반대가 아닙니다. 이 원리는 다음과 같이 알려져 있습니다. 종속성 규칙, 애플리케이션의 핵심이 독립적으로 유지되도록 보장 특정 기술로.
4개의 레이어
| 레이어 | 책임 | 각도의 예 |
|---|---|---|
| 도메인 | 엔터티, 값 개체, 저장소 인터페이스 | 순수 TypeScript 클래스, Angular 종속성 없음 |
| 애플리케이션 | 사용 사례, 논리 조정 | 도메인 운영을 조정하는 서비스 |
| 하부 구조 | 구체적인 구현, 데이터 액세스, API | HttpClient, localStorage, Firebase, 어댑터 |
| 프레젠테이션 | UI, 컴포넌트, 시각적 상태 관리 | 각도 구성 요소, 템플릿, 파이프 |
폴더 구조
폴더 구조는 아키텍처 레이어를 직접 반영합니다. 모든 기능 응용 프로그램은 동일한 계획에 따라 구성되어 프로젝트를 만듭니다. 나중에 팀에 합류하는 개발자도 예측 가능하고 탐색 가능합니다.
src/
app/
core/ # Servizi singleton, guard, interceptor
interceptors/
guards/
features/
orders/ # Feature: Gestione Ordini
domain/
models/
order.model.ts # Entità di dominio
order-item.model.ts # Value Object
repositories/
order.repository.ts # Interfaccia (porta)
application/
use-cases/
create-order.use-case.ts
get-orders.use-case.ts
cancel-order.use-case.ts
dto/
create-order.dto.ts
order-response.dto.ts
mappers/
order.mapper.ts
infrastructure/
repositories/
order-http.repository.ts # Implementazione HTTP
order-mock.repository.ts # Implementazione mock
adapters/
order-api.adapter.ts
presentation/
pages/
order-list/
order-detail/
components/
order-card/
order-form/
state/
orders.store.ts
shared/ # Componenti e utility condivisi
ui/
pipes/
directives/
레이어 도메인
도메인 레이어에는 다음이 포함됩니다. 사업체 그리고 인터페이스 이는 데이터 액세스 계약을 정의합니다. 이 레이어에는 종속성이 없습니다. Angular, RxJS 또는 외부 라이브러리에서. 이는 순수한 TypeScript 클래스 및 인터페이스입니다.
도메인 엔터티
엔터티는 비즈니스의 중심 개념을 나타냅니다. 유효성 검사 규칙이 포함되어 있습니다. 그리고 도메인의 본질적인 논리. 단순한 데이터 컨테이너(빈혈 모델)가 아닙니다. 하지만 행동이 풍부한 개체입니다.
// domain/models/order.model.ts
export type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
export class Order {
constructor(
public readonly id: string,
public readonly customerId: string,
private _items: OrderItem[],
private _status: OrderStatus = 'draft',
public readonly createdAt: Date = new Date()
) {
if (_items.length === 0) {
throw new Error('Un ordine deve contenere almeno un articolo');
}
}
get status(): OrderStatus {
return this._status;
}
get items(): ReadonlyArray<OrderItem> {
return [...this._items];
}
get total(): number {
return this._items.reduce(
(sum, item) => sum + item.subtotal, 0
);
}
confirm(): void {
if (this._status !== 'draft') {
throw new Error('Solo gli ordini in bozza possono essere confermati');
}
this._status = 'confirmed';
}
cancel(): void {
if (this._status === 'shipped' || this._status === 'delivered') {
throw new Error('Non è possibile cancellare un ordine già spedito');
}
this._status = 'cancelled';
}
addItem(item: OrderItem): void {
const existing = this._items.find(i => i.productId === item.productId);
if (existing) {
throw new Error('Prodotto già presente. Aggiorna la quantità.');
}
this._items.push(item);
}
}
값 개체
값 개체는 ID가 아닌 값으로 정의된 불변 개체입니다. 동일한 속성을 가진 두 개의 값 개체는 동일한 것으로 간주됩니다. 그들은 이상적입니다 가격, 주소, 수량과 같은 개념을 나타냅니다.
// domain/models/order-item.model.ts
export class OrderItem {
public readonly subtotal: number;
constructor(
public readonly productId: string,
public readonly productName: string,
public readonly quantity: number,
public readonly unitPrice: number
) {
if (quantity <= 0) {
throw new Error('La quantità deve essere maggiore di zero');
}
if (unitPrice < 0) {
throw new Error('Il prezzo unitario non può essere negativo');
}
this.subtotal = quantity * unitPrice;
}
equals(other: OrderItem): boolean {
return this.productId === other.productId
&& this.quantity === other.quantity
&& this.unitPrice === other.unitPrice;
}
}
리포지토리 인터페이스(포트)
저장소 인터페이스는 다음을 정의합니다. 계약 데이터에 접근하기 위해. 도메인이 필요로 하는 것을 나타내기 때문에 도메인 계층에 상주합니다. 데이터를 얻는 방법을 지정합니다. 이것이 의존성 역전의 본질이다.
// domain/repositories/order.repository.ts
import { Observable } from 'rxjs';
import { Order } from '../models/order.model';
export abstract class OrderRepository {
abstract getAll(): Observable<Order[]>;
abstract getById(id: string): Observable<Order>;
abstract getByCustomer(customerId: string): Observable<Order[]>;
abstract save(order: Order): Observable<Order>;
abstract delete(id: string): Observable<void>;
}
왜 인터페이스가 아닌 추상 클래스인가요?
Angular에서 종속성 주입 시스템에는 다음이 필요합니다. 토큰 런타임에. TypeScript 인터페이스는 컴파일 타임에 제거되며 DI 토큰으로 사용됩니다. 반면에 추상 클래스는 런타임에 존재하며 두 가지 역할을 모두 수행합니다. 주입을 위한 토큰을 제공하는 계약입니다.
애플리케이션 계층: 사용 사례
응용 프로그램 계층에는 다음이 포함됩니다. 사용 사례 (또는 상호작용자), 오케스트레이션 도메인 엔터티와 리포지토리를 조정하여 비즈니스 논리를 관리합니다. 각 사용 사례는 애플리케이션이 수행할 수 있는 단일 작업입니다. 이 레이어는 도메인에 따라 다릅니다. 하지만 인프라나 프리젠테이션의 세부 사항은 모릅니다.
사용 사례의 구조
// application/use-cases/create-order.use-case.ts
import { Injectable } from '@angular/core';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { CreateOrderDto } from '../dto/create-order.dto';
import { OrderResponseDto } from '../dto/order-response.dto';
import { OrderMapper } from '../mappers/order.mapper';
@Injectable({ providedIn: 'root' })
export class CreateOrderUseCase {
constructor(private readonly orderRepo: OrderRepository) {}
execute(dto: CreateOrderDto): Observable<OrderResponseDto> {
const items = dto.items.map(
i => new OrderItem(i.productId, i.productName, i.quantity, i.unitPrice)
);
const order = new Order(
crypto.randomUUID(),
dto.customerId,
items
);
return this.orderRepo.save(order).pipe(
map(saved => OrderMapper.toResponse(saved))
);
}
}
DTO 및 매퍼
I 데이터 전송 객체 (DTO) 들어오는 데이터의 형식을 정의합니다. 응용 프로그램 계층에서 나옵니다. 그만큼 매퍼 도메인 엔터티 간 변환 및 DTO를 사용하여 두 세계를 분리합니다.
// application/dto/create-order.dto.ts
export interface CreateOrderDto {
customerId: string;
items: {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
}[];
}
// application/dto/order-response.dto.ts
export interface OrderResponseDto {
id: string;
customerId: string;
status: string;
items: {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}[];
total: number;
createdAt: string;
}
// application/mappers/order.mapper.ts
import { Order } from '../../domain/models/order.model';
import { OrderResponseDto } from '../dto/order-response.dto';
export class OrderMapper {
static toResponse(order: Order): OrderResponseDto {
return {
id: order.id,
customerId: order.customerId,
status: order.status,
items: order.items.map(item => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
subtotal: item.subtotal,
})),
total: order.total,
createdAt: order.createdAt.toISOString(),
};
}
}
인프라 계층
인프라 계층은 다음을 제공합니다. 구체적인 구현 인터페이스 도메인에 정의되어 있습니다. 이는 HTTP 호출이 상주하는 곳이며, localStorage에 대한 지속성, Firebase 및 기타 기술 세부 사항과의 통합. 이 레이어는 도메인(인터페이스 구현을 위해)과 외부 라이브러리(HttpClient, Firebase SDK) 모두에서.
// infrastructure/repositories/order-http.repository.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
interface OrderApiResponse {
id: string;
customer_id: string;
status: string;
items: {
product_id: string;
product_name: string;
qty: number;
price: number;
}[];
created_at: string;
}
@Injectable()
export class OrderHttpRepository extends OrderRepository {
private readonly baseUrl = '/api/orders';
constructor(private readonly http: HttpClient) {
super();
}
getAll(): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(this.baseUrl).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
getById(id: string): Observable<Order> {
return this.http.get<OrderApiResponse>(
`${this.baseUrl}/${id}`
).pipe(
map(r => this.toDomain(r))
);
}
getByCustomer(customerId: string): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(
`${this.baseUrl}?customer=${customerId}`
).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
save(order: Order): Observable<Order> {
const payload = this.toApi(order);
return this.http.post<OrderApiResponse>(this.baseUrl, payload).pipe(
map(r => this.toDomain(r))
);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
private toDomain(response: OrderApiResponse): Order {
const items = response.items.map(
i => new OrderItem(i.product_id, i.product_name, i.qty, i.price)
);
return new Order(
response.id,
response.customer_id,
items,
response.status as any,
new Date(response.created_at)
);
}
private toApi(order: Order): object {
return {
customer_id: order.customerId,
status: order.status,
items: order.items.map(i => ({
product_id: i.productId,
product_name: i.productName,
qty: i.quantity,
price: i.unitPrice,
})),
};
}
}
종속성 주입 구성
인터페이스와 구현 간의 연결은 Angular의 DI 시스템을 통해 이루어집니다. 공급자 구성에서 구체적인 구현을 지정합니다. 저장소 추상 클래스가 요청될 때 사용해야 합니다.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { OrderRepository } from './features/orders/domain/repositories/order.repository';
import { OrderHttpRepository } from './features/orders/infrastructure/repositories/order-http.repository';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{
provide: OrderRepository,
useClass: OrderHttpRepository,
},
],
};
장점: 교체성
한 줄의 구성만 변경하면 다음과 같이 전환할 수 있습니다. OrderHttpRepository
a OrderMockRepository 테스트를 위해 또는 OrderFirebaseRepository
백엔드를 마이그레이션하기로 결정한 경우. 나머지 응용 프로그램은 변경되지 않습니다.
프리젠테이션 계층
프레젠테이션 계층에는 Angular 구성 요소, 템플릿 및 상태 관리가 포함됩니다. 시각적. 구성 요소는 i와 독점적으로 상호 작용합니다. 사용 사례 레이어의 도메인이나 인프라 세부 정보를 모르는 상태에서 애플리케이션.
// presentation/pages/order-list/order-list.component.ts
import { Component, OnInit, signal } from '@angular/core';
import { GetOrdersUseCase } from '../../../application/use-cases/get-orders.use-case';
import { OrderResponseDto } from '../../../application/dto/order-response.dto';
@Component({
selector: 'app-order-list',
standalone: true,
templateUrl: './order-list.component.html',
styleUrls: ['./order-list.component.css'],
})
export class OrderListComponent implements OnInit {
orders = signal<OrderResponseDto[]>([]);
loading = signal(true);
error = signal<string | null>(null);
constructor(private readonly getOrders: GetOrdersUseCase) {}
ngOnInit(): void {
this.getOrders.execute().subscribe({
next: (data) => {
this.orders.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set('Errore nel caricamento degli ordini');
this.loading.set(false);
},
});
}
}
실제의 견고한 원칙
클린 아키텍처는 프로젝트에 SOLID 원칙을 적용하기 위한 자연스러운 수단입니다. 각도. 우리가 정의한 구조에서 각 원칙이 어떻게 나타나는지 살펴보겠습니다.
| 원칙 | 클린 아키텍처에 적용 |
|---|---|
| 단일 책임 | 각 유스 케이스에는 단 하나의 책임이 있습니다. 각 계층에는 잘 정의된 역할이 있습니다. |
| 개방/폐쇄 | 기존 코드를 수정하지 않고 저장소(예: Firebase)를 새로 구현합니다. |
| 리스코프 대체 | 저장소의 모든 구현은 부작용 없이 현재 저장소를 대체할 수 있습니다. |
| 인터페이스 분리 | DTO는 필요한 데이터만 노출합니다. 저장소 인터페이스는 기능별로 다릅니다. |
| 의존성 반전 | 상위 계층(사용 사례)은 구체적인 구현이 아닌 추상화(추상 저장소)에 의존합니다. |
클린 아키텍처에서 테스트
클린 아키텍처의 가장 큰 장점 중 하나는 테스트 가능성. 종속성 분리 덕분에 각 계층을 개별적으로 테스트할 수 있습니다.
// application/use-cases/create-order.use-case.spec.ts
import { of } from 'rxjs';
import { CreateOrderUseCase } from './create-order.use-case';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
let mockRepo: jasmine.SpyObj<OrderRepository>;
beforeEach(() => {
mockRepo = jasmine.createSpyObj('OrderRepository', ['save']);
useCase = new CreateOrderUseCase(mockRepo);
});
it('should create an order and return a response DTO', (done) => {
const dto = {
customerId: 'cust-1',
items: [{
productId: 'prod-1',
productName: 'Widget',
quantity: 2,
unitPrice: 25.00,
}],
};
const savedOrder = new Order(
'order-123', 'cust-1',
[new OrderItem('prod-1', 'Widget', 2, 25.00)]
);
mockRepo.save.and.returnValue(of(savedOrder));
useCase.execute(dto).subscribe(result => {
expect(result.id).toBe('order-123');
expect(result.total).toBe(50);
expect(result.items.length).toBe(1);
expect(mockRepo.save).toHaveBeenCalledTimes(1);
done();
});
});
});
피해야 할 일반적인 실수
클린 아키텍처를 채택하려면 규율이 필요합니다. 가장 자주 발생하는 오류는 다음과 같습니다. 이는 아키텍처의 이점을 손상시킵니다.
| 실수 | 결과 | 해결책 |
|---|---|---|
| 구성 요소에서 직접 HttpClient 사용 | UI와 인프라 간의 결합 | 항상 사용 사례와 저장소를 살펴보세요. |
| 빈약한 도메인 엔터티(데이터만 있고 논리 없음) | 비즈니스 로직이 서비스로 종료되어 테스트하기 어려움 | 검증 및 동작으로 엔터티 강화 |
| 엔터티와 1:1로 일치하는 DTO | 도메인 변경은 API에 직접적인 영향을 미칩니다. | DTO와 모델을 독립적으로 유지하고 Mapper를 사용하세요. |
| 레이어 간의 순환 종속성 | 종속성 규칙 위반, 취약한 코드 | 종속성이 항상 도메인을 가리키는지 확인하십시오. |
| 간단한 애플리케이션을 위한 오버엔지니어링 | 정당화되지 않은 복잡성, 개발 속도 저하 | Clean Architecture를 채택하기 전에 프로젝트의 복잡성을 평가하십시오. |
클린 아키텍처를 채택해야 하는 경우
클린 아키텍처가 모든 문제의 해결책은 아닙니다. 채택에는 비용이 듭니다. 초기 복잡성과 코드 양 측면에서. 다음은 실무 가이드입니다. 언제 사용하는 것이 합리적인지 결정하십시오.
빠른 평가
| 대본 | 클린 아키텍처? | 대안 |
|---|---|---|
| 프로토타입 / MVP | No | 간단한 기능 기반 구조 |
| 간단한 CRUD 애플리케이션 | 아마도 그렇지 않을 것이다 | 계층화된 아키텍처(MVC) |
| 복잡한 로직을 갖춘 엔터프라이즈 애플리케이션 | Si | - |
| 훌륭한 팀, 긴 유지 관리 예상 | Si | - |
| 백엔드 또는 기술의 변경 가능성 | Si | - |
결론
Angular에 적용된 Clean Architecture는 데이터 구조화 방식을 변화시킵니다. 프론트엔드 애플리케이션. 도메인과 기술을 분리하면 더 많은 코드를 얻을 수 있습니다. 테스트 가능하고 유지 관리가 가능하며 변경에 강합니다. 거기 종속성 규칙 프레임워크를 변경하더라도 애플리케이션의 핵심이 안정적으로 유지되도록 보장합니다. 도서관이나 외부 서비스.
상용구 측면의 초기 비용은 유지 관리의 용이성으로 보상됩니다. 장기적으로는 구조의 명확성과 사람들이 일할 수 있는 가능성을 통해 독립된 레이어에 있는 다양한 팀. 다른 아키텍처 선택과 마찬가지로 이는 기본적으로 조정되어야 합니다. 프로젝트의 실제 복잡성: 비즈니스 로직을 갖춘 엔터프라이즈 애플리케이션의 경우 중요한 점은 클린 아키텍처가 시간이 지남에 따라 성과를 거두는 투자라는 점입니다.







