
빈혈 도메인 모델은 이름은 거창하지만, 실제 모습은 익숙합니다. 객체는 필드와 getter, setter만 들고 있고, 진짜 규칙과 계산은 서비스 클래스에 몰려 있는 구조입니다. 처음엔 단순해 보이지만 규모가 커질수록 수정 비용이 어디서 커지는지가 드러납니다.
이번 글에서는 Martin Fowler가 말한 Anemic Domain Model 개념을 바탕으로, 왜 이런 구조가 유지보수를 어렵게 만드는지 실무 기준으로 설명하겠습니다.
빈혈 도메인 모델이란 무엇인가
Fowler는 빈혈 도메인 모델을, 모양은 도메인 모델처럼 보이지만 실제 행동은 거의 없고 서비스가 로직을 떠안는 구조로 설명합니다. 객체는 도메인 개념의 이름을 갖고 있어도, 실제로는 데이터를 담는 상자처럼 쓰입니다.
class Order {
private int totalPrice;
private boolean paid;
public int getTotalPrice() { return totalPrice; }
public void setTotalPrice(int totalPrice) { this.totalPrice = totalPrice; }
public boolean isPaid() { return paid; }
public void setPaid(boolean paid) { this.paid = paid; }
}
class OrderService {
void pay(Order order, int amount) {
if (amount < order.getTotalPrice()) {
throw new IllegalArgumentException();
}
order.setPaid(true);
}
}위 구조는 아주 흔합니다. 문제는 단순히 setter가 있다는 사실이 아니라, 주문을 어떻게 결제해야 하는지에 대한 규칙이 Order가 아니라 OrderService에만 있다는 점입니다.
왜 서비스로 로직이 몰릴까
처음에는 객체를 얇게 두는 편이 더 안전해 보입니다. 테스트도 쉬워 보이고, 역할도 단순해 보입니다. 그래서 검증, 계산, 상태 전환을 전부 서비스에 모으기 시작합니다.
- 도메인 객체는 데이터만 들고 있게 하자
- 규칙은 서비스에 모으자
- 객체는 단순할수록 좋다
짧은 기간에는 이 방식이 빨라 보일 수 있습니다. 하지만 기능이 늘어나면 한 주문 규칙이 여러 서비스에 흩어지고, 같은 검증이 반복되며, 상태를 바꾸는 기준도 여기저기에서 달라지기 시작합니다.
유지보수가 어려워지는 이유
규칙의 주인이 흐려진다
결제 가능 여부, 취소 가능 여부, 할인 적용 조건 같은 규칙이 객체 내부에 없으면, 그 규칙이 어디에 있는지 찾는 비용이 커집니다. 규칙이 한 서비스에만 있는 것이 아니라 여러 서비스, 핸들러, 유즈케이스로 흩어지기 쉽기 때문입니다.
상태 무결성을 지키기 어렵다
객체가 스스로 상태 전환 규칙을 지키지 않으면, 외부 코드가 언제든지 모순된 상태를 만들 수 있습니다. 예를 들어 이미 결제된 주문을 다시 결제 가능 상태로 바꾸는 코드가 생겨도 막기 어렵습니다.
변경이 생길 때 영향 범위가 넓어진다
규칙이 도메인 객체가 아니라 절차 코드 여러 군데에 흩어져 있으면, 정책 하나가 바뀔 때 수정 지점도 함께 늘어납니다. 이때는 코드 양보다 규칙의 분산이 더 큰 비용이 됩니다.
그렇다고 항상 도메인 모델이 정답일까
여기서 자주 생기는 오해가 하나 있습니다. 빈혈 도메인 모델이 문제라고 해서, 모든 시스템이 풍부한 도메인 모델을 가져야 하는 것은 아닙니다. Fowler가 Transaction Script를 별도 패턴으로 설명한 이유도 여기에 있습니다.
작은 CRUD 서비스, 규칙이 거의 없는 관리자 화면, 단순 데이터 전달 API는 transaction script 쪽이 오히려 자연스러울 수 있습니다. 복잡도가 낮은데 억지로 도메인 모델을 세우면 과설계가 됩니다.
즉 핵심은 객체지향 교리가 아니라 도메인 규칙의 복잡도입니다.
어떤 순간에 도메인 모델이 더 나아질까
- 상태 전환 규칙이 분명히 존재할 때
- 검증과 계산이 여러 유즈케이스에서 반복될 때
- 같은 정책 변경이 여러 서비스에 퍼질 때
- 객체가 자기 상태를 스스로 보호해야 할 때
이 조건이 보이기 시작하면 객체 안에 행위를 조금씩 되돌려 놓는 편이 유지보수에 유리합니다. 도메인 모델의 핵심은 거대한 클래스가 아니라 데이터와 규칙을 다시 붙여놓는 것입니다.
실무에서 보는 판단 기준
서비스가 길다는 사실만으로 빈혈 도메인 모델이라고 단정할 수는 없습니다. 대신 아래 질문이 더 유용합니다.
- 이 규칙의 주인이 정말 서비스인가
- 이 상태 변경을 객체가 직접 막아야 하지 않는가
- 같은 검증이 여러 서비스에 반복되고 있지 않은가
- 정책 하나가 바뀌면 몇 군데를 같이 고쳐야 하는가
이 질문에 자주 yes가 나온다면, 이미 객체가 비어 있고 서비스가 과하게 무거워졌을 가능성이 큽니다.
리팩터링은 어떻게 시작하면 좋을까
빈혈 도메인 모델을 본다고 해서 객체를 한 번에 거대하게 바꿀 필요는 없습니다. 보통은 가장 자주 반복되는 검증 하나, 가장 자주 바뀌는 상태 전환 하나를 객체 안으로 옮기는 것부터 시작하는 편이 현실적입니다.
- 여러 서비스에서 반복되는 규칙을 찾는다
- 그 규칙이 실제로 어떤 상태를 보호하는지 확인한다
- 관련 메서드를 객체 안에 작은 행위로 옮긴다
- setter를 줄이고 의미 있는 상태 전환 메서드로 바꾼다
이렇게 조금씩 바꾸면 객체지향 원칙을 멋있게 적용하는 것보다, 변경 비용이 실제로 줄어드는 경험을 먼저 얻을 수 있습니다.
좋은 객체가 꼭 무거운 객체는 아니다
여기서 또 한 번 오해가 생깁니다. 도메인 모델을 강화한다는 말이 곧 모든 것을 객체 안에 몰아넣는다는 뜻은 아닙니다. 좋은 객체는 큰 객체가 아니라, 자기 상태와 직접 관련된 규칙을 스스로 지키는 객체에 더 가깝습니다.
마무리
객체가 상태와 규칙을 함께 가져야 하는 이유는 리스코프 치환 원칙(LSP)은 왜 자주 오해될까 글에서 말한 계약 관점과도 연결됩니다.
도메인 모델이 빈혈 상태가 되면 유지보수가 어려워지는 이유는 단순히 객체지향 원칙을 어겨서가 아닙니다. 규칙의 주인이 흐려지고, 상태 무결성을 지키기 어려워지고, 변경 영향 범위가 넓어지기 때문입니다.
반대로 규칙이 거의 없는 시스템이라면 transaction script가 더 자연스러울 수도 있습니다. 중요한 것은 패턴 이름이 아니라, 지금 이 도메인에 규칙과 상태 전환이 얼마나 복잡한가를 먼저 보는 것입니다. 배경 개념은 Domain Model과 Transaction Script 설명도 함께 보면 더 선명해집니다.