Search
🏗️

도메인 주도 설계의 사실과 오해 (5) 레포지토리와 기타패턴

Tags
Architcture
DDD
Study
Last edited time
2024/11/01 00:24
2 more properties
Search
도메인 주도 설계의 사실과 오해 - 요약 및 목차
도메인 주도 설계의 사실과 오해 - 요약 및 목차

1. 레파지토리 구현

1.1. Spring Data JPA를 이용한 Repository 구현

MenuJpaRepository는 JPA에 의존하는 인터페이로, JpaRepository를 확장하여 기본적인 CRUD 기능을 자동으로 제공받음
findByShopIdAndOpenIsTrue 메서드는 특정 ShopId에 해당하고 open이 true인 메뉴 목록을 조회하는 사용자 정의 메서드
public interface MenuJpaRepository extends JpaRepository<Menu, MenuId> { @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) List<Menu> findByShopIdAndOpenIsTrue(ShopId shopId); }
Java
복사

1.2. 기술 독립적인 인터페이스 정의

JPA에 의존하지 않으면서 데이터 저장소에 접근하기 위한 기술 독립적인 인터페이스를 정의
Repository 인터페이스는 기본적인 add, find, remove 메서드를 정의하여 구현체가 데이터 저장소에 대한 동작을 추상화할 수 있도록 함
MenuRepositoryRepository를 확장하며 findOpenMenusIn 메서드를 선언하여 메뉴 조회의 명세를 기술
public interface Repository<AR extends AggregateRoot<AR, ARID>, ARID> { void add(AR root); AR find(ARID id); void remove(ARID id); void remove(AR root); } public interface MenuRepository extends Repository<Menu, MenuId> { List<Menu> findOpenMenusIn(ShopId shopId); }
Java
복사

1.3. 커스텀 Repository 구현

이 클래스는 JpaRepository에 의존하지만, 특정 엔티티가 아닌 Repository 인터페이스를 통해 기본적인 CRUD 작업을 추상화함
BaseRepositoryRepository 인터페이스의 기본 구현체로, JPA 기능을 활용하면서도 커스텀 인터페이스를 따르도록 구현된 추상 클래스
제네릭 타입을 사용하여 다양한 엔티티 타입과 식별자 타입을 처리할 수 있음
JpaRepository를 주입받아 실제 데이터베이스 조작을 담당
add, find, remove 메서드를 구현하여 JpaRepository의 기본 기능을 사용할 수 있도록 함
public abstract class BaseRepository <AR extends AggregateRoot<AR, ARID>, ARID, R extends JpaRepository<AR, ARID>> implements Repository<AR, ARID> { protected R repository; public BaseRepository(R repository) { this.repository = repository; } public void add(AR root) { repository.save(root); } public AR find(ARID id) { return repository.findById(id).orElse(null); } public void remove(ARID id) { repository.deleteById(id); } public void remove(AR root) { repository.delete(root); } }
Java
복사

1.4. Menu Repository 구현

이 구조에서는 JPA에 의존적인 코드를 DefaultMenuRepository에 격리하고, 상위 레이어에서는 MenuRepository 인터페이스만을 사용하도록 하여 기술 독립성을 유지함
MenuRepository 인터페이스를 구현한 DefaultMenuRepositoryBaseRepository를 확장하여 MenuMenuId를 사용하는 구체적인 리포지토리 구현체
생성자에서 MenuJpaRepository를 받아 상위 클래스의 repository 필드에 설정
findOpenMenusIn 메서드는 JPA를 사용하는 findByShopIdAndOpenIsTrue 메서드를 호출하여 특정 조건에 맞는 메뉴 목록을 조회함
public interface MenuRepository extends Repository<Menu, MenuId> { List<Menu> findOpenMenusIn(ShopId shopId); } public class DefaultMenuRepository extends BaseRepository<Menu, MenuId, MenuJpaRepository> implements MenuRepository { public DefaultMenuRepository(MenuJpaRepository repository) { super(repository); } public List<Menu> findOpenMenusIn(ShopId shopId) { return repository.findByShopIdAndOpenIsTrue(shopId); } }
Java
복사

2. 팩토리

2.1. 팩토리의 정의 및 특징

어떤 객체나 전체 AGGREGATE를 생성하는 일이 복잡해 지거나 내부 구조를 너무 많이 드러내는 경우 FACTORY가 캡슐화를 제공해줌
일반적으로 객체의 생성과 조립은 도메인에서는 아무런 의미를 갖지 않지만, 구현 측면에서 반드시 필요한 것이다. 이러한 문제를 해결하기 위해 우리는 ENTITY나 VALUE OBJECT, SERVICE가 아닌 구조물을 도메인 설계에 추가해야 함
복잡한 객체와 AGGREGATE의 인스턴스를 생성하는 책임을 별도의 객체, 즉 자기 자신은 도메인 모델 내에서 아무런 책임도 맡지 않을 수도 있지만 여전히 도메인 설계의 일부분을 차지하는 객체로 옮겨라. 모든 복잡한 객체 조립 과정을 캡슐화하고 클라이언트가 인스턴스화되는 객체의 구상 클래스를 참조할 필요가 없는 인터페이스를 제공하라
전체 AGGREGATE를 하나의 단위로 생성하여 그것의 불변식이 이행되게 하라.

2.1. 장바구니와 주문 예시

2.1.1. Cart가 Order의 Factory 인 경우

Aggregate와 하위 객체들의 생성 책임을 도메인 모델에서 가지고 있음
Cart → Order
CartLineItem → OrderLineItem
CartOptionGroup → OrderOptionGroup
CartOption → OrderOption
public class Cart extends AggregateRoot<Cart, CartId> { public Order placeOrder() { return new Order(userId, shopId, toOrderLineItems()); } private List<OrderLineItem> toOrderLineItems() { return items.stream().map(CartLineItem::toOrderLineItem).collect(Collectors.toList()); } } public class CartLineItem extends DomainEntity<CartLineItem, CartLineItemId> { OrderLineItem toOrderLineItem() { return new OrderLineItem(menuId, menuName, menuCount, toOrderOptionGroups()); } private List<OrderOptionGroup> toOrderOptionGroups() { return groups.stream().map(CartOptionGroup::toOrderOptionGroup).collect(Collectors.toList()); } } public class CartOptionGroup extends DomainEntity<CartOptionGroup, CartOptionGroupId> { OrderOptionGroup toOrderOptionGroup() { return new OrderOptionGroup(name, toOrderOptions()); } private List<OrderOption> toOrderOptions() { return options.stream().map(CartOption::toOrderOption).collect(Collectors.toList()); } } public class CartOption extends ValueObject<CartOption> { OrderOption toOrderOption() { return new OrderOption(name, price); } }
Java
복사

2.1.2. 독립 Factory 추가

의존성 문제가 커진다면 독립적인 팩토리를 추가하여 복잡한 도메인 객체 생성 과정을 캡슐화 & 위임

2.2. 기타 코멘트

클래스가 인스턴스를만드는곳이 결합도가 높음
어디선가 무언가를 만들어야함
디펜던시가 많이 걸릴 것
팩토리가 그 역할을 함

3. 서비스

3.1. 서비스의 정의 및 특징

때때로 그것은 사물이 아닐 뿐이다. 설계가 매우 명확하고 실용적이더라도 개념적으로 어떠한 객체에도 속하지 않는 연산이 포함될 때가 있다. 이러한 문제를 억지로 해결하려 하기보다는 문제 자체의 면면에 따라 SERVICE를 명시적으로 포함할 수 있다.
도메인의 중대한 프로세스나 변환과정이 ENTITY나 VALUE OBJECT의 고유한 책임이 아니라면 연산을SERVICE로 선언되는 독립 인터페이스로서 모델에 추가하라.
모델의 언어라는 측면에서 인터페이스를 정의하고 연산의 이름이 UBIQUITOUS LANGUAGE의 일부를 이루고 SERVICE가 상태를 갖지 않게 하라.
잘 만들어진 SERVICE에는 다음의 세 가지 특징이 있다:
1.
연산이 원래부터 ENTITY나 VALUE OBJECT의 일부를 구성하는 것이 아니라 도메인 개념과 관련되어 있다.
2.
인터페이스가 도메인 모델의 외적 요소의 측면으로 정의된다.
3.
연산이 상태를 갖지 않는다.
세밀한 구성 단위의 도메인 객체는 도메인에서 지식이 새어 나오게 하여 도메인 객체의 행위를 조정하는 애플리케이션 계층으로 흘러가게 할 수 있다. 그렇게 되면 고도로 세분화된 상호작용의 복잡함은 결국 애플리케이션 계층에서 처리되며, 도메인 계층에서 사라진 도메인 지식이 애플리케이션 코드나 사용자 인터페이스 코드로 스며들게 된다. 도메인 서비스를 적절히 도입하면 계층 간에 경계를 선명하게 하는데 도움될 수 있다.
도메인 주도 설계의 전제 조건은 도메인 구현을 격리하는 것
도메인 서비스가 이를 도울 수 있음

3.2. 할인 적용 애플리케이션 서비스 예시

할인 적용 여부를 결정하는 도메인 로직 누수
도메인 로직이 애플리케이션 레이어에 위치함
public class PromotionApplicationService { public Optional<BenefitDTO> offer(CartId cartId, PromotionId promotionId) { Cart cart = cartRepository.find(cartId); Promotion promotion = promotionRepository.find(promotionId); PromotionLimit limit = promotionLimitRepository.find(promotionId); // 할인 수량 초과 여부 체크 if (limit.isExceeded(promotionId)) { return Optional.empty(); } cart.apply(promotion); ... } }
Java
복사
도메인 서비스 추출
할인 적용 여부 결정을 도메인 서비스로 추출
기존의 애플리케이션 레이어에서는 도메인 서비스를 호출
public class PromotionDomainService { public void offer( Cart cart, Promotion promotion, PromotionLimit promotionLimit) { // 할인 수량 초과 여부 체크 if (limit.isExceeded(promotionId)) { return Optional.empty(); } cart.apply(promotion); ... } }
Java
복사
public class PromotionApplicationService { public Optional<BenefitDTO> offer(CartId cartId, PromotionId promotionId) { Cart cart = cartRepository.find(cartId); Promotion promotion = promotionRepository.find(promotionId); PromotionLimit limit = promotionLimitRepository.find(promotionId); // 할인 수량 초과 여부 체크 promotionService.offer(cart, promotion, limit); cart.apply(promotion); ... } }
Java
복사

3.3. 기타 코멘트

특정 도메인 로직을 엔티티 / 값 객체에게 주기 애매할 때 서비스 사용
일단 애플리케이션 서비스에 둬도 괜찮다.
애매하게 도메인 서비스로 분리하면 애물단지로 전락할 수 있음
중복이 발생하면 그때 분리하자

4. 도메인 이벤트

4.1. 트랜잭션 당 하나의 애그리거트

다른 애그리거트는 별도의 트랜잭션에서 업데이트
이때 도메인 이벤트 활용

4.2. 도메인 이벤트

스프링을 사용한다면
애그리거트 루트 클래스에 도메인 이벤트 등록 로직 추가 가능
public abstract class AggregateRoot<T extends DomainEntity<T, TID>, TID> extends DomainEntity<T, TID> { @Transient private final transient List<Object> domainEvents = new ArrayList(); public AggregateRoot() { } protected void registerEvent(T event) { Assert.notNull(event, "Domain event must not be null"); this.domainEvents.add(event); } @AfterDomainEventPublication protected void clearDomainEvents() { this.domainEvents.clear(); } @DomainEvents protected Collection<Object> domainEvents() { return Collections.unmodifiableList(this.domainEvents); } }
Java
복사