From a957aac7f096b1b5a667ffe778db673e553dce4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A4=80=ED=98=84?= Date: Mon, 26 Jan 2026 11:35:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=96=B4=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../search/controller/SearchController.java | 84 +++++++++-- .../search/dto/RecentSearchResponse.java | 20 +++ .../dto/RecommendationSearchResponse.java | 21 +++ .../dto/RecommendedKeywordResponse.java | 28 ++++ .../search/dto/SearchKeywordResponse.java | 28 ++++ .../search/dto/SearchKeywordSaveRequest.java | 19 +++ .../search/entity/RecommendedKeyword.java | 98 +++++++++++++ .../domain/search/entity/SearchKeyword.java | 69 +++++++++ .../RecommendedKeywordRepository.java | 24 ++++ .../repository/SearchKeywordRepository.java | 61 ++++++++ .../search/service/SearchKeywordService.java | 134 ++++++++++++++++++ 11 files changed, 572 insertions(+), 14 deletions(-) create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/RecentSearchResponse.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/RecommendationSearchResponse.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/RecommendedKeywordResponse.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/SearchKeywordResponse.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/SearchKeywordSaveRequest.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/entity/RecommendedKeyword.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/entity/SearchKeyword.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/repository/RecommendedKeywordRepository.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/repository/SearchKeywordRepository.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/service/SearchKeywordService.java diff --git a/booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java b/booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java index 0e3fe5d..ac6c553 100644 --- a/booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java +++ b/booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java @@ -1,16 +1,21 @@ package com.example.booklog.domain.search.controller; import com.example.booklog.domain.library.books.dto.BookSearchResponse; -import com.example.booklog.domain.search.dto.AuthorSearchResponse; +import com.example.booklog.domain.search.dto.*; import com.example.booklog.domain.search.service.AuthorSearchService; import com.example.booklog.domain.search.service.BookSearchService; +import com.example.booklog.domain.search.service.SearchKeywordService; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; /** * 통합 검색 API 컨트롤러 * - 도서 검색: /api/v1/search/books * - 작가 검색: /api/v1/search/authors + * - 검색어 저장: /api/v1/search/keywords (POST) + * - 최근 검색어 조회: /api/v1/search/recent (GET) + * - 추천 검색어 조회: /api/v1/search/recommendations (GET) * - 통합 검색: /api/v1/search (추후 구현) */ @RestController @@ -20,6 +25,7 @@ public class SearchController { private final BookSearchService bookSearchService; private final AuthorSearchService authorSearchService; + private final SearchKeywordService searchKeywordService; /** * 도서 검색 @@ -53,27 +59,77 @@ public AuthorSearchResponse searchAuthors( } /** - * 통합 검색 (추후 구현) - * GET /api/v1/search?query={검색어}&page={페이지}&size={크기} + * 검색어 저장 API + * POST /api/v1/search/keywords + * + * [호출 시점] + * - 검색 실행 시 + * - 추천 검색어 클릭 시 + * - 최근 검색어 클릭 시 + * + * [동작 방식] + * - 동일 검색어가 이미 존재하면 삭제 후 재생성 (최신순 유지) + * - 최대 10개 제한, 초과 시 가장 오래된 검색어 삭제 + * + * @param userId 사용자 ID (임시로 @RequestParam 사용, 추후 인증 적용 시 @AuthenticationPrincipal 등으로 변경) + * @param request 검색어 저장 요청 */ - // TODO: 통합 검색 구현 - // @GetMapping - // public IntegratedSearchResponse search(...) + @PostMapping("/keywords") + @ResponseStatus(HttpStatus.CREATED) + public void saveSearchKeyword( + @RequestParam Long userId, // TODO: 인증 구현 후 변경 필요 + @RequestBody SearchKeywordSaveRequest request + ) { + searchKeywordService.saveSearchKeyword(userId, request.getKeyword()); + } /** - * 최근 검색어 조회 (추후 구현) + * 최근 검색어 조회 API * GET /api/v1/search/recent + * + * [호출 시점] + * - 검색 화면 진입 시 (검색창이 비어 있을 때) + * + * [응답 데이터] + * - 사용자가 이전에 검색 실행을 통해 입력한 검색어 목록 + * - 최신순 정렬 + * - 최대 10개 + * + * @param userId 사용자 ID (임시로 @RequestParam 사용, 추후 인증 적용 시 변경) + * @return 최근 검색어 목록 */ - // TODO: 최근 검색어 구현 - // @GetMapping("/recent") - // public RecentSearchResponse getRecentSearches() + @GetMapping("/recent") + public RecentSearchResponse getRecentSearches( + @RequestParam Long userId // TODO: 인증 구현 후 변경 필요 + ) { + return searchKeywordService.getRecentSearches(userId); + } /** - * 추천 검색어 조회 (추후 구현) + * 추천 검색어 조회 API * GET /api/v1/search/recommendations + * + * [호출 시점] + * - 검색 화면 진입 시 (검색창이 비어 있을 때) + * + * [응답 데이터] + * - 운영자가 관리하는 추천 검색어 목록 + * - 우선순위 순으로 정렬 + * - 로그인 여부와 무관하게 사용 가능 + * + * @return 추천 검색어 목록 */ - // TODO: 추천 검색어 구현 - // @GetMapping("/recommendations") - // public RecommendationSearchResponse getRecommendations() + @GetMapping("/recommendations") + public RecommendationSearchResponse getRecommendations() { + return searchKeywordService.getRecommendations(); + } + + /** + * 통합 검색 (추후 구현) + * GET /api/v1/search?query={검색어}&page={페이지}&size={크기} + */ + // TODO: 통합 검색 구현 + // @GetMapping + // public IntegratedSearchResponse search(...) } diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/RecentSearchResponse.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/RecentSearchResponse.java new file mode 100644 index 0000000..1ee0bba --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/RecentSearchResponse.java @@ -0,0 +1,20 @@ +package com.example.booklog.domain.search.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +/** + * 최근 검색어 목록 응답 DTO + */ +@Getter +@AllArgsConstructor +public class RecentSearchResponse { + + private List keywords; + + public static RecentSearchResponse of(List keywords) { + return new RecentSearchResponse(keywords); + } +} diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/RecommendationSearchResponse.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/RecommendationSearchResponse.java new file mode 100644 index 0000000..f71b935 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/RecommendationSearchResponse.java @@ -0,0 +1,21 @@ +package com.example.booklog.domain.search.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +/** + * 추천 검색어 목록 응답 DTO + */ +@Getter +@AllArgsConstructor +public class RecommendationSearchResponse { + + private List keywords; + + public static RecommendationSearchResponse of(List keywords) { + return new RecommendationSearchResponse(keywords); + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/RecommendedKeywordResponse.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/RecommendedKeywordResponse.java new file mode 100644 index 0000000..8a621b1 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/RecommendedKeywordResponse.java @@ -0,0 +1,28 @@ +package com.example.booklog.domain.search.dto; + +import com.example.booklog.domain.search.entity.RecommendedKeyword; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 추천 검색어 응답 DTO + */ +@Getter +@AllArgsConstructor +public class RecommendedKeywordResponse { + + private Long id; + private String keyword; + private String type; + private String description; + + public static RecommendedKeywordResponse from(RecommendedKeyword recommendedKeyword) { + return new RecommendedKeywordResponse( + recommendedKeyword.getId(), + recommendedKeyword.getKeyword(), + recommendedKeyword.getType().name(), + recommendedKeyword.getDescription() + ); + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/SearchKeywordResponse.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/SearchKeywordResponse.java new file mode 100644 index 0000000..3731055 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/SearchKeywordResponse.java @@ -0,0 +1,28 @@ +package com.example.booklog.domain.search.dto; + +import com.example.booklog.domain.search.entity.SearchKeyword; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 검색어 응답 DTO + */ +@Getter +@AllArgsConstructor +public class SearchKeywordResponse { + + private Long id; + private String keyword; + private LocalDateTime searchedAt; + + public static SearchKeywordResponse from(SearchKeyword searchKeyword) { + return new SearchKeywordResponse( + searchKeyword.getId(), + searchKeyword.getKeyword(), + searchKeyword.getCreatedAt() + ); + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/SearchKeywordSaveRequest.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/SearchKeywordSaveRequest.java new file mode 100644 index 0000000..ad78952 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/SearchKeywordSaveRequest.java @@ -0,0 +1,19 @@ +package com.example.booklog.domain.search.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 검색어 저장 요청 DTO + */ +@Getter +@AllArgsConstructor +public class SearchKeywordSaveRequest { + + private String keyword; + + public String getKeyword() { + return keyword != null ? keyword.trim() : null; + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/entity/RecommendedKeyword.java b/booklog/src/main/java/com/example/booklog/domain/search/entity/RecommendedKeyword.java new file mode 100644 index 0000000..c3b78b7 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/entity/RecommendedKeyword.java @@ -0,0 +1,98 @@ +package com.example.booklog.domain.search.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 추천 검색어 엔티티 + * 운영자가 관리하는 추천 검색어 목록 + * + * [설계 고려사항] + * 1. 로그인 여부와 무관하게 모든 사용자에게 노출 + * 2. 우선순위(priority)를 통해 노출 순서 제어 + * 3. 활성화 여부(isActive)로 관리 편의성 제공 + * 4. 인기 검색어 / 큐레이션 / 이벤트 검색어 등 다양한 타입 지원 + */ +@Entity +@Table( + name = "recommended_keywords", + indexes = { + @Index(name = "idx_recommended_keywords_active_priority", columnList = "is_active, priority ASC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecommendedKeyword { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "keyword", length = 100, nullable = false) + private String keyword; + + @Column(name = "priority", nullable = false) + private Integer priority = 0; + + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + @Enumerated(EnumType.STRING) + @Column(name = "type", length = 20, nullable = false) + private RecommendationType type = RecommendationType.CURATED; + + @Column(name = "description", length = 200) + private String description; + + @Builder + public RecommendedKeyword(String keyword, Integer priority, Boolean isActive, + RecommendationType type, String description) { + validateKeyword(keyword); + this.keyword = keyword.trim(); + this.priority = priority != null ? priority : 0; + this.isActive = isActive != null ? isActive : true; + this.type = type != null ? type : RecommendationType.CURATED; + this.description = description; + } + + /** + * 검색어 유효성 검증 + */ + private void validateKeyword(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + throw new IllegalArgumentException("검색어는 필수입니다."); + } + + if (keyword.trim().length() > 100) { + throw new IllegalArgumentException("검색어는 100자 이내로 입력해주세요."); + } + } + + /** + * 추천 검색어 활성화/비활성화 + */ + public void updateActive(boolean isActive) { + this.isActive = isActive; + } + + /** + * 우선순위 변경 + */ + public void updatePriority(int priority) { + this.priority = priority; + } + + /** + * 추천 검색어 타입 + */ + public enum RecommendationType { + CURATED, // 큐레이션 (운영자 추천) + POPULAR, // 인기 검색어 + EVENT // 이벤트/프로모션 + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/entity/SearchKeyword.java b/booklog/src/main/java/com/example/booklog/domain/search/entity/SearchKeyword.java new file mode 100644 index 0000000..8423cde --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/entity/SearchKeyword.java @@ -0,0 +1,69 @@ +package com.example.booklog.domain.search.entity; + +import com.example.booklog.domain.users.entity.Users; +import com.example.booklog.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 검색어 엔티티 + * 사용자의 최근 검색어를 저장 + * + * [설계 고려사항] + * 1. 동일한 검색어 재검색 시 기존 레코드 삭제 후 재생성 (중복 제거 + 최신순 유지) + * 2. 사용자별 최대 10개 제한 (애플리케이션 레벨에서 제어) + * 3. BaseEntity 상속으로 createdAt 활용 (최신순 정렬) + */ +@Entity +@Table( + name = "search_keywords", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_search_keywords_user_keyword", + columnNames = {"user_id", "keyword"} + ) + }, + indexes = { + @Index(name = "idx_search_keywords_user_created", columnList = "user_id, created_at DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SearchKeyword extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_search_keyword_user")) + private Users user; + + @Column(name = "keyword", length = 100, nullable = false) + private String keyword; + + @Builder + public SearchKeyword(Users user, String keyword) { + validateKeyword(keyword); + this.user = user; + this.keyword = keyword.trim(); + } + + /** + * 검색어 유효성 검증 + */ + private void validateKeyword(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + throw new IllegalArgumentException("검색어는 필수입니다."); + } + + if (keyword.trim().length() > 100) { + throw new IllegalArgumentException("검색어는 100자 이내로 입력해주세요."); + } + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/repository/RecommendedKeywordRepository.java b/booklog/src/main/java/com/example/booklog/domain/search/repository/RecommendedKeywordRepository.java new file mode 100644 index 0000000..d960115 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/repository/RecommendedKeywordRepository.java @@ -0,0 +1,24 @@ +package com.example.booklog.domain.search.repository; + +import com.example.booklog.domain.search.entity.RecommendedKeyword; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 추천 검색어 레포지토리 + */ +@Repository +public interface RecommendedKeywordRepository extends JpaRepository { + + /** + * 활성화된 추천 검색어 조회 (우선순위 오름차순) + */ + @Query("SELECT rk FROM RecommendedKeyword rk " + + "WHERE rk.isActive = true " + + "ORDER BY rk.priority ASC, rk.id ASC") + List findAllActiveOrderByPriority(); +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/repository/SearchKeywordRepository.java b/booklog/src/main/java/com/example/booklog/domain/search/repository/SearchKeywordRepository.java new file mode 100644 index 0000000..30121aa --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/repository/SearchKeywordRepository.java @@ -0,0 +1,61 @@ +package com.example.booklog.domain.search.repository; + +import com.example.booklog.domain.search.entity.SearchKeyword; +import com.example.booklog.domain.users.entity.Users; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 검색어 레포지토리 + */ +@Repository +public interface SearchKeywordRepository extends JpaRepository { + + /** + * 사용자별 최근 검색어 조회 (최신순) + */ + @Query("SELECT sk FROM SearchKeyword sk " + + "WHERE sk.user.id = :userId " + + "ORDER BY sk.createdAt DESC") + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + /** + * 사용자의 특정 검색어 조회 (중복 체크용) + */ + @Query("SELECT sk FROM SearchKeyword sk " + + "WHERE sk.user.id = :userId AND sk.keyword = :keyword") + Optional findByUserIdAndKeyword(@Param("userId") Long userId, @Param("keyword") String keyword); + + /** + * 사용자의 검색어 개수 조회 + */ + @Query("SELECT COUNT(sk) FROM SearchKeyword sk WHERE sk.user.id = :userId") + long countByUserId(@Param("userId") Long userId); + + /** + * 사용자의 가장 오래된 검색어 삭제 + */ + @Modifying + @Query("DELETE FROM SearchKeyword sk " + + "WHERE sk.id IN (" + + " SELECT s.id FROM SearchKeyword s " + + " WHERE s.user.id = :userId " + + " ORDER BY s.createdAt ASC " + + ")") + void deleteOldestByUserId(@Param("userId") Long userId); + + /** + * 사용자의 특정 검색어 삭제 + */ + @Modifying + @Query("DELETE FROM SearchKeyword sk WHERE sk.user.id = :userId AND sk.keyword = :keyword") + void deleteByUserIdAndKeyword(@Param("userId") Long userId, @Param("keyword") String keyword); +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/service/SearchKeywordService.java b/booklog/src/main/java/com/example/booklog/domain/search/service/SearchKeywordService.java new file mode 100644 index 0000000..91457b9 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/service/SearchKeywordService.java @@ -0,0 +1,134 @@ +package com.example.booklog.domain.search.service; + +import com.example.booklog.domain.search.dto.RecentSearchResponse; +import com.example.booklog.domain.search.dto.RecommendationSearchResponse; +import com.example.booklog.domain.search.dto.RecommendedKeywordResponse; +import com.example.booklog.domain.search.dto.SearchKeywordResponse; +import com.example.booklog.domain.search.entity.RecommendedKeyword; +import com.example.booklog.domain.search.entity.SearchKeyword; +import com.example.booklog.domain.search.repository.RecommendedKeywordRepository; +import com.example.booklog.domain.search.repository.SearchKeywordRepository; +import com.example.booklog.domain.users.entity.Users; +import com.example.booklog.domain.users.repository.UsersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 검색어 관리 서비스 + * - 검색어 저장 + * - 최근 검색어 조회 + * - 추천 검색어 조회 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SearchKeywordService { + + private static final int MAX_RECENT_KEYWORDS = 10; + + private final SearchKeywordRepository searchKeywordRepository; + private final RecommendedKeywordRepository recommendedKeywordRepository; + private final UsersRepository usersRepository; + + /** + * 검색어 저장 + * + * [동작 방식] + * 1. 동일한 검색어가 이미 존재하면 삭제 (중복 제거) + * 2. 새로운 검색어 저장 (최신순 유지) + * 3. 최대 개수(10개) 초과 시 가장 오래된 검색어 삭제 + * + * @param userId 사용자 ID + * @param keyword 검색어 + */ + @Transactional + public void saveSearchKeyword(Long userId, String keyword) { + // 사용자 조회 + Users user = usersRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + // 검색어 유효성 검증 + String trimmedKeyword = validateAndTrimKeyword(keyword); + + // 기존 동일 검색어 삭제 (중복 제거) + searchKeywordRepository.findByUserIdAndKeyword(userId, trimmedKeyword) + .ifPresent(searchKeywordRepository::delete); + + // 최대 개수 체크 및 오래된 검색어 삭제 + long count = searchKeywordRepository.countByUserId(userId); + if (count >= MAX_RECENT_KEYWORDS) { + // 가장 오래된 검색어 1개 삭제 + List oldestKeywords = searchKeywordRepository + .findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_RECENT_KEYWORDS)); + + if (!oldestKeywords.isEmpty()) { + SearchKeyword oldest = oldestKeywords.get(oldestKeywords.size() - 1); + searchKeywordRepository.delete(oldest); + } + } + + // 새로운 검색어 저장 + SearchKeyword searchKeyword = SearchKeyword.builder() + .user(user) + .keyword(trimmedKeyword) + .build(); + + searchKeywordRepository.save(searchKeyword); + } + + /** + * 최근 검색어 조회 + * + * @param userId 사용자 ID + * @return 최근 검색어 목록 (최신순, 최대 10개) + */ + public RecentSearchResponse getRecentSearches(Long userId) { + List keywords = searchKeywordRepository + .findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_RECENT_KEYWORDS)); + + List responses = keywords.stream() + .map(SearchKeywordResponse::from) + .collect(Collectors.toList()); + + return RecentSearchResponse.of(responses); + } + + /** + * 추천 검색어 조회 + * + * @return 활성화된 추천 검색어 목록 (우선순위 오름차순) + */ + public RecommendationSearchResponse getRecommendations() { + List keywords = recommendedKeywordRepository + .findAllActiveOrderByPriority(); + + List responses = keywords.stream() + .map(RecommendedKeywordResponse::from) + .collect(Collectors.toList()); + + return RecommendationSearchResponse.of(responses); + } + + /** + * 검색어 유효성 검증 및 공백 제거 + */ + private String validateAndTrimKeyword(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + throw new IllegalArgumentException("검색어는 필수입니다."); + } + + String trimmed = keyword.trim(); + + if (trimmed.length() > 100) { + throw new IllegalArgumentException("검색어는 100자 이내로 입력해주세요."); + } + + return trimmed; + } +} + From f813ba5f6075bb5aa87fb485b737fdff971f12d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A4=80=ED=98=84?= Date: Mon, 26 Jan 2026 14:09:57 +0900 Subject: [PATCH 2/2] feat: search API --- .../books/repository/BooksRepository.java | 41 +++++ .../search/controller/SearchController.java | 73 +++++++- .../domain/search/dto/AuthorSearchResult.java | 38 ++++ .../domain/search/dto/AuthorSummary.java | 41 +++++ .../domain/search/dto/BookSearchResult.java | 36 ++++ .../domain/search/dto/BookSortType.java | 57 ++++++ .../search/dto/IntegratedSearchResponse.java | 50 ++++++ .../search/service/BookSearchService.java | 157 +++++++++++++---- .../service/IntegratedSearchService.java | 163 ++++++++++++++++++ 9 files changed, 615 insertions(+), 41 deletions(-) create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/AuthorSearchResult.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/AuthorSummary.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/BookSearchResult.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/BookSortType.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/dto/IntegratedSearchResponse.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/search/service/IntegratedSearchService.java diff --git a/booklog/src/main/java/com/example/booklog/domain/library/books/repository/BooksRepository.java b/booklog/src/main/java/com/example/booklog/domain/library/books/repository/BooksRepository.java index 883bba0..87d4360 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/books/repository/BooksRepository.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/books/repository/BooksRepository.java @@ -2,6 +2,8 @@ import com.example.booklog.domain.library.books.entity.Books; import jakarta.persistence.QueryHint; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; @@ -58,6 +60,45 @@ public interface BooksRepository extends JpaRepository { "JOIN b.bookAuthors ba " + "WHERE ba.author.id IN :authorIds") long countBooksByAuthorIds(@Param("authorIds") List authorIds); + + /** + * 도서 제목으로 검색 (페이징 및 정렬 지원) + * + * [정렬 옵션] + * - LATEST: publishedDate DESC, id DESC + * - OLDEST: publishedDate ASC, id ASC + * - TITLE: title ASC + * - AUTHOR: 첫 번째 저자명 ASC (BookAuthors.authorOrder = 1) + * + * @param keyword 검색 키워드 (제목 LIKE 검색) + * @param pageable 페이징 및 정렬 정보 + * @return 도서 페이지 + */ + @Query(""" + SELECT DISTINCT b FROM Books b + LEFT JOIN FETCH b.bookAuthors ba + LEFT JOIN FETCH ba.author a + WHERE b.title LIKE %:keyword% + """) + @QueryHints(@QueryHint(name = "hibernate.query.passDistinctThrough", value = "false")) + Page searchByTitle(@Param("keyword") String keyword, Pageable pageable); + + /** + * 도서 제목 또는 저자명으로 검색 (페이징 및 정렬 지원) + * + * @param keyword 검색 키워드 + * @param pageable 페이징 및 정렬 정보 + * @return 도서 페이지 + */ + @Query(""" + SELECT DISTINCT b FROM Books b + LEFT JOIN FETCH b.bookAuthors ba + LEFT JOIN FETCH ba.author a + WHERE b.title LIKE %:keyword% + OR a.name LIKE %:keyword% + """) + @QueryHints(@QueryHint(name = "hibernate.query.passDistinctThrough", value = "false")) + Page searchByTitleOrAuthor(@Param("keyword") String keyword, Pageable pageable); } diff --git a/booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java b/booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java index ac6c553..0f188ea 100644 --- a/booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java +++ b/booklog/src/main/java/com/example/booklog/domain/search/controller/SearchController.java @@ -4,40 +4,60 @@ import com.example.booklog.domain.search.dto.*; import com.example.booklog.domain.search.service.AuthorSearchService; import com.example.booklog.domain.search.service.BookSearchService; +import com.example.booklog.domain.search.service.IntegratedSearchService; import com.example.booklog.domain.search.service.SearchKeywordService; import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.concurrent.TimeUnit; + /** * 통합 검색 API 컨트롤러 + * - 통합 검색: /api/v1/search (GET) * - 도서 검색: /api/v1/search/books * - 작가 검색: /api/v1/search/authors * - 검색어 저장: /api/v1/search/keywords (POST) * - 최근 검색어 조회: /api/v1/search/recent (GET) * - 추천 검색어 조회: /api/v1/search/recommendations (GET) - * - 통합 검색: /api/v1/search (추후 구현) */ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/search") public class SearchController { + private final IntegratedSearchService integratedSearchService; private final BookSearchService bookSearchService; private final AuthorSearchService authorSearchService; private final SearchKeywordService searchKeywordService; /** * 도서 검색 - * GET /api/v1/search/books?query={검색어}&page={페이지}&size={크기} + * GET /api/v1/search/books?query={검색어}&page={페이지}&size={크기}&sort={정렬} + * + * [정렬 옵션] + * - latest: 최신순 (출판일 내림차순) - 기본값 + * - oldest: 오래된순 (출판일 오름차순) + * - title: 제목순 (가나다순) + * - author: 저자순 (첫 번째 저자 기준 가나다순) + * + * @param query 검색어 (필수) + * @param page 페이지 번호 (1부터 시작, 기본값: 1) + * @param size 페이지 크기 (기본값: 10) + * @param sort 정렬 기준 (기본값: latest) + * @return 도서 검색 결과 */ @GetMapping("/books") public BookSearchResponse searchBooks( @RequestParam String query, @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "latest") String sort ) { - return bookSearchService.searchBooks(query, page, size); + BookSortType sortType = BookSortType.from(sort); + return bookSearchService.searchBooks(query, page, size, sortType); } /** @@ -125,11 +145,46 @@ public RecommendationSearchResponse getRecommendations() { } /** - * 통합 검색 (추후 구현) - * GET /api/v1/search?query={검색어}&page={페이지}&size={크기} + * 통합 검색 API + * GET /api/v1/search?query={검색어}&sort={정렬기준} + * + * [기능 설명] + * - 검색어에 대해 "작가"와 "도서"를 통합하여 조회 + * - 전체 탭에서 사용되며, 각 영역별로 제한된 개수(5개)만 표시 + * - 더 많은 결과를 보려면 개별 탭(/books, /authors)으로 이동 + * + * [응답 구조] + * - authors: 작가 검색 결과 (요약 정보, 최대 5명) + * - books: 도서 검색 결과 (기본 정보, 최대 5권) + * - 각 영역의 totalCount로 전체 개수 파악 가능 + * + * [캐시 정책] + * - Cache-Control: max-age=60 (60초) + * - 동일한 query + sort 조합에 대해 클라이언트 캐시 활용 + * + * [경계 케이스] + * - 검색어가 없거나 공백인 경우: 400 Bad Request + * - 검색 결과가 없는 경우: 빈 배열과 totalCount=0 반환 + * - 정렬 기준이 유효하지 않은 경우: 400 Bad Request + * + * @param query 검색어 (필수, 1~100자) + * @param sort 정렬 기준 (선택, latest|popular, 기본값: latest) + * @return 통합 검색 응답 (작가 + 도서) */ - // TODO: 통합 검색 구현 - // @GetMapping - // public IntegratedSearchResponse search(...) + @GetMapping + public ResponseEntity search( + @RequestParam String query, + @RequestParam(defaultValue = "latest") String sort + ) { + IntegratedSearchResponse response = integratedSearchService.search(query, sort); + + // 캐시 헤더 설정 (60초) + CacheControl cacheControl = CacheControl.maxAge(60, TimeUnit.SECONDS) + .cachePublic(); + + return ResponseEntity.ok() + .cacheControl(cacheControl) + .body(response); + } } diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/AuthorSearchResult.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/AuthorSearchResult.java new file mode 100644 index 0000000..3dba405 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/AuthorSearchResult.java @@ -0,0 +1,38 @@ +package com.example.booklog.domain.search.dto; + +import java.util.List; + +/** + * 통합 검색 응답 내 작가 검색 결과 + * + * [설계 근거] + * - 전체 검색 탭에서는 작가의 요약 정보만 필요 (책 목록 불포함) + * - totalCount를 통해 "작가 결과 n명" 표시 및 "더보기" 판단 + * - 페이징 정보는 제외 (전체 탭에서는 제한된 개수만 노출) + * + * @param totalCount 총 검색된 작가 수 + * @param items 작가 요약 정보 리스트 (최대 5개 등 제한) + */ +public record AuthorSearchResult( + int totalCount, + List items +) { + /** + * AuthorSearchResponse로부터 변환 + * 작가 검색 결과에서 책 목록을 제외한 요약 정보만 추출 + * + * @param response 작가 검색 응답 + * @return 작가 검색 결과 (요약) + */ + public static AuthorSearchResult from(AuthorSearchResponse response) { + List summaries = response.items().stream() + .map(AuthorSummary::from) + .toList(); + + return new AuthorSearchResult( + response.totalCount(), + summaries + ); + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/AuthorSummary.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/AuthorSummary.java new file mode 100644 index 0000000..9f9948e --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/AuthorSummary.java @@ -0,0 +1,41 @@ +package com.example.booklog.domain.search.dto; + +/** + * 통합 검색 응답에서 사용되는 작가 요약 정보 + * + * [설계 근거] + * - 전체 탭에서는 작가의 대표작 목록이 필요 없음 + * - UI 표시에 필요한 최소한의 정보만 포함 (이름, 프로필 이미지, 직업, 국적) + * - 작가 상세 페이지로 이동하기 위한 authorId 포함 + * + * @param authorId 작가 ID + * @param name 작가명 + * @param profileImageUrl 프로필 이미지 URL + * @param occupation 직업 (예: 소설가, 시인) + * @param nationality 국적 + */ +public record AuthorSummary( + Long authorId, + String name, + String profileImageUrl, + String occupation, + String nationality +) { + /** + * AuthorSearchItemResponse로부터 변환 + * 책 목록을 제외한 작가 정보만 추출 + * + * @param item 작가 검색 아이템 + * @return 작가 요약 정보 + */ + public static AuthorSummary from(AuthorSearchItemResponse item) { + return new AuthorSummary( + item.authorId(), + item.name(), + item.profileImageUrl(), + item.occupation(), + item.nationality() + ); + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/BookSearchResult.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/BookSearchResult.java new file mode 100644 index 0000000..5b6114b --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/BookSearchResult.java @@ -0,0 +1,36 @@ +package com.example.booklog.domain.search.dto; + +import com.example.booklog.domain.library.books.dto.BookSearchItemResponse; +import com.example.booklog.domain.library.books.dto.BookSearchResponse; + +import java.util.List; + +/** + * 통합 검색 응답 내 도서 검색 결과 + * + * [설계 근거] + * - 전체 검색 탭에서는 도서의 기본 정보만 노출 + * - totalCount를 통해 "도서 결과 n권" 표시 및 "더보기" 판단 + * - 페이징 정보는 제외 (전체 탭에서는 제한된 개수만 노출) + * + * @param totalCount 총 검색된 도서 수 + * @param items 도서 정보 리스트 (최대 5개 등 제한) + */ +public record BookSearchResult( + int totalCount, + List items +) { + /** + * BookSearchResponse로부터 변환 + * + * @param response 도서 검색 응답 + * @return 도서 검색 결과 + */ + public static BookSearchResult from(BookSearchResponse response) { + return new BookSearchResult( + response.totalCount(), + response.items() + ); + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/BookSortType.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/BookSortType.java new file mode 100644 index 0000000..8658437 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/BookSortType.java @@ -0,0 +1,57 @@ +package com.example.booklog.domain.search.dto; + +/** + * 도서 검색 정렬 기준 + * + * [지원 정렬] + * - LATEST: 최신순 (출판일 내림차순) + * - OLDEST: 오래된 순 (출판일 오름차순) + * - TITLE: 제목 순 (가나다순) + * - AUTHOR: 저자 순 (첫 번째 저자 기준 가나다순) + */ +public enum BookSortType { + LATEST("latest", "최신순"), + OLDEST("oldest", "오래된순"), + TITLE("title", "제목순"), + AUTHOR("author", "저자순"); + + private final String value; + private final String description; + + BookSortType(String value, String description) { + this.value = value; + this.description = description; + } + + public String getValue() { + return value; + } + + public String getDescription() { + return description; + } + + /** + * String 값으로부터 BookSortType 조회 + * + * @param value 정렬 기준 문자열 (latest, oldest, title, author) + * @return BookSortType + * @throws IllegalArgumentException 유효하지 않은 정렬 기준인 경우 + */ + public static BookSortType from(String value) { + if (value == null) { + return LATEST; // 기본값 + } + + for (BookSortType type : values()) { + if (type.value.equalsIgnoreCase(value)) { + return type; + } + } + + throw new IllegalArgumentException( + String.format("유효하지 않은 정렬 기준입니다: %s (사용 가능: latest, oldest, title, author)", value) + ); + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/dto/IntegratedSearchResponse.java b/booklog/src/main/java/com/example/booklog/domain/search/dto/IntegratedSearchResponse.java new file mode 100644 index 0000000..813650e --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/dto/IntegratedSearchResponse.java @@ -0,0 +1,50 @@ +package com.example.booklog.domain.search.dto; + +import com.example.booklog.domain.library.books.dto.BookSearchResponse; + +/** + * 통합 검색 응답 DTO + * 작가 검색 결과와 도서 검색 결과를 통합하여 반환 + * + * [설계 근거] + * - 작가/도서 검색 결과를 각각 독립적인 구조로 분리하여 UI에서 영역별 렌더링 용이 + * - 각 결과의 totalCount를 제공하여 UI에서 "더보기" 표시 여부 판단 가능 + * - 검색어와 정렬 기준을 응답에 포함하여 클라이언트 측 상태 관리 간소화 + * - 향후 '작가' 탭, '도서' 탭 분리 시 각각의 API와 구조 재사용 가능 + * + * @param query 검색어 + * @param sort 정렬 기준 (latest, popular 등) + * @param authors 작가 검색 결과 + * @param books 도서 검색 결과 + */ +public record IntegratedSearchResponse( + String query, + String sort, + AuthorSearchResult authors, + BookSearchResult books +) { + /** + * 작가/도서 검색 결과로부터 통합 응답 생성 + * + * @param query 검색어 + * @param sort 정렬 기준 + * @param authorResponse 작가 검색 응답 + * @param bookResponse 도서 검색 응답 + * @return 통합 검색 응답 + */ + public static IntegratedSearchResponse of( + String query, + String sort, + AuthorSearchResponse authorResponse, + BookSearchResponse bookResponse + ) { + // 작가 검색 결과 변환 (책 목록 제외한 요약 정보만) + AuthorSearchResult authorResult = AuthorSearchResult.from(authorResponse); + + // 도서 검색 결과 변환 + BookSearchResult bookResult = BookSearchResult.from(bookResponse); + + return new IntegratedSearchResponse(query, sort, authorResult, bookResult); + } +} + diff --git a/booklog/src/main/java/com/example/booklog/domain/search/service/BookSearchService.java b/booklog/src/main/java/com/example/booklog/domain/search/service/BookSearchService.java index 97238a7..97862e3 100644 --- a/booklog/src/main/java/com/example/booklog/domain/search/service/BookSearchService.java +++ b/booklog/src/main/java/com/example/booklog/domain/search/service/BookSearchService.java @@ -2,66 +2,159 @@ import com.example.booklog.domain.library.books.dto.BookSearchItemResponse; import com.example.booklog.domain.library.books.dto.BookSearchResponse; +import com.example.booklog.domain.library.books.entity.Books; +import com.example.booklog.domain.library.books.repository.BooksRepository; import com.example.booklog.domain.library.books.service.BookImportService; +import com.example.booklog.domain.search.dto.BookSortType; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; /** * 도서 검색 서비스 - * - 카카오 API를 통한 도서 검색 및 DB 업서트 + * - 카카오 API를 통한 초기 데이터 임포트 + * - DB 기반 검색 및 정렬 (최신순, 오래된순, 제목순, 저자순) */ +@Slf4j @Service @RequiredArgsConstructor public class BookSearchService { private final BookImportService bookImportService; + private final BooksRepository booksRepository; /** - * 도서 검색 및 업서트 + * 도서 검색 (정렬 기능 지원) + * + * [검색 전략] + * 1. DB에서 검색 시도 (제목 LIKE 검색) + * 2. 결과가 없으면 카카오 API로 임포트 + * 3. DB에서 재검색 (정렬 적용) + * + * [정렬 옵션] + * - latest: 최신순 (출판일 내림차순) + * - oldest: 오래된순 (출판일 오름차순) + * - title: 제목순 (가나다순) + * - author: 저자순 (첫 번째 저자 기준) + * * @param query 검색어 - * @param page 페이지 번호 + * @param page 페이지 번호 (1부터 시작) * @param size 페이지 크기 + * @param sortType 정렬 기준 (기본값: latest) * @return 검색 결과 */ - public BookSearchResponse searchBooks(String query, int page, int size) { - // 기존 BookImportService의 검색 로직 활용 - BookSearchResponse legacyResponse = - bookImportService.searchAndUpsert(query, page, size); + @Transactional + public BookSearchResponse searchBooks(String query, int page, int size, BookSortType sortType) { + log.info("도서 검색 요청 - query: {}, page: {}, size: {}, sort: {}", + query, page, size, sortType.getValue()); - // DTO 변환 (legacy -> search domain) - return convertToSearchResponse(legacyResponse); - } + // 입력 검증 + if (query == null || query.trim().isEmpty()) { + return new BookSearchResponse(page, size, true, 0, List.of()); + } - /** - * legacy DTO를 search domain DTO로 변환 - */ - private BookSearchResponse convertToSearchResponse( - BookSearchResponse legacyResponse) { + // 정렬 기준 생성 + Sort sort = createSort(sortType); + Pageable pageable = PageRequest.of(page - 1, size, sort); + + // 1. DB에서 검색 + Page booksPage = booksRepository.searchByTitle(query.trim(), pageable); + + // 2. 결과가 없으면 카카오 API로 임포트 + if (booksPage.isEmpty()) { + log.info("DB에 결과 없음. 카카오 API로 임포트 시작 - query: {}", query); + bookImportService.searchAndUpsert(query, 1, 10); // 카카오 API는 최대 10개만 가져옴 - var items = legacyResponse.items().stream() + // 3. 임포트 후 재검색 + booksPage = booksRepository.searchByTitle(query.trim(), pageable); + + if (booksPage.isEmpty()) { + log.info("임포트 후에도 검색 결과 없음 - query: {}", query); + return new BookSearchResponse(page, size, true, 0, List.of()); + } + } + + // 4. DTO 변환 + List items = booksPage.getContent().stream() .map(this::convertToSearchItem) .toList(); - return new BookSearchResponse( - legacyResponse.page(), - legacyResponse.size(), - legacyResponse.isEnd(), - legacyResponse.totalCount(), - items - ); + boolean isEnd = booksPage.isLast(); + long totalCount = booksPage.getTotalElements(); + + log.info("도서 검색 완료 - 총 {}권 중 {}권 조회", totalCount, items.size()); + + return new BookSearchResponse(page, size, isEnd, (int) totalCount, items); + } + + /** + * 도서 검색 (정렬 기준 없이 호출 시 기본값 latest 적용) + * 하위 호환성을 위한 오버로딩 + */ + public BookSearchResponse searchBooks(String query, int page, int size) { + return searchBooks(query, page, size, BookSortType.LATEST); } - private BookSearchItemResponse convertToSearchItem(BookSearchItemResponse legacyItem) { + /** + * 정렬 기준에 따른 Sort 객체 생성 + * + * @param sortType 정렬 기준 + * @return Sort 객체 + */ + private Sort createSort(BookSortType sortType) { + return switch (sortType) { + case LATEST -> Sort.by( + Sort.Order.desc("publishedDate").nullsLast(), + Sort.Order.desc("id") + ); + case OLDEST -> Sort.by( + Sort.Order.asc("publishedDate").nullsLast(), + Sort.Order.asc("id") + ); + case TITLE -> Sort.by( + Sort.Order.asc("title") + ); + case AUTHOR -> Sort.by( + Sort.Order.asc("bookAuthors.author.name"), + Sort.Order.asc("id") + ); + }; + } + + /** + * Books 엔티티를 BookSearchItemResponse로 변환 + */ + private BookSearchItemResponse convertToSearchItem(Books book) { + // 저자명 추출 (displayOrder 순서대로) + List authors = book.getBookAuthors().stream() + .filter(ba -> ba.getRole() == com.example.booklog.domain.library.books.entity.AuthorRole.AUTHOR) + .sorted((a, b) -> Integer.compare(a.getDisplayOrder(), b.getDisplayOrder())) + .map(ba -> ba.getAuthor().getName()) + .toList(); + + // 역자명 추출 + List translators = book.getBookAuthors().stream() + .filter(ba -> ba.getRole() == com.example.booklog.domain.library.books.entity.AuthorRole.TRANSLATOR) + .sorted((a, b) -> Integer.compare(a.getDisplayOrder(), b.getDisplayOrder())) + .map(ba -> ba.getAuthor().getName()) + .toList(); return new BookSearchItemResponse( - legacyItem.bookId(), - legacyItem.title(), - legacyItem.thumbnailUrl(), - legacyItem.publisherName(), - legacyItem.isbn13(), - legacyItem.authors(), - legacyItem.translators(), - legacyItem.publishedDate() + book.getId(), + book.getTitle(), + book.getThumbnailUrl(), + book.getPublisherName(), + book.getIsbn13(), + authors, + translators, + book.getPublishedDate() ); } } diff --git a/booklog/src/main/java/com/example/booklog/domain/search/service/IntegratedSearchService.java b/booklog/src/main/java/com/example/booklog/domain/search/service/IntegratedSearchService.java new file mode 100644 index 0000000..e635873 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/search/service/IntegratedSearchService.java @@ -0,0 +1,163 @@ +package com.example.booklog.domain.search.service; + +import com.example.booklog.domain.library.books.dto.BookSearchItemResponse; +import com.example.booklog.domain.library.books.dto.BookSearchResponse; +import com.example.booklog.domain.search.dto.AuthorSearchResponse; +import com.example.booklog.domain.search.dto.IntegratedSearchResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 통합 검색 서비스 + * + * [설계 전략] + * 1. 조회 전략: 분리된 트랜잭션 + * - 작가 검색과 도서 검색은 독립적으로 실행 + * - 하나의 검색 실패가 다른 검색에 영향을 주지 않도록 분리 + * - 각 검색 서비스의 readOnly 트랜잭션 활용 + * + * 2. 성능 최적화 + * - 작가/도서 검색을 순차 실행 (동일 검색어로 병렬 처리 불필요) + * - 각 검색 서비스 내부에서 N+1 문제 방지 (Batch Fetch, Fetch Join) + * - 전체 탭에서는 제한된 개수(예: 5개)만 조회하여 응답 속도 개선 + * + * 3. 확장성 + * - sort 파라미터를 각 검색 서비스로 전달하여 정렬 기준 확장 가능 + * - 향후 '작가' 탭, '도서' 탭 분리 시 동일한 서비스 재사용 + * + * 4. 캐시 전략 + * - 통합 검색은 초기 진입점이므로 짧은 캐시 TTL (60초) + * - query + sort 조합을 캐시 키로 사용 + * - HTTP 헤더(Cache-Control)로 클라이언트 캐시 제어 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class IntegratedSearchService { + + private final AuthorSearchService authorSearchService; + private final BookSearchService bookSearchService; + + // 전체 탭에서 표시할 최대 결과 개수 + private static final int DEFAULT_RESULT_SIZE = 5; + + /** + * 통합 검색 실행 + * + * [동작 흐름] + * 1. 입력값 검증 (검색어, 정렬 기준) + * 2. 작가 검색 실행 (최대 5개) + * 3. 도서 검색 실행 (최대 5개) + * 4. 결과 통합 및 DTO 변환 + * + * [예외 처리] + * - 작가 검색 실패 시: 빈 결과로 처리하고 도서 검색 계속 진행 + * - 도서 검색 실패 시: 빈 결과로 처리하고 작가 검색 결과는 반환 + * - 양쪽 모두 실패 시: 빈 결과 반환 (예외 발생 X) + * + * @param query 검색어 (필수, 1~100자) + * @param sort 정렬 기준 (선택, latest, popular 등) + * @return 통합 검색 응답 + * @throws IllegalArgumentException 검색어가 유효하지 않은 경우 + */ + public IntegratedSearchResponse search(String query, String sort) { + log.info("통합 검색 요청 - query: {}, sort: {}", query, sort); + + // 1. 입력값 검증 + validateSearchInput(query, sort); + + // 2. 작가 검색 (실패해도 계속 진행) + AuthorSearchResponse authorResponse = searchAuthorsWithFallback(query, sort); + + // 3. 도서 검색 (실패해도 계속 진행) + BookSearchResponse bookResponse = searchBooksWithFallback(query, sort); + + // 4. 결과 통합 + IntegratedSearchResponse response = IntegratedSearchResponse.of( + query, + sort, + authorResponse, + bookResponse + ); + + log.info("통합 검색 완료 - 작가: {}명, 도서: {}권", + authorResponse.totalCount(), + bookResponse.totalCount()); + + return response; + } + + /** + * 작가 검색 실행 (예외 발생 시 빈 결과 반환) + * + * @param query 검색어 + * @param sort 정렬 기준 (현재 미사용, 향후 확장) + * @return 작가 검색 응답 + */ + private AuthorSearchResponse searchAuthorsWithFallback(String query, String sort) { + try { + // TODO: sort 파라미터 활용 (향후 확장) + return authorSearchService.searchAuthors(query, 1, DEFAULT_RESULT_SIZE); + } catch (Exception e) { + log.warn("작가 검색 실패 - query: {}, error: {}", query, e.getMessage()); + return AuthorSearchResponse.of(List.of(), 1, DEFAULT_RESULT_SIZE, 0L); + } + } + + /** + * 도서 검색 실행 (예외 발생 시 빈 결과 반환) + * + * @param query 검색어 + * @param sort 정렬 기준 (현재 미사용, 향후 확장) + * @return 도서 검색 응답 + */ + private BookSearchResponse searchBooksWithFallback(String query, String sort) { + try { + // TODO: sort 파라미터 활용 (향후 확장) + return bookSearchService.searchBooks(query, 1, DEFAULT_RESULT_SIZE); + } catch (Exception e) { + log.warn("도서 검색 실패 - query: {}, error: {}", query, e.getMessage()); + return new BookSearchResponse(1, DEFAULT_RESULT_SIZE, true, 0, List.of()); + } + } + + /** + * 검색 입력값 검증 + * + * @param query 검색어 + * @param sort 정렬 기준 + * @throws IllegalArgumentException 입력값이 유효하지 않은 경우 + */ + private void validateSearchInput(String query, String sort) { + // 검색어 검증 + if (query == null || query.trim().isEmpty()) { + throw new IllegalArgumentException("검색어는 필수입니다."); + } + + if (query.trim().length() > 100) { + throw new IllegalArgumentException("검색어는 100자 이내로 입력해주세요."); + } + + // 정렬 기준 검증 (향후 확장) + if (sort != null && !isValidSortType(sort)) { + throw new IllegalArgumentException("유효하지 않은 정렬 기준입니다: " + sort); + } + } + + /** + * 정렬 기준 유효성 검증 + * + * @param sort 정렬 기준 + * @return 유효 여부 + */ + private boolean isValidSortType(String sort) { + // TODO: 정렬 기준 확장 시 enum으로 관리 + return "latest".equalsIgnoreCase(sort) || "popular".equalsIgnoreCase(sort); + } +} +