queryDSL 로 쿼리를 짜다보면 다음과 같은 에러를 마주할 때가 많다

 

Caused by: javax.persistence.PersistenceException: org.hibernate.exception.SQLGrammarException: could not extract ResultSet at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:154)

 

 

group by와 fetch join을 쓰면 이런 에러가 생기곤 하는데 원인과 이 둘을 같이 쓰면 안되는 이유와 해결법을 알아보자

 

 

 

fetch join의 동작 원리

 

먼저 fetch join은 n+1을 방지하기 위해 많이 쓰며, 쿼리 내에서 연관된 엔티티를 한번의 쿼리로 함께 가져온다.

추후 객체를 LAZY로 접근하더라도 추가 쿼리가 나가지 않도록 해준다. 그렇다면 이걸 쓰면 실제로 SQL쿼리는 어떻게 나갈까?

 

 

leftjoin만 썻을 때

List<Debate> debates = queryFactory
    .selectFrom(debate)
    .leftJoin(debate.user)
    .fetch();

// 실행되는 SQL
SELECT d.* 
FROM debate d 
LEFT JOIN user u ON d.user_id = u.user_id;

 

 

 

fetch join 사용 시

// debate와 함께 user 정보도 한 번에 가져옴
List<Debate> debates = queryFactory
    .selectFrom(debate)
    .leftJoin(debate.user).fetchJoin()
    .fetch();
    

// 실행되는 SQL
SELECT d.*, u.*  -- user 정보도 함께!
FROM debate d 
LEFT JOIN user u ON d.user_id = u.user_id;

 

 

JPA가 User 엔티티를 완전히 채워야 하기 때문에 user의 모든 컬럼을 select에 올려버린다.

 

따라서 쿼리 내에서 조인해서 조건검색을 하는 용도로는 leftjoin

결과를 가져온 후에 해당 테이블의 내용을 조회할 일이 있을 떄에는 fetchjoin을 사용해야 한다.

 

 

이때 group by를 사용한다면 어떻게 될까? 

sql문법 규칙 상 GROUP BY를 사용할 때는 집계함수가 아닌 모든 SELECT 컬럼이 GROUP BY 절에 들어가야 한다.

 

 

 

 

-- fetchjoin 없을 떄

SELECT d.seq, d.title, d.user_id
FROM debate d 
LEFT JOIN user u ON d.user_id = u.user_id
GROUP BY d.seq, d.title, d.user_id;


-- user fetchjoin 했을 떄

SELECT 
    d.seq, d.title, d.content, d.user_id,           -- debate 컬럼
    u.id, u.name, u.username, u.email, u.phone     -- user 컬럼
FROM debate d 
LEFT JOIN user u ON d.user_id = u.user_id
GROUP BY d.seq, d.title, d.user_id;  -- 오류! 나머지 user 관련 컬럼도 GROUP BY에 들어가야 함

 

 

이걸 해결하려면 User의 모든 컬럼을 group by 해야 되는데,
user별 집계(count)가 아니라 user row별로 쪼개진 group이 나오기 때문에 group by가 무의미해진다.

 

 

 

 

이걸 해결하려면 어떻게 해야할까? 

다음 예시로 해결법을 찾아보자

 

 

"사용자별 토론 개수를 조회하면서, 각 사용자 정보도 fetchJoin으로 가져오고 싶어"

queryFactory
    .select(debate.user, debate.count())
    .from(debate)
    .leftJoin(debate.user).fetchJoin()  
    .groupBy(debate.user)
    .fetch();


-- 생성되려는 SQL
SELECT u.*, COUNT(*)
FROM debate d
LEFT JOIN user u ON d.user_id = u.id
GROUP BY u.id;  -- ERROR! u.name, u.email 등도 GROUP BY에 들어가야 함

 

 

 

 

해결법 1)  2단계 쿼리로 분리

1단계: 적은 컬럼으로 조회 + 페이징 + GROUP BY

2단계: 필요한 ID들로 fetchJoin() 사용해서 모든 데이터 조회

 

 

 

이걸 2단계 쿼리로 분리해보자

// 1단계: 집계 데이터만 조회
List<Tuple> aggregateResults = queryFactory
    .select(debate.user.seq, debate.count())
    .from(debate)
    .groupBy(debate.user.seq)
    .fetch();

List<Long> userIds = aggregateResults.stream()
    .map(tuple -> tuple.get(debate.user.seq))
    .collect(Collectors.toList());
    
    

// 2단계: User 엔티티 fetchJoin으로 조회
List<User> users = queryFactory
    .selectFrom(user)
    .where(user.seq.in(userIds))
    .fetch();

// 3단계: 조합
Map<Long, Long> countsByUserId = aggregateResults.stream()
    .collect(Collectors.toMap(
        tuple -> tuple.get(debate.user.seq),
        tuple -> tuple.get(debate.count())
    ));

List<UserDebateStatsDto> results = users.stream()
    .map(user -> new UserDebateStatsDto(
        user, 
        countsByUserId.get(user.getSeq())
    ))
    .collect(Collectors.toList());

 

주의할점은 결과셋이 수십만 이상이면 2단계 in 절 방식 자체가 힘들어지니,

한 방 projection 쿼리(DTO 매핑)(해결법2)이나 배치(해결법3)을 사용해야 한다.

그러나 일반 서비스 트래픽에서 유저 단위 집계라면 위와 같이 메모리에서 조합해도 성능적으로 괜찮다.

 

 

 

 

해결법 2 ) DTO projection

List<UserDebateStatsDto> results = queryFactory
    .select(Projections.constructor(UserDebateStatsDto.class,
        user,
        debate.count()
    ))
    .from(debate)
    .join(debate.user, user)
    .groupBy(user.id)
    .fetch();

이러면 쿼리 한 방으로 [User, count] 튜플이 바로 DTO에 담김
다만 이 경우 User는 영속성 컨텍스트에 안 들어오고 DTO 전용 데이터라는 점만 주의.

 

 

 

 

해결법 3) batch fetch size 설정

그냥 fetchjoin 하지 않고, in절로 묶어서 가져온다.

다만 이경우에는 완벽하게 n+1을 방지하지는 못하고 DTO 변환 시점에 추가 쿼리가 발생한다.

쿼리 개수= 컬렉션 수 × (총 건수 ÷ batch size)

 

 

 

 

복사했습니다!