고딩왕 코범석

4장 리포지터리와 모델 구현 본문

Book Lounge/도메인 주도 개발 시작하기

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

고딩왕 코범석 2022. 7. 9. 17:15
반응형

Index

  1. JPA를 이용한 리포지터리 구현
  2. 스프링 데이터 JPA를 이용한 리포지터리 구현
  3. 매핑 구현
    1. 엔티티와 기본 밸류 매핑 구현
    2. 기본 생성자
    3. 필드 접근 방식 사용
    4. AttributeConverter를 이용한 밸류 매핑 처리
    5. 밸류 컬렉션 : 별도 테이블 매핑
    6. 밸류 컬렉션 : 한 개 컬럼 매핑
    7. 밸류를 이용한 ID 매핑
    8. 별도 테이블에 저장하는 밸류 매핑
    9. 밸류 컬렉션을 @Entity로 매핑하기
    10. ID참조와 조인 테이블을 이용한 단방향 M:N 매핑
    11. 매핑 부록
      1. EmbeddedId
  4. 애그리거트 로딩 전략
  5. 애그리거트의 영속성 전파
  6. 식별자 생성 기능
  7. 도메인 구현과 DIP

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

모듈 위치와 기본 기능 구현

  • Repository 인터페이스는 애그리거트와 같이 도메인 영역에 속한다.
  • 구현체는 Infrastructure 영역에 속한다.
  • 기본 기능으로는 Id를 통한 조회, 애그리거트 저장이 있다.
    • 인터페이스는 애그리거트 루트를 기준으로 작성한다.
  • 삭제 기능의 경우 실제로 삭제하지 않고 플래그 값과 같은 것을 통해 지워진 데이터임을 표시한다.

위로

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

스프링 데이터 JPA를 사용하면 org.springframework.data.repository.Repository<T, ID> 인터페이스를 상속하면 인터페이스를 구현한 스프링 빈 객체를 자동으로 등록시켜준다.

위로

매핑 구현

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

  • 애그리거트 루트는 Entity이므로, 클래스에 @Entity를 통해 매핑한다.

  • 밸류 타입은 @Embeddable로 매핑한다.

  • 밸류 타입의 프로퍼티는 @Embedded로 매핑한다.

    • 밸류 타입과 테이블 컬럼명이 다를 경우 @AttributeOverrides를 통해 매핑할 수 있다.
    @Embeddable
    public class Orderer {
    
        @AttributeOverrides(
            // name 속성은 포함된 타입의 필드명
            @AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
        )
        private MemberId memberId;
    }
    
    ...
    
    @Embeddable
    public class MemberId implements Serializable {
    
        @Column(name = "member_id")
        private String id;
    }

기본 생성자

  • 엔티티와 밸류 생성자는 객체 생성 시 필요한 값들을 전달받으며 밸류 타입은 불변을 위해 setter() 를 제공하지 않아야한다. 또한 파라미터 없이 객체를 생성할 수 있는 기본 생성자를 만들지 않아야 한다.
  • 하지만 JPA에서는 JPA 프로바이더가 기본 생성자를 통해 객체를 생성한다.
  • 이 경우 접근 제어자를 protected로 설정하여 다른 코드에서 객체를 마음대로 생성하지 않도록 할 수 있다.

필드 접근 방식 사용

  • 엔티티에 getter(), setter()를 추가하면 도메인의 의도가 사라질 가능성이 높다.
    • setter()를 사용하기 보다는 유의미한 메서드명을 따로 작성하여 어떤 것을 수정할건지에 대해 표현력을 높이기
  • 매핑 방식을 FIELD 방식으로 선택하여 불필요한 getter(), setter()를 제거하기

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 Integer convertToEntityAttribute(Integer value) {
        return value == null ? null : new Money(value);
    }
}

만약 @Converter(autoApply = false)인 경우 엔티티에 직접 @Convert를 명시해줘야한다.

@Entity
public class Order {

    @Column(name = "total_amounts")
    @Convert(converter = MoneyConverter.class)
    private Money totalAmounts;
}

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

데이터베이스의 일대다 관계에서 하나의 엔티티와 여러 밸류로 표현하기 위해서는 @ElementCollection, @CollectionTable을 사용한다.

List 타입 자체가 Index를 가지고 있기 때문에 @OrderColumn을 통해 지정한 컬럼에 리스트의 인덱스 값으로 지정한다.

@Entity
public class Order {

...

@ElementCollection(fetch = EAGER)
@CollectionTable(
    name = "order_line",
    joinColumns = @JoinColumn(name = "order_number") // order의 pk
)
@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;
}

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

밸류 컬렉션을 한 개의 컬럼에 저장해야한다면 AttributeConverter와 별도의 밸류 타입이 필요하다.

public class EmailSet {

    private Set<Email> emails = new HashSet<>();

    // getter, setter
}

...

@Converter(autoApply = false)
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 data) {
        if (data == null) return null;
        String[] emails = data.split(",");
        Set<Email> emailSet = Arrays.stream(emails)
            .map(value -> new Email(value))
            .collect(Collectors.toSet());
        return new EmailSet(emailSet);
    }
}

밸류를 이용한 ID 매핑

식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류로 만들 수 있다. 엔티티에 식별자 밸류 타입을 매핑할 경우는 @EmbeddedId로 매핑해야한다.

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

  • 한 애그리거트에 루트 엔티티 이외에 또다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야한다. 단지 별도 테이블에 데이터를 저장한다 해서 엔티티인 것은 아니다.
  • 만약 엔티티가 확실하다면 다른 애그리거트가 아닌지에 대해도 의심해야한다. 자신만의 독자적인 라이프사이클을 갖는다면 다른 애그리거트일 확률이 높다.
  • 이를 판단하는 방법은 연관된 엔티티가 생성이 함께 되는지, 한 엔티티가 변경되면 함께 변경되어야 하는지에 대해 따져본다.
  • 밸류가 엔티티인지 구분하는 방법은 독자적인 식별자를 갖는지에 대해서 따져본다.
  • 밸류를 한 테이블로 지정하기 위해 @SecondaryTable@AttributeOverride를 사용한다.
    • @SecondaryTable을 이용하면 밸류 타입으로 매핑된 테이블을 Join을 통해 가져온다.
    • 이렇게 되면 루트 애그리거트 엔티티 정보들만 필요할 경우, 밸류 타입의 테이블도 항상 가져오게 되어 불필요한 데이터를 가져오게 된다.
    • 이 경우는 별도의 Entity로 설정하여 지연 로딩 방식으로 설정할 수 있다.

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

  • JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.
  • @Entity를 사용하여 추상 클래스를 만들고 이를 확장하는 방식으로 구현해야한다.
  • 사용하는 애노테이션은 @Inheritance, @DiscriminatorColumn 을 사용한다.

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

  • M:N 연관 관계 시 ID 단방향 참조를 통해 구현한다.
  • 차이점이 있다면 집합의 값에 밸류 대신 연관된 밸류 타입의 ID를 컬렉션으로 지정한다는 차이점이 있다.

매핑 부록

EmbeddedId

  • @EmbeddedId로 Id를 매핑할 경우 @Embeddable 클래스에 대해 Serializable을 구현해야한다.
    • DB에 영속화하는 과정에서 ID를 직렬화해야하는데, @Embeddable 클래스는 클래스 자체로 Id를 취급하기 때문에 Serializable을 구현해야한다.
    • primitive 타입은 기본적으로 직렬화가 가능하고, 래퍼 클래스도 Serializable을 상속받고 있다.

위로

애그리거트 로딩 전략

즉시 로딩과 지연 로딩 선택

  • 애그리거트를 조회할 때, 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다.
  • 이는 JPA에서 즉시 로딩 전략을 통해 해결할 수 있다.
  • 하지만 즉시 로딩을 통해 하나의 엔티티에 속한 컬렉션 밸류 타입 객체들의 갯수가 많다면 성능상의 문제가 될 수 있다.

애그리거트가 완전해야 하는 이유

  1. 상태를 변경하는 기능 실행 시 애그리거트의 상태가 완전해야하기 때문
  2. 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요
    1. 이는 별도의 조회 모델을 작성하여 극복할 수 있다.

애그리거트에 맞는 로딩 전략

  • 조회는 별도의 모델을 구현한다.
  • 변경 시에는 지연 로딩으로 설정하여 연관 없는 객체들을 가져오지 않게 설정한다.

위로

애그리거트의 영속성 전파

애그리거트가 완전한 상태?

  • 애그리거트 루트 조회 뿐만 아니라 저장, 삭제시에도 하나로 처리해야함을 의미한다.
    • 저장 메서드는 애그리거트 루트만 저장하면 안되고, 애그리거트에 속한 모든 객체를 저장해야한다.
    • 삭제 메서드는 애그리거트 루트 뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야한다.
  • @Embeddable 의 경우 함께 저장되고 삭제되므로 cascade 옵션을 추가로 설정하지 않아도 된다.
  • 애그리거트에 속한 @Entity의 경우 cascade 옵션으로 저장과 삭제 시 함께 처리되도록 설정해야한다.

위로

식별자 생성 기능

식별자 생성 기능

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

도메인 로직으로 생성

  • 식별자 생성 규칙이 있다면 엔티티 생성 시 별도의 식별자 생성 기능으로 분리해야한다. 이를 도메인 영역에 위치시킬 수 있다.
  • 응용 서비스가 이를 주입받아 식별자를 구해 엔티티를 생성한다.
  • 다른 방법으로는 Repository 인터페이스에 식별자를 생성하는 메서드를 추가하여 구현체에서 알맞게 구현하면 된다.

DB를 이용한 일련번호 사용

  • @GeneratedValue(strategy = GenerationType.IDENTITY)를 통해 자동 증가 컬럼을 식별자로 사용한다.
  • 자동 증가 컬럼외에도 JPA의 식별자 생성 기능을 사용하는 경우도 엔티티의 저장 시점에 식별자를 생성한다.

위로

도메인 구현과 DIP

적절하게 타협하기

  • 도메인 클래스에 @Entity, @Id와 같은 구현 기술에 대한 어노테이션이 붙어있어 DIP 원칙을 어기고있다.

  • DIP 원칙을 지키려면 다음과 같이 패키지와 클래스를 구성해서 도메인이 구현 기술에 의존하는 것을 없애야한다.

    https://user-images.githubusercontent.com/37062337/178096952-555fb442-1d31-4881-bff7-2a54fe5e1433.png

  • 하지만 변경이 거의 없는 상황에서 DIP 원칙을 지키는 것은 과할 수 있기 때문에 적절한 타협이 필요하다.

위로

반응형