포스트

Spring - 레이어드 아키텍처, 클린 아키텍처, 핵사고날 아키텍처, 도메인 주도 설계

아키텍처에 대한 고찰

Spring - 레이어드 아키텍처, 클린 아키텍처, 핵사고날 아키텍처, 도메인 주도 설계

최근 2주간 집중적으로 사이드 프로젝트로 개발을 해보며 느낀 것

1. 단일 책임 원칙(SRP)을 따르는 방법은 무엇인가?

서비스 구조를 설계하면서 고민이 되었던 부분이 있다. 서비스 계층으로 책임을 분리할 것인가, 도메인 계층으로 책임을 분리할 것인가? 내가 여기서 말한 서비스 계층으로 책임을 분리한다는 것은 서비스가 다른 서비스를 호출하는 구조로, 이체는 이체 서비스가, 계좌 잔액의 변동은 계좌 서비스가 수행하도록 분리할 것을 말한다. 도메인 계층으로 분리한다는 것은, 이체 서비스가 계좌 도메인 객체를 활용해 계좌 잔액을 직접 변경할 수 있다는 것이다.
기존의 계층형 아키텍처를 사용하면서, TDD, 클린코드 강의에서 배웠던 도메인 클래스를 중심으로 비즈니스 로직을 구현하려고 해 보았더니, 레이어드 아키텍처에서 구현하기 애매하다는 것을 깨닫게 되어 그에 대해 정리하려고 한다.

서비스 계층으로 분리할 경우,

서비스 계층에서 핵심 로직을 구현한다.

  • 장점:
    • 각 서비스의 책임이 명확히 분리됨
    • 단일 책임 원칙(SRP)을 잘 따름
    • 서비스 간 의존성이 명확함
  • 단점:
    • 서비스 간 호출이 많아져 성능 저하 가능성
    • 트랜잭션 관리가 복잡해질 수 있음

도메인 클래스를 중심으로 분리할 경우,

도메인 계층은 도메인의 핵심 규칙을 구현한다.

  • 장점:
    • 도메인 로직이 한 곳에 집중되어 관리 용이
    • 객체지향적 설계에 더 가까움
    • 비즈니스 로직의 응집도가 높아짐
  • 단점:
    • 도메인 클래스가 복잡해질 수 있음
    • 책임 분리가 불명확해질 수 있음

알고보니 이것은 응용 계층 vs 도메인 계층의 분리 문제를 확장하면 클린 아키텍처, 헥사고날 아키텍처, 도메인 주도 설계 등의 개념과 연관이 있었다.

레이어드 아키텍처의 문제점

일반적으로 프레젠테이션, 비즈니스 로직, 데이터 접근 계층으로 구성된다. 이 아키텍처는 간단하고 이해하기 쉽지만, 몇 가지 주요 문제점이 있다.

  1. 단방향 의존성:
    • 상위 계층이 하위 계층에 의존하는 구조로, 하위 계층의 변경이 상위 계층에 영향을 미침
    • 문제점: 유연성 감소, 변경의 파급 효과가 큼
  2. 도메인 로직의 분산:
    • 비즈니스 로직이 여러 계층에 걸쳐 분산될 수 있음
    • 문제점: 핵심 비즈니스 로직의 응집도 저하, 유지보수 어려움
  3. 데이터베이스 중심 설계:
    • 데이터 모델이 전체 아키텍처를 지배하는 경향
    • 문제점: 도메인 모델의 순수성 훼손, 비즈니스 로직의 표현력 저하
  4. 테스트의 어려움:
    • 각 계층이 강하게 결합되어 있어 단위 테스트가 어려움
    • 문제점: 테스트 용이성 감소, 품질 보증의 어려움
  5. 횡단 관심사 처리의 어려움:
    • 로깅, 보안 등 여러 계층에 걸친 기능 구현이 복잡해짐
    • 문제점: 코드 중복, 관리의 어려움
  6. 확장성 제한:
    • 새로운 기능 추가 시 여러 계층을 수정해야 할 수 있음
    • 문제점: 애플리케이션 확장의 어려움
  7. 모듈화의 어려움:
    • 기능별 모듈화가 어려워 대규모 애플리케이션에서 복잡도 증가
    • 문제점: 코드 구조의 복잡성 증가, 유지보수 어려움
  8. 인터페이스와 구현의 분리 부족:
    • 각 계층이 직접적으로 연결되어 있어 인터페이스를 통한 추상화가 부족
    • 문제점: 유연성 감소, 시스템 구성 요소 교체의 어려움
  9. 비즈니스 규칙의 캡슐화 부족:
    • 비즈니스 규칙이 여러 계층에 걸쳐 구현될 수 있음
    • 문제점: 비즈니스 로직의 일관성 유지 어려움, 재사용성 저하
  10. 성능 최적화의 어려움:
    • 각 계층을 항상 거쳐야 하므로 불필요한 오버헤드 발생 가능
    • 문제점: 특정 상황에서의 성능 저하

이러한 문제점들을 해결하기 위해 도메인 주도 설계(DDD), 클린 아키텍처, 헥사고날 아키텍처 등의 대안적 아키텍처 패턴이 제시되었다. 이들은 비즈니스 로직의 중심성, 관심사의 명확한 분리, 유연성과 테스트 용이성 등을 강조하며, 대규모 복잡한 시스템에 더 적합할 수 있다.

클린 아키텍처

시스템을 여러 계층으로 나누어 관심사를 분리함.

  1. Entities: 핵심 비즈니스 규칙을 캡슐화. domain.model 패키지의 도메인 객체들
  2. Use Cases: 애플리케이션 특화된 비즈니스 규칙. (Use Case: 애플리케이션의 특정 기능 또는 시나리오를 나타냄) application.service 패키지의 ApplicationService 클래스들
    • 특정 비즈니스 시나리오를 구현 (예: 주문 생성, 결제 처리 등)
    • 도메인 객체와 서비스를 조정하여 작업 수행
  3. Interface Adapters: 외부 시스템(DB, 웹)과의 통신을 담당. presentation.controller, infrastructure.persistence 패키지.
    • Controllers: HTTP 요청을 처리하고 응답 생성
    • Repositories: 데이터 접근 로직 구현
    • DTOs: 데이터 전송 객체, 외부와의 데이터 교환에 사용
  4. Frameworks and Drivers: 데이터베이스, 웹 프레임워크 등 외부 도구. Spring Framework, 데이터베이스 등.

도메인 모델의 구성

  1. Presentation 사용자 인터페이스 또는 표현 : 사용자의 요청을 처리하고, 사용자에게 정보를 보여준다. 사용자는 소프트웨어를 사용하는 사람뿐만 아리나 외부 시스템일 수도 있다.
  2. Application 응용: 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
  3. Domain : 시스템이 제공할 도메인 규칙을 구현한다.
  4. Infrastructure : 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.

응용 계층은 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다. 내가 찾던 아키텍처의 모습이다.

패키지 구조

패키지 구조를 정의하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
com.example.shop
├── application
│   ├── dto
│   ├── mapper
│   └── service
├── domain
│   ├── model
│   ├── repository
│   └── service
├── infrastructure
│   ├── config
│   ├── persistence
│   └── external
└── presentation
    ├── controller
    └── advice

각 패키지의 역할:

  1. application: 응용 계층
    • dto: 데이터 전송 객체
    • mapper: DTO와 도메인 모델 간 변환
    • service: 응용 서비스 (UseCase)
  2. domain: 도메인 계층
    • model: 도메인 모델 (엔티티, 값 객체)
    • repository: 리포지토리 인터페이스
    • service: 도메인 서비스
  3. infrastructure: 인프라스트럭처 계층
    • config: 설정 클래스
    • persistence: 리포지토리 구현체
    • external: 외부 서비스 연동
  4. presentation: 표현 계층
    • controller: API 컨트롤러
    • advice: 전역 예외 처리

코드 예시

1. 도메인 계층 (Domain Layer)

이 구조에서 도메인 계층은 외부 의존성 없이 순수한 비즈니스 로직에 집중한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// domain/model/Order.java
public class Order {
    private Long id;
    private List<OrderItem> items;
    private OrderStatus status;

    public void addItem(Product product, int quantity) {
        items.add(new OrderItem(product, quantity));
    }

    public void place() {
        if (items.isEmpty()) {
            throw new EmptyOrderException("Order must have at least one item");
        }
        this.status = OrderStatus.PLACED;
    }
}

// domain/repository/OrderRepository.java
public interface OrderRepository {
    Order findById(Long id);
    void save(Order order);
}

// domain/service/DomainOrderService.java
public class DomainOrderService {
    public void validateOrder(Order order) {
        // 복잡한 도메인 규칙 검증
    }
}

2. 응용 계층 (Application Layer)

응용 계층은 use case를 구현하며, 도메인 객체를 조작하고 결과를 DTO로 변환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// application/dto/OrderRequestDto.java
public class OrderRequestDto {
    private Long orderId;
    private Map<Long, Integer> productQuantities;
    // getters, setters
}

// application/dto/OrderResponseDto.java
public class OrderResponseDto {
    private Long orderId;
    private OrderStatus status;
    // getters, setters
}

// application/mapper/OrderMapper.java
@Mapper
public interface OrderMapper {
    OrderResponseDto orderToOrderResponseDto(Order order);
}

// application/service/OrderApplicationService.java
@Service
public class OrderApplicationService {
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final DomainOrderService domainOrderService;
    private final OrderMapper orderMapper;

    @Transactional
    public OrderResponseDto placeOrder(OrderRequestDto requestDto) {
        Order order = orderRepository.findById(requestDto.getOrderId());
        
        for (Map.Entry<Long, Integer> entry : requestDto.getProductQuantities().entrySet()) {
            Product product = productRepository.findById(entry.getKey());
            order.addItem(product, entry.getValue());
        }

        domainOrderService.validateOrder(order);
        order.place();
        orderRepository.save(order);

        return orderMapper.orderToOrderResponseDto(order);
    }
}

3. 인프라스트럭처 계층 (Infrastructure Layer)

인프라스트럭처 계층은 실제 데이터 저장소와의 상호작용을 담당하며, 표현 계층은 외부 요청을 받아 응용 서비스로 전달하는 역할을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// infrastructure/persistence/JpaOrderRepository.java
@Repository
public class JpaOrderRepository implements OrderRepository {
    private final JpaOrderEntityRepository jpaOrderEntityRepository;
    private final OrderEntityMapper orderEntityMapper;

    @Override
    public Order findById(Long id) {
        OrderEntity entity = jpaOrderEntityRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
        return orderEntityMapper.orderEntityToOrder(entity);
    }

    @Override
    public void save(Order order) {
        OrderEntity entity = orderEntityMapper.orderToOrderEntity(order);
        jpaOrderEntityRepository.save(entity);
    }
}

4. 표현 계층 (Presentation Layer)

1
2
3
4
5
6
7
8
9
10
11
12
// presentation/controller/OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderApplicationService orderApplicationService;

    @PostMapping
    public ResponseEntity<OrderResponseDto> placeOrder(@RequestBody OrderRequestDto requestDto) {
        OrderResponseDto responseDto = orderApplicationService.placeOrder(requestDto);
        return ResponseEntity.ok(responseDto);
    }
}

헥사고날 아키텍처의 주요 이점:

  1. 관심사의 분리: 각 계층이 특정 책임을 가지고 있어 코드의 구조가 명확하다.
  2. 테스트 용이성: 각 계층을 독립적으로 테스트할 수 있다.
  3. 유지보수성: 특정 계층의 변경이 다른 계층에 미치는 영향을 최소화한다.
  4. 확장성: 새로운 기능이나 기술 변경 시 해당 계층만 수정하면 된다.

이러한 구조는 복잡한 비즈니스 로직을 가진 대규모 애플리케이션에서 특히 유용하다. 작은 프로젝트에서는 이 정도의 복잡한 구조가 과도할 수 있다.

reference

Spring Guide - Directory

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.