설계방법

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

Lee_SJ 2024. 5. 11. 05:58

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

도메인 모델과 리포지터리를 구현할 때 선호하는 기술을 꼽자면 JPA를 들 수 있다.

데이터보관소로 RDBMS를 사용할 때 객체 기반의 도메인 모델과 관계형 데이터 모델간의 매핑을 처리하는 기술로 ORM 만한 것이 없다.

이 절에서는 자바의 ORM표준인 JPA를 이용해서 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴본다.

 

4.1.1 모듈 위치

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

팀 표준에 따라 리포지터리 구현 클래스를 domain.impl과 같은 패키지에 위치시킬 수도 있는데 이것은 리포지터리 인터페이스와 구현체를 분리하기 위한 타협안 같은 것이지 좋은 설계 원칙을 따르는 것은 아니다.

가능하면 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮춰야한다.

 

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

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

-ID로 애그리거트 조회하기
-애그리거트 저장하기

 

두 메서드를 위한 리포지터리 인터페이스는 다음과 같은 형식을 갖는다.

public interface OrderRepository{
  Order findById(OrderNo no);
  void save(Order order);
}

 

인터페이스는 애그리거트 루트를 기준으로 작성한다.

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

 

애그리거트를 조회하는 기능의 이름을 지을 때 특별한 규칙은 없지만, 널리 사용되는 규칙은 'findBy프로퍼티이름(프로퍼티 값)' 형식을 사용하는 것이다. 위 인터페이스는 ID로 애그리거트를 조회하는 메서드 이름을 findByID()로 지정했다.

 

findById()는 ID에 해당하는 애그리거트가 존재하면 Order를 리턴하고 존재하지 않으면 null를 리턴한다.

null를 사용하고 싶지 않다면 다음과 같이 Optional을 사용해도 된다.

Optional<Order> findById(Orderno no);

 

save() 메서드는 전달받은 애그리거트를 저장한다.

이 인터페이스를 구현한 클래스는 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를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문이다.

예를 들어 다음 코드를 보자.

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

 

changeShippingInfo메서드는 스프링 프레임워크의 트랜잭션 관리 기능을 통해 트랜잭션 범위에서 실행된다.

메서드 실행이 끝나면 트랜잭션을 커밋하는데 이 때 JPA는 트랜잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 Update쿼리를 실행한다.

 

Id가 아닌 다른 조건으로 애그리거트를 조회할 때는 findBy뒤에 조건 대상이 되는 프로퍼티 이름을 붙인다. 예를 들어 특정 ID가 주문한 Order목록을 구하는 메서드는 다음과 같이 정의할 수 있다.

public interface OrderRepository{
  ...
  List<Order> findByOrderId(String ordererId, int startRow, int size);
}

 

findByOrdererID메서드는 한 개 이상의 Order객체를 리턴할 수 있으므로 컬렉션 타입 중 하나인 List를 리턴 타입으로 사용했다.

ID외에 다른 조건으로 애그리거트를 조회할 때에는 JPA의 Criteria나 JPQL을 사용할 수 있다.

 

애그리거트를 삭제하는 기능이 필요할 수도 있다. 삭제 기능을 위한 메서드는 다음과 같이 삭제할 애그리거트 객체를 파라미터로 전달받는다.

public interface OrderRepository{
  ...
  public void delete(Order order);
}

 

구현 클래스는 EntityManager의 remove() 메서드를 이용해서 삭제 기능을 구현한다.

public class JpaOrderRepository implements OrderRepository{
  @PersistenceContext
  private EntityManager entityManager;

  ...
  @Override
  public void delete(Order order){
    entityManager.remove(order);
  }
}

 

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

 

앞서 언급했듯이 스프링과 JPA를 함꼐 적용할 때는 스프링 데이터 JPA를 사용한다. 스프링 데이터 JPA는 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 스프링 빈으로 등록해준다.

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

- org.springframework.data.repository.Repository<T,ID> 인터페이스 상속
- T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정

 

예를 들어 Order 엔티티 타입의 식별자가 OrderNo 타입이라고 하자.

@Entity
@Table(name="purchase_order")
@Access(AccessType.FIELD)
public class Order{
  @EmbeddedId
  private OrderNo number; // OrderNo가 식별자 타입
}

 

Order를 위한 OrderRepository는 다음과 같이 작성할 수 있다.

public interface OrderRepository extends Repository<Order,OrderNo>{
  Optional<Order> findById(OrderNo id);
  void save(Order order);
}

 

스프링 데이터 JPA는 OrderRepository를 리포지터리로 인식해서 알맞게 구현한 객체를 스프링 빈으로 등록한다.이제 주입받아 사용하면 된다.

 

4.3 매핑구현

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

애그리거트와 JPA 매핑을 위한 기본 규칙은 다음과 같다.

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

 

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

- 밸류는 @Embeddable로 매핑 설정한다.
- 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.

 

주문 애그리거트를 예로 들어보자.

주문 애그리거트의 루트 엔티티는 Order이고 이 에그리거트에 속한 Orderer와 ShippingInfo는 밸류다.

이 세 객체와 ShippingInfo에 속한 Address와 Reciver객체는 그림 4.2처럼 한 테이블에 매핑할 수 있다.

루트 엔티티와 루트 엔티티에 속한 밸류는 그림4.2처럼 한테이블에 매핑할 때가 많다.

 

 

주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑한다.

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

 

Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑한다.

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

 

Orderer의 memberId는 Member의 애그리거트를 ID로 참조한다. Member의 ID타입으로 사용되는 MemberId는 다음과 같이 id프로퍼티와 매핑되는 테이블 컬럼으로 member_id를 지정하고 있다.

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

 

그림 4.2에서 Orderer의 memberId 프로퍼티와 매칭되는 칼럼 이름은 order_id이므로 MemberID에 설정된 member_id와 이름이 다르다. @Embeddable 타입에 설정한 칼럼 이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 애너테이션을 이용해서 Orderer의 memberId 프로퍼티와 매핑할 칼럼 이름을 변경했다.

 

Orderer와 마찬가지로 ShippingInfo 밸류도 또 다른 밸류인 Address와 Receiver를 포함한다.

Address의 매핑 설정과 다른 칼럼 이름을 사용하기 위해 @AttributeOverride이노테이션을 사용한다.

@Embeddable
public class ShippingInfo{
  @Embedded
  @AttributeOverrides({
    @AttributeOverride({ name = "zipCode",
                                         column = @Column(name="shipping_zipcode")),
    @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;
}

 

루트 엔티티인 Order클래스는 @Embedded를 이용해서 밸류 타입 프로퍼티를 설정한다.

@Entity
public class Order{
  ...
  @Embedded
  private Orderer orderer;

  @Embedded
  private ShippingInfo shippingInfo;
  ...
}

 

4.3.2 기본 생성자

엔티티와 밸류의 생성자는 객체를 생성할 때 필이ㅛ한 것을 전달받는다 예를 들어 Receiver밸류 타입은 생성 시점에 수취인 이름과 연락처를 생성자 파라미터로 전달받는다.

public class Receiver{
  private String name;
  private String phone;

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

 

Receiver가 불변 타입이면 생성 시점에 필요한 값을 모두 전달받으므로 값을 변경하는 set메서드를 제공하지 않는다. 이는 Reciver클래스에 기본 생성자(파라미터가 없는)를 추가할 필요가 없다라는 것을 의미한다.

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

DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 때문이다.

이런 기숙적인 제약으로 Receiver와 같은 불변타입은 기본 생성자가 필요 없음에도 불구하고 기본생성자를 추가해야 한다.

 

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

 

4.3.3 필드 접근 방식 사용

JPA는 필드와 메서드의 두 가지 방식으로 매핑을 처리할 수 있다. 메서드 방식을 사용하려면 다음과 같이 프로퍼티을 위한 get/set메서드를 구현해야 한다.

@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;
  }
...
}

엔티티에 프로퍼티를 위한 공개 get/set메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 기반으로 엔티티를 구현할 가능성이 높아진다.

특히 set메서드는 내부 데이터를 외부에서 변경할 수 있는 수단이 되기 때문에 캡슐화를 깨는 원인이 될 수 있다.

엔티티가 객체로서 제 역할을 하려면 외부에 set메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다.

상태 변경을 위한 setState()메서드 보다는 주문 취소를 위한 cancle()메서드가 도메인을 더 잘 표현하고 setShippingInfo()보다는 배송지를 변경한다는 의미인 changeShippingInfo()가 더 도메인을 잘 표현한다.

 

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

@Entity
@Access(AccessType.FIELD)
public class Order{
  @EmbeddedId
  private OrderNo number;
  
  @Column(name="state")
  @Enumerated(EnumType.STRING)
  private OrderState state;
}

 

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

int,long,String,localDate같은 타입은 DB테이블의 한 개 컬럼에 매핑된다.

이와 비슷하게 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야할 때도 있다. 

예를 들어 Lengt가 길이값과 단위의 두 프로퍼티를 갖고 있는데 DB테이블에는 한 개의 컬럼에 '1000mm'와 같은 형식으로 저장할 수 있다.

두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로는 처리할 수 없다.

이럴 때 사용할 수 있는 것이 AttributeConverter이다.

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

public interface AttributeConverter<x,y>{
  public Y convertToDatabaseColumn(X attribute);
  public X convertToEntityAttribute(Y dbData);
}

 

타입 파라미터 X는 밸류 타입이고 Y는 DB 타입이다. convertToDatabaseColumn() 메서드는 밸류 타입을 DB 칼럼 값으로 변환하는 기능을 구현하고 convertToEntityAttribute()메서드는 DB칼럼값을 밸류로 변환하는 기능을 구현한다.

 

이 책에서 사용하는 Money 밸류 타입을 위한 AttributeConverter는 다음과 같이 구현 가능하다.

@Converter(autoApply = true)
publicl 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);
  }
}

 

AttributeConverter 인터페이스를 구현한 클래스는 @Converter 애너테이션을 적용한다.

autoApply의 속성값을 보자.

이 속성을 true로 지정하면 model에 출현하는 모든 Money타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다.

예를 들어 Order의 totalAmounts 프로퍼티는 Money 타입인데 이 프로퍼티를 DB total_amounts 칼럼에 매핑할 때 MoneyConverter를 사용한다.

 

@Entity
@Table(name="purchase_order")
public class Order{
  ...
  @Column(name="total_amounts")
  private Money totalAmounts; // MoneyConverter를 적용해서 값 변환
}

 

@Converter의 autoApply 속성을 false로 지정하면( 이 속성의 기본값은 false다.) 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정해야한다.

public class Order{
  @Column(name="total_amounts")
  @Convert(convert=MoneyConverter.class)
  private Money totalAmounts;
}

 

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

Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다. OrderLine에 순서가 있다면 다음과 같이 List타입을 이용해서 컬렉션을 프로퍼티로 지정할 수 있다.

public class Order{
  private List<OrderLine> orderLines;
}

 

Order와 OrderLine을 저장하기 위한 테이블은 그림 4.4와 같이 매핑 가능하다.

 

밸류 컬렉션을 저장하는 ORDER_LINE 테이블은 외부키를 이용해서 엔티티에 해당하는 PURCHASE_ORDER 테이블을 참조한다.

이 외부키는 컬렉션이 속할 엔티티를 의미한다. 

List 타입의 컬렉션은 인덱스 값이 필요하므로 ORDER_LINE테이블에는 인덱스 값을 저장하기 위한 칼럼(line_idx)도 존재한다.

 

밸류 컬렉션을 별도 테이블로 매핑할 때는 @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 ProdectId productId;

  @Column(name="price")
  private Money price;

  @Column(name="quantity")
  private int quantity;
 
  @Column(name="amount")
  private Money amounts;

...
}

 

OrderLine의 매핑을 함께 표시했는데 OrderLine에는 List의 인덱스 값을 저장하기 위한 프로퍼티가 존재하지 않는다.

그 이유는 List 타입 자체가 인덱스를 갖고 있기 때문이다.

JPA는 @OrderColumn 애터네이션을 이용해서 지정한 칼럼에 리스트 인덱스 값을 저장한다.

 

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

예제 코드에서는 외부키가 한 개인데, 두 개 이상인 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 지정한다.

 

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

밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.

예를 들어 도메인 모델에는 이메일 주소 목록을 Set으로 보관하고 DB에는 한 개 칼럼에 콤마로 구분해서 저장해야 할 때가 있다.

이 때 AttributeConverter를 사용하면 밸류 컬렉션을 한 개 칼럼에 쉽게 매핑할 수 있다.

단, AttributeConverter를 사용하려면 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.

이메일의 경우 아래 코드처럼 이메일 집합을 위한 밸류 타입을 추가로 작성해야 한다.

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

 

밸류 컬렉션을 위한 타입을 추가했다면 AttributeConverter를 구현한다.

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

 

이제 EmailSet타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정한다.

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

 

4.3.7 밸류를 이용한 ID매핑

식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 수도 있다.

지금까지 살펴본 예제에서 OrderNo,MemberId 등이 식별자를 표현하기 위해 사용한 밸류 타입이다.

밸류 타입을 식별자로 매핑하면 @Id대신 @EmbeddedId 애너테이션을 사용한다.

 

@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에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 밸류 타입은 Serializble 인터페이스를 상속받아야 한다.

밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가할 수 있다는 점이다.

 

예를 들어 1세대 시스템 2세대 시스템 주문번호 구분시 첫 글자를 이용한다.

다음과 같이 OrderNo 클래스에 시스템 세대를 구분할 수 있는 기능을 구현할 수 있다.

@Embeddable
public class OrderNo implements Serializble{

  @Column(name="order_number")
  private String number;

  public boolean is2ndGeneration(){
    return number.startWith("N");
  }
...
}

 

시스템 세대 구분이 필요한 코드는 OrderNo가 제공하는 기능을 이용해서 구분하면 된다.

if(order.getNumber().is2ndGeneration()){
  ...
}

 

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

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

루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다.

단지 별도로 테이블에 데이터를 저장한다고 해서 엔티티인 것은 아니다.

주문 애그리거트도 OrderLine을 별도 테이블에 저장하지만 OrderLine 자체는 엔티티가 아니라 밸류다.

 

밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다.

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

 

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

하지만 식별자를 찾을 때 매핑되는 테이블의 식별자를 애그리거트 구성요소의 식별자와 동일한 것으로 착각하면 안된다.

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

 

예를 들어 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장하다고 하자.

이 경우 그림 4.5와 같이 Article과 ArticleContent 클래스를 두 테이블에 매핑할 수 있다.

 

그림 4.5만 보면 ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTEN 와 매핑되는 ArticleContent(Address)를 엔티티로 생각해서 둘을 1-1관계로 매핑할 수 있다.

ArticleContent는 Article의 내용을 담고 있는 value가 맞다.

ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함이지 ARTICLE_CONTEN를 위한 별도 식별자가 필요하기 때문은 아니다.

즉 이것은 게시글의 특정 프로퍼티를 별도 테이블에 보관한 것으로 접근해야한다.

 

second_table을 이용해서 value 매핑을 설정해보자

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

 

@SecondTable 테이블을 이렇게 사용하면 아래 코드를 실행할 때 두 테이블을 조인해서 데이터를 조회한다.

Article article = entityManager.find(Article.class, 1L);

 

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

개념적으로는 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.

예를 들어 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라진다고 해보자.

이를 위해 Image를 그림 4.7과 같이 계층구조로 설계할 수 있다.

 

JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.

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

밸류 타입을 @Entity로 매핑하므로 식별자 매핑을 위한 필드도 추가해야 한다.

또한 구현 클래스를 구분하기 위한 타입 식별(discriminator) 칼럼을 추가해야 한다. 이를 위한 테이블 설계는 다음과 같다.

 

한 테이블에 Image와 그 하위 클래스를 매핑하므로 Image클래스에 다음 설정을 사용한다.

- @Inheritance 애너테이션 적용
- strategy 값으로 SINGLE_TABLE 사용
- @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정

 

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="image_type")
@Table(name="image")
public abstract class Image{

  @Id
  @GenerateValue(strategy = GenerationType.IDENTITY)
  @Column(name = "image_id")
  private Long id;

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

  @Temporal(TemporalType.TIMESTAMP)
  @Column(name="upload_timd")
  private Date uploadTime;

  protected Image(){}
 
  public Image(String path){
    this.path = path;
    this.uploadTime = new Date();
  }
...
}

 

Image를 상속받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정한다.

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

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

 

Image가 @Entity이므로 목록을 담고 있는 Product는 아래와 같이 @OneToMany를 사용해서 매핑을 처리한다.

Image는 밸류이므로 독자적인 라이프 사이클을 갖고 있지 않고 Product에 완전히 의존한다.

따라서 Product 저장시 함께 저장되고 Product 삭제시 함께 삭제되도록 12행처럼 cascade속성을 지정한다.

 

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

  @Convert(converter = MoneyConverter.class)
  private Money price;
  private String detail;

  @OneToMany(
              cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
              orphanRemoval = true)
  @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);
  }
}

 

changeImages() 메서드를 보면 이미지 교체를 위해 clear() 메서드를 사용하고 있다.

@Entity에 대한 @OneToMany매핑에서 컬렉션의 clear()메서드를 호출하면 삭제 과정이 효율적이지는 않다.

이건 알아두고 가자.

 

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

3장에서 애그리거트 간 집합 연관은 성능 상의 이유로 피해야 한다고 했다. 그럼에도 불구하고 요구사항을 구현하는 데 집합 연관을 사용하는 것이 유리하다면 ID참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다. 이미 3장에서 이와 관련된 매핑 예를 보여준 바 있다.

관련 코드를 다시 보자.

 

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

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

 

이 코드는 Product에서 Category로의 단방향 M-N 연관을 ID참조방식으로 구현한 것이다.

ID참조를 이용한 애그리거트 간 단방향 M-N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정한 것을 알 수 있다.

차이점이 있다면 집합의 값에 밸류 대신 연관을 맺는 식별자가 온다는 점이다.

 

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

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

 

4.4 애그리거트 로딩 전략

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

즉 다음과 같이 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야 함을 의미한다.

//Product는 완전한 하나여야 한다.
Product product = productRepository.findById(id);

 

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

다음과 같이 컬렉션이나 @Entity에 대한 매핑의 fetch속성을 즉시 로딩으로 설정하면 EntityManager#find() 메서드로 애그리거트 루트를 구할 때 연관된 구성요소를 DB에서 함께 읽어온다.

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

@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name="order_line",
                   joinColumns = @JoinColumns(name="order_number"))
@OrderColumn(name = "line_idx")
private List<OrderLine> orderLines;

 

즉시 로딩 방식으로 설정하면 애그리거트 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있는 장점이 있지만 이것이 항상 좋은 것은 아니다.

특히 컬렉션에 대해 로딩 전략을 FetchType.EAGER로 적용하면 로딩방식이 문제가 될 수 있다.

 

애그리거트는 개념적으로 하나여야 한다. 하지만 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아니다. 

애그리거트가 완전해야 하는 이유는 두 가지 정도로 생각해 볼 수 있다.

1. 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 한다.

2. 표현영역에서 애그리거트 상태 정보를 보여줄 때 필요하다.

 

그 중 1번과 더 큰 연관이 있다. 그럼 굳이.. 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않는다.

@Transactional
public void removeOptions(ProductId id, int optIdxToBeDeleted){
  Product product = productRepository.findById(id); // 지연로딩 => 아직 옵션은 로딩 x
  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);
  }
}

 

게다가 일반적으로 상태 변경보다 조회 기능이 훨씬 많다. 그래서 모든 연관을 즉시 로딩으로 설정할 필요가 없다.

 

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

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

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

 

@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<>();

 

4.6 식별자 생성 기능

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

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

 

이메일 주소처럼 사용자가 직접 식별자를 입력하는 경우는 식별자 생성 주체가 사용자이기 때문에 도메인 영역에서 식별자 생성 기능 구현할 필요가 없다.

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

public class ProductIdService{
  public ProductId nextId(){
    ... // 정해진 규칙으로 식별자 생성
  }
}

 

응용 서비스는 이 도메인 서비스를 이용해서 식별자를 구하고 엔티티를 생성한다.

public class CreateProductService{
  @Autowired private ProductIdService idService;
  @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;
  }
}

 

특정 값의 조합으로 식별자가 생성되는 것 역시 규칙이므로 도메인 서비스를 이용해서 식별자 값을 생성할 수 있다. 

 

4.7 도메인 구현과 DIP

2장에서 DIP에 대해 알아봤는데, 이 장에서 구현한 리포지터리는 DIP 원칙을 어기고 있다. 먼저 엔티티는 아래 코드처럼 구현 기술인 JPA에 특화된 @Entity, @Table, @Id, @Column등의 애너테이션을 사용하고 있다.

@Entity
@Table(name="article")
@SecondaryTable(
    name="artice_content",
    pkJoinColumns = @PrimaryKeyJoinColumn(name="id")
)
public class Article{
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
}

 

DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데 이 코드는 도메인 모델인 Article이 영속성 구현 기술인 JPA에 의존하고 있다.

리포지터리 인터페이스도 마찬가지다.

아래 코드에서 ArticleRepository인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository인터페이스를 상속하고 있다.

즉 도메인이 인프라에 의존하는 것이다.

 

public interface ArticleRepository extends Repository<Article,Long>{
  void save(Article article);
  Optional<Article> findById(Long id);
}

 

구현 기술에 대한 의존 없이 도메인을 순수하게 유지하려면 스프링 데이터 JPA의 Repository인터페이스를 상속받지 않도록 수정하고 그림 4.9와 같이 ArticleRepository 인터페이스를 구현한 클래스를 인프라에 위치시켜야 한다.

또한 Article클래스에서 @Entity나 @Table과 같이 JPA에 특화된 애너테이션을 모두 지우고 인프라에 JPA를 연동하기 위한 클래스를 추가해야 한다.

 

이 구조를 가지면 구현기술을 변경하더라도 도메인이 받는 영향을 최소화 할 수 있다.

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

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

=> 그래서 선택에 따라 다르다.