JPA 성능 최적화
N+1 문제 해결 방법
페치 조인
- 페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.
- 페치 조인으로 일대다 조인을 할 경우 중복 결과가 발생한다. 이 때 JPQL DISTINCT를 사용해서 중복을 제거하는 것이 좋다.
// JPQL
select m from Member m join fetch m.orders
// SQL
SELECT M.*, O.*
FROM MEMBER M
INNER JOIN ORDERS O ON M.ID = I.MEMBER_ID
하이버네이트 @BatchSize
하이버네이트가 제공하는 @BatchSize
를 사용하면 size 만큼 SQL의 IN절을 사용해서 조회한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@BatchSize(size = 5)
@OneToMany(mappedBy = "member", fecth = FetchType.EAGER)
private List<Order> orders = new ArrayList<>();
...
}
- 만약 조회한 회원이 10명인데
@BatchSize(size = 5)
를 설정하면 2번의 SQL만 추가로 실행된다. - 즉시 로딩으로 설정하면 조회 시점에 10건의 데이터를 모두 조회하므로 SQL이 2번 실행된다.
- 지연 로딩으로 설정하면 최초 사용하는 시점에 SQL을 실행해 5건 데이터를 미리 로딩하고 6번째 데이터를 사용할 때 SQL이 추가로 실행된다.
hibernate.default_batch_fetch_size
속성을 사용하면 애플리케이션 전체에@BatchSize
를 적용할 수 있다.
하이버네이트 @Fetch(FetchMode.SUBSELECT)
연관된 데이터를 조회할 때 서브쿼리를 사용해서 N+1 문제를 해결한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<>();
...
}
// JPQL
select m from Member m where m.id > 10
// SQL
SELECT O
FROM ORDERS O
WHERE O.MEMBER_ID IN (
SELECT M.ID
FROM MEMBER M
WHERE M.ID > 10
)
즉시 로딩으로 설정하면 조회시점, 지연 로딩으로 설정하면 엔티티를 사용하는 시점에 SQL이 실행된다.
읽기 전용 쿼리 성능 최적화
엔티티가 영속성 컨텍스트에 관리되면 1차 캐시, 변경 감지 등 얻을 수 있는 이점이 많다. 하지만 영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용한다는 단점이 있다. 엔티티를 다시 조회할 일이 없을 때는 읽기 전용으로 조회하여 메모리 사용량을 최적화 할 수 있다.
스칼라 타입으로 조회
엔티티가 아닌 스칼라 타입으로 모든 필드를 조회하는 방법이다. 스칼라 타입은 영속성 컨텍스트에 관리되지 않는다.
select o.id, o.name, o.price from Order o
읽기 전용 쿼리 힌트 사용
하이버네이트 전용 힌트인 org.hibernate.readOnly
를 사용하여 읽기 전용으로 조회할 수 있다. 읽기 전용이므로 스냅샷을 보관하지 않아 메모리 사용량을 최적화 할 수 있다. 단, 스냅샷이 없으므로 엔티티를 수정해도 DB에 반영되지 않는다.
TypedQuery<Order> query = em.createQuery("select o from Order o", Order.class);
query.setHint("org.hibernate.readOnly", true);
읽기 전용 트랜잭션 사용
스프링 프레임워크를 사용한다면 트랜잭션을 읽기 전용 모드로 설정한다.
@Transactional(readOnly = true)
- 트랜잭션에 해당 옵션을 주면 하이버네이트 세션 플러시를
MANUAL
로 설정하여 강제로 플러시를 호출하지 않는 한 플러시가 발생하지 않는다. - 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시하지 않기 때문에 플러시할 때 일어나는 스냅샷 비교와 무거운 로직들이 수행되지 않는다. 영속성 컨텍스트를 플러시 하지 않을 뿐 트랜잭션 과정은 동일하게 진행된다.
트랜잭션 밖에서 읽기
트랜잭션 없이 엔티티를 조회한다는 의미다. 트랜잭션 자체가 존재하지 않으므로 트랜잭션을 커밋할 일이 없어 플러시가 발생하지 않는다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
읽기 전용 트랜잭션(또는 트랜잭션 밖에서 읽기)과 읽기 전용 쿼리 힌트(스칼라 타입으로 조회)를 동시에 사용하는 것이 효과적이다. 읽기 전용 트랜잭션은 플러시를 작동시키지 않아 성능을 향상시키고, 읽기 전용 엔티티 사용은 엔티티를 읽기 전용으로 조회하여 메모리를 절약할 수 있다.
배치 처리
영속성 컨텍스트에 많은 엔티티가 쌓이면 메모리 부족 오류가 발생하게 된다. 그러므로 적절한 단위로 영속성 컨텍스트를 초기화해야 한다.
JPA 등록 배치
수천에서 수만 건 이상의 엔티티를 등록할 때는 영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록 일정 단위마다 영속성 컨텍스트의 엔티티를 DB에 플러시하고 초기화하는 작업이 필요하다.
EntityManager em = entityManagerFactory.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
for(int i=0; i<10000; i++) {
Product product = new Product("item" + i, 10000);
em.persist(product);
if(i % 100 == 0) {
em.flush();
em.clear();
}
}
tx.commit();
em.close();
JPA 페이징 배치 처리
페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화한다.
EntityManager em = entityManagerFactory.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
int pageSize = 100;
for(int i=0; i<10; i++) {
List<Product> resultList = em.createQuery("select p from Product p", Product.class)
.setFirstResult(i * pageSize)
.setMaxResult(pageSize)
.getResultList();
for(Product product : resultList) {
product.setPrice(product.getPrice() + 100);
}
em.flush();
em.clear();
}
tx.commit();
em.close();
하이버네이트 scroll
하이버네이트는 scroll
이라는 이름으로 JDBC 커서를 지원한다. scroll
은 하이버네이트 전용 기능이므로 EntityManager.unwrap()
메서드로 세션을 구해야 한다.
EntityManager em = entityManagerFactory.createEntityManager();
EntityTransaction tx = em.getTransaction();
Session session = em.unwrap(Session.class);
tx.begin();
ScrollableResults scroll = session.createQuery("select p from Product p")
.setCacheMode(CacheMode.IGNORE)
.scroll(ScrollMode.FORWARD_ONLY);
int count = 0;
while(scroll.next()) {
Product p = (Product) scroll.get(0);
p.setPrice(p.getPrice() + 100);
count++;
if(count % 100 == 0) {
session.flush(); // 플러시
session.clear(); // 영속성 컨텍스트 초기화
}
}
tx.commit();
session.close();
하이버네이트 무상태 세션
하이버네이트는 무상태 세션이라는 특별한 기능을 제공한다. 이는 영속성 컨텍스트를 만들지 않고 심지어 2차 캐시도 사용하지 않는 상태를 말한다. 엔티티를 수정할 때는 무상태 세션이 제공하는 update()
메서드를 직접 호출해야 한다.
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
StatelessSession session = sessionFactory.openStatelessSession();
Transaction tx = session.beginTransaction();
ScrollableResults scroll = session.createQuery("select p from Product p").scroll();
while(scroll.next()) {
Product p = (Product)scroll.get(0);
p.setPrice(p.getPrice() + 100);
session.update(p); // update 직접 호출
}
tx.commit();
session.close();
SQL 쿼리 힌트
JPA는 DB SQL 힌트 기능을 제공하지 않는다. SQL 힌트를 사용하려면 하이버네이트 쿼리가 제공하는 addQueryHint()
메서드를 사용한다.
Session session = em.unwrap(Session.class);
List<Member> memberList = session.createQuery("select m from Member m")
.addQueryHint("FULL (MEMBER)")
.list();
// SQL
SELECT
/*+ FULL (MEMBER) */
M.ID, M.NAME
FROM MEMBER M
트랜잭션을 지원하는 쓰기 지연과 성능 최적화
트랜잭션을 지원하는 쓰기 지연과 JDBC 배치
네트워크 호출은 단순한 메서드를 수만 번 호출하는 것보다 큰 비용을 소모한다. JDBC가 제공하는 SQL 배치 기능을 사용하면 SQL을 모아서 DB에 보내는 것이 가능하다. 그러나 SQL 배치 기능을 사용하려면 코드에서 많은 부분을 수정해야 한다. 특히 비즈니스 로직이 복잡하게 얽혀 있는 곳에서 사용하기는 쉽지 않고 적용해도 코드가 지저분해진다. 그래서 보통 수백 수천 건 이상의 데이터를 변경하는 특수한 상황에만 SQL 배치 기능을 사용한다.
SQL 배치 최적화 전략은 구현체마다 다르다. 하이버네이트를 예로 들면 hibernate.jdbc.batch_size
속성을 설정하면 된다. 해당 속성에 설정된 값만큼 모아서 SQL 배치를 실행하게 된다.
em.persist(new Member()); // SQL 배치 시작
em.persist(new Member());
em.persist(new Member());
em.persist(new Member());
em.persist(new Child()); // SQL 배치 재시작
em.persist(new Member()); // SQL 배치 재시작
em.persist(new Member());
-
SQL 배치는 같은 SQL일 때만 유효하여 중간에 다른 처리가 들어가면 SQL 배치를 다시 시작한다. 위 코드를 예로 들면 총 3번의 SQL 배치가 실행된다.
-
IDENTITY 식별자 생성 전략은 엔티티를 DB에 저장해야 식별자를 구할 수 있으므로
em.persist()
를 호출하는 즉시 INSERT SQL이 DB에 전달된다. 따라서 쓰기 지연을 활용한 성능 최적화가 불가능하다.
트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성
트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능 덕분에 성능과 개발의 편의성 두 가지를 모두 얻을 수 있다. 하지만 진짜 장점은 트랜잭션을 커밋해서 영속성 컨텍스트를 플러시하기 전까지는 DB에 데이터를 등록, 수정, 삭제하지 않기 때문에 DB 테이블 Row에 락이 걸리는 시간을 최소화 한다는 점이다.
update(member); // Update SQL
비즈니스로직A(); // Update SQL
비즈니스로직B(); // Insert SQL
commit();
-
JPA를 사용하지 않고 SQL를 직접 다루면
update(member)
를 호출할 때 Update SQL을 실행하면서 DB 테이블 Row에 락을 건다. 이 락은비즈니스로직A(), 비즈니스로직B()
를 모두 수행하고commit()
을 호출할 때까지 유지된다. -
트랜잭션 격리 수준에 따라 다르지만 보통 많이 사용하는 커밋된 읽기(
Read Committed
) 격리 수준이나 그 이상에서는 DB에 현재 수정 중인 데이터를 수정하려는 다른 트랜잭션은 락이 풀릴 때까지 대기한다. -
JPA는
commit()
을 호출할 때 Update SQL을 실행하고 DB 트랜잭션을 커밋하므로 DB 락이 걸리는 시간을 최소화한다.
Comments