들어가며
헥사고날 아키텍처(Hexagonal Architecture)는 Alistair Cockburn이 2005년에 제안한 아키텍처 패턴으로, 포트와 어댑터(Ports and Adapters)라고도 불립니다. 핵심 아이디어는 단순합니다: 비즈니스 로직(도메인)을 외부 기술(DB, HTTP, 메시지 큐 등)로부터 완전히 분리하는 것입니다. 이를 통해 도메인 로직은 순수하게 유지되고, 외부 기술이 변경되어도 도메인은 영향을 받지 않습니다.
이 글에서는 전통적인 레이어드 아키텍처와의 비교부터, Spring Boot에서의 실제 패키지 구조 설계, 테스트 전략까지 실전적으로 다루겠습니다.
왜 헥사고날 아키텍처인가?
전통적인 레이어드 아키텍처의 문제
// 전형적인 레이어드 아키텍처
Controller → Service → Repository → DB
// 문제 1: 도메인이 인프라에 의존
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository; // JPA 의존
@Autowired
private KafkaTemplate kafkaTemplate; // Kafka 의존
@Autowired
private RedisTemplate redisTemplate; // Redis 의존
public void placeOrder(OrderDto dto) {
// 비즈니스 로직이 기술 구현체와 섞임
OrderEntity entity = new OrderEntity(); // JPA Entity
entity.setStatus("PLACED");
orderRepository.save(entity);
kafkaTemplate.send("order-events", entity.getId());
redisTemplate.delete("order-cache:" + entity.getId());
}
}
이 코드의 문제점들:
- 테스트 어려움: 비즈니스 로직만 테스트하려 해도 JPA, Kafka, Redis 모킹 필요
- 기술 변경 영향: Kafka를 RabbitMQ로 변경하면 Service 코드 수정 필요
- 의존 방향 문제: 도메인(Service)이 인프라(Repository, Kafka)에 의존
- 도메인 오염: JPA Entity와 도메인 모델이 합쳐져 있음
헥사고날 아키텍처 구조
헥사고날 아키텍처는 세 가지 영역으로 구성됩니다:
┌──────────────────────────────────────────────────┐
│ 외부 세계 │
│ ┌──────────┐ ┌──────────┐ │
│ │ Web(REST)│ │ DB │ │
│ │ Adapter │ │ Adapter │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ ┌────▼─────┐ ┌──────────┐ ┌──────▼─────┐ │
│ │ Inbound │ │ Domain │ │ Outbound │ │
│ │ Port │──▶│ (Core) │◀──│ Port │ │
│ └──────────┘ └──────────┘ └────────────┘ │
│ │ │ │
│ ┌────┴─────┐ ┌────┴─────┐ │
│ │ gRPC │ │ Kafka │ │
│ │ Adapter │ │ Adapter │ │
│ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────┘
1. 도메인 (Core)
비즈니스 로직의 핵심입니다. 외부 기술에 대한 어떠한 의존도 없습니다. 순수 Java 코드만으로 구성됩니다.
2. 포트 (Port)
도메인과 외부 세계 사이의 인터페이스입니다.
- 인바운드 포트 (Inbound Port, Driving Port): 외부에서 도메인을 사용하기 위한 인터페이스 (Use Case)
- 아웃바운드 포트 (Outbound Port, Driven Port): 도메인이 외부 자원을 사용하기 위한 인터페이스
3. 어댑터 (Adapter)
포트의 구현체로, 실제 기술과의 연결을 담당합니다.
- 인바운드 어댑터: REST Controller, gRPC Handler, CLI, 메시지 리스너 등 API Gateway 패턴으로 인바운드 진입점 통합하기
- 아웃바운드 어댑터: JPA Repository, Kafka Producer, HTTP Client 등
Spring Boot에서 구현하기
패키지 구조
com.example.order/
├── domain/ # 도메인 (핵심)
│ ├── model/
│ │ ├── Order.java # 도메인 모델
│ │ ├── OrderLine.java
│ │ ├── OrderId.java # Value Object
│ │ ├── OrderStatus.java
│ │ └── Money.java
│ ├── service/
│ │ └── OrderDomainService.java # 도메인 서비스
│ └── exception/
│ ├── OrderNotFoundException.java
│ └── OrderCannotBeCancelledException.java
│
├── application/ # 응용 계층
│ ├── port/
│ │ ├── in/ # 인바운드 포트
│ │ │ ├── PlaceOrderUseCase.java
│ │ │ ├── CancelOrderUseCase.java
│ │ │ └── GetOrderQuery.java
│ │ └── out/ # 아웃바운드 포트
│ │ ├── LoadOrderPort.java
│ │ ├── SaveOrderPort.java
│ │ ├── OrderEventPort.java
│ │ └── PaymentPort.java
│ └── service/
│ ├── PlaceOrderService.java # 유스케이스 구현
│ ├── CancelOrderService.java
│ └── GetOrderService.java
│
└── adapter/ # 어댑터
├── in/ # 인바운드 어댑터
│ ├── web/
│ │ ├── OrderController.java
│ │ ├── OrderRequest.java
│ │ └── OrderResponse.java
│ └── event/
│ └── OrderEventListener.java
└── out/ # 아웃바운드 어댑터
├── persistence/
│ ├── OrderJpaEntity.java
│ ├── OrderJpaRepository.java
│ ├── OrderPersistenceAdapter.java
│ └── OrderMapper.java
├── messaging/
│ └── OrderKafkaAdapter.java
└── external/
└── PaymentApiAdapter.java
인바운드 포트 (Use Case)
// 인바운드 포트: 주문 생성 유스케이스
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand command);
}
// 커맨드 객체 (불변)
public record PlaceOrderCommand(
Long customerId,
List<OrderItemCommand> items,
String shippingAddress
) {
public PlaceOrderCommand {
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("주문 항목이 비어있습니다");
}
}
}
public record OrderItemCommand(
Long productId,
int quantity,
BigDecimal unitPrice
) {}
// 인바운드 포트: 주문 조회
public interface GetOrderQuery {
OrderDetailResponse getOrder(Long orderId);
List<OrderSummaryResponse> getOrdersByCustomer(Long customerId);
}
아웃바운드 포트
// 아웃바운드 포트: 주문 영속성
public interface LoadOrderPort {
Order loadById(OrderId id);
List<Order> loadByCustomerId(Long customerId);
}
public interface SaveOrderPort {
OrderId save(Order order);
}
// 아웃바운드 포트: 이벤트 발행 CQRS와 이벤트 소싱으로 읽기/쓰기 분리하기
public interface OrderEventPort {
void publishOrderPlaced(OrderPlacedEvent event);
void publishOrderCancelled(OrderCancelledEvent event);
}
// 아웃바운드 포트: 결제
public interface PaymentPort {
PaymentResult requestPayment(Long orderId, Money amount);
PaymentResult cancelPayment(String transactionId);
}
유스케이스 구현 (Application Service)
@Service
@RequiredArgsConstructor
@Transactional
public class PlaceOrderService implements PlaceOrderUseCase {
private final LoadOrderPort loadOrderPort; // 아웃바운드 포트
private final SaveOrderPort saveOrderPort; // 아웃바운드 포트
private final PaymentPort paymentPort; // 아웃바운드 포트
private final OrderEventPort eventPort; // 아웃바운드 포트
@Override
public OrderId placeOrder(PlaceOrderCommand command) {
// 1. 도메인 객체 생성 (순수 비즈니스 로직)
Order order = Order.create(
new CustomerId(command.customerId()),
command.items().stream()
.map(item -> OrderLine.of(
new ProductId(item.productId()),
Money.of(item.unitPrice()),
item.quantity()))
.toList(),
new Address(command.shippingAddress())
);
// 2. 결제 요청 (아웃바운드 포트를 통해)
PaymentResult paymentResult = paymentPort.requestPayment(
order.getId(), order.getTotalAmount());
if (!paymentResult.isSuccess()) {
throw new PaymentFailedException(paymentResult.errorMessage());
}
// 3. 주문 확정 (도메인 로직)
order.confirmPayment(paymentResult.transactionId());
// 4. 저장 (아웃바운드 포트를 통해)
OrderId savedId = saveOrderPort.save(order);
// 5. 이벤트 발행 (아웃바운드 포트를 통해)
eventPort.publishOrderPlaced(new OrderPlacedEvent(
savedId, order.getCustomerId(), order.getTotalAmount()));
return savedId;
}
}
인바운드 어댑터 (Web)
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase; // 인바운드 포트
private final CancelOrderUseCase cancelOrderUseCase;
private final GetOrderQuery getOrderQuery;
@PostMapping
public ResponseEntity<OrderIdResponse> placeOrder(
@Valid @RequestBody OrderRequest request) {
PlaceOrderCommand command = request.toCommand();
OrderId orderId = placeOrderUseCase.placeOrder(command);
return ResponseEntity.status(HttpStatus.CREATED)
.body(new OrderIdResponse(orderId.getValue()));
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDetailResponse> getOrder(
@PathVariable Long orderId) {
OrderDetailResponse response = getOrderQuery.getOrder(orderId);
return ResponseEntity.ok(response);
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> cancelOrder(
@PathVariable Long orderId,
@RequestBody CancelRequest request) {
cancelOrderUseCase.cancel(
new CancelOrderCommand(orderId, request.reason()));
return ResponseEntity.noContent().build();
}
}
아웃바운드 어댑터 (Persistence)
// JPA Entity (인프라 관심사)
@Entity
@Table(name = "orders")
@Getter
public class OrderJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "customer_id", nullable = false)
private Long customerId;
@Enumerated(EnumType.STRING)
private String status;
@Column(name = "total_amount")
private BigDecimal totalAmount;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private List<OrderLineJpaEntity> orderLines;
@Column(name = "ordered_at")
private LocalDateTime orderedAt;
}
// Spring Data JPA Repository
public interface OrderJpaRepository extends JpaRepository<OrderJpaEntity, Long> {
List<OrderJpaEntity> findByCustomerId(Long customerId);
}
// Persistence Adapter (아웃바운드 어댑터)
@Component
@RequiredArgsConstructor
public class OrderPersistenceAdapter
implements LoadOrderPort, SaveOrderPort {
private final OrderJpaRepository jpaRepository;
private final OrderMapper mapper;
@Override
public Order loadById(OrderId id) {
OrderJpaEntity entity = jpaRepository.findById(id.getValue())
.orElseThrow(() -> new OrderNotFoundException(id));
return mapper.toDomain(entity);
}
@Override
public List<Order> loadByCustomerId(Long customerId) {
return jpaRepository.findByCustomerId(customerId)
.stream()
.map(mapper::toDomain)
.toList();
}
@Override
public OrderId save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity saved = jpaRepository.save(entity);
return new OrderId(saved.getId());
}
}
// Mapper: 도메인 ↔ JPA Entity 변환
@Component
public class OrderMapper {
public Order toDomain(OrderJpaEntity entity) {
return Order.reconstitute(
new OrderId(entity.getId()),
new CustomerId(entity.getCustomerId()),
OrderStatus.valueOf(entity.getStatus()),
entity.getOrderLines().stream()
.map(this::toOrderLine)
.toList(),
Money.of(entity.getTotalAmount()),
entity.getOrderedAt()
);
}
public OrderJpaEntity toJpaEntity(Order order) {
OrderJpaEntity entity = new OrderJpaEntity();
entity.setCustomerId(order.getCustomerId().getValue());
entity.setStatus(order.getStatus().name());
entity.setTotalAmount(order.getTotalAmount().amount());
entity.setOrderedAt(order.getOrderedAt());
entity.setOrderLines(order.getOrderLines().stream()
.map(this::toJpaOrderLine)
.toList());
return entity;
}
}
아웃바운드 어댑터 (Kafka)
@Component
@RequiredArgsConstructor
public class OrderKafkaAdapter implements OrderEventPort {
private final KafkaTemplate<String, Object> kafkaTemplate;
private final ObjectMapper objectMapper;
@Override
public void publishOrderPlaced(OrderPlacedEvent event) {
kafkaTemplate.send("order.placed",
event.orderId().toString(),
toPayload(event));
}
@Override
public void publishOrderCancelled(OrderCancelledEvent event) {
kafkaTemplate.send("order.cancelled",
event.orderId().toString(),
toPayload(event));
}
private String toPayload(Object event) {
try {
return objectMapper.writeValueAsString(event);
} catch (JsonProcessingException e) {
throw new EventSerializationException(e);
}
}
}
레이어드 vs 헥사고날 비교
| 관점 | 레이어드 | 헥사고날 |
|---|---|---|
| 의존 방향 | 상위 → 하위 (단방향) | 외부 → 도메인 (안쪽 방향) |
| 도메인 순수성 | 인프라 의존 허용 | 도메인은 순수 Java |
| 테스트 | DB 필요한 경우 많음 | 도메인 단위 테스트 용이 |
| 기술 교체 | Service 수정 필요 | 어댑터만 교체 |
| 코드량 | 상대적으로 적음 | 인터페이스/매퍼로 증가 |
| 학습 곡선 | 낮음 | 높음 |
| 적합한 프로젝트 | 단순 CRUD | 복잡한 비즈니스 로직 |
테스트 전략
헥사고날 아키텍처의 가장 큰 장점 중 하나는 테스트 용이성입니다. Testcontainers로 어댑터 통합 테스트하기
도메인 단위 테스트 (외부 의존 없음)
class OrderTest {
@Test
void 주문_생성_성공() {
// Given
List<OrderLine> items = List.of(
OrderLine.of(new ProductId(1L), Money.of(10000), 2),
OrderLine.of(new ProductId(2L), Money.of(5000), 1)
);
// When
Order order = Order.create(
new CustomerId(100L), items, new Address("서울시"));
// Then
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
assertThat(order.getTotalAmount()).isEqualTo(Money.of(25000));
assertThat(order.getOrderLines()).hasSize(2);
}
@Test
void 배송완료_주문은_취소_불가() {
// Given
Order order = createDeliveredOrder();
// When & Then
assertThatThrownBy(() -> order.cancel("변심"))
.isInstanceOf(OrderCannotBeCancelledException.class);
}
}
유스케이스 테스트 (포트 모킹)
@ExtendWith(MockitoExtension.class)
class PlaceOrderServiceTest {
@Mock private SaveOrderPort saveOrderPort;
@Mock private PaymentPort paymentPort;
@Mock private OrderEventPort eventPort;
@InjectMocks
private PlaceOrderService placeOrderService;
@Test
void 주문_생성_및_결제_성공() {
// Given
PlaceOrderCommand command = new PlaceOrderCommand(
1L,
List.of(new OrderItemCommand(10L, 2, BigDecimal.valueOf(10000))),
"서울시"
);
when(paymentPort.requestPayment(any(), any()))
.thenReturn(PaymentResult.success("txn-123"));
when(saveOrderPort.save(any()))
.thenReturn(new OrderId(1L));
// When
OrderId result = placeOrderService.placeOrder(command);
// Then
assertThat(result.getValue()).isEqualTo(1L);
verify(paymentPort).requestPayment(any(), any());
verify(saveOrderPort).save(any());
verify(eventPort).publishOrderPlaced(any());
}
@Test
void 결제_실패시_예외_발생() {
// Given
PlaceOrderCommand command = new PlaceOrderCommand(
1L,
List.of(new OrderItemCommand(10L, 2, BigDecimal.valueOf(10000))),
"서울시"
);
when(paymentPort.requestPayment(any(), any()))
.thenReturn(PaymentResult.fail("잔액 부족"));
// When & Then
assertThatThrownBy(() -> placeOrderService.placeOrder(command))
.isInstanceOf(PaymentFailedException.class);
verify(saveOrderPort, never()).save(any());
}
}
어댑터 통합 테스트
@DataJpaTest
@Import(OrderPersistenceAdapter.class)
class OrderPersistenceAdapterTest {
@Autowired
private OrderPersistenceAdapter adapter;
@Test
void 주문_저장_및_조회() {
// Given
Order order = Order.create(
new CustomerId(1L),
List.of(OrderLine.of(
new ProductId(1L), Money.of(10000), 2)),
new Address("서울시"));
// When
OrderId savedId = adapter.save(order);
Order loaded = adapter.loadById(savedId);
// Then
assertThat(loaded.getCustomerId()).isEqualTo(new CustomerId(1L));
assertThat(loaded.getTotalAmount()).isEqualTo(Money.of(20000));
}
}
도메인 보호 원칙
헥사고날 아키텍처에서 가장 중요한 원칙은 의존성 규칙(Dependency Rule)입니다.
- 도메인은 어떤 외부 계층도 알지 못합니다. Spring, JPA, Kafka 등의 import가 없어야 합니다. MSA 환경에서 헥사고날 아키텍처 활용법
- 의존 방향은 항상 안쪽(도메인)을 향합니다. 어댑터 → 포트 → 도메인 순서입니다.
- 도메인 모델과 인프라 모델을 분리합니다. JPA Entity와 도메인 객체는 별개입니다.
- 포트(인터페이스)를 통해서만 외부와 소통합니다. 구현체 직접 참조 금지입니다.
ArchUnit으로 의존성 규칙 검증
@AnalyzeClasses(packages = "com.example.order")
class ArchitectureTest {
@ArchTest
static final ArchRule domain_should_not_depend_on_adapters =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..adapter..");
@ArchTest
static final ArchRule domain_should_not_depend_on_spring =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("org.springframework..");
@ArchTest
static final ArchRule domain_should_not_use_jpa =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("jakarta.persistence..");
@ArchTest
static final ArchRule adapters_should_not_depend_on_each_other =
noClasses()
.that().resideInAPackage("..adapter.in..")
.should().dependOnClassesThat()
.resideInAPackage("..adapter.out..");
}
실전 도입 팁
- 점진적으로 도입하세요. 모든 모듈에 적용할 필요 없습니다. 비즈니스 로직이 복잡한 핵심 도메인부터 적용하세요. DDD 실전 가이드에서 전략적/전술적 설계 배우기
- CRUD 위주의 모듈은 레이어드로 충분합니다. 모든 곳에 헥사고날을 적용하면 보일러플레이트 코드만 늘어납니다.
- Mapper 코드가 부담스러우면 MapStruct를 사용하세요. 도메인 ↔ JPA Entity 변환 코드를 자동 생성합니다.
- 모듈 경계를 gradle multi-module로 강제하세요. 패키지 수준의 규칙은 쉽게 깨질 수 있습니다.
- 팀 합의가 중요합니다. 아키텍처는 팀 전체가 이해하고 따라야 의미가 있습니다.
마치며
헥사고날 아키텍처는 "도메인을 보호하라"는 단순한 원칙에서 출발합니다. 포트와 어댑터를 통해 외부 기술을 교체 가능하게 만들고, 도메인 로직을 순수하게 유지하면 테스트하기 쉽고, 변경에 강한 시스템을 만들 수 있습니다.
다만, 모든 프로젝트에 적합한 것은 아닙니다. 비즈니스 로직이 단순한 CRUD 위주의 시스템에서는 오히려 복잡성만 증가시킵니다. 핵심은 도메인의 복잡도에 맞는 아키텍처를 선택하는 것입니다. 복잡한 비즈니스 로직이 존재하는 모듈에 헥사고날 아키텍처를 적용하고, 그 외에는 레이어드 아키텍처를 사용하는 혼합 전략이 실무에서 가장 현실적인 접근법입니다.
'Architecture' 카테고리의 다른 글
| GraphQL 실전 가이드 - REST를 넘어서는 API 설계 (0) | 2026.04.13 |
|---|---|
| API Gateway 패턴 심화 - Spring Cloud Gateway와 Rate Limiting 구현 (0) | 2026.04.08 |
| DDD(Domain-Driven Design) 실전 가이드 - 전략적 설계부터 전술적 구현까지 (2) | 2026.04.07 |
| MSA 관측 가능성(Observability) 완벽 가이드 (0) | 2026.03.31 |
| MSA 관측 가능성(Observability) 완벽 가이드 (0) | 2026.03.30 |