지난 글에 이어서 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 보다 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를 활용한 개발은 명확히 이해하고 검증된 후 사용해야할 것 같다는 생각이 들었습니다.
애플리케이션의 로직에 따라 동적인 sql을 생성해야하는 경우, native sql의 단점이 더 부각되는 것 같습니다.
이후에는 QueryDSL 과 같은 동적 sql을 작성하는 것을 고려해볼 것 같습니다.