From 66063c29a2e7ed34cad13c6a9092379b5f3af2dc Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:08:44 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Comment:=20DTO=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/post/dto/PostDetailResponse.java | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/back/domain/board/post/dto/PostDetailResponse.java b/src/main/java/com/back/domain/board/post/dto/PostDetailResponse.java index ac8701fe..e9c66150 100644 --- a/src/main/java/com/back/domain/board/post/dto/PostDetailResponse.java +++ b/src/main/java/com/back/domain/board/post/dto/PostDetailResponse.java @@ -9,17 +9,19 @@ /** * 게시글 상세 응답 DTO * - * @param postId 게시글 ID - * @param author 작성자 정보 - * @param title 게시글 제목 - * @param content 게시글 내용 - * @param thumbnailUrl 썸네일 URL - * @param categories 게시글 카테고리 목록 - * @param likeCount 좋아요 수 - * @param bookmarkCount 북마크 수 - * @param commentCount 댓글 수 - * @param createdAt 게시글 생성 일시 - * @param updatedAt 게시글 수정 일시 + * @param postId 게시글 ID + * @param author 작성자 정보 + * @param title 게시글 제목 + * @param content 게시글 내용 + * @param thumbnailUrl 썸네일 URL + * @param categories 게시글 카테고리 목록 + * @param likeCount 좋아요 수 + * @param bookmarkCount 북마크 수 + * @param commentCount 댓글 수 + * @param likedByMe 좋아요 여부 + * @param bookmarkedByMe 북마크 여부 + * @param createdAt 게시글 생성 일시 + * @param updatedAt 게시글 수정 일시 */ public record PostDetailResponse( Long postId, From d2ad3d9a2556984f0d16750bede1ce498b5bb3fd Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:10:05 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Ref:=20=EB=82=B4=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20Query=20D?= =?UTF-8?q?SL=20=EA=B8=B0=EB=B0=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/post/repository/PostRepository.java | 3 - .../custom/PostRepositoryCustom.java | 1 + .../repository/custom/PostRepositoryImpl.java | 46 ++++++++-- .../back/domain/user/service/UserService.java | 5 +- .../custom/PostRepositoryImplTest.java | 87 +++++++++++++++++++ 5 files changed, 131 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/back/domain/board/post/repository/PostRepository.java b/src/main/java/com/back/domain/board/post/repository/PostRepository.java index 9d338f4f..7aafead2 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostRepository.java +++ b/src/main/java/com/back/domain/board/post/repository/PostRepository.java @@ -2,12 +2,9 @@ import com.back.domain.board.post.entity.Post; import com.back.domain.board.post.repository.custom.PostRepositoryCustom; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface PostRepository extends JpaRepository, PostRepositoryCustom { - Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryCustom.java b/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryCustom.java index febb6d37..ae26e723 100644 --- a/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryCustom.java +++ b/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryCustom.java @@ -8,4 +8,5 @@ public interface PostRepositoryCustom { Page searchPosts(String keyword, String searchType, List categoryIds, Pageable pageable); + Page findPostsByUserId(Long userId, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryImpl.java b/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryImpl.java index 4445a1f5..b3c21589 100644 --- a/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryImpl.java +++ b/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryImpl.java @@ -13,7 +13,6 @@ import com.querydsl.core.Tuple; import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; -import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.PathBuilder; import com.querydsl.jpa.JPAExpressions; @@ -37,11 +36,11 @@ public class PostRepositoryImpl implements PostRepositoryCustom { /** * 게시글 다건 검색 * - 총 쿼리 수: 3회 - * 1. 게시글 목록 조회 (User, UserProfile join) - * 2. 카테고리 목록 조회 (IN 쿼리) - * 3. 전체 count 조회 + * 1. 게시글 목록 조회 (User, UserProfile join) + * 2. 카테고리 목록 조회 (IN 쿼리) + * 3. 전체 count 조회 * - categoryIds 포함 시, 총 쿼리 수: 4회 - * 4. CategoryType 매핑 조회 추가 (buildWhere 내부) + * 4. CategoryType 매핑 조회 추가 (buildWhere 내부) * * @param keyword 검색 키워드 * @param searchType 검색 유형(title/content/author/전체) @@ -73,6 +72,43 @@ public Page searchPosts(String keyword, String searchType, Lis return new PageImpl<>(posts, pageable, total); } + /** + * 내 게시글 목록 조회 + * - 총 쿼리 수: 3회 + * 1. 게시글 목록 조회 (User, UserProfile join) + * 2. 카테고리 목록 조회 (IN 쿼리) + * 3. 전체 count 조회 + * + * @param userId 사용자 ID + * @param pageable 페이징 + 정렬 조건 + */ + @Override + public Page findPostsByUserId(Long userId, Pageable pageable) { + QPost post = QPost.post; + + // 1. 검색 조건 생성 + BooleanBuilder where = new BooleanBuilder(post.user.id.eq(userId)); + + // 2. 정렬 조건 생성 (화이트리스트 기반) + List> orders = buildOrderSpecifiers(pageable); + + // 3. 게시글 목록 조회 (User, UserProfile join으로 N+1 방지) + List posts = fetchPosts(where, orders, pageable); + + // 결과가 없으면 즉시 빈 페이지 반환 + if (posts.isEmpty()) { + return new PageImpl<>(posts, pageable, 0); + } + + // 4. 카테고리 목록 주입 (postIds 기반 IN 쿼리 1회) + injectCategories(posts); + + // 5. 전체 게시글 수 조회 + long total = countPosts(where); + + return new PageImpl<>(posts, pageable, total); + } + // -------------------- 내부 메서드 -------------------- /** diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 33051f45..ea3f8d69 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -137,7 +137,6 @@ public void deleteUser(Long userId) { } } - // TODO: 내 게시글/댓글/북마크 목록 조회 N+1 발생 가능, 추후 리팩토링 필요 /** * 내 게시글 목록 조회 서비스 * 1. 사용자 조회 및 상태 검증 @@ -151,13 +150,13 @@ public PageResponse getMyPosts(Long userId, Pageable pageable) User user = getValidUser(userId); // 게시글 목록 조회 - Page page = postRepository.findAllByUserId(userId, pageable) - .map(PostListResponse::from); + Page page = postRepository.findPostsByUserId(userId, pageable); // 페이지 응답 반환 return PageResponse.from(page); } + // TODO: 내 댓글/북마크 목록 조회 N+1 발생 가능, 추후 리팩토링 필요 /** * 내 댓글 목록 조회 서비스 * 1. 사용자 조회 및 상태 검증 diff --git a/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java b/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java index 48cda3a1..04371ec1 100644 --- a/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java +++ b/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java @@ -70,6 +70,8 @@ void setUp() { post3.updateCategories(List.of(teen, group2)); } + // ====================== 게시글 다건 검색 테스트 ====================== + @Test @DisplayName("기본 게시글 목록 조회 (카테고리, 키워드 없이)") void searchPosts_basic() { @@ -142,4 +144,89 @@ void searchPosts_empty() { assertThat(page.getTotalElements()).isZero(); assertThat(page.getContent()).isEmpty(); } + + // ====================== 내 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("특정 사용자의 게시글 목록 페이징 조회") + void findPostsByUserId_basic() { + // given + PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + Page page = postRepository.findPostsByUserId(user.getId(), pageable); + + // then + assertThat(page.getTotalElements()).isEqualTo(3); + assertThat(page.getContent()).hasSize(3); + + // 게시글 제목 확인 (생성일 역순) + assertThat(page.getContent().get(0).getTitle()).isEqualTo("10대 대상 스터디"); + assertThat(page.getContent().get(1).getTitle()).isEqualTo("과학 토론 모집"); + assertThat(page.getContent().get(2).getTitle()).isEqualTo("수학 공부 팁"); + + // 작성자 정보가 즉시 조회되었는지 확인 + PostListResponse first = page.getContent().getFirst(); + assertThat(first.getAuthor().nickname()).isEqualTo("작성자"); + + // 카테고리 목록이 정상 주입되었는지 (별도 쿼리 1회로 주입되는 구조) + assertThat(first.getCategories()).isNotEmpty(); + assertThat(first.getCategories()) + .extracting("name") + .containsAnyOf("수학", "과학", "10대", "2인"); + } + + @Test + @DisplayName("사용자가 작성한 게시글이 없으면 빈 페이지 반환") + void findPostsByUserId_empty() { + // given: 다른 사용자 + User other = User.createUser("other", "other@example.com", "encodedPwd"); + other.setUserProfile(new UserProfile(other, "다른작성자", null, null, null, 0)); + userRepository.save(other); + + PageRequest pageable = PageRequest.of(0, 10); + + // when + Page page = postRepository.findPostsByUserId(other.getId(), pageable); + + // then + assertThat(page.getTotalElements()).isZero(); + assertThat(page.getContent()).isEmpty(); + } + + @Test + @DisplayName("정렬 조건(createdAt DESC)이 올바르게 적용") + void findPostsByUserId_sorting() { + // given + PageRequest pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + Page page = postRepository.findPostsByUserId(user.getId(), pageable); + + // then + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent().get(0).getTitle()).isEqualTo("10대 대상 스터디"); + assertThat(page.getContent().get(1).getTitle()).isEqualTo("과학 토론 모집"); + } + + @Test + @DisplayName("카테고리가 없는 게시글도 정상 조회") + void findPostsByUserId_noCategory() { + // given + Post uncategorized = new Post(user, "카테고리 없음 글", "내용", null); + postRepository.save(uncategorized); + + PageRequest pageable = PageRequest.of(0, 10); + + // when + Page page = postRepository.findPostsByUserId(user.getId(), pageable); + + // then + PostListResponse target = page.getContent().stream() + .filter(p -> p.getTitle().equals("카테고리 없음 글")) + .findFirst() + .orElseThrow(); + + assertThat(target.getCategories()).isEmpty(); + } } From 8b11a5ac9abdaf53b393bd20ce0844c6b1ac44d1 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:27:30 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Ref:=20=EB=82=B4=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20Query=20DSL=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/comment/dto/MyCommentResponse.java | 28 +-- .../comment/repository/CommentRepository.java | 3 - .../custom/CommentRepositoryCustom.java | 4 +- .../custom/CommentRepositoryImpl.java | 160 +++++++++++++----- .../board/comment/service/CommentService.java | 3 +- .../back/domain/user/service/UserService.java | 5 +- .../custom/CommentRepositoryImplTest.java | 58 ++++++- 7 files changed, 175 insertions(+), 86 deletions(-) diff --git a/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java b/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java index 1e697459..58d53036 100644 --- a/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java +++ b/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java @@ -1,6 +1,6 @@ package com.back.domain.board.comment.dto; -import com.back.domain.board.comment.entity.Comment; +import com.querydsl.core.annotations.QueryProjection; import java.time.LocalDateTime; @@ -28,28 +28,6 @@ public record MyCommentResponse( LocalDateTime createdAt, LocalDateTime updatedAt ) { - public static MyCommentResponse from(Comment comment) { - return new MyCommentResponse( - comment.getId(), - comment.getPost().getId(), - comment.getPost().getTitle(), - comment.getParent() != null - ? comment.getParent().getId() - : null, - comment.getParent() != null - ? truncate(comment.getParent().getContent()) - : null, - comment.getContent(), - comment.getLikeCount(), - comment.getCreatedAt(), - comment.getUpdatedAt() - ); - } - - private static String truncate(String content) { - int length = 50; - return (content == null || content.length() <= length) - ? content - : content.substring(0, length) + "..."; - } + @QueryProjection + public MyCommentResponse {} } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java b/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java index 2b008a16..72004816 100644 --- a/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java +++ b/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java @@ -2,12 +2,9 @@ import com.back.domain.board.comment.entity.Comment; import com.back.domain.board.comment.repository.custom.CommentRepositoryCustom; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { - Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryCustom.java b/src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryCustom.java index f46be2fe..a2cc0ebe 100644 --- a/src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryCustom.java +++ b/src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryCustom.java @@ -1,9 +1,11 @@ package com.back.domain.board.comment.repository.custom; import com.back.domain.board.comment.dto.CommentListResponse; +import com.back.domain.board.comment.dto.MyCommentResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface CommentRepositoryCustom { - Page getCommentsByPostId(Long postId, Pageable pageable); + Page findCommentsByPostId(Long postId, Pageable pageable); + Page findCommentsByUserId(Long postId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImpl.java b/src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImpl.java index d9fd3ed7..b2adfc74 100644 --- a/src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImpl.java +++ b/src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImpl.java @@ -1,12 +1,16 @@ package com.back.domain.board.comment.repository.custom; import com.back.domain.board.comment.dto.CommentListResponse; +import com.back.domain.board.comment.dto.MyCommentResponse; import com.back.domain.board.comment.dto.QCommentListResponse; +import com.back.domain.board.comment.dto.QMyCommentResponse; import com.back.domain.board.comment.entity.Comment; import com.back.domain.board.comment.entity.QComment; import com.back.domain.board.common.dto.QAuthorResponse; +import com.back.domain.board.post.entity.QPost; import com.back.domain.user.entity.QUser; import com.back.domain.user.entity.QUserProfile; +import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; @@ -30,23 +34,26 @@ public class CommentRepositoryImpl implements CommentRepositoryCustom { /** * 특정 게시글의 댓글 목록 조회 * - 총 쿼리 수: 3회 - * 1.부모 댓글 목록을 페이징/정렬 조건으로 조회 - * 2.부모 ID 목록으로 자식 댓글 전체 조회 - * 3.부모 총 건수(count) 조회 + * 1.부모 댓글 목록 조회 (User, UserProfile join) + * 2.자식 댓글 목록 조회 (User, UserProfile join) + * 3.부모 전체 count 조회 * - * @param postId 게시글 Id + * @param postId 게시글 ID * @param pageable 페이징 + 정렬 조건 */ @Override - public Page getCommentsByPostId(Long postId, Pageable pageable) { + public Page findCommentsByPostId(Long postId, Pageable pageable) { QComment comment = QComment.comment; - // 1. 정렬 조건 생성 + // 1. 검색 조건 생성 + BooleanExpression condition = comment.post.id.eq(postId).and(comment.parent.isNull()); + + // 2. 정렬 조건 생성 List> orders = buildOrderSpecifiers(pageable); - // 2. 부모 댓글 조회 (페이징 적용) + // 3. 부모 댓글 조회 (페이징 적용) List parents = fetchComments( - comment.post.id.eq(postId).and(comment.parent.isNull()), + condition, orders, pageable.getOffset(), pageable.getPageSize() @@ -57,12 +64,12 @@ public Page getCommentsByPostId(Long postId, Pageable pagea return new PageImpl<>(parents, pageable, 0); } - // 3. 부모 ID 수집 + // 4. 부모 ID 수집 List parentIds = parents.stream() .map(CommentListResponse::getCommentId) .toList(); - // 4. 자식 댓글 조회 (부모 집합에 대한 전체 조회) + // 5. 자식 댓글 조회 (부모 집합에 대한 전체 조회) List children = fetchComments( comment.parent.id.in(parentIds), List.of(comment.createdAt.asc()), // 시간순 정렬 @@ -70,29 +77,104 @@ public Page getCommentsByPostId(Long postId, Pageable pagea null ); - // 5. 부모-자식 매핑 + // 6. 부모-자식 매핑 mapChildrenToParents(parents, children); - // 6. 전체 부모 댓글 수 조회 - Long total = queryFactory - .select(comment.count()) + // 7. 전체 부모 댓글 수 조회 + long total = countComments(condition); + + return new PageImpl<>(parents, pageable, total); + } + + /** + * 특정 사용자의 댓글 목록 조회 + * - 총 쿼리 수: 2회 + * 1. 댓글 목록 조회 (Comment, Post join) + * 2. 전체 count 조회 + * + * @param userId 사용자 ID + * @param pageable 페이징 + 정렬 조건 + */ + @Override + public Page findCommentsByUserId(Long userId, Pageable pageable) { + QComment comment = QComment.comment; + QComment parent = new QComment("parent"); + QPost post = QPost.post; + + // 1. 검색 조건 생성 + BooleanExpression condition = comment.user.id.eq(userId); + + // 2. 정렬 조건 생성 + List> orders = buildOrderSpecifiers(pageable); + + // 3. 댓글 목록 조회 + List comments = queryFactory + .select(new QMyCommentResponse( + comment.id, + post.id, + post.title, + parent.id, + parent.content.substring(0, 50), + comment.content, + comment.likeCount, + comment.createdAt, + comment.updatedAt + )) .from(comment) - .where(comment.post.id.eq(postId).and(comment.parent.isNull())) - .fetchOne(); + .leftJoin(comment.parent, parent) + .leftJoin(comment.post, post) + .where(condition) + .orderBy(orders.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 결과가 없으면 즉시 빈 페이지 반환 + if (comments.isEmpty()) { + return new PageImpl<>(comments, pageable, 0); + } + + // 4. 전체 댓글 수 조회 + long total = countComments(condition); - return new PageImpl<>(parents, pageable, total != null ? total : 0L); + return new PageImpl<>(comments, pageable, total); } // -------------------- 내부 메서드 -------------------- + /** + * 정렬 조건 생성 + * - Pageable의 Sort 정보를 QueryDSL OrderSpecifier 목록으로 변환 + */ + private List> buildOrderSpecifiers(Pageable pageable) { + QComment comment = QComment.comment; + PathBuilder entityPath = new PathBuilder<>(Comment.class, comment.getMetadata()); + List> orders = new ArrayList<>(); + + for (Sort.Order order : pageable.getSort()) { + String property = order.getProperty(); + + // 화이트리스트에 포함된 필드만 허용 + if (!ALLOWED_SORT_FIELDS.contains(property)) { + // 허용되지 않은 정렬 키는 무시 (런타임 예외 대신 안전하게 스킵) + continue; + } + + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(property, Comparable.class))); + } + + // 명시된 정렬이 없으면 기본 정렬(createdAt DESC) 적용 + if (orders.isEmpty()) { + orders.add(new OrderSpecifier<>(Order.DESC, comment.createdAt)); + } + + return orders; + } + /** * 댓글 조회 * - User / UserProfile join (N+1 방지) - * - * @param condition where 조건 - * @param orders 정렬 조건 - * @param offset 페이징 offset (null이면 미적용) - * @param limit 페이징 limit (null이면 미적용) */ private List fetchComments( BooleanExpression condition, @@ -147,32 +229,16 @@ private void mapChildrenToParents(List parents, List> buildOrderSpecifiers(Pageable pageable) { + private long countComments(BooleanExpression condition) { QComment comment = QComment.comment; - PathBuilder entityPath = new PathBuilder<>(Comment.class, comment.getMetadata()); - List> orders = new ArrayList<>(); - - for (Sort.Order order : pageable.getSort()) { - String property = order.getProperty(); - - // 화이트리스트에 포함된 필드만 허용 - if (!ALLOWED_SORT_FIELDS.contains(property)) { - // 허용되지 않은 정렬 키는 무시 (런타임 예외 대신 안전하게 스킵) - continue; - } - - Order direction = order.isAscending() ? Order.ASC : Order.DESC; - orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(property, Comparable.class))); - } - - // 명시된 정렬이 없으면 기본 정렬(createdAt DESC) 적용 - if (orders.isEmpty()) { - orders.add(new OrderSpecifier<>(Order.DESC, comment.createdAt)); - } - - return orders; + Long total = queryFactory + .select(comment.count()) + .from(comment) + .where(condition) + .fetchOne(); + return total != null ? total : 0L; } } diff --git a/src/main/java/com/back/domain/board/comment/service/CommentService.java b/src/main/java/com/back/domain/board/comment/service/CommentService.java index 0bf86d6d..43f49eef 100644 --- a/src/main/java/com/back/domain/board/comment/service/CommentService.java +++ b/src/main/java/com/back/domain/board/comment/service/CommentService.java @@ -4,7 +4,6 @@ import com.back.domain.board.comment.dto.CommentRequest; import com.back.domain.board.comment.dto.CommentResponse; import com.back.domain.board.comment.dto.ReplyResponse; -import com.back.domain.board.comment.entity.CommentLike; import com.back.domain.board.comment.repository.CommentLikeRepository; import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.comment.entity.Comment; @@ -91,7 +90,7 @@ public PageResponse getComments(Long postId, Pageable pagea .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); // 댓글 목록 조회 - Page comments = commentRepository.getCommentsByPostId(postId, pageable); + Page comments = commentRepository.findCommentsByPostId(postId, pageable); return PageResponse.from(comments); } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index ea3f8d69..248a6c13 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -156,7 +156,6 @@ public PageResponse getMyPosts(Long userId, Pageable pageable) return PageResponse.from(page); } - // TODO: 내 댓글/북마크 목록 조회 N+1 발생 가능, 추후 리팩토링 필요 /** * 내 댓글 목록 조회 서비스 * 1. 사용자 조회 및 상태 검증 @@ -170,13 +169,13 @@ public PageResponse getMyComments(Long userId, Pageable pagea User user = getValidUser(userId); // 댓글 목록 조회 - Page page = commentRepository.findAllByUserId(user.getId(), pageable) - .map(MyCommentResponse::from); + Page page = commentRepository.findCommentsByUserId(user.getId(), pageable); // 페이지 응답 반환 return PageResponse.from(page); } + // TODO: 내 북마크 목록 조회 N+1 발생 가능, 추후 리팩토링 필요 /** * 내 북마크 게시글 목록 조회 서비스 * 1. 사용자 조회 및 상태 검증 diff --git a/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java b/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java index 99e6cf07..95d26933 100644 --- a/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java +++ b/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java @@ -69,14 +69,16 @@ void setUp() { commentRepository.saveAll(List.of(child11, child12, child21)); } + // ====================== 특정 게시글의 댓글 목록 조회 테스트 ====================== + @Test - @DisplayName("게시글의 부모 댓글 목록과 자식 댓글이 함께 조회된다 (총 쿼리 3회 예상)") + @DisplayName("게시글의 부모 댓글 목록과 자식 댓글이 함께 조회") void getCommentsByPostId_success() { // given PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); // when - Page page = commentRepository.getCommentsByPostId(post.getId(), pageable); + Page page = commentRepository.findCommentsByPostId(post.getId(), pageable); // then assertThat(page.getTotalElements()).isEqualTo(3L); // 부모 3개 @@ -115,7 +117,7 @@ void getCommentsByPostId_empty() { PageRequest pageable = PageRequest.of(0, 5); // when - Page page = commentRepository.getCommentsByPostId(newPost.getId(), pageable); + Page page = commentRepository.findCommentsByPostId(newPost.getId(), pageable); // then assertThat(page.getTotalElements()).isZero(); @@ -129,10 +131,56 @@ void getCommentsByPostId_sortFallback() { PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "unknownField")); // when - Page page = commentRepository.getCommentsByPostId(post.getId(), pageable); + Page page = commentRepository.findCommentsByPostId(post.getId(), pageable); // then // createdAt DESC 기본 정렬이 적용되어, 마지막에 생성된 parent3이 먼저 나와야 함 - assertThat(page.getContent().get(0).getCommentId()).isEqualTo(parent3.getId()); + assertThat(page.getContent().getFirst().getCommentId()).isEqualTo(parent3.getId()); + } + + // ====================== 특정 사용자의 댓글 목록 조회 테스트 ====================== + + @Test + @DisplayName("사용자 ID로 자신의 댓글 목록을 조회") + void findCommentsByUserId_success() { + // given + PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + var page = commentRepository.findCommentsByUserId(user.getId(), pageable); + + // then + // 총 댓글 수 = 부모 3 + 자식 3 = 6 + assertThat(page.getTotalElements()).isEqualTo(6L); + assertThat(page.getContent()).hasSize(6); + + // 특정 댓글 하나 검증 + var myComment = page.getContent().stream() + .filter(c -> c.commentId().equals(child11.getId())) + .findFirst() + .orElseThrow(); + + assertThat(myComment.postId()).isEqualTo(post.getId()); + assertThat(myComment.postTitle()).isEqualTo("게시글 제목"); + assertThat(myComment.parentId()).isEqualTo(parent1.getId()); + assertThat(myComment.parentContent()).contains("부모1"); + } + + @Test + @DisplayName("댓글이 없는 사용자는 빈 페이지 반환") + void findCommentsByUserId_empty() { + // given + User newUser = User.createUser("user2", "user2@example.com", "encodedPwd"); + newUser.setUserProfile(new UserProfile(newUser, "신규", null, null, null, 0)); + newUser.setUserStatus(UserStatus.ACTIVE); + userRepository.save(newUser); + PageRequest pageable = PageRequest.of(0, 5); + + // when + var page = commentRepository.findCommentsByUserId(newUser.getId(), pageable); + + // then + assertThat(page.getTotalElements()).isZero(); + assertThat(page.getContent()).isEmpty(); } } From 3a2d82df1d78443b0df341512bd1334016b64a2e Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:25:01 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Ref:=20=EB=82=B4=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Query=20DSL=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/post/dto/PostListResponse.java | 18 ----- .../custom/PostRepositoryCustom.java | 1 + .../repository/custom/PostRepositoryImpl.java | 77 ++++++++++++++++++- .../back/domain/user/service/UserService.java | 6 +- .../custom/PostRepositoryImplTest.java | 77 ++++++++++++++++++- 5 files changed, 154 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/back/domain/board/post/dto/PostListResponse.java b/src/main/java/com/back/domain/board/post/dto/PostListResponse.java index 338c6f4d..20a5779a 100644 --- a/src/main/java/com/back/domain/board/post/dto/PostListResponse.java +++ b/src/main/java/com/back/domain/board/post/dto/PostListResponse.java @@ -1,7 +1,6 @@ package com.back.domain.board.post.dto; import com.back.domain.board.common.dto.AuthorResponse; -import com.back.domain.board.post.entity.Post; import com.querydsl.core.annotations.QueryProjection; import lombok.Getter; import lombok.Setter; @@ -50,21 +49,4 @@ public PostListResponse(Long postId, this.createdAt = createdAt; this.updatedAt = updatedAt; } - - public static PostListResponse from(Post post) { - return new PostListResponse( - post.getId(), - AuthorResponse.from(post.getUser()), - post.getTitle(), - post.getThumbnailUrl(), - post.getCategories().stream() - .map(CategoryResponse::from) - .toList(), - post.getLikeCount(), - post.getBookmarkCount(), - post.getCommentCount(), - post.getCreatedAt(), - post.getUpdatedAt() - ); - } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryCustom.java b/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryCustom.java index ae26e723..00f188a6 100644 --- a/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryCustom.java +++ b/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryCustom.java @@ -9,4 +9,5 @@ public interface PostRepositoryCustom { Page searchPosts(String keyword, String searchType, List categoryIds, Pageable pageable); Page findPostsByUserId(Long userId, Pageable pageable); + Page findBookmarkedPostsByUserId(Long userId, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryImpl.java b/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryImpl.java index b3c21589..8e00626a 100644 --- a/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryImpl.java +++ b/src/main/java/com/back/domain/board/post/repository/custom/PostRepositoryImpl.java @@ -73,7 +73,7 @@ public Page searchPosts(String keyword, String searchType, Lis } /** - * 내 게시글 목록 조회 + * 특정 사용자의 게시글 목록 조회 * - 총 쿼리 수: 3회 * 1. 게시글 목록 조회 (User, UserProfile join) * 2. 카테고리 목록 조회 (IN 쿼리) @@ -109,6 +109,67 @@ public Page findPostsByUserId(Long userId, Pageable pageable) return new PageImpl<>(posts, pageable, total); } + /** + * 특정 사용자의 북마크 게시글 목록 조회 + * - 총 쿼리 수: 3회 + * 1. 북마크된 게시글 목록 조회 (Post, User, UserProfile join) + * 2. 카테고리 목록 조회 (IN 쿼리) + * 3. 전체 count 조회 + * + * @param userId 사용자 ID + * @param pageable 페이징 + 정렬 조건 + */ + @Override + public Page findBookmarkedPostsByUserId(Long userId, Pageable pageable) { + QPost post = QPost.post; + QPostBookmark bookmark = QPostBookmark.postBookmark; + QUser user = QUser.user; + QUserProfile profile = QUserProfile.userProfile; + + // 1. 검색 조건 생성 + BooleanBuilder where = new BooleanBuilder(bookmark.user.id.eq(userId)); + + // 2. 정렬 조건 생성 (화이트리스트 기반) + List> orders = buildOrderSpecifiers(pageable); + + // 3. 북마크된 게시글 목록 조회 (Post, User, UserProfile join으로 N+1 방지) + List posts = queryFactory + .select(new QPostListResponse( + post.id, + new QAuthorResponse(post.user.id, post.user.userProfile.nickname, post.user.userProfile.profileImageUrl), + post.title, + post.thumbnailUrl, + Expressions.constant(Collections.emptyList()), // categories는 별도 주입 + post.likeCount, + post.bookmarkCount, + post.commentCount, + post.createdAt, + post.updatedAt + )) + .from(bookmark) + .join(bookmark.post, post) + .leftJoin(post.user, user) + .leftJoin(user.userProfile, profile) + .where(where) + .orderBy(orders.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 결과가 없으면 즉시 빈 페이지 반환 + if (posts.isEmpty()) { + return new PageImpl<>(posts, pageable, 0); + } + + // 4. 카테고리 목록 주입 (postIds 기반 IN 쿼리 1회) + injectCategories(posts); + + // 5. 전체 북마크 게시글 수 조회 + long total = countBookmarkedPosts(userId); + + return new PageImpl<>(posts, pageable, total); + } + // -------------------- 내부 메서드 -------------------- /** @@ -287,4 +348,18 @@ private long countPosts(BooleanBuilder where) { .fetchOne(); return total != null ? total : 0L; } + + /** + * 내 북마크 게시글 총 개수 조회 + * - 단순 count 쿼리 1회 + */ + private long countBookmarkedPosts(Long userId) { + QPostBookmark bookmark = QPostBookmark.postBookmark; + Long total = queryFactory + .select(bookmark.count()) + .from(bookmark) + .where(bookmark.user.id.eq(userId)) + .fetchOne(); + return total != null ? total : 0L; + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 248a6c13..1d747cc3 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -4,7 +4,6 @@ import com.back.domain.board.comment.repository.CommentRepository; import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; -import com.back.domain.board.post.repository.PostBookmarkRepository; import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; @@ -34,7 +33,6 @@ public class UserService { private final UserProfileRepository userProfileRepository; private final CommentRepository commentRepository; private final PostRepository postRepository; - private final PostBookmarkRepository postBookmarkRepository; private final PasswordEncoder passwordEncoder; /** @@ -175,7 +173,6 @@ public PageResponse getMyComments(Long userId, Pageable pagea return PageResponse.from(page); } - // TODO: 내 북마크 목록 조회 N+1 발생 가능, 추후 리팩토링 필요 /** * 내 북마크 게시글 목록 조회 서비스 * 1. 사용자 조회 및 상태 검증 @@ -189,8 +186,7 @@ public PageResponse getMyBookmarks(Long userId, Pageable pagea User user = getValidUser(userId); // 북마크된 게시글 조회 - Page page = postBookmarkRepository.findAllByUserId(user.getId(), pageable) - .map(bookmark -> PostListResponse.from(bookmark.getPost())); + Page page = postRepository.findBookmarkedPostsByUserId(user.getId(), pageable); // 페이지 응답 반환 return PageResponse.from(page); diff --git a/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java b/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java index 04371ec1..1d5ccdfe 100644 --- a/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java +++ b/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java @@ -3,6 +3,7 @@ import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.board.post.entity.*; import com.back.domain.board.post.enums.CategoryType; +import com.back.domain.board.post.repository.PostBookmarkRepository; import com.back.domain.board.post.repository.PostRepository; import com.back.domain.board.post.repository.PostCategoryRepository; import com.back.domain.user.entity.User; @@ -10,6 +11,9 @@ import com.back.domain.user.entity.UserStatus; import com.back.domain.user.repository.UserRepository; import com.back.global.config.QueryDslConfig; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -34,6 +38,9 @@ class PostRepositoryImplTest { @Autowired private PostRepository postRepository; + @Autowired + private PostBookmarkRepository postBookmarkRepository; + @Autowired private UserRepository userRepository; @@ -145,7 +152,7 @@ void searchPosts_empty() { assertThat(page.getContent()).isEmpty(); } - // ====================== 내 게시글 목록 조회 테스트 ====================== + // ====================== 특정 사용자의 게시글 목록 조회 테스트 ====================== @Test @DisplayName("특정 사용자의 게시글 목록 페이징 조회") @@ -229,4 +236,72 @@ void findPostsByUserId_noCategory() { assertThat(target.getCategories()).isEmpty(); } + + // ====================== 특정 사용자의 북마크 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("특정 사용자의 북마크 게시글 목록 정상 조회") + void findBookmarkedPostsByUserId_basic() { + // given + PostBookmark b1 = new PostBookmark(post1, user); + PostBookmark b2 = new PostBookmark(post3, user); + postBookmarkRepository.saveAll(List.of(b1, b2)); + + PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + Page page = postRepository.findBookmarkedPostsByUserId(user.getId(), pageable); + + // then + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()).hasSize(2); + + // 최신순 정렬 확인 + assertThat(page.getContent().get(0).getTitle()).isEqualTo("10대 대상 스터디"); + assertThat(page.getContent().get(1).getTitle()).isEqualTo("수학 공부 팁"); + + // 작성자 정보 확인 + PostListResponse first = page.getContent().getFirst(); + assertThat(first.getAuthor().nickname()).isEqualTo("작성자"); + + // 카테고리 목록 주입 검증 + assertThat(first.getCategories()).isNotEmpty(); + assertThat(first.getCategories()) + .extracting("name") + .containsAnyOf("10대", "2인", "수학"); + } + + @Test + @DisplayName("사용자가 북마크한 게시글이 없으면 빈 페이지 반환") + void findBookmarkedPostsByUserId_empty() { + // given + PageRequest pageable = PageRequest.of(0, 10); + + // when + Page page = postRepository.findBookmarkedPostsByUserId(user.getId(), pageable); + + // then + assertThat(page.getTotalElements()).isZero(); + assertThat(page.getContent()).isEmpty(); + } + + @Test + @DisplayName("정렬 조건(createdAt DESC)이 올바르게 적용") + void findBookmarkedPostsByUserId_sorting() { + // given + PostBookmark b1 = new PostBookmark(post1, user); + PostBookmark b2 = new PostBookmark(post2, user); + PostBookmark b3 = new PostBookmark(post3, user); + postBookmarkRepository.saveAll(List.of(b1, b2, b3)); + + PageRequest pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + Page page = postRepository.findBookmarkedPostsByUserId(user.getId(), pageable); + + // then + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent().get(0).getTitle()).isEqualTo("10대 대상 스터디"); + assertThat(page.getContent().get(1).getTitle()).isEqualTo("과학 토론 모집"); + } } From 7257317af538eab6e6fa761ff451c6539a2228ed Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:47:17 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Test:=20UserControllerTest=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerTest.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/back/domain/user/controller/UserControllerTest.java b/src/test/java/com/back/domain/user/controller/UserControllerTest.java index af50fed8..0a727898 100644 --- a/src/test/java/com/back/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/UserControllerTest.java @@ -30,6 +30,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; @@ -702,8 +703,9 @@ void getMyPosts_success() throws Exception { .andExpect(jsonPath("$.message").value("내 게시글 목록이 조회되었습니다.")) .andExpect(jsonPath("$.data.items").isArray()) .andExpect(jsonPath("$.data.items.length()").value(2)) - .andExpect(jsonPath("$.data.items[0].title").value("두 번째 글")) // 최신순(createdAt desc) - .andExpect(jsonPath("$.data.items[1].title").value("첫 번째 글")); + .andExpect(jsonPath("$.data.items[*].title").value( + containsInAnyOrder("첫 번째 글", "두 번째 글") + )); } @Test @@ -841,8 +843,9 @@ void getMyComments_success() throws Exception { .andExpect(jsonPath("$.message").value("내 댓글 목록이 조회되었습니다.")) .andExpect(jsonPath("$.data.items").isArray()) .andExpect(jsonPath("$.data.items.length()").value(3)) - .andExpect(jsonPath("$.data.items[0].content").value("감사합니다! 더 공부해볼게요.")) - .andExpect(jsonPath("$.data.items[1].content").value("정말 도움이 많이 됐어요!")); + .andExpect(jsonPath("$.data.items[*].content").value( + containsInAnyOrder("코딩 박사의 스프링 교재도 추천합니다.", "정말 도움이 많이 됐어요!", "감사합니다! 더 공부해볼게요.") + )); } @Test @@ -980,8 +983,9 @@ void getMyBookmarks_success() throws Exception { .andExpect(jsonPath("$.code").value("SUCCESS_200")) .andExpect(jsonPath("$.message").value("내 북마크 게시글 목록이 조회되었습니다.")) .andExpect(jsonPath("$.data.items.length()").value(2)) - .andExpect(jsonPath("$.data.items[0].title").value("테스트 코드 작성 가이드")) - .andExpect(jsonPath("$.data.items[1].title").value("JPA 영속성 전이 완벽 정리")); + .andExpect(jsonPath("$.data.items[*].title").value( + containsInAnyOrder("JPA 영속성 전이 완벽 정리", "테스트 코드 작성 가이드") + )); } @Test