[ORM 표준 JPA] 15장 고급주제와 프록시, 성능개선

2021. 9. 5. 23:40개발공부/JPA 스터디

728x90

예외 처리

  • JPA를 사용할 때 발생하는 다양한 예외와 예외에 따른 주의점
  • 트랜잭션 롤백 시 주의사항*
  • Spring 프레임워크 처리: OSIV처럼 영속성 컨텍스트 범위를 트랜잭션보다 크게 사용할 때
    문제가 발생한 트랜잭션을 롤백하더라도 다른 트랜잭션에서 영속성 컨텍스트를
    사용할 가능성이 있습니다. 하지만 이럴 경우 롤백이 영속성 컨텍스트를 포함하도록
    범위를 지정해주기 때문에 예방해줍니다.

엔티티 비교

  • 엔티티를 비교할 때 주의점과 해결 방법을 설명
  • 애플리케이션 수준의 반복 가능한 읽기*
  • 엔티티 조회 시 항상 같은 엔티티 인스턴스 반환(동등성;equals 비교 수준이 아닌
    정말 주소값이 같은 동일성 인스턴스를 반환합니다.)
    Member member1 = em.find(Member.class, "1L");
    Member member2 = em.find(Member.class, "1L");
  • DB 동등성 비교(@Id 식별자를 비교)를 이용합니다.

프록시 심화 주제

  • 프록시로 인해 발생하는 다양한 문제점과 해결방법을 다룸

1) 영속성 컨텍스트와 프록시

  • 영속성 컨텍스트는 관리하는 영속 엔티티의 동일성(Identity)을 보장합니다.
    그럼 프록시로 조회한 엔티티의 동일성도 보장할까요?
    • 비교 대상이 원본 엔티티의 경우엔 ==으로 비교가 가능하지만
      프록시라면 instanceOf를 사용해야 합니다. 왜냐하면 원본 엔티티를
      상속받았기 때문입니다.
    • 멤버변수에 접근하려면 또한 Getter를 사용합니다.
    • 상속관계 시 부모 엔티티 기반으로만 프록시가 생성되기 때문에
      프록시 벗기기 처리가 필요합니다.
      • hibernate의 unproxy 사용, Vistor(비지터) 패턴
  • 참고로 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티를 반환합니다.

성능 최적화

1) N+1

양방향 엔티티가 있고, 즉시 로딩으로 설정해뒀을 때 엔티티를 조회하면 참조 엔티티까지 함께 조회됩니다.
또한 SQL은 아래와 같이 실행됩니다.

  select M.*, O.*
    from
        member M
    outer join orders O on M.id=O.MEMBER_ID

하지만 JPQL을 사용할 경우, JOIN을 쓰지 않고 모든 회원 데이터에 연관 참조된 주문 테이블을 조회하기 때문에
성능에 부하가 걸립니다. 이처럼 결과 수 만큼 추가로 SQL을 실행하는 것을 N+1 문제라고 합니다.

    select * from member;
    select * from orders where member_id = 1;
    select * from orders where member_id = 2;
    select * from orders where member_id = 3;
    select * from orders where member_id = 4;
    select * from orders where member_id = 5;

그럼 연관 참조에 지연 로딩(LAZY) 방식을 설정하면 해결되지 않을까라는 의문을 가질 수 있습니다. JPQL에서 본 N+1문제는
생기지 않겠지만, 도메인 비즈니스 로직에서 발생할 여지는 여전히 존재합니다.

    List<Member> members = memberRepository.findAll();
    for (Member m : members) {
        System.out.println(m.getOrders().size());
    }

교재에서는 위와 같이 모든 회원의 연관 주문을 조회할 때 N+1문제가 발생할 수 있다고 합니다.
회원별로 연관 주문을 조회할 땐 연관 엔티티를 조회하는 게 맞지 않나요??
(join을 쓰지 않아서 SQL이 2번 나간다는 것 때문이라면 킹정)

교재에서 제시하는 해결은 페치 조인을 적용시켜 한번에 조인해서 연관 엔티티 정보까지 조회해오는 방법입니다.
JPQL 페치 조인 구문과 실행되는 SQL 코드입니다.

    ##JPQL
    select * from member m join fetch m.orders

    ##sql 
    select M.*, O.*
    from member M
    inner join orders O on M.id=O.member_id

또다른 해결책으론 @BatchSize 어노테이션을 사용하면 지정한 (size=5) 만큼 SQL IN 절을 사용해서 조회합니다.
만약 데이터가 10개 있다면 SQL은 총 2번 실행되겠쥬. 지연 로딩이라면 최초 데이터를 사용하는 시점에 5개의 데이터를 미리 로딩해놓고
6번째 데이터를 요청할 때 SQL을 추가로 1번 더 실행하게 됩니다.

2가지 해결책 외에도 @Fetch(FetchMode.SUBSELECT)를 사용해 orders 엔티티의 서브 쿼리를
실행해 N+1문제를 해결할 수 있다고 합니다.

N+1 정리

연관 엔티티를 매번 조회해야할 예외케이스가 아니라면 지연 로딩을 사용하는 것이 성능 최적화를 위해 좋습니다.
JPA의 페치 전략 기본값은 @OneToMany : 지연 로딩, @ManyToOne : 즉시 로딩 입니다. 따라서 N:1인 연관관계는
FetchType.LAZY로 변경해 사용하는 습관을 들이면 좋다고 합니다.
###

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

영속성 컨텍스트는 1차 캐시부터 변경 감지까지 얻을 수 있는 장점이 많지만 그만큼 메모리를 잡아먹습니다.
예를 들어 단순 조회용으로 100건의 데이터를 뽑았을 때 영속성 컨텍스트에 보관하지 않는 게 메모리 효율적이겠죠.
이 때 읽기 전용으로 엔티티를 조회하면 메모리 사용량을 줄일 수 있습니다.

스칼라 타입
엔티티가 아닌 sql을 사용해 조회한다면 확실히 영속성 컨텍스트를 사용하지 않고 데이터를 조회할 수 있습니다.
하지만 이는 객체 지향적인 방법인 아니죠.

읽기 전용 쿼리 힌트 사용
하이버네이트 전용 힌트인 org.hibernate.readOnly를 사용하면 영속성 컨텍스트 스냅샷을 저장하지 않습니다.
하지만 실무에선 Spring Data JPA를 사용하기 때문에 실용적이진 않아보입니다.

읽기 전용 트랜잭션 사용
플러시 모드를 AUTO -> MANUAL로 변경해주는 기능입니다. 스프링 프레임워크에서 트랜잭션을 읽기 전용으로 사용할 수 있습니다.

@Transactional(readOnly = true)
이렇게 설정하면 스프링에선 하이버네이트 플러시 모드를 MANUAL로 설정합니다. 따라서 강제로 플러시를 호출하지 않는 한
영속성 컨텍스트를 플러시 하지 않습니다. 당연히 Dirty checking(변경감지)도 발생하지 않겠죠.
###

3) 배치 처리

수백만 건의 대량 데이터를 처리한다면 영속성 컨텍스트를 주기적으로 초기화해 메모리 관리를 해줄 필요가 있습니다.
수동으로 SQL 실행 100개 마다 조건문을 넣어줄 수도 있고, JPA 페이징 배치 처리를 사용해 페이징 Size를 반복할 때마다
영속성 컨텍스트를 초기화 시킬수도 있습니다.

이외에도 교재에선 하이버네이트 scroll, 하이버네이트 무상태 세션을 배치 처리의 방법들로 소개하고 있습니다.
###

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

하이버네이트 SQL 배치 전략을 사용하면 지정한 (value=50) 만큼 쿼리를 모아서 SQL 배치를 실행해줍니다.
네트워크 통신 건수를 감소한다면 성능을 개선시킬 수 있기 때문에 배치를 고려해야 됩니다.

** 주의: 엔티티가 영속 상태가 되려면 식별자는 필수인데, 식별자 생성전략 IDENTITY는 엔티티를 DB에 저장해야
식별자를 구할 수 있기 때문에 em.persist()를 호출하는 즉시 INSERT SQL이 DB에 전달됩니다.
따라서 쓰기 지연을 통한 성능 최적화를 할 수 없습니다.

트랜잭션을 지원하는 쓰기지연과 애플리케이션 확장성

트랜잭션을 지원하는 쓰기지연 활용의 장점은 성능개선과 코드 개발 편의성, 그리고 테이블 rowlock시간을 최소화하는 점입니다.
만약 JPA를 사용하지 않는다면 현재 수정 중인 데이터를 수정하려는 다른 트랜잭션은 락이 풀릴 때까지 대기하기 때문에
SQL UPDATE를 처리한 뒤 다음 커밋이 실행될 때까지 row는 락이 걸려있습니다.

하지만 JPA는 쓰기 지연로딩으로 commit이 나갈 때 플러시를 호출하고 쿼리를 내보내기 때문에, update 쿼리가 나가고
바로 커밋을 호출함으로써 lock시간을 최소화 해줍니다.

    update(member);
    비즈니스로직A();
    비즈니스로직B();
    commit();

###트랜잭션과 락, 2차 캐시

1) 트랜잭션과 락

트랜잭션은 ACID를 보장해야합니다.

  • A(Atomicity) 원자성: 트랜잭션 내 실행한 작업들을 마치 하나인 것처럼 모두 실패하거나 성공해야 합니다.
  • C(Consistency) 일관성: 모든 트랜잭션은 일관된 DB를 만족해야 합니다. 데이터 무결성
  • I(Isolation) 격리성: 동시에 실행되는 트랜잭션은 서로에 영향을 미치지 않도록 격리합니다. 격리성은 동시성과 관련된 성능 이슈
    로 격리 수준을 선택할 수 있습니다.
  • D(Durability) 지속성: 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 생겨도
    DB 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 합니다.

참고로 트랜잭션은 원자성, 일관성, 지속성을 보장합니다. 격리성을 완벽히 보장하려면 커밋을 하나씩 처리해야하는데
이럴 경우 동시성 처리 성능이 매우 나빠집니다. ANSI(미국표준협회) 표준은 트랜잭션의 격리수준을 4단계로 나누었습니다.
순서대로 격시성이 낮음 -> 높음 입니다.
image

낙관적 락과 비관적 락 기초

말그래도 낙관적 락은 충둘이 발생하지 않는다는 과정하에 락을 걸고, 비관적 락은 보수적으로 우선 락을 거는 것을 말합니다.
트랜잭션 시 second lost updates problem 두 번의 갱신 분실 문제가 발생할 수 있는데, 이때는 3가지 방법이 있습니다.

e.g. 두 명이 같은 화면에서 작업하다 A가 먼저 수정완료 했지만 뒤이어 B가 수정완료를 해 나중에 완료한 B의 수정사항만 남게되는
문제입니다.

  • 마지막 커밋만 인정하기
  • 최초 커밋만 인정하기
    • @Version : JPA는 UPDATE쿼리가 나갈때마다 VERSION필드도 함께 UPDATE 하며 조회한 VERSION값이 없을때 충돌이 발생하며
      최초 커밋만 인정됩니다.
  • 충돌하는 갱신 내용 병합하기
    ###

JPA에서 권장하는 락 설정

추천하는 전략은 READ COMMITTED 격리 수준 + 낙관적 버전관리 입니다. LockModeType.OPTIMISTIC
JPA는 @Version을 사용해 낙관적 락을 설정합니다.
###

JPA 비관적 락

비관적 락은 엔티티가 아닌 스칼라 타입을 조회할 때에도 사용할 수 있습니다. 비관적 락을 사용하면 락을 획득할 때까지
대기해야되기 때문에 타임아웃을 설정해 무한정 대기를 방지할 수도 있습니다.
###

2) 2차 캐시

네트워크를 통해 DB를 접근하는 방식은 서버에서 내부 메모리를 접근하는 것보다 시간비용이 수만에서 수십만 배 이상 높습니다.
따라서 DB를 조회해서 내부 메모리 캐시에 저장해두면 비용을 아낄 수 있습니다.

트랜잭션이 종료될 때까지만 유효하거나, OSIV를 사용해도 클라이언트의 요청이 올때까지만 유효나 1차 캐시의 한계를
개선해 애플리케이션의 범위의 캐시를 지원하는데 이를 공유 캐시 혹은 2차 캐시라고 합니다.

image
###

1차 캐시

영속성 컨텍스트 그 자체여서 직접 생성/소멸을 컨트롤 할 수 없다는 한계가 있습니다. 특징은 엔티티를 조회했을 때 1차 캐시에 동일한
엔티티가 있을 경우 해당 엔티티를 반환하기 때문에 동일성을 보장합니다.
###

2차 캐시

1차 캐시와 달리 애플리케이션 종료까지 유지되며 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다 더 오래 유지될 수 있습니다.
2차 캐시는 조회하는 엔티티가 있다면 복사본을 제공합니다. 이유는 동시성을 극대화하기 위함인데 애플리케이션 전체 범위에서 같은
객체를 수정할 경우 객체에 락을 걸어야 하는데 그럴 경우 동시성이 떨어지기 때문입니다. 락에 비하면 객체를 복사하는 비용은 저렴
하기 때문에 2차 캐시는 원본 대신에 복사본을 반환합니다.

image

2차 캐시 사용

엔티티 클래스에 @Cacheable 어노테이션을 사용하면 됩니다. 캐시 모드는 javax.persistence.SharedCacheMode에 정의되어 있습니다.
캐시 모드에 따라 캐시를 무시할수도 있고 더 세밀하게 캐시 사용을 설정할 수 있습니다.

캐시 영역

캐시를 하면 엔티티는 엔티티 캐시 영역, 연관 참조 엔티티는 컬렉션 캐시 영역에 저장됩니다.

쿼리 캐시

쿼리 캐시는 쿼리와 파라미터 정보를 키로 활용해서 쿼리 결과를 캐시하는 방법입니다. 적용하기 위해선
org.hibernate.cacheable = true로 설정해줘야 합니다.

쿼리/컬렉션 캐시를 대상 엔티티 캐시와 함께 쓰지 않으면 심각한 성능적 이슈가 발생할 수 있습니다. 이유는 쿼리 캐시는 엔티티 식별자 값만
들어있기 때문에 실제 엔티티 정보는 엔티티 캐시를 통해 하나씩 조회해오기 때문에 꼭 같이 사용해줘야 합니다.

엔티티 캐시 테스트

스프링 프레임워크 StopWatch 라이브러리를 사용해서 Cache 했을 때, 하지 않을 때 Task Time 차이를 테스트 해보려고 했으나
2차 캐시 검증에 실패중입니다. EHCACHE@Cacheable, @Cache 사용법을 익혀서 테스트에 성공한 뒤 업뎃하겠습니다.


@Test
@DisplayName("실패한_2차캐시_테스트")
    void 실패한_2차캐시_테스트() {
          parentRepository.findById(1L);
          StopWatch stopWatch = new StopWatch();
          stopWatch.start("cacheTest");
          log.info("task: " + stopWatch.currentTaskName());
          //캐시했을 때 stopwatch
          parentRepository.findById(1L);
          stopWatch.stop();
          log.info("task time: " + stopWatch.getLastTaskTimeMillis());
    }

Hibernate: 
    select
        parent0_.id as id1_4_0_,
        parent0_.name as name2_4_0_ 
    from
        parent parent0_ 
    where
        parent0_.id=?
2021-09-05 22:53:37.319  INFO 13756 --- [           main] l.jpa.repository.ParentRepositoryTest    : task time: 31

참고자료: 자바 ORM 표준 JPA 프로그래밍 (김영한 저)