일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 객체지향 쿼리 언어
- 아이템 23
- 일ㅊ
- criteriaquery
- 아이템29
- JPA
- Domain Driven Design
- GitHub Actions
- Spring Batch
- ddd
- 아이템 28
- 아이템31
- 최범균
- 아이템 25
- 자바 ORM 표준 JPA 프로그래밍
- 이펙티브자바
- 아이템 27
- 기업프로젝트
- chapter5. 스프링 데이터 jpa를 이용한 조회 기능
- JPQL
- chapter4. 리포지터리와 모델 구현
- 도메인 주도 개발 시작하기
- 아이템 26
- chatgpt 연동
- cicd
- jdbc
- 큐시즘
- 아이템30
- java
- 아이템 24
- Today
- Total
코딩은 마라톤
[도메인 주도 개발 시작하기] Chapter4. 리포지터리와 모델 구현 본문
JPA를 이용한 리포지터리 구현
모듈 위치
리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에, 구현할 클래스는 인프라스트럭처 영역에 속한다.
리포지터리 기본 기능 구현
- ID로 애그리거트 조회하기
- 애그리거트 저장하기
public interface OrderRepository {
Order findById(OrderNo no);
void save(Order order);
}
인터페이스는 애그리거트 루트를 기준으로 작성한다.
위 리포지터리를 JPA로 구현하는 리포지터리는 JPA의 EntityManager를 이용해서 구현한다.
@Repository
public class JpaOrderRepository implements OrderRepository {
@PersistenceContext
private EntityManager em;
@Override
public Order findById(OrderNo id) {
return em.find(Order.class, id);
}
@Override
public void save(Order order) {
em.persist(order);
}
}
참고로 리포지터리에서 제공하는 조회와 저장 이외 수정 결과를 반영하는 메서드는 추가할 필요가 없다. → JPA 사용 시 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문이다. (@Transactional)
스프링 데이터 JPA를 이용한 리포지터리 구현
리포지터리 인터페이스를 직접 구현하지 않고, 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현할 객체를 알아서 만들어 스프링 빈으로 등록한다.
- org.springframework.data.repository.Repository<T, ID> 인터페이스 상속
- T : 엔티티 타입 / ID : 식별자 타입
public interface OrderRepository extends Repository<Order, OrderNo> {
Optional<Order> findById(OrderNo id);
void save(Order order);
}
Repository<T, ID> 와 JpaRepository<T, ID>의 차이점
Repository<T, ID> 의 경우 최상위 계층에 존재하는 인터페이스로 제공하는 메서드가 하나도 없다.
반면에 JpaRepository<T, ID>는 CrudRepository, PagingAndSortingRepository를 다중 상속하고 있는데, 두 인터페이스는
save(), findById() 등 다양한 쿼리 메서드를 제공하고 있어 JpaRepository에 선언하지 않고 바로 사용할 수 있다.
JpaRepository가 간편하게 쓸 수 있어 좋을 수 있지만, 단점도 존재한다.
사용자가 원하지 않는 쿼리 메서드가 존재하여 사용자가 진정 원하는 쿼리 메서드와 합쳐지고 필요 없는 메서드와 구분하기 어려워질 수 있다.
즉, Repository<T, ID> 를 사용해서 인터페이스에 직접 사용할 메서드를 작성하고 사용하면 명세처럼 외부에서 무슨 메서드를 사용하는지 보기 편하고 의미 또한 분명해지는 장점이 있다.
엔티티와 밸류 매핑
기본 매핑 구현
애그리거트와 JPA 매핑은 @Entity로 매핑 설정한다.
한 테이블에 엔티티와 밸류 데이터가 같이 있다면
- 밸류는 @Embeddable , 밸류 타입 프로퍼티는 @Embedded 로 매핑 설정
@Entity
@Table(name = "purchase_order")
public class Order {
...
@Embedded
private Orderer orderer;
}
@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;
}
@Embeddable
public class MemberId implements Serializable {
@Column(name = "member_id")
private String id;
}
기본 생성자
JPA에서 @Entity , @Embeddable 로 클래스를 매핑하려면 기본 생성자를 제공해야 한다. (DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 때문)
따라서 엔티티와 밸류 타입에서 JPA 프로바이더가 객체를 생성할 때만 생성자를 사용하게 하기 위해서 protected 로 선언한다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
또는
protected ValueType() {}
필드 접근 방식 사용
JPA는 필드와 메서드 두 가지 방식으로 매핑을 처리할 수 있다.
- 메서드
@Access(AccessType.PROPERTY)
메서드 방식일 경우 프로퍼티를 위한 공개 get/set 메서드를 추가해야한다.
이러면 도메인의 의도가 사라지고 데이터 기반으로 엔티티를 구현할 가능성이 높아진다. 또한 set 메서드를 통해 캡슐화를 깨는 원인이 된다.
따라서 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 말아야 한다.
- 필드
@Access(AccessType.FIELD)
AttributeConverter를 이용한 밸류 매핑 처리
두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 으로는 처리할 수 없다.
이때 사용해야하는 것이 AttributeConverter으로 밸류 타입과 db 컬럼 데이터 간의 변환을 처리하기 위한 기능을 정의한다.
public interface AttributeConverter<X, Y> {
public Y convertToDatabaseColumn(X attribute);
public X convertToEntityAttribute(Y dbData);
- X : 밸류 타입, Y : DB 타입
예시) Money를 위한 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);
}
}
밸류 컬렉션 매핑
별도 테이블 매핑
Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다.
OrderLine에 순서가 있다면 List 타입을 이용해서 프로퍼티로 지정할 수 있다.
public class Order {
..
private List<OrderLine> orderLines;
..
}
밸류 컬렉션을 저장하는 ORDER_LINE 테이블은 외부키를 이용해서 엔티티에 해당하는 PURCHASE_ORDER 테이블을 참조한다. (order_number)
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 ProductId productId;
...
}
- OrderLine에는 List 인덱스 값을 저장하는 프로퍼티가 존재하지 않는다.
- List 타입 자체가 인덱스를 가지고 있으며, JPA @OrderColumn 애너테이션을 이용해 지정한 컬럼에 리스트의 인덱스 값을 저장한다.
한 개 컬럼 매핑
밸류 컬렉션을 별도 테이블이 아닌 한 개 컬럼에 저장할 때가 있다.
예를 들어, 이메일 주소 목록을 Set으로 보관하고 DB에는 한 개 컬럼에 콤마로 구분해서 저장한다면 AttributeConverter 를 사용한다.
밸류를 이용한 ID 매핑
식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 수도 있다.
@Id 대신 @EmbeddedId 를 사용한다.
@Entity
public class Order {
@EmbeddedId
private OrderNo number;
..
}
@Embeddable
public class OrderNo implements Serializable {
@Column(name = "order_number")
private String number;
..
}
JPA에서 식별자 타입은 Serializable 타입이어야 하므로 밸류 타입은 Serializable 인터페이스를 상속받아야 한다.
ID를 밸류 타입으로 생성 시 자동 ID 증가를 못하는 문제
위에 예시에서는 number가 String 타입이기 때문에 항상 고유한 값이라면 문제가 되지 않을 거라 생각합니다.
그런데 만약 Long 타입으로 id를 GeneratedValue를 통해 1씩 증가시키려면 다음 문제가 발생합니다.
@EmbeddedId는 @GeneratedValue가 되지 않는다.
이를 해결하기 위해서 아래 방법을 사용해봤지만 부가적인 문제가 또 발생했습니다.
1. Long 타입의 id를 만들고 OrderNo는 별도의 논리적 키로 유지한다. (논리적 키로 유지할 거면 그냥 식별자를 밸류타입으로 안써도 되지 않나..?)
2. OrderNo의 number와 엔티티 생성 시 엔티티의 id를 비교한다. (동시성 문제가 발생할 수 있고, id와 number가 불일치할 수 있다.)
그래서 OrderNo의 number를 UUID로 생성하는 방식을 고려해봤습니다. @PrePersist에서 UUID.randomUUID()를 통해 엔티티가 영속성 컨텍스트에 저장되기 직전에 number를 저장하는 방식은 어떨까 고민해봤습니다.
다른 방식이 있다면 댓글로 남겨주세요!!
밸류 컬렉션을 @Entity로 매핑하기
개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity 를 사용할 수도 있다.
예를 들어 제품의 이미지 업로드 방식에 따라 이미지 경로와 썸네일 이미지 제공 여부가 달라진다. 이때 Image를 계층 구조로 설계할 수 있다.
Product와 Image를 @Embeddable, Embedded 하면 된다고 생각할 수 있지만, 하위 Internal, External은 상속 관계에 있어 사용할 수 없다.
- 상속 구조를 갖는 밸류 타입을 사용하려면 @Entity 를 이용해서 상속 매핑으로 처리해야 한다.
- @Entity 로 매핑하므로 식별자 필드도 추가해야 한다.
- 구현 클래스 구분(Internal, External) 하기 위한 식별(discriminator) 컬럼 추가해야 한다.
한 테이블에 Image와 하위 클래스를 매핑하므로 다음 설정을 사용한다.
- @Inheritance 적용
- strategy 값으로 SINGLE_TABLE 사용
- @DiscriminatorColumn 이용하여 타입 구분용으로 사용할 컬럼 지정 (IMAGE_TYPE)
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "image_type")
@Table(name = "image")
public abstract class Image {
@Id
@GeneratedValue
@Column(name = "image_id")
private Long id;
@Column(name = "image_path")
private String path;
...
public abstract String getURL();
public abstract boolean hasThumbnail();
public abstract String getThumbnailURL();
}
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
...
}
@Entity
@DiscriminatorValue("EI")
public class ExternalImage extends Image {
...
}
또한 하나의 Product에 여러 이미지가 존재하므로 @OneToMany 를 이용해서 매핑을 처리한다.
@Entity
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);
}
}
위 로직에서 이미지 교체 시 기존 이미지를 clear() 하는 메서드를 사용한다.
@Entity 에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제 과정에서 select 쿼리로 대상 엔티티 로딩 및 각 개별 엔티티에 대해 delete 쿼리를 실행한다. 따라서 변경 빈도가 높을 수록 서비스 성능에 문제가 될 수 있다.
하지만 @Embeddable 타입에 대한 컬렉션의 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다.
따라서 애그리거트의 특성을 유지하면서 위 문제를 해결하기 위해 상속을 포기하고 @Embeddable 로 매핑된 단일 클래스로 구현한다.
@Embeddable
public class Image {
...
public boolean hasThumbnail() {
// 성능을 위해 다형을 포기하고 if-else로 구현
if (imageType.equals("II") {
return true;
} else {
return false;
}
}
}
애그리거트 로딩 전략과 영속성 전파
애그리거트 로딩 전략
JPA 매핑을 설정할 때 항상 주의해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 것이다.
조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩(FetchType.EAGER)으로 설정한다.
하지만 항상 즉시 로딩으로 하는 게 옳은 것은 아니다.
애그리거트 영속성 전파
애그리거트거 완전항 상태여야 한다는 것 == 조회, 저장, 삭제할 때 하나로 처리해야 함
@Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
반면 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정해야 한다.
CascadeType.PERSIST / CascadeType.REMOVE
식별자 생성 기능
식별자는 크게 세 가지 방식 중 하나로 생성한다.
- 사용자 직접 생성
- 이메일 주소를 식별자로 할 경우, 사용자가 직접 입력하므로 따로 구현할 필요가 없다.
- 도메인 로직으로 생성
- 식별자를 생성하는 규칙이 존재한다면 이는 도메인 규칙이므로 도메인 영역에 도메인 서비스에 위치시킨다.
- DB를 이용한 일련번호 사용
- @GeneratedValue를 사용한다. 이때 식별자는 db에 insert 쿼리가 실행된 후 조회할 수 있다.
'Backend' 카테고리의 다른 글
[도메인 주도 개발 시작하기] Chapter5. 스프링 데이터 JPA를 이용한 조회 기능 (1) | 2024.11.03 |
---|---|
설명에 따른 책임을 이겨낼 것인가? (3) | 2024.10.06 |
[도메인 주도 개발 시작하기] Ch3. 애그리거트 (0) | 2024.09.22 |
[도메인 주도 개발 시작하기] Ch2. 아키텍처 개요 (1) | 2024.09.10 |
[도메인 주도 개발 시작하기] 책 선정 이유와 Ch1. 도메인 모델 시작하기 (0) | 2024.09.10 |