Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ public PageResponseDto<PostResponseDto> getUserDraftPosts(Long userId, PageReque
);
}

public PageResponseDto<PostResponseDto> getRecentPosts(PageRequestDto pageRequest, User user) {
List<Post> posts = postQueryRepository.getRecentPostsWithPaging(pageRequest);

Set<Long> likedPostIds = getLikedPostIds(posts, user);
Set<Long> scrappedPostIds = getScrappedPostIds(posts, user);

return pagingUtils.createPageResponse(
posts,
pageRequest.getValidatedSize(),
post -> toPostResponseDto(post, user, likedPostIds, scrappedPostIds),
Post::getCreatedAt,
Post::getId
);
}

public PageResponseDto<PostResponseDto> getUserScrappedPosts(Long userId, PageRequestDto pageRequest){
List<Post> posts = postQueryRepository.getUserScrappedPostsWithPaging(userId, pageRequest);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ public PageResponseDto<PostResponseDto> getAllPublishedStoryPosts(
return postQueryService.getAllPublishedStoryPosts(request, user);
}

@GetMapping("/recent")
@ResponseStatus(HttpStatus.OK)
public PageResponseDto<PostResponseDto> getRecentPosts(
@AuthenticationPrincipal User user,
@ModelAttribute PageRequestDto request
) {
return postQueryService.getRecentPosts(request, user);
}

@GetMapping("/{userId}/published")
@ResponseStatus(HttpStatus.OK)
public PageResponseDto<PostResponseDto> getUserPublishedPosts(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ public interface PostQueryRepository {
List<Post> getUserScrappedPostsWithPaging(Long userId, PageRequestDto pageRequest);
List<Post> getPostsByComposerIdWithPaging(Long composerId, PageRequestDto pageRequest);
Map<Long, StoryPostStatsDto> findStoryPostStatsByAllComposers();
List<Post> getRecentPostsWithPaging(PageRequestDto pageRequest);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -73,7 +75,21 @@ public List<CurationPost> getAllCurationPostsWithPaging(PageRequestDto pageReque

@Override
public List<StoryPost> getAllStoryPostsWithPaging(PageRequestDto pageRequest) {
return getAllPostsWithPaging(pageRequest, storyPost, storyPost._super);
JPAQuery<StoryPost> query = queryFactory
.selectFrom(storyPost)
.leftJoin(storyPost._super.user, user).fetchJoin()
.leftJoin(storyPost.primaryComposer).fetchJoin()
.where(
storyPost._super.isBlocked.isFalse()
.and(storyPost._super.postStatus.eq(PostStatus.PUBLISHED))
);

return pagingUtils.applyCursorPagination(
query,
pageRequest,
storyPost._super.createdAt,
storyPost._super.id
);
}

@Override
Expand Down Expand Up @@ -212,6 +228,64 @@ public Map<Long, StoryPostStatsDto> findStoryPostStatsByAllComposers() {
));
}

@Override
public List<Post> getRecentPostsWithPaging(PageRequestDto pageRequest) {
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);

List<FreePost> freePosts = queryFactory
.selectFrom(freePost)
.leftJoin(freePost._super.user, user).fetchJoin()
.where(
freePost._super.isBlocked.isFalse()
.and(freePost._super.postStatus.eq(PostStatus.PUBLISHED))
.and(freePost._super.createdAt.goe(sevenDaysAgo))
)
.orderBy(freePost._super.createdAt.desc(), freePost._super.id.desc())
.fetch();

List<StoryPost> storyPosts = queryFactory
.selectFrom(storyPost)
.leftJoin(storyPost._super.user, user).fetchJoin()
.leftJoin(storyPost.primaryComposer).fetchJoin()
.where(
storyPost._super.isBlocked.isFalse()
.and(storyPost._super.postStatus.eq(PostStatus.PUBLISHED))
.and(storyPost._super.createdAt.goe(sevenDaysAgo))
)
.orderBy(storyPost._super.createdAt.desc(), storyPost._super.id.desc())
.fetch();

List<CurationPost> curationPosts = queryFactory
.selectFrom(curationPost)
.leftJoin(curationPost._super.user, user).fetchJoin()
.leftJoin(curationPost.primaryComposer).fetchJoin()
.where(
curationPost._super.isBlocked.isFalse()
.and(curationPost._super.postStatus.eq(PostStatus.PUBLISHED))
.and(curationPost._super.createdAt.goe(sevenDaysAgo))
)
.orderBy(curationPost._super.createdAt.desc(), curationPost._super.id.desc())
.fetch();

List<Post> allPosts = new ArrayList<>();
allPosts.addAll(freePosts);
allPosts.addAll(storyPosts);
allPosts.addAll(curationPosts);

allPosts.sort((p1, p2) -> {
int dateCompare = p2.getCreatedAt().compareTo(p1.getCreatedAt());
if (dateCompare != 0) return dateCompare;
return Long.compare(p2.getId(), p1.getId());
});
Comment on lines +275 to +279

Choose a reason for hiding this comment

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

medium

이 정렬 로직은 getPostsByComposerIdWithPaging 메서드(189-195행)에서도 동일하게 사용되고 있습니다. 코드 중복을 피하고 가독성을 높이기 위해 Comparator를 사용하는 것이 좋습니다. Comparator.comparing()thenComparing()을 연결하여 사용하면 더 선언적이고 읽기 좋은 코드를 작성할 수 있습니다.

        allPosts.sort(java.util.Comparator.comparing(Post::getCreatedAt, java.util.Comparator.reverseOrder())
                .thenComparing(Post::getId, java.util.Comparator.reverseOrder()));


return pagingUtils.applyCursorPaginationToList(
allPosts,
pageRequest,
Post::getCreatedAt,
Post::getId
);
}
Comment on lines +232 to +287

Choose a reason for hiding this comment

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

security-high high

The getRecentPostsWithPaging method fetches all posts from the last 7 days for each post type (FreePost, StoryPost, CurationPost) into memory using .fetch() without any database-level limit. This approach, where application-level sorting and paging are applied after loading all data, can lead to excessive memory consumption and CPU usage, potentially causing an OutOfMemoryError and crashing the application under high load. This constitutes a Denial of Service (DoS) vulnerability.

To remediate this, it is strongly recommended to perform pagination at the database level. A more scalable approach involves:

  1. Querying the base Post entity with cursor-based paging to retrieve a single page of Post entities.
  2. To avoid N+1 queries, group the returned Post entities by their specific type.
  3. For each type group, execute separate queries using WHERE id IN (...) and fetchJoin to retrieve associated relationships. This populates the persistence context, preventing additional queries for related data.

Additionally, the orderBy clauses currently used when fetching data for each post type are unnecessary, as sorting is re-applied in memory. Removing these can reduce database load.


private <T extends Post> List<T> getAllPostsWithPaging(
PageRequestDto pageRequest,
EntityPathBase<T> qEntity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;

import java.time.Instant;
Expand Down Expand Up @@ -77,6 +78,9 @@ public class PostQueryServiceTest extends ServiceTestSupport {
@Autowired
private CommentLikeRepository commentLikeRepository;

@Autowired
private JdbcTemplate jdbcTemplate;

private User user;
private User otherUser;
private List<FreePost> freePosts;
Expand Down Expand Up @@ -1281,5 +1285,174 @@ void getComposerWithPosts_ReturnsOnlyPublishedPosts() {
.doesNotContain("DRAFT 스토리");
}
}

@Nested
@DisplayName("최근 게시물 조회 테스트")
class GetRecentPostsTest {

private Composer composer;

@BeforeEach
void setUpComposer() {
composer = Composer.builder()
.koreanName("베토벤")
.englishName("Ludwig van Beethoven")
.gender(Gender.MALE)
.era(Era.CLASSICAL)
.continent(Continent.EUROPE)
.build();
composerRepository.save(composer);
}

@Test
@DisplayName("7일 이내 PUBLISHED 게시물만 반환된다")
void getRecentPosts_ReturnsOnlyPostsWithinSevenDays() {
// given
FreePost recentPost = FreePost.from(
new PostCreateVo.Free(user, "최근 게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of())
);
FreePost oldPost = FreePost.from(
new PostCreateVo.Free(user, "오래된 게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of())
);
postRepository.save(recentPost);
postRepository.save(oldPost);
jdbcTemplate.update("UPDATE posts SET created_at = ? WHERE id = ?",
Instant.now().minus(8, ChronoUnit.DAYS), oldPost.getId());

PageRequestDto pageRequest = new PageRequestDto(null, 100);

// when
PageResponseDto<PostResponseDto> response = postQueryService.getRecentPosts(pageRequest, null);

// then
assertThat(response.getContent())
.extracting(PostResponseDto::title)
.contains("최근 게시물")
.doesNotContain("오래된 게시물");
}

@Test
@DisplayName("DRAFT 게시물은 반환되지 않는다")
void getRecentPosts_ExcludesDraftPosts() {
// given
FreePost draftPost = FreePost.from(
new PostCreateVo.Free(user, "DRAFT 게시물", "내용", PostStatus.DRAFT,
List.of(), null, List.of())
);
postRepository.save(draftPost);

PageRequestDto pageRequest = new PageRequestDto(null, 100);

// when
PageResponseDto<PostResponseDto> response = postQueryService.getRecentPosts(pageRequest, null);

// then
assertThat(response.getContent())
.extracting(PostResponseDto::title)
.doesNotContain("DRAFT 게시물");
}

@Test
@DisplayName("FreePost, StoryPost, CurationPost 모두 반환된다")
void getRecentPosts_ReturnsAllPostTypes() {
// given
FreePost freePost = FreePost.from(
new PostCreateVo.Free(user, "자유 게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of())
);
StoryPost storyPost = StoryPost.from(
new PostCreateVo.Story(user, "스토리 게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of(), composer)
);
CurationPost curationPost = CurationPost.from(
new PostCreateVo.Curation(user, "큐레이션 게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of(), composer, List.of())
);
postRepository.saveAll(List.of(freePost, storyPost, curationPost));

PageRequestDto pageRequest = new PageRequestDto(null, 100);

// when
PageResponseDto<PostResponseDto> response = postQueryService.getRecentPosts(pageRequest, null);

// then
assertThat(response.getContent())
.extracting(PostResponseDto::title)
.contains("자유 게시물", "스토리 게시물", "큐레이션 게시물");
}

@Test
@DisplayName("StoryPost와 CurationPost 응답에 primaryComposer가 포함된다")
void getRecentPosts_IncludesPrimaryComposerForStoryAndCuration() {
// given
StoryPost storyPost = StoryPost.from(
new PostCreateVo.Story(user, "스토리 게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of(), composer)
);
CurationPost curationPost = CurationPost.from(
new PostCreateVo.Curation(user, "큐레이션 게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of(), composer, List.of())
);
postRepository.saveAll(List.of(storyPost, curationPost));

PageRequestDto pageRequest = new PageRequestDto(null, 100);

// when
PageResponseDto<PostResponseDto> response = postQueryService.getRecentPosts(pageRequest, null);

// then
assertThat(response.getContent())
.filteredOn(p -> p.type() != com.daramg.server.post.domain.PostType.FREE)
.allSatisfy(p -> {
assertThat(p.primaryComposer()).isNotNull();
assertThat(p.primaryComposer().koreanName()).isEqualTo("베토벤");
});
}

@Test
@DisplayName("로그인 유저의 경우 isLiked, isScrapped가 포함된다")
void getRecentPosts_WithLoginUser_IncludesLikedAndScrapped() {
// given
FreePost post = FreePost.from(
new PostCreateVo.Free(user, "게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of())
);
postRepository.save(post);

PostLike like = PostLike.of(post, user);
postLikeRepository.save(like);

PageRequestDto pageRequest = new PageRequestDto(null, 100);

// when
PageResponseDto<PostResponseDto> response = postQueryService.getRecentPosts(pageRequest, user);

// then
assertThat(response.getContent().getFirst().isLiked()).isTrue();
assertThat(response.getContent().getFirst().isScrapped()).isFalse();
}

@Test
@DisplayName("비로그인 유저의 경우 isLiked, isScrapped가 null이다")
void getRecentPosts_WithoutLoginUser_IsLikedAndIsScrappedAreNull() {
// given
FreePost post = FreePost.from(
new PostCreateVo.Free(user, "게시물", "내용", PostStatus.PUBLISHED,
List.of(), null, List.of())
);
postRepository.save(post);

PageRequestDto pageRequest = new PageRequestDto(null, 100);

// when
PageResponseDto<PostResponseDto> response = postQueryService.getRecentPosts(pageRequest, null);

// then
assertThat(response.getContent().getFirst().isLiked()).isNull();
assertThat(response.getContent().getFirst().isScrapped()).isNull();
}
}
}

Loading