BackEnd/Linkllet

[Linkllet] 흔한 N+1 문제 해결하기

샤아이인 2023. 7. 21.

1. 문제 되는 상황

팀원 중 한 분이 폴더 목록을 조회할 때, 해당 폴더 내부에 몇 개의 링크가 저장되어 있는지 그 원소의 수 또한 함께 전달해 달라 요청해 주셨습니다.

이 카톡을 처음 봤을 때 시간이 늦은 시간이었던 지라 매우 간단하게 생각하고 다음날 바로 구현하겠다 하고 눈을 감았습니다만....

 

그럴 때 있잖아요? 뭔가 실수한 거 같아서 눈은 감고 있지만 계속 생각날 때?

머릿속에 스쳐 지나가는 N+1 쿼리 문제.... 아 이거 폴더마다 count 쿼리가 날아갈 텐데... 아읔....

일단 자야지 하면서.... 계속 어떻게 구현할지 생각하다 잠들어버렸습니다.....

 

다음날 눈 뜨자마자 N+1을 외치면서 눈을 뜨게 되었습니다... (진짜로.. 눈뜨자 마다 이생각부터 난...)

 

1 - 1) ER 다이어그램

현제 저희 프로젝트의 대략적인 E-R 다이어그렘은 다음과 같습니다.

Folder : Artice = 1 : N의 관계로 형성되어 있죠. 이게 문제의 시작 지점입니다.

(추가로 Folder가 Aggregate Root 역할을 하기 때문에 Root인 Folder를 통해서만 연산을 수행할 수 있는 구조입니다 by DDD)

Folder가 List<Article>을 들고 있다

 

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 하도록 구현하였습니다!

'BackEnd > Linkllet' 카테고리의 다른 글

[Linkllet] 검색 쿼리 개선하기 (by 커버링 인덱스)  (6) 2023.08.13

댓글