Chapter 04. 리포지터리와 모델 구현

beanii·2023년 3월 20일
0

DDD Study

목록 보기
4/11
post-thumbnail

4.1 JPA를 이용한 리포지터리 구현

4.1.1 모듈 위치

  • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속함
  • 리포지터리를 구현한 클래스인프라스트럭처 영역에 속함

4.1.2 리포지터리 기본 기능 구현

public interface OrderRepository {
	Order findById(OrderNo no); //1. ID로 애그리거트 조회
    void save(Order order); //2. 애그리거트 저장
}

👇 JPA의 EntityManager를 이용해서 위의 인터페이스를 구현한 클래스

@Repository
public class JpaOrderRepository implements OrderRepository {
	@PersistenceContext
    private EntityMAnager entityManager;
    
    @Override
    public Order findById(OrderNo id) {
    	return entityMAnager.find(Order.class, id)
    }
    
    @Override
    public void save(Order order) {
    	entityManager.persist(order);
    }
}

스프링과 JPA로 구현할 때는 스프링 데이터 JPA 사용

  • 리포지터리 인터페이스만 정의하면, 리포지터리 구현 객체는 자동 생성됨
  • 실질적으로 리포지터리 인터페이스 구현한 클래스 작성할 일 거의 없음

+) JPQL을 이용한 애그리거트 조회, 애그리거트 삭제 기능



4.2 스프링 데이터 JPA를 이용한 리포지터리 구현

  • 스프링 데이터 JPA
    • 지정한 규칙에 맞게 리포지터리 인터페이스 정의 -> 리포지터리 구현한 객체 자동 생성하고 스프링 빈으로 등록
    • 스프링 데이터 JPA가 인터페이스 찾는 규칙
      - org.springframwork.data.repository.Repository<T, ID> 인터페이스 상속
      - T는 엔티티 타입을 지정하고, ID는 식별자 타입을 지정

  • 스프링 데이터 JPA 기본 규칙
    • 엔티티 저장
      • Order save(Order entity)
      • void save(Order entity)
    • 엔티티 조회 (식별자 이용)
      • Order findBiId(OrderNo id) -> 엔티티 존재하지 않으면, null 반환
      • Optional<Order> findById(OrderNo id) -> 값이 없는 Object 반환
    • 엔티티 조회 (특정 프로퍼티 이용)
      • List<Order> findByOrder(Orderer orderer)
    • 엔티티 조회 (중첩 프로퍼티 이용)
      • List<Order> findByOrdererMemberId(MemberID memberId) -> Orderer 객체의 memberId 프로퍼티
    • 엔티티 삭제
      • void delete(Order order) -> 삭제할 엔티티 전달
      • void deleteBiId(OrderNo id) -> 식별자 전달


4.3 매핑 구현

4.3.1 엔티티와 밸류 기본 매핑 구현

[애그리거트와 JPA 매핑의 기본 규칙]

  • 애그리거트 루트는 @Entity로 매핑
  • 밸류는 @Embeddable로 매핑
  • 밸류 타입 프로퍼티는 @Embedded로 매핑

@Entity
@Tagble(name = "purchase_order")
public class Order {
  ...
  @Embedded
  private Orderer orderer;

  @Embedded
  private ShippingInfo shippingInfo;
  ...
}
  • 주문 애그리거트의 루트 엔티티인 Order는 JPA의 @Entity로 매핑
  • 루트 엔티티는 @Embedded 사용해서 밸류 타입 프로퍼티 설정

@Embeddable
public class Orderer {
  // MemberId에 정의된 칼럼 이름을 변경하기 위해 @AttributeOverride 사용
  @Embedded
  @AttributeOverrides(
      @AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
  )
  private MemberId memberId;

  @Column(name = "orderer_name")
  private String name;
  ...
}
  • Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑
  • @Embeddable 타입에 설정한 칼럼 이름과 실제 칼럼 이름이 다를 때, @AttributeOverrides 이용해서 특정 프로퍼티와 매핑할 칼럼 이름 변경
    ex) 아래 코드를 보면, MemberIdid 프로퍼티와 매핑되는 테이블 칼럼 이름은 member_id임. 그러나 Orderer의 memberId 프로퍼티와 매핑되는 칼럼 이름은 order_id 이므로 @AttributeOverrides애너테이션 이용

@Embaddable
public class MemberId implements Serializable {
  @Column(name = "member_id")
  private String id;
  ...
}

4.3.2 기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것 전달받음
    -> 밸류가 불변 타입이면 더이상 변경할 일이 없으므로 파라미터가 없는 기본 생성자 추가할 필요 없음

BUT!! JPA에서 @Entity@Embeddable로 클래스 매핑하려면 기본 생성자 필요함

  • DB에서 테이터 읽어와 매핑된 객체 생성할 때 기본 생성자 사용해서 객체 생성
  • 다만, 다른 코드에서 사용하면 값이 없는 온전하지 못한 객체 만들게되므로 protected로 선언

4.3.3 필드 접근 방식 사용

[JPA 매핑 처리 방식]

  1. 메서드 방식(프로퍼티 방식)
    • get/set 메서드 필요
      -> 캡슐화 깨짐, 사용 지양

  2. 필드 방식
@Entity
@Access(AccessType.FIELD) //필드 방식 사용
public class Order {
	...
}
  • JPA구현체인 하이버네이트는 @Access 를 이용해서 명시적으로 접근 방식 지정하지 않으면 @Id@EmbeddedId의 위치에 따라 접근 방식 결정

4.3.4 AttributeConverter를 이용한 밸류 매핑 처리

  • @AttributeConverter: 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑
    ex) 길이값(1000)과 단위(mm)의 두 프로퍼티를 갖고 있는 Length -> DB 테이블에는 두 프로퍼티 값을 합친 WIDTH(1000mm) 형식으로 저장
public interface AttributeConverter<X,Y> {
	//X는 밸류타입, Y는 DB 타입
	public Y convertToDatacaseColumn(X attribute); //밸류 -> DB 칼럼
	public X convertToEntityAttribute(Y dbData); //DB 칼럼 -> 밸류
}
@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, Integer> {

	@Override
	public Integer convertToDatabaseColumn(Money money) {
		if(money == null) return null;
		else return money.getValue();
	}

	@Override
	public Money convertToEntityAttribute(Integer value) {
		if(value == null) return null;
		else return new Money(value);
	}
}
  • autoApply = true : 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용
    • false인 경우에는 컨버터 직접 지정

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

  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection@CollectionTable을 함께 사용
@Entity
@Table(name = "purchase_order")
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;
	...
}
  • @CollectionTable : 밸류를 저장할 테이블 지정
  • joinColumns : 외부키로 사용할 칼럼 지정
  • @OrderColumn : 지정한 칼럼에 List의 인덱스 값 저장

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

  • 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때는 AttributeConverter 사용
  • 다만 밸류 컬렉션을 표현하는 새로운 밸류 타입 추가해야 함

ex) 이메일 주소 목록을 Set으로 보관하고 DB에 한 개 칼럼에 콤마로 구분지어 저장하는 경우

public class EmailSet {
	//이메일 집합을 위한 밸류 타입 추가
	private Set<Email> emails = new HashSet<>();

	private EmailSet(Set<Email> emails) {
		this.emails.addAll(emails);
	}

	public Set<Email> getEmails() {
		return Collections.unmodifiableSet(emails);
	}
}
@Converter
public class EmailSetConveter implements AttributeConveter<EmailSet, String> {
	//AttributeConveter 구현
	@Override
	public String convertToDatabaseColumn(EmailSet attribute) {
		if(attribute == null) return null;
		return attribute.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(toSet());
		return new EmailSet(emailSet);
	}
}

4.3.7 밸류를 이용한 ID 매핑

  • 밸류 타입을 식별자로 매핑한 경우
    • @Id 대신 @EmbeddedId 사용
    • 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 구현해야 함
    • 장점: 식별자라는 의미 부각, 식별자에 기능 추가 가능
@Entity
@Table(name = "purchase_order")
public class Order {
	@EmbeddedId
    private OrderNo number;
	...
}
@Embeddable
public class OrderNo implements Serializable {
	//Serializable 구현
	@Column(name = "order_number")
	private String number;

	public boolean is2ndGeneration() { //시스템 세대 구분할 수 있는 기능 추가
		return number.startsWith("N");
  }
  
  // ...
}

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

  • 엔티티의 특정 프로퍼티를 별도 테이블에 보관하기
@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;
	...
}
  • @SecondaryTablename 속성은 밸류를 저장할 테이블 지정, pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼
  • @AttributeOverrides : 해당 밸류 데이터가 저장된 테이블 이름 지정

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

  • 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신에 @Entity 사용해서 상속 매핑 처리
    • 식별자 매핑을 위한 필드도 추가
    • 구현 클래스를 구분하기 위한 타입 식별 칼럼 추가
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) //@Inheritance의 strategy 값을 SINGLE_TABLE으로 설정
@DiscriminatorColumn(name = "image_type") //타입 구분용으로 사용할 칼럼 지정
@Table(name = "image")
public abstract class Image {
...
}
@Entity
@DiscriminatorValue("II") //Image 상속받은 클래스 매핑 설정
public class InternalImage extends Image {
...
}
@Entity
@Table(name = "product")
public class Product {
	...
    @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)
    @JoinColumn(name = "product_id")
    @OrderColumn)name("list_idx")
    private List<Image> images = new ArrayList<>();
}

  • 다만, clear() 메서드의 사용 빈도가 높으면 전체 서비스 성능에 문제가 발생하므로 상속 포기하고 @Embeddable로 매핑된 단일 클래스 구현

항상 코드 유지 보수와 성능의 두 가지 측면 고려해서 구현 방식 선택하기!!


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

  • 3장에서 언급했듯이 애그리거트 간 집합 연관은 성능의 문제로 피하는게 좋음
  • 그러나 요구사항에 따라 집합 연관을 사용하는 것이 유리할 때는 'ID 참조를 이용한 단방향 집합 연관' 적용 가능


4.4 애그리거트 로딩 전략

  • 즉시 로딩(FetchType.EAGER)
    • 장점: 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩으로 설정하면 조회 시점에 애그리거트는 완전한 상태
    • 단점: 쿼리 중복 발생 -> 성능(실행 빈도, 트래픽, 지연 로딩 시 실행 속도 등) 문제 검토

애그리거트는 개념적으로 하나여야 하지만, 루트 엔티티 로딩 시점에 애그리거트에 속한 모든 객체를 로딩해야 하는 것은 아님
-> 애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필요 없음
-> 애그리거트에 맞게 즉시 로딩과 지연 로딩 선택하기!!

  • 애그리거트가 완전해야 하는 경우
    1. 상태를 변경하는 기능 실행하는 경우
    -> JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제 없음
    2. 표현 영역에서 애그리거트의 상태 정보를 보여주는 경우
    -> 별도의 조회 전용 기능과 모델 구현하는 방식이 더 유리


4.5 애그리거트의 영속성 전파

애그리거트가 완전한 상태이다
= 애그리거트 루트를 조회할 때뿐만 아니라 저장, 삭제할 때로 하나로 처리해야 함

  • @Embebddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정 안해도 됨
  • 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성 설정해야 됨
    • @OneToOne, @OnetoManycascade 속성의 기본값 없음
      -> CascadeType.PERSIST, @CascadeType.REMOVE 설정


4.6 식별자 생성 기능

  1. 사용자가 직접 생성
    • 식별자 생성 주체가 사용자 -> 도메인 영역에 식별자 생성 기능 구현할 필요X
      ex) 이메일 주소

  2. 도메인 로직으로 생성
    • 식별자 생성 기능을 구현한 도메인 서비스도메인 영역에 위치시킴
      -> 응용 서비스에서 도메인 서비스 이용해서 식별자 구하고 엔티티 생성
    • 식별자 생성 규칙을 리포지터리에 구현
      -> 리포지터리 인터페이스에 식별자 생성 메서드 추가, 리포지터리 구현 클래스에 기능 구현

  3. DB를 이용한 일련번호 사용
    • DB 자동 증가 칼럼 -> 식별자 매핑에서 @GeneratedValue 사용
      • 도메인 객체 저장 시점에 식별자 생성, 그 이후에 식별자 사용 가능


4.7 도메인 구현과 DIP

4장에서 구현한 리포지터리는 DIP 원칙 어기고 있음

  • 엔티티
    • 구현 기술인 JPA에 특화된 @Entity, @Table, @Id, @Column 등의 애너테이션 사용
    • DIP에 따르면 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 함
  • 리포지터리 인터페이스
    • 도메인 패키지에 위치하는 리포지터리 인터페이스가 구현 기술인 스프링 테이터 JPA의 Repository 인터페이스를 상속하고 있음
      -> 도메인이 인프라에 의존

BUT!! 구현 기술에 의존하지 않는 도메인 모델의 개발은 복잡함

  • 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않음
    -> 거의 없을 변경을 굳이 미리 대비할 필요는 없음
  • 개발 편의성과 실용성을 생각하면 DIP 어기고 JPA에 의존하는 것이 합리적 선택

0개의 댓글