From 7c952883ed8f791f7a8f18b97a3fd03d190f5853 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:30:48 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=82=A9=EB=B6=80=EC=9E=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20filesort=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20-=20#142?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #142에서 인덱스를 (enrollment_year DESC, name) 순서로 맞췄지만 정렬 비용은 그대로 남아 있었다. 원인은 두 가지다. 1. ORDER BY가 두 곳에서 지정됨 findAllByNameContaining()의 @Query에 ORDER BY가 하드코딩된 상태에서 PageRequest에도 Sort를 넘기고 있었다. Spring Data는 @Query에 ORDER BY가 있으면 Pageable의 Sort를 지우지 않고 뒤에 이어붙이기 때문에 실제 SQL은 `order by enrollment_year desc, name asc, enrollment_year desc, id asc`가 되고, 정렬 키가 인덱스와 어긋나 매 조회마다 filesort가 발생했다. 덤으로 하드코딩된 키가 항상 앞에 오는 탓에 criteria 파라미터는 무시됐고, 엔티티에 없는 이름을 넣으면 HQL이 깨져 500이 났다. @Query에서 ORDER BY를 걷어내고 정렬 생성을 resolveSort() 한 곳으로 모았다. criteria는 화이트리스트로 받아 알 수 없는 값이면 기본 정렬로 떨어진다. 2. 검색어가 없어도 LIKE '%%'를 태움 SearchCondition.search의 기본값이 ""라 목록 첫 진입에도 술어가 붙었다. 한 건도 걸러내지 못하면서 선행 와일드카드로 인덱스 접근만 막는다. 검색어가 비어 있으면 술어 없이 findAll(pageable)로 보낸다. 응답의 행 순서는 바뀌지 않는다. 기존에도 뒤에 붙던 id asc가 동점 처리 역할을 하고 있어 최종 정렬 결과는 동일하고, 정렬 방법만 filesort에서 인덱스 순서 스캔으로 바뀐다. Co-Authored-By: Claude Opus 5 --- .../payer/repository/PayerRepository.kt | 4 +- .../domain/payer/service/PayerService.kt | 38 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt index 091cdd8..114cc80 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt @@ -13,7 +13,9 @@ interface PayerRepository : JpaRepository { @Query("select p from Payer p where p.id in :ids") fun findAllByIds(@Param("ids") ids: List): List - @Query("SELECT p FROM Payer p WHERE p.name LIKE CONCAT('%', :name, '%') ORDER BY p.enrollmentYear DESC, p.name") + // 정렬은 Pageable의 Sort에만 맡긴다. 여기에 ORDER BY를 같이 두면 Spring Data가 + // Sort를 지우지 않고 뒤에 이어붙여 정렬 키가 중복된다. (PayerService.resolveSort 참고) + @Query("SELECT p FROM Payer p WHERE p.name LIKE CONCAT('%', :name, '%')") fun findAllByNameContaining(@Param("name") name: String, pageable: Pageable): Page fun findAllByEnrollmentYear(enrollmentYear: String): List diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt index 9e3d767..e475bf4 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt @@ -65,10 +65,42 @@ class PayerService( val pageRequest = PageRequest.of( pageableCondition.pageNo, pageableCondition.size, - Sort.by(Sort.Direction.DESC, pageableCondition.criteria ?: "enrollmentYear") - .and(Sort.by(Sort.Direction.ASC, "id")) + resolveSort(pageableCondition.criteria) + ) + val search = searchCondition.search + + // 검색어가 없으면 LIKE '%%'는 한 건도 걸러내지 못하면서 선행 와일드카드 때문에 + // 인덱스 접근만 막는다. 목록 첫 진입이 이 경로이므로 술어 자체를 빼서 + // 정렬 인덱스를 타고 LIMIT에서 끊게 한다. + if (search.isBlank()) { + return payerRepository.findAll(pageRequest) + } + + return payerRepository.findAllByNameContaining(search, pageRequest) + } + + /** + * 납부자 목록의 정렬 기준을 만드는 유일한 지점. + * + * 기본 정렬은 `idx_payer_enrollment_year_name(enrollment_year DESC, name)` 의 순서와 맞춘다. + * InnoDB 보조 인덱스는 뒤에 PK가 붙으므로 `id ASC` 까지 인덱스 순서로 읽을 수 있고, + * 정렬 키가 인덱스와 어긋나면 조건에 걸린 전체 행을 filesort 하게 된다. + * + * criteria는 클라이언트가 넘기는 값이라 화이트리스트로 받는다. 선언형 @Query에는 + * 프로퍼티명이 검증 없이 그대로 붙어서, 없는 이름이 들어오면 쿼리 자체가 깨진다. + */ + private fun resolveSort(criteria: String?): Sort = when (criteria) { + "name" -> Sort.by( + Sort.Order.asc("name"), + Sort.Order.desc("enrollmentYear"), + Sort.Order.asc("id") + ) + + else -> Sort.by( + Sort.Order.desc("enrollmentYear"), + Sort.Order.asc("name"), + Sort.Order.asc("id") ) - return payerRepository.findAllByNameContaining(searchCondition.search, pageRequest) } @Transactional