리포지토리, Repository와 모델 구현

706__·2023년 12월 4일

DDD

목록 보기
4/11

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

이 장의 주제는 Repository 구현이다.
애그리거트를 어떤 저장소에 저장하느냐에 따라 구현 방법이 다르기 때문에 모든 구현 기술에 대해 알아볼 수는 없다.
데이터 보관소로 RDBMS를 사용할 때, 객체 기반의 도메인 모델과 관계형 데이터 모델 간의 매핑을 처리하는 기술로 ORM만한 것이 없다.
자바의 ORM 표준에 해당하는 JPA 를 이용한 구현 방법에 대해 살펴보자.

모듈 위치

Repository 인터페이스는 애그리거트와 같이 Domain 영역에 속하고, 구현체는 Infrastructure 영역에 속한다.

팀 표준에 따라 구현체를 domain.impl과 동일한 패키지에 위치시킬 수도 있다.
그러나 이는 인터페이스와 구현체의 분리를 위한 타협안으로, 좋은 설계 원칙은 아니다.
가능하면 구현체를 Infrastructure 영역에 위치시킴으로써 인프라스트럭처에 대한 의존을 낮춰야 한다.

Repository 기본 기능 구현

리포지토리가 제공하는 기본 기능은 다음 두 가지다.

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

리포지토리 인터페이스는 애그리거트 루트를 기준으로 작성한다.
주문 애그리거트는 Order 루트 엔티티를 비롯해 OrderLine · Orderer · ShippingInfo 등 다양한 객체를 포함한다.
이 구성요소 중에서 루트 엔티티를 기준으로 리포지토리 인터페이스를 작성해야 한다.

@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를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 데이터베이스에 반영하기 때문이다.

public class ChangeOrderService {
	@Transactional
    public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
    	Optional<Order> orderOpt = orderRepository.findById(no);
        Order order = orderOpt.orElseThrow(() -> new OrderNotFoundException());
        order.changeShippingInfo(newShippingInfo);
    }
    ...
}    

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

조회

ID가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy 뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다.
한 개 이상의 Order 객체를 리턴할 수 있으므로 컬렉션 타입 중 하나를 선택할 수 있다.
ID 외에 다른 조건으로 애그리거트를 조회할 때에는 JPA의 Criteria나 JPQL을 사용할 수 있다.

public interface OrderRepository {
    List<Order> fidByOrdererId(String ordererId, int startRow, int size);
}
public class JpaOrderRepository implements OrderRepository {
	
    @Override
    public List<Order> fidByOrdererId(String ordererId, int startRow, int size) {
    	TypedQuery<Order> query = entityManager.createQuery(
        	"select o from Order o where o.orderer.memberId.id = :ordererId " +
            "order by o.number.number desc",
            ORder.class);
        query.setParameter("ordererId", ordererId);
        query.setFirstResult(startRow);
        query.setMaxResults(fetchSize);
        return query.getResultList();
    }
}    

삭제

public interface OrderRepository {
    public void delete(Order order);
}
public class JpaOrderRepository implements OrderRepository {
	@PersistenceContext
    private EntityManager entityManager;
    
    ...
    @Override
    public void delete(Order order) {
    	entityManager.remove(order);
    }
}    

삭제 요구사항이 있더라도 데이터를 실제로 삭제하는 경우는 많지 않다. 관리자 기능에서 삭제한 데이터까지 조회해야 하는 경우도 있고 데잍 원복을 위해 일정 기간동안 보관해야 할 때도 있기 때문이다. 이런 이유로 사용자가 삭제 기능을 실행할 때 데이터를 바로 삭제하기보다는 삭제 플래그를 사용해서 데이터를 화면에 보여줄지 여부를 결정하는 방식으로 구현한다.


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

스프링과 JPA를 함께 적용할 때는 스프링 데이터 JPA를 사용한다.
스프링 데이터 JPA는 지정한 규칙에 맞게 인터페이스를 정의하면 구현체를 알아서 만들어 Spring Bean 으로 등록해준다.

스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아 구현체에 해당하는 스프링 빈 객체를 자동으로 등록한다.

  • org.springframework.data.repository.Repository<T, ID> 인터페이스 상속
  • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {

	@EmbeddedId
    private OrderNo number; // 식별자 타입 OrderNo
    ...
}
public interface OrderRepository extends Repository<Order, OrderNo> {
	Optional<Order> findById(OrderNo id);
    void save(Order order);
}

스프링 데이터 JPA는 OrderRepository 를 인식해서 구현체를 스프링 빈으로 등록한다.
필요하면 다음 코드처럼 주입받아서 사용하면 된다.

@Service
public class CancelOrderService {

	private OrderRepository orderRepository;
    
    public CancelOrderService(OrderRepository orderRepository, ...) {
    	this.orderRepository = orderRepository;
        ...
    }
    
    @Transactional
    public void cancel(OrderNo orderNo, Canceller canceller) {
    	Order order = orderRepository.findById(orderNo)
        		.orElseThrow(() -> new NoOrderException());
        if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
        	throw new NoCalcellablePermission();
        }
        order.cancel();
    }
}    

스프링 데이터 JPA를 사용하려면 지정한 규칙에 맞게 메서드를 작성해야 한다.


매핑 구현

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

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

  • 밸류는 @Embeddable로 매핑 설정한다.
  • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.
@Entity
@Table(name = "purchase_order")
public class Order {
	
    // MemberId에 정의된 컬럼 이름 변경을 위해 @AttributeOverride 사용
    @Embedded
    @AttributeOverrides(
    	@AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
    )
    private MemberId memberId;
    
    @Column(name = "orderer_name")
    private String name;
}    
@Embeddable
public class MemberId implements Serializable {
	@Column(name = "member_id")
    private String id;
}    

기본 생성자

엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달받는다.
JPA에서 @Entitiy와 @Embeddable로 클래스를 매핑하기 위해 기본 생성자를 제공해야 한다.
데이터베이스에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용하기 떄문이다.
이런 기술적인 제약으로, 불변 타입으로 구성된 엔티티 클래스에 기본 생성자를 추가해야 한다.

이 때, 기본 생성자는 JPA 프로바이더가 객체를 생성할 때만 사용한다.
기본 생성자를 다른 코드에서 사용하면 값이 온전하지 못한 객체를 만들게 된다.
이런 이유로 다른 코드에서 기본 생성자를 사용하지 못하도록 protected 로 선언한다.

@Embeddable
public class Receiver {
	...
    
    protected Receiver() {}
    
    public Receiver(Strig name, String phone) {
    	this.name = name;
        this.phone = phone;
    }
    
    // getter
}    

필드 접근 방식 사용

JPA는 필드와 메서드, 두 가지 방식으로 매핑을 처리할 수 있다.

메서드 방식

  • 메서드 방식을 사용하려면 다음과 같이 프로퍼티를 위한 gettersetter 를 구현해야 한다.
  • 프로퍼티를 위한 공개 getter / setter를 추가하면 도메인의 의도가 사라지고 객체가 아닌, 데이터 기반으로 엔티티를 구현할 가능성이 높아진다.
  • 특히, setter 는 내부 데이터를 외부에서 변경할 수 있는 수단이 되기 때문에 캡슐화를 깨는 원인이 될 수 있다.
  • 엔티티가 객체로서 제 역할을 할 수 있도록 setter 대신 의도가 잘 드러나는 기능을 제공해야 한다.
@Entity
@Access(AccessType.PROPERTY)
public class Order {

	@Column(name = "state")
    @Enumerated(EnumType.STRING)
    public OrderState getState() {
    	return state;
    }
    
    public void setState(OrderState state) {
    	this.state = state;
    }
    ...
}    

필드 방식

  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하기 위해 필드 방식을 선택해서 불필요한 getter / setter를 구현하지 말아야 한다.
@Entity
@Access(AccessType.FIELD)
public class Order {

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

JPA 구현체 Hibernate 는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @Id나 @EmbeddidId가 어디에 위치했느냐에 따라 접근 방식을 결정한다. @Id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을, getter 에 위치하면 메서드 접근 방식을 선택한다.

AttributeConverter를 이용한 밸류 매핑 처리

애그리거트 로딩 전략

JPA 매핑 설정 시, 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 점을 항상 기억해야 한다.
즉, 다음과 같이 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야 한다.

Product product = productRepository.findById(id);

즉시 로딩, FetchType.EAGER

조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 루트에서 연관매핑의 조회 방식을 즉시 로딩으로 설정하면 된다.
컬렉션이나 @Entity 매핑의 fetch 속성을 즉시 로딩으로 설정 시, find() 로 애그리거트 루트를 구할 때 연관된 구성요소를 함께 가져온다.

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

즉시 로딩 방식으로 설정하면 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있다.
그러나 항상 좋은 것은 아니다.
특히 컬렉션에 대한 즉시 로딩은 오히려 문제가 될 수 있다.

연관객체를 모두 로딩하는 과정에서 Cartesian join이 사용되고, 중복 쿼리 결과가 발생할 수 있다.

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

애그리거트는 개념적으로 하나여야 한다.
하지만 루트 로딩 시점에 애그리거트에 속한 객체가 모두 로딩되어야 하는 것은 아니다.
애그리거트가 완전해야 하는 이유는 다음과 같이 정리할 수 있다.

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

이 중에서도 애그리거트의 완전한 로딩은 상태 변경과 더 관련이 있다.
상태 변경을 위해 조회 시점에 즉시 로딩을 이용해 애그리거트를 완전한 상태로 로딩할 필요는 없다.
이런 경우에는 JPA에서 제공하는, 트랜잭션 범위 내의 지연 로딩을 이용해 실제 상태를 변경하는 시점에 필요한 구성요소로만 로딩해도 문제없다.
무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는, 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택하도록 하자.


애그리거트의 영속성 전파

애그리거트가 완전한 상태여야 한다는 것은 루트 조회뿐만 아니라 저장, 삭제에도 해당된다.

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

@Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 설정하지 않아도 된다.
반면 @Entity 매핑 타입은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정해야 한다.

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

식별자 생성 기능

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

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

식별자 생성 규칙이 있다면 엔티티 생성 시에, 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리해야 한다.
식별자 생성 규칙은 도메인 규칙에 해당하므로, 도메인 영역에 위치시켜야 한다.

특정 값의 조합으로 식별자가 생성되는 것 역시 도메인 규칙으로, 도메인 서비스를 이용해서 식별자를 생성할 수 있다.
예를 들어, 주문번호가 고객 ID와 타임스탬프로 구성된다면 다음과 같은 도메인 서비스를 구현할 수 있다.

public class OrderIdService {

	public OrderId createId(UserId userId) {
    	if (userId == null) throw new IllegalArgumentException("invalid userId: " + userId);
        return new OrderId(userId.toString() + "-" + timestamp());
    }
    
    private String timestamp() {
    	return Long.toString(System.currentTimeMillis());
    }
}    

식별자 생성 규칙을 구현하기 적합한 또 다른 장소는 repository 다.
인터페이스에 식별자 생성 메서드를 추가하고 구현체에서 구현하는 것이다.
@GeneratedValue 를 사용해서 식별자 매핑에서 DB 자동 증가 컬럼을 사용할 수 있다.
자동 증가 컬럼은 INSERT 쿼리가 실행되어야 식별자가 생성되므로 도메인 객체를 저장할 때 식별자가 생성된다.
이 말은 도메인 객체를 생성하는 시점에는 식별자를 알 수 없다는 것을 의미한다.
JPA는 저장 시점에 생성한 식별자를 @Id 로 매핑한 property · field에 할당하므로 저장 이후 엔티티의 식별자를 사용할 수 있다.
자동 증가 컬럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 마찬가지로 저장 시점에 식별자를 생성한다.


도메인 구현과 DIP

지금까지 구현한 리포지토리는 DIP 원칙을 어기고 있다.
엔티티는 구현 기술인 JPA에 특화된 @Entity, @Table, @id, @Column 등의 어노테이션을 사용하고 있다.
DIP에 따르면 Article 같은 도메인 모델은 구현 기술 JPA에 의존하지 말아야 하기 때문이다.

리포지토리 인터페이스도 마찬가지다.
도메인 패키지에 위치하는 인터페이스는 구현 기술인 스프링 데이터 JPA의 인터페이스를 상속하고 있다.
즉, 도메인이 인프라에 의존하는 셈이다.

구현 기술에 대한 의존 없이 도메인을 순수하게 유지하려면 어떻게 해야 할까?

  • 스프링 데이터 JPA 인터페이스를 상속받지 않도록 수정해야 한다.
  • 리포지토리 인터페이스 구현체를 인프라 영역에 위치시켜야 한다.
  • @Entity, @Table 같이 JPA에 특화된 어노테이션을 모두 지우고 JPA 연동을 위한 클래스를 인프라 영역에 추가해야 한다.

특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 다음과 같은 구조로 구현한다.
이 구조를 가지면 구현 기술이 변경되더라도 도메인이 받는 영향을 최소화할 수 있다.

DIP를 적용하는 주된 이유는 저수준 구현이 변경되어도 고수준이 영향받지 않도록 하기 위함이다.
하지만 리포지토리와 도메인 모델의 구현 기술은 거의 변경되지 않는다.
변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다고 생각할 수 있다.

DIP를 완벽하게 지키면 좋겠지만, 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 유지할 수 있도록 타협을 하는 것도 방법의 일종이다.
도메인 모델을 단위 테스트하는 데에 문제가 없다면, 테스트의 가능성을 해치지 않는다면, 복잡도를 높이지 않고 기술에 대한 구현 제약이 낮다면 합리적인 선택이라고 볼 수 있지 않을까?

0개의 댓글