Software Architecture/DDD & Patterns
DDD Patterns - 05. 빈약한 도메인 모델
겉만 객체인 가구 — 빈약한 도메인 모델
가구 전시장에 가면 진짜 가구처럼 보이는 모형이 있다. 형태·색·질감은 같다. 하지만 서랍을 열면 속이 비어 있고, 의자에 앉으면 무너진다. 쓸 수는 없고 볼 수만 있다.
빈약한 도메인 모델(anemic domain model)이 이 전시용 가구다. 클래스가 있고, 필드가 있고, getter/setter가 있다. 객체처럼 보인다. 하지만 행동(비즈니스 로직)이 없다 — 서랍이 비어 있다. 비즈니스 규칙은 서비스 계층으로 도망가고, 객체는 데이터를 담는 주머니로 전락한다. Fowler는 이를 "객체지향의 핵심, 즉 데이터와 행동의 결합을 포기한 안티패턴"이라 불렀다.
빈약한 모델 vs 풍성한 모델
flowchart TB
subgraph Anemic["빈약한 도메인 모델"]
direction LR
DTO["Order<br/>getTotal() / setTotal()<br/>getStatus() / setStatus()<br/>(데이터만, 행동 없음)"]
Svc["OrderService<br/>calculateTotal()<br/>validate()<br/>cancel()<br/>(비즈니스 로직 전부)"]
DTO --> Svc
end
subgraph Rich["풍성한 도메인 모델"]
RO["Order<br/>calculateTotal()<br/>cancel()<br/>isCancellable()<br/>(데이터 + 행동 함께)"]
end
빈약한 모델에서 Order는 그저 total 필드와 getTotal()/setTotal()만 있다. "취소 가능한가?"라는 질문은 OrderService가 대답한다 — order.getStatus() == PENDING을 서비스에서 검사한다. 규칙이 객체 밖에 있다.
// 빈약한 모델 — 데이터 주머니, 행동 없음
public class Order {
private Money total;
private OrderStatus status;
// getter/setter만 — 로직이 없다
public Money getTotal() { return total; }
public void setTotal(Money total) { this.total = total; }
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus status) { this.status = status; }
}
// 풍성한 모델 — 객체가 자기 규칙을 안다
public class Order {
private Money total;
private OrderStatus status;
public void cancel() {
if (status != OrderStatus.PENDING)
throw new IllegalStateException("대기 중인 주문만 취소할 수 있습니다");
this.status = OrderStatus.CANCELLED;
}
public boolean isCancellable() {
return status == OrderStatus.PENDING;
}
}
풍성한 모델에서 Order는 "취소"를 안다. 서비스가 order.getStatus() == PENDING을 검사하는 게 아니라, order.cancel()을 부르면 객체가 자기 규칙을 지킨다. 규칙이 객체 안에 있다 — 서랍에 물건이 있다.
왜 빈약해지는가
빈약한 모델이 만들어지는 전형적 경로가 있다. 프레임워크(Spring, JPA)가 엔터티에 기본 생성자와 getter/setter를 요구한다. 개발자가 이 요구에 맞추다 보니, 엔터티가 데이터 매핑용 구조체로 굳어진다. 로직은 "깔끔하게" 서비스 계층에 모이고, 엔터티는 점점 더 수동적인 데이터 주머니가 된다.
이 과정은 자연스럽다 — 그래서 교활하다. 각 단계는 합리적이다(JPA 매핑을 위해 getter/setter를 둔다). 하지만 누적되면 객체지향의 이점(캡슐화, 응집)이 사라진다. 도메인 규칙이 서비스 여기저기 흩어지고, 같은 규칙("취소 가능한가")이 여러 서비스에서 중복 검사된다. 한 규칙이 바뀌면 흩어진 검사를 다 찾아 고쳐야 한다.
getter/setter 맹신의 함정
getter/setter가 있다고 캡슐화가 아니다. getStatus()를 쓰면 상태가 외부로 노출되고, 외부가 상태를 보고 판단한다 — if (order.getStatus() == PENDING)은 Order의 내부 상태를 밖으로 새어보내는 것이다. 진짜 캡슐화는 "상태를 묻지 말고 행동을 부르는 것"이다 — order.isCancellable()이 아니라 order.cancel()이 실패하면 그게 규칙이다.
Tell, Don't Ask 원칙이 이것이다. 객체에게 "네 상태가 뭐야?"라고 묻지 말고(ask), "이 일을 해"라고 말한다(tell). 빈약한 모델은 계속 ask한다 — 서비스가 엔터티의 상태를 꺼내서 판단한다. 풍성한 모델은 tell한다 — 서비스가 엔터티에게 행동을 위임한다.
god object — 빈약함의 역설
빈약한 도메인 모델의 역설 — 도메인 객체가 빈약해질수록 서비스 계층이 비대해진다. OrderService가 주문·결제·재고·배송 규칙을 전부 떠안으면, 그것이 god object다. 엔터티가 아니라 서비스가 변경 이유 다섯 개를 갖는다. 빈약한 모델은 결합을 도메인 객체에서 서비스로 옮길 뿐, 결합 자체를 없애지 않는다.
전략 없는 전술이 빈약함을 부른다
01번 글에서 본 것처럼, DDD의 본질은 전략적 설계(바운디드 컨텍스트, 보편 언어)에 있다. 전술(엔터티·리포지토리)만 쓰고 전략을 건너뛰면, 도메인에 대한 이해 없이 객체만 만들게 된다 — 그 객체는 비즈니스 규칙을 담을 수 없다. 왜냐하면 비즈니스 규칙이 뭔지 도메인 전문가와 대화하지 않았으니까. 전술적 패턴을 전략적 이해 없이 적용하면, 빈약한 모델이 거의 필연적으로 나온다. 서랍을 열 물건이 없는 것은, 무엇을 넣어야 할지 모르기 때문이다.
설계 사례 — 빈약한 Order를 풍성하게 리팩터링
빈약한 모델에서 풍성한 모델로 바꾸는 과정을 코드로 본다. 규칙을 서비스에서 끌어내 도메인 객체 안에 넣는 것이 이 리팩터링의 목표다.
Before — 빈약한 모델 + 비대한 서비스:
// Order는 데이터 주머니 — 행동 없음
public class Order {
private Money total;
private OrderStatus status;
private Instant placedAt;
// getter/setter만 — "취소 가능한가?"를 Order가 모름
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus s) { this.status = s; }
public Instant getPlacedAt() { return placedAt; }
}
// 서비스가 모든 규칙을 검사 — 도메인 지식이 서비스로 도망
public class OrderService {
public void cancel(Long orderId) {
Order order = repo.findById(orderId);
// 규칙 1: 대기 중이어야 함
if (order.getStatus() != OrderStatus.PENDING)
throw new IllegalStateException("대기 중인 주문만 취소 가능");
// 규칙 2: 3일 이내여야 함
if (order.getPlacedAt().isBefore(Instant.now().minus(Duration.ofDays(3))))
throw new IllegalStateException("3일 경과 시 취소 불가");
order.setStatus(OrderStatus.CANCELLED);
repo.save(order);
}
}
이 코드에서 "취소 가능한가?"라는 비즈니스 규칙이 OrderService에 박혀 있다. 다른 서비스(배송 취소, 환불 처리)에서도 같은 규칙이 필요하면 복사된다. 규칙이 바뀌면(3일 → 7일) 모든 복사본을 찾아 고쳐야 한다.
After — 풍성한 모델:
// Order가 자기 규칙을 안다 — 서비스는 "취소해"라고만 말함
public class Order {
private Money total;
private OrderStatus status;
private Instant placedAt;
// 도메인 규칙이 객체 안에 — 한 곳에서 관리
public void cancel() {
if (status != OrderStatus.PENDING)
throw new IllegalStateException("대기 중인 주문만 취소 가능");
if (placedAt.isBefore(Instant.now().minus(Duration.ofDays(3))))
throw new IllegalStateException("3일 경과 시 취소 불가");
status = OrderStatus.CANCELLED;
}
// Tell, Don't Ask — 상태를 묻지 말고 행동을 부름
public boolean isCancellable() {
return status == OrderStatus.PENDING
&& placedAt.isAfter(Instant.now().minus(Duration.ofDays(3)));
}
}
// 서비스는 단순해짐 — 도메인에 위임
public class OrderService {
public void cancel(Long orderId) {
Order order = repo.findById(orderId);
order.cancel(); // 규칙은 Order가 안다 — 서비스는 호출만
repo.save(order);
}
}
규칙이 Order 안으로 들어가면서 서비스가 단순해졌다. cancel()을 부르면 Order가 자기 규칙을 지킨다. 규칙이 바뀌면(3일 → 7일) Order.cancel() 한 곳만 고치면 된다. 다른 서비스가 order.isCancellable()을 쓰고 있었다면, 그곳도 자동으로 새 규칙을 따른다 — 규칙이 흩어지지 않고 한 곳에 모인다.
참고
- Fowler, Martin — "Anemic Domain Model", https://martinfowler.com/bliki/AnemicDomainModel.html — 접근 2026-07-15
- Evans, Eric — (Addison-Wesley, 2003), Ch.4 (도메인 객체의 행동) — 접근 2026-07-15
'Software Architecture > DDD & Patterns' 카테고리의 다른 글
| DDD Patterns - 07. 컨텍스트 통합 (0) | 2026.07.21 |
|---|---|
| DDD Patterns - 06. 헥사고날 실천 (0) | 2026.07.21 |
| DDD Patterns - 04. 앤터프라이즈 패턴 (0) | 2026.07.21 |
| DDD Patterns - 03. 애그리거트 설계 (0) | 2026.07.20 |
| DDD Patterns - 02. DDD 전술적 설계 (0) | 2026.07.20 |