•
JPA의 깊이 있는 고급 주제들과 JPA의 성능을 최적화하는 방안들에 대해서 알아보자.
◦
예외 처리: JPA를 사용할 때 발생하는 다양한 예외와 예외에 따른 주의점을 설명한다.
◦
엔티티 비교: 엔티티를 비교할 때 주의점과 해결 방법을 설명한다.
◦
프록시 심화 주제: 프록시로 인해 발생하는 다양한 문제점과 해결 방법을 다룬다.
◦
성능 최적화
▪
N+1 문제
▪
읽기 전용 쿼리의 성능 최적화
▪
배치 처리
▪
SQL 쿼리 힌트 사용
▪
트랜잭션을 지원하는 쓰기 지연과 성능 최적화
1. 예외 처리
1. JPA 표준 예외 정리
•
JPA 표준 예외들은 RuntimeException의 자식인 javax.persistence.PersistenceException의 자식 클래스다.
⇒ 따라서 Unchecked Exception이다.
•
JPA 표준 예외는 크게 2가지로 나눌 수 있는데 하나는 트랜잭션 롤백을 표시하는 예외와 그렇지 않은 예외다.
◦
트랜잭션 롤백을 표시하는 예외는 심각한 예외로 복구되어선 안 된다. 해당 예외가 발생하면 트랜잭션을 강제로 커밋해도 트랜잭션이 커밋되지 않고 대신 RollbackException 예외가 발생한다.
트랜잭션 롤백을 표시하는 예외 | 설명 |
javax.persistence.EntityExistsException | EntityManager.persist() 호출 시 이미 같은 엔티티가 존재하면 발생한다. |
javax.persistence.EntityNotFoundException | EntityManager.getReference() 호출 후 실제 사용 시 엔티티가 존재하지 않으면 발생한다. refresh, lock 호출에서도 발생한다. |
javax.persistence.OptimisticLockException | 낙관적 락 충돌 시 발생한다. |
javax.persistence.PessimisticLockException | 비관적 락 충돌 시 발생한다. |
javax.persistence.RollbackException | EntityTransaction.commit() 실패 시 발생, 롤백이 표시되어 있는 트랜잭션 커밋 시에도 발생한다. |
javax.persistence.TransactionRequiredException | 트랜잭션이 필요할 때, 트랜잭션이 없으면 발생한다. 트랜잭션 없이 엔티티를 변경할 때 주로 발생한다. |
◦
트랜잭션 롤백을 표시않는 예외는 심각한 예외가 아니므로 개발자가 트랜잭션을 커밋할지 롤백할지 판단하면 된다.
트랜잭션 롤백을 표시않는 예외 | 설명 |
javax.persistence.NoResultException | Query.getSingleResult() 호출 시 결과가 하나도 없을 때 발생한다. |
javax.persistence.NonUniqueResultException | Query.getSingleResult() 호출 시 결과가 둘 이상일 때 발생한다. |
javax.persistence.LockTimeoutException | 비관적 락에서 시간 초과 시 발생한다. |
javax.persistence.QueryTimeoutException | 쿼리 실행 시간 초과 시 발생한다. |
2. 스프링 프레임워크의 JPA 예외 변환
•
스프링 프레임워크는 DAO 계층의 예외가 서비스까지 퍼지는 걸 방지하기 위해 해당 계층의 예외를 추상화하여 제공한다.
3. 스프링 프레임워크에 JPA 예외 변환기 적용
•
JPA 예외를 스프링 프레임워크가 제공하는 추상화된 예외로 변경하려면 PersistenceExceptionTranslationPostProcessor를 빈으로 등록하면 된다.
◦
이는 @Repository 어노테이션을 사용한 곳에서 예외 변환 AOP를 적용하여 JPA 예외를 스프링 프레임워크가 추상화한 예욀 변환해준다.
4. 트랜잭션 롤백 시 주의사항
•
트랜잭션을 롤백하는 것은 데이터베이스의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구해주지 않는다는 점을 명심하자.
•
객체는 수정된 상태로 영속성 컨텍스트에 남아있게 되며 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험하다.
◦
새로운 영속성 컨텍스트를 생성하여 사용하거나 clear()를 호출하여 초기화한 다음 사용해야 한다.
•
이런 문제를 예방하기 위해서 영속성 컨텍스트의 범위에 따라 다른 방법을 사용한다.
•
기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료하므로 문제가 발생하지 않는다.
◦
문제는 OSIV처럼 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용해서 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할때 발생한다.
⇒ OSIV를 자주 다룰 것 같진 않아서 제외하였습니다.
2. 엔티티 비교
•
영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하기 위한 1차 캐시가 있으며 이는 영속성 컨텍스트와 생명주기를 같이한다.
◦
영속성 컨텍스트를 조회하거나 저장하면 1차 캐시에 저장되어 변경 감지, 캐시 등의 기능을 수행한다.
•
영속성 컨텍스트를 더 정확히 이해하기 위해서는 1차 캐시의 가장 큰 장점인 어플리케이션 수준의 REPEATABLE READ를 이해해야 한다.
•
단순히 동등한 객체가 아닌 동일한 객체를 반환함을 보장하는 것이 중요하다.
1. 영속성 컨텍스트가 같을 때 엔티티 비교
•
영속성 컨텍스트가 같으면 동일성, 동등성, 데이터베이스 동등성을 만족한다.
2. 영속성 컨텍스트가 다를 때 엔티티 비교
•
영속성 컨텍스트가 다르면 동등성, 데이터베이스 동등성을 만족한다.
•
따라서 객체의 비교를 상황에 영향받지 않고 수행할 수 있는 동등성 비교를 권장한다.
3. 프록시 심화 주제
•
프록시는 원본 엔티티를 상속받아 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 아니면 원본 엔티티인지 신경쓰지 않고 사용할 수 있다.
1. 영속성 컨텍스트와 프록시
•
영속성 컨텍스트는 자신이 관리하는 영속 엔티티의 동일성을 보장한다. 그럼 프록시로 조회한 엔티티의 동일성도 보장할까?
•
getReference()와 find()를 사용해 조회한 객체는 각각 프록시와 원본 엔티티로 서로 다른 인스턴스로 생각할 수 있다. 그렇지만 영속성 컨텍스트가 영속 엔티티의 동일성을 보장하지 못하는 문제가 발생한다.
•
따라서 영속성 컨텍스트는 프록시로 조회된 엔티티에 대해서 find() 요청이 들어오면 원본 엔티티가 아닌 처음 조회된 프록시를 반환한다.
◦
반대로 원본 엔티티를 먼저 조회하고 프록시를 조회하는 경우, 이미 원본 엔티티가 영속성 컨텍스트에 존재하므로 프록시를 반환하지 않아 동일성이 보장된다.
•
프록시로 조회해도 영속성 컨텍스트는 영속 엔티티의 동일성을 보장한다.
2. 프록시 타입 비교
•
프록시는 원본 엔티티를 상속받아 만들어지므로 프록시로 조회한 엔티티의 타입을 비교할 때는 instacneof를 사용해야 한다.
3. 프록시 동등성 비교
•
엔티티의 동등성을 비교하려면 비즈니스 키를 사용해서 equals() 메소드를 오버라이딩하고 비교하면 된다.
•
다만 프록시 타입의 비교는 == 대신 instacneof를 사용해야 한다.
•
프록시의 멤버 변수에 직접 접근하면 안 되고 대신 접근자 메소드를 사용해야 한다.
4. 상속관계와 프록시
•
프록시를 부모 타입으로 조회하면 문제가 발생한다.
◦
프록시를 부모 타입을 조회하면 자식이 아닌 부모의 타입을 기반으로 프록시가 생성된다.
◦
따라서 instanceof 연산을 사용할 수 없고 하위 타입으로 다운 캐스팅이 불가능하다.
•
프록시 문제를 어떻게 해결하는 방법은 다음과 같다.
◦
JPQL로 대상 직접 조회
◦
unproxy()
◦
기능을 위한 별도의 인터페이스 제공
◦
비지터 패턴 사용
4. 성능 최적화
1. N+1 문제
•
즉시 로딩과 N+1
◦
즉시 로딩을 사용하면 연관된 객체를 조인을 이용해 함께 조회한다. 그러나 JPQL을 사용할 때, JPA가 이를 분석해서 SQL을 생성할 때 즉시 로딩과 지연 로딩을 신경쓰지 않으므로 N+1 문제가 발생한다.
◦
즉, 즉시 로딩은 JPQL을 싱핼할 때, N+1 문제가 발생할 수 있다.
•
지연 로딩과 N+1
◦
지연 로딩으로 설정하면 JPQL에선 N+1 문제가 발생하지 않는다.
◦
이후 비즈니스 로직에서 SQL이 추가적으로 발생할 가능성이 있으므로 이것도 결국은 N+1 문제다.
•
N+1 문제 해결법
◦
페치 조인 사용
▪
페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.
◦
하이버네이트 @BatchSize
▪
하이버네이트가 제공하는 @BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다.
◦
하이버네이트 @Fetch(FetchMode.SUBSELECT)
▪
하이버네이트가 제공하는 @Fetch 어노테이션에 FetchMode를 SUBSELECT로 사용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용하여 N+1 문제를 해결한다.
•
N+1 정리
◦
즉시 로딩과 지연 로딩 중 추천하는 방법은 즉시 로딩은 사용하지 않고 지연 로딩만 사용하는 것이다.
◦
즉시 로딩 전략은 그럴듯해 보이지만 N+1문제는 물론이고 비즈니스 로직에 따라 필요없는 엔티티를 로딩해야 하는 상황이 자주 발생하며 성능 최적화가 어렵다는 큰 문제가 존재한다.
◦
따라서 모두 지연 로딩으로 설정하고 성능 최적화가 필요한 부분에만 JPQL 페치 조인을 사용하자.
▪
JPA의 글로벌 페치 전략의 기본값이 대상이 일인 경우 즉시 로딩을 사용하고 다인 경우 지연 로딩을 사용하므로 이를 변경해주자.
2. 읽기 전용 쿼리의 성능 최적화
•
영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 예상보다 더 많은 메모리를 사용한다는 단점이 있다.
•
따라서 이러한 기능들이 필요 없는 읽기 기능만 사용하고 싶은 경우, 읽기 전용으로 엔티티를 조회하면 메모리 사용량을 최적화할 수 있다.
◦
스칼라 타입으로 조회
▪
엔티티(*)가 아닌 스칼라 타입(user.user_id)으로 모든 필드를 조회하면 영속성 컨텍스트가 결과를 관리하지 않는다.
◦
읽기 전용 쿼리 힌트 사용
▪
하이버네이트 전용 힌트인 readOnly를 사용하면 엔티티를 읽기 전용으로 조회할 수 잇다.
▪
읽기 전용이므로 영속성 컨텍스트는 스냅샷을 보관하지 않는다. 따라서 메모리 사용량을 최적화할 수 있다.
◦
읽기 전용 트랜잭션 사용
▪
@Transactional(readOnly = true) 어노테이션을 사용하면 스프링 프레임워크가 하이버네이트 세션의 플러시 모드를 MANUAL로 설정한다.
▪
이 경우, 강제로 플러시를 호출하지 않는한 플러시가 발생하지 않는다.
◦
트랜잭션 밖에서 읽기
▪
@Transactional(propagation = Propagation.NOT_SUPPORTED)를 사용하면 트랜잭션없이 엔티티를 조회할 수 있다.
•
읽기 전용 데이터를 조회하는 경우, 메모리를 최적화하고 싶다면 스칼라 타입으로 조회하거나 하이버네이트가 제공하는 읽기 전용 쿼리 힌트를 사용하라.
•
플러시 호출을 막아 속도를 최적화하려면 읽기 전용 트랜잭션을 사용하거나 트랜잭션 밖에서 읽기를 사용하면 된다.
•
따라서 읽기 전용 트랜잭션과 읽기 전용 쿼리 힌트를 동시에 사용하는 것이 가장 효과적이다.
3. 배치 처리
•
수백만 건의 데이터를 배치 처리해야 하는 상황에서 일반적인 방식으로 엔티티를 계쏙 조회하면 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면서 OOM 오류가 발생할 것이다.
•
이런 배치 처리는 적절한 단위로 영속성 컨텍스트를 초기화해야 한다. 또한 2차 캐시를 사용하고 있다면 엔티티를 보관하지 않도록 주의해야 한다.
•
JPA 등록 배치
◦
많은 데이터를 한 번에 등록할 때 주의할 점은 영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록 일정 단위마다 영속성 컨텍스트의 엔티티를 데이터베이스에 플러시하고 영속성 컨텍스트를 초기화해야 한다.
for (int i = 0; i < 100000; i++ {
Product product = new Product("item" + i, 10000);
em.persist(product);
if ( i % 100 == 0) {
em.flush();
em.clear();
}
}
Java
복사
•
JPA 페이징 배치 처리
◦
수정할 데이터를 한 번에 n건 씩 가져와 수정 후 flush, clear한다.
•
하이버네이트 scroll 사용
◦
JPA에서 지원하지 않는 JDBC 커서를 지원하는 기능으로 하이버네이트 세션을 구한 후 사용해야 한다.
◦
다음으로 쿼리를 조회하면서 scroll() 메소드로 ScrollableResults 객체를 반환받는다. 이 객체의 next() 메소드를 호출하면 엔티티를 하나씩 조회할 수 있다.
•
하이버네이트 무상태 세션 사용
◦
하이버네이트는 무상태 세션이라는 특별한 기능을 제공하는데, 영속성 컨텍스트도 심지어 2차 캐시도 사용하지 않는다.
4. SQL 쿼리 힌트 사용
•
JPA는 데이터베이스에 SQL 힌트를 제공하지 않으므로 SQL 힌트를 사용하려면 하이버네이트를 직접 사용해야 한다.
•
SQL 힌트는 하이버네이트 쿼리가 제공하는 addQueryHint() 메소드를 사용한다.
5. 트랜잭션을 지원하는 쓰기 지연과 성능 최적화
•
트랜잭션을 지원하는 쓰기 지연과 JDBC 배치
◦
JPA는 플러시 기능이 있으므로 JDBC의 SQL 배치 기능을 효과적으로 사용할 수 있다.
◦
다만 SQL 배치는 같은 SQL일 때만 유효하다. 중간에 다른 처리가 들어가면 SQL 배치를 다시 시작한다.
•
트랜잭션을 지원하는 쓰기 지연과 어플리케이션 확장성
◦
가장 중요한 점은 데이터베이스 테이블 로우에 락이 걸리는 시간을 최소화해준다는 것이다.
◦
JPA의 쓰기 지연 기능은 데이터베이스에 락이 걸리는 시간을 최소화해서 동시에 더 많은 트랜잭션을 처리할 수 있다는 장점이 있다.
5. 정리
•
JPA의 예외는 트랜잭션 롤백을 표시하는 예외와 표시하지 않는 예외로 나눈다.
◦
트랜잭션을 롤백하는 예외는 심각한 예외이므로 트랜잭션을 강제로 커밋해도 커밋되지 않고 롤백된다.
•
스프링 프레임워크는 JPA의 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.
•
같은 영속성 컨텍스트의 엔티티를 비교할 때는 동일성 비교를 수행할 수 있지만 영속성 컨텍스트가 다르면 동일성 비교에 실패한다.
◦
따라서 자주 변하지 않는 비즈니스 키를 사용한 동등성 비교를 수행해야 한다.
•
프록시를 사용하는 클라이언트는 조회한 엔티티가 프록시인지 아니면 원본 엔티티인지 구분하지 않고 사용할 수 있어야 한다. 하지만 프록시는 구현 기술의 구조 상 완전히 대신할 수 없음을 인식하고 사용해야 한다.
•
JPA를 사용할 때는 N+1 문제를 가장 조심해야 한다. N+1 문제는 주로 페치 조인을 사용해서 해결한다.
•
엔티티를 읽기 전용으로 조회하면 스냅샷을 유지할 필요가 없어지고 영속성 컨텍스트를 플러시하지 않아도 된다.
•
대량의 엔티티를 배치 처리하려면 적절한 시점에 꼭 플러시를 호출하고 영속성 컨텍스트도 초기화해주어야 한다.
•
JPA는 SQL 쿼리 힌트를 지원하지 않지만 하이버네이트 구현체를 사용하면 SQL 쿼리 힌트를 사용할 수 있다.
•
트랜잭션을 지원하는 쓰기 지연 덕분에 SQL 배치 기능을 사용할 수 있다.