조건 없는 검색은 전체인가 빈 값인가
검색어가 비었을 때 전체를 다 긁는 위험과, 기본 스코프·필수 조건·상한으로 자신을 보호하는 검색 설계를 다룬다.
검색 기능을 만들 때 가장 먼저 결정해야 하는 것은 “조건이 하나도 없을 때 무엇을 반환할 것인가”다. 사소해 보이지만 이 한 줄의 정책이 데이터가 백만 건으로 불어난 어느 날 서비스를 멈춘다.
빈 검색어의 두 가지 해석
검색어가 비어 있을 때 시스템은 두 갈래로 갈린다.
- 전체 반환(match-all): 조건이 없으니 모든 행을 돌려준다. 직관적이지만 위험하다.
- 빈 결과 또는 거부: 조건이 없으면 아무것도 주지 않거나 입력을 요구한다.
문제는 동적 쿼리에서 이 분기가 암묵적으로 결정된다는 점이다. 흔한 패턴을 보자.
1
2
3
4
5
6
7
8
9
10
11
<select id="searchUsers" resultType="User">
SELECT id, user_name, email FROM users
<where>
<if test="keyword != null and keyword != ''">
AND user_name LIKE CONCAT('%', #{keyword}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
</select>
모든 <if>가 거짓이면 <where>는 비고, 쿼리는 SELECT ... FROM users가 된다. 즉 조건 없는 검색이 자동으로 전체 풀스캔이 된다. 개발 환경의 수십 건에서는 멀쩡하다가, 운영에서 테이블이 커지면 한 번의 빈 검색이 DB를 마비시킨다.
왜 위험한가 — 내부 메커니즘
옵티마이저 입장에서 WHERE 절이 없는 조회는 “전부 다 읽어라”는 명령이다. 인덱스를 탈 이유가 없으니 풀 테이블 스캔이 발생하고, 정렬(ORDER BY)까지 붙으면 전체를 메모리/디스크에서 정렬한다. 게다가 LIKE 검색 결과를 페이징 없이 통째로 반환하면, DB → 애플리케이션 → 직렬화 → 네트워크 전 구간이 동시에 압박받는다. 한 사용자의 빈 검색이 커넥션 풀을 점유하고, 그 사이 다른 요청들이 대기하다 타임아웃으로 연쇄 실패한다.
방어 설계
빈 검색을 무해하게 만드는 세 가지 장치.
1
2
3
4
5
6
7
8
9
10
public PageResult<User> search(SearchCond cond) {
// 1) 기본 스코프 강제: 조건이 비면 좁은 기본 범위로
if (cond.isEmpty()) {
cond.setStatus(Status.ACTIVE); // 또는 최근 N일
}
// 2) 페이지 크기 상한 (클라이언트가 size=100000 보내도 방어)
int size = Math.min(cond.getSize(), MAX_PAGE_SIZE);
// 3) 항상 페이징 + 정렬 키 고정
return repo.search(cond, cond.getPage() * size, size);
}
- 기본 스코프: 조건이 없으면 “전체”가 아니라 의미 있는 좁은 기본값(활성 상태, 최근 기간)으로 치환한다.
- 상한:
size는 서버가 강제하는 최대치를 넘지 못한다. 클라이언트 값을 믿지 않는다. - 항상 페이징: 검색 결과는 무조건 LIMIT을 건다. “전체 반환”이라는 경로 자체를 제거한다.
운영 함정
관리자 화면은 안전하다는 착각. 내부용 검색이라며 상한을 안 거는 경우가 많은데, 데이터가 쌓이면 내부 화면도 똑같이 멈춘다. 사용자 수가 적어 동시성은 낮아도, 한 번의 무거운 쿼리가 DB 자원을 독점하면 외부 서비스까지 영향받는다. 신뢰 경계와 무관하게 상한은 항상 건다.
핵심 요약
- 빈 검색어를 “전체 반환”으로 두면, 동적 쿼리가 조용히 풀스캔으로 변한다.
- 기본 스코프·페이지 상한·강제 페이징 세 장치로 “전체 반환” 경로 자체를 없앤다.
- 클라이언트가 보낸 페이지 크기를 믿지 말고 서버에서 상한을 강제한다.