IT recording...

[DDD START!] 4장 - 리포지터리와 모델 구현 (JPA중심) 본문

DevBookReview

[DDD START!] 4장 - 리포지터리와 모델 구현 (JPA중심)

I-one 2022. 1. 7. 11:43

https://adorable-aspen-d23.notion.site/DDD-START-4-JPA-ed21abe14e4147ed90c19161b9d5d7af

 

[DDD START!] 4장 - 리포지터리와 모델 구현 (JPA중심)

1. JPA를 이용한 리포지터리 구현

adorable-aspen-d23.notion.site

1. JPA를 이용한 리포지터리 구현

  • 애그리거트를 어떤 저장소에 저장하느냐에 따라 리포지터리를 구현하는 방법이 다르다.
  • 도메인 모델과 리포지터리를 구현할 때 선호하는 기술은 JPA이다.
  • 데이터 보관소로 RDBMS를 사용할 때 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM이 최고다.
  • 다양한 ORM기술 중 ORM 표준인 JPA를 사용해서 리포지터리와 애그리거트를 구현하는 방법을 살펴보자

2. 리포지터리 기본 기능 구현

  • 리포지터리의 기본 기능
    • 아이디로 애그리거트 조회하기
    • 애그리거트 생성하기
  • 인터페이스 형식
public interface OrderRepository {
	public Order findById(OrderNo no);
	public void save(Order order);
}

→ 인터페이스는 애그리거트 루트를 기준으로 작성한다.

  • 리포지터리 구현 클래스
@Repository
public class JpaOrderRepository implements OrderRepository {
	@PersistenceContext
	private EntityManaver entityManager;

	@Override
	public Order findById(OrderNo id){
		return entityManager.find(Order.class,id);
	}
	@Override
	public void save(Order order) {
		entityManager.persist(order);
	}
}
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA를 사용하면 transaction 범위에서 변경한 데이터를 자동으로 DB에 반영한다.
public class ChangeOrderService {
	@Transactional
	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
		Order order = orderRepository.findById(no);
		if (order == null) throw new OrderNotFoundException();
		order.chagneShippingInfo(newShippingInfo);
	}
...
}

→ changeShippingInfo() 메서드는 스프링 프레임워크의 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행된다.

→ 메서드 실행이 끝나면 트랜잭션을 커밋하는데, 이때 JPA는 트랜잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 자동으로 UPDATE 쿼리를 실행한다.

  • 아이디가 아닌 다른 조건으로 애그리거트를 조회할 경우
public interface OrderRepository {
	...
	public List<Order> findByOrdererId(String ordererId, int startRow, int size);
}
@Override
public List<Order> findByOrdererId(String ordererId, int startRow, int fetchSize){
	TypedQuery<Order> query = entityManager.createQuery(
		"select o from Order o where o.orderer.memberId.id = :ordererId" +
			"order by o.number.number desc",
		Order.class);
		query.setParameter("ordererId", ordererId);
		query.setFirstResult(startRow);
		query.setMaxResults(fetchSize);

	return query.getResultList();
}
  • 애그리거트 삭제 기능
    • 삭제 요구사항이 있다고 하더라도 바로 삭제하는 경우는 거의 없다.
    • 관리자 기능에서 삭제 데이터를 조회해야 하는 경우도 있고 데이터 원복을 위해 일정 기간 동안 보관해야 할 때도 있기 때문이다.
    • 따라서 삭제 플래그를 사용해서 데이터를 화면에 보여줄지 여부를 결정하는 방식으로 구현한다.
public interface OrderRepository {
	...
	public void delete(Order order);
}
public class JpaOrderRepository implements OrderRepository {
	@PersistenceContext
	private EntityManager entityManager;
	...
	@Override
	public void remove(Order order){
		entityManager.remove(order);
	}
}

3. 매핑 구현

  • 앤티티와 밸류 기본 매핑 구현

애그리거트와 JPA 매핑을 위한 기본 규칙

  • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다.
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면,
    • 밸류는 @Embeddable로 매핑 설정한다.
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.
  • Order 애그리거트
    • 루트 엔티티 : Order
    • 밸류 : Orderer, ShippingInfo
    • 그 외 객체 : Address, Receiver
    ⇒ 한 테이블에 매핑된다. (PURCAHGE_ORDER)
@Entity
@Table(name = "purchase_order")
public class Order {
	...
	@Embedded //밸류 타입 프로퍼티
	private Orderer orderer;
	
	@Embedded
	private ShippingInfo shippingInfo;
}
**@Embeddable //밸류**
public class Orderer {
	**@Embedded** //밸류 타입 프로퍼티
	@AttributeOverrides(
		@AttributeOverride(name = "id", column = @Column(name="orderer_id"))
	) **//MemberId안에 존재하는 id 값을 table에 저장할 때 orderer_id로 저장한다.**
	private MemeberId memberId;

	@Column(name = "orderer_name")
	private String name;
	**//name을 table에 저장할 때 orderer_name으로 저장한다.**
}
**@Embeddable**
public class MemberId implements Serializable {
	@Column(name = "member_id")
	private String id;
}
  • AttributeOverride 사용 방법

JPA - Entity의 가독성을 높이자(@Embedded, @Embeddable, @AttributeOverride 사용법)

@Embeddable
public class ShippingInfo {
	@Embedded
	@AttributeOverrides({
	@AttributeOverride(name = 'zipcode', column = @Column(name = "shipping_zipcode")),
	@AttributeOverride(name = 'address1', column = @Column(name = "shipping_addr1")),
	@AttributeOverride(name = 'address2', column = @Column(name = "shipping_addr2"))
	})
	private Address address;
	
	@Column(name = "shipping_message")
	private String message;

	@Embedded
	private Receiver receiver;
	
	)
}

기본 생성자

  • JPA의 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다.
  • 하이버네이트와 같은 JPA 프로바이더는 DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문이다.
  • 따라서 필요 없더라도 기본 생성자를 추가해야 한다.
  • 하지만 다른 곳에서 사용하면 값이 없는 온전하지 못한 객체를 만들게 되므로 이를 방지하기 위해 protected로 선언한다.
@Embeddable
public class Receiver {
	@Column(name = "receiver_name")
	private String name;
	@Column(name = "receiver_phone")
	private String phone;

	**protected Receiver() {}** //JPA적용 위한 기본 생성자
	
	public Receiver(String name, String phone) {
		this.name = name;
		this.phone = phone;
	}
	...//get생략
}

필드 접근 방식 사용

→ JPA는 메서드(PROPERTY) 방식, 필드(FIELD) 방식 두 가지 방식으로 매핑을 처리할 수 있다.

  • @Access(AccessType.PROPERTY)
    • get/set 메서드를 구현해야 한다.
    • 도메인의 의도가 사라지고 객체가 아닌 데이터 기반으로 엔티티를 구현할 가능성이 높아진다.
    • set메서드를 통해 외부에서 내부 데이터를 변경할 수 있기 때문에 캡슐화를 깨는 원인이 될 수 있다.(ex. setState → cancel() , setShippingInfo() → changeShippingInfo() )
    • → set메서드 대신 기능을 잘 표현하는 의미를 갖는 메서드를 사용해야 한다.
  • @Access(AccessType.FIELD)
    • get/set 메서드를 구현하지 않는다.
@Entity
**@Access(AccessType.FIELD)**
public class Order {
	@EmbeddedId
	private OrderNo number;

	@Column(name = "state")
	@Enumerated(EnumType.STRING)
	private OrderState state;

	...//cancel(), chagneShippingInfo() 등 도메인 기능 구현
	...//필요한 get 메서드 제공
}
  • JPA의 구현체인 하이버네이트는 @Access 를 이용하여 명시적으로 접근 방식을 지정하지 않으면 @Id 나 @EmbeddedId가 필드에 위치해 있을 때는 필드 방식, 위치해 있지 않다면 메서드 접근 방식을 택한다.

AttributeConverter를 이용한 밸류 매핑 처리

  • 밸류 타입의 프로퍼티(두 개 이상)을 한 개 칼럼에 매핑해야 할 때 @Embeddable로는 처리할 수 없다.
  • 이에 AttributeConverter를 사용하여 밸류타입과, 칼럼 데이터 간의 변환 처리를 가능하게 한다.
package javax.persistence;

public interface AttributeConverter<X,Y> { //X:밸류타입, Y:DB타입
	public Y convertToDatabaseColumn (X attribute);
	public X convertToEntityAttribute (Y dbData);
}
**@Converter(autoApply = True)**
public class MoneyConverter implements AttributeConverter<Money, Integer> {
	@Override
	public Integer convertToDatabaseColumn(Money money){ //밸류->DB
		if(money == null) return null;
		else return money.getValue();
	}
	@Override
	public Money convertToEntityAttribute(Integer value) { //DB->밸류
		if(value == null) return null;
		else return new Money(value);
	}
}
  • autoApply = True는 모델에 출현하는 모든 Money타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다는 것이다.
  • autoApply = false인 경우 컨버터를 명시적으로 지정할 수 있다.
public class Order {
	@Column(name = "total_amounts")
	@Convert(converter = MoneyConverter.class)
	private Money totalAmounts;
}

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

  • 한 개의 엔티티가 한 개 이상의 밸류를 가질 수 있다.
  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 타입의 컬렉션(여러 개를 담아 놓은 것)은 별도 테이블에 보관한다.
    • 밸류 컬렉션을 저장하는 ORDER_LINE 테이블은 외부키를 사용해서 엔티티에 해당하는 PURCHASE_ORDER 테이블을 참조한다.
      • 이 외부 키는 컬렉션이 속할 엔티티를 의미한다.
    • List타입의 컬렉션은 인덱스 값이 필요하므로 ORDER_LINE 테이블에는 인덱스 값을 저장하기 위한 칼럼(line_idx)도 존재한다.
public class Order {
	private List<OrderLine> orderLines;
	...
	}
  • 밸류 컬렉션을 별도 테이블에 매핑할 때는 @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;

	...
}
  • List 자체가 인덱스를 갖고 있기 때문에 OrderColumn 어노테이션을 사용해서 리스트의 인덱스를 저장한다.
  • @CollectionTable은 밸류를 저장할 테이블을 지정할 때 사용한다.
    • name 속성으로 table 이름을 지정한다.
    • joinColumns 속성으로 외부키로 사용하는 컬럼을 지정한다.
@Embeddable
public class OrderLine{
	@Embedded
	private ProductId productId;

	@Column(name = "price")
	private Money price;
	
	@Column(name = "quantity")
	private int quantity;

	@Column(name = "amounts")
	priavate Money amounts;
...
}

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

  • 밸류 컬렉션을 별도의 테이블이 아닌 한 개의 칼럼에 매핑해야 할 때가 있다.
  • AttributeConverter를 사용한다.

ex) 도메인 모델에서는 이메일 주소 목록을 Set으로 보관하고 DB에는 한 개 칼럼에 콤마로 구분해서 저장한다.

  • 밸류 컬렉션을 위한 새로운 밸류 타입을 추가해야 한다.
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);
	}
}
  • AttributeConverter 구현
@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::String)
						.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;

밸류를 이용한 아이디 매핑

  • 식별자를 기본 타입이 아닌 밸류 타입을 이용하게 되면 @Id 어노테이션 대신 @EmbeddedId 를 사용한다.
@Entity
public class Order{
	@Id
	private String number;
	...
}
@Entity
@Table(name = "purchase_order")
public class Order{
	**@EmbeddedId**
	private OrderNo number;
	...
}
**@Embeddable**
public class OrderNo **implements Serialiazable**{ //JPA에서 식별자는 Serializable타입이어야 한다.
	@Column(name = "order_number")
	private String number;
	
	public boolean is2ndGeneration(){ //식별자에 기능 추가가 가능하다.
		return number.startsWith("N");
	}
	...
}

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

  • 밸류임에도 불구하고 별도 테이블에 저장하는 경우가 있다.
  • 애그리거트에서 루트 엔티티를 제외한 나머지 구성요소는 대부분 밸류이다.
  • 엔티티로 판단된 것들이 자신만의 독자적인 라이프사이클을 갖는다면 다른 애그리거트일 가능성이 높다. (Product, Review)
    • Product와 Review는 함께 생성되지 않고, 함께 변경되지도 않는다.
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지 여부를 확인하는 것이다.
    • 단, 별도 테이블로 저장되고 테이블에 PK가 존재한다고 해서 테이블과 매핑되는 애그리거트 구성요소가 고유 식별자를 갖는 것은 아니다.
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나누어 저장한다고 하자
    • ARTICLE_CONTENT의 ID 칼럼이 식별자이므로 ArticleContent를 엔티티로 생각할 수 있는데, 이것 때문에 Article과 ArticleContent를 두 엔티티 간의 일대일 연관으로 매핑하는 실수를 할 수 있다.
    • 하지만 ArticleContent는 엔티티가 아닌 밸류이므로 다음과 같이 접근해야 한다.
  • ArticleContent는 밸류이므로 @Embeddable로 매핑한다.
    • 밸류를 매핑한 테이블을 지정하기 위해 @SecondaryTable과 @AttributeOverride를 사용한다.
      @Entity
      @Table(name = "article")
      **@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")),
      		@AttributeOverride(name = "contentType",
      							column = @Column(table = "article_contentType"))
      	})**
      	private ArticleContent content;
      ...
      }
      
      • @SecondaryTable을 이용하면 아래 코드 실행 시 두 테이블을 조인해서 데이터를 조회한다.
      //@SecondaryTable로 매핑된 article_content 테이블을 조인
      Article article = entityManager.find(Article.class, 1L);
      ​

 

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

* 개념적으로 밸류인데 구현 기술의 한계 혹은 팀 표준 때문에 @Entity를 사용해야 할 때가 있다.

ex) 제품의 이미지 업로드 방식에 따라 이미지 경로와 썸네일 이미지 제공 여부가 달라진다고 해보자.

  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
  • 따라서 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddabl대신 @Entity 를 이용한 상속 매핑으로 처리해야 한다.
  • 밸류 타입을 @Entity로 매핑하므로 식별자 매핑을 위한 필드도 추가해야 한다. (엔티티는 식별자 필요)
  • 구현 클래스 구분을 위한 타입 식별 (discriminator) 칼럼을 추가해야 한다.
    @Entity
    **@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @DiscriminatorColumn(name = "image_type")**
    @Table(name = "image")
    public abstract class Image {
    	@Id
    	@GeneratedValue(strategy = GenerationType.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;
    
    	protected Image() {} //JPA에서 @Entity,@Embeddable을 사용하면 기본 생성자 필요
    	public Image(String path) {
    			this.path = path;
    			this.uploadTime = new Date();
    	}
    
    	protected String getPath() {
    		return path;
    	}
    	public Date getUploadTime(){
    		return uploadTime;
    	}
    
    	public abstract String getURL();
    	public abstract boolean hasThumbnail();
    	public abstract String getThumbnailURL();
    }
    
    • 한 테이블에 Image 및 하위 클래스를 매핑하므로 @Inheritance를 적용하고 strategy 값으로 SINGLE_TABLE을 사용한다.
    • @DiscriminatorColumn을 사용하여 타입을 구분하는 용도로 사용할 칼럼을 지정한다.
      //Image를 상속받은 클래스
      
      **@Entity
      @DiscriminatorValue("II")**
      public class InternalImage extends Image {
      	...
      }
      
      **@Entity
      @DiscriminatorValue("EI")**
      public class ExternalImage extends Image {
      	...
      }
      
      @Entity
      @Table(name = "product")
      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);
      	}
      }
      ​
    • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해서 매핑을 처리한다.
    • Image는 밸류이므로 독자적인 라이프사이클을 가지지 않고 Product에 완전히 의존한다.
      • cascade 속성을 사용하여 Product를 저장할 때 함께 저장되고, 삭제될 때 함께 삭제되도록 설정한다.
    • 리스트에서 Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval = true로 설정한다.
  • changeImages 메소드를 보면 이미지 교체를 위해 clear() 메서드를 사용하고 있는데, Image 목록을 가져오기 위한 한 번의 select 쿼리, 각 image를 삭제하기 위한 여러 번의 delete 쿼리가 실행된다. 
    • 전체 서비스 성능에 문제가 될 수 있다.
    • 하이버네이트는 @Embeddable 타입에 대한 컬렉션의 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다.
    • ⇒ 따라서 애그리거트의 특성을 유지하면서 이 문제를 해소하려면 결국 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현해야 한다.
      @Embeddable
      public class Image{
      		@Column(name = "image_type")
      		private String imangeType;
      		@Column(name = "image_path")
      		private String path;
      
      		@Temporal(TemporalType.TIMESTAMP)
      		@Column(name = "upload_time")
      		private Date uploadTime;
      		...
      
      		**public boolean hasThumbnail(){
      			//성능을 위해 다형을 포기하고 if-else로 구현
      			if (imageType.equals("II") return true;
      			else return false;
      		}**
      }
      
      → 코드 유지보수와 성능의 두 가지 측면을 고려해서 구현 방식을 선택해야 한다.
  • ID참조와 조인 테이블을 이용한 단방향 M:N 매핑
    • 애그리거트 간 집합 연관은 성능상의 이유로 피해야 한다.
    • 그럼에도 불구하고 요구사항을 구현하는 데 집합 연관이 유리하다면 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. 애그리거트 로딩 전략
    • JPA 매핑 설정 시 애그리거트 내 모든 객체들은 항상 완전한 상태여야 한다.
    //product는 완전한 하나여야 한다.
    Product product = productRepository.findById(id);
    
    연관 매핑 조회 방식
    • FetchType.EAGER
      • 조회 시점에서 애그리거트를 완전한 상태가 되도록 할 수 있다.
      • 컬렉션이나 entity에 대한 fetch 속성을 즉시 로딩으로 설정하면 EntityManager#find() 메서드로 애그리거트 루트를 구할 때 연관된 구성요소를 DB에서 함께 읽어온다.
      • But, 컬렉션에 대해 즉시 로딩 방식을 설정하면, 카타시안 조인으로 인한 쿼리 중복 문제가 발생한다.
    @Entity
    @Table(name = "product")
    public class Product {
    	...
    	//엔티티
    	@OneToMany( 
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		**fetch = FetchType.EAGER**)
    	@JoinColumn(name = "product_id")
    	@OrderColmn(name = "list_idx")
    	private List<Image> images = new ArrayList<>();
    
    	//컬렉션
    	@ElementCollection(**fetch = FetcType.EAGER**)
    	@CollectionTable(name = "product_option",
    				joinColumns = @JoinColumn(name = "product_id"))
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList<>();
    	...
    }
    
    • 이 매핑을 사용할 때 EntityManager#find() 메서드로 Product를 조회하면 하이버네이트는 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리를 실행한다.
    select
    	p.product_id, ... img.product_id, img.image_id, img.list_idx, ... ,
    	opt.product_id, opt.option_title, opt.option_value, opt.list_idx
    from
    	product p
    	left outer join image img on p.product_id=img.product_id
    	left outer join product_option opt on p.product_id = opt.product_id
    where p.product_id=?
    
    • 이 쿼리는 카타시안 조인을 사용하여 쿼리 결과에 중복을 발생시킨다.
    • 먄약 한 개 제품에 대한 이미지가 20개이고 Option이 15개라면 EntityManager#find() 메서드는 250행의 쿼리를 리턴한다.
      • 중복된 데이터들이 발생하는데, 실제 필요한 행 개수는 36(1+20+15) 인 것에 비하면 과도하게 많다.
    • 이런 경우 조회 성능 때문에 즉시 로딩 방식을 사용하지만, 오히려 이 때문에 조회 성능이 나빠지는 문제가 발생한다.
    애그리거트가 완전해야 하는 이유
    • 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 한다.
      • 굳이 상태 변경을 위해 조회 시점에 즉시 로딩을 이용할 필요는 없다.
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하다.
      • 별도의 조회 전용 기능을 구현하면 된다.
    ⇒ JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 다음 코드처럼 실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않는다.
    @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);
    	}
    }
    
    • 지연 로딩을 하게 되면 추가 쿼리가 발생하게 되지만, 상태 변경 기능 실행 빈도보다 조회하는 기능을 실행하는 빈도가 훨씬 높기 때문에, 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 문제 되지 않는다.
    • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없다.
      • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작한다.
    ⇒ 애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필요 없다.5. 애그리거트의 영속성 전파
    • 애그리거트는 완전한 상태여야 한다.
      • 애그리거트 루트를 조회할 때
      • 애그리거트 루트를 저장할 때 (속한 모든 객체를 저장)
      • 애그리거트 루트를 삭제할 때 (속한 모든 객체를 삭제)
    • @Embeddable 매핑
      • 자동으로 함께 저장되고 삭제된다.
      • cascade속성 추가 필요 없음
    • @Entity 매핑
      • cascade 속성을 추가로 설정해서
      • 저장과 사제 시 함께 처리되도록 설정
    @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    			orphanRemoval = true)
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList<>();
    
    6. 식별자 생성 기능
    • 식별자 생성 방식
      • 사용자가 직접 생성
      • 도메인 로직으로 생성
      • DB를 이용한 일렬번호 사용
    • 식별자 생성 규칙이 있는 경우, 별도 서비스로 식별자 생성 기능을 분리한다.
      • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시킨다.
    public class **ProductIdService**{
    	public ProductId nextId(){
    		...//정해진 규칙으로 식별자 생성
    	}
    }
    
    public class CreateProductServie{
    	@Autowired private ProductIdService idService;
    	@Autowired private ProductRepository productRepository;
    
    	@Transactional
    	public ProductId createProduct(ProductCreationCommand cmd){
    		//응용 서비스는 도메인 서비스를 이용해서 식별자를 생성한다.
    		ProductId id = productIdService.nextId();
    		Product product = new Product(id, cmd.getDetail(), cmd.getPricet(),...);
    		productRepository.save(product);
    		return id;
    	}
    }
    
    • 리포지터리에 위치시킬 수도 있다.
    public interface ProductRepository{
    	...//save() 등 메서드
    
    	**ProductId nextId();**
    }
    
    • 식별자 생성으로 DB의 자동 증가 칼럼을 사용할 경우 JPA의 식별자 매핑에서 @GeneratedValue를 사용한다.
    @Entity
    @Table(name = "article")
    ...
    public class Article{
    	@Id
    	**@GeneratedValue(strategy = GenerationType.IDENTITY)**
    	private Long id;
    
    	public Long getId() {
    		return id;
    	}
    }
    
    • 자동 증가 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로, 도메인 객체를 리포지터리에 저장할 때 식별자가 생성된다.
      • 생성 시점에서는 식별자를 알 수 없고, 도메인 객체를 저장한 뒤에 식별자를 구할 수 있다.
    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() //저장 후 식별자 사용 가능
    	}
    }
    
  • → 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택해야 한다.
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { //Product를 로딩한다. 컬렉션을 지연 로딩으로 설정했다면 Option은 로딩하지 않는다. Product product = productRepository.findById(id); //트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); }
  •  
Comments