[JPA] N + 1 문제 (지연 로딩, 페치 조인, 페이징 문제 해결)

dooboocookie·2022년 12월 6일
0
post-thumbnail

N + 1 문제

@Entity
public class Hospital {
    @Id 
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long hosId;
    // ...
    @ManyToOne
    @JoinColumn(name = "hos_sigudong")
    private Sigudong hosSigudong;
	// ...
    @OneToMany(mappedBy = "hospital")
    private List<HosImg> hosImgs = new ArrayList<>();
    // ...
}
  • JPA에선 엔티티가 필드(컬럼, 속성, ...)으로 연관된 엔티티 객체 주소를 갖게 된다.
//JPQL
select h from Hopital h
  • 위와 같이 병원 엔티티 객체를 select 하는 경우 N+1문제가 발생힌다.
//SQL
select * from Hopital h
  • 위와 같이 1개의 쿼리문을 호출하면
    • 병원 1
    • 병원 2
    • 병원 3
    • ...
    • 결과로 병원 테이블의 대한 컬럼 정보들을 가지고 올 수 있지만,
    • @ManyToOne의 Sigudong은 FK(시구동의 PK)만 가져올 수 있고,
    • @OneToMany의 List<HosImg>는 병원 테이블에서 가져올 수 있는 정보가 전혀 없다.

따라서,
병원에 대한 쿼리문 1개,
각 병원의 이미지에 대한 쿼리문 N개,
각 병원의 시구동에 대한 쿼리문 N개
발생한다.

이와 같은 현상이 N+1 문제이다.

현재 병원 검색 리스트를 구현 중인데, 한번의 검색결과를 로딩할 때 마다 수십개의 쿼리문이 날라간다...

FetchType

  • @OneToMany, @ManyToOne 과 같은 연관관계를 짓는 어노테이션에는 FetchType을 설정할 수 있는 속성이 있다.

즉시 로딩

  • FetchType.EAGE 속성을 어노테이션에 붙인다.
  • 기본 값
  • N + 1 개의 쿼리를 발생 시켜 병원 정보들과 그 각각의 병원의 필드에 들어가있는 연관 엔티티 객체들을 다 가져온다.
    • em.createQuery("...", Hospital.class).getResultList();를 실행할 때 모든 쿼리가 다 날라간다.

지연 로딩

  • FetchType.LAZY
  • 쿼리가 실행되는 시점에는 병원에 대한 쿼리문만 날라간다.
    • 프록시 엔티티 객체를 필드에 초기화 해놓고
    • .getXXX()와 같이 연관관계 매핑이 된 필드에 접근을 할때 그 병원의 이미지를 가져오는 쿼리문이 따로 날라간다.

프록시

  • 실제 엔티티를 상속받아서 만들어지는 프록시 엔티티
  • 프록시 객체가 사용되는 경우
    • 지연 로딩
    • em.getReference()
    • 데이터베이스 조회를 하지 않은 상태로 프록시 엔티티로 반환함
    • 이미 영속성 컨텍스트에 있는 정보라면 실제 엔티티가 반환됨
  • 아직 DB를 조회하기 전에는 target필득 null인 상태
  • 실제 그 프록시 객체의 정보에 접근하려할 때
    • 그제서야 DB에서 엔티티 정보를 조회
    • 그 엔티티를 영속성 컨텍스트에서 관리
    • 그 엔티티 주소를 target에 초기화
    • 이후에 프록시 객체에 정보에 접근하면 target에 초기화된 엔티티의 정보를 반환함

  • 실제로는 엔티티 객체와 프록시 객체를 구분 없이 사용하면 된다.
    • (동작 방식은 이해를 하는 편이 좋음)

지연로딩의 한계

  • 지연로딩은 대부분의 상황에서 효과적이다.
  • 하지만, 그 필드 정보를 꼭 불러오는 상황이라면 지연로딩이 크게 효과
    • 예를들어 병원 검색 리스트를 뿌릴 때 병원의 정보와 병원의 사진은 꼭 같이 사용이 된다.
  • 이로 N+1이 완벽히 해결되지 않는다.

fetch join

  • 연관관계 매핑이된 엔티티 or 컬렉션을 한번의 쿼리문으로 조회할 수 있도록 한다.
    • SQL에는 없는 조인 종류이다.

사용

  • join fetch 문을 사용

엔티티 페치 조인

@ManyToOne
private Sigudong hosSigudong;
  • 보통 @ManyToOne 필드의 엔티티 정보를 함께 조회할 때
select h 
from Hospital h 
join fetch h.sigudong
  • 위와 같이 조회하면 다음과 같은 쿼리문이 발생한다.
select h.*, s.*
from Hospital h 
inner join Sigudong s
on h.sigudong_id = s.sigudong_id
  • 따라서 원래 대상으로 한 Hospital의 레코드 수보다 더 많이 조회 되지는 않는다.
    • 조인 조건에 따라 조회되는 정보가 줄어들 순 있다.
    • 병원 1, 서대문구
    • 병원 2, 은평구
    • 병원 3, 서대문구
    • 병원 4, 마포구
    • 병원 5, 서대문구
    • ...

컬렉션 페치 조인

@OneToMany(mappedBy = "hospital")
private List<HosImg> hosImgs = new ArrayList<>();
  • @OneToMany 필드의 컬렉션 필드의 엔티티 정보를 함께 조회할 때
select h 
from Hospital h 
join fetch h.hosImgs
  • 위와 같이 조회하면 다음과 같은 쿼리문이 발생한다.
select i.*, h.*
from Hospital h 
inner join Hos_Img i
on h.hospital_id = i.hospital_id
  • 이는 원래 검색 대상인 Hospital의 레코드보다 더 많은 레코드를 조회하게 된다.
    • 검색 대상이 1쪽이고 조인하는 테이블이 N쪽이기 때문에 원래 조회할 레코드보다 많은 레코드를 가져오게된다.
    • 병원 1, 병원 사진 1
    • 병원 1, 병원 사진 2
    • 병원 1, 병원 사진 3
    • 병원 2, 병원 사진 4
    • 병원 2, 병원 사진 5
    • 병원 3, 병원 사진 6
  • 이 때, JPA 엔티티로 매핑될 때는 List에 담긴다.
List<Hospital> result = em.createQuery("...", Hospital.class)
							.getResultList();
// List에 담기는 엔티티 정보
// {id = 1, hosImgs = {{id = 1, ...}, {id = 2, ...}, {id = 3, ...}}
// {id = 1, hosImgs = {{id = 1, ...}, {id = 2, ...}, {id = 3, ...}}
// {id = 1, hosImgs = {{id = 1, ...}, {id = 2, ...}, {id = 3, ...}}
// {id = 2, hosImgs = {{id = 4, ...}, {id = 5, ...}}
// {id = 2, hosImgs = {{id = 4, ...}, {id = 5, ...}}
// {id = 3, hosImgs = {{id = 6, ...}}
  • 위와 같이 한번의 쿼리로 연관관계에 있는 엔티티를 모두 가져올 수 있지만, 1:N의 경우 중복된 엔티티가 여러개 가져와지게된다.
    • 영속성 컨텍스트에 의해서 관리되고 있는 엔티티 객체이므로 중복된 객체의 주소값을 보면 다 같다.
for (Hospital hospital : result) {
	System.out.println(hospital)
}
// List의 각 요소의 객체 주소지 
// Hospital@0x100 (1번 병원)
// Hospital@0x100 (1번 병원)
// Hospital@0x100 (1번 병원)
// Hospital@0x200 (2번 병원)
// Hospital@0x200 (2번 병원)
// Hospital@0x300 (3번 병원)

distinct

  • distinct라는 명령어는 SQL에서 중복된 레코드를 제거하는데 사용된다.
  • 하지만, SQL문으로 조회하게되는 결과는 HosImg와 관련된 정보는 각각의 사진이 해당이되므로 첫번째 1번병원, 두번쨰 1번병원, 세번째 1번병원은 다른 레코드이다.
  • 병원
    hopital_id (PK) hospital_name
    1 1번 병원
    2 2번 병원
    3 3번 병원
  • 병원 사진

    hosImg_id (PK) hosImg_path hospital_id (FK)
    1 1번 사진 1
    2 2번 사진 1
    3 3번 사진 1
    4 4번 사진 2
    5 5번 사진 2
    6 6번 사진 3
  • SQL 조회 결과

    hopital_id hospital_name hosImg_id hosImg_path
    1 1번 병원 1 1번 사진
    1 1번 병원 2 2번 사진
    1 1번 병원 3 3번 사진
    2 2번 병원 4 4번 사진
    2 2번 병원 5 5번 사진
    3 3번 병원 6 6번 사진
  • 순수 SQL의 distinct의 명령어로는 중복을 제거하지 못한다.

  • 애플리케이션 메모리 상에서 중복 제거를 추가적으로 해준다.

  • List<Hospital> 에 엔티티 객체가 매핑될 때는 1번 병원은 다 같은 객체이므로 중복을 제거 해준다.

List<Hospital> result = em.createQuery("select distinct h from Hospital h fetch join h.hosImgs", Hospital.class)
							.getResultList();
// List에 담기는 엔티티 정보
// {id = 1, hosImgs = {{id = 1, ...}, {id = 2, ...}, {id = 3, ...}}
// {id = 2, hosImgs = {{id = 4, ...}, {id = 5, ...}}
// {id = 3, hosImgs = {{id = 6, ...}}

주의점

  1. 페치 조인 대상에 별칭을 줘서, 그 별칭을 통하여 조건을 줘서 컬렉션에 일부의 데이터만을 담는다는 것은 JPA의 엔티티의 방향성과 맞지 않는다. 엔티티 필드의 컬렉션에는 연관관계에 해당되는 모든 정보가 담겨 있어야 의미가 있다.

  2. 둘 이상의 컬렉션 페치 조인을 사용하면 데이터가 엄청나게 늘어날 위험이 있어서 사용하지 않는 편이 낫다.

페이징 처리

  • 컬렉션 페치 조인을 하고 페이징 API를 사용하면 JPA에서 다음과 같은 경고 로그가 뜬다.
  • 페이징 API를 사용하면 SQL 문에서 데이터 검색범위를 설정하는데, 컬렉션 페치 조인은 데이터가 동일 데이터가 여러개로 늘어나기 때문에 SQL 단계에서 distinct를 고려할 수 없다
  • 따라서 페이징 API를 사용할 수 없다.
  • 페이징을 하기 위해서는 컬렉션 페치 조인을 포기해야된다.

BatchSize

  • 해당 어노테이션으로 설정하는 옵션은 프록시 객체로서 연관관계 필드를 조회할 때, 설정한 갯수까지 한번에 가져오는 옵션이다.
  • Hospital.java
@BatchSize(size = 50)
@OneToMany(mappedBy = "hospital",orphanRemoval = true)
private List<HosImg> hosImgs = new ArrayList<>();
  • HospitalRepostory.java
em.createQuery("select h " +
        "from Hospital h "+
        "where h.hosName LIKE :keyword", Hospital.class)
    .setParameter("keyword", "%"+ 검색어 +"%")
    .setFirstResult(시작)
    .setMaxResults(총 개수)
    .getResultList();
  • 지연 로딩에 의해서 병원 정보만 가져오고 Hospital에 대한 쿼리만 나갔겠지만
  • 위의 hosImgs필드의 배치 사이즈 옵션에 의해서 50개씩 한번에 HosImg의 데이터를 조회해서 영속성 컨텍스트로 관리한다.

  • 위와 같이 WHERE 절에 IN을 통해서 최대 배치 사이즈의 갯수만큼 엔티티를 가져온다.

최종 코드

시구동

@Entity
public class Sigudong {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long sigudongId;//1124900000

    private String sigudongName; //용산구

    @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id")
    private Sigudong parent; //서울특별시 엔티티

    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
    private List<Sigudong> child = new ArrayList<>(); 
}
  • 이와 같이 의 하위 주소로 들이 속해있는 경우

  • 위의 데이터를 가져오는데 저 상황에서는 모든 엔티티가 부모와 자식의 엔티티를 필드로 가져야했다.
  • 그래서 시를 조회하는 JPQL을 일반적으로 날리면
    • 시를 조회하는 쿼리 1번
    • 서울시, 경기도, ... 각각의 자식을 조회하는 쿼리 N번해서
    • N + 1번 쿼리가 날라갔다.
  • 이를 페치 조인으로 해결
return em.createQuery("select distinct si " +
                        "from Sigudong si join fetch si.child " +
                        "where si.parent is null ", Sigudong.class)
        .getResultList();
  • 실제 실행 쿼리

병원 검색

  • 병원 검색 결과를 페이지에 따라 표현해줘야한다.
  • 병원 엔티티의 병원 이미지는 항상 필요하기 때문에 N + 1문제가 발생한다.
  • 페이징 처리가 필요해서 페치조인을 사용할 수 없다.
  • 따라서 배치 사이즈 옵션을 줘서 해결한다.
@Entity
public class Hospital {
    @Id 
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long hosId;
    // ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "hos_sigudong")
    private Sigudong hosSigudong;
	// ...
    @BatchSize(size = 50)
    @OneToMany(mappedBy = "hospital",orphanRemoval = true)
    private List<HosImg> hosImgs = new ArrayList<>();
    // ...
}
  • 실제 발생 쿼리

    • 병원 조회 (페이징)
    • 병원 이미지 조회 (배치사이즈 옵션)
  • N+1번의 쿼리문이 날라가던 문제를 2개의 쿼리로 해결할 수 있다.

profile
1일 1산책 1커밋

0개의 댓글