소프트웨어 아키텍처 시리즈 8화 – 헥사고날 아키텍처의 입출력 포트와 어댑터 설계 전략
헥사고날 아키텍처의 핵심은 ‘포트와 어댑터’입니다. 이 글에서는 입출력 포트를 어떻게 정의하고 어댑터를 어떻게 구현해야 아키텍처의 의도를 온전히 살릴 수 있는지를 실전 중심으로 설명합니다.
왜 ‘포트와 어댑터’인가?
헥사고날 아키텍처에서 가장 핵심이 되는 구조는 다음과 같습니다.
- Port(포트): 도메인 영역과 외부 환경 간의 경계 인터페이스
- Adapter(어댑터): 외부 시스템을 포트에 맞게 연결하는 구현체
이 구조를 통해 우리는 도메인은 외부 기술을 전혀 알지 않고도 동작 가능하게 만들 수 있습니다.
포트는 계약(Contract)이고, 어댑터는 그것의 구체적 실행입니다. 즉,
도메인은 “이런 기능이 필요해요”라고 말하고,
어댑터는 “그 기능, 제가 담당하겠습니다”라고 응답하는 구조입니다.
입출력 포트 구분의 개념적 정의
헥사고날 아키텍처에서는 포트를 크게 두 가지로 구분합니다.
1. Inbound Port (입력 포트)
- 도메인/애플리케이션을 외부에서 호출할 수 있도록 만든 인터페이스
- 유스케이스별로 정의됨
- 일반적으로 Service Interface나 UseCase 인터페이스로 표현
예시:
public interface PlaceOrderUseCase {
void placeOrder(OrderRequestDto request);
}
2. Outbound Port (출력 포트)
- 도메인 로직이 외부 시스템을 필요로 할 때 호출하는 인터페이스
- 기술 세부사항(DB, Kafka, 외부 API 등)은 모름
예시:
public interface PaymentGateway {
boolean requestPayment(PaymentInfo info);
}
이렇게 경계를 정의하면 도메인은 외부와 철저히 분리되고, 테스트 가능한 순수 로직으로 유지됩니다.
어댑터(Adapter) 설계 전략
어댑터는 포트를 구현합니다. 따라서 다음 두 가지 형태로 나뉩니다.
1. Inbound Adapter (입력 어댑터)
- HTTP Controller, GraphQL Resolver, CLI Command 등
- Inbound Port를 호출하는 역할
예:
@RestController
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase;
// POST /orders
public void placeOrder(@RequestBody OrderRequestDto dto) {
placeOrderUseCase.placeOrder(dto);
}
}
2. Outbound Adapter (출력 어댑터)
- JPA Repository, Kafka Producer, Redis Client 등
- Outbound Port의 구현체
예:
@Component
public class PaymentGatewayImpl implements PaymentGateway {
public boolean requestPayment(PaymentInfo info) {
// 외부 API 호출 로직
}
}
이렇게 하면 모든 외부 의존성은 어댑터에만 머무르고, 포트에는 도메인 로직과 연결된 인터페이스만 남게 됩니다.
구현 시 고려사항: ‘경계’의 모호함
실제 프로젝트에서는 다음과 같은 질문들이 자주 발생합니다.
- 유스케이스 단위로 포트를 나눠야 하나?
- DTO는 포트에 포함되나, 도메인 객체만 써야 하나?
- 어댑터가 너무 많아지면 관리가 복잡하지 않나?
정답은 없습니다. 하지만 명확한 기준을 두고 설계하면 해결 가능합니다.
실무 권장 기준
항목권장 기준
유스케이스별 포트 | 단일 책임 원칙에 따라 분리 |
DTO 사용 여부 | Application 계층까지만 허용, Domain에서는 Value Object 중심 |
어댑터 관리 | 기술 영역별 디렉토리 구분 (ex. adapter/web, adapter/jpa 등) |
중요한 건 ‘정해진 규칙’이 아니라, 팀이 공유할 수 있는 설계 기준을 명확히 세우고 일관성 있게 적용하는 것입니다.
포트와 어댑터가 주는 실전적 가치
1. 테스트 가능성(Testability)
- 모든 유스케이스는 Port 인터페이스로 테스트 가능
- 외부 어댑터를 Mock으로 대체하여 빠르고 안정적인 테스트 작성 가능
2. 기술 변경 유연성
- Kafka → RabbitMQ로 변경해도 Outbound Adapter만 바꾸면 됨
- REST API → gRPC 전환 시 Inbound Adapter만 수정
3. 도메인 중심 설계(Domain-centric)
- 비즈니스 로직은 언제나 포트 안쪽에서만 존재함
- 도메인은 외부 기술에 대한 단 한 줄의 코드도 없어야 한다
오해와 반론에 대한 솔직한 입장
오해: “포트랑 인터페이스는 그냥 분리된 서비스랑 다를 게 없잖아?”
반쯤 맞고 반쯤 틀립니다.
기존 계층 아키텍처에서도 인터페이스 분리는 가능합니다. 하지만 포트/어댑터 구조는 단순 인터페이스 분리를 넘어 의존성 방향성과 경계 유지라는 목적을 명확히 가집니다.
오해: “어댑터를 너무 세분화하면 복잡도만 늘지 않나?”
네, 무분별하게 나누면 ‘설계과잉’입니다.
하지만 팀 규모가 커지고 기술 스택이 늘어날수록, 어댑터 단위 분리는 유지보수에서 결정적 차이를 만듭니다.
실전 예제 구조 예시 (디렉토리 기준)
src
┣ domain
┃ ┗ order
┃ ┣ Order.java
┃ ┣ OrderValidator.java
┣ application
┃ ┗ order
┃ ┣ PlaceOrderUseCase.java // Inbound Port
┃ ┣ PlaceOrderService.java // UseCase 구현
┃ ┣ PaymentGateway.java // Outbound Port
┣ adapter
┃ ┣ web
┃ ┃ ┗ OrderController.java // Inbound Adapter
┃ ┣ payment
┃ ┃ ┗ PaymentGatewayImpl.java // Outbound Adapter
마무리하며 – 포트는 ‘관점’이고 어댑터는 ‘전략’이다
포트와 어댑터를 단지 구조적으로만 보면, 그냥 인터페이스와 구현체의 다른 말처럼 보일 수 있습니다.
하지만 그것은 어디에 책임을 둘 것인지, 도메인을 얼마나 보호할 것인지,
그리고 기술 변화에 얼마나 강인한 구조를 만들 것인지에 대한 깊은 전략입니다.
헥사고날 아키텍처는 단순한 기술 구조가 아니라, 개발자가 시스템을 바라보는 방식 자체를 바꾸는 도구입니다.