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
  • 도메인 주도 개발 시작하기, 최범균 지음

Categories:

Updated:

Comments