도메인 주도 개발 시작하기 (4) - 레포지터리와 모델 구현

Jaewoo Ha·2022년 12월 7일
0

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

4.1.1 모듈 위치

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

레포지터리 구현 클래스를 domain.impl과 같은 패키지에 위치시킬 수 있지만 인터페이스와 구현체를 분리하기 위한 타협안 같은 것일 뿐 좋은 설계 원칙은 아니다. 가능하면 레포지터리 구현 클래스를 인프라스트럭처 영역에 위치 시켜 인프라스트럭처에 대한 의존을 낮춰야 된다.

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

레포지터리 기본 기능

  • ID 로 애그리거트 조회하기
  • 애그리거트 저장하기
interface OrderRepository {
	fun findById(no: OrderNo): Order
    fun save(order: Order)
}

방법

  1. 인터페이스는 애그리거트 루트를 기준으로 작성한다. 애그리거트는 ValueRoot Entity를 포함하고 있기 때문이다.
  2. 메서드 이름을 작성할 때는 명확하게 작성한다. 혹은 널리 사용되는 규칙을 사용한다. ex) findBy프로퍼티이름(프로퍼티 값)
  3. JPA의 EntityManager를 이용해서 기능을 구현한다.

JPA 사용 이유

  • 애그리거트 수정 결과를 저장소에 반영하는 메서드 구현이 필요 없다. JPA를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영한다.
class ChangeOrderService(private val orderRepository: OrderRepository) {

    @Transactional
    fun changeShippingInfo(no: OrderNo, newShippingInfo: ShippingInfo) {
        val order = orderRepository.findById(no).orElseThrow {throw NotFoundException()}
        order.changeShippingInfo(newShippingInfo)
    }
}

위 코드는 스프링 프레임워크의 트랜잭션 관리 기능을 통해 트랝개션 범위에서 실행이 된다. 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 JPA는 트랜잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위헤 UPDATE 쿼리를 실행한다.

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

스트링 데이터 JPA는 지정한 규칙에 맞게 레포지터리 인터페이스를 정의하면 레포지터리를 구현한 객체를 알아서 만들어 스프링 빈으로 등록해준다.

@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
class Order(
    private val number: OrderNo,
) {
	// order의 식별자    
    var number: OrderNo = number
    private set
}

위와 같이 구현이 되면 레포지터리에서 아래와 같이 구현이 가능하다.

@Repository
interface OrderRepository : JpaRepository<Order, String> {
    fun findByOrderNumber(id: OrderNo): Order?
}

메서드 이름 규칙

저장 메서드 이름

fun save(entity: Order): Order
// or
fun save(entity: Order)

findByID

fun findById(id: OrderNo): Order
// or
fun findById(id: OrderNo): Order?

findBy프로퍼티이름(프로퍼티 값)

fun findByOrderer(orderer: Orderer): List<Order>

중첩 프로퍼티

fun findByOrdererMemberId(memberId: MemberId): List<Order>

삭제

fun delete(order: Order)
// or
fun deleteById(id: OrderNo)

4.3 매핑 구현

4.3.1 Entity와 Value 기본 매핑 구현

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

  • 애그리거트 루트는 Entity 이므로 @Entity로 매핑 설정한다.
  • 한 테이블에 EntityValue가 같이 있다면 Value@EmbeddableValue 타입 프로퍼티는 @Embedded로 매핑 설정한다.
  • 매핑된 테이블의 컬럼 이름과 다를 경우 @AttributeOverrides 애너테이션을 이용한다.

4.3.2 기본 생성자

  • 불변 타입이면 생성 시점에 필요한 값을 모두 전달받으므로 값을 변경하는 set 메서드를 제공하지 않는다.
  • JPA에서 @Entity@Embeddable로 클래스를 매핑하려면 실제로는 아무런 파라미터를 받지 않는 기본 생성자를 추가해야된다.

4.3.3 필드 접근 방식 사용

메서드 방식 매핑

  • get/set 메서드를 구현해야된다.

주의점

  • get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 기반으로 Entity를 구현할 가능성이 높아진다. set 메서드는 내부 데이터를 외부에서 변경할 수 있는 수단이 되기 때문에 캡슐화를 깨는 원인이 될 수 있다.
  • Entity가 객체로서 역할을 하기 위해선 외부에서 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 된다.
  • Value 타입을 불변으로 구현하려면 set 메서드 자체가 필요 없으며, JPA의 구현 방식 때문에 공개 set 메서드를 추가하는 것도 좋지 않다.

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

4.3.4 AttributeConverter를 이용한 Value 매핑 처리

  • Value 타입의 프로퍼티를 한 개 컬럼에 매핑해야될 때 사용한다.
  • 두 개 이상의 프로퍼티를 가진 Value 타입을 한 개 컬럼에 매핑하려면 @Embeddable 애너테이션으로는 처리가 불가하다.
  • AttributeConverterValue 타입과 Column 데이터 간의 변환을 처리하기 위한 기능을 정의한다.

AttributeConverter 인터페이스를 구현한 클래스는 @Converter 애너테이션을 적용한다.
autoApply 속성값을 true로 지정하면 모델에 출현하는 모든 타입의 프로퍼티에 대해 converter를 자동으로 적용한다. DB에 매핑할 때도 converter를 지정하여 사용한다.
autoApplyfalse로 지정하면 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정해야 한다.

4.3.5 Value collection: 별도 테이블 매핑

  • Root Entity 는 하나의 Value Collection과 매핑이 가능하다.

Value Collection을 저장하는 테이블은 외부키를 이용해서 Entity에 해당하는 테이블을 참조한다. 이 외부키는 컬렉션이 속할 Entity를 의미한다. List 타입의 컬렉션은 인덱스 값이 필요하므로 Value Collection 테이블에는 인덱스 값을 저장하기 위한 컬럼도 존재한다.

Value Collection을 별도 테이블로 매핑할 때는 @ElementCollection@CollectionTable을 함께 사용한다.

class Order (
) {
	@EmbeddedId
    var number: OrderNo = number
    private set

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_number"))
    @OrderColumn(name = "line_idx")
    var orderLines: List<OrderLine>
    private set
}

OrderColumn 애너테이션을 이용해서 지정한 컬럼에 리스트의 인덱스 값을 저장하기 때문에 OrderLine에는 List의 인덱스 값을 저장하기 위한 프로퍼티가 존재하지 않는다.

@CollectionTableValue를 저장할 테이블을 지정한다. name 속성은 테이블 이름을 지정하고 joinColumns 속성은 외부키로 사용할 칼럼을 지정한다.

4.3.6 Value Collection: 한 개 컬럼 매핑

  • Value Collection을 별도 테이블이 아닌 한 개 컬럼에 저장해야 할 떄 사용한다.
  • AttributeConverter를 사용하면 Value Collection에 쉽게 매핑이 되지만, AttributeConverter를 사용하려면 Value Collection을 표현하는 새로운 Value 타입을 추가해야된다.

4.3.7 Value를 이용한 ID 매핑

  • 식별자라는 의미를 부각시키기 위해 식별자 자체를 Value 타입으로 만들 수 있다.
  • Value 타입을 식별자로 매핑하면 @EmbeddedId 애너테이션을 사용한다.

사용 방법

  • JPA에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 Value 타입은 Serializable 인터페이스를 상속 받아야 한다.
@Embeddable
class OrderNo(private val orderNumber: String): Serializable {

    @Column(name = "order_number")
    var number: String = orderNumber
        private set
}

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

  • 애그리거트에서 Root Entity를 제외한 나머지 구성요소는 대부분 Value이다. Root Entity외에 또 다른 Entity가 있다면 진짜 Entity인지 의심해야 된다.

애그리거트 확인해야될 점

  • 애그리거트에 Root Entity가 아닌 다른 Entity가 있다면 다른 애그리거트는 아닌지 확인해야된다. 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다.
  • 애그리거트에 속한 객체가 Value인지 Entity인지 구분하는 방법은 고유 식별자를 갖는지 확인하는 것이다. 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아니기 때문이다.

4.3.9 Value Collection을 @Entity로 매핑하기

  • 개념적으로는 Value이지만 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때 사용한다.

사용 방법

  • JPA 는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않으므로 상속 구조를 갖는 Value 타입을 사용하려면 @Embeddable 대신 @Entity를 이용해서 상속 매핑으로 처리해야 한다.
  • @Inheritance 애너테이션 적용
  • strategy 값으로 SINGLE_TABLE 사용
  • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 컬럼 지정

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

  • 집합 연관을 피해야되지만 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다.

주의할 점

  • @ElementCollection을 이용하기 때문에 Root Entity를 삭제할 때 매핑에 사용한 조인 테이블의 데이터도 함께 삭제된다.

장점

  • 애그리거트를 직접 참조하는 방식을 사용했다면 영속성 전파나 로딩 전략을 고민해야 하는데 ID 참조 방식을 사용함으로써 고민을 없앨 수 있다.

4.4 애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다.
  • 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야된다.

방법

  • 조회 시점에서 래그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식(FetchType.EAGER)을 즉시 로딩으로 설정하면 된다.
  • 애그리거트 루트를 구할 때 연관된 구성요소를 DB에서 함께 읽어온다.

단점

  • 컬렉션에 대해 로딩 전략을 FetchType.EAGER로 설정하면 오히려 즉시 로딩 방식이 문제가 된다.
  • 쿼리 결과에 중복을 발생시킬 수 있다.
  • 데이터 개수가 많아질 수록 성능에 문제가될 수 있다.

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

  1. 상태를 변경하는 가능을 실행할 때 애그리거트 상태가 완전해야 된다.
  2. 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문이다.

해결 방법

  • JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 실제로 상태를 변경하는 시점에 필요한 구성요소만 ㄹ딩해도 문제가 되지 않는다.
  • 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높으므로 상태 변경을 위해 지연 로딩을 사용할 때 방생하는 추가 쿼리로 인한 실행 속도 저하는 문제가 되지 않는다.

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

  • 저장 메서드는 애그리거트 루트만 저장하면 안되고 애그리거트에 속한 모든 객체를 저장해야된다.
  • 삭제 메서드는 애그리거트 루트뿐만 아니라 래그리거트에 속한 모든 객체를 삭제해야 한다.

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

4.6 식별자 생성 기능

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

구현 방법

  • 식별자 생성 규칙이 있다면 Entity를 생성할 때 식별자를 Entity가 별도 서비스로 식별자 생성 기능을 분리해야 한다.
  • 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야한다.
  • 응용 서비스는 도메인 서비스를 이용해서 식별자를 구하고 Entity를 생성한다.
  • 특정 값의 조합으로 식별자가 생성되는 것 역시 규칙이므로 도메인 서비스를 이용해서 식별자를 생성할 수 있다.
  • 레포지터리 인터페이스에서 식별자를 생성하는 메서드를 추가하고 레포지터리 구현 클래스에서 알맞게 구현하면 된다.
  • @GeneratedValue 를 사용해서 DB 자동 증가 컬럼을 식별자로 사용한다.

4.7 도메인 구현과 DIP

  • JPA에 특화된 애너테이션을 사용함으로써 DIP 원칙을 어기고 있다.

방법

  • 스트링 데이터 JPA의 repository 인터페이스를 상속받지 않도록 수정하고 repository 인터페이스를 구현한 클래스를 인프라에 위치시켜야 한다.

  • 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위해 DIP를 적용을 하였다. JPA 전용 애넡이션을 사용하지만 도메인 모델을 단위 테스트 하는데 문제가 없다.
  • DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느 정도 유지하는 것이 좋다.
  • 복잡도를 높이지 않으면서 기술에 따른 구현 제약이 낮다면 합리적인 선택이다.
profile
내일의 코드는 더 안전하고 깔끔하게

0개의 댓글