본문 바로가기

경험과 지식

JPA N+1문제와 해결방안(2)

지난 글에 이어서 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 프로퍼티에 접근하게되면 쿼리가 한 번 더 발생합니다.

 

위를 통해 알수있는 정보는 다음과 같습니다.

  1. 일반적인 join 방식으로 JPQL을 작성하면, 연관된 엔티티의 정보를 select 하지 않는다.
  2. 연관된 엔티티의 프로퍼티에 접근하게되면 역시나 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을 작성하는 것을 고려해볼 것 같습니다.