Home > Software Engineering > Architecture > 만들면서 배우는 클린 아키텍처

만들면서 배우는 클린 아키텍처
Architecture

주요 도메인 중심 아키텍처 용어 기원

  • 아키텍처 선택 가이드: 도메인 코드가 애플리케이션에서 가장 중요하면 사용하자
  • 종류
    • 클린 아키텍처 - 로버트 마틴 (Robert C. Martin)
      • 도메인 중심 아키텍처들에 적용되는 원칙을 제시 (추상적)
      • 도메인 중심의 아키텍처들은 DDD의 조력자
    • 육각형 아키텍처 (Hexagonal Architecture) - 알리스테어 콕번 (Alistair Cockburn) (구체적)
    • 도메인 주도 설계 (DDD, Domain Driven Design)- 에릭 에반스 (Eric Evans)

전통적인 계층형 아키텍처의 문제

layered_architecture

  • 일반적인 3계층 아키텍처
  • 올바르게 계층을 구축하고 추가 아키텍처 강제 규칙 적용하면 쉽게 기능 추가 및 유지보수 가능
    • 웹계층이나 영속성 계층에 독립적으로 도메인 로직 작성 가능
    • 아래 문제점만 극복할 수 있다면, 좋은 코드 유지 가능 (강제가 없어 어려울 뿐)
  • 문제점: 장기적으로 나쁜 방향의 코드를 쉽게 허용
    • 의존성 방향으로 인해 데이터베이스 주도 설계를 유도
      • 비즈니스 관점에서 도메인 로직을 먼저 만들어야하지만, 영속성 계층을 먼저 구현하게 됨
      • 도메인 계층이 영속성 계층의 ORM 엔터티를 사용해 강한 결합 발생
    • 강제가 적어 지름길을 택하기 쉬워짐
      • 전통 계층형 아키텍처의 유일한 규칙: 같은 계층 혹은 아래 계층에만 의존 가능
      • 필요한 상위 컴포넌트를 계속 아래로 내리기 쉬움 (e.g. 영속성 계층에 몰리는 헬퍼, 유틸리티)
      • 아키텍처 규칙 강제 필요성 (빌드 실패 수준으로 관리)
    • 테스트가 어려워짐
      • 계층 건너뛰기(웹 계층 -> 영속성 계층) 시, 종종 웹 계층으로 도메인 로직 책임이 전파
      • 웹 계층 테스트에서 영속성 계층까지 모킹해야 해서, 테스트 복잡도 상승
    • 유스케이스를 숨김
      • 도메인 로직이 여러 계층에 흩어짐 -> 새로운 기능을 추가할 위치 찾기가 어려움 (수직적 측면)
      • 여러 개 유스케이스 담당하는 넓은 서비스 허용 -> 작업할 서비스 찾기 어려움 (수평적 측면)
    • 동시 작업 지원 아키텍처는 아님 (병합 충돌)
      • 영속성 -> 도메인 -> 웹 순으로 개발하므로, 특정 기능은 동시에 1명의 개발자만 작업 가능
      • 현재 넓은 서비스라면, 다른 유스케이스 작업도 같은 서비스에서 동시에 하게 됨

클린 아키텍처의 핵심 토대

  • 단일 책임 원칙(SRP)
    • 일반적 정의: 하나의 컴포넌트는 한 가지 일만 해야 한다.
    • 실질적 정의: 컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다. (책임 = 변경할 이유)
      • 컴포넌트를 변경할 이유가 한 가지라면, 다른 곳 수정 시 해당 컴포넌트를 신경쓸 필요가 없음
  • 의존성 역전 원칙(DIP)
    • 정의: 코드상의 어떤 의존성이든 그 방향을 바꿀 수 있다. (서드파티 라이브러리 제외)
    • 상위 계층의 변경할 이유를 줄임
      • e.g. 영속성 계층에 대한 도메인 계층의 의존성

클린 아키텍처 (Clean Architecture)

clean_architecture

  • 핵심
    • 의존성 규칙: 계층 간의 모든 의존성이 도메인 코드로 향해야 한다.
      • 의존성 역전 -> 영속성과 UI 문제로부터 도메인 로직의 결합 제거
      • -> 변경 이유의 수 감소
      • -> 유지보수성 향상
    • 외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬 수 있음
  • 구조
    • 애플리케이션 코어
      • 도메인 엔터티: 비즈니스 규칙에 집중
      • 유스케이스: 도메인 엔터티에 접근 가능
    • 비즈니스 규칙 지원 컴포넌트 (컨트롤러, 게이트웨이, 프레젠터 등)
    • 바깥쪽 계층: 다른 서드파티 컴포넌트에 어댑터 제공
  • 대가
    • 엔터티에 대한 모델각 계층에서 따로 유지보수해야 함 (통신할 때는 매핑 작업 필요)
    • 하지만, 결합이 제거되어 바람직한 상태
      • e.g. ORM 엔터티는 기본생성자를 강제하지만 도메인 모델에는 필요없음
  • 유의사항
    • 의존성 역전은 실제로 유스케이스영속성 어댑터 간 적용 (의존성 방향이 코어로 향하도록)

육각형 아키텍처 (Hexagonal Architecture)

hexagonal_architecture

  • 클린 아키텍처 원칙들에 부합하는 구체적 아키텍처 중 하나
  • 포트와 어댑터(ports-and-adapters) 아키텍처로도 불림
  • 핵심
    • 클린 아키텍처의 의존성 규칙 그대로 적용 (모든 의존성은 코어로 향한다.)
      • 의존성 역전 -> 영속성과 UI 문제로부터 도메인 로직의 결합 제거
      • -> 변경 이유의 수 감소
      • -> 유지보수성 향상
    • 외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬 수 있음
  • 구조
    • 애플리케이션 코어
      • 도메인 엔터티
      • 유스케이스: 도메인 엔터티와 상호작용
    • 어댑터
      • 애플리케이션다른 시스템 간의 번역을 담당
        • e.g. 웹 어댑터, 영속성 어댑터, 외부 시스템 어댑터
      • 분류
        • 주도하는 어댑터(driving adapter) = 인커밍 어댑터 = 왼쪽 어댑터
          • 애플리케이션 코어 호출 (in)
        • 주도되는 어댑터(driven adapter) = 아웃고잉 어댑터 = 오른쪽 어댑터
          • 애플리케이션 코어 호출 (out)
    • 포트
      • 애플리케이션 코어어댑터들 간의 통신을 위한 인터페이스
      • 분류
        • 입력 포트(in) = 인커밍 포트
          • 주도하는 어댑터가 호출하는 인터페이스
          • 구현체: 코어 내 유스케이스 클래스
        • 출력 포트(out) = 아웃고잉 포트
          • 애플리케이션 코어가 호출하는 인터페이스
          • 구현체: 어댑터 클래스
  • 계층 분류
    • 어댑터 계층: 어댑터
    • 애플리케이션 계층: 포트 + 유스케이스 구체 클래스(Service)
    • 도메인 계층: 도메인 엔터티
  • 유의사항
    • 의존성 역전은 실제로 유스케이스주도되는 어댑터 간에 적용됨 -> 의존성 방향이 코어로 향하도록
    • 주도하는 어댑터는 원래 의존성 방향이 코어로 향함 -> 인터페이스단순 진입점 구분 역할

표현력 있는 패키지 구조

expressive_package_structure

  • 표현력 있는 패키지 구조는 각 요소들을 패키지 하나씩에 직접 매핑
    • 아키텍처-코드 갭을 완화시킴
    • 아키텍처에 대한 적극적인 사고를 촉진
    • -> 의사소통, 개발, 유지보수 모두 조금 더 수월해짐
  • 분류
    • 엔터티: domain - Account, Activity
    • 유스케이스: application - SendMoneyService
    • 인커밍 포트: application - port - SendMoneyUseCase
    • 아웃고잉 포트: application - port - LoadAccountPort, UpdateAccountStatePort
    • 인커밍 어댑터: adapter - in - web - AccountController
    • 아웃고잉 어댑터: adapter - out - persistence - AcountPersistenceAdapter
  • 고려사항
    • 접근 제한자로 계층 사이 불필요한 의존성 예방 가능 (e.g. 도메인의 영속성 계층 의존)
      • portpublic 두기
      • 나머지는 모두 package-private
    • DDD 개념과 직접적 대응 가능
      • 상위 레벨 패키지바운디드 컨텍스트로 활용 가능 (e.g. account)
    • 책임을 좁히는 유스케이스명을 사용하자 (로버트 마틴, 소리치는 아키텍처)
      • AccountService 보다 SendMoneyService 인터페이스명이 좋음 (송금하기 유스케이스)
    • 모든 계층에 의존성을 가진 중립적인 컴포넌트를 도입해 의존성 주입하자
      • 아키텍처를 구성하는 대부분의 클래스를 초기화해 인스턴스 주입
      • e.g. AccountController, SendMoneyService, AccountPersistenceAdapter

유스케이스 구현하기

  • 입력 유효성 검증 & 비즈니스 규칙 검증
    • 두 검증 모두 비즈니스 규칙으로 다루는게 옳을 수도 있으나 구분하면 유지보수성이 향상됨
    • 구분 방법
      • 도메인 모델의 현재 상태에 접근하는가?
        • O -> 비즈니스 규칙 검증
          • e.g. 출금 계좌는 초과 출금되어서는 안된다.
        • X -> 입력 유효성 검증
          • e.g. 송금되는 금액은 0보다 커야 한다.
    • 전략
      • 유스케이스 -> 비즈니스 규칙 검증 책임 O, 입력 유효성 검증 책임 X -> 도메인 엔터티 내에 두기
      • 애플리케이션 계층 -> 입력 유효성 검증 책임 O -> 입력 모델에서 다루자!
        • 유효하지 않은 입력값이 코어로 향해 모델의 상태를 해치는 것을 막아야 함
        • 유스케이스 구현체 주위에 잘못된 입력에 대한 보호막 형성 (소위 오류 방지 계층)
        • e.g.
          • SendMoneyCommand 생성자에서 처리 (final 필드 사용하면 생성 후 변경 막음)
            • 규칙 예: 모든 파라미터는 null이 아니어야함
            • 규칙 예: 송금액은 0보다 커야함
          • Bean Validation API 사용도 좋음
            • 없는 것만 직접 구현 (requireGreaterThan())
      • 비즈니스 규칙을 도메인 엔터티 내에서 다루기 애매할 때유스케이스 코드에서 진행
        • 검증 실패 시 유효성 검증 전용 예외 던지기
  • 유스케이스마다 각각 입력 모델, 출력 모델 두기
    • 유스케이스가 보다 명확해짐
    • 다른 유스케이스와의 결합 방지 -> 유지보수성 향상
  • 읽기는 전용 쿼리 서비스 두기
    • 읽기 작업은 유스케이스가 아니고 간단한 데이터 쿼리
    • 따라서, 쿼리를 위한 전용 인커밍 포트를 만들고 쿼리 서비스(query service)에 구현하기 (CQS)
    • e.g.
      • GetAccountBalanceQuery 인터페이스 (인커밍 포트)
      • GetAccountBalanceService 구현체, getAccountBalance() 메서드

풍부한 도메인 모델 VS 빈약한 도메인 모델

도메인 로직이 도메인 엔터티에 많다면 DDD 철학을 따르는 풍부한 도메인 모델이 된다.
반면에, 유스케이스에 도메인 로직이 몰리면 빈약한 도메인 모델이 된다.
물론, 스타일에 따라 선택이 가능한 부분이다.

웹 어댑터 구현하기

  • 인커밍 포트의 필요성 (feat. 의존성 방향이 올바름에도 사용하는 이유)
    • 계층 진입점의 명세가 되어 유지보수 정보가 됨
    • 웹 소켓 시나리오의 경우 반드시 포트가 필요
      • 인커밍 포트이자 아웃고잉 포트가 되므로
  • 유스케이스 입력 모델과 다른 구조와 의미를 가지는 유효성 검증 필요
    • 웹 어댑터의 입력 모델이 유스케이스의 입력 모델로 변환할 수 있다는 것을 검증
  • 컨트롤러 나누기
    • 컨트롤러는 가능한 좁고 너무 많은게 낫다! (기본적으로 클래스 코드는 적을수록 좋음!)
    • 각 연산에 대해 별도의 패키지 안에 별도의 컨트롤러를 만드는 방식이 좋음
      • e.g. AccountController로 모두 묶는 것 보다 SendMoneyController가 나음
    • 메서드와 클래스 명유스케이스를 최대한 반영해서 짓기
      • e.g. CreateAccount 보다 RegisterAccount가 더 나을 수 있음
    • 각 컨트롤러별도의 입출력 모델을 가지는게 좋음!
      • e.g. CreateAccountResource, UpdateAccountResource
    • 컨트롤러 나누기는 동식 작업 시 병합 충돌 예방에도 도움이 됨

영속성 어댑터 구현하기

  • 입출력 모델애플리케이션 코어에 위치도메인 엔터티DTO (포트에 지정)
    • 입출력 모델이 애플리케이션 코어에 위치 -> 어댑터 내부 변경이 코어에 영향을 주지 않음
    • 다만, 어댑터에서 매핑 작업이 필요
      • 입력 모델 -> JPA 엔터티
      • JPA 엔터티 -> 출력 모델
  • 좁은 포트 인터페이스 지향하기
    • 일반적인 방법은 엔터티를 기준으로 모든 연산을 하나의 리포지토리에 모아두는 것
      • 기본은 도메인 클래스 혹은 DDD 애그리거트하나의 영속성 어댑터 구현하기
      • 추가적으로, JPA 어댑터 & SQL 어댑터 구성도 가능 (성능 개선 위해)
    • 그렇다면, 인터페이스 분리 원칙(ISP)에 따라 좁은 포트 인터페이스 지향하자!
      • 항상은 아니더라도 가능한 각 서비스가 자신에게 필요한 메서드만 알면 되도록 하자
      • 장점: 코드 가독성 향상, 테스트 편리
      • e.g. AccountRepository -> LoadAccountPort, UpdateAccountStatePort, CreateAccountPort

테스트 성공의 기준

테스트는 얼마나 마음 편하게 소프트웨어를 배포할 수 있는냐를 성공 기준으로 삼으면 된다.
초기 몇 번의 프로덕션 배포 시 버그가 나온다면, 해당 케이스를 추가해서 개선하면 된다.
시간이 걸릴지라도 결국은 유지보수를 위한 옳은 길이 될 것이다.

경계 간 매핑하기

  • 매핑에 대한 찬성과 반대
    • 찬성
      • 두 계층 간에 매핑이 없으면 서로 같은 모델을 사용하게 되어, 두 계층이 강하게 결합된다.
    • 반대
      • 매핑이 있으면 모델이 각 계층에 있어 보일러플레이트 코드가 많아진다.
      • e.g. 간단한 CRUD 유스케이스
  • 적절한 사용 원칙
    • 유스케이스마다 적절한 전략을 선택하거나 섞어 써야 한다! (한 전략을 철칙으로 여겨선 안됨!)
      • 좁은 포트를 사용하면 유스케이스마다 다른 매핑 전략 사용 가능
      • 어려운 방법이지만, 정확히 해야하는 일만 수행하면서도 유지보수하기 쉬운 코드가 될 것
    • 간단한 전략으로 시작해서 복잡한 전략으로 갈아타는 것도 좋은 방법
      • 많은 유스케이스들은 간단한 CRUD에서 점차 풍부한 비즈니스 유스케이스변화
      • 어떤 매핑 전략을 선택했더라도 나중에 언제든 바꿀 수 있음!
    • 팀 내 합의할 수 있는 가이드라인을 정해둬야 함
      • 가이드 라인 예시
        • 변경 유스케이스
          • 웹 계층 & 애플리케이션 계층 사이
            • 1순위 전략: 완전 매핑
              • 유효성 검증 규칙이 명확해짐
          • 애플리케이션 계층 & 영속성 계층 사이
            • 1순위 전략: 매핑하지 않기
              • 매핑 오버헤드 줄이고 빠르게 코드 짜기 위해
            • 2순위 전략: 양방향 매핑
              • 애플리케이션 계층에서 영속성 문제를 다루면 결합 제거가 우선
        • 쿼리 작업
          • 웹 계층 & 애플리케이션 계층 사이 + 애플리케이션 계층 & 영속성 계층 사이
            • 1순위 전략: 매핑하지 않기
              • 매핑 오버헤드 줄이고 빠르게 코드 짜기 위해
            • 2순위 전략: 양방향 매핑
              • 애플리케이션 계층에서 웹 문제, 영속성 문제를 다루면 결합 제거가 우선
  • 4가지 전략
    • 매핑하지 않기 (No Mapping)
      no_mapping_strategy
      • 모든 계층도메인 모델을 입출력 모델로 사용
      • 장점
        • 간단한 CRUD 유스케이스에는 유용 (모든 계층이 정확히 같은 구조와 정보 띄는 상황)
      • 단점: 단일 책임 원칙 위반
        • 도메인과 애플리케이션 계층이 웹이나 영속성 관련 특수 요구사항에 오염됨
        • 웹이나 영속성 관련 이유로 변경될 가능성 생김
      • 애플리케이션 및 도메인 계층웹과 영속성 문제를 다루면 바로 다른 전략을 취해야 함
    • 양방향 매핑 (Two-Way)
      two_way_strategy
      • 각 어댑터전용 모델을 사용하고 포트 전달 전에 계층 내에서 매핑 실행
        • 웹 계층에서는 웹 모델 -> 도메인 모델, 도메인 모델 -> 웹 모델 매핑 진행
        • 영속성 계층도 마찬가지
      • 장점: 단일 책임 원칙 준수
        • 한 계층의 전용 모델을 변경하더라도 다른 계층에는 영향이 없음 (깨끗한 도메인 모델)
      • 단점
        • 보일러플레이트 코드가 많아짐
          • 아주 간단한 CRUD 유스케이스에선 개발을 더디게 함
        • 인커밍 포트와 아웃커밍 포트에서 도메인 모델이 계층 경계를 넘어 통신에 사용됨
          • 도메인 모델이 바깥쪽 계층의 요구에 따른 변경에 취약해짐
    • 완전 매핑 (Full)
      full_mapping_strategy
      • 각 계층전용 모델을 가짐
        • 입력 모델은 command, request 등 네이밍 사용)
        • 웹 계층은 입력을 커맨드 객체로 매핑할 책임을 가짐
        • 애플리케이션 계층은 커맨드 객체를 도메인 모델로 매핑할 책임을 가짐
      • 전역 패턴으로는 비추천
        • 웹 계층(혹은 인커밍 어댑터)과 애플리케이션 계층 사이를 추천
        • 애플리케이션 계층과 영속성 계층 사이에서는 매핑 오버헤드로 비추천
      • 장점: 유지보수하기가 훨씬 쉬움
      • 단점: 보일러플레이트 코드가 많아짐
    • 단방향 매핑 (One-Way)
      one_way_strategy
      • 모든 계층의 모델들같은 인터페이스를 구현 (몇몇 getter 메서드를 제공하는 인터페이스)
        • 도메인 모델은 풍부한 행동을 구현
        • 도메인 객체는 매핑 없이 바깥 계층으로 전달 가능
        • 바깥 계층에서는 상태 인터페이스를 이용할지, 전용 모델로 매핑할지 결정
      • DDD의 팩터리(factory) 개념과 어울림
      • 장점: 계층 간 모델이 비슷할 때 효과적
        • 읽기 전용 연산은 전용 모델 매핑 없이 상태 인터페이스만으로 충분
      • 단점: 매핑이 계층을 넘나들며 퍼져 있어, 개념적으로 어려움

애플리케이션 조립하기

  • 설정 컴포넌트
    • 코드 의존성이 올바른 방향을 가리키게 하기 위해서 필요
    • 책임
      • 모든 클래스에 대한 의존성을 가지고 객체 인스턴스를 생성할 책임을 가짐
      • 런타임에 애플리케이션 조립에 대한 책임을 가짐
        • 의존성 주입 메커니즘으로 런타임에 필요한 곳에 객체 주입
        • 설정 파일, 커맨드라인 파라미터 등에도 접근
  • 장점
    • 단일 책임 원칙을 위반하지만, 덕분에 애플리케이션의 나머지 부분을 깔끔하게 유지 가능
    • 테스트가 쉬워짐

@Component를 포함한 커스텀 애노테이션

컴포넌트 스캔 사용 시, @PersistenceAdapter, @WebAdapter 등의 커스텀 애노테이션을 만들어 적용하면 아키텍처 구조를 더욱 쉽게 파악할 수 있다.

아키텍처 경계 강제하기

  • ‘경계를 강제한다’
    • 의존성이 올바른 방향을 향하도록 강제하는 것
    • 시간이 지나면서 아키텍처가 서서히 무너지는 것을 방지 -> 유지보수하기 좋은 코드
  • 아래 3가지 경계 강제 방법을 조합해 사용
    • 방법 1: 접근 제한자
      package_structure_with_visibility_modifier
      • 전략
        • 도메인 엔터티 및 포트public으로 열고, 나머지 모두 package-private으로 진행
          • 컴포넌트 스캐닝만 가능 (빈 수동 등록은 public 열지 않으면 불가)
      • package-private
        • 자바 패키지를 통해 클래스들을 모듈로 만들어주므로 중요
        • 모듈의 진입점으로 활용할 클래스골라서 public으로 만들면 됨
        • 의존성 규칙 위반 위험이 감소
      • 단점
        • 모듈 내 클래스가 많아지면 하위 패키지를 만들어야 함
        • -> 계층 내 의존이 불가해져 public 열어야 함
        • -> 아키텍처 의존성 규칙이 깨질 환경 조성됨
    • 방법 2: 컴파일 후 체크 (post-compile check)
      • 컴파일러로는 경계 강제 불가할 경우, 런타임에 체크 시도
      • ArchUnit 사용하기
        • 의존성 방향예상대로 설정됐는지 체크할 수 있는 API
        • 계층 간 의존성 확인 테스트를 추가해 확인
        • 단점
          • 타입 세이프하지 않음 (오타나 패키지명 리팩터링에 취약)
          • 항상 코드와 함께 유지보수해야 함
    • 방법 3: 빌드 아티팩트를 분리하기
      build_each_module_separately
      • 각 모듈 혹은 계층에 대해 각각 빌드 모듈(JAR 파일)을 만들어 계층 간 의존성 강제 가능
      • 빌드 스크립트에 아키텍처에서 허용하는 의존성만 지정
      • 장점
        • 순환 의존성 방지 보증
        • 다른 모듈을 고려하지 않고 특정 모듈 코드만 격리한 채로 변경 가능
        • 새로운 의존성 추가 시 항상 의식적으로 행동하게 됨

의식적으로 지름길 사용하기

  • 깨진 창문 이론
    • 인간의 뇌는 망가져 있는 것을 보면 더 망가뜨려도 된다고 생각한다.
    • 지름길을 거의 사용하지 않고 깨끗하게 프로젝트를 시작하고 유지하는 것이 중요
  • 지름길을 취하는 것이 가끔 실용적일 때가 있음
    • 프로젝트 전체에서 중요하지 않은 부분, 프로토타이핑, 경제적 이유…
  • 의도적인 지름길은 세심하게 기록해두자! -> 팀원이 이를 인지하면 깨진 창문 이론이 덜할 것
  • 유스케이스가 단순한 CRUD 상태에서 벗어나는 시점을 잘 파악해 리팩토링하자!
    • 단순한 CRUD 상태에서 더이상 벗어나지 않는 유스케이스그대로 유지하는게 더 경제적
  • 지름길 발생 예시
    • 유스케이스 간 입출력 모델 공유 (지양)
      • 특정 요구사항을 공유할 때만 괜찮음 -> 다만 둘이 독립적이라면 분리하는게 맞음
    • 도메인 엔터티를 입출력 모델로 사용하기 (지양)
    • 인커밍 포트 건너뛰기 (지양)
      • 인커밍 포트는 진입점 식별에 중요
    • 애플리케이션 서비스 건너뛰기 (지양)
      • 간단한 CRUD에서는 고려해볼 수 있음
        • 아웃고잉 어댑터 클래스가 애플리케이션 서비스의 인커밍 포트 구현
        • 도메인 모델을 입력 모델로 사용
      • 도메인 로직이 생기면 바로 애플리케이션 서비스 계층 사용할 것

Reference

만들면서 배우는 클린 아키텍처