Spring Example: Community #6 리팩토링(테이블 설계 문제, 벌크 쿼리)

함형주·2023년 1월 6일
0

질문, 피드백 등 모든 댓글 환영합니다.

문제

이 프로젝트에는 구조적인 문제점이 크게 두 가지 있습니다.

  1. Post 테이블의 commentNum, heartNum 컬럼 (비 정규화된 테이블)

  2. cascadeType.REMOVE 설정과 변경 감지 기능으로 인한 조회 쿼리 및 여러 단일 수정/삭제 쿼리 발생

  • 1의 해당 컬럼을 굳이 생성한 이유는 이전에 언급했듯이 Post를 조회할 때 거의 필수로 사용되는 값이기에 조회 쿼리의 양을 줄이기 위함이었습니다. 분명 Post 조회 쿼리의 양이 줄어들기는 했으나 반대로 댓글, 좋아요를 등록하고 삭제할 때 추가로 쿼리가 발생해야했고 특히 회원이 삭제 될 때 발생하는 쿼리가 문제였습니다. 이 부분과 영속성 전이, 변경 감지 수정으로 인해 단일 쿼리가 발생되며 큰 문제를 낳았습니다. 사진으로 자세히 알아보겠습니다.

상황) 게시글 1개, 댓글 3개, 좋아요 3개를 생성한 회원을 삭제

먼저 회원을 삭제하는 코드를 보겠습니다.

MemberService

    @Transactional
    public void delete(Long member_id) {
        Member member = memberRepository.findById(member_id).orElseThrow(IllegalArgumentException::new);

        List<Comment> comments = member.getComments();
        comments.forEach(comment -> comment.getPost().minusCommentNum());

        List<Heart> hearts = member.getHearts();
        hearts.forEach(like -> like.getPost().minusHeartNum());

        memberRepository.delete(member);
    }

Member을 조회하고 연관된 Comment, Heart를 조회, 변경 감지 기능으로 컬럼 값을 수정하고 영속성 전이 기능을 이용하여 Member를 삭제하여 연관된 엔티티를 삭제하는 코드입니다.

크게 문제가 없어보이지만 메서드 실행시 발생되는 SQL 쿼리를 살펴보겠습니다.
(댓글과 좋아요가 어느 게시글에 작성되었는지에 따라 발생하는 쿼리가 더 적을 수도, 많을 수도 있습니다.)

정규화 되지 않은 테이블을 사용하며 CommentNum, HeartNum 컬럼을 수정하기 위해 출처를 알기 힘들 정도의 select 쿼리가 발생하는 것을 볼 수 있습니다.

또한 영속성 전이(cascadeType.REMOVE)를 이용한 엔티티 삭제와 변경 감지를 사용하면 단일 쿼리가 생성되는 문제로 인해 update, delete 쿼리가 테이블 개수만큼 발생한 것을 볼 수 있습니다.

때문에 이런 문제를 해결하기 위해 Post 테이블의 각 컬럼을 없애고, 그에 따라 코드 리팩토링 진행 및 영속성 전이 기능을 사용하지 않고 벌크 쿼리를 작성하여 쿼리 개수를 줄여보겠습니다.

그 전에 이런 문제가 발생하는 원인을 자세히 알기 위해 영속성 전이와 변경 감지 기능이 어떻게 동작하는 지 알아보겠습니다.

JPA 변경 감지 작동 방식

자세한 사항은 블로그 참고해주세요.

간단하게 정리하자면 변경 감지 기능으로 테이블을 수정하기 위해선 엔티티가 영속 상태여야합니다. 때문에 해당 엔티티를 영속 시키기 위해 select 쿼리가 발생할 수 있으며 만약 영속화된 엔티티 다수를 수정했다면 하나씩 update 쿼리를 생성되어 자칫 발생하는 쿼리의 수가 많아 질 수 있습니다.

때문에 여러 엔티티를 수정할 경우엔 벌크성 쿼리(in 사용)를 사용해야 합니다.

영속성 전이 작동 방식

블로그를 참고했습니다.

class Parent {

	@OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE)
    private List<child> children = new ArrayList<>();
}
class ParentService {

	ParentRepository parentRepository; // JPARepository

	@Transactional
    public void delete(Long id) {
    	parentRepository.deleteById(id);
    }

}

cascadeType.REMOVE로 지정된 엔티티와 연관된 엔티티를 삭제하게 되면(ParentService.delete(id)) 아래의 과정을 거칩니다.

  1. parent를 select 쿼리로 조회, 영속화
  2. parent에서 cascadeType.REMOVE로 지정된 엔티티(child)를 select 쿼리로 조회, 영속화
  3. 2 에서 조회한 엔티티를 deleteBy()하나씩 삭제
  4. parent 삭제

결과적으로 영속성 전이 기능으로 인해 조회 쿼리가 여러번 발생하고 심지어 엔티티를 하나씩 삭제하게 되고 이는 충분히 성능 이슈가 발생할 수 있습니다.

때문에 영속성 전이를 통한 엔티티 삭제는 해당 엔티티와 연관된 엔티티가 딱 한가지 일 경우 사용하는 것이 바람직하며 비지니스 로직상 연관된 여러 엔티티를 함께 삭제해야 한다면 벌크성 쿼리를 생성해야합니다.

테이블 정규화

블로그를 참고했습니다.

결과적으로 해당 프로젝트는 단순한 예제로 테이블 설계가 간단하므로 비정규화된 테이블을 사용하는 것 보다 정규화된 테이블을 사옹하는 것이 낫다고 판단했습니다.
문제가 되는 컬럼을 지우고 join을 통해서 해당 값을 생성하도록 수정해주겠습니다.

엔티티에서 해당 필드 삭제

Post

public class Post extends BaseTimeEntity {

    @Id @GeneratedValue @Column(name = "post_id")
    private Long id;

    private String title;
    private String body;

    @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE)
    private List<Heart> hearts = new ArrayList<>();

    @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE)
    private List<Comment> comments = new ArrayList<>();

    @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id")
    private Member member;

    public static Post createPost(String title, String body, Member member) {
        Post post = new Post();
        post.title = title;
        post.body = body;

        post.member = member;
        member.getPosts().add(post);

        return post;
    }

    public void update(String title, String body) {
        this.title = title;
        this.body = body;
    }
}

해당 필드(commentNum, heartNum)와 메서드(plusCommnetNum() 등)를 지워줍니다.

클라이언트 코드 수정

PostService

public class PostService {

    public List<PostListDto> findList() {
        List<Post> find = postRepository.findPostList();
        return find.stream().map(post -> {
            return new PostListDto(post.getId(), post.getTitle(), post.getMember().getName(),
                    post.getHearts().size(), post.getComments().size(), post.getCreatedDate());
        }).collect(Collectors.toList());
    }

    public PostDto findPostAndComment(Long post_id) {
        Post findPost = postRepository.findById(post_id).orElseThrow(IllegalArgumentException::new);

        List<CommentDto> commentDtos = findPost.getComments().stream().map(comment -> {
            return new CommentDto(comment.getId(), comment.getBody(), comment.getMember().getId(), comment.getMember().getName());
        }).collect(Collectors.toList());

        return new PostDto(findPost.getId(), findPost.getTitle(), findPost.getBody(), findPost.getMember().getId(), findPost.getMember().getName(), findPost.getHearts().size(), findPost.getCreatedDate(), commentDtos);
    }
}

기존에 post.commentNum, post.heartNum을 사용하던 방식에서 post.getComments().size() 처럼 바꿔줍니다.

이런 방식으로 바꾸게되면 Comment와 Heart 조회 쿼리가 각각 발생하긴 하지만 이후 로직에서 발생하는 추가 쿼리를 막을 수 있고 테이블 정규화로 인한 db 최적화, 안정성, 무결성 등의 이점을 얻을 수 있습니다.

CommentService

public class CommentService {

    @Transactional
    public void save(CommentDto commentDto, Long post_id, Long member_id) {
        Post post = postRepository.findById(post_id).orElseThrow(IllegalArgumentException::new);
        Member member = memberRepository.findById(member_id).orElseThrow(IllegalArgumentException::new);

        Comment comment = Comment.createComment(commentDto.getBody(), member, post);
        commentRepository.save(comment);
    }
    
    @Transactional
    public void delete(Long comment_id, Long post_id) {
        Post post = postRepository.findById(post_id).orElseThrow(IllegalArgumentException::new);
        commentRepository.deleteById(comment_id);
    }
}

댓글 생성, 삭제 시 사용했던 post.plusCommentNum() 등을 삭제합니다.

HeartService

public class HeartService {

    @Transactional
    public void changeHeartStatus(Long post_id, Long member_id) {

        heartRepository.findByMemberIdAndPostId(post_id, member_id).ifPresentOrElse(
                heart -> heartRepository.delete(heart),
                () -> {
                    Post post = postRepository.findById(post_id).orElseThrow(IllegalArgumentException::new);
                    Member member = memberRepository.findById(member_id).orElseThrow(IllegalArgumentException::new);

                    heartRepository.save(Heart.createHeart(post, member));
                });
    }
}

마찬가지로 heartNum 관련 메서드를 삭제합니다.

MemberService

public class MemberService {

    @Transactional
    public void delete(Long member_id) {
        Member member = memberRepository.findById(member_id).orElseThrow(IllegalArgumentException::new);

        memberRepository.delete(member);
    }
}

여기까지 진행 후 아까와 같은 상황에서 다시 Member를 삭제해보겠습니다.

Post 엔티티를 수정하여 관련 컬럼을 수정하는 로직이 빠졌음에도 여전이 select 쿼리가 여러번 발생하는 것을 확인할 수 있습니다.
이는 위에서 언급했던 영속성 전이 기능으로 발생하는 문제입니다. 그에 따라 변경 감지로 인한 update 쿼리는 발생하지 않음을 알 수 있습니다.

또한 마찬가지로 댓글과 좋아요 생성 및 삭제를 하는 경우에도 해당 컬럼의 수정이 필요하지 않아 update 쿼리가 발생하지 않습니다.

영속성 전이 제거

영속성 전이로 인한 문제는 위의 Member를 삭제하는 로직 외에도 Post를 삭제하는 로직에도 비슷한 형식으로 발생합니다. cascade 옵션을 제거하고 벌크 삭제 쿼리를 직접 생성하여 성능 최적화를 진행하겠습니다.

cascade 제거

Member

public class Member extends BaseTimeEntity {

    @OneToMany(mappedBy = "member")
    private List<Post> posts = new ArrayList<>();

    @OneToMany(mappedBy = "member")
    private List<Comment> comments = new ArrayList<>();

    @OneToMany(mappedBy = "member")
    private List<Heart> hearts = new ArrayList<>();
}

모든 엔티티에 적용된 cascade 옵션을 제거합니다. (Post 엔티티도 마찬가지로 수정해줍니다. 코드는 첨부하지 않겠습니다.)

벌크 삭제 쿼리 생성

PostService

public class PostService {

    private final PostRepository postRepository;
    private final MemberRepository memberRepository;
    private final CommentRepository commentRepository;
    private final HeartRepository heartRepository;
    
    @Transactional
    public void delete(Long post_id) {
        heartRepository.deleteByPostId(post_id);
        commentRepository.deleteByPostId(post_id);
        postRepository.deleteByPostId(post_id);
    }
}

기존의 postRepository.deleteById(post_id) 방식 대신 직접 생성한 벌크 쿼리를 사용할 수 있도록 수정해주었습니다.

cascade 옵션을 사용하지 않으므로 Post와 연관된 엔티티(Post의 연관관계 주인)를 직접 삭제해야합니다.

때문에 Post를 삭제하기 전 post_id를 가진 Heart와 Comment를 삭제하는 과정이 필요합니다.

PostRepository, CommentRepository, HeartRepository

public interface PostRepository extends JpaRepository<Post, Long> {
    @Modifying
    @Query("delete from Post p where p.id = :post_id")
    void deleteByPostId(@Param("post_id") Long post_id);
}

public interface CommentRepository extends JpaRepository<Comment, Long> {
    @Modifying
    @Query("delete from Comment c where c.post.id = :post_id")
    void deleteByPostId(@Param("post_id") Long post_id);
}

public interface HeartRepository extends JpaRepository<Heart, Long> {
    @Modifying
    @Query("delete from Heart h where h.post.id = :post_id")
    void deleteByPostId(@Param("post_id") Long post_id);
}

@Modifying을 이용하면 벌크성 쿼리를 생성할 수 있습니다. in 방식을 사용하여 기존에 하나씩 생성되던 쿼리가 한 번에 처리됩니다.

Post를 삭제할 때 deleteById()를 사용하지 않은 이유는 deleteByXX의 경우 해당 엔티티를 조회하고 삭제하므로 의미없는 select 쿼리가 발생하기 때문입니다.

MemberService

public class MemberService {
    private final MemberRepository memberRepository;
    private final CommentRepository commentRepository;
    private final HeartRepository heartRepository;
    private final PostService postService;
    
    @Transactional
    public void delete(Long member_id) {
        postService.deleteByMemberId(member_id);
        heartRepository.deleteByMemberId(member_id);
        commentRepository.deleteByMemberId(member_id);
        memberRepository.deleteByMemberId(member_id);
    }
}

Member를 삭제할 때는 위와 마찬가지로 연관된 엔티티를 직접 제거해야합니다.

그런데 여기서 Member와 연관된 Post를 삭제하며 또 Post와 연관된 Heart와 Comment를 삭제해야 하므로 그 부분은 PostService에 담당 메서드를 생성하고 이를 호출하는 방식으로 개발했습니다.

PostService

public class PostService {
    @Transactional
    public void deleteByMemberId(Long member_id) {
        List<Post> findPosts = postRepository.findAllByMemberId(member_id);
        List<Long> postIds = findPosts.stream().map(post -> {return post.getId();}).collect(Collectors.toList());

        heartRepository.deleteByPostIds(postIds);
        commentRepository.deleteByPostIds(postIds);
        postRepository.deleteByPostIds(postIds);
    }
}

fk인 member_id로 Post를 List로 조회한 후 Post.id를 추출하여 벌크 삭제 쿼리를 생성했습니다.

PostRepository, CommentRepository, HeartRepository

public interface PostRepository extends JpaRepository<Post, Long> {
    @Modifying
    @Query("delete from Post p where p.id in :post_ids")
    void deleteByPostIds(@Param("post_ids") List<Long> postIds);
}

public interface CommentRepository extends JpaRepository<Comment, Long> {
    @Modifying
    @Query("delete from Comment c where c.post.id in :post_ids")
    void deleteByPostIds(@Param("post_ids") List<Long> postIds);
}

public interface HeartRepository extends JpaRepository<Heart, Long> {
    @Modifying
    @Query("delete from Heart h where h.post.id in :post_ids")
    void deleteByPostIds(@Param("post_ids") List<Long> postIds);
}

테이블 정규화 + cascade 옵션 제거 및 벌크 쿼리 생성을 완료했으니 쿼리가 어떻게 생성되는지 확인해보겠습니다.

Post 삭제 시 발생하는 쿼리

좋아요 2개, 댓글 3개가 달려있는 게시글을 삭제 해보겠습니다.


**변경 전**

select 쿼리 3개, delete 쿼리 6개, 총 9개의 쿼리가 발생하는 것을 볼 수 있습니다.



**변경 후**

정말 필요한 delete 쿼리 3개만 발생하는 것을 볼 수 있습니다.


Member 삭제 시 발생하는 쿼리

맨 위 예제와 같은 게시글 1개, 댓글 3개, 좋아요 3개를 생성한 회원을 삭제 해보겠습니다.

여전히 쿼리가 많이 발생하긴 하지만 확실히 쿼리 수가 줄은 것을 확인할 수 있습니다.

select 쿼리 1개, delete 쿼리가 6개 발생했는데 post_id를 사용한 것과 member_id를 사용하여 Comment와 Heart를 삭제한 쿼리가 따로 발생했기 때문입니다.

회원이 작성한 댓글과 좋아요를 삭제하는 로직과 회원이 작성한 게시글에 포함된 댓글과 좋아요를 삭제하는 로직이 분리되어있기 때문에 비슷해보이는 쿼리가 두 개 더 발생한 것입니다.

물론 이것도 각각 쿼리를 합칠 수 있는 방법이 분명 있겠지만 이 정도까지만 수정하고 리팩토링을 완료하겠습니다.

github , 배포 URL (첫 접속 시 로딩이 걸릴 수 있습니다.)

profile
평범한 대학생의 공부 일기?

0개의 댓글