반응형
Notice
Recent Posts
Recent Comments
관리 메뉴

간단한 개발관련 내용

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

IT 책/JPA (ORM 표준 JPA 프로그래밍)

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

vincenzo.dev.82 2024. 11. 27. 16:05
반응형
  • 예외처리
  • 엔터티 비교
  • 프록시 심화 주제
  • 성능 최적화
    • N+1 문제
    • 읽기 전용 쿼리 최적화
    • 배치처리
    • SQL 쿼리 힌트
    • 트랜잭션을 지원하는 쓰기 지연과 성능 최적화

15.1 예외처리

15.1.1 JPA 표준 예외 정리

JPA 표준 예외들은 RumtimeException 을 상속하는 javax.persistence.PersistenceException의 자식 클래스들이다.

  • 트랜잭션 롤백을 표시하는 예외
    • 심각한 예외로 복구해서는 안되고
    • javax.persistence.RollbackException 예외가 발생한다.
  • 트랜잭션 롤백을 표시하지 않는 예외
    • 심각한 예외가 아니라 판단에 따라 복구해도 된다

15.1 .2 스프링 프레임워크의 JPA 예외 전환

  • 데이터 접근 계층에 대한 예외를 추상화해서 개발자에게 제공

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

  • JPA의 예외를 스프링 프레임워크가 추상화한 예외로 변환하기 위해선 PersistenceExceptionTranslationPostProcessor를 스프링 빈으로 등록하면 된다.
  • JavaConfig 형태로 설정

<aside> ☝🏽

Spring Boot를 사용하면 @EnableJpaRepositories 또는 @Repository 어노테이션을 사용하여 JPA 리포지토리를 정의할 때 PersistenceExceptionTranslationPostProcessor가 자동으로 설정됩니다. 즉, 별도의 명시적인 설정 없이도 스프링 부트는 JPA 예외를 자동으로 스프링 예외로 변환할 수 있게 설정됩니다.

</aside>

15.1 .4 트랜잭션 롤백 시 주의 사항

  • 기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료하므로 문제가 발생하지 않는다.
  • OSVI처럼 영속성 컨텍스트의 범위가 트랜잭션의 범위보다 넓게 사용해서 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때 발생한다.
    • 스프링 프레임워크는 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 설정하면 트랜잭션 롤백 시 영속성 컨텍스트를 초기화(em.clear())해서 잘못된 영속성 컨텍스트를 사용하는 문제를 예방한다.

15.2 엔터티 비교

  • 1차 캐시의 큰 장점인 애플리케이션 수준의 반복가능한 읽기

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

  • 영속성 컨텍스트가 같으면 엔터티를 비교할 때 다음 3가지 조건을 모두 만족한다
    • 동일성 : Identitical : == 비교가 같다
    • 동등성 : equinalent : equals 비교가 같다
    • 데이터베이스 동등성 : @Id 데이터베이스 식별자가 같다

<aside> ❓

테스트클래스에서 @Transactional을 적용하면 테스트가 끝날 때 트랜잭션을 커밋하지 않고 트랜잭션을 강제로 롤백한다. 그래야 데이터베이스에 영향을 주지 않고 테스트를 반복해서 할 수 있기 때문이다. 문제는 롤백 시에는 영속성 컨텍스트를 플러시 하지 않는다는 점이다. 플러시를 하지 않으므로 플러시 시점에 어떤 SQL이 실행되는지 콘솔 로그에 남지 않는다. 어떤 SQL이 실행되는지 콘솔을 통해 보고 싶으면 테스트 마지막에 em.flush()를 강제로 호출하면 된다.

</aside>

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

  • 동일성 == 비교가 실패한다. (다른 객체)
  • 동등성 equals 비교는 만족한다. (equals 구현 및 데이터가 동일하다는 가정)
  • 데이터베이스 동등성 : @Id 데이터베이스 식별자가 같다

15.3 프록시 심화 주제

  • 프록시는 원본데이터를 상속받아서 만들어지므로 엔터티를 사용하는 클라이언트는 엔터티가 프록시인지 아니면 원본인지 구분하지 않고 사용한다. 따라서 원본을 사용하다 지연로딩을 위해 프록시로 변경해도 비즈니스 로직에는 영향이 없지만 사용 시 몇몇 문제들이 발생할 수 있다.

15.3.1 영속성 컨텍스트와 프록시

  • 프록시로 조회해도 영속성 컨텍스트는 영속 엔터티의 동일성을 보장한다.

15.3.2 프록시 타입 비교

  • == 대신 instanceOf로 비교
  • 프록시는 원본 엔터티의 자식 타입이므로 instanceOf 연산을 사용

15.3.3 프록시 동등성 비교

  • 정리하자면 프록시 동등성을 비교할 때는 다음 주의 사항을 주의해야한다.
    • 프록시 타입 비교는 == 대신에 instanceOf를 사용해야 한다.
    • 프록시의 멤버변수에 직접 접근하면 안 되고 접근자 메서드를 사용해야 한다.

15.3.4 상속관계와 프록시

  • 프록시를 부모타입으로 조회하면 문제가 발생한다
  • 프록시를 부모 타입으로 조회하면 부모 타입을 기반으로 하는 프록시가 생성되는 문제가 있다
    • instanceOf 연산을 사용할 수 없다
    • 하위 타입으로 다운캐스팅을 할 수 없다
  • 상속관계에서 발생하는 프록시 문제 해결법
    • JPQL로 대상 직접 조회
      • 다형성을 사용할 수 없음
    • 프록시 벗기기
      • 동일성 비교 == 실패
    • 기능을 위한 별도의 인터페이스 제공
      • 부모위에 인터페이스 선언하여 구현하게 함
    • 비지터패턴(Visitor pattern)
      • 프록시에 대한 걱정 없이 안전하게 원본 엔터티에 접근할 수 있다
      • instanceof와 타입캐스팅 없이 코드를 구현할 수 있다
      • 알고리즘과 객체 구조를 분리해서 구조를 수정하지 않고 새로운 동작을 추가할 수 있다

15.4 성능 최적화

15.4.1 N+1 문제

  • Member 엔터티에서 Order 엔터티(List<Order>)를 호출하는 OneToMany 이면서 FetchType.EAGER일 때
  • 즉시로딩과 N+1 문제
    • 특정 회원 하나를 em.find() 로 호출하면 outer join 으로 쿼리가 실행된다
    • 문제는 JPQL을 사용할 때 발생한다. JPQL로 member를 조회하면 JPA는 이것을 분석해서 SQL을 생성한다. 이 때는 즉시로딩과 지연로딩에 대해서 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성한다.
      • select * from member, 멤버를 먼저 조회하고
      • select * from order where member_id=?, 주문을 조회한다
      • 이렇게 총 2번의 쿼리로 나눠서 실행된다
    • 이렇게 즉시 로딩은 JPQL을 실행할 때 N+1 문제가 발생할 수 있다
  • 지연로딩과 N+1
    • 회원과 주문을 지연로딩으로 변경했을 때, N+1 발생
    • 반대로 JPQL에서는 N+1 문제가 발생하지 않는다
    • JPQL로 멤버 조회 후 주문을 지연로딩하면 된다
    • 하지만 지연로딩으로 사용하는 것 자체가 N+1 문제를 발생시킨다.
    • for(Member member : members) { member.getOrder().getProduct() }
  • 페치 조인 사용
    • 페치 조인은 SQL 조인을 사용해서 연관된 엔터티를 함께 조회하므로 N+1 문제가 발생하지 않는다.
      • select m from Member m join fetch m.orders
      • 중복은 distinct로 제거
  • 하이버네이트 @BatchSize
    • 하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 어노테이션을 사용하면 연관된 엔터티를 조회할 때 지정한 size만큼 SQL in 절을 사용해서 조회한다
  • 하이버네이트 @Fetch(FetchMode.SUBSELECT)
    • org.hibernate.annotations.Fetch
    • 연관된 데이터를 조회할 때 서브쿼리를 사용해서 N+1 문제를 해결
  • N+1 정리 (681 page)

<aside> 💡

즉시로딩과 지연로딩 중 추천하는 방법은 즉시로딩은 사용하지 말고 지연 로딩만 사용하는 것이다. 즉시로딩 전략은 그럴듯해 보이지만 N+1 문제는 물론이고 비즈니스 로직에 따라 필요하지 않은 엔터티를 로딩해야 하는 상황이 자주 발생한다. 그리고 즉시로딩의 가장 큰 문제는 성능 최적화가 어렵다는 점이다. 엔터티를 조회하다보면 즉시로딩이 연속으로 발생해서 전혀 예상하지 못한 SQL이 실행 될 수 있다. 따라서 모두 지연로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL fetch join 을 사용하자.

.

JPA의 글로벌 페치 전략은 다음과 같다

@OneToOne @ManyToOne : 기본 fetch 전략은 즉시로딩

@OneToMany @ManyToMany : 기본 fetch 전략은 지연로딩

.

@OneToOne @ManyToOne 두 개는 지연로딩으로 변경해서 사용하자

</aside>

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

<aside> ☝🏽

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

  1. 스냅샷 1차캐시 메모리
  2. 플러시 em.flush 트랜잭션 </aside>
  • 스칼라 타입으로 조회
    • 엔터티가 아닌 필드를 포함하는 스칼라 타입으로 조회한다.
    • 스칼라 타입은 영속성 컨텍스트가 결과를 관리하지 않는다.
      • as-is : select o from Order o
      • to-be : select o.id, o.name, o.price from Order o
  • 읽기 전용 쿼리 힌트 사용
    • org.hibernate.readOnly를 true로 사용
    • 읽기 전용이라 영속성 컨텍스트는 스냅샷을 보관하지 않는다. 따라서 메모리 사용량을 최적화 할 수 있다
  • 읽기 전용 트랜잭션 사용
    • @Transactional(readOnly = true)
    • 스프링 프레임워크가 하이버네이트 세션 플러시 모드를 MANUAL로 설정한다. 이렇게 하면 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않는다. 따라서 트랜잭션을 커밋해도 영속성 컨텍스트를 플러하지 않는다.
  • 트랜잭션 밖에서 읽기
    • 트랜잭션 밖에서 읽는 다는 것은 트랜잭션 없이 엔터티를 조회한다는 뜻이다. 따라서 조회가 목적일 때 사용한다.
    • @Transactional(propagation = Propagation.NOT_SUPPORTED) // spring
    • @TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED) // J2EE
    • 이렇게 트랜잭션을 사용하지 않으면 플러시가 일어나지 않으므로 조회 성능이 향상된다

<aside> 💡

스프링을 사용할 때는 2가지를 적용하면 최적화 할 수 있다.

  • 읽기 전용 트랜잭션 사용
    • 플러시를 작동하지 않게 함
  • 읽기 전용 쿼리 힌트 사용
    • 엔터티를 읽기 전용으로 조회 메모리 절약 (스냅샷 x) </aside>

15.4.3 배치 처리

  • 수백만 건의 데이터를 처리해야 하는 상황일 때 일반적인 방식으로 엔터터를 조회하면 영속성 컨텍스트에 아주 많은 데이터가 쌓이면서 메모리 부족 오류가 발생한다. 따라서 이런 배치 처리는 적절한 단위로 영속성 컨텍스트를 초기화해야 한다.
  • JPA 등록 배치
    • 주의할 점은 일정 단위마다 영속성 컨텍스트의 엔터티를 데이터베이스에 플러시하고 영속성 컨텍스트를 초기화 해야 한다.
  • JPA 수정 배치
    • 많은 데이터를 한 번에 로딩할 수 없어서 2가지 방법을 사용한다
      • 페이징 처리 : 데이터베이스 페이징 기능을 사용한다
      • 커서 CURSOR: 데이터베이스가 지원하는 커서 기능을 사용한다
  • JPA 페이징 배치 처리
    • em.createQuery().setFirstResult().setMaxResult(pageSize).getResultList();
      • JPA는 JDBC 커서를 지원하지 않는다. 따라서 커서를 사용하려면 하이버네이트 세션을 사용해야 한다.
  • 하이버네이트 scroll 사용
    • 하이버네이트는 scroll이라는 이름으로 JDBC 커서를 지원한다.
    • em.unwrap() 으로 하이버네이트 세션을 구하고 ScrollableResults 로 scroll.next() 하면서 엔터티를 하나씩 조회할 수 있다.
    • 중간중간에 flush, clear한다
  • 하이버네이트 무상태 세션 사용
    • 영속성 컨텍스트를 만들지 않고 심지어 2차 캐시도 사용하지 않는다
    • 영속성 컨텍스트를 플러시하거나 초기화하지 않아도 된다. 대신에 엔터티를 수정할 때 update() 메서드를 직접 호출해야 한다

15.4.4 SQL 쿼리 힌트 사용

  • JPA는 직접 쿼리 힌트를 제공하지 않는다.
  • 하이버네이트를 직접 사용해야 한다
    • em.unwrap(Session.class)
    • session.createQuery().addQueryHint().list();

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

  • 트랜잭션을 지원하는 쓰기 지연과 JDBC 배치
    • 대량의 INSERT 횟수를 하나로 줄이기
    • hibernate.jdbc.batch_size 속성 값을 설정해서 등록, 수정, 삭제 할 때 하이버네이트는 SQL 배치 기능을 사용

<aside> 💡

엔터티가 영속 상태가 되려면 식별자가 꼭 필요하다. 그런데 IDENTITY 식별자 전략은 엔터티를 데이터베이스에 저장해야 식별자를 구할 수 있으므로 em.persist()를 호출하는 즉시 INSERT SQL이 데이터베이스에 전달된다. 따라서 쓰기 지연을 활용한 성능 최적화를 할 수 없다.

</aside>

  • 트랜잭션을 지원하는 쓰기 지연과 애플리케이션의 확장성
    • 성능과 개발편의성도 있지만 진짜 장점은 데이터베이스 테이블 row에 lock이 걸리는 시간을 최소화한다는 점이다.
    • 트랜잭션을 커밋해서 영속성 컨텍스트를 플러시하기 전까지는 데이터베이스에 데이터를 등록, 수정, 삭제 하지 않는다. 따라서 커밋 직전까지 row에 락을 걸지 않는다
    • JPA의 쓰기 지연 기능은 데이터베이스에 락이 걸리는 시간을 최소화해서 동시에 더 많은 트랜잭션을 처리할 수 있는 장점이 있다.

15.5 정리

  • JPA에서 예외는
    • 트랜잭션 롤백을 표시하는 예외 : 롤백 안됌
    • 트랜잭션 롤백을 표시하지 않는 예외 : 상황에 따라 롤백
  • 같은 영속성 컨텍스트에서 엔터티 비교, 다른 영속성 컨텍스트에서 엔터티 비교
  • N + 1 문제
    • 즉시로딩 시 N+1 : 기본적으로 outer-join 이지만 직접 쿼리를 사용하면 내부에서 별도로 호출해서 N+1
    • 지연로딩 시 N+1 : 기본적으로 개별 호출이기 때문에 회원 조회 후 주문을 조죄하면 N+1 이된다
    • fetch-join 으로 해결한다
  • 트랜잭션을 지원하는 쓰기 지연
    • Id 가 IDENTITY 식별자 전략은 먹히지 않는다

 

SpringBoot에서의 PersistenceExceptionTranslationPostProcessor


PersistenceExceptionTranslationPostProcessor는 Spring Framework에서 JPA 예외스프링의 데이터 접근 예외로 변환해주는 **후처리기(Bean Post Processor)**입니다. 스프링 부트에서 이 설정은 주로 JPA나 데이터베이스와 관련된 예외를 처리할 때 사용되며, 스프링의 일관된 예외 계층 구조로 변환하는 역할을 합니다. 이를 통해 개발자는 데이터 접근 계층에서 발생하는 다양한 예외를 스프링의 데이터 접근 예외로 통합하여 쉽게 처리할 수 있습니다.

PersistenceExceptionTranslationPostProcessor의 역할

  • Hibernate, JPA, JDBC에서 발생하는 다양한 예외를 스프링의 데이터 접근 예외로 변환합니다.
  • 예외를 스프링의 DataAccessException 하위 클래스로 변환하여, 특정 데이터베이스에 종속적인 예외 처리를 하지 않고, 스프링의 추상화된 예외 처리 체계를 통해 처리할 수 있습니다.
  • 예를 들어, Hibernate의 ConstraintViolationException이 발생할 경우, 이를 스프링의 DataIntegrityViolationException으로 변환해줍니다.

사용 예시

PersistenceExceptionTranslationPostProcessor는 일반적으로 스프링 컨텍스트에 자동으로 등록되며, 수동으로 명시할 필요는 없습니다. Spring Boot를 사용하면 JPA와 관련된 설정을 할 때 자동으로 Bean Post Processor로 등록되므로 특별한 설정을 하지 않아도 됩니다.

그러나, 만약 수동으로 설정하고 싶다면, Spring의 Configuration 클래스에서 다음과 같이 등록할 수 있습니다:

@Configuration
public class PersistenceConfig {

    @Bean
    public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
        return new PersistenceExceptionTranslationPostProcessor();
    }
}

작동 방식

  • 스프링의 **AOP(Aspect-Oriented Programming)**를 활용하여 DAO(데이터 접근 객체)에 대해 예외 변환을 수행합니다.
  • DAO 계층에서 발생하는 JPA 관련 예외를 감지하고, 이를 스프링의 데이터 접근 예외로 변환해주는 역할을 합니다.

예를 들어, 아래 코드에서 JPA와 관련된 예외가 발생하면, PersistenceExceptionTranslationPostProcessor는 이를 스프링의 DataAccessException으로 변환합니다:

@Repository
public class UserRepositoryImpl {

    @PersistenceContext
    private EntityManager entityManager;

    public User findById(Long id) {
        try {
            return entityManager.find(User.class, id);
        } catch (Exception e) {
            // 이 시점에서 PersistenceExceptionTranslationPostProcessor가 예외를 처리하고 변환합니다.
            throw e;
        }
    }
}

Spring Boot에서의 설정

Spring Boot를 사용하면 @EnableJpaRepositories 또는 @Repository 어노테이션을 사용하여 JPA 리포지토리를 정의할 때 PersistenceExceptionTranslationPostProcessor가 자동으로 설정됩니다. 즉, 별도의 명시적인 설정 없이도 스프링 부트는 JPA 예외를 자동으로 스프링 예외로 변환할 수 있게 설정됩니다.

Spring Boot 프로젝트에서 기본적으로 자동 설정되어 있기 때문에, 대부분의 경우에는 특별히 수동으로 PersistenceExceptionTranslationPostProcessor를 등록할 필요가 없습니다.

스프링의 예외 변환 예시

  • JPA의 EntityNotFoundException → 스프링의 EmptyResultDataAccessException
  • Hibernate의 ConstraintViolationException → 스프링의 DataIntegrityViolationException
  • JPA의 OptimisticLockException → 스프링의 OptimisticLockingFailureException

결론

PersistenceExceptionTranslationPostProcessor는 Spring Boot에서 자동으로 설정되며, JPA나 Hibernate와 같은 ORM 프레임워크에서 발생하는 예외를 스프링의 데이터 접근 예외로 변환해주는 역할을 합니다. 이를 통해 다양한 데이터베이스 예외를 일관된 방식으로 처리할 수 있게 됩니다. 직접 명시적으로 설정할 필요는 거의 없으며, 스프링 부트에서는 기본적으로 자동 설정됩니다.

반응형