기존코드
PostCommentGetService
@Service
@RequiredArgsConstructor
public class PostCommentGetService {
private final PostCommentRepositoryCustom postCommentRepositoryCustom;
@Transactional
public List<PostCommentGetResponse> getPostComment(long postId) {
List<PostComment> postCommentList = postCommentRepositoryCustom.findByPostId(postId);
List<PostCommentGetResponse> responseList = new ArrayList<>();
Map<Long, PostCommentGetResponse> responseHashMap = new HashMap<>();
postCommentList.forEach(postComment -> {
PostCommentGetResponse postCommentGetResponse = PostCommentGetResponse.convertCommentToDTO(postComment);
responseHashMap.put(postCommentGetResponse.getPostCommentId(), postCommentGetResponse);
// 대댓글인 경우 부모 댓글의 replies에 추가
Optional.ofNullable(postComment.getParentComment())
.map(parent -> responseHashMap.get(parent.getId()))
.ifPresent(parentResponse -> parentResponse.getReplies().add(postCommentGetResponse));
// 댓글인 경우 ResponseList에 추가
if (postComment.getParentComment() == null) {
responseList.add(postCommentGetResponse);
}
});
return responseList;
}
}
PostCommentRepositoryImpl
@Repository
@RequiredArgsConstructor
public class PostCommentRepositoryImpl implements PostCommentRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<PostComment> findByPostId(Long postId) {
return queryFactory
.selectFrom(postComment)
.join(postComment.parentComment).fetchJoin()
.where(postComment.post.id.eq(postId))
.orderBy(postComment.parentComment.id.asc().nullsFirst(),
postComment.createdAt.asc())
.fetch();
}
}
PostCommentGetResponse
@Data
@AllArgsConstructor
public class PostCommentGetResponse {
private long postCommentId;
private String contents;
private List<PostCommentGetResponse> replies = new ArrayList<>();
public PostCommentGetResponse(long id, String contents) {
this.postCommentId = id;
this.contents = contents;
}
public static PostCommentGetResponse convertCommentToDTO(PostComment postComment) {
return new PostCommentGetResponse(postComment.getId(), postComment.getContents());
}
}
기존 로직 설명
댓글1
댓글2
대댓글 1-1
대댓글 1-2
대댓글 2-1
대댓글 1-3
대댓글 2-2
PostCommentRepositoryImpl에서 이런 형식으로 엔티티를 반환합니다. 그 후 서비스 단에서 HashMap을 통해
PostCommentGetResponse로 변환합니다 . 최종적으로 아래와 같은 Response로 반환합니다.
댓글1
대댓글 1-1
대댓글 1-2
대댓글 1-3
댓글2
대댓글 2-1
대댓글 2-2
변경사항
- Response에댓글 생성일자댓글 작성자 학교 logo URL 추가
- 댓글 작성자 ID
- 댓글 좋아요 수
- 서비스 단에서 DTO로 변환 → 쿼리 반환값을 DTO로 받기
- 페이징 형식으로
Response 먼저 변경하겠습니다.
변경후 PostCommentResponse
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostCommentResponse {
private long postCommentId;
private String contents;
private Long likeCount;
private LocalDateTime createdAt;
private long memberId;
private String logoUrl;
private List<PostCommentChildrenResponse> replies = new ArrayList<>();
@QueryProjection
public PostCommentResponse(long postCommentId, String contents, LocalDateTime creat리팩토링1edAt, long memberId, Long likeCount, String logoUrl) {
this.postCommentId = postCommentId;
this.contents = contents;
this.createdAt = createdAt;
this.memberId = memberId;
this.likeCount = likeCount;
this.logoUrl = logoUrl;
}
public void setPostCommentChildrenResponse(List<PostCommentChildrenResponse> replies) {
this.replies = replies;
}
}ㅣ
쿼리dsl에서 DTO로 반환을 받기 위해서는 `Projections`을 사용해야 합니다
Projection
Projection을 사용하면 엔티티가 아니라 선택적으로 필드를 반환 받을 수 있습니다.
Projection을 통해 DTO로 반환을 받는 방법은 https://9hyuk9.tistory.com/85를 참고해보세요 ~
저는 그중 컴파일 시점에 타입 안정성을 확보할 수 있는 @QueryProjection 를 사용했어요.
@QueryProjection 의 타입 안전성
- 컴파일 시점의 검증: QueryDSL의 APT(Annotation Processing Tool)가 이를 처리하여 Q-타입 클래스를 생성합니다. 이 Q-타입 클래스는 DTO의 생성자 파라미터와 정확히 일치하는 필드와 생성자를 가집니다. 이로 인해, 쿼리에서 DTO를 생성할 때 발생할 수 있는 타입 불일치 문제를 컴파일 시점에 잡아낼 수 있습니다.
- 명시적인 생성자 매핑: Q-타입 클래스를 사용하면 생성자의 파라미터를 명시적으로 지정해야 합니다. 이는 개발자가 실수로 필드를 빠뜨리거나 순서를 잘못 지정하는 것을 방지합니다.
단점
- DTO가 Querydsl에 대한 의존성이 생긴다는 단점이 있다.(아직까지는 의존성으로 문제가 되지는 않았습니다)
고민
- Response에댓글 생성일자댓글 작성자 학교 logo URL 추가
- 댓글 작성자 ID
- 댓글 좋아요 수
- 댓글 좋아요는 PostComment 테이블에 없고 PostCommentLike라는 테이블이 따로 있다. select을 어떻게 해야하지지?
- postComment의 속성들과 학교 logoURL의 테이블 관계가 멀다 댓글 작성자의 학교 logoURL도 반환해주어야 한다. PostComment → Member 와 IdCard → member를 조인해서 IdCard의 UniversityName을 구한 후, UniversityAsset 테이블에서 UniversityName과 일치하는 logoURL을 가져와야 해요. 보기만 해도 과정이 복잡해서 쿼리문을 작성하는데 두려움이 있고 엔티티 설계를 잘못했나라는 생각이 들기도 합니당…
- DTO 속성안에 DTO LIST가 있을 경우 하나의 쿼리로 가져올 수 있을까? 대댓글을 가져올 대댓글 엔티티 자체를 가져오고 싶은 게 아니라 댓글과 같이 DTO(댓글 좋아요 수 , 댓글 생성일자, 댓글 작성자 ID, 댓글 작성자 학교 logo URL 추가, 내용, 대댓글ID)를 가져오고 싶다
- 부모(댓글)와 자식(대댓글)을 그룹으로 묶고 정렬하는 것을 쿼리문에서 할까 어플리케이션에서 할까?
- 댓글 엔티티의 그룹넘버, 깊이, 순서 속성을 추가할까? 기존 속성을 활용할까?
- DTO로 반환받는 것이 아닌 엔티티로 반환 받고 DTO로 수정할까?
고민 1에 대한 해결법(PostCommentLike 테이블에 있는 댓글 좋아요 개수를 어떻게 가져올까?)
댓글 좋아요를 구하는 방법에는 3가지 방법이 있습니다.
- join
- SubQuery
- 쿼리 분리
JOIN은 일반적으로 서브쿼리보다 성능이 우수한 경우가 많습니다. 그러나 PostCommentLike를 구하기 위해서는 집계 함수를 사용해야 해서 메인 쿼리가 복잡해질 것을 예상하여 가독성을 높이기 위함과 학습을 위해 서브쿼리를 사용하였습니다.
혹시나 select에서 서브쿼리를 사용하는 경우 쿼리를 분리하는 거랑 결과가 똑같은 거 아냐? 라는 궁금증이 생길 수도 있습니다.
⇒ 그럼에도 서브쿼리를 사용하는 이유는 서브쿼리는 메인 쿼리와 함께 한 번의 데이터베이스 호출로 처리되어 데이터베이스 트래픽 부하를 줄이기 위함입니다!
(+) mysql. 5.6 이후 서브쿼리가 최적화 되었습니다!
JPQLQuery<Long> likeCountSubQuery = JPAExpressions
.select(postCommentLike.count())
.from(postCommentLike)
.where(postCommentLike.postComment.eq(postComment));
고민 2에 대한 해결법(postComment의 속성들과 학교 logoURL의 테이블 관계가 멀다)
고민 2도 마찬가지로 서브쿼리로 해결했습니다.
필요한 속성들이 관계가 먼 경우 중복될지라도 테이블에 속성을 추가하는 방법도 있겠지만, 기존 로직을 수정하는데 비용이 많이 들것이라 판단하여 2번의 조인을 거치는 걸로 결정했습니다. 후에 성능적인 퍼포먼스가 많이 떨어져 문제가 될 시 리팩토링 할 계획입니다.
JPQLQuery<String> logoUrlSubQuery = JPAExpressions
.select(universityAsset.logoUrl)
.from(idCard)
.innerJoin(universityAsset).on(universityAsset.universityName.eq(idCard.university))
.where(idCard.member.id.eq(postComment.member.id));
고민 3에 대한 해결법(DTO 속성안에 DTO LIST가 있을 경우 하나의 쿼리로 가져올 수 있을까?)
- WITH RECURSIVE
- 애플리케이션에서 처리 디비에서 정렬이 안된 댓글,대댓글을 몽땅 가져온다 → 애플리케이션단에서 대댓글에게 댓글을 찾아줘서 정렬해준다 반복문을 쓰기보다 애플리케이션에서 처리하는 것이 성능적으로 좋은 퍼포먼스를 낼 것 같아 애플리케이션에서 처리하기로 하였습니다.
- 문제) 자식 엔티티를 받는 것이 아닌 자식 dto를 받는 것이기 때문에 애플리케이션에서 처리를 못함. 어차피 쿼리를 한 번 더 날려야 함
애플리케이션에서 처리방식:
쿼리문에서 dto받기(댓글,대댓글)
→ HashMap 자료구조를 통해 댓글 dto에 대댓글 dto 넣어주기
→ 반환을 위해 HashMap을 List형식으로 변환
- 부모댓글dto를 받는 쿼리랑 자식대댓글dto 받는 쿼리 분리, 부모댓글dto 받은 후 → 자식 대댓글 dto 받기
⇒ 쿼리문에서 dto로 받기의 장점, 서비스코드에서의 간결한 코드가 사라진 것 같아 좋지 못한 것 같습니다. 다시 쿼리문에서의 정렬을 고민해보겠습니다.
⇒ WITH RECURSIVE 구문은 mysql이 최적화가 되어 있지 않고 남이 코드를 이해하는 데 힘들 것 같습니다. 앞서 언급했던 것 처럼 애플리케이션에서 처리하더라도 쿼리문을 한 번 더 날리기 때문에 깔끔한 코드를 위해 2번 방식을 선택하겠습니다.
리팩토링1
@Override
public List<PostCommentResponse> findByPostId(Long postId) {
JPQLQuery<Long> likeCountSubQuery = JPAExpressions
.select(postCommentLike.count())
.from(postCommentLike)
.where(postCommentLike.postComment.eq(postComment));
JPQLQuery<String> logoUrlSubQuery = JPAExpressions
.select(universityAsset.logoUrl)
.from(idCard)
.innerJoin(universityAsset).on(universityAsset.universityName.eq(idCard.university))
.where(idCard.member.id.eq(postComment.member.id));
List<PostCommentResponse> parentComments = queryFactory
.select(new QPostCommentResponse(
postComment.parentComment.id,
postComment.id,
postComment.contents,
postComment.createdAt,
postComment.member.id,
likeCountSubQuery,
logoUrlSubQuery))
.from(postComment)
.where(postComment.parentComment.id.isNull())
.where(postComment.post.id.eq(postId))
.orderBy(postComment.createdAt.asc())
.fetch();
for (PostCommentResponse parentComment : parentComments) {
List<PostCommentResponse> childComments = queryFactory
.select(new QPostCommentResponse(
postComment.parentComment.id,
postComment.id,
postComment.contents,
postComment.createdAt,
postComment.member.id,
likeCountSubQuery,
logoUrlSubQuery))
.from(postComment)
.where(postComment.parentComment.id.eq(parentComment.getPostCommentId()))
.where(postComment.post.id.eq(postId))
.orderBy(postComment.createdAt.asc())
.fetch();
parentComment.setReplies(childComments);
}
return parentComments;
}
쿼리가 다소 길어지긴 했지만 직관적이라 유지보수하기 쉬운 코드라고 생각합니다.
통합테스트를 돌려 쿼리문을 확인해 보겠습니다
Hibernate:
select
p1_0.post_comment_id,
p1_0.contents,
p1_0.created_at,
p1_0.member_id,
(select
count(p2_0.post_comment_like_id)
from
post_comment_like p2_0
where
p2_0.post_comment_id=p1_0.post_comment_id),
(select
u1_0.logo_url
from
post_comment p4_0
join
id_card i1_0
on i1_0.member_id=p4_0.member_id
join
university_asset u1_0
on u1_0.university_name=i1_0.university)
from
post_comment p1_0
where
p1_0.parent_id is null
order by
p1_0.created_at
Hibernate:
select
p1_0.post_comment_id,
p1_0.contents,
p1_0.created_at,
p1_0.member_id,
(select
count(p2_0.post_comment_like_id)
from
post_comment_like p2_0
where
p2_0.post_comment_id=p1_0.post_comment_id),
(select
u1_0.logo_url
from
post_comment p4_0
join
id_card i1_0
on i1_0.member_id=p4_0.member_id
join
university_asset u1_0
on u1_0.university_name=i1_0.university)
from
post_comment p1_0
where
p1_0.parent_id=?
order by
p1_0.created_at
Hibernate:
select
p1_0.post_comment_id,
p1_0.contents,
p1_0.created_at,
p1_0.member_id,
(select
count(p2_0.post_comment_like_id)
from
post_comment_like p2_0
where
p2_0.post_comment_id=p1_0.post_comment_id),
(select
u1_0.logo_url
from
post_comment p4_0
join
id_card i1_0
on i1_0.member_id=p4_0.member_id
join
university_asset u1_0
on u1_0.university_name=i1_0.university)
from
post_comment p1_0
where
p1_0.parent_id=?
order by
p1_0.created_at
보다시피 자식대댓글을 구할 때 부모댓글의 수만큼 쿼리가 나갑니다.
for문 때문에 불가피합니다. 댓글이 그리 많이 달리지 않겠지만 부모의 댓글의 수만큼 자식 대댓글 구하는 쿼리가 나가는 문제: 반복 루프로 인한 추가 쿼리 호출 문제
연관된 엔티티나 컬렉션을 SQL 한번에 함께 조회하기
방법1. Fetch join
페치 조인과 일반 조인의 차이
- 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)
- 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념이다.
- 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화
둘 이상의 컬렉션은 페치 조인 하면 좋지 않다.
💡 EntityGraph
- Fetch Join을 쉽게 사용하게 하는 어노테이션
- LEFT OUTER JOIN 사용
- ~일 관계에서만 Fetch join 쓰기
방법2. @BatchSize
지정된 size 만큼 SQL의 IN절을 사용해서 조회한다.
batch size를 사용하면 지정된 크기만큼의 자식 엔티티를 한 번의 쿼리로 로드합니다.
💡 Fetch Join과 batch size의 차이
- batch size: Lazy Loading을 유지하며 필요한 자식 데이터를 효율적으로 로드 가능.
- Fetch Join: 자식 데이터의 개수가 적고 한 번에 가져오고 싶을 때
문제
위 방법들을 통해 가져오는 것은 연관된 엔티티입니다. dto를 가져와야하는 상황에서 위 방법들은 사용할 수 없다는 것을 깨달았습니다.
리팩토링3
for문으로 인해 select이 여러번 나가는 것을 제거하기 위해 댓글,대댓글 dto를 전부 쿼리문으로 받고 애플리케이션 단에서 부모댓글 dto에 자식대댓글 dto를 넣어주기로 결정했습니다.
PostCommentGetService
@Transactional(readOnly = true)
public List<PostCommentResponse> getPostComment(long postId) {
List<PostCommentResponse> commentResponses = postCommentRepositoryCustom.findByPostId(postId);
Map<Long, PostCommentResponse> commentMap = new HashMap<>();
List<PostCommentResponse> parentCommentResponses = new ArrayList<>();
for (PostCommentResponse commentResponse : commentResponses) {
commentMap.put(commentResponse.getPostCommentId(), commentResponse);
if (commentResponse.getParentCommentId() == null) {
parentCommentResponses.add(commentResponse);
} else {
commentMap.get(commentResponse.getParentCommentId()).getReplies().add(commentResponse);
}
}
return parentCommentResponses;
}
PostCommentRepositoryImpl
@Override
public List<PostCommentResponse> findByPostId(Long postId) {
JPQLQuery<Long> likeCountSubQuery = JPAExpressions
.select(postCommentLike.count())
.from(postCommentLike)
.where(postCommentLike.postComment.eq(postComment));
JPQLQuery<String> logoUrlSubQuery = JPAExpressions
.select(universityAsset.logoUrl)
.from(idCard)
.innerJoin(universityAsset).on(universityAsset.universityName.eq(idCard.university))
.where(idCard.member.id.eq(postComment.member.id));
List<PostCommentResponse> comments = queryFactory
.select(new QPostCommentResponse(
postComment.parentComment.id,
postComment.id,
postComment.contents,
postComment.createdAt,
postComment.member.id,
likeCountSubQuery,
logoUrlSubQuery))
.from(postComment)
.where(postComment.post.id.eq(postId))
.orderBy(postComment.createdAt.asc())
.fetch();
return comments;
}
Hibernate:
select
p1_0.parent_id,
p1_0.post_comment_id,
p1_0.contents,
p1_0.created_at,
p1_0.member_id,
(select
count(p3_0.post_comment_like_id)
from
post_comment_like p3_0
where
p3_0.post_comment_id=p1_0.post_comment_id),
(select
u1_0.logo_url
from
post_comment p5_0
join
id_card i1_0
on i1_0.member_id=p5_0.member_id
join
university_asset u1_0
on u1_0.university_name=i1_0.university)
from
post_comment p1_0
where
p1_0.post_id=?
order by
p1_0.created_at
테스트 시나리오
- member:10만
- post: 10만
- comments: 110만, 테스트 대상 post의 comments: 1000개
- likes: 30만
- 스레드 수: 1000
- 스레드 당 요청 수: 1
- 테스트 시간: 30초
Before
After
반복 루프로 인한 추가 쿼리 호출(N+1문제 비슷) 문제를 해결하고 쿼리문을 하나로 만들었드니 평균 응답시간이
21000ms -> 20ms 로 성능이 개선되었습니다!
최종응답값
{
"statusResponse": {
"resultCode": "F000",
"resultMessage": "요청 정상 처리"
},
"data": [
{
"parentCommentId": null,
"postCommentId": 1,
"contents": "테스트 1 댓글내용입니다.",
"createdAt": "2023-11-26T17:58:58.484093",
"memberId": 1,
"likeCount": 2,
"logoUrl": "/가천",
"replies": [
{
"parentCommentId": 1,
"postCommentId": 2,
"contents": "테스트 1-1 대댓글내용입니다.",
"createdAt": "2023-11-26T17:58:58.498568",
"memberId": 2,
"likeCount": 1,
"logoUrl": "/가천",
"replies": []
},
{
"parentCommentId": 1,
"postCommentId": 3,
"contents": "테스트 1-2 대댓글내용입니다.",
"createdAt": "2023-11-26T17:58:58.508543",
"memberId": 3,
"likeCount": 0,
"logoUrl": "/가천",
"replies": []
}
]
},
{
"parentCommentId": null,
"postCommentId": 4,
"contents": "테스트 2 댓글내용입니다.",
"createdAt": "2023-11-26T17:58:58.560915",
"memberId": 2,
"likeCount": 0,
"logoUrl": "/가천",
"replies": [
{
"parentCommentId": 4,
"postCommentId": 5,
"contents": "테스트 2-1 댓글내용입니다.",
"createdAt": "2023-11-26T17:58:58.565903",
"memberId": 1,
"likeCount": 0,
"logoUrl": "/가천",
"replies": []
}
]
}
]
}
+) FetchType.EAGER와 join fetch은 무엇이 다를까?
- FetchType.EAGER
@Entity
public class Post {
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 설정
private Member member;
}
// 사용 시
Post post = postRepository.findById(1L); // member도 함께 조회됨
2. join fetch (QueryDSL)
// QueryDSL에서 fetch join 사용
Post post = queryFactory
.selectFrom(post)
.join(post.member).fetchJoin() // 필요한 시점에 fetch join
.where(post.id.eq(1L))
.fetchOne();
결론:
- 성능 자체는 동일한 쿼리가 실행될 경우 차이가 없음
- 하지만 fetch join이 더 선호되는 이유:
- 명시적인 제어 가능
- 필요한 시점에만 조인
따라서 실무에서는 FetchType.EAGER 보다는 fetch join 사용을 권장한다고 합니다.
+) 서브쿼리 vs 3개의 테이블과 join 성능차이
서브쿼리
3개의 테이블과 join
테스트 시나리오는 위에서 진행한 테스트와 동일합니다.
대부분 상황에서 join이 서브쿼리보다 좋은 선택지라고 해서 얼마나 차이날까 궁금해서 성능 테스트를 해보았습니다.
보시다시피 서브쿼리와 join의 성능차이가 거의 없습니다. 보통 데이터가 5천개 ~ 만 개 정도 되어야 차이가 난다고 하네요
저는 학습을 위해서 서브쿼리를 선택한 것이 큰데요. 그렇더라도 성능 확장성에 열어두어야 하니까 앞으로 서브쿼리 대신 join을 주로 사용할 예정입니다!
+) 페이징
- Offset방식: Offset과 limit 예약어를 통하여 select의 전체 결과 중 일부만 가져오는 방법이다.
- Cursor 방식 cursor는 어떠한 레코드를 가르키는 포인터이고 이 Cursor가 가르키는 레코드부터 일정 개수만큼 가져오는 방식이다. Sek Method, Keyset Pagination이라고도 한다.
댓글과 대댓글에서는 페이징을 사용하지 않을 겁니다. 페이징은 대량의 데이터를 부분으로 쪼개 가져오는 방식입니다. 만들고 있는 서비스랑 비슷한 에브리타임을 선례로 살펴보겠습니다. 에브리타임에서 글에 댓글 같은 경우 대부분 1000개가 넘지 않아요. 이정도는 페이징을 쓰지 않아도 될 것 같아요. 또 댓글과 대댓글을 테이블에서 가져올 때 페이징을 통해 잘라서 가져오면 댓글3, 대댓글3-1은 가져왔으나 대댓글3-2은 안 가져올 경우의 수가 있기 때문에(물론 부모댓글을 기준으로 가져오면 되겠지만 구현이 복잡할 것 같습니다!) 페이징을 쓰지 않았습니다!
'스프링' 카테고리의 다른 글
동시성 문제 해결 (0) | 2024.12.22 |
---|---|
DB ↔ ENUM 컨버터, 상하위 ENUM으로 명함 도메인 최적화 (0) | 2024.12.18 |
orElse함수는 잘못 쓰기 쉽다(with orElseGet함수) (1) | 2024.10.09 |
Redis 클라이언트 lettuce에 대해서 (0) | 2024.09.12 |
몰랐던 어노테이션 정리 (0) | 2024.08.07 |