Skip to content
Original file line number Diff line number Diff line change
@@ -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<FeedDetailResponse> prevFeeds,

@Schema(description = "다음 피드 목록 (최신순)")
List<FeedDetailResponse> nextFeeds,

@Schema(description = "더 이전 피드 존재 여부", example = "true")
boolean hasMorePrev,

@Schema(description = "더 다음 피드 존재 여부", example = "true")
boolean hasMoreNext
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,44 @@ private List<LinkedUserResponse> toLinkedUserResponses(List<LinkedUser> linkedUs
.build())
.toList();
}

public FeedNavigationResponse toFeedNavigationResponse(
Feed currentFeed,
List<Feed> prevFeeds,
List<Feed> nextFeeds,
Map<Long, List<Media>> mediaMap,
Map<Long, List<LinkedUser>> linkedUserMap,
boolean hasMorePrev,
boolean hasMoreNext
) {
FeedDetailResponse current = toFeedDetailResponse(
currentFeed,
mediaMap.getOrDefault(currentFeed.getId(), List.of()),
linkedUserMap.getOrDefault(currentFeed.getId(), List.of())
);

List<FeedDetailResponse> prevFeedResponses = prevFeeds.stream()
.map(feed -> toFeedDetailResponse(
feed,
mediaMap.getOrDefault(feed.getId(), List.of()),
linkedUserMap.getOrDefault(feed.getId(), List.of())
))
.toList();

List<FeedDetailResponse> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<UserBlock> blockedUsers = userBlockService.findAllByBlocker(currentUser);

// 이전/다음 피드 조회 (hasMore 정보 포함)
FeedNavigationResult prevResult = feedGetService.findPrevFeedsWithHasMore(
currentFeed, blockedUsers, validatedPrevSize
);
FeedNavigationResult nextResult = feedGetService.findNextFeedsWithHasMore(
currentFeed, blockedUsers, validatedNextSize
);

List<Feed> prevFeeds = prevResult.feeds();
List<Feed> nextFeeds = nextResult.feeds();
boolean hasMorePrev = prevResult.hasMore();
boolean hasMoreNext = nextResult.hasMore();

// 모든 피드의 미디어와 링크된 사용자 조회
List<Feed> allFeeds = new ArrayList<>();
allFeeds.add(currentFeed);
allFeeds.addAll(prevFeeds);
allFeeds.addAll(nextFeeds);

List<Media> allMedias = mediaGetService.findAllByFeeds(allFeeds);
Map<Long, List<Media>> mediaMap = allMedias.stream()
.collect(Collectors.groupingBy(media -> media.getFeed().getId()));

Map<Long, List<LinkedUser>> linkedUserMap = new HashMap<>();
for (Feed feed : allFeeds) {
List<LinkedUser> 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);
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/leets/leenk/domain/feed/domain/entity/Feed.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,4 +22,34 @@ public interface FeedRepository extends JpaRepository<Feed, Long> {

@Query("SELECT f FROM Feed f JOIN FETCH f.user WHERE f.deletedAt IS NULL AND f.id = :id")
Optional<Feed> 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<Feed> findPrevFeeds(
@Param("currentCreateDate") LocalDateTime currentCreateDate,
@Param("blockedUserIds") List<Long> blockedUserIds,
Pageable pageable
Comment on lines +31 to +38
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

컬렉션 파라미터에 null을 넘겨 즉시 실패합니다

FeedGetService.findPrevFeedsWithHasMore/findNextFeedsWithHasMore에서 차단 사용자가 없을 때 blockedUserIds.isEmpty() ? null : blockedUserIds로 이 쿼리를 호출합니다. 하지만 Hibernate 6에서는 컬렉션 파라미터에 null을 바인딩하는 순간 IllegalArgumentException: Collection parameter value must not be null가 발생하여 대부분의 사용자(차단 목록이 비어 있음)에게 네비게이션 API가 바로 500을 뱉게 됩니다. 파라미터를 절대 null로 보내지 않도록 하고(빈 리스트 그대로 전달하거나 분기하여 다른 쿼리를 호출), 필요하다면 이 조건을 단순화해주세요.

예시 수정:

--- a/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java
+++ b/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java
@@
-        List<Feed> feeds = feedRepository.findPrevFeeds(
-                currentFeed.getCreateDate(),
-                blockedUserIds.isEmpty() ? null : blockedUserIds,
-                pageable
-        );
+        List<Long> filteredBlockedIds = blockedUserIds;
+        List<Feed> feeds = feedRepository.findPrevFeeds(
+                currentFeed.getCreateDate(),
+                filteredBlockedIds,
+                pageable
+        );
@@
-        List<Feed> feeds = feedRepository.findNextFeeds(
-                currentFeed.getCreateDate(),
-                blockedUserIds.isEmpty() ? null : blockedUserIds,
-                pageable
-        );
+        List<Long> filteredBlockedIds = blockedUserIds;
+        List<Feed> feeds = feedRepository.findNextFeeds(
+                currentFeed.getCreateDate(),
+                filteredBlockedIds,
+                pageable
+        );

(필요하다면 빈 리스트일 때는 조건 자체를 생략하는 전용 쿼리로 분기해도 됩니다.) 이 수정이 없으면 네비게이션 기능이 정상 동작하지 않습니다.

Also applies to: 46-54

🤖 Prompt for AI Agents
In src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java
around lines 31-38 (and similarly 46-54), the query binds a collection parameter
:blockedUserIds which is sometimes passed as null causing Hibernate 6 to throw
IllegalArgumentException; change the repository methods so they never bind null
for collection parameters — either accept an empty list and adjust the JPQL/SQL
to handle empty collections (e.g., use (:blockedUserIds IS EMPTY OR u.id NOT IN
:blockedUserIds) or move the "u.id NOT IN :blockedUserIds" clause into an
alternate query when blockedUserIds.isEmpty()), or overload/branch to a
different query that omits the blockedUserIds condition when the list is empty;
ensure callers pass Collections.emptyList() instead of null if you keep a single
query.

);

/**
* 현재 피드보다 오래된 피드 조회 (다음 피드)
* 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<Feed> findNextFeeds(
@Param("currentCreateDate") LocalDateTime currentCreateDate,
@Param("blockedUserIds") List<Long> blockedUserIds,
Pageable pageable
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,4 +38,68 @@ public Slice<Feed> findAll(Pageable pageable, List<UserBlock> blockedUser) {
public Slice<Feed> findAllByUser(User user, Pageable pageable) {
return feedRepository.findAllByUserAndDeletedAtIsNull(user, pageable);
}

/**
* 이전 피드 조회 (더 최신) - hasMore 정보 포함
* ASC로 조회한 결과를 최신순(DESC)으로 역정렬하여 반환
* 한 번의 쿼리로 피드 목록과 추가 피드 존재 여부를 함께 반환
*/
public FeedNavigationResult findPrevFeedsWithHasMore(Feed currentFeed, List<UserBlock> blockedUsers, int size) {
Copy link
Member

Choose a reason for hiding this comment

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

이전 피드를 조회하는 찾을 때 반드시 이전에 만들어진 모든 피드를 반환해야하는건가요?
각 피드를 조회할 때마다 이전 하나와 이후 하나의 피드, 총 두개만 추가로 같이 반환해도 가능할 것 같다는 생각이 들었습니다.

즉, 어느 한 피드를 조회할 경우 이전 피드와 다음 피드가 함께 조회되며 만약 이전 피드를 조회하면 그 피드의 바로 이전 피드와 바로 다음 피드(처음에 얘기한 '어느 한 피드'가 곧 '바로 다음 피드'가 되겠죠?)가 같이 조회되도록 하는 것입니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

현재 입력한 값(여기서는 size가 되겟죠) (기본값은 1) 만큼만 조회해서 위아래로 붙여서 반환하고 있습니다!

Copy link
Member

Choose a reason for hiding this comment

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

아 넵 알겠습니다!! 확인했습니다!!

List<Long> blockedUserIds = extractBlockedUserIds(blockedUsers);

// size+1 개를 조회하여 hasMore 판단
Pageable pageable = PageRequest.of(0, size + 1);
List<Feed> 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<UserBlock> blockedUsers, int size) {
Copy link
Member

Choose a reason for hiding this comment

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

마찬가지로 다음 피드를 조회하는 찾을 때 반드시 이후에 만들어진 모든 피드를 반환해야하는건가요?
각 피드를 조회할 때마다 이전 하나와 이후 하나의 피드, 총 두개만 추가로 같이 반환해도 가능할 것 같다는 생각이 들었습니다.

List<Long> blockedUserIds = extractBlockedUserIds(blockedUsers);

// size+1 개를 조회하여 hasMore 판단
Pageable pageable = PageRequest.of(0, size + 1);
List<Feed> 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<Long> extractBlockedUserIds(List<UserBlock> blockedUsers) {
return blockedUsers.stream()
.map(UserBlock::getBlocked)
.map(User::getId)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -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<Feed> feeds,
boolean hasMore
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ public CommonResponse<FeedDetailResponse> getFeedDetail(@PathVariable @Positive
return CommonResponse.success(ResponseCode.GET_FEED_DETAIL, response);
}

@GetMapping("/{feedId}/navigation")
@Operation(
summary = "피드 네비게이션 조회 API (커서 기반 페이지네이션)",
description = "현재 피드를 중심으로 이전/다음 피드의 상세 정보를 함께 조회합니다. " +
"인스타그램/유튜브 쇼츠와 같은 무한 스크롤 구현에 사용됩니다."
)
public CommonResponse<FeedNavigationResponse> 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<Void> uploadFeed(@Parameter(hidden = true) @CurrentUserId Long userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down