안녕하세요, 오늘은 이전에 알아보았던 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 쿼리 & 결과
springDataJpaFetchJoinTest 쿼리 & 결과
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 쿼리 & 결과
dataJpaEntityGraphWithQueryTest 쿼리 & 결과
@EntityGraph는 조인 방식이 left outer join입니다.
정리
지금까지 JPA를 사용하면서 쉽게 만날 수 있는 N + 1문제에 대해 ①, ②편으로 정리해봤습니다. (양이 좀 많네요.. ㅠ)
- N + 1은 무조건 해결해야 하는 문제다.
- 해결 방법 + 최적화는 fetch join을 적극 사용하자.
- 다만 일대다 관계에서 fetch join을 사용할 때 데이터 뻥튀기가 되는 것은 유의하자 [다른 글에서 정리하도록 하겠습니다.]
- 많은 회사에서 Spring Data JPA를 사용하기 때문에 fetch join의 간편 버전인 @EntityGraph를 사용하자.
'Spring > Spring Data JPA' 카테고리의 다른 글
Spring Data JPA - 상속(Inheritance) + 다대다(N:M) 관계 (0) | 2022.01.09 |
---|---|
Spring Data JPA - Self-Reference (0) | 2021.12.20 |
Spring Data JPA - N + 1 문제 ① (0) | 2021.11.29 |
Spring Data JPA - Auditing (0) | 2021.11.28 |
Spring Data JPA - Save Method 동작 방식 (0) | 2021.11.26 |