jpa-3일차

박상원·2024년 5월 13일

spring

목록 보기
10/15

Repository 고급

메서드 이름 규칙에서 연관관계 Entity를 이용한 JOIN 쿼리 실행

  • 해당 엔티티의 연관관계가 설정된 필드를 선택하고 언더바를 사용하여 파고 들어가서 필드를 선택하고 이런식으로 join을 실행할 수 있다.

Dto Projection이란

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

Dto Projection 방법

  • Interface 기반 Projection
  • Class 기반 (DTO) Projection
  • Dynamic Projection

대부분 interface 기반 projection을 사용한다.
인터페이스 이름은 아무거나 상관없이 지어도 되고 메서드는 해당 entity와 매칭이 되는 getter를 만들어 주면 된다.
중첩 인터페이스 사용 시 여러개의 entity를 조합한 필드들을 사용할 수 있다.

벌크 insert 같은 경우는 jpa를 사용하지 않는 것이 좋다.

하나의 테이블에 컬럼을 여러개 받아오는 경우는 @JoinColumn을 여러개 사용해주면 된다.
원래는 @JoinColumns({@JoinColumn, @JoinColumn}) 형태로 사용했는데 버전이 올라가면서 @JoinColumn을 여러개 사용하는 것으로 바뀌었다.

Web Support

  • Spring Data에서 제공하는 Web 확장 기능

@EnableSpringDataWebSupport

@Configuration
@EnableWebMvc
@EnableSpringDataWebSupport
public class WebConfig {
    // ...
}

Basic Web Support

DomainClassConverter

  • MVC request parameter나 path variable로부터 Spring Data Repository가 관리하는 도메인 클래스로의 conversion을 제공

HandlerMethodArgumentResolver

  • MVC request parameter를 Pageable, Sort 인스턴스로 resolver할 수 있도록 해 준다.

Spring data가 insert를 하기 전에 select로 존재하는지 확인을 하고 insert를 한다. 이 것을 안 하기 위해서는 entity에서 Persistable을 구현하여 isNew를 재정의 하면 되는데 이 것을 사용하지 않으면 casecade를 둘 다 설정해야 한다.
이유는 SimpleJpaRepository에서 없으면 persist하고 있으면 merge를 하기 때문이다.

Dto에서 getter이름을 내가 정하고 싶을 때는 @Value(#{target.fieldName})을 추가해주면 된다.

Entity를 핸들러 메서드의 필드로 받을 수 있지만 사용하지 않는 것이 좋다.
왜냐면 Layered Architecture에서는 아래 계층이 위 계층에 역진하면 안되기 때문이다.

HandlerMethodArgumentResolver

  • Spring Data가 page, size 파라미터 값을 Controller의 Pageable 파라미터로 변환해서 전달해준다.

Controller에 Pageable 적용

@RestController
@RequestMapping("/items")
public class ItemController {
  @Autowired
  private ItemService itemService;

  @GetMapping
  public List<ItemDto> getItems(Pageable pageable) {   // GET /items?page=0&size=30
    return itemService.getItems(pageable);
  }
}

Pageable

  • pagination 정보를 추상화한 인터페이스
public interface Pageable {
  int getPageNumber();
  int getPageSize();
  int getOffset();

  Sort getSort();

  Pageable next();
  Pageable previousOrFirst();
  Pageable first();

  boolean hasPrevious();
}

Pageable interface의 대표적인 구현

PageRequest class

// ?page=0&size=30
PageRequest.of(0, 30);

Pageable을 이용한 Pagination 구현

  • JpaRepository.findAll(Pageable pageable) 메서드로 Controller에서 전달받은 Pageable 객체를 그대로 전달
@Service
public class ItemServiceImpl implement ItemService {
  public List<ItemDto> getItems(Pageable pageable) {
      Page<Item> itemPage = itemRepository.findAll(pageable);
      // ...
  }
}

Page interface

Page interface

public interface Page<T> extends Slice<T> {
	int getTotalPages();
	long getTotalElements();

    // ...
}

Slice interface

public interface Slice<T> extends Streamable<T> {
	int getNumber();
	int getSize();
	int getNumberOfElements();
	List<T> getContent();
	boolean hasContent();
	Sort getSort();

	boolean isFirst();
	boolean isLast();
	boolean hasNext();
	boolean hasPrevious();

    // ...
}

Page interface의 대표적인 구현

  • PageImpl class

find와 By 사이에는 아무런 문자가 와도 상관이 없다.

Slice 인터페이스를 Page 대신 사용하면 count를 사용하지 않고 페이지네이션을 지원한다.
mysql에서는 count 함수의 효율이 좋지 않기 때문에 10억개를 count 하게 되면 무리가 올 수 있다.
Slice를 사용하면 size + 1 만큼의 record를 가져오는 것을 시도해서 가져와지면 다음 페이지를 확인한다.

Open Entity Manager In View

  • 영속성 컨텍스트를 벗어나서 Lazy Loading 시도 시 LazyInitializationException이 발생
    • OSIV(Open Session In View, Open Entity Manager In View) 적용해서 해결 가능
  • Spring OSIV
    • org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor
    • org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter

Lazy loading을 하게 되면 컨트롤러에서 반환을 하게 되었을 때 fetch를 하기 위해 쿼리를 날려야 하는데 이 때는 트랜젝션의 범위를 벗어났기 때문에 쿼리를 날릴 수 없다.
이 방버을 해결하기 위해 OSIV를 사용하여 트랜젝션의 범위를 늘려 해결할 수 있지만 이 방법을 추천하진 않는다.
이유는 트랜젝션은 범위가 좁을수록 lock을 잡는 시간이 짧아져서 다른 곳에서 lock을 잡으려고 할 때 기다리는 시간이 짧아지기 때문에 좋다.
OSIV를 키게 되면 트랜젝션의 범위가 Controller까지 넓어지게 되는데 범위가 커지면 애플리케이션이 느려질 수 있다.

Querydsl

복잡한 쿼리 생성

JPA에서 제공하는 객체 지향 쿼리

  • JPQL: 엔티티 객체를 조회하는 객체 지향 쿼리
  • Criteria API: JPQL을 생성하는 빌더 클래스

Third party library를 이용하는 방법

  • Querydsl
  • JOOQ
  • ...

JPQL

  • SQL을 추상화해서 특정 DBMS에 의존적이지 않은 객체지향 쿼리
  • 문제 : 결국은 SQL이라는 점
    • 문자 기반 쿼리이다보니 컴파일 타임에 오류를 발견할 수 없다.
SELECT DISTINCT post
FROM Post post
  JOIN post.postUsers postUser 
  JOIN postUser.projectMember projectMember
  JOIN projectMember.member member
WHERE member.name = 'dongmyo'

Criteria API

  • 프로그래밍 코드로 JPQL을 작성할 수 있고 동적 쿼리 작성이 쉽다.
  • 컴파일 타임에 오류를 발견할 수 있고 IDE의 도움을 받을 수 있다.
  • 문제 : 너무 복잡하다.
EntityManager em = ...;
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<PostEntity> cq = cb.createQuery(PostEntity.class);
Root<PostEntity> post = cq.from(Post.class);
cq.select(post);
TypedQuery<PostEntity> q = em.createQuery(cq);
List<PostEntity> posts = q.getResultList();
  • 위 코드를 JPQL로 표현하면
SELECT post FROM PostEntity post

Querydsl 쿼리 생성 style

from(entity)
	.where(/* conditions */)
    .list();

dsl(domain specific language) : 도메인 특정 언어는 특정 종류의 문제에 최적화된 높은 수준의 추상화를 갖춘 프로그래밍 언어이다. DSL은 필드 또는 도메인의 개념과 규칙을 사용한다.

Qeurydsl dependency

        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
            <version>5.0.0</version>
        </dependency>

        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-jpa</artifactId>
            <version>5.0.0</version>
        </dependency>
    <build>
        <plugins>
            <plugin>
                <groupId>com.mysema.maven</groupId>
                <artifactId>apt-maven-plugin</artifactId>
                <version>1.1.3</version>
                <configuration>
                    <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
                </configuration>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>process</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>target/generated-sources/annotations</outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

        </plugins>
    </build>

Querydsl 라이브러리의 apt는 annotation processing tool인데 이 것이 어떤 역할을 하냐면 우리가 @Entity로 만들어놓은 엔티티를 Q를 붙여서 Q타입 클래스로 만들어준다.

Spring Data JPA + Querydsl

  • QuerydslPredicateExecutor
  • QuerydslRepositorySupport

여기서 두 번째만 사용한다.
첫 번째는 기존에 했던 JPA와 차이가 별로 없고 궁극적으로는 join이 안된다.

QueryRepositorySupport

  • JPQLQuery를 통해 Querydsl의 모든 기능 사용 가능
    • ex) JOIN
  • QuerydslRepositorySupport 추상 클래스
    • QuerydslRepository를 상속받는 Custom Repository 구현 필요
@Repository
public abstract class QuerydslRepositorySupport {
  protected JPQLQuery from(EntityPath<?>... paths) { /* ... */ }
  protected DeleteClause<JPADeleteClause> delete(EntityPath<?> path) { /* ... */ }
  protected UpdateClause<JPAUpdateClause> update(EntityPath<?> path) { /* ... */ }
  // ...
}
public interface JPQLQuery<T> {
    JPQLQuery<T> from(EntityPath<?>... sources);
    <P> JPQLQuery<T> from(CollectionExpression<?,P> target, Path<P> alias);

    <P> JPQLQuery<T> innerJoin(EntityPath<P> target);
    <P> JPQLQuery<T> innerJoin(EntityPath<P> target, Path<P> alias);

    <P> JPQLQuery<T> join(EntityPath<P> target);

    <P> JPQLQuery<T> leftJoin(EntityPath<P> target);

    <P> JPQLQuery<T> rightJoin(EntityPath<P> target);

    // ...
}

CustomRepository를 위한 interface 생성

@NoRepositoryBean
public interface ItemRepositoryCustom {
    // querydsl로 복잡한 쿼리를 수행할 메서드.
    List<Item> complexQuery();
}

뒤에 Custom을 붙이는 것은 관례이다.
CustomRepository에는 @NoRepositoryBean을 사용해서 Bean으로 등록되지 않게 하여야 한다.

Custom Repository 기능 구현

  • QueryRepositorySupport 상속
  • ItemRepositoryCustom 구현
  • constructor 구현 필요
  • 구현 메서드에서 Querydsl 사용
public class ItemRepositoryImpl extends QuerydslRepositorySupport implements ItemRepositoryCustom {
    public ItemRepositoryImpl() {
        super(Item.class);
    }

    @Override
    public List<Item> complexQuery() {
        QItem item = QItem.item;

        JPQLQuery query = from(item).where(/* ... */);
        // ...
    }
}

기본 Repository interface 변경

  • 기본 Repository interface가 Custom Repository interface를 상속받도록 변경
public interface ItemRepository extends ItemRepositoryCustom, JpaRepository<Item, Long> {
}

Custom Repository 사용

  • 기본 Repository interface를 통해 custom 메서드를 호출
@Autowired 
ItemRepository itemRepository;
List<Item> items = itemRepository.complexQuery();

querydsl에서 Dto를 반환하게 하려면 select에서 Projections.constructor를 설정하여 파라미터로 entity 클래스와 Dto 내부 필드들을 적어주면 된다.

        return from(orderItem)
                .innerJoin(orderItem.order, order)
                .innerJoin(orderItem.item, item)
                .where(orderItem.quantity.gt(quantity))
                .select(Projections.constructor(OrderDto.class, item.price, orderItem.quantity))
                .distinct()
                .fetch();

N + 1 문제

단일 Entity 조회 시

itemRepository.findById(1L);

실제 수행되는 쿼리

select
    item0_."item_id" as item_id1_4_0_,
    item0_."item_name" as item_nam2_4_0_,
    item0_."price" as price3_4_0_ 
from
    "Items" item0_ 
where
    item0_."item_id"=1

여러 개의 Entity 조회 시

itemRepository.findAll();

실제 수행되는 쿼리

select
    item0_."item_id" as item_id1_4_,
    item0_."item_name" as item_nam2_4_,
    item0_."price" as price3_4_ 
from
    "Items" item0_

여러 개의 Entity 조회 + 객체 그래프 탐색

itemRepository.findAll()
    .stream()
    .map(Item::getOrderItems)
    .flatMap(Collection::stream)
    .collect(Collectors.summarizingInt(OrderItem::getQuantity));

실제 수행되는 쿼리

select
    item0_."item_id" as item_id1_4_,
    item0_."item_name" as item_nam2_4_,
    item0_."price" as price3_4_ 
from
    "Items" item0_
select
    orderitems0_."item_id" as item_id4_8_0_,
    orderitems0_."line_number" as line_num1_8_0_,
    orderitems0_."order_id" as order_id2_8_0_,
    orderitems0_."line_number" as line_num1_8_1_,
    orderitems0_."order_id" as order_id2_8_1_,
    orderitems0_."item_id" as item_id4_8_1_,
    orderitems0_."quantity" as quantity3_8_1_,
    order1_."order_id" as order_id1_9_2_,
    order1_."order_date" as order_da2_9_2_ 
from
    "OrderItems" orderitems0_ 
inner join
    "Orders" order1_ 
        on orderitems0_."order_id"=order1_."order_id" 
where
    orderitems0_."item_id"=1
select
    orderitems0_."item_id" as item_id4_8_0_,
    orderitems0_."line_number" as line_num1_8_0_,
    orderitems0_."order_id" as order_id2_8_0_,
    orderitems0_."line_number" as line_num1_8_1_,
    orderitems0_."order_id" as order_id2_8_1_,
    orderitems0_."item_id" as item_id4_8_1_,
    orderitems0_."quantity" as quantity3_8_1_,
    order1_."order_id" as order_id1_9_2_,
    order1_."order_date" as order_da2_9_2_ 
from
    "OrderItems" orderitems0_ 
inner join
    "Orders" order1_ 
        on orderitems0_."order_id"=order1_."order_id" 
where
    orderitems0_."item_id"=2

N + 1 문제

  • 쿼리 한 번으로 N 건의 레코드를 가져왔을 때, 연관관계 Entity를 가져오기 위해 쿼리를 N번 추가 수행하는 문제

해결 방법

  • Fetch Join
    • JPQL join fetch
    • Querydsl .fetchJoin()
  • Entity Graph

JPQL join fetch

    @Query("select i "
        + "from Item i "
        + "left outer join fetch i.orderItems oi "
        + "inner join fetch oi.order o")
    List<Item> getAllItemsWithAssociations();

Querydsl .fetchJoin()

return from(member)
    .innerJoin(member.locker, locker).fetchJoin()
    .leftJoin(member.memberDetails, memberDetail).fetchJoin()
    .fetch();

Fetch Join 주의할 점

  • Pagination 쿼리에 Fetch JOIN을 적용하면 실제로는 모든 레코드를 가져오는 쿼리가 실행된다.
  • 절대 사용 금물

fetch join을 사용하면 미리 연관된 Entity를 전부 다 가져와서 영속성 컨텍스트에 넣어준다.
fetch join과 pagination을 같이 사용하면 멸망한다.
이유는 메모리상에 몸든 레코드를 다 가져오고 페이지네이션을 실행하기 때문에 OOM(OutOfMemoryError)이 발생할 수 있다.

둘 이상의 컬렉션을 Fetch Join 시 MultipleBagFetchException 발생

  • Java의 java.util.List 타입은 기본적으로 Hibernate의 Bag 타입으로 맵핑됨
  • Bag은 Hibernate에서 중복 요소를 허용하는 비순차(unordered) 컬렉션
  • 둘 이상의 컬렉션(Bag)을 Fetch Join하는 경우
    • 그 결과로 만들어지는 카테시안 곱(Cartesian Product)에서
    • 어느 행이 유효한 중복을 포함하고 있고
    • 어느 행이 그렇지 않은 지 판단할 수 없어
    • MultipleBagFetchException 발생
  • 해결 방법
    • List를 Set으로 변경

OneToMany에서 fetch를 사용하면 순서가 없고 중복이 허용되기 때문에 오류가 발생한다.
순서를 주거나 중복을 허용하지 않으면 해결할 수 있다.
순서를 주는 방법은 추천하지 않는다.
이유는 순서라는 컬럼 하나가 더 추가되기 때문이다.

Entity Graph

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

종류

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

Entity에 @NamedEntityGraph 선언

@NamedEntityGraph(name = "itemWithOrderItems", attributeNodes = {
	@NamedAttributeNode("orderItems")
})
@NamedEntityGraph(name = "itemWithOrderItemsAndOrder", attributeNodes = {
	@NamedAttributeNode(value = "orderItems", subgraph = "orderItems")
}, subgraphs = @NamedSubgraph(name = "orderItems", attributeNodes = {
	@NamedAttributeNode("order")
}))
@Entity
public class Item {
	//...
}

Repository method에서 @EntityGraph를 이용해서 적용할 entity graph 지정

@EntityGraph("itemWithOrderItems")
List<Item> readAllBy();

0개의 댓글