(4) 성능 최적화
15.4.1 N+1 문제
JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 문제
즉시 로딩과 N+1
명령 | N+1 문제 발생 여부 |
---|---|
em.find() | X |
JPQL | O |
em.find()
사용 시 조인을 사용한 한 번의 SQL로 연관된 엔티티도 함께 조회한다.
JPQL 사용 시 JPA가 이를 분석해 SQL을 사용한다. 이때는 즉시 로딩과 지연 로딩에 대해 전혀 신경쓰지 않고 JPQL만 사용해서 SQL을 생성한다.
먼저
SELECT * FROM MEMBER
실행이후
SELECT * FROM ORDERS WHERE MEMBER_ID=1
=> 회원과 연관된 주문 엔티티 조회를 위한 N개 SQL 발생
지연 로딩과 N+1
위의 시나리오를 지연 로딩으로 변경해도 N+1 문제에서 자유로울 수 없다.
명령 | N+1 문제 발생 여부 |
---|---|
JPQL | X |
JPQL로 조회한 지연 로딩 초기화 | O |
즉, 지연 로딩으로 조회한 컬렉션을 사용할 시점에 N+1 문제가 발생한다.
문제 해결 방법 1: 페치 조인 사용
가장 일반적인 해결 방법으로, SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.
select distinct m from Member m join fetch m.orders // 중복 결과를 제거하기 위해 distinct 키워드 사용// 실행된 SQLSELECT M.*, O.* FROM MEMBER MINNER JOIN ORDERS O ON M.ID=O.MEMBER_ID
해결 방법 2: 하이버네이트 @BatchSize
연관된 엔티티를 조회할 때 @org.hibernate.annotaions.BatchSize
에 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다.
ex) 조회한 회원이 10명인데 size=5
로 지정하면 2번의 SQL만 추가로 실행한다.
@org.hibernate.annotaions.BatchSize(size = 5)@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)private List<Order> orders = new ArrayList<Order>();...
- 즉시 로딩으로 설정하면 조회 시점에 10건의 데이터 모두 조회해야 하므로 다음 SQL이 두 번 실행
지연 로딩으로 설정하면 지연 로딩된 엔티티를 최초로 사용하는 시점에 다음 SQL을 실행해서 5건의 데이터를 미리 로딩.
이후 6번째 데이터를 사용하면 다음 SQL을 추가로 실행
SELECT * FROM ORDERSWHERE MEMBER_ID IN (?, ?, ?, ?, ?)
해결 방법 3: 하이버네이트 @Fetch(FetchMode.SUBSELECT)
연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N+1 문제를 해결한다.
@org.hibernate.annotaions.Fetch(FetchMode.SUBSELECT)@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)private List<Order> orders = new ArrayList<Order>();...
마찬가지로 지연 로딩 시 연관된 엔티티 조회 시점에 SQL 실행
select m from Member m where m.id > 10// 실행된 SQLSELECT O FROM ORDERS OWHERE O.MEMBER_ID IN (SELECTR M.ID FROM MEMBER MWHERE M.ID > 10)
정리
즉시 로딩과 지연 로딩 중 추천하는 방법은 즉시 로딩은 사용하지 말고 지연 로딩만 사용하는 것이다.
즉시 로딩은 N+1 문제 + 비즈니스 로직에 따라 불필요한 엔티티도 로딩해야하는 상황 발생 => 성능 최적화가 어렵다.
JPA의 글로벌 페치 전략 기본값
@OneToOne
,ManyToOne
: 기본 페치 전략은 즉시 로딩@OneToMany
,@ManyToMany
: 기본 페치 전략은 지연 로딩
결론
모두 지연 로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL 페치 조인을 사용
@OneToOne
과@ManyToOne
은 fetch = FetchType.LAZY 로 설정해서 지연 로딩 전략을 사용하도록 변경
15.4.2 읽기 전용 쿼리의 성능 최적화
엔티티가 영속성 컨텍스트에서 관리되면 1차 캐시부터 변경 감지까지 얻을 수 있는 혜택이 많다.
하지만 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용하는 단점이 있다.
만약 한 번만 읽어서 화면에 출력하고, 다시 조회할 일이 없다면 읽기 전용으로 엔티티를 조회하면 메모리 사용량을 최적화할 수 있다.
아래의 방법들은 select o from Order o
를 최적화한다.
1. 스칼라 타입으로 조회
엔티티가 아닌 스칼라 타입으로 모든 필드를 조회 -> 영속성 컨텍스트가 스칼라 타입은 관리하지 않는다.
select o.id, o.name, o.price from Order p
2. 읽기 전용 쿼리 힌트 사용
하이버네이트 전용 힌트인 org.hibernate.readOnly
를 사용하면 엔티티를 읽기 전용으로 조회할 수 있다.
읽기 전용이므로 영속성 컨텍스트는 스냅샷을 보관하지 않는다. => 메모리 사용량 최적화 가능
단 스냅샷이 없으므로 엔티티를 수정해도 데이터베이스에 반영되지 않는다.
TypedQuery<Order> query = em.createQuery("select o from Order o", Order.class);query.setHint("org.hibernate.readOnly", true);
3. 읽기 전용 트랜잭션 사용
스프링 프레임워크 사용 시 트랜잭션을 읽기 전용 모드로 설정 가능
@Transactional(readOnly = true)
=> 스프링 프레임워크가 하이버네이트 세션
의 플러시 모드를 MANUAL
로 설정한다. 강제로 플러시 호출을 하지 않는 한 플러시가 발생하지 않는다.
트랜잭션을 시작했으므로 트랜잭션 시작, 로직 수행, 트랜잭션 커밋의 과정은 이루어지지만, 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시하지는 않는다. => 엔티티 등록, 수정, 삭제 동작 X
플러시 호출 시 스냅샷 비교와 같은 무거운 로직들을 수행하지 않아 성능이 향상된다.
하이버네이트 세션
JPA 엔티티 매니저를 하이버네이트로 구현한 구현체.
Session session = em.unwrap(Session.class)
엔티티 매니저의 플러시 설정은 AUTO(기본값, 트랜잭션 커밋 or 쿼리 실행 시 플러시), COMMIT(커밋 발생 시 플러시) 모드만 존재
하이버네이트 세션의 설정에는 MANUAL 모드가 있다. => 강제로 플러시 호출하지 않으면 절대 플러시 발생 X
4. 트랜잭션 밖에서 읽기
트랜잭션 없이 엔티티를 조회한다. (조회가 목적이라서 가능. JPA에서 데이터 변경하려면 트랜잭션 필수)
스프링 프레임워크 사용 시 다음과 같이 설정
@Transactional(propagation = Propagation.NOT_SUPPORTED)
마찬가지로 플러시가 발생하지 않아 조회 성능이 향상된다. 이때 트랜잭션 자체가 없어서 커밋도 발생 X. JPQL 쿼리도 트랜잭션 없이 실행하면 플러시를 호출하지 않는다.
읽기 전용 조회 시 최적화 방법 정리
1. 메모리 최적화
스칼라 타입 조회
하이버네이트가 제공하는 읽기 전용 쿼리 힌트
2. 속도 최적화 : 플러시 호출 막기
읽기 전용 트랜잭션 사용 (스프링 프레임워크 사용 시 편리한 방법)
트랜잭션 밖에서 읽기
@Transactional(readOnly = true) // 읽기 전용 트랜잭션public List<DataEntity> findDatas(){return em.createQuery("select d from DataEntity d", DataEntity.class).setHint("org.hibernate.readOnly", true) // 읽기 전용 쿼리 힌트.getResultList();}
읽기 전용 트랜잭션 사용 : 플러시를 작동하지 않도록 해서 성능 향상
읽기 전용 엔티티 사용 : 엔티티를 읽기 전용으로 조회해서 메모리 절약
15.4.3 배치 처리
수백만 건의 데이터를 배치 처리해야 하는 상황 가정
일반적인 방식으로 엔티티를 계속 조회 시 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면서 메모리 부족 오류가 발생
따라서 이런 배치 처리는 적절한 단위로 영속성 컨텍스트의 엔티티를 데이터베이스에 플러시하고 초기화해준다. 만약 2차 캐시를 사용하고 있다면 2차 캐시에 엔티티를 보관하지 않도록 주의해야 한다.
JPA 등록 배치
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가지 방법을 주로 사용
페이징 처리
커서 방식
1. JPA 페이징 배치 처리
데이터베이스 페이징 기능을 사용. 한 번에 조회할 페이지 수를 지정. 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화
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();
2. 하이버네이트 scroll 사용
하이버네이트는 scroll
이라는 이름으로 JDBC 커서를 지원한다. => 하이버네이트 전용 기능이므로 먼저 em.unwrap()
메소드를 사용해 하이버네이트 세션을 구한다.
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();
3. 하이버네이트 무상태 세션 사용
무상태 세션은 영속성 컨텍스트를 만들지 않고 2차 캐시도 사용하지 않는다.
영속성 컨텍스트가 없어서 엔티티 수정 시 무상태 세션이 제공하는 update()
메소드를 직접 호출해야 한다.
Session session = em.unwrap(Session.class);tx.begin();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); // 직접 호출}tx.commit();session.close();
15.4.4 SQL 쿼리 힌트 사용
JPA는 데이터베이스 SQL 힌트 기능을 제공하지 않는다. SQL 힌트를 사용하려면 하이버네이트를 직접 사용해야 한다. - addQueryHint()
메소드 사용
Session session = em.unwrap(Session.class); // 하이버네이트 직접 사용List<Member> list = session.createQuery("select m from Member m").addQueryHint("FULL (MEMBER)") // SQL HINT 추가.list();// 실행된 SQLselect/* + FULL(MEMBER) */ m.id, n.namefromMember m
SQL 힌트를 사용하려면 각 방언에서 다음 메소드를 오버라이딩해서 기능을 구현해야 한다.
public String getQueryHintString(String query, List<String> hints){return query;}
15.4.5 트랜잭션을 지원하는 쓰기 지연과 성능 최적화
트랜잭션을 지원하는 쓰기 지연과 JDBC 배치
네트워크 호출 한 번의 비용 > 단순한 메소드 수만 번 호출 비용
N 개의 INSERT SQL
과 1 번의 커밋
으로 데이터와 통신할 때, 여러 번의 INSERT SQL을 모아서 한번에 데이터베이스로 보냄으로써 최적화할 수 있다.
이때 JDBC가 제공하는 SQL 패치 기능
을 사용할 수 있다.
이 기능을 사용하려면 코드의 많은 부분을 수정해야 한다.
특히 비즈니스 로직이 복잡하게 얽혀 있는 곳에서 사용하기 쉽지 않고 적용해도 코드가 상당히 지저분해진다.
따라서 보통은 수백 수천 건 이상의 데이터를 변경하는 특수한 상황에 SQL 배치 기능을 사용한다.
SQL 배치 최적화 전략은 구현체마다 조금씩 다르다.
하이버네이트에서는 다음과 같이 설정한다. 그럼 이제부터 데이터를 등록, 수정, 삭제 시 하이버네이트는 SQL 배치 기능을 사용한다.
만약 hibernate.jdbc.batch_size
속성의 값을 50으로 주면 최대 50건씩 모아서 SQL 배치를 실행한다.
SQL 배치는 같은 SQL일 때만 유효하다. 중간에 다른 처리가 들어가면 SQL 배치를 다시 시작한다.
em.persist(new Member()); // 1em.persist(new Member()); // 2em.persist(new Member()); // 3em.persist(new Member()); // 4em.persist(new Child()); //5, 다른 연산em.persist(new Member()); // 6em.persist(new Member()); // 7
1,2,3,4 모아서 하나의 SQL 배치 실행. 5를 한 번 실행. 6, 7을 모아서 실행 => 총 3번 SQL 배치 실행
엔티티가 영속 상태가 되려면 식별자가 꼭 필요한데, 만약
IDENTITY 전략
으로 식별자를 생성하면 데이터베이스에 엔티티를 저장해야 식별자를 구할 수 있다.따라서
em.persist()
호출 즉시 INSERT SQL이 데이터베이스에 전달되어 쓰기 지연을 활용한 성능 최적화를 할 수 없다.
트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성
트랜잭션을 지원하는 쓰기 지연
과 변경 감지
기능 덕분에 성능과 개발의 편의성 획득
하지만 진짜 장점은 데이터베이스 테이블 row에 lock 이 걸리는 시간을 최소화한다는 점이다.
즉, 이 기능은 트랜잭션을 커밋해서 영속성 컨텍스트를 플러시하기 전까지 데이터베이스 row에 lock을 걸지 않는다.
update(memberA); // UPDATE SQL A비즈니스 로직A(); // UPDATE SQL ...비즈니스 로직B(); // INSERT SQL ...commit();
트랜잭션 격리 수준에 따라 다르지만 보통 많이 사용하는 커밋된 읽기(Read Committed) 격리 수준
이나 그 이상
에서는 데이터베이스에 현재 수정 중인 데이터를 수정하려는 다른 트랜잭션은 락이 풀릴 때까지 대기한다.
commit()
호출할 때 UPDATE SQL
을 실행하고 바로 데이터베이스 트랜잭션을 커밋 => 데이터베이스에 락이 걸리는 시간 최소화
사용자가 증가하면 애플리케이션 서버를 증설하면 되지만, 데이터베이스 락은 애플리케이션 서버 증설만으로 해결이 불가능하다.
오히려 서버 증설로 트랜잭션이 증가할수록 더 많은 데이터베이스 락이 걸린다.
JPA 의 쓰기 지연 기능
은 데이터베이스에 락이 걸리는 시간을 최소화해서 동시에 더 많은 트랜잭션을 처리할 수 있는 장점이 있다.
JPA를 사용하지 않고 SQL을 직접 다룰 경우
update(memberA) 를 호출할 때 UPDATE SQL을 실행하면서 데이터베이스 테이블 로우에 lock을 건다.
이 lock은 비즈니스 로직A(), 비즈니스 로직B() 를 모두 수행하고 commit()을 호출할 때까지 유지.
15장 정리
1. JPA의 예외는 트랜잭션 롤백을 표시하는 예외
와 트랜잭션 롤백을 표시하지 않는 예외
로 나눈다.
- 트랜잭션을 롤백하는 예외는 심각한 예외이므로 트랜잭션을 강제로 커밋해도 커밋되지 않고 롤백된다.
2. 스프링 프레임워크는 JPA의 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.
3. 같은 영속성 컨텍스트의 엔티티 비교 시 동일성 비교는 가능. 영속성 컨텍스트가 다르면 동일성 비교 불가능
- 따라서 자주 변하지 않는 비즈니스 키를 사용한 동등성 비교를 해야 한다.
4. 프록시를 사용하는 클라이언트는 조회한 엔티티가 프록시인지 원본 엔티티인지 구분하지 않고 사용할 수 있어야 한다.
- 프록시는 기술적 한계가 있으므로 한계점을 인식하고 사용해야 한다.
5. JPA 사용 시 N+1 문제를 가장 조심.
- 주로 페치 조인을 사용해서 해결한다.
6. 엔티티 읽기 전용 조회 시 스냅샷을 유지할 필요가 없고 영속성 컨텍스트를 플러시하지 않아도 된다.
7. 대량의 엔티티를 배치 처리하려면 적절한 시점에 꼭 플러시 호출 및 영속성 컨텍스트 초기화
8. JPA는 SQL 쿼리 힌트를 지원 X, 하이버네이트 구현체는 SQL 쿼리 힌트 제공
9. 트랜잭션을 지원하는 쓰기 지연 덕분에 SQL 배치 기능을 사용할 수 있다.