본문 바로가기
세미나

[기술교육] JPA 트러블 슈팅 - 2편

by 성건희 2023. 3. 15.
반응형

이전 내용 : JPA 트러블 슈팅 - 1편 보러가기

querydsl 사용 시 흔히 하는 실수

스크린샷 2023-03-15 오후 3 04 44

위 설정때문에 custom 구현체의 이름은 뒤에 impl 을 붙여야한다.

기본 Repository interface

  • MemberRepository

Custom Repository interface

  • MemberCustomRepository

Custom Repository 구현 class

  • MemberCustomRepositoryImpl (X)
  • MemberRepositoryImpl (O)

Repository Fragment 를 이용한 Custom Repository

초창기에는 위에 내용으로 MemberCustomRepositoryImpl 처럼 작성하면 에러가 발생했었다.
하지만 시간이 흘러 Spring Data Jpa 가 Repository Fragment 를 이용하면서
하나의 커스텀 클래스에 모든 기능을 다 때려박을 필요없이, 여러개의 커스텀 클래스로 관심사를 분리하여 사용할 수 있게 개선되었다.

따라서 기존에는 아래와 같이 커스텀 인터페이스에 @NoRepositoryBean 어노테이션을 붙였었다.

@NoRepositoryBean
public interface CustomizedMemberRepository {
    List<Member> getMembersWithAssociation();
}

하지만 이제는 해당 어노테이션 없이 구현 클래스에 impl 만 붙여주면 된다.

  • MemberCustomRepositoryImpl (O)
  • MemberRepositoryImpl (O)
public interface CustomizedMemberRepository {
    List<Member> getMembersWithAssociation();
}
public class CustomizedMemberRepositoryImpl implements CustomizedMemberRepository {
    // ...
}

public interface MemberRepository extends CustomizedMemberRepository, GuestRepository {
}

Spring Data Repository 의 메서드 탐색 순서

  1. default implementation (SimpleJpaRepository) 탐색
  2. 1 에서 없으면, 메서드 이름 규칙 탐색
  3. 2 에서 없으면, custom repository impl 탐색
  4. 3 도 없으면 에러 발생

Q type class 로 DB 예약어를 쓸 수 없는 상황

QMember member = new QMember("member");
QOrder order = new QOrder("order");

해당 코드로 작성하고 실행하면 unexpected 에러가 발생한다.
해결 방법은 간단하다. Q 클래스의 구현체를 보면 member1 처럼 이름을 넣어서 우회한다.

public static final QMember member = new QMember("member1");

Entity Graph

Entity 를 조회하는 시점에 연관 Entity 들을 함께 조회할 수 있도록 해주는 기능.

종류

  • 정적 선언 - @NamedEntityGraph
  • 동적 선언 - EntityManager.createEntityGraph()

entity

스크린샷 2023-03-15 오후 3 28 00
참고 ) subgraphs : 연관된 orderItems 안에 또 연관 관계가 있을경우 사용

repository

public interface OrderRepository extends JpaRepository<Order, Long> {
    @EntityGraph("orderWithCustomer")
    List<Order> getAllBy();

    @EntityGraph("orderWithOrderItems")
    List<Order> readAllBy();

    @EntityGraph("orderWithCustomerAndOrderItems")
    List<Order> queryAllBy();

    @EntityGraph("orderWithCustomerAndOrderItemsAndItem")
    List<Order> findAllBy();

}

Pagination 쿼리에 Fetch Join

페이징 중 N + 1 이 발생해서 fetch join 을 사용했다면..?
쿼리에 limit 가 사라짐….

로그 레벨을 warn 으로 지정하면 해당 쿼리 실행 시 아래 경고가 발생함.

2023-03-15 16:04:45.696  WARN 72848 --- [           main] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
JPQLQuery<Order> query = from(order)
        .innerJoin(order.customer, customer).fetchJoin()
        .leftJoin(order.orderItems, orderItem).fetchJoin()
        .innerJoin(orderItem.item, item).fetchJoin();

JPQLQuery<Order> pagedQuery = getQuerydsl().applyPagination(pageable, query);

long totalCount = 0L;

try {
    totalCount = pagedQuery.fetchCount();
} catch (NoResultException e) {
    // ignore
}

List<Order> list = pagedQuery.fetch();

return new PageImpl<>(list, pageable, totalCount);

Pagination 쿼리에 Fetch Join을 적용하면 실제로는 모든 레코드를 가져오는 쿼리가 실행된다

우리가 눈으로 볼때는 페이징 처리가 된다.
만약 총 레코드 수가 10000개라면, 일단 JPA 는 레코드 10000개를 전부 조회한다.
그 후 페이징 숫자가 1이면?
1 페이지에 해당하는 부분만 쓰고 나머지 부분은 다 버림..
즉, 페이징은 되지만 DB 서버는 죽어나가고 있음...

해결 방법

  • Pagination 쿼리와 Fetch Join을 분리
  • Pagination 쿼리에서는 entity가 아닌 ID만을 가져온다
  • Fetch Join에는 앞서 가져온 ID 조건을 추가
JPQLQuery<Long> query = from(order).select(order.orderId); // PK 값만 가져옴
JPQLQuery<Long> pagedQuery = getQuerydsl().applyPagination(pageable, query);

long totalCount = 0L;
try {
    totalCount = pagedQuery.fetchCount();
} catch (NoResultException ex) {
    // ignore
}

List<Long> ids = pagedQuery.fetch();

List<Order> list = from(order)
        .innerJoin(order.customer, customer).fetchJoin()
        .leftJoin(order.orderItems, orderItem).fetchJoin()
        .innerJoin(orderItem.item, item).fetchJoin()
        .where(order.orderId.in(ids))
        .fetch();

return new PageImpl<>(list, pageable, totalCount);

pagination query 에서 offset, limit 에 bind 된 parameter 값이 log 에 안나오는 이유

select 
        order0_.order_id as order_id1_3_,
        order0_.customer_id as customer3_3_,
        order0_.order_dt as order_dt2_3_ 
from orders order0_
where
        order0_.order_id=?
limit ?
o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]

offset, limit 는 DBMS 별로 지원이 될 수도 있고 안 될 수도 있는 쿼리다. (DBMS 방언 (Dialect))
따라서 offset, limit 는 BasicBinder 에서 처리가 되지 않는다.

해결법

굳이 로깅 메시지에 보이기를 원한다면, log4jdbc와 같은 JDBC 레벨에서의 로깅이 가능한 라이브러리를 써야한다.

둘 이상의 컬렉션을 Fetch Join하는 경우

// entity 코드
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private List<OrderAttribute> attributes;
// querydsl 코드
return from(order)
        .leftJoin(order.orderItems, orderItem).fetchJoin()
        .leftJoin(order.attributes, attribute).fetchJoin()
        .innerJoin(orderItem.item, item).fetchJoin()
        .fetch();

위처럼 작성한 코드를 실행하면 아래와 같이 MultipleBagFetchException 에러가 발생한다..

Caused by: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

MultipleBagFetchException

  • Hibernate는 collection type으로 list, set, bag, map 등 다양한 타입을 지원
  • Java의 java.util.List 타입은 기본적으로 Hibernate의 Bag 타입으로 맵핑됨
  • Bag은 Hibernate에서 중복 요소를 허용하는 비순차(unordered) 컬렉션
  • 둘 이상의 컬렉션(Bag)을 Fetch Join하는 경우, 그 결과로 만들어지는 카테시안 곱(Cartesian Product)에서
    어느 행이 유효한 중복을 포함하고 있고 어느 행이 그렇지 않은 지 판단할 수 없어
    Bag 컬렉션으로 변환될 수 없기 때문에 MultipleBagFetchException 예외 발생

해결 방법

  • 방법 1 ) 중복을 허용하지 말자
    • List 를 Set 으로 변경
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private Set<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private Set<OrderAttribute> attributes;
  • 방법 2 ) 순서를 부여하자
    • @OrderColumn 적용
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
@OrderColumn
private List<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
@OrderColumn
private List<OrderAttribute> attributes;

이렇게 하면 해결되는 것 같지만.. OrderColumn 을 적용하면 함정이 하나 있다.

  • orderItems_order
  • attributes_order

2개의 컬럼이 테이블에 추가가 되어 버린다.
insert 시 순서에 해당하는 값까지 같이 넣기 때문이다.

요런 문제 때문에 JPA 공식 해법은 다음과 같이 내놨다.

2개 이상의 컬렉션을 쓰지 마세요.

Spring Data Jpa Repository 로는 JOIN 쿼리를 실행할 수 없는가?

 select m from Member m inner join MemberDetail md where md.type = ?
@Getter
@Setter
@Entity
@Table(name = "Members")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "member_id")
    private Long memberId;

    private String name;

    @Column(name = "create_dt")
    private LocalDateTime createDate;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "member")
    private List<MemberDetail> details = new ArrayList<>();

}
@Getter
@Setter
@Entity
@Table(name = "MemberDetails")
public class MemberDetail {
    @EmbeddedId
    private Pk pk;

    private String description;

    @ManyToOne
    @MapsId("memberId")
    private Member member;

    @Getter
    @Setter
    @NoArgsConstructor
    @EqualsAndHashCode
    @Embeddable
    public static class Pk implements Serializable {
        @Column(name = "member_id")
        private Long memberId;
        private String type;
    }
}

위 쿼리를 메서드 네이밍 기법으로 처리한다면?

List<Member> findByDetails_Pk_Type(String type);

정말 간단한 쿼리는 이런식으로 사용하면 될 것 같긴하다.
하지만 현업에서는 유지보수, 가독성 등의 문제로 잘 사용하지는 않을듯..?

DTO Projection

Repository 메서드가 Entity를 반환하는 것이 아니라 원하는 필드만 뽑아서 DTO(Data Transfer Object)로 반환하는 것

DTO projection 방법

  • Interface 기반 Projection -> 가장 많이쓰고 효과가 좋음
  • Class 기반 (DTO) Projection
  • Dynamic Projection

Spring Data Repository를 이용한 Dto Projection

  • Repository 메서드의 반환 값으로 Entity가 아닌 Dto를 사용할 수 있다
  • interface / class
  • @Value + SpEL (target)
{
    "name": "",
    "details": [{
        "type": "",
        "description": ""
    }]
}

위 형식으로 DTO 를 만들고 싶다면..?

public interface MemberRepository extends JpaRepository<Member, Long> {
    Collection<MemberDto> findAllBy();
}
public interface MemberDto {

    String getName();

    List<MemberDetailDto> getDetails();

    interface MemberDetailDto {

        @Value("#{target.pk.type}")
        String getType();

        String getDescription();
    }
}

참고 ) @Value 의 target 은 MemberDetail 을 의미한다.

정리

여기서 나온 것 이외의 문제가 발생한다면 설계 문제 일 가능성이 크다.
가장 많이 발생하는 N + 1 문제와 페이징에서의 N + 1 문제 해결 방법은 잘 알아두면 좋을 것 같다.

반응형

댓글