Search
moon
sun
🏗️

도메인 주도 설계의 사실과 오해 (4) 애그리거트 구현

Tags
Architcture
DDD
Study
Last edited time
2024/11/01 00:24
2 more properties
Search
🏗️
도메인 주도 설계의 사실과 오해 2부 (3) 모델의 경계를 긋고, 핵심에 집중하라
🏗️
도메인 주도 설계의 사실과 오해 2부 (3) 모델의 경계를 긋고, 핵심에 집중하라

1. 애그리거트 경계 정하기

1.1. 애그리거트 설계

•
기능 구현과 불변식을 고려하여 설계
•
경계는 어떻게 정하냐?
◦
보통 크게 얘기하는 단어들

1.2. 배달앱 예제 불변식 분석

1.2.1. 도메인 객체와 불변식

•
불변식
◦
판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
◦
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
◦
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.
◦
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.
◦
Menu 안에서 OptionGroup의 이름은 중복되지 말아야 한다.
◦
OptionGroup에는 Option이 한 개 이상 존재해야 한다.

1.2.2. 기능 (1) 메뉴 공개하기

•
메뉴를 공개하기 위해서는 아래 4가지 불변식을 반드시 만족시켜야함
◦
판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
◦
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
◦
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.
◦
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.
•
불변식 위반
◦
OptionGroup이 존재하지 않는 경우
◦
판매 중인 메뉴에 있는 모든 Option 가격이 0원인 경우
◦
필수 OptionGroup이 존재하지 않는 경우
◦
필수 OptionGroup이 네 개 이상 존재하는 경우

1.2.3. 기능 (2) 옵션 그룹 추가하기

•
옵션 그룹을 추가하기 위해서는 아래 2가지 불변식을 반드시 만족시켜야함
◦
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.
◦
Menu 안에서 OptionGroup의 이름은 중복되지 말아야 한다.
•
불변식 위반
◦
필수 OptionGroup이미 3개 존재하는데, 필수 옵션 그룹을 추가하려는 경우
◦
동일한 옵션 그룹 이름으로 신규 옵션 그룹을 추가하려는 경우

1.2.4. 기능 (3) 옵션 그룹 삭제하기

•
옵션 그룹을 삭제하기 위해서는 아래 3가지 불변식을 반드시 만족시켜야함
◦
판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
◦
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
◦
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.
•
불변식 위반
◦
판매 중인 OptionGroup이 존재하지 않는 경우
◦
남은 Option 가격이 모두 0원인 경우
◦
남은 OptionGroup이 전부 필수가 아닌 경우

1.3. 배달앱 예제 애그리거트 설계

•
불변식 기반으로 애그리거트 경계 결정

1.3.1. 애그리거트 경계 및 루트 정하기

•
경계
1.
메뉴 - 옵션그룹 - 옵션
2.
가게
•
루트
◦
메뉴 & 가게
◦
루트는 애그리거트 전체의 불변식을 보장

1.3.2. 엔티티와 값 객체 정하기

•
식별성, 연속성, 속성 등을 고려하여 설정

1.3.3. 연관관계 설정 및 구현

•
연관관계는 기능을 구현하는데 적합한 방향으로 결정

2. 애그리거트 구현하기

2.1. 애그리거트 루트

•
Base AggregateRoot
public abstract class AggregateRoot <T extends DomainEntity<T, TID>, TID> extends DomainEntity<T, TID> { }
Java
복사
•
Menu Aggregate
public class Menu extends AggregateRoot<Menu, MenuId> { private MenuId id; // 외부는 ID private ShopId shopId; private String name; private String description; private boolean open; // 내부 엔티티는 객체 참조 private Set<OptionGroup> optionGroups = new HashSet<>(); public Menu(ShopId shopId, String name, String description) { this(null, shopId, name, description, false, new HashSet<>()); } }
Java
복사
•
Shop Aggregate
public class Shop extends AggregateRoot<Shop, ShopId> { private ShopId id; private String name; private Money minOrderPrice; // 내부 값 객체는 객체 참조 private Map<DayOfWeek, TimePeriod> operatingHours; public Shop( String name, boolean open, Money minOrderPrice, Map<DayOfWeek, TimePeriod> operatingHours) { this(null, name, open, minOrderPrice, operatingHours); } }
Java
복사

2.2. 기능 (1) 메뉴 공개하기

•
불변식 통제는 루트의 책임
◦
Menu Aggregate
•
기능은 애그리거트 내부의 객체들 사이의 협력으로 구현
◦
책임 주도 설계

2.2.1. 불변식

•
판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
•
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
•
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.
•
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.

2.2.2. 구현

•
Menu Aggregate
◦
메뉴 판매 시작 open
▪
불변식 validation 진행
◦
countOfFreeOptionGroups
▪
OptionGroup Entity에 협력 요청
public class Menu extends AggregateRoot<Menu, MenuId> { public void open() { if (optionGroups.isEmpty()) { throw new IllegalStateException( "옵션그룹은 하나 이상 존재해야 합니다."); } if (countOfFreeOptionGroups() < 1) { throw new IllegalStateException( "금액이 설정된 옵션그룹이 최소 1개는 등록되어 있어야 합니다."); } if (countOfMandatoryOptionGroups() == 0) { throw new IllegalStateException( "필수 옵션그룹은 최소 1개는 등록되어 있어야 합니다."); } if (countOfMandatoryOptionGroups() > 3) { throw new IllegalStateException( "필수 옵션그룹은 3개까지만 등록가능 합니다."); } open = true; } private long countOfFreeOptionGroups() { return optionGroups.stream().filter(group -> group.isFree()).count(); } }
Java
복사
•
OptionGroup Entity
◦
isFree - 가격이 0원인 옵션이 있는지를 Option VO에게 협력 요청
@Getter public class OptionGroup extends DomainEntity<OptionGroup, OptionGroupId> { private OptionGroupId id; private String name; private boolean mandatory; private Set<Option> options = new HashSet<>(); public OptionGroup( OptionGroupId id, String name, boolean mandatory, Set<Option> options) { this.id = id; this.mandatory = mandatory; setName(name); setOptions(options); } private void setName(String name) { if (name == null || name.length() < 2) { throw new IllegalArgumentException( "옵션그룹명은 길이는 최소 2글자 이상이어야 합니다."); } this.name = name; } private void setOptions(Set<Option> options) { if (options == null || options.size() < 1) { throw new IllegalArgumentException( "옵션의 길이는 최소 1개 이상이어야 합니다."); } this.options.clear(); this.options = options; } public boolean isFree() { return options.stream().allMatch(Option::isFree); } }
Java
복사
•
Option Value Object
@Getter public class Option extends ValueObject<Option> { private String name; private Money price; public Option(String name, Money price) { if (name == null || name.length() < 2) { throw new IllegalArgumentException("옵션명은 2글자 이상이어야 합니다."); } if (price == null) { throw new NullPointerException("옵션 가격은 null이여서는 안됩니다."); } this.name = name; this.price = price; } public boolean isFree() { return Money.ZERO.equals(price); } @Override protected Object[] getEqualityFields() { return new Object[] { name, price }; } }
Java
복사

2.3. 기능 (2) 옵션 그룹 추가하기

2.3.1. 불변식

•
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.
•
Menu 안에서 OptionGroup의 이름은 중복되지 말아야 한다.

2.3.2. 구현

•
OptionGroup Entity에 협력 요청
◦
isMandatory
◦
countOfMandatoryOptionGroups
public class Menu extends AggregateRoot<Menu, MenuId> { public void addOptionGroup(OptionGroup optionGroup) { if (optionGroup == null) { throw new IllegalArgumentException(); } if (isOpen() && optionGroup.isMandatory() && countOfMandatoryOptionGroups() >= 3) { throw new IllegalArgumentException("..."); } if (groups.stream().anyMatch( group -> group.getName().equals(optionGroup.getName()))) { throw new IllegalArgumentException("..."); } groups.add(optionGroup); } private long countOfMandatoryOptionGroups() { return groups.stream().filter(group -> group.isMandatory()).count(); } }
Java
복사

2.4. 기능 (3) 옵션 그룹 삭제하기

2.4.1. 불변식

•
판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
•
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
•
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.

2.4.2. 구현

•
옵션그룹삭제 removeOptionGroup
◦
불변식 validation 진행
public class Menu extends AggregateRoot<Menu, MenuId> { public void removeOptionGroup(OptionGroupId optionGroupId) { OptionGroup optionGroup = optionGroups.stream() .filter(group -> group.getId().equals(optionGroupId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException()); if (!isOpen()) { optionGroups.remove(optionGroup); return; } if (optionGroups.size() == 1) { throw new IllegalArgumentException( "옵션그룹은 최소 1개는 등록되어 있어야 합니다."); } if (!optionGroup.isFree() && countOfFreeOptionGroups() == 1) { throw new IllegalArgumentException( "금액이 설정된 옵션그룹이 최소 1개는 등록되어 있어야 합니다."); } if (optionGroup.isMandatory() && countOfMandatoryOptionGroups() == 1) { throw new IllegalArgumentException( "필수 옵션그룹은 최소 1개는 등록되어 있어야 합니다."); } optionGroups.remove(optionGroup); } }
Java
복사
•
OptionGroup 엔티티 삭제 시 지역 식별자 이용하여 탐색
@Getter public class OptionGroup extends DomainEntity<OptionGroup, OptionGroupId> { // OptionGroup은 엔티티 private OptionGroupId id; ... } public class Menu extends AggregateRoot<Menu, MenuId> { // Menu 애그리거트는 지역 식별자를 이용하여 OptionGroup 탐색 public void removeOptionGroup(OptionGroupId optionGroupId) { OptionGroup optionGroup = optionGroups.stream() .filter(group -> group.getId().equals(optionGroupId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException()); ... } }
Java
복사

2.4. 기능 (4) 옵션 그룹 & 옵션 이름 변경

2.4.1. 옵션 그룹 이름 변경

•
OptionGroup 기본 의 이름을 반찬추가 선택 으로 변경하고 싶다면?
•
OptionGroup은 엔티티로 모델링
public class OptionGroup extends DomainEntity<OptionGroup, OptionGroupId> { private OptionGroupId id; ... }
Java
복사
•
OptionGroup은 엔티티이므로 연속성과 식별성을 의미함
◦
지역 식별자를 이용하여 엔티티를 찾은 후 이름 변경
public class Menu extends AggregateRoot<Menu, MenuId> { // 지역 식별자를 이용하여 해당 엔티티 탐색 public void changeOptionGroupName(OptionGroupId optionGroupId, String name) { if (groups.isEmpty()) { throw new IllegalArgumentException("옵션그룹이 비어있습니다."); } if (groups.stream().anyMatch(group -> group.getName().equals(name))) { throw new IllegalArgumentException("이름이 동일한 옵션그룹이 이미 존재합니다."); } // 지역 식별자를 이용하여 해당 엔티티 탐색 OptionGroup optionGroup = groups.stream() .filter(group -> group.getId().equals(optionGroupId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException()); // 엔티티 자체의 상태 변경 optionGroup.changeName(name); } }
Java
복사
•
OptionGroup은 엔티티이므로 ID로 추적
@RestController public class MenuController { @PutMapping("/menus/{menuId}/optionGroups/{optionGroupId}") public void changeOptionGroupName(@PathVariable("menuId") Long menuId, @PathVariable("optionGroupId") Long optionGroupId, @RequestBody OptionGroupNameChangeRequest request) { menuService.changeOptionGroupName(new MenuId(menuId), new OptionGroupId(optionGroupId), request.getName()); } } class OptionGroupNameChangeRequest { private String name; // getter/setter ... }
Java
복사

2.4.2. 옵션 이름 변경

•
Option은 값 객체로 모델링
public class Option extends ValueObject<Option> { private String name; private Money price; ... }
Java
복사
•
Option의 이름을 변경하고 싶은 경우?
◦
값 비교를 통해 동일한 객체 찾음
◦
값 객체는 연속성이 없기 때문에 새로운 객체로 교체
•
Menu Aggregate
public class Menu extends AggregateRoot<Menu, MenuId> { // OptionGroup은 식별자로 탐색 // Option 값 객체는 값으로 탐색 public void changeOptionName(OptionGroupId optionGroupId, Option target, String optionName) { if (groups.isEmpty()) { throw new IllegalArgumentException("옵션그룹이 비어있습니다."); } // 지역 식별자를 이용해 OptionGroup 탐색 OptionGroup optionGroup = groups.stream() .filter(group -> group.getId().equals(optionGroupId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException()); // 엔티티의 상태 변경 optionGroup.changeOptionName(target, optionName); } }
Java
복사
•
OptionGroup Entity
public class OptionGroup extends DomainEntity<OptionGroup, OptionGroupId> { // Option 값 객체는 값으로 탐색 public void changeOptionName(Option target, String optionName) { Option option = options.stream() // 값만 같으면 같은 객체 .filter(each -> each.equals(target)) .findFirst() .orElseThrow(IllegalArgumentException::new); // 기존의 Option 객체는 삭제 후 이름이 변경된 새로운 객체 추가 options.remove(option); options.add(target.changeName(optionName)); } }
Java
복사
•
Option Value Object
public class Option extends ValueObject<Option> { public Option changeName(String name) { // 새로운 객체 생성 return new Option(name, this.price); } }
Java
복사
•
Option의 이름 변경
◦
MenuId, OptionGroupID는 엔티티 이므로 아이디로 추적
◦
Option은 값 객체이므로 값으로 추적
@RestController public class MenuController { // 엔티티 이므로 아이디로 추적 @PutMapping("/menus/{menuId}/optionGroups/{optionGroupId}/options") public void changeOptionName(@PathVariable("menuId") Long menuId, @PathVariable("optionGroupId") Long optionGroupId, @RequestBody OptionNameChangeRequest request) { menuService.changeOptionName( new MenuId(menuId), new OptionGroupId(optionGroupId), // 값 객체이므로 Option 값을 이용하여 추적 new Option(request.getCurrentName(), Money.wons(request.getCurrentPrice())), request.getNewName()); } } class OptionNameChangeRequest { private String currentName; private Long currentPrice; private String newName; // getter/setter ... }
Java
복사

2.5. ORM Entity와 DDD Entity

•
ORM 엔티티로 DDD 엔티티 구현 가능
◦
침투적이지 않음
▪
테스트 쉽게 할 수 있는지? / 외부 의존성이 있는지?
◦
레이지 로딩 (런타임에서 도는거) 신경써야함
▪
성능 튜닝을 위한 뭔가를 밀어넣는순간 신경써야함
▪
최대한쓰지말 것
•
엔티티를 분리하는 것은 선호하지 않음
◦
함께 여러번 바꾸는게 힘들다