diff --git a/src/main/java/com/back/domain/board/comment/dto/CommentLikeResponse.java b/src/main/java/com/back/domain/board/comment/dto/CommentLikeResponse.java index 8aa94a87..44ae608a 100644 --- a/src/main/java/com/back/domain/board/comment/dto/CommentLikeResponse.java +++ b/src/main/java/com/back/domain/board/comment/dto/CommentLikeResponse.java @@ -5,8 +5,8 @@ /** * 댓글 좋아요 응답 DTO * - * @param commentId - * @param likeCount + * @param commentId 댓글 ID + * @param likeCount 좋아요 수 */ public record CommentLikeResponse( Long commentId, diff --git a/src/main/java/com/back/domain/board/comment/dto/CommentRequest.java b/src/main/java/com/back/domain/board/comment/dto/CommentRequest.java index 25f500b1..001a91d7 100644 --- a/src/main/java/com/back/domain/board/comment/dto/CommentRequest.java +++ b/src/main/java/com/back/domain/board/comment/dto/CommentRequest.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.NotBlank; /** - * 댓글 작성 및 수정을 위한 요청 DTO + * 댓글 작성/수정 요청 DTO * * @param content 댓글 내용 */ diff --git a/src/main/java/com/back/domain/board/comment/dto/CommentResponse.java b/src/main/java/com/back/domain/board/comment/dto/CommentResponse.java index 96da8d82..f99ef9b5 100644 --- a/src/main/java/com/back/domain/board/comment/dto/CommentResponse.java +++ b/src/main/java/com/back/domain/board/comment/dto/CommentResponse.java @@ -8,8 +8,8 @@ /** * 댓글 응답 DTO * - * @param commentId 댓글 Id - * @param postId 게시글 Id + * @param commentId 댓글 ID + * @param postId 게시글 ID * @param author 작성자 정보 * @param content 댓글 내용 * @param createdAt 댓글 생성 일시 diff --git a/src/main/java/com/back/domain/board/comment/dto/ReplyResponse.java b/src/main/java/com/back/domain/board/comment/dto/ReplyResponse.java index 93e720a1..5901d291 100644 --- a/src/main/java/com/back/domain/board/comment/dto/ReplyResponse.java +++ b/src/main/java/com/back/domain/board/comment/dto/ReplyResponse.java @@ -8,9 +8,9 @@ /** * 대댓글 응답 DTO * - * @param commentId 댓글 Id - * @param postId 게시글 Id - * @param parentId 부모 댓글 Id + * @param commentId 댓글 ID + * @param postId 게시글 ID + * @param parentId 부모 댓글 ID * @param author 작성자 정보 * @param content 댓글 내용 * @param createdAt 댓글 생성 일시 diff --git a/src/main/java/com/back/domain/board/comment/repository/custom/CommentLikeRepositoryImpl.java b/src/main/java/com/back/domain/board/comment/repository/custom/CommentLikeRepositoryImpl.java index d3a4c356..10d333ba 100644 --- a/src/main/java/com/back/domain/board/comment/repository/custom/CommentLikeRepositoryImpl.java +++ b/src/main/java/com/back/domain/board/comment/repository/custom/CommentLikeRepositoryImpl.java @@ -4,17 +4,23 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.util.Collection; import java.util.List; - @Repository @RequiredArgsConstructor public class CommentLikeRepositoryImpl implements CommentLikeRepositoryCustom { - private final JPAQueryFactory queryFactory; + /** + * 댓글 ID 목록 중 사용자가 좋아요한 댓글 ID 조회 + * - 총 쿼리 수: 1회 + * + * @param userId 사용자 ID + * @param commentIds 댓글 ID 목록 + */ @Override public List findLikedCommentIdsIn(Long userId, Collection commentIds) { QCommentLike commentLike = QCommentLike.commentLike; 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 97e513cc..d9fd3ed7 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 @@ -4,7 +4,6 @@ import com.back.domain.board.comment.dto.QCommentListResponse; import com.back.domain.board.comment.entity.Comment; import com.back.domain.board.comment.entity.QComment; -import com.back.domain.board.comment.entity.QCommentLike; import com.back.domain.board.common.dto.QAuthorResponse; import com.back.domain.user.entity.QUser; import com.back.domain.user.entity.QUserProfile; @@ -16,33 +15,36 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; +import org.springframework.stereotype.Repository; + import java.util.*; import java.util.stream.Collectors; +@Repository @RequiredArgsConstructor public class CommentRepositoryImpl implements CommentRepositoryCustom { private final JPAQueryFactory queryFactory; - // TODO: Comment에 likeCount 필드 추가에 따른 로직 개선 + private static final Set ALLOWED_SORT_FIELDS = Set.of("createdAt", "updatedAt", "likeCount"); + /** - * 게시글 ID로 댓글 목록 조회 - * - 부모 댓글 페이징 + 자식 댓글 전체 조회 - * - likeCount는 부모/자식 댓글을 한 번에 조회 후 주입 - * - likeCount 정렬은 메모리에서 처리 - * - 총 쿼리 수: 4회 (부모조회 + 자식조회 + likeCount + count) + * 특정 게시글의 댓글 목록 조회 + * - 총 쿼리 수: 3회 + * 1.부모 댓글 목록을 페이징/정렬 조건으로 조회 + * 2.부모 ID 목록으로 자식 댓글 전체 조회 + * 3.부모 총 건수(count) 조회 * - * @param postId 게시글 Id + * @param postId 게시글 Id * @param pageable 페이징 + 정렬 조건 */ @Override public Page getCommentsByPostId(Long postId, Pageable pageable) { QComment comment = QComment.comment; - QCommentLike commentLike = QCommentLike.commentLike; - // 1. 정렬 조건 생성 (엔티티 필드 기반) - List> orders = buildOrderSpecifiers(pageable, comment); + // 1. 정렬 조건 생성 + List> orders = buildOrderSpecifiers(pageable); - // 2. 부모 댓글 조회 (페이징) + // 2. 부모 댓글 조회 (페이징 적용) List parents = fetchComments( comment.post.id.eq(postId).and(comment.parent.isNull()), orders, @@ -50,37 +52,28 @@ public Page getCommentsByPostId(Long postId, Pageable pagea pageable.getPageSize() ); + // 부모가 비어 있으면 즉시 빈 페이지 반환 if (parents.isEmpty()) { return new PageImpl<>(parents, pageable, 0); } - // 3. 부모 ID 목록 수집 + // 3. 부모 ID 수집 List parentIds = parents.stream() .map(CommentListResponse::getCommentId) .toList(); - // 4. 자식 댓글 조회 (부모 ID 기준) + // 4. 자식 댓글 조회 (부모 집합에 대한 전체 조회) List children = fetchComments( comment.parent.id.in(parentIds), - List.of(comment.createdAt.asc()), + List.of(comment.createdAt.asc()), // 시간순 정렬 null, null ); - // 5. 부모 + 자식 댓글 ID 합쳐 likeCount 조회 (쿼리 1회) - Map likeCountMap = fetchLikeCounts(parentIds, children); - - // 6. likeCount 주입 - parents.forEach(p -> p.setLikeCount(likeCountMap.getOrDefault(p.getCommentId(), 0L))); - children.forEach(c -> c.setLikeCount(likeCountMap.getOrDefault(c.getCommentId(), 0L))); - - // 7. 부모-자식 매핑 + // 5. 부모-자식 매핑 mapChildrenToParents(parents, children); - // 8. 정렬 후처리 (통계 필드 기반) - parents = sortInMemoryIfNeeded(parents, pageable); - - // 9. 전체 부모 댓글 수 조회 + // 6. 전체 부모 댓글 수 조회 Long total = queryFactory .select(comment.count()) .from(comment) @@ -95,7 +88,11 @@ public Page getCommentsByPostId(Long postId, Pageable pagea /** * 댓글 조회 * - User / UserProfile join (N+1 방지) - * - likeCount는 이후 주입 + * + * @param condition where 조건 + * @param orders 정렬 조건 + * @param offset 페이징 offset (null이면 미적용) + * @param limit 페이징 limit (null이면 미적용) */ private List fetchComments( BooleanExpression condition, @@ -105,7 +102,7 @@ private List fetchComments( ) { QComment comment = QComment.comment; QUser user = QUser.user; - QUserProfile profile = QUserProfile.userProfile; + QUserProfile profile = QUserProfile .userProfile; var query = queryFactory .select(new QCommentListResponse( @@ -114,8 +111,8 @@ private List fetchComments( comment.parent.id, new QAuthorResponse(user.id, profile.nickname, profile.profileImageUrl), comment.content, - Expressions.constant(0L), // likeCount는 별도 주입 - Expressions.constant(false), + comment.likeCount, + Expressions.constant(false), // likedByMe는 별도 주입 comment.createdAt, comment.updatedAt, Expressions.constant(Collections.emptyList()) // children은 별도 주입 @@ -126,6 +123,7 @@ private List fetchComments( .where(condition) .orderBy(orders.toArray(new OrderSpecifier[0])); + // 페이징 적용 if (offset != null && limit != null) { query.offset(offset).limit(limit); } @@ -133,35 +131,9 @@ private List fetchComments( return query.fetch(); } - /** - * likeCount 일괄 조회 - * - IN 조건 기반 groupBy 쿼리 1회 - * - 부모/자식 댓글을 한 번에 조회 - */ - private Map fetchLikeCounts(List parentIds, List children) { - QCommentLike commentLike = QCommentLike.commentLike; - - List allIds = new ArrayList<>(parentIds); - allIds.addAll(children.stream().map(CommentListResponse::getCommentId).toList()); - - if (allIds.isEmpty()) return Map.of(); - - return queryFactory - .select(commentLike.comment.id, commentLike.count()) - .from(commentLike) - .where(commentLike.comment.id.in(allIds)) - .groupBy(commentLike.comment.id) - .fetch() - .stream() - .collect(Collectors.toMap( - tuple -> tuple.get(commentLike.comment.id), - tuple -> tuple.get(commentLike.count()) - )); - } - /** * 부모/자식 관계 매핑 - * - childMap을 parentId 기준으로 그룹화 후 children 필드에 set + * - 자식 목록을 parentId 기준으로 그룹화 후, 각 부모 DTO의 children에 설정 */ private void mapChildrenToParents(List parents, List children) { if (children.isEmpty()) return; @@ -170,53 +142,37 @@ private void mapChildrenToParents(List parents, List - parent.setChildren(childMap.getOrDefault(parent.getCommentId(), List.of())) + parent.setChildren(childMap.getOrDefault(parent.getCommentId(), Collections.emptyList())) ); } /** - * 정렬 처리 (DB 정렬) - * - createdAt, updatedAt 등 엔티티 필드 + * 정렬 조건 생성 + * - Pageable의 Sort 정보를 QueryDSL OrderSpecifier 목록으로 변환 */ - private List> buildOrderSpecifiers(Pageable pageable, QComment comment) { + 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 prop = order.getProperty(); + String property = order.getProperty(); - // 통계 필드는 메모리 정렬에서 처리 - if (prop.equals("likeCount")) { + // 화이트리스트에 포함된 필드만 허용 + if (!ALLOWED_SORT_FIELDS.contains(property)) { + // 허용되지 않은 정렬 키는 무시 (런타임 예외 대신 안전하게 스킵) continue; } + Order direction = order.isAscending() ? Order.ASC : Order.DESC; - orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(prop, Comparable.class))); + orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(property, Comparable.class))); } - return orders; - } - - /** - * 통계 기반 정렬 처리 (메모리) - * - likeCount 등 통계 필드 - * - 페이지 단위라 성능에 영향 없음 - */ - private List sortInMemoryIfNeeded(List results, Pageable pageable) { - if (results.isEmpty() || !pageable.getSort().isSorted()) return results; - - for (Sort.Order order : pageable.getSort()) { - Comparator comparator = null; - - if ("likeCount".equals(order.getProperty())) { - comparator = Comparator.comparing(CommentListResponse::getLikeCount); - } - - if (comparator != null) { - if (order.isDescending()) comparator = comparator.reversed(); - results.sort(comparator); - } + // 명시된 정렬이 없으면 기본 정렬(createdAt DESC) 적용 + if (orders.isEmpty()) { + orders.add(new OrderSpecifier<>(Order.DESC, comment.createdAt)); } - return results; + return orders; } } diff --git a/src/main/java/com/back/domain/board/post/controller/PostController.java b/src/main/java/com/back/domain/board/post/controller/PostController.java index ab0e453c..c6662ccd 100644 --- a/src/main/java/com/back/domain/board/post/controller/PostController.java +++ b/src/main/java/com/back/domain/board/post/controller/PostController.java @@ -19,6 +19,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor @@ -46,7 +48,7 @@ public ResponseEntity>> getPosts( @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, @RequestParam(required = false) String keyword, @RequestParam(required = false) String searchType, - @RequestParam(required = false) Long categoryId + @RequestParam(required = false) List categoryId ) { PageResponse response = postService.getPosts(keyword, searchType, categoryId, pageable); return ResponseEntity diff --git a/src/main/java/com/back/domain/board/post/controller/docs/PostControllerDocs.java b/src/main/java/com/back/domain/board/post/controller/docs/PostControllerDocs.java index a9c4db11..e147df34 100644 --- a/src/main/java/com/back/domain/board/post/controller/docs/PostControllerDocs.java +++ b/src/main/java/com/back/domain/board/post/controller/docs/PostControllerDocs.java @@ -21,6 +21,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import java.util.List; + @Tag(name = "Post API", description = "게시글 관련 API") public interface PostControllerDocs { @@ -229,7 +231,7 @@ ResponseEntity>> getPosts( @PageableDefault(sort = "createdAt") Pageable pageable, @RequestParam(required = false) String keyword, @RequestParam(required = false) String searchType, - @RequestParam(required = false) Long categoryId + @RequestParam(required = false) List categoryId ); diff --git a/src/main/java/com/back/domain/board/post/dto/PostBookmarkResponse.java b/src/main/java/com/back/domain/board/post/dto/PostBookmarkResponse.java index f4483711..6b0a0fe2 100644 --- a/src/main/java/com/back/domain/board/post/dto/PostBookmarkResponse.java +++ b/src/main/java/com/back/domain/board/post/dto/PostBookmarkResponse.java @@ -5,7 +5,7 @@ /** * 게시글 북마크 응답 DTO * - * @param postId 게시글 id + * @param postId 게시글 ID * @param bookmarkCount 북마크 수 */ public record PostBookmarkResponse( diff --git a/src/main/java/com/back/domain/board/post/dto/PostLikeResponse.java b/src/main/java/com/back/domain/board/post/dto/PostLikeResponse.java index ff8e7514..31e9fedb 100644 --- a/src/main/java/com/back/domain/board/post/dto/PostLikeResponse.java +++ b/src/main/java/com/back/domain/board/post/dto/PostLikeResponse.java @@ -5,7 +5,7 @@ /** * 게시글 좋아요 응답 DTO * - * @param postId 게시글 id + * @param postId 게시글 ID * @param likeCount 좋아요 수 */ public record PostLikeResponse( diff --git a/src/main/java/com/back/domain/board/post/dto/PostRequest.java b/src/main/java/com/back/domain/board/post/dto/PostRequest.java index 12e24a9c..266ef20b 100644 --- a/src/main/java/com/back/domain/board/post/dto/PostRequest.java +++ b/src/main/java/com/back/domain/board/post/dto/PostRequest.java @@ -5,7 +5,7 @@ import java.util.List; /** - * 게시글 생성 및 수정을 위한 요청 DTO + * 게시글 생성/수정 요청 DTO * * @param title 게시글 제목 * @param content 게시글 내용 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 d39277d9..febb6d37 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 @@ -4,6 +4,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; + public interface PostRepositoryCustom { - Page searchPosts(String keyword, String searchType, Long categoryId, Pageable pageable); + Page searchPosts(String keyword, String searchType, List categoryIds, 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 3310e2f3..4445a1f5 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 @@ -1,81 +1,96 @@ package com.back.domain.board.post.repository.custom; -import com.back.domain.board.comment.entity.QComment; import com.back.domain.board.common.dto.QAuthorResponse; import com.back.domain.board.post.dto.CategoryResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.board.post.dto.QCategoryResponse; import com.back.domain.board.post.dto.QPostListResponse; import com.back.domain.board.post.entity.*; +import com.back.domain.board.post.enums.CategoryType; import com.back.domain.user.entity.QUser; import com.back.domain.user.entity.QUserProfile; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.Tuple; -import com.querydsl.core.types.Expression; -import com.querydsl.core.types.ExpressionUtils; 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; -import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; +import org.springframework.stereotype.Repository; import java.util.*; import java.util.stream.Collectors; +@Repository @RequiredArgsConstructor public class PostRepositoryImpl implements PostRepositoryCustom { private final JPAQueryFactory queryFactory; - // TODO: Post에 likeCount 필드 추가에 따른 로직 개선 + private static final Set ALLOWED_SORT_FIELDS = Set.of( + "createdAt", "updatedAt", "title", "likeCount", "bookmarkCount", "commentCount" + ); + /** * 게시글 다건 검색 - * - 총 쿼리 수 : 3회 (Post + Category + count) + * - 총 쿼리 수: 3회 + * 1. 게시글 목록 조회 (User, UserProfile join) + * 2. 카테고리 목록 조회 (IN 쿼리) + * 3. 전체 count 조회 + * - categoryIds 포함 시, 총 쿼리 수: 4회 + * 4. CategoryType 매핑 조회 추가 (buildWhere 내부) * - * @param keyword 검색 키워드 - * @param searchType 검색 타입(title/content/author/전체) - * @param categoryId 카테고리 ID 필터 (nullable) - * @param pageable 페이징 + 정렬 조건 + * @param keyword 검색 키워드 + * @param searchType 검색 유형(title/content/author/전체) + * @param categoryIds 카테고리 ID 리스트 (같은 타입은 OR, 다른 타입은 AND) + * @param pageable 페이징 + 정렬 조건 */ @Override - public Page searchPosts(String keyword, String searchType, Long categoryId, Pageable pageable) { + public Page searchPosts(String keyword, String searchType, List categoryIds, Pageable pageable) { // 1. 검색 조건 생성 - BooleanBuilder where = buildWhere(keyword, searchType, categoryId); + BooleanBuilder where = buildWhere(keyword, searchType, categoryIds); - // 2. 정렬 조건 생성 (엔티티 필드 기반) + // 2. 정렬 조건 생성 (화이트리스트 기반) List> orders = buildOrderSpecifiers(pageable); - // 3. 게시글 메인 조회 - List results = fetchPosts(where, orders, pageable); + // 3. 게시글 목록 조회 (User, UserProfile join으로 N+1 방지) + List posts = fetchPosts(where, orders, pageable); - // 4. 카테고리 조회 후 결과 DTO에 매핑 - injectCategories(results); + // 결과가 없으면 즉시 빈 페이지 반환 + if (posts.isEmpty()) { + return new PageImpl<>(posts, pageable, 0); + } - // 5. 정렬 후처리 (통계 필드 기반) - results = sortInMemoryIfNeeded(results, pageable); + // 4. 카테고리 목록 주입 (postIds 기반 IN 쿼리 1회) + injectCategories(posts); - // 6. 전체 게시글 개수 카운트 쿼리 - long total = countPosts(where, categoryId); + // 5. 전체 게시글 수 조회 + long total = countPosts(where); - // 7. Page 객체로 반환 - return new PageImpl<>(results, pageable, total); + return new PageImpl<>(posts, pageable, total); } // -------------------- 내부 메서드 -------------------- /** * 검색 조건 생성 - * - title/content/author 기반 동적 필터 - * - categoryId가 존재하면 categoryMapping join 기반 추가 조건 + * - keyword, searchType, categoryIds를 기반으로 BooleanBuilder 구성 + * - 카테고리 조건 로직: + * 1. categoryIds → CategoryType 매핑 조회 (1회 쿼리) + * 2. 같은 CategoryType끼리는 OR (in 조건) + * 3. 서로 다른 CategoryType끼리는 AND 결합 + * - 결과적으로 `(SUBJECT in (...)) AND (DEMOGRAPHIC in (...))` 형태로 조합됨 */ - private BooleanBuilder buildWhere(String keyword, String searchType, Long categoryId) { + private BooleanBuilder buildWhere(String keyword, String searchType, List categoryIds) { QPost post = QPost.post; + QPostCategory category = QPostCategory.postCategory; QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; BooleanBuilder where = new BooleanBuilder(); - // 키워드 필터링 + // 키워드 필터 if (keyword != null && !keyword.isBlank()) { switch (searchType) { case "title" -> where.and(post.title.containsIgnoreCase(keyword)); @@ -89,185 +104,151 @@ private BooleanBuilder buildWhere(String keyword, String searchType, Long catego } } - // 카테고리 필터링 - if (categoryId != null) { - where.and(categoryMapping.category.id.eq(categoryId)); + // 카테고리 필터 + if (categoryIds != null && !categoryIds.isEmpty()) { + // categoryId -> CategoryType 매핑 조회 (1회 쿼리) + List categoryTypeTuples = queryFactory + .select(category.id, category.type) + .from(category) + .where(category.id.in(categoryIds)) + .fetch(); + + // 타입별 그룹핑 + Map> groupedIds = categoryTypeTuples.stream() + .filter(t -> t.get(category.id) != null && t.get(category.type) != null) + .collect(Collectors.groupingBy( + t -> t.get(category.type), + Collectors.mapping(t -> t.get(category.id), Collectors.toList()) + )); + + // 같은 타입은 OR(in), 다른 타입은 AND로 결합 + BooleanBuilder typeConditions = new BooleanBuilder(); + groupedIds.forEach((type, ids) -> { + // 각 타입별로 (category_id in ids) + BooleanBuilder subCondition = new BooleanBuilder(categoryMapping.category.id.in(ids)); + + // post.id in (select mapping.post.id ...) + typeConditions.and(post.id.in( + JPAExpressions.select(categoryMapping.post.id) + .from(categoryMapping) + .where(subCondition) + )); + }); + + where.and(typeConditions); } return where; } /** - * 정렬 처리 (DB 정렬) - * - title, createdAt, updatedAt 등 엔티티 필드 + * 정렬 조건 생성 + * - ALLOWED_SORT_FIELDS에 포함된 필드만 변환 + * - 정렬 지정이 없으면 createdAt DESC 기본 적용 */ private List> buildOrderSpecifiers(Pageable pageable) { QPost post = QPost.post; + PathBuilder entityPath = new PathBuilder<>(Post.class, post.getMetadata()); List> orders = new ArrayList<>(); - var entityPath = new com.querydsl.core.types.dsl.PathBuilder<>(Post.class, post.getMetadata()); for (Sort.Order order : pageable.getSort()) { - String prop = order.getProperty(); - - // 통계 필드는 메모리 정렬에서 처리 - if (prop.equals("likeCount") || prop.equals("bookmarkCount") || prop.equals("commentCount")) { - continue; - } + 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(prop, Comparable.class))); + orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(property, Comparable.class))); + } + + // 기본 정렬(createdAt DESC) + if (orders.isEmpty()) { + orders.add(new OrderSpecifier<>(Order.DESC, post.createdAt)); } + return orders; } /** * 게시글 조회 * - Post + User + UserProfile join (N+1 방지) - * - like/bookmark/comment count는 각각 서브쿼리로 계산 - * → 한 번의 SQL 안에서 처리 (쿼리 1회) + * - 카테고리 정보는 별도 injectCategories()에서 주입 */ private List fetchPosts(BooleanBuilder where, List> orders, Pageable pageable) { QPost post = QPost.post; QUser user = QUser.user; QUserProfile profile = QUserProfile.userProfile; - QPostLike like = QPostLike.postLike; - QPostBookmark bookmark = QPostBookmark.postBookmark; - QComment comment = QComment.comment; - - // 서브쿼리로 통계 계산 - Expression likeCount = ExpressionUtils.as( - JPAExpressions.select(like.count()) - .from(like) - .where(like.post.eq(post)), - "likeCount" - ); - - Expression bookmarkCount = ExpressionUtils.as( - JPAExpressions.select(bookmark.count()) - .from(bookmark) - .where(bookmark.post.eq(post)), - "bookmarkCount" - ); - - Expression commentCount = ExpressionUtils.as( - JPAExpressions.select(comment.count()) - .from(comment) - .where(comment.post.eq(post)), - "commentCount" - ); - // 메인 쿼리 return queryFactory .select(new QPostListResponse( post.id, - new QAuthorResponse(user.id, profile.nickname, profile.profileImageUrl), // 작성자 정보 (N+1 방지 join) + new QAuthorResponse(user.id, profile.nickname, profile.profileImageUrl), post.title, post.thumbnailUrl, Expressions.constant(Collections.emptyList()), // categories는 별도 주입 - likeCount, - bookmarkCount, - commentCount, + post.likeCount, + post.bookmarkCount, + post.commentCount, post.createdAt, post.updatedAt )) .from(post) .leftJoin(post.user, user) - .leftJoin(user.userProfile, profile) // UserProfile join으로 N+1 방지 + .leftJoin(user.userProfile, profile) .where(where) .orderBy(orders.toArray(new OrderSpecifier[0])) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) - .fetch(); // 쿼리 1회 (서브쿼리 포함) + .fetch(); } /** * 카테고리 일괄 조회 - * - Post ID 목록 기반으로 categoryMapping 테이블 IN 쿼리 1회 실행 - * - Map>로 매핑 후 DTO에 주입 - * - N+1 방지 (게시글별 조회 X) + * - postIds 기반 IN 쿼리 (1회) + * - N+1 방지 + * - Map>로 그룹핑 후 DTO에 매핑 */ private void injectCategories(List results) { - if (results.isEmpty()) return; - - QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; + QPostCategoryMapping mapping = QPostCategoryMapping.postCategoryMapping; - // postId 목록 생성 List postIds = results.stream() .map(PostListResponse::getPostId) .toList(); - // 해당하는 카테고리 정보 조회 - List categoryTuples = queryFactory + List tuples = queryFactory .select( - categoryMapping.post.id, + mapping.post.id, new QCategoryResponse( - categoryMapping.category.id, - categoryMapping.category.name, - categoryMapping.category.type + mapping.category.id, + mapping.category.name, + mapping.category.type ) ) - .from(categoryMapping) - .where(categoryMapping.post.id.in(postIds)) + .from(mapping) + .where(mapping.post.id.in(postIds)) .fetch(); - // 매핑 편의를 위해 변환 - Map> categoryMap = categoryTuples.stream() + Map> categoryMap = tuples.stream() .collect(Collectors.groupingBy( - tuple -> Objects.requireNonNull(tuple.get(categoryMapping.post.id)), + t -> Objects.requireNonNull(t.get(mapping.post.id)), Collectors.mapping(t -> t.get(1, CategoryResponse.class), Collectors.toList()) )); - // categories 주입 - results.forEach(r -> - r.setCategories(categoryMap.getOrDefault(r.getPostId(), List.of())) + results.forEach(post -> + post.setCategories(categoryMap.getOrDefault(post.getPostId(), List.of())) ); } /** - * 통계 기반 정렬 처리 (메모리) - * - likeCount / bookmarkCount / commentCount 등 통계 필드 - * - DB에서는 서브쿼리 필드 정렬 불가 → Java 단에서 정렬 - * - 데이터량이 페이지 단위(20~50건)라면 CPU 부하는 무시 가능 + * 전체 게시글 개수 조회 + * - 단순 count 쿼리 1회 + * - category 조합 조건 포함 시 중복 방지를 위해 countDistinct() 사용 */ - private List sortInMemoryIfNeeded(List results, Pageable pageable) { - if (results.isEmpty() || !pageable.getSort().isSorted()) return results; - - for (Sort.Order order : pageable.getSort()) { - Comparator comparator = null; - switch (order.getProperty()) { - case "likeCount" -> comparator = Comparator.comparing(PostListResponse::getLikeCount); - case "bookmarkCount" -> comparator = Comparator.comparing(PostListResponse::getBookmarkCount); - case "commentCount" -> comparator = Comparator.comparing(PostListResponse::getCommentCount); - } - if (comparator != null) { - if (order.isDescending()) comparator = comparator.reversed(); - results.sort(comparator); - } - } - return results; - } - - /** - * 전체 게시글 개수 카운트 - * - 페이지네이션 total 계산용 - * - categoryId가 있으면 mapping join 포함 - * - 단순 count 쿼리 1회 실행 - */ - private long countPosts(BooleanBuilder where, Long categoryId) { + private long countPosts(BooleanBuilder where) { QPost post = QPost.post; - QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; - - // 카운트 쿼리 - JPAQuery countQuery = queryFactory + Long total = queryFactory .select(post.countDistinct()) - .from(post); - - // 카테고리 필터링 - if (categoryId != null) { - countQuery.leftJoin(post.postCategoryMappings, categoryMapping); - } - - Long total = countQuery.where(where).fetchOne(); + .from(post) + .where(where) + .fetchOne(); return total != null ? total : 0L; } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/post/service/PostService.java b/src/main/java/com/back/domain/board/post/service/PostService.java index a0568788..b5db7766 100644 --- a/src/main/java/com/back/domain/board/post/service/PostService.java +++ b/src/main/java/com/back/domain/board/post/service/PostService.java @@ -69,8 +69,8 @@ public PostResponse createPost(PostRequest request, Long userId) { * 2. PageResponse 반환 */ @Transactional(readOnly = true) - public PageResponse getPosts(String keyword, String searchType, Long categoryId, Pageable pageable) { - Page posts = postRepository.searchPosts(keyword, searchType, categoryId, pageable); + public PageResponse getPosts(String keyword, String searchType, List categoryIds, Pageable pageable) { + Page posts = postRepository.searchPosts(keyword, searchType, categoryIds, pageable); return PageResponse.from(posts); } diff --git a/src/test/java/com/back/domain/board/comment/repository/custom/CommentLikeRepositoryImplTest.java b/src/test/java/com/back/domain/board/comment/repository/custom/CommentLikeRepositoryImplTest.java new file mode 100644 index 00000000..1d2e6e7a --- /dev/null +++ b/src/test/java/com/back/domain/board/comment/repository/custom/CommentLikeRepositoryImplTest.java @@ -0,0 +1,114 @@ +package com.back.domain.board.comment.repository.custom; + +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.entity.CommentLike; +import com.back.domain.board.comment.repository.CommentLikeRepository; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.config.QueryDslConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(QueryDslConfig.class) +@ActiveProfiles("test") +class CommentLikeRepositoryImplTest { + + @Autowired + private CommentLikeRepository commentLikeRepository; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + private Post post; + private Comment c1; + private Comment c2; + private Comment c3; + + @BeforeEach + void setUp() { + // 사용자 저장 + user = User.createUser("user1", "user1@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 게시글 + 댓글 3개 생성 + post = new Post(user, "게시글 제목", "내용", null); + postRepository.save(post); + + c1 = Comment.createRoot(post, user, "댓글1"); + c2 = Comment.createRoot(post, user, "댓글2"); + c3 = Comment.createRoot(post, user, "댓글3"); + commentRepository.saveAll(List.of(c1, c2, c3)); + + // 사용자가 c1, c3 좋아요 + commentLikeRepository.save(new CommentLike(c1, user)); + commentLikeRepository.save(new CommentLike(c3, user)); + } + + @Test + @DisplayName("findLikedCommentIdsIn - 사용자가 좋아요한 댓글 ID만 반환한다") + void findLikedCommentIdsIn_success() { + // when + List likedIds = commentLikeRepository.findLikedCommentIdsIn( + user.getId(), + List.of(c1.getId(), c2.getId(), c3.getId()) + ); + + // then + assertThat(likedIds) + .containsExactlyInAnyOrder(c1.getId(), c3.getId()) + .doesNotContain(c2.getId()); + } + + @Test + @DisplayName("findLikedCommentIdsIn - 좋아요한 댓글이 없는 경우 빈 리스트 반환") + void findLikedCommentIdsIn_emptyResult() { + // given + User anotherUser = User.createUser("user2", "user2@example.com", "encodedPwd"); + anotherUser.setUserProfile(new UserProfile(anotherUser, "작성자2", null, null, null, 0)); + anotherUser.setUserStatus(UserStatus.ACTIVE); + userRepository.save(anotherUser); + + // when + List likedIds = commentLikeRepository.findLikedCommentIdsIn( + anotherUser.getId(), + List.of(c1.getId(), c2.getId(), c3.getId()) + ); + + // then + assertThat(likedIds).isEmpty(); + } + + @Test + @DisplayName("findLikedCommentIdsIn - commentIds가 비어 있으면 빈 리스트 반환") + void findLikedCommentIdsIn_emptyInput() { + // when + List likedIds = commentLikeRepository.findLikedCommentIdsIn(user.getId(), List.of()); + + // then + assertThat(likedIds).isEmpty(); + } +} 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 new file mode 100644 index 00000000..99e6cf07 --- /dev/null +++ b/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java @@ -0,0 +1,138 @@ +package com.back.domain.board.comment.repository.custom; + +import com.back.domain.board.comment.dto.CommentListResponse; +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.config.QueryDslConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(QueryDslConfig.class) +@ActiveProfiles("test") +class CommentRepositoryImplTest { + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + private Post post; + private Comment parent1, parent2, parent3; + private Comment child11, child12, child21; + + @BeforeEach + void setUp() { + // 사용자 + user = User.createUser("user1", "user1@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 게시글 + post = new Post(user, "게시글 제목", "내용", null); + postRepository.save(post); + + // 부모 댓글 3개 + parent1 = Comment.createRoot(post, user, "부모1"); + parent2 = Comment.createRoot(post, user, "부모2"); + parent3 = Comment.createRoot(post, user, "부모3"); + commentRepository.saveAll(List.of(parent1, parent2, parent3)); + + // 자식 댓글 + child11 = Comment.createChild(post, user, "부모1의 자식1", parent1); + child12 = Comment.createChild(post, user, "부모1의 자식2", parent1); + child21 = Comment.createChild(post, user, "부모2의 자식1", parent2); + commentRepository.saveAll(List.of(child11, child12, child21)); + } + + @Test + @DisplayName("게시글의 부모 댓글 목록과 자식 댓글이 함께 조회된다 (총 쿼리 3회 예상)") + void getCommentsByPostId_success() { + // given + PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + Page page = commentRepository.getCommentsByPostId(post.getId(), pageable); + + // then + assertThat(page.getTotalElements()).isEqualTo(3L); // 부모 3개 + assertThat(page.getContent()).hasSize(3); + + CommentListResponse p1 = page.getContent().stream() + .filter(c -> c.getCommentId().equals(parent1.getId())) + .findFirst() + .orElseThrow(); + + CommentListResponse p2 = page.getContent().stream() + .filter(c -> c.getCommentId().equals(parent2.getId())) + .findFirst() + .orElseThrow(); + + // 부모-자식 매핑 검증 + assertThat(p1.getChildren()).extracting("commentId") + .containsExactlyInAnyOrder(child11.getId(), child12.getId()); + assertThat(p2.getChildren()).extracting("commentId") + .containsExactly(child21.getId()); + + // 부모3은 자식 없음 + CommentListResponse p3 = page.getContent().stream() + .filter(c -> c.getCommentId().equals(parent3.getId())) + .findFirst() + .orElseThrow(); + assertThat(p3.getChildren()).isEmpty(); + } + + @Test + @DisplayName("댓글이 없는 게시글은 빈 페이지 반환") + void getCommentsByPostId_empty() { + // given + Post newPost = new Post(user, "새 게시글", "내용", null); + postRepository.save(newPost); + PageRequest pageable = PageRequest.of(0, 5); + + // when + Page page = commentRepository.getCommentsByPostId(newPost.getId(), pageable); + + // then + assertThat(page.getTotalElements()).isZero(); + assertThat(page.getContent()).isEmpty(); + } + + @Test + @DisplayName("정렬 조건이 허용되지 않으면 기본 정렬(createdAt DESC)로 동작한다") + void getCommentsByPostId_sortFallback() { + // given: 허용되지 않은 정렬 필드 (likeCount만 허용됨) + PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "unknownField")); + + // when + Page page = commentRepository.getCommentsByPostId(post.getId(), pageable); + + // then + // createdAt DESC 기본 정렬이 적용되어, 마지막에 생성된 parent3이 먼저 나와야 함 + assertThat(page.getContent().get(0).getCommentId()).isEqualTo(parent3.getId()); + } +} 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 new file mode 100644 index 00000000..48cda3a1 --- /dev/null +++ b/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java @@ -0,0 +1,145 @@ +package com.back.domain.board.post.repository.custom; + +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.PostRepository; +import com.back.domain.board.post.repository.PostCategoryRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.config.QueryDslConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(QueryDslConfig.class) +@ActiveProfiles("test") +class PostRepositoryImplTest { + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostCategoryRepository categoryRepository; + + private User user; + private PostCategory math, science, teen, group2; + private Post post1, post2, post3; + + @BeforeEach + void setUp() { + // 사용자 + user = User.createUser("user1", "user1@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 카테고리 + math = categoryRepository.save(new PostCategory("수학", CategoryType.SUBJECT)); + science = categoryRepository.save(new PostCategory("과학", CategoryType.SUBJECT)); + teen = categoryRepository.save(new PostCategory("10대", CategoryType.DEMOGRAPHIC)); + group2 = categoryRepository.save(new PostCategory("2인", CategoryType.GROUP_SIZE)); + + // 게시글 + post1 = new Post(user, "수학 공부 팁", "내용1", null); + post2 = new Post(user, "과학 토론 모집", "내용2", null); + post3 = new Post(user, "10대 대상 스터디", "내용3", null); + postRepository.saveAll(List.of(post1, post2, post3)); + + // 카테고리 매핑 + post1.updateCategories(List.of(math, teen)); + post2.updateCategories(List.of(science)); + post3.updateCategories(List.of(teen, group2)); + } + + @Test + @DisplayName("기본 게시글 목록 조회 (카테고리, 키워드 없이)") + void searchPosts_basic() { + // given + PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + Page page = postRepository.searchPosts(null, null, null, pageable); + + // then + assertThat(page.getTotalElements()).isEqualTo(3); + assertThat(page.getContent()).extracting("title") + .containsExactlyInAnyOrder("수학 공부 팁", "과학 토론 모집", "10대 대상 스터디"); + } + + @Test + @DisplayName("검색어(keyword)와 searchType(title/content/author)에 따른 필터링이 적용된다") + void searchPosts_withKeyword() { + // given + PageRequest pageable = PageRequest.of(0, 10); + String keyword = "과학"; + + // when + Page page = postRepository.searchPosts(keyword, "title", null, pageable); + + // then + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent().getFirst().getTitle()).contains("과학"); + } + + @Test + @DisplayName("같은 타입(CategoryType.SUBJECT)은 OR, 다른 타입은 AND로 결합된다") + void searchPosts_withCategories() { + // given + PageRequest pageable = PageRequest.of(0, 10); + // SUBJECT 타입 2개 (math, science) + DEMOGRAPHIC 타입 1개 (teen) + List categoryIds = List.of(math.getId(), science.getId(), teen.getId()); + + // when + Page page = postRepository.searchPosts(null, null, categoryIds, pageable); + + // then + // SUBJECT는 (math OR science), DEMOGRAPHIC은 (teen) → (math OR science) AND teen + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent().getFirst().getTitle()).isEqualTo("수학 공부 팁"); + } + + @Test + @DisplayName("허용되지 않은 정렬 필드 사용 시 기본 정렬(createdAt DESC) 적용") + void searchPosts_sortFallback() { + // given + PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "unknownField")); + + // when + Page page = postRepository.searchPosts(null, null, null, pageable); + + // then + // createdAt DESC 기본 정렬 적용 + assertThat(page.getContent().getFirst().getPostId()).isEqualTo(post3.getId()); + } + + @Test + @DisplayName("게시글이 없는 경우 빈 페이지 반환") + void searchPosts_empty() { + // given + PageRequest pageable = PageRequest.of(0, 5); + Page page = postRepository.searchPosts("없는제목", "title", null, pageable); + + // then + assertThat(page.getTotalElements()).isZero(); + assertThat(page.getContent()).isEmpty(); + } +}