JPA Criteria를 이용한 동적 쿼리와 Fetch Join을 제대로 이해하기!

TAEYONG KIM·2023년 11월 12일
0

Spring

목록 보기
2/7

상황

먼저 JPA Criteria란 무엇일까??

JPQL을 자바 코드로 작성하도록 도와주는 빌더 클래스 API입니다. 문자가 아닌 코드로 JPQL을 작성하므로 문법 오류를 컴파일 단계에서 잡을 수 있고 동적 쿼리를 안전하게 생성할 수 있다는 장점이있다.

코드가 복잡하고 여러 인터페이스에 대한 이해도가 필요해서 습득하는데 오랜 시간이 걸릴 수 있다는 단점이 있다고 생각합니다.

그렇다면 JPA Criteria 를 사용해서 동적 쿼리가 필요로 했던 이유는 무엇일까?

SNS 형태의 서비스들은 해당 Feed들의 Contents들을 다른 유저들에게 보여주는 것들이 많습니다. 우리 서비스에서는 ToDo에 대해 사용자들에게 공유하고 연관된 HashTag를 보여주며 이 태그와 관련된 잔소리들을 Random하게 무한 스크롤로 제공되기 때문에 동적 쿼리를 필요로 했습니다.

  1. 무한 스크롤을 통해 Contents들을 조회할 때 처음 조회하는 케이스 분기 처리
  2. 해당 Contents들마다 Nag가 존재하는 케이스와 존재하지 않는 케이스들에 대한 분기 처리
  3. Contents마다 Nag가 존재하거나 존재하지 않는데 Nag가 초성으로 제공 될수도, 초성이 해제된 상태로 존재하기 때문에 초성 해제에 따른 유무 케이스에 따른 분기 처리

복잡한 쿼리들을 유연하게 풀어내기 위해 많은 노력을 쏟아내야 문제 없는 서비스를 제공할 수 있었습니다.

문제


사실 큰 문제보다는 Spring Data JPA를 사용하면서 Method들의 동작에 대해 정확한 이해를 했어야 했습니다.

하지만 본론으로 파악하자면

Nag라는 Entity는 NagUnlock과 1 : N 연관 관계를 가지고 있었습니다. 또한 NagUnlock은 해당 Nag의 초성을 어떠한 Member가 해제 했는지 안했는지에 대해 member_id를 가지고 있는 Entity라고 볼 수 있습니다.

@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity(name = "nag")
public class Nag extends BaseTimeEntity {
  @Id
  @Column(name = "nag_id")
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
	@OneToMany(mappedBy = "nag", cascade = CascadeType.ALL)
  private Set<NagUnlock> nagUnlocks = new HashSet<>();
	....
}
Hibernate: 
    select
        distinct nag0_.nag_id as nag_id1_4_0_,
        tag2_.tag_id as tag_id1_14_1_,
        todos3_.todo_id as todo_id1_16_2_,
        nag0_.created_at as created_2_4_0_,
        nag0_.modified_at as modified3_4_0_,
        nag0_.content as content4_4_0_,
        nag0_.like_count as like_cou5_4_0_,
        nag0_.member_id as member_i7_4_0_,
        nag0_.preview as preview6_4_0_,
        nag0_.tag_id as tag_id8_4_0_,
        tag2_.follow_count as follow_c2_14_1_,
        tag2_.name as name3_14_1_,
        todos3_.created_at as created_2_16_2_,
        todos3_.modified_at as modified3_16_2_,
        todos3_.content as content4_16_2_,
        todos3_.display as display5_16_2_,
        todos3_.finished as finished6_16_2_,
        todos3_.member_id as member_i8_16_2_,
        todos3_.nag_id as nag_id9_16_2_,
        todos3_.todo_at as todo_at7_16_2_,
        todos3_.nag_id as nag_id9_16_0__,
        todos3_.todo_id as todo_id1_16_0__ 
    from
        nag nag0_ 
    left outer join
        nag_unlock nagunlocks1_ 
            on nag0_.nag_id=nagunlocks1_.nag_id 
            and (
                nagunlocks1_.member_id=?
            ) 
    inner join
        tag tag2_ 
            on nag0_.tag_id=tag2_.tag_id 
    left outer join
        todo todos3_ 
            on nag0_.nag_id=todos3_.nag_id 
    where
        nag0_.member_id=? 
    order by
        nag0_.nag_id desc

멤버가 다른 멤버의 잔소리 목록을 조회할때

실제로 Nag Entity를 통해서 Nag를 조회하는 member의 Id를 만족하는 nag_unlock에 대한 left outer join이 발생하여 nag마다 잔소리 해제 유무에 대해 nagUnlocks라는 Set 자료구조의 크기가 0 또는 1을 만족했습니다.

이후, 아래 Response를 확인해보시면 unlocked : true 또는 false를 내려주기 위해 nagUnlocks의 size > 0 보다 크면 true 0이면 false를 반환하도록 비즈니스 로직을 구현했습니다.

{
    "code": "200",
    "message": "success",
    "data": {
        "nags": [
            {
                "nagId": 30,
                "content": "잔소리9",
                "createAt": "2023-08-15 00-31",
                "likeCount": 1,
                "deliveredCount": 4,
                "tag": {
                    "tagId": 10,
                    "tagName": "태그9"
                },
                "preview": "ㅈㅅㄹ9",
                "unlocked": true,
                "isLiked": true
            },
            {
                "nagId": 29,
                "content": "잔소리8",
                "createAt": "2023-08-15 00-31",
                "likeCount": 1,
                "deliveredCount": 2,
                "tag": {
                    "tagId": 9,
                    "tagName": "태그8"
                },
                "preview": "ㅈㅅㄹ8",
                "unlocked": false,
                "isLiked": true
            }
        ],
        "hasNext": false,
        "nextCursor": null
    }
}

문제2

왜 다른 멤버의 잔소리들을 보는 멤버가 초성 해제를 한 케이스가 없는데 unlocked의 value가 계속 true일까???? 쿼리를 봤을때는 정상 동작하고 있는데 무슨 문제인지 파악을 제대로 하지 못했습니다

이유는 쿼리의 개수를 줄이기 위해 한번의 쿼리에서 모든걸 해결하려다보니 동적 쿼리에서 Join을 이용해서 join을 구현했습니다.

nag.fetch("tag", JoinType.INNER);
nag.fetch("todos", JoinType.LEFT);
Join<Nag, NagUnlock> nagUnlocks = nag.join("nagUnlocks", JoinType.LEFT);
nagUnlocks.on(cb.equal(nagUnlocks.get("member"), viewer));

하나의 쿼리로 해결 할 수 있었지만 Join으로 인한 결과가 당연히 영속성 컨텍스트에 올라올 것이라는 생각을 했었습니다.

일반 Join은 실제 쿼리에 join을 걸어 쿼리에 영향을 주긴 하지만 join대상에 대한 영속성까지는 관여하지 않다는 것이었습니다.

따라서!

nag.getNagUnlocks()를 호출할때 해당 쿼리를 보시겠습니다.

select
        nagunlocks0_.nag_id as nag_id3_6_1_,
        nagunlocks0_.nag_unlock_id as nag_unlo1_6_1_,
        nagunlocks0_.nag_unlock_id as nag_unlo1_6_0_,
        nagunlocks0_.member_id as member_i2_6_0_,
        nagunlocks0_.nag_id as nag_id3_6_0_ 
    from
        nag_unlock nagunlocks0_ 
    where
        nagunlocks0_.nag_id in (
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
        )

그 결과 1 + N 문제가 발생합니다

행동

한번의 쿼리를 통해서 가져오지만 일반 Join을 하는 것이 아니라 Fetch Join을 통해서 Business Logic에서 하나의 자료구조를 만들어서 해결하는 것이었습니다.

따라서

nag.fetch("tag", JoinType.INNER);
nag.fetch("todos", JoinType.LEFT);
nag.fetch("nagLikes", JoinType.LEFT);
nag.fetch("nagUnlocks", JoinType.LEFT);

를 통해서 nagUnlocks들을 호출해서 Set을 채웠고

Business Logic을 통해 nagUnlock들 중 조회하는 memberId의 nagUnlock이 존재한다면 Response를 만족시켰습니다

결과

개선된 점1

동적 쿼리에서 On절에 대해 또 Query가 발생하는 점을 제거할 수 있었습니다.

즉, 다른 멤버의 잔소리 목록을 조회할 때 1건의 쿼리로 해결할 수 있었습니다.

아쉬운 점1

단순히 필터링을 위해 Join을 하는 것이 아니라 데이터베이스로부터 NagUnlock에 대해 조회하는 member에 대해 Join을 통해 해결하는 것이 아니라 Fetch Join으로 해결할 수 있었다는 사실을 뒤늦게 깨달았습니다.

또는 JPQL이나 QueryDSL을 활용할 수도 있었습니다.

profile
백엔드 개발자의 성장기

0개의 댓글