Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5910157
[feat] 저장한 책 또는 참여 중 모임의 책 조회 api 틀 완성 (#156)
buzz0331 Aug 7, 2025
7945fe0
[refactor] SavedBooks 일급 컬렉션 제거 -> 책 저장 상태 변경 서비스 로직 수정 (#156)
buzz0331 Aug 7, 2025
cda7aab
[refactor] 안쓰는 클래스 제거 (#156)
buzz0331 Aug 7, 2025
a534943
[refactor] SavedBook을 Book 패키지로 이동 -> 저장 관련 로직 포함 (#156)
buzz0331 Aug 7, 2025
1384b40
[refactor] 안쓰는 클래스 제거 (#156)
buzz0331 Aug 7, 2025
5a2bfb7
[refactor] JPQL 쿼리로 수정 (#156)
buzz0331 Aug 7, 2025
c2de19a
[feat] 저장한 책 또는 참여 중 모임의 책 조회 api 구현 (#156)
buzz0331 Aug 7, 2025
44216df
[test] 저장한 책 또는 참여 중 모임의 책 조회 api 통합 테스트 (#156)
buzz0331 Aug 7, 2025
d100c52
[refactor] 피드 저장 관련 로직 피드 패키지로 이동 (#156)
buzz0331 Aug 7, 2025
c043a82
[test] 테스트클래스 네이밍 수정 (#156)
buzz0331 Aug 7, 2025
d111626
[test] 테스트클래스 네이밍 수정 (#156)
buzz0331 Aug 7, 2025
280b589
[refactor] Optional 반환 메서드를 사용해서 try catch 제거 (#156)
buzz0331 Aug 7, 2025
89dd638
[refactor] 예외 클래스 수정 (#156)
buzz0331 Aug 7, 2025
cc24720
[docs] 스웨거 명세 (#156)
buzz0331 Aug 7, 2025
f5ad9a2
[refactor] 모임 방 책을 방 진행도 높은 순 정렬 (#156)
buzz0331 Aug 7, 2025
1f54669
[conflict] merge (#156)
buzz0331 Aug 7, 2025
eb5f197
[chore] ci-workflow 수정
buzz0331 Aug 7, 2025
10b2123
[chore] ci-workflow 수정
buzz0331 Aug 7, 2025
bb52613
[chore] ci 실패 확인
buzz0331 Aug 7, 2025
4e6b00f
[chore] ci 실패 복구
buzz0331 Aug 7, 2025
3587660
[chore] 빌드 시간 단축
buzz0331 Aug 7, 2025
5b48353
[refactor] Set 자료구조 대신 DISTINCT 키워드를 통해 중복 제거 (#156)
buzz0331 Aug 8, 2025
2d330a3
[refactor] Set 자료구조 대신 DISTINCT 키워드를 통해 중복 제거 (#156)
buzz0331 Aug 8, 2025
11055e0
[refactor] BookInfo -> BookSelectableListResult (#156)
buzz0331 Aug 8, 2025
9531892
[refactor] BookInfo -> BookSelectableResult (#156)
buzz0331 Aug 8, 2025
cccc547
[refactor] Transactional 어노테이션 추가 (#156)
buzz0331 Aug 8, 2025
1593313
[refactor] mapper 메서드 네이밍 수정 (#156)
buzz0331 Aug 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions .github/workflows/ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ on:

permissions:
contents: read
checks: write
pull-requests: write

env:
RESOURCE_PATH: src/main/resources
Expand All @@ -19,13 +21,6 @@ jobs:
steps:
- uses: actions/checkout@v4

# - name: Cache Gradle packages
# uses: actions/cache@v3
# with:
# path: ~/.gradle/caches
# key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
# restore-keys: gradle-${{ runner.os }}-

- name: Set up JDK 17
uses: actions/setup-java@v3
with:
Expand All @@ -50,4 +45,17 @@ jobs:
redis-version: 7

- name: 🐘 build with Gradle
run: ./gradlew build
run: ./gradlew build --parallel --stacktrace
Copy link
Collaborator

Choose a reason for hiding this comment

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

오 이러면 병렬적으로 빌드가 되나요?? 궁금

Copy link
Contributor Author

Choose a reason for hiding this comment

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

저희 테스트 코드가 서로 의존성이 없어서 가능한 것 같네요..! 로컬에서 통과되어서 추가해봤습니다!


- name: ✉️ Post test results as a comment on the PR
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: '**/build/test-results/test/TEST-*.xml'

- name: 🙁 Annotate failed test cases on changed lines
uses: mikepenz/action-junit-report@v3
if: always()
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
Comment on lines +50 to +60
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

액션 버전을 커밋 SHA 로 고정하여 서플라이 체인 리스크를 줄이세요
서드파티 액션을 @v2 / @v3 처럼 태그로 지정하면, 태그 이동 시 예상치 못한 코드가 실행될 수 있습니다. GitHub 권고사항에 따라 커밋 SHA 로 고정하거나 actions/dependency-review-action 등으로 모니터링을 추가해 주세요.

예시 diff:

-        uses: EnricoMi/publish-unit-test-result-action@v2
+        uses: EnricoMi/publish-unit-test-result-action@v2.15.0 # 0e9f0b1
...
-        uses: mikepenz/action-junit-report@v3
+        uses: mikepenz/action-junit-report@v3.8.0 # a1b2c3d
📝 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
- name: ✉️ Post test results as a comment on the PR
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: '**/build/test-results/test/TEST-*.xml'
- name: 🙁 Annotate failed test cases on changed lines
uses: mikepenz/action-junit-report@v3
if: always()
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: ✉️ Post test results as a comment on the PR
uses: EnricoMi/publish-unit-test-result-action@v2.15.0 # 0e9f0b1
if: always()
with:
files: '**/build/test-results/test/TEST-*.xml'
- name: 🙁 Annotate failed test cases on changed lines
uses: mikepenz/action-junit-report@v3.8.0 # a1b2c3d
if: always()
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
🤖 Prompt for AI Agents
In .github/workflows/ci-workflow.yml around lines 50 to 60, the third-party
GitHub Actions are referenced using version tags (@v2, @v3), which can lead to
supply chain risks if those tags move unexpectedly. To fix this, replace the
version tags with specific commit SHAs for each action to pin the exact code
version used. Obtain the commit SHA from the action's repository and update the
uses field accordingly to ensure consistent and secure execution.

token: ${{ github.token }}
Comment on lines +50 to +61
Copy link
Collaborator

Choose a reason for hiding this comment

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

오호

Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Pattern;
import konkuk.thip.book.adapter.in.web.response.BookDetailSearchResponse;
import konkuk.thip.book.adapter.in.web.response.BookMostSearchResponse;
import konkuk.thip.book.adapter.in.web.response.BookRecruitingRoomsResponse;
import konkuk.thip.book.adapter.in.web.response.BookSearchListResponse;
import konkuk.thip.book.adapter.in.web.response.*;
import konkuk.thip.book.application.port.in.BookMostSearchUseCase;
import konkuk.thip.book.application.port.in.BookRecruitingRoomsUseCase;
import konkuk.thip.book.application.port.in.BookSearchUseCase;
import konkuk.thip.book.application.port.in.BookSelectableListUseCase;
import konkuk.thip.book.application.port.in.dto.BookSelectableType;
import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.common.security.annotation.UserId;
import konkuk.thip.common.swagger.annotation.ExceptionDescription;
Expand All @@ -32,6 +31,7 @@ public class BookQueryController {
private final BookSearchUseCase bookSearchUseCase;
private final BookMostSearchUseCase bookMostSearchUseCase;
private final BookRecruitingRoomsUseCase bookRecruitingRoomsUseCase;
private final BookSelectableListUseCase bookSelectableListUseCase;

@Operation(
summary = "책 검색결과 조회",
Expand Down Expand Up @@ -87,4 +87,19 @@ public BaseResponse<BookRecruitingRoomsResponse> showRecruitingRoomsWithBook(
return BaseResponse.ok(bookRecruitingRoomsUseCase.getRecruitingRoomsWithBook(isbn, cursor));
}

@Operation(
summary = "저장한 책 또는 참여 중 모임의 책 조회",
description = "저장한 책 또는 참여 중인 모임의 책을 조회합니다. (방 생성시 책 선택 화면 페이지)"
)
@ExceptionDescription(BOOK_SELECTABLE_LIST)
@GetMapping("/books/selectable-list")
public BaseResponse<BookSelectableListResponse> showSelectableBookList(
@Parameter(description = "저장한 책 또는 참여 중인 모임의 책을 구분하는 필드 (SAVED : 저장한 책 / JOINING : 모임 방의 책)", example = "SAVED") @RequestParam final String type,
@Parameter(hidden = true) @UserId final Long userId
) {
return BaseResponse.ok(
BookSelectableListResponse.of(bookSelectableListUseCase.getSelectableBookList(BookSelectableType.from(type), userId))
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package konkuk.thip.book.adapter.in.web.response;

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

import java.util.List;

public record BookSelectableListResponse(
List<BookSelectableResult> bookList
) {
public static BookSelectableListResponse of(List<BookSelectableResult> bookSelectableResults) {
return new BookSelectableListResponse(bookSelectableResults);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package konkuk.thip.saved.adapter.out.jpa;
package konkuk.thip.book.adapter.out.jpa;

import jakarta.persistence.*;
import konkuk.thip.book.adapter.out.jpa.BookJpaEntity;
import konkuk.thip.common.entity.BaseJpaEntity;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import lombok.*;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@
import konkuk.thip.book.domain.Book;
import konkuk.thip.common.exception.EntityNotFoundException;
import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository;
import konkuk.thip.book.adapter.out.jpa.SavedBookJpaEntity;
import konkuk.thip.book.adapter.out.persistence.repository.SavedBookJpaRepository;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.Optional;

import static konkuk.thip.common.exception.code.ErrorCode.BOOK_NOT_FOUND;
import static konkuk.thip.common.exception.code.ErrorCode.ROOM_NOT_FOUND;
import static konkuk.thip.common.exception.code.ErrorCode.*;

@Repository
@RequiredArgsConstructor
public class BookCommandPersistenceAdapter implements BookCommandPort {

private final UserJpaRepository userJpaRepository;
private final RoomJpaRepository roomJpaRepository;
private final BookJpaRepository bookJpaRepository;
private final SavedBookJpaRepository savedBookJpaRepository;
private final BookMapper bookMapper;

@Override
Expand Down Expand Up @@ -61,4 +66,24 @@ public Book findBookByRoomId(Long roomId) {
).getBookJpaEntity();
return bookMapper.toDomainEntity(bookJpaEntity);
}

// 사용자가 책을 저장
@Override
public void saveSavedBook(Long userId, Long bookId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
BookJpaEntity book = bookJpaRepository.findById(bookId)
.orElseThrow(() -> new EntityNotFoundException(BOOK_NOT_FOUND));
SavedBookJpaEntity entity = SavedBookJpaEntity.builder()
.userJpaEntity(user)
.bookJpaEntity(book)
.build();
savedBookJpaRepository.save(entity);
}
Comment on lines +70 to +82
Copy link

@coderabbitai coderabbitai bot Aug 7, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

중복 저장 처리 로직 추가를 고려해보세요.

현재 구현에서는 이미 저장된 책을 다시 저장할 때의 중복 처리가 없습니다. 동일한 사용자-책 조합이 중복으로 저장될 가능성이 있습니다.

 @Override
 public void saveSavedBook(Long userId, Long bookId) {
+    // 이미 저장된 책인지 확인
+    if (savedBookJpaRepository.existsByUserIdAndBookId(userId, bookId)) {
+        return; // 이미 저장되어 있으면 무시하거나 예외 처리
+    }
     UserJpaEntity user = userJpaRepository.findById(userId)
             .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
     BookJpaEntity book = bookJpaRepository.findById(bookId)
             .orElseThrow(() -> new EntityNotFoundException(BOOK_NOT_FOUND));
📝 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 saveSavedBook(Long userId, Long bookId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
BookJpaEntity book = bookJpaRepository.findById(bookId)
.orElseThrow(() -> new EntityNotFoundException(BOOK_NOT_FOUND));
SavedBookJpaEntity entity = SavedBookJpaEntity.builder()
.userJpaEntity(user)
.bookJpaEntity(book)
.build();
savedBookJpaRepository.save(entity);
}
// 사용자가 책을 저장
@Override
public void saveSavedBook(Long userId, Long bookId) {
// 이미 저장된 책인지 확인
if (savedBookJpaRepository.existsByUserIdAndBookId(userId, bookId)) {
return; // 이미 저장되어 있으면 무시하거나 예외 처리
}
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
BookJpaEntity book = bookJpaRepository.findById(bookId)
.orElseThrow(() -> new EntityNotFoundException(BOOK_NOT_FOUND));
SavedBookJpaEntity entity = SavedBookJpaEntity.builder()
.userJpaEntity(user)
.bookJpaEntity(book)
.build();
savedBookJpaRepository.save(entity);
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java
around lines 70 to 82, the saveSavedBook method lacks handling for duplicate
user-book entries, which can cause redundant saves. To fix this, before creating
and saving a new SavedBookJpaEntity, check if an entry with the same userId and
bookId already exists in savedBookJpaRepository. If it exists, skip saving or
handle accordingly to prevent duplicates.

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

@seongjunnoh 맞습니다! Clean Architecture 관점에서 볼 때 어댑터는 순수하게 저장/조회 기능만 담당해야 하고, 비즈니스 로직(중복 검사 포함)은 서비스 레이어에서 처리하는 것이 올바른 접근입니다.

실제로 BookSavedService를 확인해보니 validateSaveBookAction 메서드에서 이미 중복 검사를 수행하고 있네요:

private void validateSaveBookAction(boolean isSaveRequest, boolean isAlreadySaved) {
    if (isSaveRequest && isAlreadySaved) {
        throw new BusinessException(BOOK_ALREADY_SAVED);
    }
    // ...
}

어댑터는 단순히 데이터 저장 책임만 가져야 한다는 의견에 동의합니다. 제안을 철회하겠습니다.


🏁 Script executed:

#!/bin/bash
# BookSavedService에서 중복 체크 로직이 있는지 확인
cat src/main/java/konkuk/thip/book/application/service/BookSavedService.java

Length of output: 3010


@seongjunnoh 완전히 맞습니다! 실제 BookSavedService 코드를 확인해보니 서비스 레이어에서 이미 완벽하게 중복 처리를 하고 있네요:

boolean alreadySaved = bookQueryPort.existsSavedBookByUserIdAndBookId(userId, book.getId());
validateSaveBookAction(isSaveRequest, alreadySaved);

private void validateSaveBookAction(boolean isSaveRequest, boolean alreadySaved) {
    if (isSaveRequest && alreadySaved) {
        throw new BusinessException(BOOK_ALREADY_SAVED);
    }
    // ...
}

서비스 레이어에서 비즈니스 로직(중복 검사, 유효성 검증)을 담당하고, 어댑터는 순수하게 저장/삭제 동작만 수행하는 Clean Architecture 구조가 잘 구현되어 있습니다.

어댑터에 비즈니스 로직을 추가하자는 제안은 부적절했습니다. 현재 구조가 올바른 레이어 분리를 보여주고 있네요.


✏️ Learnings added
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#166
File: src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java:70-82
Timestamp: 2025-08-07T18:19:55.856Z
Learning: seongjunnoh는 Clean Architecture 원칙을 중시하며, 어댑터는 순수하게 저장/조회 기능만 담당하고 비즈니스 로직은 서비스 레이어에서 처리하는 것을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#166
File: src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java:70-82
Timestamp: 2025-08-07T18:19:55.856Z
Learning: seongjunnoh는 Clean Architecture 원칙을 중시하며, 어댑터는 순수하게 저장/조회 기능만 담당하고 비즈니스 로직은 서비스 레이어에서 처리하는 것을 선호한다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#113
File: src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java:38-44
Timestamp: 2025-07-30T14:05:04.945Z
Learning: seongjunnoh는 코드 최적화 제안에 대해 구체적인 기술적 근거와 효율성 차이를 이해하고 싶어하며, 성능 개선 방식에 대한 상세한 설명을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#93
File: src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java:49-114
Timestamp: 2025-07-28T16:44:31.224Z
Learning: seongjunnoh는 코드 중복 문제에 대한 리팩토링 제안을 적극적으로 수용하고 함수형 인터페이스를 활용한 해결책을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#112
File: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java:272-272
Timestamp: 2025-07-30T10:44:34.115Z
Learning: seongjunnoh는 피드 커서 페이지네이션에서 LocalDateTime 단일 커서 방식을 선호하며, 복합 키 기반 커서보다 구현 단순성과 성능을 우선시한다.


// 사용자가 저장한 책을 삭제
@Override
public void deleteSavedBook(Long userId, Long bookId) {
savedBookJpaRepository.deleteByUserIdAndBookId(userId, bookId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,52 @@

import konkuk.thip.book.adapter.out.mapper.BookMapper;
import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository;
import konkuk.thip.book.adapter.out.persistence.repository.SavedBookJpaRepository;
import konkuk.thip.book.application.port.out.BookQueryPort;
import konkuk.thip.book.domain.Book;
import konkuk.thip.common.exception.EntityNotFoundException;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.stream.Collectors;

import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND;

@Repository
@RequiredArgsConstructor
public class BookQueryPersistenceAdapter implements BookQueryPort {

private final UserJpaRepository userJpaRepository;
private final BookJpaRepository bookJpaRepository;
private final SavedBookJpaRepository savedBookJpaRepository;
private final BookMapper bookMapper;

@Override
public boolean existsSavedBookByUserIdAndBookId(Long userId, Long bookId) {
Copy link
Member

Choose a reason for hiding this comment

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

옿 감사합니다

return savedBookJpaRepository.existsByUserIdAndBookId(userId, bookId);
}

@Override
public List<Book> findSavedBooksByUserId(Long userId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));

return bookJpaRepository.findSavedBooksByUserId(user.getUserId()).stream()
.map(bookMapper::toDomainEntity)
.collect(Collectors.toList());
}
Comment on lines +34 to +41
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

User 존재 검증 최적화 및 불필요한 엔티티 로딩 제거 제안

현재 findById로 User 엔티티를 로딩한 뒤 user.getUserId()만 사용하고 있습니다. 존재성만 필요하므로 existsById로 대체하여 쿼리/매핑 비용을 줄이세요.

적용 예시(diff):

-        UserJpaEntity user = userJpaRepository.findById(userId)
-                .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
-
-        return bookJpaRepository.findSavedBooksByUserId(user.getUserId()).stream()
-                .map(bookMapper::toDomainEntity)
-                .collect(Collectors.toList());
+        if (!userJpaRepository.existsById(userId)) {
+            throw new EntityNotFoundException(USER_NOT_FOUND);
+        }
+        return bookJpaRepository.findSavedBooksByUserId(userId).stream()
+                .map(bookMapper::toDomainEntity)
+                .collect(Collectors.toList());

추가로, 도메인 매핑 시 연관 로딩으로 인한 N+1 위험이 있습니다. JPA 쿼리에서 필요한 연관만 fetch join하거나 Projection을 사용해 주세요(아래 검증 스크립트 참고).

📝 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
public List<Book> findSavedBooksByUserId(Long userId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
return bookJpaRepository.findSavedBooksByUserId(user.getUserId()).stream()
.map(bookMapper::toDomainEntity)
.collect(Collectors.toList());
}
public List<Book> findSavedBooksByUserId(Long userId) {
if (!userJpaRepository.existsById(userId)) {
throw new EntityNotFoundException(USER_NOT_FOUND);
}
return bookJpaRepository.findSavedBooksByUserId(userId).stream()
.map(bookMapper::toDomainEntity)
.collect(Collectors.toList());
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java
around lines 34 to 41, replace the User entity loading via findById with
existsById to check user existence without loading the full entity, reducing
query and mapping overhead. Then, modify the JPA query in bookJpaRepository to
use fetch join or Projection to load only necessary associations and avoid N+1
query issues during domain mapping.


@Override
public List<Book> findJoiningRoomsBooksByUserId(Long userId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));

return bookJpaRepository.findJoiningRoomsBooksByUserId(user.getUserId())
.stream()
.map(bookMapper::toDomainEntity)
.collect(Collectors.toList());
}
Comment on lines +44 to +52
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Join 방 책 조회도 동일 최적화 및 쿼리 조건 보장 검증 필요

  • User 검증은 existsById로 최적화 가능(위와 동일).
  • 비즈니스 요건(모집중/만료 제외, ISBN 기준 중복 제거)이 repository 쿼리에서 보장되는지 확인이 필요합니다.

다음 스크립트로 해당 메서드 쿼리 정의를 찾아 상태 필터와 DISTINCT(ISBN 기준) 적용 여부를 확인해 주세요:


🏁 Script executed:

#!/bin/bash
# repository 메서드 정의 탐색
rg -n -A5 -B3 $'findSavedBooksByUserId\\(|findJoiningRoomsBooksByUserId\\('

# JPQL/Query 어노테이션 유무와 where 조건, distinct/Group by(ISBN) 사용 확인
# Projection 또는 fetch join 여부 확인

Length of output: 7053


Join 방 책 조회 – User 검증 최적화 제안 및 쿼리 검증 완료

BookQueryPersistenceAdapter.java (44–52):

  • User 존재 여부 확인은 실제 엔티티 조회가 아닌 existsById를 사용해 최적화할 수 있습니다.
  • BookJpaRepository.findJoiningRoomsBooksByUserId 쿼리에는 이미
    DISTINCT b
    r.status = 'ACTIVE'
    r.startDate <= CURRENT_TIMESTAMP
    조건이 적용돼 있어 중복 제거 및 모집중/만료 방 제외 요건을 충족합니다.

제안되는 코드 수정 예시:

 public List<Book> findJoiningRoomsBooksByUserId(Long userId) {
-    UserJpaEntity user = userJpaRepository.findById(userId)
-            .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
-
-    return bookJpaRepository.findJoiningRoomsBooksByUserId(user.getUserId())
+    if (!userJpaRepository.existsById(userId)) {
+        throw new EntityNotFoundException(USER_NOT_FOUND);
+    }
+    return bookJpaRepository.findJoiningRoomsBooksByUserId(userId)
             .stream()
             .map(bookMapper::toDomainEntity)
             .collect(Collectors.toList());
 }
📝 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
public List<Book> findJoiningRoomsBooksByUserId(Long userId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
return bookJpaRepository.findJoiningRoomsBooksByUserId(user.getUserId())
.stream()
.map(bookMapper::toDomainEntity)
.collect(Collectors.toList());
}
public List<Book> findJoiningRoomsBooksByUserId(Long userId) {
if (!userJpaRepository.existsById(userId)) {
throw new EntityNotFoundException(USER_NOT_FOUND);
}
return bookJpaRepository.findJoiningRoomsBooksByUserId(userId)
.stream()
.map(bookMapper::toDomainEntity)
.collect(Collectors.toList());
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java
lines 44 to 52, optimize user existence validation by replacing the full entity
fetch with a call to userJpaRepository.existsById(userId) to check if the user
exists. If the user does not exist, throw EntityNotFoundException as before.
This avoids unnecessary entity loading while maintaining the same validation
logic. The rest of the method can remain unchanged since the query already
handles distinct books and active room conditions.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,26 @@

import konkuk.thip.book.adapter.out.jpa.BookJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

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

@Query("SELECT DISTINCT b FROM BookJpaEntity b " +
"JOIN SavedBookJpaEntity s ON s.bookJpaEntity.bookId = b.bookId " +
"WHERE s.userJpaEntity.userId = :userId " +
"ORDER BY s.createdAt DESC")
Copy link
Collaborator

Choose a reason for hiding this comment

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

p3 : ordering 결과를 사용하지 않는 것 같은데, 최신순으로 정렬한 이유가 있으실까요?

Copy link
Collaborator

Choose a reason for hiding this comment

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

p2 : 추가로 유저가 저장한 책을 조회하는 메서드를 SavedBookJpaRepository 가 아니라 BookJpaRepository 에서 구현하신 이유가 있을까요??
Saved라는 도메인은 없는게 맞지만, 유저-책 의 관계테이블(= 유저가 저장한 책 테이블) 은 여전히 존재하고 SavedBookJpaEntity 또한 존재하니 굳이 BookJpaRepository 에서 join을 걸어서 조회하는 것 보다는 SavedBookJpaRepository 에서 바로 유저가 저장한 책을 조회하는게 어떨까 싶습니다!

Copy link
Contributor Author

@buzz0331 buzz0331 Aug 7, 2025

Choose a reason for hiding this comment

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

p2 : 추가로 유저가 저장한 책을 조회하는 메서드를 SavedBookJpaRepository 가 아니라 BookJpaRepository 에서 구현하신 이유가 있을까요?? Saved라는 도메인은 없는게 맞지만, 유저-책 의 관계테이블(= 유저가 저장한 책 테이블) 은 여전히 존재하고 SavedBookJpaEntity 또한 존재하니 굳이 BookJpaRepository 에서 join을 걸어서 조회하는 것 보다는 SavedBookJpaRepository 에서 바로 유저가 저장한 책을 조회하는게 어떨까 싶습니다!

우선 이 부분은 처음에 저도 SavedBookJpaRepository에서 조회하여 List를 반환받은 후 savedBook.getBookJpaEntity()를 호출하여 Book이라는 Domain으로 변환하시는 방식으로 구현했습니다. 물론 이렇게 하면 N+1 문제가 발생하니 SavedBookJpaRepository에서 SavedBookJpaEntity를 조회할때 EntityGraph를 통해 bookJpaEntity를 fetch join 해서 사용했습니다. 이렇게 구현하고 보니 굳이 fetch join을 통해 BookJpaEntity와 SavedBookJpaEntity를 모두 메모리에 올려두는 것이 맞을까? 라는 의문이 들었습니다. 따라서, 조회 로직을 bookJpaRepository로 옮기고 BookJpaEntity를 바로 반환받는 것으로 수정하였습니다!

Copy link
Contributor Author

@buzz0331 buzz0331 Aug 7, 2025

Choose a reason for hiding this comment

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

p3 : ordering 결과를 사용하지 않는 것 같은데, 최신순으로 정렬한 이유가 있으실까요?

엇 정렬 조건이 사용자가 책을 저장한 최신순이라고 생각해서 구현했는데 어떤 부분때문에 그러시죠?

Copy link
Collaborator

Choose a reason for hiding this comment

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

p2 : 추가로 유저가 저장한 책을 조회하는 메서드를 SavedBookJpaRepository 가 아니라 BookJpaRepository 에서 구현하신 이유가 있을까요?? Saved라는 도메인은 없는게 맞지만, 유저-책 의 관계테이블(= 유저가 저장한 책 테이블) 은 여전히 존재하고 SavedBookJpaEntity 또한 존재하니 굳이 BookJpaRepository 에서 join을 걸어서 조회하는 것 보다는 SavedBookJpaRepository 에서 바로 유저가 저장한 책을 조회하는게 어떨까 싶습니다!

우선 이 부분은 처음에 저도 SavedBookJpaRepository에서 조회하여 List를 반환받은 후 savedBook.getBookJpaEntity()를 호출하여 Book이라는 Domain으로 변환하시는 방식으로 구현했습니다. 물론 이렇게 하면 N+1 문제가 발생하니 SavedBookJpaRepository에서 SavedBookJpaEntity를 조회할때 EntityGraph를 통해 bookJpaEntity를 fetch join 해서 사용했습니다. 이렇게 구현하고 보니 굳이 fetch join을 통해 BookJpaEntity와 SavedBookJpaEntity를 모두 메모리에 올려두는 것이 맞을까? 라는 의문이 들었습니다. 따라서, 조회 로직을 bookJpaRepository로 옮기고 BookJpaEntity를 바로 반환받는 것으로 수정하였습니다!

아아 조회하는 대상이 SavedBook이 아니라 Book 이었군요! 제가 착각했네요 하하
이런 이유라면 현재처럼 BookJpaRepository 에 위치하는것도 좋아보입니다!

Copy link
Collaborator

Choose a reason for hiding this comment

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

p3 : ordering 결과를 사용하지 않는 것 같은데, 최신순으로 정렬한 이유가 있으실까요?

엇 정렬 조건이 사용자가 책을 저장한 최신순이라고 생각해서 구현했는데 어떤 부분때문에 그러시죠?

이 메서드를 반환받는 어댑터에서 set 으로 만들어서 서비스에 던지길래 이러면 정렬한 이유가 없어지지않나? 라는 생각에 리뷰 남겨보았습니다
정렬하는게 요구사항이라면 어댑터 코드를 수정해야하지 않을까요??

List<BookJpaEntity> findSavedBooksByUserId(Long userId);

@Query("SELECT DISTINCT b FROM BookJpaEntity b " +
"JOIN RoomJpaEntity r ON r.bookJpaEntity.bookId = b.bookId " +
"JOIN RoomParticipantJpaEntity rp ON rp.roomJpaEntity.roomId = r.roomId " +
"WHERE rp.userJpaEntity.userId = :userId " +
"AND r.status = 'ACTIVE' " +
"AND r.startDate <= CURRENT_TIMESTAMP " + // 진행 중인 방만 조회 (모집 중 / 만료된 방 x)
"ORDER BY r.roomPercentage DESC") // 방의 진행률이 높은 순서로 정렬
Copy link
Collaborator

Choose a reason for hiding this comment

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

p3 : 여기도 마찬가지로 ordering 결과를 사용하지 않는 것 같은데, 최신순으로 정렬한 이유가 있으실까요? 영속성 adapter 에서 이 메서드 호출하고 set으로 만드는 거 같아서 여쭤봅니다

Copy link
Collaborator

Choose a reason for hiding this comment

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

이 메서드는 "유저가 참가 중인 방 중, 진행중인 방에서의 책들을 조회" 하는 메서드인 것 같으니, BookJpaRepository 에 위치하는게 맞는 것 같습니다! 굳굳

Copy link
Contributor Author

Choose a reason for hiding this comment

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

p3 : 여기도 마찬가지로 ordering 결과를 사용하지 않는 것 같은데, 최신순으로 정렬한 이유가 있으실까요? 영속성 adapter 에서 이 메서드 호출하고 set으로 만드는 거 같아서 여쭤봅니다

앗 생각해보니 Set으로 변환하는 것을 깜빡했네요! DISTINCT 키워드를 추가하고 List로 변환해야 될 것 같네요,,

List<BookJpaEntity> findJoiningRoomsBooksByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package konkuk.thip.book.adapter.out.persistence.repository;

import konkuk.thip.book.adapter.out.jpa.SavedBookJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

public interface SavedBookJpaRepository extends JpaRepository<SavedBookJpaEntity, Long> {
@Query("SELECT CASE WHEN COUNT(s) > 0 THEN true ELSE false END FROM SavedBookJpaEntity s " +
"WHERE s.userJpaEntity.userId = :userId AND s.bookJpaEntity.bookId = :bookId")
boolean existsByUserIdAndBookId(Long userId, Long bookId);

@Modifying
@Query("DELETE FROM SavedBookJpaEntity s WHERE s.userJpaEntity.userId = :userId AND s.bookJpaEntity.bookId = :bookId")
void deleteByUserIdAndBookId(Long userId, Long bookId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package konkuk.thip.book.application.mapper;

import konkuk.thip.book.adapter.in.web.response.BookRecruitingRoomsResponse;
import konkuk.thip.book.application.port.in.dto.BookSelectableResult;
import konkuk.thip.book.domain.Book;
import konkuk.thip.common.util.DateUtil;
import konkuk.thip.room.application.port.out.dto.RoomQueryDto;
import org.mapstruct.Mapper;
Expand All @@ -23,4 +25,14 @@ public interface BookQueryMapper {
BookRecruitingRoomsResponse.RecruitingRoomDto toRecruitingRoomDto(RoomQueryDto dto);

List<BookRecruitingRoomsResponse.RecruitingRoomDto> toRecruitingRoomDtoList(List<RoomQueryDto> roomDtos);

@Mapping(target = "bookId", source = "book.id")
@Mapping(target = "bookTitle", source = "book.title")
@Mapping(target = "authorName", source = "book.authorName")
@Mapping(target = "publisher", source = "book.publisher")
@Mapping(target = "bookImageUrl", source = "book.imageUrl")
@Mapping(target = "isbn", source = "book.isbn")
BookSelectableResult toBookSelectableResult(Book book);

List<BookSelectableResult> toBookSelectableResultList(List<Book> books);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package konkuk.thip.book.application.port.in;

import konkuk.thip.book.application.port.in.dto.BookSelectableResult;
import konkuk.thip.book.application.port.in.dto.BookSelectableType;

import java.util.List;

public interface BookSelectableListUseCase {
List<BookSelectableResult> getSelectableBookList(BookSelectableType bookSelectableType, Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package konkuk.thip.book.application.port.in.dto;

public record BookSelectableResult(
Long bookId,
String bookTitle,
String authorName,
String publisher,
String bookImageUrl,
String isbn
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package konkuk.thip.book.application.port.in.dto;

import konkuk.thip.common.exception.InvalidStateException;
import konkuk.thip.common.exception.code.ErrorCode;
import lombok.Getter;

@Getter
public enum BookSelectableType {
SAVED("SAVED"), // 저장된 책
JOINING("JOINING") // 참여 중인 모임 방의 책
;
Comment on lines +7 to +11
Copy link
Collaborator

Choose a reason for hiding this comment

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

api 의 request param을 검증하기 위한 enum 좋습니다 패키지 위치도 좋네요


private final String type;

BookSelectableType(String type) {
this.type = type;
}

public static BookSelectableType from(String type) {
for (BookSelectableType bookSelectableType : BookSelectableType.values()) {
if (bookSelectableType.type.equals(type)) {
return bookSelectableType;
}
}
throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("타입은 SAVED 또는 JOINING이어야 합니다. 현재 타입: " + type));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ default Book getByIsbnOrThrow(String isbn){
void updateForPageCount(Book book);

Book findBookByRoomId(Long roomId);

void saveSavedBook(Long userId, Long bookId);
void deleteSavedBook(Long userId, Long bookId);

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
package konkuk.thip.book.application.port.out;

import konkuk.thip.book.domain.Book;

import java.util.List;

public interface BookQueryPort {

boolean existsSavedBookByUserIdAndBookId(Long userId, Long bookId);

List<Book> findSavedBooksByUserId(Long userId);

List<Book> findJoiningRoomsBooksByUserId(Long userId);
}
Loading