진짜 개발자
본문 바로가기

FrameWork/Spring JPA

Spring JPA - JPA N+1 문제 완전 정리

728x90

N+1에 대해 포스팅한지 1년이 넘어 제대로 다시한번 정리하는 차원에서 이번 포스팅에서는

  • N+1이란?
  • N+1이 발생하는 경우
  • X To One 관계 N+1문제와 해결방법
  • X To Many 관계 N+1문제 주의할 점

들에 대해서 알아보도록 하겠습니다.

  • EAGER vs LAZY

그리고 마지막으로는 Eager와 Lazy중 어떤것을 사용하면 좋을지에 대해서도 생각을 해보도록 하겠습니다.

사용 언어는 Kotlin 이며, Spring Boot + JPA를 이용했습니다.

https://github.com/galid1/jpa_study

코드는 위에서 보실 수 있습니다.

1. N+1 이란?

N+1 문제는, JPA의 Entity 조회시 Query 한번 내부에 존재하는 다른 연관관계에 접근할 때 또 다시 한번 쿼리 가 발생하는 비효율적인 상황을 일컫는 말입니다.

테스트는 다음의 두 엔티티를 이용해 진행합니다.

@Entity
class Member(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
    val name: String,
    @ManyToOne(fetch = 테스트에 따라 달라짐)
    val team: Team
)
@Entity
class Team(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
    val name: String
) 

Member와 Team은 N : 1 관계를 가지고 있습니다. 관계에 대한 Fetch Type은 테스트 별로 상이합니다.

2. X to One N+1

    override fun afterPropertiesSet() {
                val team1 = Team(name = "T1")
        val team2 = Team(name = "T2")
        teamRepository.saveAll(listOf(team1, team2))

        val member1 = Member(name = "m1", team = team1)
        member1Id = memberRepository.save(member1).id!!
        val member2 = Member(name = "m2", team = team2)
        member2Id = memberRepository.save(member2).id!!
    }

먼저 위와 같이, 테스트를 위한 데이터를 미리 넣어줍니다. (Spring에서 제공하는 bean 생명주기 인터페이스를 이용하여 객체가 생성되는 시점에 자동으로 생성하여 넣어주도록 했습니다.)

N+1은 다음과 같은 조건에서 발생합니다.

2.1 첫번째 경우

  • 다른 엔티티와의 관계에 대한 fetchType이 LAZY 이다.

설정

@ManyToOne(fetch = FetchType.LAZY)

위와 같이 Member의 Team에 대한 fetchType을 변경합니다.

fun getMemberWithTeam() {
        memberRepository.findById(member1Id)
            .get()
            .let {
                println("member : ${it.name}")
                println("team : ${it.team.name}")
            }
}

위 와 같이, team을 조회합니다.

결과

결과 쿼리를 보면, 그림과 같이 두번의 쿼리가 요청된 것을 볼 수 있습니다.

해결

fetchType을 EAGER로 변경합니다.

스크린샷 2021-08-15 오후 10.11.01

Join을 통해 하나의 쿼리만 요청됨을 확인할 수 있습니다.

2.2 두번째 경우

  • FetchType이 EAGER 이지만, JPQL을 사용해 하나만을 조회했다.

두번째 경우는 조금 특이합니다. FetchType이 EAGER이지만 최적화를 위해 JPQL을 직접 작성하는 경우, N+1이 발생할 수 있습니다.

설정

스크린샷 2021-08-15 오후 10.18.25

위와 같이 EAGER로 fetchType을 설정합니다.

@Service
@Transactional
class TestService (  
    private val em: EntityManager
) {
    fun getMember() {
        em.createQuery("select m from Member m", Member::class.java)
            .resultStream
            .forEach {
                println("member : ${it.name}")
            }
    }
} 

위와 같이, EntityManager를 이용해, Member만을 조회합니다.

결과

스크린샷 2021-08-15 오후 10.21.00

N+1이 발생합니다.

=> JPQL이 Member만을 조회 하지만, 엔티티 생성 이후 연관관계가 EAGER이기 때문에 추가적으로 쿼리가 발생합니다.

해결

Fetch Join 을 통해서 해결합니다.

    fun getMember() {
        em.createQuery(
            "select m from Member m join fetch m.team t"
            , Member::class.java)
            .resultStream
            .forEach {
                println("member : ${it.name}")
            }
    }

위와 같이, fetch join을 사용합니다.

3. X to Many N+1 해결 시 주의할 점

N+1이 발생하는 경우는 X to One 관계에서 알아본 상황들과 동일합니다. 따라서, 이번에는 X to Many 관계에서 N+1을 해결하며 주의해야 하는 점들에 대해서 알아보도록 하겠습니다.

    override fun afterPropertiesSet() {
        val team1 = Team(name = "T1", members = listOf(
            Member(name = "m1"),
            Member(name = "m2")
        ))

        val team2 = Team(name = "T2", members = listOf(
            Member(name = "m3"),
            Member(name = "m4")
        ))
        teamRepository.saveAll(listOf(team1, team2))
    }

위와 같이 테스트를 위한 데이터를 자동 생성되도록 해줍니다.

@Entity
class Team(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
    val name: String,
    @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST])
    val members: List<Member> = ArrayList()
)

또한 Team Entity에 위와 같이 연관관계도 추가해줍니다.

3.1 EAGER시 위험

그냥 EAGER 전략으로 모두 설정하면 간편하지 않냐고 생각할 수 있습니다.

하지만 연관관계가 여러 단계에 걸쳐서 이루어져 있다면, 한번의 조회시 엄청난 조인문이 발생할 수 있습니다.

데이터가 필요하지 않은 경우 에도 말이죠.

또한 EAGER로 인해서 어떤 쿼리가 발생할지 예측이 정말 어려워집니다. 따라서, 연관관계 fetch type은 LAZY로 설정하고, 필요시 fetch join으로 해결하는 편이 좋습니다.

3.2 X to Many Fetch Join 불가능 (중복 데이터 생성으로 인해)

    fun getTeamWithMember() {
        em.createQuery("select t from Team t join fetch t.members", Team::class.java)
            .resultList
            .stream()
            .forEach {
                println("team : ${it.name}")
                println("========= members ==========")
                it.members.stream()
                    .forEach { println("member : ${it.name}") }
            }
    }

위와 같이, N+1을 방지하기 위해서 Team을 조회하며 Member를 Fetch Join해보도록 하겠습니다.

스크린샷 2021-08-16 오전 9.38.13

N+1은 발생하지 않으며, 한번에 조인으로 가져온것을 볼 수 있습니다.

문제점 (중복 데이터 발생)

스크린샷 2021-08-16 오전 9.38.41

??? 그런데, team: T1, T2 가 중복해서 로그가 찍히며, 동시에 그에 따른 member도 중복해서 출력됨을 볼 수 있습니다.

스크린샷 2021-08-16 오전 9.53.21

이유는 위와 같습니다. Team과 Member를 Join하면, DB상에는 위의 Result를 만들어냅니다.

이를 토대로 Application에서 객체를 생성하게 되면, Team이 중복되어 생성되게 됩니다.

해결(Distinct)

fun getTeamWithMember() {
    em.createQuery("select distinct t from Team t join fetch t.members", Team::class.java)
        .resultList
        .stream()
        .forEach {
            println("team : ${it.name}")
            println("========= members ==========")
            it.members.stream()
                .forEach { println("member : ${it.name}") }
        }
}

해결방법은 간단합니다. 조회시 select distinct로 조회를 하면 됩니다.

스크린샷 2021-08-16 오전 10.35.50

결과가 올바르게 나오는것을 확인할 수 있습니다.

Distinct

![스크린샷 2021-08-16 오전 10.37.07](/Users/jeonjun-yeob/Desktop/스크린샷 2021-08-16 오전 10.37.07.png)

distinct는 db조회시, 중복된 결과를 제외하는 키워드 입니다. 하지만, distinct는 한 db row가 완전히 일치해야만 제외가 됩니다.
따라서 distinct를 추가한 쿼리를 실제 DB에 날려보면, 똑같이 Many 쪽에 맞추어 1쪽의 row가 중복되는 것을 볼 수 있습니다.

때문에 distinct를 이용하면, jpa에서 distinct 키워드에 대해서 추가적으로 조회 시 똑같은 id를 가지는 값(중복)을 제거해줍니다.

3.3 페이징 불가

하지만, 이렇게 N+1을 해결하면, 페이징이 불가능하다는 단점이 존재합니다.

우리는 Team을 기준으로 페이징을 하고 싶습니다. 예를들어, DB로부터 2개씩 팀 row를 조회해라 라고 페이징을 하고 싶습니다.

스크린샷 2021-08-16 오전 9.53.21

하지만, 위와 같이 One To Many 조인을 하면, Many 측에 맞추어 One의 데이터가 중복되게 되므로, 원하는 페이징이 불가능하게 됩니다.

정리하자면, 아래와 같습니다.

  1. 일대다 페치조인을 하는 경우, Many에 맞추어 One의 데이터가 중복됩니다.
  2. distinct 키워드를 이용해도 db에서는 하나의 로우가 완벽히 일치해야만 제거를 합니다.
  3. 따라서 JPA에서 추가적으로 id가 같은 값에 대해서 중복 제거를 해주어야 합니다.
  4. 때문에 setFristResult(1), setMaxResults(2)라고 하더라도, 데이터가 10만건이면 이를 모두 메모리에 로드한 뒤 중복을 제거하고, 올바른 데이터를 만든 다음 1번째부터 2개를 조회하게 됩니다.

JPA의 인메모리 페이징(위험)

스크린샷 2021-08-16 오전 10.47.52

따라서, JPA에서는 위와 같은 상황에서

스크린샷 2021-08-16 오전 10.48.57

다음과 같이 Warn 로그와 함께, 메모리에서 정렬을 합니다.

때문에, 이 방법은 Out Of Memory를 유발할 수 있으므로 매우 위험한 방법입니다.

해결(Hibernate @BatchSize)

앞서 X to Many 관계에서 fetch join을 이용하면 페이징이 불가능하다고 말씀드렸습니다. 하지만 Hibernate의 batchsize를 이용하면 가능합니다 !

fun getTeamWithMember() {
    em.createQuery("select t from Team t", Team::class.java)
        .resultList
        .stream()
        .forEach {
            println("team : ${it.name}")
            println("========= members ==========")
            it.members.stream()
                .forEach { println("member : ${it.name}") }
        }
}

team을 다시 fetch join하기 이전으로 되돌립니다.

스크린샷 2021-08-16 오전 11.29.08

application.properties (yml) 파일에 default_batch_fetch_size 를 추가합니다.

스크린샷 2021-08-16 오전 11.30.17

이렇게 하면, 한번의 지연로딩은 발생하지만, member의 수만큼 쿼리가 발생하지는 않습니다.

바로, 지연로딩이 발생할 시 default_batch_fetch_size 만큼 같이 조회해오기 때문입니다.

정리

X to One 관계는 fetch joineager 페치 전략 을 이용해서 N+1 문제를 해결할 수 있습니다.

X to Many 관계는 fetch join 시 중복데이터가 발생합니다.
distinct 키워드로 해결할 수 있지만, 페이징이 불가능해 집니다.
이 문제는 default_batch_fetch_size 설정을 통해 해결할 수 있습니다.

EAGER 전략의 경우 임시적으로 성능을 높일 수 있지만, 관계가 여러 단계에 걸쳐 이루어져 있는 경우 필요하지 않은 데이터도 한번에 조회하기 때문에 성능이 되려 저하될 수 있고, 어떤 쿼리가 발생할지 예측하기 어렵습니다.

때문에 LAZY 연관관계 설정 후 필요에 따라 fetch join을 하는 것이 좋습니다.