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

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

  • 애그리거트를 어떤 저장소에 저장하느냐에 따라 리포지터리를 구현하는 방법이 다르다.

  • 리포지토리의 인터페이스는 애그리거트와 함께 도메인 영역 패키지에 두고, 리포지토리의 구현 클래스는 인프라 영역에 둔다. 이를 통해 인프라 영역에 대한 의존을 낮출 수 있다.

  • 리포지토리의 인터페이스는 애그리거트 루트를 기준으로 작성한다. 예를 들어 주문 애그리거트라면 Order 엔티티 외에 Orderer, ShippingInfo 등 다양한 객체를 포함하지만 루트 엔티티인 Order를 기준으로 작성하도록 한다.

  • 가장 기본적인 조회와 저장 메서드는 아래와 같이 구현한다.

    • Spring Data JPA 사용 시 Repository 인터페이스를 정의하면 구현 객체를 자동으로 만들어주기 때문에 직접 리포지토리 구현 클래스를 만들 필요가 없다.

    • 또한 애그리거트를 수정한 결과를 저장소에 반영하는 메서드도 구현할 필요 없이, 데이터의 변경이 일어나면 트랜잭션 범위에서 자동으로 DB에 반영해준다.

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

엔티티와 밸류 매핑

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

  • 하나의 테이블에 엔티티와 밸류가 함께 있으면 밸류는 @Embeddable로 매핑하고, 밸류 타입 프로퍼티는 @Embedded로 매핑한다.

  • 루트 엔티티와 루트 엔티티에 속한 밸류는 아래와 같이 하나의 테이블에 매핑할 때가 많다.

  • 다음은 Order라는 루트 엔티티와 그에 속하는 Orderer, MemberId 밸류들을 @Embeddable을 통해 매핑하는 예제이다.

    • 밸류 타입 프로퍼티는 @Embedded 어노테이션을 붙여 매핑한다.

    • MemberId 프로퍼티와 매핑되는 칼럼 이름은 orderer_id이기 때문에 @AttributeOverrides 어노테이션을 이용해 매핑할 칼럼을 재정의한다.

@Entity
@Table(name = “purchase_order”)
public class Order {
	// ...
	@Embedded
	private Orderer orderer;
}

@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;
}
  • JPA에서는 DB에서 데이터를 읽어와 객체를 생성할 때 기본 생성자를 이용하기 때문에, @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다. 만약 기본 생성자가 필요 없는 불변 타입이라면 다른 코드에서 사용할 수 없도록 protected 으로 두어야 한다.

@Access

  • JPA에서는 필드에 접근하기 위해 메서드를 사용하거나 필드에 직접 접근하는 두 가지 방식을 사용할 수 있다. 메서드 기반 접근 방식을 사용하려면 @Access(AccessType.PROPERTY) 를 적용하고, 필드 방식을 사용하려면 @Access(AccessType.FIELD)를 적용해야 한다.

  • JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @Id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 사용하고 get 메서드에 위치하면 메서드 접근 방식을 사용한다.

  • 메서드 접근 방식을 사용할 경우 getter/setter를 제공해주어야 하는데, JPA를 위해서 추가하는 것은 불필요한 구현이 될 수 있으므로 가급적 필드 방식을 사용해야 한다.

AttributeConverter

  • 밸류 타입의 여러 프로퍼티를 하나의 컬럼에 매핑하기 위해 AttributeConverter를 사용할 수 있다.

  • Money라는 밸류 타입을 Integer 타입으로 변환해 컬럼에 저장하기 위해서는 아래와 같이 컨버터를 생성해야 한다.

    • autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다. autoApply의 기본값은 false이며 이 때에는 컨버터 적용을 원하는 필드에 @Convert(converter = MoneyConverter.class) 어노테이션을 붙여주어야 한다.

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

밸류 컬렉션 매핑

별도 테이블 매핑

  • 밸류 컬렉션을 프로퍼티로 갖는 엔티티를 매핑할 때 별도 테이블로 매핑하려면 @ElementCollection과 @CollectionTable을 함께 사용해야 한다.

  • 다음은 주문 엔티티 내부에 주문 상세 내용들을 컬렉션으로 가질 때 매핑하는 방법이다.

    • @OrderColumn 애너테이션을 이용해서 지정한 칼럼에 OrderLine List의 인덱스 값을 저장한다. 이를 통해 OrderLine이 몇번째 순서에 존재하는지 확인할 수 있다.

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

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

한개 컬럼 매핑

  • 밸류 컬렉션을 한개의 컬럼에 저장하고자 한다면 AttributeConverter를 사용해야 하며, 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.

  • 아래는 Email의 Set을 래핑한 EmailSet 클래스를만들고 Converter를 구현한 것이다.

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

@Entity
public class Person {
	@Column(name = “emails”)
	@Convert(converter = EmailSetConverter.class)
	private EmailSet emailSet;
	// ...
}

밸류 타입 식별자

  • 식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 경우 @EmbeddedId 어노테이션을 사용해 매핑해야 한다.

  • JPA에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다. 또한 식별자 타입의 비교 목적으로 equals(), hashcode() 메서드가 사용되므로 적절하게 구현해야 한다.

  • 식별자 자체를 밸류 타입으로 둘 경우 식별자에 기능을 추가해 사용할 수 있다는 장점이 있다.

  • 아래는 OrderNo라는 밸류 타입을 Order 엔티티의 식별자로 두고 이를 매핑하기 위해 @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;
	// ...
} 

밸류로 매핑

  • 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다. 만약 구성요소가 자신만의 독자적인 라이프 사이클을 갖는다면 다른 애그리거트의 엔티티일 가능성이 크다.

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

  • 주의해야 할 점은 테이블에서 클래스로 넘어올 때 테이블의 식별자를 무조건 애그리거트 구성요소의 식별자로 매핑하면 안된다는 것이다. 분리되어 있는 두 테이블을 클래스로 변환할 때 식별자를 사용하는 이유를 확인하여 고유 식별자를 둘지 말지 결정해야 한다.

  • 아래와 같은 DB 테이블을 클래스로 매핑할 때 그대로 ARTICLE_CONTENT의 id까지 매핑하는 엔티티 타입이 아닌, id를 제외한 밸류 타입으로 매핑해야 한다.

  • 아래와 같이 ArticleContent 객체와 ArticleContent 테이블을 매핑하기 위해 @SecondaryTable과 @AttributeOverride을 사용한다. @SecondaryTable의 name에는 밸류를 매핑할 테이블을, pkJoinColumns에는 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정한다.

@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;
	// ...
}
  • 위와 같이 @SecondaryTable을 사용하면 게시글 목록 화면에 보여줄 Article을 조회할 때에도 article_content 테이블까지 조인해서 데이터를 읽어온다. 이를 해결하려면 조회 전용 기능을 구현해야 하며 11장에 해결 방법이 나온다.

밸류 컬렉션을 @Entity로 매핑

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

  • 예를 들어 상품의 이미지를 List 컬렉션으로 관리하고 이미지는 여러 타입을 지원하기 위해 상속 구조를 갖도록 한다면, JPA의 @Embeddable이 상속 매핑을 지원하지 않으므로 밸류 타입에 @Entity를 이용해야만 한다.

  • 아래와 같이 여러 이미지의 자식 타입을 하나의 IMAGE 테이블에 넣기 위해 IMAGE_TYPE 정보를 담는다.

  • Image 추상 클래스에는 아래와 같이 JPA 기능을 설정한다.

    • **@**Inheritance 어노테이션을 적용하고 strategy 값으로 SINGLE_TABLE을 사용한다.

    • @DiscriminatorColumn 어노테이션을 사용해 타입 구분용으로 사용할 컬럼을 지정하면, 자식 클래스에서 @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;
	
	@Temporal(TemporalType.TIMESTAMP)
	@Column(name = “upload_time”)
	private Date uploadTime;
	
	protected Image() {}
	
	public Image(String path) {
		this.path = path;
		this.uploadTime = new Date();
	}
	// ...
}

@Entity
@DiscriminatorValue(“II”)
public class InternalImage extends Image {
	// ...
}
  • Product 클래스에서는 List 컬렉션에 @OneToMany 어노테이션을 붙여 매핑을 처리한다.@OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 Image 객체들이 Product 객체의 생명 주기에 맞춰지도록 cascade 속성과 orphanRemoval 속성을 지정해주어야 한다.

  • @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대해 delete 쿼리를 실행하므로 성능 상 문제가 발생할 수 있다.

  • 이러한 문제를 회피하려면 상속 구조를 포기하고 @Embeddable 타입을 사용해 컬렉션의 clear() 메서드를 호출해도 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행하게 해야 한다.

@Entity
@Table(name = “product”)
public class Product {
	@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);
	}
}

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

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

  • 아래는 Product에서 Category로의 단방향 M-N 연관을 ID 참조 방식으로 구현한 것으로, 밸류 컬렉션 매핑과 동일한 형태이며 컬렉션 원소로 밸류 대신 연관을 맺는 식별자가 들어간다.

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

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

애그리거트 로딩 전략과 영속성 전파

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

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

  • 컬렉션에 대해 즉시 로딩 전략을 사용하면 조회되는 데이터 개수가 많아져 성능 문제가 발생할 수 있다.

    • 아래와 같이 구성된 Product 엔티티를 조회하고자 한다면 카타시안 조인을 사용하게 되어 쿼리 결과에 중복이 생기게 된다. 하이버네이트가 중복 데이터를 자동으로 제거해주지만, 애그리거트가 커지면 쿼리 결과가 급증하게 될 것이다.

    @Entity
    @Table(name = “product”)
    public class Product {
    	@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<>();
    
    	@ElementCollection(fetch = FetchType.EAGER)
    	@CollectionTable(name = “product_option”,
    		joinColumns = @JoinColumn(name = “product_id”))
    	@OrderColumn(name = “list_idx”)
    	private List<Option> options = new ArrayList<>();
    	// ...
  • 애그리거트가 완전해야 하는 이유는 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 하기 때문이다. 다만 JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하여 실제 상태 변경 시점에 필요한 구성 요소만 로딩하더라도 문제가 되지 않는다.

  • 표현 영역에서 애그리거트의 상태 정보를 보여줄 때에도 완전한 애그리거트가 필요하지만 이는 조회 전용 기능과 모델을 구현하는 것이 훨씬 유리하다.

  • 아래는 Product 엔티티의 옵션을 변경하는 removeOption 메서드에서 옵션들을 가져와 특정 인덱스의 옵션을 제거할 때 지연 로딩을 하도록 구현한 것이다.

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

식별자 생성 기능

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

    • 사용자가 직접 생성

      • 이 경우 도메인 영역에 식별자 생성 기능을 구현할 필요가 없다.

    • 도메인 로직으로 생성

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

      • 도메인 서비스를 이용해 식별자를 생성하는 메서드를 제공하면 응용 서비스는 이를 이용해 식별자를 생성한 후 도메인 객체를 생성할 수 있다.

      public class ProductIdService {
      	public ProductId nextId() {
      		// 정해진 규칙으로 식별자 생성
      	}
      }
      
      public class CreateProductService {
      	@Transactional
      	public ProductId createProduct(ProductCreationCommand cmd) {
      		ProductId id = productIdService.nextId();
      		Product product = new Product(id, cmd.getDetail(), cmd.getPrice());
      		productRepository.save(product);
      		return id;
      	}
      }
      • 혹은 리포지토리 인터페이스에 식별자를 생성하는 메서드를 추가하고 구현하도록 둘 수도 있다.

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

      • JPA에서 제공하는 @GeneratedValue를 사용하면 된다. save() 할 때 DB에 연결을 맺어 ID 생성 요청을 하고, 해당 아이디를 기반으로 insert 쿼리를 보내게 된다.

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

도메인 구현과 DIP

  • DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 앞서 다뤘던 내용에서는 도메인 모델이 영속성 구현 기술인 JPA에 그대로 의존하고 있다.

  • 리포지토리와 도메인 모델의 구현 기술은 거의 바뀌지 않기 때문에 개발 편의성과 실용성, 낮은 복잡도를 위해 DIP를 포기하면서 개발하는 것이 나을 수도 있다.

Last updated