도메인 주도 개발 시작하기 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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을 리턴?
- Order findById(OrderNo id)
- 특정 프로퍼티를 이용해서 엔티티를 조회할 때는 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); }
- AttributeConverter는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의
- 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(); }
- Product를 조회하면 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리 실행
- 애그리거트가 완전해야 하는 이유는 두가지 정도
- 상태 변경하는 기능을 실행할 때 애그리거트 상태가 완전 해야함
- 표현 영역에서 애그리거트의 상태 정보를 보여줄때
- 이 경우에는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리함
- 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를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지 하는게 좋음
- 복잡도를 높이지 않으면서 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 생각
'도메인 주도 개발 스터디' 카테고리의 다른 글
Chapter 7 도메인 서비스 (0) | 2023.11.08 |
---|---|
Chapter 6 응용 서비스와 표현 영역 (0) | 2023.11.08 |
Chapter 5 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.11.08 |
Chapter 2 아키텍처 개요 (0) | 2023.11.08 |
Chapter 1 도메인 모델 시작하기 (0) | 2023.11.08 |