Skip to content

Commit e57c937

Browse files
authored
Merge pull request #64 from Leets-Official/refactor/filter-search
[refactor] 도서 필터 검색 로직 수정
2 parents 0df8677 + 839509b commit e57c937

5 files changed

Lines changed: 79 additions & 10 deletions

File tree

src/main/kotlin/com/stepbookstep/server/domain/book/application/BookQueryService.kt

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,21 +75,36 @@ class BookQueryService(
7575
level: Int?,
7676
pageRanges: List<String>?,
7777
origin: String?,
78-
genre: String?
78+
genre: String?,
79+
keyword: String?,
80+
cursor: Long?
7981
): BookFilterResponse {
82+
// 필터 없이 검색어만 입력한 경우 예외 처리
83+
val hasFilter = level != null || !pageRanges.isNullOrEmpty() || origin != null || genre != null
84+
if (!hasFilter && !keyword.isNullOrBlank()) {
85+
throw CustomException(ErrorCode.FILTER_REQUIRED, null)
86+
}
87+
8088
// 유효성 검증
8189
validateFilterParams(level, pageRanges, origin, genre)
8290

8391
val spec = Specification.where(BookSpecification.withLevel(level))
8492
.and(BookSpecification.withPageRange(pageRanges))
8593
.and(BookSpecification.withOrigin(origin))
8694
.and(BookSpecification.withGenre(genre))
95+
.and(BookSpecification.withKeyword(keyword))
96+
.and(BookSpecification.withCursor(cursor))
97+
98+
// PAGE_SIZE + 1개 조회하여 다음 페이지 존재 여부 확인
99+
val pageable = PageRequest.of(0, PAGE_SIZE + 1, Sort.by(Sort.Direction.ASC, "id"))
100+
val result = bookRepository.findAll(spec, pageable).content
87101

88-
val pageable = PageRequest.of(0, PAGE_SIZE, Sort.by(Sort.Direction.DESC, "createdAt"))
89-
val result = bookRepository.findAll(spec, pageable)
102+
val hasNext = result.size > PAGE_SIZE
103+
val books = if (hasNext) result.dropLast(1) else result
90104

91105
return BookFilterResponse.of(
92-
books = result.content
106+
books = books,
107+
hasNext = hasNext
93108
)
94109
}
95110

src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookSpecification.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,44 @@ object BookSpecification {
7979
}
8080
}
8181
}
82+
83+
/**
84+
* 키워드 검색 필터 (제목, 저자, 출판사)
85+
* 공백으로 구분된 각 키워드가 title/author/publisher 중 하나에 포함되면 검색됨
86+
*/
87+
fun withKeyword(keyword: String?): Specification<Book> {
88+
return Specification { root, _, cb ->
89+
if (keyword.isNullOrBlank()) {
90+
null
91+
} else {
92+
val keywords = keyword.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }
93+
if (keywords.isEmpty()) {
94+
null
95+
} else {
96+
val keywordPredicates = keywords.map { word ->
97+
val pattern = "%$word%"
98+
cb.or(
99+
cb.like(root.get("title"), pattern),
100+
cb.like(root.get("author"), pattern),
101+
cb.like(root.get("publisher"), pattern)
102+
)
103+
}
104+
cb.and(*keywordPredicates.toTypedArray())
105+
}
106+
}
107+
}
108+
}
109+
110+
/**
111+
* 커서 기반 페이지네이션 (id > cursor)
112+
*/
113+
fun withCursor(cursor: Long?): Specification<Book> {
114+
return Specification { root, _, cb ->
115+
if (cursor == null) {
116+
null
117+
} else {
118+
cb.greaterThan(root.get("id"), cursor)
119+
}
120+
}
121+
}
82122
}

src/main/kotlin/com/stepbookstep/server/domain/book/presentation/BookController.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,24 @@ class BookController(
6363
@Operation(
6464
summary = "도서 필터 검색",
6565
description = """
66-
선택한 필터 조건에 맞는 도서 목록을 조회합니다. (최대 20권씩 페이징)
66+
선택한 필터 조건에 맞는 도서 목록을 조회합니다.
6767
6868
## 필터 옵션
6969
- **level**: 난이도 (1, 2, 3)
7070
- **pageRange**: 분량 (~200, 201~250, 251~350, 351~500, 501~650, 651~) - 중복 선택 가능
7171
- **origin**: 국가별 (한국소설, 영미소설, 중국소설, 일본소설, 프랑스소설, 독일소설)
7272
- **genre**: 장르별 (로맨스, 희곡, 무협소설, 판타지/환상문학, 역사소설, 라이트노벨, 추리/미스터리, 과학소설(SF), 액션/스릴러, 호러/공포소설)
73+
- **keyword**: 검색어 (제목, 저자, 출판사에서 검색) - 필터 선택 후 사용 가능
74+
75+
## 페이지네이션
76+
- **cursor**: 마지막으로 조회한 bookId (첫 요청 시 생략)
77+
- **size**: 조회할 개수 (고정값 20)
78+
- **hasNext**: 다음 페이지 존재 여부
79+
- 정렬: id 오름차순
7380
7481
모든 필터는 선택 사항이며, 복수 필터 적용 시 AND 조건으로 검색됩니다.
7582
pageRange는 중복 선택 시 OR 조건으로 검색됩니다.
83+
keyword 입력 시 필터링된 결과 내에서 추가로 검색됩니다.
7684
유효하지 않은 필터 값을 입력하면 400 Bad Request 에러가 반환됩니다.
7785
"""
7886
)
@@ -81,9 +89,11 @@ class BookController(
8189
@Parameter(description = "난이도") @RequestParam(required = false) level: Int?,
8290
@Parameter(description = "분량 (중복 선택 가능)") @RequestParam(required = false) pageRange: List<String>?,
8391
@Parameter(description = "국가별 분류") @RequestParam(required = false) origin: String?,
84-
@Parameter(description = "장르별 분류") @RequestParam(required = false) genre: String?
92+
@Parameter(description = "장르별 분류") @RequestParam(required = false) genre: String?,
93+
@Parameter(description = "검색어 (제목, 저자, 출판사)") @RequestParam(required = false) keyword: String?,
94+
@Parameter(description = "마지막으로 조회한 bookId (첫 요청 시 생략)") @RequestParam(required = false) cursor: Long?
8595
): ResponseEntity<ApiResponse<BookFilterResponse>> {
86-
val response = bookQueryService.filter(level, pageRange, origin, genre)
96+
val response = bookQueryService.filter(level, pageRange, origin, genre, keyword, cursor)
8797
return ResponseEntity.ok(ApiResponse.ok(response))
8898
}
8999
}

src/main/kotlin/com/stepbookstep/server/domain/book/presentation/dto/BookFilterResponse.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package com.stepbookstep.server.domain.book.presentation.dto
33
import com.stepbookstep.server.domain.book.domain.Book
44

55
data class BookFilterResponse(
6-
val books: List<BookFilterItem>
6+
val books: List<BookFilterItem>,
7+
val hasNext: Boolean
78
) {
89
companion object {
9-
fun of(books: List<Book>): BookFilterResponse {
10+
fun of(books: List<Book>, hasNext: Boolean): BookFilterResponse {
1011
return BookFilterResponse(
11-
books = books.map { BookFilterItem.from(it) }
12+
books = books.map { BookFilterItem.from(it) },
13+
hasNext = hasNext
1214
)
1315
}
1416
}

src/main/kotlin/com/stepbookstep/server/global/response/ErrorCode.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ enum class ErrorCode(
112112
INVALID_ORIGIN(2005, HttpStatus.BAD_REQUEST, "유효하지 않은 국가입니다."),
113113
INVALID_GENRE(2006, HttpStatus.BAD_REQUEST, "유효하지 않은 장르입니다."),
114114
USER_BOOK_NOT_FOUND(2007, HttpStatus.NOT_FOUND,"서재에 해당 도서가 존재하지 않습니다."),
115+
FILTER_REQUIRED(2008, HttpStatus.BAD_REQUEST, "필터를 하나 이상 선택해주세요."),
116+
NO_MORE_BOOKS(2009, HttpStatus.BAD_REQUEST, "마지막 페이지입니다."),
115117

116118
// ========================
117119
// 3000~3999 : 루틴 관련 에러

0 commit comments

Comments
 (0)