Skip to content

Commit 2375561

Browse files
authored
Merge pull request #59 from Project-BookLog/feat/58/search
Feat/58/search
2 parents a92d184 + f813ba5 commit 2375561

File tree

9 files changed

+615
-41
lines changed

9 files changed

+615
-41
lines changed

booklog/src/main/java/com/example/booklog/domain/library/books/repository/BooksRepository.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.example.booklog.domain.library.books.entity.Books;
44
import jakarta.persistence.QueryHint;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
57
import org.springframework.data.jpa.repository.JpaRepository;
68
import org.springframework.data.jpa.repository.Query;
79
import org.springframework.data.jpa.repository.QueryHints;
@@ -58,6 +60,45 @@ public interface BooksRepository extends JpaRepository<Books, Long> {
5860
"JOIN b.bookAuthors ba " +
5961
"WHERE ba.author.id IN :authorIds")
6062
long countBooksByAuthorIds(@Param("authorIds") List<Long> authorIds);
63+
64+
/**
65+
* 도서 제목으로 검색 (페이징 및 정렬 지원)
66+
*
67+
* [정렬 옵션]
68+
* - LATEST: publishedDate DESC, id DESC
69+
* - OLDEST: publishedDate ASC, id ASC
70+
* - TITLE: title ASC
71+
* - AUTHOR: 첫 번째 저자명 ASC (BookAuthors.authorOrder = 1)
72+
*
73+
* @param keyword 검색 키워드 (제목 LIKE 검색)
74+
* @param pageable 페이징 및 정렬 정보
75+
* @return 도서 페이지
76+
*/
77+
@Query("""
78+
SELECT DISTINCT b FROM Books b
79+
LEFT JOIN FETCH b.bookAuthors ba
80+
LEFT JOIN FETCH ba.author a
81+
WHERE b.title LIKE %:keyword%
82+
""")
83+
@QueryHints(@QueryHint(name = "hibernate.query.passDistinctThrough", value = "false"))
84+
Page<Books> searchByTitle(@Param("keyword") String keyword, Pageable pageable);
85+
86+
/**
87+
* 도서 제목 또는 저자명으로 검색 (페이징 및 정렬 지원)
88+
*
89+
* @param keyword 검색 키워드
90+
* @param pageable 페이징 및 정렬 정보
91+
* @return 도서 페이지
92+
*/
93+
@Query("""
94+
SELECT DISTINCT b FROM Books b
95+
LEFT JOIN FETCH b.bookAuthors ba
96+
LEFT JOIN FETCH ba.author a
97+
WHERE b.title LIKE %:keyword%
98+
OR a.name LIKE %:keyword%
99+
""")
100+
@QueryHints(@QueryHint(name = "hibernate.query.passDistinctThrough", value = "false"))
101+
Page<Books> searchByTitleOrAuthor(@Param("keyword") String keyword, Pageable pageable);
61102
}
62103

63104

booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,60 @@
44
import com.example.booklog.domain.search.dto.*;
55
import com.example.booklog.domain.search.service.AuthorSearchService;
66
import com.example.booklog.domain.search.service.BookSearchService;
7+
import com.example.booklog.domain.search.service.IntegratedSearchService;
78
import com.example.booklog.domain.search.service.SearchKeywordService;
89
import lombok.RequiredArgsConstructor;
10+
import org.springframework.http.CacheControl;
911
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
1013
import org.springframework.web.bind.annotation.*;
1114

15+
import java.util.concurrent.TimeUnit;
16+
1217
/**
1318
* 통합 검색 API 컨트롤러
19+
* - 통합 검색: /api/v1/search (GET)
1420
* - 도서 검색: /api/v1/search/books
1521
* - 작가 검색: /api/v1/search/authors
1622
* - 검색어 저장: /api/v1/search/keywords (POST)
1723
* - 최근 검색어 조회: /api/v1/search/recent (GET)
1824
* - 추천 검색어 조회: /api/v1/search/recommendations (GET)
19-
* - 통합 검색: /api/v1/search (추후 구현)
2025
*/
2126
@RestController
2227
@RequiredArgsConstructor
2328
@RequestMapping("/api/v1/search")
2429
public class SearchController {
2530

31+
private final IntegratedSearchService integratedSearchService;
2632
private final BookSearchService bookSearchService;
2733
private final AuthorSearchService authorSearchService;
2834
private final SearchKeywordService searchKeywordService;
2935

3036
/**
3137
* 도서 검색
32-
* GET /api/v1/search/books?query={검색어}&page={페이지}&size={크기}
38+
* GET /api/v1/search/books?query={검색어}&page={페이지}&size={크기}&sort={정렬}
39+
*
40+
* [정렬 옵션]
41+
* - latest: 최신순 (출판일 내림차순) - 기본값
42+
* - oldest: 오래된순 (출판일 오름차순)
43+
* - title: 제목순 (가나다순)
44+
* - author: 저자순 (첫 번째 저자 기준 가나다순)
45+
*
46+
* @param query 검색어 (필수)
47+
* @param page 페이지 번호 (1부터 시작, 기본값: 1)
48+
* @param size 페이지 크기 (기본값: 10)
49+
* @param sort 정렬 기준 (기본값: latest)
50+
* @return 도서 검색 결과
3351
*/
3452
@GetMapping("/books")
3553
public BookSearchResponse searchBooks(
3654
@RequestParam String query,
3755
@RequestParam(defaultValue = "1") int page,
38-
@RequestParam(defaultValue = "10") int size
56+
@RequestParam(defaultValue = "10") int size,
57+
@RequestParam(defaultValue = "latest") String sort
3958
) {
40-
return bookSearchService.searchBooks(query, page, size);
59+
BookSortType sortType = BookSortType.from(sort);
60+
return bookSearchService.searchBooks(query, page, size, sortType);
4161
}
4262

4363
/**
@@ -125,11 +145,46 @@ public RecommendationSearchResponse getRecommendations() {
125145
}
126146

127147
/**
128-
* 통합 검색 (추후 구현)
129-
* GET /api/v1/search?query={검색어}&page={페이지}&size={크기}
148+
* 통합 검색 API
149+
* GET /api/v1/search?query={검색어}&sort={정렬기준}
150+
*
151+
* [기능 설명]
152+
* - 검색어에 대해 "작가"와 "도서"를 통합하여 조회
153+
* - 전체 탭에서 사용되며, 각 영역별로 제한된 개수(5개)만 표시
154+
* - 더 많은 결과를 보려면 개별 탭(/books, /authors)으로 이동
155+
*
156+
* [응답 구조]
157+
* - authors: 작가 검색 결과 (요약 정보, 최대 5명)
158+
* - books: 도서 검색 결과 (기본 정보, 최대 5권)
159+
* - 각 영역의 totalCount로 전체 개수 파악 가능
160+
*
161+
* [캐시 정책]
162+
* - Cache-Control: max-age=60 (60초)
163+
* - 동일한 query + sort 조합에 대해 클라이언트 캐시 활용
164+
*
165+
* [경계 케이스]
166+
* - 검색어가 없거나 공백인 경우: 400 Bad Request
167+
* - 검색 결과가 없는 경우: 빈 배열과 totalCount=0 반환
168+
* - 정렬 기준이 유효하지 않은 경우: 400 Bad Request
169+
*
170+
* @param query 검색어 (필수, 1~100자)
171+
* @param sort 정렬 기준 (선택, latest|popular, 기본값: latest)
172+
* @return 통합 검색 응답 (작가 + 도서)
130173
*/
131-
// TODO: 통합 검색 구현
132-
// @GetMapping
133-
// public IntegratedSearchResponse search(...)
174+
@GetMapping
175+
public ResponseEntity<IntegratedSearchResponse> search(
176+
@RequestParam String query,
177+
@RequestParam(defaultValue = "latest") String sort
178+
) {
179+
IntegratedSearchResponse response = integratedSearchService.search(query, sort);
180+
181+
// 캐시 헤더 설정 (60초)
182+
CacheControl cacheControl = CacheControl.maxAge(60, TimeUnit.SECONDS)
183+
.cachePublic();
184+
185+
return ResponseEntity.ok()
186+
.cacheControl(cacheControl)
187+
.body(response);
188+
}
134189
}
135190

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.example.booklog.domain.search.dto;
2+
3+
import java.util.List;
4+
5+
/**
6+
* 통합 검색 응답 내 작가 검색 결과
7+
*
8+
* [설계 근거]
9+
* - 전체 검색 탭에서는 작가의 요약 정보만 필요 (책 목록 불포함)
10+
* - totalCount를 통해 "작가 결과 n명" 표시 및 "더보기" 판단
11+
* - 페이징 정보는 제외 (전체 탭에서는 제한된 개수만 노출)
12+
*
13+
* @param totalCount 총 검색된 작가 수
14+
* @param items 작가 요약 정보 리스트 (최대 5개 등 제한)
15+
*/
16+
public record AuthorSearchResult(
17+
int totalCount,
18+
List<AuthorSummary> items
19+
) {
20+
/**
21+
* AuthorSearchResponse로부터 변환
22+
* 작가 검색 결과에서 책 목록을 제외한 요약 정보만 추출
23+
*
24+
* @param response 작가 검색 응답
25+
* @return 작가 검색 결과 (요약)
26+
*/
27+
public static AuthorSearchResult from(AuthorSearchResponse response) {
28+
List<AuthorSummary> summaries = response.items().stream()
29+
.map(AuthorSummary::from)
30+
.toList();
31+
32+
return new AuthorSearchResult(
33+
response.totalCount(),
34+
summaries
35+
);
36+
}
37+
}
38+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.example.booklog.domain.search.dto;
2+
3+
/**
4+
* 통합 검색 응답에서 사용되는 작가 요약 정보
5+
*
6+
* [설계 근거]
7+
* - 전체 탭에서는 작가의 대표작 목록이 필요 없음
8+
* - UI 표시에 필요한 최소한의 정보만 포함 (이름, 프로필 이미지, 직업, 국적)
9+
* - 작가 상세 페이지로 이동하기 위한 authorId 포함
10+
*
11+
* @param authorId 작가 ID
12+
* @param name 작가명
13+
* @param profileImageUrl 프로필 이미지 URL
14+
* @param occupation 직업 (예: 소설가, 시인)
15+
* @param nationality 국적
16+
*/
17+
public record AuthorSummary(
18+
Long authorId,
19+
String name,
20+
String profileImageUrl,
21+
String occupation,
22+
String nationality
23+
) {
24+
/**
25+
* AuthorSearchItemResponse로부터 변환
26+
* 책 목록을 제외한 작가 정보만 추출
27+
*
28+
* @param item 작가 검색 아이템
29+
* @return 작가 요약 정보
30+
*/
31+
public static AuthorSummary from(AuthorSearchItemResponse item) {
32+
return new AuthorSummary(
33+
item.authorId(),
34+
item.name(),
35+
item.profileImageUrl(),
36+
item.occupation(),
37+
item.nationality()
38+
);
39+
}
40+
}
41+
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.example.booklog.domain.search.dto;
2+
3+
import com.example.booklog.domain.library.books.dto.BookSearchItemResponse;
4+
import com.example.booklog.domain.library.books.dto.BookSearchResponse;
5+
6+
import java.util.List;
7+
8+
/**
9+
* 통합 검색 응답 내 도서 검색 결과
10+
*
11+
* [설계 근거]
12+
* - 전체 검색 탭에서는 도서의 기본 정보만 노출
13+
* - totalCount를 통해 "도서 결과 n권" 표시 및 "더보기" 판단
14+
* - 페이징 정보는 제외 (전체 탭에서는 제한된 개수만 노출)
15+
*
16+
* @param totalCount 총 검색된 도서 수
17+
* @param items 도서 정보 리스트 (최대 5개 등 제한)
18+
*/
19+
public record BookSearchResult(
20+
int totalCount,
21+
List<BookSearchItemResponse> items
22+
) {
23+
/**
24+
* BookSearchResponse로부터 변환
25+
*
26+
* @param response 도서 검색 응답
27+
* @return 도서 검색 결과
28+
*/
29+
public static BookSearchResult from(BookSearchResponse response) {
30+
return new BookSearchResult(
31+
response.totalCount(),
32+
response.items()
33+
);
34+
}
35+
}
36+
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.example.booklog.domain.search.dto;
2+
3+
/**
4+
* 도서 검색 정렬 기준
5+
*
6+
* [지원 정렬]
7+
* - LATEST: 최신순 (출판일 내림차순)
8+
* - OLDEST: 오래된 순 (출판일 오름차순)
9+
* - TITLE: 제목 순 (가나다순)
10+
* - AUTHOR: 저자 순 (첫 번째 저자 기준 가나다순)
11+
*/
12+
public enum BookSortType {
13+
LATEST("latest", "최신순"),
14+
OLDEST("oldest", "오래된순"),
15+
TITLE("title", "제목순"),
16+
AUTHOR("author", "저자순");
17+
18+
private final String value;
19+
private final String description;
20+
21+
BookSortType(String value, String description) {
22+
this.value = value;
23+
this.description = description;
24+
}
25+
26+
public String getValue() {
27+
return value;
28+
}
29+
30+
public String getDescription() {
31+
return description;
32+
}
33+
34+
/**
35+
* String 값으로부터 BookSortType 조회
36+
*
37+
* @param value 정렬 기준 문자열 (latest, oldest, title, author)
38+
* @return BookSortType
39+
* @throws IllegalArgumentException 유효하지 않은 정렬 기준인 경우
40+
*/
41+
public static BookSortType from(String value) {
42+
if (value == null) {
43+
return LATEST; // 기본값
44+
}
45+
46+
for (BookSortType type : values()) {
47+
if (type.value.equalsIgnoreCase(value)) {
48+
return type;
49+
}
50+
}
51+
52+
throw new IllegalArgumentException(
53+
String.format("유효하지 않은 정렬 기준입니다: %s (사용 가능: latest, oldest, title, author)", value)
54+
);
55+
}
56+
}
57+
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.example.booklog.domain.search.dto;
2+
3+
import com.example.booklog.domain.library.books.dto.BookSearchResponse;
4+
5+
/**
6+
* 통합 검색 응답 DTO
7+
* 작가 검색 결과와 도서 검색 결과를 통합하여 반환
8+
*
9+
* [설계 근거]
10+
* - 작가/도서 검색 결과를 각각 독립적인 구조로 분리하여 UI에서 영역별 렌더링 용이
11+
* - 각 결과의 totalCount를 제공하여 UI에서 "더보기" 표시 여부 판단 가능
12+
* - 검색어와 정렬 기준을 응답에 포함하여 클라이언트 측 상태 관리 간소화
13+
* - 향후 '작가' 탭, '도서' 탭 분리 시 각각의 API와 구조 재사용 가능
14+
*
15+
* @param query 검색어
16+
* @param sort 정렬 기준 (latest, popular 등)
17+
* @param authors 작가 검색 결과
18+
* @param books 도서 검색 결과
19+
*/
20+
public record IntegratedSearchResponse(
21+
String query,
22+
String sort,
23+
AuthorSearchResult authors,
24+
BookSearchResult books
25+
) {
26+
/**
27+
* 작가/도서 검색 결과로부터 통합 응답 생성
28+
*
29+
* @param query 검색어
30+
* @param sort 정렬 기준
31+
* @param authorResponse 작가 검색 응답
32+
* @param bookResponse 도서 검색 응답
33+
* @return 통합 검색 응답
34+
*/
35+
public static IntegratedSearchResponse of(
36+
String query,
37+
String sort,
38+
AuthorSearchResponse authorResponse,
39+
BookSearchResponse bookResponse
40+
) {
41+
// 작가 검색 결과 변환 (책 목록 제외한 요약 정보만)
42+
AuthorSearchResult authorResult = AuthorSearchResult.from(authorResponse);
43+
44+
// 도서 검색 결과 변환
45+
BookSearchResult bookResult = BookSearchResult.from(bookResponse);
46+
47+
return new IntegratedSearchResponse(query, sort, authorResult, bookResult);
48+
}
49+
}
50+

0 commit comments

Comments
 (0)