Software Architecture/DDD & Patterns

DDD Patterns - 06. 헥사고날 실천

나라마다 콘센트가 다르다 — 포트와 어댑터 실천

해외여행을 가면 콘센트 모양이 다르다. 한국은 220V 둥근 핀, 미국은 110V 평평한 핀, 유럽은 굵은 둥근 핀. 노트북(기기)은 어느 나라에서든 같은 일을 한다 — 화면을 띄우고 연산한다. 다만 꽂는 곳(콘센트)이 다르다. 그래서 변환 어댑터를 낀다. 기기 자체를 뜯어고치지 않고, 어댑터만 바꾼다.

헥사고날 아키텍처(hexagonal architecture)는 이 구조다. 도메인이 기기이고, 포트(port)가 콘센트 규격이고, 어댑터(adapter)가 변환 플러그다. 도메인은 어떤 외부 기술(DB, 웹 프레임워크, 메시지 큐)에 연결되든 자기 일을 한다. 외부가 바뀌면 어댑터만 교체한다. Alistair Cockburn이 2005년에 제안한 구조로, "포트와 어댑터(Ports and Adapters)"라는 이름으로도 불린다.

의존성이 안쪽으로만 향한다

헥사고날의 핵심 규칙은 단 하나다 — 의존성이 안쪽(도메인)으로만 향한다. 외부(어댑터)가 내부(도메인)를 알되, 내부는 외부를 모른다.

flowchart TB
    subgraph Outside["외부 세계"]
        Web["웹 어댑터<br/>(REST 컨트롤러)"]
        DB["DB 어댑터<br/>(JPA Repository)"]
        MQ["메시지 어댑터<br/>(Kafka 구독자)"]
    end
    subgraph Ports["포트 (인터페이스)"]
        InPort["OrderUseCase<br/>(인바운드 포트)"]
        OutPort["OrderRepository<br/>(아웃바운드 포트)"]
    end
    Domain["도메인 (Order 애그리거트)"]
    Web -->|"구현"| InPort
    MQ -->|"구현"| InPort
    Domain --> OutPort
    DB -->|"구현"| OutPort
    Domain -.->|"의존하지 않음"| Outside

웹 어댑터(REST 컨트롤러)는 인바운드 포트(OrderUseCase)를 호출한다. 도메인은 아웃바운드 포트(OrderRepository 인터페이스)를 정의하고, DB 어댑터가 그것을 구현한다. 도메인은 JPA·Spring·Kafka를 모른다 — 포트(인터페이스)만 안다.

// 도메인이 정의하는 포트 (아웃바운드) — 기술에 무관
public interface OrderRepository {
    void save(Order order);
    Order findById(OrderId id);
}

// 외부 어댑터가 포트를 구현 — JPA가 여기에 숨음
@Repository
public class JpaOrderRepository implements OrderRepository {
    private final OrderJpaRepository jpa;  // Spring Data JPA
    public void save(Order order) { jpa.save(order); }
}

도메인이 OrderRepository를 사용하지 JpaOrderRepository를 직접 쓰지 않는다. JPA를 MyBatis로 바꾸면 어댑터(JpaOrderRepositoryMyBatisOrderRepository)만 교체하면 된다 — 도메인은 손대지 않는다. 나라가 바뀌면 어댑터만 갈아끼우는 것과 같다.

구조가 아니라 원칙이다

헥사고날을 "폴더 구조"로 이해하면 실패한다. domain/, adapter/, port/ 폴더를 만들었다고 헥사고날이 아니다. 도메인이 외부에 의존하지 않는 것이 규칙이다 — domain/ 폴더에 JPA 애노테이션(@Entity, @Table)이 박혀 있으면, 폴더 구조와 무관하게 도메인이 JPA에 묶인 것이다. 어댑터를 바꿔도 도메인이 흔들린다.

진짜 헥사고날은 도메인이 순수하다 — 프레임워크 애노테이션 없이, 외부 기술 import 없이, 오직 도메인 규칙만으로 구성된다. 이 순수성을 지키는 것이 헥사고날의 본질이고, 이것이 "구조(폴더)"가 아니라 "원칙(의존성 방향)"이라는 말의 뜻이다.

포트는 필요한 만큼만

포트를 너무 많이 만들면 인터페이스가 널려 있어 복잡해진다. 모든 도메인 메서드에 인바운드 포트를, 모든 조회에 아웃바운드 포트를 두면 과잉이다. 필요한 경계(외부와 도메인이 만나는 곳)에만 포트를 둔다 — 웹 요청이 들어오는 곳, DB에서 읽고 쓰는 곳, 메시지를 받는 곳. 사서가 필요한 서가에만 사서를 두듯.

프레임워크 독립성의 가치

도메인이 프레임워크에 독립하면 테스트가 쉬워진다. Order 도메인을 테스트할 때 Spring 컨테이너를 띄울 필요 없이, 순수 자바 객체로 테스트한다 — DB 없이, 웹 없이. 빠르고 확실하다. 그리고 프레임워크 버전업(Spring 5 → 6, JPA 2 → 3)이 도메인에 영향을 주지 않는다 — 어댑터만 고치면 된다. 이 독립성이 헥사고날이 가져다주는 실천적 가치다.

설계 사례 — 포트/어댑터 전체 구현과 프레임워크 없는 도메인 테스트

주문 시스템에서 포트와 어댑터가 어떻게 연결되는지 전체를 본다. 도메인이 포트(인터페이스)만 알고, 외부 기술은 어댑터 뒤에 숨는다.

// === 도메인 계층 — 프레임워크 애노테이션 없음 ===

// 인바운드 포트 — 외부(웹, 메시지)가 도메인을 부르는 규격
public interface PlaceOrderUseCase {
    OrderId execute(PlaceOrderCommand command);
}

// 아웃바운드 포트 — 도메인이 외부(DB)를 부르는 규격
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
}

// 도메인 서비스 — 포트를 사용, 구현체는 모름
public class OrderService implements PlaceOrderUseCase {
    private final OrderRepository repo;   // 인터페이스 — JPA인지 모름
    public OrderService(OrderRepository repo) { this.repo = repo; }

    public OrderId execute(PlaceOrderCommand cmd) {
        Order order = Order.create(cmd.customerId(), cmd.lines());
        order.confirm();
        repo.save(order);   // "저장해" — 기술은 모름
        return order.getId();
    }
}

// === 어댑터 계층 — 외부 기술이 여기에 숨음 ===

// 웹 어댑터 — 인바운드 포트를 호출 (Spring MVC)
@RestController
public class OrderWebAdapter {
    private final PlaceOrderUseCase useCase;   // 인터페이스에 의존
    public OrderWebAdapter(PlaceOrderUseCase useCase) { this.useCase = useCase; }

    @PostMapping("/orders")
    ResponseEntity<?> place(@RequestBody OrderRequest req) {
        OrderId id = useCase.execute(req.toCommand());
        return ResponseEntity.ok(Map.of("orderId", id.value()));
    }
}

// DB 어댑터 — 아웃바운드 포트를 구현 (Spring Data JPA)
@Repository
public class JpaOrderAdapter implements OrderRepository {
    private final OrderJpaRepository jpa;
    public JpaOrderAdapter(OrderJpaRepository jpa) { this.jpa = jpa; }
    public void save(Order order) { jpa.save(OrderEntity.from(order)); }
    public Optional<Order> findById(OrderId id) {
        return jpa.findById(id.value()).map(OrderEntity::toDomain);
    }
}

이 구조의 가치는 테스트에서 드러난다. OrderService를 테스트할 때 Spring 컨테이너도, 데이터베이스도 필요 없다 — OrderRepository 인터페이스에 메모리 구현체를 주입하면 끝이다.

// 프레임워크 없는 도메인 테스트 — 밀리초 단위 실행
@Test
void 주문_확정_시_총액이_계산된다() {
    OrderRepository repo = new InMemoryOrderRepository();
    PlaceOrderUseCase useCase = new OrderService(repo);

    PlaceOrderCommand cmd = new PlaceOrderCommand(
        CustomerId.of("c1"),
        List.of(new OrderLine(ProductId.of("p1"), 2, Money.won(10000)))
    );
    OrderId id = useCase.execute(cmd);

    Order saved = repo.findById(id).orElseThrow();
    assertThat(saved.getTotal()).isEqualTo(Money.won(20000));
    assertThat(saved.getStatus()).isEqualTo(OrderStatus.PLACED);
}

이 테스트는 @SpringBootTest도, @DataJpaTest도 없이 순수 자바로 실행된다. 도메인 로직이 프레임워크에 묶이지 않았기 때문에 가능하다 — 콘센트(포트)를 통해 기기(도메인)를 분리해 둔 덕분이다.


참고