의존성
- 소프트웨어는 중심(엔티티)으로 갈수록 고수준 정책을 나타냄
- 의존성은 반드시 안쪽(고수준)을 향해야 하며, 외부 원의 이름을 내부에서 직접 참조해서는 안 됨
- 내부에서 외부를 사용할 땐 인터페이스를 통해 간접 의존해야 함
엔티티 (Entity)
- 핵심 업무 규칙을 담당하는 계층
- 기업 전반에 걸쳐 재사용 가능한 고수준 정책
- 외부 변경(유스케이스, UI, DB 등)으로 인해 변경되면 안 됨
유스케이스 (Use Case)
- 애플리케이션에 특화된 업무 규칙을 포함
- 엔티티를 활용해 유스케이스 목적을 달성
- 시스템의 모든 유스케이스를 이 계층이 구현함
- UI나 DB 변경에는 영향받지 않지만, 비즈니스 흐름 변경에는 수정되어야 함
인터페이스 어댑터 (Interface Adapter)
- 내부와 외부 간의 데이터 변환을 담당하는 계층
- 유스케이스와 외부 장치 사이를 연결하는 계층
- 예: DB row → Input DTO / Output DTO → Presenter
- 프레젠터, 컨트롤러, 뷰모델 등이 포함됨
- 외부 장치(SQL, 외부 API 등)를 다루는 것은 이 계층에서 완전히 소화되어야 하며, 안쪽 계층은 이를 몰라야 함
프레임워크와 드라이버
- 가장 바깥 계층으로, DB나 웹 프레임워크 같은 세부 기술이 포함됨
- 안쪽과 통신하는 최소한의 접합 코드만 작성
원은 네 개여야만 하나?
- 원의 개수는 고정 아님. 더 많을 수 있음
- 중요한 건 의존성은 항상 안쪽으로 향해야 함
경계 횡단하기
- 클린아키텍처 그림에서 볼 수 있는 제어 흐름: Controller → UseCase → Presenter
- 소스 코드 의존성: UseCase 방향(안쪽)을 향해야 함
- 유스케이스가 프레젠터 호출 시, 직접 호출하지 않고 출력 포트 인터페이스를 통해 호출 → DIP 적용
경계를 넘는 데이터 구조
- 함수 호출로 전달되는 데이터는 반드시 단순 구조체(DTO)
- 예: struct, dict, 문자열 등
- 엔티티나 DB row 객체 직접 전달 금지 → 외부 기술에 종속됨
- 항상 외부 ↔ 내부 포맷 간 명확한 변환 계층 필요
시나리오 예시 (코드 예시 포함)
다이어그램은 클린 아키텍처의 각 계층이 실제로 어떻게 동작하는지 보여주는 흐름 예시이다.
주된 흐름은 다음과 같다:
- Controller (Interface Adapter 계층)
- 사용자의 입력 데이터를 수신하여 InputData 객체로 변환.
- 이를 InputBoundary 인터페이스를 통해 UseCaseInteractor로 전달.
- UseCaseInteractor (Use Case 계층)
- 전달받은 데이터를 해석하고 Entity에게 위임하여 처리.
- Entity에 필요한 데이터를 DataAccessInterface를 통해 외부에서 불러오고 Entity에게 전달.
- Entity가 작업을 마치면 결과를 받아 OutputData 객체를 구성함.
- 이 OutputData는
- OutputBoundary를 통해 Presenter로 전달됨.
- DataAccessInterface를 통해 DB가 저장/수정/삭제를 할 수 있게 함. [ 필요하다면 ]
- Presenter (Interface Adapter 계층)
- OutputData를 ViewModel로 변환.
- 예: 날짜 객체 → 문자열, 통화 포맷 등
- ViewModel / View (Interface Adapter 계층)
- 변환된 데이터는 ViewModel에 담겨 View로 전달됨.
- ViewModel은 View 렌더링에 필요한 모든 정보를 가짐 (버튼 활성화 여부, 텍스트 등 포함).
- View는 단순히 ViewModel을 화면에 표현할 뿐, 별도의 로직은 없음.
// 1. Input DTO
// 사용자 요청 값 정의 (Interface Adapter 계층)
export interface CreateOrderRequest {
menuId: string;
userId: string;
address: string;
}
// 2. Controller
// 요청 수신 → Input DTO 구성 → InputBoundary 호출 (Interface Adapter 계층)
export class CreateOrderController {
constructor(private readonly useCase: CreateOrderInputBoundary) {}
async handle(req: any): Promise<void> {
const request: CreateOrderRequest = {
menuId: req.body.menuId,
userId: req.body.userId,
address: req.body.address,
};
await this.useCase.execute(request);
}
}
// 3. Input Boundary
// 유스케이스 입력 인터페이스 정의 (Use Case 계층)
export interface CreateOrderInputBoundary {
execute(request: CreateOrderRequest): Promise<void>;
}
// 4. UseCaseInteractor
// 핵심 유스케이스 처리, Entity와 DB 연동 (Use Case 계층)
export class CreateOrderInteractor implements CreateOrderInputBoundary {
constructor(
private readonly repository: OrderRepository,
private readonly presenter: CreateOrderOutputBoundary
) {}
async execute(request: CreateOrderRequest): Promise<void> {
const basePrice = await this.repository.fetchMenuPrice(request.menuId);
const deliveryFee = await this.repository.fetchDeliveryFee(request.address);
const platformFeeRate = await this.repository.fetchPlatformFeeRate();
const order = new Order(
crypto.randomUUID(),
request.menuId,
request.userId,
request.address,
new Date(),
basePrice,
deliveryFee,
platformFeeRate
);
await this.repository.save(order);
this.presenter.present({
orderId: order.orderId,
totalAmount: order.calculateTotalAmount(),
deliveryTimeEstimate: '약 30분',
});
}
}
// 5. Repository 인터페이스
// 외부에서 데이터를 가져오는 인터페이스 (인터페이스 어뎁터 계층)
export interface OrderRepository {
fetchMenuPrice(menuId: string): Promise<number>;
fetchDeliveryFee(address: string): Promise<number>;
fetchPlatformFeeRate(): Promise<number>;
save(order: Order): Promise<void>;
}
// 6. Entity
// 수수료 계산 등 핵심 비즈니스 규칙 포함 (Entity 계층)
export class Order {
constructor(
public readonly orderId: string,
public readonly menuId: string,
public readonly userId: string,
public readonly address: string,
public readonly createdAt: Date,
public readonly basePrice: number,
public readonly deliveryFee: number,
public readonly platformFeeRate: number
) {}
calculateTotalAmount(): number {
const platformFee = this.basePrice * this.platformFeeRate;
return this.basePrice + this.deliveryFee + platformFee;
}
}
// 7. Data Access 구현체
// 저장 및 외부 데이터 조회 (드라이버 계층)
export class MySQLOrderRepository implements OrderRepository {
constructor(private readonly connection: mysql.Connection) {}
async fetchMenuPrice(menuId: string): Promise<number> {
const [rows] = await this.connection.execute(
'SELECT price FROM menus WHERE id = ?',
[menuId]
);
const row = Array.isArray(rows) ? rows[0] as any : null;
return row?.price ?? 0;
}
async fetchDeliveryFee(address: string): Promise<number> {
// 지역 기반 배달료 예시
return address.includes('서울') ? 3000 : 4000;
}
async fetchPlatformFeeRate(): Promise<number> {
return 0.1; // 예시 상수
}
async save(order: Order): Promise<void> {
await this.connection.execute(
'INSERT INTO orders (id, menu_id, user_id, address, created_at, base_price, delivery_fee, fee_rate) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
order.orderId,
order.menuId,
order.userId,
order.address,
order.createdAt,
order.basePrice,
order.deliveryFee,
order.platformFeeRate
]
);
}
}
// 8. Output DTO
// 유스케이스 결과 데이터 구조 (Interface Adapter 계층)
export interface CreateOrderResponse {
orderId: string;
totalAmount: number;
deliveryTimeEstimate: string;
}
// 9. Output Boundary
// 결과를 ViewModel로 전달하기 위한 인터페이스 (Use Case 계층)
export interface CreateOrderOutputBoundary {
present(response: CreateOrderResponse): void;
}
// 10. Presenter
// OutputData를 ViewModel로 가공하여 View 전달 (Interface Adapter 계층)
export class CreateOrderPresenter implements CreateOrderOutputBoundary {
present(response: CreateOrderResponse): void {
const viewModel = {
message: `주문이 완료되었습니다. 총액은 ${response.totalAmount}원입니다.`,
expectedDelivery: response.deliveryTimeEstimate,
};
console.log('ViewModel:', viewModel);
}
}
// 11. MySQL 드라이버
// 외부 시스템와 연결 설정 (Framework & Driver 계층)
import mysql from 'mysql2/promise';
import { MySQLOrderRepository } from './infra/MySQLOrderRepository';
import { CreateOrderInteractor } from './application/CreateOrderInteractor';
import { CreateOrderPresenter } from './interface/CreateOrderPresenter';
const connection = await mysql.createConnection({ /* config */ });
// 12. main 함수
async function main() {
const connection = await mysql.createConnection({ /* config */ });
const repository = new MySQLOrderRepository(connection);
const presenter = new CreateOrderPresenter();
const useCase = new CreateOrderInteractor(repository, presenter);
const controller = new CreateOrderController(useCase);
await controller.handle({
body: {
menuId: 'menu-123',
userId: 'user-456',
address: '서울시 강남구'
}
});
}
main();
참조하면 좋을 링크: https://www.youtube.com/watch?v=g6Tg6_qpIVc
※ 본 글은 『Clean Architecture』(로버트 C. 마틴 저) 5부 22장을 기반으로 학습 목적으로 요약한 글입니다.
※ 이 글은 책의 내용을 요약한 것으로, 원문 없이 읽을 경우 오해의 여지가 있을 수 있습니다. 정확한 이해를 위해 원서의 정독을 권장합니다.
'아키텍처' 카테고리의 다른 글
Clean Architecture 정리 - 5부 24장 부분적 경계 (3) | 2025.07.25 |
---|---|
Clean Architecture 정리 - 5부 23장 프레젠터와 험블객체 (1) | 2025.07.25 |
Clean Architecture 정리 - 5부 21장 소리치는 아키텍처 (0) | 2025.07.24 |
Clean Architecture 정리 - 5부 20장 아키텍처: 엔티티, 유스케이스 (2) | 2025.07.24 |
Clean Architecture 정리 – 5부 19장 아키텍처: 정책과 수준 (0) | 2025.07.23 |