지난 글에 이어서 N+1 문제 해결과정을 정리하겠습니다.
4. join과 fetch join
Member 오브젝트를 통해 Team 에 접근할 때 발생하는 N+1 문제를 해결하기 위해서는 Member 조회시 데이터베이스에서 관련 테이블을 join 하여 한번에 가져와야합니다.
이를 위해 Repository 레이어에 JPQL 을 사용하는 메서드를 하나 추가하겠습니다.
MemberRepository.java 일부
@Query("select m from Member m join m.team t")
List<Member> findAllJoin();
다음과 같은 테스트 코드도 작성하였습니다.
MemberRepositoryTest.java 일부
@Test
public void findAllJoinTest() {
List<Member> allJoin = memberRepository.findAllJoin();
System.out.println(allJoin.get(0).getTeam().getName());
}
테스트 코드 실행 결과 findAllJoin() 메서드가 실행되면서 아래와 같은 로그가 출력됩니다.
Hibernate:
select
m1_0.id,m1_0.name,m1_0.team_id
from
member m1_0
join
team t1_0
on t1_0.id=m1_0.team_id
Hibernate:
select
t1_0.id,t1_0.name
from
team t1_0
where
t1_0.id=?
Team A
정상적으로 team 테이블이 join이 되었지만 team id를 제외한 모든 컬럼이 select 절에서 제외되었습니다.
그렇기 때문에 team의 name 프로퍼티에 접근하게되면 쿼리가 한 번 더 발생합니다.
위를 통해 알수있는 정보는 다음과 같습니다.
- 일반적인 join 방식으로 JPQL을 작성하면, 연관된 엔티티의 정보를 select 하지 않는다.
- 연관된 엔티티의 프로퍼티에 접근하게되면 역시나 N+1문제가 발생한다.
위와 같은 현상을 해결하기위해 연관된 엔티티의 정보까지 모두 조회하는 fetch join 을 활용하여 N+1문제를 해결할 수 있습니다.
JPQL 수정
select m from Member m join fetch m.team t
동일한 테스트코드 실행 결과 출력된 쿼리로그는 아래와 같습니다.
Hibernate:
select
m1_0.id,
m1_0.name,
t1_0.id,
t1_0.name
from
member m1_0
join
team t1_0
on t1_0.id=m1_0.team_id
Team A
어노테이션 선언하여 메서드 단위로 동일한 기능을 구현할 수 있습니다.
Repository 레이어에서 SpringDataJpa가 제공하는 findAll() 메서드를 오버라이드하여 재정의 하도록 합니다.
MemberRepository.java 일부
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
@EntityGraph 어노테이션 선언형방식은 Member 에 정의된 로딩전략보다 높은 우선순위로 동작합니다.
실제 쿼리로그는 아래와 같습니다.
Hibernate:
select
m1_0.id,m1_0.name,t1_0.id,t1_0.name
from
member m1_0
left join
team t1_0
on t1_0.id=m1_0.team_id
5. 실무에 적용한 방식 - native query
fetch join은 모든 문제를 해결하는 것 같지만 실무와 같이 단순하지 않은 테이블들로 사용하기엔 고려해야할 사항들이 많습니다.
대부분의 경우 애플리케이션 레벨에서 쿼리 빌더를 사용하여 동적 쿼리 방식으로 해결합니다. 다만 저의 경우 native query 작성 방식으로 해결하였습니다. 이유는 다음과 같습니다.
- 비즈니스 요구사항이 독립적이며 재사용성이 떨어진다. 요구사항에 특수성이 있으므로 고립화하여 처리한다.
- DB 기종이 변할 일이 (거의)없다.
- JPQL, QueryDSL 보다 ANSI SQL에 훨씬 더 익숙했다.
native query 방식으로 데이터를 조회하게되면, 실제 개발자가 작성한 문자열이 그대로 실행됩니다. 따라서 SQL 방언을 사용하여 쿼리를 작성하게 되면 구성 환경에 따라 에러가 발생할 수 있습니다. 또한 entity로 맵핑하는 것이 아니기 때문에, 아래와 같이 쿼리결과 맵핑을 위한 별도의 interface를 정의해야합니다.
NativeQueryInterface.java
package com.npo.npo.repository.interfaces;
public interface NativeQueryInterface {
Long getMemberId();
String getMemberName();
Long getTeamId();
String getTeamName();
}
repository의 일부에 다음과 같은 native sql문을 추가합니다.
interface에서 get메소드를 통해 각 멤버변수들을 맵핑하므로 alias 를 interface의 메소드명과 일치시켜줘야 제대로 동작합니다.
굉장히 주의해야할 점은 잘못 맵핑시킨다고 해도 get메소드는 null을 반환하기 때문에 런타임 환경에서 버그가 발견될 가능성이 많다는 점입니다. 또한 가독성도 좋지 않습니다.
@Query(value = "select " +
"m.id as memberId," +
"m.name as memberName," +
"t.id as teamId," +
"t.name as teamName " +
"from Member m left join Team t on m.team_id = t.id", nativeQuery = true)
List<NativeQueryInterface> findAllNative();
아래와 같이 테스트코드를 추가하였습니다.
MemberRepositoryTest.java 일부
@Test
public void findAllNativeTest() {
List<NativeQueryInterface> allNative = memberRepository.findAllNative();
allNative
.forEach(result ->
System.out.println(result.getMemberId() +" "+
result.getMemberName()+" "+
result.getTeamId()+" "+
result.getTeamName()
)
);
}
결과는 다음과 같습니다. 직접 작성한 sql이 실행되었고, 의도된대로 잘 출력되었습니다.
Hibernate: select m.id as memberId,m.name as memberName,t.id as teamId,t.name as teamName from Member m left join Team t on m.team_id = t.id
1 김 1 Team A
2 이 1 Team A
3 박 1 Team A
4 최 1 Team A
5 정 1 Team A
6 강 2 Team B
7 조 1 Team A
8 윤 1 Team A
9 장 3 Team C
10 임 3 Team C
6. 기타 및 회고
JPA는 추상화된 데이터베이스 접근 기술로 사용자에게 편리함을 제공하지만, 내부 동작 방식을 정확히 알고 사용하지 않으면 예상대로 동작하지 않을 위험이 있습니다.
fetch join의 한계 중 하나를 예로, 컬렉션을 fetch join할 때 페이지네이션에서 예상치 못한 오류가 발생할 수 있습니다. 일대다 관계에서는 실제 쿼리가 수행되어야 결과 레코드 수를 알 수 있기 때문에 JPA는 모든 결과 집합을 메모리에 로드한 뒤 페이징 처리합니다.
이러한 위험때문에 JPA를 활용한 개발은 명확히 이해하고 검증된 후 사용해야할 것 같다는 생각이 들었습니다.