17.2 도메인 엔티티 설계와 핵심 로직 구현
실무에서 미니 프로젝트를 설계할 때 가장 중요한 첫 단추는 도메인 엔티티(Entity) 설계입니다. 엔티티는 단순한 데이터 저장 공간(DTO)이 아니라, 비즈니스 로직을 스스로 처리하는 객체(Object)여야 합니다.
이번 장에서는 무기력한 도메인 모델(Anemic Domain Model)을 탈피하고, 핵심 비즈니스 로직이 엔티티 안에 존재하는 풍부한 도메인 모델(Rich Domain Model) 을 설계하는 방법을 배웁니다.
🏗️ 1. 무기력한 도메인 모델 vs 풍부한 도메인 모델
무기력한 도메인 모델 (Anemic Domain Model)
단순히 필드 선언과 Getter/Setter로만 이루어진 엔티티입니다. 모든 비즈니스 로직은 Service 클래스 에 집중되어 있습니다. 코드가 커질수록 서비스 레이어가 거대해지고, 유지보수가 어려워집니다.
// 안 좋은 예: 무기력한 엔티티
@Entity
@Getter @Setter
public class Account {
private Long id;
private Long balance;
}
// 서비스에서 모든 계산을 다 처리함
@Service
public class AccountService {
public void withdraw(Account account, Long amount) {
if (account.getBalance() < amount) {
throw new InsufficientBalanceException("잔액 부족");
}
account.setBalance(account.getBalance() - amount);
}
}
풍부한 도메인 모델 (Rich Domain Model)
데이터와 이를 조작하는 행위(메서드)가 같은 곳에 모여있는 모델입니다. 엔티티 자체가 비즈니스 룰을 검증하고 데이터를 변경합니다. 객체 무결성을 보장하며, 서비스 레이어는 이를 단순히 위임(Delegate)하는 역할만 담당합니다.
// 좋은 예: 풍부한 엔티티
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account {
@Id @GeneratedValue
private Long id;
private Long balance;
public void withdraw(Long amount) {
if (this.balance < amount) {
throw new InsufficientBalanceException("잔액 부족");
}
this.balance -= amount; // Setter 없이 스스로 값을 변경
}
}
// 서비스는 위임만 수행
@Service
public class AccountService {
@Transactional
public void withdraw(Long accountId, Long amount) {
Account account = accountRepository.findById(accountId).orElseThrow();
account.withdraw(amount); // 엔티티에 계산을 위임
}
}
💻 2. 실전 예제: 쇼핑몰 주문(Order) 도메인 설계
다음은 쇼핑몰의 주문(Order)과 주문상품(OrderItem) 엔티티를 풍부한 도메인 모델로 설계한 실전 예제입니다.
// OrderItem.java (주문 상품)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice;
private int count;
// 생성 메서드 (복잡한 객체 생성을 캡슐화)
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
OrderItem orderItem = new OrderItem();
orderItem.item = item;
orderItem.orderPrice = orderPrice;
orderItem.count = count;
item.removeStock(count); // 재고 차감 로직
return orderItem;
}
public void setOrder(Order order) {
this.order = order;
}
// 비즈니스 로직
public void cancel() {
this.item.addStock(count); // 재고 복구
}
public int getTotalPrice() {
return this.orderPrice * this.count;
}
}
Order.java (주문 엔티티 최상단 응집도)
@Entity
@Getter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@Enumerated(EnumType.STRING)
private OrderStatus status; // ORDER, CANCEL
private LocalDateTime orderDate;
// 연관관계 편의 메서드
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
// 생성 메서드
public static Order createOrder(Member member, OrderItem... orderItems) {
Order order = new Order();
order.member = member;
for (OrderItem orderItem : orderItems) {
order.addOrderItem(orderItem);
}
order.status = OrderStatus.ORDER;
order.orderDate = LocalDateTime.now();
return order;
}
// 핵심 비즈니스 로직: 주문 취소
public void cancel() {
if (this.status == OrderStatus.CANCEL) {
throw new IllegalStateException("이미 취소된 주문입니다.");
}
this.status = OrderStatus.CANCEL;
for (OrderItem orderItem : orderItems) {
orderItem.cancel(); // 주문 상품들에게 취소 위임
}
}
// 전체 주문 가격 조회
public int getTotalPrice() {
return orderItems.stream()
.mapToInt(OrderItem::getTotalPrice)
.sum();
}
}
코드 분석
- Setter 제거: JPA 엔티티 특성상
Setter를 무분별하게 열어두면 객체의 데이터가 어디서 변경되었는지 추적하기 힘듭니다. 생성자나 생성 메서드, 비즈니스 메서드 안에서만 데이터를 조작하게 막았습니다. - 생성 메서드 캡슐화:
createOrder등 복잡한 생성 과정을 정적 팩토리 메서드로 빼서, 엔티티 객체가 완전히 유효한 상태일 때만 만들어지게 통제했습니다. - 도메인 핵심 로직 응집도: 주문 취소(
cancel())가 호출되면, 상품 재고 복구 로직(orderItem.cancel())도 엔티티 내부에서 스스로 연쇄적으로 동작하도록 위임시킵니다.
🎯 고수 팁 (Pro Tips)
💡 데이터 타입도 풍부하게 설계하라 (Value Object 값 타입 사용) 주소나 전화번호 같은 필드를 단순
String으로 두지 마세요. JPA@Embeddable을 사용해 Value Object(값 객체)로 만들면 훨씬 안전해집니다.
// VO (Value Object)
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Address {
private String city;
private String street;
private String zipcode;
// 이 VO 안에서 주소 유효성 검사 등 자체 로직 적용 가능
}
@Entity
public class Member {
@Embedded
private Address address; // 단순 필드보다 높은 응집도 보장
}
도메인 설계가 견고해지면 서비스 레이어의 테스트 작성이 쉬워지며, 전체 아키텍처의 안정성이 크게 올라갑니다. 다음 챕터에서는 설계된 이 도메인 레이어들을 어떻게 Controller 및 외부 API 규격과 연동(Integration) 시키는지 알아보겠습니다.