이번 장에서는 JPA와 깊이 있는 고급 주제들과 JPA의 성능을 최적화하는 다양한 방안을 알아보겠습니다.
- 예외 처리: JPA를 사용할 때 발생하는 다양한 예외와 예외에 따른 주의점을 설명합니다.
- 엔티티 비교: 엔티티를 비교할 때 주의점과 해결 방법을 설명합니다.
- 프록시 심화 주제: 프록시로 인해 발생하는 다양한 문제점과 해결 방법을 다룹니다.
- 성능 최적화
- N + 1 문제
- 읽기 전용 쿼리의 성능 최적화
- 트랜잭션을 지원하는 쓰기 지연과 성능 최적화
JPA 표준 예외들은 javax.persistence.PersistenceException
의 자식 클래스입니다. 그리고 이 예외 클래스는 RuntimeException
의 자식입니다. 따라서 JPA 예외는 모두 언체크 예외입니다.
JPA 표준 예외는 크게 2가지로 나눌 수 있습니다.
- 트랜잭션 롤백을 표시하는 예외
- 트랜잭션 롤백을 표시하지 않는 예외
트랜잭션 롤백을 표시하는 예외는 심각한 예외이므로 복구해선 안됩니다. 이 예외가 발생하면 트랜잭션을 강제로 커밋해도 트랜잭션이 커밋되지 않고 대신에 javax.persistence.RollbackException
예외가 발생합니다. 반면에 트랜잭션 롤백을 표시하지 않는 예외는 심각한 예외가 아닙니다. 따라서 개발자가 트랜잭션을 커밋할지 롤백할지를 판단하면 됩니다.
트랜잭션을 롤백하는 것은 데이터베이스의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구해주지는 않습니다. 예를 들어 엔티티를 조회해서 수정하는 중에 문제가 있어서 트랜잭션을 롤백하면 데이터베이스의 데이터는 원래대로 복구되지만 수정된 상태로 영속성 컨텍스트에 남아 있습니다. 따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험합니다.
따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험합니다. 새로운 영속성 컨텍스트를 생성해서 사용하거나 EntityManager.clear()
를 호출해서 영속성 컨텍스트를 초기화한 다음에 사용해야 합니다.
스프링 프레임워크에서 사용하는 기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료하므로 문제가 발생하지 않습니다.
영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하기 위한 1차 캐시가 있습니다. 1차 캐시는 영속성 컨텍스트와 생명주기를 같이 합니다.
영속성 컨텍스트를 통해 데이터를 저장하거나 조회하면 1차 캐시에 엔티티가 저장됩니다. 1차 캐시 덕분에 변경 감지 기능도 동작하고, 이름 그대로 1차 캐시로 사용되어서 데이터베이스를 통하지 않고 데이터를 바로 조회할 수도 있습니다. 영속성 컨텍스트를 더 정확히 이해하기 위해서는 1차 캐시의 가장 큰 장점은 애플리케이션 수준의 반복 가능한 읽기
를 이해해야 합니다.
같은 양속성 컨텍스트에서 엔티티를 조회하면 다음 코드와 같이 항상 같은 엔티티 인스턴스를 반환합니다. 이것은 단순히 동등성(equals)
비교 수준이 아니라 정말 주소 값이 같은 인스턴스를 반환합니다.
Member member1 = em.find(Member.class, "1L");
Member member2 = em.find(Member.class, "1L");
프록시는 원본 엔티티를 상속 받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 아니면 원본 엔티티인지 구분하지 않고 사용할 수 있습니다.
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");
이번에는 반대로 원본 엔티티
를 먼저 조회하고 그 다음에 프록시
를 조회하겠습니다. 그러면 둘 다 원본 엔티티
가 출력되는데요. 이유는 처음에 원본 엔티티
를 조회했기에 원본 엔티티
는 영속성 컨텍스트
에 올라가 있기 때문에 프록시를 조회해도 프록시를 반환할 필요가 없기
떄문입니다.
프록시는 원본 엔티티를 상속 받아서 만들어지므로 프록시로 조회한 엔티티의 타입을 비교할 때는 ==
비교를 하면 안되고 대신에 instanceof
를 사용해야 합니다.
JPA로 애플리케이션을 개발할 때 발생하는 다양한 성능 문제와 해결 방안을 알아보겠습니다.
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
로 설정하였습니다.
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
을 통해서 한번에 데이터가 조회될 것임을 예측할 수 있습니다.
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을 추가로 실행합니다.
그러면 위와 같이 Member 전체 조회해오는 쿼리 1번
, 조회 해온 Member의 수 만큼 Order 조회
가 되어 N + 1
쿼리가 발생합니다. ex) Member가 3명이 조회되었다면 Order는 3번 조회됩니다.
회원과 주문을 지연 로딩(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 문제가 발생합니다.
이번에도 위와 같이 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
, Order
를 fetch join
해서 가져와보겠습니다.
그러면 위와 같이 inner join
을 통해서 Member
, Order
엔티티를 한번에 가져오는 것을 볼 수 있습니다.
그러면 위와 같이 IN 쿼리
를 사용하여 여러 번 네트워크를 타지 않고 한번에 가져와서 성능을 최적화할 수 있습니다.
Spring
을 사용하다 보면 @Transactional
을 자주 사용하게 되는데요. @Transactional
속성 중에 readlOnly=true
라는 것이 있습니다. 이번 글에서는 JPA를 사용하면서 readOnly=true
를 사용하게 되면 어떤 일이 일어나는지에 대해서 알아보겠습니다.
엔티티가 영속성 컨텍스트에 관리되면 1차 캐시부터 변경 감지까지 얻을 수 있는 해택이 많습니다. 하지만 영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용하는 단점이 있습니다.
그래서 단순 읽기 전용일 때 readOnly=true
를 사용하면 내부 스냅샷 인스턴스를 보관하지 않기 때문에 메모리 사용량을 최적화할 수 있습니다. 단, 스냅샷이 없으므로 엔티티를 수정해도 쓰기 지연
기능을 사용할 수 없습니다.
@Transactional(readOnly=true)
를 사용하게 되면 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANUAL
로 설정합니다. 이렇게 하면 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않습니다. 따라서 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시하지 않습니다.
영속성 컨텍스트를 플러시하지 않으니 엔티티의 등록, 수정, 삭제는 당연히 동작하지 않습니다. 하지만 플러시할 때 일어나는 스냅샷 비교와 같은 무거운 로직들을 수행하지 않으므로 성능이 향상됩니다. 물론 트랜잭션을 시작했으므로 트랜잭션 시작, 로직수행, 트랜잭션 커밋의 과정은 이루어집니다. 단지 영속성 컨텍스트를 플러시하지 않을 뿐입니다.
- em.flush() 직접 호출
- 트랜잭션 커밋 시 플러시가 자동 호출됩니다.
- JPQL 쿼리 실행 시 플러시가 자동 호출됩니다.
플러시를 호출하는 방법은 위와 같이 3가지 입니다. 플러시(flush())는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영합니다. 플러시를 실행하면 구체적으로 다음과 같은 일이 일어납니다.
변경 감지가 동작해서 영속성 컨텍스트에 있는 모든 엔티티를 스냅샷과 비교해서 수정된 엔티티를 찾는다. 수정된 엔티티는 수정 쿼리를 만들어 쓰기 지연 SQL 저장소에 등록한다. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송합니다.(등록, 수정, 삭제 쿼리)
플러시 설정에 MANUAL
모드를 사용하면 강제로 플러시를 호출하지 않으면 절대 플러시가 발생하지 않습니다.