diff --git a/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedNavigationResponse.java b/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedNavigationResponse.java new file mode 100644 index 00000000..a2664a82 --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedNavigationResponse.java @@ -0,0 +1,26 @@ +package leets.leenk.domain.feed.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "피드 네비게이션 응답 (커서 기반 페이지네이션)") +public record FeedNavigationResponse( + @Schema(description = "현재 피드 상세 정보") + FeedDetailResponse current, + + @Schema(description = "이전 피드 목록 (최신순)") + List prevFeeds, + + @Schema(description = "다음 피드 목록 (최신순)") + List nextFeeds, + + @Schema(description = "더 이전 피드 존재 여부", example = "true") + boolean hasMorePrev, + + @Schema(description = "더 다음 피드 존재 여부", example = "true") + boolean hasMoreNext +) { +} diff --git a/src/main/java/leets/leenk/domain/feed/application/mapper/FeedMapper.java b/src/main/java/leets/leenk/domain/feed/application/mapper/FeedMapper.java index 079f4e0f..758ff4ca 100644 --- a/src/main/java/leets/leenk/domain/feed/application/mapper/FeedMapper.java +++ b/src/main/java/leets/leenk/domain/feed/application/mapper/FeedMapper.java @@ -109,4 +109,44 @@ private List toLinkedUserResponses(List linkedUs .build()) .toList(); } + + public FeedNavigationResponse toFeedNavigationResponse( + Feed currentFeed, + List prevFeeds, + List nextFeeds, + Map> mediaMap, + Map> linkedUserMap, + boolean hasMorePrev, + boolean hasMoreNext + ) { + FeedDetailResponse current = toFeedDetailResponse( + currentFeed, + mediaMap.getOrDefault(currentFeed.getId(), List.of()), + linkedUserMap.getOrDefault(currentFeed.getId(), List.of()) + ); + + List prevFeedResponses = prevFeeds.stream() + .map(feed -> toFeedDetailResponse( + feed, + mediaMap.getOrDefault(feed.getId(), List.of()), + linkedUserMap.getOrDefault(feed.getId(), List.of()) + )) + .toList(); + + List nextFeedResponses = nextFeeds.stream() + .map(feed -> toFeedDetailResponse( + feed, + mediaMap.getOrDefault(feed.getId(), List.of()), + linkedUserMap.getOrDefault(feed.getId(), List.of()) + )) + .toList(); + + return FeedNavigationResponse.builder() + .current(current) + .prevFeeds(prevFeedResponses) + .nextFeeds(nextFeedResponses) + .hasMorePrev(hasMorePrev) + .hasMoreNext(hasMoreNext) + .build(); + } } diff --git a/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java b/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java index 194dcf57..764db225 100644 --- a/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java +++ b/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java @@ -15,6 +15,7 @@ import leets.leenk.domain.feed.domain.entity.LinkedUser; import leets.leenk.domain.feed.domain.entity.Reaction; import leets.leenk.domain.feed.domain.service.*; +import leets.leenk.domain.feed.domain.service.dto.FeedNavigationResult; import leets.leenk.domain.media.application.mapper.MediaMapper; import leets.leenk.domain.media.domain.entity.Media; import leets.leenk.domain.media.domain.service.MediaDeleteService; @@ -34,16 +35,16 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class FeedUsecase { + private static final int MAX_NAVIGATION_SIZE = 3; + private static final int DEFAULT_NAVIGATION_SIZE = 1; + private final UserGetService userGetService; private final UserBlockService userBlockService; private final SlackWebhookService slackWebhookService; @@ -98,6 +99,77 @@ public FeedDetailResponse getFeedDetail(Long feedId) { return feedMapper.toFeedDetailResponse(feed, medias, linkedUsers); } + @Transactional(readOnly = true) + public FeedNavigationResponse getFeedNavigation( + Long feedId, + Long currentUserId, + Integer prevSize, + Integer nextSize + ) { + // 파라미터 검증 및 기본값 설정 + int validatedPrevSize = validateSize(prevSize, DEFAULT_NAVIGATION_SIZE, MAX_NAVIGATION_SIZE); + int validatedNextSize = validateSize(nextSize, DEFAULT_NAVIGATION_SIZE, MAX_NAVIGATION_SIZE); + + // 현재 피드 조회 + Feed currentFeed = feedGetService.findById(feedId); + + // 차단 사용자 목록 조회 + User currentUser = userGetService.findById(currentUserId); + List blockedUsers = userBlockService.findAllByBlocker(currentUser); + + // 이전/다음 피드 조회 (hasMore 정보 포함) + FeedNavigationResult prevResult = feedGetService.findPrevFeedsWithHasMore( + currentFeed, blockedUsers, validatedPrevSize + ); + FeedNavigationResult nextResult = feedGetService.findNextFeedsWithHasMore( + currentFeed, blockedUsers, validatedNextSize + ); + + List prevFeeds = prevResult.feeds(); + List nextFeeds = nextResult.feeds(); + boolean hasMorePrev = prevResult.hasMore(); + boolean hasMoreNext = nextResult.hasMore(); + + // 모든 피드의 미디어와 링크된 사용자 조회 + List allFeeds = new ArrayList<>(); + allFeeds.add(currentFeed); + allFeeds.addAll(prevFeeds); + allFeeds.addAll(nextFeeds); + + List allMedias = mediaGetService.findAllByFeeds(allFeeds); + Map> mediaMap = allMedias.stream() + .collect(Collectors.groupingBy(media -> media.getFeed().getId())); + + Map> linkedUserMap = new HashMap<>(); + for (Feed feed : allFeeds) { + List linkedUsers = linkedUserGetService.findAll(feed); + linkedUserMap.put(feed.getId(), linkedUsers); + } + + return feedMapper.toFeedNavigationResponse( + currentFeed, + prevFeeds, + nextFeeds, + mediaMap, + linkedUserMap, + hasMorePrev, + hasMoreNext + ); + } + + private int validateSize(Integer size, int defaultValue, int maxValue) { + if (size == null) { + return defaultValue; + } + if (size < 0) { + return 0; + } + if (size > maxValue) { + return maxValue; + } + return size; + } + @Transactional public void uploadFeed(long userId, FeedUploadRequest request) { User author = userGetService.findById(userId); diff --git a/src/main/java/leets/leenk/domain/feed/domain/entity/Feed.java b/src/main/java/leets/leenk/domain/feed/domain/entity/Feed.java index 768e94be..7b3922fb 100644 --- a/src/main/java/leets/leenk/domain/feed/domain/entity/Feed.java +++ b/src/main/java/leets/leenk/domain/feed/domain/entity/Feed.java @@ -13,7 +13,15 @@ @Getter @Entity @SuperBuilder -@Table(name="feeds") +@Table( + name = "feeds", + indexes = { + // 피드 네비게이션 조회 최적화: createDate 기준 정렬 + deletedAt 필터링 + @Index(name = "idx_feeds_created_deleted", columnList = "create_date, deleted_at"), + // 사용자별 피드 조회 최적화: user_id + createDate 정렬 + @Index(name = "idx_feeds_user_created", columnList = "user_id, create_date") + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Feed extends BaseEntity { diff --git a/src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java b/src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java index 37068a68..7af872aa 100644 --- a/src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java +++ b/src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java @@ -6,7 +6,9 @@ import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -20,4 +22,34 @@ public interface FeedRepository extends JpaRepository { @Query("SELECT f FROM Feed f JOIN FETCH f.user WHERE f.deletedAt IS NULL AND f.id = :id") Optional findByDeletedAtIsNullAndId(Long id); + + /** + * 현재 피드보다 최신인 피드 조회 (이전 피드) + * createDate > currentCreateDate 조건으로 더 최근 피드를 ASC 정렬로 조회 + */ + @Query("SELECT f FROM Feed f JOIN FETCH f.user u " + + "WHERE f.createDate > :currentCreateDate " + + "AND f.deletedAt IS NULL " + + "AND (:blockedUserIds IS NULL OR u.id NOT IN :blockedUserIds) " + + "ORDER BY f.createDate ASC") + List findPrevFeeds( + @Param("currentCreateDate") LocalDateTime currentCreateDate, + @Param("blockedUserIds") List blockedUserIds, + Pageable pageable + ); + + /** + * 현재 피드보다 오래된 피드 조회 (다음 피드) + * createDate < currentCreateDate 조건으로 더 오래된 피드를 DESC 정렬로 조회 + */ + @Query("SELECT f FROM Feed f JOIN FETCH f.user u " + + "WHERE f.createDate < :currentCreateDate " + + "AND f.deletedAt IS NULL " + + "AND (:blockedUserIds IS NULL OR u.id NOT IN :blockedUserIds) " + + "ORDER BY f.createDate DESC") + List findNextFeeds( + @Param("currentCreateDate") LocalDateTime currentCreateDate, + @Param("blockedUserIds") List blockedUserIds, + Pageable pageable + ); } diff --git a/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java b/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java index 94d204a6..82de5787 100644 --- a/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java +++ b/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java @@ -3,13 +3,16 @@ import leets.leenk.domain.feed.application.exception.FeedNotFoundException; import leets.leenk.domain.feed.domain.entity.Feed; import leets.leenk.domain.feed.domain.repository.FeedRepository; +import leets.leenk.domain.feed.domain.service.dto.FeedNavigationResult; import leets.leenk.domain.user.domain.entity.User; import leets.leenk.domain.user.domain.entity.UserBlock; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; +import java.util.Collections; import java.util.List; @Service @@ -35,4 +38,68 @@ public Slice findAll(Pageable pageable, List blockedUser) { public Slice findAllByUser(User user, Pageable pageable) { return feedRepository.findAllByUserAndDeletedAtIsNull(user, pageable); } + + /** + * 이전 피드 조회 (더 최신) - hasMore 정보 포함 + * ASC로 조회한 결과를 최신순(DESC)으로 역정렬하여 반환 + * 한 번의 쿼리로 피드 목록과 추가 피드 존재 여부를 함께 반환 + */ + public FeedNavigationResult findPrevFeedsWithHasMore(Feed currentFeed, List blockedUsers, int size) { + List blockedUserIds = extractBlockedUserIds(blockedUsers); + + // size+1 개를 조회하여 hasMore 판단 + Pageable pageable = PageRequest.of(0, size + 1); + List feeds = feedRepository.findPrevFeeds( + currentFeed.getCreateDate(), + blockedUserIds.isEmpty() ? null : blockedUserIds, + pageable + ); + + // hasMore 판단 + boolean hasMore = feeds.size() > size; + + // size보다 많으면 잘라내기 + if (hasMore) { + feeds = feeds.subList(0, size); + } + + // ASC로 조회했으므로 최신순으로 역정렬 + Collections.reverse(feeds); + + return new FeedNavigationResult(feeds, hasMore); + } + + /** + * 다음 피드 조회 (더 오래된) - hasMore 정보 포함 + * DESC로 조회하므로 그대로 반환 + * 한 번의 쿼리로 피드 목록과 추가 피드 존재 여부를 함께 반환 + */ + public FeedNavigationResult findNextFeedsWithHasMore(Feed currentFeed, List blockedUsers, int size) { + List blockedUserIds = extractBlockedUserIds(blockedUsers); + + // size+1 개를 조회하여 hasMore 판단 + Pageable pageable = PageRequest.of(0, size + 1); + List feeds = feedRepository.findNextFeeds( + currentFeed.getCreateDate(), + blockedUserIds.isEmpty() ? null : blockedUserIds, + pageable + ); + + // hasMore 판단 + boolean hasMore = feeds.size() > size; + + // size보다 많으면 잘라내기 + if (hasMore) { + feeds = feeds.subList(0, size); + } + + return new FeedNavigationResult(feeds, hasMore); + } + + private List extractBlockedUserIds(List blockedUsers) { + return blockedUsers.stream() + .map(UserBlock::getBlocked) + .map(User::getId) + .toList(); + } } diff --git a/src/main/java/leets/leenk/domain/feed/domain/service/dto/FeedNavigationResult.java b/src/main/java/leets/leenk/domain/feed/domain/service/dto/FeedNavigationResult.java new file mode 100644 index 00000000..00715b80 --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/domain/service/dto/FeedNavigationResult.java @@ -0,0 +1,15 @@ +package leets.leenk.domain.feed.domain.service.dto; + +import leets.leenk.domain.feed.domain.entity.Feed; + +import java.util.List; + +/** + * 피드 네비게이션 조회 결과 + * 조회된 피드 목록과 추가 피드 존재 여부를 함께 반환 + */ +public record FeedNavigationResult( + List feeds, + boolean hasMore +) { +} diff --git a/src/main/java/leets/leenk/domain/feed/presentation/FeedController.java b/src/main/java/leets/leenk/domain/feed/presentation/FeedController.java index f6f65e7b..94515a30 100644 --- a/src/main/java/leets/leenk/domain/feed/presentation/FeedController.java +++ b/src/main/java/leets/leenk/domain/feed/presentation/FeedController.java @@ -44,6 +44,29 @@ public CommonResponse getFeedDetail(@PathVariable @Positive return CommonResponse.success(ResponseCode.GET_FEED_DETAIL, response); } + @GetMapping("/{feedId}/navigation") + @Operation( + summary = "피드 네비게이션 조회 API (커서 기반 페이지네이션)", + description = "현재 피드를 중심으로 이전/다음 피드의 상세 정보를 함께 조회합니다. " + + "인스타그램/유튜브 쇼츠와 같은 무한 스크롤 구현에 사용됩니다." + ) + public CommonResponse getFeedNavigation( + @PathVariable @Positive long feedId, + @Parameter(hidden = true) @CurrentUserId Long userId, + @RequestParam(required = false) + @Parameter(description = "이전 피드 개수 (0~3)", example = "1") + Integer prevSize, + @RequestParam(required = false) + @Parameter(description = "다음 피드 개수 (0~3)", example = "1") + Integer nextSize + ) { + FeedNavigationResponse response = feedUsecase.getFeedNavigation( + feedId, userId, prevSize, nextSize + ); + + return CommonResponse.success(ResponseCode.GET_FEED_NAVIGATION, response); + } + @PostMapping @Operation(summary = "피드 업로드 API") public CommonResponse uploadFeed(@Parameter(hidden = true) @CurrentUserId Long userId, diff --git a/src/main/java/leets/leenk/domain/feed/presentation/ResponseCode.java b/src/main/java/leets/leenk/domain/feed/presentation/ResponseCode.java index cf2f3dd2..6055450d 100644 --- a/src/main/java/leets/leenk/domain/feed/presentation/ResponseCode.java +++ b/src/main/java/leets/leenk/domain/feed/presentation/ResponseCode.java @@ -23,7 +23,10 @@ public enum ResponseCode implements ResponseCodeInterface { GET_ALL_USERS(1210, HttpStatus.OK, "함께한 유저 추가를 위한 사용자 조회에 성공했습니다."), DELETE_FEED(1211, HttpStatus.OK, "피드 삭제에 성공했습니다."), - REPORT_FEED(1212, HttpStatus.OK, "피드 신고에 성공했습니다."); + REPORT_FEED(1212, HttpStatus.OK, "피드 신고에 성공했습니다."), + + GET_FEED_NAVIGATION(1213, HttpStatus.OK, "피드 네비게이션 조회에 성공했습니다."); + private final int code; private final HttpStatus status;