Programming/Spring Boot

N+1 문제를 해결하기 위한 FetchJoin, 일반 Join과의 차이점은

Jan92 2023. 4. 22. 01:47

Fetch Join

부끄럽지만 아직까지 'Fetch Join'을 제대로 쓸 줄 모른다는 사실을 반성하며 정리하는 내용입니다.

 

1. N+1 문제란?

jpa 연관 관계 조회 쿼리에서 자주 만나게 되는 문제로, 연관 관계가 설정된 Entity를 조회할 경우 하위 Entity의 데이터 개수(n) 만큼 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 문제를 이야기합니다.

 

N+1 문제가 발생하는 예시를 살펴보면 다음과 같은데요.

 

 


2. JPA 연관 관계 조회 쿼리에서 발생하는 N+1 문제

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String content;

    @OneToMany(mappedBy = "board", fetch = FetchType.EAGER)
    private List<Comment> commentList = new ArrayList<>();
}

(Board Entity)

 

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "board_id", nullable = false)
    private Board board;
}

(Comment Entity)

 

Board Entity와 Comment Entity가 다음과 같은 연관 관계에 있는 경우입니다.

 

board table에 3개의 데이터가 있고, 각 board에는 여러 개의 comment 데이터가 있는 상황에서 findAll() 메서드를 통해 List<Board>를 조회하면 아래 이미지와 같이 조회하려던 1건의 쿼리 외에 3건(board의 개수)의 조회 쿼리가 더 발생하는 것을 확인할 수 있습니다.

 

N+1 문제 발생

이처럼 N+1 문제가 발생하는 이유는 JPA가 글로벌 Fetch 전략을 무시하고 오직 JPQL 자체만을 가지고 SQL문을 만들기 때문인데요.

 

* JPQL(Java Persistence Query Language)는 데이터베이스의 테이블이 아니라 엔티티 객체를 대상으로 검색하는 객체 지향 쿼리

 

예시 상황에서는 1+3 = 총 4건의 쿼리만 발생하였기 때문에 체감이 잘 되지 않을 수 있지만, 만약 연관 관계의 하위 Entity 개수가 많은 경우(n개) 1번의 조회에서 1+n 번의 쿼리가 발생하게 되고, 이것은 성능에 까지 영향을 미치게 됩니다.

 

(FetchType.LAZY)

만약 지연 로딩이 설정된 경우, 최초 findAll() 메서드를 호출했을 때는 1건의 쿼리 외에 추가 조회 쿼리가 발생하지 않는데요.

하지만 로직 중 연관 관계의 하위 Entity를 가지고 작업을 할 때 해당 데이터에 대한 추가 쿼리가 발생하기 때문에, 결국 즉시 로딩(EAGER)과 마찬가지로 N+1 문제가 발생하게 됩니다.

 

이러한 jpa의 n+1 문제를 해결하기 위해 사용되는 방법 중 하나가 바로 'FetchJoin'인데요.

 

아래 내용을 통해서 JoinFetchJoin의 차이점을 살펴보고, n+1 문제가 발생했던 위 연관 관계 그대로 fetchJoin을 사용했을 때의 쿼리 결과를 살펴보겠습니다.

 

 


3. Join과 FetchJoin

 

- Join

join의 경우, 연관 관계가 있는 entity에 join을 걸더라도 JPQL에서 조회하는 주체가 되는 entity에 대해서만 select 하여 영속화합니다.

(join을 걸어주기는 하지만 join 대상에 대한 영속성에는 관여하지 않습니다.)

때문에 연관 관계가 있는 entity에 대한 데이터는 필요하지 않지만 검색 조건에는 필요한 경우 join을 사용하는 것이 좋습니다.

 

- FetchJoin

fetch join의 경우 SQL에서 사용하는 join의 종류가 아닌 JPQL에서 성능 최적화를 위해 제공하는 기능인데요.

fetch join은 조회하는 주체가 되는 entity 외에 fetch join이 걸린 연관 관계가 있는 entity까지 함께 select 하여 영속화합니다.

 

지연 로딩(LAZY)이 설정된 연관 관계 entity를 참조하는 경우에도 이미 영속화가 되어 있기 때문에 해당 객체를 사용할 때 따로 쿼리가 발생하지 않습니다.

 

 

***

jpa는 데이터베이스와 객체의 일관성을 잘 고려해서 사용해야 하는데요.

때문에 연관 관계가 있는 entity에 대한 데이터가 필요한 경우에만 fetch join을 사용하고, 단순히 검색 조건에서만 연관 관계 entity가 사용되는 경우는 join만 사용하는 것이 좋습니다.

 

 


4. Fetch Join 적용해 보기

public class BoardRepositoryImpl implements BoardQuerydsl {

    @PersistenceContext
    private EntityManager em;

    @Override
    public List<Board> findAllApplyFetchJoin() {
        JPAQuery<Board> query = new JPAQuery<>(em);
        return query.select(board)
                .distinct()
                .from(board)
                .join(board.commentList).fetchJoin()
                .fetch();
    }
}

(querydsl 구현 부분)

 

findAllApplyFetchJoin() 실행 시 발생되는 쿼리

fetchJoin을 사용하였을 때, 위와 같은 1건의 조회 쿼리가 발생하며 board에 대한 데이터뿐만 아니라, 각 board의 하위 comment에 대한 데이터도 모두 영속화하여 가지고 오는 것을 확인할 수 있는데요.

 

예시와 같이 Collection 필드에 fetch join을 사용했을 때, 내부적으로는 inner join을 사용하기 때문에 데이터의 중복이 발생하며,

때문에 distinct를 통해 중복된 데이터를 제거해 주는 부분이 추가되어야 합니다.

 

* JPQL의 DISTINCT는 애플리케이션에서 중복된 객체(동일한 메모리 주소를 가지는)를 제거해 주는 기능도 수행합니다.

 

 

여기까지 jpa를 사용할 때 발생할 수 있는 n+1 문제와, 그 문제를 해결할 수 있는 방법 중 하나인 fetch join을 적용해 보는 과정을 살펴보았습니다.

 

 

***

하지만 fetch join은 one에서 many를 fetch join 하여 페이징 된 결과를 얻으려고 할 때, limit가 적용되지 않는 등의 문제점도 가지고 있는데요.

해당 부분을 함께 다루기에는 내용이 너무 길어질 수 있기 때문에 추후 포스팅하여 아래 링크를 달아놓을 예정입니다. 꼭 함께 참고하시면 좋을 것 같습니다.

 

 

 

< 참고 자료 >

https://jojoldu.tistory.com/165

https://devraphy.tistory.com/602