JPA + QueryDSL을 이용한 계층형 댓글, 대댓글
Post
엔티티 클래스@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
@EntityListeners(AuditListener.class)
public class Post implements Auditable {
@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "post_id")
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, length = 1000)
private String content;
@Embedded
private TimeEntity timeEntity;
@ColumnDefault("0")
@Column(name = "view_count",nullable = false)
private Integer viewCount;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = LAZY,cascade = CascadeType.PERSIST)
@JoinColumn(name = "post_category_id")
private PostCategory postCategory;
@OneToMany(mappedBy = "post", orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
@OneToMany(mappedBy = "post", orphanRemoval = true)
private List<PostLike> postLikes = new ArrayList<>();
@OneToMany(mappedBy = "post", orphanRemoval = true)
private List<Scrap> scraps = new ArrayList<>();
Post
엔티티는 위와 같이 작성하였다. post
와 comment
의 관계는 1:N 관계이다. 이 글의 핵심은 계층형 댓글과 대댓글 관계이므로 부연 설명은 패스하겠다.Comment
엔티티 클래스@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
@EntityListeners(AuditListener.class)
public class Comment implements Auditable {
@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "comment_id")
private Long id;
@Column(nullable = false, length = 1000)
private String content;
@Embedded
private TimeEntity timeEntity;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "parent_id")
private Comment parent;
@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Comment> children = new ArrayList<>();
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "post_id")
private Post post;
comment
는 자기 자신을 selfjoin
을 하고있으며, 부모 댓글이 삭제될 시 하위 댓글들도 같이 삭제가 된다.게시글은 만들어져 있다고 가정하고 게시글, 댓글, 대 댓글을 한 번에 조회하는 쿼리를 작성해 보겠다.
@Getter
@Setter
@NoArgsConstructor
public class PostOneResponse {
private Long postId;
private String title;
private String content;
private int scrapCount;
private int commentCount;
private int likeCount;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime createDate;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime updateDate;
private Integer viewCount;
private String username;
private List<PostOneCommentResponse> comments = new ArrayList<>();
@QueryProjection
public PostOneResponse(Long postId, String title, String content, int scrapCount, int commentCount, int likeCount, LocalDateTime createDate, LocalDateTime updateDate, Integer viewCount, String username) {
this.postId = postId;
this.title = title;
this.content = content;
this.scrapCount = scrapCount;
this.likeCount = likeCount;
this.commentCount = commentCount;
this.createDate = createDate;
this.updateDate = updateDate;
this.viewCount = viewCount;
this.username = username;
}
}
QueryDSL
을 사용하기 때문에 위와같이 QueryProjection
어노테이션을 사용하는 DTO를 만들어 준다.Post
를 조회할 때 컬렉션 객체
는 바로 가져올 수 없으므로 따로 처리를 해주어야 한다.QueryDSL 코드
@RequiredArgsConstructor
public class PostRepositoryImpl implements PostCustomRepository {
private final JPAQueryFactory queryFactory;
@Override
public Optional<PostOneResponse> findOnePostById(Long postId) {
queryFactory.update(post)
.set(post.viewCount, post.viewCount.add(1))
.where(post.id.eq(postId))
.execute();
Optional<PostOneResponse> response = Optional.ofNullable(queryFactory
.select(new QPostOneResponse(
post.id,
post.title,
post.content,
post.scraps.size(),
post.comments.size(),
post.postLikes.size(),
post.timeEntity.createdDate,
post.timeEntity.updatedDate,
post.viewCount,
user.nickname))
.from(post)
.innerJoin(post.user, user)
.where(post.id.eq(postId))
.fetchOne());
if (response.isEmpty()) {
return Optional.empty();
}
List<PostOneCommentResponse> comments = queryFactory
.select(new QPostOneCommentResponse(
comment.parent.id,
comment.id,
comment.content,
user.nickname,
comment.timeEntity.createdDate,
comment.timeEntity.updatedDate))
.from(comment)
.innerJoin(comment.post, post)
.innerJoin(post.user, user)
.where(post.id.eq(postId).and(comment.parent.isNull()))
.orderBy(comment.id.asc())
.fetch();
response.get().setComments(comments);
return response;
}
viewCount
를 조회 시 자동으로 1씩 증가하게 만들어주었다. (이부분은 신경쓰지 않아도 됩니다.)PostOneResponse
를 이용해서 post
를 단건으로 조회한다.Optional.empty()
가 반환이 된다.post
를 조회한 후 parent
id가 null
이 들어가 있는 걸 조회한다. (최상위 댓글)setComment
를 사용해서 값을 넣어준다.Service 비즈니스 로직
/**
* 게시글 조회
*/
@Transactional
public PostOneResponse getOnePost(Long postId) {
PostOneResponse postOneResponse = postRepository.findOnePostById(postId)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_POST));
commentsExtractor(postId, postOneResponse);
return postOneResponse;
}
private void commentsExtractor(Long postId, PostOneResponse postOneResponse) {
postOneResponse.getComments()
.forEach(comment -> {
List<CommentsChildrenResponse> comments = commentRepository.findPostComments(postId, comment.getCommentId());
comment.setChildren(comments);
});
}
postOneResponse
객체에 값이 들어가 있을 것이다.postOneReponse
에 comments
컬렉션을 foreach
로 루프를 돌려서 commentId
와 postId
로 조회를 한다.findPostComments()
@Override
public List<CommentsChildrenResponse> findPostComments(Long postId, Long commentId) {
return queryFactory.select(new QCommentsChildrenResponse(
comment.parent.id,
comment.id,
comment.content,
user.nickname,
comment.timeEntity.createdDate,
comment.timeEntity.updatedDate))
.from(comment)
.innerJoin(comment.parent)
.innerJoin(comment.post, post)
.innerJoin(post.user, user)
.where(post.id.eq(postId).and(comment.parent.id.eq(commentId)))
.orderBy(comment.id.asc())
.fetch();
}
DB
에서 조회 후 반환된 값을 comment.setCildren() 메소드를 루프를 돌려서 List로 넣어준다.{
"postId": 1,
"title": "hihi",
"content": "dsadajklsdjskaldjlsajkdljakldjjdlasjdajdaaalkajdajdkasjdlas",
"scrapCount": 0,
"commentCount": 8,
"likeCount": 0,
"createDate": "2022-04-04 21:37:45",
"updateDate": null,
"viewCount": 101,
"username": "dasdad",
"comments": [
{
"parentId": null,
"commentId": 1,
"content": "무야호~",
"username": "dasdad",
"createDate": "2022-04-04 21:37:52",
"updateDate": "2022-04-06 01:31:45",
"children": [
{
"parentId": 1,
"commentId": 4,
"content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
"username": "dasdad",
"createDate": "2022-04-04 21:38:19",
"updateDate": null
},
{
"parentId": 1,
"commentId": 9,
"content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
"username": "dasdad",
"createDate": "2022-04-07 00:38:18",
"updateDate": null
}
]
},
{
"parentId": null,
"commentId": 2,
"content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
"username": "dasdad",
"createDate": "2022-04-04 21:38:04",
"updateDate": null,
"children": [
{
"parentId": 2,
"commentId": 5,
"content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
"username": "dasdad",
"createDate": "2022-04-04 21:38:21",
"updateDate": null
}
]
},
{
"parentId": null,
"commentId": 3,
"content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
"username": "dasdad",
"createDate": "2022-04-04 21:38:09",
"updateDate": null,
"children": [
{
"parentId": 3,
"commentId": 7,
"content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
"username": "dasdad",
"createDate": "2022-04-06 23:35:28",
"updateDate": null
},
{
"parentId": 3,
"commentId": 8,
"content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
"username": "dasdad",
"createDate": "2022-04-07 00:34:16",
"updateDate": null
}
]
}
]
}
postman
으로 테스트를 한 결과 다음과 같은 결과 화면을 볼 수 있다.하지만 아주 큰 문제가 있다.....😂 바로
N+1
N+1
문제가 있다.foreach
반복문으로 commentRepository.findPostComment()
메소드를 상위 댓글의 수 만큼 반복을 하게 되는데 만약 부모 댓글의 수가 100개라면 100번 이상의 Select
쿼리가 나가게 된다.일단 완성은 하였지만 다시 리팩토링을 할 예정이다.
post
를 조회 시 comment
의 모든 데이터를 정렬을 해서 가져오고, MAP
으로 값을 넣어주는 방식을 생각하고있다. 시간이 된다면 꼭 리팩토링을 할 예정이다.