From 70852d0a551a290d32c86fdc52b726b02ad1fff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:13:37 +0900 Subject: [PATCH 01/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/adapter/in/web/BookQueryController.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java index 1d872ed94..4baac7825 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java @@ -5,10 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Pattern; 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.*; import konkuk.thip.book.application.port.in.dto.BookSelectableType; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; @@ -32,6 +29,7 @@ public class BookQueryController { private final BookMostSearchUseCase bookMostSearchUseCase; private final BookRecruitingRoomsUseCase bookRecruitingRoomsUseCase; private final BookSelectableListUseCase bookSelectableListUseCase; + private final BookShowSavedListUseCase bookShowSavedListUseCase; @Operation( summary = "책 검색결과 조회", @@ -103,4 +101,13 @@ public BaseResponse showSelectableBookList( ); } + @Operation( + summary = "저장한 책 조회", + description = "사용자가 저장한 책을 조회 합니다." + ) + @GetMapping("/books/saved") + public BaseResponse showSavedBookList(@Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(BookShowSavedListResponse.of(bookShowSavedListUseCase.getSavedBookList(userId))); + } + } From 23b2a53c50ea931ce657708043a38cc094589112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:13:55 +0900 Subject: [PATCH 02/27] =?UTF-8?q?[feat]=20BookShowSavedInfoResult.toBookSh?= =?UTF-8?q?owSavedInfoResult=20=EB=A7=A4=ED=8D=BC=EC=9E=91=EC=84=B1=20=20(?= =?UTF-8?q?#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/application/mapper/BookQueryMapper.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java b/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java index 2b789b5b4..ceb66faee 100644 --- a/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java +++ b/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java @@ -2,6 +2,7 @@ import konkuk.thip.book.adapter.in.web.response.BookRecruitingRoomsResponse; import konkuk.thip.book.application.port.in.dto.BookSelectableResult; +import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; import konkuk.thip.book.domain.Book; import konkuk.thip.common.util.DateUtil; import konkuk.thip.room.application.port.out.dto.RoomQueryDto; @@ -35,4 +36,16 @@ public interface BookQueryMapper { BookSelectableResult toBookSelectableResult(Book book); List toBookSelectableResultList(List books); + + + @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") + @Mapping(target = "isSaved", constant = "true") + BookShowSavedInfoResult toBookShowSavedInfoResult(Book book); + + List toBookShowSavedInfoResultList(List savedBookList); } From dbbfe21421ec86bf493180928d808f900c4d93d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:15:01 +0900 Subject: [PATCH 03/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20=EC=9C=A0=EC=A6=88=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/in/BookShowSavedListUseCase.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java diff --git a/src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java b/src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java new file mode 100644 index 000000000..36a9d1ab6 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.book.application.port.in; + +import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; + +import java.util.List; + +public interface BookShowSavedListUseCase { + List getSavedBookList(Long userId); +} From 09129249422cd14470ee63435bde1be4073928e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:15:09 +0900 Subject: [PATCH 04/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20=EC=9C=A0=EC=A6=88=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BookSavedListService.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/konkuk/thip/book/application/service/BookSavedListService.java diff --git a/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java b/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java new file mode 100644 index 000000000..9621abbdd --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java @@ -0,0 +1,27 @@ +package konkuk.thip.book.application.service; + +import konkuk.thip.book.application.mapper.BookQueryMapper; +import konkuk.thip.book.application.port.in.BookShowSavedListUseCase; +import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; +import konkuk.thip.book.application.port.out.BookQueryPort; +import konkuk.thip.book.domain.Book; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BookSavedListService implements BookShowSavedListUseCase { + + private final BookQueryPort bookQueryPort; + private final BookQueryMapper bookQueryMapper; + + @Override + @Transactional(readOnly = true) + public List getSavedBookList(Long userId) { + List savedBookList = bookQueryPort.findSavedBooksByUserId(userId); + return bookQueryMapper.toBookShowSavedInfoResultList(savedBookList); + } +} From 2d491fafc49f16d26a90d160645f9e3c6f417934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:15:39 +0900 Subject: [PATCH 05/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20result=20dto=20BookShowSavedIn?= =?UTF-8?q?foResult=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/repository/BookJpaRepository.java | 2 +- .../port/in/dto/BookShowSavedInfoResult.java | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 src/main/java/konkuk/thip/book/application/port/in/dto/BookShowSavedInfoResult.java diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java index b6cbaf433..e2f7aa3a7 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java @@ -10,7 +10,7 @@ public interface BookJpaRepository extends JpaRepository { Optional findByIsbn(String isbn); - @Query("SELECT DISTINCT b FROM BookJpaEntity b " + + @Query("SELECT DISTINCT b, s.createdAt FROM BookJpaEntity b " + "JOIN SavedBookJpaEntity s ON s.bookJpaEntity.bookId = b.bookId " + "WHERE s.userJpaEntity.userId = :userId " + "ORDER BY s.createdAt DESC") diff --git a/src/main/java/konkuk/thip/book/application/port/in/dto/BookShowSavedInfoResult.java b/src/main/java/konkuk/thip/book/application/port/in/dto/BookShowSavedInfoResult.java new file mode 100644 index 000000000..7a52300aa --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/dto/BookShowSavedInfoResult.java @@ -0,0 +1,12 @@ +package konkuk.thip.book.application.port.in.dto; + +public record BookShowSavedInfoResult( + Long bookId, + String bookTitle, + String authorName, + String publisher, + String bookImageUrl, + String isbn, + boolean isSaved +) { +} From 4ea7399ed80b8b6ee4d01d3f42e1c7f2c79c15fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:15:48 +0900 Subject: [PATCH 06/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20response=20dto=20BookShowSaved?= =?UTF-8?q?ListResponse=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/response/BookShowSavedListResponse.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/konkuk/thip/book/adapter/in/web/response/BookShowSavedListResponse.java diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/response/BookShowSavedListResponse.java b/src/main/java/konkuk/thip/book/adapter/in/web/response/BookShowSavedListResponse.java new file mode 100644 index 000000000..59b5b9bc5 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/in/web/response/BookShowSavedListResponse.java @@ -0,0 +1,13 @@ +package konkuk.thip.book.adapter.in.web.response; + +import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; + +import java.util.List; + +public record BookShowSavedListResponse( + List bookList +) { + public static BookShowSavedListResponse of(List bookSavedInfoResultList) { + return new BookShowSavedListResponse(bookSavedInfoResultList); + } +} From 489cca865f10c15e1fdafc51b5854ff9b430f5f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:16:11 +0900 Subject: [PATCH 07/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/adapter/in/web/FeedQueryController.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java index 9fb7dd946..1ebdfa4e3 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java @@ -31,6 +31,7 @@ public class FeedQueryController { private final FeedShowSingleUseCase feedShowSingleUseCase; private final FeedShowWriteInfoUseCase feedShowWriteInfoUseCase; private final FeedRelatedWithBookUseCase feedRelatedWithBookUseCase; + private final FeedSavedListUseCase feedSavedListUseCase; @Operation( summary = "피드 전체 조회", @@ -134,4 +135,18 @@ public BaseResponse showFeedsByBook( .build()) ); } + + @Operation( + summary = "저장한 피드 조회", + description = "사용자가 저장한 피드를 조회 합니다." + ) + @GetMapping("/feeds/saved") + public BaseResponse showSavedFeedList( + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(required = false) final String cursor, + @Parameter(hidden = true) @UserId final Long userId + ) { + return BaseResponse.ok(feedSavedListUseCase.getSavedFeedList(userId,cursor)); + } + } From 8487674a59c915771c2fa5f2b23dee0012026f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:16:29 +0900 Subject: [PATCH 08/27] =?UTF-8?q?[feat]=20FeedQueryDto=EC=97=90=20@Nullabl?= =?UTF-8?q?e=20savedCreatedAt=20=EC=B6=94=EA=B0=80=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/feed/application/port/out/dto/FeedQueryDto.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java b/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java index 66b3f857b..27b93c606 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java @@ -22,7 +22,8 @@ public record FeedQueryDto( Integer likeCount, Integer commentCount, boolean isPublic, - @Nullable Boolean isPriorityFeed + @Nullable Boolean isPriorityFeed, + @Nullable LocalDateTime savedCreatedAt ) { @QueryProjection public FeedQueryDto( @@ -40,7 +41,8 @@ public FeedQueryDto( Integer likeCount, Integer commentCount, Boolean isPublic, - @Nullable Boolean isPriorityFeed + @Nullable Boolean isPriorityFeed, + @Nullable LocalDateTime savedCreatedAt ) { this( feedId, @@ -57,7 +59,8 @@ public FeedQueryDto( likeCount == null ? 0 : likeCount, commentCount == null ? 0 : commentCount, isPublic, - isPriorityFeed + isPriorityFeed, + savedCreatedAt ); } From e79d2eca7bdb4b5b21247636f2f5eb797b1ac6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:18:23 +0900 Subject: [PATCH 09/27] =?UTF-8?q?[feat]=20=20FeedQueryMapper.toFeedShowSav?= =?UTF-8?q?edListResponse=20=EC=B6=94=EA=B0=80=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/application/mapper/FeedQueryMapper.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java b/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java index 81a1a3b05..0423a30fb 100644 --- a/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java +++ b/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java @@ -174,4 +174,14 @@ default List toFeedRelatedWi .collect(Collectors.toList()); } -} + @Mapping(target = "aliasName", source = "dto.alias") + @Mapping(target = "aliasColor", expression = "java(Alias.from(dto.alias()).getColor())") + @Mapping(target = "isSaved", constant = "true") + @Mapping(target = "isLiked", expression = "java(likedFeedIds.contains(dto.feedId()))") + @Mapping( + target = "postDate", + expression = "java(DateUtil.formatBeforeTime(dto.createdAt()))" + ) + @Mapping(target = "isWriter", source = "dto.creatorId", qualifiedByName = "isWriter") + FeedShowSavedListResponse.FeedShowSavedInfoDto toFeedShowSavedListResponse(FeedQueryDto dto, Set likedFeedIds, @Context Long userId); +} \ No newline at end of file From 8609f836921ca68bf97c58c2a0fd61148408cc8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:18:38 +0900 Subject: [PATCH 10/27] =?UTF-8?q?[feat]=20=20FeedQueryPersistenceAdapter.f?= =?UTF-8?q?indSavedFeedsByCreatedAt=20=EC=B6=94=EA=B0=80=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/FeedQueryPersistenceAdapter.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java index ea880cef8..54de7ad26 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java @@ -109,6 +109,19 @@ public boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId) { return savedFeedJpaRepository.existsByUserIdAndFeedId(userId, feedId); } + @Override + public CursorBasedList findSavedFeedsByCreatedAt(Long userId, Cursor cursor) { + LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + int size = cursor.getPageSize(); + + List feedQueryDtos = feedJpaRepository.findSavedFeedsByCreatedAt(userId, lastCreatedAt, size); + + return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { + Cursor nextCursor = new Cursor(List.of(feedQueryDto.savedCreatedAt().toString())); + return nextCursor.toEncodedString(); + }); + } + @Override public List findAllTags() { return feedJpaRepository.findAllTags(); From 52305375afc8a8051e6638d1e3b566fd6684b34e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:18:45 +0900 Subject: [PATCH 11/27] =?UTF-8?q?[feat]=20=20FeedQueryPort.findSavedFeedsB?= =?UTF-8?q?yCreatedAt=20=EC=B6=94=EA=B0=80=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konkuk/thip/feed/application/port/out/FeedQueryPort.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java index b4d7a903d..d0e92d3e8 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java @@ -37,8 +37,8 @@ public interface FeedQueryPort { */ Set findSavedFeedIdsByUserIdAndFeedIds(Set feedIds, Long userId); - boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId); + CursorBasedList findSavedFeedsByCreatedAt(Long userId, Cursor cursor); /** * 특정 책으로 작성된 피드 조회 From b89905b3a47ec8cc60a396396a3a28ac6e53f09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:18:53 +0900 Subject: [PATCH 12/27] =?UTF-8?q?[feat]=20=20FeedQueryRepository.findSaved?= =?UTF-8?q?FeedsByCreatedAt=20=EC=B6=94=EA=B0=80=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/repository/FeedQueryRepository.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java index 51e7c1ce6..c2588f32c 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java @@ -25,4 +25,6 @@ public interface FeedQueryRepository { List findFeedsByBookIsbnOrderByCreatedAt(String isbn, Long userId, LocalDateTime lastCreatedAt, int size); List findLatestPublicFeedCreatorsIn(Set userIds, int size); + + List findSavedFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size); } From ee28542319839a20665b806b4e02c86323348838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:19:10 +0900 Subject: [PATCH 13/27] =?UTF-8?q?[feat]=20FeedQueryRepositoryImpl.findSave?= =?UTF-8?q?dFeedsByCreatedAt=20=EC=B6=94=EA=B0=80=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FeedQueryRepositoryImpl.java | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index 3bf650402..c1ee4cb68 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -38,6 +38,7 @@ public class FeedQueryRepositoryImpl implements FeedQueryRepository { private final QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity; private final QBookJpaEntity book = QBookJpaEntity.bookJpaEntity; private final QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; + private final QSavedFeedJpaEntity savedFeed = QSavedFeedJpaEntity.savedFeedJpaEntity; @Override public Set findUserIdsByBookId(Long bookId) { @@ -79,7 +80,7 @@ public List findFeedsByFollowingPriority(Long userId, Integer last // 3) DTO 변환 return ordered.stream() - .map(e -> toDto(e, priorityMap.get(e.getPostId()))) + .map(e -> toDto(e, priorityMap.get(e.getPostId()),null)) .toList(); } @@ -101,7 +102,7 @@ public List findLatestFeedsByCreatedAt(Long userId, LocalDateTime // 3) DTO 변환 (priority 없음) return ordered.stream() - .map(e -> toDto(e, null)) + .map(e -> toDto(e, null,null)) .toList(); } @@ -192,7 +193,7 @@ public List findMyFeedsByCreatedAt(Long userId, LocalDateTime last // 3) DTO 변환 (priority 없음) return ordered.stream() - .map(e -> toDto(e, null)) + .map(e -> toDto(e, null, null)) .toList(); } @@ -211,7 +212,7 @@ public List findSpecificUserFeedsByCreatedAt(Long feedOwnerId, Loc // 3) DTO 변환 (priority 없음) return ordered.stream() - .map(e -> toDto(e, null)) + .map(e -> toDto(e, null, null)) .toList(); } @@ -258,7 +259,7 @@ private List fetchSpecificUserFeedIdsByCreatedAt(Long userId, LocalDateTim .fetch(); } - private FeedQueryDto toDto(FeedJpaEntity e, Integer priority) { + private FeedQueryDto toDto(FeedJpaEntity e, Integer priority, LocalDateTime savedCreatedAt) { String[] urls = e.getContentList().stream() .map(ContentJpaEntity::getContentUrl) .toArray(String[]::new); @@ -280,6 +281,7 @@ private FeedQueryDto toDto(FeedJpaEntity e, Integer priority) { .commentCount(e.getCommentCount()) .isPublic(e.getIsPublic()) .isPriorityFeed(isPriorityFeed) + .savedCreatedAt(savedCreatedAt) .build(); } @@ -364,6 +366,7 @@ private QFeedQueryDto toQueryDto() { feed.likeCount, feed.commentCount, feed.isPublic, + Expressions.nullExpression(), Expressions.nullExpression() ); } @@ -399,4 +402,59 @@ public List findLatestPublicFeedCreatorsIn(Set userIds, int size) { .limit(size) .fetch(); } + + @Override + public List findSavedFeedsByCreatedAt(Long userId, LocalDateTime lastSavedAt, int size) { + + // 1. 저장한 SavedFeed의 ID(PK) 목록 & 최신순 커서 페이징 + List savedFeedIds = fetchSavedFeedIdsLatest(userId, lastSavedAt, size); + if (savedFeedIds.isEmpty()) { + return List.of(); + } + + // 2. 저장한 SavedFeed를 가져오면서 Feed와 연관 엔티티를 fetch join + List savedEntities = fetchSavedFeedEntitiesByIds(savedFeedIds,userId); + + // 3. SavedFeed ID 역순 정렬(커서 페이징 기준에 맞게) + Map entityMap = savedEntities.stream() + .collect(Collectors.toMap(SavedFeedJpaEntity::getSavedId, e -> e)); + + // 4. SavedFeed ID 순서대로 FeedQueryDto 생성 (Feed와 저장 시각 함께 전달) + return savedFeedIds.stream() + .map(id -> { + SavedFeedJpaEntity saved = entityMap.get(id); + return toDto(saved.getFeedJpaEntity(),null, saved.getCreatedAt()); + }) + .collect(Collectors.toList()); + } + + private List fetchSavedFeedIdsLatest(Long userId, LocalDateTime lastCreatedAt, int size) { + return jpaQueryFactory + .select(savedFeed.savedId) + .from(savedFeed) + .where( + savedFeed.userJpaEntity.userId.eq(userId), + lastCreatedAt != null ? savedFeed.createdAt.lt(lastCreatedAt) : Expressions.TRUE + ) + .orderBy(savedFeed.createdAt.desc()) + .limit(size + 1) + .fetch(); + } + + private List fetchSavedFeedEntitiesByIds(List ids, Long userId) { + return jpaQueryFactory + .select(savedFeed).distinct() + .from(savedFeed) + .leftJoin(savedFeed.feedJpaEntity, feed).fetchJoin() + .leftJoin(feed.contentList, content).fetchJoin() + .leftJoin(feed.userJpaEntity, user).fetchJoin() + .leftJoin(user.aliasForUserJpaEntity, alias).fetchJoin() + .leftJoin(feed.bookJpaEntity, book).fetchJoin() + .where( + savedFeed.savedId.in(ids), + feed.status.eq(StatusType.ACTIVE), + feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.eq(true)) + ) + .fetch(); + } } From 8d7b3c9716480f6f9e169b59aeedf65e032dc92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:19:21 +0900 Subject: [PATCH 14/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=EC=9C=A0=EC=A6=88?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/application/port/in/FeedSavedListUseCase.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/konkuk/thip/feed/application/port/in/FeedSavedListUseCase.java diff --git a/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedListUseCase.java b/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedListUseCase.java new file mode 100644 index 000000000..c732a0ceb --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedListUseCase.java @@ -0,0 +1,7 @@ +package konkuk.thip.feed.application.port.in; + +import konkuk.thip.feed.adapter.in.web.response.FeedShowSavedListResponse; + +public interface FeedSavedListUseCase { + FeedShowSavedListResponse getSavedFeedList(Long userId, String cursor); +} From cfaca6ccfec8afa6c0d592aed6ae01824753dfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:19:28 +0900 Subject: [PATCH 15/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=EC=9C=A0=EC=A6=88?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EA=B5=AC=ED=98=84=EC=B2=B4=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FeedShowSavedListService.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java b/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java new file mode 100644 index 000000000..3622acfe7 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java @@ -0,0 +1,54 @@ +package konkuk.thip.feed.application.service; + +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.feed.adapter.in.web.response.FeedShowSavedListResponse; +import konkuk.thip.feed.application.mapper.FeedQueryMapper; +import konkuk.thip.feed.application.port.in.FeedSavedListUseCase; +import konkuk.thip.feed.application.port.out.FeedQueryPort; +import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; +import konkuk.thip.post.application.port.out.PostLikeQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class FeedShowSavedListService implements FeedSavedListUseCase { + + private static final int PAGE_SIZE = 10; + private final FeedQueryPort feedQueryPort; + private final PostLikeQueryPort postLikeQueryPort; + private final FeedQueryMapper feedQueryMapper; + + @Override + @Transactional(readOnly = true) + public FeedShowSavedListResponse getSavedFeedList(Long userId, String cursor) { + // 1. 커서 생성 + Cursor nextCursor = Cursor.from(cursor, PAGE_SIZE); + + // 2. 유저가 저장한 책 최신순으로 (페이징 처리 포함) + CursorBasedList result = feedQueryPort.findSavedFeedsByCreatedAt(userId, nextCursor); + Set feedIds = result.contents().stream() + .map(FeedQueryDto::feedId) + .collect(Collectors.toUnmodifiableSet()); + + // 3. 유저가 좋아한 피드들 조회 + Set likedFeedIdsByUser = postLikeQueryPort.findPostIdsLikedByUser(feedIds, userId); + + // 4. response 로의 매핑 + List feedList = result.contents().stream() + .map(dto -> feedQueryMapper.toFeedShowSavedListResponse(dto, likedFeedIdsByUser, userId)) + .toList(); + + return new FeedShowSavedListResponse( + feedList, + result.nextCursor(), + !result.hasNext() + ); + } +} From 019c08b5099519168d26bc422037fbdaf61160a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:21:12 +0900 Subject: [PATCH 16/27] =?UTF-8?q?[feat]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20FeedShowSavedListResp?= =?UTF-8?q?onse=20dto=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/FeedShowSavedListResponse.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedShowSavedListResponse.java diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedShowSavedListResponse.java b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedShowSavedListResponse.java new file mode 100644 index 000000000..3d62131d1 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedShowSavedListResponse.java @@ -0,0 +1,29 @@ +package konkuk.thip.feed.adapter.in.web.response; + +import java.util.List; + +public record FeedShowSavedListResponse( + List feedList, + String nextCursor, + boolean isLast +) { + public record FeedShowSavedInfoDto( + Long feedId, + Long creatorId, + String creatorNickname, + String creatorProfileImageUrl, + String aliasName, + String aliasColor, + String postDate, + String isbn, + String bookTitle, + String bookAuthor, + String contentBody, + String[] contentUrls, + int likeCount, + int commentCount, + boolean isSaved, + boolean isLiked, + boolean isWriter + ) { } +} \ No newline at end of file From 0eb5d41b2030f11b87698428ac9612b4f944f0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:44:56 +0900 Subject: [PATCH 17/27] =?UTF-8?q?[test]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/BookShowSavedListApiTest.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java new file mode 100644 index 000000000..d19521bbf --- /dev/null +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java @@ -0,0 +1,61 @@ +package konkuk.thip.book.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.book.adapter.out.persistence.repository.SavedBookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +@Transactional +@DisplayName("[통합] 저장한 책 조회 API 통합 테스트") +class BookShowSavedListApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private SavedBookJpaRepository savedBookJpaRepository; + + private UserJpaEntity user; + private BookJpaEntity savedBook; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + savedBook = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("1111111111111")); + savedBookJpaRepository.save(TestEntityFactory.createSavedBook(user, savedBook)); + } + + @Test + @DisplayName("사용자가 저장한 책을 정상적으로 호출한다.") + void getSavedBooks_success() throws Exception { + mockMvc.perform(get("/books/saved") + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList").isArray()) + .andExpect(jsonPath("$.data.bookList.length()").value(1)) + .andExpect(jsonPath("$.data.bookList[0].isbn").value(savedBook.getIsbn())); + } + +} \ No newline at end of file From 074895e1ef022f380608e2dcc103ab63416a8c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:45:06 +0900 Subject: [PATCH 18/27] =?UTF-8?q?[test]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/FeedShowSavedListApiTest.java | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java new file mode 100644 index 000000000..69d29cbd8 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java @@ -0,0 +1,229 @@ +package konkuk.thip.feed.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.SavedFeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; +import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 저장한 피드 조회 api 통합 테스트") +class FeedShowSavedListApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private SavedFeedJpaRepository savedFeedJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("저장된 피드 조회 시 피드 정보를 피드를 저장한 최신순으로 정렬해서 반환한다.") + void saved_feed_show_test_success() throws Exception { + // given + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(alias, "user1")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + // 피드 생성 + LocalDateTime baseTime = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save( + TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); + FeedJpaEntity f2 = feedJpaRepository.save( + TestEntityFactory.createFeed(user1, book, true, 50, 10, List.of())); + // me가 f2를 좋아요 + postLikeJpaRepository.save( + PostLikeJpaEntity.builder() + .userJpaEntity(me) + .postJpaEntity(f2) + .build() + ); + + // 저장 시연 - me가 f1 저장 + SavedFeedJpaEntity sf1 = savedFeedJpaRepository.save(SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(f1) + .build()); + + // 저장 시연 - me가 f2 저장 (조금 더 이전) + SavedFeedJpaEntity sf2 = savedFeedJpaRepository.save(SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(f2) + .build()); + + // flush 후 feed 저장일자 덮어쓰기 + // feed 저장 순서 : f2 -> f1 (f1 이 가장 최신) + feedJpaRepository.flush(); + savedFeedJpaRepository.flush(); + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(1)), sf1.getSavedId()); + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE post_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(10)), sf2.getSavedId()); + + // when & then + mockMvc.perform(get("/feeds/saved") + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(2))) + // 저장한 최신순 -> f1 먼저 + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f1.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[0].creatorNickname", is("me"))) + .andExpect(jsonPath("$.data.feedList[0].contentUrls", hasSize(2))) + .andExpect(jsonPath("$.data.feedList[0].likeCount", is(10))) + .andExpect(jsonPath("$.data.feedList[0].commentCount", is(5))) + .andExpect(jsonPath("$.data.feedList[0].isSaved", is(true))) + .andExpect(jsonPath("$.data.feedList[0].isLiked", is(false))) + // 두 번째는 f2 + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f2.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].creatorNickname", is("user1"))) + .andExpect(jsonPath("$.data.feedList[1].contentUrls", hasSize(0))) + .andExpect(jsonPath("$.data.feedList[1].likeCount", is(50))) + .andExpect(jsonPath("$.data.feedList[1].commentCount", is(10))) + .andExpect(jsonPath("$.data.feedList[1].isSaved", is(true))) + .andExpect(jsonPath("$.data.feedList[1].isLiked", is(true))); + } + + @Test + @DisplayName("request parameter의 cursor 값이 null일 경우, 첫번째 페이지에 해당하는 피드 10개와, nextCursor, last 값을 반환한다.") + void saved_feed_show_with_first_page() throws Exception { + + // given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + // 피드 12개 생성 및 저장 + LocalDateTime baseTime = LocalDateTime.now(); + FeedJpaEntity[] feeds = new FeedJpaEntity[12]; + SavedFeedJpaEntity[] savedFeeds = new SavedFeedJpaEntity[12]; + + for (int i = 0; i < 12; i++) { + FeedJpaEntity feed = feedJpaRepository.save( + TestEntityFactory.createFeed(me, book, true, 10 + i, 5 + i, List.of("contentUrl" + i))); + feeds[i] = feed; + SavedFeedJpaEntity savedFeed = savedFeedJpaRepository.save( + SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(feed) + .build() + ); + savedFeeds[i] = savedFeed; + } + savedFeedJpaRepository.flush(); + + // created_at 덮어쓰기 feedId가 작을수록 최신 저장순 + for (int i = 0; i < 12; i++) { + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(i)), savedFeeds[i].getSavedId()); + } + + // when & then + mockMvc.perform(get("/feeds/saved") + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(10))) + .andExpect(jsonPath("$.data.nextCursor", notNullValue())) + .andExpect(jsonPath("$.data.isLast", is(false))) + /** + * 정렬 조건 + * 저장한 피드를 저장한 순으로 조회 + */ + .andExpect(jsonPath("$.data.feedList[0].feedId", is(feeds[0].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(feeds[1].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[2].feedId", is(feeds[2].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[3].feedId", is(feeds[3].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[4].feedId", is(feeds[4].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[5].feedId", is(feeds[5].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[6].feedId", is(feeds[6].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[7].feedId", is(feeds[7].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[8].feedId", is(feeds[8].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[9].feedId", is(feeds[9].getPostId().intValue()))); + } + + @Test + @DisplayName("request parameter의 cursor 값이 존재할 경우, 해당 페이지에 해당하는 피드 10개와, nextCursor, last 값을 반환한다.") + void saved_feed_show_with_cursor() throws Exception { + // given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + // 피드 12개 생성 및 저장 + LocalDateTime baseTime = LocalDateTime.now(); + FeedJpaEntity[] feeds = new FeedJpaEntity[12]; + SavedFeedJpaEntity[] savedFeeds = new SavedFeedJpaEntity[12]; + + for (int i = 0; i < 12; i++) { + FeedJpaEntity feed = feedJpaRepository.save( + TestEntityFactory.createFeed(me, book, true, 10 + i, 5 + i, List.of("contentUrl" + i))); + feeds[i] = feed; + SavedFeedJpaEntity savedFeed = savedFeedJpaRepository.save( + SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(feed) + .build() + ); + savedFeeds[i] = savedFeed; + } + savedFeedJpaRepository.flush(); + + // created_at 덮어쓰기 feedId가 작을수록 최신 저장순 + for (int i = 0; i < 12; i++) { + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(i)), savedFeeds[i].getSavedId()); + } + + // DB에 저장된 sf9의 createdAt 값을 native query 로 조회 + LocalDateTime nextCursorVal = jdbcTemplate.queryForObject( + "SELECT created_at FROM saved_feeds WHERE saved_id = ?", + (rs, rowNum) -> rs.getTimestamp("created_at").toLocalDateTime(), savedFeeds[9].getSavedId() + ); + String nextCursor = nextCursorVal.toString(); + + //when //then + mockMvc.perform(get("/feeds/saved") + .requestAttr("userId", me.getUserId()) + .param("cursor", nextCursor)) // 이전에 f9 까지 조회 -> f9의 저장시간, sf9의 createdAt이 커서 + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.nextCursor", nullValue())) // nextCursor 는 null + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.feedList", hasSize(2))) + .andExpect(jsonPath("$.data.feedList[0].feedId", is(feeds[10].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(feeds[11].getPostId().intValue()))); + } +} From 456460e87d2ca3833470eb466b42fbdc3524fdef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 19:53:56 +0900 Subject: [PATCH 19/27] =?UTF-8?q?[test]=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/feed/adapter/in/web/FeedShowSavedListApiTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java index 69d29cbd8..9f70c2073 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java @@ -88,11 +88,10 @@ void saved_feed_show_test_success() throws Exception { // flush 후 feed 저장일자 덮어쓰기 // feed 저장 순서 : f2 -> f1 (f1 이 가장 최신) - feedJpaRepository.flush(); savedFeedJpaRepository.flush(); jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", Timestamp.valueOf(baseTime.minusMinutes(1)), sf1.getSavedId()); - jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE post_id = ?", + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", Timestamp.valueOf(baseTime.minusMinutes(10)), sf2.getSavedId()); // when & then From 1c7f1fa1a30698a1fdeec84c7cbcd3a96277a8ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 20:31:30 +0900 Subject: [PATCH 20/27] =?UTF-8?q?[fix]=20=EB=A1=9C=EC=A7=81=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FeedQueryRepositoryImpl.java | 53 ++++++------------- 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index c1ee4cb68..1180fb3e1 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -406,55 +406,36 @@ public List findLatestPublicFeedCreatorsIn(Set userIds, int size) { @Override public List findSavedFeedsByCreatedAt(Long userId, LocalDateTime lastSavedAt, int size) { - // 1. 저장한 SavedFeed의 ID(PK) 목록 & 최신순 커서 페이징 - List savedFeedIds = fetchSavedFeedIdsLatest(userId, lastSavedAt, size); - if (savedFeedIds.isEmpty()) { + // 1. SavedFeed를 한 번에 페이징 조회하며 feed와 연관 엔티티 fetch join + List savedFeeds = getSavedFeedJpaEntities(userId, lastSavedAt, size); + if (savedFeeds.isEmpty()) { return List.of(); } - // 2. 저장한 SavedFeed를 가져오면서 Feed와 연관 엔티티를 fetch join - List savedEntities = fetchSavedFeedEntitiesByIds(savedFeedIds,userId); - - // 3. SavedFeed ID 역순 정렬(커서 페이징 기준에 맞게) - Map entityMap = savedEntities.stream() - .collect(Collectors.toMap(SavedFeedJpaEntity::getSavedId, e -> e)); - - // 4. SavedFeed ID 순서대로 FeedQueryDto 생성 (Feed와 저장 시각 함께 전달) - return savedFeedIds.stream() - .map(id -> { - SavedFeedJpaEntity saved = entityMap.get(id); - return toDto(saved.getFeedJpaEntity(),null, saved.getCreatedAt()); - }) + // 2. 저장순대로 FeedQueryDto 변환 (Feed 및 savedCreatedAt 정보 포함) + return savedFeeds.stream() + .map(saved -> toDto(saved.getFeedJpaEntity(), null, saved.getCreatedAt())) .collect(Collectors.toList()); } - private List fetchSavedFeedIdsLatest(Long userId, LocalDateTime lastCreatedAt, int size) { - return jpaQueryFactory - .select(savedFeed.savedId) - .from(savedFeed) - .where( - savedFeed.userJpaEntity.userId.eq(userId), - lastCreatedAt != null ? savedFeed.createdAt.lt(lastCreatedAt) : Expressions.TRUE - ) - .orderBy(savedFeed.createdAt.desc()) - .limit(size + 1) - .fetch(); - } - - private List fetchSavedFeedEntitiesByIds(List ids, Long userId) { - return jpaQueryFactory - .select(savedFeed).distinct() - .from(savedFeed) + private List getSavedFeedJpaEntities(Long userId, LocalDateTime lastSavedAt, int size) { + List savedFeeds = jpaQueryFactory + .selectFrom(savedFeed) .leftJoin(savedFeed.feedJpaEntity, feed).fetchJoin() .leftJoin(feed.contentList, content).fetchJoin() .leftJoin(feed.userJpaEntity, user).fetchJoin() .leftJoin(user.aliasForUserJpaEntity, alias).fetchJoin() .leftJoin(feed.bookJpaEntity, book).fetchJoin() .where( - savedFeed.savedId.in(ids), - feed.status.eq(StatusType.ACTIVE), - feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.eq(true)) + savedFeed.userJpaEntity.userId.eq(userId), + savedFeed.feedJpaEntity.status.eq(StatusType.ACTIVE), + savedFeed.feedJpaEntity.userJpaEntity.userId.eq(userId) + .or(savedFeed.feedJpaEntity.isPublic.eq(true)), + lastSavedAt != null ? savedFeed.createdAt.lt(lastSavedAt) : Expressions.TRUE ) + .orderBy(savedFeed.createdAt.desc()) + .limit(size + 1) .fetch(); + return savedFeeds; } } From 83ae9ebfeddc3703cabf0b58825259d1b5576486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 20:31:39 +0900 Subject: [PATCH 21/27] =?UTF-8?q?[refactor]=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konkuk/thip/feed/adapter/in/web/FeedQueryController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java index 1ebdfa4e3..fefb00758 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java @@ -138,7 +138,7 @@ public BaseResponse showFeedsByBook( @Operation( summary = "저장한 피드 조회", - description = "사용자가 저장한 피드를 조회 합니다." + description = "사용자가 저장한 피드를 조회합니다." ) @GetMapping("/feeds/saved") public BaseResponse showSavedFeedList( From ac697b15af54dd9e626fce5b8948a98cf7c63307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 18 Aug 2025 20:43:12 +0900 Subject: [PATCH 22/27] =?UTF-8?q?[refactor]=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/repository/FeedQueryRepositoryImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index 1180fb3e1..cf7b0bc24 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -420,7 +420,8 @@ public List findSavedFeedsByCreatedAt(Long userId, LocalDateTime l private List getSavedFeedJpaEntities(Long userId, LocalDateTime lastSavedAt, int size) { List savedFeeds = jpaQueryFactory - .selectFrom(savedFeed) + .select(savedFeed).distinct() + .from(savedFeed) .leftJoin(savedFeed.feedJpaEntity, feed).fetchJoin() .leftJoin(feed.contentList, content).fetchJoin() .leftJoin(feed.userJpaEntity, user).fetchJoin() From 6b8943f053f8b97ad07a9a08ebfaf6f44a0d9f76 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 18 Aug 2025 23:42:28 +0900 Subject: [PATCH 23/27] =?UTF-8?q?[test]=20contents=20=EA=B0=80=20=EC=97=AC?= =?UTF-8?q?=EB=9F=AC=EA=B0=9C=EC=9D=B8=20feed=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=ED=95=9C=20=ED=9B=84,=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EA=B0=80=20=EC=A0=9C=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A3=A8=EC=96=B4=EC=A7=80=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 코드가 성공하면 중복되는 데이터가 반환되는 것임 - 테스트 코드가 실패하면 현재 QueryDSL 코드로도 페이징 처리가 제대로 이루어지는 것임 - 결과는 테스트 코드 실패 --- .../in/web/FeedShowSavedListApiTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java index 9f70c2073..91a1b007b 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java @@ -25,8 +25,11 @@ import java.sql.Timestamp; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -225,4 +228,86 @@ void saved_feed_show_with_cursor() throws Exception { .andExpect(jsonPath("$.data.feedList[0].feedId", is(feeds[10].getPostId().intValue()))) .andExpect(jsonPath("$.data.feedList[1].feedId", is(feeds[11].getPostId().intValue()))); } + + @Test + @DisplayName("[깨짐 재현] 최신 저장 피드에 contents가 많으면 첫 페이지 결과 개수가 10개보다 적게 반환된다") + void saved_feed_paging_breaks_with_many_contents() throws Exception { + // given + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias, "me")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + LocalDateTime baseTime = LocalDateTime.now(); + + // 피드 12개 생성. 가장 최신(f0)과 그 다음(f1, f2)에 많은 contents 부여 → row 폭발 유도 + FeedJpaEntity[] feeds = new FeedJpaEntity[12]; + SavedFeedJpaEntity[] savedFeeds = new SavedFeedJpaEntity[12]; + + for (int i = 0; i < 12; i++) { + List contentUrls; + if (i == 0) { + // 최신 저장된 피드: 컨텐츠 3개 + contentUrls = java.util.stream.IntStream.range(0, 3) + .mapToObj(n -> "url_f0_" + n) + .toList(); + } else if (i == 1) { + // 두 번째: 컨텐츠 3개 + contentUrls = java.util.stream.IntStream.range(0, 3) + .mapToObj(n -> "url_f1_" + n) + .toList(); + } else if (i == 2) { + // 세 번째: 컨텐츠 2개 + contentUrls = java.util.stream.IntStream.range(0, 2) + .mapToObj(n -> "url_f2_" + n) + .toList(); + } else { + // 나머지: 컨텐츠 0개 + contentUrls = List.of(); + } + + FeedJpaEntity feed = feedJpaRepository.save( + TestEntityFactory.createFeed( + me, // 작성자: me (가시성 조건 단순화) + book, + true, // 공개 + 10 + i, // likeCount + 5 + i, // commentCount + contentUrls + ) + ); + feeds[i] = feed; + + SavedFeedJpaEntity saved = savedFeedJpaRepository.save( + SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(feed) + .build() + ); + savedFeeds[i] = saved; + } + savedFeedJpaRepository.flush(); + + // created_at 덮어쓰기: i가 작을수록 더 최신 (f0가 가장 최신) + for (int i = 0; i < 12; i++) { + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(i)), savedFeeds[i].getSavedId()); + } + + // when & then + mockMvc.perform(get("/feeds/saved") + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + // ⚠️ 현재 구현(컬렉션 fetch join + limit)에서는 행 기준으로 잘려 루트 엔티티 개수가 모자라게 반환됨 + .andExpect(jsonPath("$.data.feedList", hasSize(10))) // 총 응답개수는 10개가 맞음 + .andExpect(result -> { + String body = result.getResponse().getContentAsString(); + // com.jayway.jsonpath.JsonPath 사용 (spring-boot-starter-test에 포함) + List ids = com.jayway.jsonpath.JsonPath.read(body, "$.data.feedList[*].feedId"); + Set distinct = new HashSet<>(ids); + // 중복이 있음을 기대(= 실패를 통해 버그 재현). 만약 여기서 실패하면 중복이 안 나온 것. + assertThat(distinct.size()) + .as("feedId가 중복 없이 10개 모두 달라야 하지만, 1:N 조인으로 인해 중복이 발생해야 합니다.") + .isLessThan(ids.size()); + }); + } } From 9834bd395e8ad03f8873ba1340e68ab526135d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 19 Aug 2025 01:12:51 +0900 Subject: [PATCH 24/27] =?UTF-8?q?[refactor]=20QueryProjection=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FeedQueryRepositoryImpl.java | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index cf7b0bc24..74467d4d7 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -80,7 +80,7 @@ public List findFeedsByFollowingPriority(Long userId, Integer last // 3) DTO 변환 return ordered.stream() - .map(e -> toDto(e, priorityMap.get(e.getPostId()),null)) + .map(e -> toDto(e, priorityMap.get(e.getPostId()))) .toList(); } @@ -102,7 +102,7 @@ public List findLatestFeedsByCreatedAt(Long userId, LocalDateTime // 3) DTO 변환 (priority 없음) return ordered.stream() - .map(e -> toDto(e, null,null)) + .map(e -> toDto(e, null)) .toList(); } @@ -193,7 +193,7 @@ public List findMyFeedsByCreatedAt(Long userId, LocalDateTime last // 3) DTO 변환 (priority 없음) return ordered.stream() - .map(e -> toDto(e, null, null)) + .map(e -> toDto(e, null)) .toList(); } @@ -212,7 +212,7 @@ public List findSpecificUserFeedsByCreatedAt(Long feedOwnerId, Loc // 3) DTO 변환 (priority 없음) return ordered.stream() - .map(e -> toDto(e, null, null)) + .map(e -> toDto(e, null)) .toList(); } @@ -259,7 +259,7 @@ private List fetchSpecificUserFeedIdsByCreatedAt(Long userId, LocalDateTim .fetch(); } - private FeedQueryDto toDto(FeedJpaEntity e, Integer priority, LocalDateTime savedCreatedAt) { + private FeedQueryDto toDto(FeedJpaEntity e, Integer priority) { String[] urls = e.getContentList().stream() .map(ContentJpaEntity::getContentUrl) .toArray(String[]::new); @@ -281,7 +281,6 @@ private FeedQueryDto toDto(FeedJpaEntity e, Integer priority, LocalDateTime save .commentCount(e.getCommentCount()) .isPublic(e.getIsPublic()) .isPriorityFeed(isPriorityFeed) - .savedCreatedAt(savedCreatedAt) .build(); } @@ -406,37 +405,54 @@ public List findLatestPublicFeedCreatorsIn(Set userIds, int size) { @Override public List findSavedFeedsByCreatedAt(Long userId, LocalDateTime lastSavedAt, int size) { - // 1. SavedFeed를 한 번에 페이징 조회하며 feed와 연관 엔티티 fetch join - List savedFeeds = getSavedFeedJpaEntities(userId, lastSavedAt, size); - if (savedFeeds.isEmpty()) { - return List.of(); - } + BooleanExpression where = savedFeed.userJpaEntity.userId.eq(userId) + .and(savedFeed.feedJpaEntity.status.eq(StatusType.ACTIVE)) + .and( + savedFeed.feedJpaEntity.userJpaEntity.userId.eq(userId) + .or(savedFeed.feedJpaEntity.isPublic.eq(true)) + ); - // 2. 저장순대로 FeedQueryDto 변환 (Feed 및 savedCreatedAt 정보 포함) - return savedFeeds.stream() - .map(saved -> toDto(saved.getFeedJpaEntity(), null, saved.getCreatedAt())) - .collect(Collectors.toList()); - } + if (lastSavedAt != null) { + where = where.and(savedFeed.createdAt.lt(lastSavedAt)); + } - private List getSavedFeedJpaEntities(Long userId, LocalDateTime lastSavedAt, int size) { - List savedFeeds = jpaQueryFactory - .select(savedFeed).distinct() + return jpaQueryFactory + .select(toSavedFeedQueryDto()) .from(savedFeed) - .leftJoin(savedFeed.feedJpaEntity, feed).fetchJoin() - .leftJoin(feed.contentList, content).fetchJoin() - .leftJoin(feed.userJpaEntity, user).fetchJoin() - .leftJoin(user.aliasForUserJpaEntity, alias).fetchJoin() - .leftJoin(feed.bookJpaEntity, book).fetchJoin() - .where( - savedFeed.userJpaEntity.userId.eq(userId), - savedFeed.feedJpaEntity.status.eq(StatusType.ACTIVE), - savedFeed.feedJpaEntity.userJpaEntity.userId.eq(userId) - .or(savedFeed.feedJpaEntity.isPublic.eq(true)), - lastSavedAt != null ? savedFeed.createdAt.lt(lastSavedAt) : Expressions.TRUE - ) + .join(savedFeed.feedJpaEntity, feed) + .join(feed.userJpaEntity, user) + .join(feed.bookJpaEntity, book) + .where(where) .orderBy(savedFeed.createdAt.desc()) .limit(size + 1) .fetch(); - return savedFeeds; + } + + /** + * SavedFeed 전용 DTO 매핑 + */ + private QFeedQueryDto toSavedFeedQueryDto() { + return new QFeedQueryDto( + feed.postId, + feed.userJpaEntity.userId, + user.nickname, + user.aliasForUserJpaEntity.imageUrl, + user.aliasForUserJpaEntity.value, + feed.createdAt, + book.isbn, + book.title, + book.authorName, + feed.content, + // Content는 N:1 방지 위해 서브쿼리 사용 + JPAExpressions + .select(contentUrlAggExpr()) + .from(content) + .where(content.postJpaEntity.postId.eq(feed.postId)), + feed.likeCount, + feed.commentCount, + feed.isPublic, + Expressions.nullExpression(), + savedFeed.createdAt + ); } } From 9f7cb98f421d64cd2e3d019f5c0b15d751da5c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 19 Aug 2025 01:14:34 +0900 Subject: [PATCH 25/27] =?UTF-8?q?[refactor]=20=EB=A7=A4=ED=8D=BC=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=83=80=EC=9E=85=20var=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/feed/application/service/FeedShowSavedListService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java b/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java index 3622acfe7..cec8e5203 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java @@ -41,7 +41,7 @@ public FeedShowSavedListResponse getSavedFeedList(Long userId, String cursor) { Set likedFeedIdsByUser = postLikeQueryPort.findPostIdsLikedByUser(feedIds, userId); // 4. response 로의 매핑 - List feedList = result.contents().stream() + var feedList = result.contents().stream() .map(dto -> feedQueryMapper.toFeedShowSavedListResponse(dto, likedFeedIdsByUser, userId)) .toList(); From 850ab221d1a7a5b796ddf3b7dc8ff087ac48c129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 19 Aug 2025 01:45:33 +0900 Subject: [PATCH 26/27] =?UTF-8?q?[test]=20=EC=A3=BC=EC=84=9D=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/FeedShowSavedListApiTest.java | 162 +++++++++--------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java index 91a1b007b..6eea07861 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java @@ -229,85 +229,85 @@ void saved_feed_show_with_cursor() throws Exception { .andExpect(jsonPath("$.data.feedList[1].feedId", is(feeds[11].getPostId().intValue()))); } - @Test - @DisplayName("[깨짐 재현] 최신 저장 피드에 contents가 많으면 첫 페이지 결과 개수가 10개보다 적게 반환된다") - void saved_feed_paging_breaks_with_many_contents() throws Exception { - // given - AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); - UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias, "me")); - BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); - - LocalDateTime baseTime = LocalDateTime.now(); - - // 피드 12개 생성. 가장 최신(f0)과 그 다음(f1, f2)에 많은 contents 부여 → row 폭발 유도 - FeedJpaEntity[] feeds = new FeedJpaEntity[12]; - SavedFeedJpaEntity[] savedFeeds = new SavedFeedJpaEntity[12]; - - for (int i = 0; i < 12; i++) { - List contentUrls; - if (i == 0) { - // 최신 저장된 피드: 컨텐츠 3개 - contentUrls = java.util.stream.IntStream.range(0, 3) - .mapToObj(n -> "url_f0_" + n) - .toList(); - } else if (i == 1) { - // 두 번째: 컨텐츠 3개 - contentUrls = java.util.stream.IntStream.range(0, 3) - .mapToObj(n -> "url_f1_" + n) - .toList(); - } else if (i == 2) { - // 세 번째: 컨텐츠 2개 - contentUrls = java.util.stream.IntStream.range(0, 2) - .mapToObj(n -> "url_f2_" + n) - .toList(); - } else { - // 나머지: 컨텐츠 0개 - contentUrls = List.of(); - } - - FeedJpaEntity feed = feedJpaRepository.save( - TestEntityFactory.createFeed( - me, // 작성자: me (가시성 조건 단순화) - book, - true, // 공개 - 10 + i, // likeCount - 5 + i, // commentCount - contentUrls - ) - ); - feeds[i] = feed; - - SavedFeedJpaEntity saved = savedFeedJpaRepository.save( - SavedFeedJpaEntity.builder() - .userJpaEntity(me) - .feedJpaEntity(feed) - .build() - ); - savedFeeds[i] = saved; - } - savedFeedJpaRepository.flush(); - - // created_at 덮어쓰기: i가 작을수록 더 최신 (f0가 가장 최신) - for (int i = 0; i < 12; i++) { - jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", - Timestamp.valueOf(baseTime.minusMinutes(i)), savedFeeds[i].getSavedId()); - } - - // when & then - mockMvc.perform(get("/feeds/saved") - .requestAttr("userId", me.getUserId())) - .andExpect(status().isOk()) - // ⚠️ 현재 구현(컬렉션 fetch join + limit)에서는 행 기준으로 잘려 루트 엔티티 개수가 모자라게 반환됨 - .andExpect(jsonPath("$.data.feedList", hasSize(10))) // 총 응답개수는 10개가 맞음 - .andExpect(result -> { - String body = result.getResponse().getContentAsString(); - // com.jayway.jsonpath.JsonPath 사용 (spring-boot-starter-test에 포함) - List ids = com.jayway.jsonpath.JsonPath.read(body, "$.data.feedList[*].feedId"); - Set distinct = new HashSet<>(ids); - // 중복이 있음을 기대(= 실패를 통해 버그 재현). 만약 여기서 실패하면 중복이 안 나온 것. - assertThat(distinct.size()) - .as("feedId가 중복 없이 10개 모두 달라야 하지만, 1:N 조인으로 인해 중복이 발생해야 합니다.") - .isLessThan(ids.size()); - }); - } +// @Test +// @DisplayName("[깨짐 재현] 최신 저장 피드에 contents가 많으면 첫 페이지 결과 개수가 10개보다 적게 반환된다") +// void saved_feed_paging_breaks_with_many_contents() throws Exception { +// // given +// AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); +// UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias, "me")); +// BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); +// +// LocalDateTime baseTime = LocalDateTime.now(); +// +// // 피드 12개 생성. 가장 최신(f0)과 그 다음(f1, f2)에 많은 contents 부여 → row 폭발 유도 +// FeedJpaEntity[] feeds = new FeedJpaEntity[12]; +// SavedFeedJpaEntity[] savedFeeds = new SavedFeedJpaEntity[12]; +// +// for (int i = 0; i < 12; i++) { +// List contentUrls; +// if (i == 0) { +// // 최신 저장된 피드: 컨텐츠 3개 +// contentUrls = java.util.stream.IntStream.range(0, 3) +// .mapToObj(n -> "url_f0_" + n) +// .toList(); +// } else if (i == 1) { +// // 두 번째: 컨텐츠 3개 +// contentUrls = java.util.stream.IntStream.range(0, 3) +// .mapToObj(n -> "url_f1_" + n) +// .toList(); +// } else if (i == 2) { +// // 세 번째: 컨텐츠 2개 +// contentUrls = java.util.stream.IntStream.range(0, 2) +// .mapToObj(n -> "url_f2_" + n) +// .toList(); +// } else { +// // 나머지: 컨텐츠 0개 +// contentUrls = List.of(); +// } +// +// FeedJpaEntity feed = feedJpaRepository.save( +// TestEntityFactory.createFeed( +// me, // 작성자: me (가시성 조건 단순화) +// book, +// true, // 공개 +// 10 + i, // likeCount +// 5 + i, // commentCount +// contentUrls +// ) +// ); +// feeds[i] = feed; +// +// SavedFeedJpaEntity saved = savedFeedJpaRepository.save( +// SavedFeedJpaEntity.builder() +// .userJpaEntity(me) +// .feedJpaEntity(feed) +// .build() +// ); +// savedFeeds[i] = saved; +// } +// savedFeedJpaRepository.flush(); +// +// // created_at 덮어쓰기: i가 작을수록 더 최신 (f0가 가장 최신) +// for (int i = 0; i < 12; i++) { +// jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", +// Timestamp.valueOf(baseTime.minusMinutes(i)), savedFeeds[i].getSavedId()); +// } +// +// // when & then +// mockMvc.perform(get("/feeds/saved") +// .requestAttr("userId", me.getUserId())) +// .andExpect(status().isOk()) +// // ⚠️ 현재 구현(컬렉션 fetch join + limit)에서는 행 기준으로 잘려 루트 엔티티 개수가 모자라게 반환됨 +// .andExpect(jsonPath("$.data.feedList", hasSize(10))) // 총 응답개수는 10개가 맞음 +// .andExpect(result -> { +// String body = result.getResponse().getContentAsString(); +// // com.jayway.jsonpath.JsonPath 사용 (spring-boot-starter-test에 포함) +// List ids = com.jayway.jsonpath.JsonPath.read(body, "$.data.feedList[*].feedId"); +// Set distinct = new HashSet<>(ids); +// // 중복이 있음을 기대(= 실패를 통해 버그 재현). 만약 여기서 실패하면 중복이 안 나온 것. +// assertThat(distinct.size()) +// .as("feedId가 중복 없이 10개 모두 달라야 하지만, 1:N 조인으로 인해 중복이 발생해야 합니다.") +// .isLessThan(ids.size()); +// }); +// } } From b2d51e5bb0a187290f85f0ccc6392ddf45d2b6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 19 Aug 2025 01:53:28 +0900 Subject: [PATCH 27/27] =?UTF-8?q?[chore]=20=EA=B0=9C=EB=B0=9C,=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20=EC=84=9C=EB=B2=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-workflow-prod.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/cd-workflow-prod.yml b/.github/workflows/cd-workflow-prod.yml index 878f1de36..651aecdfa 100644 --- a/.github/workflows/cd-workflow-prod.yml +++ b/.github/workflows/cd-workflow-prod.yml @@ -4,7 +4,6 @@ on: push: branches: - 'main' - - 'develop' permissions: contents: read