개발 일지

QueryDSL fetchJoin 사용 시 주의사항

계란💕 2025. 8. 18. 14:39

fetchJoin 이란?

QueryDSL 에서의 fetchJoin은 연관된 엔티티를 한 번의 쿼리로 즉시 로딩하고, 영속성 컨텍스트에 함께 적재하는 기능이다.
즉, N+1 문제를 해결할 때 자주 사용된다.

  • join : 단순히 SQL 조인만 수행 (Lazy 로딩될 수 있음)
  • fetchJoin : 조인 + 즉시 로딩(Eager Loading), 영속성 컨텍스트에 연관 엔티티까지 채워 넣음

 

Eager 로딩과 fetchJoin의  차이

// Team 클래스
@OneToMany(fetch = FetchType.EAGER)
List<Member> memberList = new ArrayList<>();
  • Eager: 쿼리를 항상 즉시 로딩 → 쿼리 제어 불가.
    • fetch type = "Eager_loading" :  항상 eager 로딩으로 고정한다. 
  • fetchJoin: 필요할 때만 JPQL로 즉시 로딩 → 쿼리 최적화 가능.

 

 

문제 상황

Team ↔ Member 관계에서 N+1 문제가 발생했다.
예를 들어 Team 목록을 조회하면, 각 Team의 members를 가져오기 위해 추가 쿼리가 팀 개수만큼 발생하는 상황이다.

이를 해결하기 위해 fetchJoin을 적용했다. 이 과정에서 fetchjoin 을 적절하게 사용하기 위한 몇 가지 조건을 알게 되었다. 

 

 

주의 사항

1. FetchJoin 을 쓸 때 SELECT 에는 반드시 기준 테이블(엔티티 클래스)이 들어가야한다. (DTO 사용 불가능)

 

Ex) 잘못된 예시

query.select(team)
     .from(member)
     .leftJoin(member.team, team).fetchJoin();

 

<hide/>
org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmSingularJoin(kr.co.platform.mgmt_api.feature.entity.MemberEntity(MemberEntity).team(TeamEntity) : teamList)] at

Hibernate 6 - SemanticException 오류 발생. 의미적으로 매끄럽지 않아서 오류 발생.  

fetch의 주인은 반드시 select 절에 있어야한다. fetchJoin 쿼리에 엔티티가 아닌 다른 클래스를 넣는 경우에 문제가 생긴다.

fetch는 반드시 그 연관 관계를 소유한 엔티티의 결과로 반환할 때만 허용되기 때문이다.

fetchJoin은 영속성 컨텍스트에 엔티티를 넣기 위한 기능 → 반드시 엔티티 기준으로 select 해야 한다.

Hibernate 6에서는 DTO projection과 같이 엔티티가 없는 경우 SemanticException이 발생한다.

👉 즉, fetchJoin을 사용할 때는 반드시 엔티티 기준으로 쿼리를 작성해야 한다.

 

 

참고 

https://discourse.hibernate.org/t/problems-with-join-fetch-using-criteria-api/10797?utm_source=chatgpt.com

 

Ex) 잘못된 예시

query.select(Projections.constructor(MemberDTO.class, member.id, team.name))
     .from(member)
     .join(member.team, team).fetchJoin();

- fetchJoin() 의 결과를 projection 할 땐 반드시 Entity 클래스를 이용해서 받아와야한다. 

- DTO 클래스를 쓸 경우 오류발생.

- fetchJoin는 엔티티 반환 전용이기 때문이다.

DTO를 써야한다면 leftJoin() 으로 바꿔야한다. (그럼 다시 n + 1 문제가 발생되므로 문제가 원점으로 돌아간다. )

 - 따라서 fetchJOin 써서 n + 1 문제 해결 후  code 조회하는 select 쿼리를 따로 날려서 둘을 조합했다. 

DTO를 select 절에 두면 영속성 컨텍스트에 담을 엔티티가 없어 오류가 발생.

 

 

2.  단방향은 연관관계의 주인만 join의 기준 테이블이 될 수 있다. 

예를 들어, Member 에는 @ManyToOne Team 에 대한 필드가 있고  Team 에는 Member  정보가 없는 단방향 매핑의 경우를 보자. 

이 때 Team 에 대한 Q 클래스인 QTeam 에는 Member 목록에 대한 필드가 없다. 
따라서 연관 관계의 주인(FK 에 대한 정보를 가진 엔티티)을 중심으로 하여금 join 에 대한 기준 테이블이 될 수 있다는 뜻이다. 

<hide/>
QMember member = QMember.member;
QTeam team = QTeam.team;

// Member → Team (연관관계 주인인 Member 기준으로만 조인 가능)
List<Tuple> result = query
    .select(member, team)
    .from(member)
    .leftJoin(member.team, team) // ✅ 가능
    .fetch();
    
// Team → Member (Team에는 Member 컬렉션이 없으므로 단방향 매핑에서는 불가
List<Tuple> result = query
    .select(member, team)
    .from(team)
    .leftJoin(team.members, member) // ❌ 경로 없음 → 컴파일 에러

 

 

그럼 만약, 보조 테이블의 어떤 필드를 기준으로 Group By 하고 싶은 경우는 어떻게 해야할까?

보조 테이블의 칼럼으로 GROUP BY 시도하는 경우의 오류

org.hibernate.query.PathException: Could not resolve join path 'memberEntity.team'
java.lang.IllegalArgumentException: org.hibernate.query.PathException: Could not resolve join path 'memberEntity.team'

 

 

Group by  안에 department.team.member 를 넣으면 invalidPathException 이 발생하는 걸 확인할 수 있다. 
따라서, 기준 테이블로 모든 데이터를 가져온 다음, Java에서 원하는 데이터를 정제해야한다. 

다음과 같은 방식으로 데이터를 변환했다. 

<hide/>
1. 쿼리 실행 결과를 Map에 담아주기.
<hide/>
Map<Department, Map<Team, List<Member>>> result =
            memberList.stream()
                    .collect(Collectors.groupingBy(
                            t -> t.getTeam().getDepartment(), // 1차: Department 기준
                            Collectors.groupingBy(
                                    t -> t.getTeam(), // 2차: Team 기준
                                    Collectors.mapping(
                                            t -> t, // 3차: Member 기준
                                            Collectors.toList()
                                    )
                            )
                    ));

2.순환참조 오류 방지 -  "일" 쪽에서는  List 세팅 후, "다" 쪽에서는  null 처리
for(Map.Entry<Department, Map<Team, List<Member>>> entry : result.entrySet()) {
    Department department = entry.getKey();
    Map<Team, List<Member>> teamMap = entry.getValue();

    for (Map.Entry<Team, List<Member>> teamEntry : teamMap.entrySet()) {
        Team team = teamEntry.getKey();
        List<Member> memberList = teamEntry.getValue();
        for (Member member : memberList) {
            member.setTeam(null);
        }
        team.setMemberList(memberList);
        team.setDepartment(null);
    }
    List<Team> teamList = teamMap.entrySet().stream()
            .peek(e -> e.getKey().setMemberList(e.getValue()))
            .map(Map.Entry::getKey)
            .collect(Collectors.toList());
    department.setTeamList(teamList);
}
- 양방향 참조를 막기 위한 null 세팅, list 값 세팅.

- null 세팅하지 않으면 순환 참조로 인한 문제가 발생한다.

 

 

실무적 대안

  • fetchJoin으로 N+1 문제를 해결하고, 서비스 레이어에서 DTO 변환.
  • 단순 코드 값 같은 경우에는 별도 쿼리 분리 (예: 코드 테이블 조회).
  • 또는 @BatchSize, EntityGraph 등 Hibernate/JPA 옵션을 활용해 쿼리 최적화 가능.

 

결론

  • fetchJoin은 엔티티 반환 전용이다.
  • select 절에 DTO를 넣으면 오류가 발생한다.
  • 따라서 fetchJoin으로 연관 엔티티를 한 번에 가져온 뒤, 필요한 경우 DTO 변환을 따로 하거나 보조 쿼리로 조합하는 것이 가장 안전한 방법이다.