본문 바로가기
Projects/청하-청년을 위한 커뮤니티 서비스

[청하] 게시글 리스트 조회 성능 최적화: N+1 문제 해결 & 인덱스 최적화

by Lpromotion 2025. 2. 4.

목차
 

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개의 게시글을 조회한다면,

  1. `postPage.getContent()` → Post 목록 조회 (1번 쿼리)
  2. `count` 쿼리 → 총 게시글 수를 가져오기 위한 쿼리 (1번 추가 쿼리)
  3. `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 |
+----+-------------+-------+------------+-------+-----------------------------+-----------------------------+---------+------+------+----------+-------------+

 

문제점 분석

  1. 인덱스만으로 처리 불가능 (커버링 인덱스 미적용)
    • `Extra` 컬럼에 `Using where` 만 표시된 것은 인덱스만으로 쿼리를 처리하지 못하고, 추가로 테이블 데이터를 조회해야 한다는 의미이다.
    • 불필요한 데이터 조회가 발생하여 성능 저하로 이어진다.
  2. 비효율적인 인덱스 활용
    • 외래 키 기반 인덱스(`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로 개선
반응형

댓글