N+1
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개의 쿼리가 나간다
완료