Skip to content

Commit d2e26ef

Browse files
committed
Ref: board 엔티티 개선
1 parent b811177 commit d2e26ef

File tree

22 files changed

+439
-278
lines changed

22 files changed

+439
-278
lines changed

src/main/java/com/back/domain/board/comment/entity/Comment.java

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,63 +9,80 @@
99

1010
import java.util.ArrayList;
1111
import java.util.List;
12+
import java.util.Objects;
1213

1314
@Entity
1415
@Getter
1516
@NoArgsConstructor
1617
public class Comment extends BaseEntity {
1718
@ManyToOne(fetch = FetchType.LAZY)
18-
@JoinColumn(name = "post_id")
19+
@JoinColumn(name = "post_id", nullable = false)
1920
private Post post;
2021

2122
@ManyToOne(fetch = FetchType.LAZY)
22-
@JoinColumn(name = "user_id")
23+
@JoinColumn(name = "user_id", nullable = false)
2324
private User user;
2425

26+
@Column(nullable = false, columnDefinition = "TEXT")
2527
private String content;
2628

27-
// TODO: 추후 CommentRepositoryImpl#getCommentsByPostId 로직 개선 필요, ERD에도 반영할 것
2829
@Column(nullable = false)
2930
private Long likeCount = 0L;
3031

31-
// 해당 댓글의 부모 댓글
3232
@ManyToOne(fetch = FetchType.LAZY)
3333
@JoinColumn(name = "parent_comment_id")
3434
private Comment parent;
3535

36-
// 해당 댓글에 달린 대댓글 목록
3736
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
3837
private List<Comment> children = new ArrayList<>();
3938

4039
@OneToMany(mappedBy = "comment", cascade = CascadeType.ALL, orphanRemoval = true)
4140
private List<CommentLike> commentLikes = new ArrayList<>();
4241

4342
// -------------------- 생성자 --------------------
44-
public Comment(Post post, User user, String content) {
45-
this.post = post;
46-
this.user = user;
47-
this.content = content;
48-
}
49-
5043
public Comment(Post post, User user, String content, Comment parent) {
5144
this.post = post;
5245
this.user = user;
5346
this.content = content;
5447
this.parent = parent;
48+
post.addComment(this);
49+
user.addComment(this);
50+
}
51+
52+
// -------------------- 정적 팩토리 메서드 --------------------
53+
/** 루트 댓글 생성 */
54+
public static Comment createRoot(Post post, User user, String content) {
55+
return new Comment(post, user, content, null);
56+
}
57+
58+
/** 대댓글 생성 */
59+
public static Comment createChild(Post post, User user, String content, Comment parent) {
60+
Comment comment = new Comment(post, user, content, parent);
61+
parent.getChildren().add(comment);
62+
return comment;
63+
}
64+
65+
// -------------------- 연관관계 편의 메서드 --------------------
66+
public void addLike(CommentLike like) {
67+
this.commentLikes.add(like);
68+
}
69+
70+
public void removeLike(CommentLike like) {
71+
this.commentLikes.remove(like);
5572
}
5673

5774
// -------------------- 비즈니스 메서드 --------------------
58-
// 댓글 업데이트
75+
/** 댓글 내용 수정 */
5976
public void update(String content) {
6077
this.content = content;
6178
}
6279

63-
// 좋아요 수 증가
80+
/** 좋아요 수 증가 */
6481
public void increaseLikeCount() {
6582
this.likeCount++;
6683
}
6784

68-
// 좋아요 수 감소
85+
/** 좋아요 수 감소 */
6986
public void decreaseLikeCount() {
7087
if (this.likeCount > 0) {
7188
this.likeCount--;

src/main/java/com/back/domain/board/comment/entity/CommentLike.java

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,31 @@
22

33
import com.back.domain.user.entity.User;
44
import com.back.global.entity.BaseEntity;
5-
import jakarta.persistence.Entity;
6-
import jakarta.persistence.FetchType;
7-
import jakarta.persistence.JoinColumn;
8-
import jakarta.persistence.ManyToOne;
5+
import jakarta.persistence.*;
96
import lombok.AllArgsConstructor;
107
import lombok.Getter;
118
import lombok.NoArgsConstructor;
129

1310
@Entity
1411
@Getter
1512
@NoArgsConstructor
16-
@AllArgsConstructor
13+
@Table(
14+
uniqueConstraints = @UniqueConstraint(columnNames = {"comment_id", "user_id"})
15+
)
1716
public class CommentLike extends BaseEntity {
1817
@ManyToOne(fetch = FetchType.LAZY)
19-
@JoinColumn(name = "comment_id")
18+
@JoinColumn(name = "comment_id", nullable = false)
2019
private Comment comment;
2120

2221
@ManyToOne(fetch = FetchType.LAZY)
23-
@JoinColumn(name = "user_id")
22+
@JoinColumn(name = "user_id", nullable = false)
2423
private User user;
24+
25+
// -------------------- 생성자 --------------------
26+
public CommentLike(Comment comment, User user) {
27+
this.comment = comment;
28+
this.user = user;
29+
comment.addLike(this);
30+
user.addCommentLike(this);
31+
}
2532
}

src/main/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImpl.java

Lines changed: 87 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.back.domain.board.comment.dto.QCommentListResponse;
55
import com.back.domain.board.comment.entity.Comment;
66
import com.back.domain.board.comment.entity.QComment;
7+
import com.back.domain.board.comment.entity.QCommentLike;
78
import com.back.domain.board.common.dto.QAuthorResponse;
89
import com.back.domain.user.entity.QUser;
910
import com.back.domain.user.entity.QUserProfile;
@@ -15,63 +16,71 @@
1516
import com.querydsl.jpa.impl.JPAQueryFactory;
1617
import lombok.RequiredArgsConstructor;
1718
import org.springframework.data.domain.*;
18-
1919
import java.util.*;
2020
import java.util.stream.Collectors;
2121

2222
@RequiredArgsConstructor
2323
public class CommentRepositoryImpl implements CommentRepositoryCustom {
2424
private final JPAQueryFactory queryFactory;
2525

26-
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("createdAt", "updatedAt", "likeCount");
27-
26+
// TODO: Comment에 likeCount 필드 추가에 따른 로직 개선
2827
/**
29-
* 특정 게시글의 댓글 목록 조회
30-
* - 총 쿼리 수: 3회
31-
* 1.부모 댓글 목록을 페이징/정렬 조건으로 조회
32-
* 2.부모 ID 목록으로 자식 댓글 전체 조회
33-
* 3.부모건수(count) 조회
28+
* 게시글 ID로 댓글 목록 조회
29+
* - 부모 댓글 페이징 + 자식 댓글 전체 조회
30+
* - likeCount는 부모/자식 댓글을 한 번에 조회 후 주입
31+
* - likeCount 정렬은 메모리에서 처리
32+
* -쿼리 수: 4회 (부모조회 + 자식조회 + likeCount + count)
3433
*
35-
* @param postId 게시글 Id
34+
* @param postId 게시글 Id
3635
* @param pageable 페이징 + 정렬 조건
3736
*/
3837
@Override
3938
public Page<CommentListResponse> getCommentsByPostId(Long postId, Pageable pageable) {
4039
QComment comment = QComment.comment;
40+
QCommentLike commentLike = QCommentLike.commentLike;
4141

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

45-
// 2. 부모 댓글 조회 (페이징 적용)
45+
// 2. 부모 댓글 조회 (페이징)
4646
List<CommentListResponse> parents = fetchComments(
4747
comment.post.id.eq(postId).and(comment.parent.isNull()),
4848
orders,
4949
pageable.getOffset(),
5050
pageable.getPageSize()
5151
);
5252

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

58-
// 3. 부모 ID 수집
57+
// 3. 부모 ID 목록 수집
5958
List<Long> parentIds = parents.stream()
6059
.map(CommentListResponse::getCommentId)
6160
.toList();
6261

63-
// 4. 자식 댓글 조회 (부모 집합에 대한 전체 조회)
62+
// 4. 자식 댓글 조회 (부모 ID 기준)
6463
List<CommentListResponse> children = fetchComments(
6564
comment.parent.id.in(parentIds),
66-
List.of(comment.createdAt.asc()), // 시간순 정렬
65+
List.of(comment.createdAt.asc()),
6766
null,
6867
null
6968
);
7069

71-
// 5. 부모-자식 매핑
70+
// 5. 부모 + 자식 댓글 ID 합쳐 likeCount 조회 (쿼리 1회)
71+
Map<Long, Long> likeCountMap = fetchLikeCounts(parentIds, children);
72+
73+
// 6. likeCount 주입
74+
parents.forEach(p -> p.setLikeCount(likeCountMap.getOrDefault(p.getCommentId(), 0L)));
75+
children.forEach(c -> c.setLikeCount(likeCountMap.getOrDefault(c.getCommentId(), 0L)));
76+
77+
// 7. 부모-자식 매핑
7278
mapChildrenToParents(parents, children);
7379

74-
// 6. 전체 부모 댓글 수 조회
80+
// 8. 정렬 후처리 (통계 필드 기반)
81+
parents = sortInMemoryIfNeeded(parents, pageable);
82+
83+
// 9. 전체 부모 댓글 수 조회
7584
Long total = queryFactory
7685
.select(comment.count())
7786
.from(comment)
@@ -86,11 +95,7 @@ public Page<CommentListResponse> getCommentsByPostId(Long postId, Pageable pagea
8695
/**
8796
* 댓글 조회
8897
* - User / UserProfile join (N+1 방지)
89-
*
90-
* @param condition where 조건
91-
* @param orders 정렬 조건
92-
* @param offset 페이징 offset (null이면 미적용)
93-
* @param limit 페이징 limit (null이면 미적용)
98+
* - likeCount는 이후 주입
9499
*/
95100
private List<CommentListResponse> fetchComments(
96101
BooleanExpression condition,
@@ -121,17 +126,42 @@ private List<CommentListResponse> fetchComments(
121126
.where(condition)
122127
.orderBy(orders.toArray(new OrderSpecifier[0]));
123128

124-
// 페이징 적용
125129
if (offset != null && limit != null) {
126130
query.offset(offset).limit(limit);
127131
}
128132

129133
return query.fetch();
130134
}
131135

136+
/**
137+
* likeCount 일괄 조회
138+
* - IN 조건 기반 groupBy 쿼리 1회
139+
* - 부모/자식 댓글을 한 번에 조회
140+
*/
141+
private Map<Long, Long> fetchLikeCounts(List<Long> parentIds, List<CommentListResponse> children) {
142+
QCommentLike commentLike = QCommentLike.commentLike;
143+
144+
List<Long> allIds = new ArrayList<>(parentIds);
145+
allIds.addAll(children.stream().map(CommentListResponse::getCommentId).toList());
146+
147+
if (allIds.isEmpty()) return Map.of();
148+
149+
return queryFactory
150+
.select(commentLike.comment.id, commentLike.count())
151+
.from(commentLike)
152+
.where(commentLike.comment.id.in(allIds))
153+
.groupBy(commentLike.comment.id)
154+
.fetch()
155+
.stream()
156+
.collect(Collectors.toMap(
157+
tuple -> tuple.get(commentLike.comment.id),
158+
tuple -> tuple.get(commentLike.count())
159+
));
160+
}
161+
132162
/**
133163
* 부모/자식 관계 매핑
134-
* - 자식 목록을 parentId 기준으로 그룹화 후, 각 부모 DTO의 children에 설정
164+
* - childMap을 parentId 기준으로 그룹화 후 children 필드에 set
135165
*/
136166
private void mapChildrenToParents(List<CommentListResponse> parents, List<CommentListResponse> children) {
137167
if (children.isEmpty()) return;
@@ -140,37 +170,53 @@ private void mapChildrenToParents(List<CommentListResponse> parents, List<Commen
140170
.collect(Collectors.groupingBy(CommentListResponse::getParentId));
141171

142172
parents.forEach(parent ->
143-
parent.setChildren(childMap.getOrDefault(parent.getCommentId(), Collections.emptyList()))
173+
parent.setChildren(childMap.getOrDefault(parent.getCommentId(), List.of()))
144174
);
145175
}
146176

147177
/**
148-
* 정렬 조건 생성
149-
* - Pageable의 Sort 정보를 QueryDSL OrderSpecifier 목록으로 변환
178+
* 정렬 처리 (DB 정렬)
179+
* - createdAt, updatedAt 등 엔티티 필드
150180
*/
151-
private List<OrderSpecifier<?>> buildOrderSpecifiers(Pageable pageable) {
152-
QComment comment = QComment.comment;
181+
private List<OrderSpecifier<?>> buildOrderSpecifiers(Pageable pageable, QComment comment) {
153182
PathBuilder<Comment> entityPath = new PathBuilder<>(Comment.class, comment.getMetadata());
154183
List<OrderSpecifier<?>> orders = new ArrayList<>();
155184

156185
for (Sort.Order order : pageable.getSort()) {
157-
String property = order.getProperty();
186+
String prop = order.getProperty();
158187

159-
// 화이트리스트에 포함된 필드만 허용
160-
if (!ALLOWED_SORT_FIELDS.contains(property)) {
161-
// 허용되지 않은 정렬 키는 무시 (런타임 예외 대신 안전하게 스킵)
188+
// 통계 필드는 메모리 정렬에서 처리
189+
if (prop.equals("likeCount")) {
162190
continue;
163191
}
164-
165192
Order direction = order.isAscending() ? Order.ASC : Order.DESC;
166-
orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(property, Comparable.class)));
193+
orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(prop, Comparable.class)));
167194
}
168195

169-
// 명시된 정렬이 없으면 기본 정렬(createdAt DESC) 적용
170-
if (orders.isEmpty()) {
171-
orders.add(new OrderSpecifier<>(Order.DESC, comment.createdAt));
196+
return orders;
197+
}
198+
199+
/**
200+
* 통계 기반 정렬 처리 (메모리)
201+
* - likeCount 등 통계 필드
202+
* - 페이지 단위라 성능에 영향 없음
203+
*/
204+
private List<CommentListResponse> sortInMemoryIfNeeded(List<CommentListResponse> results, Pageable pageable) {
205+
if (results.isEmpty() || !pageable.getSort().isSorted()) return results;
206+
207+
for (Sort.Order order : pageable.getSort()) {
208+
Comparator<CommentListResponse> comparator = null;
209+
210+
if ("likeCount".equals(order.getProperty())) {
211+
comparator = Comparator.comparing(CommentListResponse::getLikeCount);
212+
}
213+
214+
if (comparator != null) {
215+
if (order.isDescending()) comparator = comparator.reversed();
216+
results.sort(comparator);
217+
}
172218
}
173219

174-
return orders;
220+
return results;
175221
}
176-
}
222+
}

src/main/java/com/back/domain/board/comment/service/CommentService.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,15 @@ public class CommentService {
3838
private final PostRepository postRepository;
3939
private final ApplicationEventPublisher eventPublisher;
4040

41+
// TODO: 연관관계 고려, 메서드 명, 중복 코드 제거, 주석 통일
42+
// TODO: comment 끝나면 post도 해야 함.. entity > DTO > Repo > Service > Controller > Docs 순으로..
4143
/**
4244
* 댓글 생성 서비스
4345
* 1. User 조회
4446
* 2. Post 조회
45-
* 3. Comment 생성
46-
* 4. Comment 저장 및 CommentResponse 반환
47+
* 3. Comment 생성 및 저장
48+
* 4. 댓글 작성 이벤트 발행
49+
* 5. CommentResponse 반환
4750
*/
4851
public CommentResponse createComment(Long postId, CommentRequest request, Long userId) {
4952
// User 조회
@@ -54,10 +57,11 @@ public CommentResponse createComment(Long postId, CommentRequest request, Long u
5457
Post post = postRepository.findById(postId)
5558
.orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND));
5659

57-
// Comment 생성
58-
Comment comment = new Comment(post, user, request.content());
60+
// CommentCount 증가
61+
post.increaseCommentCount();
5962

60-
// Comment 저장 및 응답 반환
63+
// Comment 생성 및 저장
64+
Comment comment = Comment.createRoot(post, user, request.content());
6165
commentRepository.save(comment);
6266

6367
// 댓글 작성 이벤트 발행

0 commit comments

Comments
 (0)