문제 상황
총 4개의 테이블과 관련 있는 데이터를 뽑아야 하는 상황이다.
JPA만을 이용해서 14건의 쿼리가 나가던 것을 MyBatis로 직접 JOIN 쿼리를 작성해서 2건으로 줄였다.
문제 원인
Blog main 화면에 게시글(post) 정보를 나타내야 하는데,
여러 가지 이유로 post_tag와 tag 엔티티를 나눠놨고, member 테이블의 데이터를 하나의 DTO에 담아야 하는 상황이다.
DB 설계에 문제가 있을 수도 있고, 비즈니스 요구사항과 정규화를 위해 어쩔 수 없는 것일 수도 있다.
이 프로젝트 설계할 당시에는 관련 지식이 거의 없었고, 감으로 설계했다...
MongoDB 도입
처음엔 쿼리가 너무 많이 나가서, 해당 DTO 조회만을 위한 데이터를 테이블로 빼서 MongoDB Document로 저장하고, 데이터를 이원화했고, 조회 시에는 MongoDB의 데이터만 사용했다.
그러나 이 방식은 RDB와 MongoDB의 데이터를 지속적으로 동기화 하는데 비용이 많이 드는 문제가 있었다. 도입 시점엔 게시글 조회수가 지속적으로 증가해서 update를 해야 한다는 부분을 생각하지 못했었는데 이 부분 때문에 MongoDB Document의 update가 빈번하게 발생해서 이 부분이 비효율적이란 생각이 들었다.
그리고 애당초 목적과 다르게 조회 성능이 크게 올라가지도 않았다.
리팩터링, RDB로 전환, MyBatis 도입
SQL 작성 공부도 하고, RDB를 더 잘쓰는 것도 중요하다는 생각이 든다. 그리고 리팩터링 경험도 해보고, 프로젝트 내에서 쿼리를 사용하는 경험을 해보고자 리팩터링을 시작했다.
그런데 이 경우에 JPQL은 잘 동작하지 않았고, 애초에 JPA의 목적에 어긋나게 억지로 사용하는 느낌이었다.
그래서 마침 지금 회사에서 진행하는 프로젝트에서 JPA를 쓰다가 일부 조회 쿼리에 대해 MyBatis로 전환하게 된다는 말을 듣고 쿼리를 짜고 MyBatis를 도입해서 해결해보려고 한다.
자세히 알아보자.
데이터 요구사항
아래와 같은 데이터의 List가 필요하다.
postId, title, introduction, likes, views 등은 post 테이블,
memberId, profileImage 등은 member 테이블,
tag는 post_tag, tag 테이블에 나눠서 존재한다. (이 부분이 문제)
{
"postId": 8,
"memberId": 1,
"profileImage": null,
"accountName": "happyhsryu",
"title": "테스트 제목 0731",
"thumbnail": "https://xxx.amazonaws.com/xxx.png",
"introduction": "테스트 소개 0731",
"likes": 0,
"views": 0,
"comments": 0,
"registeredAt": "2024-07-31T08:35:55.956731",
"tags": [
"태그1",
"테스트"
]
}
데이터 연관관계
(DB는 MariaDB)
아래에서 member, post, post_tag, tag에 연관관계가 있는 데이터를 뽑아야 한다.
tags가 문제인데, 특정 post의 태그 id는 post_tag에 있고, 해당 태그의 이름은 tag에 있다.
문제 해결 과정
AS-IS
아래 메서드를 호출한다.
public interface BlogService {
/**
* 정렬 방식에 따른 postList 조회
* - sort=latest(default) : 최신 순
* - sort=hot : 조회수 순
*/
BlogPostList getAllBlogPostListBySortType(String accountName, String sort, int page);
}
기존 로직 설명
- member의 accountName으로 회원 조회
- 회원으로 Page<PostEntity> 게시글 리스트 조회
- Page<PostEntity>를 순회하며 getBlogPostDtoFromPostEntity() 메서드 호출
- (PostEntity 한건 당 아래 로직 실행)
- PostEntity로 PostTag 조회
- PostTag로 Tag 조회
- tag.getTagName()으로 tagName으로 태그명 조회 후 BlogPostDto 구성
@Override
public BlogPostList getAllBlogPostListBySortType(String accountName, String sort, int page) {
MemberEntity member = memberRepository.findByAccountName(accountName)
.orElseThrow(() -> new BlogException(ErrorCode.BLOG_NOT_FOUND));
Page<PostEntity> posts =
postRepository.findByMember(member,
PageRequest.of(page - 1, NumberConstant.DEFAULT_PAGE_SIZE, Sort.by(sortBy).descending()));
List<BlogPostDto> blogPostList =
posts.getContent().stream()
.map(this::getBlogPostDtoFromPostEntity)
.toList();
return new BlogPostList(PageDto.fromPostEntityPage(posts), blogPostList);
}
private BlogPostDto getBlogPostDtoFromPostEntity(PostEntity postEntity) {
List<String> tagList =
postTagRepository.findAllByPost(postEntity)
.stream()
.map(pt -> pt.getTag().getTagName())
.toList();
return BlogPostDto.fromEntity(postEntity, tagList);
}
AS-IS 쿼리 로그
위의 로직에서 쿼리가 총 14건이 나간다.
JPQL 사용?
JPQL을 이용해서는 이런 다중 JOIN은 어려웠고, DTO에 데이터를 매핑하는 것부터가 너무 많은 설정이 필요했다. JPA의 목적과 맞지 않다고 생각했다. GROUP_CONCAT으로 태그 명을 가져와야 하는데, 불가했다.
nativeQuery=true 옵션으로 해결하려 했다. 이 역시 오히려 쿼리 String이 너무 복잡해지고 이걸 쓸 바에는 그냥 MyBatis에 쿼리를 작성하는 게 나아 보였다.
QueryDSL을 사용하면 이런 문제를 해결할 수 있는지 모르겠다. 하지만 JPQL로는 불가한 것 같다.
MyBatis 도입
@Mapper
@Mapper
public interface BlogPostMapper {
List<BlogPostWithTagString> findPostsByMemberId(
@Param("memberId") Long memberId, @Param("page") int page, @Param("pageSize") int pageSize);
}
@Repository
@Repository
@RequiredArgsConstructor
public class MyBatisBlogPostRepository implements BlogPostRepository {
private final BlogPostMapper blogPostMapper;
@Override
public List<BlogPostWithTagString> findByMemberId(Long memberId, int page, int pageSize) {
return blogPostMapper.findPostsByMemberId(memberId,page,pageSize);
}
}
mapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.blog.som.domain.mybatis.BlogPostMapper">
<select id="findPostsByMemberId" resultType="BlogPostWithTagString">
SELECT
p.post_id AS postId,
p.member_id AS memberId,
m.profile_image AS profileImage,
m.account_name AS accountName,
p.title AS title,
p.thumbnail AS thumbnail,
p.introduction AS introduction,
p.likes AS likes,
p.views AS views,
p.comments AS comments,
p.registered_at AS registeredAt,
GROUP_CONCAT(t.tag_name ORDER BY t.tag_name ASC SEPARATOR ', ') AS tags
FROM
post p
JOIN
member m
ON p.member_id = m.member_id
LEFT JOIN
post_tag pt
ON p.post_id = pt.post_id
LEFT JOIN
tag t
ON pt.tag_id = t.tag_id
WHERE
p.member_id = #{memberId}
GROUP BY
p.post_id
limit #{page}, #{pageSize}
</select>
</mapper>
JOIN 쿼리를 짜서 MyBatis로 사용한다.
DB I/O myBatisBlogPostRepository.findByMemberId()에서 한 번만 발생한다.
@Override
public BlogPostList getAllBlogPostListBySortType(String accountName, String sort, int page) {
MemberEntity member = memberRepository.findByAccountName(accountName)
.orElseThrow(() -> new BlogException(ErrorCode.BLOG_NOT_FOUND));
List<BlogPostWithTagString> findPostList = myBatisBlogPostRepository.findByMemberId(member.getMemberId(), page - 1, NumberConstant.DEFAULT_PAGE_SIZE);
List<BlogPostDto> list = findPostList.stream().map(BlogPostDto::fromBlogPostWithTagString).toList();
return new BlogPostList(PageDto.fromPageConstants(findPostList.size(), page, NumberConstant.DEFAULT_PAGE_SIZE), list);
}
수정 후 쿼리 로그
수정 후에는 쿼리가 2건만 나가는 것을 확인할 수 있다.