Conversation
WalkthroughISBN 기반 특정 책 관련 피드 조회 기능을 추가했다. 컨트롤러 엔드포인트, 서비스 유스케이스, 정렬/커서 DTO, 리포지토리/쿼리 구현, 매퍼/DTO 투영, 책 존재 여부 확인 포트, 그리고 통합 테스트가 포함된다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Client
participant Controller as FeedQueryController
participant Service as FeedRelatedWithBookService
participant BookPort as BookQueryPort
participant FeedPort as FeedQueryPort
participant SavedPort as SavedFeedQueryPort
participant LikePort as PostLikeQueryPort
Client->>Controller: GET /feeds/related-books/{isbn}?sort&cursor
Controller->>Service: getFeedsByBook(query)
Service->>BookPort: existsBookByIsbn(isbn)
alt Book not exists
Service-->>Controller: empty response (isLast=true)
Controller-->>Client: 200 BaseResponse
else Book exists
alt sort = like
Service->>FeedPort: findFeedsByBookIsbnOrderByLike(isbn, userId, cursor)
else sort = latest
Service->>FeedPort: findFeedsByBookIsbnOrderByLatest(isbn, userId, cursor)
end
Service->>SavedPort: findSavedFeedIds(userId, feedIds)
Service->>LikePort: findLikedFeedIds(userId, feedIds)
Service-->>Controller: FeedRelatedWithBookResponse (feeds, nextCursor, isLast)
Controller-->>Client: 200 BaseResponse
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Assessment against linked issues
Possibly related issues
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
Test Results402 tests 402 ✅ 33s ⏱️ Results for commit aca5594. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (17)
src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookSortType.java (1)
17-25: from(String) 성능/내구성 개선: 사전(Map) 캐싱 + 대소문자/트림 허용 제안현재는 순회 비교(O(n))이며 입력의 대소문자나 공백에 민감합니다. 사전(Map) 캐시로 O(1) 조회 + normalize하면 견고해집니다. 또한, invalid 케이스/대소문자 케이스에 대한 단위 테스트도 함께 권장합니다.
적용 diff(메서드 교체):
- public static FeedRelatedWithBookSortType from(String type) { - for (FeedRelatedWithBookSortType sortType : values()) { - if (sortType.getType().equals(type)) { - return sortType; - } - } - throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, - new IllegalArgumentException("정렬 타입은 like 또는 latest 중 하나여야 합니다. 현재 타입: " + type)); - } + public static FeedRelatedWithBookSortType from(String type) { + String key = type == null ? null : type.trim().toLowerCase(); + FeedRelatedWithBookSortType value = BY_TYPE.get(key); + if (value != null) return value; + throw new InvalidStateException( + ErrorCode.API_INVALID_PARAM, + new IllegalArgumentException("정렬 타입은 like 또는 latest 중 하나여야 합니다. 현재 타입: " + type) + ); + }메서드 외 보강 코드(필드/임포트 추가):
// imports import java.util.Arrays; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; // enum 내부 필드 아래에 정적 맵 추가 private static final Map<String, FeedRelatedWithBookSortType> BY_TYPE = Arrays.stream(values()) .collect(Collectors.toUnmodifiableMap( v -> v.getType().toLowerCase(), Function.identity() ));src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedRelatedWithBookResponse.java (1)
13-31: 불리언 필드 접두사(is-)의 직렬화/계약 확인 권장record 컴포넌트명이 JSON 프로퍼티로 그대로 노출됩니다. FE 계약이 isWriter/isSaved/isLiked 형태로 확정된 것이 맞는지 한 번만 확인 부탁드립니다. 필요 시 @JsonProperty로 명시 가능합니다.
src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java (2)
151-153: group_concat 기반 contentUrls 분할 사용 시 구분자/길이 제약 검토 필요현재 dto.contentUrls는 MySQL GROUP_CONCAT 결과를 split한 것으로 보입니다. 콤마(,) 구분은 URL 값에 콤마가 포함될 경우 파손될 수 있고, group_concat_max_len(기본 1024) 초과 시 잘릴 수 있습니다. 안전성을 위해:
- SEPARATOR로 희귀 구분자(예: ASCII Unit Separator, '\u001F')를 사용하고 동일 구분자로 split, 또는
- JSON_ARRAYAGG로 JSON 배열로 묶고 파싱
중 하나를 고려해 주세요.
170-173: Stream 수집 간단화 및 null-안전성 제안Java 16+ 사용 가능하다면 .toList() 사용으로 간결하게 표현할 수 있습니다. 또한 dtos가 null일 가능성이 있다면 빈 리스트 반환 가드가 있으면 안전합니다.
적용 diff:
- return dtos.stream() - .map(dto -> toFeedRelatedWithBookDto(dto, savedFeedIds.contains(dto.feedId()), likedFeedIds.contains(dto.feedId()), userId)) - .collect(Collectors.toList()); + return dtos.stream() + .map(dto -> toFeedRelatedWithBookDto( + dto, + savedFeedIds.contains(dto.feedId()), + likedFeedIds.contains(dto.feedId()), + userId)) + .toList();메서드 초반 null 가드(필요 시):
if (dtos == null || dtos.isEmpty()) return List.of();src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java (1)
33-36: ISBN 입력값 정규화 및 null 처리 고려
- 하이픈 포함/미포함 등 ISBN 표기 차이가 존재할 수 있습니다. 저장 시/조회 시 일관된 canonical form(예: 하이픈 제거)으로 정규화하지 않으면 false-negative가 날 수 있습니다.
- null 인자가 전달될 가능성이 있다면, 명시적으로 방어(예: requireNonNull, @nonnull)하는 편이 디버깅에 유리합니다.
- 빈번히 호출되는 경로라면 ISBN 컬럼 인덱스 유무도 확인 부탁드립니다.
원하시면 정규화 유틸 도입과 어댑터 입력 단의 방어 코드를 제안드릴게요.
src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookQuery.java (2)
5-12: 입력 DTO에 기본 검증 어노테이션 추가 권장유효성 제약을 명시하면 서비스에서의 방어 로직을 줄일 수 있습니다. 예:
예시 diff:
package konkuk.thip.feed.application.port.in.dto; import lombok.Builder; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; @Builder public record FeedRelatedWithBookQuery( - String isbn, - FeedRelatedWithBookSortType sortType, + @NotBlank String isbn, + @NotNull FeedRelatedWithBookSortType sortType, String cursor, Long userId ) { }
5-12: Cursor 타입의 일관성 재검토(문자열 vs Typed Cursor)현재 cursor가 String입니다. 서비스 내부에서
Cursor유틸로 파싱한다면, 포트 입력부터Cursor타입으로 가져가면 계층 간 계약이 명확해지고 파싱 오류를 줄일 수 있습니다. 반대로 컨트롤러 입력 전용 DTO에서는 String 유지가 유리할 수 있습니다. 팀 컨벤션에 맞춰 결정 부탁드립니다.src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java (1)
23-25: 메서드 네이밍 가독성(Naming consistency) 개선 제안
findFeedsByBookIsbnOrderByLikeCount는 실제 정렬이 likeCount + createdAt 복합키라면 메서드명이 부분만 드러냅니다....OrderByLikeCountAndCreatedAt처럼 의도를 드러내면 유지보수에 유리합니다. 선택사항입니다.src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java (1)
45-50: Javadoc로 커서 스키마 명시 권장두 포트의 Cursor 파라미터가 기대하는 키를 명확히 문서화하면 호출측/테스트 작성이 쉬워집니다.
- OrderByLike: 필요 키 = lastLikeCount, lastCreatedAt
- OrderByLatest: 필요 키 = lastCreatedAt
반환 리스트는 size+1 전략으로 nextCursor 생성 여부를 판단한다는 점도 함께 명시하면 좋습니다.원하시면 포트/어댑터/리포지토리 전반의 커서 스키마 주석과 테스트 템플릿을 일괄 추가하는 PR 초안을 드릴 수 있어요.
src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java (2)
165-167: likeCount.toString()에서 NPE 가능성 — null-safe 변환 권장현재 FeedQueryDto.likeCount()는 Integer 타입입니다. 현 쿼리 경로에서는 QueryProjection에서 0으로 정규화하지만, 다른 투영/경로가 추가될 경우 NPE 여지가 생길 수 있습니다. 안전하게 String.valueOf로 변환하는 편이 견고합니다.
- Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString(), - feedQueryDto.likeCount().toString())); + Cursor nextCursor = new Cursor(List.of( + feedQueryDto.createdAt().toString(), + String.valueOf(feedQueryDto.likeCount()) + ));
158-160: 복합 커서 순서(생성일, 좋아요수)가 기존 패턴과 상이 — 문서화 또는 순서 통일 제안팔로잉 우선순위 커서에서는 1차키가 인덱스 0(우선순위), 2차키가 인덱스 1(createdAt)로 배치됩니다. 본 메서드는 createdAt을 0, likeCount를 1에 배치하고 있어 일관성이 떨어집니다. 기능상 문제는 없지만 혼동을 줄이기 위해
- 현 순서를 주석/문서로 명확히 남기거나,
- 아래처럼 likeCount를 0, createdAt을 1로 재배치하여 일관성을 맞추는 방안을 권장합니다(선택).
- LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); - Integer lastLikeCount = cursor.isFirstRequest() ? null : cursor.getInteger(1); + Integer lastLikeCount = cursor.isFirstRequest() ? null : cursor.getInteger(0); + LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(1); @@ - Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString(), - feedQueryDto.likeCount().toString())); + Cursor nextCursor = new Cursor(List.of( + String.valueOf(feedQueryDto.likeCount()), + feedQueryDto.createdAt().toString() + ));Also applies to: 165-166
src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java (1)
64-70: GROUP_CONCAT 구분자 충돌 가능성 메모 및 향후 확장 포인트URL에 콤마가 포함될 가능성은 낮지만 0이 아닙니다. MySQL GROUP_CONCAT 구분자를 비사용 문자(예: '|' 또는 ASCII Unit Separator)로 설정하거나, splitToArray의 구분자를 상수화해 양쪽(쿼리/파싱)에서 동일 상수를 참조하도록 정리하면 안전성이 올라갑니다. 현 단계에서는 주석 보강 정도만으로도 충분합니다.
src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java (1)
115-136: 경로변수/쿼리파라미터 Bean Validation 활성화를 위한 @validated 필요@PathVariable/@RequestParam에 부여한 @pattern이 동작하려면 컨트롤러에 @validated가 필요합니다(전역 설정이 없다면). 또한 sort 파라미터도 허용값으로 제한하면 방어적입니다.
- 제안 1: 컨트롤러 클래스에 @validated 추가
+import org.springframework.validation.annotation.Validated; @@ -@RequiredArgsConstructor +@RequiredArgsConstructor +@Validated public class FeedQueryController {
- 제안 2: sort 파라미터 허용값 제한
- @RequestParam(required = false, defaultValue = "like") final String sort, + @RequestParam(required = false, defaultValue = "like") + @Pattern(regexp = "(?i)like|latest", message = "sort는 like 또는 latest만 허용됩니다.") + final String sort,
- Swagger 문서도 허용값 힌트를 주면 UX가 좋아집니다(예: @parameter의 schema에 allowableValues 지정).
src/main/java/konkuk/thip/feed/application/service/FeedRelatedWithBookService.java (2)
51-76: 불필요한 중복 스트림 및 빈 페이지 최적화feedId 집합을 두 번 생성하고 있습니다. 한 번만 생성해 재사용하고, 비어있을 경우 추가 I/O 없이 조기 반환하면 불필요한 DB 호출을 줄일 수 있습니다.
- // 사용자가 저장한 피드 ID를 조회 - Set<Long> savedFeedIds = feedQueryPort.findSavedFeedIdsByUserIdAndFeedIds( - feedQueryDtoCursorBasedList.contents().stream() - .map(FeedQueryDto::feedId) - .collect(Collectors.toSet()), - query.userId() - ); - - // 사용자가 좋아요한 피드 ID를 조회 - Set<Long> likedFeedIds = postLikeQueryPort.findPostIdsLikedByUser( - feedQueryDtoCursorBasedList.contents().stream() - .map(FeedQueryDto::feedId) - .collect(Collectors.toSet()), - query.userId() - ); + // 조회된 피드 ID 집합을 한 번만 생성 + Set<Long> feedIds = feedQueryDtoCursorBasedList.contents().stream() + .map(FeedQueryDto::feedId) + .collect(Collectors.toSet()); + + // 빈 페이지면 추가 I/O 없이 바로 응답 + if (feedIds.isEmpty()) { + return FeedRelatedWithBookResponse.of( + List.of(), + feedQueryDtoCursorBasedList.nextCursor(), + feedQueryDtoCursorBasedList.isLast() + ); + } + + // 사용자가 저장/좋아요한 피드 ID를 배치로 조회 + Set<Long> savedFeedIds = feedQueryPort.findSavedFeedIdsByUserIdAndFeedIds(feedIds, query.userId()); + Set<Long> likedFeedIds = postLikeQueryPort.findPostIdsLikedByUser(feedIds, query.userId());
30-30: 페이지 크기 상수의 구성화 고려DEFAULT_PAGE_SIZE를 설정 파일(ex. application.yml) 또는 상수 관리 클래스로 외부화하면 운영 중 정책 변경 시 재배포 없이 조정할 수 있습니다.
src/test/java/konkuk/thip/feed/adapter/in/web/FeedRelatedWithBookApiTest.java (2)
257-258: nextCursor null 검증을 JsonNode isNull()로 명확화문자열 "null" 비교 대신 JsonNode.isNull()을 사용하면 직렬화 방식 변경에 덜 민감하고 의도가 더 분명해집니다.
- String thirdCursor = thirdRoot.path("data").path("nextCursor").asText(); - assertThat(thirdCursor).isEqualTo("null"); // 다음 커서가 없어야 함 + assertThat(thirdRoot.path("data").path("nextCursor").isNull()).isTrue(); // 다음 커서가 없어야 함
115-136: 유효성 실패 시나리오 테스트 보강 제안컨트롤러에 @pattern을 적용했으므로(적용 예정) 다음 케이스 추가가 유의미합니다.
- 잘못된 ISBN(13자 미만/이상, 숫자 외 포함) → 400
- 잘못된 sort 값 → 400
필요하시면 테스트 케이스 초안까지 바로 드리겠습니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (16)
src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java(1 hunks)src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java(1 hunks)src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java(1 hunks)src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java(3 hunks)src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedRelatedWithBookResponse.java(1 hunks)src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java(1 hunks)src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java(1 hunks)src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java(2 hunks)src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java(5 hunks)src/main/java/konkuk/thip/feed/application/port/in/FeedRelatedWithBookUseCase.java(1 hunks)src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookQuery.java(1 hunks)src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookSortType.java(1 hunks)src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java(1 hunks)src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java(2 hunks)src/main/java/konkuk/thip/feed/application/service/FeedRelatedWithBookService.java(1 hunks)src/test/java/konkuk/thip/feed/adapter/in/web/FeedRelatedWithBookApiTest.java(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-07-03T03:05:05.031Z
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로부터 조회하는 메서드를 추가함.
Applied to files:
src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java
🧬 Code Graph Analysis (1)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedRelatedWithBookApiTest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
TestEntityFactory(33-391)
⏰ 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 (17)
src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java (1)
11-12: existsBookByIsbn 포트 구현 및 사용처 연결 확인 완료
- BookQueryPersistenceAdapter에서
existsBookByIsbn구현체 존재- FeedRelatedWithBookService에서 포트 호출 사용처 존재
변경사항 승인합니다.
src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookSortType.java (1)
10-26: 열거형 기반의 안전한 정렬 타입 정의 및 검증 로직 적절허용 값 제한과 예외 메시지(한글)도 명확합니다. 컨트롤러에서 파라미터를 이 enum으로 변환하는 흐름도 일관됩니다.
src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedRelatedWithBookResponse.java (1)
7-12: Record + of() 팩토리 메서드 사용 적절응답 래퍼(페이징 커서, isLast) 구조가 명확하고, 빌더 지원으로 가독성 좋습니다.
src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java (1)
140-157: 특정 책 관련 피드 DTO 매핑 전반 LGTM
- Alias 파생값(value/color), 작성자 여부, 상대시간 포맷 등 도메인 포맷팅이 적절히 반영되었습니다.
- isSaved/isLiked를 사전 조회결과로 주입하는 방식도 효율적입니다.
src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java (1)
28-29: BookJpaEntity의 ISBN에 이미 unique 제약이 선언되어 있습니다
BookJpaEntity 클래스의 isbn 필드에@Column(unique = true)어노테이션이 있어 데이터베이스에 고유 인덱스가 자동으로 생성됩니다.
– src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java (라인 22–23)따라서
existsByIsbn메서드는 인덱스를 활용하여 빠르게 조회되며, 추가 조치가 필요 없습니다.src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java (1)
33-36: existsBookByIsbn 위임 구현은 적절합니다단순 위임으로 깔끔합니다. 테스트만 보강되면 충분해 보입니다.
src/main/java/konkuk/thip/feed/application/port/in/FeedRelatedWithBookUseCase.java (1)
3-9: 계층 간 의존성 역전(아키텍처 위반): port.in이 web adapter DTO에 의존application port(in)가
adapter.in.web.response.FeedRelatedWithBookResponse를 반환하는 것은 계층 규칙 위반입니다. 포트는 어댑터에 의존하면 안 되고, 공용 애플리케이션 계층 DTO를 반환해야 합니다.아래와 같이 포트 전용 DTO로 교체하는 것을 권장합니다.
적용 diff(현재 파일 수정):
-import konkuk.thip.feed.adapter.in.web.response.FeedRelatedWithBookResponse; +import konkuk.thip.feed.application.port.in.dto.FeedRelatedWithBookResult; public interface FeedRelatedWithBookUseCase { - FeedRelatedWithBookResponse getFeedsByBook(FeedRelatedWithBookQuery query); + FeedRelatedWithBookResult getFeedsByBook(FeedRelatedWithBookQuery query); }추가 파일 예시(새 DTO, application 계층에 위치):
package konkuk.thip.feed.application.port.in.dto; import java.util.List; public record FeedRelatedWithBookResult( List<FeedItem> items, String nextCursor ) { public record FeedItem( Long feedId, Long authorId, String authorNickname, String contentText, List<String> contentUrls, int likeCount, boolean likedByMe, boolean savedByMe, String createdAt // or Instant/LocalDateTime ) {} }컨트롤러에서는 위 Result를 web response로 매핑하도록 변경하면 계층 규칙이 회복됩니다. 필요시 연쇄 수정 PR 제안 가능.
⛔ Skipped due to learnings
Learnt from: buzz0331 PR: THIP-TextHip/THIP-Server#78 File: src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java:3-3 Timestamp: 2025-07-14T18:22:56.538Z Learning: THIP 프로젝트에서는 Query API(조회 API)에 한해서는 application 계층에서 adapter.in.web.response 패키지의 response DTO를 직접 참조하는 것을 허용함. 이는 CQRS 아키텍처에서 읽기 전용 작업의 효율성을 위한 팀 컨벤션임.src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java (1)
23-25: 복합 커서 정렬의 타이브레이커/경계 조건 재확인 요청인기순의 경우 likeCount DESC, createdAt DESC(그리고 최종적으로 id DESC까지)로 완전한 순서를 정의해야 페이지 간 중복/누락이 없습니다. 커서 조건도
- likeCount < lastLikeCount OR (likeCount = lastLikeCount AND createdAt < lastCreatedAt) [DESC 기준]
처럼 구성되어야 합니다. 최신순 역시 createdAt DESC 이후 id DESC 타이브레이커를 권장합니다. 구현부에서 해당 조건, 정렬, limit(size+1) 처리(hasNext 판별)를 사용했는지 확인 부탁드립니다.src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java (1)
171-182: LGTM — 최신순 커서 처리 및 nextCursor 생성 로직 적절createdAt 단일 커서로의 직렬화/역직렬화가 다른 메서드들과 일관되며, CursorBasedList 사용도 정상입니다.
src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java (1)
22-23: LGTM — 카운트 필드 Integer 전환 적절집계/조인 결과가 null일 수 있는 케이스를 고려한 Integer 전환과, 생성자에서의 0 정규화 처리가 합리적입니다.
src/main/java/konkuk/thip/feed/application/service/FeedRelatedWithBookService.java (1)
35-49: LGTM — 존재여부 확인 후 빈 결과 즉시 반환, 정렬 스위치 처리 명확책 존재 체크 후 조기 반환, Cursor 생성 및 정렬 분기 처리 흐름이 간결하고 명확합니다.
src/test/java/konkuk/thip/feed/adapter/in/web/FeedRelatedWithBookApiTest.java (1)
192-263: 페이징/정렬/가시성 시나리오 커버리지 우수
- like 기본 정렬의 내림차순 검증
- 최신 정렬의 결과 유효성 검증
- 커서 기반 3페이지 네비게이션
- 비공개 및 자기 글 제외
통합 테스트 품질이 높습니다.
src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java (5)
290-316: 복합 커서 페이징 구현이 적절합니다.좋아요 수와 생성일시를 활용한 복합 커서 기반 페이징 구현이 올바르게 되어 있습니다. 정렬 조건과 커서 조건이 일치하며, 동일한 좋아요 수를 가진 피드들에 대해 생성일시로 추가 정렬하는 로직이 잘 구현되어 있습니다.
322-345: 단일 커서 페이징 구현이 적절합니다.생성일시만을 사용한 단순 커서 기반 페이징이 올바르게 구현되어 있습니다.
347-369: GROUP_CONCAT을 활용한 N+1 문제 해결 방법이 우수합니다.서브쿼리와 GROUP_CONCAT을 활용하여 content URL들을 효율적으로 조회하는 방식이 잘 구현되어 있습니다. 이는 N+1 쿼리 문제를 방지하는 좋은 접근입니다.
380-385: 필터 조건이 명확하고 적절합니다.활성 상태의 피드 중 특정 ISBN에 해당하고, 요청 사용자가 작성하지 않은 공개 피드만 조회하는 필터링 로직이 잘 구현되어 있습니다.
367-367: isPriorityFeed 필드 null 처리 의도 확인
toQueryDto() 메서드에서 Expressions.nullExpression()을 사용해 isPriorityFeed를 null로 설정한 것은
책별 피드 조회에는 우선순위 개념이 없기 때문이며, 의도된 동작이 맞습니다.– 다만 클라이언트(혹은 상위 로직)에서 null 체크가 필요한지
– 또는 기본값을 false로 설정하기 위해 Expressions.constant(false) 사용을 고려할지검토 부탁드립니다.
| private StringExpression contentUrlAggExpr() { | ||
| return Expressions.stringTemplate( | ||
| "group_concat({0})", | ||
| content.contentUrl | ||
| ); | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
GROUP_CONCAT 사용 시 주의사항을 고려하세요.
GROUP_CONCAT은 MySQL의 group_concat_max_len 설정값(기본 1024바이트)을 초과하면 결과가 잘릴 수 있습니다. 피드당 콘텐츠 URL이 많을 경우 이 제한에 걸릴 수 있습니다.
다음 스크립트로 현재 MySQL 설정값과 실제 데이터를 확인해보세요:
필요시 다음과 같은 방법으로 개선할 수 있습니다:
- 애플리케이션 시작 시
group_concat_max_len값 증가 설정 - 또는 콘텐츠가 많은 피드의 경우 별도 쿼리로 분리 처리
🏁 Script executed:
#!/bin/bash
# MySQL GROUP_CONCAT 설정 확인 및 콘텐츠 URL 길이 검증
# application.yml 또는 properties 파일에서 DB 설정 정보 확인
echo "=== 데이터베이스 설정 파일 확인 ==="
fd -e yml -e yaml -e properties | xargs rg -n "group_concat|GROUP_CONCAT" || echo "GROUP_CONCAT 관련 설정 없음"
# 테스트 코드에서 많은 콘텐츠를 가진 피드 테스트 케이스 확인
echo -e "\n=== 콘텐츠 URL 관련 테스트 확인 ==="
fd -e java -g "*Test*.java" | xargs rg -n "contentUrl|content.*add|addContent" -A 3 -B 3Length of output: 52422
group_concat_max_len 기본값 설정 누락 확인됨
application.yml/properties에서 group_concat_max_len 설정이 없으며, MySQL 기본값(1024바이트) 초과 시 group_concat(content_url) 결과가 잘릴 수 있습니다. 테스트에서는 최대 2개의 URL만 검증했으나, 실제 피드당 URL 수가 많아지면 문제 발생 가능성이 있습니다.
• 대상 위치
- src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java: contentUrlAggExpr (Lines 372–377)
• 해결 방안 제안
- JDBC URL에
sessionVariables=group_concat_max_len=4096(또는 적절한 값) 추가 - 애플리케이션 시작 시
SET SESSION group_concat_max_len=…쿼리 실행 - MySQL 5.7+ 환경이라면
JSON_ARRAYAGG(content_url)사용으로 대체 - 콘텐츠 URL이 많은 피드는 별도 서브쿼리로 분리하여 애플리케이션에서 조합
운영 DB 및 테스트 환경에서 최대 길이를 확인 후 적절히 설정을 적용해주세요.
| splitToArray(concatenatedContentUrls), | ||
| likeCount == null ? 0 : likeCount, | ||
| commentCount == null ? 0 : commentCount, | ||
| isPublic, |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Boolean → boolean 오토언박싱 NPE 위험 — 안전한 변환으로 교체 권장
isPublic이 DB/투영 상 null일 경우 오토언박싱에서 NPE가 발생할 수 있습니다. 다른 필드(likeCount/commentCount)와 동일하게 안전한 변환으로 통일하는 편이 좋습니다.
- isPublic,
+ Boolean.TRUE.equals(isPublic),📝 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.
| isPublic, | |
| Boolean.TRUE.equals(isPublic), |
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java
around line 59, the field isPublic is a Boolean and risks NPE via auto-unboxing;
replace the raw Boolean usage with a null-safe boolean conversion (for example,
use Boolean.TRUE.equals(isPublic) or
Optional.ofNullable(isPublic).orElse(false)) so the DTO always supplies a
primitive boolean without throwing an NPE.
hd0rable
left a comment
There was a problem hiding this comment.
수고하셨습니다!! 코드 정말 깔끔하고 좋네여 🥇🥇🥇
|
|
||
| // 책이 존재하지 않는 경우 빈 리스트로 응답 | ||
| if(!isBookExist) { | ||
| return FeedRelatedWithBookResponse.of(List.of(), null, true); |
| ); | ||
| } | ||
|
|
||
| // contentUrl을 GROUP_CONCAT으로 묶어서 반환하는 표현식 |
| bookAuthor, | ||
| contentBody, | ||
| splitToArray(concatenatedContentUrls), | ||
| likeCount == null ? 0 : likeCount, |
| JsonNode root = objectMapper.readTree(json); | ||
| JsonNode feeds = root.path("data").path("feeds"); | ||
|
|
||
| // 자기 자신 글 제외 비공개 제외로 인해 only othersPublic 만 남아야 함 |
seongjunnoh
left a comment
There was a problem hiding this comment.
고생하셨습니다! 사소한 궁금증 하나 적어봤습니다!
| // likeCount DESC → createdAt DESC | ||
| where = where.and( | ||
| feed.likeCount.lt(lastLikeCount) | ||
| .or(feed.likeCount.eq(lastLikeCount) | ||
| .and(feed.createdAt.lt(lastCreatedAt))) | ||
| ); |
There was a problem hiding this comment.
p3 : 좋아요 수가 동일할 경우, feedId가 아니라 createdAt 을 두번째 커서 기준으로 잡으신 이유가 있을까요?? 단순 궁금
There was a problem hiding this comment.
두번째 정렬 기준과 너무 다른 순서를 보여줄 필요가 없지 않나 라는 생각에 그냥 createdAt을 두번째 커서로 잡았던 것 같아요!
| // 서브쿼리로 N:1 방지 | ||
| JPAExpressions | ||
| .select(contentUrlAggExpr()) | ||
| .from(content) | ||
| .where(content.postJpaEntity.postId.eq(feed.postId)), |
There was a problem hiding this comment.
오호 이렇게 n+1 문제를 해결하면서 QueryProjection을 사용할 수 있군요!! 좋습니다
| // Attribute convertor로 바꾸기 전에 임시 메서드 | ||
| private static String[] splitToArray(String concatenated) { | ||
| if (concatenated == null || concatenated.isEmpty()) return new String[0]; | ||
| String[] parts = concatenated.split(","); | ||
| for (int i = 0; i < parts.length; i++) parts[i] = parts[i].trim(); | ||
| return parts; | ||
| } |
| // contentUrl을 GROUP_CONCAT으로 묶어서 반환하는 표현식 | ||
| private StringExpression contentUrlAggExpr() { | ||
| return Expressions.stringTemplate( | ||
| "group_concat({0})", |
There was a problem hiding this comment.
반환할 url 순서를 보장할 수도 있다고 하는데, 이건 저희가 곧 contents 테이블을 제거할거니 고려하지 않아도 될 것 같네요!!
| // 사용자가 저장한 피드 ID를 조회 | ||
| Set<Long> savedFeedIds = feedQueryPort.findSavedFeedIdsByUserIdAndFeedIds( | ||
| feedQueryDtoCursorBasedList.contents().stream() | ||
| .map(FeedQueryDto::feedId) | ||
| .collect(Collectors.toSet()), | ||
| query.userId() | ||
| ); | ||
|
|
||
| // 사용자가 좋아요한 피드 ID를 조회 | ||
| Set<Long> likedFeedIds = postLikeQueryPort.findPostIdsLikedByUser( | ||
| feedQueryDtoCursorBasedList.contents().stream() | ||
| .map(FeedQueryDto::feedId) | ||
| .collect(Collectors.toSet()), | ||
| query.userId() | ||
| ); |
| // 좋아요 내림차순 검증 | ||
| int prevLike = Integer.MAX_VALUE; | ||
| for (JsonNode f : feeds) { | ||
| int like = f.path("likeCount").asInt(); | ||
| assertThat(like).isLessThanOrEqualTo(prevLike); | ||
| prevLike = like; | ||
| } | ||
|
|
||
| // isWriter false 검증 requester가 아닌 다른 사용자의 피드만 조회 | ||
| for (JsonNode f : feeds) { | ||
| assertThat(f.path("isWriter").asBoolean()).isFalse(); | ||
| } |
| @SpringBootTest | ||
| @ActiveProfiles("test") | ||
| @DisplayName("[통합] 특정 책으로 작성된 피드 조회 api 통합 테스트") | ||
| @AutoConfigureMockMvc(addFilters = false) | ||
| @Transactional | ||
| class FeedRelatedWithBookApiTest { |
There was a problem hiding this comment.
오호 많은 테스트 케이스를 고려해주셨군요 좋습니다
#️⃣ 연관된 이슈
📝 작업 내용
api 흐름은 다음과 같습니다.
📸 스크린샷
💬 리뷰 요구사항
기존에 FeedQueryDto에서 QueryProjection을 사용하지 못한 이유가 Content와의 1 : N 관계 때문이였는데 이를 group_concat이라는 MySQL 내부 함수를 사용하여 풀어냈습니다! (함수 내부적으로
,를 통해 그룹을 만들어주기 때문에 FeedQueryDto 내부적으로,를 파싱해서 배열로 변환하도록 하였습니다.)페이징 처리 + 정렬 + Content 그룹핑
Hibernate: select fje1_0.post_id, uje1_0.user_id, uje1_0.nickname, afuje1_0.image_url, afuje1_0.alias_value, fje1_1.created_at, bje1_0.isbn, bje1_0.title, bje1_0.author_name, fje1_1.content, (select group_concat(cje1_0.content_url) from contents cje1_0 where cje1_0.post_id=fje1_0.post_id), fje1_1.like_count, fje1_1.comment_count, fje1_0.is_public, null from feeds fje1_0 join posts fje1_1 on fje1_0.post_id=fje1_1.post_id join users uje1_0 on uje1_0.user_id=fje1_1.user_id join aliases afuje1_0 on afuje1_0.alias_id=uje1_0.user_alias_id join books bje1_0 on bje1_0.book_id=fje1_0.book_id where fje1_1.status=? and bje1_0.isbn=? and uje1_0.user_id<>? and fje1_0.is_public=? and ( fje1_1.like_count<? or fje1_1.like_count=? and fje1_1.created_at<? ) order by fje1_1.like_count desc, fje1_1.created_at desc fetch first ? rows onlyFeedQueryDto 내부 Content 파싱 메서드
추가적으로 통합 테스트 코드에서 커서가 제대로 동작하는지 확인하기 위해 25개의 피드를 만들어서 총 3번의 쿼리를 날리면서 전체 피드를 조회해보니까 처음에 27개가 나와서 잘못된 필터링을 디버깅하였습니다 😅 다들 구현하신 커서 기반 조회 api의 경우 한번씩 중복된 요소가 반환되지 않는지 테스트해보면 좋을 것 같아요!
📌 PR 진행 시 이러한 점들을 참고해 주세요
Summary by CodeRabbit
신기능
테스트