Hyun's Wonderwall

[EFUB 4기 BE Lead] 도메인 주도 개발 시작하기 - 2. 아키텍처 개요 본문

Study/Java, Spring

[EFUB 4기 BE Lead] 도메인 주도 개발 시작하기 - 2. 아키텍처 개요

Hyun_! 2024. 3. 11. 18:03

EFUB 4기 BackEnd Lead_ 도메인 주도 개발 스터디

  • 스터디 커리큘럼: 최범균, "도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지"
  • 1주차 과제: Chapter 1. 도메인 모델 시작하기, Chapter 2. 아키텍처 개요

Chapter 2. 아키텍처 개요

  • Keywords: 아키텍처, DIP, 도메인 영역의 주요 구성요소, 인프라스트럭처, 모듈

2.1 네 개의 영역

아키텍처 설계의 4개 영역: (1)표현, (2)응용, (3)도메인, (4)인프라스트럭처

 

(1) 표현 영역(UI 영역): 사용자의 요청을 받아 응용 영역에 전달하고, 응용 영역의 처리 결과를 사용자에게 보여준다.

- 스프링 MVC 프레임워크가 표현 영역을 위한 기술에 해당한다. // Controller 클래스와 template 부분을 생각하면 될 듯함

- 표현 영역의 사용자는 웹 브라우저를 사용하는 사람일 수도 있고, REST API를 호출하는 외부 시스템일 수도 있다.

- 표현 영역은 웹 브라우저가 전송한 HTTP 요청을 응용 영역이 필요로 하는 형식으로 변환해서 응용 영역에 전달하고, 응용 영역의 응답을 HTTP 응답으로 변환하여 웹 브라우저에 전송한다.

  (ex. Request Parameter 사용하는 예시의 설명: 표현 영역은 웹 브라우저가 HTTP 요청 파라미터로 전송한 데이터를, 응용 영역이 요구하는 형식의 객체 타입으로 변환해서 전달하고, 응용 서비스가 리턴한 결과를 JSON 형식으로 변환해서 HTTP 응답으로 웹 브라우저에 전송한다.)

 

(2) 응용 영역: 표현 영역을 통해 사용자의 요청을 전달받고, 시스템이 사용자에게 제공해야 할 기능을 구현한다. // 일부 Service 클래스를 생각하면 될 듯함

  (ex. '주문 등록', '주문 취소', '상품 상세 조회'와 같은 기능 구현을 예로 들 수 있다)

- 응용 영역은 기능을 구현하기 위해 도메인 영역의 도메인 모델을 사용한다. 이때 로직을 직접 수행하기보다는 도메인 모델에 로직 수행을 위임한다.

public class CancelOrderService {
    @Transactional
    public void cancelOrder(String orderId) {
    	Order order = findOrderById(orderId);
        if (order == null) throw new OrderNotFoundException(orderId);
        order.cancel();
    }
    ...
}

  (ex. CancelOrderService가 Order 주문 도메인 모델을 사용해서 '주문 취소' 기능을 구현한다. order.cancel()을 호출함으로써 주문 취소 로직을 직접 구현하지 않고 order 객체에 취소 처리를 위임하고 있다.)

 

(3) 도메인 영역: 도메인 모델을 구현한다.

- 도메인 영역에서 도메인 모델은 도메인의 핵심 로직을 구현한다. 

  (ex. 1장의 Order, OrderLine, ShippingInfo와 같은 도메인 모델이 이 영역에 위치함)

  (ex. 주문 도메인은 배송지 변경, 결제 완료, 주문 총액 계산과 같은 핵심 로직을 도메인 모델에서 구현한다.)

 

(4) 인프라스트럭처 영역: 실제 구현 기술에 대한 것을 다룬다.

- 이 영역은 RDBMS 연동을 처리하고, 메시징 큐에 메시지를 전송/수신하는 기능을 구현한다.

- 몽고DB나 레디스와의 데이터 연동을 처리한다.

- SMTP를 이용한 메일 발송 기능을 구현하거나, HTTP 클라이언트를 이용해서 REST API를 호출하는 것도 처리한다.

 

도메인 영역, 응용 영역, 표현 영역은 구현 기술을 사용한 코드를 직접 만들지 않고, 인프라스트럭처 영역에서 제공하는 기능을 사용해서 필요한 기능을 개발한다.

  (ex. 응용 영역에서 DB에 보관된 데이터가 필요하면 인프라스트럭처 영역의 DB모듈을 사용하여 데이터를 읽어본다. 외부에 메일을 발송해야 한다면 인프라스트럭처가 제공하는 SMTP 연동 모듈을 이용해서 메일을 발송한다.)

 

2.2 계층 구조 아키텍처

4계층 구조 아키텍처 구성. [ 표현 -> 응용 -> 도메인 -> 인프라스트럭처 ] // 위에서 아래로 영역을 사용한다.

- 계층 구조는 그 특성상 상위 계층에서 하위 계층으로의 의존만 존재하고, 하위 계층은 상위 계층에 의존하지 않는다.

- 계층 구조를 엄격하게 적용한다면 상위 계층은 바로 아래의 계층에만 의존을 가져야 하지만, 구현의 편리함을 위해 계층 구조를 유연하게 적용하기도 한다. 예를 들어 응용 계층은 바로 아래 계층인 도메인 계층에 의존하면서, 외부 시스템과의 연동을 위해 더 아래 계층인 인프라스트럭처 계층에 의존하기도 한다.

  (ex. 서비스(응용)가 엔티티(도메인)에 의존, 엔티티가 룰 엔진(인프라)에 의존, 서비스가 DB모듈(인프라)에 의존. // 응용 영역과 도메인 영역은 DB나 외부 시스템 연동을 위해 이렇게 인트라스트럭처의 기능을 사용한다.)

- 짚고 넘어갈 것: 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라스트럭처 계층에 종속된다.

  (ex. Drools라는 룰 엔진을 사용하는 계산 서비스를 구현 -> 서비스의 메서드에서 Drools의 세션 이름을 사용해야 하고 룰 적용 결괏값 보관을 위해 타입을 추가해야 하는 등, Drools라는 인프라스트럭처 영역의 기술에 의존함을 알 수 있음.) 

 

인프라 스트럭처에 의존하면 이렇게 '테스트 어려움'과 '기능 확장의 어려움'이라는 두 가지 문제가 발생하게 된다.

어떻게 이 두 문제를 해결할 수 있을까? 해답은 DIP이다.

 

2.3 DIP

DIP(Dependency Inversion Principle, 의존 역전 원칙): 저수준 모듈이 고수준 모듈에 의존하도록 한다.

- 보통 고수준 모듈이 저수준 모듈을 사용하려면 고수준 모듈이 저수준 모듈에 의존해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존한다고 해서 '의존 역전 원칙'이라고 부른다.

- DIP를 적용하면 다른 영역이 인프라스트럭처 영역에 의존할 때 발생하는 두 가지 문제들 '테스트 어려움',  '구현 교체가 어려움' 문제를 모두 해소할 수 있다.

 

DIP 적용 방법

상속은 의존의 또다른 형태

- 도메인에서는 클래스에서 인터페이스를 필드로 사용해 인터페이스에 의존하도록 하고, 인프라에서는 클래스가 도메인의 인터페이스를 상속하도록 해서 업캐스팅 방식으로 도메인에서 사용할 수 있도록 한다.

- 고수준 모듈은 구현을 추상화한 인터페이스에 의존한다. (필드에 인터페이스를 가지며, 생성자 방식으로 주입 가능)

- 메인에서 실제 사용할 저수준 구현 객체는 (업캐스팅=상속) 의존 주입을 이용해서 전달받을 수 있다.

 

구현 기술을 변경하더라도 사용할 저수준 구현 객체를 생성하는 코드만 변경하면 된다.

# 서비스 테스트를 위해 인터페이스들을 사용해야 하는 예시

- 스프링에서 Mock 프레임워크 이용하고, 람다식으로 객체 생성해 두 대역 객체를 생성함. 두 대역 객체는 테스트를 수행하는 데 필요한 기능만 수행한다.

- 인터페이스들의 실제 구현 클래스가 없어도 서비스들을 테스트할 수 있다. (RDBMS, Drools 없어도 거의 모든 기능 테스트 가능)

 

2.3.1 DIP 주의사항

DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈(응용/도메인) 관점에서 도출한다.

DIP의 핵심은 고수준 모듈이 저수준 모듈에 의존하지 않도록 하는 것이다. DIP를 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들인다면, 저수준 모듈에서 인터페이스를 추출해버릴 수 있는데 이것은 DIP가 아니다.
  (ex. 룰 엔진 인터페이스를 추출한 예시 - 여전히 고수준 모듈이 저수준 모듈에 의존함. CalculateDiscountService 입장에서 봤을 때 룰 엔진의 사용 유무는 중요x.
  이 예시에서는 규칙에 따라 할인 금액을 계산한다는 것이 중요하다. '할인 금액 계산'을 추상화한 인터페이스를 고수준 모듈 관점에서 도출 -> 인터페이스가 고수준 모듈에 위치하게 된다.)

 

2.3.2 DIP와 아키텍처

- 인프라스트럭처 영역: 구현 기술을 다루는 저수준 모듈 // 응용 영역과 도메인 영역: 고수준 모듈

- DIP가 적용된 아키텍처: 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조. (인프라스트럭처 계층이 가장 하단에 위치하는 계층형 구조와 차이)

[ 인프라 -> (응용) -> 도메인 ]

- DIP 구조: 인프라스트럭처에 위치한 클래스가 도메인이나 응용 영역에 정의한 인터페이스를 상속받아 구현하는 구조.

=> 도메인 영역과 응용 영역에 영향을 최소화하면서 구현체 변경/추가 가능.

# 예시 설명
- 인프라스트럭처영역의 EmailNotifier 클래스는 응용 영역의 Notifier 인터페이스를 상속받고 있다. 주문 시 통지 방식에 SMS를 추가해야 한다는 요구사항이 들어왔을 때, 응용 영역의 OrderService는 변경할 필요가 없다. 인프라스트럭처에서 두 통지 방식을 함께 제공하는 새 Notifier 구현 클래스, CompositeNotifier 클래스를 인프라스트럭처 영역에 추가하면 된다.
- 비슷하게 MyBatis 대신 JPA를 구현 기술로 사용하고 싶다면 JPA를 이용한 OrderRepository 구현 클래스, JpaOrderRepository 클래스를 인프라스트럭처 영역에 추가하면 된다.

- (DIP를 항상 적용할 필요는 없다. 사용하는 구현 기술에 따라 구현 기술에 의존적인 코드를 도메인에 일부 포함하는 게 효과적일 때도 있다. 2.6 참고)

 

2.4 도메인 영역의 주요 구성요소

도메인 영역은 도메인의 핵심 모델을 구현한다.

도메인 영역의 모델: 도메인의 주요 개념을 표현 & 핵심 로직을 구현

도메인 영역의 구성요소: (1)엔티티, (2)밸류 타입, (3)애그리거트, (4)리포지터리, (5)도메인 서비스

요소 설명
엔티티
ENTITY
고유의 식별자를 갖는 객체, 자신의 라이프 사이클을 가짐. 도메인의 고유한 개념을 표현함. 도메인 모델의 데이터를 포함하며 해당 데이터와 관련된 기능을 함께 제공함.
밸류
VALUE
고유의 식별자를 갖지 않는 객체. 주로 개념적으로 하나인 값을 표현할 때 사용됨. 엔티티의 속성으로 사용할 뿐만 아니라 다른 밸류 타입의 속성으로도 사용할 수 있음. (ex. 배송지 주소를 표현하기 위한 주소(Address)나 구매 금액을 위한 금액(Money)와 같은 타입이 밸류 타입.)
애그리거트
AGGREGATE
연관된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것. (ex.주문과 관련된 Order 엔티티, OrderLine 밸류, Orderer 밸류 객체를 주문 애그리거트로 묶을 수 있음.)
리포지터리
REPOSITORY
도메인 모델의 영속성을 처리함. (ex. DBMS 테이블에서 엔티티 객체를 로딩하거나 저장하는 기능을 제공.)
도메인 서비스
DOMAIN SETVICE
특정 엔티티에 속하지 않은 도메인 로직을 제공. 도메인 로직이 여러 엔티티와 밸류를 필요로 하면 도메인 서비스에서 로직을 구현함.

 

2.4.1 엔티티와 밸류

엔티티: 데이터와 함께 도메인 기능을 함께 제공하는 객체.

- 도메인 관점에서 기능을 구현하고 기능 구현을 캡슐화해서 데이터가 임의로 변경되는 것을 막는다.

- 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표현할 수 있다.

- 도메인 모델의 엔티티는 DB 관계형 모델의 엔티티와 같은 개념 x. (<-데이터만 담고 있음. RDBMS 등 관계형 데이터베이스에서는 밸류 타입을 제대로 표현하기 힘듦. 개별 데이터를 저장하거나 별도 테이블로 분리해서 저장해야 함.)

 

밸류: 불변으로 구현할 것을 권장. (엔티티의 밸류 타입 데이터를 변경할 때는 객체 자체를 교체, 새로운 객체를 할당.)

 

2.4.2 애그리거트

# 애그리거트의 필요성
도메인이 커짐 -> 개발할 도메인 모델도 커짐 -> (모델을 구성하는) 엔티티와 밸류가 많아짐 -> 모델 복잡해짐
- 이때 발생할 수 있는 문제: 개발자가 전체 구조가 아닌 개별 요소(한 개 엔티티와 밸류)에만 초점을 맞추다가, 큰 수준에서 모델을 이해하지 못해서 큰 틀에서 모델을 관리할 수 없는 상황에 빠질 수도...
- 지도를 보는 예시 (대축적 지도와 소축적 지도를 함께 봄)
- 도메인 모델을 볼 때도 개별 객체뿐 아니라 상위 수준에서 모델을 볼 수 있어야 전체 모델의 관계와 개별 모델을 이해하는 데 도움이 된다.

 

애그리거트: 도메인 모델에서 관련 객체를 하나로 묶은 군집.

- 관련된 객체를 애그리거트로 묶으면 객체 군집 단위로 모델을 볼 수 있다.

- 개별 객체 간 관계가 아닌, 애그리거트 간 관계로 도메인 모델을 이해하고 구현 => 큰 틀에서 도메인 모델을 관리 가능.

  (ex. 하위 개념 모델 - 배송지 정보, 주문자, 주문 목록, 총 결제 금액; 상위 개념 모델 - 주문)

 

애그리거트의 루트 엔티티: 애그리거트가 군집에 속한 객체를 관리하기 위해 가진다.

- 애그리거트에 속한 엔티티와 밸류 객체를 이용해 애그리거트가 구현해야 할 기능을 제공한다.

- 작동 방식 상세 설명: 애그리거트를 사용하는 코드는 애그리거트 루트가 제공하는 기능을 실행하고, 애그리거트 루트를 통해서 간접적으로 애그리거트 내의 다른 엔티티나 밸류 객체에 접근한다. (애그리거트의 내부 구현을 숨겨,  구현을 애그리거트 단위로 캡슐화)

# 주문 애그리거트 예시
Order: 애그리거트 루트. 주문 도메인의 로직에 맞게 애그리거트의 상태를 관리함.
ex. 배송지 정보 변경 기능 - Order는 checkShippingInfoChangeable() 메서드로 도메인 규칙에 따라 배송지를 변경할 수 있는지 확인한 후,(불가능하면 익셉션) 가능한 경우에 배송지 정보를 변경. (Order를 통하지 않고 ShippingInfo를 변경할 수 없음)
- 즉 기능을 사용하려면 루트 엔티티인 Order를 사용해야 함 -> Order가 구현한 도메인 로직을 항상 따르게 됨.

- 애그리거트 구현 시 고려할 것이 많음. 구현이 복잡해지기도 하고 트랜잭션 범위가 달라지기도 함. 또한 선택한 구현 기술에 따라 애그리거트 구현에 제약이 생기기도.

 

2.4.3 리포지터리

리포지터리(Repository): 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의하는 도메인 모델.

- 도메인 객체를 지속적으로 사용하기 위해서 물리적인 저장소(RDBMS, NoSQL, 로컬 파일 등) 에 도메인 객체를 보관해야 하는데, 이때에 사용한다.

- 리포지터리는 구현을 위한 도메인 모델이다. // 엔티티와 밸류는 요구사항에서 도출되는 도메인 모델

- 도메인 모델을 사용해야 하는 코드는 리포지터리를 통해서 도메인 객체를 구한 뒤에 도메인 객체의 기능을 실행한다.

- 리포지터리 인터페이스는 도메인 모델 영역에 속하며, 실제 구현 클래스는 인프라스트럭처 영역에 속한다.

- 응용 서비스는 의존 주입과 같은 방식을 사용해서 실제 리포지터리 구현 객체에 접근한다. (필드로 받는 것)

# 주문 도메인 리포지터리 인터페이스
public interface OrderRepository: save(), delete(), findByNumber() 등의 메서드
- 대상을 찾고 저장하는 단위가 애그리거트 루트인 Order이다.
- Order는 애그리거트에 속한 모든 객체를 포함하고 있으므로 결과적으로 애그리거트 단위로 저장, 조회한다.


# 주문 취소 기능을 제공하는 응용 서비스 CancelOrderService
필드로 OrderRepository를 받고, findByNumber() 메소드를 이용해 Order 객체를 구해 cancel() 기능을 실행한다.
- 도메인 모델 관점에서 OrderRepository: 도메인 객체를 영속화하는 데 필요한 기능을 추상화한 것=고수준 모듈.
- 기반 기술을 이용해 OrderRepository를 구현한 클래스: 저수준 모듈=인프라스트럭처 영역. (JpaOrderRepository 클래스를 만들어 사용하는 경우 OrderRepository를 상속받아서 사용)

 

응용 서비스와 리포지터리는 밀접한 연관이 있다. 그 이유들:

  • 응용 서비스가 필요한 도메인 객체를 구하거나 저장할 때 리포지터리를 사용함.
  • 응용 서비스가 트랜잭션을 관리하는데, 트랜잭션 처리는 리포지터리 구현 기술의 영향을 받음.

리포지터리는 응용 서비스가 필요로 하는 메서드를 제공한다. 기본이 되는 메서드들:

  • 애그리거트를 저장하는 메서드 // void save(Some some)
  • 애그리거트를 루트 식별자로 애그리거트를 조회하는 메서드 // Some findById(SomeId id)

이외에 delete(id), counts() 등의 메서드를 제공하기도 한다.

리포지터리를 구현하는 방법은 선택한 구현 기술에 따라 달라진다.

 

2.5 요청 처리 흐름

표현 영역

- 사용자의 요청을 받는 영역으로, 스프링 MVC의 경우 컨트롤러가 처리한다.

- 사용자가 전송한 데이터 형식이 올바른지 검사하고 문제가 없다면 데이터를 이용해 응용 서비스에 기능 실행을 위임한다. (이때 사용자가 전송한 데이터를 응용 서비스가 필요로 하는 데이터로 변환해서 전달한다.)

 

응용 서비스

- 도메인 모델을 이용해서 기능을 구현한다. (기능 구현에 필요한 도메인 객체를 리포지터리에서 가져와 실행하거나, 신규 도메인 객체를 생성해서 리포지터리에 저장한다.)

- 도메인의 상태를 변경하므로 변경 상태가 물리 저장소에 올바르게 반영되도록 트랜잭션(Transaction)을 관리해야 한다.

 

2.6 인프라스트럭처 개요

인프라스트럭처는 다른 영역에서 필요로 하는 프레임워크, 구현 기술, 보조 기능을 지원한다.

  (ex. 도메인 객체의 영속성 처리, 트랜잭션, SMTP 클라이언트, REST 클라이언트 등)

- 인프라스트럭처의 기능을 직접 사용하는 것보다 도메인, 응용 영역에 정의한 인터페이스를 인프라스트럭처 영역에서 구현하는 것이 시스템을 더 유연하고 테스트하기 쉽게 만들어준다.

- 하지만 구현의 편리함을 위해 인프라스트럭처에 대한 의존을 일부 도메인에 넣을 수도 있다. 구현의 편리함도 중요하므로 응용 영역과 도메인 영역에서 어느정도는 구현 기술에 대한 의존을 가져가는 것이 나쁘지 않다. 특히 트랜잭션 부분에서 쓰는 게 좋음.

  (ex. 스프링 사용 시 트랜잭션 처리를 위해 @Transactional을 사용하는 것이 편리하다.)

  (ex, 영속성 처리를 위해 JPA를 사용할 경우 @Entity나 @Table 같은 JPA 전용 애너테이션을 도메인 모델 클래스에 사용하는 것이 XML 매핑 설정을 이용하는 것보다 편리하다.)

- 표현 영역은 항상 인프라스트럭처 영역과 쌍을 이룬다. // 스프링 MVC, 서버 예시

 

2.7 모듈 구성

아키텍처 각 영역은 별도 패키지에 존재한다.

- 도메인 단위로 패키지를 구성한다. (ui, application, domain, infrastructure 하위 도메인 별로 모듈을 나눈다)

- 도메인 모듈은 도메인이 속한 애그리거트를 기준으로 다시 패키지를 구성한다. (카탈로그 도메인의 하위 애그리거트가 상품, 카테고리라면 application 아래에 각 패키지를 구성)

- 애그리거트, 모델, 리포지터리는 같은 패키지에 위치시킨다. (예를 들어 주문과 관련된 Order, OrderLine, Orderer, OrderRepository 등은 com.myshop.order.domain 패키지에 위치)

- 도메인이 복잡하면 도메인 모델과 도메인 서비스를 별도 패키지에 위치시킬 수도 있고, 응용 서비스도 도메인 별로 패키지를 구분할 수 있다.