Home 자바 ORM 표준 JPA 프로그래밍 - 고급 주제와 성능 최적화
Post
Cancel

자바 ORM 표준 JPA 프로그래밍 - 고급 주제와 성능 최적화

Table of Contents

  1. 예외 처리
    1. 트랜잭션 롤백을 표시하는 예외
    2. 트랜잭션 롤백을 표시하지 않는 예외
    3. 스프링 프레임워크의 JPA 예외 변환
    4. 스프링 프레임워크에 JPA 예외 변환기 적용
    5. 트랜잭션 롤백 시 주의사항
  2. 엔티티 비교
    1. 영속성 컨텍스트가 같을 때 엔티티 비교
    2. 영속성 컨텍스트가 다를 때 엔티티 비교
  3. 프록시 심화 주제
    1. 영속성 컨텍스트와 프록시
    2. 프록시 타입 비교
    3. 프록시 동등성 비교
    4. 상속관계와 프록시
      1. JPQL로 대상 직접 조회
      2. 프록시 벗기기
      3. 기능을 위한 별도의 인터페이스 제공
      4. 비지터 패턴 사용
  4. 성능 최적화
    1. N+1 문제
      1. 즉시 로딩과 N+1
      2. 지연 로딩과 N+1
      3. 페치 조인 사용
      4. 하이버네이트 @BatchSize
      5. 하이버네이트 @Fetch(FetchMode.SUBSELECT)
      6. N+1 정리
    2. 읽기 전용 쿼리의 성능 최적화
      1. 스칼라 타입으로 조회
      2. 읽기 전용 쿼리 힌트 사용
      3. 읽기 전용 트랜잭션 사용
      4. 트랜잭션 밖에서 읽기
      5. 정리
    3. 배치 처리
      1. JPA 등록 배치
      2. JPA 페이징 배치 처리
      3. 하이버네이트 scroll 사용
      4. 하이버네이트 무상태 세션 사용
    4. SQL 쿼리 힌트 사용
    5. 트랜잭션을 지원하는 쓰기 지연과 성능 최적화
    6. 트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성

예외 처리

JPA 표준 예외는 크게 2가지

트랜잭션 롤백을 표시하는 예외

심각한 예외이므로 복구해선 안 된다.
이 예외가 발생하면 트랜잭션을 강제로 커밋해도 트랜잭션이 커밋되지 않고 대신에 RollbackException 예외가 발생

트랜잭션 롤백을 표시하지 않는 예외

심각한 예외가 아니다.
개발자가 커밋할지 롤백할지 판단하면 된다.

스프링 프레임워크의 JPA 예외 변환

서비스 계층에서 데이터 접근 계층의 구현 기술에 직접 의존하는 것은 좋은 설계라 할 수 없다.
스프링 프레임워크는 이런 문제를 해결하려고 데이터 접근 계층에 대한 에외를 추상화해서 개발자에게 제공한다.

스프링 프레임워크에 JPA 예외 변환기 적용

JPA 예외를 스프링 프레임워크가 제공하는 추상화된 예외로 변경하려면 PersistenceExceptionTranslationPostProcessor를 스프링 빈으로 등록하면 된다.
@Repository 어노테이션을 사용한 곳에 예외 변환 AOP를 적용해서 JPA 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.

만약 예외를 변환하지 않고 그대로 반환하고 싶으면 throws 절에 그대로 반환할 JPA 예외나 JPA 예외의 부모 클래스를 직접 명시하면 된다.

트랜잭션 롤백 시 주의사항

트랜잭션을 롤백하는 것은 데이터베이스의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구해주지는 않는다.
따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험하다.

스프링 프레임워크는 이런 문제를 예방하기 위해 영속성 컨텍스트의 범위에 따라 다른 방법을 사용한다.

기본 전략인 트랜잭션당 영속성 컨텍스트 전략은
문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료
따라서 문제가 발생하지 않는다.

OSIV의 경우
롤백이 발생해서 영속성 컨텍스트에 이상이 발생해도 다른 트랜잭션에서 해당 영속성 컨텍스트를 그대로 사용하는 문제가 있다.
스프링 프레임워크는 영속성 컨텍스트의 범위를 트랜잭션의 범위보다 넓게 설정하면 트랜잭션 롤백시 영속성 컨텍스트를 초기화 해서 잘못된 영속성 컨텍스트를 사용하는 문제를 예방

엔티티 비교

영속성 컨텍스트가 같을 때 엔티티 비교

다음 3가지 조건을 모두 만족

  • 동일성 : == 비교가 같다.
  • 동등성 : equals() 비교가 같다.
  • 데이터베이스 동등성 : @Id인 데이터베이스 식별자가 같다.

영속성 컨텍스트가 다를 때 엔티티 비교

  • 동일성 : == 비교가 실패한다.
  • 동등성 : equals() 비교가 같다. 단 equals()를 구현해야 한다.
  • 데이터베이스 동등성 : @Id인 데이터베이스 식별자가 같다.

프록시 심화 주제

영속성 컨텍스트와 프록시

영속성 컨텍스트는 자신이 관리하는 영속 엔티티의 동일성을 보장한다.
그럼 프록시로 조회한 엔티티의 동일성도 보장할까?

영속성 컨텍스트는 프록시로 조회된 엔티티에 대해서 같은 엔티티를 찾는 요청이 오면 원본 엔티티가 아닌 처음 조회된 프록시를 반환한다.

원본 엔티티를 먼저 조회하면 영속성 컨텍스트는 원본 엔티티를 이미 데이터베이스에서 조회했으므로 프록시를 반환할 이유가 없다.

프록시 타입 비교

프록시는 원본 엔티티를 상속 받아서 만들어지므로 프록시로 조회한 엔티티의 타입을 비교할 때는 == 비교를 하면 안 되고 대신에 instanceof를 사용해야 한다.

프록시 동등성 비교

엔티티의 동등성을 비교하려면 비즈니스 키를 사용해서 equals() 메소드를 오버라이딩하고 비교하면 된다.
하지만 비교 대상이 원본 엔티티면 문제가 없지만 프록시면 문제가 발생할 수 있다.

프록시는 실제 데이터를 가지고 있지 않다. 따라서 프록시의 멤버변수에 직접 접근하면 아무값도 조회할 수 없다.
프록시의 데이터를 조회할 때는 접근자를 사용해야 한다.

결과적으로

  • 프록시 타입 비교는 == 비교 대신에 instanceof를 사용해야 한다.
  • 프록시의 멤버 변수에 직접 접근하면 안 되고 대신에 접근자 메소드를 사용해야 한다.

상속관계와 프록시

프록시를 부모 타입으로 조회하면 문제가 발생한다.
프록시를 부모 타입으로 조회하면 부모 타입을 기반으로 프록시가 생성되는 문제가 있다.

  • instanceof 연산을 사용할 수 없다.
  • 하위 타입으로 다운캐스팅을 할 수 없다.

JPQL로 대상 직접 조회

처음부터 자식 타입을 직접 조회해서 필요한 연산을 하면 된다.
물론 이 방법을 사용하면 다형성을 활용할 수 없다.

프록시 벗기기

하이버네이트가 제공하는 기능을 사용하면 프록시에서 원본 엔티티를 가져올 수 있다.

영속성 컨텍스트는 한 번 프록시로 노출한 엔티티는 계속 프록시로 노출한다.
그래야 영속성 컨텍스트가 영속 엔티티의 동일성을 보장할 수 있고, 클라이언트는 조회한 엔티티가 프록시인지 아닌지 구분하지 않고 사용할 수 있다.

그런데 이 방법은 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다.

이 방법을 사용할 때는 원본 엔티티가 꼭 필요한 곳에서 잠깐 사용하고 다른 곳에서 사용되지 않도록 하는 것이 중요하다.

기능을 위한 별도의 인터페이스 제공

인터페이스를 제공하고 각각의 클래스가 자신에게 맞는 기능을 구현하는 것은 다형성을 활용하는 좋은 방법이다.
다양한 상품 타입이 추가되어도 Item을 사용하는 OrderItem의 코드는 수정하지 않아도 된다.
프록시의 특징 때문에 프록시의 대상이 되는 타입에 인터페이스를 적용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    public interface TitleVie {
        String getTitle();
    }
    
    @Entity
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @DiscriminatorColumn(name = "DTYPE")
    public abstract class Item implements TitleView {
    
        @Id
        @GeneratedValue
        @Column(name ="ITEM_ID")
        private Long ig;
    
        private String name;
        private int price;
        private int stockQuantity;
    
        // Getter, Sstter
    }
    
    @Entity
    @DiscriminatorValue("B")
    public class Book extends Item {
    
        private String author;
        private String isbn;
    
        // Getter, Setter
    
        @Override
        public String getTitle() {
            return "this is book";
        }
    }
    
    @Entity
    @DiscriminatorValue("M")
    public class Movie extends Item {
    
        private String director;
        private String author;
    
        // Getter, Setter
    
        @Override
        public String getTitle() {
            return "this is Movie";
        }
    }

비지터 패턴 사용

장점

  • 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.
  • instanceof와 타입캐스팅 없이 코드를 구현할 수 있다.
  • 알고리즘과 객체 구조를 분리해서 구조를 수정하지 않고 새로운 동작을 추가할 수 있다.

단점

  • 너무 복잡하고 더블 디스패치를 사용하기 때문에 이해하기 힘들다.
  • 객체 구조가 변경되면 모든 Visitor를 수정해야 한다.

성능 최적화

N+1 문제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    @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() 메소드로 조회하면 즉시 로딩으로 설정한 주문 정보도 함께 조회한다.

문제는 JPQL을 사용할 때 발생한다.

JPQL을 실행하면 JPA는 이것을 분석해서 SQL을 생성한다.
이때는 즉시 로딩과 지연 로딩에 대해서 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성한다.
그런데 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어 있으므로 JPA는 주문 컬렉션을 즉시 로딩하려고 추가 SQL을 실행한다.

결과적으로 즉시 로딩은 JPQL을 사용할 때 N+1 문제가 발생할 수 있다.

지연 로딩과 N+1

지연 로딩을 사용하면 JPQL에서 N+1 문제가 발생하지 않는다.

문제는 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때 발생한다.
주문 컬렉션을 초기화하는 수만큼 추가 SQL이 실행될 수 있다.

즉, 이것도 결국 N+1 문제이다.

페치 조인 사용

N+1 문제를 해결하는 가장 일반적인 방법은 페치 조인을 사용하는 것이다.
페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.

하이버네이트 @BatchSize

연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다.
만약 조회한 회원이 10명인데 size=5로 지정하면 2번의 SQL만 추가로 실행한다.

즉시 로딩으로 설정하면 조회 시점에 10건의 데이터를 모두 조회해야 하므로 다음 SQL이 두 번 실행된다.
지연 로딩으로 설정하면 지연 로딩된 엔티티를 최초로 사용하는 시점에 다음 SQL을 실행해서 5건의 데이터를 미리 로딩해둔다.
그리고 6번째 데이터를 사용하면 다음 SQL을 추가로 실행한다.

1
2
3
4
    SELECT * FROM ORDERS
    WHERE MEMBER_ID IN (
       ?, ?, ?, ?, ?
    )

hibernate.defaultbatchfetchsize 속성을 사용하면 애플리케이션 전체에 기본으로 @BatchSize를 적용할 수 있다.

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

연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N+1 문제를 해결한다.

다음 JPQL로 회원 식별자 값이 10을 초과하는 회원을 모두 조회해보자

1
    select m from Member m where m.id > 10

즉시 로딩으로 설정하면 조회 시점에, 지연로딩으로 설정하면 지연 로딩된 엔티티를 사용하는 시점에 다음 SQL이 실행된다

1
2
3
4
5
6
7
8
    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 페치 조인을 사용하자

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

엔티티가 영속성 컨텍스트에 관리되면 1차 캐시부터 변경 감지까지 얻을 수 있는 해택이 많다.
하지만 영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용한다는 단점이 있다.

보관하는 인스턴스를 사용할 일이 없담면, 읽기 전용으로 엔티티를 조회하면 메모리 사용량을 최적화할 수 있다.

스칼라 타입으로 조회

가장 확실한 방법은 엔티티가 아닌 스칼라 타입으로 모든 필드를 조회하는 것이다.
스칼라 타입은 영속성 컨텍스트가 결과를 관리하지 않는다.

1
    select o.id, o.name, o.price from Order o

읽기 전용 쿼리 힌트 사용

하이버네이트 전용 힌트인 org.hibernate.readOnly를 사용하면 엔티티를 읽기 전용으로 조회할 수 있다.
읽기 전용이므로 영속성 컨텍스트는 스냅샷을 보관하지 않는다.

1
2
    TypedQuery<Order> query = em.createQuery("select o from Order o", Order.class);
    query.setHint("org.hibernate.readOnly", true);

읽기 전용 트랜잭션 사용

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

1
    @Transactional(readOnly = true)

읽기 전용 트랜잭션을 사용하면 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANUAL로 설정한다.
이렇게 하면 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않는다.

트랜잭션 밖에서 읽기

트랜잭션 없이 엔티티를 조회한다는 뜻이다. 조회가 목적일 때만 사용해야 한다.

1
    @Transactional(propagation = Propagation.NOT_SUPPORTED) 

이렇게 트랜잭션을 사용하지 않으면 플러시가 일어나지 않으므로 조회 성능이 향상 된다.

정리

메모리를 최적화하려면 스칼라 타입으로 조회하거나 하이버네이트가 제공하는 읽기 전용 쿼리 힌트를 사용
플러시 호출을 막아서 속도를 최적화하려면 읽기 전요 ㅇ트랜잭션을 사용하거나 트랜잭션 밖에서 읽기를 사용하면 된다.

즉, 메모리를 최적화하면서 플러스 호출을 막는것이 가장 효과적이다.

배치 처리

배치와 같이 수백만 건의 데이터를 배치 처리해야 하는 상황이라면 엔티티를 계속 조회하면서 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면서 메모리 부족으로 오류가 발생할 것이다.
따라서 이런 배치 처리는 적절한 단위로 영속성 컨텍스트를 초기화해야 한다.
2차 캐시를 사용하고 있다면 2차 캐시로 엔티티를 보관하지 않도록 주의해야 한다.

JPA 등록 배치

영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록 일정 단위마다 영속성 컨텍스트의 엔티티를 데이터베이스에 플러시하고 영속성 컨텍스트를 초기화해야 한다.

JPA 페이징 배치 처리

페이징 쿼리로 조회하면서 처리
코드는 책 참고

하이버네이트 scroll 사용

하이버네이트는 scroll이라는 이름으로 JDBC 커서를 지원한다.

scroll은 하이버네이트 전용 기능이므로 먼저 em.unwrap() 메소드를 사용해서 하이버네이트 세션을 구한다.
다음으로 쿼리를 조회하면서 scroll() 메소드로 ScrollableResults 객체를 반환받는다.
이 객체의 next() 메소드를 호출하면 엔티티를 하나씩 조회할 수 있다.

하이버네이트 무상태 세션 사용

하이버네이트는 무상태 세션이라는 특별한 기능을 제공
무상태 세션은 영속성 컨텍스트를 만들지 않고 심지어 2차 캐시도 사용하지 않는다.
무상태 세션은 영속성 컨텍스트가 없다.
그리고 엔티티를 수정하려면 무상태 세션이 제공하는 update() 메소드를 직접 호출해야 한다.

SQL 쿼리 힌트 사용

JPA는 데이터베이스 SQL 힌트 기능을 제공하지 않는다.
SQL 힌트를 사용하려면 하이버네이트를 직접 사용해야 한다.

SQL 힌트는 하이버네이트 쿼리가 제공하는 addQueryHint() 메소드를 사용한다.

1
2
3
4
5
    Session session = em.unwrap(Session.class);  // 하이버네이트 직접 사용
    
    List<Member> list = session.createQuery("select m from Member m")
       .addQueryHint("FULL (MEMBER)")   // SQL HINT 사용
       .list();

실행된 SQL은 다음과 같다.

1
2
3
4
    select
         /*+ FULL (MEMBER) */ m.id, m.name
    from
         Member m

다른 데이터베이스에서 SQL 힌트를 사용하려면 각 방언에서 org.hibernate.dialect.Dialect에 있는 다음 메소드를 오버라이딩해서 기능을 구현해야 한다.

트랜잭션을 지원하는 쓰기 지연과 성능 최적화

참고로 SQL 배치 최적화 전략은 구현체마다 조금씩 다르다.
하이버네이트에서 SQL 배치를 적용하려면 다음과 같이 설정하면 된다.

1
    <property name="hibernate.jdbc.batch_size" value="50"/>

속성값을 50으로 주면 최대 50건씩 모아서 SQL 배치를 실행한다 하지만 같은 SQL일 때만 유효하다.
중간에 다른 SQL이 들어가면 1번에 실행할 것을 3번 실행하게 된다.

트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성

트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능 덕분에 성능과 개발의 편의성이라는 두 마리 토끼를 모두 잡을 수 있었다.
하지만 진짜 장점은 데이터베이스 테이블 로우에 락이 걸리는 시간을 최소화한다는 점 이다.

JPA는 커밋을 해야 플러시를 호출하고 데이터베이스에 수정 쿼리를 보낸다.
쿼리를 보내고 바로 트랜잭션을 커밋하므로 결과적으로 데이터베이스에 락이 걸리는 시간을 최소화한다.

This post is licensed under CC BY 4.0 by the author.

자바 ORM 표준 JPA 프로그래밍 - 컬렉션과 부가 기능

자바 ORM 표준 JPA 프로그래밍 - 트랜잭션과 락, 2차 캐시