fix: 납부자 목록 조회 정렬 중복 제거 및 criteria 파라미터 오동작 수정 - #150
Merged
Conversation
#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
#142 에서
Payer인덱스를 조회 정렬 순서에 맞춰(enrollment_year DESC, name)으로 바꿨는데, 쿼리가 그 인덱스를 정렬에 쓸 수 없는 모양으로 나가고 있습니다. 인덱스는 만들어졌지만 쓰이지 않는 상태입니다.1. ORDER BY 가 두 곳에서 지정되고 있습니다.
findAllByNameContaining()의@Query에 ORDER BY 가 하드코딩된 상태에서,PayerService가PageRequest에도 Sort 를 넘기고 있습니다. Spring Data JPA 는@Query에 ORDER BY 가 있으면 Pageable 의 Sort 를 지우는 게 아니라 뒤에 이어붙입니다. 실제로 나가는 SQL:앞 두 키까지는 인덱스와 맞지만 뒤에 키가 더 붙는 순간 MySQL 은 인덱스 순서로 정렬을 대체하지 못하고, 조건에 걸린 전체 행을 filesort 합니다.
부수적으로
criteria파라미터가 죽어 있습니다. 하드코딩된enrollment_year desc가 항상 첫 번째 키라서 어떤 값을 넣어도 정렬이 바뀌지 않고, 엔티티에 없는 프로퍼티명이나 빈 문자열을 넣으면p.가 그대로 HQL 에 붙어 쿼리가 깨지면서 500 이 납니다.2. 검색어가 없어도
LIKE '%%'를 태웁니다.SearchCondition.search의 기본값이""인데 분기가 없어, 목록 첫 진입에도 술어가 붙습니다. 한 건도 걸러내지 못하면서 선행 와일드카드 때문에 인덱스 접근만 막습니다.❓ 왜 해결해야 하나요?
criteria로 인한 500 은 지금 당장의 결함입니다. API 스펙에 노출된 파라미터인데 동작하지 않을 뿐 아니라, 임의값이 들어오면 서버 에러로 떨어지는 경로가 열려 있습니다.정렬 중복은 지금은 비용이 작지만 잠복성 결함입니다. 현재
payer테이블은 2천 건 규모라 filesort 비용이 유의미하지 않습니다. 다만 #142 가 목표한 "정렬 비용 제거" 가 실제로는 달성되지 않은 상태이고, 인덱스만 있고 쓰이지 않으니 쓰기 비용만 늘고 읽기 이득은 없습니다. 납부자는 학기마다 누적되는 데이터라 규모가 커질 때 드러납니다.⭐ 어떻게 해결했나요?
PayerRepository.findAllByNameContaining()—@Query에서 ORDER BY 제거. 정렬은 Pageable 의 Sort 에만 맡깁니다. (RentalRepository.findAllByMemberNameContaining()이 이미 쓰고 있는 방식과 동일)PayerService.resolveSort()— 정렬 생성을 이 메서드 한 곳으로 모았습니다.기본 정렬이
idx_payer_enrollment_year_name(enrollment_year DESC, name)의 순서와 일치합니다. InnoDB 보조 인덱스는 뒤에 PK 가 붙으므로id ASC까지 인덱스 순서 그대로입니다.criteria는 화이트리스트로 받아 알 수 없는 값이면 기본 정렬로 떨어집니다.PayerService.getAllPayers()— 검색어가 비어 있으면 술어 없이findAll(pageable)로 보냅니다.결과 SQL (검색어 없는 경우):
🧩 이 PR의 한계 & 트레이드오프
응답시간 개선 효과는 지금 규모에선 사실상 없습니다. 2천 행 filesort 는 1ms 미만입니다. 이 PR 은 성능 수치를 움직이려는 게 아니라, 정렬이 두 곳에 흩어져 있던 걸 한 곳으로 모으고
criteria의 500 을 막는 게 목적입니다. 성능은 규모가 커졌을 때를 위한 예방입니다.EXPLAIN 으로 확인하지 않았습니다. 코드 레벨 수정만 했고, MySQL 이 실제로 이 인덱스를 정렬에 채택하는지는 검증되지 않았습니다. 보조 인덱스 뒤에 붙는 PK 를 ORDER BY 에 활용하는 건
optimizer_switch의use_index_extensions에 의존하고, DESC 인덱스는 MySQL 8.0 이상이 필요합니다.검색어가 있는 경로는 그대로입니다.
LIKE '%김%'은 선행 와일드카드라 여전히 인덱스로 범위를 좁히지 못합니다. 분기로 풀 수 있는 문제가 아니고 검색 방식 자체를 바꿔야 해서 이번 범위에서 뺐습니다.count 쿼리는 여전히 매 요청 전체를 셉니다. WHERE 가 빠져 행마다 LIKE 를 평가하는 비용은 사라졌지만
count(*)자체는 남습니다.깊은 페이지는 개선되지 않습니다.
pageNo=500이면LIMIT 5000, 10으로 5010 건을 읽고 버립니다. 커서 기반 페이징은 API 스펙 변경이라 별도 논의가 필요합니다.⛓️ 기존 기능에 미치는 영향
응답의 행 순서는 바뀌지 않습니다. 기존에도 중복으로 붙던
id asc가 동점 처리 역할을 하고 있어서, before/after 의 최종 정렬 결과는 동일합니다. 정렬 방법만 바뀝니다.criteria파라미터의 동작이 달라집니다. 이전에는 어떤 값이든 무시됐지만 이제criteria=name이 실제로 동작합니다. 프론트에서 이 값을 보내고 있다면 정렬이 실제로 바뀌므로 확인이 필요합니다.AdminPayerApi에는 주석 처리만 되어 있어 실사용 여부를 코드에서 판단하지 못했습니다.공백만 있는 검색어의 동작이 달라집니다.
isEmpty()가 아니라isBlank()로 판단해서,search=" "는 이제 "검색어 없음" 으로 전체 목록을 반환합니다. 이전에는LIKE '% %'로 이름에 공백이 든 행만 찾았습니다.DB 스키마 변경은 없습니다. 다른 도메인에 미치는 영향도 없습니다.
🔀 Edge Case & 실패 시나리오
criteria미지정enrollment_year desc, name asccriteria=namename asc, enrollment_year desc, id asccriteria=존재하지않는필드criteria=(빈 문자열)p.가 붙어 쿼리 깨짐 → 500search미지정LIKE '%%'풀스캔search=" "LIKE '% %'search=김LIKE '%김%'📋 검토한 대안과 선택 이유
@query 를 남기고 Pageable 의 Sort 를 없애는 방법 — 정렬이 리포지토리에 고정되어
criteria를 아예 못 쓰게 됩니다. 또 대여 이력 조회(RentalRepository)가 이미 "정렬은 Pageable" 방식이라 두 도메인의 패턴이 갈립니다. Pageable 쪽으로 통일했습니다.WHERE (:name IS NULL OR p.name LIKE ...)로 한 쿼리에 합치는 방법 — 분기가 사라져 코드는 짧아지지만, 옵티마이저가 실행계획을 하나로 고정해버려 검색어 없는 경우에도 인덱스를 못 타게 될 수 있습니다. 명시적 분기를 택했습니다.criteria를 enum 으로 받는 방법 — 타입 레벨에서 막히지만PageableCondition이 대여 등 다른 도메인과 공유되는 글로벌 DTO라 도메인별 정렬 키를 하나의 enum 으로 표현하기 어렵습니다. 서비스 안 화이트리스트로 두었습니다.💬 리뷰 포인트
[r]criteria가 실제로 동작하기 시작합니다. 프론트에서 이 파라미터를 보내고 있는지 확인 부탁드립니다. 보내고 있다면 정렬 결과가 바뀝니다.[c]이 PR 은 성능 수치를 목표로 하지 않습니다. 현재 관측 중인GET /admin/members/payers의 p95 는 이 변경으로 개선되지 않을 가능성이 높고, 원인은 따로 봐야 합니다.[c]resolveSort()의 화이트리스트에name만 넣었습니다. 필요한 정렬 기준이 더 있으면 알려주세요.[a]isBlank()vsisEmpty()— 공백만 입력한 경우를 "검색어 없음" 으로 봤는데, 이견 있으면isEmpty()로 바꾸겠습니다.🧪 테스트
./gradlew compileKotlin통과. 납부자 도메인에는 현재 테스트가 없어 자동 검증은 없는 상태입니다.