Skip to content

[Feat] 가장 많이 검색된 책 조회 api 개발 및 책 상세 검색 시 검색카운트 업데이트 로직 추가#49

Merged
hd0rable merged 33 commits into
developfrom
feat/#48-get-most-serched
Jul 8, 2025
Merged

[Feat] 가장 많이 검색된 책 조회 api 개발 및 책 상세 검색 시 검색카운트 업데이트 로직 추가#49
hd0rable merged 33 commits into
developfrom
feat/#48-get-most-serched

Conversation

@hd0rable

@hd0rable hd0rable commented Jul 4, 2025

Copy link
Copy Markdown
Member

#️⃣ 연관된 이슈

closes #48

📝 작업 내용

  • 가장 많이 검색된 책 조회 api를 개발했습니다.
  • 검색 정보를 저장할때 레디스의 Sorted Set(ZSET)를 활용했습니다.
  • 책 상세조회 시, Key:search:count:YYYYMMDD, Value: isbn이 저장되어 score를 1 증가시킵니다.
  • 스케쥴러를 통해 전날 집계된 검색 카운트 정보를 기준으로 Key:search:rank:YYYYMMDD, Value: score(해당 ISBN의 검색 횟수)이 저장 됩니다.
  • 스케줄러를 실행한 날짜 기준으로 전날 집계된 검색카운트 정보에서 topN정보를 추출한뒤, 전날의 랭킹 키(중복 저장방지), 전날의 카운트 키를 삭제한 후 전날 집계된 랭킹정보들이있다면 새롭게 저장합니다.
  • 랭킹정보들은 추후 랭킹 정보들이 필요할 경우들을 대비하여 TTL설정으로 7일 뒤에 자동으로 삭제되도록 하였습니다.
  • 또한 랭킹정보, topN의 반환값이 예외처리를 하지않고있는데, 서비스 오픈 초기, 비수기, 특정일에 검색이 전혀 없었던 경우 등에 예외처리를 하는 것보다는 pm분과 상의하여 이럴경우 특정한 화면으로 클라이언트에게 순위정보가 없음,아직 준비중으로 알리는 것이 더 나을것같다는 판단하에 구현하였습니다. 이부분은 추후 기획측면에서 확정되면 수정하도록 하겠습니다.
  • 랭킹 조회 시, DB에 없는 책 정보를 받아오기 위해 naver api를 통해 책정보를 파싱합니다. (굳이 db 저장 로직에 추가하지 않은 이유는 하루에 한번 실행되는 스케줄러라 내일 변경될 수 있는 값이고 최대 5번만 naver api로 파싱하여 가져오면 되고, 가장 많이 검색된 책이라면 사용자의 참여도(방 생성, 피드 생성, 책 저장)이 많아 이미 저장되있는 책일 거라 생각하였습니다)
  • 레디스 관련 키값으로 yml 파일이 수정되었습니다.
  • 테스트 작성시에 비지니스 로직에서 예외가 발생하는 부분이 없기에, 통합테스트만 작성하였습니다.
  • 기존 책 저장 상태 변경의 커밋이 아직 남아있습니다.. 해당 pr 머지되고 난 후에 리뷰해주시면 감사하겠습니다!

📸 스크린샷

image
정상적으로 순위가 반환되는 것을 확인할수 있습니다!

💬 리뷰 요구사항

스케줄러로 전날의 랭킹의 정보를 집계하는 것은 클라이언트,사용자가 관여하는것이 아닌 오로직 서버의 로직이라고 생각하여 유즈케이스를 작성하지않았는데 이방식이 맞는걸까욥??

📌 PR 진행 시 이러한 점들을 참고해 주세요

* P1 : 꼭 반영해 주세요 (Request Changes) - 이슈가 발생하거나 취약점이 발견되는 케이스 등
* P2 : 반영을 적극적으로 고려해 주시면 좋을 것 같아요 (Comment)
* P3 : 이런 방법도 있을 것 같아요~ 등의 사소한 의견입니다 (Chore)

Summary by CodeRabbit

  • 신규 기능

    • 사용자가 가장 많이 검색한 도서 목록을 조회할 수 있는 /books/most-searched API 엔드포인트가 추가되었습니다.
    • 일일 도서 검색 랭킹을 집계 및 저장하는 기능이 추가되었습니다.
    • 도서 검색 시, 해당 도서의 검색 횟수가 자동으로 집계되어 랭킹에 반영됩니다.
    • Redis 연동 및 JSON 직렬화/역직렬화 오류에 대한 예외 처리가 추가되었습니다.
  • 버그 수정

    • 도서 상세 정보 조회 시 ISBN 기반으로 정확하게 조회하도록 개선되었습니다.
  • 테스트

    • 가장 많이 검색된 도서 목록 및 일일 검색 랭킹 기능에 대한 통합 및 단위 테스트가 추가되었습니다.
  • 환경설정

    • Redis 연동 설정이 추가되어 검색 랭킹 데이터가 Redis에 저장됩니다.
    • CI 환경에서 Redis가 자동으로 구동되도록 워크플로우가 수정되었습니다.

@coderabbitai

coderabbitai Bot commented Jul 4, 2025

Copy link
Copy Markdown

Walkthrough

책 검색 순위 기능이 추가되었습니다. 책 상세 조회 시 ISBN과 검색 일자를 Redis에 저장하고, 매일 자정 스케줄러가 Redis의 책 검색 순위를 집계하여 전날 기준 Top 5를 저장합니다. /books/most-searched API를 통해 전날 가장 많이 검색된 책 목록을 제공합니다. Redis 연동 및 관련 테스트 코드도 추가되었습니다.

Changes

파일/그룹 변경 요약
src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java, .../response/GetBookMostSearchResponse.java /books/most-searched API 엔드포인트 및 응답 DTO 추가, 관련 유스케이스 의존성 주입
src/main/java/konkuk/thip/book/application/port/in/BookMostSearchUseCase.java, .../dto/BookMostSearchResult.java 가장 많이 검색된 책 조회 유스케이스 인터페이스 및 결과 DTO 추가
src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java, BookMostSearchRankService.java 책 검색 순위 집계 및 조회 서비스 클래스 추가, 스케줄러 및 비동기 처리
src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java, BookJpaRepository.java, BookQueryPersistenceAdapter.java Redis 어댑터 및 JPA, 쿼리 어댑터 추가/확장, Redis 조회/저장/삭제 기능 구현
src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java, BookRedisQueryPort.java Redis 명령/조회 포트 인터페이스 추가
src/main/java/konkuk/thip/book/application/service/BookSearchService.java, BookSavedService.java, .../out/api/BookApiNaverApiAdapter.java, .../out/BookApiQueryPort.java 책 상세 조회 시 ISBN 기반 조회로 메서드명 및 호출 변경, 검색 시 Redis에 검색 카운트 증가 추가
src/main/java/konkuk/thip/common/exception/code/ErrorCode.java JSON 직렬화/역직렬화 실패 에러 코드 추가
src/main/java/konkuk/thip/config/RedisConfig.java Redis 설정 클래스 및 RedisTemplate 빈 등록
src/main/java/konkuk/thip/ThipServerApplication.java 스케줄링 및 비동기 처리를 위한 애너테이션 추가
src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java, .../application/service/BookSearchRankServiceTest.java /books/most-searched API 및 일일 랭킹 집계 관련 통합/단위 테스트 추가
.github/workflows/ci-workflow.yml CI에서 Redis 실행 및 시크릿 파일 base64 디코딩 적용
기타 불필요한 공백/정렬 등 비기능적 변경

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Controller as BookQueryController
    participant Service as BookMostSearchService
    participant Redis as BookRedisQueryPort
    participant UserPort as UserCommandPort

    User->>Controller: GET /books/most-searched (userId)
    Controller->>Service: getMostSearchedBooks(userId)
    Service->>UserPort: findById(userId)
    Service->>Redis: getYesterdayBookRankInfos(yesterday)
    Redis-->>Service: List<BookRankInfo>
    Service-->>Controller: BookMostSearchResult
    Controller-->>User: BaseResponse<GetBookMostSearchResponse>
Loading
sequenceDiagram
    participant Scheduler as BookMostSearchRankService (스케줄러)
    participant RedisCmd as BookRedisCommandPort
    participant RedisQry as BookRedisQueryPort
    participant BookRepo as BookCommandPort
    participant BookApi as BookApiQueryPort

    Scheduler->>RedisQry: getBookSearchCountTopN(yesterday, 5)
    RedisQry-->>Scheduler: List<ISBN, count>
    Scheduler->>RedisCmd: deleteBookSearchRank(yesterday)
    Scheduler->>RedisCmd: deleteBookSearchCount(yesterday)
    alt Top 5 존재
        loop 각 ISBN
            Scheduler->>BookRepo: findByIsbn(isbn)
            alt DB에 없음
                Scheduler->>BookApi: findDetailBookByIsbn(isbn)
            end
        end
        Scheduler->>RedisCmd: saveBookSearchRank(ISBNs, counts, yesterday)
        Scheduler->>RedisCmd: saveBookSearchRankDetail(BookRankInfos, yesterday)
    end
Loading

Assessment against linked issues

Objective Addressed Explanation
책 상세조회 시 해당 책의 isbn, 검색된 날짜 저장 (#48)
매일 정각마다 스케줄러로 Redis에 책 검색 순위 집계 및 내림차순 정렬 (#48)
전날 기준 Top 5 ISBN/순위 Redis 저장 (#48)
Redis에 저장된 전날 기준 가장 많이 검색된 책 조회 API 구현 (#48)

Suggested reviewers

  • buzz0331
  • seongjunnoh

Poem

🐇
래빗이 뛰며 코드를 짓네,
책 랭킹 집계, 순위는 빛나네!
자정마다 Redis에 순위 저장,
가장 많이 본 책, 이제는 명확!
API 열면 Top 5가 반겨주니,
독서의 기쁨, 데이터로 꽃피네!
📚✨

✨ Finishing Touches
  • 📝 Generate Docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@hd0rable hd0rable changed the title Feat/#48 get most serched Feat/#48 가장 많이 검색된 책 조회 api 개발 및 책 상세 검색 시 검색카운트 업데이트 로직 추가 Jul 4, 2025
@hd0rable hd0rable changed the title Feat/#48 가장 많이 검색된 책 조회 api 개발 및 책 상세 검색 시 검색카운트 업데이트 로직 추가 [Feat] 가장 많이 검색된 책 조회 api 개발 및 책 상세 검색 시 검색카운트 업데이트 로직 추가 Jul 4, 2025

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (14)
src/main/java/konkuk/thip/book/adapter/out/persistence/BookJpaRepository.java (1)

6-12: Spring Data JPA의 In 쿼리 메서드 사용 적절함

Spring Data JPA의 findBy{FieldName}In 패턴을 올바르게 사용하였습니다. 배치 조회를 위한 효율적인 접근 방법입니다.

성능 최적화 고려사항

대량의 ISBN 리스트를 처리할 때 성능 문제가 발생할 수 있습니다. 필요시 페이징이나 배치 크기 제한을 고려해보세요.

src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java (1)

8-12: save 메서드 추가 적절함

CommandPort에 save 메서드를 추가하는 것은 CQRS 패턴과 일치합니다. Long 반환 타입도 새로 생성된 엔티티의 ID를 반환하는 일반적인 패턴을 따릅니다.

예외 처리 고려사항

save 작업 시 발생할 수 있는 예외상황(중복 ISBN, 유효하지 않은 Book 데이터 등)에 대한 처리 방안을 구현체에서 고려해야 합니다.

src/main/java/konkuk/thip/ThipServerApplication.java (1)

6-12: 스케줄링 및 비동기 처리 활성화 적절함

책 검색 랭킹 시스템의 일일 집계 작업을 위해 @EnableScheduling@EnableAsync 어노테이션을 추가하는 것은 적절합니다.

스케줄링 관련 모범 사례 제안

스케줄링 작업의 안정성을 위해 다음 사항들을 고려해보세요:

  • 스케줄러 예외 처리 및 로깅
  • 중복 실행 방지를 위한 락 메커니즘
  • 스케줄러 설정의 외부 구성 파일화
src/main/java/konkuk/thip/book/application/port/in/dto/BookIsSavedResult.java (1)

9-9: 정적 팩토리 메서드의 매개변수 간격을 개선하세요.

매개변수 사이에 공백이 없어 가독성이 떨어집니다.

-    public static BookIsSavedResult of( String isbn,boolean isSaved) {
+    public static BookIsSavedResult of(String isbn, boolean isSaved) {
src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java (1)

6-9: 필드명을 더 명확하게 변경하세요.

type이라는 필드명은 저장/삭제 작업을 나타내기에 명확하지 않습니다. 더 의미있는 이름을 사용하세요.

public record PostBookIsSavedRequest(
        @NotNull(message = "type은 필수입니다.")
-        boolean type
+        boolean isSave
) {

또는 더 명확한 이름으로:

public record PostBookIsSavedRequest(
-        @NotNull(message = "type은 필수입니다.")
-        boolean type
+        @NotNull(message = "저장 여부는 필수입니다.")
+        boolean shouldSave
) {
src/main/java/konkuk/thip/book/adapter/in/web/response/PostBookIsSavedResponse.java (1)

3-3: 사용되지 않는 import 제거 필요

BookDetailSearchResult import가 사용되지 않고 있습니다.

-import konkuk.thip.book.application.port.in.dto.BookDetailSearchResult;
src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java (1)

20-26: 배치 쿼리 구현이 효율적입니다.

ISBN 목록으로 여러 책을 한 번에 조회하는 배치 쿼리 방식으로 성능을 최적화했습니다. 스트림을 통한 엔티티 변환도 적절합니다.

Java 16 이상을 사용하는 경우 더 간결한 구문으로 개선할 수 있습니다:

-                .collect(Collectors.toList());
+                .toList();
src/main/java/konkuk/thip/config/RedisConfig.java (2)

16-20: 환경 변수 검증 및 기본값 설정 권장

Redis 연결 설정에서 환경 변수 누락 시 명확한 오류 메시지를 제공하는 것이 좋습니다.

다음과 같이 개선하는 것을 권장합니다:

-    @Value("${spring.data.redis.host}")
+    @Value("${spring.data.redis.host:localhost}")
     private String host;
 
-    @Value("${spring.data.redis.port}")
+    @Value("${spring.data.redis.port:6379}")
     private int port;

또는 @ConfigurationProperties를 사용하여 더 체계적으로 관리:

@ConfigurationProperties(prefix = "spring.data.redis")
@Data
public class RedisProperties {
    private String host = "localhost";
    private int port = 6379;
    private String password;
    private int database = 0;
}

23-30: 프로덕션 환경을 위한 Redis 설정 개선 제안

현재 설정은 기본적인 기능에는 충분하지만, 프로덕션 환경에서는 추가 설정이 필요할 수 있습니다.

다음 개선사항들을 고려해 보세요:

 @Bean
 public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
     RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
     redisTemplate.setConnectionFactory(connectionFactory);
+    
+    // Hash key, value 시리얼라이저 설정
+    redisTemplate.setHashKeySerializer(new StringRedisSerializer());
+    redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
+    
     // 일반적인 key:value의 경우 시리얼라이저
     redisTemplate.setKeySerializer(new StringRedisSerializer());
     redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
+    
+    // 트랜잭션 지원 활성화
+    redisTemplate.setEnableTransactionSupport(true);
+    
     return redisTemplate;
 }

또한 연결 풀 설정도 고려해 보세요:

@Bean
public RedisConnectionFactory redisConnectionFactory() {
    LettuceConnectionFactory factory = new LettuceConnectionFactory(host, port);
    // 연결 풀 설정 등 추가 가능
    return factory;
}
src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookMostSearchResponse.java (1)

11-17: Record에서 @builder 어노테이션 제거 권장

Java Record는 이미 모든 필드를 위한 생성자를 제공하므로 @Builder 어노테이션이 불필요합니다.

다음과 같이 개선하는 것을 권장합니다:

-    @Builder
     public record BookRankInfo(
             int rank,
             String title,
             String imageUrl,
             String isbn
     ) {}

Record의 기본 생성자를 사용하면 더 간단하고 명확합니다:

// 사용 예시
BookRankInfo bookRankInfo = new BookRankInfo(1, "책 제목", "이미지 URL", "ISBN");

만약 빌더 패턴이 정말 필요하다면, 별도의 빌더 클래스를 생성하거나 정적 팩토리 메서드를 추가하는 것이 더 적절합니다.

src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandRedisAdapter.java (1)

36-42: 배치 처리 성능 개선 제안

현재 구현은 각 ISBN-score 쌍을 개별적으로 Redis에 추가하고 있습니다. 대량의 데이터를 처리할 때 성능상 이슈가 있을 수 있습니다.

다음과 같이 한 번에 모든 데이터를 추가하도록 개선할 수 있습니다:

 @Override
 public void saveBookSearchRank(List<String> isbns, List<Double> scores, LocalDate date) {
     String redisKey = makeRedisKey(searchRankPrefix, date);
-    for (int i = 0; i < isbns.size(); i++) {
-        redisTemplate.opsForZSet().add(redisKey, isbns.get(i), scores.get(i));
-    }
+    Set<ZSetOperations.TypedTuple<String>> tuples = new HashSet<>();
+    for (int i = 0; i < isbns.size(); i++) {
+        tuples.add(ZSetOperations.TypedTuple.of(isbns.get(i), scores.get(i)));
+    }
+    redisTemplate.opsForZSet().add(redisKey, tuples);
     redisTemplate.expire(redisKey, Duration.ofDays(7));
 }

이 방법은 Redis에 대한 네트워크 호출 횟수를 줄여 성능을 향상시킵니다.

src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java (1)

42-46: 임시 구현에 대한 TODO 추가를 고려하세요

"삭제 전략 도입 전"이라는 주석은 이 구현이 임시적임을 나타냅니다. 향후 변경 계획이 있다면 TODO 주석으로 명확히 표시하는 것이 좋습니다.

또한 deleteBook 메서드는 삭제할 엔티티의 존재 여부를 확인하지 않습니다. 존재하지 않는 엔티티를 삭제하려고 할 때의 동작을 명확히 하거나, 삭제 전 존재 여부를 확인하는 로직을 추가하는 것을 고려해보세요.

src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java (1)

23-24: DateTimeFormatter를 static final로 선언하세요

DateTimeFormatter는 thread-safe하고 불변 객체이므로 static final로 선언하는 것이 성능상 유리합니다.

-    private final DateTimeFormatter DAILY_KEY_FORMATTER =
-            DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final DateTimeFormatter DAILY_KEY_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyyMMdd");
src/main/java/konkuk/thip/book/application/service/BookSavedService.java (1)

40-49: 외부 API 호출이 트랜잭션 내에서 실행됩니다

네이버 API 호출이 트랜잭션 경계 내에서 실행되고 있습니다. 외부 API의 응답 시간이 길어질 경우 DB 커넥션을 오래 점유할 수 있으므로, 필요시 트랜잭션 경계를 조정하는 것을 고려해보세요.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 652884f and 5a23a82.

📒 Files selected for processing (36)
  • src/main/java/konkuk/thip/ThipServerApplication.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java (3 hunks)
  • src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookMostSearchResponse.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/in/web/response/PostBookIsSavedResponse.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverDetailBookParseResult.java (0 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java (2 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandRedisAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/in/BookMostSearchUseCase.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/in/BookSavedUseCase.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/in/dto/BookIsSavedResult.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/in/dto/BookMostSearchResult.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/service/BookMostSearchRankService.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/service/BookSavedService.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/service/BookSearchService.java (3 hunks)
  • src/main/java/konkuk/thip/book/domain/Book.java (2 hunks)
  • src/main/java/konkuk/thip/book/domain/SavedBooks.java (1 hunks)
  • src/main/java/konkuk/thip/common/exception/code/ErrorCode.java (1 hunks)
  • src/main/java/konkuk/thip/config/RedisConfig.java (1 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedBookJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java (1 hunks)
  • src/test/java/konkuk/thip/book/adapter/in/web/BookIsSavedControllerTest.java (1 hunks)
  • src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java (1 hunks)
  • src/test/java/konkuk/thip/book/application/service/BookSearchRankServiceTest.java (1 hunks)
💤 Files with no reviewable changes (1)
  • src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverDetailBookParseResult.java
🧰 Additional context used
🧠 Learnings (18)
src/main/java/konkuk/thip/ThipServerApplication.java (1)
Learnt from: hd0rable
PR: THIP-TextHip/THIP-Server#36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.
src/main/java/konkuk/thip/book/application/service/BookSearchService.java (2)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
Learnt from: hd0rable
PR: THIP-TextHip/THIP-Server#36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.
src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/adapter/out/persistence/BookJpaRepository.java (2)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#36
File: src/main/java/konkuk/thip/user/adapter/out/persistence/UserJpaRepository.java:7-7
Timestamp: 2025-06-29T09:47:31.299Z
Learning: Spring Data JPA에서 findBy{FieldName} 패턴의 메서드는 명시적 선언 없이 자동으로 생성되며, Optional<Entity> 반환 타입을 사용하는 것이 null 안전성을 위해 권장됩니다.
src/main/java/konkuk/thip/book/application/port/in/BookMostSearchUseCase.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/config/RedisConfig.java (1)
Learnt from: hd0rable
PR: THIP-TextHip/THIP-Server#36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.
src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandRedisAdapter.java (1)
Learnt from: hd0rable
PR: THIP-TextHip/THIP-Server#36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.
src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java (1)
Learnt from: hd0rable
PR: THIP-TextHip/THIP-Server#36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.
🧬 Code Graph Analysis (2)
src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java (1)
src/main/java/konkuk/thip/book/adapter/out/api/NaverApiUtil.java (1)
  • RequiredArgsConstructor (18-123)
src/main/java/konkuk/thip/book/application/service/BookSavedService.java (1)
src/main/java/konkuk/thip/book/adapter/out/api/NaverApiUtil.java (1)
  • RequiredArgsConstructor (18-123)
🔇 Additional comments (46)
src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java (1)

4-7: 메서드 시그니처 적절함

Command 패턴에 맞게 상태 변경 작업을 수행하는 메서드들로 적절하게 설계되었습니다. 메서드명도 명확하고 직관적입니다.

src/main/java/konkuk/thip/book/application/port/in/BookSavedUseCase.java (1)

5-7: 인터페이스 설계가 잘 되어 있습니다.

메서드 시그니처가 명확하고 매개변수 이름이 적절하며, 반환 타입도 올바른 계층의 DTO를 사용하고 있습니다.

src/main/java/konkuk/thip/book/application/port/in/BookMostSearchUseCase.java (1)

5-7: 인터페이스 설계는 좋으나 의존성 문제가 있습니다.

인터페이스 자체의 설계는 명확하고 적절하지만, 반환 타입인 BookMostSearchResult가 웹 계층에 의존하는 문제가 있습니다. 해당 DTO의 아키텍처 위반을 먼저 수정하세요.

src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java (2)

3-3: 적절한 도메인 객체 import

SavedBooks 도메인 클래스를 올바르게 import하였습니다.


7-7: CQRS 패턴을 따르는 QueryPort 메서드 추가

사용자 ID로 저장된 책 목록을 조회하는 메서드가 올바르게 추가되었습니다. 학습된 컨벤션에 따라 QueryPort에 도메인 엔티티를 반환하는 메서드를 추가한 것이 적절합니다.

src/main/java/konkuk/thip/book/application/service/BookSearchService.java (2)

10-10: Redis 기반 검색 횟수 추적을 위한 의존성 추가

BookRedisCommandPort 의존성이 올바르게 추가되어 검색 랭킹 기능을 지원합니다.


92-94: 검색 횟수 증가 로직 추가

책 상세 정보 조회 시 Redis에 검색 횟수를 증가시키는 로직이 적절한 위치에 배치되었습니다. 외부 API 호출 후, 데이터베이스 존재 여부 확인 전에 실행되는 것이 논리적으로 올바릅니다.

src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java (2)

3-3: JPA 엔티티 import 추가

BookJpaEntity import가 올바르게 추가되었습니다.


25-29: 책 저장 메서드 구현

도메인 엔티티를 JPA 엔티티로 변환하여 저장하고 생성된 ID를 반환하는 로직이 올바르게 구현되었습니다. 매퍼를 통한 변환과 저장된 엔티티의 ID 반환이 적절합니다.

src/main/java/konkuk/thip/book/adapter/in/web/response/PostBookIsSavedResponse.java (1)

7-15: 적절한 응답 DTO 구현

Record 클래스를 사용한 불변 DTO 구현과 정적 팩토리 메서드가 올바르게 작성되었습니다. @builder 어노테이션으로 빌더 패턴도 제공하고 있어 좋습니다.

src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedBookJpaRepository.java (2)

6-6: List import 추가

List 타입 사용을 위한 import가 올바르게 추가되었습니다.


10-11: 저장된 책 관리를 위한 JPA 메서드 추가

사용자 ID와 책 ID를 이용한 삭제 메서드와 사용자 ID로 저장된 책 목록을 조회하는 메서드가 Spring Data JPA 컨벤션에 맞게 올바르게 추가되었습니다.

src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java (2)

5-5: 새로운 의존성과 응답 타입 추가가 적절합니다.

가장 많이 검색된 책 조회 기능을 위한 새로운 의존성과 응답 타입이 올바르게 추가되었습니다.

Also applies to: 8-8, 21-21


43-48: 새로운 엔드포인트 구현이 올바릅니다.

가장 많이 검색된 책을 조회하는 엔드포인트가 기존 패턴과 일관되게 구현되었습니다. 사용자 ID를 받아서 처리하는 구조도 적절합니다.

src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java (1)

13-18: 어댑터 클래스 구조가 올바릅니다.

CQRS 패턴에 맞는 Query 전용 어댑터로 적절하게 구현되었습니다. 의존성 주입과 어노테이션 사용도 올바릅니다.

src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java (1)

7-10: Redis 쿼리 포트 인터페이스 설계가 적절합니다.

Redis Sorted Set 데이터 구조에 적합한 반환 타입과 명확한 메서드 명명으로 잘 설계되었습니다. 날짜 기반 조회와 상위 N개 제한으로 성능도 고려되었습니다.

src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java (3)

13-13: 유효성 검사 어노테이션 추가가 적절합니다.

@Validated 어노테이션 추가로 컨트롤러 레벨에서 유효성 검사가 활성화되어 데이터 무결성과 보안이 강화되었습니다.


18-18: 새로운 UseCase 의존성 추가가 올바릅니다.

책 저장 상태 변경 기능을 위한 BookSavedUseCase 의존성이 적절하게 추가되었습니다.


20-27: 새로운 엔드포인트 구현이 올바릅니다.

책 저장 상태 변경을 위한 POST 엔드포인트가 RESTful API 설계 원칙에 맞게 구현되었습니다. ISBN 패턴 검증과 요청 본문 검증도 적절하게 적용되었습니다.

src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java (1)

27-29: 새로운 의존성 추가가 적절합니다.

사용자 검증과 책 엔티티 변환을 위한 UserJpaRepositoryBookMapper 의존성이 올바르게 추가되었습니다.

src/main/java/konkuk/thip/common/exception/code/ErrorCode.java (1)

52-54: 오류 코드 정의가 적절합니다

새로 추가된 세 개의 오류 코드들이 책 저장 기능에 적합하며, 기존 패턴과 일관성을 유지하고 있습니다:

  • HTTP 상태 코드 선택이 적절함
  • 오류 메시지가 명확하고 사용자 친화적임
  • 코드 번호가 순차적으로 할당됨
src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java (1)

6-11: 포트 인터페이스 설계가 적절합니다

Redis 명령 작업을 위한 인터페이스가 잘 설계되었습니다:

  • 메서드명이 명확하고 목적을 잘 나타냄
  • CQRS 패턴의 CommandPort 컨벤션을 따름
  • 파라미터 타입이 적절함

다만 추후 구현체에서 다음 사항들을 고려해 보세요:

  • saveBookSearchRank 메서드에서 isbns와 scores 리스트의 길이 검증
  • null 체크 및 빈 리스트 처리
  • 예외 상황에 대한 명확한 문서화
src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookMostSearchResponse.java (1)

19-21: 정적 팩토리 메서드 구현이 적절합니다

BookMostSearchResult에서 GetBookMostSearchResponse로의 변환을 위한 정적 팩토리 메서드가 잘 구현되어 있습니다. 도메인 객체와 웹 응답 객체 간의 명확한 분리를 제공합니다.

src/test/java/konkuk/thip/book/application/service/BookSearchRankServiceTest.java (4)

21-23: 테스트 클래스 설정이 적절합니다.

SpringBootTest와 test 프로필을 사용하여 통합 테스트 환경을 올바르게 구성했습니다.


25-40: 의존성 주입이 적절하게 구성되어 있습니다.

Redis 관련 포트들과 RedisTemplate, 그리고 설정값들이 올바르게 주입되어 있습니다.


42-53: 테스트 데이터 설정이 잘 구성되어 있습니다.

어제 날짜 기준으로 3개의 테스트 ISBN 데이터를 서로 다른 점수로 설정하여 랭킹 테스트에 적합한 환경을 만들었습니다.


55-88: 종합적인 랭킹 집계 로직 검증이 잘 구현되어 있습니다.

테스트가 다음을 모두 검증하고 있어 매우 포괄적입니다:

  • Top 5 조회 기능
  • 기존 키 삭제 기능
  • 새로운 랭킹 저장 기능
  • 저장된 데이터의 정확성 (비어있지 않음, 최대 5개, 내림차순 정렬, 테스트 데이터 포함)
src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java (5)

21-27: 서비스 클래스 구성이 적절합니다.

필요한 포트들이 모두 주입되어 있고, 의존성 구조가 깔끔합니다.


29-40: 빈 결과 처리에 대한 PM 상의 필요성을 잘 표현했습니다.

랭킹 데이터가 없을 때의 처리 방식을 PM과 상의 후 결정한다는 주석이 비즈니스 요구사항 불확실성을 잘 나타냅니다.


42-51: DB 조회 및 매핑 로직이 효율적입니다.

한 번의 배치 조회로 모든 ISBN을 조회하고 Map으로 변환하여 효율적인 접근이 가능합니다.


53-75: 랭킹 순서 유지 및 폴백 처리가 잘 구현되어 있습니다.

Redis의 랭킹 순서를 유지하면서 각 책에 대해 DB에서 찾지 못할 경우 Naver API로 폴백하는 로직이 적절합니다.


77-79: 결과 반환 로직이 적절합니다.

생성된 랭킹 정보 리스트를 적절한 도메인 객체로 래핑하여 반환합니다.

src/main/java/konkuk/thip/book/domain/Book.java (5)

7-7: 필요한 import가 적절히 추가되었습니다.

Objects 클래스를 사용하여 null-safe한 equals/hashCode 구현을 위해 필요한 import입니다.


31-37: ISBN 기반 equals 구현이 적절합니다.

도메인 특성상 ISBN을 기준으로 한 동등성 비교가 타당하며, null 체크와 타입 체크가 포함된 안전한 구현입니다.


39-42: hashCode 구현이 equals와 일관성을 유지합니다.

equals에서 사용하는 필드(ISBN)와 동일한 필드로 hashCode를 구현하여 equals/hashCode 계약을 준수합니다.


44-56: 정적 팩토리 메서드가 잘 구현되어 있습니다.

ID 없이 Book 객체를 생성하는 명확한 의도가 드러나며, 모든 필드를 매개변수로 받아 완전한 객체 생성이 가능합니다.


58-70: 불변 객체 패턴을 잘 구현했습니다.

기존 객체의 모든 필드를 복사하면서 ID만 변경하는 방식으로 불변 객체의 특성을 유지합니다.

src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandRedisAdapter.java (5)

14-27: Redis 어댑터 구성이 적절합니다.

필요한 의존성들이 올바르게 주입되어 있고, 날짜 포맷터와 설정값들이 잘 구성되어 있습니다.


29-33: 검색 카운트 증가 로직이 효율적입니다.

Redis Sorted Set의 incrementScore를 사용하여 원자적으로 검색 카운트를 증가시키는 방식이 적절합니다.


35-42: 랭킹 저장 로직이 잘 구현되어 있습니다.

배치로 데이터를 저장하고 7일 TTL을 설정하여 자동 정리되도록 하는 방식이 효율적입니다.


44-52: 삭제 메서드들이 적절히 구현되어 있습니다.

공통 헬퍼 메서드를 활용하여 중복 코드를 제거하고 일관성을 유지합니다.


54-62: 헬퍼 메서드들이 잘 구현되어 있습니다.

키 생성과 삭제 로직이 재사용 가능하도록 잘 분리되어 있습니다.

src/main/java/konkuk/thip/book/domain/SavedBooks.java (4)

10-12: 불변 컬렉션 사용이 적절합니다.

Set을 사용하여 중복을 방지하고 불변성을 보장하는 설계가 좋습니다.


14-22: 중복 검증 로직이 효과적입니다.

List를 Set으로 변환하여 크기 비교를 통해 중복을 감지하는 방식이 간단하고 효과적입니다. Collections.unmodifiableSet을 사용하여 불변성도 보장합니다.


24-29: 비즈니스 규칙 검증이 명확합니다.

이미 저장된 책을 중복 저장하는 것을 방지하는 비즈니스 규칙이 명확하게 구현되어 있습니다.


31-36: 삭제 가능성 검증이 적절합니다.

저장되지 않은 책을 삭제하려고 할 때 적절한 예외를 발생시키는 비즈니스 규칙이 잘 구현되어 있습니다.

Comment thread src/main/java/konkuk/thip/book/application/port/in/dto/BookMostSearchResult.java Outdated
Comment on lines +37 to +50
@Override
public SavedBooks findByUserId(Long userId) {

UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
List<SavedBookJpaEntity> savedBookEntities = savedBookJpaRepository.findByUserJpaEntity_UserId(user.getUserId());

// SavedBookJpaEntity에서 BookJpaEntity를 꺼내 도메인 Book으로 변환
List<Book> books = savedBookEntities.stream()
.map(entity -> bookMapper.toDomainEntity(entity.getBookJpaEntity()))
.collect(Collectors.toList());

return new SavedBooks(books);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

사용자별 저장된 책 조회 로직이 올바릅니다.

사용자 존재 여부 확인, 예외 처리, 그리고 도메인 객체 변환이 적절하게 구현되었습니다.

N+1 쿼리 문제가 발생할 수 있는지 확인해 주세요. SavedBookJpaEntity에서 BookJpaEntity를 가져올 때 지연 로딩으로 인한 성능 이슈가 있을 수 있습니다.


🏁 Script executed:

#!/bin/bash
# 설명: SavedBookJpaEntity와 BookJpaEntity 간의 관계 설정 확인
# 예상 결과: fetch 전략이 EAGER이거나 @EntityGraph 사용 여부 확인

# SavedBookJpaEntity 클래스에서 BookJpaEntity 관계 설정 확인
rg -A 10 -B 5 "class SavedBookJpaEntity"

# BookJpaEntity 관계 어노테이션 확인
rg -A 5 -B 5 "@ManyToOne\|@OneToOne\|@JoinColumn" --type java

Length of output: 1677


🏁 Script executed:

#!/bin/bash
# SavedBookJpaEntity에서 BookJpaEntity 매핑(fetch 전략) 확인
rg -n "@ManyToOne" -C3 src/main/java/konkuk/thip/saved/adapter/out/jpa/SavedBookJpaEntity.java

Length of output: 441


N+1 쿼리 최적화 필요

SavedBookJpaEntity에서 BookJpaEntity 매핑이 모두 fetch = FetchType.LAZY로 설정되어 있어, 아래 로직을 호출할 때 저장된 책 수만큼 추가 쿼리가 발생할 수 있습니다.

  • src/main/java/konkuk/thip/saved/adapter/out/jpa/SavedBookJpaEntity.java
    @ManyToOne(fetch = FetchType.LAZY) private BookJpaEntity bookJpaEntity;
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedBookJpaRepository.java
    List<SavedBookJpaEntity> findByUserJpaEntity_UserId(Long userId);

성능 이슈 방지를 위해 다음 중 하나를 적용하세요.

  • Repository 메소드에 @EntityGraph 추가
    @EntityGraph(attributePaths = "bookJpaEntity")
    List<SavedBookJpaEntity> findByUserJpaEntity_UserId(Long userId);
  • JPQL fetch join 사용
    @Query("select s from SavedBookJpaEntity s join fetch s.bookJpaEntity where s.userJpaEntity.userId = :userId")
    List<SavedBookJpaEntity> findByUserJpaEntity_UserId(@Param("userId") Long userId);
  • Hibernate 배치 사이즈(batch size) 설정

위 최적화 중 하나를 적용해 N+1 쿼리를 방지해주세요.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java
around lines 37 to 50, the current code triggers N+1 query issues because
BookJpaEntity is lazily loaded for each SavedBookJpaEntity. To fix this, modify
the SavedBookJpaRepository method findByUserJpaEntity_UserId to eagerly fetch
the associated BookJpaEntity by either adding @EntityGraph(attributePaths =
"bookJpaEntity") annotation or rewriting the method with a JPQL fetch join
query. This change will load all related BookJpaEntity instances in a single
query, preventing multiple additional queries and improving performance.

Comment thread src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java Outdated
Comment thread src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java Outdated
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class BookChangeSavedControllerTest {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

클래스명과 파일명이 일치하지 않습니다

파일명은 BookIsSavedControllerTest.java이지만 클래스명은 BookChangeSavedControllerTest입니다. 일관성을 위해 둘 중 하나를 수정해야 합니다.

🤖 Prompt for AI Agents
In src/test/java/konkuk/thip/book/adapter/in/web/BookIsSavedControllerTest.java
at line 38, the class name BookChangeSavedControllerTest does not match the file
name BookIsSavedControllerTest.java. Rename the class to
BookIsSavedControllerTest to ensure consistency between the class name and the
file name.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
.github/workflows/ci-workflow.yml (1)

38-44: 주석 코드는 과감히 제거하여 혼란 방지
이미 base64 방식으로 대체되었으므로, 예전 plaintext-secret 생성 로직은 주석으로 남길 이유가 없습니다. CI 파일은 배포·보안과 직결되니 불필요한 히스토리는 과감히 삭제해 가독성과 유지보수를 높여주세요.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 47d1201 and 003d72c.

📒 Files selected for processing (1)
  • .github/workflows/ci-workflow.yml (1 hunks)

Comment on lines +54 to +57
- name: 🚀 Start Redis
uses: supercharge/redis-github-action@1.7.0
with:
redis-version: 7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Redis 액션 버전 태그 고정으로 공급망 위험 완화
supercharge/redis-github-action@1.7.0 태그는 mutable tag 입니다.
SHA-pinned 즉 supercharge/redis-github-action@<commit-sha> 형식으로 고정하면, 제3자 공격으로 태그가 다른 커밋을 가리켜도 빌드가 영향을 받지 않습니다.

🤖 Prompt for AI Agents
In .github/workflows/ci-workflow.yml around lines 54 to 57, the Redis GitHub
Action uses a mutable tag '1.7.0' which poses a supply chain risk. Replace the
version tag with a specific commit SHA to pin the action to an immutable
reference, preventing unexpected changes from affecting the build. Find the
commit SHA corresponding to version 1.7.0 and update the 'uses' field to
'supercharge/redis-github-action@<commit-sha>'.

Comment on lines 45 to 50
run: |
mkdir -p ${{ env.RESOURCE_PATH }}
mkdir -p ${{ env.TEST_RESOURCE_PATH }}
echo "${{ secrets.APPLICATION_YML_DEV }}" > ${{ env.RESOURCE_PATH }}/application.yml
echo "${{ secrets.APPLICATION_YML_TEST }}" > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml

echo "${{ secrets.APPLICATION_YML_DEV }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml
echo "${{ secrets.APPLICATION_YML_TEST }}" | base64 --decode > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

echoprintf로 치환해 다중 라인 secret 디코딩 신뢰성 향상
echo는 개행·백슬래시 해석 등으로 secret 내용이 변형될 가능성이 있습니다. printf '%s' 를 사용하면 내용을 그대로 전달할 수 있어 안전합니다.

-echo "${{ secrets.APPLICATION_YML_DEV }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml
-echo "${{ secrets.APPLICATION_YML_TEST }}" | base64 --decode > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml
+printf '%s' "${{ secrets.APPLICATION_YML_DEV }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml
+printf '%s' "${{ secrets.APPLICATION_YML_TEST }}" | base64 --decode > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml

추가로, 디코딩 실패 시 조기에 잡을 수 있도록 set -euo pipefail 선언을 고려해 보세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
run: |
mkdir -p ${{ env.RESOURCE_PATH }}
mkdir -p ${{ env.TEST_RESOURCE_PATH }}
echo "${{ secrets.APPLICATION_YML_DEV }}" > ${{ env.RESOURCE_PATH }}/application.yml
echo "${{ secrets.APPLICATION_YML_TEST }}" > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml
echo "${{ secrets.APPLICATION_YML_DEV }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml
echo "${{ secrets.APPLICATION_YML_TEST }}" | base64 --decode > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml
run: |
mkdir -p ${{ env.RESOURCE_PATH }}
mkdir -p ${{ env.TEST_RESOURCE_PATH }}
printf '%s' "${{ secrets.APPLICATION_YML_DEV }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml
printf '%s' "${{ secrets.APPLICATION_YML_TEST }}" | base64 --decode > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml
🤖 Prompt for AI Agents
In .github/workflows/ci-workflow.yml around lines 45 to 50, replace the use of
echo with printf '%s' to decode the base64 secrets to prevent modification of
multiline secret content due to echo's interpretation of newlines and
backslashes. Additionally, add set -euo pipefail at the start of the run script
to ensure the workflow fails early on any error, including decoding failures.

@buzz0331 buzz0331 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

복잡한 로직이였을텐데 고생하셨습니다~ 리뷰 몇가지 남겼는데 확인 부탁드릴게요!

음 그리고 BookRankInfo 같은 경우 Redis에 어떻게 보면 영속화시키는 것이기 때문에 book안의 하나의 도메인(예를들어, RankingBook)으로 분류해도 되지 않을까.. 라는 생각이 들기는 한데 다른 분들은 어떻게 생각하시는지 궁금합니다! @seongjunnoh @hd0rable

Comment thread src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java Outdated
Comment thread src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java Outdated

public interface BookJpaRepository extends JpaRepository<BookJpaEntity, Long> {
Optional<BookJpaEntity> findByIsbn(String isbn);
List<BookJpaEntity> findByIsbnIn(List<String> isbnList);

@buzz0331 buzz0331 Jul 6, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 유용한 메서드 👍🏻

Comment thread src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java Outdated
@hd0rable

hd0rable commented Jul 7, 2025

Copy link
Copy Markdown
Member Author

복잡한 로직이였을텐데 고생하셨습니다~ 리뷰 몇가지 남겼는데 확인 부탁드릴게요!

음 그리고 BookRankInfo 같은 경우 Redis에 어떻게 보면 영속화시키는 것이기 때문에 book안의 하나의 도메인(예를들어, RankingBook)으로 분류해도 되지 않을까.. 라는 생각이 들기는 한데 다른 분들은 어떻게 생각하시는지 궁금합니다! @seongjunnoh @hd0rable

오호 네이버 api를 사용할때 따로 dto를 이용하는 것처럼 레디스로 랭킹정보를 받아올때도 이와같이 사용하자는말씀이신건가요?? 전 좋은것같습니닷!!

@buzz0331

buzz0331 commented Jul 7, 2025

Copy link
Copy Markdown
Contributor

복잡한 로직이였을텐데 고생하셨습니다~ 리뷰 몇가지 남겼는데 확인 부탁드릴게요!

음 그리고 BookRankInfo 같은 경우 Redis에 어떻게 보면 영속화시키는 것이기 때문에 book안의 하나의 도메인(예를들어, RankingBook)으로 분류해도 되지 않을까.. 라는 생각이 들기는 한데 다른 분들은 어떻게 생각하시는지 궁금합니다! @seongjunnoh @hd0rable

오호 네이버 api를 사용할때 따로 dto를 이용하는 것처럼 레디스로 랭킹정보를 받아올때도 이와같이 사용하자는말씀이신건가요?? 전 좋은것같습니닷!!

엇 제가 말을 조금 헷갈리게 드린 것 같아요
위에서 말씀드린 것처럼 Redis의 value에 isbn, title, imageUrl 등의 값을 함께 캐싱하자는 의견과 이어지는 내용인데요
이렇게 하루 정도 유지되는 형태로 데이터를 캐싱(일시적 영속화)하는 거라면, 저희 도메인 모델로 따로 분리해서 RankingBook처럼 정의해도 괜찮지 않을까 생각해봤습니다! (book 패키지 안에)

@hd0rable

hd0rable commented Jul 7, 2025

Copy link
Copy Markdown
Member Author

복잡한 로직이였을텐데 고생하셨습니다~ 리뷰 몇가지 남겼는데 확인 부탁드릴게요!

음 그리고 BookRankInfo 같은 경우 Redis에 어떻게 보면 영속화시키는 것이기 때문에 book안의 하나의 도메인(예를들어, RankingBook)으로 분류해도 되지 않을까.. 라는 생각이 들기는 한데 다른 분들은 어떻게 생각하시는지 궁금합니다! @seongjunnoh @hd0rable

오호 네이버 api를 사용할때 따로 dto를 이용하는 것처럼 레디스로 랭킹정보를 받아올때도 이와같이 사용하자는말씀이신건가요?? 전 좋은것같습니닷!!

엇 제가 말을 조금 헷갈리게 드린 것 같아요 위에서 말씀드린 것처럼 Redis의 value에 isbn, title, imageUrl 등의 값을 함께 캐싱하자는 의견과 이어지는 내용인데요 이렇게 하루 정도 유지되는 형태로 데이터를 캐싱(일시적 영속화)하는 거라면, 저희 도메인 모델로 따로 분리해서 RankingBook처럼 정의해도 괜찮지 않을까 생각해봤습니다! (book 패키지 안에)

아아 넵넵 확인했습니다! 좋은것같습니다 해당방식으로 수정해볼게욥

@seongjunnoh seongjunnoh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다! 리뷰 확인부탁드립니다!

import konkuk.thip.book.application.port.in.dto.BookMostSearchResult;

public interface BookMostSearchUseCase {
BookMostSearchResult getMostSearchedBooks(Long userId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p2 : userId 메서드는 사용되지 않고 있습니다!

Comment thread src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java Outdated
Comment on lines +26 to +28
// 일반적인 key:value의 경우 시리얼라이저
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment on lines +35 to +42
@Override
public void saveBookSearchRank(List<String> isbns, List<Double> scores, LocalDate date) {
String redisKey = makeRedisKey(searchRankPrefix, date);
for (int i = 0; i < isbns.size(); i++) {
redisTemplate.opsForZSet().add(redisKey, isbns.get(i), scores.get(i));
}
redisTemplate.expire(redisKey, Duration.ofDays(7));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3 : 서버 <-> Redis 서버간에 N번의 왕복이 발생하는 것 보다는 Redis 파이프라이닝을 활용하면 성능이 개선될 수 있을 것 같습니다!

예시코드

@Override
    public void saveBookSearchRank(List<String> isbns, List<Double> scores, LocalDate date) {
        String redisKey = makeRedisKey(searchRankPrefix, date);
        // 파이프라인 블록 시작
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            byte[] rawKey = redisTemplate.getStringSerializer().serialize(redisKey);
            for (int i = 0; i < isbns.size(); i++) {
                byte[] rawValue = redisTemplate.getStringSerializer().serialize(isbns.get(i));
                double score = scores.get(i);
                // Redis ZADD 명령
                connection.zAdd(rawKey, score, rawValue);
            }
            // TTL 설정도 파이프라인에 포함 가능
            connection.expire(rawKey, Duration.ofDays(7).getSeconds());
            return null;  // 리턴 값은 무시됨
        });
        // 파이프라인 블록 종료 — 이 시점에 한 번에 Redis로 전송
    }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그런데 다시 생각해보니 5개만 저장하는거니 거기서 거기인거 같네요 ㅎ
그냥 이런게 있다~~ 라고 넘어가셔도 될 듯 합니다

@seongjunnoh

Copy link
Copy Markdown
Collaborator

복잡한 로직이였을텐데 고생하셨습니다~ 리뷰 몇가지 남겼는데 확인 부탁드릴게요!

음 그리고 BookRankInfo 같은 경우 Redis에 어떻게 보면 영속화시키는 것이기 때문에 book안의 하나의 도메인(예를들어, RankingBook)으로 분류해도 되지 않을까.. 라는 생각이 들기는 한데 다른 분들은 어떻게 생각하시는지 궁금합니다! @seongjunnoh @hd0rable

오호 네이버 api를 사용할때 따로 dto를 이용하는 것처럼 레디스로 랭킹정보를 받아올때도 이와같이 사용하자는말씀이신건가요?? 전 좋은것같습니닷!!

엇 제가 말을 조금 헷갈리게 드린 것 같아요 위에서 말씀드린 것처럼 Redis의 value에 isbn, title, imageUrl 등의 값을 함께 캐싱하자는 의견과 이어지는 내용인데요 이렇게 하루 정도 유지되는 형태로 데이터를 캐싱(일시적 영속화)하는 거라면, 저희 도메인 모델로 따로 분리해서 RankingBook처럼 정의해도 괜찮지 않을까 생각해봤습니다! (book 패키지 안에)

음 그런데 redis에 저장하는 데이터를 dto 로 정의하는게 아니라, 따로 도메인 레벨? 로 정의하려고 하시는 이유가 있나요?

@hd0rable hd0rable requested review from buzz0331 and seongjunnoh July 7, 2025 19:29

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/main/java/konkuk/thip/book/application/port/in/dto/BookMostSearchResult.java (1)

3-3: 클린 아키텍처 의존성 규칙 위반

애플리케이션 계층이 웹 어댑터 계층의 클래스를 import하는 것은 의존성 규칙 위반입니다. BookRankInfo를 별도로 정의해주세요.

src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java (1)

53-63: topN 파라미터 검증을 추가하세요

topN 파라미터가 0 이하의 값일 때 예상치 못한 동작이 발생할 수 있습니다. 특히 topN - 1이 음수가 되면 Redis reverseRangeWithScores 호출에서 문제가 될 수 있습니다.

 private List<Map.Entry<String, Double>> getTopNFromZSet(String prefix, LocalDate date, int topN) {
+    if (topN <= 0) {
+        return Collections.emptyList();
+    }
     String dateStr = date.format(DAILY_KEY_FORMATTER);
     String redisKey = prefix + dateStr;
🧹 Nitpick comments (2)
src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandRedisAdapter.java (1)

72-72: 메서드 포맷팅 개선 필요

메서드 본문이 한 줄에 작성되어 가독성이 떨어집니다.

-    public void deleteBookSearchCount(LocalDate date) { deleteZSetKey(searchCountPrefix, date); }
+    public void deleteBookSearchCount(LocalDate date) {
+        deleteZSetKey(searchCountPrefix, date);
+    }

-    public void deleteBookSearchRankDetail(LocalDate date) { deleteZSetKey(searchRankDetailPrefix, date); }
+    public void deleteBookSearchRankDetail(LocalDate date) {
+        deleteZSetKey(searchRankDetailPrefix, date);
+    }

Also applies to: 75-75

src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java (1)

29-30: DateTimeFormatter를 static final로 선언하세요

DAILY_KEY_FORMATTER는 불변 상수이므로 static final로 선언하는 것이 메모리 효율적입니다.

-    private final DateTimeFormatter DAILY_KEY_FORMATTER =
-            DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final DateTimeFormatter DAILY_KEY_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyyMMdd");
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 003d72c and 80eb90e.

📒 Files selected for processing (16)
  • src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookMostSearchResponse.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandRedisAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/in/dto/BookMostSearchResult.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java (0 hunks)
  • src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/service/BookMostSearchRankService.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/service/BookSavedService.java (1 hunks)
  • src/main/java/konkuk/thip/book/application/service/BookSearchService.java (3 hunks)
  • src/main/java/konkuk/thip/common/exception/code/ErrorCode.java (1 hunks)
  • src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java (1 hunks)
💤 Files with no reviewable changes (1)
  • src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java
  • src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookMostSearchResponse.java
  • src/main/java/konkuk/thip/book/application/service/BookSearchService.java
  • src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java
  • src/main/java/konkuk/thip/book/application/service/BookMostSearchRankService.java
  • src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java
🧰 Additional context used
🧠 Learnings (5)
src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java (1)
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.
src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandRedisAdapter.java (1)
Learnt from: hd0rable
PR: THIP-TextHip/THIP-Server#36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.
src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java (1)
Learnt from: hd0rable
PR: THIP-TextHip/THIP-Server#36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (8)
src/main/java/konkuk/thip/book/application/service/BookSavedService.java (1)

47-47: 메서드명 변경이 적절합니다.

findDetailBookByKeyword에서 findDetailBookByIsbn으로 변경된 것이 파라미터의 실제 의미(ISBN)를 더 명확하게 표현합니다.

src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java (1)

8-8: 인터페이스 메서드명 변경이 적절합니다.

메서드명이 실제 기능과 파라미터를 더 명확하게 표현하도록 개선되었습니다.

src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java (1)

22-22: 구현체 메서드명 변경이 인터페이스와 일관되게 적용되었습니다.

메서드명이 실제 파라미터와 기능을 더 명확하게 표현하도록 개선되었습니다.

src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java (1)

20-30: 구현이 깔끔합니다!

전날의 인기 검색 도서를 조회하는 로직이 명확하게 구현되었습니다. Redis에서 데이터가 없는 경우 빈 리스트가 반환되도록 처리되어 있어 적절합니다.

src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java (1)

9-13: CQRS 패턴에 맞는 적절한 인터페이스 설계입니다.

Redis 조회 작업을 위한 메서드들이 명확하게 정의되어 있습니다.

src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandRedisAdapter.java (1)

46-52: TTL 설정이 적절합니다.

순위 데이터에 7일 TTL을 설정하여 자동 만료되도록 한 것이 좋습니다. 데이터가 5개뿐이므로 파이프라이닝 없이도 성능상 문제없습니다.

src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryRedisAdapter.java (2)

66-81: JSON 역직렬화 오류 처리가 적절합니다

Redis에서 가져온 JSON 데이터의 null/blank 체크와 Jackson 역직렬화 실패 시 적절한 예외 처리가 구현되어 있습니다. ExternalApiException을 통한 오류 코드 전달도 일관성 있게 처리되었습니다.


56-62: Redis ZSet 연산과 null 처리가 올바르게 구현되었습니다

reverseRangeWithScores를 사용한 상위 N개 조회와 null 결과에 대한 방어적 처리가 잘 구현되어 있습니다. Stream API를 활용한 데이터 변환도 적절합니다.

Comment thread src/main/java/konkuk/thip/common/exception/code/ErrorCode.java Outdated
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2025

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java (5)

32-46: DateTimeFormatter를 static final로 변경하고 필드 순서를 개선하세요.

DateTimeFormatter는 인스턴스 상태를 사용하지 않으므로 static final로 선언하는 것이 더 효율적입니다. 또한 가독성을 위해 필드 순서를 개선하는 것을 권장합니다.

+    private static final DateTimeFormatter DAILY_KEY_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyyMMdd");
+
+    @Value("${app.redis.search-count-prefix}")
+    private String searchCountPrefix;
+
+    @Value("${app.redis.search-rank-prefix}")
+    private String searchRankPrefix;
+
+    @Value("${app.redis.search-rank-detail-prefix}")
+    private String searchRankDetailPrefix;
+
     private final RedisTemplate<String, String> redisTemplate;
-    private final DateTimeFormatter DAILY_KEY_FORMATTER =
-            DateTimeFormatter.ofPattern("yyyyMMdd");
-
-    @Value("${app.redis.search-count-prefix}")
-    private String searchCountPrefix;
-
-    @Value("${app.redis.search-rank-prefix}")
-    private String searchRankPrefix;
-
-    @Value("${app.redis.search-rank-detail-prefix}")
-    private String searchRankDetailPrefix;
-
     private final ObjectMapper objectMapper;

93-100: 더 간결한 방식으로 ZSet에 데이터를 추가하세요.

현재 구현도 동작하지만, IntStream을 사용하면 더 간결하고 읽기 쉬운 코드가 됩니다.

 @Override
 public void saveBookSearchRank(List<String> isbns, List<Double> scores, LocalDate date) {
     String redisKey = makeRedisKey(searchRankPrefix, date);
-    for (int i = 0; i < isbns.size(); i++) {
-        redisTemplate.opsForZSet().add(redisKey, isbns.get(i), scores.get(i));
-    }
+    IntStream.range(0, isbns.size())
+        .forEach(i -> redisTemplate.opsForZSet().add(redisKey, isbns.get(i), scores.get(i)));
     redisTemplate.expire(redisKey, Duration.ofDays(7));
 }

추가로 필요한 import:

import java.util.stream.IntStream;

120-120: 일관된 메서드 포맷팅을 유지하세요.

다른 메서드들과 일관성을 위해 별도 줄로 분리하세요.

-    public void deleteBookSearchCount(LocalDate date) { deleteZSetKey(searchCountPrefix, date); }
+    public void deleteBookSearchCount(LocalDate date) {
+        deleteZSetKey(searchCountPrefix, date);
+    }

125-128: 메서드명을 더 정확하게 변경하세요.

deleteZSetKey는 ZSet뿐만 아니라 String 타입 키도 삭제하므로 메서드명이 부정확합니다.

-private void deleteZSetKey(String prefix, LocalDate date) {
+private void deleteRedisKey(String prefix, LocalDate date) {
     String redisKey = makeRedisKey(prefix, date);
     redisTemplate.delete(redisKey);
 }

그리고 호출하는 곳들도 함께 수정:

-    deleteZSetKey(searchRankPrefix, date);
+    deleteRedisKey(searchRankPrefix, date);

70-85: 순위 데이터를 도메인 모델로 관리하는 것을 고려하세요.

PR 코멘트에서 논의된 것처럼, Redis에 7일간 저장되는 순위 데이터는 단순 DTO가 아닌 도메인 모델(예: RankingBook)로 관리하는 것이 좋습니다. 이는 비즈니스 로직과 데이터 일관성을 더 잘 표현할 수 있습니다.

도메인 모델 도입 시 고려사항:

  • Book 패키지 내에 RankingBook 엔티티 생성
  • ISBN뿐만 아니라 제목, 이미지 URL 등 추가 정보 포함
  • Redis 저장 시 도메인 모델을 사용하여 데이터 일관성 보장

Also applies to: 103-112

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between de5fd7d and 5e53557.

📒 Files selected for processing (2)
  • src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java (1 hunks)
  • src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java
🧰 Additional context used
🧠 Learnings (1)
src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java (1)
Learnt from: hd0rable
PR: THIP-TextHip/THIP-Server#36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.

Comment on lines +87 to +91
@Override
public void incrementBookSearchCount(String isbn, LocalDate date) {
String redisKey = makeRedisKey(searchCountPrefix, date);
redisTemplate.opsForZSet().incrementScore(redisKey, isbn, 1.0);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

검색 카운트 키에도 TTL을 설정하여 데이터 누적을 방지하세요.

스케줄러가 이전 날짜의 카운트를 삭제하지만, 스케줄러 실패 시 키가 무한정 누적될 수 있습니다. 안전성을 위해 TTL을 설정하는 것을 권장합니다.

 @Override
 public void incrementBookSearchCount(String isbn, LocalDate date) {
     String redisKey = makeRedisKey(searchCountPrefix, date);
     redisTemplate.opsForZSet().incrementScore(redisKey, isbn, 1.0);
+    // 스케줄러 실패에 대비한 안전장치로 8일 TTL 설정
+    redisTemplate.expire(redisKey, Duration.ofDays(8));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public void incrementBookSearchCount(String isbn, LocalDate date) {
String redisKey = makeRedisKey(searchCountPrefix, date);
redisTemplate.opsForZSet().incrementScore(redisKey, isbn, 1.0);
}
@Override
public void incrementBookSearchCount(String isbn, LocalDate date) {
String redisKey = makeRedisKey(searchCountPrefix, date);
redisTemplate.opsForZSet().incrementScore(redisKey, isbn, 1.0);
// 스케줄러 실패에 대비한 안전장치로 8일 TTL 설정
redisTemplate.expire(redisKey, Duration.ofDays(8));
}
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java
around lines 87 to 91, the incrementBookSearchCount method sets the search count
in Redis without a TTL, risking indefinite data accumulation if the scheduler
fails. Modify the method to set an appropriate TTL on the Redis key after
incrementing the score to ensure the key expires automatically and prevents
unbounded growth.

Comment on lines +102 to +112
@Override
public void saveBookSearchRankDetail(List<BookMostSearchResult.BookRankInfo> bookRankDetails, LocalDate date) {
String redisKey = makeRedisKey(searchRankDetailPrefix, date);
String detailJson;
try {
detailJson = objectMapper.writeValueAsString(bookRankDetails);
} catch (JsonProcessingException e) {
throw new ExternalApiException(JSON_PROCESSING_ERROR);
}
redisTemplate.opsForValue().set(redisKey, detailJson);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

상세 순위 데이터에도 TTL을 설정해야 합니다.

PR 목표에 따르면 순위 데이터는 7일 TTL을 가져야 하는데, 상세 데이터에는 TTL이 설정되지 않아 데이터가 영구적으로 남을 수 있습니다.

 @Override
 public void saveBookSearchRankDetail(List<BookMostSearchResult.BookRankInfo> bookRankDetails, LocalDate date) {
     String redisKey = makeRedisKey(searchRankDetailPrefix, date);
     String detailJson;
     try {
         detailJson = objectMapper.writeValueAsString(bookRankDetails);
     } catch (JsonProcessingException e) {
         throw new ExternalApiException(JSON_PROCESSING_ERROR);
     }
     redisTemplate.opsForValue().set(redisKey, detailJson);
+    redisTemplate.expire(redisKey, Duration.ofDays(7));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public void saveBookSearchRankDetail(List<BookMostSearchResult.BookRankInfo> bookRankDetails, LocalDate date) {
String redisKey = makeRedisKey(searchRankDetailPrefix, date);
String detailJson;
try {
detailJson = objectMapper.writeValueAsString(bookRankDetails);
} catch (JsonProcessingException e) {
throw new ExternalApiException(JSON_PROCESSING_ERROR);
}
redisTemplate.opsForValue().set(redisKey, detailJson);
}
@Override
public void saveBookSearchRankDetail(List<BookMostSearchResult.BookRankInfo> bookRankDetails, LocalDate date) {
String redisKey = makeRedisKey(searchRankDetailPrefix, date);
String detailJson;
try {
detailJson = objectMapper.writeValueAsString(bookRankDetails);
} catch (JsonProcessingException e) {
throw new ExternalApiException(JSON_PROCESSING_ERROR);
}
redisTemplate.opsForValue().set(redisKey, detailJson);
redisTemplate.expire(redisKey, Duration.ofDays(7));
}
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java
around lines 102 to 112, the saveBookSearchRankDetail method saves detailed rank
data to Redis without setting a TTL, causing the data to persist indefinitely.
Modify the redisTemplate.opsForValue().set call to include a TTL of 7 days so
that the detailed rank data expires appropriately.


@Test
@DisplayName("오늘 랭킹 Top 5를 정상적으로 조회한다")
@DisplayName("어제 랭킹 Top 5를 정상적으로 조회한다")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment on lines +92 to +93
// given: 어제 날짜의 랭킹 상세정보(JSON)를 Redis에 저장
yesterday = LocalDate.now().minusDays(1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment on lines +11 to +17
@Builder
public record BookRankInfo(
int rank,
String title,
String imageUrl,
String isbn
) {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇 전 Result, response 각각의 inner class 를 얘기한 거긴 한데, 완전히 중복되는 코드라 의미없다고 생각하신 거면 지금 상태도 괜찮습니다!!

조회로직시에 result, response 를 어디까지 분리할지를 한번 얘기나눠봐도 좋을 것 같습니다

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 넵!! 제가 착각햇나봐요 한번 얘기해보면 좋을것같습니다!

}
LocalDate yesterday = LocalDate.now().minusDays(1);

List<BookMostSearchResult.BookRankInfo> bookRankInfos = bookRedisQueryPort.getYesterdayBookRankInfos(yesterday);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redis에 책 상세정보까지 저장하도록 수정하니 service 조회로직이 훨씬 간결해져서 좋은 것 같습니다!

@hd0rable hd0rable merged commit 09e7221 into develop Jul 8, 2025
3 checks passed
@seongjunnoh seongjunnoh deleted the feat/#48-get-most-serched branch July 8, 2025 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[THIP2025-91] [feat] 가장많이 검색된 책 조회 api 개발

3 participants