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 @@ -5,8 +5,8 @@
/**
* 댓글 좋아요 응답 DTO
*
* @param commentId
* @param likeCount
* @param commentId 댓글 ID
* @param likeCount 좋아요 수
*/
public record CommentLikeResponse(
Long commentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import jakarta.validation.constraints.NotBlank;

/**
* 댓글 작성 및 수정을 위한 요청 DTO
* 댓글 작성/수정 요청 DTO
*
* @param content 댓글 내용
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
/**
* 댓글 응답 DTO
*
* @param commentId 댓글 Id
* @param postId 게시글 Id
* @param commentId 댓글 ID
* @param postId 게시글 ID
* @param author 작성자 정보
* @param content 댓글 내용
* @param createdAt 댓글 생성 일시
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 댓글 생성 일시
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> findLikedCommentIdsIn(Long userId, Collection<Long> commentIds) {
QCommentLike commentLike = QCommentLike.commentLike;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,71 +15,65 @@
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<String> 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<CommentListResponse> getCommentsByPostId(Long postId, Pageable pageable) {
QComment comment = QComment.comment;
QCommentLike commentLike = QCommentLike.commentLike;

// 1. 정렬 조건 생성 (엔티티 필드 기반)
List<OrderSpecifier<?>> orders = buildOrderSpecifiers(pageable, comment);
// 1. 정렬 조건 생성
List<OrderSpecifier<?>> orders = buildOrderSpecifiers(pageable);

// 2. 부모 댓글 조회 (페이징)
// 2. 부모 댓글 조회 (페이징 적용)
List<CommentListResponse> parents = fetchComments(
comment.post.id.eq(postId).and(comment.parent.isNull()),
orders,
pageable.getOffset(),
pageable.getPageSize()
);

// 부모가 비어 있으면 즉시 빈 페이지 반환
if (parents.isEmpty()) {
return new PageImpl<>(parents, pageable, 0);
}

// 3. 부모 ID 목록 수집
// 3. 부모 ID 수집
List<Long> parentIds = parents.stream()
.map(CommentListResponse::getCommentId)
.toList();

// 4. 자식 댓글 조회 (부모 ID 기준)
// 4. 자식 댓글 조회 (부모 집합에 대한 전체 조회)
List<CommentListResponse> children = fetchComments(
comment.parent.id.in(parentIds),
List.of(comment.createdAt.asc()),
List.of(comment.createdAt.asc()), // 시간순 정렬
null,
null
);

// 5. 부모 + 자식 댓글 ID 합쳐 likeCount 조회 (쿼리 1회)
Map<Long, Long> 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)
Expand All @@ -95,7 +88,11 @@ public Page<CommentListResponse> 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<CommentListResponse> fetchComments(
BooleanExpression condition,
Expand All @@ -105,7 +102,7 @@ private List<CommentListResponse> fetchComments(
) {
QComment comment = QComment.comment;
QUser user = QUser.user;
QUserProfile profile = QUserProfile.userProfile;
QUserProfile profile = QUserProfile .userProfile;

var query = queryFactory
.select(new QCommentListResponse(
Expand All @@ -114,8 +111,8 @@ private List<CommentListResponse> 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은 별도 주입
Expand All @@ -126,42 +123,17 @@ private List<CommentListResponse> fetchComments(
.where(condition)
.orderBy(orders.toArray(new OrderSpecifier[0]));

// 페이징 적용
if (offset != null && limit != null) {
query.offset(offset).limit(limit);
}

return query.fetch();
}

/**
* likeCount 일괄 조회
* - IN 조건 기반 groupBy 쿼리 1회
* - 부모/자식 댓글을 한 번에 조회
*/
private Map<Long, Long> fetchLikeCounts(List<Long> parentIds, List<CommentListResponse> children) {
QCommentLike commentLike = QCommentLike.commentLike;

List<Long> 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<CommentListResponse> parents, List<CommentListResponse> children) {
if (children.isEmpty()) return;
Expand All @@ -170,53 +142,37 @@ private void mapChildrenToParents(List<CommentListResponse> parents, List<Commen
.collect(Collectors.groupingBy(CommentListResponse::getParentId));

parents.forEach(parent ->
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<OrderSpecifier<?>> buildOrderSpecifiers(Pageable pageable, QComment comment) {
private List<OrderSpecifier<?>> buildOrderSpecifiers(Pageable pageable) {
QComment comment = QComment.comment;
PathBuilder<Comment> entityPath = new PathBuilder<>(Comment.class, comment.getMetadata());
List<OrderSpecifier<?>> 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<CommentListResponse> sortInMemoryIfNeeded(List<CommentListResponse> results, Pageable pageable) {
if (results.isEmpty() || !pageable.getSort().isSorted()) return results;

for (Sort.Order order : pageable.getSort()) {
Comparator<CommentListResponse> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,7 +48,7 @@ public ResponseEntity<RsData<PageResponse<PostListResponse>>> 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<Long> categoryId
) {
PageResponse<PostListResponse> response = postService.getPosts(keyword, searchType, categoryId, pageable);
return ResponseEntity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -229,7 +231,7 @@ ResponseEntity<RsData<PageResponse<PostListResponse>>> 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<Long> categoryId
);


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/**
* 게시글 북마크 응답 DTO
*
* @param postId 게시글 id
* @param postId 게시글 ID
* @param bookmarkCount 북마크 수
*/
public record PostBookmarkResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/**
* 게시글 좋아요 응답 DTO
*
* @param postId 게시글 id
* @param postId 게시글 ID
* @param likeCount 좋아요 수
*/
public record PostLikeResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import java.util.List;

/**
* 게시글 생성 및 수정을 위한 요청 DTO
* 게시글 생성/수정 요청 DTO
*
* @param title 게시글 제목
* @param content 게시글 내용
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.util.List;

public interface PostRepositoryCustom {
Page<PostListResponse> searchPosts(String keyword, String searchType, Long categoryId, Pageable pageable);
Page<PostListResponse> searchPosts(String keyword, String searchType, List<Long> categoryIds, Pageable pageable);
}
Loading