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) 쿼리문 분석
- 첫 번째 쿼리: book과 book_categories를 조인하여, 20개의 book을 가져온다.
- 두 번째 쿼리: 가져온 20개의 각각 book을 조회한다.
- 세 번째 쿼리: 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가지다.
- books_categories(category_id=?로 도서 리스트 가져옴)
- books_categories의 book_id로 books와 조인
- 정렬 (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


참고
- https://rebugs.tistory.com/733
- https://dev.mysql.com/doc/refman/8.4/en/mysql-indexes.html
- https://dev.mysql.com/doc/refman/8.4/en/create-index.html
'프로젝트 > 쇼핑몰 프로젝트' 카테고리의 다른 글
[쇼핑몰 프로젝트 - 리팩토링] 생일 쿠폰 배치 성능 ~% 개선 과정 (0) | 2025.08.21 |
---|---|
[쇼핑몰 프로젝트 - 리팩토링] 카테고리 N+1 문제 해결과 Redis 캐싱 적용 (4) | 2025.08.07 |
[쇼핑몰 프로젝트] FeignClient 에러 처리: ErrorDecoder (0) | 2025.08.02 |
[쇼핑몰 프로젝트] Spring 예외 처리: ErrorCode 기반 공통 처리로 단순화하기 (3) | 2025.08.01 |
[쇼핑몰 프로젝트] Spring Cloud Gateway와 OpenFeign을 같이 사용하면 발생하는 문제 (3) | 2025.08.01 |