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

도메인 주도 개발 시작하기 4장을 요약한 내용입니다.

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

모듈 위치

리포지터리 인터페이스는 애그리거트와 같은 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭처 영역에 속한다.

  • 기능하면 리토지터리 구현 클래스를 인프라스트럭처 영역에 위치 시켜서 인프라스트럭처에 대한 의존을 낮추자.

리포지터리 기본 기능 구현

리포지터리 인터페이스는 애그리거트 루트를 기준으로 작성하자.

public interface OrderRepository {
    Optional<Order> findById(OrderNo no);
    void save(Order order);
    List<Order> findByOrdererId(String ordererId, int startRow, int size);
}

애그리거트 조회는 ID 외에도 JPA Criteria, JPQL을 사용할 수도 있다.

사용자가 삭제 기능을 실행할 때 데이터를 바로 삭제하기보다는 삭제 플래그를 사용해서 데이터를 화면에 보여줄지 여부를 결정하는 방식으로 구현하자.

관리자 기능에서 삭제 데이터 조회를 해야 하거나 데이터 원복을 위해 일정 기간 보관해야 할 경우도 있다.

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

스프링과 JPA를 함께 적용할 때는 Spring Data JPA를 사용한다.

  • 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 만들어 스프링 빈으로 등록

아래 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동 등록

  • org.springframework.data.repository.Repository<T, ID> 인터페이스 상속

  • T는 엔티티 타입, ID는 식별자 타입

Spring Data JPA 사용을 위해 지정한 규칙에 맞게 메서드를 작성해야 한다.

매핑 구현

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

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

  • 애그리거트 루트는 엔티티이므로 @Entity 매핑 설정

@Entity
@Table(name = "purchase_order")
public class Order {
    @EmbeddedId
    private OrderNo number;

    @Embedded
    private Orderer orderer;

    @Embedded
    private ShippingInfo shippingInfo;
    ...

한 테이블에 엔티티와 밸류 데이터가 같이 있다면

  • 밸류는 @Embeddable로 매핑 설정

  • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정

@Embeddable
public class Orderer {

    @Embedded
    @AttributeOverrides( // 컬럼명과 실제 컬럼명이 다르므로 매핑 컬럼 변경        
        @AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
    )
    private MemberId memberId; // Member 애그리거트를 ID로 참조

    @Column(name = "orderer_name")
    private String name;
    ...
}

...

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

@Embeddable
public class ShippingInfo {
    @Embedded
    @AttributeOverrides({ // 매핑 설정과 다른 컬럼 이름을 사용하기 위한 설정의 다른 예
            @AttributeOverride(name = "zipCode", column = @Column(name = "shipping_zip_code")),
            @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;
    ...
}

기본 생성자

앤티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.

  • 불변 타입이면 생성 시점에 필요한 값을 모두 전달받고, 값을 변경하는 set 메서드는 미제공

  • 기본 생성자는 불필요

하지만, JPA에서 @Entity, @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다.

  • DB에서 데이터를 읽어와 매핑 객체를 생성할 때 기본 생성자 사용

다른 코드에서 기본 생성자를 사용하지 못 하도록 protected 선언을 해주자.

  • 기본 생성자는 JPA 프로바이더가 객체 생성 시에만 사용

@Embeddable
public class Receiver {
    @Column(name = "receiver_name")
    private String name;

    @Column(name = "receiver_phone")
    private String phone;

    protected Receiver() {
    }

    public Receiver(String name, String phone) {
        this.name = name;
        this.phone = phone;
    }
    ...
}

필드 접근 방식 사용

객체가 제공할 기능 중심으로 엔티티를 구현하도록 유도하려면, JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 않도록 하자.

  • 하이버네이트는 @Access로 접근 방식을 지정하지 않으면 @Id, @Embeddable 위치에 따라 접근 방식을 결정

  • 필드에 위치하면 필드 접근 방식을 선택

  • get 메서드에 위치하면 메서드 접근 방식 선택

@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {

    @EmbeddedId
    private OrderNo number;

    @Column(name = "state")
    @Enumerated(EnumType.STRING)
    private OrderState state;
    ...
}

AttributeConverter를 이용한 밸류 매핑 처리

AttributeConverter는 다음과 같이 밸류 타입과 컬럼 데이터 간의 변환을 처리하기 위한 기능을 정의

  • X는 밸류 타입

  • Y는 DB 타입

package javax.persistence;

public interface AttributeConverter<X, Y> {
    Y convertToDatabaseColumn(X var1); // 밸류 타입을 DB 컬럼 값으로 변환
    X convertToEntityAttribute(Y var1); // DB 컬럼 값을 밸류로 변환
}

Money 밸류 타입을 위한 AttributeConverter 예시

  • AttributeConverter 구현 클래스는 @Converter 적용

  • autuApply = true 지정 시 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 AttributeConverter 자동 적용

  • autuApply = false(default) 지정 시 프로퍼티 값 변환 시 사용할 컨버터를 직접 지정

@Converter(autuApply = 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);
    }
}

...

/**
 * autuApply = false 일 경우
 */
@Column(name = "total_amounts")
@Convert(converter = MoneyConverter.class)
private Money totalAmounts;

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

밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection, @CollectionTable을 함께 사용

  • @OrderColumn을 이용해서 지정한 컬럼에 리스트의 인덱스 값을 저장.(List 타입 자체가 인덱스를 가지고 있다)

  • @CollectionTable은 밸류를 저장할 테이블 지정(name: 테이블 이름, joinColumns: 외부키로 사용할 컬럼)

@Entity
@Table(name = "purchase_order")
public class Order {

    @EmbeddedId
    private OrderNo number;
        
    @ElementCollection(fetch = FetchType.LAZY)
    @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;

    @Convert(converter = MoneyConverter.class)
    @Column(name = "price")
    private Money price;

    @Column(name = "quantity")
    private int quantity;
    ...
}

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

밸류 컬렉션을 별도 테이블이 아닌 한 개 컬럼에 저장해야 할 경우

  • 도메인 모델에는 이메일 주소 목록을 Set으로 보관하고, DB에는 한 개의 컬럼에 콤마로 구분해서 저장

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);
    }
}

...

public class EmailSetConverter implements AttributeConverter<EmailSet, String> {
    @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);
    }
}

...

@Column(name = "emails")
@Convert(converter = EmailSetConverter.class)
private EmailSet emails;

밸류를 이용한 ID 매핑

밸류 타입을 식별자로 매핑하면 @Id 대신 @Embeddable을 사용한다.

  • 식별자 타입은 Serializable 타입이어야 하므로, 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다.

@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는 내부적으로 엔티티 비교 목적으로 equals(), hashcode() 값을 사용하므로 식별자로 사용할 밸류 타입은 이 두 메서드를 알맞게 구현해야 한다.

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

애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.

  • 다른 엔티티가 있다면 진짜 엔티티인지 의심해 보자.

    • 다른 애그리거트는 아닌지 확인해 보자.

    • 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다.

  • 별도 테이블에 데이터를 저장한다고 엔티티는 아니다.

애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지 확인하는 것이다.

  • 별도 테이블로 저장하고 테이블에 PK가 있다고 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아니다.

밸류를 별도 테이블에 저장해야 한다면, 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable, @AttributeOverrides을 사용한다.

  • @SecondaryTable: name 속성은 밸류를 저장할 테이블, pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 컬럼 지정

    • Article 조회 시 Article, ArticleContent 두 테이블을 조인해서 데이터를 조회

  • @AttributeOverrides: 해당 밸류 데이터가 저장된 테이블 이름 지정

@Entity
@Table(name = "article")
// 목록 화면에 보여줄 Article 조회 시 불필요한 ArticleContent 테이블까지 조인해서 데이터를 읽어오는 문제가 있다.
@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;
}

Article 목록 화면 조회 시 불필요한 ArticleContent 테이블까지 조인해서 데이터를 읽어오는 문제가 있다.

  • 문제 해결을 위해 ArticleContent를 엔티티로 매핑하고 지연 로딩 방식으로 설정할 수도 있지만, 밸류 모델을 엔티티로 만드는 방식이 좋은 방법은 아니다.

  • 이럴 경우 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.

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

개념적으로 밸류인데 구현 기술 한계나 팀 표준으로 @Entity를 사용해야 할 경우이다.

상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신 @Entity를 이용해서 상속 매핑으로 처리해야 한다.

  • 밸류 타입을 @Entity로 매핑하므로 식별자 매핑을 위한 필드구현 클래스를 구분하기 위한 타입 식별 컬럼을 추가해야 한다.

├── Product
   └──Image
      ├── InternalImage
      └── ExternalImage
└── 

한 테이블에 Image와 그 하위 클래스를 매핑하기 위해

  • @Inheritance

    • InheritanceType.SINGLE_TABLE

  • @DiscriminatorColumn으로 타입 구분용 컬럼 지정

  • 상속받은 클래스는 @Entity, @DiscriminatorValue를 사용해서 매핑 설정

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "image_type")
@Table(name = "image")
public abstract class Image {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "image_id")
    private Long id;

    @Column(name = "image_path")
    private String path;

    @Column(name = "upload_time")
    private LocalDateTime uploadTime;

    protected Image() {
    }

    public Image(String path) {
        this.path = path;
        this.uploadTime = LocalDateTime.now();
    }
    ...
    // Image를 @Entity 매핑했지만 모델에서는 밸류이므로 상태 변경 기능은 제외
}

...

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

}

...

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

}

Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany로 매핑 처리

  • Image는 밸류이므로 독자적인 라이프 사이클이 없고, Product에 완전히 의존

  • cascade: Product와 함께 저장, 삭제되도록 지정

  • orphanRemoval: 리스트에서 Image 객체를 제거하면 DB에서 함께 제거되도록 지정

@Entity
@Table(name = "product")
public class Product {
    @EmbeddedId
    private ProductId id;

    @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
            orphanRemoval = true, fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList<>();

    ...
    public void changeImages(List<Image> newImages) {
        images.clear();
        images.addAll(newImages);
    }
}

여기서, images.clear() 메서드 호출의 삭제 과정은 비효율적이다.

  • 하이버네이트는 @Entity를 위한 컬렉션 객체의 clear() 메서드 호출 시 selet 쿼리로 대상 엔티티 로딩 후, 각 개별 엔티티에 대해 delete 쿼리를 실행한다.

  • 변경 빈도가 낮다면 괜찮지만 빈도가 높아지면 전체 서비스 성능에 문제가 될 수 있다.

반면, 하이버네이트는 @Embeddable 타입에 대한 컬렉션의 clear() 메서드 호출 시 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다.

  • 애그리거트의 특성을 유지하면서 이 문제를 해소하려면, 상속을 포기하고 @Embeddable 매핑된 단일 클래스로 구현해야 한다.

  • 타입에 따라 다른 기능을 구현해야 한다면 if-else를 사용해야 한다.

코드 유지 보수와 성능의 두 가지 측면을 고려해서 구현 방식을 선택해 보자.

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

애그리거트 간 집합 연산은 성능 상의 이유로 피하는게 좋지만, 요구사항을 구현하는 데 집합 연산을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해볼 수 있다.

  • Product -> Category로의 단방향 M-N 연관을 ID 참조 방식으로 구현한 것이다.

  • 밸류 컬렉션 매핑과 동일한 방식으로 설정

  • 차이점이라면 집합의 값에 밸류 대신 연관을 맺는 식별자가 오는 것

@Entity
@Table(name = "product")
public class Product {
    @EmbeddedId
    private ProductId id;

    @ElementCollection(fetch = FetchType.LAZY)
    @CollectionTable(name = "product_category",
            joinColumns = @JoinColumn(name = "product_id"))
    private Set<CategoryId> categoryIds;

@ElementCollection을 이용하므로 Product 삭제 시 매핑에 사용된 조인 테이블 데이터도 함께 삭제된다.

  • 애그리거트를 직접 참조한다면 영속성 전파나 로딩 전략을 고민해야 하지만 ID 참조 방식으로 고민을 덜 수 있다.

애그리거트 로딩 전략

JPA 매핑 설정 시 항상 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 것이다.

  • 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태

애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩(EAGER)으로 설정하면 된다.

// @Entity 컬렉션에 대한 즉시 로딩 설정
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
            orphanRemoval = true, fetch = FetchType.EAGER)
@JoinColumn(name = "product_id")
@OrderColumn(name = "list_idx")
private List<Image> images = new ArrayList<>();

// @Embeddable 컬렉션에 대한 즉시 로딩 설정
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_number"))
@OrderColumn(name = "line_idx")
private List<OrderLine> orderLines;

즉시 로딩 방식은 애그리거트 루트 로딩 시점에 애그리거트에 속한 모든 객체를 함께 로딩한다. 하지만, 컬렉션에 즉시 로딩을 설정하면 문제가 발생할 수 있다.

  • Product 조회에 Image, Option 테이블도 함께 조인할 경우 카타시안 조인이 사용되면서 쿼리 결과에 중복이 발생한다.

  • product에 image 2개, option 2개라면 카타시안 조인 쿼리로 4개의 행이 조회된다.

    • product 테이블 정보는 4번 중복, imgae, option 테이블 정보는 2번 중복

하이버네이트가 중복 데이터를 제거해서 객체로 변환해 주지만 애그리거트가 커지면 문제가 될 수 있다.

  • 보통 조회 성능 문제 때문에 즉시 로딩 방식을 사용하지만 이렇게 조회되는 데이터 개수가 많아지면 즉시 로딩 방식을 사용 시 성능(실행 빈도, 트래픽, 지연 로딩 시 실행 속도 등)을 검토해 보아야 한다.

애그리거트는 개념적으로 하나여야 한다. 애그리거트가 완전해야 하는 이유는

  • 상태를 변경하는 기능을 실행 시 애그리거트 상태가 완전해야 함

  • 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요

두 번째 사유는 별도 조회 전용 기능과 모델을 구현하는 방식을 사용하면 되지만, 첫 번째 사유는 조회 시점에 즉시 로딩으로 로딩할 필요는 없다.

  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하므로 실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제되지 않는다

  • 일반적으로 상태 변경 기능 실행 빈도보다 조회 기능 실행 빈도가 훨씬 높다.

@Transactional
public void revmoeOptions(ProductId id, int optIdxToBeDeleted) {
    // Product 로딩. 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음
    Product product = productRepository.findByid(id);
    
    // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능
    product.removeOption(optIdxToBeDeleted);
}

...

@Entity
public class Product {
	@ElementCollection(fetch = FetchType.LAZY)
	@CollectionTable(name = "product_option",
		joinColumns = @JoinColumn(name = "product_id"))
	@OrderColumn(name = "list_idx")
	private List<Option> options = new ArrayList<>();

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

애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필요는 없다.

  • 지연 로딩은 동작 방식이 항상 동일하므로 즉시 로딩처럼 경우의 수를 따질 필요가 없다.

  • 즉시 로딩은 @Entity, @Embeddable에 대해 다르게 동작하고, JPA 프로바이더에 따라 구현 방식에 차이가 있다.

  • 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 더 높다.

무조건 설정하기보다 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택하자.

애그리거트의 영속성 전파

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

  • 저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 한다.

  • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 한다.

@Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.

@Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정 필요

@OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 cascade 속성값으로 CascadeType.PERSIST, CascadeType.REMOVE 를 설정하자.

@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)
@JoinColumn(name = "product_id")
@OrderColumn(name = "list_idx")
private List<Image> images = new ArrayList<>();

식별자 생성 기능

식별자는 크게 세 가지 방식 중 하나로 생성

  • 사용자가 직접 생성

  • 도메인 로직으로 생성

  • DB를 이용한 일련번호 사용

식별자 생성 규칙은 도메인 규칙이므로 도메인 영역(도메인 서비스) 에 식별자 생성 기능을 위치시켜야 한다.

public class ProductIdService {
    public ProductId nextId() {
        ...
    }
}

...

public class CreateProductService {
    @Autowired private ProductIdService productIdService;
    @Autowired private ProductRepository productRepository;

    @Transactional
    public ProductId createProduct(ProductCreationCommand cmd) {
        // 응용 서비스는 도메인 서비스를 이용해서 식별자를 생성
        ProductId id = productIdService.nextId();
        Product product = new Product(id, cmd.getDetail(), cmd.getPrice(), ...);
        productRepository.save(product);
        return id;
    }
}

식별자 생성 규칙을 구현하기에 적합한 또 다른 장소는 리포지터리

  • @GeneratedValue: DB 자동 증가 컬럼을 식별자로 사용

  • 도메인 객체를 리포지터리에 저장할 때 식별자가 생성

public interface ProductRepository {
    ...

    ProductId nextId();
}

...

@Entity
@Table(name = "article")
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    public Long getId() {
        return id;
    }
}

...

public class WriteArticleService {
    private ArticleRepository articleRepository;

    public Long write(NewArticleRequest req) {
        Article article = new Article(...);
        articleRepository.save(article); // EntityManager#save() 실행 시 식별자 생성
        return article.getId(); // 저장 이후 식별자 사용
    }
}

도메인 구현과 DIP

도메인에서 구현 기술에 대한 의존을 없애고 순수하게 유지하려면 구현 클래스를 인프라에 위치시켜야 한다.

  • Spring Data JPA Repository 인터페이스 상속 없애기

  • Repository 구현체 인프라에 위치시키기

  • 도메인 클래스에서 @Entity, @Table 같은 JPA 특화 애너테이션 지우기

  • JPA 연동을 위한 클래스 추가하기

  • ...

DIP 적용의 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향받지 않도록 하기 위함이다.

  • 하지만, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다.

  • 변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다.

DIP를 완벽하게 지키면 좋지만, JPA 애노테이션을 도메인 모델에 사용하면서도 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느 정도 유지할 수 있다.

  • 복잡도를 높이지 않으면서 기술에 따른 구현 제약이 낮다면 합리적인 선택일 수 있다.

  • 도메인 모델 단위 테스트와 리포지터리 테스트도 문제 없다.

Last updated