본문 바로가기
Back-End/JPA

JPA 연관관계 양방향 매핑 Entity 조회시 문제&해결

by 어렵다어려웡 2022. 4. 28.

사실 제목은 마음에 들지 않는다.  N+1 문제라고 예상할 수 있겠지만, 

N+1 쿼리에 의한 문제에 대한 포스팅은 아니다.

 

새 프로젝트를 해보면서 여러가지 Query를 실행해보면서 경험을 쌓기위해서 하고 있었다.

 

문제 상황 및 예상결과 

1. 한개의 Todo Entity에 6개의 Comment Entity가 달려있다.

2. QueryDSL을 통해서 조회를 한다

3. Log에는 1개의 Todo Entity와 그 객체안에 Comment Entity가 List 타입으로 저장되어 출력된다.

 

 Todo Class

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "TODO_ID")
    private Long id;

    @Column(nullable = false) // text
    private String content; // 내용

    @Column(name = "MEMBER_ID", nullable = false)
    private String writer;

    @Convert(converter = TodoCheckedConverter.class)
    private boolean checked;

    @OneToOne(mappedBy = "todo", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    private Attach attach;

    @OneToMany(mappedBy = "todo", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    @Builder.Default
    private List<Comment> comments = new ArrayList<>();

Comment Class

   @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "COMMENT_ID")
    private Long id;

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

    @Column(nullable = false)
    private String writer;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TODO_ID")
    private Todo todo;

QueryDSL 조회 쿼리

List<Todo> todos = jpaQueryFactory.select(todo)
                .from(todo)
                .leftJoin(todo.comments, comment).fetchJoin()
                .leftJoin(todo.attach, attach).fetchJoin()
                .orderBy(todo.id.asc())
                .fetch();

테스트를 돌려보았다.

    @Test
    public void TODO_SELECTOR_FIND_ALL_TEST() {

        List<Todo> all = todoSelector.findAll();
        log.info("all.size : {}", all.size());
        for (Todo todo : all) {
            log.info(todo);
            log.info(todo.getComments());
        }
    }

 

결과 로그

Hibernate: 
    select
        todo0_.todo_id as todo_id1_4_0_,
        comments1_.comment_id as comment_1_1_1_,
        attach2_.attach_id as attach_i1_0_2_,
        todo0_.moddate as moddate2_4_0_,
        todo0_.regdate as regdate3_4_0_,
        todo0_.checked as checked4_4_0_,
        todo0_.content as content5_4_0_,
        todo0_.member_id as member_i6_4_0_,
        comments1_.moddate as moddate2_1_1_,
        comments1_.regdate as regdate3_1_1_,
        comments1_.content as content4_1_1_,
        comments1_.todo_id as todo_id6_1_1_,
        comments1_.writer as writer5_1_1_,
        comments1_.todo_id as todo_id6_1_0__,
        comments1_.comment_id as comment_1_1_0__,
        attach2_.moddate as moddate2_0_2_,
        attach2_.regdate as regdate3_0_2_,
        attach2_.to_saved_file_name as to_saved4_0_2_,
        attach2_.todo_id as todo_id6_0_2_,
        attach2_.upload_file_name as upload_f5_0_2_ 
    from
        todo todo0_ 
    left outer join
        comment comments1_ 
            on todo0_.todo_id=comments1_.todo_id 
    left outer join
        attach attach2_ 
            on todo0_.todo_id=attach2_.todo_id 
    order by
        todo0_.todo_id asc

 

all.size : 6
Todo(id=1, content=todo0, writer=writer0, checked=false)
[Comment(id=1, content=안녕하세요.0, writer=작성자0), Comment(id=2, content=안녕하세요.1, writer=작성자1), Comment(id=3, content=안녕하세요.2, writer=작성자2), Comment(id=4, content=안녕하세요.3, writer=작성자3), Comment(id=5, content=안녕하세요.4, writer=작성자4), Comment(id=6, content=안녕하세요.5, writer=작성자5)]
Todo(id=1, content=todo0, writer=writer0, checked=false)
[Comment(id=1, content=안녕하세요.0, writer=작성자0), Comment(id=2, content=안녕하세요.1, writer=작성자1), Comment(id=3, content=안녕하세요.2, writer=작성자2), Comment(id=4, content=안녕하세요.3, writer=작성자3), Comment(id=5, content=안녕하세요.4, writer=작성자4), Comment(id=6, content=안녕하세요.5, writer=작성자5)]
Todo(id=1, content=todo0, writer=writer0, checked=false)
[Comment(id=1, content=안녕하세요.0, writer=작성자0), Comment(id=2, content=안녕하세요.1, writer=작성자1), Comment(id=3, content=안녕하세요.2, writer=작성자2), Comment(id=4, content=안녕하세요.3, writer=작성자3), Comment(id=5, content=안녕하세요.4, writer=작성자4), Comment(id=6, content=안녕하세요.5, writer=작성자5)]
Todo(id=1, content=todo0, writer=writer0, checked=false)
[Comment(id=1, content=안녕하세요.0, writer=작성자0), Comment(id=2, content=안녕하세요.1, writer=작성자1), Comment(id=3, content=안녕하세요.2, writer=작성자2), Comment(id=4, content=안녕하세요.3, writer=작성자3), Comment(id=5, content=안녕하세요.4, writer=작성자4), Comment(id=6, content=안녕하세요.5, writer=작성자5)]
Todo(id=1, content=todo0, writer=writer0, checked=false)
[Comment(id=1, content=안녕하세요.0, writer=작성자0), Comment(id=2, content=안녕하세요.1, writer=작성자1), Comment(id=3, content=안녕하세요.2, writer=작성자2), Comment(id=4, content=안녕하세요.3, writer=작성자3), Comment(id=5, content=안녕하세요.4, writer=작성자4), Comment(id=6, content=안녕하세요.5, writer=작성자5)]
Todo(id=1, content=todo0, writer=writer0, checked=false)
[Comment(id=1, content=안녕하세요.0, writer=작성자0), Comment(id=2, content=안녕하세요.1, writer=작성자1), Comment(id=3, content=안녕하세요.2, writer=작성자2), Comment(id=4, content=안녕하세요.3, writer=작성자3), Comment(id=5, content=안녕하세요.4, writer=작성자4), Comment(id=6, content=안녕하세요.5, writer=작성자5)]

1개가 나오길 바랬지만 6개가 나온다. 그것도 같은 ID 를 가진 데이터로써..

마치 DB를 조회해서 직접 ROW를 검색한 결과를 보는 느낌이다.

재밌는건 fetchjoin으로 N+1 쿼리 미발생에도 불구하고 이렇게 결과가 나왔는데,

사실 이 결과는 조회하는 과정을 생각해보면 얼추 맞는 결과라고 생각했다.

 

같은 식별자를 가진 Entity가 조회 되었으니,  1개의 데이터가 나오게 하기 위해서는 Distinct를 사용해서

같은 Entity 를 제거하여 조회하도록 수정했다.

jpaQueryFactory.selectDistinct(todo)

 

결과는 하다보니 잃어버렸으나, 잘 나온 것을 확인헀다.

하지만,, 직접 API로 호출했을 때는..?

 

[
    {
        "regDate": "2022-04-28T19:21:41.465903",
        "modDate": "2022-04-28T19:21:41.465903",
        "id": 1,
        "content": "todo0",
        "writer": "writer0",
        "checked": false,
        "attach": null,
        "comments": [
            {
                "regDate": "2022-04-28T19:21:41.532232",
                "modDate": "2022-04-28T19:21:41.532232",
                "id": 1,
                "content": "안녕하세요.0",
                "writer": "작성자0",
                "todo": {
                    "regDate": "2022-04-28T19:21:41.465903",
                    "modDate": "2022-04-28T19:21:41.465903",
                    "id": 1,
                    "content": "todo0",
                    "writer": "writer0",
                    "checked": false,
                    "attach": null,
                    "comments": [
                        {
                            "regDate": "2022-04-28T19:21:41.532232",
                            "modDate": "2022-04-28T19:21:41.532232",
                            "id": 1,
                            "content": "안녕하세요.0",
                            "writer": "작성자0",
                            "todo": {
                                "regDate": "2022-04-28T19:21:41.465903",
                                "modDate": "2022-04-28T19:21:41.465903",
                                "id": 1,
                                "content": "todo0",
                                "writer": "writer0",
                                "checked": false,
                                "attach": null,
                                "comments": [
                                    {
                                        "regDate": "2022-04-28T19:21:41.532232",
                                        "modDate": "2022-04-28T19:21:41.532232",
                                        "id": 1,
                                        "content": "안녕하세요.0",
                                        "writer": "작성자0",
                                        "todo": {
                                            "regDate": "2022-04-28T19:21:41.465903",
                                            "modDate": "2022-04-28T19:21:41.465903",
                                            "id": 1,
                                            "content": "todo0",
                                            "writer": "writer0",
                                            "checked": false, 
                                            ....

Todo 한개의 데이터를 조회했는데 Postman에서는 연관관계 데이터가 계속해서 출력되어서 에러가 발생했다.

 

API의 Response는 Entity 타입이 아니라 DTO로 해줘야한다는 사실을 뒷받침해주는 증거이기도 하다.

 

여기서 해결방법을 생각해보았다.

1. Entity의 연관관계 양방향 매핑을 단방향으로 변경시킨다.

2. DTO로 변환시켜서 반환한다.

3. QueryDSL에서 @QueryProjections 을 사용해서 조회하고 각 프로퍼티를 바꿔서 보여준다.

 

내가 QueryDSL에서 항상 쓰는 방식은 3번이다.

그러나, 생각해보니 여태까지 3번방식을 쓰면서 Collection 타입의 프로퍼티를 만들어서 변환했던 적이 없다.,

 

이번 프로젝트는 완성본을 만드는 것이 목적이 아니라 실습하는 것이 목적인 프로젝트이므로,

1, 2번 두 방식 모두를 써볼 것이다.

일단 글을 쓰기전에 2번방식으로는 이미 진행했다 ㅎ..

 

과연 실무에서는 어떻게 쓸 것인가.

Entity의 연관관계의 구조를 바꿀까 아니면 DTO를 추가해서 변환시킬까.

1. 연관관계 구조를 바꾼다면, 기존 코드들이 변경될 수 있기때문에, 리팩토링이나, 디버깅에 좀 더 힘써야 할 것 같다.

2. DTO로 추가해서 변환시킨다면, 연관관계의 변경은 필요없을 것이고, DTO 파일이 추가되면서

기존 코드에 대한 수정은 크게 없지만 추후에 관리해야 할 대상이 늘어날것같다.