From ccff5287902c901043b0c20d9784cb4b245d4786 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 21:10:15 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=EC=9D=98=20LIKE=20=EC=99=80=EC=9D=BC?= =?UTF-8?q?=EB=93=9C=EC=B9=B4=EB=93=9C=EB=A5=BC=20=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/ac/kookmin/stream/db/member/MemberJpaRepository.java | 4 +++- .../kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java index 287eeb7f..7502c8fe 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java @@ -19,10 +19,12 @@ public interface MemberJpaRepository extends JpaRepository findAllByIdInAndIsDeletedFalse(List ids); + // keyword는 LikePatterns.escape로 이스케이프한 검색어다. 쿼리의 ESCAPE 문자는 LikePatterns.ESCAPE와 같아야 한다 @Query(""" SELECT m.id FROM MemberJpaEntity m WHERE m.isDeleted = false - AND (m.name LIKE CONCAT('%', :keyword, '%') OR m.studentId LIKE CONCAT('%', :keyword, '%')) + AND (m.name LIKE CONCAT('%', :keyword, '%') ESCAPE '!' + OR m.studentId LIKE CONCAT('%', :keyword, '%') ESCAPE '!') """) List searchIdsByKeyword(@Param("keyword") String keyword); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java index 0dba47a5..e8a654fe 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java @@ -4,6 +4,7 @@ import java.util.Optional; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil; +import kr.ac.kookmin.stream.db.common.LikePatterns; import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode; import kr.ac.kookmin.stream.member.domain.member.repository.MemberRepository; @@ -46,7 +47,7 @@ public List findAllByIds(List ids) { @Override public List searchIdsByKeyword(String keyword) { - return memberJpaRepository.searchIdsByKeyword(keyword); + return memberJpaRepository.searchIdsByKeyword(LikePatterns.escape(keyword)); } @Override From 2eac23fe63203e48fcc97ab9cccc948715673e73 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 21:10:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EB=AC=BC=ED=92=88=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=9D=84=20LIKE=20=EC=9D=B4=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=ED=94=84=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java | 6 +++--- .../kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java index 2bb2e7fc..d4568bd2 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java @@ -9,12 +9,12 @@ public interface ItemJpaRepository extends JpaRepository { - // 커서가 없으면 첫 페이지, 있으면 그 다음부터 조회한다. keyword는 이름 부분 일치 검색이다. - // LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다 + // 커서가 없으면 첫 페이지, 있으면 그 다음부터 조회한다. keyword는 이름 부분 일치 검색이며 LikePatterns.escape로 + // 이스케이프한 값을 받는다. 쿼리의 ESCAPE 문자는 LikePatterns.ESCAPE와 같아야 한다 @Query(""" SELECT i FROM ItemJpaEntity i WHERE (:category IS NULL OR i.category = :category) - AND (:keyword IS NULL OR LOCATE(:keyword, i.name) > 0) + AND (:keyword IS NULL OR i.name LIKE CONCAT('%', :keyword, '%') ESCAPE '!') AND (:cursorName IS NULL OR i.name > :cursorName OR (i.name = :cursorName AND i.id > :cursorId)) ORDER BY i.name ASC, i.id ASC """) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java index e21139f2..8eff8bd6 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.db.common.LikePatterns; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor; @@ -22,7 +23,7 @@ public class ItemRepositoryImpl implements ItemRepository { public CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size) { Pageable pageable = Pageable.ofSize(size + 1); List entities = itemJpaRepository.findSlice( - category, keyword, + category, LikePatterns.escape(keyword), cursor == null ? null : cursor.name(), cursor == null ? null : cursor.id(), pageable From 47ee5e541310f0813278ca8add0f5918ed265586 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 21:10:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=20=EC=A0=95=EB=A6=AC=EB=A5=BC=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/welfare/rental/AppRentalController.java | 2 +- .../api/app/welfare/rental/request/ItemListParams.java | 5 ----- .../stream/member/domain/member/service/MemberService.java | 4 ++++ .../member/domain/member/service/impl/MemberServiceImpl.java | 2 +- .../welfare/domain/rental/repository/ItemRepository.java | 2 +- .../stream/welfare/domain/rental/service/ItemService.java | 2 +- .../welfare/domain/rental/service/impl/ItemServiceImpl.java | 4 +++- 7 files changed, 11 insertions(+), 10 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java index 0be6a11a..21b57e58 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java @@ -45,7 +45,7 @@ public ApiResponse> getItems( @Valid @ModelAttribute ItemListParams params ) { CursorSliceResult result = itemService.getItems( - params.toCategory(), params.toKeyword(), params.toCursor(), params.sizeOrDefault() + params.toCategory(), params.keyword(), params.toCursor(), params.sizeOrDefault() ); return ApiResponse.success(CursorSliceResponse.from(result, ItemListItemResponse::from)); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java index 2792022c..28ca95df 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java @@ -24,11 +24,6 @@ public ItemCategory toCategory() { return ItemCategory.from(category); } - // 공백뿐인 검색어는 검색하지 않는 것과 같다 - public String toKeyword() { - return keyword == null || keyword.isBlank() ? null : keyword.strip(); - } - public ItemCursor toCursor() { return cursor == null ? null : ItemCursor.from(CursorCodec.decode(cursor)); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java index b0cded3e..9b43a5e3 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java @@ -11,6 +11,10 @@ public interface MemberService { Member getById(Long id); List findAllByIds(List ids); Map getMapByIds(List ids); + /** + * 이름 또는 학번에 검색어가 포함된 회원 ID를 조회한다. 앞뒤 공백은 지우고 찾는다. + * 검색할지 말지는 호출하는 쪽이 정하므로, 공백뿐인 검색어는 넘기지 않는다. + */ List searchIdsByKeyword(String keyword); Member updateProfile(Long id, MemberProfileCommand command); Member registerOrUpdateByStudentId(MemberProfileCommand command); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java index 16babadf..10af5890 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java @@ -46,7 +46,7 @@ public Map getMapByIds(List ids) { @Override public List searchIdsByKeyword(String keyword) { - return memberRepository.searchIdsByKeyword(keyword); + return memberRepository.searchIdsByKeyword(keyword.strip()); } @Override diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java index 4022a086..6ac141bd 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java @@ -14,7 +14,7 @@ public interface ItemRepository { * 물품을 이름 오름차순(같으면 식별자 오름차순)으로 커서 페이지 단위로 조회한다. * * @param category 카테고리. null이면 전체 - * @param keyword 이름에 포함된 검색어. null이면 검색하지 않는다 + * @param keyword 이름에 포함된 검색어. 호출하는 쪽이 공백을 정리해서 넘긴다(공백뿐이면 null). null이면 검색하지 않는다 * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 */ CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java index 8ccf9b7e..00a8502e 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java @@ -11,7 +11,7 @@ public interface ItemService { * 물품 목록을 이름순 커서 페이지로 조회한다. * * @param category 카테고리. null이면 전체 - * @param keyword 이름 검색어. null이면 검색하지 않는다 + * @param keyword 이름 검색어. null이거나 공백이면 검색하지 않는다 * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 */ CursorSliceResult getItems(ItemCategory category, String keyword, ItemCursor cursor, int size); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java index a5373542..263eb521 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java @@ -21,7 +21,9 @@ class ItemServiceImpl implements ItemService { // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다. 쿼리가 늘어 한 스냅샷이 필요해지면 @Transactional(readOnly = true)를 붙인다. @Override public CursorSliceResult getItems(ItemCategory category, String keyword, ItemCursor cursor, int size) { - return itemRepository.findSlice(category, keyword, cursor, size); + // 공백뿐인 검색어는 검색하지 않는 것과 같다 + String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.strip(); + return itemRepository.findSlice(category, normalizedKeyword, cursor, size); } @Override From 5f80545ecae399852eb9eff770f951b9ba3e6a34 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 21:16:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=EB=A5=BC=20LikePatterns.escape=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.java | 4 ++-- .../member/service/impl/MemberServiceImpl.java | 2 +- .../rental/repository/ItemRepository.java | 2 +- .../rental/service/impl/ItemServiceImpl.java | 4 +--- .../kookmin/stream/db/common/LikePatterns.java | 17 +++++++++-------- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java index 9b43a5e3..4042dbe5 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java @@ -12,8 +12,8 @@ public interface MemberService { List findAllByIds(List ids); Map getMapByIds(List ids); /** - * 이름 또는 학번에 검색어가 포함된 회원 ID를 조회한다. 앞뒤 공백은 지우고 찾는다. - * 검색할지 말지는 호출하는 쪽이 정하므로, 공백뿐인 검색어는 넘기지 않는다. + * 이름 또는 학번에 검색어가 포함된 회원 ID를 조회한다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 그대로 찾는다. + * 검색할지 말지는 호출하는 쪽이 정하므로, 검색어가 null이거나 공백뿐이면 일치하는 회원이 없어 빈 목록이다. */ List searchIdsByKeyword(String keyword); Member updateProfile(Long id, MemberProfileCommand command); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java index 10af5890..16babadf 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java @@ -46,7 +46,7 @@ public Map getMapByIds(List ids) { @Override public List searchIdsByKeyword(String keyword) { - return memberRepository.searchIdsByKeyword(keyword.strip()); + return memberRepository.searchIdsByKeyword(keyword); } @Override diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java index 6ac141bd..3d3b892c 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java @@ -14,7 +14,7 @@ public interface ItemRepository { * 물품을 이름 오름차순(같으면 식별자 오름차순)으로 커서 페이지 단위로 조회한다. * * @param category 카테고리. null이면 전체 - * @param keyword 이름에 포함된 검색어. 호출하는 쪽이 공백을 정리해서 넘긴다(공백뿐이면 null). null이면 검색하지 않는다 + * @param keyword 이름에 포함된 검색어. null이거나 공백이면 검색하지 않는다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 그대로 검색한다 * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 */ CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java index 263eb521..a5373542 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java @@ -21,9 +21,7 @@ class ItemServiceImpl implements ItemService { // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다. 쿼리가 늘어 한 스냅샷이 필요해지면 @Transactional(readOnly = true)를 붙인다. @Override public CursorSliceResult getItems(ItemCategory category, String keyword, ItemCursor cursor, int size) { - // 공백뿐인 검색어는 검색하지 않는 것과 같다 - String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.strip(); - return itemRepository.findSlice(category, normalizedKeyword, cursor, size); + return itemRepository.findSlice(category, keyword, cursor, size); } @Override diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/LikePatterns.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/LikePatterns.java index 1e82708d..5b06a4d3 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/LikePatterns.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/LikePatterns.java @@ -4,10 +4,10 @@ import lombok.NoArgsConstructor; /** - * {@code LIKE} 검색어 이스케이프 유틸. + * {@code LIKE} 검색어 유틸. 검색어를 정리하고 이스케이프한다. *

* 사용자 입력을 {@code CONCAT('%', :keyword, '%')}로 그대로 붙이면 검색어 안의 {@code %}·{@code _}가 와일드카드로 - * 동작해서 {@code %}만 입력해도 모든 행이 걸린다. 검색어를 {@link #escape(String)}로 이스케이프해서 넘기고, 쿼리는 + * 동작해서 {@code %}만 입력해도 모든 행이 걸린다. 검색어를 {@link #escape(String)}로 정리·이스케이프해서 넘기고, 쿼리는 * {@code LIKE CONCAT('%', :keyword, '%') ESCAPE '!'}로 받는다. *

* 이스케이프 문자는 {@code \}가 아니라 {@code !}다. MySQL은 문자열 리터럴에서 {@code \}를 이스케이프로 먹기 때문에 @@ -22,18 +22,19 @@ public final class LikePatterns { public static final char ESCAPE = '!'; /** - * 검색어 안의 {@code LIKE} 메타문자({@code %}, {@code _})와 이스케이프 문자를 글자 그대로 검색되도록 이스케이프한다. - * 공백 제거나 "검색어 없음" 판단은 하지 않는다. + * 검색어를 {@code LIKE}에 넘길 수 있게 정리하고 이스케이프한다. 앞뒤 공백을 지우고, 검색어 안의 {@code %}·{@code _}와 + * 이스케이프 문자를 글자 그대로 검색되도록 앞에 이스케이프 문자를 붙인다. * - * @return 이스케이프한 검색어. {@code raw}가 null이면 null + * @return 정리·이스케이프한 검색어. {@code raw}가 null이거나 공백뿐이면 "검색 없음"이라 null */ public static String escape(String raw) { - if (raw == null) { + if (raw == null || raw.isBlank()) { return null; } - StringBuilder escaped = new StringBuilder(raw.length() + 4); - for (char c : raw.toCharArray()) { + String keyword = raw.strip(); + StringBuilder escaped = new StringBuilder(keyword.length() + 4); + for (char c : keyword.toCharArray()) { if (c == ESCAPE || c == '%' || c == '_') { escaped.append(ESCAPE); }