최근 개인 공부를 위해 도메인 주도 개발 시작하기 책을 사서 읽고 있습니다.😀
5장까지 읽으며 느낀 점은 사길 정말 잘했다!! 왜 이제 샀을까 라는 생각만 드는 너무 좋은 책인것 같습니다.. JPA와 Domain에 익숙하지 않은 저 같은 분들에게는 꼭 필요한 책이라고 생각합니다!!
해당 글은 5장까지 읽으며 4장의 3번째 내용인 Entity와 value의 여러가지 매핑 구현에 대해서는 꼭 정리를 한번 해야겠다는 생각이 들어 정리하게 되었습니다.
도메인이란 소프트웨어로 해결하고자 하는 문제 영역을 뜻 합니다. 만약 쇼핑몰을 구현한다면 쇼핑몰은 도메인이 되며 주문, 상품, 회원 등 여러 하위 도메인으로 구성되는 것입니다.
아래 그림은 주문, 회원, 상품 도메인을 간단하게 보여주고 있습니다.

그림에서도 알 수 있듯이 주문과 함께 사용되는 엔티티와 밸류를 묶어 Order 애그리거트에 포함 시킬 것이며 주문자인 Orderer는 회원 도메인의 애그리거트 루트인 Member Entity를 참조하며 주문 목록인 OrderLine은 상품 도메인의 애그리거트 루트인 Product Entity를 참조할 예정입니다.
먼저 도메인 코드를 보여드리고 매핑 구현을 시작하겠습니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "purchase_order")
@Entity
public class Order extends BaseEntity {
@EmbeddedId
private OrderNo orderNo; // 주문 식별자
@Embedded
private Orderer orderer; // 주문자
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_number"))
@OrderColumn(name = "line_idx")
private List<OrderLine> orderLines;
@Convert(converter = MoneyConverter.class)
@Column(name = "total_amounts")
private Money totalAmounts; // 주문 총 금액
@Embedded
private ShippingInfo shippingInfo; // 배송지
@Enumerated(EnumType.STRING)
private OrderState state;
@Column(name = "order_date")
private LocalDateTime orderDate;
애그리거트란 연관된 밸류를 개념적으로 묶은 것인데 이러한 애그리거트에 속한 모든 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요하한데 이를 애그리거트의 루트 엔티티라고 합니다.
애그리거트와 JPA 매핑을 위한 기본 규칙입니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "purchase_order")
@Entity
public class Order extends BaseEntity {
@EmbeddedId
private OrderNo orderNo; // 주문 식별자(타입만으로도 식별자임을 알 수 있도록 한 것)
@Embedded
private Orderer orderer;
...
}
이때, OrderNo의 경우 @GeneratedValue(strategy = GenerationType.IDENTITY)을 이용해 DB 자동 증가 컬럼을 사용하지 않고 OrderNo 식별자를 별도로 만들어 줬습니다. OrderNo를 만들어 사용하면 타입만으로도 주문 식별자라는 것을 한 눈에 알 수 있습니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class OrderNo implements Serializable {
@Column(name="order_number")
private String number;
public OrderNo(String number){
this.number = number;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OrderNo orderNo = (OrderNo) o;
return Objects.equals(number, orderNo.number);
}
@Override
public int hashCode() {
return Objects.hash(number);
}
public static OrderNo of(String number){
return new OrderNo(number);
}
}
식별자로 밸류를 사용할 경우 @EmbeddedId 애너테이션을 붙여주며 밸류에는 @Embeddable 애너테이션을 붙여주고 Serializable을 implements 받아야 합니다.
코드를 보면 Orderer 의 경우 주문자인데 @Embedded 애너테이션을 붙여줬습니다. 한 테이블에 엔티티와 벨류 데이터가 같이 있다면 아래와 같이 매핑 설정 합니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class Orderer {
//회원 애그리거트를 참조할때 애그리거트 루트를 참조하면 문제를 야기할 수 있음, ID를 통해서만 참조
@AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
private MemberId memberId;
@Column(name = "orderer_name")
private String name;
public Orderer(MemberId memberId, String name) {
this.memberId = memberId;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Orderer orderer = (Orderer) o;
return Objects.equals(memberId, orderer.memberId) && Objects.equals(name, orderer.name);
}
@Override
public int hashCode() {
return Objects.hash(memberId, name);
}
}
밸류 타입의 객체는 값을 저장하기 때문에 객체 비교할 수 있도록 eqauls와 hashCode를 override 해줘야 합니다.
그리고 JPA는 @Entity와 @Embeddable로 매핑하기 위해서는 public 또는 protected 접근 제한자가 붙은 기본생성자를 제공해야 합니다. 이런 기술적 제약 때문에 그나마 접근 범위가 좁은 @NoArgsConstructor(access = AccessLevel.PROTECTED)을 추가하였습니다.
Orderer 밸류에서는 주문자의 정보를 알기 위해 Member 도메인의 정보가 필요합니다. Orderer의 memberId는 Member 애그리거트를 ID로 참조합니다. 만약 Member 애그리거트를 직접 참조하지 않는 여러 이유가 있지만 가장 큰 이유는 Member의 상태를 Order 애그리거트에서 변경할 수 있게 되기 때문입니다.
public class orderer{
private Member member;
// -> X
//orderer.getMember().changeAddress(newShipopingInfo.getAddress);
// 와 같은 코드로 order 애그리거트에서 member의 상태 접근이 가능해진다.
...
}
Member의 ID를 통한 참조를 하였을 때 @AttributeOverride 애너테이션을 사용하여 매핑할 컬럼의 이름을 변경 하였습니다. AttributeOverride는 매핑 정보를 재정의 해주는 어노테이션 입니다.
Orderer 밸류를 매핑한 것 처럼 ShippingInfo 밸류도 매핑하도록 하겠습니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class ShippingInfo {
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "zipCode", column = @Column(name = "shipping_zip_code")),
@AttributeOverride(name = "address1", column = @Column(name = "shipping_address1")),
@AttributeOverride(name = "address2", column = @Column(name = "shipping_address2"))
})
private Address address;
@Embedded
private Receiver receiver;
@Column(name = "shipping_message")
private String message;
@Builder
public ShippingInfo(Address address, Receiver receiver, String message) {
this.address = address;
this.receiver = receiver;
this.message = message;
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "purchase_order")
@Entity
public class Order extends BaseEntity {
@EmbeddedId
private OrderNo orderNo;
@Embedded
private Orderer orderer;
@Embedded
private ShippingInfo shippingInfo;
...
@Enumerated(EnumType.STRING)
private OrderState state;
@Column(name = "order_date")
private LocalDateTime orderDate;
...
먼저 밸류 타입은 의미를 명확하게 표현하기 위해 사용하는 경우도 있습니다. Order 앤티티의totalAmounts의 경우 Money 밸류를 타입으로 가집니다. Money 밸류의 경우 아래와 같이 value만 프로퍼티로 가집니다.
public class Money {
private int value;
public Money(int value) {
this.value = value;
}
public int getValue(){
return this.value;
}
...
}
그런데 만약 Money 밸류가 아래와 같이 2개의 프로퍼티로 가지고 DB에는 한개의 컬럼으로 가져야 한다면 어떻게 해야할까?
public class Money {
private int value;
private String unit;
...
}
이때 사용할 수 있는 것이 AttributeConverter입니다. AttributeConverter는 밸류 타입과 DB 컬럼 데이터 간의 변환을 처리하기 위한 기능을 정의하고 있습니다.
@Converter
public class MoneyConverter implements AttributeConverter<Money, Integer> {
@Override
public Integer convertToDatabaseColumn(Money attribute) {
return attribute == null ? null : attribute.getValue();
}
@Override
public Money convertToEntityAttribute(Integer dbData) {
return dbData == null ? null : new Money(dbData);
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "purchase_order")
@Entity
public class Order extends BaseEntity {
@EmbeddedId
private OrderNo orderNo;
@Embedded
private Orderer orderer;
@Embedded
private ShippingInfo shippingInfo;
@Convert(converter = MoneyConverter.class)
@Column(name = "total_amounts")
private Money totalAmounts; // 주문 총 금액
@Embedded
private ShippingInfo shippingInfo; // 배송지
...
@Enumerated(EnumType.STRING)
private OrderState state;
@Column(name = "order_date")
private LocalDateTime orderDate;
...
Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있습니다.
public class Order{
...
private List<OrderLine> orderLines;
...
}
밸류 컬렉션을 별도로 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable 애너테이션을 사용합니다.
public class Order{
...
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_number"))
@OrderColumn(name = "line_idx")
private List<OrderLine> orderLines;
...
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class OrderLine {
@Embedded
private ProductId product;
@Convert(converter = MoneyConverter.class)
@Column
private Money price;
@Column
private int quantity;
@Convert(converter = MoneyConverter.class)
@Column
private Money amounts;
...
}
만약 밸류 컬렉션을 한개의 컬럼애 매핑하고 싶을 경우에는 위에서 언급한
AttributeConverter를 사용해서 매핑하면 됩니다.
여기까지 하면 얼추 order에 관련한 매핑 설정이 끝이 납니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "purchase_order")
@Entity
public class Order extends BaseEntity {
@EmbeddedId
private OrderNo orderNo;
@Embedded
private Orderer orderer;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_number"))
@OrderColumn(name = "line_idx")
private List<OrderLine> orderLines;
@Convert(converter = MoneyConverter.class)
@Column(name = "total_amounts")
private Money totalAmounts;
@Embedded
private ShippingInfo shippingInfo;
@Enumerated(EnumType.STRING)
private OrderState state;
@Column(name = "order_date")
private LocalDateTime orderDate;
...
}
하지만 order에는 없지만 추가적으로 매핑하는 방법을 더 알아보도록 하겠습니다.
예를 들어 게시글 데이터를 Article 테이블과 Article_content 테이블로 나눠서 저장한다고 해보겠습니다. 이때 Article과 Article_content를 둘 다 엔티티로 보고 1-1 연관 매핑을 할 수 도 있지만 Article_content는 Article 엔티티의 내용을 담고 있는 밸류로 보는 것이 맞습니다.
이때, article_content를 별도의 테이블에 저장하기 위해 @SecondaryTable 애너테이션을 사용할 수 있습니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@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 = "content",
column = @Column(table = "article_content", name = "content")),
@AttributeOverride(
name = "contentType",
column = @Column(table = "article_content", name = "content_type"))
})
@Embedded
private ArticleContent content;
...
}
@Embeddable
@Access(AccessType.FIELD)
public class ArticleContent {
private String content;
private String contentType;
...
}
여기까지 Order 애그리거트를 구성하는 Entity와 Value에 대해 정리해 보았습니다. 혹시나 틀린 내용이나 잘못된 내용이 있으면 언제든지 말씀해주시면 감사하겠습니다!!
좋은 정보 얻어갑니다, 감사합니다.