diff --git a/build.gradle b/build.gradle index db04ad14..ca43328a 100644 --- a/build.gradle +++ b/build.gradle @@ -24,11 +24,31 @@ repositories { maven { url 'https://jitpack.io' } } +// QueryDSL +def generatedDir = "$buildDir/generated/querydsl" + +sourceSets { + main.java.srcDirs += [generatedDir] +} + +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(generatedDir)) +} + +clean.doLast { + file(generatedDir).deleteDir() +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-batch' + // QueryDSL Dependencies + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' // 채팅 웹 소켓 implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' @@ -42,7 +62,7 @@ dependencies { //Flyway implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' - + implementation 'com.fasterxml.jackson.core:jackson-databind' testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' compileOnly 'org.projectlombok:lombok' diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/team/buddyya/common/config/QueryDslConfig.java b/src/main/java/com/team/buddyya/common/config/QueryDslConfig.java new file mode 100644 index 00000000..79f97dd5 --- /dev/null +++ b/src/main/java/com/team/buddyya/common/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package com.team.buddyya.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/team/buddyya/feed/controller/FeedController.java b/src/main/java/com/team/buddyya/feed/controller/FeedController.java index 5eefcf4a..091fc64e 100644 --- a/src/main/java/com/team/buddyya/feed/controller/FeedController.java +++ b/src/main/java/com/team/buddyya/feed/controller/FeedController.java @@ -2,6 +2,7 @@ import com.team.buddyya.auth.domain.CustomUserDetails; import com.team.buddyya.feed.dto.request.feed.FeedCreateRequest; +import com.team.buddyya.feed.dto.request.feed.FeedCursorListRequest; import com.team.buddyya.feed.dto.request.feed.FeedListRequest; import com.team.buddyya.feed.dto.request.feed.FeedUpdateRequest; import com.team.buddyya.feed.dto.response.BookmarkResponse; @@ -45,6 +46,17 @@ public ResponseEntity getFeeds(@AuthenticationPrincipal Custom return ResponseEntity.ok(response); } + @GetMapping("/cursor") + public ResponseEntity getFeedsByCursor( + @AuthenticationPrincipal CustomUserDetails userDetails, + @ModelAttribute FeedCursorListRequest request) { + FeedListResponse response = feedService.getFeedsByCursor( + userDetails.getStudentInfo(), + request + ); + return ResponseEntity.ok(response); + } + @GetMapping("/popular") public ResponseEntity getPopularFeeds( @AuthenticationPrincipal CustomUserDetails userDetails, diff --git a/src/main/java/com/team/buddyya/feed/dto/request/feed/FeedCursorListRequest.java b/src/main/java/com/team/buddyya/feed/dto/request/feed/FeedCursorListRequest.java new file mode 100644 index 00000000..faab077b --- /dev/null +++ b/src/main/java/com/team/buddyya/feed/dto/request/feed/FeedCursorListRequest.java @@ -0,0 +1,10 @@ +package com.team.buddyya.feed.dto.request.feed; + +public record FeedCursorListRequest( + String university, + String category, + String keyword, + Long lastId, + Integer pageSize +) { +} diff --git a/src/main/java/com/team/buddyya/feed/dto/response/feed/FeedListResponse.java b/src/main/java/com/team/buddyya/feed/dto/response/feed/FeedListResponse.java index 13111c8e..01c5d4bc 100644 --- a/src/main/java/com/team/buddyya/feed/dto/response/feed/FeedListResponse.java +++ b/src/main/java/com/team/buddyya/feed/dto/response/feed/FeedListResponse.java @@ -3,6 +3,7 @@ import com.team.buddyya.feed.domain.Feed; import java.util.List; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; public record FeedListResponse( List feeds, @@ -19,4 +20,13 @@ public static FeedListResponse from(List feeds, Page feedInf feedInfo.hasNext() ); } + + public static FeedListResponse from(List feeds, Slice sliceInfo) { + return new FeedListResponse( + feeds, + -1, + -1, + sliceInfo.hasNext() + ); + } } diff --git a/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java b/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java index 2d2e3ea4..a0b08546 100644 --- a/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java @@ -13,7 +13,7 @@ import org.springframework.stereotype.Repository; @Repository -public interface FeedRepository extends JpaRepository { +public interface FeedRepository extends JpaRepository, FeedRepositoryCustom { @Override @EntityGraph(attributePaths = { diff --git a/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryCustom.java b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryCustom.java new file mode 100644 index 00000000..b1b11d28 --- /dev/null +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.team.buddyya.feed.repository; + +import com.team.buddyya.feed.domain.Feed; +import com.team.buddyya.feed.dto.request.feed.FeedCursorListRequest; +import org.springframework.data.domain.Slice; + +public interface FeedRepositoryCustom { + + Slice findFeedsByCursor(FeedCursorListRequest request, Feed lastFeed); +} diff --git a/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java new file mode 100644 index 00000000..e0b30b09 --- /dev/null +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java @@ -0,0 +1,75 @@ +package com.team.buddyya.feed.repository; + +import static com.team.buddyya.feed.domain.QFeed.feed; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.team.buddyya.feed.domain.Feed; +import com.team.buddyya.feed.dto.request.feed.FeedCursorListRequest; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +@RequiredArgsConstructor +public class FeedRepositoryImpl implements FeedRepositoryCustom { + + private static final int DEFAULT_PAGE_SIZE = 10; + private final JPAQueryFactory queryFactory; + + @Override + public Slice findFeedsByCursor(FeedCursorListRequest request, Feed lastFeed) { + int pageSize = (request.pageSize() != null && request.pageSize() > 0) ? request.pageSize() : DEFAULT_PAGE_SIZE; + List feeds = queryFactory + .selectFrom(feed) + .where( + cursorCondition(lastFeed), + eqUniversity(request.university()), + eqCategory(request.category()) + ) + .orderBy(feed.pinned.desc(), feed.createdDate.desc(), feed.id.desc()) + .limit(pageSize + 1) + .fetch(); + return toSlice(feeds, pageSize); + } + + private BooleanExpression cursorCondition(Feed lastFeed) { + if (lastFeed == null) { + return null; + } + BooleanExpression pinnedExpression = feed.pinned; + boolean lastPinned = lastFeed.isPinned(); + LocalDateTime lastCreatedAt = lastFeed.getCreatedDate(); + Long lastId = lastFeed.getId(); + return pinnedExpression.lt(lastPinned) + .or(pinnedExpression.eq(lastPinned) + .and(feed.createdDate.lt(lastCreatedAt))) + .or(pinnedExpression.eq(lastPinned) + .and(feed.createdDate.eq(lastCreatedAt)) + .and(feed.id.lt(lastId))); + } + + private BooleanExpression eqUniversity(String university) { + if (university == null || university.isBlank()) { + return null; + } + return feed.university.universityName.eq(university); + } + + private BooleanExpression eqCategory(String category) { + if (category == null || category.isBlank()) { + return null; + } + return feed.category.name.eq(category); + } + + private Slice toSlice(List feeds, int pageSize) { + boolean hasNext = feeds.size() > pageSize; + if (hasNext) { + feeds.remove(pageSize); + } + return new SliceImpl<>(feeds, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/team/buddyya/feed/service/FeedService.java b/src/main/java/com/team/buddyya/feed/service/FeedService.java index 9a7aafb8..a0132fdf 100644 --- a/src/main/java/com/team/buddyya/feed/service/FeedService.java +++ b/src/main/java/com/team/buddyya/feed/service/FeedService.java @@ -8,6 +8,7 @@ import com.team.buddyya.feed.domain.FeedUserAction; import com.team.buddyya.feed.dto.projection.FeedAuthorInfo; import com.team.buddyya.feed.dto.request.feed.FeedCreateRequest; +import com.team.buddyya.feed.dto.request.feed.FeedCursorListRequest; import com.team.buddyya.feed.dto.request.feed.FeedListRequest; import com.team.buddyya.feed.dto.request.feed.FeedUpdateRequest; import com.team.buddyya.feed.dto.response.feed.FeedListResponse; @@ -38,6 +39,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -85,39 +87,30 @@ public FeedListResponse getFeeds(StudentInfo studentInfo, Pageable pageable, Fee Page feeds = (request.keyword() == null || request.keyword().isBlank()) ? getFeedsByUniversityAndCategory(request, pageable) : getFeedsByKeyword(student, request.keyword(), pageable); + return createFeedListResponse(feeds, studentInfo); + } - Set blockedStudentIds = blockRepository.findBlockedStudentIdByBlockerId(studentInfo.id()); - List feedIds = feeds.getContent().stream() - .map(Feed::getId) - .toList(); - Set likedFeedIds = feedLikeRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Set bookmarkedFeedIds = bookmarkRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Map authorInfoMap = getFeedAuthorInfoMap(feeds.getContent()); - List response = filterBlockedFeeds(feeds.getContent(), blockedStudentIds, student, likedFeedIds, - bookmarkedFeedIds, authorInfoMap); - return FeedListResponse.from(response, feeds); + @Transactional(readOnly = true) + public FeedListResponse getFeedsByCursor(StudentInfo studentInfo, FeedCursorListRequest request) { + Feed lastFeed = request.lastId() != null ? + feedRepository.findById(request.lastId()) + .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_FOUND)) + : null; + Slice feedSlice = feedRepository.findFeedsByCursor(request, lastFeed); + return createFeedListResponse(feedSlice, studentInfo); } + @Transactional(readOnly = true) public FeedListResponse getPopularFeeds( StudentInfo studentInfo, Pageable pageable, FeedListRequest request ) { - Student student = findStudentByStudentId(studentInfo.id()); University university = findUniversityByUniversityName(request.university()); Page feeds = feedRepository.findByLikeCountGreaterThanEqualAndUniversity(LIKE_COUNT_THRESHOLD, university, pageable); - Set blockedStudentIds = blockRepository.findBlockedStudentIdByBlockerId(studentInfo.id()); - List feedIds = feeds.getContent().stream() - .map(Feed::getId) - .toList(); - Set likedFeedIds = feedLikeRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Set bookmarkedFeedIds = bookmarkRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Map authorInfoMap = getFeedAuthorInfoMap(feeds.getContent()); - List response = filterBlockedFeeds(feeds.getContent(), blockedStudentIds, student, likedFeedIds, - bookmarkedFeedIds, authorInfoMap); - return FeedListResponse.from(response, feeds); + return createFeedListResponse(feeds, studentInfo); } @Transactional(readOnly = true) @@ -152,17 +145,40 @@ public FeedListResponse getBookmarkFeed(StudentInfo studentInfo, Pageable pageab Student student = findStudentByStudentId(studentInfo.id()); Page bookmarks = bookmarkRepository.findAllByStudent(student, pageable); Page feeds = bookmarks.map(Bookmark::getFeed); - Set blockedStudentIds = blockRepository.findBlockedStudentIdByBlockerId(studentInfo.id()); - List feedIds = feeds.getContent().stream() - .map(Feed::getId) - .toList(); + return createFeedListResponse(feeds, studentInfo); + } + + private FeedListResponse createFeedListResponse(Page feedPage, StudentInfo studentInfo) { + List feedResponses = buildFeedResponses(feedPage.getContent(), studentInfo); + return FeedListResponse.from(feedResponses, feedPage); + } + + private FeedListResponse createFeedListResponse(Slice feedSlice, StudentInfo studentInfo) { + List feedResponses = buildFeedResponses(feedSlice.getContent(), studentInfo); + return FeedListResponse.from(feedResponses, feedSlice); + } + private List buildFeedResponses(List feeds, StudentInfo studentInfo) { + if (feeds.isEmpty()) { + return List.of(); + } + Student student = findStudentByStudentId(studentInfo.id()); + Set blockedStudentIds = blockRepository.findBlockedStudentIdByBlockerId(studentInfo.id()); + List feedIds = feeds.stream().map(Feed::getId).toList(); Set likedFeedIds = feedLikeRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); Set bookmarkedFeedIds = bookmarkRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Map authorInfoMap = getFeedAuthorInfoMap(feeds.getContent()); - List response = filterBlockedFeeds(feeds.getContent(), blockedStudentIds, student, likedFeedIds, - bookmarkedFeedIds, authorInfoMap); - return FeedListResponse.from(response, feeds); + Map authorInfoMap = getFeedAuthorInfoMap(feeds); + return feeds.stream() + .filter(feed -> !blockedStudentIds.contains(feed.getStudent().getId())) + .map(feed -> { + boolean isFeedOwner = feed.isFeedOwner(student.getId()); + boolean isLiked = likedFeedIds.contains(feed.getId()); + boolean isBookmarked = bookmarkedFeedIds.contains(feed.getId()); + FeedUserAction userAction = FeedUserAction.from(isFeedOwner, isLiked, isBookmarked); + FeedAuthorInfo authorInfo = authorInfoMap.get(feed.getStudent().getId()); + return FeedResponse.from(feed, userAction, authorInfo); + }) + .toList(); } @Transactional(readOnly = true) @@ -232,23 +248,6 @@ public void togglePin(StudentInfo studentInfo, Long feedId) { feed.togglePin(); } - private List filterBlockedFeeds(List feeds, Set blockedStudentIds, - Student currentStudent, Set likedFeedIds, - Set bookmarkedFeedIds, - Map authorInfoMap) { - return feeds.stream() - .filter(feed -> !blockedStudentIds.contains(feed.getStudent().getId())) - .map(feed -> { - boolean isFeedOwner = feed.isFeedOwner(currentStudent.getId()); - boolean isLiked = likedFeedIds.contains(feed.getId()); - boolean isBookmarked = bookmarkedFeedIds.contains(feed.getId()); - FeedUserAction userAction = FeedUserAction.from(isFeedOwner, isLiked, isBookmarked); - FeedAuthorInfo authorInfo = authorInfoMap.get(feed.getStudent().getId()); - return FeedResponse.from(feed, userAction, authorInfo); - }) - .toList(); - } - private Map getFeedAuthorInfoMap(List feeds) { Set studentIds = feeds.stream() .map(feed -> feed.getStudent().getId()) diff --git a/src/main/resources/db/migration/V32__Add_feed_index.sql b/src/main/resources/db/migration/V32__Add_feed_index.sql new file mode 100644 index 00000000..f708f251 --- /dev/null +++ b/src/main/resources/db/migration/V32__Add_feed_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_feed_pagination ON feed (university_id, category_id, pinned DESC, created_date DESC, id DESC);