이 글에서 얻는 것
- 전통적인 계층형 구조가 왜 시간이 갈수록 프레임워크 중심으로 기울어지는지 이해합니다.
- Port와 Adapter를 어디에 두고, 무엇을 의존해야 하는지 코드 수준으로 정리합니다.
- DDD의 Entity, Aggregate를 실제 애플리케이션 구조로 보호하는 방법을 연결합니다.
1) 계층형 아키텍처는 왜 자꾸 DB 중심이 될까
처음 프로젝트를 만들 때는 Controller -> Service -> Repository 구조가 가장 익숙합니다. 문제는 시간이 지나면 Service가 점점 JPA, 외부 API 응답 형식, 메시지 큐 포맷까지 모두 알아야 하는 위치가 된다는 점입니다.
그 결과,
- 비즈니스 로직이 프레임워크 어노테이션과 섞이고,
- 테스트가 스프링 컨텍스트 없이는 어려워지고,
- 저장 방식이 바뀔 때 도메인 코드까지 흔들립니다.
즉, 레이어는 있어도 의존성 방향이 잘못된 상태가 됩니다. 헥사고날 아키텍처는 이 문제를 “레이어를 더 늘려서"가 아니라 “도메인이 무엇을 알아야 하는지 다시 제한해서” 풀어냅니다.
2) 핵심 아이디어: 도메인 코어는 바깥 세상을 몰라야 한다
헥사고날 아키텍처의 핵심 문장은 아주 단순합니다.
애플리케이션 코어는 Web, DB, 외부 API를 몰라야 한다.
이를 위해 도메인 내부에서 인터페이스를 정의하고, 바깥쪽에서 그것을 구현합니다.
graph TD
subgraph Adapter [Adapters (Details)]
Web[Web Adapter<br/>(Controller)]
DB[Persistence Adapter<br/>(JPA)]
Ext[External System Adapter<br/>(Feign)]
end
subgraph Port [Ports (Interfaces)]
InPort[In Port<br/>(UseCase)]
OutPort[Out Port<br/>(Load/Save Port)]
end
subgraph Domain [Domain Core]
Service[Service]
Entity[Entity / Aggregates]
end
Web --> InPort
Service -.->|implements| InPort
Service --> OutPort
DB -.->|implements| OutPort
Ext -.->|implements| OutPort
style Domain fill:#e8f5e9,stroke:#2e7d32
style Port fill:#fff9c4,stroke:#fbc02d
style Adapter fill:#e1f5fe,stroke:#0277bd
이 그림에서 중요한 건 모양이 아니라 화살표 방향입니다. 모든 의존은 도메인 쪽을 향해야 합니다.
3) In Port와 Out Port를 실무 언어로 이해하기
헷갈리지 않게 아주 실무적으로 보면,
- In Port: “우리 시스템이 외부로부터 받는 유스케이스 계약”
- Out Port: “도메인이 외부 도움을 받을 때 기대하는 계약”
예를 들어 송금 기능이 있다면,
SendMoneyUseCase는 In Port입니다.LoadAccountPort,SaveTransferPort,SendNotificationPort는 Out Port가 될 수 있습니다.
이 구분이 좋은 이유는 도메인 서비스가 “무엇을 해야 하는지"만 알고 “어떻게 저장하고 어디로 보낼지"는 모르게 만들기 때문입니다.
Port 예시
public interface LoadAccountPort {
Account loadAccount(Long accountId);
}
Domain Service 예시
@RequiredArgsConstructor
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort;
// 핵심 로직은 인터페이스만 안다
}
Adapter 예시
@Component
@RequiredArgsConstructor
class AccountPersistenceAdapter implements LoadAccountPort {
private final SpringDataAccountRepository accountRepository;
@Override
public Account loadAccount(Long accountId) {
return accountMapper.mapToDomain(accountRepository.findById(accountId));
}
}
여기서 JPA, QueryDSL, Feign, Kafka는 모두 Adapter 쪽 디테일입니다. 도메인 로직이 여기를 import하기 시작하면 헥사고날 구조는 사실상 무너진 겁니다.
4) DDD와 헥사고날은 왜 같이 가야 할까
DDD 전술적 설계에서 Entity, VO, Aggregate를 아무리 잘 나눠도, 그 객체들이 결국 JPA Entity와 HTTP DTO 사이에 끼여 흔들리면 효과가 반감됩니다.
헥사고날 아키텍처는 DDD의 결과물을 보호하는 외곽 성벽 역할을 합니다.
- DDD가 무엇이 핵심 도메인인지를 정해주고,
- Aggregate 설계가 어디까지 같이 바뀌어야 하는지를 정해주며,
- 헥사고날 구조가 그 규칙이 외부 기술에 오염되지 않게 막아줍니다.
즉, 개념 설계와 구조 설계가 따로가 아니라 한 줄로 이어져 있습니다.
5) 흔한 오해: 포트가 많을수록 좋은 게 아니다
헥사고날을 처음 적용할 때 모든 CRUD마다 포트를 만들고, 클래스 수만 급격히 늘리는 경우가 있습니다. 이건 오히려 구조를 부담스럽게 만듭니다.
좋은 기준은 이렇습니다.
- 핵심 유스케이스는 In Port로 드러낸다.
- 외부 의존성이 바뀔 가능성이 있거나 테스트 격리가 필요한 지점만 Out Port로 추상화한다.
- 단순한 내부 헬퍼까지 전부 포트로 만들 필요는 없다.
즉, 헥사고날은 “인터페이스 남발"이 아니라 변화 방향이 다른 것들을 분리하는 기술입니다.
6) 도입 순서도 작게 가는 편이 좋다
기존 프로젝트에 한 번에 전부 적용하려 하면 반발이 큽니다. 보통은 아래 순서가 무난합니다.
- 새 기능 하나를 선택한다.
- UseCase 인터페이스를 먼저 만든다.
- 도메인 서비스가 JPA나 외부 API를 직접 모르도록 Out Port를 뺀다.
- 기존 Repository/Client를 Adapter로 감싼다.
- 테스트를 도메인 단위로 붙인다.
이렇게 작은 성공 경험을 만든 뒤 넓히는 편이 훨씬 현실적입니다.
7) 리뷰 때 바로 보는 체크포인트
- 도메인 패키지에서
org.springframework,jakarta.persistence를 직접 import하는가? - 애플리케이션 서비스가 Feign DTO, JPA Entity, API 응답 모델을 그대로 다루는가?
- UseCase 인터페이스 없이 Controller가 구현체를 직접 붙들고 있는가?
- 테스트가 전부 통합 테스트뿐이고, 순수 도메인 테스트가 없는가?
이 중 둘 이상 해당하면 구조가 이미 외부 기술 중심으로 기울었을 가능성이 큽니다.
요약
- Hexagonal Architecture의 핵심은 도메인을 중심에 두고 의존성 방향을 바로잡는 것입니다.
- In Port는 유스케이스 진입점, Out Port는 외부 의존의 계약입니다.
- 외부 기술은 모두 Adapter로 밀어내면 테스트성과 교체 가능성이 크게 좋아집니다.
- DDD의 Entity, Aggregate를 실제 코드에서 지키려면 구조적 보호막이 필요합니다.
💬 댓글