성능 최적화
- JPA로 애플리케이션을 개발할 때 발생하는 다양한 성능 문제와 해결방안
- N+1 문제
- 읽기 전용 쿼리의 성능 최적화
- 배치 처리
- SQL 쿼리 힌트 사용
- 트랜잭션을 지원하는 쓰기 지연과 성능 최적화
N+1 문제
- JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 것이 N+1 문제
// 회원 엔티티
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER) // 1:N 양방향 연관관계, 즉시 로딩
private List<Order> orders = new ArrayList<Order>();
...
}
// 회원의 주문 정보
@Entity
@Table(name = "ORDERS")
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne // N:1 양방향 연관관계
private Member member;
...
}
- 즉시 로딩과 N+1
특정 회원 하나를 em.find() 메소드로 조회하면 즉시 로딩으로 설정한 주문정보도 함께 조회됨
여기서 함께 조회하기 위해 SQL을 두 번 실행하는 것이 아니라 조인을 사용해서 한 번의 SQL로 회원과 주문정보를 함께 조회
하지만 문제는 JPQL을 사용할 때 발생하며 JPQL을 실행하면 JPA는 이것을 분석해서 SQL을 실행하는데
즉시 로딩과 지연 로딩에 대해서는 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성하므로
SQL의 실행 결과로 먼저 회원 엔티티를 로딩하고 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩 설정되어 있으므로
JPA는 주문 컬렉션을 즉시 로딩하려고 SQL을 추가로 실행함
그러므로 회원 조회 SQL로 5명의 회원 엔티티를 조회한 것을 회원 엔티티와 연관된 주문 컬렉션을 즉시 조회하려고
총 5번의 SQL을 추가로 실행하게 되어 처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하게 되는 문제 발생
// em.find()
em.find(Member.class, id);
// 실행된 SQL
// SQL을 두 번 실행하는 것이 아니라 조인을 사용해서 한 번의 SQL로 회원과 주문정보를 함께 조회
SELECT M.*, O.*
FROM
MEMBER M
OUTER JOIN ORDERS O ON M.ID=O.MEMBER_ID
// JPQL
List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
// 실행된 SQL
SELECT * FROM MEMBER // 1번 실행으로 회원 5명 조회
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 // 회원과 연관된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=5 // 회원과 연관된 주문
- 지연 로딩과 N+1
위의 즉시 로딩 시나리오를 지연 로딩으로 변경해도 N+1 문제에서 자유로울 수 없음
지연 로딩으로 설정하면 JPQL에서는 N+1 문제가 발생하지 않으며 데이터베이스에서 회원만 조회함
그리고 이후 비즈니스 로직에서 주문 컬렉션을 실제 사용할 때 지연 로딩이 발생함
하지만 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때는 주문 컬렉션을 초기화하는 수만큼 SQL이 실행됨
그러므로 회원이 5명이면 회원에 따른 주문도 5번 조회됨
// 지연 로딩 설정
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY) // 1:N 양방향 연관관계, 지연 로딩
private List<Order> orders = new ArrayList<Order>();
...
}
// JPQL
// N+1 문제 발생 X
List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
// 실행된 SQL
// 지연 로딩이므로 회원만 조회
SELECT * FROM MEMBER
// 비즈니스 로직에서 주문 컬렉션을 실제 사용할 때 지연 로딩 발생
firstMember = member.get(0);
firstMEmber.getOrders().size(); // 지연 로딩 초기화
// 실행된 SQL
SELECT * FROM ORDER WHERE MEMBER_ID=?
// 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때 N+1 문제 발생
for (Member member : members) {
// 지연 로딩 초기화
System.out.println("member = " + member.getOrders().size());
}
// 실행된 SQL
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 // 회원과 연관된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=5 // 회원과 연관된 주문
- 페치 조인 사용
가장 일반적인 N+1 문제 해결 방법은 페치 조인을 사용하는 것이며 페치 조인은 SQL 조인을 사용해 연관된 엔티티를 함께 조회
// 페치 조인을 사용하는 JPQL
select m from Member m join fetch m.orders
// 실행된 SQL
// 일대다 조인을 했으므로 결과가 늘어나서 중복된 결과가 나타날 수 있으므로 JPQL의 DISTINCT를 사용하는 것이 좋음
SELECT M.*, O.* FROM MEMBER M
INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID
- 하이버네이트 @BatchSize
하이버네이트 BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size 만큼 SQL의 IN 절을 사용해서 조회
만약 조회한 회원이 10명인데 size=5로 지정하면 2번의 SQL만 추가로 실행함
// BatchSize 적용
@Entity
public class Member {
...
@org.hibernate.annotation.BatchSize(size = 5)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
...
}
// 실행된 SQL
/* 즉시 로딩을 설정하면 조회 시점에 10건의 데이터를 모두 조회해야 하므로 아래의 SQL이 두 번 실행됨
지연 로딩을 설정하면 지연 로딩된 엔티티를 최초 사용하는 시점에 아래 SQL을 실행해서 5건의 데이터를 미리 로딩하며
그리고 6번째 데이터를 사용하면 아래의 SQL을 추가로 실행 */
SELECT * FROM ORDERS
WHERE MEMBER_ID IN (
?, ?, ?, ?, ?
)
- 하이버네이트 @Fetch(FetchMode.SUBSELECT)
하이버네이트 Fetch 어노테이션에 FetchMode를 SUBSELECT로 사용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용
// @Fetch 적용
@Entity
public class Member {
...
@org.hibernate.annotation.Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
...
}
// JPQL
// 회원 식별자 값이 10을 초과하는 회원을 모두 조회
select m from Member m where m.id > 10
// 실행된 SQL
/* 즉시 로딩을 설정하면 조회 시점에
지연 로딩을 설정하면 지연 로딩된 엔티티를 사용하는 시점에 아래의 SQL이 실행됨 */
SELECT O FROM ORDERS O
WEHRE O.MEMBER_ID IN (
SELECT
M.ID
FROM
MEMBER M
WEHRE M.ID > 10
)
- N+1 정리
즉시 로딩과 지연 로딩 중 추천하는 방법은 즉시 로딩을 사용하지 말고 지연 로딩만 사용하는 것임
즉시 로딩 전략은 N+1 문제는 물론이고 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩해야 하는 상황이 자주 발생
그리고 즉시 로딩의 가장 큰 문제는 성능 최적화가 어렵다는 점으로 엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서
전혀 예상하지 못한 SQL이 실행될 수 있으므로 지연 로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에서 JPQL 페치 조인을 사용
- @OneToOne, @ManyToOne : 기본 페치 전략은 즉시 로딩
- @OneToMany, @ManyToMany : 기본 페치 전략은 지연 로딩
따라서 기본값이 즉시 로딩인 @OneToOne, @ManyToOne은 fetch = FetchType.LAZY로 설정이 필요
읽기 전용 쿼리의 성능 최적화
- 엔티티가 영속성 컨텍스트에 관리되면 1차 캐시부터 변경 감지까지 얻을 수 있는 해택이 많지만
영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관해야하므로 더 많은 메모리를 사용하는 단점이 존재
이를 위해 단지 딱 한 번만 읽어서 화면에 출력할 경우, 읽기 전용으로 엔티티를 조회하면 메모리 사용량을 최적화할 수 있음
// JPQL 쿼리
// 단순히 엔티티 조회만 하는 쿼리 (최적화 전)
select o from Order o
- 스칼라 타입으로 조회
엔티티가 아닌 스칼라 타입으로 모든 필드를 조회하는 방법이며 스칼라 타입은 영속성 컨텍스트가 결과를 관리하지 않음
메모리 사용량을 최적화할 수 있음
select o.id, o.name, o.price from Order p
- 읽기 전용 쿼리 힌트 사용
하이버네이트 전용 힌트인 readOnly를 사용하면 엔티티를 읽기 전용으로 조회할 수 있음
읽기 전용이므로 영속성 컨텍스트는 스냅샷을 보관하지 않아 메모리 사용량을 최적화할 수 있음
단 스냅샷이 없으므로 엔티티를 수정해도 데이터베이스에 반영되지 않음
TypedQuery<Order> query = em.createQuery("select o from Order o", Order.class);
query.setHint("org.hibernate.readOnly", true);
- 읽기 전용 트랜잭션 사용
스프링 프레임워크를 사용하면 트랜잭션을 읽기 전용 모드로 설정할 수 있음
트랜잭션에 readOnly=true 옵션을 주면 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANUAL로 설정
강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않아 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시 하지 않아
엔티티의 등록, 수정, 삭제는 당연히 동작하지 않고 플러시할 때 일어나는 스냅샷 비교와 같은 무거운 로직들을 수행하지 않음
물론 트랜잭션을 시작했으므로 트랜잭션 시작, 로직수행, 트랜잭션 커밋의 과정은 이루어지지만 단지 플러시를 하지 않음
이를 통해 플러시 호출을 막아서 속도를 최적화할 수 있음
@Transactional(readOnly = true)
- 트랜잭션 밖에서 읽기
트랜잭션 없이 엔티티를 조회한다는 것으로 JPA에서 데이터를 변경하려면 트랜잭션은 필수이므로 조회가 목적일 때만 사용
트랜잭션을 사용하지 않으면 플러시가 일어나지 않으므로 조회 성능(속도 최적화)이 향상됨
기본적으로 플러시 모드는 AUTO로 설정되어 있어 트랜잭션을 커밋하거나 쿼리를 실행하면 플러시가 작동하지만
트랜잭션 자체가 존재하지 않으므로 트랜잭션을 커밋할 일이 없으며 JPQL 쿼리도 트랜잭션이 없으면 플러시를 호출하지 않음
// 스프링 프레임워크 설정
@Transactional(propagation = Propagation.NOT_SUPPORTED)
// J2EE 표준 컨테이너 설정
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
- 플러시 호출을 막아서 작동하지 않도록 해서 속도 성능을 최적화하는 읽기 전용 트랜잭션 (또는 트랜잭션 밖에서 읽기) 과
엔티티를 읽기 전용으로 조회해서 메모리를 최적화하는 읽기 전용 쿼리 힌트 (또는 스칼라 타입으로 조회) 를
동시에 사용하는 것이 가장 효과적
// 읽기 전용 트랜잭션과 읽기 전용 쿼리 힌트 적용
@Transactional(readOnly = true)
public List<DataEntity> findDatas() {
return em.createQuery("select d from DataEntity d", DataEntity.class)
.setHint("org.hibernate.readOnly", true)
.getResultList();
}
배치 처리
- 수백만 건의 데이터를 배치 처리해야 하는 상황에서 엔티티를 계속 조회하면 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면서
메모리 부족 문제가 발생하므로 적절한 단위로 영속성 컨텍스트를 초기화해야 함
또한, 2차 캐시를 사용하고 있다면 2차 캐시에 엔티티를 보관하지 않도록 주의해야 함
- JPA 등록 배치
수천에서 수만 건 이상의 엔티티를 한 번에 등록할 때 주의할 점은 영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록
일정 단위마다 영속성 컨텍스트의 엔티티를 데이터베이스에 플러시하고 영속성 컨텍스트를 초기화해야 함
만약 이런 작업을 하지 않으면 영속성 컨텍스트에 너무 많은 엔티티가 저장되면서 메모리 부족 오류가 발생
// JPA 등록 배치 예제 (다량의 엔티티 등록)
// 엔티티를 100건 저장할 때마다 플러시를 호출하고 영속성 컨텍스트를 초기화
EntityManager em = entityManagerFactory.createEntutyManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
for (int i = 0; i < 100000; i++) {
Product product = new Product("item" + i, 10000);
em.persist(product);
// 100건마다 플러시와 영속성 컨텍스트 초기화
if (i % 100 == 0) {
em.flush();
em.clear();
}
}
tx.commit();
em.close();
- JPA 수정 배치
수정 배치 처리는 아주 많은 데이터를 조회해서 수정하므로 수많은 데이터를 한 번에 메모리에 올려둘 수 없어서 2가지 방법 사용
- 페이징 처리 : 데이터베이스 페이징 기능을 사용
- 커서 : 데이터베이스가 지원하는 커서 기능을 사용
- JPA 페이징 배치 처리
JPA를 사용하는 페이징 배치 처리는 한 번에 100건씩 페이징 쿼리를 조회하면서 상품의 가격을 100원씩 증가시킴
그리고 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화함
JPA는 JDBC 커서를 지원하지 않으므로 커서를 사용하려면 하이버네이트 세션을 사용해야 함
// JPA 페이징 처리 예제
// 한 번에 100건씩 페이징 쿼리를 조회하면서 상품의 가격을 100원씩 증가
// 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화
EntityManager em = entityManagerFactory.createEntutyManager();
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)
.setMaxResults(pageSize)
.getResultList();
// 비즈니스 로직 실행
for (Product product : resultList) {
product.setPrice(product.getPrice() + 100);
}
em.flush();
em.clear();
}
tx.commit();
em.close();
- 하이버네이트 scroll 사용
하이버네이트는 scroll이라는 이름으로 JDBC 커서를 지원
scroll은 하이버네이트 전용 기능이므로 먼저 em.unwrap() 메소드를 사용해서 하이버네이트 세션을 구하고
쿼리를 조회하면서 scroll() 메소드로 ScrollableResults 겍체를 반환하고 next() 메소드를 호출해 엔티티를 하나씩 조회
// 하이버네이트 scroll 사용 예제
EntityTransaction tx = em.getTransaction();
Session session = em.unwrap(Session.class);
tx.begin();
ScrollableResults scroll = session.createQuery("select p from Product p")
.setCacheMode(CacheMode.IGNORE) // 2차 캐시 기능을 끔
.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는 데이터베이스 SQL 힌트 기능을 제공하지 않으므로 이를 사용하려면 하이버네이트를 직접 사용해야 함
SQL 힌트는 하이버네이트 쿼리가 제공하는 addQueryHint() 메소드를 사용함
하이버네이트는 오라클 방언에만 힌트가 적용되어 있음
그러므로 다른 데이터베이스에서 SQL 힌트를 사용하려면 각 방언에서 Dialect에 있는 메소드를 오버라이딩해서 기능 구현
// SQL 쿼리 힌트 사용
Session session = em.unwrap(Session.class); // 하이버네이트 직접 사용
List<Member> list = session.createQuery("select m from Member m")
.addQueryHint("FULL (MEMBER)") // SQL HINT 추가
.list();
// 실행된 SQL
select
/*+ FULL (MEMBER) */ m.id, m.name
from
Member m
// 하이버네이트는 오라클 방언에만 힌트가 적용되어 있음
// 그러므로 다른 데이터베이스에서 SQL 힌트를 사용하려면 각 방언에서 Dialect에 있는 메소드를 오버라이딩해서 기능 구현
public String getQueryHintString(String query, List<String> hints) {
return query;
}
트랜잭션을 지원하는 쓰기 지연과 성능 최적화
- 트랜잭션을 지원하는 쓰기 지연과 JDBC 배치
트랜잭션을 지원하는 쓰기 지연을 활용해서 애플리케이션 성능을 최적화할 수 있음
네트워크 호출 한 번은 단순한 메소드를 수만 번 호출하는 것보다 더 큰 비용이 듦
이를 위해 JDBC가 제공하는 SQL 배치 기능을 사용하면 SQL을 모아서 데이터베이스에 한 번에 보낼 수 있음
하지만 이 기능을 사용하려면 코드의 많은 부분을 수정해야 하며
특히 비즈니스 로직이 복잡하게 얽혀 있는 곳에서 사용하기는 쉽지 않고 적용해도 코드가 상당히 지저분함
그래서 보통은 수백 수천 건 이상의 데이터를 변경하는 특수한 상황에서 SQL 배치 기능을 사용함
또한 JPA는 플러시 기능이 있으므로 SQL 배치 기능을 효과적으로 사용할 수 있음
만약 JPA를 사용하지 않고 SQL을 직접 다루면
update(memberA)를 호출할 때 UPDATE SQL을 실행하면서 데이터베이스 테이블 로우에 락을 걸게 되고
이 락은 비즈니스로직A(), 비즈니스로직B()를 모두 수행하고 commit()을 호출할 때까지 유지됨
트랜잭션 격리 수준에 따라 다르지만 보통 많이 사용하면 커밋된 읽기 격리 수준이나 그 이상에서는
데이터베이스에 현재 수정 중인 데이터를 수정하려는 다른 트랜잭션을 락이 풀릴 때까지 대기
반면 JPA는 커밋을 해야 플러시를 호출하고 데이터베이스에 수정 쿼리를 보냄
그러므로 commit()을 호출할 때 UPDATE SQL을 실행하고 바로 데이터베이스에 트랜잭션을 커밋함
이를 통해 쿼리를 보내고 바로 트랜잭션을 커밋하므로 결과적으로 데이터베이스에 락이 걸리는 시간을 최소화함
그러므로 이처럼 JPA의 쓰기 지연 기능은 데이터베이스에 락이 걸리는 시간을 최소화해서 동시에 더 많은 트랜잭션을 처리 가능
// 5번의 INSERT SQL과 1번의 커밋으로 총 6번 데이터베이스와 통신
insert(member1); // INSERT INTO ...
insert(member2); // INSERT INTO ...
insert(member3); // INSERT INTO ...
insert(member4); // INSERT INTO ...
insert(member5); // INSERT INTO ...
commit();
// 이것을 최적화하려면 5번의 INSERT SQL을 모아서 한 번에 데이터베이스로 보내면 되며 이를 위해 SQL 배치 기능 사용
// JPA를 사용하지 않고 SQL을 직접 다룰 때
update(memberA); // UPDATE SQL A -> 락이 걸리고 commit()을 호출할 때까지 유지
비즈니스로직A(); // UPDATE SQL ...
비즈니스로직B(); // INSERT SQL ...
commit();