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

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

모듈 위치

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

리포지터리 기본 기능 구현

  • ID로 애그리거트 조회하기

  • 애그리거트 저장하

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

Spring과 JPA로 구현할 때 스프링 데이터 JPA를 사용한다. 리포지터리 인터페이스만 정의하면 나머지 리포지터리 구현 객체는 스프링 데이터 JPA가 알아서 만들어준다 .

JPA를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영해주기 때문에 애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다.

public class ChangeOrderService {

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

ID가 아닌 다른 조건으로 조회

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

삭제 기능

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

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

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

스프링 데이터 JPA를 사용하기 위한 기본 규칙 (OrderRepository 기존)

  • 저장

    • Order save(Order entity)

    • void save(Order entity)

  • 식별자를 이용해서 엔티티 조회

    • Order findById(OrderNo id)

    • Optional<Order> findById(OrderNo id)

  • 특정 프로퍼티를 이용해서 조회

    • List<Order> findByOrderer(Orderer orderer)

  • 중첩 프로퍼티를 이용해서 조회

    • List<Order> findByOrdererMemberId(MemberId memberId)

  • 삭제

    • void delete(Order order)

    • void deleteById(OrderNo id)

매핑 구현

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

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

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

  • 밸류는 @Embeddable로 매핑 설정한다.

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

@Entity 
@Table(name = "puchase_order")
public class Order {
    ...
}
@Embeddable
public class Orderer {

    @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;
    ...
} 
@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;
    ...
}
@Entity 
public class Order {
    ...
    @Embedded
    private Orderer orderer;
    
    @Embedded
    private ShippingInfo shippingInfo;
    ...
}

기본 생성자

JPA에서는 DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 때문에 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다.

@Embeddable
public class Receiver {
    @Column(name = "receiver_name")
    private String name;
    @Column(name = "receiver_phone")
    private String phone;
    
    protected Receiver() {} // JPA를 적용하기 위해 기본 생성자 추가
    
    public Receiver(String name, String phone) {
        this.name = name;
        this.phone = phone;
    }
    
    ... // get 메서드 생략
}

필드 접근 방식 사용

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

@Entity
@Accedd(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 메서드는 애부 데이터를 외부에서 변경할 수 있는 수단이 되기 때문에 캡슐화를 깨는 원인이 될 수 있다.

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

@Entity 
@Acess(AccesType.FILED)
public class Order {

    @EmbeddedId
    private OrderNo number;
    
    @Column(name = "state")
    @Enumerated(EnumType.STRING)
    private OrderState state;
    
    ... // cancel(), changeShippingInfo() 등 도메인 기능 구현
    ... // 필요한 get 메서드 제공
}

AttributeConverter를 이용한 밸류 매핑 처리

@Converter(autoApply = 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);
    }
}
@Entity
@Table(name = "purchase_order")
public class Order {
    ...
    
    @Coulmn(name = "total_amounts")
    private Money totalAmounts; //MoneyConveter 적용
    ...
}

@Converter의 autoApply 속성을 false로 지정할 경우 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정해야 한다.

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

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

d밸류 컬렉션을 별도 테이블로 매핑할 때는 @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 ProductId productId;
    
    @Column(name = "price")
    private Money price;
    
    @Column(name = "quantity")
    private int quantity;
    
    @Column(name = "amounts")
    private Money amounts;
    ...
}
  • List 타입 자체가 인덱스를 갖고 있기 때문에 OrderLine에는 List의 인덱스 값을 저장하기 위한 프로퍼티가 존재하지 않는다. JPA는 @Ordercolumn 애너테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장한다.

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

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

밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다. 이때 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.ummodifiableSet(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(Coolectors.joining(","));
    }
    
    @Override
    public EmailSet convertToEntityAttribute(String dbData) {
        if (dbData == null) return null;
        String[] emails = dbData.split(",");
        Set<Email> emailSet = Arrays.stream(emails)
                .map(valur -> new Email(value))
                .collect(toSet());
        return new EmailSet(emailSet);
    }
}

EmailSet 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정

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

밸류를 이용한 ID 매핑

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

@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;
    ...
}
@Embeddable
public class OrderNo implements Serializable {
    @Column(name = "order_number")
    private String number;
    
    public boolean is2ndGeneration() {
        return number.startWith("N");
    }
    ...
}
  • JPA 에서 식별자 타입은 Serializable 타입어야 하므로 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다.

  • 밸류 타입으로 식별자를 구현할 경우 식별자에 기능을 추가할 수 있다.

  • JPA는 내부적으로 엔티티를 비교할 목적으로 equals() 메서드와 hashcode() 값을 사용하므로 식별자로 사용할 밸류 타입은 이 두 메서드를 알맞게 구현해야 한다.

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

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

@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;
}
  • ArticleContent와 매핑되는 테이블은 Article과 매핑되는 테이블과 다르다. 이때 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable과 @AttributeOverride을 사용한다.

  • @SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정한다. pkColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정한다.

  • @AttributeOverride를 적용해 해당 밸류 데이터가 저장된 테이블 이름을 지정했다.

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

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

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

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

@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;
    
    @Temporal(TemporalType.TIMESTAMP)
    @Column(name = "upload_time")
    private Date uploadTime;
    
    protected Image() {}
    public Image(String path) {
        this.path = path;
        this.uploadTime = new Date();
    }
    
    protected String getPath() {
        return path;
    }
    
    public Date getUploadTime() {
        return uploadTime;
    }
    
    public abstract String getURL();
    public abstract boolean hasThumbnail;
    public abstract String getThumbnailURL();
 }
 

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를 삭제할 때 함께 삭제되도록 cascade 속성을 지정한다. 리스트에서 Image 객체를 제거하면 DB 에서 함께 삭제되도록 orphanRemoval도 true로 설정한다.

@Entity
@Table(name = "product")
public class Product {
    @EmbeddedId
    private ProductId id;
    private String name;
    
    @Converter(converter = MoneyConverter.class)
    private Money price;
    private String detail;
    
    @OneToMany(
        cascade = {CascadeType.PERSIS, CascadeType.REMOVE},
        orphanReoval = true)
    @JoinColumn(name = "product_id")
    @OverColumn(name = "list_idx")
    private List<Image> images = new ArrayList<>();
    
    ...
    
    public void changeImages(List<Image> newImages) {
        images.clear();
        images.addAll(newImages);
    }
}

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

하이버네이트는 @Embeddable 타입에 대한 컬렉션의 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다. 따라서 애그리거트의 특정을 유지하면서 이 문제를 해소하려면 결국 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현해야 한다. 물론 타입에 따라 다른 기능을 구현하려면 if-else를 써야 한다.

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

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

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

@Entity 
@Table(name = "product")
public class Product {
    @EmbeddedId
    private ProductId id;
    
    @ElementCollection
    @CollectionTable(name = "product_category",
        joinColumns = @JoinColumn(name = "product_id"))
    private Set<CategoryId> vategoryIds;
    ...
}

애그리거트 로딩 전략

즉시 로딩 (FetchType.EAGER)으로 설정하면 조회 시점에서 애그리거트가 완전한 상태가 된다.

즉시 로딩 방식으로 설정하면 애그리거트 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있지만, 컬렉션의 경우 즉시 로딩 방식이 쿼리 결과에 중복을 발생시킬 수도 있다. 그러므로 조회되는 데이터 개수가 많아지면 즉시 로딩 방식을 사용할 때 성능(실행 빈도, 트래픽, 지연 로딩 시 실행 속도 등)을 검토해 봐야 한다.

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

@Transactional
public void removeOptions(ProductId id, int opIdxToBeDeleted) {
    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);
    }
}

애그리거트의 영속성 전파

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

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

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

@Enbeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 되는 반면 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정해야 한다. @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 속성값을 설정해줘야 한다.

cascade
  • PERSIST

  • REMOVE

  • ...

식별자 생성 기능

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

  • 사용자가 직접 생성 - 이메일 주소처럼 식별자 생성 주체가 사용자이기 때문에 도메인 영역에 식별자 생성 기능을 구현할 필요가 없다.

  • 도메인 로직으로 생성 - 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리해야 한다.

  • DB를 이용한 일련번호 사용 - 식별자 매핑에서 @GeneratedValue를 사용한다. 자동 증가 칼럼은 DB의 insert 쿼릴르 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 저장할 때 식별자가 생성된다. 즉, 도메인 객체를 생성하는 시점에는 식별자를 알 수 없고 객체를 저장한 뒤에야 식별자를 구할 수 있다. 자동 증가 칼럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 마찬가지로 저장 시점에 식별자를 생성한다.

도메인 구현과 DIP

DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함을 어느 정도 유지했다 .복잡도를 높이지 않으면서 기술에 따른 구현 제약이 낮다면 합리적인 선택이다.

Last updated