프로젝트/개인 프로젝트(2023.11.13-2023.12.18)

[개인프로젝트] JPA OneToOne N+1 문제

dal_been 2023. 12. 17. 20:21
728x90

쿼리 리팩토링하다가 정산 배치관련해서 쿼리 보다가 이게 뭐지..?하는 쿼리들이 많았다.

나는 분명 fetch join했는데 N+1문제가 터졌다..이게 무슨일이지..?? 초반에는 그냥 넘겼다. 일단 프로젝트 완료기간이 5일남았기에 남은 기능을 완료시키기 위해 ... 근데 이제는 시간이 어느정도 있어 한번 쿼리좀 보자 이 느낌으로 고치고 있는데 ...역시 N+1이 문제가 있었구만

 

문제상황

 

정산하기 위해서 예약 table과 pay table을 fetch join하는 query를 날렸다.

 

    return new JpaPagingItemReaderBuilder<AdjustWalkerInfo>()
        .name("adjustReader")
        .entityManagerFactory(entityManagerFactory)
        .pageSize(chunkSize)
        .queryString("SELECT NEW com.project.dogwalker.batch.adjust.dto.AdjustWalkerInfo(u, ph, w) "
        + "FROM WalkerReserveServiceInfo w "
        + "JOIN FETCH w.payHistory ph "
        + "JOIN FETCH w.walker u "
        + "WHERE w.status = :status "
        + "AND ph.payStatus = :payStatus")
        .parameterValues(parameter)
        .build();

 

근데 엥...?? 이게 뭐람..? 쿼리 로그를 보니

 

 SELECT
        NEW com.project.dogwalker.batch.adjust.dto.AdjustWalkerInfo(u, ph, w) 
    FROM
        WalkerReserveServiceInfo w 
    Inner JOIN
        PayHistory ph 
            ON w.reserveId = ph.reserveService.reserveId 
    JOIN
        FETCH User u 
            ON w.walker.userId = u.userId 
    WHERE
        w.status = :status 
        AND ph.payStatus = :payStatus */ select
            u1_0.user_id,
            u1_0.user_created_at,
            u1_0.user_updated_at,
            u1_0.user_email,
            u1_0.user_lat,
            u1_0.user_lnt,
            u1_0.user_name,
            u1_0.user_phone_number,
            u1_0.user_role,
            p1_0.pay_history_id,
            p1_0.pay_created_at,
            p1_0.user_id,
            p1_0.pay_method,
            p1_0.pay_price,
            p1_0.pay_status,
            p1_0.walker_reserve_service_id,
            p1_0.pay_updated_at,
            w1_0.walker_reserve_service_id,
            w1_0.walker_reserve_service_created_at,
            w1_0.customer_id,
            w1_0.walker_service_date,
            w1_0.walker_reserve_service_price,
            w1_0.walker_service_status,
            w1_0.walker_service_time_unit,
            w1_0.walker_reserve_service_updated_at,
            w1_0.walker_id 
        from
            walker_reserve_service w1_0 
        join
            pay_history p1_0 
                on w1_0.walker_reserve_service_id=p1_0.walker_reserve_service_id 
        join
            users u1_0 
                on w1_0.walker_id=u1_0.user_id 
        where
            w1_0.walker_service_status=? 
            and p1_0.pay_status=? limit ?,?
2023-12-17T18:26:21.794+09:00 DEBUG 11476 --- [           main] org.hibernate.SQL                        : 
    select
        p1_0.pay_history_id,
        p1_0.pay_created_at,
        p1_0.user_id,
        p1_0.pay_method,
        p1_0.pay_price,
        p1_0.pay_status,
        p1_0.walker_reserve_service_id,
        p1_0.pay_updated_at 
    from
        pay_history p1_0 
    where
        p1_0.walker_reserve_service_id=?
2023-12-17T18:26:21.796+09:00 DEBUG 11476 --- [           main] org.hibernate.SQL                        : 
    select
        p1_0.pay_history_id,
        p1_0.pay_created_at,
        p1_0.user_id,
        p1_0.pay_method,
        p1_0.pay_price,
        p1_0.pay_status,
        p1_0.walker_reserve_service_id,
        p1_0.pay_updated_at 
    from
        pay_history p1_0 
    where
        p1_0.walker_reserve_service_id=?

 

뒤 쿼리가 Payhistory N개 만큼 생기고 있다. 앙...?나는 fetch join했다고.. 

그래서 찾아보니 역시나 나의 무지에 의한 실수다

 

원인

 

일단 예약 테이블과 페이 테이블은 oneToone관계이다. 

거기다가 나느 lazy모드로 해놨다. 

 

페치조인의 정의를 살펴보자

A "fetch" join allows associations or collections of values to be initialized along with their parent objects using a single select.


페치 조인을 사용하면 1개의 select문을 사용하여 주인(부모) 객체와 함께 연관된 엔티티나 컬렉션의 값을 초기화 시킬 수 있다.

 

여기서 잘 봐야하는 점은 부모객체라는 점이다. 

 

그래서 내 코드를 생각해보니 (일부만 가져왔다)

@Entity
public class WalkerReserveServiceInfo extends BaseEntity {

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

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "customer_id",nullable = false)
  private User customer;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "walker_id",nullable = false)
  private User walker;

  @OneToOne(mappedBy = "reserveService",fetch = FetchType.LAZY)
  private PayHistory payHistory;

}

 

@Entity
public class PayHistory extends BaseEntity {

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

  @OneToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "user_id")
  private User customer;

  @OneToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "walker_reserve_service_id")
  private WalkerReserveServiceInfo reserveService;

}

 

요로코롬 되어있었다. 즉 Payhistory가 부모객체이다. 그러다보니 OneToOne관계에서 아무리 Lazy로 로딩해도 또는 fetch join을 해도 연관관계의 주인이 아닌곳에서 호출하면 즉시로딩되면서 PayHistory도 같이 로딩된 것이다. 

 

그 이유는 뭘까?? 

 

그 이유는 프록시의 한계이다. 

내 테이블을 기준으로 이야기 하자면 WalkerReserveServiceInfo DB에는 PayHistory에 대한 외래키가 없다. 즉 payhistory필드가 존재하지 않는다. 

프록시는 지연로딩으로 설정이 되어있는 엔티티를 프록시로 감싸서 동작하지만 위에서의 필드가 없기에 null로 되어있는 부분을 프록시로 감쌀 수가 없다.

 

쉽게 말해 WalkerReserveServiceINfo가 어떤 PayHistory에 의해 참조되고 있는지 아예알 수 없기에 존재여부 확인을 위해 지연로딩으로 동작하지 않는 것이다.

 

해결상황

 

그래서 쿼리를 수정하엿다.

    reader.setQueryString("SELECT NEW com.project.dogwalker.batch.adjust.dto.AdjustWalkerInfo(u, ph, w) "
        + "FROM PayHistory ph "
        + "JOIN FETCH ph.walkerReserveInfo w "
        + "JOIN User u On w.walker.userId = u.userId "
        + "WHERE w.status = :status "
        + "AND ph.payStatus = :payStatus");

 

PayHistory를 기준으로 walkerReserveInfo를 fetch join하였다. 

    SELECT
        NEW com.project.dogwalker.batch.adjust.dto.AdjustWalkerInfo(u, ph, w) 
    FROM
        PayHistory ph 
    JOIN
        FETCH ph.walkerReserveInfo w 
    JOIN
        User u 
            On w.walker.userId = u.userId 
    WHERE
        w.status = :status 
        AND ph.payStatus = :payStatus */ select
            u1_0.user_id,
            u1_0.created_at,
            u1_0.updated_at,
            u1_0.user_email,
            u1_0.user_lat,
            u1_0.user_lnt,
            u1_0.user_name,
            u1_0.user_phone_number,
            u1_0.user_role,
            p1_0.pay_history_id,
            p1_0.created_at,
            p1_0.user_id,
            p1_0.pay_method,
            p1_0.pay_price,
            p1_0.pay_status,
            p1_0.updated_at,
            w1_0.walker_reserve_service_id,
            w1_0.created_at,
            w1_0.customer_id,
            w1_0.walker_service_date,
            w1_0.walker_reserve_service_price,
            w1_0.walker_service_status,
            w1_0.walker_service_time_unit,
            w1_0.updated_at,
            w1_0.walker_id 
        from
            pay_history p1_0 
        join
            walker_reserve_service w1_0 
                on w1_0.walker_reserve_service_id=p1_0.walker_reserve_service_id 
        join
            users u1_0 
                on w1_0.walker_id=u1_0.user_id 
        where
            w1_0.walker_service_status=? 
            and p1_0.pay_status=? limit ?,?

 

메우 깔끔하게 한번에 조회가 되었다. 

 

 


부모,자식 관계를 깊게 생각안해보고 사용한 나의 잘못.... 거기다가 fetch join남발한 나의 잘못이다...

 

 

https://cobbybb.tistory.com/15

https://1-7171771.tistory.com/143

https://wave1994.tistory.com/156