올바른 쿼리를 작성하는 것만큼, 효율적인 쿼리를 작성하는 것도 중요하다. 이 편에서는 QueryDSL 사용 시 알아야 할 성능 최적화 기법을 다룬다.

graph TD P[성능 최적화 기법] --> F["fetch join
N+1 해결"] P --> E["exists 최적화
count 대신 fetchFirst"] P --> B["벌크 연산
대량 수정/삭제"] P --> CI["커버링 인덱스
offset 비용 절감"] P --> CQ["count 쿼리 분리
불필요한 join 제거"]

fetch join으로 N+1 해결

N+1 문제는 JPA에서 가장 흔한 성능 문제다. 연관 엔티티를 지연 로딩으로 설정했을 때, 리스트를 조회한 뒤 각 항목의 연관 엔티티에 접근하면 건당 하나씩 추가 쿼리가 발생한다.

// N+1 발생 코드
List<Feed> feeds = queryFactory
        .selectFrom(feed)
        .fetch();

// 각 feed.getUser().getName() 접근 시마다 SELECT 쿼리 발생
for (Feed f : feeds) {
    f.getUser().getName();  // N번의 추가 쿼리
}

fetch join으로 해결한다.

// 해결: fetch join
List<Feed> feeds = queryFactory
        .selectFrom(feed)
        .join(feed.user, user).fetchJoin()
        .fetch();

이렇게 하면 Feed와 User를 한 번의 쿼리로 함께 가져온다. feed.getUser()에 접근해도 추가 쿼리가 발생하지 않는다.

여러 연관 엔티티를 함께 로딩

@ManyToOne, @OneToOne 관계는 여러 개를 동시에 fetch join할 수 있다.

List<Feed> feeds = queryFactory
        .selectFrom(feed)
        .join(feed.user, user).fetchJoin()
        .join(feed.weather, weather).fetchJoin()
        .fetch();

다만 @OneToMany 컬렉션 fetch join은 하나만 가능하다. 둘 이상의 컬렉션을 동시에 fetch join하면 MultipleBagFetchException이 발생한다.

컬렉션 N+1 해결 대안

컬렉션이 여러 개일 때는 @BatchSize를 사용한다. 엔티티 클래스나 application.yml에 설정하면, 지연 로딩 시 한 번에 여러 엔티티의 컬렉션을 IN 쿼리로 묶어서 가져온다. fetch join처럼 한 방 쿼리는 아니지만, N+1을 크게 줄여준다.

```yaml

spring:

jpa:

properties:

hibernate:

default_batch_fetch_size: 100

```

exists 최적화

특정 조건의 데이터가 존재하는지 확인할 때, count를 쓰는 것은 비효율적이다.

// 비효율: 전체 행을 세고 0보다 큰지 비교
boolean exists = queryFactory
        .select(feed.count())
        .from(feed)
        .where(feed.user.id.eq(userId))
        .fetchOne() > 0;
graph LR subgraph "count > 0 (비효율)" C1[전체 행 스캔] --> C2[행 수 카운트] --> C3["0보다 큰지 비교"] end subgraph "fetchFirst != null (효율)" F1[첫 행 발견] --> F2[즉시 반환] end style F1 fill:#9f9,stroke:#333 style F2 fill:#9f9,stroke:#333

count는 조건에 맞는 모든 행을 세야 하므로 데이터가 많을수록 느려진다. 존재 여부만 알면 되는데 전부 세는 것은 낭비다.

// 효율: 첫 번째 행만 찾으면 즉시 반환
boolean exists = queryFactory
        .selectOne()
        .from(feed)
        .where(feed.user.id.eq(userId))
        .fetchFirst() != null;

fetchFirst()는 내부적으로 LIMIT 1을 걸어서, 조건에 맞는 행이 하나라도 발견되면 즉시 반환한다. 데이터 양에 관계없이 빠르다.

벌크 연산

대량의 데이터를 수정하거나 삭제할 때, 엔티티를 하나씩 조회해서 변경하면 건당 UPDATE 쿼리가 발생한다. 벌크 연산은 하나의 쿼리로 여러 행을 한꺼번에 처리한다.

벌크 UPDATE

long updatedCount = queryFactory
        .update(user)
        .set(user.role, Role.INACTIVE)
        .where(user.lastLoginAt.before(Instant.now().minus(Duration.ofDays(365))))
        .execute();

1년간 로그인하지 않은 사용자의 역할을 일괄 변경한다. execute()가 실행한 뒤 영향받은 행 수를 반환한다.

여러 필드를 동시에 수정할 수도 있다.

long updatedCount = queryFactory
        .update(user)
        .set(user.role, Role.INACTIVE)
        .set(user.deactivatedAt, Instant.now())
        .where(user.lastLoginAt.before(cutoffDate))
        .execute();

벌크 DELETE

long deletedCount = queryFactory
        .delete(feed)
        .where(feed.createdAt.before(Instant.now().minus(Duration.ofDays(90))))
        .execute();

90일이 지난 피드를 일괄 삭제한다.

영속성 컨텍스트 주의

벌크 연산은 영속성 컨텍스트를 거치지 않고 DB에 직접 쿼리를 실행한다. 그래서 벌크 연산 후 영속성 컨텍스트에 캐시된 엔티티는 여전히 수정 전 상태를 가지고 있다.

// 1. 벌크 업데이트 실행
queryFactory
        .update(user)
        .set(user.role, Role.INACTIVE)
        .where(user.id.eq(userId))
        .execute();

// 2. 영속성 컨텍스트 초기화
entityManager.flush();
entityManager.clear();

// 3. 다시 조회하면 DB의 최신 상태가 반영됨
User refreshed = userRepository.findById(userId).orElseThrow();
sequenceDiagram participant App as Application participant PC as 영속성 컨텍스트 participant DB as Database App->>DB: 벌크 UPDATE 실행 Note over PC: 캐시는 수정 전 상태 App->>PC: flush() App->>PC: clear() Note over PC: 캐시 비움 App->>DB: findById() 조회 DB-->>PC: 최신 데이터 반환 PC-->>App: 수정된 엔티티

flush()로 대기 중인 변경사항을 DB에 반영하고, clear()로 영속성 컨텍스트를 비운다. 이후 조회하면 DB의 최신 상태를 가져온다.

벌크 연산 후 clear() 필수

벌크 연산 후 clear()를 하지 않으면, 같은 트랜잭션 내에서 조회할 때 DB가 아닌 영속성 컨텍스트의 캐시(1차 캐시)에서 데이터를 가져온다. 이 데이터는 벌크 연산 이전의 값이므로, 데이터 불일치가 발생한다.

커버링 인덱스 활용

커버링 인덱스란, 쿼리에 필요한 모든 컬럼이 인덱스에 포함되어 있어서 테이블 접근 없이 인덱스만으로 결과를 반환할 수 있는 것이다.

// 1단계: 커버링 인덱스로 ID만 조회
List<Long> ids = queryFactory
        .select(feed.id)
        .from(feed)
        .where(feed.region.eq("서울"))
        .orderBy(feed.createdAt.desc())
        .offset(1000)
        .limit(10)
        .fetch();

// 2단계: ID로 엔티티 조회 (fetch join 포함)
List<Feed> feeds = queryFactory
        .selectFrom(feed)
        .join(feed.user, user).fetchJoin()
        .where(feed.id.in(ids))
        .orderBy(feed.createdAt.desc())
        .fetch();
graph LR subgraph "1단계: 커버링 인덱스" I[인덱스 스캔] --> ID["대상 ID 목록
(테이블 접근 없음)"] end subgraph "2단계: 실제 조회" ID --> Q["WHERE id IN (...)"] --> R["엔티티 + fetch join"] end style I fill:#9f9,stroke:#333

1단계에서 인덱스만으로 대상 ID를 빠르게 추려내고, 2단계에서 소수의 ID로 실제 데이터를 가져온다. offset이 커도 인덱스 스캔만 하므로 성능이 크게 개선된다.

언제 커버링 인덱스를 고려할까

- offset 페이징에서 offset 값이 크게 증가하는 경우

- 정렬 + 필터링 + 페이징이 함께 필요한 경우

- 테이블 컬럼 수가 많아서 풀 스캔이 비싼 경우

단, 커서 기반 페이지네이션으로 전환하면 offset 자체가 없어지므로, 근본적인 해결책은 커서 기반 페이지네이션이다.

count 쿼리 분리

페이지네이션에서 전체 개수를 함께 반환해야 할 때, 조회 쿼리와 count 쿼리를 분리하는 것이 효율적이다.

public Page<Feed> searchPage(FeedSearchCondition condition, Pageable pageable) {
    List<Feed> content = queryFactory
            .selectFrom(feed)
            .where(
                    titleContains(condition.title()),
                    regionEq(condition.region())
            )
            .orderBy(feed.createdAt.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    JPAQuery<Long> countQuery = queryFactory
            .select(feed.count())
            .from(feed)
            .where(
                    titleContains(condition.title()),
                    regionEq(condition.region())
            );

    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
graph TD PEU["PageableExecutionUtils.getPage()"] --> C1{데이터 < pageSize?} C1 -- Yes --> SKIP["count 쿼리 실행 안 함"] C1 -- No --> EXEC["count 쿼리 실행"] style SKIP fill:#9f9,stroke:#333

PageableExecutionUtils.getPage()는 Spring Data가 제공하는 유틸리티다. count 쿼리를 Supplier로 받아서, 실제로 필요할 때만 실행한다. 첫 페이지에서 데이터가 page size보다 적으면 count 쿼리를 아예 실행하지 않는다.

count 쿼리에는 join이나 orderBy가 필요 없으므로, 조회 쿼리보다 단순하게 작성할 수 있다. 이것이 조회와 count를 분리하는 이유다.

자주 하는 실수

fetch join과 페이징 동시 사용

@OneToMany 컬렉션을 fetch join하면서 offset/limit을 걸면, 하이버네이트가 "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory"라는 경고와 함께 메모리에서 페이징한다. 전체 데이터를 메모리에 올린 뒤 잘라내므로, 데이터가 많으면 OOM이 발생한다.

[!DANGER] 벌크 연산 후 clear 생략

벌크 UPDATE/DELETE 후 entityManager.clear()를 호출하지 않으면, 같은 트랜잭션에서 해당 엔티티를 조회할 때 수정 전의 캐시된 값을 반환한다. 특히 벌크 연산 직후에 검증 로직이 있는 경우 치명적이다.

[!DANGER] count 쿼리에 불필요한 join 포함

count 쿼리의 목적은 행 수를 세는 것이다. 조회 쿼리에서 fetch join이나 정렬을 위해 사용한 join을 count 쿼리에 그대로 복사하면, 불필요한 조인 비용이 추가된다. count 쿼리는 where 조건만 유지하고, join과 orderBy는 제거하는 것이 맞다.