Clean Architecture 정리 - 5부 22장 클린아키텍처
2025. 7. 24. 22:46

[참고] https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

 

의존성

  • 소프트웨어는 중심(엔티티)으로 갈수록 고수준 정책을 나타냄
  • 의존성은 반드시 안쪽(고수준)을 향해야 하며, 외부 원의 이름을 내부에서 직접 참조해서는 안 됨
  • 내부에서 외부를 사용할 땐 인터페이스를 통해 간접 의존해야 함

엔티티 (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 객체 직접 전달 금지 → 외부 기술에 종속됨
  • 항상 외부 ↔ 내부 포맷 간 명확한 변환 계층 필요

시나리오 예시 (코드 예시 포함)

다이어그램은 클린 아키텍처의 각 계층이 실제로 어떻게 동작하는지 보여주는 흐름 예시이다.

주된 흐름은 다음과 같다:

  1. Controller (Interface Adapter 계층)
    • 사용자의 입력 데이터를 수신하여 InputData 객체로 변환.
    • 이를 InputBoundary 인터페이스를 통해 UseCaseInteractor로 전달.
  2. UseCaseInteractor (Use Case 계층)
    • 전달받은 데이터를 해석하고 Entity에게 위임하여 처리.
    • Entity에 필요한 데이터를 DataAccessInterface를 통해 외부에서 불러오고 Entity에게 전달.
    • Entity가 작업을 마치면 결과를 받아 OutputData 객체를 구성함.
    • 이 OutputData는 
      • OutputBoundary를 통해 Presenter로 전달됨.
      • DataAccessInterface를 통해 DB가 저장/수정/삭제를 할 수 있게 함. [ 필요하다면 ]
  3. Presenter (Interface Adapter 계층)
    • OutputData를 ViewModel로 변환.
    • 예: 날짜 객체 → 문자열, 통화 포맷 등
  4. 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장을 기반으로 학습 목적으로 요약한 글입니다.

※ 이 글은 책의 내용을 요약한 것으로, 원문 없이 읽을 경우 오해의 여지가 있을 수 있습니다. 정확한 이해를 위해 원서의 정독을 권장합니다.