querydsl 사용 시 흔히 하는 실수
위 설정때문에 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 의 메서드 탐색 순서
- default implementation (SimpleJpaRepository) 탐색
- 1 에서 없으면, 메서드 이름 규칙 탐색
- 2 에서 없으면, custom repository impl 탐색
- 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
참고 ) 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 문제 해결 방법은 잘 알아두면 좋을 것 같다.
'세미나' 카테고리의 다른 글
유지보수하기 좋은 코드를 구현하는 개발 문화 어떻게 만들 것인가? (3) | 2023.04.24 |
---|---|
[우아한테크 세미나] 테크 리더 3인이 말하는 개발자 원칙 (0) | 2023.03.30 |
[기술교육] JPA 트러블 슈팅 - 1편 (1) | 2023.03.14 |
if Kakao dev 2020 - 카카오 대 장애 회고 정리 (2) | 2022.12.07 |
코드리뷰 노하우 (0) | 2022.08.12 |
댓글