make it simple
article thumbnail

문제

  • JPA를 사용하면 흔히 발생하는 문제 중 하나가 N+1 이다. N+1을 알기전 JPA의 특성을 먼저 알아야한다.
  • JPA는 두 도메인이 @OneToMany, @ManyToOne, @OneToOne, @ManyToMany와 같이 연관관계를 맺어 손쉽게 별도의 쿼리 없이 연관관계 객체를 불러 올 수 있다. 하지만 이렇게 편리한 기능에도 잘못 쓰면 사용자의 의도와 다르게 N+1과 같은 문제가 발생한다. N+1 문제는 사용자가 하나의 객체를 조회하기 위해 1번의 쿼리를 실행했지만, JPA 내부에서는 해당 객체와 연관된 N개의 객체를 조회하기 위해 추가로 N번의 쿼리를 실행하는 상황을 말한다.
  • 사실 작은 서비스에서는 성능상 크게 문제가 되지 않지만 언제든 서비스는 확장될 수 있음을 염두해두고 개발하는것이 좋다. 또한 나중에 서비스가 커지고 코드가 복잡해질수록 이러한 문제는 찾기 어려워진다. 초기에 방지하는것이 좋다. 

배경

러닝 크루가 있다. 한 러닝 크루에는 여러명에 멤버가 존재한다. 

밑처럼 Crew : Crew Members 1 : N 관계이다.

  • Crew
@Entity
@Table(name = "crews")
class Crew(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long,
        val name: String
) {

    @OneToMany(mappedBy = "crew", cascade = [CascadeType.ALL], fetch = FetcType.EAGER)
    val members: MutableList<CrewMember> = mutableListOf()

}
  • Crew Members
@Entity
@Table(name = "crew_members")
class CrewMember(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long,
        val name: String,
        @ManyToOne
        @JoinColumn(name = "crewIds")
        val crew: Crew
)

 

테스트

@SpringBootTest
class CrewRepositoryTest @Autowired constructor(
        private val crewRepository: CrewRepository,
        private val crewMemberRepository: CrewMemberRepository,
        private val entityManager: EntityManager
) {

    @BeforeEach
    fun setup() {

        val crew = Crew(
                id = 1,
                name = "crew_1"
        ).apply {
            val members: MutableList<CrewMember> = getCrewMembers(crew = this)
            this.members.addAll(members)
        }

        crewRepository.save(crew)


        // 엔티티 매니저로 clear해줘야 독립적인 transactional에서 영속성 컨텍스를 제거하고 db를 조회한다.
        entityManager.clear()
    }

    @AfterEach
    fun tearDown() {
        crewMemberRepository.deleteAllInBatch()
        crewRepository.deleteAllInBatch()
    }

    @Test
    @DisplayName("크루를 조회할 경우 N+1이 발생하는지 테스트한다.")
    @Transactional
    fun test_N_PLUS_ONE() {
        println("================== START ====================")

        val crews = crewRepository.findAll()
        crews.forEach {
            it.members.first().name
        }

        println("================== END ====================")
    }

    private fun getCrewMembers(crew: Crew): MutableList<CrewMember> {
        val members = mutableListOf<CrewMember>()

        for (i: Int in 1..5) {
            members.add(
                    CrewMember(
                            id = i.toLong(),
                            name = "test${i}",
                            crew = crew
                    )
            )
        }
        return members
    }


}
@SpringBootTest
class CrewRepositoryTest @Autowired constructor(
        private val crewRepository: CrewRepository,
        private val crewMemberRepository: CrewMemberRepository,
        private val entityManager: EntityManager
) {

    @BeforeEach
    fun setup() {

        val crew = Crew(
                id = 1,
                name = "crew_1"

        ).apply {
            val members: MutableList<CrewMember> = getCrewMembers(crew = this)
            this.members.addAll(members)
        }

        crewRepository.save(crew)


        // 엔티티 매니저로 clear해줘야 독립적인 transactional에서 영속성 컨텍스를 제거하고 db를 조회한다.
        entityManager.clear()
    }

    @AfterEach
    fun tearDown() {
        crewMemberRepository.deleteAllInBatch()
        crewRepository.deleteAllInBatch()
    }

    @Test
    @DisplayName("크루를 조회할 경우 N+1이 발생하는지 테스트한다.")
    @Transactional
    fun test_N_PLUS_ONE() {
        println("================== START ====================")

        val crews = crewRepository.findAll()
        crews.forEach {
            it.members.first().name
        }

        println("================== END ====================")
    }

    private fun getCrewMembers(crew: Crew): MutableList<CrewMember> {
        val members = mutableListOf<CrewMember>()

        for (i: Int in 1..5) {
            members.add(
                    CrewMember(
                            id = i.toLong(),
                            name = "test${i}",
                            crew = crew
                    )
            )
        }
        return members
    }


}

 

<결과>

 

현재는 데이터가 하나라 1개의 추가 쿼리가 나갔지만 crew의 데이터가 많다면 N개의 쿼리가 추가적으로 발생해서 성능에 문제가 생길 수 있다.

위의 테스트 코드에선 findAll()을 사용하여 crew의 리스트를 불러왔다. 하지만 위처럼 crew 조회 쿼리가 나가고 crew_members를 조회하는 쿼리도 나갔다.이유가 뭘까?

 

Crew 엔티티에서 밑과 같이 fetch가 EAGER로 설정되어있다.

EAGER란? 

  • 즉시 로딩: 연관된 객체를 즉시 조회해야해서 연관된 객체를 조회하는 N개의 쿼리가 발생한다.
    @OneToMany(mappedBy = "crew", cascade = [CascadeType.ALL], fetch = FetcType.EAGER)
    val members: MutableList<CrewMember> = mutableListOf()

 

crewMember에 있는 필드(name)를 조회하는 로직이 포함되어 있다. 이럴 경우, fetchtype이 LAZY일 경우에도 도 N+1 쿼리가 발생한다.

LAZY란?

  • 지연 로딩: 연관된 객체를 참조할 때 쿼리가 발생한다.
val crews = crewRepository.findAll()
crews.forEach {
    it.members.first().name
}

 

해결 방법

쿼리에 Fetch Join 사용 -> 쿼리 1개 발생

interface CrewRepository : JpaRepository<Crew, Long> {

    @Query("select c from Crew c join fetch c.members")
    fun findAllBy():List<Crew>
}
@Test
@DisplayName("크루를 조회할 경우 N+1이 발생하는지 테스트한다.")
@Transactional
fun test_N_PLUS_ONE() {
    println("================== START ====================")

    val crews = crewRepository.findAllBy()
    crews.forEach {
        it.members.first().name
    }

    println("================== END ====================")
}

@EntityGraph 사용 -> Left join 이 되며 쿼리 1개가 발생된다. 

@EntityGraph(attributePaths = ["members"], type = EntityGraph.EntityGraphType.FETCH)
fun findAllBy(): List<Crew>
@Test
@DisplayName("크루를 조회할 경우 N+1이 발생하는지 테스트한다.")
@Transactional
fun test_N_PLUS_ONE() {
    println("================== START ====================")

    val crews = crewRepository.findAllBy()
    crews.forEach {
        it.members.first().name
    }

    println("================== END ====================")
}

 

주의사항: 만약 outer로 쿼리가 나갈경우 중복 데이터가 발생함으로  Crew 엔티티에 members 필드를 List -> Set으로 데이터 타입을 변경해야한다. 그래야 중복 데이터가 제거된다. 또는, 쿼리에 select절에 distinct를 붙혀도된다.

@OneToMany(mappedBy = "crew", cascade = [CascadeType.ALL], fetch = FetchType.EAGER)
val members: MutableSet<CrewMember> = mutableSetOf()

 

 

 

결론

  • 상황에 따라 fetch join를 쓸지 @EntityGraph를 쓸지 또, 컬렉션 데이터타입은 set으로 쓸지 다르다. 결론은 JPA가 모든걸 처리한다고 생각하지말고 쿼리를 주의 깊게 보고 상황에 맞춰 튜닝하는게 좋다. 지금 그냥 넘어가면 미래의 나나 다른 개발자가 고생한다는 것을 항상 염두해 두자...
profile

make it simple

@keep it simple

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!