목차
서비스 메서드가 계속 길어진다
주문을 취소하는 기능을 맡았다. 규칙이 몇 개 있다.
@Transactional
public void cancel(Long orderId) {
Order order = orders.findById(orderId);
if (order.getStatus() == SHIPPED) { // 이미 발송됐으면 취소 불가
throw new IllegalStateException("발송된 주문");
}
if (order.getStatus() == CANCELED) { // 이미 취소된 건 또 취소 못 함
throw new IllegalStateException("이미 취소됨");
}
order.setStatus(CANCELED);
order.setCanceledAt(LocalDateTime.now());
Product product = products.findById(order.getProductId());
product.setStock(product.getStock() + order.getQuantity()); // 재고 복구
}돌아간다. 그런데 다음 주에 관리자 화면에서도 주문을 취소해야 한다. 환불 배치도 취소를 부른다.
세 곳에 같은 검사가 복붙되고, “취소 가능 기간은 7일까지”라는 규칙이 추가되는 날 세 곳을 다 찾아 고쳐야 한다. 하나를 빠뜨리면 그 경로로만 규칙이 뚫린다.
절차에 다 적는 방식
지금 코드가 하는 일을 정확히 말하면 이렇다. “주문 취소”라는 업무 하나를 절차 하나로 적었다. 필요한 데이터를 가져오고, 조건을 검사하고, 값을 바꾼다.
이런 방식에 이름이 있다. 트랜잭션 스크립트(Transaction Script) 다. 업무 하나에 스크립트 하나. 객체는 데이터를 나르는 그릇이고, 판단은 전부 절차가 한다. 뒤에 나올 도메인 모델과 짝을 이루는 이름으로, 마틴 파울러가 Patterns of Enterprise Application Architecture(2002)에서 정리했다.
무시할 방식이 아니다. 장점이 뚜렷하다.
- 읽기 쉽다. 위에서 아래로 읽으면 업무 순서가 그대로 보인다
- 배우기 쉽다. 특별한 설계 지식이 없어도 된다
- 작은 기능에 빠르다. 값 하나 바꾸는 일에 객체 설계를 고민할 이유가 없다
그래서 대부분의 CRUD는 이 방식이 맞다. 목록을 조회하고, 한 건을 저장하고, 상태 하나를 바꾸는 일에 도메인 모델을 세울 필요는 없다.
어디서 무너지나
문제는 같은 규칙이 여러 절차에 필요해질 때 시작된다.
“발송된 주문은 취소할 수 없다”는 규칙은 취소에만 쓰이지 않는다. 주소 변경에도, 항목 수정에도, 쿠폰 적용 취소에도 같은 판단이 필요하다. 그때마다 각 절차가 자기 버전으로 그 검사를 적는다.
// cancel()
if (order.getStatus() == SHIPPED) { ... }
// changeAddress()
if (order.getStatus() == SHIPPED || order.getStatus() == DELIVERED) { ... } // 미묘하게 다르다
// addCoupon()
if (!order.getStatus().equals(PENDING)) { ... } // 또 다르다셋 다 “아직 손댈 수 있는 주문인가”를 묻고 있는데 답이 셋 다 다르다. 어느 게 맞는지는 아무도 모른다. 규칙이 코드 어디에도 한 번에 적혀 있지 않기 때문이다.
여기서 진짜 비용이 발생한다. 규칙이 바뀌면 그 규칙이 흩어져 있는 곳을 전부 찾아야 하는데, 찾는 방법이 grep밖에 없다.
객체가 자기 규칙을 갖게 한다
다른 방식은 규칙을 그 데이터를 들고 있는 객체 안에 넣는 것이다.
public class Order {
private OrderStatus status;
private LocalDateTime orderedAt;
private LocalDateTime canceledAt;
// 상품·수량 등 나머지 필드는 생략
/** 아직 손댈 수 있는 주문인가. 이 판단의 정의는 여기 하나뿐이다. */
public boolean isModifiable() {
return status == PENDING || status == PAID;
}
public void cancel() {
if (!isModifiable()) {
throw new IllegalStateException("취소할 수 없는 주문: " + status);
}
this.status = CANCELED;
this.canceledAt = LocalDateTime.now();
}
}이러면 서비스는 절차만 남는다.
@Transactional
public void cancel(Long orderId) {
Order order = orders.findById(orderId);
order.cancel(); // 규칙은 주문이 안다
Product product = products.findById(order.getProductId());
product.increaseStock(order.getQuantity()); // 재고 규칙은 상품이 안다
}데이터와 그 데이터를 다루는 규칙이 한 클래스에 같이 있는 것, 이걸 도메인 모델(Domain Model)이라고 부른다.
무엇이 달라졌나. 관리자 화면이든 배치든 order.cancel()을 부르는 이상 같은 검사를 거친다. 취소 조건이 바뀌면 고칠 곳이 한 군데다. 그리고 “취소 가능한 주문이란 무엇인가”를 알고 싶으면 Order를 열면 된다.
처음에 걱정했던 “취소 가능 기간은 7일까지”도 이제 갈 곳이 정해져 있다. isModifiable() 안에 조건 한 줄을 더하면 끝이다.
public boolean isModifiable() {
return (status == PENDING || status == PAID)
&& orderedAt.isAfter(LocalDateTime.now().minusDays(7));
}세 군데를 찾아다닐 일이 없다.
데이터만 든 객체는 도메인 모델이 아니다
여기서 자주 생기는 오해가 있다. 클래스를 만들었다고 도메인 모델이 되는 게 아니다.
public class Order {
private Long id;
private OrderStatus status;
private LocalDateTime canceledAt;
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus s) { this.status = s; }
public LocalDateTime getCanceledAt() { return canceledAt; }
public void setCanceledAt(LocalDateTime t) { this.canceledAt = t; }
// ... 필드마다 getter와 setter
}Order라는 클래스가 있고 필드도 있다. 그런데 하는 일이 하나도 없다. 값을 꺼내주고 넣어줄 뿐이다. 그러면 판단은 결국 서비스가 하게 되고, 처음의 트랜잭션 스크립트로 돌아간다. 클래스 이름만 도메인이고 속은 그릇이다.
이런 상태를 빈약한 도메인 모델(Anemic Domain Model) 이라고 부른다(이 이름도 파울러가 붙였다). 겉모습은 객체지향인데 실제 동작은 절차적인 것이다.
“빈약하면 무조건 나쁘다”는 아니다. 앞에서 봤듯 규칙이 없는 영역이라면 트랜잭션 스크립트가 맞는 선택이고, 그때 객체가 데이터만 들고 있는 건 자연스럽다. 문제가 되는 건 도메인 모델을 만들었다고 생각하면서 실제로는 절차적으로 돌아가는 경우다. 객체를 나누는 비용은 다 치르고 얻는 건 없는 상태다. 그러니 판별식은 “빈약한가”가 아니라 **“이 영역에 지킬 규칙이 있는데 아무도 안 지키고 있는가”**다.
가려내는 방법은 간단하다. 그 객체에 setter 말고 업무의 말로 된 메서드가 있는가.
| 빈약한 모델 | 도메인 모델 |
|---|---|
order.setStatus(CANCELED) | order.cancel() |
product.setStock(stock - n) | product.increaseStock(n) · decreaseStock(n) |
account.setBalance(b - amt) | account.withdraw(amt) |
왼쪽은 무엇을 하라고 지시하고, 오른쪽은 무엇이 일어났는지 말한다. 왼쪽에서는 “잔고가 음수가 되면 안 된다”를 부르는 쪽이 지켜야 하고, 오른쪽에서는 객체가 지킨다.
그렇다고 setter를 전부 없애야 한다는 뜻은 아니다. 요점은 상태를 바꾸는 길에 규칙이 붙어 있느냐다. 아무나 아무 값으로 바꿀 수 있는 통로가 열려 있으면, 그 규칙은 지켜지길 기도하는 수밖에 없다.
무엇이 규칙이고 무엇이 절차인가
객체에 다 넣으라는 말은 아니다. 넣을 것과 남길 것을 갈라야 한다.
기준은 그 판단에 무엇이 필요한가다.
자기 데이터만으로 판단되면 객체 안으로. “발송된 주문은 취소할 수 없다”는 Order의 상태만 보면 된다. 그러니 Order가 안다.
여러 객체를 조율해야 하면 서비스에. “주문을 취소하고, 재고를 되돌리고, 환불을 요청한다”는 Order도 Product도 혼자서는 못 한다. 순서가 있고 대상이 여럿이다. 이건 절차이므로 서비스 계층이 맡는다.
그래서 두 방식은 대립이 아니다. 절차는 여전히 필요하고, 그 절차 안에서 판단을 객체에 맡기는 것뿐이다. 서비스 메서드가 업무 순서의 목차처럼 읽히면 알맞게 갈린 것이다.
실무에서는 둘을 섞어 쓴다
“우리 프로젝트는 도메인 모델로 갑니다”라는 선언은 대개 지켜지지 않는다. 지킬 필요도 없다.
한 시스템 안에서도 영역마다 복잡도가 다르기 때문이다.
- 공지사항 관리 - 쓰고, 고치고, 지운다. 규칙이랄 게 없다. 트랜잭션 스크립트가 맞다. 여기에 도메인 모델을 세우면 파일만 늘고 얻는 게 없다
- 주문·정산·재고 - 상태가 여러 개고 전이 규칙이 있고 예외가 많다. 도메인 모델이 값을 한다. 규칙이 흩어지면 가장 아픈 곳이기도 하다
그래서 실무의 판단은 “어느 쪽을 채택할까”가 아니라 **“이 영역이 지금 어느 쪽인가”**다. 그리고 이건 고정된 게 아니다. 단순했던 영역에 규칙이 붙기 시작하면 그때 옮기면 된다.
옮겨야 할 신호는 대체로 이렇게 온다.
- 같은
if조건이 서비스 여기저기서 보인다 - 그 조건들이 미묘하게 다르다
- 새 규칙을 넣을 때 “어디어디를 고쳐야 하지”부터 검색한다
세 번째가 나오기 시작하면 이미 늦은 축이다.
옮긴다고 공짜는 아니다
도메인 모델에도 대가가 있다.
설계 판단이 계속 필요하다. 이 규칙이 Order의 것인가 Payment의 것인가를 매번 정해야 한다. 애매한 규칙은 어디에 둬도 어색하고, 잘못 두면 나중에 옮기는 비용이 든다.
객체가 무거워질 수 있다. 주문에 관련된 모든 규칙을 Order에 넣다 보면 그 클래스가 500줄이 된다. 처음 문제를 서비스에서 도메인으로 옮겨놓은 것뿐인 상태가 된다.
팀이 같은 그림을 봐야 한다. 한 사람은 규칙을 객체에 넣고 다른 사람은 서비스에 넣으면, 규칙을 찾을 때 두 군데를 다 봐야 한다. 일관성이 없으면 두 방식의 단점만 합쳐진다.
그래서 판단 기준을 팀이 한 문장으로 답할 수 있어야 한다. “자기 데이터로 판단되는 건 객체가, 여러 객체를 조율하는 건 서비스가.” 이 정도면 충분하다.
정리
| 트랜잭션 스크립트 | 업무 하나를 절차 하나로. 객체는 데이터 그릇 |
| 도메인 모델 | 데이터와 그 데이터의 규칙을 한 객체에 |
| 무너지는 지점 | 같은 규칙이 여러 절차에 흩어지고, 미묘하게 달라진다 |
| 빈약한 도메인 모델 | 클래스는 있는데 setter뿐. 이름만 도메인이고 실제는 절차적 |
| 가르는 기준 | 자기 데이터로 판단되면 객체, 여러 객체를 조율하면 서비스 |
| 실무 | 둘 중 하나를 고르는 게 아니라 영역마다 다르게 간다 |
| 대가 | 설계 판단이 계속 필요하고, 객체가 무거워질 수 있다 |
로직의 자리를 정하는 것, 그게 도메인 모델링의 출발점이다. 다음 글에서는 그 모델을 이루는 재료 두 가지를 본다.