4. 리포지터리와 모델구현

안성은·2022년 12월 20일
0

DDD

목록 보기
1/2

1. JPA를 이용한 리포지터리 구현

  1. Repository Interface는 애그리거트와 같이 도메인 영역, Repository 구현 클래스는 Infra 영역에 둔다.
    => Repository Interface와 Domain / Repository 구현체는 영역 분리를 통해서 의존성을 낮춘다.

  2. Order, OrderLine, Orderer, ShippingInfo 등 객체가 있을 때 Order Root Entity를 기준으로 Repository Interface를 작성한다.

  3. ID 외에 다른 조건으로 애그리거트를 조회할 때에는 JPA의 Criteria나 JPQL을 사용한다.

TIP. 삭제 기능

삭제 요청에 대해서 실제로 삭제하지 않는 경우가 많다.
=> 삭제 플레그를 이용해서 데이터 삭제 처리
1. 실제 서비스를 운영할 때 관리자가 삭제된 데이터를 조회하는 경우
2. 데이터 원복을 위해 일정 기간 동안 보관해야하는 경우


2. 매핑 구현

애그리거트와 JPA 매핑을 위한 기본 규칙

  • 애그리거트 루트는 엔티티이므로 @Entity로 매핑
  • 엔티티에 밸류 데이터가 같이 존재하는 경우
    • 밸류는 @Embeddable로 매핑 설정
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정

애그리거트와 JPA 매핑 예시

  1. 주문 애그리거트의 루트 엔티티인 Order는 JPA의 @Entity로 매핑
@Entity
@Table(name = "purchase_order")
public class Order {
	
    @Embedded
    private Orderer orderer;
    
    @Embedded
    private ShippingInfo shippingInfo;
}
  1. Order에 속하는 Orderer는 Value이므로 @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;
}

2-1. Orderer의 memberId는 Member 애그리거트를 ID로 참조

@Embeddable
public class MemberId implements Serializable {

	@Column(name="member_id")
    private String id;
}

Orderer의 memeberId 프로퍼티와 매핑되는 칼럼 이름은 orderer_id로 MemberId에 설정된 member_id와 이름이 다르다.
=> @Embeddable 타입에 설정한 컬럼 이름과 실제 칼럼 이름이 다르므로 Orderer의 memberId 프로퍼티를 매핑할 때 @AttributeOverrides 애노테이션을 이용해서 매핑할 칼럼 이름을 변경할 수 있다.

  1. Address의 매핑 설정과 다른 컬럼 이름을 사용하기 위해 @AttributeOverride 애노테이션을 사용한다.
@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;
}

기본 생성자

JPA의 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다. 하이버네이트와 같은 JPA 프로바이더는 DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성한다.
=> 기본 생성자는 JPA 프로바이더가 객체를 생성할 때만 사용한다. 때문에 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언한다.

Q. 왜 JPA의 Entity는 기본 생성자를 가져야 하는가?
A. 하이버네이트는 클래스를 상속한 프록시 객체를 이용해서 지연 로딩을 구현하는데 프록시 클래스에서 상위 클래스의 기본 생성자를 호출 할 수 있어야하기 때문에 지연 로딩대상이 되는 @Entity와 @Embeddable은 기본 생성자가 필요하고 protected로 지정
https://hyeonic.tistory.com/191
https://tecoble.techcourse.co.kr/post/2020-07-16-reflection-api/

필드 접근 방식 사용

JPA는 필드와 메서드의 두가지 방식으로 매핑 처리 가능
1. 메서드 방식
- get/set 메서드 구현을 통해서 접근하지만 무분별한 set대신에 의도가 분명한 함수명을 가져야한다.
2. 필드 방식
- 엔티티를 객체가 제공할 기능 중심으로 구현하도록 유도하려면 필드 방식 선택
=> 일반적으로 필드 방식을 많이 사용한다. @Id나 @EmbeddedId가 어디 있는지에 따라 Access 범위가 정해지는데 보통 필드에 있다.

AttributeConverter를 이용한 밸류 매핑 처리

int, long, String, LocalDate와 같이 타입은 DB 테이블의 한 개 칼럼과 매핑된다. 이와 비슷하게 밸류 타입의 프로퍼티를 한 개칼럼에 매핑해야 할 때

public class Length {
	private int value;
    private String unit;
}
=> WIDTH VARCHAR(20)

이때는 @Embeddable로 처리할 수 없다. 그래서 AttributeConverter를 사용한다.

public interface AttributeConverter<X,Y> {
	private Y convertToDatabaseColumn (X aatribute);
    private X convertToEntityAttribute (Y dbData);
}
@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, Integer> {
    @Override
    public integer convertToDataBaseColumn(Money money) {
    	if (money == null) return null;
        return money.getValue();
    }
    @Override
    public Money convertToEntityAttribute(Integer value) {
    	if (money == null) return null;
        return new Money(value);
    }
}
public class Order {
	@Column(name = "total_amounts")
    @Convert(converter = MoneyConverter.class)
    private Money totalAmounts;
}

밸류 컬렉션: 별도 테이블 매핑

Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다. OrderLine의 순서가 있다면 다음과 같이 List 타입을 이용해서 OrderLine 타입의 컬렉션을 프로퍼티로 갖게된다.

public class Order {
	private List<OrderList> orderLines;
}

List 타입의 컬렉션은 인덱스 값이 필요하므로 ORDER_LINE 테이블에는 인덱스 값을 저장하기 위한 칼럼(line_idx)도 존재한다. 또한, 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.

public class Order {
	private List<OrderList> orderLines;
    @ElementCollection
    @CollectionTable(name = "order_line", joinColumns(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;
}

OrderLine에는 List의 인덱스 값을 저장하기 위한 프로퍼티가 존재하지 않는다. 왜냐하면 List 타입 자체가 인덱스를 갖고 있기 때문이다.
=> JPA는 @OrderColumn 애노테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장한다.

@CollectionTable은 밸류를 저장할 때 테이블을 지정할 때 사용.
name 속성으로 테이블 이름을 지정, joinColumns 속성은 외부키로 사용하는 컬럼 지정
=> 두개일 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정

밸류 컬렉션: 한 개 컬럼 매핑

Set으로 보관하고 있는 DB에 한 개 컬럼에 콤마로 구분해서 저장해야 할 때가 있다.
이때, AttributeConverter를 사용하면 밸류 컬렉션을 한 개 컬럼에 쉽게 매핑할 수 있다. 단, AttributeConverter를 사용하려면 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.

public class EmailSet {
	private Set<Email> emails = new HashSet<>();
    private EmailSet() {}
    private EmailSet(Set<Email> emails) {
    	this.emails.addAll(emails);
    }
    public Set<Email> getMails() {
    	return Collections.unmodifiableSet(emails);
    }
}
@Converter
public class EmailSetConverter implements AttrivuteConverter<EmailSet, String> {
	@Override
    public String converToDatabaseColumn(EmailSet attribute) {
    	if (attribute == null) return null;
        return attribute.getEmails().stream()
        .map(Email::toString)
        .collect(Collectors.joining(","));
    }
}
@Column(name = "emails")
@Convert(converter = EmailSetConverter.class)
private EmailSet emailSet;

밸류를 이용한 아이디 매핑

식별자는 문자열이나 숫자가 기본 타입이기 때문에 String이나 Long 타입을 이용해서 식별자를 매핑한다. 밸류 타입을 식별자로 매핑하려면 @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 타입이어야 하므로 식별자로 사용될 밸류 타입은 Serializable 인터페이스를 상속받아야한다.
=> 밸류 타입으로 식별자를 사용하는 경우 메소드를 추가할 수 있다는 장점이 있다.

별도 테이블에 저장하는 밸류 매핑

에그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지 여부를 확인하는 것이다. 하지만, 식별자를 찾을 때 매핑되는 테이블의 식별자를 애그리거트 구성요소의 식별자와 동일하지 않다.

여기서 ARTICLE_CONTENT의 ID는 식별자이기는 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함히지 ARTICLE_CONTENT를 위한 별도 식별자가 필요하기 때문은 아니다. 또한, ArticleContent는 밸류이므로 @Embeddable로 매핑된다.

@Entity
@Table(name = "article")
@SecondaryTable(
	name = "article_content",
    pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article {
	@Id
    private Long id;
    private String title;
    @AttributeOverrides({
    	@AttributeOverride(
        		name = "content", 
        		column = @Column(table = "article_content")),
        @AttributeOverride(
        		name = "contentType", 
        		column = @Column(table = "article_content"))
    })
    private ArticleContent content;
}
  1. @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정한다.
  2. pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정한다.

문제점 => SecondaryTable을 사용하면 두 테이블을 조인해서 데이터를 조회한다. article만 필요한 데 article_content까지 강제로 데이터가 조회된다.

밸류 컬렉션을 @Entity로 매핑하기

개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.

JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다. 따라서 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신 @Entity를 이용한 상속 매핑으로 처리해야 한다.
=> 밸류 타입을 @Entity로 매핑하므로 식별자 매핑을 위한 필드도 추가해야 한다. 또한, 구현 클래스를 구분하기 위한 타입 식별(discriminator) 칼럼 추가

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "image_type")
@Table(name = "image")
public abstract class Image {
	
}

@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
}

@Entity
@DiscriminatorValue("EI")
public class ExternalImage extends Image {
}


Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해서 매핑을 처리한다. Image는 밸류이므로 독자적인 라이프사이클을 갖지 않고 Product에 완전히 의존한다.
=> cascade 속성을 이용해서 Product를 저장할 때 함께 저장, 삭제할 때 함께 삭제되도록 설정

@Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제과정이 비효율적인데 하이버네이트의 경우 @Entity를 위한 컬렉션 객체의 clear() 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대해 delete 쿼리를 실행한다.
=> 애그리거트의 특성을 유지하면서 N + 1문제를 해결하려면 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현해야 하는데, 이 경우 타입에 따라 다른 기능을 구현하기 위해 if-else를 써야하는 단점이 발생한다.

ID 참조와 조인 테이블을 이용한 단방향 M:N 매핑

@Entity
@Table(name = "product")
public class Product {
	@EmbeddedId
    private ProductId id;
 
	@ElementCollection
    @CollectionTable(
    		name = "product_category", 
    		joinColumns = @JoinColumn(name = "product_id"))
   	private Set<CategoryId> categoryIds;
}

Product에서 Category로 단방향 M:N 연관을 ID 참조 방식으로 구현한 것이다. 
ID 참조를 이용한 애그리거트 간 단방향 M:N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정

3. 애그리거트 로딩 전략

JPA 매핑을 설정 할 때 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 되어야한다.

  • fetch = FetchType.EAGER (즉시 로딩) 방식을 사용하면 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있는 장점이 있지만 조인할 때 카타시안 조인 방식을 사용하기 때문에 중복 쿼리가 발생한다.
    => 이는 심각한 성능 저하가 발생한다.

객체가 완전히 하나가 된다는 것은 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아니다.
1. 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전하다.
2. 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하다.

@Transactional
public void removeOptions(ProductId id, int optIdxToBeDeleted) {

	Product product = productRepository.findById(id);

	// 트랜잭션 범위이기 때문에 지연 로딩으로 설정한 연관 로딩 가능
	product.removeOption(optIdxToBeDeleted);
}
@Entity
public class Product {

	@ElementCollection(fetch = FetchType.LAZY)
    private List<Option> options = new ArrayList<>();

	public void removeOption(int optIdx) {
    	//실제 컬렉션에 접글할 때 로딩
    	this.options.remove(optIdx);
    }
}

JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 실제로 상태를 변경하는 시점에 필요한 구성 요소만 로딩해도 문제가 되지 않는다.
지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요는 없지만 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 더 높다.
=> 무슨 말이지?..


4. 애그리거트 영속성 전파

애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장하고 삭제할 때도 하나로 처리해야 한다는 것을 의미한다.

  • 저장 메서드는 애그리거트 루트 + 속한 모든 객체를 저장해야한다.
  • 삭제 메서드는 애그리거트 루트 + 속한 모든 객체를 삭제해야한다.

@Embeddable 매핑 타입의 경우 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다. 하지만, 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시 함께 처리되도록 설정

@OneToMany,@OneToOne 역시 cascade 속성 값 설정해야한다.


5. 식별자 생성 기능

식별자 생성 방법

  • 사용자가 직접 생성
  • 도메인 로직으로 생성
  • DB를 이용한 일련번호 사용

식별자 생성 규칙은 도메인 규칙으므로 도메인 영역에 식별자 생성 기능을 위치

  • 서비스
public class CreateProductService {
	@Transactional
    public ProductId createProduct(ProductCreationCommand cmd) {
    	ProductId id = productIdService.nextId();
    }
}
  • 리포지토리
public interface ProductRepository {
	ProductId nextId();
}
  • 도메인 객체
public class Article {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}
도메인 객체를 리포지터리에 저장할 때 식별자가 생성되기 때문에 도메인 객체를 생성하는 
시점에는 식별자를 알 수 없고 도메인 객체를 저장한 뒤에 식별자를 구할 수 있다.

0개의 댓글