목차
1. 기존 코드: N+1 문제 발생
PostService - getPostList 메서드
List<PostListResponseDto> postListResponseDtos = postPage.getContent().stream() // 1번의 쿼리로 Post 목록 조회
.map(post -> {
// 각 Post마다 개별적으로 이미지 URL 조회 쿼리 실행 (N번의 추가 쿼리)
String postImageUrl = imageRepository.findUrlsByPostIdOrderByIdAsc(post.getId())
.orElse(null);
return PostListResponseDto.from(post, postImageUrl);
})
.collect(Collectors.toList());
문제점: N+1 쿼리 발생
게시글을 조회한 후, 각 게시글마다 별도의 이미지 조회 쿼리가 실행된다. 이 경우 게시글 수가 많아질 수록 쿼리 수가 증가하여 성능 저하가 발생하게 된다.
예를 들어 10개의 게시글을 조회한다면,
- `postPage.getContent()` → Post 목록 조회 (1번 쿼리)
- `count` 쿼리 → 총 게시글 수를 가져오기 위한 쿼리 (1번 추가 쿼리)
- `imageRepository.findUrlsByPostIdOrderByIdAsc()` → 각 Post 마다 실행 (10번의 추가 쿼리)
총 12번의 쿼리가 실행된다. 아래의 로그에서도 확인할 수 있다.
기존 코드의 쿼리 로그
... // 유저 조회 쿼리 생략
Hibernate:
select
p1_0.id,
p1_0.comment_count,
p1_0.content,
p1_0.create_date,
p1_0.title,
p1_0.type,
p1_0.view_count,
p1_0.writer_id
from
posts p1_0
where
p1_0.type=?
order by
p1_0.create_date desc
limit
?, ?
Hibernate:
select
count(p1_0.id)
from
posts p1_0
where
p1_0.type=?
Hibernate:
SELECT
i.url
FROM
images i
WHERE
i.post_id = ?
ORDER BY
i.id ASC
LIMIT
1
Hibernate:
SELECT
i.url
FROM
images i
WHERE
i.post_id = ?
ORDER BY
i.id ASC
LIMIT
1
... // 이미지 url 조회하는 쿼리 반복
로그를 보면 이미지를 조회하는 `SELECT i.url FROM images i WHERE i.post_id = ? ORDER BY i.id ASC LIMIT 1` 쿼리가 조회하려는 게시글 수만큼 실행된다.
게시글 10개 조회 시, 총 12번의 쿼리가 실행된다. (게시글 1번 + 카운트 쿼리 1번 + 이미지 10번)
2. 1차 최적화: N+1 문제 해결 (이미지 조회 쿼리 최적화)
PostService - getPostList 메서드
// 1. 모든 게시물 ID 수집
List<Long> postIds = postPage.getContent().stream()
.map(Post::getId)
.collect(Collectors.toList());
// 2. 한 번의 쿼리로 모든 이미지 URL 조회
Map<Long, String> postImageUrls = imageRepository.findFirstImageUrlsByPostIdsRaw(postIds).stream()
.collect(Collectors.toMap(
row -> (Long) row[0], // post_id를 key로
row -> (String) row[1] // url을 value로
));
// 3. 정적 팩토리 메서드를 사용하여 DTO 생성
List<PostListResponseDto> postListResponseDtos = postPage.getContent().stream()
.map(post -> PostListResponseDto.from(post, postImageUrls.get(post.getId())))
.collect(Collectors.toList());
이미지를 조회하는 별도 쿼리를 한 번으로 줄였다. 쿼리 실행 횟수를 게시글 개수만큼(N번) 실행하는 대신, 한 번만 실행하도록 변경했다.
1. 모든 게시물 ID 수집
List<Long> postIds = postPage.getContent().stream()
.map(Post::getId)
.collect(Collectors.toList());
- 조회된 게시글의 ID만 추출해 리스트로 만든다.
- 이유: 한 번의 쿼리로 이미지를 가져오기 위해 `IN` 절에 사용할 `ID` 목록이 필요하기 때문이다. N번의 쿼리를 1번으로 줄이기 위한 단계이다.
2. 한 번의 쿼리로 모든 이미지 URL 조회
Map<Long, String> postImageUrls = imageRepository.findFirstImageUrlsByPostIdsRaw(postIds).stream()
.collect(Collectors.toMap(
row -> (Long) row[0], // post_id를 key로
row -> (String) row[1] // url을 value로
));
- 모든 게시글 ID에 대한 첫 번째 이미지를 조회한다. (게시글 리스트 조회하기 때문에 하나의 이미지만 조회한다.)
- ImageRepository
@Query(value = "SELECT i.post_id, MIN(i.url) FROM images i WHERE i.post_id IN :postIds GROUP BY i.post_id", nativeQuery = true) List<Object[]> findFirstImageUrlsByPostIdsRaw(List<Long> postIds);
- 쿼리: `IN` 절을 활용하여 여러 게시글 ID에 대한 이미지를 한 번의 쿼리로 조회한다. 각 게시글 ID에 대해 첫 번째 이미지(가장 먼저 등록된 이미지)를 `MIN(i.url)` 로 가져오고, `GROUP BY` 로 게시글 ID 별로 그룹화하여 반환한다.
- `post_id`를 키로, `url`을 값으로 가지는 `Map` 형태로 반환된다.
3. 정적 팩토리 메서드를 사용하여 DTO 생성
List<PostListResponseDto> postListResponseDtos = postPage.getContent().stream()
.map(post -> PostListResponseDto.from(post, postImageUrls.get(post.getId())))
.collect(Collectors.toList());
- 게시글(`Post`)과 이미지 URL을 조합해 `PostListResponseDto` 로 변환한다.
- 정적 팩토리 메서드 사용: `PostListResponseDto.from()`을 통해 객체를 생성한다.
최종 코드
@Transactional
public List<PostListResponseDto> getPostList(Long userId, ETopic type, Integer pageIndex, Integer pageSize) {
getUserById(userId);
Pageable pageable = PageRequest.of(pageIndex, pageSize);
Page<Post> postPage = postRepository.findByType(type, pageable);
// 1. 모든 게시물 ID 수집
List<Long> postIds = postPage.getContent().stream()
.map(Post::getId)
.collect(Collectors.toList());
// 2. 한 번의 쿼리로 모든 이미지 URL 조회
Map<Long, String> postImageUrls = imageRepository.findFirstImageUrlsByPostIdsRaw(postIds).stream()
.collect(Collectors.toMap(
row -> (Long) row[0], // post_id를 key로
row -> (String) row[1] // url을 value로
));
// 3. 정적 팩토리 메서드를 사용하여 DTO 생성
List<PostListResponseDto> postListResponseDtos = postPage.getContent().stream()
.map(post -> PostListResponseDto.from(post, postImageUrls.get(post.getId())))
.collect(Collectors.toList());
return postListResponseDtos;
}
수정 코드의 쿼리 로그
... // 유저 조회 쿼리 생략
Hibernate:
select
p1_0.id,
p1_0.comment_count,
p1_0.content,
p1_0.create_date,
p1_0.title,
p1_0.type,
p1_0.view_count,
p1_0.writer_id
from
posts p1_0
where
p1_0.type=?
order by
p1_0.create_date desc
limit
?, ?
Hibernate:
select
count(p1_0.id)
from
posts p1_0
where
p1_0.type=?
Hibernate:
SELECT
i.post_id,
MIN(i.url)
FROM
images i
WHERE
i.post_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
GROUP BY
i.post_id
- 게시글 목록 조회 쿼리: 게시글 데이터를 페이징 처리하여 조회한다.
- 게시글 수 카운트 처리: 전체 게시글 수를 가져와 페이지네이션을 처리한다.
- 이미지 조회 쿼리
- `IN` 절을 활용하여 여러 게시글 ID에 대한 이미지를 한 번에 조회한다.
- `GROUP BY` 를 통해 각 게시글에 해당하는 첫 번째 이미지를 조회한다.
⇒ 최적화 후: 게시글 조회 1번 + 카운트 쿼리 1번 + 이미지 조회 1번 = 3번의 쿼리
N+1 문제 해결 전후 성능 비교
구분 | 최적화 전 (N+1 발생) | 최적화 후 (N+1 해결) |
쿼리 수 | 2 + 게시글 수(N) (게시글 조회 1 + 카운트 조회 1 + 이미지 조회 N) |
3개 (게시글 조회 1 + 카운트 조회 1 + 이미지 조회 1) |
DB 부하 | 게시글 수가 늘어날수록 쿼리 수 급증 | 게시글 수와 무관하게 일정한 쿼리 수 |
응답 속도 | 31.2ms | 27.5ms |
3. 2차 최적화: 복합 인덱스 추가
1차 최적화로 N+1 문제를 해결하여 쿼리 수를 줄였지만, 응답 속도를 측정했을 때 생각보다 성능이 많이 향상되지 않았다. 데이터가 적다는 점도 있지만, 좀 더 확실하게 성능이 개선된 것이 맞는지 `EXPLAIN` 으로 조회한 로그를 분석했다. (`EXPLAIN` 명령어를 활용하여 쿼리의 실행 계획을 분석)
`EXPLAIN`은 쿼리가 데이터베이스에서 어떻게 실행되는지 보여주고, 인덱스 사용 여부나 조회 방식 등을 확인할 수 있다.
EXPLAIN SELECT i.post_id, MIN(i.url)
FROM images i
WHERE i.post_id IN (12, 8, 7, 2, 1)
GROUP BY i.post_id;
+----+-------------+-------+------------+-------+-----------------------------+-----------------------------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+-----------------------------+-----------------------------+---------+------+------+----------+-------------+
| 1 | SIMPLE | i | NULL | index | FKcp0pycisii8ub3q4b7x5mfpn1 | FKcp0pycisii8ub3q4b7x5mfpn1 | 8 | NULL | 1 | 100.00 | Using where |
+----+-------------+-------+------------+-------+-----------------------------+-----------------------------+---------+------+------+----------+-------------+
문제점 분석
- 인덱스만으로 처리 불가능 (커버링 인덱스 미적용)
- `Extra` 컬럼에 `Using where` 만 표시된 것은 인덱스만으로 쿼리를 처리하지 못하고, 추가로 테이블 데이터를 조회해야 한다는 의미이다.
- 불필요한 데이터 조회가 발생하여 성능 저하로 이어진다.
- 비효율적인 인덱스 활용
- 외래 키 기반 인덱스(`FKcp0pycisii8ub3q4b7x5mfpn1`)가 사용되었지만, `MIN(i.url)` 을 효율적으로 처리하지 못한다.
- `post_id` 조건은 걸려있지만, 최소값 계산 시 인덱스를 제대로 활용하지 못하여 불필요한 작업이 발생하게 된다.
해결 방법: 복합 인덱스 추가
CREATE INDEX idx_post_id_url ON images(post_id, url);
`post_id` 로 필터링하고, `url` 을 빠르게 찾기 위해 복합 인덱스를 추가했다.
이 인덱스는 쿼리에서 필요한 모든 컬럼(`post_id`, `url`)을 포함하고 있기 때문에, 커버링 인덱스로 동작하며 테이블 접근 없이 쿼리를 처리할 수 있다.
인덱스 추가 후 EXPLAIN 결과
+----+-------------+-------+------------+-------+-----------------+-----------------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+-----------------+-----------------+---------+------+------+----------+--------------------------+
| 1 | SIMPLE | i | NULL | index | idx_post_id_url | idx_post_id_url | 1030 | NULL | 2 | 100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+-----------------+-----------------+---------+------+------+----------+--------------------------+
인덱스 추가 전후 EXPLAIN 결과 비교
항목 | 인덱스 추가 전 (FKcp0pycisii8ub3q4b7x5mfpn1) |
인덱스 추가 후 (idx_post_id_url) |
변화 요약 |
type | index | index | 동일 (범위 스캔 여부는 Extra로 확인) |
possible_keys | FKcp0pycisii8ub3q4b7x5mfpn1 | idx_post_id_url | 복합 인덱스 적용됨 |
key | FKcp0pycisii8ub3q4b7x5mfpn1 | idx_post_id_url | 복합 인덱스로 변경 |
key_len | 8 | 1030 | 더 많은 인덱스 정보 활용 |
row | 1 | 2 | 예상 스캔 행 수 증가 |
Extra | Using where | Using where; Using index | 커버링 인덱스 적용 |
- `key_len` 증가 (8 → 1030): `key_len`은 인덱스 키의 길이를 나타낸다. 복합 인덱스(`post_id`, `url`)가 제대로 활용되고 있다는 것을 보여준다.
- `rows` 증가 (1 → 2): 예상 스캔 행 수가 증가한 이유는 복합 인덱스를 통해 더 많은 데이터를 효율적으로 스캔할 수 있게 되었기 때문이다.
- `Using where; Using index`: `Using index`는 커버링 인덱스가 적용되었음을 의미한다. 테이블 접근 없이 인덱스만으로 데이터 조회 가능하다.
커버링 인덱스(Covering Index)
커버링 인덱스란, 쿼리에서 필요한 모든 데이터가 인덱스에 포함되어 있어 테이블 접근 없이 인덱스만으로 쿼리를 처리할 수 있는 인덱스이다.
- 일반 인덱스 → 인덱스를 통해 찾은 후 테이블에서 추가 조회 필요
- 커버링 인덱스 → 인덱스 자체에 필요한 모든 데이터가 포함되어 추가 조회 불필요
결과적으로 쿼리 속도가 향상되고, 디스크 I/O가 줄어들어 성능이 개선된다.
인덱스 추가 전후 응답 속도 비교
단계 | 내용 | 응답 속도 (ms) |
N+1 문제 해결 전 | N+1 문제로 인한 다수의 불필요한 쿼리 발생 | 31.2ms |
1차 최적화 후 (인덱스 X) | 쿼리 최적화로 N+1 문제 해결, 인덱스 미활용 | 27.5ms |
2차 최적화 후 (인덱스 O) | 복합 인덱스(idx_post_id_url) 활용으로 성능 개선 | 23.3ms |
4. 최종 성능 개선 효과
- 쿼리 실행 횟수 75% 감소: N+1 문제 해결 → 게시글 10개 조회 시 12 → 3개로 감소
- 데이터 접근 최소화: 커버링 인덱스로 테이블 접근 없이 인덱스만으로 처리
- 응답 속도 25.3% 개선: 31.2ms → 23.3ms로 개선
'Projects > 청하-청년을 위한 커뮤니티 서비스' 카테고리의 다른 글
[청하] TimeFormatter 클래스 리팩토링 (feat. @UtilityClass) (0) | 2025.02.02 |
---|---|
[청하] 39. 홈화면 리뉴얼: 맞춤 정책, 핫한 정책, 커뮤니티 미리보기 설계 (0) | 2024.10.27 |
[청하] 38. Docker 환경에서 Spring Boot 모니터링 시스템 구축 (with. Prometheus, Grafana) (2) | 2024.10.27 |
[청하] 37. 로컬 환경에서 Spring Boot 모니터링 시스템 구축 (with. Prometheus, Grafana) (0) | 2024.10.27 |
[청하] 36. 게시글 조회수 기능 구현 (0) | 2024.10.25 |
댓글