N+1

노지환·2022년 1월 21일
0

조회할 때, 1개의 쿼리만 날릴 것으로 생각했으나, 나올 필요가 없는 조회 쿼리가 N개 더 발생하는 문제

왜 일어날까?

  • JPA같은 자동화 쿼리문이 생겨나면서 발생하는 문제입니다.
    • JPA: 객체에 대해서 조회할 때 다양한 연관관계 매핑에 의해서 관계가 맺어진 다른 객체가 함께 조회되는 경우에 N+1 발생.

Test Entity

@Entity
class Account(
    var name: String,

//    @BatchSize(size = 2)
//    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
		@OneToMany(mappedBy = "account", fetch = FetchType.EAGER)
    var folderList: List<Folder> = mutableListOf(),

//    @BatchSize(size = 2)
//    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
//    var articleList: List<Article> = mutableListOf(),
// 2개 이상의 Collection Join을 테스트할 때 사용할 객체관계!
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L
)
@Entity
class Folder(name: String, account: Account) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0L
    var name: String = name

    @ManyToOne(fetch = FetchType.EAGER)
    var account: Account = account
}
@Entity
class Article(name: String, account: Account) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0L
    var name: String = name

    @ManyToOne(fetch = FetchType.EAGER)
    var account: Account = account
}

Account가 Folder와 Article을 @OneToMany로 가지고 있는 것을 확인할 수 있습니다!

N+1이 일어날 부분들 총정리

즉시로딩(EAGER)

조회할 때부터 연관관계가 매핑된 객체들을 싹다 긁어모아옵니다.

	@Test
	fun accountFindTestWithEager() {
		println("------------------------------start")
		val findAll = accountRepository.findAll()
		val account = findAll
		println("------------------------------end")
		for(a in account) {
			val folderList = a.folderList
			for(b in folderList)
				println(b.name)
		}
	}

참고사항

  • 데이터베이스에 저장된 Account의 개수는 3개

테스트 결과

우리가 원하는 쿼리는 단 하나

추가적으로 날아가는, 조회 쿼리문

우리가 JPA에게 원하는 결과는 findAll()이라는 쿼리 하나만 날아가는 것을 원하지만, 결과는 그렇지 않습니다.

데이터베이스에 존재하는 account의 개수(현재 테스트에서는 3 == N)만큼 추가로 쿼리가 날아갑니다...!

지연로딩(LAZY)

즉시로딩에서 문제가 되는 조회에서의 N+1을 해결하기 위한 방법입니다.

→ 하지만, 첫 조회때는 1개의 쿼리만 날려서 괜찮아보이나, 연관관계 매핑된 객체를 사용할 때 따로 쿼리가 날아가서 N+1 문제는 여전히 발생합니다!

@Test
	fun accountFindTestWithLazy() {
		println("------------------------------start")
		val accountList = accountRepository.findAll()
		println("------------------------------end")

		for(a in accountList)
			println(a.folderList.size)
	}

참고사항

@Entity
class Account(
...
		@OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
		    var folderList: List<Folder> = mutableListOf(),
...
)

FetchType을 LAZY로 변경해야합니다!

테스트 결과

조회 자체는 하나의 쿼리문으로 잘 간 것 같지만...?

조회 후 for문을 통해 객체들을 사용할 때, N+1 발생..!

조회 자체는 우리가 바라는대로 쿼리문이 하나로 날아가지만,

가져온 데이터에 접근하여 연관관계가 매핑된 객체를 사용할 때마다 쿼리문이 날아가는 것을 확인할 수 있습니다!

fetch join

지연로딩을 통해 해결이 될 줄 알았지만, 우리가 원하는 결과는 아니었습니다!

그래서 나온 방법이 fetch join!

fetch join을 적용하는 방법에는 두가지가 존재합니다.

interface AccountRepository: JpaRepository<Account, Long> {

		// Query문 하나만을 사용한 fetch join
    @Query("select distinct a from Account a left join fetch a.folderList")
    fun findAllJPQLFetch(): List<Account>

		// @EntityGraph를 통해 사용하는 fetch join
    @EntityGraph(attributePaths = ["folderList"], type = EntityGraph.EntityGraphType.FETCH)
    @Query("select distinct a from Account a left join a.folderList")
    fun findAllEntityGraph(): List<Account>
		
		...
}

차이점?

  • 쿼리문을 통한 fetch join을 사용한다면, 쿼리문에 하드코딩해야한다는 단점이 존재합니다!
  • 쿼리문에 fetch를 사용하는대신, @EntityGraph를 통하여 하드코딩을 줄이는 방식으로 진행이 가능합니다.

테스트 결과

하나의 쿼리문으로 필요한 것들을 모두 가져오는 모습..!

그러나, 이렇게 fetch join만으로도 해결할 수 없는 부분들이 있는데요..!

  • pagination
  • collection join

두 부분에서는 여전히 문제가 발생합니다!

fetch join problem

pagination

@Test
	fun pagingFetchJoinTest() {
		println("------------------------------start")
		val pageRequest = PageRequest.of(0, 2)
		val findAllPage = accountRepository.findAllPage(pageRequest)
		println("------------------------------end")
		for(a in findAllPage) println(a.folderList.size)
	}

참고사항

interface AccountRepository: JpaRepository<Account, Long> {
		...
		@EntityGraph(attributePaths = ["folderList"], type = EntityGraph.EntityGraphType.FETCH)
		    @Query("select distinct a from Account a left join a.folderList")
		    fun findAllPage(pageable: Pageable): Page<Account>
		...
}

테스트 결과

잘... 된 거 같은데?

문제점

2022-01-21 11:41:54.315  WARN 67141 --- [           main] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

아주아주 유심히 보셨다면 봤을 수도 있고 warning이니까~ 하고 넘기기 쉽지만, 생각보다 큰 문제로 다가올 수 있습니다!

해석을 해보면

→ 데이터베이스에서 필요한 모든 데이터들을 일단 다 가져와서 인메모리에 저장했어!

→ limit와 offset에 맞게 인메모리에서 작업한 후에 준거야!

라고 합니다.

지금 같은 경우는 데이터가 얼마 없기 때문에 인메모리에 가져와서 하는 경우여도 괜찮지만, 데이터가 많아질수록 OOM(Out Of Memory)이 발생할 문제가 있습니다!

언제 일어나는 걸까?

~ToMany인 객체를 가져올 때

→ 다르게 말하면, ~ToOne인 객체가 포함된 Entity는 그냥 pagination해도 된다는 것!

해결책

batchSize로 날릴 쿼리문의 정도를 제한하기!

테스트 코드

@Test
	fun pagingBatchSizeTest() {
		println("------------------------------start")
		val page = accountRepository.findAll(PageRequest.of(0, 2))
		println("------------------------------end")
		for(a in page) println(a.folderList.size)
	
	}

테스트결과

됐다!

정상적인 pagination을 위한 쿼리문이 날아간 후에 지정한 batch size만큼의 객체를 받아올 수 있게 쿼리를 하나 날립니다!
그러면 지정해 놓은 100개의 객체를 가지고 와서 2개 이상 필요할 때 저장된 객체를 사용하면 됩니다.
물론 쿼리가 한번 날아가고 끝이 나는것은 아니지만, N+1보다는 적은 개수의 쿼리가 날아감으로써 해결할 수 있습니다.

tip

batch size는 코드 기준으로 folder가 아닌 account의 갯수를 의미한다.

→ 연관관계를 가지고 있는 객체를 기준으로 카운트된다고 이해하면 될 거 같음!

2개 이상의 collection join

언제 일어나는걸까?

사전조건

@Entity
class Account(
    var name: String,

//    @BatchSize(size = 100)
    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
    var folderList: List<Folder> = mutableListOf(),

//    @BatchSize(size = 100)
    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
    var articleList: List<Article> = mutableListOf(),

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L
)
interface AccountRepository: JpaRepository<Account, Long> {
		...
    @EntityGraph(attributePaths = ["folderList", "articleList"], type = EntityGraph.EntityGraphType.FETCH)
    @Query("select distinct a from Account a left join a.folderList")
    fun findAllEntityGraph2(): List<Account>
		...
}

테스트를 위해서 주석처리해놨던 articleList를 다시 살려줍니다.

또한, @EntityGraph를 통한 fetch join을 해준다면 일어나는 문제를 확인할 수 있습니다!

@Test
	fun collectionFetchJoinTest() {
		println("------------------------------start")
		accountRepository.findAllEntityGraph2()
		println("------------------------------end")
		// Batch size를 작성함으로써 해결하는 방법에서는 fetch join을 사용해서는 안된다.
		// fetch join이 batch size보다 먼저 적용되기 때문에 batch size는 무시되고 exception이 발생한다.
	}

결과

Caused by: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.n1.entity.Account.folderList, com.example.n1.entity.Account.articleList]

MutipleBagFetchException이 발생하는 것을 확인할 수 있습니다!

~ToMany인 연관관계가 두개 이상이라면 exception을 던짐으로써 막는 것을 확인할 수 있습니다.

해결방법

  • set 자료구조를 사용하면 exception이 던져지지는 않습니다! → 하지만, ~ToMany pagination의 고질적인 문제인 OOM을 해결할 수는 없습니다.

따라서, BatchSize를 통한 해결이 필요합니다.

사전 조건

@Entity
class Account(
		...
    @BatchSize(size = 2) // 데이터가 얼마 없기 때문에 batch size가 어떻게 적용되는지 보기 위하여 size를 2로 하였음.
    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
    var folderList: List<Folder> = mutableListOf(),

    @BatchSize(size = 2)
    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
    var articleList: List<Article> = mutableListOf(),
		...
)
//    @EntityGraph(attributePaths = ["folderList", "articleList"], type = EntityGraph.EntityGraphType.FETCH)
    @Query("select distinct a from Account a left join a.folderList")
    fun findAllEntityGraph2(): List<Account>

Entity에 존재하는 연관관계들에게 @BatchSize를 걸고, fetch join은 사용하지 않으면 정상적으로 동작이 가능합니다!

왜? fetch join은 사용하지 않는거야?

batch size를 사용한다면, fetch join을 걸면 안됩니다!

이유는 fetch joinbatch size보다 우선적으로 적용되기 때문에, 설정했던 batch size가 무시되기 때문입니다!

결과

다시 test 코드를 실행해보면,

batchsize인 2 만큼 불러와서 진행하는 모습을 확인할 수 있다.

test 데이터가 부족하여, @BatchSize를 2로 진행했지만, 원하는 결과가 나온 것을 확인할 수 있습니다!

N+1 총정리

즉시로딩 → N+1 문제 발생

지연로딩 → 조회할 때는 쿼리가 하나만 날아가지만, 객체 접근시 N+1발생

fetch join → 한 쿼리에 원하는 데이터를 가져오지만, pagination과 2개 이상의 collection join에서 예상치 못한 문제 발생

pagination → @BatchSize 설정을 통한 OOM 문제 해결

2개 이상의 collection join → @BatchSize 설정을 통한 MutipleBagFetchException 해결 & OOM 문제 해결!

profile
기초가 단단한 프로그래머 -ing

0개의 댓글