JPA N+1 문제

take_the_king·2024년 12월 13일

트러블슈팅

목록 보기
2/3
post-thumbnail

배경

위의 코드를 보면 reservation을 조회할 때, 자식 엔티티인 user와 item의 멤버변수가 필요하면 같이 조회한다.

문제 발생

reservation을 한 번 조회하는데 총 3번의 쿼리가 발생한다.

이렇게 한 번은 큰 차이가 없을 지 몰라도, 계속되는 조회에서 위에 처럼 1번 조회에 3번의 접근이 발생하면 점점 성능 문제가 발생할 것이다.

문제 해결 노력

1. Fetch 사용

연관된 엔티티가 있는 엔티티를 조회할 때, @OneToMany, @ManyToOne 등에 대해 적절한 지연 로딩(Lazy)과 즉시 로딩(Eager)을 설정합니다.

기본적인 설정은 다음과 같다.

@ManyToOne 은 FetchType.EAGER
@OneToMany 은 FetchType.LAZY

  • 기본적으로 FetchType.LAZY를 사용하는 것이 좋다.
  • FetchType.LAZY는 필요할 때만 명시적으로 데이터를 가져와 N+1 문제를 방지한다.

하지만 이것만으로는 근본적인 해결이 되지 않는다. 결국엔 user와 item을 접근하려면 쿼리를 위와 똑같이 보내야하기 때문이다.

그래서 적은 양의 고정적인 개수가 연관되어 있을 때는 오히려 FetchType.EAGER 가 성능에 좋을 수 있다.

2. EntityGraph 사용

연관관계가 있는 엔티티를 조회할 경우 지연 로딩으로 설정되어 있으면 연관관계에서 종속된 엔티티는 쿼리 실행 시 select 되지 않고 proxy 객체를 만들어 엔티티가 적용시킨다.
그 후 해당 프락시 객체를 호출할 때마다 그때그때 select 쿼리가 실행된다.

위 같은 연관관계가 지연 로딩으로 되어있을 경우 fetch 조인을 사용하여 여러 번의 쿼리를 한 번에 해결할 수 있다.

@EntityGraph는 Data JPA에서 fect 조인을 어노테이션으로 사용할 수 있도록 만들어 준 기능이다.

	@Override
    @EntityGraph(attributePaths = {"user", "item"})
    List<Reservation> findAll();

이렇게 EntityGraph 을 사용해서 자식 엔티티과 함께 부모 엔티티를 조회하면 아래처럼 left join을 통해 한 번의 쿼리로 모두 조회해서 정보를 가지고 오게 된다.

3. JOIN FETCH 사용

JOIN FETCH은 SQL에서 이야기하는 조인의 종류는 아니다. JPQL에서 성능 최적화를 위해 제공하는 조인의 종류이다.
JPQL에서 성능 최적화를 위해 제공하는 기능으로 연관된 엔티티나 컬렉션을 한 번에 같이 조회할 수 있는 기능이다.

	@Query("SELECT r FROM Reservation r JOIN FETCH r.item JOIN FETCH r.user ")
    List<Reservation> findAllWithFetch();

이렇게 하면 reservation을 조회할 때, 하나의 쿼리로 item과 user을 같이 조회한다.

그런데 위와 같이 2번의 쿼리가 발생했다.

자세히 살펴보니 item 엔티티에서 owner와 manager에서 user의 정보가 필요해서 쿼리를 보내는 것이었다.

	@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "owner_id")
    private User owner;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "manager_id")
    private User manager;

그래서 해당 연관관계를 FetchType.LAZY로 설정하니 user에 대한 쿼리가 발생하지 않았다.
즉, 한 번의 쿼리로 reservation과 item, user을 조회되었다.

아마도 이것은 JOIN FETCH 가 LAZY인 연관관계에서 적용되기 때문이라 생각된다.

오해했던 FetchType.EAGER

기본적으로@ManyToOne 은 FetchType.EAGER 인데, 왜 즉시로딩 때 한번에 조인해서 가져오지 않을까?

@Entity
@Getter
public class Reservation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    private LocalDateTime startAt;

    private LocalDateTime endAt;

    @Enumerated(EnumType.STRING)
    private ReservationStatus status; // PENDING, APPROVED, CANCELED, EXPIRED

    public Reservation(Item item, User user, ReservationStatus status, LocalDateTime startAt, LocalDateTime endAt) {
        this.item = item;
        this.user = user;
        this.status = status;
        this.startAt = startAt;
        this.endAt = endAt;
    }

    public Reservation() {}

    public void updateStatus(ReservationStatus status) {
        this.status = status;
    }
}

여기서 주의할 것이 FetchType.EAGER 가 쿼리에서 한 번에 join을 통해 가져온다는 의미가 아니다!

FetchType.EAGER는 당장 연관관계의 필드에 접근하지않아도 일단 조회해서 가져온다는 의미이지 join으로 가져온다는 것이 아니다. 오히려 join을 통해 가져올 때 비효율이 발생할 수도 있다.

그래서 N+1 문제가 발생한다.

결론

N+1 문제는 join을 해서 가져오는 EntityGraph나 join fetch 방법을 사용해서 조인을 통해 하나의 쿼리로 필요한 정보를 가져오도록 해야하는 것이다.

profile
개발을 좋아하는 taketheking 입니다.

0개의 댓글