블로그로 돌아가기
Engineering

QueryDSL 동적 쿼리 최적화 실전기 — 매칭 후보 검색과 N+1 해결

썸블링 매칭 시스템에서 QueryDSL을 도입하여 동적 필터링 쿼리를 최적화하고, BooleanBuilder vs BooleanExpression 비교, fetchJoin N+1 해결, Native Query 조합까지 실전 경험을 공유합니다.

Team Somebling ·
#QueryDSL #JPA #N+1 #쿼리 최적화 #Spring Boot

왜 QueryDSL이 필요했는가

썸블링의 매칭 시스템은 매일 새벽 배치를 통해 활성 회원 전체에 대해 매칭 후보를 계산합니다. 초기에는 JPA의 @Query와 JPQL로 필요한 쿼리를 구현했습니다. 그런데 매칭 선호도 필터링이 추가되면서 문제가 시작됐습니다.

사용자는 선호 성별, 나이 범위를 설정할 수 있고, 차단 목록, 기존 매칭 이력 제외, 소울 테스트 완료 여부 등 다양한 조건이 조합됩니다. 이 조건들이 **모두 선택적(optional)**입니다. 예를 들어 선호 성별을 설정한 사람도 있고 “모두”로 설정한 사람도 있습니다.

JPQL로 이것을 처리하려면 조건 조합마다 메서드를 따로 만들거나, 런타임에 문자열을 조합해야 합니다. 문자열 조합은 타입 안전성이 없고 컴파일 타임에 오류를 잡을 수 없습니다. 메서드를 조합별로 만들면 경우의 수가 폭발적으로 늘어납니다.

QueryDSL은 이 문제를 해결하는 가장 우아한 도구입니다.


Gradle 설정과 Q클래스 생성

aro-matching 모듈의 build.gradle.kts에 QueryDSL 의존성과 Q클래스 생성 설정을 추가합니다.

// aro-matching/build.gradle.kts
plugins {
    kotlin("kapt")
}

dependencies {
    implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
    kapt("com.querydsl:querydsl-apt:5.1.0:jakarta")
    kapt("jakarta.annotation:jakarta.annotation-api")
    kapt("jakarta.persistence:jakarta.persistence-api")
}

// Q클래스 생성 위치 지정
kotlin {
    sourceSets.main {
        kotlin.srcDir("build/generated/source/kapt/main")
    }
}

빌드 후 build/generated/source/kapt/main 하위에 엔티티마다 QMatchingProfile, QDailyMatch, QMember 등의 Q클래스가 생성됩니다. 이 클래스들이 타입 안전한 쿼리의 기반입니다.

QueryDSL을 사용하는 리포지토리 구현체에는 JPAQueryFactory를 주입합니다.

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    /**
     * JPAQueryFactory 빈을 등록합니다.
     * EntityManager를 주입받아 QueryDSL 쿼리 실행을 지원합니다.
     *
     * @author Rojae
     */
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

BooleanBuilder vs BooleanExpression — 무엇을 써야 하는가

QueryDSL에서 동적 조건을 조합하는 방법은 두 가지입니다. 실제 프로젝트에서 두 방식을 모두 써보고 장단점을 비교했습니다.

BooleanBuilder 방식

// 방식 1: BooleanBuilder
public List<MatchingProfile> findCandidates(MatchCandidateFilter filter) {
    QMatchingProfile qProfile = QMatchingProfile.matchingProfile;
    QMember qMember = QMember.member;

    BooleanBuilder builder = new BooleanBuilder();

    // 필수 조건: 활성 프로필, 본인 제외
    builder.and(qProfile.isActive.isTrue());
    builder.and(qProfile.member.id.ne(filter.getMemberId()));

    // 선택적 조건: 성별 필터
    if (filter.getPreferredGender() != null &&
        filter.getPreferredGender() != Gender.ANY) {
        builder.and(qMember.gender.eq(filter.getPreferredGender()));
    }

    // 선택적 조건: 나이 범위
    if (filter.getMinAge() != null) {
        LocalDate maxBirthDate = LocalDate.now().minusYears(filter.getMinAge());
        builder.and(qMember.birthDate.loe(maxBirthDate));
    }
    if (filter.getMaxAge() != null) {
        LocalDate minBirthDate = LocalDate.now().minusYears(filter.getMaxAge() + 1);
        builder.and(qMember.birthDate.goe(minBirthDate));
    }

    // 선택적 조건: 차단 목록 제외
    if (!filter.getBlockedIds().isEmpty()) {
        builder.and(qProfile.member.id.notIn(filter.getBlockedIds()));
    }

    return queryFactory
        .selectFrom(qProfile)
        .join(qProfile.member, qMember).fetchJoin()
        .where(builder)
        .fetch();
}

BooleanBuilder의 단점: 조건이 많아질수록 메서드가 길어지고, 각 조건의 의미가 흐릿해집니다. 또한 builder에 아무 조건도 추가되지 않으면 WHERE 절 없이 전체 테이블을 스캔합니다. 이것은 실수를 유발하는 함정입니다.

BooleanExpression 방식 (권장)

// 방식 2: BooleanExpression (썸블링 최종 선택)
@Repository
@RequiredArgsConstructor
public class MatchingProfileRepositoryImpl implements MatchingProfileRepositoryCustom {

    private final JPAQueryFactory queryFactory;
    private static final QMatchingProfile qProfile = QMatchingProfile.matchingProfile;
    private static final QMember qMember = QMember.member;

    /**
     * 매칭 후보를 동적 조건으로 조회합니다.
     * 각 조건은 null이면 자동으로 무시됩니다 (QueryDSL null 안전 처리).
     *
     * @param filter 매칭 필터 조건
     * @return 조건에 맞는 매칭 프로필 목록
     * @author Rojae
     */
    @Override
    public List<MatchingProfile> findCandidates(MatchCandidateFilter filter) {
        return queryFactory
            .selectFrom(qProfile)
            .join(qProfile.member, qMember).fetchJoin()
            .where(
                isActive(),
                notSelf(filter.getMemberId()),
                genderMatches(filter.getPreferredGender()),
                ageInRange(filter.getMinAge(), filter.getMaxAge()),
                notBlocked(filter.getBlockedIds()),
                notAlreadyMatched(filter.getMemberId(), filter.getExcludeMatchedIds()),
                hasSoulTestResult()
            )
            .fetch();
    }

    private BooleanExpression isActive() {
        return qProfile.isActive.isTrue();
    }

    private BooleanExpression notSelf(Long memberId) {
        return qProfile.member.id.ne(memberId);
    }

    private BooleanExpression genderMatches(Gender preferredGender) {
        // null이나 ANY이면 조건을 추가하지 않음 (QueryDSL null 반환 = WHERE절 제외)
        if (preferredGender == null || preferredGender == Gender.ANY) {
            return null;
        }
        return qMember.gender.eq(preferredGender);
    }

    private BooleanExpression ageInRange(Integer minAge, Integer maxAge) {
        BooleanExpression minCond = null;
        BooleanExpression maxCond = null;

        if (minAge != null) {
            LocalDate maxBirthDate = LocalDate.now().minusYears(minAge);
            minCond = qMember.birthDate.loe(maxBirthDate);
        }
        if (maxAge != null) {
            LocalDate minBirthDate = LocalDate.now().minusYears(maxAge + 1L);
            maxCond = qMember.birthDate.goe(minBirthDate);
        }

        if (minCond != null && maxCond != null) return minCond.and(maxCond);
        if (minCond != null) return minCond;
        if (maxCond != null) return maxCond;
        return null;
    }

    private BooleanExpression notBlocked(List<Long> blockedIds) {
        if (blockedIds == null || blockedIds.isEmpty()) return null;
        return qProfile.member.id.notIn(blockedIds);
    }

    private BooleanExpression notAlreadyMatched(Long memberId, List<Long> excludeIds) {
        if (excludeIds == null || excludeIds.isEmpty()) return null;
        return qProfile.member.id.notIn(excludeIds);
    }

    private BooleanExpression hasSoulTestResult() {
        // 소울 테스트 결과가 있는 회원만 매칭 후보에 포함
        QSoulTestResult qResult = QSoulTestResult.soulTestResult;
        return JPAExpressions
            .selectOne()
            .from(qResult)
            .where(qResult.member.id.eq(qProfile.member.id))
            .exists();
    }
}

BooleanExpression 방식의 장점:


N+1 문제 — fetchJoin으로 해결

QueryDSL을 도입하기 전, 매칭 히스토리 조회 API에서 심각한 N+1 문제가 발견됐습니다. DailyMatch 엔티티를 조회하면 member, matchedMember가 지연 로딩(LAZY)으로 설정되어 있어, 10건을 조회하면 21개의 쿼리가 실행됐습니다.

flowchart LR
    subgraph Before["개선 전: N+1 문제"]
        Q1["SELECT daily_matches<br/>WHERE member_id = ?"] --> R1["결과 20건"]
        R1 --> Q2["SELECT members<br/>WHERE id = ?<br/>× 20번"]
        R1 --> Q3["SELECT members<br/>WHERE id = ?<br/>× 20번"]
        Q2 --> T1["총 41 쿼리<br/>380ms"]
    end

    subgraph After["개선 후: fetchJoin"]
        Q4["SELECT dm<br/>JOIN FETCH member<br/>JOIN FETCH matchedMember"] --> T2["총 2 쿼리<br/>28ms"]
    end

    style T1 fill:#FF6B9D,stroke:#FF6B9D,color:#fff
    style T2 fill:#7F5AF0,stroke:#7F5AF0,color:#fff
SELECT * FROM daily_matches WHERE member_id = ? LIMIT 20;
-- 위 쿼리 1번 + 아래 쿼리 N번 (N = 결과 개수)
SELECT * FROM members WHERE id = ?;  -- member 로딩
SELECT * FROM members WHERE id = ?;  -- matchedMember 로딩
-- ... 20건이면 41번 쿼리

fetchJoin으로 단일 쿼리 처리

/**
 * 매칭 히스토리를 페이징 조회합니다.
 * fetchJoin으로 member, matchedMember를 단일 쿼리로 로딩합니다.
 *
 * @param memberId 조회 대상 회원 ID
 * @param pageable 페이징 정보
 * @return 페이징된 매칭 히스토리
 * @author Rojae
 */
@Override
public Page<DailyMatch> findMatchHistory(Long memberId, Pageable pageable) {
    QDailyMatch qMatch = QDailyMatch.dailyMatch;
    QMember qMember = QMember.member;
    QMember qMatchedMember = new QMember("matchedMember");  // alias 필수

    List<DailyMatch> content = queryFactory
        .selectFrom(qMatch)
        .join(qMatch.member, qMember).fetchJoin()
        .join(qMatch.matchedMember, qMatchedMember).fetchJoin()
        .where(
            qMatch.member.id.eq(memberId),
            qMatch.status.in(
                DailyMatchStatus.ACCEPTED,
                DailyMatchStatus.REJECTED,
                DailyMatchStatus.EXPIRED
            )
        )
        .orderBy(qMatch.matchDate.desc(), qMatch.id.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    // count 쿼리 분리 (fetchJoin 없이)
    Long total = queryFactory
        .select(qMatch.count())
        .from(qMatch)
        .where(qMatch.member.id.eq(memberId))
        .fetchOne();

    return new PageImpl<>(content, pageable, total != null ? total : 0);
}

핵심 주의사항: fetchJoin()Pageable을 함께 사용할 때, 카운트 쿼리와 컨텐츠 쿼리를 반드시 분리해야 합니다. 하나의 쿼리로 합치면 Hibernate가 메모리에서 페이징을 처리하는 HHH90003004 경고가 발생하고, 대규모 데이터에서 OOM을 유발할 수 있습니다.

fetchJoin 사용 시 발생하는 MultipleBagFetchException 해결

MemberDailyMatch + MatchPreference를 한 번에 fetchJoin하면 컬렉션이 2개 이상일 때 예외가 발생합니다.

org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags

해결 방법은 두 가지입니다.

방법 1: @BatchSize로 지연 로딩을 배치로 처리

@Entity
public class Member {
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    @BatchSize(size = 100)  // IN 절로 배치 로딩
    private List<DailyMatch> dailyMatches;
}

방법 2: 쿼리를 분리하여 각각 fetchJoin

// 1차 쿼리: Member + MatchPreference
List<Member> members = queryFactory
    .selectFrom(qMember)
    .join(qMember.matchPreference, qPreference).fetchJoin()
    .where(qMember.id.in(memberIds))
    .fetch();

// 2차 쿼리: DailyMatch (별도 조회)
List<DailyMatch> matches = queryFactory
    .selectFrom(qMatch)
    .where(qMatch.member.id.in(memberIds))
    .fetch();

썸블링에서는 성능 예측이 쉬운 방법 2를 선택했습니다.


매칭 히스토리 페이징 — QueryDSL + Slice

무한 스크롤 UI를 지원하기 위해 Page 대신 Slice를 사용했습니다. Slice는 count 쿼리를 실행하지 않아 페이징 성능이 훨씬 좋습니다.

/**
 * 커서 기반 매칭 히스토리 조회 (무한 스크롤용).
 * count 쿼리 없이 다음 페이지 존재 여부만 확인합니다.
 *
 * @param memberId 회원 ID
 * @param lastMatchId 마지막으로 조회한 매칭 ID (커서), null이면 첫 페이지
 * @param size 페이지 크기
 * @return Slice 형태의 결과
 * @author Rojae
 */
@Override
public Slice<DailyMatch> findMatchHistorySlice(
    Long memberId,
    Long lastMatchId,
    int size
) {
    QDailyMatch qMatch = QDailyMatch.dailyMatch;
    QMember qMatchedMember = new QMember("matchedMember");

    List<DailyMatch> content = queryFactory
        .selectFrom(qMatch)
        .join(qMatch.matchedMember, qMatchedMember).fetchJoin()
        .where(
            qMatch.member.id.eq(memberId),
            lastMatchId != null ? qMatch.id.lt(lastMatchId) : null  // 커서 조건
        )
        .orderBy(qMatch.id.desc())
        .limit(size + 1)  // 다음 페이지 존재 여부 확인을 위해 1개 더 조회
        .fetch();

    boolean hasNext = content.size() > size;
    if (hasNext) {
        content.remove(content.size() - 1);  // 초과분 제거
    }

    return new SliceImpl<>(content, PageRequest.of(0, size), hasNext);
}

커서 기반 페이징은 OFFSET 기반 페이징에 비해 깊은 페이지에서의 성능이 훨씬 좋습니다. OFFSET이 N이면 데이터베이스는 N개 행을 읽고 버리지만, 커서 기반은 WHERE id < cursor로 바로 필요한 위치부터 읽기 시작합니다.


코사인 유사도 Native Query와의 조합

매칭 후보 선정의 최종 단계는 코사인 유사도 계산입니다. QueryDSL은 JSONB 배열에 대한 벡터 연산을 표현할 수 없기 때문에, 이 부분은 Native Query로 처리하고 QueryDSL은 사전 필터링에만 사용합니다.

[전체 회원]
    ↓ QueryDSL 필터링
[성별/나이/차단/소울테스트 완료 조건 통과한 후보 N명]
    ↓ Native Query 코사인 유사도 계산
[Soul Score 내림차순 Top 3 선택]
    ↓ DailyMatch 저장
[오늘의 매칭 완료]
flowchart TD
    A["전체 회원 N명"] -->|"QueryDSL 필터링<br/>(성별·나이·차단·소울테스트)"| B["후보 수백 명"]
    B -->|"Native Query<br/>코사인 유사도 계산"| C["Soul Score Top 3"]
    C -->|"DailyMatch<br/>INSERT"| D["오늘의 매칭 완료"]

    style A fill:#232136,stroke:#94A1B2,color:#FFFFFE
    style B fill:#232136,stroke:#7F5AF0,color:#FFFFFE
    style C fill:#7F5AF0,stroke:#7F5AF0,color:#fff
    style D fill:#FF6B9D,stroke:#FF6B9D,color:#fff
/**
 * QueryDSL 필터링 → Native Query 유사도 계산 파이프라인
 *
 * @param memberId 매칭 대상 회원 ID
 * @param filter   필터 조건
 * @return Soul Score 상위 3명의 매칭 후보
 * @author Rojae
 */
public List<MatchCandidate> findTopCandidates(Long memberId, MatchCandidateFilter filter) {
    // Step 1: QueryDSL로 후보군 필터링 (수천 명 → 수백 명)
    List<Long> candidateIds = matchingProfileRepository
        .findCandidateIds(filter);

    if (candidateIds.isEmpty()) {
        return Collections.emptyList();
    }

    // Step 2: Native Query로 코사인 유사도 Top 3 추출
    return matchingProfileRepository
        .findTopByCosineSimilarity(memberId, candidateIds, 3);
}

두 단계를 분리하면 각각 최적화된 기술을 사용할 수 있습니다. QueryDSL은 복잡한 동적 조건 필터링에, Native Query는 벡터 연산에 최적화됩니다.


벌크 만료 처리 — Native Query

매일 새벽 배치에서 전날 PENDING 상태의 매칭을 EXPIRED로 일괄 처리합니다. JPA의 saveAll()로 개별 UPDATE를 날리면 건수만큼 쿼리가 발생합니다. 이것을 Native Query 벌크 UPDATE 한 방으로 처리합니다.

/**
 * 전날 이전의 PENDING 상태 매칭을 일괄 EXPIRED 처리합니다.
 * 벌크 UPDATE로 N번 쿼리를 1번으로 줄입니다.
 *
 * SQL: UPDATE daily_matches SET status = 'EXPIRED', updated_at = NOW()
 *      WHERE status = 'PENDING' AND match_date < CURRENT_DATE
 *
 * @return 처리된 건수
 * @author Rojae
 */
@Modifying
@Query(
    value = """
        UPDATE daily_matches
        SET status = 'EXPIRED', updated_at = NOW()
        WHERE status = 'PENDING'
          AND match_date < CURRENT_DATE
        """,
    nativeQuery = true
)
int bulkExpirePendingMatches();

@Modifying을 붙이지 않으면 InvalidDataAccessApiUsageException이 발생합니다. 또한 벌크 연산 후에는 영속성 컨텍스트를 초기화해야 이후 조회 결과가 오염되지 않습니다.

@Modifying(clearAutomatically = true, flushAutomatically = true)

clearAutomatically = true 옵션으로 벌크 UPDATE 후 자동으로 1차 캐시를 클리어합니다.


성능 개선 결과

QueryDSL 도입과 N+1 최적화 적용 후 실측 결과입니다.

항목개선 전개선 후개선율
매칭 히스토리 조회 (20건)41 쿼리, 380ms2 쿼리, 28ms93% 단축
매칭 후보 필터링동적 쿼리 없음1 쿼리, 조건별 최적화-
일일 만료 처리 (10,000건)10,000 UPDATE1 벌크 UPDATE99% 단축
빌드 타임 타입 안전성없음 (문자열)Q클래스 컴파일 타임 검증-

QueryDSL은 초기 설정 비용(Gradle 설정, Q클래스 생성, JPAQueryFactory 빈 설정)이 있지만, 이후 동적 쿼리 작성의 안전성과 N+1 해결의 편의성이 그 비용을 훨씬 상회합니다. 썸블링처럼 필터 조건이 복잡하고 자주 변경되는 도메인에서는 초기부터 QueryDSL을 도입하는 것을 강력히 권장합니다.


마치며

QueryDSL을 도입하면서 가장 크게 얻은 것은 “쿼리에 대한 자신감”입니다. JPQL 문자열이 런타임에 깨지는 것을 두려워하지 않아도 됩니다. 컬럼명이나 엔티티 필드명이 바뀌면 Q클래스가 컴파일 오류를 발생시켜 즉시 알려줍니다.

다음 단계로는 인덱스 전략을 QueryDSL과 연계하여 최적화하고, 매칭 알고리즘의 후보 필터링 단계를 더 정교하게 다듬는 작업을 진행할 예정입니다. OCEAN 점수 범위로 후보를 1차 컷팅하는 방식도 검토 중입니다.

댓글

GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.

블로그로 돌아가기