• 카테고리

    질문 & 답변
  • 세부 분야

    백엔드

  • 해결 여부

    해결됨

객체가 스스로의 리스트를 가지고, 양방향 매핑을 해도 될까요?

23.08.28 01:38 작성 조회수 258

0

[질문 템플릿]
1. 강의 내용과 관련된 질문인가요? (/아니오)
2. 인프런의 질문 게시판과 자주 하는 질문에 없는 내용인가요? (/아니오)
3. 질문 잘하기 메뉴얼을 읽어보셨나요? (/아니오)

[질문 내용]
악! 스프링 웹 개발은 즐거워! 김영한 선생님 질문있습니다.

선생님의 두 로드맵을 거의 다 듣고 그를 바탕으로 포폴용 게시판을 만들어보고 있습니다. 와중 댓글과 대댓글 기능을 구현하는 과정에서 Comment라는 객체를 만들고 객체의 타입을 Comment와 reply로 나누었습니다. 특정 게시판에 관한 데이터를 불러올 때 댓글과 대댓글을 편리하게 불러오기 위해 타입이 Comment인 객체가 reply에 해당하는 객체를 리스트로 갖도록 설계했습니다. 이후 테스트를 진행해보았는데 fetch join을 통한 데이터 로드는 문제 없이 진행되었습니다. 다만 이러한 설계 방식이 올바른지에 대한 질문을 스스로 해결할 수 없어서 글 남깁니다. 아래는 코드와 테스트 코드 및 실행 결과이고, 마지막에 질문이 있습니다.

package toy.board.domain.post;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.util.StringUtils;
import toy.board.domain.BaseDeleteEntity;
import toy.board.domain.user.Member;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(callSuper = true)
public class Comment extends BaseDeleteEntity {

    public static final int CONTENT_LENGTH = 1000;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id", nullable = false)
    private Long id;

    @Column(name = "content", nullable = false, length = CONTENT_LENGTH)
    private String content;

    @Column(name = "type", nullable = false, updatable = false)
    private CommentType type;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false, updatable = false)
    private Post post;

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

    @OneToMany(mappedBy = "parent")
    private List<Comment> replies = new ArrayList<>();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_comment_id", updatable = false)
    private Comment parent;

    /**
     * 양방향 관계인 Member와 Post에 대해 자동으로 양방향 매핑을 수행한다.
     */
    public Comment(
            @NotNull final Post post,
            @NotNull final Member member,
            @NotNull final String content,
            @NotNull final CommentType type
    ) {

        this.post = post;
        this.member = member;
        this.content = content;
        this.type = type;
    }

    public boolean update(final String content) {
        if (!StringUtils.hasText(content)) {
            return false;
        }

        this.content = content;
        return true;
    }

    public void leaveReply(Comment reply) {
        if (areTypesCorrectThisAnd(reply)) {
            throw new IllegalArgumentException("주어진 댓글과 대댓글의 타입이 올바르지 않습니다.");
        }

        if (hasComment(reply)) {
            throw new IllegalArgumentException("대댓글이 이미 다른 댓글에 포섭되어 있습니다.");
        }

        if (isNew(reply)) {
            throw new IllegalArgumentException("댓글이 이미 해당 대댓글을 포함하고 있습니다.");
        }

        this.replies.add(reply);
        reply.parent = this;
    }

    private boolean isNew(Comment reply) {
        return this.replies.contains(reply);
    }

    private static boolean hasComment(Comment reply) {
        return reply.parent != null;
    }

    private boolean areTypesCorrectThisAnd(Comment reply) {
        return this.type != CommentType.COMMENT || reply.type != CommentType.REPLY;
    }
}
    @Transactional
    @DisplayName("comment가 List<comment>를 갖고, fetch join으로 가져올 수 있는가?")
    @Test
    public void comment_has_comments_fetch_join() throws  Exception {
        //given
        Member member = Member.builder(
                "member",
                new Login("password"),
                Profile.builder("nickname").build(),
                LoginType.LOCAL_LOGIN,
                UserRole.USER
        ).build();
        em.persist(member);

        Post post = new Post(member, "title", "content");
        em.persist(post);

        Comment comment = new Comment(post, member, "comment", CommentType.COMMENT);
        em.persist(comment);

        for (int i = 0; i < 5; i++) {
            Comment reply = new Comment(post, member, "reply" + String.valueOf(i), CommentType.REPLY);
            comment.leaveReply(reply);
            em.persist(reply);
        }

        em.flush();
        em.clear();

        //when
        QComment reply = new QComment("reply");

        List<Comment> findComments = queryFactory
                .selectFrom(QComment.comment)
                .leftJoin(QComment.comment.replies, reply).fetchJoin()
                .where(
                        QComment.comment.post.id.eq(post.getId()),
                        QComment.comment.type.eq(CommentType.COMMENT)
                )
                .fetch();

        System.out.println("=============================================");
        for (Comment findComment : findComments) {
            System.out.println("findComment.getId() = " + findComment.getId());
            System.out.println("findComment.getContent() = " + findComment.getContent());
        }

        //then
        Comment findComment = findComments.get(0);
        for (Comment findReply : findComment.getReplies()) {
            System.out.println("findReply content = " + findReply.getContent());
        }
    }

[테스트 실행 시 create query]

create table comment (

is_deleted boolean default false not null,

type tinyint not null check (type between 0 and 1),

comment_id bigint generated by default as identity,

created_date timestamp(6),

deleted_date timestamp(6),

last_modified_date timestamp(6),

member_id bigint not null,

parent_comment_id bigint,

post_id bigint not null,

content varchar(1000) not null,

created_by varchar(255),

last_modified_by varchar(255),

primary key (comment_id)

)

[테스트 실행 결과]

select

c1_0.comment_id,

c1_0.content,

c1_0.created_by,

c1_0.created_date,

c1_0.deleted_date,

c1_0.is_deleted,

c1_0.last_modified_by,

c1_0.last_modified_date,

c1_0.member_id,

c1_0.parent_comment_id,

c1_0.post_id,

r1_0.parent_comment_id,

r1_0.comment_id,

r1_0.content,

r1_0.created_by,

r1_0.created_date,

r1_0.deleted_date,

r1_0.is_deleted,

r1_0.last_modified_by,

r1_0.last_modified_date,

r1_0.member_id,

r1_0.post_id,

r1_0.type,

c1_0.type

from

comment c1_0

left join

comment r1_0

on c1_0.comment_id=r1_0.parent_comment_id

where

c1_0.post_id=?

and c1_0.type=?

=====================

findComment.getId() = 1

findComment.getContent() = comment

findReply content = reply0

findReply content = reply1

findReply content = reply2

findReply content = reply3

findReply content = reply4

 

[질문]

제가 궁금한 것을 자세히 말하자면,

  1. 테이블이 만들어질 때, comment 테이블의 특정 row(대댓글인 컬럼)가 해당 테이블의 다른 row(댓글인 row)의 PK값을 FK로 갖는데, 댓글에 해당하는 row는 객체가 생성되고 DB에 저장될 때 해당 컬럼에 null이 저장됩니다. 이는 Comment 객체의 ID에 @GenerateValue 설정을 주어서 그렇습니다. 위의 상황은 실무에서 사용할만큼 적절한가요? 아니라면 대안이 있을까요?

  2. jpa와 관련된 질문을 읽다보니, 다대일 관계에서 left join fetch의 경우 where문의 결과에 따라 데이터 일관성의 오류가 나타날 수 있다는 답변이 있었습니다.(해당 글: https://www.inflearn.com/questions/15876/fetch-join-%EC%8B%9C-%EB%B3%84%EC%B9%AD%EA%B4%80%EB%A0%A8-%EC%A7%88%EB%AC%B8%EC%9E%85%EB%8B%88%EB%8B%A4) 제가 작성한 테스트 코드의 쿼리문은 일대다 관계에서 일에 해당하는 엔티티에 별칭을 주어 where문을 적용한 것이므로 일관성의 문제가 발생하지 않는다고 생각했는데 이것이 옳은 생각인가요?

  3. 마지막으로 쿼리의 복잡도와 쿼리 개수는 trade off 관점에서, 특정 게시물에 관한 데이터를 반환해야 하는 api 요청이 들어왔을 때, 게시물과 댓글 엔티티가 단방향 관계일 경우 게시물에 관한 데이터와 댓글 및 대댓글에 관한 데이터를 각각의 저장소를 통해 가져오는 것이 좋을까요 혹은 쿼리가 다소 복잡해지더라도 한 번에 가져오는 것이 좋을까요? 아니면 단방향 관계를 양방향으로 만드는 것이 더 나은 선택일까요? 혼자 공부하니 올바른 방식을 찾는게 참으로 어려운 것 같습니다.

답변 기다리겠습니다. 감사합니다.

답변 1

답변을 작성해보세요.

1

안녕하세요. 김민규님

관계형 데이터베이스에서 계층형 테이블이라고 불리는 방식을 구현하신 것인데요.

이것은 소위 셀프 조인이라고 하는 방식으로 자기 자신의 테이블과 조인하면서 계층을 이루는 방식입니다.

실제 여러가지 구현 방식이 있기 때문에 찾아서 공부해보시면 도움이 되실거에요.

  1. 민규님이 구현하신 방식의 경우 최종 부모는 당연히 FK값 하나가 비게 됩니다. 이런 부분은 한 테이블에 여러 필드가 들어가기 때문에 트레이드 오프라고 생각하시면 됩니다.

  2. 별칭을 주어서 조회하더라도 데이터를 수정하는 용도가 아니라 단순히 조회하는 용도로만 사용하고, 하이버네이트가 제공하는 2차 캐시를 사용하지 않는다면 크게 문제가 되지 않습니다.

  3. 이 부분은 정답이 없습니다. 상황에 따라서 다릅니다. 보통 너무 많은 쿼리가 발생하면 성능이 느려지기 때문에 보통 쿼리 수를 줄일 수 있는 방법을 고민하는 것이 좋습니다. 특히 N+1은 피해야 합니다.

감사합니다.

김민규님의 프로필

김민규

질문자

2023.08.29

답변 감사합니당!