JPA 연관관계 없이 1:N 매핑하기 (JPA, Querydsl)

E4ger·2024년 1월 17일
2
post-thumbnail

JPA의 연관관계가 좋지만은 않을지도..

https://www.youtube.com/watch?v=vgNHW_nb2mg
유튜브에서 위 영상을 보고나서 실무에서는 @OneToMany, @ManyToOne, @OneToOne 같은 연관관계를 지양하는 경우도 있음을 알게 되었다.

나도 프로젝트를 진행하면서 연관관계가 복잡한 엔티티를 많이 사용했었다. 이런 엔티티를 Select 하는순간 의도치않은 sql들이 JPA에 의해 자동으로 남발된적도 많았다. 아마 이런 부분들 때문에 JPA에 의존하지 않고 직접 필요한 경우에만 추가로 select를 하거나 join을 해서 처리한다는 의미인 것 같다.

연관관계 없이 엔티티 설계

연관관계를 사용하지 않고 결과를 직접 매핑하는 경우중에 제일 까다로운 것은 1:N 관계라고 생각했기에
게시글(부모) -> 댓글(자식)을 가정해보았다. 한 개의 게시글은 여러개의 댓글을 가질 수 있다.

public class Board {
    ...
    @OneToMany
    private List<BoardComment> comments;
    ...
}


public class BoardComment {
    ...
    @ManyToOne
    private Board board;
    ...
}

기존에 내가 사용했던 JPA 엔티티 방식이라면 위처럼 OneToMany와 ManyToOne으로 양방향 매핑을 했었을 것이다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String title;

    @Column
    private String content;

    @Builder
    public Board(Long id, String title, String content) {
        this.id = id;
        this.title = title;
        this.content = content;
    }
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class BoardComment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private Long boardId;

    @Column
    private String comment;

    @Builder
    public BoardComment(Long id, Long boardId, String comment) {
        this.id = id;
        this.boardId = boardId;
        this.comment = comment;
    }
}

하지만 연관관계를 사용하지 않고 엔티티를 설계한다면 위처럼 BoardComment에서는 부모인 Board의 id를 가지고 있도록 설계하게 된다.


public class BoardDto {
    @Getter
    @Setter
    @AllArgsConstructor
    public static class BoardWithComments {
        private Long id;
        private String title;
        private String content;
        private List<BoardCommentItem> comments;
    }

    @Getter
    @Setter
    @AllArgsConstructor
    public static class BoardCommentItem {
        private Long id;
        private String comment;
    }
}

그리고 게시글 조회 시 응답으로 주게 되는 DTO를 만들어보았다.

1:N 매핑하기 (JPA + Stream API)

@Repository
public interface BoardJpaRepository extends JpaRepository<Board, Long> {
    @Query(value = "SELECT b as board, bc as boardComment from Board b left join BoardComment bc on b.id = bc.boardId")
    List<Tuple> getByIdWithComments(Long id);
}

먼저 위와 같이 @Query 어노테이션을 통해 JPQL을 작성하여 join을 한다. 보면 반환값이 Tuple인데 Select 지정을 2개 해서 그렇다.
이제 이 Tuple을 가공하면 된다.

public List<BoardWithComments> findByIdWithComment_jpa(Long boardId) {
        return boardJpaRepository.getByIdWithComments(boardId)
            .stream()
            .collect(
                Collectors.groupingBy(
                    tuple -> tuple.get("board", Board.class),
                    LinkedHashMap::new,
                    Collectors.collectingAndThen(
                        Collectors.mapping(tuple -> tuple.get("boardComment", BoardComment.class), Collectors.toList()),
                        comment -> {
                            comment.removeIf(Objects::isNull); // 댓글이 없는 경우 리스트에 Null이 들어가서 나중에 오류가 발생할 수 있음
                            return comment;
                        })
                ))
            .entrySet()
            .stream()
            .map(entry -> new BoardWithComments(
                entry.getKey().getId(),
                entry.getKey().getTitle(),
                entry.getKey().getContent(),
                entry.getValue().stream().map(entity -> new BoardCommentItem(entity.getId(), entity.getComment())).collect(Collectors.toList())))
            .collect(Collectors.toList());
    }

그마나 stream을 써서 어느정도 줄인 것 같긴한데 연관관계 매핑을 포기했기에 개발자가 작성해야 할 코드가 많아진다.
앞으로 연관관계를 사용하지 않고 1:N을 매핑할 때 위방법을 써야 한다면 조금 불편함이 있을 듯 하다.

1:N 매핑하기 (Querydsl)

위에서 본 Tuple의 타입은 stream 작업에 별칭과 타입을 계속 작성해줘야 하기에 뭔가 보기 불편하고 작성하는데도 귀찮다.
그래서 자주 사용하는 querydsl을 사용하면 어떨까 해서 사용해보았다. 연관관계가 없이 join을 해야할 때는 querydsl이 굉장히 편하기도 하다.

public List<BoardWithComments> findByIdWithComment(Long boardId) {
        return jpaQueryFactory.select(board, boardComment)
            .from(board)
            .where(board.id.eq(boardId))
            .leftJoin(boardComment).on(board.id.eq(boardComment.boardId))
            .transform(
                groupBy(board.id)
                    .list(
                        Projections.constructor(BoardWithComments.class,
                            board.id,
                            board.title,
                            board.content,
                            list(Projections.constructor(BoardCommentItem.class, boardComment.id, boardComment.comment)))
                    ));
    }

위 코드가 끝이다. transform, groupBy, list 함수만 사용한다면 막 별칭과 타입을 사용할 필요 없이 처리가 가능하다.

모든 건 본인의 선택

이 방법을 고민한 이유는 과도한 연관관계 매핑 때문에 원치 않는 SQL 발생을 경험했었기 때문이다.
본문 처음에 나왔던 유튜브 영상에서도 본인이 경험한 연관관계의 데임(?) 이후로 웬만한 상황이 아니라면 사용을 안한다고 한다.
그리고 종종 구글에 검색해보니 실무에서는 OneToMany를 잘 사용하지 않는다는 글도 종종 보이기에 지금부터라도 JPA 연관관계의 편리함과 잠시 멀어져볼까 고민이다.
하지만 JPA의 연관관계를 무조건 사용하지 않는 것은 상황에 따라 다를 수 있으니 고민해보고 선택해보자.
매핑 너무 귀찮다..

0개의 댓글