지난 포스트에서 JPA의 패치 전략과 JPA 사용 시 자주 일어나는 문제로 잘 알려진 N+1 문제에 대해 알아보았다. 오늘은 N+1 문제를 해결하기 위한 해결 방안에 대해 다룬다
N+1 문제는 개발자가 작성한 한 개의 쿼리가 예상에서 벗어나 실제 데이터베이스에 연관된 엔티티 목록의 수만큼 추가 쿼리를 더 보내는 문제를 의미한다.
select * from team
--> select * from team left join memebr on team.member_id = member.id
만약 다음과 같이 쿼리를 날린다면 개발자는 쿼리 한 번에 연관 객체까지 불러오기를 기대할 것이다. 때문에 한번의 쿼리로 개발자가 기대하는 응답 결과를 불러오기 위해서는 join이 수행되어야 한다. 이를 위해서 Fetch join을 사용할 수 있다. 패치 조인은 조인 시 inner join이 실행되며 사용하는 방법은 다음과 같다.
@Query("select t from Team t join fetch t.members")
List<Team> findAllWithTeam();
기본 CRUD 쿼리처럼 JpaRepository에서 자동 생성이 불가하기 때문에 JPQL을 사용해야 한다. 따라서 자식 객체에 대한 패치 전략을 default인 지연 로딩으로 설정하고 필요할 때 패치 조인을 사용하여 최적화하는 것이 좋다. 하지만 이러한 패치 조인에도 한계는 있다.
테이블의 연관 관계가 1:N일 때 패치 조인을 사용하면 페이징 시 문제가 발생한다. 패치 조인은 부모 자식 테이블 간의 조인 테이블에서 데이터를 불러오며 limit를 사용한 페이징 처는 행을 기준으로 동작하기 때문에 불러온 결과에 대해 중복 또는 손실이 발생한다. 따라서 개발자가 원하는 결과가 아닐 가능성이 높다. 예제를 통해 자세히 살펴보자.
team_id | team_name | member_id | member_name | member_job |
---|---|---|---|---|
1 | Alpha Team | 1 | Alice | 1 |
1 | Alpha Team | 1 | Alice | 2 |
2 | Beta Team | 2 | Charlie | 1 |
2 | Beta Team | 3 | Dave | 1 |
2 | Beta Team | 4 | Eve | 3 |
1:N 연관 관계로 맺어진 Team과 Member테이블을 조인한 결과이다. Team과 Member 테이블은 생략하도록 하겠다.
Team 목록을 불러오기 위해 쿼리를 날렸다. 개발자 입장에서는 동시에 Team에 속한 Member가 누가 있는지가 궁금하기 때문에 함께 불러오기를 원한다. 하지만 패치 조인 최적화 과정에서는 두 테이블에 대한 조인이 먼저 일어난 후 그 결과에 limit를 2로 하여 페이징한 결과를 불러온다면 첫 번째행과 두번째 행만 들고 올것이다. 결론적으로 개발자는 동일 멤버인 Alice에 대한 row만 두개 들고오게 된다.
이는 개발자의 의도와는 다르다. 따라서 패치 조인은 N:1일 때 사용하는 것이 바람직하다.
다른 방법으로는 EntityGraph를 사용하여 조회 시 outer join을 사용하여 한 번의 쿼리만 수행하도록 할 수 있다.
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
attributesPaths 속성 값을 쿼리 수행 시 함께 바로 가져올 필드명으로 지정하면 Eager 조회로 가져오게 되며 조인 시 outer join을 수행한다. 따라서 한 번의 쿼리로 연관 엔티티를 불러와 N+1 문제를 해결할 수 있지만 이 방법도 한계가 존재한다.
이 방법 또한 패치 조인과 비슷하게 조인을 사용한다는 점에서는 궤가 같다. 따라서 조인 시 발생하는 한계를 가지고 있다. 이 방법의 경우에 outer join을 사용하기 때문에 카타시안 곱이 발생해 Team의 수만큼 Member에 중복에 발생할 수 있다. 따라서 중복 컬럼에 대한 처리가 필요하다.
배치 사이즈를 설정하면 자식 엔티티를 가져올 때 설정된 배치 사이즈 만큼 한 번에 로드한다.
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
@BatchSize(size = 5) // BatchSize 설정
private List<Member> members;
size 속성 값을 10으로 설정해두면 조회된 Team 목록에서 10개 이하의 Team 목록에 대한 자식 엔티티를 가져오도록 한다.
SELECT * FROM Team;
SELECT * FROM Member WHERE team_id IN (1, 2, 3); //배치 사이즈 설정에 의한 추가 쿼리
만약 Team 목록 조회 시 3개의 Team이 조회 되었다면 설정한 배치 사이즈에 의해 IN 절을 사용한 자식 엔티티 조회 쿼리가 추가적으로 일어난다. 따라서 N+1 을 1+1로 줄일 수 있는 것이다. 만약에 Team 목록이 13개라면 어떤 쿼리가 실행될까?
SELECT * FROM Team;
SELECT * FROM Member WHERE team_id IN (1, 2, 3, 4, 5);
SELECT * FROM Member WHERE team_id IN (6, 7, 8, 9, 10);
SELECT * FROM Member WHERE team_id IN (11, 12, 13);
다시 강조하면 배치 사이즈는 한번에 가져 올 수 있는 자식 엔티티의 개수를 지정하는 것이다. 따라서 다음과 같이 5개 이하의 Team에 대해 자식 엔티티를 가져오는 쿼리가 실행된다. 또한 지연 로딩으로 패치 전략을 선택했다면 엔티티 최초 사용 시점에 5건을 미리 로드하고, 6번째 엔티티를 사용할 때 다음 쿼리가 자동으로 실행된다.
세 가지 전략을 살펴보았지만 세 전략 모두 한계가 존재한다.
따라서 결론은 JPA는 만능이 아니며 잘 알려진 해결 방안을 적용할 때도 상황에 맞는 고민이 필요하다는 것이다