DDD 리포지터리와 모델
리포지터리
모듈 위치
리포지터리 인터페이스는 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 가능하면 인프라스트럭처 영역에 위치 시켜서 인프라스트럭처에 대한 의존을 낮춰야 한다.
스프링 데이터 JPA를 사용할 때 리포지터리 인터페이스만 정의하면 나머지 리포지터리 구현 객체는 스프링 데이터 JPA가 알아서 만들어준다. 그래서 리포지터리 인터페이스를 구현한 클래스를 직접 작성할 일은 거의 없다.
매핑 구현
- 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다.
- 밸류는 @Embeddable로 매핑 설정한다.
- 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.
@Entity
public class Order {
...
@Embedded
private Orderer orderer;
@Embedded
private ShippingInfo shippingInfo;
}
@Embeddable
public class Orderer {
// MemberId에 정의된 컬럼 이름을 변경하기 위해 @AttributeOverride 애너테이션 사용
// Orderer의 memberId는 Member 애그리거트를 ID로 참조한다.
@Embedded
@AttributeOverrides(
@AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
)
private MemberId memberId;
@Column(name = "orderer_name")
private String name;
}
@Embeddable
public class MemberId implements Serializable {
// Member의 ID 타입으로 사용되는 MemberId는 id 프로퍼티와 매핑되는 테이블 컬럼 이름으로 "member_id"를 지정한다.
@Column(name = "member_id")
private String id;
}
@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;
}
필드 접근 방식
- 엔티티에 프로퍼티를 위한 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 기반으로 엔티티를 구현할 가능성이 높아진다. 특히 set 메서드는 내부 데이터를 변경하는 수단이 되기 때문에 캡슐화를 깨는 원인이 된다.
- set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다. 예를들어 setState() 보다 cancel() 메서드가 도메인을 더 잘 표현한다.
- 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
@Entity
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
@Column(name = "state")
@Enumerated(EnumType.STRING)
private OrderState state;
... // 도메인 기능 구현
... // 필요한 get 메서드 제공
}
AttributeConverter를 이용한 밸류 매핑 처리
// 두 개 프로퍼티를 한 개 컬럼에 매핑해야 할 때 ex) 1000mm
public class Length {
private int value;
private String unit;
}
두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 컬럼에 매핑하려면 AttributeConverter를 이용해야 한다.
// AttributeConverter 인터페이스를 구현한 클래스는 @Converter를 적용한다.
@Converter(autoApply = true)
public class MoneyConverter 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 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다.
- autoApply 속성을 false로 지정하면 프로퍼티 값을 변환할 때
@Convert(converter = MoneyConverter.class)
와 같이 사용할 컨버터를 직접 지정해야 한다.
밸류 컬렉션 별도 테이블 매핑
@Entity
public class Order {
@EmbeddedId
private OrderNo number;
...
@ElementCollection(fetch = FetchType.EAGER)
@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;
...
}
- @OrderColumn 애너테이션은 지정한 컬럼에 리스트의 인덱스 값을 저장한다.
- @CollectionTable은 밸류를 저장할 테이블을 지정한다. name 속성은 테이블 이름을 지정하고 joinColumns 속성은 외부키로 사용할 컬럼을 지정한다.
밸류 컬렉션 한 개 컬럼 매핑
- 밸류 컬렉션을 별로 테이블이 아닌 한 개 컬럼에 저장해야 할 때가 있다. 예를 들어 이메일 주소 목록을 Set으로 보관하고 DB에는 한 개 컬럼에 콤마로 구분해서 저장해야 할 때가 있다.
- 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);
}
}
@Converter(autoApply = true)
public class EmailSetConverter implements AttributeConverter<EmailSet, String> {
@Override
public String convertToDatabaseColumn(EmailSet emailSet) {
if (emailSet == null) return null;
return emailSet.getEmails().stream()
.map(email -> email.getAddress())
.collect(Collectors.joining(","));
}
@Override
public EmailSet convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
String[] emails = dbData.split(",");
Set<Email> emailSet = Arrays.stream(emails)
.map(value -> new Email(value))
.collect(Collectors.toSet());
return new EmailSet(emailSet);
}
}
밸류를 이용한 ID 매핑
- 식별자 자체를 배률 타입으로 만들 수도 있다. 밸류 타입을 식별자로 매핑하면
@Id
대신@EmbeddedId
애너테이션을 사용한다. - JPA에서 식별자 타입은 Serializable 인터페이스를 상속받아야 한다.
- JPA 내부적으로 엔티티 비교 목적으로
equals()
메서드와hashcode()
값을 사용하므로 식별자 밸류 타입은 두 메서드를 알맞게 구현해야 한다.
@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;
// 밸류 타입으로 식별자를 구현하면 식별자에 기능을 추가할 수 있다.
// 첫 글자로 1세대 주문번호와 2세대 주문번호를 구분.
public boolean is2ndGeneration() {
return number.startsWith("N");
}
...
}
별도 테이블에 저장하는 밸류 매핑
- 별도 테이블에 데이터를 저장한다고 해서 엔티티인 것은 아니다. 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.
- 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지 확인하는 것이다. 하지만 식별자를 찾을 때 매핑되는 테이블의 식별자를 애그리거트 구성요소의 식별자와 동일한 것으로 착각하면 안 된다.
- 특정 테이블의 데이터와 연결하기 위함과 별도 식별자가 필요한 것은 차원이 다르다. 특정 테이블의 데이터와 연결할 때는
@SecondaryTable
과@AttributeOverrides
를 사용한다.
@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;
}
밸류 컬렉션을 @Entity로 매핑하기
- 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에
@Entity
를 사용해야 할 때도 있다. - 상속 구조를 갖는 밸류 타입을 사용하려면
@Embeddable
대신@Entity
를 이용해서 상속 매핑을 처리해야 한다.
ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑
- 애그리거트 간 집합 연관은 성능 상의 이유로 피해야 한다. 그럼에도 불구하고 요구사항을 구현하는 데 집합 연관을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다.
// Product에서 Category로의 단방향 M-N 연관을 ID 참조 방식으로 구현
@Entity
public class Product {
@EmbeddedId
private ProductId id;
@ElementCollection
@CollectionTable(name = "product_category", joinColumns = @JoinColumn(name = "product_id"))
private Set<CategoryId> categoryIds;
...
}
@ElementCollection
을 이용하기 때문에 Product를 삭제할 때 매핑에 사용한 조인 테이블의 데이터도 함께 삭제된다. 애그리거트를 직접 참조하는 방식을 사용했다면 영속성 전파나 로딩 전략을 고민해야 하는데 ID 참조 방식을 사용함으로써 이런 고민을 없앨 수 있다.
Reference
- 도메인 주도 개발 시작하기, 최범균 지음
Comments