CS Repo/도메인 주도 설계

도메인 주도 설계(DDD)

조금씩 차근차근 2025. 3. 11. 00:07

1. 개념 및 원칙

  • 도메인 주도 설계(Domain-Driven Design)는 소프트웨어의 핵심 비즈니스 도메인에 집중하여 복잡한 도메인 지식을 코드의 도메인 모델로 표현하고 발전시키는 설계 철학이다. 복잡한 비즈니스 로직을 도메인 모델(객체 모델)로 추상화함으로써 비즈니스 현실과 코드 간의 간극을 줄인다. 이를 위해 도메인 전문가개발자는 공유하는 보편 언어(Ubiquitous Language)를 구축하여 요구사항 분석부터 설계, 구현까지 동일한 언어로 소통한다. 이 과정에서 모델은 지속적으로 정제되고 리팩토링되어, 요구사항의 의도를 코드에 정확히 반영하게 된다.

DDD가 필요한 이유는 복잡한 도메인 문제를 다루는 소프트웨어에서 두드러진다. 전통적인 데이터 중심 설계나, 도메인 객체가 단순히 데이터 저장소 역할만 하는 빈혈 도메인 모델(Anemic Domain Model)은 도메인 지식이 코드 구조와 분리되어 이해와 유지보수가 어렵다. 반면 DDD는 풍부한 도메인 모델(Rich Domain Model)을 지향하여, 각 엔티티가 스스로 비즈니스 규칙을 포함하고 상태를 관리하도록 설계한다. 이와 같이 설계하면 객체의 캡슐화 원칙을 최대한 활용하여 도메인 일관성과 무결성을 유지할 수 있다.

주요 원칙은 다음과 같다.

  • 핵심 도메인 집중: 시스템의 가장 중요한 코어 도메인에 설계 역량을 집중한다. 코어 도메인은 해당 조직이나 비즈니스의 고유한 가치 영역이므로, 최고의 노력과 자원을 투입해야 한다. 핵심이 아닌 부분은 표준 솔루션이나 기존 제품을 활용하고, 혁신은 핵심 도메인에 집중한다.
  • 보편 언어(Ubiquitous Language): 도메인 전문가와 개발자가 공통으로 사용하는 언어를 정립한다. 예를 들어 금융 도메인에서 “약정”, “정산” 등의 용어를 모두가 동일하게 이해하며, 코드의 클래스나 메서드 이름에도 그대로 반영한다. 이를 통해 요구사항과 구현 간의 의미 불일치 및 커뮤니케이션 오류를 줄인다.
  • 도메인 모델링과 반복적 설계: 도메인 지식을 담은 모델을 지속적으로 정제하고 리팩토링한다. 초기에는 거친 모델로 시작하더라도 도메인 전문가와의 대화를 통해 개념을 다듬고 이를 코드에 반영한다. 이 과정에서 모델의 일관성무결성이 유지되어야 한다.
  • 계층 분리와 인프라 격리: 도메인 로직은 응용 계층이나 인프라스트럭처(DB, UI 등)와 분리된 도메인 계층에 위치시킨다. 이렇게 하면 비즈니스 규칙이 외부 구현 세부사항에 섞이지 않고 도메인 모델이 깔끔하게 유지된다.
  • 테스트 용이성: 비즈니스 로직이 한 곳에 집중되면 단위 테스트 작성이 용이해져, 핵심 도메인 로직의 신뢰성을 높이고 회귀 버그를 줄일 수 있다.

이러한 원칙은 기존의 데이터베이스 스키마 우선 설계3-Layer Architecture(controller-service-repository)와 차별된다. DDD에서는 데이터베이스는 도메인 모델의 영속화 수단일 뿐이며, 설계의 출발점은 언제나 도메인 개념에 있다. 따라서 DDD를 적용하면 소프트웨어 구조가 해당 비즈니스의 개념을 그대로 반영하고, 변화에 유연하게 대응할 수 있다. 단, 도메인이 단순한 경우 DDD의 모든 패턴을 적용하는 것은 오히려 과할 수 있다.


2. 전술적 설계 (Tactical Design)

DDD의 전술적 설계 패턴은 실제 코드 작성 시 도메인 모델을 구현하는 빌딩 블록을 제공한다. 주요 전술 패턴에는 엔티티(Entity), 값 객체(Value Object), 애그리거트(Aggregate), 리포지토리(Repository) 등이 있으며, 그 외에도 도메인 서비스, 팩토리, 도메인 이벤트 등이 있다.

엔티티(Entity)

엔티티는 고유한 식별자(identity)를 가지며 시간에 따라 상태가 변할 수 있는 객체이다. 예를 들어 “주문”, “회원”, “상품” 등은 고유 ID로 구분되며, 속성이 변경되더라도 동일한 엔티티로 취급된다. 두 엔티티가 모든 속성이 같더라도 식별자가 다르면 다른 객체로 분류된다.

엔티티는 자신이 가진 데이터와 비즈니스 규칙을 캡슐화한다. 예를 들어 Order 엔티티는 cancel()이나 complete() 등의 메서드를 통해 상태 전이를 제어하며, 유효하지 않은 상태 변경을 방지한다. 아래는 Java 유사 코드 예제이다:

class Order {
    private OrderId id;                // 엔티티의 식별자
    private List<OrderLine> items;     // 주문 항목 목록 (OrderLine은 Value Object로 가정)
    private OrderStatus status;        // 주문 상태 (예: NEW, PAID, SHIPPED 등)

    public Order(OrderId id) {
        this.id = id;
        this.items = new ArrayList<>();
        this.status = OrderStatus.NEW;
    }

    // 비즈니스 메서드 예: 주문에 아이템 추가
    public void addItem(Product product, int quantity) {
        if (status != OrderStatus.NEW) {
            throw new IllegalStateException("주문이 완료된 후에는 아이템을 추가할 수 없다.");
        }
        items.add(new OrderLine(product, quantity));
    }

    // 비즈니스 메서드 예: 주문 취소
    public void cancel() {
        if (status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("이미 발송된 주문은 취소할 수 없다.");
        }
        this.status = OrderStatus.CANCELED;
    }

    // getter...
}

위 예제에서 Order 엔티티는 고유한 ID를 바탕으로, 자신의 상태(status)에 따라 비즈니스 제약을 검증한다.

값 객체(Value Object)

값 객체는 고유 식별자가 없으며 속성 값으로만 동일성을 판단하는 객체이다. 두 값 객체가 모든 속성에서 동일한 값을 가지면 동일한 것으로 간주된다. 값 객체는 보통 불변(Immutable)으로 설계하여, 변경이 필요할 경우 새로운 인스턴스로 대체한다.

예를 들어, 돈을 나타내는 Money(currency, amount), 기간을 나타내는 DateRange(start, end), 주소를 나타내는 Address(city, street, zip) 등이 값 객체에 해당한다. 값 객체는 주로 작고 변경되지 않는 개념을 표현하며, 단위 테스트 작성과 값 비교가 용이하다.

// 값 객체 예시: 화폐 가치를 나타내는 Money 클래스 (불변 객체)
class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    // 두 Money 값 객체의 동등성은 금액과 통화가 모두 같을 때 성립
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money)) return false;
        Money other = (Money) o;
        return this.amount.equals(other.amount) && this.currency.equals(other.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}

Money 클래스는 통화와 금액을 속성으로 가지며, 동등성 비교를 속성 기준으로 수행한다.

애그리거트(Aggregate)와 애그리거트 루트(Aggregate Root)

애그리거트는 관련된 엔티티와 값 객체들을 하나의 군집으로 묶은 도메인 객체들의 집합이다. 애그리거트는 내부의 여러 객체들이 하나의 일관성 경계(Consistency Boundary) 내에서 동작하도록 하며, 이 집합에는 반드시 애그리거트 루트(Aggregate Root)가 존재한다. 외부에서는 반드시 이 루트를 통해서만 애그리거트 내부에 접근해야 한다.

예를 들어, “주문(Order)”과 “주문 항목(OrderLine)”의 관계를 생각하면, Order가 애그리거트 루트가 되고, OrderLine들은 그 내부 구성 요소로 취급된다. 외부에서는 OrderLine에 직접 접근하지 않고, Order의 메서드를 통해 간접적으로 조작해야 한다. 이는 애그리거트 루트가 전체 군집의 불변식을 보호하도록 하기 위함이다.

// OrderAggregate (Order 애그리거트) – Order가 루트 엔티티
class Order {
    private OrderId id;
    private List<OrderLine> items;
    private OrderStatus status;
    // ... (생성자 등)

    public void addItem(Product product, int quantity) {
        // 루트 엔티티의 메서드를 통해 OrderLine 추가
        // 내부 불변식: 동일 상품이 중복되어 추가되지 않도록 체크
        for (OrderLine line : items) {
            if (line.getProduct().equals(product)) {
                line.incrementQuantity(quantity);
                return;
            }
        }
        items.add(new OrderLine(product, quantity));
    }

    public Money getTotalPrice() {
        Money total = new Money(BigDecimal.ZERO, "USD");
        for (OrderLine line : items) {
            total = total.add(line.getPrice());
        }
        return total;
    }

    // 주문 애그리거트의 불변식 유지
}

위 예제에서 Order는 주문 항목 리스트를 포함하며, addItem 메서드를 통해서만 내부 OrderLine을 조작하도록 강제한다.

리포지토리(Repository)

리포지토리는 엔티티나 애그리거트를 영속화하고 재구성하는 역할을 수행하는 객체이다. 즉, 애그리거트 단위의 객체 컬렉션처럼 동작하는 저장소 추상화이다.

리포지토리를 사용하면 도메인 모델 코드에서 SQL 쿼리나 데이터베이스와 같은 인프라 세부사항을 노출하지 않고, 엔티티를 조회하거나 저장할 수 있다. 일반적으로 하나의 애그리거트 루트마다 하나의 리포지토리를 둔다. 리포지토리 인터페이스는 도메인 계층에 위치시키고, 그 구현은 인프라 계층에서 담당한다.

public interface OrderRepository {
    Order findById(OrderId id);                   // ID로 애그리거트 조회
    List<Order> findByCustomer(CustomerId customerId);  // 조건에 따른 조회 (예시)
    void save(Order order);                       // 애그리거트 저장 (삽입 또는 갱신)
    void delete(Order order);                     // 삭제
}

구현체에서는 예를 들어 JPA를 사용하여 save 메서드가 EntityManager를 통해 영속화 작업을 수행한다. 중요한 점은 도메인 로직에서 리포지토리 인터페이스에만 의존해야 한다는 것이다.

팩토리(Factory)

팩토리는 복잡한 도메인 객체나 애그리거트를 생성할 때, 객체 생성 로직을 캡슐화하여 객체 생성 책임을 분리하는 패턴이다. 팩토리를 사용하면 엔티티나 애그리거트 생성 시 필요한 복잡한 초기화 과정이나 제약 조건을 한 곳에 집중시켜, 클라이언트 코드에서는 단순히 생성 메서드를 호출하는 것만으로 객체를 얻을 수 있다.

예를 들어, Order 애그리거트를 생성할 때 여러 가지 기본값이나 내부 구성 객체의 초기화가 필요하다면, 이를 팩토리 클래스로 추출하여 다음과 같이 작성할 수 있다.

 
public class OrderFactory {
    
    public static Order createNewOrder(Customer customer) {
        // 엔티티 생성 시 필요한 초기화 로직 및 불변식 검증
        OrderId orderId = OrderId.generate();
        Order order = new Order(orderId);
        
        // 예를 들어, 고객의 기본 할인 정책 적용 등 추가 로직 수행
        // order.applyDiscount(customer.getDefaultDiscountPolicy());
        
        return order;
    }
}
 

이처럼 팩토리는 복잡한 생성 로직을 도메인 객체 내부에서 분리하여 관리하고, 클라이언트는 팩토리 메서드를 통해 단순히 객체를 생성할 수 있다. 팩토리 패턴은 도메인 모델의 불변성 및 일관성을 유지하는 데 기여하며, 테스트나 확장성 측면에서도 유리하다.

 

그 외 전술적 패턴: 도메인 서비스, 도메인 이벤트


3. 전략적 설계 (Strategic Design)

대규모 시스템에서는 도메인 모델이 커지고 조직의 팀이 증가함에 따라, DDD는 전략적 설계 개념을 통해 전체 시스템을 관리한다. 핵심은 큰 도메인을 여러 서브도메인(Subdomain)으로 나누고, 각 영역에 경계(Bounded Context)를 설정하여 독립된 모델을 유지하는 것이다. 또한, 각 컨텍스트 간의 관계를 컨텍스트 맵(Context Mapping)으로 명확히 하여 팀 간, 시스템 간 통합 방식을 정의한다.

바운디드 컨텍스트(Bounded Context)

바운디드 컨텍스트는 경계가 명확히 구분된 도메인 모델의 범위를 의미한다. 동일한 용어라도 문맥에 따라 달라질 경우, 각 컨텍스트 내에서 독자적인 도메인 모델과 보편 언어를 유지하도록 경계를 설정한다. 예를 들어, 전자상거래 시스템에서 “Account”는 쇼핑 컨텍스트에서는 사용자 로그인 및 권한 관리와 관련되고, 은행/결제 컨텍스트에서는 금전 거래와 잔액 관리와 관련된다. 이렇게 하면 각 컨텍스트는 별도의 모델을 가지며, 모델 변경이 다른 컨텍스트에 영향을 주지 않는다.

컨텍스트 맵핑(Context Mapping)

서로 분리된 바운디드 컨텍스트는 완전히 독립적이지 않으며, 서로 연계하여 전체 도메인을 구성한다. 컨텍스트 맵은 여러 컨텍스트 사이의 관계와 상호작용을 명시적으로 표현하는 작업 또는 그 결과물이다. 예를 들어, 한 컨텍스트가 다른 컨텍스트의 기능을 사용하는 경우 고객-공급자(Customer-Supplier) 관계로 모델링할 수 있고, 모델을 공유해야 하는 경우 공유 커널(Shared Kernel) 패턴을 적용할 수 있다.

특히 ACL(Anti-Corruption Layer, 부패 방지 계층)은 한 컨텍스트가 외부 모델의 영향을 최소화하기 위해 중간에 번역 계층을 두는 패턴이다. 예를 들어, 외부 시스템의 데이터를 직접 사용하지 않고, 중간 변환기를 통해 내부 모델로 변환한 뒤 사용함으로써 내부 모델의 무결성을 유지한다.

컨텍스트 맵은 다이어그램으로 작성되어, 각 컨텍스트 간의 관계, 의존성, 데이터 흐름을 한눈에 파악할 수 있도록 한다.

서브도메인(Subdomain)

서브도메인은 큰 도메인을 하위 영역으로 분할한 것이다. 예를 들어, 전자상거래 도메인은 주문 관리, 결제, 배송, 상품 카탈로그, 회원 관리 등으로 나눌 수 있다. DDD에서는 문제 영역을 서브도메인으로 분할한 후, 각 서브도메인을 해결하기 위한 바운디드 컨텍스트를 설계한다.

서브도메인은 보통 세 가지 유형으로 구분된다.

  1. 코어 도메인(Core Domain): 해당 비즈니스의 핵심이며, 경쟁 우위를 결정짓는 중추적인 영역이다. 이 영역에는 최고의 개발자와 자원을 투입하여 최적의 설계를 적용해야 한다.
  2. 지원 서브도메인(Supporting Subdomain): 코어 도메인을 보조하는 역할을 수행한다. 비즈니스에 필수적이지만, 코어 도메인만큼 차별화되지 않는 영역이다.
  3. 범용 서브도메인(Generic Subdomain): 여러 시스템에서 공통적으로 사용되는 일반적 도메인이다. 예를 들어, 인증/인가, 결제 처리, 회계 등은 범용 서브도메인에 해당하며, 기존 솔루션이나 오픈소스를 활용하는 것이 경제적이다.

이러한 분류를 통해 어느 영역에 가장 많은 노력을 기울일지 결정할 수 있다. 코어 도메인에는 집중적인 투자와 정교한 DDD 전술 패턴을 적용하고, 지원 및 범용 도메인은 필요에 따라 표준 솔루션을 활용한다.

서브 도메인과 바운디드 컨텍스트의 관계

서브도메인은 현실 비즈니스 문제 영역을 나눈 것이고, 바운디드 컨텍스트는 소프트웨어 솔루션 측면의 경계이다. 이상적으로는 하나의 서브도메인을 해결하기 위해, 하나의 바운디드 컨텍스트를 만들고 전담 팀이 붙는것이 좋지만, 현실적으로 이는 레거시 시스템이나 조직 구조 때문에 불가능한 경우가 많다.

따라서, DDD에서는 이를 지속적으로 개선해 하나의 컨텍스트가 하나의 서브도메인 문제에만 집중하도록 유도하는 것이 바람직한 방향이다. 그래야 모델의 일관성도 높아지고, 팀의 전문성도 도메인 지식에 집중될 수 있기 때문이다.


4. DDD와 관련된 아키텍처 패턴 – Hexagonal, CQRS, Event Sourcing 등

DDD를 효과적으로 구현하거나 지원하기 위해 여러 아키텍처 패턴이 사용된다. 대표적인 패턴은 헥사고날 아키텍처(Hexagonal Architecture), CQRS(Command Query Responsibility Segregation), 이벤트 소싱(Event Sourcing)이다.

헥사고날 아키텍처 (Hexagonal Architecture)

헥사고날 아키텍처는 애플리케이션을 안쪽의 도메인 로직바깥쪽의 인프라스트럭처로 명확히 분리하는 패턴이다. 포트-어댑터(Ports & Adapters) 아키텍처라고도 하며, 의존성 방향을 역전시켜 핵심 비즈니스 로직을 외부 기술로부터 격리한다.

  • 핵심 원칙
    • 도메인 모델은 단독으로 존재해야 하며, 데이터베이스, UI, 메시징 시스템 등 외부 요소와 직접 연관되지 않아야 한다. 이를 통해 도메인 로직의 테스트 용이성과 유지보수성을 극대화한다.
  • 구조 및 구성
    • Domain Layer (Core): 엔티티, 값 객체, 애그리거트, 도메인 서비스 등이 포함된다. 이 계층은 순수한 비즈니스 로직만을 담으며 외부 기술에 대한 의존성이 전혀 없다.
    • Application Layer (Use Cases): 도메인 객체를 이용하여 사용자의 요구사항을 처리하는 응용 서비스 계층이다. 트랜잭션 관리 및 복합 프로세스를 담당한다.
    • Ports: 도메인과 인프라스트럭처 사이의 경계 인터페이스를 정의한다. 입력 포트(Primary/Driving Port)는 외부 요청을 받아들이고, 출력 포트(Secondary/Driven Port)는 외부 시스템에 데이터를 전달한다.
    • Adapters (Infrastructure): 포트를 구현하여 실제 외부 시스템(예: 데이터베이스, 메시징 시스템, REST API, UI 등)과 연결한다.

실제 적용 사례

웹 애플리케이션에서는 도메인 로직을 담당하는 서비스 클래스가 도메인 계층에 위치하고, 이를 호출하는 컨트롤러가 입력 포트 역할을 수행한다. 데이터베이스 접근은 Repository 인터페이스를 통해 이루어지며, 실제 구현체는 JPA 혹은 다른 ORM 도구를 사용하여 어댑터 역할을 맡는다. 이 구조는 외부 기술이 변경되더라도 도메인 로직은 수정할 필요 없이 어댑터만 교체하면 되어, 시스템의 유연성과 확장성이 크게 향상된다.

헥사고날 아키텍처는 “도메인 계층은 인프라에 의존하지 말라”는 DDD의 기본 원칙을 실현하며, 결과적으로 코드의 응집도를 높이고 결합도를 낮추어 도메인 모델을 순수하게 유지하는 데 기여한다.

CQRS (Command Query Responsibility Segregation)

CQRS명령(Command)조회(Query)의 책임을 분리하는 아키텍처 패턴이다. 데이터를 변경하는 작업(쓰기)은 DDD 전술 패턴을 적용한 복잡한 도메인 모델을 사용하고, 데이터를 조회하는 작업은 별도의 단순화된 모델이나 기술을 사용하여 최적화한다.

  • 핵심 원칙
    • 쓰기 모델 (Command Model): 도메인 전술 패턴(엔티티, 애그리거트 등)을 활용하여, 비즈니스 규칙과 상태 변화를 엄격하게 관리한다. 명령 처리 시 트랜잭션 일관성을 유지하며, 도메인 이벤트를 발행하여 후속 작업을 유도한다.
    • 읽기 모델 (Query Model): 데이터 조회에 특화된 단순화된 모델로, 데이터 전용 DTO나 Projection을 사용한다. 읽기 작업은 빠른 응답과 확장성을 위해 별도의 데이터 저장소나 캐시를 활용할 수 있다.
  • 장점 및 고려사항
    • 읽기와 쓰기의 스케일링을 독립적으로 수행할 수 있어, 시스템의 부하를 효율적으로 관리할 수 있다. 단, 모델 분리로 인해 데이터 일관성(최종적 일관성) 문제나 이벤트 동기화 등의 추가 설계가 필요하다.
  • 적용 사례
    • 예를 들어, 온라인 쇼핑몰에서 주문 생성은 도메인 모델(애그리거트)을 통해 처리되고, 주문이 완료되면 OrderPlaced와 같은 도메인 이벤트를 발행한다. 이 이벤트는 별도의 읽기 전용 데이터 저장소를 갱신하는 데 사용되어, 관리용 대시보드나 검색 기능이 최적화된 조회 모델을 제공한다.

CQRS는 복잡한 도메인 로직과 빈번한 읽기 요청이 공존하는 시스템에서, 각 작업을 전문화하여 전체 시스템의 효율성을 극대화할 수 있는 강력한 패턴이다.

이벤트 소싱 (Event Sourcing)

이벤트 소싱은 시스템의 현재 상태를 직접 저장하는 대신, State Machine을 설계한 뒤, 상태 변화 이벤트를 기록하여 나중에 이 이벤트들을 재생함으로써 현재 상태를 재구성하는 방식이다. 이 방법은 상태 변화의 모든 내역을 기록함으로써, 감사(audit)와 디버깅, 복구 작업에 큰 이점을 제공한다.

  • 핵심 원칙
    • 모든 상태 변경은 이벤트 형태로 기록된다. 예를 들어, 은행 계좌의 잔액은 단순한 숫자가 아니라, “입금”, “출금” 등의 이벤트 로그로 관리된다. 이러한 로그를 순차적으로 재생하여, 언제든지 특정 시점의 상태를 재구성할 수 있다.
  • 구조 및 구현 요소
    • 도메인 이벤트: OrderPlaced, OrderCanceled, MoneyTransferred 등의 이벤트는 애그리거트의 상태 변경을 명확하게 캡처한다.
    • 이벤트 스토어: 모든 도메인 이벤트를 순차적으로 저장하는 저장소로, 시스템의 “진실(source of truth)”이 된다.
    • 스냅샷: 이벤트 재생 시 성능을 개선하기 위해 일정 시점의 상태를 기록해두고, 이후 이벤트만 재적용하는 기법을 활용한다.
  • 실제 적용 사례
    • 주문 도메인에서는 주문 상태 변경 시마다 해당 이벤트를 기록한다. 예를 들어, 주문 생성, 취소, 변경 등 각 이벤트를 순차적으로 저장해두면, 나중에 전체 이벤트를 재생하여 주문의 최종 상태를 정확하게 재구성할 수 있다. 금융 시스템에서는 모든 거래 내역이 이벤트 로그로 저장되어, 계좌 잔액 산출이나 과거 거래 분석이 용이하다.
  • 장점 및 고려사항
    • 이벤트 소싱은 모든 상태 변경 내역을 투명하게 기록하므로, 감사 및 디버깅에 매우 유리하다. CQRS와 결합하면 읽기 모델을 비동기적으로 갱신할 수 있으나, 이벤트 스토어 관리, 이벤트 버전 관리, 이벤트 재생 성능 등의 문제로 추가 설계와 관리 노력이 요구된다.

이벤트 소싱은 DDD의 도메인 이벤트와 자연스럽게 결합되어, 복잡한 비즈니스 로직의 변경 내역을 완벽하게 추적하고 재현할 수 있는 강력한 기법이다.


5. 클린 아키텍처(Clean Architecture) - 계층화 중심 타협

클린 아키텍처는 로버트 C. 마틴(“Uncle Bob”)이 제시한 아키텍처 패턴으로, 시스템의 유지보수성, 확장성, 테스트 용이성을 극대화하는 것을 목표로 한다. 이 아키텍처는 핵심 비즈니스 로직(또는 도메인)을 외부의 프레임워크, UI, 데이터베이스, 기타 기술적 세부사항으로부터 철저하게 분리하여, 비즈니스 규칙이 변화하지 않도록 보장한다.

핵심 원칙

  • 의존성 규칙(Dependency Rule)
    • 모든 의존성은 내부로 향해야 한다. 즉, 가장 핵심적인 비즈니스 규칙(엔티티)은 외부의 세부 사항(UI, DB, 프레임워크 등)에 전혀 의존하지 않으며, 내부 계층만이 자신보다 낮은 계층에 의존한다.
  • 관심사의 분리(Separation of Concerns)
    • 비즈니스 규칙, 애플리케이션 로직, 인터페이스, 인프라스트럭처의 역할을 명확하게 분리하여 각각의 변경이 다른 계층에 영향을 미치지 않도록 한다.
  • 독립성(Independence)
    • 핵심 도메인과 애플리케이션 로직은 외부 세계(데이터베이스, UI, 프레임워크 등)와 독립적으로 존재해야 하며, 필요 시 외부 기술을 쉽게 교체할 수 있어야 한다.

계층 구조 및 역할

클린 아키텍처는 일반적으로 네 개의 주요 계층으로 구분된다. 각 계층은 내부로 갈수록 비즈니스 규칙에 가까워지며, 외부로부터 완전히 격리되어야 한다.

  1. 엔티티(Entities)
    • 정의: 비즈니스 도메인의 핵심 개념과 규칙을 캡슐화한 객체들이다.
    • 역할: 시스템 전반에 걸쳐 재사용 가능한 비즈니스 규칙과 상태를 관리한다.
    • 특징: 외부 기술이나 프레임워크에 전혀 의존하지 않는다.
  2. 유스케이스(Use Cases) 또는 인터랙터(Interactors)
    • 정의: 특정 비즈니스 프로세스를 구현하는 애플리케이션 서비스 계층이다.
    • 역할: 엔티티와 상호작용하여 사용자 요구사항을 처리하며, 애플리케이션의 핵심 업무 흐름을 구현한다.
    • 특징: 비즈니스 규칙을 구체적인 작업 단위로 캡슐화하며, 엔티티와 협력하여 전체 프로세스를 수행한다.
  3. 인터페이스 어댑터(Interface Adapters)
    • 정의: 유스케이스 계층과 외부 세계(웹, DB, UI 등)를 연결하는 중간 계층이다.
    • 역할: 데이터 전송 객체(DTO), 프레젠테이션 모델, 컨트롤러 등으로 구성되어 내부 모델과 외부 표현 간의 변환을 담당한다.
    • 특징: 내부 계층이 외부의 변경에 영향을 받지 않도록, 양쪽 간의 인터페이스를 분리한다.
  4. 프레임워크 및 드라이버(Frameworks & Drivers)
    • 정의: 웹 프레임워크, 데이터베이스, UI 라이브러리 등 외부 시스템 및 기술 스택을 포함한다.
    • 역할: 어댑터를 통해 유스케이스 및 엔티티와 연결되며, 실제 I/O 작업이나 외부 연동을 수행한다.
    • 특징: 시스템에서 가장 변화하기 쉬운 계층으로, 필요시 쉽게 대체할 수 있어야 한다.

의존성 규칙: 위 계층 구조에서 의존성은 반드시 바깥쪽에서 안쪽으로 향해야 하며, 엔티티 계층은 절대 외부 계층에 의존하지 않는다.

클린 아키텍처의 장점 및 고려사항

  • 유지보수성
    • 핵심 비즈니스 로직이 외부 기술과 분리되어 있으므로, UI나 DB 등 외부 요소가 변경되어도 도메인 로직은 그대로 유지된다.
  • 테스트 용이성
    • 각 계층이 독립적으로 존재하므로, 단위 테스트나 통합 테스트를 계층별로 분리하여 수행할 수 있다.
  • 확장성 및 유연성
    • 새로운 기능 추가나 외부 기술 교체 시, 핵심 비즈니스 규칙은 건드리지 않고 어댑터나 드라이버 계층만 수정하면 되므로, 시스템 확장이 용이하다.
  • 복잡도 관리
    • 계층 간 명확한 경계를 두어 복잡한 비즈니스 로직과 외부 기술의 결합도를 낮춤으로써, 장기적으로 시스템의 복잡도를 효과적으로 관리할 수 있다.

고려사항

클린 아키텍처는 모든 시스템에 무조건 적용해야 하는 만능 해법은 아니다. 시스템의 복잡도가 낮거나 단기간의 프로젝트에서는 과도한 설계가 될 수 있으며, 오히려 개발 초기 속도를 저해할 수 있다. 따라서 프로젝트의 규모와 복잡성을 고려하여, 적절한 수준에서 계층 분리를 적용하는 것이 중요하다.

실제 적용 사례

예를 들어, 웹 애플리케이션을 개발할 때 클린 아키텍처를 적용하는 방식은 다음과 같다.

  • 엔티티 계층
    • 비즈니스 규칙을 담은 도메인 객체(예: Order, Customer, Product)를 정의한다. 이 객체들은 데이터 검증, 상태 전이 등의 로직을 포함하며, 외부 라이브러리나 프레임워크에 전혀 의존하지 않는다.
  • 유스케이스 계층
    • 주문 생성, 주문 취소 등 특정 비즈니스 프로세스를 구현하는 서비스 클래스를 구성한다. 예를 들어, PlaceOrderUseCase는 고객의 주문 요청을 받아, Order 엔티티를 생성하고 관련 규칙을 적용한다.
  • 인터페이스 어댑터 계층
    • 웹 컨트롤러(예: REST API 컨트롤러)는 외부 요청을 받아 DTO로 변환한 후 유스케이스에 전달하고, 유스케이스의 결과를 다시 외부에 맞는 형식으로 변환한다. 또한, 데이터베이스와 통신하는 Repository 인터페이스의 구현체도 이 계층에 위치시킨다.
  • 프레임워크 및 드라이버 계층
    • Spring, Express, Django 등과 같은 웹 프레임워크와 데이터베이스 드라이버, UI 라이브러리 등이 여기에 속한다. 이 계층은 인터페이스 어댑터를 통해 유스케이스와 연계되며, 실제 I/O 처리를 담당한다.

이와 같이 클린 아키텍처를 적용하면, 예를 들어 데이터베이스 기술이 변경되더라도 엔티티와 유스케이스는 전혀 수정하지 않고, 오직 어댑터 계층만 교체하면 되므로 시스템 전체의 안정성과 유연성이 확보된다.