Skip to content

Latest commit

 

History

History
424 lines (273 loc) · 17.9 KB

15장.md

File metadata and controls

424 lines (273 loc) · 17.9 KB

15장: 고급 주제와 성능 최적화

이번 장에서는 JPA와 깊이 있는 고급 주제들과 JPA의 성능을 최적화하는 다양한 방안을 알아보겠습니다.

  • 예외 처리: JPA를 사용할 때 발생하는 다양한 예외와 예외에 따른 주의점을 설명합니다.
  • 엔티티 비교: 엔티티를 비교할 때 주의점과 해결 방법을 설명합니다.
  • 프록시 심화 주제: 프록시로 인해 발생하는 다양한 문제점과 해결 방법을 다룹니다.
  • 성능 최적화
    • N + 1 문제
    • 읽기 전용 쿼리의 성능 최적화
    • 트랜잭션을 지원하는 쓰기 지연과 성능 최적화



1. JPA 표준 예외 정리

JPA 표준 예외들은 javax.persistence.PersistenceException의 자식 클래스입니다. 그리고 이 예외 클래스는 RuntimeException의 자식입니다. 따라서 JPA 예외는 모두 언체크 예외입니다.

JPA 표준 예외는 크게 2가지로 나눌 수 있습니다.

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

트랜잭션 롤백을 표시하는 예외는 심각한 예외이므로 복구해선 안됩니다. 이 예외가 발생하면 트랜잭션을 강제로 커밋해도 트랜잭션이 커밋되지 않고 대신에 javax.persistence.RollbackException 예외가 발생합니다. 반면에 트랜잭션 롤백을 표시하지 않는 예외는 심각한 예외가 아닙니다. 따라서 개발자가 트랜잭션을 커밋할지 롤백할지를 판단하면 됩니다.


트랜잭션 롤백 시 주의사항

트랜잭션을 롤백하는 것은 데이터베이스의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구해주지는 않습니다. 예를 들어 엔티티를 조회해서 수정하는 중에 문제가 있어서 트랜잭션을 롤백하면 데이터베이스의 데이터는 원래대로 복구되지만 수정된 상태로 영속성 컨텍스트에 남아 있습니다. 따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험합니다.

따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험합니다. 새로운 영속성 컨텍스트를 생성해서 사용하거나 EntityManager.clear()를 호출해서 영속성 컨텍스트를 초기화한 다음에 사용해야 합니다.

스프링 프레임워크에서 사용하는 기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료하므로 문제가 발생하지 않습니다.



2. 엔티티 비교

영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하기 위한 1차 캐시가 있습니다. 1차 캐시는 영속성 컨텍스트와 생명주기를 같이 합니다.

영속성 컨텍스트를 통해 데이터를 저장하거나 조회하면 1차 캐시에 엔티티가 저장됩니다. 1차 캐시 덕분에 변경 감지 기능도 동작하고, 이름 그대로 1차 캐시로 사용되어서 데이터베이스를 통하지 않고 데이터를 바로 조회할 수도 있습니다. 영속성 컨텍스트를 더 정확히 이해하기 위해서는 1차 캐시의 가장 큰 장점은 애플리케이션 수준의 반복 가능한 읽기를 이해해야 합니다.

같은 양속성 컨텍스트에서 엔티티를 조회하면 다음 코드와 같이 항상 같은 엔티티 인스턴스를 반환합니다. 이것은 단순히 동등성(equals) 비교 수준이 아니라 정말 주소 값이 같은 인스턴스를 반환합니다.

Member member1 = em.find(Member.class, "1L");
Member member2 = em.find(Member.class, "1L");

스크린샷 2021-12-28 오전 12 02 45



3. 프록시 심화 주제

프록시는 원본 엔티티를 상속 받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 아니면 원본 엔티티인지 구분하지 않고 사용할 수 있습니다.



영속성 컨텍스트와 프록시

Member refMember = em.getReference(Member.class, "member1");
Member findMember = em.find(Member.class, "member1");

위의 두 출력의 결과는 같습니다. refMember는 프록시이고, findMember는 원본 엔티티이므로 둘은 서로 다른 인스턴스로 생각할 수 있지만 이렇게 되면 영속성 컨텍스트가 영속 엔티티의 동일성을 보장하지 못하는 문제가 발생합니다.

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


Member findMember = em.find(Member.class, "member1");
Member refMember = em.getReference(Member.class, "member1");

이번에는 반대로 원본 엔티티를 먼저 조회하고 그 다음에 프록시를 조회하겠습니다. 그러면 둘 다 원본 엔티티가 출력되는데요. 이유는 처음에 원본 엔티티를 조회했기에 원본 엔티티영속성 컨텍스트에 올라가 있기 때문에 프록시를 조회해도 프록시를 반환할 필요가 없기 떄문입니다.



프록시 타입 비교

스크린샷 2021-08-27 오후 5 46 34

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



4. 성능 최적화

JPA로 애플리케이션을 개발할 때 발생하는 다양한 성능 문제와 해결 방안을 알아보겠습니다.



N + 1 문제

JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 것이 N + 1 문제입니다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<>();
}

@Entity
public class Order {

    @Id @GeneratedValue
    private Long id;

    private int orderAmount;

    @ManyToOne
    private Member member;
} 

위와 같이 Member : Order = 1 : N 관계입니다. 그리고 Member에서 Order를 참조할 때 OneToMany EAGER로 설정하였습니다.



즉시 로딩과 N + 1

public class JpaMain {

    public static void main(String[]  args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Member member = new Member(26, "규니");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.find(Member.class, 1L);
            System.out.println(findMember.getId());
            System.out.println(findMember.getUsername());

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

        emf.close();
    }
}

위와 같이 Member를 저장하고 Member를 조회해오면 어떻게 될까요? Member와 Order의 연관관계EAGER로 되어 있기 때문에 JOIN을 통해서 한번에 데이터가 조회될 것임을 예측할 수 있습니다.

스크린샷 2021-12-28 오전 12 32 43


public class JpaMain {

    public static void main(String[]  args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Member member1 = new Member(26, "규니1");
            Member member2 = new Member(27, "규니2");
            Member member3 = new Member(28, "규니3");
            em.persist(member1);
            em.persist(member2);
            em.persist(member3);

            em.flush();
            em.clear();

            List<Member> findMembers = em.createQuery("SELECT m FROM Member m", Member.class)
                    .getResultList();

            for (Member m : findMembers) {
                System.out.println(m.getUsername());
            }

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

        emf.close();
    }
}

이번에는 위와 같이 Member 3명persist 한 후에 JPQL을 통해서 Member를 조회하였습니다. 이번에는 쿼리가 어떻게 실행될까요? JPQL을 실행하면 JPA는 이것을 분석해서 SQL을 생성합니다. 이 때는 즉시 로딩지연 로딩에 대해서 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성합니다.

SELECT * FROM Member

따라서 위와 같은 SQL 실행 결과로 먼저 회원 엔티티를 애플리케이션에 로딩합니다. 그런데 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어 있으므로 JPA는 주문 컬렉션을 즉시 로딩하려고 다음 SQL을 추가로 실행합니다.


스크린샷 2021-12-28 오전 12 52 19


스크린샷 2021-12-28 오전 12 52 29

그러면 위와 같이 Member 전체 조회해오는 쿼리 1번, 조회 해온 Member의 수 만큼 Order 조회가 되어 N + 1 쿼리가 발생합니다. ex) Member가 3명이 조회되었다면 Order는 3번 조회됩니다.



지연 로딩과 N + 1

회원과 주문을 지연 로딩(Lazy)로 설정해도 N + 1 문제에서 자유로울 수는 없습니다.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private int age;

    private String username;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
}

위와 같이 연관관계를 LAZY로 잡고 테스트 해보겠습니다.


public class JpaMain {

    public static void main(String[]  args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Member member1 = new Member(26, "규니1");
            Member member2 = new Member(27, "규니2");
            Member member3 = new Member(28, "규니3");
            em.persist(member1);
            em.persist(member2);
            em.persist(member3);

            em.flush();
            em.clear();

            List<Member> findMembers = em.createQuery("SELECT m FROM Member m", Member.class)
                    .getResultList();

            for (Member m : findMembers) {
                System.out.println(m.getOrders().size()); // Order 객체 초기화
            }

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

        emf.close();

    }
}

이번에도 JPQL을 통해서 Member를 조회하고 for문을 돌면서 Order 객체를 초기화하고 있습니다. 지연 로딩으로 설정하였기 때문에 당연히 JPQL을 통해서 Member를 조회할 때는 Member만 진짜 객체로 가져오고 Order는 Proxy로 가져오게 됩니다.

그리고 for문을 돌면서 Order 객체가 초기화 될 때 실제 메모리상에 올라오게 됩니다. 즉, 초기화 되는 시점에 Order 쿼리가 실행되기 때문에 여기서도 N + 1 문제가 발생합니다.

스크린샷 2021-12-28 오전 1 00 21


스크린샷 2021-12-28 오전 1 00 37

이번에도 위와 같이 N + 1 쿼리가 실행되는 것을 볼 수 있습니다.



페치 조인 사용

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


public class JpaMain {

    public static void main(String[]  args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Member member1 = new Member(26, "규니1");
            Member member2 = new Member(27, "규니2");
            Member member3 = new Member(28, "규니3");
            em.persist(member1);
            em.persist(member2);
            em.persist(member3);

            em.flush();
            em.clear();

            List<Member> findMembers = em.createQuery("SELECT m FROM Member m join fetch m.orders", Member.class) // FETCH JOIN
                    .getResultList();

            for (Member m : findMembers) {
                System.out.println(m.getOrders().size()); 
            }

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

        emf.close();

    }
}

이번에는 위와 같이 Member, Orderfetch join 해서 가져와보겠습니다.


스크린샷 2021-12-28 오전 1 04 15

그러면 위와 같이 inner join을 통해서 Member, Order 엔티티를 한번에 가져오는 것을 볼 수 있습니다.



하이버네이트 @BatchSize

스크린샷 2021-12-28 오전 1 07 54


스크린샷 2021-12-28 오전 1 08 25


스크린샷 2021-12-28 오전 1 11 55

그러면 위와 같이 IN 쿼리를 사용하여 여러 번 네트워크를 타지 않고 한번에 가져와서 성능을 최적화할 수 있습니다.



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

Spring을 사용하다 보면 @Transactional을 자주 사용하게 되는데요. @Transactional 속성 중에 readlOnly=true 라는 것이 있습니다. 이번 글에서는 JPA를 사용하면서 readOnly=true를 사용하게 되면 어떤 일이 일어나는지에 대해서 알아보겠습니다.


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

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

그래서 단순 읽기 전용일 때 readOnly=true를 사용하면 내부 스냅샷 인스턴스를 보관하지 않기 때문에 메모리 사용량을 최적화할 수 있습니다. 단, 스냅샷이 없으므로 엔티티를 수정해도 쓰기 지연 기능을 사용할 수 없습니다.


읽기 전용 트랜잭션 사용

@Transactional(readOnly=true)를 사용하게 되면 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANUAL로 설정합니다. 이렇게 하면 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않습니다. 따라서 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시하지 않습니다.

영속성 컨텍스트를 플러시하지 않으니 엔티티의 등록, 수정, 삭제는 당연히 동작하지 않습니다. 하지만 플러시할 때 일어나는 스냅샷 비교와 같은 무거운 로직들을 수행하지 않으므로 성능이 향상됩니다. 물론 트랜잭션을 시작했으므로 트랜잭션 시작, 로직수행, 트랜잭션 커밋의 과정은 이루어집니다. 단지 영속성 컨텍스트를 플러시하지 않을 뿐입니다.


플러시(flush)란?

  1. em.flush() 직접 호출
  2. 트랜잭션 커밋 시 플러시가 자동 호출됩니다.
  3. JPQL 쿼리 실행 시 플러시가 자동 호출됩니다.

플러시를 호출하는 방법은 위와 같이 3가지 입니다. 플러시(flush())는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영합니다. 플러시를 실행하면 구체적으로 다음과 같은 일이 일어납니다.

변경 감지가 동작해서 영속성 컨텍스트에 있는 모든 엔티티를 스냅샷과 비교해서 수정된 엔티티를 찾는다. 수정된 엔티티는 수정 쿼리를 만들어 쓰기 지연 SQL 저장소에 등록한다. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송합니다.(등록, 수정, 삭제 쿼리)


참고하기

플러시 설정에 MANUAL 모드를 사용하면 강제로 플러시를 호출하지 않으면 절대 플러시가 발생하지 않습니다.