카테고리 없음

N + 1, 인덱스

kim00920 2025. 5. 5. 10:13

JPA  N + 1 문제점

● N + 1 문제 발생 코드 (특정 게시글에 있는 댓글 (댓글 + 대댓글) 을 전체 조회할떄)

1. Board 엔티티를 가져올떄 쿼리가 발생 (1개)

2. Comment 엔티티를 가져올떄 쿼리가 발생 (1개)

이후 Comment 엔티티를 DTO 로 변환시킨다

3. comment.getUser().getId() 를 실행할떄 프록시 객체가 실제 객체로 초기화되면서 쿼리가 발생한다 (N개)

3.1 comment.getUser().getName() 을 실행할떄 1차캐시로 인해 쿼리가 발생하지 않는다

3.2 comment.getBoard.getId() 에서도 이미 호출된 Board 엔티티를 가져왔기 떄문에 쿼리가 발생하지 않는다

3.3 이후 Comment엔티티 안에 포함되어있는 replyComment 엔티티를 DTO 로 변환시킨다 (M개)

4. replyComment.getUser().getId() 를 실행할떄 프록시 객체가 실제 객체로 초기화되면서 쿼리가 발생한다(K개)

4.1 replyComment.getUser().getName() 을 실행할떄 1차캐시로 인해 쿼리가 발생하지 않는다

 

문제점

사용자가 특정 게시글에 있는 댓글 목록을 전체 조회할떄 다음과 같은 순서로 쿼리가 발생한다

 

1. 특정 게시글 조회 (1)

2. 특정 게시글의 댓글 전체 조회 (1)

3. 각 댓글의 작성자 조회 (N)

4. 각 댓글의 대댓글 조회 (M)

5. 각 대댓글의 작성자 조회 (K)

 

-> 1 + 1 + N + M + K 만큼 추가 쿼리가 발생한다

 

해결방법

@OnetoMany (댓글  대댓글)

-> @BatchSize를 통해 대댓글 테이블은 IN 절로 조회

 

@ManytoOne (댓글 → 회원)

-> Fetch Join을 통해 댓글과 회원 정보를 한 번에 조회

@Query(“SELECT c FROM Commnet JOIN FETCH c.user where c.board = :board”)

 

@ManytoOne (대댓글 → 회원)

-> @ManytoOne(fetch = FetchType.LAZY) 설정 이후, batch_fetch_size를 통해서 회원 정보를 IN 절로 조회

 

@ManytoOne (대댓글 회원) 을 LAZY로 설정한 이유

대댓글 회원(EAGER) JOIN FETCH  로 가져와서 페이징 처리를 할려고 했습니다.

하지만, 대댓글 -> 회원을 FETCH JOIN 로 가져오는 과정에서 기존 로직이 변경되고 코드가 복잡해져서 다른 방법을 생각했습니다

 

1. 댓글 JOIN FETCH  대댓글 JOIN FETCH   회원 JOIN FETCH 로 한번에 가져온다 (X)

 >>  댓글 JOIN FETCH  대댓글은 가능하지만, 중복 행이 생기기 떄문에 페이징 처리가 불가능하다

 

2. 대댓글 회원(EAGER)을 지연로딩으로 설정 후, @BatchSize 를 통해서 N + 1 문제를 최소화한다 (O)

>> 기존의 코드 로직도 건드리지 않으면서 , K 개 추가쿼리 발생에서 (K / BatchSize) 쿼리 발생으로 N + 1 문제도 해결 가능

>>  toOne 관계에서 @BatchSize 사용은 불가능하므로, batch_fetch_size 를 통해서 회원 정보를 IN 절로 조회했습니다

 

 

● 테스트 

공통조건 : 회원 데이터 100개, 댓글 데이터 50개, 대댓글 데이터 50개, 각각 다른 회원 id

Test 1 : 15초 동안 조회 쿼리 500개 요청, N + 1 발생

Test 2 : 15초동안 조회 쿼리 500개 요청, Fetch Join + BatchSize 적용

Test 3 : 45초동안 조회 쿼리 5000개 요청, N + 1 발생

Test 4 : 45초동안 조회 쿼리 5000개 요청, Fetch Join + BatchSize 적용

 

 

● 분석

사용자의 요청이 적을 떄, Test 1과 Test 2를 비교하니 Fetch Join을 통해서 회원 테이블을 한 번에 가져오고,
Batch Size를 통해 대댓글을 IN절로 가져오게 되면서 평균시간은 8ms → 5ms로 95% 내외 16ms → 12ms로 줄었다

사용자의 요청이 많은 Test 3 과 Test 4를 비교해도 평균시간은 11ms → 4ms로 96% 내외 50ms → 9ms로 줄었다

 

기존에 1 + 1 + N + M + K개 만큼 발생하는 쿼리에서 1 + (M / BatchSize) + 1 + (K / BatchSize)  개만큼 쿼리 수가 대폭 감소

 

● 느낀점

지연로딩은 연관된 객체가 필요 할 때 마다 조회하는 방식으로, 테이블과 연관관계를 맺은 다른 테이블 객체는 프록시 객체로 로딩된다 

이 프록시 객체를 통해서 성능과 메모리를 절약할수있지만, 프록시가 실제 객체로 초기화되는 순간 N + 1 문제가 발생한다

 

만약에 댓글 회원 id 와 대댓글 회원 id 가 중복될 경우 이떄는 추가쿼리가 발생하지 않고 1차캐시를 통해서  재사용하게되어

추가 쿼리가 발생하지 않지만, 반대로 그렇지 않은 경우에는 N + 1 문제가 발생한다

 


복합 인덱스

● 인덱스 적용 쿼리

SELECT * FROM BOARD
WHERE is_notice = 0

ORDER BY view_count ASC | DESC

 

SELECT * FROM BOARD

WHERE is_notice = 0

ORDER BY like_count ASC | DESC

 

● 문제점

사용자는 조회수나 좋아요 수를 기준으로 일반 게시글을 범위 정렬 할 것이고 이때, 테이블 Full Scan이 발생한다

 

일반게시글만을 대상으로 is_notice = 0(공지글 상태)과 view_count(조회수)를 조건으로 게시글의 탐색 비용을 줄이기 위해서

복합 인덱스를 사용하면 조회 성능이 좋아질 것이다

 

● 인덱스 생성

CREATE INDEX idx_is_notice_view_count ON boards (is_notice, view_count);   일반게시글 + 조회수 

CREATE INDEX idx_is_notice_like_count ON boards (is_notice, like_count);   일반게시글 + 좋아요 수 

 

● 테스트

공통조건 : 게시글 테이블 데이터 5000개

실행쿼리 : 조회수가 많은 순서로 게시글 보기 → WHERE is_notice = 0 ORDER BY view_count DESC;

 

Test 1 : 20초 동안 조회 쿼리 1000개 요청, 인덱스 생성 X 

Test 2 : 20초 동안 조회 쿼리 1000개 요청, 인덱스 생성 O

Test 3 : 30초 동안 조회 쿼리 5000개 요청, 인덱스 생성 X

Test 4 : 30초 동안 조회 쿼리 5000개 요청, 인덱스 생성 O

 

● 분석

 

게시글을 정렬조건에 따라서 조회를 하기 때문에 조회작업만 발생하고, INSERT, DELETE, UPDATE 같은 쓰기 작업은 발생하지

않기 때문에 인덱스로 인한 성능 저하나 갱신 비용이 거의 없다

 

Test 1, Test3 : 인덱스를 사용하지 않아도, InnoDB는 기본키 id로 인해 클러스터 인덱스를 통해서 인덱스가 생성됐고
일반게시글과 조회수 내림차순 조건에 만족하는 ROW를 찾기 위해서 인덱스 Full Scan이 발생했다

Test 2, Test 4 : 일반게시글과 조회수 내림차순 조건으로 복합 인덱스로 설정하여 인덱스 Range Scan이 발생했다

 

사용자 요청이 1000개 일 때 Test 1과 Test 2를 비교해서 평균시간 8ms → 5ms, 95% 내외 33ms → 11ms로 감소했다

사용자 요청이 5000개 일 때 Test 3과 Test 4를 비교해서 평균시간 24ms → 5ms, 95% 내외 139ms → 14ms로 감소했다

 

데이터의 개수나 사용자 요청이 많아질수록 인덱스 Full Scan인덱스 Range Scan의 비용차이는 점점 더 커진다

 

 

● 의문점

인덱스를 생성할 때 정렬 방식을 명시적으로 기입하지 않는다면, 기본적으로 다음과 같이 각각 인덱스가 생성될 것이다

 

CREATE INDEX idx_is_notice_view_count ON boards (is_notice ASC, view_count ASC);

CREATE INDEX idx_is_notice_like_count ON boards (is_notice ASC, like_count ASC);

 

MySQL 은  MySQL 엔진과 스토리지 엔진으로 구성되어 있는데 사용자가 쿼리를 날리게 되면 MySQL 엔진 내부에 있는
쿼리 파서나 옵티마이저 등의 기능이 분석 및 처리한 뒤, 핸들러 API를 통해서 스토리지 엔진에 전달된다

이 스토리지 엔진에서 디스크를 읽고 데이터를 가져와 다시 MySQL 엔진으로 넘겨주게 되고 데이터가 처리될 것이다.

 

Query DSL을 통해 생성된 쿼리는 다음과 같이 DB로 날아갈 것이다

 

 

● 인덱스를 통한 데이터 처리 과정

WHERE 절 is_notice = 0에 대해 오름차순 정렬이기 때문에 인덱스를 탔지만, 조회수 정렬에 대해서는 인덱스를 타지 못했다

MySQL 엔진에서 조회수에 대해서 역순 정렬 작업이 추가 작업이 발생한다

Extra : Using Where 이 됐다

 

 

● 추가 해결 방법

CREATE INDEX idx_is_notice_like_count_desc ON boards (is_notice ASC, like_count DESC);

조회수에 대한 내림차순 복합 인덱스를 생성한다

 

기존 쿼리랑 다른 부분은 InnoDB에서 가져온 데이터들을 MySQL 엔진에서 추가 정렬 작업이 필요 없이 데이터 조회 작업만
처리하게 인덱스를 모두 타긴 했지만,

MySQL 엔진에서 조회 작업은 처리하기 때문에 Extra : Using Index Condition 이 됐다


기존 Using Where 보다는 처리 작업을 덜 하기 때문에 조회 성능은 더 좋아질 것이다

 

그럼 만약에 SELECT 컬럼이 모두 인덱스 컬럼만으로 이루어져 있다면 어떻게 될까?

 

인덱스만으로 필터링되고 MySQL 엔진으로 넘겨줄 필요도 없이 바로 데이터 처리가 완료된다

커버링 조건을 만족하고 Extra : Using Index 가 됐다

이때는 커버링 인덱스로, 성능이 가장 좋았다

 

 

● 추가 테스트

 

공통조건 : 테이블 데이터 30000개

실행쿼리 : 일반 게시글 중에서 조회수가 많은 순서로 게시글 보기 : WHERE is_notice = 0 ORDER BY view_count DESC;

 

Test 5 : 30초 동안 조회 쿼리 30개 요청, Using Where (추가 정렬작업 + 조회작업)

Test 6 : 30초 동안 조회 쿼리 30개 요청, Using Index Condition (조회작업)

Test 7 : 30초동안 조회 쿼리 30개 요청, Using Index (커버링 인덱스)

 

 

Test 5 : Where 절에 있는 컬럼만 인덱스를 타게 되고, MySQL 엔진에서 추가적으로 역순 정렬과 조회 작업이 발생한다

Test 6 : 컬럼 2개 모두 인덱스를 타지만,  복합 인덱스만으로 처리하지 못하기 때문에 MySQL 엔진에서 조회 작업이 발생한다

Test 7 : 컬럼 2개 모두 인덱스를 타고 필터링된 데이터들은 MySQL 엔진으로 넘겨주지 않고 데이터가 처리된다

   Test 5, Test 6, Test 7을 비교해서 평균시간 20ms → 14ms 감소하였으며, 95% 내외 29ms → 20ms까지 감소했다

 

인덱스는 조회 성능을 향상해 주지만 데이터의 삽입, 삭제, 수정 같은 쓰기 작업 시에 추가적인 갱신비용이 발생한다

인덱스 생성 시 추가적인 디스크 공간을 차지하기 때문에 도메인 상황에 알맞게 사용해야 한다