Reference. 자바 ORM 표준 JPA 프로그래밍
책 목차 및 이전 글
더보기
들어가기 전 JPA 특징, Q&A
1. JPA 소개
3. 영속성 관리
4. 엔티티 매핑
4.1 - 4.3 @Entity, @Table, 다양한 매핑
4.4 - 4.5 데이터베이스 스키마 자동 생성, DDL 생성 기능
5. 연관관계 매핑 기초
6. 다양한 연관관계 매핑
7. 고급매핑
8. 프록시와 연관관계 관리
9. 값 타입
9.3~5 값 타입과 불변 객체, 값 비교, 값 타입 컬렉션
10. 객체지향 쿼리 언어
11. 웹 애플리케이션 제작
11.1 프로젝트 환경설정
11.2 도메인 모델과 테이블 설계
11.3 애플리케이션 구현
12. 스프링 데이터 JPA
12.1~3 스프링 데이터 JPA 소개, 공통 인터페이스 기능
12.5~10 명세, 사용자 정의 리포지토리, Web 확장...
13. 웹 애플리케이션과 영속성 관리
14. 컬렉션과 부가기능
15. 고급 주제와 성능 최적화
15.4.1 N+1 문제
- JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야할 점은 N+1문제 (13.2.1절 일부 다룸)
@Entity public class Member { @Id @GeneratedValue private Long id; @OneToMany(mappedBy = "member", fetch = FetchType.EAGER) private List<Order> orders = new ArrayList<Order>(); ... }
@Entity @Table(name = "ORDERS") public class Order { @Id @GeneratedValue private Long id; @ManyToOne private Member member; ... }
즉시 로딩과 N+1
- 특정 회원 하나를 em.find()로 조회하면 즉시 로딩으로 설정한 주문정보도 함꼐 조회
em.find(Member.class, id); //실행 SQL SELECT M.*, O.* FROM MEMBER M OUTER JOIN ORDERS O ON M.ID = O.MEMBER_ID
- SQL을 조인을 사용해서 한 번의 SQL로 회원과 주문정보를 함께 조회
- 한번에 가져오는 것만 보면 좋아보이지만, JPQL을 사용할 때 문제 발생
List<Member> members = em.createQuery("select m from Member m", Member.class) .getResultList(); //실행 SQL SELECT * FROM MEMBER
- JPA는 즉시 로딩과 지연로딩에 대해 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성
- SQL의 실행 결과로 회원 엔티티를 애플리케이션에 로딩 후 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어 있으므로 JPA는 주문 컬렉션을 즉시 로딩하기 위해 SQL을 추가 실행
SELECT * FROM MEMBER //1번 실행으로 회원 여러명 조회 SELECT * FROM ORDERS WHERE MEMBER_ID = 1; //회원과 연관된 주문 SELECT * FROM ORDERS WHERE MEMBER_ID = 2; //회원과 연관된 주문 ...
지연 로딩과 N+1
- 이 전에 예시를 지연 로딩으로 변경한 예시
@Entity public class Member { @Id @GeneratedValue private Long id; @OneToMany(mappedBy = "member", fetch = FetchType.LAZY) private List<Order> orders = new ArrayList<Order>(); ... }
- 지연 로딩으로 설정하면 JPQL에서는 N+1 문제가 발생 안함
List<Member> members = em.createQuery("select m from Member m", Member.class) .getResultList(); //지연 로딩이므로 회원만 조회 SELECT * FROM MEMBER
- 비즈니스 로직에서 주문 컬렉션을 실제 사용할 때 지연 로딩 발생
firstMember = members.get(0); firstMember.getOrders().size(); //지연 로딩 초기화 //실행 SQL SELECT * FROM ORDERS WHERE MEMBER_ID = ?
- 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때 즉시 로딩(EAGER)과 동일한 현상 발생
for (Member member : members) { //지연 로딩 초기화 System.out.println("member = ", member.getOrders().size()); } SELECT * FROM ORDERS WHERE MEMBER_ID = 1; //회원과 연관된 주문 SELECT * FROM ORDERS WHERE MEMBER_ID = 2; //회원과 연관된 주문 ...
N+1 문제를 피할 수 있는 다양한 방법들
페치 조인 사용
- N+1 문제를 해결하는 가장 일반적인 방법은 페치 조인을 사용하는 것
//페치 조인 JPQL select m from Member m join fetch m.orders //실행된 SQL SELECT M.*, O.* FROM MEMBER M INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID
- 일대다 조인을 했으므로 중복 결과가 발생, JPQL DISTINCT를 사용해 중복 제거하는 것이 좋음
하이버네이트 @BatchSize
- 하이버네이트가 제공하는
@BatchSize
을 사용하면 size만큼 SQL의 IN절을 사용해서 조회
- 만약 조회한 회원이 10명인데 size=5로 지정하면 2번의 SQL만 추가로 실행
@Entity public class Member { ... @org.hibernate.annotaions.BatchSize(size = 5) @OneToMany(mappedBy = "member", fetch = FetchType.EAGER) private List<Order> orders = new ArrayList<Order>(); ... }
- 즉시 로딩으로 설정하면 조회 시점에 10건의 데이터를 모두 조회하므로 SQL 2번 실행
- 지연 로딩으로 설정하면 최초 사용하는 시점에 SQL을 실행해 5건 데이터 미리 로딩 그리고 6번째 데이터 사용하면 다음 SQL을 추가로 실행
SELECT * FROM ORDERS WHERE MEMBER_ID IN ( ?, ?, ?, ?, ? )
hibernate.default_batch_fetch_size
속성을 사용하면 전체에 기본으로 @BatchSize 적용 가능<property name="hibernate.default_batch_fetch_size" value="5" />
하이버네이트 @Fetch(FetchMode.SUBSELECT)
- 하이버네이트가 제공하는 @Fetch 적용 예시
@Entity public class Member { ... @org.hibernate.annotations.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 아래 실행 SELECT O FROM ORDERS O WHERE O.MEMBER_ID IN ( SELECT M.ID FROM MEMBER M WHERE M.ID > 10 )
N+1 정리
- 즉시 로딩과 지연로딩 중 추천하는 방법은 지연 로딩만 사용하는 것
- 즉시 로딩 전략은 N+1과 비즈니스 로직에 필요하지 않은 엔티티를 로딩해야 하는 문제점 발생 그리고 가장 큰 문제는 성능 최적화가 어려움
- 모두 지연로딩으로 설정하고 성능 최적화가 필요한 곳에는 JPQL 페치 조인을 사용
- JPA의 글로벌 페치 전략 기본값
@OneToOne
,@ManyToOne
: 기본 페치 전략은 즉시 로딩
@OneToMany
,@ManyToMany
: 기본 페치 전략은 지연 로딩
- JPA의 글로벌 페치 전략 기본값
@OneToOne
과@ManyToOne
은 LAZY로 설정해서 지연 로딩 전략을 사용하도록 변경
15.4.2 읽기 전용 쿼리의 성능 최적화
- 엔티티가 영속성 컨텍스트에 관리되면 캐시부터 변경 감지까지 얻을 수 있는 혜택이 많음
- 하지만 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 많은 메모리 사용하는 단점
- 엔티티를 다시 조회할 일이 없을 땐 읽기 전용으로 조회하여 메모리 사용량을 최적화 필요
- 최적회할 쿼리 예시
select o from Order o
최적화 하는 방법
- 스칼라 타입으로 조회
- 가장 확실한 방법은 엔티티가 아닌 스칼라 타입으로 모든 필드를 조회하는 방법
- 스칼라 타입은 영속성 컨텍스트가 관리하지 않음
select o.id, o.name, o.price from Order p
- 읽기 전용 쿼리 힌트 사용
- 하이버네이트 전용 힌트인
org.hibernate.readOnly
를 사용하여 읽기 전용 조회 가능
- 읽기 전용이므로 스냅샷 보관을 하지 않고, 메모리 사용량 최적화가 가능
- 단, 스냅샷이 없으므로 엔티티를 수정해도 데이터베이스 반영은 안됌
TypedQuery<Order> query = em.createQuery("select o from Order o", Order.class); query.setHint("org.hibernate.readOnly", true);
- 하이버네이트 전용 힌트인
- 읽기 전용 트랜잭션 사용
- 스프링 프레임워크를 사용하면 트랜잭션 읽기 전용 모드 설정이 가능
@Transactional(readOnly = true)
- 트랜잭션에 readOnly=true 옵션을 주면 하이버네이트 세션 플러시 모두를 MANUAL로 설정하여 강제로 플러시를 호출하지 않는 한 플러시가 발생하지 않음
- 트랜잭션을 커밋해도 영속성 컨텍스트는 플러시하지 않음, 그러므로 엔티티의 등록, 수정, 삭제는 동작하지 않음
- 플러시할때 일어나는 무거운 로직이 수행되지 않아 성능이 향상
- 영속성 컨텍스트를 플러시 하지 않을 뿐 트랜잭션에 과정은 동일하게 진행
- 스프링 프레임워크를 사용하면 트랜잭션 읽기 전용 모드 설정이 가능
- 트랜잭션 밖에서 읽기
- 트랜잭션 밖에서 읽는다는 의미는 트랜잭션 없이 엔티티를 조회한다는 의미와 동일
- JPA에선 데이터 변경을 위해 트랜잭션은 필수, 따라서 조회 목적일 때만 사용 가능
- 스프링 프레임워크를 사용하면 아래와 같이 설정
@Transactional(propagation = Propagation.NOT_SUPROTED) //Spring
- J2EE 표준 컨테이너를 사용하면 아래와 같이 설정
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED) //J2EE
- 트랜잭션을 커밋하거나 쿼리를 실행하면 플러시가 작동 (플러시 모드 default: AUTO)
- 트랜잭션 자체가 존재하지 않으므로 트랜잭션을 커밋할 일이 없음 (플러시 X)
- JPQL 쿼리도 트랜잭션 없이 실행하면 커밋할 일이 없음 (플러시 X)
정리
- 메모리를 최적화하는 방법 (영속성 컨텍스트 관리만 X)
- 스칼라 타입으로 조회
- 하이버네이트가 제공하는 읽기 전용 쿼리 힌트을 사용
- 플러시 호출을 막아 속도를 최적화 하는 방법 (무거운 로직X)
- 읽기 전용 트랜잭션을 사용
- 트랜잭션 밖에서 읽기를 사용
- 스프링 프레임워크를 사용하면 읽기 전용 트랜잭션을 사용하는 것이 편리
- 읽기 전용 트랜잭션(또는 트랜잭션 밖에서 읽기)과 읽기 전용 쿼리 힌트(또는 스칼라 타입으로 조회)를 동시에 사용하는 것이 가장 효과적
@Transactional(readOnly = true) //읽기 전용 트랜잭션 -- 1 public List<DataEntity> findDatas() { return em.createQuery("select d from DataEntity d", DataEntity.class) .setHint("org.hibernate.readOnly", true) //읽기 전용 쿼리 힌트 --2 .getResultList(); }
- 읽기 전용 트랜잭션 사용: 플러시를 작동하지 않도록 해서 성능 향상
- 읽기 전용 엔티티 사용: 엔티티를 읽기 전용으로 조회해서 메모리 절약
15.4.3 배치 처리
- 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면 메모리 부족 오류가 발생
- 배치 처리는 적절한 단위로 영속성 컨텍스트 초기화가 필요
- 2차 캐시를 사용하고 있다면 2차 캐시에 엔티티를 보관하지 않도록 주의(2차 캐시는 16.2절)
JPA 등록 배치
- 수천에서 수만 건 이상의 엔티티를 등록할때는 영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록 일정 단위마다 영속성 컨텍스트의 엔티티를 DB에 플러시하고 초기화하는 작업이 필요
- JPA 등록 배치 예제
EntityManager em = entityManagerFactory.createEntityManager(); 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();
- 수정 배치 처리는 많은 데이터를 조회해서 수정
- 수많은 데이터를 한번에 메모리에 올려둘 수 없어서 2가지 방법을 주로 사용
- 페이징 처리: 데이터베이스 페이징 기능을 사용
- 커서(CURSOR): 데이터베이스가 지원하는 커서 기능을 사용
JPA 페이징 배치 처리
- JPA 페이징 배치 처리 예제
EntityManger 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();
- 한 번에 100건씩 페이징 쿼리로 조회 후 100원씩 증가하는 로직
- 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화 처리
하이버네이트 scroll 사용
- 하이버네이트는 scroll이라는 이름으로 JDBC 커서를 지원
- 하이버네이트 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();
- scroll은 하이버네이트 전용 기능이므로 먼저
em.unwrap()
을 사용해 세션을 구함
scroll()
로ScrollableResults
객체를 반환
next()
를 호출하면 엔티티를 하나씩 조회 가능
- scroll은 하이버네이트 전용 기능이므로 먼저
하이버네이트 무상태 세션 사용
- 하이버네이트는 무상태 세션이라는 특별한 기능을 제공
- 무상태 세션은 영속성 컨텍스트를 만들지 않고 심지어 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();
- 무상태 세션은 하이버네이트 세션과 거의 비슷하지만 영속성 컨텍스트가 없음
- 영속성 컨텍스트를 플러시하거나 초기화하지 않아도 되며, update() 직접 호출이 필요
15.4.4 SQL 쿼리 힌트 사용
- JPA는 데이터베이스 SQL 힌트 기능을 제공하지 않음
- SQL 힌트는 하이버네이트 쿼리가 제공하는
addQueryHint()
를 사용
- 오라클 데이터베이스 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
- 하이버네이트 4.3.10 버전에는 오라클 방언에만 힌트가 적용, 다른 데이터베이스에서 SQL 힌트를 사용하려면 각 방언에
org.hibernate.dialect.Dialect
에 있는 메소드 오버라이딩이 필요public String getQueryHintString(String query, List<String> hints) { return query }
15.4.5 트랜잭션을 지원하는 쓰기 지연과 성능 최적화
트랜잭션을 지원하는 쓰기 지연과 JDBC 배치
- SQL을 직접 다루는 경우 예시
insert (member1); //INSERT INTO ... insert (member2); //INSERT INTO ... insert (member3); //INSERT INTO ... commit();
- 네트워크 호출 한 번은 단순한 메소드를 수만 번 호출하는 것보다 큰 비용이 소모
- 최적화하려면 INSERT SQL을 모아서 한 번에 데이터베이스로 보내는 것이 필요
- JDBC가 제공하는 SQL 배치 기능을 사용하면 SQL을 모아서 DB에 보내는 것이 가능
- SQL 배치 기능을 사용하려면 코드 많은 부분을 수정하고 적용이 어려움
- 보통은 수백 수천 건 이상의 데이터를 변경하는 특수한 상황에 SQL 배치 기능을 사용
- JPA는 플러시 기능이 있으므로 SQL 배치 기능을 효과적 사용이 가능
- SQL 배치 최적화 전략은 구현체 마다 다름, 아래 예는 하이버네이트 SQL 배치 설정 방법
//설정 후 등록, 수정, 삭제할 때 SQL 배치 기능을 사용 <property name="hibernate.jdbc.batch_size" value="50"/>
- 속성 값을 50으로 주면 최대 50건씩 모아서 SQL 배치를 실행
- SQL 배치는 같은 SQL일 때만 유효, 중간에 다른 처리가 들어가면 SQL 배치를 다시 시작
em.persist(new Member()); //1 em.persist(new Member()); //2 em.persist(new Member()); //3 em.persist(new Member()); //4 em.persist(new Child()); //5, 다른연산 em.persist(new Member()); //6 em.persist(new Member()); //7
- 1,2,3,4를 모아서 하나의 SQL 배치를 실행하고 5를 한번 실행 후 6,7을 모아서 실행 3번의 SQL 배치를 실행
- SQL 배치 최적화 전략은 구현체 마다 다름, 아래 예는 하이버네이트 SQL 배치 설정 방법
<주의>
- 엔티티 영속 상태가 되려면 식별자가 필요
- IDENTITY 식별자 생성 전략은 엔티티를 데이터베이스에 저장해야 식별자를 구할 수 있으므로
em.persist()
호출하는 즉시 INSERT SQL이 데이터베이스에 전달
- 따라서 쓰기 지연을 활용한 성능 최적화가 불가능
트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성
- 트랜잭션을 지원하는 쓰기지연과 변경 감지 기능은 개발의 편의성을 제공
- 하지만 진짜 강점은 데이터베이스 테이블 로우에 락이 걸리는 시간을 최소화하는 점
update(memberA); //UPDATE SQL A 비즈니스로직A(); //UPDATE SQL ... 비즈니스로직B(); //INSERT SQL ... commit();
- JPA를 사용하지 않고 SQL을 직접 다루면
update(memberA)
를 호출할 때 UPDATE SQL을 실행하며 DB 테이블 로우에 락을 걸음
- 락은
비즈니스 로직A()
,비즈니스로직B()
를 모두 수행하고commit()
호출 때까지 유지
- 트랜잭션 격리 수준에 따라 다르지만 보통 커밋된 읽기(
Read Committed
) 격리 수준이나 그 이상에서는 DB에 현재 수정 중인 데이터를 수정하려는 다른 트랜잭션은 락이 풀릴떄까지 대기
- JPA는
commit()
을 호출할 때 UPDATE SQL을 실행하고 바로 DB 트랜잭션을 커밋하며, 쿼리를 보낸 후 트랜잭션을 커밋하므로 결과적으로 DB락이 걸리는 시간을 최소화
- 사용자 증가시 애플리케이션 서버를 더 증설하는건 가능하지만 DB락은 해결할 수 없음
- 애플리케이션 서버를 더 증설하면 트랜잭션이 증가하므로 더 많은 DB락이 발생
'개발서적 > 자바 ORM 표준 JPA' 카테고리의 다른 글
[자바 ORM 표준 JPA 프로그래밍] 16.2 2차 캐시 (1) | 2021.10.25 |
---|---|
[자바 ORM 표준 JPA 프로그래밍] 16.1 트랜잭션과 락 (0) | 2021.10.25 |
[자바 ORM 표준 JPA 프로그래밍] 15.3 프록시 심화 주제 (0) | 2021.10.25 |
[자바 ORM 표준 JPA 프로그래밍] 15.2 엔티티 비교 (0) | 2021.10.25 |
[자바 ORM 표준 JPA 프로그래밍] 15.1 예외 처리 (0) | 2021.10.25 |