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
로 변경합니다.
Join을 통해 하나의 쿼리만 요청됨을 확인할 수 있습니다.
2.2 두번째 경우
- FetchType이
EAGER
이지만, JPQL을 사용해 하나만을 조회했다.
두번째 경우는 조금 특이합니다. FetchType이 EAGER
이지만 최적화를 위해 JPQL을 직접 작성하는 경우, N+1이 발생할 수 있습니다.
설정
위와 같이 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
만을 조회합니다.
결과
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해보도록 하겠습니다.
N+1은 발생하지 않으며, 한번에 조인으로 가져온것을 볼 수 있습니다.
문제점 (중복 데이터 발생)
??? 그런데, team: T1, T2
가 중복해서 로그가 찍히며, 동시에 그에 따른 member도 중복해서 출력됨을 볼 수 있습니다.
이유는 위와 같습니다. 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
로 조회를 하면 됩니다.
결과가 올바르게 나오는것을 확인할 수 있습니다.
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를 조회해라
라고 페이징을 하고 싶습니다.
하지만, 위와 같이 One To Many 조인을 하면, Many 측에 맞추어 One의 데이터가 중복되게 되므로, 원하는 페이징이 불가능하게 됩니다.
정리하자면, 아래와 같습니다.
- 일대다 페치조인을 하는 경우, Many에 맞추어 One의 데이터가 중복됩니다.
- distinct 키워드를 이용해도 db에서는 하나의 로우가 완벽히 일치해야만 제거를 합니다.
- 따라서 JPA에서 추가적으로 id가 같은 값에 대해서 중복 제거를 해주어야 합니다.
- 때문에 setFristResult(1), setMaxResults(2)라고 하더라도, 데이터가 10만건이면 이를 모두 메모리에 로드한 뒤 중복을 제거하고, 올바른 데이터를 만든 다음 1번째부터 2개를 조회하게 됩니다.
JPA의 인메모리 페이징(위험)
따라서, JPA에서는 위와 같은 상황에서
다음과 같이 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하기 이전으로 되돌립니다.
application.properties (yml) 파일에 default_batch_fetch_size
를 추가합니다.
이렇게 하면, 한번의 지연로딩은 발생하지만, member의 수만큼 쿼리가 발생하지는 않습니다.
바로, 지연로딩이 발생할 시 default_batch_fetch_size
만큼 같이 조회해오기 때문입니다.
정리
X to One 관계는 fetch join
과 eager 페치 전략
을 이용해서 N+1 문제를 해결할 수 있습니다.
X to Many 관계는 fetch join
시 중복데이터가 발생합니다.distinct
키워드로 해결할 수 있지만, 페이징이 불가능해 집니다.
이 문제는 default_batch_fetch_size
설정을 통해 해결할 수 있습니다.
EAGER 전략의 경우 임시적으로 성능을 높일 수 있지만, 관계가 여러 단계에 걸쳐 이루어져 있는 경우 필요하지 않은 데이터도 한번에 조회하기 때문에 성능이 되려 저하될 수 있고, 어떤 쿼리가 발생할지 예측하기 어렵습니다.
때문에 LAZY 연관관계 설정 후 필요에 따라 fetch join을 하는 것이 좋습니다.