본문 바로가기
Spring/Spring Data JPA

Spring Data JPA - N + 1 문제 ② 해결 방법

by WangTak 2021. 12. 2.
반응형

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

 

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

2021.11.29 - [Spring Data JPA] - Spring Data JPA - 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<Member> findAllFetchJoin();
}

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

 

MemberRepositoryCustomImpl

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    @PersistenceContext
    private EntityManager em;

    @Override
    public List<Member> 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<Member, Long>, MemberRepositoryCustom {
	// MemberRepositoryCustom 상속!
    
    @Query("select m from Member m join fetch m.team")
    List<Member> 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<Member> members = memberRepository.findAllFetchJoin();
        members.iterator().forEachRemaining(member -> {
            System.out.println("member.getTeam().getTeamName() = " + member.getTeam().getTeamName());
        });
    }
    
    @Test
    public void springDataJpaFetchJoinTest() {
        List<Member> 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<Member, Long>, MemberRepositoryCustom {

    @Query("select m from Member m join fetch m.team")
    List<Member> findAllQueryFetchJoin();

    // @EntityGraph 추가된 코드
    @Override
    @EntityGraph(attributePaths = "team")
    List<Member> findAll();

    @EntityGraph(attributePaths = "team")
    @Query("select m from Member m")
    List<Member> findQueryEntityGraphAll();
}
  • @EntityGraph는 findAll()과 같이 오버라이딩하여 이미 구현되어 있는 메서드와 조합할 수 있습니다.
  • 메소드 이름으로 쿼리를 생성하는 방식에서도 사용 가능합니다.
  • @Query 어노테이션을 사용한 JPQL과 혼합하여 사용할 수 있습니다.

 

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

 

Solution2Test[Spring Data JPA + @EntityGraph]

@SpringBootTest
public class Solution2Test {

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void dataJpaOverrideMethodEntityGraphTest() {
        List<Member> members = memberRepository.findAll();
        members.iterator().forEachRemaining(member -> {
            System.out.println("member.getTeam().getTeamName() = " + member.getTeam().getTeamName());
        });
    }

    @Test
    public void dataJpaEntityGraphWithQueryTest() {
        List<Member> 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를 사용하자.

 

 

반응형