////
Search
Duplicate
🎪

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

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만큼 SQLIN 절을 사용해서 조회한다.
하이버네이트 @Fetch(FetchMode.SUBSELECT)
하이버네이트가 제공하는 @Fetch 어노테이션에 FetchModeSUBSELECT로 사용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용하여 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 배치 기능을 사용할 수 있다.