본문 바로가기
프로젝트/Recipository

[Dev] 23.01.26. JPQL을 활용한 게시글 data join select (feat. fetch join)

by 규글 2023. 1. 26.

지난 게시글에서의 고찰 2

select 두 번 vs. join 한 번

 Recipe 입장에서 Link와 Comment는 모든 정보를 필요로 한다지만 Recipe 입장에서 writer는 SpUser의 모든 정보를 필요로 하는 것이 아니다. 현 상황에서 Recipe 하나의 정보를 가져오는 것에 수행되는 query 문은 recipe table select 한 번, sp_user table select 한 번으로 총 두 번이다. 그런데 이를 아예 jpql을 사용해서 한 번의 select로 가져오는 건 어떨까? 두 번 select 하여 값을 DTO에 담아 response 하는 것이 아니라, query 문의 join을 활용하여 한 번에 조회한 뒤에 DTO에 담에 response 하는 방식이 더 좋은 방식이지 않을까?


 위 내용은 지난 게시글에서의 두 가지 고찰 중 두 번째이다.

 

 고찰의 이유가 된 이미지이다. 세 군데로 구분된 영역은 왼쪽부터 각각 게시글(recipe), 참조 링크(link), 사용자(member)에 대한 table을 조회하는 select 문이 수행된 것이다. 고찰은 가장 오른쪽 영역인 member 를 select 하는 것에서 시작되었다.

 

 현재 게시글의 List를 얻고자 하여 게시글인 Recipe와 사용자인 Member를 양방향 연관 관계를 형성하고 있다. 그런데 이렇게 형성해 준 이유는 작성자 정보를 불러오기 위함인데, 두 번의 select 조회를 하는 것보다 join을 활용한 한 번의 select 조회를 하는 것이 더 좋은 방향이지 않을까 하는 생각이 든 것이다.

 더하여 전체 게시글을 보이는 index page의 경우는 게시글의 작성자 수 만큼 member table에서 조회하게 된다. 애초에 개수를 많이 노출할 목적은 아니었으나, 효율 면에서도 좋아보이는 것은 아니다.

 

작업 아이디어

 현재 JPA를 활용하고 있어서, 필요한 query문을 자동으로 생성해주고 있다. 하지만 join 한 번에 조회하기 위해서는 query문을 따로 지정해주어야 한다.

 

작업

SELECT 2번 -> JOIN SELECT 1번

1. 게시글 작성자 data 불러오기

RecipeRepository.java

//    @Query(value = "select * from recipe r join member m on r.user_id = m.user_id where r.user_id = :userId",
//            nativeQuery = true)
//    List<Recipe> getAllByMember(@Param("userId") Long userId);

//    @Query("select r from Recipe r join r.member m where m.userId = :id")
//    List<Recipe> getAllByUserId(@Param("id") Long id);

    @Query("select r from Recipe r join r.member m where m = :user")
    List<Recipe> getAllByMember(@Param("user") Member member);

 필자는 위 세 가지 중에 마지막 것을 택해서 작성했다. 하나씩 뜯어보도록 하자.

 

select * 			: 전체 항목을 가져온다.
from recipe r 			: recipe table 로부터
join member m 			: member table을 join 하는데
on r.user_id = m.user_id	: recipe table의 user_id와 member table의 user_id 가 같은 것만
where r.user_id = :userId	: recipe table의 user_id가 ~~ 인 것만

 첫 번째는 nativeQuery 속성을 true로 하여 기존에 알고 있던 SQL 그대로를 작성한 것이다. 정리해보면 recipe table의 data 와 그중에 recipe table의 user_id 와 member table의 user_id 가 같은 member table을 join 하는데, 또 그 중에서 recipe table의 user_id가 지정한 parameter에 해당하는 것만 select 하겠다는 것이다.

 가장 익숙한 방식의 query 문이며, @Param annotation을 활용해서 parameter를 query 문에 전달하였다.

 

select r 			: Recipe Entity data를 가져온다.
from Recipe r 			: Recipe Entity 로부터
join r.member m 		: Recipe와 연관 관계인 Member와 join 하는데
where m.userId = :id		: Member의 userId field 값이 ~~ 인 것만

 두 번째는 nativaQuery 속성이 false인 상태로 JPQL 을 활용한 것이다. 이전에 관련 내용을 간단히 기록해둔 적이 있다.[각주:1] 기존의 SQL 과는 다르게 JPQL 은 table 이 아닌 entity를 기준으로 하므로 다음과 같이 정리해볼 수 있겠다. Recipe Entity data를 Member Entity와 join 하여 가져오는데, 그 중에 Member의 userId field가 지정한 parameter에 해당하는 것만 select 하겠다는 것이다.

 

 마지막 세 번째는 두 번째 경우에서 where 절에 전달하는 parameter 부분만이 다르다. 지정한 Member 인 경우만 Recipe Entity data를 select 하겠다는 것이다.

 

 각각의 경우에 동작하는 query 문이다. SQL 로 작성한 것은 작성한 그대로 출력된 것이고, JPQL로 작성한 것은 작성한 내용을 기반으로 query 문을 다시 작성해준다. 공통적으로 모두 join query가 포함되어 있음을 확인할 수 있다.

 

2. 댓글 작성자 data 불러오기 (feat. JPQL fetch and N+1 problem)

 현재 상태 그대로 댓글 data를 불러오게 되면 댓글 작성자의 수만큼 사용자인 member table에 대한 query 문이 동작하는 것을 확인할 수 있었다. 지금이야 테스트 과정에서의 두 번이지만, 사용자가 많다면 이것은 문제가 될 수 있다.

 

 현재의 구조는 우선 recipe table에서 게시글 data를 Recipe Entity로 받고, Recipe Entity에서 다시 댓글 data인 Comment List를 조회하여 각각을 client로 전달하는 DTO로 변환한다. 따라서 시작점인 Comment List를 조회하는 부분부터 수정해주어야 한다.

 

 우선 기존에 댓글 data를 연관 관계를 통해서 얻어오던 것을 repository 를 통해서 얻어오는 것으로 바꾸었다. 그리고 앞서 JPQL 을 활용해 작성했던 것처럼 같은 방식으로 query 문을 작성해주었다.

 문제는 이렇게 join query 가 동작하도록 했음에도 불구하고 게시글이나 댓글 작성자를 호출하기 위해 Member Entity의 getter method가 동작할 때, member table 을 select 하는 query 문이 계속해서 동작하고 있다는 것이다. 물론 게시글과 댓글 작성자가 동일한 경우는 한 번만 호출되기는 하지만, 그것은 중요한 부분이 아니긴 하다.

 

 이와 관련하여 많은 블로그에서 'N+1 Query Problem (N+1 문제)' 를 언급하고 있었다. 'N+1 Query Problem' 란 연관 관계가 설정된 Entity에 대한 data를 조회할 때, 그것과 연관된 Entity에 대한 select query 가 추가로 발생하여 data를 읽게 되는 현상을 말한다. 관련 정의 및 설명이 있는 docs를 찾지는 못했지만, wiki 형태의 사이트에서 간략히 정리해놓은 것을 발견할 수 있었다.[각주:2] (이런 설명에 기반한다면 '1+N Query Problem' 이라고 naming 하는 것이 더 좋아보이긴 하다.)

 

 현재 필자의 상황을 다시 보면 이렇다.

 필자가 Recipe를 조회하거나 Comment를 조회할 때, 이와 관련된 Member에 대한 추가 query 가 동작하고 있다. 즉, JPQL 을 활용해서 한 번의 query문으로 data를 조회했는데, 관련된 entity에 대한 추가 query 가 동작하고 있는 것이다. 이는 연관 관계의 fetch type이 EAGER 든 LAZY 든 상관없이 나타나는 현상이다.

 

 이 현상을 해결하기 위해서 블로그들에서는 fetch join 을 언급하고 있다. 이외에도 QueryDSL, @EntityGraph annotation, batch 등을 더 언급하고 있지만 필자는 fetch join 을 활용했으므로 이에 대해서만 작성하고, 필요 시 다른 항목들에 대한 공부를 계속할 목적으로 이곳에 언급해두도록 한다.[각주:3] [각주:4] [각주:5]

 

 그러면 fetch join에 대해 알아본 것을 이어서 정리해보려고 한다.

 

 Oracle docs에서는 JPQL의 fetch join에 대해 위 이미지와 같이 언급하고 있다.[각주:6] 해석하면 다음과 같다.


 Fetch join은 query 의 수행에 대한 side effect 로서의 연관 항목을 fetching 하는(가져오는) 것이 가능하다. Fetch join은 한 entity와 그것에 관련된 entity 들로 명시된다. Fetch join에 대한 syntax는 다음과 같다.

[LEFT(OUTER) INNER] JOIN FETCH join_association_path_expression

 Fetch join 구문의 오른쪽 부분에 의해 참조되는 관계는 반드시 query의 결과로 return 되는 entity에 속해야 한다. Fetch join 구문의 오른쪽 부분에 의해 참조되는 entity 들에 대한 변수 명명이 지정되는 것은 허용되지 않으며, 그래서 implicitly 하게(내적으로) fetched 된 reference 들은 query 어디에서도 나타나지 않는다.

 이어지는 query는 magazine의 Set을 return 한다. Side effect 로서, 명시된 query result의 part 가 아님에도 magazine 에 연관된 articles 가 (DB로부터) 검색된다.

 EAGER 하게 fetch된 'articles' 의 영속된 field 들이나 property 도 전부 initialize 된다. 검색되는 'articles' 의 관계 property 들에 대한 initialization은 'Article' entity class에 대한 metadata에 의해 결정된다.

SELECT mag FROM Magazine mag LEFT JOIN FETCH mag.articles WHERE mag.id = 1

 Fetch join은 join operator의 오른쪽 부분에 명시된 관련된 객체들이 query result에 return 되지 않거나 query에 참조되지 않는다는 점을 제외하면, inner 와 outer join에 상응하는 동일한 join의 의미를 가진다. 그래서 예를 들면 id가 1인 magazine이 5개의 article을 가지고 있는 경우에는 위 query가 magazine 1 entity에 대한 5개의 reference(= article) 를 return 한다.


 

 이를 주어진 예시에 기반하여 다시 살펴보자.

 Fetch join 구문에 의해 참조되는 articles는 Article 이라고 직접적으로 변수 지정을 하는 것이 허용되지 않으면서, 동시에 articles가 return 되는 entity인 Magazine에 속한 상태이다. 따라서 내적으로 fetch 된 Article을 query 에서는 볼 수 없는 것이다. 하지만 이렇게 명시되지 않은 상태이지만 return 을 원하는 Magazine에 연관된 Article 도 함께 조회되고, 동일한 join의 의미를 가지는 것이다.

 이때 query로 인해 얻게되는 결과물은 docs의 다른 부분에서 'result of the query' 라고 따로 명시하는 것으로 보아 이곳의 query result는 수행되는 query 에서 return 하고자 하는 부분을 말하는 것이라고 생각된다. 예를 들면 select ~~ 의 물결에 해당하는 부분을 query result 라고 칭하는 것 같다. 예시의 경우는 select mag의 'mag' 자리에 해당한다. 그리고 이렇게 작성한 fetch join은 Entity에 작성한 fetch 속성보다 우선된다.

 

 다시 작업으로 돌아가보겠다.

 

 왼쪽 이미지들은 이번 게시글에 해당하는 작업에서 JPQL의 join 만을 활용한 것이고, 오른쪽 이미지들은 fetch join을 활용한 것이다.

 

 왼쪽은 한 유저가 작성한 게시글의 목록을 불러오는 query 문, 오른쪽은 게시글을 조회했을 때 해당 게시글에 작성된 댓글의 목록을 불러오는 query 문이다. 두 경우 모두 inner join query 가 수행되었으며, 이외에 따로 연관 관계가 있는 사용자에 해당하는 Member Entity에 대한 select query는 사라졌다.

 

 

 이렇게 JPA 를 통해 수행되는 query 문을 조정하기 위한 작업을 마쳤다. 반복되는 select query는 당장은 아무런 문제를 일으키지 않지만, debugging 과정에서 동작한 query 문을 확인할 때 위화감이 들어서 시작한 작업이었다. 원하는 방향으로 작업이 마무리되어 다행이고, 또 새로운 것을 배울 수 있어서 다행이다.
 이어서 작업할 항목은 단순히 게시글의 목록을 불러오는 과정에서 각 게시글에 대한 참조 링크까지 select 하는 query 동작을 없애는 것이다.

 

Reference

댓글