01 기본
1. 환경 설정과 Q클래스
2. 기본 쿼리 작성
3. 조인과 서브쿼리 ← 현재 편
02 실전
4. 동적 쿼리
5. 프로젝션과 DTO 매핑
03 심화
7. 성능 최적화
8. 실전 패턴과 면접 대비
실무에서 단일 테이블 조회만으로 끝나는 경우는 거의 없다. 대부분의 비즈니스 로직은 여러 테이블을 엮어서 데이터를 가져와야 한다. 이 편에서는 QueryDSL의 조인과 서브쿼리를 다룬다.
INNER JOIN"] J --> LJ["leftJoin()
LEFT JOIN"] J --> FJ[".fetchJoin()
FETCH JOIN"] S[서브쿼리] --> SW["WHERE 절
JPAExpressions"] S --> SS["SELECT 절
스칼라 서브쿼리"] S --> SE["EXISTS
존재 여부"]
기본 조인
QueryDSL의 조인은 JPA 연관관계를 기반으로 동작한다. 엔티티에 @ManyToOne, @OneToMany 같은 연관관계가 정의되어 있으면, Q클래스의 연관 필드를 통해 조인할 수 있다.
List<Feed> feeds = queryFactory
.selectFrom(feed)
.join(feed.user, user)
.where(user.name.eq("kim"))
.fetch();
join(feed.user, user)는 Feed 엔티티의 user 필드를 기준으로 User 테이블과 INNER JOIN한다. 두 번째 인자 user는 조인 대상의 별칭이다. SQL로 변환하면 SELECT f.* FROM feed f INNER JOIN user u ON f.user_id = u.id WHERE u.name = 'kim'이 된다.
join(feed.user, user)에서 두 번째 user는 QUser.user를 static import한 것이다. 이 별칭을 통해 조인된 테이블의 컬럼에 접근한다. 생략하면 feed.user.name처럼 경로 탐색으로 접근해야 하는데, 이 경우 QueryDSL이 내부적으로 크로스 조인을 생성할 수 있다.
left join
INNER JOIN은 양쪽 테이블에 매칭되는 행만 반환한다. 매칭되지 않는 행도 포함하려면 LEFT JOIN을 사용한다.
List<User> users = queryFactory
.selectFrom(user)
.leftJoin(user.feeds, feed)
.where(feed.title.contains("spring").or(feed.isNull()))
.fetch();
leftJoin(user.feeds, feed)는 User 기준으로 Feed를 LEFT JOIN한다. Feed가 없는 User도 결과에 포함된다.
fetch join
JPA의 지연 로딩은 연관 엔티티에 접근할 때마다 추가 쿼리를 발생시킨다. 이것이 N+1 문제다. User 10명을 조회한 뒤, 각 User의 프로필에 접근하면 프로필을 가져오는 쿼리가 10번 더 실행된다. fetch join은 이 문제를 한 번의 쿼리로 해결한다.
List<Feed> feeds = queryFactory
.selectFrom(feed)
.join(feed.user, user).fetchJoin()
.fetch();
.fetchJoin()을 붙이면 조인된 엔티티를 한 번에 함께 가져온다. SQL에서 JOIN FETCH에 해당한다. 이렇게 하면 feed.getUser()에 접근할 때 추가 쿼리가 발생하지 않는다.
leftJoin에도 fetch join을 적용할 수 있다.
List<User> users = queryFactory
.selectFrom(user)
.leftJoin(user.profile, profile).fetchJoin()
.fetch();
- fetch join에는 별칭을 통한 where 필터링을 하면 안 된다. JPA 스펙상 fetch join된 컬렉션을 필터링하면 영속성 컨텍스트와 실제 데이터가 불일치할 수 있다.
- @OneToMany 컬렉션을 fetch join하면서 페이징(offset/limit)을 사용하면, 하이버네이트가 메모리에서 페이징을 수행한다. 데이터가 많으면 OutOfMemoryError가 발생할 수 있다.
- 둘 이상의 컬렉션을 동시에 fetch join할 수 없다. MultipleBagFetchException이 발생한다.
on 절
조인 조건을 추가하려면 on()을 사용한다. INNER JOIN에서는 where와 결과가 같지만, LEFT JOIN에서는 의미가 다르다.
// LEFT JOIN + ON: Feed가 없거나, 공개된 Feed만 조인
List<User> users = queryFactory
.selectFrom(user)
.leftJoin(user.feeds, feed)
.on(feed.isPublic.isTrue())
.fetch();
LEFT JOIN에서 on에 조건을 넣으면 조인 자체에 필터가 걸린다. where에 넣으면 조인 후 전체 결과에서 필터링하므로, LEFT JOIN의 의미가 사라진다.
결과에 포함"] end subgraph "WHERE 절에 조건" W1[LEFT JOIN + WHERE 필터] --> W2[조인 후 전체 필터링] W2 --> W3["Feed 없는 User는
결과에서 제외"] end style O3 fill:#9f9,stroke:#333 style W3 fill:#f96,stroke:#333
이 차이는 LEFT JOIN을 쓸 때 반드시 이해해야 하는 핵심이다.
세타 조인
연관관계가 없는 테이블끼리 조인하는 것을 세타 조인이라고 한다. from에 엔티티를 여러 개 나열하면 크로스 조인이 생성되고, where로 조인 조건을 지정한다.
List<Tuple> result = queryFactory
.select(user, feed)
.from(user, feed)
.where(user.name.eq(feed.authorName))
.fetch();
이 방식은 @ManyToOne 같은 연관관계가 엔티티에 정의되어 있지 않을 때 사용한다. 다만 크로스 조인은 성능에 주의해야 한다.
서브쿼리
QueryDSL에서 서브쿼리는 JPAExpressions로 작성한다. WHERE 절과 SELECT 절에서 사용할 수 있다.
WHERE 절 서브쿼리
List<User> users = queryFactory
.selectFrom(user)
.where(user.age.gt(
JPAExpressions
.select(user.age.avg())
.from(user)
))
.fetch();
평균 나이보다 많은 사용자를 조회하는 쿼리다. 서브쿼리 안에서도 Q클래스를 사용할 수 있다.
서브쿼리에서 메인 쿼리와 같은 테이블을 참조할 때는 별칭이 충돌하지 않도록 새로운 Q인스턴스를 만들어야 한다.
```java
QUser subUser = new QUser("subUser");
List
.selectFrom(user)
.where(user.age.gt(
JPAExpressions
.select(subUser.age.avg())
.from(subUser)
))
.fetch();
```
IN 서브쿼리
List<Feed> feeds = queryFactory
.selectFrom(feed)
.where(feed.user.id.in(
JPAExpressions
.select(user.id)
.from(user)
.where(user.role.eq(Role.ADMIN))
))
.fetch();
관리자가 작성한 피드만 조회한다. in() 안에 서브쿼리를 넣으면 IN (SELECT ...) 구문이 생성된다.
EXISTS 서브쿼리
List<User> users = queryFactory
.selectFrom(user)
.where(JPAExpressions
.selectOne()
.from(feed)
.where(feed.user.id.eq(user.id))
.exists()
)
.fetch();
피드를 하나라도 작성한 사용자를 조회한다. selectOne()은 SELECT 1에 해당하고, .exists()로 존재 여부를 확인한다.
서브쿼리 결과 집합이 클 때는 IN보다 EXISTS가 더 효율적이다. IN은 서브쿼리 결과를 모두 메모리에 올린 뒤 비교하지만, EXISTS는 첫 번째 매칭 행을 찾으면 즉시 멈춘다.
SELECT 절 서브쿼리
List<Tuple> result = queryFactory
.select(
user.name,
JPAExpressions
.select(feed.count())
.from(feed)
.where(feed.user.id.eq(user.id))
)
.from(user)
.fetch();
각 사용자의 이름과 피드 수를 함께 조회한다. SELECT 절에 서브쿼리를 넣으면 스칼라 서브쿼리가 된다.
네이티브 쿼리
DB 뷰"] style F fill:#f96,stroke:#333 style ALT fill:#ff9,stroke:#333
JPQL은 FROM 절 서브쿼리를 지원하지 않는다. QueryDSL도 JPQL 위에서 동작하므로 같은 제약을 받는다. FROM 절 서브쿼리가 필요하면 네이티브 쿼리를 사용하거나, 쿼리를 분리하거나, DB 뷰를 활용해야 한다.
자주 하는 실수
feed.user.name.eq("kim")처럼 연관 엔티티의 필드에 직접 접근하면, QueryDSL이 내부적으로 크로스 조인을 생성할 수 있다. 명시적으로 join(feed.user, user)을 선언하고 user.name.eq("kim")으로 접근하는 것이 안전하다.
[!DANGER] fetch join + 페이징 조합
컬렉션 fetch join에 offset/limit을 걸면 하이버네이트가 경고 로그를 남기면서 메모리에서 페이징을 수행한다. 데이터가 많으면 장애로 이어질 수 있다. 컬렉션 조회가 필요하면 @BatchSize 또는 별도 쿼리로 분리하는 것이 낫다.
[!DANGER] 서브쿼리에서 별칭 충돌
메인 쿼리와 서브쿼리가 같은 테이블을 참조할 때, 같은 Q인스턴스를 쓰면 별칭이 충돌하여 잘못된 SQL이 생성된다. 서브쿼리용 Q인스턴스를 new QUser("subUser")처럼 별도로 만들어야 한다.