코드닭 2024. 8. 2. 18:32
    fun getStepOverview(enterpriseId: Long, stepId: Long): StepOverviewResponse {
        val step = stepRepository.findByIdOrNull(stepId)
            ?: throw ModelNotFoundException("Step", stepId)
        if (enterpriseId != step.mvpTest.enterpriseId)
            throw NoPermissionException(MvpTestErrorMessage.NOT_AUTHORIZED.message)
        //테스트 참여자, 해당 step의 리포트 한꺼번에 불러오기
        val members = memberTestRepository.findAllByTestId(step.mvpTest.id!!)
        val reports = reportRepository.findAllByStepId(stepId)
        //각각의 참여자, 리포트를 memberId로 묶어주고 반환 형태 만들기
        val reportMap = reports.associateBy({ it.memberTest.member.id }, { it.state })
        val reportStatusResponse = members.map { memberTest ->
            val reportState = reportMap[memberTest.member.id] ?: ReportState.MISSING
            ReportStatusResponse(
                memberId = memberTest.member.id!!,
                memberEmail = memberTest.member.email,
                completionState = reportState
            )
        }
        //참여율 백분율로 계산
        val approvedCount = reportStatusResponse.count { it.completionState == ReportState.APPROVED }
        val totalCount = reportStatusResponse.size
        val completionRate = if (totalCount > 0) {
            ((approvedCount.toDouble() / totalCount) * 100).toInt()
        } else {
            0
        }
        return StepOverviewResponse(
            completionRate = completionRate,
            reportStatusList = reportStatusResponse
        )
    }

에서 발생한 N+1 문제

 

 

 

해당 로직에서 쿼리가 나가는 부분은 총 3 부분이다

val step = stepRepository.findByIdOrNull(stepId)

////////////////////////////////////
val members = memberTestRepository.findAllByTestId(step.mvpTest.id!!)

////////////////////////////////////
 val reports = reportRepository.findAllByStepId(stepId)

 

테스트를 위해서 db에 memberTest 4개, member 4개, report3 개를 넣었을 때 

N+1이 발생하지 않을 것이라고 생각한다면 총 3번의 쿼리가 나가야 하지만

(report를 쓰지 않은 member는 조회되지 않는다)

존재하는 report에 매핑된 memberTest에서 해당 memberTest의 member의 필드를 이요하기위해

각각의 member를 조회하는 3번의 쿼리가 더 발생하고 있다

 

문제 발견 :

현재 memberTest의 member를 각각 조회하는 과정에서 n+1 문제 발생

(memberTest 가 객체 member를 알고 있는 상황에서 memberTest의 member의 필드를 사용하려고 하고 있음)

 

 

해결 방안 :

N+1 문제의 해결방안으로는 EAGER_LOADING, fetchJoin, batch szie 설정 등이 있다

 

 EAGER_LOADING 같은 경우는 다른 memberTest 조회 시에도 member를 사용한다는 확신이 없는 상태에서

다른 곳에서 memberTest를 조회 시 불필요한 데이터가 조회될 수 있을 거라 판단돼서 패스

 

fetchJoin은 이 메서드에서만 MemeberTest를 찾을 때 연관된 member 도 같이 찾아옴으로 문제를 해결할 수 있다

 

batch szie 설정 같은 경우 설정을 해도 쿼리문이 여러 번 나가는 것은 같으므로 N+1을 근본적으로 해결하는 방안이 아니라고 판단돼서 패스

 

해결 과정 :

memberTestRepository.findAllByTestId(step.mvpTest.id!!)에서 연관된 member 엔티티를 함께 로드하도록 수정할 필요가 있음

이를 통해 각 memberTest 조회 시 추가 쿼리가 발생하지 않도록 할 수 있음

 

findAllByTestId 같은 경우 다른 곳에서도 사용되는 query 이기 때문에 여기서만 사용할 fetchJoin 이 적용된 query를 하나 만들 필요가 있다

@Query("SELECT mt FROM MemberTest mt JOIN FETCH mt.member WHERE mt.test.id = :testId")
fun findAllAndMemberByTestId(testId: Long): List<MemberTest>

딱히 복잡한 query 가 아니기 때문에 Jpa의 query 어노테이션을 이용해 만들어 주었다

 

해결 :

    fun getStepOverview(enterpriseId: Long, stepId: Long): StepOverviewResponse {
        val step = stepRepository.findByIdOrNull(stepId)
            ?: throw ModelNotFoundException("Step", stepId)
        if (enterpriseId != step.mvpTest.enterpriseId)
            throw NoPermissionException(MvpTestErrorMessage.NOT_AUTHORIZED.message)
        //테스트 참여자, 해당 step의 리포트 한꺼번에 불러오기
        val members = memberTestRepository.findAllAndMemberByTestId(step.mvpTest.id!!)
        val reports = reportRepository.findAllByStepId(stepId)
        //각각의 참여자, 리포트를 memberId로 묶어주고 반환 형태 만들기
        val reportMap = reports.associateBy({ it.memberTest.member.id }, { it.state })
        val reportStatusResponse = members.map { memberTest ->
            val reportState = reportMap[memberTest.member.id] ?: ReportState.MISSING
            ReportStatusResponse(
                memberId = memberTest.member.id!!,
                memberEmail = memberTest.member.email,
                completionState = reportState
            )
        }
        //참여율 백분율로 계산
        val approvedCount = reportStatusResponse.count { it.completionState == ReportState.APPROVED }
        val totalCount = reportStatusResponse.size
        val completionRate = if (totalCount > 0) {
            ((approvedCount.toDouble() / totalCount) * 100).toInt()
        } else {
            0
        }
        return StepOverviewResponse(
            completionRate = completionRate,
            reportStatusList = reportStatusResponse
        )
    }

이제 memberTest를 가져오는 쿼리가 member 도 같이 가져오고 있기 때문에 member 필드를 이용하기 위한 추가적인

쿼리가 나가지 않을 것으로 예상하고 테스트를 돌려주었다

 

결과는 3개의 쿼리가 나간다

 

완료