[DDD] 4장. 리포지터리와 모델 구현

매빈·2023년 3월 19일
0

1. 리포지터리와 모델 구현


4.1 JPA를 이용한 리포지터리 구현

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭처 영역에 속함

4.1.2 리포지터리 기본 기능 구현

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회하기
    • 애그리거트 저장하기
  • 리포지터리 인터페이스 형식
    public interface OrderRepository {
    		  // 인터페이스는 애그리거트 루트를 기준으로 작성
        // 아래의 경우 Order
        Order findById(OrderNo no);
        // save() 메서드는 전달받은 에그리거트를 저장함.
        void save(Order order);
    }
  • 애그리거트를 조회하는 기능 이름을 지을 때의 보편적인 규칙: findBy프로퍼티이름(프로퍼티 값) 형식
  • 위의 인터페이스를 구현한 클래스를 JPA EntityManager를 이용하여 기능 구현하기
package shop.order.infra;

import org.springframework.stereotype.Repository;
import shop.order.domain.Order;
import shop.order.domain.OrderNo;
import shop.order.domain.OrderRepository;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Repository
public class JpaOrderRepository implements OrderRepository {
	@PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public Order findById(OrderNo id) {
    	// EntityManager의 find 메서드를 이용하여 ID로 애그리거트를 검색
    	return entityManager.find(Order.class, id);
    }
    
    @Override
    public void save(Order order) {
	    // EntityManager의 persist 메서드를 이용하여 애그리거트를 저장
    	entityManager.persist(order);
    }
}

➕ 알아두기
다수의 개발자는 스프링과 JPA로 구현할 때 스프링 데이터 JPA를 사용하므로 리포지터리 인터페이스만 정의하면 나머지 리포지터리 구현 객체는 스프링 데이터 JPA가 알아서 만들어줌. 따라서 리포지토리 인터페이스를 구현한 클래스를 직접 작성할 일은 거의 없음.

  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요 X
    ex.

    public class ChangeOrderService {
        @Transactional
        // 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행됨
        public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
            Optional<Order> orderOpt = orderRepository.findById(no);
            Order order = orderOpt.orElseThrow(() -> new OrderNotFoundException());
            order.changeShippingInfo(newShippingInfo);
        }
        ...
    }
  • 메서드 실행이 끝나면 트랜잭션 커밋 ➡️ JPA; 트랜잭션 범위에서 변경된 객체 데이터를 DB에 반영하기 위해 UPDATE 쿼리 실행

  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때: findBy(조건 대상이 되는 프로퍼티 이름)

  • JPQL을 이용한 findByOrdererId 메서드 구현

    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int fetchSize) {
        TypedQuery<Order> query = entityManager.createQeury(
            "select o from Order o "+
            "order by o.number.number desc",
        Order.class
        );
        query.setParameter("ordererId", ordererId);
        query.setFirstResult(startRow);
        query.setMaxResults(fetchSize);
        return query.getResultList();
    }
  • 애그리거트를 삭제한 기능 구현

    // 삭제할 애그리거트 객체를 파라미터로 전달 받기
    public interfate OrderRepository {
        ...
        public void delete(Order order);
    }
    // EntityManager의 remove() 메서드를 이용한 삭제 기능 구현
    public class JpaOrderRepository implements OrderRepository {
        @PersistenceContext
        private EntityManager entityManager;
        ...
        @Override
        public void delete(Order order) {
            entityManager.remove(order);
        }
    }

➕ 삭제 기능
삭제 요구사항이 있더라도 데이터를 실제로 삭제하는 경우는 많지 않음. 사용자가 삭제 기능을 실행할 때 삭제 플래그를 사용하여 데이터를 화면에 보여줄지 여부를 결정하는 방식으로 구현하기.


4.2 스프링 데이터 JPA를 이용한 리포지터리 구현

  • 스프링 데이터 JPA는 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 만들어 Spring Bean으로 등록해줌.

  • 스프링 데이터 JPA를 이용하여 만든 ORderRepository 인터페이스

    import org.springframework.data.repository.Repository;
    
    import java.util.Optional;
    
    public interface OrderRepository extends Repository<Order, OrderNo> {
        Optional<Order> findById(OrderNo id);
    
        void save(Order order);
    }
  • 스프링 빈에 등록된 리포지토리를 주입받아 사용하는 방법

@Service
public class CancelOrderService {
	private OrderRepository roderRepository;
    ...
    
    public CancelOrderService(OrderRepository orderRepository, ...) {
    	this.orderRepository = orderRepository;
        ...
    }
    
    @Transactional
    public void cancel(OrderNo orderNo, Canceller caneller) {
    	Order order = orderRepository.findById(orderNo)
        	.orElseThrow(() -> new NoOrderException());
        if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
        	throw new NoCancellablePermission();
        }
        order.cancel();
    }
}
  • 스프링 데이터 JPA를 사용하기 위한 메서드 작성 규칙
    • OrderREpository를 기준으로 엔티티를 저장하는 메서드
      • Order save(Order entity)
        • void save(Order entity)
    • 식별자를 이용하여 엔티티를 조회할 때
      • findById() : 식별자에 해당하는 엔티티가 존재하지 않을 때, null 리턴
        • Optional findById(OrderNo id) : 식별자에 해당하는 엔티티가 존재하지 않을 때, 값이 없는 Optional 리턴

2. 엔티티와 밸류 매핑


4.3 매핑 구현

4.3.1 엔티티와 밸류 기본 매핑 구현

  • 애그리거트와 JPA 매핑을 위한 기본 규칙: @Entity로 매핑

  • 한 테이블에 엔티티와 밸류 데이터가 같이 있을 경우,

    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 엔티티와 밸류가 한 테이블로 매핑

    @Entity
    @Tagble(name = "purchase_order")
    public class Order {
      // ...
    
      @Embedded
      private Orderer orderer;
    
      // ...
    }
    
    @Embeddable
    public class Orderer {
    
      // MemberId에 정의된 칼럼 이름을 변경하기 위해
      // @AttributeOverride 애노테이션 사용
      @Embedded
      @AttributeOverrides(
          @AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
      )
      private MemberId memberId;
    
      @Column(name = "orderer_name")
      private String name;
    
      // ...
    }
    
    @Embaddable
    public class MemberId implements Serializable {
    
      @Column(name = "member_id")
      private String id;
      // ...
    }

4.3.2 기본 생성자

  • Receiver가 불변 타입일 경우: 값을 변경하는 set 메서드 제공 X
  • JPA에서 @Entity@Embeddable로 클래스 매핑할 시 기본 생성자를 제공해야 함
@Embeddable
public class Receiver {
 @Column(name = "receiver_name")
 private String name;
  
 @Column(name = "receiver_phone")
 private String phone;
 
  // 기본 생성자를 다른 코드에서 사용하면 값이 없는 온전하지 못한 객체를 만들어내므로 protected로 선언
 protected Receiver() {} // JPA를 적용하기 위한 기본 생성자 추가
  
 public Receiver(String name, String phone) {
 	this.name = name;
  	this.phone = phone;
 }
}

4.3.3 필드 접근 방식 사용

  • 필드, 메서드의 두 가지 방식으로 매핑 처리
  • 메서드 방식을 사용하려면 get/set 메서드가 추가되어야 함
  • set 메서드는 내부 데이터를 외부에서 변경할 수 있는 수단이 되기 때문에 캡슐화를 깨는 원인이 될 수 있음. 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 함.
  • 엔티티를 객체가 제공할 기능 중심으로 구현하도록 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 말아야 함.

4.3.4 AttributeConverter를 이용한 밸류 매핑 처리

  • 구현방식에 따라 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있음.
  • ex. Length가 길이 값과 단위의 두 프로퍼티를 갖고 있을 때, DB 테이블에는 한 개 칼럼에 '1000mm' 와 같은 형식으로 저장 가능.
    ➡️ 이때 사용할 수 있는 것이 AttributeConverter
  • AttributeConverter: 밸류타입과 칼럼 데이터간의 변환을 처리하는 기능을 지원함

3. 밸류 컬렉션 매핑


4.3 매핑 구현

4.3.5 밸류 컬렉션: 별도 테이블 매핑

  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection@CollectionTable을 함께 사용

    @Entity
    @Table(name = "purchase_order")
    public class Order {
    
    // ...
    @ElementCollection
    @CollectionTable(name = "order_line", joinColumns = 	@JoinColumn(name = "order_number"))
    @OrderColumn(name = "line_idx")
    private list<OrderLine> orderLines;
    
    }
    
    @Embeddable
    public class OrderLine {
    
      // ...
      @Embedded
      private ProductId productId;
    
    }

4.3.6 밸류 컬렉션: 한 개 칼럼 매핑

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있음

  • ex. 도메인 모델에는 이메일 주소 목록을 Set으로 보관하고 DB에는 한 개 컬럼에 콤마로 구분해서 저장해야 할 때, AttributeConverter 사용. 단, AttributeConverter를 사용하려면 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 함.

    public class EmailSet {
    
      private Set<Email> emails = new HashSet<>();
    
      private EmailSet() {
      }
    
      private EmailSet(Set<Email> emails) {
        this.emails.addAll(emails);
      }
    
      public Set<Email> getEmails() {
        return Collections.unmodifiableSet(emails);
      }
    }
    @Converter
    public class EmailSetConverter implements AttributeConveter<EmailSet, String> {
    
      @Override
      public String convertToDatabaseColumn(EmailSet attribute) {
        if (attribute == null) {
          return null;
        }
        return attribute.getEmails().stream()
            .map(Email::toString)
            .collect(Collectors.joining(","));
      }
    
      @Override
      public EmailSet convertToEntityAttribute(String dbData) {
        if (dbData == null) {
          return null;
        }
        String[] emails = dbData.split(",");
        Set<Email> emailSet = Arrays.stream(emails)
            .map(value -> new Email(value))
            .collect(toSet());
        return new EmailSet(emailSet);
      }
    
    }
    @Column(name = "emails")
    @Convert(converter = EmailSetConverter.class)
    private EmailSet emailSet;

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입을 만들 수도 있음.

  • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너태이션을 사용.

    @Embeddable
    public class OrderNo implements Serializable {
    
      @Column(name = "order_number")
      private String number;
    
      public boolean is2ndGeneration() {
        return number.startsWith("N");
      }
    
      // ...
    }
  • 밸류 타입으로 식별자를 구현할 경우 식별자에 기능을 추가할 수 있음.

  • JPA 에서 식별자 타입은 Serializable 타입이어야 함.

  • 내부적으로 엔티티를 비교시 사용하는 equals()hashcode() 메서드도 알맞게 구현해야 함.

4.3.8 별도 테이블에 저장하는 밸류 매핑

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류.

  • 만약 밸류가 아니라 엔티티가 확실하다면 다른 애그리거트는 아닌지 확인해야 함. 자신만의 독자적인 라이프사이클을 갖는다면 다른 애그리거트일 가능성이 높음.

  • 밸류인지 엔티티인지 구분하는 방법: 고유 식별자를 갖는지 확인하기

  • 별도 테이블에 PK가 있다고해서 테이블과 매핑되는 애그리거트가 항상
    고유 식별자를 갖는 것은 아님.

    • ex. Article 과 ArticleContent가 나뉠 경우, ArticleContent의 ID는 식별자이긴 하지만, Article 데이터와 연결하기 위함일 뿐임.
      ➡️ 게시글의 특정 프로퍼티를 별도 테이블에 보관한 것으로 접근하기.
  • 밸류를 매핑한 테이블을 지정하기 위해 @SecondaryTable@AttributeOverride를 사용

    @Entity
    @Table(name = "article")
    // @SecondaryTable 을 이용하면 아래 코드를 실행할 때 두 테이블을 조인해서 데이터를 조회함.
    // 그러나 @SecondaryTable 을 사용하면 목록 화면에 보여줄 Article 을 조회할 때 article_content 테이블까지 조인해서 데이터를 읽어오게 됨(원하는 결과 x)
    @SecondaryTable(
        name = "article_content",
        pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
      @Id
      private Long id;
    
      private String title;
    
      @AttributeOverrides({
          @AttributeOverride(name = "content", column = @Column(table = "article_content", name = "content")),
          @AttributeOverride(name = "contentType", column = @Column(table = "article_content", name = "content_type"))
      })
      private ArticleContent content;
    
    }

    ➡️ ArticleContent 를 엔티티로 매핑하고 지연로딩을 활용할수도 있지만 좋은 방법은 아님.
    5장에서 조회 전용 쿼리를 실행하는 방법 알아보기

4.3.9 밸류 컬렉션을 @Entity로 매핑하기

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있음.
  • JPA
    • @Embeddable 타입의 클래스 상속 매핑을 지원하지 않음.
    • @Entity를 이용한 상속 매핑으로 처리하기.
  • 엔티티로 관리되므로 식별자 필드가 필요하고 타입 식별 칼럼을 추가해야 함.
    • @Inheritance 애너테이션 적용
    • strategy SINGLE_TABLE 사용
    • @DiscriminatorColumn 사용
    • @DiscriminatorValue 사용
  • 컬렉션에 clear()를 사용할 경우 다수의 DELETE 쿼리가 발생하므로 성능상 좋지 않을 수 있음
    ➡️ 대신 @Embeddable 매핑 단일 클래스를 사용할 경우 다형성을 포기하고 if-else 로 컬럼을 구분해야 함.
  • 코드 유지보수와 성능의 두 가지 측면을 고려해서 구현 방식을 선택해야 함.

4.3.10 ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑

  • 애그리거트 간 집합 연관은 성능상의 이유로 피해야 함.
    ➡️ 그럼에도 불구하고 요구사항을 구현하는 데 집합 연관을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있음.
@Entity
@Table(name = "product")
public class Product {
  // ID 참조를 이용한 애그리거트 간 단방향 M:N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정
  @EmbeddedId
  private ProductId id;

  // 차이점: 집합의 값에 밸류 대신 연관을 맺는 식별자가 옴
  // @ElementCollection을 이용하기 때문에 Product를 삭제할 때 매핑에 사용한 조인 테이블의 데이터도 함께 삭제됨.
  @ElementCollection
  @CollectionTable(name = "product_category",
      joinColumns = @JoinColumn(name = "product_id"))
  private Set<CategoryId> categoryIds;

}

4. 애그리거트 로딩 전략과 영속성 전파


4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할때 중요한 점은 애그리겉의 속한 객체가 모두 모여야 완전한 하나가 된다는 것.
  • 애그리거트는 개념적으로 하나여야 하지만, 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아님.
  • 애그리거트가 완전해야 하는 이유
    • 상태를 변경하는 기능을 실행할때 애그리거트 상태가 완전해야 함.
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요함.
      ➡️ 조회 전용 기능과 모델을 구현하는 방식을 사용하는것이 유리
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용 ➡️ 따라서, 다음 코드처럼
    실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
@Transactional
public void revmoeoptions(ProductId id,int optIdxToBeDeleted){
  //Product를 로딩. 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음
  Product product = productRepository.findByid(id);

  // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능
  product.removeOption(optIdxToBeDeleted);
}    
@Entity
public class Product {

  @ElementCollection(fetch = FetchType.LAZY)
  @CollectionTable(name = "product_option", joinColumns = @JoinColumn(name = "product_id"))
  @OrderColumn(name = "list_idx")
  private List<Option> options = new ArrayList<>();

  public void removeOption(int optIdx) {
    //실제 컬렉션에 접근할 때 로딩
    this.options.remove(optIdx);
  }

}
  • 상태를 변경하기 보다는 조회하는 빈도 수가 높음.
    ➡️ 애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필요 X
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 더 높음.
    ➡️ 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택하기.

4.5 애그리거트의 영속성 전파

  • 애그리거트는 완전한 상태여야 한다는 것은 조회할 때뿐만 아니라 저장하고 삭제할 때도 필요함.
    • 저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 함.
    • 삭제 메서드는 애그리거트 루트 뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 함.
  • @Embeddable 매핑 타입의 경우 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 됨.
  • 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정해야 함.
  • @OneToOne, @OneToManycascade 속성의 기본값이 없으므로 cascade 속성값으로 CascadeType.PERSIST, CascadeType.REMOVE를 설정

5. 식별자 생성 기능


4.6 식별자 생성 기능

  • 식별자 생성 방식
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 별도 서비스로 식별자 생성 기능을 분리해야 함.
    • 도메인 규칙이므로 도메인 영역에 위치시키기.
    • 리포지터리에 식별자 생성 규칙을 구현해도 됨. 인터페이스에 기능을 추가하고 구현 클래스에서 알맞게 구현하기.
  • DB 자동 증가 컬럼을 식별자로 사용할 경우 @GeneratedValue를 사용하며 DB에 INSERT 쿼리가 실행돼야 식별자가 생성됨.
  • JPA 의 식별자 생성 기능을 사용하는 경우에도 저장시점에 식별자를 생성함.

4.7 도메인 구현과 DIP

  • JPA 를 사용할 경우 도메인에 @Entity, @Table 등 구현 기술에 특화된 애너테이션을 사용하고 있음.
  • DIP에 따르면 이는 구현 기술에 속하므로 도메인 모델이 구현 기술인 JPA 에 의존하면 안됨.
  • Repository의 경우 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있으므로 이는 도메인이 인프라에 의존하는 것.
    ➡️ 도메인에서 구현 기술에 대한 의존을 없애려면 구현 클래스를 인프라에 위치시켜야 함.
  • DIP를 적용하는 이유: 저수준 구현이 변경되어도 고수준이 영향 받지 않도록 하기 위함. 이때, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않음.
    ➡️ 이렇게 거의 변경이 없는데도 변경을 미리 대비하는것은 오버엔지니어링일 수 있음!
  • DIP를 완전히 지키면 좋겠지만,
    • 개발 편의성과 실용성을 가져가면서 구조적인 유연함을 유지했으며,
    • 복잡도를 높이지 않으면서 구현 기술에 따른 제약이 낮다면
      ➡️ 합리적인 선택일 수 있다!

0개의 댓글