Syncope.T-*
Published 2022. 9. 1. 00:04
JPA Join에 관하여 BackEnd/Spring
728x90

 

Entity

Account

fads

@Entity
@Getter
@Setter
public class Account {

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

    @Column(name = "mail", nullable = false, columnDefinition = "TEXT")
    private String email;

    @Override
    public String toString() {
        return "Account{" +
                "id=" + id +
                ", email='" + email + '\'' +
                ", password='" + password + '\'' +
                '}';
    }

    @Column(name = "password", nullable = false, columnDefinition = "TEXT")
    private String password;
}

ProductOrder

@Entity
@Table(name = "product_order")
@Getter
@Setter
public class Orders {

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

    @Column(name = "order_no", nullable = false)
    private String orderNo;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "account_id")
    private Account account;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;
}

Product

@Entity
@Getter
@Setter
public class Product {

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

    @Column(nullable = false)
    private String name;
}

Review

@Entity
@Getter
@NoArgsConstructor
@TypeDef(name = "json", typeClass = JsonStringType.class)
public class Review implements Serializable  {

    private static final long serialVersionUID = -6952994435967100712L;

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

    @Column
    private Integer star;

    @Column
    private String contents;

		// Dirty체크의 주요 대상
    @Type(type = "json")
    @Column(columnDefinition = "json")
    private List<Storage> storage = new ArrayList<>();

    @ManyToOne
    @JoinColumn
    private Product product;

    public Review(ReviewDTO dto) {
        this.star = dto.getUserStar();
        this.contents = dto.getUserContents();
        this.storage = dto.getStorage();
    }

    public void update(ReviewDTO dto) {
        this.star = dto.getUserStar();
        this.contents = dto.getUserContents();
    }

    public void updateContents(String contents) {
        this.contents = contents;
    }

    public void updateStorage(List<Storage> storage) {
        this.storage = storage;
    }
}

OneToMay vs ManyToOne

ManyToOne

ManyToOne의 Fetch 타입은 기본적으로 Eager인데, 이는 필요하지 않아도 무조건 SQL을 실행한다. 그래서 Lazy로 바꾸면, getAccounts와 같은 메서드를 실행할때만 SQL이 실행된다.

그러면 One Query로 땡기는 방법은 없나요~?

이걸 해주는 방법이 총 2개가 있다.

  1. EntityGraph
  1. Fetch Join

EntityGraph

Repository에 Select를 할 메소드 에다가 EntityGraph Annotation을 달아주고, 제공자로 Join할 변수명을 적어준다. 단점이 모든 칼럼 착출left outer join 가기 때문에 inner 옵션이나 커스터마이징이 불가능하다. 커스텀이 필요할때는 (예로들어, 필요한 컬럼만 딱 뽑을때, Inner Join이 필요할 때 등) Fetch Join을 사용하자.

@EntityGraph(attiributes = {"변수명", "변수명2"....})
List<ProductOrder> findAll();

Fetch Join

Fetch Join은 JPQL에서 진행한다. (INNER|OUTER) JOIN FETCH o.변수명 으로 조인을 진행하면 된다.

@Query("SELECT o FROM ProductOrder o INNER JOIN FETCH o.account INNER JOIN FETCH o.product")
List<ProductOrder> findAllFetchJoin();

OneToMany

서로 상관관계가 명확할때는 OneToMany를 선언하는것이 좋다.

그러나 Account랑 Order경우는 Account가 생성시에 Order가 생성되는것이 아니니깐 Order쪽에서 ManyToOne만 만들어라

관리자 입장에서 내가 등록한 상품의 주문목록들을 조회하고 싶다고 가정하자

그러면 Product를 조회시 Order를 같이 알아야 하기 때문에 Product 클래스 내에 Order 변수 위에다 OneToMany를 입력해 놓았다. 그리고 Product Repository에서 쿼리를 아래와 같이 작성하였다.

@OneToMany(mappedBy = "product") private List<Orders> orders = new ArrayList<>();
@Query("SELECT p FROM Product p INNER JOIN FETCH p.orders o INNER JOIN FETCH o.account")
List<Product> findAllWithOrder();

그러면 아래와 같이 조회된다.

JOIN한 결과가 6개니 6개로 나왔다. 그럼 HashSet과 같이 상품이 묶여서 나와야 하는건 어떻게 할까?

SELECT Query에 Distinct를 넣으면 아래와 같이 구성된다. Application에서 Distinct를 해준다고 한다.

@Query("SELECT Distinct p FROM Product p INNER JOIN FETCH p.orders o INNER JOIN FETCH o.account")
List<Product> findAllWithOrder();

상품별로 묶여서 나오는걸 볼 수 있다.

그러나 OneToMany를 적용한 Query의 Pageable을 넣으면 Application Distinct 하기 전인 6 Row Result를 토대로 Pagination을 진행하기 때문에, 1페이지당 아이템이 5개면, 6 Row중 5 Row가 나온다.

결과는 OneToMany를 토대로 한 Entity 기준으로 Repository에 Pagable을 사용하는것은 지양하라.

결론은 Service에서 Distinct된 결과를 Pagable 객체를 재생성해서 반환하라가 됨.

※ OneToMany Fetch Join은 2개 이상 사용할 수 없다. 빌드시점에서 에러난다 테스트 코드는 아래와 같다. Product클래스에 OneToMany reviews를 넣고, Review 클래스에 ManyToOne product를 넣어보자. OneToMany된 Orders와 Reviews를 2개 동시에 사용한 쿼리다.

@Query("SELECT distinct p FROM Product p INNER JOIN FETCH p.orders o INNER JOIN FETCH p.reviews r INNER JOIN FETCH o.account")
List<Product> findAllWithOrder();

이렇게 OneToMany를 2번이상 Fetch Join하여 N+1문제를 해결하려다 보면 MultipleBagFetchException을 만나게 됨. 결국 하나만 Fetch하고 나머진 일반 Join으로하여 N+1을 안고 가야한다. 추가적으로 Batch Size를 활용하면 좋다. 이는 아래에서 서술.

DTO로 Repostiory 반환하기

Order DTO를 만든다.

@Getter
@Setter
@ToString
public class OrderDTO {
    private String email;
    private String password;

    public OrderDTO(String email) {
        this.email = email;
    }

    public OrderDTO(String email, String password) {
        this.email = email;
        this.password = password;
    }
}

Repository에서 JPQL로 SELECT할 new 객체를 선정하고

JOIN시에는 Fetch를 제외하여 입력한다.

@Query("SELECT new com.example.jpajoin.model.dto.OrderDTO(o.account.email, o.account.password) FROM Orders o JOIN o.account JOIN o.product")
List<OrderDTO> findAllJoinWithDTO();

 

Entity 생성에 관하여

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review{

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

    @Column
    private Integer star;

    @Column
    private String contents;

    public Review(ReviewDTO dto) {
        this.star = dto.getUserStar();
        this.contents = dto.getUserContents();
    }

    public void update(ReviewDTO dto) {
        this.star = dto.getUserStar();
        this.contents = dto.getUserContents();
    }

}

Entity를 Dto기반으로 생성하는것은 Protected로 지켜주면, 유지보수시 편안한 환경을 제공해준다.

가령 A가 개발하다가, B가 들어와도, Review 클래스 자체를 New해서 하지 말라는 경고를 주기도 한다.

또한, Entity의 컬럼이 추가되더라도 대응이 쉽게된다.

 

Batch Size

배치사이즈를 application.properties에서 정해준다

spring.data.web.pageable.max-page-size=50 // Pagable page 사이즈를 최대값을 막아준다
spring.jpa.properties.hibernate.default_batch_fetch_size=1 //

Order의 Account는 Fetch로, Product 목록을 일반 조인을 하면 테스트 케이스가 완성된다.

예를들어서 Account id가 1인 유저의 Order 목록이 6개고, Product가 3개, Batch Size가 2이라고 가정하자.

일반 조인에서는 Lazy Loading으로 결과값이 나오기 때문에 Product를 호출하는 시점에서 총 3번의 Select가 이루어 질 것이다. 그러나 Product쪽에 Batch Size를 결정하는 순간 총 Select 회수는 2회가 발생하고, 처음 Select에 1,2 ROW가 WHERE절의 IN에 걸리고 두번째 Select에 3번 row만 걸린다.

아래 IN에 걸린다는 예시

select
        product0_.id as id1_1_0_,
        product0_.name as name2_1_0_ 
    from
        product product0_ 
    where
        product0_.id in (
            ?, ?
        )

 

Dirty Check

Save와 Flush에 관하여

@Test
@Rollback(value = false)
void createReview(){
    ReviewDTO reviewDTO = new ReviewDTO();
    reviewDTO.setUserStar(1);
    reviewDTO.setUserContents("asdasdasd");
    Review review = new Review(reviewDTO);
    reviewRepository.save(review);
    // flush 해주지 않아도 됨. 자동으로 됨.
}

@Test
@Rollback(value = false)
@Transactional(readOnly = true)
void updateReview() {
    Review review = reviewRepository.findById(1L).orElse(null);
    review.updateContents("updateaaaaaa");
    em.flush(); // 중간에 save를 해야 될 경우는 EntityManager를 Flush 하라.
    //reviewRepository.save(review); // 이 Save는 Merge 함수로 무조건 실행된다.
    // * Point : 마지막에 save를 적어주지 않아도 된다.
    // 그러나 Method가 실행되는 구간에 @Transactional Readonly가 true이면 변경이 되진 않는다.
}

 

DirtyCheck가 일어나는 과정

더티체크는 일반적인 Object인 경우들은 Hibernate 내부적으로 ==으로 검사하거나 Equals로 검사한다.

Entity끼리는 Entity GetProperty로 ==, 혹은 Equals로 검사하지만 List나 Set과같은 기본적으로 버블검사를 진행해야하는 경우들은 내부적인 list1.get(0).eqauls(list2.get(0))으로 진행한다. 그래서 Review내에 List<Storage> storages = new ArrayList<>();와 같은 항목이 있을때. Storage 객체에 Equals와 HashCode 를 선언해주지 않으면 List비교가 동일치 않다고 나오기 때문에 CRU (D 제외)를 진행할때마다 Update가 항상 뒤따르게 된다. Hibernate의 Type Interface 내부의 isDirty가 쓰이는 곳

[CustomType]

커스텀 타입에서는 isDirty를 isSame으로만 구분한다.

isSame은 AbstractType의 isEquals만 체크한다

그러나 List나 Set에 타입에서는EqualsSnapShot이라고 Copy된 Object랑 비교한다. elementType이 또CustomType인지 MetaType인지 PersistenceListType인지 Interface내부의 isDirty를 비교하는거랑 마찬가지가 된다.

그래서 쉽게 예를들면 OriginList.get(0).equals(SnapShotList.get(0))과 같은 체크를 진행한다고 보면 된다.

💡
주의사항! Dirty Check의 예제로 AttributeConverter로 테스트 케이스를 만들어놓은 사람들이 있는데 Period는 내부에 Object가 있어서 되는 예시고, 다른 일반적인 Custom Object Class 를 사용한다면 이는 잘못된 예시이니 믿지말자, 위 그림의 예제의 데이터가 Period가 아니라 일반적인 데이터 필드만 있다면 잘못된 예제이다. 사실상 Custom Object의 내부에 필드들을 리플렉션으로 Equals 체크한다. Custom Object 내부 필드상에 Object가 있으면 DirtyCheck가 일어나는 예제가 될 순 있다. 지원하는 타입을 보려면 org.hibernate.type 에 들어간 타입들은 참고하자 (예로 Duration 타입은 이미 있어서, Duration은 따로 Equals 해주려고 Duration기능을 하는 Custon Object를 만드는 작업이 필요없음 반대로 Period는 없어서 해줘야한다는 뜻) [추가로] Period내부의 Object1이 있으면 Objcet1 클래스의 Equals와 HashCode Period 클래스의 Equals와 HashCode 둘다 달아주어야 한다.

Dirty Check가 되는지 안되는지 보려면 Select만 진행해보면 알 수 있다.

@Test
@Rollback(value = false)
void dirtyCheckingReview() {
    Review review = reviewRepository.findById(1L).orElse(null);
    //review.storages는 dirty checking 부분에서 .equals로 비교하기때문에 @EqualsAndHashCode가 없으면 서로 다른 객체로 인식하여 같은값으로 update query 실행하게된다. 
}
profile

Syncope.T-*

@Syncope

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

profile on loading

Loading...