본문 바로가기

IT/SPRING BOOT

Spring JPA 1+n 이슈에 대해 알아보기

Spring JPA 1+n 이슈에 대해 알아보기

오늘은 JPA 연관관계를 사용하면서 한번은 겪게 되는 1+N 이슈에 대해서 알아보고자 합니다.
  • N+1 이슈가 무엇이고 왜 발생하는 걸까요?
N+1이슈는
연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 조회하는 경우가 발생합니다. 이를 N+1 문제라고 합니다.

먼저 연관관계를 만들어 보기 위해서 Team, Member Entity를 만들어 보겠습니다.

  • NBA 농구에 시카고 불스 팀이 있습니다.
  • 시카고 불스 팀에 포함된 멤버가 있습니다.
  • 연관관계는 일대다, 다대일로 되어있습니다.
// Team.kt
@Entity
class Team (
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long,
      val name: String,

      @JvmSuppressWildcards
      @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
      var members: List<Members>,
)
  • @JvmSuppressWildcards 어노테이션은 제네릭을 자동변환 방지하기 위한 어노테이션입니다. 자세한 설명은 생략합니다.
// Members.kt
@Entity
class Members (

      @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long,
      val name: String,

      @ManyToOne(fetch = FetchType.LAZY)
      val team: Team
)

팀을 조회

팀을 조회하기 전에 팀은 10팀이 있고 한팀의 선수는 10명씩 DB에 저장되어 있다고 가정하겠습니다.

  • 팀을 조회하는 쿼리를 호출
  • 팀을 조회하는 쿼리 결과 row만큼 멤버가 쿼리되는 결과 발생

​ FetchType이 EAGER든 Lazy든 상관없이 동일한 결과가 발생됩니다.

​ Lazy의 경우 데이터를 사용하는 시점에 발생하는 것을 볼 수 있습니다.

N+1의 발생하는 이유는 뭘까요?

jpaRepository에 정의한 인터페이스 메서드를 실행하면 
JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 됩니다.

JPQL은 객체와 필드 이름을 가지고 쿼리하기 때문에
JPQL은 findAll()이란 메소드를 호출하면 select * from Team 쿼리만 실행 됩니다. 
연관관계가 설정되어 있으면 연관된 엔티티 데이터를 사용하는 시점에 지정된 FetchType으로 별도의 호출을 하게 됩니다.
위의 이유에서 N+1의 이슈가 발생하게 되는 것입니다.

해결 방법

  1. Join fetch
  2. EntityGraph
  3. BatchSize
  4. QueryBuilder
  5. FetchMode.SUBSELECT

해결 방법으로는 제가 알고 있는 범위에서는 위 5가지 입니다.

저 같은 경우는 3,4의 경우를 많이 사용합니다.

그럼 각 방법은 어떤 것이고 주의할 점은 어떤 것이 있는지 간략히 알아보겠습니다.

- Join Fetch

-   Join Fetch는 쿼리를 할때 미리 조인(Inner Join)할 쿼리를 만들어서 쿼리를 합니다.
-   단점으로는 FetchType을 적용할 수 없고 데이터를 페이징 할 수 없다는 것입니다

- EntityGraph

`@EntityGraph(attributePaths = ["member"])`
-   데이터를 가져올 때 attributePaths속성으로 필드명을 지정하면 해당 데이터를 같이 가져온다.
-   가져올 때 Join Fetch와 다르게 (outer join) 으로 가져오게 된다.

Join Fetch와 EntityGraph를 사용할 때 주의할 점

- Join fetch나 EntityGraph는 조인문을 사용하여 데이터를 가져오는데 상황에 따라서는 조인한 데이터가 중복으로 가져올 수 있는 문제가 있다. 그래서 중복된 데이터를 제거하고 가져와야 한다.

- Set Collection사용 및  JPQL distinct를 사용하여 중복 데이터 제거

- FetchMode.SUBSELECT

쿼리를 한번으로 해결하는 것이 아니라 서브쿼리를 날려서 해결하는 방법입니다.

Eager으로 설정하면 조회시점에, Lazy으로 설정하면 사용하는 시점에 위의 쿼리가 실행됩니다.

// Team.kt
@Entity
class Team (
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long,
      val name: String,

      @JvmSuppressWildcards
      @Fetch(FetchMode.SUBSELECT)
      @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
      var members: List<Members>,
)

- BatchSize

하이버네이트 org.hibernate.annotations.BatchSize 어노테이션을 이용하여

조회할 때 지정된 size 만큼 SQL의 IN절을 사용해서 조회하는 방법입니다.

// Team.kt
@Entity
class Team (
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long,
      val name: String,

      @JvmSuppressWildcards
      @BatchSize(size=5)
      @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
      var members: List<Members>,
)

Team의 row수만큼 쿼리를 날리지 않고 Team의 id를 SQL IN절을 사용하여 쿼리를 날리게 됩니다.

BatchSize는 IN절에 올 수 있는 최대 인자 개수를 가르킵니다. Member의 데이터가 10개라면 위의 BatchSize가 5이기 때문에 쿼리는 두번 발생하게 되겠습니다.

위의 경우 Eager이기 때문에 BatchSize수 만큼 알아서 쿼리가 두번 나가게 될것이고, 만약 FetchType이 Lazy일 경우 최초에는 BatchSize만큼만 로딩하고 6번째는 데이터 사용시점에 쿼리가 나가게 됩니다.

- 마지막으로 내가 제일 많이 사용하는 QueryBuilder

다양한 플러그인이 존재하겠지만 저같은 경우는 QueryDsl fetchJoin으로 사용합니다.

jpaQueryFactory
        .selectFrom(team)
        .leftJoin(member)
        .fetchJoin()

이런식으로 사용합니다.

정리

N+1은 JPA 연관관계를 맺는 엔티티를 사용하면서 누구나 한번 쯤 겪게되는 문제입니다.

그럴경우 위에서 설명드린 부분으로 문제를 해결 하실 수 있으실 겁니다.

그리고 프로젝트를 하심에 있어서 JPA만으로 비지니스 로직을 모두 만족할 수는 없을거라 생각됩니다.

그래서 QueryDsl을 같이 사용하시기를 추천 드립니다.