Skip to content

Commit 7c95288

Browse files
tnals0924claude
andcommitted
fix: 납부자 목록 조회의 filesort 제거 - #142
#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 <noreply@anthropic.com>
1 parent da4a161 commit 7c95288

2 files changed

Lines changed: 38 additions & 4 deletions

File tree

‎src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ interface PayerRepository : JpaRepository<Payer, Long?> {
1313
@Query("select p from Payer p where p.id in :ids")
1414
fun findAllByIds(@Param("ids") ids: List<Long>): List<Payer>
1515

16-
@Query("SELECT p FROM Payer p WHERE p.name LIKE CONCAT('%', :name, '%') ORDER BY p.enrollmentYear DESC, p.name")
16+
// 정렬은 Pageable의 Sort에만 맡긴다. 여기에 ORDER BY를 같이 두면 Spring Data가
17+
// Sort를 지우지 않고 뒤에 이어붙여 정렬 키가 중복된다. (PayerService.resolveSort 참고)
18+
@Query("SELECT p FROM Payer p WHERE p.name LIKE CONCAT('%', :name, '%')")
1719
fun findAllByNameContaining(@Param("name") name: String, pageable: Pageable): Page<Payer>
1820

1921
fun findAllByEnrollmentYear(enrollmentYear: String): List<Payer>

‎src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,42 @@ class PayerService(
6565
val pageRequest = PageRequest.of(
6666
pageableCondition.pageNo,
6767
pageableCondition.size,
68-
Sort.by(Sort.Direction.DESC, pageableCondition.criteria ?: "enrollmentYear")
69-
.and(Sort.by(Sort.Direction.ASC, "id"))
68+
resolveSort(pageableCondition.criteria)
69+
)
70+
val search = searchCondition.search
71+
72+
// 검색어가 없으면 LIKE '%%'는 한 건도 걸러내지 못하면서 선행 와일드카드 때문에
73+
// 인덱스 접근만 막는다. 목록 첫 진입이 이 경로이므로 술어 자체를 빼서
74+
// 정렬 인덱스를 타고 LIMIT에서 끊게 한다.
75+
if (search.isBlank()) {
76+
return payerRepository.findAll(pageRequest)
77+
}
78+
79+
return payerRepository.findAllByNameContaining(search, pageRequest)
80+
}
81+
82+
/**
83+
* 납부자 목록의 정렬 기준을 만드는 유일한 지점.
84+
*
85+
* 기본 정렬은 `idx_payer_enrollment_year_name(enrollment_year DESC, name)` 의 순서와 맞춘다.
86+
* InnoDB 보조 인덱스는 뒤에 PK가 붙으므로 `id ASC` 까지 인덱스 순서로 읽을 수 있고,
87+
* 정렬 키가 인덱스와 어긋나면 조건에 걸린 전체 행을 filesort 하게 된다.
88+
*
89+
* criteria는 클라이언트가 넘기는 값이라 화이트리스트로 받는다. 선언형 @Query에는
90+
* 프로퍼티명이 검증 없이 그대로 붙어서, 없는 이름이 들어오면 쿼리 자체가 깨진다.
91+
*/
92+
private fun resolveSort(criteria: String?): Sort = when (criteria) {
93+
"name" -> Sort.by(
94+
Sort.Order.asc("name"),
95+
Sort.Order.desc("enrollmentYear"),
96+
Sort.Order.asc("id")
97+
)
98+
99+
else -> Sort.by(
100+
Sort.Order.desc("enrollmentYear"),
101+
Sort.Order.asc("name"),
102+
Sort.Order.asc("id")
70103
)
71-
return payerRepository.findAllByNameContaining(searchCondition.search, pageRequest)
72104
}
73105

74106
@Transactional

0 commit comments

Comments
 (0)