도메인을 중심에 두고 DB·웹·메시징을 바깥 어댑터로 밀어내는 구조. 포트와 어댑터 아키텍처라고도 한다.
레이어드의 한계에서 출발한다
- [레이어드] — Controller → Service → Repository → DB
- 의존이 아래로 흐른다
- 도메인(Service)이 DB(Repository 구현)에 의존한다
- DB 를 바꾸면 도메인이 흔들린다. 테스트에 DB 가 필요하다
의존성을 뒤집는다
[ 도메인 ] ← 아무것도 의존하지 않는다
↑ 구현
┌──────────┴──────────┐
웹 어댑터 DB 어댑터
(Controller) (JpaRepository 구현)
// 도메인이 '필요한 것' 을 인터페이스로 선언한다 (포트)
package com.example.order.domain;
public interface OrderRepository { // 도메인 소유
Order save(Order order);
}
// 바깥이 그 인터페이스를 구현한다 (어댑터)
package com.example.order.adapter.persistence;
@Repository
class JpaOrderRepository implements OrderRepository { ... }
인터페이스를 도메인 쪽에 두는 것이 핵심이다. 그러면 화살표가 안쪽을 향한다.
무엇을 얻나
- 도메인 테스트에 Spring 도 DB 도 필요 없다 (순수 JUnit)
- DB·메시징을 바꿔도 도메인 코드가 안 바뀐다
- 도메인이 프레임워크 애노테이션에서 자유롭다
무엇을 잃나
- 클래스와 매핑 코드가 는다 (도메인 Order ↔ 엔티티 OrderEntity)
- 작은 CRUD 서비스에는 과하다
- 팀이 규칙을 지켜야 유지된다 (강제할 수단이 약하다)
// ArchUnit 으로 규칙을 테스트로 강제할 수 있다
@Test void domain_should_not_depend_on_adapter() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..adapter..")
.check(new ClassFileImporter().importPackages("com.example"));
}
어디까지 나눌 것인가
- 작은 CRUD — 레이어드로 충분하다
- 도메인 규칙이 복잡 — 헥사고날이 값을 한다
- 팀이 여럿·배포 분리 — 모듈러 모놀리스 → 필요하면 그다음이 MSA
"처음부터 MSA"가 아니라 모듈러 모놀리스로 경계를 먼저 잡는 것이 현재의 일반적 권장이다. 경계가 틀렸을 때 되돌리는 비용이 훨씬 싸다.
면접 함정
- ❌ "헥사고날이 레이어드보다 항상 낫다" → 복잡도 비용이 있다. 도메인이 얇으면 과설계다.
- ❌ "인터페이스만 만들면 헥사고날" → 인터페이스가 도메인 쪽에 있어야 의존이 뒤집힌다.
매핑 비용을 어떻게 다루나
// 도메인 모델과 JPA 엔티티를 분리하면 매핑 코드가 는다
com.example.order.domain.Order // 순수 자바. 애노테이션 없음
com.example.order.adapter.OrderEntity // @Entity
// 어댑터가 변환을 책임진다
class JpaOrderRepository implements OrderRepository {
public Order save(Order order) {
return OrderEntity.from(order).toDomain();
}
}
-
분리하면 — 도메인이 JPA 에 안 묶인다 · 순수 단위 테스트가 가능하다
- 대신 클래스와 매핑 코드가 두 배가 된다
-
합치면 — 코드가 적다
- 대신 @Entity 애노테이션·지연 로딩 제약이 도메인에 스며든다
-
작은 팀·단순한 도메인이면 합치는 것도 합리적 선택이다
"항상 분리"가 아니라 도메인 복잡도에 비례해 정한다. 규칙이 거의 없는 CRUD에서 이중 모델은 순수한 비용이다.