Spring/Issue

[JPA] fetch-Join을 이용한 N+1 문제 해결

블로그 주인장 2024. 1. 26.

JPA를 사용하면 자주 만나게 되는 것이 N + 1 문제이다.

 

N + 1 문제는 성능에 큰 영향을 줄 수 있기 때문에 N + 1 문제가 무엇이고 어떤 상황에 발생되는지,

 

어떻게 해결하면 되는지에 알아보겠습니다.

 

JPA N+1 문제란?

N + 1 문제란 1번의 쿼리를 날렸을 때 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 것을 의미한다.

 

When 언제 발생하는가?

- JPA Repository를 활용하여 인터페이스 메소드를 호출할 시에(Read 시)

 

Who 누가 발생시키는가?

- 1:N 또는 N:1 관계를 가진 엔티티를 조회할 때 발생

 

How 어떤 상황에 발생되는가?

- JPA Fetch 전략이 EAGER 전략으로 데이터를 조회하는 경우

- JPA Fetch 전략이 LAZY 전략으로 데이터를 가져온 이후에 연관관계의 하위 엔티티를 다시 조회하는 경우

 

Why 왜 발생하는가?

- JPA Repository로 find 시 실행하는 첫 쿼리에서 하위 엔티티까지 한 번에 가져오지 않고, 하위 엔티티를 사용할 때

  추가로 조회하기 때문에 발생한다.

- JPQL은 기본적으로 글로벌 Fetch 전략을 무시하고 JPQL만 가지고 SQL을 생성하기 때문에 발생한다.

 

EAGER(즉시 로딩)인 경우

1. JPQL에서 만든 SQL을 통해 데이터를 조회한다.

2. 이후 JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가로 조회한다.

3. 2번 과정에서 N + 1 문제가 발생한다.

 

LAZY(지연 로딩)인 경우

1. JPQL에서 만든 SQL을 통해 데이터를 조회한다.

2. JPA에서 Fetch 전략을 가지지만, 지연 로딩이기 때문에 추가 조회를 하지 않는다.

3. 하지만, 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하기 때문에 결국 N + 1 문제가 발생하게 된다.

 


코드 구현

현재 프로젝트로 진행하고 있는 채팅 서비스의 ERD 중 일부를 발췌한 내용입니다.- 멤버는 여러 채팅방에 참여할 수 있다. -> (N:1 관계)- 채팅방에는 여러 멤버가 참여할 수 있다. -> (N:1 관계)

Member.class

@Getter
@Entity
@Builder
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(access = PROTECTED)
public class Member extends BaseTimeEntity {

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

  @Column(nullable = false)
  private String email;

  @Column(nullable = false)
  private String username;

  //.. 생략
  
}

 

ChatPaticipant.class

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatParticipant {

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

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "member_id")
  private Member member;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "chatRoom_id")
  private ChatRoom chatRoom;

  @Builder.Default
  private boolean isManager = false;
}

 

 

이를 바탕으로 나의 채팅방 목록 조회 하는 코드를 작성해서 실제 SQL 쿼리를 호출해보겠습니다.

@Override
@Transactional(readOnly = true)
public Page<ChatParticipantResponse> findMyChatRoom(Long memberId, Pageable pageable) {
  return chatParticipantRepository.findAllByMemberId(memberId, pageable)
      .map(ChatParticipantResponse::fromEntity);
}

 

 

JPA를 활용한 구문으로 이전 서비스 클래스에서 구현된 페이지네이션을 가지고

멤버 아이디를 인자로 받아서 해당 멤버가 현재 참여중인 채팅방에 대해서 조회를 하는 것을 구현했다.

@Repository
public interface ChatParticipantRepository extends JpaRepository<ChatParticipant, Long> {
  Page<ChatParticipant> findAllByMemberId(Long memberId, Pageable pageable);
}

 

 

해당 쿼리문을 날렸을 때 실제로 호출되는 쿼리를 Hibernate SQL Log을 통해 확인해보겠습니다.

 

생성된 데이터와 쿼리가 어떻게 나가는지 살펴보면?

  • 채팅방 참여자(ChatParticipant)를 조회하는 쿼리 1개
  • 채팅방 참여자(ChatParticipant)가 소속된 채팅방(ChatRoom)을 조회하는 쿼리 N개

이를 통해 첫번째 쿼리(모든 ChatParticipant를 조회)의 결과 만큼 N번의 쿼리(채팅방 참여자와 관련된 채팅방)가 발생하게 되는 것을 확인할 수 있고, 이것이 바로 JPA n + 1 문제라고 볼 수 있습니다.


N + 1 문제 해결하기

N+1 문제를 해결하는 방법은 여러 가지가 있지만 그 중에서 Fetch Join 을 이용한 방법에 대해 알아보겠습니다.

 

Fetch-Join(페치 조인)

fetch-join은 단순하게 직역하면

연관관계의 엔티티나 컬렉션을 프록시(가짜 객체)가 아닌 진짜 데이터를 한번에 같이 조회하는 기능이라고 볼 수 있다.

 

연관된 엔티티나 컬렉션을 SQL로 한번에 조회하는 기능으로 JOIN FETCH 라는 명령어를 사용한다.

 

이 때 주의 사항으로는 엔티티의 연관관계는 지연로딩으로 fetch = FetchType.LAZY 설정을 해줘야하는데

여기서 일반 JOIN을 사용하게 되면 쿼리의 N + 1 문제가 발생한다.

 

아래와 같이 JPQL을 직접 작성한 코드이다.

@Repository
public interface ChatParticipantRepository extends JpaRepository<ChatParticipant, Long> {

  @Query("SELECT cp FROM ChatParticipant cp "
      + " JOIN FETCH cp.chatRoom cr "
      + " WHERE cp.member.id = :memberId"
      + " ORDER BY cr.createdAt DESC ")
  Page<ChatParticipant> findAllByMemberId(@Param("memberId") Long memberId, Pageable pageable);

}

 

채팅방 참여자(ChatParticipant)를 조회 시에 fetch join은 채팅방을 설정하여

파라미터로 받은 멤버 아이디와 같은 것만 추출하여 생성날짜 최신순으로 정렬하는 것을 구현한 것이다.

 

결과를 확인하면 쿼리가 1번만 발생하고 미리 채팅방과 채팅 참여자 데이터를

조인(Inner Join) 해서 가져오는 것을 확인할 수 있다.

 

 

Fetch Join(페치 조인)의 특징

1. 연관된 엔티티들을 SQL 한번으로 조회 - 성능 최적화

2. 엔티티에 직접 적용하는 글로벙 로딩 전략보다 우선이다.

3. @OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략

4. 실무에서 글로벌 로딩 전략은 모두 지연 로딩이다.

5. 최적화가 필요한 곳은 페치 조인 적용

 

 

Fetch Join(페치 조인)의 단점

1. 2개 이상의 컬렉션을 한 번에 페치 조인할 수 없다.

2. 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.

  • @OneToMany에서 조회하지 않고 @ManyToOne에서 거꾸로 조회하면 페이징 문제를 해결할 수 있다.

3. 페치 조인 대상에는 별칭(as)를 줄 수 없다.

  • 페치 조인의 대상이 where 절에서 사용되어서는 안되기 때문이다.
  • 만약 별칭을 사용하게 된다면 연관된 객체(페치 조인 대상)들은 곧바로 쿼리 결과를 리턴하지 않고
    페치 조인(parent) 객체를 통해 접근할 수 있게 된다.
  • 반대로 이야기하면 @ManyToOne, @OneToMany의 주인에게는 별칭이 가능하다는 것을 의미한다.

 

Fetch Join과 일반 Join의 차이점

일반 Join은 Fetch Join과 달리 연관 엔티티에 Join을 걸어도 실제 쿼리에서 Select 하는 엔티티는

오직 JPQL에서 조회하는 주체가 되는 엔티티만 조회하여 영속화를 한다.

 

반면 Fetch-Join은 조회의 주체가 되는 엔티티 이외에

Fetch-Join이 걸린 연관 엔티티도 함께 조회하여 모두 영속화를 한다.

 

정리

N+1 문제는 JPA가 연관된 해당 엔티티가 필요한 순간에 추가로 조회하기 때문에 발생하게 된다.

이는 조회하는 순간 연관된 엔티티를 모두 가져오는 fetch join을 사용하여 해결할 수 있다.

 

OneToMany 관계에서 fetch-join을 사용하면 row의 수가 증가하기 때문에 페이지네이션이 제대로 동작하지 않는다.

이를 해결하기 위해 JPA는 연관된 데이터를 전부 가져온 뒤에 페이지네이션을 진행하는데 이는 OOM 문제를 발생시킬 수 있다. 해당 문제를 해결하려면 ManyToOne 방향에서 fetch join을 이용하면 된다.

 


References

https://velog.io/@guns95/Fetch-Join%ED%8C%A8%EC%B9%98-%EC%A1%B0%EC%9D%B8-EntityGraphe

 

https://loosie.tistory.com/750#%ED%8E%98%EC%B9%98_%EC%A1%B0%EC%9D%B8(Fetch_Join)%EC%97%90%EC%84%9C_%EB%8C%80%EC%83%81%EC%97%90%EA%B2%8C_%EB%B3%84%EC%B9%AD%EC%9D%84_%EA%B1%B8%EB%A9%B4_%EC%95%88%EB%90%98%EB%8A%94_%EC%9D%B4%EC%9C%A0

 

https://hs-archive.tistory.com/41

 

https://cornswrold.tistory.com/486#1.%20JPQL%20%EC%82%AC%EC%9A%A9-1

 

https://soongjamm.tistory.com/151

 

https://jh2021.tistory.com/21

 

https://dev-coco.tistory.com/165

 

 

본 포스트는 작성자가 공부한 내용을 바탕으로 작성한 글입니다.
잘못된 내용이 있을 시 언제든 댓글로 피드백 부탁드리겠습니다.
항상 정확한 내용을 포스팅하도록 노력하겠습니다.

반응형

'Spring > Issue' 카테고리의 다른 글

QueryDsl을 이용한 페이지네이션 성능 개선  (2) 2024.01.27
Stomp를 활용한 웹소켓 구현  (1) 2024.01.24
SMTP 비동기로 보내기(@Async)  (2) 2024.01.09

댓글