매핑 구현
애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다.
한 테이블에 엔티티와 밸류 데이터가 같이 있다면
- 밸류는 @Embeddable로 매핑 설정한다.
- 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.
@Entity
@Table(name = "purchase_order")
public class Order {
@Embedded
private Orderer orderer;
...
}
@Embeddable
public class Orderer {
// memberId에 정의된 칼럼 이름을 변경하기 위해 @AttributeOverride 사용
@Embedded
@AttributeOverrides(
@AttributeOverride(name = "id", column = @Column(name = "order_id"))
)
private MemberId memberId;
@Column(name = "orderer_name")
private String name;
}
@Embeddable
public class MemberId implements Serializable {
@Column(name = "member_id")
private String id;
}
기본 생성자
public class Receiver {
private String name;
private String phone;
// 기본 생성자는 JPA 프로바이더가 객체를 생성할 때만 사용하므로
// protected로 다른 코드에서 사용하지 못하도록 방지
protected Receiver() {} // JPA를 적용하기 위한 기본 생성자
public Receiver(String name, String phone) {
this.name = name;
this.phone = phone;
}
}
필드 접근 방식 사용
* 메서드 방식
@Entity
@Access(AccessType.PROPERTY)
public class Order {
@Column(name = "state")
@Enumerated(EnumType.STRING)
public OrderState getState() {
return state;
}
public void setState(OrderState state) {
this.state = state;
}
...
}
엔티티에 프로퍼티를 위한 공개 getter / setter 를 추가하면 도메인의 의도가 사라지고
객체가 아닌 데이터 기반으로 엔티티를 구현할 가능성이 높아진다.
특히 setter는 내부 데이터를 외부에서 변경할 수 있는 수단이 되기 때문에 캡슐화를 깨는 원인이 될 수 있다.
객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌
필드 방식으로 선택해서 불필요한 getter / setter 메서드를 구현하지 않도록 해야 한다.
* 필드 방식
@Entity
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
@Column(name = "state")
@Enumerated(EnumType.STRING)
private OrderState state;
// cancel(), changeState() 등의 도메인 기능 구현
// getter 제공
}
@Id 또는 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 채택하고,
get 메서드에 위치하면 메서드 접근 방식을 선택한다.
AttributeConverter를 이용한 밸류 매핑 처리
int, long, LocalDate 등과 같은 타입은 DB 테이블의 한 개 칼럼에 매핑된다.
이와 비슷하게 밸류 타입의 프로퍼티를 한 개의 칼럼에 매핑해야 할 때가 있다.
public class Length {
private int value;
private String unit;
}
DB 칼럼인 WIDTH(VARCHAR 20)에 1000mm 처럼 문자열로 저장하고 싶은 경우이다.
두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개의 칼럼에 매핑하려면 @Embeddable 어노테이션을 활용할 수 없다.
이 때 사용하는 것이 AttributeConverter이다.
public interface AttributeConverter <X, Y> {
public Y convertToDatabaseColumn(X attribute);
public X convertToEntityAttribute (Y dbData);
}
@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money,Integer> {
// override...
}
@Converter의 autoApply의 값을 true로 주면 모델에 출연하는 모든 프로퍼티에 대해 Converter가 자동으로 적용된다.
@Entity
@Table(name = "purchase_order")
public class Order {
@Column(name = "total_amounts")
private Money totalAmounts; // 해당 Converter를 적용해서 값 변환
}
만약 @Converter의 autoApply 속성을 false로 지정하면 (default는 false) 프로퍼티 값을 변환할 때 사용할 컨버터를
직접 설정해줘야한다.
public class Order {
@Column(name = "total_amounts")
@Convert(converter = MoneyConverter.class)
private Money totalAmounts;
}
별도 테이블에 저장하는 밸류 매핑
애그리거트에서 루트 앤티티를 뺀 나머지 구성요소는 대부분 밸류이다.
루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해 봐야 한다.
단지 별도 테이블에 데이터를 저장한다고 해서 엔티티인 것이 아니다.
애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것이다.
하지만 식별자를 찾을 때 매핑되는 테이블의 식별자를 애그리거트 구성요소의 식별자와 동일한 것으로 착각하면 안된다.
애그리거트 로딩전략
JPA매핑을 설정할 때 항상 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 것이다.
조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면
애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩(EAGER)로 설정하면 된다.
즉시 로딩 방식으로 설정하면 애그리거트 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있는 장점이 있지만 이것이 항상 좋은 것은 아니다.
컬렉션에 로딩 전략을 즉시 로딩(EAGER)로 설정할 시 테이블 조인이 발생하고
해당 조인은 카사디안 조인을 사용하기에 쿼리 결과에 중복을 발생시킨다.
물론 하이버네이트가 중복된 데이터를 알맞게 제거해주지만, 데이터가 많을 수록 실제 필요한 행 갯수에 비해 발생하는 쿼리가 많아진다.
애그리거트는 개념적으로 하나여야한다는 것은 분명하다.
하지만 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아니다!
애그리거트가 완전해야 하는 이유는 두 가지 정도이다.
1. 상태를 변경하는 기능(setter)을 실행할 때 애그리거트 상태가 완전해야 하기 때문
2. 표현 영역(getter)에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문
이 중에서 두 번째는 별도의 조회 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리하기 때문에
애그리거트의 즉시 로딩과 관련된 문제는 상태 변경(setter)와 더 관련이 있다.
상태 변경을 실행하기 위해 조회 시점에서 즉시 로딩을 사용해 애그리거트를 완전히 로딩할 필요가 없다.
JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않는다.
애그리거트의 영속성 전파
애그리거트가 완전한 상태여야한다는 것은 애그리거트를 조회할 때 뿐만이 아니라
저장하거나 삭제할 때도 하나로 처리해야함을 의미한다.
@Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
반면에 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 해야한다.
@OneToOne, @OneToMany는 cascade 속성의 default값이 없으므로 cascade속성값을 지정해줘야 한다.
@OneToMany(cascade = {CascadeType.RESIST, CascadeType.REMOVE},
orphanRemoval = true)
@JoinColumn(name = "product_id")
@OrderColumn(name = "list_idx")
private List<Image> images = new ArrayList<>();
도메인 구현과 DIP
@Entity
@Table(name = "article")
public class Article {
@Id
@GeneratedValue(strate = GenerationType.IDENTITY)
private Long id;
}
public interface ArticleRepository extends Repository<Article, Long> {
void save(Article article);
Optional<Article> findById(Long id);
}
DIP에 따르면, @Entity, @Table은 구현 기술에 속하므로 도메인 기술은 구현 기술인 JPA에 의존하지 말아야 하는데
해당 코드는 JPA에 의존하고 있다.
DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향받지 않도록 하기 위함이다.
하지만 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다.
DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 필요하다.
복잡도를 높이지 않으면서(JPA를 도메인 모델에 사용하면서) 기술에 따른 구현 제약이 낮다면 어느정도 합리적인 선택이다.
'책 정리 > 도메인 주도 개발 시작하기' 카테고리의 다른 글
Chapter 6. 응용 서비스와 표현 영역 (1) | 2023.12.12 |
---|---|
Chapter 5. 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.12.08 |
Chapter 3. 애그리거트 (0) | 2023.12.01 |
Chapter 2. 아키텍처 개요 (0) | 2023.11.28 |
Chapter 1. 도메인 모델 시작하기 (0) | 2023.11.23 |