프로젝트/쇼핑몰 프로젝트

[쇼핑몰 프로젝트 - 리팩토링] 상품 목록 조회 최적화: N+1 해결 & 인덱스로 31% 개선

Joo.v7 2025. 8. 20. 17:09

0. 개요

기존 쇼핑몰 프로젝트의 카테고리별 도서 조회에서 N+1 문제와 조회 속도가 느리다는 문제점이 있었다.

리팩토링을 통해 N+1 문제를 해결하고, 인덱스를 통해 조회 속도를 개선했다.

이 글은 그 과정을 정리한 글이다.


1. 기존 구조의 문제점

먼저 프론트를 보면, 해당 페이지에서 필요한 데이터는 bookId, title, price, salePrice, amount 5개다.

 

하지만 백엔드는 아래 메서드로 엔티티(Page<BookCategory>) 자체를 반환했다.

 

이러한 문제 때문에 N+1 문제가 발생했다.

  • 한 번의 리스트 쿼리로 books_categories를 가져온 뒤, 행마다 Book을 개별 조회했다.
  • Book.publisher가 EAGER라 출판사까지 매번 조인됐다.
  • 20개의 책을 가져오는데, 결과적으로 요청 1번에 쿼리 23개가 찍혔다.

 

(1) 프론트

<!-- Book List -->
<tr th:each="bookCategory, iterStat : ${bookList.content}">
    <td th:text="${iterStat.index + 1}"></td>
    <td>
        <a th:href="@{/book/bookDetail(bookId=${bookCategory.book.bookId})}"
           th:text="${bookCategory.book.title}"></a>
    </td>
    <td th:text="${#numbers.formatInteger(bookCategory.book.price, 0)}"></td>
    <td th:text="${#numbers.formatInteger(bookCategory.book.salePrice, 0)}"></td>
    <td th:text="${bookCategory.book.amount}"></td>
</tr>

 

(2) 백엔드

public interface BookCategoryRepository extends JpaRepository<BookCategory, Long> {

    /**
     * 카테고리 별 도서 목록 조회
     * @param categoryId
     * @param pageable
     * @return Page<BookCategory>
     */
    @TimeTrace
    Page<BookCategory> findAllByCategory_CategoryIdOrderByBook_AmountDesc(int categoryId, Pageable pageable);

 

(3) 쿼리문 분석

  1. 첫 번째 쿼리: book과 book_categories를 조인하여, 20개의 book을 가져온다.
  2. 두 번째 쿼리: 가져온 20개의 각각 book을 조회한다.
  3. 세 번째 쿼리: Page<T>를 반환하면서 Spring이 totalElements/totalPages 계산을 위해 COUNT(*)를 추가 실행한다.
더보기
Hibernate: 
    select
        bc1_0.book_category_id,
        bc1_0.book_id,
        bc1_0.category_id 
    from
        books_categories bc1_0 
    join
        books b1_0 
            on b1_0.book_id=bc1_0.book_id 
    where
        bc1_0.category_id=? 
    order by
        b1_0.amount desc 
    limit
        ?, ?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        c1_0.category_id,
        c1_0.name,
        c1_0.category_parent_id,
        c1_0.status 
    from
        categories c1_0 
    where
        c1_0.category_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        b1_0.book_id,
        b1_0.amount,
        b1_0.content,
        b1_0.description,
        b1_0.isbn13,
        b1_0.price,
        b1_0.pubdate,
        b1_0.publisher_id,
        p1_0.publisher_id,
        p1_0.name,
        b1_0.sale_price,
        b1_0.status,
        b1_0.title,
        b1_0.views 
    from
        books b1_0 
    join
        publishers p1_0 
            on p1_0.publisher_id=b1_0.publisher_id 
    where
        b1_0.book_id=?
Hibernate: 
    select
        count(bc1_0.book_category_id) 
    from
        books_categories bc1_0 
    where
        bc1_0.category_id=?
2025-08-19T16:43:56.157+09:00  INFO 3150 --- [task-service] [nio-8500-exec-3] c.n.taskapi.aop.TimeTraceAspect          : BookCategoryRepository.findAllByCategory_CategoryIdOrderByBook_AmountDesc(..) uri=/task/book/category Total time = 1.932002291s
2025-08-19T16:43:56.162+09:00  INFO 3150 --- [task-service] [nio-8500-exec-3] c.n.t.c.log.QueryCountLoggingFilter      : STATUS_CODE: 200, METHOD: GET, URL: /task/book/category, TIME: 1.963초, QUERY_COUNT: 23
2025-08-19T16:43:56.163+09:00  WARN 3150 --- [task-service] [nio-8500-exec-3] c.n.t.c.log.QueryCountLoggingFilter      : 하나의 요청에 쿼리가 10번 이상 날라갔습니다. 쿼리 횟수 : 23
2025-08-19T16:45:29.837+09:00  INFO 3150 --- [task-service] [rap-executor-%d] c.n.d.s.r.aws.ConfigClusterResolver      : Resolving eureka endpoints via configuration

2. N+1 문제 해결

카테고리별 도서 목록의 N+1 문제는 엔티티를 그대로 반환하면서 연쇄 로딩이 발생해 불필요한 컬럼과 쿼리가 발생했다.

 

이를 해결하기 위해 연관관계를 LAZY로 수정하고, 목록 화면 전용으로 DTO 프로젝션으로 필요한 컬럼만 가져왔다.

 

그 결과 요청당 쿼리 수23 -> 2로 줄어들었다.

 

더보기

(1) 연관관계: EAGER -> LAZY 

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Table(name = "books")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long  bookId;

    // 리팩토링: EAGER -> LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "publisher_id", nullable = false)
    private Publisher publisher;

    @Column(nullable = false)
    private String title;
    @Column(columnDefinition = "MEDIUMTEXT")
    private String content;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String description;

    @NotBlank
    @Length(max = 20)
    @Column(nullable = false)
    private String isbn13;

    @NotNull
    @Column(nullable = false)
    @Min(0)
    private int price;

    @NotNull
    @Column(nullable = false)
    @Min(0)
    private int salePrice;

    private long amount;

    private long views;

    @Column(nullable = false)
    private LocalDate pubdate;

    private boolean status = false;
}

 

(2) 프론트가 필요한 컬럼만 담은 DTO

@Getter
@AllArgsConstructor
public class BookListItemDTO {
    private long bookCategoryId;
    private long bookId;
    private String title;
    private int price;
    private int salePrice;
    private long amount;
}

 

 (3) JPQL

/**
 * 리팩토링 - 카테고리 별 도서 목록 조회
 * @param categoryId
 * @param pageable
 * @return Page<BookListItemDTO>
 */
@TimeTrace
@Query(value = """
select new com.nhnacademy.taskapi.book.dto.BookListItemDTO(
bc.bookCategoryId, b.bookId, b.title, b.price, b.salePrice, b.amount)
from BookCategory bc
join bc.book b
where bc.category.categoryId = :categoryId
""")
Page<BookListItemDTO> findBookList(@Param("categoryId") int categoryId, Pageable pageable);

 

 (4) 결과

  •  쿼리 수 감소: 23 -> 2
Hibernate: 
    select
        bc1_0.book_category_id,
        bc1_0.book_id,
        b1_0.title,
        b1_0.price,
        b1_0.sale_price,
        b1_0.amount 
    from
        books_categories bc1_0 
    join
        books b1_0 
            on b1_0.book_id=bc1_0.book_id 
    where
        bc1_0.category_id=? 
    order by
        b1_0.pubdate desc 
    limit
        ?, ?
Hibernate: 
    select
        count(bc1_0.book_category_id) 
    from
        books_categories bc1_0 
    where
        bc1_0.category_id=?
2025-08-20T14:03:41.944+09:00  INFO 10257 --- [task-service] [nio-8500-exec-3] c.n.taskapi.aop.TimeTraceAspect          : BookCategoryRepository.findBookList(..) uri=/task/book/category/re Total time = 2.167589834s
2025-08-20T14:03:41.949+09:00  INFO 10257 --- [task-service] [nio-8500-exec-3] c.n.t.c.log.QueryCountLoggingFilter      : STATUS_CODE: 200, METHOD: GET, URL: /task/book/category/re, TIME: 2.181초, QUERY_COUNT: 2

3. 인덱스 적용

1) 검색 속도 문제 발생

더미 데이터를 쌓아가며 테스트했는데, 약 100만 건 수준부터 카테고리별 목록 조회가 눈에 띄게 느려졌다.

 

실제 응답 시간을 보면 조회 요청 1건에, 2초가 찍혔다.

 

* 더미 데이터 생성

더보기

(1) 프로시저

DROP PROCEDURE IF EXISTS seed_books_bulk_for_category;
DELIMITER $$

CREATE PROCEDURE seed_books_bulk_for_category(
    IN p_category_id   INT,      -- 넣을 카테고리 ID (예: 3)
    IN p_publisher_id  BIGINT,   -- 퍼블리셔 ID (예: 1)
    IN p_total_count   BIGINT,   -- 총 생성할 권수 (예: 1000000)
    IN p_chunk_size    INT,      -- 청크 크기(예: 20000; NULL/<=0 이면 20000)
    IN p_authors_pool  INT       -- 저자 풀 개수(첫 호출만 500, 그 외 0)
)
BEGIN
  DECLARE v_left BIGINT;
  DECLARE v_chunk INT;
  DECLARE v_chunk_size INT;
  DECLARE v_batch VARCHAR(80);
  DECLARE v_old_depth INT;
  DECLARE v_min_author BIGINT DEFAULT 1;
  DECLARE v_authors_cnt INT DEFAULT 0;

  /* 유효성 */
  IF p_total_count IS NULL OR p_total_count <= 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='p_total_count must be > 0';
  END IF;

  /* 청크 크기 결정 (기본 20000, 상한 50000 = CTE depth 한계 고려) */
  SET v_chunk_size = IF(p_chunk_size IS NULL OR p_chunk_size <= 0, 20000, LEAST(p_chunk_size, 50000));
  SET v_left = p_total_count;

  /* 저자 풀 (중복 무시 권장: ALTER TABLE authors ADD UNIQUE KEY uk_authors_name(name);) */
  IF p_authors_pool > 0 THEN
    INSERT IGNORE INTO authors(name)
    WITH RECURSIVE a(n) AS (
      SELECT 1 UNION ALL SELECT n+1 FROM a WHERE n < p_authors_pool
    )
    SELECT CONCAT('저자 ', n) FROM a;
  END IF;

  SELECT COALESCE(MIN(author_id),1), GREATEST(COUNT(*),1)
  INTO v_min_author, v_authors_cnt
  FROM authors;

  /* 임시 테이블(매 청크 재사용) */
  CREATE TEMPORARY TABLE IF NOT EXISTS tmp_new_books (
    book_id BIGINT PRIMARY KEY
  ) ENGINE=MEMORY;

  WHILE v_left > 0 DO
    SET v_chunk = LEAST(v_left, v_chunk_size);
    /* 유일 배치 라벨(마이크로초 + UUID) */
    SET v_batch = CONCAT('BATCH_', DATE_FORMAT(NOW(6), '%Y%m%d%H%i%S%f'), '_', UUID());

    /* CTE 깊이 (최대 50000으로 제한) */
    SET v_old_depth = @@cte_max_recursion_depth;
    SET SESSION cte_max_recursion_depth = LEAST(GREATEST(v_chunk, 1000), 50000);

    /* 책 v_chunk 권 생성 */
    INSERT INTO books
      (publisher_id, title, content, description, isbn13,
       price, sale_price, amount, views, pubdate, status)
    WITH RECURSIVE seq(n) AS (
      SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < v_chunk
    )
    SELECT
       p_publisher_id,
       CONCAT(v_batch, ' 도서 ', n),
       '내용', '설명',
       /* 배치+행번호 기반 해시 → 13자리. (운영에선 isbn 고유 제약 두는 걸 권장) */
       LPAD(CAST(CRC32(CONCAT(v_batch, '-', n)) AS UNSIGNED), 13, '0'),
       20000 + (n % 10000),
       15000 + (n %  8000),
       FLOOR(RAND(n)   * 50000),     /* 판매량 */
       FLOOR(RAND(n*7) * 200000),    /* 조회수 */
       DATE_ADD('2015-01-01', INTERVAL FLOOR(RAND(n)*3800) DAY),
       1
    FROM seq;

    /* 이번 배치 book_id 수집 */
    TRUNCATE TABLE tmp_new_books;
    INSERT INTO tmp_new_books (book_id)
    SELECT book_id FROM books WHERE title LIKE CONCAT(v_batch, '%');

    /* 카테고리 매핑 */
    INSERT INTO books_categories (category_id, book_id)
    SELECT p_category_id, book_id FROM tmp_new_books;

    /* 재고 더미 */
    INSERT INTO stocks (book_id, stock)
    SELECT book_id, 10 + FLOOR(RAND(book_id)*90)
    FROM tmp_new_books;

    /* 저자 매핑: 정렬 제거(모듈러 분배) */
    INSERT INTO books_authors (book_id, author_id)
    SELECT b.book_id, v_min_author + (b.book_id % v_authors_cnt)
    FROM tmp_new_books b;

    /* 다음 루프 */
    SET v_left = v_left - v_chunk;
    SET SESSION cte_max_recursion_depth = v_old_depth;
  END WHILE;

  DROP TEMPORARY TABLE IF EXISTS tmp_new_books;

  /* 요약 반환 */
  SELECT p_total_count AS inserted_books,
         v_chunk_size  AS chunk_size,
         CEIL(p_total_count / v_chunk_size) AS chunks;
END$$

DELIMITER ;

 

(2) 더미 데이터 삽입

-- 도서 1,000,000권, 청크 20,000, 저자풀 500명 생성
CALL seed_books_bulk_for_category(3, 1, 1000000, 20000, 500);

 

(3) 확인


2) 왜 인덱스?

페이지네이션에 정렬이 들어가면 요청 1건약 2초가 걸렸다.

 

반대로 정렬을 빼면, DB가 PK(book_id) 순서대로 바로 잘라 내서 0.158초 걸렸다.

 

즉, 정렬이 오래 걸린 이유였고, 이를 해결하기 위해서 인덱스를 적용했다.

 

(1) 정렬 적용 O

 

(2) 정렬 적용 X


3) 인덱스 적용

프론트에서 필요한 컬럼은 book의 제목(title), 정가(price), 할인가(salePrice), 판매량(amount)다.

따라서 정렬할 가능성이 있는 컬럼은 위 4가지다.

 

(1) 쿼리 패턴 분석

카테고리별 도서를 조회하는 쿼리 패턴은 크게 아래 3가지다.

  1. books_categories(category_id=?로 도서 리스트 가져옴)
  2. books_categories의 book_id로 books와 조인
  3. 정렬 (pagenation의 sort)

* books_categories, books 엔티티

더보기

books_categories

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Table(name = "books_categories")
public class BookCategory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bookCategoryId;


    @ManyToOne(optional = false)
    @JoinColumn(name = "book_id", nullable = false)
    private Book book;


    @ManyToOne(optional = false)
    @JoinColumn(name = "category_id", nullable = false)
    @JsonIgnoreProperties({"parentCategory","childrenCategory"})
    private Category category;


}

 

books

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Table(name = "books")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long  bookId;


    // 출판사 - 책 단방향 다대일
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "publisher_id", nullable = false)
    private Publisher publisher;


    @Column(nullable = false)
    private String title;
    @Column(columnDefinition = "MEDIUMTEXT")
    private String content;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String description;

    @NotBlank
    @Length(max = 20)
    @Column(nullable = false)
    private String isbn13;

    @NotNull
    @Column(nullable = false)
    @Min(0)
    private int price;

    @NotNull
    @Column(nullable = false)
    @Min(0)
    private int salePrice;

    private long amount;

    private long views;

    @Column(nullable = false)
    private LocalDate pubdate;

    private boolean status = false;
}

 

(2) 적용할 인덱스

-- 1. books_categories의 book_id와 category_id
create index idx_bc_category_book on books_categories(category_id, book_id);

-- 2. title, book_id
create index idx_books_title_book on books(title, book_id);

-- 3. price, book_id
create index idx_books_price_book on books(price, book_id);

-- 4. salePrice, book_id
create index idx_books_sale_price_book on books(sale_price, book_id);

-- 5. amount, book_id
create index idx_books_amount_book on books(amount, book_id);

 

 

(3) 문제 발생

인덱스가 예상대로 타지지 않는 문제가 발생했다.

 

아래 JPQL처럼 BookCategory에서 categoryId로 book들을 모으고 → books와 조인 → 페이징 정렬을 한다.

이때 books_categories(category_id, book_id) 인덱스는 잘 적용되어 약 2.0초 → 약 1.4초개선은 되었지만, 그 이후가 문제였다.

 

books 테이블정렬용 인덱스(title|price|sale_price|amount, book_id)까지 준비해두면 ORDER BY + LIMIT가 인덱스 스캔으로 처리될 거라 기대했다.

 

그러나 DB 옵티마이저가 인덱스를 사용하지 않는 filesort를 수행하는 실행계획을 자주 선택했다.

 

즉, 정렬을 빠르게 하려면 books(정렬필드, book_id)를 그 순서로 스캔해야 하는데, 현재 쿼리만으로는 옵티마이저가 이 실행 계획을 선택하지 않았다.

 

nativeQuery인덱스를 타도록 강제하는 방법이 있었지만, JPA를 기본으로 사용하면서 JPA로 못하는 성능 이슈가 큰 1, 2개 정도만 native를 사용한다고 생각한다. 그래서 books_categories 테이블의 인덱스 만으로도 충분히 성능 개선이 됐다고 생각해서 아래의 인덱스 하나만 적용했다. 

/**
 * 리팩토링 - 카테고리 별 도서 목록 조회
 * @param categoryId
 * @param pageable
 * @return Page<BookListItemDTO>
 */
@TimeTrace
@Query(value = """
select new com.nhnacademy.taskapi.book.dto.BookListItemDTO(
bc.bookCategoryId, b.bookId, b.title, b.price, b.salePrice, b.amount)
from BookCategory bc
join bc.book b
where bc.category.categoryId = :categoryId
""")
Page<BookListItemDTO> findBookList(@Param("categoryId") int categoryId, Pageable pageable);
create index idx_bc_category_book on books_categories(category_id, book_id);

4. 결과

31% 조회 성능 개선

  • 인덱스 적용 전: 평균 2.06s
  • 인덱스 적용 후: 평균 1.42s
컬럼 인덱스 적용 전 인덱스 적용 후
제목(title) 2.14s 1.51s
정가(price) 2.07s 1.39s
할인가(salePrice) 2.02s 1.40s
판매량(amount) 2.00s 1.39s
더보기

(1) 제목(title)

  • 인덱스 적용 전: 2.14s
  • 인덱스 적용 후: 1.51s
(좌) 인덱스 적용 전, (우) 인덱스 적용 후

(2) 정가(price)

  • 인덱스 적용 전: 2.07s
  • 인덱스 적용 후: 1.39s
(좌) 인덱스 적용 전, (우) 인덱스 적용 후

 

(3) 할인가(salePrice)

  • 인덱스 적용 전: 2.02s
  • 인덱스 적용 후: 1.40s
(좌) 인덱스 적용 전, (우) 인덱스 적용 후

(4) 판매량(amount)

  • 인덱스 적용 전: 2.00s
  • 인덱스 적용 후: 1.39s
(좌) 인덱스 적용 전, (우) 인덱스 적용 후

참고