[JPA] - N + 1 문제 ②

[JPA] - N + 1 문제 ②

안녕하세요, 오늘은 이전에 알아보았던 N + 1 문제의 해결 방법에 대해서 정리하려고 합니다.

이전 글(N + 1 문제에 대한 설명)을 확인하고 싶으시면 아래 글을 참조해주시길 바랍니다.

프로젝트 버전 [①편에서 사용한 프로젝트에 이어서 진행할 예정입니다.]

개발 도구: IntelliJ Ultimate

Spring Boot: 2.5.7

Java 11

h2 Database

Spring Data JPA 의존성 추가 [build.gradle]

JUnit 5

①편에서 작성한 코드처럼 하나의 쿼리를 위해 부수적인 쿼리가 실행되는 것은 말도 안 되고, 애플리케이션에 불필요한 부하를 일으키게 됩니다. 그래서 N + 1 문제를 해결하는 방법으로는 대표적으로 2개가 있습니다.

[Fetch Join, @EntityGraph]

각각의 해결 방안에 대한 이론적인 내용을 적고 사용법을 알아가 보겠습니다.

Fetch Join

1. fetch join이 뭘까?

먼저, fetch join은 SQL 조인 종류는 아닙니다. JPQL에서 성능 최적화를 위해 제공하는 기능입니다. `연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회`하는 기능입니다.

※ JPQL(Java Persistence Query Language)은 객체지향 쿼리 언어입니다. 따라서 테이블을 대상으로 쿼리를 날리는 것이 아니라 엔티티 객체를 대상으로 쿼리를 날리는 언어 입니다.

select m from Member m [left|inner] join fetch m.team

Member는 Member Entity를 말합니다. m은 별칭을 부여한 것입니다.

left outer join, inner join이 가능하며 생략 시에는 inner join이 기본으로 나갑니다.

위와 같이 join fetch를 사용하면 Member Entity와 연관된 Team의 값을 함께 조회 합니다.

2. 엔티티 페치 조인 vs 컬렉션 페치 조인

위 1번의 구문 `연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회`에서 확인할 수 있듯이 페치 조인은 2가지의 상황이 있을 수 있습니다.

엔티티 페치 조인

Member를 조회할 때 연관된 Team도 함께 조회를 한다고 가정해보겠습니다. 그러면 다음과 같은 JPQL + fetch join을 사용하면 한 번의 SQL로 Member뿐 아니라 Team도 함께 조회할 수 있습니다.

select m from Member m join fetch m.team

Member Entity에는 Team Entity를 @ManyToOne 관계로 묶여있습니다. 그렇기 때문에 조회를 할 때 데이터 뻥튀기가 일어나지 않습니다.

컬렉션 페치 조인

그럼 이번에는 조회하려는 주체를 바꿔보도록 하겠습니다. Team을 조회할 때 해당 Team에 속하는, 연관된 Member를 함께 조회한다고 가정해보겠습니다. 그러면 다음과 같은 JPQL + fetch join가 나옵니다.

select t from Team t join fetch t.members

이렇게 하면 문제없이 Team에 연관된 멤버들을 조회할 수 있습니다. 그런데, 이 조회에는 큰 문제가 있습니다. 바로 Team Entity에는 Member Entity가 @OneToMany로 List의 형태로 관계가 맺어져 있는데, fetch join으로 Member 조회 시 데이터 뻥튀기가 일어난다는 점입니다.

※ 데이터 뻥튀기란? 일대다 관계, 컬렉션 페치 조인에서 발생하는 것입니다. 현재 Team Table에는 2개(탁벤져스, 탁팀)의 row가, Member Table에는 5개(왕탁이1, 2, 3, 4, 5)의 row가 존재합니다. 방금 예시에서 Team을 조회했고, 그 결과로 받은 Team List의 size는 2 가 되어야 합니다. 그런데, 일대다 관계, 컬렉션 페치 조인을 했기 때문에 데이터 뻥튀기가 일어나서 Team List의 size가 5(Member row count)인 것을 확인 할 수 있습니다.. 이것이 바로 데이터 뻥튀기입니다.

※ 데이터 뻥튀기를 처리하는 방법은 다른 글에서 설명하도록 하겠습니다.

※ 일대다 관계, 컬렉션 페치 조인을 할 때는 꼭 주의 깊게 확인을 하고 개발해야 될 거 같습니다.

@EntityGraph

@EntityGraph도 fetch join과 마찬가지로 연관된 엔티티를 SQL 한 번에 조회하는 방법입니다. 사실상 페치 조인(fetch join)의 간편 버전이며, left outer join을 사용합니다. @EntityGraph는 코드를 보시면 이해가 더 쉬울 것 같습니다.

테스트 코드

1. fetch join

fetch join에 관한 코드를 작성해보도록 하겠습니다. 순수 JPA + JPQL, Spring Data JPA + @Query를 사용하도록 하겠습니다.

클래스의 구조가 조금 복잡할 수 있어도 Spring Data JPA + Custom Interface + Custom Interface 구현체 구조로 순수 JPA를 구현하도록 하겠습니다. [Querydsl을 사용할 때 위와 같은 구조로 사용합니다.]

MemberRepositoryCustom

public interface MemberRepositoryCustom { List findAllFetchJoin(); }

다음과 같이 사용자 정의 인터페이스를 만들어줍니다. 그리고 이 인터페이스를 상속하여 구현하는 구현체를 만들어줍니다.

MemberRepositoryCustomImpl

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom { @PersistenceContext private EntityManager em; @Override public List findAllFetchJoin() { return em.createQuery("select m from Member m join fetch m.team", Member.class) .getResultList(); } }

구현체에는 순수 JPA + fetch join + JPQL을 사용하여 Member Entity를 조회하는 쿼리를 만들어줍니다. 이제 저희가 직접 만든 인터페이스를 MemberRepository에 상속만 해주면 됩니다.

MemberRepository

public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { // MemberRepositoryCustom 상속! @Query("select m from Member m join fetch m.team") List findAllQueryFetchJoin(); }

MemberRepositoryCustom 인터페이스를 상속했습니다.

@Query를 이용하면 Data JPA Repository에서 JPQL을 사용할 수 있습니다.

※ 참고, 사용자 정의 인터페이스와 구현체에는 스프링 빈에 등록하기 위한 어노테이션이 따로 붙지 않습니다. (ex. @Repository) 그럼에도 정상 작동을 하게 되는데요. 몇 가지 규칙만 지키면 Spring Data JPA에서 직접 스프링 빈으로 등록하기 때문입니다.

- (인터페이스) MemberRepository[Blabla]

- (구현체) MemberRepository[Blabla]Impl

Custom 대신에 다른 단어를 쓰셔도 됩니다. 여기서 중요한 것은 사용자 정의 인터페이스 뒤에 + Impl 이 붙어야 된다는 점입니다!

그럼 테스트 코드를 작성하여 실행되는 쿼리와 결과를 확인해보도록 하겠습니다.

Solution1Test [BasicJPA + fetch join + JPQL]

@SpringBootTest public class Solution1Test { @Autowired private MemberRepository memberRepository; @Test public void basicJpaFetchJoinTest() { List members = memberRepository.findAllFetchJoin(); members.iterator().forEachRemaining(member -> { System.out.println("member.getTeam().getTeamName() = " + member.getTeam().getTeamName()); }); } @Test public void springDataJpaFetchJoinTest() { List members = memberRepository.findAllQueryFetchJoin(); members.iterator().forEachRemaining(member -> { System.out.println("member.getTeam().getTeamName() = " + member.getTeam().getTeamName()); }); } }

basicJpaFetchJoinTest 쿼리 & 결과

쿼리 & 결과[쿼리 1번 수행]

springDataJpaFetchJoinTest 쿼리 & 결과

쿼리 & 결과[쿼리 1번 수행]

2가지 방법 모두 1번의 쿼리로 원하는 데이터를 조회한 것을 확인할 수 있습니다. 앞서 말씀드린 것처럼 컬렉션 페치 조인은 데이터 뻥튀기라는 주제로 다른 글에서 다루도록 하겠습니다.

※ 확인해보고 싶으신 분들은 TeamRepository를 MemberRepository처럼 구현하시면 확인하실 수 있습니다.

2. @EntityGraph

그럼 이제 @EntityGraph 사용 방법에 대해서 알아보도록 하겠습니다. 위에서 사용하셨던 MemberRepository에 다음과 같이 코드를 추가해주시면 됩니다.

MemberRepository

public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { @Query("select m from Member m join fetch m.team") List findAllQueryFetchJoin(); // @EntityGraph 추가된 코드 @Override @EntityGraph(attributePaths = "team") List findAll(); @EntityGraph(attributePaths = "team") @Query("select m from Member m") List findQueryEntityGraphAll(); }

@EntityGraph는 findAll()과 같이 오버라이딩하여 이미 구현되어 있는 메서드와 조합할 수 있습니다.

메소드 이름으로 쿼리를 생성하는 방식에서도 사용 가능합니다.

@Query 어노테이션을 사용한 JPQL과 혼합하여 사용할 수 있습니다.

그럼 테스트 코드를 작성하여 실행되는 쿼리와 결과를 확인해보도록 하겠습니다.

Solution2Test[Spring Data JPA + @EntityGraph]

@SpringBootTest public class Solution2Test { @Autowired private MemberRepository memberRepository; @Test public void dataJpaOverrideMethodEntityGraphTest() { List members = memberRepository.findAll(); members.iterator().forEachRemaining(member -> { System.out.println("member.getTeam().getTeamName() = " + member.getTeam().getTeamName()); }); } @Test public void dataJpaEntityGraphWithQueryTest() { List members = memberRepository.findQueryEntityGraphAll(); members.iterator().forEachRemaining(member -> { System.out.println("member.getTeam().getTeamName() = " + member.getTeam().getTeamName()); }); } }

dataJpaOverrideMethodEntityGraphTest 쿼리 & 결과

쿼리 & 결과[쿼리 1번 수행]

dataJpaEntityGraphWithQueryTest 쿼리 & 결과

쿼리 & 결과[쿼리 1번 수행]

@EntityGraph는 조인 방식이 left outer join입니다.

정리

지금까지 JPA를 사용하면서 쉽게 만날 수 있는 N + 1문제에 대해 ①, ②편으로 정리해봤습니다. (양이 좀 많네요.. ㅠ)

N + 1은 무조건 해결해야 하는 문제 다.

다. 해결 방법 + 최적화는 fetch join을 적극 사용 하자.

하자. 다만 일대다 관계에서 fetch join을 사용할 때 데이터 뻥튀기가 되는 것은 유의 하자 [다른 글에서 정리하도록 하겠습니다.]

하자 [다른 글에서 정리하도록 하겠습니다.] 많은 회사에서 Spring Data JPA를 사용하기 때문에 fetch join의 간편 버전인 @EntityGraph를 사용 하자.

from http://wangtak.tistory.com/7 by ccl(A) rewrite - 2021-12-02 10:27:22