-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] 전체 피드 조회 api 구현 #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e692a3c
7ff5a17
24b61ab
7b884c3
1265961
139e1a2
9947379
eda83c4
cf22dc6
2e6c5a3
c48a389
3501a64
b85e8d0
337813a
d52c451
1c2b2f6
3d93c60
17903c6
c488c64
5cdff03
bda44d8
9a904fa
ec97322
eb4f05d
bb2e8f2
4f26e96
aa4c64f
52eff39
23453ed
ab3b484
bdb4ad8
b9a6fc4
d5b6f9f
dbb7af5
ac20dc5
12eea28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,24 @@ | ||
| package konkuk.thip.feed.adapter.in.web; | ||
|
|
||
| import konkuk.thip.common.dto.BaseResponse; | ||
| import konkuk.thip.common.security.annotation.UserId; | ||
| import konkuk.thip.feed.adapter.in.web.response.FeedShowAllResponse; | ||
| import konkuk.thip.feed.application.port.in.FeedShowAllUseCase; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| public class FeedQueryController { | ||
|
|
||
| private final FeedShowAllUseCase feedShowAllUseCase; | ||
|
|
||
| @GetMapping("/feeds") | ||
| public BaseResponse<FeedShowAllResponse> showAllFeeds( | ||
| @UserId final Long userId, | ||
| @RequestParam(value = "cursor", required = false) final String cursor) { | ||
| return BaseResponse.ok(feedShowAllUseCase.showAllFeeds(userId, cursor)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package konkuk.thip.feed.adapter.in.web.response; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record FeedShowAllResponse( | ||
| List<FeedDto> feedList, | ||
| String nextCursor, | ||
| boolean isLast | ||
| ) { | ||
| public record FeedDto( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LGTM |
||
| Long feedId, | ||
| Long creatorId, | ||
| String creatorNickname, | ||
| String creatorProfileImageUrl, | ||
| String alias, | ||
| String postDate, | ||
| String isbn, | ||
| String bookTitle, | ||
| String bookAuthor, | ||
| String contentBody, | ||
| String[] contentUrls, | ||
| int likeCount, | ||
| int commentCount, | ||
| boolean isSaved, | ||
| boolean isLiked | ||
| ) { } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,16 @@ | ||
| package konkuk.thip.feed.adapter.out.persistence; | ||
|
|
||
| import konkuk.thip.common.util.Cursor; | ||
| import konkuk.thip.common.util.CursorBasedList; | ||
| import konkuk.thip.feed.adapter.out.mapper.FeedMapper; | ||
| import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; | ||
| import konkuk.thip.feed.application.port.out.FeedQueryPort; | ||
| import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
|
|
||
| @Repository | ||
|
|
@@ -19,4 +24,28 @@ public class FeedQueryPersistenceAdapter implements FeedQueryPort { | |
| public Set<Long> findUserIdsByBookId(Long bookId) { | ||
| return feedJpaRepository.findUserIdsByBookId(bookId); | ||
| } | ||
|
|
||
| @Override | ||
| public CursorBasedList<FeedQueryDto> findFeedsByFollowingPriority(Long userId, Cursor cursor) { | ||
| Integer lastPriority = cursor.isFirstRequest() ? null : cursor.getInteger(0); | ||
| LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(1); | ||
| int size = cursor.getPageSize(); | ||
|
Comment on lines
+31
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 커서 인코딩도 adapter에서 이루어지니까 지금 하신 것처럼 이쪽에서 Cursor를 꺼내서 전달하는 것도 괜찮을 것 같네여 👍🏻
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제 개인적인 생각으로는 Cursor 객체가 너무 많은 layer에서 사용되는 것이 아니라, service에서 request param으로 받은 cursor의 파싱, 그리고 다음 페이징처리를 위해 다시 String cursor로의 인코딩으로만 사용되는게 유지보수하기에 좋지 않나 라고 생각합니다 하지만 시간 관계상 일단 기존 Cursor 코드는 건들지 않았고, adapter에서 QueryDSL 에게 파싱한 커서값을 던져주는식으로 구현하였습니다!! 추후에 Cursor의 역할과 책임에 대해서 한번 얘기해봐도 좋을것같습니다! (일단 api 개발 중에는 지금 코드 고대로 고고하셔도 될듯합니다) |
||
|
|
||
| List<FeedQueryDto> feedQueryDtos = feedJpaRepository.findFeedsByFollowingPriority(userId, lastPriority, lastCreatedAt, size); | ||
|
|
||
| return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { | ||
| Cursor nextCursor = new Cursor(List.of( | ||
| Boolean.TRUE.equals(feedQueryDto.isPriorityFeed()) ? "1" : "0", | ||
| feedQueryDto.createdAt().toString() | ||
| )); | ||
| return nextCursor.toEncodedString(); | ||
| }); | ||
| } | ||
|
|
||
| @Override | ||
| public CursorBasedList<FeedQueryDto> findLatestFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size) { | ||
| List<FeedQueryDto> feedQueryDtos = feedJpaRepository.findLatestFeedsByCreatedAt(userId, lastCreatedAt, size); | ||
|
|
||
| return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> feedQueryDto.createdAt().toString()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,15 @@ | ||
| package konkuk.thip.feed.adapter.out.persistence.repository; | ||
|
|
||
| import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
|
|
||
| public interface FeedQueryRepository { | ||
| Set<Long> findUserIdsByBookId(Long bookId); | ||
| } | ||
|
|
||
| List<FeedQueryDto> findFeedsByFollowingPriority(Long userId, Integer lastPriority, LocalDateTime lastCreatedAt, int size); | ||
|
|
||
| List<FeedQueryDto> findLatestFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,46 @@ | ||
| package konkuk.thip.feed.adapter.out.persistence.repository; | ||
|
|
||
| import com.querydsl.core.Tuple; | ||
| import com.querydsl.core.types.dsl.BooleanExpression; | ||
| import com.querydsl.core.types.dsl.CaseBuilder; | ||
| import com.querydsl.core.types.dsl.Expressions; | ||
| import com.querydsl.core.types.dsl.NumberExpression; | ||
| import com.querydsl.jpa.impl.JPAQueryFactory; | ||
| import konkuk.thip.book.adapter.out.jpa.QBookJpaEntity; | ||
| import konkuk.thip.common.entity.StatusType; | ||
| import konkuk.thip.feed.adapter.out.jpa.ContentJpaEntity; | ||
| import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; | ||
| import konkuk.thip.feed.adapter.out.jpa.QContentJpaEntity; | ||
| import konkuk.thip.feed.adapter.out.jpa.QFeedJpaEntity; | ||
| import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; | ||
| import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity; | ||
| import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity; | ||
| import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.HashSet; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class FeedQueryRepositoryImpl implements FeedQueryRepository { | ||
|
|
||
| private final JPAQueryFactory jpaQueryFactory; | ||
|
|
||
| private final QFeedJpaEntity feed = QFeedJpaEntity.feedJpaEntity; | ||
| private final QContentJpaEntity content = QContentJpaEntity.contentJpaEntity; | ||
| private final QUserJpaEntity user = QUserJpaEntity.userJpaEntity; | ||
| private final QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity; | ||
| private final QBookJpaEntity book = QBookJpaEntity.bookJpaEntity; | ||
| private final QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; | ||
|
|
||
| @Override | ||
| public Set<Long> findUserIdsByBookId(Long bookId) { | ||
| QFeedJpaEntity feed = QFeedJpaEntity.feedJpaEntity; | ||
| Set<Long> userIds = new HashSet<>( | ||
| jpaQueryFactory | ||
| .select(feed.userJpaEntity.userId) | ||
|
|
@@ -27,4 +51,172 @@ public Set<Long> findUserIdsByBookId(Long bookId) { | |
| ); | ||
| return userIds; | ||
| } | ||
|
|
||
| @Override | ||
| public List<FeedQueryDto> findFeedsByFollowingPriority(Long userId, Integer lastPriority, LocalDateTime lastCreatedAt, int size) { | ||
| // 1) 게시글 ID만 우선순위 + 페이징으로 조회 | ||
| List<Tuple> tuples = fetchFeedIdsAndPriorityByFollowingPriority(userId, lastPriority, lastCreatedAt, size); | ||
| if (tuples.isEmpty()) { | ||
| return List.of(); // early return | ||
| } | ||
|
|
||
| // 2) 상세 엔티티를 ID 순으로 조회 후 정렬 | ||
| List<Long> feedIds = tuples.stream() | ||
| .map(tuple -> tuple.get(0, Long.class)) | ||
| .toList(); | ||
| Map<Long, Integer> priorityMap = tuples.stream() | ||
| .collect(Collectors.toMap( | ||
| tuple -> tuple.get(0, Long.class), | ||
| tuple -> tuple.get(1, Integer.class) | ||
| )); | ||
|
Comment on lines
+64
to
+71
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 혹시 이게 어떤 걸 매핑하고 있는 로직이죠..?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "우선순위 + 최신순 조회" 버전의 피드 조회에서는 "유저 본인이 작성한 피드 + 유저가 팔로잉하고 있는 다른 유저가 작성한 공개 피드" 가 우선적으로 보여야 하는데, QueryDSL 코드에서
이런 플로우로 구현하였습니다 기존에는 조회한 결과에 priority 값이 누락되어 있어서 페이징 처리가 제대로 이루어지지 않는 버그가 있었는데, 이를 해결하기 위해 1번에서 현재 페이지에 해당하는 id값만 조회하는게 아니라, Tuple 을 사용하여 id와 priority 값을 한꺼번에 조회하는 식으로 수정했습니다!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아하 확인했습니다~ 💯 성준띠의 고충이 느껴지네여,, |
||
|
|
||
| List<FeedJpaEntity> entities = fetchFeedEntitiesByIds(feedIds); | ||
| Map<Long, FeedJpaEntity> entityMap = entities.stream() | ||
| .collect(Collectors.toMap(FeedJpaEntity::getPostId, e -> e)); | ||
| List<FeedJpaEntity> ordered = feedIds.stream() | ||
| .map(entityMap::get) | ||
| .toList(); | ||
|
|
||
| // 3) DTO 변환 | ||
| return ordered.stream() | ||
| .map(e -> { | ||
| String[] urls = e.getContentList().stream() | ||
| .map(ContentJpaEntity::getContentUrl) | ||
| .toArray(String[]::new); | ||
| boolean isPriority = priorityMap.get(e.getPostId()) == 1; | ||
|
|
||
| return FeedQueryDto.builder() | ||
| .feedId(e.getPostId()) | ||
| .creatorId(e.getUserJpaEntity().getUserId()) | ||
| .creatorNickname(e.getUserJpaEntity().getNickname()) | ||
| .creatorProfileImageUrl(e.getUserJpaEntity().getImageUrl()) | ||
| .alias(e.getUserJpaEntity().getAliasForUserJpaEntity().getValue()) | ||
| .createdAt(e.getCreatedAt()) | ||
| .isbn(e.getBookJpaEntity().getIsbn()) | ||
| .bookTitle(e.getBookJpaEntity().getTitle()) | ||
| .bookAuthor(e.getBookJpaEntity().getAuthorName()) | ||
| .contentBody(e.getContent()) | ||
| .contentUrls(urls) | ||
| .likeCount(e.getLikeCount()) | ||
| .commentCount(e.getCommentCount()) | ||
| .isPriorityFeed(isPriority) | ||
| .build(); | ||
| }) | ||
| .toList(); | ||
| } | ||
|
|
||
| @Override | ||
| public List<FeedQueryDto> findLatestFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size) { | ||
| // 1) 게시글 ID만 최신순 페이징으로 조회 | ||
| List<Long> feedIds = fetchFeedIdsLatest(userId, lastCreatedAt, size); | ||
| if (feedIds.isEmpty()) { | ||
| return List.of(); // early return | ||
| } | ||
|
|
||
| // 2) 상세 엔티티 조회 및 정렬 | ||
| List<FeedJpaEntity> entities = fetchFeedEntitiesByIds(feedIds); | ||
| Map<Long, FeedJpaEntity> entityMap = entities.stream() | ||
| .collect(Collectors.toMap(FeedJpaEntity::getPostId, e -> e)); | ||
| List<FeedJpaEntity> ordered = feedIds.stream() | ||
| .map(entityMap::get) | ||
| .toList(); | ||
|
|
||
| // 3) DTO 변환 | ||
| return ordered.stream() | ||
| .map(e -> { | ||
| String[] urls = e.getContentList().stream() | ||
| .map(ContentJpaEntity::getContentUrl) | ||
| .toArray(String[]::new); | ||
| return FeedQueryDto.builder() | ||
| .feedId(e.getPostId()) | ||
| .creatorId(e.getUserJpaEntity().getUserId()) | ||
| .creatorNickname(e.getUserJpaEntity().getNickname()) | ||
| .creatorProfileImageUrl(e.getUserJpaEntity().getImageUrl()) | ||
| .alias(e.getUserJpaEntity().getAliasForUserJpaEntity().getValue()) | ||
| .createdAt(e.getCreatedAt()) | ||
| .isbn(e.getBookJpaEntity().getIsbn()) | ||
| .bookTitle(e.getBookJpaEntity().getTitle()) | ||
| .bookAuthor(e.getBookJpaEntity().getAuthorName()) | ||
| .contentBody(e.getContent()) | ||
| .contentUrls(urls) | ||
| .likeCount(e.getLikeCount()) | ||
| .commentCount(e.getCommentCount()) | ||
| .build(); | ||
| }) | ||
| .toList(); | ||
| } | ||
|
|
||
| /** | ||
| * ID 목록만 우선순위 & 커서 페이징으로 조회 | ||
| */ | ||
| private List<Tuple> fetchFeedIdsAndPriorityByFollowingPriority(Long userId, Integer lastPriority, LocalDateTime lastCreatedAt, int size) { | ||
| // 내가 작성한 모든 글 + 내가 팔로우하는 다른 유저가 작성한 공개글을 우선적으로 최신순 조회 | ||
| // 이후 내가 팔로우하지 않는 다른 유저가 작성한 공개글을 최신순 조회 | ||
| NumberExpression<Integer> priority = new CaseBuilder() | ||
| .when(feed.userJpaEntity.userId.eq(userId)).then(1) | ||
| .when( | ||
| following.userJpaEntity.userId.eq(userId) | ||
| .and(following.followingUserJpaEntity.userId.eq(feed.userJpaEntity.userId)) | ||
| .and(feed.isPublic.eq(true)) | ||
| ).then(1) | ||
| .otherwise(0); | ||
|
|
||
| // 복합 커서 조건: 우선순위 및 생성일시 기준 | ||
| BooleanExpression cursorCondition = (lastPriority != null && lastCreatedAt != null) | ||
| ? priority.lt(lastPriority) | ||
| .or(priority.eq(lastPriority) | ||
| .and(feed.createdAt.lt(lastCreatedAt))) | ||
| : Expressions.TRUE; | ||
|
|
||
| return jpaQueryFactory | ||
| .select(feed.postId, priority) | ||
| .distinct() | ||
| .from(feed) | ||
| .leftJoin(following) | ||
| .on(following.userJpaEntity.userId.eq(userId) | ||
| .and(following.followingUserJpaEntity.userId.eq(feed.userJpaEntity.userId))) | ||
| .where( | ||
| // ACTIVE 인 feed & (내가 작성한 글 or 다른 유저가 작성한 공개글) & cursorCondition | ||
| feed.status.eq(StatusType.ACTIVE), | ||
| feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.eq(true)), | ||
| cursorCondition | ||
| ) | ||
| .orderBy(priority.desc(), feed.createdAt.desc()) | ||
| .limit(size + 1) | ||
| .fetch(); | ||
| } | ||
|
|
||
| /** | ||
| * ID 목록만 최신순 커서 페이징으로 조회 | ||
| */ | ||
| private List<Long> fetchFeedIdsLatest(Long userId, LocalDateTime lastCreatedAt, int size) { | ||
| return jpaQueryFactory | ||
| .select(feed.postId) | ||
| .distinct() | ||
| .from(feed) | ||
| .where( | ||
| // ACTIVE 인 feed & (내가 작성한 글 or 다른 유저가 작성한 공개글) & cursorCondition | ||
| feed.status.eq(StatusType.ACTIVE), | ||
| feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.eq(true)), | ||
| lastCreatedAt != null ? feed.createdAt.lt(lastCreatedAt) : Expressions.TRUE | ||
| ) | ||
| .orderBy(feed.createdAt.desc()) | ||
| .limit(size + 1) | ||
| .fetch(); | ||
| } | ||
|
|
||
| /** | ||
| * 주어진 ID 목록으로 엔티티를 페치조인 후 조회 | ||
| */ | ||
| private List<FeedJpaEntity> fetchFeedEntitiesByIds(List<Long> ids) { | ||
| return jpaQueryFactory | ||
| .select(feed).distinct() | ||
| .from(feed) | ||
| .leftJoin(feed.contentList, content).fetchJoin() | ||
| .leftJoin(feed.userJpaEntity, user).fetchJoin() | ||
| .leftJoin(user.aliasForUserJpaEntity, alias).fetchJoin() | ||
| .leftJoin(feed.bookJpaEntity, book).fetchJoin() | ||
| .where(feed.postId.in(ids)) | ||
| .fetch(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM