헥사고날 아키텍처란?

  • 헥사고날 아키텍처(Hexagonal Architecture)는 핵심 비즈니스 로직(도메인)을 데이터베이스나 UI 같은 외부 시스템으로부터 격리시켜 독립적으로 유지하고 테스트하기 쉽게 만드는 소프트웨어 설계 패턴
  • ‘포트와 어댑터 아키텍처’라고도 부릅니다.

주요 구성 요소

  • 도메인 (Domain): 외부 기술에 의존하지 않는 순수한 핵심 비즈니스 로직
  • 포트 (Port): 도메인과 외부를 연결하는 인터페이스 (입출력 규칙)
  • 어댑터 (Adapter): 포트를 구현해 실제 외부 시스템(웹, DB, API 등)과 통신하는 코드

장단점

  • 장점
    • [관심사 분리] 외부 기술(DB나 프레임워크)이 바뀌어도 비즈니스 로직은 안전
      • 확장 용이성. 외부 시스템이 추가되거나 수정됨에 따라 Adapter만 교체/구현하하면 된다.
    • 단위 테스트 작성이 매우 쉬워진다.
      • DB, 외부 서버를 직접 띄우지 않아도 포트(interface) 를 Mocking 하여 순수 비즈니스 로직에 대해 빠르게 단위 테스트를 할 수 있다.
    • 코드가 각자의 역할대로 깨끗하게 분리됩니다.
  • 단점
    • 포트와 어댑터를 만들면서 코드가 늘어나고 구조가 복잡해집니다.
    • 단순한 프로그램에는 오히려 과도한 설계가 될 수 있습니다.

구현

  • 프로젝트 구성
    com.example.orderservice
    ├── domain                        // [Core] 순수 도메인 모델 및 비즈니스 로직
    │   ├── model
    │   │   └── Order.java
    │
    ├── application                    // [Core] 유스케이스 및 포트 인터페이스
    │   ├── port
    │   │   ├── in                     // Inbound Port (유스케이스 인터페이스)
    │   │   │   ├── CreateOrderUseCase.java
    │   │   │   └── OrderQueryUseCase.java
    │   │   └── out                    // Outbound Port (영속성/외부 연동 인터페이스)
    │   │       ├── SaveOrderPort.java
    │   │       ├── LoadOrderPort.java
    │   │       └── CacheOrderPort.java
    │   └── service
    │       └── OrderService.java
    │
    └── adapter                        // [Infrastructure] 기술 구현체
      ├── in                         // Primary / Driving Adapters
      │   ├── web                    // REST Controller
      │   │   ├── OrderController.java
      │   │   └── dto
      │   └── listener               // Event / Message Listener
      │       ├── OrderEventListener.java
      │       └── dto
      └── out                        // Secondary / Driven Adapters
          ├── persistence            // MySQL (Spring Data JPA)
          │   ├── OrderPersistenceAdapter.java
          │   ├── OrderJpaRepository.java
          │   └── OrderEntity.java
          └── cache                  // Redis (Spring Data Redis)
              ├── OrderCacheAdapter.java
              └── OrderRedisRepository.java
    
  • Inbound Adapter
    @RestController
    @RequestMapping("/orders")
    @RequiredArgsConstructor
    public class OrderController {
          
        private final CreateOrderUseCase createOrderUseCase; // Inbound Port 의존
      
        @PostMapping
        public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
            Order order = createOrderUseCase.createOrder(request.toCommand());
            return ResponseEntity.ok(OrderResponse.from(order));
        }
    }
      
    // Event Listener Adapter (Kafka/RabbitMQ 등)
    @Component
    @RequiredArgsConstructor
    public class OrderEventListener {
      
        private final CreateOrderUseCase createOrderUseCase; // Inbound Port 의존
      
        @KafkaListener(topics = "order-create-topic", groupId = "order-group")
        public void handleOrderCreatedEvent(OrderCreateEventDto event) {
            createOrderUseCase.createOrder(event.toCommand());
        }
    }
    
  • Outbound Adapter
    // MySQL Persistence Adapter
    @Component
    @RequiredArgsConstructor
    public class OrderPersistenceAdapter implements SaveOrderPort, LoadOrderPort {
      
        private final OrderJpaRepository orderJpaRepository;
      
        @Override
        public Order saveOrder(Order order) {
            // Domain Model -> JPA Entity 변환 후 저장
            OrderEntity entity = OrderEntity.from(order);
            OrderEntity savedEntity = orderJpaRepository.save(entity);
            return savedEntity.toDomain();
        }
    }
      
    // Redis Cache Adapter
    @Component
    @RequiredArgsConstructor
    public class OrderCacheAdapter implements CacheOrderPort {
      
        private final RedisTemplate<String, Object> redisTemplate;
      
        @Override
        public void cacheOrder(Order order) {
            String key = "order:" + order.getId();
            redisTemplate.opsForValue().set(key, order, Duration.ofMinutes(30));
        }
      
        @Override
        public Optional<Order> getCachedOrder(Long orderId) {
            Object data = redisTemplate.opsForValue().get("order:" + orderId);
            return Optional.ofNullable((Order) data);
        }
    }
    
  • Port
    // [Inbound Port] 외부(Web, Message)에서 애플리케이션을 호출할 때
    public interface CreateOrderUseCase {
        Order createOrder(CreateOrderCommand command);
    }
      
    // [Outbound Port] 애플리케이션이 외부 저장소(MySQL, Redis 등)에 접근할 때
    public interface SaveOrderPort {
        Order saveOrder(Order order);
    }
      
    public interface CacheOrderPort {
        void cacheOrder(Order order);
        Optional<Order> getCachedOrder(Long orderId);
    }
    
  • Service
    @Service
    @RequiredArgsConstructor
    @Transactional
    public class OrderService implements CreateOrderUseCase { // Inbound Port 구현
      
        // Outbound Port 인터페이스들만 주입받음 (기술 구현체에 직접 의존 X)
        private final SaveOrderPort saveOrderPort;
        private final CacheOrderPort cacheOrderPort;
      
        @Override
        public Order createOrder(CreateOrderCommand command) {
            // 1. 도메인 객체 생성 및 비즈니스 로직 수행 (순수 도메인 규칙)
            Order order = Order.create(
                command.getCustomerId(), 
                command.getProducts(), 
                command.getTotalAmount()
            );
      
            // 2. Outbound Port를 통해 MySQL에 저장
            Order savedOrder = saveOrderPort.saveOrder(order);
      
            // 3. Outbound Port를 통해 Redis에 캐싱
            cacheOrderPort.cacheOrder(savedOrder);
      
            return savedOrder;
        }
    }
    

클린 아키텍처와 헥사고날 아키텍처의 차이점이 있다면?

  • 대칭적 구조(포트/어댑터) vs 계층적 구조(동심원)
    • 헥사고날 아키텍처:내부(Core)와 외부(Adapters)라는 2개의 커다란 구역으로 나뉩니다.
      • 진입점(REST API, MQ Listener 등)이든 출력점(MySQL, Redis 등)이든 상관없이, 도메인과 통신하려면 반드시 포트(Port)라는 통로를 거쳐야 합니다.
      • 외부 시스템들을 평면적으로(대칭적으로) 다루는 데 초점을 맞춥니다.
    • 클린 아키텍처:안쪽부터 Entities > Use Cases > Interface Adapters > Frameworks & Drivers라는 4개 이상의 세분화된 계층(Layer)을 명확하게 규정합니다.
      • 의존성 규칙(Dependency Rule)을 매우 엄격하게 강조합니다. (모든 의존성은 무조건 안쪽(고수준 도메인)을 향해서만 들어가야 함)
  • 용어와 세부 계층의 명확성
    • 헥사고날 아키텍처는 포트와 어댑터 개념을 강조하지만, 애플리케이션 내부(Core)를 얼마나 세부적으로 쪼갤지에 대해서는 구체적으로 규정하지 않고 자율에 맡깁니다.
    • 클린 아키텍처는 애플리케이션 내부를 다음과 같이 세분화하여 명확히 명칭을 부여합니다.
      • Entities: 전사적인 핵심 비즈니스 규칙 (가장 안쪽)
      • Use Cases: 애플리케이션에 특화된 비즈니스 규칙 및 흐름 제어
      • Controllers / Presenters / Gateways: 외부 요청을 Use Case에 맞게 변환하거나, Use Case의 결과를 UI/DB에 맞게 변환하는 인터페이스 어댑터
  • 실무적 결론:
    • 차이점보다 통일된 적용이 중요실무에서는 두 아키텍처를 엄격히 구분하여 따로 쓰기보다는, 두 개념을 섞어서 적용하는 경우가 대부분입니다.
    • 구조적 개념: 클린 아키텍처의 Entity - Use Case - Adapter 계층 구조를 가져옵니다.
    • 구현 기법: 헥사고날 아키텍처의 Inbound/Outbound Port와 Adapter 인터페이스 패턴을 활용해 패키지를 구성합니다.
    • 따라서 두 아키텍처의 용어가 조금 다를 뿐, “비즈니스 로직에 DB나 API 같은 외부 기술이 침범하지 못하도록 DIP(의존성 역전 원칙)를 적용한다”는 본질은 같습니다.