스프링/JPA

지피지기 백전불태(부제 : JPA의 오해와 사실)

지팡구 2024. 10. 20. 21:29
 

사내 팀 내 개발 문화를 도입하면서 공유한 내용입니다. 

 

간단하게 어떤 개발문화를 도입했는지에 설명하자면 다음과 같습니다.

 

- 팀원들 개인의 경험 공유 및 지식 나눔으로 매주 목요일 번갈아가며 진행

- 발표자는 자유 주제로 팀원들과 소통

- 무거운 주제도 괜찮지만 되도록 가벼운 주제로

- 다른 개발의 영역이라도 자유롭게 참가 가능

- 주 목적은 같이 성장하기

 

이미 여러 다른 회사들에서는 이러한 개발문화를 도입했을겁니다.

이는 팀 내의 역량을 증진하고 더 나아가 발표를 하기 위해, 발표를 듣기 위해 자신의 지식을 점검하며 같이 성장하기에 주 목적이 있습니다 

 

 

간단한 설명은 이쯤에서 끝내고 발표 준비한 내용을 공유도록 하겠습니다.

 

목차

  • N+1 문제의 흔한 착각
  • 여러분은 Page와 Slice를 아시나요?
  • DirtyChecking 웨 안 돼?

 

 

1. N+1 문제의 흔한 착각

  • N+1이 뭔데?
    • 타겟 엔티티(타겟 데이터)에 대해 하나의 쿼리로 여러 개의(N개)의 레코드를 가지고 왔을 때, 연관 타겟 엔티티를 가져오기 위해 쿼리를 N번 추가적으로 수행하는 문제
쉽게 풀어서 설명하자면 게시물을 하나 조회하려고 한다.

나는 게시물만 가지고 오고 싶은데, 사진까지 강제로 끼워넣기 당해서 꾸역꾸역 데이터를 가지게 되는 문제이다.

이렇게 끼워넣기 당하는 문제를 JPA 진영에서는 N+1 문제라고 칭한다. 
(JPA에서 한정적으로 발생하는 문제가 아닌 다른 ORM을 사용해도 동일한 문제가 발생)

일반적으로 @OneToMany / @ManyToOne 상황에서 자주 발생!
  • 일반적으로 JPA 에서는 해결방법을 2가지 방법으로 제시
    • Fetch Join
    • Entity Graph

Q.1 : N+1 문제는 Fetch EAGER을 사용할 때만 발생한다? (Y/N)

  • 정답은 N
    • Fetch 전략을 LAZY로 설정했다고 해도 연관 Entity를 참조하면 순간 추가적인 N개의 쿼리가 발생
  • 결국 여기서 키 포인트는 연관된 Entity를 참조하게 된다면 사진 끼워넣기 당하는겁니다..
  • 앞서 언급한 Fetch.EAGER과 Fetch.LAZY는 시점의 차이!
    • LAZY : 사용할 때, 가지고 올래!
    • EAGER : 바로 가지고 올래!

Q.2 : findAll() 메서드는 N+1 문제를 발생시키지 않을까요?(Y/N)

  • 정답은 N
    • Fetch 전략을 적용해서 연관된 Entity를 가지고 오는 것은 오직 단일 엔티티를 가지고 올 때만 적용
    • 겉으로 봤을때, findAll()이라는 메서드는 EntityManager에서 최적화를 해줄 것 같지만, 사실 EntityManager의 함수가 아니라서 내부적으로 JPQL을 사용하기에 JPA의 쿼리 최적화가 되지 않음
      • 짧은 설명글
        • JPQL : 객체를 대상으로 질의문
        • SQL : 데이터베이스 테이블을 대상으로 질의문
      요약 : findAll() 메서드는 JPQL을 사용해서, 쿼리가 수행된 다음에 반환된 엔티티(레코드) 각각에 대해서 연관관계가 적용이 됩니다. 그래서 발생 안하는 것은 아닙니다

내부 JPA 의 findAll() 메서드 구현체

@Override
public List<T> findAll() {
   return getQuery(null, Sort.unsorted()).getResultList();
}

 

 

Q.3 : Pagination + fetch join을 사용하면 N+1이 해결될까요? (Y/N)

  • 정답은 N
    • 위와 같은 조합으로 사용했을 때, Limit 갯수 만큼만 데이터를 가지고 올 것이라 생각해서 해당 조합을 선택해 사용하는 경우가 많은데,
    • 사실 이 fetch join을 사용해서 연관 데이터를 가지고 올 때, 어떤 범위까지 데이터를 가지고 오는지 알 수 없어 같이 사용하는 것이 비효율적 
    • 그래서 발생한 문제가 해결되는 것이 아님!

    • 그래서 hibernate에서 limit 조건 없이 전체를 다 가지고 오고, 그 이후 메모리에서 원하는 페이지만 반환 
      • 여기서 키포인트는 메모리에서 원하는 페이지만 반환한다는 것입니다. (**데이터를 다 가지고 온 후** ) 
    • 이렇게 데이터를 다 가지고 와서 메모리에서 만약에 페이징 조건에 맞춰 데이터를 가공한다면 메모리에 부하가 발생할 여지가 생김 (데이터가 많을 경우 가급적 사용 x) 
    • HHH000104: firstResult/maxResults specified with collection fetch; 
      applying in memory!
      
       
  • 그래서 이러한 문제를 해결하기 위해 일반적으로 제시하는 방법이 batch_size를 이용해서 n+1에서 파생된 문제를 회피
  • 이러한 문제를 일 → 다를 조회하는 것이 아닌, 다 → 일 로 방향을 바꿔서 회피하는 것도 방법 중 하나 (다수의 부모를 먼저 조회 후 자식을 따로 조회하는 전략)

 

 

2. 여러분은 Page와 Slice를 아시나요? (Page vs Slice)

Q.1 : 다음 두 메서드의 차이점은 무엇일까요?

  • 정답은 두 메서드는 이름은 다르지만 같은 쿼리를 수행합니다(그러나 집계 쿼리를 발생하고 안하고의 차이가 존재)
    • 그러나 리턴 타입이 다른데, 일반적으로 페이지네이션을 구현할 시 Page<?> 객체를 반환하는 쿼리를 많이 사용하는데, 내부적으로 Page 인터페이스를 확인하게 되면 Slice 인터페이스를 상속받고 있는 구조

  • Page 인터페이스 내부 구현체
	/**
	 * Returns the number of total pages.
	 *
	 * @return the number of total pages
	 */
	int getTotalPages();

	/**
	 * Returns the total amount of elements.
	 *
	 * @return the total amount of elements
	 */
	long getTotalElements();
  • 그래서 두 쿼리를 실제로 확인해보면 응답 객체로 Page를 받는 쿼리는 아래와 같은 쿼리를 (집계쿼리 발생 o)
selet * from Car where carCode={carCode} offset {offset} limit {limit}
select count(*) from Car where carCode ={carCode}

(페이지 객체를 사용하더라도 전달한 limit 갯수보다 적은 갯수가 반환되면 페이징 쿼리는 발생하지 않는다는 점!)

  • Slice를 사용하는 쿼리는 아래와 같은 쿼리 발생 (집계 쿼리 발생 x)
selet * from Car where carCode={carCode} offset {offset} limit {limit_plus+1}

 

그럼 어떻게 Slice에서 다음 페이지가 있는지 없는지 알 수 있을까요?

  • 바로 쿼리를 확인해보면 우리가 전달한 limit보다 하나 더 들고 와서[limit(size)+1] 그 레코드의 유무에 따라 다음이 있는지 없는지를 판단
  • 그래서 페이지네이션의 집계쿼리가 많은 데이터의 레코드를 집계할 때, 성능상 문제가 발생할 염려가 있으므로 Slice 를 사용하는 것으로 성능상의 이점을 얻을 수 있다.

 

 

3. Dirty Checking 웨 안 돼?

  • 다음과 같은 샘플 코드가 있습니다
    • 신입사원 이씨는 JPA 기반의 프로젝트를 진행하고 있고 Dirty Checking이라는 JPA의 이점을 사용하기 위해 아래와 같이 코드를 짰습니다. 그러나 이씨가 생각하는 것처럼 데이터가 변경이 되지 않았는데요, 과연 무엇이 문제일까요??

 

  • 정답 위의 코드에서는 JPA의 Dirty Checking의 이점을 사용할 수 없는 코드입니다.
    • 이유가 뭘까요??
      • 정답은 Dirty Checking의 정의에 있습니다

      • JPA에서 Dirty Checking(더티 체킹)은 영속성 컨텍스트에 의해 관리되는 엔티티의 상태 변화를 자동으로 감지하여, 변경된 필드만 데이터베이스에 반영하는 기능을 말합니다.


      •  JPA는 엔티티의 필드가 변경되면 이를 감지하고, 트랜잭션이 커밋될 때 자동으로 UPDATE 쿼리를 생성하여 데이터베이스에 반영합니다.


      • 개발자가 별도로 save()나 update() 같은 메서드를 호출하지 않아도, JPA가 자동으로 변경을 추적하고 적용합니다
    • 여기서 가장 중요한 점은 영속성 컨텍스트에 의해 관리되는 엔티티가 더티체킹의 대상이 됩니다.
  • Dirty Checking의 동작 방식
    • 영속성 컨텍스트(Persistence Context):
      • 영속성 컨텍스트는 엔티티가 "영속" 상태로 유지되는 메커니즘입니다. 이는 JPA가 관리하는 캐시와 같은 개념으로, 엔티티의 원본 상태(Snapshot)를 유지하고 있다가 트랜잭션 커밋 시점에 변경된 부분만 데이터베이스에 반영
    • Snapshot 저장:
      • JPA는 엔티티가 처음 영속성 컨텍스트에 들어갈 때, 해당 엔티티의 "Snapshot"(초기 상태)를 저장
    • 변경 감지:
      • 엔티티의 필드 값이 변경되면, 영속성 컨텍스트는 원본 Snapshot과 비교하여 변경된 부분을 감지
    • 변경 사항 반영:
      • 트랜잭션이 커밋되면 flush()가 호출되고, JPA는 변경된 엔티티에 대해 UPDATE 쿼리를 실행하여 데이터베이스에 반영

그렇다 보니 위의 예시 코드에서는 외부에서 주입한 객체가 지금 더티체킹을 사용하기 위한 영속 상태의 엔티티가 아니기 때문에 Dirty Checking을 사용할 수가 없습니다.