도메인 주도 개발 스터디

Chapter 4 리포지터리와 모델 구현

막이86 2023. 11. 8. 17:37
728x90

도메인 주도 개발 시작하기 4장을 요약한 내용입니다.

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각

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

  • 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
  • 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포티저티를 구현한 글래스는 인프라스트럭처 영역에 속한다.
  • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮추는 것이 좋음

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

  • 리포지터리가 제공하는 기본 기능
    • ID로 애그리거트 조회
    • 애그리거트 저장
    public interface OrderRepository {
    	Order findById(OrderNo no);
    	void save(Order order);
    }
    
  • 인터페이스는 애그리거트 루트를 기준으로 작성
  • 주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine, Orderer, ShippingInfo 등 다양한 객체를 포함
    • 루트 엔티티인 Order를 기준으로 리포지터리 인터페이스를 작성
  • 애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만 널리 사용되는 규칙은 findBy프로퍼티이름(프로퍼티 값) 형식을 사용
  • JPA의 EntityManager를 이용한 기능 구현
  • @Repository public class JpaOrderRepository implements OrderRepository { @PresistenceContext 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.presist(order); } }
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.
    • JPA에서 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문
    • changeShippingInfo 메서드는 스프링 프레임워크 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행 됨
    • 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랙잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update 쿼리를 실행
    public class ChangeOrderService {
    	@Transactional
    	public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    		Optional<Order> OrderOpt = orderRepository.findById(no);
    		Order order = orderOpt.orElesThrow(() -> new OrderNotFountException());
    		order.changeShippingInfo(newShippingInfo);
    	}
    }
    
  • ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
    • JPQL을 이용해서 findByOrdererId 메서드 구현
    @Override
    public List<Order> findByOrdererId(String ordererId, int startRow, int size) {
    	TypeQuery<Query> 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 { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); }
  • 애그리거트를 삭제하는 기능
    • EntityManager의 remove 메서드를 이용해서 삭제 기능 구현
    @Repository
    public class JpaOrderRepository implements OrderRepository {
    	@PresistenceContext
    	private EntityManager entityManager;
    
    	@Override
    	public void delete(Order order) {
    		entityManager.remove(order);
    	}
    }
    
  • public interface OrderRepository { Order findById(OrderNo no); void save(Order order); List<Order> findByOrdererId(String ordererId, int startRow, int size); void delete(Order order); }

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

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록
    • org.stringframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
  • Order 엔티티 타입의 식별자가 OrderNo 타입
    • Order를 위한 OrderRepository
    public interface OrderRepository extends Repository<Order, OrderNo> {
    	Optional<Order> findById(OrderNo id);
    
    	void save(Order order);
    }
    
    • OrderRepository가 필요하면 주입 받아 사용하면 된다.
    @Service
    public class CancelOrderService {
    	private OrderRepository orderRepository;
    	
    	public CancelOrderService(OrderRepository orderRepository, ...) {
    		this.orderRepository = orderRepository
    	}
    
    	@Transactional
    	public void cancel(OrderNo orderNo, Canceller canceller) {
    		Order order = orderRepository.findById(orderNo).orElseThrow(() -> new NoOrderException());
    		if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
    			throw new NoCancellablePermsiion();
    		}
    		order.cancel();
    	}
    }
    
  • @Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; }
  • OrderRepository를 기준으로 엔티티를 젖아하는 메서드
    • Order save(Order entity)
    • void save(Order entity)
  • 식별자를 이용해서 엔티티를 조회할 때는 findById() 메서드를 사용한다.
    • Order findById(OrderNo id)
      • 식별자에 해당하는 엔티티 존재하지 않을 경우 null 리턴
    • Optional<Order> findById(OrderNo id)
      • 값이 없을 경우 Optional을 리턴?
  • 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 findBy프로퍼티이름 형식의 메서드를 사용
    • List<Order> findByOrderer(Orderer orderer)
  • 중첩 프로퍼티도 가능
    • List<Order> findByOrdererMemberId(MemberId memberId)
  • 엔티티를 삭제하는 메서드
    • void delete(Order order)
    • void deleteById(OrderNo id)

4.3 매핑 구형

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

  • 애그리거트와 JPA 매핑을 위한 기본 규칙
    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정
  • 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정
  • 주문 애그리거트를 엔티티와 밸류가 한 테이블로 매핑 예제
  • 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • @Entity @Table(name = "purchase_order") public class Order { @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "orderer_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
  • Orderer의 memberId는 Member 애그리거트를 ID로 참조
    • Member ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 칼럼 이름으로 “member_id” 지정
    @Embeddable
    public class MemberId implements Serializable {
    	@Column(name = "member_id")
    	private String id;
    }
    
  • @Embeddable 타입에 설정한 칼럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해 매핑할 칼럼 이름을 변경
  • @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; }

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
  • JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 함
    • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 이용해서 객체를 생성하기 때문
  • 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가 해야함
  • @Embeddable public class Receiver { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; // JPA를 적용하기 위해 기본 생성자 추가 protected Receiver() {} public Reciver(String name, String phone) { this.name = name; this.phone = phone; } }
  • 기본 생성자는 JPA 프로바이터가 객체를 생성할 때만 사용
  • 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언

4.3.3 필드 접근 방식 사용

  • 엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 가반으로 엔티티를 구현할 가능성이 높아짐
  • 엔티티가 객체로서 제 역활을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.
    • 상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현
    • setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 chagneShippingInfo()가 도메인을 더 잘 표현
  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선언해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
  • @Entity @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Column(name = "state") @Enumerated(EnumType.STRING) private OrderState state; }

<aside> 👉 JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @id나 @EmbeddedId가 어디에 위치했느냐에 따라 접근방식을 결정한다. @id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고 get 메서드에 위치하면 메서드 접근 방식을 선택

</aside>

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

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
  • 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
  • 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로 처리할 수 없다
    • AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
      • X는 밸류
      • Y는 DB
    public interface AttributeConverter<X,Y> {
    	public Y convertToDatabaseColumn(X attribute);
    
    	public X convertToEntityAttribute(Y dbData);
    }
    
  • Money 밸류 타입을 위한 AttributeConvert
    • @Converter 애너테이션을 적용
    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해서 MoneyConverter를 자동으로 적용
    @Converter(autoApply = true)
    public class MoneyConvert 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);
    	}
    }
    
    • autoApply 속성이 false로 지정하면 프로퍼티 값을 변환할 때 사용할 건버터를 직접 지정 해야함
    public class Order {
    	@Column(name = "total_amounts")
    	@Convert(converter = MoneyConverter.class)
    	private Money totalAmounts;
    }
    

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

  • Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
    • JPA는 @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장
    • @CollectionTable은 밸류를 저장할 테이블을 지정
      • name 속성은 테이블 이름
      • joinColumns 속성은 외부키로 사용할 컬럼을 지정
      • 두 개 이상의 외부키의 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    	@ElementCollection(fetch = FetchType.EAGRE)
    	@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;
    
    	@Column(name = "price")
    	private Money price;
    
    	@Column(name = "quantity")
    	private int quantity;
    
    	@Column(name = "amounts")
    	private Money amounts;
    }
    

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
    • 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);
    	}
    }
    
    • 밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현
    public class EmailSetConverter implements AttrbuteConverter<EmailSet, String> {
    	@Override
    	public String convertToDatabaseColumn(EmailSet attribute) {
    		if (attribute == null) {
    			return null;
    		}
    		return attribute.getEmails().stream().map(email -> email.getAddress()).collect(Collecters.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());
    	}
    }
    
    • EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정
    @Column(name = "emails")
    @Converter(converter = EmailSetConverter.class)
    private EmailSet emailSet;
    

4.3.7 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각 시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다.
    • OrderNo, MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입
    • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용
    @Entity
    @Table(name = "purchase_order")
    public class Order {
    	@EmbeddedId
    	private OrderNo number;
    
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
    	@Column(name = "order_number")
    	private String number;
    }
    
  • JPA에서 식별자 타입은 Serializable 타입이어야 함
  • 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가 할 수 있다는 점

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

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
  • 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.
  • 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
    • 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것
    • 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아님
  • 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 하자
    • ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 ArticleContent를 엔티티로 생각해서 Article과 ArticleContent를 1-1연관으로 매핑할 수 있다.
    • ArticleContent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
      • ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함임
      • ARTICLE_CONTENT를 위한 별도 식별자는 아님
    • ArticleContent를 밸류로 보고 접근하면 아래 처럼 변경 가능
    • ArticleContent는 밸류이므로 @Embeddable로 매핑
      • 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttibuteOverride를 사용
      • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정
      • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정
    @Entity
    @Table(name = "article")
    @SecondaryTable(
    	name = "article_content",
    	pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
    )
    public class Article {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private String title;
    
    	@AttributeOverrides({
    		@AttributeOverride(
    			name = "contnet",
    			column = @Column(table = "article_content", name = "content")
    		),
    		@AttributeOverride(
    			name = "contnetType",
    			column = @Column(table = "article_content", name = "content_type")
    		),
    	})
    	
    	@Embedded
    	private ArticleContent content;
    }
    
  • 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다.
    • @SecondaryTable을 사용하면 Article을 조회할 때 article_contentㄱ 테이블까지 조인해서 읽어옴
  • ArticleContent를 엔티티로 매핑하고 Article에서 ArticleContent로 로딩을 지연 방식을 설정 가능
    • 맬류인 모델을 엔티티로 만드는 것이므로 좋은 방법은 아님
  • 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

  • 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라지는 예제
  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
    • @Embeddable 대시 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • 한 테이블에 Image와 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
    • @Inheritance 애너테이션 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
@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.TIMESTAM)
	@Column(name = "upload_time")
	private Date uploadTime;

	protected Image() {}

	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}

	protected Sring getPath() {
		return path;
	}

	public Date getUploadTime() {
		return uploadTime;
	}

	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image를 상속 받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
	...
}

@Entity
@DiscriminatorValue("EI")
public class ExteranlImage extends Image {
	...
}
  • Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해 매핑
  • Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존
  • Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정
@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
	private ProductId id;
	private String name;

	@Convert(converter = MoneyConvert.class)
	private Money price;
	private String detail;

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

	public void changeImages(List<Image> newImages) {
		// @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 효율적이지 못함
		images.clear();
		images.addAll(newImages);
	}
}
  • 하이버네이트의 경우 @Entity를 위함 컬렉션 객체의 clear 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대한 delete 쿼리를 실행한다.
    • 목록 조회 select * from image where product_id = ?
    • 삭제 delte from image where image_id = ?
    • 이미지 갯수만큼 삭제 쿼리가 동작하는 문제 발생
  • 하이버네트는 @Embeddable 타입에 대한 컬렉션의 clear 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한번의 delete 쿼리로 삭제 처리를 수행
  • 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현 해야한다.
@Embeddable
public class Image {
	@Column(name = "image_type")
	private String imageType;

	@Column(name = "image_path")
	private String path;

	@Temporal(TemperalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;

	public boolean hasThumbnail() {
		// 성능을 위해 다형성을 포기하고 if-else로 구현
		if (imageType.equals("II")) {
			return true;
		} else {
			return false;
		}
	}
}

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

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

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
  • 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑을 조회 방식을 즉시 로딩으로 설정하면 됨
  • // @Entity 컬렉션에 대한 즉시 로딩 설정 @OneToMany( cascade = {CascadeType.PERSIST, CascadeType.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 = "order_line", joinColumns = @JoinColumn(name = "order_number" ) @OrderColumn(name = "list_idx") private List<OrderLine> orderLines;
  • 컬렉션에 대한 로딩 전략을 즉시로딩으로 설정하면 문제가 될 수 있음
    • Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
      • 카타시안 조인을 사용하고 쿼리 결과에 중복을 발생시킨다.
        • Product의 image가 2개, opation이 2개면 결과로 구해지는 행 개수는 4개가 됨
        • Product의 정보는 4번, image, option 정보는 2번 중복됨
      • 하이버네이트가 중복된 데이터를 알맞게 제거 해주기는 함
        • 애그리거트가 커지면 문제가 발생 할 수 있음
          • 이미지가 20개 option이 15개면 300행을 리턴
    @Entity
    @Table(name = "product")
    public class Product {
    	@OneToMany(
    		cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    		orphanRemoval = true,
    		fetch = FetchType.EAGER
    	)
    	@JoinColumn(name = "product_id")
    	@OrderColumn(name = "list_idx")
    	private List<Image> images = new ArrayList()
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(
    		name = "product_option", 
    		joinColumns = @JoinColumn(name = "product_id"
    	)
    	@OrderColumn(name = "list_idx")
    	private List<Option> options = new ArrayList();
    }
    
  • 애그리거트가 완전해야 하는 이유는 두가지 정도
    • 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
    • 표현 영역에서 애그리거트의 상태 정보를 보여줄때
      • 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않음
  • @Transactional public void removeOptions(ProductId id, int optIdxToBeDeleted) { // 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDeleted); } @Entity @Table(name = "product") 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(optionIdx); } }
  • 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다
    • 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
  • 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다.
    • 즉시 로딩은 @Entity나 @Embeddable에 대해 다르게 동작
    • JPA 프로바이더에 따라 구현 방식이 다를 수 있음
  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 높다
    • 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택 해야 한다.

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

  • 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장할고 삭제할 때도 하나로 처리해야 함을 의미
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제
  • @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
    • @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 한다.
    • @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 CascadeType.PERSIST, CascadeType.REMOVE를 설정
    @OneToMany(
    	cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	orphanRemoval = true,
    	fetch = FetchType.EAGER
    )
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList()
    

4.6 식별자 생성 기능

  • 식별자는 크게 세가지 방식 중 하나로 생성
    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련번호 사용
  • 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리
    • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
  • DB 자동 증감 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용
    • 자동 증감 칼럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 젖아할 때 식별자가 생성
    • 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음
  • 자동 증감 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 저장 시점에 식별자를 생성

4.7 도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 도메인 모델인 article이 영속성 구현 기술인 JPA에 의존하고 있다.
  • @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
  • ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다.
    • 도메인이 인프라에 의존하는 것
    public interface ArticleRepository extends Repository<Article, Long> {
    	void save(Article article);
    	Optional<Article> findById(Long id);
    }
    
  • 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 그림과 같은 구조로 구현한다.
    • 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.
  • DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함
    • 리포지터리와 도메인 모델 구현 기술은 거의 바뀌지 않는다.
    • JPA로 구현한 리포지터리 구현 기술을 마이바티스나 다른 기술로 변경경한 적이 거의 없음
    • RDBMS를 사용하다 몽고DB로 변경하는 경우는 적음
  • 애그리거트, 리포티터리 등 도메인 모델을 구현할때 타협을 할 수 있다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
  • 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각
728x90