Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e692a3c
[feat] 피드 전체 조회 api controller 개발 (#96)
seongjunnoh Jul 24, 2025
7ff5a17
[feat] 피드 전체 조회 api [최신순 조회] use case 구현 (#96)
seongjunnoh Jul 24, 2025
24b61ab
[feat] 피드 전체 조회 api [팔로잉 하는 유저 우선 조회] use case 구현 (#96)
seongjunnoh Jul 24, 2025
7b884c3
[feat] 피드 전체 조회 api [유저 맞춤 피드 조회] use case 정의 (추후 구현) (#96)
seongjunnoh Jul 24, 2025
1265961
[feat] 피드에 대한 query dto, mapper 정의 (#96)
seongjunnoh Jul 24, 2025
139e1a2
[feat] 피드 전체 조회 api adapter 메서드 정의 (#96)
seongjunnoh Jul 24, 2025
9947379
[feat] 피드 전체 조회 api QueryDSL 코드 구현 (#96)
seongjunnoh Jul 24, 2025
eda83c4
[feat] dummy dto 삭제 (#96)
seongjunnoh Jul 24, 2025
cf22dc6
[feat] 테스트용 메서드 jpa entity에 추가 및 @VisibleForTesting 어노테이션 추가 (#96)
seongjunnoh Jul 24, 2025
2e6c5a3
[feat] TestEntityFactory 에 코드 추가 (#96)
seongjunnoh Jul 24, 2025
c48a389
[test] [팔로잉한 유저 우선 조회] 피드 전체 조회 api 통합 테스트 코드 구현 (#96)
seongjunnoh Jul 24, 2025
3501a64
[test] [최신순 조회] 피드 전체 조회 api 통합 테스트 코드 구현 (#96)
seongjunnoh Jul 24, 2025
b85e8d0
[fix] 테스트 코드 에러 수정 (#96)
seongjunnoh Jul 24, 2025
337813a
[fix] 테스트 코드 에러 수정 (#96)
seongjunnoh Jul 24, 2025
d52c451
develop merge (#96)
seongjunnoh Jul 27, 2025
1c2b2f6
[refactor] 전체 피드 조회 api response dto 네이밍 수정 (#96)
seongjunnoh Jul 27, 2025
3d93c60
[chore] 테스트 데이터 팩토리 메서드 주석 추가 (#96)
seongjunnoh Jul 27, 2025
17903c6
[refactor] Feed 조회용 dto인 FeedQueryDto 에 피드에 속하지 않는 속성 제거 및 builer 어노테…
seongjunnoh Jul 27, 2025
c488c64
[refactor] 수정된 FeedQueryDto에 맞춰 QueryDSL 코드 수정 (#96)
seongjunnoh Jul 27, 2025
5cdff03
[refactor] "최신순 조회" 전체 피드 조회 service 코드 수정 (#96)
seongjunnoh Jul 27, 2025
bda44d8
[feat] 유저가 저장한, 좋아하는 피드 id 목록 조회하는 영속성 코드 추가 (#96)
seongjunnoh Jul 27, 2025
9a904fa
[refactor] FeedQueryMapper 수정 (#96)
seongjunnoh Jul 27, 2025
ec97322
[refactor] "팔로잉 하는 유저가 작성한 피드가 우선순위가 높은" 전체 피드 조회 service 코드 수정 (#96)
seongjunnoh Jul 27, 2025
eb4f05d
[refactor] 유저가 저장한 피드, 유저가 좋아하는 피드 id 조회하는 QueryPort 메서드 반환타입 변경 (#96)
seongjunnoh Jul 27, 2025
bb2e8f2
develop merge (unversioned Files 만 머지) (#96)
seongjunnoh Jul 27, 2025
4f26e96
develop merge (나머지 파일들 merge) (#96)
seongjunnoh Jul 27, 2025
aa4c64f
[refactor] no use 메서드 삭제 (#96)
seongjunnoh Jul 27, 2025
52eff39
[refactor] FeedQueryDto에 현재 피드가 우선순위를 가지는 피드인지를 나타내는 필드값 추가 (#96)
seongjunnoh Jul 28, 2025
23453ed
[fix] QueryDSL 코드 수정 (#96)
seongjunnoh Jul 28, 2025
ab3b484
[refactor] FeedQueryPort, adapter 코드 수정 (#96)
seongjunnoh Jul 28, 2025
bdb4ad8
[refactor] QueryDSL 관련 코드 인터페이스 메서드 시그니처 수정 (#96)
seongjunnoh Jul 28, 2025
b9a6fc4
[refactor] "우선순위 + 최신순 조회" 버전 피드 조회 service 코드 수정 (#96)
seongjunnoh Jul 28, 2025
d5b6f9f
[refactor] "우선순위 + 최신순 조회" 버전 피드 조회 test 코드 수정 (#96)
seongjunnoh Jul 28, 2025
dbb7af5
[refactor] 유저가 좋아하는 게시글 조회하는 중복 코드 하나로 합치도록 수정 (#96)
seongjunnoh Jul 28, 2025
ac20dc5
[refactor] 유저가 저장하는 피드 조회 메서드의 시그니처 변경 (#96)
seongjunnoh Jul 28, 2025
12eea28
[refactor] 수정된 영속성 코드에 따라 service 코드 수정 (#96)
seongjunnoh Jul 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/main/java/konkuk/thip/common/entity/BaseJpaEntity.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.google.common.annotations.VisibleForTesting;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
Expand Down Expand Up @@ -38,4 +39,9 @@ public abstract class BaseJpaEntity {
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private StatusType status = StatusType.ACTIVE;
}

@VisibleForTesting
Copy link
Member

Choose a reason for hiding this comment

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

LGTM

protected void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}
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(
Copy link
Contributor

Choose a reason for hiding this comment

The 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,6 +1,7 @@

package konkuk.thip.feed.adapter.out.jpa;

import com.google.common.annotations.VisibleForTesting;
import jakarta.persistence.*;
import konkuk.thip.book.adapter.out.jpa.BookJpaEntity;
import konkuk.thip.feed.domain.Feed;
Expand All @@ -11,6 +12,7 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.List;

@Entity
Expand Down Expand Up @@ -49,4 +51,9 @@ public void updateFrom(Feed feed) {
this.likeCount = feed.getLikeCount();
this.commentCount = feed.getCommentCount();
}

@VisibleForTesting
public void setCreatedAt(LocalDateTime newCreatedAt) {
super.setCreatedAt(newCreatedAt);
}
}
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
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

커서 인코딩도 adapter에서 이루어지니까 지금 하신 것처럼 이쪽에서 Cursor를 꺼내서 전달하는 것도 괜찮을 것 같네여 👍🏻

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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)
Expand All @@ -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
Copy link
Contributor

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.

"우선순위 + 최신순 조회" 버전의 피드 조회에서는 "유저 본인이 작성한 피드 + 유저가 팔로잉하고 있는 다른 유저가 작성한 공개 피드" 가 우선적으로 보여야 하는데, QueryDSL 코드에서

  1. 1차적으로 현재 페이지에 맞는 피드의 id값과 해당 피드의 우선순위(= 0 or 1) 을 sql 쿼리를 통해 조회 -> 이때 조회 결과가 List 입니다
  2. 1에서 조회한 List을 Map<Long, Integer> prioirtyMap 으로 변환
  3. 이후 1에서 조회한 id에 따라 DB에서 dto를 구성하는 정보를 조회한 후, dto로 매핑

이런 플로우로 구현하였습니다

기존에는 조회한 결과에 priority 값이 누락되어 있어서 페이징 처리가 제대로 이루어지지 않는 버그가 있었는데, 이를 해결하기 위해 1번에서 현재 페이지에 해당하는 id값만 조회하는게 아니라, Tuple 을 사용하여 id와 priority 값을 한꺼번에 조회하는 식으로 수정했습니다!

Copy link
Contributor

@buzz0331 buzz0331 Jul 28, 2025

Choose a reason for hiding this comment

The 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();
}
}
Loading