본문 바로가기
JPA

[JPA] 페이징의 n+1 문제 개선하기

by 어떻게말이름이히힝 2025. 1. 3.

기존에 Like 기능 개선하기에서 기본적인 n+1문제를 개선했는데,

페이징 부분의 n+1문제 개선이 남았더랜다.

Like 기능의 성능 개선하기

 

Like 기능의 성능 개선하기

고민되는 코드는 CommentLike 부분으로여러 검증을 위해 Post와 Comment, User까지 조회를 하고 있다.아래는 내가 직접 작성한 코드인데 클라이언트로부터 PathVariable로 postId와 commentId를 받고 있다.userId

heehyun0221.tistory.com

 

전에 썼던 글에 Fetch Join에 대한 개념이 적혀있지 않아서 

Fetch Join의 동작 방식에 대해 간단히 적어보려고 한다.

Fetch Join은 연관된 엔티티나 컬렉션을 한 번의 쿼리로 가져오기 위해 사용되며 보통 N+1 문제를 해결하고 성능을 최적화하기 위해 사용한다.

 

 

Todo들을 전부 가져오는 서비스 계층의 코드는 아래와 같다.

컨트롤러에서 파라미터로 페이지와 페이지 사이즈를 받아오고 수정시간의 내림차순으로 정렬된 todo데이터를 TodoResponse 객체에 맞게 변환해서 돌려보낸다.

public Page<TodoResponse> getTodos(int page, int size) {
    Pageable pageable = PageRequest.of(page - 1, size);

    Page<Todo> todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable);

    return todos.map(todo -> new TodoResponse(todo.getId(), todo.getTitle(), todo.getContents(),
        todo.getWeather(), new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
        todo.getCreatedAt(), todo.getModifiedAt()));
  }

 

기존 코드로 실행시켜봤을 때 한눈에 봐도 난리가 난 걸 확인할 수 있다.(관계 설정에 기본적으로 지연로딩으로 설정)

 

 

기존 Page에 관한 Repository는 아래와 같으나 간단한 패치 조인을 사용하여 바꿨다.

Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);

 

처음에는 countQuery를 사용하지 않고 패치 조인을 사용했으나,

POSTMAN을 사용하여 조회했을 때 404 Bad Request가 발생했다.

알고보니 Hibernate에서 패치조인을 사용하게 되면 paging에 필요한 카운트 쿼리를 만들어주지 못한다는 이유였다.

1차적인 방법으로 카운트 쿼리를 명시해주는 것으로 해결했다.

 

더 자세하게 설명하자면 JPA는 페이징을 처리하기 위해서 SQL에 LIMIT과 OFFSET을 자동으로 붙여주는데(저번에 jdbc를 사용할때는  select * from todolist order by modify_date desc, list_id desc, list_id desc limit ? offset ? 이라고 명시적으로 붙여줘서 페이징 했었다, 투두리스트 앱 만들기 Lv4 페이지네이션 구현 )   여기서 패치조인을 함께 사용하게 되면 DB를 풀 스캔하여 페이징을 적용하는 최악의 사태가 벌어지는 것이다. (HHH000104: firstResult/maxResults specified with collection fetch; applying in memory! 라는 경고 로그가 발생하니 조심!) 

@Query(value = "SELECT t FROM Todo t JOIN FETCH t.user u ORDER BY t.modifiedAt DESC"
      ,countQuery = "SELECT COUNT(t) FROM Todo t")
  Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);

 

 

 

지연로딩으로 설정이 되어있어도

위에처럼 JOIN FETCH를 사용하게 되면 지연 로딩이 발생하지 않고 Todo와 User가 한 번에 로드되게 된다.

 



 

자 두번째 해결 방법인 @EntityGraph에 대해 알아보자

기본 구조는 다음과 같다.

@EntityGraph(attributePaths = {"user"})

 

바로 적용해보니 기존에 패치조인을 적용한 것과 동일하게 나오는 걸 확인할 수 있다.

EntityGraph는 항상 Left join만을 사용한다.

너무 많은 연관 관계가 있으면 복잡해진다.

따라서 간단한 관계에서만 지정해야한다.

 

 

(JPQL + DTO 방식)

Like 기능 개선하기에서 나왔지만, 현재 이 기능의 관계는 ManyToOne이기때문에

지연로딩은 그대로 가져가고 Repository에서 일반적인 Query를 DTO로 묶어서 불필요한 부모 정보를 가져오지 않게하는 것이다.

 

@Query("SELECT new org.example.expert.domain.todo.dto.response.TodoResponse("
      + "t.id,t.title,t.contents,t.weather,"
      + "new org.example.expert.domain.user.dto.response.UserResponse(u.id, u.email)"
      + ", t.createdAt, t.modifiedAt) "
      + "FROM Todo t JOIN t.user u ORDER BY t.modifiedAt desc")
  Page<TodoResponse> findAllByOrderByModifiedAtDesc(Pageable pageable);

 

초기 조회 시 Todo 엔티티만 데이터베이스에서 가져오게 되고

User 필드는 프록시 객체로 초기화 된다.

todo.getUSer() 와 같이 User 객체에 접근하면 JPA는 데이터베이스 쿼리를 실행한다.

 

비즈니스 로직에 따라 다르고 '데이터가 항상 필요한 경우'에는 즉시 로딩이 필요하다(대시보드 화면, 보고서 생성 등)

 

'JPA' 카테고리의 다른 글

[JPA] Entity의 키 생성 전략  (0) 2025.01.14
[JPA] Fetch Join과 Paging 동시 사용 충돌  (0) 2025.01.03