SW 아키텍처 비교: 3계층 vs 클린 vs 헥사고날

이프·2025년 4월 7일
0

tech-blog

목록 보기
1/4

포스트의 목적성

이 글을 왜 작성할까?

대부분 프로젝트를 진행하면서 3계층 아키텍쳐로 개발을 진행합니다. 저도 그렇구요.

그러다 문득, 아래와 같은 생각이 드네요.
생각1: 제대로 알고 사용하는 것일까?
생각2: 왜 사용해야 되는 것일까?
생각3: 내 프로젝트에서 제대로 사용했던 것일까?

이런 생각들이 정리가 되려면, SW 아키텍처에 대해 정리가 되어야 한다고 판단했습니다. 실제 기업에서도 Legacy부터 다양한 아키텍처가 섞여 있겠죠?

이번 정리를 통해 실무에 투입이 되더라도 어떤 시스템을 어떤 아키텍처로 전환해야 될 지 선택 할 수 있는 역량이 길러질 것이라고 생각합니다 :)

도입부

소프트웨어 개발에서 아키텍처를 선택하는 것은 집을 짓기 전 설계도를 그리는 것과 같습니다.

잘 설계된 아키텍처는 코드의 가독성, 유지보수성, 확장성을 높이며, 개발 팀이 장기적으로 효율적으로 협업할 수 있는 기반을 마련합니다. 반면, 부적절한 아키텍처 선택은 프로젝트가 성장함에 따라 기술 부채가 누적되어 결국 개발 속도 저하와 버그 증가로 이어질 수 있습니다.

오늘날 많은 개발자와 아키텍트가 고민하는 아키텍처 패턴 중에서도 가장 널리 사용되는 세 가지 - 3계층 아키텍처, 클린 아키텍처, 헥사고날 아키텍처에 대해 살펴보겠습니다. 이들은 각각 다른 시기에 등장했으며, 서로 다른 문제를 해결하기 위해 발전해왔습니다.

아키텍처의 역사적 배경

3계층 아키텍처는 클라이언트-서버 시대부터 시작된 전통적인 패턴으로, 관심사를 수평적 계층으로 분리하는 직관적인 접근법입니다. 프레젠테이션, 비즈니스 로직, 데이터 액세스라는 세 계층으로 애플리케이션을 구성함으로써 코드의 역할과 책임을 명확히 구분합니다. 이 패턴은 단순함과 이해하기 쉬운 구조로 인해 여전히 많은 기업 애플리케이션에서 기본 뼈대로 사용됩니다.

클린 아키텍처는 로버트 C. 마틴(Uncle Bob)이 제안한 아키텍처 패턴으로, 2012년에 그의 블로그에서 처음 소개되었습니다. 이는 이전의 헥사고날 아키텍처, 양파 아키텍처, DCI 아키텍처 등에서 영감을 받았습니다. 클린 아키텍처는 비즈니스 규칙을 외부 요소(프레임워크, 데이터베이스, UI 등)로부터 독립시켜 "의존성 규칙"을 통해 내부로 향하는 의존성만 허용하는 것이 핵심입니다.

헥사고날 아키텍처(포트 & 어댑터)는 2005년 알리스테어 콕번(Alistair Cockburn)이 제안한 패턴으로, 애플리케이션 코어와 외부 세계를 명확히 분리하는 것을 목표로 합니다. "육각형" 모델을 통해 애플리케이션의 내부와 외부를 구분하고, 모든 상호작용이 정의된 "포트"를 통해 이루어지도록 합니다. 이 아키텍처는 특히 도메인 주도 설계(DDD)와 함께 많이 사용되며, 마이크로서비스 아키텍처의 성장과 함께 더욱 주목받고 있습니다.
이 포스팅에서 다룰 내용

이 글에서는 세 가지 아키텍처 패턴에 대해 다음과 같은 내용을 다룰 예정입니다:

  • 각 아키텍처의 핵심 개념과 원칙
  • 패키지 구조 및 코드 구성 방식
  • 요청 처리 흐름과 데이터 흐름
  • 코드 예제를 통한 실제 구현 방법
  • 아키텍처 간 심층 비교 (의존성 방향, 테스트 용이성, 유연성 등)
  • 상황에 따른 적절한 아키텍처 선택 가이드
  • 실제 적용 사례와 전환 전략

현대 소프트웨어 개발에서는 어떤 아키텍처가 "최고"라고 단정할 수 없으며, 각 프로젝트의 요구사항, 팀의 경험, 비즈니스 목표에 따라 적절한 아키텍처를 선택하는 것이 중요합니다. 이 글은 추후 프로젝트에 가장 적합한 아키텍처를 결정하는 데 도움이 될 수 있으므로 작성합니다.

각 아키텍처의 핵심 개념과 실제 구현 방법을 깊이 이해함으로써, 단순히 트렌드를 따르는 것이 아닌 근거에 기반한 아키텍처 결정을 내릴 수 있게 될 것입니다. 이제 각 아키텍처에 대해 자세히 살펴보겠습니다.

3-Tier Layered Architecture


3계층 아키텍쳐가 무엇일까?

3계층 아키텍처(Three-tier Architecture)는 애플리케이션을 논리적으로 세 개의 독립적인 수평 계층으로 분리하는 클라이언트-서버 아키텍처 패턴입니다. 각 계층은 특정 책임을 갖고 있으며, 상위 계층은 바로 아래 계층에만 의존합니다.

프레젠테이션 계층(Presentation Layer)
사용자 인터페이스와 사용자 상호작용을 담당
웹 애플리케이션에서는 컨트롤러, 뷰 템플릿, REST API 엔드포인트 등이 포함
사용자 입력을 받고 결과를 표시하는 역할

비즈니스 로직 계층(Business Logic Layer)
애플리케이션의 핵심 기능과 비즈니스 규칙을 구현
서비스 클래스, 유틸리티, 도메인 객체 등을 포함
프레젠테이션 계층에서 받은 요청을 처리하고 데이터 액세스 계층과 상호작용

데이터 액세스 계층(Data Access Layer)
데이터 저장소와의 통신을 담당
리포지토리, DAO(Data Access Object), ORM 매핑 등을 포함
데이터베이스 쿼리 실행, 데이터 CRUD 작업 수행

장점
관심사 분리: 각 계층이 특정 책임만 가지므로 코드가 정돈되고 이해하기 쉬움
유지보수성: 특정 계층의 변경이 다른 계층에 미치는 영향이 제한적
재사용성: 비즈니스 로직 계층과 데이터 액세스 계층은 여러 UI에서 재사용 가능
보안성: 데이터베이스에 직접 접근하지 않고 계층을 통해 접근하므로 보안 향상
확장성: 각 계층을 독립적으로 확장할 수 있음 (예: 웹 서버만 스케일 아웃)

단점
의존성 방향: 비즈니스 로직이 데이터 액세스에 의존하여 기술 변경 시 영향받음
도메인 중심성 부족: 기술적 관점에서 구분되어 있어 비즈니스 도메인이 분산될 수 있음
강한 결합: 계층 간 직접적인 의존성으로 인해 단위 테스트가 어려울 수 있음
유연성 제한: 새로운 기술 도입이나 아키텍처 변경이 어려울 수 있음


패키지 구조

패키지 구조(1)

com.example.application/
├── controller/           # 프레젠테이션 계층
│   ├── UserController.java
│   ├── OrderController.java
│   └── dto/
│       ├── UserDto.java
│       └── OrderDto.java
├── service/              # 비즈니스 로직 계층
│   ├── UserService.java
│   ├── UserServiceImpl.java
│   ├── OrderService.java
│   └── OrderServiceImpl.java
├── repository/           # 데이터 액세스 계층
│   ├── UserRepository.java
│   └── OrderRepository.java
├── model/                # 엔티티 또는 도메인 객체
│   ├── User.java
│   └── Order.java
└── Application.java      # 애플리케이션 진입점

이 구조는 기술적 관심사에 따라 패키지를 구성하는 방식입니다. 대규모 애플리케이션에서는 기능별(도메인별) 구성으로 변형하기도 합니다.

패키지 구조(2)

com.example.application/
├── user/
│   ├── controller/
│   ├── service/
│   └── repository/
├── order/
│   ├── controller/
│   ├── service/
│   └── repository/
└── Application.java

모듈 계층 구조라고도 하며, 3계층 구조를 응용한 계층 구조이다.
많은 사람들이 왜 이렇게 구성할까?
이렇게 사용함에 있어 작성된 이유는 없지만, 생각해 볼 수 있는 3가지 관점이 있다.

  1. 한 눈에 구조가 식별된다.
  2. 모듈(도메인) 별 응집성이 높다.
  3. 객체지향 생활체조 원칙에 따르면, 너무 많은 책임 분리는 되려 악일 수 있다.

요청 처리

3계층 아키텍처에서 요청이 처리되는 흐름은 다음과 같습니다

클라이언트 요청: 사용자가 API 엔드포인트를 호출하거나 웹 페이지를 요청
프레젠테이션 계층: 컨트롤러가 요청을 받아 유효성 검사 후 비즈니스 계층에 전달
비즈니스 로직 계층: 서비스가 비즈니스 규칙을 적용하고 필요한 데이터를 데이터 액세스 계층에 요청
데이터 액세스 계층: 리포지토리가 데이터베이스에서 데이터를 조회하거나 저장
응답 반환: 결과가 비즈니스 계층과 프레젠테이션 계층을 거쳐 클라이언트에게 반환

이 과정은 항상 계층을 순차적으로 거치며, 상위 계층에서 하위 계층으로 의존성이 단방향으로 형성됩니다.


코드 예제

스프링 부트로 주문 도메인을 사용한 3계층 아키텍처의 간단한 구현 예시입니다

Model

@Entity
@Table(name = "orders")
public class Order {
    @Id
    private Long id;
    
    private String customerName;
    private BigDecimal totalAmount;
    private LocalDateTime orderDate;
    
    @Enumerated(EnumType.STRING)
    private OrderStatus status;
}

public enum OrderStatus {
    CREATED, PROCESSING, SHIPPED, DELIVERED, CANCELLED
}

Data Access

public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByCustomerName(String customerName);
    List<Order> findByStatusAndOrderDateBetween(OrderStatus status, LocalDateTime start, LocalDateTime end);
}

Business Logic

@Service
@Transactional
@RequiredArgument
public class OrderService {
    private final OrderRepository orderRepository;
    
    public Order createOrder(OrderDto orderDto) {
        Order order = new Order();
        order.setCustomerName(orderDto.getCustomerName());
        order.setTotalAmount(orderDto.getTotalAmount());
        order.setOrderDate(LocalDateTime.now());
        order.setStatus(OrderStatus.CREATED);
        
        return orderRepository.save(order);
    }
    
    public Order getOrderById(Long id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException("Order not found with id: " + id));
    }
    
    public List<Order> getOrdersByCustomer(String customerName) {
        return orderRepository.findByCustomerName(customerName);
    }
    
    public Order updateOrderStatus(Long id, OrderStatus status) {
        Order order = getOrderById(id);
        order.setStatus(status);
        return orderRepository.save(order);
    }
}

Presentation

@RestController
@RequestMapping("/api/orders")
@RequiredArgument
public class OrderController {
    private final OrderService orderService;
    
    @PostMapping
    public ResponseEntity<Order> createOrder(@RequestBody OrderDto orderDto) {
        Order createdOrder = orderService.createOrder(orderDto);
        return new ResponseEntity<>(createdOrder, HttpStatus.CREATED);
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<Order> getOrder(@PathVariable Long id) {
        Order order = orderService.getOrderById(id);
        return ResponseEntity.ok(order);
    }
    
    @GetMapping("/customer/{name}")
    public ResponseEntity<List<Order>> getOrdersByCustomer(@PathVariable String name) {
        List<Order> orders = orderService.getOrdersByCustomer(name);
        return ResponseEntity.ok(orders);
    }
    
    @PutMapping("/{id}/status")
    public ResponseEntity<Order> updateOrderStatus(
            @PathVariable Long id,
            @RequestParam OrderStatus status
    ) {
        Order updatedOrder = orderService.updateOrderStatus(id, status);
        return ResponseEntity.ok(updatedOrder);
    }
}

public class OrderDto {
    private String customerName;
    private BigDecimal totalAmount;
}

이 예제에서 볼 수 있듯이, 3계층 아키텍처에서는:

  1. 컨트롤러(OrderController)가 클라이언트 요청을 받아 서비스로 전달
  2. 서비스(OrderServiceImpl)가 비즈니스 로직을 처리하고 리포지토리를 사용
  3. 리포지토리(OrderRepository)가 데이터베이스와의 상호작용을 담당

각 계층은 명확한 책임을 가지며, 상위 계층은 하위 계층에 의존하는 단방향 의존성을 갖습니다. 이러한 구조는 간단하고 직관적이지만, 앞서 언급한 단점들도 내포하고 있습니다.


Hexagonal Architecture


그림 1: 아키텍쳐 흐름도 그림 2: 아키텍쳐 (https://netflixtechblog.com/ready-for-changes-with-hexagonal-architecture-b315ec967749)

헥사고날 아키텍처(Hexagonal Architecture)는 '포트와 어댑터(Ports and Adapters)' 패턴이라고도 불립니다.

핵심 개념
헥사고날 아키텍처는 애플리케이션을 육각형으로 표현하며, 이 육각형의 내부에는 비즈니스 로직이, 외부에는 다양한 어댑터가 위치합니다. 각 변은 외부 세계와의 통신을 위한 '포트'를 나타냅니다.

포트(Ports)
애플리케이션과 외부 세계 간의 통신 지점을 정의하는 인터페이스

어댑터(Adapters)
포트 인터페이스를 구현하여 외부 세계와 실제로 통신하는 구성 요소

애플리케이션 코어
순수한 비즈니스 로직과 도메인 모델을 포함하는 중심부

헥사고날 아키텍처는 두 종류의 포트와 어댑터를 구분합니다.

주도하는(Driving/Primary) 포트와 어댑터
외부에서 애플리케이션을 사용하는 방식
예: API 컨트롤러, CLI, UI 등

주도되는(Driven/Secondary) 포트와 어댑터
애플리케이션이 외부 시스템을 사용하는 방식
예: 데이터베이스, 외부 API, 메시징 시스템 등

장점

  • 도메인 중심성: 비즈니스 로직이 중심에 위치하고 기술적 세부사항과 분리됨
  • 테스트 용이성: 외부 의존성을 쉽게 모킹하여 단위 테스트 가능
  • 유연성: 다양한 인터페이스(UI, API, CLI 등)와 외부 시스템(DB, 외부 서비스 등) 지원
  • 유지보수성: 코드 변경 영향 범위가 제한적이고 명확함
  • 기술 독립성: 외부 기술 변경이 내부 로직에 영향을 주지 않음

단점

  • 초기 복잡성: 추가적인 추상화 계층으로 인한 초기 구현 복잡성
  • 오버엔지니어링 위험: 단순한 CRUD 애플리케이션에는 과도할 수 있음
  • 학습 곡선: 팀원들이 패턴을 이해하고 적용하는 데 시간 필요

패키지 구조

com.example.application/
├── domain/                   # 도메인 모델
│   ├── model/                
│   │   ├── Order.java
│   │   ├── OrderItem.java
│   │   └── OrderStatus.java
│   └── service/              # 도메인 서비스
│       └── OrderDomainService.java
├── application/              # 애플리케이션 코어
│   ├── port/
│   │   ├── input/            # 입력 포트 (주도하는 포트)
│   │   │   └── OrderService.java
│   │   └── output/           # 출력 포트 (주도되는 포트)
│   │       ├── OrderRepository.java
│   │       └── PaymentGateway.java
│   ├── service/              # 애플리케이션 서비스 (입력 포트 구현)
│   │   └── OrderServiceImpl.java
│   └── dto/                  # 커맨드, 쿼리 객체
│       ├── CreateOrderCommand.java
│       └── OrderSummary.java
├── adapter/                  # 어댑터 레이어
│   ├── input/                # 주도하는 어댑터
│   │   ├── web/              # 웹 어댑터
│   │   │   └── OrderController.java
│   │   ├── rest/             # REST API 어댑터
│   │   │   └── OrderRestController.java
│   │   └── messaging/        # 메시징 어댑터
│   │       └── OrderEventListener.java
│   └── output/               # 주도되는 어댑터
│       ├── persistence/      # 영속성 어댑터
│       │   ├── JpaOrderRepository.java
│       │   └── OrderEntity.java
│       └── payment/          # 결제 게이트웨이 어댑터
│           └── StripePaymentAdapter.java
└── config/                   # 설정
    └── ApplicationConfig.java

이 구조는 도메인 모델을 중심에 두고, 애플리케이션 서비스를 통해 유스케이스를 구현하며, 다양한 어댑터를 통해 외부 세계와 통신하는 방식을 보여줍니다.


요청 처리

헥사고날 아키텍처에서 요청이 처리되는 흐름은 다음과 같습니다.

  1. 외부 요청: 사용자나 외부 시스템이 요청 발생 (API 호출, 이벤트 등)
  2. 주도하는 어댑터:
    요청을 받아 애플리케이션에 적합한 형식으로 변환
    입력 포트를 통해 애플리케이션 서비스 호출
  3. 애플리케이션 서비스:
    유스케이스 로직 조정
    도메인 모델 객체와 협력
    필요시 출력 포트를 통해 외부 서비스 호출
  4. 도메인 모델:
    핵심 비즈니스 규칙 실행
    엔티티 상태 변경
  5. 주도되는 어댑터:
    애플리케이션이 출력 포트를 통해 어댑터 호출
    외부 시스템(DB, 결제 서비스 등)과 통신
  6. 응답 반환:
    처리 결과가 애플리케이션 서비스를 거쳐 주도하는 어댑터로 전달
    응답 형식으로 변환되어 클라이언트에게 반환

이 흐름에서 중요한 점은 모든 의존성이 도메인 방향으로 향하며, 애플리케이션 코어는 어댑터를 직접 알지 못하고 포트 인터페이스를 통해서만 통신한다는 것입니다.


코드 예제

Domain Model

public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderItem> items;
    private OrderStatus status;
    
    public Order(OrderId id, CustomerId customerId, List<OrderItem> items) {
        this.id = id;
        this.customerId = customerId;
        this.items = new ArrayList<>(items);
        this.status = OrderStatus.CREATED;
    }
    
    public Money calculateTotal() {
        return items.stream()
                .map(OrderItem::getSubtotal)
                .reduce(Money.ZERO, Money::add);
    }
    
    public void ship() {
        if (status != OrderStatus.PROCESSED) {
            throw new InvalidOrderStateException();
        }
        status = OrderStatus.SHIPPED;
    }
}

public class OrderId {
    private final String value;
    
    public OrderId(String value) {
        this.value = Objects.requireNonNull(value);
    }
    
    public static OrderId generate() {
        return new OrderId(UUID.randomUUID().toString());
    }
    
    public String getValue() {
        return value;
    }
}

Port(Interface)

public interface OrderService {
    OrderId createOrder(CreateOrderCommand command);
    Order getOrder(OrderId id);
    void shipOrder(OrderId id);
}

public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
}

public interface CustomerRepository {
    Optional<Customer> findById(CustomerId id);
}

public interface ShippingService {
    void scheduleDelivery(OrderId orderId, Address address);
}

Application Service

@Service
@Transactional
public class OrderServiceImpl implements OrderService {
    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;
    private final ShippingService shippingService;
    
    public OrderId createOrder(CreateOrderCommand command) {
        Customer customer = customerRepository.findById(command.getCustomerId())
                .orElseThrow(() -> new CustomerNotFoundException(command.getCustomerId()));
        
        List<OrderItem> items = command.getItems().stream()
                .map(i -> new OrderItem(i.getProductId(), i.getQuantity(), i.getPrice()))
                .collect(Collectors.toList());
        
        OrderId orderId = OrderId.generate();
        Order order = new Order(orderId, customer.getId(), items);
        
        orderRepository.save(order);
        
        return orderId;
    }
    
    public Order getOrder(OrderId id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
    
    public void shipOrder(OrderId id) {
        Order order = getOrder(id);
        order.ship(); 
        
        orderRepository.save(order);
        
        shippingService.scheduleDelivery(id, order.getShippingAddress());
    }
}

Primary Adapter

@RestController
@RequestMapping("/api/orders")
public class OrderRestAdapter {
    private final OrderService orderService;
    
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        CreateOrderCommand command = new CreateOrderCommand(
                new CustomerId(request.getCustomerId()),
                request.getItems().stream()
                        .map(this::toOrderItemDto)
                        .collect(Collectors.toList())
        );
        
        OrderId orderId = orderService.createOrder(command);
        
        return ResponseEntity.created(URI.create("/api/orders/" + orderId.getValue()))
                .body(new OrderResponse(orderId.getValue()));
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<OrderDetailsResponse> getOrder(@PathVariable String id) {
        Order order = orderService.getOrder(new OrderId(id));
        return ResponseEntity.ok(toOrderResponse(order));
    }
    
    @PostMapping("/{id}/ship")
    public ResponseEntity<Void> shipOrder(@PathVariable String id) {
        orderService.shipOrder(new OrderId(id));
        return ResponseEntity.noContent().build();
    }
}

Secondary Adapter

@Repository
public class JpaOrderRepositoryAdapter implements OrderRepository {
    private final SpringDataOrderRepository repository;
    private final OrderEntityMapper mapper;
    
    public void save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        repository.save(entity);
    }
    
    public Optional<Order> findById(OrderId id) {
        return repository.findById(id.getValue())
                .map(mapper::toDomain);
    }
}

@Service
public class ShippingServiceAdapter implements ShippingService {
    private final ShippingApiClient apiClient;
    
    public void scheduleDelivery(OrderId orderId, Address address) {
        DeliveryRequest request = new DeliveryRequest(
                orderId.getValue(),
                address.getStreet(),
                address.getCity(),
                address.getPostalCode(),
                address.getCountry()
        );
        
        apiClient.requestDelivery(request);
    }
}

이 코드 예제에서 볼 수 있듯이, 헥사고날 아키텍처는

  1. 도메인 모델이 외부 의존성 없이 순수하게 유지됨
  2. 애플리케이션 서비스가 입력 포트를 구현하고 유스케이스를 조정함
  3. 출력 포트가 외부 시스템과의 통신을 추상화함
  4. 다양한 어댑터가 포트를 통해 애플리케이션과 통신함

이러한 구조는 애플리케이션 코어를 외부 기술로부터 격리하고, 다양한 어댑터를 통해 외부 세계와 통신할 수 있는 유연성을 제공합니다.


Clean Architecture

그림 1: 아키텍쳐 흐름도 그림 2: 아키텍쳐 (http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)

클린 아키텍처가 무엇일까?

클린 아키텍쳐는 비즈니스 로직을 외부 요소로부터 독립시키는 것을 목표로 합니다. 이 아키텍처는 동심원 형태로 구성되며, 각 원은 소프트웨어의 서로 다른 영역을 나타냅니다.

프레임워크 & 드라이버 (UI/프레젠테이션 및 데이터베이스)
UI, 데이터베이스, 웹 프레임워크, 외부 인터페이스 등
시스템의 가장 바깥쪽 계층으로, 구체적인 구현체들이 위치

인터페이스 어댑터 (레포지토리 인터페이스/구현 및 컨트롤러/게이트웨이)
컨트롤러, 프레젠터, 게이트웨이 등
외부 세계의 데이터를 내부 계층에 적합한 형식으로 변환

애플리케이션 비즈니스 규칙 (유스케이스/인터랙터)
유스케이스, 인터랙터
애플리케이션의 특정 기능을 구현하는 계층

엔터프라이즈 비즈니스 규칙 (엔티티/도메인 모델)
엔티티, 값 객체 등 핵심 비즈니스 객체
시스템의 가장 안쪽에 위치하며, 어떤 외부 요소에도 의존하지 않음

클린 아키텍처의 핵심 원칙은 의존성 규칙이다

소스 코드 의존성은 항상 외부 계층에서 내부 계층으로만 향해야 함
내부 계층은 외부 계층을 알지 못함
내부 계층에 있는 코드는 외부 계층의 변경에 영향받지 않아야 함

이 의존성 규칙을 지키기 위해 의존성 역전 원칙(DIP)이 적용됩니다. 내부 계층이 외부 계층의 구현에 의존하지 않도록 인터페이스를 통해 추상화합니다.

장점
독립성: 프레임워크, 데이터베이스, UI 등 외부 요소와 독립적으로 비즈니스 로직 설계 가능
테스트 용이성: 비즈니스 로직이 외부 의존성 없이 테스트 가능
유연성: 외부 요소(DB, UI 등)를 쉽게 교체할 수 있음
유지보수성: 코드가 역할과 책임에 따라 명확히 구분되어 변경이 쉬움

단점
복잡성: 추가적인 계층과 인터페이스로 인해 간단한 문제를 해결하는 데 과도할 수 있음
학습 곡선: 개념 이해와 구현에 시간 투자 필요
보일러플레이트 코드: 많은 인터페이스와 계층 간 데이터 변환 로직 필요


패키지 구조

com.example.application/
├── domain/                   # 엔티티 계층
│   ├── model/                # 도메인 모델
│   │   ├── Order.java
│   │   ├── OrderItem.java
│   │   ├── OrderId.java
│   │   └── Money.java
│   └── exception/            # 도메인 예외
│       └── OrderException.java
├── application/              # 유스케이스 계층
│   ├── port/
│   │   ├── in/              # 입력 포트 (유스케이스 인터페이스)
│   │   │   ├── CreateOrderUseCase.java
│   │   │   └── GetOrderUseCase.java
│   │   └── out/             # 출력 포트 (레포지토리 인터페이스)
│   │       ├── OrderRepository.java
│   │       └── CustomerRepository.java
│   ├── service/              # 유스케이스 구현체
│   │   ├── CreateOrderInteractor.java
│   │   └── GetOrderInteractor.java
│   └── dto/                  # 애플리케이션 계층의 DTO
│       ├── CreateOrderCommand.java
│       └── OrderDetailsDTO.java
├── adapter/                  # 인터페이스 어댑터 계층
│   ├── in/                   # 입력 어댑터
│   │   ├── web/             # 웹 컨트롤러
│   │   │   ├── OrderController.java
│   │   │   └── dto/
│   │   │       ├── CreateOrderRequest.java
│   │   │       └── OrderResponse.java
│   │   └── rest/            # REST 컨트롤러
│   │       └── OrderRestController.java
│   └── out/                  # 출력 어댑터
│       ├── persistence/      # 영속성 어댑터
│       │   ├── JpaOrderRepository.java
│       │   └── OrderEntity.java
│       │   └── mapper/                
│       │       └── OrderEntityMapper.java
│       └── external/         # 외부 시스템 어댑터
│           └── PaymentServiceAdapter.java
└── config/                   # 설정
    └── BeanConfiguration.java

이 구조는 도메인과 애플리케이션 로직을 중심에 두고, 외부 인프라와의 통신을 어댑터를 통해 처리하는 방식입니다.

⚠️ 언뜻보면, 헥사고날과 차이가 없어보이지만 둘의 핵심적인 차이는 Usecase를 명시하는 것입니다. 실제로 클린 아키텍처는 헥사고날에서 비롯되었다고 봐도 무방합니다.

✚ 추가로 헥사고날과 클린 아키텍처의 패키지 구조는 통용되는 용어로 작성했지만, 다양한 용어로 설명되고 있습니다.

   [ Controller ]
        ↓           ← Primary Adapter
   [ UseCase (Port In) ]
        ↓           ← Interactor (Clean) or Service (Hexagonal)
   [ Port Out ]
        ↓
   [ Secondary Adapter (DB, API, MQ) ]

요청 처리

클린 아키텍처의 요청 흐름은 아래와 같습니다.

  1. 클라이언트 요청: 사용자 또는 외부 시스템에서 요청 발생
  2. 입력 어댑터: REST 컨트롤러나 웹 컨트롤러가 요청을 받아 처리
    • 요청 데이터를 유스케이스에 맞는 커맨드/쿼리 객체로 변환
  3. 유스케이스: 비즈니스 로직 실행
    • 필요한 엔티티 로드 및 작업 수행
    • 출력 포트를 통해 데이터 저장소와 통신
  4. 출력 어댑터: 유스케이스의 요청에 따라 외부 시스템과 통신
    • 데이터베이스 저장/조회
    • 외부 서비스 호출 등
  5. 응답 반환: 유스케이스 실행 결과가 입력 어댑터로 반환
    • 입력 어댑터는 이를 클라이언트에 적합한 형식으로 변환하여 응답

중요한 점은 모든 의존성이 외부에서 내부로만 향하며, 내부 계층(도메인, 유스케이스)은 외부 계층(어댑터, 프레임워크)에 의존하지 않는다는 것입니다.


코드 예제

주문 생성 기능을 구현한 클린 아키텍처 예제를 살펴보겠습니다.

Domain Model

public class Order {
    private OrderId id;
    private CustomerId customerId;
    private List<OrderItem> items;
    private OrderStatus status;
    
    public Order(CustomerId customerId, List<OrderItem> items) {
        this.id = new OrderId(UUID.randomUUID().toString());
        this.customerId = customerId;
        this.items = new ArrayList<>(items);
        this.status = OrderStatus.CREATED;
    }
    
    public Money calculateTotal() {
        return items.stream()
                .map(OrderItem::getSubtotal)
                .reduce(Money.ZERO, Money::add);
    }
}

public class OrderId {
    private final String value;
    
    public OrderId(String value) {
        this.value = Objects.requireNonNull(value);
    }
    
    public String getValue() {
        return value;
    }
}

public class Money {
    private final BigDecimal amount;
    
    public static final Money ZERO = new Money(BigDecimal.ZERO);
    
    public Money(BigDecimal amount) {
        this.amount = amount;
    }
    
    public Money add(Money other) {
        return new Money(this.amount.add(other.amount));
    }
}

입력 포트 UseCase

public interface CreateOrderUseCase {
    OrderId createOrder(CreateOrderCommand command);
}

public class CreateOrderCommand {
    private final String customerId;
    private final List<OrderItemDto> items;
    
    public CreateOrderCommand(String customerId, List<OrderItemDto> items) {
        this.customerId = customerId;
        this.items = items;
    }
}

public class CreateOrderService implements CreateOrderUseCase {
    private final OrderRepository orderRepository;
    
    
    public OrderId createOrder(CreateOrderCommand command) {
        CustomerId customerId = new CustomerId(command.getCustomerId());
        
        List<OrderItem> items = command.getItems().stream()
                .map(dto -> new OrderItem(
                        new ProductId(dto.getProductId()),
                        dto.getQuantity(),
                        new Money(dto.getPrice())
                ))
                .collect(Collectors.toList());
        
        Order order = new Order(customerId, items);
        orderRepository.save(order);
        
        return order.getId();
    }
}

출력 포트 Repository

public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
}

입력 어댑터 Controller

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final CreateOrderUseCase createOrderUseCase;
    
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
        CreateOrderCommand command = new CreateOrderCommand(
                request.getCustomerId(),
                request.getItems().stream()
                        .map(item -> new OrderItemDto(
                                item.getProductId(),
                                item.getQuantity(),
                                item.getPrice()
                        ))
                        .collect(Collectors.toList())
        );
        
        OrderId orderId = createOrderUseCase.createOrder(command);
        
        OrderResponse response = new OrderResponse(orderId.getValue());
        
        return ResponseEntity.created(URI.create("/api/orders/" + orderId.getValue()))
                .body(response);
    }
}

public class CreateOrderRequest {
    private String customerId;
    private List<OrderItemRequest> items;
}

public class OrderResponse {
    private String orderId;
}

출력 어댑터 Repository

@Repository
public class JpaOrderRepository implements OrderRepository {
    private final SpringDataOrderRepository orderRepository;
    private final OrderEntityMapper mapper;
    
    public JpaOrderRepository(SpringDataOrderRepository orderRepository, OrderEntityMapper mapper) {
        this.orderRepository = orderRepository;
        this.mapper = mapper;
    }
    
    public void save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        orderRepository.save(entity);
    }
    
    public Optional<Order> findById(OrderId id) {
        return orderRepository.findById(id.getValue())
                .map(mapper::toDomain);
    }
}

interface SpringDataOrderRepository extends JpaRepository<OrderEntity, String> {}

@Entity
@Table(name = "orders")
public class OrderEntity {
    @Id
    private String id;
    private String customerId;
    
    @Enumerated(EnumType.STRING)
    private OrderStatus status;
    
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItemEntity> items = new ArrayList<>();
}

이 코드 예제에서 볼 수 있듯이, 클린 아키텍처는

  • 도메인 모델이 외부 의존성 없이 순수하게 유지됨
  • 유스케이스가 애플리케이션의 비즈니스 로직을 구현
  • 입력/출력 포트가 내부 로직과 외부 세계 간의 경계를 정의
  • 어댑터가 포트를 구현하여 실제 기술 인프라와 통합

이러한 구조는 코드베이스가 더 복잡해지지만, 도메인 로직을 외부 의존성으로부터 보호하고 테스트와 유지보수를 용이하게 만듭니다.


아키텍처 비교

profile
if (이런 시나리오는 어떨까?) then(테스트로 검증하고 해결) else(다음 시나리오 고민)

0개의 댓글