본문 바로가기

IT/Spring JPA

JPA 성능 최적화

반응형

N+1 문제

JPA 성능상 가장 주의해야 되는 문제이다.

먼저, N +1 문제는 지연로딩, 즉시로딩 모든 경우에 발생할 수 있다.

 

1) 즉시로딩 N+1문제

 

즉시로딩의 경우 엔티티 매니저를 통해 조회할 경우 즉시 연결 데이터를 조인해서 조회하기때문에 N+1문제가 발생하지 않는다.

하지만, JPQL를 사용할 경우는 다르다. 

먼저 JPQL은 즉시 로딩, 지연 로딩를 고려해서 쿼리를 실행하지 않기 때문에 아래와 같이 조회를 할 경우 문제가 발생한다.

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
    ...
}

[실행 JPQL]

List<Member> member = em.createQuery("select m from Member m", Member.class).getResultList();

 

select * from member; 

쿼리가 먼저 실행되고, 즉시로딩으로 인해 바로 추가로 아래 쿼리가 실행된다.

select * from orders where member_id = 1;

 

이 호출이 1번이면 상관없으나 만약 for으로 반복되서 호출될 경우

select * from member; 

select * from orders where member_id = 1;

select * from orders where member_id = 2;

select * from orders where member_id = 3;

select * from orders where member_id = 4;

... 반복문만큼 호출될 것이다.

 

2) 지연로딩 N + 1 문제

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<Order>();
    ...
}

[실행 JPQL]

List<Member> member = em.createQuery("select m from Member m", Member.class).getResultList();

 

동일한 JPQL을 실행할 경우 지연로딩으로 인해 처음엔 N+1 문제가 발생하지 않는다.

하지만, Member 엔티티의 Orders를 호출하는 순간 N+1 문제는 발생한다.

 

Ex)

for(int i = 0; i < 5; i++){

    member = members.get(i);

    member.getOrders().size();

)

 

반복문만큼 

select * from orders where member_id = 1;

select * from orders where member_id = 2;

select * from orders where member_id = 3;

select * from orders where member_id = 4;

... 쿼리가 실행된다.

 

이런 N + 1문제를 해결하는 방법으론 여러가지가 존재한다.

 

 

N + 1 문제 해결방법

 

1) Fetch 조인 (= INNER JOIN)

가장 기본적인 방법이다.

연관 엔티티가 존재하는 경우 SQL 조인을 사용해서 같이 호출하도록 처리한다. 

 

[JPQL]

select m from Member join fetch m.orders

 

[변환된 SQL]

select m.*, o.* from Member m inner join Orders o on m.id = o.member_id;

 

2) 하이버네이트 @BatchSize

하이버네이트에서 제공하는 어노테이션을 사용하면 연관된 엔티티를 조회할때 지정한 사이즈만큼만 조회한다. (SQL IN절 사용)

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @org.hibernate.annotations.BatchSize(size = 5)
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
    ...
}

즉시로딩임으로 조회시 반복만큼 Orders 엔티티를 조회해야 되는데 BatchSize 설정으로 5개씩 끊어서 조회하기 된다. 

즉, 6번째 데이터를 사용할때 그때 아래 쿼리가 추가로 실행된다.

select * from orders where member_id in ('~', '~', '~', '~', '~');

 

3) 하이버네이트 @Fetch(FetchMode.SUBSELECT)

FetchMode.SUBSELECT을 사용할 경우 연관된 데이터를 서브 쿼리를 통해 N + 1문제를 해결할 수 있다.

select *

from orders

where member_id in (

                                      select id

                                      from member

                                     );

 

 

정리

일단, 즉시로딩보다 지연로딩을 사용하는 걸 권장한다. 

이유는 즉시로딩을 사용할 경우 N+1 문제는 물론 사용하지 않는 엔티티에 대한 불필요한 조회까지 실행되기 때문에 연관관계가 있는 경우 지연로딩을 사용해야 된다. 그리고 즉시로딩의 경우 성능 최적화가 어렵다 

 

그러므로 최대한 지연로딩을 사용하고 성능 최적화가 필요한 경우 페치 조인을 적용하는 걸 권장한다.

 

-------------

 

읽기 적용 쿼리의 성능 최적화

JPA는 1차 캐시, 변경 감지를 통해 얻는 장점이 많다.

하지만, 이러한 장점을 얻기 위해선 많은 인스턴스들이 메모리에 할당되어야 하는 이슈가 있다.

 

이런 이슈를 해결하고자 읽기 전용으로 엔티티를 조회하는 경우 메모리 사용량을 최적화해야 된다.

 

1) 스칼라 타입으로 조회

엔티티로 조회하는 것이 아닌 아래와 같이 스칼라 타입으로 모든 필드 조회

select o.id, o.name, o.price from order o;

 

2) 읽기 전용 쿼리 힌트 적용

하이버네이트 전용 힌트인 readOnly를 적용한다. (영속성 컨텍스트 관리에서 제외됨)

TypedQuery<Order> query = em.createQuery("select o from Order o", Order.class);

query.setHint("org.hibernate.readOnly", true);

 

3) 읽기 전용 트랜젝션 사용

스프링 프레임워크를 사용하면 트랜잭션을 읽기 전용 모드로 설정할 수 있다. 

@Transactional(readOnly = true)

 

트랜잭션을 읽기모드로 적용하면 강제로 플러시를 호출하지 않는 이상 플러시가 발생하지 않는다.

그러므로 자동으로 플러시 되면서 수행되는 무거운 로직들이 실행되지 않아 성능이 향상된다. 

 

4) 트랜잭션 밖에서 읽기

말 그대로 트랜잭션 밖에서 엔티티를 조회하는 것임으로 커밋, 플러시 어떤 것도 적용되지 않는다.

그러므로 반드시 조회목적인 경우에만 사용해야 됨.

 

결과적으로 읽기 전용 데이터를 조회할때 메모리를 최적화하려면 1) 스칼라 타입 조회, 2) 하이버네이트가 제공하는 읽기 전용 쿼리 힌트

플러시 호출을 막아서 속도를 최적화하려면 1) 읽기 전용 트랜잭션 사용, 2) 트랜잭션 밖에서 읽기를 적용하면 된다.

 

두가지 방법을 합쳐서 사용하면 가장 효과가 크다. 

@Transactional(readOnly = true)
public List<Order> findOrder(){
    return em.createQuery("select o from Order", Order.class)
    .setHint("org.hibernate.readOnly", true)
    .getResultList();
}

 

--------

배치처리

 

엔티티를 계속 조회하면 연속성 컨텍스트에 많은 엔티티가 쌓이게 된다 그러면 메모리 부족으로 오류를 발생하기 된다.

그러므로 적절한 단위로 영속석 컨텍스트를 초기화해야 한다.

 

JPA 등록 배치

수만 건의 엔티티를 한번에 등록할 경우 영속성 컨텍스트에 해당 엔티티가 계속 쌓이게 되는데 이 경우 일정 단위마다 영속성 컨텍스트의 엔티티를 데이터베이스에 플러시하여 영속성 컨텍스트를 초기화해야 한다.

EntityManagerFactory entityManagerFactory = null;
EntityManager em = entityManagerFactory.createEntityManager();
EntityTransaction et = em.getTransaction();

et.begin();

for (int i = 0; i < 10000 ; i++) {
    Order order = new Order();
            
    em.persist(order);
            
    if (i % 100 == 0){
        em.flush();
        em.clear();
    }
            
}
        
et.commit();
em.close();

등록의 경우 로직이 간단해서 위와 같이 수기로 처리할 수 있지만, 수정의 경우 내부적으로 로직이 복잡함으로 수기로 처리하기가 어렵다 

 

수정 배치의 경우 페이징처리, 커서를 통해 해결하는 걸 권장한다.

 

1) JPA 페이징 처리

EntityManager em = entityManagerFactory.createEntityManager();
EntityTransaction et = em.getTransaction();

et.begin();

int pageSize = 100;

for (int i = 0; i < 10000 ; i++) {
    List<Order> orderList = em.createQuery("select o from Order", Order.class)
                              .setFirstResult(i*pageSize)
                              .setMaxResults(pageSize)
                              .getResultList();

    for (Order order: orderList) {
        order.setId("wwwww");
    }
    
    em.flush();
    em.clear();
}

et.commit();
em.close();

2) 하이버네이트 scroll 처리 (=커서)

 

.... to be continued

반응형

'IT > Spring JPA' 카테고리의 다른 글

JPA 예외처리 및 프록시 심화 익히기  (0) 2021.08.01
JPA 영속성 관리  (0) 2021.07.15
JPA 웹 어플리케이션 개발  (0) 2021.07.01
QueryDSL 사용법  (3) 2020.02.17
생성자 제한하기  (0) 2020.02.16