[Linkllet] 흔한 N+1 문제 해결하기
1. 문제 되는 상황
팀원 중 한 분이 폴더 목록을 조회할 때, 해당 폴더 내부에 몇 개의 링크가 저장되어 있는지 그 원소의 수 또한 함께 전달해 달라 요청해 주셨습니다.
이 카톡을 처음 봤을 때 시간이 늦은 시간이었던 지라 매우 간단하게 생각하고 다음날 바로 구현하겠다 하고 눈을 감았습니다만....
그럴 때 있잖아요? 뭔가 실수한 거 같아서 눈은 감고 있지만 계속 생각날 때?
머릿속에 스쳐 지나가는 N+1 쿼리 문제.... 아 이거 폴더마다 count 쿼리가 날아갈 텐데... 아읔....
일단 자야지 하면서.... 계속 어떻게 구현할지 생각하다 잠들어버렸습니다.....
다음날 눈 뜨자마자 N+1을 외치면서 눈을 뜨게 되었습니다... (진짜로.. 눈뜨자 마다 이생각부터 난...)
1 - 1) ER 다이어그램
현제 저희 프로젝트의 대략적인 E-R 다이어그렘은 다음과 같습니다.
Folder : Artice = 1 : N의 관계로 형성되어 있죠. 이게 문제의 시작 지점입니다.
(추가로 Folder가 Aggregate Root 역할을 하기 때문에 Root인 Folder를 통해서만 연산을 수행할 수 있는 구조입니다 by DDD)
1 - 2) 데이터 테이블
DB상에 저장된 있는 데이터를 살펴보면 다음과 같습니다.
▶ Folder 테이블 데이터
총 4개의 folder가 저장되어 있는 것을 확인할 수 있습니다.
▶ Article 테이블 데이터
총 5개의 데이터, 1번 폴더 0개, 2번 폴더 2개, 3번 폴더 3개, 4번 폴더 0개 가 저장되어 있습니다.
즉 다음과 같은 상태입니다.
1 - 3) 문제가 되는 코드
특정 유저의 모든 Folder를 찾아온 후, Folder에 포함된 Article의 사이즈를 구하는 가장 간단한 로직은 대략 다음과 같아지는데...
@Transactional(readOnly = true)
fun lookupFolderList(deviceId: String): FolderLookupListResponse {
val findMember = memberRepository.findByDeviceIdOrThrow(deviceId)
return folderRepository.findAllByMemberId(findMember.getId)
.map { FolderLookupDto.of(it) }
.let(::FolderLookupListResponse)
}
findAllByMemberId로 모든 Folder를 찾아온 후, FolderLookupDto.of() 메서드를 통해 DTO로 변환하게 됩니다.
data class FolderLookupDto(
val id: Long,
val name: String,
val type: FolderType,
val size: Int,
) {
companion object {
fun of(folder: Folder): FolderLookupDto
= FolderLookupDto(folder.getId, folder.name, folder.getType, folder.size)
}
}
이렇게 하면 로직상 간단하고, 매우 직관적이며, 정상적으로 동작한다는 점을 확인할 수 있습니다.
하지만 문제점이 남아 있으니...
N+1 쿼리 문제 바로 발생!
다음과 같이 Folder 전체를 조회하는 쿼리와 각각의 Folder가 본인들의 article을 조회하는 쿼리가 4개 추가적으로 발생한 것을 알 수 있었습니다.
이렇게 하위 엔티티들을 첫 쿼리 실행 시 한 번에 가져오지 않고, Lazy Loading으로 필요한 곳에서 사용되어 쿼리가 실행될 때 발생하는 문제가 N+1 쿼리 문제입니다.
지금은 Folder가 4개이니 첫 조회(1) + 4개의 Folder의 Article 조회(4) = 5 밖에 발생하지 않았지만, 만약 Folder 조회 결과가 10만 개면 어떻게 될까요?
한 번의 서비스 로직 실행에서 DB 조회가 10만 번 일어난다는 건 말이 안 될 것 같습니다.. 설령 가능해도 엄청 느리겠지요?
그래서 이렇게 연관관계가 맺어진 Entity를 한 번에 가져오기 위해 몇 가지 방법들이 있습니다.
2. 해결하기
Fetch Join (with Query Dsl)
가장 흔하게 해결할 수 있는 방법 중 하나인 Fetch Join을 QueryDsl과 연동하여 사용하도록 하겠습니다.
우선 쿼리문을 작성하여 MySQL WorkBench 상으로 정상 동작하는지 확인하였습니다.
원하는 것처럼 1번의 쿼리로 folder_id, name, type, count를 조회하게 되었습니다.
실행계획에서 id가 둘다 1인걸 보아 join연산을 수행한다는 것을 알 수 있습니다.
또한 type에서 index로 명시된 것으로 보아, index full scan방식을 사용하여 선행테이블(folder)을 조회한 후 -> 후행 테이블인 article에서 ref로 처리하는 것 을 알 수 있습니다.
또한 article의 경우 Extra: Using index인 것으로 미루어 보아 커버링 인덱스로 수행됩니다.
filtered 또한 둘 다 100.00이라 스토리지 엔진으로부터 필요한 데이터만 적합하게 불러와 처리한다 판단됩니다.
이 정도면 쿼리 실행계획 또한 크게 문제 될 거라 생각되는 지점은 없었습니다!!
이제 이걸 QueryDsl로 이식만 하면 끝나게 됩니다! 다음과 같이 말이죠!
그럼 변경된 쿼리가 실제로 어떻게 나가는지 확인해 볼까요?
성공적으로 1번의 쿼리로 조회할 수 있게 되었습니다!!
필요로 하는 Folder데이터와 Article 데이터를 한 번에 Fetch Join 해오는 것을 확인할 수 있습니다!!
또한 Group By를 적용하여 폴더별로 나누어 count 하도록 구현하였습니다!
시간이 지나 다시 생각해 보니?? 이게 Fetch Join까지는 필요 없었던 것 같다.
User를 기반으로 객체 탐색이 즉각적으로 필요한 것은 아니었기 때문에?? 사실 폴더 내부의 링크 수만 필요했던 것이니까??
N의 입장에서 Left Join후 count 쿼리 정도로 해결 가능했던 것 같다