DDD 공부하면서 개인적으로 헷갈렸던 부분을 정리해둠. (개인적인 글)

DDD는 계속해서 이 질문을 던진다.
“이 개념은 무엇을 책임지는가?”
주문은 주문 상태와 금액을 책임진다
결제는 결제 처리와 상태를 책임진다
배송은 배송 흐름을 책임진다
👉 책임이 곧 도메인의 경계를 만든다
책임이 정해지면, 자연스럽게 경계가 생긴다.
주문은 결제 내부 로직을 몰라야 한다
유저 도메인은 상품 도메인의 구현을 몰라야 한다
도메인은 DB나 프레임워크를 몰라야 한다
👉 각 도메인은 자신의 책임 안에만 머물러야 한다
그 경계를 실제 코드에서 지키기 위한 방법이 캡슐화다.
“알 필요가 없는 것은 알지 못하게 한다”
Facade → 다른 도메인의 내부를 숨긴다
DTO → 엔티티의 내부 구조를 숨긴다
Aggregate → 내부 객체 직접 접근을 막는다
Port → 외부 기술(DB, API)을 숨긴다
👉 캡슐화는 경계를 깨지지 않게 유지하는 장치다
이 세 가지를 하나로 압축하면 결국 이 문장으로 정리된다.
“각자 책임을 나누고, 그 경계를 지키며, 내부는 숨긴다.”
책임이 없으면 → 구조가 흐려지고
경계가 없으면 → 결합도가 높아지고
캡슐화가 없으면 → 변경에 취약해진다
DDD의 모든 패턴은 이 문제를 해결하기 위해 존재한다.
애그리거트
바운디드 컨텍스트
DTO
Facade
헥사고날 아키텍처
이름은 다르지만, 결국 같은 방향을 가리킨다.
복잡한 비즈니스를 다루기 위해, 시스템을 ‘이해 가능한 단위’로 나누고, 서로 간섭하지 않도록 만든다.
DDD를 처음 배울때 많이 혼동하는 부분. 결론부터 말하면 다른 개념이지만, 완전 무관하지는 않다. 각각이 무엇을 바라보는지를 비교해보자
| 관점 | 단위 기준 | 예시 |
|---|---|---|
| 테이블 단위 | 데이터를 어떻게 저장할 것인가 | users,orders,order_items |
| 기능 단위 | 어떤 동작을 제공할까 | 회원가입, 주문하기, 결제처리 |
| 도메인(DDD) | 어떤 비즈니스 문제를 해결할까 | 유저, 주문, 결제 |
핵심 차이를 예시로 보면 - 이커머스를 예로 들어보자.
users / orders / order_items / products / payments / carts / cart_items
→ 테이블이 7개니까 Entity도 7개, Service도 7개
회원가입 / 로그인 / 상품조회 / 장바구니담기 / 주문하기 / 결제하기
→ 기능이 6개니까 Service 메서드도 6개
"이 비즈니스에서 독립적으로 존재하는 개념은 무엇인가?"
→ 유저 / 상품 / 주문 / 결제 / 장바구니 / 가게
표면적으로 비슷해보이지만, 묻는 질문이 다르다.
이 차이를 이해하는 것이 중요한 이유 :
테이블 단위나 기능 단위로 생각하면, 자연스럽게 아래와 같은 구조가 나온다.
// 테이블 단위로 생각한 결과
public class OrderService {
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository; // OrderItem도 테이블이니까
private final UserRepository userRepository; // 유저 정보도 필요하니까
private final ProductRepository productRepository; // 상품 정보도 필요하니까
private final PaymentRepository paymentRepository; // 결제도 여기서 처리하니까
}
OrderService 하나가 4~5개의 도메인의 Repository를 달고있다. 도메인 경계가 없다.
하지만 DDD 방식으로 생각하면 아래처럼 된다.
// 도메인 단위로 생각한 결과
public class OrderService {
private final OrderRepository orderRepository; // 주문 도메인만 책임진다
private final UserFacade userFacade; // 유저 도메인은 Facade로 소통
private final ProductFacade productFacade; // 상품 도메인도 Facade로 소통
// PaymentRepository는 없다 → 결제는 결제 도메인이 책임진다
}
DDD는 "이 개념이 비즈니스에서 어떤 책임을 지는가"를 먼저 묻는다. 테이블과 기능은 그 이후에 따라오는 결과물이다.
완전히 다른 개념이다. 역할 자체가 다르다
Facade > "누구에게 물어볼지"를 결정하는 창구 (클래스)
DTO > "무엇을 전달할지"를 담는 운반 상자 (데이터 객체)
코드로 보면 명확하다.
// DTO — 데이터를 담는 그릇
// Entity(User)를 그대로 넘기지 않고, 필요한 데이터만 추려서 전달한다
public class UserDto {
private Long id;
private String name;
private String email;
// 비밀번호, 권한 같은 민감한 정보는 포함하지 않는다
}
// Facade — 다른 도메인에 접근하는 창구 역할
// OrderService가 UserRepository를 직접 주입받지 않도록 중간에서 막아준다
@Component
public class UserFacade {
private final UserRepository userRepository;
// OrderService는 이 메서드만 호출한다
// 내부에서 UserRepository를 어떻게 쓰는지 알 필요 없다
public UserDto getUserById(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("유저를 찾을 수 없습니다."));
// Entity → DTO 변환 후 반환 (Entity를 외부에 직접 노출하지 않는다)
return new UserDto(user.getId(), user.getName(), user.getEmail());
}
}
// OrderService — Facade를 통해 다른 도메인과 소통한다
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final UserFacade userFacade; // UserRepository 직접 주입 X
private final ProductFacade productFacade; // ProductRepository 직접 주입 X
public void placeOrder(Long userId, Long productId, int quantity) {
// Facade에 요청 → DTO로 응답을 받는다
UserDto user = userFacade.getUserById(userId);
ProductDto product = productFacade.getProductById(productId);
// 받아온 DTO로 주문 생성
Order order = Order.create(user.getId(), product.getId(), quantity, product.getPrice());
orderRepository.save(order);
}
}
둘은 서로 다른 개념이지만, 함께 쓰이는 경우가 많다.
OrderService
→ UserFacade 호출 (Facade가 창구 역할)
→ 내부에서 UserRepository 조회
→ User Entity를 UserDto로 변환
→ UserDto 반환 (DTO가 데이터 운반)
Facade가 없으면 도메인 경계가 무너지고, DTO가 없으면 내부 엔티티가 그대로 외부에 노출된다. 둘 다 "경계를 지키는" 도구로서의 기능을 지니고 있지만, 지키는 대상이 다른 것이다.
맞지만, 조금 더 구분해서 생각하면 명확해진다.
[주문 도메인] ──── Facade ──── [유저 도메인]
(도메인 간 다리)
[도메인 내부] ──── DTO ──── [외부 세계]
(내/외부 간 다리)
Facade는 도메인과 도메인 사이의 다리다.
OrderService가 UserRepository를 직접 알지 못하도록 가운데서 막아준다.
DTO는 도메인 내부(Entity)와 외부(Controller, 다른 서비스) 사이의 다리다.
Entity를 그대로 노출하지 않고, 필요한 데이터만 골라서 건네준다.
전체 흐름으로 보면 :
Controller
↓ (RequestDto 수신)
OrderService
↓ (UserFacade 호출 — 도메인 간 다리)
UserFacade
↓ (UserRepository 조회)
User Entity
↓ (UserDto 변환 — 내/외부 경계 다리)
UserDto → OrderService로 반환
↓
Order 생성 → 저장
↓ (ResponseDto 변환)
Controller → 응답
DDD에서 제일 어려운 부분이다. 정답이 없는 영역이기 때문이다.
도메인을 나눌 때 실제로 이런 질문들을 던진다.
'주문'은 '유저' 없이도 개념으로 존재할 수 있는가? → 있다.
'주문 아이템'은 '주문' 없이 독립적으로 존재하는가? → 없다.
독립적으로 존재할 수 있으면 도메인, 없으면 하위 개념(애그리거트 내부)이다.
실제 회사를 생각해보면 감이 온다.
결제팀 → 결제, 정산
물류팀 → 배송, 재고
상품팀 → 상품, 카테고리
회원팀 → 유저, 인증
팀이 달라질 수 있다면, 도메인이 다른 것이다.
상품 정보가 바뀌는 이유와 주문 정보가 바뀌는 이유가 다른가? → 다르다.
상품 가격 정책이 바뀐다고 주문 로직이 바뀌진 않는다.
변경 이유가 다르면 다른 도메인으로 나누는 것이 맞다.
예를 들어 '리뷰'를 생각해보자.
리뷰는 상품 도메인인가? → 상품에 달리니까 맞는 것 같다.
리뷰는 유저 도메인인가? → 유저가 쓰니까 맞는 것 같다.
리뷰는 독립 도메인인가? → 리뷰만의 규칙(신고, 추천)이 있으니 맞는 것 같다.
셋 다 맞는 말이다. 이럴 때는 어느 쪽의 비즈니스 규칙이 더 복잡한가를 본다. 리뷰 신고, 베스트 리뷰 선정, 리뷰 포인트 적립 같은 규칙이 있다면 독립 도메인으로 분리하는 것이 낫다.
도메인 경계는 발견하는 것이지, 처음부터 설계하는 것이 아니다.
처음부터 완벽하게 나누려 하면 오히려 막힌다. 초반에는 대략적으로 나누고, 비즈니스가 복잡해지면서 경계가 자연스럽게 드러난다. 그때 리팩토링하는 것이 현실적인 방법이다.
그래서 DDD에서 이벤트 스토밍(Event Storming) 같은 워크숍을 하는 이유도 여기에 있다. 개발자 혼자 결정하는 게 아니라, 기획자·도메인 전문가와 함께 "이 비즈니스에서 독립적인 개념이 뭔지"를 찾아가는 과정이다.
오히려 반대다. 그리고 이는 흔히 저지르는 실수다.
처음부터 최대한 쪼개면 이런 상황이 온다.
유저 도메인 → 회원/인증/권한/프로필/설정 으로 분리
주문 도메인 → 주문/배송/환불/취소 으로 분리
결제 도메인 → 결제/정산/쿠폰/포인트 으로 분리
...
비즈니스가 아직 단순한 초기에 이렇게 나눠두면 문제가 생긴다.
많이 사용되는 접근법은 이러하다.
처음엔 크게 → 복잡해지면 쪼갠다
구체적으로는 아래와 같은 순서다.
1단계 - '명확하게' 서로 다른 것만 나눈다
누가봐도 다른 비즈니스 책임이면 처음부터 분리하고 시작한다.
유저 / 상품 / 주문 / 결제
→ 이 정도는 초기부터 나눠도 무방하다
2단계 - '애매한 것'은 일단 합쳐둔다
가령, 쿠폰이 주문 도메인인지 결제 도메인인지 모르겠으면, 일단 주문 안에 넣어둔다. 나중에 쿠폰 관련 규칙이 복잡해지면 그때 분리한다. 처음부터 세세히 분리하려 들지 말자. 쿠폰이 우리 비즈니스의 핵심 모델이 아니라면 말이다.
3단계 - 분리 신호가 오면 쪼갠다
아래의 신호가 오면 분리를 고민할 시기다.
- 하나의 Service 클래스가 너무 많은 일을 한다
- 변경 이유가 달라지기 시작한다
- 담당자가 달라진다 (쿠폰팀이 생긴다)
- 해당 기능만 따로 배포하고 싶어진다
결국 한문장으로 정리하면
처음엔 합리적인 크기로 나누고, 비즈니스가 복잡해지는 방향을 보면서 쪼개는 것이 맞다.
도메인 경계는 처음부터 완벽하게 설계하는 게 아니라, 비즈니스와 함께 진화하는 것이다. 너무 이른 분리(premature separation)는 너무 이른 최적화(premature optimization)만큼 위험하다.
출발점이 달라지고, 이는 설계의 결과를 갈라놓을 것이다.
예를 들어, "주문하기" 기능을 중심으로 생각하면 이렇게 된다.
주문하기 기능이 필요하다
→ 유저 정보 확인
→ 상품 재고 확인
→ 주문 생성
→ 결제 처리
→ 배송 정보 등록
public class OrderService {
// 기능 하나를 위해 모든 도메인을 끌어온다
private final UserRepository userRepository;
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final PaymentRepository paymentRepository;
private final ShipmentRepository shipmentRepository;
}
→ OrderService의 메서드 절차적 처리과정 완성
기능은 깔끔히 완성되었지만, 도메인 경계가 없다. 전에 배운 강결합 문제가 그대로 드러난다.
기능이 아니라 이 개념이 어떤 책임을 지는가 에서 출발한다.
주문(Order)이라는 개념은 무엇을 책임지는가?
→ 주문 항목을 관리한다 // 책임
→ 총 금액의 일관성을 보장한다 // 책임
→ 주문 상태를 관리한다 (접수 → 처리 중 → 완료) // 책임
결제(Payment)는 무엇을 책임지는가?
→ 결제 수단을 처리한다 // 책임
→ 결제 상태를 관리한다 // 책임
→ 환불을 처리한다 // 책임
보장 처리 관리... 모두 책임의 개념이다.
이러한 방식으로 생각하면, "주문하기" 라는 기능은 여러 도메인이 협력해서 완성하는 것이 된다.
주문하기 = Order 도메인이 주문을 만들고
Payment 도메인이 결제를 처리하고
Shipment 도메인이 배송을 등록한다
물론, 각 도메인은 자기 책임만 진다.
그렇지는 않다. 오히려 기능은 좋은 출발점이 될 수 있다.
단, 순서를 잘 정해서 접근해야 한다.
1단계 — 기능으로 시작한다
"주문하기, 결제하기, 리뷰쓰기..."
→ 어떤 비즈니스 흐름이 있는지 파악한다
2단계 — 기능 안에서 책임을 찾는다
"주문하기" 안에 누가 무엇을 책임지는가?
→ Order, Payment, Shipment 각자의 책임을 분리한다
3단계 — 책임 단위로 도메인을 정의한다
→ 바운디드 컨텍스트, 애그리거트를 결정한다
기능은 도메인을 발견하는 도구로서 사용되어야 한다. 기능 자체가 설계의 단위가 되면 안 된다.
기능은 도메인을 발견하는 단서고, 도메인은 기능을 실행하는 주체다.
기능 중심으로 생각하는 것 자체는 괜찮다. 다만 거기서 멈추면 강결합이 된다. 기능 안에서 "누가 이 책임을 져야 하는가"를 한 번 더 물어보는 것이 DDD의 핵심 사고방식이다.
그렇다. 그리고 그것이 DDD에서 말하는 "핵심 도메인 (Core Domain)" 개념과 정확히 맞닿아있다.
Eric Evans(DDD 창시자)는 도메인을 세 가지로 나눈다.
Core Domain → 이 서비스가 존재하는 이유, 경쟁력의 핵심
Supporting → Core를 돕는 도메인, 없으면 안 되지만 차별점은 아님
Generic → 어느 서비스에나 있는 범용 도메인
이커머스를 예로 들면,
Core Domain → 주문, 상품 (이 서비스의 핵심 가치)
Supporting → 배송, 재고 (필요하지만 차별점은 아님)
Generic → 인증, 알림, 로그 (어디서나 쓰는 범용 기능)
MVP를 먼저 생각하는 것이 곧 Core Domain을 먼저 찾는 것이다.
1단계 — Core Domain만 먼저 잡는다
"우리 서비스의 핵심은 뭔가?" → 주문, 상품
여기에 설계 에너지를 가장 많이 쏟는다
2단계 — Supporting이 복잡해지면 분리한다
배송 추적 로직이 복잡해지기 시작했다
→ 그때 배송 도메인을 독립시킨다
3단계 — Generic은 외부 솔루션을 쓴다
인증 → Spring Security
알림 → AWS SNS
직접 만들 필요 없다
처음엔 Core Domain에만 집중하고, 복잡도가 따라오면 그때 경계를 넓힌다.
MVP로 시작하는 것이 단순히 "일정이 촉박해서"가 아니라, 설계 관점에서도 올바른 순서인 이유가 여기에 있다. 비즈니스가 어느 방향으로 복잡해질지 모르는 초기에, 미리 모든 경계를 정확하게 긋는 건 불가능하다. 실제로 서비스를 운영하면서 도메인 경계가 보이기 시작한다.
시장에서의 경쟁력 입증이 되지 않은 상태에서 확장만 하는 서비스는, 금방 망한다. 이는 지난 역사가 말해준다..
맞다. 그리고 그것이 DDD에서 말하는 중요한 원칙 중 하나이다.
도메인은 비즈니스 규칙 그 자체다. 그런데 거기에 @Entity, @Column, @ManyToOne 같은 JPA 어노테이션이 가득 붙어 있으면 어떻게 되는가.
// 도메인인가, JPA 매핑 설정인가?
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems;
// 비즈니스 로직은 어디 있는가?
}
이 클래스는 비즈니스를 설명하는 것인지, DB 구조를 설명하는 것인지 구분이 안 된다. DB가 바뀌면 도메인도 바뀌어야 하는 구조가 된다.
도메인 클래스는 순수한 자바 객체(POJO — Plain Old Java Object)로 비즈니스 규칙만 표현한다.
// 어노테이션 없이도 비즈니스가 읽힌다
public class Order {
private final OrderId id;
private final List<OrderItem> orderItems;
private OrderStatus status;
private int totalPrice;
// 생성 시점에 비즈니스 규칙을 강제한다
public Order(List<OrderItem> orderItems) {
validate(orderItems);
this.orderItems = orderItems;
this.totalPrice = calculateTotal(orderItems);
this.status = OrderStatus.PENDING;
}
// 비즈니스 행위가 메서드로 표현된다
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("대기 중인 주문만 확정할 수 있습니다.");
}
this.status = OrderStatus.CONFIRMED;
}
private void validate(List<OrderItem> orderItems) {
if (orderItems == null || orderItems.isEmpty()) {
throw new IllegalArgumentException("주문 항목은 비어 있을 수 없습니다.");
}
}
}
JPA도, Spring도 모른다. 그냥 자바다. 그런데 비즈니스 규칙은 명확하게 읽힌다.
이상과 현실 사이에는 간극이 있다. 순수 도메인 객체를 완벽하게 분리하려면 결국 JPA Entity와 도메인 클래스를 따로 만들어야 한다.
도메인 클래스 (Order) → 순수 자바, 비즈니스 규칙
JPA Entity (OrderEntity) → DB 매핑 담당
그리고 Repository 구현체에서 둘을 변환한다.
// infrastructure 계층
public class OrderJpaRepository implements OrderRepository {
private final OrderJpaStore jpaStore; // Spring Data JPA
@Override
public void save(Order order) {
// 도메인 객체 → JPA Entity 변환 후 저장
OrderEntity entity = OrderMapper.toEntity(order);
jpaStore.save(entity);
}
@Override
public Order findById(OrderId id) {
// JPA Entity → 도메인 객체 변환 후 반환
OrderEntity entity = jpaStore.findById(id.getValue())
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다."));
return OrderMapper.toDomain(entity);
}
}
이 방식은 코드량이 상당히 늘어난다. 그래서 현실에서는 절충안을 많이 쓴다.
// 절충안 — JPA 어노테이션은 허용하되, 비즈니스 로직은 반드시 도메인 안에 둔다
@Entity
@Table(name = "orders")
public class Order {
@Id
private Long id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(cascade = CascadeType.ALL)
private List<OrderItem> orderItems;
// DB 매핑은 위에서 하되, 비즈니스 규칙은 여기서 표현한다
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("대기 중인 주문만 확정할 수 있습니다.");
}
this.status = OrderStatus.CONFIRMED;
}
}
완벽한 분리는 아니지만, 비즈니스 로직이 도메인 안에 있다는 원칙은 지킨다.
진짜 중요한 것은 이것이다.
JPA 어노테이션이 붙어 있어도, 도메인 객체가 자신의 규칙을 스스로 표현하고 있다면 방향은 맞다. 반대로 어노테이션이 하나도 없어도, 비즈니스 로직이 Service에 다 흩어져 있다면 DDD가 아니다.
순수 자바로 도메인을 표현하려는 노력은 맞는 방향이다. 다만 그것이 목적이 아니라, "도메인이 자신의 규칙을 스스로 책임진다"는 것이 진짜 목적이다.
맞다.
DDD는 "정답이 없는 비즈니스 문제"를 다루는 과정에서, 팀이 같은 방향을 바라볼 수 있도록 만든 공통 언어이자 사고 도구다.
그래서 DDD를 배운다는 것은 기술을 배우는 것과 결이 다르다.
JPA를 배운다 → 사용법이 있다, 정답이 있다
DDD를 배운다 → 사고방식을 훈련한다, 정답이 없다
결국 DDD가 말하고 싶은 것은 아래와 같다.
소프트웨어는 비즈니스를 닮아야 한다.
비즈니스가 바뀌면 코드도 자연스럽게 따라갈 수 있어야 한다.
그 목표를 달성하기 위해 바운디드 컨텍스트, 애그리거트, 유비쿼터스 언어 같은 개념들을 도구로 제시한 것이다. 도구가 목적이 아니다.
패턴을 얼마나 많이 아는가가 아니라, 비즈니스를 얼마나 잘 이해하고 코드로 표현할 수 있는가에 달려 있다.
시니어 개발자가 주니어와 가장 크게 차이나는 지점이 바로 여기다. 코드를 짜는 속도나 문법이 아니라, 비즈니스 문제를 구조화하는 능력이다. DDD는 그 능력을 키우는 훈련 방식이라고 봐도 좋다.
어떤 데이터를 생성하고 변경할 권한이 누구에게 있는가를 본다.
주문 금액을 바꿀 수 있는 건? → 주문 도메인
상품 가격을 바꿀 수 있는 건? → 상품 도메인
결제 상태를 바꿀 수 있는 건? → 결제 도메인
실제로 가장 많이 쓰이는 기준
상품 가격 정책이 바뀐다 → 상품 도메인만 바뀐다
배송 정책이 바뀐다 → 배송 도메인만 바뀐다
결제 수단이 추가된다 → 결제 도메인만 바뀐다
한 도메인이 바뀔 때 다른 도메인이 같이 바뀌어야 한다면, 경계가 잘못 그어진 신호다.
조직 구조와 도메인 경계는 생각보다 강하게 연결되어 있다. 실제로 콘웨이의 법칙(Conway's Law)이라는 개념이 있다.
시스템 구조는 그것을 만든 조직의 커뮤니케이션 구조를 닮는다.
결제팀이 따로 생길 수 있는가? → 결제는 독립 도메인
물류팀이 따로 생길 수 있는가? → 배송/재고는 독립 도메인
"이 기능만 담당하는 팀이 생길 수 있는가"를 물어보면 경계가 보인다.
결제 로직만 긴급 배포해야 할 일이 생기는가? → 결제는 독립 도메인
상품 검색만 따로 스케일아웃 해야 하는가? → 상품은 독립 도메인
지금 당장 MSA로 분리하지 않더라도, 나중에 분리할 가능성이 있는 것들은 처음부터 경계를 그어두는 것이 낫다.
유비쿼터스 언어 관점에서 보는 기준이다.
'고객'이라는 단어가
→ 마케팅팀에선 "구매 가능성 있는 잠재 사용자" // 사용자
→ 결제팀에선 "카드 정보를 가진 실제 결제자" // 결제자
→ 배송팀에선 "배송지 주소를 가진 수령인" // 수령인
같은 단어가 문맥에 따라 다른 의미를 가지면, 그 경계가 곧 바운디드 컨텍스트의 경계다.
다섯 가지를 한꺼번에 적용하면 오히려 혼란스럽다. 초기에는 이 순서로 쓰는 것이 현실적이다.
1. 변경 이유가 다른가? → 가장 빠르게 경계를 잡는 기준
2. 데이터의 주인이 누구인가? → 책임 소재를 명확하게 하는 기준
3. 다른 팀이 맡을 수 있는가? → 조직 구조와 맞추는 기준
나머지 두 가지는 시스템이 커지면서 자연스럽게 고려하게 된다.
도메인 경계를 나누는 가장 현실적인 질문은 "이게 바뀔 때, 저것도 같이 바뀌어야 하는가?" 이다.
같이 바뀌어야 하면 같은 도메인, 독립적으로 바뀔 수 있으면 다른 도메인이다. 이 질문 하나만 잘 던져도 초기 설계에서 크게 벗어나지 않는다.
둘은 자주 같이 언급되는데, 개념적으로 다른 층위에 있다.
DDD → 무엇을 만들까 (비즈니스 설계 사상)
헥사고날 → 어떻게 구조를 짤까 (코드 구조 패턴)
DDD가 설계 철학이라면, 헥사고날은 그 철학을 코드로 구현하는 방법 중 하나다.
DDD는 도메인이 중심이어야 한다고 말한다.
도메인은 DB를 몰라야 한다
도메인은 Spring을 몰라야 한다
도메인은 외부 API를 몰라야 한다
→ 도메인은 순수하게 비즈니스 규칙만 표현해야 한다
그런데 이걸 실제 코드로 어떻게 구현할 것인가? 여기서 헥사고날이 등장한다.
핵심 아이디어는 이러하다.
도메인을 중심에 두고, 외부 세계는 전부 바깥으로 밀어낸다.

[외부 세계]
HTTP 요청 / DB / 외부 API
↓
[Port — 인터페이스]
↓
[Domain — 순수 비즈니스 로직]
↓
[Port — 인터페이스]
↓
[Adapter — 실제 구현체]
JpaRepository / RestClient 등
도메인은 인터페이스(Port)만 알고, 실제 구현(Adapter)은 모른다. DB가 MySQL에서 MongoDB로 바뀌어도 도메인은 건드릴 필요가 없다.
코드로 보면 :
// Domain 계층 — 순수 자바, 외부를 전혀 모른다
public class Order {
private OrderId id;
private List<OrderItem> orderItems;
private OrderStatus status;
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("대기 중인 주문만 확정할 수 있습니다.");
}
this.status = OrderStatus.CONFIRMED;
}
}
// Port — 도메인이 외부에 요청하는 인터페이스
// 도메인은 이 인터페이스만 안다. JPA가 뭔지 모른다.
public interface OrderRepository {
void save(Order order);
Order findById(OrderId id);
}
// Adapter — Port의 실제 구현체. Infrastructure 계층에 있다.
// 여기서만 JPA를 안다.
@Repository
public class OrderJpaAdapter implements OrderRepository {
private final OrderJpaStore jpaStore;
@Override
public void save(Order order) {
// 도메인 객체 → JPA Entity 변환
OrderEntity entity = OrderMapper.toEntity(order);
jpaStore.save(entity);
}
@Override
public Order findById(OrderId id) {
// JPA Entity → 도메인 객체 변환
OrderEntity entity = jpaStore.findById(id.getValue())
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다."));
return OrderMapper.toDomain(entity);
}
}
도메인은 OrderRepository 인터페이스만 알고, OrderJpaAdapter가 뭔지 전혀 모른다. 의존성의 방향이 항상 도메인을 향한다.
com.example.order
├── domain/ ← 순수 비즈니스 로직
│ ├── Order.java
│ ├── OrderItem.java
│ └── OrderRepository.java (Port — 인터페이스)
│
├── application/ ← 유스케이스(use case) 조율
│ └── OrderService.java
│
├── presentation/ ← 외부 입력 처리 (Adapter)
│ └── OrderController.java
│
└── infrastructure/ ← 외부 출력 처리 (Adapter)
├── OrderJpaAdapter.java
└── OrderEntity.java
DDD가 말한다
"도메인이 중심이어야 한다.
도메인은 외부(DB, 프레임워크)에 의존하면 안 된다."
헥사고날이 답한다
"그러면 이렇게 구조를 짜면 된다.
Port와 Adapter로 도메인을 외부로부터 격리한다."
DDD의 사상을 코드 구조로 표현하는 데 헥사고날이 잘 맞아떨어지기 때문에 함께 언급되는 것이다. 강제 조합은 아니다. DDD를 쓴다고 반드시 헥사고날을 써야 하는 것은 아니고, 앞서 본 절충안처럼 레이어드 아키텍처(Layered Architecture)에 DDD 원칙을 적용하는 방식도 충분히 현실적인 선택이다.
DDD는 무엇을 지켜야 하는지 알려주고, 헥사고날은 어떻게 지킬 것인지 알려준다.
맞다.
캡슐화의 핵심은 단순히 private를 쓰는 것이 아니다.
"알 필요가 없는 것은 알지 못하게 한다."
이 원칙을 지키는 것이다.
왜 알 필요가 없어야 하는가??
A가 B의 내부를 알수록 (직접 참조할수록, 직접 연관될수록)
→ B가 바뀔 때 A도 바뀌어야 한다
→ 둘이 강하게 결합된다
→ 하나를 고치면 다른 하나도 고쳐야 한다
반대로 A가 B의 내부를 모를수록, B는 자유롭게 바뀔 수 있다.
지금까지 배운 내용을 다시 보면 전부 동일한 원칙의 반복이다.
애그리거트 루트
→ OrderItem의 내부를 Service가 직접 알지 못하게 한다
→ Order를 통해서만 접근한다
Facade
→ UserRepository의 존재를 OrderService가 알지 못하게 한다
→ UserFacade를 통해서만 접근한다
DTO
→ Entity의 내부 구조를 외부가 알지 못하게 한다
→ 필요한 데이터만 골라서 건넨다
헥사고날의 Port
→ JPA의 존재를 Domain이 알지 못하게 한다
→ 인터페이스를 통해서만 접근한다
바운디드 컨텍스트
→ 다른 도메인의 내부를 알지 못하게 한다
→ API / 이벤트로만 소통한다
...
패턴 이름은 다 다르지만, 지향하는 바는 하나다.
DDD의 모든 패턴은 결국 "알 필요 없는 것은 알지 못하게 한다" 는 캡슐화 원칙을 다양한 층위에서 반복 적용한 것이다.
클래스 수준의 캡슐화가 객체지향의 기본이라면, DDD는 그것을 도메인 수준, 시스템 수준까지 확장한 것이라고 봐도 좋다.