QueryDSL 가이드 (3/8)

이전 편: [QueryDSL] 2. 기본 쿼리 작성

다음 편: [QueryDSL] 4. 동적 쿼리

실무에서 단일 테이블 조회만으로 끝나는 경우는 거의 없다. 대부분의 비즈니스 로직은 여러 테이블을 엮어서 데이터를 가져와야 한다. 이 편에서는 QueryDSL의 조인과 서브쿼리를 다룬다.

graph TD J[조인] --> IJ["join()
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()의 두 번째 인자

join(feed.user, user)에서 두 번째 userQUser.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()에 접근할 때 추가 쿼리가 발생하지 않는다.

sequenceDiagram participant App as Application participant DB as Database Note over App,DB: fetch join 없이 (N+1) App->>DB: SELECT * FROM feed loop 각 Feed마다 App->>DB: SELECT * FROM user WHERE id = ? end Note over App,DB: fetch join 사용 App->>DB: SELECT f.*, u.* FROM feed f JOIN user u ON ... Note right of App: 추가 쿼리 없음

leftJoin에도 fetch join을 적용할 수 있다.

List<User> users = queryFactory
        .selectFrom(user)
        .leftJoin(user.profile, profile).fetchJoin()
        .fetch();
fetch join의 제약

- 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의 의미가 사라진다.

graph TD subgraph "ON 절에 조건" O1[LEFT JOIN + ON 필터] --> O2[조인 시점에 필터링] O2 --> O3["Feed 없는 User도
결과에 포함"] 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 users = queryFactory

.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가 더 효율적이다. 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 절에 서브쿼리를 넣으면 스칼라 서브쿼리가 된다.

graph LR subgraph "서브쿼리 사용 가능 위치" W["WHERE 절 ✅"] S["SELECT 절 ✅"] F["FROM 절 ❌"] end F -.->|"JPQL 제한"| ALT["대안: 쿼리 분리
네이티브 쿼리
DB 뷰"] style F fill:#f96,stroke:#333 style ALT fill:#ff9,stroke:#333
JPA 서브쿼리의 제한

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")처럼 별도로 만들어야 한다.