Clean Architecture

클린 아키텍처란?

  • 로버트 C 마틴이 제시한 개념

  • 소프트웨어 시스템 아키텍처 디자인 패턴 중 하나

  • 소프트웨어 시스템을 설계하고 구성하는 방법

  • 소프트웨어 시스템을 깔끔하게 유지하고 확장 가능하며 유지보수성을 높힘

아키텍처 특징

  • 프레임워크 독립성

  • 테스트 용이성

  • UI 독립성

  • 데이터베이스 독립성

  • 모든 외부 에이전시에 대한 독립성

가장 중요한 규칙 (의존성)

  • 안쪽에 있을수록 고수준의 정책을 의미함

    • 고수준의 정책은 바깥쪽을 의존하지 않게

  • 의존성은 반드시 안쪽으로 향함

주요 구성 요소

엔티티

  • 비즈니스 규칙을 담고 있는 엔티티, 가장 안쪽에 위치하며 가장 안정적인 부분

  • 핵심 업무 규칙

  • 외부의 변경으로부터 영향을 받지 않는 영역

유스케이스

  • 비즈니스 규칙을 적용하고 시스템의 행위를 제어하는 부분

  • 어플리케이션에 특화된 업무 규칙을 포함

인터페이스 어댑터

  • 데이터를 외부와 주고받을 때 사용하는 부분

  • 컨트롤러, 프레젠터, 게이트웨이

프레임워크와 드라이버

  • 외부 도구, 프레임워크, 라이브러리 등과의 연결을 담당

  • 프레임워크, 데이터베이스, 웹 서버 등

장점

1. 유지보수성 향상

  • 각 구성 요소가 독립적으로 존재, 각각의 책임이 분명하게 정의

  • 코드를 변경하거나 유지보수하는데 용이

  • 변경이 필요한 부분을 신속하게 식별하고 수정

2. 확장성 및 변동성 제어

  • 안쪽의 원은 안정적이고 변동성이 낮은 엔티티

    • 시스템의 핵심 비즈니스 규칙은 안정적으로 유지

  • 바깥쪽의 원은 변동성이 큰 부분

    • 외부 요인에 대한 대응이 유연

3. 테스트 용이성

  • 각 구성 요소는 독립적으로 테스트 가능

  • 의존성이 명확하게 정의되어 있어 모킹등을 통한 테스트가 용이

4. 프레임워크와 도구의 유연한 교체

  • 외부 프레임워크, 라이브러리, 디비 등과의 의존성이 인터페이스 어댑터를 통한 추상화

  • 필요에 따라 이들을 교체하거나 업그레이드하는데 용이

5. 개발자와 비즈니스 로직의 분리

  • 핵심 비즈니스 로직이 안쪽의 원에 위치, 엔티티

  • 프레임워크나 인터페이스와 독립적으로 작성

  • 개발자가 핵심 비즈니스 로직에 집중할 수 있음

6. 플랫폼 독립성

  • 안쪽의 원에는 플래폼에 종속적인 부분이 없음 -> 엔티티

  • 바깥쪽의 원에서 의존성, 종속성을 처리

  • 플랫폼 간의 이식성이 향상

7. 비즈니스 규칙의 명시적 표현

  • 비즈니스 규칙이 명시적으로 드러나도록 하는 구조를 제공

  • 비즈니스 요구사항을 반영하도록 하며, 코드를 이해하고 관리하는데 돕는다.

8. 도메인 주도 설계(Domain-Driven Design)와의 조합

  • 도메인 주도 설계 원칙과 잘 조화

  • 비즈니스 규칙을 중심으로 시스템을 구성하고 확장 가능

설계

Clean Architecture에 따르는 구조화

Domain Layer

  • 애플리케이션이 수행하는 작업을 설명

  • 플랫폼/프레임워크와 독립적으로 구성

  • Model -> 문제와 관련된 실제 세계의 Object

  • Repository -> Model 에 접근 가능한 인터페이스 제공

  • UseCase -> 애플리케이션의 비즈니스 로직 포함

Model Layer 설계

  • 데이터 모델 추출

  • 비즈니스 규칙에 초점을 맞춘 필요한 데이터를 정의 (프레임워크, 플랫폼에 구애받지 않음)

  • Typescript를 이용해서 정의

export type Position = { x: number, y: number, z: number }
export type CameraType = 'perspective' | 'orthographic' 

UseCase Layer 설계

  • "A가 발생하면 B를 수행한다." 로 정의

  • React에서의 UseCase

    • React Application에 의해 호출되는 렌더링 함수

    • 사용자 입력을 위한 이벤트 핸들러

    • 자발적으로 발생되는 effect

  • 대부분 컴포넌트 내부에서 이런 UseCase들이 스파게티처럼 뒤섞여 정의되게 된다.

    • 적절한 Layer로 풀어서 정리 필요

    • 변수간 의존성을 분석해서 문제를 풀어야 한다.

    • 다른 변수로부터 추론할 수 없는 주요 데이터 소스를 찾고 데이터 레이어에서 관리하게 만듦

UseCase 정의 예시
import { Repository } from './repository'

export async function clickOnBoard(indexOnBoard: number, repository: Repository) {
  const { board, stepNumber } = await repository.getCurrentStep();
  const newBoard = board.slice();
  if (calculateWinnerOnBoard(newBoard) || newBoard[indexOnBoard]) return;
  
  newBoard[indexOnBoard] = isNextTurnX(stepNumber) ? "X" : "O";
  await repository.deleteStepsAfterCurrentStepNumber();
  await repostiory.addStep(newBoard);
  await repository.setCurrentStepNumber(stepNumber + 1);
}

export async function jumbToStep(stepNumber: number, repository: Repository): Promise<void> {
  return repository.setCurrentStepNumber(stepNumber);
}

Repository Layer 설계

  • UseCase와 Repository 사이의 boundary 설계는 주관적

  • Repository Layer는 모델 관련 동작들 가운데에 정의

  • Repository Layer 동작 원칙

    • Operation 최소화 (사용하는 인터페이스들만 public하게 노출)

      • 예로 모든 getter/setter를 노출하면 일관되지 않은 데이터를 쉽게 생성 가능해진다.

    • 동작은 중립적, UseCase Layer에서 정의한 비즈니스 로직과 독립적

    • 각 Repository 동작은 소스간의 일관성 유지 필요

      • 한 번에 하나의 Repository 동작

      • 한 번에 여러개의 Data Source를 변경하는 경우에만 atomic operation으로 여러 data source 접근

Repository interface 설계 예시
export type Step = {
  board: Board;
  stepNumber: number;
  numOfAllSteps: number;
}

export interface Repository {
  getCurrentStep(): Promise<Step>;
  setCurrentStepNumber(): Promise<void>;
  deleteStepsAfterCurrentStepNumber(): Promise<void>;
  addStep(board: Board): Promise<void>
}

Presentation Layer

  • 애플리케이션이 실제 세계와 상호작용하는 방식을 설명

  • 즉, 사용자에게 화면으로 보여지는 레이어

  • 리엑트 컴포넌트에서 사용자에게 어떤 UI를 어떻게 렌더링할지에 대해 초점을 둔다.

Presentation Layer 설계

MVC 형태를 구성하는것이 중요 (Moel, View, Controller)

  • Model과 Controller를 하나의 객체로 병합

  • Presentation Layer와 UseCase Layer 사이의 브릿지로 사용

  • 컴포넌트 렌더 부분 (View), 커스텀 훅으로 (Model - Controller) 참조함으로써 View와 Model-Controller 분리

  • View에서는 커스텀훅(Model-Controller)를 통해서 상위 레이어에 있는 동작들(데이터소스)에 접근하거나 UseCase를 적용 등의 처리를 적용

Presentation Layer 설계 예시
// model-controller (커스텀 훅)
import { Repository, Step } from './repository';
import { clickOnBoard, jumpToStep } from './useCase';

export const useTicTacTocModelController = (repository: Repository) => {
  const [currentStep, setCurrentStep] = useState<Step | null>(null)
  
  useEffect(() => {
    async function init() {
      const initialStep = await repositroy.getCurrentStep();
      setCurrentStep(initialStep);
    }
    init();
  }, [repository])
  
  const handleClickOnBoard = async (indexOnBoard: number) => {
    await clickOnBoard(indexOnBoard, repository);
    const nextStep = await repository.getCurrentStep();
    setCurrentStep(nextStep);
  }
  
  const handleJumpToStep = async (stepNumber: number) => {
    await jumpToStep(stepNumber, reposit ory);
    const nextStep = await repository.getCurrentStep();
    setCurrentStep(nextStep);
  }
  
  return {
    currentStep,
    handleClickOnBoard,
    handleJumpToStep
  }
}
 // View 
 import { Repository } from './repository';
 import { useTicTacTocModelController } from './tictactocModelController';
 
 type TicTacTocViewProps = {
   repository: Repository
 }
 
 export function TicTacTocView({repository}: TicTacTocViewProps) {
   const { currentStep, handleClickOnBoard, handleJumbToStep } = useTicTacTocModelController(repository);
   
   if (!currentStep) return null;
   
   // Util functions
   const winner = calculateWinnerOnBoard(currentStep.stepNumber);
   const xIsNext = isNextTurnX(currentStep.stepNumber);
   
   return (
   ...jsx
    )
 }

Data Layer

  • 애플리케이션이 데이터를 관리하는 방법

Data Layer 설계

  • Data Layer는 두개의 Sub Layer 존재

  • Data:Repository Layer

    • Domain의 Repository Layer에 정의된 동작 구현

  • Data:DataSource Layer

    • 실제 데이터 스토리지 구현

Data Layer 설계 예시
 // RepositoryImpl
 import { DataSource} from './dataSource';
 import { Board } from './model';
 import { Repository, Step } from './repository';
 
 export class RepositoryImpl implements Repository {
   dataSource: DataSource;
   
   cnstructor(dataSource: DataSource) {
     this.dataSource = dataSource;
   }
   
   async getCurrentStep(): Promise<Step> {
     const { history, stepNumber } = await Promise.all([
       this.dataSource.getHistory();
       this.dataSource.getStepNumber();
     ])
     const board = history[stepNumber].board;
     const numOfAllSteps = history.length;
     
     return { board, stepNumber, numOfAllSteps };
   }
   
   async setCurrentStepNumber(stepNumber: number): Promise<void> {
     const history = await this.dataSource.getHistory();
     if (stepNumber < history.length) {
       await this.dataSource.setStepNumber(stepNumber);
     } else {
       throw Error(
         `Step number ${stepNumber} should be smaller than the history size ${history.length}`
       )
     }
   }
   
   async deleteStepAfterCurrentStepNumber(): Promise<void> {
     const [history, stepNumber] = await Promise.all([
       this.dataSource.getHistory(),
       this.dataSource.getStepNumber(),
     ]);
     const trimmedHistory = history.slice(0, stepNumber + 1);
     await this.dataSource.setHistory(trimmedHistory);
   }
   
   async addStep(board:Board): Promise<void> {
     const history = await this.dataSource.getHistory();
     history.push({ board });
     await this.dataSource.setHistory(history);
   }
 }
 // DataSource Layer Inteface
 import { History } from './model';
 
/**
 * DataSource access interface
 * Asuuming network acces, all methods are asynchronous
 */
export interface DataSource {
  setHistory(history: History): Promise<void>;
  getHistory(): Promise<History>;
  
  setStepNumber(stepNumber: number): Promise<void>;
  getStepNumber(): Promise<number>;
}
 // DataSource Impl
 import type { History, Board } from '../../domain/model';
 import type { DataSource } from './dataSource';
 
 export class OnMemoryDataSourceImpl implements DataSource {
   history: History = [];
   stepNumber: number = 0;
   
   constructor() {
     const board = this.#createEmptyBoard();
     this.history.push({ board });
   }
   
   #createEmptyBoard(): Board {
     return Array(9).fill(null);
   }
   
   async setHistory(history: History): Promise<void> {
     this.history = history
   }
   
   async getHistory(): Promise<History> {
     return this.history
   }
   
   async setStepNumber(stepNumber: number): Promise<void> {
     this.stepNumber = stepNumber;
   }
   
   async getStepNumber(): Promise<number> {
     return this.stepNumber;
   }
 }

Main Layer

  • 모든 구성요소를 묶어 하나의 애플리케이션으로 결합

Main Layer 설계

  • 여러 레이어를 하나의 애플리케이션으로

  • Repository 구현을 생성하고 View에 의존성 주입

  • Repository는 modelController(custom hook)를 통해 useCase Layer로 전달

  • UseCase는 Repository를 사용

  • UseCase에서 RepositoryImpl를 생성하면 안됨

    • 객체 생성은 Main Layer에서, 객체 사용은 UseCase Layer에서로 분리

  // Dependency Injection
const dataSource = new OnMemoryDataSourceImpl();
const repository = new RepositoryImpl(dataSource);

export function App() {
  return <TicTacTocView repository={repository} />
}

의존성 흐름의 위배

실제 애플리케이션은 제어 흐름이 항상 한 방향으로 흐르지 않는다.

  • UseCase의 비즈니스 로직이 Repository의 인터페이스를 사용

  • Repository에서 data layer에서 관리되는 데이터에 접근 등

  • 이런 의존성 규칙 위반을 해결하기 위한 수단으로 의존성 역전을 사용

의존성 역전 (Dependency Injection)

  • interface - implement의 구조

  • 인터페이스 설계에 기반으로 실제 구현체는 Data layer에서 정의

  • 정의체는 상위 레이어에 있지만 구현체는 하위에 둠으로써 서로간 느슨한 결합을 통해서 의존성 역전을 구성할 수 있다.

의존성 파악 도구

  • 코드의 의존성이 단방향으로 가리킬수 있는 의존성 규칙을 유지할 수 있는 도구(Lint와 같은)

소스파일을 분석, 파일간 의존성 검증하는 도구

  • rule을 검증

  • 위반된 규칙을 텍스트/그래픽으로 표시

  • 부수적으로 시각화된 다양한 출력 형식의 dependency graph를 생성

yarn add -D dependency-cruiser
npx depcruise --init

소스 코드 검증

npx depcruise src --include-only '^src' --config --output-type err-long

Dependency graph 생성

  • Graphviz 사용으로 의존성 그래프 생성

$ brew install graphviz

$ npx depcruise src --include-only '^src' --config --output-type dot|dot -T svg > dependency-graph.svg && open dependency-graph.svg

script 추가

위 명령어들은 자주 사용되므로 스크립트에 등록

"scripts": {
  "depcruise:validate": "depcruise src --include-only '^src' --config --output-type err-long",
  "depcruise:tree": "depcruise src --include-only '^src' --config --output-type dot|dot -T svg > dependency-graph.svg && open dependency-graph.svg"
}

Clean Architecture를 위한 Rule Custom

일반 규칙으로 감지하지 못하는 상황 1. Presentation에서 Data Layer를 참조하는 상황 (순환 참조가 아니므로 일반규칙에선 감지 못함)

  • Presentation Layer 의존성이 Domain으로 향하게끔 rule 설정

allowedSeverity: "error",
allowed: [
  {
    from: { path: "(^src/Presentation)" },
    to: { path: ["^src/Domain"] },
  }
]
  • But 다른 유효한 의존성도 not-in-allowed 로 표시된다.

  • allowed 규칙을 사용하는 경우 dependency-cruiser는 적어도 하나 이상의 규칙을 충족하지 않는 각각의 의존성에 대해 not-in-allowed 로 표시한다.

  • 유효한 의존성을 명시적으로 모두 작성해주어야 한다.

  • 번거롭지만, 리포지토리에 새 폴더를 추가할 때 마다 의존성을 설계하고 새 규칙을 추가하는 것이 좋은 습관

allowedSeverity: "error",
allowed: [
  {
    from: { path: "(^src/Main)" },
    to: {
      // "$1" is introduced from regular expression's "group matching"
      // You can reference the part method between brackets in "from"
      // string by using "$1" in "to".
      path: ["^$1", "^src/Presentation", "^src/Data", "^src/Domain"],
    }
  },
  {
    // Presentation other than Presentation/hook
    from: { path: "(^src/Presentation)", pathNot: "^src/Presentation/hook" },
    to: { path: ["^$1", "^src/Domain"] },
  },
  {
    // We want no hooks to depend on Presentation to make hooks independent
    // from any graphical presentation
    from: { path: "(^src/Presentation/hook)" },
    to: { path: ["^$1", "^src/Domain"] },
  },
  {
    from: { path: "(^src/Data)" },
    to: { path: ["^$1", "^src/Domain"] },
  },
  {
    from: { path: "(^src/Domain/Usecase)" },
    to: { path: ["^$1", "^src/Domain/Repository", "^src/Domain/Model"] },
  },
  {
    from: { path: "(^src/Domain/Repository)" },
    to: { path: ["^$1", "^src/Domain/Model"] },
  },
  {
    from: { path: "(^src/Data)" },
    to: { path: ["^$1", "^src/Domain"] },
  },
  {
    from: { path: "(^src/Domain/Model)" },
    to: { path: ["^$1"] },
  },
  {
  // Files outside of established folders (e.g. src/index.tsx) can reference
  // any files.
    from: { pathNot: ["^src/Main", "^src/Presentation", "^src/Data", "^src/Domain"] },
    to: {},
  }
]

큰 Repo의 의존성 시각화

상위 수준(폴더 기준)의 그래프 확인하여 wide하게 확인할 수 있다.

$ npx depcruise src --include-only '^src' --config --output-type ddot|dot -T svg > dependency-graph.svg && open dependency-graph.svg

의존성 검증을 git과 연동

여러 멤버가 같은 리포지토리에서 작업할 때 클린 아키텍처 의존성을 유지하기 어렵다.

변경사항을 적용*(커밋)할 때마다 의존성 크롤러를 실행해서 검증 (Husky)

$ npx husky-init && yarn
$ npx husky add .husky/pre-commit 'yarn depcruise:validate'

Last updated