728x90
쿼리 수정
- 기존에는 QueryDSL을 사용하며 DTO조회를 사용하고 있다. 이를 최대한 Entity객체를 반환하도록 수정할 것이다. 그리고 만약 수정이 힘들더라도 QueryDSL에서 조회하는 DTO와 Service단에서 응답을 위해 사용하는 DTO는 구분하여 처리할 것이다. 의존 관계를 떨어뜨리기 위함이다!
- 게시글 상세 정보 조회의 쿼리를 최적화하면서 내가 수정한 방법이 정말 최적화가 됐는지 테스트를 해보고 어떤 것이 더 괜찮은지 고민해 볼 것이다.
QueryDSL DTO조회를 Entity조회로 바꾸기
- 우선 내가 이렇게 조회 방식을 바꾸는 이유는 재사용성을 높일 수 있기 때문이다. 만약 같은 게시글 조회 쿼리라도 화면에서 보여줄 내용이 다르다면 DTO조회의 경우 새로운 DTO를 사용한 메서드를 만들어야 할 것이다. 하지만 게시글 객체로 반환한다면 내가 원하는 정보를 추출하여 DTO로 만들어 응답하면 된다.
- 즉 응답용 스펙 DTO를 만들어서 리포지토리는 Entity객체를 반환하고 Service계층에서 해당 객체에서 필요한 부분만 DTO로 변환할 것이다.
@Override
public Page<PostMemberQueryDto> getMyPosts(Long memberId, Pageable pageable) {
return applyPagination(pageable,select(
Projections.constructor(PostMemberQueryDto.class,
post.postId,
post.postName,
member.memberName,
post.postViews,
post.createdAt
))
.from(post)
.leftJoin(post.member, member)
.where(
memberIdEq(memberId)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize()),
select(post.count())
.from(post)
.leftJoin(post.member, member)
.where(memberIdEq(memberId))
);
}
- 해당 코드의 메소드는 DTO로 조회하여 게시글에 대한 조회지만 5개의 필드만 가져오고 있다. 이를 fetch join을 사용하여 한 번에 가져온 후 입맛에 맞게 필요한 데이터만 사용하도록 변경했다.
@Override
public Page<Post> getMyPosts(Long memberId, Pageable pageable) {
return applyPagination(pageable,
select(post)
.from(post)
.leftJoin(post.member, member).fetchJoin()
.where(
memberIdEq(memberId)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize()),
select(post.count())
.from(post)
.leftJoin(post.member, member)
.where(memberIdEq(memberId))
);
}
- 변경은 쉬웠다. select(post)로 변경하고 fetch join을 사용하며 리턴 타입을 Page<Post>로 변경하면 된다. 이후 기존 DTO를 그대로 반환했던 서비스 계층을 수정하고 응답용 DTO 클래스를 만들어 응답하면 된다.
@Getter
public class PostResDto {
private final Long postId;
private final String postName;
private final Long memberId;
private final String memberName;
private final Integer postViews;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = BASIC_TIME_PATTERN)
private final LocalDateTime createdAt;
public PostResDto(Post post){
this.postId = post.getPostId();
this.postName = post.getPostName();
this.memberId = post.getMember().getMemberId();
this.memberName = post.getMember().getMemberName();
this.postViews = post.getPostViews();
this.createdAt = post.getCreatedAt();
}
}
@Override
public Page<PostResDto> getMyPosts(Long memberId, Pageable pageable) {
return postRepository.getMyPosts(memberId,pageable)
.map(post -> new PostResDto(post));
}
- 위와 같이 게시글 목록을 조회할 때 사용할 응답 객체인 PostResDto를 만들었다. 그리고 Page를 사용하기 위해 map()을 사용하여 내부의 Entity를 모두 PostResDto로 만들었다.
- 응답이 DTO로 잘 오는 것을 확인할 수 있었다. 나머지도 같은 방법으로 바꿨다.
느낀점
- QueryDSL을 사용하며 DTO조회가 마냥 편하다고 생각했는데 이번에 수정하면서 아니라는 것을 알게 되었다.
- 간단하게 DTO → Entity수정만 하면 끝날 줄 알았지만 해당 DTO가 서비스 계층부터 컨트롤러 계층까지 그대로 이동하여 한 번 수정에 전체 계층 모두가 수정이 발생했다. 즉 서로 의존하게 되면 유지보수가 힘들어진다는 것을 직접 수정하며 알 수 있었다.
- 만약 DTO로 조회를 해야만 한다면 Repository에서만 사용하고 서비스 계층에서는 응답용 DTO로 반환해야 나중에 수정이 편할 것이라는 생각이 든다. 응답용 DTO는 화면과 관련 있으니 화면 스펙이 바뀌면 DTO를 수정하면 되고 쿼리가 바뀌어도 같은 게시글 객체를 반환하니 유지보수가 용이해질 것이다.
게시글 상세 조회 최적화 하기
문제점
- JMeter를 사용해서 API들을 테스트 해봤는데 위와 같이 유독 다른 API보다 2배 가량 느린 API가 있었다.
- 게시글 상세 조회가 느렸고 왜 그런지 살펴보니 QueryDSL을 사용하며 Where절에 서브 쿼리 사용이 제약이 있었다. 그래서 쿼리를 2개로 분리했기 때문에 발생한 문제였다.
고민
- 이 기능은 게시글 하나에 댓글 10개만 조회해서 보여주는 것이다. 그래서 바로 서브 쿼리를 생각했었다. 이렇게 설계한 이유는 유튜브처럼 수많은 댓글을 한 번에 가져오는 것이 아닌 일정 부분 가져오고 페이징처리를 하고 싶었기 때문이다. 그래서 서브쿼리를 사용하는 것과 서브쿼리를 다른 방법으로 개선할 수 있는지에 대해 고민해 보았다.
해결 방법
- 우선 Native Query로 변경해 보았다.
문제점
- 가독성이 좋지 않았다.
- 결과로 리턴된 값이 List<Object[]>의 형태로 나오기 때문에 DTO로 변환이 매우 힘들었다. 어떻게든 변환하면 코드가 매우 지저분한 문제가 있었다.
쿼리
public List<PostDetailQueryDto> getPostDetails(PostDetailCondition condition) {
String query = "select p.post_id, p.post_name, p.post_content, p.created_at,p.post_views," +
"pm.member_id as post_member_id ,pm.member_name as post_member_name," +
"c.comment_id, c.comment_content, c.created_at," +
"cm.member_id , cm.member_name " +
"from post p " +
"join member pm " +
" on p.member_id = pm.member_id" +
" and p.post_id =:postId " +
"left join comment c " +
"join member cm " +
" on c.member_id = cm.member_id " +
" and c.comment_id in (select *\\n" +
" from (select cc.comment_id\\n" +
" from comment cc\\n" +
" where (cc.post_id = 1)\\n" +
" limit 0,10) as cl)\\n" +
" on p.post_id =:postId";
List<Object[]> test = getEntityManager().createNativeQuery(query)
.setParameter("postId",condition.getPostId())
.getResultList();
List<PostDetailQueryDto> result = test.stream()
.map(o -> new PostDetailQueryDto(o))
.toList();
return result;
}
@Getter
public class PostDetailQueryDto {
private final Long postId;
private final String postName;
private final String postContent;
private final LocalDateTime postCreatedAt;
private final Integer postViews;
private final Long postMemberId;
private final String postMemberName;
private final Long commentId;
private final String commentContent;
private final LocalDateTime commentCreatedAt;
private final Long commentMemberId;
private final String commentMemberName;
public PostDetailQueryDto(Object[] o) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS");
this.postId = Long.parseLong(o[0].toString());
this.postName = o[1].toString();
this.postContent = o[2].toString();
String s = o[3].toString();
if(s.length() <26){
s=s+"0";
}
this.postCreatedAt = LocalDateTime.parse(s,formatter);
this.postViews = Integer.parseInt(o[4].toString());
this.postMemberId = Long.parseLong(o[5].toString());
this.postMemberName = o[6].toString();
this.commentId = o[7] == null ? null : Long.parseLong(o[7].toString());
this.commentContent = o[8] == null ? null :(String) o[8];
s = o[9].toString();
if(s.length() <26){
s=s+"0";
}
this.commentCreatedAt = o[9] == null ? null : LocalDateTime.parse(s,formatter);
this.commentMemberId = o[10] == null? null: Long.parseLong(o[10].toString());
this.commentMemberName = o[10] == null? null: (String) o[11];
}
}
- 가독성이 매우 안 좋다. 게다가 테이블 모양과 일치하는 DTO를 만들어 각각의 타입에 맞도록 변환해 주고 생성하는 과정이 필요하다. 위의 DTO생성자는 아직 미완성으로 더 나은 방안이 있다면 수정해 볼 예정이다.
- 위의 과정을 거치면서 @ResultSetMapping이나 QLRM라이브러리 사용을 고민해 봤지만 전자의 경우 Entity에 의존성이 생기고 라이브러리도 이거 하나 때문에 불필요한 의존성을 추가한다고 생각되어 위처럼 직접 매핑하는 방법을 사용했다.
- 하지만 코드가 너무 복잡하고 가독성도 안 좋으며 특히 Object[]를 직접 매핑하는 것이 번거롭고 오류도 많이 발생하였다. 그래서 Native Query사용에 좋은 JdbcTemplate과 MyBatis 중 하나를 선택하여 정리해 보았다.
JdbcTemplate 적용
- 기존의 Native Query를 JdbcTemplate을 사용하여 깔끔하게 만들어보았다.
- MyBatis를 사용하지 않은 이유는 크게 동적 쿼리가 필요하지 않고 이 기능 하나 때문에 MyBatis 의존성을 추가해서 사용하고 싶지 않았다.
@RequiredArgsConstructor
public class JdbcRepositoryImpl implements JdbcRepository{
private final NamedParameterJdbcTemplate jdbcTemplate;
@Override
public List<PostDetailQueryDto> getPostDetails(PostDetailCondition condition) {
String sql = "select p.post_id, p.post_name, p.post_content, p.created_at as post_created_at,p.post_views," +
"pm.member_id as post_member_id ,pm.member_name as post_member_name," +
"c.comment_id, c.comment_content, c.created_at as comment_created_at," +
"cm.member_id comment_member_id, cm.member_name as comment_member_name " +
"from post p " +
"join member pm " +
" on p.member_id = pm.member_id" +
" and p.post_id =:postId " +
"left join comment c " +
"join member cm " +
" on c.member_id = cm.member_id " +
" and c.comment_id in (select *\\n" +
" from (select cc.comment_id\\n" +
" from comment cc\\n" +
" where (cc.post_id = :postId)\\n" +
" limit 0,10) as cl)\\n" +
" on p.post_id =:postId";
BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(condition);
List<PostDetailQueryDto> result = jdbcTemplate.query(sql, param,
// BeanPropertyRowMapper.newInstance(PostDetailQueryDto.class)
postDetailRowMapper
);
return result;
}
private final RowMapper<PostDetailQueryDto> postDetailRowMapper = (resultSet, rowNum) -> {
return new PostDetailQueryDto(
resultSet.getLong("post_id"),
resultSet.getString("post_name"),
resultSet.getString("post_content"),
resultSet.getTimestamp("post_created_at").toLocalDateTime(),
resultSet.getInt("post_views"),
resultSet.getLong("post_member_id"),
resultSet.getString("post_member_name"),
resultSet.getLong("comment_id"),
resultSet.getString("comment_content"),
resultSet.getTimestamp("comment_created_at").toLocalDateTime(),
resultSet.getLong("comment_member_id"),
resultSet.getString("comment_member_name")
);
};
}
- 인터페이스를 만들어서 사용했고 해당 인터페이스는 Spring Data Jpa를 사용한 PostRepository가 상속받는다.
- 쿼리는 그대로이고 이름으로 파라미터를 지정하기 위해 NamedParameterJdbcTemplate을 사용했다.
- 첫 번째 방법은 주석이 쳐져있는 BeanPropertyRowMapper를 통해 매핑하는 방법인데 해당 방법을 사용하려면 DTO에 기본 생성자, Setter가 필요하다. 그러기 위해선 필드들도 final로 선언되면 안 된다. 나는 최대한 불변 객체를 활용하고자 했기에 해당 방법 대신 RowMapper를 사용하여 직접 매핑했다.
- 확실히 BeanPropertyRwoMapper를 사용하면 @Setter와 코드 한 줄로 깔끔하게 작성이 가능하지만 나의 방향과 맞지 않다고 생각되어 위의 방식을 채택했다. 그리고 해당 기능을 위해 따로 별도의 클래스를 사용해서 가독성도 괜찮다고 생각한다.
- 이후 서브쿼리를 조인으로 전부 해결할 수 있을 것 같아 아래와 같이 수정했다.
서브 쿼리를 JOIN으로 개선
@Override
public List<PostDetailQueryDto> getPostDetails(PostDetailCondition condition) {
String sql = "select p.post_id, p.post_name, p.post_content, p.created_at as post_created_at,p.post_views, " +
" pm.member_id as post_member_id ,pm.member_name as post_member_name, " +
" c.comment_id, c.comment_content, c.created_at as comment_created_at, " +
" cm.member_id as comment_member_id, cm.member_name as comment_member_name " +
"\\n" +
"from post p " +
" join member pm " +
" on p.member_id = pm.member_id " +
" and p.post_id = :postId " +
" left join comment c " +
" join member cm " +
" on c.member_id = cm.member_id " +
" on c.post_id = :postId " +
" order by comment_created_at asc " +
"limit 0,10";
BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(condition);
List<PostDetailQueryDto> result = jdbcTemplate.query(sql, param,
postDetailRowMapper
);
return result;
}
- 쿼리를 바꿀 수 없을까? 라는 지속적인 고민을 통해 쿼리를 개선한 끝에 JOIN으로 풀어낼 수 있었다. 당시엔 왜 이렇게 못했는지 아쉽다.
- 이렇게 전부 조인으로 풀어내니 Querydsl로 다시 할 수 있을 것 같았다.
public List<PostDetailQueryDto> getPostDetailsOrigin(PostDetailCondition condition) {
QMember postMember = new QMember("postMember");
QMember commentMember = new QMember("commentMember");
QComment postComment = new QComment("postComment");
return select(Projections.constructor(PostDetailQueryDto.class,
post.postId,
post.postName,
post.postContent,
post.createdAt,
post.postViews,
postMember.memberId,
postMember.memberName,
comment.commentId,
comment.commentContent,
comment.createdAt,
commentMember.memberId,
commentMember.memberName
))
.from(post)
.join(post.member, postMember)
.on(post.member.memberId.eq(postMember.memberId))
.on(postIdEq(condition.getPostId()))
.leftJoin(post.comments, comment)
.on(comment.post.postId.eq(post.postId))
.join(commentMember.member, commentMember)
.on(comment.post.postId.eq(condition.getPostId()))
.orderBy(comment.commentId.asc())
.offset(0)
.limit(5)
.fetch();
}
- 계속 시도하고 있지만 마음대로 되지 않고 아래와 같은 에러가 발생했다.
- 위와 같은 에러를 마주했고 자세히 보면 limit이 적용이 되질 않는다. 처음 서브 쿼리에서도 limit을 자체적으로 걸렀는데 이번에도 limit이 보이지 않는다. fetchJoin을 사용하지 않아 상관없을 것 같았는데 자체적으로 없애고 쿼리를 보내는 이슈가 있다.
- 그래서 페이징 문제인가? 싶어 offset()과 limit()을 제거했지만 같은 이슈가 발생했고 다른 곳에서 문제가 발생하는 것 같다.
public List<PostDetailQueryDto> getPostDetailsOrigin(PostDetailCondition condition) {
QMember commentMember = new QMember("commentMember");
return select(Projections.constructor(PostDetailQueryDto.class,
post.postId,
post.postName,
post.postContent,
post.createdAt,
post.postViews,
member.memberId,
member.memberName,
comment.commentId,
comment.commentContent,
comment.createdAt,
commentMember.memberId,
commentMember.memberName
))
.from(post)
.join(post.member, member)
.on(postIdEq(condition.getPostId()))
.leftJoin(post.comments, comment)
.on(comment.post.postId.eq(post.postId))
.join(comment.member, commentMember)
.on(comment.post.postId.eq(condition.getPostId()))
.orderBy(comment.commentId.asc())
.offset(0)
.limit(10)
.fetch();
}
- 결국 해결했다. 문제는 join()에 comment.member를 해야 하는데 새로운 QComment를 생성하고 거기서 member를 접근한 문제였다.
- 계속 오류가 생겨 생성한 Q클래스를 활용하다가 발생한 문제에서 발목을 잡혔었다.. 그래도 시간을 투자해서 해결하니 뿌듯하다.
느낀 점
- 우선 다양한 방법을 고민해 보는 과정이 좋았다.
- 고민을 통해 코드를 작성하고 비교해 보며 각각의 장단점을 몸으로 느낄 수 있었다.
- 그리고 Repository에서만 사용할 DTO, 서비스 계층에서 사용할 DTO를 분리하니 코드의 수정이 매우 용이했다. 게시판 상세 검색 메서드 하나만 JdbcRepository로 바꿔주니 다른 코드들은 수정할 필요 없었다. 이를 직접 코드를 작성하면 느낄 수 있어 좋았다.
- 시간이 오래 걸렸지만 쿼리가 점점 발전하는 것을 보고 뿌듯했다.
JMeter 테스트
어떤 것이 더 빠를까? 최적화가 된 걸까?라는 궁금증에 테스트를 해보았다.
데이터는 게시글 10000개에 조회할 게시글에만 댓글 1000개를 넣어서 했다.
모두 초당 3000번 요청 10번 반복했다.
기존 결과
Querydsl로 개선하여 테스트(게시글 상세 정보를 한 번에 가져온다)
쿼리 한 번에 게시글 조회와 동시에 댓글 10개를 조회한다.
- 확실히 성능 차이가 난다. 기존 결과에 비해 Average는 약 2배, Throuput은 처리량을 의미하는데 마찬가지로 약 2배 개선됐다. 99% Line에서도 차이가 보인다. 99% 샘플이 각각 3411, 1732보다 적은 시간에서 끝난다
- 눈에 띄는 차이를 보고 싶어 페이징 처리를 빼고 조회할 게시글의 댓글을 1000개까지 추가한 후 전체 댓글을 다 읽어봐야겠다.
페이징 제거
- 같은 조건으로 시작했는데 너무 오래 걸려서 중간에 멈췄다. 그래도 조회 시간이 오래 걸리는 것을 확인할 수 있다.
- 확실히 쿼리를 쏠 때 조건을 통해 데이터 양을 많이 줄여야 한다는 것을 알 수 있었다.
내 생각
- 내 개인적인 생각은 성능이 2배 차이 나기 때문에 네이티브 쿼리를 사용하는 것이 좋다고 생각한다. 하지만 테스트가 극단적이라 사용자가 별로 없다면 큰 차이가 없을 것이라는 생각이 든다.
728x90
'Java > Spring boot 프로젝트' 카테고리의 다른 글
[리팩토링 2] 의존성 관리하기 -게시판 만들기(15) (0) | 2023.08.09 |
---|---|
[리팩토링 1] nGrinder삽질 + JMeter 부하 테스트 해보기 - 게시판 만들기(14) (0) | 2023.08.09 |
@EntityListener로 생성일, 수정일 자동으로 넣기 + 프록시 활용(연관관계 Insert) - 게시판 만들기(13) (0) | 2023.06.29 |
Spring Security + JWT 적용하기(3) - 게시판 만들기(12) (0) | 2023.04.15 |
Spring Security + JWT 적용하기(2) - 게시판 만들기(11) (0) | 2023.04.06 |