본문 바로가기
JPA/Spring Data Jpa

[SpringDataJpa] findAll() n+1 문제 해결

by 옹알이옹 2023. 8. 9.

 


1. findAll() n+1 발생 원인

토의 프로젝트를 하며 게시판 목록 조회를 하던 중 findAll()을 사용하였는데 연관관계인 Member 조회 쿼리가
미친듯이 날라가는 것을 보고 멘붕이 왔다.
내가 알고 있는 배경 지식으로는 도무지 이해가 안 가는 상황이었다. 환경은 다음과 같다.

BoardEntity

@DynamicInsert // null 값 전달 시 insert 컬럼에서 제외,=> Default 값 적용 가능
@Entity
@Getter
@Table(name = "board")
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Board extends BaseDateEntity {
    @Id
    @Column(name = "board_no")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long boardNo;

    @NotNull
    @ManyToOne
    @JoinColumn(name = "member_no", nullable = false)
    private Member member;

    @Column(name = "board_nm", unique = true, nullable = false)
    private String boardNm;

    @Column(name = "use_at")
    @ColumnDefault("'Y'")
    private String useAt;

    @ManyToOne
    @JoinColumn(name = "com_code")
    private ComCode comCode;

    public BoardDto toDto() {
        return BoardDto.builder()
                .boardNo(this.boardNo)
                .boardNm(this.boardNm)
                .member(this.member)
                .useAt(this.useAt)
                .comCode(this.comCode)
                .build();

    }

    public void update(String boardNm) {
        this.boardNm = boardNm;
    }

}

 
MemberEntity

@Entity
@Getter
@Table(name = "member")
@ToString(
        callSuper = true,
        of = {"memberNo", "memberId", "password", "email", "firstNm", "lastNm"}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseDateEntity {

    @Id
    @Column(name = "member_no")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberNo;

    @Column(name = "member_id", unique = true, nullable = false)
    private String memberId;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "email", unique = true, nullable = false)
    private String email;

    @Column(name = "first_nm", nullable = false)
    private String firstNm;

    @Column(name = "last_nm", nullable = false)
    private String lastNm;

    @Builder
    public Member(String memberId, String password, String email, String firstNm, String lastNm) {
        this.memberId = memberId;
        this.password = password;
        this.email = email;
        this.firstNm = firstNm;
        this.lastNm = lastNm;
    }

}

 
이해가 안 가는 이유

  • 자식 객체인 Board에서 @ManyToOne을 썼는데 join을 하지 않고 가져오나?
    => @ManyToOne의 기본 fetch전략은 즉시로딩이니 join을 해서 가져와야 한다.
  • JPQL을 사용하면 위처럼 연관 객체를 가져오나, join을 하지 않고 가져온다고 알고 있다.
    => findAll()은 jpql인가?

그러한 이유로 JPA를 이해하지 못하고 있나 생각하여 먼저 공부용으로 사용하던 순수 JPA 프로젝트를 뒤져봤다.
 
EntityManager를 통해 findAll()을 사용하려는 순간 문제를 인식했다.

이미지 없음
EntityManager에는 findAll() 함수가 존재하지 않았다.

그러면은 findAll은 EntityManager의 함수가 아니므로 Spring Data Jpa의 메소드라고 생각하여,
원래 진행하던 Spring Data Jpa 프로젝트로 돌아가 해당 메소드를 타고 들어가 보았다.
 

 2. findAll() 구조

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

위의 코드는 JpaRepository의 findAll() 메소드를 타고 들어가 찾은 구현체 코드

해당 코드에서 findAll()이 getQuery()를 사용하고 있는 것을 볼 수 있다.
먼저 findAll()이 jpql인가 뭔가라고 생각했었지만 결론은 SimpleJpaRepository의 findAll()이 내부적으로
Jpql을 사용하여 jpa 쿼리 최적화가 되지 않아 join이 되지 않았던 것이다.

Jpql == 쿼리 최적화가 되지 않는다. 따라서 findAll()은 쿼리 최적화를 하지 않아 join이 아닌 N+1이 발생한다.

 

 3. findById() 구조

처음에 가장 헷갈렸던 것이 이 친구 때문이었다. 분명 똑같이 find를 하는데 왜 이것만 join을 사용하여 즉시로딩을 해줄까?
 
그래서 findById() 메소드도 타고 들어가 보았고, 이유는 금방 찾을 수 있었다.

SimpleJpaRepository의 코드

@Override
public Optional<T> findById(ID id) {

   Assert.notNull(id, ID_MUST_NOT_BE_NULL);

   Class<T> domainType = getDomainClass();

   if (metadata == null) {
      return Optional.ofNullable(em.find(domainType, id));
   }

   LockModeType type = metadata.getLockModeType();

   Map<String, Object> hints = new HashMap<>();
   getQueryHints().withFetchGraphs(em).forEach(hints::put);

   return Optional.ofNullable(type == null ? em.find(domainType, id, hints) : em.find(domainType, id, type, hints));
}

위의 코드를 보면 em.find(domainType, id) 를 사용하는 것이 보인다. 
이러한 차이로 인해 findAll()이 쿼리 최적화가 되지 않는다고 이해하게 되었다.
 

 4. n+1 해결 방법

구글링을 해보니 여러 해결 방안이 있었지만 나는 fetch join을 사용하여 해결하였다.


1. 의존 관계의 fetch 타입을 LAZY(지연) 로딩으로 바꾸어준다.
2. findAll()을 오버라이딩 하여 아래와 같이 재정의 해준다.

public interface BoardRepository extends JpaRepository<Board,Long>{

    @Override
    @Query("select b from Board b join fetch b.member")
    List<Board> findAll();


}

 
실행 결과

이미지 없음

열받게 했던 n+1 쿼리야 안녕

반응형

'JPA > Spring Data Jpa' 카테고리의 다른 글

JPA 지연 로딩 Json파싱 에러  (2) 2023.08.10
[SpringDataJpa] QueryDsl 정리  (0) 2023.08.10