도메인 주도 개발 시작하기 - 4. 리포지터리와 모델 구현

 Date: 2022-10-03

4.1 JPA를 이용한 리포지터리 구현
4.2 스프링 데이터 JPA를 이용한 리포지터리 구현
4.3 매핑 구현
4.4 애그리거트 로딩 전략
4.5 애그리거트의 영속성 전파
4.6 식별자 생성 기능
4.7 도메인 구현과 DIP

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

4.1.1) 모듈 위치

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

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회하기
    • 애그리거트 저장하기
      public interface OrderRepository {
          Order findById(OrderNo no);
        
          // null을 사용하고 싶지 않다면 Optional을 사용해도 된다
          Optional<Order> findById(OrderNo no);
        
          void savd(Order order);
      }
    
  • 리포지터리 구현 클래스
    • 실무에서는 스프링 데이터 JPA를 사용하기 때문에 리포지터리 구현 클래스를 직접 작성할 일은 거의 없다.
      @Repository
      public class JpaOrderRepository implements OrderRepository {
          @PersistenceContext
          private EntityManager em;
        
          @Override
          public Order findById(OrderNo no) {
              return em.find(Order.class, no);
          }
        
          @Override
          public void savd(Order order) {
              em.persist(order);
          }
      }
    
  • JPA를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영해 준다.

      @Transactional
      public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
          Optional<Order> orderOpt = orderRepository.findById(no);
          Order order = orderOpt.orElseThrow(OrderNotFoundException::new);
          order.changeShippingInfo(newShippingInfo);
      }
    
    • changeShippingInfo() 메서드 실행이 끝나면 트랜잭션을 커밋하는데, 이때 JPA는 트랜잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 UPDATE 쿼리를 실행한다.
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.

      public interface OrderRepository {
          List<Order> findByOrdererId(String ordererNo, int startRow, int size);
      }
    
    • ID 외에 다른 조건으로 애그리거트를 조회할 때는 JPA의 Criteria나 JPQL을 사용할 수 있다.

        // JPQL을 사용하여 구현한 예시
        public List<Order> findByOrdererId(String ordererId, int startRow, int fetchSize) {
            TypedQuery<Order> query = em.createQuery("select o "
                    + " from Order o"
                    + " where o.orderer.memberId.id = :ordererId"
                    + " order by o.orderNo.number desc", Order.class);
            query.setParameter("ordererId", ordererId);
            query.setFirstResult(startRow);
            query.setMaxResults(fetchSize);
            return query.getResultList();
        }
      
  • 실무에서 삭제 요구사항이 있더라도 실제도 데이터를 삭제하는 경우는 많지 않다.
    • 관리자 기능에서 삭제한 데이터를 조회하거나, 데이터 원복을 위해 일정 기간동안 보관하기 때문이다.
    • 이런 경우, 삭제 플래그를 사용해서 데이터를 화면에서 보여줄지 여부를 결정하는 방식으로 구현한다.

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

  • 스프링과 JPA를 함께 적용할 때는 스프링 데이터 JPA를 사용한다.
  • 리포지터리 인터페이스를 직접 구현하지 않아도 되기 때문에 개발자는 리포지터리를 쉽게 정의할 수 있다.
  • 스프링 데이터 JPA는 규칙에 맞게 리포지터리 인터페이스를 정의하면, 리포지터리를 구현한 객체를 알아서 만들어 스프링 빈으로 등록해준다.
    • 엔티티를 저장
      • Order save(Order entity)
      • void save(Order entity)
    • 식별자를 이용해서 엔티티를 조회
      • Order findById(OrderNo id) : 엔티티가 존재하지 않을 경우 null을 리턴
      • Optional<Order> findById(OrderNo id) : 엔티티가 존재하지 않을 경우 Optional을 리턴
    • 특정 프로퍼티를 이용해서 엔티티를 조회
      • List<Order> findByOrderer(Orderer orderer)
    • 중첩 프로퍼티를 이용해서 엔티티를 조회
      • List<Order> findByOrdererMemberId(MemberId memberId) : Orderer.memberId 사용
    • 엔티티를 삭제
      • void delete(Order order)
      • void deleteById(OrderNo id)

4.3 매핑 구현

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑을 설정한다.
    • 밸류는 @Embeddable로 매핑을 설정한다.
    • 밸류 타입 프로퍼티는 @Embedded로 매핑을 설정한다.
    • 컬럼 이름과 실제 컬럼 이름이 다른 경우 @AtrributeOverrides 애너테이션을 사용한다.

        @Embeddable
        public class Orderer {
            @Embedded
            @AttributeOverrides(
                @AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
            )
            private MemberId memberId;
            ...
        }
      
        @Embeddable
        public class MemberId {
            @Column(name = "member_id")
            private int id;
            ....
        }
      

4.3.2) 기본 생성자

  • JPA에서 @Entity@Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다.
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 때문이다.
    • 엔티티와 밸류 객체는 객체를 생성할 때 필요한 파라미터를 전달받아 불변 타입으로 생성하고, 기본 생성자는 JPA 프로바이더가 객체를 생성할 때만 사용한다.
    • 따라서 기본 생성자를 다른 코드에서는 사용하지 못하도록 protected로 선언한다.

4.3.3) 필드 접근 방식 사용

  • JPA는 두 가지 방식으로 매핑을 처리할 수 있다.
    • 필드 방식 : @Access(AccessType.*FIELD*)
    • 메서드 방식 : @Access(AccessType.PROPERTY)
      • 메서드 방식을 사용하려면 get/set 메서드를 구현해야 한다.
      • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 기반으로 엔티티를 구현할 가능성이 높아진다.
      • set 메서드는 외부에서 데이터를 변경할 수 있는 수단이 되기 때문에 캡슐화를 깨는 원인이 된다.
      • 엔티티가 객체로서 역할을 하려면 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다. setStatus()cnacel(), setShippingInfo()changeShippingInfo()
        → 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
    • @Access를 이용해서 명시적으로 저근 방식을 지정하지 않으면, @Id, @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고, get 메서드에 위치하면 메서드 접근 방식을 선택한다.

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

  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 컬럼에 매핑하려면 @Embeddable 애너테이션으로는 처리할 수 없다.
  • AttributeConverter를 사용하면 밸류 타입과 컬럼 데이터 간의 변환을 처리할 수 있다.

      @Converter(autoApply = true)
      public class MoneyConverter implements AttributeConverter<Money, Integer> {
        
          @Override
          public Integer convertToDatabaseColumn(Money money) {
              return money == null ? null : money.getValue();
          }
        
          @Override
          public Money convertToEntityAttribute(Integer value) {
              return value == null ? null : new Money(value);
          }
      }
    
    • 첫번째 타입 파라미터는 밸류 타입이고, 두번째 타입 파라미터는 DB 타입이다.
    • @AttributeConverter 인터페이스를 구현한 클래스는 @Converter 애너테이션에을 적용한다.
      • @Converter의 autoApply 속성의 기본 값은 false 이고, MoneyConverter를 적용하려는 프로퍼티에 @Convert 애너테이션을 설정해야 한다.
        @Column(name = "total_amounts")
        @Convert(converter = MoneyConverter.class)
        private Money totalAmount;
      
      • @Converter(autoApply = true)로 설정하면 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용햔다.
        @Column(name = "total_amounts")
        private Money totalAmount;  // MoneyConverter를 적용해서 값 변환
      

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

  • List 타입을 이용해서 컬렉션을 프로퍼티로 지정할 수 있다.

      public class Order {
          private List<OrderLine> orderLines;
          ...
      }
    
  • Order 엔티티와 OrderLine 밸류의 매핑

    • 밸류 컬렉션을 저장하는 ORDER_LINE 테이블은 외부키(order_number)를 이용해서 엔티티에 해당하는 PURCHASE_ORDER 테이블을 참조한다.
    • List 타입의 컬렉션은 인덱스 값이 필요하므로 ORDER_LINE 테이블에는 인덱스 값을 저장하기 위한 컬럼(line_idx)가 존재한다.
    • 밸류 컬렉션을 별도 테이블로 매핑할 대는 @ElementCollection@CollectionTable을 함께 사용한다.

        @Entity
        @Table(name = "purchase_order")
        public class Order {
            ...
            @ElementCollection(fetch = FetchType.LAZY)
            @CollectionTable(name = "order_line", 
                             joinColumns = @JoinColumn(name = "order_number"))
            @OrderColumn(name = "line_idx")
            private List<OrderLine> orderLines;
            ....
        }
      
      • OrderLine 에서는 List의 인덱스 값을 저장하기 위한 프로퍼티가 존재하지 않는다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 리스트의 인덱스 값을 저장한다.
    • @CollectionTable 속성은 외부키로 사용할 컬럼을 지정한다. @JoinColumn에 외부 키로 사용할 컬럼을 지정하며, 두 개 이상인 경우 배열을 사용할 수 있다.

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개의 컬럼에 저장해야 할 때가 있다.
    • AttributeConverter를 사용하면 밸류 컬랙션을 한 개 컬럼으로 매핑할 수 있다.
    • 단, AttributeConverter를 사용하려면 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.
      public class EmailSet {
          private Set<Email> emails = new HashSet<>();
        
          public EmailSet(Set<Email> emails) {
              this.emails.addAll(emails);
          }
        
          public Set<Email> getEmails() {
              return Collections.unmodifiableSet(emails);
          }
      }
    
      @Converter
      public class EmailSetConverter implements AttributeConverter<EmailSet, String> {
          @Override
          public String convertToDatabaseColumn(EmailSet attribute) {
              if (attribute == null) return null;
              return attribute.getEmails().stream()
                      .map(email -> email.getAddress())
                      .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(Email::new)
                      .collect(Collectors.toSet());
              return new EmailSet(emailSet);
          }
      }
    
      @Column(name = "emails")
      @Convert(converter = EmailSetConverter.class)
      private EmailSet emailSet;
    

4.3.7) 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 수도 있다.
  • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용한다.

      @Entity
      @Table(name = "purchase_order")
      public class Order {
          @EmbeddedId
          private OrderNo orderNo;
          ...
      }
        
      @Embeddable
      public class OrderNo implements Serializable {
          @Column(name = "order_number")
          private String number;
          ...
      }
    
    • JPA에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다.
    • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가할 수 있다는 점이다.

        @Embeddable
        public class OrderNo implements Serializable {
            @Column(name = "order_number")
            private String number;
              
            public boolean is2ndGeneration() {
                return number.startsWith("N");
            }
            ...
        }
      

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해 봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 특히 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다.
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지 확인하는 것이다.
    • 식별자를 찾을 때 매핑을 위한 식별자와 고유 식별자를 구분해야 한다.
    • 별도의 테이블과 PK가 있다고 해서 항상 고유 식별자를 갖는 것은 아니다.
  • 밸류 타입은 @Embeddable로 매핑하는데, 밸류를 별도의 테이블로 지정하려면 @SecondaryTable@AttributeOverride 애너테이션을 사용한다.

      @Entity
      @Table(name = "article")
      @SecondaryTable(
          name = "article_content",
          pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
      )
      public class Article {
          @Id
          @GeneratedValue(strategy = IDENTITY)
          private Long id;
            
          private String titile;
            
          @Embedded
          @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;
      }
    
  • @SecondaryTable을 이용하면 두 테이블을 조인해서 데이터를 조회한다.

      // @SecondaryTable로 매핑된 article_content 테이블을 조인
      Article article = em.find(Article.class, 1L);
    
    • 하지만 게시글의 목록을 보여주는 화면에서 article_content 테이블의 데이터는 필요하지 않다.

    → ArticleContent를 엔티티로 매핑하고 지연로딩을 하는 것보다, 조회 전용 기능을 구현하는 것이 좋다.

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

  • 개념적으로 밸류인데 @Entity를 사용해야 할 때도 있다.
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
  • 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
    • 밸류 타입을 @Entity로 매핑하므로 식별자 매핑을 위한 필드를 추가해야 한다.
    • 구현 클래스를 구분하기 위한 타입 식별(discriminator) 컬럼을 추가해야 한다.
  • 상위 클래스 설정
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 컬럼 지정
      @Getter
      @Entity
      @Inheritance(strategy = SINGLE_TABLE)
      @DiscriminatorColumn(name = "image_type")
      @Table(name = "image")
      @NoArgsConstructor(access = PROTECTED)
      public abstract class Image {
          @Id
          @GeneratedValue(strategy = IDENTITY)
          @Column(name = "image_id")
          private Long id;
        
          @Column(name = "image_path")
          private String path;
        
          @Temporal(TemporalType.TIMESTAMP)
          @Column(name = "upload_time")
          private Date uploadTime;
        
          public Image(String path, Date uploadTime) {
              this.path = path;
              this.uploadTime = uploadTime;
          }
        
          public abstract String getURL();
          public abstract boolean hasThumbnail();
          public abstract String getTumbnailURL();
      }
    
  • 하위 클래스 설정

      @Entity
      @DiscriminatorValue("II")
      public class InternalImage extends Image {
          ...
      }
      @Entity
      @DiscriminatorValue("EI")
      public class ExternalImage extends Image {
          ...
      }
    
  • @Entity로 매핑한 밸류를 애그리거트 루트에 매핑
    • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해서 매핑을 처리한다.
    • Image는 독자적인 라이프사이클을 갖지 않으므로 casecadeorphanRemoval 속성을 지정한다.
      @Entity
      @Table(name = "product")
      @NoArgsConstructor(access = PROTECTED)
      public class Product {
          @EmbeddedId
          private ProductId id;
        
          private String name;
        
          @Convert(converter = MoneyConverter.class)
          private Money price;
        
          private String detail;
        
          @OneToMany(
                  cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
                  orphanRemoval = true
          )
          @JoinColumn(name = "product_id")
          @OrderColumn(name = "list_idx")
          private List<Image> images = new ArrayList<>();
        
          public void changeImages(List<Image> newImages) {
              images.clear();
              images.addAll(newImages);
          }
      }
    
  • @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제 과정이 효율적이지 않다.
    • 하이버네이트의 경우, clear() 메서드를 호출하면, select 쿼리로 대상 엔티티를 로딩하고, 개별 엔티티에 대해 delete 쿼리를 실행한다.
    • 데이터의 변경 빈도가 낮으면 괜찮겠지만 그렇지 않은 경우 성능에 문제가 될 수 있다.
  • @Embeddable 타입에 대한 컬렉션의 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다.
    • 성능을 위해 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현하려면 if-else를 써야 한다.
      @Getter
      @Embeddable
      @NoArgsConstructor(access = PROTECTED)
      public class Image2 {
          @Column(name = "image_type")
          private String imageType;
        
          @Column(name = "image_path")
          private String path;
        
          @Temporal(TemporalType.TIMESTAMP)
          @Column(name = "upload_time")
          private LocalDateTime uploadTime;
        
          ... 
        
          // 성능을 위해 다형성을 포기하고  if-else로 구현
          public boolean hasThumbnail() {
              if (imageType.equals("II")) {
                  return true;
              } else {
                  return false;
              }
          }
      }
    

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

  • 애그리거트 간 집합(M-N) 연관은 성능 상의 이유로 좋지 않다. (→ 3장)
  • 그럼에도 불구하고 요구사항을 구현하는데 사용해야 한다면, ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다.

      @Entity
      @Table(name = "product")
      public class Product {
          @EmbeddedId
          private ProductId id;
        
          @ElementCollection
          @CollectionTable(name = "product_category", 
                           joinColumns = @JoinColumn(name = "product_id"))
          private Set<CategoryId> categoryIds;
          ...
      }
    
    • 위의 코드는 Product에서 Category로의 단방향 M-N 연관을 ID 참조 방식으로 구현한 것이다.
    • ID 참조를 이용한 애그리거트 간 단방향 M-N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정하는데, 차이점은 집합의 값에 밸류 대신 식별자가 온다는 것이다.
    • @ElementCollection을 이용하기 때문에 Product를 삭제할 대 매핑에 사용한 조인 테이블의 데이터도 함께 삭제된다.
    • 애그리거트를 직접 참조하는 방식을 사용했다면 영속성 전파나 로딩 전략을 고민해야 하는데, ID 참조 방식을 사용함으로써 이런 고민을 없앨 수 있다.

4.4 애그리거트 로딩 전략

  • 애그리거트는 개념적으로 하나여야 한다. 하지만 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아니다.
  • 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야 한다.

      // product는 완전한 하나여야 한다.
      Product product = productRepository.findById(id);
    
  • 조회 시점에 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩(FetchType.EAGER)으로 설정하면 된다.
    • 컬렉션이나 @Entity에 대한 매핑의 fetch 속성을 즉시 로딩으로 설정하면, EntityManager#find() 메서드로 애그리거트 루트를 구할 때 연관된 구성요소를 DB에서 함께 읽어온다.

        // @Entity 컬렉션에 대한 즉시 로딩 설정
        @OneToMany(casecade = {CasecadeType.PERSIST, CasecadeTpye.REMOVE},
                     orphanRemoval = true, fetch = FetchType.EAGER)
        @JoinColumn(name = "product_id")
        @OrderColumn(name = "list_idx")
        private List<Image> images = new ArrayList<>();
              
        // @Embeddable 컬렉션에 대한 즉시 로딩 설정
        @ElementCollection(fetch = FetchType.EAGER)
        @CollectionTable(name = "product_option", 
                         joinColumns = @JoinColumn(name = "product_id"))
        @OrderColumn(name = "list_idx")
        private List<Option> option = new ArrayList<>();
      
    • 즉시 로딩 방식을 설정하면 애그리거트 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있는 장점이 있다.
    • 하지만, 컬렉션에 대해 즉시 로딩을 설정하면 조인을 수행할 때, 카타시안 조인을 사용하여 쿼리 결과에 중복이 발생한다.
      → 하이버네이트가 중복된 데이터를 알맞게 제거해주지만, 애그리거트가 커지면 문제가 될 수 있다.

    ➡️  보통 조회 성능 문제 때문에 즉시 로딩 방식을 사용하지만, 조회되는 데이터 개수가 많아지면 즉시 로딩 방식을 사용할 때 성능(실행 빈도, 트래픽, 지연 로딩 시 실행 속도 등)을 검토해봐야 한다.

  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않는다.

      @Transactional
      public void removeOptions(ProductId id, int optIdxToBeDeleted) {
          // Product 로딩. 컬렉션은 지연 로딩으로 설정했다면, Option은 로딩하지 않음
          Product product = productRepository.findById(id)
                  .orElseThrow(ProductNotFoundException::new);
        
          // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능
          product.removeOption(optIdxToBeDeleted);
      }
    
      @Entity
      @Table(name = "product")
      public class Product {
      		...
          @ElementCollection(fetch = FetchType.EAGER)
          @CollectionTable(name = "product_option",
                  joinColumns = @JoinColumn(name = "product_id"))
          @OrderColumn(name = "list_idx")
          private List<Option> option = new ArrayList<>();
        
          public void removeOption(int optIdx) {
              // 실제 컬렉션에 접근할 때 로딩
              this.option.remove(optIdx);
          }
      }
    
  • 일반적인 애플리케이션은 상태를 변경하는 빈도보다 조회하는 빈도가 높기 때문에, 지연 로딩할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 즉시 로딩은 @Entity@Embeddable에 대해 다르게 동작하고, JPA 프로바이더에 따라 구현 방식이 다를 수 있지만, 지연 로딩은 항상 동작 방식이 동일하다.
  • 하지만 지연 로딩은 즉시 로딩 보다 쿼리 실행 횟수가 많아질 가능성이 더 높다.

    ➡️  즉시 로딩과 지연 로딩은 애그리거트에 맞게 선택해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장하고 삭제할 때도 하나로 처리해야 함을 의미한다.
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 casecade 속성을 추가로 설정하지 않아도 된다.
  • @Entity 타입에 대한 매핑은 casecade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정해야 한다.
  • @OneToOne, @OneToMany는 casecade 속성의 기본값이 없으므로, casecade 속성값으로 CaseCadeType.PERSIST, CaseCadeType.REMOVE를 설정한다.

      @OneToMany(
          cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
          orphanRemoval = true
      )
      @JoinColumn(name = "product_id")
      @OrderColumn(name = "list_idx")
      private List<Image> images = new ArrayList<>();
    

4.6 식별자 생성 기능

  • 식별자는 크게 세 가지 방식 중 하나로 생성한다.
    • 사용자가 직접 생성
      • 사용자가 직접 식별자를 입력하는 경우에는 도메인 영역에 식별자 생성 기능을 구현할 필요가 없다.
      • ex) 이메일
    • 도메인 로직으로 생성
      • 식별자 생성 규칙이 있다면, 별도 서비스로 식별자 생성 기능을 분리해야 한다.
      • 식별자 생성 기능은 도메인 규칙이므로 도메인 영역 또는 리포지터리에 위치해야 한다.
      • ex) 고객 ID와 타임스탬프로 구성되는 주문 번호
        public class OrderIdService {
            public OrderId createId(UserId userId) {
                if (userId == null) 
                    throw new IllegalArgumentException("invalid userId : " + userId);
                return new OrderId(userId.toString() + "-" + timestamp());
            }
              
            private String timestamp() {
                return Long.toString(System.currentTimeMillis());
            }
        }
      
    • DB를 이용한 일련번호 사용
      • DB 자동 증가 컬럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용한다.
      • 자동 증가 컬럼은 도메인 객체를 저장한 뒤에 식별자를 구할 수 있다.
        public class WriteArticleService {
            private ArticleRepository articleRepository;
                  
            public Long write(NewArticleRequest req) {
                Article article = new Article("제목", new ArticleContent("content", "type"));
                articleRepository.save(article);  // EntityManager#save() 실행 시 식별자 생성
                return article.getId();  // 저장 이후 식별자 사용 가능
            }
        }
      

4.7 도메인 구현과 DIP

  • 이번 장에서는 JPA에 특화된 @Entity, @Table, @Id, @Column 등의 애너테이션을 직접 사용하므로서 DIP 원칙을 어기고 있다.
  • 리포지터리 인터페이스에서도 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함이다.
  • 하지만, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다.
  • DIP를 완벽하게 지키면 좋겠지만, 개발 편의성과 실용성을 가져가면서 구조적인 유연함을 어느정도 유지할 수 있도록 타협하는 것도 합리적인 선택이다.